博客原文:https://zhangzhao.name/posts/how-commonjs-load-url-module/前端
最近兩天 ry 大神的 deno 火了一把。做爲 node 項目的發起人,如今又基於 go 從新寫了一個相似 node 的項目命名爲 deno,引起了你們的強烈關注。node
在 deno 項目 readme 的開始就列舉出了這個項目的優點和須要解決的問題,裏面最讓我矚目的就是模塊原生支持 ts ,同時也能也必須從 url 加載模塊,這也是與現有的 CommonJS 最大的不一樣。webpack
仔細思考一下,deno 的模塊化與 CommonJS 相比,更多的是一些 runtime 的能力。現有的 CommonJS 底層實現過程並非靜態化,考慮了不少的動態配置,因此基於現有到 CommonJS 改造起來仍是比較容易的,支持 url 加載或者 ts 模塊也並不複雜,主要難點在於與系統調用的耦合度上。因此週六在家準備擼個小項目,從上層入手,算是仿照 deno 的這幾個特性使得一個仿原生 node 的 CommonJS 模塊語法也能支持這些特性。git
想要讓 CommonJS 支持 url 訪問或者原生加載 ts 模塊,必須從 CommonJS 的執行過程當中入手,在中間階段將模塊注入進去。而 CommonJS 的執行過程其實總結起來很簡單,大概分爲如下幾點:github
處理路徑依賴應該也是全部模塊化加載規範的第一步,換言之就是根據路徑找到文件的位置。不管是 CommonJS 的 require 仍是 ESModule 的 import,不管是相對路徑仍是絕對路徑,都必須首先在內部對這個路徑進行處理,找到合適的文件地址。web
模塊路徑有多是絕對路徑,有多是相對路徑,有可能省略了後綴(js、node、json),有可能省略了文件名(index),甚至是動態路徑(運行時基於變量的動態拼接)等等。npm
首先就是遵照約定,同時按照必定的策略找到這個文件的真實位置,中間的過程就是補齊上面模塊化省略的東西。通常都是根據 CommonJS 的這張流程圖json
確認了路徑而且確保了文件存在以後,加載文件這一步就簡單粗暴的多。最簡單的方式就是直接讀取硬盤上的文件,將純文本的模塊源代碼讀取至內存。bootstrap
在上一步中獲取到的只是代碼的文本形式源文件,並不具備執行能力。在接下來的步驟中須要將它變爲一個可執行的代碼段。promise
若是有同窗看過 webpack 打包出來的結果,能夠發現有這麼一個現象,全部模塊化的內容都處在一個函數的閉包中,內部全部的模塊加載函數都替換成了 __webpack_require__
這類的 webpack 內部變量。
還有一個問題,在 CommonJS 模塊化規範中咱們或多或少在每一個文件中會寫 module, require 等等這樣的「字眼」,module 和 require 並不能稱爲關鍵字,JS 中關於模塊加載方面的關鍵字只有 ESModule 中 import 和 export 等等相關的內容。在平常的模塊書寫過程當中,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 給定的是一個函數,因此此時咱們須要調用這個函數來最後完成模塊的導出。
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,往後可能大有所爲...
解決了 request async 的問題以後其餘問題都變的很是簡單,ts 使用 babel + ts preset 在內存中完成了編譯,若是想要增長任何文件的支持,只須要在 lib/compile 下加入對應的文件後綴便可,在內存中只要可以完成編譯就可以最終保證代碼結果。
在以前的過程當中咱們只是包了一層注入參數的函數進去,固然也能夠上層包裹一層 async 函數,這樣就能夠在使用 nedo require 的包內部直接使用頂層 await,不須要再使用 async 進行包裹
最後通過幾個小時的不懈努力,最終可以將 hello world 跑起來了,代碼還處於 pre-pre-pre-prototype 的階段。倉庫地址 nedo ,但願你們多幫忙 review,提供更多建設性的意見...