概述如何实现一个简单的浏览器端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高级程序设计
Dec 29 Javascript
纯JS实现旋转图片3D展示效果
Apr 12 Javascript
去除html代码里面的script正则方法
May 19 Javascript
Bootstrap Metronic完全响应式管理模板学习笔记
Jul 08 Javascript
微信小程序本地缓存数据增删改查实例详解
May 24 Javascript
基于js 本地存储(详解)
Aug 16 Javascript
浅谈angular.copy() 深拷贝
Sep 14 Javascript
详解使用webpack构建多页面应用
Dec 21 Javascript
vue项目首屏加载时间优化实战
Apr 23 Javascript
仿ElementUI实现一个Form表单的实现代码
Apr 23 Javascript
vue-cli随机生成port源码的方法
Sep 02 Javascript
JavaScript事件概念详解(区分静态注册和动态注册)
Feb 05 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
zen cart新进商品的随机排序修改方法
2010/09/10 PHP
php入门之连接mysql数据库的一个类
2012/04/21 PHP
php读取txt文件组成SQL并插入数据库的代码(原创自Zjmainstay)
2012/07/31 PHP
CodeIgniter实现从网站抓取图片并自动下载到文件夹里的方法
2015/06/17 PHP
php判断数组是否为空的实例方法
2020/05/10 PHP
PHP dirname(__FILE__)原理及用法解析
2020/10/28 PHP
js 获取时间间隔实现代码
2014/05/12 Javascript
javascript实现通过表格绘制颜色填充矩形的方法
2015/04/21 Javascript
jQuery实现图片加载完成后改变图片大小的方法
2016/03/29 Javascript
js操作XML文件的实现方法兼容IE与FireFox
2016/06/25 Javascript
BootStrap实现手机端轮播图左右滑动事件
2016/10/13 Javascript
微信小程序实战之运维小项目
2017/01/17 Javascript
js实现仿购物车加减效果
2017/03/01 Javascript
vue基于Vue2.0和高德地图的地图组件实例
2017/04/28 Javascript
关于angular引入ng-zorro的问题浅析
2020/09/09 Javascript
解决iView Table组件宽度只变大不变小的问题
2020/11/13 Javascript
[38:51]2014 DOTA2国际邀请赛中国区预选赛 Orenda VS LGD-CDEC
2014/05/22 DOTA
[55:11]完美世界DOTA2联赛PWL S2 SZ vs LBZS 第一场 11.26
2020/11/30 DOTA
浅析python 内置字符串处理函数的使用方法
2014/06/11 Python
python实现红包裂变算法
2016/02/16 Python
python自动翻译实现方法
2016/05/28 Python
python基于pyDes库实现des加密的方法
2017/04/29 Python
Python程序退出方式小结
2017/12/09 Python
利用 python 对目录下的文件进行过滤删除
2017/12/27 Python
Python遍历某目录下的所有文件夹与文件路径
2018/03/15 Python
使用python读取txt文件的内容,并删除重复的行数方法
2018/04/18 Python
用python处理图片之打开\显示\保存图像的方法
2018/05/04 Python
基于python框架Scrapy爬取自己的博客内容过程详解
2019/08/05 Python
python实现while循环打印星星的四种形状
2019/11/23 Python
用Python 爬取猫眼电影数据分析《无名之辈》
2020/07/24 Python
美国顶级户外凉鞋品牌:Chacos
2017/03/27 全球购物
华为智利官方商店:Huawei Chile
2020/05/09 全球购物
2016年乡镇七一建党节活动总结
2016/04/05 职场文书
使用Pytorch实现two-head(多输出)模型的操作
2021/05/28 Python
Mysql如何实现不存在则插入,存在则更新
2022/03/25 MySQL
基于Redission的分布式锁实战
2022/08/14 Redis