聊聊前端模組化開發

一半水一半冰發表於2019-05-24

隨著JavaScript開發變得越來越普遍,名稱空間和依賴性變得越來越難以處理。前端開發者都以模組化的方式處理該問題。在這篇文章中,我們將探討前端開發人員目前使用的模組化方案以及試圖解決的問題。

為什麼需要JavaScript模組?

模組化可以使你的程式碼低耦合,功能模組直接不相互影響。

  1. 可維護性:每個模組都是單獨定義的,之間相互獨立。模組儘可能的需要和外部撇清關係,方便我們獨立的對其進行維護與改造。維護一個模組比在全域性中修改邏輯判斷要好的多。
  2. 名稱空間:為了避免在JavaScript中的全域性汙染,我們通過模組化的方式利用函式作用域來構建名稱空間。
  3. 可複用性:雖然貼上複製很簡單,但是考慮到我們之後的維護以及迭代,你會相當崩潰。

模組化的解決方案有哪些?

講完了JavaScript模組化的好處,我們來看下有哪些解決方案來實現JavaScript的模組化。

揭示模組模式(Revealing Module)

var myRevealingModule = (function () {
    var privateVar = "Ben Cherry",
        publicVar = "Hey there!";
        
    function privateFunction() {
        console.log( "Name:" + privateVar );
    }
    function publicSetName( strName ) {
        privateVar = strName;
    }
    function publicGetName() {
        privateFunction();
    }
    // Reveal public pointers to
    // private functions and properties
    return {
        setName: publicSetName,
        greeting: publicVar,
        getName: publicGetName
    };
})();
myRevealingModule.setName( "Paul Kinlan" );

通過這種構造,我們通過使用函式有了自己的作用域或“閉包”。

這種方法的好處在於,你可以在函式內部使用區域性變數,而不會意外覆蓋同名全域性變數,但仍然能夠訪問到全域性變數。

優點:

  • 可以在任何地方實現(沒有庫,不需要語言支援)。
  • 可以在單個檔案中定義多個模組。

缺點:

  • 無法以程式設計方式匯入模組(除非使用eval)。
  • 需要手動處理依賴關係。
  • 無法非同步載入模組。
  • 迴圈依賴可能很麻煩。
  • 很難通過靜態程式碼分析器進行分析。

CommonJS

CommonJS是一個旨在定義一系列規範的專案,以幫助開發伺服器端JavaScript應用程式。CommonJS團隊試圖解決的一個領域就是模組。Node.js開發人員最初打算遵循CommonJS規範,但後來決定反對它。

在 CommonJS 的規範中,每個 JavaScript 檔案就是一個獨立的模組上下文(module context),在這個上下文中預設建立的屬性都是私有的。也就是說,在一個檔案定義的變數(還包括函式和類),都是私有的,對其他檔案是不可見的。

需要注意的一點是,CommonJS以伺服器優先的方式來同步載入模組,假使我們引入三個模組的話,他們會一個個地被載入。

// In circle.js
const PI = Math.PI;
exports.area = (r) => PI * r * r;
exports.circumference = (r) => 2 * PI * r;
// In some file
const circle = require('./circle.js');
console.log( `The area of a circle of radius 4 is ${circle.area(4)}`);

Node.js的模組系統通過library的方式對CommonJS的基礎上進行了模組化實現。

在Node和CommonJS的模組中,基本上有兩個與模組系統互動的關鍵字:require和exports。

require是一個函式,可用於將介面從另一個模組匯入當前範圍。傳遞給的引數require是模組的id。在Node的實現中,它是node_modules目錄中模組的名稱(或者,如果它不在該目錄中,則是它的路徑)。

exports是一個特殊的物件:放入它的任何東西都將作為公共元素匯出。

Node和CommonJS之間的一個獨特區別在於module.exports物件的形式。

在Node中,module.exports是匯出的真正特殊物件,而exports它只是預設繫結到的變數module.exports。

另一方面,CommonJS沒有任何module.exports物件。實際意義是,在Node中,無法通過以下方式匯出完全預構造的物件module.exports:

// This won't work, replacing exports entirely breaks the binding to
// modules.exports.
exports = (width) => {
    return {
        area: () => width * width
    };
}
// This works as expected.
module.exports = (width) => {
    return {
        area: () => width * width
    };
}

優點

  • 簡單:開發人員可以在不檢視文件的情況下掌握概念。
  • 整合了依賴管理:模組需要其他模組並按所需順序載入。
  • require可以在任何地方呼叫:模組可以通過程式設計方式載入。
  • 支援迴圈依賴。

缺點

  • 同步API使其不適合某些用途(客戶端)。
  • 每個模組一個檔案。
  • 瀏覽器需要載入程式庫或轉換。
  • 模組沒有建構函式(Node支援)。
  • 很難進行靜態程式碼分析。

AMD

AMD誕生於一群對CommonJS的研究方向不滿的開發人員。事實上,AMD在開發早期就與CommonJS分道揚鑣,AMD和CommonJS之間的主要區別在於它支援非同步模組載入。

//Calling define with a dependency array and a factory function
define(['dep1', 'dep2'], function (dep1, dep2) {
    //Define the module value by returning a value.
    return function () {};
});
// Or:
define(function (require) {
    var dep1 = require('dep1'),
    dep2 = require('dep2');
    return function () {};
});

通過使用JavaScript的傳統閉包來實現非同步載入:

在請求的模組載入完成時呼叫函式。模組定義和匯入模組由同一個函式承載:定義模組時,其依賴關係是明確的。因此,AMD載入器可以在執行時具有專案的模組依賴圖。因此可以同時載入彼此不依賴的庫。這對於瀏覽器尤其重要,因為啟動時間對於良好的使用者體驗至關重要。

優點

  • 非同步載入(更好的啟動時間)。
  • 支援迴圈依賴。
  • require和的相容性exports。
  • 完全整合了依賴管理。
  • 如有必要,可以將模組拆分為多個檔案。
  • 支援建構函式。
  • 外掛支援(自定義載入步驟)。

缺點

  • 語法稍微複雜一些。
  • 除非編譯,否則需要載入程式庫。
  • 很難分析靜態程式碼。

除了非同步載入以外,AMD的另一個優點是你可以在模組裡使用物件、函式、建構函式、字串、JSON或者別的資料型別,而CommonJS只支援物件。

UMD

統一模組定義(UMD:Universal Module Definition )就是將 AMD 和 CommonJS 合在一起的一種嘗試,常見的做法是將CommonJS 語法包裹在相容 AMD 的程式碼中。

(function(define) {
    define(function () {
        return {
            sayHello: function () {
                console.log('hello');
            }
        };
    });
}(
    typeof module === 'object' && module.exports && typeof define !== 'function' ?
    function (factory) { module.exports = factory(); } :
    define
));

該模式的核心思想在於所謂的 IIFE(Immediately Invoked Function Expression),該函式會根據環境來判斷需要的引數類別

ES6模組

支援JavaScript標準化的ECMA團隊決定解決模組問題,
相容同步和非同步操作模式。

//------ lib.js ------
export const sqrt = Math.sqrt;
export function square(x) {
    return x * x;
}
export function diag(x, y) {
    return sqrt(square(x) + square(y));
}
//------ main.js ------
import { square, diag } from 'lib';
console.log(square(11)); // 121
console.log(diag(4, 3)); // 5

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

由於 ES6 模組是編譯時載入,使得靜態分析成為可能。有了它,就能進一步拓寬 JavaScript 的語法,比如引入巨集(macro)和型別檢驗(type system)這些只能靠靜態分析實現的功能。除了靜態載入帶來的各種好處,ES6 模組還有以下好處。

  • 不再需要UMD模組格式了,將來伺服器和瀏覽器都會支援 ES6 模組格式。目前,通過各種工具庫,其實已經做到了這一點。
  • 將來瀏覽器的新 API 就能用模組格式提供,不再必須做成全域性變數或者navigator物件的屬性。
  • 不再需要物件作為名稱空間(比如Math物件),未來這些功能可以通過模組提供。

ES6 的模組自動採用嚴格模式,不管有沒有在模組頭部加上"use strict";。 

嚴格模式主要有以下限制。

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

其中,尤其需要注意this的限制。ES6 模組之中,頂層的this指向undefined,即不應該在頂層程式碼使用this。

export

export語法被用來建立JavaScript模組。你可以用它來匯出物件(包括函式)和原始值(primitive values)。匯出有兩種型別:named和default。

// named
// lib.js
export function sum(a, b) {
    return a + b;
}
export function substract(a, b) {
    return a - b;
}
function divide(a, b) {
    return a / b;
}
export { divide };
// default
// dog.js
export default class Dog {
    bark() {
    console.log('bark!');
    }
}

import

import語句用來匯入其他模組。

整個匯入

// index.js 
import * as lib from './lib.js'; console.log(lib.sum(1,2)); console.log(lib.substract(3,1)); console.log(lib.divide(6,3));

匯入一個或多個named匯出

// index.js
import { sum, substract, divide } from './lib';
console.log(sum(1,2));
console.log(substract(3,1));
console.log(divide(6,3));

需要注意,相應的匯入匯出名字必須匹配。

匯入一個default匯出

// index.js 
import Dog from './dog.js'; 
const dog = new Dog(); 
dog.bark(); // 'bark!'

注意,defualt匯出在匯入時,可以用任意的名字。所以我們可以這樣做:

import Cat from './dog.js';

const dog = new Cat();
dog.bark(); // 'bark!'

參考

  1. 很全很全的JavaScript的模組講解 https://segmentfault.com/a/1190000012464333#articleHeader9
  2. JavaScript Module Systems Showdown: CommonJS vs AMD vs ES2015 https://auth0.com/blog/javascript-module-systems-showdown/
  3. ECMAScript 6 入門http://es6.ruanyifeng.com/#docs/module

相關文章