1. 程式人生 > 實用技巧 >CommonJS 和 ES6 Module 究竟有什麼區別?

CommonJS 和 ES6 Module 究竟有什麼區別?

CommonJS 和 ES6 Module 究竟有什麼區別?

作為前端開發者,你是否也曾有過疑惑,為什麼可以程式碼中可以直接使用 require 方法載入模組,為什麼載入第三方包的時候 Node 會知道選擇哪個檔案作為入口,以及常被問到的,為什麼 ES6 Module export 基礎資料型別的時候會有【引用型別】的效果?

帶著這些疑問和好奇,希望閱讀這篇文章能解答你的疑惑。

CommonJS 規範

在 ES6 之前,ECMAScript 並沒有提供程式碼組織的方式,那時候通常是基於 IIFE 來實現“模組化”,隨著 JavaScript 在前端大規模的應用,以及服務端 Javascript 的推動,原先瀏覽器端的模組規範不利於大規模應用。於是早期便有了 CommonJS 規範,其目標是為了定義模組,提供通用的模組組織方式。

模組定義和使用

在 Commonjs 中,一個檔案就是一個模組。定義一個模組匯出通過 exports 或者 module.exports 掛載即可。

 exports.count = 1;

匯入一個模組也很簡單,通過 require 對應模組拿到 exports 物件。

 const counter = require('./counter');
 console.log(counter.count);

CommonJS 的模組主要由原生模組 module 來實現,這個類上的一些屬性對我們理解模組機制有很大幫助。

 Module {
   id: '.', // 如果是 mainModule id 固定為 '.',如果不是則為模組絕對路徑
exports: {}, // 模組最終 exports filename: '/absolute/path/to/entry.js', // 當前模組的絕對路徑 loaded: false, // 模組是否已載入完畢 children: [], // 被該模組引用的模組 parent: '', // 第一個引用該模組的模組 paths: [ // 模組的搜尋路徑 '/absolute/path/to/node_modules', '/absolute/path/node_modules', '/absolute/node_modules', '/node_modules' ] }

require 從哪裡來?

在編寫 CommonJS 模組的時候,我們會使用 require 來載入模組,使用 exports 來做模組輸出,還有 module,filename, dirname 這些變數,為什麼它們不需要引入就能使用?

原因是 Node 在解析 JS 模組時,會先按文字讀取內容,然後將模組內容進行包裹,在外層裹了一個 function,傳入變數。再通過 vm.runInThisContext 將字串轉成 Function形成作用域,避免全域性汙染。

 let wrap = function(script) {
   return Module.wrapper[0] + script + Module.wrapper[1];
 };
 ​
 const wrapper = [
   '(function (exports, require, module, __filename, __dirname) { ',
   '\n});'
 ];

於是在 CommmonJS 的模組中可以不需要 require,直接訪問到這些方法,變數。

引數中的 module 是當前模組的的 module 例項(儘管這個時候模組程式碼還沒編譯執行),exports 是 module.exports 的別名,最終被 require 的時候是輸出 module.exports 的值。require 最終呼叫的也是 Module._load 方法。filename,dirname 則分別是當前模組在系統中的絕對路徑和當前資料夾路徑。

模組的查詢過程

開發者在使用 require 時非常簡單,但實際上為了兼顧各種寫法,不同型別的模組,node_modules packages 等模組的查詢過程稍微有點麻煩。

首先,在建立模組物件時,會有 paths 屬性,其值是由當前檔案路徑計算得到的,從當前目錄一直到系統根目錄的 node_modules。可以在模組中列印 module.paths 看看。

 [ 
   '/Users/evan/Desktop/demo/node_modules',
   '/Users/evan/Desktop/node_modules',
   '/Users/evan/node_modules',
   '/Users/node_modules',
   '/node_modules'
 ]

除此之外,還會查詢全域性路徑(如果存在的話)

 [
   execPath/../../lib/node_modules, // 當前 node 執行檔案相對路徑下的 lib/node_modules
   NODE_PATH, // 全域性變數 NODE_PATH
   HOME/.node_modules, // HOME 目錄下的 .node_module
   HOME/.node_libraries' // HOME 目錄下的 .node-libraries
 ]

按照官方文件給出的查詢過程已經足夠詳細,這裡只給出大概流程。

Y 路徑執行 require(X)
1. 如果 X 是內建模組(比如 require('http'))
   a. 返回該模組。
   b. 不再繼續執行。
 ​
 2. 如果 X 是以 '/' 開頭、
    a. 設定 Y 為 '/'3. 如果 X 是以 './' 或 '/' 或 '../' 開頭
    a. 依次嘗試載入檔案,如果找到則不再執行
       - (Y + X)
       - (Y + X).js
       - (Y + X).json
       - (Y + X).node
    b. 依次嘗試載入目錄,如果找到則不再執行
       - (Y + X + package.json 中的 main 欄位).js
       - (Y + X + package.json 中的 main 欄位).json
       - (Y + X + package.json 中的 main 欄位).node
   c. 丟擲 "not found"
 4. 遍歷 module paths 查詢,如果找到則不再執行
 5. 丟擲 "not found"

模組查詢過程會將軟鏈替換為系統中的真實路徑,例如 lib/foo/node_moduels/bar 軟鏈到 lib/bar,bar 包中又 require('quux'),最終執行 foo module 時,require('quux') 的查詢路徑是 lib/bar/node_moduels/quux 而不是 lib/foo/node_moduels/quux。

模組載入相關

MainModule

當執行 node index.js 時,Node 呼叫 Module 類上的靜態方法 _load(process.argv[1])載入這個模組,並標記為主模組,賦值給 process.mainModule 和 require.main,可以通過這兩個欄位判斷當前模組是主模組還是被 require 進來的。

CommonJS 規範是在程式碼執行時同步阻塞性地載入模組,在執行程式碼過程中遇到 require(X)時會停下來等待,直到新的模組載入完成之後再繼續執行接下去的程式碼。

雖說是同步阻塞性,但這一步實際上非常快,和瀏覽器上阻塞性下載、解析、執行 js 檔案不是一個級別,硬碟上讀檔案比網路請求快得多。

快取和迴圈引用

檔案模組查詢挺耗時的,如果每次 require 都需要重新遍歷資料夾查詢,效能會比較差;還有在實際開發中,模組可能包含副作用程式碼,例如在模組頂層執行 addEventListener ,如果 require 過程中被重複執行多次可能會出現問題。

CommonJS 中的快取可以解決重複查詢和重複執行的問題。模組載入過程中會以模組絕對路徑為 key, module 物件為 value 寫入 cache。在讀取模組的時候會優先判斷是否已在快取中,如果在,直接返回 module.exports;如果不在,則會進入模組查詢的流程,找到模組之後再寫入 cache。

 // a.js
 module.exports = {
     foo: 1,
 };
 ​
 // main.js
 const a1 = require('./a.js');
 a1.foo = 2;
 ​
 const a2 = require('./a.js');
 ​
 console.log(a2.foo); // 2
 console.log(a1 === a2); // true

以上例子中,require a.js 並修改其中的 foo 屬性,接著再次 require a.js 可以看到兩次 require 結果是一樣的。

模組快取可以列印 require.cache 進行檢視。

 { 
     '/Users/evan/Desktop/demo/main.js': 
        Module {
          id: '.',
          exports: {},
          parent: null,
          filename: '/Users/evan/Desktop/demo/main.js',
          loaded: false,
          children: [ [Object] ],
          paths: 
           [ '/Users/evan/Desktop/demo/node_modules',
             '/Users/evan/Desktop/node_modules',
             '/Users/evan/node_modules',
             '/Users/node_modules',
             '/node_modules'
           ]
        },
   '/Users/evan/Desktop/demo/a.js': 
        Module {
          id: '/Users/evan/Desktop/demo/a.js',
          exports: { foo: 1 },
          parent: 
           Module {
             id: '.',
             exports: {},
             parent: null,
             filename: '/Users/evan/Desktop/demo/main.js',
             loaded: false,
             children: [Array],
             paths: [Array] },
          filename: '/Users/evan/Desktop/demo/a.js',
          loaded: true,
          children: [],
          paths: 
           [ '/Users/evan/Desktop/demo/node_modules',
             '/Users/evan/Desktop/node_modules',
             '/Users/evan/node_modules',
             '/Users/node_modules',
             '/node_modules' ] } }

快取還解決了迴圈引用的問題。舉個例子,現在有模組 a require 模組 b;而模組 b 又 require 了模組 a。



// main.js
 const a = require('./a');
 console.log('in main, a.a1 = %j, a.a2 = %j', a.a1, a.a2);
 ​
 // a.js
 exports.a1 = true;
 const b = require('./b.js');
 console.log('in a, b.done = %j', b.done);
 exports.a2 = true;
 ​
 // b.js
 const a = require('./a.js');
 console.log('in b, a.a1 = %j, a.a2 = %j', a.a1, a.a2);

程式執行結果如下:



in b, a.a1 = true, a.a2 = undefined
 in main, a.a1 = true, a.a2 = true

實際上在模組 a 程式碼執行之前就已經建立了 Module 例項寫入了快取,此時程式碼還沒執行,exports 是個空物件

 '/Users/evan/Desktop/module/a.js': 
    Module {
      exports: {},
      //...
   }
 }

程式碼 exports.a1 = true; 修改了 module.exports 上的 a1 為 true, 這時候 a2 程式碼還沒執行。

 '/Users/evan/Desktop/module/a.js': 
    Module {
      exports: {
       a1: true
     }
      //...
   }
 }

進入b模組,require a.js 時發現快取上已經存在了,獲取 a 模組上的 exports 。列印 a1, a2 分別是true,和 undefined。

執行完 b 模組,繼續執行 a 模組剩餘的程式碼,exports.a2 = true; 又往 exports 物件上增加了a2屬性,此時 module a 的 export物件 a1, a2 均為 true。

 exports: { 
   a1: true,
   a2: true
 }

再回到 main 模組,由於 require('./a.js') 得到的是 module a export 物件的引用,這時候列印 a1, a2 就都為 true。

小結

CommonJS 模組載入過程是同步阻塞性地載入,在模組程式碼被執行前就已經寫入了 cache,同一個模組被多次 require 時只會執行一次,重複的 require 得到的是相同的 exports 引用。

值得留意:cache key 使用的是模組在系統中的絕對位置,由於模組呼叫位置的不同,相同的 require('foo')程式碼並不能保證返回的是統一個物件引用。我之前恰巧就遇到過,兩次 require('egg-core')但是他們並不相等。

ES6 模組

ES6 模組是前端開發同學更為熟悉的方式,使用 import, export 關鍵字來進行模組輸入輸出。ES6 不再是使用閉包和函式封裝的方式進行模組化,而是從語法層面提供了模組化的功能。

ES6 模組中不存在 require, module.exports, __filename 等變數,CommonJS 中也不能使用 import。兩種規範是不相容的,一般來說平日裡寫的 ES6 模組程式碼最終都會經由 Babel, Typescript 等工具處理成 CommonJS 程式碼。

使用 Node 原生 ES6 模組需要將 js 檔案字尾改成 mjs,或者 package.json "type"` 欄位改為 "module",通過這種形式告知Node使用ES Module 的形式載入模組。

ES6 模組 載入過程

ES6 模組的載入過程分為三步:

1. 查詢,下載,解析,構建所有模組例項。

ES6 模組會在程式開始前先根據模組關係查詢到所有模組,生成一個無環關係圖,並將所有模組例項都建立好,這種方式天然地避免了迴圈引用的問題,當然也有模組載入快取,重複 import 同一個模組,只會執行一次程式碼。

2. 在記憶體中騰出空間給即將 export 的內容(此時尚未寫入 export value)。然後使 import 和 export 指向記憶體中的這些空間,這個過程也叫連線。

這一步完成的工作是 living binding import export,藉助下面的例子來幫助理解。



// counter.js
 let count = 1;
 ​
 function increment () {
   count++;
 }
 ​
 module.exports = {
   count,
   increment
 }
 ​
 // main.js
 const counter = require('counter.cjs');
 ​
 counter.increment();
 console.log(counter.count); // 1

上面 CommonJS 的例子執行結果很好理解,修改 count++` 修改的是模組內的基礎資料型別變數,不會改變exports.count,所以列印結果認為 1。

 // counter.mjs
 export let count = 1;
 ​
 export function increment () {
   count++;
 }
 ​
 // main.mjs
 import { increment, count } from './counter.mjs'
 ​
 increment();
 console.log(count); // 2

從結果上看使用 ES6 模組的寫法,當 export 的變數被修改時,會影響 import 的結果。這個功能的實現就是 living binding,具體規範底層如何實現可以暫時不管,但是知道 living binding 比網上文章描述為 "ES6 模組輸出的是值的引用" 更好理解。

更接近 ES6 模組的 CommonJS 程式碼可以是下面這樣:

 exports.counter = 1;
 ​
 exports.increment = function () {
     exports.counter++;
 }

3. 執行模組程式碼將變數的實際值填寫在第二步生成的空間中。

到第三步,會基於第一步生成的無環圖進行深度優先後遍歷填值,如果這個過程中訪問了尚未初始化完成的空間,會丟擲異常。

 // a.mjs
 export const a1 = true;
 import * as b from './b.mjs';
 export const a2 = true;
 ​
 // b.mjs
 import { a1, a2 } from './a.mjs'
 console.log(a1, a2);

上面的例子會在執行時丟擲異常 ReferenceError: Cannot access 'a1' before initialization。如果改成 import * as a from 'a.mjs'可以看到 a 模組中 export 的物件已經佔好坑了。

 // b.mjs
 import * as a from './a.mjs'
 console.log(a);

將輸出 { a1:, a2:} 可以看出,ES6 模組為 export 的變數預留了空間,不過尚未賦值。這裡和 CommonJS 不一樣,CommonJS 到這裡是知道 a1 為 true, a2 為 undefined

除此之外,我們還能推匯出一些 ES6 模組和 CommonJS 的差異點:

CommonJS 可以在執行時使用變數進行 require, 例如 require(path.join('xxxx', 'xxx.js')),而靜態 import 語法(還有動態 import,返回 Promise)不行,因為 ES6 模組會先解析所有模組再執行程式碼。

require 會將完整的 exports 物件引入,import 可以只 import 部分必要的內容,這也是為什麼使用 Tree Shaking 時必須使用 ES6 模組 的寫法。import 另一個模組沒有 export 的變數,在程式碼執行前就會報錯,而 CommonJS 是在模組執行時才報錯。

為什麼平時開發可以混寫?

前面提到 ES6 模組和 CommonJS 模組有很大差異,不能直接混著寫。這和開發中表現是不一樣的,原因是開發中寫的 ES6 模組最終都會被打包工具處理成 CommonJS 模組,以便相容更多環境,同時也能和當前社群普通的 CommonJS 模組融合。

在轉換的過程中會產生一些困惑,比如說:

__esModule 是什麼?幹嘛用的?

使用轉換工具處理 ES6 模組的時候,常看到打包之後出現 __esModule 屬性,字面意思就是將其標記為 ES6 Module。這個變數存在的作用是為了方便在引用模組的時候加以處理。

例如 ES6 模組中的 export default 在轉化成 CommonJS 時會被掛載到 exports['default'] 上,當執行 require('./a.js') 時 是不能直接讀取到 default 上的值的,為了和 ES6 中 import a from './a.js'的行為一致,會基於 __esModule 判斷處理。

 // a.js
 export default 1;
 ​
 // main.js
 import a from './a';
 ​
 console.log(a);

轉化後

 // a.js
 Object.defineProperty(exports, "__esModule", {
   value: true
 });
 exports.default = 1;
 ​
 // main.js
 'use strict';
 ​
 var _a = require('./a');
 ​
 var _a2 = _interopRequireDefault(_a);
 ​
 function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
 ​
 console.log(_a2.default);

a 模組 export defualt 會被轉換成 exports.default = 1;,這也是平時前端專案開發中使用 require 為什麼還常常需要 .default 才能取到目標值的原因。

接著當執行 import a from './a.js' 時,es module 預期的是返回 export 的內容。工具會將程式碼轉換為 _interopRequireDefault 包裹,在裡面判斷是否為 esModule,是的話直接返回,如果是 commonjs 模組的話則包裹一層 {default: obj},最後獲取 a 的值時,也會被裝換成 _a1.default。

總結

大家有什麼要說的,歡迎在評論區留言

對了,小編為大家準備了一套2020最新的web前端資料,需要點選下方連結獲取方式

1、點贊+評論(勾選“同時轉發”)

學習前端,你掌握這些。二線也能輕鬆拿8K以上