React Hooks(四): immutable
正值tuple&record进入stage2,正好将放了半年的草稿更新一波。
对于比较复杂的 React 单页应用,性能问题和UI一致性问题是我们必须要考虑的问题,这两个问题和React的重渲染机制息息相关。本文重点讨论如何控制重渲染来解决React应用的性能问题和UI一致性问题。
默认渲染行为
react的每次触发页面更新实际上分为两个阶段
render : 主要负责进行vdom的diff计算
commit phase: 主要负责将vdom diff的结果更新到实际的DOM上。
我们这里所说的渲染以及重渲染都是指render过程(暂不讨论commit阶段),渲染分为首次渲染和重渲染两部分,首次渲染就是第一次渲染,其不可避免就不多加讨论,重渲染是指由于状态改变,props改变等因素造成的后续渲染过程,其对于我们应用的性能及其页面UI的一致性至关重要,是我们讨论的重点。
React的关于渲染的最重要的一个特性(也是最为人诟病的特性)就是
当父组件重渲染的时候,其会默认递归的重渲染所有子组件
当父组件重渲染的时候,其会默认递归的重渲染所有子组件
当父组件重渲染的时候,其会默认递归的重渲染所有子组件
以下面的例子为例,虽然我们的Child组件的props没有任何变化,但是由于Parent触发了重渲染,其也带动了子组件的重渲染
import * as React from "react"
function Parent() {
const [count, setCount] = React.useState(0)
const [name, setName] = React.useState("")
React.useEffect(() => {
setInterval(() => {
setCount(x => x + 1)
}, 1000)
}, [])
return (
<input
value={name}
onChange={e => {
setName(e.target.value)
<div>counter:{count}</div>
<Child name={name} />
function Child(props: { name: string }) {
console.log("child render", props.name)
return <div>name:{props.name}</div>
export default function App() {
return <Parent />
}
所以实际上React根本不关心你的props是否改变,就是简单粗暴的进行全局刷新。如果所有的组件的props都没发生变化, 即使React进行了全局计算,但是并没有产生任何的vdom的diff,在commmit阶段自然也不会发生任何的dom更新,你也感受不到 UI的更新,但是其仍然浪费了很多时间在render的计算过程,对于大型的React应用,有时这些计算会成为性能的瓶颈。 下面我们尝试对其进行优化。
浅比较优化
React为了帮助解决上述性能问题,实际上提供了三个API用于性能优化 shouldComponentUpdate: 如果在这个生命周期里返回false,就可以跳过后续该组件的render过程
React.PureComponent: 会对传入组件的props进行浅比较,如果浅比较相等,则跳过render过程,适用于Class Component *
React.memo: 同上,适用于functional Component
我们这里定义下引用相等(reference equality)、值相等(value equality)、浅比较相等(shallow equality)和深比较相等(deep equality ),参考 C# Equality comparisons
Javascript的value主要分两类primitive value和 Object, primitive value包括
Undefined
,
Null
,
Boolean
,
Number
,
String
, and
Symbol
而Object 包括Function, Array, Regex等) primitive 和object的最大的区别在于
* primtive是immutable的,而object一般是可以mutable的
- primitive比较是进行值比较,而对于object则进行引用比较
1 === 1 // true
{a:1} === {a:1} // false
const arr = [{a:2}]
const a = arr[0];
const b = arr[0];
a === b // true
我们发现对于上面对象即使其每个属性的值都完全相等,===返回的结果仍然是false,因为其并不会默认进行值的比较。 对于对象而言,不仅存在引用比较,还有深比较和浅比较
const x = {a:1}
const y = {a:1}
x === y // false 引用不等
shallowEqual(x,y) // true 每个对象的一级属性均相等
deepEqual(x,y) // true 对象的每个叶子节点(primitive type)的值和拓扑关系均相等
const a = {x :{x:1}, y:2}
const b = {x: {x:1}, y:2}
a === b // 引用不等
shallowEqual(x,y) // false a.x ==== b.x 结果为false,所以浅比较不等
deepEqual(x,y) // true a.x.x === b.x.x && a.y === b.y ,深比较相等
const state1 = {items: [{x:1}]} // 时间点1
state1.items.push([{x:2}]) // 时间点2
这里发现虽然state1的值在时间点1到时间点2发生了变化,但是其引用却没发生变化,即时间点1和时间点2的deepEqual实际发生了变化,但是他们的引用却没变。
我们发现对象深比较的结果和对象浅比较的结果以及对象引用比较的结果经常会发生冲突,这实际上也是很多前端问题的来源。 这里所说的深比较相等更符合我们理解的对象的值相等(区别于引用相等)的意思(后续不再区分对象的深比较相等和值相等。)
实际上React及hooks的很多的问题根源都来源于对象引用比较和对象深比较的结果的不一致性,即
对象值不变的情况下, 对象引用变化会导致React组件的缓存失效,进而导致性能问题
对象值变化的的情况下,对象引用不变会导致的React组件的UI和数据的不一致性
对于一般的MVVM框架,框架大多都负责帮忙处理ViewModel <=> View的一致性,即
当ViewModel发生变化时,View也能跟着一起刷新
当ViewModel不变的时候,View也保持不变
我们的ViewModel通常即包含primitive value也包括object value,对于大部分的UI来说,UI其实本身 并不关心对象的引用,其关心的是对象的值(即每个叶子节点属性的值和节点的拓扑关系),因为其实际是将对象的值映射到实际的UI上来的,UI上并不会直接反馈对象的引用。
React.memo 保证了只有 props 发生变化时,该组件才会发生重渲染(当然内部 state 和 context 变化也会发生重渲染),我们只要将我们的组件包裹,即可以保证Child组件在props不变的情况下,不会触发重渲染
import * as React from "react"
function Parent() {
const [count, setCount] = React.useState(0)
const [name, setName] = React.useState("")
React.useEffect(() => {
setInterval(() => {
setCount(x => x + 1)
}, 1000)
}, [])
return (
<input
value={name}
onChange={e => {
setName(e.target.value)
<div>counter:{count}</div>
<Child name={name} />
// memo包裹,保证props不变的时候不会重渲染
const Child = React.memo(function Child(props: { name: string }) {
console.log("child render", props.name)
return <div>name:{props.name}</div>
export default function App() {
return <Parent />
}
似乎事情到此为止了,如果我们的 props 只包含 primitive 类型(string、number)等,那么 React.memo 基本上就足够使用了,但是假如我们的 props 里包含了对象,就没那么简单了, 我们继续为我们的 Child 组件添加新的 Item props,这时候的 props 就变成了 object,问题 也随之而来,即使我们感觉我们的 object 并没有发生变化,但是子组件还是重渲染了。
import * as React from "react"
interface Item {
text: string
done: boolean
function Parent() {
const [count, setCount] = React.useState(0)
const [name, setName] = React.useState("")
console.log("render Parent")
const item = {
text: name,
done: false,
React.useEffect(() => {
setInterval(() => {
setCount(x => x + 1)
}, 5000)
}, [])
return (
<fragment>
<input
value={name}
onChange={e => {
setName(e.target.value)
></input>
<div>counter:{count}</div>
<Child item={item} />
</fratment>
const Child = React.memo(function Child(props: { item: Item }) {
console.log("render child")
const { item } = props;
return <div>name:{item.text}</div>
export default function App() {
return <Parent />
}
这里的问题问题在于,React.memo 比较前后两次 props 是否相等使用的是浅比较,而 child 每次接受的都是一个新的 literal object, 而由于每个literal object的比较是引用比较,虽然他们的各个属性的值可能相等,但是其比较结果仍然为false,进一步导致浅比较返回 false,造成Child组件仍然被重渲染
const obj1 = {
name: "yj",
done: true,
const obj2 = {
name: "yj",
done: true,
obj1 === obj2 // false
对于我们的引用来说,我们最终渲染的结果实际上是取决于对象的每个叶子节点的值,因此我们的期望自然是叶子节点的值不变的情况下,不要触发重渲染,即对象的深比较结果的一致的情形下不触发重渲染。
解决方式有两种,
* 第一种自然是直接进行深比较而非浅比较
- 第二种则是保证在Item深比较结果相等的情况下,浅比较的结果也相等
幸运的是 React.memo 接受第二个参数,用于自定义控制如何比较属性相等,修改 child 组件如下
const Child = React.memo(
function Child(props: { item: Item }) {
console.log("render child")
const { item } = props
return <div>name:{item.text}</div>
(prev, next) => {
// 使用深比较比较对象相等
return deepEqual(prev, next)
)
虽然这样能达到效果,但是深比较处理比较复杂的对象时仍然存在较大的性能开销甚至挂掉的风险(如处理循环引用),因此并不建议去使用深比较进行性能优化。
第二种方式则是需要保证如果对象的值相等,我们保证生成对象的引用相等, 这通常分为两种情况
如果对象本身是固定的常量,则可以通过 useRef 即可以保证每次访问的对象引用相等,修改代码如下
function Parent() {
const [count, setCount] = React.useState(0)
const [name, setName] = React.useState("")
React.useEffect(() => {
setInterval(() => {
setCount(x => x + 1)
}, 1000)
}, [])
const item = React.useRef({
text: name,
done: false,
}) // 每次访问的item都是同一个item
return (
<input
value={name}
onChange={e => {
setName(e.target.value)
<div>counter:{count}</div>
<Child item={item.current} />
}
问题也很明显,假使我们的 name 改变了,我们的item仍然使用的是旧值并不会进行更新,导致我们的子组件也不会触发重渲染,导致了数据和UI的不一致性,这比重复渲染问题更糟糕。所以 useRef 只能用在常量上面。微软的fabric ui就对这种模式进行了封装,封装了一个 useConst ,来避免render之间的常量引用发生变化的影响。
那么我们怎么保证 name 不变的时候 item 和上次相等,name 改变的时候才和上次不等。useMemo!
useMemo 可以保证当其 dependency 不变时,依赖 dependency 生成的对象也不变(由于 cache busting 的存在,实际上可能保证不了,异常尴尬),修改代码如下
function Parent() {
const [count, setCount] = React.useState(0)
const [name, setName] = React.useState("")
React.useEffect(() => {
setInterval(() => {
setCount(x => x + 1)
}, 1000)
}, [])
const item = React.useMemo(
() => ({
text: name,
done: false,
[name]
) // 如果name没变化,那么返回的始终是同一个 item
return (
<input
value={name}
onChange={e => {
setName(e.target.value)
<div>counter:{count}</div>
<Child item={item} />
}
至此我们保证了 Parent 组件里 name 之外的 state 或者 props 变化不会重新生成新的 item,借此保证了 Child 组件不会 在 props 不变的时候重新渲染。
然而事情并未到此而止
下面继续扩展我们的应用,此时一个 Parent 里可能包含多个 Child
function Parent() {
const [count, setCount] = React.useState(0)
const [name, setName] = React.useState("")
const [items, setItems] = React.useState([] as Item[])
React.useEffect(() => {
setInterval(() => {
setCount(x => x + 1)
}, 1000)
}, [])
const handleAdd = () => {
setItems(items => {
items.push({
text: name,
done: false,
id: uuid(),
return items
return (
<form onSubmit={handleAdd}>
<Row>counter:{count}</Row>
<Input
width={50}
size="small"
value={name}
onChange={e => {
setName(e.target.value)
<Button onClick={handleAdd}>+</Button>
{items.map(x => (
<Child key={x.id} item={x} />
</form>
}
当我们点击添加按钮的时候,我们发现下面的列表并没有刷新,等到下次输入的时候,列表才得以刷新。 问题的在于 useState 返回的 setState 的操作和 class 组件里的 setState 的操作意义明显不同了。
- class 的 setState: 不管你传入的是什么state,都会强制刷新当前组件
- hooks 的 setState: 如果前后两次的 state 引用相等,并不会刷新组件,因此需要用户进行保证当深比较结果不等的情况下,浅比较结果也不等,否则会造成视图和UI的不一致。
hooks 的这个变化意味着假使在组件里修改对象,也必须保证修改后的对象和之前的对象引用不等(这是以前 redux 里 reducers 的要求,并不是 class 的 setState 的需求)。 修改上述代码如下
function Parent() {
const [count, setCount] = React.useState(0)
const [name, setName] = React.useState("")
const [items, setItems] = React.useState([] as Item[])
React.useEffect(() => {
setInterval(() => {
setCount(x => x + 1)
}, 1000)
}, [])
const handleAdd = () => {
setItems(items => {
const newItems = [
...items,
text: name,
done: false,
id: uuid(),
] // 保证每次都生成新的items,这样才能保证组件的刷新
return items
return (
<form onSubmit={handleAdd}>
<Row>counter:{count}</Row>
<Input
width={50}
size="small"
value={name}
onChange={e => {
setName(e.target.value)
<Button onClick={handleAdd}>+</Button>
{items.map(x => (
<Child key={x.id} item={x} />
</form>
}
这实际要求我们不直接更新老的 state,而是保持老的 state 不变,生成一个新的 state,即 immutable 更新方式,而老的 state 保持不变意味着 state 应该是个 immutable object。 对于上面的 items 做 immutable 更新似乎并不复杂,但对于更加复杂的对象的 immutable 更新就没那么容易了
const state = [{name: 'this is good', done: false, article: {
title: 'this is a good blog',
id: 5678
}},{name: 'this is good', done: false, article:{
title: 'this is a good blog',
id: 1234
state[0].artile的title = 'new article'
// 如果想要进行上述更新,则需要如下写法
const newState = [{
...state[0],
article: {
...state[0].article,
title: 'new article'
...state
}]
我们发现相比直接的 mutable 的写法,immutable 的更新非常麻烦且难以理解。我们的代码里充斥着
...
操作,我们可称之为
spread hell
(对,又是一个 hell)。这明显不是我们想要的。
deep clone is bad
我们的需求其实很简单
- 一来是需要改变状态
- 二来是需要改变后的状态和之前的状态非引用相等
一个答案呼之欲出,做深拷贝然后再做 mutable 修改不就可以了
const state = [
name: "this is good",
done: false,
article: {
title: "this is a good blog",
id: 5678,
name: "this is good",
done: false,
article: {
title: "this is a good blog",
id: 1234,
const newState = deepCone(state)
state[0].artile的title = "new article"
深拷贝有两个明显的缺点就是拷贝的性能和对于循环引用的处理,然而即使有一些库支持了高性能的拷贝,仍然有个致命的缺陷对 reference equality 的破坏,导致 react 的整个缓存策略失效。 考虑如下代码
const a = [{ a: 1 }, { content: { title: 2 } }]
const b = lodash.cloneDeep(a)
a === b // false
a[0] === b[0] // false
a[1].content === b[0].content // false
我们发现所有对象的 reference equality 都被破坏,这意味着所有 props 里包含上述对象的组件 即使对象里的属性没变化,也会触发无意义的重渲染,这很可能导致严重的性能问题。 这实际上意味着我们状态更新还有其他的需求,在 react 中更新状态的就几个需求 对于复杂的对象 oldState,在不存在循环引用的情况下,可将其视为一个属性树,如果我们希望改变某个节点的属性,并返回一个新的对象 newState,则要求
- 该节点及其组件节点的引用在新老 state 中不相等:保证 props 发生的组件 UI 状态能够刷新,即保持 model 和 view 的一致性
- 非该节点及其祖先节点的引用在新老 state 中保持引用相等:保证 props 不变进而保证 props 不变的组件不刷新,即保证组件的缓存不失效
很可惜 Javascript 并没有内置对这种 Immutable 数据的支持,更别提对 Immutable 数据更新的支持了,但是借助于一些第三方库如immer和immutablejs,可以简化我们处理immutable数据的更新。
import { produce } from 'immer';
const handleAdd = () => {
setItems(
produce(items => {
items.push({
text: name,
done: false,
id: uuid()
};
他们都是通过structing shared的方式保证我们只更新了修改的子state的引用,不会去修改未更改子state的引用,保证整个组件树的缓存不会失效。
React解决重渲染问题总结
至此我们总结下React是如何解决重渲染问题的
- 默认情况下,React组件刷新会导致其所有的子组件会递归的进行刷新
- 通过React.memo的shallowEqual进行浅比较props,来保证props不变的情况下,组件不会被刷新
- 浅比较只能保证对primitive生效,对于对象即使其值不变,也可能导致引用发生变化
- 通过引入useRef和useMemo来保证,在对象值不变的情况下引用也不发生变化 react hook的setState只有在state前后发生改变的情况下才回去触发重新render, 这要求如果我们修改了state的值的时候,不需要保证修改了state的引用
- 我们不能通过深拷贝方式去修改state,因为会导致整个state的没有变化的子state的引用也会发生改变,导致所有缓存失效
- 这要求我们必须使用immutable的方式去更新state()来更新引用和确保缓存。
- 我们可以通过第三方库immer等来简化immutable的state更新的写法。
immutable record & tuple
至此我们发现react这套策略之所以麻烦的根源在于对象的值比较和引用比较的不一致性,如果两者是一致的, 那么就不需要担心对象值不变的情况下引用发生变化,也不需要要担心对象只变化的时候引用没发生变化。 同时如果对象内置了一套immutable更新的方式,也无需去引用第三方库来简化更新操作。
record & tuple的object literal的比价是基于值比较的
> #{x: 1, y: 4} === #{x: 1, y: 4}
true
这避免了我们需要通过useMemo|useRef来保证对象的引用相等性
record和tuple是immutable的
const obj = #{a:1}
obj.b = 10; // error 禁止修改record
这保证了我们修改record的值的时候,其一定和之前的值的比较结果不一样
更新
暂时没看到比较优雅的内置方式
immutable function
至此我们发现immutable的record和tuple能够极大的简化react的状态同步和性能问题, 但是对于复杂的Reac应用,还有一个需要考虑的东西即副作用。 大部分的副作用都和函数相关,无论是事件点击的的处理,还是useEffect里effect的触发,都脱离不了函数, 因为函数也能作为props,所以我们同样也需要保证函数的值语义和函数的引用语义保持一致的问题。否则仍然可能通过传递callback将react的缓存系统击垮。
function Parent(){
const [state,setState] = useState();
const ref = useRef(state);
useEffect(() => {
ref.current = state;
},[state])
const handleClick = () => {
console.log('state',state)
console.log('ref:', ref.current)
return <Child onClick={handleClick}></Child>
const Child = React.memo((props: {onCilck}) => {
return <div onClick={props.onClick}>
})
我们发现每次父组件重渲染都会生成一个新的handleClick,即使生成的函数其作用都一样(值语义相等)。 为了保证函数不变的情况下,引用相等,React引入了useCallback
const handleClick = useCallback(handleClick, ['state'])
如果在函数里引用了外部的自由变量,如果该变量是当前的快照(immutable),则需要将该变量写在useCallback依赖里, 这是因为
const handleClick = () => {
console.log('state:',1)
const handleClick = () => {
console.log('state:',2)
表达的是不同的值语义,因此其引用比较应该随着state变化而发生变化。
我们甚至可以进一步假象存在如下一种语法糖
const handleClick = #(() => {