JavaScript 模塊演化簡史

JavaScript 模塊演化簡史 從屬於筆者的 Web 開發基礎與工程實踐。本文主要總結自 The Evolution of JavaScript ModularityNative ECMAScript modules - the first overviewNative ECMAScript modules: the new features and differences from Webpack modulesJavaScript 語法學習資料索引 中註明的文章,更多深度思考參閱 2016-個人前端之路:工具化與工程化javascript

JavaScript 模塊化

當年 Brendan Eich 草創 JavaScript 之際,他應該沒法想象 JavaScript 在將來二十年內發揮的巨大做用;一樣做爲廣爲詬病的過於隨意的語言,缺少強有力的模塊化解決方案一直是 JavaScript 的缺陷之一。早期的 JavaScript 每每做爲嵌入到 HTML 頁面中的用於控制動畫與簡單的用戶交互的腳本語言,咱們習慣於將其直接嵌入到 script 標籤中:html

<!--html-->
<script type="application/javascript">
    // module1 code
    // module2 code
</script>

不過隨着單頁應用與富客戶端的流行,不斷增加的代碼庫也急需合理的代碼分割與依賴管理的解決方案,這也就是咱們在軟件工程領域所熟悉的模塊化(Modularity)。所謂模塊化主要是解決代碼分割、做用域隔離、模塊之間的依賴管理以及發佈到生產環境時的自動化打包與處理等多個方面。二十年間流行過的 JavaScript 模塊化解決方案包括但不限於直接聲明依賴(Directly Defined Dependences)、命名空間(Namespace Pattern)、模塊模式(Module Pattern)、依賴分離定義(Detached Dependency Definitions)、沙盒(Sandbox)、依賴注入(Dependency Injection)、CommonJS、AMD、UMD、標籤化模塊(Labeled Modules)、YModules、ES 2015 Modules。
在早期的 Web 開發中,全部的嵌入到網頁內的 JavaScript 對象都會使用全局的 window 對象來存放未使用 var 定義的變量。大概在上世紀末,JavaScript 多用於解決簡單的任務,這也就意味着咱們只需編寫少許的 JavaScript 代碼;不過隨着代碼庫的線性增加,咱們首先會碰到的就是所謂命名衝突(Name Collisions)困境:前端

// file greeting.js
var helloInLang = {
    en: 'Hello world!',
    es: '¡Hola mundo!',
    ru: 'Привет мир!'
};

function writeHello(lang) {
    document.write(helloInLang[lang]);
}

// file hello.js
function writeHello() {
    document.write('The script is broken');
}

當咱們在頁面內同時引入這兩個 JavaScript 腳本文件時,顯而易見兩個文件中定義的 writeHello 函數起了衝突,最後調用的函數取決於咱們引入的前後順序。此外在大型應用中,咱們不可能將全部的代碼寫入到單個 JavaScript 文件中;咱們也不可能手動地在 HTML 文件中引入所有的腳本文件,特別是此時還存在着模塊間依賴的問題,相信不少開發者都會遇到 jQuery 還沒有定義這樣的問題。不過物極必反,過分碎片化的模塊一樣會帶來性能的損耗與包體尺寸的增大,這包括了模塊加載、模塊解析、由於 Webpack 等打包工具包裹模塊時封裝的過多 IIFE 函數致使的 JavaScript 引擎優化失敗等。譬如咱們的源碼以下:java

// index.js
var total = 0
total += require('./module_0')
total += require('./module_1')
total += require('./module_2')
// etc.
console.log(total)

// module_0.js
module.exports = 0

// module_1.js
module.exports = 1

通過 Browserify 打包以後的代碼變成了以下式樣:webpack

(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
module.exports = 0
},{}],2:[function(require,module,exports){
module.exports = 1
},{}],3:[function(require,module,exports){
module.exports = 10
},{}],4:[function(require,module,exports){
module.exports = 100
// etc.

咱們分別測試 100、1000 與 5000 模塊,能夠發現隨着模塊數目的增加最後的包體大小並不是線性增加:
git

命名空間模式

命名空間模式始於 2002 年,顧名思義咱們可使用特殊的約定命名。譬如咱們能夠爲某個模塊內的變量統一添加 myApp_ 前綴,譬如 myApp_addressmyApp_validateUser() 等等。一樣,咱們也能夠將函數賦值給模塊內的變量或者對象的屬性,從而可使得能夠像 document.write() 這樣在子命名空間下定義函數而避免衝突。首個採樣該設計模式的界面庫當屬 Bindows,其是 Erik Arvidsson 建立於 2002 年。他沒有簡單地爲自定義函數或者對象添加命名前綴,而是將全部的 Bindows 當中的數據與邏輯代碼封裝於某個全局對象內,從而避免所謂的全局做用域污染。命名空間模式的設計思想以下所示:github

// file app.js
var app = {};

// file greeting.js
app.helloInLang = {
    en: 'Hello world!',
    es: '¡Hola mundo!',
    ru: 'Привет мир!'
};

// file hello.js
app.writeHello = function (lang) {
    document.write(app.helloInLang[lang]);
};

咱們能夠發現自定義代碼中的全部數據對象與函數都歸屬於全局對象 app,不過顯而易見這種方式對於大型多人協同項目的可維護性仍是較差,而且沒有解決模塊間依賴管理的問題。另外有時候咱們須要處理一些自動執行的 Pollyfill 性質的代碼,就須要將模塊包裹在自調用的函數中,譬如在某個大型應用中,咱們的代碼可能會切分爲以下幾個模塊:web

// polyfill-vendor.js
(function(){
    // polyfills-vendor code
}());

// module1.js
function module1(params){
    // module1 code
    return module1;
}

// module3.js
function module3(params){
    this.a = params.a;
}

module3.prototype.getA = function(){
    return this.a;
};

// app.js
var APP = {};

if(isModule1Needed){
    APP.module1 = module1({param1:1});
}

APP.module3 = new module3({a: 42});

那麼在引入的時候咱們須要手動地按照模塊間依賴順序引入進來:npm

<!--html-->
<script type="application/javascript" src="PATH/polyfill-vendor.js" ></script>
<script type="application/javascript" src="PATH/module1.js" ></script>
<script type="application/javascript" src="PATH/module2.js" ></script>
<script type="application/javascript" src="PATH/app.js" ></script>

不過這種方式對於模塊間通訊也是個麻煩。命名空間模式算是現在 JavaScript 領域最爲著名的模式之一,而在 Bindows 以後 Dojo(2005),YUI(2005) 這些優秀的界面框架也是承襲了這種思想。設計模式

依賴注入

Martin Fowler 於 2004 年提出了依賴注入(Dependency Injection)的概念,其主要用於 Java 中的組件內通訊;以 Spring 爲表明的一系列支持依賴注入與控制反轉的框架將這種設計模式發揚光大,而且成爲了 Java 服務端開發的標準模式之一。依賴注入的核心思想在於某個模塊不須要手動地初始化某個依賴對象,而只須要聲明該依賴並由外部框架自動實例化該對象實現而且傳遞到模塊內。而五年以後的 2009 年 Misko Hevery 開始設計新的 JavaScript 框架,而且使用了依賴注入做爲其組件間通訊的核心機制。這個框架就是引領一時風騷,甚至於說是現代 Web 開發先驅之一的 Angular。Angular 容許咱們定義模塊,而且在顯式地聲明其依賴模塊而由框架完成自動注入。其核心思想以下所示:

// file greeting.js
angular.module('greeter', [])
    .value('greeting', {
        helloInLang: {
            en: 'Hello world!',
            es: '¡Hola mundo!',
            ru: 'Привет мир!'
        },

        sayHello: function(lang) {
            return this.helloInLang[lang];
        }
    });

// file app.js
angular.module('app', ['greeter'])
    .controller('GreetingController', ['$scope', 'greeting', function($scope, greeting) {
        $scope.phrase = greeting.sayHello('en');
    }]);

以後在 Angular 2Slot 之中依賴注入還是核心機制之一,這也是 Angular 一系的更多的被視爲大而全的框架而不是小而美的庫的緣由之一。

CommonJS

在 Node.js 橫空出世以前,就已經有不少將運行於客戶端瀏覽器中的 JavaScript 遷移運行到服務端的框架;不過因爲缺少合適的規範,也沒有提供統一的與操做系統及運行環境交互的接口,這些框架並未流行開來。2009 年時 Mozilla 的僱員 Kevin Dangoor 發表了博客討論服務端 JavaScript 代碼面臨的困境,號召全部有志於規範服務端 JavaScript 接口的志同道合的開發者協同討論,羣策羣力,最終造成了 ServerJS 規範;一年以後 ServerJS 重命名爲 CommonJS。後來 CommonJS 內的模塊規範成爲了 Node.js 的標準實現規範,其基本語法爲 var commonjs = require("./commonjs");,核心設計模式以下所示:

// file greeting.js
var helloInLang = {
    en: 'Hello world!',
    es: '¡Hola mundo!',
    ru: 'Привет мир!'
};

var sayHello = function (lang) {
    return helloInLang[lang];
}

module.exports.sayHello = sayHello;

// file hello.js
var sayHello = require('./lib/greeting').sayHello;
var phrase = sayHello('en');
console.log(phrase);

該模塊實現方案主要包含 requiremodule 這兩個關鍵字,其容許某個模塊對外暴露部分接口而且由其餘模塊導入使用。在 Node.js 中咱們經過內建輔助函數來使用 CommonJS 的導入導出功能,而在其餘 JavaScript 引擎中咱們能夠將其包裹爲以下形式:

(function (exports, require, module, __filename, __dirname) {
    // ...
    // Your code injects here!
    // ...
});

CommonJS 規範自己只是定義了不一樣環境下支持模塊交互性的最小化原則,其具有極大的可擴展性。Node.js 中就對 require 函數添加了 main 屬性,該屬性在執行模塊所屬文件時指向 module 對象。Babel 在實現 ES2015 Modules 的轉義時也擴展了 require 關鍵字:

export default something;

Babel 將此類型的導出轉化爲了 CommonJS 模塊,簡單而言形式以下:

export.default = something;

Webpack 打包工具也使用了不少擴展,譬如 require.ensurerequire.cacherequire.context 等等。CommonJS 算是目前最流行的模塊格式,咱們不只能夠在 Node.js 中使用,還能夠經過 BrowserifyWebpack 這樣的打包工具將代碼打包到客戶端運行。另外咱們須要注意的是,Node.js 中的模塊在加載以後是以單例化運行,而且遵循值傳遞原則:

// obj.js
module.exports = {
    num:1
}

// primitive.js
module.exports = 1;

// modifier.js
var number = require('./primitive');
var obj = require('./obj');

number = 2;
obj.num = 2;

console.log(number);
console.log(obj);

// main.js
console.log(require('./primitive'));
console.log(require('./obj'));

require('./modifier.js')

console.log(require('./primitive'));
console.log(require('./obj'));

// 執行結果
1
{ num: 1 }
2
{ num: 2 }
1
{ num: 2 }

AMD

就在 CommonJS 規範火熱討論的同時,不少開發者也關注於如何實現模塊的異步加載。Web 應用的性能優化一直是前端工程實踐中不可避免的問題,而模塊的異步加載以及預加載等機制能有效地優化 Web 應用的加載速度。Mozilla 的另外一位僱員 James Burke討論組的活躍成員,他在 Dojo 1.7 版本中引入了異步模塊機制,而且在 2009 年開發了 require.js 框架。James 的核心思想在於不該該以同步方式加載模塊,而應該充分利用瀏覽器的併發加載能力;James 按照其設計理念開發出的模塊工具就是 AMD(Asynchronous Module Definition),其基本形式以下:

define(["amd-module", "../file"], function(amdModule, file) {
    require(["big-module/big/file"], function(big) {
        var stuff = require("../my/stuff");
    });
});

而將咱們上述使用的例子改寫爲 AMD 模式應當以下所示:

// file lib/greeting.js
define(function() {
    var helloInLang = {
        en: 'Hello world!',
        es: '¡Hola mundo!',
        ru: 'Привет мир!'
    };

    return {
        sayHello: function (lang) {
            return helloInLang[lang];
        }
    };
});

// file hello.js
define(['./lib/greeting'], function(greeting) {
    var phrase = greeting.sayHello('en');
    document.write(phrase);
});

hello.js 做爲整個應用的入口模塊,咱們使用 define 關鍵字聲明瞭該模塊以及外部依賴;當咱們執行該模塊代碼時,也就是執行 define 函數的第二個參數中定義的函數功能,其會在框架將全部的其餘依賴模塊加載完畢後被執行。這種延遲代碼執行的技術也就保證了依賴的併發加載。從我我的而言,AMD 及其相關技術對於前端開發的工程化進步有着很是積極的意義,不過隨着以 npm 爲主導的依賴管理機制的統一,愈來愈多的開發者放棄了使用 AMD 模式。

UMD

AMD 與 CommonJS 雖然師出同源,但仍是分道揚鑣,關注於代碼異步加載與最小化入口模塊的開發者將目光投注於 AMD;而隨着 Node.js 以及 Browserify 的流行,愈來愈多的開發者也接受了 CommonJS 規範。使人扼腕嘆息的是,符合 AMD 規範的模塊並不能直接運行於實踐了 CommonJS 模塊規範的環境中,符合 CommonJS 規範的模塊也不能由 AMD 進行異步加載,整個 JavaScript 生態圈貌似分崩離析。2011 年中,UMD,也就是 Universal Module Definition 規範正是爲了彌合這種不一致性應運而出,其容許在環境中同時使用 AMD 與 CommonJS 規範。Q 算是 UMD 的首個規範實現,其能同時運行於瀏覽器環境(以腳本標籤形式嵌入)與服務端的 Node.js 或者 Narwhal(CommonJS 模塊)環境中;稍後,James 也爲 Q 添加了對於 AMD 的支持。咱們將上述例子中的 greeting.js 改寫爲同時支持 CommonJS 與 AMD 規範的模塊:

(function(define) {
    define(function () {
        var helloInLang = {
            en: 'Hello world!',
            es: '¡Hola mundo!',
            ru: 'Привет мир!'
        };

        return {
            sayHello: function (lang) {
                return helloInLang[lang];
            }
        };
    });
}(
    typeof module === 'object' && module.exports && typeof define !== 'function' ?
    function (factory) { module.exports = factory(); } :
    define
));

該模式的核心思想在於所謂的 IIFE(Immediately Invoked Function Expression),該函數會根據環境來判斷須要的參數類別,譬如在 CommonJS 環境下上述代碼會以以下方式執行:

function (factory) {
    module.exports = factory();
}

而若是是在 AMD 模塊規範下,函數的參數就變成了 define。正是由於這種運行時的靈活性是咱們可以將同一份代碼運行於不一樣的環境中。

ES2015 Modules

JavaScript 模塊規範領域羣雄逐鹿,各領風騷,做爲 ECMAScript 標準的起草者 TC39 委員會天然也不能置身事外。ES2015 Modules 規範始於 2010 年,主要由 Dave Herman 主導;隨後的五年中 David 還參與了 asm.js,emscription,servo,等多個重大的開源項目,也使得 ES2015 Modules 的設計可以從多方面進行考慮與權衡。而最後的模塊化規範定義於 2015 年正式發佈,也就是被命名爲 ES2015 Modules。咱們上述的例子改寫爲 ES2015 Modules 規範以下所示:

// file lib/greeting.js
const helloInLang = {
    en: 'Hello world!',
    es: '¡Hola mundo!',
    ru: 'Привет мир!'
};

export const greeting = {
    sayHello: function (lang) {
        return helloInLang[lang];
    }
};

// file hello.js
import { greeting } from "./lib/greeting";
const phrase = greeting.sayHello("en");
document.write(phrase);

ES2015 Modules 中主要的關鍵字就是 importexport,前者負責導入模塊然後者負責導出模塊。完整的導出語法以下所示:

// default exports
export default 42;
export default {};
export default [];
export default foo;
export default function () {}
export default class {}
export default function foo () {}
export default class foo {}

// variables exports
export var foo = 1;
export var foo = function () {};
export var bar; // lazy initialization
export let foo = 2;
export let bar; // lazy initialization
export const foo = 3;
export function foo () {}
export class foo {}

// named exports
export {foo};
export {foo, bar};
export {foo as bar};
export {foo as default};
export {foo as default, bar};

// exports from
export * from "foo";
export {foo} from "foo";
export {foo, bar} from "foo";
export {foo as bar} from "foo";
export {foo as default} from "foo";
export {foo as default, bar} from "foo";
export {default} from "foo";
export {default as foo} from "foo";

相對應的完整的支持的導入方式以下所示:

// default imports
import foo from "foo";
import {default as foo} from "foo";

// named imports
import {bar} from "foo";
import {bar, baz} from "foo";
import {bar as baz} from "foo";
import {bar as baz, xyz} from "foo";

// glob imports
import * as foo from "foo";

// mixing imports
import foo, {baz as xyz} from "foo";
import * as bar, {baz as xyz} from "foo";
import foo, * as bar, {baz as xyz} from "foo";

ES2015 Modules 做爲 JavaScript 官方標準,日漸成爲了開發者的主流選擇。雖然咱們目前還不能直接保證在全部環境(特別是舊版本瀏覽器)中使用該規範,可是經過 Babel 等轉化工具能幫咱們自動處理向下兼容。此外 ES2015 Modules 仍是有些許被詬病的地方,譬如導入語句只能做爲模塊頂層的語句出現,不能出如今 function 裏面或是 if 裏面:

if(Math.random()>0.5){
  import './module1.js'; // SyntaxError: Unexpected keyword 'import'
}
const import2 = (import './main2.js'); // SyntaxError
try{
  import './module3.js'; // SyntaxError: Unexpected keyword 'import'
}catch(err){
  console.error(err);
}
const moduleNumber = 4;

import module4 from `module${moduleNumber}`; // SyntaxError: Unexpected token

而且 import 語句會被提高到文件頂部執行,也就是說在模塊初始化的時候全部的 import 都必須已經導入完成:

import './module1.js';

alert('code1');

import module2 from './module2.js';

alert('code2');

import module3 from './module3.js';

// 執行結果
module1
module2
module3
code1
code2

而且 import 的模塊名只能是字符串常量,導入的值也是不可變對象;好比說你不能 import { a } from './a' 而後給 a 賦值個其餘什麼東西。這些設計雖然使得靈活性不如 CommonJS 的 require,但卻保證了 ES6 Modules 的依賴關係是肯定(Deterministic)的,和運行時的狀態無關,從而也就保證了 ES6 Modules 是能夠進行可靠的靜態分析的。對於主要在服務端運行的 Node 來講,全部的代碼都在本地,按需動態 require 便可,但對於要下發到客戶端的 Web 代碼而言,要作到高效的按需使用,不能等到代碼執行了才知道模塊的依賴,必需要從模塊的靜態分析入手。這是 ES6 Modules 在設計時的一個重要考量,也是爲何沒有直接採用 CommonJS。此外咱們還須要關注下的是 ES2015 Modules 在瀏覽器內的原生支持狀況,儘管咱們能夠經過 Webpack 等打包工具將應用打包爲單個包文件。目前主流瀏覽器中默認支持 ES2015 Modules 只有 Safari,而 Firefox 在 54 版本以後容許用戶手動啓用該特性。以 Firefox 爲例,若是咱們在瀏覽器中使用 ES2015 Modules,咱們須要聲明入口模塊:

<script type="module" scr="PATH/file.js"></script>

這裏的 module 關鍵字就告訴瀏覽器該腳本中包含了對於其餘腳本的導入語句,須要進行預先處理;不過問題來了,那麼 JavaScript 解釋器又該如何判斷某個文件是否爲模塊。社區也通過不少輪的討論,咱們能夠來看下簡單的例子:

<!--index.html-->
<!DOCTYPE html>
<html>
  <head>
    <script type="module" src="main.js"></script>
  </head>
  <body>
  </body>
</html>

main.js 的代碼實現以下:

// main.js
import utils from "./utils.js";

utils.alert(`
  JavaScript modules work in this browser:
  https://blog.whatwg.org/js-modules
`);

待導入的模塊以下:

// utils.js
export default {
    alert: (msg)=>{
        alert(msg);
    }
};

咱們能夠發現,在 import 語句中咱們提供了 .js 擴展名,這也是區別於打包工具的重要特性之一,每每打包工具中並不須要咱們提供擴展名。此外,在瀏覽器中進行模塊的動態加載,也要求待加載文件具備正確的 MIME 類型。咱們經常使用的正確的模塊地址譬如:

https://example.com/apples.js
http:example.com\pears.mjs (becomes http://example.com/pears.mjs as step 1 parses with no base URL)
//example.com/bananas
./strawberries.js.cgi
../lychees
/limes.jsx
data:text/javascript,export default ‘grapes’;
blob:https://whatwg.org/d0360e2f-caee-469f-9a2f-87d5b0456f6f

不過筆者以爲有個不錯的特性在於瀏覽器中支持 CORS 協議,跨域加載其餘域中的腳本。在瀏覽器中加載進來的模塊與直接加載的腳本的做用域也是不一致的,而且不須要 use strict 聲明其也默認處於嚴格模式下:

var x = 1;

alert(x === window.x);//false
alert(this === undefined);// true

瀏覽器對於模塊的加載默認是異步延遲進行的,即模塊腳本的加載並不會阻塞瀏覽器的解析行爲,而是併發加載並在頁面加載完畢後進行解析,也就是全部的模塊腳本具備 defer 屬性。咱們也能夠爲腳本添加 async 屬性,即指明該腳本會在加載完畢後馬上執行。這一點與傳統的非模塊腳本相比很大不一樣,傳統的腳本會阻塞瀏覽器解析直到抓取完畢,在抓取以後也會馬上進行執行操做。整個加載流程以下所示:

相關文章
相關標籤/搜索