1. 程式人生 > 實用技巧 >JavaScript模組化筆記

JavaScript模組化筆記

JavaScript模組化筆記

一個模組就是一堆被封裝到一個檔案當中的程式碼,並使用export暴露部分程式碼給其他的檔案。模組專注於一小部分功能並與應用的其他部分鬆耦合,這是因為模組間沒有全域性變數或共享變數,他們僅通過暴露的模組程式碼的一部分來進行通訊。任何你想在另一個檔案中訪問的程式碼都可以被封裝為模組。

模組化歷史

沒有模組的時代

JavaScript剛出現時就是一個從上到下執行的指令碼語言,簡單的邏輯可以編寫在一整個檔案裡,沒有分塊需求

模組化萌芽時代

Ajax的提出讓前端變成了集許多功能為一身的類客戶端,前端業務邏輯越來越複雜,程式碼越來越多,此時有許多問題

  1. 所有變數都定義在一個作用域,造成變數汙染
  2. 沒有名稱空間,導致函式命名衝突
  3. HTML引入JavaScript時需要注意順序依賴,多檔案不好協調

此時的一些解決方案

  1. 用自執行函式來包裝程式碼,var將變數宣告在區域性作用域。但是還是會生成modA全域性變數

    modA = function(){
         var a = 2, b = 3; //變數a、b外部不可見
         return {
              add : function(){
                   console.log(a, b);
              }
         }
    }()
    
  2. 為了避免全域性變數衝突的Java包命名風格,麻煩複雜而且還是掛載在全域性變數上

    app.util.modA = xxx;
    app.tools.modeA = xxx;
    
  3. IIFE匿名自執行函式,將函式內容放在括號中防止其內部變數洩露,函式接受window並將其需要對外放開的功能掛載在全域性變數上

    (function(window) {
        // ...
        window.jQuery = window.$ = jQuery;
    })(window);
    

模組化需要解決的問題

  1. 如何安全的不汙染模組外程式碼的方式包裝一個模組的程式碼
  2. 如何標識唯一的模組從而能被外部輕易呼叫
  3. 如何既不增加全域性變數也能把模組API暴露出去
  4. 如何在其他模組內方便的引入所依賴的模組

模組化

CommonJS

CommonJS的模組定義如下

  1. 模組的識別符號
    • 使用/分割的由片語成的字串
    • 詞必須是駝峰格式可以使用...
    • 模組識別符號不能新增檔案的副檔名例如.js
    • 模組識別符號可以是相對路徑或頂層標識,相對的識別符號使用...開頭
    • 頂層識別符號相對於在虛擬模組名稱空間根上解析
    • 相對識別符號相對於呼叫該相對識別符號的模組位置解析
  2. 模組上下文
    • 在模組中有一個require函式,該函式接受一個模組識別符號,返回被require的依賴模組中被export暴露的API,如果依賴中有依賴則依次載入這些依賴;如果被請求的模組不能被返回,require函式會丟擲一個異常
    • 在模組中有一個exports物件變數,模組需要在執行過程中向其新增需要被暴露的API
    • 模組執行使用exports執行匯出

CommonJS的例子

// 定義模組math.js
var basicNum = 0;
function add(a, b) {
  return a + b;
}
module.exports = { //在這裡寫上需要向外暴露的函式、變數
  add: add,
  basicNum: basicNum
}

// 引用自定義的模組時,引數包含路徑,可省略.js
var math = require('./math');
math.add(2, 5);

// 引用核心模組時,不需要帶路徑
var http = require('http');
http.createService(...).listen(3000);

CommonJS是執行時動態同步載入模組,模組被載入為物件,而瀏覽器如果在執行時載入需要單獨下載模組檔案開銷很大,所以一般被用在伺服器這種本地環境中例如Nodejs

// CommonJS
let { stat, exists, readfile } = require('fs');

// 等同於
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;

AMD

AMD(Asynchronous Module Definition),使用非同步方式載入模組,模組載入不影響後面語句的執行。Require.js實現了AMD規範。AMD使用require.config()執行路徑等配置、define()定義模組,require()載入模組。

AMD推崇依賴前置(definerequire函式直接傳入依賴ID,依賴將進入factory中作為引數)、提前執行(直接依賴前置時載入的模組將會被先執行一次,除非使用後置require的方法,實際這個問題經過實驗已被解決,所有模組將不會被提前執行一遍)

AMD的規範源於CommonJS所以其中的定義與CommonJS有許多相似之處

  • 使用define()函式用來定義模組,函式接受三個引數
    • id類似CommonJS的模組識別符號,可選
    • dependencies依賴的模組ID陣列,可選,依賴會先於後面介紹的工廠函式執行,依賴獲取結果也會引數形式傳入工廠引數,預設為["require", "exports", "module"]
    • factory工廠引數,用來例項化一個模組或物件,如果工廠是一個函式則會被執行一次返回值作為模組對外暴露的值,如果工廠是一個物件,那麼物件將會被作為工廠對外暴露的值。暴露值的方法有三種:returnexports.xxx=xxxmodule.exports=xxx
  • Require.js中的require()引用函式,函式接受兩個引數
    • dependencies依賴的模組ID陣列,如define()中的dependencies差不多
    • function利用模組或直接執行的程式碼方法,前面的dependencies會被傳入該方程中

Require.js的例子

// foo/title.js 預設的名稱就會為title
// id預設為title.js當前目錄下查詢
define(["./cart", "./inventory"], function(cart, inventory) {
		//return an object to define the "my/shirt" module.
        return {
            color: "blue",
            size: "large",
            addToCart: function() {
                inventory.decrement(this);
                cart.add(this);
            }
        }
    }
);

require("title.js", function(title) {
    console.log(title.color);
});

// 可以在define中require,但是要把define新增到依賴中
define(["require"], function(require) {
    var mod = require("./relative/name");
});

但是AMD有其自身問題

  • 模組程式碼在被定義時會被執行,不符合預期且開銷較大

  • 羅列依賴模組導致definerequire的引數長度過長

    這一點可以通過在define中使用require解決,當使用這種編寫模式時只有在特別呼叫require的時候才下載該模組的程式碼

    define(function(){
         console.log('main2.js執行');
    
         require(['a'], function(a){
              a.hello();    
         });
    
         $('#b').click(function(){
             // 只有在使用者點選該按鈕後才會下載
              require(['b'], function(b){
                   b.hello();
              });
         });
    });
    

    AMD還部分相容Modules/Wrappings寫法

    // d.js factory的形參得寫上
    define(function(require, exports, module){
         console.log('d.js執行');
         return {
              helloA: function(){
                   var a = require('a');
                   a.hello();
              },
              run: function(){
                   $('#b').click(function(){
                        var b = require('b');
                        b.hello();
                   });
              }
         }
    });
    

CMD

CMD(Common Module Definition)是淘寶前端根據Modules/Wrappings規範結合了各家所長,支援CommonJS的exports和module.exports語法,支援AMD的return的寫法,暴露的API可以是任意型別的。

CMD推崇依賴就近(require在呼叫依賴緊前呼叫)、延遲執行(不會在剛下載完成就執行,而是等待使用者呼叫)

//a.js
define(function(require, exports, module){
     console.log('a.js執行');
     return {
          hello: function(){
               console.log('hello, a.js');
          }
     }
});

//b.js
define(function(require, exports, module){
     console.log('b.js執行');
     return {
          hello: function(){
               console.log('hello, b.js');
          }
     }
});

//main.js
define(function(require, exports, module){
     console.log('main.js執行');
     var a = require('a');
     a.hello();    
     $('#b').click(function(){
          var b = require('b');
          b.hello();
     });
});

sea.js實現了CMD標準,它通過對函式toString()並正則匹配到require語句來分析依賴,所有依賴將會被預先下載並延遲執行。如果想延遲下載可以使用require.asyncAPI。

AMD對比CMD

/** AMD寫法 amd.js **/
define(["a", "b", "c", "d", "e", "f"], function(a, b, c, d, e, f) { 
     // 等於在最前面宣告並初始化了要用到的所有模組
    a.doSomething();
    if (false) {
        b.doSomething()
    }
});

require(["amd.js"])

/** CMD寫法 **/
define(function(require, exports, module) {
    var a = require('./a'); //在需要時申明
    a.doSomething();
    if (false) {
        var b = require('./b');
        b.doSomething();
    }
});

/** sea.js **/
// 定義模組 math.js
define(function(require, exports, module) {
    var $ = require('jquery.js');
    var add = function(a,b){
        return a+b;
    }
    exports.add = add;
});
// 載入模組
seajs.use(['math.js'], function(math){
    var sum = math.add(1+2);
});

其實現在的情況是:

  • AMD的require.js如果使用dependencies中定義好了之後會在初始化階段獲取並初始化所有依賴,即使依賴在後續並未被呼叫

    // 內聯JavaScript的呼叫
    require(["./main"]);
    
    // main.js
    define(["./a", "./b"], function(a, b) {
        if (false) {
            a.hello();	// 即使不使用
            b.hello()
        }
    });
    
    // a.js
    define(function() {
        console.log("a init");
        return {
            hello: function() {
                console.log("a.hello executed");
            }
        }
    });
    
    // b.js
    define(function() {
        console.log("b init");
        return {
            hello: function() {
                console.log("b.hello executed");
            }
        }
    });
    
    // 剛開啟頁面上述程式碼執行結果
    // a init b init 看Network三個檔案都被下載
    

    如果使用require,則檔案將會在require之後被載入,如果require未被執行則不下載,下方程式碼中b.js將會在按鈕按下下載與執行

    // 上方main.js更改為
    define(function(){
        console.log('son.js executed');
    
        require(['a'], function(a){
            a.hello();
        });
        document.getElementById("btn").addEventListener("click", function(){
            require(['b'], function(b){
                b.hello();
            });
        });
    });
    
  • CMD直接使用require,且無論require是否執行都會下載程式碼內require()依賴的程式碼,這是因為其正則匹配方式,使用require.async延遲下載

ES6 Module

ES6 Module自動使用嚴格模式,主要有以下限制

  • 變數必須聲明後再使用
  • 函式的引數不能有同名屬性,否則報錯
  • 不能使用with語句
  • 不能對只讀屬性賦值,否則報錯
  • 不能使用字首0表示八進位制數,否則報錯
  • 不能刪除不可刪除的屬性,否則報錯
  • 不能刪除變數delete prop,會報錯,只能刪除屬性delete global[prop]
  • eval不會在它的外層作用域引入變數
  • evalarguments不能被重新賦值
  • arguments不會自動反映函式引數的變化
  • 不能使用arguments.callee
  • 不能使用arguments.caller
  • 禁止this指向全域性物件
  • 不能使用fn.callerfn.arguments獲取函式呼叫的堆疊
  • 增加了保留字(比如protectedstaticinterface

ES6模組化主要由exportimport組成,export命令用於規定模組的對外介面,import命令用於輸入其他模組提供的功能,一個模組是一個獨立的檔案。importexport必須處在模組頂層用於靜態優化,不能在動態執行的程式碼塊中

export命令

export匯出命令規定匯出對外的介面,不能直接輸出值,所以export 1是錯誤的

// profile.js
export var firstName = 'Michael';
export var lastName = 'Jackson';
export var year = 1958;

var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;
export {firstName, lastName, year};

// 預設匯出是其本身的名字
export function multiply(x, y) {
  return x * y;
};

function v1() { ... }
function v2() { ... }
// 可以使用as對匯出內容重新命名
export {
  v1 as streamV1,
  v2 as streamV2,
  v2 as streamLatestVersion
};

export default的其他用法

export default 42;	// 預設值
export default class { ... }

import命令

// main.js 如果非export default需要大括號內變數名需要與模組的對外介面名一致
import {firstName, lastName, year} from './profile';

function setName(element) {
  element.textContent = firstName + ' ' + lastName;
}

// 起別名依舊使用as
import { lastName as surname } from './profile';

靜態執行的import不能與任何動態程式碼進行組合使用。多次重複的import不會重複執行

// 第一組
export default function crc32() { // 輸出
}
import crc32 from 'crc32'; // 輸入

// 第二組
export function crc32() { // 輸出
};
import {crc32} from 'crc32'; // 輸入

使用export default預設匯出時可以不使用大括號因為匯出項只可能有一個,如果想同時輸入預設方法和其他變數可以寫成下面的樣式

import _, { each } from 'lodash';

可以使用*做整體載入

// circle.js
export function area(radius) {
  return Math.PI * radius * radius;
}
export function circumference(radius) {
  return 2 * Math.PI * radius;
}

// main.js
import * as circle from './circle';

console.log('圓面積:' + circle.area(4));
console.log('圓周長:' + circle.circumference(14));

export import複合寫法

export { foo, bar } from 'my_module';

// 可以簡單理解為
import { foo, bar } from 'my_module';
export { foo, bar };

// 介面改名
export { foo as myFoo } from 'my_module';

// 整體輸出
export * from 'my_module';

// 預設介面
export { default } from 'foo';

// 有名字的改成預設介面
export { es6 as default } from './someModule';
// 等同於
import { es6 } from './someModule';
export default es6;


模組的繼承

假設circleplus模組繼承了circle模組。export *會預設忽略circle模組的default方法,然後子模組複寫了其default方法

// circleplus.js
export * from 'circle';
export var e = 2.71828182846;
export default function(x) {
  return Math.exp(x);
}

// 呼叫circleplus.js
import * as math from 'circleplus';
import exp from 'circleplus';
console.log(exp(math.e));

import()

ES6的模組化實現是編譯時載入(靜態載入)、模組輸出值引用的方式。CommonJS中模組引用是值的拷貝,導致修改分別匯出的內容兩者雖然可能在子依賴中有關聯,但是在父模組中不會表現,也就是輸出之後模組本身改變不了已經匯出給其他模組的值

// module.js
var data = 5;
var doSomething = function () {
  data++;
};
// 暴露的介面
module.exports.data = data;
module.exports.doSomething = doSomething;
var example = require('./module.js');
console.log(example.data); // 5
example.doSomething(); 
console.log(example.data); // 5

如果暴露一個getter函式就可以正確取到了

var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  get counter() {
    return counter
  },
  incCounter: incCounter,
};

而在ES6 Module中是值的只讀引用,模組內值父模組和模組本身都可以訪問且修改

// lib.js
export let counter = 3;
export function incCounter() {
  counter++;
}

// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4

ES6 Module的引用可以新增值但是不可以重新賦值,因為匯入的其實是一個只讀的物件的地址,物件的內容可以修改但是其本身指向不能變

// lib.js
export let obj = {};

// main.js
import { obj } from './lib';

obj.prop = 123; // OK
obj = {}; // TypeError

但是為了實現執行時動態載入,可以使用ES2020提案中引入的import()函式,該函式支援動態載入模組,其接受一個與import命令相似的引數,函式返回一個Promise物件,import是非同步載入,而Node的require是同步載入

const main = document.querySelector('main');

import(`./section-modules/${someVariable}.js`)
  .then(module => {
    module.loadPageInto(main);
  })
  .catch(err => {
    main.textContent = err.message;
  });

使用import()的場景:

  1. 按需載入模組

    button.addEventListener('click', event => {
      import('./dialogBox.js')
      .then(dialogBox => {
        dialogBox.open();
      })
      .catch(error => {
        /* Error handling */
      })
    });
    
  2. 條件載入

    if (condition) {
      import('moduleA').then(...);
    } else {
      import('moduleB').then(...);
    }
    
  3. 動態模組路徑生成,和上方字面量用法相似

    import(f())
    .then(...);
    

import載入成功以後,模組會作為一個物件當作then方法的引數,可以使用物件結構語法獲得輸出介面

import('./myModule.js')
.then(({export1, export2}) => {
  // ...·
});

// default介面可以直接用引數獲得
import('./myModule.js')
.then(myModule => {
  console.log(myModule.default);
});

// 具名輸入
import('./myModule.js')
.then(({default: theDefault}) => {
  console.log(theDefault);
});

多個同時載入

Promise.all([
  import('./module1.js'),
  import('./module2.js'),
  import('./module3.js'),
])
.then(([module1, module2, module3]) => {
   ···
});

可以用在async await函式中

async function main() {
  const myModule = await import('./myModule.js');
  const {export1, export2} = await import('./myModule.js');
  const [module1, module2, module3] =
    await Promise.all([
      import('./module1.js'),
      import('./module2.js'),
      import('./module3.js'),
    ]);
}
main();