performance optimize rethink
• • ☕️ 4 min read你可以从本文了解到以下方面的优化思路
- 图片
- 字体
- 长列表
- 优先加载
背景
在混合 H5 项目 A 中,有一个大量图片的信息流页面,并且需要在多个 Tab 切换。如下图所示(已脱敏)。
存在的问题主要有:
- 图片尺寸大,下载慢。非常占用 Android 壳的缓存,约 70–80M。
- 切换 Tab 卡(尤其在长列表间切换的时候)
- 自定义字体占据 30M,字体 swap 时间长
- 长列表越往下滑动越卡。
优化策略
图片
核心:体积控制
- 尺寸在看得清的情况下尽可能小(size 够用就行)
- 高压缩比格式(quality 能接受就行)
- 尽可能少的研发
方案 1 (❌)
自己压缩全部图片 优点:自主可控 缺点:灵活性差和研发部署成本高
方案 2(✅)
使用第三方图片存储的实时处理能力(百度云 BOS)
优点:灵活性好和研发部署成本低 缺点:相对低的可控性
效果
加载速度提升 26 倍,体积缩小 95%
代码片段
import canUseWebP from './canUseWebP'; //BOS 图片优化 https://cloud.baidu.com/doc/BOS/s/Zk2l2mq0v // small 350 // mid 640 // large 1280 // size向上靠拢,尽可能应用缓存 const getLevelUpSize = (size: number) => { if (size <= 350) { return 350; } else if (size <= 640) { return 640; } else { return 1280; } }; const getBosPicUrl = (src, sizeInPx = Infinity, ImgQuality = 50) => { const isBosPic = src.indexOf('bcebos') !== -1; if (!isBosPic) return src; // 不是bos图,不处理 const format = canUseWebP ? 'f_webp' : 'f_jpg'; const dpr = window.devicePixelRatio; const size = 'w_' + Math.floor(getLevelUpSize(sizeInPx) * dpr); const quality = 'q_' + ImgQuality; // const display = 'd_progressive'; const command = [size, format, quality].join(','); return `${src}@${command}`; }; export default getBosPicUrl;
字体
痛点
体积非常大,动辄 30M,卡慢,体验差
策略
Android 壳 cacheWebview 内置字体文件,通过自定义拦截器路径拦截
效果
字体渲染提速 5 倍
前端CSS代码片段
@font-face { font-family: 'NotoSansCJKsc-Medium'; /* src: url('./assets/fonts/NotoSansCJKsc-Medium.otf') format('opentype'); */ src: local('NotoSansCJKsc-Medium'), local('Droid Sans Fallback'), url('YOUR_DOMAIN/FILE_NAME'), url('FALLBACK_URL'); font-weight: bold; font-display: swap; }
长列表
虚拟渲染-原理
只渲染用户视口区域的元素,其他区域仅撑开高度保持滚动条位置准确。
在 React 下面可以采用react-window.
简单看一下react-window核心数据结构
type ItemMetadata = {|
offset: number, // 当前item距离屏幕顶部的距离
size: number, // 当前item的高度
|};
// 一个window实例的数据结构
type InstanceProps = {|
itemMetadataMap: { [index: number]: ItemMetadata }, // 缓存已计算过的item
estimatedItemSize: number, // item默认高度
lastMeasuredIndex: number, // 最后计算的item
|};
render 函数
<Wrap>
<AutoSizer>
{({height, width}) => (
<VariableSizeList
height={height}
itemCount={this.rowData.length}
itemSize={this.getItemSize}
itemData={this.rowData}
itemKey={this.getItemKey}
width={width}
ref={(node) => (this.listEl = node)}
onScroll={this.onScroll}
>
{this.renderRow}
</VariableSizeList>
)}
</AutoSizer>
<ScrollToTop
onoff={this.state.isRrocketShow}
scrollCB={this.hideRocket}
target={this.listEl}
/>
</Wrap>
我们需要提供的数据
- itemData(列表全数据)
- 滚动事件(一方面组件内部使用),控制回到顶部小火箭的显隐。
- itemKey 方法,确定每个元素在 list 中唯一的标识符号(react 渲染用)
- itemSize 方法,获取每个元素的 height 基于这些,虚拟渲染组件会合成一个 itemMetadataMap.
从 List 到 Gird
业务需要对内容定制。一个是横高度不固定,底部会有菊花。所以对内容的类型进行了分类。
Array:一行卡片。其中每个卡片的类型是FEED_ITEM,4个一行,计算生成。
FEED_SUBJECT:XX标题
TOP_MARGIN:顶部占位块(纯布局用)
FEED_LOADER:底部更多菊花
// list数据模型的生成
getRowData = () => {
const {activeKey, tabsData} = this.props;
const list = tabsData[activeKey];
let rowData = [];
rowData.push({
type: ELEMENT_TYPES.TOP_MARGIN,
});
for (let i = 0; i < list.length; i++) {
const item = list[i];
if (
i === 0 ||
list[i - 1].teachingMetadata[0]['subject'] !==
item.teachingMetadata[0]['subject']
) {
rowData.push({
type: ELEMENT_TYPES.FEED_SUBJECT,
text: item.teachingMetadata[0]['subject'],
isFirstSubject: i === 0,
});
}
let last = rowData[rowData.length - 1];
if (last instanceof Array && last.length < 4) {
last.push({
type: ELEMENT_TYPES.FEED_ITEM,
...item,
});
} else {
rowData.push([
{
type: ELEMENT_TYPES.FEED_ITEM,
...item,
},
]);
}
}
if (this.getFeedLoader()) {
rowData.push({
type: ELEMENT_TYPES.FEED_LOADER,
});
}
return rowData;
};
// 渲染某一行
renderRow = memo(({data, index, style}) => {
// const rowData = this.rowData; //this.getRowData();
const item = data[index];
if (!item) {
return null;
}
console.log('renderRow::item', item);
if (item instanceof Array) {
return (
// ...
);
} else if (item.type === ELEMENT_TYPES.FEED_SUBJECT) {
return (
// ...
);
} else if (item.type === ELEMENT_TYPES.TOP_MARGIN) {
return (
// ...
);
} else if (item.type === ELEMENT_TYPES.FEED_LOADER) {
return (
// 菊花
<div style={style}>
<FeedLoader></FeedLoader>
</div>
);
}
}, areEqual);
// 计算行高
getItemSize = (index) => {
const row = this.rowData[index];
if (row instanceof Array) {
return (16.44 + 2) * window['remBase'];
} else if (row.type === ELEMENT_TYPES.FEED_SUBJECT) {
return (2.75 + 1.5) * window['remBase'];
} else if (row.type === ELEMENT_TYPES.TOP_MARGIN) {
return 1.5 * window['remBase'];
} else if (row.type === ELEMENT_TYPES.FEED_LOADER) {
return 4.4 * window['remBase'];
}
};
菊花组件
由于虚拟渲染天生基于观察者模式,因此菊花可以在 didMount 时候发起网络请求。
class FeedLoader extends PureComponent<Props, State> {
state: State;
static defaultProps = {};
render() {
return <BottomLoading />;
}
componentDidMount() {
console.log('FeedLoader fetching data::');
this.loadMore();
}
loadMore = () => {
HomeControl.loadMore(); // 发起网络请求
};
}
export default FeedLoader;
但这样做存在一个问题,在顺序加载过一次list项目之后,菊花的行高会被缓存在itemMetadataMap(offset,size)中。这使得我们后来数据到达之后将菊花替换成feeditem之后所占用的行高没有改变,造成样式重叠。
具体可以通过研究源码得知整个渲染的顺序如下:
- GetRowData:获取list数据
- GetItemSize:预分配行高(立即缓存)
- RenderRow:渲染行元素
因此,我们可以在新数据插入之后,重置菊花位置的高度
this.listEl.resetAfterIndex(this.rowData.length - 1);
优先加载
问题描述
浏览器并发连接数限制,在正常顺序加载的模式下,用户可能滑动了一屏,会等很久才刷出图,造成用户体验较差。
方案原理
1.用户看到图片占位图,开始加载。
2.图片还没加载完,就超出可见区域,抛弃。
如何正确抛弃
1.一个图片在 set 了 src 之后,一旦开始,则无法结束(即使是将图片从 DOM 中移除,网络连接也并不会立刻断开)。 需要更为激进的策略,即将图片的 src 置空,从而抛弃未完成加载的图片。
2.可见性管理+图片加载的生命周期管理
由于懒加载组件用了 react-lazy-load-image-component,然后在 Github 上有同学提了功能需求的issue,然后我魔改了一下
还可以改进的地方
可以知道,react-window中渲染过的元素进行过一次高度计算后,就会缓存,所以在window进行resize的时候,需要重新计算高度。因为产品场景没有这方面需求,所以没做,但从理论上是有考虑的。
总结
对于问题
- 搞清楚问题是什么
- 分清楚主要问题和次要问题
- 问题的主要方面和次要方面
对于某个具体技术
- 掌握原理(文档/源码)
- 搞清数据结构关系
- 优先使用第三方/开源产品
- 自己的贡献