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

来源:https://www.cnblogs.com/coderhf/archive/2020/06/27/13198463.html
-Advertisement-
Play Games

CommonJS 和 ES6 Module 究竟有什麼區別? 作為前端開發者,你是否也曾有過疑惑,為什麼可以代碼中可以直接使用 require 方法載入模塊,為什麼載入第三方包的時候 Node 會知道選擇哪個文件作為入口,以及常被問到的,為什麼 ES6 Module export 基礎數據類型的時候 ...


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 文件不是一個級別,硬碟上讀文件比網路請求快得多。

img

緩存和迴圈引用

文件模塊查找挺耗時的,如果每次 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以上


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 函數參數:lpad( string, padded_length, [ pad_string ] ) 參數說明: string:源字元串; padded_length: 即最終結果返回的字元串的長度;如果最終返回的字元串的長度比源字元串的小,那麼此函數實際上對源串進行截取處理,與substr(str ...
  • 本博客SQL腳本地址:gitee 準備工作 已知有如下4張表: 學生表:student(學號,學生姓名,出生年月,性別) 成績表:score(學號,課程號,成績) 課程表:course(課程號,課程名稱,教師號) 教師表:teacher(教師號,教師姓名) 一、創建資料庫和表 為了演示題目的運行過程 ...
  • 1. 獲取中獎用戶ID,隨機彈出之後集合中就不存在了【set】 2. 存儲活動中中獎的用戶ID,保證同一個用戶不會中獎兩次【set】 3. 存儲粉絲列表,value 為粉絲的用戶ID,score 是關註時間【zset】 4. 存儲學生成績,value 為學生的ID,score 是考試成績【zset】... ...
  • 我們知道數據的存儲和檢索是兩個很重要的功能,當我們的數據量大了,怎麼能快速的檢索數據呢,答案是使用索引,可索引具體的技術實現有很多,選擇哪一種呢,我就以mysql為例記錄下它為什麼選擇了B+樹作為索引的實現方式。 1. 索引簡介 索引是一種用於快速查詢行的數據結構,就像一本書的目錄就是一個索引,如果 ...
  • 前言 本文將帶大家學習使用前端開發神器-charles,從基本的下載安裝到常見配置使用,為大家一一講解。 一、花式誇獎Charles 截取 Http 和 Https 網路封包。 支持重髮網絡請求,方便後端調試。 支持修改網路請求參數。 支持網路請求的截獲並動態修改。 支持模擬慢速網路。 好,騎上我心 ...
  • 一、堆和棧,數據的存儲方式 1.註意點: JS中是沒有堆和棧的概念,我們用堆和棧來講解,目的就是方柏霓講解,存儲方式是一致的。 2.存儲方式: 基礎數據類型進行值傳遞,複雜數據類型進行地址傳遞 <script> //1.基本數據類型 var str1 = "xiaoliao"; var str2 = ...
  • Web 創建設計 設計一個網站,需要認真思考和規劃。 最重要的是要知道你的訪問用戶。 用戶是瀏覽者 一個典型的訪問者將無法讀取您的網頁的全部內容! 無論您在網頁中發佈了多麼有用的信息,一個訪問者在決定是否繼續閱讀之前僅僅會花幾秒鐘的時間進行瀏覽。 請確保使你的觀點,在頁面的第一句!另外,您還需要在整 ...
  • powertools可以稱得上插件界的瑞士軍刀。 相對於VS Code中大多數插件的出現為瞭解決某一項弊端和不足,powertools則聚合了很多強大且實用的功能,能夠增強VS Code的功能,並提升VS Code的使用體驗。 powertools就如同之前使用iOS系統時使用過的一款軟體Workf ...
一周排行
    -Advertisement-
    Play Games
  • 概述:在C#中,++i和i++都是自增運算符,其中++i先增加值再返回,而i++先返回值再增加。應用場景根據需求選擇,首碼適合先增後用,尾碼適合先用後增。詳細示例提供清晰的代碼演示這兩者的操作時機和實際應用。 在C#中,++i 和 i++ 都是自增運算符,但它們在操作上有細微的差異,主要體現在操作的 ...
  • 上次發佈了:Taurus.MVC 性能壓力測試(ap 壓測 和 linux 下wrk 壓測):.NET Core 版本,今天計劃準備壓測一下 .NET 版本,來測試並記錄一下 Taurus.MVC 框架在 .NET 版本的性能,以便後續持續優化改進。 為了方便對比,本文章的電腦環境和測試思路,儘量和... ...
  • .NET WebAPI作為一種構建RESTful服務的強大工具,為開發者提供了便捷的方式來定義、處理HTTP請求並返迴響應。在設計API介面時,正確地接收和解析客戶端發送的數據至關重要。.NET WebAPI提供了一系列特性,如[FromRoute]、[FromQuery]和[FromBody],用 ...
  • 原因:我之所以想做這個項目,是因為在之前查找關於C#/WPF相關資料時,我發現講解圖像濾鏡的資源非常稀缺。此外,我註意到許多現有的開源庫主要基於CPU進行圖像渲染。這種方式在處理大量圖像時,會導致CPU的渲染負擔過重。因此,我將在下文中介紹如何通過GPU渲染來有效實現圖像的各種濾鏡效果。 生成的效果 ...
  • 引言 上一章我們介紹了在xUnit單元測試中用xUnit.DependencyInject來使用依賴註入,上一章我們的Sample.Repository倉儲層有一個批量註入的介面沒有做單元測試,今天用這個示例來演示一下如何用Bogus創建模擬數據 ,和 EFCore 的種子數據生成 Bogus 的優 ...
  • 一、前言 在自己的項目中,涉及到實時心率曲線的繪製,項目上的曲線繪製,一般很難找到能直接用的第三方庫,而且有些還是定製化的功能,所以還是自己繪製比較方便。很多人一聽到自己畫就害怕,感覺很難,今天就分享一個完整的實時心率數據繪製心率曲線圖的例子;之前的博客也分享給DrawingVisual繪製曲線的方 ...
  • 如果你在自定義的 Main 方法中直接使用 App 類並啟動應用程式,但發現 App.xaml 中定義的資源沒有被正確載入,那麼問題可能在於如何正確配置 App.xaml 與你的 App 類的交互。 確保 App.xaml 文件中的 x:Class 屬性正確指向你的 App 類。這樣,當你創建 Ap ...
  • 一:背景 1. 講故事 上個月有個朋友在微信上找到我,說他們的軟體在客戶那邊隔幾天就要崩潰一次,一直都沒有找到原因,讓我幫忙看下怎麼回事,確實工控類的軟體環境複雜難搞,朋友手上有一個崩潰的dump,剛好丟給我來分析一下。 二:WinDbg分析 1. 程式為什麼會崩潰 windbg 有一個厲害之處在於 ...
  • 前言 .NET生態中有許多依賴註入容器。在大多數情況下,微軟提供的內置容器在易用性和性能方面都非常優秀。外加ASP.NET Core預設使用內置容器,使用很方便。 但是筆者在使用中一直有一個頭疼的問題:服務工廠無法提供請求的服務類型相關的信息。這在一般情況下並沒有影響,但是內置容器支持註冊開放泛型服 ...
  • 一、前言 在項目開發過程中,DataGrid是經常使用到的一個數據展示控制項,而通常表格的最後一列是作為操作列存在,比如會有編輯、刪除等功能按鈕。但WPF的原始DataGrid中,預設只支持固定左側列,這跟大家習慣性操作列放最後不符,今天就來介紹一種簡單的方式實現固定右側列。(這裡的實現方式參考的大佬 ...