深入 CommonJs 與 ES6 Module

李13發表於2019-04-01

目前主流的模組規範

  • UMD
  • CommonJs
  • es6 module

umd 模組(通用模組)

(function (global, factory) {
    typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
    typeof define === 'function' && define.amd ? define(factory) :
    (global.libName = factory());
}(this, (function () { 'use strict';})));
複製程式碼

如果你在js檔案頭部看到這樣的程式碼,那麼這個檔案使用的就是 UMD 規範 實際上就是 amd + commonjs + 全域性變數 這三種風格的結合 這段程式碼就是對當前執行環境的判斷,如果是 Node 環境 就是使用 CommonJs 規範, 如果不是就判斷是否為 AMD 環境, 最後匯出全域性變數 有了 UMD 後我們的程式碼和同時執行在 Node瀏覽器上 所以現在前端大多數的庫最後打包都使用的是 UMD 規範

CommonJs

Nodejs 環境所使用的模組系統就是基於CommonJs規範實現的,我們現在所說的CommonJs規範也大多是指Node的模組系統

模組匯出

關鍵字:module.exports exports

// foo.js

//一個一個 匯出
module.exports.age = 1
module.exports.foo = function(){}
exports.a = 'hello'

//整體匯出
module.exports = { age: 1, a: 'hello', foo:function(){} }

//整體匯出不能用`exports` 用exports不能在匯入的時候使用
exports = { age: 1, a: 'hello', foo:function(){} }
複製程式碼

這裡需要注意 exports 不能被賦值,可以理解為在模組開始前exports = module.exports, 因為賦值之後exports失去了 對module.exports的引用,成為了一個模組內的區域性變數

模組匯入

關鍵字:require

const foo = require('./foo.js')
console.log(foo.age) //1
複製程式碼

模組匯入規則:

假設以下目錄為 src/app/index.js 的檔案 呼叫 require()

./moduleA 相對路徑開頭

在沒有指定字尾名的情況下 先去尋找同級目錄同級目錄:src/app/

  • src/app/moduleA 無字尾名檔案 按照javascript解析
  • src/app/moduleA.js js檔案 按照javascript解析
  • src/app/moduleA.json json檔案 按照json解析
  • src/app/moduleA.node node檔案 按照載入的編譯外掛模組dlopen

同級目錄沒有 moduleA 檔案會去找同級的 moduleA目錄src/app/moduleA

  • src/app/moduleA/package.json 判斷該目錄是否有package.json檔案, 如果有 找到main欄位定義的檔案返回, 如果 main 欄位指向檔案不存在 或 main欄位不存在 或 package.json檔案不存在向下執行
  • src/app/moduleA/index.js
  • src/app/moduleA/index.json
  • src/app/moduleA/index.node

結束

/module/moduleA 絕對路徑開頭

直接在/module/moduleA目錄中尋找 規則同上

react 沒有路徑開頭

沒有路徑開頭則視為匯入一個包 會先判斷moduleA是否是一個核心模組pathhttp,優先匯入核心模組 不是核心模組 會從當前檔案的同級目錄的node_modules尋找

  • /src/app/node_modules/ 尋找規則同上 以匯入react為例 先 node_modules 下 react 檔案 -> react.js -> react.json -> react.node ->react目錄 -> react package.json main -> index.js -> index.json -> index.node 如果沒找到 繼續向父目錄的node_modules中找
  • /src/node_modules/
  • /node_modules/

直到最後找不到 結束

require wrapper

Node的模組 實際上可以理解為程式碼被包裹在一個函式包裝器內 一個簡單的require demo

function wrapper (script) {
    return '(function (exports, require, module, __filename, __dirname) {' + 
        script +
     '\n})'
}

function require(id) {
 var cachedModule = Module._cache[id];
  if(cachedModule){
    return cachedModule.exports;
  }
  
  const module = { exports: {} }

  // 這裡先將引用加入快取 後面迴圈引用會說到
  Module._cache[id] = module

  //當然不是eval這麼簡單
  eval(wrapper('module.exports = "123"'))(module.exports, require, module, 'filename', 'dirname')


  return module.exports
}
複製程式碼

也可以檢視:node module 原始碼 從以上程式碼我們可以知道:

  • 模組只執行一次 之後呼叫獲取的 module.exports 都是快取哪怕這個 js 還沒執行完畢(因為先加入快取後執行模組)
  • 模組匯出就是return這個變數的其實跟a = b賦值一樣, 基本型別匯出的是引用型別匯出的是引用地址
  • exportsmodule.exports 持有相同引用,因為最後匯出的是 module.exports, 所以對exports進行賦值會導致exports操作的不再是module.exports的引用

迴圈引用

// a.js
module.exports.a = 1
var b = require('./b')
console.log(b)
module.exports.a = 2

// b.js
module.exports.b = 11
var a = require('./a')
console.log(a)
module.exports.b = 22

//main.js
var a = require('./a')
console.log(a)
複製程式碼

執行此段程式碼結合上面的require demo,分析每一步過程:

  1. 執行 node main.js -> 第一行 require(a.js),(node 執行也可以理解為呼叫了require方法,我們省略require(main.js)內容)
  2. 進入 require(a)方法: 判斷快取(無) -> 初始化一個 module -> 將 module 加入快取 -> 執行模組 a.js 內容,(需要注意 是加入快取後執行模組內容)
  3. a.js: 第一行匯出 a = 1 -> 第二行 require(b.js)(a 只執行了第一行)
  4. 進入 require(b) 內 同 1 -> 執行模組 b.js 內容
  5. b.js: 第一行 b = 11 -> 第二行 require(a.js)
  6. require(a) 此時 a.js 是第二次呼叫 require -> 判斷快取(有)-> cachedModule.exports -> 回到 b.js(因為js物件引用問題 此時的 cachedModule.exports = { a: 1 }
  7. b.js:第三行 輸出 { a: 1 } -> 第四行 修改 b = 22 -> 執行完畢回到 a.js
  8. a.js:第二行 require 完畢 獲取到 b -> 第三行 輸出 { b: 22 } -> 第四行 匯出 a = 2 -> 執行完畢回到 main.js
  9. main.js:獲取 a -> 第二行 輸出 { a: 2 } -> 執行完畢

以上就是nodemodule模組解析和執行的大致規則

es6 module

ES6 之前 javascript 一直沒有屬於自己的模組規範,所以社群制定了 CommonJs規範, NodeCommonjs 規範中借鑑了思想於是有了 Nodemodule,而 AMD 非同步模組 也同樣脫胎於 Commonjs 規範,之後有了執行在瀏覽器上的 require.js

es6 module 基本語法:

export

export * from 'module'; //重定向匯出 不包括 module內的default
export { name1, name2, ..., nameN } from 'module'; // 重定向命名匯出
export { import1 as name1, import2 as name2, ..., nameN } from 'module'; // 重定向重新命名匯出

export { name1, name2, …, nameN }; // 與之前宣告的變數名繫結 命名匯出
export { variable1 as name1, variable2 as name2, …, nameN }; // 重新命名匯出

export let name1 = 'name1'; // 宣告命名匯出 或者 var, const,function, function*, class

export default expression; // 預設匯出
export default function () { ... } // 或者 function*, class
export default function name1() { ... } // 或者 function*, class
export { name1 as default, ... }; // 重新命名為預設匯出
複製程式碼

export 規則

  • export * from '' 或者 export {} from '',重定向匯出,重定向的命名並不能在本模組使用,只是搭建一個橋樑,例如:這個a並不能在本模組內使用
  • export {}, 與變數名繫結,命名匯出
  • export Declaration,宣告的同時,命名匯出, Declaration就是: var, let, const, function, function*, class 這一類的宣告語句
  • export default AssignmentExpression,預設匯出, AssignmentExpression的 範圍很廣,可以大致理解 為除了宣告Declaration(其實兩者是有交叉的),a=2,i++,i/4,a===b,obj[name],name in obj,func(),new P(),[1,2,3],function(){}等等很多

import

// 命名匯出 module.js
let a = 1,b = 2
export { a, b }
export let c = 3

// 命名匯入 main.js
import { a, b, c } from 'module'; // a: 1  b: 2  c: 3
import { a as newA, b, c as newC } from 'module'; // newA: 1  b: 2  newC: 3


// 預設匯出 module.js
export default 1

// 預設匯入 main.js
import defaultExport from 'module'; // defaultExport: 1


// 混合匯出 module.js
let a = 1
export { a }
const b = 2
export { b }
export let c = 3
export default [1, 2, 3]

// 混合匯入 main.js
import defaultExport, { a, b, c as newC} from 'module'; //defaultExport: [1, 2, 3]  a: 1  b: 2  newC: 3
import defaultExport, * as name from 'module'; //defaultExport: [1, 2, 3]  name: { a: 1, b: 2, c: 3 }
import * as name from 'module'; // name: { a: 1, b: 2, c: 3, default: [1, 2, 3] }


// module.js
Array.prototype.remove = function(){}

//副作用 只執行一個模組
import 'module'; // 執行module 不匯出值  多次呼叫module.js只執行一次

//動態匯入(非同步匯入)
var promise = import('module');
複製程式碼

import 規則

  • import { } from 'module', 匯入module.js命名匯出
  • import defaultExport from 'module', 匯入module.js預設匯出
  • import * as name from 'module', 將module.js的所有匯出合併為name的物件,key為匯出的命名,預設匯出的keydefault
  • import 'module',副作用,只是執行module,不為了匯出內容例如 polyfill,多次呼叫次語句只能執行一次
  • import('module'),動態匯入返回一個 PromiseTC39stage-3階段被提出 tc39 import

ES6 module 特點

ES6 module的語法是靜態的

import 會自動提升到程式碼的頂層

exportimport 只能出現在程式碼的頂層,下面這段語法是錯誤

 //if for while 等都無法使用
{
  export let a = 1

  import defaultExport from 'module'
}

true || export let a = 1
複製程式碼

import 的匯入名不能為字串或在判斷語句,下面程式碼是錯誤

import 'defaultExport' from 'module'

let name = 'Export'
import 'default' + name from 'module'
複製程式碼

靜態的語法意味著可以在編譯時確定匯入和匯出,更加快速的查詢依賴,可以使用lint工具對模組依賴進行檢查,可以對匯入匯出加上型別資訊進行靜態的型別檢查

####ES6 module的匯出是繫結的 ####

使用 import 被匯入的模組執行在嚴格模式

使用 import 被匯入的變數是只讀的,可以理解預設為 const 裝飾,無法被賦值

使用 import 被匯入的變數是與原變數繫結/引用的,可以理解為 import 匯入的變數無論是否為基本型別都是引用傳遞

// js中 基礎型別是值傳遞
let a = 1
let b = a
b = 2
console.log(a,b) //1 2

// js中 引用型別是引用傳遞
let obj = {name:'obj'}
let obj2 = obj
obj2.name = 'obj2'
console.log(obj.name, obj2.name) // obj2  obj2


// es6 module 中基本型別也按引用傳遞
// foo.js
export let a = 1
export function count(){
  a++
}

// main.js
import { a, count } from './foo'
console.log(a) //1
count()
console.log(a) //2

// export default 是無法 a 的動態繫結 這一點跟 CommonJs 有點相似 都是值的拷貝
let a = 1;
export default a 

// 可以用另一種方式實現 default 的動態繫結
let a = 1;
export { a as default }
export function count(){
  a++
}
// 就跟上面 main.js 一樣
複製程式碼

上面這段程式碼就是 CommonJs 匯出變數 和 ES6 匯出變數的區別

es module 迴圈引用

// bar.js
import { foo } from './foo'
console.log(foo);
export let bar = 'bar'

// foo.js
import { bar } from './bar'
console.log(bar);
export let foo = 'foo'

// main.js
import { bar } from './bar'
console.log(bar)
複製程式碼
  1. 執行 main.js -> 匯入 bar.js
  2. bar.js -> 匯入 foo.js
  3. foo.js -> 匯入 bar.js -> bar.js 已經執行過直接返回 -> 輸出 bar -> bar is not defined, bar 未定義報錯

我們可以使用function的方式解決:

// bar.js
import { foo } from './foo'
console.log(foo());
export function bar(){
  return 'bar'
}

// foo.js
import { bar } from './bar'
console.log(bar());
export function foo(){
  return 'foo'
}

// main.js
import { bar } from './bar'
console.log(bar)
複製程式碼

因為函式宣告會提示到檔案頂部,所以就可以直接在 foo.js 呼叫還沒執行完畢的bar.jsbar 方法,不要在函式內使用外部變數,因為變數還未宣告(let,const)和賦值,var

CommonJs 和 ES6 Module 的區別

其實上面我們已經說到了一些區別

  • CommonJs匯出的是變數的一份拷貝,ES6 Module匯出的是變數的繫結(export default 是特殊的)
  • CommonJs是單個值匯出,ES6 Module可以匯出多個
  • CommonJs是動態語法可以寫在判斷裡,ES6 Module靜態語法只能寫在頂層
  • CommonJsthis 是當前模組,ES6 Modulethisundefined

易混淆點

模組語法與解構

module語法解構語法很容易混淆,例如:

import { a } from 'module'

const { a } = require('module')
複製程式碼

儘管看上去很像,但是不是同一個東西,這是兩種完全不一樣的語法與作用,ps:兩個人撞衫了,穿一樣的衣服你不能說這倆人就是同一個人 module 的語法: 上面有寫 import/export { a } / { a, b } / { a as c} FromClause 解構 的語法:

let { a } = { a: 1 }
let { a = 2 } = { }
let { a: b } = { a: 1 }
let { a: b = 2, ...res } = { name:'a' }
let { a: b, obj: { name } } = { a: 1, obj: { name: '1' } }

function foo({a: []}) {}
複製程式碼

他們是差別非常大的兩個東西,一個是模組匯入匯出,一個是獲取物件的語法糖

匯出語法與物件屬性簡寫

同樣下面這段程式碼也容易混淆

let a = 1

export { a } // 匯出語法
export default { a } // 屬性簡寫 匯出 { a: 1 } 物件

module.exports = { a } // 屬性簡寫 匯出 { a: 1 } 物件
複製程式碼

export defaultmodule.exports 是相似的

ES6 module 支援 CommonJs 情況

先簡單說一下各個環境的 ES6 module 支援 CommonJs 情況,後面單獨說如何在不同環境中使用

因為 module.exports 很像 export default 所以 ES6模組 可以很方便相容 CommonJsES6 module中使用CommonJs規範,根據各個環境,打包工具不同也是不一樣的

我們現在大多使用的是 webpack 進行專案構建打包,因為現在前端開發環境都是在 Node 環境原因,而 npm 的包都是 CommonJs 規範的,所以 webpackES6模組進行擴充套件 支援 CommonJs,並支援node的匯入npm包的規範

如果你使用 rollup,想在ES Module中支援Commonjs規範就需要下載rollup-plugin-commonjs外掛,想要匯入node_modules下的包也需要rollup-plugin-node-resolve外掛

如果你使用 node,可以在 .mjs 檔案使用 ES6,也支援 CommonJs 檢視 nodejs es-modules.md

在瀏覽器環境 不支援CommonJs

node 與 打包工具webpack,rollup的匯入 CommonJs 差異

// module.js
module.export.a = 1

// index.js webpack rollup
import * as a from './module'
console.log(a) // { a: 1, default: { a:1 } }

// index.mjs node
import * as a from './module'
console.log(a) // { default: { a:1 } }
複製程式碼

node 只是把 module.exports 整體當做 export default 打包工具除了把 module.export 整體當做 export default,還把 module.export 的每一項 又當做 export 輸出,這樣做是為了更加簡潔 import defaultExport from './foo'defaultExport.foo() import { foo } from './foo'foo()

使用 ES6 Module

可以在 es6module example 倉庫中獲取程式碼在本地進行測試驗證

瀏覽器中使用

你需要起一個Web伺服器來訪問,雙擊本地執行 index.html 並不會執行 type=module 標籤 我們可以對 script 標籤的 type 屬性加上 module 先定義兩個模組

// index.js
import module from './module.js'
console.log(module) // 123

// module.js
export default 123
複製程式碼

html中內聯呼叫

<!-- index.html -->
<script type="module">
  import module from './module.js'
  console.log(module) // 123
</script>
複製程式碼

html中通過 scriptsrc 引用

<!-- index.html -->
<script type="module" src="index.js"></script>
// 控制檯 123
複製程式碼

瀏覽器匯入路徑規則

  • https://example.com/apples.mjs
  • http://example.com/apples.js
  • //example.com/bananas
  • ./strawberries.mjs.cgi
  • ../lychees
  • /limes.jsx
  • data:text/javascript,export default 'grapes';
  • blob:https://whatwg.org/d0360e2f-caee-469f-9a2f-87d5b0456f6f

補充:

  • 不加 字尾名 找不到具體的檔案
  • 後端可以修改介面/getjs?name=module這一類的,不過後端要返回 Content-Type: application/javascript 確保返回的是js,因為瀏覽器是根據 MIME type 識別的

因為 ES6 Module 在瀏覽器中相容並不是很好相容性表,這裡就不介紹瀏覽器支援情況了,我們一般不會直接在瀏覽器中使用

Nodejs中使用

nodejs es-modules.md

Node v8.5.0 以上支援 ES Module,需要 .mjs副檔名

NOTE: DRAFT status does not mean ESM will be implemented in Node core. Instead that this is the standard, should Node core decide to implement ESM. At which time this draft would be moved to ACCEPTED. (上面連結可以知道 ES Module的狀態是 DRAFT, 屬於起草階段)

// module.mjs
export default 123

// index.mjs
import module from './module.mjs'
console.log(module) // 123
複製程式碼

我們需要執行 node --experimental-modules index.mjs 來啟動 會提示一個 ExperimentalWarning: The ESM module loader is experimental.該功能是實驗性的(此提示不影響執行) ES Module 中匯入 CommonJs

// module.js
module.exports.a = 123 // module.exports 就相當於 export default

// index.mjs
import module from './module.js'
console.log(module) // { a: 123 }

import * as module from './module.js'
console.log(module) // { get default: { a: 123 } }

import { default as module } from './module.js';
console.log(module) // { a: 123 }

import module from 'module'; // 匯入npm包 匯入規則與 require 差不多
複製程式碼

匯入路徑規則與require差不多 這裡要注意 module 副檔名為 .js.mjs專屬於 es moduleimport form匯入的檔案字尾名只能是.mjs,在 .mjsmodule未定義, 所以呼叫 module.exports,exports 會報錯

nodeCommonJs 匯入 es module 只能使用 import() 動態匯入/非同步匯入

// es.mjs
let foo = {name: 'foo'};
export default foo;

export let a = 1

// cjs
import('./es').then((res)=>{
  console.log(res) // { get default: {name: 'foo'}, a: 1 }
});
複製程式碼

webpack中使用

webpack2 就預設支援 es module 了,並預設支援 CommonJs,支援匯入 npm包, 這裡 import 語法上面寫太多 就不再寫了

rollup中使用

rollup 專注於 es module,可以將 es module 打包為主流的模組規範,注意這裡與 webpack 的區別,我們可以在 webpackjs 中使用 Commonjs 語法, 但是 rollup 不支援,rollup需要 plugin 支援,包括載入 node_modules 下的包 form 'react' 也需要 plugin 支援

可以看到 es module瀏覽器node相容性差實驗功能的 我們大多時候在 打包工具 中使用

Tree-shaking

在最後我們說一下經常跟 es module 一起出現的一個名詞 Tree-shaking Tree-shaking 我們先直譯一下 樹木搖晃 就是 搖晃樹木把上面枯死的樹葉晃下來,在程式碼中就是把沒有用到的程式碼刪除 Tree-shaking 最早由 rollup 提出,之後 webpack 2 也開始支援 這都是基於 es module 模組特性的靜態分析

rollup

下面程式碼使用 rollup 進行打包:

// module.js
export let foo = 'foo'
export let bar = 'bar'

// index.js
import { foo } from './module'
console.log(foo) // foo
複製程式碼

線上執行 我們可以修改例子與匯出多種規範

打包結果:

let foo = 'foo';

console.log(foo); // foo
複製程式碼

可以看到 rollup 打包結果非常的簡潔,並去掉了沒有用到的 bar 是否支援對匯入 CommonJs 的規範進行 Tree-shaking

// index.js
import { a } from './module'
console.log(a) // 1

// module.js
module.exports.a = 1
module.exports.b = 2
複製程式碼

打包為 es module

var a_1 = 2;

console.log(a_1);
複製程式碼

可以看到去掉了未使用的 b

webpack

我們下面看看 webpack 的支援情況

// src/module.js
export function foo(){ return 'foo' }
export function bar(){ return 'bar' }

// src/index.js
import { foo } from './module'
console.log(foo())
複製程式碼

執行 npx webpack -p(我們使用webpack 4,0配置,-p開啟生成模式 自動壓縮) 打包後我們在打包檔案搜尋 bar 沒有搜到,bar被刪除 我們將上面例子修改一下:

// src/module.js
module.exports.foo = function (){ return 'foo' }
module.exports.bar = function (){ return 'bar' }

// src/index.js
import { foo } from './module'
console.log(foo())
複製程式碼

打包後搜尋 bar 發現bar存在,webpack 並不支援對CommonJs 進行 Tree-shaking

pkg.module

webpack 不支援 Commonjs Tree-shaking,但現在npm的包都是CommonJs規範的,這該怎麼辦呢 ?如果我發了一個新包是 es module 規範, 但是如果程式碼執行在 node 環境,沒有經過打包 就會報錯

有一種按需載入的方案

全路徑匯入,匯入具體的檔案:

// src/index.js
import remove from 'lodash/remove'
import add from 'lodash/add'

console.log(remove(), add())
複製程式碼

使用一個還好,如果用多個的話會有很多 import 語句 還可以使用外掛如 babel-plugin-lodash, & lodash-webpack-plugin

但我們不能發一個庫就自己寫外掛

這時就提出了在 package.json 加一個 module 的欄位來指向 es module規範的檔案,main -> CommonJs,那麼module - es module pkg.module

webpackrollup 都支援 pkg.module

加了 module 欄位 webpack 就可以識別我們的 es module,但是還有一個問題就是 babel

我們一般使用 babel 都會排除 node_modules,所以我們這個 pkg.module 只是的 es6 module必須是編譯之後的 es5 程式碼,因為 babel 不會幫我們編譯,我們的包就必須是 擁有 es6 module 規範的 es5 程式碼

如果你使用了 presets-env 因為會把我們的程式碼轉為 CommonJs 所以就要設定 "presets": [["env", {"modules":false}] 不將es module 轉為 CommonJs

webpackrollup 的區別

  • webpack 不支援匯出 es6 module 規範,rollup 支援匯出 es6 module
  • webpack 打包後程式碼很多冗餘無法直接看,rollup 打包後的程式碼簡潔,可讀,像原始碼
  • webpack 可以進行程式碼分割,靜態資源處理,HRMrollup 專注於 es moduletree-shaking更加強大的,精簡

如果是開發應用可以使用 webpack,因為可以進行程式碼分割,靜態資源,HRM,外掛 如果是開發類似 vuereact 等類庫,rollup 更好一些,因為可以使你的程式碼精簡,無冗餘程式碼,執行更快,匯出多種模組語法

結語

本文章介紹了 CommonjsES6 Module,匯入匯出的語法規則,路徑解析規則,兩者的區別,容易混淆的地方,在不同環境的區別,在不同環境的使用,Tree-shaking,與 webpackrollup 的區別 希望您讀完文章後,能對前端的模組化有更深的瞭解

參考連結

相關文章