浅尝一口 preload-js

2,254 阅读8分钟

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

初始化完成后,就要开始对加载「进度」进行订阅监听。

从上文得知,EventDispatcherLoadQueue的「爷爷」,因此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);
    }
  };

loadManifestloadFile的最终目的都是把资源传进_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进行「加载」。

loadManifestloadFile中,最后根据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(上文有提及)提供的方法进行事件「订阅」,并「发布」该loaderfileStart事件,最后调用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标签,如果存在,用「标签」方式加载,监听「标签」的loaderror事件,否则用xhr方式。

每一个loader都有canLoadItemloadcanLoadItem用于判断资源类型,load用于调用AbstractLoader上的load方法,本质上是调用AbstractLoader.prototype._createRequest进行「创建请求」-「监听状态」-「发送请求」。

以上,是本人对preload-js实现的大致理解。其中,还有很多细致的地方没理解透彻。如有错误或表述不当之处,烦请指出,谢谢!

最后

🧑‍💼:效果不错 👍 你这小子可以呀,回头再写两个需求给你!

🧑‍💻:卒。

参考文章:

⚠️:preload-js最新的一次更新还是在3年前,作者已经不维护了。源码基于es5编写的,里面大量使用了「原型」和「闭包」。如果有时间,我打算用es6重写并完善它,但目前还没该计划。

祝大家生活愉快,工作顺利!

「 --- The end --- 」