Hello,大家好,还是我yxchan🧑💻。最近在做业务的时候,遇到有个页面有较多的动画切换,因为动画资源比较大,切换过程会出现卡顿的情况。解决方案之一是进行资源的「预加载」,想知道大家平时是怎么做「预加载」的呢?
背景
🧑💼(产品):你这页面动画和图片加载怎么这么慢?
🧑💻(码农):素材体积太大,数量太多。
🧑💼:你就不能优化一下?
🧑💻:砍掉动画。
🧑💼:🔪砍你也不能砍动画!
🧑💻:那没办法了,不过可以加个loading页来提升体验。
🧑💼:加!
相信很多人都遇到过这种场景,产品既想要「狂拽炫酷吊炸天」的显示效果,又想要「秒开」的体验。但事实往往是「鱼与熊掌不可兼得」。炫酷的素材意味着更大的体积,同样网络状态下,加载速度必然更慢。这就会导致,页面的内容已经显示好了,但图片/动画,还在慢慢加载...
玩过游戏的同学都知道,绝大部分游戏,在进入游戏之前,都会经历一个loading的过程,进度 100% 后才真正进入游戏。实际上,loading主要是对游戏用到的「素材资源」进行「预加载」,防止在游戏过程中,出现素材显示慢、不全等问题(尤其是在动画切换)。
对于前端页面来说,也可以参考游戏的做法。新增一个页面路由,用作 「loading页」,然后在该页面对「主页面」的「静态资源」进行「预加载」,展示加载的「进度」。等资源都加载完后,再跳转至「主页面」。
「
loading页」应尽量简单,使用到资源最好都用base64格式。毕竟,谁也不想「为了加载loading页的资源」而再新增一个「loading页」吧 🌝 。
preload-js
提到「预加载」,就不得不提 preload-js 。
PreloadJS提供了「一致」的方式来「预加载」HTML应用程序中使用的内容。可以使用HTML标签和XHR进行预加载。
默认情况下,PreloadJS将优先使用XHR加载内容,因为其为「进度」和「完成事件」提供了更好的支持。但在「跨域」的情况下,也会使用「标签」的加载方式。
考虑到,有些内容只能使用XHR加载,比如「纯文本、网络音频」等,有些则只能用「标签」加载,比如「HTML音频」。preload-js内部会自动判断,采用合理的方式加载。
用法
preload-js的使用有两种方式:
- loadFile:加载单个资源
- loadManifest:加载多个资源
import createjs from 'preload-js';
const queue = new createjs.LoadQueue();
queue.on(
'complete',
() => {
console.log('done', queue.getResult("myImage"));
},
this
);
queue.on(
'progress',
progress => {
console.log('progress', progress.loaded);
},
this
);
queue.loadManifest([
{id: "myImage", src:"path/to/myImage.jpg"}
]);
queue.loadFile({id:"sound", src:"http://path/to/sound.mp3"});
源码分析
EventDispatcher
在preload-js的「预加载」过程中,需要监听「开始」-「进度」-「完成」等加载状态。因此需要一个「订阅发布器」,通过「订阅」事件,在加载的「生命周期」中,「发布」相应的事件。
./src/createjs/events/EventDispatcher.js
this.createjs = this.createjs || {};
(function() {
function EventDispatcher() {
this._listeners = null;
this._captureListeners = null;
}
var p = EventDispatcher.prototype;
EventDispatcher.initialize = function(target) {
target.addEventListener = p.addEventListener;
target.on = p.on;
target.removeEventListener = target.off = p.removeEventListener;
target.removeAllEventListeners = p.removeAllEventListeners;
target.hasEventListener = p.hasEventListener;
target.dispatchEvent = p.dispatchEvent;
target._dispatchEvent = p._dispatchEvent;
target.willTrigger = p.willTrigger;
};
// 添加指定的事件监听
p.addEventListener = function(type, listener, useCapture){};
// addEventListener的另外一种方式
p.on = function(type, listener, scope, once, data, useCapture) {};
// 移除事件
p.removeEventListener = function(type, listener, useCapture) {};
// removeEventListener的另外一种方式
p.off = p.removeEventListener;
// 移除所有事件
p.removeAllEventListeners = function(type) {};
// 发布时间
p.dispatchEvent = function(eventObj, bubbles, cancelable) {}
// 判断是否至少有一个监听器
p.hasEventListener = function(type) {};
// ...
createjs.EventDispatcher = EventDispatcher;
} ());
相关方法的具体实现大家闭着眼睛都能写得出来,然后把方法挂在到EventDispatcher的「原型」上,再把EventDispatcher挂到createjs.EventDispatcher。
AbstractLoader
在preload-js中,不同类型的资源由不同类型loader做处理。AbstractLoader是基础的loader,是所有loader的「父类」,包括LoadQueue。里面提供了loader所需的基础「属性」和「方法」。
./src/preloadjs/loaders/AbstractLoader.js
this.createjs = this.createjs || {};
(function () {
function AbstractLoader(loadItem, preferXHR, type) {
// 调用父类的构造方法
this.EventDispatcher_constructor();
// 是否加载
this.loaded = false;
// 是否取消
this.canceled = false;
// 加载进度
this.progress = 0;
// loader类型
this.type = type;
// 将原生结果格式化
this.resultFormatter = null;
// 要加载的资源的结构体
if (loadItem) {
this._item = createjs.LoadItem.create(loadItem);
} else {
this._item = null;
}
// 优先尝试使用xhr
this._preferXHR = preferXHR;
// 格式化后的加载结果
this._result = null;
// 未格式化的加载结果
this._rawResult = null;
//
this._loadedItems = null;
// 加载标签链接(标签方式使用)
this._tagSrcAttribute = null;
// 加载标签(标签方式使用)
this._tag = null;
};
var p = createjs.extend(AbstractLoader, createjs.EventDispatcher);
var s = AbstractLoader;
// ...
// 开始加载loadItem
p.load = function () {};
// 创建一个请求
p._createRequest = function() {};
// dispath一个 loadstart 事件
p._sendLoadStart = function () {};
// dispath一个 ProgressEvent 事件
p._sendProgress = function (value) {};
// dispath一个 complete 事件
p._sendComplete = function () {};
// dispath一个 error 事件
p._sendError = function (event) {};
// ...
createjs.AbstractLoader = createjs.promote(AbstractLoader, "EventDispatcher");
}());
该对象定义了每个loader都需要用到「属性」或者「方法」。
PS:一些不重要的「辅助」类方法没有罗列出来。
LoadQueue
使用的第一步是进行初始化new createjs.LoadQueue()。
./src/preloadjs/LoadQueue.js
function LoadQueue(preferXHR, basePath, crossOrigin) {
// 调用父类构造函数
this.AbstractLoader_constructor();
// 注册使用的plugin
this._plugins = [];
// plugin的回调函数
this._typeCallbacks = {};
this._extensionCallbacks = {};
// 下一个要处理的资源队列
this.next = null;
// 类型为script的资源的加载顺序标识
this.maintainScriptOrder = true;
// 当错误发生中断加载
this.stopOnError = false;
// 最大链接数
this._maxConnections = 1;
// 可用的loader
this._availableLoaders = [
createjs.FontLoader,
createjs.ImageLoader,
createjs.JavaScriptLoader,
createjs.CSSLoader,
createjs.JSONLoader,
createjs.JSONPLoader,
createjs.SoundLoader,
createjs.ManifestLoader,
createjs.SpriteSheetLoader,
createjs.XMLLoader,
createjs.SVGLoader,
createjs.BinaryLoader,
createjs.VideoLoader,
createjs.TextLoader,
];
// 可用loader数量
this._defaultLoaderLength = this._availableLoaders.length;
this.init(preferXHR, basePath, crossOrigin);
}
// ...
createjs.LoadQueue = createjs.promote(LoadQueue, "AbstractLoader");
本质上,new createjs.LoadQueue()等同于new LoadQueue(),LoadQueue继承于AbstractLoader。所以第一步就需要调用父类的「构造函数」this.AbstractLoader_constructor()。
从_availableLoaders可以看出支持预加载的「资源类型」:
- 字体
- 图片
- js脚本
- css
- json
- jsonp
- 音频
- manifest
- 精灵图
- xml
- svg
- 二进制文件
- 视频
- 文本
on
初始化完成后,就要开始对加载「进度」进行订阅监听。
从上文得知,EventDispatcher是LoadQueue的「爷爷」,因此queue.on调用的是EventDispatcher原型上的on方法(on本质上是调用addEventListener)
./src/createjs/events/EventDispatcher.js
p.addEventListener = function(type, listener, useCapture) {
var listeners;
if (useCapture) {
listeners = this._captureListeners = this._captureListeners||{};
} else {
listeners = this._listeners = this._listeners||{};
}
var arr = listeners[type];
if (arr) { this.removeEventListener(type, listener, useCapture); }
arr = listeners[type];
if (!arr) { listeners[type] = [listener]; }
else { arr.push(listener); }
return listener;
};
p.on = function(type, listener, scope, once, data, useCapture) {
if (listener.handleEvent) {
scope = scope||listener;
listener = listener.handleEvent;
}
scope = scope||this;
return this.addEventListener(type, function(evt) {
listener.call(scope, evt, data);
once&&evt.remove();
}, useCapture);
};
所有订阅的事件,通过不同的type,放在this._listeners上。因为_listeners的初始值是null,因此产生了listeners = this._listeners = this._listeners||{}这种写法。
loadManifest & loadFile
「订阅」完成之后,就开始真正的「加载」资源。
从使用方法,就可以看出,loadManifest是加载多个资源,loadFile是加载单个资源。
./src/preloadjs/LoadQueue.js
p.loadManifest = function (manifest, loadNow, basePath) {
var fileList = null;
var path = null;
// ...
for (var i = 0, l = fileList.length; i < l; i++) {
this._addItem(fileList[i], path, basePath);
}
if (loadNow !== false) {
this.setPaused(false);
} else {
this.setPaused(true);
}
};
p.loadFile = function (file, loadNow, basePath) {
// ...
this._addItem(file, null, basePath);
if (loadNow !== false) {
this.setPaused(false);
} else {
this.setPaused(true);
}
};
loadManifest和loadFile的最终目的都是把资源传进_addItem方法调用。
- manifest/file:资源(列表);
- loadnow:默认为
undefined,表示立刻加载; - basePath:资源的基础路径;
_addItem
这个方法很关键。通过调用_createLoadItem生成资源的「数据对象」,然后根据这个「数据对象」,调用_createLoader,创建该资源loader,再把该loader添加进加载队列_loadQueue中。
./src/preloadjs/LoadQueue.js
p._addItem = function (value, path, basePath) {
var item = this._createLoadItem(value, path, basePath);
if (item == null) {
return;
}
var loader = this._createLoader(item);
if (loader != null) {
if ("plugins" in loader) {
loader.plugins = this._plugins;
}
item._loader = loader;
this._loadQueue.push(loader);
this._loadQueueBackup.push(loader);
this._numItems++;
this._updateProgress();
if (
(this.maintainScriptOrder && item.type == createjs.Types.JAVASCRIPT) ||
item.maintainOrder === true
) {
this._scriptOrder.push(item);
this._loadedScripts.push(null);
}
}
};
在该方法里面调用_updateProgress方法「发布」了一个pregress事件,但此时资源的加载「进度」还是0,所以在订阅的progress事件中,一开始会返回0。
最后的if-else语句的作用是针对使用加载js脚本作处理,由于js脚本的特殊性,其加载顺序要固定。
_createLoadItem
为每个资源进行「初始化」,生成一个名为loadItem的「数据对象」。
./src/preloadjs/LoadQueue.js
p._createLoadItem = function (value, path, basePath) {
// 初始化资源的数据结构
var item = createjs.LoadItem.create(value);
var bp = "";
var useBasePath = basePath || this._basePath;
// 对 loadItem 进行赋值...
return item;
};
function LoadItem() {
// 资源路径
this.src = null;
// 资源类型
this.type = null;
// 资源标识
this.id = null;
// 是否按顺序加载
this.maintainOrder = false;
// jsonp的回调
this.callback = null;
// 扩展字段
this.data = null;
// 用来发送http请求的方法
this.method = createjs.Methods.GET;
// 跟随请求发送的键值对
this.values = null;
// 默认的请求信息
this.headers = null
this.withCredentials = false;
this.mimeType = null;
this.crossOrigin = null;
// 超时时间
this.loadTimeout = s.LOAD_TIMEOUT_DEFAULT;
};
var p = LoadItem.prototype = {};
var s = LoadItem;
s.LOAD_TIMEOUT_DEFAULT = 8000;
s.create = function (value) {
// ...
var item = new LoadItem();
// ...
return item
};
// ...
每一个资源都被定义为一个loadItem,里面包含了该资源的所有信息。
_createLoader
该方法根据loadItem,选择合适的loader并进行处理。
./src/preloadjs/LoadQueue.js
p._createLoader = function (item) {
if (item._loader != null) {
return item._loader;
}
var preferXHR = this.preferXHR;
for (var i = 0; i < this._availableLoaders.length; i++) {
var loader = this._availableLoaders[i];
if (loader && loader.canLoadItem(item)) {
return new loader(item, preferXHR);
}
}
return null;
};
遍历_availableLoaders(上文有提及的「可用loader」),每个loader内部均实现了一个canLoadItem方法,该方法判断loadItem里的type(上文有提及,为资源类型)是否为该loader能处理的类型,如果能则用loadItem初始化该loader并返回。
_updateProgress
该方法的作用是根据「资源总数」和「已加载资源数」+「加载中资源进度」,计算出当前的加载「进度」。
./src/preloadjs/LoadQueue.js
p._updateProgress = function () {
var loaded = this._numItemsLoaded / this._numItems;
var remaining = this._numItems - this._numItemsLoaded;
if (remaining > 0) {
var chunk = 0;
for (var i = 0, l = this._currentLoads.length; i < l; i++) {
chunk += this._currentLoads[i].progress;
}
loaded += (chunk / remaining) * (remaining / this._numItems);
}
if (this._lastProgress != loaded) {
this._sendProgress(loaded);
this._lastProgress = loaded;
}
};
- _numItemsLoaded:已加载资源数量
- _numItems:总资源数量
- remaining:加载中的资源数量
首先计算「已加载资源数量/总资源数量」,接着遍历「加载中的资源」获取其加载「进度」并添加到loaded上。
_loadNext
在_addItem之后,loadItem已经创建完成并生成对应的loader添加进「加载队列_loadQueue」中了。接下来,就是要启用_loadQueue里的每一个loader进行「加载」。
在loadManifest和loadFile中,最后根据loadNow来判断是否「立刻加载」,默认是「立刻加载」。
./src/preloadjs/LoadQueue.js
p.loadFile = function (file, loadNow, basePath) {
// ...
if (loadNow !== false) {
this.setPaused(false);
} else {
this.setPaused(true);
}
};
p.setPaused = function (value) {
this._paused = value;
if (!this._paused) {
this._loadNext();
}
};
p._loadNext = function () {
// ...
for (var i = 0; i < this._loadQueue.length; i++) {
if (this._currentLoads.length >= this._maxConnections) {
break;
}
var loader = this._loadQueue[i];
if (!this._canStartLoad(loader)) {
continue;
}
this._loadQueue.splice(i, 1);
i--;
this._loadItem(loader);
}
};
p._loadItem = function (loader) {
loader.on("fileload", this._handleFileLoad, this);
loader.on("progress", this._handleProgress, this);
loader.on("complete", this._handleFileComplete, this);
loader.on("error", this._handleError, this);
loader.on("fileerror", this._handleFileError, this);
this._currentLoads.push(loader);
this._sendFileStart(loader.getItem());
loader.load();
};
在_loadNext中,遍历_loadQueue,判断loader能否进行加载,然后从_loadQueue剔除该loader,然后在_loadItem方法中传入loader并调用。在_loadItem方法内部,使用EventDispatcher(上文有提及)提供的方法进行事件「订阅」,并「发布」该loader的fileStart事件,最后调用load方法,开始「加载」。
ImageLoader
preload-js提供了大量的loader。
由于篇幅有限,只分析了使用频率最高的ImageLoader,其余都大同小异。
./src/preloadjs/imageLoader.js
this.createjs = this.createjs || {};
(function () {
function ImageLoader (loadItem, preferXHR) {
this.AbstractLoader_constructor(loadItem, preferXHR, createjs.Types.IMAGE);
this.resultFormatter = this._formatResult;
this._tagSrcAttribute = "src";
if (createjs.DomUtils.isImageTag(loadItem)) {
this._tag = loadItem;
} else if (createjs.DomUtils.isImageTag(loadItem.src)) {
this._tag = loadItem.src;
} else if (createjs.DomUtils.isImageTag(loadItem.tag)) {
this._tag = loadItem.tag;
}
if (this._tag != null) {
this._preferXHR = false;
} else {
this._tag = createjs.Elements.img();
}
// ...
};
var p = createjs.extend(ImageLoader, createjs.AbstractLoader);
var s = ImageLoader;
s.canLoadItem = function (item) {
return item.type == createjs.Types.IMAGE;
};
p.load = function () {
if (this._tag.src != "" && this._tag.complete) {
this._request._handleTagComplete();
this._sendComplete();
return;
}
// ...
this.AbstractLoader_load();
};
// ...
p._formatImage = function (successCallback, errorCallback) {
var tag = this._tag;
var URL = window.URL || window.webkitURL;
if (!this._preferXHR) {} else if (URL) {
var objURL = URL.createObjectURL(this.getResult(true));
tag.src = objURL;
tag.addEventListener("load", this._cleanUpURL, false);
tag.addEventListener("error", this._cleanUpURL, false);
} else {
tag.src = this._item.src;
}
if (tag.complete) {
successCallback(tag);
} else {
tag.onload = createjs.proxy(function() {
successCallback(this._tag);
tag.onload = tag.onerror = null;
}, this);
tag.onerror = createjs.proxy(function(event) {
errorCallback(new createjs.ErrorEvent('IMAGE_FORMAT', null, event));
tag.onload = tag.onerror = null;
}, this);
}
};
// ...
createjs.ImageLoader = createjs.promote(ImageLoader, "AbstractLoader");
}());
首先调用父类的构造方法AbstractLoader_constructor,然后判断loadItem上是否存在img标签,如果存在,用「标签」方式加载,监听「标签」的load和error事件,否则用xhr方式。
每一个loader都有canLoadItem和load,canLoadItem用于判断资源类型,load用于调用AbstractLoader上的load方法,本质上是调用AbstractLoader.prototype._createRequest进行「创建请求」-「监听状态」-「发送请求」。
以上,是本人对preload-js实现的大致理解。其中,还有很多细致的地方没理解透彻。如有错误或表述不当之处,烦请指出,谢谢!
最后
🧑💼:效果不错 👍 你这小子可以呀,回头再写两个需求给你!
🧑💻:卒。
参考文章:
⚠️:preload-js最新的一次更新还是在3年前,作者已经不维护了。源码基于es5编写的,里面大量使用了「原型」和「闭包」。如果有时间,我打算用es6重写并完善它,但目前还没该计划。
祝大家生活愉快,工作顺利!
「 --- The end --- 」