博客
博客
文章目录
  1. 如何使用 seajs
  2. seajs 的参数配置
  3. 模块的加载与执行
    1. 实例化模块,一切的开端
    2. 将模块 id 转为 uri
    3. 为依赖添加入口,方便追根溯源
    4. 如何发起请求,下载其他依赖模块?
    5. 通知入口模块
    6. 依赖模块执行,完成全部操作
  4. define 定义模块
  5. 结语

seajs 源码解读

近几年前端工程化越来越完善,打包工具也已经是前端标配了,像 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 加载流程图

支持一下
扫一扫,支持一下
  • 微信扫一扫
  • 支付宝扫一扫