React 组件会在以下情况重新渲染:它自己的 state 变化,它的父组件重新渲染,或者它读取的 context 变化。渲染的意思是 React 再次调用你的组件函数以获得新的 JSX,然后只更新页面上真正变化的部分。
打开预览下方的 Console 并点击按钮。三个组件都打印了日志,包括完全不接收 props 的 Title。它之所以渲染,是因为它的父组件渲染了。
什么会触发渲染
React 只会出于以下原因渲染一个组件:
- 首次渲染。应用启动,或者组件第一次出现在组件树中。
- 它的 state 变了。你用新值调用了
useState的 setter 或useReducer的 dispatch。 - 它的父组件渲染了。默认情况下,一个组件渲染时,它返回的每个组件也都会渲染,一直到最底层。
- 它读取的 context 变了。调用了
useContext(SomeContext)的组件,会在最近的 provider 传入新的value时渲染(参见 useContext)。
一个常见的误解是组件渲染“是因为它的 props 变了”。这把因果关系弄反了。props 是父组件在渲染时传入的参数,所以只有父组件渲染时才会传来新的 props。而父组件渲染这一点本身就足够了:上面的 Title 没有任何 props,却每次都会渲染。
把 state 设置为它当前已有的值(用 Object.is 比较)不会触发子组件的渲染。React 可能仍会先调用一次这个组件,然后才发现没有任何变化,但它会丢弃这次的结果。
渲染与提交
每次更新都要经过两个阶段:
- 渲染。React 调用你的组件。组件返回 JSX,而 JSX 只是描述页面应该显示什么的对象。此时页面上什么都还没变,这就是渲染必须是纯的原因:不写 DOM,不发请求,不修改组件外部的变量。
- 提交。React 把新的输出与上一次的比较,并把差异应用到 DOM 上:只插入、删除或更新变化了的节点。然后浏览器进行绘制,之后 React 运行你的 effect。
下面的示例每次点击都会渲染,但 React 一直保留着同一个 <input> 元素。每次提交后都会运行一个 effect 来检查这一点。
在输入框中输入一些内容,然后点击几次。你输入的文字还在,因为 React 从来没有替换这个输入框:每次点击唯一的 DOM 修改就是 <p> 中的数字。日志也显示了顺序:先是 render,提交之后才是 effect。
通俗地讲虚拟 DOM
“虚拟 DOM”是组件返回的那些对象的通俗叫法。<p>Items: {count}</p> 会被编译成一个调用,创建类似 { type: 'p', props: { children: ['Items: ', 1] } } 的对象。渲染之后,React 把这些对象组成的新树与上一棵树对照着遍历,在类型和位置相同的地方保留现有的 DOM 节点,只更新变化了的属性和文本。这个比较过程叫作协调(reconciliation)。
说这个术语笼统有两个原因。React 并不会保存第二份 DOM 再对比两个 DOM:它是拿元素对象与自己内部的组件树(fiber 树)进行比较。而且同样的过程也驱动着根本没有 DOM 的平台,比如 React Native。React 官方文档基本不用这个说法,而是讲渲染和提交。实践中真正重要的是它带来的结果:与 DOM 操作相比,渲染的开销很小,因为大多数渲染最终只带来很小的 DOM 更新,甚至没有更新。
比较的工作方式引出了两条规则。同一位置出现不同类型的元素(<div> 换成 <section>,或 ComponentA 换成 ComponentB)会销毁旧的子树及其 state。而在列表中,key 告诉 React 哪一项对应哪一项,这样它就能移动节点而不是重新创建。
避免不必要的渲染
大多数多余的渲染带来的开销你根本察觉不到。当某次渲染确实造成了影响时,比如页面其他地方每按一次键,一个大列表就渲染一次,可以按顺序尝试下面的方法。
把 state 往下移
如果页面上只有一小部分用到某个 state,就把这个 state 放在只包裹那一部分的组件中。下面这个版本每次按键都会重新渲染 ProductList:
export default function App() {
const [text, setText] = useState('');
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ProductList />
</>
);
}
把输入框和它的 state 移到单独的组件中,列表就不再位于那个会渲染的组件内部了:
输入几个字母:只有 SearchBox 打印日志。
改为传入 children
有时 state 必须放在包裹开销大的部分的组件中,比如一个切换高亮的面板。让这个包裹组件接收 children。子元素由父组件创建,而包裹组件的 state 变化时父组件不会渲染,所以这些元素还是原来的那些对象,React 会跳过它们。
点击切换按钮,只有 Highlighter 打印日志。现在把 <Article /> 移到 Highlighter 的 JSX 中替换 {children},每次点击也都会打印 render Article。
记忆化
如果调整结构都不合适,就把子组件包在 memo 中。这样 React 会把它的 props 与之前的比较,全部相同时就跳过渲染。渲染时创建的对象和函数每次都是新的,所以 memo 通常要配合 useMemo 或 useCallback 来处理这些 props。React.memo 页面用可运行的示例展示了这一点。
import { memo } from 'react';
const ProductList = memo(function ProductList({ category }) {
// skipped while category stays the same
});
React Compiler 可以在构建时替你加上这类记忆化,但调整 state 的结构仍然应该首先尝试:它直接消除了工作,而不是把结果缓存起来。
渲染通常没什么问题
一次渲染就是一次返回对象的函数调用。React 能很快地执行成千上万次渲染,而输出相同的渲染不会修改任何 DOM。不要“为了保险”到处加 memo:每次比较都有自己的开销,还会让代码更难读。先测量。React DevTools Profiler 会显示哪些组件渲染了、为什么渲染,以及每次花了多长时间。
常见问题
什么会导致 React 组件重新渲染?
三种情况:它自己的 state 变化,它的父组件渲染,或者它用 useContext 读取的 context 得到了新值。props 并不是单独的触发条件:新的 props 只会因为父组件渲染而传进来。
父组件重新渲染时,子组件也会重新渲染吗?
会。默认情况下,正在渲染的父组件内部的每个组件也都会渲染,即使它的 props 没有变化。把子组件包在 memo 中,React 就能在它的 props 与上次相同时跳过它。
React 中的虚拟 DOM 是什么?
它是对组件返回的那棵普通 JavaScript 对象树(React 元素)的一种笼统叫法。React 把新树与上一棵树比较,只修改真实 DOM 中不同的节点。
重新渲染对性能不好吗?
通常不会。一次渲染就是一次生成对象的函数调用,React 只在输出发生变化的地方操作 DOM。当某次渲染确实可测量地变慢时再去优化,比如借助 React DevTools Profiler。
渲染和提交有什么区别?
渲染是 React 调用你的组件,弄清楚页面应该是什么样子。提交是 React 把差异应用到 DOM 上。输出相同的渲染不会提交任何 DOM 修改。