Prepack 介绍(译)

1,771 阅读19分钟

原文: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。这种优化被称作 “常量折叠(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;

输出中,很奇怪地,也包含 xy 的定义:

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 将 xy 保留在输出中。别忘了 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:ObjectValueFunctionValueNumberValue,甚至 UndefinedValueNullValue

Prepack 也会跟踪所有输入代码对环境产生的“效果”(例如写入全局变量)。为了在结果代码中反映这些效果,Prepack 在代码执行结束后查找所有仍能通过全局对象触及到的值。在上面例子中,global.answer 被视为“可触及的”,因为不同于局部变量 xy,外部代码未来可以读取 global.answer。这也是为什么从输出中忽略 global.answer 不安全,但忽略 xy 是安全的。

所有全局对象可触及的值(这些可能影响后续执行代码)被收集到“残留堆”。这名字听起来比实际上复杂多了。“残留堆”是“堆”(执行代码创建的所有对象)在代码完成执行后保持“残留”(例如,在输出中保留)的一部分。如果丢掉计算机科学的帽子,我们可以称之为“剩下的东西”。

序列化器(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 JSON

JavaScript 程序经常有对象间的循环引用,所以 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() 并没有被优化,因为它是残留函数,在初始化阶段没有被执行。运算 + 还是在那里。我们可以看到 22 替换了 xy,这是由于它们在程序运行期间没有改变,所以 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 的方法(getColorpaint)。Prepack 对所有实例使用复用相同的实现。

不过,这些函数得知道是三个独立修改的颜色中的 哪一个。Prepack 得知道如何有效模拟 JavaScript 闭包 但不创建嵌套函数。

为了解决这个问题,bind() 的参数(012)给了提示,表示哪个颜色在被函数“捕获”。在例子中,颜色号 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 中。不过,你很快就会发现像 windowdocument 这样的浏览器基础特性,或者 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 operator
PP0001 (3:18):  This operation is not yet supported on document at documentMode
A 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 并调试

我们会在下一篇文章中探索这些话题。