使用Memoize记忆性技术优化性能

作者:DoubleJan

前言:

我们在页面渲染的时候经常会遇到数据处理的需求,而每次props和state的变动都会引起页面重新渲染,数据处理需要重新执行。为了减少重复的数据处理,我们可以引入记忆性技术Memoize解决这种问题。

简介:

Memoize的基本处理原理是,通过判断函数上一次的传入参数和新一次的传入参数是否一致,去决定是否执行函数。如果前后两次传参一样,返回之前的缓存结果,否则则重新运行函数得到新的运算结果。这样做可以减少相同传参的数据处理,减少页面运算量,提高性能。

以下是一些比较常见的Memoize工具库:

MemoizeOne:www.npmjs.com/package/mem… Memoizee:www.npmjs.com/package/mem… Lodash: www.lodashjs.com/docs/latest… 以及性能比较结果:

单个入参:www.measurethat.net/Benchmarks/… 多个入参:www.measurethat.net/Benchmarks/…

MemoizeOne:

下面主要介绍一下MemoizeOne的原理和使用。

顾名思义,这个库对每一个实例都只缓存一个结果。先看官方给的例子:

import memoizeOne from 'memoize-one';
 
const add = (a, b) => a + b;
const memoizedAdd = memoizeOne(add);
 
memoizedAdd(1, 2); // 3
 
memoizedAdd(1, 2); // 3
// Add function is not executed: previous result is returned
 
memoizedAdd(2, 3); // 5
// Add function is called to get new value
 
memoizedAdd(2, 3); // 5
// Add function is not executed: previous result is returned
 
memoizedAdd(1, 2); // 3
// Add function is called to get new value.
// While this was previously cached,
// it is not the latest so the cached result is lost

从例子看出来,当传入参数完全一致的时候,add 函数是不会被调用的,memoizeAdd 函数直接返回上一次缓存的结果。而当传入参数改变了,则就重新运行 add 函数。

这里我们可以发现一个问题,入参的比对是如何处理的?例子中使用的参数都是字符串,使用了全等(===)对比,那入参是对象或者数组,就不能通过这个方法去对比了。对此,MemoizeOne提供了自定义匹配方法的配置。我们可以试试下面的用法:

import memoizeOne from 'memoize-one';
import { isEqual } from 'lodash'; 
 
const func= (list) => list.filter(item => !item.hidden);
const memoizedFilterList = memoizeOne(func, isEqual);
// 使用lodash的isEqual方法对传入参数进行深比较 


const func= (id, name) => `${id}-${name}`;
const compare = (prevArgs, nextArgs) => return prevArgs[0] === nextArgs[0]
const memoizedFilterList = memoizeOne(func, compare);
// 使用自定义方法对传入参数进行深比较 
// prevArgs和nextArgs对应原来的入参数组和新的入参数组,可以按自己的需要去定制比较方法
// 上例子中只比较传入id的值,id变动时才重新调用函数,name不作为判断条件

还有一点需要注意的是,this 也是作为需要比对的环境变量默认进行比对的,如果 this 的指向变了,memoize是不会使用上一次的缓存结果的,可以参照下面的例子:

function getA() {
  return this.a;
}
 
const temp1 = {
  a: 20,
};
const temp2 = {
  a: 30,
};
 
getA.call(temp1); // 20
getA.call(temp2); // 30

源码解析: memoizeOne是一个非常轻量的库,主要实现代码如下所示:

// areInputsEqual 
// 默认使用的比较函数

export default function areInputsEqual(
  newInputs: readonly unknown[],
  lastInputs: readonly unknown[],
): boolean {
  // 先比较入参数组的长度,如果不一样则不进行下一步对比,直接返回不同
  if (newInputs.length !== lastInputs.length) {
    return false;
  }

  // 如果入参数组长度相同,则使用 !== 比较里面每个参数是否相同
  for (let i = 0; i < newInputs.length; i++) {
    // using shallow equality check
    if (newInputs[i] !== lastInputs[i]) {
      return false;
    }
  }
  return true;
}

import areInputsEqual from './are-inputs-equal';

// Using ReadonlyArray<T> rather than readonly T as it works with TS v3
export type EqualityFn = (newArgs: any[], lastArgs: any[]) => boolean;

export default function memoizeOne<
  // Need to use 'any' rather than 'unknown' here as it has
  // The correct Generic narrowing behaviour.
  ResultFn extends (this: any, ...newArgs: any[]) => ReturnType<ResultFn>
>(resultFn: ResultFn, isEqual: EqualityFn = areInputsEqual): ResultFn {
  let lastThis: unknown;
  let lastArgs: unknown[] = [];
  let lastResult: ReturnType<ResultFn>;
  let calledOnce: boolean = false;

  // breaking cache when context (this) or arguments change
  function memoized(this: unknown, ...newArgs: unknown[]): ReturnType<ResultFn> {
	
	// 如果之前已经运行过这个函数,并且环境变量this没有改变,入参也相同,则直接返回上次结果
    if (calledOnce && lastThis === this && isEqual(newArgs, lastArgs)) {
      return lastResult;
    }

	// 否则则运行函数,并记录此次运行的this、入参和结果
    // Throwing during an assignment aborts the assignment: https://codepen.io/alexreardon/pen/RYKoaz
    // Doing the lastResult assignment first so that if it throws
    // nothing will be overwritten
    lastResult = resultFn.apply(this, newArgs);
    calledOnce = true;
    lastThis = this;
    lastArgs = newArgs;
    return lastResult;
  }

  return memoized as ResultFn;
}

实战: Memoize记忆性技术可以有效帮我们提高程序的性能,但这其实也是一个以空间换取速度的方法,并不是所有情况都适用。一般来说,涉及大量计算的纯函数、递归函数、图表数据处理等就可以有效利用这个技术;而当函数输出结果并不完全依靠入参,或者入参变动非常频繁,等等的一些情况可能就不太适合使用了。

在我们当前的后台项目中,框架里有一些地方是有使用MemoizeOne去做处理的,例如menu.js里面就有使用。菜单在我们进入后台之后变化是比较少的,但是经常会重新渲染,此时就适合使用这个方法去提高渲染性能:

import memoizeOne from 'memoize-one';
import { isEqual } from 'lodash';

/**
 * 格式化菜单
 */
function formatter(data, parentAuthority, parentName) {
  return data
    .map(item => {
      if (!item.name || !item.path) {
        return null;
      }

      // 如下是 菜单多语言设置
      let locale = 'menu';
      if (parentName) {
        locale = `${parentName}.${item.name}`;
      } else{
        locale = `menu.${item.name}`;
      }
      // if enableMenuLocale use item.name,close menu international
      const name = menu.disableLocal
        ? item.name
        : formatMessage({ id: locale, defaultMessage: item.name });
      const result = {
        ...item,
        name,
        locale,
        authority: item.authority || parentAuthority,
      };
      if (item.routes) {
        const children = formatter(item.routes, item.authority, locale);

        // Reduce memory usage
        result.children = children;
      }
      delete result.routes;
      return result;
    })
    .filter(item => item);
}

const memoizeOneFormatter = memoizeOne(formatter, isEqual);


/**
 * 获取面包屑映射
 */
const getBreadcrumbNameMap = menuData => {
  const routerMap = {};

  const flattenMenuData = data => {
    data.forEach(menuItem => {
      if (menuItem.children) {
        flattenMenuData(menuItem.children);
      }
      // Reduce memory usage
      routerMap[menuItem.path] = menuItem;
    });
  };
  flattenMenuData(menuData);
  return routerMap;
};

const memoizeOneGetBreadcrumbNameMap = memoizeOne(getBreadcrumbNameMap, isEqual);

总结:

Memoize技术可以有效帮我们解决日常开发里的性能问题,我们在开发过程中也可以思考一下,有什么地方,可以用什么方法去减轻计算、优化性能。