2015 年 6 月,ECMAScript 2015 正式发布,也就是大家口中的 ES6。它是 ECMAScript 历史上最大的一次更新:let / const、解构赋值、模板字符串、箭头函数、class、模块系统、Promise、Symbol、Proxy、Reflect、生成器、Map / Set……几乎每一行现代 JavaScript 代码里,都有它的影子。十年过去,这些特性早已不是“新特性”,但“会用”和“用对”之间仍然隔着一整个理解深度:为什么箭头函数不能当构造函数?为什么 let 写在 for 循环里能解决闭包问题?Promise 的 then 里到底发生了什么?本篇 30 分钟长文,从语言规范一路讲到工程实践,把 ES6 的知识点一次讲透。
一、ES6 到底是什么:版本、规范与落地
在动手写代码之前,先把几个容易混淆的概念理清楚。它们决定了你在排查兼容性问题时,能不能找对方向。
ECMAScript 与 JavaScript 的关系
ECMAScript 是语言规范,由 ECMA International 下属的 TC39 委员会维护;JavaScript 是规范的一种实现,由各大浏览器引擎(V8、SpiderMonkey、JavaScriptCore)和运行时(Node.js、Deno)提供。规范定义了语法、类型、内置对象的行为;实现决定了这些行为在什么时间、以什么方式真正可用。
ES1(1997)、ES2(1998)、ES3(1999)——奠定基础。ES4因分歧过大被放弃,ES5(2009)带来严格模式、JSON、Array.prototype.forEach等。ES6=ES2015,2015 年 6 月发布,是跨度最大的一版。- 此后改为每年一版:ES2016、ES2017……直到今天。所以“ES6”这个词严格来说只指 2015 那一版,而“ES2015+”才是持续演进的整体。
一个新特性是怎么进入标准的
TC39 使用一套五阶段的提案流程(Stage 0 到 Stage 4),任何特性都必须走完才能进入当年的标准版本。理解这个流程,能帮你判断“这个语法现在能不能上生产”。
Stage 0 则更早,属于“想法”阶段,任何人都可以在 es-discuss 或 GitHub 上提出,但离标准还有很远的距离。
语法转换与 Polyfill:两件不能混为一谈的事
在工程里用 ES6,通常要区分两类处理方式:
const add = (a, b) => a + b;
const { name, age } = user;
class Person { ... }
// Babel / TypeScript / SWC 会把它降级为 ES5
var add = function(a, b) { return a + b; };
Promise.all([...]);
new Map();
Array.from(list);
Object.assign({}, a, b);
// 必须用 polyfill 补齐运行时实现
// 例如 core-js,按 browserslist 按需注入
一句话总结:语法可以编译掉,API 只能补出来。如果你发现某个项目里 Promise 在旧浏览器报 undefined,问题一定出在 polyfill 而不是 Babel 配置上。
今天的浏览器支持情况
ES6 的全部核心特性在现代浏览器中早已全面可用,包括 IE11 之外的几乎所有环境。真正需要编译的,往往是更晚版本的新语法(可选链、空值合并、类字段、顶层 await 等),以及为了兼容老旧设备而做的降级。
二、let 与 const:块级作用域带来的秩序
如果把 ES6 的所有特性按“改变了多少代码”排序,let 和 const 一定排在第一。它们引入的块级作用域,重新定义了变量在代码中的可见范围。
var 的三个历史问题
- 函数作用域:
var声明的变量只受函数边界约束,if、for、try的花括号对它毫无意义。 - 变量提升:声明会被提升到作用域顶部,赋值留在原地。于是“先用后声明”不会报错,只会得到
undefined——一个极其隐蔽的 bug 来源。 - 允许重复声明:同一个作用域里写两次
var a完全合法,后者静默覆盖前者。
块级作用域与暂时性死区
let 和 const 声明的变量只在一对花括号内有效。if 块、for 块、甚至单独一对 {} 都能形成作用域。
var a = 1;
let b = 2;
}
console.log(a); // 1,var 泄漏到了外层
console.log(b); // ReferenceError: b is not defined
// 暂时性死区(TDZ)
console.log(c); // ReferenceError,不是 undefined
let c = 3;
TDZ(Temporal Dead Zone)是 let / const 最容易被忽视的细节:从进入作用域到声明语句执行之间,这个变量处于“不可访问”状态。访问它会直接抛 ReferenceError,而不是返回 undefined。这不是缺陷,而是特性——它把“用错变量”的错误从运行时静默失败,提前暴露成了明确的异常。
为什么 for 循环里的 let 能解决闭包问题
这是面试里最经典的一道题,也是理解块级作用域的最佳入口。
setTimeout(function () {
console.log(i);
}, 0);
}
// 输出:3 3 3
// 原因:var 只有一个 i,三个回调共享同一个变量;
// 循环结束时 i 已经变成 3,回调才真正执行。
规范里的说法是:for 语句中的 let 会在每次循环迭代开始时创建一个新的词法环境,并把上一轮的值复制进去。这不是编译器的小把戏,而是语言层面明确规定的语义。
const 不是“值不可变”
这是另一个高频误区。const 保证的是绑定不可重新赋值,而不是值本身不可变。
user.name = '李明'; // 合法:修改的是对象的属性
user = {}; // TypeError:不能给常量重新赋值
// 想真正冻结,需要 Object.freeze(浅冻结)
const config = Object.freeze({ debug: false });
config.debug = true; // 静默失败(严格模式下抛 TypeError)
// 深层冻结需要递归处理
function deepFreeze(obj) {
Object.values(obj).forEach(
v => v && typeof v === 'object' && deepFreeze(v)
);
return Object.freeze(obj);
}
选择原则
const,它传达“这个绑定不会变”的意图
let
var,除非你需要它特殊的函数作用域行为
let 与 var 声明同名变量,会直接报错
全局作用域下的一个差异
在脚本(非模块)的顶层,var 声明的变量会成为 window 的属性,而 let / const 不会。它们会被放在一个独立的全局词法环境中,因此不会污染 window,也不会与 window 上的同名属性冲突。这也是模块化时代更推荐 let / const 的原因之一。
三、解构赋值:从数据结构里优雅取值
解构赋值让“从数组或对象中取出若干个值”这件事,从多行样板代码变成了一行声明。它的本质是模式匹配:左边写形状,右边提供数据,只要形状对应,值就会被自动填入。
数组解构
// first = 1, second = 2
// 跳过元素
const [, , third] = [1, 2, 3]; // third = 3
// 默认值:仅当对应位置是 undefined 时生效
const [a = 10, b = 20] = [1]; // a = 1, b = 20
// rest 收集剩余元素
const [head, ...tail] = [1, 2, 3, 4];
// head = 1, tail = [2, 3, 4]
// 交换变量:不再需要临时变量
let x = 1, y = 2;
[x, y] = [y, x];
注意一个细节:默认值只在值为 undefined 时生效,null、0、'' 都不会触发默认值。这个规则在对象解构里同样成立。
对象解构
name: '张玥',
age: 30,
address: { city: '北京', zip: '100000' }
};
const { name, age } = user;
// 重命名:把 name 取出来改叫 userName
const { name: userName, age: userAge } = user;
// 默认值 + 重命名
const { role = 'guest' } = user;
// 嵌套解构
const { address: { city } } = user; // city = '北京'
// rest 收集剩余属性
const { name: n, ...rest } = user;
// n = '张玥', rest = { age: 30, address: {...} }
函数参数解构
当函数接收一个配置对象时,解构能让签名变得非常清晰,还自带默认值:
function createChart(options) {
const width = options.width || 600;
const height = options.height || 400;
const theme = options.theme || 'light';
}
// 改造后:签名即文档
function createChart({
width = 600,
height = 400,
theme = 'light',
data = []
} = {}) {
// ...
}
createChart(); // 全部使用默认值
createChart({ theme: 'dark' });
最后那个 = {} 很容易被忽略:如果没有它,调用 createChart() 时会因为对 undefined 做解构而直接抛错。
几个必须知道的陷阱
const { a } = null;对
null 或 undefined 解构会直接抛 TypeError。接口返回可能为空时,先兜底:const { a } = res || {};
{ a } = obj; 单独写一行时,引擎会把 { 当成块语句而不是对象模式,直接语法错误。
({ a } = obj);,或者在声明时就完成解构。
还有一个语义细节:对象解构是从右值上读取属性,因此会触发 getter;如果属性不存在,得到的是 undefined 而不是报错。这一点和访问 obj.a 完全一致。
四、模板字符串与标签模板
模板字符串用反引号 ` 包裹,解决了字符串拼接时代的两个痛点:多行文本和变量插值。
三个基础能力
const name = '张玥';
const msg = `你好,${name},你今年 ${2026 - 1996} 岁`;
// 2. 多行:换行会被原样保留
const html = `
<div class="card">
<h3>${name}</h3>
</div>
`;
// 3. 嵌套:模板里可以再写模板
const list = items.map(
item => `<li>${item.label}</li>`
).join('');
插值表达式会被自动转换为字符串,但注意:对象会变成 [object Object],需要显式 JSON.stringify 或取具体字段。
标签模板:被严重低估的能力
在模板字符串前面加一个函数名,就变成了标签模板。这个函数接收两部分参数:一个字符串数组(模板的静态片段),以及所有插值。这让你可以拦截并加工字符串的构建过程。
return strings.reduce((acc, str, i) => {
const val = values[i] !== undefined
? `<mark>${values[i]}</mark>`
: '';
return acc + str + val;
}, '');
}
highlight`搜索结果:${keyword} 共 ${count} 条`;
// "搜索结果:<mark>ES6</mark> 共 <mark>42</mark> 条"
// 内置标签:String.raw 保留反斜杠原样
String.raw`C:\dev\project\src`;
// "C:\\dev\\project\\src",不会把 \d 当成转义
标签模板在真实项目里的典型用途包括:CSS-in-JS 库(styled-components)解析样式片段、i18n 库做插值占位、SQL 模板引擎做参数转义防注入、以及 GraphQL 的 gql 标签。它的价值在于:把“字符串”升级成了一个可以被程序理解的结构化数据。
两个容易踩的坑
- 模板字符串里的缩进会被原样保留。用模板写 HTML 时,多层缩进会带进最终字符串,必要时配合
trim()或专门的缩进处理函数。 - 反引号内部的反引号和
${需要转义:\`和\${。
五、函数增强:默认参数、rest、spread 与箭头函数
默认参数
在 ES6 之前,“默认值”靠 || 实现,于是 0、''、false 这些合法输入都会被误判为“未传入”。默认参数彻底解决了这个问题:它只在实参为 undefined 时生效。
function oldWay(count) {
count = count || 10;
return count;
}
oldWay(0); // 10,错误!
// ES6 写法
function newWay(count = 10) {
return count;
}
newWay(0); // 0,正确
newWay(); // 10
newWay(null); // null,也不触发默认值
默认值可以是表达式,并且在每次调用时重新求值:
console.log(`[${time.toISOString()}] ${msg}`);
}
// 默认值可以引用前面的参数
function rect(width, height = width) {
return width * height; // 正方形时只传一个参数
}
一个重要的细节:参数列表拥有自己的作用域。一旦使用了默认值、rest 或解构参数,函数体内部就处于“参数作用域”的子作用域中,函数体内的 let 声明不能与参数同名,否则会报语法错误。
rest 参数与 spread 展开
两者语法都是 ...,但方向相反:rest 负责收集,spread 负责展开。
function sum(...nums) {
return nums.reduce((a, b) => a + b, 0);
}
sum(1, 2, 3); // 6
// rest 必须是最后一个参数
function pick(first, ...others) {}
// spread:把数组或可迭代对象拆开
Math.max(...[1, 5, 3]); // 5
const merged = [...arr1, ...arr2];
const copy = [...original];
const clone = { ...source };
// 还能展开字符串
[...'hello']; // ['h', 'e', 'l', 'l', 'o']
注意 arguments 与 rest 的区别:arguments 是类数组对象,没有 map、filter 等方法;rest 参数得到的是真正的数组。另外 arguments 在箭头函数中根本不存在。
还要提醒一句:{ ...source } 是浅拷贝。嵌套的对象仍然是同一个引用,修改副本里的深层属性会同步影响原对象。需要深拷贝时使用 structuredClone() 或专门的库。
箭头函数
箭头函数不只是“更短的写法”,它的核心差异在于 this 的绑定方式:箭头函数没有自己的 this,它使用的是定义时所在作用域的 this(词法作用域)。
count: 0,
// 普通函数:this 取决于调用方式
startBad: function () {
setInterval(function () {
this.count++; // this 是 window,不是 counter
}, 1000);
},
// 箭头函数:this 就是 counter
startGood: function () {
setInterval(() => {
this.count++;
}, 1000);
}
};
除此之外,箭头函数还有几项明确限制,它们都是同一个原因导致的——没有自己的执行上下文:
- 不能用作构造函数,没有
prototype属性,new (() => {})会抛错。 - 没有自己的
arguments、super、new.target。 - 不能用
yield,因此不能是生成器函数。 call/apply/bind无法改变它的this。
什么时候不该用箭头函数
const obj = { name: 'a', say: () => this.name };这里的
this 来自外层作用域,拿不到 obj。对象方法请用简写形式。
const obj = { name: 'a', say() { return this.name; } };
methods、jQuery 的回调、需要 arguments 的函数。
this 的任何回调。
函数 name 属性
ES6 补齐了 Function.prototype.name 的行为,让匿名函数也能有一个合理名字,这对调试和栈追踪帮助很大:
fn1.name; // 'fn1'
const fn2 = () => {};
fn2.name; // 'fn2'
const obj = { method() {} };
obj.method.name; // 'method'
const bound = fn1.bind(null);
bound.name; // 'bound fn1'
六、对象与数组的语法糖与新方法
对象字面量增强
const key = 'level';
// 属性简写:变量名即属性名
const user = { name, age: 30 };
// 方法简写
const api = {
async fetchList() {},
reset() {}
};
// 计算属性名:属性名可以是表达式
const obj = {
[`${key}_id`]: 1,
['a' + 'b']: 2
};
// { level_id: 1, ab: 2 }
Object 上的新方法
Object.is(NaN, NaN); // true(=== 是 false)
Object.is(0, -0); // false(=== 是 true)
// Object.assign:浅合并
const merged = Object.assign({}, defaults, options);
// 遍历三兄弟
Object.keys(obj); // ['a', 'b']
Object.values(obj); // [1, 2]
Object.entries(obj); // [['a', 1], ['b', 2]]
// 从键值对数组构造对象
Object.fromEntries(['a', 1], ['b', 2]);
// { a: 1, b: 2 }
// 合并 + 覆盖的经典写法
const clone = { ...defaults, ...options };
Array 上的新方法
| 方法 | 作用 | 典型场景 |
|---|---|---|
Array.from() |
把类数组或可迭代对象转为真数组 | NodeList、arguments、Set 转数组 |
Array.of() |
用参数构造数组 | 替代 new Array(3) 的歧义 |
find() |
返回第一个满足条件的元素 | 按 id 查找对象 |
findIndex() |
返回第一个满足条件的下标 | 定位后做 splice |
fill() |
用固定值填充区间 | 初始化矩阵 |
copyWithin() |
在数组内部复制片段 | 环形缓冲区 |
entries() / keys() / values() |
返回迭代器 | 配合 for...of 遍历 |
includes() |
判断是否包含某值(可识别 NaN) | 替代 indexOf !== -1 |
Array.from({ length: 5 }, (_, i) => i * 2);
// [0, 2, 4, 6, 8]
// Array.of 与 new Array 的区别
new Array(3); // [empty × 3],长度 3
Array.of(3); // [3],长度 1
// 注意:fill 传对象时,填的是同一个引用
const grid = Array(3).fill([]);
grid[0].push(1);
// grid 变成 [[1], [1], [1]],三行是同一个数组!
// 正确做法
const grid2 = Array.from({ length: 3 }, () => []);
fill 的这个坑非常经典:它填充的是同一个引用,而不是深拷贝。所有“初始化二维数组”的代码都应该用 Array.from 或 map。
Number、Math 与 String 的补充
Number.isInteger(3.0); // true
Number.isNaN('abc'); // false,不做类型转换
Number.isFinite(Infinity); // false
Number.EPSILON; // 浮点比较的容差基准
Number.MAX_SAFE_INTEGER; // 2^53 - 1
Number.parseInt('08'); // 8,不依赖八进制猜测
// Math
Math.trunc(-4.7); // -4,直接截断
Math.sign(-5); // -1
Math.cbrt(27); // 3
Math.hypot(3, 4); // 5,欧几里得距离
// String
'hello'.includes('ell'); // true
'hello'.startsWith('he'); // true
'hello'.endsWith('lo'); // true
'ab'.repeat(3); // 'ababab'
'5'.padStart(2, '0'); // '05'
'abc'.padEnd(5, '.'); // 'abc..'
特别提醒:Number.isNaN 与全局的 isNaN 完全不同。全局 isNaN('abc') 返回 true,因为它会先做 Number() 转换;而 Number.isNaN('abc') 返回 false,因为它不做转换。判断一个值是不是真正的 NaN,永远用 Number.isNaN。
七、class:语法糖之下依然是原型
ES6 的 class 并没有引入新的对象模型,它只是把已有的原型继承包装成了更易读的语法。理解这一点,才能明白它为什么有那些“看起来奇怪”的限制。
基本写法
// 实例字段(ES2022)
legs = 4;
// 静态字段
static category = 'animal';
constructor(name) {
this.name = name;
}
// 原型方法:定义在 Animal.prototype 上
speak() {
return `${this.name} 发出了声音`;
}
// 静态方法:定义在 Animal 本身上
static create(name) {
return new Animal(name);
}
// 访问器
get upperName() {
return this.name.toUpperCase();
}
set upperName(v) {
this.name = v.toLowerCase();
}
}
继承与 super
constructor(name, breed) {
super(name); // 必须先调用 super 才能用 this
this.breed = breed;
}
// 覆写方法,并通过 super 调用父类实现
speak() {
return `${super.speak()}:汪汪`;
}
static create(name) {
return new Dog(name, '未知');
}
}
const d = new Dog('旺财', '柴犬');
d instanceof Dog; // true
d instanceof Animal; // true
Object.getPrototypeOf(Dog) === Animal; // true,静态方法也会继承
特别注意 Object.getPrototypeOf(Dog) === Animal 这一行:ES6 的继承不仅继承原型方法,静态方法也会通过原型链继承。这是 class 相对传统构造函数继承的一个重要改进。
class 与构造函数的六个区别
| 维度 | class | 构造函数 |
|---|---|---|
| 调用方式 | 必须用 new,否则报错 |
可以直接调用 |
| 声明提升 | 不提升(存在 TDZ) | 函数声明会提升 |
| 严格模式 | 内部代码自动严格模式 | 取决于外部环境 |
| 可枚举性 | 原型方法不可枚举 | 手动挂载的方法可枚举 |
| 静态方法继承 | 会继承 | 不会自动继承 |
| 函数调用检查 | 内置 new.target 检查 |
需要自己写检查 |
私有字段与静态块
ES2022 补齐了 class 最后一块短板:真正的私有成员。
// # 开头的字段是真正的私有,外部完全访问不到
#count = 0;
static #instances = 0;
constructor() {
Counter.#instances++;
}
increment() {
this.#count++;
return this.#count;
}
// 私有方法
#log() {
console.log(this.#count);
}
// 静态初始化块:类定义时执行一次
static {
console.log('Counter 类已初始化');
}
}
const c = new Counter();
c.#count; // SyntaxError,外部无法访问私有字段
私有字段与“用下划线表示约定私有”(如 _count)有本质区别:前者是语言级别的强制,外部访问会直接抛语法错误;后者只是团队约定,运行时毫无保护。
class 与函数式写法怎么选
- 需要创建大量同类实例、有明确的继承关系时,
class更直观。 - 只是做数据封装、不需要继承时,工厂函数 + 闭包同样优雅,而且天然拥有真正的私有变量。
- 无论用哪种,都不要把“为了用 class 而用 class”当成目标。语法是为问题服务的。
八、模块系统:import 与 export
ES6 模块(ESM)是 JavaScript 第一次拥有官方的模块化方案。在此之前,社区靠 AMD、CommonJS、UMD 各显神通,带来了严重的割裂。ESM 的三大特点是:静态结构、严格模式、单例作用域。
基本语法
// 命名导出
export const PI = 3.14159;
export function square(x) {
return x * x;
}
// 也可以统一导出
const add = (a, b) => a + b;
const sub = (a, b) => a - b;
export { add, sub };
// 默认导出:一个模块只能有一个
export default function multiply(a, b) {
return a * b;
}
// ===== main.js =====
import multiply, { PI, square, add as plus } from './math.js';
import * as math from './math.js';
console.log(math.PI);
在 HTML 中使用时,script 标签必须写 type="module":
<!-- 模块脚本默认 defer,且始终严格模式 -->
<!-- 模块内部的 this 是 undefined,不是 window -->
<!-- 模块只执行一次,多次 import 拿到同一个实例 -->
ESM 与 CommonJS 的关键差异
| 维度 | ESM | CommonJS |
|---|---|---|
| 加载时机 | 静态解析,编译时确定依赖 | 运行时同步 require |
| 值的绑定 | 动态绑定(导出值的引用) | 值的拷贝 |
| Tree Shaking | 支持 | 基本不支持 |
| 顶层 await | 支持 | 不支持 |
| 循环依赖 | 通过提升处理,需谨慎 | 返回未完成的 exports 对象 |
“动态绑定”这一点值得展开:ESM 导入的不是值的快照,而是对导出变量的实时引用。如果导出模块修改了某个 let 变量,导入方看到的是修改后的新值——这和 CommonJS 的拷贝语义完全不同。
动态导入
import() 是一个函数式的动态导入,返回 Promise,常用于路由懒加载和按需加载大体积依赖:
button.addEventListener('click', async () => {
const { renderChart } = await import('./chart.js');
renderChart(data);
});
// 条件加载
if (needEditor) {
const editor = await import('./editor.js');
editor.init();
}
打包工具会把动态导入的模块拆成独立的 chunk,只有在真正需要时才发起网络请求。这是现代前端性能优化最有效的手段之一。
Tree Shaking 的前提
Tree Shaking(摇树优化)能删除未被使用的代码,但它有前提条件:
- 必须使用 ESM 的静态
import/export,CommonJS 的require无法静态分析。 - 模块必须没有副作用,或者在
package.json中标记"sideEffects": false。 - 导入时尽量按需解构,避免
import * as lib后整体使用。
九、迭代器、for...of 与生成器
可迭代协议
ES6 引入了一套统一的遍历协议:只要一个对象拥有 [Symbol.iterator] 方法,并且该方法返回一个带有 next() 的迭代器,它就是可迭代对象。数组、字符串、Map、Set、arguments、NodeList 都天然可迭代。
const fib = {
[Symbol.iterator]() {
let a = 0, b = 1;
return {
next() {
[a, b] = [b, a + b];
return { value: a, done: false };
}
};
}
};
for (const n of fib) {
if (n > 100) break;
console.log(n);
}
for...of 与 for...in 的区别
break / continue / return
for...in 会拿到字符串下标,且可能遍历到扩展属性
entries() 配合 for...of 解构
for (const [index, value] of list.entries()) {
console.log(index, value);
}
// Map 的遍历
for (const [key, val] of map) {
console.log(key, val);
}
生成器函数
生成器是“可以暂停和恢复的函数”,用 function* 声明。每次调用 next(),函数执行到下一个 yield 就暂停,并返回一个 { value, done } 对象。
for (let i = start; i <= end; i += step) {
yield i;
}
}
[...range(1, 5)]; // [1, 2, 3, 4, 5]
// 双向通信:next 的参数会成为上一个 yield 的结果
function* dialog() {
const name = yield '你叫什么名字?';
const age = yield `你好 ${name},你多大了?`;
return `${name} 今年 ${age} 岁`;
}
const it = dialog();
it.next(); // { value: '你叫什么名字?', done: false }
it.next('张玥'); // { value: '你好 张玥,你多大了?', done: false }
it.next(30); // { value: '张玥 今年 30 岁', done: true }
生成器最经典的用途,是在 async/await 出现之前实现“同步写法处理异步流程”。今天它更多用于:惰性序列、无限序列、自定义迭代器、以及需要暂停执行的复杂状态机。
function* flat(arr) {
for (const item of arr) {
if (Array.isArray(item)) {
yield* flat(item);
} else {
yield item;
}
}
}
[...flat([1, [2, [3, 4]], 5])];
// [1, 2, 3, 4, 5]
十、Symbol:独一无二的属性键
Symbol 是 ES6 新增的第七种原始类型(前六种是 undefined、null、boolean、number、string、object)。它的核心特性是每次调用都返回一个全新的、独一无二的值。
const s2 = Symbol('id');
s1 === s2; // false,括号里的字符串只是描述
// 作为属性键:不会被常规遍历发现
const obj = {
[s1]: '内部值',
normal: '普通值'
};
Object.keys(obj); // ['normal']
JSON.stringify(obj); // {"normal":"普通值"}
Object.getOwnPropertySymbols(obj); // [Symbol(id)]
// Symbol.for:全局注册表,同名返回同一个 Symbol
Symbol.for('app') === Symbol.for('app'); // true
Symbol.keyFor(Symbol.for('app')); // 'app'
内置的 well-known symbols
ES6 定义了一批内置 Symbol,用来定制对象在语言内部的行为:
| Symbol | 作用 |
|---|---|
Symbol.iterator |
定义默认迭代器,使对象可被 for...of 遍历 |
Symbol.asyncIterator |
定义异步迭代器,配合 for await...of |
Symbol.toPrimitive |
定制对象转原始值的行为 |
Symbol.toStringTag |
定制 Object.prototype.toString 的返回 |
Symbol.hasInstance |
定制 instanceof 的判断逻辑 |
Symbol.species |
指定派生对象的构造函数 |
class MyArray {
static [Symbol.hasInstance](instance) {
return Array.isArray(instance);
}
}
[] instanceof MyArray; // true
// 定制类型标签
const custom = {
[Symbol.toStringTag]: 'CustomType'
};
Object.prototype.toString.call(custom);
// '[object CustomType]'
实际项目里,Symbol 最常见的用途是给第三方对象挂载内部数据而不污染其属性列表,以及在库内部定义不对外暴露的钩子。React、Vue 等框架的内部实现里都有大量 Symbol 的身影。
十一、Map、Set、WeakMap 与 WeakSet
这四种集合类型解决了对象和数组在特定场景下的能力缺口。
Map 与 Object 的取舍
| 维度 | Map | Object |
|---|---|---|
| 键的类型 | 任意类型(对象、函数、NaN 均可) | 只能是字符串或 Symbol |
| 键的顺序 | 严格保持插入顺序 | 整数键会被提前排序 |
| 大小 | map.size 直接获取 |
需要 Object.keys().length |
| 遍历 | 原生可迭代,直接 for...of |
需要先转成数组 |
| 原型污染 | 无 | 需注意 __proto__ 等键 |
| JSON 序列化 | 需要手动转换 | 直接支持 |
const cache = new Map();
const nodeA = { id: 1 };
const nodeB = { id: 2 };
cache.set(nodeA, '数据 A');
cache.set(nodeB, '数据 B');
cache.get(nodeA); // '数据 A'
cache.size; // 2
// 常用方法
cache.has(nodeA); // true
cache.delete(nodeA);
cache.clear();
// 与数组互转
const m = new Map([['a', 1], ['b', 2]]);
[...m]; // [['a',1], ['b',2]]
[...m.keys()]; // ['a', 'b']
Object.fromEntries(m); // { a: 1, b: 2 }
Set 与数组
Set 是值的集合,自动去重,并且提供 O(1) 级别的 has 判断。
[...new Set([1, 2, 2, 3, 3])];
// [1, 2, 3]
// 集合运算
const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);
// 交集
const intersect = new Set([...a].filter(x => b.has(x)));
// Set { 2, 3 }
// 差集
const diff = new Set([...a].filter(x => !b.has(x)));
// Set { 1 }
// 注意:Set 去重基于 SameValueZero,NaN 会被去重
new Set([NaN, NaN]).size; // 1
new Set([{}, {}]).size; // 2,对象按引用比较
WeakMap 与 WeakSet
这两个“弱”集合的关键在于:它们对键(或成员)持有的是弱引用。如果一个对象只被 WeakMap 引用,垃圾回收器依然可以回收它。
const meta = new WeakMap();
function attach(el, data) {
meta.set(el, data);
}
function read(el) {
return meta.get(el);
}
// 当 el 从 DOM 中移除且没有其他引用时,
// meta 中对应的记录会被自动清理,不会造成内存泄漏
WeakMap 的三个限制:键必须是对象、不可枚举(没有 keys() / size)、不支持 clear()。这些限制正是实现“弱引用”所必需的——如果允许枚举,就无法保证回收时机。
size、键可能是原始值时的键值存储
十二、Proxy 与 Reflect:元编程的入口
Proxy 可以拦截对象上的基本操作,Reflect 则把这些操作以函数的形式暴露出来。两者是 Vue 3 响应式系统的技术基石。
基本用法
const proxy = new Proxy(target, {
// 读取拦截
get(obj, key, receiver) {
console.log(`读取 ${String(key)}`);
return Reflect.get(obj, key, receiver);
},
// 写入拦截
set(obj, key, value, receiver) {
if (key === 'age' && typeof value !== 'number') {
throw new TypeError('age 必须是数字');
}
return Reflect.set(obj, key, value, receiver);
},
// 删除拦截
deleteProperty(obj, key) {
console.log(`删除 ${String(key)}`);
return Reflect.deleteProperty(obj, key);
},
// in 操作符拦截
has(obj, key) {
return key in obj;
}
});
proxy.name; // 输出"读取 name",返回"张玥"
proxy.age = '三十'; // TypeError
'name' in proxy; // true
常用的拦截陷阱(trap)
| Trap | 拦截的操作 | 典型用途 |
|---|---|---|
get |
读取属性 | 响应式收集依赖、默认值、日志 |
set |
设置属性 | 数据校验、触发更新 |
has |
in 操作符 |
隐藏内部键 |
deleteProperty |
delete 操作 |
禁止删除 |
ownKeys |
Object.keys 等 |
过滤属性列表 |
apply |
函数调用 | 函数包装、缓存、限流 |
construct |
new 操作 |
构造函数增强 |
getPrototypeOf |
读取原型 | 虚拟原型链 |
Reflect 为什么存在
Reflect 是一个内置对象,把原本散落在 Object、Function、Proxy 上的底层操作统一成函数形式。它的价值有三点:
- 统一返回值语义:
Object.defineProperty失败时抛错,Reflect.defineProperty返回布尔值。在 Proxy 的 trap 里,返回布尔值更符合规范要求。 - 统一 receiver 传递:
Reflect.get(target, key, receiver)可以正确传递this,这是实现响应式代理的关键。 - 与 Proxy 一一对应:每个 Proxy trap 都有一个同名的 Reflect 方法,写起来非常自然。
Reflect.get(obj, 'a');
Reflect.set(obj, 'a', 1);
Reflect.has(obj, 'a');
Reflect.deleteProperty(obj, 'a');
Reflect.ownKeys(obj);
Reflect.construct(Fn, args);
Reflect.apply(fn, thisArg, args);
Reflect.defineProperty(obj, key, desc);
Proxy 的性能与限制
- Proxy 的拦截是有开销的。高频访问的属性(例如渲染循环里每秒读取上千次的字段)套上 Proxy 会明显影响性能。
- Proxy 只能代理对象,不能代理原始值。
- 某些内置对象(如
Date、Map)的方法内部使用内部槽,直接代理后调用方法可能报错,需要手动绑定this。 - Proxy 无法拦截
===比较、typeof判断,也无法阻止对目标对象的直接访问。
Vue 3 之所以用 Proxy 取代 Vue 2 的 Object.defineProperty,正是因为 Proxy 能够拦截新增属性、删除属性、数组下标修改等原本“监听不到”的操作,同时实现更简洁。
十三、Promise:异步编程的基石
在 ES6 之前,异步流程靠回调函数嵌套,形成了著名的“回调地狱”。Promise 把异步操作抽象成一个表示未来结果的对象,让流程控制、错误处理、组合编排成为可能。
三种状态
基本用法与链式调用
setTimeout(() => {
const ok = Math.random() > 0.5;
ok ? resolve('成功') : reject(new Error('失败'));
}, 1000);
});
p
.then(res => {
console.log(res);
return fetchNext(); // 返回新的 Promise,继续链式
})
.then(next => console.log(next))
.catch(err => console.error(err)) // 捕获整条链路上的错误
.finally(() => hideLoading()); // 无论成败都执行
链式调用的关键规则只有一条:then 返回什么,下一个 then 就收到什么。如果返回的是 Promise,就会等待它 settle;如果返回普通值,就会立刻传给下一个 then。
四个组合方法
| 方法 | 行为 | 适用场景 |
|---|---|---|
Promise.all() |
全部成功才成功,任一失败立即失败 | 并发请求,缺一不可 |
Promise.allSettled() |
等待全部完成,永远成功,返回每个结果的状态 | 批量操作,允许部分失败 |
Promise.race() |
第一个 settle 的结果决定最终结果 | 超时控制 |
Promise.any() |
第一个成功即成功,全部失败才失败 | 多镜像源竞速 |
const [user, orders, cart] = await Promise.all([
fetchUser(),
fetchOrders(),
fetchCart()
]);
// 允许部分失败
const results = await Promise.allSettled(tasks);
const ok = results
.filter(r => r.status === 'fulfilled')
.map(r => r.value);
// 超时控制
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('超时')), ms)
);
return Promise.race([promise, timeout]);
}
五个最常见的 Promise 错误
.then(() => { fetchNext(); })内层 Promise 不会被等待,链式调用直接断裂。
.then(() => fetchNext())返回这个 Promise,链才会真正串联起来。
.then(a => { getB(a).then(b => ...) })又回到了回调地狱,外层错误也无法被捕获。
.then(a => getB(a)).then(b => ...)扁平化链路,错误自然向上冒泡。
- 忘记 catch:未处理的 rejection 会触发
unhandledrejection,在浏览器控制台报错,在 Node 中可能直接终止进程。 - 在循环里顺序 await:能并发的一定要并发,否则总耗时是所有请求之和。
- 误以为 then 是同步的:
then的回调永远在微任务队列中执行,不会同步运行。
微任务:理解执行顺序的钥匙
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
// 输出顺序:1 4 3 2
// 同步代码先执行完,然后清空微任务队列(Promise.then),
// 最后才执行宏任务(setTimeout)。
理解这个顺序,是排查“为什么我的状态更新晚了一帧”“为什么 loading 没有立刻消失”这类问题的前提。Promise 的回调是微任务,在当前同步代码执行完、浏览器渲染之前就会全部清空;而 setTimeout 是宏任务,要等到下一轮事件循环。
十四、Generator 与 async/await 的演进
async/await 是 ES2017 引入的语法糖,但它的思想源头正是 ES6 的生成器。把生成器和 Promise 结合,就能用同步的写法表达异步流程——这正是 async/await 的本质。
用生成器手写一个执行器
function run(genFn) {
return new Promise((resolve, reject) => {
const gen = genFn();
function step(fn) {
let result;
try {
result = fn();
} catch (e) {
return reject(e);
}
if (result.done) {
return resolve(result.value);
}
Promise.resolve(result.value).then(
v => step(() => gen.next(v)),
e => step(() => gen.throw(e))
);
}
step(() => gen.next());
});
}
// 使用:写法几乎和 async/await 一样
run(function* () {
const a = yield fetchA();
const b = yield fetchB(a);
return a + b;
});
理解了这段代码,async/await 就没什么神秘的了:async 函数就是一个自带执行器的生成器,await 相当于 yield 加自动等待 Promise。
async/await 的正确用法
async function loadProfile(userId) {
const user = await fetchUser(userId);
const avatar = await fetchAvatar(user.avatarId);
return { ...user, avatar };
}
// 并发:互不依赖,用 Promise.all 并行
async function loadDashboard() {
const [stats, notices, tasks] = await Promise.all([
fetchStats(),
fetchNotices(),
fetchTasks()
]);
return { stats, notices, tasks };
}
// 错误处理:用 try/catch 比 .catch 更直观
async function save(data) {
try {
const res = await api.post('/save', data);
return res;
} catch (err) {
if (err.code === 'NETWORK') {
await retry(3);
} else {
throw err; // 不处理的错误要继续抛出
}
} finally {
hideLoading();
}
}
三个高频性能陷阱
for (const id of ids) { await fetchOne(id); }10 个请求各 200ms,总共要 2 秒。
await Promise.all(ids.map(id => fetchOne(id)));总耗时约等于最慢的那个请求。
const a = await Promise.resolve(1);对非 Promise 值 await 会强制插入一次微任务,白白让出执行权。
await,其余保持同步。
串行请求但需要控制并发数
async function pool(tasks, limit = 3) {
const results = [];
const executing = [];
for (const [index, task] of tasks.entries()) {
const p = Promise.resolve()
.then(task)
.then(v => { results[index] = v; });
executing.push(p);
if (executing.length >= limit) {
await Promise.race(executing);
executing.splice(
executing.indexOf(await Promise.race(executing)),
1
);
}
}
await Promise.all(executing);
return results;
}
上面这个实现虽然能工作,但 Promise.race 被调用了两次,逻辑不够优雅。实际项目里更推荐使用成熟的并发控制库,或者用 p-limit 这类小工具。关键是意识到:并发不是越多越好,浏览器的同域连接数上限通常是 6,超出后请求会排队,此时控制并发反而能更快拿到首屏数据。
顶层 await
在 ES 模块中,可以直接在顶层使用 await,不需要包裹在 async 函数里:
const res = await fetch('/api/config');
export const config = await res.json();
// 其他模块 import 时会等待 config.js 执行完成
import { config } from './config.js';
它的前提是这个模块的依赖图里没有任何循环依赖,否则会形成死锁。这个特性极大简化了“模块级异步初始化”的写法。
十五、ES6 内置能力速查表
把前面零散提到的内容整理成一张表,方便日常开发时快速定位。
| 分类 | 新增能力 | 要点 |
|---|---|---|
| Number | isInteger / isNaN / isFinite |
不做隐式类型转换 |
| Number | EPSILON / MAX_SAFE_INTEGER |
浮点比较与安全整数边界 |
| Math | trunc / sign / cbrt / hypot |
补齐常用数学运算 |
| String | includes / startsWith / endsWith |
替代 indexOf 判断 |
| String | repeat / padStart / padEnd |
格式化对齐、补零 |
| String | trimStart / trimEnd |
单侧去空白 |
| Array | from / of |
构造与转换 |
| Array | find / findIndex / includes |
查找与判断 |
| Array | fill / copyWithin |
原地修改,注意引用共享 |
| Object | assign / is / keys / values / entries |
合并、比较、遍历 |
| Object | getOwnPropertySymbols / fromEntries |
Symbol 与键值对互转 |
| RegExp | u / y 标志 |
Unicode 模式与粘性匹配 |
| 函数 | name 属性、默认参数、rest |
更规范的行为 |
| 集合 | Map / Set / WeakMap / WeakSet |
更强大的数据结构 |
| 异步 | Promise / async / await |
流程编排的基础设施 |
| 元编程 | Proxy / Reflect / Symbol |
拦截与定制语言行为 |
正则的 u 与 y 标志
/^.$/u.test('中'); // true
/^.$/.test('中'); // false,不加 u 会把中文当成两个码元
// y:粘性匹配,从 lastIndex 处精确匹配
const re = /a/y;
re.lastIndex = 1;
re.test('ba'); // true,只在位置 1 处匹配
// 具名捕获组
const match = /(?<year>\d{4})-(?<month>\d{2})/.exec('2026-09');
match.groups.year; // '2026'
match.groups.month; // '09'
处理中文、emoji、以及其他非 BMP 字符时,加上 u 标志几乎是必须的。否则像 .、length 这类操作都会按 UTF-16 码元而不是字符来计算,导致各种诡异的结果。
十六、ES2016 到 ES2024:值得掌握的后续补充
ES6 之后,标准进入了每年小步快跑的模式。下面这些特性在日常开发中的出现频率极高,非常值得一并掌握。
**、Array.prototype.includes
async/await、Object.values/entries、padStart/padEnd、Object.getOwnPropertyDescriptors
Promise.finally、异步迭代 for await...of、具名捕获组、s 标志
Array.flat/flatMap、Object.fromEntries、trimStart/End、可选 catch 绑定
?.、空值合并 ??、BigInt、globalThis、Promise.allSettled、动态 import()
replaceAll、逻辑赋值运算符 ||= &&= ??=、Promise.any、数字分隔符
#、静态块、顶层 await、Array.at、Object.hasOwn、structuredClone
findLast / toSorted 等不可变方法、Array.fromAsync、Object.groupBy
日常出现频率最高的几个
const city = user?.address?.city;
const first = list?.[0];
const result = obj.method?.();
// 空值合并:只在 null / undefined 时取默认值
const count = input ?? 0;
// 注意与 || 的区别:0 和 '' 不会被 ?? 替换
// 逻辑赋值
options.timeout ||= 3000;
config.retry ??= 2;
flag &&= check();
// flat / flatMap
[1, [2, [3]]].flat(Infinity); // [1,2,3]
[1, 2].flatMap(x => [x, x * 2]);
// [1,2, 2,4]
// Object.hasOwn:替代 hasOwnProperty
Object.hasOwn(obj, 'key');
// structuredClone:原生深拷贝
const deep = structuredClone(obj);
// 不支持函数、Symbol、DOM 节点
// 数字分隔符
const billion = 1_000_000_000;
// replaceAll
'a-b-c'.replaceAll('-', '_'); // 'a_b_c'
可选链的三个注意事项
- 不要滥用。如果某个属性在业务上一定存在,写
?.反而会掩盖真正的数据问题,让错误静默变成undefined。 - 不能用于赋值左侧。
a?.b = 1是语法错误。 - 短路是整条链的。
a?.b.c中只要a为空,后面整个表达式直接返回undefined,不会再报错。
十七、工程实践:如何安全地在项目里用 ES6
编译策略
现代前端项目的标准做法是:源码全部使用最新的语法,构建时按 browserslist 配置自动降级。
{
"browserslist": [
"> 0.5%",
"last 2 versions",
"not dead",
"not ie 11"
]
}
// polyfill 采用按需注入,而不是全量引入
// 全量 core-js 会让包体积增加几十 KB
几个判断原则:
- 语法特性(箭头函数、解构、可选链)交给编译器处理,配置正确就不需要担心。
- 内置 API(Promise、Map、Array.from)需要 polyfill,按
useBuiltIns: 'usage'自动按需引入。 - 无法 polyfill 的特性(Proxy、Reflect 的部分能力)必须靠特性检测做降级。
性能相关的注意事项
| 写法 | 潜在问题 | 建议 |
|---|---|---|
{ ...bigObject } |
每次浅拷贝都创建新对象 | 大对象避免在热路径中频繁展开 |
[...largeArray] |
复制整个数组 | 只读场景直接传引用 |
Array.from(nodeList) |
创建新数组 | 只在需要数组方法时转换 |
深层 ?. 链 |
本身开销极小 | 主要注意可读性,别写太长 |
Proxy 包裹热对象 |
每次读写都有拦截开销 | 避免在高频渲染路径上使用 |
for...of 遍历大数组 |
迭代器协议有一定开销 | 性能敏感场景可用传统 for |
| 循环中创建箭头函数 | 每轮都分配新函数对象 | 把回调提到循环外 |
需要强调的是:上面这些“性能问题”绝大多数在真实项目里根本不构成瓶颈。真正影响性能的永远是网络请求、渲染规模、算法复杂度。先写清晰可读的代码,用性能分析工具定位到具体热点,再针对性优化——不要凭直觉提前优化。
代码风格建议
const,需要重新赋值时才用 let;用解构简化取值;用模板字符串替代拼接;用箭头函数处理回调。
?. 掩盖本该报错的数据缺失。
一个容易被忽视的实践:把 ES6 特性用在类型守卫上
function getType(value) {
return Object.prototype.toString
.call(value)
.slice(8, -1)
.toLowerCase();
}
getType([]); // 'array'
getType(new Map()); // 'map'
getType(null); // 'null'
getType(async function() {}); // 'asyncfunction'
// 比 typeof 更精确,比 instanceof 更跨环境安全
// (跨 iframe 时 instanceof 会失效)
这段小工具在写通用库、做参数校验、实现序列化逻辑时非常有用,值得放进你的工具箱。
十八、ES6 使用检查清单
把全文的结论浓缩成一份可以逐条核对的清单。
- 变量声明:默认
const,需要重新赋值才用let,新代码不再使用var。 - 理解 TDZ:知道
let/const在声明前访问会抛错,而不是得到undefined。 - const 的语义:清楚它锁的是绑定而不是值,需要真正不可变时使用
Object.freeze。 - 解构取值:函数参数用对象解构 + 默认值,接口数据用解构提取,并为可能为空的值留好兜底。
- 模板字符串:用
${}替代字符串拼接,注意对象插值需要显式序列化。 - 箭头函数:理解它的
this是词法作用域;不在对象方法、需要arguments的场景使用。 - rest 与 spread:知道它们是浅拷贝,深层嵌套仍需
structuredClone。 - class:理解它本质是原型继承的语法糖;继承时
super必须先调用。 - 模块化:使用 ESM 的静态
import/export;按需动态import()做懒加载。 - 迭代:数组用
for...of或数组方法,不用for...in。 - 集合选型:需要对象作为键用
Map;需要去重或快速存在性判断用Set;需要弱引用缓存用WeakMap。 - 异步:
async/await配合try/catch;互不依赖的请求用Promise.all并发。 - 错误处理:每个
Promise链都要有catch或try/catch,不留下未处理的 rejection。 - polyfill:区分语法转换与运行时补齐,按
browserslist配置按需注入。 - 性能意识:知道浅拷贝、Proxy 拦截、循环内创建函数的开销,但不做无依据的提前优化。
- 可读性优先:语法糖是为了表达意图,不是为了炫技;一层解构能说清的事,不要写三层。
// 1. 模块与导出
import { request } from './http.js';
// 2. 常量与配置
const DEFAULTS = Object.freeze({
timeout: 5000,
retry: 2,
baseURL: '/api'
});
// 3. 带默认值的解构参数
export async function fetchList({
page = 1,
size = 20,
keyword = '',
...rest
} = {}) {
const params = { page, size, keyword, ...rest };
try {
const { data, total } = await request(
`${DEFAULTS.baseURL}/list`,
{ params, timeout: DEFAULTS.timeout }
);
return { list: data ?? [], total: total ?? 0 };
} catch (err) {
console.error(`${err.message}`);
return { list: [], total: 0 };
}
}
// 4. 类与私有字段
export class Store {
#listeners = new Set();
#state = new Map();
subscribe(fn) {
this.#listeners.add(fn);
return () => this.#listeners.delete(fn);
}
set(key, value) {
this.#state.set(key, value);
this.#emit();
}
#emit() {
const snapshot = Object.fromEntries(this.#state);
this.#listeners.forEach(fn => fn(snapshot));
}
}
ES6 真正改变的,不是某个具体语法,而是我们组织代码的方式。块级作用域让变量的生命周期变得可控,模块系统让依赖关系变得清晰,Promise 与 async/await 让异步流程变得可以阅读,解构与展开让数据搬运变得简洁。这些能力组合在一起,才有了今天前端工程化的基础。
但也要记住另一面:语法糖只是表达工具,不是思考的替代品。一个把 ?. 用到无处不在的代码库,和一个把 Promise 链写得九曲十八弯的函数,并不会比老实写 if 判断的代码更好维护。真正的进步,是知道在什么场景下用哪个特性,以及为什么。
如果这篇文章只能留下一个建议,那大概是:把 ES6 的每个特性都亲手写一遍,再想一想它在规范层面到底做了什么。当你知道 let 为什么能解决循环闭包、await 底层等价于什么、Proxy 拦截了哪些内部操作时,那些曾经“背下来的结论”,才会真正变成你的判断力。