前端模塊化的前世

隨着前端項目的愈來愈龐大,組件化的前端框架,前端路由等技術的發展,模塊化已經成爲現代前端工程師的一項必備技能。不管是什麼語言一旦發展到必定地步,其工程化能力和可維護性勢必獲得相應的發展。javascript

模塊化這件事,不管在哪一個編程領域都是至關常見的事情,模塊化存在的意義就是爲了增長可複用性,以儘量少的代碼是實現個性化的需求。同爲前端三劍客之一的 CSS 早在 2.1 的版本就提出了 @import 來實現模塊化,可是 JavaScript 直到 ES6 纔出現官方的模塊化方案: ES Module (importexport)。儘管早期 JavaScript 語言規範上不支持模塊化,但這並無阻止 JavaScript 的發展,官方沒有模塊化標準開發者們就開始本身建立規範,本身實現規範。html

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 個參與者。java

"[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."node

CommonJS 標準囊括了 JavaScript 須要在服務端運行所必備的基礎能力,好比:模塊化、IO 操做、二進制字符串、進程管理、Web網關接口 (JSGI) 。可是影響最深遠的仍是 CommonJS 的模塊化方案,CommonJS 的模塊化方案是JavaScript社區第一次在模塊系統上取得的成果,不只支持依賴管理,並且還支持做用域隔離和模塊標識。再後來 node.js 出世,他直接採用了 CommonJS 的模塊化規範,同時還帶來了npm (Node Package Manager,如今已是全球最大模塊倉庫了) 。jquery

CommonJS 在服務端表現良好,不少人就想將 CommonJS 移植到客戶端 (也就是咱們說的瀏覽器) 進行實現。因爲CommonJS 的模塊加載是同步的,而服務端直接從磁盤或內存中讀取,耗時基本可忽略,可是在瀏覽器端若是仍是同步加載,對用戶體驗極其不友好,模塊加載過程當中勢必會向服務器請求其餘模塊代碼,網絡請求過程當中會形成長時間白屏。因此從 CommonJS 中逐漸分裂出來了一些派別,在這些派別的發展過程當中,出現了一些業界較爲熟悉方案 AMD、CMD、打包工具(Component/Browserify/Webpack)。git

AMD規範:RequireJS

RequireJS logo

RequireJS 是 AMD 規範的表明之做,它之因此能表明 AMD 規範,是由於 RequireJS 的做者 (James Burke) 就是 AMD 規範的提出者。同時做者還開發了 amdefine,一個讓你在 node 中也可使用 AMD 規範的庫。github

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

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。瞭解這些已通過時的模塊化方案看起來彷佛有些無趣,可是歷史不能被遺忘,咱們應該多瞭解這些東西出現的背景,以及前人們的解決思路,而不是一直抱怨新東西更迭的速度太快。

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

相關文章
相關標籤/搜索