JavaScript 与 TypeScript 完整知识体系交互讲解

全景:JavaScript 与 TypeScript 的定位

钻进语法之前先回答三个问题:JS 与 TS 各解决什么问题、今天它们用在哪里、两者之间怎么选。后面每一章都是这张地图的放大。

JavaScript 是 Web 平台唯一的原生语言:只要代码要跑进浏览器,它就是绕不开的终点;而单线程 + 事件循环的并发模型,是它一切异步设计的根。

今天它占据哪些疆域

  • 浏览器:引擎军备竞赛把 JS 推成最快的动态语言之一;
  • 服务端三运行时并立:Node.js(事实标准)、Deno(安全默认 + 原生 TS)、Bun(极致启动速度);
  • 再加上桌面(Electron)、移动(React Native)与各类构建工具链。

和邻居怎么选

  • vs TypeScript:什么时候该上 TS 见下一张卡,本页 21 章起是 TS 的完整入门;
  • vs Go/Python:Node 胜在全栈同构与 npm 生态,I/O 密集服务是主场;CPU 密集任务它不占优。
// 单线程 + 事件循环:非阻塞是 JS 的性格底色
const fetchUser = async (id) => {
  const res = await fetch('/api/users/' + id);
  return res.json();
};

console.log('1 同步代码先执行');
fetchUser(42).then((u) => console.log('3 数据回来才轮到我', u.name));
console.log('2 不等网络,继续往下跑');
「单线程」常被误解成「一次只能干一件事、必然慢」。它只约束 JS 自身的执行:网络、文件、定时器这些 I/O 由宿主在后台并行处理,完成后才把回调塞回队列——所以 I/O 密集场景 Node 反而很能打。
ES2015 是分水岭(let/const、箭头函数、类、模块、Promise),本页以此为基准。学法:先把同步语义(作用域/闭包/this/原型)打牢,再攻异步语义(事件循环/Promise/async)——JS 的坑九成集中在这两条线上。

TypeScript 是 JS 之上的静态类型层:类型只在编译期存在,编译产物就是删掉注解的普通 JS(类型擦除)——它不改变任何运行时行为,只在你写代码时提前抓错。

今天它占据哪些疆域

  • 大型工程与团队协作的事实标配:VS Code、Angular 本身就是 TS 写的,主流框架默认出 TS 模板;
  • 类型驱动的库生态成型:zod 拿类型做运行时校验、tRPC 让前后端共享类型、Prisma 从库表生成类型。

和邻居怎么选

  • vs 纯 JS:一次性脚本、几百行小工具用纯 JS 更轻——类型的收益随代码规模与协作人数增长,几千行以上基本必上;
  • vs JSDoc:不想引入编译步骤时,JSDoc 注释 + checkJs 能拿到大半类型检查。
// 类型只在编译期存在:擦除后就是普通 JS
interface User { id: number; name: string }

function pluck<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const u: User = { id: 1, name: "Ada" };
const n = pluck(u, "name");   // n 被推断为 string
// pluck(u, "email");          // ❌ 编译期就报错,不等运行时
类型在运行时全被擦除,由此两件事必须记牢:接口拿回的 JSON 就算标了 User,运行期也不做任何校验,字段缺了会一路飘到很远才炸——外部数据要用 zod 这类在运行时真校验
结构化类型是它与 Java/C# 的根本区别:形状对上就兼容,不看类型名字。学法是把 TS 当「会说话的文档」——先让类型准确描述数据的形状,再用泛型消灭重复;任何时候写下 any,都要能说出为什么。

JavaScript · 跑通第一个程序

在讲任何语法之前,先让机器把你写的东西跑起来。装环境、跑通 Hello World、逐行看懂它,最后——也是最重要的——学会读报错。后面每一章都默认你已经有一个能立刻验证想法的环境。

JS 有个特别之处:你的电脑上很可能已经装好了——每个浏览器都内置了 JS 引擎。所以有两条路,先走哪条都行。

两条路

  • 浏览器控制台(零安装):打开任意网页按 F12(macOS 是 Cmd+Option+I)切到 Console,敲 1 + 1 回车看到 2 就成了。本页 02–14 章的绝大多数例子都能直接贴进去跑;
  • Node.js(写文件、做项目必须):到 nodejs.org 下 LTS 装上,然后新开一个终端(旧窗口读不到新 PATH)敲 node -v。21 章起的 TypeScript 部分需要它。

两条路跑不了对方的代码

它们是两个不同的宿主环境:语言核心相同,周边 API 各有各的。只有浏览器有 documentwindowlocalStorage(20 章);只有 Node 有 process 和文件读写 fs;两边都有的是语言本身,外加 fetchconsolesetTimeout所以看到 document is not defined,多半是把浏览器代码丢给 Node 跑了。

# —— 路线 B:装完 Node 后自检这三行 ——
node -v                # 今天下 LTS 会得到 v24.x。报「不是内部或外部命令」= 没装好或没重开终端
npm -v                 # npm 随 Node 一起装,无需单独安装
node -e "console.log(1 + 1)"   # 不建文件,直接跑一行 → 2

# —— 想要交互式环境(像浏览器控制台那样)——
node                   # 进入 REPL,敲一行算一行
# > 1 + 1
# 2
# 退出:连按两次 Ctrl+C,或输入 .exit
不要从搜索结果里随便找个安装包。Node 官网是 nodejs.org,npm 包管理器随它一起装好,不需要再单独安装 npm。网上大量「先装 npm 再装 node」的老教程会把你带进坑里。
版本别太旧。本页以 Node 22 为基准,Node 24(当前 Active LTS)完全兼容,装最新 LTS 即可

新建一个文件夹,建 hello.js,把右侧代码抄进去,终端 cd 过去执行 node hello.js。看到三行输出,环境就通了。

逐行拆解

  • const name = "World";——现代 JS 的默认选择就是 const,需要改动时才换 letvar 属于历史遗留(为什么见 02 章);
  • console.log(…)——console 是宿主提供的对象,浏览器和 Node 都有,所以这行两边都能跑;
  • 反引号包起来的是模板字符串,里面用 ${…} 直接嵌变量,比拼接清爽(03 章);
  • function greet(who) { … }——who 是参数,return 把结果交回调用方;
  • 行尾分号可省(JS 会自动补),本页统一写上,因为省略时有极少数会咬人的边界情况。

没有输出?

ls 确认终端当前目录里真有 hello.js;再确认文件名没被存成 hello.js.txt(Windows 默认隐藏扩展名,是高频陷阱)。报错不必紧张,下一张卡专讲怎么读。

// hello.js —— 你的第一个 JS 程序
const name = "World";

console.log("Hello, " + name + "!");

// 模板字符串:${} 里可以直接放变量
console.log(`Hello, ${name}!`);

// 函数:接收参数,返回结果
function greet(who) {
  return `Hello, ${who}!`;
}
console.log(greet("JS"));

// $ node hello.js
// Hello, World!
// Hello, World!
// Hello, JS!
从网页或文档里复制代码,最容易带进「智能引号」。" ' 抄成排版用的 “ ” ‘ ’,Node 会当场报 SyntaxError: Invalid or unexpected token、一行都跑不了。看到这个报错、而代码「明明没错」,先检查引号是不是弯的;重敲一遍或关掉编辑器的智能引号即可。
改一个字再跑一次——把名字改成你自己的,或者故意删掉一个引号看看会怎样。「改一点、跑一次」的循环建立起来,比读十页都快。

JS 的错误绝大多数是跑到那一行才炸——这正是 TypeScript 存在的理由。

报错的四个部分

  • ① 出错位置(文件:行号,并用 ^ 指出位置)——先看这个,通常就够了;② 错误类型 + 说明,最关键的一行;
  • ③ 调用栈从上往下读,第一个出现你自己文件名的那行才是你要看的,底下 node:internal/… 全是内部帧。

三种最常见的错

  • ReferenceError: x is not defined——这个名字根本不存在,九成是拼错或作用域不对;
  • TypeError: Cannot read properties of null——名字存在但值不是你以为的东西,JS 最高频的运行时错误,通常来自没检查的接口返回值;
  • SyntaxError——括号引号没配对,代码一行都没执行

一个反直觉之处

  • JS 不会因为你多传、少传参数而报错:少传的是 undefined,多传的被忽略——很多 bug 就是这样「不报错地错着」,一路飘到很远处才变成一个 TypeError。
// 新建 err.js 故意写错,看看 Node 会说什么:
//   const name = "World";
//   console.log(nn);        ← 第 2 行,把 name 敲成了 nn

// ① 用了不存在的名字
// D:\js-demo\err.js:2
// console.log(nn);
//             ^
//
// ReferenceError: nn is not defined
//     at Object.<anonymous> (D:\js-demo\err.js:2:13)   ← 只看这行
//     at Module._compile (node:internal/modules/cjs/loader:1705:14)
//     ... 以下全是 Node 内部帧,跳过
// Node.js v22.22.2

// ② 值不是你以为的东西(const s = null; console.log(s.length);)
// TypeError: Cannot read properties of null (reading 'length')
//     at Object.<anonymous> (D:\js-demo\err.js:2:15)

// ③ 括号没闭合:一行都没跑
// console.log("hi"
//             ^^^^
// SyntaxError: missing ) after argument list
别把 console.log 当调试的唯一手段。浏览器的 Sources 面板、Node 的 --inspect 都能打断点、逐行走、看每个变量当前的值——花二十分钟学会打断点,能省下后面几百次 console.log
看到 Cannot read properties of undefined,先问「这个值本该从哪来」——十有八九是接口返回里没有那个字段,或 DOM 还没渲染出来。

JavaScript · 变量与作用域

理解 let / const / var 的本质区别,是写出稳妥代码的第一步。现代 JS 工程中应优先使用 const,需要重新赋值时用 let,几乎不再使用 var。

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

var函数作用域,会被提升(hoisting)到函数顶部,且可以重复声明,容易造成意外覆盖。letconst块级作用域({}内有效),存在「暂时性死区」(TDZ)——在声明之前访问会直接报错,而不是像 var 那样得到 undefinedconst 声明的是常量绑定,意味着不能重新赋值,但如果值是对象或数组,其内部属性仍然可以修改。
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) { } // ❌ 严格模式禁止重复参数名
严格模式最容易咬人的一处:普通函数直接调用时,函数体里的 thisundefined;非严格模式下它会指向全局对象(Node 里是 globalThis)。由于 ES 模块和 class 默认严格,把一段老代码搬进模块后,原本靠 this 拿全局的写法会突然抛 Cannot read properties of undefined
现代项目用 ES Module 或 class 时已自动严格模式,通常不需要手写这行,但理解它能帮你看懂为什么某些「老式写法」在现代代码里会报错。

JavaScript · 数据类型与转换

JS 是动态类型语言,理解类型系统和隐式转换规则,能避免大量 == 与拼接产生的诡异 bug。

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

JS 有 7 种基本类型(存值,不可变):NumberStringBooleanundefinednullSymbol(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),判 NaNNumber.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 码元而非字符:"😀".length2"😀".slice(0, 1) 会切出半个乱码;要按人眼字符处理,先用 [...str] 展开([..."😀"].length1)。
拼接多个变量或带换行的文本,优先用模板字符串而非 +;只判断「是否包含」用 includesindexOf(...) !== -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`...` 能防注入,是因为库把插值收集成参数单独交给数据库;若你自己写的标签只是把 stringsvalues 拼回一个字符串,它就和普通 + 拼接一样危险。别看到反引号就以为安全。
标签函数收到的 strings 数组长度总比插值多 1(两端各有一段,可能是空串)。用 String.raw 写正则或 Windows 路径可免去反斜杠转义。

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

JS 只有一种数字类型 number(64 位双精度浮点,整数小数不分家;超大整数另有 BigInt)。算术运算符 + - * / % 与幂 **/ 不取整,要整除配 Math.floor/Math.trunc浮点精度:十进制小数无法被二进制精确表示,0.1 + 0.20.30000000000000004——展示用 toFixed,比较用误差容忍。字符串转数字用 Number()(严格)或 parseInt/parseFloat(宽松,从头解析)。BigInt:需要超过 2^53-1Number.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_INTEGER9007199254740991)就会悄悄失精9007199254740992 + 1 仍等于 9007199254740992,加了跟没加一样。后端用 int64 生成的 ID / 订单号是重灾区——传输时用 string,需要运算时上 BigInt

JavaScript · 控制流与循环

分支与循环是一切逻辑的骨架。本章讲透 if / switch / 三元的选型、for / while 与 break / continue 的正确姿势,以及 for...of、for...in、forEach 三种遍历方式的本质区别与高发坑——数组本身的方法见下一章。

条件分支的心智模型只有一句话:把「值」压成「布尔」,再决定走哪条路。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...ins[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.prototypeObject.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 唯一顺手的地方;
  • 它还接第二个参数 thisArgarr.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.allmap

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

  • 遍历途中改数组,三者行为完全不同。forEach 的访问范围在调用那一刻就定死了,循环中 push 进来的新元素不会被访问;for...of 每轮都重新问一次迭代器,会一直追着新元素跑——边遍历边 push 跑满 20 轮仍不停,就是死循环。而 splice 删除会让 forEach 漏掉一个[1,2,3,4] 里删掉首项后,回调只看到 1, 3, 4(和上一张卡「下标 for + splice 跳元素」同源);
  • 性能:日常无所谓,for...in 例外。Node 24.18 上遍历 500 万项各跑三轮:经典 for3.7 msforEach25 msfor...of34 msentries()46 msfor...in300–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 · 数组

数组是 JS 里出现频率最高的数据结构,却不是「一块连续内存」——它是键为数字的对象,长度可写、中间可以有洞、两端的增删代价差两个数量级。本章从增删改查讲到不可变四件套与分组去重,把日常写得最多的那几十行讲透。

这四个方法看起来对称,代价却完全不对称:末尾操作是 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++]);
spliceslice 一字之差、行为相反: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] = 1length 直接变 101,中间 100 个全是洞。

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

  • [1,,3]length3,但 1 in afalseObject.keys(a)["0","2"]——那一格根本不存在;
  • 跳过洞forEachfilterreduceObject.keys保留洞map(回调不执行,但结果里那一格还是洞);把洞当 undefinedfor...of、展开 [...a]includes删掉洞flat()变成 nullJSON.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 个洞,回调一次都不会执行,而且不报错。这是「代码看起来对、结果是一片空」的经典来源。

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

按问题选方法

  • 在不在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 这类靠「引用变了没」判断是否更新的地方,用错一派的后果是数据改了、界面不动,而且不报错。

谁改原数组,谁不改

  • 就地改sortreversesplicefillcopyWithinpush/pop/shift/unshift
  • 返新数组mapfiltersliceconcatflatflatMap,以及 ES2023 补上的四个不可变版:toSortedtoReversedtoSplicedwith
  • 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.fromPromise.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 属性:如果那个对象的 lengthundefined,得到的是空数组而不是报错。从奇怪的 API 拿到对象、Array.from 出来是 [] 时,先打印一下它有没有 length
反过来「数组转别的」也是同一套:new Set(arr) 去重、new Map(二维数组) 建映射、Object.fromEntries(二维数组) 建对象、arr.join("") 拼字符串。所有这些构造函数收的都是可迭代对象,所以生成器也能直接喂进去——这正是可迭代协议的价值(详见「迭代与生成器」章)。

JavaScript · 函数与 this

函数是 JS 的一等公民。理解箭头函数与普通函数在 this 绑定上的根本区别,是真正搞懂 JS 执行机制、也是实战中最容易出错的地方。

三种写法不是风格之争——它们在「提升时机」和「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 · 解构、扩展与剩余

ES6 引入的解构语法极大简化了从对象/数组中提取数据的写法,是现代框架(React/Vue)代码里随处可见的基础语法。

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

位置把数组的值提取到变量中,支持跳过元素、设置默认值、交换变量。
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] = 5const [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 · 对象:拷贝与遍历

对象字面量语法糖让代码更简洁;遍历三件套是「对象没有 map/filter」的标准替代;而浅拷贝与深拷贝的区别,是状态管理(React/Redux 不可变更新)的基础。

这些简写不只是少打字——属性简写、计算属性名让「用变量拼对象」变得直白,是配置对象、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、不能 newTypeError: obj.greet is not a constructor)。另外字面量里的 __proto__: x特例——它设置的是原型而非普通键;真要存名为 __proto__ 的数据键得用计算属性 { ["__proto__"]: x }
展开运算符合并时是浅合并,嵌套对象仍是同一引用。需要递归合并用 structuredClone 先深拷贝,或使用 lodash 的 _.merge()

对象没有 mapfilter,遍历要先转成数组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 原型对象上直接 TypeErrorObject.groupBy 的返回值就是 null 原型的)。Object.create(null) 造的对象调 hasOwnPropertyTypeError,而 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()(要下标和值)。
entriesmapfromEntries 是对象版的「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 不能克隆含 FunctionSymbol、DOM 节点的对象,这些场景仍需 lodash _.cloneDeep()
structuredClone 能处理循环引用Date/Map/Set,但遇到函数或 Symbol 会抛 DataCloneError: ... could not be cloned。老写法 JSON.parse(JSON.stringify(x)) 则会悄悄丢掉 undefined 和函数、把 Date 变成字符串——排查「拷贝后数据变样」的诡异 bug 时先想到这点。

JavaScript · Map / Set

ES6 引入的新集合类型,解决了普通对象做哈希表时键只能是字符串、容易和原型属性冲突等问题。

普通对象拿来当哈希表有两个硬伤:键只能是字符串、还会跟原型上的 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 判键用 SameValueZeroNaN 能当键并正常取回(普通 ===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]]).size2[...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²)。去重也走 SameValueZeroNaN 能正常去重、只留一个。

长期运行的应用里最隐蔽的内存泄漏,就是「对象早已没用、却还被某个 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 · 可选链 · 空值合并

ES2020 之后引入的几个语法糖,能大幅减少防御性的 if 判断代码。

深层取值时「中间某层可能是 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.ca.b 为 null 时,.c 仍会抛 TypeError: Cannot read properties of null——每一段可能为空处都得写 ?.。另外它不能作赋值目标,o?.x = 1SyntaxError: Invalid left-hand side in assignment
一旦 ?. 左侧是 null/undefined,整条链后续全部短路、直接返回 undefined,后面的属性、方法调用、下标都不再执行:a?.b.c()a 为空时 c() 根本不会被调用。方法调用写 obj.fn?.()、动态下标写 obj?.[key]

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

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

JavaScript · 类、原型与元编程

ES6 class 是基于原型链的语法糖,理解原型继承的本质才能看懂框架源码中的类设计。本章还延伸到与原型同源的元编程能力:Symbol、属性描述符与 Proxy/Reflect(可迭代协议与生成器已独立成下一章)。

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 不提升且有暂时性死区:声明前 newReferenceError: 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().makeundefined

#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语法错误、不是运行期 undefinedobj.#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.prototypenull。方法定义在 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.getOwnPropertySymbolsObject.keys/for...in 一概看不见。
业务代码里最常用的知名符号是 Symbol.iterator(见下一张卡)。有了真正的私有字段 #x(见「私有字段」卡)后,用 Symbol() 做私有键已较少见。Symbol 键不参与 JSON 序列化,也不被 structuredClone 克隆。

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

writable 管能不能改值、enumerable 管能不能被枚举、configurable 管能不能删除或再配置。Object.defineProperty 逐个精调这三个开关,freezeseal 则是更粗粒度的整体封锁。
// ── 属性描述符 ────────────────────────────────
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 里调用它完成「本来该做的事」,并正确传递 receiverVue 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 为什么该取代 hasOwnPropertyObject.is 修了 === 的哪两个坑、Object.groupBy(ES2024)怎么原生分组,以及 entriesfromEntries 如何优雅地「变换」一个对象。
// ── 属性存在性判断 ────────────────────────────
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 === NaNfalse+0 === -0true;需要精确判等改用 Object.isObject.is(NaN, NaN)trueObject.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 · 迭代与生成器

for...of 能遍历什么、不能遍历什么,取决于一个只有一行的协议。理解它,才明白展开、解构、Array.from、Promise.all、new Set 收的为什么是同一类东西;而生成器是写这类东西的捷径,迭代器助手则让它们能像数组一样链式调用,还不必先把无限序列展开。

给对象实现 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.fromnew Setnew MapPromise.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() 存下来。另外链上任何一步不加 taketoArray() 无限序列,一样会卡死——惰性不等于自动有界。
助手方法只存在于迭代器上,不在数组上——arr.take(3) 不存在,要写 arr.values().take(3).toArray()。判断当前环境有没有:typeof Iterator !== "undefined" && typeof [].values().take === "function"。它是 ES2025 的新东西,Node 22+ 与 2024 年之后的浏览器才有。

JavaScript · 错误处理

健壮的工程代码离不开规范的错误处理。掌握 try/catch/finally 与自定义错误类,能让排查问题事半功倍。

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

两个控制流细节值得记牢:异常一抛,try该行之后的语句不再执行,直接跳进 catch;而 finallyreturn 都拦得住——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.messageundefined,所以读 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.nameerr.name 会沿用父类的 "Error"toString() 和堆栈首行都印成 Error: … 而非你的类名——务必在 super(message) 之后补一句 this.name = "XxxError"
V8(Node/Chrome)里可在构造器调 Error.captureStackTrace(this, MyError),把构造器自身这一帧从堆栈抹掉,栈顶直接指向 new MyError() 的调用处(栈顶即调用点)。要给错误串联根因,用 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 ausing b,退出时打印顺序是 bodydispose bdispose 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 辅助函数(几十行),老运行时也能跑。类型上需要 libesnext,否则报 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

异步编程是 JS 区别于其他语言最核心的特性。理解事件循环、Promise 状态机、async/await 语法糖,是写出健壮网络请求代码的关键——章末两张实战卡给出防抖/节流与超时/重试/取消(AbortController)可直接抄用的写法。

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

探源:先问「为什么单线程」——页面的 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 的入队顺序;每轮末尾按入队顺序清空微任务;再取一个宏任务重复。练到能心算,本页路线图的 JS 自测标准就过了。

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

Promise 有三种状态:pendingfulfilled(触发 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返回一个新 Promisep.then(fn) === pfalse),所以能链式串联;回调 return 的值成为下一环入参,return 一个 Promise 则等它敲定再往下走。

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

两条硬规则:async 函数永远返回 Promise,哪怕函数体里写的是 return 1await 只把后续代码挂进微任务队列,不阻塞主线程,同一时刻别的任务照常在跑。
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 Promisetrue)。

互不依赖的异步任务别排队 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() 请求立刻中断并抛 AbortErrorAbortSignal.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.okthrowfetch 对 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.allArray.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

模块化是组织大型项目代码的基础。理解 ES Modules 与 CommonJS 的区别,是排查 import 报错的关键——第三张卡专讲两者最容易踩的运行语义差异(顶层 await、__dirname、live binding、循环依赖)。

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.exportsexports 只是指向它的别名——给 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 · 日期与时间

JS 内置的 Date 对象是处理时间的核心工具。掌握创建、读取、计算、格式化日期的方法,以及时区与时间戳的本质,能帮你避开大量实际开发中的坑。

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。算天数差记得除以 86400000Math.floor

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

Date 对象内部始终以 UTC 存储,getter 方法默认返回运行环境的本地时区时间。跨时区开发时,要么统一用 UTC 系列方法,要么借助 Intl.DateTimeFormattimeZone 选项指定时区——这是原生最可靠的做法,无需任何库。
// 同一个 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.DateTimeFormattimeZone 选项。
复杂的时区换算(跨夏令时、历史时区等)推荐用 Luxondate-fnsLuxon 原生内置 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 是一个仅 2KB(gzip)的轻量日期库,API 与已废弃的 Moment.js 高度兼容,但体积缩小了 97%。它的所有操作都返回新对象(不可变),支持链式调用,通过官方插件系统扩展功能,是目前最流行的日期处理解决方案之一。
// 安装: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 官方插件 ─────────────
// 相对时间(relativeTime)、时区(utc+timezone)、自定义解析(customParseFormat)、
// 区间判断(isBetween)等均以插件形式引入,用到哪个才 dayjs.extend(...)
注意: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.month6,而 new Date(…).getMonth()5③ 时区感知,见下方陷阱里那组夏令时数字;④ 明确区分「某天」与「某刻」——PlainDateInstant 当场 TypeError,逼你先说清时区。

JavaScript · 序列化与编码

序列化是将数据结构转换为可存储或传输格式的过程。掌握 JSON、二进制编码、结构化克隆等技术,是网络通信、本地存储和跨线程通信的基础。

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.createObjectURLFileReader 使用。

// ── 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 URLURL.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}`);
URLSearchParamsget() 始终返回字符串,取出数字类型的参数后需要手动转换(如 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)。如需上传进度条,仍需使用 XMLHttpRequestxhr.upload.onprogress,或等待 Fetch API 的 uploadProgress 提案正式落地。

JavaScript · 深水区

前面几章教会你怎么用,这一章是深水区——那些不知道也能写、但不知道迟早会错的东西:静默的契约、几选一的选型、藏在水面下的机制。JS 的特别之处在于:这里的规则几乎全是运行时的、静默的。Rust 那边选错方法当场编译不过,JS 不会——它不报错,只是悄悄给你一个错的值,一路飘到很远处才变成别的症状。本章七张卡各讲一条这样的暗规则。「这个方法怎么调」不在这里查——那是下一章「内置对象速查」的活,两章是深水区 vs 速查的分工,不重复。

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

四套语义与它们的分歧点

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

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

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

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

  • [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}]).size2 不是 1,[{a:1}].includes({a:1}) 也是 false。它不报错,只是一个都没去掉,而对字符串数组测试时又一切正常,所以很容易蒙混过关。

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

顺带一提 -0:它确实容易冒出来(-1 * 0Math.round(-0.2)0 / -5 都是),但它对所有字符串转换都隐形(String、模板串、JSON.stringifytoFixed 一律不带负号),能看出来的只有 Object.is(x, -0)1 / x === -Infinity。日常基本无害,知道它存在即可。
日常选型只需记两句:判断「数组里有没有这个值」一律用 includes,别用 indexOf(x) !== -1——前者语义更准(能处理 NaN)也更好读;只有确实需要位置时才用 indexOfObject.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 返回负数、相等返回 0a > 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.lengths[i]slice28
码点(code point)[...s]for...ofArray.from15
字素簇(人眼的「一个字」)Intl.Segmenter11

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

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

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

还有两处会出乎意料

  • 转大写可能变长"ß".toUpperCase() 得到 "SS",长度从 1 变 2。所以「转大写不改变长度」这个假设不成立;
  • 看起来一样的字符串可能不相等é 可以是单个码点,也可以是 e + 组合重音符两个码点。二者渲染完全相同但 === 为 falselength 一个是 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 同一个字符串,结果会跳

testexec 在带 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,而且那时直接用 matchAllreplaceAll校验(test)、找第一个(match 不带 g)、单次替换都不需要 g,加了只有坏处。

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

JavaScript · 内置对象速查

本章速查 ECMAScript 语言自带的标准库——不依赖任何宿主、在浏览器 / Node / Deno 等所有 JS 环境中都存在的内置全局对象:String / Number·Math / Array / Object 处理最常用的数据,Map·Set 管理键值集合,Promise 组合异步,JSON 做序列化,RegExp 做模式匹配。用法是两步:先用第一张任务反查表按「你想干什么」定位到设施与所在卡,再翻后面按对象分组的八张卡查具体方法,每个方法各配一行示例。宿主额外提供的 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 脚本就是这么炸的——但 fetchURLAbortController 这几个已被 Node / Deno / Bun 共同实现,两边都能用。

文本 · 数值 · 时间

想做什么用这个在哪
拼接字符串模板串(反引号插值),别用 + 连一长串03 章
补齐位数padStart(2, "0") / padEnd19·String
去掉首尾空白trim(只去首尾用 trimStart / trimEnd19·String
判断包含includes / startsWith / endsWith19·String
取倒数第 n 个at(-1)(负数索引,字符串和数组都能用)19·String
替换全部出现replaceAll——replace 只换第一处19·String
拆开 / 合起来splitjoin(一对反操作)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 / withreversesplice ⚠︎改原值19·Array
去重[...new Set(arr)]对象数组无效,改按 id 建 Map(18 章)19·Map · Set
按 key 分组Object.groupBy(要 Map 就 Map.groupBy19·Array
拍平嵌套数组flat(Infinity);边映射边拍平用 flatMap19·Array
按 key 存取对象字面量;key 不是字符串、或要保序、要频繁增删Map19·Object · Map
遍历对象Object.entries(o)for...of 解构19·Object
对象 ↔ 数组Object.entriesObject.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-Type17 章
等一组异步全完成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
在本地存点数据localStorageJSON.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()sortedlist同一个数组,你以为只是取了个副本,实则把传进来的数据洗了一遍。在 React / Vue 里这还会让状态「改了但界面不更新」(引用没变,框架认为无事发生)。ES2023 起每个都有 to 前缀的不可变版本,默认用那个

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

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

字符串是 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".length4 不是 3。而 s[i]slicesplit("") 都按码元切,切中 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 - btoSorted 同理。排序的另外两条契约(稳定性、比较器必须自洽)以及中文按拼音排序,见 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 1JSON.parse("")Unexpected end of JSON input;任何外部数据都得包 try/catch。冷知识:JSON.parse(null) 不报错,它先把 null 转成字符串 "null" 再解析,返回 null
parseInt 永远显式传基数parseInt("0x1f") 会被当十六进制解析成 31parseInt(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.okres.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 再变它也不更新;而 getElementsByClassNamegetElementsByTagName 返回实时 HTMLCollection,边遍历边删节点会漏掉元素(索引在塌缩)。另外 NodeList 只带 forEach,想用 mapfilter 得先 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) 也不是「立即」——它排的是宏任务,执行顺序是同步代码 → 微任务(queueMicrotaskPromise.then)→ 才轮到它,要在本轮尽早跑用微任务而非 0ms 定时器。

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

URL 对象可结构化解析和构建 URL,避免手动字符串拼接出错。URLSearchParams 专门处理 ?key=value 形式的查询字符串,自动处理编码和多值问题。(URLSearchParamsencodeURIComponent 的编码细节详见 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+bget("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,直接访问就 TypeErrorwriteTextreadText 还必须由用户手势(点击等)触发,脚本里主动调用会被拒;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 notificationsMutationObserver 的回调拿到的是批量队列而非单条变更,且默认不含属性旧值,要 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);传类实例则丢掉原型链——方法全没、instanceoffalse,只剩下数据字段。DOM 节点、Symbol 同样不能传,跨线程只发纯数据。
传递大型 ArrayBuffer 时可以使用 Transferable ObjectspostMessage(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)。
pushStatereplaceState 本身不会触发 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 的默认 timeoutInfinity——不显式设置,取不到信号时会一直挂着,成功和错误回调都不来,界面上像卡死。用户拒绝授权走错误回调(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);
translaterotatescale叠加到当前矩阵上的,不配 save/restore 就会累积、污染后续所有绘制;shadowColorglobalAlphafillStyle 这类同样是持续生效的全局状态,设了不复位会一直带着。另外画过跨源且未开 CORS 的图片后,canvas 被「污染」,getImageDatatoDataURL 会抛 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 · 类型基础

TypeScript 是 JavaScript 的超集,在运行的 JS 之上加了一层编译期类型检查。本章只讲 TS 独有的类型系统入口:类型注解、字面量、元组与特殊类型。

这半页假设你已经会 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 的类型不止 numberstring 这些大类,它还能把「某一个具体值」本身当成类型——这正是后面联合、判别联合、收窄的地基。

: 类型 给变量加注解,但多数时候靠类型推断即可(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 替你推断,代码更简洁也更不易过时。

数组、元组、以及 anyunknownnever,是你每天都要标注的类型;真正需要斟酌的是这三个特殊类型之间的取舍。

数组写 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 时只能访问两者共有的成员,想用各自独有的方法必须先用 typeofin/字面量相等收窄到具体分支(否则报 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 是描述对象「形状」的两种方式。理解它们的能力差异与声明合并,才能为对象、函数、第三方库写出清晰可维护的类型。

描述「一个对象长什么样」是 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 Boxx 一个 number 一个 string,报 error TS2717: Subsequent property declarations must have the same type;而两个同名 type Box 直接报 error TS2300: Duplicate identifier 'Box'——type 没有合并能力(tsc 7)。
声明合并是给「别人的类型」打补丁的正道。想给第三方库或全局对象加字段,就再声明一个同名 interface 让它自动并进去;在带 importexport 的模块文件里扩展全局类型(如 Window)时,记得包在 declare global { ... } 里,否则会被当成局部声明而不生效。

interfacetype 八成场景可以互换,真正的区别只集中在少数几处能力边界上。

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 · 函数类型

给函数加类型是 TS 日常最高频的操作。本章聚焦 TS 特有的部分:参数与返回值注解、函数类型表达式、重载与 this 类型——而非 JS 已有的函数语法。

给函数标类型是 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 编过)。
参数几乎总要显式标,返回值多数可省。参数没有推断来源,不标就是隐式 anystrict 下报错);返回值 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)回答一个问题:DogAnimal 的子类型,那「关于它们的复合类型」谁是谁的子类型?规则:返回值位置协变(返回 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 · 泛型

泛型让类型成为参数:写一次,适配多种类型,同时保持完整的类型推断。泛型约束与条件类型是构建可复用、类型安全抽象的核心工具。

泛型把「类型」本身也变成可传入的参数,让同一段代码类型安全地服务多种类型。

泛型把类型变成参数 <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"] 推出的 Tstring 而非 "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 : Tstring | null 时,会逐个成员套用后再合并,得到 stringnull 被过滤)——这常常是你要的,但也可能是意外。不想分发就把两边都用元组包起来:[T] extends [null | undefined] ? ...,此时 string | null 作为整体判断、不再拆开(tsc 7 两种结果不同)。
条件类型 + infer 是 TS 类型编程的「核武器」,但也最容易写得晦涩。日常优先用内置工具类型(第 26 章),只有它们覆盖不到时才自己写——可读性比炫技更重要。

TypeScript · 类型收窄与守卫

联合类型要用得安全,关键是「收窄」:在分支里把宽类型缩小到具体类型。判别联合、类型守卫与断言函数,是写出既灵活又类型安全代码的核心技能。typeof 的运行时行为见 03 数据类型与转换,instanceof 见 10 类、原型与元编程

联合类型要用得安全,全靠在分支里把宽类型收窄成某一个具体成员。

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 | nullif (typeof x === "object"),分支里 x 恰好被收窄成 null;真要区分「普通对象/数组/null」得额外补 x !== nullArray.isArray(x),光靠 typeof 分不清。
判别联合 + never 穷举是 TS 最实用的模式:每当给 Shape 加一个新成员,所有忘记处理它的 switch 都会在编译期报错——把「漏改」变成编译错误,而不是线上 bug。

内置的 typeofinstanceof 覆盖不到复杂对象时,自定义 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"(漏查 idname 字段)也照样编译通过、不报错;谓词逻辑写错,收窄就是错的,运行时才崩。守卫的正确性得你自己保证——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 · 工具类型

TS 内置一批工具类型,把已有类型「变形」成新类型——日常开发频率最高的特性。掌握它们能极大减少重复的类型定义。

同一份对象类型,创建时要去掉 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):numberf(x:string):stringReturnType 得到的是 string(最后一条),而不是两者的联合(把结果赋给 numberTS2322)。此外这些工具都作用于类型,对一个值要先套 typeof(ReturnType<typeof getUser>),直接写值会报错。
工具类型可以像函数一样嵌套组合。但组合层数一多就难读了——这时给中间结果起个有意义的 type 别名,比把五六个工具类型套成一行更利于维护。

TypeScript · 高级类型

映射类型与模板字面量类型是 TS 类型编程的高阶能力:批量改写对象类型、用字符串拼接生成类型。内置工具类型就是用它们实现的。

内置的 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,而 CapitalizeUppercase 这些模板工具只接受 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 · 类的增强

类语法本身 JS 已有,这里只讲 TS 在类上的增量:访问修饰符、参数属性、抽象类、implements,以及装饰器。JS 侧的类语法与原型机制见 10 类、原型与元编程

同样是给字段做封装,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) 既公开又只读(类外改 xTS2540)。而 abstract 类只能被继承,直接 newTS2511: 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 · 工程实战

把 TS 用进真实项目的最后一公里:为 JS 库写声明文件、构建端到端类型安全的 API 层、配好 tsconfig,以及一组高频实用技巧与避坑指南。

给没类型的 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(含 noImplicitAnystrictNullChecks 等所有严格检查)——这是 TS 价值的核心。另一组决定成败的是模块字段:module / moduleResolution 管「import 怎么被解析」——经打包器(Vite/webpack)构建选 "bundler";代码交给 Node 直接运行选 "nodenext"(此时相对导入必须写全 .js 后缀)。「明明文件在却找不到模块」「默认导出报错」这类玄学问题,九成出在这组字段与实际运行环境不匹配。另注意baseUrlTS 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 就把检查开满了:它只含 noImplicitAnystrictNullChecks 等一组,并不包含 noUncheckedIndexedAccessexactOptionalPropertyTypesnoImplicitOverride 这些(仅开 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[] 而不被拓宽成联合。想「既要校验又要精确」时就选它。

运行时与包管理工具链

本章不属于 JS 或 TS 任何一方,而是两者共用的工程环境:先选一个运行时(Node / Deno / Bun)把代码跑起来,再用包管理器(npm / pnpm / yarn)装依赖、跑脚本。01 章已经给过最小闭环(装 Node、npm initnpm run),这里是完整对照。建议打开终端跟着敲一遍。

在服务端 / 命令行跑 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 的触发时机很反直觉:裸建一个 .jsimport,Node 22+ 会自动放行;但只要目录里有一个不含 "type": "module"package.json,同一份代码立刻报错
拿到别人的项目,标准开场是:npm install 装齐依赖,然后翻 package.jsonscripts 看有哪些命令可用。不要因为 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 前缀的脚本会被自动带跑:定义了 prebuildpostbuild,一句 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-vitecreate-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/strictstrict 那个子路径很关键——不带它的 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 --testa.test.mjsb_test.mjstest/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定时器 mockt.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:testtest / describe / assert 三件套和 vitest 几乎同形,将来真要迁移,改的是导入语句而不是用例本身。把 "test": "node --test" 写进 package.json 的 scripts(见本章前面那张卡),门槛就归零了。

Web Components:不依赖框架的组件

浏览器原生的组件方案:自定义元素、Shadow DOM、模板。它不属于任何框架,也因此能被所有框架使用——这一章讲清楚它解决了什么、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 + ElementInternalssetFormValue()),但很多社区组件没有实现。选组件库时如果要用在表单里,先做一个最小验证:放进 <form> 里提交一次,看 FormData 有没有那个字段。
给自定义元素写 TypeScript 类型时,把它声明进全局的 HTMLElementTagNameMapdocument.querySelector("my-counter") 就能推出正确类型;再配合各框架的 JSX 类型扩展(React 的 JSX.IntrinsicElements、Vue 的 GlobalComponents),模板里也能有补全。这一步做不做,决定了团队用起来是「有类型的组件」还是「一堆 any」。

测试:从怎么跑到怎么写

30 章那张卡讲的是怎么把测试跑起来——选运行器、文件发现规则、覆盖率开关;这一章讲怎么写:用例怎么组织、断言该挑哪一个、异步为什么是「假通过」的重灾区、mock 与假定时器怎么把「等一秒」变成零秒,以及 TypeScript 的类型本身该怎么测。全章用 Node 自带的 node:test,不装任何包,示例与数字均为本机 Node 24.18;vitest 的写法几乎同形,真要迁移改的是导入语句而不是用例本身。

一条测试永远是三段:准备数据、执行被测代码、断言结果(业内叫 AAA:Arrange-Act-Assert)。node:test 提供的 test / describe / 四个钩子,和 vitest、jest 几乎同形——学会这一套,换框架只是换导入语句。

结构:test 与 describe

  • test(名字, 回调) 是一条用例;ittest别名,写 it("应该返回 3", ...) 只是让句子读起来通顺,功能完全一样;
  • describe(名字, 回调) 把相关用例分成一组,输出里会缩进成树;组可以嵌套;
  • 用例名要写成一句话的结论——「空数组时返回 0」远胜「测试 sum」。测试失败时你先看到的是这行名字,它应该直接告诉你坏了什么。

四个钩子,以及它们各跑几次

  • before / after整组只跑一次,用于开销大且可共享的准备(起一个测试数据库、建一个临时目录);
  • beforeEach / afterEach每条用例前后各跑一次,用于把状态复位——这是保证用例之间互不影响的关键;
  • 两条用例的一组,输出顺序是:beforebeforeEach → 用例1 → afterEachbeforeEach → 用例2 → afterEachafter
  • 钩子写在 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:回调不写 asyncreturn 一个 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()2fn.mock.calls[1].arguments[3, 4]fn.mock.calls[1].result7
  • 于是「回调被调了几次、第几次收到什么参数」都能直接断言——这正是测事件、测钩子、测订阅时最需要的。

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() 返回 0tick(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 --noEmiterror 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,用 && 串起来,前一步红了就不必往下跑。

从这里到精通:路线图

地图铺完了,剩下的路要亲手写出来。最后这一章给出收尾路线:难度递进的动手项目、按阶段的资料,以及 JS 与 TS 各一条自测标准。

JS 的生态浩瀚,但精通的路径恰恰是先离框架远一点:把语言本身和平台 API 写透,框架只是最后一层皮。四个项目按难度递进。

动手项目(难度递进)

  • ① 纯 JS DOM 应用:不用任何框架写一个 TodoMVC 或番茄钟(产出可直接打开的单页应用)——把 DOM API、事件委托、「状态→渲染」的手动同步练透,日后学框架才知道它们在解决什么。
  • ② Node CLI / 爬虫:写一个能 npx 运行的命令行工具或小爬虫(fetch + fs + 命令行参数解析)——脱离浏览器理解模块系统、流与文件系统。
  • ③ 手写题过异步关:亲手实现符合 Promises/A+ 的 Promise、防抖/节流、并发限制器,再构造 setTimeout 与微任务混合的场景验证执行顺序——异步模型从「背结论」变成「能推导」。
  • ④ 读一个小库源码:zustand(状态管理,核心几百行)或 dayjs(日期库)——学 API 设计与工程组织,顺手提一个 PR 走完开源协作全流程。

书与资料(按阶段)

  • 日常查证:MDN——任何 API 疑问的第一站,权威且持续更新;
  • 系统打底:《JavaScript: The Definitive Guide (7th)》(犀牛书)系统全面;《你不知道的 JavaScript》把作用域/this/原型讲到根上;
  • 在线教程:javascript.info——免费、循序渐进、有中文版,适合按章跟练;
  • 跟进标准:TC39 proposals 仓库看新特性进展,node.green 查运行时支持度。
这条路最大的坑不是难,是「教程地狱」——一部接一部地看视频、跟着敲,攒下「我都懂」的错觉,一离开教程独立写就卡壳。判据很朴素:能不能关掉教程、从空文件夹把上面的项目独立做出来——看懂别人的代码和自己写得出来之间隔着一条河,只有产出物能证明你过了河。

另一个反模式是跳过纯 JS 直接扎进框架:遇到 bug 时分不清是框架的坑还是语言的坑,把作用域、this、异步时序的困惑一股脑记到 React / Vue 头上,越学越糊涂。框架是最后一层皮,先把语言和平台 API 写透,再学框架才事半功倍。
一条自测标准:给你一段混着 setTimeout、Promise.then 与 async/await 的代码,你能否不运行就准确写出输出顺序,并用宏任务/微任务模型解释每一步——能做到,JavaScript 部分就毕业了。

TS 的精通路径很清晰:从「会加注解」走到「能设计类型」。四个项目按难度递进,每一步的产出物都能直接进简历。

动手项目(难度递进)

  • ① 渐进迁移:挑一个自己的 JS 项目,开 allowJs + checkJs 逐文件改成 .ts,最终开满 strict 全家桶并消灭所有 any——体会类型如何揪出存量 bug,这正是 TS 在真实团队落地的方式。
  • ② 类型安全工具库:写一个泛型实战库(如类型安全的事件发射器 EventEmitter 或 fetch 封装),要求调用端不写一个类型注解也能获得完整推断——泛型、约束、重载一次过手。
  • ③ 类型体操:刷 type-challenges 仓库(easy 到 medium 共几十题),条件类型、infer、映射类型、递归类型在题目里全部见真章。
  • ④ 读类型驱动库源码:zod(类型与运行时校验如何双向绑定)或 tRPC(类型如何跨网络边界流动)——见识生产级的类型设计,理解「类型即 API」。

书与资料(按阶段)

  • 入门必读:官方 Handbook——覆盖全部核心概念,配 TS Playground 边读边试;
  • 进阶首选:Total TypeScript(Matt Pocock 的教程与练习集),业界公认最佳进阶资源;
  • 专项练习:type-challenges 仓库,题目+社区题解,类型体操的标准题库;
  • 日常跟进:TypeScript 官方博客的版本发布说明,每个新版本的特性都值得扫一遍。
上了 TS 却用 anyas 把类型系统架空,是最普遍的自欺。一句 as any 能让红线立刻消失,但也把这一处的类型安全彻底关掉——迁移项目时最该警惕的就是「为了不报错而断言」,那等于花力气装了 TS 又亲手拆掉。

反方向还有个坑是类型体操上瘾:type-challenges 刷多了,就忍不住在业务代码里堆四层条件类型加 infer 炫技,结果同事(和三个月后的自己)读不懂、改不动。类型是为可读性与安全服务的,不是为了证明你会写——能用简单类型表达的就别上体操;刷题时也别急着翻题解,抄一遍答案和自己推导出来,收获天差地别。
一条自测标准:给你一个返回 any 的第三方 JS 函数,你能否为它写出精确的 .d.ts 类型声明——带泛型约束、必要时加重载,让调用端获得完整的推断与补全——能做到,TypeScript 部分就毕业了。