外观
JavaScript 系统学习文档 - 深入详解篇
补充 JavaScript 系统学习文档,深入 JS 引擎层面的工作机制
包含:执行上下文 | 闭包内存模型 | 事件循环微观层面 | Promise/async 原理 | 模块机制 | 垃圾回收
一、JavaScript 的运行时模型:执行上下文与调用栈
1.1 执行上下文是什么?
JavaScript 代码运行的环境抽象叫做"执行上下文"(Execution Context)。它不是"作用域",而是包含了作用域、this 绑定、变量提升等所有运行时信息的数据结构。
执行上下文包含三个核心组件:
javascript
ExecutionContext = {
VariableEnvironment: // 变量环境(存放 var 声明和函数声明)
{ environmentRecord: { a: undefined, foo: <function> }, outer: global },
LexicalEnvironment: // 词法环境(存放 let/const/class 声明)
{ environmentRecord: { b: <uninitialized> }, outer: global },
ThisBinding: // this 的指向
window / undefined / 实例对象
};为什么分两个环境?
ES6 引入 let 和 const 后,需要区分两种变量声明方式:
var声明的变量在初始化时被提升并被赋值为 undefinedlet/const声明的变量在初始化时被提升但不被赋值(暂时性死区)
如果只有一个环境,无法同时表达这两种不同的初始化行为。所以 JS 引擎分为 VariableEnvironment(处理 var)和 LexicalEnvironment(处理 let/const)。
javascript
console.log(a); // undefined(var 提升,初始化为 undefined)
console.log(b); // ReferenceError(let 提升但未初始化,暂时性死区)
var a = 1;
let b = 2;
// 编译阶段,两个环境的初始化状态:
//
// VariableEnvironment: { a: undefined }
// LexicalEnvironment: { b: <uninitialized> }
// ↑ b 被记住了存在,但处于"不可访问"状态
// 直到执行到 let b = 2 那一行,才变为 21.2 调用栈的工作原理
javascript
function multiply(a, b) {
console.trace(); // 打印当前调用栈
return a * b;
}
function square(n) {
return multiply(n, n);
}
function main() {
return square(3);
}
main();
// 调用栈的变化过程:
// 1. 全局执行上下文入栈 [global]
// 2. main() 调用 → [global, main]
// 3. square(3) 调用 → [global, main, square]
// 4. multiply(3, 3) 调用 → [global, main, square, multiply]
// 5. multiply 返回,出栈 → [global, main, square]
// 6. square 返回,出栈 → [global, main]
// 7. main 返回,出栈 → [global]栈溢出:
javascript
function infinite() {
return infinite();
}
infinite();
// RangeError: Maximum call stack size exceeded
// Chrome 中大约 10,000-14,000 层的深度
// Safari 中大约 50,000+ 层1.3 作用域链(Scope Chain)与标识符解析
执行上下文里的词法环境有一个 outer 引用,指向外层环境。查找变量时,从当前环境向外层逐级查找,这条链路就是作用域链。
javascript
const global = "g";
function outer() {
const a = "a";
function inner() {
const b = "b";
console.log(b); // 当前环境找到
console.log(a); // 沿 outer 引用到 outer 环境找到
console.log(global); // 继续向上到全局环境找到
}
inner();
}
outer();解析过程:
text
inner 环境 { b: "b", outer -> outer环境 }
→ 找不到 a、global,沿 outer 向上
outer 环境 { a: "a", outer -> 全局环境 }
→ 找到 a;找不到 global,继续向上
全局环境 { global: "g", outer -> null }
→ 找到 global;outer 为 null,查找终止两个要点:
- 作用域在函数定义时确定(词法作用域),与调用位置无关。闭包能「记住」定义处环境,本质就是保存了这条
outer链。 - 查找方向永远是从内到外,内层同名变量会**遮蔽(shadow)**外层变量。
javascript
const x = "outer";
function f() {
console.log(x); // 由函数定义处决定,输出 "outer"
}
function g() {
const x = "inner";
f(); // 调用 f,但 f 的词法环境里没有 x,向外找到全局的 "outer"
}
g(); // "outer"二、闭包的内部机制:词法环境 + 作用域链
2.1 闭包的函数式定义 vs 引擎层实现
函数式定义:闭包是一个函数和其词法环境的组合。即使函数在其定义的作用域之外执行,仍然可以访问其定义时的词法环境。
引擎层实现:
javascript
// 让我们在 V8 引擎的角度看闭包
function createCounter() {
let count = 0; // 局部变量
return function() { // 内部函数(闭包)
count++; // 引用了外部函数的变量
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2当 createCounter 执行完毕后,通常它的栈帧应该被销毁。但引擎发现内部函数引用了 count 变量,于是:
- 不把 count 放在栈上,而是堆分配——因为栈帧销毁后只有堆上的内存才能保留下来
- 内部函数的作用域链指向堆上的 count
- 内部函数被返回后,其引用使堆上的 count 不会被 GC 回收
javascript
// V8 的内存视角:
//
// ┌─────────────────────┐
// │ createCounter 的栈帧 │ ← 执行完毕,栈帧出栈
// │ [参数] │
// │ [局部变量] │
// │ count │ → 但由于闭包引用,count 被移到堆上
// └─────────────────────┘
//
// ┌─── 堆内存 ────────────┐
// │ count: 0 (后变为 1,2) │ ← counter 函数的作用域链指向这里
// │ ┌─ counter ────────┐ │
// │ │ [[Scopes]]: │ │
// │ │ Closure(createCounter): { count: 2 }
// │ │ Global {...} │ │
// │ └──────────────────┘ │
// └──────────────────────┘2.2 闭包和内存泄漏的引擎层分析
什么情况下闭包真的会泄漏内存?
javascript
// 问题代码
function createHandler() {
const hugeData = new Array(1000).fill("大量数据"); // 10MB
const userId = 42;
return function(event) {
// userId 被使用了
console.log(userId, event.type);
// hugeData 没被使用但仍在闭包中
};
}
const handler = createHandler();
// handler 持续存活的期间,hugeData 占据的 10MB 内存不会被释放
// 但 handler 中实际上没有使用 hugeData!现代引擎的优化:Chrome V8 的"闭包变量分析"
V8 在优化编译闭包时,会分析内部函数实际使用了哪些外部变量,只保留被使用的变量。在上面的例子中,V8 实际上只会保留 userId,而不会保留 hugeData。所以你不需要手动将 hugeData = null。
但有一个例外——eval() 和 with 语句:
javascript
function problematic() {
const data = new Array(1000).fill("泄漏");
return function() {
// eval 可以访问这里的任何变量,所以引擎无法做变量分析
// data 必须被完整保留
eval("console.log(1)");
};
}2.3 经典闭包面试题解析
javascript
for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i); // 输出什么?
}, 1000);
}
// 输出:5, 5, 5, 5, 5
// 为什么?因为 var i 是函数作用域,循环结束后 i = 5
// 所有 setTimeout 回调都引用了同一个 i修复方式分析:
javascript
// 方式一:闭包 + IIFE(Vue 2 时代的经典写法)
for (var i = 0; i < 5; i++) {
(function(j) {
setTimeout(() => console.log(j), 1000);
})(i);
}
// 每次循环创建一个新的函数作用域,j 是形参,被内部函数引用
// 方式二:let 块级作用域(推荐)
for (let i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 1000);
}
// 引擎层:let i 在每次循环迭代时都创建一个新的词法环境
// 等价于引擎自动帮你做了方式一的事情
// 方式三:setTimeout 的第三个参数
for (var i = 0; i < 5; i++) {
setTimeout(console.log, 1000, i);
}三、事件循环的微观层面
3.1 宏任务与微任务的本质
javascript
console.log("1"); // ① 同步
setTimeout(() => console.log("2"), 0); // ② 宏任务
Promise.resolve().then(() => console.log("3")); // ③ 微任务
Promise.resolve().then(() => { // ④ 微任务
console.log("4");
setTimeout(() => console.log("5"), 0); // ⑤ 宏任务(在微任务中注册)
});
console.log("6"); // ⑥ 同步
// 输出顺序:
// 1 → 6 → 3 → 4 → 2 → 5事件循环的一次迭代(tick):
执行一个宏任务(从宏任务队列取一个)
↓
执行所有微任务(清空整个微任务队列)
↓
执行 requestAnimationFrame(浏览器环境)
↓
更新渲染(DOM 重排/重绘)
↓
开始下一轮宏任务...
为什么微任务必须全部执行完才处理下一个宏任务?
因为微任务是在同一个"任务帧"中执行的,它们共享同一个执行环境。
如果在微任务执行过程中插入新的宏任务,会导致渲染时机难以预测。Node.js vs 浏览器的事件循环差异:
javascript
// Node.js 的事件循环分为 6 个阶段(phase):
// timers → pending callbacks → idle/prepare → poll → check → close callbacks
// ↑_____________________________________________↓
//
// 每个阶段都有自己的队列
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// 输出顺序:不确定!
// 因为 setTimeout 的 0 延迟并不保证在 timers 阶段立即执行,
// 取决于系统的定时器精度和 Node 的事件循环调度
// 但在 I/O 回调内部,setImmediate 总是先执行:
const fs = require("fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"));
setImmediate(() => console.log("immediate")); // 先输出
});
// 因为在 poll 阶段之后会进入 check 阶段(setImmediate 所在)
// 而 timers 阶段在下一轮才到3.2 process.nextTick 的特殊地位
Node.js 中 process.nextTick 既不是宏任务也不是微任务,它在当前操作完成后、事件循环进入下一阶段之前立即执行:
javascript
// Node.js 中的执行阶段顺序(实际比这个更复杂):
// timer 阶段开始之前 → 执行所有 nextTick 回调
// timer 阶段之后 → 执行所有 nextTick 回调
// 每个阶段之后 → 执行所有 nextTick 回调
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
// 输出顺序:
// nextTick(当前操作完成后立即执行,优先于微任务)
// promise(微任务)
// timeout 与 immediate 的相对顺序在主模块中不确定(见 3.1)四、Promise 的微观机制
4.1 Promise 的三种状态及转换
javascript
// Promise 状态机
// ┌─────────┐
// │ pending │ ← 初始状态
// └────┬────┘
// │
// ├── resolve(value) ────────────→ fulfilled
// │ │
// └── reject(reason) ──────────────→ rejected
// └── throw error ─────────────────→ rejected
//
// 状态一旦转移,不可逆转!
const p = new Promise((resolve, reject) => {
resolve("成功");
reject("失败"); // 被忽略,状态已变为 fulfilled
resolve("又成功"); // 被忽略,状态已不可变
});
p.then(console.log); // 只输出 "成功"**Promise 解决过程(Promise Resolution Procedure)**是 Promise/A+ 规范中最复杂的部分。
javascript
// 当 then 回调返回一个值 x 时,resolvePromise 会根据 x 的类型做不同处理:
function resolvePromise(promise2, x, resolve, reject) {
// 1. 如果 promise2 和 x 是同一个对象 → TypeError(循环引用)
if (promise2 === x) reject(new TypeError("循环引用"));
// 2. 如果 x 是 Promise → 等待 x 的状态
if (x instanceof Promise) {
x.then(resolve, reject);
}
// 3. 如果 x 是 thenable(有 then 方法的对象/函数)
if (typeof x === "object" && x !== null || typeof x === "function") {
// 调用 x.then(resolve, reject)
// 这是为了实现不同 Promise 库的互操作
}
// 4. 其他情况 → 直接 resolve
resolve(x);
}4.2 then 的微观行为
javascript
// Promise 的 then 是微任务还是宏任务?
// 已 resolve 的 Promise 的 then 回调在哪执行?
const p = Promise.resolve("done");
p.then(console.log); // 这个回调何时执行?
console.log("sync"); // 先输出
// 输出:sync → done
// 因为即使 Promise 已经 resolve,then 的回调仍然在微任务队列中执行为什么已经 resolve 的 Promise 的 then 回调还是异步的?
这是为了保证一致的行为。如果 p.then(cb) 中的 cb 在同步执行和异步执行之间根据状态变化,会导致不可预测的 bug:
javascript
// 一致性保证
function fetchData() {
if (cache.has(key)) {
return Promise.resolve(cache.get(key)); // 即使有缓存也返回 Promise
}
return fetch(url).then(data => {
cache.set(key, data);
return data;
});
}
// 调用方不需要关心是否有缓存,行为一致
fetchData().then(data => {
// 无论有无缓存,这里都是异步执行
});4.3 then 的返回值穿透与错误穿透
每次调用 .then() / .catch() / .finally() 都会返回一个新的 Promise,链式调用正是靠这个机制串联起来的。
返回值穿透:then 回调里 return 的值,会成为下一个 then 的入参;什么都不返回(返回 undefined)时,下一个 then 收到 undefined。
javascript
Promise.resolve(1)
.then(v => v + 1) // 返回 2
.then(v => v * 10) // 返回 20
.then(v => console.log(v)); // 20回调里返回的 Promise(或 thenable)会被「展开」,等它 settled 后才继续向后传递:
javascript
Promise.resolve(1)
.then(v => Promise.resolve(v + 1)) // 返回 Promise,会被展开
.then(v => console.log(v)); // 2错误穿透:一旦某一步 reject,后续只传了 onFulfilled 的 then 会被跳过,错误一直向下传播,直到遇到 catch:
javascript
Promise.reject(new Error("boom"))
.then(() => console.log("不执行 1"))
.then(() => console.log("不执行 2"))
.catch(err => console.log(err.message)); // "boom"⚠️ 陷阱:catch 本身也返回新 Promise。如果 catch 里正常返回一个值,链会恢复为 fulfilled 状态:
javascript
Promise.reject(new Error("boom"))
.catch(() => "默认值")
.then(v => console.log(v)); // "默认值"(错误已被 catch 消化)理解「每个 then 都返回新 Promise」+「值穿透 / 错误穿透」,是手写 Promise 和调试异步链的基础。
五、async/await 的编译原理
5.1 async/await 本质是 Generator + Promise 的语法糖
javascript
// 高层的 async/await
async function fetchUser(id) {
const response = await fetch(`/api/user/${id}`);
const user = await response.json();
return user;
}
// 编译后(简化,Babel 输出):
function fetchUser(id) {
return __awaiter(this, void 0, void 0, function* () {
const response = yield fetch(`/api/user/${id}`);
const user = yield response.json();
return user;
});
}
// __awaiter 的实现(简化):
function __awaiter(thisArg, _arguments, generator) {
return new Promise((resolve, reject) => {
const gen = generator.call(thisArg);
function step(key, arg) {
try {
const result = gen[key](arg); // gen.next(value)
if (result.done) {
resolve(result.value);
} else {
Promise.resolve(result.value).then(
value => step("next", value),
error => step("throw", error)
);
}
} catch (e) {
reject(e);
}
}
step("next");
});
}关键点:await 后面的表达式会立即执行,然后暂停当前 async 函数
javascript
async function test() {
console.log("1");
const result = await Promise.resolve("2");
console.log(result);
console.log("3");
}
test();
console.log("4");
// 执行流程详解:
// 1. 调用 test(),进入 async 函数
// 2. 输出 "1"(await 之前的同步代码)
// 3. 执行 Promise.resolve("2")(立即执行,返回已 resolve 的 Promise)
// 4. 遇到 await,暂停 test 的执行
// 5. 返回控制权给调用方,输出 "4"
// 6. 微任务队列执行 Promise.resolve("2") 的 then 回调
// 7. test 恢复执行
// 8. 输出 "2",然后输出 "3"
// 最终输出:1 → 4 → 2 → 35.2 Async 函数的错误处理链
javascript
async function getData() {
const response = await fetch("/api/data"); // 如果这里抛出
return response.json(); // 下面代码不会执行
}
// 等价于
function getData() {
return fetch("/api/data")
.then(response => response.json())
.catch(error => {
// 所有错误都被 catch 捕获
throw error; // 不处理就继续抛
});
}
// await 的 reject 和 throw 都会被 catch 捕获
async function safeFetch() {
try {
const data = await fetch("/api/data");
return data;
} catch (error) {
console.error("请求失败:", error);
return { fallback: true }; // 返回默认值
}
}5.3 return await 与 return 的区别
在 async 函数里,return p 和 return await p 大多数时候结果一样,但有一个关键区别:try/catch 能不能捕获 p 的 rejection。
javascript
async function fetchData() {
throw new Error("boom");
}
async function withoutAwait() {
try {
return fetchData(); // 没有 await,rejection 不会被这个 catch 捕获
} catch (e) {
return "fallback";
}
}
async function withAwait() {
try {
return await fetchData(); // 有 await,rejection 会被 catch 捕获
} catch (e) {
return "fallback";
}
}
withoutAwait().catch(e => console.log("未捕获:", e.message)); // 走这里
withAwait().then(v => console.log(v)); // "fallback"原因:try/catch 只能捕获同步抛出的错误,以及 await 展开时抛出的 rejection。return fetchData() 只是把一个 rejected 的 Promise 原样返回出去,rejection 发生在调用方,而不是 try 块内部。
💡 实践结论:
- 在
try/catch里要return await,否则捕获不到错误。 - 在
try/catch外,return await是多余的(多包一层 await,行为和return等价),可以省略。
ESLint 的 no-return-await 规则正是用来提醒开发者区分这两种场景。
5.3.1 async generator 与 for await...of
async function* 定义异步生成器,可以用 for await...of(ES2018)逐项消费异步数据流:
javascript
async function* createStream() {
yield "a";
yield "b";
}
for await (const chunk of createStream()) {
console.log(chunk); // "a"、"b"
}常用于流式数据、分页拉取、异步队列的逐项处理。
六、模块加载机制
6.1 ES Module 的静态分析
ES Module 的核心特征是静态的模块结构:
javascript
// ✅ 静态导入
import { add } from "./math.js"; // 编译时就知道
import * as math from "./math.js"; // 导入了什么
// ❌ 以下都是 ES Module 不支持的
import { condition ? "a" : "b" }; // 不能条件导入
import path.variable; // 不能动态路径
const module = "./path.js";
import { data } from module; // 不能变量路径静态分析的意义:
- Tree-shaking:编译器可以在编译时确定哪些导出被使用了,哪些没有。未使用的导出可以被安全地移除。
- 确定性:模块依赖图在编译时确定,不需要等到运行时才知道哪些模块会加载。
- 循环依赖检测:静态分析可以在编译时检测循环依赖。
javascript
// 只有真正被使用的导出才会被打包
// math.js
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }
// app.js
import { add } from "./math.js";
console.log(add(1, 2));
// 打包后,multiply 不会被包含(Tree-shaking 生效)6.2 动态 import()
javascript
// 动态 import 是 ES2020 引入的,它是运行时执行的
const modulePath = getModulePath(); // 运行时确定路径
const module = await import(modulePath); // 运行时加载
// 场景:按需加载
button.addEventListener("click", async () => {
const { showToast } = await import("./toast.js");
showToast("操作成功");
});6.3 CommonJS 和 ES Module 的循环依赖对比
javascript
// a.js(CommonJS)
const b = require("./b");
console.log("a.js: b.done =", b.done);
exports.done = true;
// b.js(CommonJS)
const a = require("./a");
console.log("b.js: a.done =", a.done); // undefined!
exports.done = true;
// 原因:CJS require 是同步执行,加载 b 时 a 只执行了一部分
// a.js → require("./b") → b.js → require("./a")
// ↓
// a.js 还没执行完,exports 上没有 done
// 所以 a.done === undefined
// a.mjs(ES Module)
import { done as bDone } from "./b.mjs";
export const done = true;
console.log("a.mjs: b.done =", bDone);
// b.mjs(ES Module)
import { done as aDone } from "./a.mjs";
export const done = true;
console.log("b.mjs: a.done =", aDone);
// ESM 通过"实时绑定"(live binding)解决循环依赖
// ESM 的 import 是只读引用,指向导出值的"绑定"
// 即使 a 在 b 导入时还没有值,但当 a 执行完成后,b 中的 aDone 会反映最新的值七、V8 引擎的垃圾回收机制
7.1 新生代和老生代
V8 将堆内存分为两个区域:
堆内存
├── 新生代(Young Generation)
│ ├── From 空间
│ └── To 空间
│ 存储生命周期短的对象
│ 使用 Scavenge 算法(复制算法)
│ GC 频率高但速度快
│
└── 老生代(Old Generation)
存储生命周期长的大对象
使用 Mark-Sweep & Mark-Compact
GC 频率低但速度慢(可能产生暂停)新生代的 GC 过程(Scavenge 算法):
javascript
// 1. 新生代分为 From 和 To 两个空间
// 2. 新对象分配在 From 空间
// 3. 当 From 空间快满时:V8 扫描 From 中存活的对象
// 4. 存活的对象复制到 To 空间
// 5. From 和 To 角色互换
// 6. 经过多次 GC 后仍然存活的对象 → 晋升到老生代
// 优化原理:
// JS 中的大多数对象生命周期很短(如函数内的局部对象)
// 复制只处理存活对象,而死掉的对象完全不处理
// 所以新生代 GC 的耗时只和存活对象数量成正比7.2 内存泄漏的常见模式
javascript
// 1. 定时器未清理
const element = document.getElementById("btn");
setInterval(() => {
element.textContent = Date.now();
}, 1000);
// element 被定时器回调引用,即使从 DOM 中移除也不会被 GC
// 2. 事件监听未移除
element.addEventListener("click", handler);
// 移除 element 前必须先 removeEventListener
// 3. 全局变量
function leak() {
window.hugeArray = new Array(1000000);
}
// 4. 闭包中的意外引用
function outer() {
const unused = new Array(1000000);
return function() {
// 如果这里没有引用 unused,V8 会优化掉
// 但如果存在 eval,V8 无法优化
};
}
// 5. Map 而不是 WeakMap
const map = new Map();
document.querySelectorAll("*").forEach(el => {
map.set(el, { data: "xxx" }); // el 被引用,不会被释放
});
// WeakMap 的键是弱引用,不会阻止 GC八、其他深入话题
8.1 属性描述符与 Proxy 的关系
javascript
// 属性描述符是 Object.defineProperty 的基础
const obj = {};
Object.defineProperty(obj, "hidden", {
value: "secret",
writable: false, // 不可写
enumerable: false, // 不可枚举
configurable: false // 不可配置
});
// Proxy 的 get/set 是对属性描述符的"更高层"封装
// Object.defineProperty → 拦截单个属性的读/写
// Proxy → 拦截整个对象的读/写/删/枚举等所有操作
// Proxy 在处理 get 和 set 时,最终也是调用 Reflect.get/set
// 而 Reflect.get/set 内部会调用属性的 [[Get]] 和 [[Set]] 内部方法
// 这些内部方法会检查属性描述符中的 writable/configurable8.2 Symbol 在引擎层的实现
Symbol 保证的"唯一性"是在引擎层实现的,不是通过运行时检查:
javascript
// Symbol 的值在引擎内部是一个唯一的数字 ID
const s1 = Symbol("description"); // ID = 42
const s2 = Symbol("description"); // ID = 43
s1 === s2; // false(42 !== 43)
// Symbol.for 则通过全局注册表实现
const s3 = Symbol.for("global"); // 全局注册表 { "global": Symbol(44) }
const s4 = Symbol.for("global"); // 从注册表查找,返回 Symbol(44)
s3 === s4; // true(因为返回了同一个 Symbol)九、对象模型与原型链深度解析
基础文档会告诉你“对象沿着原型链查找属性”,但深入学习必须理解对象内部槽、属性描述符、构造函数、class、this 绑定之间的关系。
9.1 对象不是“哈希表”这么简单
从语言层看,对象是属性的集合;从引擎层看,对象通常会经历隐藏类、内联缓存、字典模式等优化。学习阶段不需要掌握所有 V8 细节,但要知道:对象的形状稳定,访问性能通常更好;频繁动态增删属性,可能让对象退化到更慢的模式。
javascript
// 形状稳定:多个对象按同样顺序创建属性
function createUser(name, age) {
return {
name,
age,
active: true,
};
}
// 形状不稳定:运行时随机添加/删除属性,优化更困难
const user = {};
if (Math.random() > 0.5) user.name = "Alice";
delete user.name;
user.age = 18;工程建议:
- 业务对象尽量在创建时声明完整字段。
- 不要频繁
delete热路径对象属性,可以用null或创建新对象表达状态变化。 - 需要动态键值集合时,优先考虑
Map。
9.2 属性描述符:数据属性与访问器属性
对象属性分两类:
| 类型 | 关键字段 | 示例 |
|---|---|---|
| 数据属性 | value、writable、enumerable、configurable | 普通字段 |
| 访问器属性 | get、set、enumerable、configurable | getter/setter |
javascript
const user = {};
Object.defineProperty(user, "name", {
value: "Alice",
writable: false,
enumerable: true,
configurable: false,
});
user.name = "Bob";
console.log(user.name); // 非严格模式下仍是 Alice
console.log(Object.getOwnPropertyDescriptor(user, "name"));访问器属性:
javascript
const order = {
price: 100,
count: 2,
};
Object.defineProperty(order, "total", {
get() {
return this.price * this.count;
},
enumerable: true,
});
console.log(order.total); // 200Vue 2 的响应式核心就基于 Object.defineProperty 把字段改造成 getter/setter;Vue 3 改用 Proxy,可以拦截更多操作。
9.3 [[Get]]:属性读取的真实过程
当执行 obj.key 时,并不是简单地查一个 Map。更接近下面的过程:
- 查找对象自身是否有属性
key。 - 如果是数据属性,返回
value。 - 如果是访问器属性,调用 getter,getter 中的
this通常是接收者对象。 - 如果自身没有,沿着
[[Prototype]]查找。 - 查到
null还没有,则返回undefined。
这解释了为什么原型上的 getter 中 this 可以指向实例:
javascript
const proto = {
get upperName() {
return this.name.toUpperCase();
},
};
const user = Object.create(proto);
user.name = "alice";
console.log(user.upperName); // ALICEgetter 定义在 proto 上,但 this 是 user。
9.4 hasOwnProperty、in 与属性枚举
javascript
const proto = { role: "admin" };
const user = Object.create(proto);
user.name = "Alice";
console.log("name" in user); // true
console.log("role" in user); // true,原型上也算
console.log(Object.hasOwn(user, "role")); // false常用判断:
| 写法 | 是否查原型链 | 推荐场景 |
|---|---|---|
"key" in obj | 是 | 判断对象是否能访问某属性 |
Object.hasOwn(obj, "key") | 否 | 判断是否为自身属性 |
obj.hasOwnProperty("key") | 否 | 老写法,但对象可能覆盖该方法 |
枚举差异:
javascript
const symbolKey = Symbol("id");
const user = { name: "Alice", [symbolKey]: 1 };
Object.defineProperty(user, "hidden", {
value: true,
enumerable: false,
});
console.log(Object.keys(user)); // ["name"]
console.log(Object.getOwnPropertyNames(user)); // ["name", "hidden"]
console.log(Object.getOwnPropertySymbols(user)); // [Symbol(id)]
console.log(Reflect.ownKeys(user)); // ["name", "hidden", Symbol(id)]9.5 原型污染风险
原型链既是能力,也是安全边界。把用户输入当作对象路径合并时,如果允许写入 __proto__、constructor.prototype 等路径,可能污染全局对象原型。
危险示意:
javascript
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === "object") {
target[key] = target[key] || {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
merge({}, JSON.parse('{"__proto__":{"isAdmin":true}}'));
console.log({}.isAdmin); // 可能被污染防护原则:
- 合并对象时拒绝
__proto__、prototype、constructor等危险 key。 - 使用成熟库并保持更新。
- 需要纯字典对象时可以使用
Object.create(null)。 - 处理外部 JSON 时明确字段白名单。
javascript
const dict = Object.create(null);
dict.name = "Alice";
console.log(Object.getPrototypeOf(dict)); // null9.6 class 继承的原型结构
javascript
class Animal {
speak() {
console.log("sound");
}
static kingdom() {
return "animal";
}
}
class Dog extends Animal {
speak() {
super.speak();
console.log("wang");
}
}
const dog = new Dog();
dog.speak();这里有两条链:
text
实例方法查找链:
dog -> Dog.prototype -> Animal.prototype -> Object.prototype -> null
静态方法查找链:
Dog -> Animal -> Function.prototype -> Object.prototype -> null所以:
javascript
console.log(dog instanceof Dog); // true
console.log(dog instanceof Animal); // true
console.log(Dog.__proto__ === Animal); // true,静态继承链
console.log(Dog.prototype.__proto__ === Animal.prototype); // true,实例方法链super 在实例方法中指向父类原型,在静态方法中指向父类构造函数。
9.7 原型链学习必须会画图
看到下面代码:
javascript
function Person(name) {
this.name = name;
}
Person.prototype.say = function() {
return this.name;
};
const p = new Person("Alice");要能画出:
text
p
├── name: "Alice"
└── [[Prototype]] -> Person.prototype
├── say: function
├── constructor: Person
└── [[Prototype]] -> Object.prototype
└── [[Prototype]] -> null
Person
├── prototype -> Person.prototype
└── [[Prototype]] -> Function.prototype这张图比背“原型链是什么”有用得多。
9.8 Mixin 的本质:能力复制,不是多继承
Mixin 经常和继承一起讨论,但它不是 JavaScript 的“多继承”。JavaScript 对象查找属性时仍然只沿着一条 [[Prototype]] 链向上查找。
javascript
const CanLog = {
log(message) {
console.log(`[log] ${message}`);
}
};
const CanSerialize = {
toJSON() {
return JSON.stringify(this);
}
};
class User {
constructor(name) {
this.name = name;
}
}
Object.assign(User.prototype, CanLog, CanSerialize);
const user = new User("Alice");
user.log("created");
console.log(user.toJSON());执行完 Object.assign 后,原型关系是:
text
user
-> User.prototype
├── constructor
├── log // 从 CanLog 复制过来
└── toJSON // 从 CanSerialize 复制过来
-> Object.prototype
-> null注意,这里没有出现:
text
user -> CanLog -> CanSerialize -> Object.prototypeCanLog 和 CanSerialize 不是 user 的原型链节点,它们只是方法来源。Mixin 的关键动作通常是“复制属性”或“返回增强类”。
9.8.1 Object.assign 混入的真实限制
Object.assign(target, source) 做的是浅拷贝,并且只复制 source 自身的可枚举属性。
javascript
const mixin = {};
Object.defineProperty(mixin, "hidden", {
value: "secret",
enumerable: false
});
Object.assign(User.prototype, mixin);
console.log(User.prototype.hidden); // undefined如果 mixin 里有 getter,Object.assign 会读取 getter 的结果,而不是把 getter 本身复制过去:
javascript
const mixin = {
get now() {
return Date.now();
}
};
const target = {};
Object.assign(target, mixin);
console.log(typeof Object.getOwnPropertyDescriptor(target, "now").get);
// undefined,因为 now 已经变成普通数据属性需要保留属性描述符时,应该这样复制:
javascript
function copyProperties(target, source) {
for (const key of Reflect.ownKeys(source)) {
if (key === "constructor") continue;
const descriptor = Object.getOwnPropertyDescriptor(source, key);
Object.defineProperty(target, key, descriptor);
}
}这也是很多库实现 mixin 时不会直接裸用 Object.assign 的原因。
9.8.2 Mixin 的冲突顺序
多个 mixin 有同名属性时,后面的覆盖前面的。
javascript
const A = {
init() {
console.log("A init");
}
};
const B = {
init() {
console.log("B init");
}
};
class Service {}
Object.assign(Service.prototype, A, B);
new Service().init(); // B init这类覆盖是静默发生的,不会报错。真实项目里应该主动检测:
javascript
function mixin(target, ...sources) {
for (const source of sources) {
for (const key of Reflect.ownKeys(source)) {
if (key in target) {
throw new Error(`Mixin conflict: ${String(key)}`);
}
Object.defineProperty(
target,
key,
Object.getOwnPropertyDescriptor(source, key)
);
}
}
}9.8.3 class Mixin 为什么是“类工厂”
ES6 class 的 mixin 常写成高阶函数:
javascript
const WithCache = Base => class extends Base {
getCache(key) {
return this.cache?.get(key);
}
setCache(key, value) {
this.cache ??= new Map();
this.cache.set(key, value);
}
};
const WithLogger = Base => class extends Base {
log(message) {
console.log(`[${this.constructor.name}] ${message}`);
}
};
class BaseService {}
class UserService extends WithLogger(WithCache(BaseService)) {}它构造出来的是多层单继承:
text
UserService
-> WithLogger 返回的匿名子类
-> WithCache 返回的匿名子类
-> BaseService所以 class Mixin 的本质是“连续创建增强子类”,不是让一个类同时拥有多个父类。
9.8.4 Mixin 和组合函数的取舍
现代 JavaScript 项目中,很多场景可以不用 Mixin,而是用组合函数。
javascript
function createLogger(scope) {
return {
log(message) {
console.log(`[${scope}] ${message}`);
}
};
}
function createUserService({ logger }) {
return {
async findUser(id) {
logger.log(`find user ${id}`);
return { id };
}
};
}
const userService = createUserService({
logger: createLogger("UserService")
});组合函数的优势:
- 依赖显式,通过参数传入。
- 不修改原型,影响范围小。
- 更容易测试。
- 更容易被 TypeScript 推导。
Mixin 的优势:
- 很适合给 class 体系批量添加稳定能力。
- 对老项目、插件系统、框架内部扩展很常见。
- 对实例方法调用形式更自然,例如
this.log()。
判断标准:
text
如果你需要“对象具备某种能力”,可以考虑 Mixin。
如果你只是想复用一段逻辑,优先使用函数组合。
如果你需要共享状态,优先使用显式依赖注入或状态管理。十、Map、Set、WeakMap、WeakSet 深度解析
10.1 Map 的真实价值:键身份与插入顺序
Map 的 key 不会被字符串化。对象 key 按引用身份判断,适合缓存、图结构、编译器、权限映射、插件容器等场景。
javascript
const astNode = { type: "Identifier", name: "count" };
const scopeInfo = new Map();
scopeInfo.set(astNode, {
declared: true,
references: 3,
});
console.log(scopeInfo.get(astNode));常见工程例子:请求去重。
javascript
const pendingRequests = new Map();
function requestOnce(key, requestFn) {
if (pendingRequests.has(key)) {
return pendingRequests.get(key);
}
const promise = requestFn().finally(() => {
pendingRequests.delete(key);
});
pendingRequests.set(key, promise);
return promise;
}这里 Map 比对象更直观,因为它表达的就是“key -> Promise”的动态映射。
10.2 Map 与内存泄漏
Map 会强引用 key 和 value。只要 Map 还在,key/value 就不会被释放。
javascript
const cache = new Map();
function mount(el) {
cache.set(el, {
createdAt: Date.now(),
data: new Array(100000).fill("x"),
});
}
function unmount(el) {
el.remove();
// 如果忘记 cache.delete(el),el 及其数据仍可能被 cache 引用
}如果 key 是对象且生命周期由外部控制,优先考虑 WeakMap。
javascript
const meta = new WeakMap();
function mount(el) {
meta.set(el, { createdAt: Date.now() });
}10.3 Set 的真实价值:成员关系
Set 不只是“数组去重”。它更重要的语义是成员关系。
javascript
const selectedIds = new Set();
function toggle(id) {
if (selectedIds.has(id)) {
selectedIds.delete(id);
} else {
selectedIds.add(id);
}
}
function isSelected(id) {
return selectedIds.has(id);
}如果用数组,每次判断都要 includes,复杂度是 O(n)。Set.has 更适合频繁成员判断。
10.4 WeakMap/WeakSet 的限制是设计目的
WeakMap 和 WeakSet 不能遍历、不能获取 size,这不是能力缺失,而是因为它们不应该让业务代码观察垃圾回收结果。
适合:
- 对象元数据。
- 私有状态。
- 已处理对象标记。
- DOM 节点关联状态。
不适合:
- 需要遍历所有 key。
- 需要统计数量。
- key 是字符串或数字。
10.5 四者选择表
| 需求 | 推荐 |
|---|---|
| 普通结构化数据 | Object |
| 字符串 key 的 JSON 数据 | Object |
| 动态键值表 | Map |
| 对象作为 key | Map 或 WeakMap |
| 不重复值集合 | Set |
| 频繁判断是否存在 | Set |
| 对象元数据且避免泄漏 | WeakMap |
| 标记对象是否处理过且避免泄漏 | WeakSet |
十一、事件循环、微任务与渲染时机深度解析
11.1 “宏任务/微任务”只是学习模型,不是全部规范术语
前端学习中常说“宏任务”和“微任务”,这个模型很有用,但更准确地说,浏览器规范里有 task、microtask、event loop、rendering opportunity 等概念。面试和工程沟通中可以继续使用宏任务/微任务,但要知道它是一个简化模型。
11.2 script 本身就是一个任务
javascript
console.log("script start");
setTimeout(() => console.log("timeout"));
Promise.resolve().then(() => console.log("promise"));
console.log("script end");整段 script 是第一个任务。它执行完后,才会清空微任务,然后再进入下一个任务。
text
当前任务:script
输出 script start
注册 timeout 任务
注册 promise 微任务
输出 script end
微任务检查点:
输出 promise
下一个任务:
输出 timeout11.3 微任务为什么会影响渲染
浏览器通常会在一个任务结束并清空微任务后,才有机会渲染。如果微任务链太长,页面会迟迟不渲染。
javascript
button.addEventListener("click", () => {
button.textContent = "Loading...";
let i = 0;
function run() {
if (i++ < 100000) {
queueMicrotask(run);
}
}
run();
});点击后,文字更新可能不能及时显示,因为微任务一直占用渲染前的检查点。
更合理的分片:
javascript
button.addEventListener("click", () => {
button.textContent = "Loading...";
let i = 0;
function runChunk() {
const end = Math.min(i + 1000, 100000);
while (i < end) {
i += 1;
}
if (i < 100000) {
setTimeout(runChunk, 0);
}
}
runChunk();
});这里用任务切片,让浏览器在任务之间有机会响应输入和渲染。
11.4 Promise 链的顺序
javascript
Promise.resolve()
.then(() => {
console.log("A");
return Promise.resolve();
})
.then(() => {
console.log("B");
});
Promise.resolve()
.then(() => {
console.log("C");
})
.then(() => {
console.log("D");
});理解方式:每个 .then 回调都是一个微任务;回调执行后,后续 .then 才会被加入微任务队列。
常见输出:
text
A
C
D
B原因:
- 第一条链注册
A微任务。 - 第二条链注册
C微任务。 - 执行
A,返回一个 fulfilled Promise,后续B的调度会经历 Promise 解析过程。 - 执行
C,注册D。 D先于B执行。
不同示例中一旦涉及返回 thenable、嵌套 Promise、await,顺序会更细。不要机械背答案,要按“当前微任务执行后又注册了什么”推导。
11.5 async/await 的错误处理与并发陷阱
错误处理:
javascript
async function loadUser() {
try {
const response = await fetch("/api/user");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("load failed", error);
throw error;
}
}串行陷阱:
javascript
// 慢:第二个请求必须等第一个完成
const user = await fetchUser();
const posts = await fetchPosts();并发写法:
javascript
const [user, posts] = await Promise.all([
fetchUser(),
fetchPosts(),
]);需要“尽量都拿到结果”:
javascript
const results = await Promise.allSettled([
fetchUser(),
fetchPosts(),
fetchNotifications(),
]);
const fulfilled = results
.filter(result => result.status === "fulfilled")
.map(result => result.value);11.6 Node.js 事件循环补充
Node.js 的事件循环常被分为 timers、pending callbacks、idle/prepare、poll、check、close callbacks 等阶段。日常开发不用死记阶段,但要掌握几个高频结论:
setTimeout(fn, 0)进入 timers 阶段,不代表“立即执行”。setImmediate通常在 check 阶段执行。- I/O 回调内部,
setImmediate往往比setTimeout(..., 0)更可预测。 process.nextTick队列会在当前操作结束后优先清空,过度递归会阻塞事件循环。
javascript
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
console.log("sync");常见输出中,sync 最先,nextTick 和 Promise 微任务在定时器/Immediate 前执行;timeout 和 immediate 的相对顺序在主模块中可能受环境影响。
11.7 面试推导模板
遇到事件循环题,按下面步骤做:
- 标出所有同步代码,先执行同步输出。
- 遇到
setTimeout、事件、I/O,放入任务队列。 - 遇到
Promise.then、queueMicrotask,放入微任务队列。 - 遇到
async函数,执行到第一个await前;await后面作为后续微任务理解。 - 同步代码结束后清空微任务。
- 再取下一个任务;每个任务结束后都要清空微任务。
十二、专题练习:必须能手推输出
12.1 原型链输出题
javascript
function A() {}
A.prototype.x = 1;
const a = new A();
A.prototype = { x: 2 };
const b = new A();
console.log(a.x);
console.log(b.x);答案:
text
1
2原因:a 创建时已经链接到旧的 A.prototype;后来给 A.prototype 重新赋值,只影响之后创建的实例。
12.2 Map/Set 输出题
javascript
const map = new Map();
map.set({}, "A");
map.set({}, "B");
map.set(NaN, "C");
map.set(NaN, "D");
console.log(map.size);
console.log(map.get(NaN));答案:
text
3
D两个 {} 是不同引用;两个 NaN 作为 Map key 视为同一个键。
12.3 事件循环输出题
javascript
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve()
.then(() => {
console.log("3");
queueMicrotask(() => console.log("4"));
})
.then(() => console.log("5"));
console.log("6");答案:
text
1
6
3
4
5
2推导:3 的 then 先执行,执行过程中注册 4 微任务;第一个 then 结束后,第二个 then 的 5 也被注册。队列按注册顺序执行,因此 4 在 5 前。
12.4 async/await 顺序题
javascript
async function a() {
console.log("a1");
await b();
console.log("a2");
}
async function b() {
console.log("b1");
}
console.log("start");
a();
console.log("end");答案:
text
start
a1
b1
end
a2推导:调用 a() 后同步执行到第一个 await,输出 a1;await b() 会先立即执行 b(),输出 b1,然后 a 暂停;控制权交回调用方,输出 end;同步代码结束后,微任务恢复 a,输出 a2。
12.5 Promise 与 setTimeout 混合题
javascript
setTimeout(() => console.log("timeout"), 0);
Promise.resolve()
.then(() => {
console.log("p1");
setTimeout(() => console.log("timeout2"), 0);
})
.then(() => console.log("p2"));
console.log("sync");答案:
text
sync
p1
p2
timeout
timeout2推导:同步输出 sync;微任务队列先清空:执行第一个 then 输出 p1,并把 timeout2 塞进宏任务队列,随后第二个 then 输出 p2;微任务清空后,按注册顺序执行宏任务:先 timeout,再 timeout2。