当一份代码被冠上某某框架源码时,这份代码就变得高深圣神,好像只有一流的程序员能写得出来,我等凡人是想也不管想。其实,抛开框架源码这个枷锁,我们也能思考它解决的问题,体验思考的乐趣。比如说,写一个小版的requirejs。
实例
requirejs是前端模块化AMD规范的实现,它的出现来源对前端模块化的真实需求,但是在这里我们不讨论这一步,直接定位到代码上,如果要实现这些功能,该怎么做:
第一步,先写实例。
// text.html
// <script data-main="scripts/main" src="scripts/require.js"></script>
// main.js
require(["helper/sum"], function(sum) {
console.log('sum', sum)
});
// helper/sum.js
define(['helper/num_1', 'helper/num_2'], (num1, num2) => {
return {
sum: num1.num + num2.num
}
})
// helper/multi.js
define(() => {
return function (data) {
return data * 2
}
})
// helper/num_1.js
define(() => {
return {
num: 1
}
})
// helper/num_2.js
define(['helper/multi'], (multi) => {
return {
num: multi(3)
}
})
上面是代码和目录,我们的目标就是实现这些功能--最后在main.js能正确将结果打印出来。有以下问题需要解决:
文件是怎么被加载的?
在ES6 Module方案出来前,浏览器加载js文件只有一种方法:通过script标签。所谓文件加载,也就是构建script标签添加到页面上。比如main.js是这么加载的:创建script标签,并添加到页面上。其他模块也是同样的方法。
function startMain() {
const scripts = document.querySelectorAll('script');
for (let i = 0; i < scripts.length; i++) {
const script = scripts[i]
const attr = script.getAttribute("data-main");
if (attr) {
const mainScript = document.createElement('script')
mainScript.src = attr + '.js'
window.onload = () => {
document.body.appendChild(mainScript)
}
}
}
}
startMain()
怎么才能保证依赖执行完成后才执行回调函数?
这种延后执行回调函数,是有通用的解决方法:即用一个数组将回调函数存储,在合适的时候调用,检查依赖是否都完成了,再执行。handlers是数组,存储的是require和define的回调函数。在执行回调之前就可以对其做判断,保证依赖已经加载完成。
const handlers = [{
moduleName: '',
deps: ['depUrl1', 'depUrl2'],
cb: fn
}]
问题的关键是什么是依赖完成,是依赖模块加载完成吗?答案是否定的。因为已加载只是加载了函数,函数还没有执行,要等到执行后才能拿到值,也只有在执行之后才能继续执行被依赖的函数。所以有了下面的数据结构:executedModule存储的是已执行的模块和执行后的值。上面的handlers有一项是moduleName,也就是这里的moduleName
const executedModule = { 'moduleName', 'moduleValue' }
依赖的执行结果怎么传递给回调函数?
比如下面,'helper/multi'是模块的加载路径,那么模块名是什么呢?这里的模块名在'helper/multi'文件里面,有可能命名也有可能没有,
// helper/num_2.js
define(['helper/multi'], (multi) => {
return {
num: multi(3)
}
})
故需要一个对象,将路径与模块名对应起来,添加数据结构:
const depsToName = { 'depUrl': 'moduleName' }
那么它们的关系怎么确定呢?观看下面代码,可以看出执行顺序是这样的:
define = (name, deps, cb) => {
...
handlers.push({
name: name,
deps: deps,
fn: cb,
isLoading: true
})
loadRequireModule(deps)
}
const loadRequireModule = (deps) => {
for (let i = 0; i < deps.length; i++) {
const url = deps[i]
let script = document.createElement('script')
script.src = 'scripts/' + url + '.js'
script.setAttribute('data-name', url)
document.body.appendChild(script)
script.onload = (data) => {
const moduleName = data.target.getAttribute('data-name')
addNameToModule(moduleName)
runHandles()
}
}
}
- 构建了script标签,浏览器下载相应的js文件
- 浏览器执行define并且往handlers添加name为null的对象
- script执行完成,触发onload事件
注意2,3点,由于它们在顺序上存在联系,当前define和当前script.onload是一一对应。这样我们可以在script加载后,反向为其添加名字,将depUrl和moduleName结合起来
总结一下整个思路:
- 构建script标签加载模块
- handlers存储回掉函数,让我们能在其依赖模块加载完成再执行
- depsToName找到加载的路径和模块名称的对应关系
- executedModule找到模块名称和执行的结果,让我们执行回调函数时能取到依赖模块的值
源码解析
以下源码是能实现最上面的实例。再理清整个过程:
- require和define都只是添加函数到handlers,并执行依赖下载
- 依赖下载完成之后,第一步是为依赖添加模块名,默认是路径名;并设置depsToName:依赖路径与模块名的关系
- 接着执行存放在handlers的回调函数,先判断是否依赖都加载完成。这个过程需要将依赖路径与模块名对应起来,并通过模块名找到模块值
- 找到依赖的值,传给回调函数,回调函数执行完之后删除当前已执行的回调函数,并赋值给executedModule,最后开启下一次循序(保证了依赖执行完之后才执行回调函数)
// require.js
var require;
var define;
(function () {
var executedModule = {}; // key 为已执行的模块名称,value为执行后的值
var handlers = []; // 待依赖完成而执行的模块
var depsToName = {}; // 路径和名称对应表,key为路径,value为模块名
function checkIsAllLoaded(handle) {
if (!handle.deps.length) return true // 没有依赖
return handle.deps.every(dep => {
const moduleName = depsToName[dep]
return !!executedModule[moduleName] === true
})
}
function getArgsFromDepend(handle) {
const args = []
handle.deps.forEach(re => {
const moduleName = depsToName[re]
if (executedModule[moduleName]) {
args.push(executedModule[moduleName])
}
})
return args
}
function runHandles() {
handlers.forEach((handle, index) => {
const isDependLoaded = checkIsAllLoaded(handle)
if (isDependLoaded) {
const arg = getArgsFromDepend(handle)
var result = handle.fn(...arg)
executedModule[handle.name] = result
handlers.splice(index, 1)
runHandles()
}
})
}
require = (deps, cb) => {
if (typeof deps === 'function' && cb === undefined) {
cb = deps;
url = []
}
if (!Array.isArray(deps)) {
throw 'first argument is not array'
}
if (typeof cb !== 'function') {
throw 'second argument must be a function'
}
handlers.push({
name: 'main',
deps: deps,
fn: cb
})
loadRequireModule(deps)
}
function addNameToModule(urlName) {
for (let i = 0; i < handlers.length; i++) {
if (handlers[i].isLoading) {
if (!handlers[i].name) { //如果没有定义define名字
handlers[i].name = urlName
}
depsToName[urlName] = handlers[i].name // 将depUrl与moduleName关联起来
handlers[i].isLoading = false
break
}
}
}
function loadRequireModule(deps) {
for (let i = 0; i < deps.length; i++) {
const url = deps[i]
let script = document.createElement('script')
script.src = 'scripts/' + url + '.js'
script.setAttribute('data-name', url)
document.body.appendChild(script)
script.onload = (data) => {
const moduleName = data.target.getAttribute('data-name')
addNameToModule(moduleName) // 添加名字
runHandles() // 执行回调
}
}
}
define = (name, deps, cb) => {
if (typeof name === 'function') {
cb = name
deps = []
name = null
}
if (Array.isArray(name) && typeof deps === 'function') {
cb = deps
deps = name
name = null
}
if (typeof name === 'string' && typeof deps === 'function') {
cb = deps
deps = []
}
handlers.push({
name: name,
deps: deps,
fn: cb,
isLoading: true // 这个字符标识的是当前正在加载的路径,用于为其添加名字时的定位
})
loadRequireModule(deps)
}
function startMain() {
const scripts = document.querySelectorAll('script');
for (let i = 0; i < scripts.length; i++) {
const script = scripts[i]
const attr = script.getAttribute("data-main");
if (attr) {
const mainScript = document.createElement('script')
mainScript.src = attr + '.js'
window.onload = () => {
document.body.appendChild(mainScript)
}
}
}
}
startMain()
})()
后记
尝试着根据实例自己思考出代码,虽然这份代码很多功能没有实现,离requirejs还差很远,但主要功能都实现了。自己付出思考,写完之后成就感满满。