JavaScript高级程序设计(第5版):代码整洁之道

每个系列一本前端好书,帮你轻松学重点。

本系列来自曾供职于Google的知名前端技术专家马特·弗里斯比 编写的 《JavaScript高级程序设计》(第5版)

前文提到几种编程范式,其一是"面向过程",它的特点:将复杂事务分解为简单事务

听起来富有哲理,但世界上不存在万能范式,每个单一范式在放任自流的情况下都会暴露其弊端。

"面向过程"的弊端就是太零散,看不清代码之间的关系,同时逻辑难以复用,你只知道整个文件在干什么,无法轻易理解各部分是如何协同工作的。

这就涉及到"角色"与"分工",只有角色清晰、分工明确,你才不怕去"阅读"和"修改"。

别心存侥幸,每个项目都注定从简单走向复杂,且速度惊人,要提高可读性和维护性,就需要控制代码的颗粒度,以及为它们找到合适的归属。

数据封装

数据封装,是为变量和行为找到归属。

看一段代码:

JavaScript 复制代码
// 定义变量
let studentName = "张三";
let studentAge = 20;
let studentGrade = "大二";
let studentScores = [85, 90, 78];

// 操作变量的函数
function printStudentInfo() {
  console.log(`姓名: ${studentName}, 年龄: ${studentAge}, 年级: ${studentGrade}`);
}

function calculateAverageScore() {
  let sum = studentScores.reduce((a, b) => a + b, 0);
  return sum / studentScores.length;
}

// 使用变量和函数
printStudentInfo();
console.log(`平均分: ${calculateAverageScore()}`);

这段代码有什么问题吗?没有问题,甚至"新手友好",但就像是最简单的日记形式---流水账。

它有几宗罪:

1、变量和方法散落在全局作用域中,缺乏组织

2、每个变量名都需要添加student前缀,以避免产生冲突

3、数据和操作数据的方法完全独立,没有关联

4、只能处理单个student,无法处理多个

总结:可用,但不够好。

做个调整:

JavaScript 复制代码
// 使用对象封装学生信息及相关操作
const student = {
  name: "张三",
  age: 20,
  grade: "大二",
  scores: [85, 90, 78],
  
  printInfo() {
    console.log(`姓名: ${this.name}, 年龄: ${this.age}, 年级: ${this.grade}`);
  },
  
  calculateAverageScore() {
    const sum = this.scores.reduce((a, b) => a + b, 0);
    return sum / this.scores.length;
  }
};

// 使用对象的方法
student.printInfo();
console.log(`平均分: ${student.calculateAverageScore()}`);

感觉好多了?所有的变量和方法都归属于student对象,有组织,有关联,要再创建一个也更简单。

但似乎还不完美,当需要创建多个student的时候,要一个个写,依然不方便。

再来调整:

JavaScript 复制代码
// 将student定义为一个类
class Student {
  constructor(name, age, grade, scores) {
    this.name = name;
    this.age = age;
    this.grade = grade;
    this.scores = scores;
  }
  
  printInfo() {
    console.log(`姓名: ${this.name}, 年龄: ${this.age}, 年级: ${this.grade}`);
  }
  
  calculateAverageScore() {
    const sum = this.scores.reduce((a, b) => a + b, 0);
    return sum / this.scores.length;
  }
}

// 创建学生实例
const student1 = new Student("张三", 20, "大二", [85, 90, 78]);
const student2 = new Student("李四", 21, "大三", [92, 88, 95]);

student1.printInfo();
console.log(`平均分: ${student1.calculateAverageScore()}`);

这段代码,将原先的对象字面量改为一个名为Student的class。

class称为类,在很多语言中都有,JavaScript中直到ES6才正式引入,实际上它不是什么新魔法,背后使用的仍是原型和构造函数。

从封装的层面,它和对象类似,但从动态性和灵活性,它更强大,其中的属性都变量化,放到了类的构造函数中,需要创建一个新的student时,只要 new Student 即可,不论是创建1个,还是100个,都显得便捷又优雅。

这,就是"面向对象"编程的魅力。

除此之外,类还有哪些特性?

最出色的就是原生支持继承。

JavaScript 复制代码
class GoodStudent extends student {}

使用extends关键字实现继承,得来的类称为"派生类",可以通过原型链访问到类和原型上定义的方法。

JavaScript 复制代码
const student3 = new GoodStudent("王五", 21, "大三", [92, 88, 95]);
console.log(`平均分: ${student3.calculateAverageScore()}`);

GoodStudent中并未定义calculateAverageScore,但仍可调用,因为原型上有此方法。

"封装"和"继承"已经看了,"多态"是什么呢?

简单理解,就是多种形态,从父类继承共性,同时可以具备自有的个性

主要体现在对父类方法的重写和自有方法的定义。

JavaScript 复制代码
class GoodStudent extends student {
 constructor(name, age, grade, scores) {
     this.name = name;
     this.age = age;
     this.grade = grade;
     this.scores = scores;
   }
   
   // 父类方法重写
   printInfo() {
     console.log(`我是一名好学生,我的信息如下,姓名: ${this.name}, 年龄: ${this.age}, 年级: ${this.grade}`);
   }
 
   // 自有方法定义
   getGift(){
     console.log('好学生可以获得礼物哦!')
   }
}

如此,在创建GoodStudent实例的时候,它不仅具备一般student的属性和方法,还有自己的专有方法和输出内容。

现在,是不是觉得第一种写法有点"憨"?

行为封装

上面我们聊数据封装,其实已经涉及行为封装,就是类中的"方法"。

什么是"行为",就是要做的事。

为什么"封装"?行为有如下特点:

1、需要处理数据

2、有相关的局部变量

3、有若干行代码

4、返回结果,或者对外部数据产生影响

多数时候,行为都不是一行代码能完成的(即便起初这样认为),所以行为的封装通常会用到"函数"。

JavaScript 复制代码
calculateAverageScore() {
    const sum = this.scores.reduce((a, b) => a + b, 0);
    return sum / this.scores.length;
}

函数被定义后,有诸多好处,如:随时可以为其增加拓展性,或调整处理过程。

跟函数正式见个面:

JavaScript 复制代码
function sum(){
 return 1 + 2;
}

这就完成了一个简单函数的定义,它的行为是,计算 1 + 2,结果是3。

显然,能力很有限,干不了别的,所以,函数可以传参。

JavaScript 复制代码
function sum(num1,num2){
 return num1 + num2;
}

接收了参数,就代表无限可能,可以应对外部的动态变化。

但貌似还不够强大,只能传两个参数,如果参数个数不确定呢?当然可以,扩展操作符(...)来帮你。

JavaScript 复制代码
let values = [1, 2, 3, 4, 5];
function getSum() {
  let sum = 0;
  for (let i = 0; i < arguments.length; i++) {
    sum += arguments[i];
  }
  return sum;
}
let sum = getSum(...values); // 15

现在,你想传多少就传多少。

那么如果,有时候想传参,有时候不想传,没问题,可以设置默认参数:

JavaScript 复制代码
function getSum(num1 = 1, num2 = 2) {
  return num1 + num2;
}

不传参的时候,就是 1 + 2,传了按照实参处理。

这个特性很实用,常用来设置"类型"或者"真假",有需求的时候传参,没有就按默认的来。

闭包

顺便介绍一个跟函数紧密相关的概念---"闭包",它有用途,也有"坑",是面试中的常见问题。

什么是"闭包"?指的是那些引用了另一个函数作用域中变量的函数,通常在嵌套函数中出现。

JavaScript 复制代码
function createComparisonFunction(propertyName) {
  return function (object1, object2) {
    let value1 = object1[propertyName];
    let value2 = object2[propertyName];
    if (value1 < value2) {
      return -1;
    } else if (value1 > value2) {
      return 1;
    } else {
      return 0;
    }
  };
}

说重点:

  • 函数内部又返回一个匿名函数
  • 匿名函数引用了一个外部变量propertyName
  • 当这个函数在其他地方被使用,它仍然引用着这个变量

这会导致一个现象,有时候你觉得值应该被更新,实际没有更新。

这里的关键就是"作用域链"。

函数执行时,每个执行上下文中都会有一个包含其变量的对象。全局上下文中的叫变量对象 ,它在代码执行期间始终存在,函数局部上下文中的叫活动对象,只在函数执行期间存在。

一般来说,函数执行完毕后,局部活动对象会被销毁,内存中只剩下全局作用域。

闭包的差异就在这,当createComparisonFunction方法返回匿名函数时,它的作用域链包含活动对象和全局变量对象,并且在执行完毕后仍保留对它的引用,不会销毁,这就是"闭包"现象。

我们似乎很少主动创建闭包,那么什么时候会无意中产生闭包?

  • 函数作为参数传递
  • 在循环中创建函数
  • 事件处理函数
  • 模块导出函数
  • 回调函数
  • 函数柯里化

如果你刚好用到它的特性,会带来便利,但如果确实无意间触发,它可能造成多余的性能开销或者内存泄漏,需谨慎。

个体封装

当我们把数据和行为做了封装,编码已经从原始状态往前进了一大步,可以高枕无忧了吗?

如果代码在200行内,确实是这样,但实际上,一个文件的代码达到2000行,甚至上万行,都是常见现象。

这个时候,量变产生质变,前述所有优点几乎荡然无存,维护代码只剩下负担。

所以,代码在被封装之后,还需要在恰当的时机继续拆分,而拆分最常见的形式就是"模块化"。

当下,JavaScript中主流的模块化包括:ESMCommonJS。

CommonJS

JavaScript 复制代码
// 导出
module.exports = { ... };
// 或
exports.foo = bar;

// 导入
const module = require('./module');

ESM

JavaScript 复制代码
// 导出
export const foo = 'bar';
export default function() { ... };

// 导入
import { foo } from './module.js';
import defaultFunc from './module.js';

你应该很眼熟,这就是日常项目中代码的样子,可能一些刚入行的朋友不知道,这么便利的书写方式其实来之不易,是技术专家们多年努力的结果。

由此衍生出的,现代工程的目录结构也就明晰了:

components:组件文件

utils:工具方法文件

api:接口请求文件

store:状态管理文件

config:配置文件

每类文件都做着不同的事,但当你深入查看,它们无一例外都做了模块化拆分。

那么两种模块化机制有什么区别呢?这也是高频面试考点。

CommonJS:

  • 文件名 .cjs 或 .js
  • 主要用在服务器端,不能在浏览器直接运行
  • 动态加载(运行时解析)
  • 同步加载
  • 导出值的拷贝

ESM:

  • 文件名 .mjs 或在 package.json 中设置 "type": "module"
  • 浏览器和Nodejs环境都支持
  • 静态加载(编译时解析)
  • 异步加载
  • 导出值的引用

可以看出,ESM的应用面更广,其中一些特性为现代化、高要求的开发提供了土壤,如可以进行Tree shaking、异步加载等。

小结

代码整洁,是一项产品无关、业务无关,甚至技术无关的事,极易被忽视。

但如果你有一定的编程经验,它又是很需要、难做好的事,当我们掌握了模块化的方法和技巧,就应该在编码时对自己提高要求,为整个项目的质量提升出一份力。

本篇文章,是本系列的倒数第二篇文章,下一篇,会带来什么内容作为收尾呢?

更多好文第一时间接收,可关注公众号:"前端说书匠"

相关推荐
kingwebo'sZone4 分钟前
C#使用Aspose.Words把 word转成图片
前端·c#·word
xjt_090123 分钟前
基于 Vue 3 构建企业级 Web Components 组件库
前端·javascript·vue.js
我是伪码农35 分钟前
Vue 2.3
前端·javascript·vue.js
夜郎king1 小时前
HTML5 SVG 实现日出日落动画与实时天气可视化
前端·html5·svg 日出日落
辰风沐阳1 小时前
JavaScript 的宏任务和微任务
javascript
夏幻灵2 小时前
HTML5里最常用的十大标签
前端·html·html5
冰暮流星2 小时前
javascript之二重循环练习
开发语言·javascript·数据库
Mr Xu_2 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js
未来龙皇小蓝2 小时前
RBAC前端架构-01:项目初始化
前端·架构
程序员agions2 小时前
2026年,微前端终于“死“了
前端·状态模式