無編譯/無伺服器實現瀏覽器的CommonJS模組化
引言
平時經常會逛 github,除了一些 star 極高的大專案外,還會在 Github 上發現很多有意思的小專案。專案或是想法很有趣,或是有不錯的技術點,讀起來都讓人有所收穫。所以準備彙總成一個「漫遊Github」系列,不定期分享與解讀在 Github 上偶遇的有趣專案。本系列重在原理性講解,而不會深扣原始碼細節。
好了下面進入正題。本期要介紹的倉庫叫one-click.js。
1. one-click.js是什麼
one-click.js是個很有意思的庫。Github 裡是這麼介紹它的:
我們知道,如果希望 Commonjs的模組化程式碼能在瀏覽器中正常執行,通常都會需要構建/打包工具,例如webpack、rollup 等。而 one-click.js 可以讓你在不需要這些構建工具的同時,也可以在瀏覽器中正常執行基於 CommonJS 的模組系統。
進一步的,甚至你都不需要啟動一個伺服器。例如試著你可以試下 clone 下 one-click.js 專案,直接雙擊(用瀏覽器開啟)其中的example/index.html就可以執行。
Repo 裡有一句話概述了它的功能:
Use CommonJS modules directly in the browser with no build step and no web server.
舉個例子來說 ——
假設在當前目錄(demo/)現在,我們有三個“模組”檔案:
demo/plus.js:
// plus.js module.exports = function plus(a,b) { return a + b; }
demo/divide.js:
// divide.js module.exports = function divide(a,b) { return a / b; }
與入口模組檔案demo/main.js:
// main.js const plus = require('./plus.js'); const divide = require('./divide.js'); console.log(divide(12,add(1,2))); // output: 4
常見用法是指定入口,用webpack編譯成一個 bundle,然後瀏覽器引用。而 one-click.js 讓你可以拋棄這些,只需要在html中這麼用:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>one click example</title> </head> <body> <script type="text/javascript" src="./one-click.js" data-main="./main.js"></script> </body> </html>
注意script標籤的使用方式,其中的data-main就指定了入口檔案。此時直接用瀏覽器開啟這個本地 HTML 檔案,就可以正常輸出結果 7。
2. 打包工具是如何工作的?
上一節介紹了 one-click.js 的功能 —— 核心就是實現不需要打包/構建的前端模組化能力。
在介紹其內部實現這之前,我們先來了解下打包工具都幹了什麼。俗話說,知己知彼,百戰不殆。
還是我們那三個javaScript檔案。
plus.js:
// plus.js module.exports = function plus(a,b) { return a + b; }
divide.js:
// divide.js module.exports = function divide(a,b) { return a / b; }
與入口模組 main.js:
// main.js const plus = require('./plus.js'); const divide = require('./divide.js'); console.log(divide(12,2))); // output: 4
回憶一下,當我們使用 webpack 時,會指定入口(main.js)。webpack 會根據該入口打包出一個 bundle(例如 bundle.js)。最後我們在頁面中引入處理好的 bundle.js 即可。這時的 bundle.js 除了原始碼,已經加了很多 webpack 的“私貨”。
簡單理一理其中 webpack 涉及到的工作:
- 依賴分析:首先,在打包時 webpack 會根據語法分析結果來獲取模組的依賴關係。簡單來說,在 CommonJS 中就是根據解析出的 require語法來得到當前模組所依賴的子模組。
- 作用域隔離與變數注入:對於每個模組檔案,webpack 都會將其包裹在一個 function 中。這樣既可以做到module、require等變數的注入,又可以隔離作用域,防止變數的全域性汙染。
- 提供模組執行時:最後,為了require、exports的有效執行,還需要提供一套執行時程式碼,來實現模組的載入、執行、匯出等功能。
如果對以上的 2、3 項不太瞭解,可以從篇文章中瞭解webpack 的模組執行時設計。
3. 我們面對的挑戰
沒有了構建工具,直接在瀏覽器中執行使用了 CommonJS 的模組,其實就是要想辦法完成上面提到的三項工作:
- 依賴分析
- 作用域隔離與變數注入
- 提供模組執行時
解決這三個問題就是 one-click.js 的核心任務。下面我們來分別看看是如何解決的。
3.1. 依賴分析
這是個麻煩的問題。如果想要正確載入模組,必須準確知道模組間的依賴。例如上面提到的三個模組檔案 ——main.js依賴plus.js和divide.js,所以在執行main.js中程式碼時,需要保證plus.js和divide.js都已經載入進瀏覽器環境。然而問題就在於,沒有編譯工具後,我們自然無法自動化的知道模組間的依賴關係。
對於RequireJS這樣的模組庫來說,它是在程式碼中聲明當前模組的依賴,然後使用非同步載入加回調的方式。顯然,CommonJS 規範是沒有這樣的非同步 API 的。
而 one-click.js 用了一個取巧但是有額外成本的方式來分析依賴 —— 載入兩遍模組檔案。在第一次載入模組檔案時,為模組檔案提供一個 mock 的require方法,每當模組呼叫該方法時,就可以在 require 中知道當前模組依賴哪些子模組了。
// main.js const plus = require('./plus.js'); const divide = require('./divide.js'); console.log(minus(12,2)));
例如上面的main.js,我們可以提供一個類似下面的require方法:
const recordedFieldAccessesByRequireCall = {}; const require = function collect(modPath) { recordedFieldAccessesByRequireCall[modPath] = true; var script = document.createElement('script'); script.src = modPath; document.body.appendChild(script); };
main.js載入後,會做兩件事:
- 記錄當前模組中依賴的子模組;
- 載入子模組。
這樣,我們就可以在recordedFieldAccessesByRequireCall中記錄當前模組的依賴情況;同時載入子模組。而對於子模組也可以有遞迴操作,直到不再有新的依賴出現。最後將各個模組的recordedFieldAccessesByRequireCall整合起來就是我們的依賴關係。
此外,如果我們還想要知道main.js實際呼叫了子模組中的哪些方法,可以通過Proxy來返回一個代理物件,統計進一步的依賴情況:
const require = function collect(modPath) { recordedFieldAccessesByRequireCall[modPath] = []; var megaProxy = new Proxy(function(){},{ get: function(target,prop,receiver) { if(prop == Symbol.toPrimitive) { return function() {0;}; } return megaProxy; } }); var recordFieldAccess = new Proxy(function(){},receiver) { window.recordedFieldAccessesByRequireCall[modPath].push(prop); return megaProxy; } }); // …… 一些其他處理 return recordFieldAccess; };
以上的程式碼會在你獲取被匯入模組的屬性時記錄所使用的屬性。
上面所有模組的載入就是我們所說的“載入兩遍”的第一遍,用於分析依賴關係。而第二遍就需要基於入口模組的依賴關係,“逆向”載入模組即可。例如main.js依賴plus.js和divide.js,那麼實際上載入的順序是plus.js->divide.js->main.js。
值得一提的是,在第一次載入所有模組的過程中,這些模組執行基本都是會報錯的(因為依賴的載入順序都是錯誤的),我們會忽略執行的錯誤,只關注依賴關係的分析。當拿到依賴關係後,再使用正確的順序重新載入一遍所有模組檔案。one-click.js 中有更完備的實現,該方法名為scrapeModuleIdempotent,具體原始碼可以看這裡。
到這裡你可能會發現:“這是一種浪費啊,每個檔案都載入了兩遍。”
確實如此,這也是 one-click.js 的tradeoff:
In order to make this work offline,One Click needs to initialize your modules twice,once in the background upon page load,in order to map out the dependency graph,and then another time to actually perform the module loading.
3.2. 作用域隔離
我們知道,模組有一個很重要的特點 —— 模組間的作用域是隔離的。例如,對於如下普通的 JavaScript 指令碼:
// normal script.js var foo = 123;
當其載入進瀏覽器時,foo變數實際會變成一個全域性變數,可以通過window.foo訪問到,這也會帶來全域性汙染,模組間的變數、方法都可能互相沖突與覆蓋。
在 NodeJS 環境下,由於使用 CommonJS 規範,同樣像上面這樣的模組檔案被匯入時,foo變數的作用域只在源模組中,不會汙染全域性。而 NodeJS 在實現上其實就是用一個 wrap function 包裹了模組內的程式碼,我們都知道,function 會形成其自己的作用域,因此就實現了隔離。
NodeJS 會在requirewww.cppcns.com時對原始碼檔案進行包裝,而 webpack 這類打包工具會在編譯期對原始碼檔案進行改寫(也是類似的包裝)。而 one-click.js 沒有編譯工具,那編譯期改寫肯定行不通了,那怎麼辦呢?下面來介紹兩種常用方式:
3.2.1. JavaScript 的動態程式碼執行
一種方式可以通過fetch請求獲取 script 中文字內容,然後通過new Function或eval這樣的方式來實現動態程式碼的執行。這裡以fetch+new Function方式來做個介紹:
還是上面的除法模組divide.js,稍加改造下,原始碼如下:
// 以指令碼形式載入時,該變數將會變為 window.outerVar 的全域性變數,造成汙染 var outerVar = 123; module.exports = function (a,b) { return a / b; }
現在我們來實現作用域遮蔽:
const modMap = {}; function require(modPath) { if (modMap[modPath]) { return modMap[modPath].exports; } } fetch('./divide.js') .then(res => res.text()) .then(source => { const mod = new Function('exports','require','module',source); const modObj = { id: 1,filename: './divide.js',parents: null,children: [],exports: {} }; mod(modObj.exports,require,modObj); modMap['./divide.js'] = modObj; return; }) .then(() => { const divide = require('./divide.js') console.log(divide(10,2)); // 5 console.log(window.outerVar); // undefined });
程式碼很簡單,核心就是通過fetch獲取到原始碼後,通過new Function將其構造在一個函式內,呼叫時向其“注入”一些模組執行時的變數。為了程式碼順利執行,還提供了一個簡單的require方法來實現模組引用。
當然,上面這是一種解決方式,然而在 one-click.js 的目標下卻行不通。因為 one-click.js 還有程式設計客棧一個目標是能夠在無伺服器(offline)的情況下執行,所以fetch請求是無效的。
那麼 one-click.js 是如何處理的呢?下面我們就來了解下:
3.2.2. 另一種作用域隔離方式
一般而言,隔離的需求與沙箱非常類似,而在前端建立一個沙箱有一種常用的方式,就是 iframe。下面為了方便起見,我們把使用者實際使用的視窗叫作“主視窗”,而其中內嵌的 iframe 叫作“子視窗”。由於 iframe 天然的特性,每個子視窗都有自己的window物件,相互之間隔離,不會對主視窗進行汙染,也不會相互汙染。
下面仍然以載入 divide.js 模組為例。首先我們構造一個 iframe 用於載入指令碼:
var iframe = document.createElement("iframe"); iframe.style = "display:none !important"; document.body.appendChild(iframe); var doc = iframe.contentWindow.document; var htmlStr = ` <html><head><title></title></head><body> <script src="./divide.js"></script></body></html> `; doc.open(); doc.write(htmlStr); doc.close();
這樣就可以在“隔離的作用域”中載入模組指令碼了。但顯然它還無法正常工作,所以下一步我們就要補全它的模組匯入與匯出功能。模組匯出要解決的問題就是讓主視窗能夠訪問子視窗中的模組物件。所以我們可以在子視窗的指令碼載入執行完後,將其掛載到主視窗的變數上。
修改以上程式碼:
// ……省略重複程式碼 var htmlStr = ` <html><head><title></title></head><body> <scrip> window.require = parent.window.require; window.exports = window.module.exports = undefined; </script> <script src="./divide.js"></script> <scrip> if (window.module.exports !== undefined) { parent.window.modObj['./divide.js'] = window.module.exports; } </script> </body></html> `; // ……省略重複程式碼
核心就是通過像parent.window這樣的方式實現主視窗與子視窗之間的“穿透”:
- 將子視窗的物件掛載到主視窗上;
- 同時支援子視窗呼叫主視窗中方法的作用。
上面只是一個原理性的粗略實現,如果對更嚴謹的實現細節感興趣可以看原始碼中的loadModuleForModuleData 方法。
值得一提的是,在「3.1. 依賴分析」中提到先載入一遍所有模組來獲取依賴關係,而這部分的載入也是放在 iframe 中進行的,也需要防止“汙染”。
3.3. 提供模組執行時
模組的執行時一版包括了構造模組物件(module object)、儲存模組物件以及提供一個模組匯入方法(require)。模組執行時的各類實現一般都大同小異,這裡需要注意的就是,如果隔離的方法使用 iframe,那麼需要在主視窗與子視窗中傳遞一些執行時方法和物件。
當然,細節上還可能會需要支援模組路徑解析(resolve)、迴圈依賴的處理、錯誤處理http://www.cppcns.com等。由於這部分的實現和很多庫類似,又或者不算特別核心,在這裡就不詳細介紹了。
4. 總結
最後歸納一下大致的執行流程:
1.首先從頁面中拿到入口模組,在 one-click.js 中就是document.querySelector("script[data-main]").dataset.main;
2.在 iframe 中“順藤摸瓜”載入模組,並在require方法中收集模組依賴,直到沒有新的依賴出現;
3.收集完畢,此時就拿到了完整的依賴圖;
4.根據依賴圖,“逆向”載入相應模組檔案,使用 iframe 隔離作用域,同時注意將主視窗中的模組執行時傳給各個子視窗;
5.最後,當載入到入口指令碼時,www.cppcns.com所有依www.cppcns.com賴準備就緒,直接執行即可。
總的來說,由於沒有了構建工具與伺服器的幫助,所以要實現依賴分析與作用域隔離就成了困難。而 one-click.js 運用上面提到的技術手段解決了這些問題。
那麼,one-click.js 可以用在生產環境麼?顯然是不行的。
Do not use this in production. The only purpose of this utility is to make local development simpler.
所以注意了,作者也說了,這個庫的目的僅僅是方便本地開發。當然,其中一些技術手段作為學習資料,咱們也是可以瞭解學習一下的。感興趣的小夥伴可以訪問one-click.js 倉庫進一步瞭解。
以上就是無編譯/無伺服器實現瀏覽器的CommonJS模組化的詳細內容,更多關於無編譯/無伺服器實現CommonJS模組化的資料請關注我們其它相關文章!