React 组件通用优化方案

516 阅读3分钟

React 版本 16.14.0

例子

import { useState } from 'react';

export default function App() {
  let [color, setColor] = useState('red');
  return (
    <div>
      <input type="color" value={color} onChange={(e) => setColor(e.target.value)} />
      <p style={{ color }}>Hello, world!</p>
      <ExpensiveTree />
    </div>
  );
}

function ExpensiveTree() {
  console.log('re-render!');
  return <p>I am a very slow component tree.</p>;
}

当选择颜色时,ExpensiveTree 组件也会渲染。这对于 ExpensiveTree 来说,是一次额外的渲染。可以进行优化。

React.memo / useMemo

// React.memo
const ExpensiveTree = React.memo(() => {
  console.log('re-render !')
  return <p>I am a very slow component tree.</p>;
})

// useMemo
const ExpensiveTree = () => {
  return useMemo(() => {
    console.log('re-render !')
    return <p>I am a very slow component tree.</p>;
  }, [])
}

state 下移

也就是将需要渲染更新的组件跟不需要渲染的组件区分开来:

import { useState } from 'react';

export default function App() {
  return (
    <div>
      <Form />
      <ExpensiveTree />
    </div>
  );
}

function Form() {
  let [color, setColor] = useState('red');
  return (
    <>
  	  <input type="color" value={color} onChange={(e) => setColor(e.target.value)} />
      <p style={{ color }}>Hello, world!</p>
    </>
  )
}

function ExpensiveTree() {
  console.log('re-render!');
  return <p>I am a very slow component tree.</p>;
}

内容提升

将不需要更新渲染的组件,作为 children 传进组件 A 内,而组件 A 中包含需要更新渲染的其他组件。

import { useState } from 'react';

export default function App() {
  return (
    <Parent>
      <ExpensiveTree />
    </Parent>
  )
}

function Parent({children}) {
  let [color, setColor] = useState('red');
  return (
    <div>
      <input type="color" value={color} onChange={(e) => setColor(e.target.value)} />
      <p style={{ color }}>Hello, world!</p>
      {children}
    </div>
  );
}

function ExpensiveTree() {
  console.log('re-render!');
  return <p>I am a very slow component tree.</p>;
}

这个方法也适用于父组件需要更新渲染,但某些子组件不需要更新渲染的情况,如:

export default function App() {
  let [color, setColor] = useState('red');
  return (
    <div style={{ color }}>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      <p>Hello, world!</p>
      <ExpensiveTree />
    </div>
  );
}

改造如下:

export default function App() {
  let [color, setColor] = useState('red');
  return (
    <Parent>
      <p>Hello, world!</p>
    	<ExpensiveTree />
    </Parent>
  );
}

function Parent({children}) {
  let [color, setColor] = useState('red');
  return (
    <div style={{ color }}>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      {children}
    </div>
  );
}

为什么内容提升的做法可以优化?

可以看下 optimize-react-re-renders 中的解释:

If we create the JSX element once and re-use that same one, then we'll get the same JSX every time!

function Logger(props) {
  console.log(`${props.label} rendered`)
  return null // what is returned here is irrelevant...
}

function Counter() {
  const [count, setCount] = React.useState(0)
  const increment = () => setCount(c => c + 1)
  return (
    <div>
      <button onClick={increment}>The count is {count}</button>
      <Logger label="counter" />
    </div>
  )
}

ReactDOM.render(<Counter />, document.getElementById('root'))

每次点击按钮,控制台都会打印 Logger 组件内的 log 信息。说明 Logger 组件每次都会 re-render。

看下 jsx 对应的 React.createElement 的代码:

function App() {
  return /*#__PURE__*/React.createElement(Counter, {
    logger: /*#__PURE__*/React.createElement(Logger, {
      label: "counter"
    })
  });
}

function Counter() {
  const [count, setCount] = React.useState(0);
  const increment = () => setCount(c => c + 1);

  return /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("button", {
    onClick: increment
  }, "The count is ", count), /*#__PURE__*/React.createElement(Logger, {
    label: "counter"
  }));
}

优化后的代码如下:

export default function App() {
  return (
    <Counter logger={<Logger label="counter" />} />
  )
}

function Counter(props) {
  const [count, setCount] = React.useState(0)
  const increment = () => setCount(c => c + 1)
  return (
    <div>
      <button onClick={increment}>The count is {count}</button>
      {props.logger}
    </div>
  )
}

将对应的 jsx 翻译为 React.createElement,如下:

function App() {
  return /*#__PURE__*/React.createElement(Counter, {
    logger: /*#__PURE__*/React.createElement(Logger, {
      label: "counter"
    })
  });
}

function Counter(props) {
  const [count, setCount] = React.useState(0);
  const increment = () => setCount(c => c + 1);

  return /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("button", {
    onClick: increment
  }, "The count is ", count), props.logger);
}

观察一下,Counter 组件前后的区别:

// 未优化前
function Counter() {
  const [count, setCount] = React.useState(0);
  const increment = () => setCount(c => c + 1);

  return /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("button", {
    onClick: increment
  }, "The count is ", count), /*#__PURE__*/React.createElement(Logger, {
    label: "counter"
  }));
}

// 优化后
function Counter(props) {
  const [count, setCount] = React.useState(0);
  const increment = () => setCount(c => c + 1);

  return /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("button", {
    onClick: increment
  }, "The count is ", count), props.logger);
}

明显地看到,Logger 组件在未优化前是使用 React.createElement 创建的,而优化后是 props.logger 。为优化前,当父组件,也就是 Counter 更新时,render 方法执行后,Logger 组件也会使用 React.createElement 创建了一个新的 ReactElement 返回,也就是重渲染了。

深究一下

为什么 props.logger 可以重用 ReactElement,不需要重渲染?

beginWork 方法中,可以看到 didReceiveUpdate 为 false (也就是可重用 node)的条件为:

  1. props 不变 (oldProps === newProps)

    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;
    
  2. context 不变(hasLegacyContextChanged())

  3. type 不变

  4. lane 不变(!includesSomeLane(renderLanes, updateLanes))

回顾上面的例子:

function App() {
  return /*#__PURE__*/React.createElement(Counter, {
    logger: /*#__PURE__*/React.createElement(Logger, {
      label: "counter"
    })
  });
}

function Counter(props) {
  const [count, setCount] = React.useState(0);
  const increment = () => setCount(c => c + 1);

  return /*#__PURE__*/React.createElement("div", null, /*#__PURE__*/React.createElement("button", {
    onClick: increment
  }, "The count is ", count), props.logger);
}

可以看到 Counter 的 props 在 App 组件内就已经是确定的了,待 Counter 组件更新时,props 并不会变化,因为 App 组件是重用的,所以 props 不变,也就是:

{
  logger: /*#__PURE__*/React.createElement(Logger, {
    label: "counter"
  })
}

所以,props.logger 在 Counter 变化时,依然稳定地复用了以前的 ReactElement。

参考

[before-you-memo] overreacted.io/zh-hans/bef…

[optimize-react-re-renders] kentcdodds.com/blog/optimi…

[beginWork] packages/react-reconciler/src/ReactFiberBeginWork.old.js

[createElement] packages/react/src/ReactElement.js