一、背景
随着互动游戏业务 DAU 量级增加,性能和体验重要性也越发重要,好的性能和体验不仅可以增加用户使用体感,也可以增加用户对于互动游戏的使用粘性。
对现状分析,主要存在首屏渲染速度慢、打开页面存在白屏、页面加载过多资源等问题,核心手段是增加骨架、接口优先级调整、预渲染、减小包体积等。
优化后,互动游戏签到功能做到同类业务性能体验 Top 级别,下面是优化后数据:
- 首屏渲染速度:优化后提升首屏渲染速度 39%。
- 首屏骨架:骨架体积大小减少 44%(压缩后减少 50%)。
- 首次加载总资源:资源总体积优化后,大小减少 69%。
二、骨架
骨架屏是指在页面加载时,临时显示出页面的主要结构,可以让用户在等待页面加载完成时,得到视觉上的反馈,提升页面的用户体验。
图片
骨架示意图vs数据渲染
图片
图片
可以看出在接口返回数据之前,可以先使用骨架得到一些界面反馈。
三、缓存
虽然骨架屏可以让用户在视觉上得到反馈,毕竟不是真实的数据,总体还是有一些简陋,用户也可能并不知道这块区域实际渲染的是什么样的内容,若是网络环境不好,很可能会长时间的停留在骨架屏阶段,为了增强一些体感,使用缓存进一步对页面进行优化。
图片
使用缓存渲染具备以下优势:
- 与骨架屏相比,缓存渲染十分接近用户最终所见,因为每次接口返回数据都会更新缓存,用户再次进入时看到的都是自己上次进入时的数据。
- 当用户处在弱网或者断网等不可抗力的环境中时,可以得到较为完整的页面数据展示,可以很好减弱用户环境带来的网络营销。
使用缓存注意事项:
- 一些缓存渲染应屏蔽事件响应,避免造成不必要的报错和客诉。比如商品的缓存渲染,由于商品存在下架、优惠券调整等情况,缓存的数据和实际数据会存在一定的偏差。
- 缓存渲染逻辑需要更加前置,不应该将缓存渲染的逻辑放在原本的位置,这样会拖慢渲染的时机。
四、接口后置
浏览器对同一时间内的请求数量是有限制的,既并发请求限制。当一个页面首次渲染时需要浏览器发起很多接口请求,用于填充页面渲染需要的数据,若是对于页面渲染时的请求数量不加以控制,便可能导致一些问题出现。
现在有 home 和 info 两个接口,home 接口返回的数据是首屏渲染需要依赖的,info 接口返回的数据则不是首屏必须依赖的。假设现在还有一些其他请求占据了并发请求限制的数量,导致 home 接口请求变慢。
图片
若是 info 接口响应慢,长时间占据这浏览器的请求进程,会导致页面首屏渲染速度更慢,那么就需要有个一套方案可以根据接口的优先级进行加载顺序控制,可以将顺序变为如下。
图片
方案:当页面加载完成后一定时间后,进行低优先级接口的请求,或者触发页面的滚动、点击等时立即进行接口请求。
此方案适用于:确定接口延迟加载并不会阻塞用户的交互和操作。
将其封装为一个 hooks,便于复用,直接先看代码再解释:
import { useRM, createRM } from 'xxx'
const listen = (type: string, listener: () => void) => {
const l = () => {
listener()
document.removeEventListener(type, l)
}
document.addEventListener(type, l)
}
const pageFlowModule = createRM(
{
assemble(state) {
const reactionObserver = () => {
state.isUserReactioned = true
}
;['scroll', 'mousedown', 'touchstart'].forEach((type) => {
listen(type, reactionObserver)
})
setTimeout(reactionObserver, 4000)
},
},
{ isUserReactioned: false },
)
pageFlowModule.actions.assemble()
export const usePageFlow = () => {
const [state] = useRM(pageFlowModule)
return state
}
使用:
import { usePageFlow } from 'xxx'
const Demo = () => {
const { isUserReactioned } = usePageFlow()
const fetchHanlder = useCallback(() => {
// 接口请求数据
}, [])
useEffect(() => {
if(isUserReactioned) {
fetchHanlder()
}
}, [isUserReactioned, fetchHanlder])
return <div>{/* 渲染接口返回的数据 */}</div>
}
从上面代码可以看到,会将一些非首屏需要的请求后置,后置的接口可以在页面加载完成 4s 后自动触发调用,也会在用户有触屏、滚动页面等行为的时触发接口的调用。
五、骨架优化
签到和许愿树目前主文档中除了骨架部分还包含了一些公共的 JS 和 CSS,对不同资源类型进行拆分、汇总后发现,不管是签到还是许愿树,实际包含 HTML + JS 部分仅占极小比例,大量的流量消耗在了 CSS 上。
对 HTML 中 CSS 部分再进行梳理发现,文件中包含的除了骨架的 CSS 部分和公共组件库的 CSS 部分之外,还包含了大量弹框的 CSS。这三类中,骨架的 CSS 要保留,公共组件库的 CSS 可以拆分但是难度较大,剩下的就是弹框或者非骨架部分的 CSS。
- 需要把弹框部分组件做异步加载,保证预渲染的时候这部分 CSS 文件不会被加载到。
- 拆分骨架组件,把骨架组件从业务组件中剥离,预渲染的时候只渲染和加载骨架部分,不加载其余主文件部分 CSS,进一步缩小骨架。
图片
六、localStorage性能问题
在做优化之前,并未意识到 localStorage 所隐藏的性能问题,业务中使用了大量的本地存储,使用 Performance 记录一下存储消耗的时间。
记录核心代码:
export const setMallFlowStoreData = (data: any) => {
performance.mark('start_localstorage_operation')
// localStorage 操作.....
performance.mark('end_localstorage_operation')
performance.measure('localstorage_operation_duration', 'start_localstorage_operation', 'end_localstorage_operation')
}
输出记录的时间:
const entries = performance.getEntriesByName('localstorage_operation_duration')
const TOTAL_TIME = entries.reduce((current, next) => {
return current + next?.duration
}, 0)
console.log('全部记录:', entries, '共耗时:', TOTAL_TIME)
输出结果:
可以看到通过 localStorage 进行一次存储操作,大致需要耗时 0.2-0.5ms 之间,若是当页面存在大量的前端的存储操作时,低端机型在存储操作上消耗甚至达到 10-20ms,若是代码写的不合理,导致页面 reload、反复触发获取操作等情况,这个时间又将会成倍的增加。
接下来先一起看看为何会存在性能方面的问题和解决方案。
存储数据
问题:
localStorage 的存储是同步的操作,因此在存储大量数据时,可能会导致阻塞 UI 线程,影响用户体验。
方案:
核心思路便是将同步操作转换为异步操作,这样就不会阻塞 UI 线程。
- 使用 Web Worker ,会增加一些项目维护的复杂度,且其是 HTML5 标准中新增的技术,存在一定的兼容性(ChatGPT 给的,应该是错误答案,并未在 MDN 中看到)。
图片
- 使用 setTimeout、setInterval,兼容性绝对的好,但是并未从根本解决问题。
- 不用 localStorage,直接上 IndexDB,但是由于代码项目原因,不能改动原有的太多逻辑。
综合解决方案和历史原因,只能退而求其次选择 setTimeout 的方式解决这个问题。
读取数据
问题:
每次读取 localStorage 数据时,都需要从磁盘中读取数据,因此在处理大量数据时,可能会出现性能问题。
方案:
可以将数据进行放到内存中缓存处理,在用户的整个操作周期内只从 localStorage 获取一次数据,需要注意的是每次对数据进行操作时,需要将 localStorage 和内存缓存的数据同步更新。
数据类型转换
问题:
在存储和读取数据时,需要将数据进行序列化和反序列化操作。这些操作可能会导致性能问题。
方案:
使用 JSON.stringify() 和 JSON.parse() 函数来处理数据的序列化和反序列化。
经过对 localStorage 存储优化以后,在红米 note 11 上面进行了简单测试,首屏打开速度提升,对于整体提升首屏提升约 2%。
七、动效执行时机
页面存在渐入渐现的动效,在页面首次加载时,由于渐现动效的存在,会延迟用户感知该模块,从而导致感觉页面存在更多时间的白屏,动效如下:
图片
核心问题是首次渲染直出 DOM 结构,不走渐现动效便可,这个比较偏向于逻辑处理,属于体验优化的范畴,主打的就是在后续有相关首屏动效时,有意识对其做一下处理,保证首屏首次渲染的完整读。
八、渲染模块的取舍
首先看一下两种状态各自的样式:未签到 VS 已签到。
图片
签到业务的日历会根据用户当天签到状态进行渲染,存在已签到和未签到两种渲染逻辑,由于当前的架构限制,并不能在预渲染时感用户的签到状态,导致日历部分的渲染会滞后,严重影响页面的首屏渲染速度。
第一版本优化
将签到状态进行缓存,当用户进入签到时的大致流程如下:
图片
当用户进入页面时,会优先获取缓存中的数据进行渲染,确保用户可以第一时间看到日历部分的渲染,这里需要注意:
- 缓存需要结合用户 token 一起判断,避免造成切换账号时造成数据污染。
- 若是用户第一次进入或者当天未签到,会使用系统时间作为小日历上的数字展示,当用户修改了系统时间设置时,日期判断会存在误差。
缓存数据必然会先于接口响应数据,因此页面第一时间看到的肯定是缓存数据(没有缓存数据,会默认使用未签到数据)所渲染的页面,那么当接口响应完成时,需要使用真实的数据触发页面的 rerender,需要注意处理,避免造成页面闪烁。
虽然这样做可以提高页面的渲染体感,当进入页面时,顶部区域还是会存在一定时间的空白,毕竟还是需要执行 JS 后才能执行骨架渲染逻辑,本质提升速度为:接口响应时间 - JS 执行时间,在低端机表现会较为好一些,高端机体感并非太明显。
第二版优化
日历部分由于已签到和未签到的样式存在着较大的出入,不能像某些竞品一样:已签、未签的整体页面布局并未有区分,使用一套公用的渲染逻辑,这样也导致签到业务需要将渲染日历部分的动作滞后,那么核心就是怎么解决这个问题。
综合考虑后,决定将未签到样式作为预渲染时直接生成 DOM,这样可以保证用户未签到的状态下进入到页面可以第一时间对的状态,也可以更快的完成首屏的渲染。
若是用户已签到,便在此基础之上复用今日签到的逻辑,就是会在签到完成后展示一个小的动效,将小日历变成大日历的样式。这样做的好处可以是获取到用户真实状态后,自动切换到大日历状态,效果如下。
图片
结合用户行为分析:多数用户一天不会多次访问,也就是在即不怎么牺牲高频率访问用户的体验之下,提高了绝大多数用户的体验。
九、首屏数据优先请求
前置小知识:最大并发请求数
为了避免浏览器过度占用系统资源,浏览器对于同一域名下的请求数量是有一定限制的,也就是常见的浏览器最大请求数量。
以 Chrome 浏览器举例:同一域名下,HTTP 协议最多允许同时存在 6 个 TCP 连接进行,HTTPS 协议最多为 4 个。
业务现状
签到进入页面共计加载许多接口。
其中首屏渲染需要的几个核心接口如图红色标记所示,核心的接口滞后会导致页面数据渲染的更慢,严重影响体验,那么到底影响多少呢?可以在浏览器 Network 中查看 Waterfall。
图片
核心接口是在其他完成后开始,是因为其没有赶上浏览器第一批次接口请求队列中,需要等待前面某些接口结束后,才会将其放到请求队列中。
动作
有了问题,接下来便是如何做:
- 首先是制定方案,如何确保接口的请求可以搭上浏览器请求队列的第一班车,本质是将之前散落在各个组件内的 useEffect 中的初始化逻辑进行提取,统一触发。
- 梳理接口和首屏渲染的关联度,确定哪些接口的优先级权重更高。
核心代码如下:
export const StartModule = createRM(
{
init() {
SigninTopModule?.actions?.getHomeData()
AdModule?.actions?.reqAdInfoList()
HomeModule?.actions?.getBubbleList()
},
}
)
在页面初始化时执行 StartModule?.actions?.init(),将核心接口优化执行,通过控制接口请求顺序,签到业务在此提升了大致 6-8% 的首屏渲染速度。
十、字体使用和优化
字体加载和优化是前端开发中的一个重要问题,特别是在移动端和低网络状况下。下面是一些字体加载和优化的技巧。
FOUT问题
通过设置 Font-Display 属性可以控制字体加载时的显示效果,包括 Auto、Swap、Block、FallBack 和 Optional 几种模式,可以减少字体加载时间和防止文本闪烁。
设置属性为FallBack时效果:
图片
可以看到日期存在明显的 FOUT(无样式文本闪现)问题,设置 Swap 也是类似效果,并不符合预期。
设置属性为 Block 时效果:
图片
可以看到第一时间并没有渲染日期,而是有点的短暂空白,因为其可以避免 FOUT,字体文件必须在后台下载完全后,文本才能显示。
最终选择了 font-display: block;效果会更好一些。
注意,并不是整个页面都使用 Block 属性,对于一些非首屏关键渲染的样式,使用 fallback 更为合适一些,因为其会使用浏览器默认字体,所以还是需要结合业务、场景合理使用。
字体库大小,你得懂
先看一个 GPT 对于签到业务常用字体库打下的统计:
- DIN Condensed 字体库的大小在几百KB 到几MB之间
- Helvetica Neue 字体库的大小在几MB到十几MB之间
也就是这两种字体的大小,如果不加以处理,全部加载的大小在几 MB 到十几 MB 之间,对于前端项目而言,这是挺夸张的一件事。
可以和设计人员沟通,将字体库中常用的字体导出,前端项目仅仅引入需要的字体就好,比如 DIN Condensed 字体都是使用在阿拉伯数字上,并不会在其他字上使用,那么只需要将阿拉伯数字导出即可。比如汉字,根据《现代汉语通用字表》(GB/T 13000-2018),常用汉字(包括简体字和繁体字)共计 3500 个,其中常用的一般是指前 1000 个左右的汉字,那么在使用字体库的时候,是不是可以默认只需要导出部分即可。
经过处理后的字体库大小如下图:
图片
字体库数量,你得控制
上面说了一个字体库的大小是多大,就算是经过处理,最少也会有 30KB 大小,所以项目引入的字体种类是需要控制的,不能设计同学使用了多少种类字体设计,我们就要照单全收。
当设计同学新增字体库时,如果字体使用在 3 次以内,是不是可以使用图片来代替文字,或者使用现有的字体库来平替。
十一、慎用三方库
业务中存在一些简单的校验、转换和动效并不需要引入三方库,尤其是因为一个较为简单的功能引入了一个较为大且冷门的库时,不仅会增加项目的打包体积,还会增加项目后续维护的沟通、学习成本。
例如下面一个简单切换动效:
图片
是一个比较常规的切换动效,却在项目中引入了一个第三方库来实现,该库的使用也是有一些学习成本,因为其具备实现比较复杂的动效能力,在业务动效具备一定复杂度且非首屏的场景下,是可以考虑引入使用的,否则类似这种首屏便需要加载的动效,还是慎重。
上述的切换动效 CSS 实现代码如下:
@keyframes bigScale {
0% {
opacity: 0;
transform: scale(0.95);
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes smallScale {
0% {
transform: scale(1);
opacity: 1;
}
to {
transform: scale(0.95);
opacity: 0;
}
}
.squareInCenter {
animation: 0.3s linear 0s 1 normal forwards running bigScale;
}
.squareOutCenter {
animation: 0.3s linear 0s 1 normal forwards running smallScale;
}
在业务开发的过程中,尤其是 C 端的页面,在实现功能时对于引入额外的库是一件需要十分谨慎的事情,在内部就看到不少项目在引入关于日期处理方面的库时,DayJS、MomentJS 同时都会引用到项目中,B 端项目都不能忍,更何况 C 端项目。
十二、总结
本文仅仅介绍得物前端增长团队在互动游戏侧一些体验优化实践心得,后续还在不断迭代和优化,将实践经验应用扩大至多个业务中,将整个互动游戏性能体验优化至 TOP 级别。