
这几年不管面试候选人还是做团队 code review我都格外留意大家对 ES6 核心语法的掌握程度。不是故意挑刺而是 ES6 这个版本几乎成了现代前端开发的底层语言项目里到处是 let/const、箭头函数、解构赋值、Promise面试题也全围在它身边转。但说实话多数人的问题不是不会用而是停留在换个写法的层面不清楚每个语法背后的设计动机和边界条件。这篇是系列的第二篇我按面试高频和开发高频两条线把 ES6 核心语法完整梳理一遍。如果你在准备前端面试、工作两三年想回头补基础或者要给团队做内部技术分享这篇都可以直接拿去用。1. 声明方式的进化let/const 解决的远不止变量提升ES6 最先改变的就是声明方式。新语法出现一定是对旧语法有不满。var 最大的问题有两个函数作用域不够块以及变量提升带来的直觉偏差。理解这两个点才算真正理解为什么团队规范里统一要求默认用 const需要重新赋值再用 let。1.1 var 的历史包袱函数作用域与变量提升先看一个所有前端都见过的经典面试题for (var i 0; i 5; i) { setTimeout(() console.log(i), 0); } // 输出5 5 5 5 5很多新人第一反应是输出 0 1 2 3 4结果跑出来全是 5。原因是 var 声明没有块级作用域只有函数作用域。for 循环里的 var i 实际上是循环所属函数的同一个变量setTimeout 回调执行时循环早已结束此时 i 已经变成 5。每轮循环都在往任务队列里塞一个回调这些回调共享同一个 i打印的自然全是 5。换成 let 后每轮循环都会生成一个独立的绑定回调闭包捕获的是当前轮的 i输出就变成了 0 1 2 3 4。这个现象在事件委托、循环里绑定监听器、批量请求等场景里非常常见属于必须理解的底层行为。变量提升同样来自这个设计。var 声明会被提升到函数顶部但赋值不会console.log(a); // undefined而不是报错 var a 1;这里的 undefined 其实是个陷阱容易让人误以为变量存在但没有值掩盖了声明位置靠后这个客观事实。let 和 const 正是针对这两个问题设计的作用域收窄到块级别花括号内声明前访问则直接抛出 ReferenceError。1.2 暂时性死区TDZ究竟是哪段死了暂时性死区Temporal Dead ZoneTDZ听起来唬人其实一句话就能说清楚let/const 声明的变量在进入作用域那一刻就已经被创建但在执行到声明语句之前处于不可访问状态这段区间就是 TDZ。console.log(x); // ReferenceError: Cannot access x before initialization let x 10;值得注意的是即使使用 typeof 也会抛错typeof y; // ReferenceError let y 1;而 typeof 一个完全没有声明过的变量不会报错只会返回 undefined。这两个行为的差异就是判断变量是否进入 TDZ 的最直观方法。我初学 ES6 时在这个坑上栽过一次代码里有跨文件的全局变量引用某个文件在上游执行时先访问了另一个文件里下一步才初始化的变量排查了很久才发现是 TDZ 问题。所以写完代码如果看到访问某个 let/const 变量报错第一反应不应该是变量不存在而是变量是否还在 TDZ 里。1.3 const 限制的到底是什么const 的关键词是不可重新赋值不是值不可变。很多面试会问const 定义的对象能不能修改属性答案是能const obj { name: tom }; obj.name jerry; // 合法对象本身没有被重新赋值 obj {}; // TypeError: Assignment to constant variableconst 锁住的是变量名和内存地址之间的绑定关系而不是绑定的那个对象内部的属性。这个语义和 C 里的 const 指针、Java 里的 final 引用不完全一样表达概念时最好说清楚引用不可变对象可被修改。如果希望对象本身也冻结需要用 Object.freeze()不过它是浅冻结嵌套对象依然可以改。日常编码中我的习惯是默认 const确实需要重新赋值的变量才用 letvar 基本不碰。这个习惯能让代码的可读性和可维护性上一个台阶也会减少很多无谓的作用域 bug。2. 解构赋值与展开运算符数据操作的最小单元解构是 ES6 里性价比最高的特性之一代码量明显变少逻辑也更清晰。展开运算符则常和解构配套使用但很多人没有意识到它只是浅拷贝从而在深拷贝相关场景里踩坑。2.1 数组解构的实用套路最简单的用法是把数组元素按位置取出来const [first, second] [a, b, c]; // first a, second b有几个看起来不起眼但很好用的场景。交换两个变量不需要临时变量了let a 1, b 2; [a, b] [b, a];跳过数组中间的元素用空位占位const [, , third] [1, 2, 3]; // third 3解构赋值的默认值只在被解构位置的值为 undefined 时生效null 不会被默认值覆盖const [x 0, y 0] [null, undefined]; // x nully 0这一点很多人记错。解构本质是按位置的模式匹配默认值相当于兜底方案只在取不到有效值undefined时启用。2.2 对象解构的重命名与嵌套技巧对象解构按属性名匹配不受顺序影响const user { name: Alice, age: 18 }; const { name, age } user;更常见的是重命名。比如接口返回的字段是 user_name前端想用 userName可以直接在解构时改const { user_name: userName, age } { user_name: Alice, age: 18 }; // userName Alice这里的语法容易迷相当于要从对象里取的原始键名新变量名。如果还要给默认值就写成这样const { user_name: userName default } {};嵌套解构从深层对象里取值也很直观但可读性不要超过两层。太深了宁可用可选链或者工具函数去取值否则嵌套层级太多反而不如不拆。实际开发里我一般把对象解构用在接口数据清洗上从后端返回的一坨字段里精准挑出要用的几个比逐个 obj.xxx 赋值干净太多。2.3 展开运算符的浅拷贝边界展开运算符有三个主要用途展开数组、展开对象、收集剩余参数。前两种写法很简单const arr [1, 2, 3]; const copy [...arr]; const obj { a: 1, b: 2 }; const objCopy { ...obj };新手最容易忽略的是这只能做一层浅拷贝。数组里的元素如果是对象拷贝后依然共享引用const team [{ name: tom }, { name: jerry }]; const teamClone [...team]; teamClone[0].name spike; // team[0].name 也变成了 spike这个点的面试变体一般是展开运算符算深拷贝还是浅拷贝答案非常明确浅拷贝。需要真正独立的拷贝对象时后面我会专门讲深拷贝方案这里先记住一层这个边界就行。2.4 剩余参数与 arguments 的关键差异剩余参数语法长得像展开但作用相反展开是把数组/对象展开剩余是把剩余的参数收集成一个数组function sum(base, ...nums) { return nums.reduce((acc, n) acc n, base); } sum(1, 2, 3, 4); // 10它和 function 内部的 arguments 有本质区别arguments 是类数组对象要用 Array.from 之类的方式才能转成真数组剩余参数本身就是真数组可以直接用数组方法。arguments 包含所有参数剩余参数只收集未被具名参数接收的部分。箭头函数没有自己的 arguments如果要在箭头函数里拿参数只能靠剩余参数。团队代码规范里一般也建议统一用剩余参数少用 arguments。3. 箭头函数与函数参数增强this 规则的重新定义函数是 JavaScript 的核心ES6 对函数的改动是带方向性的箭头函数解决了 this 指向问题但也被很多人滥用默认参数、解构参数补齐了函数签名的表达能力。3.1 箭头函数的 this 是定义时决定的普通函数的 this 在调用时确定谁调它 this 指向谁。箭头函数完全不同它的 this 在定义时就固定了取自外层作用域这个特性在回调场景里极其实用。在 React 类组件时代这个区别是面试必考。下面这个例子在普通函数里就会翻车class Counter extends React.Component { handleClick() { console.log(this); } render() { return button onClick{this.handleClick}click/button; } }事件回调执行时handleClick 被 React 内部调用this 不再指向组件实例所以需要 bind 或者把方法写成箭头函数。箭头函数的 this 是从外围作用域继承的组件实例在创建时就把 this 绑定好了事件回调里自然拿得到组件实例。Vue 里也有类似现象比如在 methods 里把函数传给 setTimeout普通函数里的 this 会丢箭头函数就不会。记住一句口诀普通函数看调用点箭头函数看定义点。3.2 箭头函数不能做的三件事第一不能当作构造函数。箭头函数没有自己的 prototype 属性用 new 调用会直接抛 TypeError。原理上new 需要让新对象继承构造函数的 prototype还要把 this 绑定到新对象上箭头函数这两点都不支持。第二没有 arguments 对象。箭头函数内部访问 arguments 会取到外层作用域的 arguments如果存在很容易造成诡异 bug。第三不能作为生成器函数。语法上不支持 function* 与箭头函数结合。如果你想写一个生成器方法必须用普通 function 写法。这些限制反过来也说明它适合什么场景适合回调函数、事件处理、数组高阶函数里传参不适合做对象方法、构造函数、需要动态 this 的场景。比如在对象里定义一个方法如果用箭头函数this 不会指向对象本身const counter { count: 0, handleClick: () { this.count; // this 是外层作用域的不是 counter } };这种看着像方法实际不是方法的写法是团队 code review 里最常见的问题之一。3.3 默认参数与解构参数的搭配写法默认参数不是 ES6 首创但 ES6 把它规范化了function request(url, method GET) { console.log(url, method); } request(/api/user); // /api/user GET几个细节值得注意默认值只在传入 undefined 时生效传 null 会覆盖默认值默认值可以是一个表达式而且每次函数调用都会重新求值参数列表里的默认参数会影响 arguments 对象的行为严格模式下会更明显。解构参数搭配默认值是封装配置型函数的好姿势function fetchData({ url /api, method GET, params {} } {}) {} fetchData({ url: /api/user, params: { id: 1 } });这里的 {} 是给整个参数对象一个默认值防止调用 fetchData() 不传任何参数时直接解构 undefined 而报错。如果接口函数有多个可选配置这种写法比 arguments 硬解析清晰太多。4. Promise 与 async/await从回调地狱到可读的异步代码异步编程是前端绕不开的核心场景。ES6 引入的 Promise 和 ES2017 的 async/await 解决了回调金字塔问题。这部分面试考得多实际开发里也最容易写错。4.1 Promise 的状态机与链式调用原理Promise 本质是一个带状态的对象三个状态pending进行中、fulfilled已成功、rejected已失败。状态只能从 pending 变成 fulfilled 或 rejected一旦改变就不可逆转不可能再回到 pending。const p new Promise((resolve, reject) { // 异步操作成功后 resolve(value)失败后 reject(reason) });then 方法接收两个回调分别处理 fulfilled 和 rejected返回值是一个新的 Promise。这是链式调用的基础fetchUser() .then(user fetchProfile(user.id)) .then(profile console.log(profile)) .catch(err console.error(err));关键点在于then 回调里 return 一个值会被 Promise 包装return 一个 Promise后续的 then 会等待它如果回调抛异常后续的 catch 能捕获到。我见过不少初级同事在 then 里直接写业务逻辑完全不管返回值是什么结果链式调用就断掉了。判断一条 Promise 链是否完整只需要看每一层有没有 return。并发场景下ES6 提供 Promise.all 和 Promise.race后续又补充了 allSettled、any。四个的差别我用一张表记API行为典型场景Promise.all全部成功才返回结果数组一个失败立即失败多个依赖都完成后再渲染Promise.race第一个完成无论成败的状态决定结果超时控制Promise.allSettled不管成败等全部结束后返回每个结果批量请求失败不影响其他Promise.any第一个成功的结果全部失败才进入失败态多源备用4.2 async/await 的本质是语法糖async 函数声明一个函数总是返回 Promiseawait 放在 Promise 前面会暂停函数执行等这个 Promise 落定后拿到 resolved 的值如果是 rejected则在该行抛异常。所以 try/catch 就是对 Promise.catch 的等价写法async function loadData() { try { const data await fetchData(); console.log(data); } catch (err) { console.error(err); } }这样写最大的好处是异步代码读起来和同步代码一样直没有一层层回调嵌套。但它有个反直觉的陷阱await 是串行的。如果两件事没有依赖关系却写成连续两个 await总耗时等于两段请求时间相加const user await getUser(); // 耗时 300ms const list await getList(); // 耗时 400ms // 总共 700ms改成 Promise.all 并发总耗时就变成较大的那个const [user, list] await Promise.all([getUser(), getList()]); // 总共约 400ms这一点在性能分析里经常被忽略尤其是多个接口拖慢页面加载的时候先把串行 await 检查一遍再考虑加缓存。4.3 实际业务里的异步细节竞态、取消与内存泄漏工作里遇到最多的异步问题有三个。第一个是竞态。用户快速切换 Tab、快速触发搜索后发出的请求可能先回来覆盖掉先发请求的结果。经典修复方案是用一个序号做判断let seq 0; async function search(keyword) { const current seq; const result await fetchSearch(keyword); if (current seq) { render(result); // 只有最新的请求才有资格渲染 } }第二个是取消请求。fetch 提供了 AbortController可以在离开页面、组件卸载时调用 abort() 中断请求const controller new AbortController(); fetch(/api/xxx, { signal: controller.signal }); // 组件卸载或用户取消时 controller.abort();第三个是内存泄漏。最常见的是全局定时器和事件监听没有清理setInterval 里不判断组件是否卸载addEventListener 加了匿名函数导致 removeEventListener 永远匹配不到Promise 回调里引用了大对象但在页面卸载后还在执行。5. Set、Map 与数组方法数据操作与去重的正确姿势ES6 引入的新数据结构 Set 和 Map以及一批数组新方法直接影响日常数据操作的写法。这些内容面试难度不高但实际用的频率极高。5.1 Set 去重与集合运算Set 是一个不重复的集合add、has、delete、size 是主要 API。最经典的用法是数组去重const arr [1, 2, 2, 3, 3, 3]; const unique [...new Set(arr)]; // [1, 2, 3]这里展开运算符的作用是把 Set 转回数组。需要注意 Set 的去重规则不是严格等于而是同值零SameValueZero算法NaN 与 NaN 视为相等但两个空对象依然不相等因为它们是不同的引用new Set([NaN, NaN]); // 只有一个 NaN new Set([{}, {}]); // 有两个对象两个数组做交集、并集、差集用 Set 配合 filter 和 has 就可以const a [1, 2, 3]; const b [2, 3, 4]; const union [...new Set([...a, ...b])]; // [1, 2, 3, 4] const intersect a.filter(x new Set(b).has(x)); // [2, 3] const difference a.filter(x !new Set(b).has(x)); // [1]大数组场景下用 Set.has 做存在性判断比数组 includes 快很多因为 Set 底层是哈希结构查找是 O(1)而数组 includes 是线性查找 O(n)。做权限过滤、批量 ID 判定这种频繁查找时这个性能差异非常明显。5.2 Map 对比 Object什么时候该用谁Map 是键值对集合最大的特点是键可以是任意类型包括对象、函数、NaNconst m new Map(); m.set({ id: 1 }, user); m.set(key, value); m.get(key); // value m.has(key); // true m.size; // 2它的遍历顺序是插入顺序而且有方便的 size、forEach、clear 方法这些在 Object 上要么没有要么很别扭。什么时候用 Map我的判断标准是键不是普通字符串、需要频繁增删键值对、需要按插入顺序遍历、键的数量需要动态获取这些场景优先 Map。什么时候继续用 Object有固定的数据结构、需要 JSON 序列化、或者想利用对象上的语法糖时Object 更方便。特别要注意Map 和对象之间互转需要手动处理没有 JSON.stringify(Map) 这种直接方案。顺带提一下 WeakMap它的键只能是对象而且是弱引用不阻止垃圾回收。最常见的用途是做私有数据或内存敏感的数据关联。比如给 DOM 节点挂数据用 WeakMap 存节点被移除后数据会自动被回收不会造成泄漏。5.3 数组方法增强find、includes、flat 与 Array.fromES6 及后续版本给数组补了很多实用方法。find 返回第一个满足条件的元素findIndex 返回下标它们比 filter 更适合只找第一个的场景因为找到后就会停止遍历避免多余循环const users [{ id: 1, name: a }, { id: 2, name: b }]; const user users.find(u u.id 2); // { id: 2, name: b }includes 判断数组是否包含某个值它能正确识别 NaN而 indexOf 识别不了[NaN].includes(NaN); // true [NaN].indexOf(NaN); // -1flat 和 flatMap 用于扁平化嵌套数组[1, [2, [3]]].flat(2); // [1, 2, 3] [a, b].flatMap(x [x, x.toUpperCase()]); // [a, A, b, B]Array.from 可以把类数组转成真数组并且可以传映射函数Array.from({ length: 3 }, (_, i) i 1); // [1, 2, 3]这个技巧在路由配置、分页生成数组时很常见。上面这些方法配合箭头函数写出来的数据操作代码比 ES5 时代简洁非常多也是现在函数式风格前端代码的基础设施。6. Class 语法、模块化与深拷贝工程能力的三块基石最后一块内容把三个工程上必须掌握的点放在一起讲Class 只是原型链的语法糖模块化是 ES6 工程化的标志深拷贝则是面试和实战都会反复碰到的高频问题。6.1 class原型链的语法糖ES6 的 class 并没有引入新的面向对象模型它的底层依然是原型链只是把 constructor、继承、super 这些写法整理得更清晰了class Animal { constructor(name) { this.name name; } speak() { console.log(${this.name} makes a sound); } static kind() { return animal; } } class Dog extends Animal { constructor(name) { super(name); // 必须先调用 super 再访问 this } speak() { super.speak(); console.log(${this.name} barks); } }几个关键点子类构造函数里必须先调用 super()否则无法拿到 this这是面试常问的细节static 方法定义在构造器本身而不是实例上调用方式是 Dog.kind() 而不是实例.kind()extends 支持继承构造函数和原型上的方法。ES2022 还支持了私有字段用 # 前缀声明外部访问会直接报错class Counter { #count 0; increment() { this.#count; } }这比用闭包或者 WeakMap 模拟私有变量直观多了也不用担心属性被外部误改。6.2 模块化export 与 import 的标准姿势ES6 模块化让前端代码第一次有了官方层面的模块方案。一个文件就是一个模块显式导出什么外部才能导入什么// utils.js export const version 1.0.0; export function formatTime(ts) { return new Date(ts).toLocaleString(); } export default class Logger {}// main.js import Logger, { version, formatTime } from ./utils.js;默认导出export default只能有一个命名导出可以有很多。默认导出在 re-export 时比较灵活命名导出则利于 IDE 的自动提示和 tree-shaking所以新项目更推荐组件库和工具库优先用命名导出。动态 import() 返回一个 Promise可以实现按需加载const { default: Chart } await import(./chart.js);这在首屏体积优化里非常常见路由组件懒加载就是这么做的。模块化的真正价值不只是组织代码更重要的是它强制你思考这个文件对外暴露什么接口间接提高代码的可维护性。6.3 深拷贝从 JSON.stringify 到手写实践深拷贝是面试里的常青树实际开发里也非常常用。最省事的方案是const copy JSON.parse(JSON.stringify(obj));它能处理大多数普通对象和数组但有几个致命的坑数据类型JSON.stringify 的结果undefined、函数、Symbol直接丢失或变为 nullDate变成字符串RegExp变成空对象 {}NaN、Infinity变成 null循环引用直接抛错比如const obj { a: 1, date: new Date(), fn: () {}, x: undefined }; JSON.parse(JSON.stringify(obj)); // { a: 1, date: 2025-... }fn 和 x 消失了如果要处理这些边界情况就得手写或者用工具库。手写深拷贝的核心有两点递归复制 用 WeakMap 解决循环引用function deepClone(value, cache new WeakMap()) { if (value null || typeof value ! object) return value; if (value instanceof Date) return new Date(value); if (value instanceof RegExp) return new RegExp(value.source, value.flags); if (value instanceof Map) { const map new Map(); cache.set(value, map); value.forEach((val, key) map.set(key, deepClone(val, cache))); return map; } if (value instanceof Set) { const set new Set(); cache.set(value, set); value.forEach(item set.add(deepClone(item, cache))); return set; } if (cache.has(value)) return cache.get(value); const cloned Array.isArray(value) ? [] : {}; cache.set(value, cloned); Reflect.ownKeys(value).forEach(key { cloned[key] deepClone(value[key], cache); }); return cloned; }这个版本的完整程度应付面试足够了。实际项目里我通常分情况数据结构安全简单就直接用 JSON 方案包含 Date、RegExp、Map、Set 这些类型就推荐用 structuredClone现代浏览器直接支持老项目或需求灵活就引入 lodash.cloneDeep。不要为了手写一次而手写工具库在边界情况上维护得比个人方案完整得多。我自己的习惯是ES6 的每个特性先问三个问题——它解决什么问题、它有哪些边界、写错了会发生什么。如果你能把 let 的 TDZ、箭头函数的 this、Promise 的状态流转、Map 和 Object 的选择这些点都用大白话讲给身边的人听那说明这批语法就真正长在你自己身上了。希望这篇梳理能帮你把知识点连成网而不是只记住零散的写法。