去年 3 月,MDN 工程团队开始在 Mozilla Hacks 上发布每月变更日志的实验。在经过 9 个月的变更日志格式 之后,我们决定尝试一些我们希望对更广泛的 Web 开发社区感兴趣,并且对我们来说更有趣的写作内容。这些帖子可能不会按月发布,并且不会包含您从变更日志中期望的那种细粒度细节。它们会涵盖我们为管理和发展 MDN Web 文档网站所做的一些更有趣的工程工作。如果您想确切了解 MDN 的哪些内容已更改以及哪些人对此做出了贡献,您始终可以查看该仓库 在 GitHub 上。
1 月,我们完成了对KumaScript 代码库 的重大重构,这将成为本文的主题,因为这项工作包含了一些对 JavaScript 程序员来说很感兴趣的技术。
现代 JavaScript
进行像这样的重大重构的乐趣之一是能够使代码库现代化。自 KumaScript 首次编写以来,JavaScript 已经成熟了很多,我能够利用这一点,使用let
和const
、类、箭头函数、for...of
循环、展开运算符 (…) 和解构赋值 在重构后的代码中。由于 KumaScript 作为基于 Node 的服务器运行,我无需担心浏览器兼容性或转译:我可以自由地(就像糖果店里的孩子一样!)使用Node 10 支持的所有最新 JavaScript 功能。
KumaScript 和宏
更新到现代 JavaScript 很有趣,但这还不足以证明花在重构上的时间是合理的。要了解为什么我的团队允许我参与这个项目,您需要了解 KumaScript 的功能以及它的工作原理。所以请耐心听我解释这个背景,然后我们再回到重构中最有趣的部分。
首先,您应该知道 Kuma 是为 MDN 提供支持的基于 Python 的维基,而 KumaScript 则是一个渲染 MDN 文档中宏的服务器。如果您查看 MDN 文档的原始形式(例如HTML <body
> 元素),您会看到类似于这样的代码行
It must be the second element of an {{HTMLElement("html")}} element.
双大括号内的内容是宏调用。在本例中,该宏被定义为渲染指向html
元素的 MDN 文档的交叉引用链接。使用这样的宏可以使我们在整个网站中保持链接和尖括号格式的一致性,并简化作者的工作。
MDN 自 Kuma 服务器存在之前就开始使用这样的宏。在 Kuma 之前,我们使用的是一款商业维基产品,它允许在他们称为 DekiScript 的语言中定义宏。DekiScript 是一种基于 JavaScript 的模板语言,它有一个与维基交互的特殊 API。因此,当我们迁移到 Kuma 服务器时,我们的文档中充满了在 DekiScript 中定义的宏,我们需要实现自己的兼容版本,我们称之为 KumaScript。
由于我们的宏是使用 JavaScript 定义的,我们无法直接在基于 Python 的 Kuma 服务器中实现它们,因此 KumaScript 成为一个独立的服务,用 Node 编写。这是 7 年前在 2012 年初,当时 Node 本身才在 0.6 版本。幸运的是,当时已经存在一个名为EJS 的基于 JavaScript 的模板系统,因此创建 KumaScript 的基本工具都已经到位。
但是,有一个问题:我们的一些宏需要发出 HTTP 请求才能获取它们所需的数据。例如,上面显示的HTMLElement
宏。该宏渲染指向指定 HTML 标签的 MDN 文档的链接。但是,它还在链接中包含一个工具提示(通过title
属性),其中包含元素的简要摘要
该摘要必须来自所链接的文档。这意味着 KumaScript 宏的实现需要获取它所链接的页面才能提取其部分内容。此外,此类宏是由技术作家而不是软件工程师编写的,因此做出决定(我假设是由任何设计 DekiScript 宏系统的人做出的),HTTP 获取将使用阻塞函数同步返回,这样技术作家就无需处理嵌套回调。
这是一个很好的设计决定,但它使 KumaScript 变得棘手。Node 本身并不支持阻塞网络操作,即使支持,KumaScript 服务器也不能在它为挂起的请求获取文档时停止响应传入的请求。结果是,KumaScript 使用了node-fibers Node 的二进制扩展来定义在网络请求挂起时阻塞的方法。此外,KumaScript 采用node-hirelings 库来管理一组子进程。(它是最初的 KumaScript 作者为此目的编写的)。这使 KumaScript 服务器能够继续并行处理传入的请求,因为它可以将可能阻塞的宏渲染调用分发到一组雇佣兵子进程。
Async 和 await
这种 fibers+hirelings 解决方案渲染了 MDN 宏 7 年,但到 2018 年它已经过时。宏作者不应该理解使用回调(或 Promise)的异步编程的最初设计决定仍然是一个很好的决定。但是,当 Node 8 添加对新的async
和await
关键字的支持时,fibers 扩展和 hirelings 库不再必要。
您可以在 MDN 上阅读有关async 函数
和await 表达式
的信息,但要点是
- 如果您声明一个
async
函数,则表示它返回一个 Promise。如果您返回的值不是 Promise,那么该值将在返回之前被包装在一个已解析的 Promise 中。 await
运算符使异步 Promise 看起来像同步行为。它允许您编写与同步代码一样易于阅读和理解的异步代码。
例如,请考虑以下代码行
let response = await fetch(url);
在 Web 浏览器中,fetch()
函数启动一个 HTTP 请求并返回一个 Promise 对象,该对象将在 HTTP 响应开始从服务器到达时解析为响应对象。如果没有await
,您将不得不调用返回的 Promise 的.then()
方法,并将一个回调函数传递给它以接收响应对象。但是await
的魔力让我们可以假装fetch()
实际上阻塞了,直到 HTTP 响应被接收。只有一个问题
- 您只能在自身被声明为
async
的函数中使用await
。同时,await
实际上并没有让任何东西阻塞:底层操作仍然是根本的异步操作,即使我们假装它不是,我们也只有在某些更大的异步操作中才能做到这一点。
所有这些意味着现在可以使用 Promise 和await
关键字来实现保护 KumaScript 宏作者免受回调复杂性的设计目标。这是我进行 KumaScript 重构的见解。
正如我上面提到的,我们每个 KumaScript 宏都被实现为一个 EJS 模板。EJS 库将模板编译为 JavaScript 函数。令我高兴的是,该库的最新版本已经更新了一个选项,可以将模板编译为async
函数,这意味着现在在 EJS 中支持await
。
有了这个新库,重构就变得相对简单了。我必须找到我们宏可以使用的所有阻塞函数,并将它们转换为使用 Promise 而不是 node-fibers 扩展。然后,我可以对我们的宏文件进行搜索和替换,以便在所有这些函数的调用之前插入await
关键字。我们一些更复杂的宏定义了自己的内部函数,当这些内部函数使用await
时,我必须采取额外的步骤将这些函数更改为async
。然而,当我将以下旧代码行转换为
var title = wiki.getPage(slug).title;
到
let title = await wiki.getPage(slug).title;
直到我开始看到宏的错误,我才发现那一行代码的错误。在旧的 KumaScript 中,wiki.getPage()
会阻塞并同步返回请求的数据。在新的 KumaScript 中,wiki.getPage()
被声明为 async
,这意味着它返回一个 Promise。上面的代码试图访问该 Promise 对象上不存在的 title
属性。
在调用之前机械地插入一个 await
不会改变这一事实,因为 await
运算符的优先级低于 .
属性访问运算符。在这种情况下,我需要添加一些额外的括号来等待 Promise 解决,然后才能访问 title
属性。
let title = (await wiki.getPage(slug)).title;
这个相对较小的 KumaScript 代码更改意味着我们不再需要将 fibers 扩展编译到我们的 Node 二进制文件中;这意味着我们不再需要 hirelings 包;并且这意味着我能够删除一大堆代码,这些代码处理了主进程和实际渲染宏的 hireling 工作进程之间复杂通信的细节。
更重要的是:当渲染不进行 HTTP 请求的宏(或当 HTTP 结果被缓存)时,我看到渲染速度提高了 25 倍(不是快 25%,而是快 25 倍!)。同时,CPU 负载下降了一半。在生产环境中,新的 KumaScript 服务器速度明显更快,但远没有快 25 倍,因为当然,进行异步 HTTP 请求所需的时间占主导地位,而同步渲染模板所需的时间则微不足道。但即使是在受控条件下,实现 25 倍的加速,也让这次重构变得非常令人满意!
Object.create()
和 Object.freeze()
KumaScript 重构的另一个我想谈论的部分,因为它突出了应该更好地了解的一些 JavaScript 技术。正如我在上面写的那样,KumaScript 使用 EJS 模板。当您渲染 EJS 模板时,您会传入一个对象,该对象定义了模板中 JavaScript 代码可用的绑定。在上面,我描述了一个名为 wiki.getPage()
的 KumaScript 宏调用函数。为了实现这一点,KumaScript 必须将一个对象传递给 EJS 模板渲染函数,该对象将名称 wiki
绑定到一个对象,该对象包含一个 getPage
属性,其值为相关函数。
对于 KumaScript,我们在 EJS 模板中提供的全局环境有三个层次。最根本的是宏 API,其中包括 wiki.getPage()
和许多相关函数。由 KumaScript 渲染的所有宏都共享相同的 API。在该 API 层之上是一个 env
对象,它允许宏访问特定于页面的值,例如它们出现的页面的语言和标题。当 Kuma 服务器将 MDN 页面提交给 KumaScript 服务器进行渲染时,页面中通常有多个宏需要渲染。但是所有宏都会看到页面内变量(如 env.title
和 env.locale
)的相同值。最后,页面上的每个单独的宏调用都可以包含参数,这些参数通过将它们绑定到变量 $0
、$1
等来公开。
因此,为了渲染宏,KumaScript 必须准备一个对象,其中包含对一个相对复杂的 API、一组特定于页面的变量和一组特定于调用的参数的绑定。在重构这段代码时,我有两个目标
- 我不想为每个要渲染的宏都重建整个对象。
- 我想确保宏代码不能改变环境,从而影响将来宏的输出。
我使用 JavaScript 原型链 和 Object.create()
达成了第一个目标。我首先创建了一个对象,该对象定义了固定的宏 API 和特定于页面的变量,而不是在单个对象上定义所有三个层次的环境。我在页面中的所有宏中重复使用此对象。当要渲染单个宏时,我使用 Object.create()
创建一个新对象,该对象继承了 API 和特定于页面的绑定,然后将宏参数绑定添加到该新对象中。这意味着为每个要渲染的单个宏要做的设置工作要少得多。
但是,如果我要重用定义 API 和特定于页面的变量的对象,我必须非常确定宏不能改变环境,因为这意味着一个宏中的错误可能会改变后续宏的输出。使用 Object.create()
对此很有帮助:如果宏运行类似 wiki = null;
的代码行,这只会影响为该渲染创建的环境对象,而不是它继承的原型对象,因此 wiki.getPage()
函数仍然可用于下一个要渲染的宏。(我应该指出,像这样使用 Object.create()
在调试时可能会造成一些混乱,因为以这种方式创建的对象看起来是空的,即使它继承了属性。)
然而,这种 Object.create()
技术还不够,因为包含代码 wiki.getPage = null;
的宏仍然可以改变其执行环境并影响后续宏的输出。因此,在我创建从它继承的对象之前,我在原型对象(及其引用的对象上递归)上调用了 Object.freeze()
。
Object.freeze()
从 2009 年开始就成为 JavaScript 的一部分,但如果您不是库作者,您可能从未使用过它。它会锁定一个对象,使其所有属性都只读。此外,它会 “密封” 该对象,这意味着不能添加新属性,并且不能删除现有属性,也不能配置它们以使其再次可写。
我总是觉得知道 Object.freeze()
在需要时就在那里很令人放心,但我很少真正需要它。因此,能够为这个函数找到一个合法的用途,我感到很兴奋。不过,值得一提的是,有一个小问题:在成功使用 Object.freeze()
后,我发现我尝试存根宏 API 方法(如 wiki.getPage()
)时会静默失败。通过将宏执行环境锁定得如此严格,我已经锁定了自己编写测试的能力!解决方法是在测试时设置一个标志,然后在设置该标志时省略 Object.freeze()
步骤。
如果这一切听起来很有趣,您可以查看 KumaScript 源代码中的 Environment 类。
关于 David Flanagan
David 是 Mozilla MDN 团队的软件工程师,也是《Javascript:权威指南》一书的作者。
2 条评论