無編譯/無服務器實現瀏覽器的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, add(1, 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, add(1, 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(){}, {
        get: function(target, prop, 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 會在require時對源碼文件進行包裝,而 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)、循環依賴的處理、錯誤處理等。由於這部分的實現和很多庫類似,又或者不算特別核心,在這裡就不詳細介紹瞭。

4. 總結

最後歸納一下大致的運行流程:

1.首先從頁面中拿到入口模塊,在 one-click.js 中就是document.querySelector(“script[data-main]”).dataset.main;

2.在 iframe 中“順藤摸瓜”加載模塊,並在require方法中收集模塊依賴,直到沒有新的依賴出現;

3.收集完畢,此時就拿到瞭完整的依賴圖;

4.根據依賴圖,“逆向”加載相應模塊文件,使用 iframe 隔離作用域,同時註意將主窗口中的模塊運行時傳給各個子窗口;

5.最後,當加載到入口腳本時,所有依賴準備就緒,直接執行即可。

總的來說,由於沒有瞭構建工具與服務器的幫助,所以要實現依賴分析與作用域隔離就成瞭困難。而 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模塊化的資料請關註WalkonNet其它相關文章!

推薦閱讀: