转载

模块加载工具制造指南

看到这个标题,估计有同学会想,又要重复造轮子么?其实重复造轮子在大多数情况下确实是不太可取的,既浪费了精力又浪费了时间。但这并不能说明重复造轮子完全不可取,比如你想要某个轮子的精简版,又比如你想学习某个轮子的制造方法,重复造轮子也可以是有意义的。

接下来,我们就来学学某个轮子简易版制造方法,这个轮子就是模块加载工具。

说起模块加载工具,估计大家就会想起webpack、commonjs等,更“久远”一点的会想起requirejs和seajs。这些工具都源于前端的模块化思想。

为什么前端需要模块化?这主要得益于前端技术的发展,使得前端不再像以前那样只能展示一下静态内容,撑死加上几个飞来飞去的动画。现在的前端内容越来越丰富,我们可以播放视频,可以协同工作,还可以玩游戏。这就导致了前端代码量剧增。当代码行数噌噌噌往上涨时,模块化思想就自然而然地出来了。

对于前端来说,最简单的模块化就是拆分成多个文件,然后在html里就会出现如下的代码:

<scriptsrc="/js/module_a/a1.js"></script>
<scriptsrc="/js/module_a/a2.js"></script>
<scriptsrc="/js/module_b/b1.js"></script>
<scriptsrc="/js/module_c/c1.js"></script>
<scriptsrc="/js/module_c/c2.js"></script>
<scriptsrc="/js/module_c/c3.js"></script>
<scriptsrc="/js/module_c/c4.js"></script>

各位有没有觉得这种代码有点儿难看?像这样的代码不止难看,依赖也不清晰,假如上面的module_b只是因为module_a的需要才引入的,那么当我们去掉module_a时还得搜一下相关文档或者源码,当我们检索出确确实实只有module_a才依赖了module_b,我们才敢放心的把module_b给去掉。

因此,就衍生了像requirejs之类模块加载工具,同时还能处理依赖关系。其实像requirejs和webpack之类的构建工具处理模块化时很相似,只是处理模块依赖的时机不同,requirejs是直接在浏览器里处理,而webpack则是在上线前就将模块进行打包。而在代码上两者最大的差异就是,requirejs需要每个模块包裹一层依赖代码(其实这层代码也可以借由构建工具生成),而webpack则会在打包后的代码里注入一下模块化的脚本。事实上这两者也不是水火不容,这主要看项目的技术选型。

说了那么多,接下来就来进入正题,我们这次就是来造一个简易版的类似requirejs的模块加载工具,注意是简易版,所以这个轮子最好不要直接投入到生产环境中,造这个轮子更多的目的是为了一起学习XD。

使用方式我们就做得简单一点,只暴露一个方法出来:define方法。

当我们需要定义一个模块时,可以像如下方式编写代码:

define(['/js/a.js', '/js/b.js'], function(a, b) {
    return {
        doSth: function() {
            a.a(b)
        }
    };
});

每个模块都用define来定义,声明依赖的模块和回调方法。回调中可以返回一个对象,也可以不返回值。如果返回对象则会被注入到依赖这个模块的模块回调方法中,如果不返回值则注入空对象。同时依赖的模块可以是纯文本文件或json文件,如果纯文本,注入进来的会是该文件的字符串内容,如果是json文件则注入json对象:

define(['/json/a.json', '/html/a.html'], function(data, html) {
    return {
        doSth: function() {
            console.log(JSON.stringify(data)); // 输出a.json的内容
            console.log(html); // 输出a.html的内容
        }
    }
});

设计与思考

我们这里有如下几个问题需要思考一下:

  • 如何注入依赖?
  • 如何获取依赖模块的绝对路径?
  • 如何加载依赖的模块?
  • 如何处理循环依赖?

针对这几个问题我们来对这个模块加载工具进行设计。

如何注入依赖?

需要注入依赖到当前模块,就得保证依赖是先于当前模块加载并执行完,这样我们就需要维护一个模块队列,保证模块加载的顺序和保存模块的状态。

当遇到define方法进行模块定义时,先获取依赖,将依赖的加载顺序置于当前模块之前,这个我们通过维护一个模块的状态列表就可以达成。状态设计成以下三种:

  • LOADING:模块正在加载中。
  • WAITING:模块已经加载完毕,正在等待依赖模块加载。
  • DEFINED:模块和其依赖均已经加载完毕,并且执行过回调,完成模块定义。

每次定义模块,我们就检查该模块所依赖的模块状态,如果依赖都已定义,则进入执行回调阶段;如果依赖未完全就位,则设置为等待中,将未加载的模块放入加载列表进行加载。具体流程如下:

模块定义 --> 检查依赖 --> 依赖都已就位 --> 注入依赖,执行回调 --> 完成定义
                |
                |--> 依赖未完全就位 --> 未加载模块放入加载列表 --> 加载模块 --> 依赖模块均已完成定义 --> 注入依赖,执行回调 --> 完成定义

值得一提的是,模块的依赖也会有自己的依赖,所以当依赖一旦复杂起来,上面的流程就是循环执行的。

注意:当前模块的定义是在加载完毕之后才会进行的,因为模块未加载完毕是无法执行其中的js代码的。

如何获取依赖模块的绝对路径?

这里我们用到了一个小技巧,就是直接使用浏览器的a标签来实现。具体代码实现如下:

var a = document.createElement('a');
a.style.display = 'none';
document.body.appendChild(a);
 
// 获取绝对路径
var getAbsoluteURI = function(url) {
    a.href = url;
    return a.href; 
};

这样,我们不再需要小心翼翼地去拼url,全部交给浏览器去做,保证又快又好。

注意,在低版本ie里要使用a.getAttribute(‘href’, 4)的方式获取href。

如何加载依赖的模块?

这里的模块分两种,一种是js模块,一种是文本内容(比如html文件或json文件)。

对于js模块,我们直接使用script标签来实现,这一点和我们用过的jsonp跨域的方式很像。即是动态创建一个script标签,将script的src设置为我们要加载的模块,然后监听script的onload事件或onerror事件,在模块加载完后删除script标签,然后做其他的一些模块相关操作。代码大概如下:

var script = document.createElement('script');
 
script.type = 'text/javascript';
script.charset = 'utf-8';
 
script.onload = script.onerror = function(e) {
    // 监听脚本加载运行  
    var script = e.target || e.srcElement;
 
    // 清理脚本节点
  if(script && script.parentNode) {
    // 清除事件
    script.onload = script.onerror = null;
    // 清除script标签
    script.parentNode.removeChild(script);
  }
};
 
script.src = url;
(document.getElementsByTagName('head')[0] || document.body).appendChild(script);

注意:同样是低版本ie,不支持script的onload事件,这时候我们要监听script的onreadystatechange事件,通过判断script的readystate状态来断定是否加载完成。

对于文本内容的加载,这就更简单了。我们直接通过ajax请求就可以获取,对于json文件就再做一层解析就可以了。

如何处理循环依赖?

所谓循环依赖,就是出现如下那样你依赖我、我依赖你的情况:

// a.js
define(['./b.js'], function(b) {
    return {}; 
});
 
// b.js
define(['./a.js'], function(a) {
    return {}; 
});

不过这里的依赖有两种,分为弱依赖和强依赖。弱依赖是可以解决的,因为两个模块之间不是直接依赖,比如下面代码:

// a.js
define(['./b.js'], function(b) {
    return {
      a1: function() {console.log(b.b2)},
      a2: function() {return 'I am a'}
    }; 
});
 
// b.js
define(['./a.js'], function(a) {
    return {
      b1: function() {console.log(a.a2)},
      b2: function() {return 'I am b'}
    }; 
});

模块间的依赖不是一个闭环,调用模块的任意一个方法都会有一个终结,这就是弱依赖,在代码里我们通过强行注入一个空对象给其中一个模块,并执行其中其回调来解决这种y。比如上面代码中,我们可以强行执行a模块,并且赋值注入的b变量为一个空对象,因为在执行回调的时候b变量没有被直接使用,而是在a模块的某个方法里被使用。这时候我们可以不管b模块是否已定义。等到a模块被强行定义好之后,再去按照正常的方式去定义b模块。最重要的一步,b模块定义完成之后我们要把b模块里返回的对象拷贝到先前注入到a模块的空对象中,从完成了弱依赖的解决。

为什么可以这么做呢?因为js这里是传引用调用的。我们在定义a模块的时候,先把引用传进去,反正a模块没有直接使用到这个依赖,所以它也不关心我们传进去的对象有没有东西。等到我们的b模块完成后,再在这个引用指向的对象里填充数据。

也只有这种特殊的依赖情况我们可以解决,其他的循环依赖均被称为强依赖,会直接形成死锁,无法被打破。

首先,先把我们需要用到的用来维护模块的变量定义起来:

var MODULES = []; // 存放涉及到的所有模块的信息,包含每个模块的url、依赖和回调
var STATUS = {}; // 模块的状态
var RESULTS = {}; // 模块的回调返回的结果
var STACK = []; // 当前待加载的模块栈
 
var LOADING = 1; // 加载中
var WAITING = 2; // 等待中
var DEFINED = 3; // 已定义

接着,把我们需要暴露出去的接口进行实现:

/**
* 暴露出去的define接口
*/
window.define = function(deps, callback) {
  var args = [].slice.call(arguments, 0);
 
  STACK.push(args);
 
  // 对于页面中仍未被检测过的脚本进行处理
  var list = document.getElementsByTagName('script');
  for(var i=list.length-1; i>=0; i--) {
    var script = list[i];
 
    if(!script.nowhasload) {
      script.nowhasload = true;
 
      if(!script.src && script.innerHTML.search(//s*define/s*/(/) >= 0) {
        // 内嵌模块定义语句脚本
        args = STACK.pop();
        while(args) {
          runLoading.apply(window, args);
          args = STACK.pop();
        }
 
      } else {
        // 外嵌模块定义语句脚本
        addScriptListener(list[i]); 
      }
    }
  }
};

这里对当前页面中的script标签做了检查,因为使用define方法的地方可能是内嵌脚本,也可能是外部脚本。针对内嵌脚本做特殊处理的原因主要是内嵌脚本是不能作为一个模块被依赖的,它只能是整个依赖链的入口。而外嵌脚本是可以在弱依赖这个环里的。

上面的代码里用到了两个未实现的方法:runLoading和addScriptListener。其中runLoading用来检查模块的依赖并对依赖进行加载。addScriptListener则对已经加载完的脚本添加监听器,目的是为了在脚本加载完后对脚本进行标记,同时继续检查缓存中待加载的模块和等待中的模块。

addScriptListener方法实现如下:

/*
* 侦测脚本载入情况
*/
var addScriptListener = (function() {
  // 脚本载入完成回调
  var onScriptLoad = function(script) {
    var url = formatURL(script.src);
    if(!url) return;
 
    // 检查栈中缓存
    var arr = STACK.pop();
    if(arr) {
      arr.unshift(url);
      runLoading.apply(window, arr);
    }
 
    // 当前模块不处于等待中的话,则标记为已定义
    if(STATUS[url] !== WAITING)  STATUS[url] = DEFINED;
 
    // 清理脚本节点
    if(script && script.parentNode) {
      // 清除事件
      script.onload = script.onerror = null;
      // 清除script标签
      script.parentNode.removeChild(script);
    }
 
    // 加载完后检查等待中的模块
    runWaiting();
  };
 
  return function(script) {
    // 加载成功 或 失败
    script.onload = script.onerror = function(e) {
      onScriptLoad(e.target || e.srcElement || this);
    };
  };
})();

上面的代码中的runWaiting方法就是用来检查等待中模块。

实现runLoading方法:

/**
* 处理模块进入等待队列
*/
var runLoading = function(url, deps, callback) {
  // 如果自身是内嵌脚本的话,则使用时间戳作为url
  if(typeof url !== 'string') {
    callback = deps;
    deps = url;
 
    url = './' + (seed++) + '.js'
  }
 
  url = formatURL(url);
 
  if(STATUS[url] === DEFINED) return; // 已定义
 
  // 加载依赖模块
  for(var i=0,l=deps.length; i<l; i++) {
    deps[i] = formatURL((deps[i] || ''), url); // 格式化依赖列表中的url
    loadResource(deps[i]); // 加载资源
  }
 
  STATUS[url] = WAITING; // 存在依赖,当前模块标记为等待中
 
  // 放进模块队列中
  MODULES.push({
    url: url,
    deps: deps,
    callback: callback
  });
 
  // 检查等待中的模块
  runWaiting();
};

runLoading里的逻辑很简单,就是对依赖进行加载,然后将将自身置为等待中的模块。而runWaiting的代码如下:

/*
* 对等待中的模块进行定义
*/
var runWaiting = (function() {
  // 检查所有文件是否都载入
  var isFinishLoaded = function() {
    for(var urlin STATUS) {
      if(STATUS[url] === LOADING) return false;
    }
 
    return true;
  };
 
  // 检查依赖列表是否都载入完成
  var isListLoaded = function(deps) {
    for(var i=deps.length-1; i>=0; i--) {
      if(STATUS[deps[i]] !== DEFINED) return false;
    }
 
    return true;
  };
 
  return function() {
    if(!MODULES.length) return;
 
    for(var i=MODULES.length-1; i >= 0; ) {
      var item = MODULES[i];
 
      if(STATUS[item.url] !== DEFINED) {
        if(!isListLoaded(item.deps)) {
          // 存在未定义的文件,且依赖列表中也存在未定义的文件,则跳过
          i--;
          continue;
 
        } else {
          // 依赖列表中的文件都已定义,则进行定义自己
          runDefining(item);
        }
      }
 
      // 删除已经定义的文件,然后重新遍历
      MODULES.splice(i, 1);
      i = MODULES.length - 1;
    }
 
    if(MODULES.length>0 && isFinishLoaded()) {
      // 存在循环引用,可以尝试强行定义,不过只能解决弱依赖引用,无法解决强依赖引用
      var item = MODULES.pop();    
      runDefining(item);
      runWaiting();
    }
  };
})();

这里遍历一遍等待中的模块,针对依赖都已经就位(加载完并且定义完)的情况下,就开始执行自身的定义。对于循环依赖的问题,就用上面提到的方法,强行打破。其中runDefining就是执行定义的方法,其代码如下:

/**
* 执行模块定义
*/
var runDefining = function(item) {
  var args = [];
 
  // 遍历依赖列表
  for(var i=0,len=item.deps.length; i<len; i++) {
    var it = item.deps[i];
 
    RESULTS[it] = RESULTS[it] || {};
    args.push(RESULTS[it]);
  }
 
  if(item.callback) {
    // 注入依赖并执行
    var result = item.callback.apply(window, args) || {};
 
    // 合并依赖注入结果  
    var ret = RESULTS[item.url] || {};
    if(typeof result === 'object') {
    for(var keyin result) 
      ret[key] = result[key];
    } else {
      ret = result;
    }
 
    // 将定义好的文件放入缓存
    RESULTS[item.url] = ret;
  }
 
  STATUS[item.url] = DEFINED;
};

执行定义的过程就是把依赖定义完的结果注入到模块的回调中,然后执行模块的回调,把返回的结果缓存起来,以供依赖当前模块的模块使用。

整个流程很简单,当加载完一个模块并发现这个模块存在依赖的情况下,就先让当前模块处于等待状态,优先加载依赖。等所有依赖都定义完了,再去执行这个模块的定义。对于依赖的处理也同样。

到这里,只剩下最后一部分了——就是加载相关的逻辑:

/*
* 解析文件类型,并进行加载
*/
var loadResource = (function() {
 
  // 载入依赖文本
  var loadText = function(url, callback) {
    if(!url) return;
    // 未加载过
    if(STATUS[url] != null) return;
    // 加载文本
    STATUS[url] = LOADING; // 标记为加载中
    var xhr = new window.XMLHttpRequest();
 
    xhr.onreadystatechange = function() {
      if(xhr.readyState == 4) {
        var text = xhr.responseText || '';
 
        STATUS[url] = DEFINED; // 标记为已定义
        RESULTS[url] = text; // 储存结果
 
        if(callback) callback(text); // 针对json的处理
 
        // 加载完后检查等待中的模块
        runWaiting();
      }
    };
 
    xhr.open('GET', url, true);
    xhr.send(null);
  };
 
  // 载入依赖JSON
  var loadJSON = function(url) {
    loadText(url, function(text) {
      // 解析JSON
      RESULTS[url] = JSON.parse(text);
    });
  };
 
  // 载入依赖脚本
  var loadScript = function(url) {      
    if(STATUS[url]) return; // 已加载则返回
 
    STATUS[url] = LOADING; // 标记当前模块为加载中
 
    // 使用script标签添加到文档中,加载运行完再删除
    var script = document.createElement('script');
 
    script.nowhasload = true;
    script.type = 'text/javascript';
    script.charset = 'utf-8';
 
    addScriptListener(script); // 监听脚本加载运行
 
    script.src = url;
    (document.getElementsByTagName('head')[0] || document.body).appendChild(script);
  };
 
  return function(url) {
    var arr = url.split('.');
    var type = arr.pop();
 
    if(type === 'js') loadScript(url);
    else if(type === 'json') loadJSON(url);
    else loadText(url);
  };
})();

这段代码没什么好说的,就跟上面提到的一样,针对js使用script标签,针对其他文本则走ajax请求。

其实把上面贴出来的代码拼起来,就是一个完整、可用的简易版模块加载工具了。就如同开始所说的,这是拿来学习用的轮子,如果想拿来直接用其实也没什么问题,不过有些兼容性的问题或者功能的扩充就得自己完善(比如低版本ie,比如支持配置根路径等)。

想看完整的代码的话,请戳 这里 。

原文  http://www.alloyteam.com/2017/01/module-loading-tools-guide/
正文到此结束
Loading...