一文帶你瞭解 JS Module 的始末

袋鼠雲數棧UED發表於2023-03-14

寫在前面

模組化開發是我們日常工作潛移默化中用到的基本技能,發展至今非常地簡潔方便,但開發者們(指我自己)卻很少能清晰透徹地說出它的發展背景, 發展過程以及各個規範之間的區別。故筆者決定一探乾坤,深入淺出學習一下什麼是前端模組化。
透過本文,筆者希望各位能夠收穫到:

  • 前端模組化發展的大致歷史背景 ?
  • 各個規範之間的基本特性和區別 ??
  • 著重深入 ESM 和 CommonJs 的異同、優缺點 ???
  • 深耕 CommonJS 和 ESM 的特性 ????

本文的重點會以大家熟知的 CommonJSESM 入手,深入淺出,結合示例 Demo 和一些小故事,希望給大家能夠帶到不一樣的體驗。

一、前端模組化背景

某個技術的起源幾乎都是為了解決一些棘手的問題,模組化也不例外。下面以一個簡單的例子來給大家講個故事,透過故事給大家講一講大致的發展史。故事並未涵蓋所有時間線上發生的事件,眾所周知在前端模組化的長河裡 AMD 和 CMD 一直打的不可開交,這裡筆者挑選以 CMD 為支線向大家闡釋。

本故事的攥寫參考了部分 Sea.js 開源大佬發表在《程式設計師》雜誌 2013 年 3 月刊的文章 (侵刪)

線上連結:前端模組化開發的價值 ,本文推薦大家仔細閱讀,包括評論區。

故事開始! 在很久之前(可能就是2012年之前),JS 模組化概念並未誕生的年代,前端開發們面臨諸多問題:Web 技術雖說日益成熟、JS 能實現的功能也愈發地多,但與此同時程式碼量也是越來越大。那個年代往往會出現一個專案各個頁面公用同一個 JS 的情況,為了解決這個情況,JS 檔案出現了按功能拆分....
慢慢地,專案程式碼變成了如下:

...
...
<script src="util/wxbridge.js"></script>
<script src="util/login.js"></script>
<script src="util/base.js"></script>
<script src="util/auth.js"></script>
<script src="util/logout.js"></script>
<script src="util/pay.js"></script>
...

拆分出來的程式碼類似於如下:

function mapList(list) {
  // 具體實現
}

function canBuyIt(goodId) {
  // 具體實現
}

看似拆分很細,但卻有諸多的致命問題:

  • 全域性變數汙染:各個檔案的變數都是掛載到window物件上,汙染全域性變數;
  • 變數可能重名:不同檔案中的變數如果重名,後一份會覆蓋前面的,造成錯誤;
  • 檔案依賴順序:多個檔案之間存在依賴關係,需要保證一定載入順序問題嚴重......

拿上述 util 工具函式檔案舉例! 大家按規範模像樣地把這些函式統一放在 util.js 裡,需要用到時,直接引入該檔案就好,非常方便。隨著團隊專案越來越大,問題隨之越來越多:

空山:我想定義 mapList 方法遍歷商品列表,但是已經有了,很煩,我的只能叫 mapGoodsList 了。
空河:我自定義了一個 canBuyIt 方法,為什麼使用的時候,空山的程式碼出問題了呢?
滿山:我明明都用了空山的方法,為什麼結果還是不對呢?

經過團隊激烈討論,決定參照 Java 的方式,用 名稱空間 來解決,於是乎程式碼變成了如下:

// 這是新的 Utils.js 檔案

var userObj = {};
userObj.Auth = {};
userObj.Auth.Utils = {};

userObj.Auth.Utils.mapGoodsList = function (list) {
  // 實現
};

userObj.Auth.Utils.canBuyIt = function (goodId) {
  // 實現
};

現在透過名稱空間的方式極大地解決了一部分衝突,但是仔細看上面的程式碼,如果開發人員想要呼叫某一個簡單的方法,就需要他有強大的記憶力,個人負擔變得很重。(這裡值得提一嘴的是,Yahoo 的前端團隊 YUI 採用了名稱空間的解決方式,同時也透過利用沙箱機制很好的解決了名稱空間過長的問題,有興趣的同學可以自行了解)

書接上回。大家現在可以基於 util.js 開發各自的 UI 層通用元件了。舉一個大佬寫的 dialog.js 元件

<script src="util.js"></script>
<script src="dialog.js"></script>
<script>
  org.CoolSite.Dialog.init({ /* 傳入配置 */ });
</script>

可是無論大佬怎麼寫文件,以及多麼鄭重地發郵件宣告,時不時總會有同事詢問為什麼 dialog.js 有問題。透過一番排查,發現導致錯誤的原因經常是在 dialog.js 前沒有引入 util.js。這樣的問題和依賴依然還在可控範圍內,但是當專案越來越複雜,眾多檔案之間的依賴經常會讓人抓狂。下面這些問題,在當時每天都在真實地發生著:

  1. 通用組更新了前端基礎類庫,卻很難推動全站升級。
  2. 業務組想用某個新的通用元件,但發現無法簡單透過幾行程式碼搞定。
  3. 一個老產品要上新功能,最後評估只能基於老的類庫繼續開發。
  4. 公司整合業務,某兩個產品線要合併。結果發現前端程式碼衝突。
  5. ……

以上很多問題都是因為 檔案依賴 沒有很好的管理起來。在前端頁面裡,大部分指令碼的依賴目前依舊是透過人肉的方式保證。當團隊比較小時,這不會有什麼問題。當團隊越來越大,公司業務越來越複雜後,依賴問題如果不解決,就會成為大問題。檔案的依賴,目前在絕大部分類庫框架裡,比如國外的 YUI3 框架、國內的 KISSY 等類庫,目前是透過配置的方式來解決。拋一個例子,不深究。

YUI.add('my-module', function (Y) {
  // ...
}, '0.0.1', {
    requires: ['node', 'event']
});

上面的程式碼,透過 requires 等方式來指定當前模組的依賴。這很大程度上可以解決依賴問題,但不夠優雅。當模組很多,依賴很複雜時,煩瑣的配置會帶來不少隱患。解決命名衝突和檔案依賴,是前端開發過程中的兩個經典問題,大佬們希望透過模組化開發來解決這些問題,所以 Sea.js 營運而生,再往後,CMD 規範也就水到渠成地形成了。(準確說來是因為先有了優秀的 Sea.js,才在後續更替過程逐漸形成了我們後來人所學習到的 CMD 規範。 )

故事講到這裡要告一段落了,是時候給大夥來個評書總結了。JS 在設計上其實並沒有 模組 的概念,為了讓 JS 變成一個功能強大的語言,業界大佬們各顯神通,定了一個名為 CommonJS 的規範,實現了一個名為模組 的東西。但可惜當時環境下大多瀏覽器並不支援,只能用於 node.js,於是 CommonJS 開始分裂,變異了一個名為 AMD 規範的模組,可以用於瀏覽器端。由於 AMD 與 CommonJS 規範相去甚遠,於是 AMD 自立門戶,並且推出了 require.js 這個框架,用於實現並推廣 AMD 規範。此時,CommonJS 的擁護者認為,瀏覽端也可以實現 CommonJS 的規範,於是稍作改動,推出了 sea.js 這個框架並形成了 CMD 規範。
正在 AMD 與 CMD 打得火熱的時候,ECMAScript6 給 JS 本身定了一個模組載入的功能,彎道超車:“你們倆別爭了,JS 模組有原生的語法了”。
再後來,正因為 AMD 與CommonJS 如此不同,且用於不同的環境,為了能夠相容兩個平臺,UMD 就應運而生了,不過它僅僅是一個 polyfill,以相容兩個平臺而已,嚴格意義上來說不能成為一種標準規範。

file

至此,大致歷史背景已講述完畢,上文出現的各大規範名詞,接下來會跟大家見面。

二、模組化規範介紹

大致瞭解背景之後,接下來認真地跟各位探討一下各大規範。
開始之前,想說明一下,針對於 AMD 和 CMD,筆者不打算帶各位做原始碼級別的深究,筆者希望大家只是做一個瞭解或回顧,隨後將重心放至第三、四章的 CommonJSEMS 中。

老大哥 CommonJS

介紹

2009年,美國程式設計師 Ryan_Dahl 創造了 node.js 專案,將 JS 用於伺服器端程式設計。這標誌《 JS 模組化程式設計》正式誕生。不同於純前端的伺服器端,是一定要有模組的概念的,它與作業系統或其他應用程式有著各種各樣的互動,否則程式設計會大受限制,甚至根本無法程式設計。
Node.js 後端程式設計中最重要的思想之一就是 “模組” ,正是這個思想,讓 JavaScript 的大規模工程成為可能。也是基於此,隨後在瀏覽器端,require.js 和 sea.js 之類的工具包也出現了;在 ES module 被完全實現之前,CommonJs 統治了之前時代模組化程式設計的大半江山,它的出現也彌補了當時 JS 對於模組化沒有統一標準的缺陷。

簡單舉例 ?

在 CommonJS 中, 模組通常使用 module.exportsexports,有一個全域性性方法 require(),用於載入模組,如下:(module.exports 和 exports 後文有做闡述,此處暫且不表)

// 匯出  a.js
module.exports = function sumIt(a,b){
    return a + b
}

// 引入  main.js
const sumIt = require('./a.js');
console.log('sumIt===', sumIt(1,2));

AMD 自立門戶

簡介

AMD -- Asynchronous Module Definition(非同步模組定義)。它誕生於 Dojo 在使用 XHR+eval 時的實踐經驗,其支持者希望未來的解決方案都可以免受由於過去方案的缺陷所帶來的麻煩。由於 CommonJS 奠定了伺服器模組規範,大家便開始考慮客戶端模組,而且想兩者可以相容,讓一個模組可以同時在伺服器和瀏覽器執行。
但是 CommonJS 是同步載入模組,伺服器所有模組都存放在本地,硬碟讀取時間很快,但對於瀏覽器來說,等待時間則取決於網速的快慢,如果時間過長,瀏覽器可能會處於“假死”。例如剛剛 main.js 的程式碼,當我們呼叫 sumIt(1,2) 的時候, 瀏覽器需要等待 a.js 載入完才能進行計算,所以瀏覽器端的模組化使用同步載入是有缺陷的,需用非同步載入取代之,這也就是 AMD 規範誕生的背景。
AMD 採用非同步方式載入模組,讓模組的載入不影響它後面語句的執行。所有依賴這個模組的語句,都定義在一個回撥函式中,等到載入完成之後,這個回撥函式才會執行。

AMD 規範詳覽看這裡
AMD 模組的設計模式請看這裡

簡單舉例 ?

define(id?, dependencies?, factory)
// id: 字串,模組名稱(可選)
// dependencies: 表示需要載入的依賴模組(可選)
// factory: 工廠方法,返回一個模組函式,也可理解為載入成功後的回撥函式
//引入依賴 ,回撥函式透過形參傳入依賴
define(['Module1', ‘Module2’], function (Module1, Module2) {
  function testIt () {
      /// 業務程式碼
      Module1.test();
  }
  return testIt
});
require([module],callback())
define(function (require, exports, module) {
    var yourModule = require("./yourModule");
    yourModule.test();
    exports.yourKey = function () {
        //...
    }
});

不難發現,AMD 的優點是適合在瀏覽器環境中非同步載入模組。可以並行載入多個模組。
而缺點是提高了開發成本,並且不能按需載入,而是必須提前載入所有的依賴。

CMD -- 簡單純粹

簡介

Common Module Definition 背景有講,不多贅述,Sea.js 在推廣中對模組定義的規範化產出,推崇依賴就近,延遲執行

簡單舉例 ?

//AMD
define(['./a','./b'], function (a, b) {
    //依賴一開始就寫好
    a.xxx();
    b.xxx();
});

//CMD
define(id?, function (requie, exports, module) {
    // 依賴可以就近書寫
    var a = require('./a');
    a.xxx();

    // 軟依賴
    if (status) {
        var b = requie('./b');
        b.xxx();
    }
});

// require 是一個方法,用來獲取其他模組提供的介面

// exports 是一個物件,用來向外提供模組介面

// module  是一個物件,上面儲存了與當前模組相關聯的一些屬性和方法

CMD 規範看這裡

AMD 和 CMD 對比

  1. 對於依賴的模組 AMD 是 提前執行,CMD 是 延遲執行。不過 Require.js 從2.0開始,也改成可以延遲執行(根據寫法不同,處理方式不透過)。
  2. AMD 推崇 依賴前置(在定義模組的時候就要宣告其依賴的模組),CMD 推崇 依賴就近(只有在用到某個模組的時候再去 require —— 按需載入)。
  3. AMD 的 api 預設是一個當多個用,CMD 嚴格的區分推崇職責單一。例如:AMD 裡 require 分全域性的和區域性的。CMD 裡面沒有全域性的 require, 提供 seajs.use() 來實現模組系統的載入啟動。CMD 裡每個API 都更簡單純粹。引用一下玉伯 2012 年的自評:

file

簡談下 -- UMD

網路上關於 UMD (Universal Module Definition) 通用模組規範的說法五花八門,這裡筆者不做任何評論,只做一個通用型認知的總結: UMD 像一種 polyfill,相容支援多個模組規範。
參考引用:點這裡可以看一下娜娜關於 UMD 的解釋
UMD 理念、規範等官方資料: https://github.com/umdjs/umd
看一個簡單的例子:

output: {
    path: path.join(__dirname),
    filename: 'index.js',
    libraryTarget: "umd",//此處是希望打包的外掛型別
    library: "Swiper",
}

看一下打包之後:

!function(root,callback){
"object"==typeof exports&&"object"==typeof module?//判斷是不是nodejs環境
    module.exports=callback(require("react"),require("prop-types"))
    :
    "function"==typeof define&&define.amd?//判斷是不是requirejs的AMD環境
        define("Swiper",["react","prop-types"],callback)
        :"object"==typeof exports?//相當於連線到module.exports.Swiper
            exports.Swiper=callback(require("react"),require("prop-types"))
            :
            root.Swiper=callback(root.React,root.PropTypes)//全域性變數
}(window,callback)

新大哥 ESM

使用 Javascript 中一個標準模組系統的方案。
在此之前的時期,社群在經歷了 AMD 和 CMD 洗禮後提出了一種想法:既然都是 JS 規範,Node.js 模組能被瀏覽器環境下的 JS 程式碼隨意引用嗎?能! 本著這個想法,ES6 (ECMAScript 6th Edition, 後來被命名為 ECMAScript 2015) 於 2015年6月17日 橫空出世,主要被人熟知的其中一個特性就是 es6 module, 下文簡稱為 ESM。具體深耕內容請詳見第四章,在此介紹章節不過多贅述。

import React from 'react';
import { a, b } from './myPath';
......
export default {
  function1,
  const1,
  a,
  b
}
  1. 在很多現代瀏覽器可以使用
  2. 它兼具兩方面的優點:具有 CJS 的簡單語法和 AMD 的非同步
  3. 得益於 ES6 的靜態模組結構,可以進行  Tree Shaking
  4. ESM 允許像 Rollup 這樣的打包器刪除不必要的程式碼,減少程式碼包可以獲得更快的載入
  5. 可以在 HTML 中呼叫,如下
<script type="module">
  ...
  import { test } from 'your-path';
  test();
  ...
<script/>

三、CommonJS 的深耕

CJS 的簡單使用

先看一個簡單的 Demo:

let str = 'a檔案匯出'
module.exports = function logIt  (){
    return str
}
const logIt = require('./a.js')
module.exports = function say(){
    return {
        name: logIt(),
        sex: 1
    }
}

以上便是 CJS 最簡單的實現,那麼現在我們要帶著問題了:

  1. module.exports,exports 的本質區別是什麼????
  2. require 的載入設計是怎樣的????
  3. CJS 的優缺點和與 ESM 的異同是什麼????

CJS 的實現原理

每個模組檔案上存在 module,exports,require 三個變數(在 nodejs 中還存在 __filename 和 __dirname 變數),然而這幾個變數是沒有被定義的,但是我們可以在 Commonjs 規範下每一個 JS 模組上直接使用它們。

  • module 記錄當前模組資訊。
  • require 引入模組的方法。
  • exports 當前模組匯出的屬性
  • __dirname 在 node 中表示被執行 js 檔案的絕對路徑
  • __filename 在 node 中表示被執行 js 檔案的檔名

在編譯過程中,Commonjs 會對 JS 的程式碼塊進行包裝, 以上述的 b.js 為 ?,包裝之後如下:

(function(exports,require,module,__filename,__dirname){
    const logIt = require('./a.js')
    module.exports = function say(){
        return {
            name: logIt(),
            sex: 1
        }
    }
})

如何執行包裝的呢? 讓我們來看看包裝函式的本質:

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

// 然後是包裝函式的執行
const modulefunction = wrapper(`
  const logIt = require('./a.js')
    module.exports = function say(){
        return {
            name: logIt(),
            sex: 1
        }
    }
`)

script 為我們在 js 模組中寫的內容,最後返回的就是如上包裝之後的函式。當然這個函式暫且是一個字串。在模組載入的時候,會透過 runInThisContext (可以理解成 eval ) 執行 modulefunction ,傳入require ,exports ,module 等引數。最終我們寫的 node.js 檔案就執行了。(真實的 runInThisContext 函式執行思路和上述一致,但實現細節不一樣)

 runInThisContext(
   modulefunction
 )(module.exports, require, module, __filename, __dirname)

實現詳情請參照官方文件: runInThisContext 的官方文件和示例
到此,整個模組執行的原理大致梳理完畢。??

require 的檔案載入流程

先以 node.js 為例,看一個簡單的程式碼片段

const fs = require('fs');  
const say = require('./b.js');
const moment = require('moment');

先對檔案模組做一個簡單的分類:

  • fs 為 nodejs 底層的核心模組,其他常見的還有 path、http 模組等;
  • b.js 為我們編寫的檔案模組;
  • ./ 和 ../ 作為 相對路徑 的檔案模組, / 作為 絕對路徑 的檔案模組。
  • moment 為自定義模組,其他常見的還有 crypto-js 等;像此類非路徑形式也非核心的模組,將作為自定義模組。

當 require 方法執行的時候,接收的唯一引數作為一個 識別符號
CJS 下對不同的識別符號處理流程不同,但是目的都是找到對應的模組。

require 識別符號載入原則

此章節借鑑了 @我不是外星人 的優秀文章中的部分內容(侵刪)
線上連結:《深入淺出 Commonjs 和 Es Module》
筆者在巨人的肩膀上做了一些 Curd 潤色,供大家享用 ?

  • 快取載入:已經被載入過一次的模組,會被記錄放入快取中;
  • 核心模組:優先順序僅次於 快取載入,在 Node 原始碼編譯中,已被編譯成二進位制程式碼,所以載入核心模組速度最快;
  • 路徑模組:已 ./ ,../ 和 / 開始的識別符號,會被當作檔案模組處理。require() 方法會將路徑轉換成真實路徑,並以真實路徑作為索引,將編譯後的結果放入快取,方便二次載入。
  • 自定義塊:在當前目錄下的 node_modules 目錄查詢。如果沒有,在父級目錄的 node_modules 查詢...... 直到根目錄下的 node_modules 目錄為止。在查詢過程中,會找 package.json 下 main 屬性指向的檔案,如果沒有 package.json ,在 node 環境下會以此查詢 index.js ,index.json ,index.node。
  • 從 Node.js 12+ 起,載入第三方模組時,exports 欄位優先順序比 main 欄位要高

file

require 模組引入與處理

CommonJS 模組同步載入並執行模組檔案,CommonJS 模組在執行階段分析模組依賴

const logIt = require('./b');
console.log('我是 a 檔案');
exports.say = function(){
    const message = logIt();
    console.log(message);
}
const say = require('./a');
const obj = {
   name:'b 檔案的 object 的 name',
   author:'b 檔案的 object 的 author'
}
console.log('我是 b 檔案');
module.exports = function(){
    return obj
}
const a = require('./a');
const b = require('./b');

console.log('我是 main 檔案');

執行一下:
file

?️?️?️ 問題:

  • main.js 和 a.js 模組都引用了 b.js 模組,但是 b.js 模組為什麼只執行了一次?
  • a.js 模組 和 b.js 模組互相引用,但是為什麼沒有迴圈引用報錯?

我們先引入一個上文並未提及的概念:Module 和 module
module :在 Node 中每一個 js 檔案都是一個 module ,module 上儲存了 exports 等資訊之外,
還有一個 loaded ( boolean 型別)表示該模組是否已經被載入過。
Module :以 nodejs 為例,整個系統執行之後,會用 Module 快取每一個模組載入的資訊。

然後,在回答上述思考問題之前,一起來看一下阮一峰老師關於 require 的原始碼解讀:

 // id 為路徑識別符號
function require(id) {
   /* 查詢  Module 上有沒有已經載入的 js  物件*/
   const  cachedModule = Module._cache[id]
   
   /* 如果已經載入了那麼直接取走快取的 exports 物件  */
  if(cachedModule){
    return cachedModule.exports
  }
 
  /* 建立當前模組的 module  */
  const module = { exports: {} ,loaded: false , ...}

  /* 將 module 快取到  Module 的快取屬性中,路徑識別符號作為 id */  
  Module._cache[id] = module
  /* 載入檔案 */
  runInThisContext(wrapper('module.exports = "123"'))
  (module.exports, require, module, __filename, __dirname)
  /* 載入完成 *//
  module.loaded = true 
  /* 返回值 */
  return module.exports
}

程式碼還是非常容易理解的,解讀總結如下:

require 會接收一個引數(檔案識別符號),然後分析定位檔案(上一小節已經講到),接下來從 Module 上查詢有沒有快取,如果有快取,那麼直接返回快取的內容。

如果沒有快取,會建立一個 module 物件,快取到 Module 上,然後執行檔案;載入完檔案,將 loaded 屬性設定為 true ,然後返回 module.exports 物件。

模組匯出其實跟 a = b 賦值一樣:基本型別匯出的是值, 引用型別匯出的是引用地址。(exports 和 module.exports 持有相同引用,後文會專門解讀)

require 避免迴圈引用

我們先來分析剛剛的例子,下面先用一幅圖來表示 a.js 的載入流程:
file

理解了這幅流程圖後,再來看完整的流程圖就不再吃力了:
file

此時我們需要注意一點:
當我們第一次執行 b.js 模組的時候,a.js 還沒有匯出 say 方法,所以此時在 b.js 同步上下文中,是獲取不到 say 的,那麼如果想要獲取 say ,辦法有兩個:

非同步載入

const say = require('./a');
const obj = {
   name:'b 檔案的 object 的 name',
   author:'b 檔案的 object 的 author'
}
console.log('我是 b 檔案');

setTimeout(()=>{
    console.log('非同步列印 a 模組' , say)
},0)

module.exports = function(){
    return obj
}

動態載入

console.log('我是 a 檔案');
exports.say = function(){
    const logIt = require('./b');
    const message = logIt();
    console.log(message);
}
const a = require('./a');
a.say();

由此我們可見:
require 本質上就是一個函式,那麼函式可以在任意上下文中執行,自由地載入其他模組的屬性方法。

require 避免重複載入

正如上述所言,載入之後的檔案的 module 會被快取到 Module 上,比如一個模組已經 require 引入了 a 模組,如果另外一個模組再次引用 a ,那麼會直接讀取快取值 module ,所以無需再次執行模組。

對應 demo 片段中,首先 main.js 引用了 a.js ,a.js 中 require 了 b.js。 此時 b.js 的 module 放入快取 Module 中,接下來 main.js 再次引用 b.js ,那麼直接走的快取邏輯,所以 b.js 只會執行一次,也就是在 a.js 引入的時候,由此就避免了重複載入。

?????? 這裡給大家拋一個思考問題:

// a.js
const b = require('./b');
console.log('我是 a 檔案',b);
const tets =  Object.getPrototypeOf(b);
tets.aaa = 'new aaa test';

// b.js
console.log('我是 b 檔案');
module.exports = {
    str: 'bbbb'
}

// main.js
require('./a');
const b = require('./b');
console.log('b===', b);
console.log('proto===', Object.getPrototypeOf(b));

??? 看完這個事例,你有什麼啟發嗎?是不是和第三方侵入式的工具庫很像呢?

exports 和 module.exports

module.exports 和 exports 在一開始都是一個空物件 { },但實際上,這兩個物件應當是指向同一塊記憶體的。在不去改變它們指向的記憶體地址的情況下,module.exports 和 exports 幾乎是等價的。

require 引入的物件本質上其實是 module.exports 。那麼這就產生了一個問題,當 module.exports和 exports 指向的不是同一塊記憶體時,exports 的內容就會失效。

module.exports = { money: '20塊 ?' };
exports.money = '一伯萬!!!?';

這時候,require 真實得到的是 { money: '20塊 ?' } 。當他們二者 同時存在 的時候,會發生覆蓋的情況,所以我們通常最好選擇 exports 和 module.exports 兩者之一。

  • 思考問題1: 上述例子使用 exports = { money: '200' } 這種形式賦值物件可以嗎?

答:不可以。透過上述講解都知道 exports , module 和 require 作為形參的方式傳入到 js 模組中。我們直接 exports = { } 修改 exports ,等於重新賦值了形參,但是不會在引用原來的形參。舉個例子:

function change(myName){
    return myName.name = {
       name: '老闆'
   }
}

let myName = {
   name: '小打工人'
}

fix(myName);
console.log(myName);

  • 簡單來說 module.exports 是給 module 裡面的 exports 屬性賦值,值可以是任何型別;
  • exports 是個物件,用它來暴露模組資訊必須給它新增對應的屬性;
  • 需要注意的是:module.exports 當匯出一些函式等非物件屬性的時候,也有一些風險,就比如迴圈引用的情況下。物件會保留相同的記憶體地址,就算一些屬性是後繫結的,也能透過非同步形式訪問到。

四、ES Module 的深耕

匯入和匯出

// 匯出 a.js
const name = 'jiawen'; 
const game = 'lol';
const logIt = function (){
    console.log('log it !!!')
}
export default { 
  name, 
  author,
  logIt
}


// 引入 main.js
import { name , author , logIt } from './a.js'

// 對於引入預設匯出的模組,可以自定義名稱。
import allInfo from './a.js'

對於 ESM 規範中混合匯出方式,日常使用,這裡不再做舉例。

提一下 “重署名匯入和重定向匯出”:

import {  name as newName , say,  game as newGame  } from '/a.js';
console.log( newName , newGame , say );
export * from 'module'; // 1
export { name, author, ..., say } from 'module'; // 2
export { name as newName ,  game as newGame , ..., say } from '/a.js'; // 3 

只執行,不關心匯入:

import '/a.js' 

動態匯入:

import asyncComponent from 'dt-common/src/utils/asyncLoad';

let lazy = (async, name) => {
    return asyncComponent(
      () => async.then((module: any) => module.default), { name }
    )
}

const ApiManage = lazy(import('./views/dataService/apiManage'), 'apiManage');
  • 動態匯入 import('xxx') 返回一個 Promise. 使用時可能需要在 webpack 中做相應的配置處理。

ESM 的靜態語法

  • ES6 module 的引入和匯出是靜態的,import 會自動提升到程式碼的頂層。靜態的語法意味著可以在編譯時確定匯入和匯出,更加快速的查詢依賴,可以使用 lint 工具對模組依賴進行檢查,可以對匯入匯出加上型別資訊進行靜態的型別檢查。
  • import , export 不能放在塊級作用域或條件語句中。(錯誤示範就不再舉例了)
  • import 的匯入名不能為字串或在判斷語句中,不可以用模版字串拼接的方式。

ESM 的執行特性

  • 使用 import 匯入的模組執行在嚴格模式
  • 使用 import 匯入的變數是只讀的。(可以理解預設為 const 裝飾,無法被賦值)
  • 使用 import 匯入的變數是與原變數繫結/引用的,可以理解為 import 匯入的變數無論是否為基本型別都是引用傳遞,請看下面的例子:
// js中 基礎型別是值傳遞
let a = 1;
let b = a;
b = 2;
console.log(a, b) // 1 2

// js中 引用型別是引用傳遞
let a = { name: 'xxx' };
let b = obj
b.name = 'bbb'
console.log(a.name, b.name) // bbb  bbb
// a.js
export let a = 1
export function add(){
  a++
}

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

ESM 的 import ()

剛剛已經舉過 import () 在 TagEngine 裡實際應用的例子,其核心在於返回一個 Promise 物件, 在返回的 Promise 的 then 成功回撥中,可以獲取模組的載入成功資訊。下面舉一些 import () 的社群常用:

  • Vue 中的懶載入:
[
  ...
   {
        path: 'home',
        name: '首頁',
        component: ()=> import('./home') ,
   },
  ...
]
  • React 中的懶載入
const LazyComponent = React.lazy(() => import('./text'));
class index extends React.Component {
    render() {
        return (
            <React.Suspense
                fallback={
                    <div className="icon">
                        <SyncOutlinespin />
                    </div>
                }
            >
                <LazyComponent />
            </React.Suspense>
        );
    }
}

import() 這種載入效果,可以很輕鬆的實現程式碼分割, 避免一次性載入大量 js 檔案,造成首次載入白
屏時間過長的情況。

ESM 的迴圈引用

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

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

// main.js
import { f1 } from './f1'
console.log(bar)

此時會報錯 f1 未定義,我們可以採用函式宣告,因為函式宣告會提示到檔案頂部,所以就可以直接在 f2.js 呼叫還沒執行完畢的 f1.js的 f1 方法,但請不要在函式內使用外部變數 !!!!

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

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

// main.js
import { f1 } from './f1'
console.log(f1)

Tree Shaking 和 DCE

DCE: dead code elimination。簡稱 DCE。死程式碼消除

Tree Shaking 在 Webpack 中的實現是用來儘可能的刪除一些被 import 了但其實沒有被使用的程式碼。

export let num = 1;
export const fn1 = ()=>{
    num ++
}
export const fn2 = ()=>{
    num --
}
import { fn1 } from './a'
fn1();
  • 如上 a.js 中暴露兩個方法,fn1 和 fn2,但是在 main.js 中,只用到了 fn1,那麼構建打包的時候,fn2將作為沒有引用的方法,不被打包進來。
  • tree shaking 和 “死程式碼剔除” 是有本質區別的,“做一個?蛋糕,死程式碼剔除是扔一個雞蛋進去,做好蛋糕後把雞蛋殼拿出來;tree shaking 是先檢查並儘可能地剔除沒有用到的部分,比如雞蛋殼,再去做蛋糕。” 這二者還是有一些本質區別的。

五、ESM 與 CJS 的小結

CommonJS -- 小結

  • CommonJS 模組由 JS 執行時實現。
  • CommonJs 是單個值匯出,本質上匯出的就是 exports 屬性。
  • CommonJS 是可以動態載入的,對每一個載入都存在快取,可以有效的解決迴圈引用問題。
  • CommonJS 模組同步載入並執行模組檔案。

Es Module -- 小結

  • ES6 Module 靜態的,程式碼發生在編譯時,不能放在塊級作用域內,但可以動態匯入。
  • ES6 Module 的值是動態繫結的,可以透過匯出方法修改,可以直接訪問修改結果。
  • ES6 Module 可以匯出多個屬性和方法,可以單個匯入匯出,混合匯入匯出。
  • ES6 模組提前載入並執行模組檔案,匯入模組在嚴格模式下。
  • ES6 Module 的特性可以很容易實現 Tree Shaking 和 Code Splitting。

六、待探究的問題

此章節探討 ESM 與 CMJ 的互轉,?歡迎各位補充指正!!!?

特別鳴謝參考文章,排名不分先後:
https://github.com/amdjs/amdjs-api/wiki/AMD
https://github.com/seajs/seajs/issues/242
https://github.com/umdjs/umd
https://juejin.cn/post/6994224541312483336#heading-20
https://segmentfault.com/a/1190000017878394

??? 完結 ???

相關文章