速通 TypeScript
最近在学习 Agent 的时候,我发现很多 Agent 项目都会优先选择 TypeScript,而不是 Python。继续往下看就会发现,这不是偶然。TypeScript 背后站着完整的 JavaScript 生态,天然适合写事件驱动、异步任务、工具编排、前后端联动这些东西;而 Agent 恰好又很依赖这些能力。所以我准备认真补一下 TypeScript。
在 AI 时代,入门一门语言确实比以前快得多。很多基础内容,可能 2 到 3 天就能过完,甚至已经能开始写小项目了。但真正想把它用顺手,还是离不开反复练习。我自己也有过这种体验:之前速通过 Java 和 Go,当时感觉学得很快,可一旦换项目,一个月不碰,很多细节就又模糊了。看来还是得写笔记,而且最好是带例子的笔记,这样以后回头看时能更快把感觉找回来。
速通的方法论
这其实是我这几次“速通语言”里最大的收获:学习一门语言时,不要一开始就陷进大量语法细节里,而是先抓住它的核心特性、核心设计目标,以及它最适合解决什么问题。
换句话说,先回答这几个问题:
- 这个语言的有哪些基本概念,这些概念都是什么? (是什么?)
- 这个语言的项目是如何组织的,依赖是如何管理的? 比如项目文件结构,以及包管理工具等.如何建立一个最小项目?
- 程序是如何构建的:程序的基本结构和程序的入口是如何设置的? (程序如何跑起来,是如何被调用的,如何开始的?)
- 这个语言的核心特性/核心概念是什么? 它们相应的语法和实现是什么样子的? 有哪些典型的设计模式? (比如Java的面向对象,python的脚本到底,TS的事件回调和异步编程)
- 这个语言最适合做什么? 以及为什么被设计出来的? (设计模式的直接延申,比如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 又是什么
最简化地说:
- JavaScript 是真正被执行的语言
- 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 代码的环境”。
它至少要提供两类东西:
- JavaScript 引擎,用来解释或编译执行 JS
- 环境 API,用来做这个环境下能做的事情
例如下面这段代码:
console.log("hello")
要想让它跑起来,必须有某个环境来执行它。这个环境可能是浏览器,也可能是 Node.js,也可能是 Bun。
1.3 浏览器和运行时是什么关系
浏览器本身就是一种 JavaScript 运行时,只不过它的主场景是网页。
浏览器除了执行 JS,还会提供很多 Web API,比如:
documentwindowfetchlocalStorage- DOM 事件系统
例如:
document.querySelector("button")?.addEventListener("click", () => {
console.log("clicked")
})
这段代码之所以能工作,是因为浏览器运行时提供了 document 和 DOM 相关能力。换到 Node.js 里,这段代码默认就跑不了,因为 Node.js 不是浏览器运行时。
所以可以这样记:
- 浏览器是一个运行时
- 它擅长运行网页相关代码
- 它提供的是 Web 平台 API
1.4 Node.js 是什么
Node.js 可以理解成:
让 JavaScript 在浏览器之外也能运行的服务端 / 本地运行时。
它让 JS 可以做很多浏览器做不了或不适合做的事情,比如:
- 读写本地文件
- 启动 HTTP 服务
- 写 CLI 工具
- 跑自动化脚本
- 做后端服务
例如:
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 = 运行时 + 包管理器 + 构建工具 的组合体。
它的几个特点是:
- 启动很快
- 原生支持 TypeScript 运行
- 自带包管理能力
- 尽量兼容 Node.js 生态
例如:
bun run src/index.ts
这条命令看起来像“直接运行 TypeScript”,本质上是 Bun 把很多中间步骤帮你封装起来了。
1.6 npm、pnpm、npx 分别是什么
这几个词也是新手最容易混的。
npm
npm 是 Node.js 生态里最常见的包管理器。它主要负责:
- 安装依赖
- 记录依赖版本
- 执行
package.json里的脚本 - 发布 npm 包
例如:
npm install lodash
或者:
npm run dev
pnpm
pnpm 也是包管理器,可以把它看成 npm 的现代替代品之一。
它的优势通常在于:
- 更节省磁盘空间
- 安装更快
- 依赖隔离更严格
例如:
pnpm add lodash
pnpm dev
很多现代 TS 项目都会优先使用 pnpm,但概念层面上它和 npm 属于同一类工具。
npx
npx 不是包管理器,它更像是一个“临时命令执行器”。
最常见的用途有两个:
- 执行本地依赖里的 CLI
- 临时执行一个还没全局安装的包
例如:
npx tsc --init
这里的意思是:执行当前项目里的 tsc 命令,而不是要求你先把 TypeScript 全局装到系统里。
所以这三个可以先这样区分:
npm:官方包管理器pnpm:更现代的包管理器替代方案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.ts 和 index.ts 都可以看成模块。
包
包更偏“分发单位”或“安装单位”。
通常一个带有 package.json 的目录,就可以被当成一个包。
例如安装:
npm install lodash
这里安装的是 lodash 这个包。安装完之后,你再在代码里导入它:
import _ from "lodash"
所以可以这样理解:
- 模块回答的是“代码怎么拆、怎么导入导出”
- 包回答的是“代码作为依赖怎么发布、安装和复用”
1.8 package.json 是什么
package.json 可以理解成 Node.js / TypeScript 项目的总配置文件。
它通常负责:
- 声明项目名称和版本
- 管理依赖
- 定义脚本命令
- 记录一些模块和包相关配置
例如:
{
"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 官方标准模块系统。它解决的是:
- 一个文件如何导出内容
- 另一个文件如何导入内容
典型语法就是:
// 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 属于模块系统,不属于包管理器。
这点一定要分清:
npm install lodash是在安装包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 生态里你会同时看到两套写法:
require/module.exports:CommonJSimport/export:ESM
现代前端项目和越来越多的 TypeScript 项目都更偏向 ESM,但老项目和部分 Node.js 包里仍然会见到 CommonJS。
2.3 package.json 在项目里到底管什么
在项目结构里,package.json 主要承担三件事:
- 记录依赖
- 记录脚本命令
- 告诉运行时和工具如何理解这个项目
例如:
{
"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"
}
}
这里几个字段的意义可以先记住:
scripts:定义命令入口,比如npm run devdependencies/devDependencies:记录依赖type: "module":告诉 Node.js 这个项目默认按 ESM 规则解释.js文件
2.4 tsconfig.json 是什么
tsconfig.json 是 TypeScript 编译器的配置文件,主要决定:
- TypeScript 怎么检查类型
- TypeScript 编译成什么样的 JavaScript
- 模块规则按什么模式处理
一个常见示例是:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"outDir": "dist"
},
"include": ["src"]
}
初学阶段最值得先记住的是:
target:编译到哪个 JavaScript 版本module:模块系统按什么规则输出和解析strict:是否开启严格类型检查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 规则下,导入路径通常要和最终运行产物保持一致。
所以可以先这样理解:
- 你写的是
math.ts - 运行时看到的是
math.js - ESM 导入路径经常写
.js,是为了对齐最终运行结果
2.6 最小项目示例
先初始化项目:
npm init -y
npm install -D typescript tsx
npx tsc --init
然后把 package.json 和 tsconfig.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
这个最小项目里其实已经把几层关系都串起来了:
package.json管依赖和脚本tsconfig.json管 TypeScript 编译规则src/index.ts是源码模块npm负责执行命令tsx或tsc + 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 来说,入口更常常是:
src/index.tssrc/main.tscli.tsserver.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 的作用不是“语言强制规定入口”,而是“程序员自己把启动逻辑收拢到一个地方”。
所以这一点可以记成:
- TS 不要求必须有
main - 但复杂程序里,自己写一个
main往往会更清晰
3.4 一个脚本是怎么被“外部调用”的
所谓“外部调用”,本质上就是某个运行环境去执行你的入口文件。
这里先区分两件事:
- 谁来指定入口
- 入口确定之后,运行时再怎么继续加载后续模块
前者回答的是“程序从哪里开始”,后者回答的是“开始之后,依赖是怎么一路接进来的”。
最常见的几种方式有:
方式一:命令行直接执行入口文件
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))
这时:
index.ts是入口math.ts不是入口math.ts是在index.ts运行时被加载进来的
所以并不是每个 .ts 文件都会“独立运行”,很多文件只是被入口文件调用。
3.5 外部环境到底是怎么把一个 .ts 文件跑起来的
这一节真正值得理解的,不是“某个函数有没有被调用”,而是:
外部环境到底是怎么找到你的入口、加载它依赖的模块,并把运行所需的能力注入进来的。
因为对 TypeScript 来说,外部环境通常并不是“拿到一个函数名然后直接调一下”。更常见的情况是:
- 先确定入口文件
- 再加载这个入口依赖的模块图
- 然后把运行时提供的能力接进来
- 最后程序才开始注册事件、发请求、等待回调
所以一个 .ts 文件真正“跑起来”的过程,更像是“被某个运行时接管并装配”,而不是“被简单调用”。
先记住一个前提:浏览器并不认识 TypeScript
浏览器原生能执行的是 JavaScript,不是 TypeScript。
所以如果我们在写前端 TS 项目,通常真实发生的是:
- 你写的是
.ts - 构建工具先把它转成
.js - 浏览器再去加载这些
.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>
对浏览器来说,这里的意思大概是:
- 先解析 HTML
- 看到一个
type="module"的脚本 - 知道这不是传统脚本,而是 ESM 入口模块
- 去请求这个模块文件
- 解析里面的
import - 继续递归下载它依赖的其他模块
- 等整个模块图准备好之后,再开始执行
也就是说,浏览器真正调用的不是“你写的某个业务函数”,而是:
HTML
-> script type="module"
-> 入口模块 main.js
-> import 出来的其他模块
-> 模块初始化代码开始执行
这也是为什么前端里经常说“入口文件”而不是“main 函数”。浏览器更关心的是模块入口,不是函数入口。
浏览器里的“依赖注入”很多时候不是参数注入,而是环境注入
很多后端或框架语境里,一说“依赖注入”,容易想到:
- 容器创建对象
- 把依赖作为构造函数参数传进去
- 再把实例交给你使用
但在浏览器场景下,更基础、也更常见的一层“注入”其实是环境注入。
也就是:浏览器在执行模块之前,先把这个模块默认可用的运行时能力准备好。比如:
windowdocumentlocationhistoryfetchlocalStorage- 事件循环、定时器、DOM 事件系统
所以像下面这种代码:
const button = document.querySelector("button")
button?.addEventListener("click", () => {
console.log(window.location.href)
})
你并没有手动把 document 或 window 传进去,但代码依然能直接用。原因不是 TypeScript 做了什么魔法,而是:
- 浏览器运行时先创建了全局对象
- 这些对象挂着 DOM 和 Web API 能力
- 模块代码运行时,可以直接从全局环境访问它们
从这个角度看,浏览器的第一层“依赖注入”其实是:
运行时把 Web 平台能力提前放进全局执行环境里。
这也是很多人第一次学前端时会觉得“有点烦”的原因:这些能力确实带一点隐式感。你没有 import 它们,也没有手动传参,但它们就是存在。
这和 Python 的感觉为什么不太一样
这里很容易产生一个直觉:
好像不像 Python,JS/TS 的模块和环境更依赖运行时现场解析。
这个直觉有一部分是对的,但最好稍微修正一下。
Python 的 import 本身也是运行时行为,并不是说 Python 完全不是运行时解析。真正的区别更多在于:
- Python 的模块查找路径通常更集中,主要围绕解释器环境、包目录、
sys.path - 浏览器里的 JS 模块解析强烈依赖宿主环境规则,比如 URL、HTML、
script type="module"、import map - TypeScript 还额外多了一层“源码是
.ts,真正执行的是.js”的转换过程
所以更准确的说法不是“Python 不是运行时解析,JS/TS 才是”,而是:
JS/TS 更明显地依赖宿主环境来决定模块如何落地,所以会让人感觉规则更隐式。
尤其在浏览器里,这种“隐式感”会更强,因为浏览器除了加载模块,还默认提供了一大批全局能力。
模块依赖又是怎么“注入”进来的
除了 window、document 这种环境能力,还有另一类依赖是模块依赖,也就是 import 进来的内容。
例如:
import { createApp } from "./app.js"
import { api } from "./api.js"
createApp({ api })
这里的 createApp 和 api 并不是浏览器全局自带的,它们来自模块系统。
浏览器或打包工具大致会按下面的流程处理:
- 加载入口模块
main.js - 看到它依赖
./app.js和./api.js - 继续请求并解析这两个模块
- 执行这些模块的顶层代码
- 建立
export和import的绑定关系 - 等依赖就绪后,再执行当前模块
所以这里所谓的“注入”,本质上不是框架偷偷给你塞参数,而是:
模块加载器先把依赖模块解析好,再把它们的导出绑定到当前模块可用的名字上。
这和普通函数调用非常不一样。普通函数调用是:
我现在要这个值
-> 你把参数传给我
而模块注入更像是:
先把整个依赖图装配好
-> 再开始执行当前模块
浏览器到底怎么知道裸模块名该去哪里找
如果你写的是:
import { createApp } from "vue"
这里的 "vue" 并不是一个浏览器天然知道的 URL。
所以现实里通常有两种办法:
第一种:靠构建工具提前改写
像 Vite、Webpack、Rspack 这类工具,会在开发或构建阶段接管模块解析。
它们会做几件事:
- 读你的源码
- 发现
import { createApp } from "vue" - 去
node_modules里找到真实包位置 - 在开发时把它映射成浏览器能请求的地址
- 在生产构建时把它打进 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 包”,而是:
- 认识 URL
- 认识 ESM
- 可以在 import map 或构建工具帮助下,把模块名翻译成 URL
这也是为什么前端项目里你经常会感觉:代码表面上只是写了一个 import,但背后真正参与工作的东西其实有很多,像 HTML、开发服务器、打包器、路径重写规则、浏览器模块加载器,都会一起参与。
如果这些注入是隐式的,那我该怎么建立边界感
这是很关键的问题。因为前端环境不是不能学清楚,而是不能把所有东西都混在一起记。
一个很实用的做法是,把代码里能拿到的东西先分成三类:
- 你自己定义的局部变量、函数参数
- 通过
import拿到的模块依赖 - 宿主环境直接提供的全局能力
例如下面这段代码:
import { fetchUser } from "./api.js"
const button = document.querySelector("button")
button?.addEventListener("click", async () => {
const user = await fetchUser()
console.log(user, window.location.href)
})
这里可以这样拆:
fetchUser是模块依赖button、user是局部变量document、window是浏览器环境注入的全局能力
这样一拆,边界就会清楚很多。
再记一个很好用的小判断法:
一个名字如果不是局部变量、不是函数参数、不是
import进来的,那它大概率就是宿主环境给你的东西。
当然这条规则不是 100% 绝对,因为还可能来自 globalThis、第三方脚本或框架注入,但对初学阶段已经非常够用了。
TypeScript 其实也在帮你把这种隐式能力“显式化”
虽然浏览器给的全局能力看起来是隐式的,但 TypeScript 并不是完全放任不管。
比如你在前端项目里能写:
document.title = "hello"
fetch("/api/user")
TypeScript 之所以知道这些名字存在、也知道它们的方法签名,背后依赖的是浏览器环境对应的类型声明,最典型的就是 DOM 相关声明文件。
也就是说:
- 运行时负责真正提供
document、fetch - TypeScript 负责在编译期告诉你“这些东西长什么样”
所以它并不是把隐式问题彻底消灭了,而是至少帮你把“这东西可不可用、接口长什么样”提前标出来。
浏览器环境里,一段前端代码真正启动时通常发生了什么
可以把它想成下面这条链路:
用户打开页面
-> 浏览器请求 HTML
-> HTML 里声明 module script
-> 浏览器请求入口 JS 模块
-> 继续递归请求它 import 的其他模块
-> 浏览器提供 window / document / fetch 等运行时能力
-> 模块顶层代码执行
-> 程序开始挂载 UI、注册事件、发起请求
-> 后续再由点击、输入、网络响应等事件继续驱动
这里最关键的分界线是:
- “入口和依赖被装配起来”是模块加载阶段
- “点击按钮后触发回调”是程序开始运行后的事件阶段
很多初学者会把这两件事混在一起,但其实它们不是同一层问题。
Node.js 场景其实也是同一个思路,只是注入的能力不同
如果换到 Node.js,整体模式并没有变,只是运行时给你的不是 DOM,而是另一套能力。
例如:
processfspathhttp- 网络、文件系统、进程相关 API
所以 Node.js 下的“外部环境调用 TS/JS 文件”,也可以理解成:
- 命令行或脚本工具指定入口
- Node.js 加载入口模块
- 解析并装配依赖模块
- 把 Node 运行时能力提供给代码
- 程序再去监听端口、处理请求或执行任务
只是浏览器注入的是 Web API,Node.js 注入的是服务端 API。
这一节最想记住的一句话
TypeScript 文件并不是被外部世界“直接调一个函数”这么简单地启动起来的。
更准确的理解应该是:
外部环境先确定入口模块,再装配依赖图,并把运行时能力注入执行环境里;等这些东西都就位之后,程序才真正开始运行。
浏览器里最典型的入口是 HTML 里的 script type="module",最典型的注入是:
- 模块系统注入
import进来的依赖 - 浏览器运行时注入
window、document、fetch这类 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)
}
这里:
main.ts是入口,负责被外部环境拉起app.ts负责把各个模块装配起来,启动流程api.ts这类模块更靠近环境或外部系统config.ts负责提供配置service.ts更适合放相对稳定的业务逻辑- 其他模块被入口逐层串起来
这种分层的一个直接好处是:当你脑子里已经接受“程序先被环境启动,再被模块系统装配”这个事实之后,代码结构也会更自然地朝这个方向长出来。
也就是:
- 入口层负责接住外部世界
- 装配层负责组织依赖
- 业务层负责真正的业务规则
这类分层会比把所有代码都塞进一个文件更容易维护。
3.7 程序启动后会不会“一直在跑”
这取决于程序里还有没有未完成的工作。
例如这个脚本通常执行完就结束:
console.log("hello")
因为打印完之后就没别的任务了。
但如果程序注册了事件监听、开启了 HTTP 服务、等待定时器或网络连接,它就会继续运行:
setInterval(() => {
console.log("tick")
}, 1000)
或者:
server.listen(3000)
所以对很多 Node.js / TypeScript 程序来说,启动之后并不是“跑完立即退出”,而是进入一种“等待事件发生”的状态。
这也是为什么 JS / TS 特别适合:
- 前端交互
- HTTP 服务
- 实时通信
- Agent 编排和工具调度
因为这些场景都不是“算完就结束”,而是“启动后持续等待外部事件”。
3.8 这一节最值得先记住的结论
如果只提炼几个最重要的结论,我觉得是下面这些:
- TypeScript 程序真正的入口通常是“入口文件”,而不是强制固定名字的
main main()可以自己写,但它是组织代码的习惯,不是语言硬性要求- 外部通常通过命令、脚本、框架或事件系统来触发你的代码
- 函数既可能是你手动调用的,也可能是由事件、Promise、请求、框架回调触发的
- 很多 JS / TS 程序启动后会进入“等待事件”的状态,而不是立刻结束
4. TypeScript 最核心的能力以及典型语法是什么?(重点)
如果只让我选两个关键词,我会选:
- 类型系统
- 异步与事件驱动
通俗地讲,TS 的类型系统非常贴近网络程序和工程项目的组织方式。很多时候,它就像是在给 JSON、配置对象、请求体、返回值这些数据结构写一份“类型模板”。这一点和 Python 里的 Pydantic 确实有点像,但 TS 的这套能力要更原生一些,因为它本来就是语言的一部分,而不是后面额外补上的工具层。语法上也通常更轻、更灵活。
至于异步和事件系统,我觉得这几乎就是 TS 的核心灵魂。它之所以在前端、Node.js、Agent、插件系统这些场景里特别顺手,本质上就是因为这些程序不是“算完就结束”的程序,而是要持续接收输入、等待事件、处理回调、串联异步流程的程序。浏览器天然就是这样一个环境,所以 JavaScript / TypeScript 从一开始就在围绕这类问题生长。
这里尤其值得强调的一点是:TS 的异步系统,核心并不是 async/await 这几个语法,而是 Promise。async/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 生态里,更容易让人以“事件片段”“异步片段”“回调片段”的方式去组织程序。你不一定先知道所有代码会按什么绝对顺序执行,但你会知道:
- 某个事件发生时要做什么
- 某个 Promise 完成后要接着做什么
- 某个状态变化之后要触发什么
然后再把这些片段组合成完整流程。
这也是为什么在 TS 里,函数经常不只是“一个立即执行的步骤”,而更像是“一个等待被挂接到某个时机上的能力单元”。回调、闭包、Promise 链、事件监听,本质上都在做这件事。也正因为如此,TS 在处理复杂交互流程时,会比单纯的顺序脚本模型更自然一些。
如果把两种语言的异步模型粗略对比一下,可以先这样理解:
- Python 更强调流程的执行.
- JS / TS 更强调“异步结果如何被包装和继续传递”(面向对象,函数也作为对象)
- Python 的
await常常让你关注“当前协程在等什么” - JS / TS 的 Promise 常常让你关注“这件事完成之后,整条链路怎么往下走”
当然,这只是理解上的侧重点,并不是说某一边绝对更强,而是它们天然更适合的问题形状不同。
TS 的运行时里还有一个很重要、但初学时容易忽略的点:事件循环里其实不只是一条简单队列,而是至少可以粗略分成两层来理解。
- 宏任务队列
- 微任务队列
可以先把它们理解成:
- 宏任务负责承载“下一轮事件”,比如
setTimeout、UI 事件、I/O 回调 - 微任务负责承载“当前轮次里需要尽快接上的后续步骤”,比如 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
原因是:
- 先执行当前同步代码,所以先打印
A和D Promise.then(...)会进入微任务队列setTimeout(...)会进入宏任务队列- 当前这轮同步代码结束后,先清空微任务,再进入下一轮宏任务
所以 Promise 在 JS / TS 里之所以显得特别“顺”,并不只是语法写得漂亮,而是因为它刚好被放在了比普通宏任务更靠前的那一层调度里。这使得很多“上一步完成,马上接下一步”的异步流程,可以表现得非常连贯。
也正因为有了这两层队列,JS / TS 的异步系统并不是简单的“谁先写谁先跑”,而是“不同来源的任务,进入不同层级的调度队列”。理解这一点之后,再看 Promise、事件回调、定时器、DOM 事件之间的执行顺序,就会清楚很多。
4.1 Promise 是 TS 异步系统的核心语法
如果只看表面语法,很多人会以为 TS / JS 的异步核心是 async/await。但更准确地说,真正的核心其实是 Promise。async/await 很重要,但它更像是 Promise 之上的语法糖;底层真正负责表达“未来会完成的结果”的,是 Promise 对象本身。
先看一个最小的 Promise 例子:
function readConfig(): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => resolve("config loaded"), 1000)
})
}
readConfig().then((config) => {
console.log(config)
})
这里有几层意思:
readConfig()并不是立刻返回字符串- 它先返回一个
Promise<string> - 真正的结果要等异步任务完成之后,才能通过
.then(...)拿到
所以 Promise 本质上就是:
把“未来才会得到的值”先包装成一个对象,让它可以被传递、组合、等待和继续处理。
这也是它比传统回调更强的地方。回调更像是“把下一步操作塞进去”;Promise 更像是“把异步结果本身抽象成一个可组合的值”。
Promise 的典型语法
最常见的 Promise 语法主要有下面几种:
new Promise(...):手动创建 Promise.then(...):上一步成功后继续处理.catch(...):捕获错误.finally(...):无论成功失败都执行Promise.resolve(...)/Promise.reject(...):快速创建已完成的 PromisePromise.all(...):并发等待多个 PromisePromise.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")
})
这段代码的意思其实非常自然:
- 先等配置加载完成
- 再处理结果
- 如果中途出错,就统一进入
catch - 最后不管成功失败,都执行
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 完成,并取出它的结果”。
也就是说:
- Promise 是模型
then/catch/finally是直接操作这个模型的方式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())
这里的重点是:
read_config是协程函数await表示当前协程在等待另一个可等待对象- 最后要通过
asyncio.run(...)把整个协程跑起来
如果把它和 TS 对照着看,会发现两边虽然都写 await,但关注点不完全一样。
Python 更容易让人觉得自己在写“可以暂停和恢复的函数流程”;而 TS / JS 更容易让人觉得自己在写“由 Promise 串起来的一系列异步结果处理步骤”。
Promise 的设计模式:它更像“异步结果对象”
从设计模式的角度看,Python 的异步更偏“协程 + 调度器”的思路;而 TS / JS 的异步更偏“Promise 对象 + 事件循环 + 回调衔接”的思路。
换句话说:
- Python 更像是在描述“这一段流程如何暂停和恢复”
- TS / JS 更像是在描述“一个异步结果对象完成后,后面要挂接哪些处理逻辑”
这也是为什么在 TS 里,你会很自然地把很多步骤写成:
- 先返回一个 Promise
- 再用
.then(...)或await把后续步骤接上 - 最后再把多个 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 这里还有一个更具体的名字,通常叫:
- Promise executor
- executor function
也就是“Promise 的执行器函数”。
这里的 resolve 和 reject 不是你手动预先定义好的变量,而是 Promise 构造器在内部调用这个 executor 时,自动传进来的两个函数:
resolve(value):把 Promise 标记为成功,并给出结果reject(error):把 Promise 标记为失败,并给出错误
例如:
const p = new Promise((resolve, reject) => {
resolve("ok")
})
这里的意思就是:这个 Promise 成功完成了,结果是 "ok"。
虽然我们通常都写 resolve 和 reject,但它们本质上只是形参名,所以名字可以改:
const p = new Promise((success, fail) => {
success("ok")
})
这在语法上完全合法。只是一般不建议这么写,因为 resolve/reject 已经是最标准、最清晰的命名。
不过要注意:你可以改“名字”,但不能改“角色”。也就是说,这两个位置本质上仍然是:
- 成功回调
- 失败回调
不是任意普通参数。
那 executor 还能不能接受别的参数?严格来说,运行时真正会传进来的只有这两个。你当然可以额外多写几个形参:
new Promise((resolve, reject, extra) => {
console.log(extra)
})
但这里的 extra 并不会被 Promise 构造器提供,通常就是 undefined。所以更准确地说:
- executor 可以声明更多参数
- 但 Promise 构造器只会给前两个位置传值
- 后面的参数没有实际意义
另外还有一个细节也很重要:executor 的返回值本身是无效的。
例如:
new Promise((resolve, reject) => {
return 123
})
这里的 return 123 不会成为这个 Promise 的结果。Promise 的状态主要由下面三件事决定:
- 你是否调用了
resolve(...) - 你是否调用了
reject(...) - 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")
所以可以粗略记成:
new Promise(...):自己控制完成时机Promise.resolve(value):直接得到一个已成功的 PromisePromise.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 运行时里:
- 少传参数,缺的位置通常就是
undefined - 多传参数,多出来的部分通常会被忽略
- 不会因为“参数数目不匹配”直接报错
这也是为什么 Promise executor 可以写成下面这样:
new Promise((resolve) => {
resolve("ok")
})
虽然运行时实际会传两个参数 resolve 和 reject,但这个箭头函数只声明接收一个参数也完全没问题,第二个参数只是没有被接住。
反过来,即便你多写参数:
new Promise((resolve, reject, extra) => {
console.log(extra) // undefined
})
语法上也没问题,只是第三个参数并不会真的被 Promise 构造器提供。
所以更准确地说:
- JavaScript 的函数调用规则本身很宽松
- Promise executor 只是刚好建立在这种宽松规则之上
- TypeScript 则会在编译期尽量把这种宽松收紧
也就是说,JS 运行时允许你“参数数目不严格匹配”,而 TS 的一个重要价值,就是在工程层面帮你把这种灵活性变得可控。
Promise 的状态系统
Promise 最核心的设计之一,就是它不是一个“普通返回值”,而是一个带有明确状态的对象。
一个 Promise 只有三种状态:
pendingfulfilledrejected
它们的含义分别是:
pending:还没有完成,结果暂时未知fulfilled:已经成功完成,并拿到了一个结果值rejected:已经失败完成,并拿到了一个错误原因
例如:
const p = new Promise((resolve) => {
setTimeout(() => resolve("done"), 1000)
})
刚创建时,p 是 pending。
1 秒后调用 resolve("done"),它就会变成 fulfilled。
如果换成:
const p = new Promise((resolve, reject) => {
setTimeout(() => reject(new Error("fail")), 1000)
})
那它会先是 pending,之后变成 rejected。
这里最重要的性质是:
Promise 一旦从
pending变成fulfilled或rejected,状态就固定了,之后不能再改。
例如:
const p = new Promise((resolve, reject) => {
resolve("ok")
reject(new Error("fail"))
})
最终它仍然会保持 fulfilled,因为第一次状态变化已经把它定下来了,后面的状态修改会被忽略。
这意味着 Promise 非常适合表示“一个异步任务的最终结论”。因为它天然满足:
- 任务开始时结果未知
- 任务结束后只会有一种最终结果
- 结果一旦确定,就不会反复变化
这就是 Promise 能稳定组合的基础。
例如:
fetchData()
.then(handleData)
.catch(handleError)
.finally(cleanup)
这背后其实就是在利用 Promise 的状态系统:
- 如果 Promise 从
pending走到fulfilled,就进入.then(...) - 如果它从
pending走到rejected,就进入.catch(...) - 不管最后是哪种结果,都会走
.finally(...)
再比如:
const result = await Promise.all([
readUser(),
readSettings(),
readTasks(),
])
这里 Promise.all(...) 能成立,本质上也是因为每个 Promise 都有明确状态:
- 所有 Promise 都
fulfilled,整体才会fulfilled - 只要有一个 Promise
rejected,整体就会rejected
所以 Promise 最厉害的地方,不只是“可以异步”,而是:
它把异步任务抽象成了一个状态明确、可传递、可组合、可观察的对象。
从设计模式的角度看,这和 Python 协程那种“把函数暂停、恢复、继续调度”的思路也不完全一样。Promise 更强调的是:先把异步结果对象化,然后围绕这个对象的状态变化去继续组织后续流程。这也是为什么它特别适合拿来拼接长链条的事件流程和异步流程。
4.2 事件系统和事件循环决定了 TS 程序如何真正运行
如果说 Promise 决定了“异步结果怎么表达”,那事件系统决定的就是“程序何时被触发、何时继续往下走”。
前端和 Node.js 程序之所以很少是“跑完就结束”,就是因为它们通常都挂在某种事件系统上。比如:
- 浏览器点击事件
- 网络请求返回
- 定时器触发
- 文件读取完成
- HTTP 请求到达
- 插件钩子被触发
例如浏览器里的事件监听:
button.addEventListener("click", () => {
console.log("clicked")
})
这段代码在注册监听器的时候,并不会立刻执行回调。它做的事情其实是:
- 把回调函数挂到某个事件源上
- 等待
click事件真的发生 - 由运行时在合适的时机调用它
所以 TS / JS 的很多代码,本质上都不是“现在执行”,而是“先注册,等时机到了再执行”。
什么是事件循环
为了让这些事件、回调、Promise 后续步骤有序执行,运行时会维护一套调度机制,这就是事件循环。
可以非常粗略地把它理解成下面这件事:
执行当前同步代码
-> 看看有没有需要先处理的微任务
-> 清空当前轮次的微任务
-> 再取出下一轮宏任务
-> 重复这个过程
也就是说,JS / TS 不是“开很多线程同时乱跑”的直观模型,而更像是“主线程 + 事件循环 + 多种任务队列”的模型。
宏任务和微任务的更准确理解
初学时经常只会记一句“Promise 比 setTimeout 先执行”,但这还不够。更准确地说:
宏任务
宏任务可以理解成“一轮事件循环要处理的主要任务单元”。常见的宏任务来源包括:
- 整体脚本执行
setTimeoutsetInterval- DOM 事件回调
- I/O 回调
- HTTP 请求到达后的处理
可以把它理解成“下一轮要处理的大事件”。
微任务
微任务可以理解成“当前宏任务结束后、进入下一轮宏任务之前,必须先清空的一批后续任务”。常见的微任务来源包括:
Promise.then(...)Promise.catch(...)Promise.finally(...)queueMicrotask(...)- MutationObserver 回调
可以把它理解成“当前这轮任务内部衍生出来、优先级更高的收尾步骤”。
调度和执行顺序
一个很实用的粗略规则是:
- 先执行当前宏任务里的同步代码
- 当前宏任务结束后,立刻清空微任务队列
- 微任务清空后,才会进入下一轮宏任务
- 新的宏任务执行过程中如果又产生新的微任务,还是要在本轮结束后先清空
所以宏任务和微任务的关系不是简单的“两个平行队列”,而更像是:
每执行完一个宏任务,都要先把本轮积累出来的微任务全部处理掉,然后才有资格进入下一轮宏任务。
典型例子:Promise 和 setTimeout 的顺序
console.log("A")
setTimeout(() => {
console.log("B")
}, 0)
Promise.resolve().then(() => {
console.log("C")
})
console.log("D")
输出通常是:
A
D
C
B
原因是:
- 整段脚本本身就是第一个宏任务
- 先执行同步代码,打印
A和D Promise.then(...)进入微任务队列setTimeout(...)的回调进入后续宏任务队列- 当前宏任务结束后,先清空微任务,所以先打印
C - 然后进入下一轮宏任务,才打印
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
这里关键在于:
Promise.resolve()先得到一个已完成的 Promise- 第一个
.then(...)的回调会进入微任务队列 - 当前同步代码执行完,先执行这个微任务,所以打印
then-1 - 第一个
.then(...)返回的 Promise 完成后,第二个.then(...)才会被安排成新的微任务 - 然后再打印
then-2
也就是说,链式 Promise 并不是“一次性全塞进队列”,而是前一步完成之后,后一步才会继续入队。
这正是 Promise 特别适合描述“异步流程链”的原因之一。
事件系统为什么特别适合 Agent 和前端
从设计模式角度看,Python 更容易让人把程序写成“主流程 + 若干 await 点”;而 TS / JS 更容易让人把程序写成“事件源 + 回调片段 + Promise 衔接”。
这就导致它在下面这些场景里会格外自然:
- 前端交互
- 实时状态变化
- 插件钩子系统
- Agent 工具调用链
- 多步骤异步编排
因为这些场景的核心不是“按顺序算一遍”,而是“很多事件不断进来,很多后续动作不断被触发”。
这里最容易混淆的地方:Event、EventEmitter、宏任务、微任务不是一回事
这一块初学时非常容易绕,因为里面很多词都带一个 “event” 或“事件”的味道,但它们其实不在同一个层级上。
一定要先把下面几个概念拆开:
EventEventEmitter- 宏任务
- 微任务
- 事件循环
它们名字看起来很像,但含义并不一样。
Event 是“发生了一件事”
最广义地说,Event 指的是某个事件本身,也就是“某件事情发生了”。
例如:
- 用户点击按钮
- 输入框内容变化
- 网络请求返回
- 文件读取完成
- 一个插件钩子被触发
所以 Event 更偏“事实”或“信号”本身。
EventEmitter 是“同步分发事件的机制”
EventEmitter 不是事件本身,而是一种发布-订阅机制。它的作用是:
- 允许别人订阅某类事件
- 在某个时刻统一通知这些订阅者
它更像一个“事件广播器”或“事件分发器”。
更重要的是,EventEmitter.emit() 默认是同步的。
例如:
emitter.on("task", () => {
console.log("listener")
})
console.log("before")
emitter.emit("task")
console.log("after")
输出通常是:
before
listener
after
这说明 emit() 不是“把事情丢到下一轮宏任务”,而是在当前调用栈里立刻同步执行监听器。
所以这里一定要强调:
EventEmitter不是宏任务队列的入口,它首先是一个同步的事件分发机制。
监听器内部当然可以再去创建异步任务,但那已经是下一层的事了。
宏任务和微任务是“调度单位”,不是事件本身
宏任务和微任务描述的是:
运行时接下来该按什么顺序执行哪些代码。
所以它们属于“调度机制”,不是“事件内容”本身。
可以先这样理解:
Event是触发源- 宏任务 / 微任务是调度容器
- 事件循环负责一轮一轮地调度这些任务
换句话说,事件说的是“发生了什么”,任务队列说的是“这些回调什么时候执行”。
宏任务里说的“事件”,和 Event 这个词不是同一个概念
这也是最容易让人迷糊的地方。
我们有时会说:
- “DOM 事件回调通常按宏任务理解”
- “下一轮事件循环会取一个宏任务执行”
这里的“事件”已经不是严格意义上的 Event 对象定义了,而是在口语上说“外部触发源带来的那一轮处理过程”。
所以不要把下面两句话混为一谈:
click是一个浏览器事件click触发后,对应监听器的执行会被纳入事件循环调度
前者说的是“发生了什么”,后者说的是“代码怎么被安排执行”。
一个更清楚的层级图
可以把它们的关系粗略记成:
外部世界发生一件事
-> 运行时识别到一个 Event
-> 找到和它绑定的监听器 / 订阅者
-> 把相关回调纳入当前或后续的调度过程
-> 事件循环按宏任务 / 微任务规则执行代码
这里:
Event是“事”- 监听器 / 回调是“对这件事的响应代码”
- 宏任务 / 微任务是“响应代码如何被调度”
- 事件循环是“整个调度规则”
为什么会误以为 EventEmitter 是“进入宏任务”的入口
这是因为很多时候我们会看到:
- 某个事件被
emit - 然后系统里发生了一连串后续动作
这会让人直觉上觉得,好像 emit() 把事情“扔进了事件循环”。
但更准确地说,emit() 通常只是同步把监听器调用起来。真正把后续逻辑放进未来调度的,往往是监听器内部做的事情,比如:
- 调用了
setTimeout(...) - 创建了 Promise 并挂上
.then(...) - 发起了 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
这里真正发生的是:
emit("task")同步执行监听器- 监听器内部创建了一个微任务
- 也创建了一个后续宏任务
- 当前同步代码结束后,先跑微任务
- 再进入下一轮宏任务
所以应该记成:
emit()本身是同步分发;
Promise 把后续逻辑挂进微任务;setTimeout把后续逻辑挂进未来宏任务。
这一小块最值得记住的结论
如果只记一句话,我觉得可以记这个:
Event、EventEmitter、宏任务、微任务、事件循环是五个不同层级的概念。
前两个偏“事件本身和分发机制”,后面三个偏“代码执行的调度机制”。
把这几个词拆开之后,再回头看前端事件、Node.js 回调、Promise 链、定时器顺序,就不会那么容易混了。
把 TS / JS 里这一组相关概念系统分层
为了避免后面越学越混,我觉得最好把这一组词统一分一下层。因为很多时候真正让人困惑的,不是某个单独概念太难,而是:
运行时概念、设计模式概念、语言语法/API 概念,经常被混着说。
下面这张表可以先把我们前面讨论到的词放到一个更清楚的框架里:
| 名称 | 属于哪一层 | 它是什么 | 更接近“概念”还是“具体语法/API” | 典型对应物 |
|---|---|---|---|---|
| 事件循环 | 运行时调度层 | 运行时安排代码执行顺序的总机制 | 概念 | 浏览器 event loop、Node.js event loop |
| 宏任务 | 运行时调度层 | 一轮事件循环中的主要任务单元 | 概念 | script、setTimeout、I/O 回调、DOM 事件回调 |
| 微任务 | 运行时调度层 | 当前宏任务结束后、下一轮宏任务前必须先清空的任务 | 概念 | Promise.then、catch、finally、queueMicrotask |
| 任务队列 | 运行时调度层 | 存放待调度任务的队列结构 | 概念 | 宏任务队列、微任务队列 |
| 调用栈 | 运行时执行层 | 当前同步代码正在执行的栈结构 | 概念 | 函数调用栈 |
| 事件 | 系统交互层 | 某件事情发生这一事实 | 概念 | click、message、request、task:done |
| 事件源 | 系统交互层 | 能产生事件的对象或系统 | 概念 | DOM 节点、WebSocket、HTTP server、插件系统 |
| 回调 | 编程模型层 | 先注册、等未来某个时机再执行的函数 | 概念 | 事件监听器、定时器回调、Node 风格 callback |
| Promise | 异步抽象层 | 对“未来结果”的对象化封装 | API + 概念 | new Promise(...)、Promise.resolve(...) |
async/await | 语言语法层 | Promise 的更易读写法 | 语法 | async function、await value |
| 事件总线 | 架构/设计模式层 | 在系统内部广播和订阅事件的抽象 | 概念 | Bus、Pub/Sub、Domain Event Bus |
EventEmitter | Node API / 实现层 | 一种具体的事件分发实现 | API | on、emit、once |
| 监听器 listener | API 使用层 | 被注册到事件源上的处理函数 | 概念 + API 用法 | addEventListener(fn)、emitter.on(fn) |
| 订阅/发布 | 设计模式层 | 一种模块解耦方式 | 概念 | Pub/Sub、Observer |
| Observer 模式 | 设计模式层 | 观察者收到被观察对象变化通知 | 概念 | DOM 监听、事件系统、状态订阅 |
| Future / Deferred Result | 设计模式层 | 对“未来结果”的抽象思路 | 概念 | Promise、Future |
| 定时器 | 运行时 API 层 | 把回调安排到未来某个时间点 | API | setTimeout、setInterval |
| 闭包 | 语言能力层 | 函数连同其外部变量环境一起被保留 | 概念 + 语言能力 | 事件回调、Promise 回调、工厂函数 |
可以先用一句话粗略记成:
Promise、EventEmitter、setTimeout这些是你在代码里真正会调用的 API 或语法入口- 宏任务、微任务、事件循环、任务队列这些是运行时内部的调度概念
- 事件总线、Observer、Pub/Sub、Future 这些更偏设计模式或架构抽象
也就是说,我们平时写代码时看到的“具体名字”,很多只是某个更高层概念在某个生态里的落地形式。
例如:
Promise是“未来结果对象”这套思想在 JS 里的实现EventEmitter是“事件分发/观察者模式”在 Node.js 里的一个典型实现setTimeout是“把工作延后到未来调度轮次”这件事的一个 API.then(...)则是“在 Promise 完成后挂接后续步骤”的具体接口
哪些更偏概念,哪些更偏语法/API
如果只从“学习顺序”来分,我觉得也可以简单分成两组:
第一组:概念性词汇
这些词更偏理解模型,通常不是你直接在代码里“写出来”的关键字,而是用来解释系统是怎么工作的:
- 事件循环
- 宏任务
- 微任务
- 任务队列
- 事件
- 事件源
- 回调
- 事件总线
- 发布-订阅
- Observer 模式
- Future / Deferred Result
- 调用栈
- 闭包
第二组:语法或 API 层面的词汇
这些东西通常是你在代码里真的会写出来、调起来、看到补全提示的:
PromisePromise.resolvePromise.rejectPromise.allPromise.race.then.catch.finallyasyncawaitsetTimeoutsetIntervalqueueMicrotaskaddEventListenerEventEmitteronemitonce
这两组之间的关系非常重要:
概念层告诉你“它本质上是什么”;
语法/API 层告诉你“在 TS / JS 里具体怎么写出来”。
再补充几个前面没集中说过、但经常一起出现的相关概念
除了前面已经重点讨论过的几个词,还有一些相关概念也值得顺手记一下:
调用栈
调用栈描述的是“当前同步代码到底执行到哪里了”。所有同步函数调用都会先进入调用栈。只有当当前调用栈清空后,事件循环才会去看微任务和下一轮宏任务。
所以它和任务队列的关系是:
- 调用栈负责“现在正在执行什么”
- 任务队列负责“接下来轮到什么”
queueMicrotask
这是一个很容易被忽略、但非常能帮助理解微任务的 API:
queueMicrotask(() => {
console.log("micro")
})
它的作用就是显式地把一个回调放进微任务队列。它能帮助你把“微任务”这个概念和 Promise 分开,因为很多人会误以为“微任务 = Promise”,其实不是。更准确地说:
- Promise 的后续处理常常进入微任务
- 但微任务不等于 Promise
once
在 EventEmitter 或 DOM 事件系统里,once 用来表示“这个监听器只触发一次”。
这也是一个很有意思的点,因为它说明事件系统本来是“可多次发生”的,而 Promise 是“只完成一次”的。once 某种程度上就是把事件监听往“单次结果”方向收了一步。
addEventListener
这是浏览器侧最经典的事件监听 API。它和 Node.js 的 EventEmitter.on(...) 很像,但不是同一个实现。你可以把它们看成:
- 浏览器世界的典型事件订阅接口
- Node.js 世界的典型事件订阅接口
思想相近,但运行环境和实现细节不同。
MutationObserver
这是浏览器里一个比较典型、也比较容易被忽略的例子。它说明:
- Observer 不只是一个抽象设计模式
- 浏览器里真的有叫这个名字的 API
- 它的回调也会和事件循环、微任务等调度规则发生关系
所以很多抽象概念在 JS / TS 世界里,往往真的会落成具体 API。
process.nextTick(Node.js)
如果后面你还会继续深入 Node.js,这个词迟早会遇到。它和微任务队列的关系比较特殊,通常可以先简单记住:
- 它是 Node.js 里的一个“非常靠前”的调度机制
- 它和浏览器的标准微任务体系不完全一样
- 初学阶段先不要把它和普通 Promise 微任务完全混为一谈
这里先知道它存在就够了,后面真的深入 Node.js 再专门拆。
一个简化后的总图
如果把这一整组概念再压缩成一个最小心智模型,我觉得可以先这样记:
语言语法 / API
-> Promise / async / await / setTimeout / EventEmitter / addEventListener
运行时机制
-> 调用栈 / 事件循环 / 宏任务 / 微任务 / 任务队列
设计模式
-> Observer / 发布-订阅 / Future / 事件总线
系统中的真实现象
-> 点击、请求返回、状态变化、工具调用完成
理解这张分层图之后,再看到某个新词时,就可以先问自己:
- 它是在描述运行时机制吗?
- 它是在描述代码里的具体 API 吗?
- 它是在描述一种设计模式吗?
- 还是只是在描述系统里发生的某件事?
只要先把层级摆正,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 的类型系统非常强调“形状”,所以它特别适合表示:
- API 请求和响应
- 数据库记录
- 配置对象
- Agent 上下文
- 事件 payload
- 组件 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 会自动推断:
T是numberR也是numberresult的类型是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>
这时:
UserPreview只保留id和nameUserPatch表示“更新用户时可选提交的字段”
这类写法在接口设计里非常常见,因为真实项目里你经常不是“重新定义一个新类型”,而是“在原有类型上裁一刀、改一层、选一部分”。
常见工具类型速查
前面列出来的这些工具类型,初看名字会有点抽象。其实它们大多都很实用,而且一旦记住,写接口和改类型会快很多。
下面按最常见的几个分别看一下:
Partial<T>
作用:把一个类型里的所有字段都变成可选。
interface User {
id: number
name: string
email: string
}
type UserPatch = Partial<User>
这时 UserPatch 相当于:
type UserPatch = {
id?: number
name?: string
email?: string
}
典型用途:
- 更新接口的 patch 参数
- 表单的局部修改
- 某个对象逐步构建的中间态
例如:
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
}
典型用途:
- 从“草稿态”切到“完整态”
- 某些配置在运行前必须补齐
- 某一步处理之后,确定字段已经全部存在
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
}
典型用途:
- 列表页只展示部分字段
- 接口返回轻量版对象
- 从大对象里裁出一个子视图
Omit<T, K>
作用:从一个已有类型里,去掉某几个字段。
interface User {
id: number
name: string
password: string
email: string
}
type SafeUser = Omit<User, "password">
这时 SafeUser 就是不包含 password 的版本。
典型用途:
- 去掉敏感字段
- 去掉前端不该碰的字段
- 基于已有类型快速构造“公开版本”
例如:
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
}
典型用途:
- 枚举值到说明文字的映射
- 事件名到处理器的映射
- 一组固定 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
}
典型用途:
- 避免手写一遍函数返回类型
- 工厂函数、hook、状态创建器这类场景特别常见
- 保持“实现”和“类型”同步
再补充几个也很常见的工具类型
除了前面这几个,实际项目里还有几个也经常会碰到:
Readonly<T>
作用:把所有字段都变成只读。
interface Config {
apiBase: string
timeout: number
}
type ReadonlyConfig = Readonly<Config>
这时对象字段就不能随便再改。
典型用途:
- 配置对象
- 不希望被下游改动的数据
- 强调“这是只读视图”
Exclude<T, U>
作用:从联合类型里排除一部分成员。
type Status = "idle" | "running" | "done" | "error"
type ActiveStatus = Exclude<Status, "done" | "error">
这时 ActiveStatus 就是:
type ActiveStatus = "idle" | "running"
典型用途:
- 裁剪状态集合
- 过滤联合类型
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]
典型用途:
- 包装已有函数
- 写高阶函数
- 保持参数定义和原函数同步
一个最实用的理解方式
如果只用一句话总结工具类型,我觉得可以这样记:
工具类型的作用,不是“凭空创造新类型”,而是“在已有类型上做裁剪、补全、过滤、映射和提取”。
所以它们特别适合工程项目。因为真实开发里,你很少真的从零定义所有类型,更多时候是在已有类型上做这些操作:
- 只拿一部分字段
- 去掉一部分字段
- 把字段全变成可选
- 把字段全变成必填
- 从函数里提取返回值或参数类型
这也是为什么工具类型会让“改类型”比“重写类型”高效得多。
类型系统也能把事件系统变得更稳
事件系统如果没有类型约束,很容易出现两个问题:
- 事件名写错
- 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 生态,又需要工程约束的项目。
典型场景包括:
- Web 前端
- Node.js 后端
- CLI 工具
- 插件系统
- Agent 编排层
- Electron 桌面应用
- 全栈项目
5.1 为什么特别适合 Agent
Agent 项目通常会同时具备下面几个特征:
- 有很多异步任务
- 有事件流和状态流
- 要调用很多外部工具或 API
- 代码分层比较多,容易扩展成插件系统
- 需要和前端或 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")
这个例子虽然简单,但已经能看出几个关键点:
- 输入和输出结构清晰
- 异步调用自然
- 工具接口统一
- 后续很容易扩展成多个 Tool、多个 Hook、多个事件
5.2 TypeScript 不一定最适合什么
反过来看,TypeScript 也不是万能的。
比如下面这些场景,它未必是第一选择:
- 非常重计算、非常吃性能的底层系统
- 强依赖数值计算和科学计算生态的任务
- 极小型一次性脚本
所以更准确的说法不是“TypeScript 很强”,而是:
TypeScript 在现代应用层工程里,尤其是在事件驱动、异步编排、前后端协作、工具生态这几个方向上特别强。
6. 我现在会如何开始学 TypeScript
如果按“速通”的思路,我会把第一阶段控制在下面这些内容里:
- 先搞懂 TypeScript 和 JavaScript、Node.js、npm 各自是什么关系
- 自己从零搭一个最小项目,能跑通
dev和build - 熟悉常见基础类型、函数类型、对象类型、数组类型
- 搞懂
interface、type、联合类型、泛型 - 熟悉 Promise、async/await、Promise.all
- 用一个小项目把这些东西串起来,比如写一个 CLI、一个小型任务调度器,或者一个最简版 Agent Runner
如果只看不写,很容易以为自己会了;但只要真的写一个小项目,像下面这种问题马上就会冒出来:
- 模块怎么拆?
- 类型放哪?
- 异步错误怎么传?
- 配置对象怎么定义?
- 事件和状态怎么表示?
这些问题一旦在项目里真正走一遍,理解会快很多。
7. 一个最小但完整的 TypeScript 心智模型
最后用几句话收束一下:
- TypeScript 不是运行时,它是 JavaScript 的类型层。
- 真正运行代码的是 Node.js、浏览器或 Bun。
- TypeScript 最大的价值是让 JavaScript 更适合大型工程协作。
- 它的核心强项是类型系统、泛型、工具类型,以及事件驱动和异步编程的工程表达。
- 如果要做 Agent、前端、CLI、Node.js 服务或插件系统,TypeScript 通常都是非常强的候选项。
如果以后继续补充,我觉得最值得单独展开的几个主题是:
- TypeScript 的类型体操:条件类型、映射类型、
infer - Node.js 的 ESM / CommonJS 模块系统
- 如何写一个类型安全的事件总线
- 如何写一个最小 Agent Orchestrator
- TypeScript 和 Python 混合项目的边界设计
附录. TypeScript 的语法细节
这一部分更像一个语法速查表,专门收一收前面正文里已经出现过、但可能还想单独回头确认的基础语法点。
如果只挑最重要的一组概念,我觉得是下面这些:
- 类型注解
- 类型推断
interface与type- 联合类型与类型缩小
- 泛型
- 模块系统
- 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"
这里会分别推断出:
port是numbertitle是string
实际写代码时,一个很常见的原则是:
- 能明显推断出来的地方,不必硬写
- 边界清晰、需要对外暴露的地方,要把类型写清楚
比如函数参数、公共接口、导出的类型,通常更值得显式标注。
1.3 interface 和 type
这两个概念初学时最容易混,但可以先这样理解:
interface更像“描述对象长什么样”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
}
这个例子里:
Task.status只能是StatusTask.owner必须符合UserUser.role又只能是固定值之一
这正是 TypeScript 在大型项目里很有用的地方。
1.3.4 type、enum、as const 的区别
这三种写法都可以表达“值只能从几个固定选项里选”,但定位不同。
第一种是 type:
type Status = "idle" | "running" | "done"
特点是:
- 只存在于编译期
- 不会生成运行时代码
- 适合单纯做类型约束
第二种是 enum:
enum Status {
Idle = "idle",
Running = "running",
Done = "done",
}
特点是:
- 编译后会生成真实的 JavaScript 对象
- 运行时可以访问
- 更像传统语言里的枚举
第三种是 as const:
const STATUS = {
idle: "idle",
running: "running",
done: "done",
} as const
type Status = (typeof STATUS)[keyof typeof STATUS]
这时:
STATUS是运行时真实对象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
这表示:
- 要么是任意字符串
- 要么是字面量
12
所以可以顺便记住一个很实用的规则:
如果一个联合类型里的某一项已经被另一项完整包含了,那么这一项通常就没有额外意义。
1.3.5.1 type 的语法为什么会让人觉得特别灵活
很多人学到这里时都会有一个很强烈的感觉:type 的语法是不是有点太灵活了?
这个感觉是对的。因为 type 不是只能写“一个类型名字等于另一个类型名字”,而是一个非常通用的类型表达式工具。
也就是说,它既可以写:
type Age = number
也可以写:
type Status = "idle" | "running" | "done"
还可以写:
type A = number | "abc"
这里的 A 表示:
- 要么是任意
number - 要么是那个固定字符串
"abc"
例如:
let x: A
x = 123
x = "abc"
// x = "def" // 报错
之所以能这样写,是因为在 TypeScript 里:
number这种是普通类型"abc"这种具体值也可以变成类型,叫字面量类型|可以把它们组合成联合类型
所以 type 常见的几种写法其实可以归成下面几类:
- 基础别名:
type Age = number - 联合类型:
type ID = number | string - 字面量类型:
type Role = "user" | "admin" - 对象类型:
type User = { id: number; name: string } - 交叉类型:
type C = A & B - 元组类型:
type Pair = [number, string] - 函数类型:
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 的数据模型说明书”,这个方向是对的。
尤其在下面这些场景里,这种感觉会特别明显:
- API 请求体
- API 响应体
- 配置文件
- 数据库存储对象
- Agent 上下文
因为这些东西本质上都是结构化数据,而 type 天然就很适合描述结构化数据。
但如果说它和 JSON “强绑定”,就稍微有点过了。更准确地说:
type很适合给 JSON 这类结构化数据建模,但它并不只服务于 JSON。
原因是 type 还能描述很多 JSON 本身表达不了的东西,例如:
- 函数类型:
type Handler = (input: string) => number - 联合类型:
type ID = number | string - 字面量约束:
type Status = "idle" | "running" | "done" - 交叉类型:
type C = A & B - 元组:
type Pair = [number, string]
这些已经不只是“JSON 长什么样”,而是在表达更广义的类型规则。
所以更准确地说,二者的关系可以理解成:
- JSON 是运行时的数据
type是编译期对数据结构的描述- TypeScript 用
type来检查代码里对这些数据的使用是否合理
还有一个特别重要的点是:
type只负责静态检查,不负责运行时校验。
也就是说,下面这个定义:
type User = {
id: number
name: string
}
并不会自动帮你校验外部传进来的 JSON 一定符合这个结构。它只是告诉编译器:“在我的代码里,这里应该把它当成 User 来理解。”
如果真的需要在运行时验证数据,还需要额外的工具,比如:
- zod
- io-ts
- valibot
- Pydantic
所以如果把这一层关系压成一句话,我觉得可以记成:
type很像给 JSON 或其他结构化数据写的一份编译期模型说明书,但它不只服务于 JSON,也不负责运行时校验。
1.3.5.3 为什么静态检查会长出这么复杂的类型系统
学到这里时,一个很自然的疑问是:
我只是想做静态检查,为什么 TypeScript 最后长出了一套这么复杂的类型系统?
这个问题问得非常好。因为静态检查本身并不天然要求“必须复杂成这样”。真正让 TypeScript 变复杂的原因,不是“静态类型”这四个字本身,而是:
它要给本来就非常灵活的 JavaScript,补上一套还能跟得上的静态类型层。
如果 JavaScript 本身是一门很严格、很简单、很少变化形状的语言,那 TypeScript 当然可以设计得更朴素。但问题是,JavaScript 天生就有很多会把类型关系拉复杂的特点。
尤其是下面这些:
- 对象结构非常灵活,字段可以随时增减、嵌套、组合
- 函数是一等公民,可以像普通值一样被传来传去、返回出去、存进对象里
- 回调和高阶函数非常多
- 异步流程里有 Promise、事件、回调链
- 一个值经常会有多种可能形状
- 运行时对参数、对象、模块边界又很宽松
这里最值得单独强调的是:
JavaScript 里的函数是一等公民。
所谓一等公民,意思是函数在 JS 里不是“只能定义然后调用”的东西,而是可以像普通值一样参与整个程序:
- 赋值给变量
- 作为参数传入别的函数
- 作为返回值返回
- 存进对象
- 动态组合
例如:
function run(handler: (input: string) => number) {
return handler("hello")
}
const fn = (text: string) => text.length
run(fn)
这里 TypeScript 要检查的,已经不只是“某个变量是不是 number”这么简单了,而是:
handler是不是一个函数- 这个函数接不接受
string - 它是不是返回
number - 这个函数被传来传去之后,类型信息还能不能保留下来
只要函数是一等公民,这类问题就会非常多。而现代 JS 项目里,几乎到处都是这种写法:回调、事件监听器、Promise 链、高阶函数、工具函数、hook、插件系统,全都建立在这件事上。
再比如,真实项目里经常不是单一形状的数据,而是:
type Result =
| { ok: true; data: string }
| { ok: false; error: string }
这时 TypeScript 要做的,也不只是“检查这个变量是不是对象”,而是:
- 表达多种可能的数据形状
- 在分支里自动缩小类型
- 保证不同分支里只能访问对应字段
例如:
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 很难同时做到两件事:
- 这个函数足够通用
- 返回值类型又足够精确
所以可以把问题总结成一句话:
TypeScript 的复杂,不只是因为它想做静态检查,而是因为它想在不改变 JavaScript 编程风格的前提下,把静态检查做得足够有表达力。
这会自然逼出很多能力:
- 联合类型
- 交叉类型
- 字面量类型
- 泛型
- 工具类型
- 类型推导
- 条件类型
如果它设计得太简单,会很快碰到两个问题:
- 表达不了真实项目里的类型关系
- 很多地方只能退回
any,那静态检查就基本失效了
所以这件事本质上是个取舍:
- 类型系统简单,学习快,但很快不够用
- 类型系统强,表达力高,但学习成本会更高
TypeScript 显然选择了后者。
不过也不用因此有太大压力。因为日常开发里,高频会用到的其实只是其中一部分:
- 基础类型
- 对象类型
type/interface- 联合类型
- 泛型
- 一些常见工具类型
- Promise /
async/await
真正特别复杂的那部分,比如高级条件类型、类型体操、infer 的重度用法,很多人平时其实并不会天天写。
所以如果把这一节压成一句最值得记住的话,我觉得可以记成:
TypeScript 的类型系统之所以复杂,不是因为静态检查天然就必须这么复杂,而是因为它要给本来就很灵活的 JavaScript,尤其是“函数是一等公民”的 JavaScript,补上一套足够有表达力的静态类型层。
1.3.6 为什么会觉得嵌套 type 和 interface 很像
这种感觉非常正常,因为在“描述对象结构”这件事上,type 和 interface 的确高度重叠。
例如:
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
}
所以“嵌套 type 和 interface 很像”这个观察,本身就是对的。
1.3.8 实际开发里怎么选
一个很好用的经验是:
- 纯对象结构,优先考虑
interface - 联合类型、字面量类型、元组、交叉类型这类组合表达,优先考虑
type - 如果团队已经有统一风格,就尽量保持一致
例如:
type Status = "idle" | "running" | "done"
interface User {
id: number
name: string
}
interface Task {
id: string
status: Status
owner: User
}
这里的分工就很清楚:
Status是固定值范围,所以用typeUser和Task是对象结构,所以用interface
1.3.9 TypeScript 的类型到底是怎么定义出来的
理解 TypeScript 时,一个很容易冒出来的问题是:
TS 的类型是不是除了基本类型,其他都只是
type拼出来的?
答案是不完全是。更准确地说,TypeScript 里的类型来源主要有:
- 内置基础类型
typeinterfaceclass- 少量其他机制,比如
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。
interface 和 class 也都能参与定义类型。
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 同时有两层含义:
- 在运行时,它是一个真正的类
- 在类型层,它也表示“
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 完全支持常见的面向对象写法,比如:
classextendsabstract classpublic/private/protectedimplements
例如:
interface Animal {
speak(): void
}
class Dog implements Animal {
speak() {
console.log("wang")
}
}
但它和 Java / C# 那种“以类为中心”的语言还是不太一样。很多现代 TypeScript 项目里,更常见的风格是:
- 用
type或interface描述结构 - 用普通函数组织逻辑
- 只有在确实需要封装状态和行为时,再使用
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 一句话理解 type、interface、class 的关系
可以先用一个很实用的总结:
type:最通用的类型表达工具interface:更专注于对象结构描述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.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 这边的手感确实有一些不一样。
先记最核心的三句话:
async会把一个函数变成“返回 Promise 的函数”await的作用是等待 Promise 完成,并取出它的结果- 普通函数可以调用
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
}
这完全合法。
只是你要注意:
- 普通函数可以调用
async函数 - 但它不能直接用
await - 所以它通常只能拿到 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)
不过这通常要求:
- 文件本身是 ESM 模块
- 运行环境支持 top-level await
而下面这种写法是不行的:
function run() {
const user = await getUser() // 报错
}
因为 await 不能直接出现在普通函数里。
JS 和 Python 的感觉为什么不太一样
Python 里经常会写:
asyncio.run(main())
这会让人感觉“异步程序必须显式从 event loop 启动”。
而 JS / TS 这边通常不是这种体验。因为无论是浏览器还是 Node.js,运行时本身就已经在事件循环里了。你不是去“启动一套异步系统”,而更像是在已有系统里挂接异步任务。
所以可以粗略记成:
- Python 更像“显式启动异步主流程”
- 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 什么时候完成。
所以二者的区别可以理解成:
return更像“这个 async 函数执行到这里成功结束了”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
}
失败时的对应关系
成功时可以粗略类比:
return value≈resolve(value)
失败时则可以类比:
throw error≈reject(error)
例如:
async function foo() {
throw new Error("fail")
}
大致等价于:
function foo() {
return new Promise((resolve, reject) => {
reject(new Error("fail"))
})
}
所以如果压成一个最实用的对照表,可以先记成:
| 场景 | async 函数 | 手写 Promise |
|---|---|---|
| 成功返回值 | return value | resolve(value) |
| 失败返回错误 | throw error | reject(error) |
所以最后可以用一句话总结:
async函数里的return和 Promise 的resolve,在“成功结果传播”这件事上基本等价;但前者是语言语法层的自动包装,后者是你手动控制 Promise 状态的 API。