本文首發於 vivo互聯網技術 微信公衆號
連接: https://mp.weixin.qq.com/s/15sedEuUVTsgyUm1lswrKA
做者:Morrain
上一篇《前端科普系列(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
在以前的《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的,由於上面這三個模塊加載有前後順序,互相依賴。當一個前端應用業務規模足夠大後,這種依賴關係又變得異常難以維護。
綜上所述,前端須要模塊化,而且模塊化不光要處理全局變量污染、數據保護的問題,還要很好的解決模塊之間依賴關係的維護。
既然 JavaScript 須要模塊化來解決上面的問題,那就須要制定模塊化的規範,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
爲了方便,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
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 規範的模塊時,無外乎就是使用了 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 大體有以下幾種:
在聊 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" 的縮寫,意思就是"異步模塊定義"。
和 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 已經加載好在內存中了
前面提到的 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 的支持。
任何模塊化,都必須考慮的兩個問題就是導入依賴和導出接口。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,能夠自行學習。
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、參考文獻
更多內容敬請關注 vivo 互聯網技術 微信公衆號
注:轉載文章請先與微信號:Labs2020 聯繫。