import、require、export、module.exports 混合使用詳解

2dunn發表於2017-12-11

19年目標:消滅英語!我新開了一個公眾號記錄一個程式設計師學英語的歷程

有提升英語訴求的小夥伴可以關注公眾號:csenglish 程式設計師學英語,每天花10分鐘交作業,跟我一起學英語吧

前言

自從使用了 es6 的模組系統後,各種地方愉快地使用 import export default,但也會在老專案中看到使用commonjs規範的 require module.exports。甚至有時候也會常常看到兩者互用的場景。使用沒有問題,但其中的關聯與區別不得其解,使用起來也糊里糊塗。比如:

  1. 為何有的地方使用 require 去引用一個模組時需要加上 defaultrequire('xx').default
  2. 經常在各大UI元件引用的文件上會看到說明 import { button } from 'xx-ui' 這樣會引入所有元件內容,需要新增額外的 babel 配置,比如 babel-plugin-component
  3. 為什麼可以使用 es6 的 import 去引用 commonjs 規範定義的模組,或者反過來也可以又是為什麼?
  4. 我們在瀏覽一些 npm 下載下來的 UI 元件模組時(比如說 element-ui 的 lib 檔案下),看到的都是 webpack 編譯好的 js 檔案,可以使用 import 或 require 再去引用。但是我們平時編譯好的 js 是無法再被其他模組 import 的,這是為什麼?
  5. babel 在模組化的場景中充當了什麼角色?以及 webpack ?哪個啟到了關鍵作用?
  6. 聽說 es6 還有 tree-shaking 功能,怎麼才能使用這個功能?

如果你對這些問題都瞭然於心,那麼可以關掉本文了,如果有疑問,這篇文章就是為你準備的!

webpack 與 babel 在模組化中的作用

webpack 模組化的原理

webpack 本身維護了一套模組系統,這套模組系統相容了所有前端歷史程式下的模組規範,包括 amd commonjs es6 等,本文主要針對 commonjs es6 規範進行說明。模組化的實現其實就在最後編譯的檔案內。

我編寫了一個 demo 更好的展示效果。

// webpack

const path = require('path');

module.exports = {
  entry: './a.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
  }
};

複製程式碼

// a.js
import a from './c';

export default 'a.js';
console.log(a);

複製程式碼

// c.js

export default 333;

複製程式碼
(function(modules) {

  
  function __webpack_require__(moduleId) {
    var module =  {
      i: moduleId,
      l: false,
      exports: {}
    };
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    return module.exports;
  }

  return __webpack_require__(0);
})([
  (function (module, __webpack_exports__, __webpack_require__) {

    // 引用 模組 1
    "use strict";
    Object.defineProperty(__webpack_exports__, "__esModule", { value: true });
    /* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__c__ = __webpack_require__(1);

/* harmony default export */ __webpack_exports__["default"] = ('a.js');
console.log(__WEBPACK_IMPORTED_MODULE_0__c__["a" /* default */]);

  }),
  (function (module, __webpack_exports__, __webpack_require__) {

    // 輸出本模組的資料
    "use strict";
    /* harmony default export */ __webpack_exports__["a"] = (333);
  })
]);

複製程式碼

上面這段 js 就是使用 webpack 編譯後的程式碼(經過精簡),其中就包含了 webpack的執行時程式碼,其中就是關於模組的實現。

我們再精簡下程式碼,會發現這是個自執行函式。

(function(modules) {

})([]);

複製程式碼

自執行函式的入參是個陣列,這個陣列包含了所有的模組,包裹在函式中。

自執行函式體裡的邏輯就是處理模組的邏輯。關鍵在於 __webpack_require__ 函式,這個函式就是 require 或者是 import 的替代,我們可以看到在函式體內先定義了這個函式,然後呼叫了他。這裡會傳入一個 moduleId,這個例子中是0,也就是我們的入口模組 a.js 的內容。

我們再看 __webpack_require__ 內執行了

modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
return module.exports;
複製程式碼

即從入參的 modules 陣列中取第一個函式進行呼叫,併入參

  • module
  • module.exports
  • webpack_require

我們再看第一個函式(即入口模組)的邏輯(精簡):

function (module, __webpack_exports__, __webpack_require__) {

/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0__c__ = __webpack_require__(1);

    /* harmony default export */ __webpack_exports__["default"] = ('a.js');
    console.log(__WEBPACK_IMPORTED_MODULE_0__c__["a" /* default */]);

  }
複製程式碼

我們可以看到入口模組又呼叫了 __webpack_require__(1) 去引用入引數組裡的第2個函式。

然後會將入參的 __webpack_exports__ 物件新增 default 屬性,並賦值。

這裡我們就能看到模組化的實現原理,這裡的 __webpack_exports__ 就是這個模組的 module.exports 通過物件的引用傳參,間接的給 module.exports 新增屬性。

最後會將 module.exports return 出來。就完成了 __webpack_require__ 函式的使命。

比如在入口模組中又呼叫了 __webpack_require__(1),就會得到這個模組返回的 module.exports

**但在這個自執行函式的底部,webpack 會將入口模組的輸出也進行返回 **

return __webpack_require__(0);
複製程式碼

目前這種編譯後的js,將入口模組的輸出(即 module.exports) 進行輸出沒有任何作用,只會作用於當前作用域。這個js並不能被其他模組繼續以 requireimport 的方式引用。

babel 的作用

按理說 webpack 的模組化方案已經很好的將es6 模組化轉換成 webpack 的模組化,但是其餘的 es6 語法還需要做相容性處理。babel 專門用於處理 es6 轉換 es5。當然這也包括 es6 的模組語法的轉換。

其實兩者的轉換思路差不多,區別在於 webpack 的原生轉換 可以多做一步靜態分析,使用tree-shaking 技術(下面會講到)

babel 能提前將 es6 的 import 等模組關鍵字轉換成 commonjs 的規範。這樣 webpack 就無需再做處理,直接使用 webpack 執行時定義的 __webpack_require__ 處理。

這裡就解釋了 問題5

babel 在模組化的場景中充當了什麼角色?以及 webpack ?哪個啟到了關鍵作用?

那麼 babel 是如何轉換 es6 的模組語法呢?

匯出模組

es6 的匯出模組寫法有

export default 123;

export const a = 123;

const b = 3;
const c = 4;
export { b, c };
複製程式碼

babel 會將這些統統轉換成 commonjs 的 exports。

exports.default = 123;
exports.a = 123;
exports.b = 3;
exports.c = 4;
exports.__esModule = true;
複製程式碼

babel 轉換 es6 的模組輸出邏輯非常簡單,即將所有輸出都賦值給 exports,並帶上一個標誌 __esModule 表明這是個由 es6 轉換來的 commonjs 輸出。

babel將模組的匯出轉換為commonjs規範後,也會將引入 import 也轉換為 commonjs 規範。即採用 require 去引用模組,再加以一定的處理,符合es6的使用意圖。

引入 default

對於最常見的

import a from './a.js';
複製程式碼

在es6中 import a from './a.js' 的本意是想去引入一個 es6 模組中的 default 輸出。

通過babel轉換後得到 var a = require(./a.js) 得到的物件卻是整個物件,肯定不是 es6 語句的本意,所以需要對 a 做些改變。

我們在匯出提到,default 輸出會賦值給匯出物件的default屬性。

exports.default = 123;
複製程式碼

所以 babel 加了個 help _interopRequireDefault 函式。

function _interopRequireDefault(obj) {
    return obj && obj.__esModule
        ? obj
        : { 'default': obj };
}

var _a = require('assert');
var _a2 = _interopRequireDefault(_a);

var a = _a2['default'];
複製程式碼

所以這裡最後的 a 變數就是 require 的值的 default 屬性。如果原先就是commonjs規範的模組,那麼就是那個模組的匯出物件。

引入 * 萬用字元

我們使用 import * as a from './a.js' es6語法的本意是想將 es6 模組的所有命名輸出以及defalut輸出打包成一個物件賦值給a變數。

已知以 commonjs 規範匯出:

exports.default = 123;
exports.a = 123;
exports.b = 3;
exports.__esModule = true;
複製程式碼

那麼對於 es6 轉換來的輸出通過 var a = require('./a.js') 匯入這個物件就已經符合意圖。

所以直接返回這個物件。

if (obj && obj.__esModule) {
   return obj;
}
複製程式碼

如果本來就是 commonjs 規範的模組,匯出時沒有default屬性,需要新增一個default屬性,並把整個模組物件再次賦值給default屬性。

function _interopRequireWildcard(obj) {
    if (obj && obj.__esModule) {
        return obj;
    }
    else {
        var newObj = {}; // (A)
        if (obj != null) {
            for (var key in obj) {
                if (Object.prototype.hasOwnProperty.call(obj, key))
                    newObj[key] = obj[key];
            }
        }
        newObj.default = obj;
        return newObj;
    }
}
複製程式碼

import { a } from './a.js'

直接轉換成 require('./a.js').a 即可。

總結

經過上面的轉換分析,我們得知即使我們使用了 es6 的模組系統,如果藉助 babel 的轉換,es6 的模組系統最終還是會轉換成 commonjs 的規範。所以我們如果是使用 babel 轉換 es6 模組,混合使用 es6 的模組和 commonjs 的規範是沒有問題的,因為最終都會轉換成 commonjs。

這裡解釋了問題3

為什麼可以使用 es6 的 import 去引用 commonjs 規範定義的模組,或者反過來也可以又是為什麼?

babel5 & babel6

我們在上文 babel 對匯出模組的轉換提到,es6 的 export default 都會被轉換成 exports.default,即使這個模組只有這一個輸出。

這也解釋了問題1

為何有的地方使用 require 去引用一個模組時需要加上 defaultrequire('xx').default

我們經常會使用 es6 的 export default 來輸出模組,而且這個輸出是這個模組的唯一輸出,我們會誤以為這種寫法輸出的是模組的預設輸出。

// a.js

export default 123;
複製程式碼
// b.js 錯誤

var foo = require('./a.js')
複製程式碼

在使用 require 進行引用時,我們也會誤以為引入的是a檔案的預設輸出。

結果這裡需要改成 var foo = require('./a.js').default

這個場景在寫 webpack 程式碼分割邏輯時經常會遇到。

require.ensure([], (require) => {
   callback(null, [
     require('./src/pages/profitList').default,
   ]);
 });
複製程式碼

這是 babel6 的變更,在 babel5 的時候可不是這樣的。

import、require、export、module.exports 混合使用詳解

babeljs.io/docs/plugin…

在 babel5 時代,大部分人在用 require 去引用 es6 輸出的 default,只是把 default 輸出看作是一個模組的預設輸出,所以 babel5 對這個邏輯做了 hack,如果一個 es6 模組只有一個 default 輸出,那麼在轉換成 commonjs 的時候也一起賦值給 module.exports,即整個匯出物件被賦值了 default 所對應的值。

這樣就不需要加 default,require('./a.js') 的值就是想要的 default值。

但這樣做是不符合 es6 的定義的,在es6 的定義裡,default 只是個名字,沒有任何意義。

export default = 123;
export const a = 123;
複製程式碼

這兩者含義是一樣的,分別為輸出名為 default 和 a 的變數。

還有一個很重要的問題,一旦 a.js 檔案裡又新增了一個具名的輸出,那麼引入方就會出麻煩。

// a.js

export default 123;

export const a = 123; // 新增
複製程式碼
// b.js 

var foo = require('./a.js');

// 由之前的 輸出 123
// 變成 { default: 123, a: 123 }
複製程式碼

所以 babel6 去掉了這個hack,這是個正確的決定,升級 babel6 後產生的不相容問題 可以通過引入 babel-plugin-add-module-exports 解決。

webpack 編譯後的js,如何再被其他模組引用

通過 webpack 模組化原理章節給出的 webpack 配置編譯後的 js 是無法被其他模組引用的,webpack 提供了 output.libraryTarget 配置指定構建完的 js 的用途。

預設 var

如果指定了 output.library = 'test' 入口模組返回的 module.exports 暴露給全域性 var test = returned_module_exports

commonjs

如果library: 'spon-ui' 入口模組返回的 module.exports 賦值給 exports['spon-ui']

commonjs2

入口模組返回的 module.exports 賦值給 module.exports

所以 element-ui 的構建方式採用 commonjs2 ,匯出的元件的js 最後都會賦值給 module.exports,供其他模組引用。

import、require、export、module.exports 混合使用詳解

這裡解釋了問題4

我們在瀏覽一些 npm 下載下來的 UI 元件模組時(比如說 element-ui 的 lib 檔案下),看到的都是 webpack 編譯好的 js 檔案,可以使用 import 或 require 再去引用。但是我們平時編譯好的 js 是無法再被其他模組 import 的,這是為什麼?

模組依賴的優化

按需載入的原理

我們在使用各大 UI 元件庫時都會被介紹到為了避免引入全部檔案,請使用 babel-plugin-component 等babel 外掛。

import { Button, Select } from 'element-ui'
複製程式碼

由前文可知 import 會先轉換為 commonjs, 即

var a = require('element-ui');
var Button = a.Button;
var Select = a.Select;
複製程式碼

var a = require('element-ui'); 這個過程就會將所有元件都引入進來了。

所以 babel-plugin-component就做了一件事,將 import { Button, Select } from 'element-ui' 轉換成了

import Button from 'element-ui/lib/button'
import Select from 'element-ui/lib/select'
複製程式碼

即使轉換成了 commonjs 規範,也只是引入自己這個元件的js,將引入量減少到最低。

所以我們會看到幾乎所有的UI元件庫的目錄形式都是

|-lib
||--component1
||--component2
||--component3
|-index.common.js
複製程式碼

index.common.jsimport element from 'element-ui' 這種形式呼叫全部元件。

lib 下的各元件用於按需引用。

這裡解釋了問題2

經常在各大UI元件引用的文件上會看到說明 import { button } from 'xx-ui' 這樣會引入所有元件內容,需要新增額外的 babel 配置,比如 babel-plugin-component

tree-shaking

webpack2 開始引入 tree-shaking 技術,通過靜態分析 es6 的語法,可以刪除沒有被使用的模組。他只對 es6 的模組有效,所以一旦 babel 將 es6 的模組轉換成 commonjs,webpack2 將無法使用這項優化。所以要使用這項技術,我們只能使用 webpack 的模組處理,加上 babel 的es6轉換能力(需要關閉模組轉換)。

最方便的使用方法為修改babel的配置。

use: {
     loader: 'babel-loader',
     options: {
       presets: [['babel-preset-es2015', {modules: false}]],
     }
   }
複製程式碼

修改最開始demo

// webpack

const path = require('path');

module.exports = {
  entry: './a.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [['babel-preset-es2015', {modules: false}]],
          }
        }
      }
    ]
  }
};

複製程式碼

// a.js
import a from './c';

export default 'a.js';
console.log(a);

複製程式碼

// c.js

export default 333;

const foo = 123;
export { foo };

複製程式碼

修改的點在於增加了babel,並關閉其modules功能。然後在 c.js 中增加一個輸出 export { foo },但是 a.js 中並不引用它。

最後在編譯出的 js 中,c.js 模組如下:


"use strict";
/* unused harmony export foo */
/* harmony default export */ __webpack_exports__["a"] = (333);

var foo = 123;
複製程式碼

foo 變數被標記為沒有使用,在最後壓縮時這段會被刪除。

需要說明的是,即使在 引入模組時使用了 es6 ,但是引入的那個模組卻是使用 commonjs 進行輸出,這也無法使用tree-shaking。

而第三方庫大多是遵循 commonjs 規範的,這也造成了引入第三方庫無法減少不必要的引入。

所以對於未來來說第三方庫要同時釋出 commonjs 格式和 es6 格式的模組。es6 模組的入口由 package.json 的欄位 module 指定。而 commonjs 則還是在 main 欄位指定。

這裡解釋了問題6

聽說 es6 還有 tree-shaking 功能,怎麼才能使用這個功能?

19年目標:消滅英語!我新開了一個公眾號記錄一個程式設計師學英語的歷程

有提升英語訴求的小夥伴可以關注公眾號:csenglish 程式設計師學英語,每天花10分鐘交作業,跟我一起學英語吧

相關文章