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、异步加载等。

小结

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

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

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

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

相关推荐
盛夏绽放39 分钟前
jQuery 知识点复习总览
前端·javascript·jquery
胡gh3 小时前
依旧性能优化,如何在浅比较上做文章,memo 满天飞,谁在裸奔?
前端·react.js·面试
大怪v3 小时前
超赞👍!优秀前端佬的电子布洛芬技术网站!
前端·javascript·vue.js
胡gh3 小时前
你一般用哪些状态管理库?别担心,Zustand和Redux就能说个10分钟
前端·面试·node.js
项目題供诗3 小时前
React学习(十二)
javascript·学习·react.js
无羡仙4 小时前
Webpack 背后做了什么?
javascript·webpack
roamingcode5 小时前
Claude Code NPM 包发布命令
前端·npm·node.js·claude·自定义指令·claude code
码哥DFS5 小时前
NPM模块化总结
前端·javascript
唐璜Taro5 小时前
electron进程间通信-IPC通信注册机制
前端·javascript·electron