obsidian-computer
前端识记前端面试

01-js-core

JavaScript 核心

最后更新 2026年8月29日

JavaScript 核心

面向大厂前端 P6 / 2-1 级别面试的标准答案。每题结构:【核心答案】(30 秒口述版)→ 【展开】(原理与代码)→ 【追问 & 加分点】。

目录


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

逐步推演:

  1. 同步阶段(第一个宏任务:整段 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。同步阶段结束,调用栈清空。
  2. 微任务检查点(严格按 FIFO,边执行边追加)

    • M1 → queueMicrotask
    • M2 → 恢复 foo,打印 after await,foo 返回的 Promise 决议(无人订阅)
    • M3 → then 1,其返回值 undefined 使下游 Promise 决议 → 追加 M4
    • M4 → then 2
    • 队列空,检查点结束。
  3. 渲染机会(浏览器可能在此插入一帧的样式/布局/绘制)。

  4. 下一个宏任务:timer A → 打印 timeout A,产生一个微任务;A 回调返回、栈空 → 立即检查点 → 打印 promise inside timeout A。

  5. 下一个宏任务: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') 等
└──┴───────────────────────────┘

核心差异:

  1. 宏任务队列数量:浏览器概念上一条(实现上按任务来源分优先级),Node 按阶段分多条,setTimeout 和 setImmediate 落在不同阶段。
  2. process.nextTick:Node 独有,优先级高于所有 Promise 微任务,每次要清空 nextTick 队列后才清 Promise 队列;递归 nextTick 会饿死事件循环。
  3. 微任务插入时机的历史差异: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 p2
  1. setTimeout(fn, 0) vs setImmediate(fn) 在主模块中顺序不确定:取决于进程启动到进入 timers 阶段消耗的时间是否已经超过 1ms(Node 会把 0 钳制为 1ms)。但在 I/O 回调内部,setImmediate 一定先于 setTimeout,因为 I/O 回调处于 poll 阶段,紧接着就是 check 阶段。
  2. 浏览器的事件循环还要负责渲染,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 的关键实现细节(面试答到这一层就足够):

  1. 预解析阶段(Pre-Parser) 就会扫描函数体,统计哪些外层变量被内层函数引用,把它们标记为 context-allocated;ScopeInfo 记录这个布局。
  2. 运行时创建 Context 对象(一种普通的堆对象),把这些变量放进去;未被捕获的变量继续用栈槽或寄存器,享受完整的 TurboFan 优化。
  3. 闭包函数对象本身也在堆上,通过 Environment 强引用 Context,Context 的生命周期因此和闭包函数一样长——这就是闭包"内存泄漏"传闻的来源:不是泄漏,是合法的、可达的、你忘了释放。
  4. 同一个外层调用产生的多个内层函数共享同一个 Context:
function make() {
  let n = 0;
  return { inc: () => ++n, get: () => n }; // inc 和 get 共享同一个 Context
}
const o = make();
o.inc(); console.log(o.get()); // 1
  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 HooksuseState 的 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 互操作)确实这么干。

三点必须讲清楚的本质:

  1. 捕获的变量必须离开栈(堆分配或值拷贝),否则外层返回后就是悬垂引用。
  2. 按引用捕获 vs 按值捕获决定语义:JS/Go 是按引用(变量变了闭包里也变),Java 匿名内部类、C++ [=] lambda 是按值。这解释了 var 循环打印 3 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 严格模式下的差异清单

  1. 默认绑定:undefined(不再装箱为全局对象)。这也顺带堵死了"函数内部误写 this.x = 1 意外污染全局"的坑。
  2. call/apply/bind 传入原始值不再被包装成对象:
function show() { return this; }
show.call(7);        // 严格:7(number);非严格:Number 包装对象
show.call(null);     // 严格:null;非严格:globalThis
  1. class 体、ES Module 天然严格,不需要写 'use strict'。
  2. 严格模式下没有 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 false

4.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)); // null

ES5 时代的 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 必答):

  1. class 的方法不可枚举;ES5 手写的 prototype.x = fn 可枚举。
  2. class 必须 new,不能直接调用;且存在暂时性死区,不提升可用性。
  3. extends 同时建立两条链:Dog2.prototype.__proto__ === Animal2.prototype(实例方法)和 Dog2.__proto__ === Animal2(静态方法继承,ES5 需手写 Object.setPrototypeOf(Dog, Animal))。
  4. 派生类的 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();
  }
}

必须讲清的三个陷阱:

  1. .catch 在 .then 之前 vs 之后语义不同:p.catch(f).then(g) 中即使出错 g 也会执行(catch 消化了错误);p.then(g).catch(f) 中 g 的错误也会被捕获。
  2. .then(onFulfilled, onRejected) 的第二个参数捕获不到第一个参数抛出的错误,.catch 可以。
  3. 未 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/constvar 函数级作用域、变量提升、循环闭包共享块级作用域、TDZ 报错更早、每轮迭代新绑定
箭头函数回调中 this 丢失,需 var self = this词法 this,无 arguments/prototype
解构 + 默认值options = options || {} 一堆样板function f({ a = 1, b = 2 } = {}) {}
模板字符串字符串拼接易错插值、多行、标签模板(可做安全转义、i18n)
ESMCommonJS 运行时求值,无法静态分析静态结构 → Tree Shaking、循环依赖处理更明确、可 import() 动态分包
class手写原型继承样板多、易漏 constructor 修正语法统一、extends/super/static/#private
Proxy/ReflectObject.defineProperty 无法拦截新增/删除属性、数组索引Vue 3 响应式、不可变代理、埋点拦截
Map/Set用对象当字典,键只能是字符串,有原型污染风险任意类型键、有序、size、迭代协议
WeakMap无法给对象挂私有数据且不阻碍 GC键弱引用,实例回收后条目自动消失
?. / ??a && a.b && a.b.c、x || 0 误吞 0/''短路取值语义精确
生成器/迭代器自定义遍历要写 indexfor...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 必须知道的几个坑(区分度很高)

  1. preset-env 不转 API,只转语法。Promise、Map、Object.assign、Array.prototype.includes、String.prototype.padStart 都需要 core-js。忘了配 useBuiltIns 就会在低端机报 undefined is not a function。
  2. useBuiltIns: 'usage' 依赖静态分析,会漏判:obj[method]() 这种动态调用识别不出来;关键 API 建议在入口手动 import 'core-js/modules/es.promise.js' 兜底。
  3. core-js 版本必须写到 minor(如 '3.37'),只写 3 会按 3.0 的模块表注入,新 API 漏补。
  4. @babel/polyfill 已废弃(Babel 7.4+),改用 core-js@3 + regenerator-runtime。
  5. Proxy 无法 polyfill。这是"能力降级"而非"语法降级"的典型:目标包含 IE11 时不能用 Proxy(Vue 3 因此不支持 IE11)。同类的还有 WeakRef、FinalizationRegistry、真正的 Symbol 语义。
  6. 差异化打包(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)。

  1. 构建速度: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 做同步以避免数据竞争。
  • 创建成本:一个 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 里用 [[笔记名]] 引用它,这里就会出现,关系图谱上也会多出一条连线。

评论

关系图谱

01-js-core02-browser03-network04-css-html05-react06-vue07-engineering08-scenarios09-handwritten-js10-algorithms11-ai-frontend前端识记前端面试

On this page

JavaScript 核心目录1. 事件循环:宏任务、微任务、浏览器 vs Node、空闲调度【核心答案】【展开】1.1 宏任务与微任务的来源1.2 代码分析题(自构造)1.3 await 到底占几个微任务轮次1.4 浏览器 vs Node 事件循环1.5 空闲时是否占用 CPU【追问 & 加分点】2. 闭包:原理、内存位置、无闭包语言如何模拟【核心答案】【展开】2.1 工作原理2.2 实际开发用途2.3 如果语言没有闭包机制,如何手动模拟【追问 & 加分点】3. this 指向:完整规则与严格模式【核心答案】【展开】3.1 五条规则3.2 严格模式下的差异清单3.3 「看代码说答案」综合题3.4 手写 call / bind(常考)【追问 & 加分点】4. 原型与继承:new 的过程、instanceof、Object.create【核心答案】【展开】4.1 new 的过程与手写4.2 instanceof 原理与手写4.3 Object.create4.4 完整的继承链(ES5 寄生组合式 vs ES6 class)【追问 & 加分点】5. Promise 与 async/await 的使用区别【核心答案】【展开】5.1 错误处理5.2 并发5.3 可读性与其他差异【追问 & 加分点】6. ES6+ 新特性与 ES5 降级构建配置【核心答案】【展开】6.1 关键特性与其解决的实际问题6.2 完整的降级构建配置6.3 必须知道的几个坑(区分度很高)【追问 & 加分点】7. 单线程如何处理重任务与网络请求【核心答案】【展开】7.1 两类任务,两种解法7.2 Web Worker 完整示例7.3 无法移出主线程时:时间切片7.4 Node 侧对照【追问 & 加分点】