读书导读 · CC BY-NC 3.0 · 非商业
《JavaScript 编程精解》全书摘要与逐章导读
非商业许可:按许可,本页不引用原书文字,只链接回官方免费版本的对应小节。免费阅读原书。
《JavaScript 编程精解》讲了什么AI 辅助
与问题相符的词汇,胜过更少的行数
怎样才能让程序始终是你还看得懂的?《Eloquent JavaScript》分层作答。它从比特和类型出发——类型转换在那里悄悄误导人——一路搭起表达式、绑定、控制流和函数,显示出与问题相符的词汇胜过更少的行数。作用域、闭包和纯度随后使抽象和高阶函数成为可能,而复合的对象和数组让真实的信息得以建模。从这里起,思考的单位变成了有边界的部件:接口隐藏内部,模块声明依赖,持久的值可以孤立地推理,而大声的失败加上自动化测试,提供了这门语言所缺的纪律。后半部分把这套词汇花在真实的平台上——把一门微型语言写成解析器加求值器,通过事件而不是轮询来驱动 DOM,围绕可替换的接口和被分派的动作来造一个游戏和一个绘图程序,并经由 HTTP 和异步 I/O 在服务器上运行 JavaScript。通篇反复出现同一个动作:分层是转移困难,而不是消除困难,所以先写正确、不耍小聪明的代码,只有在证明有必要时才加通用性。
一则讲透全书的寓言AI 辅助
用一个故事装下全书最核心的想法,并老实交代故事在哪里失真。
封盒管用,直到雾渗进来
从前,在一座雾城里有一个灯匠行会。老师傅造了一盏灯:一只黄铜柜子,里面每个齿轮都咬着其他所有齿轮,只有他一个人还能把它的内部装在脑子里。他死后,行会没法修它,因为要换灯芯就得懂百叶窗,要懂百叶窗就得懂风箱。于是学徒们重新来过。最年轻的米拉做了一些小封盒,每个只干一件事,只承载一个有名字的想法,外面只露出一个把手,盖子上刻着一份清单,列着它需要的其他盒子。灯盒不在乎它的燃料盒烧的是油还是酒精;只要有合适把手的盒子就行,所以同一根曲柄能驱动许多不同的内芯。她不去刮净重填一个盒子,而是另铸一个新的、留下旧的,这样行会就能预览一个设计,再毫无代价地把它扔掉。她的盒子在把手被转错方向的那一刻还会尖叫——一声刺耳的哨音——因为否则黄铜本身只会耸耸肩,安静地接受胡话,然后发着暗淡的、不对的颜色亮上好多年。可是雾渗了进来。有些盒子漏了:在错误的时刻转动把手,会露出里面一只做到一半的齿轮;一只测量盒会给出过期的读数,除非你在拨动它之前先读。而且行会从未就一种盖子记法的标准达成一致,所以流传着两种互不相容的记法,盒子有时还彼此循环地列在对方的清单里。米拉的回答不是一只完美的盒子。而是一条规则:先造那只朴素而正确的盒子,只封住你已经理解的东西,被误用时让它尖叫,只有当第二个使用者真的出现时才加宽把手。城里的灯依然可修——不是因为雾散了,而是因为它如今必须一次一个把手地爬进来。
故事里的每样东西代表什么
| 故事里 | 书里 |
|---|---|
| 老师傅那只柜子,每个齿轮都咬着其他所有齿轮 | 复杂性:建立在简单规则上的程序会累积出自己的规则,直到程序员迷失;没有边界的代码会成为一大团泥球 |
| 每只只干一件事、只承载一个有名字想法的封盒 | 单一概念提取式的抽象,它使接口成为可能 |
| 外面的把手 | 接口:一个部件有意暴露的有限操作集合 |
| 盖子上刻着的、它所需盒子的清单 | 模块、包与依赖:一个部件既声明它的公开接口,也声明它所依赖的东西 |
| 同一根曲柄驱动许多不同的内芯 | 类、原型与多态:按预期接口写的代码,接受任何支持该接口的值 |
| 铸一只新盒子,而不是把旧的刮干净 | 持久状态与不可变性:更新产生一个全新的值,所以各操作可以孤立地推理,预览可以重算,撤销也便宜 |
| 转错时的刺耳哨音,对比那块只会耸肩、发着不对光的黄铜 | 对抗松散语义的大声失败纪律:类型转换让荒谬的计算悄悄继续,所以 === 、断言、异常和测试补上缺失的纪律 |
| 会漏的盒子——转到一半瞥见的半成品齿轮,除非在拨动之前先读,否则读到过期的读数 | 内部终究会泄漏的张力:经由异步间隙泄漏的操作中间状态,DOM 代码里延迟的布局与实时集合,正则使用中的 lastIndex |
寓言在哪里失真
这则寓言的扭曲,在于让边界看起来像物理的封印。在书里,边界只是程序员信守的一种约定;JavaScript 里没有任何东西强制执行它,而规定它的这本书同时也展示了它阻止不了的泄漏。寓言还装不下那些给接口提供机制的东西——持有引用的绑定,由程序文本而不是由调用者确定的作用域,每次调用新生的、使闭包能携带记忆的局部绑定,以及由异常来逐层展开的、待返回的调用栈。它也装不下后半部分对这套词汇能够扩展的证明:一个解析器加一个只有寥寥几种情形的求值器就能运行任何程序,而控制结构之所以需要特殊形式,是因为普通的应用先对所有参数求值;事件靠通知而不靠轮询;Promise 在单线程事件循环上代表稍后才有的值。而且书自己的证据比它的寓意更单薄:这些主张建立在短小的代码片段、一个绘图项目和小小的玩具对象、单次计时运行,以及一门只拿一个求和程序演练过的微型语言之上——所以边界的收益是被推理出来的,而不是在大规模上被演示过的,而寓言那个自信的圆满结局,是最没有挣得的部分。
《JavaScript 编程精解》的核心论点
全书捍卫的 10 个论点,按论证需要的顺序排列。
- 用与问题相符的词汇来表达解决方案,即使总代码量增加,也更可能是正确的,这就是更高层次的语言和新抽象值得它们的代价的原因。
- 类型是覆盖在未加区分的比特之上的一层解释,而不是物理性质,所以 JavaScript 松散的语义让荒谬的计算悄悄地继续,程序员必须用 ===、断言和测试补上缺失的纪律。
- 程序只靠持有引用的绑定来记忆,而因为局部绑定在每次调用时都是新创建的,每次调用都是隔离的,这正是闭包得以可能的原因。
- 原子值表达不了真实的信息,所以复合结构是必要的,而形状良好的数据,甚至能把一条统计公式变成普通的代码。
- 程序只有被拆成边界严格的部分、这些部分既暴露有限的接口又声明它们的依赖,才保持可修复;结构性的工作会被忽视,因为它的回报落在未来的维护者身上,这就是程序会腐化成纠缠的原因。
- 问题里的每个名词并不都值得有它自己的可变类;计算出新的固定值而不是修改旧的,才使操作可以孤立地推理、预览可以重算、撤销便宜。
- 错误和模式应当由明确的理论而不是猜测来处理:把异常限定在失败真正被处理的地方,通过检验假设来调试,并把贪婪匹配当作一种会悄悄匹配过多的回溯搜索。
- 编程语言不是魔法:一个产出语法树的解析器,加上一个只有寥寥几种情形的求值器,就能运行任何程序,不过控制结构必须是特殊形式,因为普通的应用先对所有参数求值。
- 是浏览器单独让 JavaScript 变得重要,但它们的平台是靠去中心化的、临时凑出的特性长大的,所以它既赋能又设计糟糕——之所以还能驾驭,只因为每一层协议都把下面的凌乱抽象掉了,而页面代码被严格限制。
- DOM 笨拙,是因为它被设计成一个与语言无关的接口,所以要用选择器而不是固定的子节点路径去导航它,在修改之前只测量一次布局,并让注册的处理函数通知你,而不是去轮询。
逐章导读
每一章都在本页:论证怎样展开、关键论点、常见误读和核心概念。从目录里选一章。
导言
while 循环如何取代手动跳转?
导言是怎样展开的AI 辅助
开篇:一个被框成缺口的问题。本章以埃伦·乌尔曼(Ellen Ullman)的一段题词开头,警告说计算机不是照着我们的形象造出来的,而是“我们自身极其细小的一部分的投射”——那一部分,交给了逻辑和规则。这段引文为后面的一切定下基调:这将是一本讲怎样迁就一台机器的狭隘的书,而不是歌颂它的威力。正式的论证随后从一个平常的观察开始——计算机像螺丝刀一样常见,却复杂得多——并很快收窄到一个具体的缺口。对常见的任务(电子邮件、计算器),已经有现成的应用;对独特的或没有边界的任务,则没有。编程正是为填这个缺口而被引入,并被直白地定义:为一台“愚蠢、迂腐”的机器构造一组精确的指令,作者称这种活动“在根本上乏味而令人沮丧”,然后才承认它也可能有回报、很快,还是一道好谜题。从这里起,开篇转向语言:编程大多是通过人工构造的语言完成的,而这些语言出人意料地借鉴了人与人交谈的方式。一段简短的历史插叙——20世纪八九十年代的 BASIC 和 DOS 提示符,让位给“更易学,但自由度更小”的可视界面——落在了本书选定的主题上:JavaScript,只要你知道往哪儿看,它还在那里,内置在每一个现代浏览器里。
展开,第一乐章:困难是真的,而且得由你自己去面对。“关于编程”这一节从主题转向读者。作者直白地说,编程很难,并诊断了原因:根本的规则很简单,但建立在它们之上的程序会累积出足够的复杂性,生出自己的规则——“在某种意义上,你是在建造自己的迷宫,而且很容易在里面迷路。”这变成了体己的劝告:读这本书有时会令人沮丧,但挣扎并不意味着你的能力有问题;歇一歇,重读,做做例子。第二段题词,出自勒古恩(Le Guin),给这份劝告提供了节奏(“当行动变得无益时,就去收集信息……”)。这一节随后去寻找程序究竟是什么的定义,承认类比总有不足,暂且落在机器的比较上——许多部件,彼此相连——然后给出本章被引用最多的表述:程序是“一座思想的建筑”,没有成本,没有重量,在我们手下轻易地生长,也同样轻易地变得复杂。于是有了统领全书的论点:“编程的技艺,是构建不让程序员困惑的程序的技艺。”这一乐章以有意转向一个对立的立场收束。有些程序员靠把自己限制在“最佳实践”和一个安全区里来管理复杂性;作者拒绝这种做法,说它既无聊又无效,坚持新问题需要新解法,你应该至少犯一次设计错误才能理解它们,而品味是“靠实践培养出来的,而不是从一份规则清单里学到的。”
展开,第二乐章:同一个程序,爬上一架抽象的梯子。“为什么语言重要”通过一个单一的演示,把本章抽象的主张变得具体,这个演示在五个层级上反复进行。它从最底层开始——在一台假想的机器上,用九行二进制把 1 到 10 加起来——并配上对开关和穿孔纸板的提醒,以及一句苦笑的让步:输入那些比特图案至少曾赐予人“一种自己是强大巫师的深切感觉”。同一个程序随后被改写成关于内存位置的、带编号的英文指令;再用名字(total、count、compare)和标签来写,这时作者停下来把逻辑走了一遍,解释说那个别扭的 compare 变量之所以存在,只因为原始的机器除了判断一个数是不是零,什么也测不了。然后是 JavaScript:一个六行的 while 循环,其中手动的跳转消失了,与零比较的花招也不再需要——“编程语言……可以替我们处理那些无趣的细节。”最后,假定有假想的 range 和 sum 操作,程序坍缩成一行,读起来几乎像英语。寓意被明说了:同一个程序可以被表达得晦涩,也可以被表达得易读,而一门好语言让程序员能在更高的层面上说话——略去细节,提供构件,让你定义自己的构件,并让它们易于组合。这一节是本章的引擎:它把“语言重要”从一句断言,变成了读者亲眼看着发生的事。
转折:从一般的编程,转到这门具体的、不完美的语言。“什么是 JavaScript?”从原理转向偶然与历史。JavaScript 于 1995 年出现在 Netscape Navigator 里,扩散到每一个主流浏览器,并使交互式的 Web 应用成为可能。作者指出,这个名字几乎与 Java 没有关系,选它是出于营销考虑,而不是出于良好的判断——“现在我们被这个名字套住了。”在 Ecma 下的标准化产生了 ECMAScript,它不过是同一门语言的另一个名字。接着是本章最坦率的一段:人们对 JavaScript 说了很难听的话,而其中许多是真的;作者本人起初就鄙视它,因为它几乎什么都接受,却把它解释得与你的本意不同。他诚实地把责任分摊给他自己的缺乏经验和一个真实的设计缺陷——这门语言的宽松本意是帮助初学者,却多半使 bug 更难找。让步随后得到平衡:同样的灵活性允许了僵硬的语言所禁止的技巧,造就一种随意而愉快的风格,而在认真学会它之后,他慢慢喜欢上了它。接下来是一份压缩的版本史(ES3 的漫长统治,在政治上不可能的第4版于 2008 年被放弃,2009 年温和的第5版,2015 年重大的第6版,此后每年的小更新),并附带实用的说明:旧程序仍能工作,较老的浏览器可能滞后,而本书使用 2024 年的版本。最后,范围越过浏览器扩大——MongoDB、CouchDB,尤其是 Node.js——使 JavaScript 不被呈现成只用于 Web 的工具。
收束:实用的说明、一张地图,和一次握手。最后三节是杂务,而且它们有意地从论证降到后勤。“代码,以及怎么处理它”坚持认为阅读和编写代码不可或缺:别对着例子一扫而过,在你真正写出一个能运行的解答之前别以为理解了一道习题,并且在真正的解释器里运行一切,这样才能得到反馈,也才会被诱惑去做实验。它指向在线版——那里的例子可以点击、可以编辑——和带有起始代码与解答的习题页,同时警告说,后面的章节依赖特定的环境,也依赖按章打包成 ZIP 提供的附带文件。“本书概览”随后把版图分成三部分摆出来——十二章讲语言,七章讲浏览器,两章讲 Node.js——外加五个项目章,并对逐章做了概述,其中包括送货机器人、编程语言的实现、平台游戏和像素画程序。“排版约定”解释了等宽字体,展示了一个 factorial 函数,并演示了表示预期输出的 // → 记法——读者已经在 55 的例子里见过两次的同一种记法,现在被正式命名。本章结束于一句简短的“祝你好运!”,它合上了前面那些坦率的警告所开启的圈:困难从未被否认,而读者无论如何被送上前去。
关键论点,以及支撑它们的原文
编程本来就乏味而令人沮丧,却值得去做,因为它能让你让一台机器做它本来做不到的事。
作者的推理建立在计算机是什么之上:它们是照字面行事、迂腐的设备,只做直截了当的事,所以指令必须以痛苦的精确来写明。历史把代价说得具体——早期程序员拨一排排开关,或者打孔纸板,所以连简单的程序都要求巧思,复杂的几乎无法设想。与此相对,机器执行那些简单步骤的速度如此之快,以致一个程序几秒钟就能完成手工永远不会去做的事。
固定的最佳实践清单,是对付复杂性的糟糕防御;好的判断靠实践、靠你自己犯设计错误来建立。
在确立了复杂性是核心困难之后,作者转而对付那个显而易见的补救办法。有些程序员把自己限制在一小撮被充分理解的技术和关于程序形式的严格规则里。他论证说这既无聊又无效,因为这个领域年轻、变化快、五花八门,足以容纳非常不同的有效做法,而新问题要求新的解法。他补充说,你应该把那些经典的设计错误至少犯一次,才能理解它们——靶子是僵硬的规则手册,而不是谨慎或自律。
同一个程序,可以写成读不懂的噪音,也可以写成接近英语的东西,所以你用的语言决定了你的代码能有多易懂。
本章让一个任务——把 1 到 10 加起来——沿着一架梯子走下来。在原始二进制里,它是九行看不出意义的比特。用英文指令写出来,它可读,却仍然晦涩,满是内存位置的编号。把这些位置命名为 total、count 和 compare,逻辑就显出来了。在 JavaScript 里,一个 while 循环去掉了手动跳转和与零比较的花招。最后,有了 range 和 sum,整件事就是一行读起来像句子的话——不过本章指出,这两个操作要到后面才有定义。
另外 1 个论点
JavaScript 的极度宽松既是一个真实的缺陷,同时也是它魅力的来源。
作者拿自己当个案。头一回被逼着写 JavaScript 时,他鄙视它:它接受他敲进去的任何东西,却把它解释成与他本意截然不同的东西。他承认其中一部分是他自己缺乏经验,却坚持认为确有一个真实的问题,因为这门语言几乎什么也不提示。把这一点倒过来,他说同样的宽松给僵硬的语言所禁止的技巧留出了空间,并支撑一种随意而愉快的风格——所以在认真学会它之后,他慢慢喜欢上了它。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
程序的增长催生它自己的复杂性
程序造起来不花成本,敲着敲着就轻易地膨胀,所以它不断增长;每添一个部件都与其他部件相互作用,简单的基础规则堆积成需要自己那套规则的结构。结果是程序员亲手造出的、又会在其中迷路的迷宫,这就是为什么真正的技艺是写出始终可理解的程序。
机制 2
更高层的构造吸收了无趣的细节
原始的机器逼你把每一步都写出来:存一个临时值,做减法,与零比较,跳回某个行号。提供循环结构的语言自己处理跳转和比较,而自制的构件能把整个计算折叠进一次调用。每一层都去掉从来不属于真正问题的记账工作,所以剩下的读起来更贴近意图。
机制 3
宽容隐藏了错误,而不是缓解错误
JavaScript 被设计成几乎接受初学者敲进去的任何东西,而不是拒绝它。因为什么都不拒绝,敲错的或糊涂的代码照样运行,只是意思不同于作者本意。所以系统从不指向问题,定位问题的工作就完全转到了程序员身上,使调试变得更难,而不是更容易。
常见误读
既然复杂性是主要的危险,稳妥的做法就是采用一套严格的最佳实践规则,并待在里面。
作者把品味当作长出来的,而不是颁发的:对一个好程序是什么样子的感觉来自实践,而这实践的一部分,就是犯设计错误并理解它们。由于编程仍然年轻而多样,新问题往往需要没有任何现成规则手册预见到的解法,所以一个自己划定的安全区,限制你多过保护你。
一门几乎接受你敲进去的任何东西的语言更友好,所以初学者用它更好。
那是这套设计背后的本意,但作者讲的是实践中相反的效果:什么都不拒绝时,错的代码照样运行,悄悄地意味着别的东西,而系统从不告诉你麻烦在哪里。这份宽松真正的好处在别处——在它所允许的不寻常的技巧和随意的风格上——而不在于让新手免于困难。
导言的核心概念
- 程序
- 一段精确指令的文本,它同时也是指挥计算机的力量,以及计算机内存里的数据。
- 适用处: 与熟悉事物的类比只是部分贴合;与机器的比较只是表面上贴合。
- 复杂性
- 简单的规则堆积成连作者自己都被弄糊涂的程序的那种方式。
- 适用处: 无法靠固定的最佳实践规则消除;品味是靠实践培养出来的。
- 抽象
- 让语言或你自己的构件去处理细节,这样你就能在更高的层面上谈论动作。
- 适用处: 只有当被省略的细节对这个任务来说真的无趣时才有用。
另外 0 个概念
读后感AI 陪读
一篇不愿显得欢迎的导言,把信用花在了诚实上
一篇格外愿意显得不受欢迎的导言:它开篇就讲乏味和挫败,承认作者曾经鄙视他即将要教的这门语言,并拒绝递上读者最想要的最佳实践清单。把它维系在一起的,是一个用两种口吻陈述的论点——复杂性是自己制造的,唯一真正的防御是对人的头脑的可理解。从比特到英语、到 while、到 sum(range()) 的那架梯子,就是这个论证的证明:每一级去掉的不是工作,而是噪音,使注意力能升到上一层。与此相对的,是诚实的摩擦:反对规则的立场很容易被误读为对混乱的许可,而对 JavaScript 宽容的叙述悄悄承认了,这本书所选的语言在结构上就不擅长教学,因为它吞掉错误,而不是指出它们。本章的补偿是动机上的,而不是技术上的——去运行代码,别相信你自己的理解感,歇一歇,而不是对自己下一个裁决。归根结底,这是一个自洽的立场:它把读者当作一个会困惑的人,假定这份困惑并不使人失格,并把自己的信用花在把这话大声说出来上。
在 FeedLitra 里读导言,每段旁边都有这些卡片。
开始读书第 1 章:值、类型与运算符
下一章里,表达式是如何变成完整程序的?
第 1 章是怎样展开的AI 辅助
开篇:从比特到值。本章以虚构的 Yuan-Ma 大师的一段题词开头,定下沉思的调子——表面之下的程序,屏幕不过是涟漪——随后立刻把这份情绪翻译成一个技术主张:在计算机的世界里只有数据,而一切数据都是比特。作者用一个具体的演示来落实它,把 13 写成二进制 00001101,并在底下标出数位的权,让读者看到二进制不过是换了个基数的十进制。从这里起,文字把镜头拉远到规模——一台现代机器拥有超过 1000 亿比特的工作内存——并提出本章的组织性难题:怎样在一片比特的海洋里工作而不被淹没?答案引入了本章标题里的概念。比特被归成值,而每个值都有一个决定其角色的类型。一段迷人的旁白指出,创建一个值只要调用它的名字,不花更多工夫,不过值确实占内存,不再使用时会被回收——对垃圾回收的头一次温和暗示。作者随后明说了计划:本章其余部分讲简单的值类型,以及作用于它们的运算符。
展开:类型逐个登场,各带着自己的但书。正文逐个类型地推进,并浮现出一种模式——每种类型都先被简单地引入,随后被一个限制弄复杂。数字最先:JavaScript 每个数字用 64 位,产生约 1800 亿亿种模式,但作者立刻连着两次对此作了限定,因为符号位和小数点的位置要吃掉一部分范围,留下大约 9 千万亿作为可靠的整数上限。这引出本章最实用的警告:在这个范围内的整数算术是精确的,而小数算术则不是,所以小数应被当作近似值。算术运算符随后登场(+、*、-、/、%),优先级被解释了,又被有意淡化——“拿不准的时候,加括号就是了。”数字类型的怪癖为这一节收尾:Infinity、-Infinity 和 NaN,作者提醒说基于无穷大的计算在数学上并不站得住,并很快滑进 NaN。字符串接着来,形状相同——先简单、后复杂:三种引号风格,用反斜杠转义,把文本表示成数字的 Unicode 基础,以及 16 位的字符串元素覆盖不了整个 Unicode、所以 emoji 占两个位置的复杂情况——这一点推迟到第5章。这一节以模板字面量这一令人愉快的能力收尾,它可以跨行,并通过 ${} 嵌入计算出来的值。一段短小的插曲用 typeof 区分一元与二元运算符,并引入 console.log 作为本书展示结果的手段。布尔值随后到来,接着是比较运算符,带着另一个关于次序的意外——字符串比较只是大致按字母序,因为大写排在小写之下——以及那个令人难忘的事实:NaN 是唯一不等于它自己的值。逻辑运算符 &&、|| 和 ! 补全了这一组,它们的优先级顺序被辩护说,这样排能让典型表达式里的括号最少,而三元条件运算符为这一节收口。对 null 和 undefined 的简短处理,把它们呈现为表示“没有”的两种方式,其区别是“JavaScript 设计里的一个意外”,最好当作可互换的。
转折:类型转换,以及从描述转向建议。本章在“自动类型转换”处转向,口吻明显变了。到此为止,作者一直在描述一个系统;现在他在对一个系统发出警告。这一节开头是一串古怪的表达式——8 * null 是 0,“5” - 1 是 4,但 “5” + 1 却是 “51”——它们被解释为类型转换,所依据的规则“往往并不是你想要或期待的”。== 运算符被单独点名为最坏的祸首,还有个特例:null 和 undefined 彼此相等,却不等于其他任何东西,作者指出这其实对测试一个值是否存在很有用。这直接导向一条建议:防御性地使用 === 和 !==。最后一节把这个主题延伸到 && 和 ||,揭示它们根本不返回布尔值,而是返回其原始操作数之一,这使 || 可以当作默认值的兜底——而 ?? 提供了一个更精确的版本,只在遇到 null 或 undefined 时才兜底。这一节以短路求值收尾:在 true || X 里,X 根本不会被求值,条件运算符也有这一性质。
收束。总结是刻意谦逊的。它盘点了所涵盖的内容——四种值类型,以及作用于它们的二元、一元和三元运算符——然后以令人缴械的诚实说出本章自己的局限:这足够把 JavaScript 当袖珍计算器用,“但也没多少别的了”。最后一句指向第2章,在那里,这些孤立的表达式将被系在一起,成为真正的程序。所以,从结构上看,本章从物理(比特)走到词汇(值与类型),再走到语法(运算符),它反复出现的节奏,是先引入一种方便、再披露它的代价——会丢失精度的 64 位数字,装不下整个 Unicode 的 16 位字符串元素,以及一套迁就到作者最后竟建议读者退出其中一部分的类型系统。
关键论点,以及支撑它们的原文
计算机里存的一切都是同一种东西——比特——所以一个值的类型描述的是它扮演的角色,而不是存储上的物理差异。
作者展示了写成 00001101 的数字 13,其中权为 8、4、1 的数位加起来是 13,并论证任何一份离散的信息都能这样改写;一块块比特之所以成为数字、文本或函数,只因为有类型给它们分派了角色。
JavaScript 里的小数应被理解为近似值,而不是精确值。
作者从比特预算出发推理:64 位本可容纳约 1800 亿亿种模式,但符号和小数点位置要消耗其中一部分,把能精确存储的最大整数压到大约 9 千万亿。在这个门槛以下,整数算术保证精确,而像圆周率这样的数需要的位数比能容纳的多,会丢失精度——这只在特定情形下才是问题,但值得知道。
在先展示了 JavaScript 乐于接受马虎的类型混用之后,作者转过身来,建议防御性地使用 === 和 !==。
类型转换的例子是铺垫:8 null 得 0,“5” - 1 得 4,而 “5” + 1 得 “51”,“five” 2 得 NaN,而 false == 0、0 == false 和 “” == false 全都为 true。既然宽松的运算符遵循一本令人困惑的转换规则书,三字符的版本就不转换地进行比较,所以 “” === false 如预期为 false。但书:当你已经知道两边类型相同时,较短的运算符也没问题。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么 64 位给精确的数字封了顶
每个数字值都占固定的 64 位槽,其中一部分位要花在符号上和小数点的位置上。这就给数字位留下一个有限的预算,所以精确的整数在大约 9 千万亿处用完,而需要更多位数的小数,则存成最接近的可表示的近似值。
机制 2
运算符如何悄悄转换类型
当操作数的类型不对时,JavaScript 会转换它,而不是拒绝运行。因为减号只对数字起作用,“5” - 1 得 4,而加号先尝试字符串拼接,所以 “5” + 1 得 “51”;任何没有合理数字解读的东西都变成 NaN,而进一步的算术会一直返回 NaN。
机制 3
为什么 && 和 || 跳过它们的右边
这些运算符把左边的值转换成布尔值,只是为了决定怎么做,随后把原来的值之一原封不动地交还。一旦左边已经决定了结果,如 true || X 或 false && X,右边就根本不会被求值;条件运算符同样只运行它选中的那个分支。
常见误读
“== 只是检查两个值是否相同,所以只要比较看上去显然为真或显然为假,我就可以信它。”
类型不同时,== 先用作者称为复杂而令人困惑的规则转换一边,这就是 “” == false 和 0 == false 为什么为 true;null 和 undefined 是进一步的特例,彼此相等,却不等于 0。而有一个值永远不等于它自己:NaN,它属于数字类型,表示一次没有意义的计算,所以 NaN == NaN 为 false。当你想让比较的含义与它看上去的一样时,就用 ===。
“在一个值后面加 ||,是在这个值可能缺失时提供默认值的稳妥办法。”
|| 在左边转换成 false 时就回退,而 0、NaN 和空字符串都算 false——所以 0 || 100 返回 100,一个合法的零或空字符串会被悄悄替换掉。?? 运算符只在左边是 null 或 undefined 时才替换,作者指出这往往才是你真正想要的行为。
第 1 章的核心概念
- 值
- 一块被当作一份信息来对待的比特,带着一个说明其角色的类型。
- 适用处: 值并非不花成本:每个值都得被存储,不再用的会被回收。
- 类型转换
- JavaScript 悄悄地把一个值转换成运算符所需的类型。
- 适用处: 规则往往与预期不同;=== 和 !== 完全避免转换。
- 短路求值
- &&、||、?? 和 ?: 在左边已经决定了答案时,跳过对右边的求值。
- 适用处: 只适用于这几个运算符,不适用于普通的二元运算符。
- NaN
- 一个数字类型的值,代表一次没有产生任何有意义结果的计算。
- 适用处: 它是 JavaScript 里唯一不等于它自己的值。
另外 0 个概念
读后感AI 陪读
运算符不只是组合:它们决定什么被求值
一章从形而上学一路降到算术,却从来没有真正放下前者。真正的论点是:类型是强加在一片未加区分的比特之海上的人类虚构——这恰恰是 JavaScript 的类型转换规则那么令人不安的原因:它们暴露了这个虚构的接缝。哈弗贝克作为教师最强的直觉,是知道该拒绝什么。他把优先级表挥到一边,宣布 null 与 undefined 的区别是个意外,并一再用一种防御性的习惯(加括号、用 ===、把浮点数当近似值)来代替一套完整的规则。这既慷慨又诚实,也正是读者该保持警觉的地方:那些只用一个顺带的分句交代的警告——NaN != NaN,浮点数是近似值,+ 往字符串方向转换而 - 往数字方向转换——每一个都是会耗费几个小时的 bug 的种子。本章悄悄的转折出现在短路求值处,在那里,运算符不再只是组合值,而是开始决定到底哪些东西要被求值;表达式获得了一个时间维度,而总结里那种把它当作袖珍计算器的框架,在写下来的时候就已经过时了。
在 FeedLitra 里读第 1 章,每段旁边都有这些卡片。
开始读书第 2 章:程序结构
按第3章的许诺,var 和 let 究竟有什么不同?
第 2 章是怎样展开的AI 辅助
开篇:借来一套语法。本章从第1章停下的地方开始——只有值,而值是惰性的——并问:要把它们组装成能运行的东西,需要什么。第一步是把表达式定义为任何产生一个值的代码片段,然后作出一个承载本章大半分量的结构性观察:一个表达式可以坐在另一个表达式里面,就像从句坐在句子里面。这里的论证靠的是与人类语言的类比,而不是演示,其回报是一个关于表达经济性的主张。因为这种包含可以无限重复,没有内置的限制,所以一小批固定的运算符和形式,就足以写下任意复杂的计算。读者被递到手里的,是一套语法,而不是一张词汇表。
第一个转折:计算还不是做事。在开篇里把表达式能表达多少确立之后,作者立刻把这一点倒过来。语句被求值,其值随即被丢弃——所以单凭表达力,换不来任何可观察的东西。证据是刻意落空的:一个只有两行的程序,计算出数字 1 和值 true。它合法,它执行,而运行它不留下任何看得见的痕迹。本章在这里给出了一行代码值得写的真正标准:它必须改变自身之外的某样东西,要么通过打印输出,要么通过让一个名字重新指向别处。该防范的常见误读,恰是这个例子所戳破的那个——以为一行正确地计算了点什么的代码,就已经在做有用的工作了。
展开:记忆,然后是重复。对副作用的需要,自然引向那唯一使程序有状态的副作用,本章于是把绑定作为程序记忆的机制引入。这个框架很谨慎,值得保留:名字不是装着值的盒子,而更像一根抓住值、让它保持可达的触手。例子是路易吉(Luigi)欠的一笔债,先设成 140,然后在还了 35 美元之后,指向 105——名字还在,而它背后的值被换掉,这恰是被记住的状态的形状。(作者还指出,另有一些绑定形式,在能否被重新指向上规则不同,而一个从未被赋值的绑定得到 undefined。)从这里起,本章转向控制流,循环带着动机而不只是语法到来:给每个偶数各写一行输出,对寥寥几个值行得通,但推广到一千以下的每个偶数,这办法就整个垮了。一个带着计数器绑定来追踪进度的循环,只用一次就把同样的活干了。一千只是说明规模;原则是,编程的存在就是为了让工作变小。条件、break 与 continue,以及 switch 补全了这套本领,本章以命名和缩进的约定为这一段收尾——这些规则,机器是不在乎的。
属于人的转折:注释。这份漠不关心,在关于注释的一节里成了明确的主题,注释按定义就是计算机忽略的文本——单行用 //,块用 /* */。给出的理由是:代码装不下程序需要告诉读它的人的一切。例子是一个持有数字 11213 的绑定,配着一段块注释,解释选它背后的个人经历:这些信息代码永远透露不了,因为代码只能展示那个值,永远展示不了它的缘由。于是,命名约定、缩进和注释构成一个连贯的论证,对象是读者,而不是解释器。
收束:一层层拼起来的图画。总结把本章拉回成一幅单一的图画,分阶段搭起来——语句可以包含语句,语句包含表达式,表达式由更小的表达式构成——然后补上一个观察:执行不过是自上而下地跑过这个结构。条件和循环被点名为仅有的打乱这个顺序的东西。回顾涉及语句、表达式、控制流、绑定、环境和函数,随后是三道习题,留给读者的是一个结构模型,而不是一份要背的语法清单。
关键论点,以及支撑它们的原文
一小批表达式形式就能描述任意复杂度的计算,因为表达式可以彼此嵌套。
作者靠与人类语言的类比来论证:就像从句可以嵌在别的从句里,一个表达式可以包含更多表达式,而正是这种无界的包含,使任意复杂的计算得以写下。
在铺好表达式能表达多少之后,作者把这一点倒了过来:一条语句只有在它可见地改变了某样东西时,才值得写。
他展示了一个只有两行的程序,它计算出数字 1 和值 true。这是一个合法的、可执行的程序,可运行它却不产生任何可观察的东西,因为计算出来的值被直接丢弃了。
程序的记忆不是来自值自己能持久,而是来自抓住它们的有名字的绑定。
作者引入一个绑定,来追踪路易吉还欠多少钱:它被设成 140,还了 35 美元之后,同一个名字被指向 105。名字还在,而它背后的值被换掉,这正是记住状态所需要的。作者指出,另有一些绑定形式,在能否被重新指向上规则不同。
另外 3 个论点
循环之所以存在,是因为编程的意义是让工作变小,而不是变大。
靠手写一行输出来打印偶数,对少数几个值行得通,但作者把同样的办法推到 1000 以下的每个偶数,它就垮了;一个带计数器绑定追踪进度的循环,只用一次就把同样的活干了。一千这个数字只是用来说明规模。
之所以需要注释,是因为代码本身装不下程序应当向读它的人传达的一切。
作者给出一个持有数字 11213 的绑定,并附上一段块注释,解释选它背后的个人经历——这些信息,写得再好的代码也透露不了,因为代码只能展示那个值。
把本章合起来看:程序就是彼此嵌套、自上而下运行的语句和表达式,而条件和循环是仅有的打乱这个顺序的东西。
作者分层搭起这幅图——语句可以包含语句,语句包含表达式,表达式由更小的表达式构成——然后观察到,执行沿着这个结构一路向下,除非 if 或循环把它改道。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
嵌套成倍放大表达力
表达式是任何产生一个值的片段,而表达式可以像从句坐在句子里那样坐在另一个表达式里面。因为这种包含可以无限重复,一小批固定的运算符和形式,就足以描述任意复杂度的计算。
机制 2
只有副作用才使一条语句算数
语句被求值,其值随即被扔掉,除非这条语句改变了自身之外的某样东西。所以一个各行只是计算值的程序,运行正确却不留痕迹;有用来自打印输出或让绑定重新指向这样的动作,而不是来自计算。
机制 3
绑定是程序记忆的方式
值自己不会持久,所以语言提供一个抓住值、让它保持可达的名字。因为名字之后可以指向另一个值,程序就能把更新过的状态带到后面,而不是重新计算或丢掉它。
常见误读
只要一行代码正确地计算了点什么,程序就已经在做有用的工作了。
计算还不是做事。语句的值会被丢弃,除非这条语句以某种方式改变了世界,这就是为什么一个只对 1 和 !false 求值的程序运行得好好的,却什么可观察的事也没发生;输出,或者更新一个绑定,才让一行代码算数。
变量名是一只把值存放在里面的盒子,所以名字和值是同一回事。
名字更像一根抓住值的触手:它指向一个值,而且之后可以被弄成指向另一个,就像债务绑定从 140 变到 105,而名字原地不动。值并不住在名字里面——而且有些绑定形式根本不能被重新指向,一个从未被赋值的绑定得到 undefined。
第 2 章的核心概念
- 表达式
- 任何产生一个值的代码片段,而表达式可以嵌套在彼此里面。
- 适用处: 单独一个表达式不是程序;它需要一个语句的框架。
- 语句与副作用
- 语句是代码里的一个整句;只有当它以某种方式改变了世界,才有用。
- 适用处: 只计算并丢掉值的语句,不留任何可观察的痕迹。
- 绑定
- 一个抓住值的名字,使程序能记住它,并且可以重新挂接到新的值上。
- 适用处: const 绑定不能被重新指向;一个未初始化的绑定得到 undefined。
- 环境
- 某一时刻存活的所有绑定和值,包括系统提供的内置绑定。 当前存在、可以按名字使用的全部绑定的集合。
- 适用处: 内容因平台而异,例如浏览器特有的输入函数。 JavaScript 系统会预先填入标准绑定,所以它从不为空。
- 控制流
- 语句运行的顺序:直线向前,用 if/else 分支,或用循环重复。
- 适用处: 没有终止条件或 break 的循环会永远运行。
- 函数
- 包在一个值里的一段程序,可以带着参数被调用,也可能返回一个值。
- 适用处: 第2章只调用现成的函数;编写函数要等到第3章。
另外 2 个概念
- 注释
- 为人类读者留在程序里的文本,计算机则完全跳过它。
- 适用处: 只有两种形式:// 持续到行尾,/ / 可以跨越任意多的文本,包括换行。
- 函数调用
- 通过写出一个打包好的程序片段的名字,并把参数放在括号里,来调用它。
- 适用处: 调用是一个表达式,所以它位于更大的表达式里面,可能产生值,也可能不产生。
读后感AI 陪读
作者承认设计有缺陷,然后要你无论如何信他
第2章是本书不再给零件命名、开始造句子的地方,它真正的教学发生在隐喻里,而不是语法表里。绑定的触手意象是承重的那个——它在读者需要引用语义的好几年之前,就悄悄地预装了它——而“表达式像从句一样嵌套”,则为整个“语言即散文”的框架发了通行证。哈弗贝克之所以与众不同,在于他多么频繁地出戏来讲真话:分号的规则容易出错,var 的行为古怪,switch 的语法别扭而且是继承来的,幂的那个例子在真实代码里会很傻,FizzBuzz 什么也筛选不出来。这份坦率,为他那些要求信任而不是给出理由的地方买下了信用——从零开始数,永远用花括号——这些习惯只有靠后面的章节才能得到辩护。我感到贯穿其中的压力,是语句靠副作用来挣得自己的位置,与后来的函数式转向——那时副作用成了该避免的东西——之间的拉扯;在这里,修改和 console.log 就是存在的意思。而藏在注释里的兰波的诗行,是本章唯一一处有意的炫技:页面上最人性的文字,恰恰是机器丢掉的东西。
在 FeedLitra 里读第 2 章,每段旁边都有这些卡片。
开始读书第 3 章:函数
闭包所记住的环境,与调用者的环境有何不同?
第 3 章是怎样展开的AI 辅助
开篇:为什么我们需要新词。本章开头不是语法,而是一个规模的比较。一个说英语的成年人掌握着大约 2 万个词,而几乎没有哪门编程语言带着接近 2 万条内置命令——而它带着的那些还很僵硬。作者得出的结论是,程序必须能够自己造词汇,否则就会淹死在冗长里。函数正是作为这样的东西被引入的:一种把一段程序包进一个值、给它起个名字、此后用一个词说出否则要许多行才能说完的东西的办法。
展开:机制,依次摆开。从这里起,本章稳步走过各个机制。先是定义语法和 return,然后是作用域——全局的、局部的,以及让内层函数能看到它所写在其中的那个函数的绑定的词法嵌套。函数被显示为普通的值,这促成了声明记法和箭头记法的并列呈现。接着是调用栈,它既解释了一个函数怎么知道往哪里返回,也解释了嵌套调用为什么有代价;然后是可选参数;以及闭包。闭包是前面两条线索系在一起的地方:因为每次调用都为函数的参数和局部变量创建全新的实例,又因为内层函数按名字写在哪里而不是按谁调用它来解析名字,一个从调用里返回出来的函数,就一直指向那次调用自己的绑定。于是 wrapValue(1) 和 wrapValue(2) 永远各自独立地产出 1 和 2。递归为机制的这前半部分收尾,作者对它的价格很诚实:因为每个待返回的调用都必须把它的返回上下文堆在栈上,递归的 power 在典型的引擎里比 for 循环的版本慢大约三倍。他由此提炼出的实用规则不是“避免递归”,而是“先写清楚的版本,只在测量显示要紧的地方才优化”——并且他立刻指出例外:像 findSolution 这样的分支式搜索,用递归写确实更清楚。
转折:从怎么做,到什么时候做。本章从写函数转向决定何时把它们提出来,并通过一个演练的例子来做。农场库存的打印代码把补零的逻辑就地重复了好几遍,一加上猪就坏了。修法不是把重复的几行原样提出来,而是把它们底下的那个想法单独分离出来——一个返回补零后字符串、而不是打印一个字符串的函数——并给它起一个恰好这样说的名字:zeroPad。可复用性来自命名的纪律,而不是来自小聪明:像 printZeroPaddedWithLabel 这样的名字,本来就是几件不相干的活儿被捆在一起的警告信号。为了平衡这一点,作者插入了一条克制的原则:在确定需要之前,不要把通用性建进去,否则你就会把日子花在写你永远用不上的框架上,而不是干活。
收束:函数的种类,然后是回顾。最后一个概念性的动作,把为副作用而存在的函数,与产生值的函数区分开,并在后者里区分出纯函数。这个论证讲的是合适,而不是美德:因为 zeroPad 交回一个字符串,它能服务打印版本永远服务不了的用途——比如对齐一张数字表。这值得细读,因为本章并不是在谴责副作用。console.log 没有纯的版本,而它显然很有价值;有些操作靠副作用来做就是更有效率。它的主张是:你应该知道自己写的是哪一种函数。本章随后以常规的方式收尾,是对函数和作用域的小结,以及一组习题。
关键论点,以及支撑它们的原文
程序需要自己的新词,因为语言的内置命令太少、太僵硬,说不简洁。
作者以一个规模的比较开启第3章:一个典型的说英语的成年人带着大约 2 万个词,而几乎没有哪门编程语言带着 2 万条内置命令。没有造新词汇的能力,程序就会淹死在冗长里。
函数的局部绑定在每次调用时都是新创建的,所以每次调用都在它自己私有的环境里运行。
在确立了我们为什么定义函数之后,作者展示它们被传来传去时会发生什么:wrapValue(1) 和 wrapValue(2) 各自捕获同一个局部绑定的自己那一份,而返回的两个函数各自独立地继续产出 1 和 2。
先写正确而易读的代码;只有在某样东西被证明慢了之后,才去优化。
从调用的成本,作者得出一条工作规则。优雅的递归 power 函数在典型的 JavaScript 引擎里比循环版本慢大约三倍,这表明优雅可以带着真实的价格——不过他指出了例外:像 findSolution 这样的分支式搜索,用递归写确实更清楚,所以这条规则讲的是测量,而不是避免递归。
另外 2 个论点
在确定需要之前,不要往函数里加通用性或小聪明。
从怎么写函数转到什么时候提取它们,作者用后果来论证:如果你对每一行重复都用一个框架来回应,你就会把日子花在写你最后用不上的代码上,而不是干真正的活。这是一条克制的原则,不是对泛化的禁令。
返回值的函数,比只执行副作用的函数适合更多的情形。
重构打印代码时,作者把会打印的辅助函数与 zeroPad 作对比,后者交回一个补零后的字符串。因为它返回一个值,zeroPad 还能用于打印版本永远服务不了的事——比如排出整齐对齐的数字表。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
每次调用都有新绑定,使闭包成为可能
每次调用都为函数的参数和局部绑定创建全新的实例,而内层函数按名字写在哪里、而不是按谁调用它来解析名字。所以一个从调用里返回的函数仍然指向那次调用自己的绑定,这就是为什么 wrapValue(1) 和 wrapValue(2) 产生相互独立的结果。
机制 2
调用有成本,主张先求可读、后求速度
每个待返回的调用都必须把它的返回上下文堆在栈上,所以一串调用比普通的循环迭代花费更多——递归的 power 函数比 for 循环的版本慢大约三倍。作者由此得出实用的结论:先写清楚的版本,只在测量显示要紧的地方才为优化付出代价,而分支式的搜索无论如何用递归写更清楚。
机制 3
给一个想法命名,使辅助函数可复用
农场库存的代码把补零的逻辑重复了好几遍,一加上猪就坏了。把底下那个单一的想法——一个返回补零字符串、而不是打印一个的函数——抽出来,并给它一个显而易见的名字,就产生了一件远超原本用途都能用的东西,比如对齐数字表。
常见误读
既然返回值的函数和纯函数被呈现为更好的那一类,副作用就是该尽可能消除的缺陷。
纯粹是一种有用的性质,而不是一种要求:没有办法写出 console.log 的纯版本,而它显然仍很有价值,同时有些操作靠副作用来做就是更有效率。要点是知道自己写的是哪一种函数,而不是禁掉其中一种。
好的因式分解,就是发现重复的几行,并把它们原样提到一个函数里。
让这次重构奏效的,是分离出一个清晰的概念,并给它一个恰好说明它做什么的名字,就像 zeroPad;像 printZeroPaddedWithLabel 这样的名字,是几件不相干的任务被捆进一个函数的警告信号。
第 3 章的核心概念
- 词法作用域
- 一个块能看到哪些绑定,由它在程序文本里所处的位置决定,而不是由谁来调用它决定。 每个块创建一个新的作用域;其中的参数和绑定保持局部,而 var 落在最近的函数作用域或全局作用域里。
- 适用处: var 无视块作用域;let/const 则不然。 var 是块作用域的例外。
- 闭包
- 一个被返回的函数,保留着创建它的那次调用的那些特定的局部绑定。
- 适用处: 它看到的是创建时的环境,而不是调用时的环境。
- 调用栈
- 每个待返回的调用的返回上下文被堆叠其中的内存,使返回知道该回到哪里。
- 适用处: 有限的:过深或无限的递归会把栈撑爆。
- 可选参数
- 多余的参数被丢弃,缺失的变成 undefined,而 = 提供默认值。
- 适用处: 参数个数不对时不会报告任何错误。
- 纯函数
- 一种产生值的函数,没有副作用,也不依赖其他代码的副作用,所以同样的参数总给出同样的结果。
- 适用处: 并不总能做到,也并不总是值得追求;console.log 不可能是纯的,而且有些操作靠副作用更有效率。
- 单一概念提取
- 不是把重复的几行整体提出来,而是抽出一个清晰的想法,比如 zeroPad,并给它一个显而易见的名字。
- 适用处: 当存在一个可复用的概念时适用;名字不能像 printZeroPaddedWithLabel 那样把几件任务混在一起。
另外 0 个概念
读后感AI 陪读
当函数命名变得别扭,说明你背后的概念是糊涂的
一章表面上讲语法——写函数的三种方式——实际上讲的是判断,而且它自己清楚这一点。语法巡礼(表达式、声明、箭头)被迅速打发,带着不寻常的坦率:箭头函数的存在“没有什么深层理由”,提升被承认是一种概念上的虚构,var 被当作考古来处理。真正的分量落在后半部分,在那里,函数不再是一项语言特性,而成了一件思考工具。三条线索汇合得很好。作用域和闭包被从要背的规则,重新框成一个可携带的模型:一个函数值带着的是它出生时的环境,而不是它的调用点。递归得到一份诚实的成本核算——慢三倍,别拿它去做 power——这换来的信用,使它随后有资格论证分支式的搜索确实需要它;带着手工追踪的调用树的 findSolution,是本章最好的一段教学。而农场库存的走查,悄悄地提出了最深的主张:一个名字的别扭(printZeroPaddedWithLabel)是概念糊涂的诊断证据,抽出一个干净的想法(zeroPad)胜过仅仅删掉重复。这在讲副作用的一节里得到兑现:返回值的那个版本成了可复用的——这个论证一直在被搭起来。本章软化的是 JavaScript 对参数的宽松。悄悄忽略多余的参数、悄悄用 undefined 补上缺失的,被呈现为一笔带着巧妙的 minus 演示的、双面的交易,而它其实更接近一道设计上的伤口;默认参数作为一个整洁的修补出现,却没有承认它们只是盖在伤口上的补丁。尽管如此,统领全章的立场——先写出正确而易读的东西,以后再测量,只有被迫时才加小聪明——是用人的注意力的理由来论证的,而不是作为格言颁下来的,这就是它能落地的原因。
在 FeedLitra 里读第 3 章,每段旁边都有这些卡片。
开始读书第 4 章:数据结构:对象与数组
方法是怎样拿到它被调用于其上的那个值的?
第 4 章是怎样展开的AI 辅助
本章开头不是语法,而是一种需要。雅克想记一份日志,每一天同时记两样东西——他做了些什么的列表,以及那天夜里他有没有变成松鼠。作者的第一个观察是,没有任何孤零零的数字、字符串或布尔值装得下这个:原子值对描述真实的信息来说太单薄了。所以本章的主题是以一种需求、而不是一份特性清单的形式宣布的——这门语言需要某种把值捆在一起的办法,松鼠的问题才能被写下来,更不用说被回答。
展开:数组和对象原来是同一回事
由这个需要,本章搭起它的两种结构,而关键的一步是消去它们之间表面的区分。数组不被呈现为一个独立的值类别,而是专门用于序列的对象,所给的证明是一行代码:typeof [] 的求值结果是 “object”。按这种读法,索引不过是碰巧是数字的属性名。把这一点定下来后,属性和方法自然随之而来,然后是更难的想法:身份。因为属性抓住的是地址而不是包含一个值,名字持有的是引用,所以复制名字并不复制数据。作者用三个绑定演示——两个指向同一个对象,第三个指向另一个初始内容相同的独立对象。设 object1.value = 15,经第二个绑定读回来是 15,经第三个仍是 10,这说明这里的“相同”指的是指向同一个对象,而不只是看上去相像。沿途还挡住了两种误读:const 锁定的是名字所指向的是哪个对象,而不是这个对象持有什么,所以它的属性仍可随意改写;而字符串、数字和布尔值确实带着 length 这样的内置属性和大量方法——不行的是添加你自己的属性,它会被悄悄忽略,而不是报错。
转折:把数据塑形,直到统计变得容易
本章随后从描述结构转向使用一个结构,松鼠日志就在这里见到回报。每一天是一对是/否的回答,作者把它读成一个两位的二进制数,并直接拿来当数组的索引——于是这两个条件的每一种组合,恰好落进四个槽中的一个,phi 系数就成了普通的查表。公式被逐行翻译到那张四槽的表上;在 [76, 9, 4, 1] 上运行得出约 0.069,吃披萨与变身之间的联系可以忽略。作者对这种形状的代价很坦率:行总数和列总数没有存储,所以必须靠把表里的字段相加重新算出来。回报在同一件工具被用到一个合成事件上时到来——一个“花生牙齿”条目,加在恰好是吃了花生并且没刷牙的那些日子上。相关性回来是 1,完全一致,而这就把原因钉在了这个组合上,而不是任何单独的因素上。
收束:从一个故事到一套通用工具
松鼠的故事解决之后,本章拓宽成一次巡礼,看这门语言的其余部分为处理这类数据提供了什么:数组方法,字符串属性,剩余参数与展开,Math 命名空间,解构,以及可选链。关于 Math 的讨论悄悄重述了本章的核心机制——内置的辅助函数以单个对象的属性、而不是以各自独立的顶层名字存在,这使共享的顶层不被挤满,也让你自己的 max 能与内置的那个共存。收尾的话题 JSON,是身份那一课以实用约束的形式回来:因为属性抓住的是地址,内存里的结构其实是一张地址的网,不能原样交给文件或另一台机器。JSON.stringify 和 JSON.parse 把值压平成一个编码字符串,再把它读回来,限度在 JSON 的范围内——只有简单的数据表达式,属性名用双引号,没有函数调用、绑定或注释。于是本章结束于它开始的地方:值被持有的方式所带来的后果。
关键论点,以及支撑它们的原文
单个原子值太单薄,描述不了真实的信息,所以语言需要把值捆在一起的办法。
作者从雅克出发,他想为每一天同时记录他做过的事的列表,以及他有没有变成松鼠。作者说,这样的日志首先需要的,是一个能容纳这种成组信息的结构——没有任何孤零零的数字、字符串或布尔值装得下这个。
数组不是一个独立的值类别;它们是专门用来存储序列的对象。
作者指出一个一行的测试:对 typeof [] 求值得到 “object”。数组的索引不过是碰巧是数字的属性名,这就是为什么数组表现得像一种有数字命名约定的对象。
既然数组和对象是同一类东西,对象值就有身份:名字持有的是引用,所以复制名字并不复制数据。
两个绑定被弄成抓住同一个对象,而第三个被给了一个初始内容相同的另一个对象。设 object1.value = 15 之后,经第二个绑定读是 15,而经第三个仍是 10——这证明这里的“相同”指的是指向同一个对象,而不只是看上去相像。
另外 3 个论点
一旦数据被塑成一个选得好的数组,一条统计公式就变成了普通的代码。
作者把 phi 系数写成对公式在一张四槽表上的逐行直译,在 [76, 9, 4, 1] 上运行得约 0.069——吃披萨与变身之间的联系可以忽略。这种形状有一个作者坦率说出的代价:行总数和列总数没有存储,所以必须靠把表里的字段相加重新算出来。
变身原来有一个复合的原因:吃了花生而没刷牙,而不是其中任何单独一个因素。
用前面造好的相关性工具,作者给恰好两个条件都成立的那些日志日子加上一个合成的“花生牙齿”事件,然后测它与变身的相关性。结果是 1——完全一致——而这把原因钉在了这个组合上。
因为属性抓住的是地址而不是包含值,数据必须先转换成扁平格式,才能被保存或发往任何地方。
内存里的结构其实是一张地址的网,这意味着它们不能原样交给文件或另一台机器。作者提供了 JSON.stringify 和 JSON.parse,把值压平成编码字符串再读回来,限度在 JSON 的范围内:只有简单的数据表达式,属性名用双引号,没有函数调用、绑定或注释。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
共享的引用,共享的改变
属性抓住的是一个地址,而不是包含值本身。所以把一个对象绑定赋给另一个,两个名字就指向同一个对象,经由一个名字所做的任何改变,立刻能经由另一个看到。
机制 2
表的两位索引
日志里的每一天是一对是/否的回答,作者把它读成一个两位的二进制数。这个数被用作数组索引,于是两个条件的每一种组合恰好落进四个槽中的一个,公式就成了普通的查表。
机制 3
命名空间让名字保持自由
内置的辅助函数被存成单个对象的属性,而不是各自独立的顶层名字。因为共享的顶层保持不拥挤,你自己叫 max 的绑定可以与内置的那个共存,后者住在 Math 里面。
常见误读
用 const 声明一个对象,就让它的内容固定了,此后它的任何东西都改不了。
既然字符串、数字和布尔值不是对象,它们就一定完全没有属性或方法。
它们确实带着 length 这样的内置属性,外加许多方法;不行的是添加你自己的新属性,它会被悄悄忽略,而不是被当作错误报告。
第 4 章的核心概念
- 数组
- 用方括号写出的、从零开始索引的有序值列表。
- 适用处: 其实只是一个专门用于数字命名的属性的对象。
- 属性
- 值上一个有名字的槽,用点号或方括号访问。
- 适用处: null 和 undefined 没有属性;点记法要求名字像绑定名。
- 方法
- 值是函数的属性,在它所属的值上被调用。
- 适用处: 它怎样拿到自己的值,要到第6章才解释。
- 可变性
- 对象的内容可以随时间改变;数字、字符串和布尔值则不能。
- 适用处: const 固定的是绑定,而不是对象的内容。
- 身份
- 两个对象引用只有指向同一个对象时才相等。
- 适用处: 没有内置的按内容进行的深度比较。
- 相关性
- 一个从 -1 到 1 的数,说明两个被测量的变量一起变动得有多强。
- 适用处: 对两个布尔变量,它由一张四格频数表算出。
另外 5 个概念
- 序列化
- 把内存里的数据——其实是一张地址的网——转成可以存储或发送的扁平文本描述。
- 适用处: JSON 只允许简单的数据表达式:属性名用双引号,没有函数调用、绑定或注释。
- 解构
- 在绑定左边用数组方括号或对象花括号,把内部的值抽出来放进它们各自的名字里。
- 适用处: 对 null 或 undefined 解构会抛出错误,就像从它们身上读属性一样。
- 绑定
- 附在一个值上的名字;顶层的太多会挤满共享的命名空间。
- 适用处: JavaScript 对重复的 let/const 名字会发出警告,但对标准绑定或 var/函数的名字则不会。
- 剩余参数
- 写在函数最后一个参数前的三个点,把所有剩下的实参收集进一个数组。
- 适用处: 声明在剩余参数之前的参数,不包括在它的数组里。
- 原始值的不可变性
- 字符串、数字和布尔值会悄悄地拒绝新属性,因为它们不是对象。
- 适用处: 它们仍带着 length 这样的内置属性和许多方法。
读后感AI 陪读
为什么一只狼人松鼠让数据结构显得必要
第4章是本书第一次真正拉宽镜头:前三章花在值、控制流和函数上——那些放进一个表达式里就装得下的东西——现在它问的是,怎样一次拿住许多东西,并以对象和数组作答。这条弧线是有意设计的。它以一只狼人松鼠开场:雅克(Jacques),在不可预测的夜晚变成松鼠,他的办法是写日志。喜剧全在后勤上——锁好门窗,摆出核桃让那头动物有事可做——一个荒唐的前提,被当作维护问题来处理,而这恰是本章需要的口吻。因为日志才是要点:一份日子的列表,每一天带着一份事件的列表和一个布尔值,这是单靠数字和字符串无法表示的问题,所以数据结构是作为一种必需、而不是教学大纲上的一个条目到来的。
从这里起,本章靠搭建来教学。数组带着我所知的对从零索引最好的一句辩护——把索引看作要跳过的项数——它把索引从名字变成了距离,把差一错误的困惑从根上化解。对象被当作长着有名字触手的章鱼来讲,而这个比喻立刻就付了房租:delete 成了一根被砍断的触手——一个能延伸到下一个运算符里的比喻,是脚手架,而不是装饰。可变性与身份随后登场,带着关键的区分:属性是抓住它们的值,而不是包含它们——本章其余部分悄悄依赖的就是这一句。
继续阅读
松鼠调查随后成了一道真正的数据分析练习:一张扁平的四元素表,phi 系数,对日志里每一个事件算出的相关性。这里有两件事值得看透。第一,扁平数组是一个被坦白的取舍——用一个不能自我解释的结构,换来简短的访问表达式;table[3] 什么也没告诉你,而作者选了这样,他不是发现了这样。第二,更尖锐:答案只在雅克发明了一个合取——花生并且没刷牙——才到来,这是一个在看过数据之后才制造出来的变量。这是对交互效应的一个可爱的演示,单独哪一项都够不到 0.6,而这一对达到 1,而如实地说,它也是 p 值操纵(p-hacking)。本章没有标出这一点,我认为一个细心的读者应该看出来。
然后语气变了。雅克吃了一颗花生,变身最后一次,选择了松鼠那短暂而精力充沛的一生。一本编程教科书刚刚把它贯穿全章的笑话,收在这样一种悲伤上:把自己从那让你觉得活着的东西里治好。分析奏效了,而这成功就是损失。这本书能用两句话做到这一点,然后直接转入数组方法,是它被信任的原因之一。
后半部分是一场带着观点的参考式巡览。push/pop/shift/unshift,indexOf/lastIndexOf,slice,concat;字符串作为一个警示性的平行——你可以给它们设置属性,而 JavaScript 会不吭一声地拒绝存下它们,这一句就解释了一半的初学者调试;剩余参数与展开;Math 作为命名空间,随机性被坦率地描述为一个隐藏的值,由复杂的计算来搅动,其中“看上去随机”悄悄做了大量的工作。解构,可选链,最后是 JSON——而这个次序是本章最好的结构动作,因为“抓住而不是包含”现在被兑现成一条工程约束:引用是地址,地址在这个进程之外毫无意义,所以序列化必须把东西压平。那个替代办法被一本正经地提了出来(把你整个内存连同地址一起送过去),好让你感到其必要,而不是被告知。小结以拿地址换成手臂和袋子收尾,而那幅身体的图像,是一个月之后还留在那里的东西。
我要对读者说的是:本章真正的主题不是语法,而是表示——那个反复出现的问题:该给你的数据什么形状,以及反复出现的答案:这是一个带代价的选择。把那些取舍当作取舍来读。也请注意,JavaScript 那种迁就的沉默,多么常常是反派:没存下的字符串属性,var 无异议地重复声明、而 let 会拒绝——这一点作者只用一个“反常地”来标记,这是他那中立教师的声音唯一滑落的地方,而他把它花在了向后兼容上。还请注意,本章停不下来地绕着 null 打转,那个不是对象、却一直宣称自己是对象的东西,一直绕到结尾习题里的“愚蠢的例外”。连习题也带着论证:那对反转的函数问的是哪个变体更有用、哪个运行得更快,而这两个问题的答案相反——这是一堂关于纯粹的代价的课,穿着一道循环题的戏服。去做它们。直到你亲自选了一个结构并与它共处,本章的想法才会沉淀下来。
在 FeedLitra 里读第 4 章,每段旁边都有这些卡片。
开始读书第 5 章:高阶函数
什么时候,循环是比 filter-map-reduce 流水线更好的选择?
第 5 章是怎样展开的AI 辅助
开篇:为抽象辩护。本章以霍尔(Hoare)一段关于简单与简单的外表的题词开头,随后提出一个刻意挑衅的比较。导言里的两个程序都把 1 到 10 加起来:一个自成一体的六行循环,和一行代码 console.log(sum(range(1, 10))),它倚仗两个外部函数。把 sum 和 range 的定义也算上,第二个其实是更大的程序——可作者论证它更可能是正确的,因为它是用一套与问题相符的词汇来表述的。“对一个数的范围求和,讲的不是循环和计数器。它讲的是范围和求和。”那些附带的细节并没有消失;它们迁移进了表达更简单想法的小函数里,因而更容易写对。豌豆汤的类比把这个道理放到了代码之外:一份一步一步的菜谱(“把它切成块。用刀!”),对比一份简练的、假定你懂得泡、炖、切碎这类词的菜谱。后者的代价是词汇量稍大一点;回报是易读。这一节以本章真正的论点收尾——能察觉到自己正在一个过低的抽象层次上工作,是一项技能。
展开:从普通函数到高阶函数。普通函数能构建抽象,但“有时它们不够用”。演练的例子是“把某件事做 N 次”:repeatLog 把动作写死了,于是动作被提升为参数,得到 repeat(n, action)。既然函数只是值,函数体就可以作为箭头函数内联写出,文中指出,这读起来像一个循环体碰巧住在调用括号里的 for 循环。随后正式给出定义——接收或返回其他函数的函数是高阶的——接着是对它们能做什么的一个快速分类:创建函数(greaterThan),包装并修改函数(noisy,记录参数和返回值),甚至发明控制流(unless)。内置的 forEach 被拿来证明这门语言本身已经是这样工作的。
转折:抽象遇上真实的数据。在理论上把案子辩完之后,本章转向高阶函数“大放光彩”的地方——数据处理——并引入 SCRIPTS 数据集,里面有 140 种 Unicode 书写系统,每种带着名字、字符范围、方向、起源年份,以及存活/历史的标志。三种核心操作随后各被手写定义,再交还给标准库:filter(建一个新数组而不是修改,并被指出是纯的)、map(长度相同,形式新)、reduce(最棘手的,把一个数组折叠成一个值,方便的是起始值可以省略)。reduce 立刻被派上不平凡的用场——characterCount 靠在归约函数的参数列表里做解构,把一种文字的各个范围加总,第二个 reduce 则找出汉字是最大的文字,这引出一小段关于汉字统一的岔题,它“至今仍让一些人非常愤怒”。
一段公允的插曲:关于可组合性。作者没有把这项技术吹过头,而是把查找最大文字改写成一个普通的循环,并承认它“也没差多少”——只多了四行,而且仍然易读。他论证说,优势出现在组合之下:SCRIPTS.filter(s => s.living).map(s => s.year) 喂给 average,读起来是一条流水线,而与之等价的单个循环,则把正在计算的东西弄模糊了,也使把 average 提取出来变得别扭,因为中间结果不是自洽的值。诚实也延伸到性能上:流水线分配中间数组,而循环只是在加数字,所以对要处理许多次的巨大数组来说,不那么抽象的风格也许值得换那份速度。
应用与收束。最后的项目——检测一段文本用了哪些文字——迫使它绕道穿过一个真正的险处:characterScript(用 some)把一个码映射到一种文字,但要从 JavaScript 字符串里取出码,就意味着要直面 UTF-16。作者公开批评它,称它“如今一般被认为是个坏主意”,“几乎像是有意设计来招惹错误的”,并苦笑着指出,emoji 终于把这份痛苦分摊得公平了。length 和方括号索引看到的是码元;charCodeAt 给出半个马 emoji,而 codePointAt 给出真正的码;对字符串用 for/of 产出真正的字符。把这一点解决之后,countBy(建立在 find 之上)给字符分组,而 textScripts 把 countBy、filter、reduce、map 和 join 串成百分比——本章的整套工具被组合在一个函数里。小结重述了那个核心意象:高阶函数用“空缺”来给计算建模,由调用者用函数值去填,并盘点了 forEach、filter、map、reduce、some 和 find。随后四道习题要求读者自己重建这套机制——用 reduce 和 concat 压平,写一个带测试/更新/主体参数的 loop 函数,把 every 实现两次(一次用循环,一次借助 some),以及计算一段文本的主要书写方向,它重新组合了本章的 characterScript 和 countBy。
关键论点,以及支撑它们的原文
只要用与问题相符的词来写,一个程序即使总体更长,也可能更可能是正确的。
作者把一个把 1 加到 10 的六行循环,与单行的 console.log(sum(range(1, 10))) 作对比;把辅助函数的定义也算上,第二个其实更大,但它谈的是范围和求和,而不是计数器。豌豆汤的菜谱说明同一个道理:说“泡、炖、切碎”的那个版本,比一步一步的版本短得多也清楚得多,前提是读者必须已经懂得那些烹饪用词。
在此基础上,接收或返回函数的函数,让你能对动作做抽象,而不只是对值做抽象。
作者展示了 repeat(n, action),它把一个只能打日志的函数泛化了;greaterThan,它制造出一个新的测试函数;noisy,它用日志把一个现有函数包起来;以及 unless,它发明了一个控制结构。每一个之所以行得通,只因为动作本身是作为值传进去的。
这些操作在串起来时回报最大,因为每一级都留下一个自洽的中间值。
按是否存活筛选文字,映射成年份,再求平均,存活的文字得 1165,已死的得 204,从头到尾都能读成一条流水线。与之等价的单个循环只保留累计值,所以没有一个年份的数组可以交给一个可复用的 average 函数。
另外 2 个论点
从另一面看,这份可读性有真实的运行时成本。
作者指出,filter 和 map 在每一级都分配新的数组,而手写的循环只是更新一两个数字,并明确设下条件:你通常负担得起易读的版本,只有对要处理许多次的巨大数组,才该考虑更底层的风格。
UTF-16 是一个有缺陷的设计,实际上是在招惹字符串代码里的 bug。
一个有两个 emoji 的字符串,报告的 length 是 4;索引 0 得到一个破碎的半个字符;charCodeAt(0) 返回 55357,一个碎片,而 codePointAt(0) 返回真正的码 128052。用 for/of 循环——后来在考虑到这些问题的情况下加进来的——则交回完整的字符。作者指出,只要程序只遇到单单元字符,这类 bug 就一直是看不见的,这就是为什么 emoji 让这个问题成了每个人的问题。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么贴合问题的词汇能减少 bug
把任务拆成有名字的操作,意味着每一块表达的都是比整个程序更简单的想法,所以每一块都更容易写对。循环和计数器仍然存在,但它们被藏在小的、可以各自验证的定义里面,让顶层代码只说明正在计算什么。
机制 2
作为值的函数填上计算中的空缺
既然函数是普通的值,一个例程就可以写成带一个洞,由调用者把缺失的动作作为参数提供。这就是一个打日志的循环变成通用的重复的办法,也是 filter、map 和 reduce 把同一副遍历骨架用于无穷无尽不同判断的办法。
机制 3
码元如何搞坏字符处理
JavaScript 字符串存储的是 16 位的单元,常见的字符占一个,其他的需要一对。因为 length 和方括号索引数的是单元而不是字符,悄悄假定每个字符一个单元的代码,在许多语言里看上去是对的,然后在 emoji 或较罕见的汉字上失败。
常见误读
高阶函数是一项高级的、异乎寻常的语言特性,留给聪明的代码用。
在 JavaScript 里,函数不过是另一种值,所以把一个函数交给函数、或返回一个函数,并不稀奇;作者指出这个名字来自数学,那里函数和其他值被分得更严格。
像 filter 这样的数组辅助函数,是通过从你给它们的数组里删掉不要的元素来工作的。
第 5 章的核心概念
- 抽象
- 一套有名字的操作构成的词汇,使你能谈论一个问题而不必带上附带的步骤。
- 适用处: 只有当这些名字表达的是比整个程序更简单的概念时才有用;抽象得太早或太低,只会添噪音。
- 高阶函数
- 接收其他函数作为参数、或返回它们的函数。
- 适用处: 在 JavaScript 里并无异乎寻常之处,因为函数是普通的值;这个术语借自数学。
- 码元
- JavaScript 字符串里的一个 16 位片段;有些字符需要两个。
- 适用处: length 和方括号数的是码元,而不是字符;for/of 和 codePointAt 才能触及真正的字符。
- 纯函数
- 返回一个新结果、而不修改传给它的东西的函数。
- 适用处: filter 是纯的;它建一个新数组,而不是从原数组里删东西。
另外 0 个概念
读后感AI 陪读
一个错误的抽象之所以看起来对,是因为受影响的人不是程序员
本章宣称的论点——把函数传来传去,得到词汇——是其中最无趣的东西。真正支撑这篇文字的,是对抽象一再进行的、不带感情的标价:你付出的,是读者必须学的词汇,是机器必须分配的中间数组,是 countBy 自己的实现是一个为了易读而不是为了质量而选的、会修改数据的二次方扫描。哈弗贝克一直让这些代价露出来,而不是把函数式风格当作免费的来推销,而正是这份克制,使论证可信。不过,真正的重心是 UTF-16 那一节,它悄悄地属于另一本书。在那里,这一课不再关乎优雅,而关乎可供性与公正:length 和 [0] 错得看起来对,而且二十年来一直看起来对,恰恰因为写程序的人不是那些字符被弄坏的人。汉字统一把三种书写传统压进一套文字里以省下码位;是 emoji 终于把 UTF-16 的代价推给了默认的受众,而只有到那时,codePointAt 和 for/of 才显得紧迫。这是本章隐藏的主题——抽象不是对现实的中立概括,而是关于谁的细节可以算作“附带的”的决定。随后习题把你拽回压平和 every,这没问题,但在那之后,有点泄气。
在 FeedLitra 里读第 5 章,每段旁边都有这些卡片。
开始读书第 6 章:对象的秘密生活
基于符号的迭代接口,如何让你自己的对象也能用在 for/of 里?
第 6 章是怎样展开的AI 辅助
开篇:为边界辩护。本章不是以语法开头,而是以对整个事业的辩护开头。作者伸手去拿一件家用电器——一台搅拌机,它把材料科学和电气工程藏在一个光滑的壳底下,只给用户几个旋钮——来确立本章的组织性主张:一个被划分成边界严格的对象类型的程序,能让它的各部分不缠在一起,这样任何一块都可以被修复或复用,而不打扰其余的部分。这个论证随后落在读者已经信任的东西上:JavaScript 自己的数组和字符串,就是这一模式的现成证明——内部藏在一个小小的接口背后,而这种隐藏,恰是它们能完好无损地从一个程序走到另一个程序的原因。动机就位之后,本章点明它的工作单位——抽象数据类型,一种暴露有限行为、而让内部保持封装的接口——然后才开始交出机制。
第一段展开:方法、this 与原型。机制按依赖的顺序到来。方法最先,并带来本章第一个真正的微妙之处:一个普通函数得到它自己的 this,在调用的那一刻、由函数是怎样被调用的来绑定,而不是由它的文本恰好坐在哪里来绑定。其后果是那个经典的伤口——把一个方法当作回调递出去,它就丢了写在它旁边的那个对象。箭头函数恰恰被作为补救引入,因为它们不声明自己的 this,而一直看着外围方法的对象,这就是为什么传给 find 的箭头回调仍然表现正常。原型随后作为共享的机制登场。一个对象链接到一个原型,对象自己缺的属性会沿着这条链去找,直到链的尽头;因为自有属性先被查询,在一个实例上赋值就会创建一个自有属性,把原型的那一份遮住。本章用兔子把这条规则变成设计规则:一只“原型兔”持有每只兔子共享的说话行为,而一只被赋予了自己那一副牙齿的杀手兔,只改变它自己——新造出来的兔子和原型仍然报告默认值。共享的默认值与每个实例的差异并存,而原因是查找的顺序。
转入记法的转折,以及从同一条规则里引出的警告。类语法是作为方便、而不是革命登场的,本章在这一点上很谨慎:class 产出一个构造函数,外加它背后共享的原型,这种记法直到 2015 年才到来,而一个用 new 调用的普通函数,也干同样的活。原型仍是底层。围绕这种记法,本章收集了实用的家什——私有的 # 属性、覆盖、getter、setter、静态成员和多态。然后是本章最锐利的反转,而这是对读者自己信心的反转:让共享变得优雅的同一套原型查找,使一个普通对象不能安全地充当查找表。年龄的例子把一个真实的人报告为未知,却愉快地声称“知道” toString,因为命中来自 Object.prototype,而根本不是来自数据。给出的补救——一个真正的 Map、一个没有原型的对象,或一次显式的自有属性检查——不如那个教训有趣:方便与 bug,可以是从两个角度看的同一个特性。
第二个转折:符号,以及它们解锁了什么。符号是通过一个冲突、而不是一条定义引入的。一条用米来度量长度的路线,不可能同时遵守数组式的 length 含义,所以没有任何建立在字符串名字上的方案,能同时服务两个接口;符号作为唯一的值而存在,却仍能充当属性名,让一个协议占一个与任何东西都不冲突的槽。这是本章后半部分摆动所围绕的铰链,因为它解释了为什么迭代挂在 Symbol.iterator 上,而不是一个普通的词。可迭代/迭代器接口随后先用手来教——从字符串上取下 Symbol.iterator,调用得到的迭代器,直到它报告自己完成——然后才被正式地建起来:一个独立的迭代器类,追踪自己沿着链表走了多远,这就是让自定义的 List 与 for/of 和展开语法合作所需要的全部。值得提一句,以防一种自然的矫枉过正:只有 Symbol.iterator 是符号,而 next、value 和 done 仍是普通字符串。符号只花在确有可能冲突的地方。
收束:继承,带着保留。继承排在最后,而且带着限定。extends、super 和 instanceof 被完整地呈现,包括一个由前面一切推出来的机械要点:子类实例所继承的状态,要等到超类的构造函数运行过才存在,所以 super 必须排在最前——在 length-list 的构造函数里写 this.length,会在私有的 #length 字段仍未填好时触发类自己的 getter。随后本章拒绝以庆祝收尾。颠倒面向对象美德的惯常排序,作者论证说,继承比封装和多态更应受到怀疑,各以它对耦合的作用来评判:后两者让各部分彼此保持不知情,而从一个类继承,往往逼你比单纯使用它更深地去理解它的内部。继承作为派生现有类型的变体的一种紧凑办法,得到了它应得的认可——但它把代码绑在一起,而不是拉开,而对原型、类、封装和多态的小结,就收在这个刻意令人不舒服的音符上。
关键论点,以及支撑它们的原文
把程序拆成边界严格的对象类型,能让各部分不缠在一起,这样一块可以被修复或复用,而不打扰其他的。
作者开篇的类比是一件电器:搅拌机把材料科学和电气工程埋在一个光滑的壳底下,只给用户几个旋钮。JavaScript 自己的数组和字符串就是现成的证明——内部藏在一个小接口背后,这正是它们能在一个又一个程序里被复用的原因。
原型应当持有一种类型的每个实例所共享的东西,而不同的东西则属于各个单独的对象。
一只“原型兔”充当共享的说话行为的容器,所以由它造出的黑兔只带着自己的类型,同时借用那个方法。给某一只杀手兔换上一副不同的牙齿,只改变那一只:新造出来的兔子和共享的原型仍然报告默认值。
在同一条查找规则的基础上,用普通对象充当查找表是不安全的,而本章给出的补救是 Map、一个没有原型的对象,或一次显式的自有属性检查。
作者的年龄表把一个真实的人报告为未知,却声称“知道” toString,因为它实际找到的东西来自 Object.prototype,而不是来自数据。一个真正的 Map 把键与继承来的属性分开,所以这种混淆不会出现。
另外 3 个论点
既然两个不同的接口可能想要同一个字符串属性名,语言加入了符号,使一个协议可以占一个与任何东西都不冲突的属性名。
作者通过一个冲突来推理:一条用米来度量长度的路线,不可能同时遵守数组式的 length 含义,所以没有哪一种字符串命名方案能同时服务两个接口。符号是唯一的值,却仍然能当属性名用,这就是迭代为什么挂在 Symbol.iterator 上,而不是一个普通的词。
由此推下去,任何实现了迭代器协议的自定义数据结构,都变得可以用于这门语言普通的循环和展开语法。
作者先用手来驱动这套机制,从一个字符串上取下 Symbol.iterator,并调用得到的迭代器,直到它报告完成。然后他写了一个独立的迭代器类,追踪自己沿着链表走了多远,这就足以让这个链表能用在 for/of 循环里,也能用于展开语法。
把对面向对象的惯常称赞掉过头来:继承比封装和多态更应受到怀疑,因为它把代码绑在一起,而不是拉开。
作者的论证讲的是每件工具对耦合的作用:封装和多态让各部分彼此保持不知情,而从一个类继承,通常逼你比单纯使用它更深地去理解它的内部运作。他仍承认,它是在你已有的类型之上造出变体的一种紧凑办法。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么方法的 this 随调用而变
每个普通函数都有它自己的 this,在调用时由它被怎样调用来绑定,而不是由代码坐在哪里来绑定,所以一个交给另一个函数的回调,会丢掉写在它旁边的那个对象。箭头函数不声明自己的 this,所以它们一直看着外围方法的对象,这就是为什么 find 里面的箭头回调仍然行得通。
机制 2
原型查找与自有属性的遮蔽
一个对象链接到一个原型,对象自己缺的属性会沿着这条链去找,直到链的尽头。因为对象自己的属性先被查询,在一个实例上赋值会创建一个自有属性,把原型的版本遮住,而别的实例则不受影响——共享的默认值和每个实例的差异就是这样并存的。
机制 3
为什么 super 必须在使用 this 之前运行
子类实例的超类状态,要等到超类构造函数运行过才存在,所以在调用 super 之前碰继承来的数据,读到的是未填的东西。在 length-list 的例子里,在构造函数里写 this.length,会在私有的 #length 字段仍然为空时触发类自己的 getter,这就是为什么超类构造函数和 super.something 的查找要排在最前。
常见误读
以为 JavaScript 里的 class 是自成一体的对象系统,与原型分开并取代了原型。
类记法是一种速记,产生一个构造函数,外加它背后共享的原型,所以原型仍是底层的机制;这种记法直到 2015 年才到来,而一个普通函数,用 new 调用时,也可以充当构造函数。
因为迭代是用符号定义的,所以迭代协议里的每个名字都必须是符号。
只有 Symbol.iterator 用了符号;next、value 和 done 是普通的字符串属性名。符号只留给那个确有可能与其他接口冲突的位置。
第 6 章的核心概念
- 抽象数据类型
- 一段子程序,其复杂的内部保持隐藏,只提供一小组方法和属性供使用。
- 适用处: 执着于把单个对象当作单位,可能会误导,因为有用的功能往往横跨几个相互合作的类。
- 接口
- 外部代码被允许对一个值执行的操作的集合;其余一切都是内部的。
- 适用处: 两个接口可能想要同一个属性名,而字符串解决不了这个问题。
- 原型
- 另一个对象所链接的对象,于是缺失属性的查找会落到它那里去。
- 适用处: 链在一个没有原型的对象处终止,而自有属性会遮住原型的属性。
- 类
- 把一个构造函数与共享方法打包在一起的声明,定义一类对象的形状。
- 适用处: 构造函数必须用 new 调用;class 直到 2015 年才被加入,任何函数都可以充当构造函数。
- 多态
- 按预期接口来写代码,使任何支持该接口的值都能插进去。 按某个接口写的代码,能与任何实现了该接口的对象一起工作。
- 适用处: 只有当值真正支持整个预期接口——比如 length 加上带编号的属性——时才行得通。 要求这些对象确实提供了约定的接口。
- 符号
- 一个可用作属性名的唯一值,使各个接口永远不会在名字上冲突。
- 适用处: 那个描述性的字符串只是为了辨认;几个符号可以共用一个名字。
另外 4 个概念
- 私有属性
- 一个用 # 标记的类成员,只有类声明内部的代码才能访问。
- 适用处: 私有的实例属性必须在类体里声明;单是赋值不够。
- 可迭代接口
- 一个对象若有一个返回迭代器对象的 Symbol.iterator 方法,就算是可迭代的。
- 适用处: 只有 Symbol.iterator 是符号;next、value 和 done 是普通字符串。
- 继承
- 从一个现有的类派生出一个新类,使它复用并覆盖其一部分行为。
- 适用处: 它把类绑在一起,并要求了解超类的内部。
- instanceof
- 一个二元运算符,告诉你一个对象是否派生自某个给定的构造函数。
- 适用处: 它能看穿继承的类型,而且几乎每个对象都是 Object 的实例。
读后感AI 陪读
抽象才是产品,其余一切都只是管道
第6章是《Eloquent JavaScript》不再描述语言、开始描述一种文化的一章——而且它带着不寻常的自我拆台的意愿。它以塑料壳里的搅拌机开头:封装是一种体贴,接口是对只想要煎饼面糊的人的怜悯。这个类比温暖,也确实有启发,但它也是本章唯一未经检验的前提,因为一个封死的壳,也是一个你没法修的壳,而作者从未为此给自己记账。相反,他一直站在壳里面:Map 出场时,“写一个能快速更新并搜索一大堆值的数据结构并不容易,但我们不必为此操心”被顺带抛了出来——而这句话,而不是任何一条关于类的定义,才是本章真正的论点。抽象是产品;其余一切都只是管道。
中间是管道,而且按正确的顺序来教:方法和 this,然后是原型,然后是作为原型之上的糖、而不是作为原语的类记法。关键的分句在小结里来得很晚——对象“表现得好像拥有它们并不拥有的属性”。这个“表现得好像”,三个字就是整个原型系统。拥有是靠查找伪造出来的,一旦看明白这一点,“toString” in ages 的演示就成了全书最锐利的教学节拍:被当作免费方便来卖的继承,把一个幽灵人物泄漏进了你的数据。原型给予,也污染,这正是 Object.create(null) 和 Map 存在的原因。表面的其余部分——私有的 # 字段、getter、setter、静态成员、符号——讲得称职,还带着几个安静的陷阱,价值超过它们的字数:静态方法里的 this 是构造函数,所以 new this(...) 让 fromArray 白送地具备了继承感知;在构造函数里写 this.length 会在一个尚未填好的 #length 上触发 getter,因为 getter 使读和写不对称,而构造函数是在一个造到一半的世界里运行的。这些,是“偷偷调用方法”寄来账单的地方。
继续阅读
符号是我最想反驳的地方。命名空间冲突的故事,被框成一个你将会遇到的设计问题,而徒步路线的例子牵强到足以说明这并不是真的——真实的动机,是这门语言需要给它自己用的、不会冲突的钩子。把这话直说,Symbol.iterator 就不再显得任意。回报在后半部分立刻到来:作为协议的可迭代/迭代器,以及一个靠实现一个约定好的方法就加入 for/of 世界的 List 类。但请注意是什么让它行得通。“为使用某个接口而写的代码,自动知道怎样与任意多种不同的对象合作”——没有任何东西是自动的。它行得通,是因为每个人都遵守了一份此处没有任何编译器在检查的契约。在这门语言里,多态是一种礼仪。
结尾是本章最诚实的一段,而且它诚实的方式,几乎没有哪个面向对象的章节做得到。他早已承认“经典面向对象编程里把单个对象当作主要组织单位的那种执念,有点不幸”——先造起大教堂,再说祭坛放错了位置。他把 extends、super 和 instanceof 教得规规矩矩,然后告诉你,“继承从根本上把类绑在一起,造成更多的纠缠”,它是三个想法里最弱的一个,而你“大概不该主动去寻找构造类层级的机会”。这是靠演示、而不是靠鼓吹来教学,直接瞄准那一代被训练成把面向对象当作造树的人。习题带着同样的坦率:他承认那道可迭代的习题要你做的是错事——别委托给数组的迭代器,尽管在真实代码里委托才是正确答案——而他那句愉快的“如果在迭代期间修改了集合,你的迭代器表现得古怪也没关系”,是盖在一个真正难题上的许可条,真实的集合库曾不得不痛苦地回答它。
我要对读者说的是:按价值的顺序来取这三个想法,这与大多数教程强调的顺序相反。封装是要点——划一条边界,保持内部小而外部稳定。多态是力量——去实现语言已经定义的那些协议,你的对象就能不继承任何一个类,而继承整个标准库的配合。继承是最后的手段,是组合确实不行时,用来共享实现的工具。而在这三者底下,抓住那个“表现得好像”:在 JavaScript 里没有魔法,只有一条查找链,而本章几乎每一个意外——幽灵 toString,造到一半的构造函数,同一个数组上两个都诚实的 length——都是这条链一如既往地那么死板地照字面行事。
在 FeedLitra 里读第 6 章,每段旁边都有这些卡片。
开始读书第 7 章:项目:机器人
goalOrientedRobot 仍然做了哪些明显浪费的选择?
第 7 章是怎样展开的AI 辅助
开篇:先有世界,再有工人。本章宣告自己是对理论的一次中断——一个“项目”章,要点是读一个真实的程序,而不是吸收新语法——并把目标定为建造一台自动机:玩具村庄里一个送信的机器人。迪杰斯特拉那段关于潜艇和游泳的题词,定下一种消胀的调子:别问这东西是否智能,要问它是否把活干了。第一个具体的动作,是造世界,而不是造机器人。Meadowfield 被给成一个由 14 条道路字符串组成的扁平数组,文中随即嫌它用起来别扭,由 buildGraph 把它转换成一张从每个地点到其可达邻居的映射。这次小小的重构确立了本章的工作方法:在解决任何事之前,先把数据重塑成问题真正需要的形式。
展开:伪装成建模步骤的设计论证。手里有了地图,本章陈述任务——取包裹、送包裹、每一步决定往哪儿走——然后停下来,写出它最有主见的一段。读者为机器人、包裹和地点写类的那个“第一冲动”被点名并被断然否决:听上去像对象,并不让某样东西成为对象,而条件反射式地写类,会得出彼此纠缠的可变状态之网。取而代之的是一份最小的状态描述——机器人的位置,加上一份未送达包裹的列表——装进 VillageState,它的 move 方法先验证道路,再用 map 带着手上的包裹一起走、用 filter 丢掉已送达的,返回一个新状态,而不是修改旧的。一个简短的演练证明了这一点:移动之后,next 显示送达已完成,而 first 仍描述原来的情形。本章随后退一步,把这推广成一节关于持久数据的内容,引入 Object.freeze,同时坦率承认作者通常只是请人别去修改东西。给出的理由明确是关于人的局限:稳定的值让你能孤立地推理操作,而“对我们能构建什么样的系统,最重要的限制是我们能理解多少”——不过也承认,在一门不帮忙的语言里,用持久结构来设计更难。
转折:三个机器人,一个比一个聪明。模拟那一节把机器人定义成一个从状态(外加记忆)到方向(外加新记忆)的函数,由 runRobot 循环驱动,没有包裹时停止。本章随后有意地升级。先是 randomRobot,被说成“可能奏效的最笨的策略”——它乱逛,靠纯粹的概率,终究会完成,耗时约 63 个回合。然后是 routeRobot,它借自真实的邮件投递:一条存在记忆里的、固定的 13 站环线,每回合消耗一个元素,保证最多 26 个回合内完成。每个机器人都是靠点出前一个的弱点来引入的,所以这个递进读起来像诊断,而不是一份清单。
寻路作为智识上的回报。沿固定路线走被斥为不智能——它无视真正要做的工作——这催生了本章唯一一段真正算法性的内容。寻路被框成一个搜索问题:解可以被检查,却不能被直接算出,所以必须生成候选,并通过只从 A 出发、永不重访一个地点来修剪搜索空间。findRoute 借助一份工作列表实现了广度优先搜索,文中仔细地解释了顺序为什么要紧:一到某个地点就立刻去探索它,会在考虑较短的替代路线之前,就连锁地陷进很深的路径。给出的意象,是一张路线之网均匀地向外爬,直到有一根线碰到目标。一条说明指出,代码从不处理工作列表耗尽的情形,因为村庄的图是连通的。goalOrientedRobot 随后把它接进来:取第一个未送达的包裹,若还没取到就朝它寻路,否则朝它的地址寻路——大约 16 个回合,比路线机器人好,但文中承认,仍不是最优。
收束:把活儿交出去。本章没有去填平那道缺口,而是把它转成习题。“度量一个机器人”要求写一个 compareRobots 函数,让两个机器人各跑 100 个相同的任务——回应那个公平性的担忧:任何单次运行都可能只是运气好。“机器人的效率”邀请读者找出 goalOrientedRobot 做的那些“显而易见的蠢事”并超过它,用比较工具来验证。“持久集合”绕回本章的哲学线索,要求写一个 PGroup 类,它的 add 和 delete 返回新实例,并带一个共享的 PGroup.empty 起始值——并以一个问题收尾,那其实是对本章全部论证的一次理解检查:为什么一个空实例就够了?所以本章结束时没有一个完工的机器人,而是一个未完工的,被有意留作读者的问题。
关键论点,以及支撑它们的原文
问题里听上去像一件东西的概念,并不一定要在程序里成为一个可变的对象。
作者论证说,为机器人、每个包裹和每个地点都写一个类,会留下一张对象之网,每个对象都带着自己不断变动的内部状态,使程序难以跟随、容易弄坏;他演示的替代办法,是把世界熬成定义它的最小的一组值——机器人在哪儿,以及哪些包裹还没送到,每个都有当前所在地和一个地址。
让值保持固定而不是去修改它们,使你能对每个操作单独进行推理。
顺着不把状态散落在各个对象里的决定,作者展示了一次从邮局到爱丽丝(Alice)家的移动:产生的状态里包裹已送达,而原来的状态仍然报告机器人在邮局、包裹未送出。操作成了自成一体的事实,而不是其含义取决于何时发生的事件。
你能造出的系统,真正的上限是你能理解它们多少。
作者承认,一个玩具村庄承受得起变动状态所带来的额外复杂性,然后他作推广:修改给每一处推理都添上一个时间维度,所以凡是能让代码更容易理解的东西,都提高了你能尝试的系统的野心。他还诚实地指出这一取舍——建立在不变数据上的代码更容易理解,却更难设计,尤其是当语言不提供帮助时。
另外 2 个论点
让机器人对实际要做的工作作出反应,胜过随机乱逛,也胜过固定的投递环线。
作者给出了具体的数字:随机游走者碰巧用 63 个回合完成了一次运行,盲目重复一条 13 站邮路的机器人最多需要 26 个,而为下一个包裹或它的地址规划路径的机器人,处理五个包裹通常大约用 16 个。他强调 16 仍不是最优,而这正是习题所接上的地方。
凭寥寥几次运行来判断一个机器人,不是公平的度量。
恰恰因为所报的回合数来自单次运行,作者指出,某个机器人可能只是抽到了更容易的场景,并要求做一次比较:生成 100 个任务,把每个任务交给两个机器人,再把步数取平均。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
靠造一个全新的世界状态来移动
移动这一步不是就地编辑村庄,而是先检查道路存在,再造一个全新的状态:携带的包裹在新地点被重新创建,地址与之相符的包裹被过滤掉。因为旧的东西一概不碰,同样的起始状态加上同样的移动,总是得到同样的结果。
机制 2
向外生长路线,以找到最短的
寻路器保持一份尚待探索的地点的工作列表,每个地点都配着到达它的那条路径,并总是先展开最老的那一项。因为探索均匀地向外扩散,而且任何已到过的地点都被跳过,所以第一条碰到目标的路径,不会被后来的任何一条胜过。
机制 3
记忆把一次选择变成一个计划
机器人不过是一个从世界状态到方向的函数,所以单靠它自己无法提前规划。通过同时返回一个下回合会被交回来的记忆值,它就能存下一整条算好的路线,每回合交出它的第一步,并把这一步从余下的部分里去掉。
常见误读
面向对象设计的意思是,描述里的每个名词——机器人、包裹、地点——都该有自己的一个类,持有它当前的状态。
给状态本身建模:找出能把情形钉死的最少的值,让行为成为由旧值算出新值的函数。作者说,把每个概念都类化的条件反射,通常是错的,因为由此得出的、各自独立修改的对象之网,正是让程序脆弱的原因。
这里展示的寻路器是一件通用的最短路径工具,总会返回点什么。
它之所以返回最短路径,只是因为它按到达的顺序展开地点,并忽略已见过的地点;它之所以永不耗尽工作,只是因为这张村庄的图是连通的、每个地点都可达。作者明说,代码不处理工作列表耗尽的情形。
第 7 章的核心概念
- 图
- 由线连起来的点;这里是由道路连起来的村庄地点。
- 适用处: 第7章假定这张图是连通的,所以搜索永不失败。
- 持久数据
- 永不改变的值;更新产生全新的值。
- 适用处: JavaScript 不强制这一点,所以它需要自律,设计起来也更难。
- 复杂性
- 你还能把自己的系统多少装在脑子里。
- 适用处: 小系统可以承受可变状态;抱负受限于理解。
- 广度优先搜索
- 从起点向外生长各条路线,使找到的第一条路线就是最短的。
- 适用处: 需要一份工作列表,并跳过已到过的地点;依赖一张连通的图。
另外 0 个概念
读后感AI 陪读
这章貌似谦逊,实则是在反对条件反射式的面向对象
一章貌似谦逊:它看上去像一道玩具式的寻路练习,实际上是一场关于复杂性住在哪里的论辩。哈弗贝克借机器人上演了一场反对条件反射式面向对象的论战——村庄被缩减成两个值,一个地点和一份包裹列表,每一个听上去配得上有个类的“东西”都被消解了。持久数据那一节是本章最有趣、也最诚实的地方:他承认 JavaScript 在和他作对,承认 Object.freeze 带来的困惑多于帮助,承认他多半靠告诉同事别去修改东西。这份坦率,比一条干净的规则是更好的教学。机器人的演进(随机 → 固定路线 → 以目标为导向)是一架搭得很好的梯子,每一级的失败都催生下一级,而最后那个机器人的弱点——parcels[0],纯粹的任意性披着策略的外衣——被有意留着暴露出来,等习题去解决。没被检验的是:搜索对工作列表是二次方的,图的连通性是被假定的而不是被检查的,而“智能行为”被悄悄重新定义为“更短的路线”,没有任何说明,迪杰斯特拉的题词几乎在激你去注意到这一点。
在 FeedLitra 里读第 7 章,每段旁边都有这些卡片。
开始读书第 8 章:Bug 与错误
对例行的错误输入,返回一个特殊值与抛出异常相比如何?
第 8 章是怎样展开的AI 辅助
开篇:找出真正的罪魁。本章开头不是一项语言特性,而是一个关于人的主张。计算机程序里的缺陷通常是程序员自己造成的——要么是对程序该做什么的思路混乱,要么是对一个其实正确的想法的草率抄写。从这里起,作者巡视了 JavaScript 的各项保障,发现它们很单薄。像 true * “monkey” 这样的表达式根本不招来任何抱怨;它悄悄地产出 NaN,而这个值随后沿着后面的计算往前走,直到碰上应付不了它的代码。其后果就是本章的组织性难题:因为语言拒绝反对,可见的症状浮现在离真正的错误很远的地方。严格模式被引入,作为对这份宽松的部分收紧,可选的类型标注(借助 TypeScript 之类的系统)则是进一步的、外部的纪律——但两者都没有补上缺口。隐含的论证是,如果语言不保护你,你就必须自己建起保护。
展开:程序员自己的保障。本章的中段按形式化程度递增的顺序,把这些保护组装起来。自动化测试最先——一种在几秒钟里、而不是手工地重新检查预期行为的办法。然后是有纪律的调试,通过一个演练的例子呈现:一个行为不对的数字转字符串函数,在每一轮里把 n 记下来,会显出 13、1.3、0.13 这个序列,从而把故障钉在一个永远产生不出整数的除法上。从这一插曲推广出的要点是方法性的而不是技术性的:调试就是对失败形成一个理论,并拿观察去检验它,而不是随意改代码,直到症状消失。测试和日志都是为这同一种检验理论服务的工具。本章随后转向传达失败——先是通过特殊的返回值,再是通过异常,而比较的结果偏向异常,出于一个具体的结构性原因。抛出的异常会展开调用栈,一个个丢掉待返回的调用,直到碰到一个外围的 try/catch,这意味着中间的函数完全不需要传递错误的代码。询问方向的例子把这一点说得具体:内层函数在输入无效时抛出,外围的 look 函数对这件事可能发生完全不知情,而外层的处理程序把它报告出来。
转折:处理不等于安全。本章随后让它自己的建议变得复杂,这是它最锐利的一招。因为那一跳可能把一个函数丢在它的副作用进行到一半的地方,它所修改的状态就可能被留在不一致的状态——转账的例子先扣了钱,才去查第二个账户,所以那一刻抛出的异常会让这笔钱直接消失。捕获异常并不能撤销已经发生的事;要保持状态连贯,要么仔细安排副作用的顺序,要么在 finally 块里修复它们,不论控制权以什么方式离开 try,finally 都会运行。紧接着是第二个麻烦:一网打尽的捕获。一个为某个预期的失败而写的 catch,也会吞掉不相干的失败。作者用一个 try 里拼错的函数名来演示这一点——由此产生的未定义绑定错误被当作用户输入有误,于是重试的循环永远转下去,而本可以指认那个笔误的消息永远到不了开发者那里。于是有了那条规则:不要不加区分地捕获,除非是有意地转送或报告失败。
化解:按类型认出,并大声地失败。补救是收窄处理程序。作者没有去匹配错误消息的文本——那是写给人读的字符串,所以任何改写或翻译都会悄悄弄坏逻辑——而是定义了一个空的 Error 子类,用 instanceof 检验传来的异常,凡不匹配的就重新抛出。这种认出必须可靠,恰恰因为重新抛出就取决于它。本章用断言结束这条线索:一个取第一个元素的辅助函数,在数组为空时抛出,而不是返回 undefined,使误用在错误旁边引爆,而不是向后渗漏。随附的告诫与技巧同样要紧——断言是为程序员犯的错、为这个函数本就没被设计去应付的用法而设的,不是为你在运行时预期会出现的情形,那些属于普通的处理;什么都检查,只会让代码杂乱。本章以一份汇集它各件工具的小结收尾:测试、特殊返回值、异常,和 finally。
关键论点,以及支撑它们的原文
JavaScript 的宽松意味着错误的计算会继续运行,而不是停下,所以最终的症状出现在离真正的错误很远的地方。
作者指出,像 true * “monkey” 这样的表达式根本不招来任何抱怨;这类运算通常只是产出 NaN,它随后一路向前流,直到后面某段代码被它呛住。严格模式只收紧了这份宽松的一部分。
调试靠的是对失败形成一个理论、再拿观察去检验它,而不是随意地改代码。
在数字转字符串的函数里,作者在每一轮里记下 n,看到 13、1.3、0.13,如此等等——这个观察把故障钉在永远产生不出整数的那个除法上。自动化测试通过让你在几秒钟里重新检查一个理论,起着同样的作用。
异常之所以有价值,是因为错误处理的代码只在问题出现的地方和问题被处理的地方才需要,而不是在沿途的每个函数里。
作者那个询问方向的函数在输入无效时抛出,而外围的 look 函数对这种可能性完全不知情,由外层的 try/catch 把它报告出来。作者标出了一项代价:如果这一跳打断了一个函数的中途,它正在改变的状态可能被留在不一致的状态。
另外 3 个论点
不加区分地捕获每个异常是有害的,因为你捕获到的可能不是你心里想的那个。
作者展示了一个 try 块里函数名的笔误:由此产生的未定义绑定错误被当作用户输入有误,造成死循环,并把本可以揭示这个错误的消息埋掉。于是有了那条反对一网打尽的捕获的规则,除非是有意地转送或报告失败。
按类型,而不是按消息的文本,来认出一个异常。
消息字符串是写给人读的,所以依据它们做决定,就把程序逻辑绑在了确切的措辞上;改写或翻译这条消息,就弄坏了代码。作者转而定义一个空的 Error 子类,用 instanceof 检验,这就是为什么这个补救仍然有效,也是为什么这种认出必须可靠,重新抛出才安全。
代码被误用时大声失败,胜过悄悄地交回 undefined,因为这样抱怨就留在离错误很近的地方。
作者那个取第一个元素的辅助函数在数组为空时抛出,而不是返回 undefined,于是误用立刻炸开,而不是向后渗漏——一个断言的缩影,用一个刻意写得很小的函数来说明。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
沉默的胡话推迟了错误
因为 JavaScript 很少反对无意义的运算,像 NaN 这样的坏值被悄悄产出,然后一路带进后面的计算。程序只有在这个值到达应付不了它的代码时才崩,而那离真正的错误所在的地方很远——所以作者论证,你必须依据理论来推理,而不是瞎摆弄。
机制 2
展开调用栈,并在其后清理
抛出的异常一个个丢掉待返回的调用,直到碰到一个外围的 try/catch,这恰是中间的函数完全不需要传递错误代码的原因。同一个跳转也可能把一个函数丢在副作用进行到一半的地方,所以是 finally 块让清理无论如何都会发生。
机制 3
一网打尽的捕获把 bug 变成死循环
一个为某个预期的失败而写的 catch 块,也吞掉不相干的失败。作者那个拼错的函数名显示了结果:未定义绑定的错误在重试循环的每一圈里都被捕获,于是程序永远转下去,而本可以指认那个笔误的消息永远到不了开发者那里。
常见误读
把有风险的代码包进 try/catch,就让它安全了,因为异常被处理了,程序继续往下走。
处理异常并不能撤销被中止的函数已经做过的事。作者转账的例子先扣了钱,才去查第二个账户,所以在那一点抛出的异常会让这笔钱直接消失;要保持状态一致,要么仔细安排副作用的顺序,要么在 finally 块里修复它们。
既然大声失败胜过沉默的 undefined,你就该对函数收到的每个可疑输入都加断言。
断言是为程序员犯的错——这个函数本就没被设计去应付的用法——而设的,不是为你在运行时预期会发生的情形,那些属于普通的处理。什么都检查只会让代码杂乱,所以作者建议省着用。
第 8 章的核心概念
- 严格模式
- 一种更严格的 JavaScript 方言,用 “use strict” 开启,它会报错,而不是悄悄发明全局变量。
- 适用处: 当拼错的绑定在作用域里已经存在时,它帮不上忙;类和模块自动是严格的。
- 自动化测试
- 写一个其工作是检查另一个程序的程序,这样你就能在几秒钟里重新验证行为。
- 适用处: 只覆盖你写过测试的那些情形,而与许多外部事物交互的代码很难搭建。
- 异常
- 一个被抛出的值,它跳出当前函数及其调用者,直到某个 try/catch 块把它捕获。
- 适用处: JavaScript 不能有选择地捕获;展开还可能跳过副作用,而 finally 块必须修复它们。
- 调用栈
- 待返回的函数调用链;异常会展开它,在往下走的路上丢掉各个调用上下文。
- 适用处: 未被捕获地到达栈底,处理就留给了环境,而这在浏览器和 Node.js 之间有所不同。
- 自定义错误类
- Error 的一个空子类,它唯一的任务是给异常一个可辨认的类型。
- 适用处: 只有当调用者需要把这个失败与其他失败区分开时才有用;它自己不增加任何行为。
- 断言
- 程序内部的一次检查,当代码被用在它本不该被使用的方式上时,大声失败。
- 适用处: 用于程序员的错误,而不是预期的运行时情形;为每个错误输入都写,会让代码嘈杂。
另外 1 个概念
- 选择性重新抛出
- 处理你认得的那个异常,把其余的往下抛,让真正的 bug 仍能浮出水面。
- 适用处: 需要一种可靠的办法认出这个异常,这就是为什么类的检验胜过消息的检验。
读后感AI 陪读
异常让函数摆脱错误处理,直到它们摆脱不了
本章真正的论点是:正确性是一门工程纪律,而不是一项语言特性,而 JavaScript 被用作那个充满敌意的案例:一门乐呵呵地去计算 true * “monkey”、从拼写错误里发明全局绑定、并递给你 NaN 或一个悄悄出错的答案的语言。对着这一点,哈弗贝克堆起一层比一层更“人”的防御——严格模式(对旧默认值的一次自愿道歉)、类型标注(先推荐,后谢绝)、自动化测试(被当作勇气、而不是证明来卖)、console.log 加上“要思考,别乱抓”的告诫,最后是异常。关于异常的材料最强,也最诚实地拆自己的台:他先把异常推销成让中间的函数不必管错误处理,随即展示银行的例子,那些同样的函数必须关心清理,而他给出的补救——一个手搓的 progress 计数器——明显是个补丁而不是方案,这就是为什么他那句宁要计算值、不要修改的旁白,比 finally 块起的作用更大。选择性捕获那一节带着最锐利的、可迁移的教训:一网打尽的 catch 会毁掉证据,而靠解析给人读的消息来做程序判断,是一个等着翻译来触发的 bug。有两股反复出现的暗流值得点出。第一,向后兼容解释了所讨论的几乎每一个疣——这门语言修不了它的默认值,于是安全成了你必须记得去要的东西。第二,这里几乎每一个补救,都把工作转移给了程序员的判断:用多少断言,抛哪些异常,保证多少清理。这不是回避,而是准确;但这意味着本章真正的收获是气质上的而不是技术上的——抗拒随意的修改,让你的失败大声,并且不要去捕获你认不出来的东西。
在 FeedLitra 里读第 8 章,每段旁边都有这些卡片。
开始读书第 9 章:正则表达式
stripComments 的多行注释分支是如何处理贪婪的?
第 9 章是怎样展开的AI 辅助
开篇:一次让步变成了论证。本章不是以推销正则表达式开头,而是以承认它们难看开头。作者重复了那个耳熟的笑话:用正则表达式解决一个问题,会留下两个问题——原来那个,加上你刚写下的那个读不懂的模式。这次让步是开篇这一招的铰链。认下了审美上的反对意见之后,作者把问题挪了位置:决定一件工具会不会变得无处不在的,不是优雅,而是合用和历史的运气。正则表达式占着一个真实的生态位——描述文本的形状——并且它们被焊进了 JavaScript 本身,所以学会那套晦涩记法的代价只付一次,却永久收回。主张不是“这很美”,而是“这会让你的效率可衡量地提高”,这为随后很长一段技术内容发了通行证。
展开:一块一块搭起记法。从这里起,本章稳步累积词汇,大致按可组合性的顺序。先是构造模式的两种办法——斜杠之间的字面量,或 RegExp 构造函数——然后是 test,用来问最简单的问题。然后是字符集和简写,本章在这里埋下一粒以后要收割的种子:\w 及其同类是在一个更以英语为中心的时代定义的,所以 é 和 β 根本不算“单词字符”,而 /\p{L}/u 却能正确匹配希腊字母 α。作者对此持平:Unicode 属性组更啰嗦,需要 u 标志,而当数字是要送去给 Number 时,\d 仍是更好的选择——但在英语之外的文本里,说“字母”的可靠办法是属性组。重复运算符(+、*、?、{n,m})接着来,然后是分组,然后是带着按索引的分组结果的提取工具 exec 和 String.match,然后是让分组派上用场的 Date 例子,然后是边界标记(^、$、\b)和前瞻,最后是用 | 表示的选择。
转折:从记法到机器。本章的转折是关于匹配引擎的那一节,它改变了所提供的理解的种类。到此为止,读者一直在收集符号;现在他们被展示了什么在运行。这个模型是一种带记忆的搜索:引擎从最左边的位置开始,在每个选择点记下自己当时所在的位置,这样失败的分支可以被放弃、另一个分支可以被尝试。被追踪的例子,是拿 “103” 去匹配一个数字模式——引擎进入二进制分支,撞上 3 而失败,退回去试十六进制,最后在十进制分支上成功。这一课很精确,值得抓牢:所报告的匹配,反映的是各分支被尝试的顺序,而不是对各种可能的穷尽调查。第一条可行的路线获胜,而不是最好的或最长的那一条。
实际的后果。回溯的图景随后被兑现成规则。第一条也是最重要的一条:贪婪的重复运算符尽可能多地抓,只有当模式的其余部分失败时才交出字符,所以留存下来的匹配可能远远伸过你原本想让它停下的地方。加上 ? 就颠倒了尝试的次序——从小处开始,只在压力下才增长——而注释剥离器把这里的利害说得具体:/\/\*[^]*?\*\//g 把 “1 /* a */+/* b */ 1” 变成 “1 + 1”,而贪婪的版本从第一个 /* 一路跑到最后一个 */,把两段注释之间的代码吃了。注意这不是什么:? 并不决定匹配是否存在,只决定在几种可能的匹配里选哪一种——而这正是它危险的原因,因为一个过大的匹配会悄悄地败坏结果,而不是大声地失败。作者的建议是,把非贪婪当作默认。接下来还有两个陷阱。由你没写的字符串拼出来的模式,必须先转义:像 “dea+hl[]rd” 这样的用户名直接交给 RegExp,会被当作运算符来读,而把它送过 replace(/[\\[.+*?(){|^$]/g, “\\$&”) 才让它重新成为字面文本。以及,全局或粘性的表达式把它的下一个起始位置存在自己身上,所以在多次 exec 调用之间共享同一个 /\d/g 对象,会在第一个字符串里找到 “1”,然后在第二个字符串上返回 null,因为遗留的 lastIndex 让搜索开始得太靠后。g 标志并没有被谴责——replace 需要它,有意设置位置也用它——警告专门针对的是在多次 exec 调用之间共享这样的对象。
收束:一个应用,和最后一个标志。本章结束时,让累积起来的机制去干真正的活。matchAll 提供了更干净的、不用循环的办法来遍历每一个匹配;search 和 lastIndex 补全了接口。然后 INI 文件解析器作为综合的例子登场:一种面向行的格式被逐行处理,表达式都带着完整的锚点,这样一个模式就不会悄悄匹配到一行里本不该接受的片段。收尾的一笔回到字符类的讨论中断的地方——u 选项,需要它是为了让由两个码元组成的字符被当作单个单位,而不是悄悄被劈开。于是本章回到它开始的地方,回到这套记法那个以英语为中心的 20 世纪 90 年代的默认值,与人们实际处理的文本之间的缺口,而如今手里有了合上这道缺口的工具。
关键论点,以及支撑它们的原文
哪些工具最终无处不在,是由合用和运气决定的,而不是由优雅的设计,所以一套笨拙的记法仍可能值得掌握。
作者认下了那个常见的笑话——伸手去拿正则表达式,会把一个问题变成两个——并借这次让步铺垫出相反的观点:因为这门晦涩的微型语言嵌在 JavaScript 里,又恰好合乎描述文本形状的生态位,所以把它理解透,会让程序员的效率可衡量地提高。
对于英语之外的文本,Unicode 属性组是说“字母”或“数字”的可靠办法。
简写 \w 是在一个更以英语为中心的时代定义的,所以那些显然是字母的字符——é、β——根本不算单词字符,而 /\p{L}/u 却能正确匹配希腊字母 α。作者承认属性组更啰嗦,并指出它们需要 u 标志,而当数字要被转成数字时,\d 仍是更好的选择。
匹配最好理解为一种带记忆的搜索:最早的可行起始位置获胜,而且只用第一个行得通的分支。
顺着前一点关于记法古怪之处的内容,作者在 “103” 上追踪引擎:它进入二进制数字分支,走到 3 时失败,退回去试十六进制,最后在十进制分支上报告匹配——这表明结果反映的是各分支被尝试的顺序,而不是对各种可能的穷尽比较。
另外 3 个论点
非贪婪的重复应当成为默认,因为贪婪的运算符会悄悄匹配得比预想的多。
作者把回溯的图景发展成一条实用规则,指出许多正则的 bug,来自在本该用非贪婪的地方用了贪婪的运算符,并展示一个注释剥离器:用 /\/\[^]?\\//g,输入 “1 / a /+/ b / 1” 变成 “1 + 1”,而贪婪的版本从第一个 / 一路跑到最后一个 */,把中间的代码吃了。
当一个模式是由你没写的字符串拼出来的,其中的特殊字符必须先转义。
作者的例子是一个拿来构造模式的用户名:像 “dea+hl[]rd” 这样的名字会被当作运算符、而不是字面文本来读,所以直接交给 RegExp 构造函数会失败。把它送过 replace(/[\\[.+*?(){|^$]/g, “\\$&”),会在每个特殊字符前加一个反斜杠,随后构造出来的表达式就能匹配文本里那个真正的名字。
在不同的 exec 调用之间重用同一个全局正则对象是个陷阱,因为上一次匹配遗留的状态会挪动起点。
一个共享的 /\d/g 在第一个字符串里找到 “1”,却在第二个上返回 null,因为第一次成功之后记下的位置被带了过来,搜索开始得太靠后。作者并没有一口咬定谴责 g 标志——replace 和有意设置位置仍需要它——警告专门针对的是在多次 exec 调用之间共享这样的值。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
第一个可行的匹配获胜
引擎从最左边的位置扫描,在每个选择点记下自己当时所在的位置,以便在一个分支失败时回去试另一条路。一发现完整的匹配,它就停下,所以被报告的是第一个可行的分支——而不是最好的或最长的那个。匹配 “103” 说明了这一点:二进制分支被尝试,在 3 处断掉,然后十进制分支成功。
机制 2
贪婪选得太多
重复运算符先尽可能多地抓文本,只有当模式的其余部分失败时才还回字符,所以留存下来的匹配可能远远伸过你想让它停的地方。加上 ? 颠倒了尝试的次序:运算符从小处开始,只在需要时才增长,这就是为什么非贪婪的注释剥离器会停在第一个 */,而不是吞掉两段注释之间的代码。
机制 3
遗留的搜索位置
一个全局或粘性的表达式在自己身上存下下一次 exec 该从哪里开始,并在每次成功匹配之后更新这个位置。所以在不同的字符串之间重用这样的一个对象,第二次搜索就从文本中间而不是开头开始——一个共享的 /\d/g 在第一个字符串里找到了那个数字,然后对第二个什么也不返回。
常见误读
内置的简写涵盖一般的字母,所以 \w 和 \b 对任何语言的文本都没问题。
这些简写是围绕英语设计的,作用于一种狭窄的单词字符概念,所以 é 或 β 这样的字母落在它们之外,单词边界也会被误判。可靠的替代办法是带 u 标志的 Unicode 属性组,如 \p{L},接受它更啰嗦——而对要转成数字的数位,\d 仍是更好的选择。
给重复运算符加个 ? 会改变模式究竟匹配不匹配,所以贪婪与非贪婪多半只是口味或速度的问题。
这个选择只决定在几种可能的匹配里选哪一种,而不是是否存在任何匹配——而这正是它要紧的原因,因为贪婪运算符定下来的那个过大的匹配,会悄悄败坏结果,就像那个吃掉两段注释之间代码的注释剥离器。
第 9 章的核心概念
- 正则表达式
- 一种小型的模式语言,作为一种对象类型嵌在 JavaScript 里,用来描述字符串里文本的形状。
- 适用处: JavaScript 的版本在处理非英语字符上历来很简陋,所以 \w 和 \b 会误判 é 这样的字母。
- 回溯
- 当一个分支或重复失败时,引擎回到一个记下的位置,试另一条路。
- 适用处: 像 /([01]+)+b/ 这种能用许多种方式匹配同一输入的模式,会使每个字符的工作量翻倍。
- 分组
- 括号把模式的一部分变成运算符眼中的单个元素,并为 exec 或 replace 捕获那段文本。
- 适用处: ?: 关闭捕获;一个用 + 重复的分组只保留它最后一次的匹配。
- 贪婪重复
- 重复运算符先尽可能多地匹配,再回溯;加上 ? 则让它们改为从小处开始。
- 适用处: 只影响在几种可能的匹配里选哪一种,不影响匹配究竟存不存在。
- lastIndex
- 正则对象上的一个属性,说明下一次 exec 匹配从哪里开始,每次成功匹配后自动更新。
- 适用处: 只在有 g 或 y 选项时生效,而且只通过 exec 方法。
- 码元
- 构成 JavaScript 字符串的 16 位片段;一个可见字符可能占两个。
- 适用处: 除非启用 u 选项,否则 . 或 {} 这样的运算符作用于码元。
另外 0 个概念
读后感AI 陪读
安静的颠覆:三十页讲正则,把它的缝隙全露了出来
第9章是本书最安静地颠覆的一段,因为它花了三十页教一件工具,却从不假装这件工具配得上它的位置。它以扎温斯基(Zawinski)的题词和那句关于存活的狡黠的话开头——赢的不是最好的设计,而是在合适的生态位里运转得足够好的——而这个框架原来是本章的结构脊梁,不是一个随口的玩笑。随后的一切,读起来都是对它的佐证。语法巡礼足够有序:字面量对 RegExp 构造函数,字符类,Unicode 属性组,重复,分组,exec 和 match,边界与前瞻,选择。但缝隙不断显出来,而作者一直在指着它们,而不是把它们抹平。\w 匹配字母、数字,以及“不知为什么”还有下划线;\s 被现代化得一直延伸到蒙古语元音分隔符,而它的邻居却冻在 ASCII 里。没有人设计过这一对。它是堆积出来的,而国际字符那一节,其实是一部标准把意外硬化成永久的简史。Date 的岔题也落在同一处——月份从零数起,日子从一数起,“令人困惑又愚蠢,要小心”——而说明问题的是,没有给出任何解释,因为没有可找的解释,只有一个要记住的错误。
本章的重心是回溯那一节,在那里它根本不再关乎语法。一旦你看清匹配器是怎样探索各种可能的,你就看到,嵌在一个分组里的一个多余的 + 会让每个字符的工作量翻倍,而几十个字符的输入就几乎要花上永远。这才是对题词的真正回答:问题不在于正则难读,而在于一个难读的表达式可以藏着一颗你的测试永远触不到的指数级炸弹。因此才有那句异乎寻常地带命令口气的“优先用非贪婪的变体”——注意被劝阻的恰是这门语言的默认值,贪婪被烤进去,成了错误的默认。动态构造那一节与此押韵:把用户输入拼进模式之前先转义,当那个用户是一个名叫 dea+hl[]rd 的书呆子少年时是好笑的,但那是把字符串拼进一门微型语言,结构上与 SQL 注入无异,却被当作一句笑料、而不是警告来呈现。有状态标志那段材料是最残忍的一段——一个正确的正则,一个匹配的字符串,却依然得到 null,因为 lastIndex 是藏在一个看上去像常量的值里的可变状态。“但它确实提供了一种不方便的办法”和“困惑是 JavaScript 正则接口的一项本质特性”,只因前面几页挣得了它们才站得住;这份干涩是承重的,它教你在操作这件仪器的同时不信任它。INI 解析器把这一切收成一个真正能用的东西,而那里的赋值表达式技巧是靠诚实的理由辩护的——它换来一串令人愉快的 else if,而不是必须如此。然后是 u 标志和三个苹果的例子合上了圈:{3} 悄悄地去量化半个 emoji,引擎在数码元,而你在数字符。“多半能用,但对某些输入表现古怪”才是本章真正的对手,比任何崩溃都糟。
继续阅读
关于文本本身,有两点小注。解释何时需要全局和粘性的那句话,塌成了一个同义反复——它们必须用的情形,就是你想用它们的情形——我敢打赌编辑时丢了一个限定从句。而结尾那句是本章最好的:一件锋利的工具,配着别扭的把手,言下之意是你会割伤的是把手,而不是刀刃。我要对读者说的是:本章实际在教的技能不是写模式,而是克制——抵抗把正则表达不干净的东西硬塞进去的冲动,这一点被留到最后一句,像一个本章太客气、没有在开头宣布的论点。把它们写短,给它们加注释,测试那些难看的输入,在你以为需要之前一步就去拿一个真正的解析器,并且当本章预先授权你对习题感到沮丧时,就照它的话去做。这份慷慨是有意的:困惑是接口的,不是你的。
在 FeedLitra 里读第 9 章,每段旁边都有这些卡片。
开始读书第 10 章:模块
ES 模块是如何在实际语法中表达接口与依赖的?
第 10 章是怎样展开的AI 辅助
第10章开头不是模块,而是对它们所解决的问题的一次诊断。特夫(Tef)的题词——“写易于删除的代码,而不是易于扩展的代码”——定下怀疑的调子,头几段描述程序如何有机地生长:随着需要出现,功能被一件件拴上去,而本可让结构保持整洁的维护工作,只会在以后回报给别人,所以被跳过。随之而来的有两个后果。一个纠缠的系统抗拒局部理解,逼你把整个东西装在脑子里;而为在别处复用而提取任何有用的一块,可能比重写它还难。为此给出的意象是“一大团泥球”,扯一块,整团都散了。补救随后被精确地定义:一个模块既声明它提供什么(它的接口,类似第6章的对象接口),也声明它依赖什么(它的依赖)。显式的依赖使自动加载成为可能,并把泥巴变成乐高——用定义明确的连接件拼接的零件。
从这里起,本章轮流变得带历史感和技术性。最初的 JavaScript 根本没有模块:一切共享同一个全局作用域,这积极地招来意外的纠缠和名字冲突。ECMAScript 2015 把程序分成脚本(旧的行为)和模块,后者得到它们自己的作用域,外加 import 和 export。一个小小的星期名模块演示了机制——一个私有的 names 常量,旁边是两个导出的函数——而周围的讨论走过各种变体:用花括号导入,靠 as 改名,不用花括号导入的 default 导出,以及用 import * 把所有导出绑到一个对象上。有两条约束被强调,因为它们把 ES 模块与后来的东西区分开:模块名在不同平台上的解析方式不同(浏览器里是网址,Node 里是文件),而且 import/export 声明必须位于模块的外层主体,因为它们是在模块加载时、而不是其代码运行时解析的。
本章随后从模块拓宽到包,动因是一个具体的烦恼:把第9章的 parseINI 拷进第二个项目,意味着 bug 要修两遍。包是一块可分发的代码——一个或多个模块、声明过的依赖、文档——而分发需要基础设施,在 JavaScript 里这就是 NPM,既是一个注册中心,也是一个命令行工具。这里的口吻是坦率而不是推销:三百多万个包里,“很大一部分是垃圾”,但几乎任何有用的东西都在那里。一小段岔题讲了许可——你默认拥有自己的代码;NPM 上的大多数代码带着宽松的许可,社区更喜欢要求更少的那一种——然后是回报:import {parse} from “ini” 取代了我们自己写的解析器。
向后的转折随 CommonJS 而来。因为 2015 年之前就已经在建大型系统,社区用语言本身即兴搭出了模块系统——最先是立即调用的函数表达式,把一个接口对象赋给一个全局变量,这带来了隔离,却仍然不声明依赖。CommonJS 用 require 和 exports 做得更好,由一个引入 ordinal 和 date-names 两个包的 formatDate 模块来说明。本章随后掀开引擎盖,定义了一个简化的 require,它读一个文件,用 Function 把它包起来,缓存结果,并把 require 和 exports 作为参数传进去——顺带指出 Function 既有趣又危险,而 readFile 只是个假装,因为文件访问是与环境相关的。关键的对比是,require 是在模块运行时发出的一次普通函数调用,所以它能出现在函数里面,也能接受算出来的名字,而导入则是事先解析的。结论是务实的:这次过渡缓慢而粗糙,CommonJS 仍会冒出来,但没有理由再用那种风格写新代码。关于构建和打包的一小节延伸了“不太算 JavaScript 的代码”这个主题——从 TypeScript 之类的方言编译,打包工具把你精心拆开的文件重新合起来(因为网络往返很贵),以及压缩工具把剩下的缩小。作者拒绝点名具体的工具,因为流行哪一个一直在变。
最后一个实质性的小节,从机制转向判断。好的设计是主观的,涉及取舍,要靠读许多程序来学——但本章坚持,你绝不该把一团令人痛苦的乱麻当作不可避免的东西接受。随后是几条原则,各自从已在上演的 INI 和寻路世界中举例:有现成约定的地方就遵循它(ini 模仿 JSON 的 parse/stringify);让模块专注于计算值,而不是执行副作用,因为一个坚持要从磁盘读取的解析器,在浏览器里没用,在内容来自别处时也没用;偏爱函数而不是有状态的对象,偏爱数组而不是自定义类型,因为把数据包在专门的类型里,会逼周围所有的代码都去了解它。dijkstrajs 的例子把可组合性这一点说得很尖锐:它的带权边格式与第7章的图不同,所以用它需要一个转换循环——于是有了“采用别人已经在用的数据结构”这条建议。收尾的一个让步软化了这条处方:当你仍在探索一个问题时,别让组织分了你的心;等某样东西感觉稳固了再整理。小结重述了接口、依赖、如今“不安地共存”的两套模块系统,以及 NPM,而习题把读者送回第7章——把机器人项目分解成模块,写一个依赖 ./graph.js 的 roads.js,并弄清手写的 require 靠在运行模块之前先缓存 exports 对象,怎样容忍一种有限形式的循环依赖。
关键论点,以及支撑它们的原文
让程序保持良好的结构,常常被跳过,因为这份工作只会在以后回报。
作者指出,程序是靠累积长大的,功能随着需要出现而被一件件拴上去;重构的努力,惠及的是下一个碰这些代码的人,而不是此刻做这件事的人,所以诱人的做法是任由各部分长得纠缠。
那被忽略的结构,随后要向你收两次账。
紧接上一点,作者点出两项具体的代价:如果什么都能碰到什么,就没有哪一块能被孤立地阅读,你就得把整个系统装在脑子里;而把一块有用的东西提出来给另一个程序用,可能比重写它还费事。“一大团泥球”的意象把第二项代价说得鲜活——扯一块,整团都在你手里散了。
一个模块系统需要模块声明它们用什么,而不只是它们提供什么。
作者把已发布的接口只当作故事的一半,并拿旧做法作反例:把模块包在立即调用的函数里,确实给了它一个私有作用域,但它仍把接口倒进了全局命名空间,并且只是假定它的依赖也这样做了——作者称之为不理想。显式的关系,才把代码从泥巴变成带着明确连接件的乐高。
另外 2 个论点
JavaScript 最后有了两套彼此别扭地并存的模块系统。
因为语言发布时没有模块,社区即兴地在其上搭出了 CommonJS,用函数做作用域,用普通对象做接口;Node.js 一开始就采用了它,而 NPM 的大部分仍在用它。内置的系统后来才到来,如今与它并存:Node 让 ES 模块能导入 CommonJS 的模块,而作者把社区的迁移描述为缓慢而粗糙。
大多数模块系统干脆禁止循环依赖。
作者的理由是次序:如果 A 需要 B 而 B 需要 A,没有哪种加载顺序能让每个模块在运行之前,其依赖都已完成。CommonJS 被呈现为部分的例外——它的加载器容忍循环,只要两边都不在加载完成之前去碰对方的接口——而前面给出的 require 实现,就被交给读者当作要检视的案例。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
声明的依赖使加载自动化
当一个模块在自己内部声明它用到了哪些其他模块,这个声明就是机器可读的:加载器可以顺着这份列表走,引入那些模块,再引入它们各自的依赖,然后才把导出交给提出请求的人。光暴露一个接口,没有给工具留下可顺着走的东西,所以加载仍是手动的。
机制 2
为什么 require 能熬过依赖环
本章展示的简化 require,先把一个空的 exports 对象放进它的缓存,然后读文件,把代码包进一个函数并运行它。所以如果那段代码反过来 require 原来的模块,缓存里已经有一项,就会把它交回来,而不是无限循环——尽管它还只填了一部分。
机制 3
导入在代码运行之前就解析了
ES 模块的导入是在模块加载时、在任何语句执行之前定下来的,这就是为什么 import 和 export 只能放在外层主体里,而且只接受普通的带引号字符串。require 只是在模块已经运行时调用的一个普通函数,所以它可以出现在函数里,也可以接受任何产生字符串的表达式。
常见误读
既然 JavaScript 现在有了真正的模块,CommonJS 就是个可以跳过的历史脚注。
作者的建议更窄:把新程序写成 ES 模块,因为如今大多数流行的 NPM 包都是这样发布的,但要预料自己会一直碰到 CommonJS 代码——它在 NPM 上仍很普遍,而 Node.js 支持从 ES 模块里导入它,恰恰因为它并没有消失。
既然 CommonJS 容忍循环,循环依赖在那里就是干脆安全的。
它允许的是一种有限的形式,而且只在一个条件下成立:加载结束之前,两个模块都不得去碰对方的接口。加载器的做法是预先登记一个空的 exports 对象,所以一个在加载中途读取其伙伴接口的模块,看到的是不完整的东西。
第 10 章的核心概念
- 模块
- 一个程序部件,既声明它的公开接口,也声明它所依赖的其他部件。
- 适用处: 只有当依赖是在模块自己里声明、而不只是由全局名字暗示时才有帮助。
- 接口
- 模块被有意暴露给外部的那一部分,其余部分保持私有。 模块中允许其他模块看到并使用的那一部分。
- 适用处: 单有接口只是故事的一半;没有声明的依赖,模块系统是不完整的。 只涵盖被导出的东西;内部的代码保持隐藏。
- 依赖
- 一个模块使用另一个模块的功能的关系,被显式地陈述。
- 适用处: 主要在机器可读时有用,这样加载器就能自动解析并加载它们。
- 一大团泥球
- 一个没有结构的大程序,其中抽出一块就把一切都拽了出来。
- 适用处: 这描述的是结构上的失败,而不单单是程序的规模。
- 包
- 一块自成一体、你可以发布、别人可以安装的代码。
- 适用处: 一个包可以容纳许多模块;质量参差不齐。
- 循环依赖
- 两个模块各自需要对方,直接地或通过一条链。
- 适用处: 只有当两个模块在加载期间都不去碰对方的接口时才可容忍。
另外 0 个概念
读后感AI 陪读
题词宣讲删除,本章却宣讲耐久
本章有两种性格。一种是称职的语法巡礼——ES 模块、CommonJS、NPM、打包工具——有用,稍显尽责,并坦率承认两套模块系统共存得很糟。另一种是一篇短短的设计随笔,直到临近结尾才真正醒来,而这是值得留下的部分:暴露普通数据,而不是定制的对象类型;把读文件挡在解析器之外;偏爱函数,而不是有状态的仪式;照抄你的邻居已经在用的数据结构。最后这一点削弱了对模块化的浪漫框架——可组合性原来多半是一个共享约定的问题,而 dijkstrajs 的岔题表明,真正的整合工作是数据的重塑,而不是优雅的接口。乐高的比喻出于同一个原因言过其实:真实的模块没有标准的凸点。而题词与正文摆在一起显得古怪,因为“易于删除”论证的是更少的结构,而本章大部分论证的是复用和耐久——两者只在末段那条“把组织推迟到问题不再移动之后”的建议里握了手。最安静而最有教益的一刻,是那个玩具 require:赋予模块私密性的机制,是用 Function 这个语言里最危险的原语拼出来的,而让它行得通的“先缓存、后执行”的花招,恰恰也让循环依赖半行得通。这里的隔离是大家客气地约定的一种惯例,而不是一堵墙。
在 FeedLitra 里读第 10 章,每段旁边都有这些卡片。
开始读书第 11 章:异步编程
生成器函数是怎样在没有 Promise 的情况下暂停的?
第 11 章是怎样展开的AI 辅助
开篇:一个被摆成两个坏选项之间的选择的问题。本章开头不是语法,而是一个时序的谜题。如果一个程序一个接一个地发出两个网络请求,总的等待至少是两次响应时间之和——数据包在路上跑时,处理器闲着。出路只有两条:开第二个线程,或让请求不阻塞地开始。作者指出,对带线程的代码做推理出了名地困难,而这恰恰是 JavaScript 平台转而走了异步这条路的原因。同步与异步的对比,于是被框成一个有明说动机的设计决定,而不是一种中立的分类。随后的一切,都是为让所选的这条路写起来能忍受而作的努力。
展开:回调,以及它们造成的伤害。提供的第一件工具是普通的回调——异步函数不能立刻交回一个结果,所以它接受一个稍后调用的函数。作者随后显示这种风格为什么有传染性。因为内层函数不能返回它的结果,建在它上面的任何东西也不能;每个调用者也都得变成异步,并带上自己的一个回调。compareFiles 的例子把代价摆出来:一个读文件的回调嵌套在另一个里面,每多一步就买下又一级缩进。失败的处境更糟,因为没有东西自动地转送它们,每一层都得手动转交错误。Promise 就是为修补这恰好两处伤而来。函数又能返回有意义的东西了——作者把同样的活儿重建成一条 then 调用链,randomFile 解析文本、挑一个条目、读那个文件,整条链作为单个 Promise 交回。而失败现在自己上路了:因为每个 then 或 catch 都产生一个新的 Promise,这条链就成了一条流水线,一个被拒绝的值沿着它跳过各个值处理函数,直到有一个 catch 接住它,随后那个处理函数的返回值就作为一个普通值继续往下走。于是一个处理函数可以覆盖许多步。
转折:读起来像我们出发时那种同步代码的记法。Promise 仍然把一种别扭的形状强加给本质上是顺序的工作,而卡拉的口令破解器就是展品。你没法在 for 循环里等待一个 Promise,所以 Promise 版本必须改写成一个递归函数——尽管这个任务本质上是线性的,总是等一次尝试结束才开始下一次。async/await 的版本不过是一对嵌套的循环,带着普通的 try/catch。作者把这呈现为叠在同一套机制之上的一种更易读的记法,而不是对它的替代;但书说得明白:底下你仍然需要理解 Promise。这是本章的铰链,也是预先挡掉一种常见误读的地方:async 函数返回 Promise、也 await Promise,所以那套机制从未真正离开。
收束:掀开引擎盖,以及藏在底下的险处。交出便利的表面之后,本章转向产生它的东西。生成器在 yield 处暂停,并带着局部状态原封不动地恢复——作者把先前光为保存位置就需要一个独立对象的 Group 迭代器,改写成一个短短的生成器,遍历成员并逐个产出。async 函数随后被揭示为一种特殊的生成器,一种返回 Promise 的生成器。卡拉的活儿提供了实用的音域:扫描本地网络,然后通过 Promise.all 和一个 async 的播放循环驱动九块 LED 显示牌。最后一招是个警告。单个事件循环一次在空栈上把一个回调运行到完,这就是为什么回调的异常没有外围的 try 去捕获它——调度它的代码早已结束。而因为每个 await 都是一次真正的暂停,其间别的排队代码会运行,所以跨过这个间隙去修改共享绑定的代码,会把自己弄坏:在作者的例子里,每个 += 都从空字符串重新开始,于是输出塌成一行,只点出最慢的那个文件。本章以这一课的两半收尾——构建新值,而不是修改共享的值;但也从 await 是显式写出来的这一事实里得到一点安慰,所以别的代码得以闯入的间隙,至少在页面上是看得见的。
关键论点,以及支撑它们的原文
JavaScript 平台靠异步而不是线程来等待缓慢的工作,因为带线程的代码要难推理得多。
作者的起点案例:一个接一个地进行的两个网络请求,至少要花两次响应时间之和,所以你要么加第二个线程,要么异步地启动请求;既然对线程做推理出了名地困难,平台就走了异步这条路。
普通的回调风格有传染性,又容易出错,因为每个调用者也都得变成异步,并手动携带失败。
compareFiles 的例子把一个读文件的回调嵌套在另一个里面,所以每多一个异步步骤就多花一级缩进,而错误处理得在每一层手动接线。
Promise 修补了回调造成的主要伤害:函数又能返回有意义的东西了,而失败自己沿着一条处理函数的流水线滑下去。
顺着回调的问题,作者用串联的 then 调用重建了同样的活儿——randomFile 解析文本、挑一个随机条目、读那个文件,整条链作为一个 Promise 返回——并展示一个被拒绝的 Promise 跳过第一个值处理函数,落进 catch,而 catch 的返回值继续流入下一个处理函数。
另外 3 个论点
当工作确实是一条直线时,async 函数是恰当的工具,因为它们用普通的循环和 try/catch 取代了递归的 Promise 接线。
在 Promise 的基础上,作者展示卡拉的口令破解器:因为你没法在 for 循环里等待一个 Promise,Promise 版本需要一个递归函数,而 async 版本不过是普通的嵌套循环——这个任务总是等一次尝试结束才进行下一次,所以它本质上是线性的。但书是:你仍然需要知道 Promise 是怎么工作的。
生成器使迭代器写起来容易得多,因为它们自己记录自己的位置和局部状态。
作者把先前需要一个独立对象来保存迭代状态的 Group 迭代器,改写成一个短短的生成器,遍历成员并逐个产出;在 yield 处暂停,会自动保留局部变量。
跨过一次 await 暂停去修改共享绑定,会弄坏结果;改为构建新值,就避免了这个问题。
在作者的例子里,每个 += 都从空字符串重新开始,因为暂停让别的运行覆盖了它,于是输出成了一行,只点出最慢的那个文件。作为补偿的一点:因为 await 是显式写出来的,这样的间隙至少容易发现。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
回调为什么扩散
异步函数不能立刻交回结果,所以它接收一个回调;建在它上面的任何函数也不能返回自己的结果,也必须接收一个回调。失败也没有自动的路线,所以每一层都必须手动转交错误,而嵌套随着每多一步而加深。
机制 2
拒绝如何沿着链传播
每个 then 或 catch 调用都产生一个新的 Promise,所以一条链就成了一条流水线。被拒绝的值会跳过各个值处理函数,直到遇到一个 catch,而那个 catch 返回的任何东西,都成为流入下一阶段的正常值,所以一个处理函数可以覆盖许多步。
机制 3
回调之下的空栈
事件循环运行脚本,然后把每个排队的回调一次一个地运行到完,所以回调是在一个全新的、空的调用栈上开始的。调度它的代码已经结束,这就是为什么外围的 try 块不再在栈上去接收回调的异常。
常见误读
一旦我写 async/await,就可以完全忘掉 Promise。
async 函数仍然返回 Promise,你也仍然 await Promise,所以你始终在直接与它们打交道;作者把这种语法呈现为叠在同一套机制之上的更易读的记法,而不是对理解它的替代。
async 函数里的代码是作为不被打断的一整块运行的,所以它在 await 时,没有别的东西能碰我的变量。
每个 await 都是一次暂停,其间别的排队代码会运行,并可以改变共享的状态,这恰恰是共享字符串的例子塌成一行输出的原因;计算新的值,而不是跨过暂停去修改绑定,才是安全的习惯。
第 11 章的核心概念
- 异步性
- 一种模型:启动一个缓慢的动作并不使程序停下;结果准备好时,你稍后会被告知。
- 适用处: 它缓解了非线性的控制流,却使直线式的代码更别扭。
- 回调
- 异步操作完成时调用的一个额外的函数参数。
- 适用处: 随着动作被串起来,嵌套和错误报告变得笨重。
- Promise
- 一个代表可能稍后才到的值的对象;then 登记拿到它之后要做什么。
- 适用处: 串联仍需要冗长的接线,而 async 函数缓解了这一点。
- 拒绝
- Promise 的失败状态,带着一个原因,沿着链往下传播,直到某个 catch 处理函数接住它。
- 适用处: 未被处理的拒绝,会被环境报告为错误。
- async 函数
- 一个标了 async 的函数,它返回一个 Promise,并且可以 await Promise,使异步代码读起来像同步代码。
- 适用处: 你仍需理解 Promise,因为你会直接与它们打交道。
- 生成器
- 一个用 function* 声明的函数,它返回一个迭代器,并在每个 yield 处把自己冻结。
- 适用处: yield 只能直接用在生成器函数体里,不能用在嵌套的函数里;它只保存局部环境和 yield 的位置。
另外 3 个概念
- 事件循环
- 那条单一的队列,把你的脚本和每个回调一个接一个地运行到完。
- 适用处: 一次只运行一件事,所以缓慢的同步代码会拖延待处理的事件。
- 异步间隙
- 在 await 或回调的边界处的那段暂停,其间别的代码可能改变状态。
- 适用处: 靠计算新值,而不是跨过间隙去修改绑定,来避免。
- Promise.all
- 把一个 Promise 数组变成一个解析为结果数组的 Promise。
- 适用处: 只要有任何一个成员 Promise 失败,它就失败,并把那个原因传下去。
读后感AI 陪读
Promise 不是为了方便:它们在栈蒸发之后重建错误传播
通读下来,第11章是一个被搭成攀登的论证,它的论点就在第二段:同步代码里,等待是隐式的;异步代码里,等待变得显式,要由我们自己掌管。此后的一切都是推论。本章随后爬上它搭起的那架梯子——原始回调、Promise、生成器、async/await——并在每一级都先承认一个真实的代价,再给出补救。回调行得通,但异步原来是有传染性的:那份别扭不会只留在一个函数里,而是顺着调用栈往上爬,直到爬到 main。Promise 作为尚不存在的值的收据到来,这个比喻恰当,因为一张收据可以被持有、被转手、被拿去交易,而货物还不在。而关键在于,Promise 的理由不是方便——而是调用栈,我们传播错误唯一的机制,在事件之间蒸发了,所以出了事要抛异常时,catch 处理函数不会在栈上。Promise 的存在,是为了在栈没了之后重建错误传播。
教学法很狡黠。就在 API 的文字变得干涩之时,一只邋遢的乌鸦开始偷手机,本章骤然信任你可以享受乐趣。卡拉(Carla)破解口令,是一个真正的计时侧信道,被当作恶作剧夹带了进来:部分口令的回应,取决于它的前缀对不对,而 10^6 缩成了六十次猜测。withTimeout 的竞赛完全建立在一项不可变性保证上——一个 Promise 只会敲定一次——所以输家的调用悄悄蒸发,竞赛在构造上就是安全的。最妙的是,那个痛苦的递归循环被有意地先完整地展示出来,使 async/await 的改写落地时是解脱,而不是语法。这是本章最锐利的一招,因为 await 真正的论据不是简洁:破解的逻辑是严格线性的,而把线性的逻辑硬塞进递归或 then 链,是对它形状的谎言。await 恢复了形状。
继续阅读
去神秘化只用了两句话。async 函数不再一口气从头跑到尾;它可以在任何 await 处被冻结,之后再恢复。然后是安静的揭示:async 函数是一种特殊的生成器——这套机制在两节之前就造好了,而 await 不过是等待 Promise 的 yield。整章里没有任何并发的魔法。只有冻结和恢复,以及盖在其上的糖。
LED 显示牌的压轴戏是手艺显现的地方。let nextFrame = wait(this.frameTime) 被放在工作之前,而不是之后,所以帧时间是一份预算,而不是额外加上的延迟——把那一行往下挪两行,播放就会漂移。网络扫描里那个空的 catch 是正确的语义,而不是马虎,因为对每个本地地址的扫描,本就预期大多数都会失败。而卡拉不只被赋予能力,也被赋予品味:她嫌“乌鸦万岁,人类流口水”太粗俗而否决了它,这让她成了一个角色,而不是 API 的道具。
事件循环一节随后在同一口气里推销单线程,又承认它的代价:没有东西同时运行,所以你没有数据竞争,而每当缓慢的代码占住栈时,你就付出延迟。就连一个已经解析了的 Promise,也依然推迟它的回调——用速度的价钱买来一致性,非确定性被预先征了税。然后是最后那个坏掉的程序,九行看似无辜的代码,只返回一行输出,错误出在一个运算符上。那道习题是本章安静的包袱:在一门没有线程的语言里,一个 await 的间隙仍然把 += 变成读-改-写的竞争,因为这个间隙是共享绑定可以在你脚下改变的一个窗口。那番耳熟的说教——计算新值而不是修改现有的值——终于在这里挣回了自己,成了一条关于正确性的性质,而不是审美。
我要对读者说的是:别把这四种机制当作要从中选一的四件工具——它们是关于谁掌管等待的同一个故事。把 await 读作冻结/恢复,而不是并行。并把每一个 await 都当作世界可能已经挪动过的一道接缝;审查你隔着它读到的东西,因为本章每一个 bug 实际上就住在那里。
在 FeedLitra 里读第 11 章,每段旁边都有这些卡片。
开始读书第 12 章:项目:编程语言
fun 对 Object.create(scope) 的使用,究竟是怎样让 Egg 有了闭包的?
第 12 章是怎样展开的AI 辅助
本章开头先把围绕语言实现的神秘感戳破。阿贝尔森(Abelson)和萨斯曼(Sussman)的一段题词提醒我们,求值器“不过是另一个程序”,作者随后附上一段个人的话:那些看似聪明得不可能的发明,往往在你读一读、试一试之后,原来平平无奇。由这个前提而来的是这个项目——Egg,一门小小的语言,却强大到足以表达任何计算,并通过函数支持抽象。
展开从语法和解析开始。Egg 彻底统一:一切都是表达式——一个绑定的名字、一个数字、一个字符串,或一次应用——而应用不仅用于函数调用,也用于 if 和 while 这样的构造,这意味着 JavaScript 的运算符成了普通的绑定,并且需要一个 do 形式来表示顺序。解析器的输出是一棵由带 “value”、“word” 和 “apply” 标签的对象构成的语法树。作者明确把它与前面某章里面向行的配置解析器对比:表达式递归地嵌套,所以解析器也必须是递归的。parseExpression 匹配三种原子形式之一,否则抛出 SyntaxError,并交给 parseApply,后者要么原样返回表达式,要么消耗一个带括号的参数列表,对每个参数回头调用 parseExpression,然后对自己递归,以允许 multiplier(2)(1) 这样的链式调用。一个包装函数 parse 检查在那唯一的顶层表达式之后没有别的东西。作者承认解析器的弱点——糟糕的错误消息,没有行号和列号的追踪——但认为它够用了。
转折随求值器而来,本章的要点在这里落地:运行一个程序所需的代码少得惊人。evaluate 按表达式类型分支——字面量产出它的值,单词在一个作用域对象里被查找,否则引发 ReferenceError,而应用要么分派给一个特殊形式,要么对运算符和参数求值,再调用得到的 JavaScript 函数。作者强调,解析器和求值器彼此镜像,因为两者都镜像着语言,把它们分开带来了清晰和灵活。特殊形式随后补上缺失的力量:if(一个表达式,比起 JavaScript 的 if 更接近 ?:,只把 false 当作假)、while、do 和 define。关键的解释是为什么 if 不能是普通函数——函数的参数都在调用之前被求值,而 if 只该求值一个分支。随后是环境:true 和 false 作为普通绑定,用 Function 在循环里合成的算术和比较运算符,一个 print 包装,以及一个用原型链表示嵌套作用域的 run 辅助函数。前面几章见过的 1 到 10 求和程序,现在在 Egg 里跑起来了,承认比它的 JavaScript 等价物更难看,却是由不到 150 行代码产出的。加上 fun,它把最后一个参数当作函数体、其余当作参数名,并在一个全新的局部作用域里求值函数体,语言就完整了,用 plusOne 和一个递归的 pow 来演示。
收尾的几节拓宽了框架。“编译”把所造的东西——直接作用于解析树的解释器——与预先做工作的编译器区分开来,后者比如把绑定解析到固定的位置,并提示说,把 Egg 翻译成 JavaScript 再调用 Function 会既快又简单,作为一道可选的习题给出。“作弊”随后承认了显而易见的事:Egg 的 if 和 while 是 JavaScript 的薄薄包装,它的值就是普通的 JavaScript 值;桥接到机器码要多费些力气,但原理相似。作者最后一招,是在教学之外为整个事业辩护——小语言能干真正的活,从手搓的正则表达式求值器,到被编译成解析器的语法记法——并走到领域特定语言这个概念,它之所以能胜过通用语言,恰恰因为它只描述自己那个狭窄的领域。四道习题收束本章,各自扩展这个实现:添加数组函数,解释为什么 fun 已经产出闭包,让 skipSpace 把 # 注释当作空白,以及添加一个 set 形式,它沿原型链走,去更新外层的绑定,而不是遮住它们。
关键论点,以及支撑它们的原文
让 Egg 里的每个构造都成为用同样的应用语法的表达式,使这门语言只用极少的代码就能实现。
作者指出,JavaScript 视为运算符的东西,比如 >,在 Egg 里是普通的绑定,像任何函数那样被应用,而且因为没有块语法,一个朴素的 do 形式充当顺序的角色。有了这种统一性,程序恰好是一个表达式,这就是为什么顶层的 parse 函数只需确认读完一个表达式之后没剩下什么。
因为 Egg 的记法是递归的而不是面向行的,解析器也必须是递归的。
作者拿它与前面某章的配置文件解析器对比,那个解析器可以简单地把输入拆成行,处理几种固定的行形状。这里的表达式包含表达式,所以每一步解析都返回找到的表达式外加剩下的文本,解析器的两半相互调用;参数列表之后的那次自调用,让 multiplier(2)(1) 得以行得通。
运行一个程序所需的机制少得出奇:寥寥几种表达式情形就够了。
顺着解析器建出来的树,求值器只区分字面值、名字查找、特殊形式和普通调用——最后两者的差别只在于是否先对参数求值。作者指出,解析器和求值器有同样的递归形状,因为两者都镜像着语言,并通过在不到 150 行的实现里运行那个熟悉的 1 到 10 的求和来演示结果,同时承认 Egg 版本看上去比 JavaScript 对应的难看得多。
另外 1 个论点
在这个设计里,条件和循环不能是普通函数。
推理是机械的:函数的参数都在调用之前被求值,而 if 只该根据条件求值它的第二个或第三个参数。这迫使 if 成为特殊形式,这又使它表现得像 JavaScript 的三元运算符,而不是它的 if 语句——它产出一个值,并且只把 false 当作假,而不把零或空字符串当作假。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
递归的文本被递归的解析器吃掉
Egg 的表达式彼此嵌套,所以解析器被写成每次调用都同时交回它所理解的那一块,和文本里未碰过的余下部分。那个余下部分可以直接喂回同一个函数去处理下一个参数,而因为一次应用本身也可以被应用,参数列表的读取器随后会再次调用自己,检查是否还有另一对括号。
机制 2
未求值的参数使控制流成为可能
普通的调用在函数体运行之前,先对每个参数求值,这会让条件失效。特殊形式则接收原始的参数表达式外加作用域,于是由处理函数决定求值什么、何时求值——if 求一个分支,while 则反复重新求值。
机制 3
原型链充当嵌套的作用域
作用域不过是一个属性即绑定的对象,子作用域是以父作用域为原型创建的。查找会自动落到外层作用域,而写入则落在最内层的对象上,所以一个程序或一次函数调用可以添加局部绑定,而不扰动全局作用域。
常见误读
Egg 的 if 就像 JavaScript 的 if,所以空字符串或零当条件会走假的分支。
Egg 只拿条件与值 false 比较,别的什么都不比,所以零、空字符串或任何其他值,都会转向第二个参数。它还是表达式而不是语句:它产出它所求值的两个分支中的那一个,很像三元运算符。
既然作用域是普通对象,而 define 往里面写,define 就是通用的赋值,可以更新一个声明在更外层的绑定。
define 总是写在最内层的作用域对象上,所以把它对准一个外层绑定,只会用一个新的局部绑定遮住它。要更新一个现有的外层绑定,需要沿原型链走——用 Object.hasOwn 检查,用 Object.getPrototypeOf 向外一步——这就是作者把普通对象的表示法看作最终会碍事的东西的原因。
第 12 章的核心概念
- 程序
- 在 Egg 里,整个程序就是一个单独的表达式,这就是为什么 parse 要检查没有剩下任何东西。
- 适用处: 对 Egg 统一的语法成立,对有语句和块的语言不成立。
- 表达式
- Egg 里的一切都是表达式:一个名字、一个数字、一个字符串,或一次应用。
- 适用处: Egg 没有语句,也没有块结构,因此才有 do 形式。
- 语法树
- 解析器产出的嵌套对象,表达式像树的分枝那样包含其他表达式。
- 适用处: 这棵树不记录行号或列号,所以错误报告仍然粗糙。
- 特殊形式
- 一个绑定到函数的单词,它接收未求值的参数表达式外加作用域,所以能选择求值什么。
- 适用处: 恰在对所有参数急切求值会出错时才需要,如 if 和 while。
- 作用域
- 一个把绑定名映射到值的普通对象;嵌套的作用域用原型链构建。
- 适用处: 一旦需要更新外层的绑定,把作用域表示成普通对象就变得别扭。
- 领域特定语言
- 一门只为一个狭窄领域设计的小语言,使它能比通用语言更直接地说那些事。
- 适用处: 只有领域狭窄时才值得;一门玩具式的通用语言,比起 JavaScript 毫无增益。
另外 0 个概念
读后感AI 陪读
承认 JavaScript 白送了什么,为什么让解释器显得优雅
一章,论点是消胀的——“这里没有魔法”——并靠构造挣得了它:一门 150 行的语言,真的能运行递归函数。方法是不懈的递归镜像:解析器、求值器和语言的结构,是同一种形状出现三次,这既是教学的有效载荷,也是它读起来如此轻松的原因。它的诚实是它最好的特性。“作弊”那一节承认了不够自信的作者会藏起来的东西:Egg 的值就是 JS 的值,它的 if 就是 JS 的 if,它的闭包是从 JS 的闭包白送的。这个解释器之所以小,不是因为解释本身小;而是因为几乎每一个难题(内存、作用域捕获、真值判断、算术)都被向下委托了。这是关于抽象层次的一个真正的洞见,也悄悄限制了本章在如何于裸机上实现一门语言这方面能教多少。我觉得最优雅的,是习题被预先装进了代码里。# 已经从单词的正则里排除了;Object.create(scope) 已经回答了闭包的问题;选用原型链作用域,是明知它们在赋值时会坏掉、并因此催生 set。设计决定与其后果在同一遍里就被铺好——读者是发现,而不是接收。在哲学上露出缝隙的唯一地方,是把作用域做成对象:靠原型链查找很方便,对修改却是实打实的错,这种 bug,真正的语言实现要花好几年来对付。而“we just and pass”那个笔误几乎是诗意的,恰好落在那唯一一句解释特殊形式为什么必须存在的话上。
在 FeedLitra 里读第 12 章,每段旁边都有这些卡片。
开始读书第 13 章:JavaScript 与浏览器
按第18章的许诺,HTTP 究竟是如何打包一个响应的?
第 13 章是怎样展开的AI 辅助
开篇:一段框定的题词,和一个关于乱的警告。本章以蒂姆·伯纳斯-李(Tim Berners-Lee)那句把 Web 说成“一个共同的信息空间”的话开头,定下理想主义的调子——普遍性,可以指向任何东西的链接。马里金·哈弗贝克随即把那份理想主义落在务实的必要上:没有浏览器,JavaScript 就无足轻重,所以接下来的几章必须去讲它们。不过,开篇真正的论证动作,是引入本章的统领性张力:Web 技术是以去中心化的、临时凑出的方式演化的,厂商发明特性,这些特性有时被别人抄走,很晚才被标准化。这被称作“既是福也是祸”——缺乏中心控制是赋能的,却造出一个“算不上内部一致的光辉典范”的平台。随后的一切,都是为解释这是怎么发生的而组织的。
展开,第一部分:自底向上搭起这一摞。正文随后分层推进,每一层都搁在前一层上。先是网络的历史——20世纪50年代计算机之间的电缆,80年代的全球互联网——以及那个关键的洞见:沿着导线射出比特毫无意义,除非两端就编码达成一致。这引出协议的概念,用 HTTP 和它的 GET /index.html HTTP/1.1 请求行来说明,然后是它底下的 TCP,通过端口、监听的服务器、连接的客户端,以及“双向管道”的比喻来解释。要点被明说为抽象:TCP 藏起了原始发送数据的凌乱。在此之上是 Web 本身——被小心地与整个互联网区分开来——然后是被分解成协议、服务器和路径的 URL,然后是 IP 地址和域名,以一次具体的走查结束:你键入 eloquentjavascript.net/13_browser.html 时,浏览器实际上做了什么。
展开,第二部分:从传输到文档再到代码。本章接着从管道转向内容。HTML 是用一份完整的示例文档引入的,然后被剖析:doctype,head 与 body,带开始和结束标签的元素,href 这样的属性,<meta> 这样的空标签,以及作为 HTML 对应于 JavaScript 反斜杠转义的实体(<、&、")。这里一个值得注意的小动作,是对 HTML 容错的演示——同一份文档去掉 <html>、<head>、<body>、结束的 </p> 标签和属性的引号,被显示出解析结果完全相同——这既说明了浏览器被标准化了的宽容,也让作者能为他在后面例子里要用的简写辩护(同时警告读者别模仿这些省略)。关于 HTML 与 JavaScript 的一节随后把这两条线接起来:带内联代码的 <script>,指向外部文件的 <script src=...>,忘了 </script> 的险处,用于 ES 模块的 type=“module”,以及 onclick 这样的事件属性及其嵌套引号的问题。
转折:从能力到约束。“在沙箱里”标志着方向的改变。在本章里一直展示浏览器里的 JavaScript 能够触及什么之后,作者停下来解释它有意触及不了什么。推理从读者自己的处境出发——你每次浏览,都在运行陌生人写的代码——并得出结论:Web 的吸引力,取决于不必信任你访问的页面。于是有了沙箱化,并给了它一个令人难忘的意象:罩着粗钢条笼子的沙盒。这一节对困难是诚实的,而不是得意的:难处在于给程序留出足够有用的空间,因为真正有价值的能力(与其他服务器交谈、读剪贴板)正是能造成隐私侵犯的那些能力。它收在一个持续的循环上——漏洞被发现,漏洞被修补,新的漏洞——带着一个有所指的希望:这些发现被公开,而不是被机构或罪犯悄悄利用。
收束:历史,和一个带保留的圆满结局。最后一节回到开头宣布的去中心化主题,这回被讲成叙事:Mosaic,然后是 Netscape,然后是 Internet Explorer,每一个占主导的厂商都觉得自己“有权单方面发明新特性”,造成了浏览器大战和 2003 年前后那种满是 bug 的不兼容。Firefox 的挑战,Chrome 的到来,Safari 的崛起,把一个垄断变成了四个彼此竞争、对标准更讲纪律的玩家,最终是微软用 Edge 取代了 Internet Explorer——到这时,读者被告知,“就当自己走运吧”。但本章没有给出一个干净的化解:随着 Firefox 萎缩、Edge 在 2018 年成了一个以 Chrome 内核做的包装,今天这份受欢迎的统一,也许不过是一次新的单一厂商的支配,这回是谷歌的。最后一段直白地合上了圈——这“一长串的历史事件与意外”就是我们拥有的平台——并把接力棒交给将要为它写程序的各章。
关键论点,以及支撑它们的原文
JavaScript 的重要性归功于浏览器;没有浏览器,没人会在乎它。
作者把这建立在平台实际是怎么长大的上:使 JavaScript 值得使用的那些能力,是浏览器厂商临时凑着加进来的,有时被对手抄走,很晚才被写进标准——所以这门语言的触及范围,一直就是浏览器的触及范围,这就是余下各章讲浏览器的原因。
Web 的去中心化发展,既是它的力量,也是它不连贯的来源。
没有中心的控制者,意味着许多方,在松散的协作或公开的敌对中,都能改进这个系统——但作者拿结果来平衡这一点:由此产生的平台算不上内部一致的典范,有些部分明摆着令人困惑、设计糟糕。HTML 两面都有,它的实体转义和容错的解析,都是从这种零敲碎打的生长中继承来的。
网络协议是分层搭建的,每一层都把它底下那个别扭的服务变成方便的东西。
具体的例子是 TCP 之上的 HTTP:HTTP 的请求行只需要点出一个资源和一个协议版本,因为 TCP 已经通过一个在编号端口上监听的服务器和连接它的客户端,保证了比特按正确的次序抵达正确的目的地。作者强调,在单纯的比特发送之上提供这些保证,本身就是个棘手的问题——这恰恰是它被分解成单独一层的原因。
另外 2 个论点
浏览器有意削弱 Web 程序能做的事,而正是这种限制,使随意的浏览成为可行。
运行来自互联网的代码,是机器被感染、数据被偷、账户被黑的途径,而你对你所访问的大多数网站背后的人所知甚少;所以浏览器封锁对你的文件和页面之外一切东西的访问。作者补上条件:这是一次走钢丝,因为与其他服务器通信或读剪贴板,既有用,又侵犯隐私。
今天相对统一、bug 较少的浏览器格局,来自有几个彼此竞争的厂商,而重新集中会使它面临危险。
两边都有历史:2003 年前后,在 Mosaic、Netscape,然后是 Internet Explorer 相继单一厂商主导之下,开发者面对的是两三个互不兼容、满是浏览器特有 bug 的平台。然后 Firefox、Chrome 和 Safari 造就了四个主要玩家,它们对标准更严格的态度减少了不兼容,微软也随之推出了 Edge。作者随后把论证掉转过来:随着 Firefox 萎缩、Edge 在 2018 年成了 Chrome 引擎的一个包装,统一可能又意味着一家厂商——谷歌——在支配 Web。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
分层的协议如何藏起下面的凌乱
原始的网络只是把比特射向另一台机器,不保证次序,也不保证抵达。TCP 加上了监听端口,以及可靠的有序交付,这才是真正困难的部分;HTTP 随后写在它上面,可以直接把连接当作一条往里扔比特的流,所以每一层只解决它底下那一层已经抽象掉的东西。
机制 2
为什么沙箱得是一个笼子
访问一个页面,意味着运行陌生人写的代码,所以浏览器不让它访问你的文件,以及它自己页面之外的任何东西。这让你不必信任任何人就能浏览,但那条边界很难划:让页面有用的能力,比如联系其他服务器或读取剪贴板,正是那些能造成隐私侵犯的能力,所以这些限制要随着新的逃逸出现而被调整、被修补。
机制 3
一家独大的浏览器如何使 Web 分裂
当单个浏览器拥有大多数用户时,它的厂商可以自行发明特性;网站作者采用它们,因为几乎人人都有那个浏览器,而其他浏览器干脆被忽略。结果不是一个 Web,而是两三个互不兼容的平台,各带各的 bug——这就是为什么推着厂商走向共同标准的,是多个严肃的竞争者,而不是一个市场领头羊。
常见误读
既然 HTML 解析会补上你省略的东西,连 doctype 和字符集声明也可以省掉。
宽容的行为涵盖的是缺失的结构性标签——浏览器可靠地补上 html、head 和 body,隐式地关闭段落,接受不带引号的属性值,一切都按标准化的方式。doctype 和字符集则是另一回事:doctype 使页面被当作现代 HTML 来读,省掉任何一个,浏览器的行为就会荒唐,这就是为什么作者把它们当作隐含存在于每个缩短了的例子里。
因为浏览器里的 JavaScript 运行在沙箱里,你打开的任何页面都是无害的。
沙箱是一场持续的较量,而不是一份已经了结的保证:人们不断找到绕过浏览器限制的办法,后果小到泄漏一点私人信息,大到接管整台机器,而每个漏洞只有在被发现之后才被堵上——有时它还被悄悄利用,而不是被报告。这条边界也必须保持得足够宽松才有用,这就让两用的能力一直在场。
第 13 章的核心概念
- 网络协议
- 在网络上通信的一种约定的方式,使双方都知道比特的含义。
- 适用处: 只描述约定;大多数协议建立在其他协议之上,而不直接碰原始的线路。
- HTTP
- 浏览器向服务器索取一个有名字的资源(如页面或图片)所用的协议。
- 适用处: 只涵盖请求和打包资源;它依赖 TCP 来做有序的交付。
- TCP
- 一种协议,把原始的射出比特,变成一个监听的服务器与一个连接的客户端之间的有序的双向管道。
- 适用处: 每个监听者需要一个端口号;它可靠地交付字节,却对其含义只字不提。
- URL
- Web 上一个文档的名字,由协议、服务器和路径构成。
- 适用处: 命名一个资源;解析服务器那一部分,仍需要把域名翻译成 IP 地址。
- HTML
- 网页的文档格式:文本加上赋予它结构的标签。
- 适用处: 被容错地解析,所以缺失的标签会被补上——但 doctype 和字符集仍应写出来。
- 沙箱化
- 把一个下载来的程序隔离起来,使它能作用于它的页面,却不能作用于你的文件或机器。
- 适用处: 硬性的限度是有用与安全之间的权衡;新的逃逸漏洞不断被发现、被修补。
另外 1 个概念
- 实体
- HTML 里一个由和号加名字构成的转义,代表一个有特殊含义的字符。
- 适用处: 与 JavaScript 字符串里的反斜杠转义相仿,所以和号本身也必须被转义。
读后感AI 陪读
Web 的安全靠研究者的道德,而不是工程
一章貌似谦逊:它看上去像背景性的管道(TCP、URL、标签),但它真正的论证是历史的、政治的。Web 被呈现为沉积而不是设计——临时凑出的厂商特性,容错的解析,一个以游乐场命名、却造得像监狱的沙箱——而作者在坦白时,始终比在解释时更有意思。有两招很突出。第一,他在开头搭起的框架(去中心化既是福也是祸),被他自己的结尾一段引爆,在那里 Edge 并入了 Chromium,而那个祸原来是:去中心化的过程终究交付了单一厂商的控制。第二,沙箱一节悄悄把 Web 安全从工程挪到了伦理:限制之所以成立,是因为研究者选择披露而不是出售。伯纳斯-李(Berners-Lee)那段关于一个普遍共享空间的题词,读到最后一页,几乎成了挽歌。在教学上,他对自己走的捷径异乎寻常地坦率(在例子里去掉 doctype,同时警告你别这么做),这换来信任。没被检验的是:为什么容错是正确的选择,尽管它造出了一个没人能干净地写出规范的系统;以及沙箱化真正的任务,是保护用户,还是让不受信任的代码在商业上可行。
在 FeedLitra 里读第 13 章,每段旁边都有这些卡片。
开始读书第 14 章:文档对象模型
querySelectorAll 是怎样把选择器语法变成搜索工具的?
第 14 章是怎样展开的AI 辅助
本章一开头就引入浏览器对一个已加载页面的表示:服务器发来的 HTML 被解析成一棵由有类型的节点构成的实时的树,而这棵树——DOM——才是 JavaScript 实际操纵的东西。作者的第一个动作是一条说明,而不是欢呼。这个接口从 JavaScript 里用起来别扭,因为它从来不是专为 JavaScript 设计的;它被规定为跨语言工作,而代价马上就在像一个节点的孩子这样基本的东西上显出来:它们以 NodeList 的形式到来,长得像数组,却不提供 slice、map 或其他任何数组方法。作者承认,与语言无关的标准往往值回它们的价格,却不肯被说服相信这里是如此。这定下了本章的调子:DOM 是你必须打交道的东西,而学它的一部分,就是学它会在哪里绊你一跤。
从这里起,本章展开找节点这件实务。沿着一条固定的孩子链走到一个元素,被显示是脆弱的,因为这样一条路径把关于文档确切形状的假设写死了。演示小得令人难忘:一个看上去有三个孩子——一个标题、两个段落——的 body,其实有七个,因为标签之间的空白也算文本节点。穿过那棵树的任何一条按索引的路线,都离断掉只差一个多余的换行。补救是改为搜索:按标签、按 id、按类,或按 CSS 选择器。确立了怎样到达节点之后,本章转向改变它们,靠节点方法和 style 属性,然后发出它最锐利的性能警告。因为浏览器通常推迟计算尺寸和位置,索取像 offsetHeight 这样的值,会逼这件工作立刻发生——而此后对文档的任何改变,都会把这个结果丢掉。所以交替读和写的代码,让浏览器一遍又一遍地重算布局。作者给两个程序计时,各自搭出一行 2000 像素宽的 X 字符:每加一个字符就测量一次的,花约 32 毫秒,而只测一个字符、其余靠计算的,花约 1 毫秒。这些是单次示范运行的数字,但比例才是要点。
本章靠后的几节在转向之前,磨尖了两个细节。第一,querySelectorAll 返回的集合是一份冻结的快照——与 getElementsByTagName 对比,后者的结果随文档的变化不断更新——却仍然不是真正的数组;Array.from 才既产出一个真正的数组,也产出一份可靠的副本。作者还演示了选择器能表达多少,用一个计数函数,其中 “p”、“.animal”、“p .animal” 和 “p > .animal” 各匹配不同数量的元素,最后一个只限于段落的直接孩子。第二,本章讲了 CSS 的 position 取值,因为在元素仍是静态定位时,给 top 和 left 赋数字一无所成:relative 把它从本该坐的地方挪开,absolute 把它从文档流里拿出来,并从最近的非静态祖先度量,没有祖先时则从文档度量。
转折,也是收束,是从操纵到运动。本章在这里作出它第二个机制性的论证:JavaScript 还在运行时,什么都不会被重绘,也不会处理任何点击,所以一个移动元素的循环,根本不会显示中间的帧。动画必须让出——requestAnimationFrame 把控制权交回去,浏览器就能绘制,然后在下一次重绘之前再叫你一次。演练的例子是一张给了相对定位的猫的图片,它的 top 和 left 在每一帧都被改写,水平偏移取自缩放 200 倍的余弦,垂直偏移取自缩放 20 倍的正弦,于是猫描出一个宽远大于高的椭圆——并且加了“px”,因为光秃秃的数字对样式系统毫无意义。最后的改进是最容易被漏掉的:因为回调不保证按固定的节奏到来,角度是由实际经过了多少时间推出来的,而不是每次调用走固定的一步。一个晚到的帧于是让猫走得更远,而不是让它变慢,动画的速度不再取决于浏览器碰巧多久叫你一次。本章于是结束于它开始的地方,即 DOM 呈现自己的方式与它必须实际被使用的方式之间的缺口——只不过这一回,这一课讲的是时间,而不是树。
关键论点,以及支撑它们的原文
DOM 从 JavaScript 里用起来别扭,是因为它从来不是专为 JavaScript 造的。
作者指出,这个接口被设计成跨语言工作,并在实践中显示出代价:一个节点的孩子列表是 NodeList,它看起来像数组,却不提供 slice 或 map 之类的任何数组方法。他承认,与语言无关的标准往往值得,只是在这个例子里并没有说服力。
沿着一条固定的孩子链走到一个元素是脆弱的;改为按标签、id、类或选择器去搜索它。
这样的路径把关于页面确切形状的假设写死了。作者例子里的 body 看上去有三个孩子,一个标题和两个段落,但标签之间的空白也算文本节点,所以它实际有七个——足以弄断任何按索引的路线。
在同一个循环里把读取布局信息与修改文档混在一起,会极其慢。
顺着那个警告,作者给两个都搭出一行 2000 像素宽的 X 字符的程序计时:每加一个字符就测量一次的,花约 32 毫秒,而只测一次单个字符、其余靠计算的,花约 1 毫秒。这些数字来自一次示范运行。
另外 2 个论点
querySelectorAll 交回的集合是一份冻结的快照,却仍然不是真正的数组。
作者拿它与 getElementsByTagName 对比,后者的结果随文档的变化不断更新,并用一个计数函数演示选择器的威力:其中 “p”、“.animal”、“p .animal” 和 “p > .animal” 各匹配不同数量的元素——最后一个只匹配段落的直接孩子。
平滑的运动来自在两次重绘之间一次次轻推样式,而不是来自一个长时间运行的循环。
演练的例子是一张给了相对定位的猫的图片,它的 top 和 left 在每个动画帧都被改写;左边的偏移用缩放 200 倍的余弦,垂直的用缩放 20 倍的正弦,于是猫描出一个宽远大于高的椭圆。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么读取布局会拖慢循环
浏览器通常推迟计算元素的尺寸和位置,但索取像 offsetHeight 这样的东西,会逼它立刻算出来。改变文档会把那个结果丢掉,所以轮流读和写的代码,使浏览器一遍又一遍地重算布局。
机制 2
为什么动画必须让出
JavaScript 还在运行时,什么都不会被重绘,也不会处理任何点击,所以一个移动元素的循环,永远不会显示中间的帧。通过 requestAnimationFrame 交回控制权,浏览器就能绘制,然后在下一次重绘之前再叫你一次。
机制 3
把经过的时间当作时钟
回调不保证按固定的节奏到来,所以由实际过去了多少时间来推出角度,意味着一个晚到的帧只是让元素走得更远。每次调用走固定的一步,则把速度绑在浏览器碰巧多久叫你一次上。
常见误读
像 childNodes 或 querySelectorAll 的结果这样的节点集合是数组,所以 map 和 slice 这样的数组方法对它们有效。
它们是类数组的:你有 length 和索引访问,却没有数组方法。用 Array.from 复制,得到一个真正的数组——以及一份稳固的快照,这也要紧,因为有些集合,不像 querySelectorAll 的结果,会随文档不断变化。
给一个元素的 top 和 left 样式赋数字,就足以把它挪到页面上的任何地方。
偏移只有在元素离开朴素的静态定位之后才生效:relative 把它从本该坐的地方挪开,absolute 把它拽出流外,并从最近的非静态祖先度量,没有时则从文档度量。数值也需要单位,这就是动画给它算出的数字加上“px”的原因。
第 14 章的核心概念
- 文档对象模型
- 浏览器对一个已解析的 HTML 页面的对象表示,每个盒子/标签一个对象。
- 适用处: 它是一个跨语言的、面向 HTML 和 XML 的接口,不是 JavaScript 原生的 API。
- 树
- 一种有分支、无环、有一个明确根的结构,如 DOM 或语法树。
- 适用处: 要求无环和单一的根;document.documentElement 是 DOM 的根。
- 实时节点集合
- 像 childNodes 或 getElementsByTagName 的结果这样的集合,会随文档的变化自己更新。
- 适用处: 要一份稳固的快照,用 Array.from;NodeList 没有 slice 和 map。
- 布局
- 每个元素计算出来的尺寸和位置,浏览器把它推迟到需要时。
- 适用处: 读取 offsetHeight 或 getBoundingClientRect 会强制进行一次布局计算。
- 层叠样式表
- 一组组规则,其声明相互合并,由优先级和次序决定谁胜出。
- 适用处: 次序只在优先级相等的规则之间起决定作用;内联的 style 属性总是胜出。
- 定位
- position 样式属性决定一个元素是留在正常流里(static/relative),还是被拽出流外(absolute),使 top 和 left 能挪动它。
- 适用处: 绝对的偏移从最近的外围非静态祖先度量,若不存在则从文档度量。
另外 2 个概念
- 动画帧调度
- 请浏览器在每次重绘之前调用你的函数,使更新大约每秒发生 60 次,而不是阻塞页面。
- 适用处: 只在窗口或标签页处于活动状态时,才以良好的速率运行。
- 弧度
- 一种角度单位,一整圈是 2π,而不是 360 度;Math.cos 和 Math.sin 按这个单位读它们的参数。
- 适用处: 大于 2π 或小于 0 的角度是有效的,因为旋转会重复。
读后感AI 陪读
你看到的页面是一个可变的对象,不是一份文档
本章以一段题词开头:房子盖完了,才学到你本该先知道的东西——这恰是进入 DOM 的正确之门,一个接口,是在没人知道 Web 会成为什么的时候就做出的那些决定的残余。从这里起,它按一个有意的次序展开:浏览器把 HTML 解析成一棵有类型的节点树;这棵树被描述为一种实时的数据结构;而其后的一切,都是这一个词的推论。“实时”回报了三次——作为你的编辑出现在屏幕上的方便,作为 getElementsByTagName 里的陷阱,以及那个必须倒着跑的图片循环,也作为布局抖动基准里的代价:天真的读取花 32 毫秒,而只算一次的重写只花 1 毫秒。通读下来,本章真正的论点,是那句话:你看到的页面,是一个可变对象的渲染,而不是一份文档。视觉、遍历、样式、动画,都是它的推论。
我看穿的是:这主要不是一次 API 巡礼。作者公开裁定不利于他所教的那个标准——与你所用语言的整合,胜过跨语言的熟悉接口——并承认 DOM 代码会变得又长又重复又难看,然后才递给你 elt,那最小可能的补救,以及,事后看来,jQuery 和 React 的起源故事。他在训练对接口的判断,而不只是对某一个接口的熟练。把空白当作节点的那个细节是最尖锐的例子:你数出三个孩子的地方,其实有七个。要点不在小知识,而在你的心智模型与浏览器的模型之间的差距,这就是倾向选择器而不是索引、并怀疑任何你没验证过的结构假设的理由。querySelectorAll 的非实时结果,随后是对实时集合陷阱的安静的反衡,而宁可隐藏而不是移除节点,则夹带了一条远大于 CSS 的原则:偏爱可逆的状态改变,而不是破坏性的。
继续阅读
动画一节是本章挣得它结尾的地方。浏览器在 JavaScript 运行期间不重绘这一事实,重新框定了整幅单线程的图景——你的代码和绘制是对手,而 requestAnimationFrame 不是计时器,而是一次让出。用经过的时间、而不是帧数来驱动角度,只是一行,却把在负载重的机器上还能撑住的动画,与会卡顿的动画分了开来。有两个细节值得比它们得到的更多的标注:“顺时针”之所以成立,只因为屏幕上的 y 向下增长,所以浏览器那根翻转的轴在默默干活;而椭圆根本不是画出来的形状,只是正弦和余弦被各自独立地缩放——屏幕上的几何,就是对两个数的算术。单位的规则是最恶劣的馈赠:因为 0 无论带不带单位都是同一个意思,不带单位的代码在静止时看上去是对的,一动起来就死。我总体上最尖锐的判断是:本章的强项是解释了那个倒着的循环和那个基准,而不是把它们藏起来;它的弱点,是让一些习惯未加标注就放了过去——翻转的轴,以及表格习题里那条“从第一行取属性名”的图式,对固定的山脉数据集没问题,对真实数据却是致命的。
习题是在收束这条弧线,而不是装点它。禁用 querySelectorAll,逼你对树手写递归,使那个内置函数不再像魔法;而只有在两道规定得很紧的任务之后,“以别的某种有意思的方式修改动画”才到来——第一道没有正确答案的提示,恰在机制已经在手之时才给予许可。
我要对读者说的是:抓牢三件事。第一,把文档当作可变的状态,而不是标记——本章里每一个意外,都由此而来。第二,成批处理:读一次布局,计算,写一次,因为读取会逼出浏览器本想推迟的工作。第三,永远别按索引导航,也永远别信任那种只在没有东西变化时才管用的代码——实时性、空白和缺失的单位,恰恰都以那种方式失败,悄悄地,而且是在之后。
在 FeedLitra 里读第 14 章,每段旁边都有这些卡片。
开始读书第 15 章:处理事件
stopPropagation 与 preventDefault 究竟有什么不同?
第 15 章是怎样展开的AI 辅助
第15章开头不是一个 API,而是一个姿态的问题:一个程序该怎样得知某件事发生了?作者先摆出天真的替代方案——一个反复检查输入的程序——并显示它为什么行不通。轮询逼程序员记得足够频繁地去看,而一次按键可能在两次检查之间的空隙里到来又消失;更糟的是,任何冗长的计算都成了负担,因为它运行的时候,没有东西在检查。所以本章的开篇一招,是主张通知胜过检查:让浏览器来告诉你。这个框架挣得了第一个机制 addEventListener,本章也立刻在这里推了一个偏好。登记的监听器可以在一个元素上不限量地叠加,而 onclick 是单个属性,所以只容许一个处理函数。这也是通向移除的自然门径,这就是为什么“只做一次”的按钮要登记一个有名字的函数——取消登记意味着交回同一个函数值,这是匿名函数提供不了的。
它如何展开
从登记起,本章顺着一个事件的解剖向外搭建。处理函数收到一个携带着发生了什么的细节的事件对象,从这里起,文字引入传播模型,那是前半部分的概念中心。一个事件在它发生的最内层节点上触发,然后穿过每一个祖先,向外传到文档根,最具体的处理函数最先运行。随之而来有两个后果,而本章两个都倚重:外层元素上的一个处理函数,可以服务嵌套在里面的一切,因为事件的 target 报告究竟是哪个内层节点引发了这件事;而处理函数可以用 stopPropagation 叫停那趟向外的旅程。与此相配的,是浏览器自己的默认行为这件单独的事——跟随一个链接,按键时滚动——由 preventDefault 压制,而文字把它与传播分开,而不是把两者混为一谈。
随后对事件类型的巡礼,是本章变得具体的地方,也是它发出最锐利告诫的地方。按键事件被讲了,随后又被部分撇清:它们是发现一个人正在输入什么文本的糟糕工具,因为 Android 的屏幕键盘根本不触发,而面向大字符集的输入法软件,会把好几次按键折成一个字符。补救是监听 input 事件,读取字段的当前值。指针和触摸事件也受同样的现实主义支配。那条靠鼠标拖动的可调整大小的栏,是一个完全合格的演示,却在触摸屏上干脆不工作,触摸屏没有按钮,还可能同时报告好几根手指——所以触摸交互需要它自己的处理,而不是被假定为白送。滚动、焦点和加载事件补全了这次巡礼,各自带着传播和时序上的怪癖。
它在哪里转折
转折来在本章不再描述单个事件、转而问它们全体有什么共同之处的时候:同一个事件循环。所有被安排的工作共享单一的队列,一次运行一块,而处理函数只在没有别的东西在运行时才运行。作者早已确立,交互只通过事件发生;把这两个事实合起来,就得出本章主要的性能论断——一个过长的处理函数或一个忙着的循环,会拖住排在它后面的一切,所以点击和按键在它结束之前都感觉迟钝。这重新框定了整个前半部分:处理函数不只是回调,它们是共享一条时间线的房客。
接着是两个逃生口。Web worker 在它自己的时间线上运行脚本的一份独立副本,本章把它们呈现为在构造上就绕开了共享状态的惯常险处:worker 看不到父级的任何变量,通信只经由 postMessage,只有能用 JSON 表示的值才能越过边界,而接收方得到的是副本,而不是原件。演示被有意写得很小——一个把收到的每个数字平方后回复的 worker,父级在 Worker 对象上监听。第二个逃生口是定时器。超时可以被取消,而这种取消为防抖的模式担保:对成串到来的事件,处理函数自己几乎什么也不该做,只清除待处理的超时、再设一个新的,这样真正的工作,只在这一串事件停顿之后才发生。文本框的例子把这一点说成了字面——每次 input 重置一个 500 毫秒的计时器,消息只在打字停下之后才记录——而作者指出,取消一个从未存在或已经触发过的计时器是无害的,这恰是代码如此之短的原因。还提到一种变体:不是等待停顿,而是在事件持续到来的同时,按间隔作出响应。
它如何收尾
结尾对这本书来说是常规的,与刚才描述的弧线一致。本章把材料汇成一份事件处理的小结——登记、事件对象、传播、默认动作、所巡礼的事件类型,以及 worker 和定时器这些循环层面的关切——然后交给读者习题,去亲手练这些机制。整体的形状是一个单一的论证,而不是一份目录:通知胜过轮询,所以登记处理函数;处理函数住在一个共享的循环里,所以要让它们轻量;而当工作确实不可能轻量时,把它挪出主时间线,或放到一个定时器后面。
关键论点,以及支撑它们的原文
被通知有输入,胜过自己反复检查它。
作者推理说,一个轮询输入的程序,得记得足够频繁地去看:一次按键可能在两次检查之间来了又走,被彻底错过,而任何其他冗长的计算都变得有风险,因为它会让检查停摆。通知卸掉了这份负担。
用 addEventListener 登记处理函数,胜过用 on* 属性。
一个元素可以持有任意数量登记的监听器,却只有一个 onclick 属性,所以属性风格只容许一个处理函数。这引向移除:只做一次的按钮之所以登记一个有名字的函数,恰恰因为取消登记需要交回同一个函数值。
按键事件是弄清一个人在输入什么文本的糟糕办法。
作者指向真实的平台:Android 的屏幕键盘根本不触发按键事件,而面向大字符集的输入法软件,把好几次按键变成一个字符。监听 input 事件,再读字段的当前值,在这些情形里行得通。
另外 3 个论点
因为每个处理函数都共享同一个事件循环,安排的工作太多,会让整个页面迟钝。
这个论证把作者已经确立的两个事实串了起来:交互只通过事件发生,而事件只在没有别的东西在运行时才运行。所以一个忙着的循环或一个过长的处理函数,会拖延每一次交互。所说的逃生口,是为不得冻结页面的后台工作而设的 web worker。
Worker 靠把作用域分开、只传副本,绕开了共享数据的惯常险处。
worker 是一个独立的脚本,无法访问父级的变量;演示是一个小脚本,把它收到的每个数字平方,并用 postMessage 作答,而父级在 Worker 对象上监听。只有能用 JSON 表示的值才能越过,接收方得到的是副本,而不是原件。
针对高频触发事件的处理函数必须保持轻量,把任何真正的工作推到一个定时器后面。
文本框的例子说明了这一点:每次 input,处理函数就清除它先前的 500 毫秒计时器,再启动一个,所以消息只在打字停下之后才被记录。作者指出这个想法的两种形状——等待一次停顿,或在事件持续到来的同时按间隔作出响应。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么事件向外传
一个事件在它发生的最内层节点上触发,然后穿过父级向外移到文档根,最具体的处理函数最先运行。所以外层元素上的一个处理函数,可以捕获里面的一切,而事件的 target 告诉它是哪个内层节点引发了这件事。
机制 2
为什么处理函数会拖慢页面
所有被安排的工作共享一条队列,一次运行一块,而处理函数只在没有别的东西在运行时才运行。所以一个长的处理函数会拖住排在它后面的每一个事件,使点击和按键在它结束之前都感觉迟钝。
机制 3
超时如何驯服成串的事件
对成串重复的事件,处理函数自己几乎什么也不做:它取消待处理的超时,再设一个新的,所以真正的工作只在这一串事件停顿之后才发生。取消一个从未存在或已经触发过的计时器没有害处,这使代码保持简短。
常见误读
我可以靠收集 keydown 事件,重建出用户键入了什么。
在 input 事件到来时读取文本字段的值,因为有些平台——尤其是 Android 的虚拟键盘——根本不触发按键事件,而输入法软件会把几次按键合并成一个字符,所以按键流与文本并不吻合。
一个由鼠标事件构成的交互,比如拖动调整大小的栏,在手机或平板上也能用。
第 15 章的核心概念
- 事件处理函数
- 你为某个有名字的事件登记的函数,使浏览器在那个事件发生于被登记对象的上下文里时调用它。 登记在 DOM 元素上的函数,在某个特定类型的事件发生并传播时运行。
- 适用处: 只在事件发生于它所登记的那个对象的上下文里时才运行;移除它需要同一个函数值。 大多数事件在某个特定元素上触发,然后传播到祖先;传播和默认动作都可以被叫停。
- 轮询
- 自己反复检查一个队列或状态来察觉输入,而不是被通知。
- 适用处: 这牺牲响应性,因为从按键到你检查之间的任何延迟都感觉迟钝;大多数程序员避开它。
- 事件传播
- 一个事件从它发生的节点向外传到它的父级和文档根,最具体的处理函数最先运行。
- 适用处: focus、blur 和 load 事件不传播;stopPropagation 叫停向外的行程。
- 默认动作
- 浏览器对一个事件自己内置的响应,比如跟随链接或滚动。
- 适用处: preventDefault 对大多数默认行为有效,但对滚动事件或某些浏览器快捷键无效。
- 事件循环
- 那条单一的队列,一次运行一块被安排的工作,所以处理函数要等运行中的脚本结束。
- 适用处: 冗长或为数众多的处理函数会拖延所有的交互;web worker 就是为繁重的后台工作而存在的。
- Worker
- 一个独立的 JavaScript 进程,与主脚本不共享任何数据,只通过消息交谈。
- 适用处: 只有能用 JSON 表示的值才能越过;接收方得到的是副本,而不是原来的值。
另外 1 个概念
- 防抖
- 用一个超时,使针对迅速重复的事件的处理函数,只在一次停顿之后运行,或每个间隔至多运行一次。
- 适用处: 两种变体:靠 clearTimeout 等待停顿,或在一串事件进行时按间隔作出响应。
读后感AI 陪读
在错误的时刻正忙着,是隐藏的主题
第15章以一段斯多葛式的题词开头,说你对自己的头脑有力量,对外界的事件却没有,而整章最好读作对这句话的一段延伸的、略带反讽的注解:在浏览器里,你几乎无法控制事情何时发生,只能控制你登记了什么,来作为回应。控制是被转移的,而不是被获得的。本章真正的主题很早就、也很安静地宣告了自己——在那句警告里:耗时的计算可能让你错过一次按键——而随后的一切,都是对这同一份焦虑的变奏:在错误的时刻正忙着。
在结构上,它从最小的单位向外移到最大的。先是处理函数:addEventListener,事件对象,以及绕道轮询的那个有意安排的迂回,作者耐心地用两段把它搭起来,却只用一句平淡的话把它打发掉——靠人为制造的挫败来教学,坏设计被造出来,是为了让好设计显得不可避免。然后是传播和 stopPropagation,默认动作和 preventDefault,然后是具体的事件家族:按键、指针、触摸、滚动、焦点、加载。然后镜头拉宽到事件循环、worker 和定时器,本章以防抖、一份小结,以及悄悄把一切重新测试一遍的习题收尾。
继续阅读
这条弧线里有两件事值得看透。第一,浏览器 API 的形状有很大一部分不是设计,而是疤痕组织。button 和 buttons 在位的次序上意见不一;触摸事件被装扮成鼠标事件,好让旧页面不至于坏掉,而随后的几页,就是那场客气的虚构寄来的账单;被滥用的特性,干脆不再显示。作者让这种堆积露出来,而不是假装有一个连贯的平台曾被规划过,而这份诚实,比一份更整齐的叙述更有教益。第二,权力是在被授予权力的地方被讨论的。preventDefault 被作为一种能力引入,同一口气里又被说成可能令人厌恶地干扰用户所期望的东西;滚动处理函数被列为对进度条有用,并带着一个玩笑,对把间谍报告发回你邪恶的总部也有用。伦理就住在 API 的描述里面,而不是在一个边栏里——同一个 API,相反的意图,而读者不被允许忘记第二种情形的存在。
最锐利的一刻,是题词倒转的地方。因为页面是单线程的,你自己的计算延迟了每一次交互;心智并没有主宰事件,心智饿着了它们。这种重新框定,使 worker 和防抖显得必要,而不是聪明。而防抖本身是本章最好的一段小小的工程写作:clearTimeout 对垃圾输入的容忍,恰恰让那四行的版本保持干净——一层允许的马虎,买来另一层的优雅——而节流的变体从一个变量里榨出两份活儿,守卫设定节奏,闭包在触发时读取最新的值,而赋值坐在 if 的外面,一个不留心的读者会把缩进读错。
那个安静的陷阱,日后最可能咬你的东西,是 removeEventListener 需要同一个函数值。箭头函数让处理函数写起来如此便宜,你就例行公事地丢掉了本需要用来收回一个的那个引用;方便与可逆彼此拉扯,本章让你感觉到这一点,而不只是说出来——气球习题把它的处理函数摘掉,作为一个关于无法给一场爆炸充气的包袱,而这个笑话就是那一课。习题大体都这样运作:气球那句“别滚动页面”的从句,就是 preventDefault 被教的全部理由,而它不肯点出那个方法的名字,所以你必须自己想起来;20 世纪 90 年代的鼠标轨迹有意地没品味,因为怀旧会让你愿意去造一个没用的东西,而这没用的东西操练了绝对定位、状态循环,以及——在它那个固定的、被重复利用的轨迹元素池里——与节流相同的纪律:永远别让一串事件去规定你的分配速率。标签页习题从 data-tabname 读标签,让标记自己声明自己的内容,是这里最真正贴近现实的东西。一处公允的省略:小结保留了冒泡,却完全放掉了捕获,这是对的——没有人的第一个心智模型应当从捕获阶段开始。
我要对读者说的是:别把这当作日后要查的事件名目录。把它读成一章关于时间的文字,关于与一个正等着你的人类共用一条线程。把传播模型学扎实,在你可能需要移除处理函数时留着它们的引用,伸手去拿 preventDefault 时要知道那是对用户做的一件事,并把防抖的模式内化——不是那段代码,而是这样一种直觉:一个每秒触发六十次的事件,是一份你有权谢绝的邀请。
在 FeedLitra 里读第 15 章,每段旁边都有这些卡片。
开始读书第 16 章:项目:平台游戏
下一段如何在没有物理引擎的情况下,让玩家在墙边停住?
第 16 章是怎样展开的AI 辅助
开篇:一个被描述成数据的游戏。本章开头不是让任何东西动起来的代码,而是一个表示的问题。一个关卡以普通字符串的形式到来——一张字符网格,每个符号都有含义——而第一件活儿,是把那段文本变成程序能对其作推理的对象。解析这一步把符号分成两类:背景方块,是世界里固定的特征(空地、墙、熔岩),和角色,那些会动、因此需要自己带位置和尺寸的对象的东西。由此而来的是配套的部件:一个持有背景网格和起始角色的 Level,一个把关卡与当前角色和状态打包在一起的 State,以及一个用于位置和速度算术的小小的 Vec 类。角色类——Player、Lava、Coin——各自知道自己的类型,以及怎样从一个网格字符创建出来。此时还没有任何东西在动;本章有意先搭词汇。
第一段展开:把绘图藏起来。在任何物理之前,本章先插入一层间接。渲染被整个推到一个固定的显示接口后面,交给游戏的是显示的构造函数——而不是一个实例。具体的实现 DOMDisplay,是以它碰巧用的机制命名的:背景网格变成一张表,每个角色变成一个绝对定位的、有颜色的方框。绘图代码本身简单得几乎令人难为情,而这正是要点:因为游戏逻辑从不直接调用任何绘图例程,整个渲染器日后可以换成别的东西——比如 canvas——而不用碰游戏。这是一个早早提出的架构主张,使此后建起来的一切都被迫遵守它。
转折:运动容易,碰到东西才难。本章随后点出真正的困难。移动一个角色,不过是把一个速度加到一个位置上;难的是决定当一个角色来到它不该在的地方时会发生什么。墙必须挡住运动,碰到的金币必须被收集,碰到的熔岩必须让关卡失败——而作者明说,一般性地回答这个问题,就意味着写一个物理引擎。所以本章不再追求一般性,而采用一个坦白说很谦逊的方案:一个 touches 函数,只问一个矩形是否与 1×1 方格网格里某种特定的背景类型重叠,外加角色之间简单的矩形重叠。每个角色得到自己的 update(它自己怎么动)和 collide(玩家碰到它时,它对状态做什么)。这是本章从结构转向行为的转轴,值得读成一次有意识的收窄抱负,而不是一个解决办法。
那个粗糙的补救,以及对它的诚实。碰撞规则本身直白得几乎令人吃惊:算出将会到达的新位置,如果它与墙重叠,就把这次移动整个丢掉。作者并不假装这是正确的。丢掉这次移动,会把角色停在离接触还差一点的某处,因为代码从不计算接触实际发生在哪里——所以总有一道缝隙。缝隙之所以看不见,是因为动画是以很小的增量推进的;剩下的距离不过是像素的几分之一。把时间步调大,同样的代码就会在玩家仍离地面很远时取消这次移动,让他悬在半空。这是本章关于批判地读一份教程的最清楚的一课:一项技术可以是在某个假设下够用,而不是正确,而这里的假设就是步子小。它还纠正了一种自然的误读——取消一次被挡住的移动,并不意味着玩家会恰好紧贴着墙停下。
化解:一条轴一条轴地来,然后是循环。碰撞规则的直白,逼出对玩家移动的一次改进。水平与垂直的运动被分别测试、分别接受,使一个被挡住的方向只取消它自己。站在地板上挡住垂直运动,但因为各轴是独立的,玩家仍能沿着地面往旁边跑——恰是平台游戏所需的行为。回报出现在跳跃里:当玩家向下运动、而碰撞检查失败时,被撞到的东西一定是地板,所以如果那一刻按着上箭头,这次碰撞就被重新解读为一个向上的冲量,而不是一次停止。重力每帧施加,而按键状态被追踪成一个当前按住的键的集合,而不是离散的事件,因为要紧的是一个键此刻是不是按着。本章以组装驱动帧的机制收尾:runAnimation 推进时间,runLevel 包进一个 Promise,使它在关卡结束时解析,runGame 把各关卡串起来并发放生命——一层整洁的最后收尾,把所有累积起来的部件变成可玩的东西。
关键论点,以及支撑它们的原文
渲染应当坐在一个显示接口后面,这样同一个游戏日后能以不同的方式绘制。
作者构建了以它所用的 DOM 元素命名的 DOMDisplay,并把它的构造函数传进游戏,恰恰是为了让另一个显示能取代它;绘图本身保持平凡,因为每个元素不过是一个有颜色、有位置的方框。
让角色动起来是容易的那一半;难的那一半,是当它们彼此相遇、与关卡相遇时会发生什么。
顺着状态与角色的搭法,作者列出了光靠运动回答不了的情形:墙必须叫停运动,碰到金币必须收集它,碰到熔岩必须让关卡失败。他指出,一般性地解决这个,就意味着写一个物理引擎,所以本章有意满足于一个谦逊的方案。
把被挡住的移动丢掉,只有因为动画以很小的增量推进,才站得住脚。
作者承认这种做法有问题,并说他靠保持步子小来掩盖它们,推理是:如果时间步——因而运动步——变得太大,这次移动就会在玩家还没到地面时被取消,让他悬在半空。
另外 1 个论点
玩家的运动必须一条轴一条轴地定下来,这样一个被挡住的方向就不会锁死另一个。
顺着碰撞方案,作者指出,撞到地板不能剥夺往旁边移动的能力,并在跳跃的情形里展示回报:当上箭头按着、而玩家在向下运动时,被撞到的东西就是地板,所以这次碰撞被变成一次向上的跳跃,而不是一次停止。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
小步长掩盖了粗糙的碰撞补救
当一次移动会与墙重叠时,游戏只是把整个移动丢掉,而不去算接触发生在哪里。这在角色与墙之间留下一道缝隙,所以这道缝隙只有在每个动画步都很小时才看不见;时间步一大,移动被丢得太早,玩家就明显地飘在地板上方。
机制 2
每条轴各自处理
玩家的水平和垂直运动被分别测试、分别接受,所以一个被挡住的方向只取消那个方向。因为地板只挡住垂直运动,玩家仍能沿着地面奔跑,而按着跳跃键时的向下碰撞,被重新解读为一次跳跃,而不是一次停止。
机制 3
藏在接口背后的绘图
所有渲染工作都只经由一个固定的显示接口到达,而显示的构造函数是交给游戏的,而不是写死的。既然游戏从不直接调用绘图代码,日后就可以换上另一个渲染器,而不碰游戏逻辑。
常见误读
既然被挡住的移动被取消了,玩家一定恰好紧贴着墙或地板停下。
取消移动,会把玩家停在离接触还差一点的某处,因为代码从不计算确切的接触点;它之所以看上去对,只因为每一步都是几分之一秒,而步子一大,同样的代码就会让玩家飘在地面上方。
第16章的碰撞方案是物体相互碰撞的通用解决办法。
它是一个有意谦逊的特例:重叠只针对矩形与 1×1 方格的网格或其他角色来测试,而作者说,一般地处理这类交互,需要一个物理引擎。
第 16 章的核心概念
- 接口
- 所有会动的元素在相同的属性和方法上达成一致,使游戏能统一地对待它们。
- 适用处: 只有共享的部分(size、pos、type、update)是统一的;每个角色的行为因类而异。
- 持久数据
- 更新游戏产生一个新的状态对象,而不是改变旧的。
- 适用处: 适用于 State 和角色,不适用于 DOM 元素,后者是被修改并替换的。
- 领域特定语言
- 关卡被写成字符网格,一种小巧的、人可编辑的地图记法。
- 适用处: 之所以行得通,只因为一切起初都可以对齐到网格;实际位置日后可以是小数。
- 碰撞检测
- 在接受一次移动之前,检查一个矩形是否与墙/熔岩的网格方块或另一个角色重叠。
- 适用处: 只针对 1x1 网格上的矩形;取消运动,而不是找出确切的接触点。
- Promise
- runLevel 返回一个 Promise,解析为关卡的结束状态,使 runGame 能按次序 await 各个关卡。
- 适用处: 在这里它只发出关卡完成的信号,而不是逐帧的进展。
- 时间步
- 自上一帧以来经过的秒数,设了上限,使隐藏的标签页不会让游戏疯狂地前进。
- 适用处: 必须保持很小,否则这套简单的碰撞办法会让玩家悬在空中。
另外 0 个概念
读后感AI 陪读
一份分阶段搭起的游戏教程,每一阶段都挣得下一阶段
本章既是这本书的凯旋圈,又是它最诚实的一段教学。它以童年的怀旧开头,然后几乎在同一口气里,劝你别把游戏编程当职业——这句旁白定下了此后一切的音域:有热情,不推销。随后是一个完整的、小小的平台游戏,建在一条不间断的弧线上,而这条弧线值得追踪,因为每一阶段都挣得下一阶段。先是数据:关卡是普通字符串,一次性被解析成由背景方块构成的 Level 和一份角色列表;一个把关卡、角色和状态打包在一起的 State;一个带 plus 和 times 的 Vec,使运动可以写成算术,而不是记账。然后是角色——Player、Lava、Coin——各自带着自己的尺寸、update 和 collide 行为,通过一个小小的共享接口、而不是类的层级被发现。然后是显示,被有意封在一个可替换的 DOMDisplay 后面,它把静态网格渲染成一张表,只做一次,并在每一帧把角色重绘为绝对定位的方框。只有到这时才是运动:touches 用来判断矩形与网格的重叠,逐轴的玩家移动带着重力和跳跃,按住的键的追踪,最后是 runAnimation/runLevel/runGame,把这一切变成帧和一连串关卡。这个次序就是那一课——先是世界,然后是东西,然后是画面,然后是时间。
细读之下我看穿的是:本章真正的主题不是物理,而是模型与感觉之间的距离。最承重的那个架构决定,是一个从句:网格是离散的,角色的坐标是小数。此后每一个机制都是它的推论——touches 里的 floor 和 ceil,要测试重叠而不是格子占用的必要,以及挪动几分之一像素的能力。第二个承重的决定也是文字而不是代码:运动按一条轴一条轴地解决,因为撞到地板不该取消你的水平奔跑。试试合在一起的向量,玩家就会粘在墙上;坐标轴的拆分恰恰是平台游戏之所以像平台游戏的原因,而它被写在一句你可能一扫而过的话里。第三个是跳跃,它的检测不是靠问“我是不是站在什么东西上”,而是靠注意到“我刚才在下落时被挡住了”——碰撞测试兼作地面传感器,这就干净利落地禁止了空中跳跃,而不需要任何一个额外的标志。这三个选择,没有一个耀眼,却就是本章。
继续阅读
我最尖锐的判断是:本章对它的模型哪里单薄,异乎寻常地坦率,而这份坦率比代码更有价值。“我们将采取简单的办法,并通过确保动画以小步前进来掩盖它的问题”——掩盖,而不是解决。那个钳制 Math.min(time - lastTime, 100),是同一份供认被压缩成一行:因为碰撞是在端点、而不是沿着路径检查的,一个大的时间步会让你径直穿过地板,而补救不是一个更好的模型,而是拒绝去走一个大到足以让误差显出来的步子。重力 30 和 jumpSpeed 17 被承认是“感觉对”的数字。泄漏的按键处理函数,在作者自己的习题里被定了罪,而不是悄悄留在身后。合起来,这相当于一条这本书诚实地说出、而大多数教科书不会说的工程标准:这里的正确,意味着误差低于感知,凭感觉调参是一种正当的方法,而不是一桩尴尬。与此并行的,是一个更小、更优雅的模式——一举两用的规则。把关卡之外的一切都当作“墙”,同时是一条游戏设计的法则(你出不了这个世界)和一条内存安全的法则(不会越界读取)。持久的 State 意味着一枚金币可以靠朴素的身份比较把自己删掉,而这之所以安全,只因为每个角色在每一帧都被全新地重建;修改会把它弄坏。弹跳的熔岩靠省略的第三个参数、而不是类型标志来编码它的变体。并非一切都一致:作者在一个 getter 里精打细算分配,却每秒六十次地重建每个角色的 DOM 节点——优化的直觉是局部的,不是有原则的,值得注意,免得你把它当成规则。
我要对读者说的是:别把这当作一章关于游戏的文字。把它当作这样一个演示:程序最重要的那些决定,往往是用一句文字表达的——离散/连续的拆分,坐标轴的分离,那个只因为更简单的镜头感觉更糟才存在的死区。亲手把它建出来,然后有意把它弄坏:把时间步的上限调高,看玩家掉出这个世界;把两条轴合起来,感觉自己粘在墙上;去掉金币随机的相位,看关卡变成一台机器。这些失败,比能用的版本教得更多。并把本章最后的诚实带在身上——在怪物的习题里,“靠近怪物的头顶”被有意地留成未指明,因为你选的容差,就是你这个游戏的难度。那一刻,这件活不再是物理,而成了与玩家期待之间的一场谈判,而这才是真正的游戏编程——在第一页就被警告别碰的那个——实际开始的地方。
在 FeedLitra 里读第 16 章,每段旁边都有这些卡片。
开始读书第 17 章:在 canvas 上绘图
对一个应用来说,什么时候 SVG 是比 canvas 更好的选择?
第 17 章是怎样展开的AI 辅助
它开头框定的是一个选择,而不是一项技术。在任何绘图发生之前,本章把三个选项并排摆出:给普通 DOM 元素加样式、SVG,以及 <canvas> 元素。最要紧的那个区分在这里被引入,并撑起第17章——SVG 保留你所创建的形状的描述,所以它们日后可以被移动或改变大小,而 canvas 只保留被涂上去的像素。canvas 上没有任何东西记得自己是一个形状。这一个事实被呈现为一次取舍,而不是一个裁决:你得到的是即刻的像素,放弃的是可编辑性。
随后它一个原语一个原语地展开 2D 绘图上下文,每一步都确立一种思维习惯。先是填充和描边,并随之而来本章第二个结构性想法:外观是上下文上的状态,事先赋给 fillStyle 和 lineWidth 这样的属性,而不是作为参数传给绘图调用。证明刻意写得朴素——由完全相同的 strokeRect 调用画出的两个方块看上去不同,因为 lineWidth 在两次之间被改过。路径随之而来,并把同样的逻辑延伸:没有可持有的路径值,只有累积在上下文里的副作用,所以一个形状就是你的调用的次序。一个填充的三角形只需要两次显式的 lineTo 调用,因为 fill 会替你把图形合上。从这里起,材料变宽——曲线,弧,一张其扇形角度由每个选项在回答中所占的份额算出来的饼图,文字,图片,以及精灵动画,在那里,每个新姿势之前的 clearRect,使开头的那个主张变得具体:要移动画出来的东西,你得先擦掉再重画。
转折随变换而来,它们的行为与此前的一切都不同。scale、rotate 和 translate 叠加在已经生效的任何变换之上,所以次序要紧,而其效果比设置它的函数活得更久。水平翻转的辅助函数,是你必须对次序和抵消作推理的演示:把坐标轴移到镜像线上,乘以 -1,再移回去。本章由此引出的是一条纪律——任何改变坐标的函数,都该先 save、后 restore,使这个改变不泄漏进以后的绘图里。递归的分形树是这条纪律见到回报的地方:每个分枝先保存,平移并旋转,递归,然后恢复,让兄弟分枝从干净的坐标起步。
累积起来的原语随后被派去干一件实质性的活。CanvasDisplay 取代了前面那个平台游戏的 DOM 显示,而它恰恰是由上面确立的那些约束造出来的。因为只存在像素,没有用来更新单个元素的把手,所以 syncState 每次都重新计算关卡的哪一部分可见——视口,被钳制得永远不会显示关卡之外的空间——并从头重绘背景和角色。精灵图提供图块和玩家的各种姿势,翻转的辅助函数把玩家转过身。不停的重绘,甚至吸收了一张只加载了一半的精灵图:头几帧可能渲染得不对,下一次刷新就纠正了它们。
它以倒转开头的比较收尾,并拒绝给这三种技术排名。基于 DOM 的图形,不论 HTML 还是 SVG,都保有一个数据结构,适合那种小部分经常变化、或单个形状需要自己的鼠标处理函数的图画;而只是重绘像素的 canvas,对海量的微小图形和像素级的效果更便宜。本章小心地点出了使这个问题消解的条件——游戏自己的显示没有文字,没有鼠标交互,元素也很少,所以三种里的任何一种都够用。随后是对 2D 接口的小结和习题。
关键论点,以及支撑它们的原文
canvas 元素给你即刻的像素,却放弃了可编辑性。
作者把它与 SVG 对照:在 SVG 里,原始的形状描述被保留,所以随时可以移动或改变大小,而 canvas 当场就栅格化了。后果被说得很具体——要移动一个画出来的形状,唯一的办法是清掉 canvas(或受影响的区域)再画一遍,就像精灵动画在每个新姿势之前先调用 clearRect 那样。
一幅画看上去怎样,是事先在上下文里设好的,而不是传进绘图调用里的。
填充的颜色、描边的粗细之类,都作为属性存在绘图上下文里。演示是两个由同样的 strokeRect 调用画出来的方块,却不一样:两次之间在上下文上改过 lineWidth,所以第二个方块的轮廓更粗。
形状是由一连串调用来描述的,而不是由你构造出来的一个值。
顺着上下文属性这个想法,路径接口完全靠对上下文的副作用来工作。一个填充的三角形只需要两次显式的 lineTo 调用,因为 fill 替你把图形合上了;同样的调用序列风格放大到饼图上:每一块扇形的角度由它在回答中所占的份额算出,画成一段弧,再加一条回到圆心的线。
另外 3 个论点
由于坐标变换会一直保留,任何改变它们的函数都该保存和恢复状态。
作者展示了一个水平翻转的辅助函数:把 y 轴挪到镜像线上,应用 -1 缩放,再挪回来——证明次序和抵消都得经过推理。随后递归树的例子依靠 save/restore:每个分枝先保存状态,平移并旋转,递归,然后恢复,使兄弟分枝从干净的坐标起步。
基于 canvas 的显示,每次更新都得重绘背景。
因为 canvas 上只有像素,事后没有任何把手可以更新单个元素。所以游戏的 syncState 先重新计算关卡的哪一部分可见,然后每次都从头重绘背景和角色——并且视口被钳制,永远不会显示关卡之外的空间。这也让一张只加载了一半的精灵图无害:头几帧可能渲染错,但不停的刷新会把它们纠正。
哪种图形技术胜出,取决于什么在变化、以及变化的有多少。
到最后把比较倒过来:基于 DOM 的办法(HTML 或 SVG)保有一个数据结构,所以适合小部分经常变化、或形状需要自己的鼠标处理函数的图画;而只是重绘像素的 canvas,对海量的微小形状和像素级的效果更便宜。作者点明了使这个问题消解的条件——游戏自己的显示没有文字,没有鼠标交互,元素也很少,所以三种技术都够用。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
canvas 保存像素,不保存形状
canvas 只存下被涂上去的像素,所以没有任何东西记得有个形状存在过。这就是为什么要移动什么东西,就得把 canvas(或其一部分)擦干净,再把一切重新涂一遍——不像 SVG,它把形状的描述留着,日后可以重新摆位或改变大小。
机制 2
路径由副作用搭成
2D 绘图接口没有可以拿在手里的路径值;每一次调用都悄悄地延长上下文里当前的那条路径。所以一个形状不过就是你调用的次序,而填充会隐式地把图形合上——一个三角形只需要两条显式的线,因为 fill 会把最后一个点接回第一个点。
机制 3
变换会叠加,而且会逗留
scale、rotate 和 translate 都是叠加在已经生效的任何变换之上的,所以次序要紧,而且效果比设置它的函数活得更久。因此一个镜像辅助函数要先挪动坐标轴,再乘以 -1 缩放,然后挪回去;而改变坐标的代码,应当先保存状态、事后再恢复,免得泄漏进以后的绘图。
常见误读
选颜色或线条粗细,是把它作为参数传给画形状的方法。
外观是绘图上下文所带的状态:你给 fillStyle 和 lineWidth 这样的属性赋值,此后每一次调用都用当前设着的那个值——这恰恰就是为什么两次完全相同的 strokeRect 调用,能画出轮廓粗细不同的方块。
因为 canvas 每一帧都得擦掉重画,所以它与 DOM 或 SVG 的显示相比,天生是效率更低的选择。
作者把它说成是有条件的取舍,而不是排名:有海量的微小形状或像素级效果时,重绘像素是更便宜的路,而当一幅大图里只有一小部分变化、或单个形状需要鼠标处理函数时,基于 DOM 的图形才划算。要求不高的场合,这个选择几乎无关紧要——游戏的显示用三种里的任何一种都能造出来。
第 17 章的核心概念
- canvas 元素
- 一个 DOM 元素,里头装着你用代码往里画的图;它只保存像素,不保存形状。
- 适用处: 要移动一个画出来的形状,就得先清掉再重画。
- 可缩放矢量图形
- 一种描述形状的 XML 标记方言,其元素留在 DOM 里,日后可以被移动或重新设置样式。
- 适用处: 第17章只是略述 SVG,并没有完整讲解。
- 绘图上下文
- getContext 返回的对象,它的方法和属性就是真正的绘图接口。
- 适用处: 样式存在上下文里,而不是放在方法的参数里。
- 路径
- 由连续的方法调用一点点搭起来的一串线段,而不是存成一个值。
- 适用处: 路径必须先闭合,才能被填充。
- 变换叠加
- scale、rotate 和 translate 各自都相对于已经生效的变换起作用,所以次序一变,结果就变。
- 适用处: 用 save/restore 或 resetTransform,别让变换泄漏出去。
- 位图图形
- 以像素数据、而不是以逻辑上的形状描述给出的图片。
- 适用处: 与第17章别处用的矢量绘图形成对照。
另外 3 个概念
- 接口
- CanvasDisplay 提供与 DOMDisplay 相同的 syncState/clear 方法,所以游戏可以不加改动地换用显示。
- 适用处: 只有方法签名一致;视口和 flipPlayer 之类的内部状态并不相同。
- 视口
- 一个被跟踪的矩形,说明关卡的哪一部分在屏幕上,取代 DOM 的滚动位置。
- 适用处: 被 Math.max/Math.min 钳住,使它永远不会显示关卡之外的空间。
- 精灵图
- 一张装了许多姿势的图片;drawImage 只把需要的那块矩形从中拷贝出来。
- 适用处: 精灵宽 24 像素,玩家宽 16,所以 x 和宽度需要用 playerXOverlap 来调整。
读后感AI 陪读
忘掉 2D 与矢量之争,要紧的全是记忆
第17章,从埃舍尔(Escher)的题词起,读来就是对一个想法的持续沉思:绘画是欺骗,而 canvas 是承认这一点的媒介。本章开头把在屏幕上放图片的三种办法排成一列——有样式的 DOM 节点、SVG 和 canvas——而浅层的读法是,这是一架性能的梯子。不是的。真正的轴,本章早早就说出,并在结尾的比较里回到它,是记忆:SVG 和 DOM 把你造出来的形状当作对象保留,而 canvas 立刻把它们转成有颜色的点,忘了它们的含义。此后的每一项技术,都是这种失忆症的推论。每帧重绘背景,clearRect,平台游戏的“一切重绘”做法,没有命中测试和无障碍——这些都不是这个 API 的怪癖,而是丢掉意图所开出的账单。如果你对这一章别的什么都没读进去,就读进这个:你不是在 2D 与矢量之间做选择,你是在选择这幅画是否记得它自己。
从这里起,本章按一个有意的次序搭建,而这个次序就是那一课。先是填充和描边的属性,然后是路径——而这里,这本书做了一件安静而勇敢的事。花了十六章主张值、组合和不可变性之后,它引入了一个完全建立在副作用上的接口,路径不是你能存起来、传来传去的东西,而是上下文里一份隐藏的累积。“古怪”这个词做了很多客气的活儿。同样的诚实出现在曲线周围:控制点有时有公式,有时你得去拨弄数字,直到看上去对。大多数编程教材会藏起这个承认;把它印出来是一种服务,因为试错式的几何,正是处理贝塞尔曲线的真实体验,而一个指望得到干净推导的读者,会以为自己做不好。
继续阅读
变换一节是本章最难的一段,而且它自己清楚。乘以 -1 来镜像是一行代码;要理解为什么第二个 translate 不是撤销,需要接受它是在一个已经翻转过的坐标框里执行的——本章那句“在镜像的宇宙里”,就是全部的困难被压缩进五个字,而那张三角形的图之所以存在,是因为光靠文字撑不起它。分形树随后拿出了为 save/restore 辩护的最好论据,办法是显示没有它会怎样:不是异常,不是崩溃,而是一幅合理的、颇为好看的错误画面。这恰是该拿来戏剧化的那种失败。在一个有状态的绘图 API 里,状态泄漏不会宣告自己;它们产出艺术,这就是为什么它们能熬过代码评审、毁掉人的下午。
平台游戏的重写——带着视口、清除再重绘的循环、精灵图和翻转的玩家的 CanvasDisplay——是本章不再教原语、开始展示品味的地方,也是细心的读者该保持戒备的地方,因为它的好几个动作比看上去的便宜。动画状态是由物理推出来的,而不是被追踪的,这在无状态上很优雅,却会在跳跃的最高点或在一个移动的平台上挑错帧。走路的循环靠挂钟时间、而不是走过的距离来驱动,所以脚永远与地面不真正吻合;没人注意到,但这是一个承认:这是一本翻页动画册,不是运动。精灵的步幅复用了已经被加宽的玩家宽度,只因为重叠的算术恰巧产出了真正的帧宽才是对的——改动一个常量,整张精灵图就悄悄错切了。而那句“用一张尚未加载的图片调用 drawImage 什么也不做”的宽慰,化解了一整类真实的加载 bug,但只因为帧循环每秒重跑六十次、会自我愈合;在一次性的渲染里,同样的代码交付的是一块空白的绘图区。这些不是书里的错误。它们是游戏代码里寻常的折中,而读者能带走的最锐利的东西,是那种辨别哪些方便是承重的、哪些只是走运的习惯。
本章收尾,是把这三种技术重新并排摆放,这回带着挣来的分量,而习题的瞄准异乎寻常地准——尤其是那道要求你不用新图片文件、画出一个倒置的角色的,它其实是在邀请你发明一个精灵缓存:翻转一次,画到一块离屏 canvas 上,此后一直直接贴图,用计算换内存。那是图形学里最古老的交易,而你自己走到它那里,比被告知更值钱。
我要对读者说的是:别把这当作日后要查的方法目录。把它当作一章关于有状态性的文字。canvas 是一个你靠副作用往里画的可变的全局对象,而其中几乎每一个困难——不能组合的路径,会叠加、必须被解开的变换,忘了自己含有什么的图画——都是同一个困难换了不同的衣服。把 save/restore 内化为纪律,而不是清理;假定任何你以后想要查询的东西,都得由你自己的数据结构来记住;并接受图形工作有一部分,就是轻推数字,直到形状看上去对。本章对这三点都很诚实,这比听上去的要少见。
在 FeedLitra 里读第 17 章,每段旁边都有这些卡片。
开始读书第 18 章:HTTP 与表单
Access-Control-Allow-Origin 与发往另一个域的凭据是如何相互作用的?
第 18 章是怎样展开的AI 辅助
开篇:页面底下的协议。本章开头远在 JavaScript 的层次之下,把一个 HTTP 请求和一个 HTTP 响应一块一块地拆开——方法、路径、头、状态码、正文。这个缓慢开头的回报,是一个关于含义、而不是关于语法的论点:方法声明意图,而 GET 尤其是一项承诺:服务器上的东西不会因此改变。作者把这一点立在可观察的客户端行为上,而不是为约定而约定。浏览器会不请自来地发出 GET 请求,预取它猜你想要的页面;它绝不会无端发出一个 POST。所以带副作用的 GET,会被软件代你悄悄触发那些副作用。从这个语义的地基出发,本章朝界面设计往外迈了一小步:既然方法已经带着含义,你就可以用资源加方法来搭一个界面,而不是去包装远程过程调用——向 /users/larry 发一个携带 JSON 用户文档的 PUT,而不是调用 addUser。地址指名事物,方法指名操作,而 HTTP 现成的机制(首推缓存)继续起作用。这个论断被小心地划了界:两种风格都能用,只是这一种暴露了你本来就在用的协议的更多部分。
展开:JavaScript 如何抵达线路。协议立住之后,本章转向浏览器代码接触它的三种办法。先是表单,而讲法对数据究竟走哪条路写得很具体:同一组字段,依 method 属性被送上两条不同的路——GET 时作为查询字符串附在 URL 后面,POST 时放进请求正文——两种情形都做百分号转义,好让别扭的字符能熬过这趟旅程。然后是 fetch,本章在这里种下它最尖锐的一处纠正。把一个没有出错就兑现的 promise 读成成功的信号,是很诱人的,但只要服务器作了回答,promise 就会兑现,404 和 500 也包括在内;状态码在 Response 上,要由你自己去查。拒绝只留给完全没有回答的情形——网络断了,主机名解析不了。随后是沙箱和跨域规则,再是 HTTPS,从网络本身的形状来论证:一个普通请求要穿过咖啡馆的 Wi-Fi、公司和国家的基础设施,每一跳都可能被读取或篡改,而 HTTPS 先验证证书、再加密,同时挫败冒充和窃听。作者在这里的措辞刻意保持有条件——运转正常时。伪造和被盗的证书、有漏洞的实现,从前都曾打破过它,所以诚实的读法是:HTTPS 抬高了攻击的成本,而不是取消了攻击。
转折:字段是对象,而不只是信封。本章随后从传输转向 DOM,这是它真正的重新框定。表单字段是普通的 DOM 节点,而本章把这个事实给你的把手一一列出:表单的 elements 集合既能按位置、也能按字段名取索引,每个字段带着一个指回上层的 form 属性,值可读可写,焦点可以挪,字段可以禁用,而浏览器内建的导航可以在 “submit” 处理函数里取消。结果是,字段不再只是向服务器运送数据的机制,而成了可能永远不提交任何东西的应用的构件。这一段的其余部分逐个走过各类型——文本字段上的选择 API,input 与 change 事件的区别,复选框和单选组,select 元素,最后是文件字段。读文件给出了本章最后一个机制层面的论证:磁盘访问慢,所以阻塞式的读取会冻结页面,浏览器于是返回一个在后台加载的 FileReader,内容就绪时触发一个 “load” 事件,其间别的一切照常运转。
收束:活得比页面更久的状态,然后是小结和习题。本章收尾,是把服务器整个去掉。它先诚实地掂量了另一条路——把数据留在服务器端,意味着账号和认证,大量额外的工作——然后表明,对一个小应用来说,浏览器本身就是足够的存储。演示是一个刻意粗糙的记笔记应用,它把整个状态序列化成 JSON,存在 localStorage 里的键 “Notes” 之下,刷新之后依然在。限制是说出来的,而不是含糊带过:存储按域划分,大小有上限,只存字符串。sessionStorage 作为寿命更短的兄弟出场。小结和习题收了本章,它走过的是一条连续的弧线——从线路上的字节,经过浏览器通向它们的三种接口,进入表示字段的 DOM 对象,再从另一头出来,到一个根本不需要线路的应用。
关键论点,以及支撑它们的原文
请求的方法是声明其意图的东西,而 GET 尤其绝不能改变服务器上的任何东西。
作者指出,客户端软件是按这些含义行事的:浏览器会自己乐意地发出 GET 请求,预取它猜你想要的页面,却不会无端发出一个 POST。如果 GET 有副作用,这种自动预取就会悄悄把它们触发。
围绕资源加 HTTP 方法来设计接口,而不是包装远程过程调用,让你能复用 HTTP 已经提供的东西。
顺着刚确立的方法语义,作者把两种风格对照起来:不去调用一个叫 addUser 的过程,而是向 /users/larry 发一个 PUT,把用户文档作为 JSON 带上。地址标识事物,方法说明操作,所以缓存和 HTTP 其余的机制都适用。两种风格仍然都能用;论断只是说,这一种暴露了协议的更多部分。
表单字段是普通的 DOM 元素,这正是让它们能被用作 JavaScript 应用的构件、而不只是向服务器发送数据的方式的原因。
作者展示了 DOM 给你的把手:表单的 elements 集合可以按位置或按字段名取索引,每个字段带着一个指回其表单的 form 属性。脚本可以读取值、挪动焦点、禁用字段,并在“submit”处理函数里取消表单内建的导航。
另外 1 个论点
对小应用来说,把状态留在浏览器里就够了——不需要服务器上的账号系统。
作者先掂量了另一条路:把数据存在服务器端,意味着用户账号和认证,大量额外的工作。演示是一个粗糙的记笔记应用,它把整个状态序列化成 JSON,存在 localStorage 里的键“Notes”之下,刷新后依然在。取舍是说明白了的:存储按域划分,大小有限制,只存字符串。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
表单数据走哪条路
表单的 method 属性把同一组字段送上两条不同的路:GET 时,名/值对作为查询字符串附在 URL 后面,而 POST 则把它们放进请求正文。无论哪种,值都做百分号转义,所以奇怪的字符也能熬过这趟旅程。
机制 2
HTTPS 为什么能抗窥探
一个普通请求要穿过咖啡馆的 Wi-Fi、公司和国家的网络,每一跳都可能被读取或篡改。HTTPS 先验证服务器的证书,再加密流量,所以拦截者既不能冒充这个站点,也读不到线路上经过的内容。
机制 3
文件读取为什么是异步的
从磁盘上拉字节很慢,所以阻塞式的读取会冻结页面。浏览器于是交回一个在后台加载的 FileReader,文本就绪时触发一个“load”事件,其间页面的其余部分继续运行。
常见误读
一次没有报错就返回的 fetch 调用,意味着请求成功了,所以这个 promise 可以当作成功的信号来信任。
只要服务器作了回答,promise 就会兑现,404 或 500 也一样——状态码是 Response 的一部分,得你自己去检查。拒绝只留给没有回答的情形:网络故障,或找不到服务器名。
一个站点一旦用了 HTTPS,连接就安全了,数据也就没事了。
作者的措辞刻意带着条件——HTTPS 在运转正常时,阻止冒充和窃听。实际上它曾被伪造或被盗的证书、被有漏洞的软件打破过,所以它抬高的是攻击的成本,而不是消除了攻击。
第 18 章的核心概念
- HTTP
- 浏览器和服务器用来搬运文档的请求与应答的约定。
- 适用处: 以 1.1 版作说明;2 版共用这些概念,但更复杂。
- Fetch
- 浏览器里发出 HTTP 请求的 JavaScript 接口,返回一个 Response 的 promise。
- 适用处: 遇到错误状态码也会兑现;只有网络故障或服务器未知时才会拒绝。
- URL 编码
- 把查询字符串里的字符转义成一个百分号加两位十六进制数字。
- 适用处: 用于查询字符串;JavaScript 提供 encodeURIComponent 和 decodeURIComponent。
- 沙箱化
- 浏览器会阻止页面脚本请求别的域,除非服务器同意。 浏览器只有在用户于文件字段里选中一个本地文件之后,才让脚本读取它。
- 适用处: 由 Access-Control-Allow-Origin 头按每个响应解除。 只适用于用户选中的文件;内容经 FileReader 异步到达。
- 默认动作
- 表单提交时内建的导航,可以在“submit”处理函数里取消。
- 适用处: 对 submit 事件对象调用 preventDefault 来阻止。
- 事件处理函数
- 登记在字段上的一个函数,用户改动或在其中输入时运行。
- 适用处: “change”只在字段被改动后失去焦点时触发;要对每次按键作出响应,用“input”。
另外 1 个概念
- 客户端存储
- 浏览器持有的、按名字取的字符串存储,比页面刷新活得更久。
- 适用处: 按域分隔,有大小限制,只存字符串;sessionStorage 在浏览器关闭时被清掉。
读后感AI 陪读
HTTP 是互相怀疑的双方之间的一场对话,而不是一次函数调用
第18章在它务实的表面之下,是一场关于协定的论证。它从蒂姆·伯纳斯-李那句关于一个“空间”、信息可以存在其中的话开始,而这条题词原来是承重的:HTTP 被一个方法接一个方法、一个头接一个头、一个空行再接正文地剖开,浮现出来的不是机器,而是礼仪。请求方法是意图的声明,从来不是权力——你尽可以叫服务器 DELETE 它的首页,它只会拒绝。全章一直保持这种口吻,把服务器拟人化:拒绝,怕自己做不到,声称自己就是它说的那个人,因为一个互相怀疑的双方之间的协议,确实是一场对话,而不是一次函数调用。
从这里起,本章按一个看上去像大杂烩、其实不是的次序展开:先是原始的 HTTP,然后是表单,然后是 fetch,然后是沙箱与 HTTPS,然后是 DOM 层面的字段 API,然后是存储。枢纽是安全一节里的一句话——控制脚本的人,其利益也许与脚本所运行的那台计算机的主人并不一致。下游的一切,都是这一个错位带来的工程后果。同源策略,Access-Control-Allow-Origin 头(留意钥匙握在谁手里:是被请求的服务器,而不是发出请求的页面——一种你无法代表自己放弃的保护,而粗心的后端又太轻易地把它发成“*”),作为“守门人”的文件输入框,用户的一次点击在那里变成一枚同意的令牌,还有 TLS,在那里威胁模型被刻意说得很具体:一个咖啡馆的热点和一个国家运营的网络,同一个数据包穿过这两者。就连那里的次序也有启发——先验证身份、后加密,因为通向冒名者的加密信道一文不值,这悄悄让证书颁发机构成了整个栈里最承重、也最脆弱的部件。
继续阅读
本章最犀利的东西,是作为旁白夹带进来的。GET 必须没有副作用,不是出于教条,而是因为浏览器会预取它认为你将会需要的链接——把删除动作做成 GET,浏览器自己的热心就会毁掉你的数据。fetch 在 404 上也成功地兑现,因为“成功”指的是对话发生了,而不是你得到了你要的东西;404 是把坏消息成功送达。而题为“欣赏 HTTP”的那一节,把力气花在一种它不赞成的做法上:把 HTTP 当作要被抽象掉的管道,什么也换不来,而把它当作词汇——资源、方法、状态码——才是让缓存和中间方得以运转的东西。表单一节是诚实的考古:这套设计假定每一次交互都意味着导航到一个新页面,而我们用 preventDefault 和拦截 submit 事件所做的每一件别扭事,都是我们在跟一个没人拿走的 1993 年的假定争论。同样的沉积也出现在 FileReader 的回调接口里——书里承认它早于 promise,你用一个五行的包装器就能修好——也出现在“change”对复选框是一回事、对文本字段又是另一回事里:一个历史的偶然,被当作规则来报告。
我最尖锐的判断是:本章真正的课程是,浏览器是一场协商,而不是一个运行时,好的网页代码是说出自己想要什么的代码,而不是攫取自己想要的东西的代码。这就是为什么 autofocus 胜过脚本里的 .focus() 调用——声明出来的意图,在你错的时候可以被礼貌地推翻;命令式的控制则不能。这就是为什么禁用提交按钮被说成是正确性、而不是锦上添花(被扣了两次款的信用卡,就住在那句话里),为什么 <select> 没法设计样式而单选按钮可以(那个催生了世上每一个自定义下拉库的取舍),也是为什么 elements 既是数组又是映射,方便得很,直到有个叫“length”的字段盖住了集合本身。
我要对读者说的是:别把这当作随手翻翻、日后再查的 API 巡礼。把 HTTP 的解剖读得足够仔细,仔细到你能徒手敲出一个请求,因为你将来用的每一个库,都是对它有损的包装,而你调试这层包装,靠的是对底下那段文本进行推理。永远检查 response.ok。假定你收到的每一项许可,都是别人的服务器授予的、也都可以被收回。把那些怪癖——change 的语义,FileReader 的形态,localStorage 只存字符串、并按源共享的作用域——当作要知道、而不是要辩护的沉积。并且认真对待哈塞海姆威的例子,别管那个笑话:selectionStart/selectionEnd 和 input 事件,确实是你将来要建的每一个自动补全的骨架。荒唐,就是助记。
在 FeedLitra 里读第 18 章,每段旁边都有这些卡片。
开始读书第 19 章:项目:像素画编辑器
不可变的图片之所以有用,第二个原因是什么?
第 19 章是怎样展开的AI 辅助
开篇:在任何代码之前先陈述的一个设计问题。本章开头不是编辑器,而是状态该住在哪里的问题。作者提出另一种办法——让界面的每一块自己保管一份数据——并展示它为什么行不通:如果当前的颜色住在颜色选取器自己的字段里,那个选取器就得更新自己,并且通知颜色出现的其他每一个地方,于是每一部分都得知道其他所有部分。此后统领一切的答案,是一个单一的状态对象,装着图片、当前的工具和颜色,而每一次改动都被汇入一个 update 函数。界面的各部分成为形状统一的组件——一个 dom 属性加一个 syncState 方法——所以它们只读自己关心的字段,也从不直接改动状态;它们把改动作为动作来宣告。这个小小的契约才是本章真正的主题,而编辑器是为检验它而造的东西。
展开:从值到像素到指针,一层层搭起来。架构定下之后,本章按依赖的次序把各部分装配起来。先是 Picture 类,刻意做成不可变的:绘制返回一张新图片,而不是去编辑旧的。然后是 canvas 组件,它的活是那层别扭的翻译——把原始的指针事件变成像素坐标,并跟踪一次拖拽。然后是控件:工具和颜色的选取器,以及工具本身。不可变性的决定在这里看得见地得到回报,而矩形工具就是演示。因为拖拽开始时的那张图片仍然是一个完好的值,每次指针移动都可以从那个原始的出发重新算出整个矩形,而不是画到当前的状态上。这恰恰就是为什么,当你把指针往起点拖回去时,预览可以重新缩小——如果先前的移动已经覆盖了像素,这种行为就不可能。
转折:从绘制到浏览器的边缘。本章随后向外转,从内部的逻辑转到平台。保存一张图片和加载一张现成的,都迫使它绕道 canvas 的 API,因为把一幅图画到 canvas 上,是拿到它各个像素的唯一路径:加载函数把图像放到 canvas 上,用 getImageData 取出原始字节——每个像素四个数——再把每个颜色三元组重新装配成一个十六进制字符串。务实的限制出现在这里(图像被限制在 100 乘 100,透明通道干脆丢掉),提醒你这个项目是个教学制品,不是产品。撤销随之而来,它是本章对先前一个决定最漂亮的回报。既然旧图片是未被触碰的值,保留它们几乎不花什么,所以每次改动之前,先把上一张图片压进一个历史数组。一个时间戳把这件事把住:一次快照最多一秒落一回,所以一笔长长的画刷笔画被合并成一个撤销步骤,而不是几百个——而撤销会把那个时间戳重置为零,保证紧接着的下一次改动一定被记录。对照一个常见的假定,值得说明:并不是每一次点击都成为它自己的撤销步骤。
收束:反思,然后是习题。startPixelEditor 用合理的默认值把各块拴在一起,而本章在退后一步中收尾。作者对浏览器技术的裁决是,它既强大又荒唐——而弄懂它为什么古怪,胜过对它生气。这个推理是历史性的,而不是技术性的:平台是在社会约束之下演进的,一个上百万网站已经依赖的功能,是撤不掉的。于是缺陷靠把新元素和新 API 堆在旧的上面来绕过,而这恰恰就是结果看上去不连贯的原因。常被忽略的实际推论是,那些别扭的部分不能简单地换成设计得更好的;它们只能被长出去、越过。习题随后把编辑器交回读者,让他们去扩展。
关键论点,以及支撑它们的原文
让界面的每一块各自持有自己的状态,会造成耦合,因为每一块于是都得知道其他块。
作者的例子是颜色选取器:如果当前的颜色住在选取器自己的字段里,那个工具就得自己更新该字段,还得通知任何其他显示这个颜色的地方——所以改为所有状态住在一个对象里,各部分把动作发给一个中央更新函数。
把图片当作一个不可改变的值,才使基于拖拽的工具行得通。
矩形工具就是演示:每次指针移动,都从拖拽开始时的那张图片重新算出矩形,而不是画到当前的图片上。这就是为什么把指针往回移时,预览可以重新缩小,而如果先前的像素已经被覆盖,这就不可能了。
要加载一张现成的图像,你必须先把它画到 canvas 上,因为这是拿到它各个像素的唯一办法。
加载函数把图像放到 canvas 上,用 getImageData 取出原始字节——每个像素四个数——再把每个颜色三元组变成 Picture 用的十六进制字符串。作者还把图像限制在 100 乘 100,并丢掉了透明通道。
另外 2 个论点
撤销最好用整张图片的零星快照来造,而不是用每一次具体改动的日志。
历史更新只在距离上一次存下的快照已超过一秒时,才存下上一张图片,所以一笔长长的画刷笔画被合并成一个撤销步骤,而不是几百个。之所以负担得起,恰恰是因为图片是从不被修改的值,所以旧的可以直接留着。
浏览器技术既强大又荒唐,弄懂它古怪的缘由,比对它们生气更有用。
作者的推理:平台是在社会与历史的约束下演进的,一个已经被上百万个网站使用的功能,是不能拿走的。于是缺陷靠把新元素和新 API 堆在旧的上面来绕过,这就是结果看上去不连贯的原因。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
一个状态对象,而不是散落的字段
如果界面的每一部分都存自己的那份状态,每一部分就都得知道还有谁持有同样的值,并让它们保持同步。保留一个状态对象、把所有更新都经过一个函数,意味着各部分只读它们需要的,并把改动作为动作来宣告。
机制 2
不可变的图片让拖拽可以重做
因为图片是一个被替换、而不是原地编辑的值,所以在拖拽进行当中,原始的那张仍然完好。因此矩形工具可以在每次指针移动时,从那个原始的出发重新算出整个形状,所以预览既能变大,也能缩小。
机制 3
撤销就是定时的快照
由于旧图片是未被触碰的值,保留它们很便宜:改动之前,先把上一张图片压进一个历史数组。一个时间戳把这件事把住,让快照最多一秒落一回,而撤销会重置那个时间戳,使下一次改动总是被记录。
常见误读
你每画一笔、每点一下,都会成为撤销历史里自己的一步。
历史只在距上一次快照至少过去一秒时才存一份,所以快速连续的改动被折成一个撤销步骤;例外是撤销之后,那时时间戳被重置为零,所以紧接着的下一次改动一定会被保存。
浏览器平台里那些别扭的部分,可以简单地换成设计得更好的。
一旦一个功能被上百万个网站依赖,删除就不是一个选项,所以平台靠在旧的旁边添加新元素和新接口来成长——这就是为什么作者建议去弄懂事情为什么是这个样子,而不是与它们对抗。
第 19 章的核心概念
- 组件
- 一个拥有一块 DOM 的对象,暴露一个 dom 属性和一个 syncState 方法。
- 适用处: 只持有它需要的那份状态;除非交给它一个 dispatch 函数,否则无法派发。
- 不可变性
- 把图片当作一个被替换、而不是原地编辑的值。
- 适用处: 批量编辑仍需要一个会拷贝像素数组的 draw 方法。
- 动作
- 一个描述状态更新的普通对象,交给一个中央函数。
- 适用处: 在这里,它只是通过对象展开去覆盖上一个状态的若干属性。
- 原始值的不可变性
- 由于每张图片都是一个永不改变的值,保留旧的副本用于撤销既便宜又安全。
- 适用处: 适用于这里的图片值;周围的应用状态仍然在每个动作上被整体替换。
- 撤销历史
- 一个装着先前图片的 done 数组,加一个 doneAt 时间戳,所以只记录相隔一秒的改动。
- 适用处: 只存相隔至少 1000 毫秒的快照,所以快速的笔画被合并成一个撤销步骤。
- 位图图形
- 从 canvas 读出一幅图像的原始像素字节,每个像素四个数,来重建一个 Picture。
- 适用处: 透明度被忽略,图像被限制在 100 乘 100 像素。
另外 1 个概念
- 解构
- 绑定名之后的默认值,让 startPixelEditor 能接受一个属性全部可选的对象。
- 适用处: 默认值只在属性缺失或为 undefined 时生效,对其他假值不生效。
读后感AI 陪读
颜色选取器打破了天真的设计,然后模块化作为解脱出现
第19章是全书最后一个长项目,它挣得这个位置,靠的是它与其说是关于像素,不如说是关于一个程序的结构是怎样被决定的。它开头是一个关于涂抹颜料的笑话——不用材料,不用天赋——而这原来就是全章的教学法:把走错一步的成本降下来,学习者就真的会去探索。随后代码把这个笑话变成了字面意义,因为接近结尾才出场的撤销历史,恰恰是一台让错步变得便宜的机器。
这一章的展开是有意归纳式的,而不是架构式的。马里金没有宣布一种状态管理模式;他让天真的办法当众失败,拿颜色选取器当那个破裂的案例——在那里,界面的每一部分都得知道其他每一部分。只有在你感受到这种非模块化之后,单一的状态对象、dom/syncState 的组件契约和那个唯一的更新函数才出场——而且是作为解脱出场,而不是作为教条。同样的一招后来在更高的层次上重演:他把浏览器里的各种库重新框定为对这个循环的人体工学包装,然后直截了当地告诉你,你刚刚造出来的,是一种粗糙的 React。这不是泄气。这是回报:亲手摇过这个循环之后,你现在知道框架是为了什么的,而这是理解一个框架唯一持久的办法。
继续阅读
最犀利的技术时刻是不可变的 Picture。不可变性在本章大部分时候读来像是一种品味,然后在一行里付了租金——矩形工具在每次指针移动时都从原始状态重画,所以实时预览不花任何成本,因为旧的图片哪儿也没去。这是我在教学文本里见过的、为持久数据结构所作的最好的单条论证,因为它是出于方便、而不是出于美德的论证。围绕它的诚实也要紧:DOM 的那些部分仍然充满副作用,而他也这么说了。纯粹性在这里没有被达成,而是被隔离了,而摆在眼前的真本事,是挑选这堆乱子被允许住在哪里。
我要给细心的读者指出的是,这份优雅在两处比看上去薄。整个架构的拱顶石,是一个函数内部对某个 let 的闭包——没有 store,没有库,只有一个函数作用域里的可变绑定——这确实漂亮,也恰好是那种两个组件一想同时写入就不再能扩展的东西。而填充算法的聪明——被画过的像素数组兼作工作列表——比行文承认的更紧:种子像素从不进入那个数组,这恰恰就是为什么必须另有一个 visited 集合。同样,撤销的粒度,那个 1000ms 的常量,是一个披着配置值外衣的判断:一笔拖拽的笔画被合并成一条历史记录,是出于时机的偶然,而不是出于任何关于用户意图的模型。小小的重复,比如 disabled: state.done.length == 0 既出现在构造函数里、又出现在 syncState 里,是手搓组件模型诚实的开销,而他毫不道歉地吃下了这个成本。留意到这笔开销,就是课程的一部分。
本章最好的文字根本不是代码。在对这个平台给了真心的赞扬之后,转折落下来了:浏览器技术是荒唐的。那个解释——一个被上百万个网站使用的功能,其实没法被取代——回溯地说明了书里每一个古怪的 API,包括本章自己那个假的链接点击和模拟的文件输入点击,平台在那里逼你去执行一个你自己在伪造的用户手势。兼容性是单向的棘轮,而不是无能。然后是这里最成年的一句话:与其对它怒吼、或死等另一个现实,不如顺着它的纹理去做事。他是在给初学者打疫苗,防的是程序员最爱的消遣:怨恨工具。
我要对读者说的是:把这一章亲手敲一遍,别扫过去。本章的价值是动觉的——你得先感到分派循环合上,抽象才有意义。然后认真对待习题,并像律师一样读它们的定义。“对角相邻的像素算作相连”这一句,悄悄指明了问题的困难版本,并排除了天真的填充;画线工具的难处,完全在于栅格化的规格,而不在于 DOM。马里金在这里为情绪预留了预算——要有充足的时间和耐心,别被最初的失败吓到——这很说明问题,因为这恰是一个自学的读者通常放弃的那一点。一本承认某一章会让人痛的教材,是一本指望你读完的教材。
在 FeedLitra 里读第 19 章,每段旁边都有这些卡片。
开始读书第 20 章:Node.js
文件服务器如何防范含有 .. 的路径?
第 20 章是怎样展开的AI 辅助
开篇:先于 API 的一个论证。本章不以安装说明或一段代码示例开头;它以一个辩护开头。一门浏览器的语言,为什么要去跑一个服务器?作者的回答有悖直觉:JavaScript 是个好的候选,恰恰因为它贫乏。它是少数几门出厂时没有任何内建设施来读输入或写输出的语言之一,而这个空缺意味着,没有任何根深蒂固的 I/O 惯例让一个新的运行时去与之争论。Node 可以把它自己相当古怪的网络和文件系统模型嫁接到这门语言上,而不与已有的任何东西相撞。与此配对的是统领此后一切的第二个前提:一个在读写完成之前停下来的程序,这期间无法为任何别人服务,而这种停顿是用户看得见的。所以 Node 把 I/O 默认做成异步的,靠回调、事件或 promise 稍后报告结果,而不是去阻塞那唯一的一条执行线。这两个论断——一张白纸,以及拒绝等待——是本章承重的假设,其余的材料都是它们的后果。
展开:平台,一块一块地装配起来。确立了这门语言合适之后,作者转向 Node 实际递给你的东西,而答案的形状是“模块,加上非常少的一组全局量”。document 和 prompt 之类的浏览器固有设施干脆缺席;你需要什么,就明确地去要。确实存在的那一小撮全局量,用 process.argv 来举例:它把命令行作为字符串交给你,真正的参数从下标 2 起——这是你的脚本与操作系统之间一个小而具体的接触点。从这里起,本章向外铺开:先是模块的故事(CommonJS 及其 require,对 ES 模块),然后是 NPM 和语义化版本,然后是自带的各个模块:管文件的 node:fs,管服务的 node:http,两者底下的流这个抽象,以及给更喜欢 promise 而不是回调的代码用的 node:fs/promises。HTTP 的引入被刻意写得很平淡:一个服务器本质上就是一个关于请求对象和响应对象的回调,每有一个客户端连上来就重新调用一次,大约八行就足以在 localhost:8000 上回答浏览器,给出一个回显所请求 URL 的页面。这种积累不是装饰——每一块,都是文件服务器日后会需要的一个部件。
转折:从一堆零件到一个必须正确的东西。本章的枢纽是文件服务器,语气在那里从“有什么”转为“什么会出错”。第一个担忧是安全。一个对 /../secret_file 的请求,否则就会爬出被提供服务的目录,而修法是个次序问题:先把请求的路径解析成一个完整的绝对路径,然后检查结果是否仍以基目录开头,不是就抛出。这道守卫作用于解析后的路径,而不是原始文本——在解析之前检查字符串,恰恰是这个设计避开的错误。第二个担忧是决定如何做出,而答案是,服务器的大部分判断是靠代码、而不是靠逻辑来承载的。文件不存在,会让 stat 抛出一个 code 为 ENOENT 的错误;GET 处理函数捕获这个特定的情形,别的则重新抛出,其余情况回答 404。GET 本身把前面的部件组合起来——用 stat 来分类,对目录用 readdir,对文件用 createReadStream 加一次 MIME 查找。PUT 和 DELETE 回答 204,这个状态码被选中,因为它传达成功,而不再多说什么。
收束:幂等,一次手跑的演示,和一个谦逊的小结。最被力争的那个决定放在最后。删除一个已经不在的文件,返回成功,而不是错误,因为请求者的目的——文件不在那里——已经成立,所以没有什么可以报告为失败。回报是幂等:重复同一个 DELETE,留下的结果与只发一次相同。(这值得牢牢记住,因为在这里回答 404 的本能很强,也是错的。)作者用一段简短的 curl 会话验证整个装配——GET 失败,PUT 创建,GET 取回,DELETE 让文件再次缺席——而值得留意的是它是什么:一次快速的手跑演练,而不是一套系统的测试。本章随后把镜头拉回到这个东西的规模来收尾。Node 是一个在浏览器之外运行 JavaScript 的紧凑系统,除非你选择退出,它的 I/O 就是异步的,并且有基于 promise 的文件访问可用;它紧凑的证明,就是文件服务器实际需要的东西是多么少。有一句提醒挺过了结尾:因为 ^2.3.0 接受直到、但不含 3.0.0 的一切,把语义化版本读成一种保证是很诱人的。它是 NPM 请作者遵循的一种约定,而没有任何东西强制他们这样做。
关键论点,以及支撑它们的原文
JavaScript 适合做服务器运行时,恰恰因为它自己没有任何输入/输出设施。
作者指出,它是少数几门缺少内建 I/O 的语言之一,并推理说,这种既定惯例的缺位,意味着 Node 那相当古怪的网络和文件处理办法,可以被装上去,而不与这门语言里已有的任何东西冲突。
确立了这门语言合适之后,作者表明 Node 靠模块加一小组全局量来交付平台的力量,而只属于浏览器的功能则干脆缺席。
document 或 prompt 之类的东西不存在;你需要什么,就明确地去要,而那几个确实存在的全局量,用 process.argv 来举例:它把命令行参数作为字符串交给你,真正的参数从下标 2 起。
Node 里的 HTTP 服务器,本质上就是一个关于请求对象和响应对象的回调,每有一个客户端连上来就重新运行一次。
交给 createServer 的那个函数在每个连接上被调用,大约八行的脚本就足以在 localhost:8000 上回答浏览器,给出一个回显所请求 URL 的 HTML 页面。
另外 3 个论点
服务器的大部分决策,是由错误码和状态码承载的,而不是由繁复的逻辑。
文件缺失时,stat 抛出一个 code 属性为 ENOENT 的错误;GET 处理函数捕获它,别的则重新抛出,其余情况回答 404。同样的思路为删除选了 204,因为这样的响应需要传达成功,而不再多说什么。
删除一个已经不在的文件时报告成功,是正确的行为,因为它让重复的 DELETE 请求与单个请求等价。
作者论证,请求者的目的——文件不在那里——已经达成,所以没有什么可以报告为失败;一段简短的 curl 会话走过:GET 失败,PUT 创建文件,GET 取回它,DELETE 让它再次缺席。这只是一次快速的手跑演示,不是系统的测试。
把本章收拢起来:Node 是一个在浏览器之外运行 JavaScript 的紧凑系统,除非你选择退出,它的 I/O 就是异步的,并提供基于 promise 的文件访问。
作者拿自带的模块来证明需要的东西是多么少:做文件系统的活用 node:fs,提供 HTTP 服务用 node:http,而 promise 的变体在 node:fs/promises 里,给更喜欢它们而不是回调的代码用。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么一门没有 I/O 的语言适合服务器
JavaScript 出厂时没有任何内建的办法来读输入或写输出,所以它没带任何根深蒂固的 I/O 惯例。恰是这片空白,让 Node 能把它自己那套不同寻常的网络和文件系统模型嫁接到这门语言上,而不与既有的接口相撞。
机制 2
等待会阻塞,所以 I/O 是异步的
一个在读或写完成之前一动不动的程序,这期间无法回应用户或其他机器,表现为看得见的延迟。所以 Node 把所有输入输出默认做成异步的,靠回调、事件或 promise 稍后报告结果,而不是阻塞那唯一的一条执行线。
机制 3
先解析路径,再比较
一个形如 /../secret_file 的请求,否则就会走出被提供服务的目录。服务器先把请求的路径解析成一个完整的绝对路径,再检查结果是否仍以基目录开头,不是就抛出错误——所以越界的企图是在解析之后被抓住的,而不是靠检查原始文本。
常见误读
删除一个不存在的文件是一次失败的请求,所以服务器应当回答一个错误或 404。
服务器回答 204,因为客户端所要求的状态已经成立;把它当作成功,正是让 DELETE 幂等的东西,所以反复发出同一个请求,留下的结果与发出一次相同。
因为 NPM 用语义化版本,写成“^2.3.0”的依赖,在 3 版之前保证不会弄坏你的代码。
这个写法确实接受从 2.3.0 起、到但不含 3.0.0 的一切,但这个三个数字的方案只是 NPM 请包的作者去遵循的一种约定;没有任何东西强制他们真的遵守。
第 20 章的核心概念
- 异步性
- 让程序同时与许多设备对话,而不是等每一次读或写完成。
- 适用处: 当你只是现在就需要一个值时帮不上忙;为此有同步的 Sync 变体。
- 模块
- 一个具名的代码单元,你必须明确地去要,因为 Node 让很少的绑定成为全局的。
- 适用处: .js 文件默认用 require;.mjs 文件用 import 和 export,不能两者并用。
- 包
- 一个发布出来的、带版本的 JavaScript 包,从 NPM 安装到本地的 node_modules 目录里。
- 适用处: 默认按项目安装,而不是装在一个中央的系统位置。
- 语义化版本
- 一种三个数字的版本方案:第一个数字在破坏性改动时变,中间那个在增加功能时变。
- 适用处: 只是 NPM 要求的一种约定;它不保证作者遵守。
- 回调
- 交给一个操作的函数,让它日后报告错误或结果。
- 适用处: 做错误处理时冗长又容易出错;promise 的变体在 node:fs/promises 里。
- 流
- 一个你一块一块往里写、或通过 data 和 end 事件从中读的对象。
- 适用处: 可读流用事件处理函数、而不是返回值,所以数据以 Buffer 块的形式到来。
另外 3 个概念
- 幂等
- 同一个请求发出许多次,留下的结果与发出一次相同。
- 适用处: 针对 DELETE 和 PUT 这类方法的 HTTP 设计准则;不适用于目的就是累积效果的请求。
- MIME 类型
- 一个像 text/plain 这样的简短标签,告诉客户端响应正文里装的是哪种数据。
- 适用处: 在这里由 mime-types 包按文件扩展名猜出,所以它只和文件名一样准确。
- Promise
- 一个代表未来结果的对象,在这里用来包装 Node 基于回调和事件的流 API。
- 适用处: 像 pipe 这样只发出事件的 Node API,要先有手写的包装器,才合得上 promise 代码。
读后感AI 陪读
诚实的伤疤组织,偶然的成功:一个真空与一种文化如何对上了
本章以一则Fu-Tzu的公案开篇,讲的是木棍和泥土,它悄悄给此后的一切定了调:Node 不是一间漂亮的小屋,而本章也从不假装它是。它展开成一条下行的路径,从故事到语法再到危险——先是背景(为什么偏偏是 JavaScript 这门语言,最后跑到了服务器上),然后是机制(node、process.argv、require 对 import、NPM 和语义化版本),然后是标准模块(fs、http、流),最后是一个能跑的文件服务器,并且警告你别把它指向一个你在乎的目录。作为一个整体来读,它与其说是对一个平台的介绍,不如说是对一个平台的伤疤组织的一次诚实的巡览。
我看穿的,是本章对 Node 成功原因的解释。明说的理由是异步性,而书把这个理由卖得很好——一条线程,没有阻塞的等待,到处是回调。但有两句话让真正的原因漏了出来:JavaScript 没有内建的 I/O,而社区本来就熟悉回调。Node 赢了,是因为这门语言没有任何要被推翻的主张,也因为它的用户的习惯本来就与它的古怪相合。一个真空,加一场社会的偶然,事后被包装成工程上的洞见。书没有藏着这一点;它只是没有去强调。它也没有强调 NPM 按项目安装的模型的代价,或 NPM 的开放性的真实形状——那个关于未被使用的包名的括号读来像一句宽慰,而真正的历史性灾难,恰恰来自那个边疆。
继续阅读
我最尖锐的判断是,本章的后半是它变得诚实的地方。到 fs 为止,一切都是方便;在路径穿越的警告那里,它不再教 API,而开始教恐惧,而从那里起,每一个设计选择都是一次坦白。错误类型是靠对 Unix 代码做字符串匹配来识别的——是考古,不是设计。process.argv 里那个神奇的下标 2,被说成是自然界的事实,而不是一个四十年的老疣子。pipe 不返回 promise,所以你得手工把三个事件接成一个,而结果在后来才到的错误上仍然会泄漏,只因为 promise 会宽恕第二次兑现才得以幸存。DELETE 被合理化成了幂等,办法是抹掉“我把它删了”与“它本来就不在”之间的区别;PUT 则不经确认、不留版本、毫不留情地覆盖。合起来看:Node 的年纪看得见,是接缝,而这项工作里成熟的部分,是把它们缝死。同步 fs 的承认——你的程序完全停下来,而有时这也没关系——是本章选择坦率而非教条的一刻,它也由此挣得了很多信任。
我要对读者说的是:抓住那个干活最多的从句,也就是浏览器的功能根本不存在。语言不是环境。初学者对 Node 的每一处困惑,都在那里消解。然后,不要把文件服务器当教程来读,而要当演示来读:用大约一百行,你造出了一个东西,用书里一本正经的话说,任何发出正确 HTTP 请求的人都可以把它糟蹋——一个从第一原理重建的维基,它与无政府状态之间,只隔着本章从未加上的认证。结尾那句别在你在乎的文件上运行它的警告,比任何功能都落得更重。你刚造出了一个用一条 curl 就能把自己抹掉的东西。这才是本章真正的课程:API 的表面容易,克制才是难的部分,而生产与你的工作区被分开,是出于你现在能感受到、而不只是背出来的理由。
在 FeedLitra 里读第 20 章,每段旁边都有这些卡片。
开始读书第 21 章:项目:技能分享网站
updated() 如何一次唤醒每一个在等待的请求?
第 21 章是怎样展开的AI 辅助
开篇:一个逼出架构的问题。本章先点出项目的名字——一个让成员提议讲座并对其评论的技能分享站——然后立刻把这个欢快的前提转成了一个技术约束。这个站点必须显示实时更新:一个人发一场讲座,其他每个人的屏幕都该不经刷新就变。但作者推理了为什么那个显而易见的办法不可用。普通的 HTTP 一个请求只给一次回复,浏览器传统上不接受传入的连接,而家用路由器无论如何也会把它们挡住。所以服务器无法自己伸手出去;唯一可用的信道,是客户端自己打开并保持存活的那一条。这是本章的枢纽,而且被放在任何代码之前:客户端先问,服务器只是把它的回答往后拖。一段简短的旁白为这类工作选 Node 辩护——一个繁忙的长轮询服务器可能同时扛着成千上万个在等待的请求和它们的 TCP 连接,而 Node 能承载这么多,却不必每个连接配一条线程。
展开:把缓存头弯成一种通知协议。从这个前提出发,文本转向 HTTP 接口,它以一个按标题寻址的 /talks 资源为中心。聪明之处在于,作者没有发明新机制;他把两个现成的拿来改作他用。服务器保持一个版本号,把它作为响应的 ETag 发出,并保持一个 waiting 数组。一个把同一个标签放进 If-None-Match 送回来、并带着一个 Prefer: wait 提示的客户端,不会被立刻回答——它的请求被停放起来。任何新的提交都会让版本号增加,而每一个停放着的请求被一次性用新数据回答。演练把这一切说得具体、而不抽象:伊曼一提交一场关于极限下坡独轮车的讲座,法特玛那个悬着的请求就在那一刻被回答,法特玛的屏幕自己更新了。值得牢牢记住,这既意味着什么、又不意味着什么。服务器从不无端地推;推送总是以一个客户端请求开始,它唯一的回复被推迟到有消息、或定时器触发为止——这恰恰就是为什么客户端必须在一个循环里不停地重新询问。作者也直白地指出,这个接口完全没有访问控制:它演示的是通知的把戏,而不是一个可部署的服务。
转折:从协议到客户端,从优雅到代价。后半转向浏览器。在这里,语气从协议设计变成了直截了当的构造:渲染函数通过小小的 elt 辅助函数,为一场讲座、它的评论、和提交讲座的表单构建 DOM;pollTalks 跑长轮询的循环;而 SkillShareApp 和 runApp 把状态、dispatch 和重绘接在一起。后半的两个设计决定,承载着本章真正的教训。第一个是刻意的稳健:当一个请求失败,pollTalks 不会死——它在一个由定时器兑现的 promise 上暂停,然后重试,所以一个消失了又回来的网络,会让这个循环从中断的地方接着走,更新恢复,不需要刷新页面。
第二个是一个刻意的缺陷,而本章并不隐瞒它。只要任何东西变了,这个组件就把一切重绘——每一场讲座的节点都被从头重建,旧的被丢弃。作者随后用一个实验、而不是一个论证,来展示这要付出什么代价:在一个窗口里,开始往某场讲座的评论框里打字,再从另一个窗口给同一场讲座加一条评论,写了一半的文字就没了,因为它只活在刚被扔掉的那个元素里。这个 bug 之所以容易被漏掉,与这个设计之所以诱人是同一个原因——替换的节点看上去与它们所替换的一模一样,所以这次调包通常不被察觉。以浪费的工作和丢失的输入为代价换来的简单,被当作一次真正的取舍摆出来,而不是一个该为之羞愧的错误。
收束:把未了的头交给读者。本章收在它的两条未解的线索所在之处,以习题的形式。一条要求做磁盘持久化,让提议的讲座在服务器重启后依然存在,而不只是活在内存里。另一条要求读者修好刚才演示的焦点与文字丢失——这意味着要直面这种“一切重绘”的策略。所以收尾与开篇呼应:文本开头是从网络实际的运转方式推出一个约束,而结尾则是留下读者,去付那个简单设计所欠下的账。
关键论点,以及支撑它们的原文
实时更新必须由浏览器这一侧来驱动,因为服务器无法自己伸手去找客户端。
作者推理说,浏览器传统上不接受传入的连接,而家用路由器无论如何也会把它们挡住,所以服务器没有任何实际的办法主动发起联系;唯一行得通的连接,是客户端自己打开并保持存活的那一条。
Node 很适合这种等待请求的服务器风格。
承着客户端必须保持连接打开这一点,作者指出,一个繁忙的长轮询服务器可能同时坐拥成千上万个在等待的请求和它们的 TCP 连接;Node 能管理这么多连接,却不必给每一个专门配一条线程,所以成本仍然可以应付。
普通的缓存头,可以被弯成一种改动通知协议。
版本号作为 ETag 发出,而一个把同一个标签连同一个请服务器等待的提示一起送回来的请求,会被扣住,而不是立刻回答。作者的演练把它说得具体:伊曼一提交一场关于极限下坡独轮车的讲座,法特玛那个悬着的请求就在那一刻被回答,所以她的屏幕自己更新了。作者也指出,这个接口完全没有访问控制。
另外 2 个论点
轮询循环被刻意写成能容忍网络断掉,而不是因它而死。
当一个请求失败,这个函数会暂停——靠一个由定时器兑现的 promise——然后再试,所以如果连接消失了一阵又回来,循环就接着走,更新恢复,而无需重新加载页面。
每当任何东西变了就把每一场讲座重绘一遍,是以浪费的工作和丢失的用户输入为代价,换来了简单。
作者演示了这个代价:在一个浏览器窗口里,开始往某场讲座的评论框里打字,再从另一个窗口给同一场讲座添加一条评论,列表重建时,打了一半的文字就被抹掉了。通常这种替换不被察觉,因为新节点看上去与旧节点一模一样——这就是为什么这个缺陷容易被漏掉。
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
被扣住的请求如何送来消息
普通的 HTTP 一个请求只给一次回复,而浏览器不接受传入的连接,所以客户端先问,服务器只是把回答往后拖,直到讲座有了变化或定时器到期。因此服务器所需要的信道,是客户端打开并保持打开的那一条。
机制 2
版本标签加等待提示
服务器跟踪一个兼作 ETag 的版本号,并把任何标签已经匹配、又带着等待提示的请求停放起来。一次新的提交让版本号增加,所以每一个停放着的请求被一次性用新数据回答。
机制 3
为什么整个重绘会吃掉你打的字
任何改动都让组件把每一场讲座的 DOM 节点从头重建,丢弃旧的。由于写了一半的评论只活在那个被丢弃的元素里,文字就消失了;这次调包通常不被察觉,因为替换品看上去一模一样。
常见误读
长轮询是服务器想什么时候往浏览器里推数据,就什么时候推的办法。
推送总是从一个客户端请求开始:浏览器发问,而服务器只是把它唯一的回复往后拖,直到有消息或超时触发。没有任何东西是无端发出的,这恰恰就是为什么客户端必须在一个循环里不停地重新询问。
既然 /talks 接口是由标准的 HTTP 方法和缓存头搭成的,它本质上就是一个现成的协议,可以放到开放的互联网上。
作者明确指出,这个接口根本没有访问控制,所以任何够得着它的人都能添加或改动讲座;这个设计是要展示通知的把戏如何运转,而不是要做一个可部署的服务。
第 21 章的核心概念
- 长轮询
- 客户端保持一个请求打开,服务器把回复往后拖,直到有消息或时间用完。 反复向服务器索取新数据;在这里,每个请求可能被保持打开,直到各场讲座有了变化。
- 适用处: 需要一个请求超时来避免死连接,还有许多打开的 TCP 连接;WebSocket 是更丰富、但更棘手的替代。 需要 ETag 和服务器端的等待支持,否则就退化成忙请求。
- HTTP
- 一个请求/响应协议,它的方法和头在这里被用作客户端与服务器之间的接口。
- 适用处: 一个请求得到一个响应,所以它自己无法向客户端推送数据。
- 路由器
- 一个把请求的方法和路径模式匹配到某个处理函数、并把抽取出来的路径部分交给它的组件。
- 适用处: 这里为说明而手写;生产里由 NPM 包来做,而未匹配的请求落到静态文件服务上。
- 动作
- 对用户想做什么的一个朴素描述,被派发出去,而不是让界面去改动状态或直接调用网络。
- 适用处: 这里状态更新和服务器请求都发生在同一个 handleAction 函数里,因为更新很简单。
- 文档对象模型
- 客户端用 elt 辅助函数构建的、用来显示讲座和评论的元素对象树。
- 适用处: 手工搭建的 DOM 代码,对复杂的界面会变得一团糟;模板或 JSX 需要额外的构建工具。
- async 函数
- 一个可以 await 请求和超时的函数,让那个无尽的轮询循环变得可读。
- 适用处: 只在工作基于 promise 的地方有帮助;它不会让循环结束。
另外 0 个概念
读后感AI 陪读
别人发帖时你的评论消失了,揭示出虚拟 DOM 的必要
一个令人满意的压轴,因为它不肯让这个栈的任何一半占上风:HTTP 接口先被设计出来,然后服务器和客户端都被造来为它服务。这里最好的教学是长轮询那一节——不是因为长轮询在今天是正确的答案(不是;WebSocket 和 SSE 才是),而是因为从一个只会拉取的协议里榨出推送的语义,逼着你真正去理解 ETag、条件请求,以及 Node 的事件循环为什么存在。本章也悄悄地满是它自己的逃生口:没有认证(“流氓”),没有持久化(习题1),没有差异比对(习题2),还有 500 路径里一个真正的 String(err) 的 bug。这些缺口与其说是草率,不如说是这种体裁的形状——教程代码的清晰,靠的是把恰恰让生产环境变难的那些问题推迟。最后一道习题是本章里最犀利的东西:在评论框里打字,而别人恰在此时发帖,然后看着你的文字消失,是我所知道的最干净的演示,说明虚拟 DOM 的比对和稳定的 key 为什么必须被发明出来。
在 FeedLitra 里读第 21 章,每段旁边都有这些卡片。
开始读书论证在哪里吃力
不是反驳,而是细心的读者会想追问的地方。
- 书敦促把内部藏在接口后面,却又反复展示它们如何泄漏——原型属性漏进普通对象充当的映射,lastIndex 和码元漏进正则的使用,实时集合和延迟的布局漏进 DOM 代码,以及经由异步间隙泄漏的操作中间状态。
- 那些被夸赞为方便的同一批语言特性——类型转换、UTF-16 字符串、贪婪模式、沙箱里被封锁却有用的能力——恰是产生 bug 和需要判断取舍的东西,而书接着教人绕开它们。
- 模块化被开成对付纠缠的药方,而 JavaScript 自己却带着两套不安共存的模块系统,还允许循环依赖,所以这剂被开出的药方本身就是碎裂的。
- 不可变性和集中分派被说成是在简化,然而撤销必须限频到每秒一份快照,所有逻辑都集中在单个 update 函数里——所以成本是被封顶、被挪走,而不是被消除。
另外 1 处张力,以及 8 条证据上的局限
- 写自己的语言被捧为解放,可书也承认 Egg 程序比它的 JavaScript 等价物难看得多,使人弄不清什么时候一种领域特定的记法才划算。
证据上的局限
- 主张大多建立在简短的示例片段、类比和假想的机器上,而不是系统的数据,像成年人两万词的词汇量、或输出写到 1000 这样的数字,只是用来说明规模。
- 性能与策略的比较来自单次运行——一次混合布局读取与修改的计时,一条被抽样的机器人路线,一个带限定条件的递归对循环的比值——所以它们无法确立可靠的量级。
- 模块化和接口的论证是在小小的玩具对象和一个绘图项目上演示的,在那里,零散的状态只会害到一个取色器,所以扩展性是被推理出来的,而不是被展示出来的。
- Egg 只在一个不到 150 行的小求和程序上被演练过,而可替换显示的说法靠的是实际造出的那两个显示,所以两种设计都没有在演示之外得到检验。
- 游戏物理承认是临时凑合的:碰撞需要一个真正的引擎,而取消被挡住的运动只在动画步长很小的情况下才有效。
- 安全和失败处理被处理得带着明确的保留或只有最浅的深度——HTTPS 被称作不完美,目录穿越只用一次解析再比较的检查来应对,韧性不过是一次延迟重试。
- 概括是从单个案例外推出来的:字符类的指导来自一个非英文的例子,不可靠的按键事件来自 Android 键盘和输入法软件,位图加载被限制在 100 乘 100 像素且不带 alpha。
- 历史性的和作者个人的判断,是作为推理而不是证据提出的——JavaScript 适合 Node,是从缺乏输入输出的惯例推出来的,作者早年对这门语言下的结论,他自己也承认是在不知道自己在干什么的时候做出的。
全书概念导图
5 组概念:通俗的叫法、一句话解释,以及原书自己的用语。
1 · 程序由什么构成
- 精确的指令,用人发明的语言写成
- 告诉计算机做什么的精确指令;更高层的人工语言省去无趣的细节,并提供可组合的构件,ECMAScript 把 JavaScript 标准化。原书用语: Program and programming language
- 比特在类型告诉你怎么读之前毫无意义
- 被当作一份信息来对待的一块比特,它的类型是一种解释性的角色,而不是物理上的差别;JavaScript 悄悄地把值转换成运算符所需的类型,由此产生意外和 NaN。原书用语: Value, type and coercion
- 产生值的小构件,嵌套在整句式的步骤里
- 任何产生一个值的片段,可无限嵌套;语句是代码里的一个整句,只在它改变了某个可观察的东西时才有用。原书用语: Expression, statement and side effect
- 各步骤运行的顺序:直行、分支或重复
- 语句运行的顺序:直线向前,用 if/else 分支,用循环重复,或由接收未求值表达式的特殊形式来选择。原书用语: Control flow
2 · 函数如何记住东西
- 名字持有值,这就是代码拥有的全部记忆
- 一个抓住值的名字,使程序记得它,并且可以重新挂接;全部存活绑定(包括内置的)的集合构成环境。原书用语: Binding and environment
- 一块打包成值、可以被调用的工作
- 包在一个值里的一段程序,带着参数被调用,也可能返回一个值,提供内置命令所缺的词汇;高阶函数接收或返回函数,留下由调用者去填的空缺。原书用语: Function
- 每次调用都得到新的局部变量,所以各次调用保持分离
- 每个块建立一个新的作用域,由程序文本而不是调用者决定;局部绑定每次调用都是新的,所以返回的函数能保留它们,而栈保存着待返回的上下文。原书用语: Scope, closure and call stack
- 抽出一个清晰的想法,给它起个名字
- 把一个清晰的想法命名为它自己的操作,包括没有副作用的纯函数,使人能讨论一个问题而不必带上它附带的步骤。原书用语: Abstraction by single-concept extraction
3 · 把数据塑造成真实的事物
- 把值归入对象和列表,按引用共享
- 对象把有名字的属性归在一起,数组容纳有索引的值;数组是对象的一种,带有方法、可变性、身份,以及扁平文本的序列化。原书用语: Composite data and reference identity
- 一个部件允许外人使用的那几个操作
- 一个部件有意暴露的有限操作集合——对象类型、模块、角色或显示——使统一的代码能驱动各不相同的实现。原书用语: Interface
- 共享的行为住在类背后的原型里
- 对象链接到持有实例共享内容的原型;类把构造函数和方法打包,继承派生出新类,而按预期接口写的代码,接受任何支持该接口的值。原书用语: Class, prototype and polymorphism
- 造一个新值,而不是改旧的
- 永不改变的值;更新产生一个全新的值,所以每个操作都可以孤立地推理,预览可以重算,旧的副本依然有效。原书用语: Persistent state and immutability
4 · 让大程序保持可修
- 简单的规则堆积起来,直到没人跟得上
- 建立在简单规则上的程序会累积出自己的规则,直到程序员迷失;你还能把自己系统的多少装在脑子里,就是边界所保护的预算。原书用语: Complexity
- 让错误早早大喊,而不是悄悄溜过
- 让错误早早浮出水面的做法——严格模式、断言、错误子类、把待返回的调用逐层展开直到被捕获的异常、重新抛出意料之外的异常,以及自动化测试。原书用语: Loud failure discipline
- 简洁的文本模式,会不断重试直到匹配
- 一门描述文本形状的晦涩模式语言,由一种重试已记住位置的搜索来匹配,贪婪重复尽可能多地匹配,而 lastIndex 和码元会泄漏出来。原书用语: Regular expression and backtracking
- 一个声明自己提供什么、需要什么的部件
- 一个程序部件既声明它的公开接口,也声明它所依赖的东西,可作为有版本的、可安装的包发布;没有这样的边界,代码就成了一大团泥球。原书用语: Module, package and dependency
5 · 从解析器到浏览器
- 文本变成树,一个微型求值器去运行它
- 由文本产生的、由嵌套表达式构成的、有分支无环的结构,由求值器来解释;为一个狭窄领域而设的微型记法,能把那个领域的事说得更直接。原书用语: Parser, syntax tree and domain-specific language
- 现在就开始缓慢的工作,稍后再处理结果
- 开始缓慢的工作而不让程序停下;事件循环一次一个地运行脚本和回调,Promise 代表稍后才有的值、传播拒绝,并在 async 函数里被 await。原书用语: Asynchronicity and promises
- 叠起来的协议让每一层都藏住它下面那一层
- 叠在一起的协议和格式——TCP、带方法和头的 HTTP、URL、HTML 与实体、fetch、URL 编码——各自藏住下面的凌乱,沙箱则限制页面代码能触及的东西。原书用语: Web platform layering
- 一棵由标签构成的活的树,脚本读它、改它
- 浏览器里每个标签对应一个对象的那棵树,脚本读取并更新它,其中有实时与静态的集合、延迟的布局计算、CSS 的优先级,以及定位。原书用语: DOM, layout and styling
读后感AI 陪读
没有哪台机器会保护你免受你自己制造的复杂性之害
通读下来,《Eloquent JavaScript》不是一本碰巧写得好的语言手册——它是一本关于管理复杂性的书,只是拿 JavaScript 当它那个充满敌意的试验场。论点在导言里就埋下了,且从未放弃:复杂性是自己制造的,没有哪台机器会保护你免受它之害,唯一持久的防御,是对人类读者的可理解。之后的每一章,都是围绕这个代价由谁来承担、又怎样来偿付所作的变奏。主线沿着三道渐次展开的弧线推进。第一道是语言本身(第1至8章):类型是强加在未加区分的比特之上的一种虚构,结构是值变成句子的那一刻,函数是铸造内置功能从未提供的词汇的核心工具,对象和数组是对原子值描述不了任何真实事物的承认,高阶函数是被诚实标了价的抽象,价码体现在分配和读者必须学习的概念上,封装是一种文化而不是一种特性——继承被有意降到最后,因为它耦合了本章其余部分一直在努力分开的东西——最后是 bug,在那里,正确性被重新界定为一种程序员自己提供的纪律,因为语言几乎什么也不提供。第二道是从业者的一层(第9至12章):正则表达式证明留存下来的工具并不是设计良好的工具,模块是在主张纠缠会让你付两次账(一次在理解上,一次在复用比重写还贵的时候),异步是一架梯子,在那里等待不再是隐式的、而成了由你自己掌管,还有那个小小的解释器,它整个要点都是消胀性的——地板底下没有魔法,只有普通的递归编程。第三道是平台(第13至21章):Web 是沉积而不是设计,DOM 是在没人知道 Web 是干什么用的时候就做出的那些决定的残余,事件是被转移而不是被获得的控制权,然后是四个项目——机器人、平台游戏、像素画编辑器、技能分享网站——它们都从不同角度论证同一个结构性教训:把必须改变的值的集合缩到最小,计算出新状态而不是修改旧状态,让每一次改变都经过一条声明过的通道,让撤销之类的派生功能几乎白送地掉出来。
书里最锐利的判断是那些拒绝。哈弗贝克扣下了读者最想要的最佳实践清单,因为递过去规则,就替代了规则本来要编码的那份判断。他不让面向对象成为条件反射,把机器人那些听上去配得上有类的“东西”消解掉了。他坚持 catch 块必须保持狭窄,因为宽泛的 catch 会把真正的 bug 藏在一个被处理的症状后面。他在正确的轴上给 canvas 与 DOM 比价——谁保留了图形的描述——而不是在天真的速度上。他告诉你长轮询在今天是错误答案,却仍然教它,因为在一个只会拉取的协议里榨出推送,正是协议真正变得看得见的地方。而且他不停地坦白:他曾经鄙视这门语言,提升是一种虚构,箭头函数的存在没有什么深层理由,浏览器技术既强大又荒谬,他自己的像素画编辑器浪费工作,他的技能分享网站会丢掉进行中的输入。这份坦率才是本书真正的教学法——它示范了这样一种姿态:好代码出自一个愿意指出自己作品里错在哪里的人。我会对读者说:不要把它当参考资料来读,也不要跳过那些项目,那正是论点不再是断言、而成了演示的地方。为那些隐喻而读——绑定的触手,塑料壳里的搅拌机,Fu-Tzu 的木棍和黏土——因为它们在语法需要之前很多年,就把直觉装进了你的脑子。预期前三分之一读起来会比它的页数所暗示的更慢,而平台各章会比它们实际上更快、也更不那么要紧。并接受本书的核心交易:它不会让你变快,它会让你变得可读,而在一门如此宽容的语言里,可读性是你真正能抓住的唯一一种正确性。
网络书评怎么说
各来源的评论者称这是一本清晰、设计精美的入门书,文字明晰,项目章和横向卷轴游戏的例子令人印象深刻;有几位称之为必读。批评者则反对:它的编排把闭包放在了基础之前,例子离真实的 Web 工作很远,术语很重的行文使绝对的初学者难以入手。
综合已发表书评的看法;不展示评分,完整书评请点来源链接。
来源与授权
CC BY-NC 3.0 · 非商业原书
- 《Eloquent JavaScript》(JavaScript 编程精解)第四版,作者 Marijn Haverbeke。采用知识共享 署名-非商业性使用 3.0 许可协议(CC BY-NC 3.0);书中代码另可视为按 MIT 许可证授权。可在 eloquentjavascript.net 免费阅读。免费阅读原书 · 许可证
- 摘要、论点、批注和读后感由 FeedLitra 根据全书原文 AI 辅助生成,引文已对照原文核对。最近更新:2026 年 10 月 22 日。
非商业许可:按许可,本页不引用原书文字,只有 FeedLitra 自己写的说明,每处都只链接回官方免费版本的对应小节。
在 FeedLitra 里一边读《JavaScript 编程精解》,一边看这些卡片。
开始读书