React渲染行为原理详解及总结
看到一篇redux作者写的介绍React 渲染行为原理的文章(找到中文版),里面也厘清了一些概念上的问题,很值得一读,以下做一些阅读方面的重点归纳。
如果不想看中间的内容可以直接跳到最后看总结。
渲染过程概述
首先介绍Render的大体过程:
在渲染过程中:
- React是从组件树的根节点循环向下寻找所有被标记为需要更新的组件
- 对每个被标记为需更新的组件执行它的 classComponentInstance.render()方法(对于class组件)或FunctionComponent()方法(对于函数组件)。
- 保存render输出
JSX元素 => 被解释为React.createElement(…) => createElement函数返回的是一个JavaScript object
// This JSX syntax:
return <SomeComponent a={42} b="testing">Text here</SomeComponent>
// is converted to this call:
return React.createElement(SomeComponent, {a: 42, b: "testing"}, "Text Here")
// and that becomes this element object:
{type: SomeComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}
然后React会把整个render出来的组件树对象和上一次的组件树对象做对比 => 收集不同之处用于做相应dom元素的更新。这个对比和计算的过程被称为 reconciliation 协调
文章里引用了一段React的作者之一的Dan Abramov的话,很有参考意义
I wish we could retire the term “virtual DOM”. It made sense in 2013 because otherwise people assumed React creates DOM nodes on every render. But people rarely assume this today. “Virtual DOM” sounds like a workaround for some DOM issue. But that’s not what React is.
React is “value UI”. Its core principle is that UI is a value, just like a string or an array. You can keep it in a variable, pass it around, use JavaScript control flow with it, and so on. That expressiveness is the point — not some diffing to avoid applying changes to the DOM.
It doesn’t even always represent the DOM, for example
<Message recipientId={10} />
is not DOM. Conceptually it represents lazy function calls: Message.bind(null, { recipientId: 10 }).
大意就是说他希望现在不再用
Virtual DOM
这个概念解释React的渲染过程,2013年以前React或许是这样处理的,但是现在Virtual DOM
会让人感觉React的reconciliation会创建dom元素,其实这是不正确的。代替的是React用一种
Value UI
的概念(也就是上面所说JSX最后转换成的Object),这个阶段不会做任何产生DOM的事。他举例
<Message recipientId={10} />
甚至渲染出来都不是一个DOM,它可以理解成产生了一个新的(滞后执行的)方法Message.bind(null, { recipientId: 10 })
渲染和提交阶段
区分渲染(render)和提交(commit)两个概念
- 渲染就是reconciliation的过程
- 提交才是更新到dom上的过程
提交后的过程
- 更新ref对象
- 执行componentDidMount或componentDidUpdate
- 执行useLayoutEffet
- 设置timeout,当到期的时候执行useEffect钩子
因此render和更新dom并不相同,render结束并不代表dom元素生效或者更改了,有两种情况render后不会更新dom
- render结果和上一次完全一样
- 并发模式下,当前更新可能会被抛弃
当初次渲染后,导致组件重新进入渲染队列的情况有:
- 类组件
- this.setState()
- this.forceUpdate()
- 函数组件
- 调用useState的setter
- 调用useReducer的dispatch
- 其他
- 调用ReactDOM.render — 效果等同与 forceUpdate
标准渲染行为
重点:React的默认render行为是这样的,只要父级元素执行了render,它会递归它下面所有的子元素都执行render过程。默认的render行为中,子元素的属性就算没有发生任何改变,只要父元素渲染了,子元素就会执行render。
React中渲染的规范
render方法必须是“纯”的,不能包含副作用。
render中不能做的事
- 修改一个已存在的变量或object的值
- 使用产生random 值的函数,比如Math.random()或Date.now()
- 发起网络请求
- 不能发起setState之类请求
render中可以做的事
- 修改render函数中本地的变量
- 抛出异常
- “Lazy initialize” data that hasn’t been created yet, such as a cached value “懒初始化”一条还未创建的数据,比如缓存(不明白)
Fiber
React保存组件数据的核心结构叫做Fiber,Fiber包含的数据:
- component type
- props / state
- 父级元素/兄弟/子元素的指针
- 其他用于渲染的元数据
component是Fiber数据的表现层(facade)
hooks的工作原理,react将组件所有的hooks列表储存到一个fiber对象上,当React渲染一个函数组件时,它从fiber的hooks列表上得到hook的数据,每次当你调用一个hook的时候,它会返回储存在该hook描述对象中的相关数据。
当父组件渲染它内部的一个子组件的时候,它会创建一个fiber对象来跟踪这个组件,如果是类组件,它的内容就是new ClassComponent(props),组件的实例保存到fiber对象上,如果是函数组件,则就是运行FunctionComponent(props)的结果保存在fiber对象上。
组件类型和协调的关系
React如何知道要重新渲染?
首先对比type(基于===)
绝对不能在渲染阶段创建新的component类型,因为它每次都是一个新的引用,会造成react不停的销毁并重建这个component在整个树结构中。
🙅♂️错误的做法
function ParentComponent() {
// This creates a new `ChildComponent` reference every time!
function ChildComponent() {}
return <ChildComponent />
}
🙆正确的做法
// This only creates one component type reference
function ChildComponent() {}
function ParentComponent() {
return <ChildComponent />
}
Key属性和协调的关系
重新渲染的条件,第二可以通过key属性(key是组件的伪属性,从component上读取不到一个叫key的属性),同一个位置的实例如果key不一样,代表它要重新创建(渲染)。
因此在列表组件的时候用index作为key有缺陷,比如有10个元素,渲染key从0…9,如果删掉了第6,7(index=5,index=6)个元素,然后又追加三个元素在后面,这时列表是11个元素,key从0…10。这时只比之前多了一个元素,前10个元素的dom元素还可以重用,这看起来不错。
但是想一下当前<TodoListItem key={6}>
其实渲染的内容是原来的第8个元素,这样如果不重新渲染的话就出错了。所以React必须更新这个子元素的DOM,这样的运行效率是比较低的(删除的元素序号越靠前效率越低)。
因此如果用key={todo.id}
才是正确的处理方式。
渲染的合并和时间问题
关于在同步过程中setState多次会合并不再赘述,不过有以下现象
const StateComponent = () => {
const [counter, setCounter] = useState(-1)
const onClick = async () => {
setCounter(0)
setCounter(1)
await new Promise((resolve) => setTimeout(resolve, 0))
setCounter(2)
setCounter(3)
}
console.log('***', counter)
return <button onClick={onClick}>render { counter }</button>
}
上面的代码会造成3次render(点下按钮,console.log打印了3次),以下是解释
This will execute three render passes. The first pass will batch together setCounter(0) and setCounter(1), because both of them are occurring during the original event handler call stack, and so they’re both occurring inside the unstable_batchedUpdates() call.
However, the call to setCounter(2) is happening after an await. This means the original synchronous call stack is done, and the second half of the function is running much later in a totally separate event loop call stack. Because of that, React will execute an entire render pass synchronously as the last step inside the setCounter(2) call, finish the pass, and return from setCounter(2).
The same thing will then happen for setCounter(3), because it’s also running outside the original event handler, and thus outside the batching.
componentDidMount, componentDidUpdate, 和useLayoutEffect 这些生命周期函数中允许你在render后再触发渲染逻辑,运用于以下场景:
- 首次渲染后还需要更多数据显示
- 在提交阶段(commit-phase)的生命周期,用ref测量dom元素的大小
- 再根据这些值设置其他state
- 用更新的数据立即重新渲染
React 总是在提交阶段的生命周期中使用同步运算,所以如果如果你想执行一次“部分->全部”的状态切换,其实屏幕上能看到的都是“全部”。
这段没体会,暂时不翻译了,但是最后说今后的react concurrent mode中,将总是合并update了。
Finally, as far as I know, state updates in useEffect callbacks are queued up, and flushed at the end of the “Passive Effects” phase once all the useEffect callbacks have completed.
It’s worth noting that the unstable_batchedUpdates API is exported publicly, but:
Per the name, it is labeled as “unstable” and is not an officially supported part of the React API
On the other hand, the React team has said that “it’s the most stable of the ‘unstable’ APIs, and half the code at Facebook relies on that function”
Unlike the rest of the core React APIs, which are exported by the react package, unstable_batchedUpdates is a reconciler-specific API and is not part of the react package. Instead, it’s actually exported by react-dom and react-native. That means that other reconcilers, like react-three-fiber or ink, will likely not export an unstable_batchedUpdates function.
For React-Redux v7, we started using unstable_batchedUpdates internally, which required some tricky build setup to work with both ReactDOM and React Native (effectively conditional imports depending on which package is available.)
In React’s upcoming Concurrent Mode, React will always batch updates, all the time, everywhere.
注意:React 在开发环境的<StrictMode>
下总是会重复渲染,这样render和commit的次数是不对等的,所以你不能依靠console.log的打印次数判断渲染次数。取而代之应该用React DevTools Profiler 来捕获跟踪并计算整体提交的渲染数量。
优化渲染的三个途径
- React.Component.shouldComponentUpdate 默认总返回true
- React.PureComponent: 由于 props 和 state 的比较是最常见的实现方式shouldComponentUpdate,因此PureComponent基类默认实现了该行为,并且可以用来代替Component+ shouldComponentUpdate。
- React.memo()
注意react对props和state的比较都使用浅比较。
有一些鲜为人知的技巧:当react组件返回的子元素和上次渲染返回的值完全一致的时候,react会略过渲染这个元素(和它的子树)。
- 将props.children放在output里
- 用useMemo返回的元素,只有在依赖关系发生改变时才会重新渲染
// The `props.children` content won't re-render if we update state
function SomeProvider({children}) {
const [counter, setCounter] = useState(0);
return (
<div>
<button onClick={() => setCounter(counter + 1)}>Count: {counter}</button>
<OtherChildComponent />
{children}
</div>
)
}
function OptimizedParent() {
const [counter1, setCounter1] = useState(0);
const [counter2, setCounter2] = useState(0);
const memoizedElement = useMemo(() => {
// This element stays the same reference if counter 2 is updated,
// so it won't re-render unless counter 1 changes
return <ExpensiveChildComponent />
}, [counter1]) ;
return (
<div>
<button onClick={() => setCounter1(counter1 + 1)}>Counter 1: {counter1}</button>
<button onClick={() => setCounter1(counter2 + 1)}>Counter 2: {counter2}</button>
{memoizedElement}
</div>
)
}
什么时候需要用useMemo和useCallback?不是每个function都要变成useCallback,只有当用和不用会影响子元素的渲染逻辑的时候才需要考虑。
另外,有人提问为什么不让每个组件默认都是被React.memo化的?
Dan Abramov早已解答过这个问题,他指出缓存也会加大比较的开销,并且很多情况下缓存化的组件仍然会render,因为它的props发生变化了。
以下来自Dan的Twitter
Why doesn’t React put memo() around every component by default? Isn’t it faster? Should we make a benchmark to check?
Ask yourself:
Why don’t you put Lodash memoize() around every function? Wouldn’t that make all functions faster? Do we need a benchmark for this? Why not?
Immutability 和渲染的关系
react当中应该一直坚持用Immutability修改状态,这个毋需多言了。
上下文和渲染的关系
function GreatGrandchildComponent() {
return <div>Hi</div>
}
function GrandchildComponent() {
const value = useContext(MyContext);
return (
<div>
{value.a}
<GreatGrandchildComponent />
</div>
}
function ChildComponent() {
return <GrandchildComponent />
}
const MemoizedChildComponent = React.memo(ChildComponent);
function ParentComponent() {
const [a, setA] = useState(0);
const [b, setB] = useState("text");
const contextValue = {a, b};
return (
<MyContext.Provider value={contextValue}>
<MemoizedChildComponent />
</MyContext.Provider>
)
}
上面例子中,如果setA(42)
- ParentComponent 会render
- MemoizedChildComponent 不回 render
- GrandchildComponent 因为消费了context,所以会render
- GreatGrandchildComponent 也会render,因为GrandchildComponent render了!
建议:Proivder下的首个子元素应该用memo缓存起来,否则context值的改变会导致下面每个组件都rerender,而有效的memo可以阻断这种级联效应。
React-Redux和渲染的关系
直接上结论吧:
- 使用connect的高阶组件可以视之为 PureComponent/React.memo(),除非它算出来的props改变了,才会重新渲染。
- 如果用useSelector,则没有这种优势,只要父级render了,子元素就会render,如果想提高性能,应该在适当的地方使用React.memo()阻断这种级联的重复渲染。
总结
- React在默认情况(没有用PureComponent和Memo)下总是递归渲染父组件下面的所有子组件
- 渲染本身是没问题的,它是React知道哪些DOM需要改变的方式
- 但是渲染需要花费时间(cpu资源),即便UI输出没有变化却有可能增加了很多“无效的渲染”
- 大多数时候直接传一个新的引用类型(回调函数或对象)给属性是可以的
- 当props没有变化的时候React.memo()可以略过不必要的渲染
- 但如果你总是传新引用属性,React.memo()实际是无效的,反而还要耗费内存去缓存结果
- 上下文可以让任意层级的组件获得它需要的上下文数据
- 上下文比较前后值的引用(引用类型)确认上下文的值是否改变
- 一个新的上下文值会导致所有消费该上下文值的组件重新渲染
- 但是大部分情况下子组件渲染都是因为父组件执行了渲染
- 所以通常来说,对context provider的子元素用React.memo包裹一下会减少很多不必要的渲染
- 当一个组件因其消费的上下文值而重新渲染,它下级的子组件也会因此而重新渲染
- React-Redux使用订阅方式检查store上的state是否改变,而不是通过改变上下文的值(provider的store值并没有变)
- 订阅是紧接着store 上state更新发生的(同步)
- React-Redux 框架做了很多工作确保只有相关属性变化的组件才会重新渲染
- connect的行为很像React.mem(),因此多使用connect能够减少同时render的组件数量
- useSelector是使用hook形式,因此它不能阻止因parent组件渲染而造成的子组件渲染,如果你的代码里到处用到useSelector,可能需要在一些关键地方使用React.memo()来减少大批量下层的组件递归刷新
还有一些使用context和redux的建议
- 以下情况使用context解决:
- 传递一些简单类型的数据,而且不经常改变
- 当你在很多地方都需要用到一些状态或者方法,又不想一层层传递props的时候
- 你不想必须使用更多额外的状态管理库,只想用当前的React方案解决问题的时候
- 以下情况使用Redux解决:
- 你有大量的应用状态,而且在不同的组件中都需用到这些数据的时候
- app的状态常常发生改变
- app状态改变的逻辑非常复杂
- app的规模较大,需要多人协作完成的大项目