React性能优化小贴士

4,873 阅读11分钟

平常在完成业务功能开发后,不知道你是否还会精益求精,做一些性能优化方面的工作呢?React框架中有一些性能优化相关的注意事项,如果平常不怎么关注的话,可能就很容易忽略掉。接下来的这篇文章,将围绕工作中会用到的几种性能优化的相关经验进行介绍。

Key

在渲染列表结构数据的时候,使用key可以说已经成为React开发中的最佳实践了。那么你知道为什么我们要使用key吗?原因是使用key能够让组件保持结构的稳定性。我们都知道React以其DOM Diff算法而著名,在实际比对节点更新的过程中带有唯一性的key能够让React更快得定位到变更的节点,从而可以做到最小化更新。

在实际使用过程中,很多人常常图方便会直接使用数组的下标(index)作为key,这是很危险的。因为经常会对数组数据进行增删,容易导致下标值不稳定。所以在开发过程中,应该尽量避免这种情况发生。

下面以商品列表组件为例,演示一下key的使用:

class ShopMenu extends React.Component {
    render() {
        return (
            <ul>
                {
                    this.props.shopItems.map((shopItem) => <ShopItem key={shopItem.id} itemName={shopItem.name}></ShopItem>)
                }
            </ul>
        )
    }
}

数据比对

作为一款优秀的前端框架,React本身已经为我们做了很多工作。不过在开发过程中,如果我们能让组件避免在非必要的情况下重新渲染,就能使开发出的组件性能更良好。

浅比较 shadowEqual

组件在更新过程中,数据比对这一过程是必不可少的,它是触发组件重新渲染的关键。因此,我们有必要深入理解React组件在更新过程中的数据变化机制。React对于状态更新的比较方式默认都是采用浅比较,我们可以看一下它的源码实现

/**
 * Performs equality by iterating through keys on an object and returning false
 * when any key has values which are not strictly equal between the arguments.
 * Returns true when the values of all keys are strictly equal.
 */
function shallowEqual(objA: mixed, objB: mixed): boolean {
  if (is(objA, objB)) {
    return true;
  }

  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false;
  }

  const keysA = Object.keys(objA);
  const keysB = Object.keys(objB);

  if (keysA.length !== keysB.length) {
    return false;
  }

  // Test for A's keys different from B.
  for (let i = 0; i < keysA.length; i++) {
    if (
      !hasOwnProperty.call(objB, keysA[i]) ||
      !is(objA[keysA[i]], objB[keysA[i]])
    ) {
      return false;
    }
  }

  return true;
}

另外,对于对象做相等比对的is方法,不同于直接使用=====,它针对特殊的+0-0,NaNNaN的比对做了修复,并且不会做隐式转换。它的实现是像这样的:

/**
 * inlined Object.is polyfill to avoid requiring consumers ship their own
 * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is
 */
function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y)
  );
}

可以从上面的代码中看到,对于引用对象来说,浅比较算法首先会使用Object.keys获取对象所有的属性,并比对对应的属性值。不过这里只会比对第一层的数据,并没有做递归对比。这大概就是叫做"浅比较"的原因吧。

shouldComponentUpdate

对于Class组件来说,我们可以使用shouldComponentUpdate方法来判断是否进行组件渲染,从而更好地提高页面性能。这个方式会在每次props和state变化的时候执行,框架对于这个方法的默认实现是直接返回true,即每次只要属性和状态变更,组件都会重新渲染。而如果我们对于数据的变更逻辑比较清楚,完全可以手动实现比对过程来避免重复渲染:

class ShopItem extends React.Component {
    shouldComponentUpdate(nextProps, nextState) {
        return this.props.itemName !== nextProps.itemName;
    }
    
    render() {
      return (<div>{this.props.itemName}</div>);
    }
}

pureComponent

要达到性能优化的目的,有时候也不必手动实现shouldComponentUpdate。你只要让你的组件继承自React.PureComponent即可,它已经内置了浅比较算法,所以上面的例子可以改写成:

class ShopItem extends React.PureComponent {
    render() {
        return (<div>{this.props.itemName}</div>);
    }
}

关于箭头函数

还有一点要记住的是,在使用箭头函数的时候要小心:

class Button extends React.Component {
	render() {
		return <button onClick={() => {console.log('hello, scq000');}}>click</button>
	}
}

直接在组件上绑定箭头函数虽然写法简便,但由于每次渲染的时候都会重新生成该函数,会导致性能损耗。即使组件的其他props或state没有变更,由于使用了内联的箭头函数也会触发重新渲染。

所以,为了避免这种情况的发生,我们可以先声明好事件监听函数后,然后再拿到其引用传给组件:

class Button extends React.Component {
	handleClick = () => {
		console.log('hello, scq000');
	}
	
	render() {
		return <button onClick={this.handleClick}>click</button>
	}
}

useCallback

如果我们使用的是函数式组件,React16中的useCallback的hook为我们提供了一种新思路:

export const Button = (text, alertMsg) => {
	const handleClick = useCallback(() => {
    	// do something with alertMsg
    }, [alertMsg]);
	return (
		<button onClick={handleClick}>{text}</button>
	);
}

将箭头函数传入useCallback方法中,这是一个高阶函数,它会返回一个记忆化(memoized)的方法。这个方法只有当它所依赖的props或state变化的时候才会更新。在上面的例子中,当它的依赖状态alertMsg变化的时候,handleClick函数才会更新。

在React16中,你可能还会用到useEffect这个Hook来处理一些副作用,就像这样:

const Student = ({name, age}) => {
	useEffect(() => {
		doSomethingWithInfos(infos)
	}, [name, age]);
	
	return (
		<div>This is a child component.</div>
	);
}

const Person = () => {
	return (<Student name="scq000" age="11" />)
}

useEffect传入的第二个参数也是它的依赖项,如果这个依赖项中使用的是一个箭头函数,那么每次useEffect中的回调函数都会执行。这样一来结果可能就不是我们想要的了,此时也可以借助useCallback来避免这种情况的发生。

useCallback虽然能够缓存函数,但对于大多数场景来说使用它反而会增加垃圾回收和运行封装函数的时间。只有对于大计算量的函数来说,利用useCallback才能起到良好的优化效果。

useMemo

除了直接缓存函数,有时候还需要缓存数据和计算结果。实现记忆化的关键是记住上一次的状态值和输出值。我们利用闭包就能实现一个简化的Memorize方法:

function memorize(func) {
  let lastInput = null;
  let lastOuput = null;
  return function() {
  	// 这里使用浅比较来判断参数是否一致
    if (!shallowEqual(lastInput, arguments)) {
      lastOuput = func.apply(null, arguments);
    }
    lastInput = arguments;
    return lastOuput;
  }
}

在React中,useMemo hook已经为我们实现了这个功能,直接使用就可以了:

const calcResult = React.useMemo(() => expensiveCalulate(a, b), [a, b]);

当输入参数a,b没有发生变化的时候,会自动使用上一次的值。这也意味着我们使用useMemo只能用来缓存纯函数的计算结果。对于大计算量的操作来说,可以有效避免重复计算过程。

React.Memo

针对Functional组件来说,由于缺少shouldComponentUpdate方法,可以考虑用React.Memo来优化组件性能:React.Memo是一个高阶组件,它内置了useMemo方法来缓存整个组件。

考虑下面这段代码:

function Demo() {
	return (
		<Parent props={props}>
			<Child title={title} subtitle={subtitle} />
		</Parent>
	);
}

父组件由于props中的属性变更重新渲染,即使子组件props没有变化,子组件Child也会跟着重新渲染。这时候,可以考虑使用React.Memo来缓存子组件:

export function Card({title, subtitle}) {
	// do some render logic
}
export const MemoziedCard = React.Memo(Card);

为了更深入地理解这部分逻辑,让我们看一下相关的源码:

if (updateExpirationTime < renderExpirationTime) {
    // This will be the props with resolved defaultProps,
    // unlike current.memoizedProps which will be the unresolved ones.
    const prevProps = currentChild.memoizedProps;
    // Default to shallow comparison
    let compare = Component.compare;
    compare = compare !== null ? compare : shallowEqual;
    if (compare(prevProps, nextProps) && current.ref === workInProgress.ref) {
      return bailoutOnAlreadyFinishedWork(
        current,
        workInProgress,
        renderExpirationTime,
      );
    }
  }

我们可以看到React.Memo默认情况下也是使用的浅比较算法,所以对于复杂的数据,我们需要自己实现数据比对逻辑。可以在React.Memo传入第二个参数,就像下面这样:

const compartor = (prevProps, nextProps) => {
	return prevProps.id === nextProps.id;
}

React.Memo(Card, compartor)

不可变数据Immutable

Immutable是Facebook封装好的抽象数据结构,由于其结构的不变性和共享性,能让引用对象在比对的时候更加快速。使用Immutable创建的数据不可变更,因此数据在整个应用中都易于追踪。这也符合函数式编程的思想。它的核心是采用持久化的数据结构,当改变数据的时候,只会更新变更的那一部分,而数据结构中的不变部分都会公用同一引用,达到结构共享的目的。所以,在高度嵌套的数据进行深拷贝的时候,性能也会更优。

438px-Purely_functional_tree_after.png

import Immutable from 'immutable';

var obj = Immutable.fromJS({1: "one"});
var map = Immutable.Map({a: 1, b: 2, c: 3});
map.set('b', 4);
var list = Immutable.List.of(1,2,3);
list.push(5);

虽然Immutable JS在性能上有它的优势,但请注意使用的影响面。不要让原生对象和Immutable对象进行混用,这样反而会导致性能下降,因为将Immutable数据再转换成原生JS对象在性能上是很差的。关于使用Immutable JS的最佳实践,可以参考这篇文章

reselect

在使用Redux过程中,组件的状态数据通常是从state派生出来的,要做很多计算的逻辑。 假设现在我应用中的状态树是这样的:

const state = {
  a: {
    b: {
      c: 'c',
      d: 'd'
    }
  }
};

每次a.b.c更新的时候,即使d没有更新,所有引用到a.b.d的地方也会重新计算。

那么,我们在这一步要优化的点,同样也是使用缓存或记忆化。reselect就是为了这个目的而生的,它可以帮助我们避免重复的计算:

import {createSelector} from "reselect";

const shopItemSelector = (state) => state.shopItems;
const parentSelector = (state) => state.parent;

export const shopMenuSelector = createSelector(
	[shopItemSelector, parentSelector],
	(shopItems, parent) => {
      // do something with shopItems and parent
	}
);

只有状态shopItemsparent变化后,才会重新计算。

默认情况下,新旧属性的比对也是采用浅比较来进行的。结合上一小节介绍的Immutable,我们可以进一步优化比对过程。

首先是将我们的整个state树改用Immutable数据结构:

const state = Immutable.fromJS(originState);

接着,改写派生状态的时候,使用Immutable中的is进行比对:

import {createSelectorCreator, defaultMemoize} from 'reselect';
import { is } from 'immutable';

const createImmutableSelector = createSelector(defaultMemoize, is);

export const shopMenuSelector = createImmutableSelector(
	[shopItemSelector, parentSelector],
	(shopItems, parent) => {
      // do something with shopItems and parent
	}
);

按需加载

上面介绍的优化方式主要都是围绕组件渲染机制来展开的,而接下来要介绍的方法是依靠延迟计算思想来优化应用响应性能。虽然并不能达到减少总渲染时间的目的,但可以更快地让用户跟页面进行交互,从而提高应用的用户体验。

在React 16之前,我们一般要实现懒加载可以使用react-loadable等库,但现在可以直接使用React.lazy方法就可以了。本质上它也是通过代码拆分的方式,让部分非核心的组件延迟加载。要使用React.lazy还需要配合Suspense组件一起。Suspense组件可以为懒加载组件提供基本的过渡效果,通常情况下是提供一个loading动画:

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

不过这一策略,目前只支持浏览器端。至于使用了SSR的React应用,可以考虑https://github.com/smooth-code/loadable-components来达到相同的目的。

测试性能

著名管理学大师彼得.德鲁克(Peter Drucker)曾说过"If you can't measure it, you can't improve it."。虽然这句话是说管理学中的事情,但放在软件开发中也是同样适用的。在考虑优化React页面性能之前,我们必须要做好对应的测试工作,找到性能瓶颈。使用React DevTools Profiler可以检测组件渲染性能,这个工具可以在谷歌商店下载到。 [图片上传失败...(image-e0f95d-1564019981226)] 更具体的使用方式可以参考reactjs.org/blog/2018/0…

总结

性能优化永远是软件开发中的痛点和难点,要学习和实践的知识有很多,只能说任重而道远。不过在工作中也并不提倡过早优化。性能虽然是重要的评判标准,但在开发过程中还必须在代码的可维护性、对未来的适应性等方面做出取舍。应用中并非所有的部分都必须快如闪电,有些部分的可维护性往往更加重要。

如果一定要做性能优化,核心还是在减少频繁计算和渲染上,在实现策略上主要有三种方式:利用key维持组件结构稳定性、优化数据比对过程和按需加载。其中优化数据比对过程可以根据具体使用的场景,分别使用缓存数据或组件、改用Immutable不可变数据等方式进行。最后,也一定记得要采用测试工具进行前后性能对比,来保障优化工作的有效性。

参考文章

www.ayqy.net/blog/react-…

zhuanlan.zhihu.com/p/56975681

codeburst.io/memorized-f…

blog.bitsrc.io/lazy-loadin…

kentcdodds.com/blog/usemem…

reactjs.org/docs/optimi…

reactjs.org/blog/2018/0…

redux.js.org/recipes/usi…

——转载请注明出处———

微信扫描二维码,关注我的公众号
最后,欢迎大家关注我的公众号,一起学习交流。