速通 TypeScript

最近在学习 Agent 的时候,我发现很多 Agent 项目都会优先选择 TypeScript,而不是 Python。继续往下看就会发现,这不是偶然。TypeScript 背后站着完整的 JavaScript 生态,天然适合写事件驱动、异步任务、工具编排、前后端联动这些东西;而 Agent 恰好又很依赖这些能力。所以我准备认真补一下 TypeScript。

在 AI 时代,入门一门语言确实比以前快得多。很多基础内容,可能 2 到 3 天就能过完,甚至已经能开始写小项目了。但真正想把它用顺手,还是离不开反复练习。我自己也有过这种体验:之前速通过 Java 和 Go,当时感觉学得很快,可一旦换项目,一个月不碰,很多细节就又模糊了。看来还是得写笔记,而且最好是带例子的笔记,这样以后回头看时能更快把感觉找回来。

速通的方法论

这其实是我这几次“速通语言”里最大的收获:学习一门语言时,不要一开始就陷进大量语法细节里,而是先抓住它的核心特性、核心设计目标,以及它最适合解决什么问题。

换句话说,先回答这几个问题:

  1. 这个语言的有哪些基本概念,这些概念都是什么? (是什么?)
  2. 这个语言的项目是如何组织的,依赖是如何管理的? 比如项目文件结构,以及包管理工具等.如何建立一个最小项目?
  3. 程序是如何构建的:程序的基本结构和程序的入口是如何设置的? (程序如何跑起来,是如何被调用的,如何开始的?)
  4. 这个语言的核心特性/核心概念是什么? 它们相应的语法和实现是什么样子的? 有哪些典型的设计模式? (比如Java的面向对象,python的脚本到底,TS的事件回调和异步编程)
  5. 这个语言最适合做什么? 以及为什么被设计出来的? (设计模式的直接延申,比如Go的现代Web后端,以及微服务架构)

从这个视角回头看,很多语言的设计其实都很一致:它们的语法不是随便长出来的,而是在服务自己的“主战场”。

比如 Python,最开始就非常偏向脚本语言和胶水语言。它解释执行、动态类型、语法松弛,写小流程、自动化脚本、数据处理、Demo 都非常舒服;但一旦项目变大,很多工程性能力就需要后补,比如类型检查、约束大型协作、模块边界等。

Java 则是典型的面向对象工程语言。接口、抽象类、类层次结构、成熟的企业级框架,这些东西让它非常适合大型后端系统。但与此同时,写小项目时也很容易显得厚重。

Go 在我看来更像是针对现代服务端场景做过取舍的一门语言。它保留了静态类型和编译型语言的工程优势,同时又尽量减少语言本身的复杂度,并且把并发、部署和性能放得很靠前。所以它非常适合现代 Web 后端,尤其是微服务和基础设施。

而 TypeScript 的核心路线也很明确:它不是重新发明一种全新的运行时,而是在 JavaScript 之上补上“工程化类型系统”,让原本灵活但容易失控的 JS,变成一门更适合大型系统协作的语言。再加上 JS/Node.js 本身就极其擅长事件驱动和异步编程,所以 TypeScript 对 Agent、前端、中后台、CLI、插件系统这类项目就会非常顺手。

至于语法细节和常见库,我现在更倾向于在写项目的过程中逐步积累,而不是一开始就死磕。先抓主线,再补枝叶,效率会高很多。

1. JavaScript / TypeScript 生态的基本概念

如果是第一次接触 TypeScript,最容易卡住的地方往往不是语法,而是生态名词太多:JavaScript、TypeScript、浏览器、运行时、Node.js、Bun、npm、pnpm、npx、ESM、包、模块,这些词经常混在一起出现。这里先把这些概念捋顺。

1.1 JavaScript 是什么,TypeScript 又是什么

最简化地说:

  1. JavaScript 是真正被执行的语言
  2. TypeScript 是 JavaScript 上面加的一层类型系统

也就是说,最终跑起来的代码还是 JavaScript。TypeScript 主要负责在编译阶段帮你做类型检查,并把带类型的代码转换成普通 JavaScript。

function add(a: number, b: number): number {
  return a + b
}

最终运行时看到的其实更像是:

function add(a, b) {
  return a + b
}

所以理解 TypeScript 时,第一个关键点是:

TypeScript 不是运行时,它是 JavaScript 的类型层。

1.2 什么叫“运行时”

“运行时”这个词很常见,但新人第一次看到时通常会比较虚。

可以先把它理解成:

运行时就是“真正负责执行 JavaScript 代码的环境”。

它至少要提供两类东西:

  1. JavaScript 引擎,用来解释或编译执行 JS
  2. 环境 API,用来做这个环境下能做的事情

例如下面这段代码:

console.log("hello")

要想让它跑起来,必须有某个环境来执行它。这个环境可能是浏览器,也可能是 Node.js,也可能是 Bun。

1.3 浏览器和运行时是什么关系

浏览器本身就是一种 JavaScript 运行时,只不过它的主场景是网页。

浏览器除了执行 JS,还会提供很多 Web API,比如:

  1. document
  2. window
  3. fetch
  4. localStorage
  5. DOM 事件系统

例如:

document.querySelector("button")?.addEventListener("click", () => {
  console.log("clicked")
})

这段代码之所以能工作,是因为浏览器运行时提供了 document 和 DOM 相关能力。换到 Node.js 里,这段代码默认就跑不了,因为 Node.js 不是浏览器运行时。

所以可以这样记:

  1. 浏览器是一个运行时
  2. 它擅长运行网页相关代码
  3. 它提供的是 Web 平台 API

1.4 Node.js 是什么

Node.js 可以理解成:

让 JavaScript 在浏览器之外也能运行的服务端 / 本地运行时。

它让 JS 可以做很多浏览器做不了或不适合做的事情,比如:

  1. 读写本地文件
  2. 启动 HTTP 服务
  3. 写 CLI 工具
  4. 跑自动化脚本
  5. 做后端服务

例如:

import { readFile } from "node:fs/promises"

const text = await readFile("note.txt", "utf-8")
console.log(text)

这里的 fs 文件系统 API 就是 Node.js 运行时提供的能力,不是 JavaScript 语言本身自带的。

所以 Node.js 不是“JavaScript 本体”,而是“JavaScript 的一个重要运行环境”。

1.5 Bun 又是什么

Bun 也是一个 JavaScript / TypeScript 运行时,但它走的是更现代、更一体化的路线。

可以先把它理解成:

Bun = 运行时 + 包管理器 + 构建工具 的组合体。

它的几个特点是:

  1. 启动很快
  2. 原生支持 TypeScript 运行
  3. 自带包管理能力
  4. 尽量兼容 Node.js 生态

例如:

bun run src/index.ts

这条命令看起来像“直接运行 TypeScript”,本质上是 Bun 把很多中间步骤帮你封装起来了。

1.6 npm、pnpm、npx 分别是什么

这几个词也是新手最容易混的。

npm

npm 是 Node.js 生态里最常见的包管理器。它主要负责:

  1. 安装依赖
  2. 记录依赖版本
  3. 执行 package.json 里的脚本
  4. 发布 npm 包

例如:

npm install lodash

或者:

npm run dev

pnpm

pnpm 也是包管理器,可以把它看成 npm 的现代替代品之一。

它的优势通常在于:

  1. 更节省磁盘空间
  2. 安装更快
  3. 依赖隔离更严格

例如:

pnpm add lodash
pnpm dev

很多现代 TS 项目都会优先使用 pnpm,但概念层面上它和 npm 属于同一类工具。

npx

npx 不是包管理器,它更像是一个“临时命令执行器”。

最常见的用途有两个:

  1. 执行本地依赖里的 CLI
  2. 临时执行一个还没全局安装的包

例如:

npx tsc --init

这里的意思是:执行当前项目里的 tsc 命令,而不是要求你先把 TypeScript 全局装到系统里。

所以这三个可以先这样区分:

  1. npm:官方包管理器
  2. pnpm:更现代的包管理器替代方案
  3. npx:命令执行器

1.7 什么是包,什么是模块

这两个词也特别容易混。

模块

模块更偏“代码组织方式”。

一个文件导出内容,另一个文件导入内容,这就是模块化。

// math.ts
export function add(a: number, b: number) {
  return a + b
}
// index.ts
import { add } from "./math.js"

console.log(add(1, 2))

这里 math.tsindex.ts 都可以看成模块。

包更偏“分发单位”或“安装单位”。

通常一个带有 package.json 的目录,就可以被当成一个包。

例如安装:

npm install lodash

这里安装的是 lodash 这个包。安装完之后,你再在代码里导入它:

import _ from "lodash"

所以可以这样理解:

  1. 模块回答的是“代码怎么拆、怎么导入导出”
  2. 包回答的是“代码作为依赖怎么发布、安装和复用”

1.8 package.json 是什么

package.json 可以理解成 Node.js / TypeScript 项目的总配置文件。

它通常负责:

  1. 声明项目名称和版本
  2. 管理依赖
  3. 定义脚本命令
  4. 记录一些模块和包相关配置

例如:

{
  "name": "ts-demo",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsc"
  },
  "devDependencies": {
    "typescript": "^5.0.0",
    "tsx": "^4.0.0"
  }
}

这个文件往往是一个项目的入口配置。以后看到一个 JS / TS 项目,先看 package.json,通常就能大概判断它怎么启动、用什么工具、依赖什么生态。

1.9 一个新手最值得先记住的关系图

可以先把这些概念粗略记成下面这样:

TypeScript:类型系统 + 编译检查
JavaScript:最终运行的语言

浏览器 / Node.js / Bun:运行时

npm / pnpm:包管理器
npx:命令执行器

模块:代码组织方式
包:依赖分发单位
package.json:项目配置中心

如果这张图先记住了,后面再学 TypeScript 项目结构、模块系统、构建流程时就不会那么乱。

2. TypeScript 项目是如何组织的

理解项目结构时,最好把“包管理”和“模块系统”分开看。包管理器负责安装依赖,模块系统负责导入导出代码。现代 TypeScript 项目通常就是在这两层之上再加一个 TypeScript 编译配置。

如果只搭一个最小可运行项目,通常会有这些文件:

ts-demo/
  package.json
  tsconfig.json
  src/
    index.ts

2.1 ESM 是什么,它和包管理有什么区别

ESM 全称是 ECMAScript Modules,它是 JavaScript 官方标准模块系统。它解决的是:

  1. 一个文件如何导出内容
  2. 另一个文件如何导入内容

典型语法就是:

// math.ts
export function add(a: number, b: number) {
  return a + b
}
// index.ts
import { add } from "./math.js"

console.log(add(1, 2))

这里的 export / import 属于模块系统,不属于包管理器。

这点一定要分清:

  1. npm install lodash 是在安装包
  2. import _ from "lodash" 是在导入模块

所以:

ESM 不是包管理系统,而是 JavaScript 的官方模块系统。

2.2 为什么还会看到 CommonJS

Node.js 在早期并不是基于 ESM 起家的,它自己先实现过一套模块系统,叫 CommonJS

CommonJS 的典型写法是:

const fs = require("fs")

module.exports = {
  hello() {
    console.log("hello")
  },
}

而 ESM 的写法则是:

import fs from "node:fs"

export function hello() {
  console.log("hello")
}

所以现在 JS 生态里你会同时看到两套写法:

  1. require / module.exports:CommonJS
  2. import / export:ESM

现代前端项目和越来越多的 TypeScript 项目都更偏向 ESM,但老项目和部分 Node.js 包里仍然会见到 CommonJS。

2.3 package.json 在项目里到底管什么

在项目结构里,package.json 主要承担三件事:

  1. 记录依赖
  2. 记录脚本命令
  3. 告诉运行时和工具如何理解这个项目

例如:

{
  "name": "ts-demo",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsc"
  },
  "devDependencies": {
    "typescript": "^5.0.0",
    "tsx": "^4.0.0"
  }
}

这里几个字段的意义可以先记住:

  1. scripts:定义命令入口,比如 npm run dev
  2. dependencies / devDependencies:记录依赖
  3. type: "module":告诉 Node.js 这个项目默认按 ESM 规则解释 .js 文件

2.4 tsconfig.json 是什么

tsconfig.json 是 TypeScript 编译器的配置文件,主要决定:

  1. TypeScript 怎么检查类型
  2. TypeScript 编译成什么样的 JavaScript
  3. 模块规则按什么模式处理

一个常见示例是:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

初学阶段最值得先记住的是:

  1. target:编译到哪个 JavaScript 版本
  2. module:模块系统按什么规则输出和解析
  3. strict:是否开启严格类型检查
  4. outDir:编译输出目录

如果是新项目,我会建议尽量把 strict 打开,这样虽然一开始报错会多一点,但后面会省掉很多模糊问题。

2.5 为什么 TypeScript 里经常写 ./math.js

这是 ESM 场景里一个特别容易让人疑惑的点。

你明明写的是 TypeScript 文件:

// math.ts
export function add(a: number, b: number) {
  return a + b
}

但导入时却常常写成:

import { add } from "./math.js"

这不是写错了,而是因为运行时最终加载的是编译后的 math.js。在 ESM 规则下,导入路径通常要和最终运行产物保持一致。

所以可以先这样理解:

  1. 你写的是 math.ts
  2. 运行时看到的是 math.js
  3. ESM 导入路径经常写 .js,是为了对齐最终运行结果

2.6 最小项目示例

先初始化项目:

npm init -y
npm install -D typescript tsx
npx tsc --init

然后把 package.jsontsconfig.json 配好,再写一个入口文件:

// src/index.ts
function greet(name: string) {
  return `Hello, ${name}`
}

console.log(greet("TypeScript"))

开发时直接运行:

npm run dev

构建时编译成 JavaScript:

npm run build
node dist/index.js

这个最小项目里其实已经把几层关系都串起来了:

  1. package.json 管依赖和脚本
  2. tsconfig.json 管 TypeScript 编译规则
  3. src/index.ts 是源码模块
  4. npm 负责执行命令
  5. tsxtsc + node 负责把代码真正跑起来

3. TypeScript 程序是怎么跑起来的

这一节重点不再讲“安装什么工具”,而是讲程序本身的结构:一个 TypeScript 程序到底从哪里开始执行、需不需要 main 函数、外部是怎么调用它的、以及函数通常会在什么情况下被触发。

如果这部分不理解,写代码时就很容易出现一种感觉:我知道语法,但不知道程序什么时候开始、什么时候结束、谁在调用谁。

3.1 先记住:TypeScript 最终跑的还是 JavaScript 文件

不管你写的是:

console.log("hello from ts")

还是:

function main() {
  console.log("hello from ts")
}

main()

真正执行它的,最终都不是 TypeScript 本身,而是某个运行时在执行 JavaScript。

最常见的流程依然是:

写 index.ts
  -> TypeScript 检查类型
  -> 编译成 index.js
  -> Node.js / Bun 执行 index.js

只是很多开发工具把这几步封装得比较好了,所以你有时会产生一种“TypeScript 被直接运行了”的错觉。

3.2 程序的入口通常在哪里

在 Java、C、Go 这类语言里,很多时候会强调一个非常明确的入口函数,比如 main()。但在 JavaScript / TypeScript 里,最常见的入口并不是“某个固定函数名”,而是:

某个被运行时直接加载执行的文件。

例如下面这个文件:

// src/index.ts
console.log("program start")

如果你执行:

tsx src/index.ts

那么 src/index.ts 就是入口文件。运行时会从这个文件的第一行开始执行。

所以对 TypeScript 来说,入口更常常是:

  1. src/index.ts
  2. src/main.ts
  3. cli.ts
  4. server.ts

也就是说,入口通常是“入口文件”,而不是固定必须叫 main 的函数。

3.3 TypeScript 需不需要 main 函数

不需要,语言层面完全没有这个强制要求。

下面这种写法就完全合法:

console.log("start")

const name = "TypeScript"
console.log(`hello, ${name}`)

只要这个文件被运行了,代码就会从上到下执行。

但是在实际项目里,很多人还是会手动写一个 main 函数,因为这样结构更清楚,尤其是当启动逻辑开始变复杂时。

例如:

async function main() {
  console.log("program start")

  const config = await loadConfig()
  console.log("config loaded:", config)
}

async function loadConfig() {
  return { port: 3000 }
}

main()

这时 main 的作用不是“语言强制规定入口”,而是“程序员自己把启动逻辑收拢到一个地方”。

所以这一点可以记成:

  1. TS 不要求必须有 main
  2. 但复杂程序里,自己写一个 main 往往会更清晰

3.4 一个脚本是怎么被“外部调用”的

所谓“外部调用”,本质上就是某个运行环境去执行你的入口文件。

这里先区分两件事:

  1. 谁来指定入口
  2. 入口确定之后,运行时再怎么继续加载后续模块

前者回答的是“程序从哪里开始”,后者回答的是“开始之后,依赖是怎么一路接进来的”。

最常见的几种方式有:

方式一:命令行直接执行入口文件

node dist/index.js

或者:

tsx src/index.ts

这时调用链大概是:

终端命令
  -> Node.js / tsx
  -> 加载入口文件
  -> 从上到下执行入口文件里的代码

这里是命令行工具在告诉运行时:“请从这个文件开始。”

方式二:通过 package.json 的脚本调用

{
  "scripts": {
    "dev": "tsx src/index.ts"
  }
}

然后执行:

npm run dev

这时并不是 npm 在执行你的 TypeScript 代码,而是:

npm
  -> 找到 scripts.dev
  -> 执行 tsx src/index.ts
  -> tsx 加载入口文件
  -> 运行入口代码

也就是说,npm run dev 更像是一个“命令别名入口”。

它并没有改变程序的本质入口,只是帮你把“该用哪个工具、从哪个文件开始”提前写进了项目配置里。

方式三:通过 HTML 把浏览器入口接进来

如果是前端项目,入口往往不是终端命令,而是 HTML。

例如:

<script type="module" src="/assets/main.js"></script>

这时真正的调用链更像是:

浏览器加载 HTML
  -> 发现 module script
  -> 请求 main.js
  -> 再按 import 继续加载后续模块

所以浏览器场景下,“外部调用”通常不是某条命令,而是页面把入口模块声明出来。

方式四:被其他模块导入

除了“整个程序被启动”,还有一种常见情况是某个文件并不是程序入口,而只是被别的文件导入。

例如:

// math.ts
export function add(a: number, b: number) {
  return a + b
}
// index.ts
import { add } from "./math.js"

console.log(add(1, 2))

这时:

  1. index.ts 是入口
  2. math.ts 不是入口
  3. math.ts 是在 index.ts 运行时被加载进来的

所以并不是每个 .ts 文件都会“独立运行”,很多文件只是被入口文件调用。

3.5 外部环境到底是怎么把一个 .ts 文件跑起来的

这一节真正值得理解的,不是“某个函数有没有被调用”,而是:

外部环境到底是怎么找到你的入口、加载它依赖的模块,并把运行所需的能力注入进来的。

因为对 TypeScript 来说,外部环境通常并不是“拿到一个函数名然后直接调一下”。更常见的情况是:

  1. 先确定入口文件
  2. 再加载这个入口依赖的模块图
  3. 然后把运行时提供的能力接进来
  4. 最后程序才开始注册事件、发请求、等待回调

所以一个 .ts 文件真正“跑起来”的过程,更像是“被某个运行时接管并装配”,而不是“被简单调用”。

先记住一个前提:浏览器并不认识 TypeScript

浏览器原生能执行的是 JavaScript,不是 TypeScript。

所以如果我们在写前端 TS 项目,通常真实发生的是:

  1. 你写的是 .ts
  2. 构建工具先把它转成 .js
  3. 浏览器再去加载这些 .js 模块

也就是说,浏览器并不是直接执行 main.ts,而是在执行构建后的入口文件,例如:

<script type="module" src="/assets/main.js"></script>

这里真正重要的不是“main.js 里哪个函数先调用”,而是浏览器看到这行 HTML 之后,会开始一整套模块加载流程。

浏览器场景下,入口通常是怎样被发现的

最常见的起点其实是 HTML。

例如:

<!doctype html>
<html>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

对浏览器来说,这里的意思大概是:

  1. 先解析 HTML
  2. 看到一个 type="module" 的脚本
  3. 知道这不是传统脚本,而是 ESM 入口模块
  4. 去请求这个模块文件
  5. 解析里面的 import
  6. 继续递归下载它依赖的其他模块
  7. 等整个模块图准备好之后,再开始执行

也就是说,浏览器真正调用的不是“你写的某个业务函数”,而是:

HTML
  -> script type="module"
  -> 入口模块 main.js
  -> import 出来的其他模块
  -> 模块初始化代码开始执行

这也是为什么前端里经常说“入口文件”而不是“main 函数”。浏览器更关心的是模块入口,不是函数入口。

浏览器里的“依赖注入”很多时候不是参数注入,而是环境注入

很多后端或框架语境里,一说“依赖注入”,容易想到:

  1. 容器创建对象
  2. 把依赖作为构造函数参数传进去
  3. 再把实例交给你使用

但在浏览器场景下,更基础、也更常见的一层“注入”其实是环境注入。

也就是:浏览器在执行模块之前,先把这个模块默认可用的运行时能力准备好。比如:

  1. window
  2. document
  3. location
  4. history
  5. fetch
  6. localStorage
  7. 事件循环、定时器、DOM 事件系统

所以像下面这种代码:

const button = document.querySelector("button")

button?.addEventListener("click", () => {
  console.log(window.location.href)
})

你并没有手动把 documentwindow 传进去,但代码依然能直接用。原因不是 TypeScript 做了什么魔法,而是:

  1. 浏览器运行时先创建了全局对象
  2. 这些对象挂着 DOM 和 Web API 能力
  3. 模块代码运行时,可以直接从全局环境访问它们

从这个角度看,浏览器的第一层“依赖注入”其实是:

运行时把 Web 平台能力提前放进全局执行环境里。

这也是很多人第一次学前端时会觉得“有点烦”的原因:这些能力确实带一点隐式感。你没有 import 它们,也没有手动传参,但它们就是存在。

这和 Python 的感觉为什么不太一样

这里很容易产生一个直觉:

好像不像 Python,JS/TS 的模块和环境更依赖运行时现场解析。

这个直觉有一部分是对的,但最好稍微修正一下。

Python 的 import 本身也是运行时行为,并不是说 Python 完全不是运行时解析。真正的区别更多在于:

  1. Python 的模块查找路径通常更集中,主要围绕解释器环境、包目录、sys.path
  2. 浏览器里的 JS 模块解析强烈依赖宿主环境规则,比如 URL、HTML、script type="module"import map
  3. TypeScript 还额外多了一层“源码是 .ts,真正执行的是 .js”的转换过程

所以更准确的说法不是“Python 不是运行时解析,JS/TS 才是”,而是:

JS/TS 更明显地依赖宿主环境来决定模块如何落地,所以会让人感觉规则更隐式。

尤其在浏览器里,这种“隐式感”会更强,因为浏览器除了加载模块,还默认提供了一大批全局能力。

模块依赖又是怎么“注入”进来的

除了 windowdocument 这种环境能力,还有另一类依赖是模块依赖,也就是 import 进来的内容。

例如:

import { createApp } from "./app.js"
import { api } from "./api.js"

createApp({ api })

这里的 createAppapi 并不是浏览器全局自带的,它们来自模块系统。

浏览器或打包工具大致会按下面的流程处理:

  1. 加载入口模块 main.js
  2. 看到它依赖 ./app.js./api.js
  3. 继续请求并解析这两个模块
  4. 执行这些模块的顶层代码
  5. 建立 exportimport 的绑定关系
  6. 等依赖就绪后,再执行当前模块

所以这里所谓的“注入”,本质上不是框架偷偷给你塞参数,而是:

模块加载器先把依赖模块解析好,再把它们的导出绑定到当前模块可用的名字上。

这和普通函数调用非常不一样。普通函数调用是:

我现在要这个值
  -> 你把参数传给我

而模块注入更像是:

先把整个依赖图装配好
  -> 再开始执行当前模块

浏览器到底怎么知道裸模块名该去哪里找

如果你写的是:

import { createApp } from "vue"

这里的 "vue" 并不是一个浏览器天然知道的 URL。

所以现实里通常有两种办法:

第一种:靠构建工具提前改写

像 Vite、Webpack、Rspack 这类工具,会在开发或构建阶段接管模块解析。

它们会做几件事:

  1. 读你的源码
  2. 发现 import { createApp } from "vue"
  3. node_modules 里找到真实包位置
  4. 在开发时把它映射成浏览器能请求的地址
  5. 在生产构建时把它打进 bundle,或者拆成浏览器可加载的 chunk

所以浏览器看到的往往已经不是源码里的 "vue",而是工具处理后的结果。

这也是前端工程化里特别关键的一点:

浏览器负责执行模块,但“模块路径如何解析”这件事,很多时候是构建工具先替浏览器做好了。

第二种:靠 import map 显式声明

浏览器也支持一种更接近原生的方式,叫 import map

例如:

<script type="importmap">
{
  "imports": {
    "vue": "/vendor/vue.runtime.esm-browser.js"
  }
}
</script>

这样浏览器在看到:

import { createApp } from "vue"

时,就知道应该去 /vendor/vue.runtime.esm-browser.js 加载对应模块。

所以如果把问题说得更本质一点,浏览器并不是“认识 npm 包”,而是:

  1. 认识 URL
  2. 认识 ESM
  3. 可以在 import map 或构建工具帮助下,把模块名翻译成 URL

这也是为什么前端项目里你经常会感觉:代码表面上只是写了一个 import,但背后真正参与工作的东西其实有很多,像 HTML、开发服务器、打包器、路径重写规则、浏览器模块加载器,都会一起参与。

如果这些注入是隐式的,那我该怎么建立边界感

这是很关键的问题。因为前端环境不是不能学清楚,而是不能把所有东西都混在一起记。

一个很实用的做法是,把代码里能拿到的东西先分成三类:

  1. 你自己定义的局部变量、函数参数
  2. 通过 import 拿到的模块依赖
  3. 宿主环境直接提供的全局能力

例如下面这段代码:

import { fetchUser } from "./api.js"

const button = document.querySelector("button")

button?.addEventListener("click", async () => {
  const user = await fetchUser()
  console.log(user, window.location.href)
})

这里可以这样拆:

  1. fetchUser 是模块依赖
  2. buttonuser 是局部变量
  3. documentwindow 是浏览器环境注入的全局能力

这样一拆,边界就会清楚很多。

再记一个很好用的小判断法:

一个名字如果不是局部变量、不是函数参数、不是 import 进来的,那它大概率就是宿主环境给你的东西。

当然这条规则不是 100% 绝对,因为还可能来自 globalThis、第三方脚本或框架注入,但对初学阶段已经非常够用了。

TypeScript 其实也在帮你把这种隐式能力“显式化”

虽然浏览器给的全局能力看起来是隐式的,但 TypeScript 并不是完全放任不管。

比如你在前端项目里能写:

document.title = "hello"
fetch("/api/user")

TypeScript 之所以知道这些名字存在、也知道它们的方法签名,背后依赖的是浏览器环境对应的类型声明,最典型的就是 DOM 相关声明文件。

也就是说:

  1. 运行时负责真正提供 documentfetch
  2. TypeScript 负责在编译期告诉你“这些东西长什么样”

所以它并不是把隐式问题彻底消灭了,而是至少帮你把“这东西可不可用、接口长什么样”提前标出来。

浏览器环境里,一段前端代码真正启动时通常发生了什么

可以把它想成下面这条链路:

用户打开页面
  -> 浏览器请求 HTML
  -> HTML 里声明 module script
  -> 浏览器请求入口 JS 模块
  -> 继续递归请求它 import 的其他模块
  -> 浏览器提供 window / document / fetch 等运行时能力
  -> 模块顶层代码执行
  -> 程序开始挂载 UI、注册事件、发起请求
  -> 后续再由点击、输入、网络响应等事件继续驱动

这里最关键的分界线是:

  1. “入口和依赖被装配起来”是模块加载阶段
  2. “点击按钮后触发回调”是程序开始运行后的事件阶段

很多初学者会把这两件事混在一起,但其实它们不是同一层问题。

Node.js 场景其实也是同一个思路,只是注入的能力不同

如果换到 Node.js,整体模式并没有变,只是运行时给你的不是 DOM,而是另一套能力。

例如:

  1. process
  2. fs
  3. path
  4. http
  5. 网络、文件系统、进程相关 API

所以 Node.js 下的“外部环境调用 TS/JS 文件”,也可以理解成:

  1. 命令行或脚本工具指定入口
  2. Node.js 加载入口模块
  3. 解析并装配依赖模块
  4. 把 Node 运行时能力提供给代码
  5. 程序再去监听端口、处理请求或执行任务

只是浏览器注入的是 Web API,Node.js 注入的是服务端 API。

这一节最想记住的一句话

TypeScript 文件并不是被外部世界“直接调一个函数”这么简单地启动起来的。

更准确的理解应该是:

外部环境先确定入口模块,再装配依赖图,并把运行时能力注入执行环境里;等这些东西都就位之后,程序才真正开始运行。

浏览器里最典型的入口是 HTML 里的 script type="module",最典型的注入是:

  1. 模块系统注入 import 进来的依赖
  2. 浏览器运行时注入 windowdocumentfetch 这类 Web API

理解了这一层,再回头看事件回调、框架挂载、前端构建流程,就会顺很多。

3.6 一个 TypeScript 程序常见的结构长什么样

如果是一个小型脚本,结构可能非常简单:

function main() {
  console.log("run script")
}

main()

如果是一个稍微正式一点的项目,通常最好不要把“环境相关的东西”和“业务逻辑”混在一起,而是拆成几层:

src/
  main.ts       <- 入口文件,负责接住外部环境 (有时候也是用index.ts命名)
  app.ts        <- 启动逻辑 / 应用装配
  config.ts     <- 配置读取
  api.ts        <- 对外部接口的访问
  service.ts    <- 业务逻辑
  utils.ts      <- 通用工具

例如:

// main.ts
import { startApp } from "./app.js"

startApp()
// app.ts
import { loadConfig } from "./config.js"
import { createUserService } from "./service.js"

export function startApp() {
  const config = loadConfig()
  const userService = createUserService(config)
  console.log("start with", userService)
}

这里:

  1. main.ts 是入口,负责被外部环境拉起
  2. app.ts 负责把各个模块装配起来,启动流程
  3. api.ts 这类模块更靠近环境或外部系统
  4. config.ts 负责提供配置
  5. service.ts 更适合放相对稳定的业务逻辑
  6. 其他模块被入口逐层串起来

这种分层的一个直接好处是:当你脑子里已经接受“程序先被环境启动,再被模块系统装配”这个事实之后,代码结构也会更自然地朝这个方向长出来。

也就是:

  1. 入口层负责接住外部世界
  2. 装配层负责组织依赖
  3. 业务层负责真正的业务规则

这类分层会比把所有代码都塞进一个文件更容易维护。

3.7 程序启动后会不会“一直在跑”

这取决于程序里还有没有未完成的工作。

例如这个脚本通常执行完就结束:

console.log("hello")

因为打印完之后就没别的任务了。

但如果程序注册了事件监听、开启了 HTTP 服务、等待定时器或网络连接,它就会继续运行:

setInterval(() => {
  console.log("tick")
}, 1000)

或者:

server.listen(3000)

所以对很多 Node.js / TypeScript 程序来说,启动之后并不是“跑完立即退出”,而是进入一种“等待事件发生”的状态。

这也是为什么 JS / TS 特别适合:

  1. 前端交互
  2. HTTP 服务
  3. 实时通信
  4. Agent 编排和工具调度

因为这些场景都不是“算完就结束”,而是“启动后持续等待外部事件”。

3.8 这一节最值得先记住的结论

如果只提炼几个最重要的结论,我觉得是下面这些:

  1. TypeScript 程序真正的入口通常是“入口文件”,而不是强制固定名字的 main
  2. main() 可以自己写,但它是组织代码的习惯,不是语言硬性要求
  3. 外部通常通过命令、脚本、框架或事件系统来触发你的代码
  4. 函数既可能是你手动调用的,也可能是由事件、Promise、请求、框架回调触发的
  5. 很多 JS / TS 程序启动后会进入“等待事件”的状态,而不是立刻结束

4. TypeScript 最核心的能力以及典型语法是什么?(重点)

如果只让我选两个关键词,我会选:

  1. 类型系统
  2. 异步与事件驱动

通俗地讲,TS 的类型系统非常贴近网络程序和工程项目的组织方式。很多时候,它就像是在给 JSON、配置对象、请求体、返回值这些数据结构写一份“类型模板”。这一点和 Python 里的 Pydantic 确实有点像,但 TS 的这套能力要更原生一些,因为它本来就是语言的一部分,而不是后面额外补上的工具层。语法上也通常更轻、更灵活。

至于异步和事件系统,我觉得这几乎就是 TS 的核心灵魂。它之所以在前端、Node.js、Agent、插件系统这些场景里特别顺手,本质上就是因为这些程序不是“算完就结束”的程序,而是要持续接收输入、等待事件、处理回调、串联异步流程的程序。浏览器天然就是这样一个环境,所以 JavaScript / TypeScript 从一开始就在围绕这类问题生长。

这里尤其值得强调的一点是:TS 的异步系统,核心并不是 async/await 这几个语法,而是 Promiseasync/await 更像是 Promise 之上的一种更容易写、更接近同步代码的表达方式;真正把“未来才会得到的结果”包装起来、让它可以被等待、传递、组合和串联的,是 Promise 这一层抽象。

Python 当然也有异步能力,而且在 Web API、并发 IO、协程任务这些场景里完全够用。但它的异步模型给人的感觉,通常更像是“一个可以暂停和恢复的协程函数”,调度核心更多放在外部的 event loop 上,而协程/异步函数本身缺乏对流程的控制能力。而 JS / TS 这边,很多异步流程天然就和 Promise、回调、事件循环绑在一起,所以它对“某件事完成之后接着做什么”“多个异步步骤如何串起来”这类问题的表达会更强一些(面向对象设计)。

如果用更直观的话来说,Python 的思路更像是“我先把这一段可暂停的流程写出来,再交给调度器运行”;而 TS 的思路更像是“我先把一个个异步片段、回调片段、事件片段写好,再把它们串成一条完整的事件链或流程链”。这也是为什么 JS / TS 很适合处理那种事件很多、状态变化很多、前后动作需要不断衔接的程序。

比如在 FastAPI 这种场景里,你经常只需要把每个请求处理函数各自写好,至于请求之间如何并发,很多时候并不需要你特别细地去关心。但如果换成浏览器交互、复杂前端状态、插件回调系统,或者 Agent 工具编排这种场景,程序往往不是一条单纯的顺序流程,而是很多事件彼此触发、很多异步步骤彼此衔接。在这种环境里,TS 的 Promise + 回调 + 事件循环模型会显得特别自然。

从编程思维上说,这也是 Python 和 TS 给人的一种天然差异。Python 更容易让人以“顺序流程”的方式去组织程序;而 TS 尤其是在浏览器和 Node.js 生态里,更容易让人以“事件片段”“异步片段”“回调片段”的方式去组织程序。你不一定先知道所有代码会按什么绝对顺序执行,但你会知道:

  1. 某个事件发生时要做什么
  2. 某个 Promise 完成后要接着做什么
  3. 某个状态变化之后要触发什么

然后再把这些片段组合成完整流程。

这也是为什么在 TS 里,函数经常不只是“一个立即执行的步骤”,而更像是“一个等待被挂接到某个时机上的能力单元”。回调、闭包、Promise 链、事件监听,本质上都在做这件事。也正因为如此,TS 在处理复杂交互流程时,会比单纯的顺序脚本模型更自然一些。

如果把两种语言的异步模型粗略对比一下,可以先这样理解:

  1. Python 更强调流程的执行.
  2. JS / TS 更强调“异步结果如何被包装和继续传递”(面向对象,函数也作为对象)
  3. Python 的 await 常常让你关注“当前协程在等什么”
  4. JS / TS 的 Promise 常常让你关注“这件事完成之后,整条链路怎么往下走”

当然,这只是理解上的侧重点,并不是说某一边绝对更强,而是它们天然更适合的问题形状不同。

TS 的运行时里还有一个很重要、但初学时容易忽略的点:事件循环里其实不只是一条简单队列,而是至少可以粗略分成两层来理解。

  1. 宏任务队列
  2. 微任务队列

可以先把它们理解成:

  1. 宏任务负责承载“下一轮事件”,比如 setTimeout、UI 事件、I/O 回调
  2. 微任务负责承载“当前轮次里需要尽快接上的后续步骤”,比如 Promise 的 .then()catch()finally()

一个很经典的例子是:

console.log("A")

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

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

console.log("D")

它的输出通常是:

A
D
C
B

原因是:

  1. 先执行当前同步代码,所以先打印 AD
  2. Promise.then(...) 会进入微任务队列
  3. setTimeout(...) 会进入宏任务队列
  4. 当前这轮同步代码结束后,先清空微任务,再进入下一轮宏任务

所以 Promise 在 JS / TS 里之所以显得特别“顺”,并不只是语法写得漂亮,而是因为它刚好被放在了比普通宏任务更靠前的那一层调度里。这使得很多“上一步完成,马上接下一步”的异步流程,可以表现得非常连贯。

也正因为有了这两层队列,JS / TS 的异步系统并不是简单的“谁先写谁先跑”,而是“不同来源的任务,进入不同层级的调度队列”。理解这一点之后,再看 Promise、事件回调、定时器、DOM 事件之间的执行顺序,就会清楚很多。

4.1 Promise 是 TS 异步系统的核心语法

如果只看表面语法,很多人会以为 TS / JS 的异步核心是 async/await。但更准确地说,真正的核心其实是 Promiseasync/await 很重要,但它更像是 Promise 之上的语法糖;底层真正负责表达“未来会完成的结果”的,是 Promise 对象本身。

先看一个最小的 Promise 例子:

function readConfig(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => resolve("config loaded"), 1000)
  })
}

readConfig().then((config) => {
  console.log(config)
})

这里有几层意思:

  1. readConfig() 并不是立刻返回字符串
  2. 它先返回一个 Promise<string>
  3. 真正的结果要等异步任务完成之后,才能通过 .then(...) 拿到

所以 Promise 本质上就是:

把“未来才会得到的值”先包装成一个对象,让它可以被传递、组合、等待和继续处理。

这也是它比传统回调更强的地方。回调更像是“把下一步操作塞进去”;Promise 更像是“把异步结果本身抽象成一个可组合的值”。

Promise 的典型语法

最常见的 Promise 语法主要有下面几种:

  1. new Promise(...):手动创建 Promise
  2. .then(...):上一步成功后继续处理
  3. .catch(...):捕获错误
  4. .finally(...):无论成功失败都执行
  5. Promise.resolve(...) / Promise.reject(...):快速创建已完成的 Promise
  6. Promise.all(...):并发等待多个 Promise
  7. Promise.race(...):谁先完成就采用谁的结果

例如:

readConfig()
  .then((config) => {
    console.log("config:", config)
    return config.toUpperCase()
  })
  .then((value) => {
    console.log("upper:", value)
  })
  .catch((error) => {
    console.error("failed:", error)
  })
  .finally(() => {
    console.log("done")
  })

这段代码的意思其实非常自然:

  1. 先等配置加载完成
  2. 再处理结果
  3. 如果中途出错,就统一进入 catch
  4. 最后不管成功失败,都执行 finally

async/await 是更容易读的 Promise 写法

上面的写法虽然已经比回调清晰,但链条一长还是会显得有点绕。所以现代 TS 里更常见的写法是:

async function bootstrap() {
  try {
    const config = await readConfig()
    console.log(config)
  } catch (error) {
    console.error(error)
  } finally {
    console.log("done")
  }
}

这里一定要记住:await 不是脱离 Promise 独立存在的东西,它本质上就是“等待一个 Promise 完成,并取出它的结果”。

也就是说:

  1. Promise 是模型
  2. then/catch/finally 是直接操作这个模型的方式
  3. async/await 是更接近同步风格的另一种写法

Python 异步的典型语法

为了比较两边的思路,可以先看一个 Python 的典型异步写法:

import asyncio

async def read_config():
    await asyncio.sleep(1)
    return "config loaded"

async def main():
    config = await read_config()
    print(config)

asyncio.run(main())

这里的重点是:

  1. read_config 是协程函数
  2. await 表示当前协程在等待另一个可等待对象
  3. 最后要通过 asyncio.run(...) 把整个协程跑起来

如果把它和 TS 对照着看,会发现两边虽然都写 await,但关注点不完全一样。

Python 更容易让人觉得自己在写“可以暂停和恢复的函数流程”;而 TS / JS 更容易让人觉得自己在写“由 Promise 串起来的一系列异步结果处理步骤”。

Promise 的设计模式:它更像“异步结果对象”

从设计模式的角度看,Python 的异步更偏“协程 + 调度器”的思路;而 TS / JS 的异步更偏“Promise 对象 + 事件循环 + 回调衔接”的思路。

换句话说:

  1. Python 更像是在描述“这一段流程如何暂停和恢复”
  2. TS / JS 更像是在描述“一个异步结果对象完成后,后面要挂接哪些处理逻辑”

这也是为什么在 TS 里,你会很自然地把很多步骤写成:

  1. 先返回一个 Promise
  2. 再用 .then(...)await 把后续步骤接上
  3. 最后再把多个 Promise 组合成更大的流程

比如并发执行多个异步任务:

async function main() {
  const [user, settings] = await Promise.all([
    Promise.resolve({ id: 1, name: "Alice" }),
    Promise.resolve({ theme: "dark" }),
  ])

  console.log(user.name, settings.theme)
}

这在 Agent 场景里几乎是高频操作,比如并发查多个工具、并发读取多个文件、并发请求多个 API。

Promise 常见语法疑问

初看 Promise 语法时,最容易让人疑惑的通常是这一段:

new Promise((resolve, reject) => {
  // ...
})

这里的 (resolve, reject) => { ... } 本质上是一个箭头函数,也就是 JavaScript 里的 arrow function。它和很多语言里的 lambda 很像,所以可以类比理解成 lambda,但在 JS / TS 语境里更标准的叫法还是“箭头函数”。

而这个箭头函数在 Promise 这里还有一个更具体的名字,通常叫:

  1. Promise executor
  2. executor function

也就是“Promise 的执行器函数”。

这里的 resolvereject 不是你手动预先定义好的变量,而是 Promise 构造器在内部调用这个 executor 时,自动传进来的两个函数:

  1. resolve(value):把 Promise 标记为成功,并给出结果
  2. reject(error):把 Promise 标记为失败,并给出错误

例如:

const p = new Promise((resolve, reject) => {
  resolve("ok")
})

这里的意思就是:这个 Promise 成功完成了,结果是 "ok"

虽然我们通常都写 resolvereject,但它们本质上只是形参名,所以名字可以改:

const p = new Promise((success, fail) => {
  success("ok")
})

这在语法上完全合法。只是一般不建议这么写,因为 resolve/reject 已经是最标准、最清晰的命名。

不过要注意:你可以改“名字”,但不能改“角色”。也就是说,这两个位置本质上仍然是:

  1. 成功回调
  2. 失败回调

不是任意普通参数。

那 executor 还能不能接受别的参数?严格来说,运行时真正会传进来的只有这两个。你当然可以额外多写几个形参:

new Promise((resolve, reject, extra) => {
  console.log(extra)
})

但这里的 extra 并不会被 Promise 构造器提供,通常就是 undefined。所以更准确地说:

  1. executor 可以声明更多参数
  2. 但 Promise 构造器只会给前两个位置传值
  3. 后面的参数没有实际意义

另外还有一个细节也很重要:executor 的返回值本身是无效的。

例如:

new Promise((resolve, reject) => {
  return 123
})

这里的 return 123 不会成为这个 Promise 的结果。Promise 的状态主要由下面三件事决定:

  1. 你是否调用了 resolve(...)
  2. 你是否调用了 reject(...)
  3. executor 里是否直接抛出了异常

例如:

new Promise((resolve, reject) => {
  throw new Error("fail")
})

这会等价于把 Promise 标记成失败。

最后还要区分 new Promise(...)Promise.resolve(...)

new Promise(...) 的意思是:

我要手动创建一个 Promise,并自己控制它什么时候成功、什么时候失败。

例如:

const p = new Promise((resolve) => {
  setTimeout(() => {
    resolve("done")
  }, 1000)
})

这里适合包裹真正的异步过程。

Promise.resolve(...) 的意思是:

我已经有一个结果了,只是想把它直接包装成一个成功状态的 Promise。

例如:

const p = Promise.resolve("done")

所以可以粗略记成:

  1. new Promise(...):自己控制完成时机
  2. Promise.resolve(value):直接得到一个已成功的 Promise
  3. Promise.reject(error):直接得到一个已失败的 Promise

如果只是下面这种写法:

const a = new Promise((resolve) => {
  resolve("ok")
})

const b = Promise.resolve("ok")

那它们的效果很接近;只是第二种更简洁。真正需要 new Promise(...) 的场景,通常是你要把 setTimeout、回调式 API、事件监听之类的东西手动包装成 Promise。

JS宽泛的函数参数

这里顺便还能看出 JavaScript 函数系统一个很典型的特征:它对参数个数非常宽松。

例如:

function foo(a, b) {
  console.log(a, b)
}

foo(1)         // 1 undefined
foo(1, 2)      // 1 2
foo(1, 2, 3)   // 1 2

也就是说,在 JavaScript 运行时里:

  1. 少传参数,缺的位置通常就是 undefined
  2. 多传参数,多出来的部分通常会被忽略
  3. 不会因为“参数数目不匹配”直接报错

这也是为什么 Promise executor 可以写成下面这样:

new Promise((resolve) => {
  resolve("ok")
})

虽然运行时实际会传两个参数 resolvereject,但这个箭头函数只声明接收一个参数也完全没问题,第二个参数只是没有被接住。

反过来,即便你多写参数:

new Promise((resolve, reject, extra) => {
  console.log(extra) // undefined
})

语法上也没问题,只是第三个参数并不会真的被 Promise 构造器提供。

所以更准确地说:

  1. JavaScript 的函数调用规则本身很宽松
  2. Promise executor 只是刚好建立在这种宽松规则之上
  3. TypeScript 则会在编译期尽量把这种宽松收紧

也就是说,JS 运行时允许你“参数数目不严格匹配”,而 TS 的一个重要价值,就是在工程层面帮你把这种灵活性变得可控。

Promise 的状态系统

Promise 最核心的设计之一,就是它不是一个“普通返回值”,而是一个带有明确状态的对象。

一个 Promise 只有三种状态:

  1. pending
  2. fulfilled
  3. rejected

它们的含义分别是:

  1. pending:还没有完成,结果暂时未知
  2. fulfilled:已经成功完成,并拿到了一个结果值
  3. rejected:已经失败完成,并拿到了一个错误原因

例如:

const p = new Promise((resolve) => {
  setTimeout(() => resolve("done"), 1000)
})

刚创建时,ppending
1 秒后调用 resolve("done"),它就会变成 fulfilled

如果换成:

const p = new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error("fail")), 1000)
})

那它会先是 pending,之后变成 rejected

这里最重要的性质是:

Promise 一旦从 pending 变成 fulfilledrejected,状态就固定了,之后不能再改。

例如:

const p = new Promise((resolve, reject) => {
  resolve("ok")
  reject(new Error("fail"))
})

最终它仍然会保持 fulfilled,因为第一次状态变化已经把它定下来了,后面的状态修改会被忽略。

这意味着 Promise 非常适合表示“一个异步任务的最终结论”。因为它天然满足:

  1. 任务开始时结果未知
  2. 任务结束后只会有一种最终结果
  3. 结果一旦确定,就不会反复变化

这就是 Promise 能稳定组合的基础。

例如:

fetchData()
  .then(handleData)
  .catch(handleError)
  .finally(cleanup)

这背后其实就是在利用 Promise 的状态系统:

  1. 如果 Promise 从 pending 走到 fulfilled,就进入 .then(...)
  2. 如果它从 pending 走到 rejected,就进入 .catch(...)
  3. 不管最后是哪种结果,都会走 .finally(...)

再比如:

const result = await Promise.all([
  readUser(),
  readSettings(),
  readTasks(),
])

这里 Promise.all(...) 能成立,本质上也是因为每个 Promise 都有明确状态:

  1. 所有 Promise 都 fulfilled,整体才会 fulfilled
  2. 只要有一个 Promise rejected,整体就会 rejected

所以 Promise 最厉害的地方,不只是“可以异步”,而是:

它把异步任务抽象成了一个状态明确、可传递、可组合、可观察的对象。

从设计模式的角度看,这和 Python 协程那种“把函数暂停、恢复、继续调度”的思路也不完全一样。Promise 更强调的是:先把异步结果对象化,然后围绕这个对象的状态变化去继续组织后续流程。这也是为什么它特别适合拿来拼接长链条的事件流程和异步流程。

4.2 事件系统和事件循环决定了 TS 程序如何真正运行

如果说 Promise 决定了“异步结果怎么表达”,那事件系统决定的就是“程序何时被触发、何时继续往下走”。

前端和 Node.js 程序之所以很少是“跑完就结束”,就是因为它们通常都挂在某种事件系统上。比如:

  1. 浏览器点击事件
  2. 网络请求返回
  3. 定时器触发
  4. 文件读取完成
  5. HTTP 请求到达
  6. 插件钩子被触发

例如浏览器里的事件监听:

button.addEventListener("click", () => {
  console.log("clicked")
})

这段代码在注册监听器的时候,并不会立刻执行回调。它做的事情其实是:

  1. 把回调函数挂到某个事件源上
  2. 等待 click 事件真的发生
  3. 由运行时在合适的时机调用它

所以 TS / JS 的很多代码,本质上都不是“现在执行”,而是“先注册,等时机到了再执行”。

什么是事件循环

为了让这些事件、回调、Promise 后续步骤有序执行,运行时会维护一套调度机制,这就是事件循环。

可以非常粗略地把它理解成下面这件事:

执行当前同步代码
  -> 看看有没有需要先处理的微任务
  -> 清空当前轮次的微任务
  -> 再取出下一轮宏任务
  -> 重复这个过程

也就是说,JS / TS 不是“开很多线程同时乱跑”的直观模型,而更像是“主线程 + 事件循环 + 多种任务队列”的模型。

宏任务和微任务的更准确理解

初学时经常只会记一句“Promise 比 setTimeout 先执行”,但这还不够。更准确地说:

宏任务

宏任务可以理解成“一轮事件循环要处理的主要任务单元”。常见的宏任务来源包括:

  1. 整体脚本执行
  2. setTimeout
  3. setInterval
  4. DOM 事件回调
  5. I/O 回调
  6. HTTP 请求到达后的处理

可以把它理解成“下一轮要处理的大事件”。

微任务

微任务可以理解成“当前宏任务结束后、进入下一轮宏任务之前,必须先清空的一批后续任务”。常见的微任务来源包括:

  1. Promise.then(...)
  2. Promise.catch(...)
  3. Promise.finally(...)
  4. queueMicrotask(...)
  5. MutationObserver 回调

可以把它理解成“当前这轮任务内部衍生出来、优先级更高的收尾步骤”。

调度和执行顺序

一个很实用的粗略规则是:

  1. 先执行当前宏任务里的同步代码
  2. 当前宏任务结束后,立刻清空微任务队列
  3. 微任务清空后,才会进入下一轮宏任务
  4. 新的宏任务执行过程中如果又产生新的微任务,还是要在本轮结束后先清空

所以宏任务和微任务的关系不是简单的“两个平行队列”,而更像是:

每执行完一个宏任务,都要先把本轮积累出来的微任务全部处理掉,然后才有资格进入下一轮宏任务。

典型例子:Promise 和 setTimeout 的顺序

console.log("A")

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

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

console.log("D")

输出通常是:

A
D
C
B

原因是:

  1. 整段脚本本身就是第一个宏任务
  2. 先执行同步代码,打印 AD
  3. Promise.then(...) 进入微任务队列
  4. setTimeout(...) 的回调进入后续宏任务队列
  5. 当前宏任务结束后,先清空微任务,所以先打印 C
  6. 然后进入下一轮宏任务,才打印 B

一个宏任务内部,Promise 和 .then(...) 的顺序

再看一个更容易混淆的例子:

console.log("start")

Promise.resolve()
  .then(() => {
    console.log("then-1")
    return Promise.resolve()
  })
  .then(() => {
    console.log("then-2")
  })

console.log("end")

输出通常是:

start
end
then-1
then-2

这里关键在于:

  1. Promise.resolve() 先得到一个已完成的 Promise
  2. 第一个 .then(...) 的回调会进入微任务队列
  3. 当前同步代码执行完,先执行这个微任务,所以打印 then-1
  4. 第一个 .then(...) 返回的 Promise 完成后,第二个 .then(...) 才会被安排成新的微任务
  5. 然后再打印 then-2

也就是说,链式 Promise 并不是“一次性全塞进队列”,而是前一步完成之后,后一步才会继续入队。

这正是 Promise 特别适合描述“异步流程链”的原因之一。

事件系统为什么特别适合 Agent 和前端

从设计模式角度看,Python 更容易让人把程序写成“主流程 + 若干 await 点”;而 TS / JS 更容易让人把程序写成“事件源 + 回调片段 + Promise 衔接”。

这就导致它在下面这些场景里会格外自然:

  1. 前端交互
  2. 实时状态变化
  3. 插件钩子系统
  4. Agent 工具调用链
  5. 多步骤异步编排

因为这些场景的核心不是“按顺序算一遍”,而是“很多事件不断进来,很多后续动作不断被触发”。

这里最容易混淆的地方:EventEventEmitter、宏任务、微任务不是一回事

这一块初学时非常容易绕,因为里面很多词都带一个 “event” 或“事件”的味道,但它们其实不在同一个层级上。

一定要先把下面几个概念拆开:

  1. Event
  2. EventEmitter
  3. 宏任务
  4. 微任务
  5. 事件循环

它们名字看起来很像,但含义并不一样。

Event 是“发生了一件事”

最广义地说,Event 指的是某个事件本身,也就是“某件事情发生了”。

例如:

  1. 用户点击按钮
  2. 输入框内容变化
  3. 网络请求返回
  4. 文件读取完成
  5. 一个插件钩子被触发

所以 Event 更偏“事实”或“信号”本身。

EventEmitter 是“同步分发事件的机制”

EventEmitter 不是事件本身,而是一种发布-订阅机制。它的作用是:

  1. 允许别人订阅某类事件
  2. 在某个时刻统一通知这些订阅者

它更像一个“事件广播器”或“事件分发器”。

更重要的是,EventEmitter.emit() 默认是同步的。

例如:

emitter.on("task", () => {
  console.log("listener")
})

console.log("before")
emitter.emit("task")
console.log("after")

输出通常是:

before
listener
after

这说明 emit() 不是“把事情丢到下一轮宏任务”,而是在当前调用栈里立刻同步执行监听器。

所以这里一定要强调:

EventEmitter 不是宏任务队列的入口,它首先是一个同步的事件分发机制。

监听器内部当然可以再去创建异步任务,但那已经是下一层的事了。

宏任务和微任务是“调度单位”,不是事件本身

宏任务和微任务描述的是:

运行时接下来该按什么顺序执行哪些代码。

所以它们属于“调度机制”,不是“事件内容”本身。

可以先这样理解:

  1. Event 是触发源
  2. 宏任务 / 微任务是调度容器
  3. 事件循环负责一轮一轮地调度这些任务

换句话说,事件说的是“发生了什么”,任务队列说的是“这些回调什么时候执行”。

宏任务里说的“事件”,和 Event 这个词不是同一个概念

这也是最容易让人迷糊的地方。

我们有时会说:

  1. “DOM 事件回调通常按宏任务理解”
  2. “下一轮事件循环会取一个宏任务执行”

这里的“事件”已经不是严格意义上的 Event 对象定义了,而是在口语上说“外部触发源带来的那一轮处理过程”。

所以不要把下面两句话混为一谈:

  1. click 是一个浏览器事件
  2. click 触发后,对应监听器的执行会被纳入事件循环调度

前者说的是“发生了什么”,后者说的是“代码怎么被安排执行”。

一个更清楚的层级图

可以把它们的关系粗略记成:

外部世界发生一件事
  -> 运行时识别到一个 Event
  -> 找到和它绑定的监听器 / 订阅者
  -> 把相关回调纳入当前或后续的调度过程
  -> 事件循环按宏任务 / 微任务规则执行代码

这里:

  1. Event 是“事”
  2. 监听器 / 回调是“对这件事的响应代码”
  3. 宏任务 / 微任务是“响应代码如何被调度”
  4. 事件循环是“整个调度规则”

为什么会误以为 EventEmitter 是“进入宏任务”的入口

这是因为很多时候我们会看到:

  1. 某个事件被 emit
  2. 然后系统里发生了一连串后续动作

这会让人直觉上觉得,好像 emit() 把事情“扔进了事件循环”。

但更准确地说,emit() 通常只是同步把监听器调用起来。真正把后续逻辑放进未来调度的,往往是监听器内部做的事情,比如:

  1. 调用了 setTimeout(...)
  2. 创建了 Promise 并挂上 .then(...)
  3. 发起了 I/O 或网络请求

例如:

emitter.on("task", () => {
  Promise.resolve().then(() => {
    console.log("micro")
  })

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

console.log("before")
emitter.emit("task")
console.log("after")

输出通常是:

before
after
micro
macro

这里真正发生的是:

  1. emit("task") 同步执行监听器
  2. 监听器内部创建了一个微任务
  3. 也创建了一个后续宏任务
  4. 当前同步代码结束后,先跑微任务
  5. 再进入下一轮宏任务

所以应该记成:

emit() 本身是同步分发;
Promise 把后续逻辑挂进微任务;
setTimeout 把后续逻辑挂进未来宏任务。

这一小块最值得记住的结论

如果只记一句话,我觉得可以记这个:

EventEventEmitter、宏任务、微任务、事件循环是五个不同层级的概念。
前两个偏“事件本身和分发机制”,后面三个偏“代码执行的调度机制”。

把这几个词拆开之后,再回头看前端事件、Node.js 回调、Promise 链、定时器顺序,就不会那么容易混了。

把 TS / JS 里这一组相关概念系统分层

为了避免后面越学越混,我觉得最好把这一组词统一分一下层。因为很多时候真正让人困惑的,不是某个单独概念太难,而是:

运行时概念、设计模式概念、语言语法/API 概念,经常被混着说。

下面这张表可以先把我们前面讨论到的词放到一个更清楚的框架里:

名称属于哪一层它是什么更接近“概念”还是“具体语法/API”典型对应物
事件循环运行时调度层运行时安排代码执行顺序的总机制概念浏览器 event loop、Node.js event loop
宏任务运行时调度层一轮事件循环中的主要任务单元概念script、setTimeout、I/O 回调、DOM 事件回调
微任务运行时调度层当前宏任务结束后、下一轮宏任务前必须先清空的任务概念Promise.thencatchfinallyqueueMicrotask
任务队列运行时调度层存放待调度任务的队列结构概念宏任务队列、微任务队列
调用栈运行时执行层当前同步代码正在执行的栈结构概念函数调用栈
事件系统交互层某件事情发生这一事实概念click、message、request、task:done
事件源系统交互层能产生事件的对象或系统概念DOM 节点、WebSocket、HTTP server、插件系统
回调编程模型层先注册、等未来某个时机再执行的函数概念事件监听器、定时器回调、Node 风格 callback
Promise异步抽象层对“未来结果”的对象化封装API + 概念new Promise(...)Promise.resolve(...)
async/await语言语法层Promise 的更易读写法语法async functionawait value
事件总线架构/设计模式层在系统内部广播和订阅事件的抽象概念Bus、Pub/Sub、Domain Event Bus
EventEmitterNode API / 实现层一种具体的事件分发实现APIonemitonce
监听器 listenerAPI 使用层被注册到事件源上的处理函数概念 + API 用法addEventListener(fn)emitter.on(fn)
订阅/发布设计模式层一种模块解耦方式概念Pub/Sub、Observer
Observer 模式设计模式层观察者收到被观察对象变化通知概念DOM 监听、事件系统、状态订阅
Future / Deferred Result设计模式层对“未来结果”的抽象思路概念Promise、Future
定时器运行时 API 层把回调安排到未来某个时间点APIsetTimeoutsetInterval
闭包语言能力层函数连同其外部变量环境一起被保留概念 + 语言能力事件回调、Promise 回调、工厂函数

可以先用一句话粗略记成:

  1. PromiseEventEmittersetTimeout 这些是你在代码里真正会调用的 API 或语法入口
  2. 宏任务、微任务、事件循环、任务队列这些是运行时内部的调度概念
  3. 事件总线、Observer、Pub/Sub、Future 这些更偏设计模式或架构抽象

也就是说,我们平时写代码时看到的“具体名字”,很多只是某个更高层概念在某个生态里的落地形式。

例如:

  1. Promise 是“未来结果对象”这套思想在 JS 里的实现
  2. EventEmitter 是“事件分发/观察者模式”在 Node.js 里的一个典型实现
  3. setTimeout 是“把工作延后到未来调度轮次”这件事的一个 API
  4. .then(...) 则是“在 Promise 完成后挂接后续步骤”的具体接口

哪些更偏概念,哪些更偏语法/API

如果只从“学习顺序”来分,我觉得也可以简单分成两组:

第一组:概念性词汇

这些词更偏理解模型,通常不是你直接在代码里“写出来”的关键字,而是用来解释系统是怎么工作的:

  1. 事件循环
  2. 宏任务
  3. 微任务
  4. 任务队列
  5. 事件
  6. 事件源
  7. 回调
  8. 事件总线
  9. 发布-订阅
  10. Observer 模式
  11. Future / Deferred Result
  12. 调用栈
  13. 闭包

第二组:语法或 API 层面的词汇

这些东西通常是你在代码里真的会写出来、调起来、看到补全提示的:

  1. Promise
  2. Promise.resolve
  3. Promise.reject
  4. Promise.all
  5. Promise.race
  6. .then
  7. .catch
  8. .finally
  9. async
  10. await
  11. setTimeout
  12. setInterval
  13. queueMicrotask
  14. addEventListener
  15. EventEmitter
  16. on
  17. emit
  18. once

这两组之间的关系非常重要:

概念层告诉你“它本质上是什么”;
语法/API 层告诉你“在 TS / JS 里具体怎么写出来”。

再补充几个前面没集中说过、但经常一起出现的相关概念

除了前面已经重点讨论过的几个词,还有一些相关概念也值得顺手记一下:

调用栈

调用栈描述的是“当前同步代码到底执行到哪里了”。所有同步函数调用都会先进入调用栈。只有当当前调用栈清空后,事件循环才会去看微任务和下一轮宏任务。

所以它和任务队列的关系是:

  1. 调用栈负责“现在正在执行什么”
  2. 任务队列负责“接下来轮到什么”

queueMicrotask

这是一个很容易被忽略、但非常能帮助理解微任务的 API:

queueMicrotask(() => {
  console.log("micro")
})

它的作用就是显式地把一个回调放进微任务队列。它能帮助你把“微任务”这个概念和 Promise 分开,因为很多人会误以为“微任务 = Promise”,其实不是。更准确地说:

  1. Promise 的后续处理常常进入微任务
  2. 但微任务不等于 Promise

once

EventEmitter 或 DOM 事件系统里,once 用来表示“这个监听器只触发一次”。

这也是一个很有意思的点,因为它说明事件系统本来是“可多次发生”的,而 Promise 是“只完成一次”的。once 某种程度上就是把事件监听往“单次结果”方向收了一步。

addEventListener

这是浏览器侧最经典的事件监听 API。它和 Node.js 的 EventEmitter.on(...) 很像,但不是同一个实现。你可以把它们看成:

  1. 浏览器世界的典型事件订阅接口
  2. Node.js 世界的典型事件订阅接口

思想相近,但运行环境和实现细节不同。

MutationObserver

这是浏览器里一个比较典型、也比较容易被忽略的例子。它说明:

  1. Observer 不只是一个抽象设计模式
  2. 浏览器里真的有叫这个名字的 API
  3. 它的回调也会和事件循环、微任务等调度规则发生关系

所以很多抽象概念在 JS / TS 世界里,往往真的会落成具体 API。

process.nextTick(Node.js)

如果后面你还会继续深入 Node.js,这个词迟早会遇到。它和微任务队列的关系比较特殊,通常可以先简单记住:

  1. 它是 Node.js 里的一个“非常靠前”的调度机制
  2. 它和浏览器的标准微任务体系不完全一样
  3. 初学阶段先不要把它和普通 Promise 微任务完全混为一谈

这里先知道它存在就够了,后面真的深入 Node.js 再专门拆。

一个简化后的总图

如果把这一整组概念再压缩成一个最小心智模型,我觉得可以先这样记:

语言语法 / API
  -> Promise / async / await / setTimeout / EventEmitter / addEventListener

运行时机制
  -> 调用栈 / 事件循环 / 宏任务 / 微任务 / 任务队列

设计模式
  -> Observer / 发布-订阅 / Future / 事件总线

系统中的真实现象
  -> 点击、请求返回、状态变化、工具调用完成

理解这张分层图之后,再看到某个新词时,就可以先问自己:

  1. 它是在描述运行时机制吗?
  2. 它是在描述代码里的具体 API 吗?
  3. 它是在描述一种设计模式吗?
  4. 还是只是在描述系统里发生的某件事?

只要先把层级摆正,TS 里这一大组“事件 / 异步 / 调度”相关概念就不会那么容易混在一起了。

4.3 类型系统让这些异步和事件流程真正变得可维护

如果只有异步和事件,而没有类型系统,JavaScript 很容易在项目变大之后失控。真正让 TS 和原生 JS 拉开差距的,是它可以把这些复杂流程里的数据结构、模块边界和函数接口提前约束起来。

类型系统的最直接价值:把错误提前到编译期

比如这段普通 JS:

function send(user) {
  console.log(user.name.toUpperCase())
}

如果有人这样调用:

send({ nickname: "eel" })

那错误只有在运行时才会暴露。

但在 TypeScript 里:

interface User {
  name: string
}

function send(user: User) {
  console.log(user.name.toUpperCase())
}

send({ nickname: "eel" }) // 编译时报错

这就是 TypeScript 在工程上的核心价值:它不是让你“少写代码”,而是让你“少踩隐蔽坑”。

结构化类型特别适合数据建模

TypeScript 的类型系统非常强调“形状”,所以它特别适合表示:

  1. API 请求和响应
  2. 数据库记录
  3. 配置对象
  4. Agent 上下文
  5. 事件 payload
  6. 组件 props

例如:

interface Message {
  role: "user" | "assistant"
  content: string
}

function printMessage(message: Message) {
  console.log(`[${message.role}] ${message.content}`)
}

这种写法的好处是:一旦数据结构固定下来,编辑器、编译器和团队协作都会立刻变得更稳定。

泛型让工具函数和框架能力更通用

很多库之所以在 TypeScript 里体验很好,就是因为它们大量利用了泛型。

例如写一个通用的映射函数:

function map<T, R>(arr: T[], fn: (item: T) => R): R[] {
  return arr.map(fn)
}

const result = map([1, 2, 3], (x) => x * 2)

这里 TypeScript 会自动推断:

  1. Tnumber
  2. R 也是 number
  3. result 的类型是 number[]

这类能力一旦放进框架、工具库、事件系统、状态管理器里,就会带来非常强的开发体验。

工具类型让“改类型”比“重写类型”更高效

TypeScript 自带很多非常实用的工具类型,比如:

Partial<T>
Required<T>
Pick<T, K>
Omit<T, K>
Record<K, T>
ReturnType<F>

例如:

interface User {
  id: number
  name: string
  age: number
  email: string
}

type UserPreview = Pick<User, "id" | "name">
type UserPatch = Partial<User>

这时:

  1. UserPreview 只保留 idname
  2. UserPatch 表示“更新用户时可选提交的字段”

这类写法在接口设计里非常常见,因为真实项目里你经常不是“重新定义一个新类型”,而是“在原有类型上裁一刀、改一层、选一部分”。

常见工具类型速查

前面列出来的这些工具类型,初看名字会有点抽象。其实它们大多都很实用,而且一旦记住,写接口和改类型会快很多。

下面按最常见的几个分别看一下:

Partial<T>

作用:把一个类型里的所有字段都变成可选。

interface User {
  id: number
  name: string
  email: string
}

type UserPatch = Partial<User>

这时 UserPatch 相当于:

type UserPatch = {
  id?: number
  name?: string
  email?: string
}

典型用途:

  1. 更新接口的 patch 参数
  2. 表单的局部修改
  3. 某个对象逐步构建的中间态

例如:

function updateUser(id: number, patch: Partial<User>) {
  console.log(id, patch)
}

updateUser(1, { name: "New Name" })

Required<T>

作用:把一个类型里的所有字段都变成必填。

interface DraftUser {
  id?: number
  name?: string
}

type FullUser = Required<DraftUser>

这时 FullUser 相当于:

type FullUser = {
  id: number
  name: string
}

典型用途:

  1. 从“草稿态”切到“完整态”
  2. 某些配置在运行前必须补齐
  3. 某一步处理之后,确定字段已经全部存在

Pick<T, K>

作用:从一个已有类型里,挑出你需要的那几个字段。

interface User {
  id: number
  name: string
  age: number
  email: string
}

type UserPreview = Pick<User, "id" | "name">

这时 UserPreview 只包含:

type UserPreview = {
  id: number
  name: string
}

典型用途:

  1. 列表页只展示部分字段
  2. 接口返回轻量版对象
  3. 从大对象里裁出一个子视图

Omit<T, K>

作用:从一个已有类型里,去掉某几个字段。

interface User {
  id: number
  name: string
  password: string
  email: string
}

type SafeUser = Omit<User, "password">

这时 SafeUser 就是不包含 password 的版本。

典型用途:

  1. 去掉敏感字段
  2. 去掉前端不该碰的字段
  3. 基于已有类型快速构造“公开版本”

例如:

function toSafeUser(user: User): Omit<User, "password"> {
  const { password, ...rest } = user
  return rest
}

Record<K, T>

作用:构造一个“键固定、值类型统一”的对象类型。

type Role = "admin" | "user" | "guest"

type RoleLabelMap = Record<Role, string>

这时它相当于:

type RoleLabelMap = {
  admin: string
  user: string
  guest: string
}

典型用途:

  1. 枚举值到说明文字的映射
  2. 事件名到处理器的映射
  3. 一组固定 key 的配置表

例如:

const roleLabels: Record<Role, string> = {
  admin: "管理员",
  user: "普通用户",
  guest: "访客",
}

ReturnType<F>

作用:拿到某个函数的返回值类型。

function createUser() {
  return {
    id: 1,
    name: "Alice",
  }
}

type User = ReturnType<typeof createUser>

这时 User 会自动变成:

type User = {
  id: number
  name: string
}

典型用途:

  1. 避免手写一遍函数返回类型
  2. 工厂函数、hook、状态创建器这类场景特别常见
  3. 保持“实现”和“类型”同步

再补充几个也很常见的工具类型

除了前面这几个,实际项目里还有几个也经常会碰到:

Readonly<T>

作用:把所有字段都变成只读。

interface Config {
  apiBase: string
  timeout: number
}

type ReadonlyConfig = Readonly<Config>

这时对象字段就不能随便再改。

典型用途:

  1. 配置对象
  2. 不希望被下游改动的数据
  3. 强调“这是只读视图”

Exclude<T, U>

作用:从联合类型里排除一部分成员。

type Status = "idle" | "running" | "done" | "error"
type ActiveStatus = Exclude<Status, "done" | "error">

这时 ActiveStatus 就是:

type ActiveStatus = "idle" | "running"

典型用途:

  1. 裁剪状态集合
  2. 过滤联合类型

Extract<T, U>

作用:从联合类型里提取出和 U 重合的部分。

type Status = "idle" | "running" | "done" | "error"
type EndStatus = Extract<Status, "done" | "error">

这时 EndStatus 就是:

type EndStatus = "done" | "error"

它和 Exclude 可以看成一对互补工具。

Parameters<F>

作用:拿到某个函数的参数类型列表。

function sendMessage(id: number, content: string) {
  console.log(id, content)
}

type SendMessageArgs = Parameters<typeof sendMessage>

这时 SendMessageArgs 会变成:

type SendMessageArgs = [id: number, content: string]

典型用途:

  1. 包装已有函数
  2. 写高阶函数
  3. 保持参数定义和原函数同步

一个最实用的理解方式

如果只用一句话总结工具类型,我觉得可以这样记:

工具类型的作用,不是“凭空创造新类型”,而是“在已有类型上做裁剪、补全、过滤、映射和提取”。

所以它们特别适合工程项目。因为真实开发里,你很少真的从零定义所有类型,更多时候是在已有类型上做这些操作:

  1. 只拿一部分字段
  2. 去掉一部分字段
  3. 把字段全变成可选
  4. 把字段全变成必填
  5. 从函数里提取返回值或参数类型

这也是为什么工具类型会让“改类型”比“重写类型”高效得多。

类型系统也能把事件系统变得更稳

事件系统如果没有类型约束,很容易出现两个问题:

  1. 事件名写错
  2. payload 结构不匹配

例如一个类型安全的事件总线:

interface AppEvents {
  "task:start": { taskId: string }
  "task:done": { taskId: string; result: string }
}

class TypedEventBus {
  private handlers: {
    [K in keyof AppEvents]?: Array<(payload: AppEvents[K]) => void>
  } = {}

  on<K extends keyof AppEvents>(event: K, handler: (payload: AppEvents[K]) => void) {
    const list = this.handlers[event] ?? []
    list.push(handler)
    this.handlers[event] = list
  }

  emit<K extends keyof AppEvents>(event: K, payload: AppEvents[K]) {
    const list = this.handlers[event] ?? []
    for (const handler of list) {
      handler(payload)
    }
  }
}

这时事件系统就不再只是“能跑”,而是连事件名和数据结构都被约束住了。

从这个角度看,TypeScript 最强的地方其实不是某一个单独语法点,而是:

它能把 Promise 异步模型、事件驱动模型和工程化类型系统这三样东西组合在一起。

这也是为什么它会在前端、Node.js、Agent Runtime、插件系统这些场景里特别强。

5. TypeScript 最适合做什么

我现在对 TypeScript 的理解是:它最适合那些既需要 JavaScript 生态,又需要工程约束的项目。

典型场景包括:

  1. Web 前端
  2. Node.js 后端
  3. CLI 工具
  4. 插件系统
  5. Agent 编排层
  6. Electron 桌面应用
  7. 全栈项目

5.1 为什么特别适合 Agent

Agent 项目通常会同时具备下面几个特征:

  1. 有很多异步任务
  2. 有事件流和状态流
  3. 要调用很多外部工具或 API
  4. 代码分层比较多,容易扩展成插件系统
  5. 需要和前端或 Web 环境配合

这些点几乎都和 TypeScript 的优势正好重合。

看一个非常简化的 Agent 执行器:

interface ToolContext {
  input: string
}

interface ToolResult {
  success: boolean
  output: string
}

type Tool = (ctx: ToolContext) => Promise<ToolResult>

const summarize: Tool = async (ctx) => {
  return {
    success: true,
    output: `summary: ${ctx.input}`,
  }
}

async function runTool(tool: Tool, input: string) {
  const result = await tool({ input })
  console.log(result.output)
}

runTool(summarize, "TypeScript is great for orchestration")

这个例子虽然简单,但已经能看出几个关键点:

  1. 输入和输出结构清晰
  2. 异步调用自然
  3. 工具接口统一
  4. 后续很容易扩展成多个 Tool、多个 Hook、多个事件

5.2 TypeScript 不一定最适合什么

反过来看,TypeScript 也不是万能的。

比如下面这些场景,它未必是第一选择:

  1. 非常重计算、非常吃性能的底层系统
  2. 强依赖数值计算和科学计算生态的任务
  3. 极小型一次性脚本

所以更准确的说法不是“TypeScript 很强”,而是:

TypeScript 在现代应用层工程里,尤其是在事件驱动、异步编排、前后端协作、工具生态这几个方向上特别强。

6. 我现在会如何开始学 TypeScript

如果按“速通”的思路,我会把第一阶段控制在下面这些内容里:

  1. 先搞懂 TypeScript 和 JavaScript、Node.js、npm 各自是什么关系
  2. 自己从零搭一个最小项目,能跑通 devbuild
  3. 熟悉常见基础类型、函数类型、对象类型、数组类型
  4. 搞懂 interfacetype、联合类型、泛型
  5. 熟悉 Promise、async/await、Promise.all
  6. 用一个小项目把这些东西串起来,比如写一个 CLI、一个小型任务调度器,或者一个最简版 Agent Runner

如果只看不写,很容易以为自己会了;但只要真的写一个小项目,像下面这种问题马上就会冒出来:

  1. 模块怎么拆?
  2. 类型放哪?
  3. 异步错误怎么传?
  4. 配置对象怎么定义?
  5. 事件和状态怎么表示?

这些问题一旦在项目里真正走一遍,理解会快很多。

7. 一个最小但完整的 TypeScript 心智模型

最后用几句话收束一下:

  1. TypeScript 不是运行时,它是 JavaScript 的类型层。
  2. 真正运行代码的是 Node.js、浏览器或 Bun。
  3. TypeScript 最大的价值是让 JavaScript 更适合大型工程协作。
  4. 它的核心强项是类型系统、泛型、工具类型,以及事件驱动和异步编程的工程表达。
  5. 如果要做 Agent、前端、CLI、Node.js 服务或插件系统,TypeScript 通常都是非常强的候选项。

如果以后继续补充,我觉得最值得单独展开的几个主题是:

  1. TypeScript 的类型体操:条件类型、映射类型、infer
  2. Node.js 的 ESM / CommonJS 模块系统
  3. 如何写一个类型安全的事件总线
  4. 如何写一个最小 Agent Orchestrator
  5. TypeScript 和 Python 混合项目的边界设计

附录. TypeScript 的语法细节

这一部分更像一个语法速查表,专门收一收前面正文里已经出现过、但可能还想单独回头确认的基础语法点。

如果只挑最重要的一组概念,我觉得是下面这些:

  1. 类型注解
  2. 类型推断
  3. interfacetype
  4. 联合类型与类型缩小
  5. 泛型
  6. 模块系统
  7. Promise 和 async/await

1.1 类型注解

最基础的写法,就是给变量、函数参数和返回值加上类型。

let count: number = 10
let username: string = "eel"
let isReady: boolean = true

function greet(name: string): string {
  return `Hello, ${name}`
}

它最直接的作用,是让错误尽量提前暴露。

function double(x: number): number {
  return x * 2
}

double(10)      // 正常
double("10")    // 编译时报错

1.2 类型推断

不过 TypeScript 也不是要求你到处手写类型。很多时候,它会自动推断。

let port = 3000
let title = "Agent App"

这里会分别推断出:

  1. portnumber
  2. titlestring

实际写代码时,一个很常见的原则是:

  1. 能明显推断出来的地方,不必硬写
  2. 边界清晰、需要对外暴露的地方,要把类型写清楚

比如函数参数、公共接口、导出的类型,通常更值得显式标注。

1.3 interfacetype

这两个概念初学时最容易混,但可以先这样理解:

  1. interface 更像“描述对象长什么样”
  2. type 更像“给任意类型表达式起一个名字”
interface User {
  id: number
  name: string
}

type UserId = number | string

再看一个更明显的例子:

type Status = "idle" | "running" | "done"

interface Task {
  id: string
  status: Status
}

这里 Status 不是对象结构,所以用 type 更自然;而 Task 是对象结构,用 interface 很顺手。

1.3.1 补充:type 到底能做什么

很多人一开始会以为,type 只是给已有类型起别名。其实更准确的说法是:

type 是给一个类型表达式命名,而这个类型表达式可以非常简单,也可以非常复杂。

例如下面这些都合法:

type UserId = number
type Status = "idle" | "running" | "done"
type Point = { x: number; y: number }

所以 type 不但能给基础类型起名字,也能表达联合类型、字面量类型和对象结构。

1.3.2 字面量类型:为什么 "idle" 也能成为类型

下面这段写法第一次看到会有点反直觉:

type Status = "idle" | "running" | "done"

它的意思不是“Status 是字符串”,而是:

Status 只能取这三个具体值之一。

例如:

let s: Status

s = "idle"
s = "running"
s = "done"
// s = "error" // 报错

这是一种非常常见的建模方式,特别适合表示状态、角色、事件名、动作类型。

1.3.3 type 可以做嵌套约束

type 不只可以描述单个值,也可以写成多层结构化约束。

type Status = "idle" | "running" | "done"

type User = {
  name: string
  role: "admin" | "guest"
}

type Task = {
  id: number
  status: Status
  owner: User
}

这个例子里:

  1. Task.status 只能是 Status
  2. Task.owner 必须符合 User
  3. User.role 又只能是固定值之一

这正是 TypeScript 在大型项目里很有用的地方。

1.3.4 typeenumas const 的区别

这三种写法都可以表达“值只能从几个固定选项里选”,但定位不同。

第一种是 type

type Status = "idle" | "running" | "done"

特点是:

  1. 只存在于编译期
  2. 不会生成运行时代码
  3. 适合单纯做类型约束

第二种是 enum

enum Status {
  Idle = "idle",
  Running = "running",
  Done = "done",
}

特点是:

  1. 编译后会生成真实的 JavaScript 对象
  2. 运行时可以访问
  3. 更像传统语言里的枚举

第三种是 as const

const STATUS = {
  idle: "idle",
  running: "running",
  done: "done",
} as const

type Status = (typeof STATUS)[keyof typeof STATUS]

这时:

  1. STATUS 是运行时真实对象
  2. Status 是从它推导出来的联合类型

如果只想限制值的范围,通常直接用 type 就够了;如果既想有运行时常量,又想自动得到类型,as const 会很常用。

1.3.5 type A = number | 12 可以写吗

可以写,语法上完全合法:

type A = number | 12

但它的实际效果等价于:

type A = number

原因是:12 本来就是 number 的一个子集。既然已经允许“所有 number”,那单独再加 12 就没有额外意义了。

真正有意义的是下面这种:

type A = string | 12

这表示:

  1. 要么是任意字符串
  2. 要么是字面量 12

所以可以顺便记住一个很实用的规则:

如果一个联合类型里的某一项已经被另一项完整包含了,那么这一项通常就没有额外意义。

1.3.5.1 type 的语法为什么会让人觉得特别灵活

很多人学到这里时都会有一个很强烈的感觉:type 的语法是不是有点太灵活了?

这个感觉是对的。因为 type 不是只能写“一个类型名字等于另一个类型名字”,而是一个非常通用的类型表达式工具。

也就是说,它既可以写:

type Age = number

也可以写:

type Status = "idle" | "running" | "done"

还可以写:

type A = number | "abc"

这里的 A 表示:

  1. 要么是任意 number
  2. 要么是那个固定字符串 "abc"

例如:

let x: A

x = 123
x = "abc"
// x = "def" // 报错

之所以能这样写,是因为在 TypeScript 里:

  1. number 这种是普通类型
  2. "abc" 这种具体值也可以变成类型,叫字面量类型
  3. | 可以把它们组合成联合类型

所以 type 常见的几种写法其实可以归成下面几类:

  1. 基础别名:type Age = number
  2. 联合类型:type ID = number | string
  3. 字面量类型:type Role = "user" | "admin"
  4. 对象类型:type User = { id: number; name: string }
  5. 交叉类型:type C = A & B
  6. 元组类型:type Pair = [number, string]
  7. 函数类型:type Handler = (input: string) => number

真正让它显得很灵活的地方,是这些能力可以继续嵌套和组合:

type Result = {
  status: "ok" | "error"
  data: string | number
  handler: ((x: number) => string) | null
}

所以可以先记一句话:

type 不是单纯的“类型别名”,而是可以把很多类型规则拼接起来的表达式工具。

1.3.5.2 type 和 JSON 是什么关系

type 和 JSON 之间确实有一种很自然的贴合关系,这也是很多人第一次上手 TypeScript 时会特别有感觉的地方。

例如一个 JSON:

{
  "id": 1,
  "name": "Alice",
  "active": true
}

很自然就能对应成:

type User = {
  id: number
  name: string
  active: boolean
}

所以从入门理解上说,把 type 看成“JSON 的数据模型说明书”,这个方向是对的。

尤其在下面这些场景里,这种感觉会特别明显:

  1. API 请求体
  2. API 响应体
  3. 配置文件
  4. 数据库存储对象
  5. Agent 上下文

因为这些东西本质上都是结构化数据,而 type 天然就很适合描述结构化数据。

但如果说它和 JSON “强绑定”,就稍微有点过了。更准确地说:

type 很适合给 JSON 这类结构化数据建模,但它并不只服务于 JSON。

原因是 type 还能描述很多 JSON 本身表达不了的东西,例如:

  1. 函数类型:type Handler = (input: string) => number
  2. 联合类型:type ID = number | string
  3. 字面量约束:type Status = "idle" | "running" | "done"
  4. 交叉类型:type C = A & B
  5. 元组:type Pair = [number, string]

这些已经不只是“JSON 长什么样”,而是在表达更广义的类型规则。

所以更准确地说,二者的关系可以理解成:

  1. JSON 是运行时的数据
  2. type 是编译期对数据结构的描述
  3. TypeScript 用 type 来检查代码里对这些数据的使用是否合理

还有一个特别重要的点是:

type 只负责静态检查,不负责运行时校验。

也就是说,下面这个定义:

type User = {
  id: number
  name: string
}

并不会自动帮你校验外部传进来的 JSON 一定符合这个结构。它只是告诉编译器:“在我的代码里,这里应该把它当成 User 来理解。”

如果真的需要在运行时验证数据,还需要额外的工具,比如:

  1. zod
  2. io-ts
  3. valibot
  4. Pydantic

所以如果把这一层关系压成一句话,我觉得可以记成:

type 很像给 JSON 或其他结构化数据写的一份编译期模型说明书,但它不只服务于 JSON,也不负责运行时校验。

1.3.5.3 为什么静态检查会长出这么复杂的类型系统

学到这里时,一个很自然的疑问是:

我只是想做静态检查,为什么 TypeScript 最后长出了一套这么复杂的类型系统?

这个问题问得非常好。因为静态检查本身并不天然要求“必须复杂成这样”。真正让 TypeScript 变复杂的原因,不是“静态类型”这四个字本身,而是:

它要给本来就非常灵活的 JavaScript,补上一套还能跟得上的静态类型层。

如果 JavaScript 本身是一门很严格、很简单、很少变化形状的语言,那 TypeScript 当然可以设计得更朴素。但问题是,JavaScript 天生就有很多会把类型关系拉复杂的特点。

尤其是下面这些:

  1. 对象结构非常灵活,字段可以随时增减、嵌套、组合
  2. 函数是一等公民,可以像普通值一样被传来传去、返回出去、存进对象里
  3. 回调和高阶函数非常多
  4. 异步流程里有 Promise、事件、回调链
  5. 一个值经常会有多种可能形状
  6. 运行时对参数、对象、模块边界又很宽松

这里最值得单独强调的是:

JavaScript 里的函数是一等公民。

所谓一等公民,意思是函数在 JS 里不是“只能定义然后调用”的东西,而是可以像普通值一样参与整个程序:

  1. 赋值给变量
  2. 作为参数传入别的函数
  3. 作为返回值返回
  4. 存进对象
  5. 动态组合

例如:

function run(handler: (input: string) => number) {
  return handler("hello")
}

const fn = (text: string) => text.length

run(fn)

这里 TypeScript 要检查的,已经不只是“某个变量是不是 number”这么简单了,而是:

  1. handler 是不是一个函数
  2. 这个函数接不接受 string
  3. 它是不是返回 number
  4. 这个函数被传来传去之后,类型信息还能不能保留下来

只要函数是一等公民,这类问题就会非常多。而现代 JS 项目里,几乎到处都是这种写法:回调、事件监听器、Promise 链、高阶函数、工具函数、hook、插件系统,全都建立在这件事上。

再比如,真实项目里经常不是单一形状的数据,而是:

type Result =
  | { ok: true; data: string }
  | { ok: false; error: string }

这时 TypeScript 要做的,也不只是“检查这个变量是不是对象”,而是:

  1. 表达多种可能的数据形状
  2. 在分支里自动缩小类型
  3. 保证不同分支里只能访问对应字段

例如:

function handle(result: Result) {
  if (result.ok) {
    console.log(result.data)
  } else {
    console.log(result.error)
  }
}

这已经比最朴素的“静态检查变量类型”复杂很多了。

再比如一个非常常见的高阶函数:

function map<T, R>(arr: T[], fn: (item: T) => R): R[] {
  return arr.map(fn)
}

这里如果没有泛型,TypeScript 很难同时做到两件事:

  1. 这个函数足够通用
  2. 返回值类型又足够精确

所以可以把问题总结成一句话:

TypeScript 的复杂,不只是因为它想做静态检查,而是因为它想在不改变 JavaScript 编程风格的前提下,把静态检查做得足够有表达力。

这会自然逼出很多能力:

  1. 联合类型
  2. 交叉类型
  3. 字面量类型
  4. 泛型
  5. 工具类型
  6. 类型推导
  7. 条件类型

如果它设计得太简单,会很快碰到两个问题:

  1. 表达不了真实项目里的类型关系
  2. 很多地方只能退回 any,那静态检查就基本失效了

所以这件事本质上是个取舍:

  1. 类型系统简单,学习快,但很快不够用
  2. 类型系统强,表达力高,但学习成本会更高

TypeScript 显然选择了后者。

不过也不用因此有太大压力。因为日常开发里,高频会用到的其实只是其中一部分:

  1. 基础类型
  2. 对象类型
  3. type / interface
  4. 联合类型
  5. 泛型
  6. 一些常见工具类型
  7. Promise / async/await

真正特别复杂的那部分,比如高级条件类型、类型体操、infer 的重度用法,很多人平时其实并不会天天写。

所以如果把这一节压成一句最值得记住的话,我觉得可以记成:

TypeScript 的类型系统之所以复杂,不是因为静态检查天然就必须这么复杂,而是因为它要给本来就很灵活的 JavaScript,尤其是“函数是一等公民”的 JavaScript,补上一套足够有表达力的静态类型层。

1.3.6 为什么会觉得嵌套 typeinterface 很像

这种感觉非常正常,因为在“描述对象结构”这件事上,typeinterface 的确高度重叠。

例如:

interface User {
  id: number
  name: string
}
type User = {
  id: number
  name: string
}

在很多场景里,它们几乎可以看成等价。

1.3.7 为什么一旦嵌套起来,它们就更像了

因为一旦 type 也拿来描述对象,它看起来当然就会和 interface 非常接近。

例如:

type Task = {
  id: number
  owner: {
    name: string
  }
}

这完全可以改写成:

interface Owner {
  name: string
}

interface Task {
  id: number
  owner: Owner
}

所以“嵌套 typeinterface 很像”这个观察,本身就是对的。

1.3.8 实际开发里怎么选

一个很好用的经验是:

  1. 纯对象结构,优先考虑 interface
  2. 联合类型、字面量类型、元组、交叉类型这类组合表达,优先考虑 type
  3. 如果团队已经有统一风格,就尽量保持一致

例如:

type Status = "idle" | "running" | "done"

interface User {
  id: number
  name: string
}

interface Task {
  id: string
  status: Status
  owner: User
}

这里的分工就很清楚:

  1. Status 是固定值范围,所以用 type
  2. UserTask 是对象结构,所以用 interface

1.3.9 TypeScript 的类型到底是怎么定义出来的

理解 TypeScript 时,一个很容易冒出来的问题是:

TS 的类型是不是除了基本类型,其他都只是 type 拼出来的?

答案是不完全是。更准确地说,TypeScript 里的类型来源主要有:

  1. 内置基础类型
  2. type
  3. interface
  4. class
  5. 少量其他机制,比如 enum

例如:

let a: number
let b: string
let list: number[]
let fn: (x: number) => string

然后才是我们自己定义类型时最常用的几种方式:

type Status = "idle" | "running" | "done"

interface User {
  id: number
  name: string
}

所以不是“除了基本类型,其他都派生自 type”,而是:

type 很强,能表达很多类型;但 TypeScript 里的类型来源并不只有 type

interfaceclass 也都能参与定义类型。

1.3.10 TypeScript 里有 class

有,而且是完整支持的。

class User {
  id: number
  name: string

  constructor(id: number, name: string) {
    this.id = id
    this.name = name
  }

  greet() {
    return `Hello, ${this.name}`
  }
}

这里的 User 同时有两层含义:

  1. 在运行时,它是一个真正的类
  2. 在类型层,它也表示“User 实例的类型”

所以可以这样写:

let u: User

1.3.11 怎么给“类型”绑定方法

如果问题是“某个类型的对象能不能有方法”,答案是可以,而且不一定非要靠 class

interface 可以写:

interface User {
  id: number
  name: string
  greet(): string
}

type 也可以写:

type User = {
  id: number
  name: string
  greet(): string
}

这表示:只要某个对象满足这个结构,它就符合这个类型。

例如:

const user: User = {
  id: 1,
  name: "Alice",
  greet() {
    return `Hello, ${this.name}`
  },
}

也就是说,在 TypeScript 里,“对象有方法”这件事和“必须使用类”并不是绑定的。

1.3.12 TypeScript 支持面向对象,但不强依赖面向对象

TypeScript 完全支持常见的面向对象写法,比如:

  1. class
  2. extends
  3. abstract class
  4. public / private / protected
  5. implements

例如:

interface Animal {
  speak(): void
}

class Dog implements Animal {
  speak() {
    console.log("wang")
  }
}

但它和 Java / C# 那种“以类为中心”的语言还是不太一样。很多现代 TypeScript 项目里,更常见的风格是:

  1. typeinterface 描述结构
  2. 用普通函数组织逻辑
  3. 只有在确实需要封装状态和行为时,再使用 class

例如下面这种写法,在现代 TS 项目里就很常见:

type Task = {
  id: string
  status: "idle" | "running" | "done"
}

function runTask(task: Task) {
  console.log(task.status)
}

而不是一开始就写成:

class Task {
  id: string
  status: "idle" | "running" | "done"

  constructor(id: string, status: "idle" | "running" | "done") {
    this.id = id
    this.status = status
  }

  run() {
    console.log(this.status)
  }
}

后者当然也能写,只是很多时候前者更轻、更灵活。

1.3.13 一句话理解 typeinterfaceclass 的关系

可以先用一个很实用的总结:

  1. type:最通用的类型表达工具
  2. interface:更专注于对象结构描述
  3. class:既是运行时代码结构,也会顺带提供类型信息

所以 TypeScript 的风格并不是“完全不用面向对象”,而更像是:

它既支持面向对象,也支持更轻量的结构化建模;实际项目中,通常会优先用 type/interface 描述结构,再按需使用 class

1.4 联合类型与类型缩小

联合类型是 TypeScript 非常常用的一种表达能力:一个值可以是多种类型之一。

function printId(id: number | string) {
  console.log("id:", id)
}

但一旦是联合类型,就不能直接把它当成某一个具体类型用,必须先缩小范围。

function formatId(id: number | string): string {
  if (typeof id === "number") {
    return id.toFixed(0)
  }

  return id.toUpperCase()
}

这里的 typeof id === "number" 就是在做类型缩小。

1.5 泛型

泛型可以理解成“把类型当参数传进去”。这是 TypeScript 很有力量的一种能力。

function wrap<T>(value: T): T {
  return value
}

const a = wrap(10)       // T 是 number
const b = wrap("hello")  // T 是 string

如果没有泛型,你往往只能把参数和返回值都写成 any,那类型系统就形同虚设了。

一个更像实际项目的例子:

function first<T>(items: T[]): T | undefined {
  return items[0]
}

const n = first([1, 2, 3])             // number | undefined
const s = first(["a", "b", "c"])       // string | undefined

这段代码只写了一次,但可以安全地服务很多不同类型的数据。

1.6 模块系统

TypeScript 项目本质上还是 JavaScript 项目,所以模块组织方式也沿用 JS 的 import/export

// math.ts
export function add(a: number, b: number): number {
  return a + b
}
// index.ts
import { add } from "./math.js"

console.log(add(1, 2))

模块化的意义在于:

  1. 让文件有边界
  2. 让代码可以复用
  3. 让大型项目可以分层组织

1.7 Promise 和 async/await

这是现代 JS / TS 编程里最常见的一组异步能力。

function fetchUser(): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => resolve("Alice"), 1000)
  })
}

配合 async/await 后会非常自然:

async function main() {
  const user = await fetchUser()
  console.log("user:", user)
}

这套模型几乎就是现代 JS / TS 编程的日常形态。网络请求、文件读写、并发工具调用,本质上都离不开它。

1.7.1 async/await 的常见用法和边界

很多人第一次学到这里时,会自然地把它和 Python 的 async/await 放在一起比较。这个方向是对的,但 JS / TS 这边的手感确实有一些不一样。

先记最核心的三句话:

  1. async 会把一个函数变成“返回 Promise 的函数”
  2. await 的作用是等待 Promise 完成,并取出它的结果
  3. 普通函数可以调用 async 函数,只是拿到的是 Promise,而不是最终值

async 到底是什么意思

例如:

async function getUser() {
  return "Alice"
}

这个函数看起来像返回字符串,但更准确地说,它返回的是:

Promise<string>

也就是说:

const x = getUser()
console.log(x) // Promise

你甚至可以把它粗略理解成:

function getUser() {
  return Promise.resolve("Alice")
}

所以一句话记住:

async 的作用,是把一个函数变成“返回 Promise 的函数”。

await 到底是什么意思

await 的作用是:

等一个 Promise 完成,并取出它成功后的值。

例如:

async function main() {
  const user = await getUser()
  console.log(user)
}

这里的 user 才是 "Alice"

如果不用 await,拿到的其实是 Promise 本身:

async function main() {
  const userPromise = getUser()
  console.log(userPromise) // Promise
}

普通函数能不能调用 async 函数

可以。

例如:

async function getUser() {
  return "Alice"
}

function run() {
  const result = getUser()
  console.log(result) // Promise
}

这完全合法。

只是你要注意:

  1. 普通函数可以调用 async 函数
  2. 但它不能直接用 await
  3. 所以它通常只能拿到 Promise,再用 .then(...) 处理

例如:

function run() {
  getUser().then((user) => {
    console.log(user)
  })
}

这也是 JS / TS 和 Python 在手感上的一个差别。JS 里你不会那么强烈地觉得“必须一直待在 async 体系里”,因为普通函数照样能调 async 函数,只是不能直接 await 它。

await 只能写在哪里

最常见的是写在 async 函数里:

async function main() {
  const user = await getUser()
  console.log(user)
}

此外,现代 JS / TS 还支持 top-level await,也就是模块顶层直接写:

const user = await getUser()
console.log(user)

不过这通常要求:

  1. 文件本身是 ESM 模块
  2. 运行环境支持 top-level await

而下面这种写法是不行的:

function run() {
  const user = await getUser() // 报错
}

因为 await 不能直接出现在普通函数里。

JS 和 Python 的感觉为什么不太一样

Python 里经常会写:

asyncio.run(main())

这会让人感觉“异步程序必须显式从 event loop 启动”。

而 JS / TS 这边通常不是这种体验。因为无论是浏览器还是 Node.js,运行时本身就已经在事件循环里了。你不是去“启动一套异步系统”,而更像是在已有系统里挂接异步任务。

所以可以粗略记成:

  1. Python 更像“显式启动异步主流程”
  2. JS / TS 更像“在已经运行的事件系统里挂上异步步骤”

这也是为什么 JS 的 async/await 更容易和 Promise、事件、回调自然混在一起使用。

async/await 的常见写法

等单个异步结果

async function main() {
  const text = await fetchText()
  console.log(text)
}

配合 try/catch 处理错误

async function main() {
  try {
    const text = await fetchText()
    console.log(text)
  } catch (error) {
    console.error(error)
  }
}

并发等待多个 Promise

async function main() {
  const [user, settings] = await Promise.all([
    fetchUser(),
    fetchSettings(),
  ])

  console.log(user, settings)
}

这是非常常见、也非常重要的一种写法。

在循环里顺序等待

async function main() {
  for (const id of [1, 2, 3]) {
    const user = await fetchUserById(id)
    console.log(user)
  }
}

这表示“一个接一个地等”。

在回调里再写 async

例如事件监听器:

button.addEventListener("click", async () => {
  const user = await fetchUser()
  console.log(user)
})

这在前端里非常常见。

await 不一定只能等 Promise

严格说,await 最终会把值“Promise 化”。

例如:

async function main() {
  const x = await 123
  console.log(x) // 123
}

这也是合法的。你可以粗略把它理解成:

const x = await Promise.resolve(123)

所以一句话记住:

await 最常见是等 Promise,但它也可以接普通值,运行时会把它按 Promise 的方式处理。

1.7.2 async 函数里的 return 和 Promise 的 resolve 是什么关系

很多人学到这里时都会问一个很自然的问题:

async 函数里的 return,是不是就等价于 Promise 里的 resolve

从“成功结果怎么往外传”这个角度看,基本可以这样理解。但更准确地说:

async 函数里,return value 的效果,大致等价于返回一个 Promise.resolve(value)

例如:

async function getName() {
  return "Alice"
}

大致等价于:

function getName() {
  return Promise.resolve("Alice")
}

所以从结果上看,下面两段代码非常接近:

async function a() {
  return 123
}
function b() {
  return new Promise((resolve) => {
    resolve(123)
  })
}

调用它们时,最终都会拿到一个成功状态的 Promise。

不过它们并不是完全同一个东西,因为它们所在的层级不同。

return 是语法层的返回

async function foo() {
  return 123
}

这里的 return 是函数语法的一部分。因为这个函数被 async 修饰了,所以运行时会自动把返回值包装成 Promise。

resolve 是 Promise API 提供的回调

new Promise((resolve) => {
  resolve(123)
})

这里的 resolve 不是语法关键字,而是 Promise 构造器传给 executor 的一个函数。你是在手动控制这个 Promise 什么时候完成。

所以二者的区别可以理解成:

  1. return 更像“这个 async 函数执行到这里成功结束了”
  2. resolve(...) 更像“我手动决定这个 Promise 现在成功完成”

这也是为什么 resolve 常常出现在“包裹已有异步机制”的场景里,例如:

function wait(ms: number) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(ms), ms)
  })
}

async return 更适合在 Promise / async 体系内部继续往下写:

async function main() {
  const ms = await wait(1000)
  return ms
}

失败时的对应关系

成功时可以粗略类比:

  1. return valueresolve(value)

失败时则可以类比:

  1. throw errorreject(error)

例如:

async function foo() {
  throw new Error("fail")
}

大致等价于:

function foo() {
  return new Promise((resolve, reject) => {
    reject(new Error("fail"))
  })
}

所以如果压成一个最实用的对照表,可以先记成:

场景async 函数手写 Promise
成功返回值return valueresolve(value)
失败返回错误throw errorreject(error)

所以最后可以用一句话总结:

async 函数里的 return 和 Promise 的 resolve,在“成功结果传播”这件事上基本等价;但前者是语言语法层的自动包装,后者是你手动控制 Promise 状态的 API。