「React進(jìn)階」探案揭秘六種React‘靈異’現(xiàn)象
前言
今天我們來(lái)一期不同尋常的React進(jìn)階文章,本文我們通過(guò)一些不同尋常的現(xiàn)象,以探案的流程分析原因,找到結(jié)果,從而認(rèn)識(shí)React,走進(jìn)React的世界,揭開React的面紗,我深信,更深的理解,方可更好的使用。
我承認(rèn)起這個(gè)名字可能有點(diǎn)標(biāo)題黨了,靈感來(lái)源于小時(shí)候央視有一個(gè)叫做《走進(jìn)科學(xué)》的欄目,天天介紹各種超自然的靈異現(xiàn)象,搞的神乎其神,最后揭秘的時(shí)候原來(lái)是各種小兒科的問題,現(xiàn)在想想都覺得搞笑????。但是我今天介紹的這些React '靈異'現(xiàn)象本質(zhì)可不是小兒科,每一個(gè)現(xiàn)象后都透露出 React 運(yùn)行機(jī)制和設(shè)計(jì)原理。(我們講的react版本是16.13.1)

好的,廢話不多說(shuō),我的大偵探們,are you ready ? 讓我們開啟今天的揭秘之旅把。
案件一:組件莫名其妙重復(fù)掛載
接到報(bào)案
之前的一位同學(xué)遇到一個(gè)詭異情況,他希望在組件更新,componentDidUpdate執(zhí)行后做一些想要做的事,組件更新源來(lái)源于父組件傳遞 props 的改變。但是父組件改變 props發(fā)現(xiàn)視圖渲染,但是componentDidUpdate沒有執(zhí)行,更怪異的是componentDidMount執(zhí)行。代碼如下:
// TODO: 重復(fù)掛載
class Index extends React.Component{
componentDidMount(){
console.log('組件初始化掛載')
}
componentDidUpdate(){
console.log('組件更新')
/* 想要做一些事情 */
}
render(){
return <div>《React進(jìn)階實(shí)踐指南》 ?? { this.props.number } + </div>
}
}
效果如下

componentDidUpdate沒有執(zhí)行,componentDidMount執(zhí)行,說(shuō)明組件根本沒有走更新邏輯,而是走了重復(fù)掛載。
逐一排查
子組件一頭霧水,根本不找原因,我們只好從父組件入手。讓我們看一下父組件如何寫的。
const BoxStyle = ({ children })=><div className='card' >{ children }</div>
export default function Home(){
const [ number , setNumber ] = useState(0)
const NewIndex = () => <BoxStyle><Index number={number} /></BoxStyle>
return <div>
<NewIndex />
<button onClick={ ()=>setNumber(number+1) } >點(diǎn)贊</button>
</div>
}
從父組件中找到了一些端倪。在父組件中,首先通過(guò)BoxStyle做為一個(gè)容器組件,添加樣式,渲染我們的子組件Index,但是每一次通過(guò)組合容器組件形成一個(gè)新的組件NewIndex,真正掛載的是NewIndex,真相大白。
注意事項(xiàng)
造成這種情況的本質(zhì),是每一次 render 過(guò)程中,都形成一個(gè)新組件,對(duì)于新組件,React 處理邏輯是直接卸載老組件,重新掛載新組件,所以我們開發(fā)的過(guò)程中,注意一個(gè)問題那就是:
對(duì)于函數(shù)組件,不要在其函數(shù)執(zhí)行上下文中聲明新組件并渲染,這樣每次函數(shù)更新會(huì)促使組件重復(fù)掛載。 對(duì)于類組件,不要在 render函數(shù)中,做如上同樣的操作,否則也會(huì)使子組件重復(fù)掛載。
案件二:事件源e.target離奇失蹤
突發(fā)案件
化名(小明)在一個(gè)月黑風(fēng)高的夜晚,突發(fā)奇想寫一個(gè)受控組件。寫的什么內(nèi)容具體如下:
export default class EventDemo extends React.Component{
constructor(props){
super(props)
this.state={
value:''
}
}
handerChange(e){
setTimeout(()=>{
this.setState({
value:e.target.value
})
},0)
}
render(){
return <div>
<input placeholder="請(qǐng)輸入用戶名?" onChange={ this.handerChange.bind(this) } />
</div>
}
}
input的值受到 state中value屬性控制,小明想要通過(guò)handerChange改變value值,但是他期望在setTimeout中完成更新。可以當(dāng)他想要改變input值時(shí)候,意想不到的事情發(fā)生了。

控制臺(tái)報(bào)錯(cuò)如上所示。Cannot read property 'value' of null 也就是說(shuō)明e.target為null。事件源 target怎么說(shuō)沒就沒呢?
線索追蹤
接到這個(gè)案件之后,我們首先排查問題,那么我們先在handerChange直接打印e.target,如下:

看來(lái)首先排查不是 handerChange 的原因,然后我們接著在setTimeout中打印發(fā)現(xiàn):

果然是setTimeout的原因,為什么setTimeout中的事件源 e.target 就莫名的失蹤了呢? 首先,事件源肯定不是莫名的失蹤了,肯定 React 底層對(duì)事件源做了一些額外的處理,首先我們知道React采用的是事件合成機(jī)制,也就是綁定的 onChange不是真實(shí)綁定的 change事件,小明綁定的 handerChange也不是真正的事件處理函數(shù)。那么也就是說(shuō)React底層幫我們處理了事件源。這一切可能只有我們從 React 源碼中找到線索。經(jīng)過(guò)對(duì)源碼的排查,我發(fā)現(xiàn)有一處線索十分可疑。
react-dom/src/events/DOMLegacyEventPluginSystem.js
function dispatchEventForLegacyPluginEventSystem(topLevelType,eventSystemFlags,nativeEvent,targetInst){
const bookKeeping = getTopLevelCallbackBookKeeping(topLevelType,nativeEvent,targetInst,eventSystemFlags);
batchedEventUpdates(handleTopLevel, bookKeeping);
}
dispatchEventForLegacyPluginEventSystem是legacy模式下,所有事件都必定經(jīng)過(guò)的主要函數(shù),batchedEventUpdates是處理批量更新的邏輯,里面會(huì)執(zhí)行我們真正的事件處理函數(shù),我們?cè)谑录砥轮v過(guò) nativeEvent 就是真正原生的事件對(duì)象 event。targetInst 就是e.target對(duì)應(yīng)的fiber對(duì)象。我們?cè)?code style="font-size: 14px;overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;color: rgb(30, 107, 184);background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;">handerChange里面獲取的事件源是React合成的事件源,那么了解事件源是什么時(shí)候,怎么樣被合成的? 這對(duì)于破案可能會(huì)有幫助。
事件原理篇我們將介紹React采用事件插件機(jī)制,比如我們的onClick事件對(duì)應(yīng)的是 SimpleEventPlugin,那么小明寫onChange也有專門 ChangeEventPlugin事件插件,這些插件有一個(gè)至關(guān)重要的作用就是用來(lái)合成我們事件源對(duì)象e,所以我們來(lái)看一下ChangeEventPlugin。
react-dom/src/events/ChangeEventPlugin.js
const ChangeEventPlugin ={
eventTypes: eventTypes,
extractEvents:function(){
const event = SyntheticEvent.getPooled(
eventTypes.change,
inst, // 組件實(shí)例
nativeEvent, // 原生的事件源 e
target, // 原生的e.target
);
accumulateTwoPhaseListeners(event); // 這個(gè)函數(shù)按照冒泡捕獲邏輯處理真正的事件函數(shù),也就是 handerChange 事件
return event; //
}
}
我們看到合成事件的事件源handerChange中的 e,就是SyntheticEvent.getPooled創(chuàng)建出來(lái)的。那么這個(gè)是破案的關(guān)鍵所在。
legacy-events/SyntheticEvent.js
SyntheticEvent.getPooled = function(){
const EventConstructor = this; // SyntheticEvent
if (EventConstructor.eventPool.length) {
const instance = EventConstructor.eventPool.pop();
EventConstructor.call(instance,dispatchConfig,targetInst,nativeEvent,nativeInst,);
return instance;
}
return new EventConstructor(dispatchConfig,targetInst,nativeEvent,nativeInst,);
}
番外:在事件系統(tǒng)篇章,文章的事件池感念,講的比較倉(cāng)促,籠統(tǒng),這篇這個(gè)部分將詳細(xì)補(bǔ)充事件池感念。
getPooled引出了事件池的真正的概念,它主要做了兩件事:
判斷事件池中有沒有空余的事件源,如果有取出事件源復(fù)用。 如果沒有,通過(guò) new SyntheticEvent的方式創(chuàng)建一個(gè)新的事件源對(duì)象。那么SyntheticEvent就是創(chuàng)建事件源對(duì)象的構(gòu)造函數(shù),我們一起研究一下。
const EventInterface = {
type: null,
target: null,
currentTarget: function() {
return null;
},
eventPhase: null,
...
};
function SyntheticEvent( dispatchConfig,targetInst,nativeEvent,nativeEventTarget){
this.dispatchConfig = dispatchConfig;
this._targetInst = targetInst; // 組件對(duì)應(yīng)fiber。
this.nativeEvent = nativeEvent; // 原生事件源。
this._dispatchListeners = null; // 存放所有的事件監(jiān)聽器函數(shù)。
for (const propName in Interface) {
if (propName === 'target') {
this.target = nativeEventTarget; // 我們真正打印的 target 是在這里
} else {
this[propName] = nativeEvent[propName];
}
}
}
SyntheticEvent.prototype.preventDefault = function (){ /* .... */ } /* 組件瀏覽器默認(rèn)行為 */
SyntheticEvent.prototype.stopPropagation = function () { /* .... */ } /* 阻止事件冒泡 */
SyntheticEvent.prototype.destructor = function (){ /* 情況事件源對(duì)象*/
for (const propName in Interface) {
this[propName] = null
}
this.dispatchConfig = null;
this._targetInst = null;
this.nativeEvent = null;
}
const EVENT_POOL_SIZE = 10; /* 最大事件池?cái)?shù)量 */
SyntheticEvent.eventPool = [] /* 綁定事件池 */
SyntheticEvent.release=function (){ /* 清空事件源對(duì)象,如果沒有超過(guò)事件池上限,那么放回事件池 */
const EventConstructor = this;
event.destructor();
if (EventConstructor.eventPool.length < EVENT_POOL_SIZE) {
EventConstructor.eventPool.push(event);
}
}
我把這一段代碼精煉之后,真相也就漸漸浮出水面了,我們先來(lái)看看 SyntheticEvent 做了什么:
首先賦予一些初始化的變量
nativeEvent等。然后按照EventInterface規(guī)則把原生的事件源上的屬性,復(fù)制一份給React 事件源。然后一個(gè)重要的就是我們打印的e.target就是this.target,在事件源初始化的時(shí)候綁定了真正的e.target->nativeEventTarget然后React事件源,綁定了自己的阻止默認(rèn)行為
preventDefault,阻止冒泡stopPropagation等方法。但是這里有一個(gè)重點(diǎn)方法就destructor,這個(gè)函數(shù)置空了React自己的事件源對(duì)象。那么我們終于找到了答案,我們的事件源e.target消失大概率就是因?yàn)檫@個(gè)destructor,destructor在release中被觸發(fā),然后將事件源放進(jìn)事件池,等待下一次復(fù)用。
現(xiàn)在所有的矛頭都指向了release,那么release是什么時(shí)候觸發(fā)的呢?
legacy-events/SyntheticEvent.js
function executeDispatchesAndRelease(){
event.constructor.release(event);
}
當(dāng) React 事件系統(tǒng)執(zhí)行完所有的 _dispatchListeners,就會(huì)觸發(fā)這個(gè)方法 executeDispatchesAndRelease釋放當(dāng)前的事件源。
真相大白
回到小明遇到的這個(gè)問題,我們上面講到,React最后會(huì)同步的置空事件源,然后放入事件池,因?yàn)?code style="font-size: 14px;overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;color: rgb(30, 107, 184);background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;">setTimeout是異步執(zhí)行,執(zhí)行時(shí)候事件源對(duì)象已經(jīng)被重置并釋放會(huì)事件池,所以我們打印 e.target = null,到此為止,案件真相大白。
通過(guò)這個(gè)案件我們明白了 React 事件池的一些概念:
React 事件系統(tǒng)有獨(dú)特合成事件,也有自己的事件源,而且還有對(duì)一些特殊情況的處理邏輯,比如冒泡邏輯等。 React 為了防止每次事件都創(chuàng)建事件源對(duì)象,浪費(fèi)性能,所以引入了事件池概念,每一次用戶事件都會(huì)從事件池中取出一個(gè)e,如果沒有,就創(chuàng)建一個(gè),然后賦值事件源,等到事件執(zhí)行之后,重置事件源,放回事件池,借此做到復(fù)用。
用一幅流程圖表示:

案件三:真假React
案發(fā)現(xiàn)場(chǎng)
這個(gè)是發(fā)生在筆者身上的事兒,之前在開發(fā) React 項(xiàng)目時(shí)候,為了邏輯復(fù)用,我把一些封裝好的自定義 Hooks 上傳到公司私有的 package 管理平臺(tái)上,在開發(fā)另外一個(gè) React 項(xiàng)目的時(shí)候,把公司的包下載下來(lái),在組件內(nèi)部用起來(lái)。代碼如下:
function Index({classes, onSubmit, isUpgrade}) {
/* useFormQueryChange 是筆者寫好的自定義hooks,并上傳到私有庫(kù),主要是用于對(duì)表單控件的統(tǒng)一管理 */
const {setFormItem, reset, formData} = useFormQueryChange()
React.useEffect(() => {
if (isUpgrade) reset()
}, [ isUpgrade ])
return <form
className={classes.bootstrapRoot}
autoComplete='off'
>
<div className='btnbox' >
{ /* 這里是業(yè)務(wù)邏輯,已經(jīng)省略 */ }
</div>
</form>
}
useFormQueryChange 是筆者寫好的自定義 hooks ,并上傳到私有庫(kù),主要是用于對(duì)表單控件的統(tǒng)一管理,沒想到引入就直接爆紅了。錯(cuò)誤內(nèi)容如下:

逐一排查
我們按照 React 報(bào)錯(cuò)的內(nèi)容,逐一排查問題所在:
第一個(gè)可能報(bào)錯(cuò)原因
You might have mismatching versions of React and the renderer (such as React DOM),意思是React和React Dom版本不一致,造成這種情況,但是我們項(xiàng)目中的React和React Dom都是v16.13.1,所以排除這個(gè)的嫌疑。第二個(gè)可能報(bào)錯(cuò)原因
You might be breaking the Rules of Hooks意思是你打破了Hooks 規(guī)則,這種情況也是不可能的,因?yàn)楣P者代碼里沒有破壞hoos規(guī)則的行為。所以也排除嫌疑。第三個(gè)可能報(bào)錯(cuò)原因
You might have more than one copy of React in the same app意思是在同一個(gè)應(yīng)用里面,可能有多個(gè) React。目前來(lái)看所有的嫌疑都指向第三個(gè),首先我們引用的自定義 hooks,會(huì)不會(huì)內(nèi)部又存在一個(gè)React 呢?
按照上面的提示我排查到自定義hooks對(duì)應(yīng)的node_modules中果然存在另外一個(gè)React,是這個(gè)假React(我們姑且稱之為假React)搞的鬼。我們?cè)贖ooks原理 文章中講過(guò),React Hooks用ReactCurrentDispatcher.current 在組件初始化,組件更新階段賦予不同的hooks對(duì)象,更新完畢后賦予ContextOnlyDispatcher,如果調(diào)用這個(gè)對(duì)象下面的hooks,就會(huì)報(bào)如上錯(cuò)誤,那么說(shuō)明了這個(gè)錯(cuò)誤是因?yàn)槲覀冞@個(gè)項(xiàng)目,執(zhí)行上下文引入的React是項(xiàng)目本身的React,但是自定義Hooks引用的是假React Hooks中的ContextOnlyDispatcher
接下來(lái)我看到組件庫(kù)中的package.json中,
"dependencies": {
"react": "^16.13.1",
"react-dom": "^16.13.1"
},
原來(lái)是React作為 dependencies所以在下載自定義Hooks的時(shí)候,把React又下載了一遍。那么如何解決這個(gè)問題呢。對(duì)于封裝React組件庫(kù),hooks庫(kù),不能用 dependencies,因?yàn)樗鼤?huì)以當(dāng)前的dependencies為依賴下載到自定義hooks庫(kù)下面的node_modules中。取而代之的應(yīng)該用peerDependencies,使用peerDependencies,自定義hooks再找相關(guān)依賴就會(huì)去我們的項(xiàng)目的node_modules中找,就能根本上解決這個(gè)問題。
所以我們這么改
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8",
},
就完美的解決了這個(gè)問題。
撥開迷霧
這個(gè)問題讓我們明白了如下:
對(duì)于一些hooks庫(kù),組件庫(kù),本身的依賴,已經(jīng)在項(xiàng)目中存在了,所以用
peerDependencies聲明。在開發(fā)的過(guò)程中,很可能用到不同版本的同一依賴,比如說(shuō)項(xiàng)目引入了 A 版本的依賴,組件庫(kù)引入了 B 版本的依賴。那么這種情況如何處理呢。在
package.json文檔中提供了一個(gè)resolutions配置項(xiàng)可以解決這個(gè)問題,在resolutions中鎖定同一的引入版本,這樣就不會(huì)造成如上存在多個(gè)版本的項(xiàng)目依賴而引發(fā)的問題。
項(xiàng)目package.json這么寫
{
"resolutions": {
"react": "16.13.1",
"react-dom": "16.13.1"
},
}
這樣無(wú)論項(xiàng)目中的依賴,還是其他庫(kù)中依賴,都會(huì)使用統(tǒng)一的版本,從根本上解決了多個(gè)版本的問題。
案件四:PureComponet/memo功能失效問題
案情描述
在 React 開發(fā)的時(shí)候,但我們想要用 PureComponent 做性能優(yōu)化,調(diào)節(jié)組件渲染,但是寫了一段代碼之后,發(fā)現(xiàn) PureComponent 功能竟然失效了,具體代碼如下:
class Index extends React.PureComponent{
render(){
console.log('組件渲染')
const { name , type } = this.props
return <div>
hello , my name is { name }
let us learn { type }
</div>
}
}
export default function Home (){
const [ number , setNumber ] = React.useState(0)
const [ type , setType ] = React.useState('react')
const changeName = (name) => {
setType(name)
}
return <div>
<span>{ number }</span><br/>
<button onClick={ ()=> setNumber(number + 1) } >change number</button>
<Index type={type} changeType={ changeName } name="alien" />
</div>
}
我們本來(lái)期望:
對(duì)于 Index 組件,只有 props中name和type改變,才促使組件渲染。但是實(shí)際情況卻是這樣:
點(diǎn)擊按鈕效果:

水落石出
為什么會(huì)出現(xiàn)這種情況呢? 我們?cè)倥挪橐幌?code style="font-size: 14px;overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;color: rgb(30, 107, 184);background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;">Index組件,發(fā)現(xiàn) Index 組件上有一個(gè) changeType,那么是不是這個(gè)的原因呢? 我們來(lái)分析一下,首先狀態(tài)更新是在父組件 Home上,Home組件更新每次會(huì)產(chǎn)生一個(gè)新的changeName,所以Index的PureComponent每次會(huì)淺比較,發(fā)現(xiàn)props中的changeName每次都不相等,所以就更新了,給我們直觀的感覺是失效了。
那么如何解決這個(gè)問題,React hooks 中提供了 useCallback,可以對(duì)props傳入的回調(diào)函數(shù)進(jìn)行緩存,我們來(lái)改一下Home代碼。
const changeName = React.useCallback((name) => {
setType(name)
},[])
效果:

這樣就根本解決了問題,用 useCallback對(duì)changeName函數(shù)進(jìn)行緩存,在每一次 Home 組件執(zhí)行,只要useCallback中deps沒有變,changeName內(nèi)存空間還指向原來(lái)的函數(shù),這樣PureComponent淺比較就會(huì)發(fā)現(xiàn)是相同changeName,從而不渲染組件,至此案件已破。
繼續(xù)深入
大家用函數(shù)組件+類組件開發(fā)的時(shí)候,如果用到React.memo React.PureComponent等api,要注意給這些組件綁定事件的方式,如果是函數(shù)組件,那么想要持續(xù)保持純組件的渲染控制的特性的話,那么請(qǐng)用 useCallback,useMemo等api處理,如果是類組件,請(qǐng)不要用箭頭函數(shù)綁定事件,箭頭函數(shù)同樣會(huì)造成失效的情況。
上述中提到了一個(gè)淺比較shallowEqual,接下來(lái)我們重點(diǎn)分析一下 PureComponent是如何shallowEqual,接下來(lái)我們?cè)谏钊胙芯恳幌?code style="font-size: 14px;overflow-wrap: break-word;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;color: rgb(30, 107, 184);background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;">shallowEqual的奧秘。那么就有從類租價(jià)的更新開始。
react-reconciler/src/ReactFiberClassComponent.js
function updateClassInstance(){
const shouldUpdate =
checkHasForceUpdateAfterProcessing() ||
checkShouldComponentUpdate(
workInProgress,
ctor,
oldProps,
newProps,
oldState,
newState,
nextContext,
);
return shouldUpdate
}
我這里簡(jiǎn)化updateClassInstance,只保留了涉及到PureComponent的部分。updateClassInstance這個(gè)函數(shù)主要是用來(lái),執(zhí)行生命周期,更新state,判斷組件是否重新渲染,返回的 shouldUpdate用來(lái)決定當(dāng)前類組件是否渲染。checkHasForceUpdateAfterProcessing檢查更新來(lái)源是否來(lái)源與 forceUpdate , 如果是forceUpdate組件是一定會(huì)更新的,checkShouldComponentUpdate檢查組件是否渲染。我們接下來(lái)看一下這個(gè)函數(shù)的邏輯。
function checkShouldComponentUpdate(){
/* 這里會(huì)執(zhí)行類組件的生命周期 shouldComponentUpdate */
const shouldUpdate = instance.shouldComponentUpdate(
newProps,
newState,
nextContext,
);
/* 這里判斷組件是否是 PureComponent 純組件,如果是純組件那么會(huì)調(diào)用 shallowEqual 淺比較 */
if (ctor.prototype && ctor.prototype.isPureReactComponent) {
return (
!shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)
);
}
}
checkShouldComponentUpdate有兩個(gè)至關(guān)重要的作用:
第一個(gè)就是如果類組件有生命周期 shouldComponentUpdate,會(huì)執(zhí)行生命周期shouldComponentUpdate,判斷組件是否渲染。如果發(fā)現(xiàn)是純組件 PureComponent,會(huì)淺比較新老props和state是否相等,如果相等,則不更新組件。isPureReactComponent就是我們使用PureComponent的標(biāo)識(shí),證明是純組件。
接下來(lái)就是重點(diǎn)shallowEqual,以props為例子,我們看一下。
shared/shallowEqual
function shallowEqual(objA: mixed, objB: mixed): boolean {
if (is(objA, objB)) { // is可以 理解成 objA === objB 那么返回相等
return true;
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
} // 如果新老props有一個(gè)不為對(duì)象,或者不存在,那么直接返回false
const keysA = Object.keys(objA); // 老props / 老state key組成的數(shù)組
const keysB = Object.keys(objB); // 新props / 新state key組成的數(shù)組
if (keysA.length !== keysB.length) { // 說(shuō)明props增加或者減少,那么直接返回不想等
return false;
}
for (let i = 0; i < keysA.length; i++) { // 遍歷老的props ,發(fā)現(xiàn)新的props沒有,或者新老props不同等,那么返回不更新組件。
if (
!hasOwnProperty.call(objB, keysA[i]) ||
!is(objA[keysA[i]], objB[keysA[i]])
) {
return false;
}
}
return true; //默認(rèn)返回相等
}
shallowEqual流程是這樣的,shallowEqual 返回 true 則證明相等,那么不更新組件;如果返回false 證明不想等,那么更新組件。is 我們暫且可以理解成 ===
第一步,直接通過(guò) === 判斷是否相等,如果相等,那么返回 true。正常情況只要調(diào)用React.createElement會(huì)重新創(chuàng)建props,props都是不相等的。第二步,如果新老 props有一個(gè)不為對(duì)象,或者不存在,那么直接返回false。第三步,判斷新老 props,key組成的數(shù)組數(shù)量等不想等,說(shuō)明props有增加或者減少,那么直接返回false。第四步,遍歷老的 props,發(fā)現(xiàn)新的props沒有與之對(duì)應(yīng),或者新老props不同等,那么返回false。默認(rèn)返回 true。
這就是shallowEqual邏輯,代碼還是非常簡(jiǎn)單的。感興趣的同學(xué)可以看一看。
案件五: useState更新相同的State,函數(shù)組件執(zhí)行2次
接到報(bào)案
這個(gè)問題實(shí)際很懸,大家可能平時(shí)沒有注意到,引起我的注意的是掘金的一個(gè)掘友問我的一個(gè)問題,問題如下:
首先非常感謝這位細(xì)心的掘友的報(bào)案,我在 React-hooks 原理 中講到過(guò),對(duì)于更新組件的方法函數(shù)組件 useState 和類組件的setState有一定區(qū)別,useState源碼中如果遇到兩次相同的state,會(huì)默認(rèn)阻止組件再更新,但是類組件中setState如果沒有設(shè)置 PureComponent,兩次相同的state 也會(huì)更新。
我們回顧一下 hooks 中是怎么樣阻止組件更新的。
react-reconciler/src/ReactFiberHooks.js -> dispatchAction
if (is(eagerState, currentState)) {
return
}
scheduleUpdateOnFiber(fiber, expirationTime); // 調(diào)度更新
如果判斷上一次的state -> currentState ,和這一次的state -> eagerState 相等,那么將直接 return阻止組件進(jìn)行scheduleUpdate調(diào)度更新。所以我們想如果兩次 useState觸發(fā)同樣的state,那么組件只能更新一次才對(duì),但是事實(shí)真的是這樣嗎?。
立案調(diào)查
順著這位掘友提供的線索,我們開始寫 demo進(jìn)行驗(yàn)證。
const Index = () => {
const [ number , setNumber ] = useState(0)
console.log('組件渲染',number)
return <div className="page" >
<div className="content" >
<span>{ number }</span><br/>
<button onClick={ () => setNumber(1) } >將number設(shè)置成1</button><br/>
<button onClick={ () => setNumber(2) } >將number設(shè)置成2</button><br/>
<button onClick={ () => setNumber(3) } >將number設(shè)置成3</button>
</div>
</div>
}
export default class Home extends React.Component{
render(){
return <Index />
}
}
如上demo,三個(gè)按鈕,我們期望連續(xù)點(diǎn)擊每一個(gè)按鈕,組件都會(huì)僅此渲染一次,于是我們開始實(shí)驗(yàn):
效果:

果然,我們通過(guò) setNumber 改變 number,每次連續(xù)點(diǎn)擊按鈕,組件都會(huì)更新2次,按照我們正常的理解,每次賦予 number 相同的值,只會(huì)渲染一次才對(duì),但是為什么執(zhí)行了2次呢?
可能剛開始會(huì)陷入困境,不知道怎么破案,但是我們?cè)谙?hooks原理中講過(guò),每一個(gè)函數(shù)組件用對(duì)應(yīng)的函數(shù)組件的 fiber 對(duì)象去保存 hooks 信息。所以我們只能從 fiber找到線索。
順藤摸瓜
那么如何找到函數(shù)組件對(duì)應(yīng)的fiber對(duì)象呢,這就順著函數(shù)組件的父級(jí) Home 入手了,因?yàn)槲覀兛梢詮念惤M件Home中找到對(duì)應(yīng)的fiber對(duì)象,然后根據(jù) child 指針找到函數(shù)組件 Index對(duì)應(yīng)的 fiber。說(shuō)干就干,我們將上述代碼改造成如下的樣子:
const Index = ({ consoleFiber }) => {
const [ number , setNumber ] = useState(0)
useEffect(()=>{
console.log(number)
consoleFiber() // 每次fiber更新后,打印 fiber 檢測(cè) fiber變化
})
return <div className="page" >
<div className="content" >
<span>{ number }</span><br/>
<button onClick={ () => setNumber(1) } >將number設(shè)置成1</button><br/>
</div>
</div>
}
export default class Home extends React.Component{
consoleChildrenFiber(){
console.log(this._reactInternalFiber.child) /* 用來(lái)打印函數(shù)組件 Index 對(duì)應(yīng)的fiber */
}
render(){
return <Index consoleFiber={ this.consoleChildrenFiber.bind(this) } />
}
}
我們重點(diǎn)關(guān)心fiber上這幾個(gè)屬性,這對(duì)破案很有幫助
Index fiber上的memoizedState屬性,react hooks原理文章中講過(guò),函數(shù)組件用memoizedState保存所有的hooks信息。Index fiber上的alternate屬性Index fiber上的alternate屬性上的memoizedState屬性。是不是很繞??,馬上會(huì)揭曉是什么。Index組件上的useState中的number。
首先我們講一下 alternate 指針指的是什么?
說(shuō)到alternate 就要從fiber架構(gòu)設(shè)計(jì)說(shuō)起,每個(gè)React元素節(jié)點(diǎn),用兩顆fiber樹保存狀態(tài),一顆樹保存當(dāng)前狀態(tài),一個(gè)樹保存上一次的狀態(tài),兩棵 fiber 樹用 alternate 相互指向。就是我們耳熟能詳?shù)?strong>雙緩沖。
初始化打印
效果圖:

初始化完成第一次render后,我們看一下fiber樹上的這幾個(gè)狀態(tài)
第一次打印結(jié)果如下,
fiber上的memoizedState中baseState = 0即是初始化useState的值。fiber上的alternate為null。Index組件上的number為 0。
初始化流程:首先對(duì)于組件第一次初始化,會(huì)調(diào)和渲染形成一個(gè)fiber樹(我們簡(jiǎn)稱為樹A)。樹A的alternate屬性為 null。
第一次點(diǎn)擊 setNumber(1)
我們第一次點(diǎn)擊發(fā)現(xiàn)組件渲染了,然后我們打印結(jié)果如下:

樹A上的 memoizedState中 **baseState = 0。樹A上的 alternate指向 另外一個(gè)fiber(我們這里稱之為樹B)。Index組件上的number為 1。
接下來(lái)我們打印樹B上的 memoizedState

結(jié)果我們發(fā)現(xiàn)樹B上 memoizedState上的 baseState = 1。
得出結(jié)論:更新的狀態(tài)都在樹B上,而樹A上的 baseState還是之前的0。
我們大膽猜測(cè)一下更新流程:在第一次更新渲染的時(shí)候,由于樹A中,不存在alternate,所以直接復(fù)制一份樹A作為 workInProgress(我們這里稱之為樹B)所有的更新都在當(dāng)前樹B中進(jìn)行,所以 baseState 會(huì)被更新成 1,然后用當(dāng)前的樹B進(jìn)行渲染。結(jié)束后樹A和樹B通過(guò)alternate相互指向。樹B作為下一次操作的current樹。
第二次點(diǎn)擊 setNumber(1)
第二次打印,組件同樣渲染了,然后我們打印fiber對(duì)象,效果如下:

fiber對(duì)象上的 memoizedState中baseState更新成了 1。
然后我們打印一下 alternate 中 baseState也更新成了 1。

第二次點(diǎn)擊之后 ,樹A和樹B都更新到最新的 baseState = 1
首先我們分析一下流程:當(dāng)我們第二次點(diǎn)擊時(shí)候,是通過(guò)上一次樹A中的 baseState = 0 和 setNumber(1) 傳入的 1做的比較。所以發(fā)現(xiàn) eagerState !== currentState ,組件又更新了一次。接下來(lái)會(huì)以current樹(樹B)的 alternate指向的樹A作為新的workInProgress進(jìn)行更新,此時(shí)的樹A上的 baseState 終于更新成了 1 ,這就解釋了為什么上述兩個(gè) baseState 都等于 1。接下來(lái)組件渲染完成。樹A作為了新的 current 樹。
在我們第二次打印,打印出來(lái)的實(shí)際是交替后樹B,樹A和樹B就這樣交替著作為最新狀態(tài)用于渲染的workInProgress樹和緩存上一次狀態(tài)用于下一次渲染的current樹。
第三次點(diǎn)擊(三者言其多也)
那么第三次點(diǎn)擊組件沒有渲染,就很好解釋了,第三次點(diǎn)擊上一次樹B中的 baseState = 1 和 setNumber(1)相等,也就直接走了return邏輯。
揭開謎底(我們學(xué)到了什么)
雙緩沖樹:React 用
workInProgress樹(內(nèi)存中構(gòu)建的樹) 和current(渲染樹) 來(lái)實(shí)現(xiàn)更新邏輯。我們console.log打印的fiber都是在內(nèi)存中即將workInProgress的fiber樹。雙緩存一個(gè)在內(nèi)存中構(gòu)建,在下一次渲染的時(shí)候,直接用緩存樹做為下一次渲染樹,上一次的渲染樹又作為緩存樹,這樣可以防止只用一顆樹更新狀態(tài)的丟失的情況,又加快了dom節(jié)點(diǎn)的替換與更新。更新機(jī)制:在一次更新中,首先會(huì)獲取current樹的
alternate作為當(dāng)前的workInProgress,渲染完畢后,workInProgress樹變?yōu)?current樹。我們用如上的樹A和樹B和已經(jīng)保存的baseState模型,來(lái)更形象的解釋了更新機(jī)制 。 hooks中的useState進(jìn)行state對(duì)比,用的是緩存樹上的state和當(dāng)前最新的state。所有就解釋了為什么更新相同的state,函數(shù)組件執(zhí)行2次了。
我們用一幅流程圖來(lái)描述整個(gè)流程。

此案已破,通過(guò)這個(gè)容易忽略的案件,我們學(xué)習(xí)了雙緩沖和更新機(jī)制。
案件六:useEffect修改DOM元素導(dǎo)致怪異閃現(xiàn)
鬼使神差
小明(化名)在動(dòng)態(tài)掛載組件的時(shí)候,遇到了靈異的Dom閃現(xiàn)現(xiàn)象,讓我們先來(lái)看一下現(xiàn)象。
閃現(xiàn)現(xiàn)象:

代碼:
function Index({ offset }){
const card = React.useRef(null)
React.useEffect(()=>{
card.current.style.left = offset
},[])
return <div className='box' >
<div className='card custom' ref={card} >《 React進(jìn)階實(shí)踐指南 》</div>
</div>
}
export default function Home({ offset = '300px' }){
const [ isRender , setRender ] = React.useState(false)
return <div>
{ isRender && <Index offset={offset} /> }
<button onClick={ ()=>setRender(true) } > 掛載</button>
</div>
}
在父組件用 isRender動(dòng)態(tài)加載Index,點(diǎn)擊按鈕控制Index渲染。在 Index的接受動(dòng)態(tài)的偏移量offset。并通過(guò)操縱用useRef獲取的原生dom直接改變偏移量,使得劃塊滑動(dòng)。但是出現(xiàn)了如上圖的閃現(xiàn)現(xiàn)象,很不友好,那么為什么會(huì)造成這個(gè)問題呢?
深入了解
初步判斷產(chǎn)生這個(gè)閃現(xiàn)的問題應(yīng)該是 useEffect造成的,為什么這么說(shuō)呢,因?yàn)轭惤M件生命周期 componentDidMount寫同樣的邏輯,然而并不會(huì)出現(xiàn)這種現(xiàn)象。那么為什么useEffect會(huì)造成這種情況,我們只能順藤摸瓜找到 useEffect 的 callback執(zhí)行時(shí)機(jī)說(shuō)起。
useEffect ,useLayoutEffect , componentDidMount執(zhí)行時(shí)機(jī)都是在 commit階段執(zhí)行。我們知道 React 有一個(gè) effectList存放不同effect。因?yàn)?React 對(duì)不同的 effect 執(zhí)行邏輯和時(shí)機(jī)不同。我們看一下useEffect被定義的時(shí)候,定義成了什么樣類型的 effect。
react-reconciler/src/ReactFiberHooks.js
function mountEffect(create, deps){
return mountEffectImpl(
UpdateEffect | PassiveEffect, // PassiveEffect
HookPassive,
create,
deps,
);
}
這個(gè)函數(shù)的信息如下:
useEffect被賦予PassiveEffect類型的effect。小明改原生dom位置的函數(shù),就是 create。
那么 create函數(shù)什么時(shí)候執(zhí)行的,React又是怎么處理PassiveEffect的呢,這是破案的關(guān)鍵。記下來(lái)我們看一 下React 怎么處理PassiveEffect。
react-reconciler/src/ReactFiberCommitWork.js
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
if ((effectTag & Passive) !== NoEffect) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
/* 異步調(diào)度 - PassiveEffect */
scheduleCallback(NormalPriority, () => {
flushPassiveEffects();
return null;
});
}
}
nextEffect = nextEffect.nextEffect;
}
}
在commitBeforeMutationEffects 函數(shù)中,會(huì)異步調(diào)度 flushPassiveEffects方法,flushPassiveEffects方法中,對(duì)于React hooks 會(huì)執(zhí)行 commitPassiveHookEffects,然后會(huì)執(zhí)行 commitHookEffectListMount 。
function commitHookEffectListMount(){
if (lastEffect !== null) {
effect.destroy = create(); /* 執(zhí)行useEffect中餓 */
}
}
在 commitHookEffectListMount中,create函數(shù)會(huì)被調(diào)用。我們給dom元素加的位置就會(huì)生效。
那么問題來(lái)了,異步調(diào)度做了些什么呢? React的異步調(diào)度,為了防止一些任務(wù)執(zhí)行耽誤了瀏覽器繪制,而造成卡幀現(xiàn)象,react 對(duì)于一些優(yōu)先級(jí)不高的任務(wù),采用異步調(diào)度來(lái)處理,也就是讓瀏覽器才空閑的時(shí)間來(lái)執(zhí)行這些異步任務(wù),異步任務(wù)執(zhí)行在不同平臺(tái),不同瀏覽器上實(shí)現(xiàn)方式不同,這里先姑且認(rèn)為效果和setTimeout一樣。
雨過(guò)天晴
通過(guò)上述我們發(fā)現(xiàn) useEffect 的第一個(gè)參數(shù) create,采用的異步調(diào)用的方式,那么閃現(xiàn)就很好理解了,在點(diǎn)擊按鈕組件第一次渲染過(guò)程中,首先執(zhí)行函數(shù)組件render,然后commit替換真實(shí)dom節(jié)點(diǎn),然后瀏覽器繪制完畢。此時(shí)瀏覽器已經(jīng)繪制了一次,然后瀏覽器有空余時(shí)間執(zhí)行異步任務(wù),所以執(zhí)行了create,修改了元素的位置信息,因?yàn)樯弦淮卧匾呀?jīng)繪制,此時(shí)又修改了一個(gè)位置,所以感到閃現(xiàn)的效果,此案已破。,
那么我們?cè)趺礃咏鉀Q閃現(xiàn)的現(xiàn)象呢,那就是 React.useLayoutEffect ,useLayoutEffect的 create是同步執(zhí)行的,所以瀏覽器繪制一次,直接更新了最新的位置。
React.useLayoutEffect(()=>{
card.current.style.left = offset
},[])
總結(jié)
本節(jié)可我們學(xué)到了什么?
本文以破案的角度,從原理角度講解了 React 一些意想不到的現(xiàn)象,透過(guò)這些現(xiàn)象,我們學(xué)習(xí)了一些 React 內(nèi)在的東西,我對(duì)如上案例總結(jié),
案件一-對(duì)一些組件渲染和組件錯(cuò)誤時(shí)機(jī)聲明的理解 案件二-實(shí)際事件池概念的補(bǔ)充。 案件三-是對(duì)一些組件庫(kù)引入多個(gè)版本 React的思考和解決方案。案件四-要注意給 memo/PureComponent綁定事件,以及如何處理PureComponent邏輯,shallowEqual的原理。案件五-實(shí)際是對(duì) fiber雙緩存樹的講解。案件六-是對(duì) useEffect create執(zhí)行時(shí)機(jī)的講解。
如果你覺得這篇內(nèi)容對(duì)你挺有啟發(fā),我想邀請(qǐng)你幫我三個(gè)小忙:
點(diǎn)個(gè)「在看」,讓更多的人也能看到這篇內(nèi)容(喜歡不點(diǎn)在看,都是耍流氓 -_-) 歡迎加我微信「TH0000666」一起交流學(xué)習(xí)... 關(guān)注公眾號(hào)「前端Sharing」,持續(xù)為你推送精選好文。
