JavaScript 深度掌握学习笔记
适合目标:6 小时内建立 JavaScript 核心知识框架,覆盖高频面试点、核心原理、手写题与记忆方法。
学习重点:闭包与作用域、原型与继承、异步编程、设计模式。
学习原则:先理解运行机制,再背结论;先会说,再会写;先会写 demo,再写手写题。
目录
- 学习总览
- 闭包与作用域
- 原型与继承
- 异步编程
- 设计模式
- 高频手写题清单
- 6 小时学习节奏建议
- 一页速记总结
1. 学习总览
1.1 这份笔记要解决什么问题
很多人学 JavaScript 容易出现三个问题:
- 名词很多,彼此关系混乱。
- 知道结论,但解释不清原理。
- 面试能背概念,写代码时却容易卡住。
这份笔记的目标就是把这些知识串起来,让你形成一条清晰主线:
代码执行 -> 作用域与上下文 -> 对象与原型 -> 异步调度 -> 设计抽象
也就是说,JavaScript 的学习顺序可以理解为:
- 代码是怎么被执行的
- 变量为什么能访问到
- 对象方法为什么能继承
- 异步任务为什么不是按书写顺序执行
- 如何把这些机制组织成更好的代码结构
1.2 核心记忆主线
主线 1:作用域解决“去哪找变量”
- 词法作用域:定义时决定查找规则
- 作用域链:一层层向外找变量
- 闭包:函数带着外层变量一起存活
主线 2:原型解决“对象从哪继承能力”
prototype:构造函数的共享原型__proto__:对象的隐式原型指针- 原型链:对象属性查找路径
主线 3:事件循环解决“异步任务什么时候执行”
- 同步任务先执行
- 微任务比宏任务优先
Promise.then属于微任务setTimeout属于宏任务
主线 4:设计模式解决“代码如何组织得更好”
- 单例:全局只要一个实例
- 观察者:状态变化自动通知
- 发布订阅:通过中间层解耦
- 策略:把 if-else 变成可替换算法
2. 闭包与作用域
这一部分是 JavaScript 的底层基础。只要作用域理解清楚,闭包、this、变量提升、TDZ、执行上下文就都更容易串起来。
2.1 词法作用域
2.1.1 什么是作用域
作用域就是变量、函数、类等标识符的可访问范围。
JavaScript 中常见的作用域:
- 全局作用域
- 函数作用域
- 块级作用域
示例:
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 不是动态作用域语言。
面试常问:
词法作用域与动态作用域的区别是什么?
标准答法:
- 词法作用域在函数定义时确定
- 动态作用域在函数调用时确定
- JavaScript 采用词法作用域
2.1.4 记忆口诀
作用域看定义,不看调用。
2.2 执行上下文
2.2.1 什么是执行上下文
执行上下文可以理解为“当前代码执行时所处的环境信息”。
每次代码执行,JavaScript 引擎都会创建对应的执行上下文。常见有三种:
- 全局执行上下文
- 函数执行上下文
eval执行上下文(实际开发很少用)
2.2.2 执行上下文里有什么
老版本面试常说 VO/AO,现代说法更准确的是“环境记录 + 外部词法环境引用 + this 绑定”等信息。
你可以这样记:
- 变量和函数声明放哪里
- 当前能访问哪些外部变量
- 当前
this指向谁
面试里为了沟通方便,经常会说:
VO:变量对象(Variable Object)AO:活动对象(Activation Object,函数上下文内)
虽然规范现在不这样叫,但面试场景仍然常见。
2.2.3 执行上下文创建阶段
可以粗略理解为三个步骤:
- 绑定
this - 收集函数声明
- 收集变量声明
函数声明优先级高于变量声明。
示例:
console.log(a); // function a() {}
var a = 1;
function a() {}
console.log(a); // 1
原因:
- 创建阶段先处理函数声明,
a指向函数 var a只声明,不覆盖已有函数声明- 执行阶段
a = 1才真正赋值为数字
2.2.4 执行栈
执行上下文会按“后进先出”的方式进入调用栈。
示例:
function one() {
two();
console.log("one");
}
function two() {
three();
console.log("two");
}
function three() {
console.log("three");
}
one();
执行顺序:
- 压入全局上下文
- 调用
one,压入one one调用two,压入twotwo调用three,压入threethree执行完弹出two执行完弹出one执行完弹出
输出:
three
two
one
2.2.5 作用域链
当代码访问一个变量时,查找路径就是作用域链。
查找规则:
- 先找当前作用域
- 找不到就沿着外层词法环境继续找
- 一直找到全局
- 还找不到就是
ReferenceError
2.2.6 this 绑定
this 不是在定义时决定,而是多数情况下在调用时决定。
这是很多人把“作用域”和“this”混淆的根源。
常见规则:
- 默认绑定:普通函数独立调用,非严格模式指向全局对象,严格模式是
undefined - 隐式绑定:对象调用,
this指向调用者对象 - 显式绑定:
call、apply、bind new绑定:指向新创建的实例- 箭头函数:没有自己的
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 为什么闭包能保存变量
因为内部函数仍然引用外部变量,只要这个引用存在,对应的词法环境就不会被垃圾回收。
你可以理解为:
- 外部函数创建变量
- 内部函数引用这些变量
- 内部函数被返回或保存到外部
- 外部函数虽然执行完,但变量仍被内部函数“抓住”
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 节点、定时器,就可能导致内存泄漏或内存占用过高。
典型场景:
- 事件监听未移除
- 定时器未清除
- 闭包中引用大量数据
- 脱离文档的 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 如何避免闭包带来的内存问题
- 不要让闭包长期持有不必要的大对象
- 事件用完及时解绑
- 定时器用完及时清除
- 置空无用引用
2.3.6 面试回答模板
什么是闭包?
可以回答:
- 闭包是函数和其词法作用域引用的组合
- 当内部函数访问外部函数变量,并在外部函数执行结束后仍然存活,就形成了典型闭包
- 闭包常用于数据私有化、函数工厂、柯里化、模块模式
- 闭包如果使用不当,可能造成内存占用增加
2.3.7 记忆口诀
闭包不是特殊语法,而是函数记住了定义时的环境。
2.4 var、let、const
2.4.1 var 的特点
- 函数作用域
- 存在变量提升
- 可以重复声明
- 不受块级作用域限制
if (true) {
var a = 1;
}
console.log(a); // 1
2.4.2 let 的特点
- 块级作用域
- 会提升,但不能在声明前访问
- 不允许同一作用域重复声明
- 存在暂时性死区 TDZ
{
// console.log(a); // ReferenceError
let a = 1;
}
2.4.3 const 的特点
- 块级作用域
- 存在 TDZ
- 声明时必须初始化
- 不能重新赋值
注意: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 实战建议
- 默认优先使用
const - 需要重新赋值时用
let - 尽量不要再使用
var
2.4.8 记忆口诀
var 只有函数墙,let/const 还有块级墙;var 提前能看见,let 提前就报错。
2.5 本章高频面试题
题 1:解释词法作用域与动态作用域的区别
答:
- 词法作用域由函数定义位置决定
- 动态作用域由函数调用位置决定
- JavaScript 使用词法作用域
题 2:手写执行上下文创建过程
答题思路:
- 创建全局或函数执行上下文
- 确定
this - 收集函数声明和变量声明
- 建立外部词法环境引用
- 代码开始逐行执行
题 3:闭包为什么不会销毁外部变量
答:
因为内部函数仍然引用外部变量,对应词法环境仍然被使用,所以垃圾回收器不会释放它。
题 4:var 与 let 的 TDZ 区别
答:
var存在变量提升,声明前访问得到undefinedlet也会提升,但在声明前处于暂时性死区,访问会报错
3. 原型与继承
JavaScript 的继承不是像 Java 那样基于类的传统继承,而是基于原型链实现的。
class只是语法糖,底层仍是原型。
3.1 原型、prototype、proto
3.1.1 先记住三个概念
- 每个函数都有一个
prototype属性 - 每个对象都有一个内部原型指针,通常可通过
__proto__访问 - 对象查找属性时会沿着原型链往上找
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 会这样找:
- 先看对象自身有没有
xxx - 没有就找
obj.__proto__ - 继续沿原型向上找
- 找到
Object.prototype - 还没有就返回
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 的核心流程可以背成四步:
- 创建一个新对象
- 把新对象的原型指向构造函数的
prototype - 用新对象作为
this执行构造函数 - 如果构造函数返回引用类型,就返回该引用;否则返回新对象
示例:
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 注意点
Object.create(Constructor.prototype)比直接操作__proto__更推荐- 如果构造函数返回基本类型,会被忽略
- 如果返回对象,则
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";
}
}
关键点:
extends建立继承关系super()在子类构造函数中必须先调用super()本质上是在调用父类构造函数
3.4.4 记忆口诀
class 好写但不神秘,底层还是原型链。
3.5 继承实现
3.5.1 常见继承方式
早期面试常问几种继承方式:
- 原型链继承
- 借用构造函数继承
- 组合继承
- 寄生组合式继承
现代开发多用 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"]
缺点:
- 引用属性会被共享
- 不能方便向父构造函数传参
3.5.3 借用构造函数继承
function Parent(name) {
this.name = name;
}
function Child(name) {
Parent.call(this, name);
}
优点:
- 可以传参
- 实例属性不共享
缺点:
- 不能继承父类原型方法
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;
缺点:
父构造函数会执行两次:
Parent.call(this, name)Child.prototype = new Parent()
3.5.5 寄生组合式继承
这是面试中最推荐回答的继承方式。
核心思想:
- 用
Parent.call(this)继承实例属性 - 用
Object.create(Parent.prototype)继承原型方法 - 避免父构造函数执行两次
实现:
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 为什么寄生组合式继承最好
- 不共享引用属性
- 能继承原型方法
- 父构造函数只执行一次
- 性能和语义都更合理
3.5.7 class extends 和寄生组合式继承的关系
可以这样理解:
class extends 本质上就是对原型继承写法的一层更高级封装。
3.6 本章高频面试题
题 1:prototype 和 proto 区别
答:
prototype是函数独有的属性__proto__是对象访问原型链的链接- 实例对象的
__proto__通常等于构造函数的prototype
题 2:new 做了什么
答:
- 创建新对象
- 连接原型
- 绑定
this - 返回对象
题 3:class 与构造函数的区别
答:
class是语法糖- 底层仍然是原型链
class必须通过new调用- 方法默认定义在原型上
题 4:为什么寄生组合式继承优于组合继承
答:
因为它避免了父构造函数执行两次,同时兼顾实例属性继承和原型方法继承。
4. 异步编程
JavaScript 是单线程语言,但能通过事件循环机制处理异步任务。面试最容易卡住的点通常是事件循环、Promise 链、async/await 和并发控制。
4.1 事件循环 Event Loop
4.1.1 为什么需要事件循环
JavaScript 主线程一次只能做一件事。如果遇到耗时操作,比如:
- 网络请求
- 定时器
- 用户点击
- 文件读取
如果全都阻塞等待,页面就会卡死。所以 JavaScript 把异步任务交给宿主环境,等时机成熟再把回调放回任务队列。
4.1.2 执行顺序核心规则
- 先执行同步代码
- 同步代码执行完,清空微任务队列
- 再取一个宏任务执行
- 每轮宏任务结束后,再清空微任务
- 如此循环
4.1.3 宏任务和微任务
常见宏任务:
setTimeoutsetIntervalsetImmediate(Node 中)- I/O
- 整体 script
常见微任务:
Promise.then/catch/finallyqueueMicrotaskMutationObserver- 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、4 Promise.then进入微任务队列setTimeout进入宏任务队列- 同步结束后先清空微任务,输出
3 - 再执行下一轮宏任务,输出
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
关键理解:
await后面的代码相当于放进微任务async1 end和promise都是微任务- 按进入队列顺序执行
4.1.6 记忆口诀
同步先跑完,微任务清空,再跑一个宏任务。
4.2 Promise
4.2.1 Promise 是什么
Promise 是对异步操作结果的统一抽象,用来解决回调地狱,提高异步代码可读性和可组合性。
4.2.2 三种状态
pending:进行中fulfilled:已成功rejected:已失败
状态特点:
- 初始状态一定是
pending - 只能从
pending变成fulfilled或rejected - 状态一旦改变,就不可逆
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
});
规则:
then返回普通值,会包装成成功 Promisethen返回 Promise,会等待其结果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");
});
说明:
catch用于捕获链路中的异常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+,但至少要理解:
- Promise 内部维护状态
then需要收集回调- 状态改变后异步执行回调
- 链式调用依赖返回新 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 规则
async函数总是返回 Promiseawait会等待右侧 Promise 完成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 的关系
可以简单理解:
async/await是 Generator 的进一步封装await类似自动执行的yield- 使用成本更低,语义更直观
4.3.6 面试回答模板
async/await 为什么更好?
答:
- 本质仍是 Promise
- 写法更接近同步代码,可读性更高
- 更适合处理复杂异步流程和异常捕获
- 但不代表性能一定更好,关键还是并发组织方式
4.4 并发控制
4.4.1 为什么要做并发控制
现实中经常不能无限并发请求,比如:
- 接口有频率限制
- 浏览器并发连接数有限
- 一次请求太多会压垮服务器
- 上传下载任务需要控制资源占用
所以常见需求是:
同时最多执行 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 这类题目的核心思路
- 维护一个正在执行数量
running - 不超过
limit就继续发任务 - 某个任务结束后补位
- 所有任务结束后返回最终结果
4.4.4 记忆口诀
并发控制就是开工位,谁做完谁补位。
4.5 本章高频面试题
题 1:输出题 Promise + setTimeout
重点回答:
- 同步任务先执行
- 微任务优先于宏任务
then是微任务,setTimeout是宏任务
题 2:Promise 三种状态
答:
pending -> fulfilled/rejected,状态一旦变化不可逆。
题 3:async/await 错误如何处理
答:
- 内部用
try...catch - 外部统一
.catch
题 4:如何实现并发限制
答:
维护任务队列和运行计数,初始启动 limit 个任务,每完成一个就补一个,直到全部完成。
5. 设计模式
设计模式不是为了背概念,而是为了让你知道“什么时候该这样组织代码”。面试里最常见的,是单例、观察者、发布订阅和策略模式。
5.1 单例模式
5.1.1 什么是单例模式
保证一个类或对象在系统中只有一个实例,并提供统一访问入口。
5.1.2 应用场景
- 全局状态管理
- 弹窗管理
- 数据库连接
- 缓存管理器
- 配置中心
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 单例模式优缺点
优点:
- 节省资源
- 统一管理全局状态
缺点:
- 容易造成全局耦合
- 不利于测试
- 状态过多时维护困难
5.1.6 面试延伸
如何实现线程安全的单例?
在前端单线程环境里,更多关注的是:
- 避免重复实例化
- 避免异步初始化重复触发
比如可以加一个初始化状态锁。
5.2 观察者模式
5.2.1 什么是观察者模式
对象之间存在一对多依赖关系,当目标对象状态发生变化时,所有依赖者会自动收到通知。
5.2.2 应用场景
- 事件总线
- Vue 响应式原理中的依赖收集与通知
- 状态更新触发视图刷新
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 特点
- 观察者直接订阅目标对象
- 目标对象保存观察者列表
- 双方关系较直接
5.3 发布订阅模式
5.3.1 什么是发布订阅
发布订阅和观察者模式很像,但中间多了一个“事件中心”或“消息通道”。
也就是说:
- 发布者不直接接触订阅者
- 订阅者也不直接接触发布者
- 双方通过中间代理通信
5.3.2 应用场景
- 组件通信
- 跨模块解耦
- 事件总线 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 应用场景
- 表单校验
- 支付方式处理
- 营销折扣计算
- 权限判断
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 优点
- 消除大量条件分支
- 易扩展
- 易测试
- 规则职责更清晰
5.4.7 记忆口诀
策略模式就是把选择逻辑和执行逻辑分开。
5.5 本章高频面试题
题 1:单例模式应用场景
答:
全局配置、弹窗管理、缓存对象、数据库连接、全局状态等。
题 2:如何实现 EventEmitter
答题要点:
- 用对象存储事件名和回调列表
- 实现
on - 实现
emit - 实现
off - 实现
once
题 3:发布订阅与观察者区别
答:
观察者模式是目标对象直接维护观察者,发布订阅模式通过事件中心中转,解耦更强。
题 4:策略模式如何消除大量 if-else
答:
把不同规则封装成可替换策略,通过 key 查表执行,而不是把所有逻辑堆在一个函数里。
6. 高频手写题清单
学完上面内容,建议你按下面顺序练手写题。
6.1 第一组:基础机制
- 手写
new - 手写
call - 手写
apply - 手写
bind - 手写寄生组合式继承
6.2 第二组:异步核心
- 手写 Promise 简化版
- 手写
Promise.all - 手写并发限制函数
- 手写防抖和节流
- 手写
sleep
6.3 第三组:模式和工具
- 手写 EventEmitter
- 手写单例
- 手写策略模式表单校验
- 手写缓存函数
memoize
6.4 推荐手写模板
写手写题时,用这个顺序思考:
- 需求是什么
- 输入输出是什么
- 需要维护什么状态
- 边界情况有哪些
- 是否需要返回新对象或新 Promise
7. 6 小时学习节奏建议
根据你给的学习规划,这里给出一个适合快速突击的节奏。
第 1 小时:闭包与作用域
目标:
- 搞清楚作用域、词法作用域、执行上下文
- 理解闭包和 TDZ
必会内容:
- 解释
var/let/const - 解释闭包
- 说明循环 +
var为什么输出一样
第 2 小时:原型与继承
目标:
- 搞清楚
prototype、__proto__、原型链 - 理解
new过程和继承方式
必会内容:
- 手写
new - 解释原型链查找
- 说出寄生组合式继承为何更优
第 3-4 小时:异步编程
目标:
- 吃透事件循环
- 理解 Promise 链
- 会用 async/await
- 能写并发控制
必会内容:
- 事件循环输出题
- Promise 三态
await后面为什么是微任务- 并发限制函数
第 5 小时:设计模式
目标:
- 理解模式意图
- 能说应用场景
- 能写基础实现
必会内容:
- 单例
- EventEmitter
- 发布订阅 vs 观察者
- 策略模式
第 6 小时:复盘 + 手写
建议:
- 不再死看概念
- 直接默写代码
- 讲给自己听
推荐顺序:
- 手写
new - 手写 EventEmitter
- 手写并发控制
- 解释闭包
- 解释事件循环
8. 一页速记总结
8.1 闭包与作用域
- JavaScript 用的是词法作用域
- 变量查找看定义位置,不看调用位置
- 闭包就是函数带着外层变量一起存活
var是函数作用域,let/const是块级作用域let/const有 TDZ
8.2 原型与继承
- 函数有
prototype - 对象有
__proto__ 实例.__proto__ === 构造函数.prototype- 属性查找沿原型链向上
class是原型的语法糖- 最推荐继承方式是寄生组合式继承
8.3 异步编程
- 同步代码先执行
- 微任务先于宏任务
Promise.then是微任务setTimeout是宏任务async/await本质是 Promise 语法糖- 并发控制核心是“谁结束谁补位”
8.4 设计模式
- 单例:只有一个实例
- 观察者:目标直接通知观察者
- 发布订阅:通过事件中心中转
- 策略:把多分支逻辑改为可替换规则
9. 最后给你的背诵版口诀
9.1 作用域
作用域看定义,变量逐层找;闭包能记住,this 看调用。
9.2 原型
函数有 prototype,对象有 __proto__,查找沿链走,尽头是 null。
9.3 异步
同步先执行,微任务清空,宏任务再来,循环往复跑。
9.4 模式
单例保唯一,观察者直连,发布订阅靠中间,策略替代 if-else。
10. 建议你接下来怎么学
如果你想真正“快速学会并记住”,建议按下面顺序反复练:
- 先通读一遍整份笔记,建立知识地图
- 每章只背“定义 + 原理 + 口诀”
- 然后手敲每章的核心代码
- 再去做输出题和手写题
- 最后把每个知识点讲给自己听
真正能记住的标准不是“看懂”,而是:
- 你能自己讲出来
- 你能自己写出来
- 你能解释为什么
11. 自测题
试着不看答案,自己回答下面问题:
- JavaScript 为什么是词法作用域而不是动态作用域?
- 执行上下文里至少包含哪些关键信息?
- 闭包为什么能让变量不被销毁?
var和let最本质的差异是什么?prototype和__proto__分别是什么?new到底做了哪 4 步?- 为什么说
class只是语法糖? - 为什么寄生组合式继承比组合继承更优?
- 事件循环里为什么微任务比宏任务先执行?
- Promise 为什么可以链式调用?
await后面的代码为什么不是立即执行?- 并发控制题的核心状态变量有哪些?
- 观察者模式和发布订阅模式差别在哪?
- 策略模式如何优化大量分支判断?
12. 面试回答总模板
如果你怕面试时说乱,可以按这个模板回答每个知识点:
- 先下定义
- 再讲原理
- 再举例子
- 最后说应用场景或注意事项
例如回答闭包:
- 闭包是函数和其词法作用域引用的组合
- 当内部函数引用外部变量,并在外部函数执行结束后依然存活时,就形成典型闭包
- 它常用于私有变量、函数工厂、模块模式
- 但如果长期持有无用引用,可能造成内存占用问题
这个模板也适用于:
- 原型链
- Promise
- async/await
- 设计模式
13. 结语
JavaScript 真正难的地方,不是 API 多,而是底层机制抽象得比较强。你只要记住四个抓手:
- 变量怎么找:作用域
- 对象怎么继承:原型链
- 异步怎么调度:事件循环
- 代码怎么组织:设计模式
把这四块打通,JavaScript 的主体就立住了。