首页  |  学习总览  |  ← 上一章:HTML 与 CSS 阶段二 · 第 2 章,共 10 章 · 深度版
阶段二 · 预计 120 小时 · 最重要的章节 · 深度版

JavaScript 核心 —— 网页的灵魂

这一版不讲「是什么」,重点讲「为什么」:每个知识点都从引擎视角拆解原理——执行上下文怎么创建、变量到底存在哪、this 是怎么绑定的、事件循环每一步在干什么、Promise 状态机如何流转。所有手写代码都可直接运行验证。学透本章,框架源码只是「JS 特性的组合应用」。

1执行上下文与作用域:JS 代码到底怎么跑起来的

不搞懂这一节,后面所有概念(提升、闭包、this、事件循环)都是在背结论。

1.1 JS 引擎执行代码的两个阶段

很多人以为 JS 是「一行一行往下执行」的。不准确。每进入一个可执行环境(script / 函数 / eval),引擎都会先做一次「预扫描」,再逐行执行

阶段引擎做什么对应的现象
① 创建阶段
(编译/预扫描)
1. 创建执行上下文对象
2. 确定作用域链(外层词法环境的引用)
3. 确定 this 指向
4. 扫描全部声明:函数声明整体提升;var 声明登记进变量环境并初始化为 undefined;let/const 登记进词法环境但不初始化
「变量提升」「函数可以先调用后声明」「TDZ 报错」
② 执行阶段逐行执行,变量在环境中查找、赋值,函数调用时压入新的执行上下文正常逻辑、闭包引用、调用栈溢出
全局执行上下文 GEC
变量环境: { a: undefined, fn: <function> }
词法环境: { }
this: window
──调用 fn()──▶ 函数执行上下文 FEC
变量环境: { b: undefined }
词法环境: { x: <未初始化> }
this: 由调用方式决定
调用栈(Call Stack):函数被调用就压栈,执行完出栈。栈满 = RangeError: Maximum call stack size exceeded(递归没写终止条件时见过吧?就是它)
console.log(a);      // undefined(不是报错!创建阶段 a 已被登记并初始化为 undefined)
console.log(fn);     // [Function: fn](函数声明整体提升,连函数体一起)
var a = 1;
function fn() {}
console.log(x);      // ❌ ReferenceError(let 在词法环境里但未初始化 → 落入暂时性死区)
let x = 2;
💡 一句话本质 「提升」不是代码被移动了,而是创建阶段的登记行为。var 登记时顺手初始化为 undefined,let/const 登记但「不开锁」——开锁之前的区域就是暂时性死区(TDZ)。

1.2 作用域与作用域链:查找变量的完整路径

作用域 = 变量的可访问范围。JS 用的是「词法作用域」(Lexical Scope):由你书写代码的位置决定,编译期就确定了,跟函数在哪调用无关。

let x = "global";
function outer() {
  let y = "outer";
  function inner() {
    let z = "inner";
    console.log(x, y, z);   // 查找链:inner 环境 → outer 环境 → 全局环境
  }
  inner();
}
outer();
inner 词法环境
{ z: "inner" }
[[outer]] ──▶
outer 词法环境
{ y: "outer" }
[[outer]] ──▶
全局词法环境
{ x: "global" }
每个词法环境都持有对外层环境的引用([[outer]]),串起来就是作用域链。查找变量 = 沿这条链逐层往上找,找到即停,找不到就 ReferenceError

反例证明「词法」与「调用位置无关」

let v = 1;
function foo() { console.log(v); }   // 定义在全局,作用域链永远指向全局
function bar() {
  let v = 2;
  foo();       // 输出 1,不是 2!foo 的作用域链由「写在哪」决定,不是「在哪被调用」
}
bar();
⚠️ 面试深挖点:作用域链 vs 原型链 作用域链找的是变量(标识符查找,`console.log(name)`),原型链找的是对象的属性(`obj.name`)。两者都会逐层向上、找到即停,但完全是两套机制,别混。

1.3 var / let / const:决策树 + 循环里的经典陷阱

规则表格大家都会背,这里给「能解释清楚」的版本:

// 陷阱原理:var i 是函数级作用域,整个循环共享同一个 i
// setTimeout 的回调进宏任务队列时,循环早已跑完(i 已经是 3)
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i)); // 3 3 3
}

// let j:每次循环迭代都会创建一个「新的词法环境」并把上一轮的 j 拷贝进去
// 相当于引擎在每轮循环体外包了一层作用域(for 循环的 let 有专门规范处理)
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j)); // 0 1 2
}

// var 想修:用 IIFE 把当时的 i 拷贝捕获进函数作用域(ES5 时代的解法,现在用 let)
for (var k = 0; k < 3; k++) {
  (function (saved) {
    setTimeout(() => console.log(saved)); // 0 1 2
  })(k);
}
✅ 工程决策树 默认写 const → 确认需要重新赋值才改 let(循环变量、累加器、状态标记)→ 永不主动写 var(只在维护老代码时见到)。const 限制的是「变量绑定不可变」,不是「值不可变」:const arr=[]; arr.push(1) 合法,arr=[] 报错。
2数据类型与内存模型:赋值、比较、拷贝一切行为的总开关

八种类型(7 原始 + 1 引用),但真正决定你代码行为的是它们「存在哪」。

2.1 栈与堆:一张图讲清

栈(Stack)
原始类型的「值」
+ 引用类型的「地址」

let a = 10 → [ 10 ]
let o = {x:1} → [ 0xA1 ]─┐
let o2 = o → [ 0xA1 ]─┤
───────────────▶ 堆(Heap)
引用类型的「对象本体」

0xA1: { x: 1 } ◀──┤
(o、o2 改的都是这一个)
赋值原始类型 = 复制值;赋值引用类型 = 复制地址(两个变量指向同一个对象)
let o = { name: "张三", tags: ["a"] };
let p = o;                  // p 和 o 持有同一个地址
p.name = "李四";             // o.name 也是 "李四"(同一个对象)

// 浅拷贝:只拷贝第一层,嵌套的 tags 数组还是同一个地址
let s = { ...o };
s.name = "王五";             // ✅ o.name 不受影响(第一层是真拷贝)
s.tags.push("b");            // ❌ o.tags 也变成 ["a","b"](第二层是共享的)
🚨 原始类型「不可变」是什么意思? let s="ab"; s[0]="c" 不会报错但也不生效(严格模式下赋值静默失败)——字符串的每个字符无法原地修改,所有字符串方法(slice/replace/toUpperCase)都返回新字符串。这跟数组方法要分清:sort/splice/push 是原地修改,slice/map/filter/concat 返回新数组。React 里强调「不可变数据」的理论根基就在这。

2.2 == 的完整转换规则(不是背题,是看懂算法)

== 比较前,若两边类型不同,按规范走 ToPrimitive 抽象操作

  1. 对象 → 先调 valueOf(),若还是对象再调 toString(),得到原始值
  2. null == undefined → true(且它们各自只与对方和自身相等,不等于 0/false/"")
  3. number vs string → 字符串转数字
  4. boolean 参与比较 → boolean 先转数字(true→1, false→0)
  5. 对象 vs 原始值 → 对象走第 1 条转原始值
[] == false   // [] → "" → 0;false → 0 → true(两大坑王合体)
[] == ![]    // ![] 是 false([] 是真值)→ 变成 [] == false → true(面试名题)
"0" == false // "0"→0, false→0 → true
null == 0    // false!null 只和 undefined 相等
NaN == NaN   // false!NaN 与任何值都不等(包括自己)→ 判断用 Number.isNaN

// ToPrimitive 实战:自己控制转换行为
const price = {
  valueOf() { return 100; },   // 优先被调用
  toString() { return "100元"; }
};
price * 2      // 200(算术场景走 valueOf)
`${price}`     // "100元"(字符串场景走 toString)
✅ 唯一例外 工程里 99% 场景用 ===。唯一可接受 == 的地方:if (x == null) 同时判断 null 和 undefined。

2.3 手写深拷贝:从 60 分写到 95 分

60 分版:递归 + 对象/数组

function deepClone(target) {
  if (typeof target !== "object" || target === null) return target;  // 原始值直接返回
  const result = Array.isArray(target) ? [] : {};
  for (const key in target) {
    if (Object.prototype.hasOwnProperty.call(target, key)) {
      result[key] = deepClone(target[key]);   // 递归拷贝每一层
    }
  }
  return result;
}

95 分版:处理循环引用 + 特殊对象 + 全类型

function deepClone(target, map = new WeakMap()) {
  // 1. 原始类型 & 函数直接返回(函数一般共享,没必要克隆)
  if (target === null || typeof target !== "object") return target;

  // 2. Date / RegExp / 原始包装对象,用各自的构造器重建
  if (target instanceof Date) return new Date(target);
  if (target instanceof RegExp) return new RegExp(target.source, target.flags);

  // 3. 循环引用:先登记「旧对象→新对象」,递归回来时直接复用,避免无限递归
  if (map.has(target)) return map.get(target);

  // 4. Map / Set 递归克隆内部成员
  if (target instanceof Map) {
    const m = new Map();
    map.set(target, m);
    target.forEach((v, k) => m.set(deepClone(k, map), deepClone(v, map)));
    return m;
  }
  if (target instanceof Set) {
    const s = new Set();
    map.set(target, s);
    target.forEach(v => s.add(deepClone(v, map)));
    return s;
  }

  // 5. 普通对象/数组:保留原型链,用 Reflect.ownKeys 连 Symbol 键也拷到
  const result = Array.isArray(target) ? [] : Object.create(Object.getPrototypeOf(target));
  map.set(target, result);          // 注意:递归前就要登记!
  Reflect.ownKeys(target).forEach(key => {
    result[key] = deepClone(target[key], map);
  });
  return result;
}

// 验证循环引用
const a = { name: "x" };
a.self = a;
const b = deepClone(a);
b.self === b        // true,且没有栈溢出
⚠️ 为什么用 WeakMap 而不是 Map? WeakMap 的键是「弱引用」——不阻止对象被垃圾回收。深拷贝这种临时缓存用 Map 会在大对象场景造成内存滞留。另外 structuredClone()(浏览器原生 API)已能处理循环引用和大部分类型,但不能克隆函数和 DOM——面试要能说出「原生 API + 它的局限」。
3函数与 this、闭包:JS 灵活性的来源,也是混乱的来源

this 不是「定义时」决定的,是「调用时」决定的——这一句能解开 90% 的 this 疑惑。

3.1 this 绑定四规则(按优先级从低到高)

优先级规则触发场景this 指向
1(最低)默认绑定独立函数调用 fn()非严格模式 window;严格模式 undefined
2隐式绑定对象方法 obj.fn()点号前面的那个对象(链式取最后一层)
3显式绑定fn.call(obj) / apply / bind指定的第一个参数
4(最高)new 绑定new fn()新创建的那个实例对象
例外箭头函数任何场景没有自己的 this,用「定义时外层作用域」的 this,call/bind 都改不动
// 规则2 隐式绑定 + 最经典的「丢失」陷阱
const obj = {
  name: "obj",
  say() { console.log(this.name); }
};
obj.say();              // "obj"(this = obj)
const f = obj.say;      // 只是取出函数本身,跟 obj 的联系被切断
f();                    // undefined(规则降级为默认绑定,this = window)

// 回调场景同理:setTimeout(obj.say, 0) 内部等价于拿到函数后 fn() 调用 → this 丢失
// 修复三连:setTimeout(obj.say.bind(obj), 0) / 箭头函数包裹 / 类字段 say = () => {}

// 规则4 new 的过程(下一节手写):new 会创建全新对象,this 指向它
// 规则3 显式绑定优先级低于 new:bind 过的函数依然可以被 new,this 以 new 为准

// 箭头函数:this 在「定义时」就锁定了外层作用域的 this
const timer = {
  seconds: 0,
  start() {
    setInterval(() => {
      this.seconds++;      // 箭头函数没有 this,往上找到 start 的 this(即 timer)
    }, 1000);
  }
};
💡 判断 this 的三步法(遇到任何题都这么走) ① 是箭头函数吗?→ 直接看外层;② 是 new 调用吗?→ 新实例;③ 前面有没有 . 或 call/apply/bind?→ 对应对象。都不是 → window / undefined。

3.2 手写 call / apply / bind(理解显式绑定的实现)

Function.prototype.myCall = function (thisArg, ...args) {
  // 1. 谁调用 myCall,this 就是哪个函数
  const fn = this;
  // 2. null/undefined 归一化为全局对象;原始值装箱成对象
  thisArg = thisArg === null || thisArg === undefined ? globalThis : Object(thisArg);
  // 3. 把函数临时挂成 thisArg 的属性 → 调用时就变成「对象.方法()」= 隐式绑定
  const key = Symbol("fn");
  thisArg[key] = fn;
  const result = thisArg[key](...args);
  // 4. 清理临时属性,避免污染
  delete thisArg[key];
  return result;
};
// apply 唯一区别:接收 args 数组 → thisArg[key](...argsArray)

Function.prototype.myBind = function (thisArg, ...preArgs) {
  const fn = this;
  // bind 的核心:返回一个「包了一层」的函数,调用时才执行,且支持继续传参(偏函数)
  const bound = function (...restArgs) {
    // new 优先级更高:如果被 new 调用,this 用新实例,忽略 thisArg
    if (this instanceof bound) {
      return fn.apply(this, [...preArgs, ...restArgs]);
    }
    return fn.apply(thisArg, [...preArgs, ...restArgs]);
  };
  // 维护原型链,让 new bound() 的实例 instanceof 原函数成立
  bound.prototype = Object.create(fn.prototype);
  return bound;
};
✅ 看懂了吗? call 的本质就是「借你的对象,当一次我的 this」:把函数临时挂到目标对象上再调用,用完删掉。Symbol 键是为了防止覆盖对象上同名属性。

3.3 闭包:定义、原理、工程应用、内存泄漏

定义(能说出口的版本):函数与其词法作用域的组合。函数哪怕在定义处之外被调用,依然能访问定义时的作用域变量。

原理:为什么外层函数都执行完了,变量还在?

正常流程:函数执行完 → 执行上下文出栈 → 局部变量等垃圾回收。但如果有内层函数引用了外层变量,引擎判定「这块词法环境还有人持有」,不会回收——这个被持有的环境就是闭包的实体。

function createCounter() {
  let count = 0;              // 本该随 createCounter 执行完而销毁
  return function () {
    count++;                  // 但被返回的内层函数持有引用 → 环境被保留 → 变量「活着」
    return count;
  };
}
const c1 = createCounter();
const c2 = createCounter();
c1(); c1(); c1();   // 1 2 3
c2();               // 1(两次调用生成两个独立的作用域环境,count 互不干扰)

工程中的四大真实应用

  1. 数据私有(模拟私有属性):count 从外部无法直接修改,只能通过暴露的方法——这是 class 私有字段 #count 出现前的标准做法
  2. 防抖/节流:timer 变量被闭包持有,跨多次调用保持状态(见 3.4)
  3. 柯里化/偏函数:预置部分参数(见 3.4)
  4. 模块模式:UMD/早期模块化的基石,暴露 API 隐藏实现

内存泄漏:闭包被滥用的经典场景

// ❌ 隐患代码:闭包持有了巨大的 data,而事件监听不解除 → data 永远释放不掉
function mount() {
  const data = new Array(1e6).fill("大对象");
  document.getElementById("btn").addEventListener("click", () => {
    console.log(data.length);
  });
}
// 修复:用完移除监听,或让回调不引用 data,或 data 置 null
⚠️ 面试标准问法「闭包会不会内存泄漏」 闭包本身不是泄漏——被持有的变量是「设计上要活的」。泄漏发生在「你不再需要它,但引用链还挂着」(忘记 removeEventListener、全局变量、被闭包无意捕获的大对象)。DevTools → Memory → 堆快照对比可以定位。

3.4 手写防抖 debounce 与节流 throttle(生产可用版)

一句话区分:防抖 = 只认最后一次(停止触发 n 秒后执行);节流 = 固定频率执行(n 秒内最多一次)。搜索框输入联想用防抖;滚动加载、按钮防连点用节流。

// 防抖:带「立即执行」和「取消」选项
function debounce(fn, delay = 300, { immediate = false } = {}) {
  let timer = null;                 // 闭包持有,跨调用保持
  function debounced(...args) {
    if (timer) clearTimeout(timer);
    if (immediate && !timer) fn.apply(this, args);   // 第一次触发立即执行
    timer = setTimeout(() => {
      timer = null;
      if (!immediate) fn.apply(this, args);
    }, delay);
  }
  debounced.cancel = () => { clearTimeout(timer); timer = null; };  // 组件卸载时调用
  return debounced;
}

// 节流:时间戳版(首次立即执行)+ 定时器版(停止后再补一次)的混合
function throttle(fn, interval = 300) {
  let last = 0, timer = null;
  return function (...args) {
    const now = Date.now();
    const remaining = interval - (now - last);
    if (remaining <= 0) {             // 距上次执行已超过 interval → 直接执行
      clearTimeout(timer); timer = null;
      last = now;
      fn.apply(this, args);
    } else if (!timer) {              // 没到时间但保证「停止触发后最后一下也能执行」
      timer = setTimeout(() => {
        last = Date.now(); timer = null;
        fn.apply(this, args);
      }, remaining);
    }
  };
}
💡 防抖节流必考点总结 ① 应用场景区分;② 立即执行版怎么写;③ this 和 event 参数怎么透传(apply + ...args);④ React 里的坑:函数组件每次渲染都产生新函数,普通 debounce 包不住,要用 useMemo/useRef/useCallback 持久化同一个实例。
4原型与继承:没有 class 的语言如何做面向对象

class 是 ES6 的语法糖,底下全是原型机制——读懂这节才能读懂 Vue/React 源码里大量的 prototype 操作。

4.1 三角关系:prototype、__proto__、constructor

构造函数 Foo
(函数本身)
  Foo.prototype ─────────▶ Foo.prototype(原型对象)
constructor ──▶ 指回 Foo
共享方法放这里

                                  ▲          ▲
                                   __proto__    __proto__
                            ┌────────────┴───┐
new Foo() 实例
__proto__ → Foo.prototype
另一个实例
共享同一个 prototype
每个函数都有 prototype 属性(指向原型对象);每个对象都有 __proto__(指向它的原型);原型对象的 constructor 指回构造函数。__proto__ 是规范内 [[Prototype]] 的历史访问器,生产代码用 Object.getPrototypeOf()
function Person(name) { this.name = name; }
Person.prototype.say = function () { console.log(`我是 ${this.name}`); };

const p1 = new Person("张三");
const p2 = new Person("李四");
p1.say();                          // 自己身上没有 say → 沿 __proto__ 找到 Person.prototype.say
p1.say === p2.say                  // true:方法只有一份,两个实例共享(这正是原型「省内存」的意义)
Object.getPrototypeOf(p1) === Person.prototype   // true
Person.prototype.constructor === Person          // true(三角闭合)

// 原型链的终点
Object.getPrototypeOf(Person.prototype) === Object.prototype   // true
Object.getPrototypeOf(Object.prototype) === null               // true!链到头了

// 检测:in 遍历含原型链;hasOwnProperty 只看自身
"name" in p1;            // true(自身)
"say" in p1;             // true(原型链上)
p1.hasOwnProperty("say");// false
⚠️ 两个必考的「奇怪现象」Person.__proto__ === Function.prototype(函数也是对象,由 Function 构造);② Function.__proto__ === Function.prototype(Function 自己构造自己,语言设计上的自举环)。还有 Object.__proto__ === Function.prototype——能推导出来就说明真懂了。

4.2 new 的完整过程 + 手写

执行 new Person("张三") 时引擎做了四件事:

  1. 创建一个全新的空对象,其 __proto__ 指向 Person.prototype
  2. 将构造函数的 this 绑定到这个新对象并执行(所以函数体里的 this.xxx 都挂到新对象上)
  3. 如果构造函数显式返回了对象,则用那个对象(否则忽略返回值)
  4. 返回新对象(显式返回原始值会被忽略)
function myNew(Ctor, ...args) {
  // 步骤1:创建对象并挂原型(两种等价写法)
  const obj = Object.create(Ctor.prototype);
  // 步骤2:绑定 this 执行
  const result = Ctor.apply(obj, args);
  // 步骤3:构造函数返回了对象就用它,否则用 obj
  return (result !== null && typeof result === "object") || typeof result === "function"
    ? result : obj;
}

// 验证
const p = myNew(Person, "王五");
p instanceof Person;   // true
p.say();               // 我是 王五

手写 instanceof(原型链遍历)

function myInstanceof(obj, Ctor) {
  if (obj === null || typeof obj !== "object") return false;  // 原始值直接 false
  let proto = Object.getPrototypeOf(obj);
  while (proto !== null) {
    if (proto === Ctor.prototype) return true;   // 链上每一层比对
    proto = Object.getPrototypeOf(proto);        // 沿链上移
  }
  return false;                                  // 走到 null 还没找到
}

4.3 继承演进史:每一步都在解决上一步的问题

方案思路致命缺陷
① 原型链继承Child.prototype = new Parent()引用类型的属性被所有实例共享(改一个全变);创建子实例时没法给父类传参
② 借用构造函数Child 里 Parent.call(this)只能继承实例属性,父类原型上的方法拿不到;方法只能定义在构造函数里无法复用
③ 组合继承(经典)①+② 结合父类构造函数被调用两次(call 一次 + new Parent() 一次),实例上有一份冗余的父类属性
④ 寄生组合式(最优解)call 继承实例属性 + Object.create(Parent.prototype) 继承原型——(ES6 class 的底层等价物)
// 寄生组合式:ES6 之前的最优继承
function inherit(Child, Parent) {
  Child.prototype = Object.create(Parent.prototype);  // 只继承原型,不调父构造函数
  Child.prototype.constructor = Child;                // 修正 constructor 指向
}
function Parent(name) { this.name = name; this.tags = []; }
Parent.prototype.say = function () { console.log(this.name); };
function Child(name, age) {
  Parent.call(this, name);      // 只继承实例属性,且能传参
  this.age = age;
}
inherit(Child, Parent);
Child.prototype.showAge = function () { console.log(this.age); };

const c = new Child("张三", 18);
c.say();      // 张三(原型链继承到)
c.showAge();  // 18

// ES6 class 本质等价于上面这套 + 语法糖差异
class Parent2 { constructor(name) { this.name = name; } say() {} }
class Child2 extends Parent2 {
  constructor(name, age) { super(name); this.age = age; }   // super(name) ≈ Parent.call(this, name)
}
typeof Parent2;   // "function"!class 就是函数,方法挂在 Parent2.prototype 上
💡 class 与 function 构造的差异(面试加分项) ① class 必须 new 调用,直接调用报错;② class 内部默认严格模式;③ 方法不可枚举;④ class 声明不提升(有 TDZ);⑤ static 方法直接挂构造函数本身而非原型。Vue3 源码大量用 class 组织模块,这些细节都会遇到。
5异步与事件循环:单线程语言如何「并发」

JS 只有一个调用栈,但浏览器是多线程的——事件循环是两者之间的调度机制。

5.1 事件循环完整流程(一步步走)

  1. 执行同步代码(调用栈里的),期间产生的宏任务进宏任务队列、微任务进微任务队列
  2. 调用栈清空后,立刻清空整个微任务队列(全部执行完,期间新产生的微任务也会在本轮执行)
  3. 浏览器视情况执行渲染(约 16.6ms 一帧,受 requestAnimationFrame 节流)
  4. 取出一个宏任务执行 → 又回到第 2 步(清微任务)→ 渲染 → 下一个宏任务……循环往复
调用栈(同步代码) ──清空──▶ 微任务队列
Promise.then / queueMicrotask /
MutationObserver(清空全部)
──清空──▶ 渲染(可选) ──▶ 宏任务队列
setTimeout / setInterval /
setImmediate(Node) / I/O / UI 事件
(每次只取一个)
核心记忆点:每执行完一个宏任务(含脚本本身),都要先把微任务队列彻底清空,才进入下一个宏任务
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
// 同步(1,6) → 清微任务(3,4) → 取宏任务(2) → 清微任务(无) → 取宏任务(5)
⚠️ 两个高频深挖点setTimeout(fn, 0) 不是 0ms 执行,浏览器最小延迟约 4ms(嵌套调用还会更长),它只保证「至少延迟指定时间」;② await 后面的代码 = .then 回调 = 微任务——await 会先让出主线程,本轮同步代码先跑完。

5.2 Promise 原理:状态机 + then 链

Promise 本质是一个状态机:pending →(resolve)→ fulfilled 或 pending →(reject)→ rejected。状态一旦变更不可逆,之后所有的 resolve/reject 调用都是无效操作(这就是「值只会被决定一次」)。

// 简版手写:只处理核心流程(面试能写出来已超过大多数人)
class MyPromise {
  constructor(executor) {
    this.state = "pending";
    this.value = undefined;
    this.callbacks = [];                       // pending 时先存起来
    const resolve = (value) => {
      if (this.state !== "pending") return;    // 状态不可逆
      this.state = "fulfilled";
      this.value = value;
      this.callbacks.forEach(cb => cb.onFulfilled(value));  // 异步执行(微任务)
    };
    const reject = (reason) => {
      if (this.state !== "pending") return;
      this.state = "rejected";
      this.value = reason;
      this.callbacks.forEach(cb => cb.onRejected(reason));
    };
    try { executor(resolve, reject); }         // executor 同步立即执行!
    catch (e) { reject(e); }
  }
  then(onFulfilled, onRejected) {
    return new MyPromise((resolve, reject) => {  // then 返回新 Promise → 链式调用的根基
      const handle = (fn, fallback) => {
        queueMicrotask(() => {                  // then 回调必须是异步的(微任务)
          try {
            const result = (fn || fallback)(this.value);
            result instanceof MyPromise ? result.then(resolve, reject) : resolve(result);
          } catch (e) { reject(e); }            // 回调抛错 → 自动冒泡给下一个 catch
        });
      };
      if (this.state === "pending") this.callbacks.push({
        onFulfilled: () => handle(onFulfilled),
        onRejected: () => handle(onRejected)
      });
      else if (this.state === "fulfilled") handle(onFulfilled);
      else handle(onRejected);
    });
  }
}
💡 then 链的三个穿透规则(看懂源码级) ① then 返回普通值 → 传给下一个 then;② 返回 Promise → 「展开」,等它落定后结果继续往下传;③ 抛异常 / 返回 rejected Promise → 跳过中间所有 then,直达最近的 catch,catch 之后链「复位」为 fulfilled 继续往下走。

手写 Promise.all / allSettled / race / any

function myAll(promises) {
  return new Promise((resolve, reject) => {
    const results = []; let count = 0;
    if (promises.length === 0) return resolve([]);
    promises.forEach((p, i) => {
      Promise.resolve(p).then(v => {         // Promise.resolve 包一层:兼容普通值
        results[i] = v;                        // 用下标保证顺序,不是完成顺序
        if (++count === promises.length) resolve(results);
      }, reject);                              // 一败全败,直接 reject
    });
  });
}
// allSettled:全部落定,永不 reject,结果带 status 字段
// race:第一个落定的(无论成败)决定结果
// any:第一个成功的决定结果;全失败才 reject(AggregateError)

5.3 async/await 的本质

async/await 是「Generator + 自动执行器」的语法糖。核心认知:

  • async 函数返回的一定是 Promise(return 的值会被 Promise.resolve 包装)
  • await 右边的表达式先同步求值;若等到的是 Promise,则「让出主线程」注册后续逻辑为微任务;等普通值也照样让出(await 本身就有一次微任务切换)
  • try/catch 能直接捕获 await 的异常——这是它优于 .then 链的地方(同步写法处理异步错误)
// 等价转换:理解了这个,await 的执行顺序题全会
async function foo() {
  console.log("a");
  const v = await bar();     // bar() 同步执行;await 处让出主线程
  console.log("c");          // ≈ .then(() => { console.log("c") }) → 微任务
}
// 近似等价于:
function foo() {
  console.log("a");
  return Promise.resolve(bar()).then(v => { console.log("c"); });
}

// 并发陷阱:串行 vs 并行(真实项目高频 bug)
// ❌ 串行:总耗时 = 1s + 2s + 3s = 6s
const a1 = await fetchA();   // 1s
const b1 = await fetchB();   // 2s
const c1 = await fetchC();   // 3s
// ✅ 并行:总耗时 = max(1,2,3) = 3s —— 先发请求再 await
const pa = fetchA(), pb = fetchB(), pc = fetchC();
const [a2, b2, c2] = await Promise.all([pa, pb, pc]);
🚨 async 错误处理的三个正确姿势 ① 每个 await 单独 try/catch(精确);② 整体包 try/catch(简单);③ await p.catch(e => fallback)(给默认值)。注意:async 函数内部的错误不会让 Node 进程崩溃,但会变成 rejected Promise——忘了 catch 就是 unhandledrejection。
6ES6+ 现代特性:读懂框架源码的前置语言

Vue3 响应式用 Proxy、迭代器协议贯穿所有集合类型、解构默认值是 Hooks 参数的标配——这节每个特性都对应框架里的真实用法。

6.1 Proxy / Reflect:Vue3 响应式的地基

Proxy 在「对象外面套一层拦截器」,13 种陷阱(trap)可拦截几乎一切操作;Reflect 提供与陷阱一一对应的「默认行为」调用,两者是天作之合。

const raw = { price: 100, qty: 2 };
const reactive = new Proxy(raw, {
  get(target, key, receiver) {
    console.log(`读取 ${String(key)}`);                       // 依赖收集的入口
    const result = Reflect.get(target, key, receiver);        // 用 Reflect 执行默认行为
    return typeof result === "object" && result !== null ? reactive(result) : result;
    // 递归代理嵌套对象 = Vue3 「惰性深层响应式」的雏形(Vue2 是初始化时递归 defineProperty,一竿子到底)
  },
  set(target, key, value, receiver) {
    console.log(`写入 ${String(key)} = ${value}`);            // 触发更新的入口
    return Reflect.set(target, key, value, receiver);         // 必须返回 true,否则严格模式下报错
  }
});
reactive.price;        // 读取 price → 100
reactive.qty = 5;      // 写入 qty = 5
对比项Object.defineProperty(Vue2)Proxy(Vue3)
拦截能力只有 get/set,新增/删除属性拦不住(所以 Vue2 要 $set/$delete)13 种陷阱:含 deleteProperty、has、ownKeys 等
数组支持差(靠重写 7 个变异方法 hack)原生支持索引赋值和 length 修改
深层代理初始化时递归全部转化(性能开销前置)访问到才代理(惰性,大对象友好)
兼容性IE9+无法 polyfill(Vue3 放弃 IE 的根本原因)

6.2 Map/Set/WeakMap:集合类型的选择依据

// Map vs 普通对象:key 不限类型、顺序保证、size 直取、频繁增删性能更好
const m = new Map();
m.set({ id: 1 }, "用户对象做键");     // 对象做键:普通对象会 toString 成 "[object Object]" 冲突
m.set("count", 0);
[...m.keys()];                        // 保持插入顺序

// Set:去重 + 交并差集(比 filter+includes 快得多,O(1) 查找)
const union = new Set([...a, ...b]);                  // 并集
const intersect = a.filter(x => bSet.has(x));          // 交集

// WeakMap/WeakSet:键只能是对象、键是弱引用、不可遍历、没有 size
// 典型用途:给对象挂「私有数据」,对象销毁数据自动跟着回收(深拷贝的缓存也用它)
const privateData = new WeakMap();
class User {
  constructor(token) { privateData.set(this, { token }); }   // token 外部拿不到
}
💡 WeakMap 与 GC 的关系 弱引用 = 「不阻止垃圾回收」。当外部对一个对象的所有强引用都消失,WeakMap 里对应的条目会被自动清除——这就是「对象私有数据不泄漏」的原理。

6.3 迭代器协议与 Generator

可迭代对象(有 Symbol.iterator 方法、返回带 next() 的迭代器)就能被 for...of / 展开运算符 / 解构 / Promise.all 消费——Array、Map、Set、String、NodeList 都是;普通对象不是(所以不能 for...of)。

// 手写可迭代对象:让 for...of 遍历普通对象
const range = {
  from: 1, to: 5,
  [Symbol.iterator]() {
    let cur = this.from, last = this.to;
    return {
      next() {
        return cur <= last ? { value: cur++, done: false } : { value: undefined, done: true };
      }
    };
  }
};
[...range];   // [1,2,3,4,5]

// Generator:function* + yield,函数可暂停可恢复
function* gen() {
  const x = yield 1;      // 执行到 yield 暂停,把 1 交出去;next(v) 时 v 作为 yield 的返回值传回来
  const y = yield x * 2;
  return x + y;
}
const g = gen();
g.next();     // { value: 1, done: false }
g.next(10);   // { value: 20, done: false }(x = 10)
g.next(5);    // { value: 15, done: true }(y = 5)
✅ 为什么值得学 ① redux-saga 整个基于 Generator;② async/await 编译后的目标代码就是 Generator + 自动执行器;③ koa 的洋葱中间件(await next())也靠它。学会手动驱动 gen.next(),就读懂了这三样东西的核心。

6.4 每天都在用但容易用错的三个语法

// ① ?. 可选链 vs &&:?. 只跳过 null/undefined,0 和 "" 照常返回
const val = obj?.a?.b ?? "默认";    // ?? 只对 null/undefined 兜底
const val2 = obj && obj.a || "默认"; // ❌ 旧写法:a 为 0 或 "" 时会误兜底

// ② 解构默认值生效条件:值严格等于 undefined 才生效
function toast({ duration = 3000, title } = {}) {}   // 参数默认 {} 防止不传参报错
toast({ duration: 0 });    // duration = 0(✅ 正确保留,用 ?? 会坑)
toast({ duration: null }); // duration = null(null 不触发默认值!)

// ③ 解构重命名 + 嵌套默认值(读组件库源码必备)
const { size = "middle", onCancel: handleClose } = props;
const { theme: { primaryColor = "#1890ff" } = {} } = options;
7高频手写题总清单(本章实战作业)

全部亲手写一遍并跑通,比看十遍文章有用。前面标 ✅ 的本章已给完整实现,其余按同样深度自己补全。

题目核心考点难度章节
✅ 深拷贝(循环引用)WeakMap、递归、类型分派★★★2.3
✅ call / apply / bind隐式绑定原理、Symbol 防冲突★★★3.2
✅ 防抖 / 节流闭包持状态、this 透传、cancel★★★3.4
✅ new / instanceof原型链、Object.create★★4.2
✅ Promise 简版 / all状态机、微任务、then 链穿透★★★★5.2
柯里化 curry闭包、参数长度判断(fn.length)★★★本章作业
发布订阅 EventEmitterMap 存回调、once/off 边界★★★本章作业
数组去重 / 扁平化 flattenSet、reduce 递归、深度参数★★本章作业
寄生组合式继承原型链 + 借用构造★★★4.3 ✅
函数记忆 memoize闭包 + Map 缓存、参数序列化做键★★本章作业
sleep / 轮询 withTimeoutPromise + setTimeout 组合★★本章作业

📝 自测:能全部答对才算过关

1. 为什么 let x = x 报错而 var x = x 是 undefined?(TDZ 与提升差异)
2. [] == ![] 的完整推导过程?
3. 闭包变量为什么在函数返回后依然存活?什么情况下变成内存泄漏?
4. new 做了四件事,构造函数 return 对象 / return 原始值分别返回什么?
5. 微任务队列和宏任务队列的清空策略差异?await 后面的代码属于哪个?
6. Promise.then 链返回 Promise 时的「展开」规则是什么?
7. Proxy 相比 defineProperty 的三大优势?Vue3 为什么放弃 IE?

✅ 学习方法建议 每个手写题的路径:先默写 60 分版 → 跑通 → 对照 95 分版找差距 → 删掉重写。每周复盘一次错题。三个月后你写这些应该像写 console.log 一样自然——这是所有前端面试的地基。