缓存与前端性能优化

前言

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

引子

一次小的优化改动

门店组件

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容

  • 前端优化 并发策略 缓存优化 数据库优化 如何构建一个高性能的Web应用, 其实是一个很大的话题。之前在公司内部对...
    曲水流觞TechRill阅读 890评论 0 14
  • 本章内容◆ I/O模型◆ nginx介绍◆ nginx安装◆ nginx各种模块实现web服务◆ nginx实现f...
    Liang_JC阅读 496评论 0 0
  • 性能优化是一门大学问,本文仅对个人一些积累知识的阐述,欢迎下面补充。 抛出一个问题,从输入url地址栏到所有内容显...
    前端一菜鸟阅读 216评论 0 2
  • 抛出一个问题,从输入url地址栏到所有内容显示到界面上做了哪些事? 1.浏览器向DNS服务器请求解析该 URL 中...
    Michael113c阅读 327评论 0 0
  • 1、从输入url到浏览器呈现页面中间经历了什么? 在输入url的时候,会进行本地历史记录和标签页的查询,提供模糊查...
    林思念阅读 2,487评论 2 24