实现简单的 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)
}
}

最新文章

  1. 【Mutual Training for Wannafly Union #1 】
  2. 40 网络相关函数(八)——live555源码阅读(四)网络
  3. 【Unity3D技巧】在Unity中使用事件/委托机制(event/delegate)进行GameObject之间的通信 (二) : 引入中间层NotificationCenter
  4. Markdown简单语法总结
  5. 运行时改变控件的大小(点击后立刻ReleaseCapture,然后计算位移,最后发消息改变位置)——最有趣的是TPanel其实也有窗口标题,因此可发HTCAPTION消息
  6. jQuery 元素移除empty() remove()与detach()的区别?
  7. Head First Html 与 Css 截图
  8. Square Coins(母函数)
  9. Hadoop源码解析之: TextInputFormat如何处理跨split的行
  10. nyoj_68:三点顺序(计算几何)
  11. 单调队列以及单调队列优化DP
  12. 解决Cannot resolve reference to bean &#39;txPointcut&#39; while setting bean property &#39;pointcut&#39;
  13. linux配置sphinx
  14. Objective-C(生命周期)
  15. Ruby知识点三:运算符
  16. POJ 3007 Organize Your Train part II (字典树 静态)
  17. HDU 5007 字符串匹配
  18. canvas学写一个字
  19. React容器组件和展示组件
  20. 生成静态页java代码

热门文章

  1. 位运算基础知识及简单例题(待补全Hamilton)
  2. WIFI Pineapple 排雷
  3. 全局程序集缓存工具(Gacutil.exe)用法详解
  4. day6 基础总结和编码方式
  5. Tomcat开启JMX监控
  6. nginx模块之ngx_http_upstream_module
  7. 图像读取函数cv::imread()的几种使用方式
  8. laravel框架用使用session 和cookie
  9. 关于springboot连接数据库失败时,系统报错 MySQL:The server time zone value &#39;&#214;&#208;&#185;&#250;&#177;&#234;&#215;&#188;&#202;&#177;&#188;&#228;&#39; is unrecognized or represents .....
  10. Codeforces 832A. Sasha and Sticks