Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

javascript模块加载器实践 #15

Open
purplebamboo opened this issue Jul 11, 2017 · 1 comment
Open

javascript模块加载器实践 #15

purplebamboo opened this issue Jul 11, 2017 · 1 comment

Comments

@purplebamboo
Copy link
Owner

purplebamboo commented Jul 11, 2017

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有了做大规模富客户端应用的能力。是前端工业化开发不可缺少的一环。

@liujiong1982
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants