我對 React 實(shí)現(xiàn)原理的理解
React 是前端開發(fā)每天都用的前端框架,自然要深入掌握它的原理。我用 React 也挺久了,這篇文章就來總結(jié)一下我對 react 原理的理解。
react 和 vue 都是基于 vdom 的前端框架,我們先聊下 vdom:
vdom
為什么 react 和 vue 都要基于 vdom 呢?直接操作真實(shí) dom 不行么?
考慮下這樣的場景:
渲染就是用 dom api 對真實(shí) dom 做增刪改,如果已經(jīng)渲染了一個(gè) dom,后來要更新,那就要遍歷它所有的屬性,重新設(shè)置,比如 id、clasName、onclick 等。
而 dom 的屬性是很多的:
有很多屬性根本用不到,但在更新時(shí)卻要跟著重新設(shè)置一遍。
能不能只對比我們關(guān)心的屬性呢?
把這些單獨(dú)摘出來用 JS 對象表示不就行了?
這就是為什么要有 vdom,是它的第一個(gè)好處。
而且有了 vdom 之后,就沒有和 dom 強(qiáng)綁定了,可以渲染到別的平臺,比如 native、canvas 等等。
這是 vdom 的第二個(gè)好處。
我們知道了 vdom 就是用 JS 對象表示最終渲染的 dom 的,比如:
{
type: 'div',
props: {
id: 'aaa',
className: ['bbb', 'ccc'],
onClick: function() {}
},
children: []
}
然后用渲染器把它渲染出來。
但是要讓開發(fā)去寫這樣的 vdom 么?
那肯定不行,這樣太麻煩了,大家熟悉的是 html 那種方式,所以我們要引入編譯的手段。
dsl 的編譯
dsl 是 domain specific language,領(lǐng)域特定語言的意思,html、css 都是 web 領(lǐng)域的 dsl。
直接寫 vdom 太麻煩了,所以前端框架都會設(shè)計(jì)一套 dsl,然后編譯成 render function,執(zhí)行后產(chǎn)生 vdom。
vue 和 react 都是這樣:
這套 dsl 怎么設(shè)計(jì)呢?
前端領(lǐng)域大家熟悉的描述 dom 的方式是 html,最好的方式自然是也設(shè)計(jì)成那樣。
所以 vue 的 template,react 的 jsx 就都是這么設(shè)計(jì)的。
vue 的 template compiler 是自己實(shí)現(xiàn)的,而 react 的 jsx 的編譯器是 babel 實(shí)現(xiàn)的,是兩個(gè)團(tuán)隊(duì)合作的結(jié)果。
比如我們可以這樣寫:
編譯成 render function 后再執(zhí)行就是我們需要的 vdom。
接下來渲染器把它渲染出來就行了。
那渲染器怎么渲染 vdom 的呢?
渲染 vdom
渲染 vdom 也就是通過 dom api 增刪改 dom。
比如一個(gè) div,那就要 document.createElement 創(chuàng)建元素,然后 setAttribute 設(shè)置屬性,addEventListener 設(shè)置事件監(jiān)聽器。
如果是文本,那就要 document.createTextNode 來創(chuàng)建。
所以說根據(jù) vdom 類型的不同,寫個(gè) if else,分別做不同的處理就行了。
switch (vdom.tag) {
case HostComponent:
// 創(chuàng)建或更新 dom
case HostText:
// 創(chuàng)建或更新 dom
case FunctionComponent:
// 創(chuàng)建或更新 dom
case ClassComponent:
// 創(chuàng)建或更新 dom
}
沒錯,不管 vue 還是 react,渲染器里這段 if else 是少不了的:
react 里是通過 tag 來區(qū)分 vdom 類型的,比如 HostComponent 就是元素,HostText 就是文本,F(xiàn)unctionComponent、ClassComponent 就分別是函數(shù)組件和類組件。
那么問題來了,組件怎么渲染呢?
這就涉及到組件的原理了:
組件
我們的目標(biāo)是通過 vdom 描述界面,在 react 里會使用 jsx。
這樣的 jsx 有的時(shí)候是基于 state 來動態(tài)生成的。如何把 state 和 jsx 關(guān)聯(lián)起來呢?
封裝成 function、class 或者 option 對象的形式。然后在渲染的時(shí)候執(zhí)行它們拿到 vdom 就行了。
這就是組件的實(shí)現(xiàn)原理:
switch (vdom.tag) {
case FunctionComponent:
const childVdom = vdom.type(props);
render(childVdom);
//...
case ClassComponent:
const instance = new vdom.type(props);
const childVdom = instance.render();
render(childVdom);
//...
}
如果是函數(shù)組件,那就傳入 props 執(zhí)行它,拿到 vdom 之后再遞歸渲染。
如果是 class 組件,那就創(chuàng)建它的實(shí)例對象,調(diào)用 render 方法拿到 vdom,然后遞歸渲染。
所以,大家猜到 vue 的 option 對象的組件描述方式怎么渲染了么?
{
data: {},
props: {}
render(h) {
return h('div', {}, '');
}
}
沒錯,就是執(zhí)行下 render 方法就行:
const childVdom = option.render();
render(childVdom);
大家可能平時(shí)會寫單文件組件 sfc 的形式,那個(gè)會有專門的編譯器,把 template 編譯成 render function,然后掛到 option 對象的 render 方法上:
所以組件本質(zhì)上只是對產(chǎn)生 vdom 的邏輯的封裝,函數(shù)的形式、option 對象的形式、class 的形式都可以。
就像 vue3 也有了函數(shù)組件一樣,組件的形式并不重要。
基于 vdom 的前端框架渲染流程都差不多,vue 和 react 很多方面是一樣的。但是管理狀態(tài)的方式不一樣,vue 有響應(yīng)式,而 react 則是 setState 的 api 的方式。
真說起來,vue 和 react 最大的區(qū)別就是狀態(tài)管理方式的區(qū)別,因?yàn)檫@個(gè)區(qū)別導(dǎo)致了后面架構(gòu)演變方向的不同。
狀態(tài)管理
react 是通過 setState 的 api 觸發(fā)狀態(tài)更新的,更新以后就重新渲染整個(gè) vdom。
而 vue 是通過對狀態(tài)做代理,get 的時(shí)候收集以來,然后修改狀態(tài)的時(shí)候就可以觸發(fā)對應(yīng)組件的 render 了。
有的同學(xué)可能會問,為什么 react 不直接渲染對應(yīng)組件呢?
想象一下這個(gè)場景:
父組件把它的 setState 函數(shù)傳遞給子組件,子組件調(diào)用了它。
這時(shí)候更新是子組件觸發(fā)的,但是要渲染的就只有那個(gè)組件么?
明顯不是,還有它的父組件。
同理,某個(gè)組件更新實(shí)際上可能觸發(fā)任意位置的其他組件更新的。
所以必須重新渲染整個(gè) vdom 才行。
那 vue 為啥可以做到精準(zhǔn)的更新變化的組件呢?
因?yàn)轫憫?yīng)式的代理呀,不管是子組件、父組件、還是其他位置的組件,只要用到了對應(yīng)的狀態(tài),那就會被作為依賴收集起來,狀態(tài)變化的時(shí)候就可以觸發(fā)它們的 render,不管是組件是在哪里的。
這就是為什么 react 需要重新渲染整個(gè) vdom,而 vue 不用。
這個(gè)問題也導(dǎo)致了后來兩者架構(gòu)上逐漸有了差異。
react 架構(gòu)的演變
react15 的時(shí)候,和 vue 的渲染流程還是很像的,都是遞歸渲染 vdom,增刪改 dom 就行。
但是因?yàn)闋顟B(tài)管理方式的差異逐漸導(dǎo)致了架構(gòu)的差異。
react 的 setState 會渲染整個(gè) vdom,而一個(gè)應(yīng)用的所有 vdom 可能是很龐大的,計(jì)算量就可能很大。
瀏覽器里 js 計(jì)算時(shí)間太長是會阻塞渲染的,會占用每一幀的動畫、重繪重排的時(shí)間,這樣動畫就會卡頓。
作為一個(gè)有追求的前端框架,動畫卡頓肯定是不行的。但是因?yàn)?setState 的方式只能渲染整個(gè) vdom,所以計(jì)算量大是不可避免的。
那能不能把計(jì)算量拆分一下,每一幀計(jì)算一部分,不要阻塞動畫的渲染呢?
順著這個(gè)思路,react 就改造為了 fiber 架構(gòu)。
fiber 架構(gòu)
優(yōu)化的目標(biāo)是打斷計(jì)算,分多次進(jìn)行,但現(xiàn)在遞歸的渲染是不能打斷的,有兩個(gè)方面的原因?qū)е碌模?/p>
- 渲染的時(shí)候直接就操作了 dom 了,這時(shí)候打斷了,那已經(jīng)更新到 dom 的那部分怎么辦?
- 現(xiàn)在是直接渲染的 vdom,而 vdom 里只有 children 的信息,如果打斷了,怎么找到它的父節(jié)點(diǎn)呢?
第一個(gè)問題的解決還是容易想到的:
渲染的時(shí)候不要直接更新到 dom 了,只找到變化的部分,打個(gè)增刪改的標(biāo)記,創(chuàng)建好 dom,等全部計(jì)算完了一次性更新到 dom 就好了。
所以 react 把渲染流程分為了兩部分:render 和 commit。
render 階段會找到 vdom 中變化的部分,創(chuàng)建 dom,打上增刪改的標(biāo)記,這個(gè)叫做 reconcile,調(diào)和。
reconcile 是可以打斷的,由 schedule 調(diào)度。
之后全部計(jì)算完了,就一次性更新到 dom,叫做 commit。
這樣,react 就把之前的和 vue 很像的遞歸渲染,改造成了 render(reconcile + schdule) + commit 兩個(gè)階段的渲染。
從此以后,react 和 vue 架構(gòu)上的差異才大了起來。
第二個(gè)問題,如何打斷以后還能找到父節(jié)點(diǎn)、其他兄弟節(jié)點(diǎn)呢?
現(xiàn)有的 vdom 是不行的,需要再記錄下 parent、silbing 的信息。所以 react 創(chuàng)造了 fiber 的數(shù)據(jù)結(jié)構(gòu)。
除了 children 信息外,額外多了 sibling、return,分別記錄著兄弟節(jié)點(diǎn)、父節(jié)點(diǎn)的信息。
這個(gè)數(shù)據(jù)結(jié)構(gòu)也叫做 fiber。(fiber 既是一種數(shù)據(jù)結(jié)構(gòu),也代表 render + commit 的渲染流程)
react 會先把 vdom 轉(zhuǎn)換成 fiber,再去進(jìn)行 reconcile,這樣就是可打斷的了。
為什么這樣就可以打斷了呢?
因?yàn)楝F(xiàn)在不再是遞歸,而是循環(huán)了:
function workLoop() {
while (wip) {
performUnitOfWork();
}
if (!wip && wipRoot) {
commitRoot();
}
}
react 里有一個(gè) workLoop 循環(huán),每次循環(huán)做一個(gè) fiber 的 reconcile,當(dāng)前處理的 fiber 會放在 workInProgress 這個(gè)全局變量上。
當(dāng)循環(huán)完了,也就是 wip 為空了,那就執(zhí)行 commit 階段,把 reconcile 的結(jié)果更新到 dom。
每個(gè) fiber 的 reconcile 是根據(jù)類型來做的不同處理。當(dāng)處理完了當(dāng)前 fiber 節(jié)點(diǎn),就把 wip 指向 sibling、return 來切到下個(gè) fiber 節(jié)點(diǎn)。:
function performUnitOfWork() {
const { tag } = wip;
switch (tag) {
case HostComponent:
updateHostComponent(wip);
break;
case FunctionComponent:
updateFunctionComponent(wip);
break;
case ClassComponent:
updateClassComponent(wip);
break;
case Fragment:
updateFragmentComponent(wip);
break;
case HostText:
updateHostTextComponent(wip);
break;
default:
break;
}
if (wip.child) {
wip = wip.child;
return;
}
let next = wip;
while (next) {
if (next.sibling) {
wip = next.sibling;
return;
}
next = next.return;
}
wip = null;
}
函數(shù)組件和 class 組件的 reconcile 和之前講的一樣,就是調(diào)用 render 拿到 vdom,然后繼續(xù)處理渲染出的 vdom:
function updateClassComponent(wip) {
const { type, props } = wip;
const instance = new type(props);
const children = instance.render();
reconcileChildren(wip, children);
}
function updateFunctionComponent(wip) {
renderWithHooks(wip);
const { type, props } = wip;
const children = type(props);
reconcileChildren(wip, children);
}
循環(huán)執(zhí)行 reconcile,那每次處理之前判斷一下是不是有更高優(yōu)先級的任務(wù),就能實(shí)現(xiàn)打斷了。
所以我們在每次處理 fiber 節(jié)點(diǎn)的 reconcile 之前,都先調(diào)用下 shouldYield 方法:
function workLoop() {
while (wip && shouldYield()) {
performUnitOfWork();
}
if (!wip && wipRoot) {
commitRoot();
}
}
shouldYiled 方法就是判斷待處理的任務(wù)隊(duì)列有沒有優(yōu)先級更高的任務(wù),有的話就先處理那邊的 fiber,這邊的先暫停一下。
這就是 fiber 架構(gòu)的 reconcile 可以打斷的原理。通過 fiber 的數(shù)據(jù)結(jié)構(gòu),加上循環(huán)處理前每次判斷下是否打斷來實(shí)現(xiàn)的。
聊完了 render 階段(reconcile + schedule),接下來就進(jìn)入 commit 階段了。
前面說過,為了變?yōu)榭纱驍嗟?,reconcile 階段并不會真正操作 dom,只會創(chuàng)建 dom 然后打個(gè) effectTag 的增刪改標(biāo)記。
commit 階段就根據(jù)標(biāo)記來更新 dom 就可以了。
但是 commit 階段要再遍歷一次 fiber 來查找有 effectTag 的節(jié)點(diǎn),更新 dom 么?
這樣當(dāng)然沒問題,但沒必要。完全可以在 reconcile 的時(shí)候把有 effectTag 的節(jié)點(diǎn)收集到一個(gè)隊(duì)列里,然后 commit 階段直接遍歷這個(gè)隊(duì)列就行了。
這個(gè)隊(duì)列叫做 effectList。
react 會在 commit 階段遍歷 effectList,根據(jù) effectTag 來增刪改 dom。
dom 創(chuàng)建前后就是 useEffect、useLayoutEffect 還有一些函數(shù)組件的生命周期函數(shù)執(zhí)行的時(shí)候。
useEffect 被設(shè)計(jì)成了在 dom 操作前異步調(diào)用,useLayoutEffect 是在 dom 操作后同步調(diào)用。
為什么這樣呢?
因?yàn)槎家僮?dom 了,這時(shí)候如果來了個(gè) effect 同步執(zhí)行,計(jì)算量很大,那不是把 fiber 架構(gòu)帶來的優(yōu)勢有毀了么?
所以 effect 是異步的,不會阻塞渲染。
而 useLayoutEffect,顧名思義是想在這個(gè)階段拿到一些布局信息的,dom 操作完以后就可以了,而且都渲染完了,自然也就可以同步調(diào)用了。
實(shí)際上 react 把 commit 階段也分成了 3 個(gè)小階段。
before mutation、mutation、layout。
mutation 就是遍歷 effectList 來更新 dom 的。
它的之前就是 before mutation,會異步調(diào)度 useEffect 的回調(diào)函數(shù)。
它之后就是 layout 階段了,因?yàn)檫@個(gè)階段已經(jīng)可以拿到布局信息了,會同步調(diào)用 useLayoutEffect 的回調(diào)函數(shù)。而且這個(gè)階段可以拿到新的 dom 節(jié)點(diǎn),還會更新下 ref。
至此,我們對 react 的新架構(gòu),render、commit 兩大階段都干了什么就理清了。
總結(jié)
react 和 vue 都是基于 vdom 的前端框架,之所以用 vdom 是因?yàn)榭梢跃珳?zhǔn)的對比關(guān)心的屬性,而且還可以跨平臺渲染。
但是開發(fā)不會直接寫 vdom,而是通過 jsx 這種接近 html 語法的 DSL,編譯產(chǎn)生 render function,執(zhí)行后產(chǎn)生 vdom。
vdom 的渲染就是根據(jù)不同的類型來用不同的 dom api 來操作 dom。
渲染組件的時(shí)候,如果是函數(shù)組件,就執(zhí)行它拿到 vdom。class 組件就創(chuàng)建實(shí)例然后調(diào)用 render 方法拿到 vdom。vue 的那種 option 對象的話,就調(diào)用 render 方法拿到 vdom。
組件本質(zhì)上就是對一段 vdom 產(chǎn)生邏輯的封裝,函數(shù)、class、option 對象甚至其他形式都可以。
react 和 vue 最大的區(qū)別在狀態(tài)管理方式上,vue 是通過響應(yīng)式,react 是通過 setState 的 api。我覺得這個(gè)是最大的區(qū)別,因?yàn)樗鼘?dǎo)致了后面 react 架構(gòu)的變更。
react 的 setState 的方式,導(dǎo)致它并不知道哪些組件變了,需要渲染整個(gè) vdom 才行。但是這樣計(jì)算量又會比較大,會阻塞渲染,導(dǎo)致動畫卡頓。
所以 react 后來改造成了 fiber 架構(gòu),目標(biāo)是可打斷的計(jì)算。
為了這個(gè)目標(biāo),不能變對比變更新 dom 了,所以把渲染分為了 render 和 commit 兩個(gè)階段,render 階段通過 schedule 調(diào)度來進(jìn)行 reconcile,也就是找到變化的部分,創(chuàng)建 dom,打上增刪改的 tag,等全部計(jì)算完之后,commit 階段一次性更新到 dom。
打斷之后要找到父節(jié)點(diǎn)、兄弟節(jié)點(diǎn),所以 vdom 也被改造成了 fiber 的數(shù)據(jù)結(jié)構(gòu),有了 parent、sibling 的信息。
所以 fiber 既指這種鏈表的數(shù)據(jù)結(jié)構(gòu),又指這個(gè) render、commit 的流程。
reconcile 階段每次處理一個(gè) fiber 節(jié)點(diǎn),處理前會判斷下 shouldYield,如果有更高優(yōu)先級的任務(wù),那就先執(zhí)行別的。
commit 階段不用再次遍歷 fiber 樹,為了優(yōu)化,react 把有 effectTag 的 fiber 都放到了 effectList 隊(duì)列中,遍歷更新即可。
在dom 操作前,會異步調(diào)用 useEffect 的回調(diào)函數(shù),異步是因?yàn)椴荒茏枞秩尽?/p>
在 dom 操作之后,會同步調(diào)用 useLayoutEffect 的回調(diào)函數(shù),并且更新 ref。
所以,commit 階段又分成了 before mutation、mutation、layout 這三個(gè)小階段,就對應(yīng)上面說的那三部分。
我覺得理解了 vdom、jsx、組件本質(zhì)、fiber、render(reconcile + schedule) + commit(before mutation、mutation、layout)的渲染流程,就算是對 react 原理有一個(gè)比較深的理解了。