03 · JavaScript 语言(详解版)
一句话定位:JavaScript 是前端唯一的通用语言。框架、构建工具、性能问题、内存泄漏,最终都能还原成「一段 JS 在引擎里怎么执行」。
读完本篇你应该能做到
- 看到一段涉及闭包 / 异步 /
this的代码,能准确说出执行顺序和每个变量的值;- 能解释「为什么这个页面用久了会卡、会崩」,并用 DevTools 找到证据;
- 知道什么时候该用 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
三个必须记住的点:
var的提升是「声明提升、赋值不提升」:声明被移到函数顶部并初始化为undefined,所以访问不报错但拿到undefined。let/const也在「提升」,但处于暂时性死区:从进入作用域到声明语句执行前,访问会抛ReferenceError。这不是「没提升」,而是「提升了但不让访问」。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 == undefined | true,其余 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);
| 值类型 | structuredClone | JSON 方案 |
|---|---|---|
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)
三个容易答错的点:
setTimeout(fn, 0)不是 0ms:嵌套超过 5 层后最小间隔 4ms,且要等当前任务与微任务清空。requestAnimationFrame在渲染前执行:它属于「渲染步骤」,不是宏任务也不是微任务。- 微任务里再产生微任务会继续在同一轮清空:可能造成「饿死渲染」,长链
Promise会让页面卡住。
// 反面例子:微任务无限递归会阻塞渲染与输入响应
function loop() {
Promise.resolve().then(loop); // 不要这样写
}
1.14 L1 小结与自检
| 知识点 | 最该记住的一句 |
|---|---|
| 变量 | var 函数作用域,let/const 块级 + TDZ |
| 类型转换 | 统一用 ===,唯一例外 x == null |
| 引用 | 对象赋值传引用,深拷贝用 structuredClone |
this | 调用时确定;箭头函数捕获外层 |
| 数组 | sort 默认字典序;区分可变/不可变方法 |
| 事件循环 | 同步 → 微任务清空 → 渲染 → 一个宏任务 |
| 事件 | 捕获→目标→冒泡;委托靠 closest |
自检题
const声明的数组能push吗?——能,绑定不可变,内容可变。- 为什么
[] == ![]是true?——右侧![]为false,左侧[]转成''再转0,false也转0。 - 微任务和宏任务谁先执行?——当前宏任务结束后先清空微任务。
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' };
关键差异:
| 维度 | ESM | CJS |
|---|---|---|
| 加载时机 | 静态分析、预加载 | 运行时同步加载 |
| 导出绑定 | live binding(值变化会同步) | 值拷贝(导出后改原始变量不影响导入方) |
require ESM | 部分环境支持 | — |
this | undefined | module.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]] → 全局环境
三个推论:
- 闭包让环境不可回收,直到闭包本身不可达。所以长期存活的闭包持有大对象,就是教科书式泄漏。
- 同一作用域内的多个闭包共享同一个环境,所以它们看到的是同一份变量。
- 「不需要的变量也会被一起留下」——因为环境是整体保留的,不是按变量挑拣。
// 泄漏形态:闭包捕获了巨大的数据,只为读一个字段
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:活绑定
| 差异点 | ESM | CJS |
|---|---|---|
| 绑定语义 | 活绑定(只读引用) | 值的拷贝(导出时快照) |
| 解析时机 | 静态解析,编译期确定依赖图 | 运行时 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('只读视图,禁止删除');
},
});
}
三个边界:
Proxy会破坏方法内的this:proxy.method()中方法收到的this是 proxy 而非 target,若方法内部访问私有字段(#x)会抛错——私有字段只认真实实例。- 性能不是零成本:每次属性访问都会进陷阱,热路径上比直接访问慢一个量级。
- 无法拦截「已存在的自有属性直接读写」之外的底层操作:如
Object.defineProperty需显式实现defineProperty陷阱。
2.16 内存泄漏的五种典型形态
| 形态 | 特征 | 定位方法 | 修法 |
|---|---|---|---|
| 未清理的定时器 | setInterval 持续持有回调闭包 | 快照里看 Timeout 数量增长 | 组件卸载时 clearInterval |
| 未移除的事件监听 | window 上的监听持有组件实例 | 快照搜 EventListener | 用 AbortController 统一注销 |
| 全局缓存无上限 | Map 只增不减 | 快照中 Map 的 retained size | LRU + 容量上限 |
| 分离 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 | 可达性;六种典型泄漏 |
| 手写 | 防抖/节流/深拷贝是基本功 |
自检题
for...of遍历对象为什么报错?——没有实现迭代器协议。Promise.all与allSettled什么时候用哪个?——需要「有一个成功就够/部分失败也要展示」时用allSettled。- 闭包什么时候会导致泄漏?——捕获了大对象且函数被长期持有。
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):见过太多,退化为哈希查找,最慢。
实践结论:
- 构造函数里一次性初始化所有属性,且顺序固定。
- 不要
delete属性(用obj.x = undefined或新对象替代)。 - 同一函数尽量只接收同一种形状的对象(避免超态)。
try/catch包裹的函数在历史版本中会影响优化,现代 V8 已大幅改善,不必过度规避。
3.2 内存问题定位
Heap Snapshot(DevTools Memory 面板):
- 操作前拍一张(Baseline);
- 反复执行可疑操作 10 次;
- 再拍一张,对比
# Delta(新增对象数); - 看
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 };
多态分级(同一处代码看到的形状数量):
| 状态 | 形状数 | 性能 |
|---|---|---|
| monomorphic | 1 | 最快,直接命中缓存 |
| polymorphic | 2–4 | 逐个比较,略慢 |
| megamorphic | > 4 | 退化为哈希查找 |
观察手段:Chrome 的 --trace-ic,或在 DevTools Performance 面板看 IC 相关事件。日常不必过度优化,但要避免在热路径上干「动态拼属性名」这种事。
3.11 内存问题的定位流程
顺序很重要,跳步会得到误导性结论:
| 步骤 | 工具 | 看什么 |
|---|---|---|
| 1. 确认是否真在涨 | Performance Monitor | JS 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 |
| 资源加载失败 | 不冒泡到 window | addEventListener('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 小结与自检
- 为什么
delete obj.x会变慢?——隐藏类迁移 + 退化为字典模式。 - 如何证明一次卡顿是 GC 引起的?——Allocation Timeline + 内存曲线 + GC 事件标记。
postMessage传大对象慢,怎么解决?——Transferable / SharedArrayBuffer / 只传必要字段。
4. L4 专家:规范、引擎与工具链
4.1 规范级推理
有些问题只能从规范找到答案:
- TDZ 的检查时机:引用未初始化的绑定,规范在
GetBindingValue步骤抛错,而不是「提升时」。 this的确定:CallExpression求值时先求ref,再判断是Reference还是值,决定thisValue。- Proxy 的不变量:
gettrap 返回的值如果与目标对象的不可配置只读属性冲突,会抛TypeError——这保证了 Proxy 不能「谎报」不可配置属性。 Object.freeze与 Proxy:冻结目标后,settrap 必须返回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 的边界成本
| 维度 | JS | WASM |
|---|---|---|
| 内存 | 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 自检
- 一次「偶发卡顿」如何证明是 GC 而不是长任务?——Performance 里看是否存在超过 50ms 的 Task 及其调用栈(长任务有明确 JS 栈);GC 多表现为周期性尖峰、栈很浅,Memory 面板呈锯齿并在时间线标出 GC;Node 侧可用
--trace-gc对照,再比较分配速率与堆上限。 - 团队要从 ES5 迁移到原生 ESM + 现代语法,分阶段方案与风险点?——先改构建与
browserslist,再用 codemod 换语法(let/const、箭头、模板串),然后 CJS 转 ESM 并处理循环依赖与 live binding,最后去 polyfill;风险集中在老旧容器内核、动态require、CJS 互操作与第三方包。 - 核心计算是否该搬去 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缓存、未清理的定时器。
- 要点:Memory 面板做三次快照(操作前、操作后、再重复一次操作),在 Comparison 视图按
- 再追问:用
WeakRef/FinalizationRegistry能替代手动解绑吗?- 要点:不能。
WeakRef不阻止回收,但回收时机由 GC 决定,deref()可能随时返回undefined,不能承担「必须存在」的语义;FinalizationRegistry的回调时机规范不保证,页面关闭时可能根本不执行。可靠做法是显式解绑 + 有界缓存(LRU)+AbortController取消在途请求。
- 要点:不能。
链 2 · 事件循环 → 帧预算 → 调度 API 选型
- 面试官:一次事件循环里发生了什么?
- 要点:取一个宏任务执行 → 清空微任务队列 → 浏览器决定是否渲染(style → layout → paint → composite,通常上限 60 帧/秒)→ 渲染前执行
requestAnimationFrame→ 帧末空闲执行requestIdleCallback。微任务在每个宏任务结束后清空,所以微任务递归会饿死渲染。
- 要点:取一个宏任务执行 → 清空微任务队列 → 浏览器决定是否渲染(style → layout → paint → composite,通常上限 60 帧/秒)→ 渲染前执行
- 追问:为什么一个 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要透传。
- 要点:状态只能从 pending 单向迁移一次;
- 追问:
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 才高亮。你怎么定位并优化?
评估点:是否先量化、是否分层归因、是否知道虚拟滚动的代价。
参考方案:
- 量化:Performance 录制 10 秒滚动,看主线程是否被单个 Long Task 占满、Layout 次数是否异常;Memory 面板确认 DOM 节点数(10 万行 × 6 列约 60 万节点,本身已超推荐上限)。
- 数据层:接口返回与解析放 Worker,主线程只收结构化结果;若数据不可减,按页请求而不是一次全量。
- 渲染层:虚拟滚动,固定行高 +
transform: translateY定位,可视区外只保留上下各 5 行缓冲;行高不固定时用估算 + 动态校正。 - 交互层:滚动监听加
passive: true;滚动过程中只更新位移,不做行内计算;点击高亮用 CSS 变量切换,避免整行重渲染。 - 验证:滚动帧率、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 秒操作,找出耗时最长的三个函数。这比背十条优化技巧更有用。