Seajs原始碼解讀

Shenfq發表於2019-03-04

近幾年前端工程化越來越完善,打包工具也已經是前端標配了,像seajs這種老古董早已停止維護,而且使用的人估計也幾個了。但這並不能阻止好奇的我,為了瞭解當年的前端前輩們是如何在瀏覽器進行程式碼模組化的,我鼓起勇氣翻開了Seajs的原始碼。下面就和我一起細細品味Seajs原始碼吧。

如何使用seajs

在看Seajs原始碼之前,先看看Seajs是如何使用的,畢竟剛入行的時候,大家就都使用browserify、webpack之類的東西了,還從來沒有用過Seajs。

<!-- 首先在頁面中引入sea.js,也可以使用CDN資源 -->
<script type="text/javascript" src="./sea.js"></script>
<script>
// 設定一些引數
seajs.config({
  debug: true, // debug為false時,在模組載入完畢後會移除head中的script標籤
  base: './js/', // 通過路徑載入其他模組的預設根目錄
  alias: { // 別名
    jquery: 'https://cdn.bootcss.com/jquery/3.2.1/jquery'
  }
})

seajs.use('main', function(main) {
    alert(main)
})
</script>

//main.js
define(function (require, exports, module) {
  // require('jquery')
  // var $ = window.$

  module.exports = 'main-module'
})
複製程式碼

seajs的引數配置

首先通過script匯入seajs,然後對seajs進行一些配置。seajs的配置引數很多具體不詳細介紹,seajs將配置項會存入一個私有物件data中,並且如果之前有設定過某個屬性,並且這個屬性是陣列或者物件,會將新值與舊值進行合併。

(function (global, undefined) {
  if (global.seajs) {
    return
  }
  var data = seajs.data = {}
  
  seajs.config = function (configData) {
    for (var key in configData) {
      var curr = configData[key] // 獲取當前配置
      var prev = data[key] // 獲取之前的配置
      if (prev && isObject(prev)) { // 如果之前已經設定過,且為一個物件
        for (var k in curr) {
          prev[k] = curr[k] // 用新值覆蓋舊值,舊值保留不變
        }
      }
      else {
        // 如果之前的值為陣列,進行concat
        if (isArray(prev)) {
          curr = prev.concat(curr)
        }
        // 確保 base 為一個路徑
        else if (key === "base") {
          // 必須已 "/" 結尾
          if (curr.slice(-1) !== "/") {
            curr += "/"
          }
          curr = addBase(curr) // 轉換為絕對路徑
        }

        // Set config
        data[key] = curr  
      }
    }
  }
})(this);
複製程式碼

設定的時候還有個比較特殊的地方,就是base這個屬性。這表示所有模組載入的基礎路徑,所以格式必須為一個路徑,並且該路徑最後會轉換為絕對路徑。比如,我的配置為base: './js',我當前訪問的域名為http://qq.com/web/index.html,最後base屬性會被轉化為http://qq.com/web/js/。然後,所有依賴的模組id都會根據該路徑轉換為uri,除非有定義其他配置,關於配置點到為止,到用到的地方再來細說。

模組的載入與執行

下面我們呼叫了use方法,該方法就是用來載入模組的地方,類似與requirejs中的require方法。

// requirejs
require(['main'], function (main) {
  console.log(main)
});
複製程式碼

只是這裡的依賴項,seajs可以傳入字串,而requirejs必須為一個陣列,seajs會將字串轉為陣列,在內部seajs.use會直接呼叫Module.use。這個Module為一個建構函式,裡面掛載了所有與模組載入相關的方法,還有很多靜態方法,比如例項化Module、轉換模組id為uri、定義模組等等,廢話不多說直接看程式碼。

seajs.use = function(ids, callback) {
  Module.use(ids, callback, data.cwd + "_use_" + cid())
  return seajs
}

// 該方法用來載入一個匿名模組
Module.use = function (ids, callback, uri) { //如果是通過seajs.use呼叫,uri是自動生成的
  var mod = Module.get(
    uri,
    isArray(ids) ? ids : [ids] // 這裡會將依賴模組轉成陣列
  )

  mod._entry.push(mod) // 表示當前模組的入口為本身,後面還會把這個值傳入他的依賴模組
  mod.history = {}
  mod.remain = 1 // 這個值後面會用來標識依賴模組是否已經全部載入完畢

  mod.callback = function() { //設定模組載入完畢的回撥,這一部分很重要,尤其是exec方法
    var exports = []
    var uris = mod.resolve()
    for (var i = 0, len = uris.length; i < len; i++) {
      exports[i] = cachedMods[uris[i]].exec()
    }
    if (callback) {
      callback.apply(global, exports) //執行回撥
    }
  }

  mod.load()
}
複製程式碼

這個use方法一共做了三件事:

  1. 呼叫Module.get,進行Module例項化
  2. 為模組繫結回撥函式
  3. 呼叫load,進行依賴模組的載入

例項化模組,一切的開端

首先use方法呼叫了get靜態方法,這個方法是對Module進行例項化,並且將例項化的物件存入到全域性物件cachedMods中進行快取,並且以uri作為模組的標識,如果之後有其他模組載入該模組就能直接在快取中獲取。

var cachedMods = seajs.cache = {} // 模組的快取物件
Module.get = function(uri, deps) {
  return cachedMods[uri] || (cachedMods[uri] = new Module(uri, deps))
}
function Module(uri, deps) {
  this.uri = uri
  this.dependencies = deps || []
  this.deps = {} // Ref the dependence modules
  this.status = 0
  this._entry = []
}
複製程式碼

繫結的回撥函式會在所有模組載入完畢之後呼叫,我們先跳過,直接看load方法。load方法會先把所有依賴的模組id轉為uri,然後進行例項化,最後呼叫fetch方法,繫結模組載入成功或失敗的回撥,最後進行模組載入。具體程式碼如下(程式碼經過精簡)

// 所有依賴載入完畢後執行 onload
Module.prototype.load = function() {
  var mod = this
  mod.status = STATUS.LOADING // 狀態置為模組載入中
  
  // 呼叫resolve方法,將模組id轉為uri。
  // 比如之前的"mian",會在前面加上我們之前設定的base,然後在後面拼上js字尾
  // 最後變成: "http://qq.com/web/js/main.js"
  var uris = mod.resolve()

  // 遍歷所有依賴項的uri,然後進行依賴模組的例項化
  for (var i = 0, len = uris.length; i < len; i++) {
    mod.deps[mod.dependencies[i]] = Module.get(uris[i])
  }

  // 將entry傳入到所有的依賴模組,這個entry是我們在use方法的時候設定的
  mod.pass()
  
  if (mod._entry.length) {
    mod.onload()
    return
  }

  // 開始進行並行載入
  var requestCache = {}
  var m

  for (i = 0; i < len; i++) {
    m = cachedMods[uris[i]] // 獲取之前例項化的模組物件
    m.fetch(requestCache) // 進行fetch
  }

  // 傳送請求進行模組的載入
  for (var requestUri in requestCache) {
    if (requestCache.hasOwnProperty(requestUri)) {
      requestCache[requestUri]() //呼叫 seajs.request
    }
  }
}
複製程式碼

將模組id轉為uri

resolve方法實現可以稍微看下,基本上是把config裡面的引數拿出來,進行拼接uri的處理。

Module.prototype.resolve = function() {
  var mod = this
  var ids = mod.dependencies // 取出所有依賴模組的id
  var uris = []
  // 進行遍歷操作
  for (var i = 0, len = ids.length; i < len; i++) {
    uris[i] = Module.resolve(ids[i], mod.uri) //將模組id轉為uri
  }
  return uris
}

Module.resolve = function(id, refUri) {
  var emitData = { id: id, refUri: refUri }
  return seajs.resolve(emitData.id, refUri) // 呼叫 id2Uri
}

seajs.resolve = id2Uri

function id2Uri(id, refUri) { // 將id轉為uri,轉換配置中的一些變數
  if (!id) return ""

  id = parseAlias(id)
  id = parsePaths(id)
  id = parseAlias(id)
  id = parseVars(id)
  id = parseAlias(id)
  id = normalize(id)
  id = parseAlias(id)

  var uri = addBase(id, refUri)
  uri = parseAlias(uri)
  uri = parseMap(uri)
  return uri
}
複製程式碼

最後就是呼叫了id2Uri,將id轉為uri,其中呼叫了很多的parse方法,這些方法不一一去看,原理大致一樣,主要看下parseAlias。如果這個id有定義過alias,將alias取出,比如id為"jquery",之前在定義alias中又有定義jquery: 'https://cdn.bootcss.com/jquery/3.2.1/jquery',則將id轉化為'https://cdn.bootcss.com/jquery/3.2.1/jquery'。程式碼如下:

function parseAlias(id) { //如果有定義alias,將id替換為別名對應的地址
  var alias = data.alias
  return alias && isString(alias[id]) ? alias[id] : id
}
複製程式碼

為依賴新增入口,方便追根溯源

resolve之後獲得uri,通過uri進行Module的例項化,然後呼叫pass方法,這個方法主要是記錄入口模組到底有多少個未載入的依賴項,存入到remain中,並將entry都存入到依賴模組的_entry屬性中,方便回溯。而這個remain用於計數,最後onload的模組數與remain相等就啟用entry模組的回撥。具體程式碼如下(程式碼經過精簡)

Module.prototype.pass = function() {
  var mod = this
  var len = mod.dependencies.length

  // 遍歷入口模組的_entry屬性,這個屬性一般只有一個值,就是它本身
  // 具體可以回去看use方法 -> mod._entry.push(mod)
  for (var i = 0; i < mod._entry.length; i++) {
    var entry = mod._entry[i] // 獲取入口模組
    var count = 0 // 計數器,用於統計未進行載入的模組
    for (var j = 0; j < len; j++) {
      var m = mod.deps[mod.dependencies[j]] //取出依賴的模組
      // 如果模組未載入,並且在entry中未使用,將entry傳遞給依賴
      if (m.status < STATUS.LOADED && !entry.history.hasOwnProperty(m.uri)) {
        entry.history[m.uri] = true // 在入口模組標識曾經載入過該依賴模組
        count++
        m._entry.push(entry) // 將入口模組存入依賴模組的_entry屬性
      }
    }
    // 如果未載入的依賴模組大於0
    if (count > 0) {
      // 這裡`count - 1`的原因也可以回去看use方法 -> mod.remain = 1
      // remain的初始值就是1,表示預設就會有一個未載入的模組,所有需要減1
      entry.remain += count - 1
      // 如果有未載入的依賴項,則移除掉入口模組的entry
      mod._entry.shift()
      i--
    }
  }
}
複製程式碼

如何發起請求,下載其他依賴模組?

總的來說pass方法就是記錄了remain的數值,接下來就是重頭戲了,呼叫所有依賴項的fetch方法,然後進行依賴模組的載入。呼叫fetch方法的時候會傳入一個requestCache物件,該物件用來快取所有依賴模組的request方法。

var requestCache = {}
for (i = 0; i < len; i++) {
  m = cachedMods[uris[i]] // 獲取之前例項化的模組物件
  m.fetch(requestCache) // 進行fetch
}

Module.prototype.fetch = function(requestCache) {
  var mod = this
  var uri = mod.uri

  mod.status = STATUS.FETCHING
  callbackList[requestUri] = [mod]

  emit("request", emitData = { // 設定載入script時的一些資料
    uri: uri,
    requestUri: requestUri,
    onRequest: onRequest,
    charset: isFunction(data.charset) ? data.charset(requestUri) : data.charset,
    crossorigin: isFunction(data.crossorigin) ? data.crossorigin(requestUri) : data.crossorigin
  })

  if (!emitData.requested) { //傳送請求載入js檔案
    requestCache[emitData.requestUri] = sendRequest
  }

  function sendRequest() { // 被request方法,最終會呼叫 seajs.request
    seajs.request(emitData.requestUri, emitData.onRequest, emitData.charset, emitData.crossorigin)
  }

  function onRequest(error) { //模組載入完畢的回撥
    var m, mods = callbackList[requestUri]
    delete callbackList[requestUri]
    // 儲存後設資料到匿名模組,uri為請求js的uri
    if (anonymousMeta) {
      Module.save(uri, anonymousMeta)
      anonymousMeta = null
    }
    while ((m = mods.shift())) {
      // When 404 occurs, the params error will be true
      if(error === true) {
        m.error()
      }
      else {
        m.load()
      }
    }
  }
}
複製程式碼

經過fetch操作後,能夠得到一個requestCache物件,該物件快取了模組的載入方法,從上面程式碼就能看到,該方法最後呼叫的是seajs.request方法,並且傳入了一個onRequest回撥。

for (var requestUri in requestCache) {
  requestCache[requestUri]() //呼叫 seajs.request
}

//用來載入js指令碼的方法
seajs.request = request

function request(url, callback, charset, crossorigin) {
  var node = doc.createElement("script")
  addOnload(node, callback, url)
  node.async = true //非同步載入
  node.src = url
  head.appendChild(node)
}

function addOnload(node, callback, url) {
  node.onload = onload
  node.onerror = function() {
    emit("error", { uri: url, node: node })
    onload(true)
  }

  function onload(error) {
    node.onload = node.onerror = node.onreadystatechange = null
    // 指令碼載入完畢的回撥
    callback(error)
  }
}
複製程式碼

通知入口模組

上面就是request的邏輯,只不過刪除了一些相容程式碼,其實原理很簡單,和requirejs一樣,都是建立script標籤,繫結onload事件,然後插入head中。在onload事件發生時,會呼叫之前fetch定義的onRequest方法,該方法最後會呼叫load方法。沒錯這個load方法又出現了,那麼依賴模組呼叫和入口模組呼叫有什麼區別呢,主要體現在下面程式碼中:

if (mod._entry.length) {
  mod.onload()
  return
}
複製程式碼

如果這個依賴模組沒有另外的依賴模組,那麼他的entry就會存在,然後呼叫onload模組,但是如果這個程式碼中有define方法,並且還有其他依賴項,就會走上面那麼邏輯,遍歷依賴項,轉換uri,呼叫fetch巴拉巴拉。這個後面再看,先看看onload會做什麼。

Module.prototype.onload = function() {
  var mod = this
  mod.status = STATUS.LOADED 
  for (var i = 0, len = (mod._entry || []).length; i < len; i++) {
    var entry = mod._entry[i]
    // 每次載入完畢一個依賴模組,remain就-1
    // 直到remain為0,就表示所有依賴模組載入完畢
    if (--entry.remain === 0) {
      // 最後就會呼叫entry的callback方法
      // 這就是前面為什麼要給每個依賴模組存入entry
      entry.callback()
    }
  }
  delete mod._entry
}
複製程式碼

依賴模組執行,完成全部操作

還記得最開始use方法中給入口模組設定callback方法嗎,沒錯,兜兜轉轉我們又回到了起點。

mod.callback = function() { //設定模組載入完畢的回撥
  var exports = []
  var uris = mod.resolve()

  for (var i = 0, len = uris.length; i < len; i++) {
    // 執行所有依賴模組的exec方法,存入exports陣列
    exports[i] = cachedMods[uris[i]].exec()
  }

  if (callback) {
    callback.apply(global, exports) //執行回撥
  }

  // 移除一些屬性
  delete mod.callback
  delete mod.history
  delete mod.remain
  delete mod._entry
}
複製程式碼

那麼這個exec到底做了什麼呢?

Module.prototype.exec = function () {
  var mod = this

  mod.status = STATUS.EXECUTING

  if (mod._entry && !mod._entry.length) {
    delete mod._entry
  }

  function require(id) {
    var m = mod.deps[id]
    return m.exec()
  }

  var factory = mod.factory

  // 呼叫define定義的回撥
  // 傳入commonjs相關三個引數: require, module.exports, module
  var exports = factory.call(mod.exports = {}, require, mod.exports, mod)
  if (exports === undefined) {
    exports = mod.exports //如果函式沒有返回值,就取mod.exports
  }
  mod.exports = exports
  mod.status = STATUS.EXECUTED

  return mod.exports // 返回模組的exports
}
複製程式碼

這裡的factory就是依賴模組define中定義的回撥函式,例如我們載入的main.js中,定義了一個模組。

define(function (require, exports, module) {
  module.exports = 'main-module'
})
複製程式碼

那麼呼叫這個factory的時候,exports就為module.exports,也是是字串"main-moudle"。最後callback傳入的引數就是"main-moudle"。所以我們執行最開頭寫的那段程式碼,最後會在頁面上彈出main-moudle

執行結果

define定義模組

你以為到這裡就結束了嗎?並沒有。前面只說了載入依賴模組中define方法中沒有其他依賴,那如果有其他依賴呢?廢話不多說,先看看define方法做了什麼:

global.define = Module.define
Module.define = function (id, deps, factory) {
  var argsLen = arguments.length

  // 引數校準
  if (argsLen === 1) {
    factory = id
    id = undefined
  }
  else if (argsLen === 2) {
    factory = deps
    if (isArray(id)) {
      deps = id
      id = undefined
    }
    else {
      deps = undefined
    }
  }

  // 如果沒有直接傳入依賴陣列
  // 則從factory中提取所有的依賴模組到dep陣列中
  if (!isArray(deps) && isFunction(factory)) {
    deps = typeof parseDependencies === "undefined" ? [] : parseDependencies(factory.toString())
  }

  var meta = { //模組載入與定義的後設資料
    id: id,
    uri: Module.resolve(id),
    deps: deps,
    factory: factory
  }

  // 啟用define事件, used in nocache plugin, seajs node version etc
  emit("define", meta)

  meta.uri ? Module.save(meta.uri, meta) :
    // 在指令碼載入完畢的onload事件進行save
    anonymousMeta = meta
  }
複製程式碼

首先進行了引數的修正,這個邏輯很簡單,直接跳過。第二步判斷了有沒有依賴陣列,如果沒有,就通過parseDependencies方法從factory中獲取。這個方法很有意思,是一個狀態機,會一步步的去解析字串,匹配到require,將其中的模組取出,最後放到一個陣列裡。這個方法在requirejs中是通過正則實現的,早期seajs也是通過正則匹配的,後來改成了這種狀態機的方式,可能是考慮到效能的問題。seajs的倉庫中專門有一個模組來講這個東西的,請看連結

獲取到依賴模組之後又設定了一個meta物件,這個就表示這個模組的原資料,裡面有記錄模組的依賴項、id、factory等。如果這個模組define的時候沒有設定id,就表示是個匿名模組,那怎麼才能與之前發起請求的那個mod相匹配呢?

這裡就有了一個全域性變數anonymousMeta,先將後設資料放入這個物件。然後回過頭看看模組載入時設定的onload函式裡面有一段就是獲取這個全域性變數的。

function onRequest(error) { //模組載入完畢的回撥
...
  // 儲存後設資料到匿名模組,uri為請求js的uri
  if (anonymousMeta) {
    Module.save(uri, anonymousMeta)
    anonymousMeta = null
  }
...
}
複製程式碼

不管是不是匿名模組,最後都是通過save方法,將後設資料存入到mod中。

 // 儲存後設資料到 cachedMods 中
Module.save = function(uri, meta) {
  var mod = Module.get(uri)
  
  if (mod.status < STATUS.SAVED) {
    mod.id = meta.id || uri
    mod.dependencies = meta.deps || []
    mod.factory = meta.factory
    mod.status = STATUS.SAVED
  }
}
複製程式碼

這裡完成之後,就是和前面的邏輯一樣了,先去校驗當前模組有沒有依賴項,如果有依賴項,就去載入依賴項和use的邏輯是一樣的,等依賴項全部載入完畢後,通知入口模組的remain減1,知道remain為0,最後呼叫入口模組的回撥方法。整個seajs的邏輯就已經全部走通,Yeah!


結語

有過看requirejs的經驗,再來看seajs還是順暢很多,對模組化的理解有了更加深刻的理解。閱讀原始碼之前還是得對框架有個基本認識,並且有使用過,要不然很多地方都很懵懂。所以以後還是閱讀一些工作中有經常使用的框架或類庫的原始碼進行閱讀,不能總像個無頭蒼蠅一樣。

最後用一張流程圖,總結下seajs的載入過程。

seajs載入流程圖

相關文章