如何讓 node 也支持從 url 載入一個 module?

如何讓 node 也支持從 url 載入一個 module?

來自專欄 coding&thinking使 node 也支持從 url 載入一個 module?

zhangzhao.name圖標

最近兩天 ry 大神的 deno 火了一把。作為 node 項目的發起人,現在又基於 go 重新寫了一個類似 node 的項目命名為 deno,引發了大家的強烈關注。

在 deno 項目 readme 的開始就列舉出了這個項目的優勢和需要解決的問題,裡面最讓我矚目的就是模塊原生支持 ts ,同時也能也必須從 url 載入模塊,這也是與現有的 CommonJS 最大的不同。

仔細思考一下,deno 的模塊化與 CommonJS 相比,更多的是一些 runtime 的能力。現有的 CommonJS 底層實現過程並不是靜態化,考慮了很多的動態配置,所以基於現有到 CommonJS 改造起來還是比較容易的,支持 url 載入或者 ts 模塊也並不複雜,主要難點在於與系統調用的耦合度上。所以周六在家準備擼個小項目,從上層入手,算是仿照 deno 的這幾個特性使得一個仿原生 node 的 CommonJS 模塊語法也能支持這些特性。

CommonJS 的執行過程

想要讓 CommonJS 支持 url 訪問或者原生載入 ts 模塊,必須從 CommonJS 的執行過程中入手,在中間階段將模塊注入進去。而 CommonJS 的執行過程其實總結起來很簡單,大概分為以下幾點:

  • 處理路徑依賴

處理路徑依賴應該也是所有模塊化載入規範的第一步,換言之就是根據路徑找到文件的位置。無論是 CommonJS 的 require 還是 ESModule 的 import,無論是相對路徑還是絕對路徑,都必須首先在內部對這個路徑進行處理,找到合適的文件地址。

模塊路徑有可能是絕對路徑,有可能是相對路徑,有可能省略了後綴(js、node、json),有可能省略了文件名(index),甚至是動態路徑(運行時基於變數的動態拼接)等等。

首先就是遵守約定,同時按照一定的策略找到這個文件的真實位置,中間的過程就是補齊上面模塊化省略的東西。一般都是根據 CommonJS 的這張流程圖:

  • 載入文件

確認了路徑並且確保了文件存在之後,載入文件這一步就簡單粗暴的多。最簡單的方式就是直接讀取硬碟上的文件,將純文本的模塊源代碼讀取至內存。

  • 拼接函數

在上一步中獲取到的只是代碼的文本形式源文件,並不具有執行能力。在接下來的步驟中需要將它變為一個可執行的代碼段。

如果有同學看過 webpack 打包出來的結果,可以發現有這麼一個現象,所有模塊化的內容都處在一個函數的閉包中,內部所有的模塊載入函數都替換成了 `__webpack_require__` 這類的 webpack 內部變數。

還有一個問題,在 CommonJS 模塊化規範中我們或多或少在每個文件中會寫 module, require 等等這樣的「字眼」。這裡的 module 和 require 其實並不能稱為關鍵字,JS 中關於模塊載入方面的關鍵字只有 ESModule 中 import 和 export 等等相關的內容,而 CommonJS 裡面帶來的 module 和 require 則完全算是 node 內部的變數。在日常的模塊書寫過程中,module 對象和 require 函數完全是 node 在包解析時注入進去的(類似上面的 __webpack_require__

這也就給了我們極大的想像空間,我們也完全可以將上面拿到的 module 進行包裹然後注入我們傳遞的每一個變數。簡單的例子:

// 純文本代碼 無法執行var str = 1;console.log(str);

將函數進行拼接,結果依舊是一個純文本代碼。但是已經可以給這個文件內部注入 require module 等變數,只需後續將它變為可執行文件並執行,就能把模塊取出來。

function(require, module, exports, __dirname, __filename) { // 純文本代碼 var str = 1; console.log(str);}

  • 轉化為可執行代碼

拼接完成之後我們拿到的是還是純字元串的代碼,接下來就需要將這個字元串變成真正的代碼,也就是將字元串變為可執行代碼片段,這種操作在 JS 的歷史上一直是危險的代名詞...一直以來也有多種方法可以使用,evalnew Function(str) 等等。而在 node 環境中可以直接使用原生提供的 vm 模塊,內部的沙盒環境支持我們手動注入一些變數,相對來說安全性還有所保證。

var txt = "function(require, module, exports, __dirname, __filename) { module.exports = 1;}"var vm = require(vm);var script = new vm.Script(txt);var func = script.runInThisContext();

上面這個示例中,func 就已經是經過 vm 從字元串變為可執行代碼段的結果,我們的 txt 給定的是一個函數,所以此時我們需要調用這個函數來最後完成模塊的導出。

var m = { exports: {}};func(null, m, m.exports);

這樣的話,內部導出的內容就會被外面全局對象 m 所截獲,將每一個模塊導出的結果緩存到全局的 m 對象上面來。

而對於 require 函數來講,注入時我們需要考慮的就是走完上面的幾個步驟,require 接受一個字元串變數路徑,然後依次通過路徑找到文件,獲取文件,拼接函數,變為可執行代碼段並執行,之後仍給全局的緩存對象,這就是 「require」需要做的內容。

過程中的切面

  • 最終形態是什麼

對於最終的形態,本質上我們是要提供一個 require 函數,它的目標就是在 runtime 能夠從遠端 url 載入 js 模塊,能夠載入 ts 模塊甚至類似 babel 提供 preset 載入各種各樣的模塊。

但是我們的 require 無法注入到 node bootstrap 階段,所以最終結果一定得是 bootsrap 文件使用 CommonJS 模塊載入,通過我們自定義的 require 載入的所有文件都能實現功能。

  • 生命周期的設計

就如上面的第二部分介紹的那樣,對於 require 函數我們要依次做這些事情,完全可以把每個階段看做一個切面,任何一個階段只關注輸入和輸出而不關註上個階段是如何產出的。

最終設置了兩個核心的過程,包裹模塊內容編譯文件結果

包裹模塊內容就是將字元串的文件結果包裹一下函數,專註於處理字元串結果,將普通文件的文本進行包裹。

編譯文件結果這一步就是將代碼結果編譯成 node 能夠直接識別的 js 而使得下一步沙盒環境進行執行,每次通過文件結果動態在內存進行編譯,從而使得下一步 js 的執行。

  • 同步還是非同步?

這個問題其實困擾了很久。最大的問題就是裡面涉及了部分非同步載入的問題,按照傳統前端的做法,這裡一般都是使用 callback 或者 promise(async/await) 的方式,但這樣就會帶來一個很大的問題。

如果是 callback 的方式,那麼意味著最終我的 require 可能得這樣調用:

var r = require("nedo");var moduleA = r("./moduleA");var moduleB = r("./moduleB");function log(module) { // 所有執行過程作為 callback // 這裡拿到 module 的結果 console.log(module);}moduleA(log); // 傳入 callback,moduleA 載入結束執行回調moduleB(log); // 傳入 callback,moduleB 載入結束執行回調

這樣就顯得很愚蠢,即使改成 AMD 那樣的 callback 調用也感覺是在開歷史的倒車。

如果是 promise(async/await) 這樣的非同步方式,那麼意味著最終我的 require 可能得這樣調用:

var r = require("nedo");var moduleA = r("./moduleA");moduleA.then(module => { // 這裡拿到 module 結果});(async function() { var moduleB = await r("./moduleB"); // 這裡拿到 module 的結果})();

說實話這種方式也顯得很愚蠢。不過中間我想了個方法,包裹函數時多包一層,包一個 IIFE 然後自執行一個 async 的 wrapper,不過這樣的話 bootstrap 文件就必須還得手動包裹在 async 的函數中,子函數的問題解決了但是上層沒有解決,不夠完美。

其實後來仔細的思考了一下,造成這樣的問題的原因究其根本是因為 request 是 async 的,這就導致了後續的代碼必須以 async 的方式出現。如果我們想要從硬碟讀取一個文件,那麼我們可以使用 promise 包裹的 fs.readFile,當然我們也可以使用 fs.readFileSync 。前者的方法會讓後續的所有調用都變成非同步,而後者的代碼還是同步,雖然性能很差但是完全符合直覺。

所以就必須找到一個 sync 的 request 的形式,才能讓最終調用變的完美,最終的想法結果應該如下:

var r = require("nedo");var moduleA = r("./moduleA");// moduleA 結果var moduleB = r("https://baidu.com");// moduleB 結果,同步阻塞

思考了半天不知道 sync 的 request 應該怎麼寫,後來只得求助萬能的 npmjs,結果真的發現了一個 sync-request 的包,仔細研究了一下代碼發現核心是藉助了 sync-rpc 這個包,雖然這個包 github 只有 5 個 star,下載量也不大。但是感覺卻是非常的厲害,能夠將任何非同步的代碼轉化為同步調用的形式,戰略性 star,日後可能大有所為...

  • runtime 編譯

解決了 request async 的問題之後其他問題都變的非常簡單,ts 使用 babel + ts preset 在內存中完成了編譯,如果想要增加任何文件的支持,只需要在 lib/compile 下加入對應的文件後綴即可,在內存中只要能夠完成編譯就能夠最終保證代碼結果。

  • top level await

在之前的過程中我們只是包了一層注入參數的函數進去,當然也可以上層包裹一層 async 函數,這樣就可以在使用 nedo require 的包內部直接使用頂層 await,不需要再使用 async 進行包裹。

最終結果

最後經過幾個小時的不懈努力,最終能夠將 hello world 跑起來了,代碼還處於 pre-pre-pre-prototype 的階段。倉庫地址 nedo ,只是憑自己的理解肯定有很多的錯誤和不足,希望大家多幫忙 review,提供更多建設性的意見...


推薦閱讀:

阮一峰,加油!
個人前端小知識總結
域名映射本地
Node.js 與後端(技術周刊 2018-05-11)
知乎登錄/註冊頁之動態背景

TAG:計算機科學 | 前端開發 | Nodejs |