使 node 也支持從 url 加載一個 module

博客原文: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 的執行過程

想要讓 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 的歷史上一直是危險的代名詞...一直以來也有多種方法可使用,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,提供更多建設性的意見...

相關文章
相關標籤/搜索