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/globals.css b/app/globals.css index 91ff300..384cd26 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1455,7 +1455,7 @@ html[data-lang="en"] .codewin-note::before { transition: all 0.18s; } -.q-opt:hover:not(:disabled) { +.q-opt:hover:not(:disabled):not([aria-disabled="true"]) { border-color: var(--acc-border); background: var(--acc-soft); color: var(--text); @@ -1511,10 +1511,24 @@ html[data-lang="en"] .codewin-note::before { color: #fff; } -.q-opt:disabled { +/* 作答后的选项仍可获得焦点(aria-disabled),焦点不会丢失 */ +.q-opt:disabled, +.q-opt[aria-disabled="true"] { cursor: default; } +/* 判定区可以接收程序移入的焦点(多选题检查之后),本身不需要焦点环 */ +.q-live:focus { + outline: none; +} + +/* aria-disabled 的按钮外观与 disabled 相同,但保留键盘焦点 */ +.quiz .btn[aria-disabled="true"] { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + .q-input { width: 100%; max-width: 320px; @@ -1524,7 +1538,8 @@ html[data-lang="en"] .codewin-note::before { background: var(--panel-2); color: var(--text); font-family: var(--font-mono); - font-size: 14px; + /* 16px:iOS 在聚焦字号小于 16px 的输入框时会放大页面 */ + font-size: 16px; outline: none; transition: border-color 0.2s, box-shadow 0.2s; } 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/quiz.tsx b/lib/quiz.tsx index 6e97770..0794e4c 100644 --- a/lib/quiz.tsx +++ b/lib/quiz.tsx @@ -5,9 +5,14 @@ // 同时点亮正确项;计分按第一次点击。 // - multi:多选,勾选后「检查」;漏选/错选分别提示。 // - fill:填空,回车或按钮判定;可反复尝试,答对为止(计分按最终是否答对)。 -// 全部答完 → 结算面板,成绩写入进度系统(取历史最好成绩,决定章节「通关」状态)。 +// 全部答完 → 结算面板,成绩写入进度系统(取历史最好成绩,决定章节是否「完成」)。 +// +// 填空题比较时忽略大小写、空白与全角/半角差异(NFKC),并把各种引号统一成 ASCII 双引号: +// 'dark'、“dark” 与 "dark" 是同一个答案。输入法正在组字时按下的回车不算提交。 +// 可访问性:每道题有一个常驻的 live region,判定出现时会被播报;作答后不禁用获得焦点的控件 +// (改用 aria-disabled),键盘焦点留在原处;多选题的「检查」按钮消失后,焦点移到判定区。 -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useProgress } from "@/lib/progress"; import { T, useL, type Loc } from "@/lib/i18n"; import type { ChapterId } from "@/lib/curriculum"; @@ -35,7 +40,7 @@ export type QuizItem = type: "fill"; q: Loc; placeholder?: Loc; - /** 允许的答案(不区分大小写、去空格后比较) */ + /** 允许的答案(比较规则见 normAnswer) */ answers: string[]; hint: Loc; why: Loc; @@ -48,8 +53,22 @@ type ItemState = const KEYS = "ABCDEFGH"; -function norm(s: string) { - return s.trim().toLowerCase().replace(/\s+/g, ""); +// 弯引号、全角引号与单引号都折叠成 ASCII 双引号(NFKC 已把全角 "' 变成 " ') +const QUOTES = /[\u2018\u2019\u201A\u201B\u201C\u201D\u201E\u201F']/g; + +/** 比较填空答案用的规范形式:NFKC、小写、去掉空白、统一引号。 */ +export function normAnswer(s: string) { + return s + .normalize("NFKC") + .toLowerCase() + .replace(/\s+/g, "") + .replace(QUOTES, '"'); +} + +/** 输入的答案是否与某个允许的答案一致。 */ +export function answerMatches(typed: string, answers: string[]) { + const t = normAnswer(typed); + return t !== "" && answers.some((a) => normAnswer(a) === t); } export function Quiz({ ch, items }: { ch: ChapterId; items: QuizItem[] }) { @@ -61,6 +80,8 @@ export function Quiz({ ch, items }: { ch: ChapterId; items: QuizItem[] }) { const [multiPicks, setMultiPicks] = useState>({}); const [fillText, setFillText] = useState>({}); const [reported, setReported] = useState(false); + const baseId = useId(); + const quizRef = useRef(null); const answered = states.filter((s) => s.phase !== "idle").length; const firstRight = states.filter( @@ -90,10 +111,14 @@ export function Quiz({ ch, items }: { ch: ChapterId; items: QuizItem[] }) { setMultiPicks({}); setFillText({}); setReported(false); + // 「重做测验」按钮随结算面板一起消失;把焦点交给第一道题 + requestAnimationFrame(() => + quizRef.current?.querySelector(".q-opt, .q-input")?.focus(), + ); }; return ( -
+
{items.map((item, i) => { const st = states[i]; const dataState = @@ -101,9 +126,14 @@ export function Quiz({ ch, items }: { ch: ChapterId; items: QuizItem[] }) { return (
- QUESTION {String(i + 1).padStart(2, "0")} / {items.length} +
-

{L(item.q)}

+

+ {L(item.q)} +

{item.type === "choice" && ( setFillText((p) => ({ ...p, [i]: v }))} onSubmit={() => { if (st.phase === "right") return; - const val = norm(fillText[i] ?? ""); - if (!val) return; - const ok = item.answers.some((a) => norm(a) === val); + const typed = fillText[i] ?? ""; + if (!typed.trim()) return; + const ok = answerMatches(typed, item.answers); if (ok) setState(i, { phase: "right", @@ -177,54 +208,56 @@ export function Quiz({ ch, items }: { ch: ChapterId; items: QuizItem[] }) { ); })} - {allDone && ( -
- - {firstRight}/{items.length} - - - {firstRight === items.length ? ( - - All correct. Chapter cleared. The green dot in the - sidebar is now lit. - - } - zh={ - <> - 全对!本章正式通关 —— 侧栏的小绿灯已经为你点亮。 - - } - /> - ) : ( - - {firstRight} correct on the first try. Read the explanations - for the ones you missed, then redo the quiz until you - get them all. - - } - zh={ - <> - 第一次尝试答对 {firstRight} 题。回头看看错题的解释,然后 - 重做一遍拿全对,才算真正拿下这一章。 - - } - /> - )} - - -
- )} +
+ {allDone && ( +
+ + {firstRight}/{items.length} + + + {firstRight === items.length ? ( + + All correct. The chapter is complete, and its dot + in the sidebar is now green. + + } + zh={ + <> + 全部答对,本章已完成。侧栏中本章的标记已经变为绿色。 + + } + /> + ) : ( + + {firstRight} correct on the first try. Read the + explanations for the ones you missed, then{" "} + redo the quiz until you get them all. + + } + zh={ + <> + 第一次作答答对 {firstRight} 题。请阅读答错题目的解析,然后 + 重做测验,全部答对后本章才算完成。 + + } + /> + )} + + +
+ )} +
); } @@ -254,7 +287,7 @@ function ChoiceBody({ key={k} type="button" className={cls} - disabled={locked} + aria-disabled={locked || undefined} onClick={() => onPick(k)} > {KEYS[k]} @@ -263,23 +296,25 @@ function ChoiceBody({ ); })}
- {st.phase === "right" && ( -
✓ {L(item.why)}
- )} - {st.phase === "wrong" && st.picked !== null && ( -
- ✕ {L(item.wrong?.[st.picked] ?? item.why)} -

- - The correct answer is {KEYS[item.correct]}: } - zh={<>正确答案是 {KEYS[item.correct]}:} - /> - - {L(item.why)} -

-
- )} +
+ {st.phase === "right" && ( +
✓ {L(item.why)}
+ )} + {st.phase === "wrong" && st.picked !== null && ( +
+ ✕ {L(item.wrong?.[st.picked] ?? item.why)} +

+ + The correct answer is {KEYS[item.correct]}: } + zh={<>正确答案是 {KEYS[item.correct]}:} + /> + + {L(item.why)} +

+
+ )} +
); } @@ -298,6 +333,7 @@ function MultiBody({ onCheck: () => void; }) { const L = useL(); + const liveRef = useRef(null); const locked = st.phase !== "idle"; const missed = item.correct.some((c) => !picks.includes(c)); const extra = picks.some((p) => !item.correct.includes(p)); @@ -316,7 +352,8 @@ function MultiBody({ key={k} type="button" className={cls} - disabled={locked} + aria-disabled={locked || undefined} + aria-pressed={locked ? undefined : picks.includes(k)} onClick={() => onToggle(k)} > {picks.includes(k) ? "✓" : KEYS[k]} @@ -331,38 +368,47 @@ function MultiBody({ type="button" className="btn btn-sm" disabled={picks.length === 0} - onClick={onCheck} + onClick={() => { + onCheck(); + // 按钮检查后消失;把焦点移到判定区,键盘用户从判定继续 + requestAnimationFrame(() => liveRef.current?.focus()); + }} >
)} - {st.phase === "right" && ( -
✓ {L(item.why)}
- )} - {st.phase === "wrong" && ( -
- ✕ {L(extra ? item.extraHint : missed ? item.missHint : item.why)} -

- - - - {item.correct.map((c) => KEYS[c]).join(" + ")} — {L(item.why)} -

-
- )} +
+ {st.phase === "right" && ( +
✓ {L(item.why)}
+ )} + {st.phase === "wrong" && ( +
+ ✕ {L(extra ? item.extraHint : missed ? item.missHint : item.why)} +

+ + + + {item.correct.map((c) => KEYS[c]).join(" + ")} — {L(item.why)} +

+
+ )} +
); } function FillBody({ item, + labelId, st, text, setText, onSubmit, }: { item: Extract; + /** 题干元素的 id,作为输入框的可访问名称 */ + labelId: string; st: ItemState; text: string; setText: (v: string) => void; @@ -375,40 +421,45 @@ function FillBody({
setText(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") onSubmit(); + // 输入法组字时的回车是确认候选词,不是提交 + if (e.key === "Enter" && !e.nativeEvent.isComposing) onSubmit(); }} />
- {solved &&
✓ {L(item.why)}
} - {st.phase === "wrong" && ( -
- ✕ - {L(item.hint)} - {st.tries >= 3 && ( -

- - - - {item.answers[0]} -

- )} -
- )} +
+ {solved &&
✓ {L(item.why)}
} + {st.phase === "wrong" && ( +
+ ✕ + {L(item.hint)} + {st.tries >= 3 && ( +

+ + + + {item.answers[0]} +

+ )} +
+ )} +
); } 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/quiz.test.tsx b/test/quiz.test.tsx new file mode 100644 index 0000000..002aa54 --- /dev/null +++ b/test/quiz.test.tsx @@ -0,0 +1,92 @@ +// The quiz keeps keyboard focus where the learner was, announces each verdict, accepts the +// forms a Chinese input method or another quote style produces, and names its text fields. + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { LangProvider } from "@/lib/i18n"; +import { ProgressProvider } from "@/lib/progress"; +import { Quiz, answerMatches, normAnswer, type QuizItem } from "@/lib/quiz"; + +const ITEMS: QuizItem[] = [ + { type: "choice", q: "Pick B", opts: ["a", "b"], correct: 1, why: "Because B." }, + { type: "multi", q: "Pick A and B", opts: ["a", "b", "c"], correct: [0, 1], missHint: "missed", extraHint: "extra", why: "A and B." }, + { type: "fill", q: "Type the literal type", answers: ['"dark"', "dark"], hint: "a literal", why: "It is the literal." }, +]; + +function renderQuiz(items = ITEMS) { + return render( + + + + + , + ); +} + +const question = (n: number) => within(document.querySelectorAll(".q-item")[n]); + +beforeEach(() => { + localStorage.clear(); + delete document.documentElement.dataset.lang; + vi.stubGlobal("requestAnimationFrame", (fn: FrameRequestCallback) => { + fn(0); + return 0; + }); +}); + +describe("answer comparison", () => { + it("folds case, spaces, full-width forms and every kind of quote", () => { + expect(normAnswer(" “Dark” ")).toBe('"dark"'); + expect(answerMatches("'dark'", ['"dark"'])).toBe(true); + expect(answerMatches(""dark"", ['"dark"'])).toBe(true); + expect(answerMatches("'oolong' | 'mango'", ['"oolong" | "mango"'])).toBe(true); + expect(answerMatches("-?", ["-?"])).toBe(true); + expect(answerMatches(" ", [""])).toBe(false); + expect(answerMatches("light", ['"dark"'])).toBe(false); + }); +}); + +describe("keyboard and screen readers", () => { + it("keeps focus on the answered option and announces the verdict", () => { + renderQuiz(); + const b = question(0).getByRole("button", { name: /^B\s*b$/ }); + b.focus(); + act(() => b.click()); + expect(document.activeElement).toBe(b); + expect(b).toHaveAttribute("aria-disabled", "true"); + expect(b).not.toBeDisabled(); + const live = b.closest(".q-item")!.querySelector('[role="status"]')!; + expect(live.textContent).toContain("Because B."); + }); + + it("moves focus to the verdict once a multiple-choice question is checked", () => { + renderQuiz(); + act(() => question(1).getByRole("button", { name: /^A\s*a$/ }).click()); + act(() => question(1).getByRole("button", { name: /^B\s*b$/ }).click()); + act(() => question(1).getByRole("button", { name: /Check/ }).click()); + const live = document.querySelectorAll(".q-item")[1].querySelector('[role="status"]')!; + expect(document.activeElement).toBe(live); + expect(live.textContent).toContain("A and B."); + }); + + it("names the text field after the question and ignores Enter while composing", () => { + renderQuiz(); + const input = screen.getByRole("textbox", { name: "Type the literal type" }); + fireEvent.change(input, { target: { value: "'dark'" } }); + fireEvent.keyDown(input, { key: "Enter", isComposing: true }); + expect(input.closest(".q-item")).toHaveAttribute("data-state", ""); + fireEvent.keyDown(input, { key: "Enter" }); + expect(input.closest(".q-item")).toHaveAttribute("data-state", "right"); + // Solved: read-only, but it keeps focus and stays in the tab order + expect(input).toHaveAttribute("readonly"); + expect(input).not.toBeDisabled(); + }); + + it("shows the question counter in the reader's language", () => { + // What the pre-paint script leaves behind for a Chinese reader + localStorage.setItem("tser-lang", "zh"); + document.documentElement.dataset.lang = "zh"; + renderQuiz(); + expect(screen.getByText("第 1 题 / 共 3 题")).toBeInTheDocument(); + }); +}); 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([]); + }); +});