Skip to main content
ES6 这块我按”能解释为什么”来记:let 的词法环境、Map 和 Object 的取舍、?? 与 || 的边界,比报菜名有用。

Q1: let/const/var 的区别?const 的值真的不能改吗?

1

ES5 时代: var 的设计缺陷

var 是函数作用域,变量提升后初始化为 undefined,容易掩盖 Bug。没有块级作用域,for 循环中的变量会泄漏到外部。
2

致命痛点: 变量提升导致隐蔽 Bug

console.log(a); var a = 1; 不报错,输出 undefined。你在不知情的情况下使用了未初始化的变量。
3

ES6 引入 let/const

let 和 const 是块级作用域,有暂时性死区(TDZ),不能重复声明。声明前访问会报错,比 var 的静默失败更安全。
4

核心机制: V8 的双环境设计

ES6 后 V8 将执行上下文拆分为”词法环境”(存放 let/const)和”变量环境”(存放 var)。let 受控于词法环境栈的推入与弹出,完美隔绝块级作用域。
5

关键洞察: const 保证的是引用不变

const 只保证引用不变,不保证值不变。const obj = {}; obj.name = 'Tom' 是合法的。
let/const/var 解决的核心问题:
let/const 用块级作用域和 TDZ 解决了 var 的设计缺陷,const 只保证引用不变。

var 的半吊子提升

var 声明会被提升,并且在创建阶段就被强行初始化为 undefined。
这种设计极易掩盖 Bug。

let/const 与 TDZ

let 和 const 同样会被提升!但在执行到声明行之前,它们处于未初始化状态(TDZ)。此时访问会直接报错。
报错比静默失败更安全。
var 就像一份租房合同——签完合同(声明)的瞬间,房子就是你的了(初始化为 undefined),你可以随时进去住(访问),虽然里面可能还是空的。let/const 就像买期房——虽然你已经签了购房合同(提升),但在交房日(声明语句)之前,你拿不到钥匙,硬闯进去就是违法(TDZ 报错)。而 const 是产权证——房子归你了,但你只能装修(修改属性),不能把房子拆了重建(重新赋值)。
很多人以为 let 不会提升,这是错误的。let 和 const 也会提升,但不会初始化。在声明语句之前访问会进入”暂时性死区”(TDZ),抛出 ReferenceError。

Q2: 箭头函数和普通函数有什么区别?

基础梳理

专家视角深度剖析

箭头函数和普通函数的核心区别是 this 的来源不同。以下是经典输出题的图解分析:

箭头函数输出题图解

V8 中箭头函数的 this 实现

箭头函数在 V8 中被编译时,其 [[ThisMode]] 被标记为 kLexical。当引擎准备为它解析 this 时,一旦发现是 kLexical,会直接跳过运行时动态绑定阶段,强行去读取闭包上下文 Context 中的 this。这就是为什么 call/apply 也无法扭转箭头函数的 this。

箭头函数不能作为构造函数

箭头函数没有 prototype 属性,也没有内部的 [[Construct]] 方法。使用 new 调用箭头函数会直接抛出 TypeError。

箭头函数的实际应用场景

箭头函数最适合用于回调函数中需要继承外层 this 的场景,最典型的例子是数组方法和定时器回调。
普通函数的 this 就像租房——谁签的合同(谁调用),房子就归谁。你把钥匙(函数引用)给别人,别人就成了新的租户。箭头函数的 this 就像自建房——从出生起就被钉死在出生地(定义时的外层作用域),无论谁来住(怎么调用),房子的位置永远不会变。所以你在对象里写了一个箭头函数,它”出生”在全局作用域,this 就永远指向全局,跟这个对象没有半毛钱关系。

Q3: 解构赋值的原理?有什么坑?

解构赋值的本质

解构赋值的本质是模式匹配——等号左边是一个”模式”,右边是数据源。引擎按照模式从数据源中提取对应的值并赋给变量。

解构的常见坑与最佳实践


Q4: Map 和 Object 的区别?什么时候用 Map?

Map 的内部实现原理

Map 内部使用哈希表实现(V8 中是 OrderedHashMap),同时维护一条插入顺序链,因此 size 是 O(1),迭代严格按插入顺序。

Map vs Object 的选择策略

当 key 不是字符串、需要频繁增删、需要保持插入顺序时用 Map。稳定结构的属性访问用 Object 更快(V8 的 Inline Cache 优化)。

WeakMap 与 Map 的区别

WeakMap 的 key 必须是对象,且是弱引用——当 key 对象没有其他引用时会被垃圾回收,适合存储 DOM 节点关联数据。

Map 深度展开:内部实现与性能

Map 的内部实现原理(哈希表): Map 内部使用哈希表实现。V8 使用 OrderedHashMap:哈希桶负责定位,另有一条插入顺序链保证迭代顺序稳定。
Map vs Object 详细对比(8 个维度):
Map 的遍历性能优势:
性能基准对比:

WeakMap 的弱引用原理与使用场景

弱引用的底层机制: WeakMap 的弱引用是语言层面的语义保证,不是手动引用计数。V8 的垃圾回收器(GC)使用标记-清除算法:当一个对象从 GC Root 不可达时,WeakMap 中的对应条目会在下次 GC 时被自动清除。
WeakMap 使用场景 1:DOM 节点关联数据
WeakMap 使用场景 2:私有变量实现
WeakMap 使用场景 3:缓存计算结果
Map 用哈希表实现 O(1) 增删查改,key 可以是任意类型且保持插入顺序;WeakMap 用弱引用关联对象,当 key 对象无其他引用时自动被 GC 清除,适合存储 DOM 元素关联数据和实现私有变量。

Set 深度展开:内部实现与去重机制

Set 的内部实现原理: Set 内部同样使用哈希表实现,但只存储 key,不存储 value。V8 对 Set 的优化策略与 Map 一致:少量元素用线性存储,大量元素切换到哈希表。
Set 的去重机制(SameValueZero 算法): Set 使用 SameValueZero 算法判断元素是否相同,与 === 几乎一致,唯一的区别是 NaN === NaN 为 false,而 Set 认为 NaN 等于 NaN。
Set vs Array 对比:
Set 的集合运算实现:

WeakSet 的使用场景

WeakSet 与 Set 的关系,等同于 WeakMap 与 Map 的关系:key 必须是对象,且是弱引用。

Q5: 可选链 (?.) 和空值合并 (??) 的区别?

可选链与空值合并都是 ES2020 引入的语法:

?? 和 || 的本质区别

?? 比 || 更精准:|| 对所有 falsy 值生效(0、”、false、null、undefined、NaN),?? 只对 null 和 undefined 生效。

可选链的短路特性

可选链 ?. 在遇到 null 或 undefined 时会立即短路返回 undefined,不会再执行后续的属性访问。

最佳实践与注意事项


Q6: Symbol 有什么用?

Symbol 的唯一性保证

每个 Symbol 值都是唯一的,即使描述(description)相同。Symbol.for() 是例外——它会在全局注册表中查找或创建 Symbol。

内置 Symbol 的应用


Q7: 数组方法的三大流派?为什么 React 里不能直接 sort?

三大流派分类

问到”你常用哪些数组方法”时,如果你只是报菜名,那只能拿 60 分。真正拉开差距的回答是:按副作用行为把数组方法分成三个流派,每个流派的适用场景和坑完全不同。

副作用派:直接修改原数组

副作用派方法会原地修改数组本身。在纯 JavaScript 脚本里没问题,但一旦进入 React/Vue 的响应式世界,就可能变成隐形炸弹。

纯函数派:返回新数组,原数组不动

纯函数派方法不会触碰原数组,而是返回一个全新的数组。这是 React/Vue 等框架最喜欢的模式——因为框架需要通过引用比较(===)来判断数据是否变化。

折叠派:reduce 不只是求和

大部分人对 reduce 的理解停留在”求数组之和”。但 reduce 真正的威力是把数组”降维”成任意数据结构——Map、Object、嵌套树,什么都行。
大型管理后台的性能优化实战:

结合框架规范:为什么 React 里不能直接 sort

React 和 Vue 的响应式系统都依赖引用比较来检测数据变化。如果你直接修改原数组,引用没变,框架就”看不见”变化,界面不会更新。
Vue 3 用 Proxy 代理响应式数据,arr.sort() 和 arr.splice() 会触发 Proxy 的 set 陷阱,Vue 能检测到变化。但这不意味着你可以随意使用副作用方法——在 computed 或 watch 中直接修改响应式数组会导致无限循环或意外行为。养成用纯函数派方法的习惯,框架无关,代码更安全。

ES2023 不可变方法:toSorted、toSpliced、toReversed

ES2023 终于给数组加上了纯函数版本的变异方法,解决了困扰开发者多年的痛点:

V8 引擎底层:数组方法的性能陷阱

unshift 的 O(n) 开销: unshift 在数组头部插入元素,V8 需要把所有现有元素往后移一位——这是连续内存的移位操作,时间复杂度 O(n)。数据量大时性能断崖式下降。
delete arr[1] 的危害: 用 delete 删除数组元素会在数组中留下”空洞”(empty slot),V8 会把这种数组从高效的 Packed Elements 模式降级为低效的 Holey Elements 模式,甚至进一步降级到 Dictionary Elements(字典模式),性能断崖式下降。
数组方法按副作用分为三派:副作用派(sort/splice/push)直接改原数组,纯函数派(map/filter/slice/toSorted)返回新数组,reduce 是折叠派能折叠成任意结构。React/Vue 中应优先用纯函数派;V8 底层对 Packed/Holey/Dictionary 三种数组模式有巨大的性能差异,避免 delete 和稀疏赋值。

底层源码:V8 如何处理 Generator 和 async/await

Generator 被 V8 编译为状态机(详见 V8 源码:src/interpreter/bytecode-generator.cc):
语义上可以把它理解为 Generator + 自动执行器的组合:await 相当于挂起等待 Promise settle,settle 后再恢复执行。需要区分的是,这是转译层面的等价模型——Babel 会把 async 函数编译成 Generator,而 V8 原生实现走的是内部挂起/恢复内建函数,不会真的创建一个 JS Generator 对象。

十、及格回答 vs 满分回答

问题:“let/const 和 var 的区别?”

及格回答(60-70分):
“var 是函数作用域,let/const 是块级作用域。let/const 有暂时性死区,不能重复声明。const 不能重新赋值但可以修改对象属性。”
问题出在:知道规则但不理解底层。 满分回答(90-100分):
“区别不仅在于作用域范围(函数作用域 vs 块级作用域),更在 V8 的字节码生成机制。var 声明在编译阶段绑定到函数的 VariableProxy,生成 LdaGlobal 字节码。而 let/const 绑定到块级作用域的 Declaration Scope,生成 LdaContextSlot。[1]”
“TDZ 的本质是 V8 在进入块级作用域时,将 let/const 变量初始化为特殊的 the_hole(空洞)值。在遇到赋值语句前访问它,V8 检测到 the_hole 即抛出 ReferenceError,这是保证代码严谨性的机制。[2]”
“Generator 在 V8 中被编译为状态机。每次 yield 暂停时,V8 保存当前的执行状态到 Generator 对象的 context 中。next() 恢复时,从 context 中恢复状态继续执行。async/await 可等价理解为 Generator + 自动执行器:await 挂起等待 Promise settle 后恢复;Babel 正是这样转译的,V8 原生则用 resumable function 内建机制实现。[3]“

问题:“说说你 ES6 ES7 都用了什么 API”

及格回答(60-70分):
“我用过 let/const、箭头函数、解构赋值、Promise、async/await、Map/Set、可选链、空值合并……”
问题出在:报菜名,没有深度。 满分回答(90-100分):
“ES6+ 的演进有三条主线。第一条是异步编程的演进——从回调地狱到 Promise 再到 async/await。Promise 解决了回调嵌套问题,async/await 让异步代码看起来像同步代码。语义上 async/await 等价于 Generator + 自动执行器,Babel 的 regenerator 就是按这个模型转译的;V8 原生用 resumable function 内建机制完成挂起与恢复。[1]”
“第二条是数据结构的完善。ES5 时代只有 Object 和 Array,但 Object 的 key 只能是字符串,频繁增删性能差,遍历顺序不稳定。ES6 引入 Map/Set/WeakMap 解决了这些问题。Map 的 key 可以是任意类型,内部用哈希表实现,增删查通常是 O(1)(不考虑哈希冲突的极端情况)。WeakMap 的 key 是弱引用,当 key 对象没有其他引用时会被垃圾回收,适合存储 DOM 节点关联数据——用 Map 存 DOM 元素的元数据,节点移除后 Map 仍持有强引用,导致内存泄漏。[2]”
“第三条是防御性编程糖。ES2020 引入的可选链 ?. 和空值合并 ?? 解决了深层属性访问和默认值设置的痛点。?? 比 || 更精准——|| 对所有 falsy 值生效(0、”、false),?? 只对 null 和 undefined 生效。实际项目中用户传 timeout: 0 时,用 || 会被覆盖为默认值,用 ?? 则正确保留 0。[3]”
“实际工程中还有兼容性问题需要关注。语法层(let/const、箭头函数、解构)靠 Babel 转译,内建对象(Promise、Map/Set、Symbol)靠 Polyfill,而 Proxy/Reflect 这类元编程 API 无法 Polyfill。配置 useBuiltIns: 'usage' 可以按需引入 polyfill,只打包用到的 API。物流 PDA 设备用 Chromium 49 内核,Promise.allSettled 没被 polyfill 直接白屏——这些踩坑经验才是真正体现工程能力的地方。“

Babel + Polyfill 深度展开:ES6+ 的工程化兼容方案

ES6+ 特性的三层分类: ES6+ 的新特性在兼容性层面可以分为三类,处理方式完全不同:
Babel 的转换原理(AST 解析 -> 代码生成): Babel 的工作流程分为三个阶段:解析(Parse)、转换(Transform)、生成(Generate)。
@babel/preset-env 的作用: @babel/preset-env 是 Babel 的智能预设,它根据目标浏览器环境,自动决定需要哪些转换插件和 Polyfill。
core-js 的作用: core-js 是 JavaScript 标准库的模块化 Polyfill 集合,提供了 ES5 到最新提案中几乎所有 API 的兼容实现。
.browserslistrc 配置详解: browserslist 是前端工具链的统一目标环境配置,被 Babel、Autoprefixer、ESLint 等工具共享。
useBuiltIns: ‘usage’ 按需引入: useBuiltIns 决定了 core-js Polyfill 的注入方式,'usage' 是最推荐的模式。
实际配置示例(完整项目配置):
用 entry 模式时,必须在入口文件顶部手动 import 'core-js/stable',否则 Polyfill 不会被注入。用 usage 模式则不需要手动导入,Babel 会自动扫描并注入。但 usage 模式有一个坑:它只能静态分析源码,如果通过 window.Promise 动态访问 API,可能漏掉 Polyfill。
语法层靠 Babel 转译(AST 解析 -> 代码生成),内建对象靠 core-js Polyfill,@babel/preset-env + useBuiltIns: 'usage' + .browserslistrc 三者配合实现按需兼容。Proxy 等元编程 API 无法 Polyfill,只能换方案。

常见追问

答题要点: 说出 async/await 是 Generator + Promise 的语法糖。高分回答: “async/await 在语义上是 Generator + Promise 的语法糖。Generator 提供暂停/恢复能力(yield),async/await 在此基础上自动执行并处理 Promise。Babel/regenerator 的转译正是把 async 编译成 Generator:遇到 await 就 yield 一个 Promise,Promise settle 后由自动执行器调用 gen.next(value) 继续;错误通过 gen.throw(err) 注入,所以 await 的错误能被 try/catch 捕获。V8 原生实现则用 resumable function 内建机制,效果等价。”
答题要点: 从 V8 的 Inline Cache 角度分析。高分回答: “Object 的属性查找依赖 V8 的 Inline Cache,如果对象形状稳定(monomorphic),性能极高——直接根据缓存的内存偏移量取值,O(1) 复杂度。但如果频繁增删属性导致形状变化(polymorphic/megamorphic),Inline Cache 失效,性能会下降。Map 的内部实现是哈希表,增删操作始终是 O(1),不受形状变化影响。所以频繁增删场景用 Map 更好,稳定结构的属性访问用 Object 更快。”
答题要点: 从遍历目标和遍历内容两个维度对比。高分回答: “for...in 遍历对象的可枚举属性(包括原型链上的),key 是字符串。for...of 遍历可迭代对象(Array、Map、Set、String 等),value 是值。数组遍历推荐用 for...of,不要用 for...in——for...in 会遍历原型链上的属性,且顺序不保证。”
答题要点: 说出两个特殊 case。高分回答: “Object.is() 比 === 更严格,有两个关键区别:
  1. Object.is(NaN, NaN) 返回 true,而 NaN === NaN 返回 false
  2. Object.is(+0, -0) 返回 false,而 +0 === -0 返回 true
其他情况两者行为一致。Object.is 的本质是比较两个值的 SameValue 算法,而 === 用的是 Strict Equality 算法。”
答题要点: 从加载方式、值的引用关系、循环依赖处理三个维度对比。高分回答: “核心区别有三个:
  1. 加载方式:CommonJS 是运行时同步加载(require),ES6 模块是编译时静态分析(import/export)
  2. 值的关系:CommonJS 导出的是值的拷贝,ES6 模块导出的是值的引用——模块内部变量变化,导入方也能感知到
  3. 循环依赖:CommonJS 遇到循环依赖会返回已执行部分的值(可能不完整),ES6 模块因为是引用关系,能正确处理循环依赖”
答题要点: 从数据结构特性和去重场景两个角度分析。高分回答: “Set 和数组的核心区别是 Set 的元素唯一(基于 SameValueZero 算法)。
Set 去重的时间复杂度是 O(n),而 Array.prototype.filter + indexOf 是 O(n^2)。大数据量场景优先用 Set。”
答题要点: 从行为差异和适用场景两个维度对比。高分回答: “三者的区别:
  1. Promise.all:所有 Promise 都成功才返回成功,有一个失败就立即失败(fail-fast)
  2. Promise.race:返回第一个完成的结果(不管成功还是失败)
  3. Promise.allSettled:等所有 Promise 都完成(不管成功还是失败),返回每个结果的状态数组
实际项目建议:用 Promise.allSettled 替代 Promise.all + try/catch,更优雅地处理部分失败。”
答题要点: 说出状态机、回调队列、异步调度、thenable 展平四个核心。高分回答: “手写 Promise 的关键不是背代码,而是理解四件事:
  1. 状态机:pending → fulfilled/rejected,状态只能改变一次(不可逆)
  2. 回调队列:pending 状态时把 then 的回调存起来,状态改变后批量执行
  3. 异步调度:then 的回调必须用 queueMicrotask 异步执行,防 Zalgo 现象
  4. thenable 展平:resolvePromise 递归处理嵌套的 Promise 和 thenable 对象
V8 中 Promise.then 的回调是通过微任务队列执行的。then 方法返回一个新的 Promise,实现了链式调用。每次 then 都创建新的 Promise 实例,状态由回调函数的返回值决定。“

延伸阅读与引用参考

  • V8 Blog: Understanding V8’s Bytecode:V8 官方文档,解释 Ignition 解释器如何处理 let/const 的 LdaGlobal/LdaContextSlot 字节码生成、变量提升与作用域绑定机制。[1]
  • Axel Rauschmayer: Variables and Shadowing (ES6):Axel Rauschmayer 在 Exploring ES6 中对 TDZ(暂时性死区)、the_hole 值和块级作用域的权威剖析,是理解 ES6 变量声明底层机制的最佳参考。[2]
  • V8 Source Code: bytecode-generator.cc:V8 字节码生成器的源码,包含箭头函数 kLexical this 模式、Generator 状态机以及 async 函数的挂起/恢复实现。[3]
  • MDN Web Docs: ES6:MDN 对 ES6+ 特性的完整文档,包括 Map/Set/WeakMap、Symbol、解构赋值、可选链、空值合并等所有 API 的权威说明。
  • Exploring JavaScript (ES2015):Axel Rauschmayer 的 JavaScript 系列书籍网站,覆盖 ES2015 到最新标准的全面深度解析,包括箭头函数、解构赋值、Symbol 等专题。