实现简单的 JS 模块加载器
1. 背景介绍
按需加载是前端性能优化的一个重要手段,按需加载的本质是从远程服务器加载一段JS代码(这里主要讨论JS,CSS或者其他资源大同小异),该JS代码就是一个模块的定义,如果您之前有去思考过按需加载的原理,那你可能已经知道按需加载需要依赖一个模块加载器。它可以加载所有的静态资源文件,比如:
- JS 脚本
- CSS 脚本
- 图片 资源
如果你了解 webpack,那您可以发现在 webpack 内部,它实现了一个模块加载器。模块加载器本身需要遵循一个规范,当然您可以自定义规范,大部分运行在浏览器模块加载器都遵循 AMD 规范,也就是异步加载。
容易理解的是,对于某个应用使用了模块加载器,那么首先需要加载该模块加载器JS代码。然后有一个主模块,程序从主模块开始执行, requireJS 中使用main来标记,webpack 中叫 webpackBootstrap 模块。
2. 实现简单的加载器
2.1 需求整理
- 模块的定义
- 模块的加载
- 已经加载过的模块需要缓存
- 同一个模块并行加载的处理
2.2 运行流程图
2.2 功能实现
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
1. 相同模块的并发加载问题?
</body>
<script>
// 模块加载配置
var config = {
baseDir: window.location.origin + '/module'
}
// loader 模块加载器
var loader = {
// 配置
config: {
baseDir: window.location.origin + '/module'
},
// 缓存
modules: {},
// 注册加载后的回调
installed: {},
// 标记某个模块是否已经加载
status: {},
// 定义模块
define: function(name, fn) {
this.modules[name] = fn ? fn() : undefined
},
// 加载模块
require: function(name, fn) {
if (this.modules[name]) {
// 已经加载成功, 直接从缓存读取
callback(this.modules[name])
} else {
if (this.status[name]) {
// 加载过了, 但是还未加载成功
this.installed[name].push(fn)
} else {
// 还未加载过
this.installed[name] = []
this.installed[name].push(fn)
this.loadScript(name)
this.status[name] = true
}
}
},
// 加载JS文件
loadScript: function (name, callback) {
let _this = this
let script = document.createElement('script')
script.src = this.config.baseDir + '/' + name + '.js'
script.onload = function () {
_this.installed[name].forEach(fn => fn(_this.modules[name]))
}
setTimeout(() => {
// 模拟请求时间
document.body.append(script)
}, 200)
}
}
loader.require('lazyload', function(lazyload){
console.log(Date.now())
lazyload()
})
loader.require('lazyload', function(lazyload){
console.log(Date.now())
lazyload()
})
</script>
</html>
// module/lazyload.js
loader.define('lazyload', function(){
return function () {
console.log('I am lazyload')
}
})
这个版本已经是简单的不能再简单了,首先它没有对模块加载失败设计异常处理机制,其次真实的场景中存在一个模块的定义依赖其他模块:
// toolbar 模块依赖common模块
loader.define('toolbar', ['common'], function(){
return function () {
console.log('I am toolbar')
}
})
3. 模块的定义依赖其他模块
3.1 程序分析
假如模块A依赖模块B和C,这里有几个关键点:
- 如何判断B和C都加载完,然后再执行模块A的导出函数
- 使用 define 定义模块A时,需要先收集依赖,然后当A模块加载完成后(loadScript),再加载其依赖项,当所有依赖项都加载完成后,再获取模块A的导出值。
演示代码:
// 定义模块
loader.define('toolbar', ['common', 'lazyload'], function(){
return function () {
const { common, lazyload } = loader.modules; // 通过这样访问依赖
console.log('I am toolbar')
}
})
// 加载模块
loader.require('lazyload', function(){
console.log('require lazyload')
})
3.2 代码实现
/**
* loader 模块加载器
*/
var loader = {
config: { // 配置
baseDir: window.location.origin + '/module'
},
modules: {}, // 缓存
installed: {}, // 加载成功
status: {}, // 加载状态
deps: {}, // 模块的依赖
moduleDefined: {}, // 缓存模块的定义
/**
* @description 注册模块, 每个模块最多只会注册1次
* @example:
* define('sleep', function(){ return 5 }) 定义模块名为sleep,导出值为5
* define('sleep', function(){ return { name: 5 } }) 定义模块名为sleep,导出一个对象
* define('sleep', function(){ return function(){ console.log(5)}}) 定义模块名为sleep,导出一个函数
*/
define: function() {
let name, fn, deps, args = arguments
if (args.length === 2) {
name = args[0]
fn = args[1]
} else if (args.length === 3) {
name = args[0]
deps = (typeof args[1] === 'string') ? [args[1]] : args[1]
fn = args[2]
} else {
throw "invalid params for define function"
}
// 收集依赖
if (deps) this.deps[name] = deps;
// 缓存模块导出函数
this.moduleDefined[name] = fn
},
/**
* @description 加载模块
* @param {string} name 模块名
* @param {*} requireCb 加载完成回调函数
*/
require: function(name, requireCb) {
if (this.modules[name]) {
// 已经加载成功, 直接从缓存读取
requireCb(this.modules[name])
} else {
if (this.status[name]) {
// 加载过了, 但是还未加载成功
this.installed[name].push(requireCb)
} else {
// 还未加载过
this.installed[name] = []
this.installed[name].push(requireCb)
this.loadScript(name)
this.status[name] = true
}
}
},
/**
* @description 加载多个模块
* @param {string} names 模块名数组
* @param {*} fn 回调函数
*/
requires: function(names, fn) {
let excuted = false
names.forEach(name => {
this.require(name, () => {
if (!excuted) {
// 保证回调只执行一次
if (names.filter(v => this.modules[v] !== undefined).length === names.length) {
excuted = true
fn && fn()
}
}
})
})
},
/**
* @description 加载JS文件
* @param {string} name 模块名
*/
loadScript: function (name) {
let _this = this
let script = document.createElement('script')
script.src = this.config.baseDir + '/' + name + '.js'
script.onload = function () {
// 需要注意, 当模块的JS文件加载完成, 不能立即调用require(name, fn) 所注册的fn回调函数
// 因为它可能依赖其它模块, 需要将依赖的模块也加载完成之后, 再触发
// _this.installed[name] 为数组是因为并行加载时, 注册了多个回调
if (!_this.deps[name]) {
_this.modules[name] = _this.moduleDefined[name]();
_this.installed[name].forEach(fn => {
fn(_this.modules[name]);
})
} else {
_this.requires(_this.deps[name], () => {
// 依赖项全部加载完成
_this.modules[name] = _this.moduleDefined[name]();
_this.installed[name].forEach(fn => {
fn(_this.modules[name]);
})
});
}
}
document.body.append(script)
}
}
4. 注入依赖到导出函数
在第3步骤中,虽然实现了模块定义的依赖支持,但是没有注入到导出函数中,我们希望模块的定义改成下面的样子:
// 定义模块
loader.define('toolbar', ['common', 'lazyload'], function(common, lazyload){
return function () {
// const { common, lazyload } = loader.modules; // 不使用这种方式
console.log('I am toolbar')
}
})
这个还是比较简单,只需要修改下面标识Mark的位置:
if (!_this.deps[name]) {
_this.modules[name] = _this.moduleDefined[name]();
_this.installed[name].forEach(fn => {
fn(_this.modules[name]);
})
} else {
_this.requires(_this.deps[name], () => {
// 依赖项全部加载完成
const injector = _this.deps[name].map(v => _this.modules[v]) // Mark
_this.modules[name] = _this.moduleDefined[name](...injector); // Mark
_this.installed[name].forEach(fn => {
fn(_this.modules[name]);
})
});
}
5. require 支持列表的方式
按照之前的方式,如果先后require两个模块,代码可能是:
loader.require('common', function(common){
loader.require('lazyload', function(lazyload){
console.log('require1 toolbar', Date.now())
})
})
这种嵌套的方式看起来非常糟糕,希望把它换成下面这种方式:
loader.require(['common', 'lazyload'], function(common, lazyload){
console.log('require1 toolbar', Date.now())
})
这里主要修改 require 方法:
require: function(name, requireCb) {
if (Array.isArray(name)) {
// 加载多个
this._requires(name, () => {
const injector = name.map(v => this.modules[v])
requireCb(...injector)
})
return
}
if (this.modules[name]) {
// 已经加载成功, 直接从缓存读取
requireCb(this.modules[name])
} else {
if (this.status[name]) {
// 加载过了, 但是还未加载成功
this.installed[name].push(requireCb)
} else {
// 还未加载过
this.installed[name] = []
this.installed[name].push(requireCb)
this.loadScript(name)
this.status[name] = true
}
}
}
6. 总结
通过一步一步的功能丰富,到此一个满足大部分功能的JS模块加载器就实现了。在梳理其过程中加深了我对依赖注入的理解。下面是完整代码:
/**
* loader 模块加载器
*/
var loader = {
config: { // 配置
baseDir: window.location.origin + '/module'
},
modules: {}, // 缓存
installed: {}, // 加载成功
status: {}, // 加载状态
deps: {}, // 模块的依赖
moduleDefined: {}, // 缓存模块的定义
/**
* @description 注册模块, 每个模块最多只会注册1次
* @example:
* define('sleep', function(){ return 5 }) 定义模块名为sleep,导出值为5
* define('sleep', function(){ return { name: 5 } }) 定义模块名为sleep,导出一个对象
* define('sleep', function(){ return function(){ console.log(5)}}) 定义模块名为sleep,导出一个函数
* define('sleep', ['common'], function(common){ return function(){}}) sleep模块依赖 common模块
*/
define: function() {
let name, fn, deps, args = arguments
if (args.length === 2) {
name = args[0]
fn = args[1]
} else if (args.length === 3) {
name = args[0]
deps = (typeof args[1] === 'string') ? [args[1]] : args[1]
fn = args[2]
} else {
throw "invalid params for define function"
}
// 收集依赖
if (deps) this.deps[name] = deps;
// 缓存模块导出函数
this.moduleDefined[name] = fn
},
/**
* @description 加载模块
* @param {string} name 模块名
* @param {*} requireCb 加载完成回调函数
* @examples:
* require('common', function(common){}) 加载一个模块
* require(['common', 'toolbar], function(common, toolbar){}) 加载多个模块
*/
require: function(name, requireCb) {
if (Array.isArray(name)) {
// 加载多个
this._requires(name, () => {
const injector = name.map(v => this.modules[v])
requireCb(...injector)
})
return
}
if (this.modules[name]) {
// 已经加载成功, 直接从缓存读取
requireCb(this.modules[name])
} else {
if (this.status[name]) {
// 加载过了, 但是还未加载成功
this.installed[name].push(requireCb)
} else {
// 还未加载过
this.installed[name] = []
this.installed[name].push(requireCb)
this.loadScript(name)
this.status[name] = true
}
}
},
/**
* @description 加载多个模块
* @param {string} names 模块名数组
* @param {*} fn 回调函数
*/
_requires: function(names, fn) {
let excuted = false
names.forEach(name => {
this.require(name, () => {
if (!excuted) {
// 保证回调只执行一次
if (names.filter(v => this.modules[v] !== undefined).length === names.length) {
excuted = true
fn && fn()
}
}
})
})
},
/**
* @description 处理某个模块加载完成
* @param {string} name
*/
_onLoadScriptSuccess: function(name) {
if (!this.deps[name]) {
this.modules[name] = this.moduleDefined[name]();
this.installed[name].forEach(fn => {
fn(this.modules[name]);
})
} else {
this._requires(this.deps[name], () => {
const injector = this.deps[name].map(v => this.modules[v])
this.modules[name] = this.moduleDefined[name](...injector);
this.installed[name].forEach(fn => {
fn(this.modules[name]);
})
});
}
},
/**
* @description 加载JS文件
* @param {string} name 模块名
*/
loadScript: function (name) {
let script = document.createElement('script')
script.src = this.config.baseDir + '/' + name + '.js'
script.onload = () => {
this._onLoadScriptSuccess(name)
}
document.body.append(script)
}
}