Skip to content

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 声明的变量在初始化时被提升并被赋值为 undefined
  • let/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 那一行,才变为 2

1.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,查找终止

两个要点:

  1. 作用域在函数定义时确定(词法作用域),与调用位置无关。闭包能「记住」定义处环境,本质就是保存了这条 outer 链。
  2. 查找方向永远是从内到外,内层同名变量会**遮蔽(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 变量,于是:

  1. 不把 count 放在栈上,而是堆分配——因为栈帧销毁后只有堆上的内存才能保留下来
  2. 内部函数的作用域链指向堆上的 count
  3. 内部函数被返回后,其引用使堆上的 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 → 3

5.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;        // 不能变量路径

静态分析的意义:

  1. Tree-shaking:编译器可以在编译时确定哪些导出被使用了,哪些没有。未使用的导出可以被安全地移除。
  2. 确定性:模块依赖图在编译时确定,不需要等到运行时才知道哪些模块会加载。
  3. 循环依赖检测:静态分析可以在编译时检测循环依赖。
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/configurable

8.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、configurablegetter/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); // 200

Vue 2 的响应式核心就基于 Object.defineProperty 把字段改造成 getter/setter;Vue 3 改用 Proxy,可以拦截更多操作。

9.3 [[Get]]:属性读取的真实过程 ​

当执行 obj.key 时,并不是简单地查一个 Map。更接近下面的过程:

  1. 查找对象自身是否有属性 key。
  2. 如果是数据属性,返回 value。
  3. 如果是访问器属性,调用 getter,getter 中的 this 通常是接收者对象。
  4. 如果自身没有,沿着 [[Prototype]] 查找。
  5. 查到 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); // ALICE

getter 定义在 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)); // null

9.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.prototype

CanLog 和 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
对象作为 keyMap 或 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
下一个任务:
  输出 timeout

11.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

原因:

  1. 第一条链注册 A 微任务。
  2. 第二条链注册 C 微任务。
  3. 执行 A,返回一个 fulfilled Promise,后续 B 的调度会经历 Promise 解析过程。
  4. 执行 C,注册 D。
  5. 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 面试推导模板 ​

遇到事件循环题,按下面步骤做:

  1. 标出所有同步代码,先执行同步输出。
  2. 遇到 setTimeout、事件、I/O,放入任务队列。
  3. 遇到 Promise.then、queueMicrotask,放入微任务队列。
  4. 遇到 async 函数,执行到第一个 await 前;await 后面作为后续微任务理解。
  5. 同步代码结束后清空微任务。
  6. 再取下一个任务;每个任务结束后都要清空微任务。

十二、专题练习:必须能手推输出 ​

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。

基于 VitePress 构建的个人学习文档知识库