正值 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>
<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} />
))}
</Row>
</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>
<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} />
))}
</Row>
</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 = #(() => {
console.log('state:',state)
})
借助于编译工具比如 babel-plugin-auto-add-use-callback(假想的, 也是 dan 常挂在嘴边的,编译器优化),可以将其自动的转换为如下代码
const handleClick = useCallback(()=> {
console.log('state:', state);
},[state])
这样我们就能够保证函数的值语义和引用语义的一致性了,即 useCallback 里解决方案 3 和解决方案 7 结合的最终方案,即 react 的整个应用的数据和 function 都严格保证值语义和引用语义严格匹配。 这也能解决陈旧闭包和 infinite loop 问题
- 陈旧闭包:所有的回调函数都使用 #(callback) 包裹,自动将其转换为 useCallback 并写入内部的闭包依赖,即避免了该有的依赖没有写入导致的闭包里引用的是陈旧的,且保证只有值语义变化的时候,引用才发生变化,避免导致缓存失效。
- infinite loop: fetchData 和 query 只有在语义变化的情况下才会导致引用变化,避免了 useEffect 被无意触发的问题。
所以 React 的 hooks 种种反直觉的问题,主要还是在于 javascript 的对象和函数默认不是 immutable 的,而这一套方案 都是基于 immutable 的设计去做的,如果处于一个默认 immutable 支持的语言中,其应该好接受的多。