Prepack 介绍(译)

原文:A Gentle Introduction to Prepack (Part 1)
内容更新至:2018-12-24html


注意:

计划在当前指南更完善后,将其引入 Prepack 文档中。
目前我以 gist 方式发布,以便收集反馈。git

Prepack 介绍(第一部分)

若是你在开发 JavaScript 应用,那么对以下这些将 JavaScript 代码转为等价代码的工具应该比较熟悉:github

  • Babel 让你可以使用更新的 JavaScript 语言特性,输出兼容老的 JavaScript 引擎的等价代码。
  • Uglify 让你可以编写可读的 JavaScript 代码,输出完成相同功能可是字节数更少的混淆代码。

Prepack 是另外一个致力于将 JavaScript 代码编译为等价代码的工具。但与 Babel 或 Uglify 不一样的是,Prepack 的目标不是新特性或代码体积。web

Prepack 让你编写普通的 JavaScript 代码,而后输出执行地更快的等价代码。编程

若是这听起来让人兴奋,那么接下来你会了解到 Prepack 是如何工做的,以及你能够怎样让它作得更好。数组

这个指南有什么?

就我我的而言,当我最终理解 Prepack 能作什么时,我很是兴奋。我认为在将来,Prepack 会解决目前我在开发大型 JavaScript 应用时遇到的不少问题。我很想传播这一点,让其余人也兴奋起来。浏览器

不过,向 Prepack 贡献力量在一开始会让人惧怕。它的源码里有不少我不熟悉的术语,我花了很长时间才明白 Prepack 作了什么。编译器相关代码倾向于使用肯定的计算机科学术语,但这些术语让它们听起来比实际状况要复杂。安全

我编写这个指南,就是为了那些没有计算机科学背景,但对 Prepack 的目标感兴趣,而且但愿帮助它实现的 JavaScript 开发者。babel

本指南就 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;

输出中,很奇怪地,也包含 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 中。不过,你很快就会发现像 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 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 并调试

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

相关文章
相关标签/搜索