首頁  >  文章  >  web前端  >  概述如何實作一個簡單的瀏覽器端js模組載入器

概述如何實作一個簡單的瀏覽器端js模組載入器

高洛峰
高洛峰原創
2016-12-28 14:26:391473瀏覽

在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(&#39;script&#39;);
    nsc.src = uri;
   document.body.appendChild(nsc);
  }
 }
};
zmm.resolvePath = function (path) {
 // 返回绝对路径
 var res = &#39;&#39;, paths = [], resPaths;
 if (path.match(/.*:\/\/.*/)) {
  // 绝对路径
  res = path.match(/.*:\/\/.*?\//)[0]; // 协议+域名
  path = path.substr(res.length);
 } else if (path.charAt(0) === &#39;/&#39;) {
  // 相对根路径 /开头
  res = this._configs.host;
  path = path.substr(1);
 } else {
  // 相对路径 ./或../开头或直接文件名
  res = this._configs.host;
  resPaths = this._configs.basePath.split(&#39;/&#39;);
 }
 resPaths = resPaths || [];
 paths = path.split(&#39;/&#39;);
 for (var i = 0; i < paths.length; i++) {
  if (paths[i] === &#39;..&#39;) {
   resPaths.pop();
  } else if (paths[i] === &#39;.&#39;) {
   // do nothing
  } else {
   resPaths.push(paths[i]);
  }
 }
 res += resPaths.join(&#39;/&#39;);
 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[&#39;&#39; + taskId] = task;
   taskId ++;
  }
 };
 proxy.emit = function (_uri) {
  console.log(_uri + &#39; is loaded!&#39;);
  deal_loaded(_uri);
 };
 return proxy;
}();

循環依賴問題

"循環載入"指的是,a腳本的執行依賴b腳本,而b腳本的執行又依賴a腳本。這是一種應該盡量避免的設計。

瀏覽器端

用上面的zmm工具載入模組a:

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

就會陷入a等待b載入完成、b等待a載入完成的死鎖狀態。 sea.js碰到這種情況也是死鎖,也許是預設這種行為不該出現。

seajs裡可以透過require.async來緩解循環依賴的問題,但必須改寫a.js:

// a.js
define(&#39;./js/a&#39;, function (require, exports, module) {
 var a = 1;
 require.async(&#39;./b&#39;, function (b) {
  a = b + 1;
  module.exports = a; //a= 3
 });
 module.exports = a; // a= 1
});
// b.js
define(&#39;./js/b&#39;, function (require, exports, module) {
 var a = require(&#39;./a&#39;);
 var b = a + 1;
 module.exports = b;
});
// main.html
seajs.use(&#39;./js/a&#39;, 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(&#39;./b&#39;);
a = b + 1;
module.exports = a;
// b.js
var a = require(&#39;./a&#39;);
var b = a + 1;
module.exports = b;
// main.js
var a = require(&#39;./a&#39;);
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 &#39;./odd&#39;;
export var counter = 0;
export function even(n) { counter++; return n == 0 || odd(n - 1);}
// odd.js
import { even } from &#39;./even&#39;;
export function odd(n) { return n != 0 && even(n - 1);}
// main.js
import * as m from &#39;./even.js&#39;;
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(&#39;./odd&#39;);
var counter = 0;
exports.counter = counter;
exports.even = function(n) {
counter++;
return n == 0 || odd(n - 1);
}
// odd.js
var even = require(&#39;./even&#39;).even;
module.exports = function(n) {
return n != 0 && even(n - 1);
}
// main.js
var m = require(&#39;./even&#39;);
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)就会报错。

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

更多概述如何实现一个简单的浏览器端js模块加载器相关文章请关注PHP中文网!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn