1. 从“能用”到“好用”为什么ES6是JavaScript的分水岭如果你写过几年JavaScript尤其是经历过那个用var声明变量、用function定义类、用回调函数处理异步的“上古时代”你一定会对ES6ECMAScript 2015的发布记忆犹新。它不像一次普通的版本更新更像是一次语言层面的“工业革命”。很多人把ES6当作一系列新语法的集合来学习比如箭头函数、解构赋值这当然没错但如果你只停留在这个层面就错过了它最核心的价值。在我看来ES6最大的贡献是为JavaScript的大规模工程化开发提供了坚实的语言基础。在ES5时代我们写一个稍微复杂点的应用就需要借助一大堆工具和库来弥补语言本身的不足用RequireJS或Browserify解决模块化用各种“类库”模拟类的继承用Promise的polyfill处理异步流程。代码写起来冗长团队协作时风格也难以统一。ES6的出现第一次让JavaScript在语言层面具备了现代编程语言应有的表达能力、组织能力和可靠性。所以当你学习ES6时千万别把它当成一堆孤立的语法糖。它的每一项特性都是为了解决ES5时代某个具体的工程痛点而设计的。理解了“为什么需要它”你才能更好地掌握“怎么用它”。这一部分我们将深入几个最能体现ES6工程化思想的核心特性模块化、类与继承、以及迭代器与生成器。这些特性共同构建了现代JavaScript应用的骨架。2. 告别“脚本标签地狱”ES6模块化如何重塑前端工程在ES6之前前端代码的依赖管理堪称一场噩梦。我们通常通过script标签的顺序来手动管理依赖全局变量污染严重依赖关系隐晦且容易出错。社区涌现了AMD如RequireJS和CommonJSNode.js采用等模块规范但它们并非语言原生支持需要额外的加载器或打包工具。ES6模块化ES Modules是语言级别的模块解决方案。它用import和export这两个关键字清晰、静态地声明了模块的输入和输出。2.1 核心语法导出与导入的多种姿势一个模块可以通过export导出任何声明变量、函数、类。导出的方式主要分为两种命名导出Named Exports和默认导出Default Export。命名导出适用于一个模块需要提供多个功能的情况。它强调“哪些东西可以被外部使用”。// mathUtils.js // 方式一在声明前直接导出 export const PI 3.14159; export function sum(a, b) { return a b; } export class Calculator { /* ... */ } // 方式二在文件末尾统一导出 const PI 3.14159; function sum(a, b) { return a b; } class Calculator { /* ... */ } export { PI, sum, Calculator }; // 推荐这种方式一目了然对应的导入需要使用相同的名称并用花括号包裹。// app.js import { PI, sum, Calculator } from ./mathUtils.js; console.log(PI); // 3.14159 console.log(sum(1, 2)); // 3这里有个非常实用的技巧你可以使用as关键字来重命名导入的变量这在处理不同模块导出同名变量时特别有用。import { sum as addTwoNumbers } from ./mathUtils.js; import { sum as calculateTotal } from ./businessLogic.js;默认导出则像一个模块的“主菜”一个模块只能有一个默认导出。它常用于模块只提供一个主要功能的情况比如一个类或者一个函数。// Logger.js export default class Logger { log(message) { console.log([INFO] ${message}); } } // 或者导出一个函数 // export default function(config) { /* ... */ }导入默认导出时不需要花括号并且可以任意命名。import MyLogger from ./Logger.js; // MyLogger这个名字可以随便取 const logger new MyLogger(); logger.log(Hello Module);注意在实际项目中我强烈建议优先使用命名导出。原因有三第一命名导出让模块的接口一目了然通过查看import语句就能知道用了哪些功能第二它支持编辑器更好的智能提示和代码跳转第三在重构时如果你重命名了一个导出所有导入它的地方都会报错这比默认导出导入时可以随意命名更安全。默认导出更适合那些确实“只有一个主要出口”的模块比如Vue的单文件组件.vue文件。2.2 静态分析与“编译时”优势ES6模块一个革命性的特点是静态化。这意味着模块的依赖关系在代码执行前编译时就确定了。import和export命令必须出现在模块的顶层作用域不能放在条件语句或函数内部。这个限制带来了巨大的好处Tree Shaking摇树优化打包工具如Webpack、Rollup可以静态分析你的代码准确地知道哪些导出被使用了哪些没有。在最终打包时那些未被引用的导出代码会被安全地删除极大减小了打包体积。提升加载效率由于依赖关系明确现代浏览器和Node.js可以并行加载多个模块而不是像过去一样串行执行script标签。更好的错误检查如果尝试导入一个不存在的导出在代码运行前工具就能报错。2.3 在浏览器与Node.js中的实践差异虽然语法是统一的但运行环境不同用法略有差异。在浏览器中你需要通过script typemodule标签来告诉浏览器这是一个ES模块。模块文件会自动启用严格模式并且拥有自己的作用域顶层的this是undefined。script typemodule import { sum } from ./mathUtils.js; console.log(sum(1, 2)); /script在Node.js中从Node.js v13.2.0开始可以通过两种方式使用ES模块。将文件扩展名改为.mjs。在package.json中设置type: module。此时导入本地模块时必须写完整的文件路径包括扩展名.js或.mjs这与CommonJS的require可以省略扩展名不同。// 在Node.js ES模块中必须写扩展名 import { something } from ./lib/utils.js; // 错误import { something } from ./lib/utils;踩坑实录混合使用ES模块和CommonJS模块是初期常见的痛点。在Node.js的ES模块中你不能直接使用require。如果你想导入一个CommonJS模块通常是npm安装的、未提供ES模块版本的包你可以直接使用import语句Node.js会帮你处理。但反过来在CommonJS模块.cjs文件或用require的.js文件中你不能使用import语句。因此在新启动的项目中我建议统一在package.json中设置type: module全面拥抱ES模块除非你有必须使用CommonJS的遗留依赖。3. 从“原型把戏”到“正经类”ES6 Class的实质与局限在ES6之前JavaScript实现面向对象编程OOP主要依靠构造函数和原型链。虽然功能上能模拟“类”但写法繁琐且反直觉尤其是继承的实现需要手动操作prototype和__proto__对新手极不友好。ES6的class语法提供了一种更清晰、更接近传统OOP语言的方式来定义类和创建对象。但必须清醒地认识到class只是一个语法糖它的底层依然是原型继承。理解这一点是用好class的关键。3.1 基本语法更优雅的构造函数我们先看一个简单的对比。ES5时代定义一个“类”function PersonES5(name) { this.name name; } PersonES5.prototype.sayHello function() { console.log(Hello, this.name); };用ES6的class重写class Person { constructor(name) { this.name name; // 实例属性 } // 实例方法实际上会被添加到 Person.prototype 上 sayHello() { console.log(Hello, ${this.name}); } // 静态方法属于类本身而非实例 static describe() { console.log(A class for creating person objects.); } } const alice new Person(Alice); alice.sayHello(); // Hello, Alice Person.describe(); // A class for creating person objects.可以看到class语法将构造函数constructor、实例方法、静态方法清晰地组织在了一起可读性大大提升。实例方法之间不需要逗号分隔这也是一个符合直觉的改进。3.2 继承extends与super的默契配合继承是OOP的核心也是ES5时代最令人头疼的部分。ES6用extends和super关键字极大地简化了它。class Student extends Person { constructor(name, grade) { super(name); // 必须首先调用父类的constructor! this.grade grade; } sayHello() { super.sayHello(); // 调用父类的方法 console.log(Im in grade ${this.grade}.); } study() { console.log(${this.name} is studying hard.); } } const bob new Student(Bob, 10); bob.sayHello(); // 输出: // Hello, Bob // Im in grade 10. bob.study(); // Bob is studying hard.这里有两个关键点super作为函数在子类的constructor中super(name)相当于Person.call(this, name)用于初始化父类定义的属性。它必须在访问this之前调用否则会报错。super作为对象在子类的实例方法如sayHello中super.sayHello()指向父类原型上的方法用于调用被覆盖的父类方法。这种写法几乎和Java、C#等语言一模一样极大地降低了理解和使用继承的门槛。3.3 穿透糖衣理解Class的实质与边界尽管class写起来像传统的类但我们必须时刻记住它底层是原型。通过Object.getPrototypeOf()和instanceof可以验证console.log(typeof Person); // function类本质上还是函数 console.log(Object.getPrototypeOf(alice) Person.prototype); // true console.log(alice instanceof Person); // true console.log(Object.getPrototypeOf(Student.prototype) Person.prototype); // true原型链class语法也有一些“局限”这些局限其实源于JavaScript原型继承的本质没有真正的“私有字段”ES2022之前在ES2022引入#私有字段语法之前类内部的所有属性理论上都是公开的。通常我们依靠命名约定如前置下划线_privateValue来提示“这是私有的别直接碰”。没有多重继承一个类只能extends一个父类。属性和方法的添加类体内部只能定义方法包括constructor和静态方法不能直接定义实例属性ES2022引入了类字段声明允许在类顶层定义实例属性。在ES6标准下实例属性必须在constructor中初始化。实操心得在实际项目中不要过度设计复杂的类继承层次。JavaScript是一门多范式的语言组合Composition往往比继承Inheritance更灵活、更易于维护。优先考虑用“类”来封装数据和相关操作用模块来组织多个相关的类。对于简单的数据结构直接使用对象字面量或Map/Set可能更合适。class语法是一个强大的工具但用它之前先问问自己这里真的需要“类”吗4. 掌控集合的遍历迭代器Iterator与生成器Generator遍历数据集合是编程中最常见的操作之一。在ES6之前遍历数组用for循环遍历对象用for...in但会遍历原型链上的可枚举属性通常需要配合hasOwnProperty检查。这些方式不够统一也不够灵活。ES6引入了迭代器Iterator和可迭代协议Iterable Protocol为各种数据结构提供了一种统一的遍历接口。而生成器Generator则是创建迭代器的超级语法糖。4.1 可迭代协议与for...of循环一个对象要成为可迭代的Iterable它必须实现iterator方法即拥有一个键为Symbol.iterator的属性。这个属性是一个函数调用后返回一个迭代器对象。迭代器对象必须有一个next()方法。每次调用next()返回一个包含value和done两个属性的对象。value当前迭代的值。done布尔值表示迭代是否已完成。ES6中的数组、字符串、Map、Set、NodeList等都内置实现了Symbol.iterator方法因此它们都是可迭代对象。for...of循环就是为消费可迭代对象而生的const arr [a, b, c]; for (const item of arr) { console.log(item); // 依次输出 a, b, c } const str hello; for (const char of str) { console.log(char); // 依次输出 h, e, l, l, o } const mySet new Set([1, 2, 3]); for (const num of mySet) { console.log(num); // 依次输出 1, 2, 3 }与for...in遍历键名不同for...of直接遍历键值且不会遍历原型链上的属性用于数组和字符串时更加直观安全。4.2 生成器按需“生成”迭代器的利器手动实现一个迭代器对象需要仔细维护内部状态比较麻烦。生成器函数Generator Function可以让你像写普通函数一样轻松创建一个迭代器。生成器函数使用function*语法定义内部使用yield关键字来“产生”迭代值。function* simpleGenerator() { yield first; yield second; yield third; } const gen simpleGenerator(); // 调用生成器函数返回一个迭代器对象 console.log(gen.next()); // { value: first, done: false } console.log(gen.next()); // { value: second, done: false } console.log(gen.next()); // { value: third, done: false } console.log(gen.next()); // { value: undefined, done: true }生成器的强大之处在于它的惰性求值和状态保持。函数执行会在每个yield处暂停下次next()调用时再从暂停处继续执行。这使得生成器非常适合用来处理异步操作、生成无限序列或实现自定义的复杂遍历逻辑。一个经典的例子是使用生成器实现斐波那契数列function* fibonacci() { let [prev, curr] [0, 1]; while (true) { yield curr; [prev, curr] [curr, prev curr]; } } const fib fibonacci(); console.log(fib.next().value); // 1 console.log(fib.next().value); // 1 console.log(fib.next().value); // 2 console.log(fib.next().value); // 3 // 可以无限调用下去...4.3 生成器与异步编程的桥梁在ES7的async/await语法普及之前生成器常与Promise结合用于以同步方式编写异步代码这就是著名的“co”库或“redux-saga”库的核心原理。其思想是yield后面跟一个Promise生成器的执行器会等待这个Promise解决resolve然后将解决后的值传回生成器内部并继续执行。虽然现在async/await已经成为了处理异步的首选但理解生成器在这方面的原理有助于你更深入地理解async函数它本质上是生成器的语法糖自动配备了执行器。经验技巧在日常开发中直接手写迭代器和生成器的场景并不算非常多但理解这套协议至关重要。因为许多现代API都构建于此之上。例如当你使用...展开运算符展开一个Map时const arr [...myMap]实际上就是调用了Map的Symbol.iterator方法。当你自己设计一个复杂的数据结构库时为其实现Symbol.iterator方法能让它无缝融入JavaScript的生态系统支持for...of、...、Array.from()等所有消费可迭代对象的语法。