记手写requirejs的思考过程

859

当一份代码被冠上某某框架源码时,这份代码就变得高深圣神,好像只有一流的程序员能写得出来,我等凡人是想也不管想。其实,抛开框架源码这个枷锁,我们也能思考它解决的问题,体验思考的乐趣。比如说,写一个小版的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)
  }
})

15477627613810

上面是代码和目录,我们的目标就是实现这些功能--最后在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()
      
    }
  }
}

  1. 构建了script标签,浏览器下载相应的js文件
  2. 浏览器执行define并且往handlers添加name为null的对象
  3. script执行完成,触发onload事件

注意2,3点,由于它们在顺序上存在联系,当前define和当前script.onload是一一对应。这样我们可以在script加载后,反向为其添加名字,将depUrl和moduleName结合起来

总结一下整个思路:

  1. 构建script标签加载模块
  2. handlers存储回掉函数,让我们能在其依赖模块加载完成再执行
  3. depsToName找到加载的路径和模块名称的对应关系
  4. executedModule找到模块名称和执行的结果,让我们执行回调函数时能取到依赖模块的值

源码解析

以下源码是能实现最上面的实例。再理清整个过程:

  1. require和define都只是添加函数到handlers,并执行依赖下载
  2. 依赖下载完成之后,第一步是为依赖添加模块名,默认是路径名;并设置depsToName:依赖路径与模块名的关系
  3. 接着执行存放在handlers的回调函数,先判断是否依赖都加载完成。这个过程需要将依赖路径与模块名对应起来,并通过模块名找到模块值
  4. 找到依赖的值,传给回调函数,回调函数执行完之后删除当前已执行的回调函数,并赋值给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还差很远,但主要功能都实现了。自己付出思考,写完之后成就感满满。