每个系列一本前端好书,帮你轻松学重点。
本系列来自曾供职于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中主流的模块化包括:ESM 和 CommonJS。
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、异步加载等。
小结
代码整洁,是一项产品无关、业务无关,甚至技术无关的事,极易被忽视。
但如果你有一定的编程经验,它又是很需要、难做好的事,当我们掌握了模块化的方法和技巧,就应该在编码时对自己提高要求,为整个项目的质量提升出一份力。
本篇文章,是本系列的倒数第二篇文章,下一篇,会带来什么内容作为收尾呢?
更多好文第一时间接收,可关注公众号:"前端说书匠"