正值 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 支持的語言中,其應該好接受的多。