概述如何实现一个简单的浏览器端js模块加载器


Posted in Javascript onDecember 07, 2016

在es6之前,js不像其他语言自带成熟的模块化功能,页面只能靠插入一个个script标签来引入自己的或第三方的脚本,并且容易带来命名冲突的问题。js社区做了很多努力,在当时的运行环境中,实现"模块"的效果。

通用的js模块化标准有CommonJS与AMD,前者运用于node环境,后者在浏览器环境中由Require.js等实现。此外还有国内的开源项目Sea.js,遵循CMD规范。(目前随着es6的普及已经停止维护,不论是AMD还是CMD,都将是一段历史了)

浏览器端js加载器

实现一个简单的js加载器并不复杂,主要可以分为解析路径、下载模块、解析模块依赖、解析模块四个步骤。

首先定义一下模块。在各种规范中,通常一个js文件即表示一个模块。那么,我们可以在模块文件中,构造一个闭包,并传出一个对象,作为模块的导出:

define(factory() {
 var x = {
  a: 1
 };
 return x;
});

define函数接收一个工厂函数参数,浏览器执行该脚本时,define函数执行factory,并把它的return值存储在加载器的模块对象modules里。

如何标识一个模块呢?可以用文件的uri,它是唯一标识,是天然的id。

文件路径path有几种形式:

  • 绝对路径:http://xxx, file://xxx
  • 相对路径:./xxx , ../xxx , xxx(相对当前页面的文件路径)
  • 虚拟绝对路径:/xxx /表示网站根目录

因此,需要一个resolvePath函数来将不同形式的path解析成uri,参照当前页面的文件路径来解析。

接着,假设我们需要引用a.js与b.js两个模块,并设置了需要a与b才能执行的回调函数f。我们希望加载器去拉取a与b,当a与b都加载完成后,从modules里取出a与b作为参数传给f,执行下一步操作。这里可以用观察者模式(即订阅/发布模式)实现,创建一个eventProxy,订阅加载a与加载b事件;define函数执行到最后,已经把导出挂载modules里之后,emit一个本模块加载完成的事件,eventProxy收到后检查a与b是否都加载完成,如果完成,就传参给f执行回调。

同理,eventProxy也可以实现模块依赖加载

// a.js
define([ 'c.js', 'd.js' ], factory (c, d) {
 var x = c + d;
 return x;
});

define函数的第一个参数可以传入一个依赖数组,表示a模块依赖c与d。define执行时,告诉eventProxy订阅c与d加载事件,加载好了就执行回调函数f存储a的导出,并emit事件a已加载。

浏览器端加载脚本的原始方法是插入一个script标签,指定src之后,浏览器开始下载该脚本。

那么加载器中的模块加载可以用dom操作实现,插入一个script标签并指定src,此时该模块为下载中状态。

PS:浏览器中,动态插入script标签与初次加载页面dom时的script加载方式不同:

初次加载页面,浏览器会从上到下顺序解析dom,碰到script标签时,下载脚本并阻塞dom解析,等到该脚本下载、执行完毕后再继续解析之后的dom(现代浏览器做了preload优化,会预先下载好多个脚本,但执行顺序与它们在dom中顺序一致,执行时阻塞其他dom解析)

动态插入script,

var a = document.createElement('script'); a.src='xxx'; document.body.appendChild(a);

浏览器会在该脚本下载完成后执行,过程是异步的。

下载完成后执行上述的操作,解析依赖->加载依赖->解析本模块->加载完成->执行回调。

模块下载完成后,如何在解析它时知道它的uri呢?有两种发发,一种是用srcipt.onload获取this对象的src属性;一种是在define函数中采用document.currentScript.src。

实现基本的功能比较简单,代码不到200行:

var zmm = {
 _modules: {},
 _configs: {
  // 用于拼接相对路径
  basePath: (function (path) {
   if (path.charAt(path.length - 1) === '/') {
    path = path.substr(0, path.length - 1);
   }
   return path.substr(path.indexOf(location.host) + location.host.length + 1);
  })(location.href),
  // 用于拼接相对根路径
  host: location.protocol + '//' + location.host + '/'
 }
};
zmm.hasModule = function (_uri) {
 // 判断是否已有该模块,不论加载中或已加载好
 return this._modules.hasOwnProperty(_uri);
};
zmm.isModuleLoaded = function (_uri) {
 // 判断该模块是否已加载好
 return !!this._modules[_uri];
};
zmm.pushModule = function (_uri) {
 // 新模块占坑,但此时还未加载完成,表示加载中;防止重复加载
 if (!this._modules.hasOwnProperty(_uri)) {
  this._modules[_uri] = null;
 }
};
zmm.installModule = function (_uri, mod) {
 this._modules[_uri] = mod;
};
zmm.load = function (uris) {
 var i, nsc;
 for (i = 0; i < uris.length; i++) {
  if (!this.hasModule(uris[i])) {
   this.pushModule(uris[i]);
   // 开始加载
   var nsc = document.createElement('script');
    nsc.src = uri;
   document.body.appendChild(nsc);
  }
 }
};
zmm.resolvePath = function (path) {
 // 返回绝对路径
 var res = '', paths = [], resPaths;
 if (path.match(/.*:\/\/.*/)) {
  // 绝对路径
  res = path.match(/.*:\/\/.*?\//)[0]; // 协议+域名
  path = path.substr(res.length);
 } else if (path.charAt(0) === '/') {
  // 相对根路径 /开头
  res = this._configs.host;
  path = path.substr(1);
 } else {
  // 相对路径 ./或../开头或直接文件名
  res = this._configs.host;
  resPaths = this._configs.basePath.split('/');
 }
 resPaths = resPaths || [];
 paths = path.split('/');
 for (var i = 0; i < paths.length; i++) {
  if (paths[i] === '..') {
   resPaths.pop();
  } else if (paths[i] === '.') {
   // do nothing
  } else {
   resPaths.push(paths[i]);
  }
 }
 res += resPaths.join('/');
 return res;
};
var define = zmm.define = function (dependPaths, fac) {
 var _uri = document.currentScript.src;
 if (zmm.isModuleLoaded(_uri)) {
  return;
 }
 var factory, depPaths, uris = [];
 if (arguments.length === 1) {
  factory = arguments[0];
  // 挂载到模块组中
  zmm.installModule(_uri, factory());
  // 告诉proxy该模块已装载好
  zmm.proxy.emit(_uri);
 } else {
  // 有依赖的情况
  factory = arguments[1];
  // 装载完成的回调函数
  zmm.use(arguments[0], function () {
   zmm.installModule(_uri, factory.apply(null, arguments));
   zmm.proxy.emit(_uri);
  });
 }
};
zmm.use = function (paths, callback) {
 if (!Array.isArray(paths)) {
  paths = [paths];
 }
 var uris = [], i;
 for (i = 0; i < paths.length; i++) {
  uris.push(this.resolvePath(paths[i]));
 }
 // 先注册事件,再加载
 this.proxy.watch(uris, callback);
 this.load(uris);
};
zmm.proxy = function () {
 var proxy = {};
 var taskId = 0;
 var taskList = {};
 var execute = function (task) {
  var uris = task.uris,
   callback = task.callback;
  for (var i = 0, arr = []; i < uris.length; i++) {
   arr.push(zmm._modules[uris[i]]);
  }
  callback.apply(null, arr);
 };
 var deal_loaded = function (_uri) {
  var i, k, task, sum;
  // 当一个模块加载完成时,遍历当前任务栈
  for (k in taskList) {
   if (!taskList.hasOwnProperty(k)) {
    continue;
   }
   task = taskList[k];
   if (task.uris.indexOf(_uri) > -1) {
    // 查看这个任务中的模块是否都已加载好
    for (i = 0, sum = 0; i < task.uris.length; i++) {
     if (zmm.isModuleLoaded(task.uris[i])) {
      sum ++;
     }
    }
    if (sum === task.uris.length) {
     // 都加载完成 删除任务
     delete(taskList[k]);
     execute(task);
    }
   }
  }
 };
 proxy.watch = function (uris, callback) {
  // 先检查一遍是否都加载好了
  for (var i = 0, sum = 0; i < uris.length; i++) {
   if (zmm.isModuleLoaded(uris[i])) {
    sum ++;
   }
  }
  if (sum === uris.length) {
   execute({
    uris: uris,
    callback: callback
   });
  } else {
   // 订阅新加载任务
   var task = {
    uris: uris,
    callback: callback
   };
   taskList['' + taskId] = task;
   taskId ++;
  }
 };
 proxy.emit = function (_uri) {
  console.log(_uri + ' is loaded!');
  deal_loaded(_uri);
 };
 return proxy;
}();

循环依赖问题

"循环加载"指的是,a脚本的执行依赖b脚本,而b脚本的执行又依赖a脚本。这是一种应该尽量避免的设计。

浏览器端

用上面的zmm工具加载模块a:

// main.html
zmm.use('/a.js', function(){...});
// a.js
define('/b.js', function(b) {
 var a = 1;
 a = b + 1;
 return a;
});
// b.js
define('/a.js', function(a) {
 var b = a + 1;
 return b;
});

就会陷入a等待b加载完成、b等待a加载完成的死锁状态。sea.js碰到这种情况也是死锁,也许是默认这种行为不应该出现。

seajs里可以通过require.async来缓解循环依赖的问题,但必须改写a.js:

// a.js
define('./js/a', function (require, exports, module) {
 var a = 1;
 require.async('./b', function (b) {
  a = b + 1;
  module.exports = a; //a= 3
 });
 module.exports = a; // a= 1
});
// b.js
define('./js/b', function (require, exports, module) {
 var a = require('./a');
 var b = a + 1;
 module.exports = b;
});
// main.html
seajs.use('./js/a', function (a) {
 console.log(a); // 1
});

但这么做a就必须先知道b会依赖自己,且use中输出的是b还没加载时a的值,use并不知道a的值之后还会改变。

在浏览器端,似乎没有很好的解决方案。node模块加载碰到的循环依赖问题则小得多。

node/CommonJS

CommonJS模块的重要特性是加载时执行,即脚本代码在require的时候,就会全部执行。CommonJS的做法是,一旦出现某个模块被"循环加载",就只输出已经执行的部分,还未执行的部分不会输出。

// a.js
var a = 1;
module.exports = a;
var b = require('./b');
a = b + 1;
module.exports = a;
// b.js
var a = require('./a');
var b = a + 1;
module.exports = b;
// main.js
var a = require('./a');
console.log(a); //3

上面main.js的代码中,先加载模块a,执行require函数,此时内存中已经挂了一个模块a,它的exports为一个空对象a.exports={};接着执行a.js中的代码;执行var b = require('./b');之前,a.exports=1,接着执行require(b);b.js被执行时,拿到的是a.exports=1,b加载完成后,执行权回到a.js;最后a模块的输出为3。

CommonJS与浏览器端的加载器有着实现上的差异。node加载的模块都是在本地,执行的是同步的加载过程,即按依赖关系依次加载,执行到加载语句就去加载另一个模块,加载完了再回到函数调用点继续执行;浏览器端加载scripts由于天生限制,只能采取异步加载,执行回调来实现。

ES6

ES6模块的运行机制与CommonJS不一样,它遇到模块加载命令import时,不会去执行模块,而是只生成一个引用。等到真的需要用到时,再到模块里面去取值。因此,ES6模块是动态引用,不存在缓存值的问题,而且模块里面的变量,绑定其所在的模块。

这导致ES6处理"循环加载"与CommonJS有本质的不同。ES6根本不会关心是否发生了"循环加载",只是生成一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。

来看一个例子:

// even.js
import { odd } from './odd';
export var counter = 0;
export function even(n) { counter++; return n == 0 || odd(n - 1);}
// odd.js
import { even } from './even';
export function odd(n) { return n != 0 && even(n - 1);}
// main.js
import * as m from './even.js';
m.even(10); // true; m.counter = 6

上面代码中,even.js里面的函数even有一个参数n,只要不等于0,就会减去1,传入加载的odd()。odd.js也会做类似作。

上面代码中,参数n从10变为0的过程中,foo()一共会执行6次,所以变量counter等于6。第二次调用even()时,参数n从20变为0,foo()一共会执行11次,加上前面的6次,所以变量counter等于17。

而这个例子要是改写成CommonJS,就根本无法执行,会报错。

// even.js
var odd = require('./odd');
var counter = 0;
exports.counter = counter;
exports.even = function(n) {
counter++;
return n == 0 || odd(n - 1);
}
// odd.js
var even = require('./even').even;
module.exports = function(n) {
return n != 0 && even(n - 1);
}
// main.js
var m = require('./even');
m.even(10); // TypeError: even is not a function

上面代码中,even.js加载odd.js,而odd.js又去加载even.js,形成"循环加载"。这时,执行引擎就会输出even.js已经执行的部分(不存在任何结果),所以在odd.js之中,变量even等于null,等到后面调用even(n-1)就会报错。

以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,如果有疑问大家可以留言交流,同时也希望多多支持三水点靠木!

Javascript 相关文章推荐
javascript 学习之旅 (3)
Feb 05 Javascript
javascript 进阶篇3 Ajax 、JSON、 Prototype介绍
Mar 14 Javascript
innerHTML与jquery里的html()区别介绍
Oct 12 Javascript
javascript创建和存储cookie示例
Jan 07 Javascript
js创建对象的区别示例介绍
Jul 24 Javascript
学习JavaScript事件流和事件处理程序
Jan 25 Javascript
javaScript产生随机数的用法小结
Apr 21 Javascript
vue页面切换过渡transition效果
Oct 08 Javascript
小程序实现新用户判断并跳转激活的方法
May 20 Javascript
深入理解Vue keep-alive及实践总结
Aug 21 Javascript
策略模式实现 Vue 动态表单验证的方法
Sep 16 Javascript
vue+elementUI动态生成面包屑导航教程
Nov 04 Javascript
jquery 标签 隔若干行加空白或者加虚线的方法
Dec 07 #Javascript
浅析js的模块化编写 require.js
Dec 07 #Javascript
JavaScript中捕获/阻止捕获、冒泡/阻止冒泡方法
Dec 07 #Javascript
纯JS焦点图特效实例(可一个页面多用)
Dec 07 #Javascript
探究JavaScript中的五种事件处理程序方式
Dec 07 #Javascript
jquery 删除节点 添加节点 找兄弟节点的简单实现
Dec 07 #Javascript
jquery插入兄弟节点的操作方法
Dec 07 #Javascript
You might like
php Ajax乱码
2008/04/09 PHP
php根据isbn书号查询amazon网站上的图书信息的示例
2014/02/13 PHP
Php中使用Select 查询语句的实例
2014/02/19 PHP
Zend Framework教程之Zend_Config_Ini用法分析
2016/03/23 PHP
PHP中file_exists使用中遇到的问题小结
2016/04/05 PHP
利用php_imagick实现复古效果的方法
2016/10/18 PHP
PHP根据树的前序遍历和中序遍历构造树并输出后序遍历的方法
2017/11/10 PHP
PHP开发中解决并发问题的几种实现方法分析
2017/11/13 PHP
jquery $.ajax各个事件执行顺序
2010/10/15 Javascript
使用js简单实现了tree树菜单
2013/11/20 Javascript
JS模式之简单的订阅者和发布者模式完整实例
2015/06/30 Javascript
JavaScript函数的一些注意要点小结及js匿名函数
2015/11/10 Javascript
jQuery解析json格式数据简单实例
2016/01/22 Javascript
概述VUE2.0不可忽视的很多变化
2016/09/25 Javascript
js放到head中失效的原因与解决方法
2017/03/07 Javascript
js编写选项卡效果
2017/05/23 Javascript
layui框架中layer父子页面交互的方法分析
2017/11/15 Javascript
jQuery实现百度图片移入移出内容提示框上下左右移动的效果
2018/06/05 jQuery
微信小程序入门之指南针
2020/10/22 Javascript
教你安装python Django(图文)
2013/11/04 Python
django 自定义用户user模型的三种方法
2014/11/18 Python
Python实现提取谷歌音乐搜索结果的方法
2015/07/10 Python
python3.4用循环往mysql5.7中写数据并输出的实现方法
2017/06/20 Python
Python 闭包的使用方法
2017/09/07 Python
git进行版本控制心得详谈
2017/12/10 Python
python 移除字符串尾部的数字方法
2018/07/17 Python
深入理解Django自定义信号(signals)
2018/10/15 Python
Python爬取视频(其实是一篇福利)过程解析
2019/08/01 Python
HTML5 语音搜索(淘宝店语音搜素)
2013/01/03 HTML / CSS
Nili Lotan官网:Nili Lotan同名品牌
2018/01/07 全球购物
eDreams意大利:南欧领先的在线旅行社
2018/11/23 全球购物
Java程序员面试题
2013/07/15 面试题
2014年高一班主任工作总结
2014/12/05 职场文书
商务信函英语问候语
2015/11/10 职场文书
2016优秀教师先进个人事迹材料
2016/02/25 职场文书
mysql sock 文件解析及作用讲解
2022/07/15 MySQL