正值 tuple&record 进入 stage2,正好将放了半年的草稿更新一波。javascript
对于比较复杂的 React 单页应用,性能问题和 UI 一致性问题是咱们必需要考虑的问题,这两个问题和 React 的重渲染机制息息相关。本文重点讨论如何控制重渲染来解决 React 应用的性能问题和 UI 一致性问题。前端
react 的每次触发页面更新实际上分为两个阶段java
render : 主要负责进行 vdom 的 diff 计算react
commit phase: 主要负责将 vdom diff 的结果更新到实际的 DOM 上。express
咱们这里所说的渲染以及重渲染都是指 render 过程(暂不讨论 commit 阶段), 渲染分为首次渲染和重渲染两部分,首次渲染就是第一次渲染,其不可避免就很少加讨论,重渲染是指因为状态改变,props 改变等因素形成的后续渲染过程,其对于咱们应用的性能及其页面 UI 的一致性相当重要,是咱们讨论的重点。npm
React 的关于渲染的最重要的一个特性(也是最为人诟病的特性) 就是redux
当父组件重渲染的时候,其会默认递归的重渲染全部子组件缓存
当父组件重渲染的时候,其会默认递归的重渲染全部子组件性能优化
当父组件重渲染的时候,其会默认递归的重渲染全部子组件babel
如下面的例子为例,虽然咱们的 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 的操做意义明显不一样了。
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)。这明显不是咱们想要的。
咱们的需求其实很简单
一个答案呼之欲出,作深拷贝而后再作 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,则要求
很惋惜 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 这套策略之因此麻烦的根源在于对象的值比较和引用比较的不一致性,若是二者是一致的, 那么就不须要担忧对象值不变的状况下引用发生变化,也不须要要担忧对象只变化的时候引用没发生变化。 同时若是对象内置了一套 immutable 更新的方式,也无需去引用第三方库来简化更新操做。
> #{x: 1, y: 4} === #{x: 1, y: 4}
true
复制代码
这避免了咱们须要经过 useMemo|useRef 来保证对象的引用相等性
const obj = #{a:1}
obj.b = 10; // error 禁止修改record
复制代码
这保证了咱们修改 record 的值的时候,其必定和以前的值的比较结果不同
暂时没看到比较优雅的内置方式
至此咱们发现 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 问题
因此 React 的 hooks 种种反直觉的问题,主要仍是在于 javascript 的对象和函数默认不是 immutable 的,而这一套方案 都是基于 immutable 的设计去作的,若是处于一个默认 immutable 支持的语言中,其应该好接受的多。