当前位置: 首页 > 科技观察

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

时间:2023-03-15 18:49:39 科技观察

前言React性能优化是业务迭代过程中不得不考虑的问题。大多数情况下,是由于项目初期没有充分考虑到项目的复杂性,定位这个产品的用户量和技术场景并不复杂,所以我们前期可能不需要考虑性能优化事业阶段。但是随着业务场景的复杂化,性能优化变得异常重要。我们从React的源码入手,结合有道精品课前端的具体业务,采用优化技术对系统进行手术式优化。同时介绍一下性能优化工具ReactProfiler是如何帮助我们定位性能瓶颈的。本文项目代码均为有道前端群开发项目中的工作记录。如有不足之处,欢迎在留言区讨论交流。页面加载过程假定用户是第一次打开页面(无缓存)。此时,页面是完全空白的;加载html和引用的css,浏览器第一次渲染;react、react-dom、业务代码加载完成,应用第一次渲染,或者第一次内容渲染;应用代码开始执行,拉取数据,进行动态导入,响应事件等,完成后页面进入交互状态;然后开始慢慢加载图片等多媒体内容;直到页面的其他资源(如报错组件、打点报告组件等)加载完整个页面。下面主要分析一下React:React对渲染性能优化的三个方向同样适用于其他软件开发领域。这三个方向是:减少计算量:在React中,通过索引减少渲染节点的数量或者降低渲染的复杂度;Utilizecache:avoidre-renderinginReact(usememotoavoidcomponentrerendering);准确的重新计算范围:在React中绑定组件和状态关系,准确判断更新的‘时机’和‘范围’。仅重新渲染更改的组件(减少渲染范围)。这三点怎么办?我们先从React本身的特点开始分析。React工作流程React是一个声明式的UI库,它负责将State转换成页面结构(虚拟DOM结构),然后再转换成真实的DOM结构供浏览器渲染。当State发生变化时,React会先进行Reconciliation,结束后立即进入Commit阶段。Commit完成后,会显示新State对应的页面。React的Reconciliation需要做两件事:计算目标State对应的虚拟DOM结构。找到“将虚拟DOM结构修改为目标虚拟DOM结构”的最优解。React以深度优先的方式遍历虚拟DOM树。在一个虚拟DOM上完成Render和Diff的计算后,它会计算下一个虚拟DOM。Diff算法会记录虚拟DOM的更新方法(如:Update、Mount、Unmount),为Commit做准备。React的Commit还需要做两件事:将Reconciliation结果应用到DOM。调用暴露的hooks如:componentDidUpdate、useLayoutEffect等。接下来我们将对三个优化方向进行精准分析。01减少计算量关于Reconciliation和Commit这两个阶段的优化方法,我在实现过程中是按照减少计算量的方法来优化的(listitems使用key属性)。这个过程是优化的重点。React内部的Fiber结构和并发模式也在减少进程的耗时阻塞。Commit执行hook时,开发者要保证hook中的代码尽量轻量,避免阻塞耗时,同时避免在CDM和CDU周期更新组件。列表项使用key属性指定框架,提示也很友好。如果您不将key属性添加到列表中,控制台将显示一个大红色。系统会一直提醒你添加Key~~1.1优化Render进程Render进程:即在Reconciliation中计算出目标State对应的虚拟DOM结构。阶段。目前有3种方式可以触发React组件的Render进程:forceUpdate、Stateupdate、父组件Render触发子组件Render进程。●优化技巧PureComponent、React.memo在React工作流中,如果只更新父组件的state,即使父组件传递给子组件的所有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来避免子组件的重新渲染。useMemo减少了组件Render过程的耗时useMemo是一种加速缓存机制,当它的依赖没有变化时,不会触发重新计算。它一般用于非常耗时的场景,例如遍历一个大列表以获取统计信息。大列表渲染很明显useMemo的作用是缓存昂贵的计算(避免每次渲染时都进行高成本计算),在业务中使用它来控制变量更新类组件中的表shouldComponentUpdate,例如添加到一个array当使用一条数据时,当时的代码很可能是state.push(item)而不是constnewState=[...state,item]。在这样的背景下,当时的开发者经常使用shouldComponentUpdate来深入比较Props,只有在Props被修改时才执行组件的Render过程。今天,由于数据不可变和功能组件的流行,这样的优化场景将不再出现。为了契合shouldComponentUpdate的思想:给子组件传递props时,只传递它需要的props,而不是全部传递:传递给子组件的参数必须保证在子组件中使用。1.2批量更新,减少Render次数在React管理的事件回调和生命周期中,setState是异步的,其他时候setState是同步的。这个问题的根本原因是在React管理的事件回调和生命周期中,setState是批量更新的,而其他时候是立即更新的。批量更新setState时,多次执行setState只会触发一次Render过程。相反,当setState立即更新时,每次setState都会触发一次Render过程,对性能有影响。假设有如下组件代码,组件返回getData()的API请求结果后,分别更新两个State。组件在setList(data.list)后会触发组件的Render进程,setInfo(data.info)后又会触发Render进程,造成性能损失。那么我们如何解决它:将多个状态合并为一个状态。比如useState({list:null,info:null})替换了list和info这两个State。使用React官方提供的unstable_batchedUpdates方法将多个setStates封装到unstable_batchedUpdates回调中。修改后的代码如下:02精化渲染阶段2.1按优先级更新,及时响应用户优先级更新是批量更新的逆操作,思路是:先响应用户行为,再完成耗时操作。一个常见的场景是:页面弹出一个Modal。当用户点击Modal中的OK按钮时,代码将执行两个操作:关闭Modal。页面处理Modal返回的数据,展示给用户。当操作2需要500ms执行时,用户会明显感觉到点击按钮和关闭Modal之间的延迟。下面是大致的实现,使用slowHandle函数作为用户点击按钮时的回调函数。slowHandle()的非延迟执行需要很长时间才能执行,用户点击按钮后会明显感觉到页面卡住了。如果先让页面隐藏输入框,用户会立即感知到页面更新,不会有卡顿感。实现优先级更新的要点是将耗时任务移到下一个宏任务,优先响应用户行为。比如本例中将setNumbers移到setTimeout的回调中,用户点击按钮后可以立即看到输入框隐藏,不会有页面卡顿的感觉。mhd项目中优化后的代码如下:延迟执行2.2PublisherSubscriber跳过中间组件Render过程React建议将公共数据放在所有“需要这个状态的组件”的共同祖先上,但是把状态放在共同祖先上之后也就是说,state需要一层一层往下传递,直到传递给使用该state的组件。传统redux数据流的每一次状态更新都会涉及到中间组件的Render进程,但是中间组件并不关心状态,它的Render进程只负责将状态传递给子组件。在这种情况下,可以使用发布者-订阅者模式来维护状态。只有关心状态的组件才能订阅状态,不需要中间组件传递状态。当状态更新时,发布者发布数据更新消息,只有订阅者组件会触发Render过程,中间组件不再执行Render过程。只要是发布订阅者模式的库,都可以使用useContext来做这个优化。例如:redux、use-global-state、React.createContext等。在业务代码中的使用如下:从图中可以看出,优化后只有使用publicstate的组件renderTable才会更新,所以可以看出这样做可以大大减少父组件和其他renderSon...组件的Render次数(减少叶子节点的重新渲染)。2.3UseMemo返回虚拟DOM跳过组件。Render进程利用了useMemo可以缓存计算结果的特性。如果useMemo返回组件的虚拟DOM,则在useMemo依赖保持不变的情况下,将跳过组件的Render阶段。这种方法类似于React.memo,但相比React.memo有以下优点:更方便。React.memo需要将组件包装一次以生成新组件。而useMemo只需要在有性能瓶颈的地方使用,不需要修改组件。更灵活。useMemo不需要考虑组件的所有props,只需要考虑当前场景使用的值。您还可以使用useDeepCompareMemo对使用的值进行深度比较。本例中,父组件状态更新后,不使用useMemo的子组件会执行Render流程,而使用useMemo的子组件会按需执行更新。如何在业务代码中使用:03准确判断更新的'时机'和'范围'3.1debounce,throttleoptimization频繁触发的回调在搜索组件中,当input中的内容被修改时,会触发搜索回调。当组件快速处理搜索结果时,用户不会遇到输入延迟。但在实际场景中,中后台应用的列表页面非常复杂,组件渲染搜索结果会造成页面卡顿,明显影响用户的输入体验。搜索场景一般使用useDebounce+useEffect的方式获取数据。在搜索场景下,只需要响应用户最后一次输入,不需要响应用户中间的输入值。去抖比较合适。Throttle更适用于需要实时响应用户的场景,比如通过拖拽调整大小或者通过拖拽缩放(比如窗口的resize事件)。3.2懒加载在SPA中,懒加载优化一般用于从一条路由跳转到另一条路由。也可以用于用户操作后显示的复杂组件,比如点击按钮后显示的弹窗模块(数据量大的弹窗)。在这些场景中,结合CodeSplit的好处更高。延迟加载是通过Webpack的动态导入和React.lazy方法实现的。在实现懒加载优化时,不仅要考虑加载状态,还需要对加载失败进行容错处理。3.3LazyRendering惰性渲染是指在组件进入或即将进入可见区域时进行渲染。对于Modal/Drawer等常见组件,只有visible属性为true时才会渲染组件的内容,也可以认为是惰性渲染的一种实现。惰性渲染的场景包括:组件在页面出现多次,组件渲染耗时,或者组件包含接口请求。如果渲染多个有请求的组件,由于浏览器限制了同一域名下的并发请求数,可能会阻塞可见区域其他组件的请求,导致可见区域内容延迟显示。用户操作后需要显示的组件。这个和懒加载是一样的,但是懒渲染不需要动态加载模块,不需要考虑加载状态和加载失败的处理,实现起来更简单。在懒渲染的实现中,借助react-visibility-observer依赖判断组件是否出现在可见区域:3.4虚拟列表虚拟列表是懒渲染的一种特殊场景。虚拟列表的组件是react-window和react-virtualized,都是同一作者开发的。react-window是react-virtualized的轻量级版本,具有更友好的API和文档。推荐使用react-window,只需要计算每个item的高度:如果每个item的高度发生变化,可以传一个函数给itemSize参数。因此,在开发过程中,当接口返回所有数据时,建议使用虚拟列表优化,提前预防此类性能瓶颈需求。可以点击【阅读原文】查看使用示例。3.5动画库直接修改DOM属性,跳过组件Render阶段。这个优化在业务上应该用不到,但是还是非常值得学习的,以后可以应用到组件库中。参考react-spring的动画实现。当一个动画开始时,每次动画属性的改变不会导致组件重新渲染,而是直接修改dom上的相关属性值:3.6避免在didMount和didUpdate中更新组件State。该技术不仅适用于didMount、didUpdate,还包括特殊场景下的willUnmount、useLayoutEffect和useEffect(当触发父组件的cDU/cDM时,会同步调用子组件的useEffect)。为了描述方便,将它们统称为“提交阶段钩子”。React工作流提交阶段的第二步是执行提交阶段钩子,它们的执行会阻止浏览器更新页面。如果在commit阶段的钩子函数中更新了组件State,会再次触发组件的更新过程,导致两倍的耗时,一般在commit阶段的hook中更新组件状态的场景包括:计算并更新组件的派生状态(DerivedState),在这种场景下,类组件应该换成getDerivedStateFromProps钩子方法,函数组件换成调用函数时执行setState的方法。使用以上两种方法,React将在一次更新中完成新状态和派生状态,根据DOM信息修改组件状态。在这种场景下,除非有办法不依赖DOM信息,否则这两个更新过程缺一不可,只能采用其他优化技术。use-swr的源代码使用了这种优化技术。当某个接口有缓存数据时,use-swr会优先使用该接口的缓存数据,在调用requestIdleCallback时重新发起请求获取最新的数据。模拟一个swr:它的第二个参数deps是当请求有参数时,如果参数发生变化,则重新发起请求。暴露给调用者的fetch函数可以处理主动刷新场景,例如页面上的刷新按钮。如果use-swr不做这个优化,会在useLayoutEffect中触发重新验证,并设置isValidating状态为true,造成组件更新过程,造成性能损失。04工具介绍——ReactProfiler4.1ReactProfiler定位Render进程瓶颈ReactProfiler是React官方提供的性能审查工具。本文仅介绍笔者的使用心得。详细手册请参考官网文档。注意:react-dom16.5+只支持DEV模式的Profiling,生产环境也可以通过profilingbundlereact-dom/profiling支持。在fb.me/react-profi查看如何使用这个包……“Profiler”面板最初是空的。可以点击record按钮开始profile:4.2Profiler只记录Render过程的耗时。不要使用Profiler来定位非Render进程的性能瓶颈。通过ReactProfiler,开发者可以查看组件Render过程的耗时,但是无法得知提交阶段的耗时。小时。Profiler面板中虽然有Committat字段,但是这个字段是相对于录制开始时间的,没有任何意义。通过使用Reactv16进行实验,Chrome的Performance和ReactProfiler统计信息同时打开。如下图,在Performance面板中,Reconciliation和Commit阶段分别耗时642ms和300ms,而Profiler面板只显示642ms:4.3打开“Recordcomponentupdatereason”点击面板上的齿轮,然后勾选“Recordwhycomponentrerenderedwhileprofiling.”,如下图:然后点击面板中的虚拟DOM节点,右侧会显示组件重新渲染的原因。4.4定位这个Render进程的原因由于React的批量更新(BatchUpdate)机制,生成一个Render进程可能会涉及到很多组件的状态更新。那么如何定位是哪个组件状态更新引起的呢?在Profiler面板左侧的虚拟DOM树结构中,从上到下检查每个渲染(未灰显)的组件。如果组件是因为State或者Hook的变化触发了Render进程,那么就是我们要找的组件,如下图:4.5站在巨人的肩膀上优化性能https://react.docschina.org/docs/optimizing-performance.htmlReact官方文档,最佳教程,利用React的分析工具。TwitterLite和大规模高性能React渐进式Web应用程序https://medium.com/@paularmstrong/twitter-lite-and-high-performance-react-progressive-web-apps-at-scale-d28a00e780a3查看Twitter是如何优化的的。