作者 | 景遇
一、概要
在前端工程中,有時(shí)我們需要在瀏覽器編譯并執(zhí)行一些代碼,這種需求常見于低代碼場(chǎng)景中。例如我們?cè)诖罱〞r(shí)需自定義一部分代碼,這些代碼需要在渲染時(shí)執(zhí)行。為了方便起見,我們寫的代碼一定是 ES6 語(yǔ)法,如果要在瀏覽器執(zhí)行,那么就必須經(jīng)過編譯。下面是前端編譯 JS 代碼的一些實(shí)踐。
二、需求描述
- 低碼搭建時(shí)需要自定義一部分代碼
- 希望代碼是以多文件形式組織的
- 可以使用 ESModule 形式導(dǎo)入/導(dǎo)出
三、需求分析
- 在瀏覽器編譯代碼必然需要使用 babel 完成;
- 如果只有一個(gè) JS 文件,那么可以直接使用 babel 的 transform 函數(shù)編譯;
- 如果存在多文件,則文件內(nèi)的變量必須相互隔離,且文件之間能夠通過某種形式相互引用,并且需要考慮文件之間的依賴關(guān);
四、核心設(shè)計(jì)
流程
1.變量隔離
由于我們的需求是多文件編輯,各個(gè)文件內(nèi)的變量應(yīng)該相互隔離。最簡(jiǎn)單的辦法是將每個(gè)文的內(nèi)容轉(zhuǎn)成一個(gè)閉包,再通過固定的接口將每個(gè)文件連接起來。
假設(shè)有 a.js,內(nèi)容如下:
const a = 1;
const b = 2;
function sum () {
return a + b'
}
sum();
可以將其轉(zhuǎn)為如下形式:
(function() {
const a = 1;
const b = 2;
function sum () {
return a + b'
}
sum();
})();
轉(zhuǎn)成這種形式之后,每個(gè)文件內(nèi)的變量就只會(huì)存在于各自的閉包之內(nèi),互不影響。
五、文件引用
文件之間的相互引用可以通過定義一種接口規(guī)則實(shí)現(xiàn):
- 所有文件的引用都將通過全局變量 module 進(jìn)行;
- 每個(gè)文件都將對(duì)應(yīng)到 module 上的一個(gè)對(duì)象,key 根據(jù)文件名而定。
1.導(dǎo)出
原文件:
https://back-media.51cto.com/editor/h6e90be6-D8rA67LO
編譯后:
(function() {
__filename = 'a.js';
const a = 1;
var mod = {};
mod.a = a;
module[__filename] = mod;
})()
2.導(dǎo)入
源文件:
// b.js
import { hello } from './a'
hello();
編譯后:
(function() {
__filename = 'b.js';
var $$a = module['a.js'];
$$a.hello();
var mod = {};
module[__filename] = mod;
})()
六、依賴樹解析
假設(shè)有一堆文件,我們通過解析(babel 或正則)后得到他們之間的關(guān)系如下:
他們之間存在循環(huán)依賴。
根據(jù)這個(gè)依賴圖可以梳理出幾條依賴路線:
- A -> B -> D -> C -> F -> 循環(huán)依賴B
- A -> B -> E -> F -> 循環(huán)依賴 B
- A -> C -> F -> B -> E -> 循環(huán)依賴 F
- A -> C -> G
從開始出現(xiàn)的第一個(gè)循環(huán)依賴截?cái)嘁蕾嚶肪€,分別統(tǒng)計(jì)統(tǒng)計(jì)每個(gè)節(jié)點(diǎn)的深度,按深度依次放入隊(duì)列中。
如果兩個(gè)節(jié)點(diǎn)深度相同,則分析兩個(gè)節(jié)點(diǎn)的依賴關(guān)系,被依賴的先進(jìn)隊(duì)列,故最終形成的隊(duì)列如下:
- F E B C D G A
為什么要得到一個(gè)編譯順序呢?
以上得出的編譯順序是為了盡可能解決如下的引用情況,但也不能解決所有:
// a.js
export const a = 2
// b.js
import { a } from 'a.js';
console.log(a + 2);
這時(shí)候,假設(shè)執(zhí)行 b 的時(shí)候,a 還沒被執(zhí)行,那么 b 內(nèi)部拿到的 a 實(shí)際上是 undefined,顯然不是我們所希望的。所以此時(shí)必須保證 a 先于 b 執(zhí)行。
但這種使用方式在存在循環(huán)引用時(shí)無(wú)法解決,只能調(diào)整文件組織形式。
事實(shí)上,假設(shè)存在循環(huán)依賴時(shí),下面的在函數(shù)內(nèi)或在類內(nèi)引用方式是沒有問題的,有問題的只是直接使用:
// a.js
export const a = 2
// b.js
import { a } from 'a.js';
export function test () {
return a + 1;
}
這樣,即使 b 有依賴 a,test 只要不是立即執(zhí)行函數(shù)也不會(huì)產(chǎn)生影響。
七、編譯
1.ESModule 轉(zhuǎn)換
此過程可以通過自定義一個(gè) Babel 插件完成,在語(yǔ)法編譯時(shí)將文件編譯成一個(gè)閉包,同時(shí)處理好 ESModule 語(yǔ)法。
該 Babel 插件很簡(jiǎn)單,在此就不展開去寫了。
2.文件隊(duì)列編譯
對(duì)單個(gè)文件的編譯可封裝成一個(gè)方法,假設(shè)函數(shù)名為:compileFile。
按照上面解析到的文件隊(duì)列按照順序逐個(gè)調(diào)用 compileFile 進(jìn)行編譯,并將結(jié)果直接拼接起來,形成一個(gè)巨大的字符串,該字符串的樣子應(yīng)該是如下的格式:
(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 執(zhí)行
最后一步,執(zhí)行上面得到的編譯結(jié)果即可,此步驟可直接使用 new Function 的方式完成,例如:
(假設(shè)以上的字符串內(nèi)容保存在 compiledScript 中)。
const exec = new Functioon(`
var module = {};
${compiledScript};
return module;
`);
const module = exec();
module['a.js'] // a.js 的導(dǎo)出內(nèi)容
module['b.js'] // b.js 的導(dǎo)出內(nèi)容
八、總結(jié)
至此,一個(gè)前端可執(zhí)行的小型打包工具就已實(shí)現(xiàn),可以直接在前端進(jìn)行多文件的編輯和執(zhí)行。
實(shí)時(shí)上,此過程僅適用于不方便借助服務(wù)器的場(chǎng)景,如果有條件允許可以借助服務(wù)器,那么編譯過程最好在服務(wù)端完成,甚至還可以借助 webpack 或 rollup 等打包工具實(shí)現(xiàn)更好的編譯效果。
參考
目前我們?cè)?ali-lowcode-engine 之上的源碼插件(@ali/lowcode-plugin-code-editor)內(nèi)部實(shí)現(xiàn)了多文件的支持,目前僅做了最簡(jiǎn)單的實(shí)現(xiàn):模塊引用直接采用了 UMD 規(guī)范,暫時(shí)也沒有考慮循環(huán)依賴和執(zhí)行順序。
后續(xù)會(huì)嚴(yán)格按照以上步驟進(jìn)行優(yōu)化。