前端模組化詳解

語言: CN / TW / HK

前言

隨著前端技術的發展,模組化開發已經是前端開發通用解決方案。

本文主要介紹了模組化的概念、由來、優點以及前端開發中常見的模組化規範。

一、認識模組化

模組概念?

對於一個複雜的程式,將其按照一定的規範封裝成幾個檔案塊,每一塊向外暴露一些介面, 但是塊的內部資料是私有的, 塊與塊之間通過暴露的介面進行通訊,這個過程稱為模組化。

一個模組具有的基本特徵:

  • 程式碼封裝,避免全域性汙染
  • 具有唯一標識
  • 暴露部分資料或者api方法供外部使用
  • 模組使用方便快捷

為什麼會有模組化

模組化的由來,需要從早期的開發模式說起。

無封裝無模組

早期的js只是作為一個瀏覽器指令碼的身份,來實現一些簡單的互動操作。

由於程式碼量少,所以直接將程式碼放在<script>標籤中即可。

<script>
  document.getElementById('hello').onClick = function () {
    alert('hello');
  }

  document.getElementById('submit-btn').onClick = function () {
    var username = document.getElementById('username').value;
    var password = document.getElementById('password').value;
    if (!username) {
      alert('請輸入使用者名稱');
      return;
    }
    if (!password) {
      alert('請輸入密碼');
      return;
    }
    // 提交表單
    console.log('提交表單');
  }
</script>
複製程式碼

隨著前端技術的發展,js的廣泛應用,程式碼量日益增加。原始的程式碼堆砌方式會有很多弊端:

  • 產生大量重複的程式碼,同樣功能的程式碼到處複製貼上
  • 邏輯混亂,不利於閱讀和維護
  • 很容易出現bug。

於是便將一部分功能封裝到函式中,通過呼叫函式來執行。

封裝全域性功能函式

通過將不同的功能封裝成不同的函式,進行呼叫

// 張三定義了setValue函式
function setValue(name){
  document.getElementById('username').value = name;
}
// 張三定義了getValue函式
function getValue(){
  return document.getElementById('username').value;
}

// 李四定義了setValue函式
function setValue(name){
  document.getElementById('phone').value = name;
}
// 李四定義了getValue函式
function getValue(){
  return document.getElementById('phone').value;
}
複製程式碼

張三定義了setValuegetValue方法,實現了自己的功能,測試了下沒有問題

第二天李四增加了功能,也定義了setValuegetValue方法,測試了下自己的功能,也沒有問題

第三天,測試給張三提bug了。

所以,這種方式也有弊端:會汙染全域性名稱空間, 容易引起命名衝突,而且模組成員之間看不出依賴

名稱空間

宣告一個名稱空間物件,將資料和方法封裝到物件中,減少了全域性變數,解決命名衝突

const tool = {
  id: 'tool_1',
  type: 'input',
  value: '123',
  getType() {
    console.log(`type-${this.type}`);
    return this.type;
  }
  getValue() {
    console.log(`value-${this.value}`);
    return this.value;
  },

}
tool.type = 'checkbox' // 直接修改模組內部的資料
tool.getType() // 'checkbox'
複製程式碼

這樣的寫法會暴露所有模組成員,內部狀態可以被外部改寫,導致資料安全問題。

立即執行函式、依賴注入

將資料和方法封裝到一個函式內部, 通過給window新增屬性來向外暴露api介面

// tool.js檔案
(function(window, $) {
  let id = '#tool_1';
  let type = 'input';
  let value = '123';
  let count = 0;

  // 函式
  function getType() {
    console.log(`type-${this.type}`);
    return type;
  }
  function getValue() {
    console.log(`value-${$(id).val()}`);
    return $(id).val();
  }
  function setValue(val) {
    value = val;
  }
  function increase() {
    count++;
  }
  // 私有方法
  function resetValue() {
    value = '123';
  }
  // 私有方法
  function resetCount() {
    count = 0;
  }

  function resetHandler() {
    console.log('resetHandler');
    resetValue();
    resetCount();
  }

  // 暴露方法
  window.tool = { getType, getValue, setValue, increase, resetHandler }
})(window, jQuery)
複製程式碼

引入js時必須保證順序正確 (index.html檔案)

<script type="text/javascript" src="jquery-1.7.2.js"></script>
<script type="text/javascript" src="tool.js"></script>
<script type="text/javascript">
  tool.setValue('567');
</script>
複製程式碼

上面例子通過jquery方法獲取input框的值,所以必須先引入jQuery庫,當作引數傳入。

原始開發方案侷限性:

script標籤 + 函式封裝 + 名稱空間 + 立即執行函式,這些方式有很大的侷限性

  • 1、全域性空間汙染
  • 2、需手動管理依賴,不具備可擴充套件性
  • 3、重複載入與迴圈引用的問題
  • 3、無法實現按需載入
  • 4、產生大量的http請求

模組化的優點

  • 避免命名衝突(減少名稱空間汙染)
  • 功能分離, 按需載入
  • 可複用性
  • 可維護性

接下來介紹開發中常用的commonjs, AMD, CMD, ES6規範。

二、模組化規範

CommonJS

CommonJS概述

一個檔案代表一個模組,有自己的作用域。模組中定義的變數、函式、類,都是私有的,通過暴露變數和api方法給外部使用。

Node採用了CommonJS模組規範,但並非完全按照CommonJS規範實現,而是對模組規範進行了一定的取捨,同時也增加了少許自身需要的特性。

CommonJS特點

  • 所有程式碼都執行在模組作用域,不會汙染全域性作用域。

  • 模組可以多次載入,但是隻會在第一次載入時執行一次,然後執行結果就被快取了,以後再載入,就直接讀取快取結果。要想讓模組再次執行,必須清除快取。

  • 模組載入的順序,按照其在程式碼中出現的順序。

  • 執行時同步載入

CommonJS基本用法

模組定義和匯出

// moduleA.js
let count = 0;

module.exports.increase = () => {
  count++;
};

module.exports.getValue = () => {
  return count;
}
複製程式碼

模組引入

require命令用於載入模組檔案,如果沒有發現指定模組,會報錯。

可以在一個檔案中引入模組並匯出另一個模組。

// moduleB.js

// 如果引數字串以“./”或者“../”開頭,則表示載入的是一個相對路徑的檔案
const { getValue, increase } = require('./moduleA');

increase();
let count = getValue();
console.log(count);

module.exports.add = (val) => {
  return val + count;
}
複製程式碼

模組標識

模組標識就是require(moduleName)函式的引數moduleName,引數需符合規範:

  • 必須是字串
    • 如果是第三方模組,則moduleName為模組名
    • 如果是自定義模組,moduleName為模組檔案路徑; 可以是相對路徑或者絕對路徑
  • 可以省略字尾名

CommonJS模組規範的意義在於將變數和方法等限制在私有的作用域中,每個模組具有獨立的空間,它們互不干擾,同時支援匯入和匯出來銜接上下游模組。

CommonJS模組的載入機制

一個模組除了自己的函式作用域之外,最外層還有一個模組作用域,module代表這個模組,是一個物件,exportsmodule的屬性,是對外暴露的介面。

require也在這個模組的上下文中,用來引入外部模組,其實就是載入其他模組的module.exports屬性。

接下來分析下CommonJS模組的大致載入流程

function loadModule(filename, module, require, __filename, __dirname) {
  const wrappedSrc = `(function (module, exports, require, __filename, __dirname) { 
    ${fs.readFileSync(filename, "utf8")} // 使用的是fs.readFileSync,同步讀取
    })(module, module.exports, require, __filename, __dirname)`;
  eval(wrappedSrc);
}
複製程式碼

這裡只是為了概述載入的流程,很多邊界及安全問題都不予考慮,如:

這裡我們只是簡單的使用 eval來我們的JS程式碼,實際上這種方式會有很多安全問題,所以真實程式碼中應該使用 vm來實現。

原始碼中還有額外兩個引數: __filename__dirname,這就是為什麼我們在寫程式碼的時候可以直接使用這兩個變數的原因。

require實現

function require(moduleName) {
  // 通過require.resolve解析補全模組路徑,得到一個絕對路徑字串
  const id = require.resolve(moduleName);
  // 先查詢下該id路徑是否已經快取到require.cache中,如果已經快取過了,則直接讀快取
  if (require.cache[id]) {
    return require.cache[id].exports;
  }
  // module 元資料
  const module = {
    exports: {},
    id,
  };
  // 新載入模組後,將模組路徑新增到快取中,方便後續通過id路徑直接讀快取
  require.cache[id] = module;
  // 載入模組
  // loadModule(id, module, require);
  // 直接將上面loadModule方法整合進來
  (function (filename, module, require) {
    (function (module, exports, require) {
      fs.readFileSync(filename, "utf8");
    })(module, module.exports, require);
  })(id, module, require);

  // 返回 module.exports 
  return module.exports;
}

require.cache = {};
require.resolve = (moduleName) => {
  /* 解析補全模組路徑,得到一個絕對路徑字串 */
  return '絕對路徑字串';
};
複製程式碼

上面的模組載入時,將module.exports物件傳入內部自執行函式中,模組內部將資料或者方法掛載到module.exports物件上,最後返回這個module.exports物件。

以前面的moduleA.jsmoduleB.js模組為例:

  • moduleA 模組中將 increasegetValue 方法掛載到 上下文的 module.exports物件上

    // moduleA.js
    let count = 0;
    
    module.exports.increase = () => {
      count++;
    };
    
    module.exports.getValue = () => {
      return count;
    }
    複製程式碼
  • moduleB 模組中 requiremoduleA,並return 掛載了increasegetValue方法的module.exports物件;這個物件經過結構賦值,最終被moduleB中的increasegetValue變數接收。

    // moduleB.js
    const { getValue, increase } = require('./moduleA');
    
    //等價於
    // let m = require('./moduleA');
    // const getValue = m.getValue;
    // const increase = m.increase;
    
    increase();
    let count = getValue();
    console.log(count);
    
    module.exports.add = (val) => {
      return val + count;
    }
    複製程式碼

require.resolve載入策略

在前面我們已經知道了resolverequire的方法屬性。它的作用就是把傳遞進來的路徑進行補全得到一個絕對路徑的字串。

function require(moduleName) {
  ......
  // 返回 module.exports 
  return module.exports;
}
require.resolve = (moduleName) => {
  /* 解析補全模組路徑,得到一個絕對路徑字串 */
  return '絕對路徑字串';
};
複製程式碼

在實際專案中,我們經常使用的方式有:

  • 匯入自己寫的模組檔案
  • 匯入nodejs提供的核心模組
  • 匯入node_modules裡的包模組

我們可以簡單地概括下載入策略:

  • 首先判斷是否為核心模組,在nodejs自身提供的模組列表中進行查詢,如果是就直接返回
  • 判斷引數 moduleName 是否以./或者../開頭,如果是就統一轉換成絕對路徑進行載入後返回
  • 如果前兩步都沒找到,就當做是包模組,去最近的node_moudles目錄中查詢

由於moduleName是可以省略字尾名的,所以應該遵循一個字尾名判斷規則,不同字尾名判斷的優先順序順序如下:

  • 如果moduleName是帶有後綴名的檔案,則直接返回;
  • 如果moduleName是不帶字尾名的路徑,則按照一下順序載入
    • moduleName.js
    • moduleName.json
    • moduleName.node
    • moduleName/index.js
    • moduleName/index.json
    • moduleName/index.node
  • 如果是載入的是包模組的話,就會按照包模組中package.json檔案的main欄位屬性的值來載入

Nodejs的模組化實現

Nodejs模組在實現中並非完全按照CommonJS來,進行了取捨,增加了一些自身的的特性。

Nodejs中一個檔案是一個模組: module, 一個模組就是一個Module的例項

Nodejs中Module建構函式:

function Moduleid, parent{
  this.id = id;
  this.exports = {};
  this.parent = parent;
  if(parent && parent.children) {
    parent.children.push(this);
  }
  this.filename = null;
  this.loaded = false;
  this.children = [];
}

//例項化一個模組
var module = new Module(filename, parent);
複製程式碼

其中id是模組id,exports是這個模組要暴露出來的api介面,parent是父級模組,loaded表示這個模組是否載入完成。

AMD

AMD(Asynchronous Module Definition),非同步模組定義:主要用於瀏覽器,由於該規範不是原生js支援的,使用AMD規範進行開發的時候需要引入第三方的庫函式,也就是流行的RequireJS RequireJS是AMD規範的一種實現。其實也可以說AMD是RequireJS在推廣過程中對模組定義的規範化產出。

AMD是一個非同步模組載入規範,它與CommonJS的主要區別就是非同步載入,允許指定回撥函式。模組載入過程中即使require的模組還沒有獲取到,也不會影響後面程式碼的執行。

由於Node.js主要用於伺服器程式設計,模組檔案一般都已經存在於本地硬碟,所以載入起來比較快,不用考慮非同步載入的方式,所以CommonJS規範會比較適用。但是,瀏覽器環境,要從伺服器端下載模組檔案,這時就必須採用非同步載入,因此瀏覽器端一般採用AMD規範。此外AMD規範比CommonJS規範在瀏覽器端實現要早。

AMD規範基本語法

RequireJS定義了一個define函式,用來定義模組

語法

define([id], [dependencies], factory)
複製程式碼

引數

  • id:可選,字串型別,定義模組標識,如果沒有提供引數,預設為檔名
  • dependencies:可選,字串陣列,即當前模組所依賴的其他模組,AMD 推崇依賴前置
  • factory:必需,工廠方法,初始化模組需要執行的函式或物件。如果為函式,它只被執行一次。如果是物件,此物件會作為模組的輸出值

模組定義和匯出

  • 定義沒有依賴的獨立模組

    // module1.js
    define({
      increase: function() {},
      getValue: function() {},
    });  
    
    // 或者
    define(function(){
      return {
        increase: function() {},
        getValue: function() {},
      }
    });  
    複製程式碼
  • 定義有依賴的模組

    // module2.js
    define(['jQuery', 'tool'], function($, tool){
      return {
        clone: $.extend,
        getType: function() {
          return tool.getType();
        }
      }
    });
    複製程式碼
  • 定義具名模組

    define('module1', ['jQuery', 'tool'], function($, tool){
      return {
        clone: $.extend,
        getType: function() {
          return tool.getType();
        }
      }
    });
    複製程式碼

引入使用模組

require(['module1', 'module2'], function(m1, m2){
  m1.getValue();
  m2.getType();
})
複製程式碼

require()函式載入依賴模組是非同步載入,這樣瀏覽器就不會失去響應

AMD規範和CommonJS規範對比

  • CommonJS一般用於服務端,AMD一般用於瀏覽器客戶端
  • CommonJSAMD都是執行時載入

什麼是執行時載入?

  • CommonJSAMD模組都只能在執行時確定模組之間的依賴關係
  • require一個模組的時候,模組會先被執行,並返回一個物件,並且這個物件是整體載入的

小結:AMD模組定義的方法能夠清晰地顯示依賴關係,不會汙染全域性環境。AMD模式可以用於瀏覽器環境,允許非同步載入模組,也可以根據需要動態載入模組。

CMD

CMD(Common Module Definition),通用模組定義,它解決的問題和AMD規範是一樣的,只不過在模組定義方式和模組載入時機上不同,CMD也需要額外的引入第三方的庫檔案,SeaJS CMD 是 SeaJS 在推廣過程中對模組定義的規範化產出。

CMD規範基本語法

define 是一個全域性函式,用來定義模組

語法

define([id], [dependencies], factory)
複製程式碼

引數

  • id:可選,字串型別,定義模組標識,如果沒有提供引數,預設為檔名
  • dependencies:可選,字串陣列,即當前模組所依賴的其他模組,CMD 推崇依賴就近
  • factory:必需,工廠方法,初始化模組需要執行的函式或物件。如果為函式,它只被執行一次。如果是物件,此物件會作為模組的輸出值

模組定義和匯出

除了給 exports 物件增加成員,還可以使用 return 直接向外提供介面

  • 定義沒有依賴的模組

    define(function(require, exports, module) {
      module.exports = {
        count: 1,
        increase: function() {},
        getValue: function() {}
      };
    })
    
    // 或者
    define(function(require, exports, module) {
      return {
        count: 1,
        increase: function() {},
        getValue: function() {}
      };
    })
    複製程式碼
  • 定義有依賴的模組

    define(function(require, exports, module){
      // 引入依賴模組(同步)
      const module1 = require('./module1');
    
      // 引入依賴模組(非同步)
      require.async('./tool', function (tool) {
        tool.getType();
      })
    
      // 暴露模組
      module.exports = {
        value: 1
      };
    })
    複製程式碼

引入使用模組

define(function (require) {
  var m1 = require('./module1');
  var m2 = require('./module2');

  m1.getValue();
  m2.getType();
})
複製程式碼

CMD規範專門用於瀏覽器端,模組的載入是非同步的,模組使用時才會載入執行。CMD規範整合了CommonJS和AMD規範的特點。

ES6模組化

ES6 模組的設計思想是儘量的靜態化,使得編譯時就能確定模組的依賴關係,以及匯入和匯出的變數。CommonJS 和 AMD 模組,都只能在執行時才能確定。

ES6模組化語法

export命令用於暴露模組的對外介面,import命令用於匯入其他模組。

模組定義和匯出

// moduleA.js
let count = 0;

export const increase = () => {
  count++;
};

export const getValue = () => {
  return count;
}
複製程式碼

模組引入

// moduleB.js
import { getValue, increase } from './moduleA.js';

increase();
let count = getValue();
console.log(count);

export function add(val) {
  return val + count;
}
複製程式碼

匯入模組時可以給變數或方法指定別名,需要使用as關鍵字來定義別名

// moduleB.js
import { getValue as getCountValue, increase as increaseHandler } from './moduleA.js';

increaseHandler();
let count = getCountValue();
console.log(count);
複製程式碼

如上例所示,使用import命令的時候,需要知道所要載入的變數名或函式名,否則無法載入。

為了給使用者提供方便,讓他們不用閱讀文件就能載入模組,就要用到export default命令,為模組指定預設輸出。

模組預設匯出後, 其他模組載入該模組時,import命令可以為該匿名函式指定任意名字。

// add.js
export default function (a, b) {
  return a + b;
}

// demo.js
import add from './add';
console.log(add(1, 2)); // 3
複製程式碼

如果想在一條import語句中,同時匯入預設方法和其他變數、方法,可以寫成下面這樣。

// moduleA.js
let count = 0;

export const increase = () => {
  count++;
};
export const getValue = () => {
  return count;
}
export default {
  a: 1
}

// moduleB.js
import _, { getValue, increase } from './moduleA.js';

increase();
let count = getValue();
console.log(count);

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

這種用法在react專案中隨處可見

import React, { useState } from 'react';

function Hello() {
  let [ count, setCount ] = useState(0);
  return (
    <div>
      <p>You click { count } times</p>
      <button onClick={() => setCount(count + 1)}>設定count</button>
    </div>
  )
}
複製程式碼

整體匯入

除了指定載入某些輸出值,還可以使用整體載入,即用星號(*)指定一個物件,所有輸出值都載入在這個物件上面。

// moduleB.js
// import { getValue, increase } from './moduleA.js';
import * as handler from './moduleA.js';

handler.increase();
let count = handler.getValue();
console.log(count);
複製程式碼

其他情況

import命令具有提升效果,會提升到整個模組的頭部,首先執行。

foo();

import { foo } from './foo.js';
複製程式碼

上面的程式碼不會報錯,因為import的執行早於foo的呼叫。這種行為的本質是,import命令是編譯階段執行的,在程式碼執行之前。

由於import是靜態執行,所以不能使用表示式和變數,這些只有在執行時才能得到結果的語法結構。

import { 'f' + 'oo' } from './foo'; // 報錯

let module = './foo';
import { foo } from module; // 報錯

// 報錯
if (x === 1) {
  import { foo } from './foo1';
} else {
  import { foo } from './foo2';
}
複製程式碼

上面三種寫法都會報錯,因為它們用到了表示式、變數和if結構。在靜態分析階段,這些語法都是無法得到值的。

import語句會執行所載入的模組,因此可以有下面的寫法。

import './initData';
複製程式碼

initData.js中會自執行初始化資料的方法,並不需要匯出變數和方法。所以只需要import這個模組,執行初始化操作即可。

ES6 模組與 CommonJS 模組的差異

它們有兩個重大差異:

  • 1、CommonJS 模組是執行時載入,ES6 模組是編譯時輸出介面。
  •  
  • 2、CommonJS 模組輸出的是一個值的拷貝,ES6 模組輸出的是值的引用。

CommonJS 載入的是一個物件(即module.exports屬性),該物件只有在指令碼執行完才會生成。而 ES6 模組不是物件,它的對外介面只是一種靜態定義,在程式碼靜態解析階段就會生成。

ES6 模組的執行機制與 CommonJS 不一樣。JS 引擎對指令碼靜態分析的時候,遇到模組載入命令import,就會生成一個只讀引用。等到指令碼真正執行時,再根據這個只讀引用,到被載入的那個模組裡面去取值。換句話說,ES6 的import有點像 Unix 系統的“符號連線”,原始值變了,import載入的值也會跟著變。因此,ES6 模組是動態引用,並且不會快取值,模組裡面的變數繫結其所在的模組。

小結

由於 ES6 模組是編譯時載入,使得靜態分析成為可能。有了它,就能進一步拓寬 JavaScript 的語法,比如型別檢驗(type system)等只能靠靜態分析實現的功能。

三、總結

  • CommonJS規範主要用於服務端程式設計,載入模組是同步的;在瀏覽器環境中,同步載入會導致阻塞,所以不適合這個規範,因此有了AMDCMD規範。

  • AMD規範在瀏覽器環境中非同步載入模組,而且可以並行載入多個模組。不過,AMD規範開發成本高,程式碼的閱讀和書寫比較困難。

  • CMD規範與AMD規範很相似,都用於瀏覽器程式設計,依賴就近,延遲執行,可以很容易在Node.js中執行。

  • ES6 在語言標準的層面上,實現了模組功能,而且實現得相當簡單,完全可以取代 CommonJS 和 AMD 規範,成為瀏覽器和伺服器通用的模組解決方案。

最後

如果你覺得此文對你有一丁點幫助,點個贊。或者可以加入我的開發交流群:1025263163相互學習,我們會有專業的技術答疑解惑

如果你覺得這篇文章對你有點用的話,麻煩請給我們的開源專案點點star:http://github.crmeb.net/u/defu不勝感激 !

PHP學習手冊:http://doc.crmeb.com
技術交流論壇:http://q.crmeb.com