前言
最近兩天 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, module.exports require 等等這樣的「字眼」,因為這里的 module 和 require 講道理并不能稱為關鍵字,JS 中關于模塊加載方面的關鍵字只有 ESModule 中 import 和 export 等等相關的內容,他們是真真正正的關鍵字。而這里 CommonJS 里面帶來的 module 和 require 則完全算是自己實現的一種 hack,在日常的 CommonJS 模塊書寫過程中,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,提供更多建設性的意見…
總結
以上就是這篇文章的全部內容了,希望本文的內容對大家的學習或者工作具有一定的參考學習價值,如果有疑問大家可以留言交流,謝謝大家對武林網的支持。
新聞熱點
疑難解答