From 2148ad8427588b9aa1ef44c0333f9bd740c8cfbe Mon Sep 17 00:00:00 2001 From: WEIREN FENG Date: Fri, 9 Oct 2026 16:07:42 -0700 Subject: [PATCH 1/2] Remove the stray spaces that line breaks put into the Chinese copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSX trims each line of a text block and joins the lines with one space. The Chinese copy is wrapped across source lines throughout the course, so it rendered with spaces between Chinese characters and around full-width punctuation: 1,572 of them in 36 files. - 1,548 line breaks are joined by a script that walks the TypeScript syntax tree and touches only JSX text and JSX attribute strings, never code samples, template literals or comments. Breaks next to Latin letters, digits, expressions, "——" or operators keep their space. - 24 more spaces go: 20 after full-width punctuation, 3 between Chinese text and a tag whose text is Chinese, and one {" "} between a full-width mark and Chinese text. - test/zh-copy-spacing.test.ts, the check DataData and AlgoAlgo use, scans every TSX file and fails on any new stray space; CLAUDE.md states the rule. Apart from the one {" "}, the diff changes nothing but whitespace. The text every edited file renders differs from master only by deleted spaces, each next to Chinese text, so the English copy is unchanged. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 + app/classes/page.tsx | 148 ++++++------------ app/classes/viz.tsx | 26 +--- app/functions/page.tsx | 206 +++++++------------------ app/functions/viz.tsx | 43 ++---- app/generics/page.tsx | 98 ++++-------- app/generics/viz.tsx | 28 ++-- app/home-viz.tsx | 12 +- app/mindset/page.tsx | 250 +++++++++--------------------- app/mindset/viz.tsx | 45 ++---- app/modules/page.tsx | 253 +++++++++---------------------- app/modules/viz.tsx | 33 ++-- app/narrowing/page.tsx | 147 ++++++------------ app/narrowing/viz.tsx | 48 ++---- app/page.tsx | 72 +++------ app/structural/page.tsx | 163 ++++++-------------- app/structural/viz.tsx | 23 +-- app/tsconfig/page.tsx | 284 ++++++++++------------------------- app/tsconfig/viz.tsx | 39 ++--- app/type-magic/page.tsx | 117 +++++---------- app/type-magic/viz.tsx | 23 ++- app/types/page.tsx | 151 ++++++------------- app/types/viz.tsx | 44 ++---- app/utility/page.tsx | 131 +++++----------- app/utility/viz.tsx | 27 ++-- lib/classes-data.tsx | 126 +++++----------- lib/functions-data.tsx | 172 +++++++-------------- lib/generics-data.tsx | 85 ++++------- lib/home-data.tsx | 48 ++---- lib/mindset-data.tsx | 182 +++++++--------------- lib/modules-data.tsx | 160 ++++++-------------- lib/narrowing-data.tsx | 160 ++++++-------------- lib/structural-data.tsx | 120 +++++---------- lib/tsconfig-data.tsx | 124 +++++---------- lib/type-magic-data.tsx | 89 ++++------- lib/types-data.tsx | 67 +++------ lib/utility-data.tsx | 128 +++++----------- test/zh-copy-spacing.test.ts | 155 +++++++++++++++++++ 38 files changed, 1319 insertions(+), 2710 deletions(-) create mode 100644 test/zh-copy-spacing.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 40635ca..9f710d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,8 @@ 或本地 tsc 跑一遍再抄 —— 全书反复要求读者去验证,自己更不能想当然; - 术语第一次出现时中文+英文双写(如「泛型(generics)」),之后可只用惯用形; - 别怕句子短。短句有力。 +- **中文正文在 JSX 里不要折行。** JSX 把跨行文本的换行连接成一个空格,中文字之间、全角标点前后就会多出空格。中文段落在源码里写成一行;中文与英文单词、数字、行内代码之间照常空一格,「——」与运算符两侧也保留空格,其余地方不留。 + `test/zh-copy-spacing.test.ts` 扫描全部 TSX 文件,发现多余的空格就报错。 ## 课程结构(12 页,由易到难) diff --git a/app/classes/page.tsx b/app/classes/page.tsx index 4a985cf..d0692d5 100644 --- a/app/classes/page.tsx +++ b/app/classes/page.tsx @@ -497,9 +497,7 @@ export default function ClassesPage() { zh: ( <> class 描述形状,也生产对象。TypeScript - 在它之上加了三级访问权限、一套「必须有这个成员」的约束, - 以及一次和 interface 的形状检查。 - 这些全部发生在编译期,而且几乎都不会留在产物里。 + 在它之上加了三级访问权限、一套「必须有这个成员」的约束,以及一次和 interface 的形状检查。这些全部发生在编译期,而且几乎都不会留在产物里。 ), }} @@ -568,8 +566,7 @@ export default function ClassesPage() { zh={ <> 在类里写下 name: string,等于告诉编译器: - 这个类造出来的每个对象都有 name,而且是 string。 - 于是别处的代码可以直接写{" "} + 这个类造出来的每个对象都有 name,而且是 string。于是别处的代码可以直接写{" "} shop.name.toUpperCase(),不必先判断{" "} undefined。 @@ -650,8 +647,7 @@ export default function ClassesPage() { <> 把赋值挪到别的方法里。构造器里调{" "} this.init()、由 init 给字段赋值 —— - 照样报 ts(2564)。编译器不跨方法追踪赋值, - 因为它无法确定那个方法一定会被调用。要么就在构造器里赋值。 + 照样报 ts(2564)。编译器不跨方法追踪赋值,因为它无法确定那个方法一定会被调用。要么就在构造器里赋值。 } /> @@ -676,11 +672,8 @@ export default function ClassesPage() { 太早搬出 !。 - boss!: string 是明确赋值断言: - 它把这份保证从编译器手里转到你手里。检查不再报错, - 但也没有任何验证。真是 undefined{" "} - 的话,出问题的时刻挪到了运行时。 - 只在「类外有东西确定会赋值」时用它,比如由框架注入的字段。 + boss!: string 是明确赋值断言:它把这份保证从编译器手里转到你手里。检查不再报错,但也没有任何验证。真是 undefined{" "} + 的话,出问题的时刻挪到了运行时。只在「类外有东西确定会赋值」时用它,比如由框架注入的字段。 } /> @@ -737,9 +730,7 @@ export default function ClassesPage() { } zh={ <> - 方法没有新规则。方法就是写在类里的函数, - 参数和返回值的标注方式和第 02 章完全一样。 - 真正的新内容从下一节开始:规定哪些代码可以读某个成员。 + 方法没有新规则。方法就是写在类里的函数,参数和返回值的标注方式和第 02 章完全一样。真正的新内容从下一节开始:规定哪些代码可以读某个成员。 } /> @@ -772,8 +763,7 @@ export default function ClassesPage() { } zh={ <> - public 表示任何代码都能读这个成员。它是默认值, - 不写修饰符就等于写了 public。protected{" "} + public 表示任何代码都能读这个成员。它是默认值,不写修饰符就等于写了 public。protected{" "} 收紧到「本类,以及任何 extends 它的类」。private{" "} 再收紧一步,只剩类体自己。写成代码是这样: @@ -807,9 +797,7 @@ export default function ClassesPage() { } zh={ <> - 上面有两行是报错的。最快的确认办法,是让编译器自己说。 - 下面这个窗口里跑的是真的 tsc,就在你的浏览器里: - 把 protected 删掉,或者把 private{" "} + 上面有两行是报错的。最快的确认办法,是让编译器自己说。下面这个窗口里跑的是真的 tsc,就在你的浏览器里:把 protected 删掉,或者把 private{" "} 改成 public,对应的那条报错会自己消失。 } @@ -853,8 +841,7 @@ export default function ClassesPage() { 标题栏上还挂着另外两份稿子。只声明不赋值 那一份里有两个字段,一个都没给值 —— 这正是 §01 那道检查,ts(2564)。把代码上方的{" "} - strictPropertyInitialization 关掉, - 两条报错一起消失:代码一个字没变,变的是一个编译选项。 + strictPropertyInitialization 关掉,两条报错一起消失:代码一个字没变,变的是一个编译选项。 } /> @@ -909,8 +896,7 @@ export default function ClassesPage() { zh={ <> 有一个后果很容易忽略:子类自己的字段初始化,是在{" "} - super() 返回之后才执行的。 - 所以父类构造器如果去读一个由子类初始化的字段,读到的是{" "} + super() 返回之后才执行的。所以父类构造器如果去读一个由子类初始化的字段,读到的是{" "} undefined。父类构造器需要的东西,应该以参数形式传给它。 } @@ -946,11 +932,9 @@ export default function ClassesPage() { } zh={ <> - 第 01 章说过,TypeScript 编译时会把类型全部擦除。 - 访问修饰符是类型系统的一部分,所以同样会被擦掉: + 第 01 章说过,TypeScript 编译时会把类型全部擦除。访问修饰符是类型系统的一部分,所以同样会被擦掉: private vaultCode{" "} - 在产物里就是一个普通属性,谁都读得到。 - JavaScript 自己也有私有字段,写作 # 开头 —— + 在产物里就是一个普通属性,谁都读得到。JavaScript 自己也有私有字段,写作 # 开头 —— 那是另一套机制。两边对照着看: } @@ -998,9 +982,7 @@ export default function ClassesPage() { <> 注意左边第 7 行。s["vaultCode"]{" "} 完全不报错,连断言都不用写:TypeScript - 有意允许用方括号访问私有成员,给测试和老代码留一条路。 - 而一个普通 JavaScript 文件引入这个类时更是毫无察觉, - 直接 s.vaultCode 就读到了。 + 有意允许用方括号访问私有成员,给测试和老代码留一条路。而一个普通 JavaScript 文件引入这个类时更是毫无察觉,直接 s.vaultCode 就读到了。 } /> @@ -1022,10 +1004,8 @@ export default function ClassesPage() { } zh={ <> - 上面那张对照是事先摆好的,下面这个不是:一个类,两种私有字段, - 底下是真编译器。切到编译产物 JS 读一遍类体 ——{" "} - private 不在里面,那个词只是给类型检查器看的; - 而 #realCode 还在,和你写下的一模一样。 + 上面那张对照是事先摆好的,下面这个不是:一个类,两种私有字段,底下是真编译器。切到编译产物 JS 读一遍类体 ——{" "} + private 不在里面,那个词只是给类型检查器看的;而 #realCode 还在,和你写下的一模一样。 } /> @@ -1068,10 +1048,8 @@ export default function ClassesPage() { 有两件事值得试。把 target 调到{" "} es2015 —— 那个版本的 JavaScript 还没有{" "} # 字段:编译器会把 #realCode{" "} - 改写成一个以实例为键的 WeakMap, - 好让它在那里也照样读不到。private{" "} - 不需要这套机械,因为它在运行时本来就没许诺过什么。 - 然后换上从类外读一读,对比那两条报错:ts(2341) + 改写成一个以实例为键的 WeakMap,好让它在那里也照样读不到。private{" "} + 不需要这套机械,因为它在运行时本来就没许诺过什么。然后换上从类外读一读,对比那两条报错:ts(2341) 来自类型系统,ts(18013) 来自 JavaScript 这门语言。 } @@ -1098,8 +1076,7 @@ export default function ClassesPage() { zh={ <> 没有运行时开销,报错清楚,而且会参与类型兼容判定(§06 - 讲的就是它)。用它来标记哪些成员属于内部实现, - 让改代码的人不会顺手伸进去。封装的大部分需求,到这里就够了。 + 讲的就是它)。用它来标记哪些成员属于内部实现,让改代码的人不会顺手伸进去。封装的大部分需求,到这里就够了。 } /> @@ -1123,10 +1100,7 @@ export default function ClassesPage() { } zh={ <> - 这是 JavaScript 的语言特性,不是 TypeScript 的。 - 它在编译后依然存在,程序运行时类外也确实读不到。 - TypeScript 对它照样做完整的类型检查。 - 当「你控制不了的代码绝对不能碰到这个字段」时用它 —— + 这是 JavaScript 的语言特性,不是 TypeScript 的。它在编译后依然存在,程序运行时类外也确实读不到。TypeScript 对它照样做完整的类型检查。当「你控制不了的代码绝对不能碰到这个字段」时用它 —— 主要是写库的场景。 } @@ -1161,9 +1135,7 @@ export default function ClassesPage() { 字段并不构成保护。一句 JSON.stringify(shop){" "} 就打印出来了,浏览器 DevTools 一样看得见,任何序列化对象的日志也一样。 - #field 确实能挡住这几处, - 但值仍然是内存里的明文,如果是硬编码的,它照样在你的打包产物里。 - 真正的机密应该放在服务端,而不是任何一种字段里。 + #field 确实能挡住这几处,但值仍然是内存里的明文,如果是硬编码的,它照样在你的打包产物里。真正的机密应该放在服务端,而不是任何一种字段里。 } /> @@ -1195,9 +1167,7 @@ export default function ClassesPage() { } zh={ <> - 参数属性(parameter properties)省下的样板最多: - 在构造器参数前面加一个访问修饰符,这一行就同时完成了 - 声明字段、接收实参、赋值三件事: + 参数属性(parameter properties)省下的样板最多:在构造器参数前面加一个访问修饰符,这一行就同时完成了声明字段、接收实参、赋值三件事: } /> @@ -1253,14 +1223,11 @@ export default function ClassesPage() { } zh={ <> - 这是本章唯一一个在 JavaScript 里没有对应写法的特性。 - 这一章其他东西要么被擦除,要么本来就是 JavaScript; - 而参数属性会让编译器生成一句你源码里没写过的{" "} + 这是本章唯一一个在 JavaScript 里没有对应写法的特性。这一章其他东西要么被擦除,要么本来就是 JavaScript;而参数属性会让编译器生成一句你源码里没写过的{" "} this.db = db。Node 内置的 TypeScript 支持(22.18 起)只做类型剥离,所以会拒绝这种语法。打开{" "} erasableSyntaxOnly 后,编译器会以 ts(1294) - 提前提醒你。有正常构建步骤时尽管用; - 但如果要让 Node 直接跑 .ts 文件,就别用它。 + 提前提醒你。有正常构建步骤时尽管用;但如果要让 Node 直接跑 .ts 文件,就别用它。 } /> @@ -1309,12 +1276,9 @@ export default function ClassesPage() { zh={ <> readonly 和 private{" "} - 一样是编译期检查,而且管的范围比看上去窄: - 它禁止的是给这个属性赋值,不是禁止改属性指向的那个对象。 - 比如 readonly tags: string[], + 一样是编译期检查,而且管的范围比看上去窄:它禁止的是给这个属性赋值,不是禁止改属性指向的那个对象。比如 readonly tags: string[], o.tags = [] 会报错,但{" "} - o.tags.push("x") 完全合法。 - 想在运行时也拦住修改,那是 Object.freeze 的事。 + o.tags.push("x") 完全合法。想在运行时也拦住修改,那是 Object.freeze 的事。 } /> @@ -1345,8 +1309,7 @@ export default function ClassesPage() { zh={ <> 从 TypeScript 4.3 起,setter 接受的类型可以比 getter - 返回的更宽,只要 getter 的类型能赋给 setter 的类型。 - 当你想接受多种输入形式、却总是返回同一种时很好用: + 返回的更宽,只要 getter 的类型能赋给 setter 的类型。当你想接受多种输入形式、却总是返回同一种时很好用: set sugar(v: number | string) 配上{" "} get sugar(): number,调用方可以写{" "} o.sugar = "30",而读 o.sugar{" "} @@ -1384,9 +1347,7 @@ export default function ClassesPage() { } zh={ <> - abstract 类是一个没写完的类:一部分成员已经实现, - 标了 abstract 的那些只有签名。 - 它不能被实例化;子类必须把每一个抽象成员都实现掉,才能拿来用: + abstract 类是一个没写完的类:一部分成员已经实现,标了 abstract 的那些只有签名。它不能被实例化;子类必须把每一个抽象成员都实现掉,才能拿来用: } /> @@ -1418,8 +1379,7 @@ export default function ClassesPage() { <> implements 是一次检查,不是继承:interface 列出一个类必须有哪些成员,implements{" "} - 让编译器去核对这个类有没有。它不会给类添加任何东西。 - 收银台不关心背后接的是哪家支付服务,只要那个对象能{" "} + 让编译器去核对这个类有没有。它不会给类添加任何东西。收银台不关心背后接的是哪家支付服务,只要那个对象能{" "} pay、能 refund: } @@ -1451,8 +1411,7 @@ export default function ClassesPage() { zh={ <> interface 的价值体现在调用方:MilkTeaShop 接受任何 - 符合 PaymentProvider 的对象, - 所以换一家支付服务,店铺内部一行都不用改: + 符合 PaymentProvider 的对象,所以换一家支付服务,店铺内部一行都不用改: } /> @@ -1491,13 +1450,10 @@ export default function ClassesPage() { } zh={ <> - ① implements 不会改变类的类型: - 它不添加成员,也不替你标注任何东西。如果写成{" "} + ① implements 不会改变类的类型:它不添加成员,也不替你标注任何东西。如果写成{" "} pay(amount) {"{ … }"} 而不标类型, - amount 不会变成 number, - 而是隐式 any,在 strict 下直接报错 - ts(7006)。参数类型得自己写, - 编译器再拿你写的去和接口核对。 + amount 不会变成 number,而是隐式 any,在 strict 下直接报错 + ts(7006)。参数类型得自己写,编译器再拿你写的去和接口核对。 } /> @@ -1518,10 +1474,7 @@ export default function ClassesPage() { zh={ <> ② abstract class 和 interface 的区别。interface - 只描述形状,编译期就被擦除 —— 运行时这个名字根本不存在。 - abstract 类是一个真实的类:它在运行时存在, - 可以带着已实现的方法和状态,子类会继承这些。 - 想让子类继承可用的代码,用 abstract 类;只想描述形状,用 interface。 + 只描述形状,编译期就被擦除 —— 运行时这个名字根本不存在。abstract 类是一个真实的类:它在运行时存在,可以带着已实现的方法和状态,子类会继承这些。想让子类继承可用的代码,用 abstract 类;只想描述形状,用 interface。 } /> @@ -1581,9 +1534,7 @@ export default function ClassesPage() { } zh={ <> - 第 04 章说过,TypeScript 比较类型看的是形状,不是名字。 - class 也不例外:两个毫无关系的类,只要成员一样就可以互换。 - 但有一个例外,只要类里出现 private 或{" "} + 第 04 章说过,TypeScript 比较类型看的是形状,不是名字。class 也不例外:两个毫无关系的类,只要成员一样就可以互换。但有一个例外,只要类里出现 private 或{" "} protected 成员,它就出现了。逐帧看: } @@ -1612,8 +1563,7 @@ export default function ClassesPage() { } zh={ <> - 把两行 private stock 都删掉,赋值就通过了; - 或者保留它,把这个声明挪到两个杯子共同 extends + 把两行 private stock 都删掉,赋值就通过了;或者保留它,把这个声明挪到两个杯子共同 extends 的基类里 —— 这样声明只有一处,两个类型重新兼容。 } @@ -1642,11 +1592,8 @@ export default function ClassesPage() { } zh={ <> - 类型按形状比较,但只要涉及 private 或 protected 成员, - 两个类型必须让这些成员来自同一处声明才兼容 - (从共同基类继承下来的算)。 - 这也正是一些库模拟名义类型的办法:给类塞一个私有成员, - 形状相同的路人就再也顶替不了它。 + 类型按形状比较,但只要涉及 private 或 protected 成员,两个类型必须让这些成员来自同一处声明才兼容 + (从共同基类继承下来的算)。这也正是一些库模拟名义类型的办法:给类塞一个私有成员,形状相同的路人就再也顶替不了它。 } /> @@ -1694,10 +1641,8 @@ export default function ClassesPage() { ), zh: ( <> - 声明了的字段必须真的被赋值:就地初始化、在构造器里赋值, - 或者用 !{" "} - 自己承担。strictPropertyInitialization 负责这项检查, - 而且它不追踪写在其他方法里的赋值。 + 声明了的字段必须真的被赋值:就地初始化、在构造器里赋值,或者用 !{" "} + 自己承担。strictPropertyInitialization 负责这项检查,而且它不追踪写在其他方法里的赋值。 ), }, @@ -1729,10 +1674,8 @@ export default function ClassesPage() { ), zh: ( <> - private 会被擦除:编译产物里它就是个普通属性, - 连在 TypeScript 里 s["field"]{" "} - 都能直接读到,不用断言。要一个运行时也拿不到的字段, - 用 JavaScript 的 #field。 + private 会被擦除:编译产物里它就是个普通属性,连在 TypeScript 里 s["field"]{" "} + 都能直接读到,不用断言。要一个运行时也拿不到的字段,用 JavaScript 的 #field。 ), }, @@ -1747,8 +1690,7 @@ export default function ClassesPage() { ), zh: ( <> - 参数属性一行顶四行,但它是本章唯一会生成代码的特性。 - 只做类型剥离的运行时会拒绝它 —— 比如直接跑{" "} + 参数属性一行顶四行,但它是本章唯一会生成代码的特性。只做类型剥离的运行时会拒绝它 —— 比如直接跑{" "} .ts 文件的 Node。 ), @@ -1767,8 +1709,7 @@ export default function ClassesPage() { <> abstract{" "} 标记的是不能实例化的类,以及子类必须实现的成员。 - implements 只是拿类去和接口核对: - 既不添加成员,也不推断参数类型。一个类可以同时接受多个接口的检查。 + implements 只是拿类去和接口核对:既不添加成员,也不推断参数类型。一个类可以同时接受多个接口的检查。 ), }, @@ -1784,8 +1725,7 @@ export default function ClassesPage() { zh: ( <> class 也按形状比较,只有一个例外:一旦类里有{" "} - private 或 protected 成员, - 这些成员必须来自同一处声明,两个类型才兼容。 + private 或 protected 成员,这些成员必须来自同一处声明,两个类型才兼容。 ), }, diff --git a/app/classes/viz.tsx b/app/classes/viz.tsx index 041ea14..3f1bd0b 100644 --- a/app/classes/viz.tsx +++ b/app/classes/viz.tsx @@ -302,8 +302,7 @@ export function AccessGate() { <> 九种组合都点一遍。public 三处全通,protected 两通一拦,private 一通两拦。两条报错值得读细:protected 那条末尾多了{" "} - and its subclasses,private 那条没有。 - 两种检查都只发生在编译期。 + and its subclasses,private 那条没有。两种检查都只发生在编译期。 } /> @@ -353,10 +352,8 @@ const ERASE_TABS: EraseTab[] = [ } zh={ <> - private 这个词被整个擦掉了。编译产物里就是一个普通属性。 - 连源码里的 shop["vaultCode"] TypeScript - 都放行 —— 用方括号访问私有成员是它有意保留的后门,连断言都不用写。 - 运行时,JSON.stringify(shop)、DevTools + private 这个词被整个擦掉了。编译产物里就是一个普通属性。连源码里的 shop["vaultCode"] TypeScript + 都放行 —— 用方括号访问私有成员是它有意保留的后门,连断言都不用写。运行时,JSON.stringify(shop)、DevTools 和任何一个普通 JavaScript 文件都看得见这个值。 } @@ -395,8 +392,7 @@ const ERASE_TABS: EraseTab[] = [ #vaultCode 是 JavaScript 的语法,不是 TypeScript 的。 在较新的 target 下它原样输出,运行时类外确实拿不到。类外写{" "} shop.#vaultCode 是语法错误, - JSON.stringify(shop) 打印出来是 {"{}"}。 - target 较老时,编译器会改用 WeakMap 实现,行为一样。 + JSON.stringify(shop) 打印出来是 {"{}"}。target 较老时,编译器会改用 WeakMap 实现,行为一样。 } /> @@ -557,8 +553,7 @@ const CUP_FRAMES: CupFrame[] = [ zh={ <> 第 04 章的规则在 class 上照常生效:编译器比较的是形状,不是名字 - 。这两个类声明的成员完全一样,所以要 PaperCup 的地方, - 给一个 PlasticCup 也接受。 + 。这两个类声明的成员完全一样,所以要 PaperCup 的地方,给一个 PlasticCup 也接受。 } /> @@ -582,8 +577,7 @@ const CUP_FRAMES: CupFrame[] = [ } zh={ <> - 现在两个类各自声明了一个 private stock。名字一样、 - 类型一样,赋值依然失败: + 现在两个类各自声明了一个 private stock。名字一样、类型一样,赋值依然失败: Types have separate declarations of a private property 'stock'. @@ -615,9 +609,7 @@ const CUP_FRAMES: CupFrame[] = [ zh={ <> 为什么?对 private 和 protected 成员,TypeScript 比较的是 - 它们在哪里声明,而不是名字和类型。两处声明就是两个不同的成员。 - private 的用意是「这是某一个类的内部细节」,如果别的类能顶替, - 这个用意就落空了。这是 class 唯一一处按「出身」而不是按形状比较的地方。 + 它们在哪里声明,而不是名字和类型。两处声明就是两个不同的成员。private 的用意是「这是某一个类的内部细节」,如果别的类能顶替,这个用意就落空了。这是 class 唯一一处按「出身」而不是按形状比较的地方。 } /> @@ -644,9 +636,7 @@ const CUP_FRAMES: CupFrame[] = [ } zh={ <> - 解法顺着规则就出来了:让两个类 extends 同一个基类。 - 这时 private stock 只有一处声明,两边都继承自它, - 于是重新兼容。 + 解法顺着规则就出来了:让两个类 extends 同一个基类。这时 private stock 只有一处声明,两边都继承自它,于是重新兼容。 } /> diff --git a/app/functions/page.tsx b/app/functions/page.tsx index aa93f65..64a9327 100644 --- a/app/functions/page.tsx +++ b/app/functions/page.tsx @@ -837,9 +837,7 @@ export default function FunctionsPage() { ), zh: ( <> - 一行签名就记下了每个参数的形状和返回值的形状。 - 写的人不用另作解释,调的人不用猜, - 而编译器会盯着双方各自守约。 + 一行签名就记下了每个参数的形状和返回值的形状。写的人不用另作解释,调的人不用猜,而编译器会盯着双方各自守约。 ), }} @@ -885,8 +883,7 @@ export default function FunctionsPage() { } zh={ <> - 先看差距。同一个函数写两遍:左边你得读函数体才知道它想要什么, - 右边第一行就写明白了。 + 先看差距。同一个函数写两遍:左边你得读函数体才知道它想要什么,右边第一行就写明白了。 } /> @@ -931,8 +928,7 @@ export default function FunctionsPage() { } zh={ <> - 参数类型通常必须写:编译器没有别的材料可以读。 - 返回值类型通常不必写,因为编译器会从{" "} + 参数类型通常必须写:编译器没有别的材料可以读。返回值类型通常不必写,因为编译器会从{" "} return 语句里读出来。 } @@ -964,10 +960,7 @@ export default function FunctionsPage() { } zh={ <> - 两种情况。第一种是对外边界:会被其他模块、其他团队, - 或者发布出去的包调用的函数。 - 这时注解就是那份承诺,能挡住「函数体一改, - 所有人依赖的类型跟着悄悄变了」。 + 两种情况。第一种是对外边界:会被其他模块、其他团队,或者发布出去的包调用的函数。这时注解就是那份承诺,能挡住「函数体一改,所有人依赖的类型跟着悄悄变了」。 } /> @@ -987,9 +980,7 @@ export default function FunctionsPage() { zh={ <> 第二种是报错的位置会更好。写了注解,错误就报在 - 定义处,也就是你正在改的这一行。不写注解, - 错误的类型会流出去,报错出现在很远的某个调用处, - 甚至根本不报。最糟的情况是 any: + 定义处,也就是你正在改的这一行。不写注解,错误的类型会流出去,报错出现在很远的某个调用处,甚至根本不报。最糟的情况是 any: } /> @@ -1012,8 +1003,7 @@ export default function FunctionsPage() { } zh={ <> - 写上 : Order 相当于在出口装了一道闸。 - 函数体内部仍然可能推断出 any,但调用方拿到的是{" "} + 写上 : Order 相当于在出口装了一道闸。函数体内部仍然可能推断出 any,但调用方拿到的是{" "} Order,写错字段当场被抓。 } @@ -1061,9 +1051,7 @@ export default function FunctionsPage() { zh={ <> 两者都允许调用方不写这个实参。区别在于参数在函数体 - 内部长什么样。写 topping?: string, - 它的类型是 string | undefined,用之前得先检查。 - 写 sugar = 50,它的类型就是干净的{" "} + 内部长什么样。写 topping?: string,它的类型是 string | undefined,用之前得先检查。写 sugar = 50,它的类型就是干净的{" "} number:实参没给的时候默认值先跑一遍, undefined 根本进不到函数体里。 @@ -1084,11 +1072,9 @@ export default function FunctionsPage() { } zh={ <> - 显式传 undefined 同样会触发默认值, - 所以{" "} + 显式传 undefined 同样会触发默认值,所以{" "} pourSugar("Milk Green", undefined){" "} - 拿到的也是 50。当一个参数存在合理的默认值时, - 优先用默认值而不是 ? —— 函数体里少一道检查。 + 拿到的也是 50。当一个参数存在合理的默认值时,优先用默认值而不是 ? —— 函数体里少一道检查。 } /> @@ -1119,9 +1105,7 @@ export default function FunctionsPage() { } zh={ <> - 实参是按位置对号入座的。可选参数一旦插在中间, - 它后面的必选参数就再也认不出来了。所以规矩是: - 必选在前,可选和带默认值的在后。 + 实参是按位置对号入座的。可选参数一旦插在中间,它后面的必选参数就再也认不出来了。所以规矩是:必选在前,可选和带默认值的在后。 } /> @@ -1164,8 +1148,7 @@ export default function FunctionsPage() { zh: ( <> ? 管的是这个实参能不能整个不写; - | undefined 管的是这个实参能放哪些值。 - 后者仍然要求你传点什么进来。 + | undefined 管的是这个实参能放哪些值。后者仍然要求你传点什么进来。 ), }} @@ -1185,8 +1168,7 @@ export default function FunctionsPage() { zh={ <> rest 参数的类型是一个数组。它也可以是一个元组 —— - 固定住前面几个位置各自的类型,同时仍然接受后面数量不定的实参。 - 变长元组类型的实用价值就在这里。 + 固定住前面几个位置各自的类型,同时仍然接受后面数量不定的实参。变长元组类型的实用价值就在这里。 } /> @@ -1237,10 +1219,7 @@ export default function FunctionsPage() { } zh={ <> - 注意那个回调的返回值写的是 void。 - 在函数类型里,void 的意思不是「你不许返回任何东西」, - 而是「返回什么调用方都不会用」。 - 所以一个真的返回了东西的函数,照样可以放进来。 + 注意那个回调的返回值写的是 void。在函数类型里,void 的意思不是「你不许返回任何东西」,而是「返回什么调用方都不会用」。所以一个真的返回了东西的函数,照样可以放进来。 } /> @@ -1261,10 +1240,7 @@ export default function FunctionsPage() { ), zh: ( <> - 这是有意设计的。单行箭头函数会把表达式的值返回出去, - 不管你要不要。如果 (…) => void 拒绝这种函数, - 大部分 forEach 回调都得改写成花括号函数体, - 只为了把那个值丢掉。 + 这是有意设计的。单行箭头函数会把表达式的值返回出去,不管你要不要。如果 (…) => void 拒绝这种函数,大部分 forEach 回调都得改写成花括号函数体,只为了把那个值丢掉。 ), }} @@ -1327,9 +1303,7 @@ export default function FunctionsPage() { } zh={ <> - 这个方向很好接受:调用方要一个 Animal, - 拿到的东西是 Animal 而且还多点别的。 - 参数方向则正好反过来 —— 这里最容易让人意外。 + 这个方向很好接受:调用方要一个 Animal,拿到的东西是 Animal 而且还多点别的。参数方向则正好反过来 —— 这里最容易让人意外。 } /> @@ -1356,10 +1330,7 @@ export default function FunctionsPage() { } zh={ <> - 从调用方的角度读一遍。持有 FeedAnimal 的那段代码, - 可以拿任意一只动物来调用它,比如一只猫。 - 而一个要读 d.breed 的函数这时就会出错, - 所以编译器把它拦下。参数按这种反方向检查,叫做 + 从调用方的角度读一遍。持有 FeedAnimal 的那段代码,可以拿任意一只动物来调用它,比如一只猫。而一个要读 d.breed 的函数这时就会出错,所以编译器把它拦下。参数按这种反方向检查,叫做 逆变(contravariance),由{" "} strictFunctionTypes 开关控制,而{" "} strict 会打开它。 @@ -1393,8 +1364,7 @@ export default function FunctionsPage() { 类型所在的位置。用方法语法声明的成员 —— 也就是写成 feed(a: Animal): void 而不是{" "} feed: (a: Animal) => void —— - 沿用的还是旧规则:参数往哪个方向都放得进去。 - 这叫双变(bivariance)。 + 沿用的还是旧规则:参数往哪个方向都放得进去。这叫双变(bivariance)。 } /> @@ -1422,14 +1392,9 @@ export default function FunctionsPage() { } zh={ <> - 这是一处已知的、刻意保留的不健全, - 而不是「恰好安全」的规则。上面最后两行能编译通过, - 然后把一个普通 Animal 塞进了一个实际上是{" "} - Dog[] 的数组里。TypeScript 之所以接受, - 是因为拒绝它就等于让 Array<Dog>{" "} - 不能当作 Array<Animal> 使用, - 同时废掉大量现存的 JavaScript 写法。 - 官方在这里选择了可用性而不是健全性,并且把这个取舍写进了文档。 + 这是一处已知的、刻意保留的不健全,而不是「恰好安全」的规则。上面最后两行能编译通过,然后把一个普通 Animal 塞进了一个实际上是{" "} + Dog[] 的数组里。TypeScript 之所以接受,是因为拒绝它就等于让 Array<Dog>{" "} + 不能当作 Array<Animal> 使用,同时废掉大量现存的 JavaScript 写法。官方在这里选择了可用性而不是健全性,并且把这个取舍写进了文档。 } /> @@ -1446,9 +1411,7 @@ export default function FunctionsPage() { } zh={ <> - 实际怎么用:想让 interface 的某个成员走严格检查, - 就用属性语法声明它。反过来,当某个你以为会报错的赋值被放行时, - 先看一眼是不是方法语法。 + 实际怎么用:想让 interface 的某个成员走严格检查,就用属性语法声明它。反过来,当某个你以为会报错的赋值被放行时,先看一眼是不是方法语法。 } /> @@ -1500,10 +1463,7 @@ export default function FunctionsPage() { } zh={ <> - map 调用回调时会传三个实参, - 而你几乎总是只写一个参数的回调。在 JavaScript 里, - 多余的实参会被直接忽略,所以「声明的参数更少」的回调 - 永远可以安全调用。类型系统允许的,正是语言本来就允许的。 + map 调用回调时会传三个实参,而你几乎总是只写一个参数的回调。在 JavaScript 里,多余的实参会被直接忽略,所以「声明的参数更少」的回调永远可以安全调用。类型系统允许的,正是语言本来就允许的。 } /> @@ -1519,8 +1479,7 @@ export default function FunctionsPage() { } zh={ <> - 反过来就不安全了。声明了第四个参数的回调, - 会去读一个根本没人传的实参,所以编译器把它拦下。 + 反过来就不安全了。声明了第四个参数的回调,会去读一个根本没人传的实参,所以编译器把它拦下。 } /> @@ -1554,8 +1513,7 @@ export default function FunctionsPage() { } zh={ <> - 有的函数会根据传进来的东西表现不同:传字符串还回来一种结果, - 传数组还回来另一种。这可以用几条重载签名加一条 + 有的函数会根据传进来的东西表现不同:传字符串还回来一种结果,传数组还回来另一种。这可以用几条重载签名加一条 实现签名来描述。 } @@ -1588,11 +1546,8 @@ export default function FunctionsPage() { } zh={ <> - 第一条:实现签名不能从外部调用。 - 它只需要和上面每一条重载都兼容。例子里 price{" "} - 的实现接受 string | string[], - 但拿一个 string | string[] 的值去调用它却会报错, - 因为两条重载谁都不接受这个类型。 + 第一条:实现签名不能从外部调用。它只需要和上面每一条重载都兼容。例子里 price{" "} + 的实现接受 string | string[],但拿一个 string | string[] 的值去调用它却会报错,因为两条重载谁都不接受这个类型。 } /> @@ -1609,9 +1564,7 @@ export default function FunctionsPage() { } zh={ <> - 第二条:TypeScript 按你书写的顺序,取第一条匹配上的重载, - 而不是去找「最合适的那条」。所以重载的顺序是 API 的一部分: - 越具体的签名越要写在前面。 + 第二条:TypeScript 按你书写的顺序,取第一条匹配上的重载,而不是去找「最合适的那条」。所以重载的顺序是 API 的一部分:越具体的签名越要写在前面。 } /> @@ -1635,8 +1588,7 @@ export default function FunctionsPage() { } zh={ <> - 另一件事是一个名字叫 this 的参数。 - 它不是真的参数,而是告诉编译器:这个函数运行时, + 另一件事是一个名字叫 this 的参数。它不是真的参数,而是告诉编译器:这个函数运行时, this 必须是什么。 } @@ -1658,9 +1610,7 @@ export default function FunctionsPage() { ), zh: ( <> - this 参数和其他类型一起被擦除, - 编译出来的 JavaScript 里是一个单参数函数。 - 它在运行时不改变任何东西,只是让编译器能拦下那些{" "} + this 参数和其他类型一起被擦除,编译出来的 JavaScript 里是一个单参数函数。它在运行时不改变任何东西,只是让编译器能拦下那些{" "} this 会出错的调用。 ), @@ -1688,10 +1638,8 @@ export default function FunctionsPage() { zh={ <> 返回值类型还可以写成 x is Dog 或{" "} - asserts x is Dog。 - 这是类型谓词和断言签名 —— - 用签名告诉编译器「这次检查证明了什么」。 - 它们属于收窄的范畴,03 章会讲。 + asserts x is Dog。这是类型谓词和断言签名 —— + 用签名告诉编译器「这次检查证明了什么」。它们属于收窄的范畴,03 章会讲。 } /> @@ -1735,11 +1683,7 @@ export default function FunctionsPage() { } zh={ <> - readonly 只在编译期拦你。 - 它在产出的 JavaScript 里不留任何痕迹, - 所以运行时这个属性照样能写。想要真正的运行时冻结, - 得用 Object.freeze。 - 但编译期这道检查依然值得要:误改几乎都被它挡住了。 + readonly 只在编译期拦你。它在产出的 JavaScript 里不留任何痕迹,所以运行时这个属性照样能写。想要真正的运行时冻结,得用 Object.freeze。但编译期这道检查依然值得要:误改几乎都被它挡住了。 } /> @@ -1763,11 +1707,8 @@ export default function FunctionsPage() { zh={ <> 这个说法不用信,可以当场验。下面这个窗口里,给 id{" "} - 赋值那一行被画了波浪线;切到编译产物 JS 页签, - 再找 readonly 这个词 —— 产物里根本没有它。 - 检查发生过,然后被擦掉了。第二条红线来自另一个修饰: - desc 可能不存在,用之前必须先检查。 - 把 readonly 这个词删掉,它那条报错就跟着没了。 + 赋值那一行被画了波浪线;切到编译产物 JS 页签,再找 readonly 这个词 —— 产物里根本没有它。检查发生过,然后被擦掉了。第二条红线来自另一个修饰: + desc 可能不存在,用之前必须先检查。把 readonly 这个词删掉,它那条报错就跟着没了。 } /> @@ -1793,9 +1734,7 @@ export default function FunctionsPage() { } zh={ <> - 有时候键是事先列不出来的:库存表明天可能上任何一个新货号。 - 这时用索引签名(index signature), - 只约定键的类型和值的类型。 + 有时候键是事先列不出来的:库存表明天可能上任何一个新货号。这时用索引签名(index signature),只约定键的类型和值的类型。 } /> @@ -1827,10 +1766,8 @@ export default function FunctionsPage() { } zh={ <> - 字段名写得全,就老老实实一个个写出来,编译器会帮你查拼写。 - 只有当键是运行时才定的 —— - 货号、用户输入、由数据拼出来的字典 —— 才用索引签名。 - 它更宽松,代价正是你刚刚放弃的那道拼写检查。 + 字段名写得全,就老老实实一个个写出来,编译器会帮你查拼写。只有当键是运行时才定的 —— + 货号、用户输入、由数据拼出来的字典 —— 才用索引签名。它更宽松,代价正是你刚刚放弃的那道拼写检查。 } /> @@ -1879,8 +1816,7 @@ export default function FunctionsPage() { } zh={ <> - 右边用的 & 是交叉类型。 - 值得单独看一眼,因为下一章会拿它和联合类型作对比。 + 右边用的 & 是交叉类型。值得单独看一眼,因为下一章会拿它和联合类型作对比。 } /> @@ -1910,9 +1846,7 @@ export default function FunctionsPage() { ), zh: ( <> - 两个冲突的属性类型交叉之后会被归约成 never, - 声明处并不报错,问题要到后面真正使用这个属性的地方才暴露。 - 这就是「扩展对象类型时优先用 extends」的实际理由。 + 两个冲突的属性类型交叉之后会被归约成 never,声明处并不报错,问题要到后面真正使用这个属性的地方才暴露。这就是「扩展对象类型时优先用 extends」的实际理由。 ), }} @@ -2067,10 +2001,7 @@ export default function FunctionsPage() { } zh={ <> - TypeScript handbook 现行的建议很朴素: - 两个随便挑一个,在同一个代码库里保持一致。 - 需要联合类型或映射类型时,只有 type 写得出来; - 写库、并且希望使用者能扩展你的声明时,用{" "} + TypeScript handbook 现行的建议很朴素:两个随便挑一个,在同一个代码库里保持一致。需要联合类型或映射类型时,只有 type 写得出来;写库、并且希望使用者能扩展你的声明时,用{" "} interface。至于「interface{" "} 编译一定更快」这种说法,不足以作为选型依据。 @@ -2112,8 +2043,7 @@ export default function FunctionsPage() { zh: ( <> 高亮的这段签名就是本章开头那一份。注意 toppings{" "} - 这次用的是默认值而不是 ?: - 函数体里它的类型就是 Topping[],不含{" "} + 这次用的是默认值而不是 ?:函数体里它的类型就是 Topping[],不含{" "} undefined,所以 toppings.length{" "} 不需要任何检查。 @@ -2132,8 +2062,7 @@ export default function FunctionsPage() { } zh={ <> - 签名定下来了,现在换你当编译器。下面六种调用, - 先猜「能不能通过」,再看编译器的原话。 + 签名定下来了,现在换你当编译器。下面六种调用,先猜「能不能通过」,再看编译器的原话。 } /> @@ -2155,11 +2084,8 @@ export default function FunctionsPage() { } zh={ <> - 猜是一半,另一半是自己动手违约。 - 下面这个窗口里是同一份签名,最后一行那次调用守约。 - 把杯型改成名单外的字符串、删掉一个实参、再多塞一个 —— - 你拿到的每一句都是当场生成的,出自编译器本人。 - 标题栏那三份稿子,是最常见的三种错法。 + 猜是一半,另一半是自己动手违约。下面这个窗口里是同一份签名,最后一行那次调用守约。把杯型改成名单外的字符串、删掉一个实参、再多塞一个 —— + 你拿到的每一句都是当场生成的,出自编译器本人。标题栏那三份稿子,是最常见的三种错法。 } /> @@ -2205,9 +2131,7 @@ export default function FunctionsPage() { 两份把参数个数搞错的稿子,报的都是 TS2554: Expected 2-3 arguments, but got 1. 和{" "} Expected 2-3 arguments, but got 4. - 那个 2-3 的范围,就是这份签名念出来的样子: - 两个必填参数,加一个可以不写的。这个范围没有人写在任何地方, - 是编译器从签名上数出来的 —— 你改一下签名,它当场重数一遍。 + 那个 2-3 的范围,就是这份签名念出来的样子:两个必填参数,加一个可以不写的。这个范围没有人写在任何地方,是编译器从签名上数出来的 —— 你改一下签名,它当场重数一遍。 } /> @@ -2244,10 +2168,7 @@ export default function FunctionsPage() { makeOrder(item: MenuItem, size: Size, toppings?: Topping[]): Order - 。第三个参数声明时写的是默认值, - 显示出来却带着一个问号。 - 因为站在调用方这一侧,两者确实是同一件事:这个实参可以不写。 - 区别在函数体内部,§02 讲过 —— 有默认值,就没有{" "} + 。第三个参数声明时写的是默认值,显示出来却带着一个问号。因为站在调用方这一侧,两者确实是同一件事:这个实参可以不写。区别在函数体内部,§02 讲过 —— 有默认值,就没有{" "} undefined 需要检查了。 } @@ -2275,8 +2196,7 @@ export default function FunctionsPage() { zh={ <> 03 章里,这个 Order 会长出一个状态字段: - pending、paid、delivered。 - 写成可辨识联合之后,编译器就能在每个分支里自己算出有哪些字段。 + pending、paid、delivered。写成可辨识联合之后,编译器就能在每个分支里自己算出有哪些字段。 } /> @@ -2324,9 +2244,7 @@ export default function FunctionsPage() { ), zh: ( <> - 参数类型通常必须写,返回值类型通常靠推断。 - 对外边界上要写,想让错误报在定义处而不是很远的调用处时也要写。 - 它同时能挡住 any 从函数里漏出去。 + 参数类型通常必须写,返回值类型通常靠推断。对外边界上要写,想让错误报在定义处而不是很远的调用处时也要写。它同时能挡住 any 从函数里漏出去。 ), }, @@ -2343,11 +2261,8 @@ export default function FunctionsPage() { ), zh: ( <> - ? 让调用方可以不写这个实参, - 而函数体里的类型会带上 | undefined。 - 默认值同样允许不写,但函数体里的类型不含{" "} - undefined。可选参数必须排在最后。 - rest 参数是数组,也可以是元组。 + ? 让调用方可以不写这个实参,而函数体里的类型会带上 | undefined。默认值同样允许不写,但函数体里的类型不含{" "} + undefined。可选参数必须排在最后。rest 参数是数组,也可以是元组。 ), }, @@ -2363,10 +2278,7 @@ export default function FunctionsPage() { ), zh: ( <> - 函数类型里的 void 表示调用方不看返回值, - 所以真的返回了东西的函数照样放得进去; - 声明上的 void 则禁止返回值; - 类型为 void 的变量只接受 undefined。 + 函数类型里的 void 表示调用方不看返回值,所以真的返回了东西的函数照样放得进去;声明上的 void 则禁止返回值;类型为 void 的变量只接受 undefined。 ), }, @@ -2388,8 +2300,7 @@ export default function FunctionsPage() { 返回值按协变检查:返回 Dog 可以放进「要{" "} Animal」的位置。参数在{" "} strictFunctionTypes 下按逆变检查 —— - 但用方法语法写的成员是例外,仍然双变。 - 这个例外是刻意保留的不健全,也正是{" "} + 但用方法语法写的成员是例外,仍然双变。这个例外是刻意保留的不健全,也正是{" "} Array<Dog> 能赋给{" "} Array<Animal> 的原因。 @@ -2406,10 +2317,8 @@ export default function FunctionsPage() { ), zh: ( <> - 参数更少的函数可以放进「要求参数更多」的位置, - 因为 JavaScript 本来就会忽略多余的实参 —— 这就是{" "} - array.map(x => x) 能编译的原因。 - 反过来,声明的参数比目标提供的还多,会被拒绝。 + 参数更少的函数可以放进「要求参数更多」的位置,因为 JavaScript 本来就会忽略多余的实参 —— 这就是{" "} + array.map(x => x) 能编译的原因。反过来,声明的参数比目标提供的还多,会被拒绝。 ), }, @@ -2426,9 +2335,7 @@ export default function FunctionsPage() { zh: ( <> 重载里,实现签名不能从外部调用; - TypeScript 按书写顺序取第一条匹配的重载, - 而不是最合适的那条。this 参数在编译期被擦除, - 箭头函数不能声明它。 + TypeScript 按书写顺序取第一条匹配的重载,而不是最合适的那条。this 参数在编译期被擦除,箭头函数不能声明它。 ), }, @@ -2465,8 +2372,7 @@ export default function FunctionsPage() { interface 和 type{" "} 大部分时候可以互换。联合类型和映射类型只能用{" "} type;declaration merging 只有{" "} - interface 有。A & B 是交叉类型: - 一个同时满足两边的值。 + interface 有。A & B 是交叉类型:一个同时满足两边的值。 ), }, diff --git a/app/functions/viz.tsx b/app/functions/viz.tsx index 5f7b65c..8869d03 100644 --- a/app/functions/viz.tsx +++ b/app/functions/viz.tsx @@ -86,9 +86,7 @@ const SIG_SEGS: SigSeg[] = [ } zh={ <> - 这个函数叫什么。用动词短语最好读, - 因为调用处应该一眼看出发生了什么:makeOrder、addTopping、calcTotal。 - 开门括号一开,参数列表就开始了。 + 这个函数叫什么。用动词短语最好读,因为调用处应该一眼看出发生了什么:makeOrder、addTopping、calcTotal。开门括号一开,参数列表就开始了。 } /> @@ -110,9 +108,7 @@ const SIG_SEGS: SigSeg[] = [ } zh={ <> - 冒号左边是参数名,函数体里用这个名字称呼它; - 右边是类型,也就是调用方必须交出的形状。 - 传一个不是 MenuItem 的东西进来,这次调用就编译不过。 + 冒号左边是参数名,函数体里用这个名字称呼它;右边是类型,也就是调用方必须交出的形状。传一个不是 MenuItem 的东西进来,这次调用就编译不过。 } /> @@ -165,10 +161,8 @@ const SIG_SEGS: SigSeg[] = [ } zh={ <> - 名字后面挂一个 ?,表示这个实参可以整个不写。 - 代价在函数体里:那里它的类型是{" "} - Topping[] | undefined,用之前得先检查。 - 可选参数还必须排在所有必选参数后面 —— 原因见 §02。 + 名字后面挂一个 ?,表示这个实参可以整个不写。代价在函数体里:那里它的类型是{" "} + Topping[] | undefined,用之前得先检查。可选参数还必须排在所有必选参数后面 —— 原因见 §02。 } /> @@ -191,10 +185,7 @@ const SIG_SEGS: SigSeg[] = [ } zh={ <> - 关门括号后面的 : Order 是这个函数承诺交出的东西。 - return 的对象少一个字段,错误就报在这里、报在定义处。 - TypeScript 本来能推断出它, - 但在会被别人调用的函数上写出来,这份承诺才稳定 —— 见 §01 的提示框。 + 关门括号后面的 : Order 是这个函数承诺交出的东西。return 的对象少一个字段,错误就报在这里、报在定义处。TypeScript 本来能推断出它,但在会被别人调用的函数上写出来,这份承诺才稳定 —— 见 §01 的提示框。 } /> @@ -268,9 +259,7 @@ const CC_CASES: CcCase[] = [ } zh={ <> - toppings 带了 ?,所以整个不写是允许的。 - 函数体里 toppings 是 undefined, - 返回值仍然是一张完整的 Order。 + toppings 带了 ?,所以整个不写是允许的。函数体里 toppings 是 undefined,返回值仍然是一张完整的 Order。 } /> @@ -292,8 +281,7 @@ const CC_CASES: CcCase[] = [ } zh={ <> - size 没有 ?,是必填的。 - 两个必选参数加一个可选参数 —— 报错里那个 2-3 的范围就是这么来的。 + size 没有 ?,是必填的。两个必选参数加一个可选参数 —— 报错里那个 2-3 的范围就是这么来的。 } /> @@ -315,9 +303,7 @@ const CC_CASES: CcCase[] = [ } zh={ <> - Size 只认三个字符串:small、medium、large。 - "grande" 不在其中。字面量联合比的是整个字符串, - 差一个字母就已经是另一个类型了。 + Size 只认三个字符串:small、medium、large。"grande" 不在其中。字面量联合比的是整个字符串,差一个字母就已经是另一个类型了。 } /> @@ -338,9 +324,7 @@ const CC_CASES: CcCase[] = [ } zh={ <> - 第三个参数要的是 Topping[]: - 一个数组,里面每一项都得是 Topping 联合里的字符串。 - 这两个值都在册。 + 第三个参数要的是 Topping[]:一个数组,里面每一项都得是 Topping 联合里的字符串。这两个值都在册。 } /> @@ -362,8 +346,7 @@ const CC_CASES: CcCase[] = [ } zh={ <> - 第一个参数要的是一件商品的完整形状:id、name、price。 - 光一个商品名只是个字符串。编译器比的是形状,不是意思 —— + 第一个参数要的是一件商品的完整形状:id、name、price。光一个商品名只是个字符串。编译器比的是形状,不是意思 —— 04 章专门讲这件事。 } @@ -388,8 +371,7 @@ const CC_CASES: CcCase[] = [ zh={ <> 就算只加一样配料,也得装进数组: - ["boba"]。第三个参数的类型是数组, - 而「一个字符串」和「装着一个字符串的数组」是两种类型。 + ["boba"]。第三个参数的类型是数组,而「一个字符串」和「装着一个字符串的数组」是两种类型。 } /> @@ -535,8 +517,7 @@ export function CallCheck() { } zh={ <> - 六单全审完,你就有了编译器的习惯: - 看到一次调用,先在脑子里对一遍签名,再去运行。 + 六单全审完,你就有了编译器的习惯:看到一次调用,先在脑子里对一遍签名,再去运行。 } /> diff --git a/app/generics/page.tsx b/app/generics/page.tsx index 9e9da6f..e068e3f 100644 --- a/app/generics/page.tsx +++ b/app/generics/page.tsx @@ -538,8 +538,7 @@ export default function GenericsPage() { ), zh: ( <> - 先别急着说是什么类型 —— 留一个洞 <T>, - 调用的人来填。同一个签名里的每个 T{" "} + 先别急着说是什么类型 —— 留一个洞 <T>,调用的人来填。同一个签名里的每个 T{" "} 都是同一个,所以返回值的类型始终和参数的类型绑在一起。 ), @@ -607,10 +606,8 @@ export default function GenericsPage() { } zh={ <> - 一个小店的点单系统要「取订单列表的第一单」 - 「取菜单的第一项」「取杯型选项的第一个」—— - 同一个动作做三遍,面对三种不同的数组类型。 - 用现有的写法,你有三条路,三条都不体面。 + 一个小店的点单系统要「取订单列表的第一单」「取菜单的第一项」「取杯型选项的第一个」—— + 同一个动作做三遍,面对三种不同的数组类型。用现有的写法,你有三条路,三条都不体面。 } /> @@ -669,9 +666,7 @@ export default function GenericsPage() { 看右边这版:一份代码通吃所有数组,而且输入和输出是 连着的 —— 喂它 string[],拿回来的东西和{" "} string 有关;喂它 Order[], 拿回来的就和{" "} - Order 有关。 - 中间那一版丢掉的正是这条联系。下一节把一次调用放慢, - 看它是怎么建立起来的。 + Order 有关。中间那一版丢掉的正是这条联系。下一节把一次调用放慢,看它是怎么建立起来的。 } /> @@ -712,8 +707,7 @@ export default function GenericsPage() { zh={ <> 函数名后面的尖括号声明了一个占位符,并给它取名 T - 。它叫类型参数: - 和普通参数一样是参数,只不过里面装的是类型,不是值。 + 。它叫类型参数:和普通参数一样是参数,只不过里面装的是类型,不是值。 } /> @@ -736,8 +730,7 @@ export default function GenericsPage() { } zh={ <> - 签名里每个 T 指的都是那一个占位符。 - 这就是它的承诺:数组里装的和返回的是同一种类型。 + 签名里每个 T 指的都是那一个占位符。这就是它的承诺:数组里装的和返回的是同一种类型。 any 说不出这句话。 } @@ -763,8 +756,7 @@ export default function GenericsPage() { } zh={ <> - 写这个函数的人不知道 T 会是什么,也不需要知道。 - 每次调用各自带着实参过来,编译器就为这一次调用单独解出{" "} + 写这个函数的人不知道 T 会是什么,也不需要知道。每次调用各自带着实参过来,编译器就为这一次调用单独解出{" "} T。 } @@ -795,9 +787,7 @@ export default function GenericsPage() { } zh={ <> - 编译器从第一个实参读出 T = string, - 再拿第二个实参去对。想让两个位置各是各的类型, - 那就声明两个占位符:{"pair(a: A, b: B)"} —— + 编译器从第一个实参读出 T = string,再拿第二个实参去对。想让两个位置各是各的类型,那就声明两个占位符:{"pair(a: A, b: B)"} —— 动手任务里有一题练这个。 } @@ -832,8 +822,7 @@ export default function GenericsPage() { T 取自 Type。你也可以叫它 Item、 Row,就像普通参数可以叫 x 也可以叫{" "} count。常见的还有 K / V - (键和值)、E(元素)。名字短,是因为这个占位符 - 常常真的什么都可能是。一旦它有明确含义,就该起个真名: 使用处写{" "} + (键和值)、E(元素)。名字短,是因为这个占位符常常真的什么都可能是。一旦它有明确含义,就该起个真名: 使用处写{" "} {"Paginated"} 比{" "} {"Paginated"} 好读。 @@ -879,9 +868,7 @@ export default function GenericsPage() { <> 推断的原料只有一个来源:实参。空数组带不出元素类型, 于是{" "} T 被推成 never,结果是{" "} - undefined。这不是报错,但通常不是你要的。 - 在意料之外的地方看到 never, - 就该想到去把类型实参写出来。 + undefined。这不是报错,但通常不是你要的。在意料之外的地方看到 never,就该想到去把类型实参写出来。 ), }} @@ -905,9 +892,7 @@ export default function GenericsPage() { } zh={ <> - 到这里为止,「T 是什么」都是注释里的一句断言。 - 下面这个窗口里跑的是编译器本身,断言可以当场核对。 - 点四次调用里的任意一个 first:弹出来的签名里{" "} + 到这里为止,「T 是什么」都是注释里的一句断言。下面这个窗口里跑的是编译器本身,断言可以当场核对。点四次调用里的任意一个 first:弹出来的签名里{" "} T 已经被换成了实际类型,价格那一行是{" "} {"function first(arr: number[]): number | undefined"} @@ -947,8 +932,7 @@ export default function GenericsPage() { {"function first(arr: Order[]): Order | undefined"} ,因为类型是你自己点名的;first([]) 显示{" "} {"function first(arr: never[]): undefined"} - ,因为空数组带不出元素类型,剩下的只有 never。 - 这一行不报错 —— 正因为不报错,才值得练成一眼认出来。 + ,因为空数组带不出元素类型,剩下的只有 never。这一行不报错 —— 正因为不报错,才值得练成一眼认出来。 } /> @@ -974,9 +958,7 @@ export default function GenericsPage() { } zh={ <> - 空数组好歹还是个实参,编译器手里有东西。 - 更极端的情况是:类型参数只出现在返回值类型里。 - 这时没有任何实参提到它,编译器就回落到 unknown。 + 空数组好歹还是个实参,编译器手里有东西。更极端的情况是:类型参数只出现在返回值类型里。这时没有任何实参提到它,编译器就回落到 unknown。 } /> @@ -1005,10 +987,7 @@ export default function GenericsPage() { zh={ <> 这种写法在解析数据、请求数据的代码里很常见,但要小心: - parseJson 承诺返回一个 T, - 却没有任何东西检查解析出来的内容真的长成那样。 - 类型实参是调用方的一句断言,不是编译器给的保证。 - 拿到这种值之后怎么检查,是第 03 章的内容。 + parseJson 承诺返回一个 T,却没有任何东西检查解析出来的内容真的长成那样。类型实参是调用方的一句断言,不是编译器给的保证。拿到这种值之后怎么检查,是第 03 章的内容。 } /> @@ -1028,8 +1007,7 @@ export default function GenericsPage() { } zh={ <> - 经验法则:默认交给推断。 - 只有在实参给不出信息时才手写类型实参 —— + 经验法则:默认交给推断。只有在实参给不出信息时才手写类型实参 —— 比如空数组、没有参数的调用、类型参数只出现在返回值里 —— 或者推出来的类型不是你想要的那个。 @@ -1152,8 +1130,7 @@ export default function GenericsPage() { zh: ( <> 两个占位符。T 是那个对象;K 被约束成{" "} - keyof T,也就是这个对象的键名联合。 - 安全性正是这个约束给的:既然 K 只能是 T{" "} + keyof T,也就是这个对象的键名联合。安全性正是这个约束给的:既然 K 只能是 T{" "} 真有的键,索引访问 T[K]{" "} 取到的就一定是存在的类型,写错键名也会在程序运行前被拦下。 keyof 和 T[K] 的完整用法在第 07 章。 @@ -1192,9 +1169,7 @@ export default function GenericsPage() { to parameter of type '{"{ length: number; }"}'. ,getProp(menu, "topping"){" "} - 拿到的是同一个错误码,里面列着那个对象真有的两个键名。 - 点报错可以跳到出问题的调用。然后按一下把约束去掉: - 调用处的两处报错消失,函数体里换成了{" "} + 拿到的是同一个错误码,里面列着那个对象真有的两个键名。点报错可以跳到出问题的调用。然后按一下把约束去掉:调用处的两处报错消失,函数体里换成了{" "} TS2339: Property 'length' does not exist on type 'T'. @@ -1238,8 +1213,7 @@ export default function GenericsPage() { 这一次切换,就是整笔交易。 约束把抱怨从函数体挪到了调用处 —— 而调用处才是人能动手的地方:写 longest 的人可以放心写{" "} - a.length,不用和编译器争辩; - 传了 number 进来的人,在自己写的那一行上就被拦住。 + a.length,不用和编译器争辩;传了 number 进来的人,在自己写的那一行上就被拦住。 } /> @@ -1306,10 +1280,8 @@ export default function GenericsPage() { ), zh: ( <> - 区别在于占位符什么时候定下来。 - 泛型类是每个实例定一次,所有成员共用它,所以一个{" "} - Basket<string> 从头到尾只收 string。 - 泛型方法是每次调用定一次,所以同一个 Counter{" "} + 区别在于占位符什么时候定下来。泛型类是每个实例定一次,所有成员共用它,所以一个{" "} + Basket<string> 从头到尾只收 string。泛型方法是每次调用定一次,所以同一个 Counter{" "} 实例可以先数 number,再数 string。 ), @@ -1338,7 +1310,7 @@ export default function GenericsPage() { <> {""} 限制哪些类型被允许; {""}{" "} - 是在既没写、也推不出来时补一个类型。 两者回答的是不同的问题, + 是在既没写、也推不出来时补一个类型。两者回答的是不同的问题, {''} 就是同时用上。 ), @@ -1371,8 +1343,7 @@ export default function GenericsPage() { 的简写,而 Array 就是一个泛型接口。 Promise<Order> 表示「将来会是一个{" "} Order」。Map<string, number>{" "} - 有两个占位符。读懂了尖括号, - 标准库的类型签名就变成了可以读的说明书。 + 有两个占位符。读懂了尖括号,标准库的类型签名就变成了可以读的说明书。 } /> @@ -1395,7 +1366,7 @@ export default function GenericsPage() { Array<Animal>, 哪怕这样就能往原数组里{" "} push 一个非 Dog。这是关于{" "} Array{" "} - 的方法如何比较的一个有意为之的决定,不是泛型的通则。 第 02 章 + 的方法如何比较的一个有意为之的决定,不是泛型的通则。第 02 章 §04 讲透了这件事。 } @@ -1462,8 +1433,7 @@ export default function GenericsPage() { 和所有注解,都在编译过程中被去掉。所以程序运行时没法问{" "} T 是什么,也写不出 new T() 或{" "} if (T === String)。泛型函数并不知道自己的类型实参 - —— 知道的是编译器,而且是在编译之前就知道。 - 真要在运行时按类型分支,那得对值做真正的运行时检查, 那是第 + —— 知道的是编译器,而且是在编译之前就知道。真要在运行时按类型分支,那得对值做真正的运行时检查, 那是第 03 章的内容。 } @@ -1497,9 +1467,7 @@ export default function GenericsPage() { {"function log(x: T): void { console.log(x) }"} {" "} - 声明了 T,然后只提到它一次。 - 它没把任何东西和任何东西联系起来,也就什么都没承诺。 - 一条好用的规矩:类型参数至少要出现两次 —— + 声明了 T,然后只提到它一次。它没把任何东西和任何东西联系起来,也就什么都没承诺。一条好用的规矩:类型参数至少要出现两次 —— 联系两个参数,或者联系参数和返回值。只出现一次的话, 直接写{" "} {"(x: unknown)"} 更诚实。 @@ -1529,11 +1497,8 @@ export default function GenericsPage() { } zh={ <> - any 是放弃类型:不管进去的是什么, - 出来的都不再受检查。泛型是保住类型: - T 在调用那一刻被解成一个具体类型, - 输入和输出全程受检。两者方向相反, - 看起来像,只是因为它们都「什么类型都收」。 + any 是放弃类型:不管进去的是什么,出来的都不再受检查。泛型是保住类型: + T 在调用那一刻被解成一个具体类型,输入和输出全程受检。两者方向相反,看起来像,只是因为它们都「什么类型都收」。 } /> @@ -1616,8 +1581,7 @@ export default function GenericsPage() { ), zh: ( <> - 类型参数是一个类型的占位符,在调用处才被填上。 - 同一个签名里的每个 T 都是同一个 —— + 类型参数是一个类型的占位符,在调用处才被填上。同一个签名里的每个 T 都是同一个 —— 返回值的类型就是这样和参数的类型绑在一起的。 ), @@ -1649,8 +1613,7 @@ export default function GenericsPage() { ), zh: ( <> - 推断是默认,它的原料是实参。 - 实参给不出信息时才手写类型实参 —— 空数组会得到{" "} + 推断是默认,它的原料是实参。实参给不出信息时才手写类型实参 —— 空数组会得到{" "} never,只出现在返回值里的类型参数会得到{" "} unknown。 @@ -1689,8 +1652,7 @@ export default function GenericsPage() { zh: ( <> 类型参数会被擦除:编译之后没有 T{" "} - 可查,泛型函数在运行时并不知道自己的类型实参。 - 泛型类的占位符按实例定一次,泛型方法的按调用定一次。 + 可查,泛型函数在运行时并不知道自己的类型实参。泛型类的占位符按实例定一次,泛型方法的按调用定一次。 ), }, diff --git a/app/generics/viz.tsx b/app/generics/viz.tsx index af72cd8..b72dd41 100644 --- a/app/generics/viz.tsx +++ b/app/generics/viz.tsx @@ -100,8 +100,7 @@ export function HoleFiller() { zh={ <> 先只看签名。<T> 是在声明占位符, 后面两个{" "} - T 是同一个占位符的两次使用。 - 现在还什么都没填。点「下一步」,让一次调用进场。 + T 是同一个占位符的两次使用。现在还什么都没填。点「下一步」,让一次调用进场。 } />, @@ -119,7 +118,7 @@ export function HoleFiller() { <> 编译器去读实参:{run.argLit} 的类型是{" "} {run.argType}。拿它对上声明的参数 arr: T[] - ,得出 T = {run.fill}。 这就是类型实参推断,而它的原料只有实参。 + ,得出 T = {run.fill}。这就是类型实参推断,而它的原料只有实参。 } />, @@ -136,8 +135,7 @@ export function HoleFiller() { zh={ <> 只有一个占位符,所以所有使用处同时被填上:这个签名里的每个{" "} - T 现在都是 {run.fill}。 - 同一次调用里,不可能这头是 string、那头是{" "} + T 现在都是 {run.fill}。同一次调用里,不可能这头是 string、那头是{" "} number。 } @@ -155,7 +153,7 @@ export function HoleFiller() { zh={ <> 返回值类型跟着落定:x 的类型是 {run.result} - 。类型信息从实参一路走到了结果。 这就是它和 any 的区别 ——{" "} + 。类型信息从实参一路走到了结果。这就是它和 any 的区别 ——{" "} any 把类型丢在门口。 } @@ -297,8 +295,7 @@ const CANDS: Cand[] = [ } zh={ <> - string 本来就有 .length, - 可以赋值给这个约束,调用通过,T = string。注意 T 并不会被削成{" "} + string 本来就有 .length,可以赋值给这个约束,调用通过,T = string。注意 T 并不会被削成{" "} {"{ length: number }"} —— 它仍然是完整的{" "} string。 @@ -322,8 +319,7 @@ const CANDS: Cand[] = [ } zh={ <> - 数组也有 .length,同样通过,T = string[]。 - 约束问的是这个类型「有什么」,不是它「叫什么」。 + 数组也有 .length,同样通过,T = string[]。约束问的是这个类型「有什么」,不是它「叫什么」。 } /> @@ -346,8 +342,7 @@ const CANDS: Cand[] = [ zh={ <> 一个没名字、也没声明过类型的对象字面量,但它确实有{" "} - length: number。 - 上一章那套结构化检查,正是约束在这里用的检查。 + length: number。上一章那套结构化检查,正是约束在这里用的检查。 } /> @@ -373,8 +368,7 @@ const CANDS: Cand[] = [ } zh={ <> - number 身上没有 length, - 所以你还在敲代码的时候这次调用就被拒绝了: + number 身上没有 length,所以你还在敲代码的时候这次调用就被拒绝了: Argument of type 'number' is not assignable to parameter of type {"'{ length: number; }'"}. @@ -401,8 +395,7 @@ const CANDS: Cand[] = [ } zh={ <> - boolean 同样没有 length,拒绝。 - 约束是加在占位符上的一个条件:不是什么类型都能填进去。 + boolean 同样没有 length,拒绝。约束是加在占位符上的一个条件:不是什么类型都能填进去。 } /> @@ -478,8 +471,7 @@ export function ConstraintGate() { } zh={ <> - longest 要比较两个值的 .length, - 所以它的占位符带了一个条件:填进来的类型必须有{" "} + longest 要比较两个值的 .length,所以它的占位符带了一个条件:填进来的类型必须有{" "} length: number。点上面的候选值,挨个送去检查。 } diff --git a/app/home-viz.tsx b/app/home-viz.tsx index e56c60f..c97bc96 100644 --- a/app/home-viz.tsx +++ b/app/home-viz.tsx @@ -260,8 +260,7 @@ const TL_FRAMES: TLFrame[] = [ } zh={ <> - 凌晨三点:客服电话、翻日志、一行行加 console.log —— 两小时后才发现, - 凶手是 5 个拼错的字母。同一时刻,TS 工程师在睡觉。 + 凌晨三点:客服电话、翻日志、一行行加 console.log —— 两小时后才发现,凶手是 5 个拼错的字母。同一时刻,TS 工程师在睡觉。 } /> @@ -387,8 +386,7 @@ const E_CAPTION: Record = { } zh={ <> - 带颜色的部分全是写给编译器看的:type、interface、冒号后面的注解。 - 运行逻辑其实只有下面那几行。 + 带颜色的部分全是写给编译器看的:type、interface、冒号后面的注解。运行逻辑其实只有下面那几行。 } /> @@ -403,8 +401,7 @@ const E_CAPTION: Record = { } zh={ <> - 划线的这些,编译时会被整体擦掉 —— 一个字节都不会进产物。 - 这个动作就叫类型擦除(type erasure)。 + 划线的这些,编译时会被整体擦掉 —— 一个字节都不会进产物。这个动作就叫类型擦除(type erasure)。 } /> @@ -421,8 +418,7 @@ const E_CAPTION: Record = { } zh={ <> - 这就是用户浏览器里真正跑的代码:类型全没了,逻辑一字未动。 - 所以类型防不住运行时闯进来的坏数据 —— 这个伏笔,终章回收。 + 这就是用户浏览器里真正跑的代码:类型全没了,逻辑一字未动。所以类型防不住运行时闯进来的坏数据 —— 这个伏笔,终章回收。 } /> diff --git a/app/mindset/page.tsx b/app/mindset/page.tsx index a680fd2..ef66387 100644 --- a/app/mindset/page.tsx +++ b/app/mindset/page.tsx @@ -553,9 +553,7 @@ export default function MindsetPage() { zh: ( <> 前十一章讲完了语法,这一章讲怎么想。TypeScript 就是 JavaScript - 加上一层类型,这层类型只存在于你写代码和编译的阶段: - 编译器检查完就把它删掉,真正运行的是你写的那份 JavaScript。 - 五条心法,一张全书地图,一场总测验。 + 加上一层类型,这层类型只存在于你写代码和编译的阶段:编译器检查完就把它删掉,真正运行的是你写的那份 JavaScript。五条心法,一张全书地图,一场总测验。 ), }} @@ -646,17 +644,13 @@ export default function MindsetPage() { zh={ <>

- 给一个值「定类型」,其实是在对编译器说话, - 而这三种写法说的是三句不同的话。注解( - : Config)是「请你检查」: - 编译器检查这个值,然后把推断结果换成你声明的类型。 - 断言(as Config)是「我说了算」: - 检查被跳过。 + 给一个值「定类型」,其实是在对编译器说话,而这三种写法说的是三句不同的话。注解( + : Config)是「请你检查」:编译器检查这个值,然后把推断结果换成你声明的类型。 + 断言(as Config)是「我说了算」:检查被跳过。 satisfies - (TypeScript 4.9 加入)是「检查完别改我」: - 形状照查,推断结果保留。下面直接对比。 + (TypeScript 4.9 加入)是「检查完别改我」:形状照查,推断结果保留。下面直接对比。

} @@ -688,10 +682,8 @@ export default function MindsetPage() { zh={ <> 上面那份对照是写好的。下面这个窗口里跑的是真正的 TypeScript - 编译器,就在你的浏览器里。同一个对象的四份稿子藏在标题栏的按钮后面, - 彼此只差一行。切着看,并且点一下 theme{" "} - 这个词 —— 编辑器下面那条显示的是编译器推断出的类型, - 不是我们写上去的。 + 编译器,就在你的浏览器里。同一个对象的四份稿子藏在标题栏的按钮后面,彼此只差一行。切着看,并且点一下 theme{" "} + 这个词 —— 编辑器下面那条显示的是编译器推断出的类型,不是我们写上去的。 } /> @@ -724,8 +716,7 @@ export default function MindsetPage() { the types '"dark"' and '"light"' have no overlap.
{" "} - 这一处报错本身就是三种写法的差别,由编译器说出口: - 只有 satisfies 之后,它还记得 theme{" "} + 这一处报错本身就是三种写法的差别,由编译器说出口:只有 satisfies 之后,它还记得 theme{" "} 是 "dark"。 } @@ -783,11 +774,8 @@ export default function MindsetPage() { } zh={ <> - 第四份稿子值得花一分钟。maxSugar 根本不在里面, - 而这个文件一处报错都没有 —— 包括调用{" "} - config.maxSugar.toFixed(1) 那一行, - 它在运行时是一个必然发生的 TypeError。 - 现在你自己在编辑器里把那个 as Config 改成{" "} + 第四份稿子值得花一分钟。maxSugar 根本不在里面,而这个文件一处报错都没有 —— 包括调用{" "} + config.maxSugar.toFixed(1) 那一行,它在运行时是一个必然发生的 TypeError。现在你自己在编辑器里把那个 as Config 改成{" "} : Config,编译器立刻回话: TS2741: Property 'maxSugar' is missing in type @@ -827,16 +815,14 @@ export default function MindsetPage() { <> 但 as 不是万能通行证 —— 这一点值得弄准,不要靠猜。它要求两个类型仍然 - 可比较(comparable):其中一个能赋给另一个。 - 少一个字段是过关的,因为 Config{" "} + 可比较(comparable):其中一个能赋给另一个。少一个字段是过关的,因为 Config{" "} 可以赋给你写下的那个更小的形状。而把 maxSugar: 7{" "} 改成 maxSugar: "7" 就过不去: TS2352: Conversion of type … may be a mistake because neither type sufficiently overlaps with the other. {" "} - 所以关于 as 的老实说法比「什么都放行」 - 更窄、也更有用: + 所以关于 as 的老实说法比「什么都放行」更窄、也更有用: 它不会替你发现漏掉的东西,但明摆着矛盾的它仍然拒绝。 } @@ -946,9 +932,7 @@ export default function MindsetPage() { 值得注解的地方有两处。函数参数 —— 参数来自外部,推断没有材料可读。对外的边界 —— 导出的函数、模块 API、共享数据 —— - 那里的类型是给别的代码的承诺,写下来, - 编译器才会去检查这份承诺,而不是照抄你碰巧返回了什么。 - 除此之外,交给推断。 + 那里的类型是给别的代码的承诺,写下来,编译器才会去检查这份承诺,而不是照抄你碰巧返回了什么。除此之外,交给推断。

} @@ -984,10 +968,8 @@ export default function MindsetPage() { zh={ <> as 不是坏东西 —— - 它就是为「你确实比编译器知道得多」的场合准备的。 - 判断标准只有一条:你的信息从哪来? - 来自你亲自确认过的事实,这个断言就合理; - 来自「另一种情况我懒得处理」,就不合理。 + 它就是为「你确实比编译器知道得多」的场合准备的。判断标准只有一条:你的信息从哪来? + 来自你亲自确认过的事实,这个断言就合理;来自「另一种情况我懒得处理」,就不合理。 } /> @@ -1018,10 +1000,7 @@ export default function MindsetPage() { zh={ <> querySelector 返回{" "} - Element | null。编译器没读过你的 HTML, - 你读过 —— 这份信息差让断言变得合理。 - 但它仍然是一份承诺:哪天页面结构改了, - 这一行就会静悄悄地变成错的。 + Element | null。编译器没读过你的 HTML,你读过 —— 这份信息差让断言变得合理。但它仍然是一份承诺:哪天页面结构改了,这一行就会静悄悄地变成错的。 } /> @@ -1051,9 +1030,7 @@ export default function MindsetPage() { zh={ <> 请求回来的 JSON、localStorage、用户输入 —— - 这些数据的形状不由你的代码库决定。 - 这里的 as 不是知识, - 而是把一个猜测写成了事实。正确做法见 §03。 + 这些数据的形状不由你的代码库决定。这里的 as 不是知识,而是把一个猜测写成了事实。正确做法见 §03。 } /> @@ -1090,13 +1067,9 @@ export default function MindsetPage() { zh={ <>

- 当两个类型毫无交集时,直接 as 会被拒绝, - 于是有人写 x as unknown as T 分两步硬转。 - 它的真实含义是: + 当两个类型毫无交集时,直接 as 会被拒绝,于是有人写 x as unknown as T 分两步硬转。它的真实含义是: 「把编译器对这个值的全部认知丢掉,然后让我重新贴标签」 - 。在测试代码里,它有时是个合理的逃生口; - 出现在业务代码里,通常说明类型建模本身有问题, - 该改的是模型。 + 。在测试代码里,它有时是个合理的逃生口;出现在业务代码里,通常说明类型建模本身有问题,该改的是模型。

} @@ -1130,15 +1103,12 @@ export default function MindsetPage() { zh={ <>

- 一个类型系统是健全的(sound), - 意思是通过检查的程序不会以类型宣称不可能的方式出错。 - TypeScript 并不健全,而这是设计决定,不是缺陷 —— + 一个类型系统是健全的(sound),意思是通过检查的程序不会以类型宣称不可能的方式出错。TypeScript 并不健全,而这是设计决定,不是缺陷 —— 完全健全的系统会拒绝大量普通的 JavaScript, TypeScript 用一部分保证换取了在真实代码上可用。

- 知道具体的口子在哪很有用, - 因为正是在这些地方,「编译通过」什么也不能说明。 + 知道具体的口子在哪很有用,因为正是在这些地方,「编译通过」什么也不能说明。

} @@ -1171,12 +1141,9 @@ export default function MindsetPage() { } zh={ <> - 这几行现在都不报错,而四种情况在运行时都可能出问题。 - 这不是不该信任 TypeScript 的理由, - 而是这一章后面几节存在的理由: + 这几行现在都不报错,而四种情况在运行时都可能出问题。这不是不该信任 TypeScript 的理由,而是这一章后面几节存在的理由: - 少用 any 和 as, - 在边界上自己检查数据,项目扛得住时打开{" "} + 少用 any 和 as,在边界上自己检查数据,项目扛得住时打开{" "} noUncheckedIndexedAccess 。 @@ -1214,11 +1181,7 @@ export default function MindsetPage() { } zh={ <> - 类型在编译时会被擦除。这带来一个冷酷的结论: - 你写的每一个 interface 在线上都不存在, - 从外面进来的数据没有任何东西替你校验。 - 一个描述接口返回值的 interface, - 是对数据的一个声称,不是保证。逐帧看一遍。 + 类型在编译时会被擦除。这带来一个冷酷的结论:你写的每一个 interface 在线上都不存在,从外面进来的数据没有任何东西替你校验。一个描述接口返回值的 interface,是对数据的一个声称,不是保证。逐帧看一遍。 } /> @@ -1258,15 +1221,11 @@ export default function MindsetPage() {

很多人以为类型报错会让一切停下,其实不会。默认情况下, tsc 会报出错误,同时照样写出 JavaScript 文件 - ;想让它停下,需要开 noEmitOnError。 - 不少构建工具走得更远:它们直接删掉类型,根本不做检查, - 于是这个类型错误在构建过程中连出现的机会都没有。 + ;想让它停下,需要开 noEmitOnError。不少构建工具走得更远:它们直接删掉类型,根本不做检查,于是这个类型错误在构建过程中连出现的机会都没有。

- 所以那条红线是一条消息,不是一堵墙。 - 这一点值得直说,因为它解释了一个常见的困惑: - 有类型错误的代码照样能跑,也照样能发布。 - 把类型检查放进持续集成,才是把消息变成墙。 + 所以那条红线是一条消息,不是一堵墙。这一点值得直说,因为它解释了一个常见的困惑: + 有类型错误的代码照样能跑,也照样能发布。把类型检查放进持续集成,才是把消息变成墙。

} @@ -1285,8 +1244,7 @@ export default function MindsetPage() { zh={ <> 站在边界上的那个 isOrder,就是第 03 章学过的 - 类型谓词(type predicate): - 在值这一层做检查,换来类型这一层的收窄。 + 类型谓词(type predicate):在值这一层做检查,换来类型这一层的收窄。 } /> @@ -1312,9 +1270,7 @@ export default function MindsetPage() { } zh={ <> - 注意第 10 行的 as:它只活在函数内部, - 只为逐个读取字段服务,而且每个字段之后真的被检查了。 - 这是断言给检查打下手,不是断言替检查上岗。 + 注意第 10 行的 as:它只活在函数内部,只为逐个读取字段服务,而且每个字段之后真的被检查了。这是断言给检查打下手,不是断言替检查上岗。 } /> @@ -1332,8 +1288,7 @@ export default function MindsetPage() { } zh={ <> - 这些都是断言,读到断言就该想验一下。 - 下面这个窗口就是验它的:同一道边界的三份稿子,由真编译器判。 + 这些都是断言,读到断言就该想验一下。下面这个窗口就是验它的:同一道边界的三份稿子,由真编译器判。 } /> @@ -1385,10 +1340,8 @@ export default function MindsetPage() { TS18046: 'data' is of type 'unknown'.
{" "} 这不是编译器为难你,是编译器实话实说: - JSON.parse 返回的就是字符串里当时装着的东西, - 在它上面读 .total 是猜。切到 - 加类型谓词,报错没了 —— 不是因为你把它捂住了, - 而是因为 isOrder 真的先看了一眼那个值。 + JSON.parse 返回的就是字符串里当时装着的东西,在它上面读 .total 是猜。切到 + 加类型谓词,报错没了 —— 不是因为你把它捂住了,而是因为 isOrder 真的先看了一眼那个值。 } /> @@ -1416,12 +1369,10 @@ export default function MindsetPage() { data.totl.toFixed(2),以及连装都不装的{" "} data.anything.at.all()。 - unknown 和 any 说的都是「我不知道这是什么」, - 差别在下一步 + unknown 和 any 说的都是「我不知道这是什么」,差别在下一步 :unknown 逼你去弄清楚,any{" "} - 是你说什么它都点头。站在边界上,你选的就是这两句话之一, - 而其中一句等于决定不再检查。 + 是你说什么它都点头。站在边界上,你选的就是这两句话之一,而其中一句等于决定不再检查。 } /> @@ -1460,14 +1411,12 @@ export default function MindsetPage() { <>

真实项目里形状会变大,手写的校验函数又长又容易写漏。 - zod 这类运行时校验库解决的正是这件事: - 用代码描述一次形状,校验函数和 TypeScript + zod 这类运行时校验库解决的正是这件事:用代码描述一次形状,校验函数和 TypeScript 类型都从这一份描述里生成。

本课不展开,记一句就够: - 它们存在,是因为类型被擦除了,运行时得有人来查。 - 思路和你刚读的 isOrder 完全一样。 + 它们存在,是因为类型被擦除了,运行时得有人来查。思路和你刚读的 isOrder 完全一样。

} @@ -1505,10 +1454,7 @@ export default function MindsetPage() { <> 前面十一章一直在说「少用 any」,这里说句公道话: any 有它的正当用途 —— 迁移期的旧代码 - (第 10 章讲的过渡状态),以及真正动态的边界, - 比如 eval 的结果、 - 形状没有任何文档的第三方回调。 - 纪律只有一条:把它锁在能跑通的最小范围里。 + (第 10 章讲的过渡状态),以及真正动态的边界,比如 eval 的结果、形状没有任何文档的第三方回调。纪律只有一条:把它锁在能跑通的最小范围里。 } /> @@ -1537,8 +1483,7 @@ export default function MindsetPage() { zh={ <> any 会扩散:从 any{" "} - 值上取出来的东西还是 any。 - 一旦它进入导出签名,项目里所有调用方也一起失去了检查。 + 值上取出来的东西还是 any。一旦它进入导出签名,项目里所有调用方也一起失去了检查。 } /> @@ -1605,15 +1550,11 @@ export default function MindsetPage() { <>

两者都能接收任何值,差别在于之后你能拿它做什么。 - unknown 必须先收窄才能使用, - 编译器会逼你补上检查;any 允许任何用法, - 所以不需要检查,也就没有检查。 + unknown 必须先收窄才能使用,编译器会逼你补上检查;any 允许任何用法,所以不需要检查,也就没有检查。

因此拿不准类型时,第一反应是 unknown —— - 它是类型安全的「我还不知道」。 - 只有当 unknown 确实让代码写不下去时, - 才降级到 any,并且锁在尽可能小的作用域里。 + 它是类型安全的「我还不知道」。只有当 unknown 确实让代码写不下去时,才降级到 any,并且锁在尽可能小的作用域里。

} @@ -1646,10 +1587,7 @@ export default function MindsetPage() { } zh={ <> - 同一个订单状态,两种建模。左边每个字段都可选, - 于是不可能存在的组合可以随便造; - 右边用第 03 章的可辨识联合, - 把「什么状态下有什么字段」写进了类型。 + 同一个订单状态,两种建模。左边每个字段都可选,于是不可能存在的组合可以随便造;右边用第 03 章的可辨识联合,把「什么状态下有什么字段」写进了类型。 } /> @@ -1677,8 +1615,7 @@ export default function MindsetPage() { } zh={ <> - 你可以在文档里写「status 为 paid 时才有 paidAt」。 - 文档不会报错,代码也不读文档 —— + 你可以在文档里写「status 为 paid 时才有 paidAt」。文档不会报错,代码也不读文档 —— 这个约定只活在某个人的记忆里。 } @@ -1713,10 +1650,8 @@ export default function MindsetPage() { } zh={ <> - 写完 switch (order.status), - 每个分支里恰好有该状态的字段,漏掉的状态由{" "} - never 兜住。第 03 章给了你技术, - 这一章给的是用它的理由: + 写完 switch (order.status),每个分支里恰好有该状态的字段,漏掉的状态由{" "} + never 兜住。第 03 章给了你技术,这一章给的是用它的理由: 把约定写进类型,而不是写在旁边的注释里。 } @@ -1759,16 +1694,12 @@ export default function MindsetPage() { <>

前四条心法讲的是怎么和编译器相处,这一条讲的是 - 类型系统真正的用途:它不是拼写检查器, - 而是一门描述你的问题域规则的语言。 - 「订单没付钱就不可能已送达」就是这样一条规则 —— + 类型系统真正的用途:它不是拼写检查器,而是一门描述你的问题域规则的语言。「订单没付钱就不可能已送达」就是这样一条规则 —— 写进类型之后,今后每一次破坏它的改动都编译不过。

- 注意这个联合是靠什么区分的:status, - 一个运行时真实存在的属性。它没法靠类型的名字来区分 —— - 因为 TypeScript 比较的是类型的形状,不是名字, - 而名字在编译之后就没了。这套比较规则,第 04 章讲得很细。 + 注意这个联合是靠什么区分的:status,一个运行时真实存在的属性。它没法靠类型的名字来区分 —— + 因为 TypeScript 比较的是类型的形状,不是名字,而名字在编译之后就没了。这套比较规则,第 04 章讲得很细。

} @@ -1803,9 +1734,7 @@ export default function MindsetPage() { zh={ <> 第一个:MyOmit —— 重写第 06 章起你一直在用的{" "} - Omit。零件全来自第 07 章: - 映射类型遍历键,as 键重映射(TypeScript 4.1) - 负责把不要的键去掉。 + Omit。零件全来自第 07 章:映射类型遍历键,as 键重映射(TypeScript 4.1)负责把不要的键去掉。 } /> @@ -1828,8 +1757,7 @@ export default function MindsetPage() { } zh={ <> - never 在这里又立了一功: - 被映射成 never 的键会从结果里消失 —— + never 在这里又立了一功:被映射成 never 的键会从结果里消失 —— 在类型层面的代码里,never 是通用的「删除」手段。 } @@ -1850,8 +1778,7 @@ export default function MindsetPage() { zh={ <> 第二个,难一些:DeepReadonly。内置的{" "} - Readonly 只锁最外一层,嵌套对象里面照样能改。 - 想全锁住,让映射类型调用它自己。 + Readonly 只锁最外一层,嵌套对象里面照样能改。想全锁住,让映射类型调用它自己。 } /> @@ -1876,10 +1803,7 @@ export default function MindsetPage() { } zh={ <> - 条件类型(第 07 章)+ 映射类型(第 07 章)+ 递归, - 就得到一个新工具。严谨的版本还要处理函数和数组: - 函数也是 object,往里递归没有意义。 - 那是 type-challenges 上 medium 难度的题,值得你自己做一遍。 + 条件类型(第 07 章)+ 映射类型(第 07 章)+ 递归,就得到一个新工具。严谨的版本还要处理函数和数组:函数也是 object,往里递归没有意义。那是 type-challenges 上 medium 难度的题,值得你自己做一遍。 } /> @@ -1906,9 +1830,7 @@ export default function MindsetPage() { zh={ <> github.com/type-challenges/type-challenges - 是社区维护的类型题库,难度从 easy 到 extreme。 - 每道题都能在 Playground 里做,自带测试用例,当场就有结果。 - 你刚写的 MyOmit 和 DeepReadonly 都是里面的原题。 + 是社区维护的类型题库,难度从 easy 到 extreme。每道题都能在 Playground 里做,自带测试用例,当场就有结果。你刚写的 MyOmit 和 DeepReadonly 都是里面的原题。 } /> @@ -1931,9 +1853,7 @@ export default function MindsetPage() { } zh={ <> - typescriptlang.org/docs 下的 Handbook 是第一手资料: - 本课讲过的每个概念,那里都有权威版本。 - 以你现在的水平读它不再吃力 —— 这正是这门课的目的之一。 + typescriptlang.org/docs 下的 Handbook 是第一手资料:本课讲过的每个概念,那里都有权威版本。以你现在的水平读它不再吃力 —— 这正是这门课的目的之一。 } /> @@ -1957,10 +1877,7 @@ export default function MindsetPage() { } zh={ <> - typescriptlang.org/play 免注册、可分享链接、 - 能切换 TypeScript 版本、能看编译产物。 - 行为拿不准的时候别猜,贴进去看答案。 - 这个习惯比这门课里的任何一个知识点都值钱。 + typescriptlang.org/play 免注册、可分享链接、能切换 TypeScript 版本、能看编译产物。行为拿不准的时候别猜,贴进去看答案。这个习惯比这门课里的任何一个知识点都值钱。 } /> @@ -2046,13 +1963,8 @@ export default function MindsetPage() { zh={ <>

- 序章那天,你还在问「类型是干嘛的,JavaScript 不是跑得好好的」。 - 现在你能读懂编译器报错里的每一个词, - 能为请求回来的数据设计校验边界, - 能用可辨识联合让非法状态写不出来,能自己实现{" "} - Omit, - 还能给一个老的 JavaScript 项目排一份分步迁移计划。 - 十二章没有白走。 + 序章那天,你还在问「类型是干嘛的,JavaScript 不是跑得好好的」。现在你能读懂编译器报错里的每一个词,能为请求回来的数据设计校验边界,能用可辨识联合让非法状态写不出来,能自己实现{" "} + Omit,还能给一个老的 JavaScript 项目排一份分步迁移计划。十二章没有白走。

} @@ -2085,12 +1997,9 @@ export default function MindsetPage() { } zh={ <> - 用 Vite + TypeScript 起一个项目, - 把贯穿全书的点单系统实现出来:MenuItem、 - 用可辨识联合写的 Order、一个泛型容器、 + 用 Vite + TypeScript 起一个项目,把贯穿全书的点单系统实现出来:MenuItem、用可辨识联合写的 Order、一个泛型容器、 Partial 草稿单、边界上的{" "} - isOrder。strict 全开, - 再把 noUncheckedIndexedAccess 也加上。 + isOrder。strict 全开,再把 noUncheckedIndexedAccess 也加上。 } /> @@ -2122,9 +2031,7 @@ export default function MindsetPage() { } zh={ <> - type-challenges 从 easy 刷起,每天一道;做不出来就看讨论区, - 那里有社区里很聪明的写法。刷完 easy 和 medium, - 再读开源库的类型定义就不会发怵了。 + type-challenges 从 easy 刷起,每天一道;做不出来就看讨论区,那里有社区里很聪明的写法。刷完 easy 和 medium,再读开源库的类型定义就不会发怵了。 } /> @@ -2150,11 +2057,8 @@ export default function MindsetPage() { } zh={ <> - 打开你最常用的 JavaScript 库, - 去 node_modules 里读它的 .d.ts; - 再去 DefinitelyTyped 看看 @types{" "} - 是怎么给没有类型的库补类型的。 - 先读懂别人的类型设计,才谈得上设计自己的。 + 打开你最常用的 JavaScript 库,去 node_modules 里读它的 .d.ts;再去 DefinitelyTyped 看看 @types{" "} + 是怎么给没有类型的库补类型的。先读懂别人的类型设计,才谈得上设计自己的。 } /> @@ -2203,21 +2107,16 @@ export default function MindsetPage() { <>

最后说一句。这门课教的不是语法,是一种看待程序的方式: - 每个值都有形状,而形状可以被描述、被检查、被推导。 - 你脑子里那些默契 ——「这里不会是 null」 - 「这个状态下一定有那个字段」—— + 每个值都有形状,而形状可以被描述、被检查、被推导。你脑子里那些默契 ——「这里不会是 null」「这个状态下一定有那个字段」—— 统统可以写下来,交给一个不会累、也不会忘的审查员。

- 它不会替你抓住所有问题。类型会被擦除, - 类型系统并不完全健全,外部进来的数据仍然归你负责。 - 但它能抓住的那些,会抓得很早 —— + 它不会替你抓住所有问题。类型会被擦除,类型系统并不完全健全,外部进来的数据仍然归你负责。但它能抓住的那些,会抓得很早 —— 早到你还在看着造成问题的那段代码。

- 往后你写任何语言,都会先问一句「这里的约定是什么」。 - 这个习惯,才是这门课真正教给你的东西。 + 往后你写任何语言,都会先问一句「这里的约定是什么」。这个习惯,才是这门课真正教给你的东西。

@@ -2242,8 +2141,7 @@ export default function MindsetPage() { ), zh: ( <> - 类型是写下来的约定:写下来的,编译器替你守; - 只记在脑子里的,只能靠运气。 + 类型是写下来的约定:写下来的,编译器替你守;只记在脑子里的,只能靠运气。 ), }, @@ -2259,8 +2157,7 @@ export default function MindsetPage() { zh: ( <> 三种写法:注解检查但拓宽,as 既不检查也不保留, - satisfies 两样都做。需要精确保留字面量时, - 再加 as const。 + satisfies 两样都做。需要精确保留字面量时,再加 as const。 ), }, @@ -2276,9 +2173,7 @@ export default function MindsetPage() { ), zh: ( <> - 类型在编译期被擦除,所以运行时没有任何类型检查。 - 外部数据一律用 unknown 接收, - 再用你自己写的检查收窄 —— + 类型在编译期被擦除,所以运行时没有任何类型检查。外部数据一律用 unknown 接收,再用你自己写的检查收窄 —— 让错误发生在边界,而不是三个文件之外。 ), @@ -2294,8 +2189,7 @@ export default function MindsetPage() { ), zh: ( <> - 类型系统是刻意不完全健全的:any、断言、 - 数组协变、未开检查的下标访问,都是真实存在的口子 —— + 类型系统是刻意不完全健全的:any、断言、数组协变、未开检查的下标访问,都是真实存在的口子 —— 「编译通过」不等于「代码正确」。 ), @@ -2311,9 +2205,7 @@ export default function MindsetPage() { ), zh: ( <> - any 在迁移期和真正动态的边界上是正当的。 - 纪律是控制范围:内部可以乱,导出签名必须精确。 - 先用 unknown。 + any 在迁移期和真正动态的边界上是正当的。纪律是控制范围:内部可以乱,导出签名必须精确。先用 unknown。 ), }, @@ -2328,9 +2220,7 @@ export default function MindsetPage() { ), zh: ( <> - 类型最有价值的用途是建模:可辨识联合让非法状态写不出来, - 约定就不再只是一句注释。拿不准的时候, - 去 Playground 验证,别猜。 + 类型最有价值的用途是建模:可辨识联合让非法状态写不出来,约定就不再只是一句注释。拿不准的时候,去 Playground 验证,别猜。 ), }, diff --git a/app/mindset/viz.tsx b/app/mindset/viz.tsx index c971f81..d158ed6 100644 --- a/app/mindset/viz.tsx +++ b/app/mindset/viz.tsx @@ -188,8 +188,7 @@ const TRIO_TESTS: { ), zh: ( <> - 断言只要求两个类型有足够的重叠,能互相当成对方。 - 多余属性检查完全不做,拼错的字段就这样带着 Config{" "} + 断言只要求两个类型有足够的重叠,能互相当成对方。多余属性检查完全不做,拼错的字段就这样带着 Config{" "} 的名分通过了。 ), @@ -265,10 +264,8 @@ const TRIO_TESTS: { ), zh: ( <> - Config 可以赋给你写的这个更小的形状, - 两个类型有重叠,断言就通过了。运行时{" "} - config.maxSugar 是 undefined, - 拿它做算术会得到 NaN。 + Config 可以赋给你写的这个更小的形状,两个类型有重叠,断言就通过了。运行时{" "} + config.maxSugar 是 undefined,拿它做算术会得到 NaN。 ), }, @@ -320,9 +317,7 @@ const TRIO_TESTS: { ), zh: ( <> - 注解把推断结果换成了你声明的类型。 - 编译器从此只知道「是两者之一」,不知道具体是哪一个。 - 写 {'if (config.theme === "light")'}{" "} + 注解把推断结果换成了你声明的类型。编译器从此只知道「是两者之一」,不知道具体是哪一个。写 {'if (config.theme === "light")'}{" "} 它仍然认为有可能成立。 ), @@ -341,8 +336,7 @@ const TRIO_TESTS: { ), zh: ( <> - 断言同样把字面量信息换成了 Config。 - 检查没做,精确的推断也丢了 —— 两头都没落着。 + 断言同样把字面量信息换成了 Config。检查没做,精确的推断也丢了 —— 两头都没落着。 ), }, @@ -363,8 +357,7 @@ const TRIO_TESTS: { zh: ( <> 检查照做,推断不动 —— config.theme 依然是字面量类型{" "} - "dark"。「既要校验又要精确推断」, - 正是 TypeScript 4.9 加入 satisfies 的原因。 + "dark"。「既要校验又要精确推断」,正是 TypeScript 4.9 加入 satisfies 的原因。 ), }, @@ -451,7 +444,7 @@ export function TrioLab() { } zh={ <> - 总结:注解 检查 ✓ 推断 ✕ · as 检查 ✕ 推断 ✕ · + 总结:注解检查 ✓ 推断 ✕ · as 检查 ✕ 推断 ✕ · satisfies 检查 ✓ 推断 ✓ —— 两样都想要,用 satisfies。 } @@ -535,9 +528,7 @@ const EF_FRAMES: FlowFrame[] = [ } zh={ <> - 一次请求带回一段 JSON。你写下 as Order, - 编译器不再检查这个值,直接接受。 - 从这里开始,在编译器眼里它的类型就是 Order。 + 一次请求带回一段 JSON。你写下 as Order,编译器不再检查这个值,直接接受。从这里开始,在编译器眼里它的类型就是 Order。 } /> @@ -567,9 +558,7 @@ const EF_FRAMES: FlowFrame[] = [ } zh={ <> - 编译产物是普通 JavaScript。Order 这个名字在运行时并不存在, - 门口也没有任何检查。这就是类型擦除: - 编译器能检查你写的代码,检查不了别人发来的数据。 + 编译产物是普通 JavaScript。Order 这个名字在运行时并不存在,门口也没有任何检查。这就是类型擦除:编译器能检查你写的代码,检查不了别人发来的数据。 } /> @@ -607,13 +596,11 @@ const EF_FRAMES: FlowFrame[] = [ } zh={ <> - 某天后端把 total 改名成 amount。 - JSON 照样进来, + 某天后端把 total 改名成 amount。JSON 照样进来, order.total 变成 undefined - ,一路传过三个文件,直到报表页才出错。 - 出错的地方离原因越远,排查越费时间。 + ,一路传过三个文件,直到报表页才出错。出错的地方离原因越远,排查越费时间。 } /> @@ -642,9 +629,7 @@ const EF_FRAMES: FlowFrame[] = [ } zh={ <> - 换一种做法:外部数据先标成 unknown。 - 这样编译器会拒绝一切用法,直到你把它收窄, - 于是你在边界上写一个检查函数 —— + 换一种做法:外部数据先标成 unknown。这样编译器会拒绝一切用法,直到你把它收窄,于是你在边界上写一个检查函数 —— 这个检查就是类型系统替你做不了的那部分。 } @@ -682,10 +667,8 @@ const EF_FRAMES: FlowFrame[] = [ } zh={ <> - 坏数据再来一次,isOrder 在门口拦下, - 一行日志就能说清问题。错误仍然会发生,只是 - 发生在边界,而不是三个文件之外。 - 「用 unknown 接收,再用检查收窄」的价值就在这里。 + 坏数据再来一次,isOrder 在门口拦下,一行日志就能说清问题。错误仍然会发生,只是 + 发生在边界,而不是三个文件之外。「用 unknown 接收,再用检查收窄」的价值就在这里。 } /> diff --git a/app/modules/page.tsx b/app/modules/page.tsx index b1fe1e5..c100685 100644 --- a/app/modules/page.tsx +++ b/app/modules/page.tsx @@ -655,11 +655,8 @@ export default function ModulesPage() { ), zh: ( <> - 模块把自己的顶层名字留在自己手里; - 声明文件描述一个模块导出了什么,而不包含任何代码。 - 这一章讲这几件事:什么让一个文件成为模块、一行 import - 到底带着什么,以及当一个 JavaScript 库自己没有类型时, - 它的类型从哪里来。 + 模块把自己的顶层名字留在自己手里;声明文件描述一个模块导出了什么,而不包含任何代码。这一章讲这几件事:什么让一个文件成为模块、一行 import + 到底带着什么,以及当一个 JavaScript 库自己没有类型时,它的类型从哪里来。 ), }} @@ -717,10 +714,8 @@ export default function ModulesPage() { zh={ <> 先说其他一切都建立在上面的那条规则。一个文件只有在顶层写了{" "} - import 或 export 时,才是模块; - 两者都没有的文件是脚本,它的顶层声明会进入 - 全局作用域 —— 而程序里所有脚本共用这一个作用域。 - 这就是为什么两个从没打算共享名字的文件会撞车。 + import 或 export 时,才是模块;两者都没有的文件是脚本,它的顶层声明会进入 + 全局作用域 —— 而程序里所有脚本共用这一个作用域。这就是为什么两个从没打算共享名字的文件会撞车。 } /> @@ -745,8 +740,7 @@ export default function ModulesPage() { } zh={ <> - export {"{}"} 什么都不导出, - 它唯一的作用就是让这个文件获得模块作用域。 + export {"{}"} 什么都不导出,它唯一的作用就是让这个文件获得模块作用域。 §04 还会再见到它 —— declare global{" "} 需要这个前提。 @@ -785,15 +779,12 @@ export default function ModulesPage() {

tsconfig.json 里的 module{" "} 选的是输出格式:esnext、 - commonjs 之类。 - 它不决定某个文件是不是模块 —— + commonjs 之类。它不决定某个文件是不是模块 —— 只有文件自己能决定,靠顶层的 import 或 export。

- 这一点在 .d.ts 里最要紧, - 因为那种文件很容易两者都没有。同一段{" "} - declare module 放在哪种文件里, - 含义完全不同。§04 会把两种都演一遍。 + 这一点在 .d.ts 里最要紧,因为那种文件很容易两者都没有。同一段{" "} + declare module 放在哪种文件里,含义完全不同。§04 会把两种都演一遍。

} @@ -816,12 +807,10 @@ export default function ModulesPage() { zh={ <> 接下来是你会遇到的两套模块系统。ES 模块(ESM)用{" "} - import 和 export, - 它的导入会被提升 —— + import 和 export,它的导入会被提升 —— 所有被导入的模块都在当前文件第一条语句之前加载并执行完 —— 而且导入清单不需要运行任何代码就能读出来。CommonJS - (CJS)用 require,那是一次普通的函数调用, - 写在哪里就在哪里执行。 + (CJS)用 require,那是一次普通的函数调用,写在哪里就在哪里执行。 } /> @@ -874,15 +863,11 @@ export default function ModulesPage() { zh={ <>

- ESM 的 import / export 声明必须写在顶层,名字也是固定的, - 所以打包器不运行程序就能算出哪些导出被用到, - 再把没用到的导出从产物里去掉。这种去除叫{" "} + ESM 的 import / export 声明必须写在顶层,名字也是固定的,所以打包器不运行程序就能算出哪些导出被用到,再把没用到的导出从产物里去掉。这种去除叫{" "} tree shaking。

- require 给不了这个保证: - 它可以写在 if 里面,可以接一个算出来的路径, - 每次还可能返回不同的对象。所以打包器通常只能把整个模块留下。 + require 给不了这个保证:它可以写在 if 里面,可以接一个算出来的路径,每次还可能返回不同的对象。所以打包器通常只能把整个模块留下。

} @@ -924,16 +909,12 @@ export default function ModulesPage() { export default x 在 ES 模块上创建一个名叫{" "} default 的具名导出; module.exports = x{" "} - 则是把 CommonJS 模块的整个导出对象换掉。 - 所以一个把函数赋给 module.exports 的 CommonJS + 则是把 CommonJS 模块的整个导出对象换掉。所以一个把函数赋给 module.exports 的 CommonJS 模块,并没有一个 default 属性给 ESM 的默认导入去读。

- esModuleInterop 就是搭在这两者之间的桥: - 它让编译器产出一个小助手 __importDefault, - 把非 ESM 的导出包成 {"{ default: … }"}, - 默认导入这才有东西可拿。它同时也改了类型规则 —— + esModuleInterop 就是搭在这两者之间的桥:它让编译器产出一个小助手 __importDefault,把非 ESM 的导出包成 {"{ default: … }"},默认导入这才有东西可拿。它同时也改了类型规则 —— 不开这个开关,那句默认导入会直接被拒绝。

@@ -990,8 +971,7 @@ export default function ModulesPage() { zh={ <> 编译时类型会被擦除。那么,把类型带进来的那行{" "} - import 会怎么样? - 把下面的面板从「编译前」切到「编译后」看看。 + import 会怎么样?把下面的面板从「编译前」切到「编译后」看看。 } /> @@ -1039,9 +1019,7 @@ export default function ModulesPage() { <>

tsc 看得到整个项目,所以它自己就能算出{" "} - Order 只是类型、这行导入可以删。 - 而 Vite、esbuild、SWC 一次只转译一个文件, - 它们分不清 Order 是值还是类型 —— + Order 只是类型、这行导入可以删。而 Vite、esbuild、SWC 一次只转译一个文件,它们分不清 Order 是值还是类型 —— 删错一行导入,程序会在运行时坏掉。

@@ -1050,15 +1028,12 @@ export default function ModulesPage() { 语句会被原样输出,导入类型时不写 type 会报{" "} ts(1484),"'Order' is a type and must be imported using a type-only import when - 'verbatimModuleSyntax' is enabled."; - 转手导出类型时不写 export type 则报对应的{" "} + 'verbatimModuleSyntax' is enabled.";转手导出类型时不写 export type 则报对应的{" "} ts(1205)。

还有第二个理由,和工具链无关:.js{" "} - 模块被加载时可以执行代码。 - 一行纯类型导入如果活到了产物里, - 就会在运行时白白加载那个模块一次。 + 模块被加载时可以执行代码。一行纯类型导入如果活到了产物里,就会在运行时白白加载那个模块一次。

} @@ -1083,9 +1058,7 @@ export default function ModulesPage() { } zh={ <> - 一个好习惯:某个名字只出现在类型位置上, - 导入时就给它标上 type。 - 编辑器的自动导入现在默认就是这么做的。 + 一个好习惯:某个名字只出现在类型位置上,导入时就给它标上 type。编辑器的自动导入现在默认就是这么做的。 } /> @@ -1108,13 +1081,9 @@ export default function ModulesPage() { } zh={ <> - 下面这个窗口里跑的是真编译器,它手上只有一个文件, - 所以没有别的模块可以 import。 - 同一条分界线在导出这一侧照样看得见:四个声明,其中两个是类型。 - 切到编译产物 JS —— TAX 和{" "} + 下面这个窗口里跑的是真编译器,它手上只有一个文件,所以没有别的模块可以 import。同一条分界线在导出这一侧照样看得见:四个声明,其中两个是类型。切到编译产物 JS —— TAX 和{" "} receiptLine 在,Size、 - Order 和整行 export type 都不在。 - 产物就是浏览器要跑的东西,它压根不知道那两个类型存在过。 + Order 和整行 export type 都不在。产物就是浏览器要跑的东西,它压根不知道那两个类型存在过。 } /> @@ -1154,13 +1123,10 @@ export default function ModulesPage() { zh={ <> 现在换上把 type 去掉。把 Order 和{" "} - Size 当值导出,会得到两条 ts(1205)。 - 有个细节值得说清:这个实验室一次只编译一个文件 —— - 和打包器一样 —— 所以 isolatedModules 一直开着, - 报错里点名的就是它。把 verbatimModuleSyntax{" "} + Size 当值导出,会得到两条 ts(1205)。有个细节值得说清:这个实验室一次只编译一个文件 —— + 和打包器一样 —— 所以 isolatedModules 一直开着,报错里点名的就是它。把 verbatimModuleSyntax{" "} 打开,同一条报错只是改口点了另一个的名字。两个选项,同一条规则 - —— 类型必须以类型的身份导出, - 因为负责删掉这一行的工具,手上只有这一个文件可看。 + —— 类型必须以类型的身份导出,因为负责删掉这一行的工具,手上只有这一个文件可看。 } /> @@ -1195,16 +1161,11 @@ export default function ModulesPage() { zh={ <>

- 两个 ES 模块可以互相导入,规范允许, - 类型检查也不会报任何东西。仍然可能出问题的是顺序: - 模块是一个接一个执行的, - 如果一方在另一方还没走到初始化那一行时就去读它的绑定, - 这次读取会抛 ReferenceError。 + 两个 ES 模块可以互相导入,规范允许,类型检查也不会报任何东西。仍然可能出问题的是顺序:模块是一个接一个执行的,如果一方在另一方还没走到初始化那一行时就去读它的绑定,这次读取会抛 ReferenceError。

晚一点再读没问题 —— - 比如放在两个模块都执行完之后才被调用的函数里。 - 实际的解法是把共享的声明挪到第三个模块,两边都从那里导入。 + 比如放在两个模块都执行完之后才被调用的函数里。实际的解法是把共享的声明挪到第三个模块,两边都从那里导入。

} @@ -1247,9 +1208,7 @@ export default function ModulesPage() { } zh={ <> - 声明文件(.d.ts)描述一个模块对外的形状: - 每个导出,连带它的类型,除此之外什么都没有。 - 这种文件不一定要手写 —— 你让编译器生成,它就会从源码里生成: + 声明文件(.d.ts)描述一个模块对外的形状:每个导出,连带它的类型,除此之外什么都没有。这种文件不一定要手写 —— 你让编译器生成,它就会从源码里生成: } /> @@ -1289,8 +1248,7 @@ export default function ModulesPage() { } zh={ <> - 上面那一对是事先摆好的,下面这个不是: - 一段奶茶店 SDK 的切片,底下是真编译器。 + 上面那一对是事先摆好的,下面这个不是:一段奶茶店 SDK 的切片,底下是真编译器。 编译产物 JS 页签里是去掉类型的实现, 声明文件 .d.ts 页签里是去掉实现的类型 —— 同一个文件的两半,哪一半都不用手写。 @@ -1334,15 +1292,10 @@ export default function ModulesPage() { } zh={ <> - 那份 .d.ts 里有四处值得回头看。 - 没有人给 priceOf 写过返回类型, - 声明里却写着 : number —— + 那份 .d.ts 里有四处值得回头看。没有人给 priceOf 写过返回类型,声明里却写着 : number —— 编译器自己算出来,然后替你写给所有人看。 - find 出来是 MenuItem | undefined, - 因为 Array.prototype.find 可能空手而归。 - private items 只列了名字、没有类型: - 它在不在会影响类型兼容(第 08 章 §06), - 它是什么类型则不关外人的事。而 EXTRA{" "} + find 出来是 MenuItem | undefined,因为 Array.prototype.find 可能空手而归。 + private items 只列了名字、没有类型:它在不在会影响类型兼容(第 08 章 §06),它是什么类型则不关外人的事。而 EXTRA{" "} 整个不见了 —— 它从来没被导出过。 } @@ -1363,11 +1316,9 @@ export default function ModulesPage() { } zh={ <> - 换上撤掉 priceOf 的导出,看它去了哪里。 - 这个函数还在编译产物 JS 里 —— tsc{" "} + 换上撤掉 priceOf 的导出,看它去了哪里。这个函数还在编译产物 JS 里 —— tsc{" "} 会把你写的每一行都输出去;它从 .d.ts 里消失了 —— - 那份文件只描述这个模块对外提供什么。 - 一次改动,就说完了声明文件的全部职责。 + 那份文件只描述这个模块对外提供什么。一次改动,就说完了声明文件的全部职责。 } /> @@ -1403,8 +1354,7 @@ export default function ModulesPage() { <>

.d.ts 里的一切都是环境声明(ambient - declaration):关于「别处存在某样东西」的陈述。 - 在里面写一个函数体,你会得到{" "} + declaration):关于「别处存在某样东西」的陈述。在里面写一个函数体,你会得到{" "} error TS1183: An implementation cannot be declared in ambient contexts. @@ -1458,9 +1408,7 @@ export default function ModulesPage() { 选项决定加载哪几本。

- 在编辑器里按住 Ctrl 或 Cmd 点一下 fetch, - 你会跳进 lib.dom.d.ts。 - 亲眼看一次,「内置」就不再像是什么特殊情况了。 + 在编辑器里按住 Ctrl 或 Cmd 点一下 fetch,你会跳进 lib.dom.d.ts。亲眼看一次,「内置」就不再像是什么特殊情况了。

} @@ -1485,8 +1433,7 @@ export default function ModulesPage() { } zh={ <> - 注意 declare 这个词:实现不在这里, - 但运行时它会存在。§04 专门讲这个词。 + 注意 declare 这个词:实现不在这里,但运行时它会存在。§04 专门讲这个词。 } /> @@ -1528,8 +1475,7 @@ export default function ModulesPage() { } zh={ <> - 用 TypeScript 写的库,编译时开 --declaration, - 再用 package.json 的 types{" "} + 用 TypeScript 写的库,编译时开 --declaration,再用 package.json 的 types{" "} 字段(或 exports 里的 types{" "} 条件)指向产物。装完就有类型。 @@ -1552,10 +1498,8 @@ export default function ModulesPage() { } zh={ <> - 一个社区仓库,为自己不带类型的库写声明, - 发布成 @types/* 包: - npm i -D @types/lodash。 - 几乎所有老牌 JavaScript 库都被收录了。 + 一个社区仓库,为自己不带类型的库写声明,发布成 @types/* 包: + npm i -D @types/lodash。几乎所有老牌 JavaScript 库都被收录了。 } /> @@ -1632,8 +1576,7 @@ export default function ModulesPage() { <> 两种写法在真实项目里都常见。顶层的 types{" "} 字段是较早的写法;exports 里的{" "} - types 条件由较新的解析模式读取, - 而且可以按子路径分别指定 —— + types 条件由较新的解析模式读取,而且可以按子路径分别指定 —— 所以一个包能为它的 ESM 入口和 CommonJS 入口提供不同的类型。 @@ -1703,13 +1646,11 @@ export default function ModulesPage() { zh={ <>

- DefinitelyTyped 里的声明是志愿者写的,可能落后于库本体。 - 你装了新的大版本 lodash,而 @types/lodash{" "} + DefinitelyTyped 里的声明是志愿者写的,可能落后于库本体。你装了新的大版本 lodash,而 @types/lodash{" "} 还停在旧版,类型描述的就会是库已经不再有的行为。

- 约定是 @types 包的 major 和 minor 版本跟随库本体。 - 类型看起来不对的时候,先比这两个版本号。 + 约定是 @types 包的 major 和 minor 版本跟随库本体。类型看起来不对的时候,先比这两个版本号。

} @@ -1760,15 +1701,11 @@ export default function ModulesPage() {

找到声明文件这件事叫模块解析,规则由{" "} moduleResolution 选择。node10{" "} - 是旧的 Node 算法:沿着 node_modules 往上找, - 读 main 和 types,并且 + 是旧的 Node 算法:沿着 node_modules 往上找,读 main 和 types,并且 忽略 exports 字段。node16{" "} - 和 nodenext 按现代 Node 的真实行为来: - 认 exports,而且在 ESM 文件里, - 相对导入必须写文件扩展名(不写会报 ts(2835))。 + 和 nodenext 按现代 Node 的真实行为来:认 exports,而且在 ESM 文件里,相对导入必须写文件扩展名(不写会报 ts(2835))。 bundler 对应打包器的行为:认{" "} - exports,但允许省略扩展名。 - 这几种没有哪一种放到哪里都对 —— + exports,但允许省略扩展名。这几种没有哪一种放到哪里都对 —— 正确的那一个,是和真正加载你代码的那一方对得上的那个。

@@ -1776,11 +1713,8 @@ export default function ModulesPage() { tsc 不会改写模块标识,所以{" "} import {"{ TAX }"} from "@/tax"{" "} 在产物里还是这么写着,Node 于是报{" "} - Cannot find package '@/tax'。 - Next.js、Vite、webpack 会读这些 paths, - 或者提供等价的别名选项 —— - 这就是为什么别名在应用里好用, - 却在你第一次用纯 node 跑产物时失败。 + Cannot find package '@/tax'。Next.js、Vite、webpack 会读这些 paths,或者提供等价的别名选项 —— + 这就是为什么别名在应用里好用,却在你第一次用纯 node 跑产物时失败。

} @@ -1807,9 +1741,7 @@ export default function ModulesPage() { } zh={ <> - 这个项目用 bundler,因为代码是 Next.js 加载的。 - 一个要发布到 npm 的库通常想要 nodenext, - 这样它产出的声明才和 Node 实际的处理方式对得上。 + 这个项目用 bundler,因为代码是 Next.js 加载的。一个要发布到 npm 的库通常想要 nodenext,这样它产出的声明才和 Node 实际的处理方式对得上。 } /> @@ -1844,15 +1776,12 @@ export default function ModulesPage() { <>

skipLibCheck: true 跳过的是{" "} - .d.ts 文件内部的类型检查, - 包括它们之间的互相检查。 - 你自己代码对这些库的调用,照样完整检查。 + .d.ts 文件内部的类型检查,包括它们之间的互相检查。你自己代码对这些库的调用,照样完整检查。

换来的是更快的编译,以及躲开你根本改不了的报错 —— 比如两个 @types{" "} - 包用不同的类型声明了同一个全局变量。 - 代价是声明文件内部真有错时不会被报出来。 + 包用不同的类型声明了同一个全局变量。代价是声明文件内部真有错时不会被报出来。

} @@ -1886,8 +1815,7 @@ export default function ModulesPage() { } zh={ <> - 有些东西运行时确实存在,但编译器看不见: - 构建工具注入的全局常量、由 <script>{" "} + 有些东西运行时确实存在,但编译器看不见:构建工具注入的全局常量、由 <script>{" "} 标签加载进来的函数、没有类型的 npm 包。declare{" "} 就是你陈述它们类型的方式:它存在,形状是这样,请采信我。 @@ -1916,8 +1844,7 @@ export default function ModulesPage() { } zh={ <> - 最常见的真实需求:有东西被挂到了 window 上, - 你希望它有类型。在模块内部,这需要 declare global。 + 最常见的真实需求:有东西被挂到了 window 上,你希望它有类型。在模块内部,这需要 declare global。 } /> @@ -1948,9 +1875,7 @@ export default function ModulesPage() { zh={ <> 为什么要写 export {"{}"}? - declare global 只允许出现在模块里, - 而顶层没有 import / export 的文件是脚本。 - 写在脚本里会得到{" "} + declare global 只允许出现在模块里,而顶层没有 import / export 的文件是脚本。写在脚本里会得到{" "} error TS2669: Augmentations for the global scope can only be directly nested in external modules or ambient module @@ -2006,21 +1931,16 @@ export default function ModulesPage() { 写在脚本文件里(顶层没有 import / export)的{" "} declare module "x"{" "} 是环境模块声明,含义是:模块{" "} - "x" 存在,类型是这些。 - 用于自己没有类型的包。 + "x" 存在,类型是这些。用于自己没有类型的包。

- 同样一段写在模块文件里,就是模块扩充, - 含义是:模块 "x"{" "} - 已经有类型了,把这些追加进去。用于扩展一个有类型的库, - 比如给某个 theme 接口加一个字段。 + 同样一段写在模块文件里,就是模块扩充,含义是:模块 "x"{" "} + 已经有类型了,把这些追加进去。用于扩展一个有类型的库,比如给某个 theme 接口加一个字段。

- 两者搞反会得到一个令人困惑的报错。 - 某个文件里的 declare module{" "} + 两者搞反会得到一个令人困惑的报错。某个文件里的 declare module{" "} 本意是描述一个没有类型的包,你却给它加了{" "} - export {"{}"}, - 编译器就会把它当成扩充,而那里没有东西可扩充: + export {"{}"},编译器就会把它当成扩充,而那里没有东西可扩充: error TS2665: Invalid module name in augmentation. Module 'boba-sdk' resolves to an untyped module …, which @@ -2075,9 +1995,7 @@ export default function ModulesPage() { gtag is not defined 失败。

- 类型检查通过、一运行就失败时,查三件事: - 脚本真的加载了吗?那个包真的导出了这个名字吗? - 名字拼对了吗? + 类型检查通过、一运行就失败时,查三件事:脚本真的加载了吗?那个包真的导出了这个名字吗?名字拼对了吗?

} @@ -2114,13 +2032,10 @@ export default function ModulesPage() {

在较早的声明文件里你会看到 declare namespace。 namespace 早于 ES 模块 —— - 在 JavaScript 还没有自己的模块之前, - 它是 TypeScript 自己的名字分组方式。它现在依然有效, - 而且描述「把所有东西挂在一个全局对象上」的库时依然最自然。 + 在 JavaScript 还没有自己的模块之前,它是 TypeScript 自己的名字分组方式。它现在依然有效,而且描述「把所有东西挂在一个全局对象上」的库时依然最自然。

- 新写的代码则推荐用模块。 - 模块本身就给了你一个私有作用域和一份明确的导出清单 —— + 新写的代码则推荐用模块。模块本身就给了你一个私有作用域和一份明确的导出清单 —— 那正是 namespace 当年要解决的事。

@@ -2171,9 +2086,7 @@ export default function ModulesPage() { } zh={ <> - 设定:项目要接一个(假想的)老 JavaScript 包 boba-sdk, - 它没有类型,@types/boba-sdk 也不存在。 - 第一次 import 就失败: + 设定:项目要接一个(假想的)老 JavaScript 包 boba-sdk,它没有类型,@types/boba-sdk 也不存在。第一次 import 就失败: } /> @@ -2199,8 +2112,7 @@ export default function ModulesPage() { zh={ <> 第一步:先让它能编译。建一个{" "} - types/boba-sdk.d.ts。放在哪里都行, - 只要在 tsconfig.json 的 include{" "} + types/boba-sdk.d.ts。放在哪里都行,只要在 tsconfig.json 的 include{" "} 范围内。 } @@ -2227,11 +2139,8 @@ export default function ModulesPage() { } zh={ <> - 这种简写只声明模块存在,不说它的内容, - 所以从它导入的一切都是 any。 - 让这个文件保持脚本身份:不要写顶层的{" "} - export,否则这段会变成模块扩充, - 报 ts(2665)。 + 这种简写只声明模块存在,不说它的内容,所以从它导入的一切都是 any。让这个文件保持脚本身份:不要写顶层的{" "} + export,否则这段会变成模块扩充,报 ts(2665)。 } /> @@ -2277,8 +2186,7 @@ export default function ModulesPage() { <> 从这一步开始,fetchMenu{" "} 有了真实的返回类型,给 order{" "} - 传错杯型会报错 —— 这就是「不受检查的导入」和 - 「受检查的导入」的分界线。 + 传错杯型会报错 —— 这就是「不受检查的导入」和「受检查的导入」的分界线。 } /> @@ -2297,8 +2205,7 @@ export default function ModulesPage() { zh={ <> 第三步:补齐其余部分。boba-sdk - 的文档里还提到一个默认导出的客户端和一个事件回调, - 一并补上: + 的文档里还提到一个默认导出的客户端和一个事件回调,一并补上: } /> @@ -2342,8 +2249,7 @@ export default function ModulesPage() {

如果你真写出了一份完整可靠的,把它提给 DefinitelyTyped。 - @types/boba-sdk 一发布, - 下一个用这个库的人就完全不用再做这些事了。 + @types/boba-sdk 一发布,下一个用这个库的人就完全不用再做这些事了。

} @@ -2462,8 +2368,7 @@ export default function ModulesPage() { 写在哪里就在哪里执行。export default 和{" "} module.exports = 是两回事, esModuleInterop 会产出{" "} - __importDefault 包装, - 让默认导入能读到 CommonJS 的导出。 + __importDefault 包装,让默认导入能读到 CommonJS 的导出。 ), }, @@ -2480,10 +2385,8 @@ export default function ModulesPage() { zh: ( <> import type 和 export type{" "} - 把一行标为「只有类型」,于是整行被删除, - 运行时不会加载任何模块。verbatimModuleSyntax{" "} - 把这个标注变成必需:导入报 ts(1484), - 转手导出报 ts(1205)。 + 把一行标为「只有类型」,于是整行被删除,运行时不会加载任何模块。verbatimModuleSyntax{" "} + 把这个标注变成必需:导入报 ts(1484),转手导出报 ts(1205)。 ), }, @@ -2498,9 +2401,7 @@ export default function ModulesPage() { ), zh: ( <> - .d.ts 描述导出,不产出任何东西, - 也放不了实现:ts(1183)。 - 它同样不能替代模块本身 —— 有声明而没有对应的{" "} + .d.ts 描述导出,不产出任何东西,也放不了实现:ts(1183)。它同样不能替代模块本身 —— 有声明而没有对应的{" "} .js,类型检查会通过,运行时会失败。 ), @@ -2542,13 +2443,10 @@ export default function ModulesPage() { ), zh: ( <> - declare 只陈述类型、不产出代码, - 所以断言错了会在运行时失败。declare global{" "} + declare 只陈述类型、不产出代码,所以断言错了会在运行时失败。declare global{" "} 是模块触及全局作用域的方式(写在别处报{" "} ts(2669))。declare module "x"{" "} - 写在脚本文件里是声明一个没有类型的模块, - 写在模块文件里是扩充一个已有类型的模块; - 去扩充一个没有类型的模块会报 ts(2665)。 + 写在脚本文件里是声明一个没有类型的模块,写在模块文件里是扩充一个已有类型的模块;去扩充一个没有类型的模块会报 ts(2665)。 ), }, @@ -2566,8 +2464,7 @@ export default function ModulesPage() { <> 解析只发生在编译期。moduleResolution 决定是否遵守{" "} exports 和文件扩展名规则,而 paths{" "} - 不会改动产物里的模块标识。 - 真正运行你代码的那一方,需要一份自己的对应配置。 + 不会改动产物里的模块标识。真正运行你代码的那一方,需要一份自己的对应配置。 ), }, diff --git a/app/modules/viz.tsx b/app/modules/viz.tsx index b8af5d4..c8d20b7 100644 --- a/app/modules/viz.tsx +++ b/app/modules/viz.tsx @@ -167,11 +167,8 @@ export function LibShelf() { zh={ <> 不写 lib 时,TypeScript 会按 target{" "} - 选一套默认的声明文件,而这套默认里包含 DOM。 - 自己写 lib 是替换默认值,不是往上追加。 - 所以一个只跑 Node 的项目写了{" "} - "lib": ["es2022"] 之后就没有 DOM, - 这时用 document 会报 ts(2584)。 + 选一套默认的声明文件,而这套默认里包含 DOM。自己写 lib 是替换默认值,不是往上追加。所以一个只跑 Node 的项目写了{" "} + "lib": ["es2022"] 之后就没有 DOM,这时用 document 会报 ts(2584)。 } /> @@ -277,9 +274,7 @@ export function ErasedImports() { } zh={ <> - 类型会被擦除,所以纯类型导入没有东西可导入,整行就消失了; - 值导入则被原样保留。把只当类型用的名字标上 type, - 这个结果在任何构建工具下都一样。 + 类型会被擦除,所以纯类型导入没有东西可导入,整行就消失了;值导入则被原样保留。把只当类型用的名字标上 type,这个结果在任何构建工具下都一样。 } /> @@ -294,8 +289,7 @@ export function ErasedImports() { } zh={ <> - 三行导入,两种东西:值 —— 代码运行时要用到; - 类型 —— 只存在于编译期。切到 编译后 .js{" "} + 三行导入,两种东西:值 —— 代码运行时要用到;类型 —— 只存在于编译期。切到编译后 .js{" "} 看它们各自的结果。 } @@ -369,8 +363,7 @@ const QUEST_FRAMES: QuestFrameDef[] = [ zh={ <> 你写下 import {"{ debounce }"} from "lodash" - 。编译器现在需要知道 debounce 的类型, - 于是开始找它的声明。它会按顺序看三个地方,哪一个有结果就停。 + 。编译器现在需要知道 debounce 的类型,于是开始找它的声明。它会按顺序看三个地方,哪一个有结果就停。 } /> @@ -394,9 +387,7 @@ const QUEST_FRAMES: QuestFrameDef[] = [ zh={ <> 第一站:包里面。编译器读 lodash 的{" "} - package.json,找 types 字段, - 或者 exports 里的 types 条件 —— 没有。 - lodash 是纯 JavaScript 写的,自己不带声明。 + package.json,找 types 字段,或者 exports 里的 types 条件 —— 没有。lodash 是纯 JavaScript 写的,自己不带声明。 } /> @@ -445,10 +436,8 @@ const QUEST_FRAMES: QuestFrameDef[] = [ } zh={ <> - 假如第二站也是空的(冷门库很常见),还剩最后一个地方: - 你项目里自己的声明文件。写一段{" "} - declare module "lodash", - 把你真正用到的部分描出来。 + 假如第二站也是空的(冷门库很常见),还剩最后一个地方:你项目里自己的声明文件。写一段{" "} + declare module "lodash",把你真正用到的部分描出来。 } /> @@ -474,8 +463,7 @@ const QUEST_FRAMES: QuestFrameDef[] = [ <> 三个地方全是空的。那 debounce 就是隐式{" "} any;打开 noImplicitAny - (strict 的一部分)时会报 ts(7016)。 - 报错原文自己就写出了剩下两条路:"Try{" "} + (strict 的一部分)时会报 ts(7016)。报错原文自己就写出了剩下两条路:"Try{" "} npm i --save-dev @types/lodash if it exists or add a new declaration (.d.ts) file containing{" "} declare module 'lodash';" @@ -499,8 +487,7 @@ const QUEST_FRAMES: QuestFrameDef[] = [ zh={ <> 再看一遍顺序:① 包自带的类型 → ② @types → ③ 你自己写的声明 - ,第一个有结果的胜出。给项目挑库时按这个顺序查一遍, - 装之前就知道它的类型支持要付多少代价。 + ,第一个有结果的胜出。给项目挑库时按这个顺序查一遍,装之前就知道它的类型支持要付多少代价。 } /> diff --git a/app/narrowing/page.tsx b/app/narrowing/page.tsx index ecf02ed..47668eb 100644 --- a/app/narrowing/page.tsx +++ b/app/narrowing/page.tsx @@ -695,8 +695,7 @@ export default function NarrowingPage() { } zh={ <> - 想用某一种类型独有的成员,先写一道检查。 - 这道检查会告诉编译器:这条路上只可能是它。 + 想用某一种类型独有的成员,先写一道检查。这道检查会告诉编译器:这条路上只可能是它。 } /> @@ -731,9 +730,7 @@ export default function NarrowingPage() { <> string | number 表示这个值是 string{" "} 或是 number,二者取一,而不是「同时具备两者的能力」 - —— 那是交叉类型 &,02 章已经讲过。 - 所以联合的成员越多,不检查时能做的事越少: - 成员越多,共有成员越少。 + —— 那是交叉类型 &,02 章已经讲过。所以联合的成员越多,不检查时能做的事越少:成员越多,共有成员越少。 } /> @@ -779,12 +776,9 @@ export default function NarrowingPage() { zh={ <> 在分支里把类型变得更具体,这件事叫收窄(narrowing) - 。编译器的做法是:按执行顺序走一遍你的代码, - 在每一行记住此刻还剩哪些可能。这趟遍历叫 - 控制流分析(control flow analysis)。 - 同一个变量,第 3 行和第 5 - 行的类型可以不一样。在编辑器里把光标悬停在不同行的同一个变量上, - 就能看到类型在变。 + 。编译器的做法是:按执行顺序走一遍你的代码,在每一行记住此刻还剩哪些可能。这趟遍历叫 + 控制流分析(control flow analysis)。同一个变量,第 3 行和第 5 + 行的类型可以不一样。在编辑器里把光标悬停在不同行的同一个变量上,就能看到类型在变。 } /> @@ -830,9 +824,7 @@ export default function NarrowingPage() { <> 1. 真值检查会连 0 和空字符串一起排除。 if (count) 看起来只排除了{" "} - undefined,但 0 同样是 falsy, - 也会走进 else 分支。「0 份配料」是真实数据,不是没填。 - 真正想表达的意思,就写 count !== undefined。 + undefined,但 0 同样是 falsy,也会走进 else 分支。「0 份配料」是真实数据,不是没填。真正想表达的意思,就写 count !== undefined。 } /> @@ -879,11 +871,8 @@ export default function NarrowingPage() { } zh={ <> - 第二个错误,与其读,不如亲眼看一次。下面这个窗口跑的是真编译器。 - 订单备注可能是一段自由文本、一个结构化备注,也可能根本没有; - 而 typeof note === "object"{" "} - 守住的那个分支,把 null 和对象一起放了进来。 - 第 6 行直接点名: + 第二个错误,与其读,不如亲眼看一次。下面这个窗口跑的是真编译器。订单备注可能是一段自由文本、一个结构化备注,也可能根本没有;而 typeof note === "object"{" "} + 守住的那个分支,把 null 和对象一起放了进来。第 6 行直接点名: 'note' is possibly 'null'.{" "} 两个预设分别是「完全不检查」的版本,和「先排除 null」的版本。 @@ -961,8 +950,7 @@ export default function NarrowingPage() { zh={ <> 最常见的是回调函数。编译器无法知道这个回调什么时候 - 执行 —— 它可能在变量被改动之后才跑起来, - 那么你先前写的那道检查就不再能说明任何事。 + 执行 —— 它可能在变量被改动之后才跑起来,那么你先前写的那道检查就不再能说明任何事。 } /> @@ -990,8 +978,7 @@ export default function NarrowingPage() { zh={ <> 把最后一行删掉,报错就消失了:只要 value{" "} - 在整个函数里从未被重新赋值,编译器就把它当作固定不变的, - 回调里的收窄依然有效。这条规则真正看的是 + 在整个函数里从未被重新赋值,编译器就把它当作固定不变的,回调里的收窄依然有效。这条规则真正看的是 这个变量还会不会变,而不是「是不是回调」。 } @@ -1011,8 +998,7 @@ export default function NarrowingPage() { } zh={ <> - 被收窄的属性也一样,而且更严格: - 对象属性随时可能被别处改掉,所以编译器从不把属性的收窄结果带进回调。 + 被收窄的属性也一样,而且更严格:对象属性随时可能被别处改掉,所以编译器从不把属性的收窄结果带进回调。 } /> @@ -1049,9 +1035,7 @@ export default function NarrowingPage() { } zh={ <> - 这是标准做法,而且它是诚实的: - 你捕获的就是当时检查过的那个值, - 而不是期望那个属性稍后还装着同一个值。 + 这是标准做法,而且它是诚实的:你捕获的就是当时检查过的那个值,而不是期望那个属性稍后还装着同一个值。 } /> @@ -1084,14 +1068,10 @@ export default function NarrowingPage() { } zh={ <> - 在同一个函数里,被收窄的属性能挺过一次普通的函数调用。 - 写成{" "} + 在同一个函数里,被收窄的属性能挺过一次普通的函数调用。写成{" "} if (d.note) { clear(d); d.note.trim(); } ,编译器一声不吭 —— 哪怕 clear 可能已经把{" "} - d.note 改成了 undefined。 - TypeScript 是有意留下这个缺口的: - 如果要追踪每一次可能的修改,会有大量正确代码被误判。 - 这是少数几个「编译通过 ≠ 值还在」的地方。 + d.note 改成了 undefined。TypeScript 是有意留下这个缺口的:如果要追踪每一次可能的修改,会有大量正确代码被误判。这是少数几个「编译通过 ≠ 值还在」的地方。 } /> @@ -1119,10 +1099,7 @@ export default function NarrowingPage() { } zh={ <> - 给 let 赋值,会从那一行起清掉它的收窄结果。 - 回调里能否保留收窄,取决于那个变量有没有被重新赋值过。 - 属性的收窄则永远不会带进回调。 - 这三种情况的解法几乎都一样:把检查过的值复制进一个{" "} + 给 let 赋值,会从那一行起清掉它的收窄结果。回调里能否保留收窄,取决于那个变量有没有被重新赋值过。属性的收窄则永远不会带进回调。这三种情况的解法几乎都一样:把检查过的值复制进一个{" "} const。 } @@ -1155,8 +1132,7 @@ export default function NarrowingPage() { } zh={ <> - 先看不好用的那种写法。所有字段挤在一个类型里, - 「哪个状态有哪些字段」这条规则只存在于你的脑子里。 + 先看不好用的那种写法。所有字段挤在一个类型里,「哪个状态有哪些字段」这条规则只存在于你的脑子里。 } /> @@ -1183,8 +1159,7 @@ export default function NarrowingPage() { } zh={ <> - 那个 ! 就是类型系统帮不上忙的地方。 - 而且 status 的类型是宽泛的{" "} + 那个 ! 就是类型系统帮不上忙的地方。而且 status 的类型是宽泛的{" "} string,把它拼成{" "} "pald" 也能通过编译。 @@ -1218,8 +1193,7 @@ export default function NarrowingPage() { 每种状态一个成员,而且 status 的类型是 字面量类型 —— 就是{" "} "paid" 这个词本身,而不是{" "} - string。 - 只要比对这一个字段,整个对象的形状就确定了。 + string。只要比对这一个字段,整个对象的形状就确定了。 } /> @@ -1282,9 +1256,7 @@ export default function NarrowingPage() { 都行。第二,这个字段是字面量类型,比如{" "} "paid"、1、 true,而不是 string{" "} - 这样的宽泛类型。第三,各成员的字面量互不相同。 - 三条都满足,比对一次就能收窄整个对象。 - 这是 TypeScript 里写状态机的常规做法。 + 这样的宽泛类型。第三,各成员的字面量互不相同。三条都满足,比对一次就能收窄整个对象。这是 TypeScript 里写状态机的常规做法。 } /> @@ -1320,10 +1292,8 @@ export default function NarrowingPage() { zh={ <> 把收窄推到尽头。每个 case{" "} - 从联合里去掉一个成员,最后一个成员被去掉之后,就什么都不剩了。 - 「什么都不剩」这个类型就是 never。而{" "} - never 不接受任何值,所以只有当编译器认同这一行 - 永远不会被执行到时,这个赋值才能通过。 + 从联合里去掉一个成员,最后一个成员被去掉之后,就什么都不剩了。「什么都不剩」这个类型就是 never。而{" "} + never 不接受任何值,所以只有当编译器认同这一行永远不会被执行到时,这个赋值才能通过。 } /> @@ -1349,8 +1319,7 @@ export default function NarrowingPage() { zh={ <> 把这行赋值读成一句断言:「走到 default 时, - order 已经没有任何可能了。」 - 现在这句断言成立,所以代码编译通过,一切平静。 + order 已经没有任何可能了。」现在这句断言成立,所以代码编译通过,一切平静。 } /> @@ -1379,9 +1348,7 @@ export default function NarrowingPage() { } zh={ <> - 它的价值在后面。三个月后店里开始支持退款, - 类型要加第四个状态 —— 而那个函数没人打算动。 - 就在下面这个窗口里做,它跑的是真编译器:往联合里加一行{" "} + 它的价值在后面。三个月后店里开始支持退款,类型要加第四个状态 —— 而那个函数没人打算动。就在下面这个窗口里做,它跑的是真编译器:往联合里加一行{" "} | { status: "refunded"; createdAt: Date; refundedAt: Date } @@ -1432,12 +1399,9 @@ export default function NarrowingPage() { zh={ <> 把这条消息读成一句话,它就是一条待办:走到{" "} - default 时 refunded 还在场, - 所以「这里什么都不剩了」这句断言不再成立。 - 加状态前后各点一次 default 里的{" "} + default 时 refunded 还在场,所以「这里什么都不剩了」这句断言不再成立。加状态前后各点一次 default 里的{" "} order:它的类型会从 never{" "} - 变成你漏掉的那个成员。补上缺的 case, - 报错自己就消失了。 + 变成你漏掉的那个成员。补上缺的 case,报错自己就消失了。 } /> @@ -1463,11 +1427,7 @@ export default function NarrowingPage() { } zh={ <> - 如果项目里有二十个函数在 switch order.status, - 加一个状态就会让这二十处同时报错、逐个点名。 - 你顺着报错清单改下去,不会漏掉任何一处。 - 没有这道检查,新状态会安静地落进 default, - 或者根本没有分支接住它,函数返回 undefined。 + 如果项目里有二十个函数在 switch order.status,加一个状态就会让这二十处同时报错、逐个点名。你顺着报错清单改下去,不会漏掉任何一处。没有这道检查,新状态会安静地落进 default,或者根本没有分支接住它,函数返回 undefined。 } /> @@ -1498,8 +1458,7 @@ export default function NarrowingPage() { 保护来自那行 never 赋值,而不是「有 default 分支」。在上面那个窗口里试一次:留着第四个状态,把整个{" "} default 块换成{" "} - default: return "unknown status";, - 所有报错都会消失,包括那个你根本没处理的状态 —— + default: return "unknown status";,所有报错都会消失,包括那个你根本没处理的状态 —— 你等于告诉编译器,这事你管过了。 } @@ -1549,13 +1508,8 @@ export default function NarrowingPage() { } zh={ <> - o is Paid 是你对编译器的承诺, - 编译器不看函数体就接受它。 - 哪怕你不小心写成{" "} - return o.status === "pending", - 它照样相信。从此类型就不再描述这个程序了。 - 这是少数几个能让 TypeScript 判断错误的地方, - 所以谓词的函数体要写得短、写得一目了然。 + o is Paid 是你对编译器的承诺,编译器不看函数体就接受它。哪怕你不小心写成{" "} + return o.status === "pending",它照样相信。从此类型就不再描述这个程序了。这是少数几个能让 TypeScript 判断错误的地方,所以谓词的函数体要写得短、写得一目了然。 } /> @@ -1575,8 +1529,7 @@ export default function NarrowingPage() { zh={ <> 谓词是在 if 分支里收窄。断言函数 - 则是让调用之后的每一行都收窄:检查不通过就抛异常, - 所以只要代码还在往下走,就说明检查通过了。 + 则是让调用之后的每一行都收窄:检查不通过就抛异常,所以只要代码还在往下走,就说明检查通过了。 } /> @@ -1603,9 +1556,7 @@ export default function NarrowingPage() { zh={ <> 这条标注规则很容易在不经意间撞上。用 function{" "} - 声明写出来就没问题; - 但如果把断言函数放进变量里,这个变量必须写出显式类型, - 否则调用它不会产生收窄,编译器还会直接报错。 + 声明写出来就没问题;但如果把断言函数放进变量里,这个变量必须写出显式类型,否则调用它不会产生收窄,编译器还会直接报错。 } /> @@ -1642,8 +1593,7 @@ export default function NarrowingPage() { } zh={ <> - 只有「单参数、直接返回一个收窄表达式」的简短回调, - 编译器才会推断出谓词。更复杂的逻辑仍然要手写{" "} + 只有「单参数、直接返回一个收窄表达式」的简短回调,编译器才会推断出谓词。更复杂的逻辑仍然要手写{" "} is —— 而手写就意味着,那句承诺又归你负责了。 } @@ -1683,8 +1633,7 @@ export default function NarrowingPage() { } zh={ <> - ?? 有一个长得很像的前辈 ||, - 两者的区别正是 §03 里那个真值检查的问题。 + ?? 有一个长得很像的前辈 ||,两者的区别正是 §03 里那个真值检查的问题。 } /> @@ -1743,11 +1692,8 @@ export default function NarrowingPage() { <> ?. 和 ?? 是 JavaScript 的正式操作符,编译后是真实的运行时检查。而 !{" "} - 是 TypeScript 的标注,编译后会消失。 - 它不做任何检查,只是让编译器不再报错。 - 运行时那个值如果真的是 null,你照样会拿到和 - JavaScript 里一样的 TypeError, - 而且这次连编译器的提醒都没有了。规矩:能用 ?.{" "} + 是 TypeScript 的标注,编译后会消失。它不做任何检查,只是让编译器不再报错。运行时那个值如果真的是 null,你照样会拿到和 + JavaScript 里一样的 TypeError,而且这次连编译器的提醒都没有了。规矩:能用 ?.{" "} 和 ?? 表达的,就不要用 !。 } @@ -1795,8 +1741,7 @@ export default function NarrowingPage() { } zh={ <> - 联合类型只允许你使用每一种成员类型都有的成员, - 因为编译器按最坏情况处理。想用其余的,先收窄。 + 联合类型只允许你使用每一种成员类型都有的成员,因为编译器按最坏情况处理。想用其余的,先收窄。 } />, @@ -1813,8 +1758,7 @@ export default function NarrowingPage() { } zh={ <> - 收窄的本质是控制流分析:编译器按顺序走一遍代码, - 在每个分支里给变量一个更具体的类型。typeof + 收窄的本质是控制流分析:编译器按顺序走一遍代码,在每个分支里给变量一个更具体的类型。typeof 、真值检查、相等比较、in、 instanceof、Array.isArray{" "} 和字面量比对,都能收窄。 @@ -1850,9 +1794,7 @@ export default function NarrowingPage() { } zh={ <> - 收窄会在这些地方失效:let 被重新赋值之后; - 回调内部,只要那个变量可能被重新赋值,或者它是对象属性。 - 解法是把检查过的值复制进一个 const。 + 收窄会在这些地方失效:let 被重新赋值之后;回调内部,只要那个变量可能被重新赋值,或者它是对象属性。解法是把检查过的值复制进一个 const。 } />, @@ -1867,8 +1809,7 @@ export default function NarrowingPage() { } zh={ <> - 可辨识联合需要:一个公共字段、字面量类型、各成员的字面量互不相同。 - 三条齐了,比对一次就能收窄整个对象。 + 可辨识联合需要:一个公共字段、字面量类型、各成员的字面量互不相同。三条齐了,比对一次就能收窄整个对象。 } />, @@ -1886,9 +1827,7 @@ export default function NarrowingPage() { zh={ <> 在 default 分支里写{" "} - const _x: never = order 就是穷尽检查。 - 所有 case 都处理完时,剩下的类型是空联合,赋值成立; - 一旦联合里多出一个成员,这行立刻编译失败。 + const _x: never = order 就是穷尽检查。所有 case 都处理完时,剩下的类型是空联合,赋值成立;一旦联合里多出一个成员,这行立刻编译失败。 } />, @@ -1904,8 +1843,7 @@ export default function NarrowingPage() { zh={ <> o is Paid 把一段检查打包复用, - asserts o is Paid 则让调用之后的代码收窄。 - 两者的函数体编译器都不验证 —— 那句承诺由你负责。 + asserts o is Paid 则让调用之后的代码收窄。两者的函数体编译器都不验证 —— 那句承诺由你负责。 } />, @@ -1922,8 +1860,7 @@ export default function NarrowingPage() { zh={ <> ?. 和 ?? 会编译成真实的运行时检查; - ! 编译后什么都不剩,也什么都不检查。 - 当默认值有可能合法地取 0 或{" "} + ! 编译后什么都不剩,也什么都不检查。当默认值有可能合法地取 0 或{" "} "" 时,绝不要用 ||。 } diff --git a/app/narrowing/viz.tsx b/app/narrowing/viz.tsx index 0def8be..b4dd655 100644 --- a/app/narrowing/viz.tsx +++ b/app/narrowing/viz.tsx @@ -157,8 +157,7 @@ const FRAME_MSGS: ReactNode[] = [ } zh={ <> - 第三道检查:数字也走了。每过一道检查,类型就少一个成员。 - 这就是收窄的含义,而且编译器是逐行跟踪的。 + 第三道检查:数字也走了。每过一道检查,类型就少一个成员。这就是收窄的含义,而且编译器是逐行跟踪的。 } />, @@ -192,8 +191,7 @@ const FRAME_MSGS: ReactNode[] = [ } zh={ <> - 再假想加一道检查,把 boolean 也排除掉。 - 之后的类型就是 never:空联合 + 再假想加一道检查,把 boolean 也排除掉。之后的类型就是 never:空联合 ,一个没有任何可能取值的类型。记住它 —— §06 的穷尽检查完全建立在这个「空」之上。 @@ -344,8 +342,7 @@ const GUARDS: Guard[] = [ } zh={ <> - typeof x === "string" 为真时, - 编译器排除掉所有不是字符串的可能;else 分支里再把字符串排除掉。 + typeof x === "string" 为真时,编译器排除掉所有不是字符串的可能;else 分支里再把字符串排除掉。 typeof 的返回值只有八种: "string"、"number"、 "bigint"、"boolean"、 @@ -371,8 +368,7 @@ const GUARDS: Guard[] = [ zh={ <> typeof null === "object"。这个行为从 1995 年 - JavaScript 的第一个版本就是如此,改动会破坏现有代码,所以不会再改。 - 也就是说 typeof x === "object" 排除不掉{" "} + JavaScript 的第一个版本就是如此,改动会破坏现有代码,所以不会再改。也就是说 typeof x === "object" 排除不掉{" "} null,得先写一句 x !== null。 } @@ -411,8 +407,7 @@ const GUARDS: Guard[] = [ <> if (count) 会排除所有 falsy 值: undefined、null、0、 - ""、NaN。 - 它一行就能收窄最多,这也正是它最容易用错的原因。 + ""、NaN。它一行就能收窄最多,这也正是它最容易用错的原因。 } /> @@ -430,8 +425,7 @@ const GUARDS: Guard[] = [ zh={ <> 0 和 "" 通常是真实数据: - 0 份配料、空的备注。真值检查却把它们当成「没填」。 - 如果你只想排除 undefined,就写{" "} + 0 份配料、空的备注。真值检查却把它们当成「没填」。如果你只想排除 undefined,就写{" "} count !== undefined。 } @@ -520,9 +514,7 @@ function brew(drink: Tea | Coffee) { } zh={ <> - "steep" in drink 问的是这个对象上有没有这个属性。 - 编译器保留声明了该属性的成员,排除掉其余的。 - 它适合形状不同的对象联合,尤其是你不想额外加一个标签字段的时候。 + "steep" in drink 问的是这个对象上有没有这个属性。编译器保留声明了该属性的成员,排除掉其余的。它适合形状不同的对象联合,尤其是你不想额外加一个标签字段的时候。 } /> @@ -540,11 +532,9 @@ function brew(drink: Tea | Coffee) { } zh={ <> - 如果那个属性声明为可选,else 分支就不会收窄。 - 对于{" "} + 如果那个属性声明为可选,else 分支就不会收窄。对于{" "} type A = { kind: "a"; extra?: string } - ,一个 A 完全可以没有 extra, - 所以编译器在 else 分支里仍然保留 A。 + ,一个 A 完全可以没有 extra,所以编译器在 else 分支里仍然保留 A。 } /> @@ -584,8 +574,7 @@ function brew(drink: Tea | Coffee) { x instanceof Date 沿着 x{" "} 的原型链往上找,看 Date.prototype{" "} 在不在上面。它适合类的实例:Date、 - Error、你自己写的 class。 - 对字面量对象无效,因为它们不是由构造函数创建的。 + Error、你自己写的 class。对字面量对象无效,因为它们不是由构造函数创建的。 } /> @@ -624,8 +613,7 @@ function brew(drink: Tea | Coffee) { <> typeof [] === "object",所以{" "} typeof 分不出数组和普通对象。 - Array.isArray 分得出,TypeScript 也把它当作收窄检查。 - 它在标准库里就是用类型谓词声明的 —— §07 会讲这个机制。 + Array.isArray 分得出,TypeScript 也把它当作收窄检查。它在标准库里就是用类型谓词声明的 —— §07 会讲这个机制。 } /> @@ -659,9 +647,7 @@ function brew(drink: Tea | Coffee) { } zh={ <> - 前六种检查收窄的是一个值;这一种只比对一个字段, - 却能把整个对象收窄到联合里的某一个成员, - 连它带的其他字段一起确定。在真实代码里它最有用,§05 整节讲它。 + 前六种检查收窄的是一个值;这一种只比对一个字段,却能把整个对象收窄到联合里的某一个成员,连它带的其他字段一起确定。在真实代码里它最有用,§05 整节讲它。 } /> @@ -751,8 +737,7 @@ const OS_CASES: OsCase[] = [ zh={ <> 订单刚下,还没付款,所以在这个分支里读{" "} - order.paidAt 是编译错误。 - 换成纯 JavaScript,同样的代码会安静地给你一个{" "} + order.paidAt 是编译错误。换成纯 JavaScript,同样的代码会安静地给你一个{" "} undefined,然后在{" "} .toLocaleTimeString() 上抛错。 @@ -777,8 +762,7 @@ const OS_CASES: OsCase[] = [ zh={ <> 只比对了一句 status === "paid", - paidAt 就从「可能没有」变成了「一定有」。 - 你没有多写一个 if,也不需要 !。 + paidAt 就从「可能没有」变成了「一定有」。你没有多写一个 if,也不需要 !。 } /> @@ -800,9 +784,7 @@ const OS_CASES: OsCase[] = [ } zh={ <> - 终态有全部三个时间戳。已送达的订单一定付过款, - 所以这个成员里也有 paidAt。 - 类型把这条业务规则原样记了下来。 + 终态有全部三个时间戳。已送达的订单一定付过款,所以这个成员里也有 paidAt。类型把这条业务规则原样记了下来。 } /> diff --git a/app/page.tsx b/app/page.tsx index 20a82f2..703ccf2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -191,14 +191,10 @@ export default function HomePage() {

但 JavaScript 没看到。读一个不存在的属性?它不报错,安静地给你 undefined。undefined 乘 2?也不报错,算出 NaN。NaN - 拼进字符串?照样不报错。于是深夜 1:47,一位买两杯四季春的用户, - 看到了「合计:¥NaN」—— 截图、发群、@客服。你凌晨三点爬起来, - 对着日志一行行 console.log,两小时后才找到那 5 个字母。 + 拼进字符串?照样不报错。于是深夜 1:47,一位买两杯四季春的用户,看到了「合计:¥NaN」—— 截图、发群、@客服。你凌晨三点爬起来,对着日志一行行 console.log,两小时后才找到那 5 个字母。

- 同一段代码,如果文件后缀是 .ts —— 在你保存文件的那一刻, - 编辑器就画出红线:「没有 totle,你是不是想写 total?」 - 连改法都替你想好了。 + 同一段代码,如果文件后缀是 .ts —— 在你保存文件的那一刻,编辑器就画出红线:「没有 totle,你是不是想写 total?」连改法都替你想好了。

} @@ -285,10 +281,7 @@ export default function HomePage() { } zh={

- 危险品免不了有人带 —— 问题是在登机口拦下,还是在天上发现。 - TypeScript 就是把安检口设在「保存文件」这一刻:所有拼错的属性、 - 传错的参数、想不到的 undefined,都在代码起飞前拦住。 - 安检慢三秒,好过空中冒烟。 + 危险品免不了有人带 —— 问题是在登机口拦下,还是在天上发现。TypeScript 就是把安检口设在「保存文件」这一刻:所有拼错的属性、传错的参数、想不到的 undefined,都在代码起飞前拦住。安检慢三秒,好过空中冒烟。

} /> @@ -323,11 +316,7 @@ export default function HomePage() { } zh={ <> - {"{ drink: string; total: number }"} 翻译成人话就是: - 「凡是订单,必须带一个字符串的 drink 和一个数字的 total」。 - 这就是类型(type):对一个值的形状,事先立下的约定。 - 有了约定,编译器才有依据替你把关 —— 你写 order.totle,它翻一眼 - 说明书:约定里没有 totle 这一栏,红线伺候。 + {"{ drink: string; total: number }"} 翻译成人话就是:「凡是订单,必须带一个字符串的 drink 和一个数字的 total」。这就是类型(type):对一个值的形状,事先立下的约定。有了约定,编译器才有依据替你把关 —— 你写 order.totle,它翻一眼说明书:约定里没有 totle 这一栏,红线伺候。 } /> @@ -397,11 +386,8 @@ export default function HomePage() { } zh={

- 意味着 TypeScript 不是一门要从头学的新语言。变量、函数、 - 数组、箭头函数……全都是你认识的 JS。TS 加的那一层 —— - 类型注解、interface、泛型 —— 是「描述」而不是「逻辑」: - 描述你的数据长什么样、你的函数收什么吐什么。这门课接下来的 - 十一章,教的全是这层「描述」怎么写、怎么想。 + 意味着 TypeScript 不是一门要从头学的新语言。变量、函数、数组、箭头函数……全都是你认识的 JS。TS 加的那一层 —— + 类型注解、interface、泛型 —— 是「描述」而不是「逻辑」:描述你的数据长什么样、你的函数收什么吐什么。这门课接下来的十一章,教的全是这层「描述」怎么写、怎么想。

} /> @@ -440,8 +426,7 @@ export default function HomePage() { ), zh: ( <> - JS 的逻辑:totle 不存在?那就是 undefined。undefined 乘 2? - 那就是 NaN。全程零报错,炸给用户看。 + JS 的逻辑:totle 不存在?那就是 undefined。undefined 乘 2?那就是 NaN。全程零报错,炸给用户看。 ), }} @@ -510,9 +495,7 @@ export default function HomePage() { zh={ <> 不是录像,也不是我手写的报错文案 —— 真正的 TypeScript - 编译器就在你的浏览器里跑,在后台线程。改代码,它重新检查; - 把 totle 拼对,报错自己消失。点任意名字看它推断出的 - 类型,切到编译产物 JS 看真正运行的是什么。 + 编译器就在你的浏览器里跑,在后台线程。改代码,它重新检查;把 totle 拼对,报错自己消失。点任意名字看它推断出的类型,切到编译产物 JS 看真正运行的是什么。 } /> @@ -567,8 +550,7 @@ export default function HomePage() { > typescriptlang.org/play - ,免注册。左边贴代码,红线立刻出现;鼠标悬停变量能看类型; - 右侧 .JS 标签是编译产物;还能一键分享链接 —— + ,免注册。左边贴代码,红线立刻出现;鼠标悬停变量能看类型;右侧 .JS 标签是编译产物;还能一键分享链接 —— 全书的动手任务都在这打。 } @@ -635,8 +617,7 @@ export default function HomePage() { ), zh: ( <> - tsc 只干两件事:检查 + 翻译。检查:对照类型说明书找错, - 有错就报;翻译:把 .ts 擦掉类型变成 .js。两件事可以分开干 —— + tsc 只干两件事:检查 + 翻译。检查:对照类型说明书找错,有错就报;翻译:把 .ts 擦掉类型变成 .js。两件事可以分开干 —— --noEmit 就是「只安检,不放行产物」。 ), @@ -672,8 +653,7 @@ export default function HomePage() { zh={ <> 很多人以为 TS 会把类型「带进」运行时,替你在线上把关。恰恰相反: - 编译后,类型全部消失(type erasure),产物就是普通 JS, - 运行时行为和你手写 JS 一模一样。点下面三个按钮,亲眼看一遍: + 编译后,类型全部消失(type erasure),产物就是普通 JS,运行时行为和你手写 JS 一模一样。点下面三个按钮,亲眼看一遍: } /> @@ -700,10 +680,8 @@ export default function HomePage() { } zh={

- 类型检查的对象是你写的代码,不是运行时闯进来的数据。 - 接口返回的 JSON 长歪了、用户输入了奇怪的东西 —— - 这些发生在类型被擦掉之后,TS 管不着。运行时的保护要靠真正的校验代码 - (亲手写 if 判断,或用 zod 这类校验库)。这个伏笔埋在这, + 类型检查的对象是你写的代码,不是运行时闯进来的数据。接口返回的 JSON 长歪了、用户输入了奇怪的东西 —— + 这些发生在类型被擦掉之后,TS 管不着。运行时的保护要靠真正的校验代码(亲手写 if 判断,或用 zod 这类校验库)。这个伏笔埋在这, 终章「类型思维」回收。

} @@ -812,9 +790,7 @@ export default function HomePage() { } zh={

- 学习阶段,Playground 就是你的主场:免装、免配、实时报错。 - 等第 10 章讲 tsconfig 时,再回来认真配一条自己的生产路线。 - 现在只要记住一件事:三条路的终点都是 JS。 + 学习阶段,Playground 就是你的主场:免装、免配、实时报错。等第 10 章讲 tsconfig 时,再回来认真配一条自己的生产路线。现在只要记住一件事:三条路的终点都是 JS。

} /> @@ -854,9 +830,7 @@ export default function HomePage() { zh={

不是。TS 是 JS 的超集:你写过的每一行 JS 在 TS - 里原样有效,改个后缀就是起点。要新学的只有「类型」这一层描述, - 而且可以渐进 —— 今天给一个函数标注,明天给一个对象定形, - 不需要推倒重来。 + 里原样有效,改个后缀就是起点。要新学的只有「类型」这一层描述,而且可以渐进 —— 今天给一个函数标注,明天给一个对象定形,不需要推倒重来。

} /> @@ -882,8 +856,7 @@ export default function HomePage() { zh={

类型在编译后全部擦除,运行时没有任何检查发生。TS - 保证的是「你的代码内部自洽」,保证不了「外面进来的数据规矩」。 - 接口数据、用户输入,该校验还得校验 —— 终章教你怎么把两层防线接起来。 + 保证的是「你的代码内部自洽」,保证不了「外面进来的数据规矩」。接口数据、用户输入,该校验还得校验 —— 终章教你怎么把两层防线接起来。

} /> @@ -907,9 +880,7 @@ export default function HomePage() { } zh={

- 不会。编译产物就是普通 JS,引擎看不到任何类型,谈不上按类型优化。 - TS 提速的不是程序,是你:重构敢下手、补全真的准、 - 半夜不用起来查 NaN —— 快在开发,不在运行。 + 不会。编译产物就是普通 JS,引擎看不到任何类型,谈不上按类型优化。TS 提速的不是程序,是你:重构敢下手、补全真的准、半夜不用起来查 NaN —— 快在开发,不在运行。

} /> @@ -991,8 +962,7 @@ export default function HomePage() { ), zh: ( <> - 类型 = 值的形状说明书,一份事先立下的约定; - 编译器拿着说明书,替你验每一次取值和传参。 + 类型 = 值的形状说明书,一份事先立下的约定;编译器拿着说明书,替你验每一次取值和传参。 ), }, @@ -1022,8 +992,7 @@ export default function HomePage() { ), zh: ( <> - 编译后类型全部擦除,产物就是普通 JS:运行时不多一分保护, - 也不多一分开销 —— 外部数据的校验要另外写。 + 编译后类型全部擦除,产物就是普通 JS:运行时不多一分保护,也不多一分开销 —— 外部数据的校验要另外写。 ), }, @@ -1037,8 +1006,7 @@ export default function HomePage() { ), zh: ( <> - tsc = 检查 + 翻译;跑 TS 三条路(tsc / bundler / 直接跑), - 终点都是 JS。学习阶段,Playground 是你的主场。 + tsc = 检查 + 翻译;跑 TS 三条路(tsc / bundler / 直接跑),终点都是 JS。学习阶段,Playground 是你的主场。 ), }, diff --git a/app/structural/page.tsx b/app/structural/page.tsx index 5eb54a3..addade5 100644 --- a/app/structural/page.tsx +++ b/app/structural/page.tsx @@ -427,10 +427,7 @@ export default function StructuralPage() { ), zh: ( <> - TypeScript 从不问一个类型叫什么名字, - 它只把值实际拥有的成员和目标要求的成员比一遍。 - 这一章讲透这条贯穿全书的地基规则, - 再拆掉几乎每个新手都会撞上的那个例外:多余属性检查。 + TypeScript 从不问一个类型叫什么名字,它只把值实际拥有的成员和目标要求的成员比一遍。这一章讲透这条贯穿全书的地基规则,再拆掉几乎每个新手都会撞上的那个例外:多余属性检查。 ), }} @@ -507,16 +504,10 @@ export default function StructuralPage() { zh={ <>

- 奶茶店缺人,老板贴出要求:有名字,会做茶。 - 来了个应聘者,没带任何证书 —— 老板不在乎。名字?有。做茶? - 当场做了一杯。录用。至于这人以前叫「正式工」还是 - 「隔壁店员工」,没人问。 + 奶茶店缺人,老板贴出要求:有名字,会做茶。来了个应聘者,没带任何证书 —— 老板不在乎。名字?有。做茶?当场做了一杯。录用。至于这人以前叫「正式工」还是「隔壁店员工」,没人问。

- 这就是结构化类型(structural typing)的全部内容: - 一个值属不属于某个类型,只取决于它有哪些成员, - 而不取决于它曾经声明过自己是谁。 - 这个想法还有一个更早的名字:鸭子测试(duck test)—— + 这就是结构化类型(structural typing)的全部内容:一个值属不属于某个类型,只取决于它有哪些成员,而不取决于它曾经声明过自己是谁。这个想法还有一个更早的名字:鸭子测试(duck test)—— 走路像鸭子、叫声像鸭子,那就当它是鸭子。

@@ -544,8 +535,7 @@ export default function StructuralPage() { } zh={ <> - zhen 和 Staff 之间没有任何声明过的关系。 - 编译器把 zhen 的成员和 Staff{" "} + zhen 和 Staff 之间没有任何声明过的关系。编译器把 zhen 的成员和 Staff{" "} 要求的成员比了一遍,全都找到了,于是放行。 } @@ -572,8 +562,7 @@ export default function StructuralPage() { } zh={ <> - Staff 是你给某个形状贴的标签,方便人读代码。 - 编译器比较类型时,标签不参与比较。 + Staff 是你给某个形状贴的标签,方便人读代码。编译器比较类型时,标签不参与比较。 } /> @@ -598,8 +587,7 @@ export default function StructuralPage() { zh={ <> 有哪些成员、每个成员是什么类型 —— - 这份清单才是编译器眼里的类型。 - 清单相同的两个类型,是同一样东西的两个名字。 + 这份清单才是编译器眼里的类型。清单相同的两个类型,是同一样东西的两个名字。 } /> @@ -624,8 +612,7 @@ export default function StructuralPage() { } zh={ <> - 目标要求的成员你都有,而且类型也对得上,检查就通过。 - 不需要任何声明 —— 兼容关系是算出来的,不是登记出来的。 + 目标要求的成员你都有,而且类型也对得上,检查就通过。不需要任何声明 —— 兼容关系是算出来的,不是登记出来的。 } /> @@ -658,9 +645,7 @@ export default function StructuralPage() { 不是。interface A {"{ x: number }"} 和{" "} interface B {"{ x: number }"}{" "} 可以互相赋值,两个方向都行 —— 名字只是标签,形状相同。 - type 在这里和 interface 表现完全一致: - 两者都不会仅仅因为名字不同就造出一个独立的类型。 - 真要让同形状的两个类型互不相通,§05 会给出标准做法。 + type 在这里和 interface 表现完全一致:两者都不会仅仅因为名字不同就造出一个独立的类型。真要让同形状的两个类型互不相通,§05 会给出标准做法。 } /> @@ -723,12 +708,9 @@ export default function StructuralPage() { } zh={ <> - 名义类型系统比较的是声明:两个类型有关系, - 必须有一方用 extends 或 implements{" "} - 说出来。结构化类型系统比较的是成员,什么都不用声明。 - 再看一眼右边:在 TypeScript 里,连 class 都按形状比 —— - 这是从 Java 转过来的人最先撞上的一条。 - 例外只有一个,而且要类里出现 private 或{" "} + 名义类型系统比较的是声明:两个类型有关系,必须有一方用 extends 或 implements{" "} + 说出来。结构化类型系统比较的是成员,什么都不用声明。再看一眼右边:在 TypeScript 里,连 class 都按形状比 —— + 这是从 Java 转过来的人最先撞上的一条。例外只有一个,而且要类里出现 private 或{" "} protected 成员才会触发,第 08 章会讲。 } @@ -761,14 +743,8 @@ export default function StructuralPage() { } zh={ <> - 因为它的任务是描述已经存在的 JavaScript。 - 想想日常的 JS 值长什么样:随手写下的对象字面量、 - JSON.parse 返回的数据、 - 几个函数拼出来的对象 —— 它们谁都没有声明过什么。 - 如果兼容必须先有声明,那所有现存的 JS 文件都得先改写一遍 - 才能被 TypeScript 标注,没人会这么干。 - 结构化类型让 TypeScript 能按 JS 本来的写法去描述它, - 这正是「JavaScript 超集」这句承诺的技术前提。 + 因为它的任务是描述已经存在的 JavaScript。想想日常的 JS 值长什么样:随手写下的对象字面量、 + JSON.parse 返回的数据、几个函数拼出来的对象 —— 它们谁都没有声明过什么。如果兼容必须先有声明,那所有现存的 JS 文件都得先改写一遍才能被 TypeScript 标注,没人会这么干。结构化类型让 TypeScript 能按 JS 本来的写法去描述它,这正是「JavaScript 超集」这句承诺的技术前提。 } /> @@ -873,9 +849,7 @@ export default function StructuralPage() { name: string; makeTea: () => void; years: number; }' but required in type 'Staff'.
{" "} - 注意:barista 本身一个字没改。 - 可见「兼容」不是某个值自带的属性, - 而是每次对着目标的要求重新算出来的。 + 注意:barista 本身一个字没改。可见「兼容」不是某个值自带的属性,而是每次对着目标的要求重新算出来的。 } /> @@ -925,10 +899,8 @@ export default function StructuralPage() { 描述的集合小得多 —— 要求越多,合格的值越少。

- 所以「成员多」=「更具体」=「集合更小」。 - 小集合里的每个值同时也在大集合里,所以 Barista{" "} - 总能用在要 Staff 的位置,反过来不行。 - 第 03 章的联合类型、第 05 章的泛型约束,靠的都是这同一个方向。 + 所以「成员多」=「更具体」=「集合更小」。小集合里的每个值同时也在大集合里,所以 Barista{" "} + 总能用在要 Staff 的位置,反过来不行。第 03 章的联合类型、第 05 章的泛型约束,靠的都是这同一个方向。

} @@ -960,8 +932,7 @@ export default function StructuralPage() { 这两种写法很像,形状却不同。 {"{ note?: string }"} 表示这个键可以不存在; {"{ note: string | undefined }"}{" "} - 表示这个键必须存在,只是它的值可以是 undefined。 - 后者要求了一个前者并不要求的成员,所以前者不能赋给后者。 + 表示这个键必须存在,只是它的值可以是 undefined。后者要求了一个前者并不要求的成员,所以前者不能赋给后者。 } /> @@ -991,8 +962,7 @@ export default function StructuralPage() { } zh={ <> - 成员是函数时同样按形状比较,只是它的参数和返回值方向相反。 - 这部分第 02 章已经讲全了,包括{" "} + 成员是函数时同样按形状比较,只是它的参数和返回值方向相反。这部分第 02 章已经讲全了,包括{" "} strictFunctionTypes 开关,以及方法语法的例外。 } @@ -1055,13 +1025,9 @@ export default function StructuralPage() { } zh={ <> - 两边的对象一模一样。写在调用处报错,先存进变量就通过。 - 这不是 bug,而是一道独立的、更严格的检查: - 多余属性检查(excess property check)。 - 它只对新鲜的对象字面量生效 —— - 也就是直接写在「需要某个类型」的位置上的字面量。 - 它不属于可赋值性规则本身,这正是先存进变量它就消失的原因。 - 这样设计的理由,是下面两种情形的区别: + 两边的对象一模一样。写在调用处报错,先存进变量就通过。这不是 bug,而是一道独立的、更严格的检查: + 多余属性检查(excess property check)。它只对新鲜的对象字面量生效 —— + 也就是直接写在「需要某个类型」的位置上的字面量。它不属于可赋值性规则本身,这正是先存进变量它就消失的原因。这样设计的理由,是下面两种情形的区别: } /> @@ -1089,10 +1055,8 @@ export default function StructuralPage() { } zh={ <> - 这个对象是当场写出来、当场交出去的,没有第二个用途。 - 所以多出来的属性只有两种可能:拼错了( - sweetnes),或者误解了类型。 - 两种都是 bug,编译器于是报出来 —— + 这个对象是当场写出来、当场交出去的,没有第二个用途。所以多出来的属性只有两种可能:拼错了( + sweetnes),或者误解了类型。两种都是 bug,编译器于是报出来 —— 左边那杯半糖就是这么被救回来的。 } @@ -1124,10 +1088,7 @@ export default function StructuralPage() { } zh={ <> - 存在变量里的对象,可能在别处有完全正当的用途: - 它也许本来就是信息更全的 Barista, - 顺带被当成 Staff 用。§03 允许这件事, - 编译器就允许。代价是拼错的属性会一路混过去 —— + 存在变量里的对象,可能在别处有完全正当的用途:它也许本来就是信息更全的 Barista,顺带被当成 Staff 用。§03 允许这件事,编译器就允许。代价是拼错的属性会一路混过去 —— 右边那杯半糖就是这么丢的。 } @@ -1165,8 +1126,7 @@ export default function StructuralPage() { {'makeOrder({ item: "Boba milk tea", sweetnes: "half sugar" } as Order)'}
{" "} 确实能让报错消失。但错字还在,半糖照样丢。 - as 只是让编译器别再检查,它不会改变这个对象。 - 正确的修法永远是同一个:看清报错里的属性名,把拼写改对。 + as 只是让编译器别再检查,它不会改变这个对象。正确的修法永远是同一个:看清报错里的属性名,把拼写改对。 } /> @@ -1221,8 +1181,7 @@ export default function StructuralPage() {

- 当多出来的属性和目标类型里的任何成员都不像时, - 就没有建议,错误码也不同: + 当多出来的属性和目标类型里的任何成员都不像时,就没有建议,错误码也不同: Object literal may only specify known properties, and 'cup' does not exist in type 'Order'. @@ -1230,8 +1189,7 @@ export default function StructuralPage() {

- 两条说的是同一件事:对象字面量只能写目标类型认识的属性。 - 看到 ts(2561) 反而是好事 —— + 两条说的是同一件事:对象字面量只能写目标类型认识的属性。看到 ts(2561) 反而是好事 —— 编译器已经替你算出了正确的拼写。

@@ -1254,11 +1212,7 @@ export default function StructuralPage() { } zh={ <> - 到这里为止,这条规则都只是被描述了一遍。 - 下面这个窗口跑的是真编译器,它可以直接证明。 - 同一个对象在里面写了两遍:一遍是直接写在调用处的字面量, - 一遍先过了一次变量,两遍都把 sweetness 拼错了。 - 只有一处被报出来,就是第 11 行那个字面量 —— + 到这里为止,这条规则都只是被描述了一遍。下面这个窗口跑的是真编译器,它可以直接证明。同一个对象在里面写了两遍:一遍是直接写在调用处的字面量,一遍先过了一次变量,两遍都把 sweetness 拼错了。只有一处被报出来,就是第 11 行那个字面量 —— 在那个位置,多出来的属性不可能有别的用途。 } @@ -1304,14 +1258,9 @@ export default function StructuralPage() { } zh={ <> - 在那个窗口里,有四件事值得动手做一遍。切到第一个预设: - 字面量没了,报错也没了,顾客要的半糖同样没了 —— + 在那个窗口里,有四件事值得动手做一遍。切到第一个预设:字面量没了,报错也没了,顾客要的半糖同样没了 —— 这就是这道豁免的全部代价。切到第二个预设:cup{" "} - 和 Order 里的任何成员都不像, - 建议随之消失,错误码变成 ts(2353)。然后用正确的方式修: - 给 sweetnes 补上那个 s。 - 最后,想知道 as 到底买到了什么, - 就在字面量后面加一句 as Order —— + 和 Order 里的任何成员都不像,建议随之消失,错误码变成 ts(2353)。然后用正确的方式修:给 sweetnes 补上那个 s。最后,想知道 as 到底买到了什么,就在字面量后面加一句 as Order —— 报错走了,错字还在。 } @@ -1356,13 +1305,7 @@ export default function StructuralPage() { } zh={ <> - 店里有两个类型:外卖平台用的 DeliveryAddress, - 和到店自取用的 PickupInfo。 - 两拨人分头定义,结果都是一个电话加一句备注。 - 某天有人把自取单传给了打快递面单的函数, - 编译器一声没吭 —— 成员对得上,它没有理由拦。 - 当晚,骑手照着面单上的备注, - 去店里取了一单根本不存在的外卖。 + 店里有两个类型:外卖平台用的 DeliveryAddress,和到店自取用的 PickupInfo。两拨人分头定义,结果都是一个电话加一句备注。某天有人把自取单传给了打快递面单的函数,编译器一声没吭 —— 成员对得上,它没有理由拦。当晚,骑手照着面单上的备注,去店里取了一单根本不存在的外卖。 } /> @@ -1390,9 +1333,7 @@ export default function StructuralPage() { } zh={ <> - 结构化类型的判断完全正确:两个类型的成员相同。 - 问题在于「外卖」和「自取」的区别只存在于我们脑子里, - 从没写进形状 —— 而编译器只读形状。 + 结构化类型的判断完全正确:两个类型的成员相同。问题在于「外卖」和「自取」的区别只存在于我们脑子里,从没写进形状 —— 而编译器只读形状。 } /> @@ -1414,9 +1355,7 @@ export default function StructuralPage() { zh={ <> 更常见的版本是 ID 混用:UserId 和{" "} - PostId 都是 string, - 于是「拿帖子 ID 去查用户」全程编译通过。 - 真正的解法只有一个:把区别写进形状 —— + PostId 都是 string,于是「拿帖子 ID 去查用户」全程编译通过。真正的解法只有一个:把区别写进形状 —— 既然只比形状,那就让形状不同。 } @@ -1450,14 +1389,10 @@ export default function StructuralPage() { } zh={ <> - 让每个类型和一个带标记成员的对象类型求交集, - 两个形状就不一样了,两个 string 也就不再能互换。 - __brand 只存在于编译期,运行时没有任何开销。 - 代价也要说清楚:普通 string 不能赋给{" "} - UserId,所以造一个出来总得断言一次。 - 通常的做法是只写一个 toUserId(s: string){" "} - 函数来做这次断言,只在数据进入系统的入口调用它, - 别处一律不写 as UserId。§06 的第 3 + 让每个类型和一个带标记成员的对象类型求交集,两个形状就不一样了,两个 string 也就不再能互换。 + __brand 只存在于编译期,运行时没有任何开销。代价也要说清楚:普通 string 不能赋给{" "} + UserId,所以造一个出来总得断言一次。通常的做法是只写一个 toUserId(s: string){" "} + 函数来做这次断言,只在数据进入系统的入口调用它,别处一律不写 as UserId。§06 的第 3 个任务会带你走一遍。 } @@ -1495,12 +1430,9 @@ export default function StructuralPage() { 把鸭子测试推到极限:{"{}"} 要求 零个成员,而一份空要求几乎人人满足。 42、"tea"、 - true、函数、数组、任何对象, - 统统能赋给 {"{}"}。只有 null 和{" "} + true、函数、数组、任何对象,统统能赋给 {"{}"}。只有 null 和{" "} undefined 进不来,而且这还是靠{" "} - strictNullChecks 开着。所以: - 想表达「任意对象」写 object; - 想表达「什么值都可能、用之前先收窄」写{" "} + strictNullChecks 开着。所以:想表达「任意对象」写 object;想表达「什么值都可能、用之前先收窄」写{" "} unknown。{"{}"}{" "} 看着像一条要求,其实什么都没要求。 @@ -1548,8 +1480,7 @@ export default function StructuralPage() { ), zh: ( <> - TypeScript 用的是结构化类型:兼容取决于成员,不取决于名字。 - 名字是标签,成员清单才是类型本体。class 也按同一套规则比较。 + TypeScript 用的是结构化类型:兼容取决于成员,不取决于名字。名字是标签,成员清单才是类型本体。class 也按同一套规则比较。 ), }, @@ -1564,8 +1495,7 @@ export default function StructuralPage() { ), zh: ( <> - 方向很重要:成员多的类型可以赋给成员少的,反过来不行。 - 用集合观最好记 —— 要求越多,集合越小,小集合被包含在大集合里。 + 方向很重要:成员多的类型可以赋给成员少的,反过来不行。用集合观最好记 —— 要求越多,集合越小,小集合被包含在大集合里。 ), }, @@ -1580,10 +1510,7 @@ export default function StructuralPage() { ), zh: ( <> - 多余属性检查只对新鲜的对象字面量生效: - 写在调用处报错,先存进变量就放行。 - 它是叠加在可赋值性之上的一道额外检查,目标是抓拼写错误, - 不是一条通用规则。 + 多余属性检查只对新鲜的对象字面量生效:写在调用处报错,先存进变量就放行。它是叠加在可赋值性之上的一道额外检查,目标是抓拼写错误,不是一条通用规则。 ), }, @@ -1598,8 +1525,7 @@ export default function StructuralPage() { ), zh: ( <> - 用 as 压掉这个报错,只是把错字藏起来,并没有修好。 - 读报错里的属性名,把拼写改对。ts(2561){" "} + 用 as 压掉这个报错,只是把错字藏起来,并没有修好。读报错里的属性名,把拼写改对。ts(2561){" "} 连正确的名字都告诉你了,ts(2353) 是同一个错误,只是没有建议。 ), @@ -1620,8 +1546,7 @@ export default function StructuralPage() { zh: ( <> 形状相同就能互换,哪怕两者的含义毫不相干。 - UserId 混 PostId 这类事故, - 用品牌类型把区别写进形状。另外记住: + UserId 混 PostId 这类事故,用品牌类型把区别写进形状。另外记住: {"{ a?: number }"} 不等于{" "} {"{ a: number | undefined }"};{"{}"}{" "} 什么都不要求 —— 想说「任意对象」请写 object。 diff --git a/app/structural/viz.tsx b/app/structural/viz.tsx index b20b2da..2f38c8b 100644 --- a/app/structural/viz.tsx +++ b/app/structural/viz.tsx @@ -96,9 +96,7 @@ export function CompatPlayground() { zh={ <> 通过。 - Staff 只问一件事:有没有 name, - 是不是 string?Barista 的回答是有。 - 多出来的 makeTea 和 years{" "} + Staff 只问一件事:有没有 name,是不是 string?Barista 的回答是有。多出来的 makeTea 和 years{" "} 不在要求里,直接被忽略。成员多的类型,可以顶替成员少的。 } @@ -440,8 +438,7 @@ function buildFrames(preset: Preset, literal: boolean): MatchFrame[] { } zh={ <> - 还剩一个 {extraPins[0].key},目标类型里没有声明它。 - 这个字面量只为这一次调用而写,多出来的属性几乎都是拼错或误解。 + 还剩一个 {extraPins[0].key},目标类型里没有声明它。这个字面量只为这一次调用而写,多出来的属性几乎都是拼错或误解。 多余属性检查在这里报错。 } @@ -459,9 +456,7 @@ function buildFrames(preset: Preset, literal: boolean): MatchFrame[] { } zh={ <> - 还剩一个 {extraPins[0].key},目标类型里没有声明它。 - 但这个对象存在变量里,别处可能另有正当用途, - 于是按普通规则判断:成员多是允许的。不报错。 + 还剩一个 {extraPins[0].key},目标类型里没有声明它。但这个对象存在变量里,别处可能另有正当用途,于是按普通规则判断:成员多是允许的。不报错。 } /> @@ -507,8 +502,7 @@ function buildFrames(preset: Preset, literal: boolean): MatchFrame[] { } zh={ <> - 通过。多出来的 {extraPins[0].key} 没有被追究。 - 想看另一种结果,把上面的开关拨到「字面量直传」,再走一遍。 + 通过。多出来的 {extraPins[0].key} 没有被追究。想看另一种结果,把上面的开关拨到「字面量直传」,再走一遍。 } /> @@ -523,8 +517,7 @@ function buildFrames(preset: Preset, literal: boolean): MatchFrame[] { } zh={ <> - 通过。每个必需成员都在,类型也对得上。 - 这个对象从没声明过自己是 Staff,而这并不影响结果。 + 通过。每个必需成员都在,类型也对得上。这个对象从没声明过自己是 Staff,而这并不影响结果。 } /> @@ -540,8 +533,7 @@ function buildFrames(preset: Preset, literal: boolean): MatchFrame[] { } zh={ <> - 拒绝。有必需成员没对上,而且无论对象怎么传都是这个结果。 - 编译器的原文就在上面 —— 它描述的正是刚才没通过的那个成员。 + 拒绝。有必需成员没对上,而且无论对象怎么传都是这个结果。编译器的原文就在上面 —— 它描述的正是刚才没通过的那个成员。 } /> @@ -559,8 +551,7 @@ function buildFrames(preset: Preset, literal: boolean): MatchFrame[] { zh={ <> 拒绝。同一个对象,先存进变量就能通过 —— - 把开关拨到「先存变量」验证一下。这不是前后矛盾: - 编译器对新鲜的字面量刻意更严,因为此处多出来的属性几乎都是 bug。 + 把开关拨到「先存变量」验证一下。这不是前后矛盾:编译器对新鲜的字面量刻意更严,因为此处多出来的属性几乎都是 bug。 } /> diff --git a/app/tsconfig/page.tsx b/app/tsconfig/page.tsx index cf844ea..ce5bb4e 100644 --- a/app/tsconfig/page.tsx +++ b/app/tsconfig/page.tsx @@ -604,9 +604,7 @@ export default function TsconfigPage() { <> tsconfig.json 有两个读者。一个是{" "} tsc,你在终端或 CI 里跑的那个命令;另一个是 - 类型服务 —— 编辑器在后台跑的进程, - 你打字时的红线就是它画的。两者读的是同一份文件, - 所以编辑器里的红线和 CI 里的失败说的是同一句话。 + 类型服务 —— 编辑器在后台跑的进程,你打字时的红线就是它画的。两者读的是同一份文件,所以编辑器里的红线和 CI 里的失败说的是同一句话。 } /> @@ -622,8 +620,7 @@ export default function TsconfigPage() { } zh={ <> - 它还决定检查有多严 —— 这一点很像游戏里的难度设置: - 同一份代码,规则不同,结论就不同。生成这个文件只要一句命令。 + 它还决定检查有多严 —— 这一点很像游戏里的难度设置:同一份代码,规则不同,结论就不同。生成这个文件只要一句命令。 } /> @@ -663,11 +660,8 @@ export default function TsconfigPage() { <> 有两点值得注意。一是那个多余的逗号和这些注释: tsconfig.json 是 JSONC —— - 允许写注释的 JSON,所以你可以在里面记下「为什么开这一项」。 - 二是段落名。strict 在「Recommended Options」里, - 而 noUncheckedIndexedAccess 和{" "} - exactOptionalPropertyTypes 被单列成另一段。 - 这个分家是语言层面的事实,§03 就讲它。 + 允许写注释的 JSON,所以你可以在里面记下「为什么开这一项」。二是段落名。strict 在「Recommended Options」里,而 noUncheckedIndexedAccess 和{" "} + exactOptionalPropertyTypes 被单列成另一段。这个分家是语言层面的事实,§03 就讲它。 } /> @@ -695,8 +689,7 @@ export default function TsconfigPage() { zh={ <> noImplicitAny、strictNullChecks{" "} - 以及另外七项,决定编译器拒绝多少东西。 - 这是本章的重点,§02 逐项讲。 + 以及另外七项,决定编译器拒绝多少东西。这是本章的重点,§02 逐项讲。 } /> @@ -718,8 +711,7 @@ export default function TsconfigPage() { } zh={ <> - 产出的 JavaScript 用哪一代语法、哪种模块格式、 - 放进哪个目录。§04 讲这一组 —— + 产出的 JavaScript 用哪一代语法、哪种模块格式、放进哪个目录。§04 讲这一组 —— 也讲它们在什么情况下完全不起作用。 } @@ -777,11 +769,8 @@ export default function TsconfigPage() { zh={ <> tsc 做两件互相独立的事:检查类型,和 - 写出 JavaScript —— 每个输入文件对应一个输出文件。 - 它不是打包器,从不把你的模块合成一个文件。 - 而这两件事确实是独立的: - 类型报错不会阻止 JavaScript 被写出来, - 除非你同时开 noEmitOnError。 + 写出 JavaScript —— 每个输入文件对应一个输出文件。它不是打包器,从不把你的模块合成一个文件。而这两件事确实是独立的: + 类型报错不会阻止 JavaScript 被写出来,除非你同时开 noEmitOnError。 } /> @@ -812,8 +801,7 @@ export default function TsconfigPage() { <> 今天大多数项目会把这两件事分开:打包器,或者 swc{" "} / esbuild,负责擦掉类型、产出 JavaScript,而{" "} - tsc --noEmit 只用来做类型检查。 - 这种配置下,tsconfig 里的产物类选项不影响上线的东西{" "} + tsc --noEmit 只用来做类型检查。这种配置下,tsconfig 里的产物类选项不影响上线的东西{" "} —— 决定权在另一个工具的配置里。花时间调{" "} target 之前,先确认你的项目属于哪一种。 @@ -871,9 +859,7 @@ export default function TsconfigPage() { useUnknownInCatchVariables、 alwaysStrict。这个名单是会变长的 —— strictBuiltinIteratorReturn 是 TypeScript 5.6 - 才加进来的 —— 所以要确认某个具体版本的成员, - 查那个版本的文档,别凭记忆。还有几个听起来像成员、 - 其实不是的:noImplicitOverride、 + 才加进来的 —— 所以要确认某个具体版本的成员,查那个版本的文档,别凭记忆。还有几个听起来像成员、其实不是的:noImplicitOverride、 noUnusedLocals、noImplicitReturns{" "} 都在模板另一段「Style Options」里,而且是关着的。 @@ -925,11 +911,8 @@ export default function TsconfigPage() { } zh={ <> - 文件里埋了三处问题,每一处由不同的开关负责。 - 先全部关掉,读一下结论:没有报错。 - 再单独打开 noImplicitAny,再单独打开{" "} - strictNullChecks,最后打开 strict。 - 代码一个字没动,变的只有规则。 + 文件里埋了三处问题,每一处由不同的开关负责。先全部关掉,读一下结论:没有报错。再单独打开 noImplicitAny,再单独打开{" "} + strictNullChecks,最后打开 strict。代码一个字没动,变的只有规则。 } /> @@ -985,12 +968,9 @@ export default function TsconfigPage() { 'undefined'.
而数组只有两个元素, menu[5] 依旧一声不响。 - 管这件事的是 noUncheckedIndexedAccess, - 它不是这个家族的成员。 - {" "} - 单独把它打开,picked 上才出现第三处报错。 - §03 讲的就是这句话,而你刚刚是问编译器要的答案, - 不是听我们说的。 + 管这件事的是 noUncheckedIndexedAccess,它不是这个家族的成员。 +
+ 单独把它打开,picked 上才出现第三处报错。§03 讲的就是这句话,而你刚刚是问编译器要的答案,不是听我们说的。 } /> @@ -1012,12 +992,10 @@ export default function TsconfigPage() { } zh={ <> - 还有一种组合值得试:让 strictNullChecks 关着, - 单独打开 noUncheckedIndexedAccess。开关是亮的, + 还有一种组合值得试:让 strictNullChecks 关着,单独打开 noUncheckedIndexedAccess。开关是亮的, 但什么也没发生 —— 整个文件一处报错都没有。没有{" "} strictNullChecks,string | undefined{" "} - 会退回成 string,这个选项也就无话可说。 - 「开着但什么也没做」,正是手工挑选成员时会踩的那个坑。 + 会退回成 string,这个选项也就无话可说。「开着但什么也没做」,正是手工挑选成员时会踩的那个坑。 } /> @@ -1036,9 +1014,7 @@ export default function TsconfigPage() { } zh={ <> - 九项里有两项值得细看。第一项是 noImplicitAny。 - 当编译器自己推不出类型时,这项检查会让它报错, - 而不是悄悄按 any 处理: + 九项里有两项值得细看。第一项是 noImplicitAny。当编译器自己推不出类型时,这项检查会让它报错,而不是悄悄按 any 处理: } /> @@ -1064,8 +1040,7 @@ export default function TsconfigPage() { zh={ <> 推不出类型就当 any,而 any{" "} - 会让由它派生出的一切都不受检查。 - 写错的字段、传错的参数,全都通过。 + 会让由它派生出的一切都不受检查。写错的字段、传错的参数,全都通过。 那几行只是名义上的 TypeScript。 } @@ -1094,8 +1069,7 @@ export default function TsconfigPage() { } zh={ <> - 规则就是:编译器推不出来,你就得自己写。 - 一旦类型写下来了,pirce{" "} + 规则就是:编译器推不出来,你就得自己写。一旦类型写下来了,pirce{" "} 这个拼写错误就在它所在的那一行报错。 } @@ -1118,10 +1092,8 @@ export default function TsconfigPage() { } zh={ <> - 第二项是 strictNullChecks, - 它是对存量代码影响最大的一员。它关着时,null 和{" "} - undefined 可以赋给任何类型, - 于是「这个值可能没有」这句话根本没法写进类型: + 第二项是 strictNullChecks,它是对存量代码影响最大的一员。它关着时,null 和{" "} + undefined 可以赋给任何类型,于是「这个值可能没有」这句话根本没法写进类型: } /> @@ -1179,8 +1151,7 @@ export default function TsconfigPage() { } zh={ <> - 「可能没有」现在写进了类型,所以取属性之前必须先检查。 - 凌晨两点的 TypeError{" "} + 「可能没有」现在写进了类型,所以取属性之前必须先检查。凌晨两点的 TypeError{" "} 变成了今天下午的一条红线。 } @@ -1212,9 +1183,7 @@ export default function TsconfigPage() { zh={ <> null 引用是 Tony Hoare 在 1965 年引入的,他在 2009 - 年公开为此道歉,称之为自己的「十亿美元错误」。 - 问题不在这个值本身,而在于多数语言里每个类型都默默包含它, - 于是编译器没法告诉你哪里漏了检查。 + 年公开为此道歉,称之为自己的「十亿美元错误」。问题不在这个值本身,而在于多数语言里每个类型都默默包含它,于是编译器没法告诉你哪里漏了检查。 strictNullChecks 是 TypeScript 给出的回答: 值可能没有,就写进类型;要用,先检查。 如果这一族里只能开一项,就开它。 @@ -1234,8 +1203,7 @@ export default function TsconfigPage() { } zh={ <> - 剩下七项,一张表过。要读的是最后一列: - 这项检查关着时,什么会被漏掉。 + 剩下七项,一张表过。要读的是最后一列:这项检查关着时,什么会被漏掉。 } /> @@ -1278,8 +1246,7 @@ export default function TsconfigPage() { } zh={ <> - 只能处理 Cat 的函数, - 被放到了要求处理任意 Animal 的位置。 + 只能处理 Cat 的函数,被放到了要求处理任意 Animal 的位置。 TS2322 } @@ -1347,8 +1314,7 @@ export default function TsconfigPage() { } zh={ <> - 打算「回头再赋值」的属性一直是 undefined, - 直到有人去读它。TS2564 + 打算「回头再赋值」的属性一直是 undefined,直到有人去读它。TS2564 } /> @@ -1370,8 +1336,7 @@ export default function TsconfigPage() { zh={ <> 内置迭代器的 next() 返回{" "} - IteratorResult<T, undefined>, - 而不是 <T, any> + IteratorResult<T, undefined>,而不是 <T, any> } /> @@ -1425,8 +1390,7 @@ export default function TsconfigPage() { } zh={ <> - 游离函数里的 this 是 any, - 从它上面读的每个属性都不受检查。TS2683 + 游离函数里的 this 是 any,从它上面读的每个属性都不受检查。TS2683 } /> @@ -1528,10 +1492,7 @@ export default function TsconfigPage() { } zh={ <> - strictPropertyInitialization 没法单独成立。 - 「没赋值」的意思就是「值是 undefined」, - 而 strictNullChecks 关着时这句话不成立。 - 编译器不是忽略这个组合,而是直接拒绝: + strictPropertyInitialization 没法单独成立。「没赋值」的意思就是「值是 undefined」,而 strictNullChecks 关着时这句话不成立。编译器不是忽略这个组合,而是直接拒绝: TS5052: Option 'strictPropertyInitialization' cannot be specified @@ -1554,9 +1515,7 @@ export default function TsconfigPage() { } zh={ <> - 这也是为什么推荐直接写 strict: true, - 而不是把成员一条条列出来:它们本来是配套设计的, - 自己挑一部分,很容易挑出一个「开着但什么也没做」的选项。 + 这也是为什么推荐直接写 strict: true,而不是把成员一条条列出来:它们本来是配套设计的,自己挑一部分,很容易挑出一个「开着但什么也没做」的选项。 } /> @@ -1591,9 +1550,7 @@ export default function TsconfigPage() { zh={ <> 第一项是 noUncheckedIndexedAccess。读{" "} - arr[0] 得到的是元素类型, - 编译器默认下标不会越界。即便 strict 家族全开, - 左边这段照样能编译: + arr[0] 得到的是元素类型,编译器默认下标不会越界。即便 strict 家族全开,左边这段照样能编译: } /> @@ -1621,8 +1578,7 @@ export default function TsconfigPage() { zh={ <> 数组只有三个元素,所以运行时 sizes[3] 是{" "} - undefined。而类型说它是 string, - 没有任何东西反驳这句话。 + undefined。而类型说它是 string,没有任何东西反驳这句话。 } /> @@ -1651,8 +1607,7 @@ export default function TsconfigPage() { } zh={ <> - 每次下标取值都变成 T | undefined, - 想用就得先收窄。注意它的依赖:这个选项需要{" "} + 每次下标取值都变成 T | undefined,想用就得先收窄。注意它的依赖:这个选项需要{" "} strictNullChecks。后者关着时, string | undefined 会退回成{" "} string,于是这个选项完全没有效果。 @@ -1676,9 +1631,7 @@ export default function TsconfigPage() { } zh={ <> - 第二项是 exactOptionalPropertyTypes。 - 可选属性平时把两件事混在一起,它把它们分开: - 键不存在,和键存在但值是 undefined。 + 第二项是 exactOptionalPropertyTypes。可选属性平时把两件事混在一起,它把它们分开:键不存在,和键存在但值是 undefined。 } /> @@ -1705,8 +1658,7 @@ export default function TsconfigPage() { <> 这个区分听着像抬杠,直到你写一个运行时判断。 {`"topping" in o`} 和{" "} - Object.keys(o) 问的是「键在不在」。 - 只有开了这个选项,答案才和类型对得上 —— 用 in{" "} + Object.keys(o) 问的是「键在不在」。只有开了这个选项,答案才和类型对得上 —— 用 in{" "} 收窄之后拿到的是 string,而不是{" "} string | undefined。 @@ -1739,10 +1691,7 @@ export default function TsconfigPage() { <> 这两项针对的都是存量代码里到处都有的写法。 noUncheckedIndexedAccess 对每一次 - 下标读取都有话说,所以在一个大项目里打开它, - 可能冒出几百个错 —— 单个都很小,加起来是一个项目。 - 把它们放在 strict 之外, - 存量项目就可以先采用 strict,不必同时接下这件事。 + 下标读取都有话说,所以在一个大项目里打开它,可能冒出几百个错 —— 单个都很小,加起来是一个项目。把它们放在 strict 之外,存量项目就可以先采用 strict,不必同时接下这件事。 } /> @@ -1762,8 +1711,7 @@ export default function TsconfigPage() { } zh={ <> - 新项目没有这个包袱,tsc --init 也体现了这一点: - 它把两个选项都写成 true。另外注意, + 新项目没有这个包袱,tsc --init 也体现了这一点:它把两个选项都写成 true。另外注意, exactOptionalPropertyTypes 和{" "} strictPropertyInitialization 有同样的硬依赖 —— 不开 strictNullChecks 就设它,编译器会报{" "} @@ -1799,9 +1747,7 @@ export default function TsconfigPage() { } zh={ <> - target 决定编译器写出哪一代 JavaScript 语法。 - 比 target 更新的写法,都会被改写成较旧的等价形式。 - 拨一下对比看看: + target 决定编译器写出哪一代 JavaScript 语法。比 target 更新的写法,都会被改写成较旧的等价形式。拨一下对比看看: } /> @@ -1821,9 +1767,7 @@ export default function TsconfigPage() { } zh={ <> - 上面那份对照是手画的。下面是把同一个问题交给编译器: - 在下拉框里挑一个 target,切到编译产物 JS, - 读真正写出来的东西。源码全程不变。 + 上面那份对照是手画的。下面是把同一个问题交给编译器:在下拉框里挑一个 target,切到编译产物 JS,读真正写出来的东西。源码全程不变。 } /> @@ -1875,8 +1819,7 @@ export default function TsconfigPage() { class 字段搬进了构造函数,而只声明没赋值的{" "} note 整个消失了。到 es2015, ?? 和 ?. 变成了{" "} - _a !== null && _a !== void 0, - 文件顶部还多出一个临时变量。到 es5, + _a !== null && _a !== void 0,文件顶部还多出一个临时变量。到 es5, class 变成往 prototype 上挂方法的{" "} IIFE,模板字符串变成{" "} "".concat(…),for...of{" "} @@ -1900,10 +1843,8 @@ export default function TsconfigPage() { } zh={ <> - 这些改写,每一条都是你没写过、却要发布、要调试、 - 要在堆栈信息里读的代码。这就是低 target{" "} - 的价钱,只有当外面真的有跑不了新语法的东西时才值得付。 - 另外注意:在这里换 target,默认的{" "} + 这些改写,每一条都是你没写过、却要发布、要调试、要在堆栈信息里读的代码。这就是低 target{" "} + 的价钱,只有当外面真的有跑不了新语法的东西时才值得付。另外注意:在这里换 target,默认的{" "} lib 也跟着换了 —— 下一段讲的正是「为什么这是两个问题」。 @@ -1934,8 +1875,7 @@ export default function TsconfigPage() { Promise、Map、 Array.prototype.at、document{" "} 这些名字到底认不认识。设了 target{" "} - 就顺带定了一个默认 lib, - 所以在你需要把它们分开之前,这两个选项感觉像是一个: + 就顺带定了一个默认 lib,所以在你需要把它们分开之前,这两个选项感觉像是一个: } /> @@ -1966,12 +1906,8 @@ export default function TsconfigPage() { zh={ <> 这就是让区别变明显的那种情况:你想让 Promise{" "} - 有类型,同时仍然产出 ES5 语法,于是把两个选项分开设。 - 但要清楚你说出口的是什么。lib{" "} - 是你对运行环境的声明,编译器会信你的话, - 它不会加 polyfill。声明了 es2022, - 却跑在没有 Promise 的引擎上, - 就是运行时失败,而编译期一声不响。 + 有类型,同时仍然产出 ES5 语法,于是把两个选项分开设。但要清楚你说出口的是什么。lib{" "} + 是你对运行环境的声明,编译器会信你的话,它不会加 polyfill。声明了 es2022,却跑在没有 Promise 的引擎上,就是运行时失败,而编译期一声不响。 } /> @@ -1992,12 +1928,10 @@ export default function TsconfigPage() { } zh={ <> - module 和 moduleResolution 同样是两个选项, - 不是一个。module 是编译器写出的模块格式, + module 和 moduleResolution 同样是两个选项,不是一个。module 是编译器写出的模块格式, moduleResolution 是它用来找到{" "} import "./util"{" "} - 背后那个文件的算法。实际配置时你是成对选的, - 而选哪一对取决于谁消费产物: + 背后那个文件的算法。实际配置时你是成对选的,而选哪一对取决于谁消费产物: } /> @@ -2031,8 +1965,7 @@ export default function TsconfigPage() { } zh={ <> - nodenext 按 Node 自己的规则来, - 包括 package.json 里的 type 和{" "} + nodenext 按 Node 自己的规则来,包括 package.json 里的 type 和{" "} exports 字段。它同时替你定下{" "} moduleResolution —— 你写成别的会得到{" "} @@ -2071,8 +2004,7 @@ export default function TsconfigPage() { zh={ <> bundler 对齐的是 Vite 和 esbuild - 实际的解析行为,这样编译器和真正干活的那个工具说的是一致的。 - 它要求 module 是 esnext、 + 实际的解析行为,这样编译器和真正干活的那个工具说的是一致的。它要求 module 是 esnext、 preserve,或 es2015 及以上 —— 否则报 TS5095。而既然 JavaScript 由打包器产出,这里通常就该开 noEmit。 @@ -2107,8 +2039,7 @@ export default function TsconfigPage() { } zh={ <> - 产物去哪(dist)、源码在哪(src)。 - 不设 outDir,每个 .js{" "} + 产物去哪(dist)、源码在哪(src)。不设 outDir,每个 .js{" "} 就落在自己的 .ts 旁边 —— 源码树变得难读,也很容易误提交。 @@ -2130,8 +2061,7 @@ export default function TsconfigPage() { } zh={ <> - 写出一份从产物回到源码的映射, - 于是调试器和堆栈信息可以指向你的 .ts{" "} + 写出一份从产物回到源码的映射,于是调试器和堆栈信息可以指向你的 .ts{" "} 那一行,而不是产物那一行。开着。 } @@ -2153,9 +2083,7 @@ export default function TsconfigPage() { } zh={ <> - 只当类型用的导入必须写成 import type, - 否则报 TS1484。这样编译器就不必猜 - 「这个 import 能不能删」—— 它只删标了 type{" "} + 只当类型用的导入必须写成 import type,否则报 TS1484。这样编译器就不必猜「这个 import 能不能删」—— 它只删标了 type{" "} 的那些。第 09 章讲这件事。 } @@ -2177,8 +2105,7 @@ export default function TsconfigPage() { } zh={ <> - 拒绝那些无法「一个文件一个文件单独编译」的写法。 - 比如不写 export type 就转发一个类型,会报{" "} + 拒绝那些无法「一个文件一个文件单独编译」的写法。比如不写 export type 就转发一个类型,会报{" "} TS1205。当产物由 Babel、swc{" "} 或 esbuild 这类单文件转译器生成时,这一项是必要的 —— 它们看不到你的其他文件。 @@ -2207,14 +2134,12 @@ export default function TsconfigPage() { zh={ <> 让你可以对一个用 export = 的 CommonJS 模块写{" "} - import legacy from "legacy"。 - 不开会报{" "} + import legacy from "legacy"。不开会报{" "} TS1259: Module can only be default-imported using the 'esModuleInterop' flag - 。它同时会改变产物里的互操作代码, - 所以现代配置里默认是开的。 + 。它同时会改变产物里的互操作代码,所以现代配置里默认是开的。 } /> @@ -2237,8 +2162,7 @@ export default function TsconfigPage() { <> noEmit:只检查、不写文件 —— JavaScript 由别的工具产出时,就该用它。 - noEmitOnError:只在没有报错时才写产物。 - 这是两个不同的问题,而两者默认都是关的。 + noEmitOnError:只在没有报错时才写产物。这是两个不同的问题,而两者默认都是关的。 } /> @@ -2273,10 +2197,7 @@ export default function TsconfigPage() { .d.ts 文件内部 - 的类型检查 —— 全部 .d.ts, - 包括你自己写的那些。它不跳过你的 .ts 代码, - 也不影响这些声明继续被用来检查你的代码。 - 它跳过的只是「声明文件自己有没有毛病」这一项。 + 的类型检查 —— 全部 .d.ts,包括你自己写的那些。它不跳过你的 .ts 代码,也不影响这些声明继续被用来检查你的代码。它跳过的只是「声明文件自己有没有毛病」这一项。 } /> @@ -2295,10 +2216,7 @@ export default function TsconfigPage() { } zh={ <> - 严格来说这会丢信息:库的声明文件确实可能有错。 - 但现实里那些错不归你修,而两个库的全局声明互相冲突时, - 还会因为一个与你的代码无关的原因让构建失败。 - 常见的选择是打开它,把检查的力气花在自己的文件上。 + 严格来说这会丢信息:库的声明文件确实可能有错。但现实里那些错不归你修,而两个库的全局声明互相冲突时,还会因为一个与你的代码无关的原因让构建失败。常见的选择是打开它,把检查的力气花在自己的文件上。 } /> @@ -2324,15 +2242,12 @@ export default function TsconfigPage() { zh={ <> 有个相关的选项值得知道:erasableSyntaxOnly - (TypeScript 5.8 起)会拒绝一切有运行时效果、 - 因此无法「只擦类型」就去掉的语法 —— enum、 - 带实现的 namespace、构造函数参数属性。报错是{" "} + (TypeScript 5.8 起)会拒绝一切有运行时效果、因此无法「只擦类型」就去掉的语法 —— enum、带实现的 namespace、构造函数参数属性。报错是{" "} TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled. {" "} - 如果你希望自己的文件能被「只擦类型」的工具直接运行 - (比如较新版本的 Node),这一项就有意义。 + 如果你希望自己的文件能被「只擦类型」的工具直接运行(比如较新版本的 Node),这一项就有意义。 } /> @@ -2367,10 +2282,7 @@ export default function TsconfigPage() { } zh={ <> - 失败的做法是一次做完:所有文件改名,strict 开满, - 然后面对四百个报错,又没法只上线一部分修复。 - 有效的做法是渐进:每一步系统都在跑, - 每一步都比上一步严。一帧一帧走一遍: + 失败的做法是一次做完:所有文件改名,strict 开满,然后面对四百个报错,又没法只上线一部分修复。有效的做法是渐进:每一步系统都在跑,每一步都比上一步严。一帧一帧走一遍: } /> @@ -2391,8 +2303,7 @@ export default function TsconfigPage() { zh={ <> 第 2 天那一步其实不必动 tsconfig。在某个 .js{" "} - 文件顶部写两行注释就够了:一行打开检查, - 一行给它一个可以对照的类型。 + 文件顶部写两行注释就够了:一行打开检查,一行给它一个可以对照的类型。 } /> @@ -2426,8 +2337,7 @@ export default function TsconfigPage() { 为这个文件打开检查。@type 注释给{" "} menu 一个类型 —— 没有它,menu 是{" "} any,而在 any 上读{" "} - it.pirce 是合法的,拼写错误就不会被报出来。 - JSDoc 类型是迁移期的桥,不是终点:等文件改成{" "} + it.pirce 是合法的,拼写错误就不会被报出来。JSDoc 类型是迁移期的桥,不是终点:等文件改成{" "} .ts,它们就变成普通的类型标注。 } @@ -2446,8 +2356,7 @@ export default function TsconfigPage() { } zh={ <> - 每次迁移都会有今天修不了的错。 - 压住一行有两种注释,而它们随时间的表现差得很远: + 每次迁移都会有今天修不了的错。压住一行有两种注释,而它们随时间的表现差得很远: } /> @@ -2492,8 +2401,7 @@ export default function TsconfigPage() { } zh={ <> - 它说的是「我预期这里有错」。错误消失时, - 这句话变成假的,于是注释自己报错。 + 它说的是「我预期这里有错」。错误消失时,这句话变成假的,于是注释自己报错。 迁移期用它,并在后面写上原因。 } @@ -2537,12 +2445,8 @@ export default function TsconfigPage() { } zh={ <> - 顺序反了。strict 的成本随代码量增长。 - 第一天它是零:每个错都在你写下那一行的那一分钟被纠正。 - 三个月后,它是散落在你已经记不清的代码里的几百个错, - 外加一个「要不就这样吧」的念头。渐进地打开{" "} - strict 是存量代码的合理策略; - 对新代码来说,它只是一张延后的账单。 + 顺序反了。strict 的成本随代码量增长。第一天它是零:每个错都在你写下那一行的那一分钟被纠正。三个月后,它是散落在你已经记不清的代码里的几百个错,外加一个「要不就这样吧」的念头。渐进地打开{" "} + strict 是存量代码的合理策略;对新代码来说,它只是一张延后的账单。 } /> @@ -2571,10 +2475,7 @@ export default function TsconfigPage() { } zh={ <> - 每一个 @ts-ignore 都是检查器不再读的一行。 - 偶尔为之是合理的取舍;成为习惯,就是一行一行地拆掉类型系统, - 而且没有任何地方记着拆在哪里:项目看起来是有检查的, - 实际上到处是没人列得出来的豁免。真的修不了的时候,用{" "} + 每一个 @ts-ignore 都是检查器不再读的一行。偶尔为之是合理的取舍;成为习惯,就是一行一行地拆掉类型系统,而且没有任何地方记着拆在哪里:项目看起来是有检查的,实际上到处是没人列得出来的豁免。真的修不了的时候,用{" "} @ts-expect-error 并在旁边写上原因 —— 问题消失后,这条记录会自己要求被删掉。 @@ -2605,13 +2506,8 @@ export default function TsconfigPage() { } zh={ <> - target 不是用来比新的版本号, - 它是你对「产物要跑在什么环境里」的陈述。 - 如果那个环境是某个公司内网里锁定在旧版本的浏览器, - 把它定成 esnext,就是把一个{" "} - SyntaxError 送到真实用户面前。 - 编译器没法提醒你,因为只有你知道产物跑在哪。 - 先回答「谁来跑」。 + target 不是用来比新的版本号,它是你对「产物要跑在什么环境里」的陈述。如果那个环境是某个公司内网里锁定在旧版本的浏览器,把它定成 esnext,就是把一个{" "} + SyntaxError 送到真实用户面前。编译器没法提醒你,因为只有你知道产物跑在哪。先回答「谁来跑」。 } /> @@ -2631,8 +2527,7 @@ export default function TsconfigPage() { zh={ <> 这个误区还有另一半:在一个由打包器产出 JavaScript - 的项目里花时间调 target。那种配置下, - 上线的语法由打包器自己的 target 决定,tsconfig 里的{" "} + 的项目里花时间调 target。那种配置下,上线的语法由打包器自己的 target 决定,tsconfig 里的{" "} target 只影响类型检查,以及 tsc{" "} 自己写出的那些文件。 @@ -2663,13 +2558,9 @@ export default function TsconfigPage() { } zh={ <> - 不存在唯一正确的配置。 - 从别的项目抄一份过来,通常会一起抄来一些描述 - 「那个项目的环境」而不是你的环境的设置。 - 值得抄的是判断依据。两个问题就决定了这个文件的大半: + 不存在唯一正确的配置。从别的项目抄一份过来,通常会一起抄来一些描述「那个项目的环境」而不是你的环境的设置。值得抄的是判断依据。两个问题就决定了这个文件的大半: 谁运行产物 —— 直接是 Node,还是打包器;以及 - 谁生成产物 —— tsc, - 还是另一个工具加上 tsc --noEmit 做检查。 + 谁生成产物 —— tsc,还是另一个工具加上 tsc --noEmit 做检查。 } /> @@ -2688,10 +2579,7 @@ export default function TsconfigPage() { zh={ <> 有两件事可以一般性地说。开 strict —— - 对新代码来说,没有反对的理由。以及, - 把你手上这份文件读一遍,而不是继承它: - 里面每一个选项都是关于你的项目的一句断言, - 而其中有些可能已经不成立了。 + 对新代码来说,没有反对的理由。以及,把你手上这份文件读一遍,而不是继承它:里面每一个选项都是关于你的项目的一句断言,而其中有些可能已经不成立了。 } /> @@ -2740,8 +2628,7 @@ export default function TsconfigPage() { zh: ( <> tsconfig.json 由 tsc{" "} - 和编辑器的类型服务共同读取,所以同一份代码在两边得到同一个答案。 - 它的选项分三类:检查多严、产物长什么样、管哪些文件。 + 和编辑器的类型服务共同读取,所以同一份代码在两边得到同一个答案。它的选项分三类:检查多严、产物长什么样、管哪些文件。 ), }, @@ -2790,8 +2677,7 @@ export default function TsconfigPage() { strict 不等于全部检查。 noUncheckedIndexedAccess(下标取值变成{" "} T | undefined)和{" "} - exactOptionalPropertyTypes 都在它之外, - 要单独开。两者都需要 strictNullChecks 才起作用。 + exactOptionalPropertyTypes 都在它之外,要单独开。两者都需要 strictNullChecks 才起作用。 ), }, @@ -2810,11 +2696,8 @@ export default function TsconfigPage() { zh: ( <> target 定产出的语法代次,并顺带定一个默认{" "} - lib;lib 决定有哪些类型声明存在, - 不会加 polyfill。module 是写出的模块格式, - moduleResolution 是怎么找到 import 的东西。 - 直接给 Node 跑:module: nodenext; - 交给打包器: + lib;lib 决定有哪些类型声明存在,不会加 polyfill。module 是写出的模块格式, + moduleResolution 是怎么找到 import 的东西。直接给 Node 跑:module: nodenext;交给打包器: esnext + moduleResolution: bundler。 ), @@ -2834,10 +2717,8 @@ export default function TsconfigPage() { <> tsc 会检查,并为每个输入文件写出一个{" "} .js;它不打包,而且除非设了{" "} - noEmitOnError,类型报错也不会阻止它写产物。 - 如果 JavaScript 是打包器或 swc 产出的、 - tsc --noEmit 只负责检查, - 那么产物类选项不影响上线的东西。 + noEmitOnError,类型报错也不会阻止它写产物。如果 JavaScript 是打包器或 swc 产出的、 + tsc --noEmit 只负责检查,那么产物类选项不影响上线的东西。 ), }, @@ -2859,8 +2740,7 @@ export default function TsconfigPage() { checkJs 或 @ts-check 加 JSDoc 类型 → 一次一个文件改成 .ts → strict{" "} 的成员一项一项开。压住某一行用{" "} - @ts-expect-error,不用 @ts-ignore: - 错误消失后它会报 TS2578,所以这笔账丢不了。 + @ts-expect-error,不用 @ts-ignore:错误消失后它会报 TS2578,所以这笔账丢不了。 ), }, diff --git a/app/tsconfig/viz.tsx b/app/tsconfig/viz.tsx index b96967e..68fd7eb 100644 --- a/app/tsconfig/viz.tsx +++ b/app/tsconfig/viz.tsx @@ -362,8 +362,7 @@ export function StrictPanel() { specified without specifying option 'strictNullChecks'.
- 「没赋值」的意思就是「值是 undefined」, - 而 strictNullChecks 关着时这句话根本不成立。 + 「没赋值」的意思就是「值是 undefined」,而 strictNullChecks 关着时这句话根本不成立。 } /> @@ -383,8 +382,7 @@ export function StrictPanel() { 开着,但什么也没变。strictNullChecks 关着时, string | undefined 会退回成{" "} string,所以给下标取值加上{" "} - | undefined 没有任何作用。 - 把 strictNullChecks 打开,这一行才开始报错。 + | undefined 没有任何作用。把 strictNullChecks 打开,这一行才开始报错。 } /> @@ -417,8 +415,7 @@ export function StrictPanel() { <> 🏆 五个 bug 现在全是编译错误。看最后一个开关: noUncheckedIndexedAccess{" "} - 不属于 strict: true。上面那个按钮只打开四个家族成员, - 这一个不动 —— 它得你自己开。 + 不属于 strict: true。上面那个按钮只打开四个家族成员,这一个不动 —— 它得你自己开。 } /> @@ -555,8 +552,7 @@ export function TargetSwitch() { zh={ <> 源码里用到的写法在 ES2022 里都已经存在,所以 - tsc 只擦掉类型,其他一个字不动。 - 产物小,而且和源码对得上、还能读。 + tsc 只擦掉类型,其他一个字不动。产物小,而且和源码对得上、还能读。 } /> @@ -575,11 +571,8 @@ export function TargetSwitch() { } zh={ <> - 为了跑在只认 ES5 的引擎上,tsc 把每一种较新的写法都改写成 - 旧的等价形式。产物变大,也更难读。只有真的存在这种引擎时 - 才把 target 定这么低 —— 而且记住, - target 不会替你补上缺失的运行时 API。 - 那件事由 lib 描述,而 lib{" "} + 为了跑在只认 ES5 的引擎上,tsc 把每一种较新的写法都改写成旧的等价形式。产物变大,也更难读。只有真的存在这种引擎时才把 target 定这么低 —— 而且记住, + target 不会替你补上缺失的运行时 API。那件事由 lib 描述,而 lib{" "} 只改变检查器相信什么。 } @@ -625,8 +618,7 @@ const MIG_FRAMES: MigFrame[] = [ } zh={ <> - 三个 JavaScript 文件,九百行,注释只有一句「别动」。 - 报错 0 —— 不是没有 bug,是没有人在查。 + 三个 JavaScript 文件,九百行,注释只有一句「别动」。报错 0 —— 不是没有 bug,是没有人在查。 } /> @@ -655,8 +647,7 @@ const MIG_FRAMES: MigFrame[] = [ zh={ <> npx tsc --init 生成配置。先只开一个选项: - allowJs。.js 文件从此进入编译, - 但只是被收进来,还没有被检查。谁也没被打扰,系统照常跑。 + allowJs。.js 文件从此进入编译,但只是被收进来,还没有被检查。谁也没被打扰,系统照常跑。 } /> @@ -687,8 +678,7 @@ const MIG_FRAMES: MigFrame[] = [ zh={ <> 加上 checkJs(或在某个文件顶部写{" "} - {"// @ts-check"}),编译器开始读这些旧代码。 - 假设它报了 23 个错。这些 bug 一直都在,今天才被点名。 + {"// @ts-check"}),编译器开始读这些旧代码。假设它报了 23 个错。这些 bug 一直都在,今天才被点名。 注意:完全没有类型标注的 JavaScript 会藏住大部分问题 —— JSDoc 注释就是你给检查器提供对照物的方式。 @@ -719,10 +709,8 @@ const MIG_FRAMES: MigFrame[] = [ } zh={ <> - 一次只改一个文件:order.js → order.ts, - 修完它的报错再动下一个。一时修不了的,写上{" "} - @ts-expect-error 并注明原因。 - 这行注释会在错误消失时自己报错,所以这笔账丢不了。 + 一次只改一个文件:order.js → order.ts,修完它的报错再动下一个。一时修不了的,写上{" "} + @ts-expect-error 并注明原因。这行注释会在错误消失时自己报错,所以这笔账丢不了。 } /> @@ -751,10 +739,7 @@ const MIG_FRAMES: MigFrame[] = [ } zh={ <> - 所有文件都是 .ts 了,于是把这一族一项一项打开: - 先 noImplicitAny 清掉隐式 any 的参数, - 再 strictNullChecks 处理没判的 null。 - 每开一项,报错都是一批做得完的量。 + 所有文件都是 .ts 了,于是把这一族一项一项打开:先 noImplicitAny 清掉隐式 any 的参数,再 strictNullChecks 处理没判的 null。每开一项,报错都是一批做得完的量。 } /> diff --git a/app/type-magic/page.tsx b/app/type-magic/page.tsx index 88ad27e..d795f60 100644 --- a/app/type-magic/page.tsx +++ b/app/type-magic/page.tsx @@ -428,8 +428,7 @@ export default function TypeMagicPage() { ), zh: ( <> - 上一章那些工具类型,拆开都是同一批零件:keyof、 - 条件类型、infer、映射类型。 这一章把零件一个个认全 —— + 上一章那些工具类型,拆开都是同一批零件:keyof、条件类型、infer、映射类型。这一章把零件一个个认全 —— 章末,你会亲手把那五个工具类型重新写一遍。 ), @@ -513,9 +512,7 @@ export default function TypeMagicPage() { zh={ <> 上一章你用 Partial、Pick{" "} - 用得挺顺,没问过它们怎么工作。拆开看,里面没有特别的东西: - 一个读键的、一个读类型的、一个做判断的、一个做循环的。 - 把它们拼起来,类型系统就成了一门可以编程的语言: + 用得挺顺,没问过它们怎么工作。拆开看,里面没有特别的东西:一个读键的、一个读类型的、一个做判断的、一个做循环的。把它们拼起来,类型系统就成了一门可以编程的语言: 输入是类型,输出也是类型,而且只在编译时运行。 } @@ -534,8 +531,7 @@ export default function TypeMagicPage() { } zh={ <> - 你熟悉的 JavaScript 操作,大多在类型这一侧有个对应的写法。 - 下面这张表就是本章的地图:每一行对应一节。 + 你熟悉的 JavaScript 操作,大多在类型这一侧有个对应的写法。下面这张表就是本章的地图:每一行对应一节。 } /> @@ -645,8 +641,7 @@ export default function TypeMagicPage() { } zh={ <> - 这一章的所有东西都发生在 tsc 检查代码的时候。 - 检查完,它们全部被去掉。这就是类型擦除,和第 00 + 这一章的所有东西都发生在 tsc 检查代码的时候。检查完,它们全部被去掉。这就是类型擦除,和第 00 章讲的是同一条规则。所以复杂的类型只会花掉编译时间和可读性, 打包出来的 JavaScript 一个字节都不会多。 @@ -696,8 +691,7 @@ export default function TypeMagicPage() { <> 对比着记:Object.keys(order){" "} 在程序运行时给你一个字符串数组;keyof Order{" "} - 在编译时给你一个字符串字面量联合。 - 这个联合的每个成员都是真实存在的键 —— 所以{" "} + 在编译时给你一个字符串字面量联合。这个联合的每个成员都是真实存在的键 —— 所以{" "} "cup" 会被拒绝。 ), @@ -769,12 +763,10 @@ export default function TypeMagicPage() { } zh={ <> - JavaScript 的 typeof 在运行时执行, - 返回八种字符串之一,比如 "string"、 + JavaScript 的 typeof 在运行时执行,返回八种字符串之一,比如 "string"、 "object"(第 03 章拿它做过收窄)。TypeScript 的 typeof 只出现在 - 类型位置 —— type 声明的等号右边、 - 注解的冒号后面 —— 编译时会被去掉。 + 类型位置 —— type 声明的等号右边、注解的冒号后面 —— 编译时会被去掉。 } /> @@ -844,10 +836,9 @@ export default function TypeMagicPage() { 说明:联合里如果一个成员已经覆盖了另一个,后者就会被吸收 —— Size 是字符串组成的联合,而联合里本来就有{" "} string,所以 Size 消失了。第二, - T[number] 是读取数组元素类型的标准写法。 配上{" "} + T[number] 是读取数组元素类型的标准写法。配上{" "} as const - (它把数组变成由字面量类型组成的只读元组), - 一份数据同时成了一份可选值名单:改一处,两边一起变。 + (它把数组变成由字面量类型组成的只读元组),一份数据同时成了一份可选值名单:改一处,两边一起变。 ), }} @@ -865,8 +856,7 @@ export default function TypeMagicPage() { zh={ <> 三个零件齐了:keyof 读键,typeof{" "} - 读值的类型,T[K] 读属性的类型。 - 本章后面的东西都建在它们上面。 + 读值的类型,T[K] 读属性的类型。本章后面的东西都建在它们上面。 } /> @@ -905,7 +895,7 @@ export default function TypeMagicPage() { zh: ( <> 这里的 extends 是「能不能赋给」的意思 —— 和第 04 - 章的兼容判断是同一件事,现在被用作三元表达式的条件。 成立就取{" "} + 章的兼容判断是同一件事,现在被用作三元表达式的条件。成立就取{" "} X,不成立就取 Y。 ), @@ -925,8 +915,7 @@ export default function TypeMagicPage() { } zh={ <> - 单看没什么。重要的行为出现在被判断的类型是联合的时候。 - 上一章说过 Exclude 的定义只有一行 —— + 单看没什么。重要的行为出现在被判断的类型是联合的时候。上一章说过 Exclude 的定义只有一行 —— 就是下面这行。可是一个三元表达式,怎么能从联合里 删掉成员? @@ -970,11 +959,8 @@ export default function TypeMagicPage() { 只有 extends 左边是裸类型参数时才会发生 {" "} - —— 也就是光秃秃的 T,外面什么都没包。 - 发生分发时,如果 T 是 A | B, - 编译器就把整个条件类型改写成{" "} - (A extends U ? X : Y) | (B extends U ? X : Y)。 - 规则就这么多。不知道这条,谁都没法预测条件类型会返回什么。 + —— 也就是光秃秃的 T,外面什么都没包。发生分发时,如果 T 是 A | B,编译器就把整个条件类型改写成{" "} + (A extends U ? X : Y) | (B extends U ? X : Y)。规则就这么多。不知道这条,谁都没法预测条件类型会返回什么。 } /> @@ -1011,10 +997,8 @@ export default function TypeMagicPage() { zh={ <> 这是设计,不是缺陷。想让每个成员各自处理( - Exclude 那种),就用裸 T; - 想把联合当成一个类型来判断,就把两边 - 都包进单元素元组:[T] extends [U]。 - 只包一边会改变比较的对象,所以两边都要包。 + Exclude 那种),就用裸 T;想把联合当成一个类型来判断,就把两边 + 都包进单元素元组:[T] extends [U]。只包一边会改变比较的对象,所以两边都要包。 } /> @@ -1052,12 +1036,9 @@ export default function TypeMagicPage() { } zh={ <> - never 是没有成员的联合。对它分发, - 判断跑零次,所以结果是 never。 - 这不是编译器里的特例,只是根本没有东西可以分发。 而{" "} + never 是没有成员的联合。对它分发,判断跑零次,所以结果是 never。这不是编译器里的特例,只是根本没有东西可以分发。而{" "} boolean 也不是单个类型,它是{" "} - true | false,所以分布式条件类型会对它跑两次。 - 记住这两点,条件类型大部分「怪结果」就不怪了。 + true | false,所以分布式条件类型会对它跑两次。记住这两点,条件类型大部分「怪结果」就不怪了。 } /> @@ -1104,8 +1085,7 @@ export default function TypeMagicPage() { Promise<某个类型> 的样子, 就把那个类型记作{" "} U 并返回 U。infer 在{" "} extends{" "} - 后面的模式里声明一个类型变量,由编译器在匹配时填上。 - 相当于对类型做解构。这个变量只能在真分支里使用。 + 后面的模式里声明一个类型变量,由编译器在匹配时填上。相当于对类型做解构。这个变量只能在真分支里使用。 ), }} @@ -1133,8 +1113,7 @@ export default function TypeMagicPage() { <> 第 7 行就是上一章 ReturnType 的核心:如果{" "} T 是函数,就把返回值类型抓出来。TypeScript - 还允许给这个变量加约束(infer U extends …), - 本章不用它。 + 还允许给这个变量加约束(infer U extends …),本章不用它。 ), }} @@ -1219,10 +1198,8 @@ export default function TypeMagicPage() { zh={ <> 写成 {"{ [K in keyof T]: … }"}{" "} - 这个形式时,映射类型做的不只是造一个新对象类型。 - 它会保留源类型的 ? 和 readonly - (只要你没去改它们),而且数组进去还是数组,元组进去还是元组。 - 这样的映射类型叫同态(homomorphic)映射类型。 这也正是{" "} + 这个形式时,映射类型做的不只是造一个新对象类型。它会保留源类型的 ? 和 readonly + (只要你没去改它们),而且数组进去还是数组,元组进去还是元组。这样的映射类型叫同态(homomorphic)映射类型。这也正是{" "} -? 和 -readonly{" "} 必须存在的原因:修饰符默认会被照抄,否则就没办法去掉它们。 @@ -1426,8 +1403,7 @@ export default function TypeMagicPage() { 这就是 Pick 拼错键会报错、Omit{" "} 不报的原因 - :Omit 的键约束是 keyof any。 - 上一章留下的问题,到这里有答案了。 + :Omit 的键约束是 keyof any。上一章留下的问题,到这里有答案了。 } /> @@ -1545,16 +1521,11 @@ export default function TypeMagicPage() { } zh={ <> - 读懂一个定义和写出一个定义是两回事。下面的窗口里放着三个占位定义和一个判分器, - 判分器本身只有两行类型代码。 - {"Expect"} 是一个类型参数, - 它的约束就是 true 这一个类型,所以塞给它{" "} + 读懂一个定义和写出一个定义是两回事。下面的窗口里放着三个占位定义和一个判分器,判分器本身只有两行类型代码。 + {"Expect"} 是一个类型参数,它的约束就是 true 这一个类型,所以塞给它{" "} false,和任何一次约束违反没有区别。 Equal<X, Y> 拿两个只有 X 和{" "} - Y 不同的泛型函数类型作比较; - 编译器只有在两个延迟求值的条件类型「出身相同」时才认为它们相关, - 所以这个判定比 X extends Y 严格。 - 能用它,不需要能推导它 —— 它是类型练习里的通用判分器。 + Y 不同的泛型函数类型作比较;编译器只有在两个延迟求值的条件类型「出身相同」时才认为它们相关,所以这个判定比 X extends Y 严格。能用它,不需要能推导它 —— 它是类型练习里的通用判分器。 } /> @@ -1601,14 +1572,11 @@ export default function TypeMagicPage() { TS2344: Type 'false' does not satisfy the constraint 'true'. {" "} - 改对一个,对应那条报错自己消失。清到没有报错, - 就意味着你从零写出了 Partial、Pick、 + 改对一个,对应那条报错自己消失。清到没有报错,就意味着你从零写出了 Partial、Pick、 Exclude,而且每一个都和标准库的版本核对过 —— 这句话是编译器验的,不是我说的。那些差一点的写法也值得试: - {"{ [K in keyof T]: T[K] }"} 少一个问号, - 或者把 MyPick 写成{" "} - {"{ [P in keyof T]: T[P] }"}。两个都像对的, - 两个都不对,判分器当场就说。 + {"{ [K in keyof T]: T[K] }"} 少一个问号,或者把 MyPick 写成{" "} + {"{ [P in keyof T]: T[P] }"}。两个都像对的,两个都不对,判分器当场就说。 } /> @@ -1641,10 +1609,8 @@ export default function TypeMagicPage() { 五个工具类型全部亲手写过,而且你的版本和标准库几乎一字不差。 零件只有这么几个,剩下的是组合。在 VS Code 里按住 Cmd 或 Ctrl 点一下 Partial,跳进{" "} - lib.es5.d.ts:上一章还读不下去的地方, - 现在就是普通代码。内置类型不够用的时候(深层{" "} - Readonly、严格的 Omit), - 你已经能自己写 —— 下面的动手任务里就有一个。 + lib.es5.d.ts:上一章还读不下去的地方,现在就是普通代码。内置类型不够用的时候(深层{" "} + Readonly、严格的 Omit),你已经能自己写 —— 下面的动手任务里就有一个。 } /> @@ -1677,10 +1643,8 @@ export default function TypeMagicPage() { zh={ <> 类型层面的代码和普通代码守同一条规矩: - 一眼能读懂的,别写成三层嵌套的条件类型。 一个{" "} - interface 说得清的事,不用上条件类型。 - 同事悬停十秒还看不懂的类型是成本,不是成绩。 - 这条线画在哪,终章「类型思维」会再谈。 + 一眼能读懂的,别写成三层嵌套的条件类型。一个{" "} + interface 说得清的事,不用上条件类型。同事悬停十秒还看不懂的类型是成本,不是成绩。这条线画在哪,终章「类型思维」会再谈。 } /> @@ -1726,8 +1690,7 @@ export default function TypeMagicPage() { ), zh: ( <> - 类型自己是一门小语言:输入类型、输出类型,只在编译时运行, - 之后被去掉。值层面的操作大多在类型层面有个对应写法。 + 类型自己是一门小语言:输入类型、输出类型,只在编译时运行,之后被去掉。值层面的操作大多在类型层面有个对应写法。 ), }, @@ -1769,8 +1732,7 @@ export default function TypeMagicPage() { T extends U ? X : Y 是类型世界的三元表达式。 只有被判断的类型是裸类型参数时才会对联合分发: A | B 会变成{" "} - (A extends U ? X : Y) | (B extends U ? X : Y)。 - 变成 never 的成员会消失,因为 never{" "} + (A extends U ? X : Y) | (B extends U ? X : Y)。变成 never 的成员会消失,因为 never{" "} 就是空联合。Exclude 全部内容就是这些。 ), @@ -1787,8 +1749,7 @@ export default function TypeMagicPage() { ), zh: ( <> - 两边都包起来 —— [T] extends [U] —— 就关掉分发。 - 两个推论:对 never 分发没有成员可判,结果是{" "} + 两边都包起来 —— [T] extends [U] —— 就关掉分发。两个推论:对 never 分发没有成员可判,结果是{" "} never;boolean 是{" "} true | false,所以会分发两次。 @@ -1809,8 +1770,7 @@ export default function TypeMagicPage() { zh: ( <> infer 从匹配到的形状里抓出类型。映射类型{" "} - {"{ [K in keyof T]: … }"} 逐键重建类型, - 照抄它没改的修饰符,并支持 ?、-?、 + {"{ [K in keyof T]: … }"} 逐键重建类型,照抄它没改的修饰符,并支持 ?、-?、 readonly、-readonly 。配合模板字面量类型 的 as @@ -1830,8 +1790,7 @@ export default function TypeMagicPage() { ), zh: ( <> - 上一章那五个工具类型你已经全部亲手写过。 - 随之而来的是克制:可读性优先于聪明, 而递归类型仍然有深度上限( + 上一章那五个工具类型你已经全部亲手写过。随之而来的是克制:可读性优先于聪明, 而递归类型仍然有深度上限( ts(2589))。 ), diff --git a/app/type-magic/viz.tsx b/app/type-magic/viz.tsx index 4cdc080..b8f940e 100644 --- a/app/type-magic/viz.tsx +++ b/app/type-magic/viz.tsx @@ -85,8 +85,7 @@ export function TmDistribute() { <> 规则是这样的:条件类型会逐个成员判断, 但前提是{" "} extends 左边是一个裸类型参数 —— 也就是光秃秃的{" "} - T,这里正是如此。 - 所以四个成员在左边排好队,一个一个过闸。 + T,这里正是如此。所以四个成员在左边排好队,一个一个过闸。 } /> @@ -108,8 +107,7 @@ export function TmDistribute() { <> "{m}" extends{" "} "cancelled" 成立,所以这个成员变成{" "} - never,被丢弃。never 是空联合, - 合并结果时它什么也不贡献。 + never,被丢弃。never 是空联合,合并结果时它什么也不贡献。 } /> @@ -126,8 +124,7 @@ export function TmDistribute() { zh={ <> "{m}" 不能赋给{" "} - "cancelled",判断为假,走假分支。 - 假分支写的是 T,所以这个成员原样保留。 + "cancelled",判断为假,走假分支。假分支写的是 T,所以这个成员原样保留。 } /> @@ -172,8 +169,7 @@ export function TmDistribute() { } zh={ <> - 分发逐帧看:MyExclude<OrderStatus, "cancelled"> - 是怎么算出来的 + 分发逐帧看:MyExclude<OrderStatus, "cancelled">是怎么算出来的 } /> @@ -327,7 +323,7 @@ const TM_RULES: { } zh={ <> - 每个键都照抄一遍,只在冒号前加上 ?。 这就是{" "} + 每个键都照抄一遍,只在冒号前加上 ?。这就是{" "} Partial 的全部定义。 } @@ -353,8 +349,8 @@ const TM_RULES: { } zh={ <> - 修饰符前面加个减号就是去掉它,于是 ? 被摘掉。 这就是{" "} - Required。注意这一次进来的键带着 ?。 在{" "} + 修饰符前面加个减号就是去掉它,于是 ? 被摘掉。这就是{" "} + Required。注意这一次进来的键带着 ?。在{" "} strictNullChecks 下,-?{" "} 同时会把属性类型里的 undefined 去掉。 @@ -379,7 +375,7 @@ const TM_RULES: { } zh={ <> - readonly 写在方括号前面。这就是 Readonly。 同理, + readonly 写在方括号前面。这就是 Readonly。同理, -readonly 可以把它去掉。 } @@ -405,8 +401,7 @@ const TM_RULES: { } zh={ <> - as 把键名整个换掉:模板字面量类型拼出新名字, - 属性类型改成回调。一个对象类型就变成了配套的事件处理器类型。 + as 把键名整个换掉:模板字面量类型拼出新名字,属性类型改成回调。一个对象类型就变成了配套的事件处理器类型。 as 和模板字面量类型都是 TypeScript 4.1 加入的。 } diff --git a/app/types/page.tsx b/app/types/page.tsx index 797b5e7..e454ad9 100644 --- a/app/types/page.tsx +++ b/app/types/page.tsx @@ -370,9 +370,7 @@ export default function TypesPage() { ), zh: ( <> - string、number、boolean…… 类型没那么神秘, - 就是每个值随身带的一张标签。更省事的是: - 大多数标签不用你写 —— TypeScript + string、number、boolean…… 类型没那么神秘,就是每个值随身带的一张标签。更省事的是:大多数标签不用你写 —— TypeScript 看一眼值,自己就填好了。这手本事叫推断。 ), @@ -440,21 +438,18 @@ export default function TypesPage() { zh={ <>

- 你是店长。进货间堆着几十个箱子,每个箱子上贴着标签: - 「珍珠 · 袋装 · 5kg」「椰浆 · 罐装 · 易腐」。 + 你是店长。进货间堆着几十个箱子,每个箱子上贴着标签:「珍珠 · 袋装 · 5kg」「椰浆 · 罐装 · 易腐」。 不用开箱,你就知道每箱能干什么、不能干什么。

TypeScript 给程序里的每个值也贴这么一张标签,学名叫 类型(type):name 这一栏是 string,price 这一栏是 - number。之后你每次用这个值,编译器都先看一眼标签: - 拿 string 去做乘法?标签不允许,报错。 + number。之后你每次用这个值,编译器都先看一眼标签:拿 string 去做乘法?标签不允许,报错。

大多数标签你从来不用写。写下 let price = 22,TS 看一眼右边的 22 就把标签填好了 —— 这叫 - 推断(inference)。只有少数地方需要你亲手写, - 也就是注解(annotation),§04 讲清楚是哪几处。 + 推断(inference)。只有少数地方需要你亲手写,也就是注解(annotation),§04 讲清楚是哪几处。

} @@ -479,8 +474,7 @@ export default function TypesPage() { } zh={ <> - 程序里真实存在的东西:22、"Oolong Tea"、 - 一个订单对象。运行时真正干活的是它们。 + 程序里真实存在的东西:22、"Oolong Tea"、一个订单对象。运行时真正干活的是它们。 } /> @@ -507,8 +501,7 @@ export default function TypesPage() { } zh={ <> - 对值的描述:是什么、有哪些字段。类型只在编译期存在。 - 编译器会把所有注解、type、 + 对值的描述:是什么、有哪些字段。类型只在编译期存在。编译器会把所有注解、type、 interface 全部删掉,产出普通 JavaScript —— 所以运行时没有任何类型检查,除非那段检查是你自己写的。 @@ -534,8 +527,7 @@ export default function TypesPage() { } zh={ <> - 它负责填标签(推断)和读标签(检查)。 - 发现某个操作和标签对不上,就在代码运行之前报错。 + 它负责填标签(推断)和读标签(检查)。发现某个操作和标签对不上,就在代码运行之前报错。 } /> @@ -589,17 +581,13 @@ export default function TypesPage() { zh={ <>

- 日常写码 95% 的时间在跟 string、number、boolean、null、 - undefined 打交道。null 和 undefined 的区别值得记一句: - undefined 是「从来没人赋过值」时你拿到的东西(没写初始值、 - 属性不存在、函数没有 return);null 是有人特意赋进去的、 - 表示「空」的值。 + 日常写码 95% 的时间在跟 string、number、boolean、null、undefined 打交道。null 和 undefined 的区别值得记一句: + undefined 是「从来没人赋过值」时你拿到的东西(没写初始值、属性不存在、函数没有 return);null 是有人特意赋进去的、表示「空」的值。

它们之所以是两个独立的类型,是因为{" "} strictNullChecks 开着。这个开关一关, - null 和 undefined 可以赋给任何类型,这一整类错误编译器就不再管了。 - 开关本身第 10 章细讲。 + null 和 undefined 可以赋给任何类型,这一整类错误编译器就不再管了。开关本身第 10 章细讲。

} @@ -637,8 +625,7 @@ export default function TypesPage() { zh={ <> string[] 读作「string 的数组」。 - Array<string> 是完全相同的意思。 - 元素类型简单时,社区习惯用前者,因为短。 + Array<string> 是完全相同的意思。元素类型简单时,社区习惯用前者,因为短。 } /> @@ -672,14 +659,11 @@ export default function TypesPage() { zh={ <> number[] 这样的数组类型完全不说明元素有几个; - [string, number] 这样的元组则明确说「就两个」, - 并且规定每个位置是什么。有一个反直觉的点: + [string, number] 这样的元组则明确说「就两个」,并且规定每个位置是什么。有一个反直觉的点: 元组上的 push 依然合法 - ,因为元组在运行时仍然是个普通数组。 - 固定长度只在构造这个值和用下标取值时检查,后续修改不查。 - 想连修改一起禁掉,给它加 readonly。 + ,因为元组在运行时仍然是个普通数组。固定长度只在构造这个值和用下标取值时检查,后续修改不查。想连修改一起禁掉,给它加 readonly。 } /> @@ -708,8 +692,7 @@ export default function TypesPage() { zh={ <> {"{ name: string; price: number }"}{" "} - 本身就是一个类型,直接写在原地,叫对象类型字面量, - 属性之间用分号隔开。形状能嵌套、能进数组 —— + 本身就是一个类型,直接写在原地,叫对象类型字面量,属性之间用分号隔开。形状能嵌套、能进数组 —— 真实项目的数据几乎都是这样描述出来的。可选属性{" "} ?、interface 和 type{" "} 别名,是下一章的全部内容。 @@ -758,13 +741,8 @@ export default function TypesPage() { } zh={ <> - 上面那台放大镜里的每个类型,都是为了讲解手写进去的。 - 下面这个窗口里跑的是真正的 TypeScript 编译器, - 就在你的浏览器里。点 drink、price、 - menu、cheap, - 给出答案的和你编辑器里那台是同一个程序。 - 然后看它画波浪线的那一行:totalFor 的参数。 - 推断读的是值,而参数没有值可读。 + 上面那台放大镜里的每个类型,都是为了讲解手写进去的。下面这个窗口里跑的是真正的 TypeScript 编译器,就在你的浏览器里。点 drink、price、 + menu、cheap,给出答案的和你编辑器里那台是同一个程序。然后看它画波浪线的那一行:totalFor 的参数。推断读的是值,而参数没有值可读。 } /> @@ -798,14 +776,8 @@ export default function TypesPage() { } zh={ <> - 那个窗口的第二个页签值得打开: - 它是编译器把这份文件里所有推断结果一次列全的清单。 - 读最后一行 —— - declare function totalFor(qty: any): number。 - 参数推成了 any,这是编译器在说「这一处我放弃了」。 - 在文件末尾补一句 totalFor("three"), - 不会多出任何报错。any 的代价是本章 §06 的内容; - 第二个页签里那种文件是干什么用的,09 章讲。 + 那个窗口的第二个页签值得打开:它是编译器把这份文件里所有推断结果一次列全的清单。读最后一行 —— + declare function totalFor(qty: any): number。参数推成了 any,这是编译器在说「这一处我放弃了」。在文件末尾补一句 totalFor("three"),不会多出任何报错。any 的代价是本章 §06 的内容;第二个页签里那种文件是干什么用的,09 章讲。 } /> @@ -824,8 +796,7 @@ export default function TypesPage() { } zh={ <> - 规律就在这里。局部变量这种「声明的同时就赋值」的场合, - 推断全包了。那注解该写在哪?写在边界上 —— + 规律就在这里。局部变量这种「声明的同时就赋值」的场合,推断全包了。那注解该写在哪?写在边界上 —— 凡是别的代码要依赖这个类型的地方,以及推断无从下手的地方。 } @@ -856,8 +827,7 @@ export default function TypesPage() { zh={ <> 参数是外面塞进来的,推断没有材料;开着{" "} - noImplicitAny 时必须写。 - 返回值可以推断,但写出来等于把承诺讲明白 —— + noImplicitAny 时必须写。返回值可以推断,但写出来等于把承诺讲明白 —— 对外导出的函数建议写。 } @@ -882,8 +852,7 @@ export default function TypesPage() { } zh={ <> - 导出的常量、全店共用的菜单结构 —— 给它们一个具名类型。 - 这份类型就是契约,谁塞错东西,就在那一行报错。 + 导出的常量、全店共用的菜单结构 —— 给它们一个具名类型。这份类型就是契约,谁塞错东西,就在那一行报错。 } /> @@ -937,8 +906,7 @@ export default function TypesPage() { zh={ <> 注解是承诺,推断是观察。 - 观察已经对了的地方再加一个冒号,不会让代码更安全, - 只会让真正的契约更难被看见。§08 还会说到它。 + 观察已经对了的地方再加一个冒号,不会让代码更安全,只会让真正的契约更难被看见。§08 还会说到它。 } /> @@ -978,13 +946,10 @@ export default function TypesPage() { zh={ <> 类型可以很宽(「是个字符串」),也可以很窄(「就是{" "} - "small" 这个字符串」)。 - 窄的那种叫字面量类型(literal type)。 - 拿到哪一种,取决于变量是怎么声明的:let{" "} + "small" 这个字符串」)。窄的那种叫字面量类型(literal type)。拿到哪一种,取决于变量是怎么声明的:let{" "} 会把字面量放宽成 string,这个动作叫拓宽(widening); const 则保留字面量。道理很直白: - let 变量本来就是要重新赋值的, - 类型只允许一个值就没法用了。 + let 变量本来就是要重新赋值的,类型只允许一个值就没法用了。 } /> @@ -1016,9 +981,7 @@ export default function TypesPage() { "small" | "medium" | "large" {" "} - 读作「三者之一」,这就是字面量联合: - 把「只能取这几个值」写进类型本身, - 错杯型在你保存文件那一刻就被拦下。竖线 |{" "} + 读作「三者之一」,这就是字面量联合:把「只能取这几个值」写进类型本身,错杯型在你保存文件那一刻就被拦下。竖线 |{" "} 是第 03 章的主角。 } @@ -1037,9 +1000,7 @@ export default function TypesPage() { } zh={ <> - 拓宽这件事,与其读结论,不如直接问编译器。 - 下面一共三份稿子:现在这份,加标题栏里的另外两份。 - 每换一份,都点一下 cupSize,看它回什么。 + 拓宽这件事,与其读结论,不如直接问编译器。下面一共三份稿子:现在这份,加标题栏里的另外两份。每换一份,都点一下 cupSize,看它回什么。 } /> @@ -1088,8 +1049,7 @@ export default function TypesPage() { Type '"mega"' is not assignable to type 'Size'.
{" "} - 值一个字都没变,变的是类型收得多窄。 - 中间那份稿子里可点的名字有三个,而只有第一个保住了字面量类型 —— + 值一个字都没变,变的是类型收得多窄。中间那份稿子里可点的名字有三个,而只有第一个保住了字面量类型 —— 原因在下面这张卡片里。 } @@ -1168,10 +1128,7 @@ export default function TypesPage() { } zh={ <> - 用 string 存杯型,"mega"、"Large"、 - "LARGE" 全都合法,写错的代价照旧发生在运行时。 - 用字面量联合,名单之外的值编译期直接拒收。 - 合法值的范围收得越窄,编译器能替你挡的就越多 —— + 用 string 存杯型,"mega"、"Large"、"LARGE" 全都合法,写错的代价照旧发生在运行时。用字面量联合,名单之外的值编译期直接拒收。合法值的范围收得越窄,编译器能替你挡的就越多 —— 这是贯穿全书的思路,03 章开始大量使用。 } @@ -1212,9 +1169,7 @@ export default function TypesPage() { zh={ <> 两行高亮都藏着真错误:键名 prise{" "} - 拼错了,没人报;然后 total 也成了 any, - 于是拿一个数字调 toUpperCase() 同样被放行。 - 两个错都活到了运行时 —— 和裸写 JavaScript 一模一样。 + 拼错了,没人报;然后 total 也成了 any,于是拿一个数字调 toUpperCase() 同样被放行。两个错都活到了运行时 —— 和裸写 JavaScript 一模一样。 } /> @@ -1255,22 +1210,16 @@ export default function TypesPage() { zh={ <>

- 一,关掉检查。值一旦是 any, - 对它做任何操作编译器都放行:属性拼错、方法不存在、参数传错, - 全部沉默。 + 一,关掉检查。值一旦是 any,对它做任何操作编译器都放行:属性拼错、方法不存在、参数传错,全部沉默。

二,会扩散。从 any - 值上取的属性、由它算出的结果、把它传进去的回调参数, - 统统变成 any。一个 any 能让一整条数据流失去检查。 + 值上取的属性、由它算出的结果、把它传进去的回调参数,统统变成 any。一个 any 能让一整条数据流失去检查。

这不等于永远不能用。迁移老 JavaScript 项目时它有用,偶尔也可以当临时出口(终章专门讨论「何时 any - 合理」)。原则是:有意识地用,并且清楚自己关掉了什么。 - 另外还有一个安全的替代品 unknown: - 它接受任何值,但在你检查清楚它到底是什么之前, - 几乎什么都不让你做。03 章细讲。 + 合理」)。原则是:有意识地用,并且清楚自己关掉了什么。另外还有一个安全的替代品 unknown:它接受任何值,但在你检查清楚它到底是什么之前,几乎什么都不让你做。03 章细讲。

} @@ -1294,9 +1243,7 @@ export default function TypesPage() { 还有一种你没写、自己长出来的 any —— 最常见的来源是没标类型的函数参数。strict 家族里的{" "} noImplicitAny{" "} - 专门管这个:凡是类型悄悄退化成 any 的地方一律报错。 - TypeScript Playground 默认开着 strict, - 所以你在动手任务里会亲眼见到它工作。 + 专门管这个:凡是类型悄悄退化成 any 的地方一律报错。TypeScript Playground 默认开着 strict,所以你在动手任务里会亲眼见到它工作。 } /> @@ -1368,8 +1315,7 @@ export default function TypesPage() { zh={ <> 推断照单全收,prise{" "} - 被当成一个真实字段记了下来。 - 在 filter 用到 price{" "} + 被当成一个真实字段记了下来。在 filter 用到 price{" "} 之前什么都不报,而报错时指向的是 并没有写错的那段代码。 @@ -1401,9 +1347,7 @@ export default function TypesPage() { zh={ <> 立了 MenuItem 这份契约之后,错误 - 精确落在写错的那一行,还附带改法。 - 这就是「在边界上注解」的回报: - 报错的位置就是犯错的位置。 + 精确落在写错的那一行,还附带改法。这就是「在边界上注解」的回报:报错的位置就是犯错的位置。 } /> @@ -1474,8 +1418,7 @@ export default function TypesPage() { <> 大写的 String 是 new String(){" "} 造出来的那个包装对象的类型,不是原始字符串。 - String 不能赋给 string, - 写了它就会遇到一串莫名其妙的兼容错误。 + String 不能赋给 string,写了它就会遇到一串莫名其妙的兼容错误。 注解一律用小写:string、number、boolean。大写的{" "} String / Number /{" "} Boolean,当它们不存在。 @@ -1504,9 +1447,7 @@ export default function TypesPage() { zh={ <> let count: number = 0{" "} - 里的注解,只是把编译器从 0 那里已经读到的信息又说了一遍。 - 它占地方,还会把真正重要的注解淹没掉。 - 显而易见的地方交给推断,注解留给 §04 说的三种边界 —— + 里的注解,只是把编译器从 0 那里已经读到的信息又说了一遍。它占地方,还会把真正重要的注解淹没掉。显而易见的地方交给推断,注解留给 §04 说的三种边界 —— 这样读代码的人一眼就能看出契约在哪。 } @@ -1536,9 +1477,7 @@ export default function TypesPage() { <> 空数组没有元素,推断不出元素类型,TS 只能先记成{" "} any[]。它接着会尝试从同一作用域后面的{" "} - push 反推类型,这叫「演化数组」。 - 但只要这个值离开函数,或者在填进内容之前就被使用, - 这套猜测就失效了,noImplicitAny 会当场报错。 + push 反推类型,这叫「演化数组」。但只要这个值离开函数,或者在填进内容之前就被使用,这套猜测就失效了,noImplicitAny 会当场报错。 好习惯:空箱子出生就贴标签。 } @@ -1587,8 +1526,7 @@ export default function TypesPage() { Variable 'items' implicitly has an 'any[]' type - 。注意顶层那行 toppings 完全不报错, - 它的类型就是 any[]。一行注解{" "} + 。注意顶层那行 toppings 完全不报错,它的类型就是 any[]。一行注解{" "} const safe: string[] = []{" "} 就能解决全部问题,之后每一次 push 都受检。 @@ -1637,9 +1575,7 @@ export default function TypesPage() { ), zh: ( <> - 类型就是值随身带的标签。推断 = 编译器替你读这张标签, - 所以大多数局部变量一个冒号都不用写。 - 类型在编译时被删除:程序运行时没有任何东西再检查它们。 + 类型就是值随身带的标签。推断 = 编译器替你读这张标签,所以大多数局部变量一个冒号都不用写。类型在编译时被删除:程序运行时没有任何东西再检查它们。 ), }, @@ -1694,9 +1630,7 @@ export default function TypesPage() { <> let 拓宽("small" → string),const 保留字面量类型。但{" "} - const 锁不住内容: - 数组元素和对象属性照样拓宽,除非写 as const。 - 字面量联合("small" | "medium" | + const 锁不住内容:数组元素和对象属性照样拓宽,除非写 as const。字面量联合("small" | "medium" | "large")是 03 章的入口。 ), @@ -1714,8 +1648,7 @@ export default function TypesPage() { zh: ( <> any 关掉检查,并顺着表达式扩散;空的{" "} - [] 会先落成 any[]。 - 两者当临时手段可以,长期留着不行。noImplicitAny{" "} + [] 会先落成 any[]。两者当临时手段可以,长期留着不行。noImplicitAny{" "} 负责报出那些你没亲手写的 any。 ), diff --git a/app/types/viz.tsx b/app/types/viz.tsx index ada9e81..733a610 100644 --- a/app/types/viz.tsx +++ b/app/types/viz.tsx @@ -96,8 +96,7 @@ const ID_ENTRIES: IdEntry[] = [ } zh={ <> - JS/TS 只有一种数字类型:22、19.5、-3 都是 number。 - 别的语言里的 int / float 之分,这里没有。 + JS/TS 只有一种数字类型:22、19.5、-3 都是 number。别的语言里的 int / float 之分,这里没有。 } /> @@ -117,9 +116,7 @@ const ID_ENTRIES: IdEntry[] = [ } zh={ <> - NaN 的意思是「不是数」,可它的类型偏偏是 number。 - 所以类型系统拦不住 NaN —— 要拦,得去查产生它的那个值, - 通常是某个 undefined。 + NaN 的意思是「不是数」,可它的类型偏偏是 number。所以类型系统拦不住 NaN —— 要拦,得去查产生它的那个值,通常是某个 undefined。 } /> @@ -154,8 +151,7 @@ const ID_ENTRIES: IdEntry[] = [ } zh={ <> - 「这里空着」,而且是有意为之的空。 - null 不会自己出现,一定是某段代码把它赋了进去 —— + 「这里空着」,而且是有意为之的空。null 不会自己出现,一定是某段代码把它赋了进去 —— 通常表示「这个字段存在,但故意留空」。 } @@ -195,8 +191,7 @@ const ID_ENTRIES: IdEntry[] = [ } zh={ <> - 「从来没人往这儿放过值」。声明了没赋值、读一个不存在的属性、 - 函数没有 return —— 拿到的都是它。 + 「从来没人往这儿放过值」。声明了没赋值、读一个不存在的属性、函数没有 return —— 拿到的都是它。 } /> @@ -213,8 +208,7 @@ const ID_ENTRIES: IdEntry[] = [ } zh={ <> - null 和 undefined 是两个不同的值,含义也不同。 - 它们之所以是两个独立的类型,是因为 strictNullChecks{" "} + null 和 undefined 是两个不同的值,含义也不同。它们之所以是两个独立的类型,是因为 strictNullChecks{" "} 开着;这个开关一关,两者可以赋给任何类型,编译器也就不再提醒你了。 } @@ -234,8 +228,7 @@ const ID_ENTRIES: IdEntry[] = [ } zh={ <> - 大到 number 存不准的整数,写法是在数字后面加一个 n。 - 日常业务代码几乎用不上,知道有这户人家就行。 + 大到 number 存不准的整数,写法是在数字后面加一个 n。日常业务代码几乎用不上,知道有这户人家就行。 } /> @@ -255,8 +248,7 @@ const ID_ENTRIES: IdEntry[] = [ } zh={ <> - 保证和其他任何值都不相等的值,常用来做「绝不会撞名」的对象键。 - 日常业务代码里也少见,遇到再回来查。 + 保证和其他任何值都不相等的值,常用来做「绝不会撞名」的对象键。日常业务代码里也少见,遇到再回来查。 } /> @@ -349,8 +341,7 @@ const LENS_LINES: LensLine[] = [ } zh={ <> - let 声明的变量以后还能改,所以 TS 往宽了记: - 「反正是个字符串」。从字面量类型放宽到 string,这个动作叫 + let 声明的变量以后还能改,所以 TS 往宽了记:「反正是个字符串」。从字面量类型放宽到 string,这个动作叫 拓宽(widening)。 } @@ -368,8 +359,7 @@ const LENS_LINES: LensLine[] = [ } zh={ <> - const 变量不会再被赋值,TS 就敢把话说死: - 类型就是字面量 "Grape Tea" 本身 —— + const 变量不会再被赋值,TS 就敢把话说死:类型就是字面量 "Grape Tea" 本身 —— 比 string 窄得多,这叫字面量类型。 } @@ -392,8 +382,7 @@ const LENS_LINES: LensLine[] = [ } zh={ <> - 数字同理:初始值是 22,但以后可能改成 19.5,所以记成 number。 - 注意你一个冒号都没写。 + 数字同理:初始值是 22,但以后可能改成 19.5,所以记成 number。注意你一个冒号都没写。 } /> @@ -494,9 +483,7 @@ const LENS_LINES: LensLine[] = [ zh={ <> 这一行常让人意外:const 版依旧是 string[]。 - const 只保证「变量名不能重新赋值」, - 数组内容照样能 push、能改,所以元素类型必须留宽。 - 想连内容一起锁死,要用 as const(终章讲)。 + const 只保证「变量名不能重新赋值」,数组内容照样能 push、能改,所以元素类型必须留宽。想连内容一起锁死,要用 as const(终章讲)。 } /> @@ -524,8 +511,7 @@ const LENS_LINES: LensLine[] = [ } zh={ <> - 对象:TS 把每个属性的类型都推出来,拼成一份「形状」。 - 后面的章节反复出现的就是这份形状。 + 对象:TS 把每个属性的类型都推出来,拼成一份「形状」。后面的章节反复出现的就是这份形状。 } /> @@ -587,8 +573,7 @@ const LENS_LINES: LensLine[] = [ } zh={ <> - const 版在赋值这一步就被拦下了,还轮不到类型出场。 - 这是 JavaScript 自己的规矩,TS 只是把它提前到编译期告诉你。 + const 版在赋值这一步就被拦下了,还轮不到类型出场。这是 JavaScript 自己的规矩,TS 只是把它提前到编译期告诉你。 } /> @@ -616,8 +601,7 @@ const LENS_LINES: LensLine[] = [ zh={ <> 这一行是整节的重点:你一个类型注解都没写,TS - 照样逮住「拿字符串当数字用」。 - 推断出来的类型,和你手写的一样严格。 + 照样逮住「拿字符串当数字用」。推断出来的类型,和你手写的一样严格。 } /> diff --git a/app/utility/page.tsx b/app/utility/page.tsx index 1b1e80d..10e7bd9 100644 --- a/app/utility/page.tsx +++ b/app/utility/page.tsx @@ -494,9 +494,7 @@ export default function UtilityPage() { ), zh: ( <> - 工具类型接受一个你已经有的类型,返回一个与它相关的新类型。 - TypeScript 自带一套标准工具,新类型不必手写。 - 这一章讲怎么用它们,第 07 章讲它们是怎么造出来的。 + 工具类型接受一个你已经有的类型,返回一个与它相关的新类型。TypeScript 自带一套标准工具,新类型不必手写。这一章讲怎么用它们,第 07 章讲它们是怎么造出来的。 ), }} @@ -579,15 +577,11 @@ export default function UtilityPage() { zh={ <>

- 草稿单:顾客还在挑,属性可以缺。锁定单:小票已经打出来, - 一个字都不许改。列表行:只显示三个属性。对外接口: - 内部备注绝不能出去。库存表:每个杯型对一个数字。 - 五个需求,五种类型。 + 草稿单:顾客还在挑,属性可以缺。锁定单:小票已经打出来,一个字都不许改。列表行:只显示三个属性。对外接口:内部备注绝不能出去。库存表:每个杯型对一个数字。五个需求,五种类型。

你当然可以照着 Order 把五个都手写一遍 —— - 六个属性抄五遍。然后 Order 加了一个属性, - 五个地方都得改。漏掉一处,这些类型之间就悄悄不一致了。 + 六个属性抄五遍。然后 Order 加了一个属性,五个地方都得改。漏掉一处,这些类型之间就悄悄不一致了。

} @@ -614,8 +608,7 @@ export default function UtilityPage() { zh={ <> 这就是第 01 章那个奶茶店类型;§05 会再把第 03 - 章的订单状态联合加进来。把这份定义贴进 Playground, - 本章后面的例子都能接着它跑。 + 章的订单状态联合加进来。把这份定义贴进 Playground,本章后面的例子都能接着它跑。 } /> @@ -639,11 +632,9 @@ export default function UtilityPage() { zh={ <> TypeScript 的解法是随语言自带一批工具类型(utility - types)。每一个都接受一个已有的类型,产出一个新类型。 - 写法看起来像函数调用,只是圆括号换成了尖括号: + types)。每一个都接受一个已有的类型,产出一个新类型。写法看起来像函数调用,只是圆括号换成了尖括号: Partial<Order>。这和第 05 - 章的泛型是同一套写法,原因也一样:尖括号里的类型是一个实参。 - 上面五个需求,一个工具类型对应一个。 + 章的泛型是同一套写法,原因也一样:尖括号里的类型是一个实参。上面五个需求,一个工具类型对应一个。 } /> @@ -719,8 +710,7 @@ export default function UtilityPage() { } zh={ <> - 一行只显示订单号、饮品和杯型。这是白名单: - 点到名的留下,其余不带走。 + 一行只显示订单号、饮品和杯型。这是白名单:点到名的留下,其余不带走。 } /> @@ -744,8 +734,7 @@ export default function UtilityPage() { } zh={ <> - internalNote 只给店员看。这是黑名单: - 点到名的删掉,其余原样保留。 + internalNote 只给店员看。这是黑名单:点到名的删掉,其余原样保留。 } /> @@ -773,10 +762,8 @@ export default function UtilityPage() { } zh={

- 工具类型不发明新东西:它读一个已有的类型,返回一个新类型。 - 它也从不改动输入 —— Partial<Order>{" "} - 造出草稿类型之后,Order 依然是那六个必填属性。 - 本章每一个工具类型都是这样,下面的流水线可以直接看到这一点。 + 工具类型不发明新东西:它读一个已有的类型,返回一个新类型。它也从不改动输入 —— Partial<Order>{" "} + 造出草稿类型之后,Order 依然是那六个必填属性。本章每一个工具类型都是这样,下面的流水线可以直接看到这一点。

} /> @@ -811,11 +798,8 @@ export default function UtilityPage() { } zh={ <> - 注意三件事。进去的是类型,出来的也是类型,没有任何代码在运行。 - 换工具只改变输出,从不改动输入。还有:被 Pick 或{" "} - Omit 排除掉的属性,只是在新类型里没有。 - 它在 Order 里还好好地待着, - 用到 Order 的代码不受任何影响。 + 注意三件事。进去的是类型,出来的也是类型,没有任何代码在运行。换工具只改变输出,从不改动输入。还有:被 Pick 或{" "} + Omit 排除掉的属性,只是在新类型里没有。它在 Order 里还好好地待着,用到 Order 的代码不受任何影响。 } /> @@ -861,10 +845,8 @@ export default function UtilityPage() { zh={ <> 第 02 章手动给属性加过 ?,Partial{" "} - 就是对每个属性都做一次。留意最后一行: - 因为这个属性可能不存在,读它拿到的是{" "} - string | undefined,用之前要先收窄。 - 收窄是第 03 章的内容。 + 就是对每个属性都做一次。留意最后一行:因为这个属性可能不存在,读它拿到的是{" "} + string | undefined,用之前要先收窄。收窄是第 03 章的内容。 } /> @@ -902,8 +884,7 @@ export default function UtilityPage() { 就可选标记而言,Partial 和 Required{" "} 是相反的一对,但它们并不是严格互逆的。Partial{" "} 加上 ?,连带把 undefined{" "} - 加进属性类型;Required 把两者一起去掉。 - 所以上面的 Required<A> 是{" "} + 加进属性类型;Required 把两者一起去掉。所以上面的 Required<A> 是{" "} {"{ note: string }"},而不是{" "} {"{ note: string | undefined }"}。 @@ -937,8 +918,7 @@ export default function UtilityPage() { zh={ <> 最后两行是本节的重点。readonly 拒绝的是 - 给属性赋值。它对属性指向的那个对象或数组不作任何限制, - 所以 push 依然合法。 + 给属性赋值。它对属性指向的那个对象或数组不作任何限制,所以 push 依然合法。 } /> @@ -983,16 +963,13 @@ export default function UtilityPage() { <>

Partial、Required、 - Readonly 只处理最外层对象的属性, - 不会进到属性类型的内部。在{" "} + Readonly 只处理最外层对象的属性,不会进到属性类型的内部。在{" "} Partial<{"{ meta: { note: string } }"}> 里, meta 变成了可选,meta.note{" "} 仍然必填。

- Readonly 这一点值得再说一遍,因为它的名字听起来 - 比它做的事更强。它阻止的是给属性赋值, - 并不会冻结属性指向的那个对象,而且它在运行时完全不存在 —— + Readonly 这一点值得再说一遍,因为它的名字听起来比它做的事更强。它阻止的是给属性赋值,并不会冻结属性指向的那个对象,而且它在运行时完全不存在 —— Object.freeze 是另一件事,发生在运行时。

@@ -1114,17 +1091,11 @@ export default function UtilityPage() { T 上真实存在的键。Omit<T, K>{" "} 声明的是 K extends keyof any,任何{" "} string、number、 - symbol 都收。这个更宽的约束是官方故意的: - 它让你可以对一个「可能存在也可能不存在」的键写{" "} + symbol 都收。这个更宽的约束是官方故意的:它让你可以对一个「可能存在也可能不存在」的键写{" "} Omit。代价就是拼错不报错。

- 而这恰好在最想用 Omit 的场合最危险: - 如果你删掉一个属性是因为它不能发给客户端, - 那么一个拼错的键会顺利编译,属性照样在里面。 - 这种场合用 Pick 白名单,让编译器帮你核对名字; - 或者学完第 07 章,自己写一个严格版的 Omit。 - 本节末尾的实验室里可以亲眼看这个洞漏出来:拼错的{" "} + 而这恰好在最想用 Omit 的场合最危险:如果你删掉一个属性是因为它不能发给客户端,那么一个拼错的键会顺利编译,属性照样在里面。这种场合用 Pick 白名单,让编译器帮你核对名字;或者学完第 07 章,自己写一个严格版的 Omit。本节末尾的实验室里可以亲眼看这个洞漏出来:拼错的{" "} Omit 算出来的形状里,internalNote{" "} 原封不动地还在,而没有任何地方报错。

@@ -1167,13 +1138,9 @@ export default function UtilityPage() { zh={ <> Record<Size, number> 比{" "} - {"{ [k: string]: number }"} 严格, - 因为 Size 是有限的联合, - 编译器清楚知道必须有哪几个键。键类型换成{" "} + {"{ [k: string]: number }"} 严格,因为 Size 是有限的联合,编译器清楚知道必须有哪几个键。键类型换成{" "} string 之后,Record{" "} - 就等于一个索引签名,没有名单可以核对。 - 注意最后一行:索引签名承诺每个键都有值, - 所以读一个不存在的键能通过类型检查,运行时却拿到{" "} + 就等于一个索引签名,没有名单可以核对。注意最后一行:索引签名承诺每个键都有值,所以读一个不存在的键能通过类型检查,运行时却拿到{" "} undefined。想让编译器在这里补上{" "} | undefined,要开{" "} noUncheckedIndexedAccess(第 10 章)。 @@ -1207,8 +1174,7 @@ export default function UtilityPage() { 这一章里每一处写着「展开成什么样」的注释,都可以一键核对。在下面的窗口里点 等号左边的名字 —— DraftOrder、 ListRow、PublicOrder、 - CupStock —— 编译器就把算完的形状整个打出来, - 每个属性都写全。Partial 展开是{" "} + CupStock —— 编译器就把算完的形状整个打出来,每个属性都写全。Partial 展开是{" "} {"{ id?: string | undefined; drink?: string | undefined; … }"} ,一眼能看到它做的两件事:加上问号,同时把{" "} undefined 并进每个属性的类型里。 @@ -1338,14 +1304,11 @@ export default function UtilityPage() { Partial、Pick、Omit{" "} 接受的是对象类型,处理它的属性;Exclude、 Extract、NonNullable 接受的是 - 联合类型,处理它的成员。不确定该用哪个时问自己: - 我手上这个类型,是一组属性,还是一份候选名单? + 联合类型,处理它的成员。不确定该用哪个时问自己:我手上这个类型,是一组属性,还是一份候选名单?

- 这一组三个都是对联合的每个成员分别求值的, - 所以 Exclude<A | B | C, B> 得到的是{" "} - A | C,而不是把整个联合当成一个整体去比较。 - 第 07 章会给出产生这种行为的那一行定义。 + 这一组三个都是对联合的每个成员分别求值的,所以 Exclude<A | B | C, B> 得到的是{" "} + A | C,而不是把整个联合当成一个整体去比较。第 07 章会给出产生这种行为的那一行定义。

} @@ -1393,9 +1356,7 @@ export default function UtilityPage() { 注意 typeof makeOrder。makeOrder{" "} 是一个值,而 ReturnType 需要一个 类型。出现在类型位置上的 typeof{" "} - 会取出一个值的类型,这就是两者之间的桥。 - 忘了写它是这里最常见的错误,最后三行就是那条报错。 - 这个 typeof 和 JavaScript + 会取出一个值的类型,这就是两者之间的桥。忘了写它是这里最常见的错误,最后三行就是那条报错。这个 typeof 和 JavaScript 的同名运算符做的不是一件事,第 07 章会讲。 } @@ -1418,10 +1379,8 @@ export default function UtilityPage() { } zh={ <> - 什么时候用得上?库导出了函数、却没导出结果类型的时候; - 以及你不想专门给一个内部函数的结果起名字的时候。注意{" "} - Parameters<T> 返回的是一个元组, - 不是联合也不是对象,所以 MakeOrderArgs[1] 就是{" "} + 什么时候用得上?库导出了函数、却没导出结果类型的时候;以及你不想专门给一个内部函数的结果起名字的时候。注意{" "} + Parameters<T> 返回的是一个元组,不是联合也不是对象,所以 MakeOrderArgs[1] 就是{" "} Size,这个元组也可以直接展开传进一次调用。 } @@ -1497,13 +1456,11 @@ export default function UtilityPage() { "LARGE",Lowercase{" "} 方向相反;Capitalize<"size">{" "} 得到 "Size",Uncapitalize{" "} - 方向相反。四个都只作用在字符串字面量类型上, - 配上第 07 章的模板字面量类型才真正有用。现在先认个脸。 + 方向相反。四个都只作用在字符串字面量类型上,配上第 07 章的模板字面量类型才真正有用。现在先认个脸。

另外还有一个 NoInfer<T>,TypeScript 5.4 - 加进来的。它标记某个参数位置,让编译器在推断类型实参时不使用它。 - 很冷门,知道它存在就够了。 + 加进来的。它标记某个参数位置,让编译器在推断类型实参时不使用它。很冷门,知道它存在就够了。

} @@ -1544,9 +1501,7 @@ export default function UtilityPage() { } zh={ <> - 从里往外读。第 2 行先挑出两个属性,再把两者都变成可选。 - 第 9 行先从状态联合里去掉两个成员, - 再把剩下的当成 Record 的键集合。 + 从里往外读。第 2 行先挑出两个属性,再把两者都变成可选。第 9 行先从状态联合里去掉两个成员,再把剩下的当成 Record 的键集合。 } /> @@ -1565,9 +1520,7 @@ export default function UtilityPage() { } zh={ <> - 顺序有影响吗?有时有。作用在同一层级上的两个工具, - 换顺序往往结果相同;作用在不同层级上的两个,通常就不同。 - 与其背规则,不如悬停在类型别名上直接看结果: + 顺序有影响吗?有时有。作用在同一层级上的两个工具,换顺序往往结果相同;作用在不同层级上的两个,通常就不同。与其背规则,不如悬停在类型别名上直接看结果: } /> @@ -1661,8 +1614,7 @@ export default function UtilityPage() { 三,Omit 的键拼错不报错。 - 为了安全去掉一个属性时,把拼写再核对一遍; - 或者用 Pick 白名单,让编译器替你核对。 + 为了安全去掉一个属性时,把拼写再核对一遍;或者用 Pick 白名单,让编译器替你核对。

} @@ -1692,8 +1644,7 @@ export default function UtilityPage() { 这套工具很好用,但它们一点都不特殊。Partial{" "} 的定义只有一行 TypeScript,Exclude 更短。第 07 章会把它们拆开:映射类型、条件类型、keyof、 - infer。零件就那么几个,拆完之后, - 这一章用过的每一个工具,你都能自己写出来。 + infer。零件就那么几个,拆完之后,这一章用过的每一个工具,你都能自己写出来。

} /> @@ -1738,8 +1689,7 @@ export default function UtilityPage() { ), zh: ( <> - 工具类型接受一个类型,返回一个新类型。写法像用尖括号的函数调用, - 输入的类型永远不会被改动。 + 工具类型接受一个类型,返回一个新类型。写法像用尖括号的函数调用,输入的类型永远不会被改动。 ), }, @@ -1776,8 +1726,7 @@ export default function UtilityPage() { <> 挑属性:Pick 是白名单,Omit 是黑名单, Record<K, V> 现造一个对象类型 —— 当{" "} - K 是有限联合时,它要求每个键都在。 - 只有 Pick 会检查键。 + K 是有限联合时,它要求每个键都在。只有 Pick 会检查键。 ), }, @@ -1794,8 +1743,7 @@ export default function UtilityPage() { <> 筛联合:Exclude 去掉匹配的成员, Extract 只留下它们,NonNullable 清掉{" "} - null 和 undefined。 - 这一组作用在成员上,不是属性上。 + null 和 undefined。这一组作用在成员上,不是属性上。 ), }, @@ -1829,8 +1777,7 @@ export default function UtilityPage() { zh: ( <> 它们可以组合,结果从里往外读。这里缺的东西(深{" "} - Readonly、严格版 Omit), - 第 07 章你自己就能造出来。 + Readonly、严格版 Omit),第 07 章你自己就能造出来。 ), }, diff --git a/app/utility/viz.tsx b/app/utility/viz.tsx index fdf1f0e..5724cf6 100644 --- a/app/utility/viz.tsx +++ b/app/utility/viz.tsx @@ -164,8 +164,7 @@ function utFieldMsg(tool: UtToolId, f: UtField): ReactNode { } zh={ <> - {f.key} 过机:冒号前加上一个 ?。 - 这个属性现在可以不写,所以读它拿到的是{" "} + {f.key} 过机:冒号前加上一个 ?。这个属性现在可以不写,所以读它拿到的是{" "} {f.type} | undefined @@ -208,8 +207,7 @@ function utFieldMsg(tool: UtToolId, f: UtField): ReactNode { } zh={ <> - {f.key} 过机:前面加上 readonly。 - 对象建好之后,再给这个属性赋值就会被编译器拒绝。 + {f.key} 过机:前面加上 readonly。对象建好之后,再给这个属性赋值就会被编译器拒绝。 } /> @@ -225,8 +223,7 @@ function utFieldMsg(tool: UtToolId, f: UtField): ReactNode { } zh={ <> - {f.key} 在白名单上,原样通过。名字、类型、 - 可选与只读标记全都不变。 + {f.key} 在白名单上,原样通过。名字、类型、可选与只读标记全都不变。 } /> @@ -256,8 +253,7 @@ function utFieldMsg(tool: UtToolId, f: UtField): ReactNode { } zh={ <> - {f.key} 不在白名单上,被丢掉。新类型里没有这个属性, - 读它就是一个编译错误。 + {f.key} 不在白名单上,被丢掉。新类型里没有这个属性,读它就是一个编译错误。 } /> @@ -271,8 +267,7 @@ function utFieldMsg(tool: UtToolId, f: UtField): ReactNode { } zh={ <> - {f.key} 被点了名,Omit 把它丢掉。 - 对外的类型里再也没有这个属性。 + {f.key} 被点了名,Omit 把它丢掉。对外的类型里再也没有这个属性。 } /> @@ -308,8 +303,7 @@ export function UtPipeline() { } zh={ <> - 六个属性在左边排好队,机器上装的是 {tool}。 - 点「下一步」,让它们一个一个过机。 + 六个属性在左边排好队,机器上装的是 {tool}。点「下一步」,让它们一个一个过机。 } /> @@ -328,8 +322,7 @@ export function UtPipeline() { zh={ <> 出料完毕,这就是 {meta.out}。回头看入料区:Order{" "} - 和原来一模一样。工具类型接受一个类型、返回一个新类型, - 从不改动你交给它的那个类型。 + 和原来一模一样。工具类型接受一个类型、返回一个新类型,从不改动你交给它的那个类型。 } /> @@ -527,8 +520,7 @@ export function UtUnionSieve() { <> Exclude 去掉与第二个参数匹配的成员: "done" 和{" "} - "cancelled" 被去掉,其余留下。 - 这正好是「还在进行中」的订单状态,适合用在取餐大屏上。 + "cancelled" 被去掉,其余留下。这正好是「还在进行中」的订单状态,适合用在取餐大屏上。 } /> @@ -543,8 +535,7 @@ export function UtUnionSieve() { } zh={ <> - Extract 只留下匹配的成员,其余全部去掉。 - 这里得到的是两个终结状态,归档表要的就是这个类型。 + Extract 只留下匹配的成员,其余全部去掉。这里得到的是两个终结状态,归档表要的就是这个类型。 } /> diff --git a/lib/classes-data.tsx b/lib/classes-data.tsx index 6b4675d..f46b29f 100644 --- a/lib/classes-data.tsx +++ b/lib/classes-data.tsx @@ -208,8 +208,7 @@ export const LABS: Lab[] = [ public / protected / private{" "} 三种成员的 MilkTeaShop,再写一个子类{" "} FranchiseShop。然后从子类里读 private 成员、 - 从类外读 protected 成员,把两条报错完整读一遍。 - 接着再加一行 shop["vaultCode"] —— + 从类外读 protected 成员,把两条报错完整读一遍。接着再加一行 shop["vaultCode"] —— 编译器会说什么吗?

} @@ -227,10 +226,7 @@ export const LABS: Lab[] = [ } zh={ <> - 把鼠标停在红波浪线上就能看到报错。对比两条的措辞: - 一条以 only accessible within class 结尾, - 另一条多了 and its subclasses。 - 方括号那一行才是最出人意料的。 + 把鼠标停在红波浪线上就能看到报错。对比两条的措辞:一条以 only accessible within class 结尾,另一条多了 and its subclasses。方括号那一行才是最出人意料的。 } /> @@ -258,8 +254,7 @@ export const LABS: Lab[] = [ 两条报错的差别,就是两级权限的差别:protected{" "} 那条以 and its subclasses 结尾, private 那条停在{" "} - within class 'MilkTeaShop'。 - 最关键的是最后一行:shop["vaultCode"]{" "} + within class 'MilkTeaShop'。最关键的是最后一行:shop["vaultCode"]{" "} 一点错都不报。用方括号访问私有成员是被有意允许的 —— 这提醒我们,private{" "} 是给改代码的人看的一道检查,不是一把锁。 @@ -296,8 +291,7 @@ export const LABS: Lab[] = [ 在 Playground 里定义本章的 PaymentProvider 接口( pay + refund),写一个{" "} class CashPay implements PaymentProvider,但 - 故意漏掉 refund,读一读编译器说了什么。 - 然后把它补上,并让同一个类再 implements 第二个接口{" "} + 故意漏掉 refund,读一读编译器说了什么。然后把它补上,并让同一个类再 implements 第二个接口{" "} Named {"{ name: string }"}。

} @@ -314,8 +308,7 @@ export const LABS: Lab[] = [ } zh={ <> - 失败时报的是 ts(2420) incorrectly implements, - 第二行会点名缺了哪个成员。多个接口用逗号隔开: + 失败时报的是 ts(2420) incorrectly implements,第二行会点名缺了哪个成员。多个接口用逗号隔开: implements A, B。 } @@ -338,10 +331,8 @@ export const LABS: Lab[] = [ } zh={ <> - 注意报错出现的位置:在类名上,而不是散落在使用它的地方。 - 这正是 implements 的用处 —— - 让类明确写出自己要符合什么,少了成员就在定义处报出来, - 而不是等到别人调用时才暴露。 + 注意报错出现的位置:在类名上,而不是散落在使用它的地方。这正是 implements 的用处 —— + 让类明确写出自己要符合什么,少了成员就在定义处报出来,而不是等到别人调用时才暴露。 } /> @@ -372,10 +363,8 @@ export const LABS: Lab[] = [ zh={

在 Playground 里写一个带{" "} - private vaultCode = "8848" 的类, - 点开右侧的 .JS 标签页看编译产物;然后把{" "} - private vaultCode 改成 #vaultCode, - 再看一次。最后分别从类外读一次这个字段,并对两个实例各做一次{" "} + private vaultCode = "8848" 的类,点开右侧的 .JS 标签页看编译产物;然后把{" "} + private vaultCode 改成 #vaultCode,再看一次。最后分别从类外读一次这个字段,并对两个实例各做一次{" "} JSON.stringify。

} @@ -394,10 +383,8 @@ export const LABS: Lab[] = [ } zh={ <> - 要对比的只有一点:编译产物里,那个字段前面还剩下什么。 - 一个版本里 private 消失了,另一个版本里{" "} - # 原样还在。把 Playground 的 target 设为 ES2022, - 第二个版本就不会被改写成 WeakMap。 + 要对比的只有一点:编译产物里,那个字段前面还剩下什么。一个版本里 private 消失了,另一个版本里{" "} + # 原样还在。把 Playground 的 target 设为 ES2022,第二个版本就不会被改写成 WeakMap。 } /> @@ -422,8 +409,7 @@ export const LABS: Lab[] = [ .JS 面板里,ShopA 只剩下一个普通的{" "} vaultCode = "8848" ——{" "} private 这个词不见了;ShopB 的{" "} - #vaultCode 则原样保留。 - 差别就在这里:private 在编译时检查, + #vaultCode 则原样保留。差别就在这里:private 在编译时检查, #field 在运行时生效。 } @@ -457,8 +443,7 @@ export const LABS: Lab[] = [

写一个 abstract 类 Staff:抽象方法{" "} greet(): string,加一个普通方法{" "} - clockIn()(内部调用 this.greet())。 - 依次制造三种情况:① 直接 new Staff();② 子类{" "} + clockIn()(内部调用 this.greet())。依次制造三种情况:① 直接 new Staff();② 子类{" "} Barista 不实现 greet;③ 实现之后调用{" "} clockIn()。三种情况各是什么结果?

@@ -502,10 +487,7 @@ export const LABS: Lab[] = [ } zh={ <> - 这三个结果就是「没写完的类」的完整生命周期: - 类本身不能实例化(①);子类在实现完每一个抽象成员之前不能用(②); - 实现之后,它就继承到了那些已经写好的方法(③)。 - 父类固定流程的步骤,子类补上每一步的细节。 + 这三个结果就是「没写完的类」的完整生命周期:类本身不能实例化(①);子类在实现完每一个抽象成员之前不能用(②);实现之后,它就继承到了那些已经写好的方法(③)。父类固定流程的步骤,子类补上每一步的细节。 } /> @@ -632,10 +614,8 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - private 是编译期检查:它挡住的是改代码的人, - 挡不住 s["vaultCode"]、 - JSON.stringify、DevTools 和普通 JavaScript。 - 要一个运行时也拿不到的字段,用 JavaScript 的 #field。 + private 是编译期检查:它挡住的是改代码的人,挡不住 s["vaultCode"]、 + JSON.stringify、DevTools 和普通 JavaScript。要一个运行时也拿不到的字段,用 JavaScript 的 #field。 } /> @@ -653,8 +633,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 关于 protected 和 private 的差别, - 哪句话说对了? + 关于 protected 和 private 的差别,哪句话说对了? } /> @@ -740,8 +719,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 字段、方法、访问器、参数属性,两个修饰符都能用。 - 它们管的是访问范围,不挑成员种类。 + 字段、方法、访问器、参数属性,两个修饰符都能用。它们管的是访问范围,不挑成员种类。 } />, @@ -775,8 +753,7 @@ export const QUIZ: QuizItem[] = [ zh={ <> public:任何代码。protected - :本类,以及任何 extends 它的类。private:只有类体自己。 - 三者都在编译期检查。 + :本类,以及任何 extends 它的类。private:只有类体自己。三者都在编译期检查。 } /> @@ -795,8 +772,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - strict 下 name: string; 报了 ts(2564): - 声明了却从未赋值。下面哪种改法不能消掉这个错误? + strict 下 name: string; 报了 ts(2564):声明了却从未赋值。下面哪种改法不能消掉这个错误? } /> @@ -896,9 +872,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - ! 确实能消掉报错。 - 代价是这份保证从编译器转到了你身上,那是另一回事; - 但就「检查能不能通过」而言,它是有效的。 + ! 确实能消掉报错。代价是这份保证从编译器转到了你身上,那是另一回事;但就「检查能不能通过」而言,它是有效的。 } />, @@ -917,8 +891,7 @@ export const QUIZ: QuizItem[] = [ zh={ <> 编译器不跨方法追踪赋值:它无法确定 init(){" "} - 一定会被调用,也无法确定它在每条路径上都赋了值。 - 就地初始化、构造器里赋值,或者用 ! —— + 一定会被调用,也无法确定它在每条路径上都赋了值。就地初始化、构造器里赋值,或者用 ! —— 绕道 init 是唯一无效的那条路。 } @@ -971,9 +944,7 @@ export const QUIZ: QuizItem[] = [ zh={ <> abstract 类是一个没写完的类:标了{" "} - abstract 的成员只有签名没有方法体, - 子类必须把它们全部实现。直接对它 new 会报 ts(2511)。 - 那些已经实现好的方法,子类照常继承。 + abstract 的成员只有签名没有方法体,子类必须把它们全部实现。直接对它 new 会报 ts(2511)。那些已经实现好的方法,子类照常继承。 } /> @@ -1008,8 +979,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 编译期核对这个类有没有接口列出的成员。它不提供任何东西, - 也不留下运行时痕迹 + 编译期核对这个类有没有接口列出的成员。它不提供任何东西,也不留下运行时痕迹 } />, @@ -1052,8 +1022,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 没有东西可复制。interface 只描述形状,里面没有任何方法体, - 每一个都得类自己写。 + 没有东西可复制。interface 只描述形状,里面没有任何方法体,每一个都得类自己写。 } />, @@ -1103,10 +1072,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - implements 不会以任何方式改变类的类型, - 它只是让编译器确认这个类符合接口。 - 正因为它只是一次检查,一个类可以同时接受多个接口的检查, - 而 extends 只能有一个基类。 + implements 不会以任何方式改变类的类型,它只是让编译器确认这个类符合接口。正因为它只是一次检查,一个类可以同时接受多个接口的检查,而 extends 只能有一个基类。 } /> @@ -1206,8 +1172,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 这是最常见的错误预期。implements 只做检查: - 它拿你写好的类去和接口比对,绝不会把接口的类型倒推回你的参数上。 + 这是最常见的错误预期。implements 只做检查:它拿你写好的类去和接口比对,绝不会把接口的类型倒推回你的参数上。 } />, @@ -1260,8 +1225,7 @@ export const QUIZ: QuizItem[] = [ zh={ <> 这个检查只有一个方向:从类到接口。 - pay(amount: number) 还是要自己写, - 编译器再拿你写的去和 PaymentProvider 比对。 + pay(amount: number) 还是要自己写,编译器再拿你写的去和 PaymentProvider 比对。 } /> @@ -1346,8 +1310,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 漏了一条。想一想 readonly 在这里算不算修饰符, - 以及一个只做类型剥离的运行时拿这种语法能怎么办。 + 漏了一条。想一想 readonly 在这里算不算修饰符,以及一个只做类型剥离的运行时拿这种语法能怎么办。 } /> @@ -1363,8 +1326,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 有一项选错了。参数属性会让编译器生成一条赋值语句, - 而生成的语句正是留在产物里的痕迹。 + 有一项选错了。参数属性会让编译器生成一条赋值语句,而生成的语句正是留在产物里的痕迹。 } /> @@ -1385,8 +1347,7 @@ export const QUIZ: QuizItem[] = [ zh={ <> 参数属性一行顶四行,但它会生成代码:编译器替你写下{" "} - this.db = db。 - 这就是它不能被单纯擦除、以及 erasableSyntaxOnly{" "} + this.db = db。这就是它不能被单纯擦除、以及 erasableSyntaxOnly{" "} 会报它的原因。构造器参数前写上 public、 private、protected、readonly{" "} 中任意一个,都会形成参数属性。 @@ -1409,8 +1370,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - PaperCup 和 PlasticCup 成员完全相同, - 且各自声明了一个 private stock = 0。 + PaperCup 和 PlasticCup 成员完全相同,且各自声明了一个 private stock = 0。 const cup: PaperCup = new PlasticCup() 的结果是? } @@ -1427,8 +1387,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 报错:private 成员必须来自同一处声明, - 所以这两个类是按「出身」比较的 + 报错:private 成员必须来自同一处声明,所以这两个类是按「出身」比较的 } />, @@ -1488,8 +1447,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - TypeScript 的类型检查没有「警告」这一档, - 赋值要么通过要么不通过 —— 这里是结结实实的 ts(2322)。 + TypeScript 的类型检查没有「警告」这一档,赋值要么通过要么不通过 —— 这里是结结实实的 ts(2322)。 } />, @@ -1504,9 +1462,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 两个同形状且没有私有成员的类是可以互相赋值的, - 第 04 章讲过。这里出问题的原因是 private, - 而不是「它们是 class」。 + 两个同形状且没有私有成员的类是可以互相赋值的,第 04 章讲过。这里出问题的原因是 private,而不是「它们是 class」。 } />, @@ -1526,8 +1482,7 @@ export const QUIZ: QuizItem[] = [ <> 完整规则:类型按形状比较,但 private 和{" "} protected 成员必须来自同一处声明 - (从共同基类继承下来的算)。想让它们重新兼容, - 就让两个类 extends 同一个声明了 stock 的基类。 + (从共同基类继承下来的算)。想让它们重新兼容,就让两个类 extends 同一个声明了 stock 的基类。 } /> @@ -1619,8 +1574,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - private 会被擦除,运行时就是个普通属性。 - 连在 TypeScript 里,s["vaultCode"]{" "} + private 会被擦除,运行时就是个普通属性。连在 TypeScript 里,s["vaultCode"]{" "} 都能读到而且不报错。 } @@ -1653,8 +1607,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - readonly 管的是这个属性能不能被赋值, - 而不是谁能读它 —— 而且它同样只在编译期检查。 + readonly 管的是这个属性能不能被赋值,而不是谁能读它 —— 而且它同样只在编译期检查。 } />, @@ -1672,8 +1625,7 @@ export const QUIZ: QuizItem[] = [ zh={ <> 「程序运行时读不到」是一个运行时要求,只有 JavaScript 的{" "} - #field 做得到。 - 它不属于类型系统,所以擦除类型不会把它擦掉。 + #field 做得到。它不属于类型系统,所以擦除类型不会把它擦掉。 } /> diff --git a/lib/functions-data.tsx b/lib/functions-data.tsx index 3b52301..ec6bc14 100644 --- a/lib/functions-data.tsx +++ b/lib/functions-data.tsx @@ -39,11 +39,9 @@ export const LABS: Lab[] = [ zh={ <>

- 打开 TypeScript Playground(typescriptlang.org/play), - 把下面这段 JS 风格的函数贴进去: + 打开 TypeScript Playground(typescriptlang.org/play),把下面这段 JS 风格的函数贴进去: {`function makeTea(base, sweet) { return base + "(" + sweet + "% sugar)"; }`} - 。给它补上注解:base 是 string,sweet 是 number,返回值 string。 - 然后故意把参数反着传,调用{" "} + 。给它补上注解:base 是 string,sweet 是 number,返回值 string。然后故意把参数反着传,调用{" "} makeTea(50, "Oolong"),读一读编译器报了什么。

@@ -61,9 +59,8 @@ export const LABS: Lab[] = [ } zh={ <> - 每个参数写成 名字: 类型,返回值写在关门括号后面: - ): string。 - 报错会说清是哪个参数、期望什么、实际给了什么。 + 每个参数写成名字: 类型,返回值写在关门括号后面: + ): string。报错会说清是哪个参数、期望什么、实际给了什么。 } /> @@ -107,10 +104,7 @@ makeTea(50, "Oolong"); // Argument of type 'number' is not assignable to parameter of type 'string'.`} />

- 只会出现一条报错,不是两条。 - 对一个只有单条签名的函数,TypeScript 报出第一个对不上的实参就停了; - 把它改对,第二个实参的报错才会冒出来。 - 而没有注解的 JS 版本根本不报错:程序照跑,打印出 + 只会出现一条报错,不是两条。对一个只有单条签名的函数,TypeScript 报出第一个对不上的实参就停了;把它改对,第二个实参的报错才会冒出来。而没有注解的 JS 版本根本不报错:程序照跑,打印出 "50(Oolong% sugar)"。

@@ -151,9 +145,7 @@ makeTea(50, "Oolong"); zh={ <>

- 在 Playground 里写一个 orderLine 函数: - 第一个参数 base(string,必选), - 第二个参数 sugar(默认值 50),后面收任意多个配料{" "} + 在 Playground 里写一个 orderLine 函数:第一个参数 base(string,必选),第二个参数 sugar(默认值 50),后面收任意多个配料{" "} ...toppings: string[]。然后做三件事: ① 悬停 sugar,看推断出来的类型; ② 确认 orderLine("Milk Green") 和{" "} @@ -179,8 +171,7 @@ makeTea(50, "Oolong"); } zh={ <> - 带默认值的参数不用写类型;rest 参数必须放在最后。 - 第 ③ 步不会报「顺序错误」—— 想想这时候函数到底要求几个实参。 + 带默认值的参数不用写类型;rest 参数必须放在最后。第 ③ 步不会报「顺序错误」—— 想想这时候函数到底要求几个实参。 } /> @@ -236,15 +227,10 @@ bad("Milk Green"); // Expected 2 arguments, but got 1.`} />

- 悬停 sugar 显示 number,是从默认值 50 推断的。 - 第 ③ 步最有意思:把带默认值的参数放在前面并不是语法错误, - 但这个默认值用不上了 —— - 一个参数要在调用处变成可选,前提是它后面的参数也全都可选。 - 这里 base 是必选的,所以函数仍然要求两个实参, - bad("Milk Green") 直接卡在个数上。 - 想用默认值就得写成{" "} - bad(undefined, "Milk Green")。 - 所以实践中带默认值的参数照样放在最后。 + 悬停 sugar 显示 number,是从默认值 50 推断的。第 ③ 步最有意思:把带默认值的参数放在前面并不是语法错误,但这个默认值用不上了 —— + 一个参数要在调用处变成可选,前提是它后面的参数也全都可选。这里 base 是必选的,所以函数仍然要求两个实参, + bad("Milk Green") 直接卡在个数上。想用默认值就得写成{" "} + bad(undefined, "Milk Green")。所以实践中带默认值的参数照样放在最后。

} @@ -301,8 +287,7 @@ bad("Milk Green"); } zh={ <> - 索引签名的写法是 {"[sku: string]: number"}。 - 两条报错的说法完全不同:一条说属性只读,一条说类型不可赋值。 + 索引签名的写法是 {"[sku: string]: number"}。两条报错的说法完全不同:一条说属性只读,一条说类型不可赋值。 } /> @@ -368,9 +353,7 @@ stock["tea-002"] = "many"; // Type 'string' is not assignable to type 'number'.`} />

- 两条规则各管一头:readonly 管的是这个属性能不能写, - 索引签名管的是写进去的值长什么样。 - 顺手再做个实验:点开右侧的 .JS 标签 —— readonly{" "} + 两条规则各管一头:readonly 管的是这个属性能不能写,索引签名管的是写进去的值长什么样。顺手再做个实验:点开右侧的 .JS 标签 —— readonly{" "} 和所有类型都不见了。这就是「类型只活在编译期」的实际含义。

@@ -435,8 +418,7 @@ stock["tea-002"] = "many"; } zh={ <> - 第 ① 步的重点是「什么都不报」:两个同名 interface 会被合并成一个。 - 造对象时少写任何一个字段,编译器都会告诉你。 + 第 ① 步的重点是「什么都不报」:两个同名 interface 会被合并成一个。造对象时少写任何一个字段,编译器都会告诉你。 } /> @@ -496,10 +478,7 @@ type Shop2 = { city: string }; // Duplicate identifier 'Shop2'. type Size = "small" | "large";`} />

- 合并看着像 bug,其实是一道特意留的门: - 用来给不属于你的类型做补充,比如给 window 加自定义字段, - 或者给第三方包的类型打补丁(09 章细讲)。 - 也正因为它在读代码时容易被忽略,团队通常会约定统一用法 —— + 合并看着像 bug,其实是一道特意留的门:用来给不属于你的类型做补充,比如给 window 加自定义字段,或者给第三方包的类型打补丁(09 章细讲)。也正因为它在读代码时容易被忽略,团队通常会约定统一用法 —— 「随便选一个,但要一致」说的就是这件事。

@@ -569,8 +548,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - topping?: string 本身是完全合法的可选参数写法。 - 问题不在这个参数身上,在它站的位置。 + topping?: string 本身是完全合法的可选参数写法。问题不在这个参数身上,在它站的位置。 ), }, @@ -584,8 +562,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 参数个数没有上限,rest 参数甚至能收任意多个。 - 报错说的是顺序,不是数量。 + 参数个数没有上限,rest 参数甚至能收任意多个。报错说的是顺序,不是数量。 ), }, @@ -598,8 +575,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 必选参数本来就不需要默认值。 - 真正的规矩是:可选的、带默认值的,都排在必选参数后面。 + 必选参数本来就不需要默认值。真正的规矩是:可选的、带默认值的,都排在必选参数后面。 ), }, @@ -616,9 +592,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 实参按位置对号入座。调用 brew("Oolong") 时, - 没法说清 "Oolong" 算 topping 还是 base, - 所以 TypeScript 直接禁止这样声明。报错原文:A required parameter + 实参按位置对号入座。调用 brew("Oolong") 时,没法说清 "Oolong" 算 topping 还是 base,所以 TypeScript 直接禁止这样声明。报错原文:A required parameter cannot follow an optional parameter. ), @@ -693,8 +667,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - () => 123 是一个返回 123 的箭头函数。 - 这道题问的是类型检查,不是这个值本身。 + () => 123 是一个返回 123 的箭头函数。这道题问的是类型检查,不是这个值本身。 ), }, @@ -707,8 +680,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 运行时什么事都没有。类型全部被擦除, - 这个函数老老实实返回 123,只是没人去读它。 + 运行时什么事都没有。类型全部被擦除,这个函数老老实实返回 123,只是没人去读它。 ), }, @@ -725,9 +697,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - () => void 的宽容是有意设计的。 - 否则 forEach((n) => arr.push(n)) 就通不过检查, - 因为 push 返回一个 number。记住这个分工: + () => void 的宽容是有意设计的。否则 forEach((n) => arr.push(n)) 就通不过检查,因为 push 返回一个 number。记住这个分工: 类型里的 void 表示调用方不看结果, 声明上的 void 表示函数不返回值。 @@ -790,8 +760,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 确实可能抛,但 TypeScript 不强制 try/catch,这段代码编译毫无问题。 - 编译器检查的是类型,不是运行时风险。 + 确实可能抛,但 TypeScript 不强制 try/catch,这段代码编译毫无问题。编译器检查的是类型,不是运行时风险。 ), }, @@ -805,8 +774,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - JSON.parse 吃的就是字符串,json: string 写得完全正确。 - 问题出在「出去」那一头,不在「进来」这一头。 + JSON.parse 吃的就是字符串,json: string 写得完全正确。问题出在「出去」那一头,不在「进来」这一头。 ), }, @@ -819,8 +787,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 推断确实在工作,但它读的是 JSON.parse 的返回值类型, - 而那个类型在标准库里就是 any。 + 推断确实在工作,但它读的是 JSON.parse 的返回值类型,而那个类型在标准库里就是 any。 ), }, @@ -836,10 +803,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - JSON.parse 返回 any,推断原样放行, - 于是 load 的返回值也是 any。 - 调用方拼错字段、乱调方法,编译器全程沉默。 - 修法是写出返回值类型,把 any 关在函数内部。 + JSON.parse 返回 any,推断原样放行,于是 load 的返回值也是 any。调用方拼错字段、乱调方法,编译器全程沉默。修法是写出返回值类型,把 any 关在函数内部。 ), }, @@ -918,8 +882,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 勾多了。其中一项是两边都会的基本功, - 还有一项恰恰是 interface 独有的能力 —— 两个同名 type 只会报 + 勾多了。其中一项是两边都会的基本功,还有一项恰恰是 interface 独有的能力 —— 两个同名 type 只会报 Duplicate identifier。 ), @@ -935,8 +898,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 描述对象形状两边都行;合并是 interface 独有; - 联合类型和映射类型是 type 独有 —— + 描述对象形状两边都行;合并是 interface 独有;联合类型和映射类型是 type 独有 —— 这也是 06、07 两章的类型编程全部写在 type 一侧的原因。 ), @@ -997,8 +959,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - Object.freeze 是要你自己调用的运行时函数。 - TypeScript 从不往产物里添加行为,类型擦除是铁律。 + Object.freeze 是要你自己调用的运行时函数。TypeScript 从不往产物里添加行为,类型擦除是铁律。 ), }, @@ -1011,8 +972,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - getter 是真实的 JavaScript 语法,得你自己写。 - readonly 一个字节的代码都不产生。 + getter 是真实的 JavaScript 语法,得你自己写。readonly 一个字节的代码都不产生。 ), }, @@ -1026,8 +986,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 连注释都不会留下。类型信息对运行时完全不可见, - 这正是「类型擦除」的含义。 + 连注释都不会留下。类型信息对运行时完全不可见,这正是「类型擦除」的含义。 ), }, @@ -1043,10 +1002,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - readonly 是编译期的检查: - 你写代码时编译器拦你,运行时没有任何防护。 - 想要真冻结就用 Object.freeze。 - 不过在团队协作里,编译期这一拦已经挡掉了绝大多数误改。 + readonly 是编译期的检查:你写代码时编译器拦你,运行时没有任何防护。想要真冻结就用 Object.freeze。不过在团队协作里,编译期这一拦已经挡掉了绝大多数误改。 ), }, @@ -1095,8 +1051,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - void。写在类型位置上,它表示调用方不会读结果, - 所以返回任何值的函数都能匹配。这就是 forEach 能接收{" "} + void。写在类型位置上,它表示调用方不会读结果,所以返回任何值的函数都能匹配。这就是 forEach 能接收{" "} push 这种有返回值的回调的原因。 ), @@ -1146,8 +1101,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 试试就知道:g() 会报 Expected 1 arguments, but got 0。 - 两者的区别正是「能不能空手调用」。 + 试试就知道:g() 会报 Expected 1 arguments, but got 0。两者的区别正是「能不能空手调用」。 ), }, @@ -1213,8 +1167,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - [1, 2, 3].map((n) => n * 2) 能编译通过, - 可是 map 调用回调时会传三个实参。为什么? + [1, 2, 3].map((n) => n * 2) 能编译通过,可是 map 调用回调时会传三个实参。为什么? ), }, @@ -1261,8 +1214,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - map 只有一条签名。这条规则是通用的, - 对所有函数类型都成立,不是 map 的特例。 + map 只有一条签名。这条规则是通用的,对所有函数类型都成立,不是 map 的特例。 ), }, @@ -1277,8 +1229,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 没有任何补齐。运行时 map 照样传三个实参, - 只是这个回调没有给后两个起名字。 + 没有任何补齐。运行时 map 照样传三个实参,只是这个回调没有给后两个起名字。 ), }, @@ -1310,10 +1261,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 在 JavaScript 里,传的实参比声明的参数多是正常且无害的。 - TypeScript 允许的正是这一点, - 所以单参数的回调放得进三参数的回调类型里。 - 反过来会被拒绝:第四个参数会去读一个根本没人传的实参。 + 在 JavaScript 里,传的实参比声明的参数多是正常且无害的。TypeScript 允许的正是这一点,所以单参数的回调放得进三参数的回调类型里。反过来会被拒绝:第四个参数会去读一个根本没人传的实参。 ), }, @@ -1332,8 +1280,7 @@ export const QUIZ: QuizItem[] = [ <> 即使开着 strict, interface Feeder { feed(a: Animal): void }{" "} - 也接受一个 feed 只收 Dog 的对象。 - 这说明什么? + 也接受一个 feed 只收 Dog 的对象。这说明什么? ), }, @@ -1364,8 +1311,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - strictFunctionTypes 默认关着, - 必须在 strict 之外单独打开。 + strictFunctionTypes 默认关着,必须在 strict 之外单独打开。 ), }, @@ -1402,8 +1348,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - strict 确实会打开 strictFunctionTypes。 - 只是这个开关对用方法语法声明的成员不生效。 + strict 确实会打开 strictFunctionTypes。只是这个开关对用方法语法声明的成员不生效。 ), }, @@ -1417,9 +1362,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 在函数类型里,参数按逆变检查: - 一个收 Dog 的函数不能赋给「要求收 Animal」的位置。 - 方法语法才是例外。 + 在函数类型里,参数按逆变检查:一个收 Dog 的函数不能赋给「要求收 Animal」的位置。方法语法才是例外。 ), }, @@ -1438,12 +1381,8 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 在 strictFunctionTypes 下,函数类型位置上的参数 - 按逆变检查,而用方法语法声明的成员沿用旧的双变规则。 - 这是类型系统里一个已知且刻意保留的漏洞: - 堵上它,Array<Dog> 就不能当作{" "} - Array<Animal> 用了。 - 想要严格检查,就用属性语法写这个成员。 + 在 strictFunctionTypes 下,函数类型位置上的参数按逆变检查,而用方法语法声明的成员沿用旧的双变规则。这是类型系统里一个已知且刻意保留的漏洞:堵上它,Array<Dog> 就不能当作{" "} + Array<Animal> 用了。想要严格检查,就用属性语法写这个成员。 ), }, @@ -1523,8 +1462,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 重载解析不会去找「最合适的那条」。 - 它在第一条能接受这些实参的签名上就停了,而 unknown{" "} + 重载解析不会去找「最合适的那条」。它在第一条能接受这些实参的签名上就停了,而 unknown{" "} 什么都接受。 ), @@ -1569,10 +1507,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 重载按书写顺序尝试,第一条匹配的胜出。 - 把两条签名调换,fmt(1) 就变成 number 了。 - 顺序是 API 的一部分:越具体的签名越要写在前面。 - 另外别忘了,实现签名不能从外部调用。 + 重载按书写顺序尝试,第一条匹配的胜出。把两条签名调换,fmt(1) 就变成 number 了。顺序是 API 的一部分:越具体的签名越要写在前面。另外别忘了,实现签名不能从外部调用。 ), }, @@ -1603,8 +1538,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 随便选一个并保持一致;需要 union 或映射类型时用 type, - 需要 merging 时用 interface。 + 随便选一个并保持一致;需要 union 或映射类型时用 type,需要 merging 时用 interface。 ), }, @@ -1630,8 +1564,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 「一定更快」是流传很广的说法,并没有这样的绝对结论, - 官方也从没建议以性能为由禁用 type。 + 「一定更快」是流传很广的说法,并没有这样的绝对结论,官方也从没建议以性能为由禁用 type。 ), }, @@ -1659,8 +1592,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - handbook 恰恰没有强制规定, - 现行说法是按个人与团队偏好选,保持一致即可。 + handbook 恰恰没有强制规定,现行说法是按个人与团队偏好选,保持一致即可。 ), }, @@ -1676,10 +1608,8 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 这场争论被夸大了。大部分代码里两者可以互换, - 真正的选型标准只有两条:你需要的能力 - (union 或映射类型 → type;merging → interface), - 以及代码库内部的一致性。 + 这场争论被夸大了。大部分代码里两者可以互换,真正的选型标准只有两条:你需要的能力 + (union 或映射类型 → type;merging → interface),以及代码库内部的一致性。 ), }, diff --git a/lib/generics-data.tsx b/lib/generics-data.tsx index fdb4664..46cbdbd 100644 --- a/lib/generics-data.tsx +++ b/lib/generics-data.tsx @@ -205,8 +205,7 @@ export const LABS: Lab[] = [ <> 在函数名后面加 <T> 声明占位符, 再把签名里写死的{" "} string / number / boolean{" "} - 都换成 T。 返回值里的 | undefined 要留着: - 空数组取第 0 个,拿到的是 undefined。 + 都换成 T。返回值里的 | undefined 要留着:空数组取第 0 个,拿到的是 undefined。 } /> @@ -225,8 +224,7 @@ export const LABS: Lab[] = [ zh={

这一题的重点就是悬停:你能亲眼看到 T{" "} - 在每次调用被解成不同的类型,返回值类型也跟着变。 - 一份定义,一个类型都没丢。 + 在每次调用被解成不同的类型,返回值类型也跟着变。一份定义,一个类型都没丢。

} /> @@ -292,8 +290,7 @@ export const LABS: Lab[] = [ } zh={ <> - 切片用 all.slice((page - 1) * size, page * size)。 - 返回值类型写成 {"Paginated"} —— + 切片用 all.slice((page - 1) * size, page * size)。返回值类型写成 {"Paginated"} —— 函数的占位符和类型的占位符是同一个 T。 } @@ -353,11 +350,8 @@ export const LABS: Lab[] = [ {"function longest(a: T, b: T): T"} - ,返回 length 更大的那个。然后调用四次: - 两个字符串、两个数组、longest(10, 100)、 - {"longest({ length: 3 }, { length: 7 })"}。 - 每次先自己预测结果,再看编译器怎么说。最后把约束删掉, - 看函数体里哪一行先坏掉。 + ,返回 length 更大的那个。然后调用四次:两个字符串、两个数组、longest(10, 100)、 + {"longest({ length: 3 }, { length: 7 })"}。每次先自己预测结果,再看编译器怎么说。最后把约束删掉,看函数体里哪一行先坏掉。

} /> @@ -375,8 +369,7 @@ export const LABS: Lab[] = [ zh={ <> 判断一个类型能不能通过,只问一句:它可以赋值给{" "} - {"{ length: number }"} 吗?这就是上一章那套结构化检查。 - 和 class、继承没有任何关系。 + {"{ length: number }"} 吗?这就是上一章那套结构化检查。和 class、继承没有任何关系。 } /> @@ -396,8 +389,7 @@ export const LABS: Lab[] = [ zh={

最后那个实验才是这题的题眼:约束同时朝两个方向起作用 —— - 对调用方它是一个条件,对函数体它是一个保证。 - 删掉它,先垮的是函数体。 + 对调用方它是一个条件,对函数体它是一个保证。删掉它,先垮的是函数体。

} /> @@ -434,11 +426,9 @@ export const LABS: Lab[] = [

一个签名可以声明不止一个占位符。请在 Playground 里实现两个函数:①{" "} {"swap(pair: [A, B]): [B, A]"}, 交换一个二元组;②{" "} - {"zip(as: A[], bs: B[]): [A, B][]"}, - 把两个数组按位配对,以短的那个为准。验收标准: + {"zip(as: A[], bs: B[]): [A, B][]"},把两个数组按位配对,以短的那个为准。验收标准: {'swap(["Boba milk tea", 18])'} 悬停显示{" "} - [number, string],而且配出来的每一对, - 两个位置各自的类型都不糊。 + [number, string],而且配出来的每一对,两个位置各自的类型都不糊。

} /> @@ -481,8 +471,7 @@ export const LABS: Lab[] = [ } zh={

- A 和 B 各自独立推断, - 各自在出现的每个位置保持一致。swap{" "} + A 和 B 各自独立推断,各自在出现的每个位置保持一致。swap{" "} 换的是值的位置,不是类型;zip{" "} 配出的每一对都是货真价实的 [A, B]。多个占位符正是{" "} {"Record"}、{"Map"}{" "} @@ -547,8 +536,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 「什么都收 + 不报错」any 就做得到,而且做得更彻底。 - 泛型的价值恰恰在于还会报错:类型对不上的时候它拦得住。 + 「什么都收 + 不报错」any 就做得到,而且做得更彻底。泛型的价值恰恰在于还会报错:类型对不上的时候它拦得住。 ), }, @@ -563,8 +551,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 类型参数只存在于编译期,擦除之后运行时没有 T。 - 按值的形状分支那是收窄,第 03 章的内容。 + 类型参数只存在于编译期,擦除之后运行时没有 T。按值的形状分支那是收窄,第 03 章的内容。 ), }, @@ -577,8 +564,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 字符数往往还变多了。泛型省掉的是重复的定义和丢失的类型信息, - 不是键盘敲击数。 + 字符数往往还变多了。泛型省掉的是重复的定义和丢失的类型信息,不是键盘敲击数。 ), }, @@ -593,8 +579,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 类型参数是一个在调用处才被填上的占位符, - 同一个签名里的每次使用指的都是同一个类型 —— + 类型参数是一个在调用处才被填上的占位符,同一个签名里的每次使用指的都是同一个类型 —— 返回值的类型就是这样和实参的类型绑在一起的。 ), @@ -689,8 +674,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> unknown{" "} - 是在完全没有推断原料时才会出现的结果,比如类型参数只出现在返回值里。 - 这里原料充足:[9.9, 19.9] 就是 number[]。 + 是在完全没有推断原料时才会出现的结果,比如类型参数只出现在返回值里。这里原料充足:[9.9, 19.9] 就是 number[]。 ), }, @@ -796,8 +780,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 「写得长」不是论点。区别在行为:一个把类型丢在门口, - 一个把类型带到结果。 + 「写得长」不是论点。区别在行为:一个把类型丢在门口,一个把类型带到结果。 ), }, @@ -827,8 +810,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - any 没有被废弃,它有合法的用途。 - 它和泛型是两个不同的工具,不是新旧版本的关系。 + any 没有被废弃,它有合法的用途。它和泛型是两个不同的工具,不是新旧版本的关系。 ), }, @@ -917,8 +899,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 数组也有 .length,通过,T = number[]。 - 约束检查的是形状,不是「你是不是数组」。 + 数组也有 .length,通过,T = number[]。约束检查的是形状,不是「你是不是数组」。 ), }, @@ -932,8 +913,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 这个匿名对象字面量正好有 length: number, - 所以满足约束。约束问的是形状,不问这个类型是哪来的。 + 这个匿名对象字面量正好有 length: number,所以满足约束。约束问的是形状,不问这个类型是哪来的。 ), }, @@ -1027,8 +1007,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> 还有对的没选全。编译出的 JavaScript 里还有 T 吗? - {""} 是合法语法吗? - 同一次调用会把同一个占位符填成两种类型吗? + {""} 是合法语法吗?同一次调用会把同一个占位符填成两种类型吗? ), }, @@ -1046,8 +1025,7 @@ export const QUIZ: QuizItem[] = [ <> 选进了错误说法。类型参数列表里的 extends 是约束,不是继承 —— 它的意思是「可以赋值给」, - string 或一个普通对象类型都满足它。 - 而绝大多数泛型调用靠推断,尖括号是例外。 + string 或一个普通对象类型都满足它。而绝大多数泛型调用靠推断,尖括号是例外。 ), }, @@ -1104,7 +1082,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - T 只出现一次,没联系任何东西,也就什么都没承诺。 改成{" "} + T 只出现一次,没联系任何东西,也就什么都没承诺。改成{" "} x: unknown 更诚实 ), @@ -1146,9 +1124,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 这种灵活是假的。x: unknown 一样什么值都收。 - 类型参数在这里没提供 unknown 给不了的任何东西, - 只多了阅读成本。 + 这种灵活是假的。x: unknown 一样什么值都收。类型参数在这里没提供 unknown 给不了的任何东西,只多了阅读成本。 ), }, @@ -1164,8 +1140,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> 加约束解决不了根本问题:T{" "} - 依然只出现一次,依然没联系任何东西。 - 问题不是「洞太松」,而是「这个洞根本没在做事」。 + 依然只出现一次,依然没联系任何东西。问题不是「洞太松」,而是「这个洞根本没在做事」。 ), }, @@ -1196,8 +1171,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 类型参数的价值在于把东西联系起来,所以它至少要出现两次: - 在两个参数之间,或者在参数和返回值之间。 只出现一次的 T{" "} + 类型参数的价值在于把东西联系起来,所以它至少要出现两次:在两个参数之间,或者在参数和返回值之间。只出现一次的 T{" "} 什么都没承诺,直接写 unknown 更清楚。 ), @@ -1232,8 +1206,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 和 class 继承用的是同一个单词,但在类型参数列表里它的意思是 - 「可以赋值给」。本章 §04 那道门上就写着它。 + 和 class 继承用的是同一个单词,但在类型参数列表里它的意思是「可以赋值给」。本章 §04 那道门上就写着它。 ), }, @@ -1248,8 +1221,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> {""} 的意思是 T - 必须可以赋值给 {"{ length: number }"}。它是约束,不是继承, - 判定用的是第 04 章那套结构化检查。 + 必须可以赋值给 {"{ length: number }"}。它是约束,不是继承,判定用的是第 04 章那套结构化检查。 ), }, @@ -1348,8 +1320,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 它连运行的机会都没有。错误在编译期就报出来了, - 这正是把约束写进签名的意义。 + 它连运行的机会都没有。错误在编译期就报出来了,这正是把约束写进签名的意义。 ), }, diff --git a/lib/home-data.tsx b/lib/home-data.tsx index 733d8ab..c074d74 100644 --- a/lib/home-data.tsx +++ b/lib/home-data.tsx @@ -49,8 +49,7 @@ export const LABS: Lab[] = [ typescriptlang.org/play (免注册),清空左侧编辑器,把下面这段纯 JS - 贴进去 —— 一个类型都别写。然后把鼠标依次悬停在 shopName、price、 - menu 这几个变量名上,看弹出来的类型提示。 + 贴进去 —— 一个类型都别写。然后把鼠标依次悬停在 shopName、price、menu 这几个变量名上,看弹出来的类型提示。 } /> @@ -224,8 +223,7 @@ const menu = [ zh={ <> 把下面这段贴进 Playground,然后看右侧面板的 .JS 标签 - (默认就开着)—— 那就是编译产物。对照找一找:type 那行去哪了? - 两个冒号注解去哪了? + (默认就开着)—— 那就是编译产物。对照找一找:type 那行去哪了?两个冒号注解去哪了? } /> @@ -335,8 +333,7 @@ function priceOf(base, size) { zh={ <> 三步:npm i -D typescript →{" "} - npx tsc --init → npx tsc。 - 编译产物默认生成在源文件旁边。 + npx tsc --init → npx tsc。编译产物默认生成在源文件旁边。 } /> @@ -375,8 +372,7 @@ diff order.ts order.js`} } zh={ <> - diff 会列出所有差异:type 行没了、两个注解没了,其余原样。 - 再跑 node order.js,输出 large 22 —— + diff 会列出所有差异:type 行没了、两个注解没了,其余原样。再跑 node order.js,输出 large 22 —— 和写 JS 没有任何区别。顺手把 size 改成{" "} "mega" 再 npx tsc ,感受一下编译器把关的样子:报错、拒绝放行。 @@ -467,8 +463,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 浏览器根本不认识 TS,它只跑 JS。TS 必须先编译(擦掉类型) - 才能进浏览器 —— 这也是「三条路」都绕不开的一步。 + 浏览器根本不认识 TS,它只跑 JS。TS 必须先编译(擦掉类型)才能进浏览器 —— 这也是「三条路」都绕不开的一步。 } />, @@ -500,8 +495,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 超集意味着你会的 JS 一行都没白学。TS 加的那层「类型」是描述, - 不是逻辑 —— 描述你的数据长什么样,好让编译器替你把关。 + 超集意味着你会的 JS 一行都没白学。TS 加的那层「类型」是描述,不是逻辑 —— 描述你的数据长什么样,好让编译器替你把关。 } /> @@ -555,8 +549,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 运行时已经太晚了 —— 而且类型在编译后全部擦除,运行时根本没有 - 「类型」这个东西可查。 + 运行时已经太晚了 —— 而且类型在编译后全部擦除,运行时根本没有「类型」这个东西可查。 } />, @@ -571,8 +564,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 部署只是把编译产物搬过去。检查早在你编辑器里保存那一刻 - (以及 CI 里跑 tsc 时)就完成了。 + 部署只是把编译产物搬过去。检查早在你编辑器里保存那一刻(以及 CI 里跑 tsc 时)就完成了。 } />, @@ -697,8 +689,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 反了 —— 被保证擦干净的恰恰是类型;注释保不保留倒要看编译配置。 - 类型从不进产物。 + 反了 —— 被保证擦干净的恰恰是类型;注释保不保留倒要看编译配置。类型从不进产物。 } />, @@ -715,8 +706,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 类型是「编译期的安检」,下了飞机(运行时)就不存在了。想通这一点, - 你就同时想通了两件事:TS 不拖慢程序,TS 也不在运行时保护你。 + 类型是「编译期的安检」,下了飞机(运行时)就不存在了。想通这一点,你就同时想通了两件事:TS 不拖慢程序,TS 也不在运行时保护你。 } /> @@ -768,8 +758,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 三条正路你漏了至少一条 —— 提示:一条靠「编译」,一条靠「转译」, - 还有一条是运行时「边擦边跑」。 + 三条正路你漏了至少一条 —— 提示:一条靠「编译」,一条靠「转译」,还有一条是运行时「边擦边跑」。 } /> @@ -784,8 +773,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 有一项到今天也办不到:浏览器只认 JS,你给它 .ts, - 它只会当成语法错误的脚本。 + 有一项到今天也办不到:浏览器只认 JS,你给它 .ts,它只会当成语法错误的脚本。 } /> @@ -921,8 +909,7 @@ export const QUIZ: QuizItem[] = [ zh={ <> undefined 不吭声,乘个数变 NaN,NaN 再一路传染 —— - 等它在页面上露面,案发现场早离出错那行十万八千里了。 - 这正是「编译期安检」要拦的头号危险品。 + 等它在页面上露面,案发现场早离出错那行十万八千里了。这正是「编译期安检」要拦的头号危险品。 } /> @@ -969,8 +956,7 @@ export const QUIZ: QuizItem[] = [ <> tsc 干两件事:检查(对照类型说明书报错)+ 翻译 (把 .ts 擦成 .js)。npx tsc --init 生成配置, - npx tsc --noEmit 只检查不出产物 —— 这几个命令后面章节 - 会一直用。 + npx tsc --noEmit 只检查不出产物 —— 这几个命令后面章节会一直用。 } /> @@ -1039,8 +1025,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 不存在这回事:产物就是 JS,引擎该怎么执行还怎么执行, - 一步都没省。 + 不存在这回事:产物就是 JS,引擎该怎么执行还怎么执行,一步都没省。 } />, @@ -1055,8 +1040,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 也不慢 —— 擦除几乎不改动代码本身,产物性能和手写 JS 相同。 - 「快慢」这个维度上,TS 是中性的。 + 也不慢 —— 擦除几乎不改动代码本身,产物性能和手写 JS 相同。「快慢」这个维度上,TS 是中性的。 } />, diff --git a/lib/mindset-data.tsx b/lib/mindset-data.tsx index 2fac2d4..e5e8c3a 100644 --- a/lib/mindset-data.tsx +++ b/lib/mindset-data.tsx @@ -203,11 +203,8 @@ export const LABS: Lab[] = [

打开 typescriptlang.org/play,把 §01 的 Config{" "} 和配置对象粘进去,然后做三轮:① 用 : Config 注解 ② 用{" "} - as Config ③ 用 satisfies Config。 - 每一轮都把对象弄坏两次:把 theme 拼成{" "} - thema、删掉 maxSugar, - 记下哪种写法报错、哪种放行。最后恢复正确的对象, - 悬停 config.theme,看三种写法各推断出什么。 + as Config ③ 用 satisfies Config。每一轮都把对象弄坏两次:把 theme 拼成{" "} + thema、删掉 maxSugar,记下哪种写法报错、哪种放行。最后恢复正确的对象,悬停 config.theme,看三种写法各推断出什么。

), @@ -223,9 +220,7 @@ export const LABS: Lab[] = [ ), zh: ( <> - 你在填一张九格表:三种写法 × 三个问题(拼错的键、缺失的键、 - 推断出的类型)。填完应该和 §01 的三盏灯完全一致。 - 有一格对不上,先弄清楚再往下走。 + 你在填一张九格表:三种写法 × 三个问题(拼错的键、缺失的键、推断出的类型)。填完应该和 §01 的三盏灯完全一致。有一格对不上,先弄清楚再往下走。 ), }, @@ -252,9 +247,7 @@ export const LABS: Lab[] = [ zh: ( <> 再做一个实验:把最后一行改成{" "} - {"} as const satisfies Config;"} 再悬停。 - 这时每个属性都是 readonly,每个字面量都保留精确 - 类型,形状仍然按 Config 校验 —— + {"} as const satisfies Config;"} 再悬停。这时每个属性都是 readonly,每个字面量都保留精确类型,形状仍然按 Config 校验 —— 配置对象通常就这么写。 ), @@ -307,9 +300,7 @@ export const LABS: Lab[] = [ "interface Order { id: string; total: number; toppings: string[] }" }
- ,然后手写 isOrder(x: unknown): x is Order, - 逐个字段检查,包括 toppings 是不是「字符串数组」。 - 光用 Array.isArray 不够,它不说明元素是什么。 + ,然后手写 isOrder(x: unknown): x is Order,逐个字段检查,包括 toppings 是不是「字符串数组」。光用 Array.isArray 不够,它不说明元素是什么。

用两份数据自测: @@ -334,10 +325,8 @@ export const LABS: Lab[] = [ ), zh: ( <> - 数组的套路:先 Array.isArray(o.toppings) 确认是数组, - 再 {'o.toppings.every((t) => typeof t === "string")'}{" "} - 检查元素。TypeScript 5.5 起,编译器能自己给这个箭头函数推断出类型谓词, - 所以 every 之后数组会被收窄成 string[]。 + 数组的套路:先 Array.isArray(o.toppings) 确认是数组,再 {'o.toppings.every((t) => typeof t === "string")'}{" "} + 检查元素。TypeScript 5.5 起,编译器能自己给这个箭头函数推断出类型谓词,所以 every 之后数组会被收窄成 string[]。 ), }, @@ -360,8 +349,7 @@ export const LABS: Lab[] = [ zh: ( <> 第二份数据的 total 是字符串{" "} - {'"30"'},不是数字 30。 - 线上最常见的坏数据就是这种「只差一点点」的。 + {'"30"'},不是数字 30。线上最常见的坏数据就是这种「只差一点点」的。 as 会放它进来,校验函数不会。 ), @@ -419,10 +407,8 @@ export const LABS: Lab[] = [ ), zh: ( <> - 两条路:① 键重映射 [P in keyof T as …], - 把黑名单上的键映射成 never;② 先用{" "} - Exclude 过滤 keyof T,再映射剩下的键。 - 两种都写出来,才算真懂。 + 两条路:① 键重映射 [P in keyof T as …],把黑名单上的键映射成 never;② 先用{" "} + Exclude 过滤 keyof T,再映射剩下的键。两种都写出来,才算真懂。 ), }, @@ -445,9 +431,7 @@ export const LABS: Lab[] = [ zh: ( <> 两种写法行为相同。第二种更接近标准库的真实定义: - {"Pick>"}。 - 去 type-challenges 的第 3 题(Omit)提交你的版本, - 看看测试用例能不能找出你漏掉的边角。 + {"Pick>"}。去 type-challenges 的第 3 题(Omit)提交你的版本,看看测试用例能不能找出你漏掉的边角。 ), }} @@ -485,13 +469,10 @@ export const LABS: Lab[] = [ zh: ( <>

- 在 Playground 实现 DeepReadonly<T>: - 对象每一层的每个属性都变成 readonly。 - 验收方法:拿一个嵌套两层的配置对象,给最里层的字段赋值 —— + 在 Playground 实现 DeepReadonly<T>:对象每一层的每个属性都变成 readonly。验收方法:拿一个嵌套两层的配置对象,给最里层的字段赋值 —— 这一行必须报错。做完这道,去{" "} github.com/type-challenges/type-challenges 的 easy - 区做三道,推荐 Pick、Readonly、Tuple to Object。 - 每道题自带测试用例,当场就有结果。 + 区做三道,推荐 Pick、Readonly、Tuple to Object。每道题自带测试用例,当场就有结果。

), @@ -509,10 +490,8 @@ export const LABS: Lab[] = [ ), zh: ( <> - 骨架:一个带 readonly 修饰符的映射类型, - 属性类型走条件类型 —— 是对象就递归{" "} - DeepReadonly<T[K]>,不是就原样保留。 - 小心函数:函数也是对象,想放过它,就再加一层{" "} + 骨架:一个带 readonly 修饰符的映射类型,属性类型走条件类型 —— 是对象就递归{" "} + DeepReadonly<T[K]>,不是就原样保留。小心函数:函数也是对象,想放过它,就再加一层{" "} extends 判断。 ), @@ -538,9 +517,7 @@ export const LABS: Lab[] = [ ), zh: ( <> - type-challenges 的官方判题要求更细 —— 数组和元组还有各自的 - 规则。提交你的版本,读失败的测试用例,再改。 - 这个循环比现成答案更有用。 + type-challenges 的官方判题要求更细 —— 数组和元组还有各自的规则。提交你的版本,读失败的测试用例,再改。这个循环比现成答案更有用。 ), }} @@ -633,8 +610,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 再想想 b。const 绑定不会被重新赋值, - 编译器没有理由拓宽它,推断停在字面量类型{" "} + 再想想 b。const 绑定不会被重新赋值,编译器没有理由拓宽它,推断停在字面量类型{" "} "Oolong Tea"。 ), @@ -650,8 +626,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - a 用 let 声明,以后可能被赋成别的字符串。 - 编译器把它拓宽成 string,不然你连改都改不了。 + a 用 let 声明,以后可能被赋成别的字符串。编译器把它拓宽成 string,不然你连改都改不了。 ), }, @@ -665,8 +640,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 正好说反了:let 拓宽,const 保留字面量。 - 「会不会被改」决定「推得多宽」。 + 正好说反了:let 拓宽,const 保留字面量。「会不会被改」决定「推得多宽」。 ), }, @@ -751,9 +725,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 那是没有穷尽检查时的情况:新状态被静默吞掉, - 页面显示成一片空白,没人知道为什么。 - 那行 never 赋值就是为了打破这种沉默。 + 那是没有穷尽检查时的情况:新状态被静默吞掉,页面显示成一片空白,没人知道为什么。那行 never 赋值就是为了打破这种沉默。 ), }, @@ -768,8 +740,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - never 只存在于编译期。类型被擦除, - 运行时找不到它的任何痕迹。它的全部作用就是把错误提前到编译期。 + never 只存在于编译期。类型被擦除,运行时找不到它的任何痕迹。它的全部作用就是把错误提前到编译期。 ), }, @@ -802,10 +773,8 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 这是穷尽检查(exhaustiveness check):联合的成员全部处理完之后, - 走到 default 的值被收窄成 never,赋给{" "} - never 合法;漏了一个,那个成员还在,赋值就失败。 - 新增状态的那一刻,所有没处理它的 switch 一起报错。—— 回看第 03 章。 + 这是穷尽检查(exhaustiveness check):联合的成员全部处理完之后,走到 default 的值被收窄成 never,赋给{" "} + never 合法;漏了一个,那个成员还在,赋值就失败。新增状态的那一刻,所有没处理它的 switch 一起报错。—— 回看第 03 章。 ), }, @@ -823,8 +792,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> {'order({ size: "large", ice: "none" })'} 报错说{" "} - ice 不存在,但先把同一个对象存进变量再传就通过了。 - 为什么? + ice 不存在,但先把同一个对象存进变量再传就通过了。为什么? ), }, @@ -843,8 +811,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 直接传的对象字面量是「新鲜」的,要做多余属性检查; - 存进变量之后只按普通的结构兼容判断,多出来的属性不追究 + 直接传的对象字面量是「新鲜」的,要做多余属性检查;存进变量之后只按普通的结构兼容判断,多出来的属性不追究 ), }, @@ -877,8 +844,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 没有「优先级」这回事。两种情况走的检查不同: - 新鲜的字面量在普通结构比较之外,多受一道检查。 + 没有「优先级」这回事。两种情况走的检查不同:新鲜的字面量在普通结构比较之外,多受一道检查。 ), }, @@ -894,8 +860,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 这是刻意设计:在调用处现写的字面量,多出来的属性几乎都是拼错 - 或误解,值得报出来;变量可能在别处另有用途,多几个属性是合法的。 + 这是刻意设计:在调用处现写的字面量,多出来的属性几乎都是拼错或误解,值得报出来;变量可能在别处另有用途,多几个属性是合法的。 ), }, @@ -909,8 +874,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - const 对象的类型检查一点不少,少的只是多余属性检查 - 这一项。结构兼容仍然生效:少了属性照样报错。 + const 对象的类型检查一点不少,少的只是多余属性检查这一项。结构兼容仍然生效:少了属性照样报错。 ), }, @@ -1030,8 +994,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 数组也有 length,T 推断成 number[]。 - 这个调用合法。 + 数组也有 length,T 推断成 number[]。这个调用合法。 ), }, @@ -1046,8 +1009,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 这个对象的形状里明写着 length: number。 - TypeScript 比较形状而不是名字,谁提供了 length{" "} + 这个对象的形状里明写着 length: number。TypeScript 比较形状而不是名字,谁提供了 length{" "} 谁就满足约束。 ), @@ -1064,10 +1026,8 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - extends {"{ length: number }"} 是类型参数的约束: - 传进来的类型必须有一个 number 类型的{" "} - length 属性。number 没有 length, - 调用被拦下。—— 回看第 05 章。 + extends {"{ length: number }"} 是类型参数的约束:传进来的类型必须有一个 number 类型的{" "} + length 属性。number 没有 length,调用被拦下。—— 回看第 05 章。 ), }, @@ -1084,8 +1044,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 要做草稿功能:没填完的 Order 也要能保存,每个字段都允许先空着。 - 哪个工具类型是为这件事准备的? + 要做草稿功能:没填完的 Order 也要能保存,每个字段都允许先空着。哪个工具类型是为这件事准备的? ), }, @@ -1152,8 +1111,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - Required 方向相反:它把所有可选字段变成必填。 - 那是提交前做最后校验用的,不是保存草稿用的。 + Required 方向相反:它把所有可选字段变成必填。那是提交前做最后校验用的,不是保存草稿用的。 ), }, @@ -1167,8 +1125,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - Readonly 管的是「能不能改」,不是「能不能空」。 - 草稿恰恰是要反复改的。 + Readonly 管的是「能不能改」,不是「能不能空」。草稿恰恰是要反复改的。 ), }, @@ -1182,8 +1139,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - Pick 是挑几个字段组成新类型。草稿不是「只剩 id」, - 而是「每个字段都可以先没有」。 + Pick 是挑几个字段组成新类型。草稿不是「只剩 id」,而是「每个字段都可以先没有」。 ), }, @@ -1264,8 +1220,7 @@ export const QUIZ: QuizItem[] = [ <> typeof(用在类型位置)从值里取出类型, keyof 再把键收成联合: - {'"oolong" | "mango"'}。菜单只写一遍, - 名字的类型从菜单里长出来;菜单改了,类型跟着改。—— 回看第 07 章。 + {'"oolong" | "mango"'}。菜单只写一遍,名字的类型从菜单里长出来;菜单改了,类型跟着改。—— 回看第 07 章。 ), }, @@ -1403,8 +1358,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> useUnknownInCatchVariables(strict 家族成员)让 catch - 变量是 unknown:一种类型安全的「不知道」。 - 用之前必须收窄,通常是 e instanceof Error。 + 变量是 unknown:一种类型安全的「不知道」。用之前必须收窄,通常是 e instanceof Error。 —— 回看第 10 章。 ), @@ -1424,8 +1378,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 写一个主题配置对象:既要编译器校验它符合 Config, - 又要 config.theme 保持字面量类型{" "} + 写一个主题配置对象:既要编译器校验它符合 Config,又要 config.theme 保持字面量类型{" "} "dark",方便后续做精确判断。用哪种写法? ), @@ -1526,8 +1479,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 什么都不加,推断确实精确,但没有任何校验:字段拼错、漏写, - 要到使用它的地方才暴露,离你写下它的位置很远。 + 什么都不加,推断确实精确,但没有任何校验:字段拼错、漏写,要到使用它的地方才暴露,离你写下它的位置很远。 ), }, @@ -1544,8 +1496,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - satisfies(TypeScript 4.9)校验形状,同时保留推断出的 - 字面量类型 —— 只有它两样都给。一句话:注解检查但拓宽, + satisfies(TypeScript 4.9)校验形状,同时保留推断出的字面量类型 —— 只有它两样都给。一句话:注解检查但拓宽, as 既不检查也不保留,satisfies 两样都做。 —— 回看本章 §01。 @@ -1618,8 +1569,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 迁移期在局部用 any 可以接受, - 只要它不出现在导出的函数签名上 + 迁移期在局部用 any 可以接受,只要它不出现在导出的函数签名上 ), }, @@ -1649,8 +1599,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 还漏了一条。想想 any 最麻烦的性质: - 它会顺着赋值和属性访问一路传下去。另外「别进导出签名」那条也别漏。 + 还漏了一条。想想 any 最麻烦的性质:它会顺着赋值和属性访问一路传下去。另外「别进导出签名」那条也别漏。 ), }, @@ -1666,8 +1615,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> 有一项选多了。要么是把 unknown 和 any{" "} - 当成了一回事(它们对「怎么用这个值」的规定完全不同), - 要么是高估了编译器对运行时数据的了解(类型被擦除了,它管不到)。 + 当成了一回事(它们对「怎么用这个值」的规定完全不同),要么是高估了编译器对运行时数据的了解(类型被擦除了,它管不到)。 ), }, @@ -1688,11 +1636,8 @@ export const QUIZ: QuizItem[] = [ zh: ( <> unknown 接收任何值,但用之前必须收窄;any{" "} - 接收任何值且放行任何用法,还会扩散。原则:先用 unknown, - 只有当它让代码写不下去时才降级到 any, - 并且锁在最小的作用域里。E 是错的:res.json() 在 DOM - 库里的类型是 Promise<any>, - 真实形状只有在运行时被检查过才知道。—— 回看本章 §03、§04。 + 接收任何值且放行任何用法,还会扩散。原则:先用 unknown,只有当它让代码写不下去时才降级到 any,并且锁在最小的作用域里。E 是错的:res.json() 在 DOM + 库里的类型是 Promise<any>,真实形状只有在运行时被检查过才知道。—— 回看本章 §03、§04。 ), }, @@ -1799,8 +1744,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - require 是 CommonJS 的运行时函数,导入的是值, - 擦不掉,和类型导入完全是两回事。 + require 是 CommonJS 的运行时函数,导入的是值,擦不掉,和类型导入完全是两回事。 ), }, @@ -1814,8 +1758,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 命名空间导入拿进来的是整个模块的值。你只想要一个类型, - 却把全部实现搬了进来 —— 这正是 import type 要避免的。 + 命名空间导入拿进来的是整个模块的值。你只想要一个类型,却把全部实现搬了进来 —— 这正是 import type 要避免的。 ), }, @@ -1831,8 +1774,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - import type 明确表示这一行只有类型, - 编译时整行删除,打包器也不用猜。 + import type 明确表示这一行只有类型,编译时整行删除,打包器也不用猜。 verbatimModuleSyntax(TypeScript 5.0) 把这个习惯变成了硬规定。—— 回看第 09 章、第 10 章。 @@ -1867,8 +1809,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - as const 的作用就是「一点都不拓宽」。 - 推断停在你写下的那个字面量上,同时每个属性都变成{" "} + as const 的作用就是「一点都不拓宽」。推断停在你写下的那个字面量上,同时每个属性都变成{" "} readonly。 ), @@ -1885,10 +1826,8 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - as const 把推断钉在字面量 {'"dark"'} 上, - 并加上 readonly。没有它,对象属性会被拓宽成{" "} - string。配合 satisfies, - 就是配置对象最常见的写法。—— 回看本章 §01。 + as const 把推断钉在字面量 {'"dark"'} 上,并加上 readonly。没有它,对象属性会被拓宽成{" "} + string。配合 satisfies,就是配置对象最常见的写法。—— 回看本章 §01。 ), }, @@ -1934,8 +1873,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 不能 —— interface 在编译时被擦除, - 运行时的判断只能检查值本身(类型谓词或校验函数) + 不能 —— interface 在编译时被擦除,运行时的判断只能检查值本身(类型谓词或校验函数) ), }, @@ -1977,8 +1915,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 恰好相反:interface 不产生任何 JavaScript, - 在编译产物里一个字节都找不到。 + 恰好相反:interface 不产生任何 JavaScript,在编译产物里一个字节都找不到。 ), }, @@ -1993,8 +1930,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - strict 管的是编译期检查有多严,变不出运行时的类。 - 擦除对所有配置一视同仁。 + strict 管的是编译期检查有多严,变不出运行时的类。擦除对所有配置一视同仁。 ), }, @@ -2008,8 +1944,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 这件事上 type 和 interface 完全一样: - 都只存在于编译期,都会被擦除。运行时只能检查值本身的形状。 + 这件事上 type 和 interface 完全一样:都只存在于编译期,都会被擦除。运行时只能检查值本身的形状。 ), }, @@ -2028,10 +1963,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 类型擦除既是这门课的第一课,也是最后一课:类型存在于你写代码和 - 编译的阶段;真正运行的是普通 JavaScript,里面只有值。 - 所以边界上需要你手写校验(本章 §03 的 isOrder); - 也所以可辨识联合靠的是 status 这个 + 类型擦除既是这门课的第一课,也是最后一课:类型存在于你写代码和编译的阶段;真正运行的是普通 JavaScript,里面只有值。所以边界上需要你手写校验(本章 §03 的 isOrder);也所以可辨识联合靠的是 status 这个 真实存在的属性,而不是类型的名字。—— 回看序章、本章 §03。 ), diff --git a/lib/modules-data.tsx b/lib/modules-data.tsx index e24a46f..0ac6b53 100644 --- a/lib/modules-data.tsx +++ b/lib/modules-data.tsx @@ -181,8 +181,7 @@ export const LABS: Lab[] = [

在一个空目录里建两个文件:a.ts 里写{" "} const TAX = 0.06;,b.ts 里写{" "} - const TAX = 0.08;,两个文件都不写任何 import / export。 - 先跑 npx tsc --noEmit a.ts b.ts;再往 b.ts{" "} + const TAX = 0.08;,两个文件都不写任何 import / export。先跑 npx tsc --noEmit a.ts b.ts;再往 b.ts{" "} 顶上加一行 export {"{}"},重跑一次。 TAX 这个名字的归属发生了什么变化?

@@ -198,8 +197,7 @@ export const LABS: Lab[] = [ ), zh: ( <> - 第一次会在两个文件里各报一次同样的错。这就是线索: - 两个声明在同一个作用域里,所以它们互相看得见。 + 第一次会在两个文件里各报一次同样的错。这就是线索:两个声明在同一个作用域里,所以它们互相看得见。 ), }, @@ -228,11 +226,8 @@ export const LABS: Lab[] = [

一个顶层既没有 import 也没有 export{" "} - 的文件是脚本。它的顶层声明会进入全局作用域, - 而程序里所有脚本共用这一个作用域, - 所以两个脚本声明同名变量就会撞车。加上 export {"{}"}{" "} - 之后,文件变成模块:顶层的名字只属于这个文件, - 别的文件只有在你导出、它导入之后才看得见。 + 的文件是脚本。它的顶层声明会进入全局作用域,而程序里所有脚本共用这一个作用域,所以两个脚本声明同名变量就会撞车。加上 export {"{}"}{" "} + 之后,文件变成模块:顶层的名字只属于这个文件,别的文件只有在你导出、它导入之后才看得见。

本章后面的内容都建立在这个区分之上 —— @@ -315,8 +310,7 @@ export const LABS: Lab[] = [ export declare function cheapest(items: MenuItem[]): MenuItem; {" "} - —— 只有签名,没有函数体;interface 则被原样抄了过去。 - .JS 面板正相反:函数体在,interface 不见了。 + —— 只有签名,没有函数体;interface 则被原样抄了过去。.JS 面板正相反:函数体在,interface 不见了。

同一份源码,两样产物:.js 是真正运行的代码, @@ -348,9 +342,7 @@ export const LABS: Lab[] = [ ), zh: (

- 找个空目录,写一个 order.ts,导出一个函数和一个 interface。 - 然后跑 npx tsc order.ts --declaration, - 打开生成的两个文件,和源码对照着看。 + 找个空目录,写一个 order.ts,导出一个函数和一个 interface。然后跑 npx tsc order.ts --declaration,打开生成的两个文件,和源码对照着看。

), }, @@ -364,8 +356,7 @@ export const LABS: Lab[] = [ ), zh: ( <> - 产物应该有两个:order.js 和 order.d.ts。 - 只想要声明文件?再加 --emitDeclarationOnly。 + 产物应该有两个:order.js 和 order.d.ts。只想要声明文件?再加 --emitDeclarationOnly。 ), }, @@ -404,8 +395,7 @@ export const LABS: Lab[] = [

TS 写的库「出厂自带类型」就是这么做的:编译时打开{" "} declaration,再用 package.json 的{" "} - types 字段指到这份 .d.ts。 - 这就是类型三个来源里的第一个。 + types 字段指到这份 .d.ts。这就是类型三个来源里的第一个。

), @@ -438,8 +428,7 @@ export const LABS: Lab[] = [ 新建目录,跑 npm init -y,再 npm i lodash{" "} —— 注意先别装 @types/lodash。写一个{" "} index.ts 导入 debounce,跑{" "} - npx tsc --noEmit --strict index.ts,把报错读全。 - 然后 npm i -D @types/lodash,重跑一次。 + npx tsc --noEmit --strict index.ts,把报错读全。然后 npm i -D @types/lodash,重跑一次。

), }, @@ -477,8 +466,7 @@ export const LABS: Lab[] = [

@types/lodash 装好的那一刻,编辑器里{" "} - debounce 的补全和参数提示同时出现。 - 库本体没变,变的只是类型声明到货了。打开{" "} + debounce 的补全和参数提示同时出现。库本体没变,变的只是类型声明到货了。打开{" "} node_modules/@types/lodash 看一眼:全是{" "} .d.ts,一行实现都没有。

@@ -536,10 +524,8 @@ export const LABS: Lab[] = [ ), zh: ( <> - 把 .d.ts 和源文件一起交给 tsc。 - 另外确认任何上级目录的 node_modules 里都没有真的{" "} - boba-sdk,否则 Node 会把它找出来。 - 类型检查会通过,Node 会失败 —— 想清楚这两个结果为什么不矛盾。 + 把 .d.ts 和源文件一起交给 tsc。另外确认任何上级目录的 node_modules 里都没有真的{" "} + boba-sdk,否则 Node 会把它找出来。类型检查会通过,Node 会失败 —— 想清楚这两个结果为什么不矛盾。 ), }, @@ -574,12 +560,10 @@ export const LABS: Lab[] = [ />

- 声明是对「运行时有什么」的一个断言。编译器只能采信它,无从核实。 - Node 从不读 .d.ts —— 它去找真正的模块,找不到就报错。 + 声明是对「运行时有什么」的一个断言。编译器只能采信它,无从核实。Node 从不读 .d.ts —— 它去找真正的模块,找不到就报错。

- 所以 declare module 面对的是装了但没有类型的包, - 而不是一种「不装也能导入」的办法。 + 所以 declare module 面对的是装了但没有类型的包,而不是一种「不装也能导入」的办法。

), @@ -650,9 +634,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 那是加上 export {"{}"} 之后的情况。 - 顶层没有任何 import / export 时,这个文件根本不算模块, - 它的名字是共享的,不是私有的。 + 那是加上 export {"{}"} 之后的情况。顶层没有任何 import / export 时,这个文件根本不算模块,它的名字是共享的,不是私有的。 ), }, @@ -682,8 +664,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> module 决定的是输出格式(esnext、 - commonjs 等)。某个文件到底是不是模块,由文件自己决定: - 顶层有没有 import / export? + commonjs 等)。某个文件到底是不是模块,由文件自己决定:顶层有没有 import / export? ), }, @@ -707,8 +688,7 @@ export const QUIZ: QuizItem[] = [ error TS2451: Cannot redeclare block-scoped variable 'TAX'. {" "} - 加一行 export {"{}"} 让文件获得模块作用域,报错就消失。 - 本章所有关于声明文件的内容都建立在这个区分之上。 + 加一行 export {"{}"} 让文件获得模块作用域,报错就消失。本章所有关于声明文件的内容都建立在这个区分之上。 ), }, @@ -762,8 +742,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 一行实现都不允许。declare 的含义正是「实现在别处」。 - 带实现的那份文件叫 .ts。 + 一行实现都不允许。declare 的含义正是「实现在别处」。带实现的那份文件叫 .ts。 ), }, @@ -795,9 +774,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - .d.ts 描述每个导出长什么样,自己不产出任何代码; - 真正运行的代码在旁边那份 .js 里。 - 因为描述形状不需要改动代码,.d.ts{" "} + .d.ts 描述每个导出长什么样,自己不产出任何代码;真正运行的代码在旁边那份 .js 里。因为描述形状不需要改动代码,.d.ts{" "} 可以描述任何 JavaScript 库 —— 包括从来不是用 TypeScript 写的库。 ), @@ -869,8 +846,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> 那就成了值导入,运行时会真的去加载 ./order。 - type 的意义正是声明「这一行只有类型」, - 所以整行都可以删掉。 + type 的意义正是声明「这一行只有类型」,所以整行都可以删掉。 ), }, @@ -884,8 +860,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - require 是输出格式为 CommonJS 时才出现的东西, - 那是另一个问题。纯类型导入在轮到输出格式之前就已经被删掉了。 + require 是输出格式为 CommonJS 时才出现的东西,那是另一个问题。纯类型导入在轮到输出格式之前就已经被删掉了。 ), }, @@ -899,8 +874,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 浏览器不认识 TypeScript 的 type 关键字, - 所以这一行必须在编译期处理掉,没有任何东西留给运行时决定。 + 浏览器不认识 TypeScript 的 type 关键字,所以这一行必须在编译期处理掉,没有任何东西留给运行时决定。 ), }, @@ -916,10 +890,8 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 类型会被擦除,所以纯类型导入没有任何东西可导入。 - 删掉这一行同时也删掉了那次模块加载 —— 这一点很重要: - .js 模块被加载时可以执行代码, - 而你不希望仅仅因为需要一个类型名字就触发它。 + 类型会被擦除,所以纯类型导入没有任何东西可导入。删掉这一行同时也删掉了那次模块加载 —— 这一点很重要: + .js 模块被加载时可以执行代码,而你不希望仅仅因为需要一个类型名字就触发它。 ), }, @@ -949,8 +921,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - esbuild、SWC 这类单文件转译器分不清一个名字是值还是类型, - 显式标注让它们不必猜 + esbuild、SWC 这类单文件转译器分不清一个名字是值还是类型,显式标注让它们不必猜 ), }, @@ -985,8 +956,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 这一行编译后就没了,谈不上加载。 - 收益在于构建工具不用猜,而不是运行时速度。 + 这一行编译后就没了,谈不上加载。收益在于构建工具不用猜,而不是运行时速度。 ), }, @@ -1000,8 +970,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 类型永远到不了运行时,擦除是无条件的。真正的风险方向相反: - 工具猜错,把某个值需要的导入删掉了,程序在运行时才失败。 + 类型永远到不了运行时,擦除是无条件的。真正的风险方向相反:工具猜错,把某个值需要的导入删掉了,程序在运行时才失败。 ), }, @@ -1016,8 +985,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 只用 tsc 编译的项目,差别确实不大。 - 一旦接入单文件转译器,它就成了正确性问题 —— + 只用 tsc 编译的项目,差别确实不大。一旦接入单文件转译器,它就成了正确性问题 —— 这也是 TypeScript 5.0 加入 verbatimModuleSyntax{" "} 把规则明确下来的原因。 @@ -1134,8 +1102,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> 包已经装好了 —— 没装的话,报错会是{" "} - ts(2307): Cannot find module 'lodash'。 - 缺的是类型声明,重装并不会带来它。 + ts(2307): Cannot find module 'lodash'。缺的是类型声明,重装并不会带来它。 ), }, @@ -1149,9 +1116,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 能编译通过,但 lodash 的每个函数从此都是 any: - 没有补全、不检查参数、没有返回类型。 - 为了消掉一行报错,代价太大。 + 能编译通过,但 lodash 的每个函数从此都是 any:没有补全、不检查参数、没有返回类型。为了消掉一行报错,代价太大。 ), }, @@ -1164,8 +1129,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 为了一个库,把整个项目的检查都削弱了。 - 而正确的解法,报错原文里已经写着。 + 为了一个库,把整个项目的检查都削弱了。而正确的解法,报错原文里已经写着。 ), }, @@ -1181,9 +1145,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 按顺序检查三个来源:包自带 → @types → 自己写声明。 - lodash 是老牌库,@types/lodash 一定有。 - 一条命令,类型、补全和文档提示就都回来了。 + 按顺序检查三个来源:包自带 → @types → 自己写声明。lodash 是老牌库,@types/lodash 一定有。一条命令,类型、补全和文档提示就都回来了。 ), }, @@ -1215,8 +1177,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 两个英文单词连写,在 GitHub 上。 - 这个名字是在讲它收录的那些库:现在它们「确实有类型了」。 + 两个英文单词连写,在 GitHub 上。这个名字是在讲它收录的那些库:现在它们「确实有类型了」。 ), }, @@ -1232,8 +1193,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> DefinitelyTyped 收录了几千个 JavaScript 库的社区声明,并发布成{" "} - @types/*。你也可以给它提 PR, - 让下一个人不必再写一遍同样的声明。 + @types/*。你也可以给它提 PR,让下一个人不必再写一遍同样的声明。 ), }, @@ -1302,9 +1262,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - @types 包里只有类型,不改变任何运行时能力。 - Node 能直接跑 TypeScript 是 Node 自己的功能 - (类型剥离,Node 22.6 加入),和这个包无关。 + @types 包里只有类型,不改变任何运行时能力。Node 能直接跑 TypeScript 是 Node 自己的功能(类型剥离,Node 22.6 加入),和这个包无关。 ), }, @@ -1318,8 +1276,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - DOM 的类型随 TypeScript 一起发布,在 lib.dom.d.ts 里, - 由 lib 选项控制 —— 完全是另一个来源。 + DOM 的类型随 TypeScript 一起发布,在 lib.dom.d.ts 里,由 lib 选项控制 —— 完全是另一个来源。 ), }, @@ -1352,8 +1309,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> fs、path、process{" "} - 不属于 JavaScript 语言本身,它们由 Node 提供, - 所以声明不在 TypeScript 自带的那几本 lib 里,而来自{" "} + 不属于 JavaScript 语言本身,它们由 Node 提供,所以声明不在 TypeScript 自带的那几本 lib 里,而来自{" "} @types/node。这也是几乎每个 Node 项目都要装它的原因。 ), @@ -1466,10 +1422,8 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - declare 只陈述类型、不产出代码, - 所以断言错了,失败会发生在运行时、发生在你的程序里。 - declare global 是从模块内部触及全局作用域的正确写法, - 写在别处会报 ts(2669)。至于模块标识, + declare 只陈述类型、不产出代码,所以断言错了,失败会发生在运行时、发生在你的程序里。 + declare global 是从模块内部触及全局作用域的正确写法,写在别处会报 ts(2669)。至于模块标识, declare module 接受任何能用来导入模块的字符串 —— 包名、路径,或者像 "*.css" 这样的通配符。 @@ -1616,12 +1570,8 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - paths 是编译期的映射, - 它只告诉编译器某个标识指向哪个文件。真正运行代码的那一方 —— - Node、打包器、测试运行器 —— 需要一份自己的对应配置,否则导入就会失败。 - Next.js、Vite、webpack 会读 tsconfig.json 的 paths, - 或者提供等价的别名选项;这就是为什么别名在应用里通常好用, - 却在你第一次用纯 node 跑产物时失败。 + paths 是编译期的映射,它只告诉编译器某个标识指向哪个文件。真正运行代码的那一方 —— + Node、打包器、测试运行器 —— 需要一份自己的对应配置,否则导入就会失败。Next.js、Vite、webpack 会读 tsconfig.json 的 paths,或者提供等价的别名选项;这就是为什么别名在应用里通常好用,却在你第一次用纯 node 跑产物时失败。 ), }, @@ -1697,8 +1647,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 方向反了。参数传错、方法名拼错,照样报错。 - 跳过的是声明之间的互相检查,不是「你用得对不对」。 + 方向反了。参数传错、方法名拼错,照样报错。跳过的是声明之间的互相检查,不是「你用得对不对」。 ), }, @@ -1712,8 +1661,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - node_modules 里的 JavaScript 本来就不参与类型检查, - 除非你打开 allowJs 并把它 include 进来。 + node_modules 里的 JavaScript 本来就不参与类型检查,除非你打开 allowJs 并把它 include 进来。 skipLibCheck 管的是 .d.ts。 ), @@ -1749,11 +1697,8 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 声明文件之间会互相冲突:两个 @types 包可能用不同的类型 - 声明了同一个全局变量,而这两份你都改不了。 - skipLibCheck 跳过这类冲突,同时让编译更快。 - 代价真实存在但不大:声明文件内部真有错也不会报出来, - 你可能只能在调用处发现某个类型不对劲。 + 声明文件之间会互相冲突:两个 @types 包可能用不同的类型声明了同一个全局变量,而这两份你都改不了。 + skipLibCheck 跳过这类冲突,同时让编译更快。代价真实存在但不大:声明文件内部真有错也不会报出来,你可能只能在调用处发现某个类型不对劲。 ), }, @@ -1773,8 +1718,7 @@ export const QUIZ: QuizItem[] = [ <> 项目里只有 helpers.d.ts,没有对应的{" "} helpers.js。你写{" "} - import {"{ helper }"} from "./helpers", - 然后用 Node 运行产物,结果是? + import {"{ helper }"} from "./helpers",然后用 Node 运行产物,结果是? ), }, @@ -1835,8 +1779,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 编译器不会拦。声明文件在,形状也对得上,导入就解析成功了。 - 这正是这个坑容易漏过去的地方:一路绿灯,直到运行才出问题。 + 编译器不会拦。声明文件在,形状也对得上,导入就解析成功了。这正是这个坑容易漏过去的地方:一路绿灯,直到运行才出问题。 ), }, @@ -1850,8 +1793,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - .d.ts 不产出任何东西,没有实现可用。 - 声明写得再详细,也变不出代码。 + .d.ts 不产出任何东西,没有实现可用。声明写得再详细,也变不出代码。 ), }, @@ -1864,8 +1806,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 根本走不到那一步。模块本身不存在, - 导入这一步就失败了,轮不到读取任何绑定。 + 根本走不到那一步。模块本身不存在,导入这一步就失败了,轮不到读取任何绑定。 ), }, @@ -1892,8 +1833,7 @@ export const QUIZ: QuizItem[] = [ '.../helpers.js'
,在 CommonJS 下报{" "} - Error: Cannot find module './helpers'。 - 无论哪种,声明只能让编译器满意。 + Error: Cannot find module './helpers'。无论哪种,声明只能让编译器满意。 ), }, diff --git a/lib/narrowing-data.tsx b/lib/narrowing-data.tsx index 373ebe5..0784f7e 100644 --- a/lib/narrowing-data.tsx +++ b/lib/narrowing-data.tsx @@ -234,11 +234,8 @@ export const LABS: Lab[] = [

打开 TypeScript Playground(typescriptlang.org/play),写一个{" "} {"function shout(id: string | number)"} - ,函数体里直接 return id.toUpperCase()。 - 读一读报错,注意它有两行:第二行点名了到底是谁缺这个成员。 - 然后用 typeof 把它修好。修好后,把光标分别悬停在{" "} - if 里面和 if 之后的 id 上, - 对比这两个类型。 + ,函数体里直接 return id.toUpperCase()。读一读报错,注意它有两行:第二行点名了到底是谁缺这个成员。然后用 typeof 把它修好。修好后,把光标分别悬停在{" "} + if 里面和 if 之后的 id 上,对比这两个类型。

} /> @@ -254,8 +251,7 @@ export const LABS: Lab[] = [ } zh={ <> - 报错的第二行会告诉你联合里哪个成员缺 toUpperCase。 - 修的办法是:写一道检查,让这条路径上只剩一种可能。 + 报错的第二行会告诉你联合里哪个成员缺 toUpperCase。修的办法是:写一道检查,让这条路径上只剩一种可能。 } /> @@ -314,14 +310,8 @@ export const LABS: Lab[] = [ zh={

在 Playground 里先声明{" "} - {"declare function later(fn: () => void): void;"}。 - 写一个接收 x: string | number 的函数,把它复制进{" "} - let value = x,用 typeof 收窄, - 然后调用两次 value.toUpperCase(): - 一次直接调用,一次放在传给 later 的回调里面。 - 最后在函数末尾加一行 value = 42。 - 这时只有一次调用会报错。再把 value = 42 删掉, - 看那个报错消失。 + {"declare function later(fn: () => void): void;"}。写一个接收 x: string | number 的函数,把它复制进{" "} + let value = x,用 typeof 收窄,然后调用两次 value.toUpperCase():一次直接调用,一次放在传给 later 的回调里面。最后在函数末尾加一行 value = 42。这时只有一次调用会报错。再把 value = 42 删掉,看那个报错消失。

} /> @@ -337,9 +327,7 @@ export const LABS: Lab[] = [ } zh={ <> - 编译器不知道回调什么时候执行。想一想: - 到了那个时刻,要满足什么条件,先前那道检查才依然成立? - 这就是末尾那行重新赋值为什么会影响结果。 + 编译器不知道回调什么时候执行。想一想:到了那个时刻,要满足什么条件,先前那道检查才依然成立?这就是末尾那行重新赋值为什么会影响结果。 } /> @@ -364,8 +352,7 @@ export const LABS: Lab[] = [ <> 然后换成属性再试一次:给参数一个{" "} {"{ note?: string }"} 类型,写{" "} - if (d.note),再在回调里用 d.note。 - 这一次一定会报{" "} + if (d.note),再在回调里用 d.note。这一次一定会报{" "} 'd.note' is possibly 'undefined' ,哪怕代码里没有任何重新赋值 —— 因为属性随时可能被函数外面的代码改掉。 @@ -405,9 +392,7 @@ export const LABS: Lab[] = [ zh={

定义一个三状态的 Order 类型:pending(只有{" "} - createdAt)、paid(多一个 paidAt)、 - delivered(再多一个 deliveredAt)。 - 每个成员的 status 都写成字面量类型。然后写{" "} + createdAt)、paid(多一个 paidAt)、delivered(再多一个 deliveredAt)。每个成员的 status 都写成字面量类型。然后写{" "} {"function report(order: Order): string"},对{" "} status 做 switch。在每个 case 里悬停{" "} order,确认形状不一样。最后故意在 pending 分支里读{" "} @@ -452,8 +437,7 @@ export const LABS: Lab[] = [ zh={ <> 现在把 status 改成 string{" "} - 再看:order 在所有 case 里都不再收窄, - 每一处 paidAt 都会报错。宽泛的{" "} + 再看:order 在所有 case 里都不再收窄,每一处 paidAt 都会报错。宽泛的{" "} string 区分不了成员,所以标签字段必须是字面量类型 —— 这正是三个条件里的第二条。 @@ -491,11 +475,9 @@ export const LABS: Lab[] = [ zh={

接着上一题。给 switch 补一个 default 分支,里面写{" "} - const _exhaustive: never = order;,确认没有报错。 - 然后给 Order 加第四个成员{" "} + const _exhaustive: never = order;,确认没有报错。然后给 Order 加第四个成员{" "} {'{ status: "refunded"; refundedAt: Date }'},并且 - 不要动 report。看编译器把错报在哪一行、说了什么。 - 最后补上缺的 case,看报错消失。 + 不要动 report。看编译器把错报在哪一行、说了什么。最后补上缺的 case,看报错消失。

} /> @@ -534,8 +516,7 @@ export const LABS: Lab[] = [ zh={ <> 体会这个工作流:改类型 → 编译器列出所有没跟上的 switch → - 逐个补齐 → 完成。真实项目里一个状态可能被二十处代码消费, - 这一招把「全局搜索然后祈祷」变成了照着清单干活。 + 逐个补齐 → 完成。真实项目里一个状态可能被二十处代码消费,这一招把「全局搜索然后祈祷」变成了照着清单干活。 } /> @@ -568,8 +549,7 @@ export const LABS: Lab[] = [ 还是那个 Order 类型:① 先写{" "} {'const paid = orders.filter((o) => o.status === "paid")'} ,悬停 paid 看类型;② 写一个谓词函数{" "} - {"function isPaid(o: Order): o is Paid"}, - 用它再 filter 一次,对比结果;③ 把 Playground 的 TypeScript + {"function isPaid(o: Order): o is Paid"},用它再 filter 一次,对比结果;③ 把 Playground 的 TypeScript 版本切到 5.5 以下,再看第 ① 步的类型。

} @@ -586,8 +566,7 @@ export const LABS: Lab[] = [ } zh={ <> - 版本选择器在 Playground 顶部。第 ① 步的答案随版本变化, - 亲眼看到这个差别正是这道题的重点。 + 版本选择器在 Playground 顶部。第 ① 步的答案随版本变化,亲眼看到这个差别正是这道题的重点。 } /> @@ -610,8 +589,7 @@ export const LABS: Lab[] = [ <> 从 TS 5.5 起,「单参数、直接返回一个收窄表达式」的 filter 回调会自动获得谓词。更长的逻辑仍然需要显式的{" "} - is。而一旦手写,编译器就无条件信你: - 函数体写错了,没有任何东西会提醒你。 + is。而一旦手写,编译器就无条件信你:函数体写错了,没有任何东西会提醒你。 } /> @@ -696,8 +674,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 当然允许。只要确定 id 是字符串,这个调用就没问题。 - 问题在于现在还不确定。 + 当然允许。只要确定 id 是字符串,这个调用就没问题。问题在于现在还不确定。 } />, @@ -713,9 +690,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 共有成员可以直接调用。toString 就没问题, - 因为 string 和 number 都有它。 - 被挡住的只是「有成员没有」的那些。 + 共有成员可以直接调用。toString 就没问题,因为 string 和 number 都有它。被挡住的只是「有成员没有」的那些。 } />, @@ -846,9 +821,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - typeof null === "object"。 - 这来自 1995 年 JavaScript 的第一版实现,为了兼容性一直保留。 - 所以判断对象的完整写法是{" "} + typeof null === "object"。这来自 1995 年 JavaScript 的第一版实现,为了兼容性一直保留。所以判断对象的完整写法是{" "} x !== null && typeof x === "object" @@ -929,8 +902,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 0 确实是合法的 number,但真值检查看的不是类型, - 而是这个值真不真 —— 0 不真。 + 0 确实是合法的 number,但真值检查看的不是类型,而是这个值真不真 —— 0 不真。 } />, @@ -945,8 +917,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 真值检查对任何类型都合法,这正是它危险的地方。 - 编译器一声不吭,坑就这么安静地埋下了。 + 真值检查对任何类型都合法,这正是它危险的地方。编译器一声不吭,坑就这么安静地埋下了。 } />, @@ -960,8 +931,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - if (0) 在运行时平静地得到 false,不会有任何错误。 - 没有异常,只有走错的分支。 + if (0) 在运行时平静地得到 false,不会有任何错误。没有异常,只有走错的分支。 } />, @@ -981,8 +951,7 @@ export const QUIZ: QuizItem[] = [ <> 真值检查会一次排除所有 falsy 值:undefined、 null、0、""、 - NaN。「0 件」是数据,不是没填。 - 如果你只想排除 undefined,就写{" "} + NaN。「0 件」是数据,不是没填。如果你只想排除 undefined,就写{" "} count !== undefined。 } @@ -1005,10 +974,8 @@ export const QUIZ: QuizItem[] = [ zh={ <> x 的类型是 string | number。在{" "} - if (typeof x === "string") 里, - 你给 setTimeout 传了一个调用{" "} - x.toUpperCase() 的回调;同一个函数里的别处, - 你还写了 x = 42。会发生什么? + if (typeof x === "string") 里,你给 setTimeout 传了一个调用{" "} + x.toUpperCase() 的回调;同一个函数里的别处,你还写了 x = 42。会发生什么? } /> @@ -1067,9 +1034,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 回调写在哪里并不重要,重要的是它什么时候执行, - 而编译器无法知道。既然 x 还会被重新赋值, - 到那时那道检查可能已经不成立了。 + 回调写在哪里并不重要,重要的是它什么时候执行,而编译器无法知道。既然 x 还会被重新赋值,到那时那道检查可能已经不成立了。 } />, @@ -1086,8 +1051,7 @@ export const QUIZ: QuizItem[] = [ zh={ <> 对一个 string | number 类型的变量来说,{" "} - x = 42 是完全合法的赋值。 - 它是报错的原因,而不是报错的位置。 + x = 42 是完全合法的赋值。它是报错的原因,而不是报错的位置。 } />, @@ -1101,8 +1065,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 回调里是会收窄的,但仅限于从未被重新赋值的变量。 - 把 x = 42 删掉,报错就没了。 + 回调里是会收窄的,但仅限于从未被重新赋值的变量。把 x = 42 删掉,报错就没了。 } />, @@ -1121,10 +1084,8 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 只有当变量不可能改变时,编译器才会把收窄结果带进嵌套函数。 - 函数里任何一处重新赋值都足以让它失效。被收窄的对象属性 - 更严格:它永远不会被带进回调,因为外部代码随时可能改动它。 - 两种情况的解法一样:把检查过的值复制进一个 const。 + 只有当变量不可能改变时,编译器才会把收窄结果带进嵌套函数。函数里任何一处重新赋值都足以让它失效。被收窄的对象属性 + 更严格:它永远不会被带进回调,因为外部代码随时可能改动它。两种情况的解法一样:把检查过的值复制进一个 const。 } /> @@ -1197,8 +1158,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 叫 kind、type、tag 都行。 - 编译器看的是结构,不是名字。 + 叫 kind、type、tag 都行。编译器看的是结构,不是名字。 } />, @@ -1214,9 +1174,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 差一个词:它必须是字面量类型,而不是 string。 - 宽泛的 string 区分不了成员。 - 数字字面量和布尔字面量同样可以当标签。 + 差一个词:它必须是字面量类型,而不是 string。宽泛的 string 区分不了成员。数字字面量和布尔字面量同样可以当标签。 } />, @@ -1247,8 +1205,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 三个条件:公共字段、字面量类型、 - 各成员的字面量互不相同。三条都满足时,一句{" "} + 三个条件:公共字段、字面量类型、各成员的字面量互不相同。三条都满足时,一句{" "} switch (order.status) 就能把整个对象收窄到对应的成员。 } @@ -1292,8 +1249,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 还漏了一个。把字段和字面量比较,就是一次普通的运行时比较, - 也是可辨识联合最日常的写法。 + 还漏了一个。把字段和字面量比较,就是一次普通的运行时比较,也是可辨识联合最日常的写法。 } /> @@ -1309,8 +1265,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 勾多了。有两个选项编译后就消失了,运行时什么都不检查。 - 那不是收窄,那只是让编译器别再报错。 + 勾多了。有两个选项编译后就消失了,运行时什么都不检查。那不是收窄,那只是让编译器别再报错。 } /> @@ -1328,11 +1283,8 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - typeof、in 和字面量比较, - 编译成 JavaScript 后都真的会执行, - 所以收窄背后有一次真实的检查。as 和 !{" "} - 编译后什么都不剩。类型判断错了,程序照样出错, - 只是这次连提醒都没有了。 + typeof、in 和字面量比较,编译成 JavaScript 后都真的会执行,所以收窄背后有一次真实的检查。as 和 !{" "} + 编译后什么都不剩。类型判断错了,程序照样出错,只是这次连提醒都没有了。 } /> @@ -1353,8 +1305,7 @@ export const QUIZ: QuizItem[] = [ zh={ <> 穷尽检查:在 default 分支里写{" "} - const _x: ____ = order;, - 就能让「漏了一个 case」变成编译错误。空格里填哪个类型? + const _x: ____ = order;,就能让「漏了一个 case」变成编译错误。空格里填哪个类型? } /> @@ -1371,8 +1322,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 它是空联合 —— 所有 case 都处理完之后剩下的那个类型。 - §02 那段动画的最后一帧就是它。 + 它是空联合 —— 所有 case 都处理完之后剩下的那个类型。§02 那段动画的最后一帧就是它。 } /> @@ -1390,9 +1340,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - never 是空联合,所以任何值都不能赋给它。 - 当所有 case 都被处理时,编译器认同这一行不可能被执行到, - 赋值成立。一旦联合里多出一个成员,它会流到{" "} + never 是空联合,所以任何值都不能赋给它。当所有 case 都被处理时,编译器认同这一行不可能被执行到,赋值成立。一旦联合里多出一个成员,它会流到{" "} default,赋值失败 —— 编译器就替你指出了漏掉的分支。 } @@ -1479,8 +1427,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 代码运行前所有类型都被擦除了,不会发生任何转换。 - 谓词只改变编译器记录的内容,值本身分毫不动。 + 代码运行前所有类型都被擦除了,不会发生任何转换。谓词只改变编译器记录的内容,值本身分毫不动。 } />, @@ -1495,8 +1442,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 恰恰相反,它不检查。这正是谓词最危险的地方: - 逻辑写反了,编译器照样相信你。 + 恰恰相反,它不检查。这正是谓词最危险的地方:逻辑写反了,编译器照样相信你。 } />, @@ -1532,12 +1478,10 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - o is Paid 的意思是「只要我返回 true,o 就是 Paid」。 - 所以在 if (isPaid(order)) 里 order{" "} + o is Paid 的意思是「只要我返回 true,o 就是 Paid」。所以在 if (isPaid(order)) 里 order{" "} 变成 Paid,而 orders.filter(isPaid){" "} 的类型是 Paid[]。还有一种相近的写法{" "} - asserts o is Paid,它收窄的不是某个分支, - 而是调用之后的所有代码。 + asserts o is Paid,它收窄的不是某个分支,而是调用之后的所有代码。 } /> @@ -1676,10 +1620,8 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - ! 是一句编译期的断言。它让报错消失, - 但不增加任何保护。断言错了,运行时照样是{" "} - TypeError,而且这次编译器完全没有提醒过你。 - 规矩:能用 ?. 或 ?? 表达的,就用它们。 + ! 是一句编译期的断言。它让报错消失,但不增加任何保护。断言错了,运行时照样是{" "} + TypeError,而且这次编译器完全没有提醒过你。规矩:能用 ?. 或 ?? 表达的,就用它们。 } /> @@ -1763,8 +1705,7 @@ export const QUIZ: QuizItem[] = [ zh={ <> 这是你想要的结果,但 || 判断的是真值,而{" "} - 0 是 falsy,所以它被替换掉了。 - 想要这个效果,用 ??。 + 0 是 falsy,所以它被替换掉了。想要这个效果,用 ??。 } />, @@ -1779,8 +1720,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - || 总会返回两个操作数之一。左边是 falsy, - 所以返回右边,也就是 50。 + || 总会返回两个操作数之一。左边是 falsy,所以返回右边,也就是 50。 } />, @@ -1794,8 +1734,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 这是合法的 JavaScript,也是合法的 TypeScript。 - 这种逻辑问题,编译器捕捉不到。 + 这是合法的 JavaScript,也是合法的 TypeScript。这种逻辑问题,编译器捕捉不到。 } />, @@ -1814,8 +1753,7 @@ export const QUIZ: QuizItem[] = [ <> || 判断的是真值,所以 0 和{" "} "" 也会被替换;?? 只认{" "} - null 和 undefined。 - 写默认值,用 ??。 + null 和 undefined。写默认值,用 ??。 } /> diff --git a/lib/structural-data.tsx b/lib/structural-data.tsx index 6b5a3ac..fcc0994 100644 --- a/lib/structural-data.tsx +++ b/lib/structural-data.tsx @@ -198,8 +198,7 @@ export const LABS: Lab[] = [ 和一个 makeOrder(o: Order) 函数。然后做两件事:① 把带错字属性 sweetnes 的对象字面量 在调用处直接传进去,读一遍报错;② - 把同一个对象先存进变量,再把变量传进去,它会安静通过。 - 把 ① 的报错逐字读完 —— 编译器有没有猜出你想写什么? + 把同一个对象先存进变量,再把变量传进去,它会安静通过。把 ① 的报错逐字读完 —— 编译器有没有猜出你想写什么? } /> @@ -216,8 +215,7 @@ export const LABS: Lab[] = [ ), zh: ( <> - 两次传的对象一个字都没改,唯一的区别是字面量写没写在调用处。 - 规则就是这么一句:多余属性检查只看「新鲜」的对象字面量。 + 两次传的对象一个字都没改,唯一的区别是字面量写没写在调用处。规则就是这么一句:多余属性检查只看「新鲜」的对象字面量。 ), }, @@ -240,11 +238,8 @@ export const LABS: Lab[] = [ zh={ <> ① 的报错结尾是 Did you mean to write 'sweetness'?{" "} - —— 编译器不但拦下错字,还说出了改法。② 编译通过, - 但运行时打印的是 full sugar: - 错字属性跟着混了进来,真正的 sweetness{" "} - 从头到尾没被赋值。一屏之内就是全部取舍: - 写在调用处的字面量从严查,变量按普通规则查。 + —— 编译器不但拦下错字,还说出了改法。② 编译通过,但运行时打印的是 full sugar:错字属性跟着混了进来,真正的 sweetness{" "} + 从头到尾没被赋值。一屏之内就是全部取舍:写在调用处的字面量从严查,变量按普通规则查。 } /> @@ -281,8 +276,7 @@ export const LABS: Lab[] = [ {"type HasName = { name: string }"} 和函数{" "} greet(x: HasName)。然后造三个来路完全不同的值:① 一个 class 的实例;② 一个存进变量的对象字面量;③ - 一个函数的返回值 —— 让它们全部通过 greet, - 全程不写 implements、不写类型注解、不写{" "} + 一个函数的返回值 —— 让它们全部通过 greet,全程不写 implements、不写类型注解、不写{" "} as。 } @@ -321,9 +315,7 @@ export const LABS: Lab[] = [ zh={ <> 三个值和 HasName{" "} - 之间没有任何声明过的关系。编译器把每个形状和要求的成员比了一遍, - 每次都找到了 name: string。 - 这里的兼容是算出来的,不是登记出来的。 + 之间没有任何声明过的关系。编译器把每个形状和要求的成员比了一遍,每次都找到了 name: string。这里的兼容是算出来的,不是登记出来的。 } /> @@ -358,9 +350,7 @@ export const LABS: Lab[] = [ 第一步:定义 type UserId = string 和{" "} type PostId = string,写一个{" "} getUser(id: UserId),然后故意传一个{" "} - PostId 进去,确认编译器毫无反应。第二步: - 把两个类型改成品牌类型(branded types), - 让同样的误传变成编译错误。再试着从一个普通 string 造出{" "} + PostId 进去,确认编译器毫无反应。第二步:把两个类型改成品牌类型(branded types),让同样的误传变成编译错误。再试着从一个普通 string 造出{" "} UserId,看看代价是什么。 } @@ -377,8 +367,7 @@ export const LABS: Lab[] = [ ), zh: ( <> - 编译器只比形状,那就让两个形状不一样: - 各自和一个带标记成员的对象类型求交集,标记成员的类型互不相同。 + 编译器只比形状,那就让两个形状不一样:各自和一个带标记成员的对象类型求交集,标记成员的类型互不相同。 ), }, @@ -407,12 +396,9 @@ export const LABS: Lab[] = [ zh={ <> __brand 只存在于类型层面。运行时{" "} - UserId 就是普通 string,标记不占任何开销。 - 注意诚实的那一面:普通 string 不能赋给{" "} - UserId,所以造一个出来总得断言一次。 - 通常的做法是只写一个 toUserId(s: string){" "} - 函数来做这次断言,只在数据进入系统的入口调用它, - 别处一律不写 as UserId。 + UserId 就是普通 string,标记不占任何开销。注意诚实的那一面:普通 string 不能赋给{" "} + UserId,所以造一个出来总得断言一次。通常的做法是只写一个 toUserId(s: string){" "} + 函数来做这次断言,只在数据进入系统的入口调用它,别处一律不写 as UserId。 } /> @@ -450,8 +436,7 @@ export const LABS: Lab[] = [ "tea"、true、 {"() => {}"}、[1, 2]、null、 undefined,记下哪些报错。再把 x{" "} - 的类型换成 object 重测一轮,对比两张结果, - 说清楚两个类型各自到底挡住了什么。 + 的类型换成 object 重测一轮,对比两张结果,说清楚两个类型各自到底挡住了什么。 } /> @@ -504,9 +489,7 @@ export const LABS: Lab[] = [ 结果:{"{}"} 只挡 null 和{" "} undefined,原始值全部放行 —— 因为它的必需成员清单是空的,任何值都满足。object{" "} - 挡掉全部原始值,只收对象、数组和函数。所以: - 想表达「任意对象」写 object; - 想表达「什么值都可能,用之前先收窄」写{" "} + 挡掉全部原始值,只收对象、数组和函数。所以:想表达「任意对象」写 object;想表达「什么值都可能,用之前先收窄」写{" "} unknown;{"{}"} 尽量别用。 } @@ -581,8 +564,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 声明关系不是必需的。一个从没提过目标类型的对象字面量, - 只要形状覆盖了目标的要求,照样通过。 + 声明关系不是必需的。一个从没提过目标类型的对象字面量,只要形状覆盖了目标的要求,照样通过。 ), }, @@ -595,8 +577,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 在哪里声明完全没有影响。两个毫无关系的文件里, - 形状相同的两个类型可以互换。 + 在哪里声明完全没有影响。两个毫无关系的文件里,形状相同的两个类型可以互换。 ), }, @@ -612,8 +593,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - TypeScript 用的是结构化类型(structural typing):忽略名字,比较成员。 - 源类型有目标要求的每个成员,且这些成员的类型也兼容,赋值就成立。 + TypeScript 用的是结构化类型(structural typing):忽略名字,比较成员。源类型有目标要求的每个成员,且这些成员的类型也兼容,赋值就成立。 ), }, @@ -674,8 +654,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> 「不同 interface 就是不同类型」是名义类型系统的规则。TypeScript - 里名字只是标签,x: number 对上 x: number, - 就是兼容。 + 里名字只是标签,x: number 对上 x: number,就是兼容。 ), }, @@ -691,8 +670,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> extends{" "} - 能让意图更好读,但它不是兼容的前提。一句声明都不写, - 形状相同照样能赋值。 + 能让意图更好读,但它不是兼容的前提。一句声明都不写,形状相同照样能赋值。 ), }, @@ -724,8 +702,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> 形状相同、名字不同的两个类型,可以互相赋值。interface 和{" "} - type 在这一点上表现一致: - 都不会仅仅因为名字不同就造出一个不兼容的新类型。 + type 在这一点上表现一致:都不会仅仅因为名字不同就造出一个不兼容的新类型。 ), }, @@ -745,8 +722,7 @@ export const QUIZ: QuizItem[] = [ <> {"type Staff = { name: string }"},变量{" "} barista 的类型是{" "} - {"{ name: string; makeTea: () => void }"}。 - 下面哪个赋值能通过? + {"{ name: string; makeTea: () => void }"}。下面哪个赋值能通过? ), }, @@ -797,8 +773,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 方向反了。Staff 身上没有 makeTea, - 缺了一个必需成员: + 方向反了。Staff 身上没有 makeTea,缺了一个必需成员: Property 'makeTea' is missing in type ... ts(2741) @@ -832,8 +807,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 第一个方向是通的。barista 有 name, - 类型也对,Staff 的要求全部满足。 + 第一个方向是通的。barista 有 name,类型也对,Staff 的要求全部满足。 ), }, @@ -849,8 +823,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 成员越多越具体,对应的值集合越小。小集合里的每个值同时也属于大集合, - 所以更具体的类型能用在要求较宽松的位置,反过来不行。 + 成员越多越具体,对应的值集合越小。小集合里的每个值同时也属于大集合,所以更具体的类型能用在要求较宽松的位置,反过来不行。 ), }, @@ -890,8 +863,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 多余属性检查只对「新鲜」的对象字面量生效。对象一旦存进变量, - 就只按普通的兼容规则判断,多出来的成员是允许的 + 多余属性检查只对「新鲜」的对象字面量生效。对象一旦存进变量,就只按普通的兼容规则判断,多出来的成员是允许的 ), }, @@ -940,9 +912,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 这是刻意设计,也是文档里写明的行为。写在调用处的字面量只用这一次, - 多出来的成员几乎都是拼错;变量可能在别处另有正当用途, - 所以按普通规则判断。 + 这是刻意设计,也是文档里写明的行为。写在调用处的字面量只用这一次,多出来的成员几乎都是拼错;变量可能在别处另有正当用途,所以按普通规则判断。 ), }, @@ -976,9 +946,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 多余属性检查是叠加在可赋值性之上的一道额外检查,目标是抓拼写错误。 - 只有当一个新鲜的对象字面量被直接赋值、或直接传给已知类型的位置时, - 它才会运行。它不属于可赋值性规则本身 —— + 多余属性检查是叠加在可赋值性之上的一道额外检查,目标是抓拼写错误。只有当一个新鲜的对象字面量被直接赋值、或直接传给已知类型的位置时,它才会运行。它不属于可赋值性规则本身 —— 这正是先存进变量它就消失的原因。 ), @@ -1053,8 +1021,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 选进了一个错误说法。多写的属性只在字面量「新鲜」时才被报出来, - 而类型是谁写的,编译器根本不看。 + 选进了一个错误说法。多写的属性只在字面量「新鲜」时才被报出来,而类型是谁写的,编译器根本不看。 ), }, @@ -1111,8 +1078,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 用品牌类型:{'string & { __brand: "user" }'}, - 让两个形状不同 + 用品牌类型:{'string & { __brand: "user" }'},让两个形状不同 ), }, @@ -1145,8 +1111,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 运行时检查只是兜底,而且要等错误的值已经传进来才会响。 - 这里的目标是让这次调用根本编译不过。 + 运行时检查只是兜底,而且要等错误的值已经传进来才会响。这里的目标是让这次调用根本编译不过。 ), }, @@ -1161,8 +1126,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - any 是往反方向走:它把检查整个关掉, - 连本来能拦下的错误也放过了。问题是检查太粗,不是检查太多。 + any 是往反方向走:它把检查整个关掉,连本来能拦下的错误也放过了。问题是检查太粗,不是检查太多。 ), }, @@ -1175,8 +1139,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 编译器不看名字。名字再长,形状还是 string, - 误传照样通过。 + 编译器不看名字。名字再长,形状还是 string,误传照样通过。 ), }, @@ -1194,10 +1157,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 既然编译器比的是形状,就把区别写进形状: - 各自和一个类型不同的标记成员求交集,两者就不再能互换。 - 标记只存在于编译期,没有运行时开销。代价是造一个值需要断言, - 通常把它包进唯一一个工厂函数里。 + 既然编译器比的是形状,就把区别写进形状:各自和一个类型不同的标记成员求交集,两者就不再能互换。标记只存在于编译期,没有运行时开销。代价是造一个值需要断言,通常把它包进唯一一个工厂函数里。 ), }, @@ -1245,9 +1205,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> structural typing,结构化类型。相对的一种做法是 Java、C# 用的 - nominal typing(名义类型): - 编译器比较声明的名字和声明的继承关系,所以成员完全一样的两个类 - 依然毫无关系。 + nominal typing(名义类型):编译器比较声明的名字和声明的继承关系,所以成员完全一样的两个类依然毫无关系。 ), }, @@ -1263,8 +1221,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 想给函数参数一个类型,表达「任意对象都行,但原始值不行」, - 应该写哪个? + 想给函数参数一个类型,表达「任意对象都行,但原始值不行」,应该写哪个? ), }, @@ -1347,8 +1304,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - any 把检查关掉了,什么都拦不住, - 而且从它读出来的值也不再受检查。 + any 把检查关掉了,什么都拦不住,而且从它读出来的值也不再受检查。 ), }, @@ -1361,8 +1317,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - unknown 的意思是「任何值都可能」,原始值也收。 - 它安全,但表达的不是「只要对象」。 + unknown 的意思是「任何值都可能」,原始值也收。它安全,但表达的不是「只要对象」。 ), }, @@ -1382,8 +1337,7 @@ export const QUIZ: QuizItem[] = [ <> object 的意思是「不是原始值」:对象、数组、函数进得来, number、string、boolean{" "} - 进不来。想要任意对象用 object; - 想要任何值、用之前先收窄,用 unknown;{"{}"}{" "} + 进不来。想要任意对象用 object;想要任何值、用之前先收窄,用 unknown;{"{}"}{" "} 放着别用。 ), diff --git a/lib/tsconfig-data.tsx b/lib/tsconfig-data.tsx index 34772cb..996b5f7 100644 --- a/lib/tsconfig-data.tsx +++ b/lib/tsconfig-data.tsx @@ -201,10 +201,7 @@ export const LABS: Lab[] = [ } zh={ <> - 新建一个空目录,跑 npx tsc --init。 - 打开生成的 tsconfig.json 读一遍, - 记下哪些选项已经是 true, - 以及模板给它们分的段落名。 + 新建一个空目录,跑 npx tsc --init。打开生成的 tsconfig.json 读一遍,记下哪些选项已经是 true,以及模板给它们分的段落名。 } /> @@ -220,8 +217,7 @@ export const LABS: Lab[] = [ } zh={ <> - 然后把 §02 调节台里那段演示代码存成 order.ts。 - 文件顶部补两行,免得报「找不到名字」: + 然后把 §02 调节台里那段演示代码存成 order.ts。文件顶部补两行,免得报「找不到名字」: } /> @@ -248,8 +244,7 @@ declare function submit(x: unknown): void;`} 跑 npx tsc --noEmit,数一数报错。然后把{" "} strict、noUncheckedIndexedAccess、 exactOptionalPropertyTypes 都改成{" "} - false 再跑一次。两个数字的差, - 就是这份配置替你做的事。 + false 再跑一次。两个数字的差,就是这份配置替你做的事。 } /> @@ -269,8 +264,7 @@ declare function submit(x: unknown): void;`} zh: ( <> --noEmit 的意思是只检查、不写文件。模板把{" "} - strict 放在「Recommended Options」, - 另外两个放在「Stricter Typechecking Options」—— + strict 放在「Recommended Options」,另外两个放在「Stricter Typechecking Options」—— 这个分段本身就回答了「noUncheckedIndexedAccess 属于 strict 吗」。 ), @@ -295,8 +289,7 @@ declare function submit(x: unknown): void;`} } zh={ <> - 再把 strict 单独开回来,另外两个仍然关着: - 报错是 4 个,不是 5 个。少掉的那个正是{" "} + 再把 strict 单独开回来,另外两个仍然关着:报错是 4 个,不是 5 个。少掉的那个正是{" "} sizes[3] 那一行。一次实验就够了 ——{" "} noUncheckedIndexedAccess 不属于{" "} strict。 @@ -333,9 +326,7 @@ declare function submit(x: unknown): void;`} } zh={ <> - 把下面这段存成 boss.js, - 用带 TypeScript 支持的编辑器打开。 - 里面有两个写错的属性名:pirce 和{" "} + 把下面这段存成 boss.js,用带 TypeScript 支持的编辑器打开。里面有两个写错的属性名:pirce 和{" "} nmae。在第一行加上{" "} {"// @ts-check"},看编辑器报出几个。 @@ -353,8 +344,7 @@ declare function submit(x: unknown): void;`} } zh={ <> - 答案是 0 个。先自己想清楚为什么, - 再想办法让编辑器把两个都报出来。 + 答案是 0 个。先自己想清楚为什么,再想办法让编辑器把两个都报出来。 } /> @@ -377,8 +367,7 @@ declare function submit(x: unknown): void;`} zh: ( <> cheapest 的参数没有类型,所以 list 是{" "} - any。在 any 上取任何属性都是合法的, - 所以此刻 it.pirce 还不算错。用 JSDoc 给参数补一个类型: + any。在 any 上取任何属性都是合法的,所以此刻 it.pirce 还不算错。用 JSDoc 给参数补一个类型: {"/** @param {{ name: string, price: number }[]} list */"} @@ -410,12 +399,8 @@ declare function submit(x: unknown): void;`} } zh={ <> - 一个文件,两个教训。@ts-check 只用一行, - 就为单个 .js 文件打开了检查。 - 但检查需要有类型可对照 —— 没有类型,就没有可矛盾的东西。 - checkJs: true 做的是同一件事, - 只不过一次覆盖所有 .js 文件, - 也有同样的局限。 + 一个文件,两个教训。@ts-check 只用一行,就为单个 .js 文件打开了检查。但检查需要有类型可对照 —— 没有类型,就没有可矛盾的东西。 + checkJs: true 做的是同一件事,只不过一次覆盖所有 .js 文件,也有同样的局限。 } /> @@ -470,11 +455,9 @@ declare function submit(x: unknown): void;`} } zh={ <> - 会出现两个错。读一读:两个都建议你改 lib, - 不是 target。现在把 lib 设成{" "} + 会出现两个错。读一读:两个都建议你改 lib,不是 target。现在把 lib 设成{" "} ES2022,target 仍然留在{" "} - ES5,报错就消失了。然后切到 .JS 标签, - 看看产出的代码有没有变。 + ES5,报错就消失了。然后切到 .JS 标签,看看产出的代码有没有变。 } /> @@ -527,10 +510,7 @@ declare function submit(x: unknown): void;`} <> 改 lib 前后,产出的 JavaScript 一字不差 —— 因为{" "} lib 不参与产物生成。这里的风险值得记住: - lib 是你对运行环境的声明, - 而编译器会信你。声明了 es2022, - 却把代码跑在没有 Promise 的引擎上, - 就是运行时失败,而编译期一声不响。lib{" "} + lib 是你对运行环境的声明,而编译器会信你。声明了 es2022,却把代码跑在没有 Promise 的引擎上,就是运行时失败,而编译期一声不响。lib{" "} 不会替你加 polyfill。 } @@ -566,10 +546,8 @@ declare function submit(x: unknown): void;`} } zh={ <> - 建一个小项目:三个互相 import 的 .js 文件。 - 故意埋两个写错的属性名,再埋一个「可能返回 null - 而调用处没判」的函数。然后照 §05 的路线走一遍, - 每一步都记下 npx tsc --noEmit 的输出。 + 建一个小项目:三个互相 import 的 .js 文件。故意埋两个写错的属性名,再埋一个「可能返回 null + 而调用处没判」的函数。然后照 §05 的路线走一遍,每一步都记下 npx tsc --noEmit 的输出。 } /> @@ -585,8 +563,7 @@ declare function submit(x: unknown): void;`} } zh={ <> - 重点不是最后那个数字。重点是每一步系统都在跑, - 而且每一步都比上一步严。 + 重点不是最后那个数字。重点是每一步系统都在跑,而且每一步都比上一步严。 } /> @@ -631,8 +608,7 @@ declare function submit(x: unknown): void;`} } zh={ <> - 真实项目做完这件事要几个月,但节奏是一样的。 - 开工前先知道一件事:类型报错并不会阻止 tsc{" "} + 真实项目做完这件事要几个月,但节奏是一样的。开工前先知道一件事:类型报错并不会阻止 tsc{" "} 把 JavaScript 写出来。除非你同时开{" "} noEmitOnError,否则报错期间构建照样出产物。 @@ -694,8 +670,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - noImplicitAny 在家族里。它的作用是:不许因为「推不出类型」 - 就悄悄按 any 处理。 + noImplicitAny 在家族里。它的作用是:不许因为「推不出类型」就悄悄按 any 处理。 ), }, @@ -752,8 +727,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - findOrder 声明的返回类型是 Order | null, - 你直接写 findOrder(id).total。关着{" "} + findOrder 声明的返回类型是 Order | null,你直接写 findOrder(id).total。关着{" "} strictNullChecks 时会发生什么? ), @@ -823,8 +797,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 编译器从不添加这类运行时行为。它只做两件事:擦掉类型、报告错误。 - 判空代码永远得你自己写。 + 编译器从不添加这类运行时行为。它只做两件事:擦掉类型、报告错误。判空代码永远得你自己写。 ), }, @@ -857,8 +830,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> 关掉 strictNullChecks,null 和{" "} - undefined 可以赋给任何类型。「这个值可能没有」 - 从类型里消失了,也就没什么可检查的。开着时,同一行会报{" "} + undefined 可以赋给任何类型。「这个值可能没有」从类型里消失了,也就没什么可检查的。开着时,同一行会报{" "} TS18047: 'order' is possibly 'null'.{" "} 如果这一族里只能开一个,就开它。 @@ -972,8 +944,7 @@ export const QUIZ: QuizItem[] = [ es2022 则原样保留、只擦掉类型。它还会顺带定一个默认{" "} lib,而那是另一件事:lib{" "} 决定有哪些类型声明可用。另外注意:如果你的 JavaScript - 是打包器、swc 或 esbuild 产出的, - 那么上线的语法代次由那个工具的 target 决定,tsconfig 里的{" "} + 是打包器、swc 或 esbuild 产出的,那么上线的语法代次由那个工具的 target 决定,tsconfig 里的{" "} target 说了不算。 ), @@ -1060,8 +1031,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 两者能压住的错误是同一批,都作用于下一行。 - 差别在于错误消失之后会发生什么。 + 两者能压住的错误是同一批,都作用于下一行。差别在于错误消失之后会发生什么。 ), }, @@ -1074,8 +1044,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - @ts-ignore 仍然是合法的 TypeScript。 - 问题在于它永远沉默 —— 债还清了也不吭声。 + @ts-ignore 仍然是合法的 TypeScript。问题在于它永远沉默 —— 债还清了也不吭声。 ), }, @@ -1089,8 +1058,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 两者都是编译期指令,和环境无关。编译器读到就生效, - 不区分开发和生产。 + 两者都是编译期指令,和环境无关。编译器读到就生效,不区分开发和生产。 ), }, @@ -1108,8 +1076,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - @ts-expect-error 的意思是「我预期这里有错」。 - 一旦下一行不再报错,这行注释自己就变成了错误: + @ts-expect-error 的意思是「我预期这里有错」。一旦下一行不再报错,这行注释自己就变成了错误: TS2578: Unused '@ts-expect-error' directive.{" "} 这就是「以后找得回来的债」和「找不回来的债」的区别。 @@ -1170,8 +1137,7 @@ export const QUIZ: QuizItem[] = [ 的类型变成 string | undefined,想用先收窄。它要靠{" "} strictNullChecks 才有效果 —— 后者关着时, string | undefined 会退回成 string。它不在{" "} - strict 里,因为它会对每一次下标读取发话, - 存量项目一开工作量很大。而 tsc --init{" "} + strict 里,因为它会对每一次下标读取发话,存量项目一开工作量很大。而 tsc --init{" "} 给新项目是默认开的。 ), @@ -1231,8 +1197,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> bundler 描述的是 Vite / esbuild - 这类打包器的解析方式。产物不经打包直接给 Node 跑, - 就该按 Node 自己的规矩来:nodenext。 + 这类打包器的解析方式。产物不经打包直接给 Node 跑,就该按 Node 自己的规矩来:nodenext。 ), }, @@ -1262,8 +1227,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - amd 是 RequireJS 时代的浏览器模块格式, - 和 Node 后端没有关系。 + amd 是 RequireJS 时代的浏览器模块格式,和 Node 后端没有关系。 ), }, @@ -1290,15 +1254,13 @@ export const QUIZ: QuizItem[] = [ <> 两条路,按「谁消费产物」来分。产物直接给 Node 跑: module: nodenext —— 它同时把{" "} - moduleResolution 钉在 nodenext, - 你写成别的,编译器会报{" "} + moduleResolution 钉在 nodenext,你写成别的,编译器会报{" "} TS5109: Option 'moduleResolution' must be set to 'NodeNext' 。产物交给打包器: - module: esnext + moduleResolution: bundler, - 把模块解析整个交给打包器。这两个选项别混:module{" "} + module: esnext + moduleResolution: bundler,把模块解析整个交给打包器。这两个选项别混:module{" "} 是编译器写出的模块格式,moduleResolution 是它怎么找到你 import 的东西。 @@ -1410,8 +1372,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 有一项选多了。要么是把「渐进开 strict」 - 这个存量项目的策略错套在了新项目上,要么是把一件 tsc{" "} + 有一项选多了。要么是把「渐进开 strict」这个存量项目的策略错套在了新项目上,要么是把一件 tsc{" "} 不做的事算在了它头上。 ), @@ -1437,8 +1398,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - A:一份配置两个读者,所以编辑器的红线和 CI 的失败是一致的。B: - 库的声明文件有错不归你修,而两个库的全局声明打架还会拖垮你的构建。D: + A:一份配置两个读者,所以编辑器的红线和 CI 的失败是一致的。B:库的声明文件有错不归你修,而两个库的全局声明打架还会拖垮你的构建。D: useUnknownInCatchVariables 是家族成员。C 说反了 —— 第一天开 strict 成本为零,成本随代码量增长。E 不对: tsc 是每个输入文件产出一个 .js,从不打包。 @@ -1463,8 +1423,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 你接手一套九百行的 JavaScript 点单系统,负责人要求「一天都不能停」。 - 第一步该做什么? + 你接手一套九百行的 JavaScript 点单系统,负责人要求「一天都不能停」。第一步该做什么? ), }, @@ -1520,8 +1479,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 把 .js 后缀全改成 .ts, - 内容不用动就已经是 TypeScript 了 + 把 .js 后缀全改成 .ts,内容不用动就已经是 TypeScript 了 ), }, @@ -1538,8 +1496,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 这样会一次冒出几百个错,又没法只上线一部分修复,最后只能回滚。 - 渐进的做法能让系统一直跑着。 + 这样会一次冒出几百个错,又没法只上线一部分修复,最后只能回滚。渐进的做法能让系统一直跑着。 ), }, @@ -1554,8 +1511,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - @ts-ignore 是把检查关掉,不是把问题修好。 - 整个文件都压住,等于装了 TypeScript 却什么都没得到。 + @ts-ignore 是把检查关掉,不是把问题修好。整个文件都压住,等于装了 TypeScript 却什么都没得到。 ), }, @@ -1572,8 +1528,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> 改后缀不改变代码本身。隐式 any 的参数、没判的{" "} - null 一个都没少,而且现在会一起报出来、 - 把所有人挡住。应该先用 allowJs{" "} + null 一个都没少,而且现在会一起报出来、把所有人挡住。应该先用 allowJs{" "} 把文件收进来,再一个个迁移。 ), @@ -1592,8 +1547,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> 路线是:allowJs 把 .js 文件收进编译, - checkJs 或 @ts-check 开始检查它们, - 然后一次改一个文件成 .ts,再把 strict{" "} + checkJs 或 @ts-check 开始检查它们,然后一次改一个文件成 .ts,再把 strict{" "} 的成员一项一项开。每一步系统都在跑,每一步都比上一步严。 ), diff --git a/lib/type-magic-data.tsx b/lib/type-magic-data.tsx index 89670d1..f1d69c5 100644 --- a/lib/type-magic-data.tsx +++ b/lib/type-magic-data.tsx @@ -134,8 +134,7 @@ export const LABS: Lab[] = [

在 TypeScript Playground 里写两个条件类型: Naked<T> = T extends string ? true : false 和{" "} - Wrapped<T> = [T] extends [string] ? true : false。 - 都传同一个实参 "a" | 1,再悬停看两个结果 —— + Wrapped<T> = [T] extends [string] ? true : false。都传同一个实参 "a" | 1,再悬停看两个结果 —— 说清楚为什么一个是 boolean,另一个是 false。

), @@ -151,9 +150,7 @@ export const LABS: Lab[] = [ ), zh: ( <> - 裸 T 会让条件类型对联合的每个成员各跑一次, - 结果再合并成联合;用 [ ] 包起来则是整体判断一次。 - 再想一想:true | false 会被显示成什么。 + 裸 T 会让条件类型对联合的每个成员各跑一次,结果再合并成联合;用 [ ] 包起来则是整体判断一次。再想一想:true | false 会被显示成什么。 ), }, @@ -191,11 +188,9 @@ export const LABS: Lab[] = [ hl={[4, 8]} />

- A 显示成 boolean 是最容易卡住的一步。 - 编译器没有偷懒:true | false 和 boolean{" "} + A 显示成 boolean 是最容易卡住的一步。编译器没有偷懒:true | false 和 boolean{" "} 本来就是同一个类型,boolean{" "} - 只是它的打印形式。所以条件类型返回了一个你没预料到的联合时, - 第一件要检查的事就是:它是不是发生了分发。 + 只是它的打印形式。所以条件类型返回了一个你没预料到的联合时,第一件要检查的事就是:它是不是发生了分发。

), @@ -287,10 +282,8 @@ export const LABS: Lab[] = [ hl={[2]} />

- 四个零件挤在一行里:映射类型的循环、给键改名的 as、 - 模板字面量类型,以及 Capitalize。Vue 的{" "} - onXxx 属性、测试库的 mockXxx, - 类型层面都是这个形状。 + 四个零件挤在一行里:映射类型的循环、给键改名的 as、模板字面量类型,以及 Capitalize。Vue 的{" "} + onXxx 属性、测试库的 mockXxx,类型层面都是这个形状。

), @@ -318,10 +311,7 @@ export const LABS: Lab[] = [ zh: (

本章的 Unbox 只剥一层:喂它{" "} - Promise<Promise<Promise<string>>>, - 剩下的两层还在。把它升级成 DeepUnbox<T>: - 不管几层都剥到底,最后拿到 string。提示: - 类型别名可以引用自己。 + Promise<Promise<Promise<string>>>,剩下的两层还在。把它升级成 DeepUnbox<T>:不管几层都剥到底,最后拿到 string。提示:类型别名可以引用自己。

), }, @@ -336,8 +326,7 @@ export const LABS: Lab[] = [ ), zh: ( <> - 剥掉一层之后,抓到的 U 可能还是 Promise。 - 那就先别急着返回:把 U 再交给 DeepUnbox{" "} + 剥掉一层之后,抓到的 U 可能还是 Promise。那就先别急着返回:把 U 再交给 DeepUnbox{" "} 自己处理 —— ? DeepUnbox<U> : T。 ), @@ -387,8 +376,7 @@ export const LABS: Lab[] = [ hl={[2]} />

- 第 2 行就是类型系统里的递归:真分支里写出了正在定义的这个别名, - 于是同一条规则反复应用,直到 T 不再是{" "} + 第 2 行就是类型系统里的递归:真分支里写出了正在定义的这个别名,于是同一条规则反复应用,直到 T 不再是{" "} Promise。你刚刚写出了内置 Awaited{" "} 的核心。标准库那一版还会处理任何带 then{" "} 方法的对象,不只是 Promise,但形状是一样的。 @@ -399,8 +387,7 @@ export const LABS: Lab[] = [ error TS2589: Type instantiation is excessively deep and possibly infinite. {" "} - 这个上限对真实数据够用;而且当递归调用占据了整个分支 - (就像这里),上限还会高得多 —— + 这个上限对真实数据够用;而且当递归调用占据了整个分支(就像这里),上限还会高得多 —— 这时编译器可以反复执行同一步,而不必层层嵌套。

@@ -436,7 +423,7 @@ export const LABS: Lab[] = [ MyOmit<T, K>,对 Order 验证它和内置{" "} Omit 行为一致。第二步:把 K 的约束改成{" "} K extends keyof T,做出严格版{" "} - StrictOmit —— 键拼错要当场报错,而不是被默默忽略。 第 06 + StrictOmit —— 键拼错要当场报错,而不是被默默忽略。第 06 章那个漏洞就补上了。

), @@ -500,16 +487,12 @@ export const LABS: Lab[] = [ hl={[2, 9]} />

- 第 2 行就是 lib.es5.d.ts 里的原文,一字不差。 - 标准库的约束是 keyof any,也就是{" "} - string | number | symbol,所以几乎任何键类型都收, - 拼错也能过。第 9 行只改了约束,默默失效就变成了编译错误。 - 这两章要给你的就是这个:内置类型不合用的时候,你已经能自己造一个。 + 第 2 行就是 lib.es5.d.ts 里的原文,一字不差。标准库的约束是 keyof any,也就是{" "} + string | number | symbol,所以几乎任何键类型都收,拼错也能过。第 9 行只改了约束,默默失效就变成了编译错误。这两章要给你的就是这个:内置类型不合用的时候,你已经能自己造一个。

Omit 宽松是故意的。约束松一点,它才能接受不在{" "} - T 上的键 —— 当 T 本身是联合、 - 或者还是泛型时,这一点有用。想抓拼写错误就用 StrictOmit + T 上的键 —— 当 T 本身是联合、或者还是泛型时,这一点有用。想抓拼写错误就用 StrictOmit ,其他地方继续用 Omit。

@@ -619,7 +602,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 它比 string 精确得多:就是那六个名字,一个不多。 所以{" "} + 它比 string 精确得多:就是那六个名字,一个不多。所以{" "} const k: keyof Order = "cup" 会报{" "} Type '"cup"' is not assignable to type @@ -708,8 +691,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - console.log 要的是实参,所以这里是表达式, - 会真的执行,打印出 "object"。 + console.log 要的是实参,所以这里是表达式,会真的执行,打印出 "object"。 ), }, @@ -724,8 +706,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> const t = 后面是表达式位置,属于值世界。 - t 里装的是字符串 "object", - 不是类型。 + t 里装的是字符串 "object",不是类型。 ), }, @@ -742,9 +723,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 看位置。type 声明的等号右边、注解的冒号后面, - 都是类型位置,那里的 typeof 属于 TypeScript, - 编译时被擦除。其余位置都是 JavaScript 的那个运算符,会真的执行。 + 看位置。type 声明的等号右边、注解的冒号后面,都是类型位置,那里的 typeof 属于 TypeScript,编译时被擦除。其余位置都是 JavaScript 的那个运算符,会真的执行。 ), }, @@ -850,8 +829,7 @@ export const QUIZ: QuizItem[] = [ <> as const 把数组变成由字面量类型组成的只读元组, T[number] 取出全部元素类型的联合: - "boba" | "coconut jelly"。 - 数据只写一份,类型跟着它走。 + "boba" | "coconut jelly"。数据只写一份,类型跟着它走。 ), }, @@ -956,8 +934,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 如果是整体判断,Exclude 就永远删不掉任何东西。 - 裸类型参数遇到联合时故意不整体判断, + 如果是整体判断,Exclude 就永远删不掉任何东西。裸类型参数遇到联合时故意不整体判断, Exclude 才成立。 ), @@ -988,8 +965,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - never 只替换匹配上的那一个成员。它是空联合, - 合并结果时安静消失,不影响其他成员。 + never 只替换匹配上的那一个成员。它是空联合,合并结果时安静消失,不影响其他成员。 ), }, @@ -1007,8 +983,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> 这就是分布式条件类型:拆开联合、逐个判断、合并结果。 - "b" 变成 never,什么也没留下, - 所以结果是 "a" | "c"。§04 + "b" 变成 never,什么也没留下,所以结果是 "a" | "c"。§04 的可视化演的就是这一段。 ), @@ -1108,10 +1083,8 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - [T] 让 T 不再是裸类型参数, - 于是不分发,联合被当成一个类型整体判断。 - ["a" | 1] 不能赋给 [string], - 所以答案是 "mixed"。注意两边都要包,才有效。 + [T] 让 T 不再是裸类型参数,于是不分发,联合被当成一个类型整体判断。 + ["a" | 1] 不能赋给 [string],所以答案是 "mixed"。注意两边都要包,才有效。 ), }, @@ -1145,8 +1118,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 在 extends 后面的模式里声明一个类型变量; - 模式匹配成功时,这个变量装着匹配到的类型,真分支里可以用 + 在 extends 后面的模式里声明一个类型变量;模式匹配成功时,这个变量装着匹配到的类型,真分支里可以用 ), }, @@ -1218,8 +1190,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> T extends Promise<infer U> ? U : T 读作:如果{" "} - T 长成 Promise<某个类型> 的样子, - 就把那个类型记作 U 并返回它。相当于对类型做解构。 + T 长成 Promise<某个类型> 的样子,就把那个类型记作 U 并返回它。相当于对类型做解构。 ), }, @@ -1253,8 +1224,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 修饰符能加也能减:+ 是加,可以省略不写; - 另一个符号是减。手写版 Required 用的就是它。 + 修饰符能加也能减:+ 是加,可以省略不写;另一个符号是减。手写版 Required 用的就是它。 ), }, @@ -1271,7 +1241,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> {"{ [K in keyof T]-?: T[K] }"} 把每个属性的{" "} - ? 去掉,这正是 Required 的定义。 在{" "} + ? 去掉,这正是 Required 的定义。在{" "} strictNullChecks 下,它同时把属性类型里的{" "} undefined 也去掉。-readonly 同理,用来去掉{" "} readonly。 @@ -1397,8 +1367,7 @@ export const QUIZ: QuizItem[] = [ <> 三个零件:映射类型对 K 逐键循环; 泛型约束保证{" "} K 里的每个名字都是 T 上真实的键( - Pick 严格的根源); - 索引访问把原来的属性类型抄过来。没有条件类型,也没有 infer + Pick 严格的根源);索引访问把原来的属性类型抄过来。没有条件类型,也没有 infer 。 ), diff --git a/lib/types-data.tsx b/lib/types-data.tsx index 84865de..ad838d2 100644 --- a/lib/types-data.tsx +++ b/lib/types-data.tsx @@ -41,9 +41,7 @@ export const LABS: Lab[] = [ comment next to it. Compare with the answer afterwards. } zh={ - <>打开 {PLAYGROUND},贴入下面的代码 —— 里面一个注解都没有。 - 然后把鼠标依次悬停在每一个变量名上, - 把 TS 推断出的类型写在旁边的注释里,写完再对答案。 + <>打开 {PLAYGROUND},贴入下面的代码 —— 里面一个注解都没有。然后把鼠标依次悬停在每一个变量名上,把 TS 推断出的类型写在旁边的注释里,写完再对答案。 } />

@@ -155,8 +153,7 @@ const nothing: null // null 的类型就是 null`, zh={ <> 在 Playground 里逐行输入下面四行,每写一行就 hover - 一次变量名,记下类型。最后回答:哪几行是字面量类型, - 哪几行被拓宽了?为什么? + 一次变量名,记下类型。最后回答:哪几行是字面量类型,哪几行被拓宽了?为什么? } /> @@ -272,8 +269,7 @@ const d = 42; // 42 -- 同 b`, price(数字)、sizes(字符串数组)、 soldOut(布尔);② 声明{" "} const menu: MenuItem[] 并填两三款奶茶;③ - 故意把其中一款的 price 拼成 prise, - 观察报错落在哪一行、说了什么。 + 故意把其中一款的 price 拼成 prise,观察报错落在哪一行、说了什么。 } /> @@ -352,9 +348,7 @@ const menu: MenuItem[] = [ <> 报错精确落在错字上,还给了改法。现在把{" "} : MenuItem[] 删掉再看:错误消失了 —— - 因为推断把 prise 也当成一个合法字段收下了。 - 它会在很久以后、别处读 price 时才重新出现。 - 这就是给共享数据立契约的价值。 + 因为推断把 prise 也当成一个合法字段收下了。它会在很久以后、别处读 price 时才重新出现。这就是给共享数据立契约的价值。 } /> @@ -702,8 +696,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 推断就是编译器替你把类型算出来。let 加初始值, - 类型当场就定了,你一个冒号都没写 —— + 推断就是编译器替你把类型算出来。let 加初始值,类型当场就定了,你一个冒号都没写 —— 所以「用 TS 就得到处写注解」是个误解。 } @@ -797,8 +790,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - const 意味着变量不会再变,所以类型可以就是那个值本身。 - 字面量类型是 03 章联合类型的地基: + const 意味着变量不会再变,所以类型可以就是那个值本身。字面量类型是 03 章联合类型的地基: "small" | "medium" | "large" {" "} @@ -891,8 +883,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 恰恰相反 —— 官方建议注解用小写原始类型名; - 大写的包装对象类型是历史遗留,几乎从来不是你想要的。 + 恰恰相反 —— 官方建议注解用小写原始类型名;大写的包装对象类型是历史遗留,几乎从来不是你想要的。 } />, @@ -918,8 +909,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 口诀:注解一律小写 —— string、number、boolean。 - 看到注解里出现 String / Number /{" "} + 口诀:注解一律小写 —— string、number、boolean。看到注解里出现 String / Number /{" "} Boolean,直接改。 } @@ -981,8 +971,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 想想「边界」两个字 —— 别人要依赖的地方, - 以及声明时右边没有值的地方。你还漏了一个。 + 想想「边界」两个字 —— 别人要依赖的地方,以及声明时右边没有值的地方。你还漏了一个。 } /> @@ -998,8 +987,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 你选的里面有一项,TS 看一眼初始值就知道了。 - 给它写注解不算错,但属于噪音 —— 就是 §08 的坑二。 + 你选的里面有一项,TS 看一眼初始值就知道了。给它写注解不算错,但属于噪音 —— 就是 §08 的坑二。 } /> @@ -1018,8 +1006,7 @@ export const QUIZ: QuizItem[] = [ zh={ <> 注解是承诺,推断是观察。承诺写在边界上:参数(开着{" "} - noImplicitAny 时是必写的)、返回值、共享契约、 - 先声明后赋值的变量。局部变量交给推断,真正的契约才显眼。 + noImplicitAny 时是必写的)、返回值、共享契约、先声明后赋值的变量。局部变量交给推断,真正的契约才显眼。 } /> @@ -1089,8 +1076,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 不止一行。从 any 值上取的属性、由它算出的结果、 - 把它传进去的回调参数,统统也变成 any。 + 不止一行。从 any 值上取的属性、由它算出的结果、把它传进去的回调参数,统统也变成 any。 } />, @@ -1140,8 +1126,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - any 的意思是「别检查这个」。当临时手段有用, - 长期留着就等于把 TS 关掉了。noImplicitAny{" "} + any 的意思是「别检查这个」。当临时手段有用,长期留着就等于把 TS 关掉了。noImplicitAny{" "} 这个开关,就是防止 any 从没注解的参数之类的地方悄悄溜进来。 } @@ -1182,8 +1167,7 @@ export const QUIZ: QuizItem[] = [ zh={ <> number[] 和 Array<number>{" "} - 是完全相同的类型。元素类型简单时,社区习惯用前者; - 后者的尖括号是泛型语法,05 章正式拆解。 + 是完全相同的类型。元素类型简单时,社区习惯用前者;后者的尖括号是泛型语法,05 章正式拆解。 } /> @@ -1232,8 +1216,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 空数组没有推断材料,类型先落成 any[]; - 一旦这个值离开填充它的作用域被使用,noImplicitAny{" "} + 空数组没有推断材料,类型先落成 any[];一旦这个值离开填充它的作用域被使用,noImplicitAny{" "} 就会报错。出生就注解最稳 } @@ -1316,8 +1299,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - new Array() 一样看不出元素类型。 - 问题不在写法,而在缺那张标签。 + new Array() 一样看不出元素类型。问题不在写法,而在缺那张标签。 } />, @@ -1333,8 +1315,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 空箱子出生就贴标签:const toppings: string[] = []。 - 之后 push 错任何东西都会当场报错。 + 空箱子出生就贴标签:const toppings: string[] = []。之后 push 错任何东西都会当场报错。 } /> @@ -1353,8 +1334,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - let size = "small" 推断成了 string, - 但你想让 size 只能取三种杯型。最对路的做法是? + let size = "small" 推断成了 string,但你想让 size 只能取三种杯型。最对路的做法是? } /> @@ -1442,9 +1422,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - const 的意思是「不能重新赋值」,不是「只能取这三种」。 - 它会把值固定成 "small", - 而订单里的杯型本来就要换。 + const 的意思是「不能重新赋值」,不是「只能取这三种」。它会把值固定成 "small",而订单里的杯型本来就要换。 } />, @@ -1459,8 +1437,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 做得到,而且这是 TS 最有用的能力之一: - 把合法值的名单写进类型,名单外的值编译期直接拒收。 + 做得到,而且这是 TS 最有用的能力之一:把合法值的名单写进类型,名单外的值编译期直接拒收。 } />, @@ -1477,9 +1454,7 @@ export const QUIZ: QuizItem[] = [ } zh={ <> - 字面量联合就是把「只能是这几个」写成类型。 - 奶茶店案例在 03 章正是这样升级的:杯型、糖度、订单状态, - 全靠这一招守住。 + 字面量联合就是把「只能是这几个」写成类型。奶茶店案例在 03 章正是这样升级的:杯型、糖度、订单状态,全靠这一招守住。 } /> diff --git a/lib/utility-data.tsx b/lib/utility-data.tsx index 05e6bbe..f3b2dbd 100644 --- a/lib/utility-data.tsx +++ b/lib/utility-data.tsx @@ -173,8 +173,7 @@ export const LABS: Lab[] = [ 造出三个变体:DraftOrder(全部属性可选)、 PublicOrder(没有 internalNote ,其余属性全部只读)、CheckoutPatch(只有{" "} - size 和 toppings,且都可选)。 - 再给每个类型各声明一个变量。给 PublicOrder 的变量写{" "} + size 和 toppings,且都可选)。再给每个类型各声明一个变量。给 PublicOrder 的变量写{" "} internalNote 属性,必须报错。

} @@ -193,8 +192,7 @@ export const LABS: Lab[] = [ zh={ <> 第二、第三个变体各要套两层。从里往外想:先决定「有哪些属性」( - Pick 或 Omit), - 再决定「这些属性怎么用」(Partial 或{" "} + Pick 或 Omit),再决定「这些属性怎么用」(Partial 或{" "} Readonly)。 } @@ -220,9 +218,7 @@ export const LABS: Lab[] = [ } zh={

- 高亮的三行就是全部答案,六个属性一个都没重抄。 - 以后 Order 增加属性,三个变体自动跟着变。 - 工具类型省下的不是打字,而是「维护多份副本」的成本。 + 高亮的三行就是全部答案,六个属性一个都没重抄。以后 Order 增加属性,三个变体自动跟着变。工具类型省下的不是打字,而是「维护多份副本」的成本。

} /> @@ -258,8 +254,7 @@ export const LABS: Lab[] = [ 还是 Playground 里那个 Order。故意把键名拼错:先写{" "} Omit<Order, "internalNotes">(多一个{" "} s),再写{" "} - Pick<Order, "internalNotes">。 - 看哪一个报错、哪一个一声不吭。然后悬停在 Omit{" "} + Pick<Order, "internalNotes">。看哪一个报错、哪一个一声不吭。然后悬停在 Omit{" "} 那个类型别名上,数一数它到底产出了几个属性。

} @@ -276,8 +271,7 @@ export const LABS: Lab[] = [ } zh={ <> - 重点在悬停:类型别名会展开成完整的属性列表, - 删掉了谁、没删掉谁,一眼就能看清。 + 重点在悬停:类型别名会展开成完整的属性列表,删掉了谁、没删掉谁,一眼就能看清。 } /> @@ -302,10 +296,7 @@ export const LABS: Lab[] = [ } zh={

- 记住这条规则:Pick 会检查键,Omit 不检查。 - 当你删掉一个属性是因为它绝对不能外泄时,一个不报错的拼写错误 - 就意味着这个属性还在。这种场合改用 Pick 白名单, - 或者学完第 07 章,自己写一个严格版的 Omit。 + 记住这条规则:Pick 会检查键,Omit 不检查。当你删掉一个属性是因为它绝对不能外泄时,一个不报错的拼写错误就意味着这个属性还在。这种场合改用 Pick 白名单,或者学完第 07 章,自己写一个严格版的 Omit。

} /> @@ -336,10 +327,8 @@ export const LABS: Lab[] = [ } zh={

- 用 Record<Size, number> 给三个杯型建一张库存表。 - 然后删掉 large 这一项,读一下报错。再声明一个类型为{" "} - {"{ [k: string]: number }"} 的版本,同样只写两项, - 想清楚它为什么能编译过。 + 用 Record<Size, number> 给三个杯型建一张库存表。然后删掉 large 这一项,读一下报错。再声明一个类型为{" "} + {"{ [k: string]: number }"} 的版本,同样只写两项,想清楚它为什么能编译过。

} /> @@ -389,11 +378,8 @@ export const LABS: Lab[] = [ zh={

一条实用规则:键是一份已知的、有限的名单时,用{" "} - Record 配字面量联合,编译器会帮你查有没有漏; - 键真的开放(用户输入、动态字典),再退回索引签名或{" "} - Record<string, V>。注意最后两行: - 两种写法在读取属性时都不会加上 | undefined。 - 那是另一个编译选项 noUncheckedIndexedAccess{" "} + Record 配字面量联合,编译器会帮你查有没有漏;键真的开放(用户输入、动态字典),再退回索引签名或{" "} + Record<string, V>。注意最后两行:两种写法在读取属性时都不会加上 | undefined。那是另一个编译选项 noUncheckedIndexedAccess{" "} 的事,第 10 章再讲。

} @@ -476,9 +462,7 @@ export const LABS: Lab[] = [ } zh={

- 第 7 行在真实项目里出现得很频繁。很多库只导出函数, - 不导出返回结果的类型,这一行就是取回你需要的类型的标准写法。 - 库以后改了返回类型,这一行也依然是对的。 + 第 7 行在真实项目里出现得很频繁。很多库只导出函数,不导出返回结果的类型,这一行就是取回你需要的类型的标准写法。库以后改了返回类型,这一行也依然是对的。

} /> @@ -601,8 +585,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 类型在编译后会被擦除(第 01 章讲过),所以类型不可能产生任何运行时行为。 - 默认值还是得你自己写。 + 类型在编译后会被擦除(第 01 章讲过),所以类型不可能产生任何运行时行为。默认值还是得你自己写。 ), }, @@ -654,8 +637,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 不报错。新类型有 Order 的全部六个属性, - 拼错等于什么都没删。 + 不报错。新类型有 Order 的全部六个属性,拼错等于什么都没删。 ), }, @@ -691,8 +673,7 @@ export const QUIZ: QuizItem[] = [ <> 那是 Pick 的行为:它的键参数受{" "} K extends keyof T 约束。Omit{" "} - 的键参数只受 keyof any 约束, - 任何 string、number、symbol 都收。 + 的键参数只受 keyof any 约束,任何 string、number、symbol 都收。 ), }, @@ -707,8 +688,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - Omit 返回的是对象类型,不是 never。 - 它删掉名单上的键,名单上一个都对不上,就一个都不删。 + Omit 返回的是对象类型,不是 never。它删掉名单上的键,名单上一个都对不上,就一个都不删。 ), }, @@ -722,8 +702,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - TypeScript 没有介于报错和通过之间的「警告」这一档。 - 这里就是干干净净地通过 —— 危险正在这里。 + TypeScript 没有介于报错和通过之间的「警告」这一档。这里就是干干净净地通过 —— 危险正在这里。 ), }, @@ -739,9 +718,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - Omit 的键参数只要求是一个合法的属性键, - 不要求真的存在于 T 上。拼错照样编译通过,属性照样留着。 - 删属性是为了防外泄时,优先用 Pick 白名单。 + Omit 的键参数只要求是一个合法的属性键,不要求真的存在于 T 上。拼错照样编译通过,属性照样留着。删属性是为了防外泄时,优先用 Pick 白名单。 ), }, @@ -757,8 +734,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 已有 const o: Readonly<Order> = …, - 下面哪一行会被编译器拒绝? + 已有 const o: Readonly<Order> = …,下面哪一行会被编译器拒绝? ), }, @@ -817,8 +793,7 @@ export const QUIZ: QuizItem[] = [ <> 这一行合法。Readonly 是浅的:它拦住「给{" "} o.toppings 赋值」,但拦不住修改{" "} - o.toppings 指向的那个数组。 - 想连 push 一起拦,属性类型得写成{" "} + o.toppings 指向的那个数组。想连 push 一起拦,属性类型得写成{" "} readonly string[]。 ), @@ -847,8 +822,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 只有赋值那一行被拒绝。读取不受限制,改动数组内容的方法也不受限制, - 因为 Readonly 是浅的。 + 只有赋值那一行被拒绝。读取不受限制,改动数组内容的方法也不受限制,因为 Readonly 是浅的。 ), }, @@ -865,8 +839,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> readonly 拒绝的是「给属性赋值」,o.size = …{" "} - 正是这种。push 没有给 o.toppings 赋值, - 它改的是这个属性已经指向的那个数组。 + 正是这种。push 没有给 o.toppings 赋值,它改的是这个属性已经指向的那个数组。 ), }, @@ -882,8 +855,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 下面哪些工具类型接受一个联合类型, - 并返回一个「留下或去掉部分成员」的联合类型?(多选) + 下面哪些工具类型接受一个联合类型,并返回一个「留下或去掉部分成员」的联合类型?(多选) ), }, @@ -922,8 +894,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> 你选中的有一个是作用在对象属性上的。Partial、 - Pick、Readonly 改的是对象类型的属性。 - 问自己一句:我手上这个类型,是一组属性,还是一份候选名单? + Pick、Readonly 改的是对象类型的属性。问自己一句:我手上这个类型,是一组属性,还是一份候选名单? ), }, @@ -941,8 +912,7 @@ export const QUIZ: QuizItem[] = [ <> 三个作用在联合成员上:Exclude 去掉匹配的成员, Extract 只留下匹配的成员,NonNullable{" "} - 清掉 null 和 undefined。 - 三者都是条件类型,会对每个成员分别求值。第 07 章会讲这是怎么做到的。 + 清掉 null 和 undefined。三者都是条件类型,会对每个成员分别求值。第 07 章会讲这是怎么做到的。 ), }, @@ -979,8 +949,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - Record<Size, number> 要求三个键都在,少一个就报错; - 索引签名没有「必须齐全」的键名单。 + Record<Size, number> 要求三个键都在,少一个就报错;索引签名没有「必须齐全」的键名单。 ), }, @@ -1039,8 +1008,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 两者的值类型都可以任意写,对象、数组、函数都行。区别在键: - 一边是有限的名单,一边是无限的集合。 + 两者的值类型都可以任意写,对象、数组、函数都行。区别在键:一边是有限的名单,一边是无限的集合。 ), }, @@ -1054,8 +1022,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 两者都是类型,编译时都会被擦除。运行时的数据结构就是那个对象字面量本身, - 跟你用哪种类型标注无关。 + 两者都是类型,编译时都会被擦除。运行时的数据结构就是那个对象字面量本身,跟你用哪种类型标注无关。 ), }, @@ -1073,8 +1040,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> 键类型是有限的字面量联合时,Record{" "} - 会检查每个键都在,这就是它比索引签名严格的地方。 - 两者在读取属性时都不会加上 | undefined,除非开启{" "} + 会检查每个键都在,这就是它比索引签名严格的地方。两者在读取属性时都不会加上 | undefined,除非开启{" "} noUncheckedIndexedAccess。 ), @@ -1171,8 +1137,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 它不做任何检查,只负责取类型。取到的类型合不合适, - 由外面 ReturnType 的约束来判断。 + 它不做任何检查,只负责取类型。取到的类型合不合适,由外面 ReturnType 的约束来判断。 ), }, @@ -1186,8 +1151,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 类型碰不到正在运行的程序,这里一行代码都不会执行。 - 类型是编译器算出来的,不是跑出来看到的。 + 类型碰不到正在运行的程序,这里一行代码都不会执行。类型是编译器算出来的,不是跑出来看到的。 ), }, @@ -1203,9 +1167,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - makeOrder 是值,而 ReturnType 需要类型。 - 类型位置上的 typeof 就是「从值取到它的类型」的桥。 - 它和运行时的同名运算符做的不是一件事,第 07 章会详细讲。 + makeOrder 是值,而 ReturnType 需要类型。类型位置上的 typeof 就是「从值取到它的类型」的桥。它和运行时的同名运算符做的不是一件事,第 07 章会详细讲。 ), }, @@ -1238,8 +1200,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - Awaited 会一直拆:一层不够就再拆一层, - 直到没有 Promise 为止。这和运行时 await{" "} + Awaited 会一直拆:一层不够就再拆一层,直到没有 Promise 为止。这和运行时 await{" "} 的行为一致。 ), @@ -1255,8 +1216,7 @@ export const QUIZ: QuizItem[] = [ zh: ( <> Awaited 会递归地拆壳,两层 Promise{" "} - 都被拆掉,剩下 number。 - 类型为什么可以递归,第 07 章会讲。 + 都被拆掉,剩下 number。类型为什么可以递归,第 07 章会讲。 ), }, @@ -1345,8 +1305,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 那是「深 Partial」的行为。内置的这个是浅的: - 它只给最外层对象的属性加 ?,不会进到属性类型内部。 + 那是「深 Partial」的行为。内置的这个是浅的:它只给最外层对象的属性加 ?,不会进到属性类型内部。 ), }, @@ -1360,8 +1319,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 方向反了。Partial 处理的是外层对象的键, - 这里只有 meta 一个。 + 方向反了。Partial 处理的是外层对象的键,这里只有 meta 一个。 ), }, @@ -1386,9 +1344,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - Partial 是浅的:它给最外层属性加 ?, - 对这些属性的类型不做任何处理。深版本得自己写, - 用到第 07 章的映射类型和条件类型。 + Partial 是浅的:它给最外层属性加 ?,对这些属性的类型不做任何处理。深版本得自己写,用到第 07 章的映射类型和条件类型。 ), }, @@ -1481,8 +1437,7 @@ export const QUIZ: QuizItem[] = [ <> 这是常见的猜法,但 Required 是用 -?{" "} 定义的,去掉 ? 的同时也从属性类型里去掉了{" "} - undefined,结果是 string。注意: - 如果属性本来写成 note: string | undefined(没有{" "} + undefined,结果是 string。注意:如果属性本来写成 note: string | undefined(没有{" "} ?),那个 undefined 会保留。 ), @@ -1496,8 +1451,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 什么都没丢。Required 只改可选标记, - 属性类型仍然是 string。 + 什么都没丢。Required 只改可选标记,属性类型仍然是 string。 ), }, @@ -1510,8 +1464,7 @@ export const QUIZ: QuizItem[] = [ ), zh: ( <> - 把可选属性变成必填正是 Required 的用途, - 不会有任何东西变成 never。 + 把可选属性变成必填正是 Required 的用途,不会有任何东西变成 never。 ), }, @@ -1530,8 +1483,7 @@ export const QUIZ: QuizItem[] = [ <> Required<A> 得到 note: string:去掉{" "} ? 的同时,属性类型里的 undefined{" "} - 也被去掉。这只适用于本来写了 ? 的属性; - 必填属性上显式写的 | undefined 不受影响。 + 也被去掉。这只适用于本来写了 ? 的属性;必填属性上显式写的 | undefined 不受影响。 ), }, diff --git a/test/zh-copy-spacing.test.ts b/test/zh-copy-spacing.test.ts new file mode 100644 index 0000000..f303ba8 --- /dev/null +++ b/test/zh-copy-spacing.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment node +// JSX trims each line of a text block and joins the lines with one space, so a +// line break inside Chinese copy renders as a stray space ("算。 建议"); a space +// written after full-width punctuation ("。 暴力:") shows up the same way. +// The site's convention keeps a space between Chinese and Latin letters or +// digits ("第 3 章"), after an expression such as O(1), around "——" and around +// math operators, and nowhere else. This scans every TSX file for the rest. + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const HAN = /[㐀-鿿豈-﫿]/u; +const FW = /[ -〿＀-￯]/u; // CJK and full-width punctuation +const ZH = /[㐀-鿿豈-﫿 -〿＀-￯]/u; +const PUNCT = /[、-〃〈-】〔-〟!-/:-?[-`{-・]/u; +const K = ts.SyntaxKind; + +/** An ellipsis that closes Chinese text, not one inside a spaced math sequence. */ +function closesChinese(raw: string, i: number) { + let j = i; + while (j > 0 && raw[j - 1] === "…") j--; + return j > 0 && ZH.test(raw[j - 1]); +} + +/** Whether the line break between raw[i] and the next text should not be a space. */ +function breakIsStray(raw: string, i: number, a: string, b: string) { + if (FW.test(a) && b !== "—") return true; + if (a === "…" && closesChinese(raw, i) && b !== "—") return true; + if (FW.test(b) && a !== "—") return true; + if (HAN.test(a) && (HAN.test(b) || b === "(" || b === "“")) return true; + if (/[,:;?!]/.test(a) && HAN.test(b)) return true; + if (a === ")" && HAN.test(b)) { + const open = raw.lastIndexOf("(", i); + return open > 0 && ZH.test(raw[open - 1]); + } + return false; +} + +/** What JSX renders for a text node. */ +function rendered(raw: string) { + if (!raw.includes("\n")) return raw; + return raw + .split("\n") + .map((l, i, all) => (i === 0 ? l.trimEnd() : i === all.length - 1 ? l.trimStart() : l.trim())) + .filter((l) => l.length) + .join(" "); +} + +function edge(node: ts.Node, sf: ts.SourceFile, last: boolean): string | null | undefined { + if (node.kind === K.JsxText) { + const t = rendered(node.getFullText(sf)); + return t.length ? t[last ? t.length - 1 : 0] : null; + } + if (ts.isJsxExpression(node)) { + const e = node.expression; + if (e && (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e))) + return e.text.length ? e.text[last ? e.text.length - 1 : 0] : null; + return undefined; + } + if (ts.isJsxElement(node) || ts.isJsxFragment(node)) { + const kids = node.children.filter((c) => !(c.kind === K.JsxText && rendered(c.getFullText(sf)) === "")); + return kids.length ? edge(last ? kids[kids.length - 1] : kids[0], sf, last) : null; + } + return undefined; +} + +/** The text a JSX node renders, ignoring expressions other than string literals. */ +function textOf(node: ts.Node, sf: ts.SourceFile): string { + if (node.kind === K.JsxText) return rendered(node.getFullText(sf)); + if (ts.isJsxExpression(node)) + return node.expression && ts.isStringLiteral(node.expression) ? node.expression.text : ""; + if (ts.isJsxElement(node) || ts.isJsxFragment(node)) + return node.children.map((c) => textOf(c, sf)).join(""); + return ""; +} + +// A tag that holds a spaced formula ("反转值 ≥ 剩余值", "分 / 治 / 合") keeps a space on each +// side, like the operators inside it +const FORMULA = / [=≠≥≤<>/→←⟺+−×÷] /; + +function strays(file: string): string[] { + const text = readFileSync(file, "utf8"); + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const found: string[] = []; + const at = (pos: number, what: string) => + found.push(`${file}:${sf.getLineAndCharacterOfPosition(pos).line + 1} ${what}`); + + const scanText = (start: number, raw: string) => { + for (const m of raw.matchAll(/(\S)([ \t]*\n[ \t\n]*)(\S)/g)) { + if (breakIsStray(raw, m.index!, m[1], m[3])) at(start + m.index!, `line break ${m[1]}⏎${m[3]}`); + } + for (const m of raw.matchAll(/(\S)( +)(\S)/g)) { + const a = m[1], b = m[3]; + const stray = (PUNCT.test(a) || (a === "…" && closesChinese(raw, m.index!))) && ZH.test(b) && b !== "—"; + if (stray) at(start + m.index!, `space ${a} ${b}`); + } + }; + + const visit = (node: ts.Node) => { + if (node.kind === K.JsxText) scanText(node.pos, node.getFullText(sf)); + else if (ts.isStringLiteral(node) && node.parent?.kind === K.JsxAttribute) + scanText(node.getStart(sf), node.getText(sf)); + if (ts.isJsxElement(node) || ts.isJsxFragment(node)) { + const kids = node.children.filter((c) => !(c.kind === K.JsxText && rendered(c.getFullText(sf)) === "")); + kids.forEach((c, i) => { + const prev = kids[i - 1]; + const next = kids[i + 1]; + // A space written on the same line between Chinese text and a tag whose text is + // Chinese ("叫 前缀函数", " 能解") is as stray as a line break there. A line + // break next to a tag renders as nothing, so only spaces count. + if (c.kind === K.JsxText) { + const raw = c.getFullText(sf); + const lead = /^( +)(\S)/.exec(raw); + const before = prev ? edge(prev, sf, true) : null; + if (lead && before && HAN.test(before) && HAN.test(lead[2]) && prev && ts.isJsxElement(prev) && !FORMULA.test(textOf(prev, sf))) + at(c.pos, `space after before ${lead[2]}`); + const trail = /(\S)( +)$/.exec(raw); + const after = next ? edge(next, sf, false) : null; + if (trail && after && HAN.test(after) && HAN.test(trail[1]) && next && ts.isJsxElement(next) && !FORMULA.test(textOf(next, sf))) + at(c.end - 1, `space before <${next.openingElement.tagName.getText(sf)}> after ${trail[1]}`); + } + if (!prev) return; + const a = edge(prev, sf, true); + if (!a || !PUNCT.test(a)) return; + if (ts.isJsxExpression(c) && c.expression && ts.isStringLiteral(c.expression) && /^ +$/.test(c.expression.text) && next) { + const b = edge(next, sf, false); + if (b && ZH.test(b)) at(c.getStart(sf), `{" "} after ${a}`); + } + if (ts.isJsxElement(c) && c.children[0]?.kind === K.JsxText) { + const m = /^( +)(\S)/.exec(c.children[0].getFullText(sf)); + if (m && ZH.test(m[2])) at(c.children[0].pos, `space opening <${c.openingElement.tagName.getText(sf)}> after ${a}`); + } + }); + } + ts.forEachChild(node, visit); + }; + visit(sf); + return found; +} + +function tsxFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((e) => + e.isDirectory() ? tsxFiles(join(dir, e.name)) : e.name.endsWith(".tsx") ? [join(dir, e.name)] : [], + ); +} + +describe("Chinese copy", () => { + it("has no line break or space that renders as a stray space", () => { + const files = [...tsxFiles("app"), ...tsxFiles("lib")]; + expect(files.length).toBeGreaterThan(30); + expect(files.flatMap(strays)).toEqual([]); + }); +}); From 06cb4c680650646326c36ca7dbfbbb43ba7a57f4 Mon Sep 17 00:00:00 2001 From: WEIREN FENG Date: Fri, 9 Oct 2026 22:58:29 -0700 Subject: [PATCH 2/2] Qualify chapter 09's module and declaration-lookup rules; update chapter 08's details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chapter 09 - Whether a file is a module is decided by the file only with the default moduleDetection: with "force", which TypeScript 5.9's tsc --init writes, every non-declaration file is a module. The callout (title and text) and the quiz now say so; declaration files still follow the top-level import/export rule, so the .d.ts discussion is unchanged. - The three sources of a library's types are a checklist, not the compiler's lookup order: a declare module you have already written takes precedence over the package's types and @types. The section summary, the flow's first and last frames and the key point say so, with the practical advice to delete a stopgap declaration once real types exist. Chapter 08 - Since TypeScript 5.1 a getter and a setter may have unrelated types when both are annotated; #field keeps a value out of JSON.stringify and logs, but DevTools still shows it. Both chapters - Formal Chinese register (老老实实, 撞上, 坑) and the course terms 注解 / 标记 instead of 标注; quiz titles 本章测验 / Chapter quiz; aria-pressed on the segmented buttons of the member, placement, erasure and output visuals. Co-Authored-By: Claude Opus 5.5 --- app/classes/page.tsx | 24 ++++++++++++++---------- app/classes/viz.tsx | 3 +++ app/modules/page.tsx | 35 ++++++++++++++++++++--------------- app/modules/viz.tsx | 24 ++++++++++++++---------- lib/classes-data.tsx | 4 ++-- lib/modules-data.tsx | 26 ++++++++++++++------------ 6 files changed, 67 insertions(+), 49 deletions(-) diff --git a/app/classes/page.tsx b/app/classes/page.tsx index d0692d5..ebeac08 100644 --- a/app/classes/page.tsx +++ b/app/classes/page.tsx @@ -730,7 +730,7 @@ export default function ClassesPage() { } zh={ <> - 方法没有新规则。方法就是写在类里的函数,参数和返回值的标注方式和第 02 章完全一样。真正的新内容从下一节开始:规定哪些代码可以读某个成员。 + 方法没有新规则。方法就是写在类里的函数,参数和返回值的注解方式和第 02 章完全一样。真正的新内容从下一节开始:规定哪些代码可以读某个成员。 } /> @@ -1123,9 +1123,10 @@ export default function ClassesPage() { A password or a token stored in a private field is not protected. One JSON.stringify(shop) prints it. So does the browser's DevTools, and so does any log line - that serializes the object. #field does keep the - value out of those places, but it is still plain text in memory - and it is still in your bundle if you hard-coded it. Real + that serializes the object. #field keeps the value out + of JSON.stringify and most serialized logs, but the + browser's DevTools still shows it, it is still plain text + in memory, and it is still in your bundle if you hard-coded it. Real secrets belong on a server, not in a field of either kind. } @@ -1135,7 +1136,7 @@ export default function ClassesPage() { 字段并不构成保护。一句 JSON.stringify(shop){" "} 就打印出来了,浏览器 DevTools 一样看得见,任何序列化对象的日志也一样。 - #field 确实能挡住这几处,但值仍然是内存里的明文,如果是硬编码的,它照样在你的打包产物里。真正的机密应该放在服务端,而不是任何一种字段里。 + #field 能让值不出现在 JSON.stringify 和多数序列化日志里,但浏览器 DevTools 照样看得见,值仍然是内存里的明文,如果是硬编码的,它照样在你的打包产物里。真正的机密应该放在服务端,而不是任何一种字段里。 } /> @@ -1179,7 +1180,7 @@ export default function ClassesPage() { lang="ts" title={{ en: "Written out · four lines of boilerplate", - zh: "老老实实写 · 四行样板", + zh: "完整地写出 · 四行样板", }} code={S4_VERBOSE} /> @@ -1303,7 +1304,10 @@ export default function ClassesPage() { set sugar(v: number | string) together with{" "} get sugar(): number lets callers write{" "} o.sugar = "30" while{" "} - o.sugar still reads as a number. + o.sugar still reads as a number. Since + TypeScript 5.1 even that restriction is gone when both sides + have explicit types, although a property whose read and write + types are unrelated usually confuses callers. } zh={ @@ -1313,7 +1317,7 @@ export default function ClassesPage() { set sugar(v: number | string) 配上{" "} get sugar(): number,调用方可以写{" "} o.sugar = "30",而读 o.sugar{" "} - 拿到的仍然是 number。 + 拿到的仍然是 number。TypeScript 5.1 起,只要两边都显式写了类型,连这条限制也取消了;不过读写类型毫不相关的属性通常会让调用方困惑。 } /> @@ -1450,7 +1454,7 @@ export default function ClassesPage() { } zh={ <> - ① implements 不会改变类的类型:它不添加成员,也不替你标注任何东西。如果写成{" "} + ① implements 不会改变类的类型:它不添加成员,也不替你写任何类型注解。如果写成{" "} pay(amount) {"{ … }"} 而不标类型, amount 不会变成 number,而是隐式 any,在 strict 下直接报错 ts(7006)。参数类型得自己写,编译器再拿你写的去和接口核对。 @@ -1618,7 +1622,7 @@ export default function ClassesPage() {
setMember(i)} > {gm.ico} {gm.mod} {gm.key} @@ -248,6 +249,7 @@ export function AccessGate() { key={i} type="button" className={`seg-btn${place === i ? " on" : ""}`} + aria-pressed={place === i} onClick={() => setPlace(i)} > {L(gp.label)} @@ -414,6 +416,7 @@ export function ErasureViz() { key={et.label} type="button" className={`seg-btn${tab === i ? " on" : ""}`} + aria-pressed={tab === i} onClick={() => setTab(i)} > {et.label} diff --git a/app/modules/page.tsx b/app/modules/page.tsx index c100685..4707828 100644 --- a/app/modules/page.tsx +++ b/app/modules/page.tsx @@ -752,8 +752,8 @@ export default function ModulesPage() { module option in{" "} tsconfig.json chooses the output format:{" "} esnext, commonjs, and so on. It does - not decide whether a given file is a module. Only the file - itself does that, by having a top-level import or export. + not decide whether a given file is a module. By default the + file itself does that, by having a top-level import or export. + A separate option, moduleDetection, can change the + rule: with "force", every non-declaration + file is treated as a module, and that is what{" "} + tsc --init writes in TypeScript 5.9. Declaration + files are not affected; for them a top-level import or export + still decides.

This matters most in .d.ts files, where it is easy @@ -777,10 +783,7 @@ export default function ModulesPage() { zh={ <>

- tsconfig.json 里的 module{" "} - 选的是输出格式:esnext、 - commonjs 之类。它不决定某个文件是不是模块 —— - 只有文件自己能决定,靠顶层的 import 或 export。 + tsconfig.json 里的 module 选的是输出格式:esnext、commonjs 之类。它不决定某个文件是不是模块 —— 默认由文件自己决定,靠顶层的 import 或 export。tsconfig 里另有一个 moduleDetection 选项可以改变规则:设为 "force" 时,所有非声明文件都按模块处理 —— TypeScript 5.9 的 tsc --init 生成的配置就是这样。.d.ts 文件不受它影响,仍然看顶层的 import 或 export。

这一点在 .d.ts 里最要紧,因为那种文件很容易两者都没有。同一段{" "} @@ -1452,8 +1455,8 @@ export default function ModulesPage() { zh: "一个库的类型从哪里来", }} desc={{ - en: "Three places, checked in order. The compiler stops at the first one that answers.", - zh: "三个地方,按顺序查。哪一个先有结果,编译器就停在那里。", + en: "Three sources. Check them in this order when you investigate, but a declare module you wrote yourself, once it exists, takes precedence over the other two.", + zh: "三个来源。排查时按这个顺序看;但要知道,你自己写的 declare module 一旦存在,优先级最高。", }} >

@@ -2318,7 +2321,7 @@ export default function ModulesPage() {
import type 和 export type{" "} 把一行标为「只有类型」,于是整行被删除,运行时不会加载任何模块。verbatimModuleSyntax{" "} - 把这个标注变成必需:导入报 ts(1484),转手导出报 ts(1205)。 + 把这个标记变成必需:导入报 ts(1484),转手导出报 ts(1205)。 ), }, @@ -2413,7 +2416,10 @@ export default function ModulesPage() { package's own types (or the{" "} types condition in exports), then{" "} @types from DefinitelyTyped, then a declaration you - write. All three empty means ts(7016). Keep the{" "} + write. All three empty means ts(7016). A{" "} + declare module you have already written takes + precedence over the other two, so remove a stopgap declaration + once real types exist. Keep the{" "} @types major and minor version aligned with the library. @@ -2424,8 +2430,7 @@ export default function ModulesPage() { types(或 exports 里的{" "} types 条件)→ DefinitelyTyped 的{" "} @types → 你自己写的声明。三个都空就是{" "} - ts(7016)。@types 的 major 和 minor - 版本要和库本体对齐。 + ts(7016)。你已经写好的 declare module 优先于前两者,有了真正的类型之后,要删掉当初的临时声明。@types 的 major 和 minor 版本要和库本体对齐。 ), }, diff --git a/app/modules/viz.tsx b/app/modules/viz.tsx index c8d20b7..e94f530 100644 --- a/app/modules/viz.tsx +++ b/app/modules/viz.tsx @@ -227,6 +227,7 @@ export function ErasedImports() {