徒手撸一个资源加载器

简介: Phaser资源加载器和PreloadJS都能进行资源记载,并通过一个key来使用。 看起来很美好,但是当页面再次刷新的时候,还是会发出请求,再次加载文件。

1.JPG


前言


最近学习H5游戏编程,Phaser代码里面有如下的代码, 就能加载图片。


this.load.image("phaser-logo-i", "../images/logo.png");
复制代码


也有专门的资源加载库PreloadJS


两者的原理都是相同的,请求到资源,然后利用URL.createObjectURL生成url地址,以便之后复用。


Phaser资源加载器和PreloadJS都能进行资源记载,并通过一个key来使用。 看起来很美好,但是当页面再次刷新的时候,还是会发出请求,再次加载文件。


这里就延伸到浏览器缓存了,比较理想的就是Service Worker啦,合理配置之后,可以不发出请求,离线依旧可以使用。


我这里就是就是用的另外一种比较常见的缓存方案,indexedDB。


演示和源码


const resourcesInfo = [{
    pre: ["promise"],
    key: "axios",
    ver: "1.2",
    url: "https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js"
},{
    key: "mqtt",
    ver: "1.0",
    url: "https://cdnjs.cloudflare.com/ajax/libs/mqtt/4.2.6/mqtt.min.js"
},{
    key: "lottie",
    url: "https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.7.8/lottie.min.js"
},{
    key: "flv",
    url: "https://cdnjs.cloudflare.com/ajax/libs/flv.js/1.5.0/flv.min.js"
},
{
    key: "promise",
    url: "https://cdnjs.cloudflare.com/ajax/libs/promise-polyfill/8.2.0/polyfill.min.js"
}];
let startTime;
const rl = new ResourceLoader(resourcesInfo, idb);
rl.on("progress", (progress, info)=>{
    console.log("progress:", progress,  info);
});
rl.on("completed", (datas)=>{
    console.log("completed event:", datas);    
    console.log("total time:", Date.now() - startTime)
});
rl.on("loaded", (datas)=>{
    console.log("loaded event:", datas);    
    console.log("total time:", Date.now() - startTime)
});
rl.on("error", (error, info)=>{
    console.log("error event:", error.message, info);
});
复制代码


r-loader演示地址


r-loader源码


基本思路


这里先看看几个资源属性,


  • key: 资源的唯一标记
  • ver: 资源的版本标记
  • pre: 资源加载的前置项, 比如react-dialog的依赖项 ["react", "react-dom"]
  • url: 资源的地址


我们来过一下流程:


  1. 检查资源是否加载完毕
    如果记载完毕,直接触发completed事件
  2. 从indexedDB载入缓存
  3. 检查是否有可加载的资源


如果有,进入4


如果没有,检查是否均加载完毕, 加载完毕触发complete事件


  1. 检查资源是否有前置依赖项


如果没有,进入5


如果有,进入3


  1. 检查是否有缓存


如果有,更新内部状态,触发progress事件,如果加载完毕,直接触发completed事件, 反之进入3


如果没有,发起网络请求, 下载资源,存入indexedDB,更新内部状态。 触发progress事件,如果加载完毕,直接触发completed事件, 反之进入3


备注:


  • 如果某个被依赖的项加载失败了,目前的处理是,把依赖他的项都设置为error状态。


实现-工具方法


工欲善其事,必先利其器。 先撸工具方法。


我们需要网路请求,对象复制(不破坏传入的参数),参数的合并,检查key值等。


  1. 网络请求


function fetchResource(url){
    return fetch(url, {
         method: "get",
         responseType: 'blob'
     }).then(res=>{
         if(res.status >= 400){
             throw new Error(res.status + "," + res.statusText);
         }
         return res;
     }).then(res=> res.blob());
 }  
复制代码


  1. 版本比较


function compareVersion(v1 = "", v2 = "") {
    if(v1 == v2){
        return 0;
    }
    const version1 = v1.split('.')
    const version2 = v2.split('.')
    const len = Math.max(version1.length, version2.length);
    while (version1.length < len) {
      version1.push('0')
    }
    while (version2.length < len) {
      version2.push('0')
    }
    for (let i = 0; i < len; i++) {
      const num1 = parseInt(version1[i]) || 0;
      const num2 = parseInt(version2[i]) || 0;
      if (num1 > num2) {
        return 1
      } else if (num1 < num2) {
        return -1
      }
    }
    return 0
}
复制代码


  1. 对象复制


function copyObject(obj){
    return  JSON.parse(JSON.stringify(obj));
 }
复制代码


  1. 生成资源地址


function generateBlobUrl(blob){
   return URL.createObjectURL(blob); 
}
复制代码


  1. 检查key, key不能为空,key不能重复


function validateKey(resources){
    let failed = false;
    // 空key检查
    const emptyKeys = resources.filter(r=> r.key == undefined || r.key == "");
    if(emptyKeys.length > 0){
        failed = true;
        console.error("ResourceLoader validateKey: 资源都必须有key");
        return failed;
    }
    // 资源重复检查
    const results = Object.create(null);
    resources.forEach(r=>{
        (results[r.key] = results[r.key]  || []).push(r);
    });   
    Object.keys(results).forEach(k=>{
        if(results[k].length > 1){
            console.error("key " +  k + " 重复了," + results[k].map(r=>r.url).join(","));
            failed = true;
        }
    });    
    return failed;
}
复制代码


实现-消息通知


我们的资源加载是要有进度通知,错误通知,完毕通知的。这就是一个典型的消息通知。


class Emitter {
    constructor() {
        this._events = Object.create(null);
    }
    emit(type, ...args) {
        const events = this._events[type];
        if (!Array.isArray(events) || events.length === 0) {
            return;
        }
        events.forEach(event => event.apply(null, args));
    }
    on(type, fn) {
        const events = this._events[type] || (this._events[type] = []);
        events.push(fn)
    }
    off(type, fn) {
        const events = this._events[type] || (this._events[type] = []);
        const index = events.find(f => f === fn);
        if (index < -1) {
            return;
        }
        events.splice(index, 1);
    }
}
复制代码


实现-缓存管理


我们为了方便扩展,对缓存管理进行一次抽象。


CacheManager的传入参数storage真正负责存储的, 我这里使用一个极其轻量级的库idb-keyval。更多的库可以参见IndexedDB


class CacheManager {
    constructor(storage) {
        this.storage = storage;
        this._cached = Object.create(null);
    }
    load(keys) {
        const cached = this._cached;
        return this.storage.getMany(keys).then(results => {
            results.forEach((value, index) => {
                if (value !== undefined) {
                    cached[keys[index]] = value;
                }
            });
            return cached;
        })
    }
    get datas() {
        return this._cached;
    }
    get(key) {
        return this._cached[key]
    }
    isCached(key) {
        return this._cached[key] != undefined;
    }
    set(key, value) {
        return this.storage.set(key, value);
    }
    clear() {
        this._cached = Object.create(null);
        // return this.storage.clear();
    }
    del(key){
        delete this._cached[key];
        // return this.storage.del();
    }
}
复制代码


实现-Loader


Loader继承Emitter,自己就是一个消息中心了。


/ status: undefined loading loaded error
class ResourceLoader extends Emitter {
    constructor(resourcesInfo, storage = defaultStorage) {      
    }
    // 重置
    reset() {      
    }
    // 检查是否加载完毕
    isCompleted() {       
    }
    // 当某个资源加载完毕后的回调
    onResourceLoaded = (info, data, isCached) => {  
    }
    // 某个资源加载失败后的回调
    onResourceLoadError(err, info) {     
    }
    // 进行下一次的Load, onResourceLoadError和onResourceLoaded均会调用次方法
    nextLoad() {      
    }
    // 获取下载进度
    getProgress() {    
    }
    // 获取地址url
    get(key) {        
    }
    // 获得缓存信息
    getCacheData(key) {        
    }
    // 请求资源
    fetchResource(rInfo) {    
    }  
    // 某个资源记载失败后,设置依赖他的资源的状态
    setFactorErrors(info) {    
    }
    // 检查依赖项是不是都被加载了
    isPreLoaded(pre) {      
    }
    // 查找可以加载的资源
    findCanLoadResource() {    
    }
    // 获取资源
    fetchResources() { 
    }
    // 准备,加载缓存
    prepare() {      
    }
    // 开始加载
    startLoad() {
    }
}
复制代码


简单先看一下骨架, startLoad为入口。 其余每个方法都加上了备注。很好理解。 Loader类的全部代码


const defaultStorage = {
    get: noop,
    getMany: noop,
    set: noop,
    del: noop,
    clear: noop
};
// status: undefined loading loaded error
class ResourceLoader extends Emitter {
    constructor(resourcesInfo, storage = defaultStorage) {
        super();
        this._originResourcesInfo = resourcesInfo;
        this._cacheManager = new CacheManager(storage);
        this.reset();
    }
    reset() {
        const resourcesInfo = this._originResourcesInfo;
        this.resourcesInfo = resourcesInfo.map(r => copyObject(r));
        this.resourcesInfoObj = resourcesInfo.reduce((obj, cur) => {
            obj[cur.key] = cur;
            return obj;
        }, {});
        // 已缓存, 缓存不等已加载,只有调用URL.createObjectURL之后,才会变为loaded
        this._loaded = Object.create(null);
        this._cacheManager.clear();
    }
    isCompleted() {
        return this.resourcesInfo.every(r => r.status === "loaded" || r.status === "error");
    }
    onResourceLoaded = (info, data, isCached) => {
        console.log(`${info.key} is loaded`);
        const rInfo = this.resourcesInfo.find(r => r.key === info.key);
        rInfo.status = "loaded";
        this._loaded[rInfo.key] = {
            key: rInfo.key,
            url: generateBlobUrl(data)
        };
        this.emit("progress", this.getProgress(), rInfo);
        if (!isCached) {
            const info = {
                data,
                key: rInfo.key,
                url: rInfo.url,
                ver: rInfo.ver || ""
            };
            this._cacheManager.set(info.key, info);
        }
        this.nextLoad();
    }
    nextLoad() {
        if (!this.isCompleted()) {
            return this.fetchResources()
        }
        this.emit("completed", this._loaded);
        // 全部正常加载,才触发loaded事件
        if (this.resourcesInfo.every(r => r.status === "loaded")) {
            this.emit("loaded", this._loaded);
        }
    }
    getProgress() {
        const total = this.resourcesInfo.length;
        const loaded = Object.keys(this._loaded).length;
        return {
            total,
            loaded,
            percent: total === 0 ? 0 : + ((loaded / total) * 100).toFixed(2)
        }
    }
    get(key) {
        return (this._loaded[key] || this.resourcesInfoObj[key]).url;
    }
    getCacheData(key) {
        return this._cacheManager.get(key)
    }
    fetchResource(rInfo) {
        return fetchResource(`${rInfo.url}?ver=${rInfo.ver}`)
            .then(blob => this.onResourceLoaded(rInfo, blob))
            .catch(error => this.onResourceLoadError(error, rInfo));
    }
    onResourceLoadError(err, info) {
        const rInfo = this.resourcesInfo.find(r => r.key === info.key);
        rInfo.status = "error";
        console.error(`${info.key}(${info.url}) load error: ${err.message}`);
        this.emit("error", err, info);
        // 因被依赖,会导致其他依赖他的资源为失败
        this.setFactorErrors(info); 
        this.nextLoad();
    }
    setFactorErrors(info) {
        // 未开始,pre包含info.key
        const rs = this.resourcesInfo.filter(r => !r.status && r.pre && r.pre.indexOf(info.key) >= 0);
        if (rs.length < 0) {
            return;
        }
        rs.forEach(r => {
            console.warn(`mark ${r.key}(${r.url}) as error because it's pre failed to load`);
            r.status = "error"
        });
    }
    isPreLoaded(pre) {
        const preArray = Array.isArray(pre) ? pre : [pre]
        return preArray.every(p => this._loaded[p] !== undefined);
    }
    findCanLoadResource() {
        const info = this.resourcesInfo.find(r => r.status == undefined && (r.pre == undefined || this.isPreLoaded(r.pre)));
        return info;
    }
    fetchResources() {
        let info = this.findCanLoadResource();
        while (info) {
            const cache = this._cacheManager.get(info.key);
            // 有缓存
            if (cache) {
                const isOlder = compareVersion(cache.ver, info.ver || "") < 0;
                // 缓存过期
                if (isOlder) {
                    console.warn(`${info.key} is cached, but is older version, cache:${cache.ver} request: ${info.ver}`);
                } else {
                    console.log(`${info.key} is cached, load from db, pre`, info.pre);
                    this.onResourceLoaded(info, cache.data, true);
                    info = this.findCanLoadResource();
                    continue;
                }
            }
            console.log(`${info.key} load from network ${info.url}, pre`, info.pre);
            info.status = "loading";
            this.fetchResource(info);
            info = this.findCanLoadResource();
        }
    }
    prepare() {
        const keys = this.resourcesInfo.map(r => r.key);
        return this._cacheManager.load(keys);
    }
    startLoad() {
        const failed = validateKey(this.resourcesInfo);
        if (failed) {
            return;
        }
        if (this.isCompleted()) {
            this.emit("completed", this._cacheManager.datas);
        }
        this.prepare()
            .then(() => this.fetchResources())
            .catch(err=> this.emit("error", err));
    }
}
复制代码


问题和后续


  1. 语法过于高级,需要使用babel转换一下。
  2. fetch状态码的处理,用XMLHttpRequest替换方案。
  3. indexedDB不被支持的妥协方案。
  4. ......
相关文章
|
8天前
|
存储 缓存 Java
写代码原来如此简单:两种常用代码范式
一次项目包含非常多的流程,有需求拆解,业务建模,项目管理,风险识别,代码模块设计等等,如果我们在每次项目中,都将精力大量放在这些过程的思考上面,那我们剩余的,放在业务上思考的精力和时间就会大大减少;这也是为什么我们要 总结经验/方法论/范式 的原因;这篇文章旨在建立代码模块设计上的思路,给出了两种非常常用的设计范式,减少未来在这一块的精力开销。
|
5月前
|
Python
惊呆了!学会这一招,你的Python上下文管理器也能玩出花样来文管理器也能玩出花样来
【7月更文挑战第6天】Python的上下文管理器是资源优雅管理的关键,与with语句结合,确保资源获取和释放。通过实现`__enter__`和`__exit__`,不仅能做资源分配和释放,还能扩展实现如计时、自动重试、事务处理等功能。例如,TimerContextManager类记录代码执行时间,展示了上下文管理器的灵活性。学习和利用这一机制能提升代码质量,增强功能,是Python编程的必备技巧。
37 0
|
JavaScript 前端开发 开发工具
简简单单一个vite⚡⚡插件搞定用户的另类需求——自给自足的感觉真好
简简单单一个vite⚡⚡插件搞定用户的另类需求——自给自足的感觉真好
|
设计模式 JavaScript 前端开发
基于装饰器——我劝你不要在业务代码上装逼!!!(下)
基于装饰器——我劝你不要在业务代码上装逼!!!(下)
|
JavaScript 前端开发
基于装饰器——我劝你不要在业务代码上装逼!!!(上)
基于装饰器——我劝你不要在业务代码上装逼!!!(上)
|
程序员
有了这些不愁找不到对象,520表白代码
有了这些不愁找不到对象,520表白代码
97 0
|
存储 JavaScript 前端开发
20个JS精简代码无形装逼集合,最为致命,记得收藏好
20个JS精简代码无形装逼集合,最为致命,记得收藏好
|
XML 缓存 Android开发
求知 | Android资源加载的那些事 - 小试牛刀
引言 聊到到 Android 的 资源加载 ,每一个开发同学都会非常熟悉,毕竟 getText() 等, 我们实在用了太多。 那如果此时问你,你知道 它们到底是怎么被加载的,内部会有什么处理吗? 为什么同一个drawable界面更改了透明度,其他界面也会生效?
156 0
求知 | Android资源加载的那些事 - 小试牛刀
|
数据采集 算法 Java
库调多了,都忘了最基础的概念-《线程池篇》
库调多了,都忘了最基础的概念-《线程池篇》
134 0
库调多了,都忘了最基础的概念-《线程池篇》