关于React的性能优化

由于react遵循 UI=render(data)的设计哲学,传递进去一个数据,渲染出一个界面,推崇函数式的编程范式。 它没有像Vue那样采用双向绑定的思路,实时监测组件上状态的变化,而是每次进行组件和子孙组件的调和-渲染过程。 如果不注意,可能写的React应用会有性能上的问题。

react的渲染机制

举个例子,说明react的渲染机制。

当组件App上的状态发生了改变,受此影响的子组件链路为:App > div > p,我们希望变化只发生这一条链路上,而实际的过程确不这样。

默认情况下react会自顶向下,将涉及到的所有子孙结点,都比对一遍,确定是否需要渲染,这就是react中最重要的reconcile(调和)的过程。因为,不确定数据变动,具体造成了哪些组件受到影响,需要重新渲染,所以react会将所有的子孙组件都调和一遍。

状态变化的组件下所有的子孙组件结点都参与一遍调和的过程。当页面比较复杂,组件树上的结点层级和数量较多时,有可能会造成页面的反应卡顿。因为浏览器的刷新频率一般为60HZ,这就要求在屏幕的刷新周期(16.7毫秒内)完成JS的执行,并将程序执行权还给浏览器的渲染线程进行布局和绘制。

而优化的思路的,无外乎,减少不必要的调和渲染过程。

  • 从子组件自身的角度,阻断来自父容器的渲染执行过程
  • 从父容器组件角度,精准控制哪些子组件的渲染执行过程
  • 启动并行的渲染模式
  • 懒加载执行
  • 业务场景的优化
  • 使用新版的过渡API

类子组件的渲染阻断

js 复制代码
// 子组件 A
class ChildA extends React.Component {
  state = {
    countA: 0,
  }

  render() {
    console.log('ChildA is rendering...');
    return (
      <div>
        <p>
          ChildA count: {this.state.countA}
          <button onClick={() => this.setState({countA: this.state.countA + 1})}>+</button>
        </p>
        <p>ChildA msgA: {this.props.msgA}</p>
      </div>
    );
  }
}

// 子组件B
class ChildB extends React.Component {
  state = {
    countB: 0,
  }

  render() {
    console.log('ChildB is rendering...');
    return (
      <div>
        <p>
          ChildB count: {this.state.countB}
          <button onClick={() => this.setState({countB: this.state.countB + 1})}>+</button>
        </p>
        <p>ChildB msgB: {this.props.msgB}</p>
      </div>
    );
  }
}

// 容器组件
class Parent extends React.Component {
  state = {
    messageA: Date.now(),
    messageB: Date.now(),
  }

  render() {
    console.log('Parent is rendering ...');
    return (
      <div>
        <h2>test react rendering...</h2>
        <ChildA msgA={this.state.messageA} />
        <ChildB msgB={this.state.messageB} />
        <section>
          <button onClick={() => this.setState({messageA: Date.now()})}>changeChildA</button>
          <button onClick={() => this.setState({messageB: Date.now(0)})}>changeChildB</button>
        </section>
      </div>
    )
  }
}

上面是一个简单的react示例,容器组件 Parent 嵌套着两个子组件ChildAChildB。每个子组件除了自身定义了一个countX的状态变量外,还接收来自父级容器组件传递的属性msgX(代表msgA | msgB)。

  • 当改变 A子组件的状态,控制台输出:ChildA is rendering...
  • 当改变 B子组件的状态,控制台输出:ChildB is rendering...
  • 当点击父容器组件上的按钮,无论是changeChildA 或者 changeChildB按钮,都会打印三句log语句

这就是react内部的渲染逻辑导致的结果,即使在子组件中没有引用到相关的属性,当父级组件重新渲染时,所有的子孙组件都会走一遍render函数。 当修改子组件A的属性时,子组件B本没有必要执行render逻辑的,这就造成了计算资源的浪费。

针对上面这种情况,我们可以使用 shouldComponentUpdate方法,在子组件内进行属性的对比判断

js 复制代码
// 子组件内判断属性是否真的改变了
shouldComponentUpdate(prevProp, state) {
  return prevProp.msgA !== this.props.msgA
}
shouldComponentUpdate(prevProp, state) {
  return prevProp.msgB !== this.props.msgB
}

这样就能有效的解决,子组件被重复执行的问题。当点击父容器组件上的(changeChildAchangeChildB)按钮,只会打印两句log语句了。

同时,react官方也提供了纯组件的方案。

js 复制代码
// 继承 PureComponent
class ChildA extends React.PureComponent {
  state = {
    countA: 0,
  }

  render() {
    console.log('ChildA is rendering...');
    return (
      <div>
        <p>
          ChildA count: {this.state.countA}
          <button onClick={() => this.setState({countA: this.state.countA + 1})}>+</button>
        </p>
        <p>ChildA msgA: {this.props.msgA}</p>
      </div>
    );
  }
}

// 继承 PureComponent
class ChildB extends React.PureComponent {
  state = {
    countB: 0,
  }

  render() {
    console.log('ChildB is rendering...');
    return (
      <div>
        <p>
          ChildB count: {this.state.countB}
          <button onClick={() => this.setState({countB: this.state.countB + 1})}>+</button>
        </p>
        <p>ChildB msgB: {this.props.msgB}</p>
      </div>
    );
  }
}

这样就不用每次都书写 shouldComponentUpdate方法了,框架内部会针对继承了 PureComponent的子组件,做前后属性的浅层比较,当发现组件的属性没有改变时,就不走后面的render过程了。

函数子组件的渲染阻断

上面是类组件的示例,函数式组件也面临同样的问题。

js 复制代码
// 子组件-A
const ChildA = ({msgA}) => {
  const [countA, setCountA] = useState(0);

  console.log('ChildA is rendering...');
  return (
    <div>
      <p>
        ChildA count: {countA}
        <button onClick={() => setCountA(countA + 1)}>+</button>
      </p>
      <p>ChildA msgA: {msgA}</p>
    </div>
  )
}

// 子组件-B
const ChildB = ({msgB}) => {
  const [countB, setCountB] = useState(0);

  console.log('ChildB is rendering...');
  return (
    <div>
      <p>
        ChildB count: {countB}
        <button onClick={() => setCountB(countB + 1)}>+</button>
      </p>
      <p>ChildB msgB: {msgB}</p>
    </div>
  )
}
// 容器组件
const Parent = () => {
  const [messageA, setMsgA] = useState(Date.now())
  const [messageB, setMsgB] = useState(Date.now())

  console.log('Parent is rendering ...');
  return (
    <div>
      <h2>test react rendering...</h2>
      <ChildA msgA={messageA} />
      <ChildB msgB={messageB} />
      <section>
        <button onClick={() => setMsgA(Date.now())}>changeChildA</button>
        <button onClick={() => setMsgB(Date.now())}>changeChildB</button>
      </section>
    </div>
  )
}

当修改子组件 A的属性,子组件B 会重新渲染;修改子组件 B的属性,子组件A会重新渲染。

函数式组件中,无法使用 shouldComponentUpdate 和 纯组件的方式了,React中针对函数式组件提供了React.memo的缓存方案。

js 复制代码
//将 子组件 用 React.memo 包裹起来
const ChildA = React.memo(({msgA}) => {
  const [countA, setCountA] = useState(0);

  console.log('ChildA is rendering...');
  return (
    <div>
      <p>
        ChildA count: {countA}
        <button onClick={() => setCountA(countA + 1)}>+</button>
      </p>
      <p>ChildA msgA: {msgA}</p>
    </div>
  )
})

React.memo 包装后的子组件,就拥有了匹配类组件中纯组件的能力。同时,React.memo还接收第二个参数:isEqual,isEqual是一个函数返回值为Boolean类型,true表示组件的属性前后相等,无需重新渲染,false则需要执行渲染,正好和shouldComponentUpdate含义相反。

类容器组件的渲染控制

除了从子组件的角度来思考解决方案外,我们还可以从容器组件着手,精准控制子组件的渲染时机。

js 复制代码
class Parent extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      messageA: Date.now(),
      messageB: Date.now(),
    }

    this.ChildA = <ChildA msgA={this.state.messageA} />
    this.ChildB = <ChildB msgB={this.state.messageB} />
  }

  // 控制子组件的渲染
  controlChildRender() {
    const { props: propsA } = this.ChildA;
    if (propsA.msgA !== this.state.messageA) {
      this.ChildA = React.cloneElement(this.ChildA, {msgA: this.state.messageA})
    }
    const { props: propsB } = this.ChildB;
    if (propsB.msgB !== this.state.messageB) {
      this.ChildB = React.cloneElement(this.ChildB, {msgB: this.state.messageB})
    }

    return (
      <>
        {this.ChildA}
        {this.ChildB}
      </>
    )
  }

  render() {
    console.log('Parent is rendering ...');
    return (
      <div>
        <h2>test react rendering...</h2>
        {
          this.controlChildRender()
        }
        <section>
          <button onClick={() => this.setState({messageA: Date.now()})}>changeChildA</button>
          <button onClick={() => this.setState({messageB: Date.now(0)})}>changeChildB</button>
        </section>
      </div>
    )
  }
}

我们在容器组件中声明controlChildRender方法,里面有子组件的渲染控制逻辑,这样就能按照我们的预期来展示了。 不过这种写法比较繁琐,有一定的代码冗余,不利于后期的维护。下面我们可以借助新版react提供的 hooks API,在函数式组件中以一种更加优雅的方式来实现同等效果。

函数容器组件的渲染控制

react针对函数式组件,有 useMemo 这个hook API 来帮助我们缓存组件,避免不必要的重新渲染。

js 复制代码
const Parent = () => {
  const [messageA, setMsgA] = useState(Date.now())
  const [messageB, setMsgB] = useState(Date.now())
  // 缓存子组件 A ,仅当 messageA 发生改变时,重新渲染子组件
  const memoChildA = useMemo(() => <ChildA msgA={messageA} />, [messageA]);
  // 缓存子组件 B ,仅当 messageB 发生改变时,重新渲染子组件
  const memoChildB = useMemo(() => <ChildB msgB={messageB} />, [messageB]);

  console.log('Parent is rendering ...');
  return (
    <div>
      <h2>test react rendering...</h2>
      {memoChildA}
      {memoChildB}
      <section>
        <button onClick={() => setMsgA(Date.now())}>changeChildA</button>
        <button onClick={() => setMsgB(Date.now())}>changeChildB</button>
      </section>
    </div>
  )
}

React.useMemo,接收两个参数,第一个参数是回调函数,第二个参数是依赖项。将缓存回调函数中的返回结果,当且仅当依赖项改变时,重新生成组件。这样就能更加精准细粒度的控制渲染过程了。

当点击容器组件的changeChildA | changeChildB按钮时,只会触发属性相关联的子组件的渲染了。

引用数据类型的属性

当给子组件传递的属性,为引用类型的数据时(对象、数组、函数等),需要特别注意引用值的变化了。

js 复制代码
const ChildA = React.memo(({msgA, changeMsgA}) => {
  const [countA, setCountA] = useState(0);

  console.log('ChildA is rendering...');
  return (
    <div>
      <p>
        ChildA count: {countA}
        <button onClick={() => setCountA(countA + 1)}>+</button>
      </p>
      <p>
        ChildA msgA: {msgA}
        {/* 这里子组件A 接收了一个 changeMsgA 函数属性值 */}
        <button onClick={() => changeMsgA('child-A changed...')}>changeMsgA</button>
      </p>
    </div>
  )
})

const ChildB = React.memo(({msgB, changeMsgB}) => {
  const [countB, setCountB] = useState(0);

  console.log('ChildB is rendering...');
  return (
    <div>
      <p>
        ChildB count: {countB}
        <button onClick={() => setCountB(countB + 1)}>+</button>
      </p>
      <p>
        ChildB msgB: {msgB}
        {/* 这里子组件B 接收了一个 changeMsgB 函数属性值 */}
        <button onClick={() => changeMsgB('child-B changed...')}>changeMsgB</button>
      </p>
    </div>
  )
})

const Parent = () => {
  const [messageA, setMsgA] = useState(Date.now())
  const [messageB, setMsgB] = useState(Date.now())

  const changeMessageA = (msg) => setMsgA(msg);
  const changeMessageB = (msg) => setMsgB(msg);

  console.log('Parent is rendering ...');
  return (
    <div>
      <h2>test react rendering...</h2>
      <ChildA msgA={messageA} changeMsgA = {changeMessageA}/>
      <ChildB msgB={messageB} changeMsgB = {changeMessageB}/>
      <section>
        <button onClick={() => setMsgA(Date.now())}>changeChildA</button>
        <button onClick={() => setMsgB(Date.now())}>changeChildB</button>
      </section>
    </div>
  )
}

这是一个普通的示例,分别给子组件A和B传递了一个事件函数,子组件通过事件函数来修改容器组件内的状态变量。

因为每次渲染父级容器组件的时候,const changeMessageA = (msg) => setMsgA(msg)都会新生成一个事件函数,即便我们给子组件都加上了 React.memo,控制台依然会输出三次 rendering的日志信息。

  • Parent is rendering ...
  • ChildA is rendering...
  • ChildB is rendering...

说明针对引用类型的属性,每次都是改变的。为了验证我们的猜想,可以在 React.memo的第二个函数参数中,添加打印语句。

js 复制代码
const ChildA = React.memo(({msgA, changeMsgA}) => {
  const [countA, setCountA] = useState(0);

  console.log('ChildA is rendering...');
  return (
    // 省略......
  )
}, (prevProp, nextProp) => {
  // 基础类型 
  console.log(prevProp.msgA === nextProp.msgA); // true
  // 事件函数 引用类型
  // 说明:这次传递过来的事件函数 和 上次传递过来的事件函数不一样,内存地址变了
  console.log(prevProp.changeMsgA === nextProp.nextProp); // false

  return prevProp.changeMsgA === nextProp.nextProp && prevProp.msgA === nextProp.msgA
})

这里就涉及JavaScript的基础知识了,在JS中基础类型(number、string、boolean等)的比较是值比较,而引用类型(函数、对象、数组等)是引用比较,比较的是内存中的地址。在容器组每次更新时,都会执行一遍函数逻辑,新生成一个 事件函数的引用,造成了所有关联了引用属性的子组件都会重新渲染。(其实在本例中,传递一个对象、数组也会是同样的效果,事件函数的属性在我们业务开发中更常见)。

该如何解决呢?针对事件函数,我们可以用react提供的 useCallback 包裹起来,告诉react在重新渲染该组件时,还是用上一次缓存的那个就好,不用每次生成新的。

js 复制代码
// 将事件函数 useCallback包裹,这样就能缓存起来
const changeMessageA = useCallback((msg) => setMsgA(msg), []);
const changeMessageB = useCallback((msg) => setMsgB(msg), []);

在浏览器测试效果:

useCallback 就是 useMemo的变种,二者在框架内部的处理逻辑基本相同,useMemo缓存的是函数执行结果,useCallback缓存的是函数本身。

懒加载&异步渲染

针对需要请求数据接口,后端返回结果后才能正常展示的组件,react提供了 Suspense 的处理方式,搭配 React.lazy实现异步组件的懒加载。 看下面的示例:

js 复制代码
// MockRemoteComp
export default function() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({
        // 加载一个 默认的组件对象
        default: () => <div>this is a remote component</div>
      })
    }, 2000);
  })
}

// LazyComp 组件
import MockRemoteComp from "./MockRemoteComp"
const RemoteComp = React.lazy(MockRemoteComp)

const LazyComp  = () => {
  return (
    <div>
      <p>test load a remote lazy component</p>
      <Suspense
        fallback={<div> loading comp ... </div>}
      >
        <RemoteComp />
      </Suspense>
    </div>
  )
}

React.lazy 接收一个Promise类型的数据,需要给它在成功回调后resolve一个默认的React组件。

这样 2 秒后,页面的状态发生改变,成功加载出异步组件,这里的Suspense提供了一种占位的功能,并明确了回退fallback的展示状态。

Suspense组件内部的执行流程如下:

另外在 react 18 中增加了 SuspenseList 这个组件API,用来支持多个 Suspense组件 的渲染控制。

jsx 复制代码
<SuspenseList revealOrder="forwards">
  <Suspense fallback={'loading A Comp...'}>
    <CompA  />
  </Suspense>
  <Suspense fallback={'loading B Comp...'}>
    <CompB  />
  </Suspense>
  <Suspense fallback={'loading C comp...'}>
    <CompC  />
  </Suspense>
</SuspenseList>

目前还是个体验的API,在V18.2.0版中实测还不能使用。

减少后面结点的插入

在react 调和的过程中,newReactElement 和 oldFiber 进行domDiff,当结点的type 和 key匹配时就会复用之前的结点,避免DOM结点的重新生成。在满足了复用的条件后,遵循下面的交换规律。

因此,在业务开发中,应尽量减少后面的结点移动到前面,来提高效率。

业务场景

在具体的业务中,可能会遇到长列表展示、搜索框、动画效果等场景。

虚拟列表

当列表中要渲染的数据量非常大时,如果不做优化,随着下拉滚动不停地追加新数据,展示在页面中的DOM元素越来越多,页面会逐渐变得反应卡顿。

这时,我们可以设置一个虚拟的滚动条,只展示视窗范围内的有限条数据,超出视窗范围的数据直接不渲染或销毁,并且用空的div结点来占位。 实时计算滚动条的位置,当滚动到某个位置时,展示对应可视范围内的数据。在实际操作的过程中,为避免用户手速过快,出现白屏的尴尬状况,可以上下预留出2屏的缓冲区。

防抖&节流

防抖&节流,就其本质,是降低触发的频次,减少react内部 reconcile的次数。

页面中的搜索输入框,属于高频操作的场景,及时做好防抖的处理。

js 复制代码
// 防抖 hook的简单实现
const useDebounce = (fn, ms, deps) => {
  const timer = useRef(null);
  useEffect(() => {
    if (timer.current) {
      clearTimeout(timer.current);
    }
    timer.current = setTimeout(() => {
      console.log('debounce action ...');
      fn()
    }, ms);
  }, deps)

  const cancel = () => {
    clearTimeout(timer);
    timer.current = null;
  }

  return [cancel];
}

当改变视窗的大小,下拉滚动条,拖拽事件等行为发生时,及时做好节流处理。

js 复制代码
// 节流hook的简单实现
function useThrottle(fn, ms, deps = []) {
  let previous = useRef(0);
  const [delay, setDelay] = useState(ms);

  useEffect(() => {

    if (Date.now() - previous.current > delay) {
      fn();
      previous.current = Date.now()
    }
  }, deps)

  const cancel = () => setDelay(0);
  return [cancel]
}

在一般的项目工程中,都会引入lodash这样的工具库,里面内置防抖、节流的功能。

CSS动画

页面上的动效交互,尽量交给浏览器来处理,避免在组件内部用状态变量来控制动画的属性参数。

  • 给元素结点添加动画样式类 (✅)
  • 操纵原生的DOM结点 (✅)
  • 使用定时器反复修改状态变量,来调整动画属性参数(❌)

推荐,使用动画样式类的方式

jsx 复制代码
<div className={ isAnimation ? 'normal z-moving' : 'normal'  } ></div>

避免,使用组件的状态变量,绑定动画属性

jsx 复制代码
// left, top 为 组件中通过 useState 生成的状态变量
<div className='normal' style={{ transform:`translate(${ left }px,${ top }px )` }}  ></div>

必要时,可以通过useRef获得原生的DOM结点,来直接修改外观样式,避免走react的渲染调和过程。

开启并发渲染

借助于 fiber 模式的重构,react 拥有了并发渲染的能力。当切换到 concurrent 模式下后,可以将耗时的任务分解,将高优先级的任务提前执行,避免阻塞用户在UI上的操作,提升交互体验。

jsx 复制代码
function App() {
  const list = new Array(3000).fill(0);
  return (
    <ul>
      {list.map((_, i) => <li>{i}</li>)}
    </ul>
  );
}

ReactDOM.render(<App/>, document.getElementById("#root")); // 同步渲染模式

在页面渲染 3000 个li结点,在同步模式下运行

如上图所示,在一次浏览器的事件循环周期内,会出现阻塞页面布局绘制的超长任务。JS执行线程占据了 73.65毫秒,严重超过了屏幕刷新频率的正常周期(16.7毫秒)。由于在浏览器中JS的执行线程和UI的渲染线程是互斥的,当JS执行时间过长,就占据了本该属于UI渲染绘制的时间,页面上出现掉帧卡顿的现象。

jsx 复制代码
ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

改造最后一句代码,开启并发的渲染模式

在新版的react中,增加了 scheduler模块,可以调度任务的优先级和顺序,所有的渲染调和任务将变成异步可中断的,将耗时长的任务拆解在不同的CPU时间片(默认5毫秒)内执行,这样可以保证页面的流畅运行,不会出现JS执行时间过长而造成页面卡顿的现象。

同时,在react v18中提供的 useTransition可以在不阻塞UI的前提下更新状态。

useTransition 不需要传入参数,返回 isPendingstartTransition一个是过度状态,另一个是开始过渡的回调函数。它能帮助我们处理高优先级的任务,让用户获得更及时的响应。

jsx 复制代码
import { useTransition } from 'react';

export default function TabButton({ children, isActive, onClick }) {
  const [isPending, startTransition] = useTransition();
  if (isActive) {
    return <b>{children}</b>
  }
  if (isPending) {
    return <b className="pending">{children}</b>;
  }
  return (
    <button onClick={() => {
      startTransition(() => {
        onClick();
      });
    }}>
      {children}
    </button>
  );
}

上面的程序中 isPending = true的时候,表示一个过渡(transition )正在进行中,需要在页面中展示一下loading状态。 当切换到需要消耗大量时间才能装载完成的Tab面板时,根据 pending 状态给出一个过渡效果,不致于让页面卡住。等到处于空闲状态时,再处理 startTransition 中的回调任务。

Transition 本质上是用于一些不是很急迫的更新上,在 React 18 之前,所有的更新任务都被视为急迫的任务,在 React 18 诞生了 concurrent Mode 模式,在这个模式下,渲染是可以中断,低优先级任务,可以让高优先级的任务先更新渲染。

useDeferredValue 是与 useTransition 相似功效的的一个hook。它的作用是延迟更新UI上的某个部分。 比如,一个常见的业务场景,一个搜索框下面紧挨着一个搜索列表,我们期望输入搜索文字时要流畅,下面的查询结果可以有一定的延迟。

jsx 复制代码
export default function App() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  return (
    <>
      <label>
        Search albums:
        <input value={query} onChange={e => setQuery(e.target.value)} />
      </label>
      <Suspense fallback={<h2>Loading...</h2>}>
        <SearchResults query={deferredQuery} />
      </Suspense>
    </>
  );
}

上面的 query状态对应的输入框会及时响应用户的输入,deferredQuery对应的搜索结果组件由于使用的是延迟的值(useDeferredValue),在数据加载完成前会展示之前的结果状态。

总结

本文从react的渲染流程的角度来讲述性能优化的方式,通过减少不必要的渲染流程,减少渲染的频率和次数,引入延迟渲染的机制,加快对用户操作的响应,进而提升用户体验。针对react应用的性能优化,也不仅限于此,还包括工程化的调优,前后端网络请求链路的优化等等。

参考

相关推荐
想自律的露西西★2 小时前
用el-scrollbar实现滚动条,拖动滚动条可以滚动,但是通过鼠标滑轮却无效
前端·javascript·css·vue.js·elementui·前端框架·html5
hummhumm5 小时前
第 28 章 - Go语言 Web 开发入门
java·开发语言·前端·python·sql·golang·前端框架
幼儿园的小霸王6 小时前
通过socket设置版本更新提示
前端·vue.js·webpack·typescript·前端框架·anti-design-vue
GISer_Jing11 小时前
React核心功能详解(一)
前端·react.js·前端框架
FØund40413 小时前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html
疯狂的沙粒14 小时前
如何在 React 项目中应用 TypeScript?应该注意那些点?结合实际项目示例及代码进行讲解!
react.js·typescript
鑫宝Code15 小时前
【React】React Router:深入理解前端路由的工作原理
前端·react.js·前端框架
沉默璇年1 天前
react中useMemo的使用场景
前端·react.js·前端框架
2401_882727571 天前
BY组态-低代码web可视化组件
前端·后端·物联网·低代码·数学建模·前端框架
红绿鲤鱼1 天前
React-自定义Hook与逻辑共享
前端·react.js·前端框架