ES6新特性详解

张玥 2026年9月19日 阅读时间 30分钟
ES6 JavaScript 异步编程 模块化 最佳实践
前端开发技巧之ES6新特性详解

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),任何特性都必须走完才能进入当年的标准版本。理解这个流程,能帮你判断“这个语法现在能不能上生产”。

1
Stage 1
提案
问题被明确定义,给出初步的 API 形态,指定负责人,进入 TC39 正式讨论。
2
Stage 2
草案
写出正式的规范草案,语法与语义基本确定,此时引擎可能开始试验性实现。
3
Stage 3
候选
规范文本完成,等待实现反馈与用户测试。浏览器通常会在此阶段开始落地。
4
Stage 4
完成
至少两个独立实现通过测试,正式并入下一版 ECMAScript 标准。

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 块、甚至单独一对 {} 都能形成作用域。

if (true) {
  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 能解决闭包问题

这是面试里最经典的一道题,也是理解块级作用域的最佳入口。

for (var i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log(i);
  }, 0);
}

// 输出:3 3 3
// 原因:var 只有一个 i,三个回调共享同一个变量;
// 循环结束时 i 已经变成 3,回调才真正执行。

规范里的说法是:for 语句中的 let 会在每次循环迭代开始时创建一个新的词法环境,并把上一轮的值复制进去。这不是编译器的小把戏,而是语言层面明确规定的语义。

const 不是“值不可变”

这是另一个高频误区。const 保证的是绑定不可重新赋值,而不是值本身不可变。

const user = { name: '张玥' };
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 的原因之一。

三、解构赋值:从数据结构里优雅取值

解构赋值让“从数组或对象中取出若干个值”这件事,从多行样板代码变成了一行声明。它的本质是模式匹配:左边写形状,右边提供数据,只要形状对应,值就会被自动填入。

数组解构

const [first, second] = [1, 2, 3];
// 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、'' 都不会触发默认值。这个规则在对象解构里同样成立。

对象解构

const user = {
  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: {...} }

函数参数解构

当函数接收一个配置对象时,解构能让签名变得非常清晰,还自带默认值:

// 改造前:一堆 options.xxx
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 完全一致。

四、模板字符串与标签模板

模板字符串用反引号 ` 包裹,解决了字符串拼接时代的两个痛点:多行文本和变量插值。

三个基础能力

// 1. 插值:${} 里可以放任意表达式
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 或取具体字段。

标签模板:被严重低估的能力

在模板字符串前面加一个函数名,就变成了标签模板。这个函数接收两部分参数:一个字符串数组(模板的静态片段),以及所有插值。这让你可以拦截并加工字符串的构建过程。

function highlight(strings, ...values) {
  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 时生效。

// 旧写法:传 0 会被替换成 10
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,也不触发默认值

默认值可以是表达式,并且在每次调用时重新求值:

function log(msg, time = new Date()) {
  console.log(`[${time.toISOString()}] ${msg}`);
}

// 默认值可以引用前面的参数
function rect(width, height = width) {
  return width * height; // 正方形时只传一个参数
}

一个重要的细节:参数列表拥有自己的作用域。一旦使用了默认值、rest 或解构参数,函数体内部就处于“参数作用域”的子作用域中,函数体内的 let 声明不能与参数同名,否则会报语法错误。

rest 参数与 spread 展开

两者语法都是 ...,但方向相反:rest 负责收集,spread 负责展开。

// rest:把不定数量的实参收进一个真正的数组
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(词法作用域)。

const counter = {
  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; } };
需要动态 this 的场景
Vue 的 methods、jQuery 的回调、需要 arguments 的函数。
最适合的场景
数组方法回调、Promise 链、定时器、需要保持外层 this 的任何回调。

函数 name 属性

ES6 补齐了 Function.prototype.name 的行为,让匿名函数也能有一个合理名字,这对调试和栈追踪帮助很大:

const fn1 = function () {};
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 name = '张玥';
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:更严格的相等判断
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 的第二个参数相当于 map
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
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 并没有引入新的对象模型,它只是把已有的原型继承包装成了更易读的语法。理解这一点,才能明白它为什么有那些“看起来奇怪”的限制。

基本写法

class Animal {
  // 实例字段(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

class Dog extends Animal {
  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 最后一块短板:真正的私有成员。

class Counter {
  // # 开头的字段是真正的私有,外部完全访问不到
  #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 的三大特点是:静态结构、严格模式、单例作用域。

基本语法

// ===== math.js =====
// 命名导出
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":

<script type="module" src="./main.js"></script>

<!-- 模块脚本默认 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 的区别

for...of 遍历值,要求对象可迭代,支持 break / continue / return
for...in 遍历可枚举属性名,会沿原型链向上查找,不适合数组
陷阱 对数组用 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 } 对象。

function* range(start, end, step = 1) {
  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 出现之前实现“同步写法处理异步流程”。今天它更多用于:惰性序列、无限序列、自定义迭代器、以及需要暂停执行的复杂状态机。

// yield* 委托给另一个可迭代对象
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 s1 = Symbol('id');
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 指定派生对象的构造函数
// 定制 instanceof
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 序列化 需要手动转换 直接支持
// 用对象作为键:Object 做不到,Map 可以
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 引用,垃圾回收器依然可以回收它。

// 给 DOM 节点挂载元数据,节点移除后自动释放
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()。这些限制正是实现“弱引用”所必需的——如果允许枚举,就无法保证回收时机。

WeakMap 私有数据容器、DOM 节点元数据、对象缓存
WeakSet 标记“已处理过的对象”,避免重复处理且不阻止回收
Map 需要有序、需要 size、键可能是原始值时的键值存储
Set 去重、快速存在性判断、集合运算

十二、Proxy 与 Reflect:元编程的入口

Proxy 可以拦截对象上的基本操作,Reflect 则把这些操作以函数的形式暴露出来。两者是 Vue 3 响应式系统的技术基石。

基本用法

const target = { name: '张玥', age: 30 };

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 常用方法
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 把异步操作抽象成一个表示未来结果的对象,让流程控制、错误处理、组合编排成为可能。

三种状态

pending 进行中,既未成功也未失败
fulfilled 已成功,携带一个值
rejected 已失败,携带一个原因
不可逆 状态一旦改变就不能再变,之后再调用 resolve/reject 都会被忽略

基本用法与链式调用

const p = new Promise((resolve, reject) => {
  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 错误

忘记 return
.then(() => { fetchNext(); })
内层 Promise 不会被等待,链式调用直接断裂。
正确
.then(() => fetchNext())
返回这个 Promise,链才会真正串联起来。
嵌套 then 而不是链式
.then(a => { getB(a).then(b => ...) })
又回到了回调地狱,外层错误也无法被捕获。
正确
.then(a => getB(a)).then(b => ...)
扁平化链路,错误自然向上冒泡。
  • 忘记 catch:未处理的 rejection 会触发 unhandledrejection,在浏览器控制台报错,在 Node 中可能直接终止进程。
  • 在循环里顺序 await:能并发的一定要并发,否则总耗时是所有请求之和。
  • 误以为 then 是同步的:then 的回调永远在微任务队列中执行,不会同步运行。

微任务:理解执行顺序的钥匙

console.log(1);

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 的本质。

用生成器手写一个执行器

// 一个极简的 co 实现,帮助理解 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();
  }
}

三个高频性能陷阱

循环中顺序 await
for (const id of ids) { await fetchOne(id); }
10 个请求各 200ms,总共要 2 秒。
并发写法
await Promise.all(ids.map(id => fetchOne(id)));
总耗时约等于最慢的那个请求。
同步流程里滥用 await
const a = await Promise.resolve(1);
对非 Promise 值 await 会强制插入一次微任务,白白让出执行权。
正确
只在真正需要等待异步结果时使用 await,其余保持同步。

串行请求但需要控制并发数

// 一次最多并发 limit 个请求
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 函数里:

// config.js
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:正确处理 Unicode,尤其对中文、emoji 很重要
/^.$/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 之后,标准进入了每年小步快跑的模式。下面这些特性在日常开发中的出现频率极高,非常值得一并掌握。

ES2016 指数运算符 **、Array.prototype.includes
ES2017 async/await、Object.values/entries、padStart/padEnd、Object.getOwnPropertyDescriptors
ES2018 对象 rest/spread、Promise.finally、异步迭代 for await...of、具名捕获组、s 标志
ES2019 Array.flat/flatMap、Object.fromEntries、trimStart/End、可选 catch 绑定
ES2020 可选链 ?.、空值合并 ??、BigInt、globalThis、Promise.allSettled、动态 import()
ES2021 replaceAll、逻辑赋值运算符 ||= &&= ??=、Promise.any、数字分隔符
ES2022 类字段、私有成员 #、静态块、顶层 await、Array.at、Object.hasOwn、structuredClone
ES2023+ 数组的 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 配置自动降级。

// package.json
{
  "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”而堆砌语法糖;一行里套三层解构加重命名;用 ?. 掩盖本该报错的数据缺失。

一个容易被忽视的实践:把 ES6 特性用在类型守卫上

// 用 Symbol.toStringTag 做可靠的类型判断
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 拦截、循环内创建函数的开销,但不做无依据的提前优化。
  • 可读性优先:语法糖是为了表达意图,不是为了炫技;一层解构能说清的事,不要写三层。
/* 一份可直接复用的 ES6+ 代码基线 */

// 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 拦截了哪些内部操作时,那些曾经“背下来的结论”,才会真正变成你的判断力。