前端科普系列(3):CommonJS 不是前端卻革命了前端

本文首發於 vivo互聯網技術 微信公衆號 
連接: https://mp.weixin.qq.com/s/15sedEuUVTsgyUm1lswrKA
做者:Morrain

1、前言

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

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

CommonJS 是一個項目,其目標是爲 JavaScript 在網頁瀏覽器以外建立模塊約定。建立這個項目的主要緣由是當時缺少廣泛可接受形式的 JavaScript 腳本模塊單元,模塊在與運行JavaScript 腳本的常規網頁瀏覽器所提供的不一樣的環境下能夠重複使用。

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

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

2、爲何須要模塊化

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

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

// index.html
<script>
var name = 'morrain'
var age = 18
</script>

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

// index.html
<script src="./mine.js"></script>
 
// mine.js
var name = 'morrain'
var age = 18

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

// 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 文件衝突。全局變量污染開始成爲開發者的噩夢。es6

二、模塊化的原型

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

// 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卻絕不知情!這顯然是不被容許的。web

聰明的開發者又開始利用 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的,由於上面這三個模塊加載有前後順序,互相依賴。當一個前端應用業務規模足夠大後,這種依賴關係又變得異常難以維護。

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

3、CommonJS 規範簡介

既然 JavaScript 須要模塊化來解決上面的問題,那就須要制定模塊化的規範,CommonJS 就是解決上面問題的模塊化規範,規範就是規範,沒有爲何,就和編程語言的語法同樣。咱們一塊兒來看看。

一、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

二、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

三、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

4、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 核心的規範,上面的實現中都知足了。很是簡單,沒想像的那麼難。

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

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

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

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

一、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" 的縮寫,意思就是"異步模塊定義"。

二、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 已經加載好在內存中了

三、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,敬請期待……

6、參考文獻

  1. CommonJS規範
  2. ES Module 的語法
  3. ES Module 的加載實現
  4. 前端模塊化開發解決方案詳解
  5. webpack模塊化原理-commonjs

更多內容敬請關注 vivo 互聯網技術 微信公衆號

注:轉載文章請先與微信號:Labs2020 聯繫。

相關文章
相關標籤/搜索