全景: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 不等网络,继续往下跑');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"); // ❌ 编译期就报错,不等运行时User,运行期也不做任何校验,字段缺了会一路飘到很远才炸——外部数据要用 zod 这类在运行时真校验。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 各有各的。只有浏览器有 document、window、localStorage(20 章);只有 Node 有 process 和文件读写 fs;两边都有的是语言本身,外加 fetch、console、setTimeout。所以看到 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,或输入 .exitnodejs.org,npm 包管理器随它一起装好,不需要再单独安装 npm。网上大量「先装 npm 再装 node」的老教程会把你带进坑里。新建一个文件夹,建 hello.js,把右侧代码抄进去,终端 cd 过去执行 node hello.js。看到三行输出,环境就通了。
逐行拆解
const name = "World";——现代 JS 的默认选择就是const,需要改动时才换let;var属于历史遗留(为什么见 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 listconsole.log 当调试的唯一手段。浏览器的 Sources 面板、Node 的 --inspect 都能打断点、逐行走、看每个变量当前的值——花二十分钟学会打断点,能省下后面几百次 console.log。Cannot read properties of undefined,先问「这个值本该从哪来」——十有八九是接口返回里没有那个字段,或 DOM 还没渲染出来。JavaScript · 变量与作用域
理解 let / const / var 的本质区别,是写出稳妥代码的第一步。现代 JS 工程中应优先使用 const,需要重新赋值时用 let,几乎不再使用 var。
这三个关键字不是三种风格之争,而是三件事的差异:作用域边界、声明前能不能访问、绑定能不能重新赋值——任何一条搞混,都会写出「变量莫名是 undefined」或「循环闭包全拿到同一个值」这类经典 bug。
var 是函数作用域,会被提升(hoisting)到函数顶部,且可以重复声明,容易造成意外覆盖。let 和 const 是块级作用域({}内有效),存在「暂时性死区」(TDZ)——在声明之前访问会直接报错,而不是像 var 那样得到undefined。const 声明的是常量绑定,意味着不能重新赋值,但如果值是对象或数组,其内部属性仍然可以修改。function test() {
if (true) {
var a = 1; // 函数作用域,泄漏到外面
let b = 2; // 块级作用域,只在 if 内有效
}
console.log(a); // 1(能访问到)
console.log(b); // ReferenceError: b is not defined
}
console.log(x); // undefined(var 提升)
var x = 5;
console.log(y); // ReferenceError(TDZ,暂时性死区)
let y = 5;
const obj = { name: "Tom" };
obj.name = "Jerry"; // ✅ 合法:改的是属性,不是绑定本身
obj = {}; // ❌ TypeError: Assignment to constant variableconst 代表「对象不可变」。实际上 const 只锁定变量与值之间的引用关系,对象内部属性依然可变。要真正冻结对象需用 Object.freeze(obj)(仅浅冻结)。闭包常被当成玄乎的「高级特性」,其实它只是「函数是一等值」和「词法作用域」两条设计撞在一起的必然结果——理解了它,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 每次迭代都会创建新的块级绑定。严格模式今天很少需要你手写,但它没有消失:ES 模块和 class 里默认就开着——所以理解它,本质是理解「为什么同一段老代码,放进现代模块里就突然报错了」。
严格模式是 ES5 引入的运行模式,能让 JS 引擎更早地抛出错误而不是静默失败。在文件或函数顶部写'use strict' 开启。ES6 模块(import/export)和 class 内部默认就是严格模式,不需要手动声明。'use strict';
x = 10; // ❌ ReferenceError(非严格模式下会静默创建全局变量)
function f(a, a) { } // ❌ 严格模式禁止重复参数名this 是 undefined;非严格模式下它会指向全局对象(Node 里是 globalThis)。由于 ES 模块和 class 默认严格,把一段老代码搬进模块后,原本靠 this 拿全局的写法会突然抛 Cannot read properties of undefined。JavaScript · 数据类型与转换
JS 是动态类型语言,理解类型系统和隐式转换规则,能避免大量 == 与拼接产生的诡异 bug。
先分清「基本类型按值存、对象按引用存」,后面几乎所有「改了 a 结果 b 也跟着变」「相等比较出人意料」的坑,根都在这张卡。
JS 有 7 种基本类型(存值,不可变):Number、String、Boolean、undefined、null、Symbol(ES6)、BigInt(ES2020)。其余都是Object引用类型(数组、函数、日期等都是对象)。基本类型按值存储和复制,对象按引用存储和复制。typeof 42 // "number"
typeof "hi" // "string"
typeof true // "boolean"
typeof undefined // "undefined"
typeof null // "object" ⚠️ 历史遗留 bug
typeof Symbol() // "symbol"
typeof 10n // "bigint"
typeof {} // "object"
typeof [] // "object"(用 Array.isArray() 判断)
typeof function(){} // "function"
const a = { x: 1 };
const b = a; // b 和 a 指向同一个对象
b.x = 99;
console.log(a.x); // 99(引用类型共享内存)typeof null === "object" 是 JS 早期实现的 bug,由于兼容性原因一直保留至今。判断是否为 null 应该用 value === null。typeof:它把 null 说成 "object"、把数组也说成 "object"。判 null 用 x === null,判数组用 Array.isArray(x),判 NaN 用 Number.isNaN(x)——typeof 只适合快速区分基本类型和函数。这张卡不是让你背下 == 的转换规则,恰恰是让你彻底放弃背它:规则复杂到连老手都记不全,正确的应对是几乎永远用 ===。
===(严格相等)不做类型转换,类型不同直接返回 false,推荐始终使用。==(宽松相等)会先进行隐式类型转换再比较,规则复杂且反直觉,是大量 bug 的来源。1 == "1" // true(字符串被转成数字)
1 === "1" // false(类型不同,直接 false)
null == undefined // true(特例)
null === undefined // false
0 == false // true
"" == 0 // true
[] == false // true
NaN === NaN // false!NaN 不等于自己
Number.isNaN(NaN) // true(判断 NaN 的正确方式)value == null(同时判断 null 和 undefined)。== 有多不讲道理,跑一遍这几个——全是 true:"" == 0、"0" == false、[] == false、[] == ![]。记不住是正常的,所以规则就一条:始终用 ===,只在 x == null(一次判 null 和 undefined)时破例。把一个值放进 if、||、? 这些位置时,JS 会先偷偷把它转成布尔——记住 8 个假值这份短名单,就等于记住了所有分支的走向。
JS 中只有 8 个假值:false, 0, -0, 0n, "", null, undefined, NaN。其余所有值(包括空数组 [] 和空对象 {})都是真值,这一点和 Python 不同,是新手最容易踩的坑。if ([]) console.log("空数组是真值"); // 会执行!
if ({}) console.log("空对象是真值"); // 会执行!
if ("0") console.log("字符串0是真值"); // 会执行!
Boolean("") // false
Boolean("0") // true(非空字符串)
Boolean(null) // false
Boolean([]) // true"0" 或 "false" 在 JS 里是真值!判断接口返回的布尔字段时一定要小心字符串类型。false, 0, -0, 0n, "", null, undefined, NaN),其余全是真值——包括 []、{}、"0"、"false"。想显式看一个值的真假,用 Boolean(x) 或 !!x。显式转换你说了算,隐式转换是运算符背着你干的——JS 里大半「结果莫名其妙」的表达式,都是某个运算符偷偷触发了一次你没料到的转换。
显式转换:用Number()、String()、Boolean() 主动转换。隐式转换:运算符触发的自动转换,最常见的坑是 + 运算符——只要有一方是字符串,就会触发字符串拼接而非加法;其余算术运算符(- * /)都会把操作数转成数字。1 + "1" // "11"(字符串拼接)
1 - "1" // 0(转成数字相减)
1 + true // 2(true 转成 1)
1 + null // 1(null 转成 0)
1 + undefined // NaN
[] + {} // "[object Object]"
Number("42px") // NaN
parseInt("42px") // 42(只解析开头能识别的部分)Number() 有一批反直觉的空值结果:Number("")、Number(" ")、Number(null)、Number([]) 全都得 0(不是 NaN),只有 Number(undefined) 才是 NaN。所以拿 Number(x) 校验用户输入时,空字符串会被悄悄当成 0 放过——先 trim 判空、再转换。+ 一旦遇到字符串就变拼接;- * / 几乎总是转数字。不确定时显式调用 Number() / String() 更安全可读。字符串方法虽多,却有个共同前提值得先立住:字符串不可变,每个方法都返回新串、绝不改原串——想不到这点,就会写出「调了 trim 却发现原变量没变」的困惑。
模板字符串用反引号包裹,内部${...} 可嵌入任意表达式并支持跨行——全页高频使用,取代老式的 + 拼接。字符串是不可变的,所有方法都返回新串:slice 截取、split 切成数组、replace/replaceAll 替换、trim 去首尾空白、padStart/padEnd 补齐、includes/startsWith/endsWith 判断包含。复杂模式匹配交给正则,正则见站内 regex 页。const user = "Ada";
// 模板字符串:反引号包裹,${...} 内插表达式、可跨行
const msg = `Hello, ${user}!`; // "Hello, Ada!"
// 字符串不可变,方法都返回新串
" hi ".trim(); // "hi"(去首尾空白)
"a,b,c".split(","); // ["a", "b", "c"]
"hello".slice(1, 3); // "el"(起止下标,可负)
"2024-06".replace("-", "/"); // "2024/06"(replaceAll 换全部)
"abc".includes("b"); // true(还有 startsWith/endsWith)
"7".padStart(3, "0"); // "007"(补齐到 3 位)
"abc".toUpperCase(); // "ABC"replace("-", "/") 只换第一处,想全换用 replaceAll 或带 /g 的正则。② .length 和下标数的是 UTF-16 码元而非字符:"😀".length 是 2,"😀".slice(0, 1) 会切出半个乱码;要按人眼字符处理,先用 [...str] 展开([..."😀"].length 为 1)。+;只判断「是否包含」用 includes 比 indexOf(...) !== -1 更直观。普通模板字符串是「把值填进字符串」,标签模板反过来——把「字符串的骨架」和「值」拆开交给一个函数处置,这一步跨越正是 styled-components、gql、sql 这些库能存在的底层机制。
在模板字符串前紧挨着写一个函数名,就是「标签模板」:函数被调用,第一个参数是被插值切开的静态字符串数组(还带一个.raw 保存未转义原文),后面依次是各个 ${...} 的求值结果。它把「模板的结构」与「动态值」分离交给函数自由处理——这正是 styled-components(拼 CSS)、GraphQL 的 gql``、SQL 库的 sql``(自动参数化防注入)以及许多 i18n 方案的底层机制。// 标签函数:strings 是静态片段数组,values 是各插值结果
function tag(strings, ...values) {
// strings = ["Hi ", ", you have ", " msgs"](比 values 多 1 个)
// values = [name, count]
return strings.reduce(
(acc, s, i) => acc + s + (values[i] ?? ""), ""
);
}
const name = "Ada", count = 3;
tag`Hi ${name}, you have ${count} msgs`;
// "Hi Ada, you have 3 msgs"
// .raw 保留未转义原文(\n 不会变成换行)
String.raw`C:\dir\new`; // "C:\dir\new"(String.raw 是内置标签函数)
// 真实世界:这些「DSL」都是标签模板
// const Btn = styled.button`color: ${p => p.color};`;sql`...` 能防注入,是因为库把插值收集成参数单独交给数据库;若你自己写的标签只是把 strings 和 values 拼回一个字符串,它就和普通 + 拼接一样危险。别看到反引号就以为安全。strings 数组长度总比插值多 1(两端各有一段,可能是空串)。用 String.raw 写正则或 Windows 路径可免去反斜杠转义。JS 只有一种数字类型,整数小数共用 64 位浮点——这条设计让你少记类型,也埋下「0.1 + 0.2 不等于 0.3」和「大整数会悄悄失精」两颗雷,这张卡就是拆这两颗雷。
JS 只有一种数字类型number(64 位双精度浮点,整数小数不分家;超大整数另有 BigInt)。算术运算符 + - * / % 与幂 **;/ 不取整,要整除配 Math.floor/Math.trunc。浮点精度:十进制小数无法被二进制精确表示,0.1 + 0.2 得 0.30000000000000004——展示用 toFixed,比较用误差容忍。字符串转数字用 Number()(严格)或 parseInt/parseFloat(宽松,从头解析)。BigInt:需要超过 2^53-1(Number.MAX_SAFE_INTEGER)的精确大整数时,用字面量后缀 n(如 10n)或 BigInt(x);它不能与 number 直接混合运算(会抛 TypeError),需先显式转换,也不参与 Math 与 JSON 序列化。// 只有一种 number(64 位浮点);超大整数用 BigInt
7 / 2 // 3.5(除法不取整)
Math.floor(7 / 2) // 3(向下取整;round 四舍五入、trunc 截断)
7 % 2 // 1(取余)
2 ** 10 // 1024(幂)
// ⚠️ 浮点精度:十进制小数无法被精确表示
0.1 + 0.2 // 0.30000000000000004,不等于 0.3!
(0.1 + 0.2).toFixed(2) // "0.30"(定点字符串,用于展示)
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON // true(带误差比较,细节见 18 章)
// 字符串 → 数字
Number("42") // 42;Number("42px") → NaN(严格)
parseInt("42px", 10) // 42(宽松,从头解析;带基数更稳)
parseFloat("3.14x") // 3.14
Number.isNaN(Number("x")) // true(判断转换失败)
// BigInt:精确大整数,字面量后缀 n
9007199254740993n // 超出 number 安全范围仍精确
2n ** 64n // 18446744073709551616n(不丢精度)
// 1n + 1 → ❌ TypeError:BigInt 不能与 number 混算
Number(10n) + 1 // 11(先显式转换才能混用)0.1 + 0.2 !== 0.3 是浮点运算的固有特性(几乎所有语言都如此)。涉及金额时用整数最小单位(如以「分」为单位,100 表示 1 元)或 decimal 库,切勿用浮点直接判等。Number.MAX_SAFE_INTEGER(9007199254740991)就会悄悄失精:9007199254740992 + 1 仍等于 9007199254740992,加了跟没加一样。后端用 int64 生成的 ID / 订单号是重灾区——传输时用 string,需要运算时上 BigInt。JavaScript · 控制流与循环
分支与循环是一切逻辑的骨架。本章讲透 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 = "" || "匿名"; // "匿名"——空串是假值也被兜底了let x = ... 会直接报「重复声明」错——需要变量时给 case 体单独套一层 { }。② 嵌套三元(a ? b : c ? d : e)读的人要在脑内画决策树,两层以上就该换成 if/else 或查表对象。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; // 直接跳出外层循环
}
}splice,删除后后面的元素整体前移,下一轮 i++ 会跳过一个元素。解法:倒序遍历(从 length-1 递减),或干脆用 filter 生成新数组。选遍历工具前先问一句:我要的是「值」还是「键」? for...of 拿值、for...in 拿键、forEach 是数组方法——三者语义完全不同,用错的代价从「拿到奇怪的字符串下标」到「async 静默失效」不等。
for...of:遍历可迭代对象的「值」(首选)
- 凡是实现了迭代协议的对象都能遍历:数组、字符串、Map、Set、NodeList、
arguments、生成器(协议细节见 12 章「可迭代对象」卡); - 支持
break/continue,循环体里await会被正常等待——它是普通循环语句,没有回调边界; - 要下标时配
entries():for (const [i, v] of arr.entries()),拿到的i是数字; - 遍历字符串时按码点走,一个 emoji 是一轮——而
for...in和s[i]按 UTF-16 码元,"👍a"用 for...of 是 2 轮、for...in 是 3 个键(详见 18 章「字符串是 UTF-16 码元序列」); - 普通对象不可迭代,直接 for...of 会抛
TypeError: ... is not iterable;惯用法是Object.keys / values / entries转成数组再 for...of——只取自身属性,不碰原型链。
for...in:遍历可枚举「键」,三大坑
- 坑一:它会沿原型链把继承来的可枚举属性也遍历出来(有代码给
Array.prototype或Object.prototype加过属性你就会遇到)。非用不可时套一层守卫:if (!Object.hasOwn(o, k)) continue;(键的五种取法见 08 章「对象遍历」卡); - 坑二:遍历数组时拿到的下标是字符串
"0" "1",做key + 1得到的是"01"; - 坑三:它遍历的是键而不是下标区间——空洞被跳过,而挂在数组上的非下标属性会混进来。给
[1, , 3]加一个arr.foo后 for...in 得到["0", "2", "foo"]; - 所以几乎永远不要用 for...in 遍历数组,它唯一合理的场景是调试时探查对象的全部可枚举键。
forEach:先看清签名,再看它不能干什么
- 回调收三个参数:
arr.forEach((值, 下标, 原数组) => ...)——要下标不必配 entries(),第二个参数就是数字下标,这是 forEach 相对 for...of 唯一顺手的地方; - 它还接第二个参数 thisArg:
arr.forEach(fn, obj)把回调里的this绑成obj——但只对function回调有效,箭头函数会无视它(箭头回调里 this 仍是外层的)。箭头函数时代这个参数基本已经没人用了; - 它返回 undefined,不能像
map/filter那样链式接下去;跳过空洞([1, , 3]只走 2 次,而 for...of 走 3 次、中间给undefined); - 它无法 break / continue:回调里
return只能跳过当前元素,想中途停只能换 for...of,或用some / every([1,2,3,4,5].some(v => v === 3)只访问了 3 项就停); - 回调写成 async 也没用——forEach 不会等待回调返回的 Promise,所有回调几乎同时发起且外部无法知道何时全部完成。要顺序执行用 for...of + await,要并发用
Promise.all配map。
两个只有对比才看得出的差别
- 遍历途中改数组,三者行为完全不同。forEach 的访问范围在调用那一刻就定死了,循环中
push进来的新元素不会被访问;for...of 每轮都重新问一次迭代器,会一直追着新元素跑——边遍历边 push 跑满 20 轮仍不停,就是死循环。而splice删除会让 forEach 漏掉一个:[1,2,3,4]里删掉首项后,回调只看到1, 3, 4(和上一张卡「下标 for + splice 跳元素」同源); - 性能:日常无所谓,for...in 例外。Node 24.18 上遍历 500 万项各跑三轮:经典
for约 3.7 ms、forEach约 25 ms、for...of约 34 ms、entries()约 46 ms、for...in约 300–450 ms。前四者折算到每项都是纳秒级,几百上千项的日常场景按可读性选即可;但 for...in 慢了将近 100 倍(它要现算一份键列表并爬原型链)——这是不用它的第四个理由。
const arr = ["a", "b", "c"];
for (const v of arr) console.log(v); // "a" "b" "c" —— 值
for (const k in arr) console.log(k); // "0" "1" "2" —— 字符串键!
// for...in 拿字符串下标做运算的坑
for (const i in arr) {
console.log(i + 1); // "01" "11" "21"(字符串拼接,不是加法)
}
// for...in 遍历的是"键":洞被跳过,挂上去的属性混进来
const holed = [1, , 3];
holed.foo = "bar";
for (const k in holed) console.log(k); // "0" "2" "foo":没有 "1",多了 "foo"
// —— 要下标的两种写法 ——
for (const [i, v] of arr.entries()) {
console.log(i, v); // 0 "a" / 1 "b" / 2 "c",i 是数字
}
arr.forEach((v, i, 原数组) => { // forEach 回调第二个参数就是下标
console.log(i, v); // 第三个参数是数组本身,用得很少
});
// —— 遍历途中改数组:范围定死 vs 追着新元素跑 ——
const p = [1, 2, 3];
p.forEach(v => { if (v === 1) p.push(99); }); // 回调只跑 1 2 3,99 不会被访问
const q = [1, 2, 3];
for (const v of q) q.push(v); // ❌ 死循环:每轮都重新问迭代器
const r = [1, 2, 3, 4];
r.forEach(v => { if (v === 1) r.splice(0, 1); }); // 回调只看到 1 3 4 —— 漏了 2
// forEach 的 async 陷阱:await 不会被等待
const ids = [1, 2, 3];
ids.forEach(async (id) => {
await save(id); // ❌ 三个请求同时发出,外部无法等它们完成
});
// ✅ 顺序执行:for...of 里 await 会被正常等待
for (const id of ids) {
await save(id);
}
// 普通对象:Object.entries + for...of + 解构
const user = { name: "Tom", age: 18 };
for (const [key, value] of Object.entries(user)) {
console.log(key, value); // name Tom / age 18
}await 以为是顺序执行——实际所有回调立即全部发起,后续代码也不会等它们,「保存了但列表没刷新」多半源于此。async 场景一律改 for...of(顺序)或 await Promise.all(ids.map(...))(并发)(异步详见 14 章)。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++]);splice 与 slice 一字之差、行为相反:splice 改原数组、返回被删掉的部分;slice 不改原数组、返回选中的部分。把 arr.splice(0, 3) 当成「取前三个」用,是每个人都栽过一次的坑——它取走了前三个。arr.at(-1),别用 arr.pop()——后者会真的删掉它。想一次追加另一个数组的全部元素,arr.push(...other) 比 arr = arr.concat(other) 少造一个新数组,但展开出来的是函数实参,个数有上限——10 万项还能跑、12.5 万项就 RangeError: Maximum call stack size exceeded(阈值随引擎与当前栈深浮动)。上万量级就老老实实用 for...of 循环 push。JS 的数组是对象,下标是键、length 是一个普通的可写属性。由此带来两件其它语言里没有的事:给 length 赋值会当场截断或补长,以及数组中间可以是「什么都没有」——空洞(hole),它与 undefined 不是一回事。
length 可写
a.length = 2把[1,2,3,4]当场截成[1,2],多出来的项被丢弃——这是最短的清空写法a.length = 0的来历;a.length = 3把[1]撑长到 3,但新增的两格是空洞:Object.keys()仍然只有["0"];- 给一个超出末尾的下标赋值同理:空数组上写
a[100] = 1,length直接变 101,中间 100 个全是洞。
空洞的行为:一半方法跳过它,一半方法把它当 undefined
[1,,3]的length是 3,但1 in a是 false,Object.keys(a)是 ["0","2"]——那一格根本不存在;- 跳过洞:
forEach、filter、reduce、Object.keys;保留洞:map(回调不执行,但结果里那一格还是洞);把洞当 undefined:for...of、展开[...a]、includes;删掉洞:flat();变成 null:JSON.stringify→"[1,null,3]"; delete arr[1]只挖洞、不改长度——它是对象的删属性语义,不是数组的删元素语义。要真删元素用splice。
// —— length 可写 ——
const t = [1, 2, 3, 4];
t.length = 2; // [1, 2] ← 当场截断
const g = [1];
g.length = 3; // 长度 3,但 Object.keys(g) 仍是 ["0"]
// —— 空洞 ≠ undefined ——
const a = [1, , 3]; // 中间是「洞」
a.length; // 3
1 in a; // false ← 这个键不存在
Object.keys(a); // ["0", "2"]
a.forEach(x => ...); // 只走 2 次:1 和 3,洞被跳过
a.map(x => x * 2); // [2, <1 empty item>, 6] ← 洞保留,回调没跑
[...a]; // [1, undefined, 3] ← 展开把洞变成 undefined
a.flat(); // [1, 3] ← 洞被删掉
JSON.stringify(a); // "[1,null,3]" ← 洞变成 null
a.includes(undefined); // true ← 但 a.indexOf(undefined) 是 -1
// —— delete 只挖洞,不缩短 ——
const d = [1, 2, 3];
delete d[1];
d.length; // 3 ← 还是 3!
Object.keys(d); // ["0", "2"]
// —— 造一个「长度为 n 且真的有值」的数组 ——
new Array(3); // 3 个洞:.map() 一次都不会执行
Array(3).fill(0); // [0, 0, 0] ← fill 会把洞填实
Array.from({ length: 3 }, (_, i) => i); // [0, 1, 2] ← 最常用的写法delete 删数组元素,或者从别处拿到一个 new Array(n)。真实数据里出现洞,通常意味着上游有 bug。判断有没有洞最快的一招是 arr.length !== Object.keys(arr).length。Array.from({ length: n }, (_, i) => i),别写 new Array(n).map((_, i) => i)——后者返回的是 n 个洞,回调一次都不会执行,而且不报错。这是「代码看起来对、结果是一片空」的经典来源。四组方法覆盖「这东西在不在」「它在第几位」「第一个满足条件的是谁」「倒着取一个」。它们的差别不只是写法,indexOf 和 includes 用的甚至不是同一套相等语义。
按问题选方法
- 在不在 →
includes(值)返回布尔;在第几位 →indexOf(值)/lastIndexOf(值)返回下标,找不到是 -1; - 按条件找 →
find(fn)返回元素本身、findIndex(fn)返回下标;从后往前是findLast/findLastIndex(ES2023);四个都命中即短路; - 要判断「存在与否」就别用
indexOf(x) !== -1——includes更直白,而且能正确处理NaN; - 负下标取值 →
at(-1)(ES2022),比arr[arr.length - 1]短,也不会在链式表达式里把前半段写两遍。
两条语义差
[NaN].indexOf(NaN)是 -1,[NaN].includes(NaN)是 true:前者用===(而NaN === NaN为假),后者用 SameValueZero(把 NaN 视为等于自身)。数值数组里查 NaN,只有includes有效;- 对空洞:
[1,,3].includes(undefined)是 true(洞被当作 undefined),[1,,3].indexOf(undefined)是 -1(洞不是一个存在的键)——同一个数组,两个方法给出相反的答案。
const nums = [1, 2, 3, 4];
nums.includes(2); // true ← 问「在不在」用这个
nums.indexOf(2); // 1 ← 问「在第几位」用这个
nums.indexOf(99); // -1 ← 找不到是 -1,不是 undefined
nums.find(n => n > 2); // 3 (元素本身)
nums.findIndex(n => n > 2); // 2 (下标)
nums.findLast(n => n < 4); // 3 (从后往前,ES2023)
nums.findLastIndex(n => n < 4); // 2
nums.at(-1); // 4 ← 末项,不改数组
nums.at(-2); // 3
// —— NaN:两套相等语义的分水岭 ——
[NaN].indexOf(NaN); // -1 ← 用 ===,而 NaN === NaN 为假
[NaN].includes(NaN); // true ← 用 SameValueZero
// —— 反复查成员:换 Set,O(n) 变 O(1) ——
const banned = new Set(bannedIds);
rows.filter(r => !banned.has(r.id)); // ✅ 每行一次哈希查找
rows.filter(r => !bannedIds.includes(r.id)); // ❌ 每行全表扫一遍includes,是最常见的隐形 O(n²)。几十个元素看不出来,几万个就是几秒的卡顿。判据很简单:只要「在一个集合里反复查成员」,先 new Set(...) 再查(选型对照见深水区的「对象 · Map · 数组 · Set」)。find 找不到返回 undefined,配可选链最顺手:list.find(x => x.id === id)?.name ?? "未知"。而 findIndex 找不到返回 -1——-1 是真值,if (list.findIndex(...)) 在「没找到」时反而成立,这一条必须写成 !== -1。比起手写 for 循环,高阶方法把「遍历」标准化,让你只声明「每一项要变成什么」——代码更短、无需维护中间变量、且天然可链式组合。它们都返回新数组(或新值)、不改原数组,契合不可变风格。
三个核心 + 两类常用
- map(fn):一对一变换,长度不变——「每项映射成新值」;
- filter(fn):保留回调返回真值的项——「筛选」,长度可变小;
- reduce(fn, init):把整个数组「折叠」成一个值(求和、分组、拼对象)——最通用,map/filter 都能用它实现;
- flatMap(fn):map 后再拍平一层,适合「一项展开成多项」;find / some / every:查找与存在性判断,命中即短路。
易错点
forEach只为副作用、返回 undefined,想要新数组必须用map;- 回调写成
async得到的是 Promise 数组,需await Promise.all(arr.map(...))收集; reduce务必传初始值,空数组不传会抛错。
const nums = [1, 2, 3, 4];
nums.map(n => n * 2); // [2, 4, 6, 8](一对一)
nums.filter(n => n % 2 === 0); // [2, 4](筛选)
nums.reduce((sum, n) => sum + n, 0); // 10(折叠成一个值)
// flatMap:一项展开成多项,再拍平一层
[1, 2].flatMap(n => [n, n * 10]); // [1, 10, 2, 20]
// 链式组合:过滤 → 变换 → 求和,读起来像一句话
const total = [{ price: 5, on: true }, { price: 8, on: false }]
.filter(p => p.on)
.map(p => p.price)
.reduce((a, b) => a + b, 0); // 5
// reduce 把数组折叠成对象(建索引 / 分组)
const byId = [{ id: "a" }, { id: "b" }]
.reduce((acc, x) => (acc[x.id] = x, acc), {});
// { a: {id:"a"}, b: {id:"b"} }
// 查找 / 存在性(命中即短路)
nums.find(n => n > 2); // 3(第一个满足的元素)
nums.some(n => n > 3); // true
nums.every(n => n > 0); // truemap 的回调若写 async,返回的是 Promise 数组而非结果数组——必须 await Promise.all(arr.map(async ...))。另外 reduce 不传初始值且数组为空会 TypeError,永远显式传第二个参数。Array 卡。数组方法分成泾渭分明的两派:就地派改原数组并返回同一个引用,返新派原数组一动不动。在 React / Vue / Redux 这类靠「引用变了没」判断是否更新的地方,用错一派的后果是数据改了、界面不动,而且不报错。
谁改原数组,谁不改
- 就地改:
sort、reverse、splice、fill、copyWithin、push/pop/shift/unshift; - 返新数组:
map、filter、slice、concat、flat、flatMap,以及 ES2023 补上的四个不可变版:toSorted、toReversed、toSpliced、with; with(下标, 值)是「换掉一项」的不可变写法,替代[...arr.slice(0,i), v, ...arr.slice(i+1)];fill没有对应的不可变版,需要时用map。
sort 的默认行为几乎总不是你要的
sort()不传比较器时,会把每个元素转成字符串再按码元序比较——[10, 9, 1].sort()得到[1, 10, 9],因为"10" < "9"。数字数组必须写sort((a, b) => a - b);sort返回的是原数组本身:const sorted = arr.sort()之后sorted === arr为真,你以为拿到了副本,其实两个名字指着同一个数组;- 比较器的更多契约(稳定性、必须自洽、字符串排序要用
localeCompare)见深水区的「排序的三条契约」。
// —— 默认排序是字符串序 ——
[10, 9, 1].sort(); // [1, 10, 9] ← "10" < "9"
[10, 9, 1].sort((a, b) => a - b); // [1, 9, 10] ← 数字必须传比较器
// —— 就地:返回的就是原数组 ——
const arr = [3, 1, 2];
const sorted = arr.sort((a, b) => a - b);
sorted === arr; // true!原数组已经被改了
// —— 返新:ES2023 四件套,原数组一动不动 ——
const src = [3, 1, 2];
src.toSorted((a, b) => a - b); // [1, 2, 3],src 仍是 [3, 1, 2]
src.toReversed(); // [2, 1, 3]
src.toSpliced(1, 1); // [3, 2] ← 删掉下标 1 之后的新数组
src.with(0, 9); // [9, 1, 2] ← 换掉一项
// —— 状态更新里的差别 ——
setList(list.sort(f)); // ❌ 引用没变,React 认为「没更新」,界面不动
setList(list.toSorted(f)); // ✅ 新数组新引用
setList([...list].sort(f)); // ✅ 旧写法:先复制再就地排toSorted 等四个方法是 ES2023,Node 20 / 现代浏览器都有,但面向老环境(尤其是没上 polyfill 的小程序、旧 WebView)时会直接 TypeError。不确定就退回 [...arr].sort()——多一次复制,兼容性换来的。to 开头的(toSorted / toReversed / toSpliced)一定返新,这是 TC39 特意定的命名约定;with 是个例外,但它同样返新。同一批提案也给字符串和 TypedArray 加了对应方法。三个日常高频操作,现在都有了原生写法,不必再手写 reduce:嵌套拍平、按 key 分组、去重。
三件事的标准写法
- 拍平:
flat(深度)默认只拍一层,flat(Infinity)拍到底——[1,[2,[3,[4]]]].flat(Infinity)得[1,2,3,4];flatMap(fn)等于map之后flat(1),用于「一项展开成零到多项」; - 分组:
Object.groupBy(可迭代, 取键函数)(ES2024)返回普通对象;要用非字符串的键(对象、数字保持原类型)就用Map.groupBy,它返回真正的Map; - 去重:
[...new Set(arr)]——保持首次出现的顺序,用的是 SameValueZero(所以NaN也能正确去重)。对象数组按字段去重则用Map:[...new Map(list.map(x => [x.id, x])).values()],同 id 保留最后一个。
groupBy 的返回值不是普通对象
Object.groupBy(...)返回的是 null 原型对象:Object.getPrototypeOf(结果)是null。取值、for...in、展开都正常,但它没有从 Object.prototype 继承来的方法——调结果.hasOwnProperty("odd")会当场 TypeError;- 这其实是刻意的安全设计:分组键往往来自用户数据,null 原型让
"__proto__"、"toString"这类键不会撞上继承属性。要判断某个分组在不在,用Object.hasOwn(结果, 键)或键 in 结果。
// —— 拍平 ——
[1, [2, [3]]].flat(); // [1, 2, [3]] ← 默认深度 1
[1, [2, [3, [4]]]].flat(Infinity); // [1, 2, 3, 4]
["a b", "c"].flatMap(s => s.split(" ")); // ["a", "b", "c"]
// —— 分组(ES2024)——
Object.groupBy([1, 2, 3, 4], n => n % 2 ? "odd" : "even");
// { odd: [1, 3], even: [2, 4] } ← 但原型是 null:
// Object.getPrototypeOf(结果) === null
// 结果.hasOwnProperty("odd") → TypeError: 不是函数
// Object.hasOwn(结果, "odd") → true ← 用这个
Map.groupBy(users, u => u.team); // 返回真正的 Map,键可以是任意值
// —— 去重 ——
[...new Set([1, 2, 2, 3])]; // [1, 2, 3] ← 保持首次出现的顺序
// 对象数组按 id 去重(同 id 保留最后一个)
[...new Map(list.map(x => [x.id, x])).values()];
// 交集 / 差集:Set 查找 + filter
const b = new Set(listB);
listA.filter(x => b.has(x)); // 交集
listA.filter(x => !b.has(x)); // 差集[...new Set(arr)] 去重只对原始值有效:两个内容相同的对象是两个不同的引用,Set 一个都不会合并。对象数组一律走 Map + 业务主键那条路。另外 flat() 会顺手删掉空洞,如果你正靠洞占位,拍平之后位置就全错了。Object.groupBy 挂在 Object 上而不是 Array 上,是因为它接受任意可迭代对象——Set、Map、生成器都能直接分组,不必先转数组。同理 Array.from、Promise.all 收的也都是可迭代对象。「看起来像数组但没有数组方法」是 DOM 和旧 API 里的日常。转换有两条路,它们接受的东西并不一样——搞混就是一句 is not iterable。
两条路的分界线
- 展开
[...x]只认可迭代对象——必须实现Symbol.iterator。数组、字符串、Set、Map、生成器、NodeList、arguments都可以; Array.from(x)认两类:可迭代对象,以及「类数组」——只要有length和数字键就行,不需要Symbol.iterator。[...{ 0: "a", 1: "b", length: 2 }]抛 TypeError: x is not iterable,而Array.from同一个对象得到["a", "b"];Array.from还接第二个参数(映射函数),省掉一次中间数组:Array.from(set, x => x * 2)比[...set].map(x => x * 2)少造一个数组。
三个容易记反的点
Array(3)与Array.of(3)完全不同:前者造一个长度为 3 的空数组(三个洞),后者造[3]。Array.of存在的唯一理由就是绕开这个「单个数字参数被当成长度」的历史包袱;typeof []是"object",判断数组必须用Array.isArray(x)。x instanceof Array在跨 iframe / 跨 vm 上下文时会失效(每个 realm 有自己的Array),isArray不会;- 字符串按码点迭代:
[..."ab"]得["a","b"],但 emoji 这类会被拆成什么,取决于它是不是代理对——细节见深水区的「字符串是 UTF-16 码元序列」。
// —— 展开只吃可迭代对象 ——
const like = { 0: "a", 1: "b", length: 2 }; // 类数组,没有 Symbol.iterator
[...like]; // ❌ TypeError: like is not iterable
Array.from(like); // ✅ ["a", "b"]
// —— Array.from 的第二个参数:边转边映射 ——
Array.from(new Set([1, 2]), x => x * 2); // [2, 4],不造中间数组
Array.from({ length: 3 }, (_, i) => i); // [0, 1, 2]
Array.from("abc"); // ["a", "b", "c"]
// —— Array(3) 与 Array.of(3) ——
Array(3); // 长度 3、全是洞
Array.of(3); // [3]
// —— 判断是不是数组 ——
typeof []; // "object" ← typeof 分辨不出数组
Array.isArray([]); // true ← 只用这个
// —— DOM 里最常见的一处 ——
const nodes = document.querySelectorAll("li"); // NodeList,不是数组
nodes.map(...); // ❌ nodes.map is not a function
[...nodes].map(...); // ✅ NodeList 是可迭代的
Array.from(nodes, n => n.textContent); // ✅ 一步到位Array.from(类数组) 依赖的是 length 属性:如果那个对象的 length 是 undefined,得到的是空数组而不是报错。从奇怪的 API 拿到对象、Array.from 出来是 [] 时,先打印一下它有没有 length。new Set(arr) 去重、new Map(二维数组) 建映射、Object.fromEntries(二维数组) 建对象、arr.join("") 拼字符串。所有这些构造函数收的都是可迭代对象,所以生成器也能直接喂进去——这正是可迭代协议的价值(详见「迭代与生成器」章)。JavaScript · 函数与 this
函数是 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。当函数被单独拎出来调用(当回调、借用别的对象的方法)时 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 || 默认 判断,变成了声明式、更不易出错的写法。
...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] = 5、const [a] = null 都报 TypeError: ... is not iterable(普通对象 {} 也不行)。想安全兜底可先 ?? []:const [a] = arr ?? []。Set、生成器都能解构:const [a, b] = "hi" 得到 "h"、"i"。默认值同样只对 undefined 生效,传 null 会原样保留。对象解构按属性名取值,最常见的落点就是函数参数——把一坨配置项在参数括号里一次拆开、顺手给默认值。
支持三种变体:重命名(const { name: userName } = user)、嵌套解构与默认值。const user = { name: "Tom", age: 20, address: { city: "北京" } };
const { name, age } = user;
const { name: userName } = user; // 重命名
const { job = "未知" } = user; // 默认值
const { address: { city } } = user; // 嵌套解构
function createUser({ name, age = 18, role = "user" }) {
console.log(name, age, role);
}
createUser({ name: "Jerry" }); // "Jerry" 18 "user"const { address: { city } } = {} 报 TypeError: Cannot read properties of undefined (reading 'city')——因为要先读 address 再取 city,缺一层就断。另外 const { address: { city } } 里 address 不会成为变量,只有最内层的 city 是。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]SyntaxError: Rest element must be last element,也不能同时有多个 rest。函数形参同理,...args 后面不能再有参数。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、不能 new(TypeError: obj.greet is not a constructor)。另外字面量里的 __proto__: x 是特例——它设置的是原型而非普通键;真要存名为 __proto__ 的数据键得用计算属性 { ["__proto__"]: x }。structuredClone 先深拷贝,或使用 lodash 的 _.merge()。对象没有 map、filter,遍历要先转成数组。Object.keys / values / entries 是标准三件套;for...in 看起来更顺手,但它会把原型链上的可枚举属性也遍历进来——这是它几乎不该被使用的原因。
五种取键方式,各自看得见什么
Object.keys / values / entries:只要自有的、可枚举的、字符串键——日常 99% 用这三个;for...in:自有的 + 继承来的可枚举字符串键。在一个原型上带inherited属性的对象上,Object.keys给["1","2","b","a"],for...in多出一个"inherited";Object.getOwnPropertyNames:自有的,连不可枚举的也算;Object.getOwnPropertySymbols:自有的 Symbol 键;- Symbol 键在前三者和
for...in里全都不可见,JSON.stringify也会连同值为undefined的键一起丢掉——JSON.stringify({ a: undefined, [Symbol("k")]: 1, b: 1 })得到{"b":1}; - 键的顺序不是插入序:整数样式的键会被提前并升序(细节见深水区的「对象 · Map · 数组 · Set」)。
判断「有没有这个键」:三个写法,三种含义
"k" in o—— 自有 或 继承来的都算 true;Object.hasOwn(o, "k")(ES2022)—— 只认自有,这是现在的首选;o.hasOwnProperty("k")—— 语义同上,但会被同名属性遮蔽,也在 null 原型对象上直接 TypeError(Object.groupBy的返回值就是 null 原型的)。Object.create(null)造的对象调hasOwnProperty抛 TypeError,而Object.hasOwn正常返回true。
const o = { name: "A", age: 20 };
// —— 标准三件套:只看自有的可枚举字符串键 ——
Object.keys(o); // ["name", "age"]
Object.values(o); // ["A", 20]
Object.entries(o); // [["name", "A"], ["age", 20]]
// —— 「改造每个值」的标准三步:entries → map → fromEntries ——
Object.fromEntries(
Object.entries(o).map(([k, v]) => [k, String(v)])
); // { name: "A", age: "20" }
// 按值筛选(对象没有 filter)
Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
// —— for...in 会爬原型链 ——
const child = Object.create({ inherited: 1 });
child.own = 1;
Object.keys(child); // ["own"]
for (const k in child) ... // "own", "inherited" ← 多了一个!
// —— 判断键是否存在 ——
"inherited" in child; // true ← 继承的也算
Object.hasOwn(child, "inherited"); // false ← 只认自有(ES2022,首选)
const bare = Object.create(null);
bare.x = 1;
bare.hasOwnProperty("x"); // ❌ TypeError:null 原型没有这个方法
Object.hasOwn(bare, "x"); // ✅ true
// —— 这些是看不见的 ——
JSON.stringify({ a: undefined, [Symbol("k")]: 1, b: 1 });
// {"b":1} ← undefined 值和 Symbol 键都被丢掉了for...in 不要用来遍历数组:它给出的下标是字符串("0" 而不是 0),会跳过空洞,还会把别人挂在 Array.prototype 上的属性一并遍历进来。数组遍历一律用 for...of(要值)或 entries()(要下标和值)。entries → map → fromEntries 是对象版的「map/filter」,值得当成一个固定手法记住。要遍历的是「键是运行时数据、还要保序、还要频繁增删」的集合,就别用对象了,换 Map——它自带 size、可以直接 for...of,选型对照见深水区那张卡。拷贝出错的根源是「引用」:改了副本却动了原件。分清浅拷贝只复制第一层、深拷贝逐层复制,才能写对不可变更新。
浅拷贝只复制第一层,嵌套对象仍是同一引用。深拷贝递归复制所有层级,推荐用structuredClone()(现代环境原生支持,比 JSON 序列化更强大,细节与局限见 15 序列化与编码)。const original = { name: "A", info: { age: 20 }, tags: ["js"] };
// ── 浅拷贝 ────────────────────────────────────
const shallow = { ...original }; // 或 Object.assign({}, original)
shallow.name = "B"; // ✅ 原始值:互不影响
shallow.info.age = 99; // ❌ 引用类型:原对象也被改了!
console.log(original.info.age); // 99
// ── 深拷贝:structuredClone(推荐)──────────
const deep = structuredClone(original); // 支持 Date/Map/Set/ArrayBuffer
deep.info.age = 1;
deep.tags.push("ts");
console.log(original.info.age); // 20,完全独立
console.log(original.tags); // ["js"],完全独立
// ── JSON 深拷贝(老写法,有局限)─────────────
const jsonClone = JSON.parse(JSON.stringify(original));
// ⚠️ 不支持 undefined / Function / Symbol / Date / Map / Set 等类型structuredClone 不能克隆含 Function、Symbol、DOM 节点的对象,这些场景仍需 lodash _.cloneDeep()。structuredClone 能处理循环引用和 Date/Map/Set,但遇到函数或 Symbol 会抛 DataCloneError: ... could not be cloned。老写法 JSON.parse(JSON.stringify(x)) 则会悄悄丢掉 undefined 和函数、把 Date 变成字符串——排查「拷贝后数据变样」的诡异 bug 时先想到这点。JavaScript · Map / Set
ES6 引入的新集合类型,解决了普通对象做哈希表时键只能是字符串、容易和原型属性冲突等问题。
普通对象拿来当哈希表有两个硬伤:键只能是字符串、还会跟原型上的 toString 之类撞车。Map 就是为「任意键的字典」而生。
.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。NaN 能当键并正常取回(普通 === 里 NaN !== NaN,Map 是例外),+0 与 -0 视为同一个键。取回按插入顺序,for...of 直接拿到 [key, value]。「数组去重」和「这个我处理过没有」是天天遇到的需求,Set 把它们从 indexOf 循环、对象打标记的老套路里解放出来。
Set 存储不重复的值,最常见用途是数组去重。const arr = [1, 1, 2, 3, 3];
const unique = [...new Set(arr)]; // [1, 2, 3]
const set = new Set([1,2,3]);
set.add(4);
set.has(2); // true
set.delete(1);
set.size; // 3new Set([[1],[1]]).size 是 2,[...new Set([{a:1},{a:1}])] 也留两个。去重对象数组得自己降维——先按某个 key(如 id)或 JSON.stringify 归一。a.union(b)、a.intersection(b)、a.difference(b)、a.symmetricDifference(b)(只在一边出现的元素,{1,2} 对 {2,3} 得 {1,3}),三个直接回答是非的 a.isSubsetOf(b)、a.isSupersetOf(b)、a.isDisjointFrom(b)。省掉手写 filter,也省掉「先转数组再 includes」的 O(n²)。去重也走 SameValueZero,NaN 能正常去重、只留一个。长期运行的应用里最隐蔽的内存泄漏,就是「对象早已没用、却还被某个 Map 攥着回收不掉」。WeakMap/WeakSet 让关联数据随对象一起被 GC 带走。
JS 的垃圾回收(GC)基于可达性:从根出发再也引用不到的对象会被自动回收。普通Map 的键是强引用——对象在别处已经没用了,只要还躺在 Map 里就永远回收不掉,这是长生命周期应用最典型的内存泄漏。WeakMap / WeakSet 的键是弱引用:键对象一旦在别处不可达,条目自动消失。代价是键必须是对象(ES2023 起也接受非注册 Symbol)、不可遍历、没有 size(条目随时可能被 GC 拿走)。典型用途:给 DOM 节点或第三方对象挂关联数据、做实例级缓存。const meta = new WeakMap();
let panel = document.querySelector("#panel");
meta.set(panel, { clicks: 0 });
meta.get(panel).clicks++; // 像 Map 一样读写
panel.remove();
panel = null; // 节点不可达 → 条目随之可被回收
// 若用 new Map():Map 仍强引用节点,DOM 删了内存也收不回
// WeakSet:给对象打"处理过"的标记,而不阻止回收
const seen = new WeakSet();
function handle(obj) {
if (seen.has(obj)) return;
seen.add(obj);
}JavaScript · 可选链 · 空值合并
ES2020 之后引入的几个语法糖,能大幅减少防御性的 if 判断代码。
深层取值时「中间某层可能是 null」逼出满屏 a && a.b && a.b.c,?. 就是把这串防御压成一个符号。
?. 会短路返回 undefined 而不是抛出错误。const user = { profile: { address: null } };
// ❌ 旧写法
const city = user.profile && user.profile.address && user.profile.address.city;
// ✅ 新写法
const city = user.profile?.address?.city; // undefined,不报错
user.sayHi?.(); // 方法不存在时不报错?. 只保护紧挨它左边那一次访问:a?.b.c 在 a.b 为 null 时,.c 仍会抛 TypeError: Cannot read properties of null——每一段可能为空处都得写 ?.。另外它不能作赋值目标,o?.x = 1 报 SyntaxError: Invalid left-hand side in assignment。?. 左侧是 null/undefined,整条链后续全部短路、直接返回 undefined,后面的属性、方法调用、下标都不再执行:a?.b.c() 里 a 为空时 c() 根本不会被调用。方法调用写 obj.fn?.()、动态下标写 obj?.[key]。|| 设默认值会把 0、""、false 这些合法值一起吃掉,?? 就是只在「真的没值」(null/undefined)时才兜底。
null 和 undefined 两个值——0、NaN、""、false 一律算「有值」,原样返回左侧。const count = 0;
count || 10; // 10 ❌ 把合法的 0 覆盖掉了
count ?? 10; // 0 ✅ 只有 null/undefined 才用默认值
const cfg = { retries: 0, timeout: null };
cfg.retries ?? 3; // 0(保留有效的 0)
cfg.timeout ?? 5000; // 5000?? 不能和 ||/&& 在同一层混用,a || b ?? c 直接报 SyntaxError: Unexpected token '??',必须加括号 (a || b) ?? c 显式指定优先级。还要留意 NaN 不算 nullish,NaN ?? 1 得到的是 NaN。a = a ?? b 这类「有值就留着、没值才补」的初始化太常见,逻辑赋值把它压成了一个运算符。
a ??= b 等价于 a = a ?? b,是惰性赋值/初始化默认值的简洁写法。const cache = {};
(cache.list ??= []).push(1);
(cache.list ??= []).push(2);
console.log(cache.list); // [1, 2]a ??= b 当 a 已有值时连赋值动作都跳过——若 a 是带 setter 的属性或响应式对象(Vue/Proxy),setter 不会触发(非空时 setter 调用数为 0)。指望 setter 副作用时别想当然。a ??= b 仅当 a 为 null/undefined 才赋值;a ||= b 在 a 为假值时赋值;a &&= b 在 a 为真值时才赋值。(cache.list ??= []).push(1) 是「懒初始化再用」的一行经典写法。JavaScript · 类、原型与元编程
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(); // "狗: 汪汪"new 报 ReferenceError: Cannot access 'X' before initialization;把类当普通函数调用(漏了 new)报 TypeError: Class constructor X cannot be invoked without 'new'。x = 1)挂在每个实例上、Object.keys 枚举得到;方法定义在 prototype 上、所有实例共享同一份且不可枚举——Object.keys(new C()) 只列字段不列方法。想每实例独立一份状态用字段,想省内存的共享行为用方法。extends 把子类的原型接到父类,而 super 是子类访问父类构造器与方法的唯一通道。
子类构造函数中必须先调用 super() 才能使用 this,super() 负责初始化父类的部分。class Dog extends Animal {
constructor(name, breed) {
super(name, "汪汪");
this.breed = breed;
}
speak() {
super.speak();
console.log(`品种: ${this.breed}`);
}
}
const h = new Dog("哈士奇", "雪橇犬");
h instanceof Dog; // true
h instanceof Animal; // truesuper() 之前碰 this 直接报 ReferenceError: Must call super constructor in derived class before accessing 'this'…;哪怕整段不写 super()、只要用到 this 也是同一个错——所以派生类构造器第一句几乎总是 super(…)。extends 继承:子类可直接 Sub.make() 调父类静态方法(原型链上 Object.getPrototypeOf(Sub) === Base);但静态方法不落到实例上,new Sub().make 是 undefined。#field 是语言级的私有——不靠下划线约定,而是引擎强制外部根本碰不到。
ES2022 引入了真正的私有字段语法#field,外部完全无法访问。get/set 定义计算属性,访问时不需要加括号。class BankAccount {
#balance = 0;
constructor(initial) { this.#balance = initial; }
deposit(amount) { this.#balance += amount; }
get balance() { return `¥${this.#balance}`; }
}
const acc = new BankAccount(100);
acc.deposit(50);
console.log(acc.balance); // "¥150"#x 是语法错误、不是运行期 undefined:obj.#balance 直接 SyntaxError: Private field '#balance' must be declared in an enclosing class,连解析都过不去。另外只写 getter 不写 setter 时,严格模式下赋值报 TypeError: Cannot set property … which has only a getter,非严格模式静默丢弃。#x in obj 做 brand check——返回 true/false 而不报错。getter/setter 访问时不带括号:写 acc.balance 而非 acc.balance()。读懂原型链,才能真正看明白 class、instanceof 和方法共享在底层各自做了什么。
JS 的继承本质是原型链:每个对象都有一个内部指针[[Prototype]](可通过 Object.getPrototypeOf(obj) 读取),当访问一个属性时,引擎先在对象自身查找,找不到就沿着原型链逐级向上查找,直到 null 为止。class 语法只是对原型链的一层封装,理解原型链才能真正看懂 instanceof、方法共享、框架源码等底层机制。// 构造函数 + prototype(class 的底层实现)
function Animal(name) {
this.name = name;
}
Animal.prototype.speak = function() {
console.log(`${this.name} 说话了`);
};
const dog = new Animal("狗");
dog.speak(); // "狗 说话了"
Object.getPrototypeOf(dog) === Animal.prototype; // true
// 原型链查找顺序:dog → Animal.prototype → Object.prototype → null
dog.hasOwnProperty("name"); // true(自身属性)
dog.hasOwnProperty("speak"); // false(在原型上)
// Object.create:直接指定原型创建对象
const proto = {
greet() { console.log("Hello, I am", this.name); }
};
const obj = Object.create(proto);
obj.name = "Tom";
obj.greet(); // "Hello, I am Tom"
// Object.create(null) → 无原型的纯净对象(常用于字典/Map 替代)
const dict = Object.create(null);
// dict 没有 toString/hasOwnProperty 等继承方法,适合做干净的 key-value 存储
// class 与原型链的等价关系
class Dog extends Animal {}
// 等价于:
// Dog.prototype.__proto__ === Animal.prototype → true
// Object.getPrototypeOf(Dog) === Animal → true(静态继承)
Object.getPrototypeOf(Dog.prototype) === Animal.prototype; // true
// instanceof 的原理:检查原型链上是否有目标的 prototype
dog instanceof Animal; // true → dog.__proto__ === Animal.prototype
dog instanceof Object; // true → 原型链最终都指向 Object.prototype
// 检查属性来源
for (const key in dog) {
// for...in 会枚举原型链上的可枚举属性
if (dog.hasOwnProperty(key)) console.log("自身:", key);
else console.log("原型:", key);
}__proto__(如 obj.__proto__ = other),这是非标准且性能极差的操作。需要改变原型关系应使用 Object.create() 或 Object.setPrototypeOf()(同样有性能代价,应在对象创建时就确定好原型)。prototype → 上级构造函数的 prototype → … → Object.prototype → null。方法定义在 prototype 上,所有实例共享同一份函数,节省内存;属性定义在实例上(this.xxx = ...),每个实例独立。Symbol 提供一种绝不撞名、也不会被常规枚举翻到的键,是给对象挂协议和元数据的官方手段。
Symbol() 生成独一无二、不可伪造的值,是 ES6 新增的基本类型(typeof 得 "symbol",见 03 章的七种基本类型)。每次调用都全新且互不相等,天生适合做「不会和别人撞」的对象键——给对象挂私有/元数据字段而不污染普通属性,也不被 for...in/Object.keys/JSON 枚举到。Symbol.for(key) 走全局注册表:同名返回同一个 Symbol,可跨模块共享。知名符号(well-known symbols)是语言预留的 Symbol,用作「定制内置行为」的钩子:实现 [Symbol.iterator] 让对象可迭代、[Symbol.toPrimitive] 接管类型转换、[Symbol.hasInstance] 定制 instanceof。// 独一无二:即使描述相同也不相等
Symbol("id") === Symbol("id"); // false
typeof Symbol(); // "symbol"
// 作对象键:不撞名、不被常规枚举
const ID = Symbol("id");
const user = { name: "Ada", [ID]: 1001 };
Object.keys(user); // ["name"](Symbol 键被跳过)
user[ID]; // 1001(需持有该 Symbol 才能取)
// Symbol.for:全局注册表,同名共享同一个 Symbol
Symbol.for("app.id") === Symbol.for("app.id"); // true
// 知名符号:定制内置行为(这里接管类型转换)
const money = {
amount: 42,
[Symbol.toPrimitive](hint) {
return hint === "string" ? `$${this.amount}` : this.amount;
}
};
String(money); // "$42"(hint "string")
+money; // 42(hint "number")JSON.stringify 直接丢弃、structuredClone 也不复制(克隆后 getOwnPropertySymbols 为空)。想枚举 Symbol 键得专门用 Object.getOwnPropertySymbols,Object.keys/for...in 一概看不见。Symbol.iterator(见下一张卡)。有了真正的私有字段 #x(见「私有字段」卡)后,用 Symbol() 做私有键已较少见。Symbol 键不参与 JSON 序列化,也不被 structuredClone 克隆。每个属性背后都藏着 writable、enumerable、configurable 三个开关,掌控它们就能定制对象的可变边界。
writable 管能不能改值、enumerable 管能不能被枚举、configurable 管能不能删除或再配置。Object.defineProperty 逐个精调这三个开关,freeze 与 seal 则是更粗粒度的整体封锁。// ── 属性描述符 ────────────────────────────────
const obj = { a: 1 };
Object.getOwnPropertyDescriptor(obj, "a");
// { value: 1, writable: true, enumerable: true, configurable: true }
// defineProperty:精细控制属性行为
Object.defineProperty(obj, "VERSION", {
value: "1.0.0",
writable: false, // 不可修改
enumerable: false, // 不出现在 for...in / Object.keys
configurable: false, // 不可删除,也不能再次 defineProperty
});
// obj.VERSION = "2.0" → 严格模式报错,否则静默失败
// getOwnPropertyNames:含不可枚举属性(比 Object.keys 更全)
Object.getOwnPropertyNames(obj); // ["a", "VERSION"]
// getOwnPropertySymbols:获取 Symbol 键
const sym = Symbol("id");
const o = { [sym]: 99, name: "test" };
Object.getOwnPropertySymbols(o); // [Symbol(id)]
// ── 冻结 vs 密封 ─────────────────────────────
// freeze:完全冻结,不可增删改(浅冻结)
const cfg = Object.freeze({ host: "localhost", port: 3000 });
cfg.port = 8080; // ❌ 严格模式抛错,非严格静默失败
Object.isFrozen(cfg); // true
// seal:密封,可改现有值,但不可增删
const s = Object.seal({ x: 1 });
s.x = 2; // ✅ 允许修改现有属性
s.y = 3; // ❌ 无法新增
delete s.x; // ❌ 无法删除Object.freeze 是浅冻结,嵌套对象的属性依然可改。需要深度冻结要递归处理:function deepFreeze(o) { Object.keys(o).forEach(k => typeof o[k] === "object" && deepFreeze(o[k])); return Object.freeze(o); }Object.freeze 常用于定义配置常量,防止被意外修改;Object.seal 适合「允许更新值但不允许改结构」的场景(如固定格式的状态对象)。Proxy 让对象的基本操作变得可编程,这是 Vue 响应式、Immer 这类「魔法」共同的底座。
Proxy 在对象外面包一层拦截器:读、写、删、in、函数调用等 13 种基本操作都可以被 trap 函数接管,对象的行为从此可编程。Reflect 提供与每个 trap 一一对应的默认行为(Reflect.get/set/has…),在 trap 里调用它完成「本来该做的事」,并正确传递 receiver。Vue 3 的 reactive() 就是 get 里收集依赖、set 里触发更新的 Proxy;参数校验、默认值兜底、Mock 对象也都是它的地盘。const state = { count: 0 };
const reactive = new Proxy(state, {
get(target, key, receiver) {
console.log("依赖收集:", key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const ok = Reflect.set(target, key, value, receiver);
console.log("触发更新:", key, "=", value);
return ok; // set trap 必须返回布尔值
},
});
reactive.count; // 依赖收集: count
reactive.count = 1; // 触发更新: count = 1
// 另一经典用途:访问不存在的属性直接报错,拼写错误无所遁形
const strict = new Proxy({ a: 1 }, {
get(t, k) {
if (!(k in t)) throw new Error("没有属性 " + String(k));
return t[k];
},
});value.bind(target)),否则会因 this 指向代理而报错。这张卡专挑 Object 静态方法里最容易记错语义的几个讲透,和末尾的速查卡分工、不重复。
本卡只挑Object 静态方法里最容易踩错或被忽略的几个讲透——常规的枚举/合并/原型方法(keys/values/assign/create…)的一行速查见末尾19 章「内置对象速查」的 Object 卡,两张卡是「讲透 vs 速查」的分工,不重复。这里的重点:Object.hasOwn 为什么该取代 hasOwnProperty、Object.is 修了 === 的哪两个坑、Object.groupBy(ES2024)怎么原生分组,以及 entries ↔ fromEntries 如何优雅地「变换」一个对象。// ── 属性存在性判断 ────────────────────────────
const obj = { a: 1 };
obj.hasOwnProperty("a"); // true,但有隐患:
// 若 obj = Object.create(null) 则没有 hasOwnProperty 方法
// 或若属性名叫 "hasOwnProperty",也会被遮蔽
Object.hasOwn(obj, "a"); // true ✅ ES2022,推荐替代写法
"a" in obj; // true,但含原型链(不同语义)
// ── Object.is:修复 === 的两个特殊 case ─────
NaN === NaN; // false(令人困惑)
Object.is(NaN, NaN); // true ✅
+0 === -0; // true(令人困惑)
Object.is(+0, -0); // false ✅
// ── Object.groupBy(ES2024)─────────────────
const items = [
{ name: "Apple", type: "fruit" },
{ name: "Banana", type: "fruit" },
{ name: "Carrot", type: "veggie" },
];
Object.groupBy(items, item => item.type);
// { fruit: [{Apple}, {Banana}], veggie: [{Carrot}] }
// ── keys / entries / fromEntries 组合 ────────
const prices = { apple: 5, banana: 3, cherry: 12 };
// 过滤出价格 > 4 的条目
const expensive = Object.fromEntries(
Object.entries(prices).filter(([, v]) => v > 4)
); // { apple: 5, cherry: 12 }
// 所有价格打八折
const discounted = Object.fromEntries(
Object.entries(prices).map(([k, v]) => [k, v * 0.8])
);"key" in obj 会顺原型链查找、Object.hasOwn 只看自身属性,两者语义不同别混用。=== 判等有两个坑——NaN === NaN 为 false、+0 === -0 为 true;需要精确判等改用 Object.is(Object.is(NaN, NaN) 得 true、Object.is(+0, -0) 得 false)。Object.groupBy Chrome 117+/Node 21+ 原生支持,低版本可用 Array.prototype.reduce 手写替代。Object.hasOwn 是 ESLint 推荐的 no-prototype-builtins 规则的标准解法。要完整方法清单(含 assign/create/getPrototypeOf/defineProperty/structuredClone)翻到19 章「内置对象速查」的 Object 卡。JavaScript · 迭代与生成器
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.from、new Set、new Map、Promise.all 全都能吃同一个东西——这就是协议的价值。至于卡片末尾那个手写的 take,现在已经不必自己写了,见本章「迭代器助手」卡。生成器不止是「吐值的迭代器」——它能暂停、能双向通信,正是 async/await 的原型。
上一张卡把生成器当作「迭代器的快捷写法」,但它的能力远不止吐值:yield 是双向的——next(v) 的参数会成为 yield 表达式的返回值,外部还能用 gen.return()/gen.throw() 提前终止或注入异常。异步生成器(async function*)配合 for await...of 消费「异步序列」(分页 API、逐块读流),实现的是 Symbol.asyncIterator 协议。// ── yield 双向通信:next(v) 把值送回生成器内部 ──
function* dialog() {
const answer = yield "第一问"; // answer 接住 next 传入的值
yield "你回答了 " + answer;
}
const g = dialog();
g.next(); // { value: "第一问", done: false }
g.next(42); // { value: "你回答了 42", done: false }
g.return(); // 提前终止;g.throw(err) 注入异常
// ── 异步生成器:优雅地消费分页 API ──
async function* fetchPages(first) {
let url = first;
while (url) {
const res = await fetch(url);
const page = await res.json();
yield* page.items; // 逐个吐出本页条目
url = page.next; // 没有下一页则退出
}
}
for await (const item of fetchPages("/api/list?page=1")) {
console.log(item); // 调用方完全感知不到分页
}数组的链式方法很好用,代价是每一步都要把整个中间结果物化成一个新数组——序列无限长时直接挂死。ES2025 的迭代器助手把同一套方法搬到了迭代器上,语义改成惰性:要几项就只算几项。
有哪些方法,挂在哪
- Node 24.18 全部可用:
.map()、.filter()、.take(n)、.drop(n)、.flatMap()、.toArray()、.reduce()、.forEach()、.some()、.every()、.find(); - 它们挂在
Iterator.prototype上,所以所有内置迭代器都自动带上了——[].values()、new Set([...]).values()、map.entries()、生成器对象,typeof [].values().take就是"function"; - 手上是普通可迭代对象(不是迭代器)时,用
Iterator.from(x)升级一下再链。
惰性到底省了什么
- 拿一个每次产出都计数的无限生成器,跑
counted().map(x => x).take(4).toArray(),生成器被拉动的次数正好是 4——map没有先把无限序列算完,take也不是「算完再截断」,整条链是拉一项走一遍; - 所以
fib().map(x => x * 2).take(5).toArray()在斐波那契无限序列上瞬间返回[0, 2, 2, 4, 6],而把fib()先[...展开]成数组会直接卡死; - 反过来,数据量小、且本来就是数组时,数组方法更快——助手每项都要过一次迭代器协议的
next(),有固定开销。助手的价值在「无限 / 超大 / 来源是流」的场景,不是替代arr.map。
function* fib() {
let [a, b] = [0, 1];
for (;;) { yield a; [a, b] = [b, a + b]; }
}
// —— 无限序列上照样链式(只计算用得到的那几项)——
fib().map(x => x * 2).take(5).toArray(); // [0, 2, 2, 4, 6]
fib().drop(3).take(3).toArray(); // [2, 3, 5]
// 对比:数组方法在这里会挂死
[...fib()].map(x => x * 2).slice(0, 5); // ❌ 展开无限序列,卡死
// —— 内置迭代器自动带上了助手 ——
typeof [].values().take; // "function"
new Set([1, 2]).values().toArray(); // [1, 2]
map.entries().filter(([k]) => k.startsWith("a")).toArray();
// —— 普通可迭代对象先升级 ——
Iterator.from([1, 2, 3]).some(x => x > 2); // true
// —— 典型用法:大文件逐行读,只要前 100 条匹配的 ——
const hits = lines(file) // 生成器,逐行产出,不全读进内存
.filter(l => l.includes("ERROR"))
.map(l => parse(l))
.take(100)
.toArray(); // 读到第 100 条就停,后面的行根本没读const it = fib().take(3) 之后 it.toArray() 拿到三项,再调一次得到的是空数组。要复用结果就先 toArray() 存下来。另外链上任何一步不加 take 就 toArray() 无限序列,一样会卡死——惰性不等于自动有界。arr.take(3) 不存在,要写 arr.values().take(3).toArray()。判断当前环境有没有:typeof Iterator !== "undefined" && typeof [].values().take === "function"。它是 ES2025 的新东西,Node 22+ 与 2024 年之后的浏览器才有。JavaScript · 错误处理
健壮的工程代码离不开规范的错误处理。掌握 try/catch/finally 与自定义错误类,能让排查问题事半功倍。
try、catch、finally 是 JS 异常处理的地基:捕获错误、尝试恢复、以及无论成败都要做的收尾。
两个控制流细节值得记牢:异常一抛,try 里该行之后的语句不再执行,直接跳进 catch;而 finally 连 return 都拦得住——try { return 1 } finally { return 2 } 最终返回的是 2。function parseJSON(str) {
try {
return JSON.parse(str);
} catch (err) {
console.error("解析失败:", err.message);
return null;
} finally {
console.log("解析流程结束");
}
}finally 里的 return/throw 会覆盖 try 中的返回或异常:try { return "a" } finally { return "b" } 最终返回 "b";更隐蔽的是 finally 里一 return,try 中已抛出的异常会被悄悄吞掉。所以 finally 里别写控制流,只做收尾。catch 可省略参数:不关心错误对象时写 catch {} 即可。抛出的不一定是 Error——throw "字符串" 也合法,此时 catch 到的就是那个字符串、e.message 是 undefined,所以读 e.message 前最好先确认类型。继承 Error 造出语义化的错误类型,配合 instanceof 就能把不同种类的错误分门别类地处理。
两件容易漏的事:构造函数里必须super(message) 把消息传给父类;还要显式写 this.name = "XxxError"——不写的话 err.name 一直是 "Error",日志里分不出是哪种错误。class ValidationError extends Error {
constructor(message, field) {
super(message);
this.name = "ValidationError";
this.field = field;
}
}
try {
if (age < 0) throw new ValidationError("年龄不能为负", "age");
} catch (err) {
if (err instanceof ValidationError) {
console.log(`字段 ${err.field}: ${err.message}`);
} else {
throw err; // 未知错误继续往上抛
}
}this.name,err.name 会沿用父类的 "Error",toString() 和堆栈首行都印成 Error: … 而非你的类名——务必在 super(message) 之后补一句 this.name = "XxxError"。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 却没抓到错」的头号原因。no-floating-promises 规则(@typescript-eslint 提供)能在写代码时就拦住。文件句柄、数据库连接、锁、订阅——凡是「用完必须关」的东西,过去只能靠 try { … } finally { close() },嵌套三层就没法看了。using 声明(ES 显式资源管理,TypeScript 5.2 起支持)把「关」这件事绑到变量的作用域上:块一退出,自动调用它的 [Symbol.dispose]()。
怎么用:给对象加一个 Symbol.dispose
- 任何对象只要实现了
[Symbol.dispose]()(同步)或[Symbol.asyncDispose]()(异步),就能被using/await using接管。 using r = 表达式;—— 变量是块作用域的,而且不能重新赋值——TS 里改它当场报TS2588: Cannot assign to 'r' because it is a constant。这是设计使然:句柄换了人,原来那个就关不掉了。- 标准库正在陆续给对象加上这个方法,自己写的资源类补一个方法即可,不需要继承任何基类。
语义:三条都和直觉一致
- 后进先出——同一个块里
using a、using b,退出时打印顺序是body→dispose b→dispose a。和finally的嵌套顺序一致,不会搞反。 - 抛错也会清理——块里
throw,先打印dispose x再进 catch。这正是finally的语义,只是不用自己写。 await using会真的等——异步 dispose 打印在函数体之后,且被 await 住。
今天能不能用
- Chrome 150:原生可用,
Symbol.dispose就是标准的 well-known symbol。 - Node 22.22:语法层面直接 SyntaxError——
using r = …报Unexpected identifier。注意 Node 22 上Symbol.dispose是存在的(打印出来是Symbol(nodejs.dispose),Node 自己塞的别名),别看到它有就以为语法能用。要在 Node 上跑得升到支持这个语法的版本,或者过一遍 TS。 - TypeScript:5.2 起支持。
--target esnext时原样输出;--target es2022会展开成一对__addDisposableResource/__disposeResources辅助函数(几十行),老运行时也能跑。类型上需要lib含esnext,否则报TS2318: Cannot find global type 'Disposable'。
// 自己的资源类:补一个 Symbol.dispose 就行
class FileHandle {
constructor(path) { this.fd = open(path); }
[Symbol.dispose]() { close(this.fd); }
}
// 旧写法
const f = new FileHandle("a.txt");
try { use(f); } finally { f[Symbol.dispose](); }
// 新写法:块一退出就关,抛错也关
{
using f = new FileHandle("a.txt");
use(f);
} // ← 这里自动 dispose
// 异步资源:await using + Symbol.asyncDispose
class Conn { async [Symbol.asyncDispose]() { await this.end(); } }
async function q() {
await using c = await connect();
return c.query("select 1");
} // ← 这里 await 住 asyncDisposeusing——它按作用域无条件关闭,把别人传进来的连接 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 → 4queueMicrotask/.then 不断产出微任务,宏任务(含 setTimeout、渲染)会被饿死、永远轮不到——递归微任务全部跑完,排在最前的 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 → 下一个宏任务 EsetTimeout(fn, 0) 会「立刻」执行——它最快也要等当前同步代码与全部微任务跑完,浏览器对嵌套定时器还有约 4ms 的最小延迟。Promise 是一台一次性的状态机:从 pending 出发,一旦敲定就锁死,这决定了它后续的一切行为。
Promise 有三种状态:pending、fulfilled(触发 then)、rejected(触发 catch)。状态一旦改变就不可逆。每个then/catch 都返回一个新的 Promise,因此可以链式串联;回调里 return 的值会成为下一环 then 的入参(链式传值),若返回的是 Promise 则等它敲定后再往下走;链条中任意一环抛错或 reject,会跳过后续 then、直奔最近的 catch(错误沿链传播)。new Promise((resolve, reject) => {
setTimeout(() => resolve("成功"), 1000);
})
.then(data => console.log(data))
.catch(err => console.error(err))
.finally(() => console.log("结束"));.catch 处理完后,链条会恢复成 fulfilled 继续往下——catch 之后的 .then 照常执行,别以为进了 catch 整条链就停了。想让错误继续中断后续,得在 catch 里重新 throw。.then/.catch 都返回一个新 Promise(p.then(fn) === p 为 false),所以能链式串联;回调 return 的值成为下一环入参,return 一个 Promise 则等它敲定再往下走。async/await 把 Promise 链写成同步的模样,可读性拉满,底层却仍是 Promise 和微任务。
两条硬规则:async 函数永远返回 Promise,哪怕函数体里写的是 return 1;await 只把后续代码挂进微任务队列,不阻塞主线程,同一时刻别的任务照常在跑。async function getUser(id) {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error("网络错误");
return await res.json();
} catch (err) {
console.error(err.message);
throw err;
}
}await 之前的代码是同步执行的——调用一个 async 函数会一路跑到第一个 await 才交出控制权(打印顺序 before → inside-1 → after → inside-2);async 函数无论 return 什么都被包成 Promise(return 42 拿到的是 Promise<42>,instanceof Promise 为 true)。互不依赖的异步任务别排队 await——这四个组合器让你按「全都要」「各自结算」「抢第一」不同策略并发。
互不依赖的异步任务应并发执行而不是逐个 await。Promise.all:全部成功才成功,一个失败就整体失败。Promise.allSettled:全部完成后返回每个结果的状态数组。Promise.race:最先敲定(成功或失败)者胜出。Promise.any:最先成功者胜出,全部失败则抛 AggregateError。// ❌ 串行,总耗时叠加
const a = await fetchA();
const b = await fetchB();
// ✅ 并发,总耗时取最慢的
const [a, b] = await Promise.all([fetchA(), fetchB()]);
// race:超时控制
await Promise.race([
fetchData(),
new Promise((_, reject) =>
setTimeout(() => reject("超时"), 5000))
]);Promise.all 一个 reject 就整体 reject、其余结果全丢(抛出第一个失败的 reason)——要拿到每个任务各自的成败得用 allSettled(返回 {status,value}/{status,reason} 数组,永不 reject)。race 是最先敲定者胜出,先 reject 也算数——先失败的一方抢到就整体 reject。高频事件(输入、滚动、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 里创建一次并复用。debounce/throttle——还支持 leading/trailing 边缘触发与 cancel()。真实网络请求要处理三件事:会不会卡死(超时)、失败要不要重试、用户离开能不能取消。核心是 AbortController:它给出一个 signal 传进 fetch,一 abort() 请求立刻中断并抛 AbortError;AbortSignal.timeout(ms) 则是「到点自动 abort」的快捷方式。
// 超时:AbortSignal.timeout(Node 18+ / 现代浏览器)
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
// 手动取消:用户点「取消」或离开页面
const ctrl = new AbortController();
fetch(url, { signal: ctrl.signal });
cancelBtn.onclick = () => ctrl.abort();
// 重试 + 指数退避
async function fetchRetry(url, retries = 3, delay = 500) {
for (let i = 0; i <= retries; i++) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error('HTTP ' + res.status);
return res;
} catch (err) {
if (i === retries) throw err; // 最后一次仍失败:抛出
await new Promise(r => setTimeout(r, delay * 2 ** i)); // 退避
}
}
}res.ok 再 throw:fetch 对 404/500 是正常 resolve 的(13 章),不手动抛错,4xx/5xx 会被当成功、根本不触发重试。另外别重试非幂等请求——超时了不等于服务端没执行,POST 重试可能下两个单。delay * 2 ** i(指数退避)比固定间隔更友好,能避免「重试风暴」打垮正在恢复的服务;更讲究可再叠加随机抖动(jitter)。这两个是 ES2024 添进来的小工具,Node 22 与 Chrome 150 双边均已可用。它们各自替掉一段人人都写过的样板代码——一个终结「Promise 构造器反模式」,一个终结「for await 里手动 push」。
Promise.withResolvers:不再需要在构造器里往外捞
- 老写法是声明两个
let,在new Promise((res, rej) => { resolve = res; reject = rej; })的回调里赋值捞出来——能用,但难读,还容易漏掉 reject。 - 新写法一行:
const { promise, resolve, reject } = Promise.withResolvers();(返回的就是这三个键)。 - 什么时候真需要它:触发点和等待点不在同一段代码里。典型是把事件转成 Promise——WebSocket 的一次往返、「等用户点确认」的对话框、要在别处调用
resolve的队列。日常「包一个异步操作」用async函数就够,别为了新 API 而用。
Array.fromAsync:异步版的 Array.from
await Array.fromAsync(异步可迭代对象)把一个for await循环压成一行,还支持第二个参数做映射,和Array.from对称。- 它逐个顺序等,不是并发——这一点和
Promise.all完全不同。要并发拿一批结果仍然用Promise.all;Array.fromAsync面对的是「一个一个吐出来」的流式来源(分页接口、可读流、异步生成器)。 - 喂给它一个装着 Promise 的普通数组也行,此时它会依次 await,等价于串行版的
Promise.all。
// ── Promise.withResolvers(ES2024)─────────────
// 老写法:构造器反模式
let resolve, reject;
const p = new Promise((res, rej) => { resolve = res; reject = rej; });
// 新写法:一行
const { promise, resolve, reject } = Promise.withResolvers();
// 典型场景:把「等一次事件」变成可 await 的值
function once(target, type) {
const { promise, resolve } = Promise.withResolvers();
target.addEventListener(type, resolve, { once: true });
return promise;
}
// ── Array.fromAsync(ES2024)───────────────────
async function* pages() {
for (let i = 1; i <= 3; i++) yield await fetchPage(i);
}
// 老写法
const all = [];
for await (const p of pages()) all.push(p);
// 新写法(顺序等,不是并发)
const all2 = await Array.fromAsync(pages());Array.fromAsync 是串行的,别拿它当 Promise.all 用——把一个装着 10 个 fetch Promise 的数组喂进去,是一个接一个等,总耗时是相加而不是取最大值。判据:来源是「已经全部发出去的一批 Promise」→ Promise.all;来源是「一个吐完才有下一个」的异步迭代器 → Array.fromAsync。另外它会把整个序列收进内存,无限流或超大结果集别用它,还是老老实实 for await 边收边处理。Promise.withResolvers 的价值不在少写几个字,而在它让「谁来 resolve」这件事显式化。看到它就知道「这个 Promise 由外部某处触发完成」,比在构造器回调里赋值给外层 let 清楚得多。反过来说,如果你发现自己在用它包一段本来就能写成 async 函数的逻辑,那是用错了。JavaScript · 模块化:ESM & CJS
模块化是组织大型项目代码的基础。理解 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.,要改值只能调导出模块提供的函数。import() 函数(返回 Promise)。CommonJS 是 Node 早期的模块方案:require 同步加载、能写在条件分支里,至今仍有大量存量代码在用。
真正被导出的是module.exports,exports 只是指向它的别名——给 exports 整体赋值(exports = { a: 1 })会断开这层引用,require 到的是空对象 {},这是 CJS 最经典的坑。// math.js
function add(a, b) { return a + b; }
module.exports = { add, PI: 3.14159 };
// main.js
const { add, PI } = require('./math.js');"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');require 会拿到尚未执行完的半成品 module.exports(可能是 undefined 或缺字段),且不报错——最隐蔽的坑。根治办法是消除循环依赖本身。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月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()) 验一下,别等序列化时才崩。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);d.setFullYear(2025).setMonth(0) 抛 TypeError: d.setFullYear(...).setMonth is not a function。要连改多个分量得分成多行,或用支持多参数的重载(如 setHours(23, 59, 59))。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。toLocaleDateString + locale 参数,或 Intl.DateTimeFormat。Date 参与算术会自动转成毫秒时间戳,算时间差、比大小都很方便——唯独「相等」不能靠隐式转换,这张卡讲清楚这条边界。
Date 对象参与运算时会自动转成时间戳(毫秒数)。利用这一特性可以方便地计算时间差。直接用< > 比较两个 Date 对象是安全的(同样走隐式转换),但 == 比较的是引用,需要用 .getTime()。const start = new Date("2024-01-01");
const end = new Date("2024-06-15");
// 时间差(毫秒 → 天)
const diffMs = end - start; // 自动转时间戳相减
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
console.log(diffDays); // 166
// 比较大小(隐式转时间戳)
start < end // true ✅
start > end // false ✅
// 判断相等必须用 getTime()
const a = new Date("2024-01-01");
const b = new Date("2024-01-01");
a == b // false(不同对象引用)
a.getTime() === b.getTime() // true ✅
// 判断日期是否有效
const invalid = new Date("not a date");
isNaN(invalid.getTime()) // true → 无效日期setMonth() 时,1月31日 + 1月 会变成 3月2日或3日(因为2月没有31号,自动溢出)。处理月末边界需要额外判断。< > 直接用 Date 就行(走隐式 valueOf 转时间戳);但判「同一时刻」必须 a.getTime() === b.getTime()——==/=== 比的是对象引用,new Date("2024-01-01") == new Date("2024-01-01") 是 false。算天数差记得除以 86400000 再 Math.floor。Date 内部永远以 UTC 存,getter 却默认给你「运行环境的本地时区」——一旦代码要在别的时区正确显示,这个默认就成了坑,这张卡讲原生怎么指定时区。
Date 对象内部始终以 UTC 存储,getter 方法默认返回运行环境的本地时区时间。跨时区开发时,要么统一用 UTC 系列方法,要么借助Intl.DateTimeFormat 的 timeZone 选项指定时区——这是原生最可靠的做法,无需任何库。// 同一个 Date,本地时间 vs UTC 时间
const d = new Date("2024-06-15T00:00:00Z"); // UTC 0点
d.getHours() // 8(在 UTC+8 环境下)
d.getUTCHours() // 0
// 用 Intl 指定任意时区输出
const toTZ = (date, tz) =>
new Intl.DateTimeFormat("zh-CN", {
timeZone: tz,
dateStyle: "short",
timeStyle: "medium",
}).format(date);
toTZ(d, "Asia/Shanghai"); // "2024/6/15 08:00:00"
toTZ(d, "America/New_York"); // "2024/6/14 20:00:00"
toTZ(d, "Europe/London"); // "2024/6/15 01:00:00"
// 获取当前环境时区名
Intl.DateTimeFormat().resolvedOptions().timeZone
// "Asia/Shanghai"getHours() 等 getter 返回的是运行环境的本地时区,不是某个固定时区——同一段代码在服务器(常为 UTC)和你本机(UTC+8)跑,getHours() 会差 8 小时,是「本地没问题、线上时间全错」的经典成因。Date 本身没有「设定时区」的能力,要固定时区只能靠 Intl.DateTimeFormat 的 timeZone 选项。字符串解析是 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);
}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(...)month() 仍然从 0 开始(与原生 Date 一致),这一点与 date-fns 不同。此外,时区插件(utc + timezone)内部依赖 Intl.DateTimeFormat,在 Node.js 12 及更早(默认 small-ICU)可能需要单独引入完整 ICU / IANA 时区数据(Node 13+ 已默认打包)。Date 的月份 0 基、可变、时区糊涂这些毛病改不动了,Temporal 是 TC39 用来彻底替代它的新 API。它已经不再是「将来时」——本机 Chrome 150 无需任何旗标、无需 polyfill,Temporal.Now.plainDateISO() 直接返回当天日期;Firefox 139+ 同样已原生支持。Node 这边还要等(22.22 上 typeof Temporal 仍是 undefined),服务端暂时还得靠 @js-temporal/polyfill。
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; // 166d.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 逼你想清楚要哪个。d.add({ months: 1 }) 返回新对象,原来那个仍是 2024-06-15;② 月份从 1 开始——同一个 6 月,PlainDate.month 是 6,而 new Date(…).getMonth() 是 5;③ 时区感知,见下方陷阱里那组夏令时数字;④ 明确区分「某天」与「某刻」——PlainDate 减 Instant 当场 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()(见下){};③ 循环引用对象会抛 TypeError: circular structure;④ 对象键是 Symbol 或值是 undefined/函数的属性会被静默丢弃。「深拷贝」长期靠 JSON.parse(JSON.stringify()) 硬凑,会丢 Date/Map/Set、还怕循环引用;structuredClone 是浏览器与 Node 17+ 原生内置的正解。
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 liststructuredClone 会丢失原型链:克隆自定义类的实例,结果只是一个普通对象(prototype 变成 Object.prototype)。如果需要保留类实例,需要在类上实现自定义克隆逻辑。{ ...obj } 或 JSON.parse(JSON.stringify());需要保留类型 → structuredClone();需要保留类实例和方法 → 自定义 clone() 方法或第三方库(如 lodash _.cloneDeep)。前端处理文件上传、图片、WebSocket 二进制帧、Web Crypto API 时,必须和二进制数据打交道。下面分两条线理清相关 API。
Base64 / 文本编码
Base64 把二进制编码为纯 ASCII 字符串,常用于在 JSON 或 CSS 中嵌入小文件(data URL);btoa/atob 只认 Latin-1,中文等需先用 TextEncoder/TextDecoder 在字符串与 UTF-8 字节间转换。
ArrayBuffer / Blob / File
ArrayBuffer 是固定长度的原始二进制缓冲区,TypedArray(如 Uint8Array)与 DataView 是操作它的「视图」;Blob/File 是文件级的不可变二进制对象,配合 URL.createObjectURL 或 FileReader 使用。
// ── Base64 编解码 ─────────────────────────────
// btoa / atob:浏览器内置(仅支持 Latin-1,中文需先编码)
btoa("Hello World") // "SGVsbG8gV29ybGQ="
atob("SGVsbG8gV29ybGQ=") // "Hello World"
// 中文/Unicode → Base64(先用 TextEncoder 转字节再编码)
function toBase64(str) {
const bytes = new TextEncoder().encode(str); // UTF-8 字节
const binary = Array.from(bytes, b => String.fromCharCode(b)).join("");
return btoa(binary);
}
function fromBase64(b64) {
const binary = atob(b64);
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
toBase64("你好") // "5L2g5aW9"
fromBase64("5L2g5aW9") // "你好"
// Node.js 内置 Buffer.from / toString
Buffer.from("你好", "utf-8").toString("base64"); // "5L2g5aW9"
Buffer.from("5L2g5aW9", "base64").toString("utf-8"); // "你好"
// ── ArrayBuffer & TypedArray ──────────────────
// ArrayBuffer:不能直接读写,必须通过视图
const buf = new ArrayBuffer(8); // 8 字节的二进制缓冲区
// TypedArray 视图(共享同一段内存)
const u8 = new Uint8Array(buf); // 8 个 uint8(0~255)
const u32 = new Uint32Array(buf); // 2 个 uint32
const f64 = new Float64Array(buf);// 1 个 float64
u8[0] = 255;
console.log(u8); // Uint8Array [255, 0, 0, 0, 0, 0, 0, 0]
// 常用 TypedArray 类型对照:
// Int8Array Uint8Array Uint8ClampedArray(图像像素用)
// Int16Array Uint16Array
// Int32Array Uint32Array
// Float32Array Float64Array
// BigInt64Array BigUint64Array
// DataView:精细控制字节序(大端/小端)
const view = new DataView(buf);
view.setUint32(0, 0xDEADBEEF, true); // true = 小端(little-endian)
view.getUint32(0, true); // 3735928559
// ── TextEncoder / TextDecoder ─────────────────
// 字符串 ↔ UTF-8 字节数组互转
const enc = new TextEncoder();
const dec = new TextDecoder("utf-8");
const encoded = enc.encode("Hello 你好"); // Uint8Array
dec.decode(encoded); // "Hello 你好"
// ── Blob:二进制大对象 ────────────────────────
// Blob 是不可变的原始数据,常用于文件操作和下载
const blob = new Blob(['{"name":"Alice"}'], { type: "application/json" });
blob.size; // 16(字节数)
blob.type; // "application/json"
// Blob → ArrayBuffer
const arrayBuf = await blob.arrayBuffer();
// Blob → 文本
const text = await blob.text();
// 触发浏览器下载文件
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url); // 用完立即释放
}
// data URL(Base64 嵌入图片/文件)
const reader = new FileReader();
reader.onload = e => console.log(e.target.result); // "data:image/png;base64,..."
reader.readAsDataURL(imageBlob);Blob URL(URL.createObjectURL)代替 Base64 data URL。另外,btoa() 对非 Latin-1 字符(如中文)会直接报错,必须先用 TextEncoder 转 UTF-8 字节再处理。ArrayBuffer = 原始内存容器(只管存);TypedArray = 有类型的读写视图(指定数据格式);DataView = 低级字节级别的读写(控制字节序);Blob = 不可变的二进制大对象(适合文件操作);TextEncoder/Decoder = 字符串与字节互转。URL 只允许特定字符,空格、中文、&、= 这些不编码就会破坏结构或丢数据——这张卡讲两级编码函数,以及现代的 URLSearchParams。
encodeURIComponent 编码单个参数值;encodeURI 编码整个 URL(保留 :/?#[]@ 等结构字符)。URLSearchParams 是构建和解析 query string 的现代方式,自动处理编解码。// ── encodeURIComponent vs encodeURI ───────────
encodeURIComponent("name=Alice&age=25")
// "name%3DAlice%26age%3D25"(连 = & 都编码,用于参数值)
encodeURI("https://example.com/搜索?q=你好")
// "https://example.com/%E6%90%9C%E7%B4%A2?q=%E4%BD%A0%E5%A5%BD"
// (保留 :// ? = 等结构字符,只编码非 ASCII 部分)
decodeURIComponent("%E4%BD%A0%E5%A5%BD") // "你好"
// ── URLSearchParams:现代 query string 工具 ───
// 构建 query string
const params = new URLSearchParams({
q: "JavaScript 教程",
page: 1,
tags: "web",
});
params.toString(); // "q=JavaScript+%E6%95%99%E7%A8%8B&page=1&tags=web"
// 追加 / 修改 / 删除
params.append("tags", "js"); // 同名追加(不覆盖)
params.getAll("tags"); // ["web", "js"](同名多值,删除前)
params.set("page", 2); // 覆盖
params.delete("tags"); // 删除所有 tags
params.has("q"); // true
params.get("q"); // "JavaScript 教程"
// 解析现有 URL 的 query string
const url = new URL("https://example.com/search?q=hello&page=3");
url.searchParams.get("q"); // "hello"
url.searchParams.get("page"); // "3"(注意:始终是字符串)
// 与 fetch 配合
const qs = new URLSearchParams({ keyword: "ts", size: 10 });
await fetch(`/api/search?${qs}`);URLSearchParams 的 get() 始终返回字符串,取出数字类型的参数后需要手动转换(如 Number(params.get("page")))。另外,encodeURIComponent 不编码 ! ' ( ) *,在极严格的 RFC 3986 场景下需要额外处理这些字符。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);
// ⚠️ 同名多值只保留最后一个,仅用于单值字段的调试Content-Type: multipart/form-data——因为 multipart 格式需要一个随机的 boundary 分隔符,浏览器会自动在 Content-Type 里附加它(如 multipart/form-data; boundary=----WebKitFormBoundary...),手动设置后 boundary 缺失会导致服务器解析失败。ReadableStream body 进度需要实验性 API)。如需上传进度条,仍需使用 XMLHttpRequest 的 xhr.upload.onprogress,或等待 Fetch API 的 uploadProgress 提案正式落地。JavaScript · 深水区
前面几章教会你怎么用,这一章是深水区——那些不知道也能写、但不知道迟早会错的东西:静默的契约、几选一的选型、藏在水面下的机制。JS 的特别之处在于:这里的规则几乎全是运行时的、静默的。Rust 那边选错方法当场编译不过,JS 不会——它不报错,只是悄悄给你一个错的值,一路飘到很远处才变成别的症状。本章七张卡各讲一条这样的暗规则。「这个方法怎么调」不在这里查——那是下一章「内置对象速查」的活,两章是深水区 vs 速查的分工,不重复。
大多数语言里相等就是相等。JS 里「相等」是四套规则,分别被不同的 API 使用——平时察觉不到,因为它们只在两个值上产生分歧:NaN 和 -0。而这两个值恰恰是数值计算里最容易冒出来的。
四套语义与它们的分歧点
| 语义 | 谁在用它 | NaN 与 NaN | 0 与 -0 |
|---|---|---|---|
| 严格相等 | ===、indexOf、lastIndexOf、switch | 不等 | 相等 |
| SameValueZero | includes、Set、Map 的键、has | 相等 | 相等 |
| SameValue | Object.is | 相等 | 不等 |
| 宽松相等 | ==(会先做类型转换) | 不等 | 相等 |
横着看:没有任何两套是完全一致的。Object.is 是唯一区分 0 和 -0 的,includes 是唯一能在数组里找到 NaN 的。
最常咬人的一处:indexOf 找不到 NaN
includes 和 indexOf 看着是一对(「找到没有」和「找到在哪」),实际用的是两套不同的相等规则:
[NaN].includes(NaN)→true(SameValueZero)[NaN].indexOf(NaN)→-1(严格相等,而NaN !== NaN)
所以「用 indexOf(x) !== -1 判断存在」这个老写法,遇到 NaN 会给出错误答案。要找 NaN 的位置,只能用 findIndex(Number.isNaN)。
Set 与 Map 的键也走 SameValueZero
这带来两个平时想不到但很实用的性质:NaN 可以当 Map 的键并正常取回(尽管 NaN !== NaN);而 0 和 -0 会被当成同一个键,Set 也只会保留其中一个。
// —— 分歧点一:NaN ——
NaN === NaN; // false
NaN == NaN; // false(宽松相等也救不了)
Object.is(NaN, NaN); // true
[NaN].includes(NaN); // true ← SameValueZero
[NaN].indexOf(NaN); // -1 ← 严格相等,找不到!
[NaN].findIndex(Number.isNaN); // 0 ← 要位置只能这么找
// —— 分歧点二:-0 ——
0 === -0; // true
Object.is(0, -0); // false ← 唯一能区分的
[-0].includes(0); // true
// —— 集合按 SameValueZero 去重 / 建键 ——
new Set([NaN, NaN]).size; // 1(NaN 被认作重复)
new Set([0, -0]).size; // 1(0 和 -0 同一个值)
new Map([[NaN, 1]]).has(NaN); // true(NaN 可以当键)
// —— switch 用的是严格相等 ——
switch (NaN) { case NaN: /* 永远进不来 */ }new Set([{a:1}, {a:1}]).size 是 2 不是 1,[{a:1}].includes({a:1}) 也是 false。它不报错,只是一个都没去掉,而对字符串数组测试时又一切正常,所以很容易蒙混过关。要按内容去重,得自己指定「什么算同一个」:
[...new Map(rows.map((r) => [r.id, r])).values()](按 id,推荐);没有天然 id 时可以退而用 JSON.stringify 当键,但要注意键顺序不同的两个对象会被当成不同的。顺带一提
-0:它确实容易冒出来(-1 * 0、Math.round(-0.2)、0 / -5 都是),但它对所有字符串转换都隐形(String、模板串、JSON.stringify、toFixed 一律不带负号),能看出来的只有 Object.is(x, -0) 和 1 / x === -Infinity。日常基本无害,知道它存在即可。includes,别用 indexOf(x) !== -1——前者语义更准(能处理 NaN)也更好读;只有确实需要位置时才用 indexOf。Object.is 属于「知道它存在就行」的 API,真正用到的场景很少,主要是需要辨别 -0 的数值库。sort 是最容易「看起来能用」的方法——小数据上随便写都对,数据一变就出错。它有三条必须知道的契约。
契约一:不传比较函数 = 按字符串比较
默认行为是把每个元素转成字符串,再按 UTF-16 码元逐个比较(不是码点——两者的区别见下一张卡)。后果不止于数字:
[10, 9, 1].sort()→[1, 10, 9]("10" < "9")["b", "a", "B"].sort()→["B", "a", "b"](大写字母码元更小,全排在前面)["item10", "item2"].sort()→["item10", "item2"]
只要不是「纯 ASCII 小写字符串」,默认排序基本都不是你要的。
契约二:ES2019 起保证稳定
稳定指比较结果相等的元素保持原有相对顺序。这条是 ES2019 才写进规范的,此前各引擎行为不一(V8 曾对长数组用不稳定的快排)。现在可以放心依赖它做多级排序:先按次要条件排一遍,再按主要条件排一遍,次要顺序会被保留。
契约三:比较函数必须自洽
比较器要满足:a < b 返回负数、相等返回 0、a > b 返回正数,且结果要有传递性。违反了规范不保证任何结果——不是报错,是结果不可预测。最常见的两种写错:
- 相等时没返回 0:写成
(a, b) => a.age > b.age ? 1 : -1,相等时返回了 -1,稳定性随之失效; - 用随机数洗牌:
arr.sort(() => Math.random() - 0.5)是网上流传极广的写法,但它既不是均匀随机、结果还每次都不同(同一个数组连排两次结果不一样)。要洗牌请用 Fisher–Yates。
要按人类习惯排,得用 localeCompare
码点序对中文是按 Unicode 编号,不是拼音;对英文是大写全部在前。localeCompare / Intl.Collator 才给出符合语言习惯的顺序,后者还能开 numeric 处理「item2 < item10」。排大量数据时用 Intl.Collator 而不是 localeCompare——前者只建一次比较器,后者每次调用都要重新解析 locale。
// —— 默认按字符串码点,不是你想的那样 ——
[10, 9, 1].sort(); // [1, 10, 9]
["b", "a", "B"].sort(); // ["B", "a", "b"] 大写在前
["赵", "钱", "孙", "李"].sort(); // ["孙","李","赵","钱"] ← 按 Unicode 编号,没有意义
// —— 要人类习惯的顺序 ——
["赵", "钱", "孙", "李"].toSorted((a, b) => a.localeCompare(b, "zh"));
// ["李","钱","孙","赵"] ← 按拼音 li/qian/sun/zhao
const coll = new Intl.Collator(undefined, { numeric: true });
["item10", "item2", "item1"].toSorted(coll.compare);
// ["item1", "item2", "item10"] ← 数字段按数值比
// —— 稳定性可以依赖:两趟排出多级顺序 ——
const result = rows
.toSorted((a, b) => a.name.localeCompare(b.name)) // 次要条件先排
.toSorted((a, b) => a.age - b.age); // 主要条件后排
// 同龄的人之间,仍保持按姓名排好的顺序
// —— 这个洗牌写法是错的 ——
// arr.sort(() => Math.random() - 0.5); 比较器不自洽,分布不均匀
function shuffle(a) { // Fisher–Yates 才对
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}undefined 会被排到最后,而且不经过比较函数。规范规定:数组里的 undefined 一律移到末尾,你的比较器根本不会看到它们。所以想靠比较函数把「缺失值」排到前面是做不到的——只能先 filter 掉再拼回去。稀疏数组的空洞同理,也排在最后、且排在 undefined 之后。sort 唯一必须背下来的一条。除此之外,优先用 toSorted 而不是 sort(不改原数组,见 19 章 Array 卡);比较对象数组时把取值逻辑抽出来会好读很多:const by = (f) => (a, b) => f(a) - f(b),然后 rows.toSorted(by((r) => r.age))。这是 JS 字符串最反直觉的一点,也是最典型的一条「代码在测试数据上永远正确,遇到真实用户输入才崩」的契约。JS 字符串的底层是 UTF-16 码元数组:.length 数的是码元,不是人眼看到的字符。
三个层次,三个不同的「长度」
| 层次 | 怎么数 | "👍" | "👨👩👧" |
|---|---|---|---|
| 码元(UTF-16 code unit) | s.length、s[i]、slice | 2 | 8 |
| 码点(code point) | [...s]、for...of、Array.from | 1 | 5 |
| 字素簇(人眼的「一个字」) | Intl.Segmenter | 1 | 1 |
基本平面之外的字符(大部分 emoji、汉字扩展区、音乐符号)都要两个码元表示——注意™、☺、❤ 这些老符号在基本平面内,只占一个,这一对叫代理对(surrogate pair)。而带零宽连接符的 emoji(家庭、职业、肤色)由多个码点拼成,只有 Intl.Segmenter 能正确数成 1。
按码元下标操作会把字符撕成两半
s[i]、slice、substring、split("") 全都按码元切。切中代理对中间,得到的是半个字符——一个孤立的、无法显示的码元(通常渲染成 �)。「截断显示前 20 个字」这类需求是重灾区。
还有两处会出乎意料
- 转大写可能变长:
"ß".toUpperCase()得到"SS",长度从 1 变 2。所以「转大写不改变长度」这个假设不成立; - 看起来一样的字符串可能不相等:
é可以是单个码点,也可以是e+ 组合重音符两个码点。二者渲染完全相同但===为 false,length一个是 1 一个是 2。比较用户输入、做去重或当对象 key 之前,先normalize("NFC")归一化。
// —— 同一个 emoji,三种数法三个答案 ——
"👍".length; // 2 ← 码元(一个代理对)
[..."👍"].length; // 1 ← 码点
const family = "👨👩👧"; // 带零宽连接符的家庭 emoji
family.length; // 8
[...family].length; // 5
[...new Intl.Segmenter("zh", { granularity: "grapheme" })
.segment(family)].length; // 1 ← 人眼看到的就是 1 个
// —— 按下标切会撕碎代理对 ——
const s = "a👍b";
s.length; // 4(不是 3)
s[1]; // "\ud83d" ← 半个字符,显示成乱码
s.slice(0, 2); // "a\ud83d" ← 同样撕碎了
[...s].slice(0, 2).join(""); // "a👍" ← 先按码点拆再切才对
// —— 转大写会变长 ——
"ß".toUpperCase(); // "SS"(长度 1 → 2)
// —— 看着一样,其实不等 ——
const a = "café"; // é 是单个码点
const b = "cafe\u0301"; // e + 组合重音符
a === b; // false!但屏幕上一模一样
a.length === b.length; // false(4 vs 5)
a.normalize("NFC") === b.normalize("NFC"); // truevalue.slice(0, 20) 在用户输入 emoji 时会切出半个代理对,轻则显示成 �,重则这个坏字符串被存进数据库——某些数据库会直接报错拒绝,某些则静默存成乱码,而且之后每次读出来都是坏的。正确写法是先按字素簇拆:[...seg.segment(v)].slice(0, 20).map((x) => x.segment).join("")。顺带一提,
maxlength 属性数的也是码元,所以HTML 表单的字数限制对 emoji 同样不准——一个家庭 emoji 会占掉 8 格。.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]]} ← 先转数组JSON.stringify,而且是静默的:变成 {}、数据全丢却不报错,直到接口那头发现字段是空的。要传输就先转成数组或普通对象——具体写法、以及 structuredClone 为什么反而能克隆它们,见 19 章。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; // trueJSON.parse 内部,等你在代码里拿到这个数时末几位已经错了——BigInt(response.id) 救不回来,因为它只是把一个已经失真的 number 转成 BigInt。这个坑在对接 Java / Go 后端时极常见,它们的 long 轻松超过 253。三条出路,按优先级:① 让后端把这类 ID 用字符串传——最省事也最不容易出错,前端拿到就是精确的;② 用 reviver 的第三个参数
context.source(json-parse-with-source),它给的是解析前的原始字面量文本,从那里构造 BigInt 就是精确的:JSON.parse(s, (k, v, ctx) => k === "id" ? BigInt(ctx.source) : v)——Node 22 与 Chrome 已支持,Firefox 尚未,浏览器端用之前先确认目标环境;③ 实在没辙,在 parse 之前用正则把长数字字面量包成字符串,但这是 hack——字符串值里恰好含长数字会被误伤。Number.isSafeInteger(x),比自己和 MAX_SAFE_INTEGER 比更省事。处理外部数据时,若某个字段是 ID,先用它检查一遍,不安全就说明上游该改成字符串传。Promise.all 一个失败就整体 reject——这句人人都知道。但它只是提前把结果给了你,并没有停下别的任务:其余请求照常发出、照常返回、副作用照常发生,只是没人再接收它们了。这个区别在「重试」和「写操作」场景下会变成真实的 bug。
实际发生了什么
两个任务:A 在 20ms 失败,B 在 120ms 成功。Promise.all 在 20ms 就抛出了,但 B 在 120ms 仍然真正完成了——如果 B 是一笔扣款、一次写库、一条推送,它已经生效了,而你的 catch 分支还以为「这批操作失败了」。
常见的错误反应是在 catch 里整批重试,于是 B 被执行了两次。
四个组合子的语义差别
| 方法 | 什么时候落定 | 失败时 | 典型用途 |
|---|---|---|---|
all | 全部成功,或任一失败 | 抛出最先 reject 的那个(不是数组里最靠前的) | 都要成功才有意义 |
allSettled | 全部落定(永不 reject) | 结果数组里标 rejected | 要知道每个的成败 |
race | 第一个落定(成功或失败都算) | 第一个是失败就失败 | 超时竞速 |
any | 第一个成功 | 全失败才抛 AggregateError | 多个源取最快可用的 |
要真的停下来,得传取消信号
唯一能真正中止的办法是让每个任务都接受一个 AbortSignal,失败时主动 abort()。fetch 原生支持;自己写的异步函数要自己检查 signal.aborted。14 章的重试/取消卡有完整写法。
// —— all 抛出后,慢任务仍在继续 ——
const task = (name, ms, fail) =>
new Promise((res, rej) =>
setTimeout(() => {
console.log(name + " 真正完成"); // ← 副作用在这里
fail ? rej(new Error(name)) : res(name);
}, ms));
try {
await Promise.all([task("A", 20, true), task("B", 120)]);
} catch (e) {
console.log("all 抛出:" + e.message);
}
// 输出顺序:
// A 真正完成 ← 20ms
// all 抛出:A ← 20ms,这里你以为「整批失败了」
// B 真正完成 ← 120ms,但 B 其实成功执行了!
// —— 要真取消:传 signal,失败时 abort ——
const ctrl = new AbortController();
try {
await Promise.all([
fetch("/a", { signal: ctrl.signal }),
fetch("/b", { signal: ctrl.signal }),
]);
} catch (e) {
ctrl.abort(); // ← 这一句才真正掐断其余请求
throw e;
}
// —— 想知道每个的成败,用 allSettled ——
const results = await Promise.allSettled(tasks);
const failed = results.filter((r) => r.status === "rejected");
// 每项是 { status: "fulfilled", value } 或 { status: "rejected", reason }catch 里整批重试是最危险的组合。all 早退时,其余任务可能已经成功了;整批重试等于把它们又执行一遍。如果是写操作——下单、扣款、发消息——就是重复执行。两个方向的解法:让每个操作幂等(带唯一请求 ID,服务端去重),或者改用
allSettled 只重试真正失败的那些。前者更根本,因为网络超时重试同样会带来重复请求。all,「想知道谁成谁败」用 allSettled。判断依据是失败后你打算做什么:如果一个失败就整批放弃,all 让你早点知道;如果要逐个汇报或部分重试,allSettled 才给得出信息——用 all 的话,除了最先 reject 的那一个,其余失败的信息你永远看不到。正则字面量看起来像个常量,但带 g(或 y)标志的 RegExp 对象内部藏着一个可变的 lastIndex——它记录下次从哪里开始匹配。这让「同一个正则对象」在不同调用间互相影响,是 JS 里少见的「同样的输入得到不同输出」。
连续 test 同一个字符串,结果会跳
test 和 exec 在带 g 时会推进 lastIndex;匹配失败时又把它重置为 0。于是对同一个字符串反复调用,结果是 true, false, true, false… 交替:
- 第一次:从 0 开始,匹配到,
lastIndex变成 1,返回true; - 第二次:从 1 开始,后面没有了,返回
false,同时把lastIndex重置为 0; - 第三次:又从 0 开始,返回
true……
不带 g 就没有这个问题——每次都从头匹配。
最隐蔽的一种:模块顶层的正则常量
const EMAIL = /\S+@\S+/g 写在模块顶层,看着是个纯粹的常量,实际上是整个模块共享的可变状态。任何一处调用 EMAIL.test(x) 都会改变它,另一处的校验就会莫名其妙地失败——而且只在特定调用顺序下复现,是最难查的那类 bug。校验用的正则不要加 g(校验本来也不需要它)。
要提取全部匹配,用 matchAll
matchAll 内部自己管理位置、返回迭代器,且要求必须带 g(不带会抛 TypeError)——它把状态问题封在了内部。相比之下 while ((m = re.exec(s)) !== null) 这个老写法完全依赖 lastIndex,一旦正则被别处用过就会从中间开始。
// —— 同一个对象,同样的输入,结果在跳 ——
const re = /a/g;
re.test("a"); // true lastIndex: 0 → 1
re.test("a"); // false 从下标 1 开始找不到,lastIndex 重置为 0
re.test("a"); // true 又从 0 开始
// 不带 g 就没有状态
const plain = /a/;
plain.test("a"); // true
plain.test("a"); // true
// —— 模块顶层的「常量」其实是共享可变状态 ——
const EMAIL = /\S+@\S+/g; // ← 这个 g 是祸根
function validate(v) { return EMAIL.test(v); }
validate("a@b.com"); // true
validate("a@b.com"); // false ← 同样的输入!
// 改法:校验用的正则去掉 g → const EMAIL = /\S+@\S+/;
// —— 提取全部匹配:matchAll 自己管状态 ——
const text = "a1 b2 c3";
[...text.matchAll(/([a-z])(\d)/g)].map((m) => m[1] + m[2]);
// ["a1", "b2", "c3"] ← 每次调用都从头开始,互不干扰
// 老写法依赖 lastIndex,正则被别处用过就会从中间开始
// let m; while ((m = re.exec(text)) !== null) { ... }
// —— 实在要复用带 g 的正则,手动归零 ——
re.lastIndex = 0;const re = /x/g 写在循环外,然后循环里 if (re.test(item))——结果大约一半的项被判为不匹配,而且哪一半取决于前面有多少项匹配过,看起来完全随机。同样地,正则字面量在函数内每次调用都会新建一个对象(所以放函数里反而是安全的),但放在模块顶层、类字段、或用
new RegExp() 存进变量时就是长期共享的。拿不准就别加 g,或者每次现建。g,而且那时直接用 matchAll 或 replaceAll。校验(test)、找第一个(match 不带 g)、单次替换都不需要 g,加了只有坏处。String.prototype.match 的行为也随 g 而变:带 g 返回所有匹配的字符串数组但丢掉捕获组,不带 g 返回第一个匹配的完整信息(含捕获组和 index)。需要「所有匹配 + 捕获组」只能用 matchAll。JavaScript · 内置对象速查
本章速查 ECMAScript 语言自带的标准库——不依赖任何宿主、在浏览器 / Node / Deno 等所有 JS 环境中都存在的内置全局对象: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 脚本就是这么炸的——但 fetch、URL、AbortController 这几个已被 Node / Deno / Bun 共同实现,两边都能用。
文本 · 数值 · 时间
| 想做什么 | 用这个 | 在哪 |
|---|---|---|
| 拼接字符串 | 模板串(反引号插值),别用 + 连一长串 | 03 章 |
| 补齐位数 | padStart(2, "0") / padEnd | 19·String |
| 去掉首尾空白 | trim(只去首尾用 trimStart / trimEnd) | 19·String |
| 判断包含 | includes / startsWith / endsWith | 19·String |
| 取倒数第 n 个 | at(-1)(负数索引,字符串和数组都能用) | 19·String |
| 替换全部出现 | replaceAll——replace 只换第一处 | 19·String |
| 拆开 / 合起来 | split ↔ join(一对反操作) | 19·String · Array |
| 字符串转数字 | Number(s) 要求整串合法(但空串和 null 会变 0);parseInt(s, 10) 容忍尾巴("12px"→12),基数别省 | 03 章 |
| 判断是不是 NaN | Number.isNaN——别用全局 isNaN | 19·Number |
| 保留几位小数 | toFixed(2)(注意返回字符串,不是数字) | 19·Number |
| 取整 | Math.floor / ceil / round / trunc 四选一 | 19·Number |
| 随机数 | Math.random()(非密码学安全) | 19·Number |
| 当前时间戳 | Date.now()(毫秒) | 16 章 |
| 格式化日期给人看 | toLocaleString 系列;复杂需求上 day.js | 16 章 |
| 显示「3 分钟前」 | Intl.RelativeTimeFormat(别自己算) | 16 章 |
数组 · 集合 · 对象
| 想做什么 | 用这个 | 在哪 |
|---|---|---|
| 存一串东西 | 数组字面量 []——拿不准就选它 | 19·Array |
| 每一项做变换 | map | 19·Array |
| 挑出符合条件的 | filter | 19·Array |
| 归约成一个值 | reduce(务必传初始值) | 19·Array |
| 找一个元素 | find / findIndex;从后往前用 findLast | 19·Array |
| 判断存在 | includes(找值)/ some(找条件) | 19·Array |
| 排序 | toSorted((a,b) => a-b);sort ⚠︎改原值 | 19·Array |
| 反转 / 换掉一项 | toReversed / with;reverse、splice ⚠︎改原值 | 19·Array |
| 去重 | [...new Set(arr)];对象数组无效,改按 id 建 Map(18 章) | 19·Map · Set |
| 按 key 分组 | Object.groupBy(要 Map 就 Map.groupBy) | 19·Array |
| 拍平嵌套数组 | flat(Infinity);边映射边拍平用 flatMap | 19·Array |
| 按 key 存取 | 对象字面量;key 不是字符串、或要保序、要频繁增删 → Map | 19·Object · Map |
| 遍历对象 | Object.entries(o) 配 for...of 解构 | 19·Object |
| 对象 ↔ 数组 | Object.entries ↔ Object.fromEntries | 19·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/catch | 17 章 |
| 拼 / 解析查询串 | URLSearchParams,别手工拼 & 和 = | 17 章 · 20·URL |
| Base64 与二进制 | TextEncoder · Uint8Array · btoa | 17 章 |
| 上传文件 / 表单 | FormData(别自己设 Content-Type) | 17 章 |
| 等一组异步全完成 | Promise.all;要「都跑到底不早退」用 allSettled | 19·Promise · 14 章 |
| 取最快的那个 | Promise.any(首个成功)/ race(首个落定) | 19·Promise |
| 延时 / sleep | setTimeout——JS 没有内置 sleep,自己包一个 Promise | 19·JSON 与全局 |
| 发网络请求 | fetch + await res.json(),先查 res.ok | 20·fetch |
| 取消请求 / 加超时 | AbortController;只要超时用 AbortSignal.timeout(ms) | 14 章 · 20·fetch |
| 在本地存点数据 | localStorage 配 JSON.stringify(只能存字符串) | 20·Storage |
| 匹配 / 提取文本 | RegExp + match / matchAll;语法见站内 regex 页 | 19·RegExp |
| 找元素 / 建元素 | querySelector / createElement | 20·DOM |
| 监听事件 | addEventListener | 20·事件 |
| 元素进视口时做事 | 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) 得到 nullarr.sort() 看着像「返回排好序的数组」,实际上原地排完再把原数组返回给你——于是 const sorted = list.sort() 里 sorted 和 list 是同一个数组,你以为只是取了个副本,实则把传进来的数据洗了一遍。在 React / Vue 里这还会让状态「改了但界面不更新」(引用没变,框架认为无事发生)。ES2023 起每个都有 to 前缀的不可变版本,默认用那个。另一处反直觉:
fetch 遇到 404 / 500 不会抛异常,只有网络层失败(断网、DNS、CORS、被 abort)才 reject。所以 ③ 里那句 if (!res.ok) throw 不是可选的防御,漏了它就会把一份 HTML 错误页当成 JSON 去解析,最终报出一个和真实病因毫无关系的 SyntaxError。最右列的「17 / 18」比它看起来更重要:19 章是 ECMAScript 语言本身,浏览器、Node、Deno、Bun 里一定都有;20 章是宿主额外提供的,要分两类看——
document、localStorage、事件、各种 Observer、Worker 绑死在浏览器上,写进 Node 脚本会直接 ReferenceError(01 章那张报错卡讲的就是这个症状);而 fetch、URL / URLSearchParams、AbortController、structuredClone 这批虽然出身浏览器,如今已被 Node / Deno / Bun 一并实现,两边通用。反过来 fs、process 在浏览器里也不存在。字符串是 JS 最常用的基本类型之一,不可变——变换类方法(slice/replace/toUpperCase 等)返回新字符串,查询类(indexOf/includes/length 等)返回数字或布尔;下面 s 代表任意字符串实例,每个方法各配一行示例。模板字符串(反引号插值)详见 03 章,正则匹配详见站内 regex 页。
const s = "Hello, World";
// —— 截取与定位 ——
s.length; // 12(属性,非方法)
s.slice(7); // "World"(支持负索引)
s.substring(0, 5); // "Hello"(负数按 0 处理)
s.substr(7, 3); // "Wor"(已废弃,勿用)
s.indexOf("o"); // 4(首次索引,无则 -1)
s.lastIndexOf("o"); // 8(末次索引)
s.includes("World"); // true(是否包含)
s.startsWith("Hello"); // true(是否以…开头)
s.endsWith("World"); // true(是否以…结尾)
s.at(-1); // "d"(ES2022,支持负数)
s.charAt(0); // "H"
s.charCodeAt(0); // 72(UTF-16 码元)
s.codePointAt(0); // 72(码点)
// —— 大小写与空白 ——
s.toUpperCase(); // "HELLO, WORLD"
s.toLowerCase(); // "hello, world"
" hi ".trim(); // "hi"(去两端空白)
" hi ".trimStart(); // "hi "(去开头)
" hi ".trimEnd(); // " hi"(去结尾)
// —— 补齐 · 拆分 · 替换 · 重复 ——
"7".padStart(3, "0"); // "007"
"7".padEnd(3, "0"); // "700"
s.split(", "); // ["Hello", "World"]
s.replace("l", "L"); // "HeLlo, World"(仅首个)
s.replaceAll("l", "L"); // "HeLLo, WorLd"(全部)
"ab".repeat(3); // "ababab"
// —— 正则匹配(详见站内 regex 页)——
"a1b2".match(/\d/g); // ["1", "2"]
[..."a1b2".matchAll(/\d/g)]; // 两个匹配结果对象.length 数的是 UTF-16 码元不是字符:emoji 占 2 个,"a👍b".length 是 4 不是 3。而 s[i]、slice、split("") 都按码元切,切中 emoji 会得到半个字符(孤立代理项,渲染成乱码)。凡处理可能含 emoji 的用户输入,数长度改用 [...s].length、截断改用 Intl.Segmenter(详见 18 章「字符串是 UTF-16 码元序列」)。数值运算分散在两处: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。数组是最常用的数据结构;方法分可变(直接改原数组,如 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,调了会 TypeErrorsort() / reverse() / splice() / fill() 会直接改原数组。ES2023 给前三个各配了一个 to 前缀的不可变版(toSorted / toReversed / toSpliced),外加换单个元素的 with——写 React / Vue 状态时优先用它们,比 [...nums].sort() 更省事也更不容易漏。fill() 没有对应的不可变版(没有 toFilled),要不改原值只能自己 [...nums] 拷一份。sort() 还有个独立的坑:默认按字符串 Unicode 比较,所以 [10, 9, 1].sort() 得到 [1, 10, 9]——数字排序必须传比较函数 (a, b) => a - b,toSorted 同理。排序的另外两条契约(稳定性、比较器必须自洽)以及中文按拼音排序,见 18 章。reduce 务必传初始值(第二参),否则空数组会抛错;回调里忘记 return 累加器是最常见的 bug。Object 的静态方法负责枚举、合并、原型与属性描述;structuredClone 是全局函数而非 Object 方法。下面每个方法各配一行示例——这里是平铺速查;is / hasOwn vs in 的语义细节与 entries 变换组合技见 11 章「Object:易错语义与组合技」。
const o = { a: 1, b: 2 };
// —— 枚举 ——
Object.keys(o); // ["a", "b"]
Object.values(o); // [1, 2]
Object.entries(o); // [["a", 1], ["b", 2]]
// —— 合并与冻结 ——
Object.assign({}, o, { c: 3 }); // { a:1, b:2, c:3 }(浅拷贝合并)
Object.freeze(o); // 浅冻结,禁止增删改属性
// —— 原型与属性描述 ——
Object.create(o); // 以 o 为原型创建新对象
Object.getPrototypeOf(o); // Object.prototype(取原型)
Object.defineProperty(o, "c", { value: 3 }); // 定义属性及描述符
// —— 转换与判断 ——
Object.fromEntries([["x", 1]]); // { x: 1 }(entries 逆操作)
Object.hasOwn(o, "a"); // true(ES2022,取代 hasOwnProperty)
// —— 深拷贝(全局函数,非 Object 方法)——
structuredClone(o); // { a:1, b:2 } 深拷贝Object.keys/values/entries 只枚举自有可枚举属性,且整数样式的键会被提前并升序——Object.keys({ 2:1, 1:1, b:1 }) 得到 ["1","2","b"],不是插入序。此外 Object.assign 和对象展开都只做浅合并,嵌套对象仍是共享引用。要保插入序、要非字符串键、要深拷贝,分别用 Map(18 章)与 structuredClone(17 章)。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/deleteJSON.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")); // 已失败的 Promisenew Promise(fn) 里的 executor 是同步立即执行的,别把该延后的逻辑写进去;② 漏写 await——const x = asyncFn() 拿到的是 Promise 不是值,而且这个 Promise 若 reject 又没人 catch,Node 22 默认会因 unhandledRejection 直接崩掉进程。await Promise.all([a(), b()]);写成 await a(); await b(); 会退化成串行、慢一倍。要「都跑到底不早退」用 allSettled,要「取最快成功的一个」用 any——选哪个取决于失败后你打算怎么办(详见 12、18 章)。这些工具直接挂在全局作用域上,无需任何对象前缀即可调用:JSON 在对象与字符串间互转(网络传输、本地存储的基础),parseInt / parseFloat / isNaN 做宽松的数值解析(更严谨的版本见上方 Number 卡),encodeURIComponent 系列处理 URL 编码,setTimeout 系列与 queueMicrotask 安排任务的执行时机。下面每个各配一行示例(JSON 详见 17 章、定时器见 20 章「Web API 速查」、微任务详见 14 章)。
// —— JSON(详见 17 章)——
JSON.stringify({ a: 1 }); // '{"a":1}'
JSON.parse('{"a":1}'); // { a: 1 }
// —— 全局解析与判断(建议优先 Number.* 版本)——
parseInt("42px", 10); // 42(遇非数字停止)
parseFloat("3.14em"); // 3.14
isNaN("foo"); // true(会先隐式转换)
isFinite(42); // true
// —— URI 组件编解码 ——
encodeURIComponent("a b&c"); // "a%20b%26c"
decodeURIComponent("a%20b"); // "a b"
// —— 定时器(详见 20 章 Web API 速查)——
const id = setTimeout(fn, 1000); // 延时一次,返回 id
clearTimeout(id); // 取消 setTimeout
const t = setInterval(fn, 1000); // 周期执行,返回 id
clearInterval(t); // 取消 setInterval
// —— 微任务(详见 14 章)——
queueMicrotask(() => console.log("微任务")); // 加入微任务队列JSON.parse 遇非法输入抛 SyntaxError——JSON.parse("{a:1}")(键没加引号)报 SyntaxError: Expected property name or '}' in JSON at position 1,JSON.parse("") 报 Unexpected end of JSON input;任何外部数据都得包 try/catch。冷知识:JSON.parse(null) 不报错,它先把 null 转成字符串 "null" 再解析,返回 null。parseInt 永远显式传基数:parseInt("0x1f") 会被当十六进制解析成 31,parseInt(str, 10) 才稳妥;要求「整串必须是合法数字」则用 Number()(但注意空串和 null 都会变 0)。判 NaN 用 Number.isNaN 而非全局 isNaN。正则表达式用于字符串的模式匹配,可用字面量 /pattern/flags 或构造函数 new RegExp(pattern, flags?) 创建;下面把方法、标志与 String 侧配合各配一行。正则语法(字符类、分组、断言等)详见站内 regex 页。
// —— 创建 ——
const re = /\d+/g; // 字面量:一个或多个数字
const re2 = new RegExp("\\d+", "g"); // 构造:字符串里反斜杠要转义
// —— 方法 ——
re.test("abc123"); // true(是否匹配)
re.exec("a1 b2"); // ["1", ...] 或 null(配 g 循环取)
// —— 常用标志 ——
/x/g; // g 全局
/x/i; // i 忽略大小写
/x/m; // m 多行
/x/s; // s dotAll(. 匹配换行)
/x/u; // u Unicode
/x/y; // y 粘连
// —— String 侧配合 ——
"a1b2".match(/\d/g); // ["1", "2"]
[..."a1b2".matchAll(/\d/g)]; // 全部匹配结果对象
"a1b2".replace(/\d/g, "#"); // "a#b#"(首个 / 全部随 g)
"a1b2".replaceAll(/\d/g, "#"); // "a#b#"(正则须带 g)
"a1b2".split(/\d/); // ["a", "b", ""]g 标志的正则对象有内部 lastIndex 状态,重复用同一个 re 调 test / exec 时结果会「跳着走」;循环里尽量新建正则或改用 matchAll。这条坑的完整机制、以及「模块顶层的正则常量是共享可变状态」这个更隐蔽的变体,见 18 章。g,且那时直接用 matchAll/replaceAll;校验(test)、单次替换都别加 g(会引入 lastIndex 状态,见 pitfall)。注意 replaceAll 传正则时必须带 g,否则抛 TypeError: String.prototype.replaceAll called with a non-global RegExp argument;构造函数写法里反斜杠要双写——new RegExp("\\d+")。JavaScript · Web API 速查
本章速查 宿主环境(浏览器)提供的 Web API——它们不属于 ECMAScript 语言本身,而由浏览器注入到全局:fetch 发网络请求,localStorage 存数据,DOM 与事件系统操作页面,定时器与各类 Observer 响应变化,URL / Clipboard / History / Geolocation 封装平台能力,Canvas / SVG / WebGL 负责绘图。这些 API 在 Node 中多不存在或需 polyfill;语言内置对象见上一章「内置对象速查」。
fetch 是浏览器发起网络请求的现代标准,一个函数配上 async/await 就取代了繁琐的 XMLHttpRequest。
一个容易忘的两段式:fetch 的 Promise 只等到响应头就 resolve,响应体还得再 await res.json() / res.text()(它们各自又返回 Promise)——所以一次请求通常要 await 两次。// GET 请求
const res = await fetch("https://api.example.com/users");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
// POST 请求(发送 JSON)
const res2 = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user: "alice", pwd: "123" }),
});
// 超时控制(用 AbortController)
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 5000);
const res3 = await fetch(url, { signal: ctrl.signal });res.ok 或 res.status,否则会把错误响应当成成功处理。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");setItem 会把值先转成字符串——存 123 读回来是 "123",存对象忘了 JSON.stringify 会变成 "[object Object]"。读不存在的键返回 null(不是 undefined);好在 JSON.parse(null) 恰好得到 null 而不报错(Node 22),但 JSON.parse 遇到损坏数据会抛 SyntaxError,读回对象最好包一层 try/catch。网页上看得见的每个标签,在 JS 眼里都是这棵 DOM 树上可增删改查的节点。
DOM(文档对象模型)是浏览器把 HTML 解析成的树状对象结构,页面上每个标签都是一个可被 JS 读写的节点。日常操作分四类:用querySelector 系列查询元素,用 createElement + appendChild 创建与插入节点,用 textContent / classList / setAttribute 读写内容与属性,用 remove 删除节点。每次改动都可能触发浏览器重排/重绘,高频或批量操作要留意性能。// 查询元素
document.querySelector("#app"); // 返回第一个匹配
document.querySelectorAll(".item"); // 返回 NodeList
// 创建 & 插入
const div = document.createElement("div");
div.textContent = "Hello";
div.classList.add("box");
document.body.appendChild(div);
// 更高效的批量插入(DocumentFragment)
const frag = document.createDocumentFragment();
["A", "B", "C"].forEach(text => {
const li = document.createElement("li");
li.textContent = text;
frag.appendChild(li);
});
document.querySelector("ul").appendChild(frag);
// 删除节点
div.remove();querySelectorAll 返回的是静态快照 NodeList,取出后 DOM 再变它也不更新;而 getElementsByClassName/getElementsByTagName 返回实时 HTMLCollection,边遍历边删节点会漏掉元素(索引在塌缩)。另外 NodeList 只带 forEach,想用 map/filter 得先 Array.from 转成数组。textContent 而非 innerHTML 插入文本,可避免 XSS 注入风险。批量插入时用 DocumentFragment 可以减少重排次数,性能更好。用户的每一次点击、输入、滚动,都是靠给元素挂监听器接住的。
浏览器通过事件模型实现用户交互。事件传播分为三个阶段:捕获(从 document 向目标)→ 目标 → 冒泡(向上回传)。事件委托是将事件监听器绑定在父元素上,利用冒泡机制统一处理子元素的事件,性能更优。// 基本用法
btn.addEventListener("click", (e) => {
e.preventDefault(); // 阻止默认行为(如表单提交)
e.stopPropagation(); // 阻止冒泡
console.log(e.target); // 实际被点击的元素
});
// 只触发一次
btn.addEventListener("click", handler, { once: true });
// 事件委托:统一处理列表项点击
document.querySelector("ul").addEventListener("click", (e) => {
const li = e.target.closest("li"); // 向上找最近的 li
if (!li) return;
console.log("点击了:", li.dataset.id);
});removeEventListener 必须传入与添加时同一个函数引用才能解绑——匿名函数、每次新建的箭头函数、.bind(this) 的返回值都是新引用,绑上去就再也移不掉,是单页应用里监听器越堆越多、内存泄漏的常见来源。正确做法是把 handler 存成具名变量,添加和移除都用它。{ passive: true } 选项可提升滚动性能,适用于 touchstart/wheel 事件(告诉浏览器你不会调用 preventDefault)。让代码「过一会儿再跑」或「每隔一段跑一次」,靠的就是这三个定时器。
setTimeout 延迟一次性执行;setInterval 周期性重复执行,用 clearInterval 停止。requestAnimationFrame(rAF) 将回调同步到浏览器的下一次重绘,适合做流畅动画,比 setInterval 精度更高且不在后台执行(自动节能)。// 延迟执行
const id = setTimeout(() => console.log("1s 后"), 1000);
clearTimeout(id); // 取消
// 周期执行(每 500ms 一次)
const tid = setInterval(() => tick(), 500);
clearInterval(tid);
// 动画循环(推荐方式)
let x = 0;
function animate() {
x += 2;
el.style.transform = `translateX(${x}px)`;
if (x < 300) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);setInterval 不保证精准间隔——如果回调执行时间超过间隔,会导致积压。复杂定时任务推荐用 setTimeout 递归实现(每次执行完再安排下一次)。setTimeout(fn, delay, a, b) 从第三个参数起会原样传给回调,省去包一层闭包(Node 22 a b 如实传入)。注意 setTimeout(fn, 0) 也不是「立即」——它排的是宏任务,执行顺序是同步代码 → 微任务(queueMicrotask/Promise.then)→ 才轮到它,要在本轮尽早跑用微任务而非 0ms 定时器。与其手写字符串拼 URL 和查询参数,不如交给专门的对象去解析和编码。
URL 对象可结构化解析和构建 URL,避免手动字符串拼接出错。URLSearchParams 专门处理 ?key=value 形式的查询字符串,自动处理编码和多值问题。(URLSearchParams 与 encodeURIComponent 的编码细节详见 17 章「URL 编码」卡,本卡侧重 URL 对象本身。)const url = new URL("https://example.com/search?q=js&page=2");
url.hostname; // "example.com"
url.pathname; // "/search"
url.searchParams.get("q"); // "js"
url.searchParams.get("page"); // "2"
// 修改参数
url.searchParams.set("page", "3");
url.searchParams.append("sort", "asc");
url.toString(); // "https://example.com/search?q=js&page=3&sort=asc"
// 独立使用 URLSearchParams
const params = new URLSearchParams(location.search);
params.has("token"); // true / false
[...params.entries()]; // [["q","js"],["page","2"]]URLSearchParams 用 + 表示空格:?q=a+b 的 get("q") 解出 "a b"(Node 22),而 toString() 又把空格编码回 +、把 & 编码成 %26——它和 encodeURIComponent(空格→%20)不是一套规则,别混用。还有 new URL("/path") 不传第二个 base 参数会直接抛 TypeError。new URL("/path", "https://example.com")。处理来自后端的回调 URL 时,用 URL 对象解析比正则安全得多。「一键复制」按钮背后,是浏览器开放的异步剪贴板读写能力。
navigator.clipboard 提供异步读写系统剪贴板的能力,是现代「一键复制」功能的标准实现。读取剪贴板需要用户授予权限;写入(复制)在安全上下文(HTTPS 或 localhost)下通常无需额外授权。// 复制文本到剪贴板
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
console.log("✅ 复制成功");
} catch (err) {
console.error("复制失败:", err);
}
}
// 从剪贴板读取文本
async function pasteText() {
const text = await navigator.clipboard.readText();
console.log("剪贴板内容:", text);
}
// 兼容旧浏览器的降级方案
function legacyCopy(text) {
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
}navigator.clipboard 只在安全上下文(HTTPS/localhost)里存在,普通 http 页面上它是 undefined,直接访问就 TypeError。writeText/readText 还必须由用户手势(点击等)触发,脚本里主动调用会被拒;readText(读剪贴板)权限更严,Firefox 长期只对扩展开放,做「粘贴」功能要有降级预案。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 按容器自适应),取代了监听 windowresize 再手动测量的老做法;MutationObserver 监听 DOM 树的增删改(子节点、属性、文本变化),常用于感知第三方脚本注入或框架外的 DOM 改动。两者都用 observe(target, options) 开始、disconnect() 停止。// ResizeObserver:监听容器尺寸变化
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
console.log(`尺寸变为 ${width}×${height}`);
}
});
ro.observe(document.querySelector("#chart"));
// ro.disconnect(); // 停止监听
// MutationObserver:监听 DOM 变化
const mo = new MutationObserver(mutations => {
mutations.forEach(m => {
console.log(m.type, m.addedNodes, m.removedNodes);
});
});
mo.observe(document.body, {
childList: true, // 监听子节点增删
subtree: true, // 递归监听所有后代
attributes:true, // 监听属性变化
});ResizeObserver 回调里改动被观察元素的尺寸会引发循环,控制台报 ResizeObserver loop completed with undelivered notifications。MutationObserver 的回调拿到的是批量队列而非单条变更,且默认不含属性旧值,要 attributeOldValue: true 才有;两者都不会为观察开始前的既有状态补发通知,只报之后的变化。JS 主线程只有一根,想不卡界面地跑重计算,就得把活儿丢到后台线程。
JS 主线程是单线程的,复杂计算会阻塞 UI 渲染。Web Worker 允许在后台线程运行脚本,通过postMessage 与主线程通信(数据传递是结构化克隆,非共享内存)。Worker 内无法访问 DOM,适合执行 CPU 密集型任务。// main.js — 主线程
const worker = new Worker("worker.js");
worker.postMessage({ data: bigArray }); // 发送数据给 worker
worker.onmessage = (e) => {
console.log("结果:", e.data.result); // 接收计算结果
};
worker.onerror = (err) => console.error(err);
// worker.js — 后台线程(独立文件)
self.onmessage = (e) => {
const result = heavyCompute(e.data.data); // 不阻塞主线程
self.postMessage({ result });
};
// 内联 Worker(不需要单独文件)
const blob = new Blob([`self.onmessage=e=>self.postMessage(e.data*2)`]);
const w = new Worker(URL.createObjectURL(blob));postMessage 走结构化克隆而非共享引用:传函数直接抛 DataCloneError(Node 22 structuredClone 报 () => 1 could not be cloned);传类实例则丢掉原型链——方法全没、instanceof 变 false,只剩下数据字段。DOM 节点、Symbol 同样不能传,跨线程只发纯数据。ArrayBuffer 时可以使用 Transferable Objects(postMessage(buf, [buf])),数据所有权转移而非复制,性能更高。SPA 要在不刷新页面的前提下改地址栏、留下前进后退记录,靠的就是 History API。
History API 让 SPA 在不刷新页面的情况下更改浏览器 URL 和历史记录。pushState 添加一条记录,replaceState 替换当前记录,popstate 事件在用户点击前进/后退时触发。这是 React Router、Vue Router 等路由库的底层实现。// 导航到新路由(不刷新页面)
history.pushState({ page: "about" }, "", "/about");
// 替换当前历史记录(不产生新条目)
history.replaceState({ page: "home" }, "", "/home");
// 监听浏览器前进/后退
window.addEventListener("popstate", (e) => {
console.log("当前状态:", e.state);
renderPage(location.pathname); // 根据 URL 重新渲染
});
// 手动前进/后退/跳跃
history.back();
history.forward();
history.go(-2); // 后退两步#/about)是另一种方案,不需要服务端配合,但 URL 不美观。History API 路由要求服务端对所有路径都返回同一个 HTML 文件(Nginx 配置 try_files)。pushState/replaceState 本身不会触发 popstate——只有用户点前进/后退(或调 history.back())才触发;所以改完地址一定要自己手动调一次渲染函数,别指望监听器代劳。传给它的 state 对象走结构化克隆存储,塞函数会 DataCloneError,且有大小上限(Firefox 约 640KB),只放能序列化的轻量数据。拿到用户的经纬度需要一套标准接口,也需要用户点头授权。
navigator.geolocation 可获取设备当前的经纬度,需要用户主动授权。getCurrentPosition 获取一次;watchPosition 持续监听位置变化(适合导航类应用)。仅在安全上下文(HTTPS)下可用。// 获取当前位置(一次性)
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude, longitude, accuracy } = pos.coords;
console.log(`纬度: ${latitude}, 经度: ${longitude}`);
console.log(`精度: ${accuracy} 米`);
},
(err) => console.error(err.message),
{ enableHighAccuracy: true, timeout: 5000 }
);
// 持续监听位置变化(适合导航)
const watchId = navigator.geolocation.watchPosition(
(pos) => updateMap(pos.coords),
(err) => console.error(err)
);
// 停止监听
navigator.geolocation.clearWatch(watchId);getCurrentPosition 的默认 timeout 是 Infinity——不显式设置,取不到信号时会一直挂着,成功和错误回调都不来,界面上像卡死。用户拒绝授权走错误回调(err.code === 1,即 PERMISSION_DENIED);默认还可能返回缓存的旧坐标,要实时位置得设 maximumAge: 0。且整套 API 仅在 HTTPS 下可用。enableHighAccuracy: true 会使用 GPS(精度高但耗电多);默认使用 WiFi/基站定位(精度低但快速省电)。用户拒绝授权后,需引导其在浏览器设置中手动开启。canvas 是一块交给 JS 用命令逐笔去画的位图画布,画完即忘、没有 DOM 节点。
<canvas> 是浏览器提供的位图绘图画布,通过 getContext("2d") 获取 2D 渲染上下文,然后用 JS 命令式地绘制路径、文字、图形、图像。常用于图表、签名板、图片处理、游戏等场景。Canvas 是即时模式(画完就忘),没有 DOM 节点,不能用 CSS 控制内部元素。const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
// 矩形
ctx.fillStyle = "#4f8ef7";
ctx.fillRect(10, 10, 100, 60); // x, y, width, height
ctx.clearRect(20, 20, 30, 30); // 擦除区域
ctx.strokeStyle = "#ff7b9c";
ctx.lineWidth = 2;
ctx.strokeRect(150, 10, 80, 60); // 描边矩形
// 路径(Path)
ctx.beginPath();
ctx.moveTo(50, 100);
ctx.lineTo(150, 100);
ctx.lineTo(100, 180);
ctx.closePath();
ctx.fillStyle = "#7ee787";
ctx.fill();
ctx.stroke();
// 圆弧
ctx.beginPath();
ctx.arc(250, 80, 40, 0, Math.PI * 2); // cx, cy, r, 起角, 终角
ctx.fillStyle = "#c9a8ff";
ctx.fill();
// 文字
ctx.font = "bold 18px sans-serif";
ctx.fillStyle = "#fff";
ctx.fillText("Hello Canvas", 10, 220);
// 绘制图片
const img = new Image();
img.src = "photo.jpg";
img.onload = () => ctx.drawImage(img, 0, 0, 200, 150);
// 导出为图片
const dataURL = canvas.toDataURL("image/png");width/height 乘以 devicePixelRatio,再用 CSS 缩回原尺寸,并调用 ctx.scale(dpr, dpr)。ctx.save() 保存状态(变换矩阵、样式等),完成后 ctx.restore() 恢复,避免样式污染。动画循环用 requestAnimationFrame 配合 clearRect 每帧清空重绘。在基础绘图之上,2D 上下文还能叠加坐标变换、渐变、阴影这些让画面「立体」起来的能力。
在基础绘图之上,Canvas 2D 上下文还提供变换矩阵系统——translate / rotate / scale 会叠加改变后续绘制的坐标系(务必配合 save/restore 隔离);以及渐变、阴影、全局透明度,和直接读写像素的 getImageData / putImageData,可实现丰富的视觉效果与滤镜。// 变换(基于当前坐标系叠加)
ctx.save();
ctx.translate(200, 200); // 移动坐标原点
ctx.rotate(Math.PI / 4); // 旋转 45°
ctx.scale(1.5, 1.5); // 缩放
ctx.fillRect(-30, -30, 60, 60); // 围绕新原点绘制
ctx.restore(); // 恢复之前的坐标系
// 线性渐变
const grad = ctx.createLinearGradient(0, 0, 200, 0);
grad.addColorStop(0, "#4f8ef7");
grad.addColorStop(0.5, "#c9a8ff");
grad.addColorStop(1, "#ff7b9c");
ctx.fillStyle = grad;
ctx.fillRect(0, 50, 200, 60);
// 径向渐变
const radial = ctx.createRadialGradient(100, 100, 10, 100, 100, 60);
radial.addColorStop(0, "white");
radial.addColorStop(1, "rgba(0,0,0,0)");
// 阴影
ctx.shadowColor = "rgba(0,0,0,0.5)";
ctx.shadowBlur = 10;
ctx.shadowOffsetX = 4;
ctx.shadowOffsetY = 4;
// 全局透明度
ctx.globalAlpha = 0.7;
// 像素操作(滤镜/截图)
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// imgData.data 是 [R,G,B,A, R,G,B,A, ...] 的 Uint8ClampedArray
for (let i = 0; i < imgData.data.length; i += 4) {
const gray = (imgData.data[i] + imgData.data[i+1] + imgData.data[i+2]) / 3;
imgData.data[i] = imgData.data[i+1] = imgData.data[i+2] = gray; // 灰度化
}
ctx.putImageData(imgData, 0, 0);translate/rotate/scale 是叠加到当前矩阵上的,不配 save/restore 就会累积、污染后续所有绘制;shadowColor、globalAlpha、fillStyle 这类同样是持续生效的全局状态,设了不复位会一直带着。另外画过跨源且未开 CORS 的图片后,canvas 被「污染」,getImageData/toDataURL 会抛 SecurityError。ctx.setTransform(a, b, c, d, e, f) 可以直接设置变换矩阵(绕过叠加),ctx.resetTransform() 重置为单位矩阵。像素操作(getImageData/putImageData)性能较重,大图建议在 OffscreenCanvas + Worker 中处理。和位图 canvas 不同,SVG 的每个图形都是真实 DOM 节点,能任意缩放不失真、能用 CSS 和 JS 直接操控。
SVG(可缩放矢量图形)是 XML 格式的矢量图,内嵌在 HTML 中后每个图形元素都是真实的 DOM 节点,可以用 CSS 设样式、用 JS 操控、用 CSS/SMIL/JS 做动画,且任意缩放不失真。适合图标、数据可视化、复杂插图;Canvas 适合像素操作和高频重绘的游戏。<!-- HTML 中内联 SVG -->
<svg width="200" height="200" viewBox="0 0 200 200">
<circle id="dot" cx="100" cy="100" r="40" fill="#4f8ef7" />
<rect x="20" y="20" width="60" height="40" rx="8" fill="#7ee787" />
<line x1="0" y1="0" x2="200" y2="200" stroke="#ff7b9c" stroke-width="2"/>
<path d="M10 80 Q95 10 180 80 T350 80" fill="none" stroke="#c9a8ff"/>
<text x="100" y="170" text-anchor="middle" fill="white">SVG</text>
</svg>
// JS 动态创建 SVG 元素(必须用 createElementNS)
const NS = "http://www.w3.org/2000/svg";
const svg = document.querySelector("svg");
const circle = document.createElementNS(NS, "circle");
circle.setAttribute("cx", "50");
circle.setAttribute("cy", "50");
circle.setAttribute("r", "30");
circle.setAttribute("fill", "#ffb454");
svg.appendChild(circle);
// CSS 动画(在 <style> 中)
// #dot { animation: pulse 1s infinite alternate; }
// @keyframes pulse { to { r: 60; opacity: 0.5; } }
// JS 控制 SVG 元素(像操作普通 DOM 一样)
document.querySelector("#dot").addEventListener("click", (e) => {
e.target.setAttribute("fill", "#ff7b9c");
});createElementNS 而非 createElement,否则元素无法正确渲染。SVG 几何属性(r/cx/cy)的 CSS 过渡/动画在 Chrome、Firefox 已支持、Safari 支持较晚,跨浏览器可用 SMIL 或 JS 插值兜底。当图形多到 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]);getContext("2d") 再求 getContext("webgl2") 会返回 null。WebGL 上下文还可能丢失(切后台、GPU 重置、显存不足),要监听 webglcontextlost 事件并重建纹理/缓冲等资源,否则画面变黑。transferControlToOffscreen() 会把控制权永久交给 Worker,此后主线程再直接对原 canvas 绘制无效。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.tsnode app.ts 能跑通,不代表类型没问题。Node 的原生支持只是把类型注解删掉,一次检查都不做:const n: number = "明明是字符串" 照样正常打印。同理,enum、参数属性等「会生成运行时代码」的 TS 语法它直接拒绝执行(报 ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX)。想要类型安全,tsc --noEmit 这一步不能省。tsc --noEmit 负责「有没有类型错误」,node 负责「跑起来对不对」——两者互不替代。真实项目里前者通常挂在编辑器和 CI 上持续跑,后者才是你手动敲的那条命令。TS 的类型不止 number、string 这些大类,它还能把「某一个具体值」本身当成类型——这正是后面联合、判别联合、收窄的地基。
: 类型 给变量加注解,但多数时候靠类型推断即可(const x = 42 自动推断为 number,再写 : number 是冗余)。字面量类型让「值本身」成为类型("left" | "right");as const 把对象/数组推断为最窄的只读字面量类型。// 原始类型注解(多数可省略,靠推断)
let name: string = "Alice";
let big: bigint = 9007199254740991n;
let sym: symbol = Symbol("id");
// 字面量类型:值就是类型
let dir: "left" | "right" | "up" | "down";
dir = "left"; // ✅
// dir = "diagonal"; ← ❌ 报错
// as const:推断为最窄的只读字面量类型
const config = { method: "GET" } as const;
// config.method 的类型是 "GET",而非 string;整体 readonlylet m = "GET" 推断成 string(可变,故加宽),传给要求 "GET" | "POST" 的形参当场报 error TS2345: Argument of type 'string' is not assignable;对象属性同理,{ method: "GET" }.method 也是 string 而非 "GET"。要留住字面量类型,用 const 声明或给对象加 as const(tsc 7)。const arr: number[] = [])、以及想收窄变量可取值范围时(字面量联合)。让 TS 替你推断,代码更简洁也更不易过时。数组、元组、以及 any/unknown/never,是你每天都要标注的类型;真正需要斟酌的是这三个特殊类型之间的取舍。
T[] 或 Array<T>。元组是定长定类型的数组([string, number])。三个特殊类型:any 关闭检查(尽量避免)、unknown 是「安全的 any」(用前必须收窄)、never 表示不可能的值(永不返回的函数、穷举检查)。const nums: number[] = [1, 2, 3];
const entry: [string, number] = ["Alice", 28]; // 元组
// unknown:接收外部数据,用前必须收窄
function parse(input: unknown) {
if (typeof input === "string") {
return input.toUpperCase(); // ✅ 收窄后才能用
}
}
// never:函数永不正常返回
function fail(msg: string): never {
throw new Error(msg);
}any 等于关掉 TypeScript。接收 JSON、API 响应等外部数据时用 unknown,强制调用者先做类型检查再使用;any 会让类型错误悄无声息地溜进运行时。any 是「别检查我」,unknown 是「检查完再用我」,never 是「我根本不该出现」。外部输入一律先接成 unknown,用前用 typeof 或类型守卫收窄——没收窄就访问成员会报 error TS18046: 'x' is of type 'unknown';never 则留给「穷举分支」和「永不返回的函数」(tsc 7)。「或」与「且」这两个类型运算符,是 TS 组合已有类型最基础的两把工具,也是判别联合与工程建模的起点。
联合类型A | B 表示「或」(是 A 或是 B),用时常配类型收窄。交叉类型 A & B 表示「且」(同时具备两者所有字段),常用于组合已有类型。// 联合:是 A 或 B
type ID = string | number;
function printId(id: ID) {
if (typeof id === "string") id.toUpperCase(); // 这里是 string
else id.toFixed(2); // 这里是 number
}
// 交叉:同时具备两者所有字段
type Timestamped = { createdAt: Date };
type Audited = { createdBy: string };
type Entity = Timestamped & Audited;
// 常见:扩展已有类型
type AdminUser = User & { permissions: string[] };never,不是「都满足」。string & number 结果是 never(没有值能同时是二者);两个对象里同名字段类型冲突时,{ a: number } & { a: string } 的 a 也变成 never——声明处不报错,等你给它赋任何值时才报 error TS2322: Type 'number' is not assignable to type 'never'(tsc 7)。A | B 时只能访问两者共有的成员,想用各自独有的方法必须先用 typeof/in/字面量相等收窄到具体分支(否则报 error TS2339: Property ... does not exist on type);A & B 则相反,一步得到同时含两边全部字段的类型,最常用来给已有对象类型「加料」。enum 是 TS 里少数会「漏进运行时」的语法,用还是不用,几乎是每个 TS 项目都要表态的一道选择题。
enum 是 TS 里极少数会生成运行时代码的语法(编译后是真实对象),违背「类型擦除」原则,现代 TS 对它态度审慎:字符串枚举尚可;const enum 在 esbuild/Babel 等单文件转译器下无法内联;Node 原生运行 TS 的「类型剥离」模式干脆不支持 enum(参见 erasableSyntaxOnly 编译选项)。现代替代:字面量联合类型(轻量首选),或 as const 对象 + keyof typeof 派生(需要「既是值又是类型」时)。// enum:编译后生成真实的运行时对象
enum Direction { Up, Down }
// 替代①:字面量联合——零运行时,够用就选它
type Status = "pending" | "done" | "failed";
// 替代②:as const 对象——需要"既是值又是类型"时
const STATUS = {
Pending: "pending",
Done: "done",
Failed: "failed",
} as const;
type StatusV = (typeof STATUS)[keyof typeof STATUS];
// ^ "pending" | "done" | "failed"
function move(s: StatusV) {}
move(STATUS.Done); // ✅ 和 enum 一样有"命名空间"感
move("done"); // ✅ 字面量直接传也行(enum 则不允许)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 Box 里 x 一个 number 一个 string,报 error TS2717: Subsequent property declarations must have the same type;而两个同名 type Box 直接报 error TS2300: Duplicate identifier 'Box'——type 没有合并能力(tsc 7)。interface 让它自动并进去;在带 import/export 的模块文件里扩展全局类型(如 Window)时,记得包在 declare global { ... } 里,否则会被当成局部声明而不生效。interface 和 type 八成场景可以互换,真正的区别只集中在少数几处能力边界上。
type 能做 interface 做不到的:联合、元组、映射类型、条件类型等任意类型组合。interface 能做 type 做不到的:声明合并。准则:对象/类结构用 interface(可合并、更语义化)、联合/元组/复杂组合用 type。// 只有 type 能做:
type Shape = Circle | Rectangle; // 联合
type Point = [number, number]; // 元组
type Optional<T> = { [K in keyof T]?: T[K] }; // 映射
type IsStr<T> = T extends string ? true : false; // 条件
// 只有 interface 能做:声明合并(见上一张卡)
// 实践准则:
// 对象/类结构 → interface 联合/元组/组合 → typeinterface 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 编过)。any(strict 下报错);返回值 TS 能从函数体推断,写不写看是否想「对外锁定契约」。可选参数 ? 的真实类型是 T | undefined,且必须排在必选参数之后(否则 error TS1016),也不能和默认值 = 同写(error TS1015: Parameter cannot have question mark and initializer,tsc 7)。一个函数按不同入参返回不同类型时,重载是 TS 的经典表达方式——尽管今天它常被泛型 + 索引类型替代。
重载用多个签名描述函数的不同调用方式(返回不同类型),最后一个「实现签名」对外不可见。但现代更推荐用泛型 + 索引类型代替重载——更简洁、可扩展。// 重载签名:按入参得到不同返回类型
function create(tag: "a"): HTMLAnchorElement;
function create(tag: "canvas"): HTMLCanvasElement;
function create(tag: string): HTMLElement { // 实现签名
return document.createElement(tag);
}
// 更推荐:泛型 + keyof 索引,替代重载
function make<K extends keyof HTMLElementTagNameMap>(
tag: K
): HTMLElementTagNameMap[K] {
return document.createElement(tag);
}error TS2769: No overload matches this call(而非直白的 TS2345);实现签名与某条重载不兼容时报 error TS2394: This overload signature is not compatible with its implementation signature——记住这两个码,遇到时才知道是重载没对齐(tsc 7)。JS 的 this 在运行时飘忽不定,TS 用一个假参数 this 把它的类型在编译期钉死。
this 的类型(伪参数,不占实际参数位)。两个用途:返回 this 类型实现类型安全的链式调用;声明 function(this: User) 防止在错误上下文中调用。interface Builder {
value: number;
add(this: Builder, n: number): this; // 返回 this → 可链式
build(this: Builder): number;
}
// builder.add(5).add(3).build() ← 类型安全的链式调用
// 伪参数 this:限定调用上下文
function formatUser(this: User) {
return `${this.name} <${this.email}>`;
}
// formatUser() ← ❌ 不能独立调用
// formatUser.call(user) ← ✅this 类型的函数不能「裸调用」。function fmt(this: User) {...} 直接写 fmt() 会报 error TS2684: The 'this' context of type 'void' is not assignable to method's 'this' of type 'User';必须 fmt.call(user) 或作为 user.fmt() 方法调用才能提供正确的 this(tsc 7)。this 是伪参数:不占实参位、编译后消失。它只在类型层面工作,用来干两件事——方法返回 this 类型实现类型安全的链式调用;以及声明 function f(this: User) 把函数钉死在正确的上下文里。调用方传实参时完全无需理会这个 this,它不计入参数个数。两个函数之间谁能赋给谁,取决于参数与返回值的型变方向——这是 TS 类型兼容里最反直觉的一块。
型变(variance)回答一个问题:Dog 是 Animal 的子类型,那「关于它们的复合类型」谁是谁的子类型?规则:返回值位置协变(返回 Dog 的函数可赋给要求返回 Animal 处——给得更具体,安全);参数位置逆变(接收 Animal 的函数可赋给要求接收 Dog 处——要求更宽松,安全)。「参数逆变、返回协变」是函数子类型判定的核心。开启 strictFunctionTypes 后 TS 对函数类型的参数按逆变严格检查;但用方法语法写的参数是双变(协变逆变都放行)——为兼容数组等常见模式而故意保留的历史妥协。class Animal {}
class Dog extends Animal { bark() {} }
// 返回值协变:更具体的返回类型可兼容
type MakeAnimal = () => Animal;
const makeDog: MakeAnimal = () => new Dog(); // ✅ 返回 Dog 兼容返回 Animal
// 参数逆变:更宽松的参数类型可兼容
type HandleDog = (d: Dog) => void;
const handle: HandleDog = (a: Animal) => {}; // ✅ 接收 Animal 更宽松,安全
// ❌ 反过来不安全:要求处理任意 Animal,却只会处理 Dog
type HandleAnimal = (a: Animal) => void;
// const bad: HandleAnimal = (d: Dog) => d.bark();
// strictFunctionTypes 下报错:Dog 参数比 Animal 窄
// 方法写法双变(不受 strictFunctionTypes 约束)vs 属性写法逆变(更严)
interface A { on(d: Dog): void; } // 方法语法 → 双变
type B = { on: (d: Dog) => void }; // 属性语法 → 逆变on(d: Dog): void 写成方法语法时,即使拿一个只会处理 Dog 的函数去顶替「要处理任意 Animal」的位置,tsc 也不报错;换成属性语法 on: (d: Dog) => void 才会按逆变严格检查、报 error TS2322 ... Property 'bark' is missing in type 'Animal' but required in type 'Dog'。Dog[] 能赋给 Animal[] 也是这个双变妥协的产物(tsc 7)。on: (x) => void)而非方法写法(on(x): void),并开启 strictFunctionTypes(已含于 strict)。TypeScript · 泛型
泛型让类型成为参数:写一次,适配多种类型,同时保持完整的类型推断。泛型约束与条件类型是构建可复用、类型安全抽象的核心工具。
泛型把「类型」本身也变成可传入的参数,让同一段代码类型安全地服务多种类型。
泛型把类型变成参数<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 IDT,你对它「一无所知」。泛型默认不假设 T 有任何成员,函数体里写 v.length 会报 error TS2339: Property 'length' does not exist on type 'T'——想用 T 的属性,必须先用 extends 约束它(见下一张卡)。这是初学泛型最常撞的墙(tsc 7)。<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") ← ❌ 不存在的 keyfunction first<T>(a: T[]): T 传 ["a"] 推出的 T 是 string 而非 "a"——const r = first(["a"]) 里 r 就是 string,再 const t: "a" = r 便报 error TS2322(注意别把数组字面量直接写在带注解的赋值处,那样目标类型会回流让 T 反推成 "a")。想保留字面量,把类型参数写成 <const T>(TS 5.0+,tsc 7 有效),或在调用处对实参用 as const。K extends keyof T + 索引访问 T[K] 是类型安全取值函数的标准套路。getProp(obj, key) 里把 key 约束成 keyof T,既能返回精确的 T[K] 类型,又能在你把 key 拼错时当场报 error TS2345: Argument of type '"email"' is not assignable to parameter of type '"age" | "name"'——把「取错字段」变成编译错误(tsc 7)。泛型的默认推断有两个让人别扭的地方:传数组进去会被加宽成 string[]、丢掉字面量;多个参数共用一个 T 时,每个参数都会往 T 里「投票」,本想当约束的那个反而把类型撑大了。TS 给了两个专门的旋钮。
const 类型参数(TS 5.0):留住字面量,不必在调用处写 as const
- 把
<T>写成<const T>,推断就按「调用方写了as const」来做。 - tsc 7 同一个调用
f(["a", "b"]):普通泛型推出string[],加了const推出readonly ["a", "b"]——元素类型、长度、只读性一次全保住。 - 受益最大的是配置式 API:路由表、状态机的状态列表、表单字段定义。以前要求使用者到处写
as const(忘了就静默退化成string[]),现在把要求写进库的签名里,调用方什么都不用做。
NoInfer<T>(TS 5.4):这个参数只用来检查,不参与推断
function pick<T extends string>(items: T[], fallback: NoInfer<T>)——T只由items决定,fallback拿它当标尺来检查。- tsc 7
pick(["a", "b"], "c")报error TS2345: Argument of type '"c"' is not assignable to parameter of type '"a" | "b"'——正是我们想要的。不加NoInfer时,"c"会一起参与推断把T撑成"a" | "b" | "c",于是什么都不报,错误一路溜到运行时。 - 识别这个模式的信号:同一个类型参数出现在多个位置,而你心里其实有主次——「以这个为准,那个照着它检查」。默认推断不知道主次,
NoInfer就是告诉它。
两者的分工
const T管推得够不够窄(别把"a"加宽成string)。NoInfer<T>管谁有资格参与推断(别让兜底值把类型撑大)。- 两者经常一起出现在同一个库签名里,互不冲突。
// ── const 类型参数(TS 5.0)─────────────────
function normal<T>(x: T): T { return x; }
function keep<const T>(x: T): T { return x; }
const a = normal(["a", "b"]); // string[]
const b = keep(["a", "b"]); // readonly ["a", "b"](tsc 7)
// ── NoInfer(TS 5.4)───────────────────────
function pick<T extends string>(items: T[], fallback: NoInfer<T>): T {
return items.includes(fallback) ? fallback : items[0];
}
pick(["a", "b"], "a"); // OK
pick(["a", "b"], "c"); // TS2345: '"c"' 不能赋给 '"a" | "b"'
// 不加 NoInfer 的话,"c" 会把 T 撑成 "a"|"b"|"c",一声不吭const T 推出来的是只读类型(readonly ["a","b"]),把它传给要求可变数组 string[] 的地方会报「readonly 不能赋给可变」。这不是 bug,是它保住字面量的必然代价——需要可变就别用 const,或者在下游标成 readonly T[]。另外 const 只影响字面量写在调用处的情形,传进去一个已经声明成 string[] 的变量,它救不回来。const T,兜底值与默认值标 NoInfer<T>。它们都只影响推断、不影响运行时,加上去零成本,却能把一批「类型看着对、实际没检查」的调用挡在编译期。写业务代码时用得少,但读库的类型定义时天天见。条件类型给类型加上 if/else,infer 则在匹配的同时就地「抓出」一段类型——TS 的类型编程从这里开始。
T extends U ? X : Y 让类型「分支」。infer 在条件中声明并推断一个类型变量,用来「提取」类型(从函数提取返回值、从 Promise 提取解析值)。TS 内置的 ReturnType 就是这样实现的(内置 Awaited 会递归解包多层 Promise,本例只演示单层)。infer R 用于在类型匹配中声明一个待推断的占位类型:即「若该类型符合某种结构,则把其中某部分提取出来并命名为 R」,相当于类型层面的模式提取。// 条件类型
type NonNull<T> = T extends null | undefined ? never : T;
// infer:在条件类型里提取类型
type MyReturn<T> =
T extends (...args: any[]) => infer R ? R : never;
type MyAwaited<T> =
T extends Promise<infer U> ? U : T;
type V = MyAwaited<Promise<string>>; // stringNonNull<T> = T extends null | undefined ? never : T 传 string | null 时,会逐个成员套用后再合并,得到 string(null 被过滤)——这常常是你要的,但也可能是意外。不想分发就把两边都用元组包起来:[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 | null 写 if (typeof x === "object"),分支里 x 恰好被收窄成 null;真要区分「普通对象/数组/null」得额外补 x !== null 和 Array.isArray(x),光靠 typeof 分不清。never 穷举是 TS 最实用的模式:每当给 Shape 加一个新成员,所有忘记处理它的 switch 都会在编译期报错——把「漏改」变成编译错误,而不是线上 bug。内置的 typeof/instanceof 覆盖不到复杂对象时,自定义 is 谓词让你把任意校验逻辑接进 TS 的收窄机制。
参数 is 类型 的函数——TS 会在 true 分支里把参数收窄为该类型。常用于验证 unknown 的外部数据。// 返回类型用 "val is User" 谓词
function isUser(val: unknown): val is User {
return (
typeof val === "object" && val !== null &&
"id" in val && "name" in val
);
}
function handle(data: unknown) {
if (isUser(data)) {
data.name; // ✅ 这里 data 已收窄为 User
}
}v is User,哪怕函数体只写 return typeof v === "object"(漏查 id/name 字段)也照样编译通过、不报错;谓词逻辑写错,收窄就是错的,运行时才崩。守卫的正确性得你自己保证——TS 只认那句 is(tsc 7 不完整甚至逻辑相反的守卫均编过)。unknown 外部数据「把关」。返回类型写 val is T,在 true 分支里 TS 就把 val 当作 T 使用。它最适合校验 JSON、接口响应这类进程序前类型未知的数据——一处校验,处处收窄,比到处 as 断言安全得多。asserts、!、as 都是你对 TS 说「这里我说了算」——用对了省事,用错了只是把类型错误推迟到运行时。
asserts val is T:执行通过即视为成立(否则抛错),之后该变量被收窄。非空断言 ! 告诉 TS「这值不是 null/undefined」;as 类型断言强制转换类型。三者都是「我比 TS 更清楚」的声明——错了运行时就崩。// 断言函数:通过后变量被收窄
function assertDefined<T>(v: T): asserts v is NonNullable<T> {
if (v == null) throw new Error("nullish!");
}
// 非空断言 !
const el = document.getElementById("app")!; // 去掉 null
// as 类型断言(慎用)
const input = document.querySelector("input") as HTMLInputElement;! 和 as 会绕过类型检查,断言错了运行时直接崩(Cannot read property of null)。优先用类型守卫替代断言;双重断言 x as unknown as T 更是几乎总是设计有问题的信号。assertDefined(x) 调用没问题;但 const fn = assertDefined; fn(x) 这样经过一个无注解变量转手后,收窄会失效——TS 报 error TS2775: Assertions require every name in the call target to be declared with an explicit type annotation,其后 x 仍是可空的(TS18047)。断言函数要么直接调用、要么给中转变量显式标类型(tsc 7)。TypeScript · 工具类型
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):number 与 f(x:string):string 取 ReturnType 得到的是 string(最后一条),而不是两者的联合(把结果赋给 number 报 TS2322)。此外这些工具都作用于类型,对一个值要先套 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,而 Capitalize、Uppercase 这些模板工具只接受 string。直接写 Capitalize<K> 会报 TS2344: Type 'K' does not satisfy the constraint 'string'——必须先用 string & K 把键收窄成字符串(卡内 Capitalize<string & K> 的 string & 正是为此)。[K in keyof T] 的映射是同态的,会自动继承 T 上每个字段原有的 readonly 和 ?——连加 as 重映射键名后也照样保留(重映射后原 readonly 字段仍不可写、报 TS2540)。所以只有想改变修饰符时才需要显式写 -? / -readonly,不写就是「原样带过」。把「事件名带 on 前缀」「CSS 值带 px 单位」这类字符串约定搬进类型系统,让拼错的字符串在编译期就变红,而不是等运行时才发现 onClck 根本没绑上。
模板字面量类型用反引号 +${} 在类型层面拼接字符串,配 Capitalize 等内置工具,还能让联合类型笛卡尔展开。用于约束 CSS 值、事件名、构建类型安全的事件系统等。// 约束字符串形状
type CSSValue = `${number}px` | `${number}rem`;
// 联合展开:4 个方向 → 4 个属性名
type Dir = "top" | "right" | "bottom" | "left";
type Margin = `margin-${Dir}`; // "margin-top" | ... | "margin-left"
// 实战:类型安全的事件处理器对象
type EventMap = { click: MouseEvent; input: InputEvent };
type Handlers = {
[K in keyof EventMap as `on${Capitalize<K>}`]?: (e: EventMap[K]) => void;
};
// { onClick?: (e: MouseEvent) => void; onInput?: ... }${number} 比想象中宽松:"1.5px"、"1e3px"、"-5px"、甚至 "0x10px" 都算合法(tsc 7 全部通过),它挡不住负数与科学计数法,别把它当成「正整数像素」的强校验。另一个坑是组合爆炸:多个联合用模板拼接会做笛卡尔积,成员数超过约十万就直接报 TS2590: Expression produces a union type that is too complex to represent。as 重映射组合,能从一份「数据形状」自动派生出 getter 接口、事件处理器接口、表单字段类型等——一处定义、多处类型安全,是高级库(如表单/ORM)类型推断的常见手法。TypeScript · 类的增强
类语法本身 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;
}private 只是编译期检查,编译成 JS 后字段仍可访问。需要运行时真私有请用 JS 原生的 #field(11 章「类、原型与元编程」已覆盖)。两者别混淆:private 给类型安全,# 给真实封装。public/private/protected/readonly)就是参数属性,一行同时完成「声明字段 + 赋值」,省掉 this.x = x 样板;还能叠加,如 constructor(public readonly x: number) 既公开又只读(类外改 x 报 TS2540)。而 abstract 类只能被继承,直接 new 报 TS2511: Cannot create an instance of an abstract class。装饰器是 NestJS、Angular 这些框架的命门,但 TS 里它「标准」和「传统」两套实现并存——动手前先分清用的是哪套,能省掉大半玄学报错。
装饰器用@ 给类及成员附加行为,是 NestJS、TypeORM、Angular 等框架的核心机制。它有两套互不兼容的写法,先分清再动手:标准装饰器(ECMAScript 提案,TS 5.0 起内置,无需任何编译选项,签名 (value, context))是新代码的默认选择,下例即是;传统装饰器(需开 experimentalDecorators,签名 target, key, descriptor)是 TS 早年的自有实现,NestJS/TypeORM/Angular 等框架目前仍依赖它——接入这些框架时照它们的文档走。// 标准装饰器:TS 5.0+ 开箱可用,不需要 experimentalDecorators
// 方法装饰器:包裹原方法,记录日志
function log<T extends (...args: any[]) => any>(
original: T,
context: ClassMethodDecoratorContext
) {
// 标准装饰器:返回一个新函数来替换原方法
return function (this: any, ...args: any[]) {
console.log(`调用 ${String(context.name)}`, args);
return original.apply(this, args);
};
}
class Service {
value = 42;
@log
getValue() { return this.value; }
}TS1206: Decorators are not valid here——标准提案暂不支持参数装饰器;而开了 experimentalDecorators 后,若仍用标准的双参签名 (value, context) 去写参数装饰器,又会报 TS1239: ...the runtime will invoke the decorator with 3 arguments, but the decorator expects 2——传统参数装饰器要的是 (target, key, index) 三参。看报错码就能反推该切哪一套。function log(target: any, key: string, desc: PropertyDescriptor)——改写 desc.value 而不是返回新函数。看到这个签名就知道是老写法,需要 tsconfig 里开 experimentalDecorators。TypeScript · 工程实战
把 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(含 noImplicitAny、strictNullChecks 等所有严格检查)——这是 TS 价值的核心。另一组决定成败的是模块字段:module / moduleResolution 管「import 怎么被解析」——经打包器(Vite/webpack)构建选 "bundler";代码交给 Node 直接运行选 "nodenext"(此时相对导入必须写全 .js 后缀)。「明明文件在却找不到模块」「默认导出报错」这类玄学问题,九成出在这组字段与实际运行环境不匹配。另注意:baseUrl 在 TS 7 已被移除(报 TS5102),paths 从 4.1 起就不再依赖它,但此时映射值必须是相对路径(./src/*),否则报 TS5090。{
"compilerOptions": {
"target": "ES2022", // 输出语法基准
"module": "ESNext", // Node 直跑则改 "nodenext"
"moduleResolution": "bundler", // 与 module 配套:bundler / nodenext
"strict": true, // 核心,开启全部严格检查
"noUncheckedIndexedAccess": true, // arr[i] 推断为 T | undefined
"noUnusedLocals": true,
"verbatimModuleSyntax": true, // import type 写了才留,消除导入歧义
"skipLibCheck": true, // 不检查 .d.ts,显著提速,几乎必开
"paths": { "@/*": ["./src/*"] }, // 路径别名;没有 baseUrl 时必须写成相对路径
"noEmit": true // 只查类型,产物交给 Vite/esbuild
}
}"strict": true 就把检查开满了:它只含 noImplicitAny、strictNullChecks 等一组,并不包含 noUncheckedIndexedAccess、exactOptionalPropertyTypes、noImplicitOverride 这些(仅开 strict 时 arr[0] 仍被推断为 number 而非 number | undefined,补开该项后才报 TS2322)。想要数组越界、可选属性这类额外保护,得在 strict 之外逐个显式打开。strict: true 起步。给老项目迁移时,不要「先开 strict 再关掉报错多的子项」——strictNullChecks 一关会污染整个项目的类型推断,是最难再开回来的一项。正确做法是保持 strict 全开,按文件推进:暂时改不动的文件顶部加 // @ts-nocheck,改完一个删一个。allowJs + 逐文件从 .js 改名为 .ts 也是同一思路;noUncheckedIndexedAccess 虽然啰嗦,却能挡住大量「数组越界拿到 undefined」的运行时错误,强烈建议开。TS 用久了会沉淀一批「就该这么写」的小技巧,这张卡把最高频的四个 satisfies、typeof、as const、Zod 放在一起,顺带点出它们各自的边界。
四个高频技巧:satisfies(既校验类型又保留精确推断,TS 4.9+)、typeof(从值取类型)、as const+索引(从对象推出字面量联合)、Zod(运行时校验外部数据并反推 TS 类型)。: 类型 注解会把变量按声明类型对待、丢失更精确的字面信息;satisfies 仅校验是否符合该类型而不改变其推断结果,从而兼顾约束与精度。// satisfies:校验但不拓宽类型,保留精确推断
const palette = {
red: [255, 0, 0], green: "#0f0",
} satisfies Record<string, string | number[]>;
// palette.red 仍是 number[],不被拓宽成联合类型
// typeof + as const 从对象推字面量联合:见 21 章「枚举 enum」卡的替代②
// Zod:运行时校验 + 自动推断类型(一举两得)
import { z } from "zod";
const UserSchema = z.object({ id: z.number(), name: z.string() });
type User = z.infer<typeof UserSchema>;
const user = UserSchema.parse(apiResponse); // 运行时验证experimentalDecorators 开或不开是全局的,签名写错会得到「装饰器不能应用于此声明」一类的报错。判断依据很简单:看框架文档,NestJS/TypeORM 走传统,新写的工具函数走标准。TS 只在编译期检查,对运行时数据无能为力。API 返回的数据、表单输入、
JSON.parse 的结果,类型注解都只是「假设」——一旦后端改了字段,TS 不会报错但运行时会崩。边界处用 Zod 等做运行时校验,才能真正闭环类型安全。satisfies 和 : 类型 注解的分工:注解会把变量拓宽成声明类型、丢掉字面量精度;satisfies(TS 4.9+)只校验符不符合、不改变推断。所以 const cfg = {...} satisfies Config 既能挡住拼错的键,又让 cfg.red 保留 number[] 而不被拓宽成联合。想「既要校验又要精确」时就选它。运行时与包管理工具链
本章不属于 JS 或 TS 任何一方,而是两者共用的工程环境:先选一个运行时(Node / Deno / Bun)把代码跑起来,再用包管理器(npm / pnpm / yarn)装依赖、跑脚本。01 章已经给过最小闭环(装 Node、npm init、npm 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.tsnode 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。单个 .js 够用很久,但要用别人的库就需要一个「项目」。
三步建起一个项目
npm init -y生成package.json——项目的身份证,记录依赖清单和快捷命令;npm install dayjs做三件事:下载到node_modules/、把包记进dependencies、把精确版本写进package-lock.json;然后代码里import dayjs from "dayjs"就能用(要让import生效,见下方陷阱);scripts字段给长命令起名字,npm run 名字触发。拿到一个陌生项目,先看这里——它是「这个项目怎么跑」的唯一权威答案。
三个文件该怎么办
package.json——提交,手写编辑没问题;package-lock.json——提交但不要手改。它锁定整棵依赖树的精确版本,是「我这儿能跑、你那儿跑不了」的解药;node_modules/——写进 .gitignore,永不提交,它随时能由前两个文件重建。
# 建项目
mkdir my-app && cd my-app
npm init -y # 生成 package.json
npm install dayjs # 装依赖(简写 npm i dayjs)
// package.json 大致长这样:
// {
// "name": "my-app",
// "type": "module", ← npm init -y 生成的是 "commonjs",改成这个才能用 import
// "scripts": {
// "dev": "node --watch app.js"
// },
// "dependencies": { "dayjs": "^1.11.13" }
// }
npm run dev # 触发 scripts.dev
npx create-vite my-app # 临时借用一个包,用完不留(30 章)Cannot use import statement outside a module 的触发时机很反直觉:裸建一个 .js 写 import,Node 22+ 会自动放行;但只要目录里有一个不含 "type": "module" 的 package.json,同一份代码立刻报错。npm install 装齐依赖,然后翻 package.json 的 scripts 看有哪些命令可用。不要因为 node_modules/ 不在仓库里就以为项目不完整——它本来就不该在。package.json 是项目的「身份证」:记录依赖、脚本命令与元信息。scripts 字段里定义的命令用 npm run <名> 触发(其中 start / test 可省略 run)。
node app.js # 执行 JS 文件
node -e "console.log(1+1)" # 直接执行一段代码
node --watch app.js # 监听变化自动重启
node --inspect app.js # 开启调试器(chrome://inspect)
# package.json 的 scripts:
# "scripts": { "dev": "vite", "build": "tsc && vite build" }
npm run dev # 运行自定义脚本
npm test # = npm run test(可省 run)
npm start # = npm run start
npm run # 不带脚本名:列出本项目所有可用脚本 ← 拿到陌生项目先敲这个-- 分隔,否则参数被 npm 自己吃掉,而且不报错。假设 "dev": "node show.js":·
npm run dev -- --port 3000 → 脚本收到 ["--port", "3000"] ✅·
npm run dev --port 3000 → 脚本只收到 ["3000"],--port 被 npm 当成自己的选项静默吞掉 ❌这类「参数莫名其妙丢了一半」的问题极难排查,习惯性写上
-- 就好。另外,
pre / post 前缀的脚本会被自动带跑:定义了 prebuild 和 postbuild,一句 npm run build 会依次执行三个。这是特性不是 bug,但如果你不知道,就会困惑于「我没调用过的命令为什么执行了」——反过来也别把普通脚本随手命名成 predeploy 之类。npm run 不带任何参数,会把本项目所有脚本连同它们的真实命令一起列出来——拿到一个陌生项目,这比翻 package.json 更快,也顺带告诉你「npm run dev 背后到底跑的是什么」。三者都读写 package.json,命令大同小异:npm 随 Node 自带、最通用;pnpm 用硬链接 + 内容寻址,省磁盘、装得快、依赖隔离严格;yarn 早期以速度和 lockfile 出名。下面按用途逐条对照。
# 初始化 package.json
npm init -y / pnpm init / yarn init # -y 仅 Yarn Classic;Berry 忽略它
# 安装全部依赖
npm install / pnpm install / yarn
# 添加生产依赖
npm i lodash / pnpm add lodash / yarn add lodash
# 添加开发依赖
npm i -D eslint / pnpm add -D eslint / yarn add -D eslint
# 全局安装
npm i -g tsx / pnpm add -g tsx / yarn global add tsx # global add 仅 Yarn Classic;Berry 用 dlx
# 移除依赖
npm rm lodash / pnpm remove lodash / yarn remove lodash
# 运行脚本
npm run dev / pnpm dev / yarn dev
# 临时执行包(免安装)
npx vite / pnpm dlx vite / yarn dlx vitepackage-lock.json / pnpm-lock.yaml / yarn.lock),导致依赖树不一致。lockfile 必须提交到 Git,保证团队与 CI 装到完全相同的版本。npm ci,不是 npm install(pnpm / yarn 的对应写法是 pnpm install --frozen-lockfile / yarn install --immutable)。区别在于:npm install 会为了满足 package.json 而顺手改写 lockfile,于是「锁定版本」形同虚设;npm ci 则删掉 node_modules 严格按 lockfile 重装,一旦两个文件对不上就直接失败(npm error code EUSAGE ... can only install packages when your package.json and package-lock.json are in sync)。这个「宁可失败也不悄悄改」正是 CI 想要的行为——本地 npm i 加了依赖却忘了提交 lockfile,CI 会当场拦下,而不是装出一棵和你不同的依赖树。npx(pnpm 用 dlx、yarn 也用 dlx)临时下载并运行一个包,用完即弃、不污染全局环境——最常用于脚手架这类一次性工具。
npx create-vite my-app # 脚手架创建项目
npx tsx script.ts # 免装直接跑 TS
npx eslint . # 跑临时的 eslint
pnpm dlx create-vite my-app # pnpm 等价写法
yarn dlx create-vite my-app # yarn 等价写法npx 包名 在本地找不到时,会直接从 registry 下载并执行它。这意味着敲错一个字母,你就运行了一个陌生作者的任意代码——而抢注相似包名(typosquatting)是 npm 生态的常见攻击手法,create-vite 与 create-vitte 只差一个字母。所以:脚手架命令建议从官方文档复制,不要凭记忆手敲;不确定时先
npm view 包名 看看发布者、下载量和更新时间。交互式终端里 npx 下载前会提示 Ok to proceed? (y),但在 CI 等非交互环境中不会问,别把这道提示当成安全网。npm install 是「把工具装到我家里」,npx 是「借用一下,用完归还」。脚手架、代码生成器等一次性命令一律用 npx / dlx。还有个容易忽略的行为:包已经装在本项目里时,
npx 优先跑本地那份,不会去下载。所以在项目里敲 npx eslint . 用的是 package.json 锁定的版本,和 CI 一致——这正是你想要的。写了几十行以上的东西就该有测试,而这件事现在不需要装任何包——Node 自带测试运行器和断言库。等项目长大、需要浏览器环境或更强的 mock 时,再换 vitest 也不迟。
零依赖起步:node --test
- 从
node:test导入test/describe/mock,断言用node:assert/strict(strict那个子路径很关键——不带它的assert.equal用的是宽松相等——assert.equal("1", 1)在node:assert下通过,在node:assert/strict下抛ERR_ASSERTION)。 - 跑一句
node --test就行,输出是 TAP 格式,末尾给# pass/# fail汇总。两个用例一过一败时,失败那条会带上文件与行号、code: 'ERR_TEST_FAILURE'。 - 覆盖率也内置:
node --test --experimental-test-coverage,直接打出 all files 的行/分支/函数三档百分比,不用装 c8 或 nyc。 - 能直接测
.ts——e.test.ts被照常发现并跑通(走类型剥离)。但请记住 21 章那条:剥离不等于检查,类型错误照样跑过,tsc --noEmit这一步不能省。
文件发现规则:认 .test. 不认 .spec.
- 放四个文件跑
node --test:a.test.mjs、b_test.mjs、test/c.mjs三个被跑到,而d.spec.mjs一个都没跑——汇总里就是# tests 3。 - 这是最容易白忙一场的地方:从 jest / vitest 项目搬过来的
*.spec.ts命名,在node --test下会静默地一个都不执行,而它不报错、还显示全绿。改名成*.test.*,或者显式把路径传给命令。
什么时候该换 vitest
- 要浏览器环境:测 DOM、测组件,需要 jsdom / happy-dom 的一整套接线。
- 要和构建共用一套配置:vitest 直接吃 vite 的 config,路径别名、TS 路径映射、环境变量一处配置两边生效——这是它相对内置运行器最实在的优势。
- 要更强的 mock 与快照:这条差距比过去小了很多——Node 24 的内置运行器已经带了
mock.fn/mock.method、定时器 mock(t.mock.timers)与快照(t.assert.snapshot),连模块级 mock 也有了(t.mock.module,需加--experimental-test-module-mocks)。vitest 仍胜在 inline snapshot、浏览器侧的 mock 与整条生态工具链。 - 反过来,写 CLI 工具、纯函数库、后端服务的单元测试,内置的就够了,还省掉一整棵依赖树。
这张卡讲的是「怎么把测试跑起来」。「怎么写」——用例组织与钩子、断言选型、异步为什么是假通过的重灾区、mock 与假定时器、TS 的类型怎么测——见 32 章「测试:从怎么跑到怎么写」。
// sum.test.mjs —— 零依赖,node --test 直接跑
import { test, describe } from "node:test";
import assert from "node:assert/strict"; // 注意 /strict
const sum = (a, b) => a + b;
describe("sum", () => {
test("加起来", () => {
assert.equal(sum(1, 2), 3);
});
});
// 命令行
// node --test 跑全部
// node --test --watch 改了就重跑
// node --test --experimental-test-coverage 带覆盖率
// node --test sum.test.mjs 只跑这一个node --test 会一个用例都不跑却正常退出,CI 上看起来一片通过——一个只放 *.spec.mjs 的目录跑 node --test,输出 # tests 0 而退出码是 0。养成看汇总行 # tests N 的习惯,数字对不上就是发现规则的问题。同理,用 assert 而不是 assert/strict 时,assert.equal("1", 1) 会通过——这条断言等于没写。node:test 的 test / describe / assert 三件套和 vitest 几乎同形,将来真要迁移,改的是导入语句而不是用例本身。把 "test": "node --test" 写进 package.json 的 scripts(见本章前面那张卡),门槛就归零了。Web Components:不依赖框架的组件
浏览器原生的组件方案:自定义元素、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 边界 */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)
| 方案 | min | gzip |
|---|---|---|
| 纯原生自定义元素(一个计数器) | 0.3 KB | 0.2 KB |
| Lit 3.3.3(同一个计数器) | 15.3 KB | 5.9 KB |
| Shoelace 2.20.1:只引一个 Button | 62.2 KB | 18.2 KB |
| Shoelace:六个常用组件 | 163.3 KB | 43.4 KB |
| Shoelace:整包引入 | 458.7 KB | 105.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");<my-list items="[1,2,3]"> 传进去的是一个字符串,不是数组。要传对象/数组/函数,只能走 property(el.items = [1,2,3])或框架里的对应语法。症状是「传进去的数据变成了 [object Object]」——看到这个字符串就知道走错了通道。「任何框架都能用」在语法层面成立,在细节上有三处摩擦。知道这三处,接入成本就从「踩一周坑」变成「查一次文档」。
① 传数据:属性还是特性
- Vue:对自定义元素会自动判断——如果元素实例上存在同名属性就走 property,否则走 attribute。所以
:items="arr"通常直接能用; - React 19 起也支持了:会优先设置 property(这修复了 React 长期以来「什么都当字符串塞进 attribute」的问题)。但老版本 React(18 及以前)不会,只能用 ref 手动赋值——网上大量教程写的是这个旧方案;
- Svelte / Solid / Angular 各有各的语法(
prop:前缀之类),用之前查一下对应文档的「custom elements」一节。
② 收事件:自定义事件不走框架的合成系统
- React 的
onFoo只对它认识的事件名有效,自定义事件(my-change)必须用addEventListener手动监听(React 19 起对自定义元素的事件支持有所改善,但跨版本仍需确认); - Vue 里
@my-change可以直接用; - 命名建议全小写加连字符(
value-change)——大小写在 HTML 属性里会被规范化,混用会导致某些框架监听不到。
③ 服务端渲染:Shadow DOM 天生是客户端的
- 自定义元素的升级发生在浏览器里,服务端渲染出来的只是一个空标签——首屏会看到布局塌陷或闪一下;
- 规范给了答案:声明式 Shadow DOM(
<template shadowrootmode="open">),让服务端能直接吐出 shadow 内容,浏览器解析时自动附加; - Lit 提供了配套的 SSR 方案,但生态整体不如框架自带的 SSR 成熟。判据:如果项目对首屏与 SEO 敏感,Web Components 要慎重,或只用在非首屏区域。
// ① 传复杂数据:属性只能是字符串,对象要走 property
<my-list items='[1,2,3]'></my-list> // ❌ 传进去的是字符串
el.items = [1, 2, 3]; // ✅ property
// React 18 及以前的做法(很多教程停在这里)
const ref = useRef(null);
useEffect(() => { ref.current.items = arr; }, [arr]);
return <my-list ref={ref} />;
// ② 自定义事件:React 里要手动监听
useEffect(() => {
const el = ref.current;
const h = (e) => setValue(e.detail);
el.addEventListener("value-change", h);
return () => el.removeEventListener("value-change", h);
}, []);
<!-- ③ 声明式 Shadow DOM:让服务端也能吐出 shadow 内容 -->
<my-card>
<template shadowrootmode="open">
<style>:host { display: block; }</style>
<slot></slot>
</template>
<p>这段内容属于外部作用域</p>
</my-card><my-input> 里的值不会出现在 FormData 里,回车也不触发提交、原生校验也覆盖不到它。规范给的解法是表单关联自定义元素(static formAssociated = true + ElementInternals 的 setFormValue()),但很多社区组件没有实现。选组件库时如果要用在表单里,先做一个最小验证:放进 <form> 里提交一次,看 FormData 有没有那个字段。HTMLElementTagNameMap,document.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(名字, 回调)是一条用例;it是test的别名,写it("应该返回 3", ...)只是让句子读起来通顺,功能完全一样;describe(名字, 回调)把相关用例分成一组,输出里会缩进成树;组可以嵌套;- 用例名要写成一句话的结论——「空数组时返回 0」远胜「测试 sum」。测试失败时你先看到的是这行名字,它应该直接告诉你坏了什么。
四个钩子,以及它们各跑几次
before/after:整组只跑一次,用于开销大且可共享的准备(起一个测试数据库、建一个临时目录);beforeEach/afterEach:每条用例前后各跑一次,用于把状态复位——这是保证用例之间互不影响的关键;- 两条用例的一组,输出顺序是:
before→beforeEach→ 用例1 →afterEach→beforeEach→ 用例2 →afterEach→after; - 钩子写在
describe里只作用于该组,写在文件顶层作用于整个文件。
筛选:skip / todo / only,第三个有坑
test.skip:跳过不跑,汇总里进skipped,输出标# SKIP;test.todo:占位「还没写」,输出是个 ✔ 但不计入 pass,而是单独计入todo——用它标记待补的用例,比注释掉强;test.only:必须配合命令行--test-only才生效。不加这个标志时,only的和非only的全都照跑(tests 2),而且不报错、不警告;加上之后才只跑一条(tests 1)。以为在单跑一条、其实全跑了,是调试时最费解的一种「怎么还是这个错」。
并发:同文件内默认串行
- 三条各
sleep(100)的用例:默认串行,共 330 ms;给describe传{ concurrency: true }后并发,共 107 ms; - 但不同测试文件之间 Node 默认就是并行跑的——所以两个测试文件抢同一个端口、同一个临时目录或同一张表,会得到「单独跑都过、一起跑就挂」的经典幻觉。共享外部资源时给每个文件分配独立的名字。
import { test, describe, before, beforeEach, after, afterEach } from "node:test";
import assert from "node:assert/strict";
describe("购物车", () => {
let cart;
before(() => 连接测试数据库()); // 整组一次
beforeEach(() => { cart = []; }); // 每条用例前复位状态 ← 关键
afterEach(() => 清空表()); // 每条用例后
after(() => 关闭连接()); // 整组一次
test("空车总价为 0", () => { // 用例名写成结论,不是"测试总价"
assert.equal(total(cart), 0);
});
test("两件相加", () => {
cart.push({ price: 10 }, { price: 5 }); // 准备
const sum = total(cart); // 执行
assert.equal(sum, 15); // 断言
});
});
// —— 筛选 ——
test.skip("暂时跳过", () => {}); // 进 skipped,输出标 # SKIP
test.todo("还没写"); // 输出是 ✔,但计入 todo 不计 pass
test.only("只跑这条", () => {}); // ⚠ 必须 node --test --test-only
// —— 并发:默认串行 330ms → 并发 107ms(三条各 sleep 100ms)——
describe("慢用例", { concurrency: true }, () => { /* ... */ });const cart = [] 提到 describe 外面,第一条用例往里 push 的东西会留给第二条——顺序一换结果就变,而测试框架不保证你能永远靠顺序吃饭(加了 concurrency 更是直接乱套)。状态一律在 beforeEach 里重新构造。另一个:
test.only 不加 --test-only 静默失效,非 only 的用例照跑且毫无提示。把 "test:only": "node --test --test-only" 写进 scripts,或者干脆用 node --test 文件名 只跑一个文件。beforeEach 里做复位,而不是 afterEach。两者看起来对称,实际不是:某条用例中途抛错时,afterEach 虽然仍会执行,但如果它自己依赖那条用例没跑完的状态就会连环失败;而 beforeEach 是「无论上一条留下什么烂摊子,我先把桌面擦干净」,天然更稳。afterEach 留给必须释放的外部资源(关连接、删临时文件)。断言写错的代价是它根本没在验你以为的那件事——不报错、显示全绿。node:assert/strict 一共就几个方法,把选择规则记死即可。
第一条规则:比对象和数组只能用 deep 版
assert.equal(实际, 期望)用的是===——对对象、数组、Map、Set 一律是引用比较。assert.equal([1, 2], [1, 2])失败,Node 还专门给了句提示:Values have same structure but are not reference-equal(结构一样但不是同一个引用);- 要比内容用
assert.deepStrictEqual(实际, 期望)——[1, 2]与[1, 2]通过,两个内容相同的new Map([["k", 1]])也通过; - 记法:基本类型用
equal,其余一律deepStrictEqual。
第二条:/strict 那个子路径不能省
- 从
node:assert导入时,equal是==宽松相等——assert.equal("1", 1)通过;从node:assert/strict导入才是===,同一句正常失败; deepEqual同理:宽松版忽略原型,{ a: 1 }和一个同结构的类实例比通过;deepStrictEqual则失败——它要求原型也相同。「返回的是User实例而不是普通对象」这类回归,只有 strict 版拦得住。
第三条:deepStrictEqual 是第五套相等语义
- 18 章讲过 JS 的四套相等(
==/===/Object.is/ SameValueZero)。deepStrictEqual对基本类型用的不是===,而是Object.is那一套:deepStrictEqual(NaN, NaN)通过(===是 false)、deepStrictEqual(0, -0)失败(===是 true); - 还有一条最容易查半天的:值为
undefined的属性算差异。{ a: 1 }与{ a: 1, b: undefined }不相等——严格版宽松版都不相等。函数里写了obj.b = maybe()而maybe()返回 undefined,就会撞上它;想忽略这类差异,把对象先过一遍JSON.parse(JSON.stringify(x)),或改断言具体字段。
剩下几个:throws / rejects / match / snapshot
assert.throws(函数, 期望)断言同步抛错,第二个参数可以是构造器(TypeError)、正则或{ message: "..." };被测代码没抛时它自己失败,报Missing expected exception——注意传的是函数本身而不是调用结果,写成assert.throws(f())会先把错抛在断言外面;await assert.rejects(异步函数或 Promise, 期望)是它的异步版,见下一张卡;assert.match(字符串, 正则)比assert(re.test(s))好——失败时会打印实际字符串,后者只会告诉你「表达式为假」;- 快照:
t.assert.snapshot(值)(Node 22+ 内置)把结构化输出存进文件名.snapshot。首次运行必须带--test-update-snapshots,否则直接报ERR_INVALID_STATE: Cannot read snapshot file;生成后再跑就是内容比对。适合大块输出(渲染结果、序列化配置),不适合替代具体断言。
import assert from "node:assert/strict"; // ← /strict 不能省
// —— 对象 / 数组:equal 比的是引用 ——
assert.equal([1, 2], [1, 2]); // ❌ 失败:结构相同但不是同一引用
assert.deepStrictEqual([1, 2], [1, 2]); // ✅
// —— 宽松 vs 严格(导入路径决定)——
// node:assert → assert.equal("1", 1) 通过 ← 这条断言等于没写
// node:assert/strict → assert.equal("1", 1) 失败 ← 要的就是这个
// —— deepStrictEqual 用 Object.is 语义,不是 === ——
assert.deepStrictEqual(NaN, NaN); // ✅ 通过(NaN === NaN 是 false)
assert.deepStrictEqual(0, -0); // ❌ 失败(0 === -0 是 true)
// —— undefined 属性算差异,排查起来最费时 ——
assert.deepStrictEqual({ a: 1 }, { a: 1, b: undefined }); // ❌ 不相等
// —— 抛错:传函数,别传调用结果 ——
assert.throws(() => parse("坏数据"), TypeError); // ✅
assert.throws(parse("坏数据")); // ❌ 错抛在断言外面了
assert.match(id, /^user_\d+$/); // 失败时会打印实际字符串
// —— 快照:首跑必须 node --test --test-update-snapshots ——
test("渲染结果", (t) => { t.assert.snapshot(render(data)); });try 块里而 catch 是空的——被测代码抛错时断言被跳过,catch 把异常吃掉,用例显示通过。要断言「不该抛错」就什么都不写(抛了自然会让用例失败),要断言「应该抛错」就用 assert.throws,两者都不需要手写 try/catch。assert.equal(实际, 期望)。写反了测试照样能过(相等就是相等),但失败信息会把两边说反——本来该说「期望 3、实际得到 5」,变成「期望 5、实际得到 3」,排查时先怀疑错地方。另外
assert(表达式) 这种真值断言尽量少用:它失败时只会说 The expression evaluated to a falsy value,不告诉你实际值是多少。能用 equal / deepStrictEqual / match 就别用它。同步测试要么过要么挂,很难骗人;异步测试则可以在断言根本没执行的情况下显示通过。这张卡把三种「少写一个 await」的后果一遍——它们的共同点是失败信息不出现在失败的那条用例上。
三种合法的等待方式
- async 回调:
test("...", async () => { ... })——最常用,里面照常await; - 返回 Promise:回调不写
async但return一个 Promise,运行器同样会等(一条返回new Promise(r => setTimeout(r, 50))的用例耗时 62 ms,确实等满了); - done 回调:回调写成
(t, done) => { ... },用于包不了 Promise 的老式回调 API,完成时调done(),出错时done(err)。忘了调 done 就会一直挂到超时——给{ timeout: 300 }的用例在 307 ms 时失败。这至少是个响亮的失败,不会假通过。
最坑的一种:忘了 await 断言
assert.rejects返回的是 Promise。写成assert.rejects(fn)不 await,断言的成败就跟这条用例脱钩了;- 让它去断言一个根本不会 reject 的函数(本该失败):那条用例显示 ✔ 通过,汇总里也算
pass;真正的失败晚一步才浮出来,报在文件层级:Test "..." generated asynchronous activity after the test ended. This activity created the error "AssertionError: Missing expected rejection." and would have caused the test to fail, but instead triggered an unhandledRejection event; - 也就是说你会看到「所有用例都 ✔,但文件 ✖、CI 红了」这种自相矛盾的输出。看到「generated asynchronous activity after the test ended」就一个思路:某个 await 漏了;
- 同类漏法还有:
await忘在被测函数前(断言拿到的是 Promise 对象而不是值,deepStrictEqual报你一个Promise { ... })、以及forEach里写await(04 章讲过,回调根本不被等)。
断言「应该失败」:用 rejects,别手写 try/catch
await assert.rejects(异步函数或 Promise, 期望)——期望可以是构造器、正则或{ message: "..." },await assert.rejects(boom, { message: "炸了" })正常通过;- 对应的「不该抛」不需要写任何断言:真抛了用例自然就红了;
- 手写
try { await f(); } catch { /* 期望走这里 */ }的问题是:f()没抛时 catch 不执行,用例照样通过——这条用例什么都没验证。非要手写就在try末尾补一句assert.fail("本该抛错")。
import { test } from "node:test";
import assert from "node:assert/strict";
// ✅ async 回调
test("取到用户", async () => {
const u = await fetchUser(1);
assert.deepStrictEqual(u, { id: 1, name: "Tom" });
});
// ❌ 漏了 await:断言与用例脱钩
test("坏数据应当抛错", () => {
assert.rejects(parse("坏数据")); // 用例显示 ✔,文件却 ✖
}); // → generated asynchronous activity
// after the test ended
// ✅ 补上 await 与 async
test("坏数据应当抛错", async () => {
await assert.rejects(() => parse("坏数据"), { message: "坏数据" });
});
// ❌ 手写 try/catch:没抛时 catch 不执行,用例照过
test("手写版的坑", async () => {
try {
await parse("坏数据");
assert.fail("本该抛错"); // ← 少了这句,没抛错也算通过
} catch (e) {
assert.match(e.message, /坏数据/);
}
});
// 老式回调 API:done;忘了调就挂到超时(timeout:300 → 307ms 失败)
test("回调风格", { timeout: 300 }, (t, done) => {
readOld("a.txt", (err, data) => {
assert.equal(data, "hi");
done(err); // 出错就把 err 交给它
});
});setTimeout 等异步结果。「等 100 ms 应该就好了」在本机成立,在 CI 上不一定——机器一慢就随机红,机器一快又白等,这就是所谓的不稳定测试(flaky test),比失败更消耗团队信任。要么等一个明确的信号(Promise、事件、轮询到条件成立),要么用下一张卡的假定时器把时间直接推过去。真 sleep 只应该出现在「就是要测超时行为」的用例里。assert.rejects / assert.doesNotReject 前面有没有 await,以及回调是不是漏写了 async。ESLint 的
require-await 和 TypeScript 的 no-floating-promises(@typescript-eslint)能在写的时候就把这类漏网 Promise 标出来,比事后读输出便宜得多。两件事测起来最别扭:「有没有调到」(发了邮件没?写库了没?)和 「过一段时间才发生」(防抖、重试、超时)。前者用 mock,后者用假定时器——都在 node:test 里内置,不用装包。
mock.fn:一个能自查的假函数
const fn = mock.fn()造一个假函数,也可以传实现mock.fn((a, b) => a + b);- 调用记录挂在
fn.mock上,调两次后:fn.mock.callCount()得 2,fn.mock.calls[1].arguments得[3, 4],fn.mock.calls[1].result得 7; - 于是「回调被调了几次、第几次收到什么参数」都能直接断言——这正是测事件、测钩子、测订阅时最需要的。
mock.method:替换真实方法,但还原规则有个坑
t.mock.method(对象, "方法名", 假实现)把对象上的方法换掉,被换后的方法自身也带.mock,可以继续查调用次数;t.mock(用例参数上的)在用例结束时自动还原,顶层导入的mock不会。同一个共享对象:用t.mock.method替换,下一条用例读到的是真实现;改用顶层mock.method替换,下一条用例读到的还是假的——污染就这么泄漏出去了,直到手动调mock.restoreAll()才恢复;- 结论:一律用
t.mock(回调签名写成(t) => { ... })。真要用顶层mock,就配一个afterEach(() => mock.restoreAll())。
假定时器:时间由你推(测防抖)
t.mock.timers.enable({ apis: ["setTimeout"] })之后,setTimeout不再真的等待,只有你调tick(毫秒)时时间才前进;- 拿 14 章那个防抖函数(延迟 1000 ms):连点三次后立刻查,回调 0 次;
tick(999)后仍是 0 次;再tick(1)变成 1 次,且拿到的实参是"c"——「多次触发只执行最后一次」这个语义被完整地验了出来,整条用例耗时是 0 ms; apis里还能放"setInterval"、"Date"。enable({ apis: ["Date"], now: 0 })后Date.now()返回 0,tick(5000)后返回 5000——凡是「结果里带时间戳」的快照测试,把 Date 冻住就不会每次都不一样了。
import { test, mock } from "node:test";
import assert from "node:assert/strict";
// —— mock.fn:断言"有没有被调、收到了什么" ——
test("提交时会通知一次", () => {
const onSave = mock.fn();
submit({ name: "Tom" }, onSave);
assert.equal(onSave.mock.callCount(), 1);
assert.deepStrictEqual(onSave.mock.calls[0].arguments, [{ name: "Tom" }]);
});
// —— t.mock.method:用例结束自动还原 ——
test("不真的写库", (t) => { // ← 注意接住 t
t.mock.method(db, "save", () => "ok");
handle();
assert.equal(db.save.mock.callCount(), 1);
}); // 出了这条用例,db.save 自动变回真的
// ❌ 顶层 mock 不会自动还原,泄漏给后面所有用例
// mock.method(db, "save", ...) → 需要 afterEach(() => mock.restoreAll())
// —— 假定时器:测防抖,用例耗时 0ms ——
test("防抖只执行最后一次", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const spy = mock.fn();
const d = debounce(spy, 1000);
d("a"); d("b"); d("c");
assert.equal(spy.mock.callCount(), 0); // 连点三次,还没到点
t.mock.timers.tick(999);
assert.equal(spy.mock.callCount(), 0); // 差 1ms,仍未触发
t.mock.timers.tick(1);
assert.equal(spy.mock.callCount(), 1); // 到点,只执行一次
assert.deepStrictEqual(spy.mock.calls[0].arguments, ["c"]); // 用的是最后一次的参数
});
// —— 冻住时间:快照里带时间戳时必备 ——
// t.mock.timers.enable({ apis: ["Date"], now: 0 });
// Date.now() → 0 ;tick(5000) 后 → 5000await new Promise(r => setTimeout(r, 10)) 会永远等下去——没人去 tick,那个定时器就永不触发,用例挂到超时才失败,而报错信息只说超时,完全不提是假定时器造成的。所以:只把真正需要的 api 放进 enable(要测 setTimeout 就别顺手把 Date 也冻上),并且在同一条用例里就把时间推完。TS 项目里有一条最容易漏的防线:测试跑绿不代表类型是对的。因为运行测试的那一步把类型注解直接删掉了,两者根本不在同一个环节。这张卡讲清楚该在哪一步拦什么,以及类型断言怎么写成能自动检查的测试。
先认清:node --test 跑 .ts,是剥离不是检查
- Node 22.18+ 能直接跑
.ts,测试文件也一样——30 章那张卡过e.test.ts被正常发现并跑通; - 但它只删注解,不做任何类型检查。一个测试文件里写
const n: number = "这是字符串",node --test照常 ✔ 通过、fail 0——类型错得再明显也拦不住; - 所以 CI 里必须是两步:
tsc --noEmit管类型、node --test管行为。少了前一步,「测试全绿」给的是虚假的安全感(详见 29 章「工程实战」); - 顺带:需要生成运行时代码的语法(
enum、装饰器、参数属性)在剥离模式下直接报ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX,测试文件同样受这条限制。
@ts-expect-error:一条会自己失效的类型断言
- 它的特别之处在于双向:下一行有类型错误 → 静默吃掉;下一行没有类型错误 → 它自己反过来报错。TS 7.0.2 下,给一个本来合法的调用加上这行注释,
tsc --noEmit报error TS2578: Unused '@ts-expect-error' directive; - 这正是它能当断言用的原因:「这样写应该编译不过」也成了一条会被持续验证的测试。哪天你把类型放宽了、这个错误消失了,
tsc会立刻告诉你——而@ts-ignore没有这个反向检查,纯粹是闭嘴,所以任何时候都优先用@ts-expect-error; - 惯例是给它写上原因:
// @ts-expect-error 传数字应当被拒绝——这行注释就是用例名。
要测「类型推导出来是什么」,得靠工具
@ts-expect-error只能测「该不该报错」,测不了「推导结果是不是恰好等于某个类型」。想断言后者,一种零依赖写法是造一个「相等则通过」的类型工具,让不匹配变成编译错误(见代码);- 生态方案是 vitest 的
expectTypeOf(配vitest --typecheck),写法和普通断言同形,适合类型体操较多的库; - 判断标准:应用代码基本只需要
tsc --noEmit+ 少量@ts-expect-error;发布给别人用的库,公开 API 的类型才值得成套地测——因为对使用者来说,类型签名就是 API 本身。
顺带:模块级 mock 现在内置了(实验特性,)
- 30 章那张卡说「模块级 mock 是换 vitest 的理由之一」,Node 24 已经补上了:
t.mock.module("./dep.mjs", { exports: { ... } }); - 必须带命令行标志
--experimental-test-module-mocks,不带时直接TypeError: t.mock.module is not a function;带上后再await import()拿到的就是替身(返回值从 100 变成 999); - 它还是实验特性、会打
ExperimentalWarning,选型时按「实验」对待——但用来替掉网络层、时间源这类边界依赖已经完全可用。
// —— 1. 类型错误逃不过 tsc,却逃得过 node --test ——
const n: number = "这是字符串"; // node --test:✔ 通过;tsc --noEmit:报错
// —— 2. @ts-expect-error 当断言用 ——
function greet(name: string): string { return "hi " + name; }
// @ts-expect-error 传数字应当被拒绝
greet(123); // ✅ 确实有错 → 注释吃掉它,tsc 通过
// @ts-expect-error 这行其实完全合法
greet("ok"); // ❌ 期望落空 → error TS2578:
// Unused '@ts-expect-error' directive
// —— 3. 零依赖地断言"推导结果恰好是这个类型" ——
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends
(<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T; // 不是 true 就编译不过
type _1 = Expect<Equal<ReturnType<typeof greet>, string>>; // ✅
// type _2 = Expect<Equal<ReturnType<typeof greet>, number>>; ← 这行会报错
// —— 4. 模块级 mock(需 --experimental-test-module-mocks)——
test("不打真实接口", async (t) => {
t.mock.module("./price.mjs", { exports: { fetchPrice: () => 999 } });
const { fetchPrice } = await import("./price.mjs");
assert.equal(fetchPrice(), 999); // 真实现返回 100
});
# CI 必须两步,缺一不可
# "scripts": { "check": "tsc --noEmit && node --test" }@ts-ignore 是个只进不出的黑洞。它压制下一行的所有类型错误,而且错误消失后它自己毫无反应——于是代码库里会慢慢堆积一批「当年为了绕过某个问题加的、如今早就没必要」的 ignore,谁也不敢删。@ts-expect-error 天生自带回收机制:问题一修好,它就变成 TS2578 逼你删掉它。存量的 @ts-ignore 可以直接全局替换成 @ts-expect-error 再跑一遍 tsc,报出来的每一条 TS2578 都是一处可以当场清理的历史包袱。tsc --noEmit 也当成一条测试看待,它抓的是另一类完全不同的 bug:测试验的是「这段代码在我想到的输入下表现正确」,类型验的是「这段代码在所有输入下不会被这样误用」。两者互补,谁也替不了谁——所以 package.json 里那条给 CI 用的脚本应该是 tsc --noEmit && node --test,用 && 串起来,前一步红了就不必往下跑。从这里到精通:路线图
地图铺完了,剩下的路要亲手写出来。最后这一章给出收尾路线:难度递进的动手项目、按阶段的资料,以及 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 写透,再学框架才事半功倍。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 官方博客的版本发布说明,每个新版本的特性都值得扫一遍。
any 和 as 把类型系统架空,是最普遍的自欺。一句 as any 能让红线立刻消失,但也把这一处的类型安全彻底关掉——迁移项目时最该警惕的就是「为了不报错而断言」,那等于花力气装了 TS 又亲手拆掉。反方向还有个坑是类型体操上瘾:type-challenges 刷多了,就忍不住在业务代码里堆四层条件类型加
infer 炫技,结果同事(和三个月后的自己)读不懂、改不动。类型是为可读性与安全服务的,不是为了证明你会写——能用简单类型表达的就别上体操;刷题时也别急着翻题解,抄一遍答案和自己推导出来,收获天差地别。