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..ebeac08 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 对它照样做完整的类型检查。当「你控制不了的代码绝对不能碰到这个字段」时用它 —— 主要是写库的场景。 } @@ -1149,9 +1123,10 @@ export default function ClassesPage() { A password or a token stored in a private field is not protected. One JSON.stringify(shop) prints it. So does the browser's DevTools, and so does any log line - that serializes the object. #field does keep the - value out of those places, but it is still plain text in memory - and it is still in your bundle if you hard-coded it. Real + that serializes the object. #field keeps the value out + of JSON.stringify and most serialized logs, but the + browser's DevTools still shows it, it is still plain text + in memory, and it is still in your bundle if you hard-coded it. Real secrets belong on a server, not in a field of either kind. } @@ -1161,9 +1136,7 @@ export default function ClassesPage() { 字段并不构成保护。一句 JSON.stringify(shop){" "} 就打印出来了,浏览器 DevTools 一样看得见,任何序列化对象的日志也一样。 - #field 确实能挡住这几处, - 但值仍然是内存里的明文,如果是硬编码的,它照样在你的打包产物里。 - 真正的机密应该放在服务端,而不是任何一种字段里。 + #field 能让值不出现在 JSON.stringify 和多数序列化日志里,但浏览器 DevTools 照样看得见,值仍然是内存里的明文,如果是硬编码的,它照样在你的打包产物里。真正的机密应该放在服务端,而不是任何一种字段里。 } /> @@ -1195,9 +1168,7 @@ export default function ClassesPage() { } zh={ <> - 参数属性(parameter properties)省下的样板最多: - 在构造器参数前面加一个访问修饰符,这一行就同时完成了 - 声明字段、接收实参、赋值三件事: + 参数属性(parameter properties)省下的样板最多:在构造器参数前面加一个访问修饰符,这一行就同时完成了声明字段、接收实参、赋值三件事: } /> @@ -1209,7 +1180,7 @@ export default function ClassesPage() { lang="ts" title={{ en: "Written out · four lines of boilerplate", - zh: "老老实实写 · 四行样板", + zh: "完整地写出 · 四行样板", }} code={S4_VERBOSE} /> @@ -1253,14 +1224,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 +1277,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 的事。 } /> @@ -1339,18 +1304,20 @@ export default function ClassesPage() { set sugar(v: number | string) together with{" "} get sugar(): number lets callers write{" "} o.sugar = "30" while{" "} - o.sugar still reads as a number. + o.sugar still reads as a number. Since + TypeScript 5.1 even that restriction is gone when both sides + have explicit types, although a property whose read and write + types are unrelated usually confuses callers. } zh={ <> 从 TypeScript 4.3 起,setter 接受的类型可以比 getter - 返回的更宽,只要 getter 的类型能赋给 setter 的类型。 - 当你想接受多种输入形式、却总是返回同一种时很好用: + 返回的更宽,只要 getter 的类型能赋给 setter 的类型。当你想接受多种输入形式、却总是返回同一种时很好用: set sugar(v: number | string) 配上{" "} get sugar(): number,调用方可以写{" "} o.sugar = "30",而读 o.sugar{" "} - 拿到的仍然是 number。 + 拿到的仍然是 number。TypeScript 5.1 起,只要两边都显式写了类型,连这条限制也取消了;不过读写类型毫不相关的属性通常会让调用方困惑。 } /> @@ -1384,9 +1351,7 @@ export default function ClassesPage() { } zh={ <> - abstract 类是一个没写完的类:一部分成员已经实现, - 标了 abstract 的那些只有签名。 - 它不能被实例化;子类必须把每一个抽象成员都实现掉,才能拿来用: + abstract 类是一个没写完的类:一部分成员已经实现,标了 abstract 的那些只有签名。它不能被实例化;子类必须把每一个抽象成员都实现掉,才能拿来用: } /> @@ -1418,8 +1383,7 @@ export default function ClassesPage() { <> implements 是一次检查,不是继承:interface 列出一个类必须有哪些成员,implements{" "} - 让编译器去核对这个类有没有。它不会给类添加任何东西。 - 收银台不关心背后接的是哪家支付服务,只要那个对象能{" "} + 让编译器去核对这个类有没有。它不会给类添加任何东西。收银台不关心背后接的是哪家支付服务,只要那个对象能{" "} pay、能 refund: } @@ -1451,8 +1415,7 @@ export default function ClassesPage() { zh={ <> interface 的价值体现在调用方:MilkTeaShop 接受任何 - 符合 PaymentProvider 的对象, - 所以换一家支付服务,店铺内部一行都不用改: + 符合 PaymentProvider 的对象,所以换一家支付服务,店铺内部一行都不用改: } /> @@ -1491,13 +1454,10 @@ export default function ClassesPage() { } zh={ <> - ① implements 不会改变类的类型: - 它不添加成员,也不替你标注任何东西。如果写成{" "} + ① implements 不会改变类的类型:它不添加成员,也不替你写任何类型注解。如果写成{" "} pay(amount) {"{ … }"} 而不标类型, - amount 不会变成 number, - 而是隐式 any,在 strict 下直接报错 - ts(7006)。参数类型得自己写, - 编译器再拿你写的去和接口核对。 + amount 不会变成 number,而是隐式 any,在 strict 下直接报错 + ts(7006)。参数类型得自己写,编译器再拿你写的去和接口核对。 } /> @@ -1518,10 +1478,7 @@ export default function ClassesPage() { zh={ <> ② abstract class 和 interface 的区别。interface - 只描述形状,编译期就被擦除 —— 运行时这个名字根本不存在。 - abstract 类是一个真实的类:它在运行时存在, - 可以带着已实现的方法和状态,子类会继承这些。 - 想让子类继承可用的代码,用 abstract 类;只想描述形状,用 interface。 + 只描述形状,编译期就被擦除 —— 运行时这个名字根本不存在。abstract 类是一个真实的类:它在运行时存在,可以带着已实现的方法和状态,子类会继承这些。想让子类继承可用的代码,用 abstract 类;只想描述形状,用 interface。 } /> @@ -1581,9 +1538,7 @@ export default function ClassesPage() { } zh={ <> - 第 04 章说过,TypeScript 比较类型看的是形状,不是名字。 - class 也不例外:两个毫无关系的类,只要成员一样就可以互换。 - 但有一个例外,只要类里出现 private 或{" "} + 第 04 章说过,TypeScript 比较类型看的是形状,不是名字。class 也不例外:两个毫无关系的类,只要成员一样就可以互换。但有一个例外,只要类里出现 private 或{" "} protected 成员,它就出现了。逐帧看: } @@ -1612,8 +1567,7 @@ export default function ClassesPage() { } zh={ <> - 把两行 private stock 都删掉,赋值就通过了; - 或者保留它,把这个声明挪到两个杯子共同 extends + 把两行 private stock 都删掉,赋值就通过了;或者保留它,把这个声明挪到两个杯子共同 extends 的基类里 —— 这样声明只有一处,两个类型重新兼容。 } @@ -1642,11 +1596,8 @@ export default function ClassesPage() { } zh={ <> - 类型按形状比较,但只要涉及 private 或 protected 成员, - 两个类型必须让这些成员来自同一处声明才兼容 - (从共同基类继承下来的算)。 - 这也正是一些库模拟名义类型的办法:给类塞一个私有成员, - 形状相同的路人就再也顶替不了它。 + 类型按形状比较,但只要涉及 private 或 protected 成员,两个类型必须让这些成员来自同一处声明才兼容 + (从共同基类继承下来的算)。这也正是一些库模拟名义类型的办法:给类塞一个私有成员,形状相同的路人就再也顶替不了它。 } /> @@ -1671,7 +1622,7 @@ export default function ClassesPage() {
- 声明了的字段必须真的被赋值:就地初始化、在构造器里赋值, - 或者用 !{" "} - 自己承担。strictPropertyInitialization 负责这项检查, - 而且它不追踪写在其他方法里的赋值。 + 声明了的字段必须真的被赋值:就地初始化、在构造器里赋值,或者用 !{" "} + 自己承担。strictPropertyInitialization 负责这项检查,而且它不追踪写在其他方法里的赋值。 ), }, @@ -1729,10 +1678,8 @@ export default function ClassesPage() { ), zh: ( <> - private 会被擦除:编译产物里它就是个普通属性, - 连在 TypeScript 里 s["field"]{" "} - 都能直接读到,不用断言。要一个运行时也拿不到的字段, - 用 JavaScript 的 #field。 + private 会被擦除:编译产物里它就是个普通属性,连在 TypeScript 里 s["field"]{" "} + 都能直接读到,不用断言。要一个运行时也拿不到的字段,用 JavaScript 的 #field。 ), }, @@ -1747,8 +1694,7 @@ export default function ClassesPage() { ), zh: ( <> - 参数属性一行顶四行,但它是本章唯一会生成代码的特性。 - 只做类型剥离的运行时会拒绝它 —— 比如直接跑{" "} + 参数属性一行顶四行,但它是本章唯一会生成代码的特性。只做类型剥离的运行时会拒绝它 —— 比如直接跑{" "} .ts 文件的 Node。 ), @@ -1767,8 +1713,7 @@ export default function ClassesPage() { <> abstract{" "} 标记的是不能实例化的类,以及子类必须实现的成员。 - implements 只是拿类去和接口核对: - 既不添加成员,也不推断参数类型。一个类可以同时接受多个接口的检查。 + implements 只是拿类去和接口核对:既不添加成员,也不推断参数类型。一个类可以同时接受多个接口的检查。 ), }, @@ -1784,8 +1729,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..4bbbb33 100644 --- a/app/classes/viz.tsx +++ b/app/classes/viz.tsx @@ -231,6 +231,7 @@ export function AccessGate() { key={gm.key} type="button" className={`seg-btn${member === i ? " on" : ""}`} + aria-pressed={member === i} onClick={() => setMember(i)} > {gm.ico} {gm.mod} {gm.key} @@ -248,6 +249,7 @@ export function AccessGate() { key={i} type="button" className={`seg-btn${place === i ? " on" : ""}`} + aria-pressed={place === i} onClick={() => setPlace(i)} > {L(gp.label)} @@ -302,8 +304,7 @@ export function AccessGate() { <> 九种组合都点一遍。public 三处全通,protected 两通一拦,private 一通两拦。两条报错值得读细:protected 那条末尾多了{" "} - and its subclasses,private 那条没有。 - 两种检查都只发生在编译期。 + and its subclasses,private 那条没有。两种检查都只发生在编译期。 } /> @@ -353,10 +354,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 +394,7 @@ const ERASE_TABS: EraseTab[] = [ #vaultCode 是 JavaScript 的语法,不是 TypeScript 的。 在较新的 target 下它原样输出,运行时类外确实拿不到。类外写{" "} shop.#vaultCode 是语法错误, - JSON.stringify(shop) 打印出来是 {"{}"}。 - target 较老时,编译器会改用 WeakMap 实现,行为一样。 + JSON.stringify(shop) 打印出来是 {"{}"}。target 较老时,编译器会改用 WeakMap 实现,行为一样。 } /> @@ -418,6 +416,7 @@ export function ErasureViz() { key={et.label} type="button" className={`seg-btn${tab === i ? " on" : ""}`} + aria-pressed={tab === i} onClick={() => setTab(i)} > {et.label} @@ -557,8 +556,7 @@ const CUP_FRAMES: CupFrame[] = [ zh={ <> 第 04 章的规则在 class 上照常生效:编译器比较的是形状,不是名字 - 。这两个类声明的成员完全一样,所以要 PaperCup 的地方, - 给一个 PlasticCup 也接受。 + 。这两个类声明的成员完全一样,所以要 PaperCup 的地方,给一个 PlasticCup 也接受。 } /> @@ -582,8 +580,7 @@ const CUP_FRAMES: CupFrame[] = [ } zh={ <> - 现在两个类各自声明了一个 private stock。名字一样、 - 类型一样,赋值依然失败: + 现在两个类各自声明了一个 private stock。名字一样、类型一样,赋值依然失败: Types have separate declarations of a private property 'stock'. @@ -615,9 +612,7 @@ const CUP_FRAMES: CupFrame[] = [ zh={ <> 为什么?对 private 和 protected 成员,TypeScript 比较的是 - 它们在哪里声明,而不是名字和类型。两处声明就是两个不同的成员。 - private 的用意是「这是某一个类的内部细节」,如果别的类能顶替, - 这个用意就落空了。这是 class 唯一一处按「出身」而不是按形状比较的地方。 + 它们在哪里声明,而不是名字和类型。两处声明就是两个不同的成员。private 的用意是「这是某一个类的内部细节」,如果别的类能顶替,这个用意就落空了。这是 class 唯一一处按「出身」而不是按形状比较的地方。 } /> @@ -644,9 +639,7 @@ const CUP_FRAMES: CupFrame[] = [ } zh={ <> - 解法顺着规则就出来了:让两个类 extends 同一个基类。 - 这时 private stock 只有一处声明,两边都继承自它, - 于是重新兼容。 + 解法顺着规则就出来了:让两个类 extends 同一个基类。这时 private stock 只有一处声明,两边都继承自它,于是重新兼容。 } /> diff --git a/app/functions/page.tsx b/app/functions/page.tsx index aa93f65..64a9327 100644 --- a/app/functions/page.tsx +++ b/app/functions/page.tsx @@ -837,9 +837,7 @@ export default function FunctionsPage() { ), zh: ( <> - 一行签名就记下了每个参数的形状和返回值的形状。 - 写的人不用另作解释,调的人不用猜, - 而编译器会盯着双方各自守约。 + 一行签名就记下了每个参数的形状和返回值的形状。写的人不用另作解释,调的人不用猜,而编译器会盯着双方各自守约。 ), }} @@ -885,8 +883,7 @@ export default function FunctionsPage() { } zh={ <> - 先看差距。同一个函数写两遍:左边你得读函数体才知道它想要什么, - 右边第一行就写明白了。 + 先看差距。同一个函数写两遍:左边你得读函数体才知道它想要什么,右边第一行就写明白了。 } /> @@ -931,8 +928,7 @@ export default function FunctionsPage() { } zh={ <> - 参数类型通常必须写:编译器没有别的材料可以读。 - 返回值类型通常不必写,因为编译器会从{" "} + 参数类型通常必须写:编译器没有别的材料可以读。返回值类型通常不必写,因为编译器会从{" "} return 语句里读出来。 } @@ -964,10 +960,7 @@ export default function FunctionsPage() { } zh={ <> - 两种情况。第一种是对外边界:会被其他模块、其他团队, - 或者发布出去的包调用的函数。 - 这时注解就是那份承诺,能挡住「函数体一改, - 所有人依赖的类型跟着悄悄变了」。 + 两种情况。第一种是对外边界:会被其他模块、其他团队,或者发布出去的包调用的函数。这时注解就是那份承诺,能挡住「函数体一改,所有人依赖的类型跟着悄悄变了」。 } /> @@ -987,9 +980,7 @@ export default function FunctionsPage() { zh={ <> 第二种是报错的位置会更好。写了注解,错误就报在 - 定义处,也就是你正在改的这一行。不写注解, - 错误的类型会流出去,报错出现在很远的某个调用处, - 甚至根本不报。最糟的情况是 any: + 定义处,也就是你正在改的这一行。不写注解,错误的类型会流出去,报错出现在很远的某个调用处,甚至根本不报。最糟的情况是 any: } /> @@ -1012,8 +1003,7 @@ export default function FunctionsPage() { } zh={ <> - 写上 : Order 相当于在出口装了一道闸。 - 函数体内部仍然可能推断出 any,但调用方拿到的是{" "} + 写上 : Order 相当于在出口装了一道闸。函数体内部仍然可能推断出 any,但调用方拿到的是{" "} Order,写错字段当场被抓。 } @@ -1061,9 +1051,7 @@ export default function FunctionsPage() { zh={ <> 两者都允许调用方不写这个实参。区别在于参数在函数体 - 内部长什么样。写 topping?: string, - 它的类型是 string | undefined,用之前得先检查。 - 写 sugar = 50,它的类型就是干净的{" "} + 内部长什么样。写 topping?: string,它的类型是 string | undefined,用之前得先检查。写 sugar = 50,它的类型就是干净的{" "} number:实参没给的时候默认值先跑一遍, undefined 根本进不到函数体里。 @@ -1084,11 +1072,9 @@ export default function FunctionsPage() { } zh={ <> - 显式传 undefined 同样会触发默认值, - 所以{" "} + 显式传 undefined 同样会触发默认值,所以{" "} pourSugar("Milk Green", undefined){" "} - 拿到的也是 50。当一个参数存在合理的默认值时, - 优先用默认值而不是 ? —— 函数体里少一道检查。 + 拿到的也是 50。当一个参数存在合理的默认值时,优先用默认值而不是 ? —— 函数体里少一道检查。 } /> @@ -1119,9 +1105,7 @@ export default function FunctionsPage() { } zh={ <> - 实参是按位置对号入座的。可选参数一旦插在中间, - 它后面的必选参数就再也认不出来了。所以规矩是: - 必选在前,可选和带默认值的在后。 + 实参是按位置对号入座的。可选参数一旦插在中间,它后面的必选参数就再也认不出来了。所以规矩是:必选在前,可选和带默认值的在后。 } /> @@ -1164,8 +1148,7 @@ export default function FunctionsPage() { zh: ( <> ? 管的是这个实参能不能整个不写; - | undefined 管的是这个实参能放哪些值。 - 后者仍然要求你传点什么进来。 + | undefined 管的是这个实参能放哪些值。后者仍然要求你传点什么进来。 ), }} @@ -1185,8 +1168,7 @@ export default function FunctionsPage() { zh={ <> rest 参数的类型是一个数组。它也可以是一个元组 —— - 固定住前面几个位置各自的类型,同时仍然接受后面数量不定的实参。 - 变长元组类型的实用价值就在这里。 + 固定住前面几个位置各自的类型,同时仍然接受后面数量不定的实参。变长元组类型的实用价值就在这里。 } /> @@ -1237,10 +1219,7 @@ export default function FunctionsPage() { } zh={ <> - 注意那个回调的返回值写的是 void。 - 在函数类型里,void 的意思不是「你不许返回任何东西」, - 而是「返回什么调用方都不会用」。 - 所以一个真的返回了东西的函数,照样可以放进来。 + 注意那个回调的返回值写的是 void。在函数类型里,void 的意思不是「你不许返回任何东西」,而是「返回什么调用方都不会用」。所以一个真的返回了东西的函数,照样可以放进来。 } /> @@ -1261,10 +1240,7 @@ export default function FunctionsPage() { ), zh: ( <> - 这是有意设计的。单行箭头函数会把表达式的值返回出去, - 不管你要不要。如果 (…) => void 拒绝这种函数, - 大部分 forEach 回调都得改写成花括号函数体, - 只为了把那个值丢掉。 + 这是有意设计的。单行箭头函数会把表达式的值返回出去,不管你要不要。如果 (…) => void 拒绝这种函数,大部分 forEach 回调都得改写成花括号函数体,只为了把那个值丢掉。 ), }} @@ -1327,9 +1303,7 @@ export default function FunctionsPage() { } zh={ <> - 这个方向很好接受:调用方要一个 Animal, - 拿到的东西是 Animal 而且还多点别的。 - 参数方向则正好反过来 —— 这里最容易让人意外。 + 这个方向很好接受:调用方要一个 Animal,拿到的东西是 Animal 而且还多点别的。参数方向则正好反过来 —— 这里最容易让人意外。 } /> @@ -1356,10 +1330,7 @@ export default function FunctionsPage() { } zh={ <> - 从调用方的角度读一遍。持有 FeedAnimal 的那段代码, - 可以拿任意一只动物来调用它,比如一只猫。 - 而一个要读 d.breed 的函数这时就会出错, - 所以编译器把它拦下。参数按这种反方向检查,叫做 + 从调用方的角度读一遍。持有 FeedAnimal 的那段代码,可以拿任意一只动物来调用它,比如一只猫。而一个要读 d.breed 的函数这时就会出错,所以编译器把它拦下。参数按这种反方向检查,叫做 逆变(contravariance),由{" "} strictFunctionTypes 开关控制,而{" "} strict 会打开它。 @@ -1393,8 +1364,7 @@ export default function FunctionsPage() { 类型所在的位置。用方法语法声明的成员 —— 也就是写成 feed(a: Animal): void 而不是{" "} feed: (a: Animal) => void —— - 沿用的还是旧规则:参数往哪个方向都放得进去。 - 这叫双变(bivariance)。 + 沿用的还是旧规则:参数往哪个方向都放得进去。这叫双变(bivariance)。 } /> @@ -1422,14 +1392,9 @@ export default function FunctionsPage() { } zh={ <> - 这是一处已知的、刻意保留的不健全, - 而不是「恰好安全」的规则。上面最后两行能编译通过, - 然后把一个普通 Animal 塞进了一个实际上是{" "} - Dog[] 的数组里。TypeScript 之所以接受, - 是因为拒绝它就等于让 Array<Dog>{" "} - 不能当作 Array<Animal> 使用, - 同时废掉大量现存的 JavaScript 写法。 - 官方在这里选择了可用性而不是健全性,并且把这个取舍写进了文档。 + 这是一处已知的、刻意保留的不健全,而不是「恰好安全」的规则。上面最后两行能编译通过,然后把一个普通 Animal 塞进了一个实际上是{" "} + Dog[] 的数组里。TypeScript 之所以接受,是因为拒绝它就等于让 Array<Dog>{" "} + 不能当作 Array<Animal> 使用,同时废掉大量现存的 JavaScript 写法。官方在这里选择了可用性而不是健全性,并且把这个取舍写进了文档。 } /> @@ -1446,9 +1411,7 @@ export default function FunctionsPage() { } zh={ <> - 实际怎么用:想让 interface 的某个成员走严格检查, - 就用属性语法声明它。反过来,当某个你以为会报错的赋值被放行时, - 先看一眼是不是方法语法。 + 实际怎么用:想让 interface 的某个成员走严格检查,就用属性语法声明它。反过来,当某个你以为会报错的赋值被放行时,先看一眼是不是方法语法。 } /> @@ -1500,10 +1463,7 @@ export default function FunctionsPage() { } zh={ <> - map 调用回调时会传三个实参, - 而你几乎总是只写一个参数的回调。在 JavaScript 里, - 多余的实参会被直接忽略,所以「声明的参数更少」的回调 - 永远可以安全调用。类型系统允许的,正是语言本来就允许的。 + map 调用回调时会传三个实参,而你几乎总是只写一个参数的回调。在 JavaScript 里,多余的实参会被直接忽略,所以「声明的参数更少」的回调永远可以安全调用。类型系统允许的,正是语言本来就允许的。 } /> @@ -1519,8 +1479,7 @@ export default function FunctionsPage() { } zh={ <> - 反过来就不安全了。声明了第四个参数的回调, - 会去读一个根本没人传的实参,所以编译器把它拦下。 + 反过来就不安全了。声明了第四个参数的回调,会去读一个根本没人传的实参,所以编译器把它拦下。 } /> @@ -1554,8 +1513,7 @@ export default function FunctionsPage() { } zh={ <> - 有的函数会根据传进来的东西表现不同:传字符串还回来一种结果, - 传数组还回来另一种。这可以用几条重载签名加一条 + 有的函数会根据传进来的东西表现不同:传字符串还回来一种结果,传数组还回来另一种。这可以用几条重载签名加一条 实现签名来描述。 } @@ -1588,11 +1546,8 @@ export default function FunctionsPage() { } zh={ <> - 第一条:实现签名不能从外部调用。 - 它只需要和上面每一条重载都兼容。例子里 price{" "} - 的实现接受 string | string[], - 但拿一个 string | string[] 的值去调用它却会报错, - 因为两条重载谁都不接受这个类型。 + 第一条:实现签名不能从外部调用。它只需要和上面每一条重载都兼容。例子里 price{" "} + 的实现接受 string | string[],但拿一个 string | string[] 的值去调用它却会报错,因为两条重载谁都不接受这个类型。 } /> @@ -1609,9 +1564,7 @@ export default function FunctionsPage() { } zh={ <> - 第二条:TypeScript 按你书写的顺序,取第一条匹配上的重载, - 而不是去找「最合适的那条」。所以重载的顺序是 API 的一部分: - 越具体的签名越要写在前面。 + 第二条:TypeScript 按你书写的顺序,取第一条匹配上的重载,而不是去找「最合适的那条」。所以重载的顺序是 API 的一部分:越具体的签名越要写在前面。 } /> @@ -1635,8 +1588,7 @@ export default function FunctionsPage() { } zh={ <> - 另一件事是一个名字叫 this 的参数。 - 它不是真的参数,而是告诉编译器:这个函数运行时, + 另一件事是一个名字叫 this 的参数。它不是真的参数,而是告诉编译器:这个函数运行时, this 必须是什么。 } @@ -1658,9 +1610,7 @@ export default function FunctionsPage() { ), zh: ( <> - this 参数和其他类型一起被擦除, - 编译出来的 JavaScript 里是一个单参数函数。 - 它在运行时不改变任何东西,只是让编译器能拦下那些{" "} + this 参数和其他类型一起被擦除,编译出来的 JavaScript 里是一个单参数函数。它在运行时不改变任何东西,只是让编译器能拦下那些{" "} this 会出错的调用。 ), @@ -1688,10 +1638,8 @@ export default function FunctionsPage() { zh={ <> 返回值类型还可以写成 x is Dog 或{" "} - asserts x is Dog。 - 这是类型谓词和断言签名 —— - 用签名告诉编译器「这次检查证明了什么」。 - 它们属于收窄的范畴,03 章会讲。 + asserts x is Dog。这是类型谓词和断言签名 —— + 用签名告诉编译器「这次检查证明了什么」。它们属于收窄的范畴,03 章会讲。 } /> @@ -1735,11 +1683,7 @@ export default function FunctionsPage() { } zh={ <> - readonly 只在编译期拦你。 - 它在产出的 JavaScript 里不留任何痕迹, - 所以运行时这个属性照样能写。想要真正的运行时冻结, - 得用 Object.freeze。 - 但编译期这道检查依然值得要:误改几乎都被它挡住了。 + readonly 只在编译期拦你。它在产出的 JavaScript 里不留任何痕迹,所以运行时这个属性照样能写。想要真正的运行时冻结,得用 Object.freeze。但编译期这道检查依然值得要:误改几乎都被它挡住了。 } /> @@ -1763,11 +1707,8 @@ export default function FunctionsPage() { zh={ <> 这个说法不用信,可以当场验。下面这个窗口里,给 id{" "} - 赋值那一行被画了波浪线;切到编译产物 JS 页签, - 再找 readonly 这个词 —— 产物里根本没有它。 - 检查发生过,然后被擦掉了。第二条红线来自另一个修饰: - desc 可能不存在,用之前必须先检查。 - 把 readonly 这个词删掉,它那条报错就跟着没了。 + 赋值那一行被画了波浪线;切到编译产物 JS 页签,再找 readonly 这个词 —— 产物里根本没有它。检查发生过,然后被擦掉了。第二条红线来自另一个修饰: + desc 可能不存在,用之前必须先检查。把 readonly 这个词删掉,它那条报错就跟着没了。 } /> @@ -1793,9 +1734,7 @@ export default function FunctionsPage() { } zh={ <> - 有时候键是事先列不出来的:库存表明天可能上任何一个新货号。 - 这时用索引签名(index signature), - 只约定键的类型和值的类型。 + 有时候键是事先列不出来的:库存表明天可能上任何一个新货号。这时用索引签名(index signature),只约定键的类型和值的类型。 } /> @@ -1827,10 +1766,8 @@ export default function FunctionsPage() { } zh={ <> - 字段名写得全,就老老实实一个个写出来,编译器会帮你查拼写。 - 只有当键是运行时才定的 —— - 货号、用户输入、由数据拼出来的字典 —— 才用索引签名。 - 它更宽松,代价正是你刚刚放弃的那道拼写检查。 + 字段名写得全,就老老实实一个个写出来,编译器会帮你查拼写。只有当键是运行时才定的 —— + 货号、用户输入、由数据拼出来的字典 —— 才用索引签名。它更宽松,代价正是你刚刚放弃的那道拼写检查。 } /> @@ -1879,8 +1816,7 @@ export default function FunctionsPage() { } zh={ <> - 右边用的 & 是交叉类型。 - 值得单独看一眼,因为下一章会拿它和联合类型作对比。 + 右边用的 & 是交叉类型。值得单独看一眼,因为下一章会拿它和联合类型作对比。 } /> @@ -1910,9 +1846,7 @@ export default function FunctionsPage() { ), zh: ( <> - 两个冲突的属性类型交叉之后会被归约成 never, - 声明处并不报错,问题要到后面真正使用这个属性的地方才暴露。 - 这就是「扩展对象类型时优先用 extends」的实际理由。 + 两个冲突的属性类型交叉之后会被归约成 never,声明处并不报错,问题要到后面真正使用这个属性的地方才暴露。这就是「扩展对象类型时优先用 extends」的实际理由。 ), }} @@ -2067,10 +2001,7 @@ export default function FunctionsPage() { } zh={ <> - TypeScript handbook 现行的建议很朴素: - 两个随便挑一个,在同一个代码库里保持一致。 - 需要联合类型或映射类型时,只有 type 写得出来; - 写库、并且希望使用者能扩展你的声明时,用{" "} + TypeScript handbook 现行的建议很朴素:两个随便挑一个,在同一个代码库里保持一致。需要联合类型或映射类型时,只有 type 写得出来;写库、并且希望使用者能扩展你的声明时,用{" "} interface。至于「interface{" "} 编译一定更快」这种说法,不足以作为选型依据。 @@ -2112,8 +2043,7 @@ export default function FunctionsPage() { zh: ( <> 高亮的这段签名就是本章开头那一份。注意 toppings{" "} - 这次用的是默认值而不是 ?: - 函数体里它的类型就是 Topping[],不含{" "} + 这次用的是默认值而不是 ?:函数体里它的类型就是 Topping[],不含{" "} undefined,所以 toppings.length{" "} 不需要任何检查。 @@ -2132,8 +2062,7 @@ export default function FunctionsPage() { } zh={ <> - 签名定下来了,现在换你当编译器。下面六种调用, - 先猜「能不能通过」,再看编译器的原话。 + 签名定下来了,现在换你当编译器。下面六种调用,先猜「能不能通过」,再看编译器的原话。 } /> @@ -2155,11 +2084,8 @@ export default function FunctionsPage() { } zh={ <> - 猜是一半,另一半是自己动手违约。 - 下面这个窗口里是同一份签名,最后一行那次调用守约。 - 把杯型改成名单外的字符串、删掉一个实参、再多塞一个 —— - 你拿到的每一句都是当场生成的,出自编译器本人。 - 标题栏那三份稿子,是最常见的三种错法。 + 猜是一半,另一半是自己动手违约。下面这个窗口里是同一份签名,最后一行那次调用守约。把杯型改成名单外的字符串、删掉一个实参、再多塞一个 —— + 你拿到的每一句都是当场生成的,出自编译器本人。标题栏那三份稿子,是最常见的三种错法。 } /> @@ -2205,9 +2131,7 @@ export default function FunctionsPage() { 两份把参数个数搞错的稿子,报的都是 TS2554: Expected 2-3 arguments, but got 1. 和{" "} Expected 2-3 arguments, but got 4. - 那个 2-3 的范围,就是这份签名念出来的样子: - 两个必填参数,加一个可以不写的。这个范围没有人写在任何地方, - 是编译器从签名上数出来的 —— 你改一下签名,它当场重数一遍。 + 那个 2-3 的范围,就是这份签名念出来的样子:两个必填参数,加一个可以不写的。这个范围没有人写在任何地方,是编译器从签名上数出来的 —— 你改一下签名,它当场重数一遍。 } /> @@ -2244,10 +2168,7 @@ export default function FunctionsPage() { makeOrder(item: MenuItem, size: Size, toppings?: Topping[]): Order - 。第三个参数声明时写的是默认值, - 显示出来却带着一个问号。 - 因为站在调用方这一侧,两者确实是同一件事:这个实参可以不写。 - 区别在函数体内部,§02 讲过 —— 有默认值,就没有{" "} + 。第三个参数声明时写的是默认值,显示出来却带着一个问号。因为站在调用方这一侧,两者确实是同一件事:这个实参可以不写。区别在函数体内部,§02 讲过 —— 有默认值,就没有{" "} undefined 需要检查了。 } @@ -2275,8 +2196,7 @@ export default function FunctionsPage() { zh={ <> 03 章里,这个 Order 会长出一个状态字段: - pending、paid、delivered。 - 写成可辨识联合之后,编译器就能在每个分支里自己算出有哪些字段。 + pending、paid、delivered。写成可辨识联合之后,编译器就能在每个分支里自己算出有哪些字段。 } /> @@ -2324,9 +2244,7 @@ export default function FunctionsPage() { ), zh: ( <> - 参数类型通常必须写,返回值类型通常靠推断。 - 对外边界上要写,想让错误报在定义处而不是很远的调用处时也要写。 - 它同时能挡住 any 从函数里漏出去。 + 参数类型通常必须写,返回值类型通常靠推断。对外边界上要写,想让错误报在定义处而不是很远的调用处时也要写。它同时能挡住 any 从函数里漏出去。 ), }, @@ -2343,11 +2261,8 @@ export default function FunctionsPage() { ), zh: ( <> - ? 让调用方可以不写这个实参, - 而函数体里的类型会带上 | undefined。 - 默认值同样允许不写,但函数体里的类型不含{" "} - undefined。可选参数必须排在最后。 - rest 参数是数组,也可以是元组。 + ? 让调用方可以不写这个实参,而函数体里的类型会带上 | undefined。默认值同样允许不写,但函数体里的类型不含{" "} + undefined。可选参数必须排在最后。rest 参数是数组,也可以是元组。 ), }, @@ -2363,10 +2278,7 @@ export default function FunctionsPage() { ), zh: ( <> - 函数类型里的 void 表示调用方不看返回值, - 所以真的返回了东西的函数照样放得进去; - 声明上的 void 则禁止返回值; - 类型为 void 的变量只接受 undefined。 + 函数类型里的 void 表示调用方不看返回值,所以真的返回了东西的函数照样放得进去;声明上的 void 则禁止返回值;类型为 void 的变量只接受 undefined。 ), }, @@ -2388,8 +2300,7 @@ export default function FunctionsPage() { 返回值按协变检查:返回 Dog 可以放进「要{" "} Animal」的位置。参数在{" "} strictFunctionTypes 下按逆变检查 —— - 但用方法语法写的成员是例外,仍然双变。 - 这个例外是刻意保留的不健全,也正是{" "} + 但用方法语法写的成员是例外,仍然双变。这个例外是刻意保留的不健全,也正是{" "} Array<Dog> 能赋给{" "} Array<Animal> 的原因。 @@ -2406,10 +2317,8 @@ export default function FunctionsPage() { ), zh: ( <> - 参数更少的函数可以放进「要求参数更多」的位置, - 因为 JavaScript 本来就会忽略多余的实参 —— 这就是{" "} - array.map(x => x) 能编译的原因。 - 反过来,声明的参数比目标提供的还多,会被拒绝。 + 参数更少的函数可以放进「要求参数更多」的位置,因为 JavaScript 本来就会忽略多余的实参 —— 这就是{" "} + array.map(x => x) 能编译的原因。反过来,声明的参数比目标提供的还多,会被拒绝。 ), }, @@ -2426,9 +2335,7 @@ export default function FunctionsPage() { zh: ( <> 重载里,实现签名不能从外部调用; - TypeScript 按书写顺序取第一条匹配的重载, - 而不是最合适的那条。this 参数在编译期被擦除, - 箭头函数不能声明它。 + TypeScript 按书写顺序取第一条匹配的重载,而不是最合适的那条。this 参数在编译期被擦除,箭头函数不能声明它。 ), }, @@ -2465,8 +2372,7 @@ export default function FunctionsPage() { interface 和 type{" "} 大部分时候可以互换。联合类型和映射类型只能用{" "} type;declaration merging 只有{" "} - interface 有。A & B 是交叉类型: - 一个同时满足两边的值。 + interface 有。A & B 是交叉类型:一个同时满足两边的值。 ), }, diff --git a/app/functions/viz.tsx b/app/functions/viz.tsx index 5f7b65c..8869d03 100644 --- a/app/functions/viz.tsx +++ b/app/functions/viz.tsx @@ -86,9 +86,7 @@ const SIG_SEGS: SigSeg[] = [ } zh={ <> - 这个函数叫什么。用动词短语最好读, - 因为调用处应该一眼看出发生了什么:makeOrder、addTopping、calcTotal。 - 开门括号一开,参数列表就开始了。 + 这个函数叫什么。用动词短语最好读,因为调用处应该一眼看出发生了什么:makeOrder、addTopping、calcTotal。开门括号一开,参数列表就开始了。 } /> @@ -110,9 +108,7 @@ const SIG_SEGS: SigSeg[] = [ } zh={ <> - 冒号左边是参数名,函数体里用这个名字称呼它; - 右边是类型,也就是调用方必须交出的形状。 - 传一个不是 MenuItem 的东西进来,这次调用就编译不过。 + 冒号左边是参数名,函数体里用这个名字称呼它;右边是类型,也就是调用方必须交出的形状。传一个不是 MenuItem 的东西进来,这次调用就编译不过。 } /> @@ -165,10 +161,8 @@ const SIG_SEGS: SigSeg[] = [ } zh={ <> - 名字后面挂一个 ?,表示这个实参可以整个不写。 - 代价在函数体里:那里它的类型是{" "} - Topping[] | undefined,用之前得先检查。 - 可选参数还必须排在所有必选参数后面 —— 原因见 §02。 + 名字后面挂一个 ?,表示这个实参可以整个不写。代价在函数体里:那里它的类型是{" "} + Topping[] | undefined,用之前得先检查。可选参数还必须排在所有必选参数后面 —— 原因见 §02。 } /> @@ -191,10 +185,7 @@ const SIG_SEGS: SigSeg[] = [ } zh={ <> - 关门括号后面的 : Order 是这个函数承诺交出的东西。 - return 的对象少一个字段,错误就报在这里、报在定义处。 - TypeScript 本来能推断出它, - 但在会被别人调用的函数上写出来,这份承诺才稳定 —— 见 §01 的提示框。 + 关门括号后面的 : Order 是这个函数承诺交出的东西。return 的对象少一个字段,错误就报在这里、报在定义处。TypeScript 本来能推断出它,但在会被别人调用的函数上写出来,这份承诺才稳定 —— 见 §01 的提示框。 } /> @@ -268,9 +259,7 @@ const CC_CASES: CcCase[] = [ } zh={ <> - toppings 带了 ?,所以整个不写是允许的。 - 函数体里 toppings 是 undefined, - 返回值仍然是一张完整的 Order。 + toppings 带了 ?,所以整个不写是允许的。函数体里 toppings 是 undefined,返回值仍然是一张完整的 Order。 } /> @@ -292,8 +281,7 @@ const CC_CASES: CcCase[] = [ } zh={ <> - size 没有 ?,是必填的。 - 两个必选参数加一个可选参数 —— 报错里那个 2-3 的范围就是这么来的。 + size 没有 ?,是必填的。两个必选参数加一个可选参数 —— 报错里那个 2-3 的范围就是这么来的。 } /> @@ -315,9 +303,7 @@ const CC_CASES: CcCase[] = [ } zh={ <> - Size 只认三个字符串:small、medium、large。 - "grande" 不在其中。字面量联合比的是整个字符串, - 差一个字母就已经是另一个类型了。 + Size 只认三个字符串:small、medium、large。"grande" 不在其中。字面量联合比的是整个字符串,差一个字母就已经是另一个类型了。 } /> @@ -338,9 +324,7 @@ const CC_CASES: CcCase[] = [ } zh={ <> - 第三个参数要的是 Topping[]: - 一个数组,里面每一项都得是 Topping 联合里的字符串。 - 这两个值都在册。 + 第三个参数要的是 Topping[]:一个数组,里面每一项都得是 Topping 联合里的字符串。这两个值都在册。 } /> @@ -362,8 +346,7 @@ const CC_CASES: CcCase[] = [ } zh={ <> - 第一个参数要的是一件商品的完整形状:id、name、price。 - 光一个商品名只是个字符串。编译器比的是形状,不是意思 —— + 第一个参数要的是一件商品的完整形状:id、name、price。光一个商品名只是个字符串。编译器比的是形状,不是意思 —— 04 章专门讲这件事。 } @@ -388,8 +371,7 @@ const CC_CASES: CcCase[] = [ zh={ <> 就算只加一样配料,也得装进数组: - ["boba"]。第三个参数的类型是数组, - 而「一个字符串」和「装着一个字符串的数组」是两种类型。 + ["boba"]。第三个参数的类型是数组,而「一个字符串」和「装着一个字符串的数组」是两种类型。 } /> @@ -535,8 +517,7 @@ export function CallCheck() { } zh={ <> - 六单全审完,你就有了编译器的习惯: - 看到一次调用,先在脑子里对一遍签名,再去运行。 + 六单全审完,你就有了编译器的习惯:看到一次调用,先在脑子里对一遍签名,再去运行。 } /> diff --git a/app/generics/page.tsx b/app/generics/page.tsx index 9e9da6f..e068e3f 100644 --- a/app/generics/page.tsx +++ b/app/generics/page.tsx @@ -538,8 +538,7 @@ export default function GenericsPage() { ), zh: ( <> - 先别急着说是什么类型 —— 留一个洞 <T>, - 调用的人来填。同一个签名里的每个 T{" "} + 先别急着说是什么类型 —— 留一个洞 <T>,调用的人来填。同一个签名里的每个 T{" "} 都是同一个,所以返回值的类型始终和参数的类型绑在一起。 ), @@ -607,10 +606,8 @@ export default function GenericsPage() { } zh={ <> - 一个小店的点单系统要「取订单列表的第一单」 - 「取菜单的第一项」「取杯型选项的第一个」—— - 同一个动作做三遍,面对三种不同的数组类型。 - 用现有的写法,你有三条路,三条都不体面。 + 一个小店的点单系统要「取订单列表的第一单」「取菜单的第一项」「取杯型选项的第一个」—— + 同一个动作做三遍,面对三种不同的数组类型。用现有的写法,你有三条路,三条都不体面。 } /> @@ -669,9 +666,7 @@ export default function GenericsPage() { 看右边这版:一份代码通吃所有数组,而且输入和输出是 连着的 —— 喂它 string[],拿回来的东西和{" "} string 有关;喂它 Order[], 拿回来的就和{" "} - Order 有关。 - 中间那一版丢掉的正是这条联系。下一节把一次调用放慢, - 看它是怎么建立起来的。 + Order 有关。中间那一版丢掉的正是这条联系。下一节把一次调用放慢,看它是怎么建立起来的。 } /> @@ -712,8 +707,7 @@ export default function GenericsPage() { zh={ <> 函数名后面的尖括号声明了一个占位符,并给它取名 T - 。它叫类型参数: - 和普通参数一样是参数,只不过里面装的是类型,不是值。 + 。它叫类型参数:和普通参数一样是参数,只不过里面装的是类型,不是值。 } /> @@ -736,8 +730,7 @@ export default function GenericsPage() { } zh={ <> - 签名里每个 T 指的都是那一个占位符。 - 这就是它的承诺:数组里装的和返回的是同一种类型。 + 签名里每个 T 指的都是那一个占位符。这就是它的承诺:数组里装的和返回的是同一种类型。 any 说不出这句话。 } @@ -763,8 +756,7 @@ export default function GenericsPage() { } zh={ <> - 写这个函数的人不知道 T 会是什么,也不需要知道。 - 每次调用各自带着实参过来,编译器就为这一次调用单独解出{" "} + 写这个函数的人不知道 T 会是什么,也不需要知道。每次调用各自带着实参过来,编译器就为这一次调用单独解出{" "} T。 } @@ -795,9 +787,7 @@ export default function GenericsPage() { } zh={ <> - 编译器从第一个实参读出 T = string, - 再拿第二个实参去对。想让两个位置各是各的类型, - 那就声明两个占位符:{"pair(a: A, b: B)"} —— + 编译器从第一个实参读出 T = string,再拿第二个实参去对。想让两个位置各是各的类型,那就声明两个占位符:{"pair(a: A, b: B)"} —— 动手任务里有一题练这个。 } @@ -832,8 +822,7 @@ export default function GenericsPage() { T 取自 Type。你也可以叫它 Item、 Row,就像普通参数可以叫 x 也可以叫{" "} count。常见的还有 K / V - (键和值)、E(元素)。名字短,是因为这个占位符 - 常常真的什么都可能是。一旦它有明确含义,就该起个真名: 使用处写{" "} + (键和值)、E(元素)。名字短,是因为这个占位符常常真的什么都可能是。一旦它有明确含义,就该起个真名: 使用处写{" "} {"Paginated"} 比{" "} {"Paginated"} 好读。 @@ -879,9 +868,7 @@ export default function GenericsPage() { <> 推断的原料只有一个来源:实参。空数组带不出元素类型, 于是{" "} T 被推成 never,结果是{" "} - undefined。这不是报错,但通常不是你要的。 - 在意料之外的地方看到 never, - 就该想到去把类型实参写出来。 + undefined。这不是报错,但通常不是你要的。在意料之外的地方看到 never,就该想到去把类型实参写出来。 ), }} @@ -905,9 +892,7 @@ export default function GenericsPage() { } zh={ <> - 到这里为止,「T 是什么」都是注释里的一句断言。 - 下面这个窗口里跑的是编译器本身,断言可以当场核对。 - 点四次调用里的任意一个 first:弹出来的签名里{" "} + 到这里为止,「T 是什么」都是注释里的一句断言。下面这个窗口里跑的是编译器本身,断言可以当场核对。点四次调用里的任意一个 first:弹出来的签名里{" "} T 已经被换成了实际类型,价格那一行是{" "} {"function first(arr: number[]): number | undefined"} @@ -947,8 +932,7 @@ export default function GenericsPage() { {"function first(arr: Order[]): Order | undefined"} ,因为类型是你自己点名的;first([]) 显示{" "} {"function first(arr: never[]): undefined"} - ,因为空数组带不出元素类型,剩下的只有 never。 - 这一行不报错 —— 正因为不报错,才值得练成一眼认出来。 + ,因为空数组带不出元素类型,剩下的只有 never。这一行不报错 —— 正因为不报错,才值得练成一眼认出来。 } /> @@ -974,9 +958,7 @@ export default function GenericsPage() { } zh={ <> - 空数组好歹还是个实参,编译器手里有东西。 - 更极端的情况是:类型参数只出现在返回值类型里。 - 这时没有任何实参提到它,编译器就回落到 unknown。 + 空数组好歹还是个实参,编译器手里有东西。更极端的情况是:类型参数只出现在返回值类型里。这时没有任何实参提到它,编译器就回落到 unknown。 } /> @@ -1005,10 +987,7 @@ export default function GenericsPage() { zh={ <> 这种写法在解析数据、请求数据的代码里很常见,但要小心: - parseJson 承诺返回一个 T, - 却没有任何东西检查解析出来的内容真的长成那样。 - 类型实参是调用方的一句断言,不是编译器给的保证。 - 拿到这种值之后怎么检查,是第 03 章的内容。 + parseJson 承诺返回一个 T,却没有任何东西检查解析出来的内容真的长成那样。类型实参是调用方的一句断言,不是编译器给的保证。拿到这种值之后怎么检查,是第 03 章的内容。 } /> @@ -1028,8 +1007,7 @@ export default function GenericsPage() { } zh={ <> - 经验法则:默认交给推断。 - 只有在实参给不出信息时才手写类型实参 —— + 经验法则:默认交给推断。只有在实参给不出信息时才手写类型实参 —— 比如空数组、没有参数的调用、类型参数只出现在返回值里 —— 或者推出来的类型不是你想要的那个。 @@ -1152,8 +1130,7 @@ export default function GenericsPage() { zh: ( <> 两个占位符。T 是那个对象;K 被约束成{" "} - keyof T,也就是这个对象的键名联合。 - 安全性正是这个约束给的:既然 K 只能是 T{" "} + keyof T,也就是这个对象的键名联合。安全性正是这个约束给的:既然 K 只能是 T{" "} 真有的键,索引访问 T[K]{" "} 取到的就一定是存在的类型,写错键名也会在程序运行前被拦下。 keyof 和 T[K] 的完整用法在第 07 章。 @@ -1192,9 +1169,7 @@ export default function GenericsPage() { to parameter of type '{"{ length: number; }"}'. ,getProp(menu, "topping"){" "} - 拿到的是同一个错误码,里面列着那个对象真有的两个键名。 - 点报错可以跳到出问题的调用。然后按一下把约束去掉: - 调用处的两处报错消失,函数体里换成了{" "} + 拿到的是同一个错误码,里面列着那个对象真有的两个键名。点报错可以跳到出问题的调用。然后按一下把约束去掉:调用处的两处报错消失,函数体里换成了{" "} TS2339: Property 'length' does not exist on type 'T'. @@ -1238,8 +1213,7 @@ export default function GenericsPage() { 这一次切换,就是整笔交易。 约束把抱怨从函数体挪到了调用处 —— 而调用处才是人能动手的地方:写 longest 的人可以放心写{" "} - a.length,不用和编译器争辩; - 传了 number 进来的人,在自己写的那一行上就被拦住。 + a.length,不用和编译器争辩;传了 number 进来的人,在自己写的那一行上就被拦住。 } /> @@ -1306,10 +1280,8 @@ export default function GenericsPage() { ), zh: ( <> - 区别在于占位符什么时候定下来。 - 泛型类是每个实例定一次,所有成员共用它,所以一个{" "} - Basket<string> 从头到尾只收 string。 - 泛型方法是每次调用定一次,所以同一个 Counter{" "} + 区别在于占位符什么时候定下来。泛型类是每个实例定一次,所有成员共用它,所以一个{" "} + Basket<string> 从头到尾只收 string。泛型方法是每次调用定一次,所以同一个 Counter{" "} 实例可以先数 number,再数 string。 ), @@ -1338,7 +1310,7 @@ export default function GenericsPage() { <> {""} 限制哪些类型被允许; {""}{" "} - 是在既没写、也推不出来时补一个类型。 两者回答的是不同的问题, + 是在既没写、也推不出来时补一个类型。两者回答的是不同的问题, {''} 就是同时用上。 ), @@ -1371,8 +1343,7 @@ export default function GenericsPage() { 的简写,而 Array 就是一个泛型接口。 Promise<Order> 表示「将来会是一个{" "} Order」。Map<string, number>{" "} - 有两个占位符。读懂了尖括号, - 标准库的类型签名就变成了可以读的说明书。 + 有两个占位符。读懂了尖括号,标准库的类型签名就变成了可以读的说明书。 } /> @@ -1395,7 +1366,7 @@ export default function GenericsPage() { Array<Animal>, 哪怕这样就能往原数组里{" "} push 一个非 Dog。这是关于{" "} Array{" "} - 的方法如何比较的一个有意为之的决定,不是泛型的通则。 第 02 章 + 的方法如何比较的一个有意为之的决定,不是泛型的通则。第 02 章 §04 讲透了这件事。 } @@ -1462,8 +1433,7 @@ export default function GenericsPage() { 和所有注解,都在编译过程中被去掉。所以程序运行时没法问{" "} T 是什么,也写不出 new T() 或{" "} if (T === String)。泛型函数并不知道自己的类型实参 - —— 知道的是编译器,而且是在编译之前就知道。 - 真要在运行时按类型分支,那得对值做真正的运行时检查, 那是第 + —— 知道的是编译器,而且是在编译之前就知道。真要在运行时按类型分支,那得对值做真正的运行时检查, 那是第 03 章的内容。 } @@ -1497,9 +1467,7 @@ export default function GenericsPage() { {"function log(x: T): void { console.log(x) }"} {" "} - 声明了 T,然后只提到它一次。 - 它没把任何东西和任何东西联系起来,也就什么都没承诺。 - 一条好用的规矩:类型参数至少要出现两次 —— + 声明了 T,然后只提到它一次。它没把任何东西和任何东西联系起来,也就什么都没承诺。一条好用的规矩:类型参数至少要出现两次 —— 联系两个参数,或者联系参数和返回值。只出现一次的话, 直接写{" "} {"(x: unknown)"} 更诚实。 @@ -1529,11 +1497,8 @@ export default function GenericsPage() { } zh={ <> - any 是放弃类型:不管进去的是什么, - 出来的都不再受检查。泛型是保住类型: - T 在调用那一刻被解成一个具体类型, - 输入和输出全程受检。两者方向相反, - 看起来像,只是因为它们都「什么类型都收」。 + any 是放弃类型:不管进去的是什么,出来的都不再受检查。泛型是保住类型: + T 在调用那一刻被解成一个具体类型,输入和输出全程受检。两者方向相反,看起来像,只是因为它们都「什么类型都收」。 } /> @@ -1616,8 +1581,7 @@ export default function GenericsPage() { ), zh: ( <> - 类型参数是一个类型的占位符,在调用处才被填上。 - 同一个签名里的每个 T 都是同一个 —— + 类型参数是一个类型的占位符,在调用处才被填上。同一个签名里的每个 T 都是同一个 —— 返回值的类型就是这样和参数的类型绑在一起的。 ), @@ -1649,8 +1613,7 @@ export default function GenericsPage() { ), zh: ( <> - 推断是默认,它的原料是实参。 - 实参给不出信息时才手写类型实参 —— 空数组会得到{" "} + 推断是默认,它的原料是实参。实参给不出信息时才手写类型实参 —— 空数组会得到{" "} never,只出现在返回值里的类型参数会得到{" "} unknown。 @@ -1689,8 +1652,7 @@ export default function GenericsPage() { zh: ( <> 类型参数会被擦除:编译之后没有 T{" "} - 可查,泛型函数在运行时并不知道自己的类型实参。 - 泛型类的占位符按实例定一次,泛型方法的按调用定一次。 + 可查,泛型函数在运行时并不知道自己的类型实参。泛型类的占位符按实例定一次,泛型方法的按调用定一次。 ), }, diff --git a/app/generics/viz.tsx b/app/generics/viz.tsx index af72cd8..b72dd41 100644 --- a/app/generics/viz.tsx +++ b/app/generics/viz.tsx @@ -100,8 +100,7 @@ export function HoleFiller() { zh={ <> 先只看签名。<T> 是在声明占位符, 后面两个{" "} - T 是同一个占位符的两次使用。 - 现在还什么都没填。点「下一步」,让一次调用进场。 + T 是同一个占位符的两次使用。现在还什么都没填。点「下一步」,让一次调用进场。 } />, @@ -119,7 +118,7 @@ export function HoleFiller() { <> 编译器去读实参:{run.argLit} 的类型是{" "} {run.argType}。拿它对上声明的参数 arr: T[] - ,得出 T = {run.fill}。 这就是类型实参推断,而它的原料只有实参。 + ,得出 T = {run.fill}。这就是类型实参推断,而它的原料只有实参。 } />, @@ -136,8 +135,7 @@ export function HoleFiller() { zh={ <> 只有一个占位符,所以所有使用处同时被填上:这个签名里的每个{" "} - T 现在都是 {run.fill}。 - 同一次调用里,不可能这头是 string、那头是{" "} + T 现在都是 {run.fill}。同一次调用里,不可能这头是 string、那头是{" "} number。 } @@ -155,7 +153,7 @@ export function HoleFiller() { zh={ <> 返回值类型跟着落定:x 的类型是 {run.result} - 。类型信息从实参一路走到了结果。 这就是它和 any 的区别 ——{" "} + 。类型信息从实参一路走到了结果。这就是它和 any 的区别 ——{" "} any 把类型丢在门口。 } @@ -297,8 +295,7 @@ const CANDS: Cand[] = [ } zh={ <> - string 本来就有 .length, - 可以赋值给这个约束,调用通过,T = string。注意 T 并不会被削成{" "} + string 本来就有 .length,可以赋值给这个约束,调用通过,T = string。注意 T 并不会被削成{" "} {"{ length: number }"} —— 它仍然是完整的{" "} string。 @@ -322,8 +319,7 @@ const CANDS: Cand[] = [ } zh={ <> - 数组也有 .length,同样通过,T = string[]。 - 约束问的是这个类型「有什么」,不是它「叫什么」。 + 数组也有 .length,同样通过,T = string[]。约束问的是这个类型「有什么」,不是它「叫什么」。 } /> @@ -346,8 +342,7 @@ const CANDS: Cand[] = [ zh={ <> 一个没名字、也没声明过类型的对象字面量,但它确实有{" "} - length: number。 - 上一章那套结构化检查,正是约束在这里用的检查。 + length: number。上一章那套结构化检查,正是约束在这里用的检查。 } /> @@ -373,8 +368,7 @@ const CANDS: Cand[] = [ } zh={ <> - number 身上没有 length, - 所以你还在敲代码的时候这次调用就被拒绝了: + number 身上没有 length,所以你还在敲代码的时候这次调用就被拒绝了: Argument of type 'number' is not assignable to parameter of type {"'{ length: number; }'"}. @@ -401,8 +395,7 @@ const CANDS: Cand[] = [ } zh={ <> - boolean 同样没有 length,拒绝。 - 约束是加在占位符上的一个条件:不是什么类型都能填进去。 + boolean 同样没有 length,拒绝。约束是加在占位符上的一个条件:不是什么类型都能填进去。 } /> @@ -478,8 +471,7 @@ export function ConstraintGate() { } zh={ <> - longest 要比较两个值的 .length, - 所以它的占位符带了一个条件:填进来的类型必须有{" "} + longest 要比较两个值的 .length,所以它的占位符带了一个条件:填进来的类型必须有{" "} length: number。点上面的候选值,挨个送去检查。 } diff --git a/app/home-viz.tsx b/app/home-viz.tsx index e56c60f..c97bc96 100644 --- a/app/home-viz.tsx +++ b/app/home-viz.tsx @@ -260,8 +260,7 @@ const TL_FRAMES: TLFrame[] = [ } zh={ <> - 凌晨三点:客服电话、翻日志、一行行加 console.log —— 两小时后才发现, - 凶手是 5 个拼错的字母。同一时刻,TS 工程师在睡觉。 + 凌晨三点:客服电话、翻日志、一行行加 console.log —— 两小时后才发现,凶手是 5 个拼错的字母。同一时刻,TS 工程师在睡觉。 } /> @@ -387,8 +386,7 @@ const E_CAPTION: Record = { } zh={ <> - 带颜色的部分全是写给编译器看的:type、interface、冒号后面的注解。 - 运行逻辑其实只有下面那几行。 + 带颜色的部分全是写给编译器看的:type、interface、冒号后面的注解。运行逻辑其实只有下面那几行。 } /> @@ -403,8 +401,7 @@ const E_CAPTION: Record = { } zh={ <> - 划线的这些,编译时会被整体擦掉 —— 一个字节都不会进产物。 - 这个动作就叫类型擦除(type erasure)。 + 划线的这些,编译时会被整体擦掉 —— 一个字节都不会进产物。这个动作就叫类型擦除(type erasure)。 } /> @@ -421,8 +418,7 @@ const E_CAPTION: Record = { } zh={ <> - 这就是用户浏览器里真正跑的代码:类型全没了,逻辑一字未动。 - 所以类型防不住运行时闯进来的坏数据 —— 这个伏笔,终章回收。 + 这就是用户浏览器里真正跑的代码:类型全没了,逻辑一字未动。所以类型防不住运行时闯进来的坏数据 —— 这个伏笔,终章回收。 } /> diff --git a/app/mindset/page.tsx b/app/mindset/page.tsx index a680fd2..ef66387 100644 --- a/app/mindset/page.tsx +++ b/app/mindset/page.tsx @@ -553,9 +553,7 @@ export default function MindsetPage() { zh: ( <> 前十一章讲完了语法,这一章讲怎么想。TypeScript 就是 JavaScript - 加上一层类型,这层类型只存在于你写代码和编译的阶段: - 编译器检查完就把它删掉,真正运行的是你写的那份 JavaScript。 - 五条心法,一张全书地图,一场总测验。 + 加上一层类型,这层类型只存在于你写代码和编译的阶段:编译器检查完就把它删掉,真正运行的是你写的那份 JavaScript。五条心法,一张全书地图,一场总测验。 ), }} @@ -646,17 +644,13 @@ export default function MindsetPage() { zh={ <>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -2242,8 +2141,7 @@ export default function MindsetPage() { ), zh: ( <> - 类型是写下来的约定:写下来的,编译器替你守; - 只记在脑子里的,只能靠运气。 + 类型是写下来的约定:写下来的,编译器替你守;只记在脑子里的,只能靠运气。 ), }, @@ -2259,8 +2157,7 @@ export default function MindsetPage() { zh: ( <> 三种写法:注解检查但拓宽,as 既不检查也不保留, - satisfies 两样都做。需要精确保留字面量时, - 再加 as const。 + satisfies 两样都做。需要精确保留字面量时,再加 as const。 ), }, @@ -2276,9 +2173,7 @@ export default function MindsetPage() { ), zh: ( <> - 类型在编译期被擦除,所以运行时没有任何类型检查。 - 外部数据一律用 unknown 接收, - 再用你自己写的检查收窄 —— + 类型在编译期被擦除,所以运行时没有任何类型检查。外部数据一律用 unknown 接收,再用你自己写的检查收窄 —— 让错误发生在边界,而不是三个文件之外。 ), @@ -2294,8 +2189,7 @@ export default function MindsetPage() { ), zh: ( <> - 类型系统是刻意不完全健全的:any、断言、 - 数组协变、未开检查的下标访问,都是真实存在的口子 —— + 类型系统是刻意不完全健全的:any、断言、数组协变、未开检查的下标访问,都是真实存在的口子 —— 「编译通过」不等于「代码正确」。 ), @@ -2311,9 +2205,7 @@ export default function MindsetPage() { ), zh: ( <> - any 在迁移期和真正动态的边界上是正当的。 - 纪律是控制范围:内部可以乱,导出签名必须精确。 - 先用 unknown。 + any 在迁移期和真正动态的边界上是正当的。纪律是控制范围:内部可以乱,导出签名必须精确。先用 unknown。 ), }, @@ -2328,9 +2220,7 @@ export default function MindsetPage() { ), zh: ( <> - 类型最有价值的用途是建模:可辨识联合让非法状态写不出来, - 约定就不再只是一句注释。拿不准的时候, - 去 Playground 验证,别猜。 + 类型最有价值的用途是建模:可辨识联合让非法状态写不出来,约定就不再只是一句注释。拿不准的时候,去 Playground 验证,别猜。 ), }, diff --git a/app/mindset/viz.tsx b/app/mindset/viz.tsx index c971f81..d158ed6 100644 --- a/app/mindset/viz.tsx +++ b/app/mindset/viz.tsx @@ -188,8 +188,7 @@ const TRIO_TESTS: { ), zh: ( <> - 断言只要求两个类型有足够的重叠,能互相当成对方。 - 多余属性检查完全不做,拼错的字段就这样带着 Config{" "} + 断言只要求两个类型有足够的重叠,能互相当成对方。多余属性检查完全不做,拼错的字段就这样带着 Config{" "} 的名分通过了。 ), @@ -265,10 +264,8 @@ const TRIO_TESTS: { ), zh: ( <> - Config 可以赋给你写的这个更小的形状, - 两个类型有重叠,断言就通过了。运行时{" "} - config.maxSugar 是 undefined, - 拿它做算术会得到 NaN。 + Config 可以赋给你写的这个更小的形状,两个类型有重叠,断言就通过了。运行时{" "} + config.maxSugar 是 undefined,拿它做算术会得到 NaN。 ), }, @@ -320,9 +317,7 @@ const TRIO_TESTS: { ), zh: ( <> - 注解把推断结果换成了你声明的类型。 - 编译器从此只知道「是两者之一」,不知道具体是哪一个。 - 写 {'if (config.theme === "light")'}{" "} + 注解把推断结果换成了你声明的类型。编译器从此只知道「是两者之一」,不知道具体是哪一个。写 {'if (config.theme === "light")'}{" "} 它仍然认为有可能成立。 ), @@ -341,8 +336,7 @@ const TRIO_TESTS: { ), zh: ( <> - 断言同样把字面量信息换成了 Config。 - 检查没做,精确的推断也丢了 —— 两头都没落着。 + 断言同样把字面量信息换成了 Config。检查没做,精确的推断也丢了 —— 两头都没落着。 ), }, @@ -363,8 +357,7 @@ const TRIO_TESTS: { zh: ( <> 检查照做,推断不动 —— config.theme 依然是字面量类型{" "} - "dark"。「既要校验又要精确推断」, - 正是 TypeScript 4.9 加入 satisfies 的原因。 + "dark"。「既要校验又要精确推断」,正是 TypeScript 4.9 加入 satisfies 的原因。 ), }, @@ -451,7 +444,7 @@ export function TrioLab() { } zh={ <> - 总结:注解 检查 ✓ 推断 ✕ · as 检查 ✕ 推断 ✕ · + 总结:注解检查 ✓ 推断 ✕ · as 检查 ✕ 推断 ✕ · satisfies 检查 ✓ 推断 ✓ —— 两样都想要,用 satisfies。 } @@ -535,9 +528,7 @@ const EF_FRAMES: FlowFrame[] = [ } zh={ <> - 一次请求带回一段 JSON。你写下 as Order, - 编译器不再检查这个值,直接接受。 - 从这里开始,在编译器眼里它的类型就是 Order。 + 一次请求带回一段 JSON。你写下 as Order,编译器不再检查这个值,直接接受。从这里开始,在编译器眼里它的类型就是 Order。 } /> @@ -567,9 +558,7 @@ const EF_FRAMES: FlowFrame[] = [ } zh={ <> - 编译产物是普通 JavaScript。Order 这个名字在运行时并不存在, - 门口也没有任何检查。这就是类型擦除: - 编译器能检查你写的代码,检查不了别人发来的数据。 + 编译产物是普通 JavaScript。Order 这个名字在运行时并不存在,门口也没有任何检查。这就是类型擦除:编译器能检查你写的代码,检查不了别人发来的数据。 } /> @@ -607,13 +596,11 @@ const EF_FRAMES: FlowFrame[] = [ } zh={ <> - 某天后端把 total 改名成 amount。 - JSON 照样进来, + 某天后端把 total 改名成 amount。JSON 照样进来, order.total 变成 undefined - ,一路传过三个文件,直到报表页才出错。 - 出错的地方离原因越远,排查越费时间。 + ,一路传过三个文件,直到报表页才出错。出错的地方离原因越远,排查越费时间。 } /> @@ -642,9 +629,7 @@ const EF_FRAMES: FlowFrame[] = [ } zh={ <> - 换一种做法:外部数据先标成 unknown。 - 这样编译器会拒绝一切用法,直到你把它收窄, - 于是你在边界上写一个检查函数 —— + 换一种做法:外部数据先标成 unknown。这样编译器会拒绝一切用法,直到你把它收窄,于是你在边界上写一个检查函数 —— 这个检查就是类型系统替你做不了的那部分。 } @@ -682,10 +667,8 @@ const EF_FRAMES: FlowFrame[] = [ } zh={ <> - 坏数据再来一次,isOrder 在门口拦下, - 一行日志就能说清问题。错误仍然会发生,只是 - 发生在边界,而不是三个文件之外。 - 「用 unknown 接收,再用检查收窄」的价值就在这里。 + 坏数据再来一次,isOrder 在门口拦下,一行日志就能说清问题。错误仍然会发生,只是 + 发生在边界,而不是三个文件之外。「用 unknown 接收,再用检查收窄」的价值就在这里。 } /> diff --git a/app/modules/page.tsx b/app/modules/page.tsx index b1fe1e5..4707828 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{" "} 需要这个前提。 @@ -758,8 +752,8 @@ export default function ModulesPage() { module option in{" "} tsconfig.json chooses the output format:{" "} esnext, commonjs, and so on. It does - not decide whether a given file is a module. Only the file - itself does that, by having a top-level import or export. + not decide whether a given file is a module. By default the + file itself does that, by having a top-level import or export. + A separate option, moduleDetection, can change the + rule: with "force", every non-declaration + file is treated as a module, and that is what{" "} + tsc --init writes in TypeScript 5.9. Declaration + files are not affected; for them a top-level import or export + still decides.

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

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

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

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

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

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

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

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

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

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

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

} @@ -1247,9 +1211,7 @@ export default function ModulesPage() { } zh={ <> - 声明文件(.d.ts)描述一个模块对外的形状: - 每个导出,连带它的类型,除此之外什么都没有。 - 这种文件不一定要手写 —— 你让编译器生成,它就会从源码里生成: + 声明文件(.d.ts)描述一个模块对外的形状:每个导出,连带它的类型,除此之外什么都没有。这种文件不一定要手写 —— 你让编译器生成,它就会从源码里生成: } /> @@ -1289,8 +1251,7 @@ export default function ModulesPage() { } zh={ <> - 上面那一对是事先摆好的,下面这个不是: - 一段奶茶店 SDK 的切片,底下是真编译器。 + 上面那一对是事先摆好的,下面这个不是:一段奶茶店 SDK 的切片,底下是真编译器。 编译产物 JS 页签里是去掉类型的实现, 声明文件 .d.ts 页签里是去掉实现的类型 —— 同一个文件的两半,哪一半都不用手写。 @@ -1334,15 +1295,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 +1319,9 @@ export default function ModulesPage() { } zh={ <> - 换上撤掉 priceOf 的导出,看它去了哪里。 - 这个函数还在编译产物 JS 里 —— tsc{" "} + 换上撤掉 priceOf 的导出,看它去了哪里。这个函数还在编译产物 JS 里 —— tsc{" "} 会把你写的每一行都输出去;它从 .d.ts 里消失了 —— - 那份文件只描述这个模块对外提供什么。 - 一次改动,就说完了声明文件的全部职责。 + 那份文件只描述这个模块对外提供什么。一次改动,就说完了声明文件的全部职责。 } /> @@ -1403,8 +1357,7 @@ export default function ModulesPage() { <>

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

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

} @@ -1485,8 +1436,7 @@ export default function ModulesPage() { } zh={ <> - 注意 declare 这个词:实现不在这里, - 但运行时它会存在。§04 专门讲这个词。 + 注意 declare 这个词:实现不在这里,但运行时它会存在。§04 专门讲这个词。 } /> @@ -1505,8 +1455,8 @@ export default function ModulesPage() { zh: "一个库的类型从哪里来", }} desc={{ - en: "Three places, checked in order. The compiler stops at the first one that answers.", - zh: "三个地方,按顺序查。哪一个先有结果,编译器就停在那里。", + en: "Three sources. Check them in this order when you investigate, but a declare module you wrote yourself, once it exists, takes precedence over the other two.", + zh: "三个来源。排查时按这个顺序看;但要知道,你自己写的 declare module 一旦存在,优先级最高。", }} >
@@ -1528,8 +1478,7 @@ export default function ModulesPage() { } zh={ <> - 用 TypeScript 写的库,编译时开 --declaration, - 再用 package.json 的 types{" "} + 用 TypeScript 写的库,编译时开 --declaration,再用 package.json 的 types{" "} 字段(或 exports 里的 types{" "} 条件)指向产物。装完就有类型。 @@ -1552,10 +1501,8 @@ export default function ModulesPage() { } zh={ <> - 一个社区仓库,为自己不带类型的库写声明, - 发布成 @types/* 包: - npm i -D @types/lodash。 - 几乎所有老牌 JavaScript 库都被收录了。 + 一个社区仓库,为自己不带类型的库写声明,发布成 @types/* 包: + npm i -D @types/lodash。几乎所有老牌 JavaScript 库都被收录了。 } /> @@ -1632,8 +1579,7 @@ export default function ModulesPage() { <> 两种写法在真实项目里都常见。顶层的 types{" "} 字段是较早的写法;exports 里的{" "} - types 条件由较新的解析模式读取, - 而且可以按子路径分别指定 —— + types 条件由较新的解析模式读取,而且可以按子路径分别指定 —— 所以一个包能为它的 ESM 入口和 CommonJS 入口提供不同的类型。 @@ -1703,13 +1649,11 @@ export default function ModulesPage() { zh={ <>

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

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

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

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

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

} @@ -1886,8 +1818,7 @@ export default function ModulesPage() { } zh={ <> - 有些东西运行时确实存在,但编译器看不见: - 构建工具注入的全局常量、由 <script>{" "} + 有些东西运行时确实存在,但编译器看不见:构建工具注入的全局常量、由 <script>{" "} 标签加载进来的函数、没有类型的 npm 包。declare{" "} 就是你陈述它们类型的方式:它存在,形状是这样,请采信我。 @@ -1916,8 +1847,7 @@ export default function ModulesPage() { } zh={ <> - 最常见的真实需求:有东西被挂到了 window 上, - 你希望它有类型。在模块内部,这需要 declare global。 + 最常见的真实需求:有东西被挂到了 window 上,你希望它有类型。在模块内部,这需要 declare global。 } /> @@ -1948,9 +1878,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 +1934,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 +1998,7 @@ export default function ModulesPage() { gtag is not defined 失败。

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

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

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

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

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

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

} @@ -2412,7 +2321,7 @@ export default function ModulesPage() {
export default 和{" "} module.exports = 是两回事, esModuleInterop 会产出{" "} - __importDefault 包装, - 让默认导入能读到 CommonJS 的导出。 + __importDefault 包装,让默认导入能读到 CommonJS 的导出。 ), }, @@ -2480,10 +2388,8 @@ export default function ModulesPage() { zh: ( <> import type 和 export type{" "} - 把一行标为「只有类型」,于是整行被删除, - 运行时不会加载任何模块。verbatimModuleSyntax{" "} - 把这个标注变成必需:导入报 ts(1484), - 转手导出报 ts(1205)。 + 把一行标为「只有类型」,于是整行被删除,运行时不会加载任何模块。verbatimModuleSyntax{" "} + 把这个标记变成必需:导入报 ts(1484),转手导出报 ts(1205)。 ), }, @@ -2498,9 +2404,7 @@ export default function ModulesPage() { ), zh: ( <> - .d.ts 描述导出,不产出任何东西, - 也放不了实现:ts(1183)。 - 它同样不能替代模块本身 —— 有声明而没有对应的{" "} + .d.ts 描述导出,不产出任何东西,也放不了实现:ts(1183)。它同样不能替代模块本身 —— 有声明而没有对应的{" "} .js,类型检查会通过,运行时会失败。 ), @@ -2512,7 +2416,10 @@ export default function ModulesPage() { package's own types (or the{" "} types condition in exports), then{" "} @types from DefinitelyTyped, then a declaration you - write. All three empty means ts(7016). Keep the{" "} + write. All three empty means ts(7016). A{" "} + declare module you have already written takes + precedence over the other two, so remove a stopgap declaration + once real types exist. Keep the{" "} @types major and minor version aligned with the library. @@ -2523,8 +2430,7 @@ export default function ModulesPage() { types(或 exports 里的{" "} types 条件)→ DefinitelyTyped 的{" "} @types → 你自己写的声明。三个都空就是{" "} - ts(7016)。@types 的 major 和 minor - 版本要和库本体对齐。 + ts(7016)。你已经写好的 declare module 优先于前两者,有了真正的类型之后,要删掉当初的临时声明。@types 的 major 和 minor 版本要和库本体对齐。 ), }, @@ -2542,13 +2448,10 @@ export default function ModulesPage() { ), zh: ( <> - declare 只陈述类型、不产出代码, - 所以断言错了会在运行时失败。declare global{" "} + declare 只陈述类型、不产出代码,所以断言错了会在运行时失败。declare global{" "} 是模块触及全局作用域的方式(写在别处报{" "} ts(2669))。declare module "x"{" "} - 写在脚本文件里是声明一个没有类型的模块, - 写在模块文件里是扩充一个已有类型的模块; - 去扩充一个没有类型的模块会报 ts(2665)。 + 写在脚本文件里是声明一个没有类型的模块,写在模块文件里是扩充一个已有类型的模块;去扩充一个没有类型的模块会报 ts(2665)。 ), }, @@ -2566,8 +2469,7 @@ export default function ModulesPage() { <> 解析只发生在编译期。moduleResolution 决定是否遵守{" "} exports 和文件扩展名规则,而 paths{" "} - 不会改动产物里的模块标识。 - 真正运行你代码的那一方,需要一份自己的对应配置。 + 不会改动产物里的模块标识。真正运行你代码的那一方,需要一份自己的对应配置。 ), }, diff --git a/app/modules/viz.tsx b/app/modules/viz.tsx index b8af5d4..e94f530 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)。 } /> @@ -230,6 +227,7 @@ export function ErasedImports() {