原文:A Gentle Introduction to Prepack (Part 1)内容更新至:2018-12-24注意:计划在当前指南更完善后,将其引入 Prepack 文档中。目前我以 gist 方式发布,以便收集反馈。Prepack 介绍(第一部分)如果你在开发 JavaScript 应用,那么对如下这些将 JavaScript 代码转为等价代码的工具应该比较熟悉:Babel 让你能够使用更新的 JavaScript 语言特性,输出兼容老的 JavaScript 引擎的等价代码。Uglify 让你能够编写可读的 JavaScript 代码,输出完成相同功能但是字节数更少的混淆代码。Prepack 是另一个致力于将 JavaScript 代码编译为等价代码的工具。但与 Babel 或 Uglify 不同的是,Prepack 的目标不是新特性或代码体积。Prepack 让你编写普通的 JavaScript 代码,然后输出执行地更快的等价代码。如果这听起来让人兴奋,那么接下来你会了解到 Prepack 是如何工作的,以及你可以怎样让它做得更好。这个指南有什么?就我个人而言,当我最终理解 Prepack 能做什么时,我非常兴奋。我认为在未来,Prepack 会解决目前我在开发大型 JavaScript 应用时遇到的很多问题。我很想传播这一点,让其他人也兴奋起来。不过,向 Prepack 贡献力量在一开始会让人害怕。它的源码里有很多我不熟悉的术语,我花了很长时间才明白 Prepack 做了什么。编译器相关代码倾向于使用确定的计算机科学术语,但这些术语让它们听起来比实际情况要复杂。我编写这个指南,就是为了那些没有计算机科学背景,但对 Prepack 的目标感兴趣,并且希望帮助它实现的 JavaScript 开发者。本指南就 Prepack 如何工作提供了高度的概括,给你参与的起点。Prepack 中的很多概念直接对应到那些你日常使用的 JavaScript 代码工具:对象、属性、条件和循环。即使你还不能在项目中使用 Prepack,你也会发现,在 Prepack 上的工作,有助于增强你对每天编写的 JavaScript 代码的理解。在我们深入之前 ????注意,Prepack “还没有为主流做好准备”。你还不能把它像 Babel 或 Uglify 那样嵌入到构建系统中,并期望它能正常工作。相反,你得把 Prepack 视作你可以参与的正在进行中且有雄心壮志的试验,并且在未来它会对你有用。由于其目标很广,所以有很多机会可以参与进来。不过,这并不意外着 Prepack 不能工作。但由于其目前只关注于特定的一些场景,而且在生产环境中很可能会有让人不能接受的过多 bug。好消息是你可以帮助 Prepack 支持更多用例,以及修复 bug。这个指南会帮助你开始。Prepack 基础让我们重新审视上面提到的 Prepack 的目标:Prepack 让你编写普通的 JavaScript 代码,输出等价但执行更快的 JavaScript 代码。为什么我们不直接编写更快的代码呢?我们可以尝试,如果可以的话也的确应该。但是,在很多应用中,撇开由性能工具识别出的瓶颈,其实并没有很多明显可以优化的地方。通常并没有单独一处导致程序变慢;相反,程序忍受的是“千刀万剐”。那些提升关注分离的特性,例如函数调用、分配对象和各种抽象,在运行时吃掉了性能。然而,在源码中移除这些会导致难以维护,而且也并没有我们可以应用的容易的优化方式。甚至 JavaScript 引擎在多年的优化工作中也有所限制,特别是在初始化只执行一次的代码上。最明确的提升性能的方式,是少做一些事情。Prepack 根据这个理念引出其逻辑结论:它 在构建阶段 执行程序以了解代码 将要 做什么,然后生成等价的代码,但是减少了计算量。这听起来太奇幻,所以我们来看一些例子,了解 Prepack 的优势和限制。我们会使用 Prepack REPL 来在线对一段代码应用 Prepack。计算 2 + 2 的两种方式让我们先打开 这个例子:(function() { var x = 2; var y = 2; global.answer = x + y;})();输出为:answer = 4;实际上,运行两个代码片段产生相同的效果:值 4 被赋值到名为 answer 的全局变量上。不过 Prepack 的版本并没有包含 2 + 2 的计算。不同的是,Prepack 在编译阶段执行 2 + 2,并将最终的赋值操作进行了 “序列化(serialize)”(“写入”或“生成”的一种花哨的说法)。这并没有特别厉害:例如,Google Closure Compiler 也能将 2 + 2 变为 4%2520%257B%250A%2520%2520var%2520x%2520%253D%25202%253B%250A%2520%2520var%2520y%2520%253D%25202%253B%250A%2520%2520global.answer%2520%253D%2520x%2520%252B%2520y%253B%250A%257D)()%253B)。这种优化被称作 “常量折叠(constant folding)”。Prepack 的不同在于,它能执行任意 JavaScript 代码,不仅仅是常量折叠或类似的有限优化。 Prepack 也有其自身的限制,我们一会再说。考虑如下这种有意编写的超级绕的计算 2 + 2 的情况:(function() { function getNumberCalculatorFactory(injectedServices) { return { create() { return { calculate() { return injectedServices.operatorProvider.operate( injectedServices.xProvider.provideNumber(), injectedServices.yProvider.provideNumber() ) } }; } } } function getNumberProviderService(number) { return { provideNumber() { return number; } }; } function createPlusOperatorProviderService() { return { operate(x, y) { return x + y; } }; } var numberCalculatorFactory = getNumberCalculatorFactory({ xProvider: getNumberProviderService(2), yProvider: getNumberProviderService(2), operatorProvider: createPlusOperatorProviderService(), }); var numberCalculator = numberCalculatorFactory.create(); global.answer = numberCalculator.calculate();})();尽量我们并不推荐以这种方式来计算两个数值的和,不过你会看到 Prepack 输出了相同的结果:answer = 4;在两个例子中,Prepack 在构建阶段 执行 代码,计算出环境中的 “结果”(修改),然后“序列化”(写)得到实现相同效果但运行时负担最小的代码。对于任何其他通过 Prepack 执行的代码,抽象来看都是如此。边注:Prepack 是如何执行我的代码的?在构建阶段“执行”代码听起来很可怕。你不希望 Prepack 因为执行了包含 fs.unlink() 调用的代码,就将文件系统中的文件删除。我们要明确 Prepack 并非只是在 Node 环境中 eval 输入的代码。Prepack 包含一个完整的 JavaScript 解释器的实现,所以可以在“空的”独立环境中执行任意代码。缺省地,它并不支持像 Node 的 require()、module,或者浏览器的 document。我们后面会再提到这些限制。这并不是说,在“宿主(host)” Node 环境和 Prepack JS 环境之间搭建桥梁是不能的。事实上这在未来会是一个值得探索的有趣的观点。或许你会是参与者之一?森林中倒下的一棵树你可能听过这个哲学问题:如果森林中倒下一棵树而周围的人都没有听到,那么它有声音吗?这其实与 Prepack 能做什么和不能做什么直接相关。考虑 第一个例子的简单变种:var x = 2;var y = 2;global.answer = x + y;输出中,很奇怪地,也包含 x 和 y 的定义:var y, x;x = 2; // 为什么这个也会序列化?y = 2; // 为什么这个也会序列化?answer = 4;这是由于 Prepack 将输入代码视为脚本(script),而非模块(module)。一个在函数外部的 var 声明 变成了全局变量,所以从 Prepack 的角度来看,好像是我们有意向全局环境声明了它们:var x = 2; // 等同:global.x = 2;var y = 2; // 等同:global.y = 2;global.answer = x + y;这也是为什么 Prepack 将 x 和 y 保留在输出中。别忘了 Prepack 目标是产生等价的代码,也包括 JavaScript 的陷阱。最容易的避免这个错误的方法是 始终将提供给 Prepack 的代码包裹在 IIFE 中,并且明确地将结果以全局变量记录。(function() { // 创建函数作用域 var x = 2; // 不再是全局变量 var y = 2; // 不再是全局变量 global.answer = x + y;})(); // 别忘了调用!这产生了预期的输出:answer = 4;这是 另一个容易让人糊涂的例子:(function() { var x = 2; var y = 2; var answer = 2 + 2;})();Prepack REPL 输出了有用的警告:// Your code was all dead code and thus eliminated.// Try storing a property on the global object.这里,另一个问题出现了:尽管我们执行了计算,但没有任何效果作用于环境。 如果有其他脚本随后执行,它并不能判断我们的代码是否执行过。所以不必序列化任何值。再一次,为了修复这个问题,我们要将 需要 保留的东西以追加到全局对象的方式标记,让 Prepack 忽略其他:(function() { var x = 2; // Prepack 会丢弃这个变量 var y = 2; // Prepack 会丢弃这个变量 global.answer = 2 + 2; // 但这个值会被序列化})();概念上,这可能让你想起 垃圾回收:对于全局对象“可触达”的对象,需要“保持活跃”(或者,在 Prepack 中,被序列化)。除了设置全局属性外,还有其他的“结果”是 Prepack 支持的,我们后面再讲。残留堆(Residual Heap)现在我们可以粗略地描述 Prepack 是如何工作的了。在 Prepack 解释执行输入代码时,它构造了程序使用的所有对象的内部表示。对于每一个 JavaScript 值(如对象、函数、数值),都有内部的 Prepack 对象记录其相关信息。Prepack 代码中有这样的 class:ObjectValue、FunctionValue、NumberValue,甚至 UndefinedValue 和 NullValue。Prepack 也会跟踪所有输入代码对环境产生的“效果”(例如写入全局变量)。为了在结果代码中反映这些效果,Prepack 在代码执行结束后查找所有仍能通过全局对象触及到的值。在上面例子中,global.answer 被视为“可触及的”,因为不同于局部变量 x 和 y,外部代码未来可以读取 global.answer。这也是为什么从输出中忽略 global.answer 不安全,但忽略 x 和 y 是安全的。所有全局对象可触及的值(这些可能影响后续执行代码)被收集到“残留堆”。这名字听起来比实际上复杂多了。“残留堆”是“堆”(执行代码创建的所有对象)在代码完成执行后保持“残留”(例如,在输出中保留)的一部分。如果丢掉计算机科学的帽子,我们可以称之为“剩下的东西”。序列化器(Serializer)Prepack 是如何产生输出的代码呢?在 Prepack 在残留堆上标记所有的“可触及”的值后,它运行一个 序列化器。序列化器的任务是解决如何将 Prepack 残留堆上的 JavaScript 的对象、函数和其他值的对象表示,转为输出代码。如果你对 JSON.stringify() 比较熟悉,从概念上你可以认为 Prepack 序列化器做了类似的事情。不过,JSON.stringify() 可以避免像对象间的循环引用这样的复杂情况:var a = {};var b = {};a.b = b;b.a = a;var x = {a, b};JSON.stringify(x); // Uncaught TypeError: Converting circular structure to JSONJavaScript 程序经常有对象间的循环引用,所以 Prepack 序列化器需要支持这样的情况,并且生成等价的代码以重建这些对象。所以 对于这样的输入:(function() { var a = {}; var b = {}; a.b = b; b.a = a; global.x = {a, b};})();Prepack 生成像这样的代码:(function () { var _2 = { // <– b a: void 0 }; var _1 = { // <– a b: _2 }; _2.a = _1; x = { a: _1, b: _2 };})();注意赋值顺序是不同的(输入代码先构造 a,但是输出代码从 b 开始)。这是因为这个场景下赋值顺序并不重要。同时,这也展示了 Prepack 运行的核心理念:Prepack 并不转换输入代码。它执行输入代码,找到残留堆上的所有值,然后序列化这些值和使用到的效果到输出的 JavaScript 代码中。边注:把东西放到全局对象上好吗?上面的例子你可能会疑问:把值放到全局不是不好的方式吗?但这是指在生产环境中的代码,而如果你在生产环境使用还不能用于生产的试验性的 JavaScript 抽象解释器,那才是更大的问题。对于在类 CommonJS 的环境中通过 module.exports 运行 Prepack 已有部分支持,但现在还很原始(而且也是通过全局对象实现)。不过,这不重要,因为并没有从根本上改变代码的执行,只有当 Prepack 要和其他工具集成时才有压力。残留函数假设我们要向代码添加一些封装,将 2 + 2 的计算放到到一个函数中:(function () { global.getAnswer = function() { var x = 2; var y = 2; return x + y; };})();如果你 尝试对此进行编译,你可能会惊讶于如下的结果:(function () { var _0 = function () { var x = 2; var y = 2; return x + y; }; getAnswer = _0;})();看起来好像 Prepack 并没有优化我们的计算!为什么会这样?缺省情况下,Prepack 只优化“初始化路径”(立即执行的代码)。从 Prepack 的角度来看,Prepack 执行了所有语句后程序已经结束。程序的效果以全局变量 getAnswer 对应的函数所记录。工作已经结束。如果我们在退出程序前调用 getAnswer(),Prepack 会执行它。getAnswer() 的实现是否存在于输出,取决于函数本身对于全局对象是否“可触及”(所以忽略它会不安全)。生成到输出中的函数,被称为“残留函数”(它们是在输出中“残留的”,或者剩下的)。缺省情况下,Prepack 不 会尝试执行或优化残留函数。这通常是不安全的。在残留函数被外部代码调用的时候,JavaScript 运行时全局对象如 Object.prototype,以及由输入代码创建的对象都可能会被修改,这超出了 Prepack 的感知范围。这时 Prepack 可能要使用残留堆中的旧值,再与原始代码中的行为进行比对,或者始终假设任何东西都会修改,这都让优化变得过于困难。哪种方案都不会让人满意,所以残留函数保持原样。不过有个试验模式,可以让你选择优化特定函数,这个后面会提到。速度 vs. 体积开销考虑这个例子:(function () { var x = 2; var y = 2; function getAnswer() { return x + y; }; global.getAnswer = getAnswer;})();Prepack 生成如下代码,在输出中保持 getAnswer() 为残留函数:(function () { var _0 = function () { return 2 + 2; }; getAnswer = _0;})();注意 getAnswer() 并没有被优化,因为它是残留函数,在初始化阶段没有被执行。运算 + 还是在那里。我们可以看到 2 和 2 替换了 x 和 y,这是由于它们在程序运行期间没有改变,所以 Prepack 将其视为常量。如果我们动态生成一个函数,再将其添加到全局对象上呢?例如:(function() { function makeCar(color) { return { getColor() { return color; }, } }; global.cars = [‘red’, ‘green’, ‘blue’, ‘yellow’, ‘pink’].map(makeCar);})();这里,我们创建了多个对象,每个对象都包含一个 getColor() 函数,返回传入 makeCar() 的不同值。Prepack 像这样输出:(function () { var _2 = function () { return “red”; }; var _5 = function () { return “green”; }; var _8 = function () { return “blue”; }; var _B = function () { return “yellow”; }; var _E = function () { return “pink”; }; cars = [{ getColor: _2 }, { getColor: _5 }, { getColor: _8 }, { getColor: _B }, { getColor: _E }];})();注意输出是怎样的,Prepack 并没有保持抽象的 makeCar()。相反,它执行了 makeCar() 调用,并将返回的函数进行了序列化。这也是为什么输出结果中有多个 getColor(),每个 Car 对象一个。这个例子也展示了 Prepack 优化运行时性能,但可能有字节体积上的代价。JavaScript 引擎执行 Prepack 生成的代码会更快,因为它不必执行函数调用并初始化所有的内嵌闭包。但是,生成的代码可能会比输入代码更大 —— 有时候非常明显。这种“代码爆炸”有助于发现初始化阶段哪些代码做了过多的昂贵的元编程(metaprogramming),但也让 Prepack 很难用于对打包后体积敏感的项目中(例如 web 项目)。今天,最简单的处理“代码爆炸”的方法是 延迟运行这些代码将其移入残留函数中,这样就从 Prepack 的执行路径中移除了。当然,这种情况下 Prepack 也就无法优化它。在未来,Prepack 可能会有更好的启发,进而对速度和体积开销有更好的控制。延迟闭包初始化在上一个例子中,color 值被内联到残留函数中,因为它们是常量。但如果闭包中的 color 值会改变呢?考虑如下的例子:(function() { function makeCar(color) { return { getColor() { return color; }, // 读取 color paint(newColor) { color = newColor; }, // 修改 color } }; global.cars = [‘red’, ‘green’, ‘blue’].map(makeCar);})();现在 Prepack 不能直接生成一系列包含类似 return “red” 语句的 getColor() 函数,因为外部代码会通过调用 paint(newColor) 改变颜色。这是 上面场景生成的代码:(function () { var __scope_0 = Array(3); var __scope_1 = function (__selector) { var __captured; switch (__selector) { case 0: __captured = [“red”]; break; case 1: __captured = [“green”]; break; case 2: __captured = [“blue”]; break; default: throw new Error(“Unknown scope selector”); } __scope_0[__selector] = __captured; return __captured; }; var $_0 = function (__scope_2) { var __captured__scope_2 = __scope_0[__scope_2] || __scope_1(__scope_2); return __captured__scope_2[0]; }; var $_1 = function (__scope_2, newColor) { var __captured__scope_2 = __scope_0[__scope_2] || __scope_1(__scope_2); __captured__scope_2[0] = newColor; }; var _2 = $_0.bind(null, 0); var _4 = $_1.bind(null, 0); var _6 = $_0.bind(null, 1); var _8 = $_1.bind(null, 1); var _A = $_0.bind(null, 2); var _C = $_1.bind(null, 2); cars = [{ getColor: _2, paint: _4 }, { getColor: _6, paint: _8 }, { getColor: _A, paint: _C }];})();这看起来非常复杂!我们来看看是怎么回事。注意:如果你一直搞不明白这一节也是完全没关系的。我也是在开始写这一节的时候才搞明白。可能从下往上读更容易些。首先,我们可以看到 Prepack 仍然没有保留 makeCar(),而是将零碎的对象手动拼起来以避免函数调用和闭包创建。每个函数实例是不同的: cars = [{ getColor: _2, // redCar.getColor paint: _4 // redCar.paint }, { getColor: _6, // greenCar.getColor paint: _8 // greenCar.paint }, { getColor: _A, // blueCar.getColor paint: _C // blueCar.paint }];这些函数从哪里来的?Prepack 在上面声明了: var _2 = $_0.bind(null, 0); // redCar.getColor var _4 = $_1.bind(null, 0); // redCar.paint var _6 = $_0.bind(null, 1); // greenCar.getColor var _8 = $_1.bind(null, 1); // greenCar.paint var _A = $_0.bind(null, 2); // blueCar.getColor var _C = $_1.bind(null, 2); // blueCar.paint可以看到被绑定的函数($_0 和 $_1)对应 car 的方法(getColor 和 paint)。Prepack 对所有实例使用复用相同的实现。不过,这些函数得知道是三个独立修改的颜色中的 哪一个。Prepack 得知道如何有效模拟 JavaScript 闭包 但不创建嵌套函数。为了解决这个问题,bind() 的参数(0、1 和 2)给了提示,表示哪个颜色在被函数“捕获”。在例子中,颜色号 0 初始为 ‘red’,颜色号 1 开始是 ‘green’,2 开始是 ‘blue’。当前颜色保存在数组中,在这个函数之后初始化: var __scope_0 = Array(3); // index -> color 映射 var __scope_1 = function (__selector) { // __selector 为索引 var __captured; switch (__selector) { case 0: __captured = [“red”]; break; case 1: __captured = [“green”]; break; case 2: __captured = [“blue”]; break; default: throw new Error(“Unknown scope selector”); } __scope_0[__selector] = __captured; // 在数组中保存初始值 return __captured; };在上面代码中,__scope_0 是数组,Prepack 用于记录颜色所以到颜色值的对应关系。__scope_1 是函数,向数组特定索引设置初始颜色。最终,所有 getColor() 的实现从颜色数组中读取颜色值。如果数组不存在,则通过调用函数来初始化。 var $_0 = function (__scope_2) { var __captured__scope_2 = __scope_0[__scope_2] || __scope_1(__scope_2); return __captured__scope_2[0]; };类似地,paint() 确保数组存在,然后写入。 var $_1 = function (__scope_2, newColor) { var __captured__scope_2 = __scope_0[__scope_2] || __scope_1(__scope_2); __captured__scope_2[0] = newColor; };为什么都有 [0],为什么向数组写入 [“red”] 而不是直接存储颜色?每个闭包可能包含不只一个变量,所以 Prepack 使用额外的数组层级来引用它们。在我们的例子中,color 是闭包中唯一的变量,所以 Prepack 使用了单元素的数组来保存。你可能注意到输出的代码有点长。这在经过压缩后会好些。目前,序列化器的这一部分,专注于正确性而非更有效率的输出。更可能地是,输出可以逐步进行优化,所以如果你发现有更好的优化方案,不要犹豫,直接提交 issue。在一开始,Prepack 并没有生成可以延迟分配闭包的代码。相反,所有捕获的变量都被提升并初始化到输出的全局代码中。这也是一个速度与代码体积的交换,逐渐会有所变化。环境影响这个时候,你可能想试着复制粘贴一些现有代码到 Prepack REPL 中。不过,你很快就会发现像 window 或 document 这样的浏览器基础特性,或者 Node 的 require,并不能如你所想地工作。例如,React DOM 包含如下的特性检查代码,这个 Prepack 不能编译:var documentMode = null;if (‘documentMode’ in document) { documentMode = document.documentMode;}错误信息为:PP0004 (2:23): might be an object that behaves badly for the in operatorPP0001 (3:18): This operation is not yet supported on document at documentModeA fatal error occurred while prepacking.多数 Prepack 的错误码对应有错误描述的 Wiki 页面。例如,这是与 PP0004 对应的页面。(另一个 PP0001 错误来自老的错误系统,你可以帮忙进行迁移)所以为什么上面的代码不能工作?为了回答这个问题,我们需要回顾 Prepack 的工作原理。为了执行代码,Prepack 需要知道不同的值等于什么。而有的东西只在运行时才知道。Prepack 无法知道代码在浏览器中运行时的情况,所以它不能确定 是应该安全地为 document 对象应用 in 运算符,还是应该抛出异常(如果上面有 try / catch,这会是一个潜在的不同的代码路径)。这听起来很槽糕。不过,初始化代码从环境中读取一些在构建阶段不清楚的东西是很常见的。对此有两种方法。一种是只对不依赖外部数据的代码应用 Prepack,把任何环境检测的代码放到 Prepack 以外。对于可以比较容易分离的代码,这是合理的策略。另一种解决方法是使用 Prepack 最强大的特性:抽象值。在下一节中,我们会深入了解抽象值,不过当前 gist 没有这样的例子。Prepack 可以在不知道某些表达式的具体值的情况下执行代码,你可以为 Node 或浏览器 API 或其他未知的输入提供进一步的提示。待续我们涉及了 Prepack 工作原理的基础部分,但还没有探讨更有趣的特性:手动优化选择的残留函数在某些值未知情况下执行代码Prepack 如何“连接”函数执行流使用 Prepack 查看变量可以接收的所有值试验性的 React 编译模式本地检出 Prepack 并调试我们会在下一篇文章中探索这些话题。