01-js-core
JavaScript 核心
JavaScript 核心
面向大厂前端 P6 / 2-1 级别面试的标准答案。每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理与代码)→ 【追问 & 加分点】。
目录
- 1. 事件循环:宏任务、微任务、浏览器 vs Node、空闲调度
- 2. 闭包:原理、内存位置、无闭包语言如何模拟
- 3. this 指向:完整规则与严格模式
- 4. 原型与继承:new 的过程、instanceof、Object.create
- 5. Promise 与 async/await 的使用区别
- 6. ES6+ 新特性与 ES5 降级构建配置
- 7. 单线程如何处理重任务与网络请求
1. 事件循环:宏任务、微任务、浏览器 vs Node、空闲调度
【核心答案】
JS 引擎本身只有一个调用栈,同步代码跑完栈空之后,由宿主环境(浏览器 / Node)的事件循环决定下一步执行什么。执行单位分两级:宏任务(task) 和 微任务(microtask)。规则是:取一个宏任务执行 → 执行完立刻把微任务队列全部清空(清空过程中新产生的微任务也要在本轮清完)→ 浏览器视需要进行一次渲染 → 再取下一个宏任务。浏览器只有一条宏任务队列语义(实现上按来源分优先级),Node 把宏任务拆成了 timers / pending / poll / check / close 等多个阶段,每个阶段一条队列,并且额外有一条优先级高于 Promise 微任务的 process.nextTick 队列。事件循环空闲时不占用 CPU——它阻塞在操作系统的 I/O 多路复用调用上(epoll / kqueue / IOCP),由内核在事件到达时唤醒线程,是"睡眠等待"而不是"轮询忙等"。
【展开】
1.1 宏任务与微任务的来源
| 类别 | 浏览器常见来源 | Node 常见来源 |
|---|---|---|
| 宏任务 | script 整体、setTimeout / setInterval、DOM 事件回调、网络回调(XHR/fetch 的事件)、MessageChannel、postMessage、requestIdleCallback(idle 时机) | setTimeout / setInterval、setImmediate、I/O 完成回调、close 事件 |
| 微任务 | Promise.then/catch/finally、await 恢复、queueMicrotask、MutationObserver、IntersectionObserver 回调派发 | process.nextTick(更高优先级的独立队列)、Promise 微任务、queueMicrotask |
注意几个容易答错的点:
requestAnimationFrame不是宏任务队列里的普通任务,它属于"渲染步骤(update the rendering)"的一部分,在一帧开始时统一执行(详见浏览器篇)。MutationObserver是微任务;ResizeObserver/IntersectionObserver的回调派发时机绑定在渲染流水线上。- 每个宏任务执行完必须清空微任务队列,这个动作叫 microtask checkpoint,触发条件是"JS 调用栈清空"。所以浏览器里事件回调之间、
rAF的每个回调之间都会各自清一次微任务。
1.2 代码分析题(自构造)
console.log('script start');
setTimeout(() => {
console.log('timeout A');
// 宏任务内部产生的微任务,会在这个宏任务结束后立即执行,
// 不会等到所有 timer 都跑完(Node 11+ 与浏览器一致)
Promise.resolve().then(() => console.log('promise inside timeout A'));
}, 0);
setTimeout(() => console.log('timeout B'), 0);
queueMicrotask(() => console.log('queueMicrotask'));
async function bar() {
console.log('bar sync'); // async 函数体在被调用时同步执行
}
async function foo() {
console.log('foo sync');
await bar(); // await 之后的代码等价于放进 .then 回调
console.log('after await');
}
foo();
new Promise((resolve) => {
console.log('promise executor'); // executor 是同步执行的
resolve();
})
.then(() => console.log('then 1'))
.then(() => console.log('then 2')); // 链式 then 是"一层一个微任务轮次"
console.log('script end');输出顺序:
script start
foo sync
bar sync
promise executor
script end
queueMicrotask
after await
then 1
then 2
timeout A
promise inside timeout A
timeout B逐步推演:
-
同步阶段(第一个宏任务:整段 script)
- 打印
script start。 - 注册两个 0ms 定时器 A、B,进入定时器队列(等待到期后入宏任务队列,且 A 先于 B)。
queueMicrotask→ 微任务队列:[M1]。foo()被调用:打印foo sync;执行bar(),打印bar sync,bar返回一个已 fulfilled 的 Promise;await对这个已决议的原生 Promise 注册恢复回调,入队 → 微任务队列:[M1, M2(恢复 foo)]。new Promise的 executor 同步执行,打印promise executor,resolve()使其立即 fulfilled;.then(then1)注册到已 fulfilled 的 Promise 上,入队 →[M1, M2, M3(then 1)]。第二个.then此时挂在then 1返回的 pending Promise 上,尚未入队。- 打印
script end。同步阶段结束,调用栈清空。
- 打印
-
微任务检查点(严格按 FIFO,边执行边追加)
M1→queueMicrotaskM2→ 恢复foo,打印after await,foo返回的 Promise 决议(无人订阅)M3→then 1,其返回值undefined使下游 Promise 决议 → 追加M4M4→then 2- 队列空,检查点结束。
-
渲染机会(浏览器可能在此插入一帧的样式/布局/绘制)。
-
下一个宏任务:timer A → 打印
timeout A,产生一个微任务;A 回调返回、栈空 → 立即检查点 → 打印promise inside timeout A。 -
下一个宏任务:timer B → 打印
timeout B。
关键结论:微任务不会被别的宏任务插队,一个宏任务只要产生微任务就必须清干净才能进下一个宏任务;这也是"微任务死循环会卡死页面"的原因(
function loop(){ Promise.resolve().then(loop) }),而宏任务死循环还能穿插渲染。
1.3 await 到底占几个微任务轮次
await v 的语义是:PromiseResolve(v) 得到一个 Promise,再在其上 PerformPromiseThen,把 await 之后的代码作为恢复回调。
- ES2019 之前:即使
v已经是原生 Promise,也会额外包一层,需要 3 个微任务轮次才恢复。 - ES2019 的
await优化(V8 7.2 / Chrome 73 / Node 12 起):如果v是构造器为%Promise%的原生 Promise,PromiseResolve直接返回它本身,恢复只需 1 个微任务轮次,和.then对齐。 - 如果
v是 thenable(非原生 Promise),仍需额外轮次去调用它的then方法。
所以老面经里"await 比 .then 慢两拍"的结论在现代引擎上已经不成立,面试时应明确说明版本差异——这是明显的加分点。
1.4 浏览器 vs Node 事件循环
浏览器(HTML 规范定义):一次迭代 = 取一个 task → 微任务检查点 → 判断是否到了渲染时机(rAF 回调 → 样式计算 → 布局 → 绘制 → 合成)。渲染频率与显示器刷新率对齐,不是"每个宏任务后都渲染"。
Node(libuv 实现):一轮循环按固定顺序走 6 个阶段:
┌───────────────────────────┐
┌─>│ timers │ setTimeout / setInterval 到期回调
│ ├───────────────────────────┤
│ │ pending callbacks │ 上一轮延迟的系统级回调(如 TCP ECONNREFUSED)
│ ├───────────────────────────┤
│ │ idle, prepare │ libuv 内部使用
│ ├───────────────────────────┤ ┌───────────────┐
│ │ poll │<────│ 等待 I/O │ ← 空闲时阻塞在这里
│ ├───────────────────────────┤ └───────────────┘
│ │ check │ setImmediate 回调
│ ├───────────────────────────┤
│ │ close callbacks │ socket.on('close') 等
└──┴───────────────────────────┘核心差异:
- 宏任务队列数量:浏览器概念上一条(实现上按任务来源分优先级),Node 按阶段分多条,
setTimeout和setImmediate落在不同阶段。 process.nextTick:Node 独有,优先级高于所有 Promise 微任务,每次要清空 nextTick 队列后才清 Promise 队列;递归nextTick会饿死事件循环。- 微任务插入时机的历史差异:Node 10 及以前,同一阶段内的多个回调会全部执行完才清微任务;Node 11 起改为每个宏任务回调后就清一次,与浏览器对齐。经典对比代码:
setTimeout(() => { console.log('timer1'); Promise.resolve().then(() => console.log('p1')); }, 0);
setTimeout(() => { console.log('timer2'); Promise.resolve().then(() => console.log('p2')); }, 0);
// Node 11+ / 浏览器:timer1 p1 timer2 p2
// Node 10-: timer1 timer2 p1 p2setTimeout(fn, 0)vssetImmediate(fn)在主模块中顺序不确定:取决于进程启动到进入 timers 阶段消耗的时间是否已经超过 1ms(Node 会把 0 钳制为 1ms)。但在 I/O 回调内部,setImmediate一定先于setTimeout,因为 I/O 回调处于 poll 阶段,紧接着就是 check 阶段。- 浏览器的事件循环还要负责渲染,Node 没有渲染步骤。
1.5 空闲时是否占用 CPU
不占用。事件循环的"等待"不是 while(true) 忙等,而是阻塞式系统调用:
- Linux:
epoll_wait(timeout) - macOS/BSD:
kqueue - Windows:IOCP
GetQueuedCompletionStatus/ 浏览器 UI 线程的MsgWaitForMultipleObjectsEx
线程进入内核态睡眠,被移出可运行队列,CPU 占用为 0;有 fd 就绪、定时器到期或有 IPC 消息时由内核唤醒。传给等待函数的 timeout 参数就是"最近一个定时器的剩余时间",这也是定时器精度受限的根因之一。
浏览器在此之上还做了调度优化:
- Chrome 的
Scheduler给任务分优先级队列(输入 > 渲染 > 默认 > 空闲),后台标签页的定时器被节流到 ≥1s(甚至进入"intensive throttling"到 1 分钟一次),rAF完全停发。 requestIdleCallback只在一帧渲染完成后的剩余时间里执行回调,回调可通过deadline.timeRemaining()判断剩余预算,超时可用{ timeout }兜底。
function processQueue(deadline) {
// 在帧空闲期分片处理低优任务,避免掉帧
while (deadline.timeRemaining() > 1 && queue.length) {
handle(queue.shift());
}
if (queue.length) requestIdleCallback(processQueue, { timeout: 2000 }); // 没处理完,下个空闲期继续
}
requestIdleCallback(processQueue, { timeout: 2000 });【追问 & 加分点】
- "
setTimeout(fn, 0)真的是 0ms 吗?"——HTML 规范规定嵌套层级 >5 时最小间隔钳制为 4ms;后台标签页钳到 1s+。需要"尽快异步"且不想被钳制,用MessageChannel(宏任务)或queueMicrotask(微任务)。 - "如何在不阻塞渲染的前提下执行一个 50ms 的大计算?"——时间切片:把任务拆片,用
MessageChannel或scheduler.postTask(Chrome 94+,带优先级)调度,每 5ms 让出主线程;React Fiber 的调度器就是MessageChannel实现的(setTimeout有 4ms 钳制所以不用)。 - "微任务队列会不会饿死宏任务?"——会,微任务里递归产生微任务会导致本轮检查点永不结束,页面完全卡死且不渲染。这也是不能在微任务里做重活的原因。
- "
await在 for 循环里的性能问题?"——for (const x of arr) await f(x)是串行;需要并发用await Promise.all(arr.map(f)),注意并发度失控时要加p-limit之类的限流。
2. 闭包:原理、内存位置、无闭包语言如何模拟
【核心答案】
闭包 = 函数 + 其定义时所在的词法环境的引用。JS 的作用域是词法(静态)作用域,函数在创建时就把外层环境记在自己身上(Environment 内部槽),调用时以它为父级创建新的执行环境,所以即使外层函数已经返回,内层函数依然能访问外层变量。从 V8 视角看,被内层函数引用的变量不会分配在栈上,而是在编译期就被标记为"上下文变量",分配在堆上的 Context 对象里;函数对象持有指向该 Context 的指针,Context 之间通过 previous 指针串成链。没被捕获的变量仍然待在栈槽/寄存器里,随栈帧销毁。所以准确说法是"被捕获的变量在堆上,不是整个作用域都在堆上"。
【展开】
2.1 工作原理
function makeCounter(start) {
let count = start; // 被内层引用 → 分配到堆上的 Context
const step = 1; // 也被引用 → 同样进 Context
const unused = new Array(1e6); // 没被内层引用 → 留在栈帧,makeCounter 返回后即可回收
return function inc() {
count += step;
return count;
};
}
const c1 = makeCounter(0);
const c2 = makeCounter(100);
console.log(c1(), c1(), c2()); // 1 2 101 —— 两次调用产生两个独立 Context内存模型示意:
c1 (JSFunction) 堆
├─ Code → inc 的字节码/机器码
└─ Environment ──────────────▶ Context#1 { count: 2, step: 1, previous → 全局 Context }
c2 (JSFunction)
└─ Environment ──────────────▶ Context#2 { count: 101, step: 1, previous → 全局 Context }V8 的关键实现细节(面试答到这一层就足够):
- 预解析阶段(Pre-Parser) 就会扫描函数体,统计哪些外层变量被内层函数引用,把它们标记为
context-allocated;ScopeInfo记录这个布局。 - 运行时创建
Context对象(一种普通的堆对象),把这些变量放进去;未被捕获的变量继续用栈槽或寄存器,享受完整的 TurboFan 优化。 - 闭包函数对象本身也在堆上,通过
Environment强引用 Context,Context 的生命周期因此和闭包函数一样长——这就是闭包"内存泄漏"传闻的来源:不是泄漏,是合法的、可达的、你忘了释放。 - 同一个外层调用产生的多个内层函数共享同一个 Context:
function make() {
let n = 0;
return { inc: () => ++n, get: () => n }; // inc 和 get 共享同一个 Context
}
const o = make();
o.inc(); console.log(o.get()); // 1- 由于共享,V8 里存在一个经典陷阱:同一作用域内任一闭包捕获了某变量,该变量就进 Context,即使当前存活的闭包不用它,也会被一起留住。DevTools 的 Memory 面板里能看到
(closure)类型的对象。
function leak() {
const huge = new Array(1e7).fill('*'); // 被 rarely 捕获
function rarely() { return huge.length; }
function often() { return 1; } // 只保留 often,huge 仍可能被同一 Context 留住
return often;
}现代 V8 会做 Context 裁剪(只把真正被引用的变量放进 Context),上例中 huge 只被 rarely 引用,而 rarely 未被返回,huge 可以被回收;但如果两个函数都在同一 Context 且都被引用,huge 就跑不掉。结论:不确定时用 DevTools 实测,不要凭感觉。
2.2 实际开发用途
| 用途 | 例子 |
|---|---|
| 数据私有化 / 模块模式 | IIFE 模块、#private 出现之前的私有字段 |
| 保存函数调用间的状态 | 计数器、缓存 memoize、id 生成器 |
| 函数工厂 / 偏函数 / 柯里化 | bind 的 polyfill、curry |
| 防抖节流、once | 用闭包存 timer / lastCall / called |
| 循环中捕获每轮变量 | let 的块级作用域本质是每轮迭代一个新 Context |
| 异步回调携带上下文 | 请求发出时的参数在响应回来时依然可用 |
| React Hooks | useState 的 setter、useEffect 的闭包快照(stale closure 问题的根源) |
// 防抖:timer 与 lastArgs 通过闭包保存
function debounce(fn, wait = 300, { leading = false } = {}) {
let timer = null;
return function debounced(...args) {
const shouldCallNow = leading && timer === null;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
if (!leading) fn.apply(this, args);
}, wait);
if (shouldCallNow) fn.apply(this, args);
};
}
// memoize:cache 是私有的,外部拿不到也改不了
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = args.length === 1 ? args[0] : JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const val = fn.apply(this, args);
cache.set(key, val);
return val;
};
}经典的 var 循环问题及三种解法,正好体现闭包共享 vs 独立:
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 3 3 3:三个闭包共享同一个 i
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 0 1 2:每轮迭代创建新的绑定
for (var j = 0; j < 3; j++) ((k) => setTimeout(() => console.log(k)))(j); // 0 1 2:IIFE 手动造作用域2.3 如果语言没有闭包机制,如何手动模拟
闭包的本质就是 代码指针 + 环境指针 的二元组(学术上叫 closure conversion / lambda lifting)。没有闭包的语言(C、早期 Java、Go 之前的很多系统语言)都是这么手写的:
方案一:函数指针 + 环境结构体(C 风格)
/* 把被捕获的变量抬到堆上的结构体里,函数额外接收环境指针 */
typedef struct { int count; int step; } CounterEnv;
int inc(CounterEnv *env) { /* 环境显式作为第一个参数传入 */
env->count += env->step;
return env->count;
}
typedef struct { int (*fn)(CounterEnv *); CounterEnv *env; } Counter; /* 这就是"闭包对象" */
Counter make_counter(int start) {
CounterEnv *env = malloc(sizeof(CounterEnv)); /* 关键:必须在堆上,否则栈帧一销毁就悬垂 */
env->count = start; env->step = 1;
Counter c = { inc, env };
return c;
}
/* 调用:c.fn(c.env); 释放:free(c.env); —— 手动 GC */方案二:对象即闭包(面向对象语言的做法)
对象 = 数据 + 方法,天然就是"环境 + 代码"。Java 8 之前用匿名内部类,把捕获的局部变量复制成实例字段(所以要求变量 final——因为是值拷贝,不能再变,否则语义不一致)。
用 JS 写出等价形式,说明"闭包只是语法糖":
// 不使用闭包,用对象显式携带环境
class CounterClosure {
constructor(start) {
this.env = { count: start, step: 1 }; // 显式的堆上环境
}
call() { // 显式的代码部分
this.env.count += this.env.step;
return this.env.count;
}
}
const c = new CounterClosure(0);
console.log(c.call(), c.call()); // 1 2 —— 与闭包版行为完全一致方案三:全局表 + 句柄(最原始)
把环境存进一个全局哈希表,函数只接收一个 id,通过 id 查环境。缺点是要手动管理生命周期、并发不安全、无法嵌套,但在没有堆分配抽象的场景(如某些嵌入式脚本、WebAssembly 早期与 JS 互操作)确实这么干。
三点必须讲清楚的本质:
- 捕获的变量必须离开栈(堆分配或值拷贝),否则外层返回后就是悬垂引用。
- 按引用捕获 vs 按值捕获决定语义:JS/Go 是按引用(变量变了闭包里也变),Java 匿名内部类、C++
[=]lambda 是按值。这解释了var循环打印3 3 3。 - 生命周期需要有人管:JS 靠 GC,C 靠手动
free,C++ 靠shared_ptr。
【追问 & 加分点】
- "闭包会造成内存泄漏吗?"——闭包本身不泄漏(GC 可达即保留是正确行为)。真正的泄漏是:闭包被挂在长生命周期对象上(全局变量、未解绑的事件监听、未
clearInterval的定时器、未取消的订阅),顺带把大对象拖住。修复思路是解绑 + 在闭包内把不再需要的大对象置null。 - "IE 下 DOM 与闭包的循环引用问题?"——IE 的 DOM 用 COM 引用计数,与 JS 的 GC 是两套系统,
element.onclick = function(){ element... }形成跨系统循环引用无法回收。现代浏览器 DOM 与 JS 堆统一由 GC 标记清除管理,已不存在此问题。 - "闭包和
#private字段、WeakMap做私有属性有什么取舍?"——闭包:每个实例一份函数,内存开销大,但兼容性最好;WeakMap:方法在原型上共享,实例被回收时条目自动清除;#x:语法级私有,引擎优化最好,是现在的首选。 - "React 的 stale closure 是什么?"——
useEffect/useCallback捕获的是某次渲染时的 props/state 快照,依赖数组不全就会读到旧值。解法:补全依赖、用useRef存最新值、或用函数式更新setState(prev => ...)。
3. this 指向:完整规则与严格模式
【核心答案】
除箭头函数外,this 在调用时动态确定,与定义位置无关。优先级从高到低:new 绑定 > 显式绑定(call/apply/bind)> 隐式绑定(作为对象方法调用)> 默认绑定。箭头函数没有自己的 this,它在词法作用域里向外查找,且不可被 call/bind 改变、也不能作构造函数。默认绑定在非严格模式下指向全局对象(浏览器 window、Node globalThis),严格模式下是 undefined——这是严格模式下 this 行为最主要的变化。此外 class 内部代码、ES Module 顶层默认就是严格模式。
【展开】
3.1 五条规则
'use strict';
globalThis.tag = 'global';
// ① 默认绑定
function f() { return this; }
f(); // 严格模式:undefined;非严格:globalThis(this 被"装箱"为全局对象)
// ② 隐式绑定:看调用点的 . 左边是谁
const obj = { tag: 'obj', get() { return this.tag; } };
obj.get(); // 'obj'
const detached = obj.get;
detached(); // 严格模式:TypeError(this 是 undefined);非严格:undefined(读 globalThis.tag → 'global')
// ③ 显式绑定
detached.call({ tag: 'A' }); // 'A'
detached.apply({ tag: 'B' }); // 'B'
const bound = detached.bind({ tag: 'C' });
bound(); // 'C',且 bind 后不可再被 call 改变
bound.call({ tag: 'D' }); // 仍是 'C'
// ④ new 绑定
function Person(name) { this.name = name; }
const p = new Person('Tom'); // this 指向新创建的对象
new (Person.bind({ tag: 'X' }))('Jerry'); // new 的优先级高于 bind,硬绑定被忽略
// ⑤ 箭头函数:词法 this
const o2 = {
tag: 'o2',
method() {
const arrow = () => this.tag; // 捕获 method 调用时的 this
return arrow.call({ tag: 'ignored' }); // 'o2' —— call 对箭头函数无效
},
};几个特殊上下文:
| 场景 | this |
|---|---|
| DOM 事件监听(普通函数) | 绑定的元素(currentTarget) |
| DOM 事件监听(箭头函数) | 定义处的外层 this |
class 方法(未绑定就取出) | undefined(class body 恒为严格模式) |
class 字段写成箭头函数 m = () => {} | 实例(在构造期以实例为 this 求值),代价是每实例一份函数 |
| ES Module 顶层 | undefined |
浏览器 <script> 顶层(非 module) | window |
| Node CommonJS 模块顶层 | module.exports(即 {}) |
setTimeout(obj.method, 0) | 非严格下是 window/Timeout 对象,方法丢失接收者 |
| getter/setter | 触发属性访问的那个对象 |
Symbol.iterator/生成器方法 | 按普通方法规则 |
3.2 严格模式下的差异清单
- 默认绑定:
undefined(不再装箱为全局对象)。这也顺带堵死了"函数内部误写this.x = 1意外污染全局"的坑。 call/apply/bind传入原始值不再被包装成对象:
function show() { return this; }
show.call(7); // 严格:7(number);非严格:Number 包装对象
show.call(null); // 严格:null;非严格:globalThis- class 体、ES Module 天然严格,不需要写
'use strict'。 - 严格模式下没有
arguments.callee、fn.caller,所以"用callee递归匿名函数"的老写法不可用。
3.3 「看代码说答案」综合题
'use strict';
globalThis.tag = 'global';
const obj = {
tag: 'obj',
regular() {
console.log('A:', this.tag);
function inner() { console.log('B:', this); }
inner();
const arrow = () => console.log('C:', this.tag);
arrow();
[1].forEach(function () { console.log('D:', this); });
[1].forEach(function () { console.log('E:', this.tag); }, { tag: 'thisArg' });
[1].forEach(() => console.log('F:', this.tag));
},
arrowProp: () => console.log('G:', typeof this),
};
obj.regular();
obj.arrowProp();
const { regular } = obj;
try { regular(); } catch (e) { console.log('H:', e.constructor.name); }
const bound = obj.regular.bind({ tag: 'bound' });
bound();
class Widget {
tag = 'widget';
normal() { return this?.tag; }
arrow = () => this.tag;
}
const w = new Widget();
const { normal, arrow } = w;
console.log('I:', normal());
console.log('J:', arrow());答案与解析:
A: obj 隐式绑定,调用点是 obj.regular()
B: undefined inner 是普通函数调用 → 默认绑定 → 严格模式为 undefined
C: obj 箭头函数捕获 regular 的 this
D: undefined forEach 的回调是普通函数,未传 thisArg,严格模式下为 undefined
E: thisArg forEach 第二个参数指定了回调的 this
F: obj 箭头函数无视 thisArg,仍取词法 this
G: undefined arrowProp 定义在模块/脚本顶层,捕获顶层 this(ES Module 中为 undefined,
typeof undefined === 'undefined')
H: TypeError 解构出来的 regular 丢失接收者,读 undefined.tag 抛错
A: bound / B: undefined / C: bound / D: undefined / E: thisArg / F: bound
I: undefined normal 是原型方法,脱离实例调用,this 为 undefined,用 ?. 兜住
J: widget arrow 是实例字段上的箭头函数,构造时捕获了实例,脱离对象依然正确面试口述要点:只看"调用点",不看"定义点"(箭头函数相反);解构、赋值给变量、作为回调传参这三种操作都会丢失隐式绑定。
3.4 手写 call / bind(常考)
Function.prototype.myCall = function (thisArg, ...args) {
if (typeof this !== 'function') throw new TypeError('not callable');
// 非严格模式语义:null/undefined 回退到全局对象;原始值装箱。此处按非严格语义模拟
const ctx = thisArg == null ? globalThis : Object(thisArg);
const key = Symbol('fn'); // 用 Symbol 避免覆盖已有属性
Object.defineProperty(ctx, key, { value: this, enumerable: false, configurable: true });
try {
return ctx[key](...args); // 借助"隐式绑定"实现显式绑定
} finally {
delete ctx[key]; // 保证异常时也清理
}
};
Function.prototype.myBind = function (thisArg, ...preArgs) {
if (typeof this !== 'function') throw new TypeError('not callable');
const target = this;
function bound(...args) {
// 被 new 调用时,this 是 bound 的实例,此时忽略 thisArg
return target.apply(this instanceof bound ? this : thisArg, [...preArgs, ...args]);
}
bound.prototype = Object.create(target.prototype); // 维持原型链,使 instanceof 正确
return bound;
};【追问 & 加分点】
- "
new和bind谁优先?为什么?"——new优先。规范里 bound function 有Construct内部方法,被new调用时走Construct路径,把boundThis丢弃,只保留预置参数。 - "箭头函数为什么不能当构造函数?"——箭头函数没有
Construct内部方法,也没有prototype属性、没有arguments、没有new.target、不能用作 generator。 - "为什么 React class 组件要在 constructor 里
bind?"——JSX 中onClick={this.handle}是把方法当值传出去,丢失接收者。现代写法用 class 字段箭头函数或直接用函数组件 + Hooks。 - "
this在class私有方法/静态块里?"——静态方法中this是类本身;static {}初始化块里this也是类;私有方法遵循普通方法规则,但脱离实例调用会抛TypeError(因为私有字段的 brand check)。
4. 原型与继承:new 的过程、instanceof、Object.create
【核心答案】
JS 用原型链做继承:每个对象有内部槽 Prototype(可通过 Object.getPrototypeOf / __proto__ 访问),属性查找沿链向上直到 null。new Foo() 做四件事:① 创建一个以 Foo.prototype 为原型的空对象;② 以该对象为 this 执行构造函数体;③ 如果构造函数显式返回一个对象则以它为结果,否则返回步骤①的对象;④ 期间 new.target 指向 Foo。instanceof 的原理是沿着左操作数的原型链向上找,看有没有等于右操作数 prototype 属性的对象,且可被 Symbol.hasInstance 覆写。Object.create(proto) 则是直接以指定原型创建对象,不经过构造函数。
【展开】
4.1 new 的过程与手写
function myNew(Ctor, ...args) {
if (typeof Ctor !== 'function') throw new TypeError(`${Ctor} is not a constructor`);
// ① 以 Ctor.prototype 为原型创建对象;若 prototype 不是对象(如被改成基本类型),回退到 Object.prototype
const proto = (Ctor.prototype !== null && typeof Ctor.prototype === 'object')
? Ctor.prototype : Object.prototype;
const obj = Object.create(proto);
// ② 以 obj 为 this 执行构造函数
const ret = Ctor.apply(obj, args);
// ③ 返回值是对象(含函数)就用它,否则用 obj
return (ret !== null && (typeof ret === 'object' || typeof ret === 'function')) ? ret : obj;
}myNew 无法完美模拟原生 new 的地方(面试说出来是加分点):
- 无法正确设置
new.target:真正的new会让构造函数内new.target === Ctor,myNew里是undefined。 - 不能用于
class:class 有IsClassConstructor,直接Ctor.apply(...)调用会抛TypeError: Class constructor cannot be invoked without 'new'。 - 不能正确处理内置构造器的 exotic 对象(
Array、Map、Proxy等在Construct里分配特殊内部槽)。 - 不支持
Reflect.construct(Ctor, args, newTarget)那种指定 newTarget 的能力;用Reflect.construct才是真正等价的实现。
const realNew = (Ctor, ...args) => Reflect.construct(Ctor, args); // 与 new 完全等价4.2 instanceof 原理与手写
function myInstanceof(left, Right) {
if (typeof Right !== 'function') throw new TypeError('Right-hand side is not callable');
// ① 先看有没有 Symbol.hasInstance 覆写(规范优先走这条)
const hasInstance = Right[Symbol.hasInstance];
if (typeof hasInstance === 'function' && hasInstance !== Function.prototype[Symbol.hasInstance]) {
return Boolean(hasInstance.call(Right, left));
}
// ② 原始值永远返回 false
if (left === null || (typeof left !== 'object' && typeof left !== 'function')) return false;
const target = Right.prototype;
if (target === null || (typeof target !== 'object' && typeof target !== 'function')) {
throw new TypeError('prototype is not an object');
}
// ③ 沿原型链逐级比较
let proto = Object.getPrototypeOf(left);
while (proto !== null) {
if (proto === target) return true;
proto = Object.getPrototypeOf(proto);
}
return false;
}要点:
- 比较的是
Right.prototype这个对象的引用,不是构造函数本身。所以运行时修改Foo.prototype = {}之后,之前创建的实例instanceof Foo会变成false。 - 跨 realm 失效:iframe 或 Node 的 vm 里造出来的数组,
arr instanceof Array为false,因为两个 realm 有各自的Array.prototype。稳妥判断类型用Array.isArray(x)或Object.prototype.toString.call(x) === '[object Array]'。 Symbol.hasInstance可以自定义:
class Even {
static [Symbol.hasInstance](n) { return typeof n === 'number' && n % 2 === 0; }
}
console.log(2 instanceof Even, 3 instanceof Even); // true false4.3 Object.create
// 签名:Object.create(proto, propertiesObject?)
const base = { greet() { return `hi ${this.name}`; } };
const o = Object.create(base, {
name: { value: 'Tom', writable: true, enumerable: true, configurable: true },
id: { value: 1 }, // 默认全 false:不可写、不可枚举、不可配置
});
console.log(o.greet(), Object.keys(o)); // 'hi Tom' ['name']
// 纯字典:无原型,避免 __proto__ / toString 等原型污染与键名冲突
const dict = Object.create(null);
dict.toString = 1; // 安全,不会覆盖任何东西
console.log(Object.getPrototypeOf(dict)); // nullES5 时代的 polyfill(体现其本质就是"借一个空构造函数"):
if (!Object.create) {
Object.create = function (proto) {
function F() {} // 空构造函数,避免执行父类构造逻辑
F.prototype = proto;
return new F(); // 只继承原型,不继承实例属性
};
}4.4 完整的继承链(ES5 寄生组合式 vs ES6 class)
// ES5:寄生组合式继承(最优解,父构造函数只调用一次)
function Animal(name) { this.name = name; }
Animal.prototype.speak = function () { return `${this.name} makes a sound`; };
function Dog(name, breed) {
Animal.call(this, name); // ① 继承实例属性
this.breed = breed;
}
Dog.prototype = Object.create(Animal.prototype); // ② 继承原型方法(不调用 Animal 构造)
Dog.prototype.constructor = Dog; // ③ 修正 constructor 指向
Dog.prototype.speak = function () { return `${this.name} barks`; };
// ES6:等价写法,且额外继承了静态成员(Dog.__proto__ === Animal)
class Animal2 {
constructor(name) { this.name = name; }
speak() { return `${this.name} makes a sound`; }
static create(n) { return new this(n); } // this 指向调用的类,子类调用会返回子类实例
}
class Dog2 extends Animal2 {
constructor(name, breed) {
super(name); // 必须先调 super 才能用 this
this.breed = breed;
}
speak() { return `${super.speak()} (woof)`; } // super 走的是 HomeObject.__proto__
}class 与 ES5 模拟的实质差异(P6 必答):
- class 的方法不可枚举;ES5 手写的
prototype.x = fn可枚举。 - class 必须
new,不能直接调用;且存在暂时性死区,不提升可用性。 extends同时建立两条链:Dog2.prototype.__proto__ === Animal2.prototype(实例方法)和Dog2.__proto__ === Animal2(静态方法继承,ES5 需手写Object.setPrototypeOf(Dog, Animal))。- 派生类的
this由父类创建:super()返回后this才被初始化,这是"派生类中super()之前不能用this"的规范原因,也是为什么 Babel 编译的 class 无法真正继承Array/Error这类内置对象(会丢失内部槽,extends Error的stack、extends Array的 length 自动维护都失效),必须靠Reflect.construct或降级 target 到 ES6。
【追问 & 加分点】
- "
__proto__和prototype区别?"——prototype是函数才有的属性,指向"用它 new 出来的实例的原型对象";__proto__是所有对象都有的访问器(定义在Object.prototype上,属于 Annex B 遗留特性),指向该对象的Prototype。生产代码用Object.getPrototypeOf/Object.setPrototypeOf。 - "为什么不建议用
Object.setPrototypeOf改原型?"——会让 V8 的隐藏类/内联缓存(IC)失效,对象退化到字典模式,性能断崖式下降。V8 官方明确警告过。 - "
Function.prototype为什么是个函数?"——它是可调用的空函数(返回 undefined),这是规范为兼容历史保留的 exotic 设计;Object instanceof Function和Function instanceof Object同时为 true 就来自这个环形结构。 - "如何判断一个属性是自有的还是继承的?"——
Object.hasOwn(obj, k)(ES2022,推荐)或Object.prototype.hasOwnProperty.call(obj, k);不要直接obj.hasOwnProperty(k),因为Object.create(null)的对象没有这个方法。
5. Promise 与 async/await 的使用区别
【核心答案】
两者底层是同一套 Promise 机制,async/await 是基于 Generator + Promise 的语法糖,差异只在书写与控制流表达力上:await 把异步写成同步的顺序结构,可以用 try/catch/finally 统一处理错误、可以在循环和条件里自然地表达依赖关系、栈追踪更完整(V8 有 zero-cost async stack traces);而 Promise 链在并发编排上更直观(all / allSettled / race / any),也更适合做与主流程解耦的"副作用挂载"(如统一打点、缓存写入)。最容易踩的坑:await 在循环里会把并发退化成串行;try/catch 只能捕获被 await 的那一个 Promise;忘写 await 会产生未处理的 rejection。
【展开】
5.1 错误处理
// Promise 链:错误沿链下传,一个 catch 兜底,但难区分是哪一步失败
fetchUser(id)
.then((u) => fetchOrders(u.id))
.then((orders) => render(orders))
.catch((err) => report(err)) // render 抛错也会进来,容易误判为网络错误
.finally(() => hideLoading());
// async/await:可精确到语句级,且能自然使用局部变量
async function load(id) {
showLoading();
try {
const user = await fetchUser(id); // ← 只有这一句的错误被下面区分
let orders;
try {
orders = await fetchOrders(user.id);
} catch (e) {
orders = []; // 降级:订单失败不影响主流程
report(e, { stage: 'orders' });
}
render(orders);
} catch (e) {
report(e, { stage: 'user' });
throw e; // 需要向上传播就显式 rethrow
} finally {
hideLoading();
}
}必须讲清的三个陷阱:
.catch在.then之前 vs 之后语义不同:p.catch(f).then(g)中即使出错g也会执行(catch 消化了错误);p.then(g).catch(f)中g的错误也会被捕获。.then(onFulfilled, onRejected)的第二个参数捕获不到第一个参数抛出的错误,.catch可以。- 未
await的 Promise 抛错 →unhandledrejection。浏览器触发window.addEventListener('unhandledrejection'),Node ≥15 默认直接使进程崩溃。
async function bad() {
doAsyncThing(); // 忘记 await:错误逃逸出 try/catch
try {
doAsyncThing(); // 同样逃逸
} catch { /* 永远进不来 */ }
}
// ESLint 规则 no-floating-promises / require-await 可静态拦住这类问题5.2 并发
// ❌ 串行:总耗时 = t1 + t2 + t3
async function serial(ids) {
const out = [];
for (const id of ids) out.push(await fetchOne(id));
return out;
}
// ✅ 并发:总耗时 ≈ max(t1, t2, t3)。先全部发起,再统一 await
async function parallel(ids) {
return Promise.all(ids.map((id) => fetchOne(id)));
}
// ✅ 部分并行 + 有依赖:先并发拿到不相关的两份数据,再串行用
async function mixed(id) {
const [user, config] = await Promise.all([fetchUser(id), fetchConfig()]);
return fetchOrders(user.id, config.pageSize);
}组合器语义对照(常考):
| 方法 | 兑现条件 | 拒绝条件 | 典型场景 |
|---|---|---|---|
Promise.all | 全部 fulfilled → 结果数组 | 任一 rejected 即立刻 reject(其余仍在跑,不会被取消) | 强依赖的批量请求 |
Promise.allSettled | 永远 fulfilled → {status, value/reason}[] | 不会 reject | 批量上报、部分失败可接受 |
Promise.race | 第一个settled(无论成败)决定结果 | 同左 | 超时控制 |
Promise.any | 第一个 fulfilled | 全部失败 → AggregateError | 多 CDN 竞速取最快可用 |
// 超时控制(推荐用 AbortSignal.timeout,能真正取消底层请求)
async function fetchWithTimeout(url, ms = 3000) {
// AbortSignal.timeout 需要较新的浏览器/Node 17.3+;老环境用 AbortController + setTimeout
const res = await fetch(url, { signal: AbortSignal.timeout(ms) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// 并发限流:既不串行也不打爆服务端
async function mapLimit(items, limit, worker) {
const results = new Array(items.length);
let cursor = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const i = cursor++; // 单线程下自增是原子的,无需锁
results[i] = await worker(items[i], i);
}
});
await Promise.all(runners);
return results;
}
Promise.all的 fail-fast 语义要点:某个失败后其他请求并不会被取消,只是结果被忽略。要真取消得配合AbortController。
5.3 可读性与其他差异
- 顺序依赖的多步流程:
await完胜,尤其涉及条件分支、循环、try/finally资源释放。 - 扇出扇入的编排:Promise 组合器表达更直接。
for await...of处理异步迭代器(如流式响应)是await独有的能力:
const res = await fetch('/api/stream');
for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
appendToUI(chunk); // 边下边渲染
}- 顶层
await:仅在 ES Module 中可用,会阻塞该模块及其依赖者的求值,慎用于关键路径。 - 返回值:
async函数总是返回 Promise;return await p与return p的差异在于前者多一个微任务轮次,但在try块中return await p是必要的(否则p的 rejection 逃出try)。 - 调试:
await可以直接在异步行打断点单步;Promise 链只能在回调内部打点。V8 的异步栈追踪对await支持更好。
【追问 & 加分点】
- "
async函数抛出的同步错误会怎样?"——不会同步抛出,而是转成 rejected Promise。所以调用方必须.catch或await+try。这与普通函数不同,是常见的错误处理漏洞点。 - "如何实现一个
Promise.all?"——计数器 + 结果按 index 落位;注意空数组要立刻 resolve、非 Promise 值要Promise.resolve包装、必须遍历完全部再判断完成。 - "Promise 状态机的三个不变式?"——① 状态只能从 pending 单向变为 fulfilled 或 rejected;② 一经settled 不可再变;③
then回调恒异步执行(即使 Promise 已 settled,也要放进微任务队列,保证时序一致)。 - "
await一个非 Promise 值会怎样?"——仍会让出一次,产生一个微任务轮次;await 1不是同步的。这是很多"输出顺序题"的考点。 - "如何取消一个 Promise?"——Promise 本身不可取消。用
AbortController通知底层(fetch 原生支持),或用Promise.race加一个"取消信号 Promise" 抢跑(但底层任务仍在跑,只是结果被忽略)。
6. ES6+ 新特性与 ES5 降级构建配置
【核心答案】
ES6+ 相比 ES5 的核心优势可以归为五类:① 作用域与声明(let/const 块级作用域 + TDZ,消除 var 提升与循环闭包问题);② 语法表达力(箭头函数、解构、模板字符串、默认/剩余/展开参数、可选链 ?.、空值合并 ??);③ 异步能力(Promise、async/await、for await...of、顶层 await);④ 语言基础设施(class、原生模块 ESM 静态可分析 → Tree Shaking、Symbol、Proxy/Reflect 元编程、迭代器/生成器);⑤ 新数据结构(Map/Set/WeakMap/WeakSet、BigInt、Object.entries、Array.prototype.flat/at/includes)。上线要降到 ES5,用 Babel(@babel/preset-env)转语法 + core-js 补 API + browserslist 统一声明目标环境,三者缺一不可——preset-env 只转语法不补 API,Promise、Map、Array.prototype.includes 这些是靠 core-js polyfill 的。
【展开】
6.1 关键特性与其解决的实际问题
| 特性 | ES5 的痛点 | ES6+ 的改进 |
|---|---|---|
let/const | var 函数级作用域、变量提升、循环闭包共享 | 块级作用域、TDZ 报错更早、每轮迭代新绑定 |
| 箭头函数 | 回调中 this 丢失,需 var self = this | 词法 this,无 arguments/prototype |
| 解构 + 默认值 | options = options || {} 一堆样板 | function f({ a = 1, b = 2 } = {}) {} |
| 模板字符串 | 字符串拼接易错 | 插值、多行、标签模板(可做安全转义、i18n) |
| ESM | CommonJS 运行时求值,无法静态分析 | 静态结构 → Tree Shaking、循环依赖处理更明确、可 import() 动态分包 |
class | 手写原型继承样板多、易漏 constructor 修正 | 语法统一、extends/super/static/#private |
Proxy/Reflect | Object.defineProperty 无法拦截新增/删除属性、数组索引 | Vue 3 响应式、不可变代理、埋点拦截 |
Map/Set | 用对象当字典,键只能是字符串,有原型污染风险 | 任意类型键、有序、size、迭代协议 |
WeakMap | 无法给对象挂私有数据且不阻碍 GC | 键弱引用,实例回收后条目自动消失 |
?. / ?? | a && a.b && a.b.c、x || 0 误吞 0/'' | 短路取值语义精确 |
| 生成器/迭代器 | 自定义遍历要写 index | for...of、惰性序列、协程式流程控制 |
6.2 完整的降级构建配置
目录结构
project/
├─ babel.config.js # 项目根配置,作用于 node_modules 也能生效(推荐)
├─ .browserslistrc # 目标环境的唯一真相来源
└─ package.json.browserslistrc
# 生产环境:覆盖率 + 兜底
> 0.5%
last 2 versions
Firefox ESR
not dead
not op_mini all
[modern]
last 2 chrome versions
last 2 firefox versions
last 2 safari versions
[development]
last 1 chrome version为什么用 browserslist 而不是在 preset-env 里写
targets:Babel、Autoprefixer、postcss-preset-env、esbuild/swc、Lightning CSS 都读同一份 browserslist,避免 JS 和 CSS 的降级目标不一致。
babel.config.js(应用项目)
// babel.config.js —— 使用项目级配置(而非 .babelrc),可对 node_modules 中的依赖生效
module.exports = (api) => {
const isTest = api.env('test');
api.cache.using(() => process.env.NODE_ENV); // 按环境缓存配置
return {
presets: [
['@babel/preset-env', {
// targets 不写 → 自动读取 .browserslistrc,保持单一真相来源
// 测试环境直接跑在当前 Node 上,不必降级,编译更快
...(isTest ? { targets: { node: 'current' } } : {}),
// 'usage': 按代码中实际用到的 API 自动按需注入 polyfill(推荐)
// 'entry': 需在入口手写 import 'core-js/stable',按 targets 展开
// false : 完全不注入
useBuiltIns: 'usage',
// 必须与 package.json 中安装的 core-js 版本一致(含 minor),否则按需注入不准
corejs: { version: '3.37', proposals: false },
// 用更精准的 bugfix 转换,产物更小(Babel 7.9+)
bugfixes: true,
// 保留 ESM 语法交给打包器做 Tree Shaking;设为 'commonjs' 会破坏 Tree Shaking
modules: false,
}],
],
plugins: [
['@babel/plugin-transform-runtime', {
// 应用项目:polyfill 交给 useBuiltIns:'usage' 全局注入,这里只复用 helper
corejs: false,
helpers: true, // 把 _classCallCheck 等辅助函数抽到 @babel/runtime,避免每文件重复
regenerator: true, // 需要 regenerator 时从 runtime 引用,不污染全局
}],
],
};
};依赖
npm i -D @babel/core @babel/preset-env @babel/plugin-transform-runtime browserslist
npm i core-js@3 @babel/runtime # 注意:这两个是运行时依赖,不是 devDependencies如果是发布给别人用的「库」,配置不同:
// 库不能污染使用者的全局环境,必须用 transform-runtime 的沙箱式 polyfill
module.exports = {
presets: '@babel/preset-env', { useBuiltIns: false, modules: false },
plugins: [['@babel/plugin-transform-runtime', {
corejs: { version: 3, proposals: false }, // 需安装 @babel/runtime-corejs3
helpers: true,
regenerator: true,
}]],
};Webpack 接入
module: {
rules: [{
test: /\.m?js$/,
// 关键:不要无脑 exclude 整个 node_modules。很多现代库只发 ES2018+ 产物,
// 不转会导致低端浏览器白屏。推荐只排除已知安全的包,或用 include 白名单。
exclude: /node_modules[\\/](?!(some-es6-lib|another-lib)[\\/])/,
use: { loader: 'babel-loader', options: { cacheDirectory: true } },
}],
}6.3 必须知道的几个坑(区分度很高)
- preset-env 不转 API,只转语法。
Promise、Map、Object.assign、Array.prototype.includes、String.prototype.padStart都需要 core-js。忘了配useBuiltIns就会在低端机报undefined is not a function。 useBuiltIns: 'usage'依赖静态分析,会漏判:obj[method]()这种动态调用识别不出来;关键 API 建议在入口手动import 'core-js/modules/es.promise.js'兜底。- core-js 版本必须写到 minor(如
'3.37'),只写3会按 3.0 的模块表注入,新 API 漏补。 @babel/polyfill已废弃(Babel 7.4+),改用core-js@3+regenerator-runtime。- Proxy 无法 polyfill。这是"能力降级"而非"语法降级"的典型:目标包含 IE11 时不能用 Proxy(Vue 3 因此不支持 IE11)。同类的还有
WeakRef、FinalizationRegistry、真正的Symbol语义。 - 差异化打包(module/nomodule) 是现代最佳实践:现代浏览器加载未降级的小包,老浏览器加载 ES5 包,互不浪费。
<script type="module" src="app.modern.js"></script>
<script nomodule defer src="app.legacy.js"></script>Vite 用 @vitejs/plugin-legacy 自动产出这两套;Webpack 可用两份配置 + browserslist 的环境段(process.env.BROWSERSLIST_ENV=modern)。
- 构建速度:esbuild / SWC 比 Babel 快一个数量级,但 esbuild 最低只能降到 ES2015,不能降到 ES5;需要 ES5 产物仍要 Babel(或 SWC,SWC 支持 ES5 target)。常见做法:开发用 esbuild/SWC,生产用 Babel 做兼容层。
【追问 & 加分点】
- "
useBuiltIns: 'usage'和'entry'怎么选?"——应用项目用usage(产物最小,但有动态调用漏判风险);需要确定性、或代码里有大量动态调用时用entry(在入口显式import 'core-js/stable',按 targets 精确裁剪);库用false+ transform-runtime。 - "为什么
@babel/plugin-transform-runtime能减小体积?"——Babel 转换会在每个文件顶部插入_classCallCheck、_createClass等辅助函数,几十个文件就重复几十份;该插件把它们改成从@babel/runtime统一import,由打包器去重。 - "编译产物为什么还是有
let?"——检查 browserslist 是否已经不包含需要降级的浏览器(npx browserslist打印实际目标列表),或者该文件被exclude掉了、或者是第三方依赖未参与编译。 - "如何验证降级是否成功?"——
npx browserslist看目标;用eslint-plugin-compat或@babel/eslint-parser+es-check(npx es-check es5 dist/**/*.js)在 CI 里静态校验产物语法级别。 - "ESM 和 CommonJS 的关键差异?"——ESM 是静态结构、编译期建立绑定(导入的是活绑定 live binding 而非拷贝)、异步加载、严格模式、顶层
this为undefined、支持 Tree Shaking;CJS 是运行时求值、module.exports是值拷贝、同步加载。
7. 单线程如何处理重任务与网络请求
【核心答案】
"JS 是单线程"指的是执行 JS 的主线程只有一个,但浏览器/Node 整体是多线程的。网络请求、定时器、文件 I/O 这类等待型任务根本不占用 JS 线程:JS 只是发起请求并注册回调,实际工作由浏览器的网络进程 / libuv 线程池 / 内核完成,完成后把回调投递到事件循环队列——这是异步非阻塞 I/O模型,单线程也能扛住高并发。真正的问题是计算密集型任务(大数据解析、图像处理、加解密、复杂 diff),它们会实打实占用主线程导致掉帧卡顿,解法是把它们移出主线程:浏览器用 Web Worker(配合 SharedArrayBuffer/Transferable 减少拷贝、OffscreenCanvas 做离屏渲染),Node 用 worker_threads 或子进程;如果因为要操作 DOM 而无法移出,就用时间切片(scheduler.postTask / MessageChannel / requestIdleCallback)把长任务拆成 <5ms 的小片,主动让出主线程。
【展开】
7.1 两类任务,两种解法
| 任务类型 | 特征 | 解法 |
|---|---|---|
| I/O 密集(网络、定时器、读文件、IndexedDB) | 大部分时间在等,不消耗 CPU | 异步回调 / Promise / async-await。宿主用独立线程 + 事件通知,主线程零占用 |
| CPU 密集(解析、压缩、加密、排序百万数据、图像滤镜) | 实打实消耗 CPU | 移出主线程(Worker / WASM / 服务端);或时间切片让出 |
浏览器一次 fetch 背后的线程分工:
JS 主线程 网络进程/线程 JS 主线程
│ fetch() ──▶ DNS / 连接 / TLS / 收发 / 解压
│ (立即返回 (独立线程,主线程此刻可以
│ Promise) 继续跑别的 JS 和渲染)
│ │
│ 响应完成 ──IPC──▶ 把回调作为任务投递到事件循环
▼ │
继续执行同步代码 ... 栈空 ... 事件循环取出任务 ◀────┘ → 执行 .then 回调7.2 Web Worker 完整示例
主线程
// main.js
// 用 new URL(..., import.meta.url) 让打包器(Vite/Webpack 5)能正确解析 worker 入口
const worker = new Worker(new URL('./heavy.worker.js', import.meta.url), {
type: 'module', // 允许在 worker 内使用 import
name: 'image-proc', // 便于 DevTools 调试
});
// 用 Promise 包装 postMessage,把消息式 API 变成可 await 的调用
function callWorker(worker, type, payload, transfer = []) {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const onMessage = (e) => {
if (e.data.id !== id) return; // 支持并发多请求
worker.removeEventListener('message', onMessage);
e.data.ok ? resolve(e.data.result) : reject(new Error(e.data.error));
};
worker.addEventListener('message', onMessage);
// 第二个参数是 Transferable 列表:所有权转移,零拷贝,转移后主线程侧的 buffer 变成 detached
worker.postMessage({ id, type, payload }, transfer);
});
}
// worker 内未捕获错误会触发 error 事件(注意:error 事件不会经过上面的 Promise)
worker.addEventListener('error', (e) => console.error('worker crashed:', e.message));
worker.addEventListener('messageerror', (e) => console.error('结构化克隆失败:', e));
// 使用:把 1000 万个数的求和挪出主线程,页面动画不掉帧
const data = new Float64Array(1e7).map(() => Math.random());
const sum = await callWorker(worker, 'sum', data, [data.buffer]); // 转移所有权,避免 80MB 拷贝
console.log(sum, data.length); // 注意:转移后 data.length 变成 0(buffer 已 detached)
worker.terminate(); // 不用了要销毁,否则线程和内存一直占着Worker 线程
// heavy.worker.js
// Worker 全局是 DedicatedWorkerGlobalScope:没有 window / document / DOM,
// 但有 fetch、WebSocket、IndexedDB、Cache、crypto、OffscreenCanvas、importScripts
self.addEventListener('message', async (e) => {
const { id, type, payload } = e.data;
try {
let result;
switch (type) {
case 'sum': {
let s = 0;
for (let i = 0; i < payload.length; i++) s += payload[i]; // 跑满 CPU 也不影响主线程
result = s;
break;
}
case 'parse':
result = JSON.parse(payload); // 大 JSON 解析是典型的主线程杀手
break;
default:
throw new Error(`unknown type: ${type}`);
}
self.postMessage({ id, ok: true, result });
} catch (err) {
// 必须自己捕获并回传,否则主线程只能从 error 事件拿到有限信息
self.postMessage({ id, ok: false, error: err.message });
}
});Worker 的能力边界(面试必答):
- 不能访问 DOM、
window、localStorage、alert;可以用fetch、XMLHttpRequest、WebSocket、IndexedDB、Cache API、crypto.subtle、importScripts、OffscreenCanvas。 - 通信靠消息传递,默认走结构化克隆算法(可传对象、数组、Map/Set/Date/RegExp/TypedArray/Blob/ArrayBuffer,不能传函数、DOM 节点、含原型链的类实例的方法,循环引用可以)。大对象克隆本身有成本。
- 零拷贝的两种手段:
- Transferable(
ArrayBuffer、MessagePort、ImageBitmap、OffscreenCanvas):所有权转移,原持有方 detached。 SharedArrayBuffer+Atomics:真共享内存,需要页面部署 COOP/COEP 响应头(Cross-Origin-Opener-Policy: same-origin+Cross-Origin-Embedder-Policy: require-corp)才能启用,且要用Atomics做同步以避免数据竞争。
- Transferable(
- 创建成本:一个 Worker 约 1–5MB 内存 + 几毫秒到几十毫秒启动开销。高频短任务要用 Worker 池复用,不要每次 new。
- 工程上推荐用 Comlink,把消息通信包装成 Proxy,直接
await workerApi.doWork(x),省掉手写协议。
7.3 无法移出主线程时:时间切片
DOM 操作只能在主线程做。这时把长任务拆片,每片跑完主动让出,让浏览器有机会处理输入和渲染:
// 让出主线程:优先用标准调度 API,逐级降级
const yieldToMain = () =>
globalThis.scheduler?.yield?.() // Chrome 129+,最优
?? new Promise((r) => {
// MessageChannel 是宏任务且不受 setTimeout 的 4ms 钳制,React 调度器同款方案
const { port1, port2 } = new MessageChannel();
port1.onmessage = () => { port1.close(); r(); };
port2.postMessage(null);
});
async function renderLongList(items) {
let lastYield = performance.now();
const frag = document.createDocumentFragment();
for (const item of items) {
frag.append(createRow(item));
// 每累计 5ms 就让出一次,保证不产生 >50ms 的 Long Task(影响 INP 指标)
if (performance.now() - lastYield > 5) {
await yieldToMain();
lastYield = performance.now();
}
}
document.querySelector('#list').append(frag); // 一次性插入,只触发一次回流
}其他主线程减负手段:
- 虚拟列表:只渲染可视区 DOM,把 10 万条变成 20 条。
content-visibility: auto/contain:让浏览器跳过屏外元素的布局与绘制。WebAssembly:把图像处理、视频编解码、加解密这类计算搬到 WASM,性能接近原生(配合 Worker 效果最佳)。- 服务端下沉:能在服务端算的(排序、聚合、缩略图)就不要在客户端算。
IntersectionObserver/ResizeObserver:把原本要在scroll/resize里高频计算的逻辑交给浏览器异步派发。
7.4 Node 侧对照
// Node:worker_threads 用于 CPU 密集;I/O 密集本来就是 libuv 线程池 + 事件循环,不需要 worker
import { Worker } from 'node:worker_threads';
const worker = new Worker(new URL('./cpu-task.js', import.meta.url), {
workerData: { size: 1e7 },
});
worker.on('message', (r) => console.log(r));
worker.on('error', (e) => console.error(e));
worker.on('exit', (code) => { if (code !== 0) console.error(`exit ${code}`); });worker_threads:同进程内多线程,共享SharedArrayBuffer,启动比子进程快,适合 CPU 密集计算。child_process/cluster:多进程,隔离性好、能利用多核抗住更多请求,适合把整个 HTTP 服务多开。- libuv 线程池(默认 4 线程,
UV_THREADPOOL_SIZE可调):fs、dns.lookup、crypto.pbkdf2、zlib走线程池;网络 I/O 走 epoll/IOCP 不占线程池。这是"为什么大量文件读写会互相排队,而大量 HTTP 请求不会"的原因。
【追问 & 加分点】
- "Worker 和 Service Worker、SharedWorker 的区别?"——Dedicated Worker 专属于创建它的页面,页面关闭即销毁;SharedWorker 可被同源多个页面共享(通过
port通信);Service Worker 是网络代理层,生命周期独立于页面,用于离线缓存和推送,不能做 CPU 密集计算(会被浏览器随时终止)。 - "怎么衡量主线程是否被阻塞?"——Performance 面板看 Long Task(>50ms);Core Web Vitals 里的 INP(Interaction to Next Paint,已取代 FID)直接反映交互响应;代码里可用
PerformanceObserver监听longtask/event类型上报。 - "
SharedArrayBuffer为什么默认被禁用?"——Spectre 侧信道攻击需要高精度计时器,SAB + Atomics 可以构造出来。所以现在必须开启跨源隔离(COOP + COEP)才能使用,同时performance.now()的精度也被降低了。 - "既然可以开 Worker,为什么不把所有逻辑都搬进去?"——① 通信有序列化成本,频繁小消息反而更慢;② 无法访问 DOM,UI 逻辑搬不动;③ 每个 Worker 有内存与启动开销;④ 调试复杂度上升。判断标准是"单次任务耗时 >50ms 且不涉及 DOM"。
反向链接(0)
还没有笔记链接到这里。在 Obsidian 里用 [[笔记名]] 引用它,这里就会出现,关系图谱上也会多出一条连线。