网上关于Es6的知识点笔记一搜一大把,但大多数停在“let/const能替代var、箭头函数能少写function”的语法速查层面。真正干活的时候你会发现,决定代码上限的往往是Map/Set这类新容器、Proxy/Reflect这种元编程能力,以及深拷贝这种老生常谈里的边界细节。我最近重构一个老项目,把ES6这部分重新过了一遍,才意识到很多知识点不是“不会写”,而是“没理解到”。这篇笔记就是我基于这次复盘整理出来的带坑版本,不是语法大全,而是把块级作用域、解构陷阱、Map/Set选型、深拷贝环引用、Symbol、Proxy/Reflect、异步迭代这些真正影响编码行为的地方讲透。适合已经会写ES6、但想补上机制盲区的进阶前端阅读。
1. 块级作用域与解构赋值:那些“以为会了”的边界行为
1.1 let/const 的块级绑定规律与 TDZ
块级作用域看起来非常简单:大括号里声明,外面访问不到。但真正影响代码行为的,是“块级绑定”和“暂时性死区”(Temporal Dead Zone,简称 TDZ)这两个细节。
先说经典的循环闭包问题。很多人最早学ES6就是因为这个面试题:
for (var i = 0; i < 5; i++) { setTimeout(() => console.log(i)); } // 输出 5 5 5 5 5换成 let 之后,输出就变成了0 1 2 3 4。原因不是“let 每次循环重新赋值”,而是每次迭代都会创建一个新的词法环境,当前迭代的 i 被绑定在这个环境里,setTimeout 回调读到的就是本轮的值。这个机制在 Chrome 里的实现方式是每次迭代复制一份绑定,但工程师不需要关心具体实现,只需要记住:let/const 让循环体内的闭包捕获变得符合直觉。
TDZ 才是真正容易踩坑的地方。JavaScript 以前有一种安全的检测写法:typeof 某变量 === 'undefined',未声明的变量也不会报错。但 let/const 声明之后,在声明语句之前访问该变量,会直接抛 ReferenceError,而不是返回 undefined。看这个例子:
if (true) { console.log(typeof myConfig); // ReferenceError: Cannot access 'myConfig' before initialization const myConfig = {}; }这对老代码来说是行为断裂。以前写工具函数时,习惯用 typeof 判断一个全局变量是否被引入,一旦那个变量改成了 const,代码会在运行时静默崩溃。我的经验是:团队规范如果允许“先使用后声明”的旧习惯,ESLint 最好开启 no-use-before-define 规则,强制变量声明在使用之前。
块级绑定还有一个不太被注意的优势:它允许我们在一个函数内部用裸花括号创建隔离作用域。比如临时处理一段数据,不想污染外层变量:
function handleData(raw) { let result; { const normalized = raw.trim().toLowerCase(); result = normalized.split(','); } // normalized 在这里访问不到 return result; }这是 C 语言风格的块作用域用法,在 JavaScript 里完全合法。遇到复杂的临时变量组合时,这种写法比 IIFE 更清爽。
1.2 解构默认值的 undefined 与 null 差异
解构赋值的默认值机制,大多数人的理解是“属性为空就取默认值”,但这个“空”的判定标准非常严格:默认值只在属性值为 undefined 时生效,null 不会触发。这是个反直觉的坑,我见过不止一次线上配置因此失效:
const config = { theme: null }; const { theme = 'dark' } = config; console.log(theme); // null很多人预期这里输出 'dark',但实际得到 null。原因在于默认值机制本质上是“当读取结果为 undefined 时,把默认值赋给变量”。null 是一个有效的属性值,应当保留。解决方式是在解构前主动对 null 做归一化,或者用??运算符:
const { theme } = config; const finalTheme = theme ?? 'dark';默认值还可以是表达式,而且这个表达式是惰性求值的。只有当属性值为 undefined 时才会计算默认值。利用这个特性,可以避免不必要的开销:
const { token = generateToken() } = response;如果 response.token 存在,generateToken 根本不会被调用。
嵌套解构里也有一个很实用的兜底技巧。处理接口返回时,常见结构是data.list可能整体缺失:
const { data: { list = [] } = {} } = response;这里给 data 一个空对象默认值,再给 list 一个空数组默认值,三层嵌套都不会抛错。注意顺序:默认值是逐层作用域解析的,先有data: {},然后才轮到 list 的默认值。这个模式我基本每个 API 封装里都会用,能省掉大量response.data && response.data.list的空值判断。
2. 从 Object 升级到 Map/Set:容器选择背后的工程判断
2.1 Map 与 Object 的核心差异对比
先说一个我踩过的真实事故。早期做缓存时,我用普通对象存任务计算结果,键是两个不同类型的任务对象。跑了一阵发现所有缓存全部命中同一个值,排查半天才意识到:Object 的键会被强制转成字符串,两个不同对象都变成了"[object Object]",后写入的缓存直接覆盖了前一个。这就是 Object 做字典的根本局限——键类型只能是字符串或 Symbol。
Map 的出现解决了这类问题,但工程上并不是所有场景都要用 Map。下面这张对比表是我后来整理给团队的选型依据:
| 维度 | Object | Map |
|---|---|---|
| 键类型 | 字符串或 Symbol | 任意值(对象、函数、NaN 都可以) |
| 获取大小 | Object.keys(obj).length | map.size,直接读取 |
| 迭代顺序 | 整数键按升序优先,字符串键按插入顺序 | 完全按插入顺序 |
| 原型链 | 有 prototype,键可能撞上原型属性 | 无原型冲突 |
| 增删性能 | 动态增删属性较慢 | 专为频繁增删设计 |
| JSON 序列化 | 原生支持 | 不原生支持,需要手动转换 |
这里面最容易忽略的是 NaN 键。Map 内部使用 SameValueZero 算法判断相等性,所以NaN === NaN在 Map 里是成立的:
const m = new Map(); m.set(NaN, 'not a number'); console.log(m.get(NaN)); // 'not a number'对象键做缓存是 Map 最常见的实战场景。下面这个 memoize 函数,用参数列表作键,缓存重型计算结果:
const cache = new Map(); function memoized(keyArgs) { if (cache.has(keyArgs)) return cache.get(keyArgs); const result = expensiveComputation(keyArgs); cache.set(keyArgs, result); return result; }如果这里用 Object,两个结构相同但引用不同的对象会被转成同一个字符串键,缓存就完全失效了。Map 用对象本身作为键,引用不同就不会互相覆盖。
遍历顺序也是实战中容易出问题的点。Object 对整数键有特殊的排序规则:
const obj = { b: 1, 2: 'two', a: 3, 1: 'one' }; console.log(Object.keys(obj)); // ['1', '2', 'b', 'a']如果业务对顺序敏感,比如按注册顺序渲染配置项,Object 会悄悄改变顺序。Map 严格按插入顺序迭代,语义清晰得多。
工程选型建议可以浓缩成一句话:如果键不是字符串、需要频繁增删、或者需要按插入顺序遍历,直接用 Map;如果只是普通配置文件、需要 JSON 序列化、或者对性能极其敏感且键都是字符串,保留 Object 也完全合理。
2.2 Set 的集合运算与 WeakMap/WeakSet 的 GC 优势
Set 最著名的用法是数组去重,但真正有工程价值的是它的“哈希集合”语义。比如判断一个元素是否存在,数组的 includes 是 O(n),Set 的 has 是 O(1)。大型名单过滤场景下,先把名单转成 Set,再对数据流逐条判定,性能差距非常明显。
集合运算在业务里也常遇到,ES6 标准没有直接提供交集、并集、差集,需要自己实现:
const intersect = (a, b) => new Set([...a].filter((x) => b.has(x))); const difference = (a, b) => new Set([...a].filter((x) => !b.has(x))); const union = (a, b) => new Set([...a, ...b]);这些小工具虽然两行就能写完,但写好封装后,权限比对、标签过滤这类需求会清晰很多。
WeakMap 和 WeakSet 则完全不是 Set/Map 的“弱化版”,而是观察角度完全不同的存在。WeakSet 里的对象是弱引用,不会阻止垃圾回收。这个特性用来解决“对象是否已被处理过”的标记问题非常合适:
const boundNodes = new WeakSet(); function bindOnce(node, event, handler) { if (boundNodes.has(node)) return; node.addEventListener(event, handler); boundNodes.add(node); }SPA 里反复渲染列表时,避免同一个 DOM 节点被重复绑定事件,是内存泄漏和事件重复触发的重要防线。如果用 Set 存储节点,节点被移除后仍被 Set 引用,垃圾回收无法释放;WeakSet 在节点从文档移除后,引用自动失效,内存随之释放。用生活类比的话,WeakSet 就像一张不干胶标签,贴上后不改变对象本身,标签对象消失后标签也自动脱落。
WeakMap 的经典应用是给对象附加私有状态。比如在 DOM 节点上关联组件实例状态,节点删除时状态也能被回收。Vue 3 的响应式系统中,依赖关系收集也大量使用了 WeakMap 语义。这个数据结构不是用来高性能遍历的,它的定位从来就是“与对象生命周期绑定的额外数据”。
3. 深拷贝的完整方案对比与递归实现
3.1 JSON.parse(JSON.stringify()) 的死亡清单
深拷贝是 ES6 相关搜索里的高频词,但绝大多数人还在用JSON.parse(JSON.stringify(obj))一把梭。不是不能用,而是必须先知道它会在哪些数据上翻车。这张死亡清单可以贴在团队文档里:
| 数据类型 | JSON 方法的行为 |
|---|---|
| undefined、function、Symbol | 作为对象属性时整个字段被删除;出现在数组里时变成 null |
| NaN、Infinity | 变成 null |
| Date | 变成 ISO 字符串,不再是 Date 实例 |
| RegExp | 变成空对象 {} |
| Map / Set | 变成空对象 {} |
| BigInt | 直接抛错 |
| 循环引用 | 抛 RangeError |
| 不可枚举属性 / 原型链属性 | 全部丢失 |
| getter | 被当作普通值读取和固化 |
| Symbol 键名 | 丢失 |
可以想象一下这个崩溃现场:状态管理工具里有一段“防手抖”逻辑,把当前状态 JSON 序列化后存到 localStorage,恢复时再 parse。某天某位开发往状态里加了一个 Map,用于存标签映射表,结果页面刷新后整块数据变成空对象,用户标签全部消失,还很难定位。这就是 JSON 方法的典型事故:纯数据对象、没有循环引用、只有字符串数字布尔和普通数组时,用它很安全;一旦数据结构往复杂方向走,就必须换方案。
3.2 用 WeakMap 解决环引用的递归深拷贝
深拷贝的核心难点不是递归本身,而是“环引用”。比如一个链表结构里,tail.next 指回头部,普通递归会无限循环。解决方案也很直观:用一个 Map 记录“已经拷贝过的原对象 → 拷贝后的副本”,每次递归前先查表,遇到环直接返回副本。
为什么用 WeakMap 而不用 Map?拷贝过程结束后,这份映射表如果带着所有源对象引用,会让本来可以被垃圾回收的源对象一直留在内存里。WeakMap 不会阻止垃圾回收,源对象被外部释放后,映射自动失效。这是整个拷贝函数里最关键的结构选型。
下面是我整理的一个可直接使用的核心实现,实际工作中如果因为体积原因不想引 lodash,这个版本能覆盖大多数场景:
const isObject = (value) => value !== null && typeof value === 'object'; function deepClone(source, memo = new WeakMap()) { if (!isObject(source)) return source; if (typeof source === 'function') return source; if (memo.has(source)) return memo.get(source); let target; if (source instanceof Date) { const date = new Date(source.getTime()); memo.set(source, date); return date; } if (source instanceof RegExp) { const regexp = new RegExp(source.source, source.flags); regexp.lastIndex = source.lastIndex; memo.set(source, regexp); return regexp; } if (source instanceof Map) target = new Map(); else if (source instanceof Set) target = new Set(); else if (Array.isArray(source)) target = []; else target = Object.create(Object.getPrototypeOf(source)); memo.set(source, target); if (source instanceof Map) { for (const [key, value] of source) { target.set(deepClone(key, memo), deepClone(value, memo)); } return target; } if (source instanceof Set) { for (const value of source) { target.add(deepClone(value, memo)); } return target; } for (const key of Reflect.ownKeys(source)) { const descriptor = Object.getOwnPropertyDescriptor(source, key); if (descriptor && 'value' in descriptor) { Object.defineProperty(target, key, { ...descriptor, value: deepClone(descriptor.value, memo), }); } } return target; }这里有几个容易忽略的细节。
第一,Object.create(Object.getPrototypeOf(source))保留了源对象的原型链,所以实例的 instanceof 关系在拷贝后仍然成立。普通浅拷贝和 JSON 方法都会丢失原型。
第二,用Reflect.ownKeys而不是for...in,因为 ownKeys 能同时拿到字符串键、Symbol 键和不可枚举键,而 for...in 只能拿到可枚举字符串键,且还会走原型链。
第三,对 Date 和 RegExp 单独做了 instance 判断。没有这个分支的话,Date 会被当作普通对象,拷贝出来是一个没有任何内部槽位的空壳。
第四,函数直接返回原始引用。深拷贝一个函数的语义本身就不明确,lodash 的做法也是直接引用原函数,因为函数承载的是行为而不是状态。
3.3 structuredClone 与工程场景取舍
ES6 本身没有提供深拷贝 API,但现在浏览器和 Node.js 环境已经支持全局structuredClone方法。这个原生函数的背后是结构化克隆算法,天然支持环引用、Date、RegExp、Map、Set、TypedArray、ArrayBuffer 等类型。用法极其简单:
const copy = structuredClone(original);它的限制是:对象内部不能有函数和 Symbol,否则会抛 DataCloneError。也就是说,一个纯业务数据对象,用 structuredClone 往往是最稳妥、最高效的选择。
四种方案的取舍可以总结成下面这张表:
| 方案 | 环引用 | 函数/Symbol | 原型链 | 性能 | 适用场景 |
|---|---|---|---|---|---|
| JSON 方法 | 不支持 | 丢失 | 丢失 | 中等 | 纯 JSON 数据序列化 |
| 手写递归 | 支持(WeakMap) | 函数引用,Symbol 属性可保留 | 保留 | 慢但可控 | 特殊结构,教学理解 |
| structuredClone | 支持 | 抛错 | 不保留 | 原生高效 | 一般业务数据 |
| lodash.cloneDeep | 支持 | 函数引用,多数可枚举属性 | 保留 | 稳定 | 大型项目直接依赖 |
如果项目里已经引了 lodash,直接用cloneDeep是成本最低的选择,它经过大量业务验证,边界处理比手写版本完整得多。但如果你是出于学习目的,我强烈建议自己实现一遍深拷贝。把环引用、Symbol 键、原型链这三个点想清楚之后,JS 对象系统的整体认知会上一个台阶。
4. Symbol 与 class:被快速扫过但实际很能打的语法
4.1 Symbol 的字段隔离与内置协议
Symbol 在 ES6 里看起来就是“生成一个唯一值”,所以很多人学完就忘了。其实它的真正价值在于属性名的隔离。用 Symbol 作为键时,这个属性不会出现在 for...in、Object.keys、JSON.stringify 的结果里,只有 Object.getOwnPropertySymbols 和 Reflect.ownKeys 能看到它。
这个特性很适合给对象挂“元数据”。比如统计一个函数的调用次数,不想让这个字段被业务逻辑误读,也不想污染 JSON 输出:
const CALL_COUNT = Symbol('callCount'); function track(fn) { if (!track[CALL_COUNT]) track[CALL_COUNT] = 0; track[CALL_COUNT] += 1; return fn(); } track(() => console.log('run')); const descriptor = Object.getOwnPropertyDescriptor(track, CALL_COUNT); console.log(descriptor.value); // 1 console.log(JSON.stringify(track)); // {}Symbol.for 提供的是全局共享的唯一标识。跨模块传输同一个 Symbol 时,用Symbol.for('app.id')可以保证两个模块拿到同一个值,而普通 Symbol 每次调用都是新的。
内置 Symbol 里工程价值最高的是 Symbol.iterator。它是 for...of 协议的底层约定。下面这个 range 生成器虽然简短,但把 Symbol 键、生成器、迭代协议一次都串起来了:
const range = (start, end) => ({ *[Symbol.iterator]() { for (let i = start; i <= end; i += 1) yield i; }, }); for (const n of range(1, 5)) { console.log(n); // 1 2 3 4 5 }Symbol.toStringTag 可以定制Object.prototype.toString的输出标签。默认情况下:
class MyClass {} console.log(Object.prototype.toString.call(new MyClass())); // [object Object]设置[Symbol.toStringTag]后:
class MyClass { get [Symbol.toStringTag]() { return 'MyClass'; } } console.log(Object.prototype.toString.call(new MyClass())); // [object MyClass]这在写自定义类型、配合日志系统和类型检测库时非常有用。
4.2 class 字段初始化顺序与私有字段
class 是 ES6 的核心语法之一,但很多人只把它当成“构造函数加原型方法的语法糖”。真正理解 class 后,需要关注两个细节:字段初始化顺序和私有字段。
类字段(class fields)在执行时机上有严格要求。基类中,字段在构造函数体之前初始化;派生类中,字段在调用 super() 之后初始化。这意味着:
class Counter { count = 0; constructor(step = 1) { this.step = step; console.log(this.count); // 0,字段已初始化 } }派生类里更危险:
class Child extends Counter { double = this.count * 2; constructor() { super(); console.log(this.double); // 0 } }如果派生类字段声明在 super() 之前读取 this,会直接抛 ReferenceError。这个规则在 Babel 等转译器下早已生效,是常见的启动崩溃源。
私有字段 # 是后续标准补充的,但它常和 ES6 的 class 一起被讨论。私有字段真正做到了“外部无法读取”,比 Symbol 模拟更安全:
class Person { #age; constructor(age) { this.#age = age; } get age() { return this.#age; } } const p = new Person(30); console.log(p.age); // 30 console.log(p.#age); // SyntaxError需要注意,含私有字段的对象无法直接深拷贝,也不能用 Reflect 的常规方式全量复制。需要拷贝时,必须为类实现自定义的 toJSON 或 clone 方法。这是我在实现通用深拷贝函数后总结的一条经验:深拷贝不是万能的,类实例的私有槽位天然不适合通用复制,正确的做法是让类自己决定如何序列化和恢复。
5. Proxy 与 Reflect:让代码拥有“行为拦截”能力
5.1 为什么 Proxy 值得进入业务代码
很多初级前端觉得 Proxy 是面试造火箭用的,实际业务里碰不到。但 Proxy 最直接的价值,是解决了 Object.defineProperty 三个解决不了的问题。
第一是新增属性拦截。defineProperty 必须预先知道所有键,对象后续动态新增的属性没有 setter,数据变化就感知不到。这也是早期响应式框架需要对数组新增元素和对象新增属性做额外处理的原因。
第二是数组操作拦截。通过索引修改数组项,defineProperty 也不是不拦截,而是处理性能差、边界多。Proxy 可以直接代理整个数组对象,包括索引赋值、数组长度变化等操作。
第三是元操作拦截范围完全不同。Proxy 可以拦截 has(in 运算符)、deleteProperty、ownKeys、getPrototypeOf 等操作,defineProperty 根本做不到。
一句话总结:当你要为一个对象增加“规则”时,Proxy 是比 defineProperty 更完整的答案。
5.2 用 Proxy 做一个轻量配置校验
下面这个例子很典型,适合放在配置中心或 SDK 入口层。目标:配置对象只允许写 schema 中声明过的字段,类型必须匹配,不允许删除必填项。
function createConfig(initial, schema) { return new Proxy({ ...initial }, { set(target, key, value) { const rule = schema[key]; if (!rule) { throw new TypeError(`未知配置项: ${String(key)}`); } if (rule.type && typeof value !== rule.type) { throw new TypeError(`配置项 ${String(key)} 需要是 ${rule.type} 类型`); } if (rule.validator && !rule.validator(value)) { throw new TypeError(`配置项 ${String(key)} 未通过校验`); } return Reflect.set(target, key, value); }, deleteProperty(target, key) { if (key in target) { throw new TypeError(`配置项 ${String(key)} 不允许删除`); } return Reflect.deleteProperty(target, key); }, }); } const settings = createConfig({ host: '127.0.0.1', port: 3000 }, { host: { type: 'string' }, port: { type: 'number', validator: (v) => v >= 0 && v <= 65535 }, }); settings.port = 99999; // TypeError这里必须注意 Proxy 陷阱的返回值约定。set 陷阱必须返回布尔值,表示赋值成功与否。直接target[key] = value然后不返回,在严格模式下会让赋值操作抛出 TypeError,因为普通对象赋值默认需要返回 true。使用Reflect.set(target, key, value)能保证返回值与默认行为完全一致。Reflect 的作用就是把实际操作翻译成可返回布尔值的函数调用,和 Proxy 的陷阱天然配合。
5.3 调试代理与函数绑定
Proxy 还有一个很实用的场景,就是开发阶段给对象加访问日志。线上不好复现的问题,可以临时用一个调试代理包住可疑对象:
function createDebugProxy(target, label = '') { return new Proxy(target, { get(target, key, receiver) { const value = Reflect.get(target, key, receiver); console.log(`[debug] ${label}.${String(key)} 被读取`, value); if (typeof value === 'function') { return value.bind(target); } return value; }, }); }这里有一个细节容易被忽略:从代理上取出的函数如果直接调用,this 指向代理对象而不是原对象,方法内部的属性访问会走一遍代理,虽然通常也能工作,但某些依赖严格 instanceof 或内部槽位的场景会出错。用value.bind(target)把方法绑定回原对象,可以避免这类边界问题。
Proxy 不是没有代价。每个代理都会带来一层额外开销,在循环里频繁访问属性会有可见的性能损耗。所以我的建议是:Proxy 适合加在数据入口、配置对象、状态容器这类“访问频率可控”的位置,不要试图代理每一个业务对象。
6. 异步迭代与 AsyncGenerator:分页与流处理的正确姿势
6.1 用 for await...of 消费分页接口
这里先明确一点:async/await 严格来说属于 ES2017,异步迭代则是 ES2018,但它们几乎总被归入广义的 ES6 学习路线里,因为 Promise 和迭代器都起源于 ES6 时代。我把它们收进这篇笔记,也是因为它们已经成为现代前端日常工作的一部分。
异步迭代解决的问题是:数据不是一次性全部就位,而是分批到达,但我们希望用 for...of 那种简洁的写法逐批消费。分页接口是最经典的场景。
async function* fetchAllPages(api, maxPage) { for (let page = 1; page <= maxPage; page += 1) { const response = await fetch(`${api}?page=${page}`); const list = await response.json(); if (list.length === 0) return; yield list; } } for await (const pageData of fetchAllPages('/api/users', 20)) { renderPage(pageData); }这段代码里,fetchAllPages 是异步生成器函数。调用它并不会立刻请求数据,而是返回一个异步迭代器。每次 for await 迭代都会向这个迭代器要下一批数据,迭代器内部执行 fetch 并 await 结果,然后 yield 出列表。等循环体处理完这一批,再去获取下一批。整个过程看起来像同步代码,实际上每一批都经历了完整的网络往返。
6.2 大文件流读取与提前退出的资源释放
Node.js 的 Readable 流从 v10 开始就实现了Symbol.asyncIterator,所以大文件分块读取可以写成极简形式:
import { createReadStream } from 'node:fs'; async function processLargeFile(filePath) { const stream = createReadStream(filePath, { encoding: 'utf8' }); let processed = 0; try { for await (const chunk of stream) { processed += chunk.length; if (processed > 10 * 1024 * 1024) break; } } finally { stream.destroy(); } console.log(`已处理 ${processed} 字符`); }这里有踩过坑才懂的经验:for await 在 break 或循环体提前抛错时,会尝试调用异步迭代器的 return 方法,理论上流应该被关闭。但有些流实现的 return 并不等价于 destroy,尤其是 Readable 流读了一半就 break 的场景,底层 fd 可能不会自动释放。所以最稳妥的姿势永远是把stream.destroy()放在 finally 里。这个习惯帮我避免过多次文件句柄泄漏。
如果要对多个文件做并发读取,for await 本身是串行的,可以先用 Promise.all 把多个异步迭代器同时消费完,再统一合并结果。异步生成器提供了底层机制,但并发方案还是需要工程师自己设计。
6.3 手动迭代时理解 next 协议
for await 之所以好用,背后是一套简单的协议:异步迭代器需要实现 next() 方法,每次调用返回一个 Promise,resolve 后得到一个形如{ value, done }的对象。
如果要手动控制读取节奏,可以这样:
const iterator = fetchAllPages('/api/users', 20); const firstBatch = await iterator.next(); console.log(firstBatch.value);这里能看出 for await 替你做的三件事:等待 Promise resolve、读取 value、处理 done。手动写的话,错误传播和终止逻辑都要自己管。理解这个协议之后,调试异步迭代相关代码会轻松很多。
我一直觉得,异步迭代是 ES6 时代最被低估的能力之一。它把“异步数据流”抽象成了语言层面的原生语义,而这也恰恰是整个 ES6 设计思路的缩影:不仅要给你更短的写法,更要把复杂的执行环境处理得让开发者可以放心依赖。
如果用一句话给这套 Es6 知识点笔记收个尾,那就是:真正决定代码质量的,从来不是某个新语法会不会用,而是你对它背后的机制有没有想透。遇到深拷贝时能想到环引用,做缓存时能想到 Map,写数据入口时能想到 Proxy,这几个“想到”比背一百条语法规则都管用。下次重构时,试着在这些关键点停留两分钟,感受一下 ES6 带来的不只是语法变化,还有思维方式的升级。