一个cmd模块加载器toy

520 阅读6分钟


基本信息

CMD风格的模块加载器是解决javascript模块化加载的一个方案,典型代表是sea.js,它的规范也很简洁。在研究原理的时候,可以自己动手写一个简单的loader。为了方便,就叫它testLoader

基本实现

testLoader有这样的功能:

  1. 通过testLoader.config({...})来配置一些全局的信息
  2. 通过testLoader.define(function(){})来定义模块,但是不指定模块的id,用模块所在文件的路径来作为模块的id
  3. 在模块内部通过require来加载别的模块
  4. 在模块内部通过exportsmodule.exports来对外暴露接口
  5. 通过testLoader.bootstrap(id, callback)来作为入口启动

首先,定义testLoader的基本信息:

window.testLoader = {
  define: define,
  bootstrap: bootstrap,
  require: require,
  modules: {},
  config: {
    root: ''
  },
  config: function(obj) {
    this.config = {...this.config, ...obj}
  },
  MODULE_STATUS: {
    PENDING: 0,
    LOADING: 1,
    COMPLETED: 2,
    ERROR: 3
  }
}

testLoader.bootstrap开始看。testLoader.bootstrap(id, callback)在执行时,首先是根据id来加载模块,加载模块完成后,将模块暴露出的对象作为参数,执行callback。在加载模块的时候,首先是从testLoader的模块缓存中查找有没有相应的模块,如果有,就直接取,否则,就创建一个新的模块,并将这个模块缓存。代码如下:

const generatePath = (id) => `${testLoader.config.root}${id}`
const load = (id) => new Promise((resolve, reject) => {
  let mod = testLoader.modules[id] || (new Module(id))
  mod.on('complete', () => {
    let exp = getModuleExports(mod)
    resolve(exp)
  })
  mod.on('error', reject)
})
const bootstrap = (ids, callback) => {
  ids = Array.isArray(ids) ? ids : [ids]
  Promise.all(ids.map((id) => load(generatePath(id))))
  .then((list) => {
    callback.apply(window, list)
  }).catch((error) => {
    throw error
  })
}

getModuleExports时是用于获取模块暴露出的接口,实现如下:

const getModuleExports = (mod) => {
  if (!mod.exports) {
    mod.exports = {}
    mod.factory(testLoader.require, mod.exports, mod)
  }
  return mod.exports
}

当模块的exports属性为空的时候,执行mod.factory(testLoader.require, mod.exports, mod),因为传入的mod.exports是一个引用类型,在factory执行的过程中会因为副作用,为mod.exports提供值。

而Module则是一个用来生成模块对象的Class,定义如下:

class Module {
  constructor(id) {
    this.id = id 
    testLoader.modules[id] = this
    this.status = testLoader.MODULE_STATUS.PENDING
    this.factory = null
    this.dependences = null
    this.callbacks = {}
    this.load()
  }
  load() {
    let id = this.id
    let script = document.createElement('script')
    script.src = id
    script.onerror = (event) => {
      this.setStatus(testLoader.MODULE_STATUS.ERROR, {
        id: id,
        error: new Error('module can not load')
      })
    }
    document.head.appendChild(script)
    this.setStatus(testLoader.MODULE_STATUS.LOADING)
  }
  on(event, callback) {
    (this.callbacks[event] || (this.callbacks[event] = [])).push(callback)
    if (
      (this.status === testLoader.MODULE_STATUS.LOADING && event === 'load') || 
      (this.status === testLoader.MODULE_STATUS.COMPLETED && event === 'complete')
    ) {
      callback(this)
    }
    if (this.status === testLoader.MODULE_STATUS.ERROR && event === 'error') {
      callback(this, this.error)
    }
  }
  emit(event, arg) {
    (this.callbacks[event] || []).forEach((callback) => {
      callback(arg || this)
    })
  }
  setStatus(status, info) {
    if (this.status === status) return
    if (status === testLoader.MODULE_STATUS.LOADING) {
      this.emit('load')
    }
    else if (status === testLoader.MODULE_STATUS.COMPLETED) {
      this.emit('complete')
    }
    else if (status === testLoader.MODULE_STATUS.ERROR) {
      this.emit('error', info)
    }
    else return
  }
}

在创建一个模块对象的时候,首先是给模块赋予一些基本的信息,然后通过script标签来加载模块的内容。这个模块对象只是提供了一个模块的基本的属性和简单的事件通信机制,但是模块的内容,模块的依赖这些信息,需要通过define来提供。define为开发者提供了定义模块的能力,Module则是提供了testLoader描述表示模块的方式。

通过define定义模块,在define执行的时候,首先需要为模块定义一个id,这个id是模块在testLoader中的唯一标识。在前面已经说明了,在testLoader中,不能指定id,只是通过路径来生成id,那么通过获取当前正在运行的script代码的路径来生成id。获取到id之后,从testLoader的缓存中取出对应的模块表示,然后解析模块的依赖。由于define的时候,不能指定id和依赖,对依赖的解析是通过匹配关键字require来实现的,通过解析require('x')获取所有的依赖模块的id,然后加载所有依赖。就完成了模块的定义,代码如下:

const getCurrentScript = () => document.currentScript.src
const getDependence = (factoryString) => {
  let list = factoryString.match(/require\(.+?\)/g) || []
  return list.map((dep) => dep.replace(/(^require\(['"])|(['"]\)$)/g, ''))
}
const define = (factory) => {
  let id = getCurrentScript().replace(location.origin, '')
  let mod = testLoader.modules[id]
  mod.dependences = getDependence(factory.toString())
  mod.factory = factory
  if(mod.dependences.length === 0) {
    mod.setStatus(testLoader.MODULE_STATUS.COMPLETED)
    return
  }
  Promise.all(mod.dependences.map((id) => new Promise((resolve, reject) => {
      id = generatePath(id) 
      let depModule = testLoader.modules[id] || (new Module(id))
      depModule.on('complete', resolve)
      depModule.on('error', reject)
    })
  )).then((list) => {
    mod.setStatus(testLoader.MODULE_STATUS.COMPLETED)
  }).catch((error) => {
    mod.setStatus(testLoader.MODULE_STATUS.ERROR, error)
  })
}

那么依赖别的模块是通过require来实现的,它核心的功能是获取一个模块暴露出来的接口,代码如下:

const require = (id) => {
  id = generatePath(id)
  let mod = testLoader.modules[id]
  if (mod) {
    return getModuleExports(mod)
  }
  else {
    throw 'can not get module by id: ' + id
  }
}

从上面解析依赖的方式可以看出,在通过define定义模块的时候,匿名函数有三个参数

testLoader.define(function(requrie, exports, module){})

exports本质上是module.exports的引用,所以通过exports.a=x是可以暴露接口的,但是exports={a:x}则不行,因为后一种方式本质上是改变了将exports作为一个值类型的参数,修改它的值,这种操作,在函数调用结束后,是不会生效的。按照这种原理,module.exports={a:x}是可以达到效果的。

测试例子
  1. index.js
testLoader.define(function(require, exports, module) {
   var a = require('a.js')
   var b = require('b.js')
   a(b)
   module.exports = {
     a: a,
     b: b
   }
 })
  1. a.js
testLoader.define(function(requrie, exports, module) {
   module.exports = function(msg) {
     console.log('in the a.js')
     document.body.innerHTML = msg
   }
 })
  1. b.js
testLoader.define(function(require, exports, module) {
   console.log('in the b.js')
   module.exports = 'Wonderful Tonight'
 })
  1. index.html
<html lang="en">
   <head>
     <title></title>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <script src='./test-cmd-loader.js'></script>
     <script>
       testLoader.config({
         root: '/Users/guangyi/code/javascript/sass/lib/test/'
       })
       testLoader.bootstrap('index.js', (list) => {
         console.log(list)
       })
     </script>
   </head>
   <body></body>
 </html>
  1. 用浏览器打开,在调试窗口能看到打印的log,页面上也渲染出了Wonderful Tonight。<html lang="en">

</html>

总结

通过这个简单的loader,可以了解CMD的规范,以及CMD规范的loader工作的基本流程。但是,和专业的loader相比,还有很多没有考虑到,比如define的时候,支持指定模块的id和依赖,不过在上面的基础上,也很容易实现,在生成id的时候将自动生成的id作为默认值,在决定依赖的时候,将参数中定义的依赖和解析生成的依赖执行一次merge处理即可。但是,这些能力本质上还是一样的,因为这种机制定义的依赖是静态依赖,在这个模块的内容执行之前,依赖的模块已经被加载了,所以类似

if (condition) {
  require('./a.js')
}
else {
  require('./b.js)
}

这种加载依赖的方式是不生效的,不论condition的值是什么,两个模块都会被加载。要实现动态加载,或者说运行时加载,一个可行的方案是在上面的基础上,提供一个新的声明依赖的关键字,然后这个关键字代表的函数,在执行的时候再创建模块,加载代码。

还有,当模块之间存在循环依赖的情况,还没有处理。理论上,通过分析模块之间的静态依赖关系,就可以发现循环依赖的情况。也可以在运行的时候,根据模块的状态决定模块是否返回空来结束循环依赖。

还有跨语言的支持也是一个很有意思的问题。