前端模組化的前世

大霧發表於2019-10-10

隨著前端專案的越來越龐大,元件化的前端框架,前端路由等技術的發展,模組化已經成為現代前端工程師的一項必備技能。無論是什麼語言一旦發展到一定地步,其工程化能力和可維護性勢必得到相應的發展。

模組化這件事,無論在哪個程式設計領域都是相當常見的事情,模組化存在的意義就是為了增加可複用性,以儘可能少的程式碼是實現個性化的需求。同為前端三劍客之一的 CSS 早在 2.1 的版本就提出了 @import 來實現模組化,但是 JavaScript 直到 ES6 才出現官方的模組化方案: ES Module (importexport)。儘管早期 JavaScript 語言規範上不支援模組化,但這並沒有阻止 JavaScript 的發展,官方沒有模組化標準開發者們就開始自己建立規範,自己實現規範。

CommonJS 的出現

十年前的前端沒有像現在這麼火熱,模組化也只是使用閉包簡單的實現一個名稱空間。2009 年對 JavaScript 無疑是重要的一年,新的 JavaScript 引擎 (v8) ,並且有成熟的庫 (jQuery、YUI、Dojo),ES5 也在提案中,然而 JavaScript 依然只能出現在瀏覽器當中。早在2007年,AppJet 就提供了一項服務,建立和託管服務端的 JavaScript 應用。後來 Aptana 也提供了一個能夠在服務端執行 Javascript 的環境,叫做 Jaxer。網上還能搜到關於 AppJet、Jaxer 的部落格,甚至 Jaxer 專案還在github上。

Jaxer

但是這些東西都沒有發展起來,Javascript 並不能替代傳統的服務端指令碼語言 (PHP、Python、Ruby) 。儘管它有很多的缺點,但是不妨礙有很多人使用它。後來就有人開始思考 JavaScript 要在服務端執行還需要些什麼?於是在 2009 年 1 月,Mozilla 的工程師 Kevin Dangoor 發起了 CommonJS 的提案,呼籲 JavaScript 愛好者聯合起來,編寫 JavaScript 執行在服務端的相關規範,一週之後,就有了 224 個參與者。

"[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 標準囊括了 JavaScript 需要在服務端執行所必備的基礎能力,比如:模組化、IO 操作、二進位制字串、程式管理、Web閘道器介面 (JSGI) 。但是影響最深遠的還是 CommonJS 的模組化方案,CommonJS 的模組化方案是JavaScript社群第一次在模組系統上取得的成果,不僅支援依賴管理,而且還支援作用域隔離和模組標識。再後來 node.js 出世,他直接採用了 CommonJS 的模組化規範,同時還帶來了npm (Node Package Manager,現在已經是全球最大模組倉庫了) 。

CommonJS 在服務端表現良好,很多人就想將 CommonJS 移植到客戶端 (也就是我們說的瀏覽器) 進行實現。由於CommonJS 的模組載入是同步的,而服務端直接從磁碟或記憶體中讀取,耗時基本可忽略,但是在瀏覽器端如果還是同步載入,對使用者體驗極其不友好,模組載入過程中勢必會向伺服器請求其他模組程式碼,網路請求過程中會造成長時間白屏。所以從 CommonJS 中逐漸分裂出來了一些派別,在這些派別的發展過程中,出現了一些業界較為熟悉方案 AMD、CMD、打包工具(Component/Browserify/Webpack)。

AMD規範:RequireJS

RequireJS logo

RequireJS 是 AMD 規範的代表之作,它之所以能代表 AMD 規範,是因為 RequireJS 的作者 (James Burke) 就是 AMD 規範的提出者。同時作者還開發了 amdefine,一個讓你在 node 中也可以使用 AMD 規範的庫。

AMD 規範由 CommonJS 的 Modules/Transport/C 提案發展而來,毫無疑問,Modules/Transport/C 提案的發起者就是 James Burke。

James Burke 指出了 CommonJS 規範在瀏覽器上的一些不足:

  1. 缺少模組封裝的能力:CommonJS 規範中的每個模組都是一個檔案。這意味著每個檔案只有一個模組。這在伺服器上是可行的,但是在瀏覽器中就不是很友好,瀏覽器中需要做到儘可能少的發起請求。
  2. 使用同步的方式載入依賴:雖然同步的方法進行載入可以讓程式碼更容易理解,但是在瀏覽器中使用同步載入會導致長時間白屏,影響使用者體驗。
  3. CommonJS 規範使用一個名為 export 的物件來暴露模組,將需要匯出變數附加到 export 上,但是不能直接給該物件進行賦值。如果需要匯出一個建構函式,則需要使用 module.export,這會讓人感到很疑惑。

AMD 規範定義了一個 define 全域性方法用來定義和載入模組,當然 RequireJS 後期也擴充套件了 require 全域性方法用來載入模組 。通過該方法解決了在瀏覽器使用 CommonJS 規範的不足。

define(id?, dependencies?, factory);
  1. 使用匿名函式來封裝模組,並通過函式返回值來定義模組,這更加符合 JavaScript 的語法,這樣做既避免了對 exports 變數的依賴,又避免了一個檔案只能暴露一個模組的問題。
  2. 提前列出依賴項並進行非同步載入,這在瀏覽器中,這能讓模組開箱即用。

    define("foo", ["logger"], function (logger) {
        logger.debug("starting foo's definition")
        return {
            name: "foo"
        }
    })
  3. 為模組指定一個模組 ID (名稱) 用來唯一標識定義中模組。此外,AMD的模組名規範是 CommonJS 模組名規範的超集。

    define("foo", function () {
        return {
            name: 'foo'
        }
    })

RequireJS 原理

在討論原理之前,我們可以先看下 RequireJS 的基本使用方式。

  • 模組資訊配置:

    require.config({
      paths: {
        jquery: 'https://code.jquery.com/jquery-3.4.1.js'
      }
    })
  • 依賴模組載入與呼叫:

    require(['jquery'], function ($){
      $('#app').html('loaded')
    })
  • 模組定義:

    if ( typeof define === "function" && define.amd ) {
      define( "jquery", [], function() {
        return jQuery;
      } );
    }

我們首先使用 config 方法進行了 jquery 模組的路徑配置,然後呼叫 require 方法載入 jquery 模組,之後在回撥中呼叫已載入完成的 $ 物件。在這個過程中,jquery 會使用 define 方法暴露出我們所需要的 $ 物件。

在瞭解了基本的使用過程後,我們就繼續深入 RequireJS 的原理。

模組資訊配置

模組資訊的配置,其實很簡單,只用幾行程式碼就能實現。定義一個全域性物件,然後使用 Object.assign 進行物件擴充套件。

// 配置資訊
const cfg = { paths: {} }

// 全域性 require 方法
req = require = () => {}

// 擴充套件配置
req.config = config => {
  Object.assign(cfg, config)
}

依賴模組載入與呼叫

require 方法的邏輯很簡單,進行簡單的引數校驗後,呼叫 getModule 方法對 Module 進行了例項化,getModule 會對已經例項化的模組進行快取。因為 require 方法進行模組例項的時候,並沒有模組名,所以這裡產生的是一個匿名模組。Module 類,我們可以理解為一個模組載入器,主要作用是進行依賴的載入,並在依賴載入完畢後,呼叫回撥函式,同時將依賴的模組逐一作為引數回傳到回撥函式中。

// 全域性 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 = {} // 已註冊的模組

// 模組載入器的工廠方法
const getModule = name => {
  if (!name) {
    // 如果模組名不存在,表示為匿名模組,自動構造模組名
    name = `@mod_${++reqCounter}`
  }
  let mod = registry[name]
  if (!mod) {
    mod = registry[name] = new Module(name)
  }
  return mod
}

模組載入器是是整個模組載入的核心,主要包括 enable 方法和 check 方法。

模組載入器在完成例項化之後,會首先呼叫 init 方法進行初始化,初始化的時候傳入模組的依賴以及回撥。

// 模組載入器

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. 記錄已載入模組數 (this.depCount++),該變數用於判斷依賴模組是否全部載入完畢;
  3. 例項化依賴模組的模組載入器,並繫結 definedFn 方法;

    definedFn 方法會在依賴模組載入完畢後呼叫,主要作用是獲取依賴模組的內容,並將 depCount 減 1,最後呼叫 check 方法 (該方法會判斷 depCount 是否已經小於 1,以此來界定依賴全部載入完畢);
  4. 最後通過依賴模組名,在配置中獲取依賴模組的路徑,進行模組載入。
class Module {
  ...
  // 啟用模組,進行依賴載入
  enable() {
    // 遍歷依賴
    this.deps.forEach((name, i) => {
      // 記錄已載入的模組數
      this.depCount++
      
      // 例項化依賴模組的模組載入器,繫結模組載入完畢的回撥
      const mod = getModule(name)
      mod.definedFn = exports => {
        this.depCount--
        this.depExports[i] = exports
        this.check()
      }
      
      // 在配置中獲取依賴模組的路徑,進行模組載入
      const url = cfg.paths[name]
      loadModule(name, url)
    });
  }
  ...
}

loadModule 的主要作用就是通過 url 去載入一個 js 檔案,並繫結一個 onload 事件。onload 會重新獲取依賴模組已經例項化的模組載入器,並呼叫 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
  // 設定一個 data 屬性,便於依賴載入完畢後拿到模組名
  node.setAttribute('data-module', name)
  node.addEventListener('load', onScriptLoad, false)
  node.src = url
  head.appendChild(node)
  return node
}

// 節點繫結的 onload 事件函式
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)
}

看到之前的案例,因為只有一個依賴 (jQuery),並且 jQuery 模組並沒有其他依賴,所以 init 方法會直接呼叫 check 方法。這裡也可以思考一下,如果是一個有依賴項的模組後續的流程是怎麼樣的呢?

define( "jquery", [] /* 無其他依賴 */, function() {
  return jQuery;
} );

check 方法主要用於依賴檢測,以及呼叫依賴載入完畢後的回撥。

// 模組載入器
class Module {
  ...
  // 檢查依賴是否載入完畢
  check() {
    let exports = this.exports
    //如果依賴數小於1,表示依賴已經全部載入完畢
    if (this.depCount < 1) { 
      // 呼叫回撥,並獲取該模組的內容
      exports = this.callback.apply(null, this.depExports)
      this.exports = exports
      //啟用 defined 回撥
      this.definedFn(exports)
    }
  }
  ...
}

最終通過 definedFn 重新回到被依賴模組,也就是最初呼叫 require 方法例項化的匿名模組載入器中,將依賴模組暴露的內容存入 depExports 中,然後呼叫匿名模組載入器的 check 方法,呼叫回撥。

mod.definedFn = exports => {
  this.depCount--
  this.depExports[i] = exports
  this.check()
}

模組定義

還有一個疑問就是,在依賴模組載入完畢的回撥中,怎麼拿到的依賴模組的依賴和回撥呢?

const def = defMap[name]
mod.init(def.deps, def.callback)

答案就是通過全域性定義的 define 方法,該方法會將模組的依賴項還有回撥儲存到一個全域性變數,後面只要按需獲取即可。

const defMap = {} // 快取載入的模組
define = (name, deps, callback) => {
  defMap[name] = { name, deps, callback }
}

RequireJS 原理總結

最後可以發現,RequireJS 的核心就在於模組載入器的實現,不管是通過 require 進行依賴載入,還是使用 define 定義模組,都離不開模組載入器。

感興趣的可以在我的github上檢視關於簡化版 RequrieJS 的完整程式碼

CMD規範:sea.js

sea.js logo

CMD 規範由國內的開發者玉伯提出,儘管在國際上的知名度遠不如 AMD ,但是在國內也算和 AMD 齊頭並進。相比於 AMD 的非同步載入,CMD 更加傾向於懶載入,而且 CMD 的規範與 CommonJS 更貼近,只需要在 CommonJS 外增加一個函式呼叫的包裝即可。

define(function(require, exports, module) {
  require("./a").doSomething()
  require("./b").doSomething()
})

作為 CMD 規範的實現 sea.js 也實現了類似於 RequireJS 的 api:

seajs.use('main', function (main) {
  main.doSomething()
})

sea.js 在模組載入的方式上與 RequireJS 一致,都是通過在 head 標籤插入 script 標籤進行載入的,但是在載入順序上有一定的區別。要講清楚這兩者之間的差別,我們還是直接來看一段程式碼:

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 的地方,才會真正執行模組。而 RequireJS,會先執行所有的依賴,得到所有依賴暴露的結果後再執行回撥。

正是因為懶載入的機制,所以 sea.js 提供了 seajs.use 的方法,來執行已經定義的模組。所有 define 的回撥函式都不會立即執行,而是將所有的回撥函式進行快取,只有 use 之後,以及被 require 的模組回撥才會進行執行。

sea.js 原理

下面簡單講解一下 sea.js 的懶載入邏輯。在呼叫 define 方法的時候,只是將 模組放入到一個全域性物件進行快取。

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,是一個與 RequireJS 類似的模組載入器。後面執行的 seajs.use 就會從快取取出對應的模組進行載入。

注意:這一部分程式碼只是簡單介紹 use 方法的邏輯,並不能直接執行。
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 中宣告的,在模組被呼叫的時候,sea.js 會將 factory 轉成字串,然後匹配出所有的 require('xxx') 中的 xxx ,來進行依賴的儲存。前面程式碼中的 parseDependencies 方法就是做這件事情的。

早期 sea.js 是直接通過正則的方式進行匹配的:

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
}

但是後來發現正則有各種各樣的 bug,並且過長的正則也不利於維護,所以 sea.js 後期捨棄了這種方式,轉而使用狀態機進行詞法分析的方式獲取 require 依賴。

詳細程式碼可以檢視 sea.js 相關的子專案:crequire

sea.js 原理總結

其實 sea.js 的程式碼邏輯大體上與 RequireJS 類似,都是通過建立 script 標籤進行模組載入,並且都有實現一個模組記載器,用於管理依賴。

主要差異在於,sea.js 的懶載入機制,並且在使用方式上,sea.js 的所有依賴都不是提前宣告的,而是 sea.js 內部通過正則或詞法分析的方式將依賴手動進行提取的。

感興趣的可以在我的github上檢視關於簡化版 sea.js 的完整程式碼

總結

ES6 的模組化規範已經日趨完善,其靜態化思想也為後來的打包工具提供了便利,並且能友好的支援 tree shaking。瞭解這些已經過時的模組化方案看起來似乎有些無趣,但是歷史不能被遺忘,我們應該多瞭解這些東西出現的背景,以及前人們的解決思路,而不是一直抱怨新東西更迭的速度太快。

不說雞湯了,挖個坑,敬請期待下一期的《前端模組化的今生》。

相關文章