React渲染性能优化的若干问题

@重新渲染

重新渲染是什么?

组件函数的重新执行。

重新渲染的来源是什么?

React 中所有重新渲染的来源都是状态更新。状态更新后会沿组件树向下传播,导致依赖它的嵌套组件也重新渲染。

重新渲染是 props 引起的吗?

“props 更改时会引发重新渲染”—— 这句话的适用情况是当组件被 React.memo 包裹时。不能说所有组件更新都跟 props 有关,实际上可以说是毫无关系,根本原因在于state的改变。

什么是“状态下移”?

“状态下移”是一种有效的技术,用于通过将状态及其相关的组件隔离到更小的、更独立的组件中来防止不必要的重新渲染。

什么是“钩子下移”?

“当自定义钩子内部发生状态更新时,使用该钩子的组件将触发重新渲染”由于状态被钩子包裹并隐藏了,所以这个事实往往被人忽略,从而导致性能问题。所以应该避免在距离根组件较近的地方大量使用钩子,而应该将其下移。

@设计模式

为什么将组件作为 prop 传递可以提高应用程序的性能?

将组件作为 prop 传递可以显著提高性能,因为传入的组件在宿主状态更新时不会重新渲染。当组件作为 prop 传入时,宿主组件一整个重新渲染过程中涉及到的对Fiber树的比较和更新都跟prop组件无关,因为prop是作为组件渲染产物中的外部值和确定值存在的,不在比较的内容范围中。

具体而言,当一个组件(比如 SlowComponent)在父组件中被声明并作为 prop(例如 content 或 children)传递给子组件(例如 ScrollableWithMovingBlock)时,该 SlowComponent 的元素对象是在父组件的作用域内创建的。

当 ScrollableWithMovingBlock(子组件)的状态更新并触发自身重新渲染时,React 会调用 ScrollableWithMovingBlock 的函数。在该函数内部,它渲染了作为 content 或 children prop 接收到的元素。由于这个 content 或 children prop 的值是一个在父组件中创建的不变的引用(在子组件重新渲染时,这个引用并没有改变),React 在比较子组件返回的元素树时,会发现 Object.is(contentBeforeRender, contentAfterRender) 返回 true。因此,React 会跳过对作为 prop 传入的 SlowComponent 的重新渲染,因为它认为这个部分没有发生变化。

组件作为 props 传递的模式如何提高代码质量?

在组织内部的项目或者一些开源项目中,都会经常遇到 props 爆炸式增长的情况,使得组件的内部逻辑变得复杂且难以理解,这时可以结合具体情况,考虑使用组件作为 Props 的模式来解决。

例如,一个需要支持多种图标和头像的 Button 组件,可能最终会有一半的 props 专门用于控制这些内部元素(如 isLoading、iconLeftName、iconLeftColor、isIconLeftAvatar 等)。这不仅使得组件难以维护,每次修改都可能导致现有功能损坏,而且消费者也难以记住和正确使用所有这些 props。

组件作为 props 传递的模式通过将配置责任下放给使用者来解决这个问题。组件不再通过复杂的 props 来控制其内部元素的行为,而是直接接受一个完整的元素作为 prop,从而大大简化了父组件的接口和内部逻辑。父组件的责任被限制为仅仅将接收到的元素渲染到预期的位置。

render props在当前Hooks范式下仍有用武之地吗?

尽管Hook在99%的情况下取代了render props,但render props仍然在特定场景下有用,特别是当共享的逻辑或数据直接与DOM元素相关时。

在处理与DOM元素相关的状态(例如滚动位置)时,render props可以提供一个更直接的解决方案。对于像ScrollDetector这样的组件,它监听自身DOM元素的滚动事件并获取滚动位置。使用render props,可以直接将这个滚动位置作为children函数的参数传递,如{children(scroll)}。消费者可以直接在children函数中访问这个值,并基于它来渲染UI。相比之下,如果尝试使用Hook来解决类似问题,可能需要引入Ref来引用DOM元素,并将其传递给Hook或在Hook内部进行管理,这可能会增加一些复杂性。在这种情况下,render props可以更自然地将DOM事件监听和状态共享结合起来。

@记忆化

React 中记忆化(Memoization)试图解决什么问题?

记忆化在 React 中主要解决的问题是重新渲染过程中的引用变化

React 在每次重新渲染时需要比较大量值(===),如带有依赖项的 Hooks(如 useEffect)。

如果一个对象在组件每次重新渲染时都被重新创建(因为它在组件内部声明),即使两个对象看起来完全相同,由于重新创建后引用不同,它们的比较(===)也会返回 false,这会导致依赖于它的 Hook 每次都触发。

记忆化的目标就是确保这些非原始值在重新渲染之间保持相同的引用。

useMemo 和 useCallback Hooks 有什么区别?

useCallback 记忆化一个函数本身。

它接受一个函数作为第一个参数,并返回该函数的记忆化版本。这意味着,只要依赖项数组没有改变,useCallback 就会返回相同的函数引用。

useMemo 记忆化一个函数的返回值。

它接受一个函数作为第一个参数,并执行该函数,然后记忆化其返回结果。只要依赖项数组没有改变,useMemo 就会返回相同的结果引用。

传入 useMemo 或 useCallback 的函数本身在每次重新渲染时仍会被重新创建。然而,这些 Hooks 会根据依赖项的变化来决定是返回缓存的版本还是新的版本。

传递给未记忆化组件为什么是一种反模式?

将 useCallback 或 useMemo 应用于传递给未记忆化组件(即未用 React.memo 包裹的组件)的 Props 是一种反模式。

如果一个父组件重新渲染,那么它的所有子组件(包括那些未被 React.memo 包裹的)也会重新渲染。即使记忆化了传递给子组件的 Props,子组件仍然会重新渲染。这意味着所做的记忆化工作是徒劳的。

导致 React.memo 失效的若干情况

在使用 React.memo 时,记忆化组件的 Props 很难,因为任何疏忽都会导致 React.memo 失效。

  1. Props 链式传递(Prop Spreading):当 Props 通过 ...props 这种方式从父组件传递给被 React.memo 包裹的子组件时,如果原始 Props 中包含未记忆化的非原始值,记忆化就会被破坏。
  2. 来自其他组件或自定义 Hook 的非原始 Props:如果将从另一个组件或自定义 Hook 获得的非原始值(如函数或数据对象)直接传递给记忆化的组件,而这些值没有被稳定地记忆化,那么记忆化也会被破坏。自定义 Hook 尤其隐蔽,因为可能无法从调用站点判断其返回值是否稳定。
  3. 作为 Props 的子元素(Children as Props):在 JSX 中嵌套的子元素(children Prop)本身就是一个非原始对象(JSX 元素)。如果这些子元素没有被记忆化,那么即使父组件被 React.memo 包裹,它也会重新渲染。

如何正确使用 useMemo ?

useMemo 经常被误用。正确的做法是:

  1. 首先测量:在决定使用 useMemo 之前,务必先测量“昂贵计算”是否真的耗时。在实际用户的设备和上下文环境中进行测量,并与组件重新渲染的成本进行比较。
  2. 理解 useMemo 的局限性:useMemo 只对组件重新渲染时的性能有帮助。如果组件从不重新渲染,那么 useMemo 只是增加了额外的开销(初始渲染时的缓存)。

@Key

Key的作用?

Key在强制重新渲染和保留渲染状态两个方面发挥作用。原理时将不同的组件通过Key值标记为相同或不同。

为什么不应该在其他组件内部创建组件?

在其他组件内部创建组件(例如,const Component = () => { const Input = () => <input />; return <Input />; })是一个反模式。

每次父组件重新渲染时,内部定义的组件函数都会被重新创建,生成一个新的函数引用。当React进行差异化时,它会比较这些“类型”字段,发现它们的引用不同(因为a === b对两个相同的函数定义也会返回false),即使它们在逻辑上是同一个组件。结果是,React会认为前一个组件已经消失,并重新挂载一个新的组件。这种强制性的重新挂载(unmounting和mounting)比简单的重新渲染效率低得多,会导致组件状态丢失(如输入框的值或焦点),并可能引起屏幕“闪烁”。

当两个看似相同的组件根据条件渲染时,为什么它们的内部状态会保留?

在某些条件下,如果两个不同的条件分支都渲染了相同类型的组件(例如,isCompany ? <Input id="company-tax-id-number" /> : <Input id="person-tax-id-number" />),React会认为这两个Input组件是同一个实例。因此,React不会卸载并重新挂载组件,而是简单地更新现有Input实例的props(如id和placeholder)。这意味着Input的内部状态(例如,用户输入的文本)将保留,因为组件实例本身没有被销毁和重建。

"key"属性是否只用于动态列表?什么是“状态维持”和“状态重置”?

不,key属性不仅限于动态列表。它可以在任何React元素上使用,并且其核心功能是帮助React在重新渲染之间识别同一位置的元素是相同还是不同。我们可以利用key在非列表场景中强制组件进行卸载和挂载(“状态重置”技术)。例如,如果一个组件的key根据某些条件(如URL变化)而变化,React会认为这是一个全新的组件,从而卸载旧组件并挂载新组件,从而有效地重置该组件的内部状态。反之,我们也可以通过在不同条件分支下给相同Input类型组件赋予相同的key,来强制React重用该组件的实例,即使它在虚拟DOM树中的位置或渲染条件发生变化,从而保留其内部状态。

@Context

React Context 有什么优点和缺陷?

1.优点

  1. 一方面,React Context 允许将数据从组件树顶部直接传递给深层嵌套的组件,无需通过中间组件通过props层层传递。这解决了“props drilling”(属性钻取)的问题。
  2. 另一方面,在整个子树中,只有实际订阅了顶部状态的组件会重渲,父级节点和兄弟节点不会受到影响。

例如,在一个包含侧边栏和主内容区域的布局中,侧边栏的展开/折叠状态如果通过 props 传递,会导致其所有父级和兄弟组件(包括可能非常“慢”的组件)在状态改变时都进行重渲染。而使用 Context,只有实际订阅了该状态的组件会重渲染。

2.缺陷

  1. Context 消费者在 Provider 的 value 属性变化时会全部重渲染,即使消费者只使用了 value 中未改变的部分。
  2. 如果 Context Provider 自身因父组件重渲染而重渲染,即使 Context 内部的状态没有改变,其 value 对象也会被重新创建,导致所有 Context 消费者不必要地重渲染。
import React, { createContext, useContext, useState } from 'react';

const MyContext = createContext();

function MyProvider({ children }) {
  const [count, setCount] = useState(0);

  // 每次 MyProvider 渲染时,都会创建一个新的 value 对象
  const value = {
    count,
    increment: () => setCount(c => c + 1)
  };

  return <MyContext.Provider value={value}>{children}</MyContext.Provider>;
}

function ConsumerComponent() {
  const { count } = useContext(MyContext);
  console.log('ConsumerComponent 渲染了');
  return <div>{count}</div>;
}

function App() {
  const [dummy, setDummy] = useState(false);
  
  return (
    <MyProvider>
      <ConsumerComponent />
      <button onClick={() => setDummy(d => !d)}>触发父组件重渲染</button>
    </MyProvider>
  );
}

当点击按钮时,App 组件的状态 dummy 会改变,导致 App 重新渲染

  1. App 重新渲染会导致 MyProvider 重新渲染
  2. MyProvider 每次渲染都会创建一个新的​ value 对象(即使 count 没有变化)
  3. 因为 value 引用改变了,ConsumerComponent 会重新渲染,即使它只使用了 count 且 count 没有变化 %%

如何防止 Context 的 value 属性导致的冗余渲染?

1.记忆属性

应该始终使用 useMemo 和 useCallback 来记忆化传递给 Provider 的 value 对象及其内部的函数。

例如,将 value 对象本身用 useMemo 包裹,并将 value 中包含的任何函数(如事件处理器)用 useCallback 包裹,并确保它们的依赖项正确。

2.拆分 Provider

“拆分 Provider”——因为只有订阅了那个特定 Context 的消费者才会重渲染,可以利用这个特性进一步细化粒度,将一个 Context 分成多个 Context,确保只有真正需要更新的组件才重渲染。

例如,一个 Context 用于管理经常变化的状态数据,另一个 Context 用于管理不经常变化的 API 函数。

3. 拆分dispatch

useReducerjie当使用 useState 时,改变状态的函数通常会依赖于当前状态值,这使得它们需要在每次状态更新时重新创建。然而,通过 useReducer,可以将状态逻辑集中到 reducer 函数中,而 dispatch 函数本身是不变的,从而可以安全地将它们放入一个独立的、不经常变化的 Context Provider 中,与状态数据分离,避免了消费这些操作函数的组件不必要的重渲染。

// 1. 创建独立的 ActionsProvider
function ActionsProvider({ dispatch, children }) {
  const actions = useMemo(() => ({
    open: () => dispatch({ type: 'OPEN' }),
    close: () => dispatch({ type: 'CLOSE' }),
    toggle: () => dispatch({ type: 'TOGGLE' }),
  }), [dispatch]);

  return (
    <ActionsContext.Provider value={actions}>
      {children}
    </ActionsContext.Provider>
  );
}

// 2. 在 AppProvider 中使用 React.memo 包裹 ActionsProvider
const MemoizedActionsProvider = React.memo(ActionsProvider);

function AppProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <StateContext.Provider value={state}>
      <MemoizedActionsProvider dispatch={dispatch}>
        {children}
      </MemoizedActionsProvider>
    </StateContext.Provider>
  );
}

4.模仿选择器

虽然 React Context 本身没有像 Redux 那样内置的“选择器”机制,可以直接选择部分 Context 值而只在选择部分改变时重渲染,但可以通过高阶组件(HOC)结合 React.memo 来模仿这种行为。

具体做法是创建一个 HOC,它在内部使用 useContext 获取 Context 值,然后将所需的部分(例如一个不常变化的函数)作为 props 传递给被包裹的组件。关键在于,被包裹的组件需要使用 React.memo 进行记忆化。这样,当 Context 值变化时,虽然 HOC 内部的匿名组件会重渲染,但如果它传递给 React.memo 过的子组件的 props 没有改变(例如,只传递了 Context 中一个稳定的函数),那么子组件就不会重渲染,从而实现了选择器类似的效果。

@闭包陷阱

什么是“闭包”?

一个函数能够记住并访问其词法作用域(即定义时所处的作用域)中的变量,即使该函数在其词法作用域之外被执行时。函数在词法作用域之外执行的原因是,对其引用进行了传递(函数旅行)。

什么是“陈旧闭包”和“闭包陷阱”?

“陈旧闭包”——如果一个函数(及其形成的闭包)被缓存,并且没有在它依赖的外部变量发生变化时重新创建,那么这个闭包就会变得“陈旧”。它会永远保留创建时捕获的变量值。这在 React 中特别容易出现的错误。

例如在使用 useCallback 而忘记添加所有依赖项,或使用 useRef 存储一个回调函数但没有及时更新。useCallback 是为了缓存函数,但是会产生陈旧闭包问题,要想没有陈旧闭包,被缓存函数就要每次重新渲染都更新(依赖数组为空或依赖一个频繁更新的状态),这就失去了缓存的功能掉进了另一个陷阱。

如何解决 Memoization 相关的闭包陷阱问题?

一种巧妙的解决方案是利用 useRef 的可变性。其核心思想是使用 useRef 来存储一个不断更新的最新回调函数的引用,同时使用一个稳定不变的 useCallback 回调来实际调用这个引用。

  1. 创建一个 useRef 实例(例如 ref)。
  2. 在 useEffect 钩子内部,将一个包含最新状态/属性的函数赋值给 ref.current。这个 useEffect 不应有依赖项数组(或者依赖项数组为空),这样它会在每次重新渲染时都执行,从而确保 ref.current 始终指向包含最新状态的闭包。
  3. 创建一个 useCallback 回调,其依赖项数组为空(确保它自身是稳定的,不会导致 React.memo 组件重新渲染)。
  4. 在这个 useCallback 回调内部,调用 ref.current()。 由于 ref 本身是一个稳定的引用(ref 对象本身在重新渲染之间不会改变),useCallback 不需要将其添加到依赖项中。但 ref.current 是可变的,useEffect 会在每次渲染时更新它指向的函数。因此,当稳定的 useCallback 函数被调用时,它会执行 ref.current 所指向的最新闭包,从而访问到最新的状态,同时保持了 memoization 的优势。

如何解决防抖和节流的闭包陷阱问题?

防抖(Debouncing)节流(Throttling) 都是避免短时间内调用过于频繁的技术。

  • 防抖(Debouncing):当一个函数在指定的时间间隔内被多次触发时,防抖会取消之前的调用,并重新计时。如自动搜索。
  • 节流(Throttling):节流确保一个函数在指定的时间间隔内只被执行一次,无论它被触发了多少次。如自动保存。

简而言之,防抖是“等到事件停止后才执行”,而节流是“每隔一段时间执行一次”。

const useDebounce = (callback, delay) => {

const ref = useRef(); // 用于存储最新回调的Ref
  

useEffect(() => {
	ref.current = callback; // 每次callback变化时更新Ref
}, [callback]);

  

const debouncedCallback = useMemo(() => {
	const func = () => {
		ref.current?.(); // 通过Ref调用最新回调
	};
	return debounce(func, delay); // 创建一次性防抖函数
}, []); // 空依赖,只创建一次,避免防抖被状态更新连累刷新,丢失计时。

  
return debouncedCallback; // 返回防抖函数
};

@界面闪烁

React应用为什么会闪烁?

当使用 useEffect 处理元素尺寸测量并调整 UI(例如隐藏或重新排列元素)时,可能会出现视觉上的“闪烁”或“跳动”。这是因为 useEffect 通常是异步执行的。从浏览器的角度来看,初始渲染(显示所有元素)是一个任务,而 useEffect 中根据测量结果进行的 UI 调整是另一个单独的任务。浏览器在这两个任务之间有机会绘制屏幕。

useLayoutEffect 通过将两者 作为同一个同步任务来解决视觉闪烁问题。浏览器在完成所有计算并应用最终的 UI 调整之前,不会重新绘制屏幕。useLayoutEffect 是同步执行的,会阻塞浏览器的渲染。如果 useLayoutEffect 中的任务耗时过长,会导致页面响应迟缓。因此,useLayoutEffect 应该只在消除视觉“故障”时使用。

渲染任务指的是什么?如何影响 React 应用的性能?

“浏览器渲染”(在 React 语境中也常被称为“render”)指的是浏览器将网页内容呈现到屏幕上的过程。它不是一个连续的实时更新过程,而是以帧(“幻灯片”)的形式进行,现代浏览器通常以 60 FPS(每秒 60 帧)的目标运行,这意味着每大约 13 毫秒更新一次屏幕。

浏览器通过执行“任务”来完成更新,这些任务被放入一个队列中。一个“任务”是指浏览器作为一个整体来处理的同步执行的代码块。例如,如果在 JavaScript 中连续修改一个元素的多个样式属性,浏览器会将所有这些修改视为一个任务,并在所有修改完成后才进行一次绘制。如果一个任务执行时间超过 13 毫秒,浏览器将无法在其中间进行绘制,用户会看到屏幕卡顿或空白,直到任务完成并显示最终结果,这被称为“渲染阻塞”。

React 通过将大型应用程序的渲染过程分解成更小的、异步执行的“块”(例如通过 setTimeout、Promise 或内部机制如 postMessage 和 requestAnimationFrame),来避免渲染阻塞。这使得浏览器可以在处理不同任务之间进行绘制,从而保持应用程序的响应性。useLayoutEffect 的特殊之处在于,它有意地将某些操作强制合并到一个同步任务中,以确保 UI 调整在绘制之前完成。

为什么 useLayoutEffect 在SSR框架中可能会失效?

在 Next.js 或其他 SSR 框架中,useLayoutEffect 在初始加载时会失效,浏览器首次显示从服务器接收到的 HTML 页面时(此时页面尚未交互),它会显示组件的初始渲染状态,例如显示所有导航链接和“更多”按钮。只有当浏览器下载并执行客户端的 React 代码后,useLayoutEffect 才会在客户端运行,然后才能隐藏多余的按钮。这种延迟导致了视觉上的“闪烁”。

在 SSR 环境下,解决 useLayoutEffect 失效导致的闪烁问题通常需要将方案降级,推荐的方法是引入一个状态变量,判断是否是客户端环境,仅在客户端环境渲染真实组件,服务器中渲染的是非动态的替代品:


const Component = () => {
	const [shouldRender, setShouldRender] = useState(false);
	
	useEffect(() => {
		// 确保这只在客户端运行
		// 不要尝试通过 typeof window === 'undefined' 等方式检测 SSR 环境
		setShouldRender(true);
	}, []);
	
	if (!shouldRender) {
		// SSR 期间或客户端 JS 加载前显示替代组件,例如加载状态或占位符
		return <SomeNavigationSubstitute />;
	}

	// 客户端 JS 运行后显示正常的由 useLayoutEffect 驱动的响应式导航
	return <Navigation />;
};