缓存与前端性能优化

前言

关于前端性能优化,除了各种常见套路之外,对于特定业务场景下的性能优化也十分有趣。

引子

一次小的优化改动

门店组件

比如这个组件,当门店数目为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,从而发起一次完全请求。

组件触发按钮

可是商户的门店并不是高频更新的,基本排除在操作这几分钟,用户门店突然发生变更的情况。况且接口是幂等的,并不需要如此频繁地请求接口。
想象一下,拥有全国连锁门店的大商户,停留在这个页面的每一次点击触发弹窗,都要忍受从头开始的几十次接口请求,这就让勾选门店这个基本操作变成了需要慎重考虑的事情。

对于幂等的接口,可以设计缓存来存储请求结果。

幂等接口的缓存

功能分析

对于这个缓存,功能比较简单,大概如下

  1. 淘汰策略:LRU算法
  2. 可支持同一接口的多param查询
  3. 实现简单易于维护
  4. 省空间
  5. 插入查找缓存性能足够好

案例分析

缓存的设计随处可见,可以参考一些常见的库带来启发的一些结构设计。

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 callback结构

代码

感兴趣就看代码,不看算了,没影响。

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节点的呢?

缓存结构

keep-alive的数据结构

用一个Object去存储缓存数据,再额外使用一个数组去存储键值对中的key用以维护LRU策略。

策略

当缓存满了需要腾出空间时,这个keys数组将排在队头的key到cache中找出来,把位置清掉。具体的流程如下图:

缓存存储及更新流程

每次访问,若命中了缓存,则将keys中的那个key移到数组尾部,这样排在数组头部的就是最近最少使用的缓存key。

访问时缓存变化

最终设计

数据结构

  1. 这里和keep-alive不同之处在于:
    keep-alive只有一对映射:key和VNode的映射;
    而要设计的缓存有两对映射:serviceName和请求结果的映射,请求结果中有入参与出参的映射。
  2. serviceName和请求结果的映射:
    一个页面的接口是有限个的,且数量不多,暂时不需要使用LRU去管理,所以直接用的Object。
    如果需要也可以加一个数组去维护serviceName的LRU策略。
  3. 入参与出参的映射组使用数组存储
  • 查找效率
    考虑到入参与出参的映射组不会特别多,特殊的就像本例一样,再夸张也就上百了,这点数据量使用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);
}

代码设计

  1. 使用单例模式确保多次import返回同一个实例,这样才能达到同一个缓存的效果;
  2. 使用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

你可能感兴趣的:(缓存与前端性能优化)