摘要:JavaScript 的灵活性容易让人迷失在 API 里。本文不聊框架用法,只谈运行时基石。从引擎与规范的实现视角,拆解闭包的内存模型、原型链查找、DOM 事件流,以及异步从回调到 Promise / Event Loop 的演进。这是一份面试备忘,也是前端工程师的底层修炼提纲。
说明:文中未给出实现的函数(如
checkStock、calculateShipping、deductPoints、submitOrder、getProductInfo、handleError、reportAPM、apiFn)均为示意依赖,需自行提供。代码示例面向现代浏览器环境。
引言:从 API 调用者到机制掌控者
框架迭代很快,但 Vue / React 仍建立在同一套语言与运行时机制上。建议掌握的六大主题是:
- 闭包(作用域与堆上 Context)
- 原型链(属性查找与继承)
- 事件流(捕获 / 目标 / 冒泡与委托)
- 异步演进(回调 → Promise → async/await;「回调地狱」是痛点,不是与前五并列的语言基石)
- Promise(状态机与链式规范化)
- Event Loop(任务、微任务与渲染协同)
掌握它们,读框架源码和面试答题都会更稳。
一、闭包:堆上 Context 与状态持久化
1. 逃逸变量与 Context
闭包不是魔法,而是「内层函数仍引用外层词法环境」的结果。
更贴近 V8 的说法是:函数执行时有调用栈帧,多数局部量可留在栈上。但若内层函数引用了外层变量(变量逃逸),这些变量会分配到堆上的 Context,由闭包持有引用。外层函数返回、栈帧弹出后,只要闭包还在,对应 Context 就不会被 GC 回收------这就是「记住变量」的原因。
注:旧规范里的 Activation Object / Variable Object 是抽象机概念,不宜与 V8 的 Context 混成「先建 Activation Object 再提升到 Context」。也勿把这里的「分配到堆」说成变量提升(hoisting)。Context 的懒分配、压缩等属于引擎优化细节,会随版本变化。
2. 业务实战:搜索请求竞态(Race Condition)
高频输入场景里,后发请求可能先返回,页面会闪回旧数据。用闭包维护单调递增的 requestId,只采纳「最新一次」的结果,是常见做法。
这里提供两个版本的思路。注意:第一个示例仅解决竞态丢弃,并非完整防抖;工业级方案应合并触发(清定时器)并配合 AbortController 取消过期请求。
基础版:竞态丢弃
javascript
function createSearchFetcher(apiFn) {
let requestId = 0; // 闭包:最新请求序号
return async function (keyword) {
const currentRequestId = ++requestId;
// 可选:简单延迟;这不等于防抖(每次调用仍会请求)
await new Promise((r) => setTimeout(r, 300));
const result = await apiFn(keyword);
// 若期间又有新输入,丢弃本次结果
// 注意:此处用 null 表示过期;若业务结果也可能是 null,调用方无法区分
if (currentRequestId !== requestId) {
console.log('丢弃过期响应:', keyword);
return null;
}
return result;
};
}
工业版:防抖 + Abort + 序号双保险
更接近工业实践的组合:防抖合并触发 + Abort 取消 in-flight + 序号立刻作废。
关键约定:
- 被挤掉 / 已过期 / 已 abort 的调用,统一结算为
{ cancelled: true, reason },不用裸null,以免与业务空结果混淆。 apiFn必须把signal传给底层请求(如fetch(url, { signal }))。- 硬前提:若 apiFn 既忽略 signal、又永不 settle(挂死),旧 Promise 会一直 pending;须保证可取消,或再包超时(如 Promise.race)。
- 关键细节:每次新调用一进来就先
requestId += 1(立刻作废 in-flight),再 abort。不要等到下次定时器触发才递增,否则在「新输入 → 下次 setTimeout 回调」之间存在竞态窗口。这也是为什么你会看到requestId有时跳两格------入口加一次,定时器里再加一次,这是刻意的双保险。
ini
function isAbortLikeError(err) {
return (
err?.name === 'AbortError' ||
err?.name === 'CanceledError' ||
err?.code === 'ERR_CANCELED'
);
}
function createDebouncedSearch(apiFn, wait = 300) {
let timer = null;
let controller = null;
let requestId = 0;
let pending = null; // { resolve, reject }
return function (keyword) {
clearTimeout(timer);
// 结算仍在防抖等待中的上一次 Promise
if (pending) {
pending.resolve({ cancelled: true, reason: 'superseded' });
pending = null;
}
// ① 先递增:立刻作废已在飞行中的请求
// ② 再 abort:尽量让底层尽快结束
requestId += 1;
controller?.abort();
return new Promise((resolve, reject) => {
pending = { resolve, reject };
timer = setTimeout(async () => {
const settle = pending;
pending = null;
// 为「这一次实际发出的请求」分配 id
const currentId = ++requestId;
const ac = new AbortController();
controller = ac;
try {
const value = await apiFn(keyword, { signal: ac.signal });
if (currentId !== requestId) {
settle?.resolve({ cancelled: true, reason: 'stale' });
return;
}
settle?.resolve({ cancelled: false, value });
} catch (err) {
// 优先认本次 signal;再兼容各库的取消错误名
if (ac.signal.aborted || isAbortLikeError(err)) {
settle?.resolve({ cancelled: true, reason: 'aborted' });
return;
}
settle?.reject(err);
}
}, wait);
});
};
}
// 调用方示意
// const outcome = await debouncedSearch('鞋');
// if (outcome.cancelled) return;
// render(outcome.value);
3. 面试高频:循环闭包陷阱
Q:为什么 var 在循环里配合异步会打出同一个值?给出两种修复。
javascript
// ❌ var 只有函数作用域,五个回调共享同一个 i
for (var i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 0); // 五个 5
}
// ✅ 方案 1:let 块级作用域(每次迭代新绑定)
for (let i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 0); // 0 1 2 3 4
}
// ✅ 方案 2:IIFE 捕获当时的值
for (var i = 0; i < 5; i++) {
(function (j) {
setTimeout(() => console.log(j), 0);
})(i);
}
二、原型链:查找算法与共享行为
1. \[Prototype] 与 class
对象通过内部槽 [[Prototype]] 链接到另一个对象。__proto__ 只是访问该槽的遗留访问器;工程上优先用 Object.getPrototypeOf / Object.create。
- 属性查找:在自身找不到 → 沿
[[Prototype]]向上,直到null。 - Class 本质:ES6 class 大体是原型继承的语法糖(方法挂在 prototype 上,实例通过
[[Prototype]]链接)。但并非毫无差异:例如 class 构造器不可省略 new 调用(其[[Call]]直接抛TypeError)、方法默认不可枚举、super有特殊绑定语义等。
2. 业务实战:组件实例工厂(共享原型)
低代码 / 动态组件场景里,用共享原型挂公共方法,实例只存差异数据,可降低内存。
ini
const BaseComponentProto = {
render() {
console.log(`Rendering ${this.type}`);
},
destroy() {
console.log('Cleaning up resources');
},
};
function ComponentFactory(config) {
const instance = Object.create(BaseComponentProto);
instance.type = config.type;
instance.props = config.props;
return instance;
}
const btn = ComponentFactory({ type: 'Button', props: {} });
btn.render(); // 方法来自共享原型
3. 面试高频:手写 instanceof
需对齐常见边界:null / undefined / 原始值左侧为 false;右侧须为带 prototype 的对象。
教学说明:下列实现覆盖 OrdinaryHasInstance 路径,未实现
Symbol.hasInstance。若右侧自定义了@@hasInstance,原生instanceof会走该方法,本函数不会。
javascript
function myInstanceof(left, right) {
// 与原生 Ordinary 路径一致:原始值、null、undefined 都为 false
if (left === null || (typeof left !== 'object' && typeof left !== 'function')) {
return false;
}
if (typeof right !== 'function' && (typeof right !== 'object' || right === null)) {
throw new TypeError('Right-hand side of instanceof is not an object');
}
// OrdinaryHasInstance Step 1:非 callable 直接返回 false
if (typeof right !== 'function') return false;
const prototype = right.prototype;
if (typeof prototype !== 'object' || prototype === null) {
throw new TypeError('Function has non-object prototype');
}
let proto = Object.getPrototypeOf(left);
while (proto) {
if (proto === prototype) return true;
proto = Object.getPrototypeOf(proto);
}
return false;
}
三、事件流:DOM 交互与框架委托
1. 规范视角:三阶段与 React 委托
现代浏览器遵循:捕获 → 目标 → 冒泡。
React 为减少监听器数量,把多数事件委托在根容器上。更准确的说法是:
- React 17+ 委托在应用 root(不再是
document)。 - 原生事件沿 DOM 传播到 root 的监听时,React 再按组件树派发合成事件。
- 不宜简单说成「一定在整个原生冒泡完全结束之后」。
- 注意 16 与 17+ 挂载点差异;少数事件(如部分 scroll、媒体相关事件)不完全走同一套委托。
2. 业务实战:Dashboard 全局快捷键
可用捕获阶段做全局拦截(如阻止浏览器默认保存),具体业务仍可在目标 / 冒泡阶段处理。跨平台需同时考虑 Windows 的 Ctrl 与 macOS 的 ⌘(metaKey)。key 在按住 Shift 时可能是 'S',应做大小写不敏感比较。
javascript
window.addEventListener(
'keydown',
(e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
console.log('触发全局保存逻辑');
}
},
true // 捕获阶段
);
3. 面试高频:事件委托
万级列表不要给每个子节点绑点击;在父节点委托,并用 closest 处理点击落在子元素内部的情况。e.target 可能是文本节点,需先落到 Element。
注意:假定页面上已存在
id="list"的节点;若getElementById得到null,直接addEventListener会抛错。Element.closest不会穿过 Shadow DOM 边界。若列表在 Web Component 的 shadow 树内,需改用event.composedPath()等方案,下文示例仅覆盖普通 DOM。
javascript
const list = document.getElementById('list');
if (!list) {
throw new Error('Expected #list to exist');
}
list.addEventListener('click', function (e) {
const raw = e.target;
const el =
raw instanceof Element
? raw
: raw instanceof Node
? raw.parentElement
: null;
const li = el?.closest('li');
if (!li || !this.contains(li)) return;
console.log('点击了列表项:', li.textContent);
});
四、异步演进:从回调嵌套到线性控制流
1. 问题本质:控制反转与信任危机
回调地狱的本质是 控制权交给第三方(Inversion of Control):调用次数、时机、错误通道都可能失控。深层嵌套还让错误处理分散、难读。
2. 业务实战:电商下单四阶段
阶段一:回调嵌套(难维护)
javascript
checkStock(productId, (stock) => {
calculateShipping(stock, (shipping) => {
deductPoints(shipping, (points) => {
submitOrder(points, (orderId) => {
console.log('订单提交成功:', orderId);
}, handleError);
}, handleError);
}, handleError);
}, handleError);
阶段二:Promise 链(扁平化 + 统一 catch)
scss
checkStock(productId)
.then((stock) => calculateShipping(stock))
.then((shipping) => deductPoints(shipping))
.then((points) => submitOrder(points))
.then((orderId) => console.log('订单提交成功:', orderId))
.catch(handleError);
阶段三:async/await(同步风格)
语义上可类比「Generator + Promise 自动执行器」;历史上也确有 co 等库走这条路。现代引擎多为独立实现,未必再 desugar 成真正的 Generator。
javascript
async function processOrder(productId) {
try {
const stock = await checkStock(productId);
const shipping = await calculateShipping(stock);
const points = await deductPoints(shipping);
const orderId = await submitOrder(points);
console.log('订单提交成功:', orderId);
} catch (err) {
handleError(err);
}
}
阶段四:无依赖步骤并行
javascript
async function processOrderOptimized(productId) {
const [stock, productInfo] = await Promise.all([
checkStock(productId),
getProductInfo(productId),
]);
const shipping = await calculateShipping(stock, productInfo.weight);
// ...
}
3. 面试高频:简易 co(Generator 自动执行)
async/await 普及前,常用 Generator + 执行器。下面是教学用实现:返回 Promise,正确续传 throw,并在 Generator return 值为 thenable 时拆一层(更接近 async 函数的 return 语义)。
注:此实现未覆盖
yield*委托情况。
scss
function co(generatorFn) {
const gen = generatorFn();
return new Promise((resolve, reject) => {
function onFulfilled(value) {
let step;
try {
step = gen.next(value);
} catch (e) {
return reject(e);
}
next(step);
}
function onRejected(err) {
let step;
try {
step = gen.throw(err);
} catch (e) {
return reject(e);
}
next(step);
}
function next({ done, value }) {
if (done) {
// 与 async 的 return 类似:若最终值是 thenable,拆一层再结束
return Promise.resolve(value).then(resolve, reject);
}
Promise.resolve(value).then(onFulfilled, onRejected);
}
onFulfilled(undefined);
});
}
// 使用示例
co(function* () {
const stock = yield checkStock(101);
const shipping = yield calculateShipping(stock);
console.log(shipping);
});
五、Promise:状态机与异步规范化
1. 规范核心(Promise/A+)
- 三态:
pending→fulfilled/rejected,不可逆、不可再变。 - 链式:
then总是返回新 Promise,支撑链式调用。 - 微任务:
then回调以微任务调度(不是setTimeout宏任务)。 - 完整实现:还需 Resolution Procedure(解析 thenable)、值穿透、错误冒泡等。
2. 业务实战:并发请求与 APM
Promise.allSettled 不会因单个失败而 reject,适合「都要等完再汇总」。失败信息在每项的 status 里,不要指望外层 catch 接到业务失败。
另:
fetch在收到 HTTP 4xx / 5xx 时仍然 fulfill(status 为'fulfilled'),只有网络故障、CORS 阻断、AbortSignal 触发等才会 reject。业务成败需再看response.ok/status。
javascript
async function fetchWithMonitoring(urls) {
const start = performance.now();
const results = await Promise.allSettled(urls.map((url) => fetch(url)));
try {
reportAPM({ duration: performance.now() - start }); // 示意
} catch (err) {
console.error('监控上报失败', err);
}
// 如需区分「网络失败」与「HTTP 错误」:
// results.forEach((r) => {
// if (r.status === 'fulfilled' && !r.value.ok) { /* HTTP 错误 */ }
// if (r.status === 'rejected') { /* 网络等失败 */ }
// });
return results;
// [{ status: 'fulfilled', value: Response }, { status: 'rejected', reason }, ...]
}
3. 面试高频:教学版 Promise(非完整 A+)
下面演示状态机、回调队列与链式返回。刻意用 queueMicrotask。
明确边界:构造器
resolve(x)不拆 thenable;then回调返回值不走 Promise Resolution Procedure(即回调若返回Promise/thenable,不会被自动展开------链式传递的是Promise对象本身而非其值);无catch/finally等。因此只适合讲解状态机与微任务调度,不能当生产实现。
ini
class MyPromise {
constructor(executor) {
this.status = 'pending';
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.status !== 'pending') return;
this.status = 'fulfilled';
this.value = value;
this.onFulfilledCallbacks.forEach((fn) => fn());
};
const reject = (reason) => {
if (this.status !== 'pending') return;
this.status = 'rejected';
this.reason = reason;
this.onRejectedCallbacks.forEach((fn) => fn());
};
try {
executor(resolve, reject);
} catch (e) {
reject(e);
}
}
then(onFulfilled, onRejected) {
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (v) => v;
onRejected =
typeof onRejected === 'function'
? onRejected
: (e) => {
throw e;
};
return new MyPromise((resolve, reject) => {
const run = (cb, arg) => {
queueMicrotask(() => {
try {
const x = cb(arg);
resolve(x);
} catch (e) {
reject(e);
}
});
};
if (this.status === 'fulfilled') {
run(onFulfilled, this.value);
} else if (this.status === 'rejected') {
run(onRejected, this.reason);
} else {
this.onFulfilledCallbacks.push(() => run(onFulfilled, this.value));
this.onRejectedCallbacks.push(() => run(onRejected, this.reason));
}
});
}
}
六、Event Loop:调度与 UI 协同
1. 更准确的模型(浏览器主线程 / HTML 事件环简化版)
下文指浏览器主线程。规范用语是 task(任务)与 microtask(微任务);中文面试常说的「宏任务」是社区俗称,约等于 task。Node.js 的阶段模型(含 process.nextTick 等)与此不同,勿直接套用。
注:本模型指经典
<script>执行流程,不含type="module"/defer等加载细节。
浏览器主线程可简化为:
- 从任务队列取一个 task 执行(每个经典
<script>块的执行对应一个独立task)。 - 执行完毕后清空微任务队列(微任务中再排队的微任务也会在本轮清空)。
- 浏览器可能渲染(按帧预算,不是每轮必渲染;渲染前后规范里还有 microtask checkpoint,此处从略)。
- 再取下一个 task(
setTimeout、部分 I/O / UI 任务等)。
| 类型 | 常见示例 | 相对时机 |
|---|---|---|
| 微任务 | Promise.then、queueMicrotask、MutationObserver、await 之后的续跑 |
当前 task 结束后、下一 task 前清空 |
| 任务(俗称宏任务) | setTimeout、setInterval、部分 I/O / UI 任务 |
进入后续 task |
勿写成固定流水线「同步 → 微任务 → 必渲染 → 宏任务」。同步代码属于当前 task 的一部分;渲染是可选步骤。以上是便于面试与工程沟通的简化模型,不是规范全文。
2. 业务实战:大数据分片渲染(Time Slicing)
一次插入十万 DOM 会长时间占住主线程。用任务切片可让出主线程(注意 setTimeout(0) 仍有最小延迟钳制)。若目标是「跟帧绘制」,可再结合 requestAnimationFrame / scheduler.postTask。
ini
function renderLargeList(data, container) {
let index = 0;
const chunkSize = 20;
function processChunk() {
const fragment = document.createDocumentFragment();
for (let i = 0; i < chunkSize && index < data.length; i++) {
const div = document.createElement('div');
div.textContent = data[index++];
fragment.appendChild(div);
}
container.appendChild(fragment);
if (index < data.length) {
setTimeout(processChunk, 0); // 让出主线程;跟帧可用 rAF
}
}
processChunk();
}
3. 面试高频:输出顺序
javascript
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出: 1 → 4 → 3 → 2
原因:
1、4在当前 task(script)同步执行;3是微任务,在该 task 结束后、下一 task 前执行;2是 timer 任务,进入后续轮次。
结语
闭包与 Context、原型查找、事件流、Promise 状态机、Event Loop 调度,构成前端可迁移的护城河。读框架时先映射到这些机制,比死记 API 更抗过时。
互动:你在项目里遇过最棘手的闭包或异步竞态是什么?欢迎分享踩坑与解法。