JavaScript 与 TypeScript 全面速查手册

速查总览:这一页怎么查

本页是速查手册,不按学习顺序读、按问题翻:每章第一张卡都是把该章设施一次列全的速查表,后面的卡再逐条展开语义与坑。这一章给三张索引——章节地图(想查什么翻哪章)、JS 与 TS 的差异对照、语言版本与运行时的支持矩阵。

全页三十四章分四段:02–18 是 JavaScript 语言本身(语法与语义),19–20 是标准库与宿主 API(记不住名字时翻这里),21–29 是 TypeScript 类型系统,30–33 是工具链与工程。每章开头那张速查表卡把该章涉及的全部语法或 API 平铺成一张表,先扫表定位,再展开具体卡片看语义与坑。

JavaScript · 语言

章查什么关键词
01怎么把代码跑起来、报错怎么读node / tsx / REPL · 五类 Error · console
02变量声明与作用域规则var / let / const · TDZ · 闭包
03类型判定与转换typeof · == 规则 · Number / String
04语句与循环写法for-of / for-in · switch · falsy
05数组方法(最常翻的一章)map / reduce · sort vs toSorted
06函数形式与 this 绑定箭头函数 · call / apply / bind
07解构、展开、剩余参数默认值 · 重命名 · 嵌套解构
08对象操作与拷贝深度Object.* · 浅拷贝 · 属性描述符
09键值集合Map / Set / WeakMap / WeakSet
10现代运算符与优先级?. · ?? · ??= · 位运算
11类、原型与元编程class · # 私有 · Proxy / Reflect
12迭代协议与生成器Symbol.iterator · yield* · for await
13错误类型与处理Error 家族 · cause · 全局兜底
14异步与事件循环Promise 组合子 · 微任务 · async
15模块与包入口ESM / CJS · exports 字段
16日期与时间Date · Intl · day.js · Temporal
17序列化与编码JSON · Base64 · URL 编码
18值语义的边角(深水区)相等三兄弟 · ToPrimitive · 装箱

标准库 · 宿主 API · TypeScript · 工具链

章查什么关键词
19语言自带的内置对象(任何环境都有)String / Math / Object / JSON / RegExp
20宿主提供的 API(多数只有浏览器有)fetch · DOM · 事件 · Storage · Worker
21TS 基础类型unknown / never / 字面量 / 元组
22接口与类型别名interface vs type · 索引签名
23函数类型重载 · 类型谓词 · this 参数
24泛型约束 · 默认 · infer · 变型注解
25类型收窄判别联合 · 守卫 · satisfies
26内置工具类型(一张表列全)Partial / Pick / ReturnType / Awaited
27类型层运算映射类型 · 条件类型 · 模板字面量类型
28类在 TS 里的增强修饰符 · 参数属性 · 装饰器
29tsconfig 与工程配置strict 家族 · moduleResolution
30运行时与包管理命令npm / pnpm / bun · semver
31不依赖框架的组件Custom Elements · Shadow DOM
32测试vitest · node:test · 断言与 mock
33规范、兼容性与资料索引TC39 阶段 · MDN · Baseline
别把章号当难度。本页按话题分章,不按由浅入深排:05 章的数组、14 章的异步是日常写得最多的部分,而 18 章「深水区」列的是平时遇不到、一遇到就查半天的值语义边角。带「进阶」徽章的卡片是同一话题下更深的一层,跳过不影响读同章其它卡。
两种入口,看你手上有什么线索。只记得「想干什么」——从这张表定位到章,再看该章第一张速查表;已经知道 API 名字、只是忘了签名——直接用浏览器的页内搜索搜名字,卡片标题和速查表里都写了原名。

19 与 20 章的分界最值得先记住:19 章是 ECMAScript 语言的一部分,浏览器 / Node / Deno / Bun 里一定存在;20 章是宿主给的,其中 DOM、事件、Storage、Worker 只有浏览器有。

TypeScript = JavaScript + 只存在于编译期的类型层。编译产物是删掉类型注解的普通 JS(类型擦除),运行时行为一模一样——所以本页 02–20 章的一切在 TS 里全部照旧成立,21 章起讲的是额外多出来的那部分。

关键差异

维度JavaScriptTypeScript
类型检查无,错误留到运行时编译期检查,tsc --noEmit 只查不产出
怎么跑直接跑要转译:tsc / tsx / esbuild / swc;Deno、Bun 与新版 Node 可直接跑 .ts
类型系统—结构化:形状对上就兼容,不看类型名
运行时校验手写同样要手写——类型擦除后不校验任何外部数据
多出的语法—类型注解、interface、type、泛型、enum、as、satisfies、参数属性、abstract
不想引编译步骤—JSDoc 注释 + checkJs,能拿到大半检查能力

什么时候上 TS

  • 几百行的脚本:纯 JS 更轻,或加 JSDoc;
  • 多人协作 / 上千行 / 有对外 API:基本必上,收益随规模增长;
  • 发布成库:即使用 JS 写,也该产出 .d.ts,否则使用者那边全是 any。
// 同一段逻辑:TS 版本(上)擦除后就是 JS 版本(下),一字不多
function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

function pluck(obj, key) {
  return obj[key];
}

// 结构化类型:没写 implements,形状对上就兼容
interface Point { x: number; y: number }
const p: Point = { x: 1, y: 2, z: 3 };  // ❌ 字面量多字段会被「多余属性检查」拦下
const raw = { x: 1, y: 2, z: 3 };
const q: Point = raw;                // ✅ 经变量中转就合法,形状够用即可
类型擦除意味着运行时一无所知。const u = await res.json() as User 里的 as 只是让编译器闭嘴,接口少给一个字段照样一路飘到很远才炸。外部数据(HTTP 响应、localStorage、URL 参数、用户输入)必须在运行时真校验——zod、valibot 这类库校验完顺带产出类型,比手写 as 安全。
看到 TS 报错但代码「明明能跑」,先分清是哪一层的事。类型报错不阻止 tsx / esbuild / Vite 产出可运行的代码——它们只删注解、不做检查;真正的把关要靠单独跑一次 tsc --noEmit,CI 里这一步不能省。

JS 一年一版,特性按年份而不是版本号记。写业务代码时真正要问的只有一句:目标环境有没有——下表列出每版最常用的东西和它进入 Node 的大致版本,拿不准就查 MDN 页头的 Baseline 徽章。

各版本带来了什么

版本常用特性Node
ES2015let/const、箭头函数、类、模板串、解构、Promise、Map/Set、Symbol、生成器、模块6
ES2016**、Array.includes7
ES2017async/await、Object.entries/values、padStart8
ES2018对象展开、for await、Promise.finally、正则具名捕获组10
ES2019flat/flatMap、Object.fromEntries、trimStart/trimEnd12
ES2020?.、??、BigInt、Promise.allSettled、动态 import()、globalThis14
ES2021replaceAll、??=/&&=/||=、Promise.any、WeakRef、数字分隔符15
ES2022顶层 await、类字段与 # 私有、静态块、at()、Object.hasOwn、error.cause16.11
ES2023findLast、不可变四件套 toSorted/toReversed/toSpliced/with20
ES2024Object.groupBy、Promise.withResolvers、Array.fromAsync、正则 v 标志21–22
ES2025Set 集合运算(union/intersection…)、Iterator 惰性方法、RegExp.escape、import 属性、Promise.try22–24

四个运行时的差别(写脚本前先确认)

环境模块默认直接跑 TS要留意的
浏览器ESM(type="module")否有 DOM / Storage / Worker,无 fs / process
Node由 package.json 的 type 决定22.6+ 实验、22.18 起默认开fetch / URL / structuredClone 已内置
DenoESM是默认无文件 / 网络权限,要显式授权
BunESM,兼容 CJS是启动最快,少数 Node 原生模块行为有差
语法能编译,API 不会凭空长出来。TypeScript 的 target 只管把新语法降级成旧语法(箭头函数变 function),不会给你补 Object.groupBy 这种运行时方法——那要靠 polyfill 或 core-js。同理 lib 选项只影响「编译器认不认识这个 API」,跟目标环境有没有它是两码事:lib 写了 ES2024 而线上是老浏览器,照样运行时报 undefined is not a function。
「这个特性能用吗」有两个可靠答案来源:MDN 每个页面顶部的 Baseline 徽章(widely available 表示三大浏览器都已支持满两年半,可以直接用),以及 node.green 查 Node 版本对应关系。TC39 提案的阶段(Stage 1–4)见 33 章——只有 Stage 4 才是已进标准。

JavaScript · 运行、报错与调试速查

三张表解决「跑不起来」与「跑起来但不对」:运行方式表(浏览器 / Node / TS / 单文件依赖,各自的命令与适用场合)、报错对照表(错误文本 → 病因 → 修法,五类内置错误加上最常见的具体信息)、调试输出表(console 全套方法、断点、性能计时)。

同一段 JS 至少有五种跑法,区别不在快慢,在能拿到什么环境:浏览器里有 DOM 没有 fs,Node 里反过来。下表按「你手上是什么」选命令。

跑一段代码

场合命令 / 做法说明
试一两行浏览器 F12 → Console,或终端敲 node 进 REPLREPL 里 .exit 退出,.editor 进多行模式
跑一个文件node app.jsNode 22+ 也能直接 node app.ts
改一次自动重跑node --watch app.jsNode 18.11+ 内置,不用 nodemon
跑 TypeScriptnpx tsx app.ts只转译不检查;检查另跑 tsc --noEmit
编译 TS 再跑npx tsc && node dist/app.js产出物要发布时用这条
免安装依赖deno run -A app.ts直接跑 TS,依赖按 URL / npm: 前缀解析
要最快启动bun app.tsTS / JSX 原生支持
跑在网页里<script type="module" src="app.js"></script>模块脚本天然延迟执行,无需 defer
网页要看效果npx serve . 或编辑器的 Live Server别用 file:// 开,模块与 fetch 会被 CORS 挡
传参给脚本node app.js a b → process.argv.slice(2)Node 18.3+ 有 util.parseArgs 解析选项

环境自检

问题怎么确认
装没装、什么版本node -v / npm -v(推荐 LTS 偶数版)
装了多个版本要切fnm use 22 或 nvm use 22
当前是 ESM 还是 CJS看 package.json 的 type;.mjs 恒为 ESM,.cjs 恒为 CJS(15 章)
我在哪个运行时里typeof window / typeof process / typeof Deno
// 一个文件跑通四件事:读参数、读环境变量、异步、退出码
const [name = "world"] = process.argv.slice(2);
console.log("hello, " + name);

const res = await fetch("https://example.com");   // 顶层 await:ESM 里可直接用
if (!res.ok) {
  console.error("请求失败:", res.status);
  process.exit(1);                            // 非 0 退出码,CI 才知道这步挂了
}
直接双击 HTML 用 file:// 打开是最常见的坑。该协议下 <script type="module"> 会被同源策略拦成 CORS 错误、fetch 本地文件一律失败,而报错信息完全指不到真正原因。永远用一个本地 HTTP 服务打开页面,一句 npx serve . 就够。
选运行方式的唯一判据是「需要哪个环境的 API」。要碰 DOM、localStorage、点击事件,就得跑在浏览器里;要读写文件、起服务、调 process,跑 Node。两边都要的逻辑(纯计算、fetch、URL)写成不依赖任一方的模块,两边都能 import。

JS 的报错读三样:错误类型(决定病因大类)、信息里的名字(谁是 undefined)、栈顶第一行(文件与行号,往下是调用链)。下面第一张表按类型分,第二张按你实际会看到的原文。

五类内置错误

类型含义典型病因
SyntaxError解析阶段就没过括号 / 引号没配对;把 JSON 当 JS;CJS 里写 import
ReferenceError名字根本不存在拼错;忘了 import;在 TDZ 里提前用(02 章);把浏览器 API 用在 Node 里
TypeError名字存在,但值不是那个类型对 undefined 取属性;把非函数当函数调;改 const 绑定
RangeError值越界递归太深;new Array(-1);toFixed(101)
URIError编码 / 解码失败decodeURIComponent("%")(17 章)

按原文查

报错原文真正的病因怎么修
Cannot read properties of undefined (reading 'x')取属性的那个对象是 undefined,不是 x 有问题先打印那个对象;链路上用 ?.(10 章)
x is not a function拿到的不是函数:拼错方法名、import 方式不对、值是 undefinedconsole.log(typeof x, x) 看真身
Cannot access 'x' before initializationlet/const 的暂时性死区把声明提到使用之前(02 章)
Assignment to constant variable给 const 重新赋值(改它内部属性是允许的)换 let,或只改属性
Unexpected token < in JSON服务端返回的是 HTML 错误页,被当 JSON 解析先判 res.ok,再 res.json()(14 章)
Cannot use import statement outside a module当前文件按 CJS 解析package.json 加 "type": "module",或改扩展名 .mjs
require is not defined反过来:ESM 里用了 CJS 写法改 import,或用 createRequire(15 章)
ERR_MODULE_NOT_FOUNDESM 下相对导入必须带扩展名写成 ./util.js
document / localStorage is not defined浏览器 API 跑在 Node 里(或 SSR 阶段)挪进浏览器侧代码或做环境判断
Maximum call stack size exceeded递归没有出口,或对象互相引用无限展开补终止条件;深拷贝用 structuredClone
Unhandled promise rejectionPromise 挂了但没人接补 catch;Node 里这会直接终止进程(13 章)
NaN 出现在计算结果里某个操作数是字符串 / undefinedNumber.isNaN 定位;入口处转换(03 章)
Cannot set properties of nullDOM 还没渲染就 querySelector 到了 null脚本放 </body> 前或用 type="module"
报错信息里的名字常常不是罪魁祸首。Cannot read properties of undefined (reading 'name') 说的不是 name 有问题,而是 .name 前面那个东西是 undefined——真正要查的是「它为什么是 undefined」:接口字段名对不上、数组越界、await 忘写。养成先打印那个对象本身的习惯,能省一半排查时间。
栈的读法:自顶向下,第一行带你项目路径的那句才是现场。上面若干行常在 node_modules 里,那只是别人的代码替你炸了。异步栈天生更短——需要完整链路时给错误加 cause(13 章)重新抛出,比在每层打日志有效。

console 远不止 log:表格、分组、计时、计数、条件断言各有专用方法,选对了不用自己拼字符串。真要看变量演化时,断点比打印快得多。

console 方法表

方法用途备注
log / info / warn / error四个级别warn/error 走 stderr,带栈
table(arr)对象数组渲染成表格第二参可指定要显示的列
dir(obj, {depth: null})展开深层对象Node 下 log 默认只展开两层
group / groupEnd折叠一组输出groupCollapsed 默认收起
time / timeEnd / timeLog耗时统计同一个标签配对使用
count / countReset数这行跑了几次查重复渲染很好用
assert(cond, msg)条件为假才打印省掉一层 if
trace()打印当前调用栈查「谁调用了我」

断点与检查器

做法怎么用
源码里下断点写一行 debugger;,开着 DevTools 时自动停住
浏览器调试Sources 面板点行号;条件断点右键设置表达式
Node 调试node --inspect-brk app.js,浏览器打开 chrome://inspect
编辑器调试VS Code 里 .js/.ts 直接按 F5,断点可跨 TS 源码(靠 source map)
看某个变量的每次变化DevTools 右键属性 → Break on 属性修改
// 打印对象别用字符串拼接:拼完就只剩 [object Object]
console.log("user: " + user);          // ❌ user: [object Object]
console.log("user:", user);           // ✅ 逗号分隔,控制台可展开
console.table(users);                 // ✅ 一组对象直接看成表

// 计时与计数:查性能和重复执行
console.time("parse");
parseAll(input);
console.timeEnd("parse");            // parse: 12.4ms

// 在链式调用中间插一脚看中间值,不破坏表达式
const out = items
  .filter((x) => x.ok)
  .map((x) => (console.log("mid", x), x.value));
console.log 留在生产代码里不只是脏。它在热路径上是同步 I/O,能把吞吐拖垮;打印整个大对象还会让它无法被回收,成为内存泄漏源。调试完就删,或换成有级别开关的日志库;构建工具里配 drop_console 也可以,但别依赖它兜底。
打印无效时,先怀疑「打印的时机」而不是「打印的内容」。控制台展开对象看到的是展开那一刻的值,异步改过之后再展开就对不上了——要看当时的快照,打印 structuredClone(obj) 或 JSON.stringify(obj)。

JavaScript · 变量与作用域

六种声明方式的作用域、提升、可否重复声明与是否挂到全局,一张表对齐;随后是闭包与 TDZ 的具体行为。

这一章的全部规则可以压成一张表:谁的作用域到哪、用在声明之前会怎样、能不能重复声明、会不会污染全局对象。日常只需记结论——const 默认,要改再换 let,var 只在读旧代码时遇到。

声明方式对照

写法作用域声明前使用同名重复声明可重新赋值挂到 globalThis
var函数undefined(提升但未赋值)允许是顶层声明时是
let块 {}抛 ReferenceError(TDZ)报错是否
const块抛 ReferenceError(TDZ)报错否(但对象内部可改)否
function 声明块(严格模式下)可以调用,整体提升允许,后者覆盖是顶层声明时是
class块抛 ReferenceError(TDZ)报错是否
import模块顶层可用(绑定在求值前建立)报错否否
函数参数函数体—非严格模式允许是否
catch (e)catch 块——是否

按需求查

想要写法
循环里每轮拿到独立的值for (let i …)——每次迭代都是新绑定,var 全共享一个
常量不可变(含内部)const + Object.freeze(只冻一层,见 08 章)
私有状态闭包 / 模块作用域 / 类的 # 字段(11 章)
跨环境拿全局对象globalThis——浏览器 window、Node global 的统一写法
只在块内临时用裸块 { let tmp = …; },出块即失效
立即执行并隔离(() => { … })();模块化之后已很少需要
// 闭包:函数记住的是「变量本身」,不是当时的值
function counter() {
  let n = 0;
  return { inc: () => ++n, get: () => n };   // n 外部拿不到,只能经这两个口
}

// let 每轮新绑定,var 三轮共享一个
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i));  // 0 1 2
for (var j = 0; j < 3; j++) setTimeout(() => console.log(j));  // 3 3 3

// TDZ:声明存在但不可用,与「未声明」的报错文本不同
console.log(a);   // ReferenceError: Cannot access 'a' before initialization
let a = 1;
「提升」这个词把两件事混在了一起。var 与函数声明是提升且可用(前者值为 undefined,后者能直接调用);let/const/class 同样被提升到块顶,但落进暂时性死区,碰它就抛 ReferenceError。所以「let 不提升」是错的说法,正确的说法是「提升了但不能用」——这也解释了为什么它的报错是 Cannot access 'x' before initialization 而不是 x is not defined。
默认 const,需要重新赋值时才 let。这不是风格洁癖:读代码时看到 const 就知道这个名字此后不会再指向别的东西,省掉一次全文搜索。const 锁的是绑定不是内容,const arr = [] 之后 arr.push(1) 完全合法。

这三个关键字不是三种风格之争,而是三件事的差异:作用域边界、声明前能不能访问、绑定能不能重新赋值——任何一条搞混,都会写出「变量莫名是 undefined」或「循环闭包全拿到同一个值」这类经典 bug。

var 是函数作用域,会被提升(hoisting)到函数顶部,且可以重复声明,容易造成意外覆盖。let 和 const 是块级作用域({}内有效),存在「暂时性死区」(TDZ)——在声明之前访问会直接报错,而不是像 var 那样得到 undefined。const 声明的是常量绑定,意味着不能重新赋值,但如果值是对象或数组,其内部属性仍然可以修改。
function test() {
  if (true) {
    var a = 1;   // 函数作用域,泄漏到外面
    let b = 2;   // 块级作用域,只在 if 内有效
  }
  console.log(a); // 1(能访问到)
  console.log(b); // ReferenceError: b is not defined
}

console.log(x); // undefined(var 提升)
var x = 5;

console.log(y); // ReferenceError(TDZ,暂时性死区)
let y = 5;

const obj = { name: "Tom" };
obj.name = "Jerry";   // ✅ 合法:改的是属性,不是绑定本身
obj = {};               // ❌ TypeError: Assignment to constant variable
常见误区:认为 const 代表「对象不可变」。实际上 const 只锁定变量与值之间的引用关系,对象内部属性依然可变。要真正冻结对象需用 Object.freeze(obj)(仅浅冻结)。
工程惯例:默认全部用 const,只有确定需要重新赋值(如循环计数器、累加器)时才用 let。这样代码意图更清晰,也能让 ESLint 帮你发现误改的 bug。

闭包常被当成玄乎的「高级特性」,其实它只是「函数是一等值」和「词法作用域」两条设计撞在一起的必然结果——理解了它,useState、防抖节流、私有变量才不再是背下来的魔法。

探源:闭包不是一个「特性」,而是 JS 两条设计的必然产物——① 函数是一等值(能被返回、传递),② 采用词法作用域(函数用哪个变量,在它被写下的位置就定死了,与在哪调用无关)。于是当内部函数被返回到外部时,引擎不能销毁它出生时所在的变量环境(否则它引用的变量就没了)——这份「被一直留住的变量环境 + 引用它的函数」就是闭包。顺着这个根:数据私有化(外部只能经由闭包暴露的函数碰到变量)、防抖节流、柯里化全是「让状态活在闭包里」的应用;循环里的经典坑也源于此——var 时所有闭包共享同一个变量,let 每轮是新绑定。(下例用函数写法以避开箭头函数,箭头函数见 06 章。)
function createCounter() {
  let count = 0;          // 外部变量被"闭包"住
  return {
    increment: function () { return ++count; },
    reset: function () { count = 0; },
    value: function () { return count; }
  };
}

const counter = createCounter();
counter.increment();
counter.increment();
console.log(counter.value()); // 2
// count 变量外部无法直接访问 —— 这就是"私有变量"
经典坑:在 for 循环里用 var 创建闭包,所有闭包会共享同一个变量,最终都拿到循环结束后的值。用 let 可以解决,因为 let 每次迭代都会创建新的块级绑定。
记住一句话:"闭包 = 函数 + 它能访问的外部变量环境"。理解闭包后,React Hooks 的 useState、防抖/节流函数都会变得很容易理解。

严格模式今天很少需要你手写,但它没有消失:ES 模块和 class 里默认就开着——所以理解它,本质是理解「为什么同一段老代码,放进现代模块里就突然报错了」。

严格模式是 ES5 引入的运行模式,能让 JS 引擎更早地抛出错误而不是静默失败。在文件或函数顶部写 'use strict' 开启。ES6 模块(import/export)和 class 内部默认就是严格模式,不需要手动声明。
'use strict';

x = 10; // ❌ ReferenceError(非严格模式下会静默创建全局变量)

function f(a, a) { } // ❌ 严格模式禁止重复参数名
严格模式最容易咬人的一处:普通函数直接调用时,函数体里的 this 是 undefined;非严格模式下它会指向全局对象(Node 里是 globalThis)。由于 ES 模块和 class 默认严格,把一段老代码搬进模块后,原本靠 this 拿全局的写法会突然抛 Cannot read properties of undefined。
现代项目用 ES Module 或 class 时已自动严格模式,通常不需要手写这行,但理解它能帮你看懂为什么某些「老式写法」在现代代码里会报错。

JavaScript · 数据类型与转换

三张表:typeof 的全部返回值与判定手段、各种值转成数字 / 字符串 / 布尔的结果、== 与 === 与 Object.is 的差别。后面的卡逐条讲清为什么。

JS 有 7 种原始类型(undefined、null、boolean、number、bigint、string、symbol)加 object。麻烦全在判定和隐式转换上,下面三张表把结论列全。

怎么判定

想判断写法说明
原始类型typeof x返回 8 种字符串之一;typeof null === "object" 是历史遗留 bug
是不是数组Array.isArray(x)typeof 对数组返回 "object",指望不上
是不是 nullx === null只有严格等号可靠
null 或 undefinedx == null唯一推荐使用 == 的场合,一次判两个
是不是 NaNNumber.isNaN(x)全局 isNaN 会先转换,isNaN("abc") 得 true
是不是整数Number.isInteger(x)大整数安全范围用 Number.isSafeInteger
是不是有限数Number.isFinite(x)排除 Infinity 与 NaN
类实例x instanceof Foo跨 iframe / 跨 realm 会失效
内置对象的真实种类Object.prototype.toString.call(x)得到 [object Date] 这类,能区分 Date / RegExp / Map
普通对象(非数组非 null)typeof x === "object" && x !== null && !Array.isArray(x)没有更短的写法
自己有这个属性Object.hasOwn(obj, k)ES2022;旧写法 hasOwnProperty

转换结果表(背下这几行能省很多调试)

值Number(x)String(x)Boolean(x)
""0""false
"12px"NaN(parseInt 得 12)"12px"true
" 42 "42(两端空白被忽略)原样true
null0"null"false
undefinedNaN"undefined"false
[]0""true
[7]7"7"true
[1,2]NaN"1,2"true
{}NaN"[object Object]"true
"0"0"0"true
NaNNaN"NaN"false

falsy 只有 八个:false、0、-0、0n、""、null、undefined、NaN。其余一切为真,包括 []、{}、"false"、"0"。

三种相等

写法规则用在哪
===类型不同直接 false;NaN !== NaN;+0 === -0默认用它
==先转换再比:"1" == 1、null == undefined、[] == false 全为真只用 x == null 这一种
Object.is同 ===,但 NaN 等于自身、+0 不等于 -0需要区分 ±0 或判 NaN 时
Number("") 是 0,不是 NaN。于是 Number(input.value) 在用户什么都没填时静静给出 0,一路当成合法数字写进数据库。Number(null) 同样是 0,Number([]) 也是 0。要区分「空」和「零」就必须先判空串再转换,别指望 NaN 帮你兜底。
把转换收在边界上。表单值、URL 参数、localStorage 取出来的都是字符串,进业务逻辑前统一转一次(Number() 严格、parseInt(s, 10) 容忍尾巴),中间层就不必再到处防御。判空优先 ?? 而不是 ||——后者会把 0 和 "" 一并当空(10 章)。

先分清「基本类型按值存、对象按引用存」,后面几乎所有「改了 a 结果 b 也跟着变」「相等比较出人意料」的坑,根都在这里。

JS 有 7 种基本类型(存值,不可变):Number、String、Boolean、undefined、null、Symbol(ES6)、BigInt(ES2020)。其余都是Object引用类型(数组、函数、日期等都是对象)。基本类型按值存储和复制,对象按引用存储和复制。
typeof 42            // "number"
typeof "hi"          // "string"
typeof true          // "boolean"
typeof undefined     // "undefined"
typeof null           // "object"  ⚠️ 历史遗留 bug
typeof Symbol()      // "symbol"
typeof 10n            // "bigint"
typeof {}             // "object"
typeof []             // "object"(用 Array.isArray() 判断)
typeof function(){} // "function"

const a = { x: 1 };
const b = a;        // b 和 a 指向同一个对象
b.x = 99;
console.log(a.x); // 99(引用类型共享内存)
typeof null === "object" 是 JS 早期实现的 bug,由于兼容性原因一直保留至今。判断是否为 null 应该用 value === null。
判类型别只靠 typeof:它把 null 说成 "object"、把数组也说成 "object"。判 null 用 x === null,判数组用 Array.isArray(x),判 NaN 用 Number.isNaN(x)——typeof 只适合快速区分基本类型和函数。

下面不是要你背下 == 的转换规则,恰恰是让你彻底放弃背它:规则复杂到连老手都记不全,正确的应对是几乎永远用 ===。

===(严格相等)不做类型转换,类型不同直接返回 false,推荐始终使用。==(宽松相等)会先进行隐式类型转换再比较,规则复杂且反直觉,是大量 bug 的来源。
1 == "1"        // true(字符串被转成数字)
1 === "1"       // false(类型不同,直接 false)
null == undefined  // true(特例)
null === undefined // false
0 == false      // true
"" == 0         // true
[] == false      // true
NaN === NaN     // false!NaN 不等于自己
Number.isNaN(NaN) // true(判断 NaN 的正确方式)
规则:永远用 === 和 !==,唯一公认例外是 value == null(同时判断 null 和 undefined)。
想直观感受 == 有多不讲道理,跑一遍这几个——全是 true:"" == 0、"0" == false、[] == false、[] == ![]。记不住是正常的,所以规则就一条:始终用 ===,只在 x == null(一次判 null 和 undefined)时破例。

把一个值放进 if、||、? 这些位置时,JS 会先偷偷把它转成布尔——记住 8 个假值这份短名单,就等于记住了所有分支的走向。

JS 中只有 8 个假值:false, 0, -0, 0n, "", null, undefined, NaN。其余所有值(包括空数组 [] 和空对象 {})都是真值,这一点和 Python 不同,是最容易踩的坑。
if ([])  console.log("空数组是真值");   // 会执行!
if ({})  console.log("空对象是真值");   // 会执行!
if ("0") console.log("字符串0是真值"); // 会执行!

Boolean("")     // false
Boolean("0")    // true(非空字符串)
Boolean(null)   // false
Boolean([])     // true
后端转来的字符串 "0" 或 "false" 在 JS 里是真值!判断接口返回的布尔字段时一定要小心字符串类型。
反过来记更省事:假值只有 8 个(false, 0, -0, 0n, "", null, undefined, NaN),其余全是真值——包括 []、{}、"0"、"false"。想显式看一个值的真假,用 Boolean(x) 或 !!x。

显式转换你说了算,隐式转换是运算符背着你干的——JS 里大半「结果莫名其妙」的表达式,都是某个运算符偷偷触发了一次你没料到的转换。

显式转换:用 Number()、String()、Boolean() 主动转换。隐式转换:运算符触发的自动转换,最常见的坑是 + 运算符——只要有一方是字符串,就会触发字符串拼接而非加法;其余算术运算符(- * /)都会把操作数转成数字。
1 + "1"     // "11"(字符串拼接)
1 - "1"     // 0(转成数字相减)
1 + true    // 2(true 转成 1)
1 + null    // 1(null 转成 0)
1 + undefined // NaN
[] + {}         // "[object Object]"

Number("42px")   // NaN
parseInt("42px") // 42(只解析开头能识别的部分)
Number() 有一批反直觉的空值结果:Number("")、Number(" ")、Number(null)、Number([]) 全都得 0(不是 NaN),只有 Number(undefined) 才是 NaN。所以拿 Number(x) 校验用户输入时,空字符串会被悄悄当成 0 放过——先 trim 判空、再转换。
记住一条规律:+ 一旦遇到字符串就变拼接;- * / 几乎总是转数字。不确定时显式调用 Number() / String() 更安全可读。

字符串方法虽多,却有个共同前提值得先立住:字符串不可变,每个方法都返回新串、绝不改原串——想不到这点,就会写出「调了 trim 却发现原变量没变」的困惑。

模板字符串用反引号包裹,内部 ${...} 可嵌入任意表达式并支持跨行——全页高频使用,取代老式的 + 拼接。字符串是不可变的,所有方法都返回新串:slice 截取、split 切成数组、replace/replaceAll 替换、trim 去首尾空白、padStart/padEnd 补齐、includes/startsWith/endsWith 判断包含。复杂模式匹配交给正则,正则见站内 regex 页。
const user = "Ada";
// 模板字符串:反引号包裹,${...} 内插表达式、可跨行
const msg = `Hello, ${user}!`;   // "Hello, Ada!"

// 字符串不可变,方法都返回新串
"  hi  ".trim();               // "hi"(去首尾空白)
"a,b,c".split(",");            // ["a", "b", "c"]
"hello".slice(1, 3);          // "el"(起止下标,可负)
"2024-06".replace("-", "/");  // "2024/06"(replaceAll 换全部)
"abc".includes("b");           // true(还有 startsWith/endsWith)
"7".padStart(3, "0");          // "007"(补齐到 3 位)
"abc".toUpperCase();           // "ABC"
两个高频坑:① replace("-", "/") 只换第一处,想全换用 replaceAll 或带 /g 的正则。② .length 和下标数的是 UTF-16 码元而非字符:"😀".length 是 2,"😀".slice(0, 1) 会切出半个乱码;要按人眼字符处理,先用 [...str] 展开([..."😀"].length 为 1)。
拼接多个变量或带换行的文本,优先用模板字符串而非 +;只判断「是否包含」用 includes 比 indexOf(...) !== -1 更直观。

普通模板字符串是「把值填进字符串」,标签模板反过来——把「字符串的骨架」和「值」拆开交给一个函数处置,这一步跨越正是 styled-components、gql、sql 这些库能存在的底层机制。

在模板字符串前紧挨着写一个函数名,就是「标签模板」:函数被调用,第一个参数是被插值切开的静态字符串数组(还带一个 .raw 保存未转义原文),后面依次是各个 ${...} 的求值结果。它把「模板的结构」与「动态值」分离交给函数自由处理——这正是 styled-components(拼 CSS)、GraphQL 的 gql``、SQL 库的 sql``(自动参数化防注入)以及许多 i18n 方案的底层机制。
// 标签函数:strings 是静态片段数组,values 是各插值结果
function tag(strings, ...values) {
  // strings = ["Hi ", ", you have ", " msgs"](比 values 多 1 个)
  // values  = [name, count]
  return strings.reduce(
    (acc, s, i) => acc + s + (values[i] ?? ""), ""
  );
}
const name = "Ada", count = 3;
tag`Hi ${name}, you have ${count} msgs`;
// "Hi Ada, you have 3 msgs"

// .raw 保留未转义原文(\n 不会变成换行)
String.raw`C:\dir\new`;  // "C:\dir\new"(String.raw 是内置标签函数)

// 真实世界:这些「DSL」都是标签模板
// const Btn = styled.button`color: ${p => p.color};`;
标签模板本身不做任何转义或防注入,安全性完全取决于标签函数怎么实现。sql`...` 能防注入,是因为库把插值收集成参数单独交给数据库;若你自己写的标签只是把 strings 和 values 拼回一个字符串,它就和普通 + 拼接一样危险。别看到反引号就以为安全。
标签函数收到的 strings 数组长度总比插值多 1(两端各有一段,可能是空串)。用 String.raw 写正则或 Windows 路径可免去反斜杠转义。

JS 只有一种数字类型,整数小数共用 64 位浮点——这条设计让你少记类型,也埋下「0.1 + 0.2 不等于 0.3」和「大整数会悄悄失精」两颗雷,下面就是拆这两颗雷。

JS 只有一种数字类型 number(64 位双精度浮点,整数小数不分家;超大整数另有 BigInt)。算术运算符 + - * / % 与幂 **;/ 不取整,要整除配 Math.floor/Math.trunc。浮点精度:十进制小数无法被二进制精确表示,0.1 + 0.2 得 0.30000000000000004——展示用 toFixed,比较用误差容忍。字符串转数字用 Number()(严格)或 parseInt/parseFloat(宽松,从头解析)。BigInt:需要超过 2^53-1(Number.MAX_SAFE_INTEGER)的精确大整数时,用字面量后缀 n(如 10n)或 BigInt(x);它不能与 number 直接混合运算(会抛 TypeError),需先显式转换,也不参与 Math 与 JSON 序列化。
// 只有一种 number(64 位浮点);超大整数用 BigInt
7 / 2                 // 3.5(除法不取整)
Math.floor(7 / 2)     // 3(向下取整;round 四舍五入、trunc 截断)
7 % 2                 // 1(取余)
2 ** 10               // 1024(幂)

// ⚠️ 浮点精度:十进制小数无法被精确表示
0.1 + 0.2             // 0.30000000000000004,不等于 0.3!
(0.1 + 0.2).toFixed(2)          // "0.30"(定点字符串,用于展示)
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON  // true(带误差比较,细节见 18 章)

// 字符串 → 数字
Number("42")           // 42;Number("42px") → NaN(严格)
parseInt("42px", 10)    // 42(宽松,从头解析;带基数更稳)
parseFloat("3.14x")     // 3.14
Number.isNaN(Number("x")) // true(判断转换失败)

// BigInt:精确大整数,字面量后缀 n
9007199254740993n       // 超出 number 安全范围仍精确
2n ** 64n               // 18446744073709551616n(不丢精度)
// 1n + 1 → ❌ TypeError:BigInt 不能与 number 混算
Number(10n) + 1          // 11(先显式转换才能混用)
0.1 + 0.2 !== 0.3 是浮点运算的固有特性(几乎所有语言都如此)。涉及金额时用整数最小单位(如以「分」为单位,100 表示 1 元)或 decimal 库,切勿用浮点直接判等。
整数一旦超过 Number.MAX_SAFE_INTEGER(9007199254740991)就会悄悄失精:9007199254740992 + 1 仍等于 9007199254740992,加了跟没加一样。后端用 int64 生成的 ID / 订单号是重灾区——传输时用 string,需要运算时上 BigInt。

JavaScript · 控制流与循环

分支与循环的全部写法列在一张表里:六种循环各自遍历什么、什么时候用哪个,加上 switch 的穿透规则与八个 falsy 值。

选循环只看两件事:遍历的是键还是值、要不要中途跳出。forEach 无法 break 是最常被忘记的一条。

循环对照

写法遍历什么能 break适用
for (let i = 0; …)索引是要下标、要反向、要跳步
for…of值(可迭代对象)是日常首选;配 .entries() 可同时拿下标
for…in键(含继承的可枚举属性)是只用于对象,别用来遍历数组
forEach值 + 下标否确定要跑完全程;return 只跳过本次
while / do…while—是次数未知;do…while 至少跑一次
for await…of异步可迭代的值是流、分页拉取(12 / 14 章)

跳转与分支

写法作用提醒
break / continue跳出整个循环 / 跳过本轮在 switch 里 break 属于 switch
outer: for … break outer带标签跳出外层循环嵌套循环里比设标志位干净
switch (x) { case … }多路分支,用 === 比较漏 break 会穿透到下一个 case
case a: case b: 连写多值共用一段逻辑这是穿透的正当用法
switch (true)用 case 写区间判断可读性不如 if…else if
cond ? a : b三元表达式嵌套超过一层就改写成 if
a && f() / a || b短路求值取默认值用 ?? 更安全(10 章)
提前 return / continue卫语句,减少嵌套比一层层 else 好读
// 同时要值和下标:entries(),别退回 C 风格 for
for (const [i, item] of list.entries()) console.log(i, item);

// for…in 用在数组上会踩三个坑:键是字符串、顺序无保证、连继承属性一起来
for (const k in ["a", "b"]) console.log(typeof k);   // "string" "string"

// forEach 里 break 不存在;要中断就换 for…of,或用 some/every 表达「找到就停」
const found = list.some((x) => x.id === target);

// switch 的穿透:连写是特性,漏 break 是 bug
switch (kind) {
  case "jpg":
  case "png":
    renderImage();
    break;
  default:
    renderText();
}
for…in 遍历数组是经典错误。它给出的键是字符串("0"、"1"),顺序不作保证,还会带上原型链上任何可枚举属性——某个库往 Array.prototype 上挂了东西,你的循环就会多跑几轮。数组一律 for…of,对象才用 for…in,而遍历对象更推荐 Object.entries(08 章)。
能用数组方法表达意图时,优先于手写循环。「筛一遍」是 filter、「变一遍」是 map、「找到就停」是 find/some、「算个总数」是 reduce(05 章)。手写 for 留给需要下标运算、提前跳出且带复杂状态的场合。

条件分支的心智模型只有一句话:把「值」压成「布尔」,再决定走哪条路。if 的括号里发生的正是 03 章讲过的隐式 Boolean 转换——真值放行、假值拦下,所以 if ([]) 也会进分支(空数组是真值)。

三种写法各自的主场

  • if / else if / else:处理区间判断或复杂条件组合。链条自上而下逐个判断,命中即止,后面的分支不再求值——所以把最可能命中的条件放前面,既快又好读;
  • switch:一个值要和多个固定候选比对时更清晰。注意它用 === 严格比较:switch ("1") 匹配不到 case 1,不会做任何类型转换;
  • 三元 cond ? a : b:它是表达式而非语句,可以出现在赋值右侧、return、模板拼接里——适合「二选一取值」这一种场景,不适合往里塞副作用或多层嵌套。

switch 必须知道的三条规则

  • 命中 case 后如果没写 break,会继续贯穿(fall-through)执行后面所有 case 的代码——这是语言特性而非 bug,但绝大多数场景你需要 break;
  • 连续写多个 case 再共享一段逻辑,是合法且惯用的贯穿用法(见示例中的周末判断);
  • default 不必写在最后,位置任意——它只在所有 case 都未命中时执行,但按惯例放最后可读性最好。

短路求值:不写 if 的条件执行

  • a && b:a 为真才求值 b——惯用于「条件成立才执行」;
  • a || b:a 为假值才求值 b——惯用于「兜底默认值」,但注意 0 和 "" 也是假值会被换掉,只想排除 null/undefined 时用 ??(见 10 章)。
const score = 85;
if (score >= 90) {
  console.log("优秀");
} else if (score >= 60) {  // 上一条不满足才会走到这里
  console.log("及格");
} else {
  console.log("不及格");
}

const day = new Date().getDay();
switch (day) {
  case 6:               // 多 case 合并:合法的贯穿用法
  case 0:
    console.log("周末");
    break;              // 忘写 break 会继续执行下面的 case!
  default:              // default 位置任意,所有 case 未命中时执行
    console.log("工作日");
}

// 三元:二选一"取值",是表达式,能直接参与赋值
const label = score >= 60 ? "pass" : "fail";

// && / || 短路做条件执行(惯用法)
const isDebug = true;
isDebug && console.log("debug info");  // 左侧为真才执行右侧
const name = "" || "匿名";             // "匿名"——空串是假值也被兜底了
两个高发坑:① switch 的整个花括号是同一个块级作用域,两个 case 里都写 let x = ... 会直接报「重复声明」错——需要变量时给 case 体单独套一层 { }。② 嵌套三元(a ? b : c ? d : e)读的人要在脑内画决策树,两层以上就该换成 if/else 或查表对象。
工程直觉:候选值是一组固定常量时,比 switch 更简洁的往往是查表:const text = { 0: "周日", 6: "周六" }[day] ?? "工作日"——数据和逻辑分离,增删分支只改数据。

经典三段式 for (初始化; 条件; 步进) 把循环的三要素写在同一行括号里:状态从哪开始、什么时候停、每轮怎么变。三段都可省略,for (;;) 就是死循环。

要点

  • 用 let 声明计数器:let 在 for 里有特殊行为——每轮迭代创建一个独立的绑定,循环体里创建的闭包各自捕获「当轮的 i」;换成 var 则所有闭包共享同一个变量,最后全拿到循环结束值(02 章闭包卡的经典坑,根治方案就在这里);
  • while:只有条件、没有内建的初始化和步进——适合「循环次数未知、由外部状态决定何时停」的场景(如读取直到耗尽、重试直到成功);
  • do-while:先执行后判断,循环体至少跑一次——适合「先做一次再看要不要继续」(如先询问输入再校验);
  • break 立即终止整个循环;continue 跳过本轮剩余代码直接进入下一轮判断。

标签语句:跳出多层嵌套

  • break/continue 默认只作用于最内层循环。给外层循环起个标签(outer:),break outer 就能一次跳出多层——比设标志位再逐层 break 干净得多;
  • 标签用得多说明嵌套太深,通常更好的解法是把内层循环抽成函数用 return 退出。
// let 计数器:每轮迭代独立绑定,闭包捕获的是"当轮的 i"
const fns = [];
for (let i = 0; i < 3; i++) {
  fns.push(() => i);
}
console.log(fns.map(f => f())); // [0, 1, 2];换成 var 则是 [3, 3, 3]

// while:次数未知,由外部状态决定何时停
let retries = 3;
while (retries > 0 && !connect()) {
  retries--;                 // 忘了更新条件变量 = 死循环
}

// do-while:先执行后判断,至少跑一次
let input;
do {
  input = ask("请输入 1-10");
} while (input < 1 || input > 10);

// break 终止整个循环;continue 跳过本轮
for (let i = 0; i < 10; i++) {
  if (i % 2 === 0) continue; // 偶数跳过本轮
  if (i > 7) break;          // 超过 7 整个终止
  console.log(i);              // 1 3 5 7
}

// 标签语句:一次跳出多层嵌套
const matrix = [[1, 2], [3, 4]];
outer: for (const row of matrix) {
  for (const cell of row) {
    if (cell === 3) break outer; // 直接跳出外层循环
  }
}
边遍历边删是循环第一大坑:用下标 for 循环对数组 splice,删除后后面的元素整体前移,下一轮 i++ 会跳过一个元素。解法:倒序遍历(从 length-1 递减),或干脆用 filter 生成新数组。
选型直觉:知道确切次数或需要下标运算 → 三段式 for;次数未知 → while;至少执行一次 → do-while;只是「对每个元素做点事」 → 优先 for...of 或数组方法(见本章后面)。

选遍历工具前先问一句:我要的是「值」还是「键」? for...of 拿值、for...in 拿键、forEach 是数组方法——三者语义完全不同,用错的代价从「拿到奇怪的字符串下标」到「async 静默失效」不等。

for...of:遍历可迭代对象的「值」(首选)

  • 凡是实现了迭代协议的对象都能遍历:数组、字符串、Map、Set、NodeList、arguments、生成器(协议细节见 12 章「可迭代对象」卡);
  • 支持 break / continue,循环体里 await 会被正常等待——它是普通循环语句,没有回调边界;
  • 要下标时配 entries():for (const [i, v] of arr.entries()),拿到的 i 是数字;
  • 遍历字符串时按码点走,一个 emoji 是一轮——而 for...in 和 s[i] 按 UTF-16 码元,"👍a" 用 for...of 是 2 轮、for...in 是 3 个键(详见 18 章「字符串是 UTF-16 码元序列」);
  • 普通对象不可迭代,直接 for...of 会抛 TypeError: ... is not iterable;惯用法是 Object.keys / values / entries 转成数组再 for...of——只取自身属性,不碰原型链。

for...in:遍历可枚举「键」,三大坑

  • 坑一:它会沿原型链把继承来的可枚举属性也遍历出来(有代码给 Array.prototype 或 Object.prototype 加过属性你就会遇到)。非用不可时套一层守卫:if (!Object.hasOwn(o, k)) continue;(键的五种取法见 08 章「对象遍历」卡);
  • 坑二:遍历数组时拿到的下标是字符串 "0" "1",做 key + 1 得到的是 "01";
  • 坑三:它遍历的是键而不是下标区间——空洞被跳过,而挂在数组上的非下标属性会混进来。给 [1, , 3] 加一个 arr.foo 后 for...in 得到 ["0", "2", "foo"];
  • 所以几乎永远不要用 for...in 遍历数组,它唯一合理的场景是调试时探查对象的全部可枚举键。

forEach:先看清签名,再看它不能干什么

  • 回调收三个参数:arr.forEach((值, 下标, 原数组) => ...)——要下标不必配 entries(),第二个参数就是数字下标,这是 forEach 相对 for...of 唯一顺手的地方;
  • 它还接第二个参数 thisArg:arr.forEach(fn, obj) 把回调里的 this 绑成 obj——但只对 function 回调有效,箭头函数会无视它(箭头回调里 this 仍是外层的)。箭头函数时代这个参数基本已经没人用了;
  • 它返回 undefined,不能像 map/filter 那样链式接下去;跳过空洞([1, , 3] 只走 2 次,而 for...of 走 3 次、中间给 undefined);
  • 它无法 break / continue:回调里 return 只能跳过当前元素,想中途停只能换 for...of,或用 some / every([1,2,3,4,5].some(v => v === 3) 只访问了 3 项就停);
  • 回调写成 async 也没用——forEach 不会等待回调返回的 Promise,所有回调几乎同时发起且外部无法知道何时全部完成。要顺序执行用 for...of + await,要并发用 Promise.all 配 map。

两个只有对比才看得出的差别

  • 遍历途中改数组,三者行为完全不同。forEach 的访问范围在调用那一刻就定死了,循环中 push 进来的新元素不会被访问;for...of 每轮都重新问一次迭代器,会一直追着新元素跑——边遍历边 push 跑满 20 轮仍不停,就是死循环。而 splice 删除会让 forEach 漏掉一个:[1,2,3,4] 里删掉首项后,回调只看到 1, 3, 4(和本章前面「下标 for + splice 跳元素」同源);
  • 性能:日常无所谓,for...in 例外。Node 24.18 上遍历 500 万项各跑三轮:经典 for 约 3.7 ms、forEach 约 25 ms、for...of 约 34 ms、entries() 约 46 ms、for...in 约 300–450 ms。前四者折算到每项都是纳秒级,几百上千项的日常场景按可读性选即可;但 for...in 慢了将近 100 倍(它要现算一份键列表并爬原型链)——这是不用它的第四个理由。
const arr = ["a", "b", "c"];

for (const v of arr) console.log(v); // "a" "b" "c" —— 值
for (const k in arr) console.log(k); // "0" "1" "2" —— 字符串键!

// for...in 拿字符串下标做运算的坑
for (const i in arr) {
  console.log(i + 1);  // "01" "11" "21"(字符串拼接,不是加法)
}

// for...in 遍历的是"键":洞被跳过,挂上去的属性混进来
const holed = [1, , 3];
holed.foo = "bar";
for (const k in holed) console.log(k); // "0" "2" "foo":没有 "1",多了 "foo"

// —— 要下标的两种写法 ——
for (const [i, v] of arr.entries()) {
  console.log(i, v);   // 0 "a" / 1 "b" / 2 "c",i 是数字
}
arr.forEach((v, i, 原数组) => {  // forEach 回调第二个参数就是下标
  console.log(i, v);   // 第三个参数是数组本身,用得很少
});

// —— 遍历途中改数组:范围定死 vs 追着新元素跑 ——
const p = [1, 2, 3];
p.forEach(v => { if (v === 1) p.push(99); });  // 回调只跑 1 2 3,99 不会被访问

const q = [1, 2, 3];
for (const v of q) q.push(v);      // ❌ 死循环:每轮都重新问迭代器

const r = [1, 2, 3, 4];
r.forEach(v => { if (v === 1) r.splice(0, 1); }); // 回调只看到 1 3 4 —— 漏了 2

// forEach 的 async 陷阱:await 不会被等待
const ids = [1, 2, 3];
ids.forEach(async (id) => {
  await save(id);       // ❌ 三个请求同时发出,外部无法等它们完成
});
// ✅ 顺序执行:for...of 里 await 会被正常等待
for (const id of ids) {
  await save(id);
}

// 普通对象:Object.entries + for...of + 解构
const user = { name: "Tom", age: 18 };
for (const [key, value] of Object.entries(user)) {
  console.log(key, value); // name Tom / age 18
}
最高发事故:在 forEach 回调里写 await 以为是顺序执行——实际所有回调立即全部发起,后续代码也不会等它们,「保存了但列表没刷新」多半源于此。async 场景一律改 for...of(顺序)或 await Promise.all(ids.map(...))(并发)(异步详见 14 章)。
一句话选型:遍历数组/字符串/Map/Set 的值 → for...of;遍历普通对象 → Object.keys/values/entries 配 for...of;简单「每项做点事」且无 break、无 await → forEach 也行;要下标:不需要 break 用 forEach((v, i) => ...) 最省事,需要 break 或 await 用 for...of + entries();for...in → 基本只留给调试探查。

JavaScript · 数组

全部数组方法列成一张表:作用、改不改原数组、返回什么、哪一版进的标准;再加一张「怎么造一个数组」。后面的卡展开日常写得最多的那几组。

查数组方法只有两个问题:它改不改原数组、它返回什么。下表两列专门回答这两条——凡标 ⚠︎改原值 的,用在 React / Vue 的状态上必出 bug。

增删与拼接

方法作用改原值返回
push(…v)尾部追加⚠︎是新长度
pop()删尾⚠︎是被删元素
unshift(…v)头部插入(要挪动全部元素)⚠︎是新长度
shift()删头⚠︎是被删元素
splice(i, n, …v)任意位置删 / 插 / 替⚠︎是被删元素组成的数组
toSpliced(i, n, …v)同上但返新数组否新数组(ES2023)
slice(a, b)截一段(不含 b,支持负数)否新数组(浅拷贝)
concat(…x)拼接,数组参数会被展开一层否新数组
fill(v, a, b)区间填充⚠︎是原数组
copyWithin(t, a, b)内部复制一段⚠︎是原数组
with(i, v)换掉第 i 个否新数组(ES2023)

查找与判断

方法作用返回提醒
indexOf(v) / lastIndexOf找下标下标或 -1用 ===,找不到 NaN
includes(v)是否包含布尔能找到 NaN
find(fn) / findIndex(fn)按条件找第一个元素 / 下标找不到给 undefined / -1
findLast / findLastIndex从尾部找同上ES2023
some(fn) / every(fn)存在 / 全部满足布尔短路;every 对空数组恒为 true
at(i)支持负数索引取值元素at(-1) 取最后一个
Array.isArray(x)是不是数组布尔静态方法

遍历与变换

方法作用返回提醒
forEach(fn)逐个执行undefined不能 break,不能 await 串行
map(fn)一对一变换等长新数组回调忘了 return 就得到一串 undefined
filter(fn)筛选新数组回调返回布尔
reduce(fn, init)折叠成一个值累积值空数组不给初值会抛错
reduceRight从右折叠累积值少用
flat(d)拍平 d 层新数组flat(Infinity) 全拍平
flatMap(fn)map 后拍平一层新数组回调返 [] 即删掉该项
join(sep)拼成字符串字符串默认逗号;null/undefined 变空串
keys() / values() / entries()三种迭代器迭代器配 for…of 拿下标

排序与反转:两个版本一定要选对

方法改原值说明
sort(cmp)⚠︎是默认按字符串排:[10,9].sort() 得 [10,9];数字排序必须传 (a,b)=>a-b
reverse()⚠︎是原地反转
toSorted(cmp) / toReversed()否ES2023 的不可变版,默认用这两个

怎么造一个数组

需求写法
0…n-1 的序列Array.from({ length: n }, (_, i) => i)
n 个相同初值new Array(n).fill(0)
类数组 / 可迭代转数组Array.from(x) 或 [...x](前者还吃类数组)
去重[...new Set(arr)]
按键分组Object.groupBy(arr, fn)(ES2024,结果是 null 原型对象)
异步迭代收集await Array.fromAsync(gen)(ES2024)
字面量含指定元素Array.of(7) 得 [7],而 new Array(7) 是长度 7 的空数组
arr.sort() 返回的是原数组本身。const sorted = list.sort() 之后 sorted === list,你以为取了副本,其实把传进来的数据洗了一遍;在 React / Vue 里还会因为引用没变而「数据改了但界面不更新」。

另外 new Array(3) 造出的是三个空洞而不是三个 undefined:map、forEach 会跳过空洞,所以 new Array(3).map((_, i) => i) 得到的还是三个洞——必须先 .fill() 或改用 Array.from({length: 3}, …)。
先问「要不要保留原数组」,再挑方法。ES2023 起每个会改原值的方法都有 to 前缀的孪生版本(toSorted / toReversed / toSpliced / with),把它们当默认,需要就地修改时再显式选原版——这样代码里每一处改动都是刻意的。

这四个方法看起来对称,代价却完全不对称:末尾操作是 O(1),头部操作要把后面每一项都搬一格,是 O(n)。写队列时选错一个方法,十万条数据就是毫秒与半秒的差别。

先记住它们各自返回什么

  • push(...项) 末尾追加,返回新长度;pop() 删末尾,返回被删的那一项;
  • unshift(...项) 头部插入,返回新长度;shift() 删头部,返回被删的那一项;
  • splice(起点, 删几个, ...插入的项) 任意位置增删改,返回被删项组成的数组,原数组被就地改掉;
  • 四个方法全都改原数组——想要「返回新数组」的版本,见本章「就地还是返新」那张卡。

同样十万次,两端差两个数量级

  • Node 24.18 / V8 13.6 上各跑三轮,结果稳定:push 十万次约 1 ms,unshift 十万次约 580 ms——差约 500 倍;pop 掉十万项约 4 ms,shift 掉十万项约 425 ms——差约 90 倍。
  • 原因不在方法本身,而在下标:数组的键是 0、1、2……,从头部插一项意味着后面每一项的下标都要 +1,引擎必须整体重排。末尾进出则谁都不用动。
  • 所以用数组做队列(先进先出)是个陷阱:push 入队没问题,shift 出队会随队列变长而越来越慢。正确做法是留一个读游标只加不减,或直接用链表结构。
const arr = ["a", "b"];

// —— 返回值不一样,别记混 ——
arr.push("c");      // 3        ← 新长度
arr.pop();          // "c"      ← 被删的那一项
arr.unshift("z");   // 3        ← 新长度
arr.shift();        // "z"      ← 被删的那一项

// —— splice:一个方法干完增 / 删 / 改 ——
const s = ["a", "b", "c", "d"];
s.splice(1, 2, "X");  // 返回 ["b", "c"](被删掉的)
s;                    // ["a", "X", "d"]  ← 原数组被就地改了
s.splice(1, 0, "Y");  // 删 0 个 = 纯插入,返回 []

// —— 队列别这么写 ——
while (queue.length) handle(queue.shift());   // ❌ 每次都要重排全部下标

let head = 0;                                  // ✅ 只移游标,不动数组
while (head < queue.length) handle(queue[head++]);
splice 与 slice 一字之差、行为相反:splice 改原数组、返回被删掉的部分;slice 不改原数组、返回选中的部分。把 arr.splice(0, 3) 当成「取前三个」用,是每个人都栽过一次的坑——它取走了前三个。
想「取出末项但不改数组」用 arr.at(-1),别用 arr.pop()——后者会真的删掉它。想一次追加另一个数组的全部元素,arr.push(...other) 比 arr = arr.concat(other) 少造一个新数组,但展开出来的是函数实参,个数有上限——10 万项还能跑、12.5 万项就 RangeError: Maximum call stack size exceeded(阈值随引擎与当前栈深浮动)。上万量级就老老实实用 for...of 循环 push。

JS 的数组是对象,下标是键、length 是一个普通的可写属性。由此带来两件其它语言里没有的事:给 length 赋值会当场截断或补长,以及数组中间可以是「什么都没有」——空洞(hole),它与 undefined 不是一回事。

length 可写

  • a.length = 2 把 [1,2,3,4] 当场截成 [1,2],多出来的项被丢弃——这是最短的清空写法 a.length = 0 的来历;
  • a.length = 3 把 [1] 撑长到 3,但新增的两格是空洞:Object.keys() 仍然只有 ["0"];
  • 给一个超出末尾的下标赋值同理:空数组上写 a[100] = 1,length 直接变 101,中间 100 个全是洞。

空洞的行为:一半方法跳过它,一半方法把它当 undefined

  • [1,,3] 的 length 是 3,但 1 in a 是 false,Object.keys(a) 是 ["0","2"]——那一格根本不存在;
  • 跳过洞:forEach、filter、reduce、Object.keys;保留洞:map(回调不执行,但结果里那一格还是洞);把洞当 undefined:for...of、展开 [...a]、includes;删掉洞:flat();变成 null:JSON.stringify → "[1,null,3]";
  • delete arr[1] 只挖洞、不改长度——它是对象的删属性语义,不是数组的删元素语义。要真删元素用 splice。
// —— length 可写 ——
const t = [1, 2, 3, 4];
t.length = 2;              // [1, 2]        ← 当场截断
const g = [1];
g.length = 3;              // 长度 3,但 Object.keys(g) 仍是 ["0"]

// —— 空洞 ≠ undefined ——
const a = [1, , 3];        // 中间是「洞」
a.length;                  // 3
1 in a;                    // false      ← 这个键不存在
Object.keys(a);            // ["0", "2"]

a.forEach(x => ...);       // 只走 2 次:1 和 3,洞被跳过
a.map(x => x * 2);        // [2, <1 empty item>, 6]  ← 洞保留,回调没跑
[...a];                    // [1, undefined, 3]       ← 展开把洞变成 undefined
a.flat();                 // [1, 3]                  ← 洞被删掉
JSON.stringify(a);         // "[1,null,3]"            ← 洞变成 null
a.includes(undefined);    // true    ← 但 a.indexOf(undefined) 是 -1

// —— delete 只挖洞,不缩短 ——
const d = [1, 2, 3];
delete d[1];
d.length;                  // 3        ← 还是 3!
Object.keys(d);            // ["0", "2"]

// —— 造一个「长度为 n 且真的有值」的数组 ——
new Array(3);              // 3 个洞:.map() 一次都不会执行
Array(3).fill(0);          // [0, 0, 0]      ← fill 会把洞填实
Array.from({ length: 3 }, (_, i) => i);  // [0, 1, 2]  ← 最常用的写法
空洞在日常代码里几乎只有一个来源:用 delete 删数组元素,或者从别处拿到一个 new Array(n)。真实数据里出现洞,通常意味着上游有 bug。判断有没有洞最快的一招是 arr.length !== Object.keys(arr).length。
需要「0 到 n-1」时永远写 Array.from({ length: n }, (_, i) => i),别写 new Array(n).map((_, i) => i)——后者返回的是 n 个洞,回调一次都不会执行,而且不报错。这是「代码看起来对、结果是一片空」的经典来源。

四组方法覆盖「这东西在不在」「它在第几位」「第一个满足条件的是谁」「倒着取一个」。它们的差别不只是写法,indexOf 和 includes 用的甚至不是同一套相等语义。

按问题选方法

  • 在不在 → includes(值) 返回布尔;在第几位 → indexOf(值) / lastIndexOf(值) 返回下标,找不到是 -1;
  • 按条件找 → find(fn) 返回元素本身、findIndex(fn) 返回下标;从后往前是 findLast / findLastIndex(ES2023);四个都命中即短路;
  • 要判断「存在与否」就别用 indexOf(x) !== -1——includes 更直白,而且能正确处理 NaN;
  • 负下标取值 → at(-1)(ES2022),比 arr[arr.length - 1] 短,也不会在链式表达式里把前半段写两遍。

两条语义差

  • [NaN].indexOf(NaN) 是 -1,[NaN].includes(NaN) 是 true:前者用 ===(而 NaN === NaN 为假),后者用 SameValueZero(把 NaN 视为等于自身)。数值数组里查 NaN,只有 includes 有效;
  • 对空洞:[1,,3].includes(undefined) 是 true(洞被当作 undefined),[1,,3].indexOf(undefined) 是 -1(洞不是一个存在的键)——同一个数组,两个方法给出相反的答案。
const nums = [1, 2, 3, 4];

nums.includes(2);        // true    ← 问「在不在」用这个
nums.indexOf(2);         // 1       ← 问「在第几位」用这个
nums.indexOf(99);        // -1      ← 找不到是 -1,不是 undefined

nums.find(n => n > 2);          // 3   (元素本身)
nums.findIndex(n => n > 2);     // 2   (下标)
nums.findLast(n => n < 4);      // 3   (从后往前,ES2023)
nums.findLastIndex(n => n < 4); // 2

nums.at(-1);              // 4       ← 末项,不改数组
nums.at(-2);              // 3

// —— NaN:两套相等语义的分水岭 ——
[NaN].indexOf(NaN);      // -1      ← 用 ===,而 NaN === NaN 为假
[NaN].includes(NaN);     // true    ← 用 SameValueZero

// —— 反复查成员:换 Set,O(n) 变 O(1) ——
const banned = new Set(bannedIds);
rows.filter(r => !banned.has(r.id));   // ✅ 每行一次哈希查找
rows.filter(r => !bannedIds.includes(r.id)); // ❌ 每行全表扫一遍
在循环里对同一个数组反复 includes,是最常见的隐形 O(n²)。几十个元素看不出来,几万个就是几秒的卡顿。判据很简单:只要「在一个集合里反复查成员」,先 new Set(...) 再查(选型对照见深水区的「对象 · Map · 数组 · Set」)。
find 找不到返回 undefined,配可选链最顺手:list.find(x => x.id === id)?.name ?? "未知"。而 findIndex 找不到返回 -1——-1 是真值,if (list.findIndex(...)) 在「没找到」时反而成立,这一条必须写成 !== -1。

比起手写 for 循环,高阶方法把「遍历」标准化,让你只声明「每一项要变成什么」——代码更短、无需维护中间变量、且天然可链式组合。它们都返回新数组(或新值)、不改原数组,契合不可变风格。

三个核心 + 两类常用

  • map(fn):一对一变换,长度不变——「每项映射成新值」;
  • filter(fn):保留回调返回真值的项——「筛选」,长度可变小;
  • reduce(fn, init):把整个数组「折叠」成一个值(求和、分组、拼对象)——最通用,map/filter 都能用它实现;
  • flatMap(fn):map 后再拍平一层,适合「一项展开成多项」;find / some / every:查找与存在性判断,命中即短路。

易错点

  • forEach 只为副作用、返回 undefined,想要新数组必须用 map;
  • 回调写成 async 得到的是 Promise 数组,需 await Promise.all(arr.map(...)) 收集;
  • reduce 务必传初始值,空数组不传会抛错。
const nums = [1, 2, 3, 4];

nums.map(n => n * 2);          // [2, 4, 6, 8](一对一)
nums.filter(n => n % 2 === 0);  // [2, 4](筛选)
nums.reduce((sum, n) => sum + n, 0); // 10(折叠成一个值)

// flatMap:一项展开成多项,再拍平一层
[1, 2].flatMap(n => [n, n * 10]); // [1, 10, 2, 20]

// 链式组合:过滤 → 变换 → 求和,读起来像一句话
const total = [{ price: 5, on: true }, { price: 8, on: false }]
  .filter(p => p.on)
  .map(p => p.price)
  .reduce((a, b) => a + b, 0);  // 5

// reduce 把数组折叠成对象(建索引 / 分组)
const byId = [{ id: "a" }, { id: "b" }]
  .reduce((acc, x) => (acc[x.id] = x, acc), {});
// { a: {id:"a"}, b: {id:"b"} }

// 查找 / 存在性(命中即短路)
nums.find(n => n > 2);   // 3(第一个满足的元素)
nums.some(n => n > 3);   // true
nums.every(n => n > 0);  // true
map 的回调若写 async,返回的是 Promise 数组而非结果数组——必须 await Promise.all(arr.map(async ...))。另外 reduce 不传初始值且数组为空会 TypeError,永远显式传第二个参数。
选型口诀:要等长新数组 → map、要子集 → filter、要单一结果(数字/对象)→ reduce、只做副作用 → forEach。完整方法速查见19 章「内置对象速查」的 Array 卡。

数组方法分成泾渭分明的两派:就地派改原数组并返回同一个引用,返新派原数组一动不动。在 React / Vue / Redux 这类靠「引用变了没」判断是否更新的地方,用错一派的后果是数据改了、界面不动,而且不报错。

谁改原数组,谁不改

  • 就地改:sort、reverse、splice、fill、copyWithin、push/pop/shift/unshift;
  • 返新数组:map、filter、slice、concat、flat、flatMap,以及 ES2023 补上的四个不可变版:toSorted、toReversed、toSpliced、with;
  • with(下标, 值) 是「换掉一项」的不可变写法,替代 [...arr.slice(0,i), v, ...arr.slice(i+1)];fill 没有对应的不可变版,需要时用 map。

sort 的默认行为几乎总不是你要的

  • sort() 不传比较器时,会把每个元素转成字符串再按码元序比较——[10, 9, 1].sort() 得到 [1, 10, 9],因为 "10" < "9"。数字数组必须写 sort((a, b) => a - b);
  • sort 返回的是原数组本身:const sorted = arr.sort() 之后 sorted === arr 为真,你以为拿到了副本,其实两个名字指着同一个数组;
  • 比较器的更多契约(稳定性、必须自洽、字符串排序要用 localeCompare)见深水区的「排序的三条契约」。
// —— 默认排序是字符串序 ——
[10, 9, 1].sort();              // [1, 10, 9]   ← "10" < "9"
[10, 9, 1].sort((a, b) => a - b); // [1, 9, 10]   ← 数字必须传比较器

// —— 就地:返回的就是原数组 ——
const arr = [3, 1, 2];
const sorted = arr.sort((a, b) => a - b);
sorted === arr;                     // true!原数组已经被改了

// —— 返新:ES2023 四件套,原数组一动不动 ——
const src = [3, 1, 2];
src.toSorted((a, b) => a - b);  // [1, 2, 3],src 仍是 [3, 1, 2]
src.toReversed();               // [2, 1, 3]
src.toSpliced(1, 1);            // [3, 2]     ← 删掉下标 1 之后的新数组
src.with(0, 9);                 // [9, 1, 2]  ← 换掉一项

// —— 状态更新里的差别 ——
setList(list.sort(f));      // ❌ 引用没变,React 认为「没更新」,界面不动
setList(list.toSorted(f));  // ✅ 新数组新引用
setList([...list].sort(f)); // ✅ 旧写法:先复制再就地排
toSorted 等四个方法是 ES2023,Node 20 / 现代浏览器都有,但面向老环境(尤其是没上 polyfill 的小程序、旧 WebView)时会直接 TypeError。不确定就退回 [...arr].sort()——多一次复制,兼容性换来的。
分不清一个方法属于哪一派时,看名字:以 to 开头的(toSorted / toReversed / toSpliced)一定返新,这是 TC39 特意定的命名约定;with 是个例外,但它同样返新。同一批提案也给字符串和 TypedArray 加了对应方法。

三个日常高频操作,现在都有了原生写法,不必再手写 reduce:嵌套拍平、按 key 分组、去重。

三件事的标准写法

  • 拍平:flat(深度) 默认只拍一层,flat(Infinity) 拍到底——[1,[2,[3,[4]]]].flat(Infinity) 得 [1,2,3,4];flatMap(fn) 等于 map 之后 flat(1),用于「一项展开成零到多项」;
  • 分组:Object.groupBy(可迭代, 取键函数)(ES2024)返回普通对象;要用非字符串的键(对象、数字保持原类型)就用 Map.groupBy,它返回真正的 Map;
  • 去重:[...new Set(arr)]——保持首次出现的顺序,用的是 SameValueZero(所以 NaN 也能正确去重)。对象数组按字段去重则用 Map:[...new Map(list.map(x => [x.id, x])).values()],同 id 保留最后一个。

groupBy 的返回值不是普通对象

  • Object.groupBy(...) 返回的是 null 原型对象:Object.getPrototypeOf(结果) 是 null。取值、for...in、展开都正常,但它没有从 Object.prototype 继承来的方法——调 结果.hasOwnProperty("odd") 会当场 TypeError;
  • 这其实是刻意的安全设计:分组键往往来自用户数据,null 原型让 "__proto__"、"toString" 这类键不会撞上继承属性。要判断某个分组在不在,用 Object.hasOwn(结果, 键) 或 键 in 结果。
// —— 拍平 ——
[1, [2, [3]]].flat();            // [1, 2, [3]]   ← 默认深度 1
[1, [2, [3, [4]]]].flat(Infinity); // [1, 2, 3, 4]
["a b", "c"].flatMap(s => s.split(" ")); // ["a", "b", "c"]

// —— 分组(ES2024)——
Object.groupBy([1, 2, 3, 4], n => n % 2 ? "odd" : "even");
// { odd: [1, 3], even: [2, 4] }  ← 但原型是 null:
//   Object.getPrototypeOf(结果) === null
//   结果.hasOwnProperty("odd")   → TypeError: 不是函数
//   Object.hasOwn(结果, "odd")   → true   ← 用这个

Map.groupBy(users, u => u.team);  // 返回真正的 Map,键可以是任意值

// —— 去重 ——
[...new Set([1, 2, 2, 3])];      // [1, 2, 3]   ← 保持首次出现的顺序

// 对象数组按 id 去重(同 id 保留最后一个)
[...new Map(list.map(x => [x.id, x])).values()];

// 交集 / 差集:Set 查找 + filter
const b = new Set(listB);
listA.filter(x => b.has(x));    // 交集
listA.filter(x => !b.has(x));   // 差集
[...new Set(arr)] 去重只对原始值有效:两个内容相同的对象是两个不同的引用,Set 一个都不会合并。对象数组一律走 Map + 业务主键那条路。另外 flat() 会顺手删掉空洞,如果你正靠洞占位,拍平之后位置就全错了。
Object.groupBy 挂在 Object 上而不是 Array 上,是因为它接受任意可迭代对象——Set、Map、生成器都能直接分组,不必先转数组。同理 Array.from、Promise.all 收的也都是可迭代对象。

「看起来像数组但没有数组方法」是 DOM 和旧 API 里的日常。转换有两条路,它们接受的东西并不一样——搞混就是一句 is not iterable。

两条路的分界线

  • 展开 [...x] 只认可迭代对象——必须实现 Symbol.iterator。数组、字符串、Set、Map、生成器、NodeList、arguments 都可以;
  • Array.from(x) 认两类:可迭代对象,以及「类数组」——只要有 length 和数字键就行,不需要 Symbol.iterator。[...{ 0: "a", 1: "b", length: 2 }] 抛 TypeError: x is not iterable,而 Array.from 同一个对象得到 ["a", "b"];
  • Array.from 还接第二个参数(映射函数),省掉一次中间数组:Array.from(set, x => x * 2) 比 [...set].map(x => x * 2) 少造一个数组。

三个容易记反的点

  • Array(3) 与 Array.of(3) 完全不同:前者造一个长度为 3 的空数组(三个洞),后者造 [3]。Array.of 存在的唯一理由就是绕开这个「单个数字参数被当成长度」的历史包袱;
  • typeof [] 是 "object",判断数组必须用 Array.isArray(x)。x instanceof Array 在跨 iframe / 跨 vm 上下文时会失效(每个 realm 有自己的 Array),isArray 不会;
  • 字符串按码点迭代:[..."ab"] 得 ["a","b"],但 emoji 这类会被拆成什么,取决于它是不是代理对——细节见深水区的「字符串是 UTF-16 码元序列」。
// —— 展开只吃可迭代对象 ——
const like = { 0: "a", 1: "b", length: 2 };  // 类数组,没有 Symbol.iterator
[...like];              // ❌ TypeError: like is not iterable
Array.from(like);      // ✅ ["a", "b"]

// —— Array.from 的第二个参数:边转边映射 ——
Array.from(new Set([1, 2]), x => x * 2);  // [2, 4],不造中间数组
Array.from({ length: 3 }, (_, i) => i);      // [0, 1, 2]
Array.from("abc");                            // ["a", "b", "c"]

// —— Array(3) 与 Array.of(3) ——
Array(3);         // 长度 3、全是洞
Array.of(3);      // [3]

// —— 判断是不是数组 ——
typeof [];        // "object"     ← typeof 分辨不出数组
Array.isArray([]); // true         ← 只用这个

// —— DOM 里最常见的一处 ——
const nodes = document.querySelectorAll("li");  // NodeList,不是数组
nodes.map(...);            // ❌ nodes.map is not a function
[...nodes].map(...);       // ✅ NodeList 是可迭代的
Array.from(nodes, n => n.textContent); // ✅ 一步到位
Array.from(类数组) 依赖的是 length 属性:如果那个对象的 length 是 undefined,得到的是空数组而不是报错。从奇怪的 API 拿到对象、Array.from 出来是 [] 时,先打印一下它有没有 length。
反过来「数组转别的」也是同一套:new Set(arr) 去重、new Map(二维数组) 建映射、Object.fromEntries(二维数组) 建对象、arr.join("") 拼字符串。所有这些构造函数收的都是可迭代对象,所以生成器也能直接喂进去——这正是可迭代协议的价值(详见「迭代与生成器」章)。

JavaScript · 函数与 this

八种函数写法的差别(提升、this、arguments、能否 new)列成一张表,再加 this 的绑定优先级与 call/apply/bind 的区别。

函数写法之间的差别集中在四列:会不会提升、this 从哪来、有没有 arguments、能不能 new。箭头函数是唯一没有自己 this 的形式,这既是它的用途也是它的限制。

定义形式对照

写法提升thisarguments可 new典型用途
function f() {} 声明整体提升调用方决定有是顶层工具函数
const f = function () {}否调用方决定有是需要控制求值顺序时
const f = () => {}否沿用外层,不可改无(用 …rest)否回调、需要保住外层 this
{ m() {} } 方法简写否调用者对象有否对象 / 类的方法
get x() {} / set x(v) {}否所属对象有否取值时算、赋值时校验
function* g() {}提升调用方决定有否生成器(12 章)
async function f() {}提升调用方决定有否返回 Promise(14 章)
(() => {})() IIFE—外层——老代码里的作用域隔离

this 从哪来:从上往下匹配,先中先得

优先级调用形式this 是
1箭头函数定义时所在作用域的 this,任何手段都改不了
2new Foo()新建的实例
3f.call(o) / f.apply(o) / f.bind(o)显式传入的 o
4obj.f()obj(点号前面那个东西)
5f() 裸调用严格模式 / 模块里是 undefined,否则是全局对象

三个改绑方法与参数写法

写法说明
f.call(o, a, b)立即调用,参数逐个传
f.apply(o, [a, b])立即调用,参数是数组
f.bind(o, a)不调用,返回绑好的新函数(可预置参数 = 柯里化)
function f(a, b = 1)默认参数:只有传 undefined 才生效,传 null 不会
function f(…rest)剩余参数,是真数组,箭头函数里的唯一选择
f(…arr)展开调用,取代 apply
function f({ a, b } = {})具名参数:调用处可读,且顺序无关(07 章)
f.length / f.name形参个数(不含默认值与 rest)/ 函数名
// this 丢失的经典现场:方法被单独取出来,点号没了
const obj = { n: 1, get() { return this.n; } };
const g = obj.get;
g();          // ❌ TypeError:this 是 undefined
obj.get();    // ✅ 1
g.call(obj);  // ✅ 1,或 const g = obj.get.bind(obj)

// 回调里用箭头函数,this 自动沿用外层——这是它最主要的用途
class Timer {
  n = 0;
  start() { setInterval(() => this.n++, 1000); }   // 写成 function 就丢 this
}

// 默认参数只认 undefined
const greet = (name = "匿名") => name;
greet(undefined);   // "匿名"
greet(null);        // null —— 默认值不生效
把箭头函数当对象方法写,this 会指向模块顶层而不是对象。const o = { n: 1, get: () => this.n } 里的 this 是定义处的外层作用域(模块里为 undefined),o.get() 直接抛错。同理,构造函数、class 上的原型方法、需要动态 this 的事件处理器都不能用箭头函数——addEventListener 传箭头函数时 this 不再是触发元素,得改用 e.currentTarget。
判断 this 时看调用的那一刻,不看定义在哪。唯一的例外是箭头函数——它在定义时就把外层 this 焊死了。所以规则很简单:对象方法用简写或 function,回调用箭头;类里需要作为回调传出去的方法,写成箭头函数字段或在构造器里 bind。

三种写法不是风格之争——它们在「提升时机」和「this 归属」上有本质差别,选错会让代码在定义前调用时报错、或让回调里的 this 悄悄丢掉。

三种定义函数的方式:函数声明会被整体提升(可以在定义前调用);函数表达式赋值给变量,不会提升函数体;箭头函数(ES6)语法更简洁,且没有自己的 this/arguments,会从外层作用域继承。
// 函数声明(hoisted,定义前可调用)
greet();
function greet() { console.log("hi"); }

// 函数表达式(不提升)
const add = function(a, b) { return a + b; };

// 箭头函数
const square = x => x * x;
const sum = (a, b) => a + b;
const makeObj = () => ({ a: 1 }); // 返回对象字面量需用括号包裹
箭头函数不能用作构造函数(不能 new),也没有自己的 arguments 对象,需要用剩余参数 ...args 代替。
只有函数声明会整体提升;函数表达式赋给 const 会进 TDZ,定义前调用报 ReferenceError: Cannot access 'fn' before initialization。箭头函数直接返回对象字面量必须加括号 () => ({ a: 1 }),否则 {} 会被当成函数体、返回 undefined。

this 是 JS 最反直觉的一处——同一个函数换个方式调用,this 就变了。下面把它「飘」的规律一次讲清。

探源:this 之所以「飘」,是因为普通函数的 this 不由定义位置决定,而是每次调用时才作为一个隐藏参数传进去——传谁,由调用方式决定。四条规则都从这一句推出:看调用处「点」号前是谁——obj.fn() 里就是 obj;直接 fn() 没有点、没归属(严格模式 undefined、非严格是全局);new Fn() 绑到新建的对象;fn.call/apply/bind(x) 手动指定 x。箭头函数则根本没有自己的 this:它不接收这个隐藏参数,用到 this 时沿词法作用域向上借外层的——所以它一经定义就固定,连 call/apply/bind 都改不动。回调里「this 丢了」用箭头函数能修好,正是这个道理。
const obj = {
  name: "Alice",
  normalFn: function() {
    console.log(this.name); // "Alice"
  },
  arrowFn: () => {
    console.log(this.name); // 拿不到 "Alice":this 是外层的 this
  },
  delayedLog: function() {
    setTimeout(function() {
      console.log(this.name); // this 丢失:非严格下指向全局
    }, 100);

    setTimeout(() => {
      console.log(this.name); // ✅ "Alice",箭头函数继承外层 this
    }, 100);
  }
};
把普通函数当回调传给 forEach/setTimeout 时 this 会丢:严格模式(含 ES 模块)下它变成 undefined,访问 this.name 直接抛 TypeError: Cannot read properties of undefined。改用箭头函数或 bind(this) 才能锁住外层 this。
经验法则:定义对象方法用普通函数,定义回调函数(setTimeout、数组方法、事件监听)优先用箭头函数。

当函数被单独拎出来调用(当回调、借用别的对象的方法)时 this 会丢,这三个方法就是把 this 手动塞回去的工具。

三个方法都能手动指定函数执行时的 this。call 立即调用,参数逐个传;apply 立即调用,参数用数组传;bind 不立即调用,返回一个 this 被永久绑定的新函数。
function introduce(city) {
  console.log(`${this.name} 来自 ${city}`);
}
const person = { name: "小明" };

introduce.call(person, "北京");
introduce.apply(person, ["上海"]);

const bound = introduce.bind(person);
bound("深圳");
bind 是永久绑定:对已 bind 的函数再 bind 或用 call 传别的 this 都无效,仍是第一次绑的那个(两次 bind 结果不变)。箭头函数同理——它没有自己的 this,call/apply/bind 对它的 this 完全不起作用。
记法:call 用逗号挨个传参(Comma),apply 用数组传参(Array),bind 只绑不调、返回新函数留着以后用。三者第一个参数都是要绑定的 this。

默认值和剩余参数把过去手写的 arguments 兜底和 a || 默认 判断,变成了声明式、更不易出错的写法。

ES6 允许函数参数设置默认值(当传入 undefined 时生效),以及用 ...args 收集不定数量的参数为一个真正的数组。
function greet(name = "游客", greeting = "你好") {
  console.log(`${greeting}, ${name}`);
}
greet();           // "你好, 游客"
greet("小红");    // "你好, 小红"

function sum(...numbers) {
  return numbers.reduce((a, b) => a + b, 0);
}
sum(1, 2, 3, 4); // 10
...rest 是真数组(可直接 .map/.reduce),而老的 arguments 只是类数组、且箭头函数里根本没有 arguments。默认参数按从左到右求值,前面引用后面的会踩 TDZ:function f(a = b, b = 2) 一调用就报 ReferenceError: Cannot access 'b' before initialization。
默认值只在实参为 undefined 时生效,传 null 不触发(greet(null) 得到 null 而非默认值)。后一个参数的默认值可以引用前一个参数:function box(w, h = w)。

JavaScript · 解构、扩展与剩余

解构、展开、剩余三种语法的全部形态列成一张表:重命名、默认值、嵌套、参数解构、交换变量、把展开用在数组 / 对象 / 函数调用上各是什么语义。

同一个 … 在等号左边是剩余(收集),在右边是展开(铺开);解构则是「按形状取值」。下表把能写的形态列全。

解构

写法含义
const { a, b } = obj按名字取,顺序无关
const [x, y] = arr按位置取
const { a: newName } = obj取出来改名
const { a = 1 } = obj默认值:只在值为 undefined 时生效
const { a: n = 1 } = obj改名 + 默认值
const { a: { b } } = obj嵌套解构;obj.a 为 undefined 会直接抛错
const { a, …rest } = obj剩下的属性收进新对象(浅拷贝)
const [first, …others] = arr头 + 尾
const [, , third] = arr跳过前两个
[a, b] = [b, a]交换变量,无需中间变量
const { length } = str字符串也能解构(任何对象都行)
for (const { id, name } of list)循环里直接拆
const [{ a }] = arr数组与对象混合嵌套
({ a } = obj)不带声明时必须加括号,否则 { 被当成块

展开与剩余:位置决定语义

写法含义提醒
[…a, …b]合并数组只吃可迭代对象
{ …a, …b }合并对象,后者覆盖前者只拷可枚举自有属性,且是浅拷贝
{ …obj, a: 1 }覆盖某个字段生成新对象不可变更新的标准写法
f(…args)数组铺成实参取代 f.apply(null, args)
[…str] / […set] / […map]转成数组字符串按码点拆,不会切碎 emoji
function f(…rest)收集实参为真数组必须是最后一个形参
{ …null }得到 {}不报错,可安全用于可选参数

函数参数上的常见组合

写法为什么这么写
function f({ a, b })具名参数:调用处自解释,加参数不破坏顺序
function f({ a = 1 } = {})两个默认值缺一不可:里面的管字段缺失,外面的管整个参数没传
function f([x, y])接收坐标 / 元组这类固定长度的数组
// 默认值只认 undefined —— null 会原样通过
const { retry = 3 } = { retry: null };   // retry 是 null,不是 3

// 嵌套解构在中间层缺失时会炸;给中间层也配默认值
const { user: { name } = {} } = payload;

// 展开对象是浅拷贝:嵌套对象仍然共享
const copy = { ...state, list: [...state.list] };  // 每层都要显式展开

// 剩余属性做「排除某几个字段」
const { password, token, ...safe } = user;      // safe 里没有这两个字段
嵌套解构会在中间层缺失时抛 TypeError,而不是给你 undefined。const { data: { list } } = res 在接口没返回 data 时直接崩,报错文本还只提 list。拆外部数据时要么只解构一层,要么给中间层补 = {},要么改用可选链 res.data?.list(10 章)。
解构最实用的两个场景:函数参数和不可变更新。参数写成 { a, b } 后,调用方 f({ b: 2, a: 1 }) 顺序随意、新增可选项不破坏旧调用;更新状态写成 { …old, changed: v } 天然产生新引用,框架才认得出变化。

解构不是花式赋值,而是把「按位置取值」这件天天做的事压成一行——交换变量、取头取尾、跳过某项都不用中间变量。

按位置把数组的值提取到变量中,支持跳过元素、设置默认值、交换变量。
const [a, b, c] = [1, 2, 3];

const [first, , third] = ["x", "y", "z"]; // 跳过第二个

let [m, n] = [1, 2];
[m, n] = [n, m];   // 优雅交换变量

const [head, ...rest] = [1, 2, 3, 4];
console.log(head, rest); // 1 [2, 3, 4]
右侧不是可迭代对象就抛错:const [a] = 5、const [a] = null 都报 TypeError: ... is not iterable(普通对象 {} 也不行)。想安全兜底可先 ?? []:const [a] = arr ?? []。
数组解构走的是迭代器,字符串、Set、生成器都能解构:const [a, b] = "hi" 得到 "h"、"i"。默认值同样只对 undefined 生效,传 null 会原样保留。

对象解构按属性名取值,最常见的落点就是函数参数——把一坨配置项在参数括号里一次拆开、顺手给默认值。

支持三种变体:重命名(const { name: userName } = user)、嵌套解构与默认值。
const user = { name: "Tom", age: 20, address: { city: "北京" } };

const { name, age } = user;
const { name: userName } = user;        // 重命名
const { job = "未知" } = user;          // 默认值
const { address: { city } } = user;     // 嵌套解构

function createUser({ name, age = 18, role = "user" }) {
  console.log(name, age, role);
}
createUser({ name: "Jerry" }); // "Jerry" 18 "user"
嵌套解构时中间层缺失会抛错:const { address: { city } } = {} 报 TypeError: Cannot read properties of undefined (reading 'city')——因为要先读 address 再取 city,缺一层就断。另外 const { address: { city } } 里 address 不会成为变量,只有最内层的 city 是。
React 函数组件的 props 几乎总是用解构写法接收:function Button({ label, onClick }) {...}

一个 ... 把「合并数组」「复制并覆盖几个字段」这类高频操作,从循环或 Object.assign 压成了一行。

... 在数组/对象字面量或函数调用中把可迭代对象「展开」,常用于合并数组、合并对象(浅拷贝)、把数组元素当多个参数传入函数。
const arr2 = [...[1,2], 3, 4]; // [1, 2, 3, 4]

const base = { a: 1, b: 2 };
const merged = { ...base, b: 99, c: 3 }; // { a:1, b:99, c:3 }

Math.max(...[5, 2, 8]); // 8
扩展运算符做的是浅拷贝,如果对象内部还有嵌套对象,内层引用仍然是共享的。
对象展开时后写的同名键覆盖先写的,且只复制自身可枚举属性(原型上继承来的不带)。注意不对称:数组里 [...5] 抛 TypeError: 5 is not iterable,但对象里 { ...5 }、{ ...null } 不报错、静默忽略。

Rest 是 Spread 的镜像:Spread 把一个展成多个,Rest 把剩下的多个收成一个数组或对象。

出现在解构赋值左侧或函数参数列表时,把「剩下的部分」收集成一个数组/对象。
const { id, ...otherProps } = { id: 1, name: "A", age: 20 };
console.log(otherProps); // { name: "A", age: 20 }

function logAll(first, ...others) {
  console.log(first, others);
}
logAll(1, 2, 3); // 1  [2, 3]
rest 必须是最后一个元素,否则解析期就报 SyntaxError: Rest element must be last element,也不能同时有多个 rest。函数形参同理,...args 后面不能再有参数。
对象 rest 把剩余的自身可枚举属性打包成新对象:const { id, ...rest } = obj,常用来「剔掉某个字段、其余原样传下去」。数组与函数参数的 rest 收进来的都是真数组。

JavaScript · 对象:拷贝与遍历

Object 静态方法全表 + 四种遍历方式各自看得见什么 + 拷贝深度对照(展开 / assign / structuredClone)+ 属性描述符与冻结。

对象这一章的三个高频问题:怎么遍历、拷到第几层、某个键到底在不在。三张表分别回答。

Object 静态方法

方法作用返回
Object.keys(o)自有可枚举字符串键数组
Object.values(o)对应的值数组
Object.entries(o)[键, 值] 对二维数组
Object.fromEntries(pairs)entries 的逆操作新对象
Object.assign(t, …s)浅合并进目标目标对象本身(改原值)
Object.hasOwn(o, k)是不是自有属性布尔(ES2022,取代 hasOwnProperty)
Object.freeze(o) / isFrozen冻结一层对象 / 布尔
Object.seal(o) / isSealed禁止增删键,值仍可改对象 / 布尔
Object.create(proto, descs)指定原型建对象新对象;Object.create(null) 得纯字典
Object.getPrototypeOf / setPrototypeOf读写原型原型 / 对象
Object.defineProperty(o, k, desc)精确定义属性对象
Object.getOwnPropertyDescriptor(s)读描述符描述符对象
Object.getOwnPropertyNames(o)自有键,含不可枚举数组
Object.getOwnPropertySymbols(o)自有 Symbol 键数组
Object.groupBy(items, fn)按键分组(ES2024)null 原型对象
Object.is(a, b)同 ===,但认 NaN、分 ±0布尔

四种遍历看得见什么

方式自有继承不可枚举Symbol 键
Object.keys / values / entries✓✗✗✗
for…in✓✓✗✗
Object.getOwnPropertyNames✓✗✓✗
Reflect.ownKeys✓✗✓✓

键的枚举顺序是有规定的:整数样式的键升序在前,然后是字符串键按插入顺序,最后是 Symbol 键——所以 { 2: "b", 1: "a" } 遍历出来 1 在前。

拷到第几层

写法深度注意
{ …obj } / Object.assign({}, obj)一层嵌套对象仍共享引用
structuredClone(obj)深支持 Date / Map / Set / 循环引用;函数、Symbol、DOM 节点会抛错
JSON.parse(JSON.stringify(obj))深,但有损Date 变字符串,undefined / 函数丢失,循环引用抛错
手写递归 / lodash cloneDeep深需要拷函数或特殊类型时才值得
// 遍历对象:entries + 解构,比 for…in 干净
for (const [k, v] of Object.entries(config)) console.log(k, v);

// 变换对象的值:entries → map → fromEntries
const upper = Object.fromEntries(
  Object.entries(o).map(([k, v]) => [k, String(v).toUpperCase()])
);

// 浅拷贝的陷阱:改副本的嵌套字段,原对象跟着变
const copy = { ...state };
copy.user.name = "新名字";      // state.user.name 也变了

// 纯字典用 null 原型,杜绝 __proto__ / toString 这类键的干扰
const dict = Object.create(null);
dict.toString = 1;                // 安全:这里没有继承来的 toString
Object.freeze 只冻一层。freeze(state) 之后 state.list.push(1) 照样成功,因为冻的是 state 自己的属性、不是 state.list 指向的那个数组。要真冻结得递归。

同样只有一层的还有展开与 Object.assign:「拷贝了一份」只对第一层成立,嵌套的对象、数组仍是同一个引用,这是「改了副本原始数据也变」的头号原因。
需要「键值集合」而不是「结构化数据」时,改用 Map(09 章)。键不是字符串、需要频繁增删、要按插入顺序遍历、要知道有几对——这四种情况 Map 都更直接。对象适合形状固定、字段名写死在代码里的数据。

这些简写不只是少打字——属性简写、计算属性名让「用变量拼对象」变得直白,是配置对象、reducer、动态表单里的日常写法。

ES6 为对象字面量引入了三类简写:属性简写(变量名与键名相同时省略冒号)、方法简写(省略 function 关键字)、计算属性名(用 [expr] 动态指定键名)。展开运算符 ... 可以合并多个对象,是日常编码中替代 Object.assign 的首选。
const name = "Tom", age = 18;

// ── 属性简写 ──────────────────────────────────
const user = { name, age }; // 等价于 { name: name, age: age }

// ── 方法简写 ──────────────────────────────────
const obj = {
  greet() { return "hello"; },       // 等价于 greet: function() {...}
  async load() { return await fetch("/"); }, // 异步方法
};

// ── 计算属性名 ────────────────────────────────
const field = "score";
const record = { [field]: 100, [`max_${field}`]: 150 };
// { score: 100, max_score: 150 }

// ── 展开合并(替代 Object.assign)────────────
const defaults = { theme: "dark", lang: "zh", retries: 3 };
const userCfg  = { lang: "en", timeout: 5000 };
const config   = { ...defaults, ...userCfg };
// { theme: "dark", lang: "en", retries: 3, timeout: 5000 }
// 后面的同名键覆盖前面的(解构提取详见「解构、扩展与剩余」模块)
方法简写 { greet(){} } 生成的函数没有 prototype、不能 new(TypeError: obj.greet is not a constructor)。另外字面量里的 __proto__: x 是特例——它设置的是原型而非普通键;真要存名为 __proto__ 的数据键得用计算属性 { ["__proto__"]: x }。
展开运算符合并时是浅合并,嵌套对象仍是同一引用。需要递归合并用 structuredClone 先深拷贝,或使用 lodash 的 _.merge()。

对象没有 map、filter,遍历要先转成数组。Object.keys / values / entries 是标准三件套;for...in 看起来更顺手,但它会把原型链上的可枚举属性也遍历进来——这是它几乎不该被使用的原因。

五种取键方式,各自看得见什么

  • Object.keys / values / entries:只要自有的、可枚举的、字符串键——日常 99% 用这三个;
  • for...in:自有的 + 继承来的可枚举字符串键。在一个原型上带 inherited 属性的对象上,Object.keys 给 ["1","2","b","a"],for...in 多出一个 "inherited";
  • Object.getOwnPropertyNames:自有的,连不可枚举的也算;Object.getOwnPropertySymbols:自有的 Symbol 键;
  • Symbol 键在前三者和 for...in 里全都不可见,JSON.stringify 也会连同值为 undefined 的键一起丢掉——JSON.stringify({ a: undefined, [Symbol("k")]: 1, b: 1 }) 得到 {"b":1};
  • 键的顺序不是插入序:整数样式的键会被提前并升序(细节见深水区的「对象 · Map · 数组 · Set」)。

判断「有没有这个键」:三个写法,三种含义

  • "k" in o —— 自有 或 继承来的都算 true;
  • Object.hasOwn(o, "k")(ES2022)—— 只认自有,这是现在的首选;
  • o.hasOwnProperty("k") —— 语义同上,但会被同名属性遮蔽,也在 null 原型对象上直接 TypeError(Object.groupBy 的返回值就是 null 原型的)。Object.create(null) 造的对象调 hasOwnProperty 抛 TypeError,而 Object.hasOwn 正常返回 true。
const o = { name: "A", age: 20 };

// —— 标准三件套:只看自有的可枚举字符串键 ——
Object.keys(o);     // ["name", "age"]
Object.values(o);   // ["A", 20]
Object.entries(o);  // [["name", "A"], ["age", 20]]

// —— 「改造每个值」的标准三步:entries → map → fromEntries ——
Object.fromEntries(
  Object.entries(o).map(([k, v]) => [k, String(v)])
);                    // { name: "A", age: "20" }

// 按值筛选(对象没有 filter)
Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));

// —— for...in 会爬原型链 ——
const child = Object.create({ inherited: 1 });
child.own = 1;
Object.keys(child);              // ["own"]
for (const k in child) ...        // "own", "inherited"  ← 多了一个!

// —— 判断键是否存在 ——
"inherited" in child;             // true   ← 继承的也算
Object.hasOwn(child, "inherited"); // false  ← 只认自有(ES2022,首选)

const bare = Object.create(null);
bare.x = 1;
bare.hasOwnProperty("x");        // ❌ TypeError:null 原型没有这个方法
Object.hasOwn(bare, "x");       // ✅ true

// —— 这些是看不见的 ——
JSON.stringify({ a: undefined, [Symbol("k")]: 1, b: 1 });
// {"b":1}   ← undefined 值和 Symbol 键都被丢掉了
for...in 不要用来遍历数组:它给出的下标是字符串("0" 而不是 0),会跳过空洞,还会把别人挂在 Array.prototype 上的属性一并遍历进来。数组遍历一律用 for...of(要值)或 entries()(要下标和值)。
entries → map → fromEntries 是对象版的「map/filter」,值得当成一个固定手法记住。要遍历的是「键是运行时数据、还要保序、还要频繁增删」的集合,就别用对象了,换 Map——它自带 size、可以直接 for...of,选型对照见深水区那张卡。

拷贝出错的根源是「引用」:改了副本却动了原件。分清浅拷贝只复制第一层、深拷贝逐层复制,才能写对不可变更新。

浅拷贝只复制第一层,嵌套对象仍是同一引用。深拷贝递归复制所有层级,推荐用 structuredClone()(现代环境原生支持,比 JSON 序列化更强大,细节与局限见 15 序列化与编码)。
const original = { name: "A", info: { age: 20 }, tags: ["js"] };

// ── 浅拷贝 ────────────────────────────────────
const shallow = { ...original };        // 或 Object.assign({}, original)
shallow.name = "B";                     // ✅ 原始值:互不影响
shallow.info.age = 99;                  // ❌ 引用类型:原对象也被改了!
console.log(original.info.age);         // 99

// ── 深拷贝:structuredClone(推荐)──────────
const deep = structuredClone(original); // 支持 Date/Map/Set/ArrayBuffer
deep.info.age = 1;
deep.tags.push("ts");
console.log(original.info.age);         // 20,完全独立
console.log(original.tags);             // ["js"],完全独立

// ── JSON 深拷贝(老写法,有局限)─────────────
const jsonClone = JSON.parse(JSON.stringify(original));
// ⚠️ 不支持 undefined / Function / Symbol / Date / Map / Set 等类型
React/Redux 更新状态必须用不可变方式(返回新对象),不能直接改原对象嵌套属性。structuredClone 不能克隆含 Function、Symbol、DOM 节点的对象,这些场景仍需 lodash _.cloneDeep()。
structuredClone 能处理循环引用和 Date/Map/Set,但遇到函数或 Symbol 会抛 DataCloneError: ... could not be cloned。老写法 JSON.parse(JSON.stringify(x)) 则会悄悄丢掉 undefined 和函数、把 Date 变成字符串——排查「拷贝后数据变样」的诡异 bug 时先想到这点。

JavaScript · Map / Set

Map / Set / WeakMap / WeakSet 的 API 全表与选型对照:什么时候比对象和数组更合适,以及 ES2025 新增的集合运算。

四个容器的分工:Map 是任意键的字典,Set 是去重集合,两个 Weak 版本不阻止垃圾回收、用来给对象挂附加数据。

API 全表

操作MapSetWeakMapWeakSet
创建new Map(entries)new Set(iterable)new WeakMap()new WeakSet()
写入set(k, v)(可链式)add(v)set(k, v)add(v)
读取get(k)—get(k)—
判断存在has(k)has(v)has(k)has(v)
删除delete(k)delete(v)delete(k)delete(v)
清空 / 计数clear() / sizeclear() / size都没有都没有
遍历keys/values/entries/forEach,按插入顺序同左(keys 等同 values)不可遍历不可遍历
键的限制任意值任意值只能是对象只能是对象

Set 的集合运算(ES2025)

方法含义
a.union(b)并集
a.intersection(b)交集
a.difference(b)差集(在 a 不在 b)
a.symmetricDifference(b)对称差
a.isSubsetOf(b) / isSupersetOf / isDisjointFrom三个判定

选哪个

需求选理由
字段名写死在代码里的数据普通对象能解构、能 JSON 序列化
键来自运行时数据、频繁增删Map无原型键冲突,size 直接可得,增删更快
键不是字符串(对象 / 数字)Map对象的键会被强制转成字符串
去重Set[…new Set(arr)] 一行搞定
反复判断「在不在里面」Set查找是常数级,includes 是线性
给别人的对象挂缓存 / 元数据WeakMap对象没人用了会连同条目一起被回收
标记「这个对象处理过了」WeakSet同上,不造成内存泄漏
// 对象的键会被转成字符串,Map 不会
const o = {};
o[{ id: 1 }] = "a";
o[{ id: 2 }] = "b";
Object.keys(o);          // ["[object Object]"] —— 两个键撞成了一个

const m = new Map();
m.set(userA, "a").set(userB, "b");   // 两个对象是两个键,且可链式

// Map ↔ 对象 ↔ JSON:Map 不能直接 JSON.stringify
const obj = Object.fromEntries(m);        // 键会变成字符串
const back = new Map(Object.entries(obj));

// Set 判重比数组快得多;先转一次,再在循环里查
const banned = new Set(bannedList);
const ok = users.filter((u) => !banned.has(u.id));
Map 和 Set 都无法直接 JSON 序列化——JSON.stringify(new Map([["a",1]])) 得到的是 {},数据静静丢光。存进 localStorage 或发给接口前必须先转成数组或对象([…map] / Object.fromEntries(map)),取回来再转回去。

另外 Set 的去重用的是 SameValueZero:NaN 能被正确去重,但两个内容相同的对象是两个元素——对象去重要自己挑一个 id 字段做键。
「反复查存在性」是换 Set 的信号。在循环里写 arr.includes(x),复杂度是两层线性;先 new Set(arr) 再 has,几万条数据上的差距是肉眼可见的。同理,先按 id 建一张 Map 再查,比每次 find 一遍强。

普通对象拿来当哈希表有两个硬伤:键只能是字符串、还会跟原型上的 toString 之类撞车。Map 就是为「任意键的字典」而生。

Map 的键可以是任意类型(对象、函数、NaN 都行),且保持插入顺序,自带 .size 属性,不会和原型链属性冲突。
const map = new Map();
map.set("name", "Tom");
map.set(1, "数字键");
map.set({}, "对象键");

map.get("name"); // "Tom"
map.has("name"); // true
map.size;         // 3

for (const [key, value] of map) {
  console.log(key, value);
}
对象/数组做键是按引用判等、不看内容:map.set({}, 1); map.get({}) 得到 undefined,因为是两个不同的 {}。要命中必须把同一个变量存下来、再拿它去 get。
Map 判键用 SameValueZero:NaN 能当键并正常取回(普通 === 里 NaN !== NaN,Map 是例外),+0 与 -0 视为同一个键。取回按插入顺序,for...of 直接拿到 [key, value]。

「数组去重」和「这个我处理过没有」是天天遇到的需求,Set 把它们从 indexOf 循环、对象打标记的老套路里解放出来。

Set 存储不重复的值,最常见用途是数组去重。
const arr = [1, 1, 2, 3, 3];
const unique = [...new Set(arr)]; // [1, 2, 3]

const set = new Set([1,2,3]);
set.add(4);
set.has(2);    // true
set.delete(1);
set.size;      // 3
Set 去重只认同一引用、不做结构比较:new Set([[1],[1]]).size 是 2,[...new Set([{a:1},{a:1}])] 也留两个。去重对象数组得自己降维——先按某个 key(如 id)或 JSON.stringify 归一。
Node 22 与 Chrome 150 七个集合方法全部可用(ES2025)——三个产出新 Set 的 a.union(b)、a.intersection(b)、a.difference(b)、a.symmetricDifference(b)(只在一边出现的元素,{1,2} 对 {2,3} 得 {1,3}),三个直接回答是非的 a.isSubsetOf(b)、a.isSupersetOf(b)、a.isDisjointFrom(b)。省掉手写 filter,也省掉「先转数组再 includes」的 O(n²)。去重也走 SameValueZero,NaN 能正常去重、只留一个。

长期运行的应用里最隐蔽的内存泄漏,就是「对象早已没用、却还被某个 Map 攥着回收不掉」。WeakMap/WeakSet 让关联数据随对象一起被 GC 带走。

JS 的垃圾回收(GC)基于可达性:从根出发再也引用不到的对象会被自动回收。普通 Map 的键是强引用——对象在别处已经没用了,只要还躺在 Map 里就永远回收不掉,这是长生命周期应用最典型的内存泄漏。WeakMap / WeakSet 的键是弱引用:键对象一旦在别处不可达,条目自动消失。代价是键必须是对象(ES2023 起也接受非注册 Symbol)、不可遍历、没有 size(条目随时可能被 GC 拿走)。典型用途:给 DOM 节点或第三方对象挂关联数据、做实例级缓存。
const meta = new WeakMap();

let panel = document.querySelector("#panel");
meta.set(panel, { clicks: 0 });
meta.get(panel).clicks++;      // 像 Map 一样读写

panel.remove();
panel = null;                  // 节点不可达 → 条目随之可被回收
// 若用 new Map():Map 仍强引用节点,DOM 删了内存也收不回

// WeakSet:给对象打"处理过"的标记,而不阻止回收
const seen = new WeakSet();
function handle(obj) {
  if (seen.has(obj)) return;
  seen.add(obj);
}
内存泄漏三大常客:忘记清除的定时器与事件监听、闭包长期攥着大对象、只进不出的全局缓存 Map。WeakMap 只能救第三种的「键泄漏」,前两种要靠 clearInterval / removeEventListener / AbortController 主动断链。
验证泄漏用 DevTools → Memory 面板:先后拍两张堆快照对比 Detached DOM 节点与持续增长的对象。另有 WeakRef / FinalizationRegistry 提供更底层的弱引用,业务代码几乎用不到,知道存在即可。

JavaScript · 可选链 · 空值合并

运算符一览:可选链、空值合并、逻辑赋值、位运算、in / delete / void 等,加上一张「哪些运算符的优先级必须加括号」。

除了四则运算,JS 常用的运算符大约二十个。下表按用途分组,重点标出短路与优先级——它们是实际写代码时唯一会出错的地方。

取值与判空

运算符含义提醒
a?.ba 是 null/undefined 就整条表达式返回 undefined不掩盖「拼错属性名」,只兜空值
a?.[k] / a?.(x)可选下标 / 可选调用后者用于「有这个回调才调」
a ?? ba 为 null/undefined 时取 b0 和空串会保留
a || ba 为任意 falsy 时取 b0、""、false 会被吃掉
a && ba 为真才求 b常用作「条件执行」
a ??= ba 为空才赋值ES2021;b 不会被求值
a ||= b / a &&= b同上,按 falsy / truthy 判断同样短路

判断与操作

运算符含义提醒
"k" in obj键是否存在(含继承)只查自有用 Object.hasOwn
#x in obj是不是本类实例(私有字段探测)ES2022(11 章)
x instanceof C原型链上有没有 C.prototype跨窗口失效
delete obj.k删属性用在数组上只留下空洞,长度不变
typeof x类型字符串对未声明的名字也安全,不抛错
void 0恒等于 undefined老代码里的写法
a, b逗号运算符,取最后一个值只在 for 头部值得用
a ** b幂右结合;负数底数必须加括号
+x / -x转数字比 Number(x) 隐晦,慎用
!!x转布尔等价 Boolean(x)

位运算(32 位整数上进行)

运算符含义常见用途
& | ^ ~与 / 或 / 异或 / 取反标志位组合与判断
<< >>左移 / 带符号右移乘除 2 的幂
>>>无符号右移处理颜色 / 哈希值
x | 0 / ~~x截断成整数超过 2³¹ 会溢出,安全做法是 Math.trunc

必须加括号的几处

写法结果
a ?? b || c语法错误——?? 与 &&/|| 混用必须显式加括号
a && b ?? c同样是语法错误
-2 ** 2语法错误,要写 (-2) ** 2
a + b << 1位运算优先级低于加法,几乎总要加括号
typeof a === "x"正确;typeof (a === "x") 才是另一回事
(a?.b).c可选链只短路到本条链末尾,套括号后短路失效、照样抛错
// ?? 与 || 的区别在「0 和空串算不算有值」
const pageSize = input ?? 20;     // input 为 0 时保留 0
const pageSize2 = input || 20;    // input 为 0 时变成 20 —— 多半是 bug

// 可选调用:有回调才调,省一层 if
options.onDone?.(result);

// ??= 做惰性初始化,右边只在需要时求值
cache[key] ??= expensiveCompute(key);

// 可选链不掩盖真正的错误:属性名拼错依然得到 undefined,但方法不存在会照常抛
user?.getNmae?.();          // 加了 ?. 就静默了 —— 别对确定存在的方法加
a ?? b || c 是语法错误,不是优先级问题。规范禁止 ?? 与 &&/|| 不加括号地混写,就是为了逼你说清意图——写成 (a ?? b) || c 或 a ?? (b || c)。

另一个隐蔽的坑是 delete arr[1]:它不会让数组变短,只是留下一个空洞,length 不变而 map/forEach 会跳过它。删数组元素用 splice(改原值)或 toSpliced / filter(返新数组)。
取默认值一律先想 ??。|| 只在「空串也算没填」的场合才对,比如表单里的可选文本;而数量、价格、页码、开关这类字段用 || 就会把合法的 0 和 false 悄悄替换掉。

可选链要用在确实可能为空的那一环上:res.data?.list 表达的是「data 可能没有」,而 res?.data?.list?.map?.() 这种全链条加问号只会把真 bug 变成静默失败。

深层取值时「中间某层可能是 null」逼出满屏 a && a.b && a.b.c,?. 就是把这串防御压成一个符号。

访问深层嵌套属性时,如果中间某一级是 null/undefined,?. 会短路返回 undefined 而不是抛出错误。
const user = { profile: { address: null } };

// ❌ 旧写法
const city = user.profile && user.profile.address && user.profile.address.city;

// ✅ 新写法
const city = user.profile?.address?.city; // undefined,不报错
user.sayHi?.();   // 方法不存在时不报错
?. 只保护紧挨它左边那一次访问:a?.b.c 在 a.b 为 null 时,.c 仍会抛 TypeError: Cannot read properties of null——每一段可能为空处都得写 ?.。另外它不能作赋值目标,o?.x = 1 报 SyntaxError: Invalid left-hand side in assignment。
一旦 ?. 左侧是 null/undefined,整条链后续全部短路、直接返回 undefined,后面的属性、方法调用、下标都不再执行:a?.b.c() 里 a 为空时 c() 根本不会被调用。方法调用写 obj.fn?.()、动态下标写 obj?.[key]。

|| 设默认值会把 0、""、false 这些合法值一起吃掉,?? 就是只在「真的没值」(null/undefined)时才兜底。

判定标准只有 null 和 undefined 两个值——0、NaN、""、false 一律算「有值」,原样返回左侧。
const count = 0;

count || 10;  // 10 ❌ 把合法的 0 覆盖掉了
count ?? 10;  // 0  ✅ 只有 null/undefined 才用默认值

const cfg = { retries: 0, timeout: null };
cfg.retries ?? 3;   // 0(保留有效的 0)
cfg.timeout ?? 5000; // 5000
处理「数字可以为 0」等合法假值场景时,永远用 ?? 而不是 || 来设默认值。
?? 不能和 ||/&& 在同一层混用,a || b ?? c 直接报 SyntaxError: Unexpected token '??',必须加括号 (a || b) ?? c 显式指定优先级。还要留意 NaN 不算 nullish,NaN ?? 1 得到的是 NaN。

a = a ?? b 这类「有值就留着、没值才补」的初始化太常见,逻辑赋值把它压成了一个运算符。

把逻辑运算符和赋值合并的简写,a ??= b 等价于 a = a ?? b,是惰性赋值/初始化默认值的简洁写法。
const cache = {};

(cache.list ??= []).push(1);
(cache.list ??= []).push(2);
console.log(cache.list); // [1, 2]
「短路」是真的不执行赋值:a ??= b 当 a 已有值时连赋值动作都跳过——若 a 是带 setter 的属性或响应式对象(Vue/Proxy),setter 不会触发(非空时 setter 调用数为 0)。指望 setter 副作用时别想当然。
三个都是短路赋值:a ??= b 仅当 a 为 null/undefined 才赋值;a ||= b 在 a 为假值时赋值;a &&= b 在 a 为真值时才赋值。(cache.list ??= []).push(1) 是「懒初始化再用」的一行经典写法。

JavaScript · 类、原型与元编程

类的全部成员语法一张表(字段、# 私有、静态、静态块、访问器、继承),加原型链 API、13 个 Proxy 陷阱与 Reflect 方法对照。

类是原型继承的语法糖,但语法糖本身有不少形态;元编程那一层(Proxy / Reflect / 知名 Symbol)则是拦截语言默认行为的口子。

类成员语法

写法含义提醒
constructor() {}构造器,每类最多一个子类里必须先 super() 才能用 this
name = 1实例字段在构造器之前初始化,每个实例一份
#secret = 1真私有字段外部访问是语法错误,不是 undefined
#helper() {}私有方法同上;#x in obj 可探测
static VERSION = 1静态字段挂在类本身
static { … }静态初始化块ES2022,做复杂的类级初始化
get x() {} / set x(v) {}访问器用起来像属性,实际是函数
m() {}原型方法所有实例共享一份
m = () => {}箭头字段this 焊死在实例上,代价是每个实例一份
[Symbol.iterator]() {}计算成员名让实例可被 for…of(12 章)
extends Base / super.m()继承与调父方法extends null 等边角基本用不到
new.target是否用 new 调用的可用来禁止直接调用
static [Symbol.hasInstance]()自定义 instanceof元编程口子之一

原型链 API

写法作用
Object.getPrototypeOf(o)读原型(o.__proto__ 是不推荐的老写法)
Object.setPrototypeOf(o, p)改原型,性能很差,尽量在创建时定
Object.create(p)以 p 为原型建对象
C.prototype类 / 构造函数上挂实例方法的地方
o instanceof C沿原型链找 C.prototype
C.prototype.isPrototypeOf(o)同样的判断,另一个方向写

Proxy 的 13 个陷阱

trap拦截什么
get / set读属性 / 写属性
hasin 运算符
deletePropertydelete
apply / construct函数调用 / new
ownKeysObject.keys、for…in、展开
getOwnPropertyDescriptor / defineProperty描述符读写
getPrototypeOf / setPrototypeOf原型读写
isExtensible / preventExtensions可扩展性

Reflect 提供与每个 trap 同名的默认实现(Reflect.get、Reflect.ownKeys…),在 trap 里把默认行为转交给它是标准写法,比手写 target[key] 正确——它会带上 receiver,getter 里的 this 才对。

// 私有字段是语法级的,不是约定
class Account {
  #balance = 0;
  static #count = 0;
  constructor(init) { this.#balance = init; Account.#count++; }
  get balance() { return this.#balance; }        // 只读对外暴露
  static isAccount(x) { return #balance in x; }      // 私有字段探测
}
// new Account(1).#balance —— 直接语法错误,连运行都到不了

// Proxy + Reflect:加一层日志,默认行为交回 Reflect
const logged = new Proxy(target, {
  get(t, k, recv) {
    console.log("read", k);
    return Reflect.get(t, k, recv);          // 带上 recv,getter 里的 this 才正确
  },
});
# 私有和 TypeScript 的 private 完全不是一回事。private 只在编译期检查,擦除后照样能 obj["secret"] 读到;# 是运行时强制的,外部连语法都过不去。要真正藏住数据(比如凭据、内部状态)必须用 #。

另外类不会被提升到可用状态:写在声明之前的 new Foo() 抛 ReferenceError(02 章的 TDZ),这与函数声明可以先调用后定义的行为正好相反。
方法写在原型上(m() {})还是写成实例字段(m = () => {}),是一次明确的取舍。原型方法所有实例共享一份内存,但单独取出来调用会丢 this;箭头字段的 this 永远正确,代价是每个实例都复制一份函数。要作为回调传出去的方法用箭头字段,其余一律原型方法。

class 写起来像传统面向对象,底层却仍是给构造函数的原型挂方法——它只是原型继承的一层语法糖。

class 底层依然基于原型链实现。包含 constructor、实例方法、静态方法(static,属于类本身而非实例)。
class Animal {
  constructor(name, sound) {
    this.name = name;
    this.sound = sound;
  }

  speak() {
    console.log(`${this.name}: ${this.sound}`);
  }

  static create(name) {
    return new Animal(name, "...");
  }
}

const dog = new Animal("狗", "汪汪");
dog.speak(); // "狗: 汪汪"
class 不提升且有暂时性死区:声明前 new 报 ReferenceError: Cannot access 'X' before initialization;把类当普通函数调用(漏了 new)报 TypeError: Class constructor X cannot be invoked without 'new'。
实例字段(x = 1)挂在每个实例上、Object.keys 枚举得到;方法定义在 prototype 上、所有实例共享同一份且不可枚举——Object.keys(new C()) 只列字段不列方法。想每实例独立一份状态用字段,想省内存的共享行为用方法。

extends 把子类的原型接到父类,而 super 是子类访问父类构造器与方法的唯一通道。

子类构造函数中必须先调用 super() 才能使用 this,super() 负责初始化父类的部分。
class Dog extends Animal {
  constructor(name, breed) {
    super(name, "汪汪");
    this.breed = breed;
  }

  speak() {
    super.speak();
    console.log(`品种: ${this.breed}`);
  }
}

const h = new Dog("哈士奇", "雪橇犬");
h instanceof Dog;    // true
h instanceof Animal; // true
派生类构造器里,super() 之前碰 this 直接报 ReferenceError: Must call super constructor in derived class before accessing 'this'…;哪怕整段不写 super()、只要用到 this 也是同一个错——所以派生类构造器第一句几乎总是 super(…)。
静态成员也随 extends 继承:子类可直接 Sub.make() 调父类静态方法(原型链上 Object.getPrototypeOf(Sub) === Base);但静态方法不落到实例上,new Sub().make 是 undefined。

#field 是语言级的私有——不靠下划线约定,而是引擎强制外部根本碰不到。

ES2022 引入了真正的私有字段语法 #field,外部完全无法访问。get/set 定义计算属性,访问时不需要加括号。
class BankAccount {
  #balance = 0;

  constructor(initial) { this.#balance = initial; }

  deposit(amount) { this.#balance += amount; }

  get balance() { return `¥${this.#balance}`; }
}

const acc = new BankAccount(100);
acc.deposit(50);
console.log(acc.balance); // "¥150"
类外访问 #x 是语法错误、不是运行期 undefined:obj.#balance 直接 SyntaxError: Private field '#balance' must be declared in an enclosing class,连解析都过不去。另外只写 getter 不写 setter 时,严格模式下赋值报 TypeError: Cannot set property … which has only a getter,非严格模式静默丢弃。
判断某对象是不是「本类实例、且带某私有字段」可用 #x in obj 做 brand check——返回 true/false 而不报错。getter/setter 访问时不带括号:写 acc.balance 而非 acc.balance()。

读懂原型链,才能真正看明白 class、instanceof 和方法共享在底层各自做了什么。

JS 的继承本质是原型链:每个对象都有一个内部指针 [[Prototype]](可通过 Object.getPrototypeOf(obj) 读取),当访问一个属性时,引擎先在对象自身查找,找不到就沿着原型链逐级向上查找,直到 null 为止。class 语法只是对原型链的一层封装,理解原型链才能真正看懂 instanceof、方法共享、框架源码等底层机制。
// 构造函数 + prototype(class 的底层实现)
function Animal(name) {
  this.name = name;
}
Animal.prototype.speak = function() {
  console.log(`${this.name} 说话了`);
};

const dog = new Animal("狗");
dog.speak();                         // "狗 说话了"
Object.getPrototypeOf(dog) === Animal.prototype; // true

// 原型链查找顺序:dog → Animal.prototype → Object.prototype → null
dog.hasOwnProperty("name");  // true(自身属性)
dog.hasOwnProperty("speak"); // false(在原型上)

// Object.create:直接指定原型创建对象
const proto = {
  greet() { console.log("Hello, I am", this.name); }
};
const obj = Object.create(proto);
obj.name = "Tom";
obj.greet(); // "Hello, I am Tom"

// Object.create(null) → 无原型的纯净对象(常用于字典/Map 替代)
const dict = Object.create(null);
// dict 没有 toString/hasOwnProperty 等继承方法,适合做干净的 key-value 存储

// class 与原型链的等价关系
class Dog extends Animal {}
// 等价于:
// Dog.prototype.__proto__ === Animal.prototype  →  true
// Object.getPrototypeOf(Dog) === Animal         →  true(静态继承)

Object.getPrototypeOf(Dog.prototype) === Animal.prototype; // true

// instanceof 的原理:检查原型链上是否有目标的 prototype
dog instanceof Animal;  // true  → dog.__proto__ === Animal.prototype
dog instanceof Object;  // true  → 原型链最终都指向 Object.prototype

// 检查属性来源
for (const key in dog) {
  // for...in 会枚举原型链上的可枚举属性
  if (dog.hasOwnProperty(key)) console.log("自身:", key);
  else console.log("原型:", key);
}
不要直接修改 __proto__(如 obj.__proto__ = other),这是非标准且性能极差的操作。需要改变原型关系应使用 Object.create() 或 Object.setPrototypeOf()(同样有性能代价,应在对象创建时就确定好原型)。
核心公式:实例对象 → 构造函数的 prototype → 上级构造函数的 prototype → … → Object.prototype → null。方法定义在 prototype 上,所有实例共享同一份函数,节省内存;属性定义在实例上(this.xxx = ...),每个实例独立。

Symbol 提供一种绝不撞名、也不会被常规枚举翻到的键,是给对象挂协议和元数据的官方手段。

Symbol() 生成独一无二、不可伪造的值,是 ES6 新增的基本类型(typeof 得 "symbol",见 03 章的七种基本类型)。每次调用都全新且互不相等,天生适合做「不会和别人撞」的对象键——给对象挂私有/元数据字段而不污染普通属性,也不被 for...in/Object.keys/JSON 枚举到。Symbol.for(key) 走全局注册表:同名返回同一个 Symbol,可跨模块共享。知名符号(well-known symbols)是语言预留的 Symbol,用作「定制内置行为」的钩子:实现 [Symbol.iterator] 让对象可迭代、[Symbol.toPrimitive] 接管类型转换、[Symbol.hasInstance] 定制 instanceof。
// 独一无二:即使描述相同也不相等
Symbol("id") === Symbol("id");  // false
typeof Symbol();                // "symbol"

// 作对象键:不撞名、不被常规枚举
const ID = Symbol("id");
const user = { name: "Ada", [ID]: 1001 };
Object.keys(user);              // ["name"](Symbol 键被跳过)
user[ID];                       // 1001(需持有该 Symbol 才能取)

// Symbol.for:全局注册表,同名共享同一个 Symbol
Symbol.for("app.id") === Symbol.for("app.id"); // true

// 知名符号:定制内置行为(这里接管类型转换)
const money = {
  amount: 42,
  [Symbol.toPrimitive](hint) {
    return hint === "string" ? `$${this.amount}` : this.amount;
  }
};
String(money);  // "$42"(hint "string")
+money;         // 42(hint "number")
Symbol 键对序列化隐身:JSON.stringify 直接丢弃、structuredClone 也不复制(克隆后 getOwnPropertySymbols 为空)。想枚举 Symbol 键得专门用 Object.getOwnPropertySymbols,Object.keys/for...in 一概看不见。
业务代码里最常用的知名符号是 Symbol.iterator(见本章后面)。有了真正的私有字段 #x(见「私有字段」卡)后,用 Symbol() 做私有键已较少见。Symbol 键不参与 JSON 序列化,也不被 structuredClone 克隆。

每个属性背后都藏着 writable、enumerable、configurable 三个开关,掌控它们就能定制对象的可变边界。

writable 管能不能改值、enumerable 管能不能被枚举、configurable 管能不能删除或再配置。Object.defineProperty 逐个精调这三个开关,freeze 与 seal 则是更粗粒度的整体封锁。
// ── 属性描述符 ────────────────────────────────
const obj = { a: 1 };
Object.getOwnPropertyDescriptor(obj, "a");
// { value: 1, writable: true, enumerable: true, configurable: true }

// defineProperty:精细控制属性行为
Object.defineProperty(obj, "VERSION", {
  value: "1.0.0",
  writable: false,      // 不可修改
  enumerable: false,   // 不出现在 for...in / Object.keys
  configurable: false, // 不可删除,也不能再次 defineProperty
});
// obj.VERSION = "2.0" → 严格模式报错,否则静默失败

// getOwnPropertyNames:含不可枚举属性(比 Object.keys 更全)
Object.getOwnPropertyNames(obj); // ["a", "VERSION"]

// getOwnPropertySymbols:获取 Symbol 键
const sym = Symbol("id");
const o = { [sym]: 99, name: "test" };
Object.getOwnPropertySymbols(o); // [Symbol(id)]

// ── 冻结 vs 密封 ─────────────────────────────
// freeze:完全冻结,不可增删改(浅冻结)
const cfg = Object.freeze({ host: "localhost", port: 3000 });
cfg.port = 8080;       // ❌ 严格模式抛错,非严格静默失败
Object.isFrozen(cfg);  // true

// seal:密封,可改现有值,但不可增删
const s = Object.seal({ x: 1 });
s.x = 2;     // ✅ 允许修改现有属性
s.y = 3;     // ❌ 无法新增
delete s.x;  // ❌ 无法删除
Object.freeze 是浅冻结,嵌套对象的属性依然可改。需要深度冻结要递归处理:function deepFreeze(o) { Object.keys(o).forEach(k => typeof o[k] === "object" && deepFreeze(o[k])); return Object.freeze(o); }
Object.freeze 常用于定义配置常量,防止被意外修改;Object.seal 适合「允许更新值但不允许改结构」的场景(如固定格式的状态对象)。

Proxy 让对象的基本操作变得可编程,这是 Vue 响应式、Immer 这类「魔法」共同的底座。

Proxy 在对象外面包一层拦截器:读、写、删、in、函数调用等 13 种基本操作都可以被 trap 函数接管,对象的行为从此可编程。Reflect 提供与每个 trap 一一对应的默认行为(Reflect.get/set/has…),在 trap 里调用它完成「本来该做的事」,并正确传递 receiver。Vue 3 的 reactive() 就是 get 里收集依赖、set 里触发更新的 Proxy;参数校验、默认值兜底、Mock 对象也都是它的地盘。
const state = { count: 0 };

const reactive = new Proxy(state, {
  get(target, key, receiver) {
    console.log("依赖收集:", key);
    return Reflect.get(target, key, receiver);
  },
  set(target, key, value, receiver) {
    const ok = Reflect.set(target, key, value, receiver);
    console.log("触发更新:", key, "=", value);
    return ok;                      // set trap 必须返回布尔值
  },
});

reactive.count;                     // 依赖收集: count
reactive.count = 1;                 // 触发更新: count = 1

// 另一经典用途:访问不存在的属性直接报错,拼写错误无所遁形
const strict = new Proxy({ a: 1 }, {
  get(t, k) {
    if (!(k in t)) throw new Error("没有属性 " + String(k));
    return t[k];
  },
});
Proxy 无法 polyfill(ES5 环境模拟不出来);代理带内部槽位的内建对象(Map、Date、含私有字段 #x 的类实例)时,方法必须绑回原对象(trap 里返回 value.bind(target)),否则会因 this 指向代理而报错。
业务代码很少直接写 Proxy,但读懂它之后,Vue 响应式、Immer 的「可变写法」、Mock 库的魔法全部祛魅——这是从「会用框架」到「知其所以然」的分界线之一。

下面专挑 Object 静态方法里最容易记错语义的几个讲透,和末尾的速查卡分工、不重复。

这里只挑 Object 静态方法里最容易踩错或被忽略的几个讲透——常规的枚举/合并/原型方法(keys/values/assign/create…)的一行速查见末尾19 章「内置对象速查」的 Object 卡,两张卡是「讲透 vs 速查」的分工,不重复。这里的重点:Object.hasOwn 为什么该取代 hasOwnProperty、Object.is 修了 === 的哪两个坑、Object.groupBy(ES2024)怎么原生分组,以及 entries ↔ fromEntries 如何优雅地「变换」一个对象。
// ── 属性存在性判断 ────────────────────────────
const obj = { a: 1 };

obj.hasOwnProperty("a");  // true,但有隐患:
// 若 obj = Object.create(null) 则没有 hasOwnProperty 方法
// 或若属性名叫 "hasOwnProperty",也会被遮蔽

Object.hasOwn(obj, "a");   // true ✅ ES2022,推荐替代写法
"a" in obj;                // true,但含原型链(不同语义)

// ── Object.is:修复 === 的两个特殊 case ─────
NaN === NaN;           // false(令人困惑)
Object.is(NaN, NaN);  // true ✅
+0 === -0;            // true(令人困惑)
Object.is(+0, -0);   // false ✅

// ── Object.groupBy(ES2024)─────────────────
const items = [
  { name: "Apple",  type: "fruit"  },
  { name: "Banana", type: "fruit"  },
  { name: "Carrot", type: "veggie" },
];
Object.groupBy(items, item => item.type);
// { fruit: [{Apple}, {Banana}], veggie: [{Carrot}] }

// ── keys / entries / fromEntries 组合 ────────
const prices = { apple: 5, banana: 3, cherry: 12 };

// 过滤出价格 > 4 的条目
const expensive = Object.fromEntries(
  Object.entries(prices).filter(([, v]) => v > 4)
); // { apple: 5, cherry: 12 }

// 所有价格打八折
const discounted = Object.fromEntries(
  Object.entries(prices).map(([k, v]) => [k, v * 0.8])
);
"key" in obj 会顺原型链查找、Object.hasOwn 只看自身属性,两者语义不同别混用。=== 判等有两个坑——NaN === NaN 为 false、+0 === -0 为 true;需要精确判等改用 Object.is(Object.is(NaN, NaN) 得 true、Object.is(+0, -0) 得 false)。
Object.groupBy Chrome 117+/Node 21+ 原生支持,低版本可用 Array.prototype.reduce 手写替代。Object.hasOwn 是 ESLint 推荐的 no-prototype-builtins 规则的标准解法。要完整方法清单(含 assign/create/getPrototypeOf/defineProperty/structuredClone)翻到19 章「内置对象速查」的 Object 卡。

JavaScript · 迭代与生成器

迭代协议的接口签名、哪些内置对象自带迭代器、生成器的四个方法与 yield* 的语义,外加异步迭代。

「可迭代」不是玄学,就是对象上有一个 [Symbol.iterator]() 方法,返回带 next() 的对象。for…of、展开、解构、Array.from 全部走这一个口子。

两个协议

协议要求
可迭代(Iterable)有 [Symbol.iterator](),返回一个迭代器
迭代器(Iterator)有 next(),返回 { value, done };可选 return() / throw()
异步可迭代有 [Symbol.asyncIterator](),next() 返回 Promise

谁是可迭代的

可迭代不可迭代(常被误以为可以)
Array、String(按码点)、Map、Set、TypedArray普通对象——展开 {…o} 走的是另一条规则
arguments、NodeList、FormData、URLSearchParams、Headers类数组对象(有 length 但没迭代器):用 Array.from
生成器对象、ReadableStream(异步)—

生成器

写法含义
function* g() {}调用它不执行函数体,只返回生成器对象
yield v产出一个值并暂停在这里
yield* other把另一个可迭代整个委托出去
g.next(v)恢复执行;v 会成为上一个 yield 表达式的值
g.return(v)提前结束,触发 finally
g.throw(e)在暂停处抛错,可被内部 try 接住
async function* g() {}异步生成器,配 for await…of 消费

惰性迭代器方法(ES2025)

Iterator.prototype 上新增了 map / filter / take / drop / flatMap / reduce / toArray / some / every / find。它们不预先生成中间数组,可以直接作用在无限序列上:naturals().map(x => x * x).take(5).toArray()。

// 让自己的类支持 for…of:实现一个 [Symbol.iterator]
class Range {
  constructor(a, b) { this.a = a; this.b = b; }
  *[Symbol.iterator]() { for (let i = this.a; i < this.b; i++) yield i; }
}
[...new Range(1, 4)];        // [1, 2, 3]

// 无限序列 + 惰性取用:生成器天然做得到,数组做不到
function* naturals() { let n = 1; while (true) yield n++; }

// 异步分页:把「一页页拉」的细节藏进生成器,调用方只管 for await
async function* pages(url) {
  let next = url;
  while (next) {
    const r = await (await fetch(next)).json();
    yield* r.items;
    next = r.nextUrl;
  }
}
for await (const item of pages("/api/list")) handle(item);
普通对象不可迭代,但可以被展开——这两件事用的不是同一套规则。{…obj} 拷的是自有可枚举属性,而 […obj] 要求 Symbol.iterator,直接抛 TypeError: obj is not iterable。要把对象变成数组,走 Object.entries / keys / values。

还有一条:迭代器是一次性的。Object.entries() 给的是数组可以反复遍历,而生成器对象、map.entries() 消费完就空了,第二次 for…of 一轮都不跑。
生成器最实在的用处是「把遍历的细节包起来」:分页拉取、按行读大文件、遍历树。调用方只写 for…of,不必知道背后是递归还是网络请求,也不会一次性把所有数据装进内存。

给对象实现 Symbol.iterator,它就能被 for...of、展开、解构、Array.from 统一消费。

实现方式:在对象上定义 [Symbol.iterator]() 方法,返回一个迭代器(含 next() 方法的对象);生成器函数(Generator)是写迭代器的快捷语法。顺带一提,Promise.all() 收的也是可迭代对象,不限于数组。
// ── 迭代器协议 ───────────────────────────────────
// next() 返回 { value, done } 对象
function makeRange(start, end) {
  let current = start;
  return {
    [Symbol.iterator]() { return this; }, // 自身就是迭代器
    next() {
      if (current <= end) return { value: current++, done: false };
      return { value: undefined, done: true };
    }
  };
}

const r = makeRange(1, 3);
for (const n of r) console.log(n); // 1  2  3
[...makeRange(1, 5)];              // [1, 2, 3, 4, 5]

// ── 生成器函数(Generator)────────────────────
// function* 函数遇到 yield 暂停,下次 next() 继续
function* range(start, end, step = 1) {
  for (let i = start; i <= end; i += step) {
    yield i;
  }
}

for (const n of range(0, 10, 2)) console.log(n); // 0 2 4 6 8 10

// 生成器可以 yield* 委托另一个可迭代
function* concat(...iters) {
  for (const iter of iters) yield* iter;
}
[...concat([1, 2], [3, 4], [5])]; // [1, 2, 3, 4, 5]

// ── 内置可迭代对象 ────────────────────────────
// Array, String, Map, Set, arguments, NodeList 都实现了 Symbol.iterator
for (const ch of "hello") console.log(ch);     // h e l l o
for (const [k, v] of new Map([["a", 1]])) { } // 解构 Map 条目

// ── 给类添加 Symbol.iterator ──────────────────
class NumberRange {
  constructor(from, to) { this.from = from; this.to = to; }

  *[Symbol.iterator]() {         // 生成器作为迭代器(最简写法)
    for (let i = this.from; i <= this.to; i++) yield i;
  }
}

const nr = new NumberRange(1, 5);
[...nr];           // [1, 2, 3, 4, 5]
const [a, b] = nr; // a=1, b=2(解构)
Array.from(nr);    // [1, 2, 3, 4, 5]

// ── 无限序列(惰性计算)──────────────────────
function* fibonacci() {
  let [a, b] = [0, 1];
  while (true) {       // 无限序列!但用 yield 惰性求值
    yield a;
    [a, b] = [b, a + b];
  }
}

const fib = fibonacci();
fib.next().value; // 0
fib.next().value; // 1
fib.next().value; // 1
fib.next().value; // 2

// 取前 N 个:手动遍历,够 n 个就 break
// 注:不能直接 spread 无限生成器!
function take(iter, n) {
  const res = [];
  for (const v of iter) { res.push(v); if (res.length >= n) break; }
  return res;
}
take(fibonacci(), 8); // [0, 1, 1, 2, 3, 5, 8, 13]
迭代器是一次性的:消费完之后再次遍历不会重置,next() 会持续返回 { value: undefined, done: true }。若需要多次遍历,每次要重新创建迭代器,或让 [Symbol.iterator]() 每次都返回一个新的迭代器对象(而非 this)。
判断一个东西能不能 for...of,只看一件事:typeof x[Symbol.iterator] === "function"。普通对象没有它,所以 for...of 遍历对象会报 is not iterable(遍历对象用 Object.entries,见 08 章);反过来只要实现了它,展开、解构、Array.from、new Set、new Map、Promise.all 全都能吃同一个东西——这就是协议的价值。至于卡片末尾那个手写的 take,现在已经不必自己写了,见本章「迭代器助手」卡。

生成器不止是「吐值的迭代器」——它能暂停、能双向通信,正是 async/await 的原型。

本章前面把生成器当作「迭代器的快捷写法」,但它的能力远不止吐值:yield 是双向的——next(v) 的参数会成为 yield 表达式的返回值,外部还能用 gen.return()/gen.throw() 提前终止或注入异常。异步生成器(async function*)配合 for await...of 消费「异步序列」(分页 API、逐块读流),实现的是 Symbol.asyncIterator 协议。
// ── yield 双向通信:next(v) 把值送回生成器内部 ──
function* dialog() {
  const answer = yield "第一问";  // answer 接住 next 传入的值
  yield "你回答了 " + answer;
}
const g = dialog();
g.next();      // { value: "第一问", done: false }
g.next(42);    // { value: "你回答了 42", done: false }
g.return();    // 提前终止;g.throw(err) 注入异常

// ── 异步生成器:优雅地消费分页 API ──
async function* fetchPages(first) {
  let url = first;
  while (url) {
    const res = await fetch(url);
    const page = await res.json();
    yield* page.items;             // 逐个吐出本页条目
    url = page.next;               // 没有下一页则退出
  }
}

for await (const item of fetchPages("/api/list?page=1")) {
  console.log(item);               // 调用方完全感知不到分页
}
生成器对象是一次性的:遍历完即耗尽,再次 for...of 不会重来(数组等可迭代对象每次都返回新迭代器);需要重跑就再调一次生成器函数。
生成器是「可暂停的函数」——这正是 async/await 的实现原型(async 函数 ≈ 自动帮你调 next 的生成器)。理解了暂停与恢复,回头看异步章的 await 微任务拆解会通透得多。

数组的链式方法很好用,代价是每一步都要把整个中间结果物化成一个新数组——序列无限长时直接挂死。ES2025 的迭代器助手把同一套方法搬到了迭代器上,语义改成惰性:要几项就只算几项。

有哪些方法,挂在哪

  • Node 24.18 全部可用:.map()、.filter()、.take(n)、.drop(n)、.flatMap()、.toArray()、.reduce()、.forEach()、.some()、.every()、.find();
  • 它们挂在 Iterator.prototype 上,所以所有内置迭代器都自动带上了——[].values()、new Set([...]).values()、map.entries()、生成器对象,typeof [].values().take 就是 "function";
  • 手上是普通可迭代对象(不是迭代器)时,用 Iterator.from(x) 升级一下再链。

惰性到底省了什么

  • 拿一个每次产出都计数的无限生成器,跑 counted().map(x => x).take(4).toArray(),生成器被拉动的次数正好是 4——map 没有先把无限序列算完,take 也不是「算完再截断」,整条链是拉一项走一遍;
  • 所以 fib().map(x => x * 2).take(5).toArray() 在斐波那契无限序列上瞬间返回 [0, 2, 2, 4, 6],而把 fib() 先 [...展开] 成数组会直接卡死;
  • 反过来,数据量小、且本来就是数组时,数组方法更快——助手每项都要过一次迭代器协议的 next(),有固定开销。助手的价值在「无限 / 超大 / 来源是流」的场景,不是替代 arr.map。
function* fib() {
  let [a, b] = [0, 1];
  for (;;) { yield a; [a, b] = [b, a + b]; }
}

// —— 无限序列上照样链式(只计算用得到的那几项)——
fib().map(x => x * 2).take(5).toArray();  // [0, 2, 2, 4, 6]
fib().drop(3).take(3).toArray();          // [2, 3, 5]

// 对比:数组方法在这里会挂死
[...fib()].map(x => x * 2).slice(0, 5);  // ❌ 展开无限序列,卡死

// —— 内置迭代器自动带上了助手 ——
typeof [].values().take;              // "function"
new Set([1, 2]).values().toArray();     // [1, 2]
map.entries().filter(([k]) => k.startsWith("a")).toArray();

// —— 普通可迭代对象先升级 ——
Iterator.from([1, 2, 3]).some(x => x > 2);  // true

// —— 典型用法:大文件逐行读,只要前 100 条匹配的 ——
const hits = lines(file)          // 生成器,逐行产出,不全读进内存
  .filter(l => l.includes("ERROR"))
  .map(l => parse(l))
  .take(100)
  .toArray();                    // 读到第 100 条就停,后面的行根本没读
助手返回的仍然是一次性的迭代器:const it = fib().take(3) 之后 it.toArray() 拿到三项,再调一次得到的是空数组。要复用结果就先 toArray() 存下来。另外链上任何一步不加 take 就 toArray() 无限序列,一样会卡死——惰性不等于自动有界。
助手方法只存在于迭代器上,不在数组上——arr.take(3) 不存在,要写 arr.values().take(3).toArray()。判断当前环境有没有:typeof Iterator !== "undefined" && typeof [].values().take === "function"。它是 ES2025 的新东西,Node 22+ 与 2024 年之后的浏览器才有。

JavaScript · 错误处理

内置错误类型表、Error 的全部属性(含 cause)、try/catch/finally 的执行顺序规则,以及浏览器与 Node 各自的全局兜底钩子。

错误处理的三个问题:抛什么(内置类型还是自定义)、在哪接(同步 try 还是 Promise catch)、没人接会怎样(全局钩子与进程行为)。

内置错误类型

类型什么时候由引擎抛出
Error基类,自定义错误从它继承
TypeError值的类型不对:对 undefined 取属性、把非函数当函数调
ReferenceError名字不存在,或处在 TDZ 中
SyntaxError解析失败;JSON.parse 失败也是它
RangeError越界:递归过深、toFixed(101)、非法数组长度
URIErrordecodeURIComponent 收到非法转义
AggregateErrorPromise.any 全部失败时,errors 里装着每一个
DOMExceptionWeb API 抛的(如 AbortError),不是 ECMAScript 的一部分

Error 的属性与构造

写法说明
new Error(msg)message 给人看,别塞进结构化数据
new Error(msg, { cause: err })ES2022:保留原始错误,形成因果链
err.name / err.message / err.stack类型名 / 描述 / 调用栈(stack 非标准但各家都有)
class MyError extends Error自定义:构造器里设 this.name,附加字段自己加
Error.isError(x)跨 realm 也可靠的判定(新特性,旧环境用 instanceof)

try / catch / finally 的规则

情况结果
catch {} 不写参数合法(ES2019),用不上 e 时更干净
try 里 return,finally 也 returnfinally 的返回值胜出,会吞掉前者——别在 finally 里 return
finally 里抛错覆盖原来的错误,原因丢失
异步函数里 await 一个失败的 Promise可被外层 try/catch 接住
setTimeout 回调里抛错外层 try 接不住——它已不在同一个调用栈上
没有 await 的 Promise 失败变成未处理拒绝,见下表

没人接住时

环境同步错误Promise 拒绝
浏览器window.onerror / "error" 事件"unhandledrejection" 事件
Nodeprocess.on("uncaughtException")process.on("unhandledRejection"),默认直接退出进程
// 自定义错误:带上业务字段,调用方才能分类处理
class HttpError extends Error {
  constructor(status, url, options) {
    super("HTTP " + status, options);   // options 里可带 cause
    this.name = "HttpError";
    this.status = status;
    this.url = url;
  }
}

// cause:包一层但不丢现场,排查时能一路追到根因
try {
  await save(data);
} catch (e) {
  throw new Error("保存草稿失败", { cause: e });
}

// catch 里先判类型再处理,别把所有错误一视同仁
catch (e) {
  if (e instanceof HttpError && e.status === 404) return null;
  throw e;                        // 处理不了的原样抛出去
}
try/catch 接不住异步回调里的错误。try { setTimeout(() => { throw new Error("x") }) } catch {} 什么也接不到——回调执行时那个 try 早就出栈了。同理,忘了 await 的 Promise 失败也不会进 catch,它会变成未处理拒绝,在 Node 里直接让进程退出。异步的错误只能用 await + try 或 .catch() 接。
只在能真正处理的地方 catch。能补默认值、能重试、能给用户一句人话——这三种才叫处理;否则让它继续往上抛,交给统一的边界(请求拦截器、路由错误页、进程钩子)。捕获后原样吞掉是最糟的选择:现场没了,问题还在。

抛开自己加的业务字段,catch 到的 Error 实例上原生只有四个属性,而且分布并不直观:message 与 stack 在实例自身上,name 在原型上,cause 只有构造时传过才存在。四个全是不可枚举的——这就是「错误打进日志变成一个空对象」的全部原因。

语言给的属性

属性在哪什么时候有说明
message实例自有new Error(msg) 传了才有不传时实例上根本没这个属性,读到的 "" 来自原型
name原型上,实例上没有始终可读子类不写 this.name 就一直是 "Error",连栈首行也是
stack实例自有一创建就有非标准但三家引擎都有;首行是 name: message,其余是栈帧,格式各家不同
cause实例自有只有 new Error(msg, { cause }) 传过才有传 undefined 也算传过,所以判存在要用 "cause" in err
errors实例自有仅 AggregateErrorPromise.any 全败时装着每一个原始错误
toString()原型上始终String(err) 得到 "name: message";任一为空串就只剩另一个

「不可枚举」意味着什么

写法结果
Object.keys(err)[]
JSON.stringify(err)"{}"——错误一进日志管道就没了
{ ...err } / Object.assign({}, err){},只有自己 this.xxx = 加的字段会被复制走
console.log(err)正常打印,引擎对 Error 有特判,别拿它当序列化没问题的证据
structuredClone(err)保住 name/message/stack/cause,但自定义子类退化成 Error、自己加的字段全丢

不是语言给的,但 catch 里常遇到

来源多出来的东西
DOMException(AbortError 等)name / message / code 都在原型上,实例自有属性只有 stack;signal.reason 拿到的就是它
Node 系统错误code("ENOENT")/ errno / syscall / path,这几个是可枚举的——分支判断认 code,别去匹配 message
V8 的静态成员Error.captureStackTrace / Error.stackTraceLimit(默认 10 帧)/ Error.prepareStackTrace,都挂在 Error 上而不在实例上
判定Error.isError(x) 跨 realm 有效,instanceof 对 iframe / vm 里造的错误会返回 false
const err = new Error("boom");

Reflect.ownKeys(err);          // ["stack", "message"]——没有 name,也没有 cause
Object.keys(err);              // []——四个属性全不可枚举
JSON.stringify(err);           // "{}"——日志里错误变空对象
Object.hasOwn(err, "name");    // false——name 挂在 Error.prototype 上

// cause 的有无只能用 in 判,看 !== undefined 会判错
"cause" in new Error("x");                        // false
"cause" in new Error("x", { cause: undefined });  // true

// 要进日志就显式挑字段,顺带把 cause 链摊平
const toLog = (e) => ({
  name: e.name, message: e.message, stack: e.stack,
  cause: e.cause ? toLog(e.cause) : undefined,
});
Node 的系统错误反过来会「只剩码、没有人话」。code/errno/syscall/path 是可枚举的自有属性,于是 JSON.stringify(err) 得到 {"errno":-4058,"code":"ENOENT","syscall":"open","path":"…"}——message 依旧被丢在外面。

别把 stack 当结构化数据解析。它不在标准里,V8、SpiderMonkey、JSC 的格式各不相同,还会被 Error.stackTraceLimit 截断(默认只留 10 帧,设成 0 就只剩首行)。要定位就整段打出来,要分支就靠 name、code 或 instanceof。
要把错误送进日志或跨线程传,先想清楚哪条路会吃掉什么。自己挑字段拼一个普通对象最可控(记得递归 cause);structuredClone 与 postMessage 能带上栈和 cause,但类型和自定义字段保不住;JSON.stringify 什么也带不走。给自定义错误加一个 toJSON() 方法是最省事的兜底——JSON.stringify 会优先调它。

try、catch、finally 是 JS 异常处理的地基:捕获错误、尝试恢复、以及无论成败都要做的收尾。

两个控制流细节值得记牢:异常一抛,try 里该行之后的语句不再执行,直接跳进 catch;而 finally 连 return 都拦得住——try { return 1 } finally { return 2 } 最终返回的是 2。
function parseJSON(str) {
  try {
    return JSON.parse(str);
  } catch (err) {
    console.error("解析失败:", err.message);
    return null;
  } finally {
    console.log("解析流程结束");
  }
}
finally 里的 return/throw 会覆盖 try 中的返回或异常:try { return "a" } finally { return "b" } 最终返回 "b";更隐蔽的是 finally 里一 return,try 中已抛出的异常会被悄悄吞掉。所以 finally 里别写控制流,只做收尾。
ES2019 起 catch 可省略参数:不关心错误对象时写 catch {} 即可。抛出的不一定是 Error——throw "字符串" 也合法,此时 catch 到的就是那个字符串、e.message 是 undefined,所以读 e.message 前最好先确认类型。

继承 Error 造出语义化的错误类型,配合 instanceof 就能把不同种类的错误分门别类地处理。

两件容易漏的事:构造函数里必须 super(message) 把消息传给父类;还要显式写 this.name = "XxxError"——不写的话 err.name 一直是 "Error",日志里分不出是哪种错误。
class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = "ValidationError";
    this.field = field;
  }
}

try {
  if (age < 0) throw new ValidationError("年龄不能为负", "age");
} catch (err) {
  if (err instanceof ValidationError) {
    console.log(`字段 ${err.field}: ${err.message}`);
  } else {
    throw err; // 未知错误继续往上抛
  }
}
子类构造器里若忘了设 this.name,err.name 会沿用父类的 "Error",toString() 和堆栈首行都印成 Error: … 而非你的类名——务必在 super(message) 之后补一句 this.name = "XxxError"。
V8(Node/Chrome)的 Error.captureStackTrace(目标, 截到哪个函数) 能把指定函数连同它之上的帧一起从栈里抹掉。写在子类构造器里其实是白写——V8 本来就不把 new MyError() 这一帧记进栈,栈顶已经是调用处;真正用得着的是工厂函数:const e = new MyError(m); Error.captureStackTrace(e, makeError),这样 makeError 自己那一帧才会消失。要给错误串联根因,用 ES2022 的 new Error(msg, { cause: 原始错误 })——err.cause 能拿回原始错误对象。

try/catch 抓不住回调里和「没接住的 Promise」里的错——下面讲清它的边界,以及最后那道全局兜底网。

try/catch 只能抓住同步代码和 await 表达式抛出的异常。两类它抓不住:回调里的异常(setTimeout、事件回调——执行时外层 try 早已退出)和没有 await、也没接 .catch 的 Promise(变成 unhandled rejection)。工程规则:每条 Promise 链的末端,要么被 await 包在 try 里,要么挂 .catch;最后留一道全局兜底网,只做上报与提示,不做业务恢复。
// ✅ await + try/catch:异步错误回到同步语感
try {
  const res = await fetch("/api/user");
  if (!res.ok) throw new Error("HTTP " + res.status);
} catch (e) {
  showToast("加载失败");
}

// ❌ 抓不住:回调执行时,外层 try 早已退出
try {
  setTimeout(() => { throw new Error("boom"); });
} catch (e) { /* 永远到不了这里 */ }

// 全局兜底(浏览器):只上报,不吞业务错误
window.addEventListener("unhandledrejection", (e) => {
  report(e.reason);
  e.preventDefault();          // 拦下控制台的默认报错
});
window.addEventListener("error", (e) => report(e.error));

// Node 对应:process.on("unhandledRejection" / "uncaughtException")
fetch 只在网络层失败时 reject;404/500 会正常 resolve,必须自己检查 res.ok——这是「明明加了 catch 却没抓到错」的头号原因。
async 函数「忘了 await」是静默事故高发区:错误飞进 unhandledrejection,主流程却继续往下走。开 ESLint 的 no-floating-promises 规则(@typescript-eslint 提供)能在写代码时就拦住。

文件句柄、数据库连接、锁、订阅——凡是「用完必须关」的东西,过去只能靠 try { … } finally { close() },嵌套三层就没法看了。using 声明(ES 显式资源管理,TypeScript 5.2 起支持)把「关」这件事绑到变量的作用域上:块一退出,自动调用它的 [Symbol.dispose]()。

怎么用:给对象加一个 Symbol.dispose

  • 任何对象只要实现了 [Symbol.dispose]()(同步)或 [Symbol.asyncDispose]()(异步),就能被 using / await using 接管。
  • using r = 表达式; —— 变量是块作用域的,而且不能重新赋值——TS 里改它当场报 TS2588: Cannot assign to 'r' because it is a constant。这是设计使然:句柄换了人,原来那个就关不掉了。
  • 标准库正在陆续给对象加上这个方法,自己写的资源类补一个方法即可,不需要继承任何基类。

语义:三条都和直觉一致

  • 后进先出——同一个块里 using a、using b,退出时打印顺序是 body → dispose b → dispose a。和 finally 的嵌套顺序一致,不会搞反。
  • 抛错也会清理——块里 throw,先打印 dispose x 再进 catch。这正是 finally 的语义,只是不用自己写。
  • await using 会真的等——异步 dispose 打印在函数体之后,且被 await 住。

今天能不能用

  • Chrome 150:原生可用,Symbol.dispose 就是标准的 well-known symbol。
  • Node 22.22:语法层面直接 SyntaxError——using r = … 报 Unexpected identifier。注意 Node 22 上 Symbol.dispose 是存在的(打印出来是 Symbol(nodejs.dispose),Node 自己塞的别名),别看到它有就以为语法能用。要在 Node 上跑得升到支持这个语法的版本,或者过一遍 TS。
  • TypeScript:5.2 起支持。--target esnext 时原样输出;--target es2022 会展开成一对 __addDisposableResource / __disposeResources 辅助函数(几十行),老运行时也能跑。类型上需要 lib 含 esnext,否则报 TS2318: Cannot find global type 'Disposable'。
// 自己的资源类:补一个 Symbol.dispose 就行
class FileHandle {
  constructor(path) { this.fd = open(path); }
  [Symbol.dispose]() { close(this.fd); }
}

// 旧写法
const f = new FileHandle("a.txt");
try { use(f); } finally { f[Symbol.dispose](); }

// 新写法:块一退出就关,抛错也关
{
  using f = new FileHandle("a.txt");
  use(f);
}   // ← 这里自动 dispose

// 异步资源:await using + Symbol.asyncDispose
class Conn { async [Symbol.asyncDispose]() { await this.end(); } }
async function q() {
  await using c = await connect();
  return c.query("select 1");
}   // ← 这里 await 住 asyncDispose
三个坑:① 别对不属于自己的资源用 using——它按作用域无条件关闭,把别人传进来的连接 using 一下,函数返回时就把人家的连接关了;② 同步 using 碰上异步清理会静默漏掉——需要等待的清理必须写成 [Symbol.asyncDispose] 并用 await using,写成同步版本的话那个 Promise 没人等,进程可能先退出;③ Node 22 上有 Symbol.dispose 不代表语法可用(见上),特性探测要探语法不是探符号。
判断该不该用它很简单:你在写 finally { 关掉 } 吗?是就能换。收益在多资源时最明显——三个资源用 try/finally 要嵌三层(或者在一个 finally 里小心翼翼地按序关、还得各自判空),用 using 就是平铺的三行,顺序由语言保证。

JavaScript · 异步:Promise & async/await

Promise 的全部静态方法与实例方法、七种组合场景选哪个、事件循环里各类任务的执行顺序,以及取消与超时的标准写法。

异步的两张核心表:要并发多个任务时选哪个组合子,以及各类回调按什么顺序执行。前者决定语义正确,后者解释「为什么这行先打印」。

Promise 静态方法

方法何时兑现何时拒绝用在哪
all(list)全部成功,结果按原顺序成数组任一失败立刻拒绝缺一不可的并发请求
allSettled(list)全部结束永不拒绝,每项给 {status, value/reason}批量操作,要知道各自成败
any(list)第一个成功的全部失败,抛 AggregateError多个镜像源取最快可用
race(list)第一个结束的(成败都算)同左加超时(现在更推荐 AbortSignal.timeout)
resolve(v) / reject(e)立即产生已定的 Promise—统一返回类型
withResolvers()拿到 {promise, resolve, reject}—ES2024,把控制权交给外部(如事件回调)
try(fn)同步函数抛错也包装成拒绝—ES2025,统一同步 / 异步的错误路径

实例方法与 async 语法

写法说明
.then(onOk, onErr)返回新 Promise,可链
.catch(fn)等价 .then(undefined, fn)
.finally(fn)成败都跑;不接收值也不改变结果
async function返回值自动包成 Promise,抛错自动变拒绝
await x非 Promise 也能 await(包一层);会让出一轮微任务
顶层 await只在 ESM 里可用(ES2022)
for await (const x of it)逐个等待异步迭代(12 章)

执行顺序:同一轮里谁先

顺序类别包括
1同步代码当前调用栈上的一切
2微任务(清空为止).then/catch/finally 回调、await 之后的代码、queueMicrotask;Node 里 process.nextTick 更靠前
3渲染(浏览器)requestAnimationFrame 回调,然后绘制
4宏任务(一轮取一个)setTimeout、setInterval、I/O 事件、setImmediate(Node)

关键结论:微任务队列会被清空后才轮到下一个宏任务——所以在 .then 里不停制造新的微任务能把渲染饿死。

取消 · 超时 · 限流

需求写法
超时放弃fetch(url, { signal: AbortSignal.timeout(5000) })
手动取消const c = new AbortController();传 c.signal,调 c.abort()
多个信号合一AbortSignal.any([a, b])
延时const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
串行 vs 并发循环里 await = 串行;先全部发起再 await Promise.all = 并发
控制并发数分批 Promise.all,或用 p-limit 这类库
// 串行 vs 并发:差别只在 await 写在哪
for (const id of ids) await fetchOne(id);              // 串行,n 倍耗时
await Promise.all(ids.map(fetchOne));                     // 并发,取决于最慢的一个

// 批量操作要逐个知道成败:用 allSettled,别用 all
const results = await Promise.allSettled(ids.map(fetchOne));
const failed = results.filter((r) => r.status === "rejected");

// 顺序问题的标准考题
console.log(1);
setTimeout(() => console.log(4));                        // 宏任务,最后
Promise.resolve().then(() => console.log(3));            // 微任务,同步之后
console.log(2);                                        // 输出 1 2 3 4
forEach 里写 await 完全不起作用。list.forEach(async (x) => { await save(x) }) 会立刻返回,所有 save 同时飞出去,外层根本等不到——因为 forEach 不理会回调返回的 Promise。要串行用 for…of + await,要并发用 Promise.all(list.map(…))。

还有一条:Promise.all 遇到第一个失败就拒绝,但其余任务并不会被取消,它们照样跑完,失败的还可能变成未处理拒绝。要真取消得传 AbortSignal。
先发起、后等待,是把串行改成并发的通用手法。const a = fetchA(); const b = fetchB(); const [x, y] = await Promise.all([a, b]);——两个请求在第一行就都上路了。只有当后一个请求依赖前一个的结果时才该串行。

单线程为什么不会被慢操作卡死?答案是事件循环——外加微任务永远插在宏任务前面这条铁律。

探源:先问「为什么单线程」——页面的 DOM 不是线程安全的,多线程同时改 UI 会产生竞态,于是 JS 干脆只用一个线程跑你的代码。单线程又不能被慢操作卡死,于是有了事件循环:慢任务(定时器、网络)登记到队列,主线程跑完手头的同步代码就回来取下一个。关键在两级队列的优先级:微任务(Promise.then、queueMicrotask)高于宏任务(setTimeout、I/O、事件)——规则是「每跑完一个宏任务,就把当时微任务队列里的全部清空,才取下一个宏任务」(浏览器还会趁这个间隙渲染)。为什么微任务优先:它的语义是「在浏览器做别的事(渲染、下一个任务)之前,把这一串 .then 连锁全部结算掉」,以保证 Promise 链的原子性——这正是 Promise.then 总排在 setTimeout(…, 0) 前面的根本原因。
console.log("1 同步");
setTimeout(() => console.log("4 宏任务"), 0);
Promise.resolve().then(() => console.log("3 微任务"));
console.log("2 同步");
// 输出:1 → 2 → 3 → 4
微任务会「插队到底」:一轮清空里新产生的微任务仍在本轮执行,若递归地 queueMicrotask/.then 不断产出微任务,宏任务(含 setTimeout、渲染)会被饿死、永远轮不到——递归微任务全部跑完,排在最前的 setTimeout(…, 0) 才终于执行。
记住:微任务永远比宏任务先执行,即使 setTimeout 延迟是 0。

判断任何混合异步代码的输出顺序,只需一套固定规则:执行一个宏任务 → 清空整个微任务队列 →(渲染)→ 取下一个宏任务。同步代码本身属于第一个宏任务。

两个队列里都有谁

  • 宏任务:setTimeout / setInterval、I/O 回调、UI 事件——每轮循环只取一个;
  • 微任务:Promise.then/catch/finally、await 之后的代码、queueMicrotask()、MutationObserver——每轮全部清空,清空期间新产生的微任务也在本轮执行。

await 的本质

  • await x 等价于把函数剩余部分包成 then 回调:await 之前同步执行,await 之后进微任务队列;
  • 即使 await 一个已完成的值(如 await null),后续代码也要排队等一个微任务节拍,不会原地继续。
console.log("A");                          // ① 同步

setTimeout(() => console.log("E"));       // 宏任务,排到下一轮

(async () => {
  console.log("B");                        // ② await 之前是同步的
  await null;                              // 剩余部分入队 → 微任务队列:[D]
  console.log("D");
})();

Promise.resolve().then(() => console.log("C")); // 入队晚于 D → [D, C]

// 输出:A B D C E
// 推演:同步 A、B → 清空微任务 D、C → 下一个宏任务 E
最常见的误判:认为 async 函数一被调用就是「异步的」——实际上 await 之前的部分完全同步执行;以及认为 setTimeout(fn, 0) 会「立刻」执行——它最快也要等当前同步代码与全部微任务跑完,浏览器对嵌套定时器还有约 4ms 的最小延迟。
推演三步法:先通读同步代码并记下 then/await 的入队顺序;每轮末尾按入队顺序清空微任务;再取一个宏任务重复。练到能心算,事件循环这一块就算过关了。

Promise 是一台一次性的状态机:从 pending 出发,一旦敲定就锁死,这决定了它后续的一切行为。

Promise 有三种状态:pending、fulfilled(触发 then)、rejected(触发 catch)。状态一旦改变就不可逆。每个 then/catch 都返回一个新的 Promise,因此可以链式串联;回调里 return 的值会成为下一环 then 的入参(链式传值),若返回的是 Promise 则等它敲定后再往下走;链条中任意一环抛错或 reject,会跳过后续 then、直奔最近的 catch(错误沿链传播)。
new Promise((resolve, reject) => {
  setTimeout(() => resolve("成功"), 1000);
})
  .then(data => console.log(data))
  .catch(err => console.error(err))
  .finally(() => console.log("结束"));
.catch 处理完后,链条会恢复成 fulfilled 继续往下——catch 之后的 .then 照常执行,别以为进了 catch 整条链就停了。想让错误继续中断后续,得在 catch 里重新 throw。
每个 .then/.catch 都返回一个新 Promise(p.then(fn) === p 为 false),所以能链式串联;回调 return 的值成为下一环入参,return 一个 Promise 则等它敲定再往下走。

async/await 把 Promise 链写成同步的模样,可读性拉满,底层却仍是 Promise 和微任务。

两条硬规则:async 函数永远返回 Promise,哪怕函数体里写的是 return 1;await 只把后续代码挂进微任务队列,不阻塞主线程,同一时刻别的任务照常在跑。
async function getUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new Error("网络错误");
    return await res.json();
  } catch (err) {
    console.error(err.message);
    throw err;
  }
}
忘记 try/catch 会导致 rejected Promise 产生未捕获的异常。
await 之前的代码是同步执行的——调用一个 async 函数会一路跑到第一个 await 才交出控制权(打印顺序 before → inside-1 → after → inside-2);async 函数无论 return 什么都被包成 Promise(return 42 拿到的是 Promise<42>,instanceof Promise 为 true)。

互不依赖的异步任务别排队 await——这四个组合器让你按「全都要」「各自结算」「抢第一」不同策略并发。

互不依赖的异步任务应并发执行而不是逐个 await。Promise.all:全部成功才成功,一个失败就整体失败。Promise.allSettled:全部完成后返回每个结果的状态数组。Promise.race:最先敲定(成功或失败)者胜出。Promise.any:最先成功者胜出,全部失败则抛 AggregateError。
// ❌ 串行,总耗时叠加
const a = await fetchA();
const b = await fetchB();

// ✅ 并发,总耗时取最慢的
const [a, b] = await Promise.all([fetchA(), fetchB()]);

// race:超时控制
await Promise.race([
  fetchData(),
  new Promise((_, reject) =>
    setTimeout(() => reject("超时"), 5000))
]);
Promise.all 一个 reject 就整体 reject、其余结果全丢(抛出第一个失败的 reason)——要拿到每个任务各自的成败得用 allSettled(返回 {status,value}/{status,reason} 数组,永不 reject)。race 是最先敲定者胜出,先 reject 也算数——先失败的一方抢到就整体 reject。
多个任务无依赖 → Promise.all 并发;有依赖 → 串行 await。

高频事件(输入、滚动、resize)每次都跑回调会卡顿。两者都用闭包 + 定时器(见 02 章闭包)限制频率,区别只在何时放行:防抖等你停下来才执行一次;节流固定间隔最多执行一次。

// 防抖:停止触发 delay 毫秒后才执行一次
function debounce(fn, delay) {
  let timer;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

// 节流:每 interval 毫秒最多执行一次
function throttle(fn, interval) {
  let last = 0;
  return function (...args) {
    const now = Date.now();
    if (now - last >= interval) {
      last = now;
      fn.apply(this, args);
    }
  };
}

// 用法
input.addEventListener('input', debounce(search, 300));
window.addEventListener('scroll', throttle(onScroll, 200));
防抖/节流靠闭包里的同一个 timer/last 记状态——每个需要独立限频的目标必须各自持有同一份包装函数。若在每次事件回调里、或每次 React 渲染里现场 debounce(fn),每次都新建闭包、timer 永远是全新的,限频完全失效。应在组件外或 useRef/useMemo 里创建一次并复用。
选择口诀:只关心最终结果用防抖(搜索联想、表单校验、自动保存);要过程中的采样用节流(滚动加载、拖拽、进度上报)。生产可直接用 lodash 的 debounce/throttle——还支持 leading/trailing 边缘触发与 cancel()。

真实网络请求要处理三件事:会不会卡死(超时)、失败要不要重试、用户离开能不能取消。核心是 AbortController:它给出一个 signal 传进 fetch,一 abort() 请求立刻中断并抛 AbortError;AbortSignal.timeout(ms) 则是「到点自动 abort」的快捷方式。

// 超时:AbortSignal.timeout(Node 18+ / 现代浏览器)
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });

// 手动取消:用户点「取消」或离开页面
const ctrl = new AbortController();
fetch(url, { signal: ctrl.signal });
cancelBtn.onclick = () => ctrl.abort();

// 重试 + 指数退避
async function fetchRetry(url, retries = 3, delay = 500) {
  for (let i = 0; i <= retries; i++) {
    try {
      const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
      if (!res.ok) throw new Error('HTTP ' + res.status);
      return res;
    } catch (err) {
      if (i === retries) throw err;              // 最后一次仍失败:抛出
      await new Promise(r => setTimeout(r, delay * 2 ** i)); // 退避
    }
  }
}
<重试逻辑里必须自己检查 res.ok 再 throw:fetch 对 404/500 是正常 resolve 的(13 章),不手动抛错,4xx/5xx 会被当成功、根本不触发重试。另外别重试非幂等请求——超时了不等于服务端没执行,POST 重试可能下两个单。
退避用 delay * 2 ** i(指数退避)比固定间隔更友好,能避免「重试风暴」打垮正在恢复的服务;更讲究可再叠加随机抖动(jitter)。

这两个是 ES2024 添进来的小工具,Node 22 与 Chrome 150 双边均已可用。它们各自替掉一段人人都写过的样板代码——一个终结「Promise 构造器反模式」,一个终结「for await 里手动 push」。

Promise.withResolvers:不再需要在构造器里往外捞

  • 老写法是声明两个 let,在 new Promise((res, rej) => { resolve = res; reject = rej; }) 的回调里赋值捞出来——能用,但难读,还容易漏掉 reject。
  • 新写法一行:const { promise, resolve, reject } = Promise.withResolvers();(返回的就是这三个键)。
  • 什么时候真需要它:触发点和等待点不在同一段代码里。典型是把事件转成 Promise——WebSocket 的一次往返、「等用户点确认」的对话框、要在别处调用 resolve 的队列。日常「包一个异步操作」用 async 函数就够,别为了新 API 而用。

Array.fromAsync:异步版的 Array.from

  • await Array.fromAsync(异步可迭代对象) 把一个 for await 循环压成一行,还支持第二个参数做映射,和 Array.from 对称。
  • 它逐个顺序等,不是并发——这一点和 Promise.all 完全不同。要并发拿一批结果仍然用 Promise.all;Array.fromAsync 面对的是「一个一个吐出来」的流式来源(分页接口、可读流、异步生成器)。
  • 喂给它一个装着 Promise 的普通数组也行,此时它会依次 await,等价于串行版的 Promise.all。
// ── Promise.withResolvers(ES2024)─────────────
// 老写法:构造器反模式
let resolve, reject;
const p = new Promise((res, rej) => { resolve = res; reject = rej; });

// 新写法:一行
const { promise, resolve, reject } = Promise.withResolvers();

// 典型场景:把「等一次事件」变成可 await 的值
function once(target, type) {
  const { promise, resolve } = Promise.withResolvers();
  target.addEventListener(type, resolve, { once: true });
  return promise;
}

// ── Array.fromAsync(ES2024)───────────────────
async function* pages() {
  for (let i = 1; i <= 3; i++) yield await fetchPage(i);
}

// 老写法
const all = [];
for await (const p of pages()) all.push(p);

// 新写法(顺序等,不是并发)
const all2 = await Array.fromAsync(pages());
Array.fromAsync 是串行的,别拿它当 Promise.all 用——把一个装着 10 个 fetch Promise 的数组喂进去,是一个接一个等,总耗时是相加而不是取最大值。判据:来源是「已经全部发出去的一批 Promise」→ Promise.all;来源是「一个吐完才有下一个」的异步迭代器 → Array.fromAsync。另外它会把整个序列收进内存,无限流或超大结果集别用它,还是老老实实 for await 边收边处理。
Promise.withResolvers 的价值不在少写几个字,而在它让「谁来 resolve」这件事显式化。看到它就知道「这个 Promise 由外部某处触发完成」,比在构造器回调里赋值给外层 let 清楚得多。反过来说,如果你发现自己在用它包一段本来就能写成 async 函数的逻辑,那是用错了。

JavaScript · 模块化:ESM & CJS

import / export 的全部写法、ESM 与 CJS 的九项差异对照,以及 package.json 里决定「别人 import 你时拿到什么」的那几个字段。

模块的坑几乎都出在两套系统混用上。先记住一条分界:import/export 是 ESM,require/module.exports 是 CJS,当前文件属于哪一套由 package.json 的 type 和扩展名决定。

导出与导入的全部写法

写法含义
export const a = 1具名导出(推荐)
export { a, b as c }集中导出,可改名
export default x默认导出,每模块最多一个
export * from "./m"转发全部具名导出
export * as ns from "./m"转发为一个命名空间
export type { T }TS:只导出类型,编译后整行消失
import x from "./m.js"取默认导出
import { a, b as c } from "./m.js"取具名导出
import * as ns from "./m.js"整个命名空间
import "./side-effect.js"只执行,不取值
const m = await import("./m.js")动态导入:按需加载、路径可变,返回 Promise
import.meta.url当前模块的 URL(ESM 里没有 __dirname)
import data from "./d.json" with { type: "json" }导入属性(ES2025)

ESM 与 CJS 的差异

维度ESMCJS
语法import / exportrequire / module.exports
文件判定"type": "module" 或 .mjs默认,或 .cjs
相对导入必须带扩展名 ./a.js可省略,会自动补
解析时机静态,编译期确定依赖运行时,可写在 if 里
绑定语义活绑定:原模块改了值,导入方看得到值拷贝
加载异步同步
顶层 await支持不支持
目录变量import.meta.dirname(Node 21+)__dirname / __filename
互相引用ESM 可 import CJS(多数只能取默认导出)CJS 不能 require ESM,要用动态 import()
Tree-shaking可以(静态结构)基本不行

package.json:别人 import 你时拿到什么

字段作用
type"module" 让 .js 按 ESM 解析
exports现代入口,优先级最高;可按条件分发 import/require/types,并封闭未列出的路径
main / module老字段,没有 exports 时的回退
types类型声明入口(也可写进 exports)
imports包内私有别名,以 # 开头
sideEffects: false告诉打包器可以放心摇掉未用代码
files / bin发布哪些文件 / 提供哪些命令
// 动态导入:按需加载重依赖,或按条件选实现
const { default: hljs } = await import("highlight.js");

// ESM 里没有 __dirname,用 import.meta
const here = import.meta.dirname;              // Node 21+
const url = new URL("./data.json", import.meta.url);

// 在 ESM 里临时用 CJS 包的写法
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);

// exports 字段:一份代码给两套系统,还带类型
// "exports": { ".": { "types": "./d.ts", "import": "./m.js", "require": "./m.cjs" } }
ESM 里相对导入漏写扩展名,是 Node 上最常见的启动失败。浏览器和 Node 的 ESM 都不做扩展名补全,import "./util" 直接 ERR_MODULE_NOT_FOUND——打包器帮你补过,所以本地开发时看不出来,一到 node dist/index.js 就炸。TypeScript 项目里更绕:源码写 .js、实际文件是 .ts,这是官方推荐的写法(编译产物才是最终形态)。
具名导出优于默认导出。具名导出能被自动补全和自动 import 找到、改名时编辑器能全局重构、拼错立刻报错;默认导出在每个引用处都可以随便起名,一段时间后同一个模块在项目里有三个名字。例外是「一个文件就是一个组件 / 一个类」这种约定明确的场景。

ESM 是 JS 的官方标准模块系统:具名导出要对应名字、默认导出随意起名,且能被静态分析。

具名导出一个模块可以有多个,默认导出每个模块最多一个,两者能同时用:import def, { a, b } from "./m.js"。
// math.js
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export default class User { }

// main.js
import User, { PI, add } from './math.js';
import * as MathUtils from './math.js';
import 会被提升到模块顶部、且先于本模块代码执行——被导入模块的顶层代码在你第一行之前就跑完了(dep 的 body 先于 main 打印)。导入进来的绑定是只读的,试图给它赋值报 TypeError: Assignment to constant variable.,要改值只能调导出模块提供的函数。
ESM 是静态分析的,import 被提升到文件顶部。需要按条件动态加载用 import() 函数(返回 Promise)。

CommonJS 是 Node 早期的模块方案:require 同步加载、能写在条件分支里,至今仍有大量存量代码在用。

真正被导出的是 module.exports,exports 只是指向它的别名——给 exports 整体赋值(exports = { a: 1 })会断开这层引用,require 到的是空对象 {},这是 CJS 最经典的坑。
// math.js
function add(a, b) { return a + b; }
module.exports = { add, PI: 3.14159 };

// main.js
const { add, PI } = require('./math.js');
在 package.json 中设置 "type": "module" 后,.js 文件默认按 ESM 解析(require 不可用)。需要混用时用 .mjs / .cjs 后缀显式区分。
require 是同步的,可以放进 if 里按需加载。Node 22 起 CJS 甚至能直接 require 一个 ESM 模块(能取到具名导出),但该 ESM 或其依赖含顶层 await 就会抛 ERR_REQUIRE_ASYNC_MODULE,那种情况仍得改用动态 import()。

两者语法好记,真正容易误解的是运行语义——下面几点是 import 报错、以及「迁移到 ESM 后跑不起来」的高发区。

  • 加载时机:ESM 静态、异步,import 先于代码执行被提升;CJS 同步,require 执行到那一行才加载(所以能写在 if 里)。
  • 顶层 await:只有 ESM 支持;CJS 里写顶层 await 是语法错误。
  • 路径变量:ESM 里没有 __dirname/__filename/require,改用 import.meta.url(Node 20.11+ 可直接用 import.meta.dirname)。
  • 导出绑定:ESM 导出是实时只读绑定(源改了值,导入方看到最新);CJS 导出的是值的拷贝(导出后再改不影响已 require 的一方)。
  • 互操作:ESM 里可 import 一个 CJS 包(走 default)。反方向过去不行、现在可以——Node 22.12+(及 20.19+)起 CJS 能直接 require 一个 ESM;但仅限该模块及其依赖都没有顶层 await,否则报 ERR_REQUIRE_ASYNC_MODULE,此时仍需动态 import()。老 Node 上则一律走动态 import()。
// —— ESM 里替代 __dirname / require(Node)——
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Node 20.11+ 可直接用 import.meta.dirname

// —— 顶层 await:只有 ESM 能这么写 ——
const data = await fetch(url).then(r => r.json());

// —— live binding:ESM 导出随源实时变化 ——
// counter.js
export let count = 0;
export function inc() { count++; }
// main.js
import { count, inc } from './counter.js';
inc();
console.log(count); // 1(换成 CJS 的值拷贝则一直是 0)

// —— 在 ESM 里加载纯 CJS 依赖 ——
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacy = require('some-cjs-package');
循环依赖两者行为不同:ESM 靠 live binding,晚绑定的引用等真正用到时才求值,往往还能跑通;CJS 里 require 会拿到尚未执行完的半成品 module.exports(可能是 undefined 或缺字段),且不报错——最隐蔽的坑。根治办法是消除循环依赖本身。
选型一句话:写库 / 发包优先出 ESM,需要兼容旧环境时用 package.json 的 "exports" 字段同时提供 ESM / CJS 双入口(dual package);纯 Node 脚本用哪个都行,但别在同一个包里混着写。

在 ESM 里导入一个 JSON 文件,不能像 CJS 那样 require("./a.json") 直接拿——必须写导入属性 with { type: "json" }。这不是可加可不加的优化开关,漏了直接报错。本机 Node 22.22 静态与动态两种写法都已可用。

两种写法

  • 静态导入:import cfg from "./config.json" with { type: "json" };——注意 with 子句在路径之后、分号之前。
  • 动态导入:属性挪进第二个参数,且要多套一层——await import("./config.json", { with: { type: "json" } })。两处的 with 嵌套层数不一样,是最容易写错的地方。
  • JSON 模块只有默认导出,没有具名导出:import { port } from "./config.json" 是错的,要写 cfg.port。

为什么要这么啰嗦

  • 安全考虑:模块的解析方式不能由服务器返回的 MIME 类型单方面决定。如果不写死「我要的是 JSON」,一个本该返回 JSON 的地址被替换成 JavaScript,就会被当代码执行。导入属性把「我期望什么」写在了导入方这一侧,类型对不上就拒绝加载。
  • 这也是为什么它是语法的一部分而不是运行时选项——静态分析阶段就要知道。

旧写法 assert 已经作废

  • 提案早期用的是 assert { type: "json" },很多 2023 年前后的教程和 Stack Overflow 答案还停在这个写法。关键字已经从 assert 换成了 with,语义也变了——不再只是「断言」,而是可以影响模块如何被解析。
  • 看到 assert { type: … } 就当成过期资料的信号,连带检查那份资料里别的 API 是不是也旧了。
// ✓ 静态导入:with 在路径后面
import cfg from "./config.json" with { type: "json" };
cfg.port;              // 只有默认导出,按属性取

// ✓ 动态导入:with 挪进第二个参数,多一层嵌套
const m = await import("./config.json", { with: { type: "json" } });
m.default.port;

// ✗ 漏掉 with:直接报错,不是警告
import cfg2 from "./config.json";

// ✗ JSON 模块没有具名导出
import { port } from "./config.json" with { type: "json" };

// ✗ 已废弃的旧关键字(2023 年前的教程里常见)
import cfg3 from "./config.json" assert { type: "json" };
两处嵌套层数不同是高频笔误:静态是 with { type: "json" },动态是 { with: { type: "json" } }——后者外面还要包一层键名也叫 with 的对象。另外 TypeScript 侧要读 JSON 模块还需要 tsconfig 打开 resolveJsonModule,那是另一件事:它管的是类型能不能推出来,和运行时要不要写 with 互不替代,两个都得有。
如果只是想读一个配置文件、又不想跟这套语法纠缠,用 fs.readFileSync + JSON.parse 反而更直白,还能拿到解析错误的行号。导入属性的真正价值在于让打包器把 JSON 当模块图的一部分——参与依赖分析、能被 tree-shake、路径由模块解析规则统一处理。按需选。

JavaScript · 日期与时间

Date 的读写方法全表、toLocaleString 的选项对照、常见格式化与计算的成品写法,以及 day.js 与 Temporal 的取舍。

先记两条贯穿全章的事实:月份从 0 开始(0 是一月),Date 内部只有一个 UTC 时间戳,所有不带 UTC 的方法都按运行机器的时区换算。

创建

写法得到提醒
new Date()此刻—
new Date(ms)按时间戳毫秒,注意后端常给秒
new Date("2026-09-10")解析字符串纯日期按 UTC 解析,带时间则按本地
new Date(2026, 8, 10)按分量(本地时区)8 是九月;超范围会自动进位
Date.now()当前时间戳比 new Date().getTime() 省一次分配
Date.UTC(y, m, d)按 UTC 分量算时间戳要固定时区时用

读写分量

读写范围
getFullYear()setFullYear()四位年(别用 getYear)
getMonth()setMonth()0–11
getDate()setDate()1–31(getDay 是星期几,别搞混)
getDay()—0–6,0 是周日
getHours/Minutes/Seconds/Milliseconds对应 setter本地时区
getUTC* 全套setUTC*同上但按 UTC
getTime() / valueOf()setTime()时间戳
getTimezoneOffset()—本地与 UTC 差几分钟(符号是反的)

所有 setter 改的是原对象(Date 可变),并且会自动进位:setDate(0) 得上月最后一天,setMonth(12) 进到明年一月——这正是「加一个月」的正确写法。

输出与格式化

方法结果形态用途
toISOString()2026-09-10T04:00:00.000Z传给接口、存数据库(永远 UTC)
toJSON()同上JSON.stringify 自动调用它
toLocaleDateString(loc, opts)按地区的日期给人看的首选
toLocaleTimeString / toLocaleString时间 / 日期时间同上
new Intl.DateTimeFormat(loc, opts)可复用的格式化器批量格式化时快得多
Intl.RelativeTimeFormat「3 天前」相对时间
toString() / toDateString()英文长格式调试看看就行

常用选项:dateStyle/timeStyle(full|long|medium|short,最省事);细控用 year/month/day(numeric|2-digit|long)、weekday、hour12、timeZone: "Asia/Shanghai"。

常见任务怎么写

任务写法
加 7 天d.setDate(d.getDate() + 7)(自动跨月跨年)
加 1 个月d.setMonth(d.getMonth() + 1)(1 月 31 日会溢出成 3 月,需自行修正)
相差几天(b - a) / 86400000——两个 Date 相减得毫秒差
比较先后a < b 可直接比;判等必须 a.getTime() === b.getTime()
当天零点d.setHours(0, 0, 0, 0)
本月天数new Date(y, m + 1, 0).getDate()
拷贝一份new Date(d)——Date 可变,别直接传引用
new Date("2026-09-10") 与 new Date("2026/09/10") 解析出的时刻不同。前者是 ISO 纯日期格式,按规范当作 UTC 零点;后者是非标准格式,各引擎按本地零点处理。在东八区,前者显示成 9 月 10 日 08:00,跨到西边的时区甚至会变成 9 月 9 日——「日期整体差一天」的报障几乎都出自这里。要按本地时间构造,用分量形式 new Date(2026, 8, 10)。
存储与传输一律用 UTC(toISOString() 或时间戳),只在最后渲染那一步转成本地时区。这一条能挡掉九成时区 bug。要在指定时区显示,别自己加减小时——给 toLocaleString 传 { timeZone: "Asia/Shanghai" },夏令时和历史时区规则由 Intl 负责。

JS 只有 Date 一个日期类型,创建它却有四种写法,且每种的时区含义都不一样——先把它们分清,后面所有的坑才躲得开。

new Date() 有四种常见调用形式,返回值都是一个 Date 实例。最推荐用 ISO 8601 字符串("YYYY-MM-DD")或时间戳,因为其他字符串格式在不同浏览器/Node 版本中解析行为不一致,容易出错。
// 1. 无参数 → 当前时刻
const now = new Date();

// 2. 时间戳(毫秒,Unix epoch = 1970-01-01 UTC)
const d1 = new Date(0);           // 1970-01-01T00:00:00.000Z
const d2 = new Date(1700000000000);

// 3. ISO 8601 字符串(推荐)
const d3 = new Date("2024-06-15");        // 日期(UTC 0时)
const d4 = new Date("2024-06-15T08:30:00"); // 本地时间
const d5 = new Date("2024-06-15T08:30:00Z"); // UTC 时间

// 4. 分项传入(注意:月份从 0 开始!)
const d6 = new Date(2024, 5, 15, 8, 30, 0);
//                   年   月  日  时  分  秒  ← 月份 5 = 6月
月份从 0 开始是 Date 最臭名昭著的坑:new Date(2024, 0, 1) 是 1月1日,new Date(2024, 11, 31) 是 12月31日。忘记这一点会导致日期错位整整一个月。
想要「本地时区某天零点」时别用字符串解析,用分项构造 new Date(2026, 6, 22)(月份记得 -1)——它永远按运行环境本地时区,不受字符串格式那套解析规则影响;而 new Date("2026-07-22") 按 UTC 零点、new Date("2026/07/22") 按本地零点,同一天差 8 小时。

时间戳是唯一没有时区歧义的时间表示,存储和传输都该用它——下面讲怎么拿到它、以及它和 Date 怎么互转。

时间戳是从 1970-01-01 00:00:00 UTC 到某一时刻经过的毫秒数,是存储和传输时间的最佳格式(数字,无时区歧义)。Date.now() 比 new Date().getTime() 更简洁高效,是获取当前时间戳的首选。
// 获取当前时间戳(毫秒)
const ts = Date.now();              // 推荐
const ts2 = new Date().getTime(); // 等价
const ts3 = +new Date();           // 利用隐式转换,不推荐

// 时间戳 ↔ Date 互转
const d = new Date(1718438400000);
console.log(d.getTime()); // 1718438400000

// 性能计时(精确到毫秒)
const start = Date.now();
// ... 执行一些操作 ...
console.log(Date.now() - start, "ms");

// 更高精度计时用 performance.now()(微秒级)
const t0 = performance.now();
无效日期不会报错:new Date("坏字符串") 得到一个 Invalid Date,它的 getTime() 是 NaN;可一旦对它调 toISOString() 或 toJSON(),又会抛 RangeError: Invalid time value。所以拿到 Date 先用 Number.isNaN(d.getTime()) 验一下,别等序列化时才崩。
存入数据库或通过接口传输时,始终用时间戳(数字)或 ISO 字符串,不要传 Date 对象或本地化字符串,否则跨时区会出错。

getter 分「本地」和「UTC」两套,在中国差整整 8 小时——混用是日期显示错乱的头号原因,下面把两套摆在一起对照。

Date 提供一组 get* 方法读取各时间分量。分为本地时间(无 UTC 前缀)和 UTC 时间(带 UTC 前缀)两套。在中国(UTC+8),两者相差 8 小时,混用会导致时间显示错乱。
const d = new Date("2024-06-15T14:30:45.500");

d.getFullYear()     // 2024
d.getMonth()       // 5(注意!6月 = 5)
d.getDate()        // 15(日)
d.getDay()         // 6(星期六,0=周日 1=周一 … 6=周六)
d.getHours()       // 14
d.getMinutes()     // 30
d.getSeconds()     // 45
d.getMilliseconds()// 500

// UTC 版本(时区无关)
d.getUTCHours()    // UTC 小时数(比中国时间少8小时)
d.getTimezoneOffset() // -480(中国 UTC+8,返回分钟数,负数=东区)
getDay() 返回的是星期(0–6),getDate() 才是日期(1–31),两者极易混淆。另外 getMonth() 返回 0–11,显示时记得 +1。
拼「年-月-日」记住两件事:getMonth() 返回 0–11,展示要 +1 并补零 String(d.getMonth()+1).padStart(2,"0");「星期几」用 getDay()(0=周日),别和「几号」的 getDate() 搞混——这俩名字最容易记反。

每个 getter 都有配对的 setter,会就地改原对象还能自动进位、做日期加减很顺手——但「可变」和「溢出」也正是它咬人的地方。

进位是自动的:1 月 1 日 setDate(32) 得到 2 月 1 日,setMonth(12) 滚到明年一月——做日期加减不必自己判断月末。
const d = new Date("2024-01-15");

d.setFullYear(2025);    // 改年份
d.setMonth(11);         // 改月份(11 = 12月)
d.setDate(31);          // 改日
d.setHours(23, 59, 59); // 改时分秒(支持多参数)

// 自动进位:给一月份设 date=0 → 变成上个月最后一天
const lastDay = new Date(2024, 2, 0); // 2024-02-29(闰年)

// 加 N 天(常用技巧)
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);

// 加 N 个月
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
setter 的返回值是改动后的时间戳(number),不是 Date 对象,所以不能链式调用:d.setFullYear(2025).setMonth(0) 抛 TypeError: d.setFullYear(...).setMonth is not a function。要连改多个分量得分成多行,或用支持多参数的重载(如 setHours(23, 59, 59))。
Date 对象是可变的。如果不想污染原始日期,先克隆一份再修改:const copy = new Date(original.getTime())。

给人看的日期不该用 toString()——它随浏览器和系统语言而变;下面讲原生就能做的本地化格式化,从简单方法到可复用的 Intl.DateTimeFormat。

toLocaleDateString / toLocaleTimeString 各取一半,toLocaleString 取全部;要反复格式化就建一个 Intl.DateTimeFormat 实例、复用它的 .format()——建实例远比格式化本身贵,循环里每次新建能差出两个数量级,自己跑一下就知道。
const d = new Date("2024-06-15T14:30:00");

// 基础方法
d.toISOString()      // "2024-06-15T06:30:00.000Z"(UTC,标准格式)
d.toLocaleDateString("zh-CN") // "2024/6/15"
d.toLocaleTimeString("zh-CN") // "14:30:00"
d.toLocaleString("zh-CN")     // "2024/6/15 14:30:00"

// Intl.DateTimeFormat(推荐:可复用 formatter)
const fmt = new Intl.DateTimeFormat("zh-CN", {
  year:   "numeric",
  month:  "long",    // "6月"
  day:    "numeric",
  weekday:"short",   // "周六"
  hour:   "2-digit",
  minute: "2-digit",
});
fmt.format(d); // "2024年6月15日周六 14:30"

// 手动补零(自定义格式时常用)
const pad = n => String(n).padStart(2, "0");
const yyyymmdd = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
// "2024-06-15"
toISOString() 输出的是 UTC 时间(结尾带 Z):本地 UTC+8 时,new Date("2024-06-15T14:30:00").toISOString() 得到 "2024-06-15T06:30:00.000Z"——比你写下的钟点早 8 小时。把它当「本地时间字符串」直接展示会整体差一个时区,展示请用 toLocaleString 系列或 Intl.DateTimeFormat。
避免用 toString() 的结果做展示——它的格式因浏览器和系统语言而异。统一用 toLocaleDateString + locale 参数,或 Intl.DateTimeFormat。

Date 参与算术会自动转成毫秒时间戳,算时间差、比大小都很方便——唯独「相等」不能靠隐式转换,下面讲清楚这条边界。

Date 对象参与运算时会自动转成时间戳(毫秒数)。利用这一特性可以方便地计算时间差。直接用 < > 比较两个 Date 对象是安全的(同样走隐式转换),但 == 比较的是引用,需要用 .getTime()。
const start = new Date("2024-01-01");
const end   = new Date("2024-06-15");

// 时间差(毫秒 → 天)
const diffMs   = end - start;               // 自动转时间戳相减
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
console.log(diffDays); // 166

// 比较大小(隐式转时间戳)
start < end   // true  ✅
start > end   // false ✅

// 判断相等必须用 getTime()
const a = new Date("2024-01-01");
const b = new Date("2024-01-01");
a == b               // false(不同对象引用)
a.getTime() === b.getTime() // true ✅

// 判断日期是否有效
const invalid = new Date("not a date");
isNaN(invalid.getTime()) // true → 无效日期
月末陷阱:计算「加一个月」用 setMonth() 时,1月31日 + 1月 会变成 3月2日或3日(因为2月没有31号,自动溢出)。处理月末边界需要额外判断。
比大小 < > 直接用 Date 就行(走隐式 valueOf 转时间戳);但判「同一时刻」必须 a.getTime() === b.getTime()——==/=== 比的是对象引用,new Date("2024-01-01") == new Date("2024-01-01") 是 false。算天数差记得除以 86400000 再 Math.floor。

Date 内部永远以 UTC 存,getter 却默认给你「运行环境的本地时区」——一旦代码要在别的时区正确显示,这个默认就成了坑,下面讲原生怎么指定时区。

Date 对象内部始终以 UTC 存储,getter 方法默认返回运行环境的本地时区时间。跨时区开发时,要么统一用 UTC 系列方法,要么借助 Intl.DateTimeFormat 的 timeZone 选项指定时区——这是原生最可靠的做法,无需任何库。
// 同一个 Date,本地时间 vs UTC 时间
const d = new Date("2024-06-15T00:00:00Z"); // UTC 0点
d.getHours()    // 8(在 UTC+8 环境下)
d.getUTCHours() // 0

// 用 Intl 指定任意时区输出
const toTZ = (date, tz) =>
  new Intl.DateTimeFormat("zh-CN", {
    timeZone: tz,
    dateStyle: "short",
    timeStyle: "medium",
  }).format(date);

toTZ(d, "Asia/Shanghai");     // "2024/6/15 08:00:00"
toTZ(d, "America/New_York");  // "2024/6/14 20:00:00"
toTZ(d, "Europe/London");     // "2024/6/15 01:00:00"

// 获取当前环境时区名
Intl.DateTimeFormat().resolvedOptions().timeZone
// "Asia/Shanghai"
无前缀的 getHours() 等 getter 返回的是运行环境的本地时区,不是某个固定时区——同一段代码在服务器(常为 UTC)和你本机(UTC+8)跑,getHours() 会差 8 小时,是「本地没问题、线上时间全错」的经典成因。Date 本身没有「设定时区」的能力,要固定时区只能靠 Intl.DateTimeFormat 的 timeZone 选项。
复杂的时区换算(跨夏令时、历史时区等)推荐用 Luxon 或 date-fns:Luxon 原生内置 IANA 时区,date-fns 需搭配 date-fns-tz;二者都不可变,比 Moment.js 更现代。

字符串解析是 Date 最不可靠的部分——只有 ISO 8601 是规范强制所有引擎支持的,其余格式全看引擎脸色,下面只依赖安全格式。

Date.parse(str) 将字符串解析为时间戳(毫秒),解析失败返回 NaN。只有 ISO 8601 格式是规范要求浏览器必须支持的,其他格式(如 "2024/06/15"、"June 15, 2024")在不同引擎中表现不一,生产代码中应避免依赖。
// ✅ 安全:ISO 8601 格式
Date.parse("2024-06-15")            // 1718409600000
Date.parse("2024-06-15T08:00:00Z")   // 1718438400000

// ⚠️ 不安全:格式依赖引擎
Date.parse("2024/06/15")  // Chrome: ok,Safari 旧版: NaN
Date.parse("15/06/2024")  // 多数引擎: NaN

// 推荐:手动解析非标准格式
function parseDate(str) {
  // 将 "2024/06/15" 转为 ISO
  const iso = str.replace(/\//g, "-");
  const ts  = Date.parse(iso);
  if (isNaN(ts)) throw new Error(`无效日期: ${str}`);
  return new Date(ts);
}
Safari 的经典坑:new Date("2024-06-15 08:00:00")(用空格代替 T)在 Safari 中会返回 Invalid Date,Chrome 没问题。跨浏览器时必须写成 "2024-06-15T08:00:00"。
别以为「能解析出数字」就等于「解析对了」:Date.parse("2024-06-15") 按 UTC 零点算,而换成斜杠的 Date.parse("2024/06/15") 按本地零点算,两者相差整整 8 小时(一个时区)。要跨环境结果一致,统一用带时区的 ISO 串——结尾加 Z 或 +08:00。

「3 分钟前」这类相对时间是 UI 高频需求,原生 Intl.RelativeTimeFormat 就能多语言实现,不必为它引入整个日期库。

用法是传「数值 + 单位」:rtf.format(-3, "minute") 得到「3分钟前」,正数为将来、负数为过去。默认只会算出「1天前」,要输出「昨天」得开 { numeric: "auto" }。
const rtf = new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto" });

rtf.format(-1,  "day")    // "昨天"
rtf.format(-3,  "day")    // "3天前"
rtf.format(1,   "day")    // "明天"
rtf.format(2,   "week")   // "2周后"
rtf.format(-30, "minute") // "30分钟前"

// 实用函数:自动选择合适的单位
function timeAgo(date) {
  const diff = date - Date.now(); // 可以为负
  const abs  = Math.abs(diff);
  const rtf  = new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto" });
  if (abs < 60_000)      return rtf.format(Math.round(diff/1000),    "second");
  if (abs < 3_600_000)   return rtf.format(Math.round(diff/60_000),  "minute");
  if (abs < 86_400_000)  return rtf.format(Math.round(diff/3_600_000),"hour");
  return rtf.format(Math.round(diff/86_400_000), "day");
}
timeAgo(new Date(Date.now() - 5 * 60000)); // "5分钟前"
RelativeTimeFormat 只负责「把数值 + 单位翻成文字」,既不替你算时间差、也不自动选单位——用 day 还是 hour、怎么取整都得你自己定。传小数不会报错也不归整:rtf.format(-0.5, "day") 直接输出「0.5天前」;numeric: "auto" 也只在很小的整数(如 ±1「昨天/明天」、中文 ±2「前天/后天」)才给自然词,其余仍是「N天前」。
numeric: "auto" 会在可以用自然语言表达时自动选择(「昨天」而非「1天前」);改成 "always" 则始终输出数字形式。

Moment.js 已停止维护,day.js 用几乎相同的 API 和约 1/30 的体积接棒——下面是它的速查,也顺带交代什么场景该选它、什么场景原生就够。

day.js 的核心只有 3.0 KB(1.11.23 的 dayjs.min.js 压缩后),API 与已废弃的 Moment.js 高度兼容。所有操作返回新对象(不可变),支持链式调用;核心之外的能力全部拆成 37 个官方插件与 143 个 locale,用到哪个才 extend 哪个——这正是它比 Moment 小一个数量级的原因。

官方插件全表(37 个,dayjs/plugin/名字)

分组插件作用
解析customParseFormat按给定格式串解析。不装的话 dayjs("15/06/2024") 直接是 Invalid Date
解析arraySupportdayjs([2024,5,15])(月份 0 基)
解析objectSupportdayjs({year:2024,month:5,day:15})
解析bigIntSupport接受 BigInt 时间戳 dayjs(1718438400000n)
解析negativeYear公元前年份:dayjs("-2024-06-15").year() → -2024
解析preParsePostFormat非拉丁数字的 locale(阿拉伯语等)解析/输出时换数字
格式化advancedFormat补 Q(季度)、X/x(秒/毫秒时间戳)、z(时区缩写)、Do 等 token
格式化localizedFormatL LL LLL LT 这类本地化格式串:format("LLL") → 「2024年6月15日下午2点30分」
格式化buddhistEra佛历:format("BBBB-MM-DD") → 2567-06-15
格式化toArray[2024,5,15,14,30,45,0]
格式化toObject{years,months,date,hours,minutes,seconds,milliseconds}
时区utc.utc()/.local(),以 UTC 解析与输出
时区timezonedayjs.tz(...) 按 IANA 时区名换算,依赖 utc
比较isBetween区间判断,第四参数控制端点开闭
比较isSameOrAfter / isSameOrBefore核心只有严格的 isAfter/isBefore,含等号要靠这两个
比较minMaxdayjs.max(a,b,...) / dayjs.min(...)
比较isToday / isTomorrow / isYesterday三个独立插件,各管一天
比较isLeapYear.isLeapYear()
比较isMomentdayjs.isMoment(x),Moment 迁移期的兼容判断
周与季度weekOfYear.week(),周序随 locale 的周首日变
周与季度isoWeekISO 8601 周:.isoWeek() / .isoWeekday() / .isoWeekYear()
周与季度weekYear周所属的年(跨年那周会与 year() 不同)
isoWeeksInYear周与季度该年有 52 还是 53 个 ISO 周(2020 是 53)
weekday周与季度按当前 locale 周首日计的星期序号(与固定 0=周日的 day() 不同)
dayOfYear周与季度一年中的第几天
quarterOfYear周与季度.quarter(),也让 startOf("quarter") 可用
时长durationdayjs.duration(90,"minute")、解析 ISO 8601 P1Y2M3D、.humanize()
时长relativeTime.fromNow() / .from(x) / .toNow()
时长calendar近期日期说人话:"Tomorrow at 5:03 PM"
本地化localeData取当前 locale 的月名、周首日等元数据(自己画日历时要用)
本地化updateLocale改现成 locale,比如把 weekStart 设成 1
杂项badMutable把 dayjs 变回可变——只用于从 Moment 迁移的过渡期,别在新代码里装
杂项devHelper开发期把可疑用法打成控制台警告,生产别打包
杂项pluralGetSet复数别名 .years() .months() .dates() .hours()

用插件的四条纪律

  • 顺序有依赖:timezone 必须排在 utc 之后。只 extend 前者,调用时抛的是 TypeError: r.utc is not a function——错误信息完全不提插件顺序,能卡很久;
  • 忘了 extend 不会有友善提示:直接 TypeError: dayjs(...).isBetween is not a function。所以第三方组件库文档里那句「请先 extend XXX」不是客套话;
  • locale 不用 extend:import "dayjs/locale/zh-cn" 是副作用注册,再 dayjs.locale("zh-cn") 切换。想改周首日这类细节要另装 updateLocale;
  • 算体积按插件算:核心 3.0 KB,duration 1.7 KB、customParseFormat 1.8 KB、timezone 1.1 KB、utc 0.95 KB,一个 locale 约 0.8 KB(均为压缩后)。装满常用几个就到 8–9 KB,此时相对 date-fns 的体积优势已经不明显——按需 extend 是 day.js 的前提,不是可选项。
// 安装:npm install dayjs
import dayjs from "dayjs";

// ── 创建 ────────────────────────────────────────
dayjs()                          // 当前时刻
dayjs("2024-06-15")             // 解析 ISO 字符串
dayjs("2024/06/15", "YYYY/MM/DD")// 指定格式(需 CustomParseFormat 插件)
dayjs(1718438400000)            // 从时间戳创建
dayjs(new Date())               // 从 Date 对象创建

// ── 格式化(format token 与 Moment.js 相同)────
dayjs().format("YYYY-MM-DD")         // "2024-06-15"
dayjs().format("YYYY年MM月DD日 HH:mm") // "2024年06月15日 14:30"
dayjs().format("ddd, MMM D")         // "Sat, Jun 15"

// ── 读取分量 ────────────────────────────────────
const d = dayjs("2024-06-15T14:30:45");
d.year()      // 2024
d.month()     // 5  ⚠️ 同原生 Date,从 0 开始
d.date()      // 15(日)
d.day()       // 6(星期六,0=周日)
d.hour()      // 14
d.minute()    // 30
d.second()    // 45
d.valueOf()   // 时间戳(毫秒)
d.toDate()    // 转回原生 Date 对象

// ── 加减(不可变,返回新对象)──────────────────
dayjs().add(7, "day")        // 加 7 天
dayjs().subtract(1, "month") // 减 1 个月
dayjs().add(2, "year")       // 加 2 年
dayjs().add(3, "hour")       // 加 3 小时

// 时间单位(add/subtract/startOf/endOf 通用):
// "year" "month" "week" "day" "hour" "minute" "second" "millisecond"

// ── 边界:月初 / 月末 ────────────────────────
dayjs().startOf("month")  // 当月第一天 00:00:00
dayjs().endOf("month")    // 当月最后一天 23:59:59.999
dayjs().startOf("week")   // 本周日 00:00(默认周日为第一天)

// ── 比较 ─────────────────────────────────────
const a = dayjs("2024-01-01");
const b = dayjs("2024-06-15");
a.isBefore(b)            // true
a.isAfter(b)             // false
a.isSame(b, "year")      // true(同一年)
b.isBetween(a, dayjs())  // 需要 isBetween 插件

// ── 时间差:diff() ────────────────────────────
b.diff(a, "day")   // 166(整数,默认截断)
b.diff(a, "month") // 5
b.diff(a, "day", true) // 166.xxx(第三参数=true 返回浮点数)

// ── 扩展能力:按需 extend 官方插件(共 37 个)──
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";       // 依赖 utc
import customParseFormat from "dayjs/plugin/customParseFormat";
import "dayjs/locale/zh-cn";                        // locale 是副作用导入,不 extend

dayjs.extend(utc);
dayjs.extend(timezone);        // 顺序反了会报 TypeError: r.utc is not a function
dayjs.extend(customParseFormat);
dayjs.locale("zh-cn");

dayjs.tz("2024-06-15 14:30", "America/New_York").format()
// "2024-06-15T14:30:00-04:00"
dayjs("15/06/2024", "DD/MM/YYYY").format("YYYY-MM-DD")  // "2024-06-15"
注意:dayjs 的 month() 仍然从 0 开始(与原生 Date 一致),这一点与 date-fns 不同。此外,时区插件(utc + timezone)内部依赖 Intl.DateTimeFormat,在 Node.js 12 及更早(默认 small-ICU)可能需要单独引入完整 ICU / IANA 时区数据(Node 13+ 已默认打包)。
选型参考:轻量无插件需求 → day.js(2KB);需要树摇(tree-shake)、强类型 → date-fns;需要复杂时区和 Duration 计算 → Luxon(无需额外插件,内置 IANA 支持);纯展示的相对时间 → 直接用原生 Intl.RelativeTimeFormat,无需依赖任何库。

Date 的月份 0 基、可变、时区糊涂这些毛病改不动了,Temporal 是 TC39 用来彻底替代它的新 API。它已经不再是「将来时」——本机 Chrome 150 无需任何旗标、无需 polyfill,Temporal.Now.plainDateISO() 直接返回当天日期;Firefox 139+ 同样已原生支持。Node 这边还要等(22.22 上 typeof Temporal 仍是 undefined),服务端暂时还得靠 @js-temporal/polyfill。

它把 Date 一个类干的活拆成一族类型:Temporal.PlainDate(不带时区的日历日期)、Temporal.Instant(时间轴上的一个点)、Temporal.ZonedDateTime(带时区的时刻)。「本地日期」和「时刻」本就是两种东西,Date 把它们混成一个类,正是无数时区 bug 的源头。
// 浏览器(Chrome 150 / Firefox 139+)里 Temporal 是全局对象,直接用
// Node 尚未内置,服务端需要:npm i @js-temporal/polyfill
// import { Temporal } from "@js-temporal/polyfill";

// 纯日期(无时间,无时区)
const today = Temporal.PlainDate.from("2024-06-15");
today.add({ months: 1 });  // Temporal.PlainDate "2024-07-15"(不可变!)

// 带时区的精确时刻
const now = Temporal.Now.zonedDateTimeISO("Asia/Shanghai");
now.year; now.month; now.hour; // 直接访问属性

// 持续时间(Duration)
const dur = Temporal.Duration.from({ days: 3, hours: 2 });
today.add(dur); // "2024-06-18"

// 两个日期相差多少天
const d1 = Temporal.PlainDate.from("2024-01-01");
const d2 = Temporal.PlainDate.from("2024-06-15");
d1.until(d2).days; // 166
从 Date 迁过来最容易栽的三点(均为 Chrome 150):① 不可变——d.add({ days: 1 }) 返回新对象、原对象一动不动,照 Date 的 setter 习惯写却不接返回值,等于什么都没做;② 比较不能用 <——valueOf 被故意做成抛错,两个 PlainDate 直接比大小得到 TypeError: Do not use Temporal.PlainDate.prototype.valueOf; use ... compare,必须走静态的 Temporal.PlainDate.compare(a, b)(返回 -1/0/1)。这是好事:Date 那种 d1 < d2 悄悄转成毫秒数的隐式行为正是 bug 温床;③ 「加一天」和「加 24 小时」不是一回事——纽约夏令时切换那天,add({ days: 1 }) 得到次日 12:00(日历日),add({ hours: 24 }) 得到次日 13:00(绝对时长)。Date 上你没得选,Temporal 逼你想清楚要哪个。
四条核心改进,都在 Chrome 150 上验证过:① 不可变——d.add({ months: 1 }) 返回新对象,原来那个仍是 2024-06-15;② 月份从 1 开始——同一个 6 月,PlainDate.month 是 6,而 new Date(…).getMonth() 是 5;③ 时区感知,见下方陷阱里那组夏令时数字;④ 明确区分「某天」与「某刻」——PlainDate 减 Instant 当场 TypeError,逼你先说清时区。

JavaScript · 序列化与编码

六种「把数据变成可传输形式」的手段横向对照:JSON、结构化克隆、Base64、URL 编码、FormData、TextEncoder,各自支持什么类型、丢失什么。

选错序列化手段的代价是静默丢数据:Date 变字符串、undefined 消失、Map 变成 {}。下表按「支持什么、丢什么」排。

横向对照

手段产物支持丢失 / 报错用在哪
JSON.stringify字符串对象 / 数组 / 字符串 / 数字 / 布尔 / nullundefined、函数、Symbol 被删;Date 变字符串;Map/Set 变 {};BigInt 与循环引用抛错HTTP 请求体、配置、localStorage
structuredClone新对象Date / Map / Set / RegExp / ArrayBuffer / 循环引用函数、Symbol、DOM 节点、类的原型(抛错或退化成普通对象)进程内深拷贝、postMessage
encodeURIComponent字符串任意文本不转 !'()*;对整条 URL 用会毁掉 :/?拼查询参数的值
URLSearchParams查询串键值对,自动编码值一律转成字符串构造查询串的首选
btoa / atobBase64 字符串只吃拉丁一字节字符中文直接抛错,需先 TextEncoder 转字节data URI、简单编码
FormDatamultipart 请求体文本 + 文件不能直接 JSON.stringify文件上传、原生表单提交
TextEncoder / TextDecoderUint8Array ↔ 字符串UTF-8 字节—二进制、哈希、流处理

JSON 的两个钩子

写法作用
JSON.stringify(v, replacer, space)replacer 可以是函数(逐键改写)或字符串数组(白名单字段);space 传 2 得到缩进美化
JSON.parse(s, reviver)逐键还原,典型用途是把 ISO 字符串转回 Date
对象上的 toJSON()自定义该类型的序列化形态(Date 就是这么实现的)
// Map / Set / BigInt 要自己转,否则静静丢光或直接抛错
JSON.stringify({ m: new Map([["a", 1]]) });   // {"m":{}} —— 数据没了
JSON.stringify({ m: [...map] });                // ✅ 先转数组

// 中文过 Base64:先编成字节,再逐字节转字符
const bytes = new TextEncoder().encode("你好");
const b64 = btoa(String.fromCharCode(...bytes));

// 构造查询串别手拼,交给 URLSearchParams
const qs = new URLSearchParams({ q: "a b&c", page: 2 }).toString();

// reviver:把 ISO 字符串还原成 Date
const data = JSON.parse(text, (k, v) =>
  typeof v === "string" && isoRe.test(v) ? new Date(v) : v);
JSON.stringify 会安静地删掉 undefined 与函数属性。{ a: 1, b: undefined } 序列化后是 {"a":1},接收方拿到的对象里根本没有 b 这个键——如果业务上「字段存在但为空」和「字段不存在」含义不同,这里就出错了。要显式表达空值,用 null。

数组里则相反:undefined 会变成 null 而不是被删,长度保持不变。
进程内复制用 structuredClone,跨进程 / 存盘 / 发网络用 JSON。前者保真度高得多(Date、Map、循环引用都在),后者胜在通用。别再用 JSON.parse(JSON.stringify(x)) 做深拷贝——它是三者里最容易悄悄改变数据的。

JSON 是跨语言数据交换的通用格式,但它只认得 JS 值的一个子集——重点不只是怎么用,更是哪些值会被悄悄丢掉或改写。

JSON(JavaScript Object Notation)是最常用的数据序列化格式,几乎所有语言都支持。JSON.stringify() 将 JS 值转为 JSON 字符串;JSON.parse() 将 JSON 字符串还原为 JS 值。两者都支持第二个参数用于过滤/转换,stringify 还支持第三个参数控制缩进格式。
// ── 基本用法 ─────────────────────────────────
const obj = { name: "Alice", age: 25, tags: ["dev", "js"] };

const json = JSON.stringify(obj);
// '{"name":"Alice","age":25,"tags":["dev","js"]}'

JSON.parse(json);  // 还原为 JS 对象

// 格式化输出(第三参数=缩进空格数)
JSON.stringify(obj, null, 2);
// {
//   "name": "Alice",
//   "age": 25,
//   "tags": ["dev", "js"]
// }

// ── 哪些值会被丢弃或转换 ─────────────────────
JSON.stringify({
  a: undefined,          // 属性被丢弃(undefined 不是合法 JSON)
  b: function(){},       // 属性被丢弃
  c: Symbol("x"),       // 属性被丢弃
  d: NaN,                 // → null
  e: Infinity,            // → null
  f: new Date(),         // → ISO 字符串 "2024-06-15T..."
  g: 42n,                // ❌ TypeError: BigInt 不支持
});

[undefined, Symbol(), function(){}]  // 数组中变成 null
JSON.stringify([undefined, 1]); // "[null,1]"

// ── replacer:过滤 / 转换属性 ─────────────────
// 数组形式:只保留指定的 key
JSON.stringify(obj, ["name", "age"]); // '{"name":"Alice","age":25}'

// 函数形式:对每个键值对做转换
JSON.stringify(obj, (key, val) => {
  if (key === "age") return undefined; // undefined → 丢弃该属性
  return val;
}); // '{"name":"Alice","tags":["dev","js"]}'

// ── reviver:反序列化时恢复类型 ──────────────
const data = JSON.parse('{"name":"Alice","createdAt":"2024-06-15T00:00:00.000Z"}',
  (key, val) => key === "createdAt" ? new Date(val) : val
);
data.createdAt instanceof Date; // true(字符串恢复为 Date 对象)

// ── toJSON:自定义序列化行为 ──────────────────
class Money {
  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }
  toJSON() {  // JSON.stringify 会自动调用
    return `${this.currency}${this.amount.toFixed(2)}`;
  }
}
JSON.stringify({ price: new Money(9.9, "¥") }); // '{"price":"¥9.90"}'

// ── 深克隆(简单场景)────────────────────────
const clone = JSON.parse(JSON.stringify(obj));
// ⚠️ 会丢失 Date/Map/Set/undefined/函数/循环引用,限简单纯数据对象
// 推荐改用 structuredClone()(见下)
四大常见坑:① Date 序列化后变字符串,parse 回来还是字符串不是 Date;② Map/Set 序列化后变空对象 {};③ 循环引用对象会抛 TypeError: circular structure;④ 对象键是 Symbol 或值是 undefined/函数的属性会被静默丢弃。
JSON5 / superjson:如果需要在 JSON 中传输 Date、Map、Set、undefined、BigInt 等,可以使用 superjson(支持这些类型并能完整还原)或在 reviver 中手动处理。

「深拷贝」长期靠 JSON.parse(JSON.stringify()) 硬凑,会丢 Date/Map/Set、还怕循环引用;structuredClone 是浏览器与 Node 17+ 原生内置的正解。

structuredClone() 是浏览器和 Node.js 17+ 原生内置的深克隆 API,底层使用结构化克隆算法(Structured Clone Algorithm),比 JSON 序列化支持更多类型:Date、Map、Set、ArrayBuffer、RegExp、Error、循环引用等,同时不会丢失任何数据。它也是 postMessage、IndexedDB 存储的底层序列化机制。
// ── 基础深克隆 ───────────────────────────────
const original = {
  name: "Alice",
  date: new Date("2024-06-15"),
  scores: [90, 85, 95],
  meta: { active: true },
};

const clone = structuredClone(original);

clone.meta.active = false;
original.meta.active; // true(完全独立)
clone.date instanceof Date; // true(Date 正确克隆)

// ── 支持的特殊类型(JSON 不支持)─────────────
structuredClone(new Map([["a", 1]]));  // ✅ Map
structuredClone(new Set([1, 2, 3]));  // ✅ Set
structuredClone(/\d+/g);  // ✅ RegExp
structuredClone(new Error("oops"));   // ✅ Error
structuredClone(new Int32Array([1, 2]))// ✅ TypedArray

// ✅ 循环引用也能处理
const a = { name: "a" };
a.self = a;
const b = structuredClone(a); // 不会死循环!
b.self === b; // true(循环结构被正确复制)

// ── 不支持的类型(会抛错)────────────────────
structuredClone(function(){});  // ❌ DataCloneError:函数不可克隆
structuredClone(Symbol());     // ❌ DataCloneError:Symbol 不可克隆
structuredClone(document.createElement("div")); // ❌ DOM 节点不可克隆

// ── transfer:转移所有权(零拷贝)────────────
// 适用于 ArrayBuffer 等可转移对象,避免拷贝开销
const buf = new ArrayBuffer(1024);
const transferred = structuredClone(buf, { transfer: [buf] });
buf.byteLength; // 0(原对象已被转移,不可再用)

// 这也是 postMessage 传递大数据的底层机制:
worker.postMessage({ data: buf }, [buf]); // [buf] 即 transfer list
structuredClone 会丢失原型链:克隆自定义类的实例,结果只是一个普通对象(prototype 变成 Object.prototype)。如果需要保留类实例,需要在类上实现自定义克隆逻辑。
选型速查:纯数据对象(无 Date/Map/Set)→ { ...obj } 或 JSON.parse(JSON.stringify());需要保留类型 → structuredClone();需要保留类实例和方法 → 自定义 clone() 方法或第三方库(如 lodash _.cloneDeep)。

前端处理文件上传、图片、WebSocket 二进制帧、Web Crypto API 时,必须和二进制数据打交道。下面分两条线理清相关 API。

Base64 / 文本编码

Base64 把二进制编码为纯 ASCII 字符串,常用于在 JSON 或 CSS 中嵌入小文件(data URL);btoa/atob 只认 Latin-1,中文等需先用 TextEncoder/TextDecoder 在字符串与 UTF-8 字节间转换。

ArrayBuffer / Blob / File

ArrayBuffer 是固定长度的原始二进制缓冲区,TypedArray(如 Uint8Array)与 DataView 是操作它的「视图」;Blob/File 是文件级的不可变二进制对象,配合 URL.createObjectURL 或 FileReader 使用。

// ── Base64 编解码 ─────────────────────────────
// btoa / atob:浏览器内置(仅支持 Latin-1,中文需先编码)
btoa("Hello World")   // "SGVsbG8gV29ybGQ="
atob("SGVsbG8gV29ybGQ=") // "Hello World"

// 中文/Unicode → Base64(先用 TextEncoder 转字节再编码)
function toBase64(str) {
  const bytes = new TextEncoder().encode(str);  // UTF-8 字节
  const binary = Array.from(bytes, b => String.fromCharCode(b)).join("");
  return btoa(binary);
}
function fromBase64(b64) {
  const binary = atob(b64);
  const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
  return new TextDecoder().decode(bytes);
}
toBase64("你好")  // "5L2g5aW9"
fromBase64("5L2g5aW9") // "你好"

// Node.js 内置 Buffer.from / toString
Buffer.from("你好", "utf-8").toString("base64"); // "5L2g5aW9"
Buffer.from("5L2g5aW9", "base64").toString("utf-8"); // "你好"

// ── ArrayBuffer & TypedArray ──────────────────
// ArrayBuffer:不能直接读写,必须通过视图
const buf = new ArrayBuffer(8); // 8 字节的二进制缓冲区

// TypedArray 视图(共享同一段内存)
const u8  = new Uint8Array(buf);  // 8 个 uint8(0~255)
const u32 = new Uint32Array(buf); // 2 个 uint32
const f64 = new Float64Array(buf);// 1 个 float64

u8[0] = 255;
console.log(u8);  // Uint8Array [255, 0, 0, 0, 0, 0, 0, 0]

// 常用 TypedArray 类型对照:
// Int8Array     Uint8Array     Uint8ClampedArray(图像像素用)
// Int16Array    Uint16Array
// Int32Array    Uint32Array
// Float32Array  Float64Array
// BigInt64Array BigUint64Array

// DataView:精细控制字节序(大端/小端)
const view = new DataView(buf);
view.setUint32(0, 0xDEADBEEF, true);  // true = 小端(little-endian)
view.getUint32(0, true);              // 3735928559

// ── TextEncoder / TextDecoder ─────────────────
// 字符串 ↔ UTF-8 字节数组互转
const enc = new TextEncoder();
const dec = new TextDecoder("utf-8");

const encoded = enc.encode("Hello 你好");   // Uint8Array
dec.decode(encoded);                        // "Hello 你好"

// ── Blob:二进制大对象 ────────────────────────
// Blob 是不可变的原始数据,常用于文件操作和下载
const blob = new Blob(['{"name":"Alice"}'], { type: "application/json" });
blob.size;  // 16(字节数)
blob.type;  // "application/json"

// Blob → ArrayBuffer
const arrayBuf = await blob.arrayBuffer();

// Blob → 文本
const text = await blob.text();

// 触发浏览器下载文件
function downloadBlob(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url); // 用完立即释放
}

// data URL(Base64 嵌入图片/文件)
const reader = new FileReader();
reader.onload = e => console.log(e.target.result); // "data:image/png;base64,..."
reader.readAsDataURL(imageBlob);
Base64 编码会使数据体积增大约 33%(每 3 字节变 4 字节),不适合大文件内联。大文件应使用 Blob URL(URL.createObjectURL)代替 Base64 data URL。另外,btoa() 对非 Latin-1 字符(如中文)会直接报错,必须先用 TextEncoder 转 UTF-8 字节再处理。
记忆口诀:ArrayBuffer = 原始内存容器(只管存);TypedArray = 有类型的读写视图(指定数据格式);DataView = 低级字节级别的读写(控制字节序);Blob = 不可变的二进制大对象(适合文件操作);TextEncoder/Decoder = 字符串与字节互转。

URL 只允许特定字符,空格、中文、&、= 这些不编码就会破坏结构或丢数据——下面讲两级编码函数,以及现代的 URLSearchParams。

URL 只能包含特定字符集,特殊字符(空格、中文、符号等)必须百分号编码(Percent-encoding)才能安全传输。JS 提供了两级编码函数:encodeURIComponent 编码单个参数值;encodeURI 编码整个 URL(保留 :/?#[]@ 等结构字符)。URLSearchParams 是构建和解析 query string 的现代方式,自动处理编解码。
// ── encodeURIComponent vs encodeURI ───────────
encodeURIComponent("name=Alice&age=25")
// "name%3DAlice%26age%3D25"(连 = & 都编码,用于参数值)

encodeURI("https://example.com/搜索?q=你好")
// "https://example.com/%E6%90%9C%E7%B4%A2?q=%E4%BD%A0%E5%A5%BD"
// (保留 :// ? = 等结构字符,只编码非 ASCII 部分)

decodeURIComponent("%E4%BD%A0%E5%A5%BD") // "你好"

// ── URLSearchParams:现代 query string 工具 ───
// 构建 query string
const params = new URLSearchParams({
  q: "JavaScript 教程",
  page: 1,
  tags: "web",
});
params.toString(); // "q=JavaScript+%E6%95%99%E7%A8%8B&page=1&tags=web"

// 追加 / 修改 / 删除
params.append("tags", "js");      // 同名追加(不覆盖)
params.getAll("tags");             // ["web", "js"](同名多值,删除前)
params.set("page", 2);             // 覆盖
params.delete("tags");             // 删除所有 tags
params.has("q");                   // true
params.get("q");                   // "JavaScript 教程"

// 解析现有 URL 的 query string
const url = new URL("https://example.com/search?q=hello&page=3");
url.searchParams.get("q");    // "hello"
url.searchParams.get("page"); // "3"(注意:始终是字符串)

// 与 fetch 配合
const qs = new URLSearchParams({ keyword: "ts", size: 10 });
await fetch(`/api/search?${qs}`);
URLSearchParams 的 get() 始终返回字符串,取出数字类型的参数后需要手动转换(如 Number(params.get("page")))。另外,encodeURIComponent 不编码 ! ' ( ) *,在极严格的 RFC 3986 场景下需要额外处理这些字符。
实战建议:构建接口 URL 时,始终用 URLSearchParams 而不是手动拼接字符串——它自动处理编码、避免注入,且支持同名多值(如多选标签 tags=a&tags=b)。

传纯数据用 JSON 就够,但一旦要带文件,就得换成 multipart 格式——FormData 是浏览器原生、天然支持文件上传的那把钥匙。

FormData 是浏览器原生的表单数据容器,以 multipart/form-data 格式编码,天然支持文件上传。与 fetch 配合时不需要手动设置 Content-Type(浏览器会自动附带 boundary),是处理混合数据(文字 + 文件)上传的标准方式。
// ── 从 HTML 表单创建 ──────────────────────────
const form = document.querySelector("form");
const fd = new FormData(form);  // 自动读取表单字段

// ── 手动构建 ─────────────────────────────────
const fd2 = new FormData();
fd2.append("username", "Alice");
fd2.append("age", "25");     // 值必须是字符串或 Blob/File
fd2.append("avatar", fileInput.files[0]);  // 附加文件
fd2.append("avatar", anotherFile, "renamed.jpg"); // 可指定文件名

// ── 读取数据 ─────────────────────────────────
fd2.get("username");      // "Alice"
fd2.getAll("avatar");    // [File, File](同名多文件)
fd2.has("age");          // true
fd2.delete("age");

// 遍历
for (const [key, value] of fd2) {
  console.log(key, value); // key: string, value: string | File
}

// ── 与 fetch 配合上传 ─────────────────────────
// ⚠️ 不要设置 Content-Type,让浏览器自动生成 boundary
await fetch("/api/upload", {
  method: "POST",
  body: fd2,  // 直接传 FormData,不需要 JSON.stringify
});

// ── 上传进度监控(需用 XMLHttpRequest)────────
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", e => {
  const pct = Math.round((e.loaded / e.total) * 100);
  console.log(`上传进度: ${pct}%`);
});
xhr.open("POST", "/api/upload");
xhr.send(fd2);

// ── FormData → 普通对象(用于调试)──────────
Object.fromEntries(fd2);
// ⚠️ 同名多值只保留最后一个,仅用于单值字段的调试
用 fetch + FormData 上传时,千万不要手动设置 Content-Type: multipart/form-data——因为 multipart 格式需要一个随机的 boundary 分隔符,浏览器会自动在 Content-Type 里附加它(如 multipart/form-data; boundary=----WebKitFormBoundary...),手动设置后 boundary 缺失会导致服务器解析失败。
fetch 还不支持上传进度(ReadableStream body 进度需要实验性 API)。如需上传进度条,仍需使用 XMLHttpRequest 的 xhr.upload.onprogress,或等待 Fetch API 的 uploadProgress 提案正式落地。

JavaScript · 深水区

平时遇不到、一遇到就要查半天的语义边角:四套相等、排序契约、UTF-16 与真实字符数、数值边界、正则 lastIndex 的状态。这一章是「为什么会这样」的答案页。

大多数语言里相等就是相等。JS 里「相等」是四套规则,分别被不同的 API 使用——平时察觉不到,因为它们只在两个值上产生分歧:NaN 和 -0。而这两个值恰恰是数值计算里最容易冒出来的。

四套语义与它们的分歧点

语义谁在用它NaN 与 NaN0 与 -0
严格相等===、indexOf、lastIndexOf、switch不等相等
SameValueZeroincludes、Set、Map 的键、has相等相等
SameValueObject.is相等不等
宽松相等==(会先做类型转换)不等相等

横着看:没有任何两套是完全一致的。Object.is 是唯一区分 0 和 -0 的,includes 是唯一能在数组里找到 NaN 的。

最常咬人的一处:indexOf 找不到 NaN

includes 和 indexOf 看着是一对(「找到没有」和「找到在哪」),实际用的是两套不同的相等规则:

  • [NaN].includes(NaN) → true(SameValueZero)
  • [NaN].indexOf(NaN) → -1(严格相等,而 NaN !== NaN)

所以「用 indexOf(x) !== -1 判断存在」这个老写法,遇到 NaN 会给出错误答案。要找 NaN 的位置,只能用 findIndex(Number.isNaN)。

Set 与 Map 的键也走 SameValueZero

这带来两个平时想不到但很实用的性质:NaN 可以当 Map 的键并正常取回(尽管 NaN !== NaN);而 0 和 -0 会被当成同一个键,Set 也只会保留其中一个。

// —— 分歧点一:NaN ——
NaN === NaN;                  // false
NaN == NaN;                   // false(宽松相等也救不了)
Object.is(NaN, NaN);           // true
[NaN].includes(NaN);          // true  ← SameValueZero
[NaN].indexOf(NaN);           // -1    ← 严格相等,找不到!
[NaN].findIndex(Number.isNaN); // 0     ← 要位置只能这么找

// —— 分歧点二:-0 ——
0 === -0;                     // true
Object.is(0, -0);             // false ← 唯一能区分的
[-0].includes(0);            // true

// —— 集合按 SameValueZero 去重 / 建键 ——
new Set([NaN, NaN]).size;      // 1(NaN 被认作重复)
new Set([0, -0]).size;        // 1(0 和 -0 同一个值)
new Map([[NaN, 1]]).has(NaN); // true(NaN 可以当键)

// —— switch 用的是严格相等 ——
switch (NaN) { case NaN: /* 永远进不来 */ }
四套语义有一个共同前提:对象比的是「是不是同一个对象」,不是「长得一样不一样」。这让「用 Set 给对象数组去重」这个到处都能看到的写法完全无效——new Set([{a:1}, {a:1}]).size 是 2 不是 1,[{a:1}].includes({a:1}) 也是 false。它不报错,只是一个都没去掉,而对字符串数组测试时又一切正常,所以很容易蒙混过关。

要按内容去重,得自己指定「什么算同一个」:[...new Map(rows.map((r) => [r.id, r])).values()](按 id,推荐);没有天然 id 时可以退而用 JSON.stringify 当键,但要注意键顺序不同的两个对象会被当成不同的。

顺带一提 -0:它确实容易冒出来(-1 * 0、Math.round(-0.2)、0 / -5 都是),但它对所有字符串转换都隐形(String、模板串、JSON.stringify、toFixed 一律不带负号),能看出来的只有 Object.is(x, -0) 和 1 / x === -Infinity。日常基本无害,知道它存在即可。
日常选型只需记两句:判断「数组里有没有这个值」一律用 includes,别用 indexOf(x) !== -1——前者语义更准(能处理 NaN)也更好读;只有确实需要位置时才用 indexOf。Object.is 属于「知道它存在就行」的 API,真正用到的场景很少,主要是需要辨别 -0 的数值库。

sort 是最容易「看起来能用」的方法——小数据上随便写都对,数据一变就出错。它有三条必须知道的契约。

契约一:不传比较函数 = 按字符串比较

默认行为是把每个元素转成字符串,再按 UTF-16 码元逐个比较(不是码点——两者的区别见本章后面)。后果不止于数字:

  • [10, 9, 1].sort() → [1, 10, 9]("10" < "9")
  • ["b", "a", "B"].sort() → ["B", "a", "b"](大写字母码元更小,全排在前面)
  • ["item10", "item2"].sort() → ["item10", "item2"]

只要不是「纯 ASCII 小写字符串」,默认排序基本都不是你要的。

契约二:ES2019 起保证稳定

稳定指比较结果相等的元素保持原有相对顺序。这条是 ES2019 才写进规范的,此前各引擎行为不一(V8 曾对长数组用不稳定的快排)。现在可以放心依赖它做多级排序:先按次要条件排一遍,再按主要条件排一遍,次要顺序会被保留。

契约三:比较函数必须自洽

比较器要满足:a < b 返回负数、相等返回 0、a > b 返回正数,且结果要有传递性。违反了规范不保证任何结果——不是报错,是结果不可预测。最常见的两种写错:

  • 相等时没返回 0:写成 (a, b) => a.age > b.age ? 1 : -1,相等时返回了 -1,稳定性随之失效;
  • 用随机数洗牌:arr.sort(() => Math.random() - 0.5) 是网上流传极广的写法,但它既不是均匀随机、结果还每次都不同(同一个数组连排两次结果不一样)。要洗牌请用 Fisher–Yates。

要按人类习惯排,得用 localeCompare

码点序对中文是按 Unicode 编号,不是拼音;对英文是大写全部在前。localeCompare / Intl.Collator 才给出符合语言习惯的顺序,后者还能开 numeric 处理「item2 < item10」。排大量数据时用 Intl.Collator 而不是 localeCompare——前者只建一次比较器,后者每次调用都要重新解析 locale。

// —— 默认按字符串码点,不是你想的那样 ——
[10, 9, 1].sort();            // [1, 10, 9]
["b", "a", "B"].sort();      // ["B", "a", "b"]  大写在前
["赵", "钱", "孙", "李"].sort(); // ["孙","李","赵","钱"] ← 按 Unicode 编号,没有意义

// —— 要人类习惯的顺序 ——
["赵", "钱", "孙", "李"].toSorted((a, b) => a.localeCompare(b, "zh"));
// ["李","钱","孙","赵"] ← 按拼音 li/qian/sun/zhao

const coll = new Intl.Collator(undefined, { numeric: true });
["item10", "item2", "item1"].toSorted(coll.compare);
// ["item1", "item2", "item10"] ← 数字段按数值比

// —— 稳定性可以依赖:两趟排出多级顺序 ——
const result = rows
  .toSorted((a, b) => a.name.localeCompare(b.name))  // 次要条件先排
  .toSorted((a, b) => a.age - b.age);                // 主要条件后排
// 同龄的人之间,仍保持按姓名排好的顺序

// —— 这个洗牌写法是错的 ——
// arr.sort(() => Math.random() - 0.5);  比较器不自洽,分布不均匀
function shuffle(a) {                          // Fisher–Yates 才对
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}
undefined 会被排到最后,而且不经过比较函数。规范规定:数组里的 undefined 一律移到末尾,你的比较器根本不会看到它们。所以想靠比较函数把「缺失值」排到前面是做不到的——只能先 filter 掉再拼回去。稀疏数组的空洞同理,也排在最后、且排在 undefined 之后。
数字排序永远记得传比较函数,这是 sort 唯一必须背下来的一条。除此之外,优先用 toSorted 而不是 sort(不改原数组,见 19 章 Array 卡);比较对象数组时把取值逻辑抽出来会好读很多:const by = (f) => (a, b) => f(a) - f(b),然后 rows.toSorted(by((r) => r.age))。

这是 JS 字符串最反直觉的一点,也是最典型的一条「代码在测试数据上永远正确,遇到真实用户输入才崩」的契约。JS 字符串的底层是 UTF-16 码元数组:.length 数的是码元,不是人眼看到的字符。

三个层次,三个不同的「长度」

层次怎么数"👍""👨‍👩‍👧"
码元(UTF-16 code unit)s.length、s[i]、slice28
码点(code point)[...s]、for...of、Array.from15
字素簇(人眼的「一个字」)Intl.Segmenter11

基本平面之外的字符(大部分 emoji、汉字扩展区、音乐符号)都要两个码元表示——注意™、☺、❤ 这些老符号在基本平面内,只占一个,这一对叫代理对(surrogate pair)。而带零宽连接符的 emoji(家庭、职业、肤色)由多个码点拼成,只有 Intl.Segmenter 能正确数成 1。

按码元下标操作会把字符撕成两半

s[i]、slice、substring、split("") 全都按码元切。切中代理对中间,得到的是半个字符——一个孤立的、无法显示的码元(通常渲染成 �)。「截断显示前 20 个字」这类需求是重灾区。

还有两处会出乎意料

  • 转大写可能变长:"ß".toUpperCase() 得到 "SS",长度从 1 变 2。所以「转大写不改变长度」这个假设不成立;
  • 看起来一样的字符串可能不相等:é 可以是单个码点,也可以是 e + 组合重音符两个码点。二者渲染完全相同但 === 为 false,length 一个是 1 一个是 2。比较用户输入、做去重或当对象 key 之前,先 normalize("NFC") 归一化。
// —— 同一个 emoji,三种数法三个答案 ——
"👍".length;                  // 2  ← 码元(一个代理对)
[..."👍"].length;               // 1  ← 码点

const family = "👨‍👩‍👧";             // 带零宽连接符的家庭 emoji
family.length;                 // 8
[...family].length;            // 5
[...new Intl.Segmenter("zh", { granularity: "grapheme" })
  .segment(family)].length;     // 1  ← 人眼看到的就是 1 个

// —— 按下标切会撕碎代理对 ——
const s = "a👍b";
s.length;                      // 4(不是 3)
s[1];                          // "\ud83d" ← 半个字符,显示成乱码
s.slice(0, 2);                 // "a\ud83d" ← 同样撕碎了
[...s].slice(0, 2).join("");   // "a👍" ← 先按码点拆再切才对

// —— 转大写会变长 ——
"ß".toUpperCase();             // "SS"(长度 1 → 2)

// —— 看着一样,其实不等 ——
const a = "café";                 // é 是单个码点
const b = "cafe\u0301";           // e + 组合重音符
a === b;                       // false!但屏幕上一模一样
a.length === b.length;         // false(4 vs 5)
a.normalize("NFC") === b.normalize("NFC");  // true
「限制输入 20 个字」几乎必然写错。value.slice(0, 20) 在用户输入 emoji 时会切出半个代理对,轻则显示成 �,重则这个坏字符串被存进数据库——某些数据库会直接报错拒绝,某些则静默存成乱码,而且之后每次读出来都是坏的。正确写法是先按字素簇拆:[...seg.segment(v)].slice(0, 20).map((x) => x.segment).join("")。

顺带一提,maxlength 属性数的也是码元,所以HTML 表单的字数限制对 emoji 同样不准——一个家庭 emoji 会占掉 8 格。
需要在意这些的场合有限。只处理 ASCII(变量名、十六进制、协议字段)时用 .length 和下标完全没问题,不必负担。只要字符串可能来自用户——昵称、评论、搜索词、文件名——就该换成:数长度用 [...s].length,截断用 Intl.Segmenter,比较和去重前先 normalize("NFC")。Intl.Segmenter 在 Node 16+、Chrome 87+、Safari 14.1+ 上早已可用,Firefox 是最后一个补齐的(125,2024 年 4 月)——只在浏览器里跑且要兼容老 Firefox ESR 时留意一下。

「存一堆键值对」和「存一堆值」各有两个选项,它们不是新旧关系——各有各的契约,选错了不会报错,只会在某个规模或某种数据下出问题。

对象 vs Map

普通对象Map
键的类型只能是字符串或 Symbol,其它一律转字符串任意值(对象、函数、NaN 都行)
顺序整数样式的键被提前并升序,其余按插入序严格插入序
取大小Object.keys(o).length(要建数组)m.size
频繁增删较慢——反复 delete 会让引擎把对象转成慢属性模式(哈希表)为此优化过
JSON.stringify正常变成 {},数据全丢
与原型属性冲突会(o["toString"] 命中继承来的属性)不会

一句话选型:形状固定、字段名写死在代码里、要 JSON 化 → 对象;键是运行时才知道的数据(尤其是用户输入或 ID)、要保序、要频繁增删 → Map。

对象键的两个陷阱

  • 键会被转成字符串:o[1] 和 o["1"] 是同一个键;拿对象当键会变成 "[object Object]"——两个不同对象当键会互相覆盖;
  • 整数样式的键会被重排到最前面并升序:{ b: 1, 2: 1, a: 1, 1: 1 } 的 Object.keys 是 ["1", "2", "b", "a"]。用数字 ID 当 key 时,「按插入顺序遍历」的假设会静默失效。要顺序就用 Map。

数组 vs Set:差在查找代价

arr.includes(x) 要从头扫一遍(O(n)),set.has(x) 是哈希查找(均摊 O(1))。小数据看不出差别,数据量一大就是数量级的差距——在二十万元素里反复查找,两者相差两到三个数量级——查不到的那些差得最多(数组必须扫完全部才能确定「没有」),具体倍数取决于机器和数据,自己跑一下就知道。判据很简单:只要「在一个集合里反复查成员」,就用 Set。只遍历一遍、或元素只有几十个,数组更省事。

代价是 Set 不能按下标访问、不能直接 map/filter(要先 [...set]),也不能 JSON 化。常见做法是数组存数据、Set 存索引:const ids = new Set(rows.map((r) => r.id))。

// —— 对象的键会被转成字符串 ——
const o = {};
o[1] = "数字";
o["1"] = "字符串";
Object.keys(o).length;          // 1 ← 是同一个键,后者覆盖了前者

o[{ id: 1 }] = "A";
o[{ id: 2 }] = "B";            // 两个键都变成 "[object Object]"
Object.keys(o);                 // ["1", "[object Object]"] ← B 覆盖了 A

// Map 没有这个问题
const m = new Map();
m.set({ id: 1 }, "A").set({ id: 2 }, "B");
m.size;                        // 2 ← 不同对象是不同的键

// —— 整数样式的键被提前并升序 ——
Object.keys({ b: 1, 2: 1, a: 1, 10: 1, 1: 1 });
// ["1", "2", "10", "b", "a"]  ← 不是插入顺序!

new Map([["b",1], [2,1], ["a",1], [10,1], [1,1]]).keys();
// "b", 2, "a", 10, 1  ← 严格插入序,键也保留了原类型

// —— 反复查成员:数组存数据,Set 存索引 ——
const banned = new Set(bannedList);
const clean = rows.filter((r) => !banned.has(r.id));
// 若写成 bannedList.includes(r.id),就是每行都全表扫一遍

// —— Map / Set 不能直接 JSON 化 ——
JSON.stringify({ m: new Map([[1, 2]]) });  // {"m":{}} ← 数据没了
JSON.stringify({ m: [...new Map([[1, 2]])] }); // {"m":[[1,2]]} ← 先转数组
Map 和 Set 过不了 JSON.stringify,而且是静默的:变成 {}、数据全丢却不报错,直到接口那头发现字段是空的。要传输就先转成数组或普通对象——具体写法、以及 structuredClone 为什么反而能克隆它们,见 19 章。
Map 和 Set 的键用的是 SameValueZero(见本章的相等语义卡),所以 NaN 可以当键并正常取回,而 0 和 -0 会被当成同一个键。这是它们与 indexOf 那套语义不同的地方。

需要「键是对象、且不希望它阻止垃圾回收」时用 WeakMap——典型场景是给第三方对象挂缓存或私有数据(09 章有讲解,19 章有速查)。

浮点的基本坑(0.1 + 0.2 !== 0.3、金额用整数分)在 03 章已经讲过,这里只补三处「知道了才不会错」的边界。

toFixed 的舍入不是四舍五入

它舍的是二进制里实际存的那个数,而不是你写下的十进制字面量。1.005 在 IEEE 754 里存的其实略小于 1.005,于是:

  • (1.005).toFixed(2) → "1.00"(不是 "1.01")
  • (2.675).toFixed(2) → "2.67"(不是 "2.68")

这个偏差不是每次都发生,取决于具体数值——正因为它只在部分数上出错,才特别难发现。涉及金额时不要用 toFixed 做计算,只在最后展示时用;真要正确舍入就先转成整数(Math.round(x * 100) / 100 也只是缓解,不是根治),或者用 decimal 库。

超过安全整数的字面量会被静默改写

JS 的 number 是双精度浮点,只能精确表示 ±253-1 以内的整数(Number.MAX_SAFE_INTEGER = 9007199254740991)。超出后不报错,直接给你一个最接近的值——连源码里写死的字面量都会被改:9007199254740993 求值后就是 9007199254740992。

最常撞上的是后端返回的雪花 ID / 大整数主键:它们经常是 18~19 位,JSON.parse 之后末几位就变了,而且前后端各自都不会报错。解法是让后端把这类 ID 用字符串传,或用 BigInt 接。

比较浮点用 EPSILON,别自己编容差

Number.EPSILON 是 1 与「下一个可表示的数」之间的差,用它作容差比手写 1e-9 更有依据。注意它只适用于量级接近 1 的数——比较大数时容差要按量级放大。

// —— toFixed 舍的是二进制实际值 ——
(1.005).toFixed(2);      // "1.00"  不是 "1.01"
(2.675).toFixed(2);      // "2.67"  不是 "2.68"
(1.5).toFixed(0);        // "2"     这个又是对的
// 因为 1.005 存进 double 后略小于 1.005,而 1.5 能被精确表示

// —— 超过安全整数,字面量当场就变 ——
Number.MAX_SAFE_INTEGER;   // 9007199254740991
9007199254740993;          // 求值后是 9007199254740992 ← 少了 1
9007199254740992 === 9007199254740993;  // true(都是同一个数)

// 后端的雪花 ID 直接 parse 会坏掉:
JSON.parse('{"id": 7213487566925103105}').id;
// 7213487566925103000 ← 末尾几位没了,且不报错

// 用 BigInt 才精确(但不能和 number 混算)
9007199254740993n;         // 精确
// 1n + 1  →  TypeError: Cannot mix BigInt and other types

// —— 浮点比较用 EPSILON ——
0.1 + 0.2 === 0.3;         // false
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON;  // true
损坏发生在 JSON.parse 内部,等你在代码里拿到这个数时末几位已经错了——BigInt(response.id) 救不回来,因为它只是把一个已经失真的 number 转成 BigInt。这个坑在对接 Java / Go 后端时极常见,它们的 long 轻松超过 253。

三条出路,按优先级:① 让后端把这类 ID 用字符串传——最省事也最不容易出错,前端拿到就是精确的;② 用 reviver 的第三个参数 context.source(json-parse-with-source),它给的是解析前的原始字面量文本,从那里构造 BigInt 就是精确的:JSON.parse(s, (k, v, ctx) => k === "id" ? BigInt(ctx.source) : v)——Node 22 与 Chrome 已支持,Firefox 尚未,浏览器端用之前先确认目标环境;③ 实在没辙,在 parse 之前用正则把长数字字面量包成字符串,但这是 hack——字符串值里恰好含长数字会被误伤。
判断「是不是安全整数」有现成的 Number.isSafeInteger(x),比自己和 MAX_SAFE_INTEGER 比更省事。处理外部数据时,若某个字段是 ID,先用它检查一遍,不安全就说明上游该改成字符串传。

Promise.all 一个失败就整体 reject——这句人人都知道。但它只是提前把结果给了你,并没有停下别的任务:其余请求照常发出、照常返回、副作用照常发生,只是没人再接收它们了。这个区别在「重试」和「写操作」场景下会变成真实的 bug。

实际发生了什么

两个任务:A 在 20ms 失败,B 在 120ms 成功。Promise.all 在 20ms 就抛出了,但 B 在 120ms 仍然真正完成了——如果 B 是一笔扣款、一次写库、一条推送,它已经生效了,而你的 catch 分支还以为「这批操作失败了」。

常见的错误反应是在 catch 里整批重试,于是 B 被执行了两次。

四个组合子的语义差别

方法什么时候落定失败时典型用途
all全部成功,或任一失败抛出最先 reject 的那个(不是数组里最靠前的)都要成功才有意义
allSettled全部落定(永不 reject)结果数组里标 rejected要知道每个的成败
race第一个落定(成功或失败都算)第一个是失败就失败超时竞速
any第一个成功全失败才抛 AggregateError多个源取最快可用的

要真的停下来,得传取消信号

唯一能真正中止的办法是让每个任务都接受一个 AbortSignal,失败时主动 abort()。fetch 原生支持;自己写的异步函数要自己检查 signal.aborted。14 章的重试/取消卡有完整写法。

// —— all 抛出后,慢任务仍在继续 ——
const task = (name, ms, fail) =>
  new Promise((res, rej) =>
    setTimeout(() => {
      console.log(name + " 真正完成");      // ← 副作用在这里
      fail ? rej(new Error(name)) : res(name);
    }, ms));

try {
  await Promise.all([task("A", 20, true), task("B", 120)]);
} catch (e) {
  console.log("all 抛出:" + e.message);
}
// 输出顺序:
//   A 真正完成      ← 20ms
//   all 抛出:A     ← 20ms,这里你以为「整批失败了」
//   B 真正完成      ← 120ms,但 B 其实成功执行了!

// —— 要真取消:传 signal,失败时 abort ——
const ctrl = new AbortController();
try {
  await Promise.all([
    fetch("/a", { signal: ctrl.signal }),
    fetch("/b", { signal: ctrl.signal }),
  ]);
} catch (e) {
  ctrl.abort();            // ← 这一句才真正掐断其余请求
  throw e;
}

// —— 想知道每个的成败,用 allSettled ——
const results = await Promise.allSettled(tasks);
const failed = results.filter((r) => r.status === "rejected");
// 每项是 { status: "fulfilled", value } 或 { status: "rejected", reason }
在 catch 里整批重试是最危险的组合。all 早退时,其余任务可能已经成功了;整批重试等于把它们又执行一遍。如果是写操作——下单、扣款、发消息——就是重复执行。

两个方向的解法:让每个操作幂等(带唯一请求 ID,服务端去重),或者改用 allSettled 只重试真正失败的那些。前者更根本,因为网络超时重试同样会带来重复请求。
「都要成功才有意义」用 all,「想知道谁成谁败」用 allSettled。判断依据是失败后你打算做什么:如果一个失败就整批放弃,all 让你早点知道;如果要逐个汇报或部分重试,allSettled 才给得出信息——用 all 的话,除了最先 reject 的那一个,其余失败的信息你永远看不到。

正则字面量看起来像个常量,但带 g(或 y)标志的 RegExp 对象内部藏着一个可变的 lastIndex——它记录下次从哪里开始匹配。这让「同一个正则对象」在不同调用间互相影响,是 JS 里少见的「同样的输入得到不同输出」。

连续 test 同一个字符串,结果会跳

test 和 exec 在带 g 时会推进 lastIndex;匹配失败时又把它重置为 0。于是对同一个字符串反复调用,结果是 true, false, true, false… 交替:

  • 第一次:从 0 开始,匹配到,lastIndex 变成 1,返回 true;
  • 第二次:从 1 开始,后面没有了,返回 false,同时把 lastIndex 重置为 0;
  • 第三次:又从 0 开始,返回 true……

不带 g 就没有这个问题——每次都从头匹配。

最隐蔽的一种:模块顶层的正则常量

const EMAIL = /\S+@\S+/g 写在模块顶层,看着是个纯粹的常量,实际上是整个模块共享的可变状态。任何一处调用 EMAIL.test(x) 都会改变它,另一处的校验就会莫名其妙地失败——而且只在特定调用顺序下复现,是最难查的那类 bug。校验用的正则不要加 g(校验本来也不需要它)。

要提取全部匹配,用 matchAll

matchAll 内部自己管理位置、返回迭代器,且要求必须带 g(不带会抛 TypeError)——它把状态问题封在了内部。相比之下 while ((m = re.exec(s)) !== null) 这个老写法完全依赖 lastIndex,一旦正则被别处用过就会从中间开始。

// —— 同一个对象,同样的输入,结果在跳 ——
const re = /a/g;
re.test("a");    // true   lastIndex: 0 → 1
re.test("a");    // false  从下标 1 开始找不到,lastIndex 重置为 0
re.test("a");    // true   又从 0 开始

// 不带 g 就没有状态
const plain = /a/;
plain.test("a");  // true
plain.test("a");  // true

// —— 模块顶层的「常量」其实是共享可变状态 ——
const EMAIL = /\S+@\S+/g;          // ← 这个 g 是祸根
function validate(v) { return EMAIL.test(v); }
validate("a@b.com");              // true
validate("a@b.com");              // false ← 同样的输入!
// 改法:校验用的正则去掉 g  →  const EMAIL = /\S+@\S+/;

// —— 提取全部匹配:matchAll 自己管状态 ——
const text = "a1 b2 c3";
[...text.matchAll(/([a-z])(\d)/g)].map((m) => m[1] + m[2]);
// ["a1", "b2", "c3"]  ← 每次调用都从头开始,互不干扰

// 老写法依赖 lastIndex,正则被别处用过就会从中间开始
// let m; while ((m = re.exec(text)) !== null) { ... }

// —— 实在要复用带 g 的正则,手动归零 ——
re.lastIndex = 0;
把正则放进循环里复用,是这个坑最常见的现场。const re = /x/g 写在循环外,然后循环里 if (re.test(item))——结果大约一半的项被判为不匹配,而且哪一半取决于前面有多少项匹配过,看起来完全随机。

同样地,正则字面量在函数内每次调用都会新建一个对象(所以放函数里反而是安全的),但放在模块顶层、类字段、或用 new RegExp() 存进变量时就是长期共享的。拿不准就别加 g,或者每次现建。
一条简单规则就能绕开全部麻烦:只有在「要找出所有匹配」时才加 g,而且那时直接用 matchAll 或 replaceAll。校验(test)、找第一个(match 不带 g)、单次替换都不需要 g,加了只有坏处。

String.prototype.match 的行为也随 g 而变:带 g 返回所有匹配的字符串数组但丢掉捕获组,不带 g 返回第一个匹配的完整信息(含捕获组和 index)。需要「所有匹配 + 捕获组」只能用 matchAll。

JavaScript · 内置对象速查

本章速查 ECMAScript 语言自带的标准库——不依赖任何宿主、在浏览器 / Node / Deno 等所有 JS 环境中都存在的内置全局对象。用法是两步:先用第一张任务反查表按「你想干什么」定位到设施与所在卡,再翻后面按对象分组的八张卡查具体方法,每个方法各配一行示例。宿主额外提供的 fetch / DOM / Canvas 等能力见下一章「Web API 速查」。

后面几张卡是按对象排的(String 一张、Array 一张……),前提是你已经知道该找哪个对象。下面管的是前一步:左边是你想干的事,右边是该用的东西和它在本页哪一章哪张卡——拿到位置再往下翻。

JS 没有 C/C++ 那种「头文件 = 主题」的索引,它的难点在别处,这张表正好对着这三点:① 同一件事的方法可能挂在实例上(s.padStart)、静态方法上(Number.isNaN)或全局(parseInt),光靠猜找不到;② 很多操作有「改原值」和「返回新值」两个版本,选错就是 bug——表里凡改原值的都标了 ⚠︎改原值;③ 最右列还带一条能不能跑的信息:19 章是语言本身,任何 JS 环境里都有;20 章是宿主提供的,其中 DOM / 事件 / Storage / Observer / Worker 只有浏览器有,把 localStorage 写进 Node 脚本就是这么炸的——但 fetch、URL、AbortController 这几个已被 Node / Deno / Bun 共同实现,两边都能用。

文本 · 数值 · 时间

想做什么用这个在哪
拼接字符串模板串(反引号插值),别用 + 连一长串03 章
补齐位数padStart(2, "0") / padEnd19·String
去掉首尾空白trim(只去首尾用 trimStart / trimEnd)19·String
判断包含includes / startsWith / endsWith19·String
取倒数第 n 个at(-1)(负数索引,字符串和数组都能用)19·String
替换全部出现replaceAll——replace 只换第一处19·String
拆开 / 合起来split ↔ join(一对反操作)19·String · Array
字符串转数字Number(s) 要求整串合法(但空串和 null 会变 0);parseInt(s, 10) 容忍尾巴("12px"→12),基数别省03 章
判断是不是 NaNNumber.isNaN——别用全局 isNaN19·Number
保留几位小数toFixed(2)(注意返回字符串,不是数字)19·Number
取整Math.floor / ceil / round / trunc 四选一19·Number
随机数Math.random()(非密码学安全)19·Number
当前时间戳Date.now()(毫秒)16 章
格式化日期给人看toLocaleString 系列;复杂需求上 day.js16 章
显示「3 分钟前」Intl.RelativeTimeFormat(别自己算)16 章

数组 · 集合 · 对象

想做什么用这个在哪
存一串东西数组字面量 []——拿不准就选它19·Array
每一项做变换map19·Array
挑出符合条件的filter19·Array
归约成一个值reduce(务必传初始值)19·Array
找一个元素find / findIndex;从后往前用 findLast19·Array
判断存在includes(找值)/ some(找条件)19·Array
排序toSorted((a,b) => a-b);sort ⚠︎改原值19·Array
反转 / 换掉一项toReversed / with;reverse、splice ⚠︎改原值19·Array
去重[...new Set(arr)];对象数组无效,改按 id 建 Map(18 章)19·Map · Set
按 key 分组Object.groupBy(要 Map 就 Map.groupBy)19·Array
拍平嵌套数组flat(Infinity);边映射边拍平用 flatMap19·Array
按 key 存取对象字面量;key 不是字符串、或要保序、要频繁增删 → Map19·Object · Map
遍历对象Object.entries(o) 配 for...of 解构19·Object
对象 ↔ 数组Object.entries ↔ Object.fromEntries19·Object
合并对象{ ...a, ...b } 或 Object.assign(都只浅一层)08 章 · 19·Object
深拷贝structuredClone(o)——别再用 JSON.parse(JSON.stringify())17 章
判断自有属性Object.hasOwn(o, k)(in 会连原型链一起算)19·Object

序列化 · 异步 · 正则 · 平台能力

想做什么用这个在哪
对象转 JSON 文本JSON.stringify(o, null, 2)(第三参 = 缩进)17 章 · 19·JSON
JSON 文本转对象JSON.parse——外部数据必须 try/catch17 章
拼 / 解析查询串URLSearchParams,别手工拼 & 和 =17 章 · 20·URL
Base64 与二进制TextEncoder · Uint8Array · btoa17 章
上传文件 / 表单FormData(别自己设 Content-Type)17 章
等一组异步全完成Promise.all;要「都跑到底不早退」用 allSettled19·Promise · 14 章
取最快的那个Promise.any(首个成功)/ race(首个落定)19·Promise
延时 / sleepsetTimeout——JS 没有内置 sleep,自己包一个 Promise19·JSON 与全局
发网络请求fetch + await res.json(),先查 res.ok20·fetch
取消请求 / 加超时AbortController;只要超时用 AbortSignal.timeout(ms)14 章 · 20·fetch
在本地存点数据localStorage 配 JSON.stringify(只能存字符串)20·Storage
匹配 / 提取文本RegExp + match / matchAll;语法见站内 regex 页19·RegExp
找元素 / 建元素querySelector / createElement20·DOM
监听事件addEventListener20·事件
元素进视口时做事IntersectionObserver(懒加载、曝光埋点)20·Observer
CPU 密集又不想卡界面Web Worker——单线程绕不开它20·Worker
// ——— 六个最高频任务的成品写法(表里查到名字后,照着抄)———

// ① 去重 + 排序,且不动原数组
const uniq = [...new Set(raw)].toSorted((a, b) => a - b);

// ② 按 key 分组(ES2024,此前都得手写 reduce)
const byType = Object.groupBy(items, (it) => it.type);

// ③ 发请求:带超时、带错误处理——三行缺一不可
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error("HTTP " + res.status);  // 404 不会自动抛!
const data = await res.json();

// ④ 并发跑一组异步(任一失败即整体失败;要都跑完用 allSettled)
const [a, b] = await Promise.all([fetchA(), fetchB()]);

// ⑤ sleep:JS 没有内置,用 setTimeout 包一个 Promise
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await sleep(1000);

// ⑥ 读写 localStorage:只能存字符串,进出各转一次
localStorage.setItem("cfg", JSON.stringify(cfg));
const saved = JSON.parse(localStorage.getItem("cfg") ?? "{}");
// 取不到时 getItem 返回 null,?? 兜一个空对象,否则 JSON.parse(null) 得到 null
表里标了 ⚠︎改原值 的那几个,是 JS 最高频的一类 bug 来源。arr.sort() 看着像「返回排好序的数组」,实际上原地排完再把原数组返回给你——于是 const sorted = list.sort() 里 sorted 和 list 是同一个数组,你以为只是取了个副本,实则把传进来的数据洗了一遍。在 React / Vue 里这还会让状态「改了但界面不更新」(引用没变,框架认为无事发生)。ES2023 起每个都有 to 前缀的不可变版本,默认用那个。

另一处反直觉:fetch 遇到 404 / 500 不会抛异常,只有网络层失败(断网、DNS、CORS、被 abort)才 reject。所以 ③ 里那句 if (!res.ok) throw 不是可选的防御,漏了它就会把一份 HTML 错误页当成 JSON 去解析,最终报出一个和真实病因毫无关系的 SyntaxError。
这张表按「动词」组织,后面几张卡按「对象」组织,两种索引各有各的用处。不知道该找谁时从这里进;已经知道是字符串的事、只是忘了方法名,直接翻 String 那张卡更快。

最右列的「19 / 20」比它看起来更重要:19 章是 ECMAScript 语言本身,浏览器、Node、Deno、Bun 里一定都有;20 章是宿主额外提供的,要分两类看——document、localStorage、事件、各种 Observer、Worker 绑死在浏览器上,写进 Node 脚本会直接 ReferenceError(01 章那张报错卡讲的就是这个症状);而 fetch、URL / URLSearchParams、AbortController、structuredClone 这批虽然出身浏览器,如今已被 Node / Deno / Bun 一并实现,两边通用。反过来 fs、process 在浏览器里也不存在。

字符串是 JS 最常用的基本类型之一,不可变——变换类方法(slice/replace/toUpperCase 等)返回新字符串,查询类(indexOf/includes/length 等)返回数字或布尔;下面 s 代表任意字符串实例,每个方法各配一行示例。模板字符串(反引号插值)详见 03 章,正则匹配详见站内 regex 页。

const s = "Hello, World";
// —— 截取与定位 ——
s.length;                          // 12(属性,非方法)
s.slice(7);                    // "World"(支持负索引)
s.substring(0, 5);            // "Hello"(负数按 0 处理)
s.substr(7, 3);                // "Wor"(已废弃,勿用)
s.indexOf("o");               // 4(首次索引,无则 -1)
s.lastIndexOf("o");           // 8(末次索引)
s.includes("World");          // true(是否包含)
s.startsWith("Hello");        // true(是否以…开头)
s.endsWith("World");          // true(是否以…结尾)
s.at(-1);                      // "d"(ES2022,支持负数)
s.charAt(0);                  // "H"
s.charCodeAt(0);              // 72(UTF-16 码元)
s.codePointAt(0);             // 72(码点)
// —— 大小写与空白 ——
s.toUpperCase();                // "HELLO, WORLD"
s.toLowerCase();                // "hello, world"
"  hi  ".trim();               // "hi"(去两端空白)
"  hi  ".trimStart();          // "hi  "(去开头)
"  hi  ".trimEnd();            // "  hi"(去结尾)
// —— 补齐 · 拆分 · 替换 · 重复 ——
"7".padStart(3, "0");         // "007"
"7".padEnd(3, "0");           // "700"
s.split(", ");                // ["Hello", "World"]
s.replace("l", "L");          // "HeLlo, World"(仅首个)
s.replaceAll("l", "L");       // "HeLLo, WorLd"(全部)
"ab".repeat(3);              // "ababab"
// —— 正则匹配(详见站内 regex 页)——
"a1b2".match(/\d/g);            // ["1", "2"]
[..."a1b2".matchAll(/\d/g)];    // 两个匹配结果对象
.length 数的是 UTF-16 码元不是字符:emoji 占 2 个,"a👍b".length 是 4 不是 3。而 s[i]、slice、split("") 都按码元切,切中 emoji 会得到半个字符(孤立代理项,渲染成乱码)。凡处理可能含 emoji 的用户输入,数长度改用 [...s].length、截断改用 Intl.Segmenter(详见 18 章「字符串是 UTF-16 码元序列」)。
slice 支持负索引、substring 不支持——统一用 slice 最省心;substr 已废弃,新代码不要用。

数值运算分散在两处:Number 负责解析与判断(静态方法如 parseInt / isInteger / isNaN)以及格式化(实例方法如 toFixed / toString 进制转换);全局对象 Math 集中取整、幂与根、最值和随机数等纯函数——全是静态方法 / 常量,无需 new。下面每个方法各配一行示例。

// —— Number 静态方法 ——
Number.parseInt("ff", 16);      // 255(radix 指定进制)
Number.parseFloat("3.14em");      // 3.14
Number.isInteger(3.0);           // true
Number.isNaN(NaN);               // true(不做类型转换)
Number.isFinite(42);             // true(是否有限数)
// —— Number 实例方法 ——
(3.14159).toFixed(2);         // "3.14"(返回字符串)
(255).toString(16);           // "ff"(可指定进制)
// —— Math 取整 ——
Math.floor(4.7);                // 4(向下)
Math.ceil(4.1);                 // 5(向上)
Math.round(4.5);                // 5(四舍五入)
Math.trunc(-4.7);              // -4(直接去小数)
// —— Math 运算 ——
Math.abs(-5);                   // 5(绝对值)
Math.max(1, 9, 3);              // 9
Math.min(1, 9, 3);              // 1
Math.pow(2, 10);               // 1024(等价 2 ** 10)
Math.sqrt(144);                 // 12(平方根)
Math.sign(-3);                  // -1(负 -1 / 零 0 / 正 1)
Math.random();                   // [0, 1) 随机小数
Math.hypot(3, 4);               // 5(平方和的平方根)
三个反直觉处:① toFixed 返回的是字符串不是数字,且按二进制实际值舍入——(1.005).toFixed(2) 是 "1.00" 不是 "1.01",金额别拿它算;② Math.round 对 .5 一律朝 +∞ 方向,所以 Math.round(-2.5) 是 -2 而非 -3,负数不对称;③ 全局 isNaN("foo") 会先隐式转数字故返回 true,判 NaN 一律用 Number.isNaN。
全局 isNaN("foo") 会先转数字再判断,故返回 true;Number.isNaN 不转换,结果更可预期。解析用户输入优先 Number.parseInt 并显式传 radix。

数组是最常用的数据结构;方法分可变(直接改原数组,如 sort / splice / reverse / fill)与不可变(返回新数组,如 map / filter / slice / concat)两类,nums 代表任意数组,每个方法各配一行示例。

const nums = [1, 2, 3, 4];
// —— 遍历与变换 ——
nums.map((n) => n * 2);          // [2, 4, 6, 8]
nums.filter((n) => n % 2);      // [1, 3]
nums.forEach((n) => console.log(n)); // 遍历,无返回值
nums.reduce((a, b) => a + b);     // 10(归约为单值)
// —— 查找与判断 ——
nums.find((n) => n > 2);         // 3(首个匹配元素)
nums.findIndex((n) => n > 2);    // 2(首个匹配索引)
nums.some((n) => n > 3);         // true(存在满足)
nums.every((n) => n > 0);        // true(全部满足)
nums.includes(2);              // true(是否包含)
nums.indexOf(2);               // 1(首个索引)
// —— 切片 · 增删 · 取值 ——
nums.slice(1, 3);              // [2, 3](不改原数组)
nums.splice(1, 2);             // [2, 3](删除,改原数组)
nums.at(-1);                    // 4(ES2022,支持负数)
// —— 拼接与扁平 ——
[1, 2].concat([3, 4]);      // [1, 2, 3, 4]
[1, [2, [3]]].flat();        // [1, 2, [3]](默认深度 1)
[1, 2].flatMap((n) => [n, n]); // [1, 1, 2, 2](映射后扁平一层)
[1, 2].join("-");          // "1-2"(拼成字符串)
// —— 排序与填充(改原数组)——
[3, 1, 2].sort((a, b) => a - b); // [1, 2, 3]
[1, 2, 3].reverse();       // [3, 2, 1]
[0, 0, 0].fill(7, 1);      // [0, 7, 7]
// —— sort / reverse / splice 的不可变版:返回新数组,原数组一动不动(ES2023)——
[3, 1, 2].toSorted((a, b) => a - b); // [1, 2, 3]
[1, 2, 3].toReversed();     // [3, 2, 1]
[1, 2, 3].toSpliced(1, 1);  // [1, 3](删除后的新数组)
[1, 2, 3].with(0, 9);       // [9, 2, 3](换掉一个位置;fill 没有对应的不可变版)
// —— 从后往前找(ES2023)——
[1, 2, 3, 4].findLast((n) => n < 4);      // 3(末个匹配元素)
[1, 2, 3, 4].findLastIndex((n) => n < 4); // 2(末个匹配索引)
// —— 静态方法 ——
Array.from("abc");              // ["a", "b", "c"](类数组转数组)
Array.isArray(nums);             // true(是否数组)
// 按 key 分组(ES2024)。挂在 Object 上而不是 Array 上;要 Map 用 Map.groupBy
Object.groupBy([1, 2, 3, 4], (n) => n % 2 ? "odd" : "even");
// → { odd: [1, 3], even: [2, 4] },但它是 null 原型对象:
//   能正常取值和 for...in,却没有 hasOwnProperty,调了会 TypeError
sort() / reverse() / splice() / fill() 会直接改原数组。ES2023 给前三个各配了一个 to 前缀的不可变版(toSorted / toReversed / toSpliced),外加换单个元素的 with——写 React / Vue 状态时优先用它们,比 [...nums].sort() 更省事也更不容易漏。fill() 没有对应的不可变版(没有 toFilled),要不改原值只能自己 [...nums] 拷一份。

sort() 还有个独立的坑:默认按字符串 Unicode 比较,所以 [10, 9, 1].sort() 得到 [1, 10, 9]——数字排序必须传比较函数 (a, b) => a - b,toSorted 同理。排序的另外两条契约(稳定性、比较器必须自洽)以及中文按拼音排序,见 18 章。
reduce 务必传初始值(第二参),否则空数组会抛错;回调里忘记 return 累加器是最常见的 bug。

Object 的静态方法负责枚举、合并、原型与属性描述;structuredClone 是全局函数而非 Object 方法。下面每个方法各配一行示例——这里是平铺速查;is / hasOwn vs in 的语义细节与 entries 变换组合技见 11 章「Object:易错语义与组合技」。

const o = { a: 1, b: 2 };
// —— 枚举 ——
Object.keys(o);                  // ["a", "b"]
Object.values(o);                // [1, 2]
Object.entries(o);               // [["a", 1], ["b", 2]]
// —— 合并与冻结 ——
Object.assign({}, o, { c: 3 });  // { a:1, b:2, c:3 }(浅拷贝合并)
Object.freeze(o);                 // 浅冻结,禁止增删改属性
// —— 原型与属性描述 ——
Object.create(o);                 // 以 o 为原型创建新对象
Object.getPrototypeOf(o);         // Object.prototype(取原型)
Object.defineProperty(o, "c", { value: 3 }); // 定义属性及描述符
// —— 转换与判断 ——
Object.fromEntries([["x", 1]]);  // { x: 1 }(entries 逆操作)
Object.hasOwn(o, "a");           // true(ES2022,取代 hasOwnProperty)
// —— 深拷贝(全局函数,非 Object 方法)——
structuredClone(o);            // { a:1, b:2 } 深拷贝
Object.keys/values/entries 只枚举自有可枚举属性,且整数样式的键会被提前并升序——Object.keys({ 2:1, 1:1, b:1 }) 得到 ["1","2","b"],不是插入序。此外 Object.assign 和对象展开都只做浅合并,嵌套对象仍是共享引用。要保插入序、要非字符串键、要深拷贝,分别用 Map(18 章)与 structuredClone(17 章)。
Object.freeze 是浅冻结,嵌套对象仍可改;需要深拷贝优先用全局 structuredClone(结构化克隆,能处理 Map/Set/循环引用,但遇到函数或 DOM 节点会抛 DataCloneError)。

Map 是有序键值对集合,键可为任意类型(对象也行)、能直接读 size、可被迭代——当字典比普通对象更省心;Set 是值唯一的集合,天生用于去重与成员判断。二者的语义细节见 09 章,「什么时候该用 Map 而不是对象」这类选型与契约见 18 章,此处每个方法各配一行速查。WeakMap / WeakSet 的键必须是对象、不可枚举、且不阻止垃圾回收——适合给对象挂私有数据或缓存而不担心内存泄漏。

// —— Map ——
const m = new Map();
m.set("k", 1).set("j", 2); // set 返回 map,可链式
m.get("k");                    // 1(取值)
m.has("k");                    // true(判断)
m.size;                          // 2(键值对数量,属性)
[...m.keys()];                  // ["k", "j"](键迭代器)
[...m.values()];                // [1, 2](值迭代器)
[...m.entries()];               // [["k", 1], ["j", 2]]
m.delete("j");                 // true(删除成功)
m.clear();                     // 清空
// —— Set ——
const se = new Set([1, 1, 2]);
se.add(3);                    // Set {1, 2, 3}(返回 set,可链式)
se.has(2);                    // true(判断)
se.size;                         // 3(自动去重)
se.delete(1);                 // true(删除)
se.clear();                    // 清空
// —— WeakMap / WeakSet(键须为对象、不阻止 GC)——
const wm = new WeakMap();
wm.set(obj, 1);                // 仅 get/set/has/delete,不可枚举
const ws = new WeakSet();
ws.add(obj);                    // 仅 add/has/delete
Map/Set 过不了 JSON.stringify:直接变成 {},数据静默全丢、还不报错。要序列化先转普通结构——Object.fromEntries(map) 得对象、[...map] 得 [[k,v],...];而 structuredClone 能正确克隆二者,这是它和 JSON 往返的关键差别。另外 WeakMap/WeakSet 不可迭代、没有 size,只有 get/set/has/delete。
取数量用 .size(是属性,既不是 .length 也不是方法);判存在用 .has(),是均摊 O(1),比数组 includes 的 O(n) 快得多——「反复查成员」就该用 Set。遍历 Map 直接 for (const [k, v] of map) 解构,键值一步到位。

Promise 代表一个尚未完成的异步操作,有 pending / fulfilled / rejected 三种状态、一旦敲定不可逆。实例方法 then / catch / finally 串起成功、失败与收尾逻辑;静态方法 all / allSettled / race / any 把多个 Promise 组合并发。完整语义(含 async/await、微任务时机)已在 14 章(异步)展开,此处每个方法各配一行速查。

// —— 实例方法 ——
fetchData()
  .then((data) => use(data))   // 成功回调,返回新 Promise
  .catch((err) => console.error(err)) // 捕获拒绝(等价 then 第二参)
  .finally(() => hideLoading()); // 无论成败都执行
// —— 静态组合 ——
await Promise.all([p1, p2]);        // 全部成功;任一失败即 reject
await Promise.allSettled([p1, p2]); // 等全部敲定,返回状态数组
await Promise.race([p1, p2]);       // 最先敲定(成功或失败)
await Promise.any([p1, p2]);        // 最先成功;全败抛 AggregateError
// —— 创建已敲定的 Promise ——
Promise.resolve(42);             // 已成功的 Promise
Promise.reject(new Error("x")); // 已失败的 Promise
两个高频坑:① new Promise(fn) 里的 executor 是同步立即执行的,别把该延后的逻辑写进去;② 漏写 await——const x = asyncFn() 拿到的是 Promise 不是值,而且这个 Promise 若 reject 又没人 catch,Node 22 默认会因 unhandledRejection 直接崩掉进程。
要并发就一起 await Promise.all([a(), b()]);写成 await a(); await b(); 会退化成串行、慢一倍。要「都跑到底不早退」用 allSettled,要「取最快成功的一个」用 any——选哪个取决于失败后你打算怎么办(详见 12、18 章)。

这些工具直接挂在全局作用域上,无需任何对象前缀即可调用:JSON 在对象与字符串间互转(网络传输、本地存储的基础),parseInt / parseFloat / isNaN 做宽松的数值解析(更严谨的版本见上方 Number 卡),encodeURIComponent 系列处理 URL 编码,setTimeout 系列与 queueMicrotask 安排任务的执行时机。下面每个各配一行示例(JSON 详见 17 章、定时器见 20 章「Web API 速查」、微任务详见 14 章)。

// —— JSON(详见 17 章)——
JSON.stringify({ a: 1 });        // '{"a":1}'
JSON.parse('{"a":1}');            // { a: 1 }
// —— 全局解析与判断(建议优先 Number.* 版本)——
parseInt("42px", 10);           // 42(遇非数字停止)
parseFloat("3.14em");           // 3.14
isNaN("foo");                   // true(会先隐式转换)
isFinite(42);                   // true
// —— URI 组件编解码 ——
encodeURIComponent("a b&c");    // "a%20b%26c"
decodeURIComponent("a%20b");    // "a b"
// —— 定时器(详见 20 章 Web API 速查)——
const id = setTimeout(fn, 1000);  // 延时一次,返回 id
clearTimeout(id);                // 取消 setTimeout
const t = setInterval(fn, 1000);  // 周期执行,返回 id
clearInterval(t);               // 取消 setInterval
// —— 微任务(详见 14 章)——
queueMicrotask(() => console.log("微任务")); // 加入微任务队列
JSON.parse 遇非法输入抛 SyntaxError——JSON.parse("{a:1}")(键没加引号)报 SyntaxError: Expected property name or '}' in JSON at position 1,JSON.parse("") 报 Unexpected end of JSON input;任何外部数据都得包 try/catch。冷知识:JSON.parse(null) 不报错,它先把 null 转成字符串 "null" 再解析,返回 null。
parseInt 永远显式传基数:parseInt("0x1f") 会被当十六进制解析成 31,parseInt(str, 10) 才稳妥;要求「整串必须是合法数字」则用 Number()(但注意空串和 null 都会变 0)。判 NaN 用 Number.isNaN 而非全局 isNaN。

正则表达式用于字符串的模式匹配,可用字面量 /pattern/flags 或构造函数 new RegExp(pattern, flags?) 创建;下面把方法、标志与 String 侧配合各配一行。正则语法(字符类、分组、断言等)详见站内 regex 页。

// —— 创建 ——
const re = /\d+/g;                // 字面量:一个或多个数字
const re2 = new RegExp("\\d+", "g"); // 构造:字符串里反斜杠要转义
// —— 方法 ——
re.test("abc123");              // true(是否匹配)
re.exec("a1 b2");               // ["1", ...] 或 null(配 g 循环取)
// —— 常用标志 ——
/x/g;                            // g 全局
/x/i;                            // i 忽略大小写
/x/m;                            // m 多行
/x/s;                            // s dotAll(. 匹配换行)
/x/u;                            // u Unicode
/x/y;                            // y 粘连
// —— String 侧配合 ——
"a1b2".match(/\d/g);            // ["1", "2"]
[..."a1b2".matchAll(/\d/g)];    // 全部匹配结果对象
"a1b2".replace(/\d/g, "#");    // "a#b#"(首个 / 全部随 g)
"a1b2".replaceAll(/\d/g, "#"); // "a#b#"(正则须带 g)
"a1b2".split(/\d/);             // ["a", "b", ""]
带 g 标志的正则对象有内部 lastIndex 状态,重复用同一个 re 调 test / exec 时结果会「跳着走」;循环里尽量新建正则或改用 matchAll。这条坑的完整机制、以及「模块顶层的正则常量是共享可变状态」这个更隐蔽的变体,见 18 章。
只在「要找出所有匹配」时才加 g,且那时直接用 matchAll/replaceAll;校验(test)、单次替换都别加 g(会引入 lastIndex 状态,见 pitfall)。注意 replaceAll 传正则时必须带 g,否则抛 TypeError: String.prototype.replaceAll called with a non-global RegExp argument;构造函数写法里反斜杠要双写——new RegExp("\\d+")。

JavaScript · Web API 速查

本章速查 宿主环境(浏览器)提供的 Web API——它们不属于 ECMAScript 语言本身,而由浏览器注入到全局:fetch 发网络请求,localStorage 存数据,DOM 与事件系统操作页面,定时器与各类 Observer 响应变化,URL / Clipboard / History / Geolocation 封装平台能力,Canvas / SVG / WebGL 负责绘图。这些 API 在 Node 中多不存在或需 polyfill;语言内置对象见上一章「内置对象速查」。

fetch 是浏览器发起网络请求的现代标准,一个函数配上 async/await 就取代了繁琐的 XMLHttpRequest。

一个容易忘的两段式:fetch 的 Promise 只等到响应头就 resolve,响应体还得再 await res.json() / res.text()(它们各自又返回 Promise)——所以一次请求通常要 await 两次。
// GET 请求
const res = await fetch("https://api.example.com/users");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();

// POST 请求(发送 JSON)
const res2 = await fetch("/api/login", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ user: "alice", pwd: "123" }),
});

// 超时控制(用 AbortController)
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 5000);
const res3 = await fetch(url, { signal: ctrl.signal });
最常见的误区:fetch 在 HTTP 4xx/5xx 时不会抛出错误,只有网络故障才 reject。务必手动检查 res.ok 或 res.status,否则会把错误响应当成成功处理。
fetch 的 credentials 默认为 same-origin:同源请求自动带 Cookie;跨源请求默认不带、需显式 credentials: "include",且服务器 CORS 要允许凭据。

浏览器给每个站点内置了两块键值对存储,区别只在数据能活多久。

两者都是浏览器提供的键值对存储,数据以字符串形式保存。区别在于生命周期:localStorage 永久存储(手动清除才消失);sessionStorage 仅在当前标签页会话内有效,关闭标签页即清空。两者的容量通常为 5MB,不能跨域访问。
// localStorage — 持久存储
localStorage.setItem("token", "abc123");
localStorage.getItem("token");       // "abc123"
localStorage.removeItem("token");
localStorage.clear();                  // 清空全部

// 存储对象必须手动 JSON 序列化
const user = { name: "Alice", age: 25 };
localStorage.setItem("user", JSON.stringify(user));
const loaded = JSON.parse(localStorage.getItem("user"));

// sessionStorage — 会话级存储,同 API
sessionStorage.setItem("step", "2");
localStorage 是同步 API,大量读写会阻塞主线程。敏感信息(如密码、隐私数据)不应存入 localStorage,因为同域下所有脚本都能访问到它。
setItem 会把值先转成字符串——存 123 读回来是 "123",存对象忘了 JSON.stringify 会变成 "[object Object]"。读不存在的键返回 null(不是 undefined);好在 JSON.parse(null) 恰好得到 null 而不报错(Node 22),但 JSON.parse 遇到损坏数据会抛 SyntaxError,读回对象最好包一层 try/catch。

网页上看得见的每个标签,在 JS 眼里都是这棵 DOM 树上可增删改查的节点。

DOM(文档对象模型)是浏览器把 HTML 解析成的树状对象结构,页面上每个标签都是一个可被 JS 读写的节点。日常操作分四类:用 querySelector 系列查询元素,用 createElement + appendChild 创建与插入节点,用 textContent / classList / setAttribute 读写内容与属性,用 remove 删除节点。每次改动都可能触发浏览器重排/重绘,高频或批量操作要留意性能。
// 查询元素
document.querySelector("#app");         // 返回第一个匹配
document.querySelectorAll(".item");    // 返回 NodeList

// 创建 & 插入
const div = document.createElement("div");
div.textContent = "Hello";
div.classList.add("box");
document.body.appendChild(div);

// 更高效的批量插入(DocumentFragment)
const frag = document.createDocumentFragment();
["A", "B", "C"].forEach(text => {
  const li = document.createElement("li");
  li.textContent = text;
  frag.appendChild(li);
});
document.querySelector("ul").appendChild(frag);

// 删除节点
div.remove();
querySelectorAll 返回的是静态快照 NodeList,取出后 DOM 再变它也不更新;而 getElementsByClassName/getElementsByTagName 返回实时 HTMLCollection,边遍历边删节点会漏掉元素(索引在塌缩)。另外 NodeList 只带 forEach,想用 map/filter 得先 Array.from 转成数组。
优先用 textContent 而非 innerHTML 插入文本,可避免 XSS 注入风险。批量插入时用 DocumentFragment 可以减少重排次数,性能更好。

用户的每一次点击、输入、滚动,都是靠给元素挂监听器接住的。

浏览器通过事件模型实现用户交互。事件传播分为三个阶段:捕获(从 document 向目标)→ 目标 → 冒泡(向上回传)。事件委托是将事件监听器绑定在父元素上,利用冒泡机制统一处理子元素的事件,性能更优。
// 基本用法
btn.addEventListener("click", (e) => {
  e.preventDefault();    // 阻止默认行为(如表单提交)
  e.stopPropagation();  // 阻止冒泡
  console.log(e.target); // 实际被点击的元素
});

// 只触发一次
btn.addEventListener("click", handler, { once: true });

// 事件委托:统一处理列表项点击
document.querySelector("ul").addEventListener("click", (e) => {
  const li = e.target.closest("li"); // 向上找最近的 li
  if (!li) return;
  console.log("点击了:", li.dataset.id);
});
removeEventListener 必须传入与添加时同一个函数引用才能解绑——匿名函数、每次新建的箭头函数、.bind(this) 的返回值都是新引用,绑上去就再也移不掉,是单页应用里监听器越堆越多、内存泄漏的常见来源。正确做法是把 handler 存成具名变量,添加和移除都用它。
{ passive: true } 选项可提升滚动性能,适用于 touchstart/wheel 事件(告诉浏览器你不会调用 preventDefault)。

让代码「过一会儿再跑」或「每隔一段跑一次」,靠的就是这三个定时器。

setTimeout 延迟一次性执行;setInterval 周期性重复执行,用 clearInterval 停止。requestAnimationFrame(rAF) 将回调同步到浏览器的下一次重绘,适合做流畅动画,比 setInterval 精度更高且不在后台执行(自动节能)。
// 延迟执行
const id = setTimeout(() => console.log("1s 后"), 1000);
clearTimeout(id); // 取消

// 周期执行(每 500ms 一次)
const tid = setInterval(() => tick(), 500);
clearInterval(tid);

// 动画循环(推荐方式)
let x = 0;
function animate() {
  x += 2;
  el.style.transform = `translateX(${x}px)`;
  if (x < 300) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
setInterval 不保证精准间隔——如果回调执行时间超过间隔,会导致积压。复杂定时任务推荐用 setTimeout 递归实现(每次执行完再安排下一次)。
setTimeout(fn, delay, a, b) 从第三个参数起会原样传给回调,省去包一层闭包(Node 22 a b 如实传入)。注意 setTimeout(fn, 0) 也不是「立即」——它排的是宏任务,执行顺序是同步代码 → 微任务(queueMicrotask/Promise.then)→ 才轮到它,要在本轮尽早跑用微任务而非 0ms 定时器。

与其手写字符串拼 URL 和查询参数,不如交给专门的对象去解析和编码。

URL 对象可结构化解析和构建 URL,避免手动字符串拼接出错。URLSearchParams 专门处理 ?key=value 形式的查询字符串,自动处理编码和多值问题。(URLSearchParams 与 encodeURIComponent 的编码细节详见 17 章「URL 编码」卡,这里侧重 URL 对象本身。)
const url = new URL("https://example.com/search?q=js&page=2");
url.hostname;     // "example.com"
url.pathname;     // "/search"
url.searchParams.get("q");    // "js"
url.searchParams.get("page"); // "2"

// 修改参数
url.searchParams.set("page", "3");
url.searchParams.append("sort", "asc");
url.toString(); // "https://example.com/search?q=js&page=3&sort=asc"

// 独立使用 URLSearchParams
const params = new URLSearchParams(location.search);
params.has("token"); // true / false
[...params.entries()]; // [["q","js"],["page","2"]]
URLSearchParams 用 + 表示空格:?q=a+b 的 get("q") 解出 "a b"(Node 22),而 toString() 又把空格编码回 +、把 & 编码成 %26——它和 encodeURIComponent(空格→%20)不是一套规则,别混用。还有 new URL("/path") 不传第二个 base 参数会直接抛 TypeError。
相对 URL 必须传第二个参数(base):new URL("/path", "https://example.com")。处理来自后端的回调 URL 时,用 URL 对象解析比正则安全得多。

「一键复制」按钮背后,是浏览器开放的异步剪贴板读写能力。

navigator.clipboard 提供异步读写系统剪贴板的能力,是现代「一键复制」功能的标准实现。读取剪贴板需要用户授予权限;写入(复制)在安全上下文(HTTPS 或 localhost)下通常无需额外授权。
// 复制文本到剪贴板
async function copyText(text) {
  try {
    await navigator.clipboard.writeText(text);
    console.log("✅ 复制成功");
  } catch (err) {
    console.error("复制失败:", err);
  }
}

// 从剪贴板读取文本
async function pasteText() {
  const text = await navigator.clipboard.readText();
  console.log("剪贴板内容:", text);
}

// 兼容旧浏览器的降级方案
function legacyCopy(text) {
  const ta = document.createElement("textarea");
  ta.value = text;
  document.body.appendChild(ta);
  ta.select();
  document.execCommand("copy");
  ta.remove();
}
navigator.clipboard 只在安全上下文(HTTPS/localhost)里存在,普通 http 页面上它是 undefined,直接访问就 TypeError。writeText/readText 还必须由用户手势(点击等)触发,脚本里主动调用会被拒;readText(读剪贴板)权限更严,Firefox 长期只对扩展开放,做「粘贴」功能要有降级预案。
Clipboard API 只能在 HTTPS 或 localhost 下使用。在不支持的环境中用 execCommand("copy") 降级,但该 API 已被标准废弃,尽快迁移到 Clipboard API。

想知道某个元素有没有进入视口,别再用 scroll 事件死算——交给这个异步观察器。

IntersectionObserver 异步监听目标元素与视口(或指定容器)的交叉状态,不会阻塞主线程,是实现图片懒加载、无限滚动、埋点曝光的最佳方式,完全替代了昔日的 scroll 事件 + getBoundingClientRect() 方案。
// 图片懒加载
const io = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;   // 真正加载图片
      observer.unobserve(img);   // 加载后停止观察
    }
  });
}, { threshold: 0.1 });         // 10% 进入视口即触发

document.querySelectorAll("img[data-src]").forEach(img => io.observe(img));

// 无限滚动:监听列表末尾哨兵元素
const sentinel = document.querySelector("#sentinel");
const scrollIO = new IntersectionObserver(([entry]) => {
  if (entry.isIntersecting) loadMore();
});
scrollIO.observe(sentinel);
observe 之后回调会立即触发一次汇报初始状态,此时 isIntersecting 往往是 false——务必先判断它再执行逻辑,别一进回调就当元素已经可见。另外 threshold: 1.0(要求 100% 可见)对比视口更高的元素永远不会满足,因为它露不全,这类需求要把阈值调低。
rootMargin: "200px" 可以提前 200px 触发加载(预加载),提升用户体验。threshold: [0, 0.5, 1] 可以在元素进入视口 0%/50%/100% 时分别触发回调。

元素尺寸变了、DOM 被人改了,与其轮询检查,不如让浏览器主动通知你。

浏览器提供一组 Observer 来被动监听变化而非轮询,回调异步批量触发、几乎不影响性能。ResizeObserver 监听元素尺寸变化(响应式布局、Canvas 按容器自适应),取代了监听 window resize 再手动测量的老做法;MutationObserver 监听 DOM 树的增删改(子节点、属性、文本变化),常用于感知第三方脚本注入或框架外的 DOM 改动。两者都用 observe(target, options) 开始、disconnect() 停止。
// ResizeObserver:监听容器尺寸变化
const ro = new ResizeObserver(entries => {
  for (const entry of entries) {
    const { width, height } = entry.contentRect;
    console.log(`尺寸变为 ${width}×${height}`);
  }
});
ro.observe(document.querySelector("#chart"));
// ro.disconnect(); // 停止监听

// MutationObserver:监听 DOM 变化
const mo = new MutationObserver(mutations => {
  mutations.forEach(m => {
    console.log(m.type, m.addedNodes, m.removedNodes);
  });
});
mo.observe(document.body, {
  childList: true,   // 监听子节点增删
  subtree:   true,   // 递归监听所有后代
  attributes:true,   // 监听属性变化
});
在 ResizeObserver 回调里改动被观察元素的尺寸会引发循环,控制台报 ResizeObserver loop completed with undelivered notifications。MutationObserver 的回调拿到的是批量队列而非单条变更,且默认不含属性旧值,要 attributeOldValue: true 才有;两者都不会为观察开始前的既有状态补发通知,只报之后的变化。
MutationObserver 常用于:检测第三方 SDK 注入 DOM、实现富文本编辑器历史记录、监听路由变化(SPA 框架的早期实现方式)。

JS 主线程只有一根,想不卡界面地跑重计算,就得把活儿丢到后台线程。

JS 主线程是单线程的,复杂计算会阻塞 UI 渲染。Web Worker 允许在后台线程运行脚本,通过 postMessage 与主线程通信(数据传递是结构化克隆,非共享内存)。Worker 内无法访问 DOM,适合执行 CPU 密集型任务。
// main.js — 主线程
const worker = new Worker("worker.js");
worker.postMessage({ data: bigArray });       // 发送数据给 worker
worker.onmessage = (e) => {
  console.log("结果:", e.data.result);         // 接收计算结果
};
worker.onerror = (err) => console.error(err);

// worker.js — 后台线程(独立文件)
self.onmessage = (e) => {
  const result = heavyCompute(e.data.data);  // 不阻塞主线程
  self.postMessage({ result });
};

// 内联 Worker(不需要单独文件)
const blob = new Blob([`self.onmessage=e=>self.postMessage(e.data*2)`]);
const w = new Worker(URL.createObjectURL(blob));
postMessage 走结构化克隆而非共享引用:传函数直接抛 DataCloneError(Node 22 structuredClone 报 () => 1 could not be cloned);传类实例则丢掉原型链——方法全没、instanceof 变 false,只剩下数据字段。DOM 节点、Symbol 同样不能传,跨线程只发纯数据。
传递大型 ArrayBuffer 时可以使用 Transferable Objects(postMessage(buf, [buf])),数据所有权转移而非复制,性能更高。

SPA 要在不刷新页面的前提下改地址栏、留下前进后退记录,靠的就是 History API。

History API 让 SPA 在不刷新页面的情况下更改浏览器 URL 和历史记录。pushState 添加一条记录,replaceState 替换当前记录,popstate 事件在用户点击前进/后退时触发。这是 React Router、Vue Router 等路由库的底层实现。
// 导航到新路由(不刷新页面)
history.pushState({ page: "about" }, "", "/about");

// 替换当前历史记录(不产生新条目)
history.replaceState({ page: "home" }, "", "/home");

// 监听浏览器前进/后退
window.addEventListener("popstate", (e) => {
  console.log("当前状态:", e.state);
  renderPage(location.pathname); // 根据 URL 重新渲染
});

// 手动前进/后退/跳跃
history.back();
history.forward();
history.go(-2); // 后退两步
Hash 路由(#/about)是另一种方案,不需要服务端配合,但 URL 不美观。History API 路由要求服务端对所有路径都返回同一个 HTML 文件(Nginx 配置 try_files)。
pushState/replaceState 本身不会触发 popstate——只有用户点前进/后退(或调 history.back())才触发;所以改完地址一定要自己手动调一次渲染函数,别指望监听器代劳。传给它的 state 对象走结构化克隆存储,塞函数会 DataCloneError,且有大小上限(Firefox 约 640KB),只放能序列化的轻量数据。

拿到用户的经纬度需要一套标准接口,也需要用户点头授权。

navigator.geolocation 可获取设备当前的经纬度,需要用户主动授权。getCurrentPosition 获取一次;watchPosition 持续监听位置变化(适合导航类应用)。仅在安全上下文(HTTPS)下可用。
// 获取当前位置(一次性)
navigator.geolocation.getCurrentPosition(
  (pos) => {
    const { latitude, longitude, accuracy } = pos.coords;
    console.log(`纬度: ${latitude}, 经度: ${longitude}`);
    console.log(`精度: ${accuracy} 米`);
  },
  (err) => console.error(err.message),
  { enableHighAccuracy: true, timeout: 5000 }
);

// 持续监听位置变化(适合导航)
const watchId = navigator.geolocation.watchPosition(
  (pos) => updateMap(pos.coords),
  (err) => console.error(err)
);

// 停止监听
navigator.geolocation.clearWatch(watchId);
getCurrentPosition 的默认 timeout 是 Infinity——不显式设置,取不到信号时会一直挂着,成功和错误回调都不来,界面上像卡死。用户拒绝授权走错误回调(err.code === 1,即 PERMISSION_DENIED);默认还可能返回缓存的旧坐标,要实时位置得设 maximumAge: 0。且整套 API 仅在 HTTPS 下可用。
enableHighAccuracy: true 会使用 GPS(精度高但耗电多);默认使用 WiFi/基站定位(精度低但快速省电)。用户拒绝授权后,需引导其在浏览器设置中手动开启。

canvas 是一块交给 JS 用命令逐笔去画的位图画布,画完即忘、没有 DOM 节点。

<canvas> 是浏览器提供的位图绘图画布,通过 getContext("2d") 获取 2D 渲染上下文,然后用 JS 命令式地绘制路径、文字、图形、图像。常用于图表、签名板、图片处理、游戏等场景。Canvas 是即时模式(画完就忘),没有 DOM 节点,不能用 CSS 控制内部元素。
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

// 矩形
ctx.fillStyle = "#4f8ef7";
ctx.fillRect(10, 10, 100, 60);       // x, y, width, height
ctx.clearRect(20, 20, 30, 30);       // 擦除区域
ctx.strokeStyle = "#ff7b9c";
ctx.lineWidth = 2;
ctx.strokeRect(150, 10, 80, 60);     // 描边矩形

// 路径(Path)
ctx.beginPath();
ctx.moveTo(50, 100);
ctx.lineTo(150, 100);
ctx.lineTo(100, 180);
ctx.closePath();
ctx.fillStyle = "#7ee787";
ctx.fill();
ctx.stroke();

// 圆弧
ctx.beginPath();
ctx.arc(250, 80, 40, 0, Math.PI * 2); // cx, cy, r, 起角, 终角
ctx.fillStyle = "#c9a8ff";
ctx.fill();

// 文字
ctx.font = "bold 18px sans-serif";
ctx.fillStyle = "#fff";
ctx.fillText("Hello Canvas", 10, 220);

// 绘制图片
const img = new Image();
img.src = "photo.jpg";
img.onload = () => ctx.drawImage(img, 0, 0, 200, 150);

// 导出为图片
const dataURL = canvas.toDataURL("image/png");
Canvas 的坐标原点在左上角,Y 轴向下为正方向(和数学坐标系相反)。高 DPI 屏(devicePixelRatio > 1)上图形会模糊,需要将 canvas 的 width/height 乘以 devicePixelRatio,再用 CSS 缩回原尺寸,并调用 ctx.scale(dpr, dpr)。
复杂绘制前调用 ctx.save() 保存状态(变换矩阵、样式等),完成后 ctx.restore() 恢复,避免样式污染。动画循环用 requestAnimationFrame 配合 clearRect 每帧清空重绘。

在基础绘图之上,2D 上下文还能叠加坐标变换、渐变、阴影这些让画面「立体」起来的能力。

在基础绘图之上,Canvas 2D 上下文还提供变换矩阵系统——translate / rotate / scale 会叠加改变后续绘制的坐标系(务必配合 save/restore 隔离);以及渐变、阴影、全局透明度,和直接读写像素的 getImageData / putImageData,可实现丰富的视觉效果与滤镜。
// 变换(基于当前坐标系叠加)
ctx.save();
ctx.translate(200, 200);          // 移动坐标原点
ctx.rotate(Math.PI / 4);          // 旋转 45°
ctx.scale(1.5, 1.5);               // 缩放
ctx.fillRect(-30, -30, 60, 60);    // 围绕新原点绘制
ctx.restore();                      // 恢复之前的坐标系

// 线性渐变
const grad = ctx.createLinearGradient(0, 0, 200, 0);
grad.addColorStop(0,   "#4f8ef7");
grad.addColorStop(0.5, "#c9a8ff");
grad.addColorStop(1,   "#ff7b9c");
ctx.fillStyle = grad;
ctx.fillRect(0, 50, 200, 60);

// 径向渐变
const radial = ctx.createRadialGradient(100, 100, 10, 100, 100, 60);
radial.addColorStop(0, "white");
radial.addColorStop(1, "rgba(0,0,0,0)");

// 阴影
ctx.shadowColor = "rgba(0,0,0,0.5)";
ctx.shadowBlur = 10;
ctx.shadowOffsetX = 4;
ctx.shadowOffsetY = 4;

// 全局透明度
ctx.globalAlpha = 0.7;

// 像素操作(滤镜/截图)
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// imgData.data 是 [R,G,B,A, R,G,B,A, ...] 的 Uint8ClampedArray
for (let i = 0; i < imgData.data.length; i += 4) {
  const gray = (imgData.data[i] + imgData.data[i+1] + imgData.data[i+2]) / 3;
  imgData.data[i] = imgData.data[i+1] = imgData.data[i+2] = gray; // 灰度化
}
ctx.putImageData(imgData, 0, 0);
translate/rotate/scale 是叠加到当前矩阵上的,不配 save/restore 就会累积、污染后续所有绘制;shadowColor、globalAlpha、fillStyle 这类同样是持续生效的全局状态,设了不复位会一直带着。另外画过跨源且未开 CORS 的图片后,canvas 被「污染」,getImageData/toDataURL 会抛 SecurityError。
ctx.setTransform(a, b, c, d, e, f) 可以直接设置变换矩阵(绕过叠加),ctx.resetTransform() 重置为单位矩阵。像素操作(getImageData/putImageData)性能较重,大图建议在 OffscreenCanvas + Worker 中处理。

和位图 canvas 不同,SVG 的每个图形都是真实 DOM 节点,能任意缩放不失真、能用 CSS 和 JS 直接操控。

SVG(可缩放矢量图形)是 XML 格式的矢量图,内嵌在 HTML 中后每个图形元素都是真实的 DOM 节点,可以用 CSS 设样式、用 JS 操控、用 CSS/SMIL/JS 做动画,且任意缩放不失真。适合图标、数据可视化、复杂插图;Canvas 适合像素操作和高频重绘的游戏。
<!-- HTML 中内联 SVG -->
<svg width="200" height="200" viewBox="0 0 200 200">
  <circle id="dot" cx="100" cy="100" r="40" fill="#4f8ef7" />
  <rect x="20" y="20" width="60" height="40" rx="8" fill="#7ee787" />
  <line x1="0" y1="0" x2="200" y2="200" stroke="#ff7b9c" stroke-width="2"/>
  <path d="M10 80 Q95 10 180 80 T350 80" fill="none" stroke="#c9a8ff"/>
  <text x="100" y="170" text-anchor="middle" fill="white">SVG</text>
</svg>

// JS 动态创建 SVG 元素(必须用 createElementNS)
const NS = "http://www.w3.org/2000/svg";
const svg = document.querySelector("svg");
const circle = document.createElementNS(NS, "circle");
circle.setAttribute("cx", "50");
circle.setAttribute("cy", "50");
circle.setAttribute("r",  "30");
circle.setAttribute("fill", "#ffb454");
svg.appendChild(circle);

// CSS 动画(在 <style> 中)
// #dot { animation: pulse 1s infinite alternate; }
// @keyframes pulse { to { r: 60; opacity: 0.5; } }

// JS 控制 SVG 元素(像操作普通 DOM 一样)
document.querySelector("#dot").addEventListener("click", (e) => {
  e.target.setAttribute("fill", "#ff7b9c");
});
用 JS 创建 SVG 元素必须用 createElementNS 而非 createElement,否则元素无法正确渲染。SVG 几何属性(r/cx/cy)的 CSS 过渡/动画在 Chrome、Firefox 已支持、Safari 支持较晚,跨浏览器可用 SMIL 或 JS 插值兜底。
Canvas vs SVG 选型:元素数量少(<1000)、需要交互/缩放 → SVG;元素极多(粒子、游戏帧)、需要像素操作 → Canvas。D3.js 操作 SVG;WebGL/Three.js 操作 GPU。

当图形多到 CPU 扛不住时,就该把绘制交给 GPU,甚至挪出主线程。

getContext("webgl2") 让你直接调用 GPU 进行 3D 渲染或大规模 2D 计算。原生 WebGL 需要编写 GLSL 着色器,较为底层,实际工程中通常使用 Three.js(3D)或 PixiJS(2D GPU 渲染)封装。OffscreenCanvas 允许在 Web Worker 中执行 Canvas/WebGL 绘制,彻底不占用主线程。
// WebGL 基础:获取上下文并清屏
const canvas = document.querySelector("canvas");
const gl = canvas.getContext("webgl2");

gl.clearColor(0.1, 0.1, 0.15, 1.0); // RGBA (0-1)
gl.clear(gl.COLOR_BUFFER_BIT);

// 实际项目使用 Three.js(WebGL 的高级封装)
import * as THREE from "three";

const renderer = new THREE.WebGLRenderer({ canvas });
const scene    = new THREE.Scene();
const camera   = new THREE.PerspectiveCamera(75, 16/9, 0.1, 1000);

const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshStandardMaterial({ color: "#4f8ef7" });
const cube     = new THREE.Mesh(geometry, material);
scene.add(cube);
camera.position.z = 5;

function animate() {
  requestAnimationFrame(animate);
  cube.rotation.x += 0.01;
  cube.rotation.y += 0.01;
  renderer.render(scene, camera);
}
animate();

// OffscreenCanvas:在 Worker 中绘图,不阻塞主线程
// main.js
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
一张 canvas 的上下文类型唯一且不可变——已经 getContext("2d") 再求 getContext("webgl2") 会返回 null。WebGL 上下文还可能丢失(切后台、GPU 重置、显存不足),要监听 webglcontextlost 事件并重建纹理/缓冲等资源,否则画面变黑。transferControlToOffscreen() 会把控制权永久交给 Worker,此后主线程再直接对原 canvas 绘制无效。
选型建议:2D 图表 → Canvas 2D 或 SVG;2D 游戏/特效 → PixiJS(基于 WebGL);3D 场景 → Three.js;GPU 通用计算 → WebGPU(新标准,Chrome 113+ 支持)。

TypeScript · 类型基础

TS 全部基础类型一张表:原始类型、any / unknown / never / void 的分工、字面量、数组与元组、联合与交叉、enum 的现状。

TS 的类型分三层:JS 本来就有的(string、number…)、TS 造出来描述形状的(字面量、元组、联合)、顶与底(unknown、never)。写注解前先确认这一层。

类型全表

类型含义什么时候用
string number boolean原始类型永远写小写;String 是包装对象,别用
bigint symbol另两个原始类型大整数、唯一键
null / undefined空值strictNullChecks 开启后它们不再能赋给别的类型
any关掉检查,可赋给任何类型迁移期的临时逃生口,其余场合禁用
unknown顶类型:什么都能进,用之前必须先收窄any 的安全替代,外部数据入口
never底类型:不存在这样的值抛错 / 死循环函数的返回;穷尽检查
void「不关心返回值」回调签名;与 undefined 不完全等价
object任何非原始值很少用,多数时候该写具体形状
"a" | "b"字面量联合取代字符串常量与 enum
T[] / Array<T>数组,两种写法等价同一项目里保持一致
readonly T[]只读数组参数不打算改时用它宣告
[string, number]元组:长度与每位类型都固定坐标、返回一对值
[a: string, b?: number]具名元组、可选元素提升可读性
[string, …number[]]可变长元组首项固定、其余任意
A | B联合:是其中之一用前要收窄(25 章)
A & B交叉:同时满足合并对象形状
enum E {}枚举会产出运行时代码;多数场景用字面量联合或 as const 更好
const enum E {}内联枚举与 isolatedModules 冲突,慎用

四个容易混的对子

对比差别
any vs unknown前者可以直接 .foo(编译器不管),后者必须先判类型——默认选 unknown
never vs void函数返回 void表示跑完了但没值;返回 never表示它根本不会正常返回
string vs String后者是包装对象类型,几乎总是写错了
type X = 1 vs const x = 1类型世界与值世界分开;typeof x 是从值取类型的桥
// as const:把字面量锁死,得到最窄的类型
const ROLES = ["admin", "user"] as const;
type Role = (typeof ROLES)[number];        // "admin" | "user"

// unknown 强制你先判断再用,any 不会
function handle(x: unknown) {
  // x.length            ❌ 编译期就拦住
  if (typeof x === "string") return x.length;   // ✅ 收窄后可用
}

// never 做穷尽检查:将来多一个分支,这里立刻编译报错
function area(s: Shape) {
  switch (s.kind) {
    case "circle": return Math.PI * s.r ** 2;
    case "rect":   return s.w * s.h;
    default: {
      const _exhaustive: never = s;
      throw new Error("未知形状");
    }
  }
}
enum 是 TS 里少数会产出运行时代码的语法,且数字枚举有双向映射。enum E { A } 编译后是一个对象,E[0] 能反查出 "A",这意味着任何数字都能悄悄赋给它。多数场景改用 as const 对象或字面量联合:零运行时开销、能被 tree-shaking、类型更严。确实需要枚举语义时,用字符串枚举。
能让 TS 推断的就别手写注解。const n = 1 已经是 1 了,写成 const n: number = 1 反而把类型放宽。该写注解的地方只有三处:函数参数、对外暴露的返回类型、初值为空的容器(const list: User[] = [])。

这半页假设你已经会 JavaScript。如果 const/箭头函数/解构/async-await/模块这些还不熟,先回到 02–20 章——TypeScript 不是另一门语言,它只是给 JS 加了一层类型标注,JS 的坑它一个都不会替你挡。

① TS 只在你写代码时存在

  • TS 代码不能被浏览器或 Node 原样理解,中间必须有一步把类型抹掉、变回普通 JS——这一步叫类型擦除(有时是你手动跑 tsc,有时是运行时替你悄悄做掉,见下条);
  • 所以从这一章起,你的工作流多了一个环节:写 .ts → 检查/编译 → 跑 .js;
  • 擦除意味着类型在运行时完全不存在:不能 if (x instanceof MyInterface),也不能靠类型挡住后端返回的脏数据(29 章会讲边界校验)。

② 怎么把一个 .ts 跑起来(四条路,由轻到重)

  • 只想试语法:用官方 TypeScript Playground(typescriptlang.org/play),左边写右边就出编译结果和报错,零安装——本章到 26 章的例子都适合在这儿玩;
  • Node 直接跑:node app.ts(Node 22.18+ / 23.6+ 起原生支持)。但它只是把类型删掉就跑,完全不做类型检查——见下方陷阱;
  • 要真正检查类型:npx tsc --noEmit app.ts。这是 TS 的本职工作,--noEmit 表示只查不产出文件;
  • Deno / Bun:原生跑 TS,省掉配置,但 npm 生态的兼容性要自己确认(30 章对照)。

③ tsconfig.json 什么时候需要

单个文件练手不需要。一旦是个项目(多文件、要给编辑器统一规则、要配路径别名),就需要它——它是「这个项目的 TS 规则」的唯一出处。完整字段见 29 章,现在只需知道 npx tsc --init 能生成一份带注释的默认配置。

# ① 装一个本地 tsc(推荐装到项目里,而不是全局)
npm i -D typescript

# ② 只做类型检查,不产出文件 —— TS 的本职工作
npx tsc --noEmit app.ts

# 真实报错长这样:文件(行,列): error TS编号: 说明
# app.ts(1,5): error TS2322: Type 'string' is not assignable to type 'number'.
# app.ts(3,1): error TS2554: Expected 1 arguments, but got 0.
# app.ts(4,39): error TS18047: 's' is possibly 'null'.
# ↑ TS 后面的编号是稳定的,搜索时带上它比搜中文描述准得多

# ③ 编译成 JS 再跑
npx tsc app.ts && node app.js

# ④ 或者让 Node 直接跑(22.18+,只删类型、不检查)
node app.ts
node app.ts 能跑通,不代表类型没问题。Node 的原生支持只是把类型注解删掉,一次检查都不做:const n: number = "明明是字符串" 照样正常打印。同理,enum、参数属性等「会生成运行时代码」的 TS 语法它直接拒绝执行(报 ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX)。想要类型安全,tsc --noEmit 这一步不能省。
把类型检查和运行分开看待。tsc --noEmit 负责「有没有类型错误」,node 负责「跑起来对不对」——两者互不替代。真实项目里前者通常挂在编辑器和 CI 上持续跑,后者才是你手动敲的那条命令。

TS 的类型不止 number、string 这些大类,它还能把「某一个具体值」本身当成类型——这正是后面联合、判别联合、收窄的地基。

用 : 类型 给变量加注解,但多数时候靠类型推断即可(const x = 42 自动推断为 number,再写 : number 是冗余)。字面量类型让「值本身」成为类型("left" | "right");as const 把对象/数组推断为最窄的只读字面量类型。
// 原始类型注解(多数可省略,靠推断)
let name: string = "Alice";
let big:  bigint = 9007199254740991n;
let sym:  symbol = Symbol("id");

// 字面量类型:值就是类型
let dir: "left" | "right" | "up" | "down";
dir = "left";       // ✅
// dir = "diagonal"; ← ❌ 报错

// as const:推断为最窄的只读字面量类型
const config = { method: "GET" } as const;
// config.method 的类型是 "GET",而非 string;整体 readonly
字面量类型会被「加宽」,赋值方式决定成败。let m = "GET" 推断成 string(可变,故加宽),传给要求 "GET" | "POST" 的形参当场报 error TS2345: Argument of type 'string' is not assignable;对象属性同理,{ method: "GET" }.method 也是 string 而非 "GET"。要留住字面量类型,用 const 声明或给对象加 as const(tsc 7)。
能推断就别手写注解。需要注解的主要是:函数参数、空容器(const arr: number[] = [])、以及想收窄变量可取值范围时(字面量联合)。让 TS 替你推断,代码更简洁也更不易过时。

数组、元组、以及 any/unknown/never,是你每天都要标注的类型;真正需要斟酌的是这三个特殊类型之间的取舍。

数组写 T[] 或 Array<T>。元组是定长定类型的数组([string, number])。三个特殊类型:any 关闭检查(尽量避免)、unknown 是「安全的 any」(用前必须收窄)、never 表示不可能的值(永不返回的函数、穷举检查)。
const nums: number[]       = [1, 2, 3];
const entry: [string, number] = ["Alice", 28];  // 元组

// unknown:接收外部数据,用前必须收窄
function parse(input: unknown) {
  if (typeof input === "string") {
    return input.toUpperCase();   // ✅ 收窄后才能用
  }
}

// never:函数永不正常返回
function fail(msg: string): never {
  throw new Error(msg);
}
滥用 any 等于关掉 TypeScript。接收 JSON、API 响应等外部数据时用 unknown,强制调用者先做类型检查再使用;any 会让类型错误悄无声息地溜进运行时。
记住三个特殊类型各自的口令:any 是「别检查我」,unknown 是「检查完再用我」,never 是「我根本不该出现」。外部输入一律先接成 unknown,用前用 typeof 或类型守卫收窄——没收窄就访问成员会报 error TS18046: 'x' is of type 'unknown';never 则留给「穷举分支」和「永不返回的函数」(tsc 7)。

「或」与「且」这两个类型运算符,是 TS 组合已有类型最基础的两把工具,也是判别联合与工程建模的起点。

联合类型 A | B 表示「或」(是 A 或是 B),用时常配类型收窄。交叉类型 A & B 表示「且」(同时具备两者所有字段),常用于组合已有类型。
// 联合:是 A 或 B
type ID = string | number;

function printId(id: ID) {
  if (typeof id === "string") id.toUpperCase();  // 这里是 string
  else id.toFixed(2);                            // 这里是 number
}

// 交叉:同时具备两者所有字段
type Timestamped = { createdAt: Date };
type Audited     = { createdBy: string };
type Entity = Timestamped & Audited;

// 常见:扩展已有类型
type AdminUser = User & { permissions: string[] };
交叉冲突的类型会塌成 never,不是「都满足」。string & number 结果是 never(没有值能同时是二者);两个对象里同名字段类型冲突时,{ a: number } & { a: string } 的 a 也变成 never——声明处不报错,等你给它赋任何值时才报 error TS2322: Type 'number' is not assignable to type 'never'(tsc 7)。
联合先收窄再用,交叉直接叠字段。拿到 A | B 时只能访问两者共有的成员,想用各自独有的方法必须先用 typeof/in/字面量相等收窄到具体分支(否则报 error TS2339: Property ... does not exist on type);A & B 则相反,一步得到同时含两边全部字段的类型,最常用来给已有对象类型「加料」。

enum 是 TS 里少数会「漏进运行时」的语法,用还是不用,几乎是每个 TS 项目都要表态的一道选择题。

enum 是 TS 里极少数会生成运行时代码的语法(编译后是真实对象),违背「类型擦除」原则,现代 TS 对它态度审慎:字符串枚举尚可;const enum 在 esbuild/Babel 等单文件转译器下无法内联;Node 原生运行 TS 的「类型剥离」模式干脆不支持 enum(参见 erasableSyntaxOnly 编译选项)。现代替代:字面量联合类型(轻量首选),或 as const 对象 + keyof typeof 派生(需要「既是值又是类型」时)。
// enum:编译后生成真实的运行时对象
enum Direction { Up, Down }

// 替代①:字面量联合——零运行时,够用就选它
type Status = "pending" | "done" | "failed";

// 替代②:as const 对象——需要"既是值又是类型"时
const STATUS = {
  Pending: "pending",
  Done: "done",
  Failed: "failed",
} as const;
type StatusV = (typeof STATUS)[keyof typeof STATUS];
//   ^ "pending" | "done" | "failed"

function move(s: StatusV) {}
move(STATUS.Done);   // ✅ 和 enum 一样有"命名空间"感
move("done");        // ✅ 字面量直接传也行(enum 则不允许)
数字枚举有反向映射(Direction[0] === "Up"),生成的对象比预期大;两个结构相同的 enum 互不兼容(enum 是名义类型,这点与 TS 的结构化类型系统相悖)。存量代码遇到 enum 不必恐慌——字符串枚举问题最少——但新代码建议默认不用。
一句话决策:只要类型 → 字面量联合;要类型 + 运行时常量 → as const 对象;接手的老项目已在用 enum → 保持一致,别混两种风格。

TypeScript · 接口与类型别名

interface 与 type 的能力对照表(谁能声明合并、谁能写联合、谁能被 extends),加上索引签名、可选与只读修饰符。

两者九成场景可以互换。真正的差别只有三条:声明合并、能不能表达联合 / 映射、报错信息的形态。

能力对照

能力interfacetype
描述对象形状✓✓
联合类型 A | B✗✓
映射类型 / 条件类型 / 模板字面量✗✓
元组、原始类型别名✗✓
同名多次声明自动合并✓✗(重复定义直接报错)
扩展extends(也能继承 type 的对象类型)& 交叉
被类 implements✓✓(对象形状时)
循环引用自身✓✓(对象成员位置)
编辑器悬浮显示保留名字常被展开成一大坨

成员写法

写法含义
a?: string可选:类型实为 string | undefined
readonly a: string只读(仅编译期,运行时照样能改)
[k: string]: number索引签名:任意字符串键,其它成员必须兼容它
[k: `data-${string}`]: string模式索引签名(TS 4.4+)
(x: number): string调用签名:这个类型本身可被调用
new (x: number): Foo构造签名
get x(): number / set x(v)访问器签名
interface A extends B, C多继承

声明合并的正反两面

同名 interface 会自动合并成员,这既是给第三方库补类型的官方手段(declare module 里给 Window 加字段),也意味着你的接口可以被任何人悄悄扩充。库作者用 interface 留出扩展口,应用层内部类型用 type 更可控。

// 索引签名要求其它成员都兼容它,否则报错
interface Dict {
  [k: string]: number;
  count: number;      // ✅ 兼容
  // name: string;    ❌ string 不属于 number
}

// 给全局对象扩字段:声明合并的正当用法
declare global {
  interface Window { __APP_VERSION__: string }
}

// type 才能做的事
type Result<T> = { ok: true; value: T } | { ok: false; error: Error };
type Keys = keyof Result<number>;
readonly 和 ? 都只在编译期存在。readonly 的对象通过 as 转一下就能改,运行时毫无保护;a?: string 在 exactOptionalPropertyTypes 关闭时还允许你显式传 undefined。需要运行时保证就用 Object.freeze 或 # 私有字段。
一个够用的选法:对外暴露的对象形状写 interface,其余一律 type。前者留下声明合并的扩展口、报错信息里保留类型名;后者表达力更强,联合、映射、条件类型都只有它能写。别为这件事在团队里反复争论——保持一致比选哪个更重要。

描述「一个对象长什么样」是 TS 最日常的活儿,interface 就是为这件事量身打造的工具。

interface 描述对象的字段:readonly 只读、? 可选、嵌套对象、索引签名 [key: string]: T 描述动态 key、extends 继承扩展。也能描述函数(调用签名)。
interface User {
  readonly id: number;          // 创建后不可改
  name: string;
  age?: number;                  // 可选字段
  role: "admin" | "user";
}

// 继承扩展
interface Admin extends User {
  permissions: string[];
}

// 索引签名:动态 key
interface Dict { [key: string]: number; }
const scores: Dict = { alice: 95, bob: 87 };

// 调用签名:描述函数
interface Transformer { (input: string): string; }
「多余属性检查」只在对象字面量直接赋值时触发。const u: User = { id: 1, name: "a", role: "x" } 会报 error TS2353: Object literal may only specify known properties;但把同一个对象先存进变量再赋值,检查就被跳过、不报错。这既是防拼写错的护栏,也是它「有时候拦不住」的原因(tsc 7)。
readonly 只是编译期的锁,不是运行时的锁。它拦得住 obj.id = 2 这类重新赋值(编译报错),但擦除类型后 JS 照样能改——真想在运行时冻结得靠 Object.freeze。同理 ? 可选字段的类型其实是 T | undefined,读出来用前要判空。

同名 interface 自动合并,是它相对 type 独有的一项能力,也是官方推荐的「给第三方类型打补丁」手段。

同名 interface 会自动合并成一个整体——这是 interface 独有、type 没有的能力,常用于给第三方库/全局类型打补丁。注意:在带 import/export 的模块文件里,扩展全局类型(如 Window)需包在 declare global 中;给第三方库(如 Express 的 Request)补字段的完整写法见 26 工程实战。
// 同名 interface 自动合并成一个整体
interface Box { width: number; }
interface Box { height: number; }
// 等价于 { width: number; height: number }

const b: Box = { width: 10, height: 20 }; // 两个字段都要求
合并要求同名字段类型完全一致,type 则根本不能同名。两个 interface Box 里 x 一个 number 一个 string,报 error TS2717: Subsequent property declarations must have the same type;而两个同名 type Box 直接报 error TS2300: Duplicate identifier 'Box'——type 没有合并能力(tsc 7)。
声明合并是给「别人的类型」打补丁的正道。想给第三方库或全局对象加字段,就再声明一个同名 interface 让它自动并进去;在带 import/export 的模块文件里扩展全局类型(如 Window)时,记得包在 declare global { ... } 里,否则会被当成局部声明而不生效。

interface 和 type 八成场景可以互换,真正的区别只集中在少数几处能力边界上。

type 能做 interface 做不到的:联合、元组、映射类型、条件类型等任意类型组合。interface 能做 type 做不到的:声明合并。准则:对象/类结构用 interface(可合并、更语义化)、联合/元组/复杂组合用 type。
// 只有 type 能做:
type Shape = Circle | Rectangle;          // 联合
type Point = [number, number];               // 元组
type Optional<T> = { [K in keyof T]?: T[K] }; // 映射
type IsStr<T> = T extends string ? true : false; // 条件

// 只有 interface 能做:声明合并(见本章前面)

// 实践准则:
// 对象/类结构 → interface    联合/元组/组合 → type
「组合」时两者报错时机差得远。interface B extends A 覆盖了不兼容字段会立刻报 error TS2430: Interface 'B' incorrectly extends interface 'A';而 type B = A & { ... } 遇到冲突字段声明处不报、悄悄把该字段塌成 never,直到你给它赋值才报 TS2322——同样是叠加类型,一个当场拦、一个拖到用时才炸(tsc 7)。
决策口诀:对象/类结构用 interface,联合/元组/映射/条件类型用 type。两者八成场景可互换,选 interface 的额外好处是能被声明合并、能一次 extends A, B 继承多个父接口(tsc 7 通过);只有 type 能写出 A | B、[number, number] 这类非对象结构。

TypeScript · 函数类型

函数在类型层面的全部写法:参数与返回值、可选与默认、重载、this 参数、类型谓词与断言签名,以及型变规则。

函数类型只有两种写法(箭头式与调用签名式),但围绕它有一圈修饰:可选参数、重载、this 标注、把返回值当作类型证据的谓词。

写法一览

写法含义
(a: number) => string函数类型,最常用
{ (a: number): string }调用签名,可同时挂静态属性
{ new (a: number): Foo }构造签名
(a?: number)可选参数,必须排在必选之后
(a = 1)默认值,类型自动推断且参数变可选
(…rest: number[])剩余参数
(…args: [name: string, age: number])用元组给剩余参数具名
function f(this: HTMLElement, e: Event)this 参数:只在类型层存在,不占实参位
function f(x: string): asserts x is Foo断言签名:调用后 x 就被收窄
function isFoo(x: unknown): x is Foo类型谓词:把运行时判断告诉编译器
(): never不会正常返回(抛错 / 死循环)
(): void返回值不被使用;实现方返回任何值都合法
async (): Promise<T>异步函数的返回类型必然是 Promise
function* (): Generator<T, R, N>生成器:产出 / 返回 / 接收三个类型参数

重载

要点说明
写法先写若干条只有签名的声明,最后写一个兼容全部的实现签名
实现签名对外不可见——调用方只能匹配上面那些
匹配顺序从上到下第一个匹配的胜出,把最具体的放前面
替代方案参数类型不同但行为一致时,用联合类型或泛型更简单

型变:什么样的函数能赋给什么样的函数

位置规则直觉
返回值协变:返回更具体的类型可以「给得更多」总是安全的
参数逆变(strictFunctionTypes 开启时):接受更宽的类型可以「要求更少」才安全
方法简写 m(x: T): void例外:双变,历史兼容所致写成属性形式 m: (x: T) => void 才享受严格检查
返回 void 的回调实现方返回任何值都合法所以 arr.forEach(x => arr2.push(x)) 不报错
// 类型谓词:把运行时的判断结果带进类型系统
function isUser(x: unknown): x is User {
  return typeof x === "object" && x !== null && "id" in x;
}
if (isUser(data)) data.id;      // 这里 data 已是 User

// 重载:具体签名在前,实现签名对外不可见
function parse(s: string): object;
function parse(s: string, raw: true): string;
function parse(s: string, raw?: boolean) { return raw ? s : JSON.parse(s); }

// TS 5.5 起:简单的过滤回调无需写谓词,编译器自己推断
const nums = list.filter((x) => x !== null);   // 推断出去掉 null 的类型
类型谓词是一句没有被验证的承诺。x is Foo 只是告诉编译器「返回 true 时它就是 Foo」,函数体里判断得对不对没人替你检查——写错了,收窄之后的所有代码都建立在假前提上,运行时照样炸。外部数据的校验交给 zod 这类库,让类型从校验器推导出来,而不是自己手写谓词。
写库时给公开函数标注返回类型,写应用内部时交给推断。显式返回类型能挡住「改了实现却意外改了对外契约」,还能让报错落在函数内部而不是调用处;内部小函数标注则多是噪音。

给函数标类型是 TS 每天写得最多的注解,先把可选、默认、剩余、void 这几种参数与返回形态理清楚。

给参数和返回值加类型注解。可选参数用 ?(必须在必选参数后)、默认参数 = 值、剩余参数 ...args: T[]。返回值 void 表示不关心返回。用函数类型表达式给整个函数变量定型,参数类型可自动推断。
function add(a: number, b: number): number {
  return a + b;
}

// 可选(?) / 默认(=) / 剩余(...)
function greet(name: string, greeting = "Hello"): string {
  return `${greeting}, ${name}!`;
}
function sum(...nums: number[]): number {
  return nums.reduce((a, b) => a + b, 0);
}

// 函数类型表达式:参数类型自动推断
type Comparator = (a: number, b: number) => number;
const compare: Comparator = (a, b) => a - b;
返回类型 void 不等于「必须不返回」。它只表示「调用方不看返回值」,所以 const c: () => void = () => 42 能编过——这正是 arr.forEach(x => arr.push(x)) 合法的原因(push 返回 number,被 void 回调忽略)。别指望用 void 返回类型去禁止函数返回东西(tsc 7 编过)。
参数几乎总要显式标,返回值多数可省。参数没有推断来源,不标就是隐式 any(strict 下报错);返回值 TS 能从函数体推断,写不写看是否想「对外锁定契约」。可选参数 ? 的真实类型是 T | undefined,且必须排在必选参数之后(否则 error TS1016),也不能和默认值 = 同写(error TS1015: Parameter cannot have question mark and initializer,tsc 7)。

一个函数按不同入参返回不同类型时,重载是 TS 的经典表达方式——尽管今天它常被泛型 + 索引类型替代。

重载用多个签名描述函数的不同调用方式(返回不同类型),最后一个「实现签名」对外不可见。但现代更推荐用泛型 + 索引类型代替重载——更简洁、可扩展。
// 重载签名:按入参得到不同返回类型
function create(tag: "a"): HTMLAnchorElement;
function create(tag: "canvas"): HTMLCanvasElement;
function create(tag: string): HTMLElement {   // 实现签名
  return document.createElement(tag);
}

// 更推荐:泛型 + keyof 索引,替代重载
function make<K extends keyof HTMLElementTagNameMap>(
  tag: K
): HTMLElementTagNameMap[K] {
  return document.createElement(tag);
}
重载的两类报错都比普通类型错更难读。调用匹配不上任何重载时报的是 error TS2769: No overload matches this call(而非直白的 TS2345);实现签名与某条重载不兼容时报 error TS2394: This overload signature is not compatible with its implementation signature——记住这两个码,遇到时才知道是重载没对齐(tsc 7)。
实现签名对外不可见,别漏掉任何一种合法调用。调用方只能匹配你在上面列出的重载签名,最后那条「实现签名」再宽也不参与匹配;所以每一种想让外部使用的调用方式,都必须单独写成一条重载,否则调用方会「找不到匹配」。能用泛型 + 索引类型表达时优先用泛型,重载更啰嗦也更难维护。

JS 的 this 在运行时飘忽不定,TS 用一个假参数 this 把它的类型在编译期钉死。

TS 可为方法显式标注 this 的类型(伪参数,不占实际参数位)。两个用途:返回 this 类型实现类型安全的链式调用;声明 function(this: User) 防止在错误上下文中调用。
interface Builder {
  value: number;
  add(this: Builder, n: number): this;  // 返回 this → 可链式
  build(this: Builder): number;
}
// builder.add(5).add(3).build()  ← 类型安全的链式调用

// 伪参数 this:限定调用上下文
function formatUser(this: User) {
  return `${this.name} <${this.email}>`;
}
// formatUser()        ← ❌ 不能独立调用
// formatUser.call(user) ← ✅
标了 this 类型的函数不能「裸调用」。function fmt(this: User) {...} 直接写 fmt() 会报 error TS2684: The 'this' context of type 'void' is not assignable to method's 'this' of type 'User';必须 fmt.call(user) 或作为 user.fmt() 方法调用才能提供正确的 this(tsc 7)。
this 是伪参数:不占实参位、编译后消失。它只在类型层面工作,用来干两件事——方法返回 this 类型实现类型安全的链式调用;以及声明 function f(this: User) 把函数钉死在正确的上下文里。调用方传实参时完全无需理会这个 this,它不计入参数个数。

两个函数之间谁能赋给谁,取决于参数与返回值的型变方向——这是 TS 类型兼容里最反直觉的一块。

型变(variance)回答一个问题:Dog 是 Animal 的子类型,那「关于它们的复合类型」谁是谁的子类型?规则:返回值位置协变(返回 Dog 的函数可赋给要求返回 Animal 处——给得更具体,安全);参数位置逆变(接收 Animal 的函数可赋给要求接收 Dog 处——要求更宽松,安全)。「参数逆变、返回协变」是函数子类型判定的核心。开启 strictFunctionTypes 后 TS 对函数类型的参数按逆变严格检查;但用方法语法写的参数是双变(协变逆变都放行)——为兼容数组等常见模式而故意保留的历史妥协。
class Animal {}
class Dog extends Animal { bark() {} }

// 返回值协变:更具体的返回类型可兼容
type MakeAnimal = () => Animal;
const makeDog: MakeAnimal = () => new Dog();   // ✅ 返回 Dog 兼容返回 Animal

// 参数逆变:更宽松的参数类型可兼容
type HandleDog = (d: Dog) => void;
const handle: HandleDog = (a: Animal) => {};  // ✅ 接收 Animal 更宽松,安全

// ❌ 反过来不安全:要求处理任意 Animal,却只会处理 Dog
type HandleAnimal = (a: Animal) => void;
// const bad: HandleAnimal = (d: Dog) => d.bark();
//   strictFunctionTypes 下报错:Dog 参数比 Animal 窄

// 方法写法双变(不受 strictFunctionTypes 约束)vs 属性写法逆变(更严)
interface A { on(d: Dog): void; }        // 方法语法 → 双变
type B = { on: (d: Dog) => void };    // 属性语法 → 逆变
方法写法的参数是「双变」,会放行不安全的赋值。把 on(d: Dog): void 写成方法语法时,即使拿一个只会处理 Dog 的函数去顶替「要处理任意 Animal」的位置,tsc 也不报错;换成属性语法 on: (d: Dog) => void 才会按逆变严格检查、报 error TS2322 ... Property 'bark' is missing in type 'Animal' but required in type 'Dog'。Dog[] 能赋给 Animal[] 也是这个双变妥协的产物(tsc 7)。
记忆法:输出可以更具体(协变),输入必须更宽松(逆变)。想让参数检查最严,就用属性写法(on: (x) => void)而非方法写法(on(x): void),并开启 strictFunctionTypes(已含于 strict)。

TypeScript · 泛型

泛型的全部语法点:类型参数与约束、默认值、keyof 组合、推断控制(const 参数与 NoInfer)、条件类型与 infer、变型注解。

泛型的本质是把类型当参数传。日常真正会用到的语法点不多,下面一张表列全,附上「什么时候需要它」。

语法点

写法含义什么时候要
function f<T>(x: T): T类型参数输入输出类型有联动
<T extends object>约束:T 至少是这个形状函数体里要访问某些成员
<K extends keyof T>键约束取属性、按键操作
<T = string>默认类型参数多数调用方不关心时
T[K]索引访问类型取出属性的类型
<const T>const 类型参数(TS 5.0)让传入的字面量保持最窄,调用方不必写 as const
NoInfer<T>把某个位置排除出推断(TS 5.4)让编译器只从指定参数推断
A extends B ? X : Y条件类型按类型分支产出不同结果
infer U在条件类型里捕获一个类型从数组取元素、从 Promise 取值
T extends any ? … : …分布式条件类型:联合会逐个进入批量变换联合成员
[T] extends [any] ? …套一层元组关掉分布需要把联合当整体判断时
interface Box<in out T>变型注解(TS 4.7)只影响检查方式与速度,写库才用得上
class Store<T> / type F<T>类与类型别名同样可泛型—

什么时候不该用泛型

写法问题改成
<T>(x: T) => voidT 只出现一次,等于没约束(x: unknown) => void
<T extends string>(x: T) 但只当字符串用无谓的复杂(x: string)
三四个类型参数互相约束报错信息将无法阅读拆成几个小函数,或改用重载

一句话判据:类型参数至少要在两个位置出现(比如参数与返回值),泛型才在传递信息。

// 约束 + 索引访问:取属性且返回值类型精确
function pluck<T, K extends keyof T>(o: T, k: K): T[K] { return o[k]; }
pluck({ id: 1, name: "Ada" }, "name");    // 推断为 string

// infer:从容器里把类型掏出来
type ElementOf<T> = T extends (infer U)[] ? U : never;
type Unwrap<T> = T extends Promise<infer U> ? U : T;

// const 类型参数:调用方不用再写 as const
function tuple<const T extends readonly unknown[]>(v: T): T { return v; }
const t = tuple(["a", "b"]);              // readonly ["a", "b"]

// 分布式条件类型:联合被逐个处理
type ToArray<T> = T extends any ? T[] : never;
type R = ToArray<string | number>;      // string[] | number[]
条件类型对联合默认是「分布」的,这经常出人意料。type NonNull<T> = T extends null ? never : T 作用于 string | null 时,是把两个成员分别代入再合并,得到 string——这正是想要的;但当你想判断「整个 T 是不是联合」时,分布会让判断永远落在单个成员上。把两边都套一层元组 [T] extends [U] 即可关掉分布。
先把函数写成具体类型,等到第二次为不同类型复制粘贴时再泛型化。提前泛型化几乎总是过度设计——写出来的约束往往对不上真实用法,还让报错信息变得难读。

泛型把「类型」本身也变成可传入的参数,让同一段代码类型安全地服务多种类型。

泛型把类型变成参数 <T>。调用时可显式传 identity<string>(...) 或让 TS 推断。接口/类型也能泛型化(ApiResponse<T>),并可设默认类型参数 <T, ID = number>。普通参数是值的占位符,泛型 <T> 则是类型的占位符:定义时不固定具体类型,调用时由 TS 根据实参自动推断填入,使同一段代码可类型安全地服务多种类型。
// 泛型函数:写一次,适配多种类型
function identity<T>(value: T): T { return value; }
identity(42);              // 推断 T = number

// 多类型参数
function pair<A, B>(a: A, b: B): [A, B] { return [a, b]; }

// 泛型接口 + 默认类型参数
interface Repository<T, ID = number> {
  findById(id: ID): Promise<T | null>;
  save(entity: T): Promise<T>;
}
type UserRepo = Repository<User>;
type PostRepo = Repository<Post, string>;  // string ID
没加约束的 T,你对它「一无所知」。泛型默认不假设 T 有任何成员,函数体里写 v.length 会报 error TS2339: Property 'length' does not exist on type 'T'——想用 T 的属性,必须先用 extends 约束它(见本章后面)。这是泛型最常撞的墙(tsc 7)。
优先让 TS 推断类型参数,少手写 <T>。identity(42) 会自动推出 T = number,比 identity<number>(42) 更简洁;显式传只在两种情况用——推断不出(如空数组),或想故意收窄/指定。显式传错还会当场挡下:identity<string>(42) 报 error TS2345(tsc 7)。

无约束的泛型太宽、写死类型又太窄,extends 约束正好卡在中间:既保留灵活,又拿回类型信息。

用 extends 约束泛型范围:<T extends { length: number }> 要求 T 有 length。配 keyof 可只允许传对象已有的 key,并用索引访问 T[K] 拿到对应值的类型——这是写类型安全工具函数的基础。
// extends 约束:T 必须有 length
function len<T extends { length: number }>(v: T) {
  return v.length;
}
len("hello");   len([1, 2]);   // ✅
// len(42) ← ❌ number 没有 length

// keyof 约束:key 必须是对象已有的属性名
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
const user = { name: "Alice", age: 28 };
getProp(user, "name");   // 返回 string
// getProp(user, "email") ← ❌ 不存在的 key
泛型推断会「加宽」字面量。function first<T>(a: T[]): T 传 ["a"] 推出的 T 是 string 而非 "a"——const r = first(["a"]) 里 r 就是 string,再 const t: "a" = r 便报 error TS2322(注意别把数组字面量直接写在带注解的赋值处,那样目标类型会回流让 T 反推成 "a")。想保留字面量,把类型参数写成 <const T>(TS 5.0+,tsc 7 有效),或在调用处对实参用 as const。
K extends keyof T + 索引访问 T[K] 是类型安全取值函数的标准套路。getProp(obj, key) 里把 key 约束成 keyof T,既能返回精确的 T[K] 类型,又能在你把 key 拼错时当场报 error TS2345: Argument of type '"email"' is not assignable to parameter of type '"age" | "name"'——把「取错字段」变成编译错误(tsc 7)。

泛型的默认推断有两个让人别扭的地方:传数组进去会被加宽成 string[]、丢掉字面量;多个参数共用一个 T 时,每个参数都会往 T 里「投票」,本想当约束的那个反而把类型撑大了。TS 给了两个专门的旋钮。

const 类型参数(TS 5.0):留住字面量,不必在调用处写 as const

  • 把 <T> 写成 <const T>,推断就按「调用方写了 as const」来做。
  • tsc 7 同一个调用 f(["a", "b"]):普通泛型推出 string[],加了 const 推出 readonly ["a", "b"]——元素类型、长度、只读性一次全保住。
  • 受益最大的是配置式 API:路由表、状态机的状态列表、表单字段定义。以前要求使用者到处写 as const(忘了就静默退化成 string[]),现在把要求写进库的签名里,调用方什么都不用做。

NoInfer<T>(TS 5.4):这个参数只用来检查,不参与推断

  • function pick<T extends string>(items: T[], fallback: NoInfer<T>)——T 只由 items 决定,fallback 拿它当标尺来检查。
  • tsc 7 pick(["a", "b"], "c") 报 error TS2345: Argument of type '"c"' is not assignable to parameter of type '"a" | "b"'——正是我们想要的。不加 NoInfer 时,"c" 会一起参与推断把 T 撑成 "a" | "b" | "c",于是什么都不报,错误一路溜到运行时。
  • 识别这个模式的信号:同一个类型参数出现在多个位置,而你心里其实有主次——「以这个为准,那个照着它检查」。默认推断不知道主次,NoInfer 就是告诉它。

两者的分工

  • const T 管推得够不够窄(别把 "a" 加宽成 string)。
  • NoInfer<T> 管谁有资格参与推断(别让兜底值把类型撑大)。
  • 两者经常一起出现在同一个库签名里,互不冲突。
// ── const 类型参数(TS 5.0)─────────────────
function normal<T>(x: T): T { return x; }
function keep<const T>(x: T): T { return x; }

const a = normal(["a", "b"]);   // string[]
const b = keep(["a", "b"]);     // readonly ["a", "b"](tsc 7)

// ── NoInfer(TS 5.4)───────────────────────
function pick<T extends string>(items: T[], fallback: NoInfer<T>): T {
  return items.includes(fallback) ? fallback : items[0];
}

pick(["a", "b"], "a");   // OK
pick(["a", "b"], "c");   // TS2345: '"c"' 不能赋给 '"a" | "b"'

// 不加 NoInfer 的话,"c" 会把 T 撑成 "a"|"b"|"c",一声不吭
const T 推出来的是只读类型(readonly ["a","b"]),把它传给要求可变数组 string[] 的地方会报「readonly 不能赋给可变」。这不是 bug,是它保住字面量的必然代价——需要可变就别用 const,或者在下游标成 readonly T[]。另外 const 只影响字面量写在调用处的情形,传进去一个已经声明成 string[] 的变量,它救不回来。
写库时把这两个当默认习惯:接收配置对象/数组的参数标 const T,兜底值与默认值标 NoInfer<T>。它们都只影响推断、不影响运行时,加上去零成本,却能把一批「类型看着对、实际没检查」的调用挡在编译期。写业务代码时用得少,但读库的类型定义时天天见。

条件类型给类型加上 if/else,infer 则在匹配的同时就地「抓出」一段类型——TS 的类型编程从这里开始。

条件类型 T extends U ? X : Y 让类型「分支」。infer 在条件中声明并推断一个类型变量,用来「提取」类型(从函数提取返回值、从 Promise 提取解析值)。TS 内置的 ReturnType 就是这样实现的(内置 Awaited 会递归解包多层 Promise,本例只演示单层)。infer R 用于在类型匹配中声明一个待推断的占位类型:即「若该类型符合某种结构,则把其中某部分提取出来并命名为 R」,相当于类型层面的模式提取。
// 条件类型
type NonNull<T> = T extends null | undefined ? never : T;

// infer:在条件类型里提取类型
type MyReturn<T> =
  T extends (...args: any[]) => infer R ? R : never;

type MyAwaited<T> =
  T extends Promise<infer U> ? U : T;

type V = MyAwaited<Promise<string>>;   // string
裸类型参数的条件类型会在联合上「分发」。NonNull<T> = T extends null | undefined ? never : T 传 string | null 时,会逐个成员套用后再合并,得到 string(null 被过滤)——这常常是你要的,但也可能是意外。不想分发就把两边都用元组包起来:[T] extends [null | undefined] ? ...,此时 string | null 作为整体判断、不再拆开(tsc 7 两种结果不同)。
条件类型 + infer 是 TS 类型编程的「核武器」,但也最容易写得晦涩。日常优先用内置工具类型(第 26 章),只有它们覆盖不到时才自己写——可读性比炫技更重要。

TypeScript · 类型收窄与守卫

把宽类型缩成窄类型的全部手段列成一张表:typeof / instanceof / in / 判别联合 / 自定义守卫 / 断言函数,以及 as 与 satisfies 的区别。

拿到 string | number 或 unknown 之后,得先证明它是什么才能用。TS 认识下面这些「证据」,写在 if 里就会自动收窄。

收窄手段

写法能收窄什么
typeof x === "string"原始类型
Array.isArray(x)数组
x instanceof Date类实例
"kind" in x按属性存在与否分辨对象
x.kind === "circle"判别联合,最推荐的建模方式
if (x) / x != null去掉 null 与 undefined(注意 0 和 "" 也会被排除)
x === "a"字面量联合里的一支
function isFoo(x): x is Foo自定义守卫(23 章)
function assertFoo(x): asserts x is Foo断言函数:调用之后一直生效
switch (x.kind) + never 兜底穷尽检查,将来加分支会编译报错

三种「相信我」写法

写法含义风险
x as Foo断言:让编译器闭嘴不做任何运行时检查,错了就是隐藏 bug
x!非空断言同上,只是省字;宁可写 if (!x) return
x satisfies Foo检查 x 符合 Foo,但保留 x 的窄类型无风险——想校验配置对象时用它,不要用 as
as unknown as Foo双重断言,强行绕过基本等于关掉类型系统,加注释说明理由

收窄会失效的几处

场景为什么怎么办
回调 / 闭包里编译器无法保证回调执行时值没变先存进局部 const
let 变量被重新赋值后收窄结果被作废能用 const 就用
obj.a.b 这类深层路径中途调用过函数后编译器不再信任解构出来再判断
可选属性 + 函数调用调用可能修改对象同上
// 判别联合:给每个分支一个字面量标签,switch 即可收窄
type State =
  | { status: "loading" }
  | { status: "ok"; data: User[] }
  | { status: "error"; error: Error };

if (s.status === "ok") s.data;     // 只有这一支才有 data,编译器认得

// satisfies:既校验又不放宽类型
const routes = {
  home: "/",
  user: "/u/:id",
} satisfies Record<string, string>;
routes.home.startsWith("/");         // 仍然是字面量类型,as 会把它放宽

// 收窄在闭包里失效:先落到 const 上
const el = ref.current;
if (!el) return;
setTimeout(() => el.focus(), 0);   // 用 ref.current 就会重新变回可空
as 不是转换,只是断言。const n = "5" as unknown as number 之后 n 在运行时依然是字符串,n.toFixed() 立刻抛错。真正需要转换时用 Number(x);真正需要校验时用类型守卫或 zod。代码里每出现一个 as,都应该能说出「我凭什么知道它是这个类型」。
建模时优先「判别联合」,而不是「一堆可选字段」。{ loading?: boolean; data?: User[]; error?: Error } 允许出现「既在加载又有错误」这种不可能的状态,每次使用都得判空;带 status 标签的联合让非法组合无法被表达,收窄一次之后字段全都确定。

联合类型要用得安全,全靠在分支里把宽类型收窄成某一个具体成员。

TS 会在分支里自动收窄类型:typeof(原始类型)、instanceof(类)、in(属性存在)、字面量相等。最强的是判别联合:给每个成员一个共同的「标签」字段(如 kind),用 switch 区分;配 never 做穷举检查。
type Shape =
  | { kind: "circle";    radius: number }
  | { kind: "rect";      width: number; height: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.radius ** 2;
    case "rect":   return s.width * s.height;
    default:
      const _: never = s;   // 穷举:漏处理新 case 会在此报错
      return 0;
  }
}
typeof 会骗你,收窄 object 前要补判断。typeof null === "object"、typeof [] === "object"、typeof NaN === "number"(Node 22 object object number)。所以对 string | null 写 if (typeof x === "object"),分支里 x 恰好被收窄成 null;真要区分「普通对象/数组/null」得额外补 x !== null 和 Array.isArray(x),光靠 typeof 分不清。
判别联合 + never 穷举是 TS 最实用的模式:每当给 Shape 加一个新成员,所有忘记处理它的 switch 都会在编译期报错——把「漏改」变成编译错误,而不是线上 bug。

内置的 typeof/instanceof 覆盖不到复杂对象时,自定义 is 谓词让你把任意校验逻辑接进 TS 的收窄机制。

内置守卫不够时,写返回 参数 is 类型 的函数——TS 会在 true 分支里把参数收窄为该类型。常用于验证 unknown 的外部数据。
// 返回类型用 "val is User" 谓词
function isUser(val: unknown): val is User {
  return (
    typeof val === "object" && val !== null &&
    "id" in val && "name" in val
  );
}

function handle(data: unknown) {
  if (isUser(data)) {
    data.name;     // ✅ 这里 data 已收窄为 User
  }
}
TS 完全信任你的谓词,从不检查函数体对不对。返回类型写了 v is User,哪怕函数体只写 return typeof v === "object"(漏查 id/name 字段)也照样编译通过、不报错;谓词逻辑写错,收窄就是错的,运行时才崩。守卫的正确性得你自己保证——TS 只认那句 is(tsc 7 不完整甚至逻辑相反的守卫均编过)。
谓词函数专门用来给 unknown 外部数据「把关」。返回类型写 val is T,在 true 分支里 TS 就把 val 当作 T 使用。它最适合校验 JSON、接口响应这类进程序前类型未知的数据——一处校验,处处收窄,比到处 as 断言安全得多。

asserts、!、as 都是你对 TS 说「这里我说了算」——用对了省事,用错了只是把类型错误推迟到运行时。

断言函数 asserts val is T:执行通过即视为成立(否则抛错),之后该变量被收窄。非空断言 ! 告诉 TS「这值不是 null/undefined」;as 类型断言强制转换类型。三者都是「我比 TS 更清楚」的声明——错了运行时就崩。
// 断言函数:通过后变量被收窄
function assertDefined<T>(v: T): asserts v is NonNullable<T> {
  if (v == null) throw new Error("nullish!");
}

// 非空断言 !
const el = document.getElementById("app")!;  // 去掉 null

// as 类型断言(慎用)
const input = document.querySelector("input") as HTMLInputElement;
! 和 as 会绕过类型检查,断言错了运行时直接崩(Cannot read property of null)。优先用类型守卫替代断言;双重断言 x as unknown as T 更是几乎总是设计有问题的信号。
断言函数必须存进「有显式类型注解」的名字才生效。直接 assertDefined(x) 调用没问题;但 const fn = assertDefined; fn(x) 这样经过一个无注解变量转手后,收窄会失效——TS 报 error TS2775: Assertions require every name in the call target to be declared with an explicit type annotation,其后 x 仍是可空的(TS18047)。断言函数要么直接调用、要么给中转变量显式标类型(tsc 7)。

TypeScript · 工具类型

标准库里全部内置工具类型一张表:对象变形、联合筛选、函数与 Promise 取值、字符串大小写,附各自的等价实现与常见误用。

这些类型不需要 import,编译器内置。查的时候按「输入是什么」分组:改对象形状的一组、筛联合成员的一组、从函数或 Promise 里取类型的一组。

对象变形

类型作用提醒
Partial<T>全部属性变可选只作用一层
Required<T>全部变必选同时去掉 undefined
Readonly<T>全部变只读只一层,且只在编译期
Record<K, V>造字典类型K 用联合时会要求每个键都存在
Pick<T, K>挑出若干属性K 拼错会报错,安全
Omit<T, K>去掉若干属性K 拼错不报错——它不要求 K 是 T 的键

联合筛选

类型作用例子
Exclude<T, U>从联合里去掉能赋给 U 的成员Exclude<"a"|"b", "a"> → "b"
Extract<T, U>只保留能赋给 U 的成员从大联合里挑一类
NonNullable<T>去掉 null 与 undefined等价 Exclude<T, null | undefined>

Omit 与 Exclude 常被搞混:前者作用于对象的属性,后者作用于联合的成员。

函数与构造器

类型取出什么
Parameters<F>参数类型组成的元组
ReturnType<F>返回类型
ConstructorParameters<C>构造器参数元组
InstanceType<C>实例类型
ThisParameterType<F>this 参数的类型
OmitThisParameter<F>去掉 this 参数后的函数类型
ThisType<T>标记对象字面量中 this 的类型(需 noImplicitThis)
Awaited<T>递归解开 Promise,直到非 Promise 为止
NoInfer<T>把该位置排除出类型推断(TS 5.4)

字符串

类型作用
Uppercase<S> / Lowercase<S>整体大小写转换
Capitalize<S> / Uncapitalize<S>首字母大小写

四个都作用在类型层的字符串字面量上,通常与模板字面量类型配合(27 章),比如从 "click" 造出 "onClick"。

// 从一个源类型派生一整套:改一处,全跟着变
interface User { id: number; name: string; email: string; createdAt: Date }

type NewUser   = Omit<User, "id" | "createdAt">;   // 建用户时的入参
type UserPatch = Partial<NewUser>;                     // 局部更新
type UserBrief = Pick<User, "id" | "name">;           // 列表项
type UserMap   = Record<number, User>;                 // 按 id 索引

// 从已有函数反推类型,不必重复声明
type Opts = Parameters<typeof createServer>[0];
type Data = Awaited<ReturnType<typeof fetchUser>>;

// 递归版 Partial:内置的只作用一层
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };
Omit 不检查键名是否存在。Omit<User, "emial">(拼错)不报错,只是什么都没删——重构改了字段名后,一整批 Omit 静静失效。Pick 没有这个问题(它要求 K 必须是 T 的键)。需要安全的排除,可以自己写一个 StrictOmit<T, K extends keyof T>。
让类型互相派生,别平行地写三遍。一个 User 加上 Omit / Pick / Partial 就能长出「创建入参」「更新入参」「列表项」,源头改字段时全部自动跟上;手写三个独立接口则必然会在某次改动后不同步。

同一份对象类型,创建时要去掉 id、更新时要全可选、列表展示又只留几个字段——手写三四个接口既啰嗦又容易和源类型脱节,工具类型让这些变体从一个 source 自动派生。

把对象类型整体变形:Partial<T> 全可选(更新请求体)、Required<T> 全必选、Readonly<T> 全只读、Pick<T,K> 取部分字段、Omit<T,K> 排除字段(创建 DTO 去掉 id)、Record<K,V> 构建键值映射。
interface User { id: number; name: string; role: "admin" | "user"; }

type UpdateDto = Partial<User>;        // 所有字段可选
type CreateDto = Omit<User, "id">;     // 去掉 id
type Summary   = Pick<User, "id" | "name">; // 只取两字段
type Frozen    = Readonly<User>;       // 全只读

// Record:键值映射
type Perms = Record<"admin" | "user", string[]>;
const perms: Perms = { admin: ["all"], user: ["read"] };
Pick 的键受 keyof T 约束,拼错立刻报 TS2344;但 Omit 的键是 keyof any,写一个源类型里根本不存在的字段名也不报错——重构时把某字段改了名,Omit<User, "oldName"> 会静默失效,把本该排除的字段又漏回结果里。另外 Partial/Readonly 都是浅层的:Partial<{a:{b:number}}> 只让 a 可选,内层 a.b 仍是必填(漏写报 TS2741)。
用字面量联合做 Record 的键会强制穷举:Record<"admin"|"user"|"guest", V> 少配一个键就报 TS2741,很适合给「每个枚举值都必须有一份配置」的表打底;键写成 string 则退化成普通索引签名,不再校验完整性。

不必为「某函数的返回值」「解包后的 Promise 结果」另写一份类型——让派生类型跟着源函数走,源改了它们自动跟着变。

操作联合与函数类型:Exclude<T,U>/Extract<T,U> 从联合中剔除/保留、NonNullable<T> 去掉 null|undefined、ReturnType<T> 取函数返回类型、Parameters<T> 取参数元组、Awaited<T> 递归解包 Promise。可组合使用。
type A = Exclude<"a" | "b" | "c", "a">;   // "b" | "c"
type B = NonNullable<string | null>;       // string

// 从函数/Promise 提取类型
const getUser = () => ({ id: 1, name: "Alice" });
type U = ReturnType<typeof getUser>;    // { id; name }
type P = Awaited<Promise<string>>;       // string

// 组合:必须有 id,其余可选
type Patch<T> = Partial<Omit<T, "id">> & Pick<T, "id">;
ReturnType/Parameters 作用于重载函数时只取最后一个重载签名:给 f(x:number):number 与 f(x:string):string 取 ReturnType 得到的是 string(最后一条),而不是两者的联合(把结果赋给 number 报 TS2322)。此外这些工具都作用于类型,对一个值要先套 typeof(ReturnType<typeof getUser>),直接写值会报错。
工具类型可以像函数一样嵌套组合。但组合层数一多就难读了——这时给中间结果起个有意义的 type 别名,比把五六个工具类型套成一行更利于维护。

TypeScript · 高级类型

类型层的运算工具:映射类型的四个修饰符与 as 重映射、模板字面量类型、keyof / typeof / 索引访问,以及递归类型的写法与代价。

类型层有一套自己的「表达式」:keyof 取键、T[K] 取值类型、映射类型批量改写、模板字面量拼字符串。组合起来就能从一份数据推出一整套类型。

取类型的四个操作符

写法得到
keyof TT 的键组成的联合
typeof x从值取类型(typeof 在类型位置的含义)
T[K]索引访问:某个属性的类型
T[number]数组 / 元组的元素类型
(typeof ARR)[number]从 as const 数组造出字面量联合,最常用的组合

映射类型

写法含义
{ [K in keyof T]: T[K] }遍历每个键,原样重建
{ [K in keyof T]?: … }加可选(Partial 就是这么实现的)
{ -readonly [K in keyof T]-?: … }- 前缀去掉修饰符:可变、必选
{ [K in keyof T as NewK]: … }键重映射(TS 4.1):改键名,或返回 never 删掉该键
{ [K in "a" | "b"]: number }直接遍历一个字面量联合

模板字面量类型

写法得到
`on${Capitalize<K>}`"click" → "onClick"
`${number}px`受约束的字符串模式
联合参与拼接会做笛卡尔积:两个 10 成员的联合 = 100 个字面量
infer 配合可以在类型层「解析」字符串,如从路由 "/u/:id" 提取参数名

代价

类型层运算是在编译时求值的:深递归、大联合的笛卡尔积会让 tsc 明显变慢,甚至撞上「类型实例化过深」的错误。判据很简单——编辑器悬浮提示开始转圈,就说明该收手了,把那部分改回显式声明。

// 从常量数组造类型:单一事实来源,加一项类型自动跟上
const STATUSES = ["draft", "published"] as const;
type Status = (typeof STATUSES)[number];      // "draft" | "published"

// 键重映射:给每个字段生成一个事件名
type Handlers<T> = {
  [K in keyof T as `on${Capitalize<string & K>}Change`]: (v: T[K]) => void;
};
// { name: string } → { onNameChange: (v: string) => void }

// 返回 never 即从结果里删掉这个键:挑出所有函数属性
type MethodsOf<T> = {
  [K in keyof T as T[K] extends Function ? K : never]: T[K];
};
类型体操写爽了,报错信息就没人能读懂。一个五层嵌套的条件类型出错时,编译器会把整棵展开的类型糊在一行里,几百字符没有断点,同事(和三个月后的你)根本无法定位。够用就好:能用 Pick / Omit 表达的别写映射类型,能显式列出十个字面量的别用模板拼——类型的读者是人,不只是编译器。
「单一事实来源」是这些工具最值得的用法。路由表、权限清单、状态机的状态列表——写成一个 as const 常量,再用 typeof + 索引访问派生出类型,运行时和编译期永远同步;两边各写一份则迟早对不上。

内置的 Partial、Readonly 本身就是用映射类型写出来的——学会它,你就能造自己的「批量改写对象类型」工具,而不是干等 TS 内置。

映射类型遍历已有类型的每个 key 生成新类型 { [K in keyof T]: ... }。可加/减修饰符:?(可选)、readonly,前缀 - 移除(-?/-readonly)。as 子句可重映射 key(改名或过滤掉某些 key)。
// 手写 Partial / Readonly(理解原理)
type MyPartial<T>  = { [K in keyof T]?: T[K] };
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };

// 修饰符取反:-? 去可选,-readonly 去只读
type Mutable<T>  = { -readonly [K in keyof T]: T[K] };
type Complete<T> = { [K in keyof T]-?: T[K] };

// as 重映射 key:生成 getter 名
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

// as ... never 过滤 key:只保留 string 字段
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
keyof T 的类型是 string | number | symbol,而 Capitalize、Uppercase 这些模板工具只接受 string。直接写 Capitalize<K> 会报 TS2344: Type 'K' does not satisfy the constraint 'string'——必须先用 string & K 把键收窄成字符串(卡内 Capitalize<string & K> 的 string & 正是为此)。
直接写 [K in keyof T] 的映射是同态的,会自动继承 T 上每个字段原有的 readonly 和 ?——连加 as 重映射键名后也照样保留(重映射后原 readonly 字段仍不可写、报 TS2540)。所以只有想改变修饰符时才需要显式写 -? / -readonly,不写就是「原样带过」。

把「事件名带 on 前缀」「CSS 值带 px 单位」这类字符串约定搬进类型系统,让拼错的字符串在编译期就变红,而不是等运行时才发现 onClck 根本没绑上。

模板字面量类型用反引号 + ${} 在类型层面拼接字符串,配 Capitalize 等内置工具,还能让联合类型笛卡尔展开。用于约束 CSS 值、事件名、构建类型安全的事件系统等。
// 约束字符串形状
type CSSValue = `${number}px` | `${number}rem`;

// 联合展开:4 个方向 → 4 个属性名
type Dir    = "top" | "right" | "bottom" | "left";
type Margin = `margin-${Dir}`;  // "margin-top" | ... | "margin-left"

// 实战:类型安全的事件处理器对象
type EventMap = { click: MouseEvent; input: InputEvent };
type Handlers = {
  [K in keyof EventMap as `on${Capitalize<K>}`]?: (e: EventMap[K]) => void;
};
// { onClick?: (e: MouseEvent) => void; onInput?: ... }
${number} 比想象中宽松:"1.5px"、"1e3px"、"-5px"、甚至 "0x10px" 都算合法(tsc 7 全部通过),它挡不住负数与科学计数法,别把它当成「正整数像素」的强校验。另一个坑是组合爆炸:多个联合用模板拼接会做笛卡尔积,成员数超过约十万就直接报 TS2590: Expression produces a union type that is too complex to represent。
模板字面量类型 + as 重映射组合,能从一份「数据形状」自动派生出 getter 接口、事件处理器接口、表单字段类型等——一处定义、多处类型安全,是高级库(如表单/ORM)类型推断的常见手法。

TypeScript · 类的增强

类在 TS 里多出来的东西:五个修饰符、参数属性、implements 与 override、抽象类、accessor 与装饰器,一张表列全并标出哪些有运行时代价。

这些语法有一半只活在编译期(擦除后消失),另一半会真的产出代码。下表最后一列专门标这件事——它决定了产物体积与运行时行为。

成员修饰符与语法

写法含义运行时
public默认,可省略无
private仅编译期私有无——obj["x"] 照样读得到
protected自身与子类可访问无
#x运行时真私有(JS 语法,11 章)有
readonly只在构造器里可赋值无
static类成员有
abstract抽象类 / 抽象成员,不能实例化类保留、抽象成员消失
override标明覆盖父类成员无(配 noImplicitOverride 才强制)
implements I声明遵守某接口,只做检查不带来实现无
constructor(private x: number)参数属性:声明 + 赋值一步到位有(生成赋值语句)
declare x: number只声明类型,不产出字段无
accessor x = 1自动生成 getter/setter 对(TS 4.9)有
x!: number确定赋值断言:告诉编译器「别管,它会被赋值」无
class C<T>泛型类无
this 作为返回类型多态 this,链式调用在子类里仍返回子类无

装饰器(TS 5.0 起对齐 ES 提案)

位置签名要点
类装饰器(value, context) => 新类 | void
方法 / getter / setter可替换实现,context.addInitializer 可在实例化时执行
字段返回一个初始化函数来改写初值
accessor 字段可同时拦截读写
与旧版的区别旧的 experimentalDecorators 是另一套签名,两者不兼容;Angular、NestJS、TypeORM 等仍依赖旧版
元数据新版不再依赖 reflect-metadata,用 context.metadata
// 参数属性:这一行等于「声明字段 + 构造器里赋值」
class Service {
  constructor(private readonly repo: Repo, public name: string) {}
}

// implements 只检查形状,不给实现;成员仍要自己写
interface Closable { close(): void }
class Conn implements Closable {
  close() {}
}

// 多态 this:子类链式调用不会退化成父类类型
class Builder {
  step(): this { return this; }
}
implements 不会帮你推断成员类型。class C implements I 里的方法参数仍需自己标注,漏标就是隐式 any(noImplicitAny 才会报错)——这一点和 Java 的直觉正好相反。想要「按接口自动获得类型」,应该用类型标注变量:const c: I = { … }。
需要「别人真的读不到」时用 #,需要「团队约定不要碰」时用 private。前者是运行时强制的,代价是无法用 JSON.stringify 序列化、调试时看起来更绕;后者零成本但只是君子协定。存凭据、内部句柄一类用 #,普通实现细节用 private 足够。

同样是给字段做封装,TS 在 JS 类之上叠了一整套编译期修饰符;下面讲它们各自管到哪,以及「参数属性」这个能省掉一半样板代码的语法糖。

TS 给类加了编译期访问控制:public(默认)、private(仅本类)、protected(本类+子类)、readonly(仅构造时赋值)。参数属性:在构造函数参数前加修饰符,自动声明并赋值同名字段。abstract 类不能实例化、只能被继承,implements 约束类实现某接口。
class Account {
  private balance: number = 0;       // 仅本类(编译期检查)
  protected id: string = "";        // 本类 + 子类
  readonly createdAt = new Date();    // 仅构造时可赋值

  // 参数属性:自动声明 + 赋值 this.owner
  constructor(public owner: string) {}

  deposit(n: number): this {      // 返回 this → 可链式
    this.balance += n; return this;
  }
}

// 抽象类:子类必须实现 abstract 成员
abstract class Animal {
  abstract speak(): string;
}
TS 的 private 只是编译期检查,编译成 JS 后字段仍可访问。需要运行时真私有请用 JS 原生的 #field(11 章「类、原型与元编程」已覆盖)。两者别混淆:private 给类型安全,# 给真实封装。
构造函数参数前加修饰符(public/private/protected/readonly)就是参数属性,一行同时完成「声明字段 + 赋值」,省掉 this.x = x 样板;还能叠加,如 constructor(public readonly x: number) 既公开又只读(类外改 x 报 TS2540)。而 abstract 类只能被继承,直接 new 报 TS2511: Cannot create an instance of an abstract class。

装饰器是 NestJS、Angular 这些框架的命门,但 TS 里它「标准」和「传统」两套实现并存——动手前先分清用的是哪套,能省掉大半玄学报错。

装饰器用 @ 给类及成员附加行为,是 NestJS、TypeORM、Angular 等框架的核心机制。它有两套互不兼容的写法,先分清再动手:标准装饰器(ECMAScript 提案,TS 5.0 起内置,无需任何编译选项,签名 (value, context))是新代码的默认选择,下例即是;传统装饰器(需开 experimentalDecorators,签名 target, key, descriptor)是 TS 早年的自有实现,NestJS/TypeORM/Angular 等框架目前仍依赖它——接入这些框架时照它们的文档走。
// 标准装饰器:TS 5.0+ 开箱可用,不需要 experimentalDecorators

// 方法装饰器:包裹原方法,记录日志
function log<T extends (...args: any[]) => any>(
  original: T,
  context: ClassMethodDecoratorContext
) {
  // 标准装饰器:返回一个新函数来替换原方法
  return function (this: any, ...args: any[]) {
    console.log(`调用 ${String(context.name)}`, args);
    return original.apply(this, args);
  };
}

class Service {
  value = 42;
  @log
  getValue() { return this.value; }
}
两套装饰器的报错会精准暴露你用错了模式。在标准装饰器(默认)下写参数装饰器直接报 TS1206: Decorators are not valid here——标准提案暂不支持参数装饰器;而开了 experimentalDecorators 后,若仍用标准的双参签名 (value, context) 去写参数装饰器,又会报 TS1239: ...the runtime will invoke the decorator with 3 arguments, but the decorator expects 2——传统参数装饰器要的是 (target, key, index) 三参。看报错码就能反推该切哪一套。
传统装饰器的等价写法是 function log(target: any, key: string, desc: PropertyDescriptor)——改写 desc.value 而不是返回新函数。看到这个签名就知道是老写法,需要 tsconfig 里开 experimentalDecorators。

TypeScript · 工程实战

落地要处理的四件事:tsconfig 选项全表、给没有类型的库补声明、把类型一路带到 API 层、以及项目里最容易踩的配置陷阱。

tsconfig 的选项有上百个,真正每个项目都要拿主意的是下面这些。按用途分四组查。

输出与语言级别

选项作用常见取值
target产出哪一版语法es2022 起步;只降语法不补 API
lib编译器认识哪些内置 API不写则跟随 target;浏览器项目加 dom
module产出哪种模块格式打包器用 esnext,Node 用 nodenext
moduleResolutionimport 怎么找文件bundler 或 nodenext,与运行环境对齐
jsxJSX 编译方式React 17+ 用 react-jsx
outDir / rootDir产物与源码目录—
declaration / declarationMap产出 .d.ts 与其 source map发布库时必开
sourceMap调试映射回 TS 源码开发必开
noEmit只检查不产出转译交给 esbuild / swc 时用

严格检查(strict: true 一键打开这一组)

选项挡住什么
noImplicitAny没标类型又推不出来的参数
strictNullChecks最有价值的一项:null/undefined 不再能随便赋值
strictFunctionTypes参数逆变检查(23 章)
strictPropertyInitialization类字段没被赋值就使用
strictBindCallApplycall/apply 的参数类型
useUnknownInCatchVariablescatch (e) 里的 e 变成 unknown
noUncheckedIndexedAccess不在 strict 里:arr[i] 带上 undefined,最贴近现实
exactOptionalPropertyTypes区分「没有这个键」与「值是 undefined」
noImplicitOverride / noFallthroughCasesInSwitch / noUnusedLocals三个便宜好用的额外检查

互操作与性能

选项作用
esModuleInterop让 import x from "cjs-pkg" 正常工作,基本必开
verbatimModuleSyntax不擅自增删 import;配合 import type 使用
isolatedModules保证每个文件可独立转译(esbuild / swc 要求)
skipLibCheck跳过 .d.ts 之间的检查,显著提速
paths路径别名;值要写相对路径,并需打包器同步配置
incremental / 项目引用大仓库的增量编译
allowJs / checkJs让 .js 参与编译 / 参与检查(JSDoc 路线)
resolveJsonModule允许 import JSON

常用命令

命令用途
tsc --init生成带注释的配置骨架
tsc --noEmitCI 里的类型门禁
tsc --watch持续检查
tsc --showConfig看合并 extends 之后的最终配置
tsc --traceResolution排查「找不到模块」:打印每一步查找路径
tsc --generateTrace dir编译太慢时定位是哪个类型拖的
// 一份现代应用的最小配置(交给打包器构建)
{
  "compilerOptions": {
    "target": "es2022",
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "noEmit": true,
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src"]
}
「明明文件在,却报找不到模块」几乎总是 moduleResolution 与实际运行方式不匹配。交给 Vite / webpack 构建却写了 node16、直接用 Node 跑却写了 bundler,都会让解析规则和现实对不上;后者还会让相对导入是否要带 .js 后缀的要求反过来。排查时先跑 tsc --traceResolution,它会把每一步找过的路径打出来。
三个开关的性价比最高:strict、noUncheckedIndexedAccess、skipLibCheck。第一个是 TS 的价值本身;第二个让 arr[i] 带上 undefined,从此不会再假设「取到的一定有值」;第三个在大项目上能把检查时间砍掉一大截,而它跳过的只是第三方声明文件之间的自洽性检查。

给没类型的 JS 库补类型、给 window 挂全局字段、给第三方接口的类型加一列——这些「改别人的类型」需求,都靠声明文件和模块增强来解决。

.d.ts 只含类型、不含实现。declare module "x" 为纯 JS 库补类型;declare global 给全局(window/全局变量)加类型;模块增强(同名 declare module + interface 合并)给第三方类型加字段。import type 只导入类型,编译后被擦除、不产生运行时依赖。
// 为纯 JS 库提供类型(my-lib.d.ts)
declare module "my-lib" {
  export function compute(input: number): number;
}

// 给全局加类型
declare global {
  interface Window { __APP_CONFIG__: { apiUrl: string }; }
}

// 模块增强:给 express 的 Request 加字段
// 注意:要增强全局 Express 命名空间,而非 "express" 模块
declare global {
  namespace Express {
    interface Request { userId?: string; }
  }
}

// 仅类型导入:编译后擦除,无运行时副作用
import type { User } from "./types";
declare global 只能出现在模块里。如果文件没有任何顶层 import/export,它会被当成全局脚本,此时写 declare global 直接报 TS2669: Augmentations for the global scope can only be directly nested in external modules or ambient module declarations。解法很简单:给文件补一句 export {}; 把它变成模块。
import type 编译后整行被擦除,不产生任何运行时 require/import——「只拿来做类型标注、不想引入运行时依赖」的导入用它最稳(把它当值用会报 TS1361);配 verbatimModuleSyntax: true 可强制团队显式区分值导入与类型导入。给纯 JS 库补类型时,.d.ts 只写签名、不写实现。

手写 fetch 时返回值往往是 any,一路裸奔到组件里才崩;把请求体和响应类型用泛型串起来,调用处一处标注、全链路推断,是 TS 在工程里回报最高的模式之一。

落地形态通常是一个泛型请求函数 request<TReq, TRes>(url, body: TReq): Promise<TRes>,各接口只在调用处标注一次。但要清楚泛型只是断言不是校验——后端悄悄改了字段,类型系统一无所知,要挡住这类变化得再配一层 zod 之类的运行时校验。
interface ApiConfig<TBody = unknown> {
  method: "GET" | "POST";
  url: string;
  body?: TBody;
}

async function request<TRes, TBody = never>(
  cfg: ApiConfig<TBody>
): Promise<TRes> {
  const res = await fetch(cfg.url, {
    method: cfg.method,
    body: cfg.body ? JSON.stringify(cfg.body) : undefined,
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<TRes>;
}

// 使用:全程类型推断
const users = await request<User[]>({ method: "GET", url: "/api/users" });
别被泛型的类型安全假象骗了:res.json() 的返回类型其实是 Promise<any>(),代码里的 as Promise<TRes> 只是一句无校验的断言——后端真返回了别的形状,TS 一声不吭、直到运行时才崩。类型安全到网络边界就断了,要真正闭环得在这里接 Zod 等运行时校验。
把响应类型放第一个泛型参数、请求体放带默认值的第二个(request<TRes, TBody = never>),调用处就能只写 request<User[]>(...) 省掉 body 泛型;GET 这类没有 body 的请求用 never 兜底,还能顺带在类型层面禁掉「误传 body」。

一份 tsconfig 决定了 TS 到底帮你挡多少 bug、以及 import 能不能被正确解析——这里挑出最影响成败的几个字段。

必开 "strict": true(含 noImplicitAny、strictNullChecks 等所有严格检查)——这是 TS 价值的核心。另一组决定成败的是模块字段:module / moduleResolution 管「import 怎么被解析」——经打包器(Vite/webpack)构建选 "bundler";代码交给 Node 直接运行选 "nodenext"(此时相对导入必须写全 .js 后缀)。「明明文件在却找不到模块」「默认导出报错」这类玄学问题,九成出在这组字段与实际运行环境不匹配。另注意:baseUrl 在 TS 7 已被移除(报 TS5102),paths 从 4.1 起就不再依赖它,但此时映射值必须是相对路径(./src/*),否则报 TS5090。
{
  "compilerOptions": {
    "target": "ES2022",           // 输出语法基准
    "module": "ESNext",           // Node 直跑则改 "nodenext"
    "moduleResolution": "bundler",  // 与 module 配套:bundler / nodenext

    "strict": true,                  // 核心,开启全部严格检查
    "noUncheckedIndexedAccess": true, // arr[i] 推断为 T | undefined
    "noUnusedLocals": true,

    "verbatimModuleSyntax": true,   // import type 写了才留,消除导入歧义
    "skipLibCheck": true,           // 不检查 .d.ts,显著提速,几乎必开
    "paths": { "@/*": ["./src/*"] }, // 路径别名;没有 baseUrl 时必须写成相对路径

    "noEmit": true              // 只查类型,产物交给 Vite/esbuild
  }
}
别以为 "strict": true 就把检查开满了:它只含 noImplicitAny、strictNullChecks 等一组,并不包含 noUncheckedIndexedAccess、exactOptionalPropertyTypes、noImplicitOverride 这些(仅开 strict 时 arr[0] 仍被推断为 number 而非 number | undefined,补开该项后才报 TS2322)。想要数组越界、可选属性这类额外保护,得在 strict 之外逐个显式打开。
新项目直接 strict: true 起步。给老项目迁移时,不要「先开 strict 再关掉报错多的子项」——strictNullChecks 一关会污染整个项目的类型推断,是最难再开回来的一项。正确做法是保持 strict 全开,按文件推进:暂时改不动的文件顶部加 // @ts-nocheck,改完一个删一个。allowJs + 逐文件从 .js 改名为 .ts 也是同一思路;noUncheckedIndexedAccess 虽然啰嗦,却能挡住大量「数组越界拿到 undefined」的运行时错误,强烈建议开。

TS 用久了会沉淀一批「就该这么写」的小技巧,下面把最高频的四个 satisfies、typeof、as const、Zod 放在一起,顺带点出它们各自的边界。

四个高频技巧:satisfies(既校验类型又保留精确推断,TS 4.9+)、typeof(从值取类型)、as const+索引(从对象推出字面量联合)、Zod(运行时校验外部数据并反推 TS 类型)。: 类型 注解会把变量按声明类型对待、丢失更精确的字面信息;satisfies 仅校验是否符合该类型而不改变其推断结果,从而兼顾约束与精度。
// satisfies:校验但不拓宽类型,保留精确推断
const palette = {
  red: [255, 0, 0], green: "#0f0",
} satisfies Record<string, string | number[]>;
// palette.red 仍是 number[],不被拓宽成联合类型

// typeof + as const 从对象推字面量联合:见 21 章「枚举 enum」卡的替代②

// Zod:运行时校验 + 自动推断类型(一举两得)
import { z } from "zod";
const UserSchema = z.object({ id: z.number(), name: z.string() });
type User = z.infer<typeof UserSchema>;
const user = UserSchema.parse(apiResponse);  // 运行时验证
两套装饰器不能混用——同一个项目里 experimentalDecorators 开或不开是全局的,签名写错会得到「装饰器不能应用于此声明」一类的报错。判断依据很简单:看框架文档,NestJS/TypeORM 走传统,新写的工具函数走标准。

TS 只在编译期检查,对运行时数据无能为力。API 返回的数据、表单输入、JSON.parse 的结果,类型注解都只是「假设」——一旦后端改了字段,TS 不会报错但运行时会崩。边界处用 Zod 等做运行时校验,才能真正闭环类型安全。
记牢 satisfies 和 : 类型 注解的分工:注解会把变量拓宽成声明类型、丢掉字面量精度;satisfies(TS 4.9+)只校验符不符合、不改变推断。所以 const cfg = {...} satisfies Config 既能挡住拼错的键,又让 cfg.red 保留 number[] 而不被拓宽成联合。想「既要校验又要精确」时就选它。

运行时与包管理工具链

命令速查:三个运行时的对应命令、npm / pnpm / yarn / bun 四套包管理器的等价写法、package.json 字段与 semver 记号。

四套包管理器做的是同一件事,只是命令拼写不同。下表按任务横向对齐,换工具时照抄即可。

命令对照

任务npmpnpmyarnbun
装全部依赖npm ipnpm iyarnbun i
严格按锁文件装(CI)npm cipnpm i --frozen-lockfileyarn --immutablebun i --frozen-lockfile
加依赖npm i pkgpnpm add pkgyarn add pkgbun add pkg
加开发依赖npm i -D pkgpnpm add -D pkgyarn add -D pkgbun add -d pkg
删依赖npm rm pkgpnpm rm pkgyarn remove pkgbun rm pkg
跑脚本npm run devpnpm devyarn devbun run dev
临时执行包npx pkgpnpm dlx pkgyarn dlx pkgbunx pkg
看过时依赖npm outdatedpnpm outdatedyarn outdatedbun outdated
安全审计npm auditpnpm audityarn npm auditbun audit
工作区(monorepo)npm -w pkg <cmd>pnpm -F pkg <cmd>yarn workspace pkg <cmd>bun --filter pkg <cmd>

semver 记号

写法允许升到
^1.2.3< 2.0.0(默认,主版本不动)
~1.2.3< 1.3.0(只跟补丁)
1.2.3锁死
^0.2.3< 0.3.0——0.x 版本里次版本号被当作主版本对待
* / latest任意版本,生产项目别用

锁文件(package-lock.json / pnpm-lock.yaml)记录的是实际装了哪一个版本,必须提交进仓库;CI 上用带 frozen 的命令,保证与本地完全一致。

三个运行时的等价命令

任务NodeDenoBun
跑文件node a.jsdeno run a.tsbun a.ts
改动重跑node --watch a.jsdeno run --watch a.tsbun --watch a.ts
跑测试node --testdeno testbun test
装依赖npm i按需自动缓存 / deno addbun i
权限模型无限制默认拒绝,需 --allow-net 等无限制
格式化 / lint外部工具deno fmt / deno lint 内置外部工具
// package.json 里最该有的几段
{
  "name": "my-app",
  "type": "module",
  "engines": { "node": ">=22" },
  "packageManager": "pnpm@10.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  }
}
npm i 与 npm ci 不能互换。前者会按 semver 范围更新锁文件,于是 CI 上装到的可能是你本地没测过的新版本——「本地好好的,线上炸了」的经典来源。CI 与生产构建一律用 npm ci(或对应的 frozen 命令),它只按锁文件装,锁文件与 package.json 不一致时直接报错。
用 packageManager 字段把工具与版本钉死。配合 Corepack,团队里每个人和 CI 用的都是同一个包管理器的同一个版本——避免「你 npm i 我 pnpm i」产生两份锁文件,也避免版本差异带来的依赖树差异。

在服务端 / 命令行跑 JS 有三个运行时:Node.js 是事实标准、生态最全;Deno 默认安全(文件 / 网络需显式授权)且原生跑 TS;Bun 主打极致启动速度与内置打包 / 测试。三者命令高度相似,下面各配一行对照。

# —— 查看版本 ——
node -v                        # 当前 LTS 是 v24.x;v20 已于 2026-04-30 停止维护
deno --version                 # Deno:含 V8 / TS 版本
bun --version                  # Bun:1.x
# —— 执行脚本 ——
node app.js                    # 新版 Node(22.18+/23.6+)也能直接 node app.ts(类型剥离)
deno run app.ts                # 直接跑 TS,联网要加 --allow-net
bun app.ts                     # 直接跑 TS / JS,启动极快
# —— REPL 交互环境 ——
node                           # 进入 REPL
deno repl
bun repl
# —— 监听变更自动重启 ——
node --watch app.js            # Node 18.11+
deno run --watch app.ts
bun --watch app.ts
node app.ts 只剥离类型,不检查类型。这是最容易误解的一点:把 const n: number = "字符串" 交给 node 跑,它照常打印、退出码 0——类型注解被当注释删掉了而已。「能 node 跑通」不等于「类型是对的」,类型检查始终得另跑 tsc --noEmit(见 29 章)。

同理,需要生成运行时代码的 TS 语法在剥离模式下直接报 ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX,最常撞上的是 enum(还有 namespace、传统装饰器、constructor(private x) 参数属性)——因为删掉它们就什么都不剩了。要么改用 as const 对象替代(21 章「枚举 enum」卡),要么改走 tsx / Deno / Bun。
新项目若重度使用 TypeScript,Deno / Bun 能省去编译配置;但要接入海量 npm 包与成熟 CI 生态,Node 仍是最稳的默认选择。

单个 .js 够用很久,但要用别人的库就需要一个「项目」。

三步建起一个项目

  • npm init -y 生成 package.json——项目的身份证,记录依赖清单和快捷命令;
  • npm install dayjs 做三件事:下载到 node_modules/、把包记进 dependencies、把精确版本写进 package-lock.json;然后代码里 import dayjs from "dayjs" 就能用(要让 import 生效,见下方陷阱);
  • scripts 字段给长命令起名字,npm run 名字 触发。拿到一个陌生项目,先看这里——它是「这个项目怎么跑」的唯一权威答案。

三个文件该怎么办

  • package.json——提交,手写编辑没问题;
  • package-lock.json——提交但不要手改。它锁定整棵依赖树的精确版本,是「我这儿能跑、你那儿跑不了」的解药;
  • node_modules/——写进 .gitignore,永不提交,它随时能由前两个文件重建。
# 建项目
mkdir my-app && cd my-app
npm init -y            # 生成 package.json
npm install dayjs      # 装依赖(简写 npm i dayjs)

// package.json 大致长这样:
// {
//   "name": "my-app",
//   "type": "module",            ← npm init -y 生成的是 "commonjs",改成这个才能用 import
//   "scripts": {
//     "dev": "node --watch app.js"
//   },
//   "dependencies": { "dayjs": "^1.11.13" }
// }

npm run dev            # 触发 scripts.dev
npx create-vite my-app # 临时借用一个包,用完不留(30 章)
Cannot use import statement outside a module 的触发时机很反直觉:裸建一个 .js 写 import,Node 22+ 会自动放行;但只要目录里有一个不含 "type": "module" 的 package.json,同一份代码立刻报错。
拿到别人的项目,标准开场是:npm install 装齐依赖,然后翻 package.json 的 scripts 看有哪些命令可用。不要因为 node_modules/ 不在仓库里就以为项目不完整——它本来就不该在。

package.json 是项目的「身份证」:记录依赖、脚本命令与元信息。scripts 字段里定义的命令用 npm run <名> 触发(其中 start / test 可省略 run)。

node app.js                    # 执行 JS 文件
node -e "console.log(1+1)"       # 直接执行一段代码
node --watch app.js            # 监听变化自动重启
node --inspect app.js          # 开启调试器(chrome://inspect)
# package.json 的 scripts:
# "scripts": { "dev": "vite", "build": "tsc && vite build" }
npm run dev                    # 运行自定义脚本
npm test                       # = npm run test(可省 run)
npm start                      # = npm run start
npm run                        # 不带脚本名:列出本项目所有可用脚本 ← 拿到陌生项目先敲这个
给脚本传参必须加 -- 分隔,否则参数被 npm 自己吃掉,而且不报错。假设 "dev": "node show.js":
· npm run dev -- --port 3000 → 脚本收到 ["--port", "3000"] ✅
· npm run dev --port 3000 → 脚本只收到 ["3000"],--port 被 npm 当成自己的选项静默吞掉 ❌
这类「参数莫名其妙丢了一半」的问题极难排查,习惯性写上 -- 就好。

另外,pre / post 前缀的脚本会被自动带跑:定义了 prebuild 和 postbuild,一句 npm run build 会依次执行三个。这是特性不是 bug,但如果你不知道,就会困惑于「我没调用过的命令为什么执行了」——反过来也别把普通脚本随手命名成 predeploy 之类。
npm run 不带任何参数,会把本项目所有脚本连同它们的真实命令一起列出来——拿到一个陌生项目,这比翻 package.json 更快,也顺带告诉你「npm run dev 背后到底跑的是什么」。

三者都读写 package.json,命令大同小异:npm 随 Node 自带、最通用;pnpm 用硬链接 + 内容寻址,省磁盘、装得快、依赖隔离严格;yarn 早期以速度和 lockfile 出名。下面按用途逐条对照。

# 初始化 package.json
npm init -y      / pnpm init          / yarn init      # -y 仅 Yarn Classic;Berry 忽略它
# 安装全部依赖
npm install      / pnpm install       / yarn
# 添加生产依赖
npm i lodash     / pnpm add lodash    / yarn add lodash
# 添加开发依赖
npm i -D eslint  / pnpm add -D eslint / yarn add -D eslint
# 全局安装
npm i -g tsx     / pnpm add -g tsx    / yarn global add tsx  # global add 仅 Yarn Classic;Berry 用 dlx
# 移除依赖
npm rm lodash    / pnpm remove lodash / yarn remove lodash
# 运行脚本
npm run dev      / pnpm dev           / yarn dev
# 临时执行包(免安装)
npx vite         / pnpm dlx vite      / yarn dlx vite
一个项目只用一种包管理器——混用会生成多份 lockfile(package-lock.json / pnpm-lock.yaml / yarn.lock),导致依赖树不一致。lockfile 必须提交到 Git,保证团队与 CI 装到完全相同的版本。
CI 里该用的是 npm ci,不是 npm install(pnpm / yarn 的对应写法是 pnpm install --frozen-lockfile / yarn install --immutable)。区别在于:npm install 会为了满足 package.json 而顺手改写 lockfile,于是「锁定版本」形同虚设;npm ci 则删掉 node_modules 严格按 lockfile 重装,一旦两个文件对不上就直接失败(npm error code EUSAGE ... can only install packages when your package.json and package-lock.json are in sync)。这个「宁可失败也不悄悄改」正是 CI 想要的行为——本地 npm i 加了依赖却忘了提交 lockfile,CI 会当场拦下,而不是装出一棵和你不同的依赖树。

npx(pnpm 用 dlx、yarn 也用 dlx)临时下载并运行一个包,用完即弃、不污染全局环境——最常用于脚手架这类一次性工具。

npx create-vite my-app         # 脚手架创建项目
npx tsx script.ts              # 免装直接跑 TS
npx eslint .                   # 跑临时的 eslint
pnpm dlx create-vite my-app    # pnpm 等价写法
yarn dlx create-vite my-app    # yarn 等价写法
npx 包名 在本地找不到时,会直接从 registry 下载并执行它。这意味着敲错一个字母,你就运行了一个陌生作者的任意代码——而抢注相似包名(typosquatting)是 npm 生态的常见攻击手法,create-vite 与 create-vitte 只差一个字母。

所以:脚手架命令建议从官方文档复制,不要凭记忆手敲;不确定时先 npm view 包名 看看发布者、下载量和更新时间。交互式终端里 npx 下载前会提示 Ok to proceed? (y),但在 CI 等非交互环境中不会问,别把这道提示当成安全网。
npm install 是「把工具装到我家里」,npx 是「借用一下,用完归还」。脚手架、代码生成器等一次性命令一律用 npx / dlx。

还有个容易忽略的行为:包已经装在本项目里时,npx 优先跑本地那份,不会去下载。所以在项目里敲 npx eslint . 用的是 package.json 锁定的版本,和 CI 一致——这正是你想要的。

写了几十行以上的东西就该有测试,而这件事现在不需要装任何包——Node 自带测试运行器和断言库。等项目长大、需要浏览器环境或更强的 mock 时,再换 vitest 也不迟。

零依赖起步:node --test

  • 从 node:test 导入 test / describe / mock,断言用 node:assert/strict(strict 那个子路径很关键——不带它的 assert.equal 用的是宽松相等——assert.equal("1", 1) 在 node:assert 下通过,在 node:assert/strict 下抛 ERR_ASSERTION)。
  • 跑一句 node --test 就行,输出是 TAP 格式,末尾给 # pass / # fail 汇总。两个用例一过一败时,失败那条会带上文件与行号、code: 'ERR_TEST_FAILURE'。
  • 覆盖率也内置:node --test --experimental-test-coverage,直接打出 all files 的行/分支/函数三档百分比,不用装 c8 或 nyc。
  • 能直接测 .ts——e.test.ts 被照常发现并跑通(走类型剥离)。但请记住 21 章那条:剥离不等于检查,类型错误照样跑过,tsc --noEmit 这一步不能省。

文件发现规则:认 .test. 不认 .spec.

  • 放四个文件跑 node --test:a.test.mjs、b_test.mjs、test/c.mjs 三个被跑到,而 d.spec.mjs 一个都没跑——汇总里就是 # tests 3。
  • 这是最容易白忙一场的地方:从 jest / vitest 项目搬过来的 *.spec.ts 命名,在 node --test 下会静默地一个都不执行,而它不报错、还显示全绿。改名成 *.test.*,或者显式把路径传给命令。

什么时候该换 vitest

  • 要浏览器环境:测 DOM、测组件,需要 jsdom / happy-dom 的一整套接线。
  • 要和构建共用一套配置:vitest 直接吃 vite 的 config,路径别名、TS 路径映射、环境变量一处配置两边生效——这是它相对内置运行器最实在的优势。
  • 要更强的 mock 与快照:这条差距比过去小了很多——Node 24 的内置运行器已经带了 mock.fn / mock.method、定时器 mock(t.mock.timers)与快照(t.assert.snapshot),连模块级 mock 也有了(t.mock.module,需加 --experimental-test-module-mocks)。vitest 仍胜在 inline snapshot、浏览器侧的 mock 与整条生态工具链。
  • 反过来,写 CLI 工具、纯函数库、后端服务的单元测试,内置的就够了,还省掉一整棵依赖树。

下面讲的是「怎么把测试跑起来」。「怎么写」——用例组织与钩子、断言选型、异步为什么是假通过的重灾区、mock 与假定时器、TS 的类型怎么测——见 32 章「测试:从怎么跑到怎么写」。

// sum.test.mjs —— 零依赖,node --test 直接跑
import { test, describe } from "node:test";
import assert from "node:assert/strict";   // 注意 /strict

const sum = (a, b) => a + b;

describe("sum", () => {
  test("加起来", () => {
    assert.equal(sum(1, 2), 3);
  });
});

// 命令行
// node --test                              跑全部
// node --test --watch                      改了就重跑
// node --test --experimental-test-coverage 带覆盖率
// node --test sum.test.mjs                 只跑这一个
「全绿」不等于「跑过了」——文件名不符合发现规则时,node --test 会一个用例都不跑却正常退出,CI 上看起来一片通过——一个只放 *.spec.mjs 的目录跑 node --test,输出 # tests 0 而退出码是 0。养成看汇总行 # tests N 的习惯,数字对不上就是发现规则的问题。同理,用 assert 而不是 assert/strict 时,assert.equal("1", 1) 会通过——这条断言等于没写。
起步的顺序建议是先用内置的把测试写起来,别一上来就纠结选哪个框架——node:test 的 test / describe / assert 三件套和 vitest 几乎同形,将来真要迁移,改的是导入语句而不是用例本身。把 "test": "node --test" 写进 package.json 的 scripts(见本章前面那张卡),门槛就归零了。

Web Components:不依赖框架的组件

原生组件的 API 面:自定义元素的生命周期回调、Shadow DOM 与 slot、属性与特性的映射规则,以及与框架互操作的坑。

Web Components 由三块拼成:自定义元素(行为)、Shadow DOM(样式与结构隔离)、<template> 与 <slot>(内容分发)。API 面不大,一张表能列完。

自定义元素

成员时机 / 作用
customElements.define("my-el", C)注册;名字必须带连字符
constructor()只做最基本的初始化,此时不能碰属性与子节点
connectedCallback()插入文档时——渲染、绑事件放这里
disconnectedCallback()移出文档时——解绑、清定时器
attributeChangedCallback(n, old, val)被观察的特性变化时
static observedAttributes = […]声明要观察哪些特性,不写就不会回调
adoptedCallback()被移动到另一个文档时(少见)
static formAssociated = true让元素能参与表单,配 attachInternals()
customElements.whenDefined(name)等待某个元素注册完成

Shadow DOM 与内容分发

写法说明
this.attachShadow({ mode: "open" })open 外部可通过 el.shadowRoot 访问;closed 不可
<slot> / <slot name="x">默认插槽 / 具名插槽
::slotted(sel)从内部给插进来的内容加样式(只能选中顶层节点)
:host / :host(.cls)选中宿主元素自身
::part(name) + part="name"作者主动开放给外部定制的样式口
CSS 自定义属性能穿透 Shadow 边界,是最常用的主题化手段
adoptedStyleSheets多个实例共享一份样式表,避免重复注入
slotchange 事件插槽内容变化时触发

特性(attribute)与属性(property)

attributeproperty
写在 HTML 上可以不行
类型只能是字符串任意值(对象、数组、函数)
读写getAttribute / setAttributeel.foo
变化通知attributeChangedCallback自己写 setter

约定是两边同步:属性 setter 里回写特性,attributeChangedCallback 里更新内部状态——这样 HTML 里写和 JS 里设都能生效。传对象 / 数组只能走属性。

// 一个最小但完整的自定义元素
class CounterEl extends HTMLElement {
  static observedAttributes = ["count"];
  #root = this.attachShadow({ mode: "open" });

  get count() { return Number(this.getAttribute("count") ?? 0); }
  set count(v) { this.setAttribute("count", String(v)); }   // 属性回写特性

  connectedCallback() { this.render(); }
  attributeChangedCallback() { this.render(); }
  render() { this.#root.innerHTML = "<button>" + this.count + "</button>"; }
}
customElements.define("x-counter", CounterEl);

// 对外发事件:composed 为 true 才能穿出 Shadow 边界
this.dispatchEvent(new CustomEvent("change", { detail: v, bubbles: true, composed: true }));
事件默认穿不出 Shadow 边界。new CustomEvent("x", { bubbles: true }) 在 Shadow DOM 里发出后,外面收不到——还必须加 composed: true。相对地,原生的 click 等事件天生是 composed 的,所以「为什么点击能冒泡出来、我的自定义事件却不行」的答案就在这里。
要跨框架复用的基础组件(设计系统、嵌入式挂件)才值得用原生方案。它不依赖任何框架版本,十年后仍能跑。业务界面还是交给框架——原生方案缺少响应式与模板,纯手写 innerHTML 很快就会失控,需要时用 Lit 补上这一层。

Web Components 不是一个库,而是三个互相独立的浏览器特性:自定义元素(定义新标签)、Shadow DOM(样式与结构隔离)、以及 <template>。三者可以单独用,也可以合起来用。

自定义元素的四个生命周期

  • constructor:创建实例时。此时不能碰属性与子节点(元素可能还没插进文档),只做最基本的初始化;
  • connectedCallback:被插入文档时。渲染与事件监听写在这里——注意它可能被调用多次(元素被移动时会先断开再连接);
  • disconnectedCallback:被移出文档时,解绑事件、清定时器;
  • attributeChangedCallback(name, old, new):被观察的属性变化时。要先用静态的 observedAttributes 声明观察哪些属性,没声明的属性变化不会有回调——这是第一个坑。

Shadow DOM:真正的样式隔离

  • attachShadow({ mode: "open" }) 之后,内部的 DOM 与样式与外界互不影响:外面的 .title { color: red } 进不来,里面的样式也漏不出去;
  • 这是 CSS Modules 之类方案给不了的隔离(那些只是给类名加哈希,继承属性照样穿透);
  • 代价是定制变难:使用者无法用普通选择器改你内部的样式,只能通过你显式暴露的接口——::part()(暴露某个内部节点)与 CSS 自定义属性(CSS 变量是能穿透 Shadow 边界的少数几样东西之一);
  • <slot> 用来接收外部传进来的内容,插槽内容仍然属于外部作用域(样式由外部控制)——这个「谁的作用域」的划分是 Shadow DOM 里最容易搞混的一点。

体积:

  • 一个用纯原生 API 写的计数器组件(含 Shadow DOM 与样式),esbuild 打包压缩后 gzip 仅约 0.2 KB;
  • 这是所有方案里的下限——没有运行时、没有框架、没有依赖;
  • 本章后面会看到:加上 Lit 是 5.9 KB,用成品组件库是 18 KB 起步。三档之间差的不是「好不好用」,而是「你自己要写多少」。
// 原生自定义元素:不需要任何依赖
class MyCounter extends HTMLElement {
  static observedAttributes = ["start"];      // 不声明就不会有回调

  connectedCallback() {
    const root = this.attachShadow({ mode: "open" });
    let n = Number(this.getAttribute("start") ?? 0);
    root.innerHTML = "<style>button{padding:4px 8px}</style><button></button>";
    const btn = root.querySelector("button");
    btn.textContent = String(n);
    btn.addEventListener("click", () => {
      btn.textContent = String(++n);
      // 对外通信用事件,composed: true 才能穿出 Shadow 边界
      this.dispatchEvent(new CustomEvent("change", { detail: n, bubbles: true, composed: true }));
    });
  }
}
customElements.define("my-counter", MyCounter);   // 名字必须带连字符

<!-- 用起来就是一个标签,任何框架里都能写 -->
<my-counter start="3"></my-counter>

/* 外部只能通过这两个口子定制内部样式 */
my-counter::part(button) { border-radius: 999px; }
my-counter { --gap: 8px; }                /* CSS 变量能穿透 Shadow 边界 */
Shadow DOM 里的事件默认穿不出去。普通事件冒泡到 shadow root 就停了,外部监听不到;要穿出去必须同时设 bubbles: true 和 composed: true。而且穿出去之后,event.target 会被重定向(retarget)成你的宿主元素而不是内部的按钮——这是设计(保护封装),但会让「为什么 target 不是我点的那个东西」困惑很久。需要知道内部细节就用 event.composedPath()[0]。
自定义元素的标签名必须包含连字符(my-counter 合法,counter 不合法)——这是规范用来避免与未来的原生标签冲突的硬规定,写错会直接抛 SyntaxError。顺带一提:浏览器对未知标签是宽容的,所以一个还没注册的 <my-counter> 会先按普通行内元素渲染,注册后才「活过来」——这就是所谓的「升级(upgrade)」。

原生 API 能用,但缺两样东西:「属性变了自动重渲染」和「高效地更新 DOM 的一小块」。自己实现这两样就是在写一个小框架——Lit(3.3.3)就是那个已经写好的小框架,gzip 约 5.9 KB。

它加了什么

  • 响应式属性:声明 static properties,赋值即触发重渲染,还自动处理属性(attribute,字符串)与特性(property,任意类型)的转换;
  • 模板:基于标签模板字符串的 html`…`,只更新变化的那部分——它靠模板字面量的「静态部分不变」这一特性把动态槽位记下来,更新时只改槽位,不重建 DOM;
  • 样式:static styles = css`…`,会用 Constructable Stylesheet 在同类实例间共享一份样式对象,不是每个实例复制一遍;
  • 生命周期更好用:willUpdate / updated / firstUpdated,以及一个 updateComplete 的 Promise(测试时等它就行,不用 setTimeout 猜)。

体积对照(本机 esbuild 打包 + gzip)

方案mingzip
纯原生自定义元素(一个计数器)0.3 KB0.2 KB
Lit 3.3.3(同一个计数器)15.3 KB5.9 KB
Shoelace 2.20.1:只引一个 Button62.2 KB18.2 KB
Shoelace:六个常用组件163.3 KB43.4 KB
Shoelace:整包引入458.7 KB105.6 KB

注意这些数字里不含任何框架运行时——Web Components 的产物就是这些。作为对照,一个空白 React 应用本身就要 58 KB gzip。

什么时候值得用 Web Components

  • 要给多个技术栈复用同一套组件(公司里有 React 也有 Vue 也有老页面)——这是它最不可替代的场景;
  • 要嵌进别人的页面(第三方挂件、评论组件、支付按钮)——Shadow DOM 的样式隔离在这里是刚需;
  • 做设计系统的底层:用 WC 实现一次,再为各框架出一层薄封装;
  • 不适合:单一框架的普通业务应用——框架自己的组件模型更顺手,生态也更全。
// Lit:同一个计数器,响应式与模板都由它管
import { LitElement, html, css } from "lit";

class MyCounter extends LitElement {
  static properties = { n: { type: Number } };      // 声明即响应式
  static styles = css`button { padding: 4px 8px; }`;   // 同类实例共享一份

  constructor() { super(); this.n = 0; }

  render() {
    return html`<button @click=${() => this.n++}>${this.n}</button>`;
  }
}
customElements.define("my-counter", MyCounter);

// 测试时等这个 Promise,别用 setTimeout 猜
el.n = 5;
await el.updateComplete;
expect(el.shadowRoot.querySelector("button").textContent).toBe("5");
属性(attribute)与特性(property)是两回事,这是 Web Components 最大的日常摩擦。HTML 属性只能是字符串:<my-list items="[1,2,3]"> 传进去的是一个字符串,不是数组。要传对象/数组/函数,只能走 property(el.items = [1,2,3])或框架里的对应语法。症状是「传进去的数据变成了 [object Object]」——看到这个字符串就知道走错了通道。
Lit 的模板之所以能「只更新变化的部分」,用的正是标签模板字符串的一个特性:同一处代码的静态片段数组是同一个对象(引擎会缓存它)。于是 Lit 可以拿这个数组当模板的身份标识,第一次渲染时记下动态槽位的位置,之后只更新槽位。这是本页 06 章讲的标签模板在真实框架里最漂亮的一个应用——回头看那一章会更有体感。

「任何框架都能用」在语法层面成立,在细节上有三处摩擦。知道这三处,接入成本就从「踩一周坑」变成「查一次文档」。

① 传数据:属性还是特性

  • Vue:对自定义元素会自动判断——如果元素实例上存在同名属性就走 property,否则走 attribute。所以 :items="arr" 通常直接能用;
  • React 19 起也支持了:会优先设置 property(这修复了 React 长期以来「什么都当字符串塞进 attribute」的问题)。但老版本 React(18 及以前)不会,只能用 ref 手动赋值——网上大量教程写的是这个旧方案;
  • Svelte / Solid / Angular 各有各的语法(prop: 前缀之类),用之前查一下对应文档的「custom elements」一节。

② 收事件:自定义事件不走框架的合成系统

  • React 的 onFoo 只对它认识的事件名有效,自定义事件(my-change)必须用 addEventListener 手动监听(React 19 起对自定义元素的事件支持有所改善,但跨版本仍需确认);
  • Vue 里 @my-change 可以直接用;
  • 命名建议全小写加连字符(value-change)——大小写在 HTML 属性里会被规范化,混用会导致某些框架监听不到。

③ 服务端渲染:Shadow DOM 天生是客户端的

  • 自定义元素的升级发生在浏览器里,服务端渲染出来的只是一个空标签——首屏会看到布局塌陷或闪一下;
  • 规范给了答案:声明式 Shadow DOM(<template shadowrootmode="open">),让服务端能直接吐出 shadow 内容,浏览器解析时自动附加;
  • Lit 提供了配套的 SSR 方案,但生态整体不如框架自带的 SSR 成熟。判据:如果项目对首屏与 SEO 敏感,Web Components 要慎重,或只用在非首屏区域。
// ① 传复杂数据:属性只能是字符串,对象要走 property
<my-list items='[1,2,3]'></my-list>      // ❌ 传进去的是字符串
el.items = [1, 2, 3];                         // ✅ property

// React 18 及以前的做法(很多教程停在这里)
const ref = useRef(null);
useEffect(() => { ref.current.items = arr; }, [arr]);
return <my-list ref={ref} />;

// ② 自定义事件:React 里要手动监听
useEffect(() => {
  const el = ref.current;
  const h = (e) => setValue(e.detail);
  el.addEventListener("value-change", h);
  return () => el.removeEventListener("value-change", h);
}, []);

<!-- ③ 声明式 Shadow DOM:让服务端也能吐出 shadow 内容 -->
<my-card>
  <template shadowrootmode="open">
    <style>:host { display: block; }</style>
    <slot></slot>
  </template>
  <p>这段内容属于外部作用域</p>
</my-card>
表单里的自定义元素默认不参与表单提交。<my-input> 里的值不会出现在 FormData 里,回车也不触发提交、原生校验也覆盖不到它。规范给的解法是表单关联自定义元素(static formAssociated = true + ElementInternals 的 setFormValue()),但很多社区组件没有实现。选组件库时如果要用在表单里,先做一个最小验证:放进 <form> 里提交一次,看 FormData 有没有那个字段。
给自定义元素写 TypeScript 类型时,把它声明进全局的 HTMLElementTagNameMap,document.querySelector("my-counter") 就能推出正确类型;再配合各框架的 JSX 类型扩展(React 的 JSX.IntrinsicElements、Vue 的 GlobalComponents),模板里也能有补全。这一步做不做,决定了团队用起来是「有类型的组件」还是「一堆 any」。

测试:从怎么跑到怎么写

测试 API 对照:node:test 与 vitest 的等价写法、断言选型表、异步与计时器的处理,以及类型本身怎么测。

两套主流工具的概念一一对应,换工具时改的只是 import。真正要挑对的是断言——用错一个 equal 就会得到「明明一样却不通过」。

结构与钩子

做什么vitest / jestnode:test
一条测试test(name, fn) / ittest(name, fn)
分组describedescribe
前后钩子beforeEach / afterEach / beforeAll / afterAll同名四个
只跑这条 / 跳过test.only / test.skip同左,或 { only: true }
参数化test.each(rows)自己写循环
期望失败test.fails{ todo: true }
跑起来vitest run / vitest(watch)node --test / node --test --watch
覆盖率vitest run --coveragenode --test --experimental-test-coverage

断言选型

要断言什么vitestnode:assert
原始值相等expect(a).toBe(b)assert.strictEqual
对象 / 数组内容相等toEqual(忽略 undefined 字段)/ toStrictEqualassert.deepStrictEqual
包含toContain / toMatchObject手写
真假 / 空toBeTruthy / toBeNull / toBeUndefinedassert.ok
浮点数toBeCloseTo自行比较误差
抛错expect(fn).toThrow(msg)assert.throws
异步拒绝await expect(p).rejects.toThrow()await assert.rejects(p)
快照toMatchSnapshot / toMatchInlineSnapshot—
调用情况toHaveBeenCalledWith / toHaveBeenCalledTimesmock.calls 自行断言

替身与时间

需求vitestnode:test
造一个假函数vi.fn()mock.fn()
替换对象的方法vi.spyOn(obj, "m")mock.method(obj, "m")
替换整个模块vi.mock("./m")mock.module("./m")
还原vi.restoreAllMocks()mock.reset()
假定时器vi.useFakeTimers() + vi.advanceTimersByTime(1000)mock.timers.enable() + tick(1000)
// 对象比较用 toEqual / deepStrictEqual —— toBe 比的是同一个引用
expect({ a: 1 }).toBe({ a: 1 });        // ❌ 永远失败
expect({ a: 1 }).toEqual({ a: 1 });     // ✅

// 异步断言:忘了 await,测试会「假通过」
await expect(load("bad")).rejects.toThrow("404");

// 假定时器:把「等一秒」变成零秒
vi.useFakeTimers();
startPolling();
vi.advanceTimersByTime(3000);
expect(fetchMock).toHaveBeenCalledTimes(3);
vi.useRealTimers();
异步测试忘了 await 或忘了 return,会「假通过」。测试函数同步返回后框架就判定通过,里面的断言即使失败也只是变成一条未处理拒绝,报告上一片绿。凡是碰异步的测试,测试函数必须是 async 且每个 Promise 都被 await;对「应该抛错」的用例更要留意——漏掉 await 时它连错误都收不到。
测行为,不测实现。断言「调用后返回值 / 状态是什么」,而不是「内部调用了哪个私有方法几次」——后者会让每次重构都伴随一堆无意义的测试修改。mock 只用在边界上:网络、时间、随机数、文件系统。

一条测试永远是三段:准备数据、执行被测代码、断言结果(业内叫 AAA:Arrange-Act-Assert)。node:test 提供的 test / describe / 四个钩子,和 vitest、jest 几乎同形——学会这一套,换框架只是换导入语句。

结构:test 与 describe

  • test(名字, 回调) 是一条用例;it 是 test 的别名,写 it("应该返回 3", ...) 只是让句子读起来通顺,功能完全一样;
  • describe(名字, 回调) 把相关用例分成一组,输出里会缩进成树;组可以嵌套;
  • 用例名要写成一句话的结论——「空数组时返回 0」远胜「测试 sum」。测试失败时你先看到的是这行名字,它应该直接告诉你坏了什么。

四个钩子,以及它们各跑几次

  • before / after:整组只跑一次,用于开销大且可共享的准备(起一个测试数据库、建一个临时目录);
  • beforeEach / afterEach:每条用例前后各跑一次,用于把状态复位——这是保证用例之间互不影响的关键;
  • 两条用例的一组,输出顺序是:before → beforeEach → 用例1 → afterEach → beforeEach → 用例2 → afterEach → after;
  • 钩子写在 describe 里只作用于该组,写在文件顶层作用于整个文件。

筛选:skip / todo / only,第三个有坑

  • test.skip:跳过不跑,汇总里进 skipped,输出标 # SKIP;
  • test.todo:占位「还没写」,输出是个 ✔ 但不计入 pass,而是单独计入 todo——用它标记待补的用例,比注释掉强;
  • test.only:必须配合命令行 --test-only 才生效。不加这个标志时,only 的和非 only 的全都照跑(tests 2),而且不报错、不警告;加上之后才只跑一条(tests 1)。以为在单跑一条、其实全跑了,是调试时最费解的一种「怎么还是这个错」。

并发:同文件内默认串行

  • 三条各 sleep(100) 的用例:默认串行,共 330 ms;给 describe 传 { concurrency: true } 后并发,共 107 ms;
  • 但不同测试文件之间 Node 默认就是并行跑的——所以两个测试文件抢同一个端口、同一个临时目录或同一张表,会得到「单独跑都过、一起跑就挂」的经典幻觉。共享外部资源时给每个文件分配独立的名字。
import { test, describe, before, beforeEach, after, afterEach } from "node:test";
import assert from "node:assert/strict";

describe("购物车", () => {
  let cart;

  before(() => 连接测试数据库());   // 整组一次
  beforeEach(() => { cart = []; }); // 每条用例前复位状态 ← 关键
  afterEach(() => 清空表());       // 每条用例后
  after(() => 关闭连接());        // 整组一次

  test("空车总价为 0", () => {      // 用例名写成结论,不是"测试总价"
    assert.equal(total(cart), 0);
  });

  test("两件相加", () => {
    cart.push({ price: 10 }, { price: 5 }); // 准备
    const sum = total(cart);                 // 执行
    assert.equal(sum, 15);                    // 断言
  });
});

// —— 筛选 ——
test.skip("暂时跳过", () => {});   // 进 skipped,输出标 # SKIP
test.todo("还没写");              // 输出是 ✔,但计入 todo 不计 pass
test.only("只跑这条", () => {});   // ⚠ 必须 node --test --test-only

// —— 并发:默认串行 330ms → 并发 107ms(三条各 sleep 100ms)——
describe("慢用例", { concurrency: true }, () => { /* ... */ });
用例之间共享可变状态,是「单独跑都过、一起跑就挂」的头号成因。把 const cart = [] 提到 describe 外面,第一条用例往里 push 的东西会留给第二条——顺序一换结果就变,而测试框架不保证你能永远靠顺序吃饭(加了 concurrency 更是直接乱套)。状态一律在 beforeEach 里重新构造。

另一个:test.only 不加 --test-only 静默失效,非 only 的用例照跑且毫无提示。把 "test:only": "node --test --test-only" 写进 scripts,或者干脆用 node --test 文件名 只跑一个文件。
beforeEach 里做复位,而不是 afterEach。两者看起来对称,实际不是:某条用例中途抛错时,afterEach 虽然仍会执行,但如果它自己依赖那条用例没跑完的状态就会连环失败;而 beforeEach 是「无论上一条留下什么烂摊子,我先把桌面擦干净」,天然更稳。afterEach 留给必须释放的外部资源(关连接、删临时文件)。

断言写错的代价是它根本没在验你以为的那件事——不报错、显示全绿。node:assert/strict 一共就几个方法,把选择规则记死即可。

第一条规则:比对象和数组只能用 deep 版

  • assert.equal(实际, 期望) 用的是 ===——对对象、数组、Map、Set 一律是引用比较。assert.equal([1, 2], [1, 2]) 失败,Node 还专门给了句提示:Values have same structure but are not reference-equal(结构一样但不是同一个引用);
  • 要比内容用 assert.deepStrictEqual(实际, 期望)——[1, 2] 与 [1, 2] 通过,两个内容相同的 new Map([["k", 1]]) 也通过;
  • 记法:基本类型用 equal,其余一律 deepStrictEqual。

第二条:/strict 那个子路径不能省

  • 从 node:assert 导入时,equal 是 == 宽松相等——assert.equal("1", 1) 通过;从 node:assert/strict 导入才是 ===,同一句正常失败;
  • deepEqual 同理:宽松版忽略原型,{ a: 1 } 和一个同结构的类实例比通过;deepStrictEqual 则失败——它要求原型也相同。「返回的是 User 实例而不是普通对象」这类回归,只有 strict 版拦得住。

第三条:deepStrictEqual 是第五套相等语义

  • 18 章讲过 JS 的四套相等(== / === / Object.is / SameValueZero)。deepStrictEqual 对基本类型用的不是 ===,而是 Object.is 那一套:deepStrictEqual(NaN, NaN) 通过(=== 是 false)、deepStrictEqual(0, -0) 失败(=== 是 true);
  • 还有一条最容易查半天的:值为 undefined 的属性算差异。{ a: 1 } 与 { a: 1, b: undefined } 不相等——严格版宽松版都不相等。函数里写了 obj.b = maybe() 而 maybe() 返回 undefined,就会撞上它;想忽略这类差异,把对象先过一遍 JSON.parse(JSON.stringify(x)),或改断言具体字段。

剩下几个:throws / rejects / match / snapshot

  • assert.throws(函数, 期望) 断言同步抛错,第二个参数可以是构造器(TypeError)、正则或 { message: "..." };被测代码没抛时它自己失败,报 Missing expected exception——注意传的是函数本身而不是调用结果,写成 assert.throws(f()) 会先把错抛在断言外面;
  • await assert.rejects(异步函数或 Promise, 期望) 是它的异步版,见本章后面;
  • assert.match(字符串, 正则) 比 assert(re.test(s)) 好——失败时会打印实际字符串,后者只会告诉你「表达式为假」;
  • 快照:t.assert.snapshot(值)(Node 22+ 内置)把结构化输出存进 文件名.snapshot。首次运行必须带 --test-update-snapshots,否则直接报 ERR_INVALID_STATE: Cannot read snapshot file;生成后再跑就是内容比对。适合大块输出(渲染结果、序列化配置),不适合替代具体断言。
import assert from "node:assert/strict";  // ← /strict 不能省

// —— 对象 / 数组:equal 比的是引用 ——
assert.equal([1, 2], [1, 2]);            // ❌ 失败:结构相同但不是同一引用
assert.deepStrictEqual([1, 2], [1, 2]);  // ✅

// —— 宽松 vs 严格(导入路径决定)——
// node:assert        → assert.equal("1", 1) 通过    ← 这条断言等于没写
// node:assert/strict → assert.equal("1", 1) 失败    ← 要的就是这个

// —— deepStrictEqual 用 Object.is 语义,不是 === ——
assert.deepStrictEqual(NaN, NaN);  // ✅ 通过(NaN === NaN 是 false)
assert.deepStrictEqual(0, -0);      // ❌ 失败(0 === -0 是 true)

// —— undefined 属性算差异,排查起来最费时 ——
assert.deepStrictEqual({ a: 1 }, { a: 1, b: undefined }); // ❌ 不相等

// —— 抛错:传函数,别传调用结果 ——
assert.throws(() => parse("坏数据"), TypeError);   // ✅
assert.throws(parse("坏数据"));                   // ❌ 错抛在断言外面了

assert.match(id, /^user_\d+$/);  // 失败时会打印实际字符串

// —— 快照:首跑必须 node --test --test-update-snapshots ——
test("渲染结果", (t) => { t.assert.snapshot(render(data)); });
最隐蔽的一种失效:断言写在了根本执行不到的地方。典型是把断言放进 try 块里而 catch 是空的——被测代码抛错时断言被跳过,catch 把异常吃掉,用例显示通过。要断言「不该抛错」就什么都不写(抛了自然会让用例失败),要断言「应该抛错」就用 assert.throws,两者都不需要手写 try/catch。
断言的参数顺序是「实际值在前,期望值在后」:assert.equal(实际, 期望)。写反了测试照样能过(相等就是相等),但失败信息会把两边说反——本来该说「期望 3、实际得到 5」,变成「期望 5、实际得到 3」,排查时先怀疑错地方。

另外 assert(表达式) 这种真值断言尽量少用:它失败时只会说 The expression evaluated to a falsy value,不告诉你实际值是多少。能用 equal / deepStrictEqual / match 就别用它。

同步测试要么过要么挂,很难骗人;异步测试则可以在断言根本没执行的情况下显示通过。下面把三种「少写一个 await」的后果一遍——它们的共同点是失败信息不出现在失败的那条用例上。

三种合法的等待方式

  • async 回调:test("...", async () => { ... })——最常用,里面照常 await;
  • 返回 Promise:回调不写 async 但 return 一个 Promise,运行器同样会等(一条返回 new Promise(r => setTimeout(r, 50)) 的用例耗时 62 ms,确实等满了);
  • done 回调:回调写成 (t, done) => { ... },用于包不了 Promise 的老式回调 API,完成时调 done(),出错时 done(err)。忘了调 done 就会一直挂到超时——给 { timeout: 300 } 的用例在 307 ms 时失败。这至少是个响亮的失败,不会假通过。

最坑的一种:忘了 await 断言

  • assert.rejects 返回的是 Promise。写成 assert.rejects(fn) 不 await,断言的成败就跟这条用例脱钩了;
  • 让它去断言一个根本不会 reject 的函数(本该失败):那条用例显示 ✔ 通过,汇总里也算 pass;真正的失败晚一步才浮出来,报在文件层级:Test "..." generated asynchronous activity after the test ended. This activity created the error "AssertionError: Missing expected rejection." and would have caused the test to fail, but instead triggered an unhandledRejection event;
  • 也就是说你会看到「所有用例都 ✔,但文件 ✖、CI 红了」这种自相矛盾的输出。看到「generated asynchronous activity after the test ended」就一个思路:某个 await 漏了;
  • 同类漏法还有:await 忘在被测函数前(断言拿到的是 Promise 对象而不是值,deepStrictEqual 报你一个 Promise { ... })、以及 forEach 里写 await(04 章讲过,回调根本不被等)。

断言「应该失败」:用 rejects,别手写 try/catch

  • await assert.rejects(异步函数或 Promise, 期望)——期望可以是构造器、正则或 { message: "..." },await assert.rejects(boom, { message: "炸了" }) 正常通过;
  • 对应的「不该抛」不需要写任何断言:真抛了用例自然就红了;
  • 手写 try { await f(); } catch { /* 期望走这里 */ } 的问题是:f() 没抛时 catch 不执行,用例照样通过——这条用例什么都没验证。非要手写就在 try 末尾补一句 assert.fail("本该抛错")。
import { test } from "node:test";
import assert from "node:assert/strict";

// ✅ async 回调
test("取到用户", async () => {
  const u = await fetchUser(1);
  assert.deepStrictEqual(u, { id: 1, name: "Tom" });
});

// ❌ 漏了 await:断言与用例脱钩
test("坏数据应当抛错", () => {
  assert.rejects(parse("坏数据"));   // 用例显示 ✔,文件却 ✖
});                                // → generated asynchronous activity
                                   //   after the test ended
// ✅ 补上 await 与 async
test("坏数据应当抛错", async () => {
  await assert.rejects(() => parse("坏数据"), { message: "坏数据" });
});

// ❌ 手写 try/catch:没抛时 catch 不执行,用例照过
test("手写版的坑", async () => {
  try {
    await parse("坏数据");
    assert.fail("本该抛错");      // ← 少了这句,没抛错也算通过
  } catch (e) {
    assert.match(e.message, /坏数据/);
  }
});

// 老式回调 API:done;忘了调就挂到超时(timeout:300 → 307ms 失败)
test("回调风格", { timeout: 300 }, (t, done) => {
  readOld("a.txt", (err, data) => {
    assert.equal(data, "hi");
    done(err);                    // 出错就把 err 交给它
  });
});
别用真 setTimeout 等异步结果。「等 100 ms 应该就好了」在本机成立,在 CI 上不一定——机器一慢就随机红,机器一快又白等,这就是所谓的不稳定测试(flaky test),比失败更消耗团队信任。要么等一个明确的信号(Promise、事件、轮询到条件成立),要么用本章后面的假定时器把时间直接推过去。真 sleep 只应该出现在「就是要测超时行为」的用例里。
把「所有用例 ✔ 但文件 ✖」当成一条固定诊断信号:它几乎只由「测试结束后还有异步活动」引起,而根因基本都是漏 await。排查时不用逐条读用例,直接搜这个文件里所有 assert.rejects / assert.doesNotReject 前面有没有 await,以及回调是不是漏写了 async。

ESLint 的 require-await 和 TypeScript 的 no-floating-promises(@typescript-eslint)能在写的时候就把这类漏网 Promise 标出来,比事后读输出便宜得多。

两件事测起来最别扭:「有没有调到」(发了邮件没?写库了没?)和 「过一段时间才发生」(防抖、重试、超时)。前者用 mock,后者用假定时器——都在 node:test 里内置,不用装包。

mock.fn:一个能自查的假函数

  • const fn = mock.fn() 造一个假函数,也可以传实现 mock.fn((a, b) => a + b);
  • 调用记录挂在 fn.mock 上,调两次后:fn.mock.callCount() 得 2,fn.mock.calls[1].arguments 得 [3, 4],fn.mock.calls[1].result 得 7;
  • 于是「回调被调了几次、第几次收到什么参数」都能直接断言——这正是测事件、测钩子、测订阅时最需要的。

mock.method:替换真实方法,但还原规则有个坑

  • t.mock.method(对象, "方法名", 假实现) 把对象上的方法换掉,被换后的方法自身也带 .mock,可以继续查调用次数;
  • t.mock(用例参数上的)在用例结束时自动还原,顶层导入的 mock 不会。同一个共享对象:用 t.mock.method 替换,下一条用例读到的是真实现;改用顶层 mock.method 替换,下一条用例读到的还是假的——污染就这么泄漏出去了,直到手动调 mock.restoreAll() 才恢复;
  • 结论:一律用 t.mock(回调签名写成 (t) => { ... })。真要用顶层 mock,就配一个 afterEach(() => mock.restoreAll())。

假定时器:时间由你推(测防抖)

  • t.mock.timers.enable({ apis: ["setTimeout"] }) 之后,setTimeout 不再真的等待,只有你调 tick(毫秒) 时时间才前进;
  • 拿 14 章那个防抖函数(延迟 1000 ms):连点三次后立刻查,回调 0 次;tick(999) 后仍是 0 次;再 tick(1) 变成 1 次,且拿到的实参是 "c"——「多次触发只执行最后一次」这个语义被完整地验了出来,整条用例耗时是 0 ms;
  • apis 里还能放 "setInterval"、"Date"。enable({ apis: ["Date"], now: 0 }) 后 Date.now() 返回 0,tick(5000) 后返回 5000——凡是「结果里带时间戳」的快照测试,把 Date 冻住就不会每次都不一样了。
import { test, mock } from "node:test";
import assert from "node:assert/strict";

// —— mock.fn:断言"有没有被调、收到了什么" ——
test("提交时会通知一次", () => {
  const onSave = mock.fn();
  submit({ name: "Tom" }, onSave);

  assert.equal(onSave.mock.callCount(), 1);
  assert.deepStrictEqual(onSave.mock.calls[0].arguments, [{ name: "Tom" }]);
});

// —— t.mock.method:用例结束自动还原 ——
test("不真的写库", (t) => {              // ← 注意接住 t
  t.mock.method(db, "save", () => "ok");
  handle();
  assert.equal(db.save.mock.callCount(), 1);
});                                    // 出了这条用例,db.save 自动变回真的

// ❌ 顶层 mock 不会自动还原,泄漏给后面所有用例
// mock.method(db, "save", ...)   → 需要 afterEach(() => mock.restoreAll())

// —— 假定时器:测防抖,用例耗时 0ms ——
test("防抖只执行最后一次", (t) => {
  t.mock.timers.enable({ apis: ["setTimeout"] });

  const spy = mock.fn();
  const d = debounce(spy, 1000);
  d("a"); d("b"); d("c");

  assert.equal(spy.mock.callCount(), 0);   // 连点三次,还没到点
  t.mock.timers.tick(999);
  assert.equal(spy.mock.callCount(), 0);   // 差 1ms,仍未触发
  t.mock.timers.tick(1);
  assert.equal(spy.mock.callCount(), 1);   // 到点,只执行一次
  assert.deepStrictEqual(spy.mock.calls[0].arguments, ["c"]); // 用的是最后一次的参数
});

// —— 冻住时间:快照里带时间戳时必备 ——
// t.mock.timers.enable({ apis: ["Date"], now: 0 });
// Date.now() → 0 ;tick(5000) 后 → 5000
假定时器开了就得记住「时间不会自己走」。开启后 await new Promise(r => setTimeout(r, 10)) 会永远等下去——没人去 tick,那个定时器就永不触发,用例挂到超时才失败,而报错信息只说超时,完全不提是假定时器造成的。所以:只把真正需要的 api 放进 enable(要测 setTimeout 就别顺手把 Date 也冻上),并且在同一条用例里就把时间推完。
mock 要打在「边界」上,不要打在自己的业务逻辑上。值得替换的是你不拥有或代价高的东西:网络请求、数据库、文件系统、时间、随机数。把自家的纯函数也 mock 掉,测的就只剩「我调用了我自己」——重构一次全红,却抓不到任何真 bug。判据很简单:如果这个 mock 让你在改实现(而非改行为)时必须同步改测试,它就打错地方了。

TS 项目里有一条最容易漏的防线:测试跑绿不代表类型是对的。因为运行测试的那一步把类型注解直接删掉了,两者根本不在同一个环节。下面讲清楚该在哪一步拦什么,以及类型断言怎么写成能自动检查的测试。

先认清:node --test 跑 .ts,是剥离不是检查

  • Node 22.18+ 能直接跑 .ts,测试文件也一样——30 章那张卡过 e.test.ts 被正常发现并跑通;
  • 但它只删注解,不做任何类型检查。一个测试文件里写 const n: number = "这是字符串",node --test 照常 ✔ 通过、fail 0——类型错得再明显也拦不住;
  • 所以 CI 里必须是两步:tsc --noEmit 管类型、node --test 管行为。少了前一步,「测试全绿」给的是虚假的安全感(详见 29 章「工程实战」);
  • 顺带:需要生成运行时代码的语法(enum、装饰器、参数属性)在剥离模式下直接报 ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX,测试文件同样受这条限制。

@ts-expect-error:一条会自己失效的类型断言

  • 它的特别之处在于双向:下一行有类型错误 → 静默吃掉;下一行没有类型错误 → 它自己反过来报错。TS 7.0.2 下,给一个本来合法的调用加上这行注释,tsc --noEmit 报 error TS2578: Unused '@ts-expect-error' directive;
  • 这正是它能当断言用的原因:「这样写应该编译不过」也成了一条会被持续验证的测试。哪天你把类型放宽了、这个错误消失了,tsc 会立刻告诉你——而 @ts-ignore 没有这个反向检查,纯粹是闭嘴,所以任何时候都优先用 @ts-expect-error;
  • 惯例是给它写上原因:// @ts-expect-error 传数字应当被拒绝——这行注释就是用例名。

要测「类型推导出来是什么」,得靠工具

  • @ts-expect-error 只能测「该不该报错」,测不了「推导结果是不是恰好等于某个类型」。想断言后者,一种零依赖写法是造一个「相等则通过」的类型工具,让不匹配变成编译错误(见代码);
  • 生态方案是 vitest 的 expectTypeOf(配 vitest --typecheck),写法和普通断言同形,适合类型体操较多的库;
  • 判断标准:应用代码基本只需要 tsc --noEmit + 少量 @ts-expect-error;发布给别人用的库,公开 API 的类型才值得成套地测——因为对使用者来说,类型签名就是 API 本身。

顺带:模块级 mock 现在内置了(实验特性,)

  • 30 章那张卡说「模块级 mock 是换 vitest 的理由之一」,Node 24 已经补上了:t.mock.module("./dep.mjs", { exports: { ... } });
  • 必须带命令行标志 --experimental-test-module-mocks,不带时直接 TypeError: t.mock.module is not a function;带上后再 await import() 拿到的就是替身(返回值从 100 变成 999);
  • 它还是实验特性、会打 ExperimentalWarning,选型时按「实验」对待——但用来替掉网络层、时间源这类边界依赖已经完全可用。
// —— 1. 类型错误逃不过 tsc,却逃得过 node --test ——
const n: number = "这是字符串";   // node --test:✔ 通过;tsc --noEmit:报错

// —— 2. @ts-expect-error 当断言用 ——
function greet(name: string): string { return "hi " + name; }

// @ts-expect-error 传数字应当被拒绝
greet(123);        // ✅ 确实有错 → 注释吃掉它,tsc 通过

// @ts-expect-error 这行其实完全合法
greet("ok");       // ❌ 期望落空 → error TS2578:
                    //    Unused '@ts-expect-error' directive

// —— 3. 零依赖地断言"推导结果恰好是这个类型" ——
type Equal<A, B> =
  (<T>() => T extends A ? 1 : 2) extends
  (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;   // 不是 true 就编译不过

type _1 = Expect<Equal<ReturnType<typeof greet>, string>>;  // ✅
// type _2 = Expect<Equal<ReturnType<typeof greet>, number>>;   ← 这行会报错

// —— 4. 模块级 mock(需 --experimental-test-module-mocks)——
test("不打真实接口", async (t) => {
  t.mock.module("./price.mjs", { exports: { fetchPrice: () => 999 } });
  const { fetchPrice } = await import("./price.mjs");
  assert.equal(fetchPrice(), 999);   // 真实现返回 100
});

# CI 必须两步,缺一不可
# "scripts": { "check": "tsc --noEmit && node --test" }
@ts-ignore 是个只进不出的黑洞。它压制下一行的所有类型错误,而且错误消失后它自己毫无反应——于是代码库里会慢慢堆积一批「当年为了绕过某个问题加的、如今早就没必要」的 ignore,谁也不敢删。@ts-expect-error 天生自带回收机制:问题一修好,它就变成 TS2578 逼你删掉它。存量的 @ts-ignore 可以直接全局替换成 @ts-expect-error 再跑一遍 tsc,报出来的每一条 TS2578 都是一处可以当场清理的历史包袱。
把 tsc --noEmit 也当成一条测试看待,它抓的是另一类完全不同的 bug:测试验的是「这段代码在我想到的输入下表现正确」,类型验的是「这段代码在所有输入下不会被这样误用」。两者互补,谁也替不了谁——所以 package.json 里那条给 CI 用的脚本应该是 tsc --noEmit && node --test,用 && 串起来,前一步红了就不必往下跑。

查得更远:规范 · 兼容性 · 资料索引

本页查不到时去哪儿查:标准与提案的权威入口、TC39 的五个阶段各意味着什么、TypeScript 逐版本的特性年表,以及生态里各类工具的定位对照。

遇到「这个行为到底该是什么样」「这个 API 现在能不能用」,答案只在少数几个地方。下表按问题类型给入口。

查什么去哪儿

问题去哪儿说明
某个 API 怎么用、参数是什么MDN第一站;页头的 Baseline 徽章顺带回答兼容性
浏览器支持到什么程度caniuse.com按版本细分,可查市场占比
Node 支持哪些语言特性node.green按 Node 版本对照 ES 特性
语言行为的最终答案ECMA-262(规范正文)抽象操作写得极精确,但不适合当教程读
国际化 / 格式化行为ECMA-402Intl 全家的规范
DOM / fetch / 事件WHATWG 各 Living Standard浏览器 API 不在 ECMAScript 规范里
新特性到哪一步了TC39 proposals 仓库见下面的阶段表
TypeScript 每版改了什么TS 官方 Release Notes升级前必扫
某个包该不该用npm 页面 + 仓库 issue 活跃度看最近发布时间、未解决 issue、依赖数

TC39 的五个阶段

阶段含义能不能在生产里用
Stage 0 稻草人只是个想法不能
Stage 1 提案有人负责推进,写了初步方案不能
Stage 2 草案语法基本定了,开始写规范文本不建议(2.7 起形状趋于稳定)
Stage 3 候选规范完成,等待实现与反馈可谨慎尝鲜,但仍可能改
Stage 4 完成已被多个引擎实现,进入下一版标准可以,剩下的只是环境支持度问题

装饰器就是「阶段会反复」的典型:它在 Stage 2 停留多年、方案被整个推翻重做,早年基于旧提案写的代码(Angular、NestJS 那一套)与今天的标准装饰器互不兼容——这就是不在 Stage 3 之前押注的理由。

规范正文不是用来「读懂一个特性」的。ECMA-262 里满是 ToPrimitive、OrdinaryGet 这类抽象操作,逐条追下去往往越读越远。正确的用法是把它当裁判:MDN 与实测结果打架、两个引擎表现不一致时,再回规范定位那一段——日常学习和查用法应该在 MDN。
「能不能用」拆成两个独立问题:语法与 API。语法(可选链、类字段)可以由构建工具降级,所以只要提案够稳就能写;API(Object.groupBy、Set.prototype.union)不会被降级,必须目标环境真的有,或者引入 polyfill。查 Baseline 时也按这两类分别确认。

TS 大约每两三个月一版。下表只列会改变你写法的特性——查「这个语法要求哪个版本」时用它。

特性年表

版本带来什么
4.0可变长元组、具名元组元素
4.1模板字面量类型、键重映射(as)、递归条件类型
4.3getter / setter 可以是不同类型、override
4.4模式索引签名、useUnknownInCatchVariables
4.5Awaited、import type 内联写法
4.7变型注解 in / out、moduleResolution: node16、ESM 支持成熟
4.9satisfies、accessor 关键字
5.0标准装饰器、const 类型参数、所有枚举变成联合枚举
5.2using 声明(显式资源管理),配 Symbol.dispose
5.3导入属性 with { type: "json" }
5.4NoInfer、闭包里的收窄保持得更久
5.5自动推断类型谓词(简单 filter 不必再手写守卫)、isolatedDeclarations
5.6永真 / 永假表达式检查、正则语法检查
5.8更细粒度的返回表达式检查、erasableSyntaxOnly(配合 Node 直接跑 TS)
7.0编译器原生移植,检查速度提升一个数量级;同时移除若干长期废弃的选项(baseUrl 报 TS5102,paths 的值必须写成相对路径)

升级时的固定动作

步骤做什么
1读该版 Release Notes 的 Breaking Changes 一节
2先只升 typescript,跑 tsc --noEmit 看新增报错
3新报错多半是更严的检查发现了旧 bug,逐个看而不是加 any 压下去
4同步升级 @types/* 与 ESLint 的 TS 插件,版本要求常常联动
5编辑器用的是工作区版本还是内置版本,确认一致(VS Code 可切换)
升级后突然冒出的一堆报错,通常不是编译器的错。新版把某类不安全写法从「放过」改成「报错」,那些位置本来就有隐患。此时最坏的应对是全局加 any 或 @ts-ignore——它把编译期发现的问题重新推回运行时。真的一时改不完,也用 @ts-expect-error 而不是 @ts-ignore:前者在问题被修好之后会反过来提醒你这行注释可以删了。
学 TS 新特性最快的路径是逐版本读 Release Notes。官方每篇都配「以前怎么写、现在怎么写」的对照例子,比任何二手教程都准。挑三个对日常影响最大的先看:satisfies(4.9)、const 类型参数(5.0)、自动推断类型谓词(5.5)。

JS 生态的重复造轮子出了名,但每一类里真正的常用选项就那么几个。下表按要解决的问题分类,不按热度排。

构建与工具链

类别常见选项怎么选
开发服务器 / 打包Vite、Rspack、webpack、Parcel新项目默认 Vite;巨型存量项目考虑 Rspack 平滑替换 webpack
底层转译器esbuild、SWC、Babel前两个快,后者插件生态最全
库打包tsup、Rollup、unbuild出多格式产物 + .d.ts
Lint / 格式化ESLint + Prettier、Biome、oxlintBiome 一个工具全包且极快,生态规则不如 ESLint 全
测试Vitest、node:test、Playwright前两个跑单元测试,Playwright 跑端到端
monorepopnpm workspace + Turborepo / Nx包管理归 pnpm,任务编排与缓存归后者

框架与运行时

类别常见选项定位
前端框架React、Vue、Svelte、Solid本站各有专页;差异主要在响应式模型与心智负担
全栈框架Next.js、Nuxt、SvelteKit、SolidStart、Astro路由 + 服务端渲染 + 数据加载一体;内容站选 Astro
Node 服务端Fastify、Hono、NestJS、ExpressHono 跨运行时且极轻,NestJS 适合大团队的强约定
不用框架Web Components + Lit跨框架复用的基础组件(31 章)

常用库

要解决的问题选项
运行时校验 + 出类型zod、valibot(体积更小)
日期时间原生 Intl 与 Temporal 优先;需兼容旧环境用 day.js(16 章)
HTTP 客户端原生 fetch 够用;需要拦截器 / 进度用 ky、axios
状态管理zustand、jotai、nanostores(框架无关)
服务端数据缓存TanStack Query
表格 / 虚拟滚动TanStack Table / Virtual
端到端类型安全tRPC,或 zod 配合各框架的 server function
ORMDrizzle(贴近 SQL)、Prisma(约定更多)
工具函数先查标准库(19 章);确实缺才引 es-toolkit / remeda
按「谁的 star 多」选型,是踩坑的高发路径。star 数反映的是历史热度,而不是当前维护状态——很多明星库早已停更,issue 区堆着几百个未回复的问题。真正该看的是:最近一次发布距今多久、未解决 issue 的趋势、有没有明确的迁移路径。另外注意包体积(bundlephobia 之类可查)与它自身的依赖数量:一个引入十几个传递依赖的小工具,通常不值。
引一个依赖之前先问三个问题:标准库能不能做(structuredClone、Intl、URLSearchParams 已经替掉了一大批老库)、它最近一次发布是什么时候、出了问题我能不能读懂它的源码。三个都不理想的库,短期省下的时间会在升级和排障时连本带利还回来。