原文:A Gentle Introduction to Prepack (Part 1)
內容更新至:2018-12-24html
注意:計劃在當前指南更完善後,將其引入 Prepack 文檔中。
目前我以 gist 方式發佈,以便收集反饋。git
若是你在開發 JavaScript 應用,那麼對以下這些將 JavaScript 代碼轉爲等價代碼的工具應該比較熟悉:github
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 讓你編寫普通的 JavaScript 代碼,輸出等價但執行更快的 JavaScript 代碼。
爲何咱們不直接編寫更快的代碼呢?咱們能夠嘗試,若是能夠的話也的確應該。可是,在不少應用中,撇開由性能工具識別出的瓶頸,其實並無不少明顯能夠優化的地方。
一般並無單獨一處致使程序變慢;相反,程序忍受的是「千刀萬剮」。那些提高關注分離的特性,例如函數調用、分配對象和各類抽象,在運行時吃掉了性能。然而,在源碼中移除這些會致使難以維護,並且也並無咱們能夠應用的容易的優化方式。甚至 JavaScript 引擎在多年的優化工做中也有所限制,特別是在初始化只執行一次的代碼上。
最明確的提高性能的方式,是少作一些事情。Prepack 根據這個理念引出其邏輯結論:它 在構建階段 執行程序以瞭解代碼 將要 作什麼,而後生成等價的代碼,可是減小了計算量。
這聽起來太奇幻,因此咱們來看一些例子,瞭解 Prepack 的優點和限制。咱們會使用 Prepack REPL 來在線對一段代碼應用 Prepack。
讓咱們先打開 這個例子:
(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 由於執行了包含 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 支持的,咱們後面再講。
如今咱們能夠粗略地描述 Prepack 是如何工做的了。
在 Prepack 解釋執行輸入代碼時,它構造了程序使用的全部對象的內部表示。對於每個 JavaScript 值(如對象、函數、數值),都有內部的 Prepack 對象記錄其相關信息。Prepack 代碼中有這樣的 class:ObjectValue
、FunctionValue
、NumberValue
,甚至 UndefinedValue
和 NullValue
。
Prepack 也會跟蹤全部輸入代碼對環境產生的「效果」(例如寫入全局變量)。爲了在結果代碼中反映這些效果,Prepack 在代碼執行結束後查找全部仍能經過全局對象觸及到的值。在上面例子中,global.answer
被視爲「可觸及的」,由於不一樣於局部變量 x
和 y
,外部代碼將來能夠讀取 global.answer
。這也是爲何從輸出中忽略 global.answer
不安全,但忽略 x
和 y
是安全的。
全部全局對象可觸及的值(這些可能影響後續執行代碼)被收集到「殘留堆」。這名字聽起來比實際上覆雜多了。「殘留堆」是「堆」(執行代碼建立的全部對象)在代碼完成執行後保持「殘留」(例如,在輸出中保留)的一部分。若是丟掉計算機科學的帽子,咱們能夠稱之爲「剩下的東西」。
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 可能要使用殘留堆中的舊值,再與原始代碼中的行爲進行比對,或者始終假設任何東西都會修改,這都讓優化變得過於困難。哪一種方案都不會讓人滿意,因此殘留函數保持原樣。
不過有個試驗模式,可讓你選擇優化特定函數,這個後面會提到。
考慮這個例子:
(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 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 工做原理的基礎部分,但尚未探討更有趣的特性:
咱們會在下一篇文章中探索這些話題。