前端模塊化的前世
前言
隨著前端項(xiàng)目的越來(lái)越龐大,組件化的前端框架,前端路由等技術(shù)的發(fā)展,模塊化已經(jīng)成為現(xiàn)代前端工程師的一項(xiàng)必備技能。無(wú)論是什么語(yǔ)言一旦發(fā)展到一定地步,其工程化能力和可維護(hù)性勢(shì)必得到相應(yīng)的發(fā)展。
模塊化這件事,無(wú)論在哪個(gè)編程領(lǐng)域都是相當(dāng)常見(jiàn)的事情,模塊化存在的意義就是為了增加可復(fù)用性,以盡可能少的代碼是實(shí)現(xiàn)個(gè)性化的需求。同為前端三劍客之一的 CSS 早在 2.1 的版本就提出了 @import 來(lái)實(shí)現(xiàn)模塊化,但是 JavaScript 直到 ES6 才出現(xiàn)官方的模塊化方案: ES Module (import、export)。盡管早期 JavaScript 語(yǔ)言規(guī)范上不支持模塊化,但這并沒(méi)有阻止 JavaScript 的發(fā)展,官方?jīng)]有模塊化標(biāo)準(zhǔn)開(kāi)發(fā)者們就開(kāi)始自己創(chuàng)建規(guī)范,自己實(shí)現(xiàn)規(guī)范。
CommonJS 的出現(xiàn)
十年前的前端沒(méi)有像現(xiàn)在這么火熱,模塊化也只是使用閉包簡(jiǎn)單的實(shí)現(xiàn)一個(gè)命名空間。2009 年對(duì) JavaScript 無(wú)疑是重要的一年,新的 JavaScript 引擎 (v8) ,并且有成熟的庫(kù) (jQuery、YUI、Dojo),ES5 也在提案中,然而 JavaScript 依然只能出現(xiàn)在瀏覽器當(dāng)中。早在2007年,AppJet 就提供了一項(xiàng)服務(wù),創(chuàng)建和托管服務(wù)端的 JavaScript 應(yīng)用。后來(lái) Aptana 也提供了一個(gè)能夠在服務(wù)端運(yùn)行 Javascript 的環(huán)境,叫做 Jaxer。網(wǎng)上還能搜到關(guān)于 AppJet、Jaxer 的博客,甚至 Jaxer 項(xiàng)目還在github上。
Jaxer
但是這些東西都沒(méi)有發(fā)展起來(lái),Javascript 并不能替代傳統(tǒng)的服務(wù)端腳本語(yǔ)言 (PHP、Python、Ruby) 。盡管它有很多的缺點(diǎn),但是不妨礙有很多人使用它。后來(lái)就有人開(kāi)始思考 JavaScript 要在服務(wù)端運(yùn)行還需要些什么?于是在 2009 年 1 月,Mozilla 的工程師 Kevin Dangoor 發(fā)起了 CommonJS 的提案,呼吁 JavaScript 愛(ài)好者聯(lián)合起來(lái),編寫(xiě) JavaScript 運(yùn)行在服務(wù)端的相關(guān)規(guī)范,一周之后,就有了 224 個(gè)參與者。
“"[This] is not a technical problem,It's a matter of people getting together and making a decision to step forward and start building up something bigger and cooler together."
CommonJS 標(biāo)準(zhǔn)囊括了 JavaScript 需要在服務(wù)端運(yùn)行所必備的基礎(chǔ)能力,比如:模塊化、IO 操作、二進(jìn)制字符串、進(jìn)程管理、Web網(wǎng)關(guān)接口 (JSGI) 。但是影響最深遠(yuǎn)的還是 CommonJS 的模塊化方案,CommonJS 的模塊化方案是JavaScript社區(qū)第一次在模塊系統(tǒng)上取得的成果,不僅支持依賴管理,而且還支持作用域隔離和模塊標(biāo)識(shí)。再后來(lái) node.js 出世,他直接采用了 CommonJS 的模塊化規(guī)范,同時(shí)還帶來(lái)了npm (Node Package Manager,現(xiàn)在已經(jīng)是全球最大模塊倉(cāng)庫(kù)了) 。
CommonJS 在服務(wù)端表現(xiàn)良好,很多人就想將 CommonJS 移植到客戶端 (也就是我們說(shuō)的瀏覽器) 進(jìn)行實(shí)現(xiàn)。由于CommonJS 的模塊加載是同步的,而服務(wù)端直接從磁盤或內(nèi)存中讀取,耗時(shí)基本可忽略,但是在瀏覽器端如果還是同步加載,對(duì)用戶體驗(yàn)極其不友好,模塊加載過(guò)程中勢(shì)必會(huì)向服務(wù)器請(qǐng)求其他模塊代碼,網(wǎng)絡(luò)請(qǐng)求過(guò)程中會(huì)造成長(zhǎng)時(shí)間白屏。所以從 CommonJS 中逐漸分裂出來(lái)了一些派別,在這些派別的發(fā)展過(guò)程中,出現(xiàn)了一些業(yè)界較為熟悉方案 AMD、CMD、打包工具(Component/Browserify/Webpack)。
AMD規(guī)范:RequireJS
RequireJS logo
RequireJS 是 AMD 規(guī)范的代表之作,它之所以能代表 AMD 規(guī)范,是因?yàn)?RequireJS 的作者 (James Burke) 就是 AMD 規(guī)范的提出者。同時(shí)作者還開(kāi)發(fā)了 amdefine,一個(gè)讓你在 node 中也可以使用 AMD 規(guī)范的庫(kù)。
AMD 規(guī)范由 CommonJS 的 Modules/Transport/C 提案發(fā)展而來(lái),毫無(wú)疑問(wèn),Modules/Transport/C 提案的發(fā)起者就是 James Burke。
James Burke 指出了 CommonJS 規(guī)范在瀏覽器上的一些不足:
- 缺少模塊封裝的能力:CommonJS 規(guī)范中的每個(gè)模塊都是一個(gè)文件。這意味著每個(gè)文件只有一個(gè)模塊。這在服務(wù)器上是可行的,但是在瀏覽器中就不是很友好,瀏覽器中需要做到盡可能少的發(fā)起請(qǐng)求。
- 使用同步的方式加載依賴:雖然同步的方法進(jìn)行加載可以讓代碼更容易理解,但是在瀏覽器中使用同步加載會(huì)導(dǎo)致長(zhǎng)時(shí)間白屏,影響用戶體驗(yàn)。
- CommonJS 規(guī)范使用一個(gè)名為 export 的對(duì)象來(lái)暴露模塊,將需要導(dǎo)出變量附加到export 上,但是不能直接給該對(duì)象進(jìn)行賦值。如果需要導(dǎo)出一個(gè)構(gòu)造函數(shù),則需要使用module.export,這會(huì)讓人感到很疑惑。
AMD 規(guī)范定義了一個(gè) define 全局方法用來(lái)定義和加載模塊,當(dāng)然 RequireJS 后期也擴(kuò)展了require 全局方法用來(lái)加載模塊 。通過(guò)該方法解決了在瀏覽器使用 CommonJS 規(guī)范的不足。
- define(id?, dependencies?, factory);
使用匿名函數(shù)來(lái)封裝模塊,并通過(guò)函數(shù)返回值來(lái)定義模塊,這更加符合 JavaScript 的語(yǔ)法,這樣做既避免了對(duì) exports 變量的依賴,又避免了一個(gè)文件只能暴露一個(gè)模塊的問(wèn)題。
提前列出依賴項(xiàng)并進(jìn)行異步加載,這在瀏覽器中,這能讓模塊開(kāi)箱即用。
- define("foo", ["logger"], function (logger) {
- logger.debug("starting foo's definition")
- return {
- name: "foo"
- }
- })
為模塊指定一個(gè)模塊 ID (名稱) 用來(lái)唯一標(biāo)識(shí)定義中模塊。此外,AMD的模塊名規(guī)范是 CommonJS 模塊名規(guī)范的超集。
- define("foo", function () {
- return {
- name: 'foo'
- }
- })
RequireJS 原理
在討論原理之前,我們可以先看下 RequireJS 的基本使用方式。
- 模塊信息配置:
- require.config({
- paths: {
- jquery: 'https://code.jquery.com/jquery-3.4.1.js'
- }
- })
- 依賴模塊加載與調(diào)用:
- require(['jquery'], function ($){
- $('#app').html('loaded')
- })
- 模塊定義:
- if ( typeof define === "function" && define.amd ) {
- define( "jquery", [], function() {
- return jQuery;
- } );
- }
我們首先使用 config 方法進(jìn)行了 jquery 模塊的路徑配置,然后調(diào)用 require 方法加載 jquery 模塊,之后在回調(diào)中調(diào)用已加載完成的 $ 對(duì)象。在這個(gè)過(guò)程中,jquery 會(huì)使用define 方法暴露出我們所需要的 $ 對(duì)象。
在了解了基本的使用過(guò)程后,我們就繼續(xù)深入 RequireJS 的原理。
模塊信息配置
模塊信息的配置,其實(shí)很簡(jiǎn)單,只用幾行代碼就能實(shí)現(xiàn)。定義一個(gè)全局對(duì)象,然后使用 Object.assign 進(jìn)行對(duì)象擴(kuò)展。
- // 配置信息
- const cfg = { paths: {} }
- // 全局 require 方法
- req = require = () => {}
- // 擴(kuò)展配置
- req.config = config => {
- Object.assign(cfg, config)
- }
依賴模塊加載與調(diào)用
require 方法的邏輯很簡(jiǎn)單,進(jìn)行簡(jiǎn)單的參數(shù)校驗(yàn)后,調(diào)用 getModule 方法對(duì) Module 進(jìn)行了實(shí)例化,getModule 會(huì)對(duì)已經(jīng)實(shí)例化的模塊進(jìn)行緩存。因?yàn)?require 方法進(jìn)行模塊實(shí)例的時(shí)候,并沒(méi)有模塊名,所以這里產(chǎn)生的是一個(gè)匿名模塊。Module 類,我們可以理解為一個(gè)模塊加載器,主要作用是進(jìn)行依賴的加載,并在依賴加載完畢后,調(diào)用回調(diào)函數(shù),同時(shí)將依賴的模塊逐一作為參數(shù)回傳到回調(diào)函數(shù)中。
- // 全局 require 方法
- req = require = (deps, callback) => {
- if (!deps && !callback) {
- return
- }
- if (!deps) {
- deps = []
- }
- if (typeof deps === 'function') {
- callback = deps
- deps = []
- }
- const mod = getModule()
- mod.init(deps, callback)
- }
- let reqCounter = 0
- const registry = {} // 已注冊(cè)的模塊
- // 模塊加載器的工廠方法
- const getModule = name => {
- if (!name) {
- // 如果模塊名不存在,表示為匿名模塊,自動(dòng)構(gòu)造模塊名
- name = `@mod_${++reqCounter}`
- }
- let mod = registry[name]
- if (!mod) {
- mod = registry[name] = new Module(name)
- }
- return mod
- }
模塊加載器是是整個(gè)模塊加載的核心,主要包括 enable 方法和 check 方法。
模塊加載器在完成實(shí)例化之后,會(huì)首先調(diào)用 init 方法進(jìn)行初始化,初始化的時(shí)候傳入模塊的依賴以及回調(diào)。
- // 模塊加載器
- class Module {
- constructor(name) {
- this.name = name
- this.depCount = 0
- this.depMaps = []
- this.depExports = []
- this.definedFn = () => {}
- }
- init(deps, callback) {
- this.deps = deps
- this.callback = callback
- // 判斷是否存在依賴
- if (deps.length === 0) {
- this.check()
- } else {
- this.enable()
- }
- }
- }
enable 方法主要用于模塊的依賴加載,該方法的主要邏輯如下:
1.遍歷所有的依賴模塊;
2.記錄已加載模塊數(shù) (this.depCount++),該變量用于判斷依賴模塊是否全部加載完畢;
3.實(shí)例化依賴模塊的模塊加載器,并綁定 definedFn 方法;
“definedFn 方法會(huì)在依賴模塊加載完畢后調(diào)用,主要作用是獲取依賴模塊的內(nèi)容,并將 depCount 減 1,最后調(diào)用 check 方法 (該方法會(huì)判斷 depCount 是否已經(jīng)小于 1,以此來(lái)界定依賴全部加載完畢);
4.最后通過(guò)依賴模塊名,在配置中獲取依賴模塊的路徑,進(jìn)行模塊加載。
- class Module {
- ...
- // 啟用模塊,進(jìn)行依賴加載
- enable() {
- // 遍歷依賴
- this.deps.forEach((name, i) => {
- // 記錄已加載的模塊數(shù)
- this.depCount++
- // 實(shí)例化依賴模塊的模塊加載器,綁定模塊加載完畢的回調(diào)
- const mod = getModule(name)
- mod.definedFn = exports => {
- this.depCount--
- this.depExports[i] = exports
- this.check()
- }
- // 在配置中獲取依賴模塊的路徑,進(jìn)行模塊加載
- const url = cfg.paths[name]
- loadModule(name, url)
- });
- }
- ...
- }
loadModule 的主要作用就是通過(guò) url 去加載一個(gè) js 文件,并綁定一個(gè) onload 事件。onload 會(huì)重新獲取依賴模塊已經(jīng)實(shí)例化的模塊加載器,并調(diào)用 init 方法。
- // 緩存加載的模塊
- const defMap = {}
- // 依賴的加載
- const loadModule = (name, url) => {
- const head = document.getElementsByTagName('head')[0]
- const node = document.createElement('script')
- node.type = 'text/javascript'
- node.async = true
- // 設(shè)置一個(gè) data 屬性,便于依賴加載完畢后拿到模塊名
- node.setAttribute('data-module', name)
- node.addEventListener('load', onScriptLoad, false)
- node.src = url
- head.appendChild(node)
- return node
- }
- // 節(jié)點(diǎn)綁定的 onload 事件函數(shù)
- const onScriptLoad = evt => {
- const node = evt.currentTarget
- node.removeEventListener('load', onScriptLoad, false)
- // 獲取模塊名
- const name = node.getAttribute('data-module')
- const mod = getModule(name)
- const def = defMap[name]
- mod.init(def.deps, def.callback)
- }
看到之前的案例,因?yàn)橹挥幸粋€(gè)依賴 (jQuery),并且 jQuery 模塊并沒(méi)有其他依賴,所以init 方法會(huì)直接調(diào)用 check 方法。這里也可以思考一下,如果是一個(gè)有依賴項(xiàng)的模塊后續(xù)的流程是怎么樣的呢?
- define( "jquery", [] /* 無(wú)其他依賴 */, function() {
- return jQuery;
- } );
check 方法主要用于依賴檢測(cè),以及調(diào)用依賴加載完畢后的回調(diào)。
- // 模塊加載器
- class Module {
- ...
- // 檢查依賴是否加載完畢
- check() {
- let exports = this.exports
- //如果依賴數(shù)小于1,表示依賴已經(jīng)全部加載完畢
- if (this.depCount < 1) {
- // 調(diào)用回調(diào),并獲取該模塊的內(nèi)容
- exports = this.callback.apply(null, this.depExports)
- this.exports = exports
- //激活 defined 回調(diào)
- this.definedFn(exports)
- }
- }
- ...
- }
最終通過(guò) definedFn 重新回到被依賴模塊,也就是最初調(diào)用 require 方法實(shí)例化的匿名模塊加載器中,將依賴模塊暴露的內(nèi)容存入 depExports 中,然后調(diào)用匿名模塊加載器的check 方法,調(diào)用回調(diào)。
- mod.definedFn = exports => {
- this.depCount--
- this.depExports[i] = exports
- this.check()
- }
模塊定義
還有一個(gè)疑問(wèn)就是,在依賴模塊加載完畢的回調(diào)中,怎么拿到的依賴模塊的依賴和回調(diào)呢?
- const def = defMap[name]
- mod.init(def.deps, def.callback)
答案就是通過(guò)全局定義的 define 方法,該方法會(huì)將模塊的依賴項(xiàng)還有回調(diào)存儲(chǔ)到一個(gè)全局變量,后面只要按需獲取即可。
- const defMap = {} // 緩存加載的模塊
- define = (name, deps, callback) => {
- defMap[name] = { name, deps, callback }
- }
RequireJS 原理總結(jié)
最后可以發(fā)現(xiàn),RequireJS 的核心就在于模塊加載器的實(shí)現(xiàn),不管是通過(guò) require 進(jìn)行依賴加載,還是使用 define 定義模塊,都離不開(kāi)模塊加載器。
感興趣的可以在我的github上查看關(guān)于簡(jiǎn)化版 RequrieJS 的完整代碼 。
CMD規(guī)范:sea.js
sea.js logo
CMD 規(guī)范由國(guó)內(nèi)的開(kāi)發(fā)者玉伯提出,盡管在國(guó)際上的知名度遠(yuǎn)不如 AMD ,但是在國(guó)內(nèi)也算和 AMD 齊頭并進(jìn)。相比于 AMD 的異步加載,CMD 更加傾向于懶加載,而且 CMD 的規(guī)范與 CommonJS 更貼近,只需要在 CommonJS 外增加一個(gè)函數(shù)調(diào)用的包裝即可。
- define(function(require, exports, module) {
- require("./a").doSomething()
- require("./b").doSomething()
- })
作為 CMD 規(guī)范的實(shí)現(xiàn) sea.js 也實(shí)現(xiàn)了類似于 RequireJS 的 api:
- seajs.use('main', function (main) {
- main.doSomething()
- })
sea.js 在模塊加載的方式上與 RequireJS 一致,都是通過(guò)在 head 標(biāo)簽插入 script 標(biāo)簽進(jìn)行加載的,但是在加載順序上有一定的區(qū)別。要講清楚這兩者之間的差別,我們還是直接來(lái)看一段代碼:
RequireJS :
- // RequireJS
- define('a', function () {
- console.log('a load')
- return {
- run: function () { console.log('a run') }
- }
- })
- define('b', function () {
- console.log('b load')
- return {
- run: function () { console.log('b run') }
- }
- })
- require(['a', 'b'], function (a, b) {
- console.log('main run')
- a.run()
- b.run()
- })
requirejs result
sea.js :
- // sea.js
- define('a', function (require, exports, module) {
- console.log('a load')
- exports.run = function () { console.log('a run') }
- })
- define('b', function (require, exports, module) {
- console.log('b load')
- exports.run = function () { console.log('b run') }
- })
- define('main', function (require, exports, module) {
- console.log('main run')
- var a = require('a')
- a.run()
- var b = require('b')
- b.run()
- })
- seajs.use('main')
sea.js result
可以看到 sea.js 的模塊屬于懶加載,只有在 require 的地方,才會(huì)真正運(yùn)行模塊。而 RequireJS,會(huì)先運(yùn)行所有的依賴,得到所有依賴暴露的結(jié)果后再執(zhí)行回調(diào)。
正是因?yàn)閼屑虞d的機(jī)制,所以 sea.js 提供了 seajs.use 的方法,來(lái)運(yùn)行已經(jīng)定義的模塊。所有 define 的回調(diào)函數(shù)都不會(huì)立即執(zhí)行,而是將所有的回調(diào)函數(shù)進(jìn)行緩存,只有 use 之后,以及被 require 的模塊回調(diào)才會(huì)進(jìn)行執(zhí)行。
sea.js 原理
下面簡(jiǎn)單講解一下 sea.js 的懶加載邏輯。在調(diào)用 define 方法的時(shí)候,只是將 模塊放入到一個(gè)全局對(duì)象進(jìn)行緩存。
- const seajs = {}
- const cache = seajs.cache = {}
- define = (id, factory) => {
- const uri = id2uri(id)
- const deps = parseDependencies(factory.toString())
- const mod = cache[uri] || (cache[uri] = new Module(uri))
- mod.deps = deps
- mod.factory = factory
- }
- class Module {
- constructor(uri, deps) {
- this.status = 0
- this.uri = uri
- this.deps = deps
- }
- }
這里的 Module,是一個(gè)與 RequireJS 類似的模塊加載器。后面運(yùn)行的 seajs.use 就會(huì)從緩存取出對(duì)應(yīng)的模塊進(jìn)行加載。
“注意:這一部分代碼只是簡(jiǎn)單介紹 use 方法的邏輯,并不能直接運(yùn)行。
- let cid = 0
- seajs.use = (ids, callback) => {
- const deps = isArray(ids) ? ids : [ids]
- deps.forEach(async (dep, i) => {
- const mod = cache[dep]
- mod.load()
- })
- }
另外 sea.js 的依賴都是在 factory 中聲明的,在模塊被調(diào)用的時(shí)候,sea.js 會(huì)將 factory 轉(zhuǎn)成字符串,然后匹配出所有的 require('xxx') 中的 xxx ,來(lái)進(jìn)行依賴的存儲(chǔ)。前面代碼中的 parseDependencies 方法就是做這件事情的。
早期 sea.js 是直接通過(guò)正則的方式進(jìn)行匹配的:
- const parseDependencies = (code) => {
- const REQUIRE_RE = /"(?:\\"|[^"])*"|'(?:\\'|[^'])*'|\/\*[\S\s]*?\*\/|\/(?:\\\/|[^/\r\n])+\/(?=[^\/])|\/\/.*|\.\s*require|(?:^|[^$])\brequire\s*\(\s*(["'])(.+?)\1\s*\)/g
- const SLASH_RE = /\\\\/g
- const ret = []
- code
- .replace(SLASH_RE, '')
- .replace(REQUIRE_RE, function(_, __, id) {
- if (id) {
- ret.push(id)
- }
- })
- return ret
- }
但是后來(lái)發(fā)現(xiàn)正則有各種各樣的 bug,并且過(guò)長(zhǎng)的正則也不利于維護(hù),所以 sea.js 后期舍棄了這種方式,轉(zhuǎn)而使用狀態(tài)機(jī)進(jìn)行詞法分析的方式獲取 require 依賴。
詳細(xì)代碼可以查看 sea.js 相關(guān)的子項(xiàng)目:crequire。
sea.js 原理總結(jié)
其實(shí) sea.js 的代碼邏輯大體上與 RequireJS 類似,都是通過(guò)創(chuàng)建 script 標(biāo)簽進(jìn)行模塊加載,并且都有實(shí)現(xiàn)一個(gè)模塊記載器,用于管理依賴。
主要差異在于,sea.js 的懶加載機(jī)制,并且在使用方式上,sea.js 的所有依賴都不是提前聲明的,而是 sea.js 內(nèi)部通過(guò)正則或詞法分析的方式將依賴手動(dòng)進(jìn)行提取的。
感興趣的可以在我的github上查看關(guān)于簡(jiǎn)化版 sea.js 的完整代碼 。
總結(jié)ES6 的模塊化規(guī)范已經(jīng)日趨完善,其靜態(tài)化思想也為后來(lái)的打包工具提供了便利,并且能友好的支持 tree shaking。了解這些已經(jīng)過(guò)時(shí)的模塊化方案看起來(lái)似乎有些無(wú)趣,但是歷史不能被遺忘,我們應(yīng)該多了解這些東西出現(xiàn)的背景,以及前人們的解決思路,而不是一直抱怨新東西更迭的速度太快。
本文轉(zhuǎn)載自微信公眾號(hào)「更了不起的前端」,可以通過(guò)以下二維碼關(guān)注。轉(zhuǎn)載本文請(qǐng)聯(lián)系更了不起的前端公眾號(hào)。