前言
最近学习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); }); 复制代码
基本思路
这里先看看几个资源属性,
- key: 资源的唯一标记
- ver: 资源的版本标记
- pre: 资源加载的前置项, 比如react-dialog的依赖项 ["react", "react-dom"]
- url: 资源的地址
我们来过一下流程:
- 检查资源是否加载完毕
如果记载完毕,直接触发completed事件 - 从indexedDB载入缓存
- 检查是否有可加载的资源
如果有,进入4
如果没有,检查是否均加载完毕, 加载完毕触发complete事件
- 检查资源是否有前置依赖项
如果没有,进入5
如果有,进入3
- 检查是否有缓存
如果有,更新内部状态,触发progress事件,如果加载完毕,直接触发completed事件, 反之进入3
如果没有,发起网络请求, 下载资源,存入indexedDB,更新内部状态。 触发progress事件,如果加载完毕,直接触发completed事件, 反之进入3
备注:
- 如果某个被依赖的项加载失败了,目前的处理是,把依赖他的项都设置为error状态。
实现-工具方法
工欲善其事,必先利其器。 先撸工具方法。
我们需要网路请求,对象复制(不破坏传入的参数),参数的合并,检查key值等。
- 网络请求
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()); } 复制代码
- 版本比较
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 } 复制代码
- 对象复制
function copyObject(obj){ return JSON.parse(JSON.stringify(obj)); } 复制代码
- 生成资源地址
function generateBlobUrl(blob){ return URL.createObjectURL(blob); } 复制代码
- 检查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)); } } 复制代码
问题和后续
- 语法过于高级,需要使用babel转换一下。
- fetch状态码的处理,用XMLHttpRequest替换方案。
- indexedDB不被支持的妥协方案。
- ......