blog
blog copied to clipboard
javascript模块加载器实践
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规范。
关于模块规范的具体历史,可以参考:https://github.com/seajs/seajs/issues/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的源码?