03

JavaScript 语言

2,465 行约 116 分钟103 个代码块37 张表格更新于 2026-11-03

03 · JavaScript 语言(详解版)

一句话定位:JavaScript 是前端唯一的通用语言。框架、构建工具、性能问题、内存泄漏,最终都能还原成「一段 JS 在引擎里怎么执行」。

读完本篇你应该能做到

  1. 看到一段涉及闭包 / 异步 / this 的代码,能准确说出执行顺序和每个变量的值;
  2. 能解释「为什么这个页面用久了会卡、会崩」,并用 DevTools 找到证据;
  3. 知道什么时候该用 Web Worker、什么时候该拆长任务,以及为什么。

0. 领域导论

0.1 这个领域解决什么问题

写 JS 的人常有一种错觉:语法都学过,所以「会了」。但线上事故往往出在这几处:

  • 一个 for 循环里的 setTimeout 打印出五个一样的值(闭包 + 作用域);
  • 页面跑了 8 小时崩溃,因为某个数组只增不减(内存泄漏);
  • 页面「点了没反应」,因为主线程被一个 200ms 的循环占住(事件循环与长任务);
  • 两个 await 写成串行,接口耗时翻倍(并发模型)。

这些都不是「语法问题」,而是运行时模型问题。学 JS 的核心,是把「代码 → 引擎执行 → 结果」这条链路变成可推理的模型。

0.2 一个类比

把 JS 引擎想成一家只有一名大堂经理的餐厅:

  • 调用栈(call stack) 是经理手里的一摞待办单,做完一张才做下一张。
  • 任务队列(task queue) 是门口排队的客人(setTimeout、点击事件、网络回调)。
  • 微任务队列(microtask queue) 是经理桌上的便利贴(Promise.then),每做完一张单子就先把便利贴清空,才叫下一位客人。
  • 渲染 是必须定期做的「打扫卫生」——如果经理一直不做完手里的单子,卫生就没法做,客人看到的就是卡住的画面。

理解了这套排队规则,Promise 顺序、setTimeout(fn, 0) 为什么不立即执行、为什么长任务会卡住动画,就都能推出来。

0.3 学习地图

03 JavaScript
  ├─ 前置:无
  ├─ 紧接着:04 TypeScript(给 JS 加类型)
  │             05 浏览器原理(运行环境)
  │             06 框架(基于 JS 的抽象)
  ├─ 交叉:11 性能(长任务/内存)、14 Node(同一语言的服务端)
  └─ 长期:09 工程化(AST 与构建)

0.4 分级标尺

级别能力特征典型产出自检标准
L1 基础会用语言特性写出能跑的代码业务函数、数据处理逻辑能解释常见语法行为
L2 进阶理解作用域、闭包、异步模型通用工具库、异步流程能解释「为什么这样输出」
L3 高级理解引擎与内存,能做性能与并发设计Worker 架构、内存专项、调度方案能解释「引擎内部怎么处理」
L4 专家语义级与规范级推理,设计语言层基础设施编译器插件、DSL、语言规范能对边界行为给出规范依据

1. L1 基础:先把「值」和「执行」搞清楚

1.1 变量与作用域:var、let、const 的真实差别

// var:函数作用域 + 变量提升(初始化为 undefined)
console.log(a); // undefined,不报错
var a = 1;

// let/const:块级作用域 + 暂时性死区(TDZ)
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 1;

// const 只保证「绑定不变」,不保证内容不可变
const list = [1, 2];
list.push(3);      // 允许
list = [4];        // TypeError: Assignment to constant variable

三个必须记住的点:

  1. var 的提升是「声明提升、赋值不提升」:声明被移到函数顶部并初始化为 undefined,所以访问不报错但拿到 undefined。
  2. let/const 也在「提升」,但处于暂时性死区:从进入作用域到声明语句执行前,访问会抛 ReferenceError。这不是「没提升」,而是「提升了但不让访问」。
  3. const 常量绑定:对象/数组内容可变。需要真正不可变,用 Object.freeze()(仅浅冻结)或不可变数据结构。

自检:for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)) 输出什么?换成 let 呢?——var 输出 3 3 3:三个回调共享同一个函数作用域变量,执行时循环早已结束;let 输出 0 1 2:每次迭代创建新的绑定,每个闭包捕获各自那一轮的值。

1.2 类型与转换:JS 最容易被吐槽的部分

八种类型:undefined、null、boolean、number、bigint、string、symbol、object。

typeof undefined;   // 'undefined'
typeof null;        // 'object'  ← 历史 bug,无法修复
typeof [];          // 'object'  ← 数组也是对象
typeof function(){};// 'function'
typeof 1n;          // 'bigint'

显式转换优先用 Number()、String()、Boolean()。隐式转换只发生在这些地方:比较、算术、模板字符串、条件判断。

== 的转换规则(只记结论,实践统一用 ===):

null == undefined;   // true
null == 0;           // false  ← 容易踩
'' == 0;             // true
'0' == false;        // true
[] == false;         // true   ← [] → '' → 0
[] == ![];           // true   ← 右侧 ![] 是 false

唯一推荐的 == 用法是 x == null(同时判断 null 和 undefined)。

+ 的陷阱:

1 + '1';      // '11'(有字符串就是拼接)
1 + 1 + '1';  // '21'(从左到右)
'1' + 1 + 1;  // '111'
[] + [];      // ''
[] + {};      // '[object Object]'

自检:[1,2,3].map(String) 和 [1,2,3].map(parseInt) 结果一样吗?为什么?——不一样:map(String) 得到 ['1','2','3'];map(parseInt) 得到 [1, NaN, NaN]。因为 map 传的是 (元素, 索引, 数组),parseInt 把索引当成第二个参数「基数」:parseInt('1', 0) 按十进制得 1,parseInt('2', 1) 基数非法得 NaN,parseInt('3', 2) 中 3 不是二进制数字得 NaN。

1.3 值类型与引用类型

let a = { n: 1 };
let b = a;      // 复制的是「引用」,两个变量指向同一个对象
b.n = 2;
console.log(a.n); // 2

let x = 1;
let y = x;      // 复制的是值
y = 2;
console.log(x); // 1

判断相等:=== 对对象比较的是引用地址,不是内容。{a:1} === {a:1} 是 false。

浅拷贝与深拷贝:

const origin = { a: 1, nested: { b: 2 } };

const shallow = { ...origin };          // 浅拷贝:nested 仍共享
shallow.nested.b = 99;
console.log(origin.nested.b);           // 99(被改了)

const deep = structuredClone(origin);   // 原生深拷贝(现代浏览器/Node 17+)

structuredClone 支持 Map、Set、Date、循环引用,但不支持函数、DOM 节点、Symbol。

1.4 函数与 this:四条绑定规则

this 的值在函数被调用时确定(箭头函数除外,它在定义时确定)。

const obj = {
  name: 'obj',
  regular() { return this.name; },
  arrow: () => this?.name,
};

obj.regular();          // 'obj'   ← 隐式绑定:谁调用指向谁
const fn = obj.regular;
fn();                   // undefined(严格模式)← 默认绑定

obj.regular.call({ name: 'other' });  // 'other' ← 显式绑定
new obj.regular();                    // this 指向新对象 ← new 绑定

优先级:new > call/apply/bind > 隐式绑定 > 默认绑定。箭头函数不参与这套规则,它捕获外层 this。

为什么箭头函数不能做方法:

const counter = {
  count: 0,
  inc: () => { this.count++; },  // this 是外层(模块/全局),不是 counter
};

函数参数与 arguments:

function sum(...nums) {           // 剩余参数,得到真数组
  return nums.reduce((a, b) => a + b, 0);
}

function oldStyle() {
  return Array.from(arguments);   // arguments 是类数组对象
}

自检:const o = { f() { return this } }; const g = o.f; g() 返回什么?为什么?——返回 undefined(模块或严格模式下),非严格脚本里是全局对象。因为 g() 是普通函数调用,this 由「调用方式」决定,与函数原本挂在哪个对象上无关——const g = o.f 已经把方法从对象上摘下来了。

1.5 数组与对象常用 API

// 数组:优先用不改变原数组的方法
const nums = [1, 2, 3, 4];
nums.map((n) => n * 2);            // [2,4,6,8]
nums.filter((n) => n % 2 === 0);   // [2,4]
nums.reduce((sum, n) => sum + n, 0);// 10
nums.find((n) => n > 2);           // 3
nums.some((n) => n > 3);           // true
nums.every((n) => n > 0);          // true

// ⚠️ 会改变原数组的方法
nums.push(5); nums.pop(); nums.splice(0, 1); nums.sort(); nums.reverse();

sort 默认按字符串排序:

[10, 2, 1].sort();          // [1, 10, 2]  ← 按字典序
[10, 2, 1].sort((a, b) => a - b); // [1, 2, 10]

对象:

const user = { name: '张三', age: 20 };

Object.keys(user);              // ['name', 'age']
Object.entries(user);           // [['name','张三'], ['age',20]]
Object.fromEntries([['a', 1]]); // { a: 1 }

// 可选链与空值合并
const city = user?.address?.city ?? '未知';   // ?? 只在 null/undefined 时取右值

⚠️ 常见坑:?? 与 || 混用会报语法错误,必须加括号:(a ?? b) || c。

1.6 事件循环:一段代码到底怎么跑

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

queueMicrotask(() => console.log('4'));

console.log('5');

// 输出:1 5 3 4 2

执行模型:

1. 执行同步代码(调用栈清空)
2. 清空所有微任务(microtask queue):Promise.then、queueMicrotask、MutationObserver
3. 渲染(如果需要)
4. 取一个宏任务(task):setTimeout、事件回调、网络回调
5. 回到第 2 步

关键结论:

  • 微任务会在当前宏任务结束后、渲染前全部执行完。
  • 如果微任务里不断产生新的微任务,页面会永远得不到渲染机会(死循环)。
  • setTimeout(fn, 0) 不是 0ms,而是「至少 4ms 后放进宏任务队列」,且要等前面的任务。

自检:Promise.resolve().then(() => { while(true){} }) 会发生什么?——页面会直接卡死:当前宏任务结束后必须先清空微任务队列,而这个 then 永不返回,主线程被永久占用,既不渲染也不响应输入,画面停在上一帧,只能强杀标签页。它不会「稍后恢复」,因为队列永远清不完。

1.7 DOM 事件:捕获、冒泡、委托

<div id="outer">
  <button id="inner">点我</button>
</div>
const outer = document.getElementById('outer');

// 捕获阶段(从外到内)
outer.addEventListener('click', () => console.log('capture'), true);

// 冒泡阶段(从内到外,默认)
outer.addEventListener('click', () => console.log('bubble'));

事件流:捕获 → 目标 → 冒泡。

// 阻止默认行为(如链接跳转、表单提交)
event.preventDefault();

// 阻止继续冒泡
event.stopPropagation();

事件委托:把监听器挂在父元素,靠 event.target 判断来源。好处是「动态新增的子元素自动生效」,也减少监听器数量。

document.getElementById('list').addEventListener('click', (event) => {
  const item = event.target.closest('[data-id]');
  if (!item) return;
  console.log('点击了', item.dataset.id);
});

适用边界:不冒泡的事件(focus、blur、mouseenter、mouseleave)不能委托;scroll 不冒泡但可在捕获阶段监听。

自检:为什么 stopPropagation 会导致「点击埋点收不到数据」?——埋点一般委托在 document 或根容器上,靠事件冒泡汇总;中间某个业务 handler 调了 stopPropagation(),事件就不再向上冒泡,根上的委托监听收不到,埋点整条丢失。解决办法是埋点用捕获阶段监听(addEventListener(type, fn, true)),或在 stopPropagation 之前先上报。

1.8 Environment Record:var 与 let 到底差在哪

执行上下文里挂着一个词法环境,词法环境由若干 Environment Record 组成。理解这层结构,var/let/const 的差异就不需要背了。

Environment Record 类型存什么出现位置
Declarative词法绑定(let/const/函数声明)函数体、块、模块、catch
Object绑定即对象属性全局环境、with
Function参数与函数自身绑定函数环境
Global= Object(var/函数)+ Declarative(let/const)的复合全局

所以:

var a = 1;
let b = 2;

console.log(window.a);        // 1:var 在全局 Object Record 上建了属性
console.log(window.b);        // undefined:let 在 Declarative Record 上,不上挂对象

console.log(delete window.a); // false:不可配置

函数声明与变量声明的创建时机:

声明创建阶段初始化阶段
function f(){}声明实例化时就创建并初始化已完成(可提前调用)
var a创建但初始化为 undefined执行到赋值语句才变
let b创建但不初始化(TDZ)执行到声明语句才初始化

TDZ 的精确边界:从块开始到声明语句之间,访问会抛 ReferenceError,连 typeof 也抛(这与未声明变量的 typeof 返回 'undefined' 不同):

console.log(typeof notDeclared);  // 'undefined':真的不存在
console.log(typeof tdzed);        // ReferenceError:存在但未初始化
let tdzed = 1;

1.9 类型转换:== 的判定表与 ToPrimitive

ToPrimitive 的顺序是 Symbol.toPrimitive → valueOf → toString,一旦返回原始值就停止:

const obj = {
  valueOf: () => 42,
  toString: () => 'str',
};

console.log(obj + 1);   // 43:valueOf 先返回原始值
console.log(`${obj}`);  // 'str':字符串上下文提示 'string',优先 toString
const obj2 = {
  [Symbol.toPrimitive]: (hint) => (hint === 'number' ? 1 : 'x'),
};

console.log(obj2 + 1);   // 2
console.log(`${obj2}`);  // 'x'

== 的完整判定(按顺序命中即返回):

左 / 右规则
null == undefinedtrue,其余 null/undefined 与任何值比较都是 false
数字 vs 字符串字符串转数字
布尔 vs 任意布尔先转数字
对象 vs 原始值对象 ToPrimitive 后比较
对象 vs 对象比较引用

几个「看着像但结果意外」的等式:

[] == false        // true:[] → '' → 0,false → 0
[] == ![]          // true:右边 ![] 是 false → 0,左边 [] → '' → 0
null == 0          // false:null 只与 undefined 相等
NaN == NaN         // false
'1' == 1           // true
[1] == 1           // true:[1] → '1' → 1

Number('') 是 0,Number(' ') 也是 0,但 Number('1,2') 是 NaN;parseInt 会「能读多少读多少」:parseInt('12px') === 12,因此表单里优先用 Number 并显式判空。

1.10 深拷贝:structuredClone 的边界

const source = { date: new Date(), map: new Map(), arr: [1, 2] };
const copy = structuredClone(source);
值类型structuredCloneJSON 方案
Date保留变字符串
Map/Set保留变 {}
RegExp保留变 {}
undefined/NaN/Infinity保留丢失/变 null
循环引用支持抛错
函数/Symbol抛 DataCloneError静默丢失
DOM 节点抛错抛错
原型链丢失(变普通对象)丢失
// 需要保留原型或类实例时,结构化克隆不够用
const user = new User('a');
const cloned = structuredClone(user);  // 变成普通对象,instanceof 失效

三个替代方案与取舍:

方案优点缺点
structuredClone原生、快、支持循环丢原型、不支持函数
手写 clone完全可控维护成本高,容易漏字段
不可变数据(immutable)从根上不需要深拷贝改造量大,学习成本

1.11 this 的四条绑定规则与优先级

规则触发条件this 指向
new 绑定new Fn()新建的实例
显式绑定call/apply/bind传入的对象
隐式绑定obj.method()obj
默认绑定裸调用严格模式 undefined,否则全局对象

优先级:new > 显式 > 隐式 > 默认。箭头函数不参与,它的 this 在定义时按词法捕获。

function Fn() {
  console.log(this.name);
}

const bound = Fn.bind({ name: 'bound' });
new bound();            // undefined:new 优先级更高,this 指向新实例

三个高频坑:

// 1. 方法被「解构」后丢 this
const obj = { name: 'a', hi() { return this.name; } };
const { hi } = obj;
hi();                   // 严格模式下抛错 / 非严格为 undefined

// 2. 回调里丢 this
setTimeout(obj.hi, 0);  // this 不是 obj,应写 setTimeout(() => obj.hi(), 0)

// 3. 类字段箭头函数 vs 原型方法
class A {
  name = 'a';
  hi = () => this.name;   // 每个实例一份,绑定确定,但内存开销更大
  hi2() { return this.name; }  // 挂在原型上,共享
}

1.12 数组与对象 API 的边界行为

API边界行为坑
sort()默认转字符串比较[10, 9].sort() → [10, 9],必须传比较函数
sort() 稳定性ES2019 起稳定老引擎不稳定,排序键相同时顺序不定
reduce() 空数组无初始值抛 TypeError总是传初始值
forEach 稀疏数组跳过空位[,,].forEach 不执行
map 稀疏数组保留空位结果长度不变但有空洞
flat(Infinity)完全摊平深层嵌套可能爆栈
at(-1)支持负数索引比 arr[arr.length - 1] 简洁
findLast从后往前找需确认目标环境支持
Array.from({length:3})得到 [undefined×3]想要索引要传 map 函数
Object.freeze浅冻结嵌套对象仍可改
// 数字排序必须给比较函数,否则按字符串比
const nums = [10, 9, 100];
nums.sort((a, b) => a - b);   // [9, 10, 100]

// 深冻结需要递归
/**
 * 递归冻结对象,防止嵌套属性被意外修改。
 * @template T
 * @param {T} value
 * @returns {T}
 */
function deepFreeze(value) {
  if (value && typeof value === 'object' && !Object.isFrozen(value)) {
    Object.freeze(value);
    Object.values(value).forEach(deepFreeze);
  }
  return value;
}

1.13 事件循环:一次点击后的完整顺序

阶段内容说明
1一个宏任务(task)脚本、setTimeout、事件回调、postMessage
2微任务队列清空Promise.then、queueMicrotask、MutationObserver
3可能的渲染若需要更新画面,执行 rAF 回调 → 样式 → 布局 → 绘制
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
queueMicrotask(() => console.log('4'));
console.log('5');

// 输出:1 5 3 4 2
// 原因:同步先跑完(1、5),清空微任务(3、4),最后才是宏任务(2)

三个容易答错的点:

  1. setTimeout(fn, 0) 不是 0ms:嵌套超过 5 层后最小间隔 4ms,且要等当前任务与微任务清空。
  2. requestAnimationFrame 在渲染前执行:它属于「渲染步骤」,不是宏任务也不是微任务。
  3. 微任务里再产生微任务会继续在同一轮清空:可能造成「饿死渲染」,长链 Promise 会让页面卡住。
// 反面例子:微任务无限递归会阻塞渲染与输入响应
function loop() {
  Promise.resolve().then(loop);   // 不要这样写
}

1.14 L1 小结与自检

知识点最该记住的一句
变量var 函数作用域,let/const 块级 + TDZ
类型转换统一用 ===,唯一例外 x == null
引用对象赋值传引用,深拷贝用 structuredClone
this调用时确定;箭头函数捕获外层
数组sort 默认字典序;区分可变/不可变方法
事件循环同步 → 微任务清空 → 渲染 → 一个宏任务
事件捕获→目标→冒泡;委托靠 closest

自检题

  1. const 声明的数组能 push 吗?——能,绑定不可变,内容可变。
  2. 为什么 [] == ![] 是 true?——右侧 ![] 为 false,左侧 [] 转成 '' 再转 0,false 也转 0。
  3. 微任务和宏任务谁先执行?——当前宏任务结束后先清空微任务。

2. L2 进阶:作用域、原型、异步

2.1 执行上下文与闭包

执行上下文 = 变量环境 + 词法环境 + this 绑定。函数调用时创建,调用结束出栈。

function makeCounter() {
  let count = 0;              // 被闭包捕获,不会随函数返回而回收
  return {
    inc: () => ++count,
    get: () => count,
  };
}

const c1 = makeCounter();
c1.inc();
c1.inc();
console.log(c1.get()); // 2
console.log(count);    // ReferenceError:外部访问不到

闭包的本质:函数 + 它出生时的词法环境引用。它让变量「活」在函数之外,这既是能力(私有状态、记忆化),也是泄漏来源(被长期持有)。

循环中的经典问题:

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

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 0); // 0 1 2
}

原因:var 只有一个 i,循环结束后是 3;let 每次迭代创建新的绑定。

记忆化(闭包的正面用法):

/**
 * 创建一个带缓存的纯函数包装。
 * @param {(n: number) => number} fn 需要缓存的函数
 * @returns {(n: number) => number} 带缓存版本
 */
function memoize(fn) {
  const cache = new Map();
  return (n) => {
    if (!cache.has(n)) cache.set(n, fn(n));
    return cache.get(n);
  };
}

2.2 原型与继承

function Animal(name) {
  this.name = name;
}
Animal.prototype.speak = function () {
  return `${this.name} 发出声音`;
};

const dog = new Animal('狗');
dog.speak(); // '狗 发出声音'

// 原型链:dog → Animal.prototype → Object.prototype → null
Object.getPrototypeOf(dog) === Animal.prototype; // true

__proto__(对象自己的原型引用)与 prototype(构造函数上的属性,供实例共享)是两回事:

Animal.prototype;          // 构造函数的原型对象
dog.__proto__;             // 实例的原型(等于 Animal.prototype)

class 是语法糖,但语义更严格:

class Animal {
  constructor(name) { this.name = name; }
  speak() { return `${this.name} 发出声音`; }
  static create(name) { return new Animal(name); }
}

class Dog extends Animal {
  speak() { return `${super.speak()}:汪`; }
}

与函数写法的差异:class 不能当普通函数调用(Animal() 报错)、方法不可枚举、内部默认严格模式、必须先 super() 才能用 this。

判断类型:

Array.isArray([]);            // true
[] instanceof Array;          // true(跨 iframe 会失效)
Object.prototype.toString.call([]); // '[object Array]'(最稳)

自检:Object.create(null) 创建的对象有什么特别?——没有原型链,不能调用 hasOwnProperty,适合做纯字典(避免原型污染)。

2.3 模块系统:ESM 与 CJS

// ESM:静态结构,编译期就能确定依赖
import { readFile } from 'node:fs/promises';
export const version = '1.0.0';
export default function init() {}

// CJS:运行时加载
const fs = require('node:fs');
module.exports = { version: '1.0.0' };

关键差异:

维度ESMCJS
加载时机静态分析、预加载运行时同步加载
导出绑定live binding(值变化会同步)值拷贝(导出后改原始变量不影响导入方)
require ESM部分环境支持—
thisundefinedmodule.exports
Tree shaking支持基本不支持

live binding 的例子:

// counter.mjs
export let count = 0;
export function inc() { count++; }

// main.mjs
import { count, inc } from './counter.mjs';
inc();
console.log(count); // 1(跟着变)

循环依赖的表现也不同:ESM 会先建立绑定再执行,CJS 可能出现「拿到空对象」。

2.4 Promise:状态机与错误传播

const p = new Promise((resolve, reject) => {
  // 只能决定一次
  resolve(1);
  reject(new Error('无效')); // 被忽略
});

三种状态:pending → fulfilled / rejected,一旦确定不可逆。

链式调用:

fetch('/api/user')
  .then((res) => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`); // 主动抛错才会进 catch
    return res.json();
  })
  .then((user) => console.log(user))
  .catch((err) => console.error('失败', err))
  .finally(() => console.log('结束'));

错误传播规则:

  • .then 里的 throw 会被下一个 .catch 捕获;
  • .catch 返回正常值后,后续 .then 继续执行(相当于「恢复」);
  • 没有 .catch 的拒绝会触发 unhandledrejection。

四个组合方法:

Promise.all([a, b]);        // 全部成功才成功;有一个失败立即失败
Promise.allSettled([a, b]); // 等全部结束,返回状态数组(适合「部分成功也要展示」)
Promise.race([a, b]);       // 第一个结束(成功或失败)就返回
Promise.any([a, b]);        // 第一个成功就返回;全失败才失败(AggregateError)

自检:Promise.all 中一个失败后,其它请求会停止吗?——不会,它们仍在执行,只是结果被忽略。

2.5 async/await:语法糖与它带来的新坑

async function load() {
  try {
    const user = await fetchUser();
    const orders = await fetchOrders(user.id); // 串行:等第一个结果才发第二个
    return { user, orders };
  } catch (err) {
    console.error('加载失败', err);
  }
}

并发写法(无依赖的请求必须并行):

async function loadFast() {
  const [user, config] = await Promise.all([fetchUser(), fetchConfig()]);
  return { user, config };
}

常见错误:

// 错误 1:forEach 里的 async 不会被等待
items.forEach(async (item) => {
  await save(item); // 外层函数不等它
});
console.log('全部保存完成'); // 实际还没保存完

// 正确
await Promise.all(items.map((item) => save(item)));

// 错误 2:吞掉错误
try {
  await doSomething();
} catch (e) {} // 空 catch 让问题彻底消失

// 错误 3:循环里的串行 await(能用并发就用并发)
for (const id of ids) {
  results.push(await fetchItem(id)); // 100 个请求串行执行
}

在 async 函数中,await 之后抛出的错误可以被 try/catch 捕获;但 async 函数返回的 Promise 如果不 await 也不 .catch,错误会变成未处理拒绝。

2.6 迭代器与生成器

可迭代协议:对象实现 [Symbol.iterator](),返回一个带 next() 的迭代器。

const range = {
  from: 1,
  to: 3,
  [Symbol.iterator]() {
    let current = this.from;
    const last = this.to;
    return {
      next() {
        return current <= last
          ? { value: current++, done: false }
          : { value: undefined, done: true };
      },
    };
  },
};

[...range]; // [1, 2, 3]

生成器让迭代器写起来像普通函数:

function* idGenerator() {
  let id = 0;
  while (true) {
    yield id++;
  }
}

const gen = idGenerator();
gen.next().value; // 0
gen.next().value; // 1

生成器的实战价值:惰性序列(无限列表)、协作式调度(可中断的长任务)、状态机。async/await 的底层就是「生成器 + 自动执行器」。

自检:for...of 能遍历普通对象吗?——不能,除非手动实现 Symbol.iterator。

2.7 Proxy 与 Reflect:元编程

const target = { name: '张三' };

const proxy = new Proxy(target, {
  /**
   * 拦截属性读取。
   * @param {object} obj 目标对象
   * @param {string|symbol} key 属性名
   * @returns {unknown} 属性值
   */
  get(obj, key) {
    if (key === 'greet') return `你好,${obj.name}`;
    return Reflect.get(obj, key);
  },

  /**
   * 拦截属性写入,做校验。
   * @param {object} obj 目标对象
   * @param {string|symbol} key 属性名
   * @param {unknown} value 新值
   * @returns {boolean} 是否写入成功
   */
  set(obj, key, value) {
    if (key === 'age' && typeof value !== 'number') {
      throw new TypeError('age 必须是数字');
    }
    return Reflect.set(obj, key, value);
  },
});

proxy.greet;      // '你好,张三'
proxy.age = '20'; // TypeError

Proxy 能拦截 13 种操作(get/set/has/deleteProperty/ownKeys/apply/construct/...)。Vue 3 的响应式就是基于 Proxy + Reflect。

代价:Proxy 创建的对象性能低于普通对象,且无法被某些引擎优化(隐藏类失效)。不要在高频路径(每帧执行的循环)里用 Proxy 包裹数据。

2.8 垃圾回收基础

JS 用可达性(reachability) 判断对象是否可回收:从根(全局对象、调用栈、DOM 树)出发,能访问到的对象存活。

分代策略(V8):

  • 新生代(Scavenge):存活时间短的小对象,复制算法,快。
  • 老生代(Mark-Sweep / Mark-Compact):多次存活的对象晋升,标记清除 + 整理。

六种典型泄漏:

// 1. 全局缓存只增不减
const cache = {};
function remember(key, value) { cache[key] = value; } // 永不清理

// 2. 定时器未清除
const timer = setInterval(poll, 1000);
// 组件卸载时必须 clearInterval(timer)

// 3. 事件监听未解绑
window.addEventListener('resize', handler);
// 卸载时 removeEventListener

// 4. 闭包持有大对象
function create() {
  const bigData = new Array(1e6).fill('x');
  return () => bigData.length; // bigData 永远不释放
}

// 5. 游离 DOM(Detached DOM)
let node = document.getElementById('x');
node.remove();      // 从 DOM 移除
console.log(node);  // 变量仍引用它 → 无法回收

// 6. 未完成的 Promise / 请求未取消
fetch('/slow').then(update); // 组件已卸载仍在更新状态

自检:为什么把 DOM 节点存进数组(即使已从页面移除)会阻止它被回收?——因为数组持有对节点的强引用:节点虽已从文档树移除,只要 JS 里还有引用就不会被 GC;节点又会引用它的子节点与事件监听,整棵子树一起被留住,这就是「detached DOM」泄漏。排查用 Heap Snapshot 搜 Detached,确认后把引用置空,或改用 WeakRef / WeakMap。

2.9 手写基本功(面试与实战都考)

防抖:等用户停止操作 N 毫秒后执行(搜索输入)。

/**
 * 防抖:停止触发 wait 毫秒后才执行。
 * @param {Function} fn 目标函数
 * @param {number} wait 等待毫秒数
 * @returns {Function} 包装后的函数
 */
function debounce(fn, wait = 300) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), wait);
  };
}

节流:固定频率执行(滚动监听)。

/**
 * 节流:每 wait 毫秒最多执行一次。
 * @param {Function} fn 目标函数
 * @param {number} wait 间隔毫秒数
 * @returns {Function} 包装后的函数
 */
function throttle(fn, wait = 200) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= wait) {
      last = now;
      fn.apply(this, args);
    }
  };
}

深拷贝(处理循环引用与特殊类型):

/**
 * 深拷贝:支持对象、数组、Date、Map、Set,并处理循环引用。
 * @param {unknown} value 待拷贝值
 * @param {WeakMap<object, unknown>} seen 已拷贝记录,防止循环引用
 * @returns {unknown} 拷贝结果
 */
function deepClone(value, seen = new WeakMap()) {
  if (value === null || typeof value !== 'object') return value;
  if (seen.has(value)) return seen.get(value);

  if (value instanceof Date) return new Date(value);
  if (value instanceof Map) {
    const m = new Map();
    seen.set(value, m);
    value.forEach((v, k) => m.set(deepClone(k, seen), deepClone(v, seen)));
    return m;
  }
  if (value instanceof Set) {
    const s = new Set();
    seen.set(value, s);
    value.forEach((v) => s.add(deepClone(v, seen)));
    return s;
  }

  const out = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value));
  seen.set(value, out);
  for (const key of Reflect.ownKeys(value)) {
    out[key] = deepClone(value[key], seen);
  }
  return out;
}

2.10 错误处理

class HttpError extends Error {
  /**
   * @param {number} status HTTP 状态码
   * @param {string} message 错误信息
   * @param {unknown} [cause] 底层原因
   */
  constructor(status, message, cause) {
    super(message, { cause });
    this.name = 'HttpError';
    this.status = status;
  }
}

try {
  await request();
} catch (err) {
  if (err instanceof HttpError && err.status === 401) {
    redirectToLogin();
  } else {
    reportToMonitor(err);
  }
}

原则:

  • try/catch 只包住可能出错的最小范围,不要包住整个函数体(会掩盖真正的 bug)。
  • 区分「可恢复错误」(网络抖动 → 重试)与「不可恢复错误」(参数错误 → 上报)。
  • 不要空 catch;至少上报。

2.11 闭包的内存形态:它捕获的是「环境」不是「值」

function outer() {
  let count = 0;
  return () => ++count;
}
const inc = outer();

内存上的样子:

inc(函数对象) ──[[Environment]]──▶ Closure Environment
                                     ├─ count: 0
                                     └─ [[OuterEnv]] → 全局环境

三个推论:

  1. 闭包让环境不可回收,直到闭包本身不可达。所以长期存活的闭包持有大对象,就是教科书式泄漏。
  2. 同一作用域内的多个闭包共享同一个环境,所以它们看到的是同一份变量。
  3. 「不需要的变量也会被一起留下」——因为环境是整体保留的,不是按变量挑拣。
// 泄漏形态:闭包捕获了巨大的数据,只为读一个字段
function makeFormatter(hugeDataset) {
  const id = hugeDataset[0].id;        // 只需要 id
  return () => format(id);             // 但整个 hugeDataset 都被环境留住
}

// 修法:先取出需要的值,再返回闭包
function makeFormatterFixed(hugeDataset) {
  const id = hugeDataset[0].id;
  return () => format(id);
}

循环中的 var 与 let 差异,本质也是「环境是每轮一份还是共用一份」:

for (var i = 0; i < 3; i++) setTimeout(() => console.log(i));   // 3 3 3
for (let j = 0; j < 3; j++) setTimeout(() => console.log(j));   // 0 1 2

2.12 new 的四步与原型链的实际查找路径

/**
 * 模拟 new 的行为,用于解释「new 到底做了什么」。
 * @param {Function} Ctor 构造函数
 * @param {...any} args 参数
 */
function mockNew(Ctor, ...args) {
  // 1. 创建一个以 Ctor.prototype 为原型的对象
  const obj = Object.create(Ctor.prototype);
  // 2. 以该对象为 this 执行构造函数
  const result = Ctor.apply(obj, args);
  // 3. 构造函数返回对象则用它,否则用第 1 步创建的对象
  return result !== null && typeof result === 'object' ? result : obj;
}

属性查找路径:obj → Ctor.prototype → Object.prototype → null。三个易错点:

现象原因
改 Ctor.prototype 后新实例生效、老实例不生效老实例的 [[Prototype]] 指向旧对象
用对象字面量重写 prototype 后 constructor 丢失新对象没有 constructor 属性,需手动补
instanceof 对 Object.create(null) 为 false原型链为 null,与任何构造器无关
class Base { #secret = 1; static #count = 0; }
// # 私有字段是运行时的硬保证:外部无法读取、无法伪造,且 Reflect 也拿不到

2.13 ESM 的活绑定与循环依赖

ESM 的 import 不是值拷贝,而是指向导出绑定的只读引用,导出方更新后导入方能读到新值。

// counter.js
export let count = 0;
export function increment() { count++; }

// main.js
import { count, increment } from './counter.js';
increment();
console.log(count);   // 1:活绑定
差异点ESMCJS
绑定语义活绑定(只读引用)值的拷贝(导出时快照)
解析时机静态解析,编译期确定依赖图运行时 require
循环依赖提升,未初始化访问抛 TDZ 错误返回「半成品」module.exports
顶层 await支持不支持
条件加载动态 import()任意位置 require

双包问题:同一个库同时发布 CJS 与 ESM,两种格式被同时加载,导致「两份单例」——状态不共享、instanceof 失效。解法是在 package.json 里用 exports 条件导出,并保证同一入口只解析到一份。

{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

2.14 Promise 家族的行为差异与 await 的微任务次数

API任一失败时结果形态适用
Promise.all立即 reject(其余仍在跑)值数组全部成功才有意义
Promise.allSettled不 reject{status, value/reason} 数组批量任务要逐项报告
Promise.race取最先落定的(含失败)单个值超时竞速
Promise.any全部失败才 reject(AggregateError)第一个成功值多源取最快成功

await 会拆成微任务。数清次数:

async function run() {
  console.log('a');
  await null;            // 产生一次微任务
  console.log('b');
  await null;            // 再一次
  console.log('c');
}

run();
console.log('d');

// 输出:a d b c

并发与顺序:for 循环里 await 是串行;要并发用 Promise.all + map:

// 串行:总耗时 = 各请求之和
for (const id of ids) await fetchUser(id);

// 并发:总耗时 = 最慢的那个,但要注意并发上限
await Promise.all(ids.map((id) => fetchUser(id)));

超时与取消的正确封装:

/**
 * 给任意 Promise 加超时,超时后通过 AbortSignal 通知底层请求取消。
 * @template T
 * @param {(signal: AbortSignal) => Promise<T>} task
 * @param {number} timeoutMs
 */
async function withTimeout(task, timeoutMs) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    return await task(controller.signal);
  } finally {
    clearTimeout(timer);   // 无论成功失败都要清理,否则计时器泄漏
  }
}

只 reject 而不 abort,底层请求仍在跑并计费——这是「超时了还在烧钱」的常见原因。

2.15 Proxy 的不变量约束与「为什么不能用它做万能拦截」

Proxy 的陷阱必须遵守规范定义的不变量,违反会直接抛 TypeError:

陷阱关键不变量
get目标属性是不可配置只读数据属性时,必须返回同值
set不可配置只读属性不能被改为不同值
deleteProperty不可配置属性不能删
has目标不可扩展且属性不存在时不能返回 true
ownKeys必须包含所有不可配置的自有属性
getPrototypeOf目标不可扩展时返回值必须是真实原型
/**
 * 用 Proxy 实现「只读视图」:写操作抛错,读操作透传。
 * 注意:这是浅层保护,嵌套对象仍可变。
 * @template {object} T
 * @param {T} target
 * @returns {T}
 */
function readonly(target) {
  return new Proxy(target, {
    set() {
      throw new TypeError('只读视图,禁止修改');
    },
    deleteProperty() {
      throw new TypeError('只读视图,禁止删除');
    },
  });
}

三个边界:

  1. Proxy 会破坏方法内的 this:proxy.method() 中方法收到的 this 是 proxy 而非 target,若方法内部访问私有字段(#x)会抛错——私有字段只认真实实例。
  2. 性能不是零成本:每次属性访问都会进陷阱,热路径上比直接访问慢一个量级。
  3. 无法拦截「已存在的自有属性直接读写」之外的底层操作:如 Object.defineProperty 需显式实现 defineProperty 陷阱。

2.16 内存泄漏的五种典型形态

形态特征定位方法修法
未清理的定时器setInterval 持续持有回调闭包快照里看 Timeout 数量增长组件卸载时 clearInterval
未移除的事件监听window 上的监听持有组件实例快照搜 EventListener用 AbortController 统一注销
全局缓存无上限Map 只增不减快照中 Map 的 retained sizeLRU + 容量上限
分离 DOM节点已移出文档但被 JS 引用快照筛「Detached」置空引用、避免闭包持有
悬挂的 Promise永不 resolve 的链持有上下文堆里大量 Promise reaction明确超时与取消
/**
 * 事件监听与组件同生命周期:用 AbortController 一次性注销。
 * @param {HTMLElement} el
 */
function bindWithSignal(el) {
  const controller = new AbortController();
  const { signal } = controller;

  el.addEventListener('click', onClick, { signal });
  window.addEventListener('resize', onResize, { signal });

  // 卸载时调用一次,全部监听同时移除
  return () => controller.abort();
}

验证是否真的回收:DevTools 里手动触发 GC(垃圾桶图标)后再拍快照,对比两次快照的 retained size;只有「GC 后仍存在」才算泄漏。

2.17 L2 小结与自检

主题关键结论
闭包函数 + 词法环境;既能做私有状态也会造成泄漏
原型实例 → 构造函数.prototype → Object.prototype
模块ESM 静态 + live binding;CJS 运行时 + 值拷贝
Promise状态不可逆;then 里 throw 才会进 catch
async无依赖请求必须 Promise.all 并行
Proxy拦截 13 种操作,Vue 3 响应式基础,性能有代价
GC可达性;六种典型泄漏
手写防抖/节流/深拷贝是基本功

自检题

  1. for...of 遍历对象为什么报错?——没有实现迭代器协议。
  2. Promise.all 与 allSettled 什么时候用哪个?——需要「有一个成功就够/部分失败也要展示」时用 allSettled。
  3. 闭包什么时候会导致泄漏?——捕获了大对象且函数被长期持有。

3. L3 高级:引擎、内存与并发

3.1 V8 内部:隐藏类与内联缓存

JS 是动态类型,但 V8 会为对象建立隐藏类(Hidden Class / Map),记录「这个对象有哪些属性、什么顺序、什么类型」,从而像静态语言一样用偏移量访问属性。

// 好:属性顺序一致,共享隐藏类
function createPoint(x, y) { return { x, y }; }
const p1 = createPoint(1, 2);
const p2 = createPoint(3, 4); // 与 p1 同一个隐藏类

// 坏:动态增删属性导致隐藏类迁移链变长
const p3 = { x: 1 };
p3.y = 2;      // 迁移到新隐藏类
delete p3.x;   // 变成字典模式(慢属性)

内联缓存(Inline Cache):函数里 obj.x 的访问点会记住「上次这个位置是什么类、x 在哪个偏移」,下次直接读。缓存分三种状态:

  • 单态(monomorphic):只见过一种隐藏类,最快;
  • 多态(polymorphic):见过 2-4 种,稍慢;
  • 超态(megamorphic):见过太多,退化为哈希查找,最慢。

实践结论:

  1. 构造函数里一次性初始化所有属性,且顺序固定。
  2. 不要 delete 属性(用 obj.x = undefined 或新对象替代)。
  3. 同一函数尽量只接收同一种形状的对象(避免超态)。
  4. try/catch 包裹的函数在历史版本中会影响优化,现代 V8 已大幅改善,不必过度规避。

3.2 内存问题定位

Heap Snapshot(DevTools Memory 面板):

  1. 操作前拍一张(Baseline);
  2. 反复执行可疑操作 10 次;
  3. 再拍一张,对比 # Delta(新增对象数);
  4. 看 Retained Size(该对象被回收能释放多少内存),而不是 Shallow Size。

Detached DOM:快照里搜索 Detached,常见于「移除节点但变量仍引用」。

内存泄漏的验证方法:

// 手动观察(Node 环境)
setInterval(() => {
  const { heapUsed } = process.memoryUsage();
  console.log(`堆内存:${(heapUsed / 1024 / 1024).toFixed(1)} MB`);
}, 5000);

如果内存曲线持续上升且 GC 后不回落,就是泄漏。

弱引用(适合缓存):

const cache = new WeakMap(); // 键是对象,键被回收时条目自动消失

function attachMeta(el, meta) {
  cache.set(el, meta); // el 被移除且无其它引用时,缓存自动清理
}

WeakMap/WeakSet 的键必须是对象,且不可枚举(无法遍历),这正是它能自动清理的原因。

3.3 渲染时机与任务调度

一帧的预算(60fps ≈ 16.7ms)大致分配:

输入处理 → rAF 回调(动画)→ 样式计算 → 布局 → 绘制 → 合成

长任务(Long Task):任何超过 50ms 的主线程任务,会阻塞输入响应与渲染。Performance 面板里标红。

拆分长任务的正确方式:

/**
 * 把大循环拆成多个小任务,让浏览器有机会渲染与响应输入。
 * @param {number[]} items 待处理数据
 * @param {(item: number) => void} handle 单项处理逻辑
 */
async function processInChunks(items, handle) {
  const CHUNK = 200;
  for (let i = 0; i < items.length; i += CHUNK) {
    const slice = items.slice(i, i + CHUNK);
    slice.forEach(handle);
    // 让出主线程:用宏任务,微任务不会让出渲染机会
    await new Promise((resolve) => setTimeout(resolve, 0));
  }
}

为什么不用 queueMicrotask 让出?因为微任务会在同一轮事件循环里全部清空,浏览器依然没有渲染机会。

更现代的方式:

// 优先级调度(Chrome 94+)
scheduler.postTask(task, { priority: 'background' });

// 判断是否有待处理的用户输入
if (navigator.scheduling?.isInputPending?.()) {
  await yieldToMain();
}

3.4 并发模型:Worker

// main.js
const worker = new Worker('./heavy.worker.js', { type: 'module' });

worker.postMessage({ type: 'compute', payload: bigArray });

worker.onmessage = (event) => {
  console.log('结果', event.data);
};

// heavy.worker.js
self.onmessage = (event) => {
  const { payload } = event.data;
  const result = payload.map((n) => n * 2);
  self.postMessage(result);
};

通信成本:postMessage 使用结构化克隆,会复制数据(大对象很贵)。三种优化:

// 1. Transferable:转移所有权,零拷贝(原线程失效)
worker.postMessage(buffer, [buffer]);

// 2. SharedArrayBuffer:共享内存(需 COOP/COEP 响应头)
const sab = new SharedArrayBuffer(1024);
const view = new Int32Array(sab);
Atomics.add(view, 0, 1);

// 3. 只传必要字段,别传整个 store
worker.postMessage({ id, range: [start, end] });

Worker 池:避免频繁创建销毁(每个 Worker 有启动成本)。

OffscreenCanvas:把 Canvas 渲染搬到 Worker,彻底解放主线程(图表、游戏、图像处理)。

注意:Worker 里没有 window、document、localStorage,有 self、fetch、indexedDB、caches。

3.5 可中断计算与协作式调度

把大任务做成「可暂停/可取消」的生成器:

/**
 * 协作式任务:每处理一批就 yield 一次,外部可随时中止。
 * @param {number[]} items 数据
 */
function* processCooperatively(items) {
  const BATCH = 100;
  for (let i = 0; i < items.length; i += BATCH) {
    for (let j = i; j < Math.min(i + BATCH, items.length); j++) {
      // 处理 items[j]
    }
    yield i; // 交还控制权
  }
}

const it = processCooperatively(data);
function tick() {
  const { done } = it.next();
  if (!done) requestAnimationFrame(tick); // 每帧处理一批
}

AbortController 贯穿取消链路:

const controller = new AbortController();

fetch('/api/data', { signal: controller.signal }).catch((err) => {
  if (err.name === 'AbortError') return; // 正常取消,不算错误
  throw err;
});

// 用户切换页面 / 输入新关键词
controller.abort();

3.6 性能敏感写法

// 1. 避免在热路径创建闭包与临时对象
// 差:每帧都创建新数组
function frame() { render([...items, extra]); }

// 2. 稀疏数组会退化为字典模式
const sparse = [];
sparse[1000000] = 1; // 不要这样

// 3. 字符串拼接:少量用 +,大量用数组 join 或模板
const html = parts.join('');

// 4. 对象池:高频创建/销毁大对象时复用
class Pool {
  constructor(factory) {
    this.factory = factory;
    this.free = [];
  }
  acquire() { return this.free.pop() ?? this.factory(); }
  release(obj) { this.free.push(obj); }
}

不要过早优化:先用 Performance 面板找到真正的瓶颈,再针对热点优化。大部分「性能技巧」的收益远小于「减少一次不必要的渲染」。

3.7 异常可观测

// 1. 同步错误
window.addEventListener('error', (event) => {
  report({ type: 'js-error', message: event.message, stack: event.error?.stack });
});

// 2. Promise 未处理拒绝
window.addEventListener('unhandledrejection', (event) => {
  report({ type: 'unhandled-rejection', reason: String(event.reason) });
});

// 3. 资源加载失败(不冒泡,需捕获阶段)
window.addEventListener(
  'error',
  (event) => {
    const target = event.target;
    if (target instanceof HTMLImageElement) {
      report({ type: 'img-error', src: target.src });
    }
  },
  true
);

跨域脚本的错误信息会被浏览器屏蔽(只显示 Script error.),解决:脚本响应头加 Access-Control-Allow-Origin + <script crossorigin>。

3.8 标准演进:怎么判断一个特性能不能用

TC39 阶段:

阶段含义实践
Stage 0想法不要用
Stage 1提案关注
Stage 2草案可用 Babel 试验
Stage 3候选,浏览器开始实现可用于非核心功能
Stage 4进入规范,下一版 ES可放心用(配合转译)

近两年值得关注的:

  • Array.prototype.group / Object.groupBy:按 key 分组。
  • structuredClone:原生深拷贝。
  • Array.fromAsync:异步可迭代转数组。
  • Temporal:替代 Date 的新日期 API(不可变、带时区)。
  • using(显式资源管理):自动释放资源(类似 try/finally 的语法糖)。

3.9 正则与文本处理

// 命名捕获组
const m = /(?<year>\d{4})-(?<month>\d{2})/.exec('2026-10');
m.groups.year; // '2026'

// 后行断言
/(?<=¥)\d+/.exec('价格 ¥199'); // '199'

// Unicode 属性转义
/\p{Script=Han}+/u.test('中文'); // true

// 全局匹配用 matchAll,避免 lastIndex 陷阱
for (const match of 'a1b2'.matchAll(/\d/g)) {
  console.log(match[0]);
}

性能警告:(a+)+b 这类嵌套量词在长文本上会指数级回溯,可被用作 ReDoS 攻击。处理用户输入的正则要限制长度或使用非回溯写法。

3.10 隐藏类与内联缓存:为什么「换个写法就慢了」

V8 用隐藏类(Shape)描述对象结构,用内联缓存(IC)记录「上次在这里读到的是哪个偏移」。两者都依赖结构的稳定性。

让隐藏类失效的写法:

写法后果
先创建空对象再逐个加属性每加一个就迁移一次隐藏类
同一构造函数产生的对象属性顺序不同生成不同的隐藏类,IC 变多态
delete obj.prop从快速属性退化为字典模式
属性名动态拼接(obj[k])无法静态确定偏移,IC 退化为 megamorphic
给数组混入不同元素类型elements kind 降级(packed double → packed elements)
// 差:逐个追加,形状反复迁移
const a = {};
a.x = 1;
a.y = 2;

// 好:一次性定型,隐藏类稳定
const b = { x: 1, y: 2 };

多态分级(同一处代码看到的形状数量):

状态形状数性能
monomorphic1最快,直接命中缓存
polymorphic2–4逐个比较,略慢
megamorphic> 4退化为哈希查找

观察手段:Chrome 的 --trace-ic,或在 DevTools Performance 面板看 IC 相关事件。日常不必过度优化,但要避免在热路径上干「动态拼属性名」这种事。

3.11 内存问题的定位流程

顺序很重要,跳步会得到误导性结论:

步骤工具看什么
1. 确认是否真在涨Performance MonitorJS heap 是否锯齿上升且不回落
2. 找到增长来源Allocation instrumentation on timeline哪段时间分配最多
3. 定位持有者Heap snapshot(拍两次对比)谁的 retained size 在涨
4. 找到引用链快照中的 Retainers「谁在引用它」
5. 验证修复强制 GC 后再拍对象是否消失
/**
 * 在页面里临时观测堆增长速率,用于判断「是泄漏还是正常缓存」。
 * @param {number} [intervalMs=5000]
 * @returns {() => void} 停止观测
 */
function watchHeap(intervalMs = 5000) {
  let prev = performance.memory?.usedJSHeapSize ?? 0;

  const timer = setInterval(() => {
    const now = performance.memory?.usedJSHeapSize ?? 0;
    console.log(`堆增长: ${((now - prev) / 1024 / 1024).toFixed(2)} MB`);
    prev = now;
  }, intervalMs);

  return () => clearInterval(timer);
}

注意 performance.memory 只在 Chromium 且非严格隐私模式下可用,生产环境应换成自定义埋点。

分离 DOM 的识别:快照里筛 Detached,展开 Retainers,通常能看到「一个已卸载组件的闭包持有了旧节点」。

3.12 调度:一帧的时间预算

16.7ms(60Hz)预算分配
├─ 输入事件处理          优先,必须快
├─ rAF 回调(动画)
├─ 样式计算 / 布局
├─ 绘制 / 合成
└─ 剩余时间才能跑长任务
手段特点适用
setTimeout(fn, 0)最小 4ms 嵌套延迟,无优先级不推荐做调度
requestIdleCallback只在空闲执行,可能永不执行非关键的后台任务
MessageChannel宏任务,无 4ms 延迟时间切片的首选
scheduler.postTask有优先级(user-blocking/background)现代浏览器,可配 signal
/**
 * 用 MessageChannel 把长任务切片,避免阻塞输入响应。
 * @param {any[]} items
 * @param {(item: any) => void} handle
 * @param {number} [budgetMs=8] 每片的时间预算
 */
function runInSlices(items, handle, budgetMs = 8) {
  const channel = new MessageChannel();
  let index = 0;

  const step = () => {
    const start = performance.now();

    while (index < items.length && performance.now() - start < budgetMs) {
      handle(items[index++]);
    }

    if (index < items.length) channel.port2.postMessage(null);
  };

  channel.port1.onmessage = step;
  channel.port2.postMessage(null);   // 启动
}

为什么不用 setTimeout:嵌套超过 5 层后每次至少 4ms,切片粒度会变得很粗;MessageChannel 没有这个钳制。

3.13 Worker:结构化克隆的成本与转移

postMessage 默认走结构化克隆,大对象会有明显的主线程阻塞(序列化发生在发送方)。

数据类型默认行为优化
ArrayBuffer拷贝用 transfer 转移所有权,零拷贝
大数组(普通)深拷贝改用 TypedArray + transfer
可转移流拷贝ReadableStream 可直接转移
函数/闭包抛错只能传数据,不能传行为
/**
 * 把图像像素交给 Worker 处理,并转移缓冲区避免拷贝。
 * @param {Worker} worker
 * @param {ImageData} imageData
 */
function processPixels(worker, imageData) {
  const buffer = imageData.data.buffer;

  worker.postMessage({ buffer, width: imageData.width, height: imageData.height }, [
    buffer,   // 转移:主线程之后访问该 buffer 会得到 detached 状态
  ]);
}

共享内存需要 SharedArrayBuffer,前提是页面开启了跨源隔离:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

没有这两个响应头,SharedArrayBuffer 不可用——这是「本地能跑、线上 undefined」的常见原因。

3.14 异常可观测:跨域脚本的错误为什么只剩 Script error.

场景现象解决
跨域脚本抛错Script error.,无堆栈<script crossorigin="anonymous"> + 服务端 CORS
Promise 未捕获不触发 window.onerror监听 unhandledrejection
资源加载失败不冒泡到 windowaddEventListener('error', fn, true) 捕获阶段
错误被吞catch 里没上报统一封装 error.cause 链
/**
 * 统一错误上报:同时覆盖同步异常、未捕获 Promise、资源加载失败。
 * @param {(payload: object) => void} report 上报函数
 */
function installErrorReport(report) {
  window.addEventListener('error', (event) => {
    // 资源加载失败时 event.target 是元素,没有 error 对象
    if (event.target && event.target !== window) {
      report({ type: 'resource', url: event.target.src || event.target.href });
      return;
    }
    report({ type: 'js', message: event.message, stack: event.error?.stack });
  }, true);   // 捕获阶段,否则资源错误收不到

  window.addEventListener('unhandledrejection', (event) => {
    report({ type: 'promise', reason: String(event.reason) });
  });
}

还原堆栈需要 SourceMap,上报时记得带上构建版本号与 release 标识,否则线上堆栈无法定位到源码行。

3.15 L3 小结与自检

  1. 为什么 delete obj.x 会变慢?——隐藏类迁移 + 退化为字典模式。
  2. 如何证明一次卡顿是 GC 引起的?——Allocation Timeline + 内存曲线 + GC 事件标记。
  3. postMessage 传大对象慢,怎么解决?——Transferable / SharedArrayBuffer / 只传必要字段。

4. L4 专家:规范、引擎与工具链

4.1 规范级推理

有些问题只能从规范找到答案:

  • TDZ 的检查时机:引用未初始化的绑定,规范在 GetBindingValue 步骤抛错,而不是「提升时」。
  • this 的确定:CallExpression 求值时先求 ref,再判断是 Reference 还是值,决定 thisValue。
  • Proxy 的不变量:get trap 返回的值如果与目标对象的不可配置只读属性冲突,会抛 TypeError——这保证了 Proxy 不能「谎报」不可配置属性。
  • Object.freeze 与 Proxy:冻结目标后,set trap 必须返回 false 或抛错,否则也会违反不变量。

为什么这重要:写库(状态管理、ORM、框架)时,这些边界决定你的实现会不会在用户手里炸。

4.2 引擎实现差异

同一段代码在 V8 / JSC / SpiderMonkey 上可能不同:

场景差异
Date 解析非 ISO 格式(2026-10-30 10:00)各引擎行为不一致
Intl时区名、货币格式细节有差异
Error.stack格式不同(V8 带函数名与列号,JSC 不同)
正则部分后行断言/命名组的历史支持差异
对象属性顺序整数键升序、字符串键插入序,规范已统一,但历史实现有差异

建立跨引擎回归用例集:把「依赖引擎行为」的代码抽成测试,在 Chrome/Safari/Firefox 各跑一遍。

4.3 JS 与 WebAssembly

const { instance } = await WebAssembly.instantiateStreaming(fetch('/math.wasm'));
const result = instance.exports.fibonacci(40);

适用边界:

适合 WASM不适合
图像/音视频处理DOM 操作
加密、压缩、编解码频繁调用的小函数(边界成本高)
大型数值计算(物理、几何)依赖 GC 的复杂对象图
已有 C/C++/Rust 库复用简单业务逻辑

边界成本:JS ↔ WASM 每次调用都要转换参数(数字直接、字符串要编码/解码到线性内存)。大批量处理 + 少量跨界才是正确用法。

4.4 AST 与工具链

import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import generate from '@babel/generator';

const code = `const answer = 40 + 2;`;
const ast = parse(code, { sourceType: 'module' });

traverse(ast, {
  BinaryExpression(path) {
    // 编译期常量折叠:40 + 2 → 42
    if (path.node.left.type === 'NumericLiteral' && path.node.right.type === 'NumericLiteral') {
      path.replaceWith({ type: 'NumericLiteral', value: path.node.left.value + path.node.right.value });
    }
  },
});

generate(ast).code; // 'const answer = 42;'

工程价值:

  • 自动化重构(批量替换 API、迁移框架);
  • 编写 lint 规则(检测危险写法);
  • 编译期优化(常量折叠、死代码消除);
  • 生成类型 / 文档(从 AST 提取 props)。

4.5 性能归因:把「慢」拆开

一次页面卡顿的完整归因链:

用户感受到卡顿
  → 掉帧(Performance 面板显示 >16.7ms 的帧)
    → 主线程长任务(Long Task)
      → 具体函数(Bottom-Up 排序)
        → 是 JS 执行?强制同步布局?GC?还是绘制?

工具组合:

现象工具看什么
卡顿Performance 面板主线程火焰图、长任务、Layout 警告
内存增长Memory 面板Heap Snapshot 对比、Allocation Timeline
渲染慢Layers / Rendering合成层数量、Paint flashing
网络慢Network瀑布图、TTFB、资源优先级

4.6 组织级语言策略

决策关键问题常见答案
目标语法版本用户浏览器分布现代浏览器用 ES2020+,配合 browserslist
是否 polyfill不支持比例与功能重要性核心功能按需 polyfill,非核心降级
是否上 TS团队规模与项目寿命中大型项目必上,小工具可选 JSDoc
老代码迁移存量代码量与风险绞杀者模式:新代码新规范,旧代码逐模块替换
lint 严格度团队共识先 recommended,再逐步加类型感知规则

规范落地的关键:不是写文档,而是工具化——lint 规则、模板、脚手架、CI 门禁。文档只能说服愿意遵守的人。

4.7 读规范:怎么把「怪异行为」查到根

步骤做法
1定位到条款:ECMA-262 用章节号 + 标题,如 13.3.10 是 new 表达式
2区分 normative(规定行为)与 note(解释性说明)
3看 [[...]] 内部方法与抽象的 ToXxx 操作
4按「先查前提条件,再查步骤」的顺序读,异常路径通常在步骤里显式 throw

一个示范:为什么 new.target 在箭头函数里是语法错误?因为箭头函数没有自己的 [[Construct]],规范在 FunctionDeclaration 的早期错误规则里直接禁止。

读规范的收益不是背条款,而是知道「这不是引擎的 bug」,从而不去写奇怪的绕行代码。

4.8 引擎差异:三类历史遗留

差异现象现在还需要关心吗
TDZ 与块级函数早期各引擎对 if (x) { function f(){} } 处理不同现代引擎已统一,老代码要小心
sort 稳定性ES2019 前不稳定已统一,但业务代码仍应显式给比较函数
日期字符串解析new Date('2026-01-01') 曾按 UTC,'2026/01/01' 按本地仍未完全统一,永远显式传参或带时区
toFixed 舍入浮点表示导致 1.005.toFixed(2) 结果不符直觉金融计算用整数分或 decimal 库
// 时间处理的安全写法:显式给出时区语义
const d = new Date(Date.UTC(2026, 0, 1));
// 展示时再按用户时区格式化
const text = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai' }).format(d);

4.9 JS 与 WebAssembly 的边界成本

维度JSWASM
内存GC 托管线性内存,手动管理
调用开销普通函数调用跨边界调用有固定成本,不要高频细粒度调用
适合负载业务逻辑、DOM 操作编解码、图像处理、加密、物理计算
启动解析 + 编译编译 + 实例化,大模块启动慢

判断标准:单次调用耗时要远大于跨边界开销才值得用 WASM。把 add(a, b) 搬进 WASM 只会更慢。

4.10 AST 与工具链:插件为什么能改代码

三段式流水线:parse(源码 → AST)→ transform(遍历修改)→ generate(AST → 源码)。

/**
 * Babel 插件骨架:把 console.log 替换为项目统一的 logger。
 * @param {import('@babel/core')} babel
 */
module.exports = function removeConsole({ types: t }) {
  return {
    name: 'replace-console',
    visitor: {
      MemberExpression(path) {
        if (!path.matchesPattern('console.log')) return;
        // 用带命名空间的对象替换,保留后续的调用参数
        path.replaceWith(t.memberExpression(t.identifier('logger'), t.identifier('info')));
      },
    },
  };
};

SWC/esbuild 快的两个真实原因:编译期并行(Rust/Go 无 GC) 与 不做类型检查。所以它们能做构建加速,但类型检查仍需 tsc 单独跑。

4.11 性能归因:把「慢」拆成五段

段落观测指标常见原因
网络TTFB、下载时长无缓存、串行请求
解析/编译scripting 中的 compile包太大、无必要 polyfill
执行长任务、火焰图同步大循环、重复计算
GC主线程 GC 事件频繁分配、大对象反复创建
渲染样式/布局耗时大面积重排、频繁读写混合

拆分方法:在 Performance 面板按这五段给火焰图「分区」,看哪一段占比最大再动手。90% 的「JS 慢」实际是渲染或网络问题,先分段再优化能省掉大量无用功。

4.12 组织级语言策略

决策建议理由
编译目标对齐 browserslist,不盲目降级过度降级增加 polyfill 体积
polyfill按需注入,不做全量全量 core-js 可能增加数百 KB
TS 严格度strict: true + noUncheckedIndexedAccess把运行时错误前移到编译期
特性开关用构建期常量做死代码消除运行期 if 无法摇树
依赖引入优先原生 API一个日期库可能换掉整个 Intl
// 构建期常量,未命中的分支会被完全移除
if (__ENABLE_DEBUG_PANEL__) {
  mountDebugPanel();
}

4.13 L4 自检

  1. 一次「偶发卡顿」如何证明是 GC 而不是长任务?——Performance 里看是否存在超过 50ms 的 Task 及其调用栈(长任务有明确 JS 栈);GC 多表现为周期性尖峰、栈很浅,Memory 面板呈锯齿并在时间线标出 GC;Node 侧可用 --trace-gc 对照,再比较分配速率与堆上限。
  2. 团队要从 ES5 迁移到原生 ESM + 现代语法,分阶段方案与风险点?——先改构建与 browserslist,再用 codemod 换语法(let/const、箭头、模板串),然后 CJS 转 ESM 并处理循环依赖与 live binding,最后去 polyfill;风险集中在老旧容器内核、动态 require、CJS 互操作与第三方包。
  3. 核心计算是否该搬去 WASM?给出量化评估框架。——看它是否纯计算且占 profile 20% 以上、跨边界数据拷贝是否可控、包体增量与加载时间、团队维护成本;用原型对比吞吐、延迟与内存,收益不足 2 倍就不迁。

5. 综合实战:一个「搜索框」的完整实现

需求:输入关键词,300ms 防抖后请求接口,展示结果;切换页面时取消请求;结果超过 1000 条时用 Worker 做过滤。

步骤 1 · 防抖 + 取消

let controller = null;

const search = debounce(async (keyword) => {
  controller?.abort();               // 取消上一次
  controller = new AbortController();

  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, {
      signal: controller.signal,
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    render(await res.json());
  } catch (err) {
    if (err.name === 'AbortError') return; // 正常取消
    showError(err);
  }
}, 300);

步骤 2 · 竞态处理

let seq = 0;

async function request(keyword) {
  const mySeq = ++seq;
  const data = await fetchResult(keyword);
  if (mySeq !== seq) return; // 旧请求,丢弃
  render(data);
}

步骤 3 · Worker 过滤

// filter.worker.js
self.onmessage = ({ data }) => {
  const { list, keyword } = data;
  const result = list.filter((item) => item.name.includes(keyword));
  self.postMessage(result);
};

步骤 4 · 性能与体验

  • 输入时不阻塞:防抖 + 取消;
  • 大列表渲染:虚拟滚动(见 11 性能);
  • 错误提示:区分「网络错误」与「无结果」;
  • 无障碍:role="searchbox"、结果数量用 aria-live 播报。

步骤 5 · 生产级检查表

维度判据反例
错误处理网络、业务、未知三类错误分开处理只有一个 catch 打日志
取消每次请求都能被下一次输入取消旧请求返回覆盖新结果
并发并发请求有上限,失败可重试一次打出上百个请求
内存定时器、监听、Worker 都在卸载时释放组件卸载后仍在轮询
可测性核心逻辑与 DOM 解耦,可单测逻辑全写在事件回调里
降级接口失败时有本地缓存或空态白屏
/**
 * 可取消的防抖搜索:新输入到来时,先取消上一次请求。
 * @param {(keyword: string, signal: AbortSignal) => Promise<any[]>} search
 * @param {number} [delay=300]
 */
function createSearch(search, delay = 300) {
  let timer = null;
  let controller = null;

  return (keyword) => {
    clearTimeout(timer);
    controller?.abort();            // 关键:取消旧请求,避免竞态覆盖

    timer = setTimeout(async () => {
      controller = new AbortController();
      try {
        const result = await search(keyword, controller.signal);
        render(result);
      } catch (error) {
        if (error.name === 'AbortError') return;   // 主动取消不算错误
        renderError(error);
      }
    }, delay);
  };
}

6. 常见误区对照表

误区事实
「闭包一定导致内存泄漏」只有「被长期持有且不再需要」的引用才是泄漏
「微任务优于宏任务,所以都用微任务」微任务会阻塞渲染,长任务切片应该用宏任务
「async 里 await 越多越好读」串行 await 会让并发退化为串行,性能损失巨大
「深拷贝一律 JSON.parse(JSON.stringify())」丢失 Date/Map/Set/undefined/函数,遇循环引用直接报错
「Proxy 比 defineProperty 全面所以更好」性能与兼容成本更高,Vue 3 的取舍有明确前提
「forEach 里写 async 会等待」不会,需要用 Promise.all + map
「let 没有变量提升」有提升,但处于暂时性死区

7. 面试高频题(附答题思路)

Q1:说说闭包。 定义(函数 + 词法环境)→ 形成条件(内部函数引用外部变量)→ 用途(私有状态、记忆化、柯里化)→ 代价(内存)→ 一个真实例子。

Q2:this 的指向规则? 四条规则 + 优先级 + 箭头函数例外 + 严格模式影响。用一个例子演示四种绑定。

Q3:事件循环,微任务和宏任务的区别? 调用栈 → 微任务清空 → 渲染 → 一个宏任务。说明「为什么长任务会卡 UI」和「为什么微任务死循环会冻结页面」。

Q4:深拷贝怎么实现? 递归 + 循环引用处理(WeakMap)+ 特殊类型(Date/Map/Set/RegExp)+ 原型保持;对比 structuredClone。

Q5:V8 如何优化对象访问? 隐藏类 + 内联缓存 + 单态/多态/超态;给出三条实践建议。


8. 面试深挖

8.1 面试官在考察什么

维度看什么加分回答减分回答
内存模型是否用「可达性」而不是「引用计数」解释泄漏能说清 Retained Size 与 Retainers 链的用法「闭包一定会泄漏」
执行时序微任务、宏任务、渲染三者的相对位置能指出「微任务递归会饿死渲染」只会背「先微后宏」
异步错误错误在哪一层被吞掉区分同步 throw、rejected Promise、unhandledrejection「加 try/catch 就行」
引擎认知是否知道优化会失效能说出隐藏类被破坏的三种写法「V8 会自动优化」
归因能力能否从现象定位到具体函数用 Performance + Memory 两个面板交叉验证只给优化清单,不给证据

8.2 追问链

链 1 · 闭包 → 泄漏判定 → 堆快照定位

  • 面试官:闭包会造成内存泄漏吗?
    • 要点:闭包只是延长变量的生命周期,本身不是泄漏。泄漏的判定标准是「对象已不再被业务需要,但仍从 GC Roots 可达」;V8 用的是可达性分析,不是引用计数,所以循环引用不会泄漏。
  • 追问:怎么证明一块内存「该释放却没释放」?
    • 要点:Memory 面板做三次快照(操作前、操作后、再重复一次操作),在 Comparison 视图按 Delta 排序;先看 Shallow Size(对象自身),再看 Retained Size(回收它能释放的总量),最后沿 Retainers 找到持有者。常见持有者是闭包引用 DOM、未解绑的事件监听、模块级 Map 缓存、未清理的定时器。
  • 再追问:用 WeakRef / FinalizationRegistry 能替代手动解绑吗?
    • 要点:不能。WeakRef 不阻止回收,但回收时机由 GC 决定,deref() 可能随时返回 undefined,不能承担「必须存在」的语义;FinalizationRegistry 的回调时机规范不保证,页面关闭时可能根本不执行。可靠做法是显式解绑 + 有界缓存(LRU)+ AbortController 取消在途请求。

链 2 · 事件循环 → 帧预算 → 调度 API 选型

  • 面试官:一次事件循环里发生了什么?
    • 要点:取一个宏任务执行 → 清空微任务队列 → 浏览器决定是否渲染(style → layout → paint → composite,通常上限 60 帧/秒)→ 渲染前执行 requestAnimationFrame → 帧末空闲执行 requestIdleCallback。微任务在每个宏任务结束后清空,所以微任务递归会饿死渲染。
  • 追问:为什么一个 50ms 的任务会让点击「没反应」?
    • 要点:输入事件、JS 执行、渲染共用主线程;长任务期间点击事件只能排队,INP 的「输入延迟」段直接增长。Performance 面板中超过 50ms 的 Task 会被标红,这是长任务的通用阈值。
  • 再追问:scheduler.postTask、requestIdleCallback、setTimeout(0) 怎么选?
    • 要点:postTask 带 user-blocking / user-visible / background 三档优先级且返回可 abort 的 TaskHandle;requestIdleCallback 只在空闲时执行,可能几十帧都不触发,必须配 timeout 兜底,且不能用于动画;setTimeout(0) 会被嵌套钳制到 4ms 且没有优先级。React Scheduler 用 MessageChannel 自建队列,就是因为 postTask 兼容性不足。

链 3 · Promise → 微任务 → async 的错误路径

  • 面试官:手写 Promise 的关键点是什么?
    • 要点:状态只能从 pending 单向迁移一次;then 必须返回新 Promise 以支持链式;回调必须异步(微任务)执行;resolve 一个 thenable 时要「展开」,递归直到非 thenable;非函数的 onFulfilled / onRejected 要透传。
  • 追问:async 函数里 throw 之后错误去了哪?
    • 要点:async 函数总是返回 Promise,同步 throw 会变成 rejected Promise;await 一个 rejected Promise 会在该行抛出。未处理的 rejection 触发 unhandledrejection,但判定发生在「本轮微任务结束仍无 handler」时,晚绑定的 .catch 可能已经错过。
  • 再追问:为什么 forEach 里写 async 不会按顺序执行?
    • 要点:forEach 同步调用回调,每个 async 回调立刻返回 pending Promise 并继续下一个,循环结束时全部处于 pending。顺序执行用 for...of + await,全并发用 map + Promise.all,限流用信号量或调度器。

8.3 手写题

题 1 · 带并发上限的任务调度器

考察点:队列管理、异常不中断队列、取消语义。

/**
 * 创建并发上限调度器。
 * @param {number} limit 最大并发数
 * @returns {(task: (signal: AbortSignal) => Promise<unknown>) => Promise<unknown>}
 */
function createLimiter(limit) {
  /** @type {Array<{task: Function, resolve: Function, reject: Function, controller: AbortController}>} */
  const queue = [];
  let active = 0;

  /** 从队列取任务,直到达到并发上限 */
  const pump = () => {
    while (active < limit && queue.length > 0) {
      const item = queue.shift();
      if (item.controller.signal.aborted) {
        item.reject(new DOMException('aborted', 'AbortError'));
        continue;
      }
      active += 1;
      Promise.resolve()
        .then(() => item.task(item.controller.signal))
        .then(item.resolve, item.reject)
        .finally(() => {
          active -= 1;
          pump();
        });
    }
  };

  return (task) =>
    new Promise((resolve, reject) => {
      queue.push({ task, resolve, reject, controller: new AbortController() });
      pump();
    });
}

追问:

  • 为什么 .finally 里要再次 pump()?→ 任务完成才可能腾出并发位,否则队列会停在 limit 个任务上。
  • 失败任务会不会卡住队列?→ 不会,then(resolve, reject) 已经把失败转成该 Promise 的结果,finally 仍会执行。
  • 怎么支持「取消排队中的任务」?→ 把 AbortController 暴露给调用方,在 pump 取出时先检查 signal.aborted;在途任务则依赖 task 内部监听 signal。

题 2 · throttle 的完整版

/**
 * 节流:保证 wait 毫秒内最多执行一次。
 * @param {Function} fn 目标函数
 * @param {number} wait 间隔毫秒
 * @param {{leading?: boolean, trailing?: boolean}} [options] 首尾是否执行
 * @returns {Function & {cancel: () => void, flush: () => void}}
 */
function throttle(fn, wait, { leading = true, trailing = true } = {}) {
  let last = 0;
  let timer = null;
  let lastArgs = null;
  let lastThis = null;

  /** 立即执行并重置状态 */
  const invoke = (time) => {
    last = time;
    const args = lastArgs;
    const ctx = lastThis;
    lastArgs = lastThis = null;
    fn.apply(ctx, args);
  };

  const throttled = function (...args) {
    const now = Date.now();
    if (last === 0 && !leading) last = now;

    lastArgs = args;
    lastThis = this;
    const remaining = wait - (now - last);

    if (remaining <= 0) {
      if (timer) {
        clearTimeout(timer);
        timer = null;
      }
      invoke(now);
    } else if (!timer && trailing) {
      timer = setTimeout(() => {
        timer = null;
        invoke(Date.now());
      }, remaining);
    }
  };

  throttled.cancel = () => {
    clearTimeout(timer);
    timer = null;
    last = 0;
    lastArgs = lastThis = null;
  };

  throttled.flush = () => {
    if (timer) {
      clearTimeout(timer);
      timer = null;
      invoke(Date.now());
    }
  };

  return throttled;
}

追问:

  • leading: true, trailing: true 时最后一次会不会漏?→ 不会,剩余时间内会挂一个定时器补执行;但两次执行间隔可能略大于 wait。
  • 为什么记录 lastThis?→ 事件回调里 this 是绑定元素,直接用箭头函数会丢上下文。
  • 组件卸载时要注意什么?→ 必须调用 cancel(),否则定时器持有闭包与 DOM 引用,形成事实上的泄漏。

8.4 场景设计题

题:一个 10 万行、每行 6 列的运营报表,滚动掉帧到 15fps,点击行要 300ms 才高亮。你怎么定位并优化?

评估点:是否先量化、是否分层归因、是否知道虚拟滚动的代价。

参考方案:

  1. 量化:Performance 录制 10 秒滚动,看主线程是否被单个 Long Task 占满、Layout 次数是否异常;Memory 面板确认 DOM 节点数(10 万行 × 6 列约 60 万节点,本身已超推荐上限)。
  2. 数据层:接口返回与解析放 Worker,主线程只收结构化结果;若数据不可减,按页请求而不是一次全量。
  3. 渲染层:虚拟滚动,固定行高 + transform: translateY 定位,可视区外只保留上下各 5 行缓冲;行高不固定时用估算 + 动态校正。
  4. 交互层:滚动监听加 passive: true;滚动过程中只更新位移,不做行内计算;点击高亮用 CSS 变量切换,避免整行重渲染。
  5. 验证:滚动帧率、INP、DOM 节点数三项前后对比;用 PerformanceObserver('longtask') 埋点做线上回归。

加分:指出虚拟滚动会破坏 Ctrl+F、读屏和打印,需要 aria-rowcount / aria-rowindex 与「导出全部」兜底;指出 transform 定位会创建合成层,行数多时反而增加内存。

减分:只答「用虚拟列表」;不量化就上 will-change;把 10 万行一次性 innerHTML 后指望浏览器扛住。

8.5 最容易答错的点

常见说法纠正
「闭包一定导致内存泄漏」泄漏取决于是否仍被可达引用持有,闭包只是延长生命周期
「微任务优先级高,所以都放微任务」微任务递归会阻塞渲染,切片应交给宏任务或调度 API
「var 没有块级作用域但有函数作用域」说成「没有作用域」它有函数作用域与变量提升,只是没有块级作用域
「await 会自动并行」串行 await 把并发退化为串行,需 Promise.all
「JSON.parse(JSON.stringify()) 能深拷贝一切」丢失 Date/Map/Set/undefined/函数,循环引用直接抛错
「instanceof 能判断所有类型」跨 iframe 与 Object.create(null) 会失效,原始值也不能用
「Proxy 一定比 defineProperty 快」Proxy trap 开销更高,Vue 3 的取舍有明确前提
「try/catch 能捕获异步错误」只能捕获同栈同步错误,异步要走 rejection 处理或全局兜底

8.6 现场追问:从一段输出倒推到规范

链 1 · 「这段代码输出什么?为什么?」

console.log(1);
setTimeout(() => console.log(2));
Promise.resolve().then(() => console.log(3));
queueMicrotask(() => console.log(4));
console.log(5);
  • 第一问:输出顺序?
    • 要点:1 5 3 4 2。同步先执行完(1、5),再清空微任务队列(3、4),最后执行宏任务(2)。
  • 第二问:为什么 queueMicrotask 排在 then 之后?
    • 要点:二者进入同一微任务队列,按入队顺序执行;then 的回调在 resolve 后入队,queueMicrotask 立即入队,但此处 then 先调用。
  • 第三问:如果微任务里再 Promise.resolve().then(...),会插队到 setTimeout 前吗?
    • 要点:会。微任务队列没清空就不会进入下一个宏任务,这也是「微任务递归会饿死渲染」的原因。
  • 第四问:setTimeout(fn, 0) 为什么不是立刻执行?
    • 要点:它至少是「下一个宏任务」,链式嵌套超过 5 层后还有 4ms 最小间隔。

链 2 · 「为什么 this 是 undefined?」

  • 第一问:什么情况下会这样?
    • 要点:严格模式下裸调用,或方法被解构/作为回调传递后丢失绑定;箭头函数的 this 来自定义处的词法环境。
  • 第二问:bind 之后 new 会怎样?
    • 要点:new 优先级高于 bind,this 指向新实例,bind 的预设参数仍生效。
  • 第三问:类里为什么推荐箭头函数字段?
    • 要点:每个实例一份、绑定确定,适合作为回调传递;代价是内存开销增加,且不能被子类通过原型覆盖。

链 3 · 「页面的内存一直涨」

  • 第一问:怎么判断是泄漏还是缓存?
    • 要点:强制 GC 后再看快照是否回落;不回落才是泄漏。
  • 第二问:最常见的三个持有者?
    • 要点:window 上的事件监听、未清理的定时器、闭包持有的分离 DOM。
  • 第三问:怎么让清理不可能被忘记?
    • 要点:用 AbortSignal 统一注销监听、把定时器句柄封装进组件的生命周期、在 CI 里加长跑用例观察堆增长。

8.7 白板三题:20 行以内见功底

题 1 · 深比较(考虑循环引用)

/**
 * 结构化相等判断:支持对象、数组、Date、Map、Set 与循环引用。
 * @param {any} a
 * @param {any} b
 * @param {WeakMap<object, object>} [seen] 已比较过的对象对,用于断开循环
 * @returns {boolean}
 */
function isEqual(a, b, seen = new WeakMap()) {
  if (Object.is(a, b)) return true;
  if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) return false;
  if (a.constructor !== b.constructor) return false;

  if (seen.get(a) === b) return true;   // 同一个环已经比过,直接认为相等
  seen.set(a, b);

  if (a instanceof Date) return a.getTime() === b.getTime();
  if (a instanceof Map || a instanceof Set) return isEqual([...a], [...b], seen);

  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  if (keysA.length !== keysB.length) return false;

  return keysA.every((key) => isEqual(a[key], b[key], seen));
}

追问:为什么要用 WeakMap 而不是 Map?——避免比较过程本身持有对象导致无法回收,且比较完成后无需手动清理。

题 2 · 并发限制器

/**
 * 限制同时执行的任务数量,超出的排队等待。
 * @param {Array<() => Promise<any>>} tasks
 * @param {number} limit
 * @returns {Promise<any[]>} 按输入顺序返回结果
 */
async function runWithLimit(tasks, limit) {
  const results = new Array(tasks.length);
  let cursor = 0;

  /** 一个「工人」循环取任务,直到取完 */
  const worker = async () => {
    while (cursor < tasks.length) {
      const index = cursor++;
      results[index] = await tasks[index]();
    }
  };

  await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
  return results;
}

追问:某个任务失败会怎样?——await 抛出后该工人退出,Promise.all 立即 reject,其余任务仍在跑。要「不中断」需包一层 try/catch 转为 {status, value}。

题 3 · 带自动解绑的事件总线

/**
 * 事件总线:支持一次性监听与批量解绑,避免组件卸载后回调泄漏。
 */
class EventBus {
  /** @type {Map<string, Set<Function>>} */
  #listeners = new Map();

  /**
   * 注册监听。
   * @param {string} type
   * @param {Function} handler
   * @param {{ once?: boolean, signal?: AbortSignal }} [options]
   * @returns {() => void} 手动解绑函数
   */
  on(type, handler, { once = false, signal } = {}) {
    const wrap = (...args) => {
      if (once) this.off(type, wrap);
      handler(...args);
    };

    if (!this.#listeners.has(type)) this.#listeners.set(type, new Set());
    this.#listeners.get(type).add(wrap);

    // 与 AbortSignal 联动:一次 abort 清掉所有相关监听
    signal?.addEventListener('abort', () => this.off(type, wrap), { once: true });

    return () => this.off(type, wrap);
  }

  /**
   * 解除监听。
   * @param {string} type
   * @param {Function} handler
   */
  off(type, handler) {
    this.#listeners.get(type)?.delete(handler);
  }

  /**
   * 触发事件。
   * @param {string} type
   * @param {...any} args
   */
  emit(type, ...args) {
    // 复制一份再遍历,防止回调内部解绑导致遍历跳过
    [...(this.#listeners.get(type) ?? [])].forEach((fn) => fn(...args));
  }
}

追问:为什么 emit 要复制集合?——遍历中删除元素会让迭代器跳过下一项,这是「偶发丢事件」的经典来源。


9. 延伸资源

  • 《JavaScript 高级程序设计(第 4 版)》:体系化基础。
  • 《你不知道的 JavaScript》:作用域、原型、异步三卷。
  • ECMAScript 规范(tc39.es/ecma262):查抽象操作,遇到「为什么」时的最终答案。
  • V8 Blog:Hidden Classes、Orinoco GC、Sparkplug/Maglev。
  • TC39 proposals 仓库:判断新特性成熟度。
  • JavaScript.info:按主题组织的现代教程,适合查漏补缺。
  • 工具:Chrome DevTools Performance/Memory、node --inspect、0x(火焰图)。

下一步练习:打开任意一个你写过的页面,在 Performance 面板录 10 秒操作,找出耗时最长的三个函数。这比背十条优化技巧更有用。