文章目录

JavaScript 深度掌握学习笔记

适合目标:6 小时内建立 JavaScript 核心知识框架,覆盖高频面试点、核心原理、手写题与记忆方法。
学习重点:闭包与作用域、原型与继承、异步编程、设计模式。
学习原则:先理解运行机制,再背结论;先会说,再会写;先会写 demo,再写手写题。


目录

  1. 学习总览
  2. 闭包与作用域
  3. 原型与继承
  4. 异步编程
  5. 设计模式
  6. 高频手写题清单
  7. 6 小时学习节奏建议
  8. 一页速记总结

1. 学习总览

1.1 这份笔记要解决什么问题

很多人学 JavaScript 容易出现三个问题:

  1. 名词很多,彼此关系混乱。
  2. 知道结论,但解释不清原理。
  3. 面试能背概念,写代码时却容易卡住。

这份笔记的目标就是把这些知识串起来,让你形成一条清晰主线:

代码执行 -> 作用域与上下文 -> 对象与原型 -> 异步调度 -> 设计抽象

也就是说,JavaScript 的学习顺序可以理解为:

  1. 代码是怎么被执行的
  2. 变量为什么能访问到
  3. 对象方法为什么能继承
  4. 异步任务为什么不是按书写顺序执行
  5. 如何把这些机制组织成更好的代码结构

1.2 核心记忆主线

主线 1:作用域解决“去哪找变量”

  • 词法作用域:定义时决定查找规则
  • 作用域链:一层层向外找变量
  • 闭包:函数带着外层变量一起存活

主线 2:原型解决“对象从哪继承能力”

  • prototype:构造函数的共享原型
  • __proto__:对象的隐式原型指针
  • 原型链:对象属性查找路径

主线 3:事件循环解决“异步任务什么时候执行”

  • 同步任务先执行
  • 微任务比宏任务优先
  • Promise.then 属于微任务
  • setTimeout 属于宏任务

主线 4:设计模式解决“代码如何组织得更好”

  • 单例:全局只要一个实例
  • 观察者:状态变化自动通知
  • 发布订阅:通过中间层解耦
  • 策略:把 if-else 变成可替换算法

2. 闭包与作用域

这一部分是 JavaScript 的底层基础。只要作用域理解清楚,闭包、this、变量提升、TDZ、执行上下文就都更容易串起来。

2.1 词法作用域

2.1.1 什么是作用域

作用域就是变量、函数、类等标识符的可访问范围。

JavaScript 中常见的作用域:

  1. 全局作用域
  2. 函数作用域
  3. 块级作用域

示例:

const globalName = "global";

function outer() {
  const outerName = "outer";

  if (true) {
    const innerName = "inner";
    console.log(globalName); // global
    console.log(outerName);  // outer
    console.log(innerName);  // inner
  }

  // console.log(innerName); // 报错,块级作用域外无法访问
}

2.1.2 什么是词法作用域

词法作用域指的是:函数写在哪里,它的作用域就决定了,而不是函数在哪里调用。

也就是说,变量查找规则在“定义时”确定,而不是在“运行时调用位置”确定。

示例:

const name = "global";

function foo() {
  console.log(name);
}

function bar() {
  const name = "bar";
  foo();
}

bar(); // global

原因:

  • foo 定义在全局作用域下
  • 所以它查找 name 时,先看自己内部
  • 找不到就去定义位置的外层,也就是全局
  • 不会因为它在 bar 里被调用,就去用 bar 的变量

2.1.3 动态作用域是什么

动态作用域是“调用位置决定变量查找”,JavaScript 不是动态作用域语言。

面试常问:

词法作用域与动态作用域的区别是什么?

标准答法:

  1. 词法作用域在函数定义时确定
  2. 动态作用域在函数调用时确定
  3. JavaScript 采用词法作用域

2.1.4 记忆口诀

作用域看定义,不看调用。


2.2 执行上下文

2.2.1 什么是执行上下文

执行上下文可以理解为“当前代码执行时所处的环境信息”。

每次代码执行,JavaScript 引擎都会创建对应的执行上下文。常见有三种:

  1. 全局执行上下文
  2. 函数执行上下文
  3. eval 执行上下文(实际开发很少用)

2.2.2 执行上下文里有什么

老版本面试常说 VO/AO,现代说法更准确的是“环境记录 + 外部词法环境引用 + this 绑定”等信息。

你可以这样记:

  1. 变量和函数声明放哪里
  2. 当前能访问哪些外部变量
  3. 当前 this 指向谁

面试里为了沟通方便,经常会说:

  • VO:变量对象(Variable Object)
  • AO:活动对象(Activation Object,函数上下文内)

虽然规范现在不这样叫,但面试场景仍然常见。

2.2.3 执行上下文创建阶段

可以粗略理解为三个步骤:

  1. 绑定 this
  2. 收集函数声明
  3. 收集变量声明

函数声明优先级高于变量声明。

示例:

console.log(a); // function a() {}

var a = 1;

function a() {}

console.log(a); // 1

原因:

  1. 创建阶段先处理函数声明,a 指向函数
  2. var a 只声明,不覆盖已有函数声明
  3. 执行阶段 a = 1 才真正赋值为数字

2.2.4 执行栈

执行上下文会按“后进先出”的方式进入调用栈。

示例:

function one() {
  two();
  console.log("one");
}

function two() {
  three();
  console.log("two");
}

function three() {
  console.log("three");
}

one();

执行顺序:

  1. 压入全局上下文
  2. 调用 one,压入 one
  3. one 调用 two,压入 two
  4. two 调用 three,压入 three
  5. three 执行完弹出
  6. two 执行完弹出
  7. one 执行完弹出

输出:

three
two
one

2.2.5 作用域链

当代码访问一个变量时,查找路径就是作用域链。

查找规则:

  1. 先找当前作用域
  2. 找不到就沿着外层词法环境继续找
  3. 一直找到全局
  4. 还找不到就是 ReferenceError

2.2.6 this 绑定

this 不是在定义时决定,而是多数情况下在调用时决定。

这是很多人把“作用域”和“this”混淆的根源。

常见规则:

  1. 默认绑定:普通函数独立调用,非严格模式指向全局对象,严格模式是 undefined
  2. 隐式绑定:对象调用,this 指向调用者对象
  3. 显式绑定:callapplybind
  4. new 绑定:指向新创建的实例
  5. 箭头函数:没有自己的 this,继承外层

示例:

const obj = {
  name: "obj",
  say() {
    console.log(this.name);
  }
};

obj.say(); // obj

const fn = obj.say;
fn(); // 非严格模式下可能是全局,严格模式下是 undefined

2.2.7 记忆口诀

作用域决定变量查找,this 决定函数执行时的拥有者。


2.3 闭包

2.3.1 什么是闭包

闭包本质上是:函数能够访问其词法作用域中的变量,即使该函数在定义作用域之外执行

更通俗一点:

函数 + 它能访问到的外部变量 = 闭包

示例:

function createCounter() {
  let count = 0;

  return function () {
    count += 1;
    return count;
  };
}

const counter = createCounter();

console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

这里 createCounter 执行结束后,本来局部变量 count 应该销毁,但返回的内部函数还在引用它,所以 count 继续存活。

2.3.2 为什么闭包能保存变量

因为内部函数仍然引用外部变量,只要这个引用存在,对应的词法环境就不会被垃圾回收。

你可以理解为:

  1. 外部函数创建变量
  2. 内部函数引用这些变量
  3. 内部函数被返回或保存到外部
  4. 外部函数虽然执行完,但变量仍被内部函数“抓住”

2.3.3 闭包的经典应用

1. 数据私有化
function createUser() {
  let password = "123456";

  return {
    check(value) {
      return value === password;
    },
    update(newPassword) {
      password = newPassword;
    }
  };
}
2. 函数柯里化
function add(a) {
  return function (b) {
    return a + b;
  };
}

const add10 = add(10);
console.log(add10(5)); // 15
3. 模块模式
const calculator = (function () {
  let total = 0;

  return {
    add(num) {
      total += num;
      return total;
    },
    getTotal() {
      return total;
    }
  };
})();
4. 记忆函数
function memoize(fn) {
  const cache = {};

  return function (...args) {
    const key = JSON.stringify(args);
    if (key in cache) return cache[key];
    const result = fn.apply(this, args);
    cache[key] = result;
    return result;
  };
}

2.3.4 闭包常见坑

坑 1:循环里的闭包
for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 0);
}

// 输出 3 3 3

原因:

  • var 没有块级作用域
  • 三个回调共享同一个 i
  • 等回调执行时,循环已经结束,i = 3

解决方式 1:用 let

for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 0);
}

// 输出 0 1 2

解决方式 2:用立即执行函数创建独立作用域

for (var i = 0; i < 3; i++) {
  (function (j) {
    setTimeout(() => {
      console.log(j);
    }, 0);
  })(i);
}
坑 2:闭包导致内存无法释放

如果闭包长期引用不再需要的大对象、DOM 节点、定时器,就可能导致内存泄漏或内存占用过高。

典型场景:

  1. 事件监听未移除
  2. 定时器未清除
  3. 闭包中引用大量数据
  4. 脱离文档的 DOM 仍被引用

示例:

function bindHandler() {
  const hugeData = new Array(100000).fill("data");

  function handler() {
    console.log(hugeData.length);
  }

  window.addEventListener("click", handler);

  return () => {
    window.removeEventListener("click", handler);
  };
}

2.3.5 如何避免闭包带来的内存问题

  1. 不要让闭包长期持有不必要的大对象
  2. 事件用完及时解绑
  3. 定时器用完及时清除
  4. 置空无用引用

2.3.6 面试回答模板

什么是闭包?

可以回答:

  1. 闭包是函数和其词法作用域引用的组合
  2. 当内部函数访问外部函数变量,并在外部函数执行结束后仍然存活,就形成了典型闭包
  3. 闭包常用于数据私有化、函数工厂、柯里化、模块模式
  4. 闭包如果使用不当,可能造成内存占用增加

2.3.7 记忆口诀

闭包不是特殊语法,而是函数记住了定义时的环境。


2.4 var、let、const

2.4.1 var 的特点

  1. 函数作用域
  2. 存在变量提升
  3. 可以重复声明
  4. 不受块级作用域限制
if (true) {
  var a = 1;
}

console.log(a); // 1

2.4.2 let 的特点

  1. 块级作用域
  2. 会提升,但不能在声明前访问
  3. 不允许同一作用域重复声明
  4. 存在暂时性死区 TDZ
{
  // console.log(a); // ReferenceError
  let a = 1;
}

2.4.3 const 的特点

  1. 块级作用域
  2. 存在 TDZ
  3. 声明时必须初始化
  4. 不能重新赋值

注意:const 保证的是“绑定不能变”,不是“值一定不可变”。

const obj = { count: 1 };
obj.count = 2; // 可以

// obj = {}; // 不可以

2.4.4 什么是暂时性死区 TDZ

从块级作用域开始,到变量正式声明之前,这段区域就是暂时性死区。

在 TDZ 内访问变量会直接报错,而不是得到 undefined

{
  // TDZ 开始
  // console.log(x); // ReferenceError
  let x = 10;
  // TDZ 结束
}

2.4.5 var 与 let 的核心区别

对比项 var let
作用域 函数作用域 块级作用域
变量提升 有,且可在声明前访问为 undefined 有,但声明前不可访问
重复声明 允许 不允许
是否有 TDZ 没有

2.4.6 提升示例

console.log(a); // undefined
var a = 1;

等价近似理解为:

var a;
console.log(a);
a = 1;

let 不是简单提升为 undefined,而是“已创建但不可访问”。

2.4.7 实战建议

  1. 默认优先使用 const
  2. 需要重新赋值时用 let
  3. 尽量不要再使用 var

2.4.8 记忆口诀

var 只有函数墙,let/const 还有块级墙;var 提前能看见,let 提前就报错。


2.5 本章高频面试题

题 1:解释词法作用域与动态作用域的区别

答:

  1. 词法作用域由函数定义位置决定
  2. 动态作用域由函数调用位置决定
  3. JavaScript 使用词法作用域

题 2:手写执行上下文创建过程

答题思路:

  1. 创建全局或函数执行上下文
  2. 确定 this
  3. 收集函数声明和变量声明
  4. 建立外部词法环境引用
  5. 代码开始逐行执行

题 3:闭包为什么不会销毁外部变量

答:

因为内部函数仍然引用外部变量,对应词法环境仍然被使用,所以垃圾回收器不会释放它。

题 4:var 与 let 的 TDZ 区别

答:

  1. var 存在变量提升,声明前访问得到 undefined
  2. let 也会提升,但在声明前处于暂时性死区,访问会报错

3. 原型与继承

JavaScript 的继承不是像 Java 那样基于类的传统继承,而是基于原型链实现的。class 只是语法糖,底层仍是原型。

3.1 原型、prototype、proto

3.1.1 先记住三个概念

  1. 每个函数都有一个 prototype 属性
  2. 每个对象都有一个内部原型指针,通常可通过 __proto__ 访问
  3. 对象查找属性时会沿着原型链往上找

3.1.2 prototype 是什么

prototype 是构造函数用来给实例共享属性和方法的对象。

function Person(name) {
  this.name = name;
}

Person.prototype.sayHi = function () {
  return `Hi, I am ${this.name}`;
};

const p1 = new Person("Tom");
const p2 = new Person("Jerry");

console.log(p1.sayHi === p2.sayHi); // true

说明:

  • 如果把方法写在构造函数内部,每个实例都会拷贝一份
  • 写到 prototype 上,所有实例共享同一个方法,更省内存

3.1.3 proto 是什么

__proto__ 是对象实例指向其原型对象的链接。

关系可以简单记成:

实例对象.__proto__ === 构造函数.prototype

示例:

function Person() {}

const p = new Person();

console.log(p.__proto__ === Person.prototype); // true

3.1.4 constructor 是什么

原型对象上默认有一个 constructor,指回构造函数。

function Person() {}

console.log(Person.prototype.constructor === Person); // true

3.1.5 一张关系图

Person ----prototype----> Person.prototype
   ^                          |
   |                          |
constructor               __proto__
   |                          |
   +----------- p ------------+

3.1.6 记忆口诀

函数有 prototype,对象有 __proto__,实例通过 __proto__ 连到构造函数的 prototype。


3.2 原型链查找机制

3.2.1 属性查找流程

当访问 obj.xxx 时,JavaScript 会这样找:

  1. 先看对象自身有没有 xxx
  2. 没有就找 obj.__proto__
  3. 继续沿原型向上找
  4. 找到 Object.prototype
  5. 还没有就返回 undefined

示例:

const obj = { a: 1 };

console.log(obj.toString); // 来自 Object.prototype

3.2.2 原型链的终点

大多数普通对象最终都会指向:

Object.prototype.__proto__ === null

所以原型链终点是 null

3.2.3 面试常问

原型链查找机制是什么?

答:

访问对象属性时,会先查找对象自身,若没有则沿着对象的隐式原型逐级向上查找,直到 null 为止,这个查找路径就是原型链。


3.3 new 操作符

3.3.1 new 的 4 个步骤

new 的核心流程可以背成四步:

  1. 创建一个新对象
  2. 把新对象的原型指向构造函数的 prototype
  3. 用新对象作为 this 执行构造函数
  4. 如果构造函数返回引用类型,就返回该引用;否则返回新对象

示例:

function Person(name) {
  this.name = name;
}

const p = new Person("Alice");

内部近似过程:

const obj = {};
obj.__proto__ = Person.prototype;
const result = Person.call(obj, "Alice");
const finalObj = typeof result === "object" && result !== null ? result : obj;

3.3.2 手写 new

function myNew(Constructor, ...args) {
  const obj = Object.create(Constructor.prototype);
  const result = Constructor.apply(obj, args);

  const isObject = result !== null && (typeof result === "object" || typeof result === "function");
  return isObject ? result : obj;
}

3.3.3 注意点

  1. Object.create(Constructor.prototype) 比直接操作 __proto__ 更推荐
  2. 如果构造函数返回基本类型,会被忽略
  3. 如果返回对象,则 new 的结果就是那个对象

3.4 class 语法

3.4.1 class 是什么

class 是 ES6 提供的更接近传统面向对象写法的语法,但底层仍然是基于原型实现。

示例:

class Person {
  constructor(name) {
    this.name = name;
  }

  sayHi() {
    return `Hi, ${this.name}`;
  }
}

const p = new Person("Tom");

本质上与下面类似:

function Person(name) {
  this.name = name;
}

Person.prototype.sayHi = function () {
  return `Hi, ${this.name}`;
};

3.4.2 class 与构造函数区别

对比项 class 构造函数
本质 语法糖 原生函数机制
调用方式 必须 new 可普通调用,也可 new
方法定义位置 默认在原型上 需手动挂到 prototype
变量提升 存在类似 TDZ,不可提前使用 函数声明可提升

3.4.3 class 继承

class Animal {
  constructor(name) {
    this.name = name;
  }

  eat() {
    return `${this.name} is eating`;
  }
}

class Dog extends Animal {
  constructor(name, color) {
    super(name);
    this.color = color;
  }

  bark() {
    return "wang";
  }
}

关键点:

  1. extends 建立继承关系
  2. super() 在子类构造函数中必须先调用
  3. super() 本质上是在调用父类构造函数

3.4.4 记忆口诀

class 好写但不神秘,底层还是原型链。


3.5 继承实现

3.5.1 常见继承方式

早期面试常问几种继承方式:

  1. 原型链继承
  2. 借用构造函数继承
  3. 组合继承
  4. 寄生组合式继承

现代开发多用 class extends,但面试仍需要理解这些底层思路。

3.5.2 原型链继承

function Parent() {
  this.colors = ["red", "blue"];
}

function Child() {}

Child.prototype = new Parent();

const c1 = new Child();
const c2 = new Child();

c1.colors.push("green");
console.log(c2.colors); // ["red", "blue", "green"]

缺点:

  1. 引用属性会被共享
  2. 不能方便向父构造函数传参

3.5.3 借用构造函数继承

function Parent(name) {
  this.name = name;
}

function Child(name) {
  Parent.call(this, name);
}

优点:

  1. 可以传参
  2. 实例属性不共享

缺点:

  1. 不能继承父类原型方法

3.5.4 组合继承

function Parent(name) {
  this.name = name;
  this.colors = ["red", "blue"];
}

Parent.prototype.sayName = function () {
  return this.name;
};

function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}

Child.prototype = new Parent();
Child.prototype.constructor = Child;

缺点:

父构造函数会执行两次:

  1. Parent.call(this, name)
  2. Child.prototype = new Parent()

3.5.5 寄生组合式继承

这是面试中最推荐回答的继承方式。

核心思想:

  1. Parent.call(this) 继承实例属性
  2. Object.create(Parent.prototype) 继承原型方法
  3. 避免父构造函数执行两次

实现:

function Parent(name) {
  this.name = name;
  this.colors = ["red", "blue"];
}

Parent.prototype.sayName = function () {
  return this.name;
};

function Child(name, age) {
  Parent.call(this, name);
  this.age = age;
}

Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

Child.prototype.sayAge = function () {
  return this.age;
};

3.5.6 为什么寄生组合式继承最好

  1. 不共享引用属性
  2. 能继承原型方法
  3. 父构造函数只执行一次
  4. 性能和语义都更合理

3.5.7 class extends 和寄生组合式继承的关系

可以这样理解:

class extends 本质上就是对原型继承写法的一层更高级封装。


3.6 本章高频面试题

题 1:prototype 和 proto 区别

答:

  1. prototype 是函数独有的属性
  2. __proto__ 是对象访问原型链的链接
  3. 实例对象的 __proto__ 通常等于构造函数的 prototype

题 2:new 做了什么

答:

  1. 创建新对象
  2. 连接原型
  3. 绑定 this
  4. 返回对象

题 3:class 与构造函数的区别

答:

  1. class 是语法糖
  2. 底层仍然是原型链
  3. class 必须通过 new 调用
  4. 方法默认定义在原型上

题 4:为什么寄生组合式继承优于组合继承

答:

因为它避免了父构造函数执行两次,同时兼顾实例属性继承和原型方法继承。


4. 异步编程

JavaScript 是单线程语言,但能通过事件循环机制处理异步任务。面试最容易卡住的点通常是事件循环、Promise 链、async/await 和并发控制。

4.1 事件循环 Event Loop

4.1.1 为什么需要事件循环

JavaScript 主线程一次只能做一件事。如果遇到耗时操作,比如:

  1. 网络请求
  2. 定时器
  3. 用户点击
  4. 文件读取

如果全都阻塞等待,页面就会卡死。所以 JavaScript 把异步任务交给宿主环境,等时机成熟再把回调放回任务队列。

4.1.2 执行顺序核心规则

  1. 先执行同步代码
  2. 同步代码执行完,清空微任务队列
  3. 再取一个宏任务执行
  4. 每轮宏任务结束后,再清空微任务
  5. 如此循环

4.1.3 宏任务和微任务

常见宏任务:

  1. setTimeout
  2. setInterval
  3. setImmediate(Node 中)
  4. I/O
  5. 整体 script

常见微任务:

  1. Promise.then/catch/finally
  2. queueMicrotask
  3. MutationObserver
  4. Node 中的 process.nextTick(优先级更特殊)

4.1.4 经典输出题

console.log(1);

setTimeout(() => {
  console.log(2);
}, 0);

Promise.resolve().then(() => {
  console.log(3);
});

console.log(4);

输出:

1
4
3
2

解释:

  1. 先执行同步:14
  2. Promise.then 进入微任务队列
  3. setTimeout 进入宏任务队列
  4. 同步结束后先清空微任务,输出 3
  5. 再执行下一轮宏任务,输出 2

4.1.5 再看一道综合题

async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}

async function async2() {
  console.log("async2");
}

console.log("script start");

setTimeout(() => {
  console.log("setTimeout");
}, 0);

async1();

Promise.resolve().then(() => {
  console.log("promise");
});

console.log("script end");

输出:

script start
async1 start
async2
script end
async1 end
promise
setTimeout

关键理解:

  1. await 后面的代码相当于放进微任务
  2. async1 endpromise 都是微任务
  3. 按进入队列顺序执行

4.1.6 记忆口诀

同步先跑完,微任务清空,再跑一个宏任务。


4.2 Promise

4.2.1 Promise 是什么

Promise 是对异步操作结果的统一抽象,用来解决回调地狱,提高异步代码可读性和可组合性。

4.2.2 三种状态

  1. pending:进行中
  2. fulfilled:已成功
  3. rejected:已失败

状态特点:

  1. 初始状态一定是 pending
  2. 只能从 pending 变成 fulfilledrejected
  3. 状态一旦改变,就不可逆

4.2.3 基本用法

const p = new Promise((resolve, reject) => {
  const success = true;

  if (success) {
    resolve("ok");
  } else {
    reject(new Error("fail"));
  }
});

p.then((res) => {
  console.log(res);
}).catch((err) => {
  console.log(err);
});

4.2.4 then 链式调用原理

then 会返回一个新的 Promise,所以可以链式调用。

Promise.resolve(1)
  .then((res) => res + 1)
  .then((res) => res + 1)
  .then((res) => {
    console.log(res); // 3
  });

规则:

  1. then 返回普通值,会包装成成功 Promise
  2. then 返回 Promise,会等待其结果
  3. then 抛错,会进入失败态

4.2.5 catch 和 finally

Promise.resolve("data")
  .then((res) => {
    console.log(res);
    throw new Error("error");
  })
  .catch((err) => {
    console.log("catch:", err.message);
  })
  .finally(() => {
    console.log("finally");
  });

说明:

  1. catch 用于捕获链路中的异常
  2. finally 不接收最终值,只负责收尾逻辑

4.2.6 常见静态方法

Promise.resolve

把一个值转成成功 Promise。

Promise.reject

快速返回失败 Promise。

Promise.all

全部成功才成功,一个失败就失败。

Promise.all([fetchA(), fetchB(), fetchC()]);

适合:

所有请求都成功才算完成。

Promise.allSettled

不管成功失败,全部结束后拿结果。

适合:

批量请求,需要知道每个任务状态。

Promise.race

谁先结束用谁的结果。

适合:

超时控制、抢最快结果。

Promise.any

谁先成功用谁;全部失败才失败。

4.2.7 手写 Promise 的核心理解

面试不一定要求你完整写出 Promise/A+,但至少要理解:

  1. Promise 内部维护状态
  2. then 需要收集回调
  3. 状态改变后异步执行回调
  4. 链式调用依赖返回新 Promise

简化版示意:

class MyPromise {
  constructor(executor) {
    this.state = "pending";
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => {
      if (this.state !== "pending") return;
      this.state = "fulfilled";
      this.value = value;
      this.onFulfilledCallbacks.forEach((fn) => fn());
    };

    const reject = (reason) => {
      if (this.state !== "pending") return;
      this.state = "rejected";
      this.reason = reason;
      this.onRejectedCallbacks.forEach((fn) => fn());
    };

    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }

  then(onFulfilled, onRejected) {
    if (this.state === "fulfilled") {
      onFulfilled && onFulfilled(this.value);
    }

    if (this.state === "rejected") {
      onRejected && onRejected(this.reason);
    }

    if (this.state === "pending") {
      this.onFulfilledCallbacks.push(() => onFulfilled && onFulfilled(this.value));
      this.onRejectedCallbacks.push(() => onRejected && onRejected(this.reason));
    }
  }
}

注意:

这只是帮助理解原理的简化版,不是完整可用的 Promise 实现。

4.2.8 记忆口诀

Promise 管状态,then 返回新 Promise,错误会沿链往后冒泡。


4.3 async/await

4.3.1 async/await 是什么

async/await 是基于 Promise 的语法糖,用同步写法组织异步逻辑。

4.3.2 规则

  1. async 函数总是返回 Promise
  2. await 会等待右侧 Promise 完成
  3. await 后面的代码会放到微任务中继续执行
async function test() {
  const result = await Promise.resolve(100);
  return result;
}

test().then(console.log); // 100

4.3.3 错误处理

方式 1:try...catch

async function getData() {
  try {
    const res = await fetch("/api/data");
    const data = await res.json();
    return data;
  } catch (error) {
    console.error("请求失败:", error);
    throw error;
  }
}

方式 2:外部统一 .catch

getData().catch((error) => {
  console.error(error);
});

4.3.4 async/await 常见误区

误区 1:串行等待导致性能差
async function bad() {
  const a = await fetchA();
  const b = await fetchB();
  return [a, b];
}

如果两个请求互不依赖,这样会串行执行。

更好的写法:

async function good() {
  const [a, b] = await Promise.all([fetchA(), fetchB()]);
  return [a, b];
}
误区 2:forEach 里不能正确 await

错误示例:

async function test(list) {
  list.forEach(async (item) => {
    await save(item);
  });
}

原因:

forEach 不会等待异步回调。

正确示例 1:串行

async function test(list) {
  for (const item of list) {
    await save(item);
  }
}

正确示例 2:并行

async function test(list) {
  await Promise.all(list.map((item) => save(item)));
}

4.3.5 async/await 与 Generator 的关系

可以简单理解:

  1. async/await 是 Generator 的进一步封装
  2. await 类似自动执行的 yield
  3. 使用成本更低,语义更直观

4.3.6 面试回答模板

async/await 为什么更好?

答:

  1. 本质仍是 Promise
  2. 写法更接近同步代码,可读性更高
  3. 更适合处理复杂异步流程和异常捕获
  4. 但不代表性能一定更好,关键还是并发组织方式

4.4 并发控制

4.4.1 为什么要做并发控制

现实中经常不能无限并发请求,比如:

  1. 接口有频率限制
  2. 浏览器并发连接数有限
  3. 一次请求太多会压垮服务器
  4. 上传下载任务需要控制资源占用

所以常见需求是:

同时最多执行 N 个任务

4.4.2 手写并发限制函数

下面是高频面试版本:

function limitConcurrency(tasks, limit) {
  return new Promise((resolve, reject) => {
    const results = [];
    let index = 0;
    let running = 0;
    let finished = 0;

    function runNext() {
      if (finished === tasks.length) {
        resolve(results);
        return;
      }

      while (running < limit && index < tasks.length) {
        const currentIndex = index;
        const task = tasks[index];
        index += 1;
        running += 1;

        Promise.resolve()
          .then(() => task())
          .then((res) => {
            results[currentIndex] = res;
          })
          .catch(reject)
          .finally(() => {
            running -= 1;
            finished += 1;
            runNext();
          });
      }
    }

    runNext();
  });
}

使用示例:

const tasks = [
  () => fetch("/api/1"),
  () => fetch("/api/2"),
  () => fetch("/api/3"),
  () => fetch("/api/4")
];

limitConcurrency(tasks, 2).then((res) => {
  console.log(res);
});

4.4.3 这类题目的核心思路

  1. 维护一个正在执行数量 running
  2. 不超过 limit 就继续发任务
  3. 某个任务结束后补位
  4. 所有任务结束后返回最终结果

4.4.4 记忆口诀

并发控制就是开工位,谁做完谁补位。


4.5 本章高频面试题

题 1:输出题 Promise + setTimeout

重点回答:

  1. 同步任务先执行
  2. 微任务优先于宏任务
  3. then 是微任务,setTimeout 是宏任务

题 2:Promise 三种状态

答:

pending -> fulfilled/rejected,状态一旦变化不可逆。

题 3:async/await 错误如何处理

答:

  1. 内部用 try...catch
  2. 外部统一 .catch

题 4:如何实现并发限制

答:

维护任务队列和运行计数,初始启动 limit 个任务,每完成一个就补一个,直到全部完成。


5. 设计模式

设计模式不是为了背概念,而是为了让你知道“什么时候该这样组织代码”。面试里最常见的,是单例、观察者、发布订阅和策略模式。

5.1 单例模式

5.1.1 什么是单例模式

保证一个类或对象在系统中只有一个实例,并提供统一访问入口。

5.1.2 应用场景

  1. 全局状态管理
  2. 弹窗管理
  3. 数据库连接
  4. 缓存管理器
  5. 配置中心

5.1.3 简单实现

class Singleton {
  constructor() {
    if (Singleton.instance) {
      return Singleton.instance;
    }

    this.data = {};
    Singleton.instance = this;
  }
}

const a = new Singleton();
const b = new Singleton();

console.log(a === b); // true

5.1.4 更安全的闭包写法

const createSingleton = (function () {
  let instance = null;

  return function () {
    if (!instance) {
      instance = {
        id: Date.now()
      };
    }
    return instance;
  };
})();

5.1.5 单例模式优缺点

优点:

  1. 节省资源
  2. 统一管理全局状态

缺点:

  1. 容易造成全局耦合
  2. 不利于测试
  3. 状态过多时维护困难

5.1.6 面试延伸

如何实现线程安全的单例?

在前端单线程环境里,更多关注的是:

  1. 避免重复实例化
  2. 避免异步初始化重复触发

比如可以加一个初始化状态锁。


5.2 观察者模式

5.2.1 什么是观察者模式

对象之间存在一对多依赖关系,当目标对象状态发生变化时,所有依赖者会自动收到通知。

5.2.2 应用场景

  1. 事件总线
  2. Vue 响应式原理中的依赖收集与通知
  3. 状态更新触发视图刷新

5.2.3 基本实现

class Subject {
  constructor() {
    this.observers = [];
  }

  subscribe(observer) {
    this.observers.push(observer);
  }

  unsubscribe(observer) {
    this.observers = this.observers.filter((item) => item !== observer);
  }

  notify(data) {
    this.observers.forEach((observer) => observer.update(data));
  }
}

class Observer {
  constructor(name) {
    this.name = name;
  }

  update(data) {
    console.log(`${this.name} received:`, data);
  }
}

5.2.4 特点

  1. 观察者直接订阅目标对象
  2. 目标对象保存观察者列表
  3. 双方关系较直接

5.3 发布订阅模式

5.3.1 什么是发布订阅

发布订阅和观察者模式很像,但中间多了一个“事件中心”或“消息通道”。

也就是说:

  1. 发布者不直接接触订阅者
  2. 订阅者也不直接接触发布者
  3. 双方通过中间代理通信

5.3.2 应用场景

  1. 组件通信
  2. 跨模块解耦
  3. 事件总线 EventBus

5.3.3 实现 EventEmitter

class EventEmitter {
  constructor() {
    this.events = {};
  }

  on(eventName, handler) {
    if (!this.events[eventName]) {
      this.events[eventName] = [];
    }
    this.events[eventName].push(handler);
  }

  off(eventName, handler) {
    if (!this.events[eventName]) return;
    this.events[eventName] = this.events[eventName].filter((fn) => fn !== handler);
  }

  once(eventName, handler) {
    const wrapper = (...args) => {
      handler(...args);
      this.off(eventName, wrapper);
    };
    this.on(eventName, wrapper);
  }

  emit(eventName, ...args) {
    if (!this.events[eventName]) return;
    this.events[eventName].forEach((handler) => handler(...args));
  }
}

5.3.4 观察者模式与发布订阅区别

对比项 观察者模式 发布订阅模式
通信方式 目标对象直接通知观察者 通过事件中心中转
耦合度 相对更高 更低
典型场景 响应式依赖通知 EventBus、消息系统

5.3.5 记忆口诀

观察者是直接通知,发布订阅是中间转发。


5.4 策略模式

5.4.1 什么是策略模式

把一组可互换的算法或规则封装起来,根据不同条件选择不同策略执行。

核心价值:

把大量 if-else 拆成独立规则。

5.4.2 应用场景

  1. 表单校验
  2. 支付方式处理
  3. 营销折扣计算
  4. 权限判断

5.4.3 普通 if-else 写法

function calculatePrice(type, price) {
  if (type === "normal") return price;
  if (type === "vip") return price * 0.8;
  if (type === "superVip") return price * 0.6;
  return price;
}

5.4.4 策略模式写法

const strategies = {
  normal(price) {
    return price;
  },
  vip(price) {
    return price * 0.8;
  },
  superVip(price) {
    return price * 0.6;
  }
};

function calculatePrice(type, price) {
  const strategy = strategies[type] || strategies.normal;
  return strategy(price);
}

5.4.5 表单校验示例

const validatorStrategies = {
  isNotEmpty(value, message) {
    if (value === "") return message;
  },
  minLength(value, length, message) {
    if (value.length < length) return message;
  }
};

function validate(value, rules) {
  for (const rule of rules) {
    const { strategy, params = [], message } = rule;
    const error = validatorStrategies[strategy](value, ...params, message);
    if (error) return error;
  }
}

5.4.6 优点

  1. 消除大量条件分支
  2. 易扩展
  3. 易测试
  4. 规则职责更清晰

5.4.7 记忆口诀

策略模式就是把选择逻辑和执行逻辑分开。


5.5 本章高频面试题

题 1:单例模式应用场景

答:

全局配置、弹窗管理、缓存对象、数据库连接、全局状态等。

题 2:如何实现 EventEmitter

答题要点:

  1. 用对象存储事件名和回调列表
  2. 实现 on
  3. 实现 emit
  4. 实现 off
  5. 实现 once

题 3:发布订阅与观察者区别

答:

观察者模式是目标对象直接维护观察者,发布订阅模式通过事件中心中转,解耦更强。

题 4:策略模式如何消除大量 if-else

答:

把不同规则封装成可替换策略,通过 key 查表执行,而不是把所有逻辑堆在一个函数里。


6. 高频手写题清单

学完上面内容,建议你按下面顺序练手写题。

6.1 第一组:基础机制

  1. 手写 new
  2. 手写 call
  3. 手写 apply
  4. 手写 bind
  5. 手写寄生组合式继承

6.2 第二组:异步核心

  1. 手写 Promise 简化版
  2. 手写 Promise.all
  3. 手写并发限制函数
  4. 手写防抖和节流
  5. 手写 sleep

6.3 第三组:模式和工具

  1. 手写 EventEmitter
  2. 手写单例
  3. 手写策略模式表单校验
  4. 手写缓存函数 memoize

6.4 推荐手写模板

写手写题时,用这个顺序思考:

  1. 需求是什么
  2. 输入输出是什么
  3. 需要维护什么状态
  4. 边界情况有哪些
  5. 是否需要返回新对象或新 Promise

7. 6 小时学习节奏建议

根据你给的学习规划,这里给出一个适合快速突击的节奏。

第 1 小时:闭包与作用域

目标:

  1. 搞清楚作用域、词法作用域、执行上下文
  2. 理解闭包和 TDZ

必会内容:

  1. 解释 var/let/const
  2. 解释闭包
  3. 说明循环 + var 为什么输出一样

第 2 小时:原型与继承

目标:

  1. 搞清楚 prototype__proto__、原型链
  2. 理解 new 过程和继承方式

必会内容:

  1. 手写 new
  2. 解释原型链查找
  3. 说出寄生组合式继承为何更优

第 3-4 小时:异步编程

目标:

  1. 吃透事件循环
  2. 理解 Promise 链
  3. 会用 async/await
  4. 能写并发控制

必会内容:

  1. 事件循环输出题
  2. Promise 三态
  3. await 后面为什么是微任务
  4. 并发限制函数

第 5 小时:设计模式

目标:

  1. 理解模式意图
  2. 能说应用场景
  3. 能写基础实现

必会内容:

  1. 单例
  2. EventEmitter
  3. 发布订阅 vs 观察者
  4. 策略模式

第 6 小时:复盘 + 手写

建议:

  1. 不再死看概念
  2. 直接默写代码
  3. 讲给自己听

推荐顺序:

  1. 手写 new
  2. 手写 EventEmitter
  3. 手写并发控制
  4. 解释闭包
  5. 解释事件循环

8. 一页速记总结

8.1 闭包与作用域

  1. JavaScript 用的是词法作用域
  2. 变量查找看定义位置,不看调用位置
  3. 闭包就是函数带着外层变量一起存活
  4. var 是函数作用域,let/const 是块级作用域
  5. let/const 有 TDZ

8.2 原型与继承

  1. 函数有 prototype
  2. 对象有 __proto__
  3. 实例.__proto__ === 构造函数.prototype
  4. 属性查找沿原型链向上
  5. class 是原型的语法糖
  6. 最推荐继承方式是寄生组合式继承

8.3 异步编程

  1. 同步代码先执行
  2. 微任务先于宏任务
  3. Promise.then 是微任务
  4. setTimeout 是宏任务
  5. async/await 本质是 Promise 语法糖
  6. 并发控制核心是“谁结束谁补位”

8.4 设计模式

  1. 单例:只有一个实例
  2. 观察者:目标直接通知观察者
  3. 发布订阅:通过事件中心中转
  4. 策略:把多分支逻辑改为可替换规则

9. 最后给你的背诵版口诀

9.1 作用域

作用域看定义,变量逐层找;闭包能记住,this 看调用。

9.2 原型

函数有 prototype,对象有 __proto__,查找沿链走,尽头是 null。

9.3 异步

同步先执行,微任务清空,宏任务再来,循环往复跑。

9.4 模式

单例保唯一,观察者直连,发布订阅靠中间,策略替代 if-else。


10. 建议你接下来怎么学

如果你想真正“快速学会并记住”,建议按下面顺序反复练:

  1. 先通读一遍整份笔记,建立知识地图
  2. 每章只背“定义 + 原理 + 口诀”
  3. 然后手敲每章的核心代码
  4. 再去做输出题和手写题
  5. 最后把每个知识点讲给自己听

真正能记住的标准不是“看懂”,而是:

  1. 你能自己讲出来
  2. 你能自己写出来
  3. 你能解释为什么

11. 自测题

试着不看答案,自己回答下面问题:

  1. JavaScript 为什么是词法作用域而不是动态作用域?
  2. 执行上下文里至少包含哪些关键信息?
  3. 闭包为什么能让变量不被销毁?
  4. varlet 最本质的差异是什么?
  5. prototype__proto__ 分别是什么?
  6. new 到底做了哪 4 步?
  7. 为什么说 class 只是语法糖?
  8. 为什么寄生组合式继承比组合继承更优?
  9. 事件循环里为什么微任务比宏任务先执行?
  10. Promise 为什么可以链式调用?
  11. await 后面的代码为什么不是立即执行?
  12. 并发控制题的核心状态变量有哪些?
  13. 观察者模式和发布订阅模式差别在哪?
  14. 策略模式如何优化大量分支判断?

12. 面试回答总模板

如果你怕面试时说乱,可以按这个模板回答每个知识点:

  1. 先下定义
  2. 再讲原理
  3. 再举例子
  4. 最后说应用场景或注意事项

例如回答闭包:

  1. 闭包是函数和其词法作用域引用的组合
  2. 当内部函数引用外部变量,并在外部函数执行结束后依然存活时,就形成典型闭包
  3. 它常用于私有变量、函数工厂、模块模式
  4. 但如果长期持有无用引用,可能造成内存占用问题

这个模板也适用于:

  1. 原型链
  2. Promise
  3. async/await
  4. 设计模式

13. 结语

JavaScript 真正难的地方,不是 API 多,而是底层机制抽象得比较强。你只要记住四个抓手:

  1. 变量怎么找:作用域
  2. 对象怎么继承:原型链
  3. 异步怎么调度:事件循环
  4. 代码怎么组织:设计模式

把这四块打通,JavaScript 的主体就立住了。