从源码中来,到业务中去,React性能优化终极指南

标签: 技术分享 大前端 react | 发表时间:2021-05-27 11:03 | 作者:youdao
出处:http://techblog.youdao.com

前言:我们从React源码入手,结合有道精品课大前端的具体业务,运用三大原则对系统进行外科手术式的优化。同时介绍React Profiler这款工具如何帮我们定位性能瓶颈前言:我们从React源码入手,结合有道精品课大前端的具体业务,运用三大原则对系统进行外科手术式的优化。同时介绍React Profiler这款工具如何帮我们定位性能瓶颈

作者/ 安增平

编辑/ Ein

React性能优化是在业务迭代过程中不得不考虑的问题,大部分情况是由于项目启动之初,没有充分考虑到项目的复杂度,定位该产品的用户体量及技术场景并不复杂,那么我们在业务前期可能并不需要考虑性能优化。但是随着业务场景的复杂化,性能优化就变得格外重要。

我们从React源码入手,结合有道精品课大前端的具体业务,运用优化技巧对系统进行外科手术式的优化。同时介绍一下React Profiler这款性能优化的利器是如何帮我们定位性能瓶颈的。

本文中的项目代码全部是在有道大前端组开发项目中的工作记录,如有不足欢迎在留言区讨论交流,笔芯❤

页面加载流程

  1. 假设用户首次打开页面(无缓存),这个时候页面是完全空白的;
  2. html 和引用的 css 加载完毕,浏览器进行 首次渲染
  3. react、react-dom、业务代码加载完毕,应用第一次渲染,或者说 首次内容渲染
  4. 应用的代码开始执行,拉取数据、进行动态import、响应事件等等,完毕后页面进入 可交互状态;
  5. 接下来 lazyload 的图片等多媒体内容开始逐渐加载完毕;
  6. 直到页面的其它资源(如错误上报组件、打点上报组件等)加载完毕,整个页面加载完成。

我们主要来针对React进行剖析

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

  1. 减少计算的量:React 中就是减少渲染的节点或通过索引减少渲染复杂度;
  2. 利用缓存:React 中就是避免重新渲染(利用 memo 方式来避免组件重新渲染);
  3. 精确重新计算的范围:React 中就是绑定组件和状态关系, 精确判断更新的’时机’和’范围’. 只重新渲染变更的组件(减少渲染范围)。

如何做到这三点呢?我们从React本身的特性入手分析。

React 工作流

React 是声明式 UI 库,负责将 State 转换为页面结构(虚拟 DOM 结构)后,再转换成真实 DOM 结构,交给浏览器渲染。State 发生改变时,React 会先进行Reconciliation,结束后立刻进入Commit阶段,Commit结束后,新 State 对应的页面才被展示出来。

React 的 Reconciliation需要做两件事:

  1. 计算出目标 State 对应的虚拟 DOM 结构。
  2. 寻找「将虚拟 DOM 结构修改为目标虚拟 DOM 结构」的最优方案。

React 按照深度优先遍历虚拟 DOM 树的方式,在一个虚拟 DOM 上完成Render和Diff的计算后,再计算下一个虚拟 DOM。Diff 算法会记录虚拟 DOM 的更新方式(如:Update、Mount、Unmount),为Commit做准备。

React 的 Commit也需要做两件事:

  1. 将Reconciliation结果应用到 DOM 中。
  2. 调用暴露的hooks如:componentDidUpdate、useLayoutEffect 等。

下面我们将针对三个优化方向进行精准分析。

减少计算的量

关于以上 ReconciliationCommit两个阶段的优化办法,我在实现的过程中遵循 减少计算量的方法进行优化( 列表项使用 key 属性)该过程是优化的重点,React 内部的 Fiber 结构和并发模式也是在减少该过程的耗时阻塞。对于 Commit在执行hooks时,开发者应保证hooks中的代码尽量轻量,避免耗时阻塞,同时应避免在 CDM、CDU周期中更新组件。

列表项使用 key 属性

特定框架中,提示也做的十分友好。假如你没有在列表中添加key属性,控制台会为你展示一片大红

系统会时刻提醒你记得加Key哦~~

优化Render 过程

Render 过程:即Reconciliation中计算出目标 State 对应的虚拟 DOM 结构这一阶段 。

触发 React 组件的 Render 过程目前有三种方式:

  1. forceUpdate、
  2. State 更新、
  3. 父组件 Render 触发子组件 Render 过程。

优化技巧

PureComponent、React.memo

在 React 工作流中,如果只有父组件发生状态更新,即使父组件传给子组件的所有 Props 都没有修改,也会引起子组件的 Render 过程。

从 React 的声明式设计理念来看,如果子组件的 Props 和 State 都没有改变,那么其生成的 DOM 结构和副作用也不应该发生改变。当子组件符合声明式设计理念时,就可以忽略子组件本次的 Render 过程。

PureComponent 和 React.memo 就是应对这种场景的,PureComponent 是对类组件的 Props 和 State 进行浅比较,React.memo 是对函数组件的 Props 进行浅比较。

useMemo、useCallback 实现稳定的 Props 值

如果传给子组件的派生状态或函数,每次都是新的引用,那么 PureComponent 和 React.memo 优化就会失效。所以需要使用 useMemo 和 useCallback 来生成稳定值,并结合 PureComponent 或 React.memo 避免子组件重新 Render。

useMemo 减少组件 Render 过程耗时

useMemo 是一种缓存机制提速,当它的依赖未发生改变时,就不会触发重新计算。一般用在「计算派生状态的代码」非常耗时的场景中,如:遍历大列表做统计信息。

显然useMemo的作用是缓存昂贵的计算(避免在每次渲染时都进行高开销的计算),在业务中使用它去控制变量来更新表格

shouldComponentUpdate

在类组件中,例如要往数组中添加一项数据时,当时的代码很可能是 state.push(item),而不是 const newState = [...state, item]。

在此背景下,当时的开发者经常使用

shouldComponentUpdate 来深比较 Props,只在 Props 有修改才执行组件的 Render 过程。如今由于数据不可变性和函数组件的流行,这样的优化场景已经不会再出现了。

为了贴合shouldComponentUpdate的思想:给子组件传props的时候一定只传其需要的而并非一股脑全部传入:

传入到子组件的参数一定保证其在自组件中被使用到。

批量更新,减少 Render 次数

在 React 管理的事件回调和生命周期中,setState 是异步的,而其他时候 setState 都是同步的。这个问题根本原因就是 React 在自己管理的事件回调和生命周期中,对于 setState 是批量更新的,而在其他时候是立即更新的。

批量更新 setState 时,多次执行 setState 只会触发一次 Render 过程。相反在立即更新 setState 时,每次 setState 都会触发一次 Render 过程,就存在性能影响。

假设有如下组件代码,该组件在 getData() 的 API 请求结果返回后,分别更新了两个 State 。

该组件会在 setList(data.list) 后触发组件的 Render 过程,然后在 setInfo(data.info) 后再次触发 Render 过程,造成性能损失。那我们该如何解决呢:

  1. 将多个 State 合并为单个 State。例如 useState({ list: null, info: null }) 替代 list 和 info 两个 State。
  2. 使用 React 官方提供的 unstable_batchedUpdates 方法,将多次 setState 封装到 unstable_batchedUpdates 回调中。

修改后代码如下:

精细化渲染阶段

按优先级更新,及时响应用户

优先级更新是批量更新的逆向操作,其思想是:优先响应用户行为,再完成耗时操作。常见的场景是:页面弹出一个 Modal,当用户点击 Modal 中的确定按钮后,代码将执行两个操作:

  1. 关闭 Modal。
  2. 页面处理 Modal 传回的数据并展示给用户。

当操作2需要执行500ms时,用户会明显感觉到从点击按钮到 Modal 被关闭之间的延迟。

以下为一般的实现方式,将 slowHandle 函数作为用户点击按钮的回调函数。

slowHandle() 执行过程耗时长,用户点击按钮后会明显感觉到页面卡顿。

如果让页面优先隐藏输入框,用户便能立刻感知到页面更新,不会有卡顿感。

实现优先级更新的要点是将耗时任务移动到下一个宏任务中执行,优先响应用户行为。

例如在该例中,将 setNumbers 移动到 setTimeout 的回调中,用户点击按钮后便能立即看到输入框被隐藏,不会感知到页面卡顿。mhd项目中优化后的代码如下:

发布者订阅者跳过中间组件 Render 过程

React 推荐将公共数据放在所有「需要该状态的组件」的公共祖先上,但将状态放在公共祖先上后,该状态就需要层层向下传递,直到传递给使用该状态的组件为止。

每次状态的更新都会涉及中间组件的 Render 过程,但中间组件并不关心该状态,它的 Render 过程只负责将该状态再传给子组件。在这种场景下可以将状态用发布者订阅者模式维护,只有关心该状态的组件才去订阅该状态,不再需要中间组件传递该状态。

当状态更新时,发布者发布数据更新消息,只有订阅者组件才会触发 Render 过程,中间组件不再执行 Render 过程。

只要是发布者订阅者模式的库,都可以使用useContext进行该优化。比如:redux、use-global-state、React.createContext 等。

业务代码中的使用如下:

从图中可看出,优化后只有使用了公共状态的组件renderTable才会发生更新,由此可见这样做可以大大减少父组件和 其他renderSon… 组件的 Render 次数(减少叶子节点的重渲染)。

useMemo 返回虚拟 DOM 可跳过该组件 Render 过程

利用 useMemo 可以缓存计算结果的特点,如果 useMemo 返回的是组件的虚拟 DOM,则将在 useMemo 依赖不变时,跳过组件的 Render 阶段。

该方式与 React.memo 类似,但与 React.memo 相比有以下优势:

  1. 更方便。React.memo 需要对组件进行一次包装,生成新的组件。而 useMemo 只需在存在性能瓶颈的地方使用,不用修改组件。
  2. 更灵活。useMemo 不用考虑组件的所有 Props,而只需考虑当前场景中用到的值,也可使用 useDeepCompareMemo 对用到的值进行深比较。

该例子中,父组件状态更新后,不使用 useMemo 的子组件会执行 Render 过程,而使用 useMemo 的子组件会按需执行更新。业务代码中的使用方法:

精确判断更新的’时机’和’范围’

debounce、throttle 优化频繁触发的回调

在搜索组件中,当 input 中内容修改时就触发搜索回调。当组件能很快处理搜索结果时,用户不会感觉到输入延迟。

但实际场景中,中后台应用的列表页非常复杂,组件对搜索结果的 Render 会造成页面卡顿,明显影响到用户的输入体验。

在搜索场景中一般使用 useDebounce+ useEffect 的方式获取数据。

在搜索场景中,只需响应用户最后一次输入,无需响应用户的中间输入值,debounce 更适合。而 throttle 更适合需要实时响应用户的场景中更适合,如通过拖拽调整尺寸或通过拖拽进行放大缩小(如:window 的 resize 事件)。

懒加载

在 SPA 中,懒加载优化一般用于从一个路由跳转到另一个路由。

还可用于用户操作后才展示的复杂组件,比如点击按钮后展示的弹窗模块(大数据量弹窗)。

在这些场景下,结合 Code Split 收益较高。懒加载的实现是通过 Webpack 的动态导入和 React.lazy 方法。

实现懒加载优化时,不仅要考虑加载态,还需要对加载失败进行容错处理。

懒渲染

懒渲染指当组件进入或即将进入可视区域时才渲染组件。常见的组件 Modal/Drawer 等,当 visible 属性为 true 时才渲染组件内容,也可以认为是懒渲染的一种实现。懒渲染的使用场景有:

  1. 页面中出现多次的组件,且组件渲染费时、或者组件中含有接口请求。如果渲染多个带有请求的组件,由于浏览器限制了同域名下并发请求的数量,就可能会阻塞可见区域内的其他组件中的请求,导致可见区域的内容被延迟展示。
  2. 需用户操作后才展示的组件。这点和懒加载一样,但懒渲染不用动态加载模块,不用考虑加载态和加载失败的兜底处理,实现上更简单。

懒渲染的实现中判断组件是否出现在可视区域内借助react-visibility-observer依赖:

虚拟列表

虚拟列表是懒渲染的一种特殊场景。虚拟列表的组件有 react-window和 react-virtualized,它们都是同一个作者开发的。

react-window 是 react-virtualized 的轻量版本,其 API 和文档更加友好。推荐使用 react-window,只需要计算每项的高度即可:

如果每项的高度是变化的,可给 itemSize 参数传一个函数。

所以在开发过程中,遇到接口返回的是所有数据时,需提前预防这类会有展示的性能瓶颈的需求时,推荐使用虚拟列表优化。使用示例: react-window​react-window.vercel.app

动画库直接修改 DOM 属性,跳过组件 Render 阶段

这个优化在业务中应该用不上,但还是非常值得学习的,将来可以应用到组件库中。

参考 react-spring 的动画实现,当一个动画启动后,每次动画属性改变不会引起组件重新 Render ,而是直接修改了 dom 上相关属性值:

避免在 didMount、didUpdate 中更新组件 State

这个技巧不仅仅适用于 didMount、didUpdate,还包括 willUnmount、useLayoutEffect 和特殊场景下的 useEffect(当父组件的 cDU/cDM 触发时,子组件的 useEffect 会同步调用),本文为叙述方便将他们统称为「提交阶段钩子」。

React 工作流commit阶段的第二步就是执行提交阶段钩子,它们的执行会阻塞浏览器更新页面。

如果在提交阶段钩子函数中更新组件 State,会再次触发组件的更新流程,造成两倍耗时。一般在提交阶段的钩子中更新组件状态的场景有:

  1. 计算并更新组件的派生状态(Derived State)。在该场景中,类组件应使用 getDerivedStateFromProps 钩子方法代替,函数组件应使用函数调用时执行 setState 的方式代替。使用上面两种方式后,React 会将新状态和派生状态在一次更新内完成。
  2. 根据 DOM 信息,修改组件状态。在该场景中,除非想办法不依赖 DOM 信息,否则两次更新过程是少不了的,就只能用其他优化技巧了。

use-swr 的源码就使用了该优化技巧。当某个接口存在缓存数据时,use-swr 会先使用该接口的缓存数据,并在 requestIdleCallback 时再重新发起请求,获取最新数据。模拟一个swr:

  1. 它的第二个参数 deps,是为了在请求带有参数时,如果参数改变了就重新发起请求。
  2. 暴露给调用方的 fetch 函数,可以应对主动刷新的场景,比如页面上的刷新按钮。

如果 use-swr 不做该优化的话,就会在 useLayoutEffect 中触发重新验证并设置 isValidating 状态为 true·,引起组件的更新流程,造成性能损失。

工具介绍——React Profiler

React Profiler 定位 Render 过程瓶颈

React Profiler 是 React 官方提供的性能审查工具,本文只介绍笔者的使用心得,详细的使用手册请移步官网文档。

Note:react-dom 16.5+ 在 DEV 模式下才支持 Profiling,同时生产环境下也可以通过一个 profiling bundle react-dom/profiling 来支持。请在 fb.me/react-profi… 上查看如何使用这个 bundle。

“Profiler” 的面板在刚开始的时候是空的。你可以点击 record 按钮来启动 profile:

Profiler 只记录了 Render 过程耗时

不要通过 Profiler 定位非 Render 过程的性能瓶颈问题

通过 React Profiler,开发者可以查看组件 Render 过程耗时,但无法知晓提交阶段的耗时。

尽管 Profiler 面板中有 Committed at 字段,但这个字段是相对于录制开始时间,根本没有意义。

通过在 React v16 版本上进行实验,同时开启 Chrome 的 Performance 和 React Profiler 统计。

如下图,在 Performance 面板中,Reconciliation和Commit阶段耗时分别为 642ms 和 300ms,而 Profiler 面板中只显示了 642ms:

开启「记录组件更新原因」

点击面板上的齿轮,然后勾选「Record why each component rendered while profiling.」,如下图:

然后点击面板中的虚拟 DOM 节点,右侧便会展示该组件重新 Render 的原因。

定位产生本次 Render 过程原因

由于 React 的批量更新(Batch Update)机制,产生一次 Render 过程可能涉及到很多个组件的状态更新。那么如何定位是哪些组件状态更新导致的呢?

在 Profiler 面板左侧的虚拟 DOM 树结构中,从上到下审查每个发生了渲染的(不会灰色的)组件。

如果组件是由于 State 或 Hook 改变触发了 Render 过程,那它就是我们要找的组件,如下图:

站在巨人的肩膀上

Optimizing Performance React 官方文档,最好的教程, 利用好 React 的性能分析工具。

Twitter Lite and High Performance React Progressive Web Apps at Scale 看看 Twitter 如何优化的。

-END-

相关 [源码 业务 react] 推荐:

从源码中来,到业务中去,React性能优化终极指南

- - 有道技术沙龙博客
前言:我们从React源码入手,结合有道精品课大前端的具体业务,运用三大原则对系统进行外科手术式的优化. 同时介绍React Profiler这款工具如何帮我们定位性能瓶颈前言:我们从React源码入手,结合有道精品课大前端的具体业务,运用三大原则对系统进行外科手术式的优化. 同时介绍React Profiler这款工具如何帮我们定位性能瓶颈.

谈谈 React Native

- - 唐巧的技术博客
几天前,Facebook 在 React.js Conf 2015 大会上推出了 React Native( 视频链接). 我发了一条微博( 地址),结果引来了 100 多次转发. 为什么 React Native 会引来如此多的关注呢. 我在这里谈谈我对 React Native 的理解. 一个新框架的出现总是为了解决现有的一些问题,那么对于现在的移动开发者来说,到底有哪些问题 React Native 能涉及呢.

Webpack 和 React 小书

- - SegmentFault 最新的文章
Webpack 和 React 小书. 这本小书的目的是引导你进入 React 和 Webpack 的世界. 他们两个都是非常有用的技术,如果同时使用他们,前端开发会更加有趣. 这本小书会提供所有相关的技能. 如果你只是对 React 感兴趣,那可以跳过 Webpack 相关的内容,反之亦然. 如果想学习更多的相关知识可以移步 SurviveJS - Webpack and React.

React入门实例学习

- - JavaScript - Web前端 - ITeye博客
        React可以在浏览器运行,也可以在服务器运行,但是在这为了尽量保持简单,且React语法是一致的,服务器的用法和浏览器差别不大,在这只涉及浏览器. 一. HTML模板.         使用React的网页源码,结构大致如下:.         1.最后一个script标签的type属性为text/jsx.

轻松入门React和Webpack

- - SegmentFault 最新的文章
小广告:更多内容可以看 我的博客和 读书笔记. 最近在学习React.js,之前都是直接用最原生的方式去写React代码,发现组织起来特别麻烦,之前听人说用Webpack组织React组件得心应手,就花了点时间学习了一下,收获颇丰. 一个组件,有自己的结构,有自己的逻辑,有自己的样式,会依赖一些资源,会依赖某些其他组件.

React 入门实例教程

- - 阮一峰的网络日志
现在最热门的前端框架,毫无疑问是 React. 上周,基于 React 的 React Native 发布,结果一天之内,就获得了 5000 颗星,受瞩目程度可见一斑. React 起源于 Facebook 的内部项目,因为该公司对市场上所有 JavaScript MVC 框架,都不满意,就决定自己写一套,用来架设 Instagram 的网站.

React Native 原理与实践

- - 掘金 前端
React Native 介绍. 什么是 React Native. React Native 是一个由 Facebook 于 2015 年 9 月发布的一款开源的 JavaScript 框架,它可以让开发者使用 JavaScript 和 React 来开发跨平台的移动应用. 它既保留了 React 的开发效率,又同时拥有 Native 应用的良好体验,加上 Virtual DOM 跨平台的优势,实现了真正意义上的:.

React Native通信机制详解

- - bang's blog
React Native是facebook刚开源的框架,可以用javascript直接开发原生APP,先不说这个框架后续是否能得到大众认可,单从源码来说,这个框架源码里有非常多的设计思想和实现方式值得学习,本篇先来看看它最基础的JavaScript-ObjectC通信机制(以下简称JS/OC). 普通的JS-OC通信实际上很简单,OC向JS传信息有现成的接口,像webview提供的-stringByEvaluatingJavaScriptFromString方法可以直接在当前context上执行一段JS脚本,并且可以获取执行后的返回值,这个返回值就相当于JS向OC传递信息.

使用 React 和 Next.js 构建博客

- -
Next.js是由 Vercel 创建和维护的基于 React 的应用程序框架. Next.js构建一个小型的博客网站:. Markdown文件生成的动态路由. 服务器端渲染(在请求时渲染). 本教程将通过创建一个简单的博客来展示. Next.js适合这样的博客的开发吗. 先来了解一下一般博客都需要什么.

ChatGPT ReAct (Reason+Act) 模式实现

- - hooopo (Hooopo)
ChatGPT 是一个语言模型,对自然语言的理解和输出比人类要强很多,对编程语言和结构化处理相关的问题更是比人类好很多. 对于开发者来说,目前 ChatGPT 存在的几个问题:. 在 Chat 模型里对话过长会出现失忆现象. 前两个问题可以通过 数据填充机制(Augmentation)解决. 后几个问题一般引入 ReAct(Reason+Act) 模式来解决.