一、前言

上一篇《 前端科普系列(2):Node.js 換個角度看世界 》,我們聊了 Node.js 相關的東西,Node.js 能在誕生後火到如此一塌糊塗,離不開它成熟的模塊化實現,Node.js 的模塊化是在 CommonJS 規範的基礎上實現的。那 CommonJS 又是什麼呢?

先來看下,它在 維基百科 上的定義:

CommonJS 是一個項目,其目標是爲 JavaScript 在網頁瀏覽器之外創建模塊約定。創建這個項目的主要原因是當時缺乏普遍可接受形式的 JavaScript 腳本模塊單元,模塊在與運行JavaScript 腳本的常規網頁瀏覽器所提供的不同的環境下可以重複使用。

我們知道,很長一段時間 JavaScript 語言是沒有模塊化的概念的,直到 Node.js 的誕生, 把 JavaScript 語言帶到服務端後,面對文件系統、網絡、操作系統等等複雜的業務場景,模塊化就變得不可或缺 。於是 Node.js 和 CommonJS 規範就相得益彰、相映成輝,共同走入開發者的視線。

由此可見,CommonJS 最初是服務於服務端的,所以我說 CommonJS 不是前端,但它的載體是前端語言 JavaScript,爲後面前端模塊化的盛行產生了深遠的影響,奠定了結實的基礎。CommonJS:不是前端卻革命了前端!

二、爲什麼需要模塊化

1、沒有模塊化時,前端是什麼樣子

在之前的《Web:一路前行一路忘川》中,我們提到過 JavaScript 誕生之初只是作爲一個腳本語言來使用,做一些簡單的表單校驗等等。所以代碼量很少,最開始都是直接寫到 <script> 標籤裏,如下所示:

// index.html

<script>

var name = 'morrain'

var age = 18

</script>

隨着業務進一步複雜,Ajax 誕生以後,前端能做的事情越來越多,代碼量飛速增長,開發者們開始把 JavaScript 寫到獨立的 js 文件中,與 html 文件解耦。像下面這樣:

// index.html

<script src="./mine.js"></script>

// mine.js

var name = 'morrain'

var age = 18

再後來,更多的開發者參與進來,更多的 js 文件被引入進來:

// index.html

<script src="./mine.js"></script>

<script src="./a.js"></script>

<script src="./b.js"></script>

// mine.js

var name = 'morrain'

var age = 18

// a.js

var name = 'lilei'

var age = 15

// b.js

var name = 'hanmeimei'

var age = 13

不難發現,問題已經來了!JavaScript 在 ES6 之前是沒有模塊系統,也沒有封閉作用域的概念的,所以上面三個 js 文件裏申明的變量都會存在於全局作用域中。不同的開發者維護不同的 js 文件,很難保證不和其它 js 文件衝突。全局變量污染開始成爲開發者的噩夢。

2、模塊化的原型

爲了解決全局變量污染的問題,開發者開始使用命名空間的方法,既然命名會衝突,那就加上命名空間唄,如下所示:

// index.html

<script src="./mine.js"></script>

<script src="./a.js"></script>

<script src="./b.js"></script>

// mine.js

app.mine = {}

app.mine.name = 'morrain'

app.mine.age = 18

// a.js

app.moduleA = {}

app.moduleA.name = 'lilei'

app.moduleA.age = 15

// b.js

app.moduleB = {}

app.moduleB.name = 'hanmeimei'

app.moduleB.age = 13

此時,已經開始有隱隱約約的模塊化的概念,只不過是用命名空間實現的。這樣在一定程度上是解決了命名衝突的問題, b.js 模塊的開發者,可以很方便的通過 app.moduleA.name 來取到模塊A中的名字,但是也可以通過  app.moduleA.name = 'rename' 來任意改掉模塊A中的名字,而這件事情,模塊A卻毫不知情!這顯然是不被允許的。

聰明的開發者又開始利用 JavaScript 語言的函數作用域,使用閉包的特性來解決上面的這一問題。

// index.html

<script src="./mine.js"></script>

<script src="./a.js"></script>

<script src="./b.js"></script>

// mine.js

app.mine = (function(){

var name = 'morrain'

var age = 18

return {

getName: function(){

return name

}

}

})()

// a.js

app.moduleA = (function(){

var name = 'lilei'

var age = 15

return {

getName: function(){

return name

}

}

})()

// b.js

app.moduleB = (function(){

var name = 'hanmeimei'

var age = 13

return {

getName: function(){

return name

}

}

})()

現在 b.js 模塊可以通過

app.moduleA.getName() 來取到模塊A的名字,但是各個模塊的名字都保存在各自的函數內部,沒有辦法被其它模塊更改。這樣的設計,已經有了模塊化的影子,每個模塊內部維護私有的東西,開放接口給其它模塊使用,但依然不夠優雅,不夠完美。譬如上例中,模塊B可以取到模塊A的東西,但模塊A卻取不到模塊B的,因爲上面這三個模塊加載有先後順序,互相依賴。當一個前端應用業務規模足夠大後,這種依賴關係又變得異常難以維護。

綜上所述,前端需要模塊化,並且模塊化不光要處理全局變量污染、數據保護的問題,還要很好的解決模塊之間依賴關係的維護。

三、CommonJS 規範簡介

既然 JavaScript 需要模塊化來解決上面的問題,那就需要制定模塊化的規範,CommonJS 就是解決上面問題的模塊化規範,規範就是規範,沒有爲什麼,就和編程語言的語法一樣。我們一起來看看。

1、CommonJS 概述

Node.js 應用由模塊組成, 每個文件就是一個模塊,有自己的作用域。在一個文件裏面定義的變量、函數、類,都是私有的,對其他文件不可見。

// a.js

var name = 'morrain'

var age = 18

上面代碼中,a.js 是 Node.js 應用中的一個模塊,裏面申明的變量 name 和 age 是 a.js 私有的,其它文件都訪問不到。

CommonJS 規範還規定,每個模塊內部有兩個變量可以使用,require 和 module。

require 用來加載某個模塊

module 代表當前模塊,是一個對象,保存了當前模塊的信息。exports 是 module 上的一個屬性,保存了當前模塊要導出的接口或者變量,使用 require 加載的某個模塊獲取到的值就是那個模塊使用 exports 導出的值

// a.js

var name = 'morrain'

var age = 18

module.exports.name = name

module.exports.getAge = function(){

return age

}

//b.js

var a = require('a.js')

console.log(a.name) // 'morrain'

console.log(a.getAge())// 18

2、CommonJS 之 exports

爲了方便,Node.js 在實現 CommonJS 規範時,爲每個模塊提供一個 exports的私有變量,指向 module.exports。你可以理解爲 Node.js 在每個模塊開始的地方,添加了如下這行代碼。

var exports = module.exports

於是上面的代碼也可以這樣寫:

// a.js

var name = 'morrain'

var age = 18

exports.name = name

exports.getAge = function(){

return age

}

有一點要尤其注意,exports 是模塊內的私有局部變量,它只是指向了 module.exports,所以直接對 exports 賦值是無效的,這樣只是讓 exports 不再指向module.exports了而已。

如下所示:

// a.js

var name = 'morrain'

var age = 18

exports = name

如果一個模塊的對外接口,就是一個單一的值,可以使用 module.exports 導出

// a.js

var name = 'morrain'

var age = 18

module.exports = name

3、CommonJS 之 require

require 命令的基本功能是,讀入並執行一個 js 文件,然後返回該模塊的 exports 對象。如果沒有發現指定模塊,會報錯。

第一次加載某個模塊時,Node.js 會緩存該模塊。以後再加載該模塊,就直接從緩存取出該模塊的 module.exports 屬性返回了。

// a.js

var name = 'morrain'

var age = 18

exports.name = name

exports.getAge = function(){

return age

}

// b.js

var a = require('a.js')

console.log(a.name) // 'morrain'

a.name = 'rename'

var b = require('a.js')

console.log(b.name) // 'rename'

如上所示,第二次 require 模塊A時,並沒有重新加載並執行模塊A。而是直接返回了第一次 require 時的結果,也就是模塊A的 module.exports。

還一點需要注意,CommonJS 模塊的加載機制是,require 的是被導出的值的拷貝。也就是說,一旦導出一個值,模塊內部的變化就影響不到這個值 。

// a.js

var name = 'morrain'

var age = 18

exports.name = name

exports.age = age

exports.setAge = function(a){

age = a

}

// b.js

var a = require('a.js')

console.log(a.age) // 18

a.setAge(19)

console.log(a.age) // 18

四、CommonJS 實現

瞭解 CommonJS 的規範後,不難發現我們在寫符合 CommonJS 規範的模塊時,無外乎就是使用了 require 、 exports 、 module 三個東西,然後一個 js 文件就是一個模塊。如下所示:

// a.js

var name = 'morrain'

var age = 18

exports.name = name

exports.getAge = function () {

return age

}

// b.js

var a = require('a.js')

console.log('a.name=', a.name)

console.log('a.age=', a.getAge())

var name = 'lilei'

var age = 15

exports.name = name

exports.getAge = function () {

return age

}

// index.js

var b = require('b.js')

console.log('b.name=',b.name)

(滑動可查看

如果我們向一個立即執行函數提供 require 、 exports 、 module 三個參數,模塊代碼放在這個立即執行函數里面。模塊的導出值放在 module.exports 中,這樣就實現了模塊的加載。如下所示:

(function(module, exports, require) {

// b.js

var a = require("a.js")

console.log('a.name=', a.name)

console.log('a.age=', a.getAge())

var name = 'lilei'

var age = 15

exports.name = name

exports.getAge = function () {

return age

}

})(module, module.exports, require)

(滑動可查看

知道這個原理後,就很容易把符合 CommonJS 模塊規範的項目代碼,轉化爲瀏覽器支持的代碼。很多工具都是這麼實現的,從入口模塊開始,把所有依賴的模塊都放到各自的函數中,把所有模塊打包成一個能在瀏覽器中運行的 js 文件。譬如 Browserify 、webpack 等等。

我們以 webpack 爲例,看看如何實現對 CommonJS 規範的支持。我們使用 webpack 構建時,把各個模塊的文件內容按照如下格式打包到一個 js 文件中,因爲它是一個立即執行的匿名函數,所以可以在瀏覽器直接運行。

// bundle.js

(function (modules) {

// 模塊管理的實現

})({

'a.js': function (module, exports, require) {

// a.js 文件內容

},

'b.js': function (module, exports, require) {

// b.js 文件內容

},

'index.js': function (module, exports, require) {

// index.js 文件內容

}

})

(滑動可查看

接下來,我們需要按照 CommonJS 的規範,去實現模塊管理的內容。首先我們知道,CommonJS 規範有說明,加載過的模塊會被緩存,所以需要一個對象來緩存已經加載過的模塊,然後需要一個 require 函數來加載模塊,在加載時要生成一個 module,並且 module 上 要有一個 exports 屬性,用來接收模塊導出的內容。

// bundle.js

(function (modules) {

// 模塊管理的實現

var installedModules = {}

/**

* 加載模塊的業務邏輯實現

* @param {String} moduleName 要加載的模塊名

*/

var require = function (moduleName) {

// 如果已經加載過,就直接返回

if (installedModules[moduleName]) return installedModules[moduleName].exports

// 如果沒有加載,就生成一個 module,並放到 installedModules

var module = installedModules[moduleName] = {

moduleName: moduleName,

exports: {}

}

// 執行要加載的模塊

modules[moduleName].call(modules.exports, module, module.exports, require)

return module.exports

}

return require('index.js')

})({

'a.js': function (module, exports, require) {

// a.js 文件內容

},

'b.js': function (module, exports, require) {

// b.js 文件內容

},

'index.js': function (module, exports, require) {

// index.js 文件內容

}

})

(滑動可查看

可以看到, CommonJS 核心的規範,上面的實現中都滿足了。非常簡單,沒想像的那麼難。

五、其它前端模塊化的方案

我們對 CommonJS 的規範已經非常熟悉了,require 命令的基本功能是,讀入並執行一個 js 文件,然後返回該模塊的 exports 對象,這在服務端是可行的,因爲服務端加載並執行一個文件的時間消費是可以忽略的,模塊的加載是運行時同步加載的,require 命令執行完後,文件就執行完了,並且成功拿到了模塊導出的值。

這種規範天生就不適用於瀏覽器,因爲它是同步的。可想而知,瀏覽器端每加載一個文件,要髮網絡請求去取,如果網速慢,就非常耗時,瀏覽器就要一直等 require 返回,就會一直卡在那裏,阻塞後面代碼的執行,從而阻塞頁面渲染,使得頁面出現假死狀態。

爲了解決這個問題,後面發展起來了衆多的前端模塊化規範,包括 CommonJS 大致有如下幾種:

1、AMD (Asynchronous Module Definition)

在聊 AMD 之前,先熟悉一下 RequireJS。

官網是這麼介紹它的:

"RequireJS is a JavaScript file and module loader. It is optimized for in-browser use, but it can be used in other JavaScript environments, like Rhino and Node. Using a modular script loader like RequireJS will improve the speed and quality of your code."

翻譯過來大致就是:

RequireJS 是一個 js 文件和模塊加載器。它非常適合在瀏覽器中使用,但它也可以用在其他 js 環境, 就像 Rhino 和 Node。使用 RequireJS 加載模塊化腳本能提高代碼的加載速度和質量。

它解決了 CommonJS 規範不能用於瀏覽器端的問題,而 AMD 就是 RequireJS 在推廣過程中對模塊定義的規範化產出。

來看看 AMD 規範的實現:

<script src="require.js"></script>

<script src="a.js"></script>

首先要在 html 文件中引入 require.js 工具庫,就是這個庫提供了定義模塊、加載模塊等功能。它提供了一個全局的 define 函數用來定義模塊。所以在引入 require.js 文件後,再引入的其它文件,都可以使用 define 來定義模塊。

define(id?, dependencies?, factory)

id:可選參數,用來定義模塊的標識,如果沒有提供該參數,就使用 js 文件名(去掉拓展名)對於一個 js 文件只定義了一個模塊時,這個參數是可以省略的。dependencies:可選參數,是一個數組,表示當前模塊的依賴,如果沒有依賴可以不傳 factory:工廠方法,模塊初始化要執行的函數或對象。如果爲函數,它應該只被執行一次,返回值便是模塊要導出的值。如果是對象,此對象應該爲模塊的輸出值。

所以模塊A可以這麼定義:

// a.js

define(function(){

var name = 'morrain'

var age = 18

return {

name,

getAge: () => age

}

})

// b.js

define(['a.js'], function(a){

var name = 'lilei'

var age = 15

console.log(a.name) // 'morrain'

console.log(a.getAge()) // 18

return {

name,

getAge: () => age

}

})

(滑動可查看

它採用異步方式加載模塊,模塊的加載不影響它後面語句的運行。所有依賴這個模塊的語句,都定義在回調函數中,等到加載完成之後,這個回調函數纔會運行。

RequireJS 的基本思想是,通過 define 方法,將代碼定義爲模塊。當這個模塊被 require 時,它開始加載它依賴的模塊,當所有依賴的模塊加載完成後,開始執行回調函數,返回值是該模塊導出的值。AMD 是 "Asynchronous Module Definition" 的縮寫,意思就是"異步模塊定義"。

2、CMD (Common Module Definition)

和 AMD 類似,CMD 是 Sea.js 在推廣過程中對模塊定義的規範化產出。Sea.js 是阿里的玉伯寫的。它的誕生在 RequireJS 之後,玉伯覺得 AMD 規範是異步的,模塊的組織形式不夠自然和直觀。於是他在追求能像 CommonJS 那樣的書寫形式。於是就有了 CMD 。

Sea.js 官網這麼介紹 Sea.js:

"Sea.js 追求簡單、自然的代碼書寫和組織方式,具有以下核心特性:"

"簡單友好的模塊定義規範:Sea.js 遵循 CMD 規範,可以像 Node.js 一般書寫模塊代碼。自然直觀的代碼組織方式:依賴的自動加載、配置的簡潔清晰,可以讓我們更多地享受編碼的樂趣。"

來看看 CMD 規範的實現:

<script src="sea.js"></script>

<script src="a.js"></script>

首先要在 html 文件中引入 sea.js 工具庫,就是這個庫提供了定義模塊、加載模塊等功能。它提供了一個全局的 define 函數用來定義模塊。所以在引入 sea.js 文件後,再引入的其它文件,都可以使用 define 來定義模塊。

// 所有模塊都通過 define 來定義

define(function(require, exports, module) {

// 通過 require 引入依賴

var a = require('xxx')

var b = require('yyy')

// 通過 exports 對外提供接口

exports.doSomething = ...

// 或者通過 module.exports 提供整個接口

module.exports = ...

})

// a.js

define(function(require, exports, module){

var name = 'morrain'

var age = 18

exports.name = name

exports.getAge = () => age

})

// b.js

define(function(require, exports, module){

var name = 'lilei'

var age = 15

var a = require('a.js')

console.log(a.name) // 'morrain'

console.log(a.getAge()) //18

exports.name = name

exports.getAge = () => age

})

(滑動可查看

Sea.js 可以像 CommonsJS 那樣同步的形式書寫模塊代碼的祕訣在於:當 b.js 模塊被 require 時,b.js 加載後,Sea.js 會掃描 b.js 的代碼,找到 require 這個關鍵字,提取所有的依賴項,然後加載,等到依賴的所有模塊加載完成後,執行回調函數,此時再執行到 require('a.js') 這行代碼時,a.js 已經加載好在內存中了

3、ES6 Module

前面提到的 CommonJS 是服務於服務端的,而 AMD、CMD 是服務於瀏覽器端的,但它們都有一個共同點: 都在代碼運行後才能確定導出的內容CommonJS 實現 中可以看到。

還有一點需要注意,AMD 和 CMD 是社區的開發者們制定的模塊加載方案,並不是語言層面的標準。 從 ES6 開始,在語言標準的層面上,實現了模塊化功能,而且實現得相當簡單,完全可以取代 CommonJS 和 CMD、AMD 規範,成爲瀏覽器和服務器通用的模塊解決方案。

事實也是如些,早在2013年5月,Node.js 的包管理器 NPM 的作者 Isaac Z. Schlueter 說過 CommonJS 已經過時,Node.js 的內核開發者已經決定廢棄該規範 。原因主要有兩個,一個是因爲 Node.js 本身也不是完全採用 CommonJS 的規範,譬如在 CommonJS 之 exports 中的提到 exports 屬性就是 Node.js 自己加的,Node.js 當時是決定不再跟隨 CommonJS 的發展而發展了。二來就是 Node.js 也在逐步用 ES6 Module 替代 CommonJS。

2017.9.12 Node.js 發佈的 8.5.0 版本開始支持 ES6 Module。只不過是處於實驗階段。需要添加 --experimental-modules 參數。

2019.11.21 Node.js 發佈的 13.2.0 版本中取消了 --experimental-modules 參數 ,也就是說從 v13.2 版本開始,Node.js 已經默認打開了 ES6 Module 的支持。

(1) ES6 Module 語法

任何模塊化,都必須考慮的兩個問題就是導入依賴和導出接口。ES6 Module 也是如此,模塊功能主要由兩個命令構成:export 和 import。export 命令用於導出模塊的對外接口,import 命令用於導入其他模塊導出的內容。

具體語法講解請參考 阮一峯老師的教程 ,示例如下:

// a.js

export const name = 'morrain'

const age = 18

export function getAge () {

return age

}

//等價於

const name = 'morrain'

const age = 18

function getAge (){

return age

}

export {

name,

getAge

}

使用 export 命令定義了模塊的對外接口以後,其他 JavaScript 文件就可以通過 import 命令加載這個模塊。

// b.js

import { name as aName, getAge } from 'a.js'

export const name = 'lilei'

console.log(aName) // 'morrain'

const age = getAge()

console.log(age) // 18

// 等價於

import * as a from 'a.js'

export const name = 'lilei'

console.log(a.name) // 'morrin'

const age = a.getAge()

console.log(age) // 18

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

從上面的例子可以看到,使用 import 命令的時候,用戶需要知道所要導入的變量名,這有時候比較麻煩,於是 ES6 Module 規定了一種方便的用法,使用 export default命令,爲模塊指定默認輸出。

// a.js

const name = 'morrain'

const age = 18

function getAge () {

return age

}

export default {

name,

getAge

}

// b.js

import a from 'a.js'

console.log(a.name) // 'morrin'

const age = a.getAge()

console.log(age) // 18

顯然,一個模塊只能有一個默認輸出,因此 export default 命令只能使用一次。同時可以看到,這時 import 命令後面,不需要再使用大括號了。

除了基礎的語法外,還有 as 的用法、export 和 import 複合寫法、export * from 'a'、import()動態加載 等內容,可以自行學習。

前面提到的 Node.js 已經默認支持 ES6 Module ,瀏覽器也已經全面支持 ES6 Module。至於 Node.js 和 瀏覽器 如何使用 ES6 Module,可以自行學習。

(2)ES6 Module 和 CommonJS 的區別

CommonJS 只能在運行時確定導出的接口,實際導出的就是一個對象。而 ES6 Module 的設計思想是儘量的靜態化,使得編譯時就能確定模塊的依賴關係,以及導入和導出的變量,也就是所謂的"編譯時加載"。

正因爲如此,import 命令具有提升效果,會提升到整個模塊的頭部,首先執行。下面的代碼是合法的,因爲 import 的執行早於 getAge 的調用。

// a.js

export const name = 'morrain'

const age = 18

export function getAge () {

return age

}

// b.js

const age = getAge()

console.log(age) // 18

import { getAge } from 'a.js'

也正因爲 ES6 Module 是編譯時加載, 所以不能使用表達式和變量,因爲這些是隻有在運行時才能得到結果的語法結構。如下所示:

// 報錯

import { 'n' + 'ame' } from 'a.js'

// 報錯

let module = 'a.js'

import { name } from module

前面在 CommonJS 之 require 有提到,require 的是被導出的值的拷貝。也就是說,一旦導出一個值,模塊內部的變化就影響不到這個值。一起來看看,ES Module是什麼樣的。

先回顧一下之前的例子:

// a.js

var name = 'morrain'

var age = 18

exports.name = name

exports.age = age

exports.setAge = function(a){

age = a

}

// b.js

var a = require('a.js')

console.log(a.age) // 18

a.setAge(19)

console.log(a.age) // 18

使用 ES6 Module 來實現這個例子:

// a.js

var name = 'morrain'

var age = 18

const setAge = a => age = a

export {

name,

age,

setAge

}

// b.js

import * as a from 'a.js'

console.log(a.age) // 18

a.setAge(19)

console.log(a.age) // 19

ES6 Module 是 ES6 中對模塊的規範,ES6 是 ECMAScript 6.0 的簡稱,是 JavaScript 語言的下一代標準,已經在 2015 年 6 月正式發佈了。我們在第一節的《Web:一路前行一路忘川》中提過,ES6 從制定到發佈歷經了十幾年,引入了很多的新特性以及新的機制,對於開發者而言,學習成本還是蠻大的。

下一篇,聊聊 ES6+ 和 Babel,敬請期待……

六、參考文獻

  1. CommonJS規範

  2. ES Module 的語法

  3. ES Module 的加載實現

  4. 前端模塊化開發解決方案詳解

  5. webpack模塊化原理-commonjs

vivo互聯網技術

vivo移動互聯網是基於vivo 智能手機所建立的完整移動互聯網生態圈,圍繞vivo大數據運營,打造包括應用、遊戲、資訊、品牌、電商、內容、金融、搜索的全方位服務生態,滿足海量用戶的多樣化需求。

點一下,代碼無 bug

相關文章