<dfn id="hx5t3"><strike id="hx5t3"><em id="hx5t3"></em></strike></dfn>

    <thead id="hx5t3"></thead><nobr id="hx5t3"><font id="hx5t3"><rp id="hx5t3"></rp></font></nobr>

    <listing id="hx5t3"></listing>

    <var id="hx5t3"></var>
    <big id="hx5t3"></big>

      
      

      <output id="hx5t3"><ruby id="hx5t3"></ruby></output>
      <menuitem id="hx5t3"><dfn id="hx5t3"></dfn></menuitem>

      <big id="hx5t3"></big>

        深入Node.js的模塊加載機制,手寫require函數

        蔣鵬飛

        模塊是Node.js里面一個很基本也很重要的概念,各種原生類庫是通過模塊提供的,第三方庫也是通過模塊進行管理和引用的。本文會從基本的模塊原理出發,到最后我們會利用這個原理,自己實現一個簡單的模塊加載機制,即自己實現一個require。

        本文完整代碼已上傳GitHub:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/Node.js/Module/MyModule/index.js

        簡單例子

        老規矩,講原理前我們先來一個簡單的例子,從這個例子入手一步一步深入原理。Node.js里面如果要導出某個內容,需要使用module.exports,使用module.exports幾乎可以導出任意類型的JS對象,包括字符串,函數,對象,數組等等。我們先來建一個a.js導出一個最簡單的hello world:

        // a.js 
        module.exports = "hello world";

        然后再來一個b.js導出一個函數:

        // b.js
        function add(a, b) {
          return a + b;
        }
        
        module.exports = add;

        然后在index.js里面使用他們,即require他們,require函數返回的結果就是對應文件module.exports的值:

        // index.js
        const a = require('./a.js');
        const add = require('./b.js');
        
        console.log(a);      // "hello world"
        console.log(add(1, 2));    // b導出的是一個加法函數,可以直接使用,這行結果是3

        require會先運行目標文件

        當我們require某個模塊時,并不是只拿他的module.exports,而是會從頭開始運行這個文件,module.exports = XXX其實也只是其中一行代碼,我們后面會講到,這行代碼的效果其實就是修改模塊里面的exports屬性。比如我們再來一個c.js

        // c.js
        let c = 1;
        
        c = c + 1;
        
        module.exports = c;
        
        c = 6;

        c.js里面我們導出了一個c,這個c經過了幾步計算,當運行到module.exports = c;這行時c的值為2,所以我們requirec.js的值就是2,后面將c的值改為了6并不影響前面的這行代碼:

        const c = require('./c.js');
        
        console.log(c);  // c的值是2

        前面c.js的變量c是一個基本數據類型,所以后面的c = 6;不影響前面的module.exports,那他如果是一個引用類型呢?我們直接來試試吧:

        // d.js
        let d = {
          num: 1
        };
        
        d.num++;
        
        module.exports = d;
        
        d.num = 6;

        然后在index.js里面require他:

        const d = require('./d.js');
        
        console.log(d);     // { num: 6 }

        我們發現在module.exports后面給d.num賦值仍然生效了,因為d是一個對象,是一個引用類型,我們可以通過這個引用來修改他的值。其實對于引用類型來說,不僅僅在module.exports后面可以修改他的值,在模塊外面也可以修改,比如index.js里面就可以直接改:

        const d = require('./d.js');
        
        d.num = 7;
        console.log(d);     // { num: 7 }

        requiremodule.exports不是黑魔法

        我們通過前面的例子可以看出來,requiremodule.exports干的事情并不復雜,我們先假設有一個全局對象{},初始情況下是空的,當你require某個文件時,就將這個文件拿出來執行,如果這個文件里面存在module.exports,當運行到這行代碼時將module.exports的值加入這個對象,鍵為對應的文件名,最終這個對象就長這樣:

        {
          "a.js": "hello world",
          "b.js": function add(){},
          "c.js": 2,
          "d.js": { num: 2 }
        }

        當你再次require某個文件時,如果這個對象里面有對應的值,就直接返回給你,如果沒有就重復前面的步驟,執行目標文件,然后將它的module.exports加入這個全局對象,并返回給調用者。這個全局對象其實就是我們經常聽說的緩存。所以requiremodule.exports并沒有什么黑魔法,就只是運行并獲取目標文件的值,然后加入緩存,用的時候拿出來用就行。再看看這個對象,因為d.js是一個引用類型,所以你在任何地方獲取了這個引用都可以更改他的值,如果不希望自己模塊的值被更改,需要自己寫模塊時進行處理,比如使用Object.freeze(),Object.defineProperty()之類的方法。

        模塊類型和加載順序

        這一節的內容都是一些概念,比較枯燥,但是也是我們需要了解的。

        模塊類型

        Node.js的模塊有好幾種類型,前面我們使用的其實都是文件模塊,總結下來,主要有這兩種類型:

        1. 內置模塊:就是Node.js原生提供的功能,比如fs,http等等,這些模塊在Node.js進程起來時就加載了。
        2. 文件模塊:我們前面寫的幾個模塊,還有第三方模塊,即node_modules下面的模塊都是文件模塊。

        加載順序

        加載順序是指當我們require(X)時,應該按照什么順序去哪里找X,在官方文檔上有詳細偽代碼,總結下來大概是這么個順序:

        1. 優先加載內置模塊,即使有同名文件,也會優先使用內置模塊。
        2. 不是內置模塊,先去緩存找。
        3. 緩存沒有就去找對應路徑的文件。
        4. 不存在對應的文件,就將這個路徑作為文件夾加載。
        5. 對應的文件和文件夾都找不到就去node_modules下面找。
        6. 還找不到就報錯了。

        加載文件夾

        前面提到找不到文件就找文件夾,但是不可能將整個文件夾都加載進來,加載文件夾的時候也是有一個加載順序的:

        1. 先看看這個文件夾下面有沒有package.json,如果有就找里面的main字段,main字段有值就加載對應的文件。所以如果大家在看一些第三方庫源碼時找不到入口就看看他package.json里面的main字段吧,比如jquerymain字段就是這樣:"main": "dist/jquery.js"。
        2. 如果沒有package.json或者package.json里面沒有main就找index文件。
        3. 如果這兩步都找不到就報錯了。

        支持的文件類型

        require主要支持三種文件類型:

        1. .js.js文件是我們最常用的文件類型,加載的時候會先運行整個JS文件,然后將前面說的module.exports作為require的返回值。
        2. .json.json文件是一個普通的文本文件,直接用JSON.parse將其轉化為對象返回就行。
        3. .node.node文件是C++編譯后的二進制文件,純前端一般很少接觸這個類型。

        手寫require

        前面其實我們已經將原理講的七七八八了,下面來到我們的重頭戲,自己實現一個require。實現require其實就是實現整個Node.js的模塊加載機制,我們再來理一下需要解決的問題:

        1. 通過傳入的路徑名找到對應的文件。
        2. 執行找到的文件,同時要注入modulerequire這些方法和屬性,以便模塊文件使用。
        3. 返回模塊的module.exports

        本文的手寫代碼全部參照Node.js官方源碼,函數名和變量名盡量保持一致,其實就是精簡版的源碼,大家可以對照著看,寫到具體方法時我也會貼上對應的源碼地址??傮w的代碼都在這個文件里面:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js

        Module類

        Node.js模塊加載的功能全部在Module類里面,整個代碼使用面向對象的思想,如果你對JS的面向對象還不是很熟悉可以先看看這篇文章。Module類的構造函數也不復雜,主要是一些值的初始化,為了跟官方Module名字區分開,我們自己的類命名為MyModule

        function MyModule(id = '') {
          this.id = id;       // 這個id其實就是我們require的路徑
          this.path = path.dirname(id);     // path是Node.js內置模塊,用它來獲取傳入參數對應的文件夾路徑
          this.exports = {};        // 導出的東西放這里,初始化為空對象
          this.filename = null;     // 模塊對應的文件名
          this.loaded = false;      // loaded用來標識當前模塊是否已經加載
        }

        require方法

        我們一直用的require其實是Module類的一個實例方法,內容很簡單,先做一些參數檢查,然后調用Module._load方法,源碼看這里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L970。精簡版的代碼如下:

        MyModule.prototype.require = function (id) {
          return Module._load(id);
        }

        MyModule._load

        MyModule._load是一個靜態方法,這才是require方法的真正主體,他干的事情其實是:

        1. 先檢查請求的模塊在緩存中是否已經存在了,如果存在了直接返回緩存模塊的exports。
        2. 如果不在緩存中,就new一個Module實例,用這個實例加載對應的模塊,并返回模塊的exports。

        我們自己來實現下這兩個需求,緩存直接放在Module._cache這個靜態變量上,這個變量官方初始化使用的是Object.create(null),這樣可以使創建出來的原型指向null,我們也這樣做吧:

        MyModule._cache = Object.create(null);
        
        MyModule._load = function (request) {    // request是我們傳入的路勁參數
          const filename = MyModule._resolveFilename(request);
        
          // 先檢查緩存,如果緩存存在且已經加載,直接返回緩存
          const cachedModule = MyModule._cache[filename];
          if (cachedModule !== undefined) {
            return cachedModule.exports;
          }
        
          // 如果緩存不存在,我們就加載這個模塊
          // 加載前先new一個MyModule實例,然后調用實例方法load來加載
          // 加載完成直接返回module.exports
          const module = new MyModule(filename);
          
          // load之前就將這個模塊緩存下來,這樣如果有循環引用就會拿到這個緩存,但是這個緩存里面的exports可能還沒有或者不完整
          MyModule._cache[filename] = module;
          
          module.load(filename);
          
          return module.exports;
        }

        上述代碼對應的源碼看這里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L735

        可以看到上述源碼還調用了兩個方法:MyModule._resolveFilenameMyModule.prototype.load,下面我們來實現下這兩個方法。

        MyModule._resolveFilename

        MyModule._resolveFilename從名字就可以看出來,這個方法是通過用戶傳入的require參數來解析到真正的文件地址的,源碼中這個方法比較復雜,因為按照前面講的,他要支持多種參數:內置模塊,相對路徑,絕對路徑,文件夾和第三方模塊等等,如果是文件夾或者第三方模塊還要解析里面的package.jsonindex.js。我們這里主要講原理,所以我們就只實現通過相對路徑和絕對路徑來查找文件,并支持自動添加jsjson兩種后綴名:

        MyModule._resolveFilename = function (request) {
          const filename = path.resolve(request);   // 獲取傳入參數對應的絕對路徑
          const extname = path.extname(request);    // 獲取文件后綴名
        
          // 如果沒有文件后綴名,嘗試添加.js和.json
          if (!extname) {
            const exts = Object.keys(MyModule._extensions);
            for (let i = 0; i < exts.length; i++) {
              const currentPath = `${filename}${exts[i]}`;
        
              // 如果拼接后的文件存在,返回拼接的路徑
              if (fs.existsSync(currentPath)) {
                return currentPath;
              }
            }
          }
        
          return filename;
        }

        上述源碼中我們還用到了一個靜態變量MyModule._extensions,這個變量是用來存各種文件對應的處理方法的,我們后面會實現他。

        MyModule._resolveFilename對應的源碼看這里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L822

        MyModule.prototype.load

        MyModule.prototype.load是一個實例方法,這個方法就是真正用來加載模塊的方法,這其實也是不同類型文件加載的一個入口,不同類型的文件會對應MyModule._extensions里面的一個方法:

        MyModule.prototype.load = function (filename) {
          // 獲取文件后綴名
          const extname = path.extname(filename);
        
          // 調用后綴名對應的處理函數來處理
          MyModule._extensions[extname](this, filename);
        
          this.loaded = true;
        }

        注意這段代碼里面的this指向的是module實例,因為他是一個實例方法。對應的源碼看這里: https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L942

        加載js文件: MyModule._extensions['.js']

        前面我們說過不同文件類型的處理方法都掛載在MyModule._extensions上面的,我們先來實現.js類型文件的加載:

        MyModule._extensions['.js'] = function (module, filename) {
          const content = fs.readFileSync(filename, 'utf8');
          module._compile(content, filename);
        }

        可以看到js的加載方法很簡單,只是把文件內容讀出來,然后調了另外一個實例方法_compile來執行他。對應的源碼看這里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L1098

        編譯執行js文件:MyModule.prototype._compile

        MyModule.prototype._compile是加載JS文件的核心所在,也是我們最常使用的方法,這個方法需要將目標文件拿出來執行一遍,執行之前需要將它整個代碼包裹一層,以便注入exports, require, module, __dirname, __filename,這也是我們能在JS文件里面直接使用這幾個變量的原因。要實現這種注入也不難,假如我們require的文件是一個簡單的Hello World,長這樣:

        module.exports = "hello world";

        那我們怎么來給他注入module這個變量呢?答案是執行的時候在他外面再加一層函數,使他變成這樣:

        function (module) { // 注入module變量,其實幾個變量同理
          module.exports = "hello world";
        }

        所以我們如果將文件內容作為一個字符串的話,為了讓他能夠變成上面這樣,我們需要再給他拼接上開頭和結尾,我們直接將開頭和結尾放在一個數組里面:

        MyModule.wrapper = [
          '(function (exports, require, module, __filename, __dirname) { ',
          '\n});'
        ];

        注意我們拼接的開頭和結尾多了一個()包裹,這樣我們后面可以拿到這個匿名函數,在后面再加一個()就可以傳參數執行了。然后將需要執行的函數拼接到這個方法中間:

        MyModule.wrap = function (script) {
          return MyModule.wrapper[0] + script + MyModule.wrapper[1];
        };

        這樣通過MyModule.wrap包裝的代碼就可以獲取到exports, require, module, __filename, __dirname這幾個變量了。知道了這些就可以來寫MyModule.prototype._compile了:

        MyModule.prototype._compile = function (content, filename) {
          const wrapper = Module.wrap(content);    // 獲取包裝后函數體
        
          // vm是nodejs的虛擬機沙盒模塊,runInThisContext方法可以接受一個字符串并將它轉化為一個函數
          // 返回值就是轉化后的函數,所以compiledWrapper是一個函數
          const compiledWrapper = vm.runInThisContext(wrapper, {
            filename,
            lineOffset: 0,
            displayErrors: true,
          });
        
          // 準備exports, require, module, __filename, __dirname這幾個參數
          // exports可以直接用module.exports,即this.exports
          // require官方源碼中還包裝了一層,其實最后調用的還是this.require
          // module不用說,就是this了
          // __filename直接用傳進來的filename參數了
          // __dirname需要通過filename獲取下
          const dirname = path.dirname(filename);
        
          compiledWrapper.call(this.exports, this.exports, this.require, this,
            filename, dirname);
        }

        上述代碼要注意我們注入進去的幾個參數和通過call傳進去的this:

        1. this:compiledWrapper是通過call調用的,第一個參數就是里面的this,這里我們傳入的是this.exports,也就是module.exports,也就是說我們js文件里面this是對module.exports的一個引用。
        2. exports: compiledWrapper正式接收的第一個參數是exports,我們傳的也是this.exports,所以js文件里面的exports也是對module.exports的一個引用。
        3. require: 這個方法我們傳的是this.require,其實就是MyModule.prototype.require,也就是MyModule._load。
        4. module: 我們傳入的是this,也就是當前模塊的實例。
        5. __filename:文件所在的絕對路徑。
        6. __dirname: 文件所在文件夾的絕對路徑。

        到這里,我們的JS文件其實已經記載完了,對應的源碼看這里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L1043

        加載json文件: MyModule._extensions['.json']

        加載json文件就簡單多了,只需要將文件讀出來解析成json就行了:

        MyModule._extensions['.json'] = function (module, filename) {
          const content = fs.readFileSync(filename, 'utf8');
          module.exports = JSONParse(content);
        }

        exportsmodule.exports的區別

        網上經常有人問,node.js里面的exportsmodule.exports到底有什么區別,其實前面我們的手寫代碼已經給出答案了,我們這里再就這個問題詳細講解下。exportsmodule.exports這兩個變量都是通過下面這行代碼注入的。

        compiledWrapper.call(this.exports, this.exports, this.require, this,
            filename, dirname);

        初始狀態下,exports === module.exports === {},exportsmodule.exports的一個引用,如果你一直是這樣使用的:

        exports.a = 1;
        module.exports.b = 2;
        
        console.log(exports === module.exports);   // true

        上述代碼中,exportsmodule.exports都是指向同一個對象{},你往這個對象上添加屬性并沒有改變這個對象本身的引用地址,所以exports === module.exports一直成立。

        但是如果你哪天這樣使用了:

        exports = {
          a: 1
        }

        或者這樣使用了:

        module.exports = {
            b: 2
        }

        那其實你是給exports或者module.exports重新賦值了,改變了他們的引用地址,那這兩個屬性的連接就斷開了,他們就不再相等了。需要注意的是,你對module.exports的重新賦值會作為模塊的導出內容,但是你對exports的重新賦值并不能改變模塊導出內容,只是改變了exports這個變量而已,因為模塊始終是module,導出內容是module.exports。

        循環引用

        Node.js對于循環引用是進行了處理的,下面是官方例子:

        a.js:

        console.log('a 開始');
        exports.done = false;
        const b = require('./b.js');
        console.log('在 a 中,b.done = %j', b.done);
        exports.done = true;
        console.log('a 結束');

        b.js:

        console.log('b 開始');
        exports.done = false;
        const a = require('./a.js');
        console.log('在 b 中,a.done = %j', a.done);
        exports.done = true;
        console.log('b 結束');

        main.js:

        console.log('main 開始');
        const a = require('./a.js');
        const b = require('./b.js');
        console.log('在 main 中,a.done=%j,b.done=%j', a.done, b.done);

        main.js 加載 a.js 時, a.js 又加載 b.js。 此時, b.js 會嘗試去加載 a.js。 為了防止無限的循環,會返回一個 a.jsexports 對象的 未完成的副本b.js 模塊。 然后 b.js 完成加載,并將 exports 對象提供給 a.js 模塊。

        那么這個效果是怎么實現的呢?答案就在我們的MyModule._load源碼里面,注意這兩行代碼的順序:

        MyModule._cache[filename] = module;
        
        module.load(filename);

        上述代碼中我們是先將緩存設置了,然后再執行的真正的load,順著這個思路我能來理一下這里的加載流程:

        1. main加載a,a在真正加載前先去緩存中占一個位置
        2. a在正式加載時加載了b
        3. b又去加載了a,這時候緩存中已經有a了,所以直接返回a.exports,即使這時候的exports是不完整的。

        總結

        1. require不是黑魔法,整個Node.js的模塊加載機制都是JS實現的。
        2. 每個模塊里面的exports, require, module, __filename, __dirname五個參數都不是全局變量,而是模塊加載的時候注入的。
        3. 為了注入這幾個變量,我們需要將用戶的代碼用一個函數包裹起來,拼一個字符串然后調用沙盒模塊vm來實現。
        4. 初始狀態下,模塊里面的this, exports, module.exports都指向同一個對象,如果你對他們重新賦值,這種連接就斷了。
        5. module.exports的重新賦值會作為模塊的導出內容,但是你對exports的重新賦值并不能改變模塊導出內容,只是改變了exports這個變量而已,因為模塊始終是module,導出內容是module.exports。
        6. 為了解決循環引用,模塊在加載前就會被加入緩存,下次再加載會直接返回緩存,如果這時候模塊還沒加載完,你可能拿到未完成的exports。
        7. Node.js實現的這套加載機制叫CommonJS。

        本文完整代碼已上傳GitHub:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/Node.js/Module/MyModule/index.js

        參考資料

        Node.js模塊加載源碼:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js

        Node.js模塊官方文檔:http://nodejs.cn/api/modules.html

        文章的最后,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支持是作者持續創作的動力。

        歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~

        “前端進階知識”系列文章源碼地址: https://github.com/dennis-jiang/Front-End-Knowledges

        1270_300二維碼_2.png

        閱讀 3.6k

        進擊的大前端
        前端工程師,底層技術人。思否2020年度“Top Writer”!掘金“優秀作者”!開源中國2020年度“優秀源創作者”...

        前端工程師,底層技術人。

        4.5k 聲望
        2k 粉絲
        0 條評論

        前端工程師,底層技術人。

        4.5k 聲望
        2k 粉絲
        宣傳欄
        一本到在线是免费观看_亚洲2020天天堂在线观看_国产欧美亚洲精品第一页_最好看的2018中文字幕