徹底掌握前端模塊化

一. 什麼是模塊化開發

1.1. JavaScript 設計缺陷

那麼,到底什麼是模塊化開發呢?

上面說提到的結構,就是模塊;

按照這種結構劃分開發程序的過程,就是模塊化開發的過程;

無論你多麼喜歡 JavaScript,以及它現在發展的有多好,我們都需要承認在 Brendan Eich 用了 10 天寫出 JavaScript 的時候,它都有很多的缺陷:

Brendan Eich 本人也多次承認過 JavaScript 設計之初的缺陷,但是隨着 JavaScript 的發展以及標準化,存在的缺陷問題基本都得到了完善。

在網頁開發的早期,Brendan Eich 開發 JavaScript 僅僅作爲一種腳本語言,做一些簡單的表單驗證或動畫實現等,那個時候代碼還是很少的:

<button id="btn">按鈕</button>

<script>
  document.getElementById("btn").onclick = function() {
    console.log("按鈕被點擊了");
  }
</script>

但是隨着前端和 JavaScript 的快速發展,JavaScript 代碼變得越來越複雜了:

所以,模塊化已經是 JavaScript 一個非常迫切的需求:

在這個章節,我們將詳細學習 JavaScript 的模塊化,尤其是 CommonJS 和 ES6 的模塊化。

1.2. 沒有模塊化的問題

我們先來簡單體會一下沒有模塊化代碼的問題。

我們知道,對於一個大型的前端項目,通常是多人開發的(即使一個人開發,也會將代碼劃分到多個文件夾中):

小明開發了 aaa.js 文件,代碼如下(當然真實代碼會複雜的多):

var flag = true;

if (flag) {
  console.log("aaa的flag爲true")
}

小麗開發了 bbb.js 文件,代碼如下:

var flag = false;

if (!flag) {
  console.log("bbb使用了flag爲false");
}

很明顯出現了一個問題:

但是,小明又開發了 ccc.js 文件:

if (flag) {
  console.log("使用了aaa的flag");
}

問題來了:小明發現 ccc 中的 flag 值不對

備註:引用路徑如下:

<script src="./aaa.js"></script>
<script src="./bbb.js"></script>
<script src="./ccc.js"></script>

所以,沒有模塊化對於一個大型項目來說是災難性的。

當然,我們有辦法可以解決上面的問題:立即函數調用表達式(IIFE)

aaa.js

const moduleA = (function () {
  var flag = true;

  if (flag) {
    console.log("aaa的flag爲true")
  }

  return {
    flag: flag
  }
})();

bbb.js

const moduleB = (function () {
  var flag = false;

  if (!flag) {
    console.log("bbb使用了flag爲false");
  }
})();

ccc.js

const moduleC = (function() {
  const flag = moduleA.flag;
  if (flag) {
    console.log("使用了aaa的flag");
  }
})();

命名衝突的問題,有沒有解決呢?解決了。

但是,我們其實帶來了新的問題:

所以,我們會發現,雖然實現了模塊化,但是我們的實現過於簡單,並且是沒有規範的。

JavaScript 社區爲了解決上面的問題,湧現出一系列好用的規範,接下來我們就學習具有代表性的一些規範。

二. CommonJS 規範

2.1. CommonJS 和 Node

我們需要知道 CommonJS 是一個規範,最初提出來是在瀏覽器意外的地方使用,並且當時被命名爲 ServerJS,後來爲了體現它的廣泛性,修改爲 CommonJS,平時我們也會簡稱爲 CJS。

所以,Node 中對 CommonJS 進行了支持和實現,讓我們在開發 node 的過程中可以方便的進行模塊化開發:

前面我們提到過模塊化的核心是導出和導入,Node 中對其進行了實現:

2.2. Node 模塊化開發

我們來看一下兩個文件:

bar.js

const name = 'coderwhy';
const age = 18;

function sayHello(name) {
  console.log("Hello " + name);
}

main.js

console.log(name);
console.log(age);

sayHello('kobe');

上面的代碼會報錯:

2.2.1. exports 導出

強調:exports 是一個對象,我們可以在這個對象中添加很多個屬性,添加的屬性會導出

bar.js 中導出內容:

exports.name = name;
exports.age = age;
exports.sayHello = sayHello;

main.js 中導入內容:

const bar = require('./bar');

上面這行代碼意味着什麼呢?

main中的bar = bar中的exports

所以,我可以編寫下面的代碼:

const bar = require('./bar');

const name = bar.name;
const age = bar.age;
const sayHello = bar.sayHello;

console.log(name);
console.log(age);

sayHello('kobe');

爲了進一步論證,bar 和 exports 是同一個對象:

2.2.2. module.exports

但是 Node 中我們經常導出東西的時候,又是通過 module.exports 導出的:

我們追根溯源,通過維基百科中對 CommonJS 規範的解析:

但是,爲什麼 exports 也可以導出呢?

注意:真正導出的模塊內容的核心其實是 module.exports,只是爲了實現 CommonJS 的規範,剛好 module.exports 對 exports 對象有一個引用而已;

那麼,如果我的代碼這樣修改了:

你能猜到內存中會有怎麼樣的表現嗎?

2.2.3. require 細節

我們現在已經知道,require 是一個函數,可以幫助我們引入一個文件(模塊)中導入的對象。

那麼,require 的查找規則是怎麼樣的呢?

這裏我總結比較常見的查找規則:

導入格式如下:require(X)

2.2.4. 模塊加載順序

這裏我們研究一下模塊的加載順序問題。

結論一:模塊在被第一次引入時,模塊中的 js 代碼會被運行一次

aaa.js

const name = 'coderwhy';

console.log("Hello aaa");

setTimeout(() ={
  console.log("setTimeout");
}, 1000);

main.js

const aaa = require('./aaa');

aaa.js 中的代碼在引入時會被運行一次

結論二:模塊被多次引入時,會緩存,最終只加載(運行)一次

main.js

const aaa = require('./aaa');
const bbb = require('./bbb');

aaa.js

const ccc = require("./ccc");

bbb.js

const ccc = require("./ccc");

ccc.js

console.log('ccc被加載');

ccc 中的代碼只會運行一次。

爲什麼只會加載運行一次呢?

結論三:如果有循環引入,那麼加載順序是什麼?

如果出現下面模塊的引用關係,那麼加載順序是什麼呢?

2.3. Node 的源碼解析

Module 類

Module.prototype.require 函數

Module._load 函數

三. AMD 和 CMD 規範

3.1. CommonJS 規範缺點

CommonJS 加載模塊是同步的:

如果將它應用於瀏覽器呢?

所以在瀏覽器中,我們通常不使用 CommonJS 規範:

在早期爲了可以在瀏覽器中使用模塊化,通常會採用 AMD 或 CMD:

3.2. AMD 規範

AMD 主要是應用於瀏覽器的一種模塊化規範:

我們提到過,規範只是定義代碼的應該如何去編寫,只有有了具體的實現才能被應用:

這裏我們以 require.js 爲例講解:

第一步:下載 require.js

第二步:定義 HTML 的 script 標籤引入 require.js 和定義入口文件:

<script src="./lib/require.js" data-main="./index.js"></script>

第三步:編寫如下目錄和代碼

├── index.html
├── index.js
├── lib
│   └── require.js
└── modules
    ├── bar.js
    └── foo.js

index.js

(function() {
  require.config({
    baseUrl: '',
    paths: {
      foo: './modules/foo',
      bar: './modules/bar'
    }
  })
 
  // 開始加載執行foo模塊的代碼
  require(['foo']function(foo) {

  })
})();

modules/bar.js

define(function() {
  const name = "coderwhy";
  const age = 18;
  const sayHello = function(name) {
    console.log("Hello " + name);
  }

  return {
    name,
    age, 
    sayHello
  }
})

modules/foo.js

define(['bar']function(bar) {
  console.log(bar.name);
  console.log(bar.age);
  bar.sayHello('kobe');
})

3.3. CMD 規範

CMD 規範也是應用於瀏覽器的一種模塊化規範:

CMD 也有自己比較優秀的實現方案:

我們一起看一下 SeaJS 如何使用:

第一步:下載 SeaJS

第二步:引入 sea.js 和使用主入口文件

<script src="./lib/sea.js"></script>
<script>
  seajs.use('./index.js');
</script>

第三步:編寫如下目錄和代碼

├── index.html
├── index.js
├── lib
│   └── sea.js
└── modules
    ├── bar.js
    └── foo.js

index.js

define(function(require, exports, module) {
  const foo = require('./modules/foo');
})

bar.js

define(function(require, exports, module) {
  const name = 'lilei';
  const age = 20;
  const sayHello = function(name) {
    console.log("你好 " + name);
  }

  module.exports = {
    name,
    age,
    sayHello
  }
})

foo.js

define(function(require, exports, module) {
  const bar = require('./bar');

  console.log(bar.name);
  console.log(bar.age);
  bar.sayHello("韓梅梅");
})

四. ES Module

4.1. 認識 ES Module

JavaScript 沒有模塊化一直是它的痛點,所以纔會產生我們前面學習的社區規範:CommonJS、AMD、CMD 等,所以在 ES 推出自己的模塊化系統時,大家也是興奮異常。

ES Module 和 CommonJS 的模塊化有一些不同之處:

ES Module 模塊採用 export 和 import 關鍵字來實現模塊化:

瞭解:採用 ES Module 將自動採用嚴格模式:use strict

4.2. ES Module 的使用

4.2.1. 代碼結構組件

這裏我在瀏覽器中演示 ES6 的模塊化開發:

代碼結構如下:

├── index.html
├── main.js
└── modules
    └── foo.js

index.html 中引入兩個 js 文件作爲模塊:

<script src="./modules/foo.js" type="module"></script>
<script src="main.js" type="module"></script>

如果直接在瀏覽器中運行代碼,會報如下錯誤:

這個在 MDN 上面有給出解釋:

我這裏使用的 VSCode,VSCode 中有一個插件:Live Server

4.2.2. export 關鍵字

export 關鍵字將一個模塊中的變量、函數、類等導出;

foo.js 文件中默認代碼如下:

const name = 'coderwhy';
const age = 18;
let message = "my name is why";

function sayHello(name) {
  console.log("Hello " + name);
}

我們希望將其他中內容全部導出,它可以有如下的方式:

方式一:在語句聲明的前面直接加上 export 關鍵字

export const name = 'coderwhy';
export const age = 18;
export let message = "my name is why";

export function sayHello(name) {
  console.log("Hello " + name);
}

方式二:將所有需要導出的標識符,放到 export 後面的 {}

const name = 'coderwhy';
const age = 18;
let message = "my name is why";

function sayHello(name) {
  console.log("Hello " + name);
}

export {
  name,
  age,
  message,
  sayHello
}

方式三:導出時給標識符起一個別名

export {
  name as fName,
  age as fAge,
  message as fMessage,
  sayHello as fSayHello
}

4.2.3. import 關鍵字

import 關鍵字負責從另外一個模塊中導入內容

導入內容的方式也有多種:

方式一:import {標識符列表} from '模塊'

import { name, age, message, sayHello } from './modules/foo.js';

console.log(name)
console.log(message);
console.log(age);
sayHello("Kobe");

方式二:導入時給標識符起別名

import { name as wName, age as wAge, message as wMessage, sayHello as wSayHello } from './modules/foo.js';

方式三:將模塊功能放到一個模塊功能對象(a module object)上

import * as foo from './modules/foo.js';

console.log(foo.name);
console.log(foo.message);
console.log(foo.age);
foo.sayHello("Kobe");

4.2.4. export 和 import 結合

如果從一個模塊中導入的內容,我們希望再直接導出出去,這個時候可以直接使用 export 來導出。

bar.js 中導出一個 sum 函數:

export const sum = function(num1, num2) {
  return num1 + num2;
}

foo.js 中導入,但是隻是做一箇中轉:

export { sum } from './bar.js';

main.js 直接從 foo 中導入:

import { sum } from './modules/foo.js';
console.log(sum(20, 30));

甚至在 foo.js 中導出時,我們可以變化它的名字

export { sum as barSum } from './bar.js';

爲什麼要這樣做呢?

4.2.4. default 用法

前面我們學習的導出功能都是有名字的導出(named exports):

還有一種導出叫做默認導出(default export)

導出格式如下:

export default function sub(num1, num2) {
  return num1 - num2;
}

導入格式如下:

import sub from './modules/foo.js';

console.log(sub(20, 30));

注意:在一個模塊中,只能有一個默認導出(default export);

4.2.5. import()

通過 import 加載一個模塊,是不可以在其放到邏輯代碼中的,比如:

if (true) {
  import sub from './modules/foo.js';
}

爲什麼會出現這個情況呢?

const path = './modules/foo.js';

import sub from path;

但是某些情況下,我們確確實實希望動態的來加載某一個模塊:

aaa.js 模塊:

export function aaa() {
  console.log("aaa被打印");
}

bbb.js 模塊:

export function bbb() {
  console.log("bbb被執行");
}

main.js 模塊:

let flag = true;
if (flag) {
  import('./modules/aaa.js').then(aaa ={
    aaa.aaa();
  })
} else {
  import('./modules/bbb.js').then(bbb ={
    bbb.bbb();
  })
}

4.3. ES Module 的原理

4.3.1. ES Module 和 CommonJS 的區別

CommonJS 模塊加載 js 文件的過程是運行時加載的,並且是同步的:

console.log("main代碼執行");

const flag = true;
if (flag) {
  // 同步加載foo文件,並且執行一次內部的代碼
  const foo = require('./foo');
  console.log("if語句繼續執行");
}

CommonJS 通過 module.exports 導出的是一個對象:

ES Module 加載 js 文件的過程是編譯(解析)時加載的,並且是異步的:

<script src="main.js" type="module"></script>
<!-- 這個js文件的代碼不會被阻塞執行 -->
<script src="index.js"></script>

ES Module 通過 export 導出的是變量本身的引用:

所以我們下面的代碼是成立的:

bar.js 文件中修改

let name = 'coderwhy';

setTimeout(() ={
  name = "湖人總冠軍";
}, 1000);

setTimeout(() ={
  console.log(name);
}, 2000);

export {
  name
}

main.js 文件中獲取

import { name } from './modules/bar.js';

console.log(name);

// bar中修改, main中驗證
setTimeout(() ={
  console.log(name);
}, 2000);

但是,下面的代碼是不成立的:main.js 中修改

import { name } from './modules/bar.js';

console.log(name);

// main中修改, bar中驗證
setTimeout(() ={
  name = 'kobe';
}, 1000);

思考:如果 bar.js 中導出的是一個對象,那麼 main.js 中是否可以修改對象中的屬性呢?

4.3.2. Node 中支持 ES Module

在 Current 版本中

在最新的 Current 版本(v14.13.1)中,支持 es module 我們需要進行如下操作:

這裏我們暫時選擇以 .mjs 結尾的方式來演練:

bar.mjs

const name = 'coderwhy';

export {
  name
}

main.mjs

import { name } from './modules/bar.mjs';

console.log(name);

在 LTS 版本中

在最新的 LST 版本(v12.19.0)中,我們也是可以正常運行的,但是會報一個警告:

4.3.3. ES Module 和 CommonJS 的交互

CommonJS 加載 ES Module

結論:通常情況下,CommonJS 不能加載 ES Module

ES Module 加載 CommonJS

結論:多數情況下,ES Module 可以加載 CommonJS

foo.js

const address = 'foo的address';

module.exports = {
  address
}

main.js

import foo from './modules/foo.js';
console.log(foo.address);
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/dnqJsbz5oEGqfZjGTfNMmg