# 项目《王者图鉴》中资源加载优化方面总结
# 1. ⽹⻚加载进度条
监听静态资源加载情况
可以通过 window.performance 对象来监听⻚⾯资源加载进度。该对象提供了各种⽅法来获取资源加载的详细信息。
可以使⽤ performance.getEntries () ⽅法获取⻚⾯上所有的资源加载信息。可以使⽤该⽅法来监测每个资源的加载状态,计算加载时间,并据此来实现⼀个资源加载进度条。
下⾯是⼀个简单的实现⽅式:
const resources = window.performance.getEntriesByType('resource'); | |
const totalResources = resources.length; | |
let loadedResources = 0; | |
resources.forEach((resource) => {if (resource.initiatorType !== | |
'xmlhttprequest') {// 排除 AJAX 请求 | |
resource.onload = () => { | |
loadedResources++;const progress = Math.round((loadedResources / | |
totalResources) * 100); | |
updateProgress(progress); | |
}; | |
} | |
}); | |
function updateProgress(progress) {// 更新进度条 | |
} |
该代码会遍历所有资源,并注册⼀个 onload 事件处理函数。当每个资源加载完成后,会更新 loadedResources 变量,并计算当前的进度百分⽐,然后调⽤ updateProgress () 函数来更新进度条。需要注意的是,这⾥排除了 AJAX 请求,因为它们不属于⻚⾯资源。当所有资源加载完成后,⻚⾯就会完全加载。
当所有资源加载完成后,⻚⾯就会完全加载。
实现进度条
⽹⻚加载进度条可以通过前端技术实现,⼀般的实现思路是通过监听浏览器的⻚⾯加载事件和资源加载事件,来实时更新进度条的状态。下⾯介绍两种实现⽅式。
- 使⽤原⽣进度条
在 HTML5 中提供了 progress 元素,可以通过它来实现⼀个原⽣的进度条。
<progress id="progressBar" value="0" max="100"></progress> |
然后在 JavaScript 中,监听⻚⾯加载事件和资源加载事件,实时更新 progress 元素的 value 属性。
- 使⽤第三⽅库
使⽤第三⽅库可以更加⽅便地实现⽹⻚加载进度条,如 nprogress 库
使⽤ nprogress 可以⾃定义进度条的样式,同时也提供了更多的 API 供我们使⽤,⽐如说⼿动控制进度条的显⽰和隐藏,以及⽀持 Promise 和 Ajax 请求的进度条等等。
# 本项目把资源托管在 github 上,实现了一个下载和解压资源的进度条功能:
- 数据来源:通过 useGetZip Hook 获取下载和解压的状态和数据。
useGetZip 是一个 自定义的 Vue Hook(Vue 3 中用于封装可复用逻辑的函数)。它的作用是处理文件的下载和解压过程,并返回相关的状态和数据。
P.S. Vue 3 的函数式组件本质是纯函数,无法直接维护状态。Hook 通过函数式 API(如 useState、useEffect)向函数式组件注入状态和副作用能力。Vue 编译器会扫描函数体中的 Hook 调用,将它们转换为对应的生命周期钩子调用和状态管理逻辑。当 Hook 内部访问响应式数据(如 ref 或 reactive 对象)时,Vue 会自动将其添加到依赖追踪列表中。Hook 是惰性求值的。Hook 将组件内的状态管理和副作用(如 API 请求、事件监听)抽象为可复用的函数。它是跨组件共享的:多个组件可直接调用同一 Hook,无需通过 props 或事件传递数据。每个 Hook 维护独立状态:即使多个 Hook 使用相同名称(如 useState ('count')),它们的状态也是完全隔离的。
- 下载与解压
(1)下载:这里使用了 Axios + $ResourceHttp + $RemoteHttp, 通过 Axios 的 Get 方法实现。ProgressEvent 是浏览器自带的 API, 代表了在数据传输过程中发生的进度事件,这些事件可以让开发者实时了解数据传输的进度,例如已经传输了多少字节、总共需要传输多少字节等信息。通过 onDownloadProgress 回调函数来实时获取下载进度。
(2)解压:依赖第三方库(如 JSZip),非浏览器原生功能。
(3)存储:依赖 IndexedDB(浏览器原生 API)。
export const ZipResource = ( | |
url: string, | |
onDownloadProgress: (progressEvent: AxiosProgressEvent) => void, | |
) => { | |
return $ResourceHttp.Get<any>(`/${url}`, { | |
onDownloadProgress, | |
}); | |
}; | |
export const ZipDatabase = ( | |
url: string, | |
onDownloadProgress: (progressEvent: AxiosProgressEvent) => void, | |
) => { | |
return $RemoteHttp.Get<any>(`/${url}`, { | |
onDownloadProgress, | |
}); | |
}; | |
const onDownloadProgress = (progressEvent) => { | |
const percent = (progressEvent.loaded / progressEvent.total) * 100; | |
console.log(`下载进度:${percent}%`); | |
}; |
Axios 的 onDownloadProgress 用于监听下载进度。
progressEvent:包含下载进度信息的事件对象。
(1) progressEvent.loaded:已下载的字节数。
(2) progressEvent.total:总字节数。
(3) progressEvent.progress:下载进度百分比(0 到 1)
- 使用第三方库 JSZip 解析 zip 文件
通过传入的 apiUrl 和 version 向服务器发起请求,下载 ZIP 文件。使用 request 函数进行下载,并监听下载进度,实时更新下载状态。
分块下载:通过监听下载流的进度,实时更新已下载的文件大小和百分比(回调函数传递 downloaded_size 和 download_progress)。下载的 ZIP 文件以二进制 Blob 形式暂存于内存,供后续解压使用。浏览器原生不支持 ZIP 解压,因此代码中依赖第三方库(如 JSZip)解析 ZIP 文件。
使用 JSZip 库加载下载的 ZIP 文件。遍历 ZIP 文件中的每个文件,过滤出符合特定扩展名的文件(如 .jpg、.mp3 等)。将每个文件解压为 Blob 对象,并生成一个 URL 用于访问该文件。
- 项目将解压后的数据缓存到本地 IndexedDB 中。
IndexedDB 是浏览器提供的本地存储技术之一,可以实现结构化数据存储,支持索引、事务,容量大(数百 MB),适合复杂数据持久化(如离线应用、游戏数据)。
将解压后的 Blob 对象存储在 IndexedDB 中,方便后续快速访问。
使用 ZipDatabase(一个封装了 IndexedDB 的工具)来存储和管理这些数据。
- 数据转换与存储
_blobTextToBase64:将 Blob 转换为 Base64 字符串,以便存储到 IndexedDB。
_base64ToObject:将 Base64 字符串解码为 JSON 对象(用于 JSON 数据包)。
- 数据绑定:
将解压后的资源 URL 或 JSON 数据绑定到响应式变量,供组件使用。
通过 useGetZip Hook 获取下载和解压的相关状态和数据,包括:
const { | |
zip_key_name, | |
zip_name, | |
downloaded_index, | |
zip_size, | |
zip_downloaded_size, | |
zip_download_progress, | |
zip_download_finish, | |
zip_decompression_progress, | |
zip_decompression_finish, | |
getZipList, | |
} = useGetZip(); |
进度更新: 使用 ref 变量实时更新下载和解压的进度
进度计算: 使用 computed 计算属性动态计算进度信息。
通过 computed 定义了一个计算属性 download_info,根据当前状态(下载或解压)返回不同的进度信息;参数 status 用于区分需要返回的信息类型:
(1) "进度条":返回进度百分比,用于设置进度条的宽度。
(2) "下载内容":返回当前操作的描述(如 “正在下载 XXX” 或 “正在解压 XXX”)。
(3) "进度信息":返回具体的进度信息(如 “已下载大小 / 总大小” 或解压进度百分比)。
如果下载未完成,显示下载进度;如果下载完成但解压未完成,显示解压进度;如果下载和解压都完成,显示 “加载完毕,祝你体验愉快。”
- 进度条渲染:通过动态绑定样式和内容,显示进度条和进度信息。
通过 @import url ("./index.less"); 引入样式文件,定义了进度条、下载列表等的样式。
# 2. 图片懒加载、图片模糊加载
- 效果:
英雄海报、皮肤海报、铭文贴图加载时,会读取本地下载的小图,加载大图时,将小图模糊,当大图加载完成后,替换为大图并去除模糊。
每一张皮肤海报包含了三种尺寸:小图 (100×100,用于模糊加载)、中图 (640×294,用于列表封面展示)、大图 (2351×1080,用于英雄详情页全屏皮肤背景)、4K 图 (用于查看大图及下载原图)。
所有图片列表都使用了懒加载,即进入可视区后才会加载图片,但做了一个防抖,必须停留可视区 250ms 才会加载,不会加载快速略过的图片。
- 实现:
Intersection Observer API 是⼀种⽤于异步检查⽂档中元素与视⼝叠加程度的 API。可以将其⽤于检测图⽚是否已经进⼊视⼝,并根据需要进⾏相应的处理。
需要为需要懒加载的图⽚设置占位符,并将未加载的图⽚路径保存在 data 属性中,以便在需要时进⾏加载。这些占位符可以是简单的 div 或样式类,⽤于预留图⽚的空间,避免⻚⾯布局的混乱。
- vBlurLoad 指令:
实现了一个名为 v-blur-load 的 Vue 自定义指令,用于实现图片的懒加载和模糊图到大图的平滑过渡效果:
(1) 懒加载:图片仅在进入视口时开始加载。
用户滚动到图片区域时,IntersectionObserver 触发加载逻辑。
const observer = new IntersectionObserver((entries, observer) => { | |
entries.forEach(async (entry) => { | |
if (entry.isIntersecting) { | |
observer.unobserve(el); | |
await readPromise; | |
// 其他逻辑... | |
} | |
}); | |
}); |
(2) 模糊预加载:先显示模糊图,加载成功后替换为大图。
通过 el.onload 和 el.onerror 监听模糊图加载状态
(3) 错误处理:大图加载失败时重试最多 3 次,超限后显示默认图。
使用 coverImg 预加载大图。
成功后替换 el.src,应用过渡动画。
失败则重试,超限后显示默认图。
const loadImg = () => { | |
// 当大图加载完成后,替换模糊图片并设置过渡动画 | |
coverImg.src = binding.value.img; | |
coverImg.onload = () => { | |
el.src = binding.value.img; | |
el.style.transition = "0.5s"; | |
// 当大图替换完成后,移除模糊效果 | |
el.onload = removeBlur; | |
binding.value.onSuccess?.(); | |
}; | |
// 当大图加载失败时,进行重试 | |
coverImg.onerror = () => { | |
retryCount++; | |
if (retryCount < 3) { | |
setTimeout(loadImg, 1000); | |
} else { | |
// 若重试次数超过 3 次,则显示未知图片并移除模糊效果 | |
el.src = _getMiscLink("unknown"); | |
removeBlur(); | |
binding.value.onError?.(); | |
} | |
}; | |
}; |
setTimeout (loadImg, 1000); 当图片加载失败时,延迟 1000ms 再进行重试,避免频繁请求。
(4) 过渡动画:通过 CSS 过渡实现模糊图到大图的平滑切换。
removeBlur ():通过 setTimeout 延迟移除模糊效果,确保动画流畅。
const removeBlur = () => { | |
el.style.filter = "blur(0)"; | |
setTimeout(() => { | |
el.style.filter = ""; | |
el.style.transition = el._transition; | |
}, 500); | |
}; |
- vLazyLoad 指令:
核心逻辑是:当图片进入可视区域并停留 250ms 后,才会加载图片。主要在 mounted 钩子中实现。
# 3. 虚拟列表
英雄列表、商城 - 碎片商店 - 英雄列表采用分页加载,皮肤列表、商城 - 碎片商店 - 英雄列表、商城 - 水晶商店 - 皮肤列表采用虚拟列表,图集列表采用瀑布流布局 + 分页加载,当屏幕尺寸缩小时,通过改变列表一行的个数来进行适配,虚拟列表和瀑布流布局都有适配。
虚拟列表的核心思想是:只渲染当前可见区域的数据,而不是渲染整个列表,从而减少 DOM 节点的数量,提升性能。
- 核心原理:
只渲染可见区域的数据:根据滚动位置,计算当前可见区域的起始索引和结束索引。只渲染这些索引范围内的数据,其他数据用空白填充(通过 paddingTop 和 paddingBottom 实现)。
动态调整渲染范围:监听滚动事件,当滚动位置变化时,重新计算可见区域并更新渲染的数据。
interface Props { | |
/** 数据列表 */ | |
data: T[]; | |
/** 一行的数目 */ | |
columnCount: number; | |
/** 是否暂无更多 */ | |
finish?: boolean; | |
/** 是否启用加载更多 */ | |
loadMore?: boolean; | |
/** 是否处于加载中 */ | |
loading?: boolean; | |
/** 间隔 */ | |
gap?: string; | |
/** 间隔多少行后更新列表,当遇到分页加载场景时,元素 {bufferLineCount} 行高度必须小于分页触发的高度,当分页数据加载完毕,会触发 renderItems 函数更新缓冲数量,bufferLineCount 数值过大会导致多次触发,产生列表抖动并造成 renderItems 频繁错误触发,也可以选择列表滚动到底部后加载更多 */ | |
bufferLineCount?: number; | |
} |
bufferLineCount:缓冲行数,用于在可见区域上下额外渲染一些数据,避免滚动时出现空白。
** 计算可视元素数量 */ | |
const visibleCount = computed(() => { | |
if (virtualListRef.value) { | |
return Math.ceil((virtualListRef.value.offsetHeight / itemHeight.value) * $props.columnCount); | |
} | |
return 1; | |
}); |
visibleCount:计算当前可见区域可以显示多少行数据。
公式:可见行数 = 容器高度 / 每行高度。
/** @description 渲染项目函数 */ | |
const renderItems = () => { | |
if (!virtualListRef.value || !fillListRef.value) return; | |
const data = $props.data; | |
const column_count = $props.columnCount; | |
// 由于间隔行数包含可视区上和可视区下面的缓冲,所需实际使用需要 * 2 | |
const bufferLineCount = $props.bufferLineCount * 2; | |
/** 获取滚动位置 */ | |
const scrollTop = virtualListRef.value.scrollTop; | |
/** 计算开始索引 */ | |
const startIdx = Math.max( | |
0, | |
Math.floor(scrollTop / itemHeight.value) * column_count - bufferLineCount * column_count, | |
); | |
/** 计算结束索引 */ | |
const endIdx = Math.min( | |
data.length, | |
startIdx + visibleCount.value + bufferLineCount * column_count * 2, | |
); | |
/** 新的渲染数据列表 */ | |
const newRenderedData = []; | |
for (let i = startIdx; i < endIdx; i += column_count) { | |
// 以列为单位创建元素 | |
newRenderedData.push(...data.slice(i, i + column_count)); | |
} | |
// 更新渲染数据 | |
renderedData.value = newRenderedData; | |
/** 计算顶部填充高度 */ | |
const topPadding = Math.floor(startIdx / column_count) * itemHeight.value; | |
/** 计算底部填充高度 */ | |
const bottomPadding = Math.floor((data.length - endIdx) / column_count) * itemHeight.value; | |
/** 设置顶部填充 */ | |
fillListRef.value.style.paddingTop = `${topPadding}px`; | |
/** 设置底部填充 */ | |
fillListRef.value.style.paddingBottom = `${bottomPadding}px`; | |
/** 更新上次滚动位置 */ | |
lastScrollTop = scrollTop; | |
}; |
renderItems:据滚动位置和缓冲行数,计算需要渲染的数据范围(startIdx 和 endIdx)。
更新 renderedData,只渲染 startIdx 到 endIdx 之间的数据。
通过 paddingTop 和 paddingBottom 模拟未渲染数据的高度,保持滚动条的准确性。
const handleScroll = (e: Event) => { | |
const el = e.target as HTMLElement; | |
const scrollTop = el.scrollTop; | |
$emit("scroll", scrollTop); | |
// 如果滚动距离超过一行高度,则重新渲染 | |
if (Math.abs(scrollTop - lastScrollTop) >= itemHeight.value) { | |
renderItems(); | |
} | |
}; |
handleScroll: 监听滚动事件,获取当前滚动位置。
- 如果滚动距离超过一行的高度(itemHeight),则调用 renderItems 重新渲染可见区域的数据。
const debounceUpdateStatus = _debounce(() => { | |
const child_el = fillListRef.value!.children[0] as HTMLElement; | |
if (!child_el) return; | |
itemHeight.value = child_el.offsetHeight; // 更新每行高度 | |
renderItems(); // 重新渲染 | |
}, 500); |
debounceUpdateStatus: 动态调整虚拟列表中每行的高度(itemHeight),并通过防抖函数(_debounce)对调整逻辑进行优化:在一定时间内,只执行最后一次调用。
- 防抖函数会等待一段时间( 500ms),确保高度不再变化后再执行调整逻辑,避免频繁调整。通过防抖函数,将多次调用合并为一次,减少不必要的计算和渲染,提升性能。
# 4. 分包下载
针对不同类型的资源(如音效包、图片包、视频包、数据包等)定义了不同的链接引用,并且分别调用 getMaterialZip 或 getDataZip 函数来下载和处理这些资源,每种类型的资源都是独立进行下载和管理的。
在 getZipList 方法中,不同类型的资源是按照一定顺序依次下载的,每次下载完成一个类型的资源后,才会开始下载下一个类型的资源。
async getZipList() { | |
await load(); | |
await getMaterialZip(audio_links, RESOURCE_NAME.ZIP_AUDIO, audio_version.value, "AUDIO"); | |
downloaded_index.value = 1; | |
await getMaterialZip(image_activity_banner_links, RESOURCE_NAME.ZIP_IMAGE_ACTIVITY_BANNER, image_activity_banner_version.value, "IMAGE_ACTIVITY_BANNER"); | |
downloaded_index.value = 2; | |
// 其他资源下载调用... | |
} |
await 关键字确保每个资源包的下载和处理完成后才会继续下一个资源包的下载
async 函数:async 关键字用于定义一个异步函数,该函数总是返回一个 Promise 对象。即使函数内部没有显式地返回 Promise,JavaScript 也会自动将返回值包装在一个已解决(resolved)的 Promise 中。
await 关键字:await 只能在 async 函数内部使用,它会暂停 async 函数的执行,直到其后面的 Promise 被解决(resolved)或被拒绝(rejected),然后返回 Promise 的解决值。这样就可以确保在 await 表达式之后的代码在 Promise 完成后才会执行。
# 5. 瀑布流布局
瀑布流的核心原理
将元素按列排列,每一列的高度动态调整。
新元素总是插入到当前高度最小的列中,确保布局紧凑。
关键点是计算每列的高度,以及动态调整元素的位置(top 和 left)
import { ref, type Ref } from "vue"; | |
interface Params { | |
/** 列数 */ | |
count: Ref<number>; | |
/** 间距 */ | |
gap: number; | |
/** 元素数量 */ | |
num: Ref<number>; | |
} | |
/** @description 瀑布流 */ | |
const useWaterfall = (obj: Params) => { | |
const { count, gap, num } = obj; | |
const ExposeData = { | |
/** 每个元素的 top 和 left */ | |
children_position: ref<{ left: number; top: number }[]>([]), | |
}; | |
const { children_position } = ExposeData; | |
const ExposeMethods = { | |
updatePosition: async () => { | |
if (num.value === 0) return; | |
/** 记录每列高度信息 */ | |
const height_List: number[] = []; | |
// 记录最小高度列的索引及高度,决定下一个元素的填充位置 | |
const min_column: Record<string, number> = { | |
minHeight: 0, | |
minIndex: 0, | |
}; | |
for (let i = 0; i < num.value; i++) { | |
const { offsetWidth: el_width, offsetHeight: el_height } = document.querySelector( | |
`#item-${i}`, | |
) as HTMLElement; | |
children_position.value[i] ??= { | |
left: 0, | |
top: 0, | |
}; | |
if (count.value > i) { | |
// 设置第一行的元素信息 | |
children_position.value[i].left = (el_width + gap) * i; | |
children_position.value[i].top = 0; | |
// 更新当前列的高度信息,用于下一行计算 | |
height_List.push(children_position.value[i].top + el_height); | |
} else { | |
/** 最小高度列的高度 */ | |
min_column.minHeight = Math.min(...height_List); | |
/** 最小高度列的索引 */ | |
min_column.minIndex = height_List.indexOf(min_column.minHeight); | |
// 通过计算出来的最小高度,将元素填充到对应列数位置 | |
children_position.value[i].top = min_column.minHeight + gap; | |
children_position.value[i].left = (el_width + gap) * min_column.minIndex; | |
height_List[min_column.minIndex] = children_position.value[i].top + el_height; | |
} | |
} | |
}, | |
}; | |
return { | |
...ExposeData, | |
...ExposeMethods, | |
}; | |
}; | |
export { useWaterfall }; |
- 数据结构:
children_position: Ref<{ left: number; top: number }[]> |
每个元素的位置信息是一个对象,包含 left 和 top 属性。
- updatePosition 函数
计算每个元素的位置,并更新 children_position。
逻辑:
(1)初始化列高度:
使用 height_List 数组记录每列的当前高度。
(2)处理第一行元素:
第一行的元素从左到右排列,top 为 0,left 根据列数和间距计算。
更新每列的高度。
(3)处理后续元素:
找到当前高度最小的列。
将新元素插入到该列中,并更新该列的高度。
(4)更新元素位置:
将计算出的 left 和 top 赋值给 children_position。
- 瀑布流布局优化细节:
如若频繁读取 DOM 元素(offsetWidth 和 offsetHeight),可能导致回流和重绘太频繁。
P.S. 回流是指浏览器重新计算元素的几何属性(如位置、大小),并重新构建渲染树的过程。重绘是指浏览器重新绘制受影响的部分页面,但不改变布局的过程。
优化方案:
(1)缓存 DOM 元素:一次性获取所有元素的尺寸,避免重复查询。
(2)使用 ResizeObserver:监听元素尺寸变化,动态更新布局。
(3)批量更新 DOM:使用 requestAnimationFrame 或 Vue.nextTick 减少重排和重绘。