purplebamboo / blog

purplebamboo的博客

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

javascript模块加载器实践

purplebamboo opened this issue · comments

javascript模块加载器实践

但凡是比较成熟的服务端语言,都会有模块或者包的概念。模块化开发的好处就不用多说了。由于javascript的运行环境(浏览器)的特殊性。js很早之前一直都没有模块的概念。经过一代代程序猿们的努力。提供了若干的解决方案。

基本对象

为了解决模块化的问题。早期的程序员会把代码放到某个变量里。做一个最简单的命名空间的划分。

比如一个工具模块:util

var util = {
    _prefix:'我想说:',
    log:function(msg){ console.log(_prefix +msg)}
    /*
    其他工具函数
    */
}

这样所有的工具函数都托管在util这个对象变量里,极其简陋的弄了个伪命名空间。这样的局限性很大,因为我们可以随意修改。util不存在私有的属性。_prefix这个私有属性,后面可以随意修改。而我们很难定位到到底在哪边被修改了。

闭包立即执行

后来,一些程序员想到了方法解决私有属性的问题,有了下面这种写法:

var util = (function(window){

    var _prefix = '我想说:';
    return {
        log:function(msg){ console.log(_prefix +msg)}
    }

})(window)

主要使用了匿名函数立即执行的技巧,这样 _prefix 是一个匿名函数里面的局部变量,外面无法修改。但是log这个函数里面又因为闭包的关系可以访问到_prefix。只把公用的方法暴露出去。

这是后来模块划分的主要技巧,各大库比如jQuery,都会在最外层包裹这样一个匿名函数。

但是这只是在同一个文件里面的技巧,如果我们把util单独写到一个文件util.js。而我们程序的主代码是main.js那我们需要在页面里面一起用script标签引入:

<script src="main.js"></script>
<script src="util.js"></script>

这会有不少问题,最典型的比如如果我们的main.js如下:

util.log('我是模块主代码,我加载好了')

这个就执行不了,因为我们的util.js是在main.js后面引入的。所以执行main.js的内容的时候util还没定义呢。
不止这个问题,再比如如果引入了其他的js文件,并且也定义了util这个变量。就会混乱。

模块加载器

node作为javascript服务端的一种应用场景,加入了文件模块的概念,主要是实现的CommonJS规范

后来一些程序员就想,服务端可以有文件模块。浏览器端为什么就不可以呢。但是CommonJS规范是设计给服务端语言用的,不适合浏览器端的js。

于是出现了amd规范,并且在这个基础上出现了实现amd规范的库requirejs。

后来国内的大神玉伯由于多次给requirejs提建议(比如用时定义)一直不被采纳。于是另起炉灶制作了seajs。慢慢的也沉淀出了seajs的cmd规范

关于模块规范的具体历史,可以参考:seajs/seajs#588

两个规范差别并不是很大,可能由于写node习惯了,个人更喜欢cmd的编写方式。

首先我们看看基于cmd规范(其实就是seajs)后我们怎么写代码:

//util.js
define(function(require, exports, module){
    var _prefix = '我想说:';
    module.exports = {
        log:function(msg){ console.log(_prefix +msg)}
    }
})

///main.js
define(function(require, exports, module){
    var util = require('util')
    util.log('我是模块主代码,我加载好了')
})

///index.html
<html>
<head>
<script src="seajs.js"></script>
</head>
<body>
<script type='text/javascript'>
    seajs.use(["main"])
</script>
</body>
</html>

seajs的书写风格跟node很像。

  • 使用define来定义一个模块。
  • 模块代码里可以使用require去加载另一个模块,
  • 使用exports,module.exports来设置结果。
  • 通过seajs.use来加载一个主模块。类似c,java里面的main函数。

seajs会自动帮你加载好模块的文件,并且正确的处理依赖关系。于是前端终于也可以使用模块化的开发方式了。

一步一步实现模块加载器

下面我们来实现一个简单的cmd模块加载器程序,也可以当作是seajs的核心源码分析。

获取加载根路径

cmd模块规定一个模块一个文件,当我们require('util')的时候需要找到对应的文件,一般会加上根路径。默认情况下加载模块的根路径就是seajs.js所在目录。如何获取这个目录地址呢?我们只要在seajs.js里面写上:

var loadderDir = (function(){

    //使用正则获取一个文件所在的目录
    function dirname(path) {
        return path.match(/[^?#]*\//)[0]
    }
    //拿到引用seajs所在的script节点
    var scripts = document.scripts
    var ownScript = scripts[scripts.length - 1]

    //获取绝对地址的兼容写法
    var src = ownScript.hasAttribute ? ownScript.src :ownScript.getAttribute("src", 4)

    return dirname(src)

})()

这边有两个小技巧:

  • 浏览器是遇到一个script标记执行一个,当seajs.js正在执行的时候,document.scripts获取到的最后一个script就是当前正在执行的script。所以我们可以通过scripts[scripts.length - 1]拿到引用seajs.js的那个script节点引用。
  • 要获取一个 script节点的src绝对地址。除ie67外,ownScript.src返回的都是绝对地址,但是ie67src是什么就返回什么,这边就是'seajs.js'而不是绝对地址。幸好ie下支持getAttribute("src", 4)的方式获取绝对地址。参考这里。ie67下没有 hasAttribute属性,所以就有了获取绝对地址的兼容写法。

异步js文件加载器

模块加载是建立在文件加载器基础上的。在浏览器环境下我们可以通过动态生成script标记的方式,加载js。我们写一个简单js文件加载器:

var head = document.getElementsByTagName("head")[0]
var baseElement = head.getElementsByTagName("base")[0]
;function request(url,callback){

    var node = document.createElement("script")

    var supportOnload = "onload" in node

    if (supportOnload) {
        node.onload = function() {
            callback()
        }
    }else {
        node.onreadystatechange = function() {
          if (/loaded|complete/.test(node.readyState)) {
            callback()
          }
        }
    }

    node.async = true
    node.src = url
    //ie6下如果有base的script节点会报错,
    //所以有baseElement的时候不能用`head.appendChild(node)`,而是应该插入到base之前
    baseElement ? head.insertBefore(node, baseElement) : head.appendChild(node)

}

主要就是动态生成一个script节点加载js,监听事件触发回调函数,没什么难度,算是一个工具函数,给下面的模块使用。

模块类定义

终于到了重头戏。我们需要引入一个模块类的概念。util,main这些都是一个模块。模块有自己的依赖,有自己的状态。

我们先定义一个模块类:

function Module(uri,deps){
  this.uri = uri
  this.dependencies = deps || []
  this.factory = null
  this.status = 0

  // 哪些模块依赖我
  this._waitings = {}

  // 我依赖的模块还有多少没加载好
  this._remain = 0
}

1.uri代表当前模块的地址,一般是使用baseUrl(就是上面的loadderDir)+ id + '.js'

2.dependencies是当前模块依赖的模块。

3.factory就是我们定义模块时define的参数function(require, exports, module){}

4.status代表当前模块的状态,我们先定义下面这些状态:

var STATUS = Module.STATUS = {
  // 1 - 对应的js文件正在加载
  FETCHING: 1,
  // 2 - js加载完毕,并且已经分析了js文件得到了一些相关信息,存储了起来
  SAVED: 2,
  // 3 - 依赖的模块正在加载
  LOADING: 3,
  // 4 - 依赖的模块也都加载好了,处于可执行状态
  LOADED: 4,
  // 5 - 正在执行这个模块
  EXECUTING: 5,
  // 6 - 这个模块执行完成
  EXECUTED: 6
}

5._waitings存放着依赖我的模块实例集合,_remain则代表我还有多少依赖模块是处于不可用,也就是上面的小于LOADED的状态。
这个的作用是什么呢?

是这样的,比如A模块依赖B,C模块。那么A模块装载的时候会先去通知B,C模块把自己(A)加入到他们的_waitings里面。当B模块装载好了,就可以通过遍历B自己的_waitings去更新依赖它的模块比如A的_remain值。B发现更新后A的_remain后不为0,就什么也不做。直到C也好了,C更新下A的_remain值发现为0了,就会调用A的完成回调了。

如果B,C有自己的依赖模块也是一样的原理。

而如果一个模块没有依赖的模块,就会立即进入完成状态,然后通知依赖它的模块更新_remain值。他们处于最底端,往上一级级的去更新状态。

模块相互之间的通知机制就是这样,那么状态是如何变化的呢。
我们给模块增加一些原型方法:

//用于加载当前模块所在文件
//加载前状态是STATUS.FETCHING,加载完成后状态是SAVED,加载完后调用当前模块的load方法
Module.prototype.fetch = function(){}

//用于装载当前模块,装载之前状态变为STATUS.LOADING,主要初始化依赖的模块的加载情况。
//看一下依赖的模块有多少没有达到SAVED的状态,赋值给自己的_remain。另外对还没有加载的模块设置对应的_waitings,增加对自己的引用。
//挨个检查自己依赖的模块。发现依赖的模块都加载完成,或者没有依赖的模块就直接调用自己的onload
//如果发现依赖模块还有没加载的就调用它的fetch让它去加载。如果已经是加载完了,也就是SAVED状态的。就调用它的load
Module.prototype.load = function() {}

//当模块装载完,也就是load之后会调用此函数。会将状态变为LOADED,并且遍历自己的_waitings,找到依赖自己的那些模块,更新相应的_remain值,发现为0的话就调用对应的onload。
//onload调用有两种情况,第一种就是一个模块没有任何依赖直接load后调用自己的onload.
//还有一种就是当前模块依赖的模块都已经加载完成,在那些加载完成的模块的onload里面会帮忙检测_remain。通知当前模块是否该调用onload
//这样就会使用上面说的那套通知机制,当一个没有依赖的模块加载好了,会检测依赖它的模块。发现_remain为0,就会帮忙调用那个模块的onload函数
Module.prototype.onload = function() {}

/*===========================================*/
/*****下面的几个跟上面的通知机制就没啥关系了*****/
/*===========================================*/

//exec用于执行当前模块的factory
//执行前为STATUS.FETCHING 执行后为STATUS.EXECUTED
Module.prototype.exec = function(){}

//这是一个辅助方法,用来获取格式化当前依赖的模块的地址。
//比如上面就会把  ['util'] 格式化为 [baseUrl(就是上面的loadderDir)+ util + '.js']
Module.prototype.resolve = function(){}

//实例生成方法,所有的模块都是单例的,get用来获得一个单例。
Module.get = function(){}

是不是感觉有点晕,没事我们一个个来看。

辅助函数

我们先把辅助函数实现下:

//存储实例化的模块对象
cachedMods = {}
//根据uri获取一个对象,没有的话就生成一个新的
Module.get = function(uri, deps) {
  return cachedMods[uri] || (cachedMods[uri] = new Module(uri, deps))
}

//进行id到url的转换,实际情况会比这个复杂的多,可以支持各种配置,各种映射。
function id2Url(id){
    return loadderDir + id + '.js'
}
//解析依赖的模块的实际地址的集合
Module.prototype.resolve = function(){
  var mod = this
  var ids = mod.dependencies
  var uris = []

  for (var i = 0, len = ids.length; i < len; i++) {
    uris[i] = id2Url(ids[i])
  }
  return uris

}

fetch与define的实现

实现fetch之前我们先实现全局函数define。

fetch会生成script节点加载模块的具体代码。
还记得我们上面模块定义的写法吗?都是使用define来定义一个模块。define的主要任务就是生成当前模块的一些信息,给fetch使用。

define的实现:

var REQUIRE_RE = /"(?:\\"|[^"])*"|'(?:\\'|[^'])*'|\/\*[\S\s]*?\*\/|\/(?:\\\/|[^\/\r\n])+\/(?=[^\/])|\/\/.*|\.\s*require|(?:^|[^$])\brequire\s*\(\s*(["'])(.+?)\1\s*\)/g
var SLASH_RE = /\\\\/g

//工具函数,解析依赖的模块
function parseDependencies(code) {
  var ret = []

  code.replace(SLASH_RE, "")
      .replace(REQUIRE_RE, function(m, m1, m2) {
        if (m2) {
          ret.push(m2)
        }
      })

  return ret
}

function define (factory) {
  //使用正则分析获取到对应的依赖模块
  deps = parseDependencies(factory.toString())
  var meta = {
    deps: deps,
    factory: factory
  }
  //存到一个全局变量,等后面fetch在script的onload回调里获取。
  anonymousMeta = meta
}

这边为了尽量展现原理,去掉了很多兼容的代码。
比如其实define是支持function (id, deps, factory)这种写法的,这样就可以提前写好模块的id和deps,这样就不需要通过正则去获取依赖的模块了。一般写的时候只写factory,上线时会使用构建工具生成好deps参数,这样可以避免压缩工具把require关键字压缩掉而导致依赖失效。性能上也会更好。

另外,为了兼容ie下面的script标签不一定触发的问题。这边其实有个getCurrentScript()的方法,用于获取当前正在解析的script节点的地址。这边略去,有兴趣的可以去源码里看看。

function getCurrentScript() {

//主要原理就是在ie6-9下面可以查看script.readyState === "interactive"来判断当前节点是否处于加载状态
  var scripts = head.getElementsByTagName("script")
  for (var i = scripts.length - 1; i >= 0; i--) {
    var script = scripts[i]
    if (script.readyState === "interactive") {
      return script
    }
  }

下面是fetch的实现:

Module.prototype.fetch = function() {
  var mod = this
  var uri = mod.uri

  mod.status = STATUS.FETCHING
  //调用工具函数,异步加载js
  request(uri, onRequest)

  //保存模块信息
  function saveModule(uri, anonymousMeta){
      //使用辅助函数获取模块,没有就实例化个新的
      var mod = Module.get(uri)
      //保存meta信息
      if (mod.status < STATUS.SAVED) {
        mod.id = anonymousMeta.id || uri
        mod.dependencies = anonymousMeta.deps || []
        mod.factory = anonymousMeta.factory
        mod.status = STATUS.SAVED
      }
  }

  function onRequest() {
    //拿到之前define保存的meta信息
    if (anonymousMeta) {
      saveModule(uri, anonymousMeta)
      anonymousMeta = null
    }
    //调用加载函数
    mod.load()
  }
}

load与onload的实现

fetch完成后会调用load方法。

我们看下load的实现:

Module.prototype.load = function() {
  var mod = this
  // If the module is being loaded, just wait it onload call
  if (mod.status >= STATUS.LOADING) {
    return
  }
  mod.status = STATUS.LOADING

  //拿到解析后的依赖模块的列表
  var uris = mod.resolve()

  //复制_remain
  var len = mod._remain = uris.length
  var m

  for (var i = 0; i < len; i++) {
    //拿到依赖的模块对应的实例
    m = Module.get(uris[i])

    if (m.status < STATUS.LOADED) {
      // Maybe duplicate: When module has dupliate dependency, it should be it's count, not 1
      //把我注入到依赖的模块里的_waitings,这边可能依赖多次,也就是在define里面多次调用require加载了同一个模块。所以要递增
      m._waitings[mod.uri] = (m._waitings[mod.uri] || 0) + 1
    }
    else {
      mod._remain--
    }
  }
  //如果一开始就发现自己没有依赖模块,或者依赖的模块早就加载好了,就直接调用自己的onload
  if (mod._remain === 0) {
    mod.onload()
    return
  }
  //检查依赖的模块,如果有还没加载的就调用他们的fetch让他们开始加载
  for (i = 0; i < len; i++) {
    m = cachedMods[uris[i]]

    if (m.status < STATUS.FETCHING) {
      m.fetch()
    }
    else if (m.status === STATUS.SAVED) {
      m.load()
    }
  }
}

Module.prototype.onload = function() {
  var mod = this
  mod.status = STATUS.LOADED
  //回调,预留接口给之后主函数use使用,这边先不管
  if (mod.callback) {
    mod.callback()
  }

  var waitings = mod._waitings
  var uri, m
  //遍历依赖自己的那些模块实例,挨个的检查_remain,如果更新后为0,就帮忙调用对应的onload
  for (uri in waitings) {
    if (waitings.hasOwnProperty(uri)) {
      m = cachedMods[uri]
      m._remain -= waitings[uri]
      if (m._remain === 0) {
        m.onload()
      }
    }
  }

}

这样整个通知机制就结束了。

exec的实现

模块onload之后代表已经处于一种可执行状态。seajs不会立即执行模块代码,只有你真正require了才会去调用模块的exec去执行。这就是用时定义。

Module.prototype.exec = function () {
  var mod = this

  if (mod.status >= STATUS.EXECUTING) {
    return mod.exports
  }

  mod.status = STATUS.EXECUTING

  var uri = mod.uri

  //这是会传递给factory的参数,factory执行的时候,所有的模块已经都加在好处于可用的状态了,但是还没有执行对应的factory。这就是cmd里面说的用时定义,只有第一次require的时候才会去获取并执行
  function require(id) {
    return Module.get(id2Url(id)).exec()
  }

  function isFunction (obj) {
    return ({}).toString.call(obj) == "[object Function]"
  }

  // Exec factory
  var factory = mod.factory
  //如果factory是函数,直接执行获取到返回值。否则赋值,主要是为了兼容define({数据})这种写法,可以用来发jsonp请求等等。
  var exports = isFunction(factory) ?
      factory(require, mod.exports = {}, mod) :
      factory
  //没有返回值,就使用mod.exports的值。看到这边你受否明白了,为什么我们要返回一个函数的时候,直接exports = function(){}不行了呢?因为这边取的是mod.exports。exports只是传递过去的指向{}的一个引用。你改变了这个引用地址,却没有改变mod.exports。所以当然是不行的。
  if (exports === undefined) {
    exports = mod.exports
  }

  mod.exports = exports
  mod.status = STATUS.EXECUTED

  return exports

}

入口函数seajs.use

上面这套东西已经完成了整个模块之间的加载执行依赖关系了。但是还缺少一个入口。

这时候就是seajs.use出场的时候了。seajs.use用来加载一些模块。比如下面:

seajs.use(["main"])

其实我们可以把它当作一个主模块,use的后面那些比如main就是它的依赖模块。而且这个主模块比较特殊,他不需要经过加载的过程,直接可以从load装载开始,于是use的实现就很简单了:

seajs = {}
seajs.use = function (ids, callback) {
  //生成一个带依赖的模块
  var mod = Module.get('_use_special_id', ids)
  //还记得上面我们在onload里面预留的接口嘛。这边派上用场了。
  mod.callback = function() {
    var exports = []
    //拿到依赖的模块地址数组
    var uris = mod.resolve()

    for (var i = 0, len = uris.length; i < len; i++) {
      //执行依赖的那些模块
      exports[i] = cachedMods[uris[i]].exec()
    }
    //注入到回调函数中
    if (callback) {
      callback.apply(global, exports)
    }
  }
  //直接使用load去装载。
  mod.load()
}

于是整个流程就变成了这样:

主入口函数use直接生成一个模块,直接load。然后建立好依赖关系。通过上面那套通知机制,从下到上一个个的触发模块的onload。然后主函数里面调用依赖模块的exec去执行,然后一层层的下去,每一层都可以通过require来执行对应的factory。整个过程就是这样。

结语

又是一个因为js本身的缺陷,然后后人擦屁股的事情。这样的例子已经数不胜数了。js真是让人又爱又恨。总之有了模块加载器,让js有了做大规模富客户端应用的能力。是前端工业化开发不可缺少的一环。

请问源码在哪找到,你的repo里没有这个seajs的源码?