
实现一个简易的模块加载器
Apr 11, 2019
随着项目的需求和功能越来越复杂,模块化开发已经成为现代工程不可缺少的一部分。然而 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.loaded
为 true
则直接调用 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)
})()