前端實現多檔案編譯器

一  概要

在前端工程中,有時我們需要在瀏覽器編譯並執行一些程式碼,這種需求常見於低程式碼場景中。例如我們在搭建時需自定義一部分程式碼,這些程式碼需要在渲染時執行。為了方便起見,我們寫的程式碼一定是 ES6 語法,如果要在瀏覽器執行,那麼就必須經過編譯。下面是前端編譯 JS 程式碼的一些實踐。

二  需求描述

  1. 低碼搭建時需要自定義一部分程式碼
  2. 希望程式碼是以多檔案形式組織的
  3. 可以使用 ESModule 形式匯入/匯出

三  需求分析

1、在瀏覽器編譯程式碼必然需要使用 babel 完成;
2、如果只有一個 JS 檔案,那麼可以直接使用 babel 的 transform 函式編譯;
3、如果存在多檔案,則檔案內的變數必須相互隔離,且檔案之間能夠透過某種形式相互引用,並且需要考慮檔案之間的依賴關係;

四  核心設計

流程

1  變數隔離

由於我們的需求是多檔案編輯,各個檔案內的變數應該相互隔離。最簡單的辦法是將每個文的內容轉成一個閉包,再透過固定的介面將每個檔案連線起來。

假設有 a.js,內容如下:
const a = 1;const b = 2;functionsum(){return a + b'}sum();
可以將其轉為如下形式:
(function(){const a = 1;const b = 2;functionsum(){return a + b' } sum();})();
轉成這種形式之後,每個檔案內的變數就只會存在於各自的閉包之內,互不影響。

五  檔案引用

檔案之間的相互引用可以透過定義一種介面規則實現:
  1. 所有檔案的引用都將透過全域性變數 module 進行;
  2. 每個檔案都將對應到 module 上的一個物件,key 根據檔名而定。

1  匯出

原檔案:
// a.jsexportconst a = 1;
編譯後:
(function() { __filename = 'a.js';const a = 1;var mod = {}; mod.a = a;module[__filename] = mod;})()

2  匯入

原始檔
// b.jsimport { hello } from'./a'hello();
編譯後
(function() { __filename = 'b.js';var $$a = module['a.js']; $$a.hello();var mod = {};module[__filename] = mod;})()

六  依賴樹解析

假設有一堆檔案,我們透過解析(babel 或正則)後得到他們之間的關係如下:

他們之間存在迴圈依賴

根據這個依賴圖可以梳理出幾條依賴路線:

A -> B -> D -> C -> F -> 迴圈依賴B

A -> B -> E -> F -> 迴圈依賴 B

A -> C -> F -> B -> E -> 迴圈依賴 F

A -> C -> G


從開始出現的第一個迴圈依賴截斷依賴路線,分別統計統計每個節點的深度,按深度依次放入佇列中。
如果兩個節點深度相同,則分析兩個節點的依賴關係,被依賴的先進佇列,故最終形成的佇列如下:

F E B C D G A

為什麼要得到一個編譯順序呢?

以上得出的編譯順序是為了儘可能解決如下的引用情況,但也不能解決所有:
// a.jsexportconst a = 2// b.jsimport { a } from'a.js';console.log(a + 2);
這時候,假設執行 b 的時候,a 還沒被執行,那麼 b 內部拿到的 a 實際上是 undefined,顯然不是我們所希望的。所以此時必須保證 a 先於 b 執行。

但這種使用方式在存在迴圈引用時無法解決,只能調整檔案組織形式。

事實上,假設存在迴圈依賴時,下面的在函式內或在類內引用方式是沒有問題的,有問題的只是直接使用:
// a.jsexportconst a = 2// b.jsimport { a } from'a.js';exportfunctiontest () {return a + 1;}
這樣,即使 b 有依賴 a,test 只要不是立即執行函式也不會產生影響。

七  編譯

1  ESModule 轉換

此過程可以透過自定義一個 Babel 外掛完成,在語法編譯時將檔案編譯成一個閉包,同時處理好 ESModule 語法。

該 Babel 外掛很簡單,在此就不展開去寫了。

2  檔案佇列編譯

對單個檔案的編譯可封裝成一個方法,假設函式名為:compileFile

按照上面解析到的檔案佇列按照順序逐個呼叫 compileFile 進行編譯,並將結果直接拼接起來,形成一個巨大的字串,該字串的樣子應該是如下的格式:
(function() { __filename = 'b.js';var $$a = module['a.js'];// ...var mod = {};module[__filename] = mod;})();(function() { __filename = 'a.js';var $$b = module['b.js'];// ...var mod = {};module[__filename] = mod;})();// ...

3  JS 執行

最後一步,執行上面得到的編譯結果即可,此步驟可直接使用 new Function 的方式完成,例如:

(假設以上的字串內容儲存在 compiledScript 中)
const exec = new Functioon(`varmodule = {}; ${compiledScript};returnmodule;`);constmodule = exec();module['a.js'] // a.js 的匯出內容module['b.js'] // b.js 的匯出內容

八  總結

至此,一個前端可執行的小型打包工具就已實現,可以直接在前端進行多檔案的編輯和執行。

即時上,此過程僅適用於不方便藉助伺服器的場景,如果有條件允許可以藉助伺服器,那麼編譯過程最好在服務端完成,甚至還可以藉助 webpack 或 rollup 等打包工具實現更好的編譯效果。

參考

目前我們在 ali-lowcode-engine 之上的原始碼外掛(@ali/lowcode-plugin-code-editor)內部實現了多檔案的支援,目前僅做了最簡單的實現:模組引用直接採用了 UMD 規範,暫時也沒有考慮迴圈依賴和執行順序。

後續會嚴格按照以上步驟進行最佳化。

資料分析系統之資料管理與資料倉庫

點選閱讀原文檢視詳情


相關文章