old school面试题解读:requirejs和seajs的区别

2,267 阅读10分钟

seajs和requirejs的区别

写在前面

前段时间在看一些前端面试相关的东西,发现有些比较有意思的经典面试题网上少有能说的通俗易懂的文章。

比如说,seajs 和 requirejs 的区别,defer 和 async 的区别。这两道面试题应该算是非常old school了,搜索引擎一艘前端面试题,发现老早就能看到这两道题的身影。

不知道小伙伴们被问这两道题的多吗,至少我还没被问过哈哈。。虽然感觉被问的概率比较低,题目也比较经典,但如果你深入了解这两道题,也许你会发现新大陆,你会大受震撼,直呼:厉害啊,还能这么搞,牛x

所以打算新开一个系列讲讲这个,目前打算先写上面说到的两篇,这是我自己想捣鼓的两道题,后续有什么想看的小伙伴可以评论区留言~

简单说说API

在讲 seajs 和 requirejs 的原理之前,先讲讲这两个库的基本用法,大概有个印象

seajs

声明模块

define 是用来声明模块的,传入的参数是一个工厂函数,这个函数就是我们所说的模块,用法上跟 cjs 规范差不多

define(function(require, exports, module) {
  // exports、module暴露接口
  exports.a = { a: 'a' }
  module.exports = { b: 'b' }
})

注册模块

define 声明完一个模块之后,还需要注册到 seajs 里面,否则无法引入,具体参数可以参考 官网

seajs.config({
    base: "../lib/",
    alias: {
      "jquery": "jquery.min.js",
    }
})

引用模块

使用 use API可以引入模块,参数一是引入的模块,参数二是引入的回调,回调参数是模块的到处内容

seajs.use(['jquery','../static/main.js'],function($,main){  
    ...
});

requirejs

声明模块

声明自己的模块

define(function(){
    const a = 'a'
    const b = 'b'
    // 直接返回导出内容
    return {
        a, b
    };
})

声明需要依赖的模块。requirejs 推崇的是依赖前置,意思是,在模块声明前先引入需要依赖的模块。当然也只是推崇,在 requirejs 中你也可以使用依赖就近

// 注册有依赖关系的模块
define(['依赖的模块路径'], function(依赖模块导出内容){
  // 依赖就近的引入方式
  require(['otherModule'],function(moduleExport){
      ...
  })
 return {

 };
});

注册模块

同样的,requirejs 也需要将声明的模块注册到用全局对象上,更多参数可以参考 官网

require.config({
    paths:{
        "jquery":'../lib/jquery.min'
    }
});

引用模块

// 引用单个
require(['jquery'],function($){
    //通过此方式引入jquery才能使用$,接下来正常写jquery代码就好
})

// 引用多个
require(['A', 'B', 'C'],function(A, B, C){
    // 引入的模块会按顺序放入到回调函数的参数中
})

// 嵌套引用(有依赖关系的话需要嵌套引入)
require(['jquery'],function($){
     require(['A'],function(A){
        ...
     })
})

用法总结

看了上面的用法之后,可以简单概括为三类API,注册、声明和引入。

两者的区别

原则上的区别

看过网上讲解两者区别的小伙伴都知道,requirejs 属于 amd 规范,seajs 是属于 cmd 规范。

amd 规范推崇的是依赖前置,cmd 推崇的是依赖就近。这两个新名词看起来比较抽象,但实际上可以非常简单的理解为:

  1. 依赖前置就是在模块声明/引入前,先把该模块需要的依赖先引入
  2. 依赖就近就是模块声明/引入不需要提前引入依赖,在模块内部再引入

在 seajs 和 requirejs 中这两种原则其实都是支持的,两者看似不一样,但本质都离不开以下四步:

  1. 收集依赖
  2. 加载依赖
  3. 执行模块的工厂函数,导出模块内容
  4. 执行依赖引入的回调

而 seajs 和 requirejs 的区别更多是在于收集依赖这一块的区别

  • seajs 的依赖是需要手动收集的
  • requirejs 的依赖是以函数的参数形式传递的,也就是说在代码执行阶段我已经知道这个模块需要引入哪些依赖

seajs 的依赖是手动收集是什么意思??这里先按下不表,这一操作我认为是 seajs 的非常亮眼的特性,下一节详细展开

使用上的区别

对于 requirejs,其实也是支持依赖就近的,但并不保证按模块加载顺序执行,模块在加载完毕后就会立即执行。

define(function(require, exports, module) {
    console.log('require module: main');

    var mod1 = require('./mod1');
    mod1.hello();
    var mod2 = require('./mod2');
    mod2.hello();

    return {
        hello: function() {
            console.log('hello main');
        }
    };
});
// seajs
require module: main
require module: mod1
hello mod1
require module: mod2
hello mod2
helo main

// requirejs
require module: mod2
require module: mod1
require module: main
hello mod1
hello mod2
helo main

从上面代码结果看,seajs 的结果更符合我们常规的代码执行顺序

所以,amd 规范里面使用依赖就近会有一个很明显的问题,模块执行不按顺序,如果某个加载的比较快的模块依赖了一个加载的比较慢的模块,就会出现问题。所以这种情况在 requirejs 里面最好是用回调的 api 来模拟依赖就近原则

define(function(require, exports, module) {
    console.log('require module: main');

    require('./mod1', function(mod1) {
        mod1.hello();
    });
    
    require('./mod2', function(mod2) {
        mod2.hello();
    });

    return {
        hello: function() {
            console.log('hello main');
        }
    };
});

揭秘seajs实现原理

requirejs 的源码太长且不好看,所以没有逐行代码认真去看,但是具体流程跟上一节说的步骤差不多。seajs 的代码相对就好看很多了,有兴趣的小伙伴可以去看看,很有意思。

seajs 具体的实现,我们可以从刚刚的三类API入手,分别注册、声明和引入

注册模块

seajs.config 的主要功能是收集传入的各个模块的url地址

function Seajs() {
  this.moduleConfig = {}
}
// 注册模块
Seajs.prototype.config = function(config) {
  this.moduleConfig = config
}
window.seajs = new Seajs()

代码很简单,因为就是用来存储配置的,下面我们看看怎么使用

seajs.config({
    alias: {
      "sea1": "./sea1.js",
      "sea2": "./sea2.js",
    }
})

这样我们就收集到了两个模块的 url 地址

声明模块

声明模块的入口函数是 define,我们可以猜一下这个函数是做什么的。define 的参数是一个工厂函数,这个工厂函数就是模块的核心了。所以 define 就是接收并执行这个工厂函数?

答案:不是

从上面 API 使用示例我们知道,引入模块首先要加载所需依赖,再执行引入的回调。对于 依赖前置 我们可以以参数形式提前知道这个模块需要的依赖,但 依赖就近 是无法提前得知这个模块需要哪些依赖的。

那 seajs 是从哪里得知这个模块需要什么依赖的呢?关键在于将工厂函数转换为字符串

var REQUIRE_RE = /"(?:\\"|[^"])*"|'(?:\\'|[^'])*'|\/\*[\S\s]*?\*\/|\/(?:\\\/|[^\/\r\n])+\/(?=[^\/])|\/\/.*|\.\s*require|(?:^|[^$])\brequire\s*\(\s*(["'])(.+?)\1\s*\)/g
var SLASH_RE = /\\\\/g

// 解析函数字符串,收集依赖
function parseDependencies(code) {
  var ret = []

  code.replace(SLASH_RE, "")
      .replace(REQUIRE_RE, function(m, m1, m2) {
        if (m2) {
          ret.push(m2)
        }
      })

  return ret
}
function define(factory) {
  // 将工厂函数转换为字符串
  var factoryStr = factory.toString()
  const deps = parseDependencies(factoryStr)
  // 以下内容将会在 script 标签的 onload 里面用到
  anonymousMeta = {
    deps: deps,
    factory: factory
  }
}

没看源码之前,我是怎么都不会想到还有这种操作,将模块字符串化,再提取依赖,玉伯大佬真的太强了。

当然,这种操作在目前的模块化规范里面其实已经用不到了,我们可以观察 webpack 打包的产物,模块化都依赖 IIFE,不需要像 seajs 和 requirejs 那样需要独立的js文件做处理

引入模块

对于引入模块,可以细分为上面说的四个步骤

  1. 收集依赖
  2. 加载依赖
  3. 执行模块的工厂函数,导出模块内容
  4. 执行依赖引入的回调

收集依赖在上一小节讲了,接下来也是从API use 入手

let count = 0
Seajs.prototype.use = function(deps, callback) {
  // 基于此次引入创建一个模块对象
  var mod = Module.get(`__use__${count++}`, deps)
  // 该模块没有被谁依赖,所以自己就是入口本身
  mod._entry.push(mod)
  // 记录该模块的依赖数量
  mod.remain = deps.length

  // 模块依赖加载完毕后执行的回调
  mod.callback = function() {
    // 模块导出
    var exports = []
    deps.map(i => {
      exports.push(cachedMods[i].exec())
    })
    
    执行依赖引入的回调
    if (callback) callback.apply(window, exports)
  }
  // 执行模块的加载
  mod.load()
}

上面就是 use 函数的主要逻辑,整体逻辑都是依赖 Module 对象运行的,下面看看 Module 到底是什么

function Module(deps) {
  this.dependences = deps ? deps instanceof Array ? deps : [deps] : []
  this.deps = {}
  // 0 尚未加载 1 加载中 2 加载完毕 3 缓存结果
  this.status = 0
  this._entry = []
}
Module.get = function(moduleName, deps) {
  // 已存在这个模块对象就复用,否则就新建
  return cachedMods[moduleName] || (cachedMods[moduleName] = new Module(deps))
}

Module 对象主要存放的是模块的依赖,也就是 use 函数的第一个参数。而 Module.get 则是起到一个创建对象的作用。

use 函数的最后执行的是 load 函数,我们看看 load 的逻辑

Module.prototype.load = function() {
  if (!this.status) {
    this.status = 1
    const dependenceList = []
    // 根据模块的各个依赖创建 Module 对象
    this.dependences.map(dep => {
      const moduleName = dep.toString()
      const m = Module.get(moduleName)
      if (m.status == 0) {
        // 记录当前依赖的uri
        m.uri = seajs.moduleConfig.alias[moduleName]
        // 依赖的入口就是当前 Module 对象
        m._entry.push(this)
        this.deps[moduleName] = m
        // 模块加载完毕的回调
        m.callback = () => {
          m.status = 2
          m.remain = 0
          this.onload.call(this)
        }
        dependenceList.push(m)
      }
    })
    // 如果 dependenceList 为空,就代表当前模块的依赖已经加载过了,直接使用缓存即可
    if (!dependenceList.length) {
      this.fetch(this.onload.bind(this))
    } else {
      // 否则逐个请求依赖资源
      dependenceList.map(m => {
        m.status = 1
        m.fetch(this.onload.bind(this))
      })
    }
  }
}

load 函数主要做的是将模块的依赖封装成 Module 对象,然后去请求依赖的资源,这里其实类似于是一个向下递归加载依赖的过程。接下来看看 fetch 函数

function requestScript(url, cb) {
  var node = document.createElement("script")
  node.async = true
  node.src = url
  node.onload = () => {
    cb()
    document.head.removeChild(node)
  }
  document.head.appendChild(node)
}
Module.prototype.fetch = function(resolve) {
  // 如果当前模块没有uri(代表不是第三方加载的脚本),则直接加载模块内容
  if (!this.uri) {
    resolve()
    return
  }
  requestScript(this.uri, () => {
    this.factory = anonymousMeta.factory
    if (anonymousMeta.deps.length) {
      const moduleName = anonymousMeta.deps.toString()
      const m = Module.get(moduleName)
      // 将收集到的依赖信息记录到当前模块对象中
      this.dependences = this.dependences.concat(anonymousMeta.deps)
      this.deps[moduleName] = m
      this.remain = anonymousMeta.deps.length
      m.uri = seajs.moduleConfig.alias[moduleName]
      m.callback = this.callback
      // 将当前模块的对象加入到依赖的entry属性里面
      m._entry.push(this)
      anonymousMeta = null
      // 加载依赖
      m.load()
    } else resolve()
  })
}

fetch 函数主要做的是创建 script 标签,并将 src 设置为当前 Module 对象的 uri 属性,也就是刚刚 load 函数里面处理的逻辑。等脚本 onload 了之后,承接为我们上一节说到的 anonymousMeta

anonymousMeta 有两个属性:

  • deps:deps是当前这个模块的依赖,也就是上一节说到的 define 函数收集到的依赖
  • factory:是模块的工厂函数,等这个模块的所有依赖都被加载完了,就会执行这个工作函数,得到模块导出内容

脚本资源文件加载完毕之后,接下来要看这个模块是否有依赖别的脚本

  • 没有:代表当前模块的依赖都加载完了,可以执行这个模块的工厂函数了,执行完再回调给这个模块的入口(_entry属性),告诉入口模块我已经加载完毕了
  • 有:如果依赖了别的脚本,需要先把这些依赖的脚本加载完了,才能回调给当前模块的入口

接下来看下回调给入口模块的逻辑

Module.prototype.onload = function() {
  this.status = 2
  // 执行模块的工厂函数
  if (this.factory) this.exec()
  // 回调给各个入口模块
  this._entry.map(e => {
    if (!this.remain || --this.remain == 0) {
      e.callback()
    }
  })
}
Module.prototype.exec = function() {
  // 使用缓存
  if (this.status == 3) return this.exports
  this.status = 3
  // 没有缓存的化,就执行工厂函数
  this.exports = this.factory(require)
  return this.exports
}

源码总结

先上一张思维导图

Seajs.png

总的来说,对于 seajs 来说,代表当前模块加载完毕的标志,就是当前模块工厂函数执行完毕,而允许执行当前工厂函数的前提是,当前模块的所有依赖都需要加载完毕。

下面放一个完整的代码和使用示例,小伙伴们可以本地调试

源码:

/** --------------------- Module对象 ---------------------**/
function requestScript(url, cb) {
  var node = document.createElement("script")
  node.async = true
  node.src = url
  node.onload = () => {
    cb()
    document.head.removeChild(node)
  }
  document.head.appendChild(node)
}
var cachedMods = {}, anonymousMeta = null
function require(dep) {
  return cachedMods[dep].exports
}
function Module(deps) {
  this.dependences = deps ? deps instanceof Array ? deps : [deps] : []
  this.deps = {}
  // 0 尚未加载 1 加载中 2 加载完毕 3 缓存结果
  this.status = 0
  this._entry = []
}
Module.prototype.load = function() {
  if (!this.status) {
    this.status = 1
    const dependenceList = []
    this.dependences.map(dep => {
      const moduleName = dep.toString()
      const m = Module.get(moduleName)
      if (m.status == 0) {
        // 记录当前模块的uri
        m.uri = seajs.moduleConfig.alias[moduleName]
        m._entry.push(this)
        this.deps[moduleName] = m
        m.callback = () => {
          m.status = 2
          m.remain = 0
          this.onload.call(this)
        }
        dependenceList.push(m)
      }
    })
    if (!dependenceList.length) {
      this.fetch(this.onload.bind(this))
    } else {
      dependenceList.map(m => {
        m.status = 1
        m.fetch(this.onload.bind(this))
      })
    }
  }
}
Module.prototype.fetch = function(resolve) {
  // 如果当前模块没有uri(代表不是第三方加载的脚本),则直接加载模块内容
  if (!this.uri) {
    resolve()
    return
  }
  requestScript(this.uri, () => {
    this.factory = anonymousMeta.factory
    if (anonymousMeta.deps.length) {
      const moduleName = anonymousMeta.deps.toString()
      const m = Module.get(moduleName)
      // 将收集到的依赖信息记录到当前模块对象中
      this.dependences = this.dependences.concat(anonymousMeta.deps)
      this.deps[moduleName] = m
      this.remain = anonymousMeta.deps.length
      m.uri = seajs.moduleConfig.alias[moduleName]
      m.callback = this.callback
      // 将当前模块的对象加入到依赖的entry属性里面
      m._entry.push(this)
      anonymousMeta = null
      // 加载依赖
      m.load()
    } else resolve()
  })
}
Module.prototype.onload = function() {
  this.status = 2
  if (this.factory) this.exec()
  this._entry.map(e => {
    if (!this.remain || --this.remain == 0) {
      e.callback()
    }
  })
}
Module.prototype.exec = function() {
  if (this.status == 3) return this.exports
  this.status = 3
  this.exports = this.factory(require)
  return this.exports
}
Module.get = function(moduleName, deps) {
  return cachedMods[moduleName] || (cachedMods[moduleName] = new Module(deps))
}
/** --------------------- Module对象 ---------------------**/


/** --------------------- 声明模块 ---------------------**/
var REQUIRE_RE = /"(?:\\"|[^"])*"|'(?:\\'|[^'])*'|\/\*[\S\s]*?\*\/|\/(?:\\\/|[^\/\r\n])+\/(?=[^\/])|\/\/.*|\.\s*require|(?:^|[^$])\brequire\s*\(\s*(["'])(.+?)\1\s*\)/g
var SLASH_RE = /\\\\/g

function parseDependencies(code) {
  var ret = []

  code.replace(SLASH_RE, "")
      .replace(REQUIRE_RE, function(m, m1, m2) {
        if (m2) {
          ret.push(m2)
        }
      })

  return ret
}
function define(factory) {
  var factoryStr = factory.toString()
  const deps = parseDependencies(factoryStr)
  anonymousMeta = {
    deps: deps,
    factory: factory
  }
}
/** --------------------- 声明模块 ---------------------**/ 


/** --------------------- seajs全局对象 ---------------------**/
let count = 0
function Seajs() {
  this.moduleConfig = {}
}
// 注册模块
Seajs.prototype.config = function(config) {
  this.moduleConfig = config
}
// 引入模块
Seajs.prototype.use = function(deps, callback) {
  var mod = Module.get(`__use__${count++}`, deps)
  mod._entry.push(mod)
  mod.remain = deps.length

  mod.callback = function() {
    var exports = []
    deps.map(i => {
      exports.push(cachedMods[i].exec())
    })

    if (callback) callback.apply(window, exports)
  }

  mod.load()
}
window.seajs = new Seajs()
/** --------------------- seajs全局对象 ---------------------**/

使用示例:

index.html

<!DOCTYPE html>
  <html lang="zh-cn">

  <head>
    <meta charset="UTF-8" />
    <meta name="viewport"
      content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0, shrink-to-fit=no">
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <!-- <script src="./lib/sea.js"></script> -->
    <script src="./lib/sea-mini.js"></script>
    <style>
    </style>
  <body>
    <div id="root">
      <button onclick="clickMethod()">加载sea1模块</button>
    </div>
  </html>
<script>
function clickMethod() {
  seajs.use(['sea1'], function name(params) {
    console.log('params', params)
  })
}
seajs.config({
    alias: {
      "sea1": "./sea1.js",
      "sea2": "./sea2.js",
    }
})

</script>

sea1.js

define(function(require, factory) {
    const sea2 = require('sea2')
    return {
        a: 'a',
        b: 'b',
        sea2
    }
});

sea2.js

define(function(require, factory) {
    return {
        c: 'c',
        d: 'd'
    }
});

总结

上面放的其实不是 seajs 的源码,是我看了源码过后自己简单的整理出来的,是一个能跑通的小 demo,相比真正的 seajs 还差了不少细节,比如说:

  1. Module 对象的一些状态,seajs会更丰富一些
  2. 没有实现依赖前置,seajs里面是有的
  3. 对于那三大类API,没有完善参数接收和校验
  4. 没有实现异步引入,也就是 require.async

即使如今 seajs 已经不是主流的规范了,但并不减我对他的兴趣和赞叹,factory.toString() 这句代码所引申出来的设计和思想我认为永不过时。

今天是2021年的最后一天了,还在努力肝文章呜呜,啥时候能像大佬们那样一篇文章几百个赞。。。最近也准备出击找工作了,看到这篇文章的大佬们如果觉得小弟可以,也可以捞捞小弟~~