前言
关于前端性能优化,除了各种常见套路之外,对于特定业务场景下的性能优化也十分有趣。
引子
一次小的优化改动
比如这个组件,当门店数目为1w+时,每一次勾选的卡顿时间会特别长。
组件功能:左边的树为门店的全部展示,右边只展示已勾选的门店。
耗时:选择一家门店2079ms,反选5404ms。
分析一下
左右树的节点数目众多(达到1w+),如此庞大的Dom给页面渲染带来不小压力。
当每次产生勾选变化时,都会引发render
。而render
方法就是从头开始用循环和递归去构建树的Dom结构。
诚然,对于渲染,React已经使用diff算法对渲染进行了优化,所以不大可能再缩减渲染时间。但是对于左边的树来说,不论勾选的状态如何,Dom结构都不会发生改变。
改动
拿到全部门店信息之后,将左边树的Dom结构缓存下来,每次操作时直接return,就能起到一点优化的效果。
计时代码
let observer = new MutationObserver(() => {
console.log("timestamp", new Date().getTime());
});
observer.observe(document.getElementsByClassName("tree-container")[0], {
childList: true,
subtree: true,
attributes: true,
characterData: true,
});
observer.observe(
document.getElementsByClassName("ant-tree ant-tree-icon-hide")[0],
{ childList: true, subtree: true, attributes: true, characterData: true }
);
数据
优化后数据如下(总门店13409,单门店2000+)
操作 | 原耗时 | 现耗时 | 性能提升比例 |
---|---|---|---|
选择一家店 | 2079ms | 1963 | 5.58% |
反选一家店 | 5404ms | 5106 | 5.51% |
当然,门店1w+的商户还是比较少的,随手搜了一下,截至2021年9月星爸爸的门店数量为5000家。
有一点点的提升效果。
额外思考
想到缓存,这个组件还有一个比较严重的问题,就是当商户类型被判定为大门店时,门店内容将会以市为单位分批次请求回来。
也就是说,如果商户的门店分布在全国30个市,就有1(第一次请求省)+30(市)次请求。
对于这个组件来说,在同一个页面,用户每次点击按钮(如下图),都会触发门店弹窗的mount
,从而发起一次完全请求。
可是商户的门店并不是高频更新的,基本排除在操作这几分钟,用户门店突然发生变更的情况。况且接口是幂等的,并不需要如此频繁地请求接口。
想象一下,拥有全国连锁门店的大商户,停留在这个页面的每一次点击触发弹窗,都要忍受从头开始的几十次接口请求,这就让勾选门店这个基本操作变成了需要慎重考虑的事情。
对于幂等的接口,可以设计缓存来存储请求结果。
幂等接口的缓存
功能分析
对于这个缓存,功能比较简单,大概如下
- 淘汰策略:LRU算法
- 可支持同一接口的多param查询
- 实现简单易于维护
- 省空间
- 插入查找缓存性能足够好
案例分析
缓存的设计随处可见,可以参考一些常见的库带来启发的一些结构设计。
Redis的压缩列表
它并不是基础数据结构,而是 Redis 自己设计的一种数据存储结构。它有点儿类似数组,通过一片连续的内存空间,来存储数据。不过,它跟数组不同的一点是,它允许存储的数据大小不同。
对比一下普通的数组
普通的数组
一般的数组概念是,数组这个数据结构会占用一段连续的内存空间,所以按照下标能极快地查找到对应数据的内存地址从而读取数据。每个下标的占用位置是固定的,数组大小也是初始化之后固定的。
差异
js的数组可以存储不同的数据,大小不定,数组长度也可以不断变化。
Redis存储小的数据用的压缩列表和前者很像,用一个字段(图中的data_len)标识数据长度,接下来的字段来填写数据。这样这个数组看上去就能存储长度不同的数据了。
这带来一个启发,就是一个数组里存储的数据用途可以不同,不是非得data1-data2-data3这样,可以装一些描述性的字段去拓展数据的含义。
又想起一个例子
TCP报文结构
TCP报文大家都很熟悉,几乎每天都在接触:
TCP中的流指的是流入到进程或从进程流出的字节序列。
应用程序和TCP的交互是一次一个数据块,大小不等。应用数据会被分为多个数据块发送。接收方的应用程序必须有能力识别收到的字节流,把它还原成有意义的应用层数据。
在TCP中一个数据块的内容也是按照一定的规则放置的。这样即使传输的只是一串字节流,也能确保接收方应用程序可以按照规则把发送方的数据还原出来。
这和上面提到的压缩列表处理数据的办法是不是有一些相似。
Promise实现
这里使用bluebirdjs的promise一个片段来看一下,支持链式调用传入许多组callback的promise是如何存储的。
使用场景
这个场景就是,使用new Promise((resolve,reject)=>{}).then((resolve,reject)=>{}).then((resolve,reject)=>{})
这种方式传递的resolve和reject函数及其他信息存储规则为this[base + XXX]
,每一个then会占用四个位置。
如图:
代码
感兴趣就看代码,不看算了,没影响。
Promise.prototype._addCallbacks = function (
fulfill,
reject,
promise,
receiver,
context
) {
ASSERT(typeof context === "object");
ASSERT(!this._isFateSealed());
ASSERT(!this._isFollowing());
var index = this._length();
if (index >= MAX_LENGTH - CALLBACK_SIZE) {
index = 0;
this._setLength(0);
}
if (index === 0) {
ASSERT(this._promise0 === undefined);
ASSERT(this._receiver0 === undefined);
ASSERT(this._fulfillmentHandler0 === undefined);
ASSERT(this._rejectionHandler0 === undefined);
this._promise0 = promise;
this._receiver0 = receiver;
if (typeof fulfill === "function") {
this._fulfillmentHandler0 = util.contextBind(context, fulfill);
}
if (typeof reject === "function") {
this._rejectionHandler0 = util.contextBind(context, reject);
}
} else {
ASSERT(this[base + CALLBACK_PROMISE_OFFSET] === undefined);
ASSERT(this[base + CALLBACK_RECEIVER_OFFSET] === undefined);
ASSERT(this[base + CALLBACK_FULFILL_OFFSET] === undefined);
ASSERT(this[base + CALLBACK_REJECT_OFFSET] === undefined);
var base = index * CALLBACK_SIZE - CALLBACK_SIZE;
this[base + CALLBACK_PROMISE_OFFSET] = promise;
this[base + CALLBACK_RECEIVER_OFFSET] = receiver;
if (typeof fulfill === "function") {
this[base + CALLBACK_FULFILL_OFFSET] =
util.contextBind(context, fulfill);
}
if (typeof reject === "function") {
this[base + CALLBACK_REJECT_OFFSET] =
util.contextBind(context, reject);
}
}
this._setLength(index + 1);
return index;
};
Vue的keep-alive组件
keep-alive
用于缓存组件状态,常用于使用tab组件切换的时候。
那么keep-alive
是如何管理这些存储下来的VNode
节点的呢?
缓存结构
用一个Object去存储缓存数据,再额外使用一个数组去存储键值对中的key用以维护LRU策略。
策略
当缓存满了需要腾出空间时,这个keys
数组将排在队头的key到cache中找出来,把位置清掉。具体的流程如下图:
每次访问,若命中了缓存,则将keys中的那个key移到数组尾部,这样排在数组头部的就是最近最少使用的缓存key。
访问时缓存变化
最终设计
数据结构
- 这里和keep-alive不同之处在于:
keep-alive只有一对映射:key和VNode的映射;
而要设计的缓存有两对映射:serviceName和请求结果的映射,请求结果中有入参与出参的映射。 -
serviceName和请求结果的映射:
一个页面的接口是有限个的,且数量不多,暂时不需要使用LRU去管理,所以直接用的Object。
如果需要也可以加一个数组去维护serviceName的LRU策略。 - 入参与出参的映射组使用数组存储:
- 查找效率
考虑到入参与出参的映射组不会特别多,特殊的就像本例一样,再夸张也就上百了,这点数据量使用array.indexOf查找就已经足够。前一个为入参,后一个为出参成对存储删除。 - LRU
数据使用数组存储,而数组实现LRU很方便,不像键值对Object那样额外需要数组去实现LRU。
示例
数据
请求耗时对比:以一次打开弹窗为例,含11次接口请求
缓存前 | 缓存后 |
---|---|
5956ms | 27ms |
Code
Talk is cheap,show you code
- 缓存实现
function remove(arr: string[], index: number) {
// 移除一组数据
if (arr.length) {
return arr.splice(index, 2);
}
return arr;
}
interface OptionsType {
max?: number;
}
const MAX = Symbol("max");
const CACHE = Symbol("cache");
/**
* 幂等接口的缓存
* */
export default class ServiceCache {
static instance;
constructor(options: OptionsType = {}) {
if (typeof ServiceCache.instance === "object") {
return ServiceCache.instance;
}
ServiceCache.instance = this;
this[CACHE] = Object.create(null);
this[MAX] = options.max || 999; // 对于一个接口,最多要求存储多少个结果
}
/** 访问cache,有则返回,没有则调用sendRequest并添加进cache */
async visit(
serviceName: string,
paramsOrigin: any,
sendRequest: (param?: any) => any
) {
let params = paramsOrigin;
let res = null; // 缓存结果
if (typeof params !== "string") {
params = JSON.stringify(paramsOrigin);
}
if (!this[CACHE][serviceName]) this[CACHE][serviceName] = []; // 如果缓存没有这个接口,就给一个空数组
const serviceResArr = this[CACHE][serviceName];
if (serviceResArr) {
// 如果缓存里有这个接口
const index = serviceResArr.indexOf(params);
if (index > -1) {
// 如果这个接口结果arr里有这个请求入参
res = serviceResArr[index + 1];
remove(serviceResArr, index);
// serviceResArr like [...arr,params,res]
serviceResArr.push(params);
serviceResArr.push(res);
return res;
}
}
// 如果没找到则请求接口
res = await sendRequest(paramsOrigin);
serviceResArr.push(params);
serviceResArr.push(res);
if (this[CACHE][serviceName].length >> 1 >= this[MAX]) {
// 如果当前 数组长度/2 >=max
remove(serviceResArr, 0);
}
console.log(this[CACHE]);
return res;
}
get cache() {
return this[CACHE];
}
/** 移除相关缓存,传一个参数则移除整个serviceName对应的cache,传两个参数移除对应的入参的结果 */
remove(serviceName: string, paramsOrigin?: any) {
let params = paramsOrigin;
const serviceResArr = this[CACHE][serviceName];
if (params) {
if (typeof params !== "string") {
params = JSON.stringify(paramsOrigin);
}
if (serviceResArr?.length) {
const index = serviceResArr.indexOf(params);
if (index > -1) remove(serviceResArr, index);
}
} else {
this[CACHE][serviceName] = null;
}
}
}
- 引用
export function queryCityShopsCache(param) {
return serviceCache.visit(QUERY_CITY_SHOPS_URL, param, queryCityShops);
}
代码设计
- 使用单例模式确保多次import返回同一个实例,这样才能达到同一个缓存的效果;
- 使用
symbol
类型去对不希望暴露给外部随意访问和修改的变量做处理,达到私有变量的效果,再用get
修饰符去提供访问;
参考:
1. 极客时间《数据结构与算法之美》
2. https://github.com/isaacs/node-lru-cache/blob/master/index.js
3. https://react.iamkasong.com/hooks/create.html#%E6%9B%B4%E6%96%B0%E6%98%AF%E4%BB%80%E4%B9%88
4. 《计算机网络(第七版)》
5. vue2 keep-alive