美文网首页
缓存与前端性能优化

缓存与前端性能优化

作者: 景阳冈大虫在此 | 来源:发表于2021-11-22 19:49 被阅读0次

    前言

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

    引子

    一次小的优化改动

    门店组件
    比如这个组件,当门店数目为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

    相关文章

      网友评论

          本文标题:缓存与前端性能优化

          本文链接:https://www.haomeiwen.com/subject/logqtrtx.html