随着项目的需求和功能越来越复杂,模块化开发已经成为现代工程不可缺少的一部分。然而 es6 之前 js 并没有原生支持模块化开发,这会带来一系列问题,例如命名冲突,依赖 script 标签引入顺序等等。随着社区的发展,出现了 commonJS 规范,AMD 规范,CMD 规范。分别有 node.js,requireJS,seaJS 实现。如今,es6 也已经原生支持了模块化。

关于介绍 js 模块化历史的和解释各个规范的文章已经有很多了,因此这里不在赘述。如果还不了解模块化开发,我这里给几个链接。

正文

我们今天的目标是实现一个简单的阉割版 requireJS,因为是练手,所以我们不考虑循环引用和同步加载,就只简单实现的异步加载。关于 requireJS 上面两个链接都有介绍,或者也可以去官方文档

需求分析

  • html 引入 require.js,通过 data-main 指定入口文件。
  • 模块加载原理是配置好各个模块 path,然后动态创建 script 标签加载模块。
  • 使用 require(id, callback) 函数加载模块,加载完毕后通过回调传入模块变量,即可使用模块。
  • 模块定义则是通过 define(id [, depends], factory),依赖会传入工厂函数。
  • 为了模块不重复加载,因此加载完模块需要缓存,等下次使用时直接从缓存里取。

入口文件

我们先来看看 html 文件

<script src="./require.js" data-main="./main.js"></script>

通过 data-main 指定入口文件,然后执行 main.js。

// main.js
;(function () {
  require.config({
    paths: {
      modA: "./modA.js",
      modB: "./modB.js",
      modC: "./modc.js",
    },
  })
  console.log("hello main")
  require(["modA"])
})()

main.js 中定义了各个模块的 path,之后加载模块就会根据模块 id 动态创建 script 标签,src 就是模块对应的 path。

动态创建 script 标签加载模块

我们先来实现下最基本的模块加载。

// require.js
function load(src, func) {
  const script = document.createElement("script")
  script.setAttribute("src", src)
  script.onload = func
  document.body.appendChild(script)
}
load(document.querySelector("script[data-main]").dataset.main)

动态创建 script 标签直接加载入口文件,这个没什么好说的,值得一提的是,script 标签的 onload 事件,会在 script 标签加载完成后并且执行完脚本后才会调用,所以回调会在脚本执行完后调用,可以用这个来知道模块什么时候加载完成。不过这不是这篇文章的重点,因为接下来并没有用到这个回调,通过事件通知的方法来告知模块加载完成。

然后我们还需要一个方法传入 config 对象,之后 require 模块则会从这个 config 对象里拿到 path 加载模块

// require.js
const config = {}
const require = function () {}
require.config = function (conf) {
  Object.assign(config, conf)
}

基本代码结构

接下来,我们先搭下基本的代码结构。

// require.js
const config = {}
function load(src, callback) {
  const script = document.createElement("script")
  script.setAttribute("src", src)
  script.onload = callback
  document.body.appendChild(script)
}
const require = function () {}
require.config = function (conf) {
  Object.assign(config, conf)
}
const define = function () {}
window.require = require
window.define = define
load(document.querySelector("script[data-main]").dataset.main)

模块加载

然后是 require 方法和 define 方法,我们要往里面填充些内容。先假设只能引入单个模块,然后我们看下代码结构。根据上面的需求分析,我们不难想到

// require.js
;(function () {
  // ....
  const INSTALLED_MODULES = {}
  const require = function (id, callback) {
    if (INSTALLED_MODULES[id]) {
      callback(INSTALLED_MODULES[id])
    } else {
      const path = config.paths[id]
      load(path, () => {
        callback(INSTALLED_MODULES[id])
      })
    }
  }
  const define = function (moduleId, factory) {
    INSTALLED_MODULES[id] = factory()
  }
  // ....
})()

首先我们需要一个变量 INSTALLED_MODULES 来缓存已经加载的模块,通过 define 定义模块,在 define 函数中执行完模块的工厂函数,然后把模块缓存在 INSTALLED_MODULES 中。调用 require 方法是,会先从 INSTALLED_MODULES 中查找模块是否已存在,如果已存在就直接返回模块内容,否则就调用 load 方法去加载模块。

此时,我们可以简单使用

// modA
require("modB", function (modB) {
  modB.greet()
})
// modB
define("modB", function () {
  console.log("B")
  return {
    greet() {
      console.log("hello from modB")
    },
  }
})

控制台输出

define 引入依赖

这样子没有用,因为定义模块时不可以使用别的依赖,并且一次只能 require 一个。但是只是为了熟悉下流程,现在我们来改造一下。

首先是三个模块

// modA
define("modA", ["modB", "modC"], function (modB, modC) {
  console.log("A")
  modB.greet("modA")
  modC.greet("modA")
})
// modB
define("modB", function () {
  console.log("B")
  return {
    greet(name) {
      console.log(`${name} call modB`)
    },
  }
})
//modC
define("modC", ["modB"], function (modB) {
  console.log("C")
  modB.greet("modC")
  return {
    greet(name) {
      console.log(`${name} call modC`)
    },
  }
})

模块 A 依赖模块 B 和模块 C,模块 B 没有依赖,模块 C 又依赖模块 B。然后在回调里会使用依赖模块的方法。 然后是 require 方法和 define 方法

const require = function (moduleIds, callback) {
  const modules = []
  const len = moduleIds.length // 需要加载的模块数量
  let count = 0 // 记录模块加载完成的数量
  const handleLoaded = function (id, index) {
    // 保存模块并记录数量
    modules[index] = INSTALLED_MODULES[id]
    count++
    if (count === len) {
      // 已加载完所有模块, 执行回调
      typeof callback === "function" && callback(...modules)
    }
  }
  for (let i = 0; i < len; i++) {
    const moduleId = moduleIds[i]
    if (INSTALLED_MODULES[moduleId]) {
      handleLoaded(moduleId, i)
    } else {
      // 动态插入script标签加载模块,  并在onload事件里执行handle
      load(config.paths[moduleId], function () {
        handleLoaded(moduleId, i)
      })
    }
  }
}
const define = function (id, depends, factory) {
  if (typeof depends === "function") {
    INSTALLED_MODULES[id] = depends()
    return
  }
  require(depends, function (...modules) {
    INSTALLED_MODULES[id] = factory(...modules)
  })
}

我们先来解释下代码,首先 define 函数,如果第二个参数是函数,则表示该模块没有依赖,直接调用工厂函数并缓存。 否则去 require 依赖,在回调里将依赖传入工厂函数并缓存。

require 函数我们定义了几个变量,modules 用来保存模块,count 记录保存的模块数量。handleLoaded 闭包会在模块缓存完之后调用,里面保存模块并且判断数量来决定何时调用 callback。 然后就是循环去加载模块了。

一般流程就是在 define 里调用 require 去加载依赖,依赖加载完后,会传入模块工厂并保存模块。

然后一样的在 main.js 里 require('modA'),打开控制台,我们能看到 似乎能运行,但其实存在问题,你多刷新几次,会发现 modA 调用 modC 这一行报错了😦。

我们来分析下原因。首先,模块 A 依赖 B 和 C,模块 B 没有依赖,所以 modB.js 脚本里调用 define,define 里同步调用模块 B 的工厂函数缓存起来。然后再来看看模块 C,模块 C 也依赖模块 B,所以 modC.js 脚本执行 define 后,进入 require 函数,会去查看 INSTALLED_MODULES 里面是否存在模块 B,存在的话直接同步执行代码,不存在的话就会去异步加载模块 B。问题的关键就出在异步加载模块 B 这里了:

我们捋一捋流程,我们模块 A 的工厂函数,会在模块 B 和模块 C 脚本的同步代码执行完后的 onload 的事件里调用。如果 script 标签请求 modB.js 先完成,那么执行 modB.js,这里面全是同步代码,模块 B 会被缓存到 INSTALLED_MODULES。执行完后,等 modC.js 加载完,执行 modC.js 脚本,modC.js 在 define 函数里 require 模块 B,由于模块 B 已经被缓存,直接从 INSTALLED_MODULES 里取,所以这里也是同步执行完 modC.js。两个脚本同步代码都执行完,就会调用模块 A 的工厂函数,并将两个模块传进来,这种情况下是正常。

但是,如果 script 标签请求 modC.js 先完成,由于模块 B 还没加载完,所以 modC 的工厂函数会等到 modB 加载完后异步调用。问题就出在

const handleLoaded = function (id, index) {
  modules[index] = INSTALLED_MODULES[id]
  count++
  if (count === len) {
    typeof callback === "function" && callback(...modules)
  }
}

这个 handle 会在 modC.js 同步代码执行完的时候调用,由于模块 C 的工厂函数被异步调用,此时模块 C 还没被缓存,所以 INSTALLED_MODULES['modC']undefined,然后被传入 modules 数组,等到某一刻模块 B 加载完成,count 变为 2,此时就应该去调用模块 A 的工厂函数了,然而此时模块 C 的工厂函数还没被调用,所以传入模块 A 的工厂函数的 modules = [modB, undefined],调用时自然报错。并且这里还有一个问题,模块 A 和 C 都依赖模块 B,但是在模块 B 还没加载完成时它们都不知道是否已经去请求模块 B,因此这里会重复请求模块 B。

事件通知

流程弄明白,问题的关键在于,script 标签的 onload 事件实在同步代码执行完后调用,而此时可能模块工厂函数还没调用,因此我们不能在这里面判断模块加载完成然后去调用 require 回调。我们应该在 define 函数缓存模块时,去通知模块已经加载完成,在 require 里只要监听每个模块完成时的事件,在事件回调里判断是否已经加载完全部模块,等到全部加载完那一刻,才调用 require 的回调。我们还应该更改 INSTALLED_MODULES 里的模块对象,初始化模块用一个 loaded 字段表示是否已加载,exports 存储模块导出的变量。这样我们就能通过判断模块初始化区分是否已经发出请求。

上一张流程图梳理下逻辑

首先我们需要个事件机制,用最简单的方式实现

class Channel {
  constructor() {
    this.events = {}
  }
  subscribe(eventName, callback) {
    if (!this.events[eventName]) {
      this.events[eventName] = []
    }
    this.events[eventName].push(callback)
  }
  publish(eventName, message) {
    this.events[eventName].forEach(callback => {
      callback(message)
    })
  }
}
const channel = new Channel()

然后在 require 里面要判断模块是否初始化,如果未初始化则初始化模块并去请求模块,如果已经初始化且已加载完成,直接同步存进数组,否则就去监听模块加载完成事件

const require = function (moduleIds = [], callback) {
  // 上面一样,  省略...
  for (let i = 0; i < len; i++) {
    const moduleId = moduleIds[i]
    const module = INSTALLED_MODULES[moduleId]
    if (module) {
      if (module.loaded) {
        handleLoaded(moduleId, i)
        continue
      }
    } else {
      // 初始化模块并请求
      INSTALLED_MODULES[moduleId] = {
        exports: {},
        loaded: false,
      }
      load(config.paths[moduleId])
    }
    // 订阅模块加载完毕事件
    channel.subscribe(moduleId, () => {
      handleLoaded(moduleId, i)
    })
  }
}

INSTALLED_MODULES[moduleId] 拿到模块判断是否初始化,如果已经初始化,根据 loaded 字段可以知道是否已加载完成。module.loadedtrue 则直接调用 handle,否则就去监听模块加载完成事件。

const define = function (moduleId, depends, factory) {
  if (typeof depends === "function") {
    INSTALLED_MODULES[moduleId].exports = depends()
    INSTALLED_MODULES[moduleId].loaded = true
    channel.publish(moduleId)
  } else {
    require(depends, function (...modules) {
      INSTALLED_MODULES[moduleId].exports = factory(...modules)
      INSTALLED_MODULES[moduleId].loaded = true
      channel.publish(moduleId)
    })
  }
}

通过 require 引入模块并判断是否初始化模块,define 一定会调用并且只有一次,因此只要在 define 函数中缓存模块时发布模块加载完成事件就可以了。这里就直接用模块名称当作事件名了。发布事件后,require 里监听事件的回调会被调用,此时回调里已经可以拿到模块变量了,拿到模块变量保存并判断加载数量,在合适的时机调用 require 的回调就 ojbk 了

查看下控制台

至此,一个简单的模块加载器大功告成,撒花~ :tada::tada: 最后代码:

;(function () {
  class Channel {
    constructor() {
      this.events = {}
    }
    subscribe(eventName, callback) {
      if (!this.events[eventName]) {
        this.events[eventName] = []
      }
      this.events[eventName].push(callback)
    }
    publish(eventName, message) {
      this.events[eventName].forEach(callback => {
        callback(message)
      })
    }
  }
  const channel = new Channel()
  const INSTALLED_MODULES = {}
  const config = {}
  function load(src, callback) {
    const script = document.createElement("script")
    script.setAttribute("src", src)
    script.onload = callback
    document.body.appendChild(script)
  }
  const require = function (moduleIds = [], callback) {
    const modules = []
    const len = moduleIds.length
    let count = 0
    const handleLoaded = function (id, index) {
      // 保存模块并记录数量
      modules[index] = INSTALLED_MODULES[id].exports
      count++
      if (count === len) {
        // 已加载完所有模块,执行回调
        typeof callback === "function" && callback(...modules)
      }
    }
    for (let i = 0; i < len; i++) {
      const moduleId = moduleIds[i]
      const module = INSTALLED_MODULES[moduleId]
      if (module) {
        if (module.loaded) {
          handleLoaded(moduleId, i)
          continue
        }
      } else {
        // 初始化模块并请求
        INSTALLED_MODULES[moduleId] = {
          exports: {},
          loaded: false,
        }
        load(config.paths[moduleId])
      }
      // 订阅模块加载完毕事件
      channel.subscribe(moduleId, () => {
        handleLoaded(moduleId, i)
      })
    }
  }
  require.config = function (conf) {
    Object.assign(config, conf)
  }
  const define = function (moduleId, depends, factory) {
    if (typeof depends === "function") {
      INSTALLED_MODULES[moduleId].exports = depends()
      INSTALLED_MODULES[moduleId].loaded = true
      channel.publish(moduleId)
    } else {
      require(depends, function (...modules) {
        INSTALLED_MODULES[moduleId].exports = factory(...modules)
        INSTALLED_MODULES[moduleId].loaded = true
        channel.publish(moduleId)
      })
    }
  }
  window.require = require
  window.define = define
  load(document.querySelector("script[data-main]").dataset.main)
})()