浅谈React性能优化的方向

本文来源于公司内部的一次闪电分享,稍做润色分享出来。主要讨论 React 性能优化的主要方向和一些小技巧。若是你以为能够,请多点赞,鼓励我写出更精彩的文章🙏。html


React 渲染性能优化的三个方向,其实也适用于其余软件开发领域,这三个方向分别是:react

  • 减小计算的量。 -> 对应到 React 中就是减小渲染的节点 或者 下降组件渲染的复杂度
  • 利用缓存。-> 对应到 React 中就是如何避免从新渲染,利用函数式编程的 memo 方式来避免组件从新渲染
  • 精确从新计算的范围。 对应到 React 中就是绑定组件和状态关系, 精确判断更新的'时机'和'范围'. 只从新渲染'脏'的组件,或者说下降渲染范围

目录git



减小渲染的节点/下降渲染计算量(复杂度)

首先从计算的量上下功夫,减小节点渲染的数量或者下降渲染的计算量能够显著的提升组件渲染性能。github


0️⃣ 不要在渲染函数都进行没必要要的计算

好比不要在渲染函数(render)中进行数组排序、数据转换、订阅事件、建立事件处理器等等. 渲染函数中不该该放置太多反作用web


1️⃣ 减小没必要要的嵌套

咱们团队是重度的 styled-components 用户,其实大部分状况下咱们都不须要这个玩意,好比纯静态的样式规则,以及须要重度性能优化的场景。除了性能问题,另一个困扰咱们的是它带来的节点嵌套地狱(如上图)。编程

因此咱们须要理性地选择一些工具,好比使用原生的 CSS,减小 React 运行时的负担.数组

通常没必要要的节点嵌套都是滥用高阶组件/RenderProps 致使的。因此仍是那句话‘只有在必要时才使用 xxx’。 有不少种方式来代替高阶组件/RenderProps,例如优先使用 props、React Hooks缓存


2️⃣ 虚拟列表

虚拟列表是常见的‘长列表'和'复杂组件树'优化方式,它优化的本质就是减小渲染的节点。安全

虚拟列表只渲染当前视口可见元素:性能优化

虚拟列表渲染性能对比:

虚拟列表经常使用于如下组件场景:

  • 无限滚动列表,grid, 表格,下拉列表,spreadsheets
  • 无限切换的日历或轮播图
  • 大数据量或无限嵌套的树
  • 聊天窗,数据流(feed), 时间轴
  • 等等

相关组件方案:

扩展:



3️⃣ 惰性渲染

惰性渲染的初衷本质上和虚表同样,也就是说咱们只在必要时才去渲染对应的节点

举个典型的例子,咱们经常使用 Tab 组件,咱们没有必要一开始就将全部 Tab 的 panel 都渲染出来,而是等到该 Tab 被激活时才去惰性渲染。

还有不少场景会用到惰性渲染,例如树形选择器,模态弹窗,下拉列表,折叠组件等等。

这里就不举具体的代码例子了,留给读者去思考.


4️⃣ 选择合适的样式方案

如图(图片来源于THE PERFORMANCE OF STYLED REACT COMPONENTS), 这个图片是17年的了,可是大抵的趋势仍是这样。

因此在样式运行时性能方面大概能够总结为:CSS > 大部分CSS-in-js > inline style




避免从新渲染

减小没必要要的从新渲染也是 React 组件性能优化的重要方向. 为了不没必要要的组件从新渲染须要在作到两点:

  1. 保证组件纯粹性。即控制组件的反作用,若是组件有反作用则没法安全地缓存渲染结果
  2. 经过shouldComponentUpdate生命周期函数来比对 state 和 props, 肯定是否要从新渲染。对于函数组件可使用React.memo包装

另外这些措施也能够帮助你更容易地优化组件从新渲染:


0️⃣ 简化 props

① 若是一个组件的 props 太复杂通常意味着这个组件已经违背了‘单一职责’,首先应该尝试对组件进行拆解. ② 另外复杂的 props 也会变得难以维护, 好比会影响shallowCompare效率, 还会让组件的变更变得难以预测和调试.

下面是一个典型的例子, 为了判断列表项是否处于激活状态,这里传入了一个当前激活的 id:

这是一个很是糟糕的设计,一旦激活 id 变更,全部列表项都会从新刷新. 更好的解决办法是使用相似actived这样的布尔值 prop. actived 如今只有两种变更状况,也就是说激活 id 的变更,最多只有两个组件须要从新渲染.

简化的 props 更容易理解, 且能够提升组件缓存的命中率


1️⃣ 不变的事件处理器

避免使用箭头函数形式的事件处理器, 例如:

<ComplexComponent onClick={evt => onClick(evt.id)} otherProps={values} />
复制代码

假设 ComplexComponent 是一个复杂的 PureComponent, 这里使用箭头函数,其实每次渲染时都会建立一个新的事件处理器,这会致使 ComplexComponent 始终会被从新渲染.

更好的方式是使用实例方法:

class MyComponent extends Component {
  render() {
    <ComplexComponent onClick={this.handleClick} otherProps={values} />;
  }
  handleClick = () => {
    /*...*/
  };
}
复制代码

② 即便如今使用hooks,我依然会使用useCallback来包装事件处理器,尽可能给下级组件暴露一个静态的函数:

const handleClick = useCallback(() => {
  /*...*/
}, []);

return <ComplexComponent onClick={handleClick} otherProps={values} />; 复制代码

可是若是useCallback依赖于不少状态,你的useCallback可能会变成这样:

const handleClick = useCallback(() => {
  /*...*/
  // 🤭
}, [foo, bar, baz, bazz, bazzzz]);
复制代码

这种写法实在让人难以接受,这时候谁还管什么函数式非函数式的。我是这样处理的:

function useRefProps<T>(props: T) {
  const ref = useRef < T > props;
  // 每次渲染更新props
  useEffect(() => {
    ref.current = props;
  });
}

function MyComp(props) {
  const propsRef = useRefProps(props);

  // 如今handleClick是始终不变的
  const handleClick = useCallback(() => {
    const { foo, bar, baz, bazz, bazzzz } = propsRef.current;
    // do something
  }, []);
}
复制代码

设计更方便处理的 Event Props. 有时候咱们会被逼的不得不使用箭头函数来做为事件处理器:

<List>
  {list.map(i => (
    <Item key={i.id} onClick={() => handleDelete(i.id)} value={i.value} /> ))} </List>
复制代码

上面的 onClick 是一个糟糕的实现,它没有携带任何信息来标识事件来源,因此这里只能使用闭包形式,更好的设计多是这样的:

// onClick传递事件来源信息
const handleDelete = useCallback((id: string) => {
  /*删除操做*/
}, []);

return (
  <List> {list.map(i => ( <Item key={i.id} id={i.id} onClick={handleDelete} value={i.value} /> ))} </List> ); 复制代码

若是是第三方组件或者 DOM 组件呢? 实在不行,看能不能传递data-*属性:

const handleDelete = useCallback(event => {
  const id = event.currentTarget.dataset.id;
  /*删除操做*/
}, []);

return (
  <ul> {list.map(i => ( <li key={i.id} data-id={i.id} onClick={handleDelete} value={i.value} /> ))} </ul> ); 复制代码


2️⃣ 不可变数据

不可变数据可让状态变得可预测,也让 shouldComponentUpdate '浅比较'变得更可靠和高效. 笔者在React 组件设计实践总结 04 - 组件的思惟介绍过不可变数据,有兴趣读者能够看看.

相关的工具备Immutable.jsImmer、immutability-helper 以及 seamless-immutable。


3️⃣ 简化 state

不是全部状态都应该放在组件的 state 中. 例如缓存数据。按照个人原则是:若是须要组件响应它的变更, 或者须要渲染到视图中的数据才应该放到 state 中。这样能够避免没必要要的数据变更致使组件从新渲染.


4️⃣ 使用 recompose 精细化比对

尽管 hooks 出来后,recompose 宣称再也不更新了,但仍是不影响咱们使用 recompose 来控制shouldComponentUpdate方法, 好比它提供了如下方法来精细控制应该比较哪些 props:

/* 至关于React.memo */
 pure()
 /* 自定义比较 */
 shouldUpdate(test: (props: Object, nextProps: Object) => boolean): HigherOrderComponent
 /* 只比较指定key */
 onlyUpdateForKeys( propKeys: Array<string>): HigherOrderComponent
复制代码

其实还能够再扩展一下,好比omitUpdateForKeys忽略比对某些 key.



精细化渲染

所谓精细化渲染指的是只有一个数据来源致使组件从新渲染, 好比说 A 只依赖于 a 数据,那么只有在 a 数据变更时才渲染 A, 其余状态变化不该该影响组件 A。

Vue 和 Mobx 宣称本身性能好的一部分缘由是它们的'响应式系统', 它容许咱们定义一些‘响应式数据’,当这些响应数据变更时,依赖这些响应式数据视图就会从新渲染. 来看看 Vue 官方是如何描述的:


0️⃣ 响应式数据的精细化渲染

大部分状况下,响应式数据能够实现视图精细化的渲染, 但它仍是不能避免开发者写出低效的程序. 本质上仍是由于组件违背‘单一职责’.

举个例子,如今有一个 MyComponent 组件,依赖于 A、B、C 三个数据源,来构建一个 vdom 树。如今的问题是什么呢?如今只要 A、B、C 任意一个变更,那么 MyComponent 整个就会从新渲染:

更好的作法是让组件的职责更单一,精细化地依赖响应式数据,或者说对响应式数据进行‘隔离’. 以下图, A、B、C 都抽取各自的组件中了,如今 A 变更只会渲染 A 组件自己,而不会影响父组件和 B、C 组件:


举一个典型的例子,列表渲染:

import React from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react-lite';

const initialList = [];
for (let i = 0; i < 10; i++) {
  initialList.push({ id: i, name: `name-${i}`, value: 0 });
}

const store = observable({
  list: initialList,
});

export const List = observer(() => {
  const list = store.list;
  console.log('List渲染');
  return (
    <div className="list-container"> <ul> {list.map((i, idx) => ( <div className="list-item" key={i.id}> {/* 假设这是一个复杂的组件 */} {console.log('render', i.id)} <span className="list-item-name">{i.name} </span> <span className="list-item-value">{i.value} </span> <button className="list-item-increment" onClick={() => { i.value++; console.log('递增'); }} > 递增 </button> <button className="list-item-increment" onClick={() => { if (idx < list.length - 1) { console.log('移位'); let t = list[idx]; list[idx] = list[idx + 1]; list[idx + 1] = t; } }} > 下移 </button> </div> ))} </ul> </div>
  );
});
复制代码

上述的例子是存在性能问题的,单个 list-item 的递增和移位都会致使整个列表的从新渲染:

缘由大概能猜出来吧? 对于 Vue 或者 Mobx 来讲,一个组件的渲染函数就是一个依赖收集的上下文。上面 List 组件渲染函数内'访问'了全部的列表项数据,那么 Vue 或 Mobx 就会认为你这个组件依赖于全部的列表项,这样就致使,只要任意一个列表项的属性值变更就会从新渲染整个 List 组件。

解决办法也很简单,就是将数据隔离抽取到单一职责的组件中。对于 Vue 或 Mobx 来讲,越细粒度的组件,能够收获更高的性能优化效果:

export const ListItem = observer(props => {
  const { item, onShiftDown } = props;
  return (
    <div className="list-item"> {console.log('render', item.id)} {/* 假设这是一个复杂的组件 */} <span className="list-item-name">{item.name} </span> <span className="list-item-value">{item.value} </span> <button className="list-item-increment" onClick={() => { item.value++; console.log('递增'); }} > 递增 </button> <button className="list-item-increment" onClick={() => onShiftDown(item)}> 下移 </button> </div>
  );
});

export const List = observer(() => {
  const list = store.list;
  const handleShiftDown = useCallback(item => {
    const idx = list.findIndex(i => i.id === item.id);
    if (idx !== -1 && idx < list.length - 1) {
      console.log('移位');
      let t = list[idx];
      list[idx] = list[idx + 1];
      list[idx + 1] = t;
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  console.log('List 渲染');

  return (
    <div className="list-container"> <ul> {list.map((i, idx) => ( <ListItem key={i.id} item={i} onShiftDown={handleShiftDown} /> ))} </ul> </div> ); }); 复制代码

效果很明显, list-item 递增只会从新渲染自己; 而移位只会从新渲染 List, 由于列表项没有变更, 因此下级 list-item 也不须要从新渲染:



1️⃣ 不要滥用 Context

其实 Context 的用法和响应式数据正好相反。笔者也看过很多滥用 Context API 的例子, 说到底仍是没有处理好‘状态的做用域问题’.

首先要理解 Context API 的更新特色,它是能够穿透React.memo或者shouldComponentUpdate的比对的,也就是说,一旦 Context 的 Value 变更,全部依赖该 Context 的组件会所有 forceUpdate.

这个和 Mobx 和 Vue 的响应式系统不一样,Context API 并不能细粒度地检测哪些组件依赖哪些状态,因此说上节提到的‘精细化渲染’组件模式,在 Context 这里就成为了‘反模式’.

总结一下使用 Context API 要遵循一下原则:


  • 明确状态做用域, Context 只放置必要的,关键的,被大多数组件所共享的状态。比较典型的是鉴权状态

    举一个简单的例子:

    扩展:Context其实有个实验性或者说非公开的选项observedBits, 能够用于控制ContextConsumer是否须要更新. 详细能够看这篇文章<ObservedBits: React Context的秘密功能>. 不过不推荐在实际项目中使用,并且这个API也比较难用,不如直接上mobx。

  • 粗粒度地订阅 Context

    以下图. 细粒度的 Context 订阅会致使没必要要的从新渲染, 因此这里推荐粗粒度的订阅. 好比在父级订阅 Context,而后再经过 props 传递给下级。


另外程墨 Morgan 在避免 React Context 致使的重复渲染一文中也提到 ContextAPI 的一个陷阱:

<Context.Provider
  value={{ theme: this.state.theme, switchTheme: this.switchTheme }}
>
  <div className="App">
    <Header />
    <Content />
  </div>
</Context.Provider>
复制代码

上面的组件会在 state 变化时从新渲染整个组件树,至于为何留给读者去思考。

因此咱们通常都不会裸露地使用 Context.Provider, 而是封装为独立的 Provider 组件:

export function ThemeProvider(props) {
  const [theme, switchTheme] = useState(redTheme);
  return (
    <Context.Provider value={{ theme, switchTheme }}> {props.children} </Context.Provider> ); } // 顺便暴露useTheme, 让外部必须直接使用Context export function useTheme() { return useContext(Context); } 复制代码

如今 theme 变更就不会从新渲染整个组件树,由于 props.children 由外部传递进来,并无发生变更。

其实上面的代码还有另一个比较难发现的陷阱(官方文档也有提到):

export function ThemeProvider(props) {
  const [theme, switchTheme] = useState(redTheme);
  return (
    {/* 👇 💣这里每一次渲染ThemeProvider, 都会建立一个新的value(即便theme和switchTheme没有变更),
        从而致使强制渲染全部依赖该Context的组件 */}
    <Context.Provider value={{ theme, switchTheme }}>
      {props.children}
    </Context.Provider>
  );
}
复制代码

因此传递给 Context 的 value 最好作一下缓存:

export function ThemeProvider(props) {
  const [theme, switchTheme] = useState(redTheme);
  const value = useMemo(() => ({ theme, switchTheme }), [theme]);
  return <Context.Provider value={value}>{props.children}</Context.Provider>; } 复制代码


扩展

相关文章
相关标签/搜索