【漫遊Github】無編譯/無服務器,實現瀏覽器的 CommonJS 模塊化

引言

平時經常會逛 Github,除了一些 star 極高的大項目外,還會在 Github 上發現很多有意思的小項目。項目或是想法很有趣,或是有不錯的技術點,讀起來都讓人有所收穫。所以準備彙總成一個「漫遊Github」系列,不定期分享與解讀在 Github 上偶遇的有趣項目。本系列重在原理性講解,而不會深扣源碼細節。好了下面進入正題。本期要介紹的倉庫叫 one-click.js[1] 。

1. one-click.js 是什麼

one-click.js[2] 是個很有意思的庫。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.jsmodule.exports = functionplus(a, b) {
    return a + b;
}
複製代碼

demo/divide.js

// divide.jsmodule.exports = functiondivide(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 編譯成一個 bundle,然後瀏覽器引用。而 one-click.js 讓你可以拋棄這些,只需要在 HTML 中這麼用:

<!DOCTYPE html><htmllang="en"><head><metacharset="UTF-8"><title>one click example</title></head><body><scripttype="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.jsmodule.exports = functionplus(a, b) {
    return a + b;
}
複製代碼

divide.js:

// divide.jsmodule.exports = functiondivide(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複製代碼

回憶一下,當我們使用 webpack 時,會指定入口(main.js)。webpack 會根據該入口打包出一個 bundle(例如 bundle.js)。最後我們在頁面中引入處理好的 bundle.js 即可。這時的 bundle.js 除了源碼,已經加了很多 webpack 的“私貨”。

簡單理一理其中 webpack 涉及到的工作:

1.依賴分析:首先,在打包時 webpack 會根據語法分析結果來獲取模塊的依賴關係。簡單來說,在 CommonJS 中就是根據解析出的 require 語法來得到當前模塊所依賴的子模塊。2.作用域隔離與變量注入:對於每個模塊文件,webpack 都會將其包裹在一個 function 中。這樣既可以做到 modulerequire 等變量的注入,又可以隔離作用域,防止變量的全局污染。3.提供模塊運行時:最後,爲了 requireexports 的有效執行,還需要提供一套運行時代碼,來實現模塊的加載、執行、導出等功能。

如果對以上的 2、3 項不太瞭解,可以從篇文章中瞭解 webpack 的模塊運行時設計[3]

3. 我們面對的挑戰

沒有了構建工具,直接在瀏覽器中運行使用了 CommonJS 的模塊,其實就是要想辦法完成上面提到的三項工作:

依賴分析作用域隔離與變量注入提供模塊運行時

解決這三個問題就是 one-click.js 的核心任務。下面我們來分別看看是如何解決的。

3.1. 依賴分析

這是個麻煩的問題。如果想要正確加載模塊,必須準確知道模塊間的依賴。例如上面提到的三個模塊文件 —— main.js 依賴 plus.js 和 divide.js,所以在運行 main.js 中代碼時,需要保證 plus.js 和 divide.js 都已經加載進瀏覽器環境。然而問題就在於,沒有編譯工具後,我們自然無法自動化的知道模塊間的依賴關係。

對於 RequireJS[4] 這樣的模塊庫來說,它是在代碼中聲明當前模塊的依賴,然後使用異步加載加回調的方式。顯然,CommonJS 規範是沒有這樣的異步 API 的。

而 one-click.js 用了一個取巧但是有額外成本的方式來分析依賴 —— 加載兩遍模塊文件。在第一次加載模塊文件時,爲模塊文件提供一個 mock 的 require 方法,每當模塊調用該方法時,就可以在 require 中知道當前模塊依賴哪些子模塊了。

// main.jsconst plus = require('./plus.js');
const divide = require('./divide.js');
console.log(minus(12, add(1, 2)));
複製代碼

例如上面的 main.js,我們可以提供一個類似下面的 require 方法:

const recordedFieldAccessesByRequireCall = {};
constrequire = functioncollect(modPath) {
    recordedFieldAccessesByRequireCall[modPath] = true;
    var script = document.createElement('script');
    script.src = modPath;
    document.body.appendChild(script);
};
複製代碼

main.js 加載後,會做兩件事:

1.記錄當前模塊中依賴的子模塊;2.加載子模塊。

這樣,我們就可以在 recordedFieldAccessesByRequireCall 中記錄當前模塊的依賴情況;同時加載子模塊。而對於子模塊也可以有遞歸操作,直到不再有新的依賴出現。最後將各個模塊的 recordedFieldAccessesByRequireCall 整合起來就是我們的依賴關係。

此外,如果我們還想要知道 main.js 實際調用了子模塊中的哪些方法,可以通過 Proxy 來返回一個代理對象,統計進一步的依賴情況:

constrequire = functioncollect(modPath) {
    recordedFieldAccessesByRequireCall[modPath] = [];
    var megaProxy = newProxy(function(){}, {
        get: function(target, prop, receiver) {
            if(prop == Symbol.toPrimitive) {
                returnfunction() {0;};
            }
            return megaProxy;
        }
    });
    var recordFieldAccess = newProxy(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,具體源碼可以看這裏[5]

到這裏你可能會發現:“這是一種浪費啊,每個文件都加載了兩遍。”

確實如此,這也是 one-click.js 的 tradeoff[6]

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.jsvar foo = 123;
複製代碼

當其加載進瀏覽器時,foo 變量實際會變成一個全局變量,可以通過 window.foo 訪問到,這也會帶來全局污染,模塊間的變量、方法都可能互相沖突與覆蓋。

在 NodeJS 環境下,由於使用 CommonJS 規範,同樣像上面這樣的模塊文件被導入時, foo 變量的作用域只在源模塊中,不會污染全局。而 NodeJS 在實現上其實就是用一個 wrap function 包裹了模塊內的代碼[7],我們都知道,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 = {};
functionrequire(modPath) {
    if (modMap[modPath]) {
        return modMap[modPath].exports;
    }
}


fetch('./divide.js')
    .then(res => res.text())
    .then(source => {
        const mod = newFunction('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
    });
複製代碼

代碼很簡單,核心就是通過 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 方法[8]

值得一提的是,在「3.1. 依賴分析」中提到先加載一遍所有模塊來獲取依賴關係,而這部分的加載也是放在 iframe 中進行的,也需要防止“污染”。

3.3. 提供模塊運行時

模塊的運行時一版包括了構造模塊對象(module object)、存儲模塊對象以及提供一個模塊導入方法(require)。模塊運行時的各類實現一般都大同小異,這裏需要注意的就是,如果隔離的方法使用 iframe,那麼需要在主窗口與子窗口中傳遞一些運行時方法和對象。

當然,細節上還可能會需要支持模塊路徑解析(resolve)、循環依賴的處理、錯誤處理等。由於這部分的實現和很多庫類似,又或者不算特別核心,在這裏就不詳細介紹了。

4. 總結

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

1.首先從頁面中拿到入口模塊,在 one-click.js 中就是 document.querySelector("script[data-main]").dataset.main2.在 iframe 中“順藤摸瓜”加載模塊,並在 require 方法中收集模塊依賴,直到沒有新的依賴出現;3.收集完畢,此時就拿到了完整的依賴圖;4.根據依賴圖,“逆向”加載相應模塊文件,使用 iframe 隔離作用域,同時注意將主窗口中的模塊運行時傳給各個子窗口;5.最後,當加載到入口腳本時,所有依賴準備就緒,直接執行即可。

總的來說,由於沒有了構建工具與服務器的幫助,所以要實現依賴分析與作用域隔離就成了困難。而 one-click.js 運用上面提到的技術手段解決了這些問題。

那麼,one-click.js 可以用在生產環境麼?顯然是不行的[9]

Do not use this in production. The only purpose of this utility is to make local development simpler.

所以注意了,作者也說了,這個庫的目的僅僅是方便本地開發。當然,其中一些技術手段作爲學習資料,咱們也是可以瞭解學習一下的。感興趣的小夥伴可以訪問 one-click.js 倉庫[10]進一步瞭解。


好了,這期的「漫遊 Github」就到這裏了。本系列會不定期和大家一起看一看、聊一聊、學一學 github 上有趣的項目,不僅學習一些技術點,還可以瞭解作者的技術思考,歡迎感興趣的小夥伴關注。

往期內容

【漫遊Github】quicklink 的實現原理與給前端的啓發[11]【漫遊Github】如何提升JSON.stringify()的性能?[12]

References

[1] one-click.js: https://github.com/jordwalke/one-click.js
[2] one-click.js: https://github.com/jordwalke/one-click.js
[3] webpack 的模塊運行時設計: https://juejin.im/post/5b82ac82f265da431d0e6d25#heading-3
[4] RequireJS: https://requirejs.org/
[5] 源碼可以看這裏: https://github.com/jordwalke/one-click.js/blob/8db5f181fe7dafa050d5789741fbe4b2c87ba779/one-click.js#L378-L505
[6] tradeoff: https://github.com/jordwalke/one-click.js#tradeoffs
[7] 用一個 wrap function 包裹了模塊內的代碼: https://juejin.im/post/5b82ac82f265da431d0e6d25#heading-2
[8] loadModuleForModuleData 方法: https://github.com/jordwalke/one-click.js/blob/8db5f181fe7dafa050d5789741fbe4b2c87ba779/one-click.js#L203-L281
[9] 不行的: https://github.com/jordwalke/one-click.js#not-using
[10] one-click.js 倉庫: https://github.com/jordwalke/one-click.js
[11] 【漫遊Github】quicklink 的實現原理與給前端的啓發: https://juejin.im/post/5c21f8435188256d12597789
[12] 【漫遊Github】如何提升JSON.stringify()的性能?: https://juejin.im/post/5cf61ed3e51d4555fd20a2f3

長按二維碼,可以關注我喲

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章