如何讓node 也支持從url 加載一個module?
最近兩天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 的歷史上一直是危險的代名詞...一直以來也有多種方法可以使用, eval
、 new 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給定的是一個函數,所以此時我們需要調用這個函數來最後完成模塊的導出。
varm={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 可能得這樣調用:
varr=require("nedo");varmoduleA=r("./moduleA");moduleA.then(module=>{// 这里拿到 module 结果
});(asyncfunction(){varmoduleB=awaitr("./moduleB");// 这里拿到 module 的结果
})();
說實話這種方式也顯得很愚蠢。不過中間我想了個方法,包裹函數時多包一層,包一個IIFE 然後自執行一個async 的wrapper,不過這樣的話bootstrap 文件就必須還得手動包裹在async 的函數中,子函數的問題解決了但是上層沒有解決,不夠完美。
其實後來仔細的思考了一下,造成這樣的問題的原因究其根本是因為request 是async 的,這就導致了後續的代碼必須以async 的方式出現。如果我們想要從硬盤讀取一個文件,那麼我們可以使用promise 包裹的fs.readFile,當然我們也可以使用fs.readFileSync 。前者的方法會讓後續的所有調用都變成異步,而後者的代碼還是同步,雖然性能很差但是完全符合直覺。
所以就必須找到一個sync 的request 的形式,才能讓最終調用變的完美,最終的想法結果應該如下:
varr=require("nedo");varmoduleA=r("./moduleA");// moduleA 结果
varmoduleB=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,提供更多建設性的意見...