無編譯/無服務(wù)器實(shí)現(xiàn)瀏覽器的CommonJS模塊化
平時(shí)經(jīng)常會(huì)逛 Github,除了一些 star 極高的大項(xiàng)目外,還會(huì)在 Github 上發(fā)現(xiàn)很多有意思的小項(xiàng)目。項(xiàng)目或是想法很有趣,或是有不錯(cuò)的技術(shù)點(diǎn),讀起來都讓人有所收獲。所以準(zhǔn)備匯總成一個(gè)「漫游Github」系列,不定期分享與解讀在 Github 上偶遇的有趣項(xiàng)目。本系列重在原理性講解,而不會(huì)深扣源碼細(xì)節(jié)。
好了下面進(jìn)入正題。本期要介紹的倉庫叫one-click.js。
1. one-click.js是什么one-click.js是個(gè)很有意思的庫。Github 里是這么介紹它的:
我們知道,如果希望 Commonjs的模塊化代碼能在瀏覽器中正常運(yùn)行,通常都會(huì)需要構(gòu)建/打包工具,例如webpack、rollup 等。而 one-click.js 可以讓你在不需要這些構(gòu)建工具的同時(shí),也可以在瀏覽器中正常運(yùn)行基于 CommonJS 的模塊系統(tǒng)。
進(jìn)一步的,甚至你都不需要啟動(dòng)一個(gè)服務(wù)器。例如試著你可以試下 clone 下 one-click.js 項(xiàng)目,直接雙擊(用瀏覽器打開)其中的example/index.html就可以運(yùn)行。
Repo 里有一句話概述了它的功能:
Use CommonJS modules directly in the browser with no build step and no web server.
舉個(gè)例子來說 ——
假設(shè)在當(dāng)前目錄(demo/)現(xiàn)在,我們有三個(gè)“模塊”文件:
demo/plus.js:
// plus.jsmodule.exports = function plus(a, b) { return a + b;}
demo/divide.js:
// divide.jsmodule.exports = function divide(a, b) { return a / b;}
與入口模塊文件demo/main.js:
// main.jsconst plus = require(’./plus.js’);const divide = require(’./divide.js’);console.log(divide(12, add(1, 2)));// output: 4
常見用法是指定入口,用webpack編譯成一個(gè) 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='http://www.cgvv.com.cn/bcjs/one-click.js' data-main='./main.js'></script></body></html>
注意script標(biāo)簽的使用方式,其中的data-main就指定了入口文件。此時(shí)直接用瀏覽器打開這個(gè)本地 HTML 文件,就可以正常輸出結(jié)果 7。
2. 打包工具是如何工作的?上一節(jié)介紹了 one-click.js 的功能 —— 核心就是實(shí)現(xiàn)不需要打包/構(gòu)建的前端模塊化能力。
在介紹其內(nèi)部實(shí)現(xiàn)這之前,我們先來了解下打包工具都干了什么。俗話說,知己知彼,百戰(zhàn)不殆。
還是我們那三個(gè)JavaScript文件。
plus.js:
// plus.jsmodule.exports = function plus(a, b) { return a + b;}
divide.js:
// divide.jsmodule.exports = function divide(a, b) { return a / b;}
與入口模塊 main.js:
// main.jsconst plus = require(’./plus.js’);const divide = require(’./divide.js’);console.log(divide(12, add(1, 2)));// output: 4
回憶一下,當(dāng)我們使用 webpack 時(shí),會(huì)指定入口(main.js)。webpack 會(huì)根據(jù)該入口打包出一個(gè) bundle(例如 bundle.js)。最后我們?cè)陧撁嬷幸胩幚砗玫?bundle.js 即可。這時(shí)的 bundle.js 除了源碼,已經(jīng)加了很多 webpack 的“私貨”。
簡(jiǎn)單理一理其中 webpack 涉及到的工作:
依賴分析:首先,在打包時(shí) webpack 會(huì)根據(jù)語法分析結(jié)果來獲取模塊的依賴關(guān)系。簡(jiǎn)單來說,在 CommonJS 中就是根據(jù)解析出的 require語法來得到當(dāng)前模塊所依賴的子模塊。 作用域隔離與變量注入:對(duì)于每個(gè)模塊文件,webpack 都會(huì)將其包裹在一個(gè) function 中。這樣既可以做到module、require等變量的注入,又可以隔離作用域,防止變量的全局污染。 提供模塊運(yùn)行時(shí):最后,為了require、exports的有效執(zhí)行,還需要提供一套運(yùn)行時(shí)代碼,來實(shí)現(xiàn)模塊的加載、執(zhí)行、導(dǎo)出等功能。如果對(duì)以上的 2、3 項(xiàng)不太了解,可以從篇文章中了解webpack 的模塊運(yùn)行時(shí)設(shè)計(jì)。
3. 我們面對(duì)的挑戰(zhàn)沒有了構(gòu)建工具,直接在瀏覽器中運(yùn)行使用了 CommonJS 的模塊,其實(shí)就是要想辦法完成上面提到的三項(xiàng)工作:
依賴分析 作用域隔離與變量注入 提供模塊運(yùn)行時(shí)解決這三個(gè)問題就是 one-click.js 的核心任務(wù)。下面我們來分別看看是如何解決的。
3.1. 依賴分析這是個(gè)麻煩的問題。如果想要正確加載模塊,必須準(zhǔn)確知道模塊間的依賴。例如上面提到的三個(gè)模塊文件 ——main.js依賴plus.js和divide.js,所以在運(yùn)行main.js中代碼時(shí),需要保證plus.js和divide.js都已經(jīng)加載進(jìn)瀏覽器環(huán)境。然而問題就在于,沒有編譯工具后,我們自然無法自動(dòng)化的知道模塊間的依賴關(guān)系。
對(duì)于RequireJS這樣的模塊庫來說,它是在代碼中聲明當(dāng)前模塊的依賴,然后使用異步加載加回調(diào)的方式。顯然,CommonJS 規(guī)范是沒有這樣的異步 API 的。
而 one-click.js 用了一個(gè)取巧但是有額外成本的方式來分析依賴 —— 加載兩遍模塊文件。在第一次加載模塊文件時(shí),為模塊文件提供一個(gè) mock 的require方法,每當(dāng)模塊調(diào)用該方法時(shí),就可以在 require 中知道當(dāng)前模塊依賴哪些子模塊了。
// main.jsconst plus = require(’./plus.js’);const divide = require(’./divide.js’);console.log(minus(12, add(1, 2)));
例如上面的main.js,我們可以提供一個(gè)類似下面的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加載后,會(huì)做兩件事:
記錄當(dāng)前模塊中依賴的子模塊; 加載子模塊。這樣,我們就可以在recordedFieldAccessesByRequireCall中記錄當(dāng)前模塊的依賴情況;同時(shí)加載子模塊。而對(duì)于子模塊也可以有遞歸操作,直到不再有新的依賴出現(xiàn)。最后將各個(gè)模塊的recordedFieldAccessesByRequireCall整合起來就是我們的依賴關(guān)系。
此外,如果我們還想要知道m(xù)ain.js實(shí)際調(diào)用了子模塊中的哪些方法,可以通過Proxy來返回一個(gè)代理對(duì)象,統(tǒng)計(jì)進(jìn)一步的依賴情況:
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(){}, {get: function(target, prop, receiver) { window.recordedFieldAccessesByRequireCall[modPath].push(prop); return megaProxy;} }); // …… 一些其他處理 return recordFieldAccess;};
以上的代碼會(huì)在你獲取被導(dǎo)入模塊的屬性時(shí)記錄所使用的屬性。
上面所有模塊的加載就是我們所說的“加載兩遍”的第一遍,用于分析依賴關(guān)系。而第二遍就需要基于入口模塊的依賴關(guān)系,“逆向”加載模塊即可。例如main.js依賴plus.js和divide.js,那么實(shí)際上加載的順序是plus.js->divide.js->main.js。
值得一提的是,在第一次加載所有模塊的過程中,這些模塊執(zhí)行基本都是會(huì)報(bào)錯(cuò)的(因?yàn)橐蕾嚨募虞d順序都是錯(cuò)誤的),我們會(huì)忽略執(zhí)行的錯(cuò)誤,只關(guān)注依賴關(guān)系的分析。當(dāng)拿到依賴關(guān)系后,再使用正確的順序重新加載一遍所有模塊文件。one-click.js 中有更完備的實(shí)現(xiàn),該方法名為scrapeModuleIdempotent,具體源碼可以看這里。
到這里你可能會(huì)發(fā)現(xiàn):“這是一種浪費(fèi)啊,每個(gè)文件都加載了兩遍。”
確實(shí)如此,這也是 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. 作用域隔離我們知道,模塊有一個(gè)很重要的特點(diǎn) —— 模塊間的作用域是隔離的。例如,對(duì)于如下普通的 JavaScript 腳本:
// normal script.jsvar foo = 123;
當(dāng)其加載進(jìn)瀏覽器時(shí),foo變量實(shí)際會(huì)變成一個(gè)全局變量,可以通過window.foo訪問到,這也會(huì)帶來全局污染,模塊間的變量、方法都可能互相沖突與覆蓋。
在 NodeJS 環(huán)境下,由于使用 CommonJS 規(guī)范,同樣像上面這樣的模塊文件被導(dǎo)入時(shí),foo變量的作用域只在源模塊中,不會(huì)污染全局。而 NodeJS 在實(shí)現(xiàn)上其實(shí)就是用一個(gè) wrap function 包裹了模塊內(nèi)的代碼,我們都知道,function 會(huì)形成其自己的作用域,因此就實(shí)現(xiàn)了隔離。
NodeJS 會(huì)在require時(shí)對(duì)源碼文件進(jìn)行包裝,而 webpack 這類打包工具會(huì)在編譯期對(duì)源碼文件進(jìn)行改寫(也是類似的包裝)。而 one-click.js 沒有編譯工具,那編譯期改寫肯定行不通了,那怎么辦呢?下面來介紹兩種常用方式:
3.2.1. JavaScript 的動(dòng)態(tài)代碼執(zhí)行
一種方式可以通過fetch請(qǐng)求獲取 script 中文本內(nèi)容,然后通過new Function或eval這樣的方式來實(shí)現(xiàn)動(dòng)態(tài)代碼的執(zhí)行。這里以fetch+new Function方式來做個(gè)介紹:
還是上面的除法模塊divide.js,稍加改造下,源碼如下:
// 以腳本形式加載時(shí),該變量將會(huì)變?yōu)?window.outerVar 的全局變量,造成污染var outerVar = 123;module.exports = function (a, b) { return a / b;}
現(xiàn)在我們來實(shí)現(xiàn)作用域屏蔽:
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)); // 5console.log(window.outerVar); // undefined });
代碼很簡(jiǎn)單,核心就是通過fetch獲取到源碼后,通過new Function將其構(gòu)造在一個(gè)函數(shù)內(nèi),調(diào)用時(shí)向其“注入”一些模塊運(yùn)行時(shí)的變量。為了代碼順利運(yùn)行,還提供了一個(gè)簡(jiǎn)單的require方法來實(shí)現(xiàn)模塊引用。
當(dāng)然,上面這是一種解決方式,然而在 one-click.js 的目標(biāo)下卻行不通。因?yàn)?one-click.js 還有一個(gè)目標(biāo)是能夠在無服務(wù)器(offline)的情況下運(yùn)行,所以fetch請(qǐng)求是無效的。
那么 one-click.js 是如何處理的呢?下面我們就來了解下:
3.2.2. 另一種作用域隔離方式
一般而言,隔離的需求與沙箱非常類似,而在前端創(chuàng)建一個(gè)沙箱有一種常用的方式,就是 iframe。下面為了方便起見,我們把用戶實(shí)際使用的窗口叫作“主窗口”,而其中內(nèi)嵌的 iframe 叫作“子窗口”。由于 iframe 天然的特性,每個(gè)子窗口都有自己的window對(duì)象,相互之間隔離,不會(huì)對(duì)主窗口進(jìn)行污染,也不會(huì)相互污染。
下面仍然以加載 divide.js 模塊為例。首先我們構(gòu)造一個(gè) 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='http://www.cgvv.com.cn/bcjs/divide.js'></script></body></html>`;doc.open();doc.write(htmlStr);doc.close();
這樣就可以在“隔離的作用域”中加載模塊腳本了。但顯然它還無法正常工作,所以下一步我們就要補(bǔ)全它的模塊導(dǎo)入與導(dǎo)出功能。模塊導(dǎo)出要解決的問題就是讓主窗口能夠訪問子窗口中的模塊對(duì)象。所以我們可以在子窗口的腳本加載運(yùn)行完后,將其掛載到主窗口的變量上。
修改以上代碼:
// ……省略重復(fù)代碼var htmlStr = ` <html><head><title></title></head><body> <scrip>window.require = parent.window.require;window.exports = window.module.exports = undefined; </script> <script src='http://www.cgvv.com.cn/bcjs/divide.js'></script> <scrip>if (window.module.exports !== undefined) { parent.window.modObj[’./divide.js’] = window.module.exports;} </script> </body></html>`;// ……省略重復(fù)代碼
核心就是通過像parent.window這樣的方式實(shí)現(xiàn)主窗口與子窗口之間的“穿透”:
將子窗口的對(duì)象掛載到主窗口上; 同時(shí)支持子窗口調(diào)用主窗口中方法的作用。上面只是一個(gè)原理性的粗略實(shí)現(xiàn),如果對(duì)更嚴(yán)謹(jǐn)?shù)膶?shí)現(xiàn)細(xì)節(jié)感興趣可以看源碼中的loadModuleForModuleData 方法。
值得一提的是,在「3.1. 依賴分析」中提到先加載一遍所有模塊來獲取依賴關(guān)系,而這部分的加載也是放在 iframe 中進(jìn)行的,也需要防止“污染”。
3.3. 提供模塊運(yùn)行時(shí)模塊的運(yùn)行時(shí)一版包括了構(gòu)造模塊對(duì)象(module object)、存儲(chǔ)模塊對(duì)象以及提供一個(gè)模塊導(dǎo)入方法(require)。模塊運(yùn)行時(shí)的各類實(shí)現(xiàn)一般都大同小異,這里需要注意的就是,如果隔離的方法使用 iframe,那么需要在主窗口與子窗口中傳遞一些運(yùn)行時(shí)方法和對(duì)象。
當(dāng)然,細(xì)節(jié)上還可能會(huì)需要支持模塊路徑解析(resolve)、循環(huán)依賴的處理、錯(cuò)誤處理等。由于這部分的實(shí)現(xiàn)和很多庫類似,又或者不算特別核心,在這里就不詳細(xì)介紹了。
4. 總結(jié)最后歸納一下大致的運(yùn)行流程:
1.首先從頁面中拿到入口模塊,在 one-click.js 中就是document.querySelector('script[data-main]').dataset.main;
2.在 iframe 中“順藤摸瓜”加載模塊,并在require方法中收集模塊依賴,直到?jīng)]有新的依賴出現(xiàn);
3.收集完畢,此時(shí)就拿到了完整的依賴圖;
4.根據(jù)依賴圖,“逆向”加載相應(yīng)模塊文件,使用 iframe 隔離作用域,同時(shí)注意將主窗口中的模塊運(yùn)行時(shí)傳給各個(gè)子窗口;
5.最后,當(dāng)加載到入口腳本時(shí),所有依賴準(zhǔn)備就緒,直接執(zhí)行即可。
總的來說,由于沒有了構(gòu)建工具與服務(wù)器的幫助,所以要實(shí)現(xiàn)依賴分析與作用域隔離就成了困難。而 one-click.js 運(yùn)用上面提到的技術(shù)手段解決了這些問題。
那么,one-click.js 可以用在生產(chǎn)環(huán)境么?顯然是不行的。
Do not use this in production. The only purpose of this utility is to make local development simpler.
所以注意了,作者也說了,這個(gè)庫的目的僅僅是方便本地開發(fā)。當(dāng)然,其中一些技術(shù)手段作為學(xué)習(xí)資料,咱們也是可以了解學(xué)習(xí)一下的。感興趣的小伙伴可以訪問one-click.js 倉庫進(jìn)一步了解。
以上就是無編譯/無服務(wù)器實(shí)現(xiàn)瀏覽器的CommonJS模塊化的詳細(xì)內(nèi)容,更多關(guān)于無編譯/無服務(wù)器實(shí)現(xiàn)CommonJS模塊化的資料請(qǐng)關(guān)注好吧啦網(wǎng)其它相關(guān)文章!
相關(guān)文章:
1. Python如何批量生成和調(diào)用變量2. 基于 Python 實(shí)踐感知器分類算法3. 通過CSS數(shù)學(xué)函數(shù)實(shí)現(xiàn)動(dòng)畫特效4. python利用opencv實(shí)現(xiàn)顏色檢測(cè)5. ASP.NET MVC實(shí)現(xiàn)橫向展示購物車6. ASP.Net Core(C#)創(chuàng)建Web站點(diǎn)的實(shí)現(xiàn)7. windows服務(wù)器使用IIS時(shí)thinkphp搜索中文無效問題8. ASP.Net Core對(duì)USB攝像頭進(jìn)行截圖9. Python 中如何使用 virtualenv 管理虛擬環(huán)境10. ajax動(dòng)態(tài)加載json數(shù)據(jù)并詳細(xì)解析
