再看 JavaScript
JavaScript 诞生于 1995 年,由 Brendan Eich 仅用 10 天设计完成,最初叫做 LiveScript,后来在与 Sun Microsystems 合作期间改名为 JavaScript——名字里蹭了 Java 的热度,实际上两者关系并不大。如今,它是全球使用最广泛的编程语言之一,撑起了大半个互联网:浏览器端的交互、Node.js 驱动的后端服务、甚至嵌入式脚本,都能看到它的身影。弱类型、动态、解释执行——这些特性让它极度灵活。
1. 类型
1.1 基本数据类型
和大多数语言一样,基本数据类型无非是数字、字符串、布尔值这些。不同的是,JS 没有指针的概念,统一用”引用”来访问对象,在一定程度上降低了语言的复杂度。
截至目前(2026/04/24),JS 共有 7 种基本数据类型:数字相关的 number 和 bigint,两个空值 null 和 undefined,布尔值 boolean,以及 string 和 symbol。MDN 文档 明确指出,JS 的基本数据类型都是不可变的(immutable)。
我对“不可变”理解是:由于没有指针,无法直接修改分配在栈上内存里的原始值,只能重新赋值给变量。
let a = 10;// 并没有直接修改原来的值,而是让变量 a 指向一个新值a = 100;
let str = "hello, world";// 在严格模式下,以下操作会直接报错str[1] = "b";// TypeError: Cannot assign to read only property '1' of string 'hello, world'JS 有两个空值:关键字 null 和标识符 undefined。从语义上讲,undefined 有“声明了但尚未赋值”的含义,因此普遍的共识是:null 表示有意的空,undefined 则更多是无意的空。其中 typeof null === "object" 是早期实现留下的历史遗留问题,原因与早期的类型标签设计有关。这个 bug 之所以一直保留,是为了兼容既有网页。undefined 本质上是全局对象(浏览器中为 window,Node.js 中为 global,标准化后统一可用 globalThis 访问)的一个只读属性。早年间甚至可以对它重新赋值,后来才改为只读:
// TypeError: Cannot assign to read only property 'undefined' of object '#<Window>'globalThis.undefined = "aaa";还有一个比较特殊的基本类型:symbol,很长一段时间内,JavaScript 对象的属性名只能是字符串,库一多就容易出现命名冲突——两个不同的库都在对象上挂了一个叫 type 的属性,互相覆盖。Symbol() 每次调用都会返回一个唯一的 symbol,天然解决了这个问题:
const sym1 = Symbol("description");const sym2 = Symbol("description");console.log(sym1 === sym2); // false,即使描述相同,也是不同的值symbol 还有另一个用途:JS 引擎内置了一批 Well-known Symbols(如 Symbol.iterator、Symbol.toPrimitive、Symbol.hasInstance 等),作为“协议接口”定义对象的行为能力。这个机制和 Java 的 interface 有几分相似——通过实现特定的 symbol 方法,赋予对象某种能力:
// 给自定义对象实现 Symbol.iterator,相当于实现了 Java 的 Iterable 接口const range = { start: 1, end: 5, [Symbol.iterator]() { let current = this.start; const last = this.end; return { next() { if (current <= last) { return { value: current++, done: false }; } return { done: true }; }, }; },};
for (const num of range) { console.log(num); // 1, 2, 3, 4, 5}不过相较于 Java 的 interface,这是一种运行时协议:引擎只会在运行时检查对象是否有对应的 symbol 方法,没有编译期类型检查,也不能保证方法签名正确。
1.2 引用类型
JS 中除基本类型以外,其余都是引用类型,统一属于 Object,如 Date、RegExp、Map、Set 等,以及部分基本类型的包装类 Number、String、Symbol。在 Java 中,类是对象的模板,在类中定义方法,实例化的每个对象都共享这些方法,类与类之间可以继承以复用代码,且所有类都是 Object 的子类。JS 在 ES6 加入了 class 关键字,虽说是语法糖,但写起来和 Java 几乎一样:
class Animal { constructor(name) { this.name = name; }
sayHi() { console.log(`Hi, I am ${this.name}!`); }}
class Cat extends Animal { constructor(name) { super(name); }
catchFish() { console.log("I am catching fish!"); }}构造器、方法定义、this、super 以及 extends,简直和 Java 如出一辙。但语法糖终究是语法糖,查看 Animal 的类型,会发现它本质是一个函数:
console.log(typeof Animal === "function"); // trueES6 之前,JS 通过原型链来实现代码复用。上面的类定义,等价于:
// Animal 构造函数function Animal(name) { this.name = name;}
// 在 prototype 上定义方法,new 出来的所有实例共享这份方法Animal.prototype.sayHi = function () { console.log(`Hi, I am ${this.name}!`);};
// Cat 构造函数function Cat(name) { Animal.call(this, name); // 等价于 super(name)}
// 建立原型链:让 Cat.prototype 继承自 Animal.prototypeCat.prototype = Object.create(Animal.prototype);// 修复 constructor 指向(Object.create 后 constructor 指向了 Animal)Cat.prototype.constructor = Cat;
// Cat 特有的原型方法Cat.prototype.catchFish = function () { console.log("I am catching fish!");};用 class 定义类时,蓝图和实例的关系一目了然;而用原型链时,这层关系就模糊许多——Animal 本身是一个 Function 对象,同时充当构造函数,其 prototype 属性才是后续 new 出来的对象真正的“蓝图”。
理解原型链,需要区分两个容易混淆的属性:
prototype:这是函数对象上的属性,指向一个对象,作为“蓝图”供new出来的实例继承。只有函数有。[[Prototype]](即__proto__):这是每个对象内部都有的一个插槽,指向该对象的原型。实例通过它向上查找属性。
当你访问 sillyCat.sayHi 时,JS 引擎做的事情是:先在 sillyCat 自身的属性上找,没有;再沿 [[Prototype]] 向上到 Cat.prototype 上找,没有;再向上到 Animal.prototype 上找,找到了,执行之。如果一路找到 Object.prototype 还没有,就继续找 null——Object.prototype 的 [[Prototype]] 就是 null,链到此终止,返回 undefined。
const sillyCat = new Cat("Silly");
console.log(Animal.__proto__ === Function.prototype); // true,Animal 是 Function 的实例console.log(sillyCat.__proto__ === Cat.prototype); // trueconsole.log(sillyCat.__proto__.__proto__ === Animal.prototype); // trueconsole.log(sillyCat.__proto__.__proto__.__proto__ === Object.prototype); // trueconsole.log(Object.prototype.__proto__ === null); // true,链到此终止
sillyCat.sayHi(); // Hi, I am Silly!根据 ECMAScript 规范,someObject.[[Prototype]] 是访问对象原型的标准内部插槽,推荐通过 Object.getPrototypeOf() 和 Object.setPrototypeOf() 访问和修改。__proto__ 是等效的非标准访问器,虽然主流引擎都实现了它,但在生产代码中应优先使用标准 API。
上面特意强调了 function 和 new 关键字,这其实和 this 的动态绑定密切相关。在 Java 中,this 明确指向当前实例;在 JS 中,this 的值取决于函数的调用方式,而不是函数的定义位置。函数作为某个对象的方法调用,this 指向调用点前面那个对象,此时和Java无异;但如果作为单独的函数调用,this将会绑定到全局对象上,而在严格模式下则是 undefined。
const sillyCat = new Cat("Silly");sillyCat.sayHi(); // 隐式绑定:this = sillyCat,正常
const funcSayHi = sillyCat.sayHi;funcSayHi(); // 默认绑定:严格模式下 this = undefined,报错// TypeError: Cannot read properties of undefined (reading 'name')我们可以手动的改变 this 的指向,JS 提供了三种方法:call、apply 和 bind。前两者会立即执行函数,区别在于传参方式;bind 则返回一个新的函数,绑定了指定的 this,但不立即执行:
funcSayHi.call(sillyCat); // 立即执行,逐个传参funcSayHi.apply(sillyCat, []); // 立即执行,数组传参const bound = funcSayHi.bind(sillyCat); // 返回绑定后的新函数,不立即执行bound();综合来看,new 关键字大致做了以下几件事:1. 创建一个新的空对象;2. 将其 [[Prototype]] 指向构造函数的 prototype;3. 将 this 绑定到该对象并执行构造函数。
function myNew(constructor, ...args) { // 步骤 1 + 2:创建空对象,原型指向构造函数的 prototype const obj = Object.create(constructor.prototype);
// 步骤 3:绑定 this 并执行构造函数 const result = constructor.apply(obj, args);
// 如果构造函数显式返回了一个对象或函数,以其为准;否则返回新对象 return (typeof result === "object" && result !== null) || typeof result === "function" ? result : obj;}由于箭头函数没有自己的 this,它不参与上述任何绑定规则,而是在定义时捕获外层词法作用域的 this,且无法被 call/apply/bind 改变,也不能用 new 调用:
function Timer() { this.seconds = 0; setInterval(() => { // 箭头函数捕获了 Timer 构造调用时的 this(即实例本身) // 如果这里用普通函数,this 会变成 globalThis 或 undefined this.seconds++; console.log(this.seconds); }, 1000);}箭头函数没有
prototype,但有__proto__——它自身作为对象,原型指向Function.prototype。
说到可见性控制,Java 有非常丰富的修饰符(private、public、final 等)。在 JS 中,对象是属性的集合,属性分为两种:数据属性(键值对)和访问器属性(getter/setter)。两者都有特性描述符,可以通过 Object.defineProperty 精确控制行为:
let obj = { name: "Alice" };
// 精确控制数据属性// 描述符:[[Value]] [[Writable]] [[Enumerable]] [[Configurable]]Object.defineProperty(obj, "id", { value: 1001, writable: false, // 不可修改 enumerable: true, // 可枚举(for...in 可见) configurable: false, // 不可删除、不可再重新配置});
// 精确控制访问器属性Object.defineProperty(obj, "age", { // _age 是真正的存储字段,约定以下划线开头表示"不应直接访问" get() { return this._age; }, set(val) { if (val < 0) throw new Error("年龄不能为负"); this._age = val; }, enumerable: true, configurable: true,});枚举 vs 迭代:
enumerable控制属性键能否被枚举(for...in...),而前文提到的Symbol.iterator定义的是元素值能否被迭代(for...of...)
自 ES6 引入 class 语法糖后,后续版本也持续补充了更多原生能力:ES2020 带来了以 # 为前缀的私有字段和私有方法;ES2022 允许在类顶层直接声明字段,引入 #field in obj 的品牌检查(brand check)以及静态初始化块。
class BankAccount { // ES2022: 顶层公有字段 owner = "Unknown"; // ES2022: 顶层私有字段声明 #accountNumber; // ES2020: 私有字段 #balance; // ES2020: 静态私有字段 static #bankName;
// ES2022: 静态初始化块(比静态字段赋值更灵活,可写条件逻辑) static { BankAccount.#bankName = "Global Bank"; }
constructor(owner, accountNumber, initialBalance) { this.owner = owner; this.#accountNumber = accountNumber; this.#balance = initialBalance; }
// ES2020: 私有方法 #logTransaction(type, amount) { console.log(`${type}: ${amount},账号:${this.#accountNumber}`); }
deposit(amount) { this.#balance += amount; this.#logTransaction("存款", amount); }
withdraw(amount) { if (amount <= this.#balance) { this.#balance -= amount; this.#logTransaction("取款", amount); } }
get balance() { return this.#balance; }
static getBankName() { return BankAccount.#bankName; }
// ES2022: 品牌检查——比 instanceof 更可靠,可抵御跨 realm 的误判 static isBankAccount(obj) { return #balance in obj; }}2. 作用域与函数
2.1 作用域
JS 主要有 4 种作用域:全局作用域、函数作用域、块级作用域(ES6+)和模块作用域(ES6+),和大多数编程语言大同小异。
全局作用域意味着所有代码都能访问,比如 globalThis 对象本身。在本站的源码里,HTML 头部塞入了一小段 JS,在第一帧绘制前就设置好主题颜色,避免闪烁。几个辅助函数暴露在全局作用域中,这样其他地方可以直接调用——比如现在打开控制台输入 toggleTheme() 就能触发主题切换:
const applyTheme = () => { document.documentElement.classList.toggle( "dark", localStorage.theme === "dark" || (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches), );};
const toggleTheme = () => { const isDark = document.documentElement.classList.toggle("dark"); localStorage.theme = isDark ? "dark" : "light";};
const getTheme = () => { const isDark = document.documentElement.classList.contains("dark") || window.matchMedia("(prefers-color-scheme: dark)").matches; return isDark ? "dark" : "light";};这是一种特殊用法,一般不建议污染全局作用域。如果只是需要立即执行一些初始化代码,推荐使用 IIFE(Immediately Invoked Function Expression),通过函数作用域将内部变量隔离起来:
(() => { var a = 10; console.log("Hi.");})();// a 无法从外部访问模块作用域是 ES6 加入的。如果在 <script> 标签上加 type="module",其中定义的变量和函数就不再影响全局作用域了。在绝大多数项目开发中,随处可见的 export 和 import 本质上都在利用模块作用域——一个文件即一个独立作用域,Node.js 等运行时也遵循同样的规则。
函数作用域和块级作用域是 JS 里最值得深聊的部分。JS 诞生之初,为了打破“函数必须先声明才能调用”的顺序限制,JS 设计了提升(Hoisting)机制。引擎在正式执行代码之前,有一个解析阶段,会提前扫描当前作用域内所有的函数声明和变量声明,在内存中为它们预留位置:
// 先调用sayHello();
// 后声明——解析阶段已将其提升function sayHello() { console.log("Hello, Hoisting!");}对于 var 声明的变量,提升的只是声明,不包括赋值。变量在提升后会被初始化为 undefined:
console.log(a); // undefined(声明已提升,但赋值尚未执行到)var a = 10;console.log(a); // 10这也解释了为什么 undefined 比 null 更多地代表“无意的空”——变量被提升并初始化后、赋值前,JS 就给了它一个 undefined。函数表达式的变量也只提升声明,不提升赋值:
greet(); // TypeError: greet is not a function(greet 此时是 undefined)
var greet = function () { console.log("Hi");};
// 引擎实际执行的等价逻辑:var greet; // 提升声明,初始化为 undefinedgreet(); // 调用 undefined,报错greet = function () { console.log("Hi");};ES6 加入的 let 和 const** 也会在解析阶段被登记(“提升”),但它们不会被初始化为 **undefined,而是被放入暂时性死区(TDZ,Temporal Dead Zone)——在声明语句被实际执行之前,该变量处于“已存在但不可访问”的状态,任何读写都会抛出 ReferenceError:
console.log(y); // ReferenceError: Cannot access 'y' before initializationlet y = 20;// 当执行到这一行时,y 才脱离 TDZ,被初始化为 20TDZ 的本质是一种安全设计:它保证了 let/const 声明的变量在初始化之前不可见,从根本上杜绝了 var 那种“先用后声明”的隐患。
跟着 let/const 一起加入的,还有块级作用域。核心规则简单:内层作用域可以访问外层作用域的变量,反之不行。而 var 对块级作用域视而不见,直接逃逸到外层的函数或全局作用域,可以总结为:var 的提升最低是函数作用域级别;let/const 的提升是块级作用域级别,且在初始化前受 TDZ 保护。
{ let block = "inside block"; const PI = 3.14; var escape = "I am var"; // var 穿透块,逃到外层}
console.log(block); // ReferenceErrorconsole.log(PI); // ReferenceErrorconsole.log(escape); // "I am var"
// 经典的 var 循环陷阱for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0);}// 输出:3, 3, 3// 原因:var i 属于外层函数/全局作用域,三个箭头函数共享同一个 i// 当 setTimeout 的回调执行时,循环早已结束,i 已经是 3
// let 正确行为for (let j = 0; j < 3; j++) { setTimeout(() => console.log(j), 0);}// 输出:0, 1, 2// 原因:每次迭代 let j 创建一个独立的块级绑定,每个回调捕获的是不同的 j2.2 函数
JS 中的函数是一等公民,可以赋值给变量、作为参数传入、也可以从函数中返回。ES6 加入箭头函数后,函数式编程的风格愈发明显。
function square(a) { return a * a;}
// 箭头函数:更简洁,且没有自己的 this、arguments、prototypeconst squareArrow = (a) => a * a;在普通函数中,可以通过类数组对象 arguments 获取传入的所有参数;箭头函数没有 arguments,改用剩余参数(rest parameters),拿到的是真数组,语义更清晰:
function sum() { // arguments 是类数组对象,有索引和 length,但没有 map、filter 等数组方法 console.log(arguments); // Arguments [1, 2, 3, ...] let total = 0; for (let val of arguments) total += val; return total;}
const arrowSum = (...args) => { // args 是真正的数组,可以直接使用所有数组方法 console.log(args); // [1, 2, 3] return args.reduce((a, b) => a + b, 0);};JS 函数的一等公民地位,使得 Lambda、闭包、柯里化等函数式编程技巧都十分自然:
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map((n) => n * 2); // 匿名函数(Lambda)const doubleIt = (n) => n * 2;const doubled2 = numbers.map(doubleIt); // 函数作为参数(高阶函数)闭包(Closure) 是函数在定义时捕获其词法作用域中自由变量的能力。具体来说,当一个函数被创建时,它会持有一个指向其外层词法环境(Lexical Environment)的引用;即使外层函数已经执行完毕、从调用栈上弹出,只要内层函数还存活,这片词法环境就不会被垃圾回收,依然保存在堆内存中。
这正是防抖和节流能够工作的原因——每次调用 debounce 或 throttle 都会在堆上创建一个新的 timer 变量,返回的函数持有对它的引用,后续每次调用都在操作同一个 timer:
// 防抖:连续触发时只执行最后一次,常用于搜索输入function debounce(fn, delay) { let timer = null; // 这个 timer 活在堆上,被返回的函数持有 return function (...args) { if (timer) clearTimeout(timer); // 每次触发都取消上一个计时器 timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); };}
// 节流:固定时间间隔内最多执行一次,常用于滚动事件function throttle(fn, delay) { let timer = null; return function (...args) { if (timer) return; // 计时器存在说明还在冷却中,直接跳过 timer = setTimeout(() => { fn.apply(this, args); timer = null; }, delay); };}高阶函数是指接收函数作为参数或返回函数的函数。debounce、throttle 本身就是高阶函数;Array.prototype.map、filter、reduce 也都是。柯里化是将一个多参数函数转化为一系列单参数函数的技术。其核心价值在于参数复用:固定部分参数,得到一个更具体的函数:
const add = (a, b) => a + b;
// 柯里化:一次只接受一个参数const curriedAdd = (a) => (b) => a + b;
const add5 = curriedAdd(5); // 固定 a = 5,返回一个新函数console.log(add5(3)); // 8console.log(add5(10)); // 15柯里化、纯函数(Pure Function)、函子(Functor)等更深层的概念属于函数式编程的范畴,感兴趣的话推荐去了解下 Haskell——换一门语言来理解这些概念,往往比在 JS 里绕圈子清晰得多。
3. 并发系统
3.1 事件循环
JavaScript 的并发模型本质上是单线程 + 事件驱动 + 异步机制。无论是浏览器还是 Node.js,JS 主线程永远是单线程的,但整体运行环境并不只有单线程:浏览器是“多模块并发”(渲染线程、网络线程、JS 线程等各司其职),Node.js 是“统一调度 + libuv 线程池并发”。下面以浏览器为主;Node.js 的事件循环还有 libuv 的阶段划分和 process.nextTick 等细节。
JS 代码在一个后进先出(LIFO)的调用栈(Call Stack)中执行,函数调用压栈,执行完毕出栈。同一时刻只能处理一件事。但当主线程遇到 setTimeout、fetch 等异步调用时,可以将其委托给浏览器提供的 Web API,由对应的后台线程(计时器线程、网络线程等)处理,主线程继续向下执行,不需要等待。
当后台的异步操作完成后,其注册的回调函数不会立刻执行,而是被放入一个任务队列等待。事件循环就是驱动整个系统的调度器:它持续监测调用栈,一旦调用栈为空,就从任务队列中取出一个任务推入栈中执行,如此循环往复。
JS 将任务分为宏任务和微任务两类:
| 类型 | 来源示例 |
|---|---|
| 宏任务 | setTimeout、setInterval、I/O 回调、<script> 初始执行、MessageChannel |
| 微任务 | Promise.then/catch/finally、queueMicrotask、MutationObserver、queueReactiveSideEffect(Vue 内部) |
分成两类的原因在于优先级:如果把点击回调、网络响应、Promise 回调全塞进一个队列,某类高频任务就可能拖延其他任务,影响输入响应和页面渲染的流畅性。
事件循环的完整执行节奏如下:
- 从宏任务队列取出一个宏任务执行;
- 该宏任务执行完毕后,清空整个微任务队列——包括在清空过程中新产生的微任务(微任务可以递归产生新的微任务,全部清完才停);
- 浏览器进行渲染更新(如果有需要);
- 回到步骤 1,取下一个宏任务。
console.log("1 - 同步");
setTimeout(() => console.log("2 - 宏任务"), 0);
Promise.resolve() .then(() => { console.log("3 - 微任务 A"); // 在微任务执行过程中再产生一个微任务 return Promise.resolve(); }) .then(() => console.log("4 - 微任务 B"));
console.log("5 - 同步");
// 输出顺序:// 1 - 同步// 5 - 同步// 3 - 微任务 A ← 当前宏任务(script)结束,开始清空微任务队列// 4 - 微任务 B ← 微任务 A 产生的新微任务,也在这一轮被清空// 2 - 宏任务 ← 微任务队列清空后,才轮到下一个宏任务3.2 Promise
在 Promise 出现之前,JS 处理异步的主要方式是回调函数(Callback)。回调本身没有问题,但当多个异步操作存在依赖关系时,就会出现层层嵌套的回调地狱(Callback Hell),代码的逻辑流向不再是线性的,可读性和可维护性都极差:
// 回调地狱:嵌套层数随依赖深度线性增长fetchUser(userId, (user) => { fetchOrders(user.id, (orders) => { fetchOrderDetail(orders[0].id, (detail) => { render(detail, (result) => { console.log(result); // 每多一层依赖,就多一层缩进 }); }); });});ES6 引入的 Promise 是对异步操作的一层封装,代表一个尚未完成但最终会有结果的操作。Promise 有三种状态:
pending:初始状态,操作尚未完成;fulfilled:操作成功完成,持有一个结果值;rejected:操作失败,持有一个错误原因。
状态一旦从 pending 转变为 fulfilled 或 rejected,就不可再改变。
const promise = new Promise((resolve, reject) => { // executor 函数在 new Promise 时同步执行 setTimeout(() => { const success = true; if (success) { resolve("操作成功"); // 将 Promise 推向 fulfilled } else { reject(new Error("操作失败")); // 将 Promise 推向 rejected } }, 1000);});
promise .then((result) => console.log(result)) // "操作成功" .catch((err) => console.error(err));Promise 最关键的设计是链式调用。.then 每次调用都返回一个全新的 Promise,其状态取决于处理函数的返回值:
- 返回一个普通值 → 新 Promise 以该值
fulfilled; - 返回一个 Promise → 新 Promise 跟随该 Promise 的状态;
- 抛出异常 → 新 Promise 以该异常
rejected。
这个机制使得多个异步操作可以被串成一条线性的链,彻底摆脱嵌套:
fetchUser(userId) .then((user) => fetchOrders(user.id)) // 返回 Promise,链继续 .then((orders) => fetchOrderDetail(orders[0].id)) .then((detail) => { render(detail); return detail; // 返回普通值,下一个 .then 收到它 }) .then((detail) => console.log("完成:", detail)) .catch((err) => { // 链中任意一步 reject 或抛出异常,都会跳到这里 console.error("出错了:", err); });Promise 还提供了几个处理并发场景的静态方法:
// 全部成功才 resolve,任一失败则立即 reject(适合"必须都成功"的场景)Promise.all([fetch("/api/a"), fetch("/api/b")]).then(([resA, resB]) => { /* 两个都成功 */});
// 任一率先 settle(无论成功还是失败)就返回(适合取最快响应)Promise.race([fetchFromServerA(), fetchFromServerB()]).then((result) => console.log("最快的结果:", result));
// 等全部 settle 后返回所有结果(无论成败),适合"需要知道每个结果"的场景Promise.allSettled([fetch("/api/a"), fetch("/api/b")]).then((results) => { results.forEach((r) => { if (r.status === "fulfilled") console.log("成功:", r.value); else console.log("失败:", r.reason); });});ES2017 带来了 async/await,本质是 Promise 链的语法糖,让异步代码读起来像同步代码,大幅降低了理解成本:
async function loadDetail(userId) { try { const user = await fetchUser(userId); const orders = await fetchOrders(user.id); const detail = await fetchOrderDetail(orders[0].id); return detail; // 返回值自动包装为 Promise.resolve(detail) } catch (err) { // 任意一个 await 的 Promise rejected,都会跳到这里 console.error("出错了:", err); }}async 和 await 各自做了什么?先说 async:它修饰一个函数,确保该函数的返回值始终是一个 Promise。如果函数体 return 的是普通值,JS 引擎会自动用 Promise.resolve() 包装;如果函数体抛出异常,则返回一个 rejected 的 Promise:
async function foo() { return 42;}// 等价于function foo() { return Promise.resolve(42);}
async function bar() { throw new Error("oops");}// 等价于function bar() { return Promise.reject(new Error("oops"));}再说 await:它只能在 async 函数内使用(ES2022 起模块顶层也可以),做的事情有两个:
-
包装 Promise:
await后面跟的表达式会被传入Promise.resolve()。如果它本身就是一个 Promise,直接使用;如果是普通值,就包装成一个立即 fulfilled 的 Promise。也就是说,await 42和await Promise.resolve(42)是等价的。 -
交出控制权:
await会暂停当前async函数的执行,将await之后的代码注册为一个微任务(等效于.then(callback)),然后立即让出主线程的控制权,主线程继续执行调用栈中剩余的同步代码。等到当前宏任务的同步代码全部执行完毕、微任务队列被清空时,await后面的代码才会被调度执行。
结合事件循环来理解:await 后面的代码,本质上就是被拆成了 .then() 的回调,进入微任务队列。下面这个例子可以清楚地看到整个过程:
console.log("script start");
async function async1() { console.log("async1 start"); await async2(); console.log("async1 end");}
async function async2() { console.log("async2");}
Promise.resolve().then(() => { console.log("promise then1"); setTimeout(() => { console.log("promise then1 setTimeout"); });});
setTimeout(() => { console.log("settimeout");});
async1();console.log("script end");逐步拆解执行过程:
console.log('script start')→ 同步输出 script start。Promise.resolve().then(...)→ 将回调注册为微任务,进入微任务队列。setTimeout(...)→ 将回调委托给浏览器计时器线程,0ms 后回调进入宏任务队列。- 调用
async1():console.log('async1 start')→ 同步输出 async1 start。await async2()→ 调用async2(),其函数体同步执行,遇到Promise再交出控制权,输出 async2。async2()没有显式return,返回Promise.resolve(undefined)。await将async1中剩下的代码(console.log('async1 end'))注册为微任务,然后交出控制权,async1()暂停。
console.log('script end')→ 同步输出 script end。
至此,整个 <script> 宏任务的同步代码执行完毕。事件循环开始清空微任务队列,队列中当前有两个微任务:
- 步骤 2 注册的
.then()回调 - 步骤 4 中
await注册的续执行代码
微任务按 FIFO 顺序执行:
- 先执行
.then()回调 → 输出 promise then1。其中又注册了一个setTimeout,其回调进入宏任务队列。 - 再执行
await的续执行代码 → 输出 async1 end。
微任务队列清空后,事件循环进入下一轮,从宏任务队列取任务(FIFO):
- 第一个
setTimeout回调 → 输出 settimeout。 - 第二个
setTimeout回调(由微任务内部注册)→ 输出 promise then1 setTimeout。
最终输出顺序:
script startasync1 startasync2script endpromise then1async1 endsettimeoutpromise then1 setTimeout从这个例子可以清晰看到 await 交出控制权的时机——await async2() 之后的代码并没有在 async2() 返回后立即执行,而是被推迟到了当前宏任务结束后、作为微任务执行。这也印证了 async/await 本质上就是 Promise + .then() 的语法糖。