當咱們由淺入深地認知同樣新事物的時候,每每須要遵循 Why > What > How 這樣一個認知過程。它們是相輔相成、缺一不可的。而瞭解了具體的 What 和 How 以後,每每可以更加具象地回答理論層面的 Why,所以,在進入 Why 的探索以前,咱們先總體感知一下 What 和 How 兩個過程。html
What
打開 React 官網,第一眼便能看到官方給出的回答。前端
React 是用於構建用戶界面的 JavaScript 庫。node
不知道你有沒有想過,構建用戶界面的方式有千百種,爲何 React 會突出?一樣,咱們能夠從 React 哲學裏獲得迴應。react
咱們認爲, React 是用 JavaScript 構建快速響應的大型 Web 應用程序的首選方式。它在 Facebook 和 Instagram 上表現優秀。git
可見,關鍵是實現了 快速響應 ,那麼制約 快速響應 的因素有哪些呢?React 是如何解決的呢?github
How
讓咱們帶着上面的兩個問題,在遵循真實的React代碼架構的前提下,實現一個包含時間切片、fiber
、Hooks
的簡易 React,並捨棄部分優化代碼和非必要的功能,將其命名爲 HuaMu
。數組
注意:爲了和源碼有點區分,函數名首字母大寫,源碼是小寫。瀏覽器
CreateElement
函數
在開始以前,咱們先簡單的瞭解一下JSX
,若是你感興趣,能夠關注下一篇《JSX
背後的故事》。數據結構
JSX
會被工具鏈Babel
編譯爲React.createElement()
,接着React.createElement()
返回一個叫做React.Element
的JS
對象。架構
這麼說有些抽象,經過下面demo
看下轉換先後的代碼:
// JSX 轉換前 const el = <h1 title="el_title">HuaMu<h1>; // 轉換後的 JS 對象 const el = { type:"h1", props:{ title:"el_title", children:"HuaMu", } }
可見,元素是具備 type
和 props
屬性的對象,而 CreateElement
函數的主要任務就是建立該對象。
/** * @param {string} type HTML標籤類型 * @param {object} props 具備JSX屬性中的全部鍵和值 * @param {string | array} children 元素樹 */ function CreateElement(type, props, ...children) { return { type, props:{ ...props, children, } } }
說明:咱們將剩餘參數賦予
children
,擴展運算符用於構造字面量對象props
,對象表達式將按照key-value
的方式展開,從而保證props.children
始終是一個數組。接下來,咱們一塊兒看下demo
:
CreateElement("h1", {title:"el_title"}, 'hello', 'HuaMu') // 返回的 JS 對象 { "type": "h1", "props": { "title": "el_title" // key-value "children": ["hello", "HuaMu"] // 數組類型 } }
注意:當
...children
爲空或爲原始值時,React 不會建立props.children
,但爲了簡化代碼,暫不考慮性能,咱們爲原始值建立特殊的類型TEXT_EL
。
function CreateElement(type, props, ...children) { return { type, props:{ ...props, children: children.map(child => typeof child === "object" ? child : CreateTextElement(child)) } } } function CreateTextElement(text) { return { type: "TEXT_EL", props: { nodeValue: text, children: [] } } }
Render
函數
CreateElement
函數將標籤轉化爲對象輸出,接着 React 進行一系列處理,Render
函數將處理好的節點根據標記進行添加、更新或刪除內容,最後附加到容器中。下面簡單的實現 Render
函數是如何實現添加內容的:
-
首先建立對應的DOM節點,而後將新節點附加到容器中,並遞歸每一個孩子節點作一樣的操做。
-
將元素的
props
屬性分配給節點。function Render(el,container) { // 建立節點 const dom = el.type === 'TEXT_EL'? document.createTextNode("") : document.createElement(el.type); el.props.children.forEach(child => Render(child, dom)) // 爲節點分配 props 屬性 const isProperty = key => key !== 'children'; const setProperty = name => dom[name] = el.props[name]; Object.keys(el.props).filter(isProperty).forEach(setProperty) container.appendChild(dom); }
注意:文本節點使用
textNode
而不是innerText
,是爲了保證以相同的方式對待全部的元素 。
到目前爲止,咱們已經實現了一個簡易的用於構建用戶界面的 JavaScript
庫。如今,讓 Babel
使用自定義的 HuaMu
代替 React,將 /** @jsx HuaMu.CreateElement */
添加到代碼中,打開 codesandbox
看看效果吧。
併發模式
在繼續向下探索以前,咱們先思考一下上面的代碼中,有哪些代碼制約 快速響應 了呢?
是的,在Render
函數中遞歸每一個孩子節點,即這句代碼el.props.children.forEach(child => Render(child, dom))
存在問題。一旦開始渲染,便不會中止,直到渲染了整棵元素樹,咱們知道,GUI
渲染線程與JS
線程是互斥的,JS腳本執行和瀏覽器佈局、繪製不能同時執行。若是元素樹很大,JS腳本執行時間過長,可能會阻塞主線程,致使頁面掉幀,形成卡頓,且妨礙瀏覽器執行高優做業。
那如何解決呢?
經過時間切片的方式,即將任務分解爲多個工做單元,每完成一個工做單元,判斷是否有高優做業,如有,則讓瀏覽器中斷渲染。下面經過requestIdleCallback
模擬實現:
簡單說明一下:
-
window.requestIdleCallback(cb[, options])
:瀏覽器將在主線程空閒時運行回調。函數會接收到一個IdleDeadline
的參數,這個參數能夠獲取當前空閒時間(timeRemaining
)以及回調是否在超時前已經執行的狀態(didTimeout
)。 -
React 已再也不使用
requestIdleCallback
,目前使用 scheduler package。但在概念上是相同的。
依據上面的分析,代碼結構以下:
// 當瀏覽器準備就緒時,它將調用 WorkLoop requestIdleCallback(WorkLoop) let nextUnitOfWork = null; function PerformUnitOfWork(nextUnitOfWork) { // TODO } function WorkLoop(deadline) { // 當前線程的閒置時間是否能夠在結束前執行更多的任務 let shouldYield = false; while(nextUnitOfWork && !shouldYield) { nextUnitOfWork = PerformUnitOfWork(nextUnitOfWork) // 賦值下一個工做單元 shouldYield = deadline.timeRemaining() < 1; // 若是 idle period 已經結束,則它的值是 0 } requestIdleCallback(WorkLoop) }
咱們在 PerformUnitOfWork
函數裏實現當前工做的執行並返回下一個執行的工做單元,可下一個工做單元如何快速查找呢?讓咱們初步瞭解 Fibers
吧。
Fibers
爲了組織工做單元,即方便查找下一個工做單元,需引入fiber tree
的數據結構。即每一個元素都有一個fiber
,連接到其第一個子節點,下一個兄弟姐妹節點和父節點,且每一個fiber
都將成爲一個工做單元。
// 假設咱們要渲染的元素樹以下 const el = ( <div> <h1> <p /> <a /> </h1> <h2 /> </div> )
其對應的 fiber tree
以下:
若將上圖轉化到咱們的代碼裏,咱們第一件事得找到root fiber
,即在Render
中,設置nextUnitOfWork
初始值爲root fiber
,並將建立節點部分獨立出來。
function Render(el,container) { // 設置 nextUnitOfWork 初始值爲 root fiber nextUnitOfWork = { dom: container, props:{ children:[el], } } } // 將建立節點部分獨立出來 function CreateDom(fiber) { const dom = fiber.type === 'TEXT_EL'? document.createTextNode("") : document.createElement(fiber.type); // 爲節點分配props屬性 const isProperty = key => key !== 'children'; const setProperty = name => dom[name] = fiber.props[name]; Object.keys(fiber.props).filter(isProperty).forEach(setProperty) return dom }
剩餘的 fiber
將在 performUnitOfWork
函數上執行如下三件事:
-
爲元素建立節點並添加到
dom
-
爲元素的子代建立
fiber
-
選擇下一個執行工做單元
function PerformUnitOfWork(fiber) { // 爲元素建立節點並添加到 dom if(!fiber.dom) { fiber.dom = CreateDom(fiber) } // 若元素存在父節點,則掛載 if(fiber.parent) { fiber.parent.dom.appendChild(fiber.dom) } // 爲元素的子代建立 fiber const els = fiber.props.children; let index = 0; // 做爲一個容器,存儲兄弟節點 let prevSibling = null; while(index < els.length) { const el = els[index]; const newFiber = { type: el.type, props: el.props, parent: fiber, dom: null } // 子代在fiber樹中的位置是child仍是sibling,取決於它是否第一個 if(index === 0){ fiber.child = newFiber; } else { prevSibling.sibling = newFiber; } prevSibling = newFiber; index++; } // 選擇下一個執行工做單元,優先級是 child -> sibling -> parent if(fiber.child){ return fiber.child; } let nextFiber = fiber; while(nextFiber) { if(nextFiber.sibling) { return nextFiber.sibling; } nextFiber = nextFiber.parent; } }
Render
和 Commit
階段
在上面的代碼中,咱們加入了時間切片,但它還存在一些問題,下面咱們來看看:
-
在
performUnitOfWork
函數裏,每次爲元素建立節點以後,都向dom
添加一個新節點,即if(fiber.parent) { fiber.parent.dom.appendChild(fiber.dom) }
-
咱們都知道,主流瀏覽器刷新頻率爲60Hz,即每(1000ms / 60Hz)16.6ms瀏覽器刷新一次。當JS執行時間過長,超出了16.6ms,此次刷新就沒有時間執行樣式佈局和樣式繪製了。也就是在渲染完整棵樹以前,瀏覽器可能會中斷,致使用戶看不到完整的UI。
那該如何解決呢?
-
首先將建立一個節點就向
dom
進行添加處理的方式更改成跟蹤fiber root
,也被稱爲progress root
或者wipRoot
-
一旦完成全部的工做,即沒有下一個工做單元時,纔將
fiber
提交給dom
// 跟蹤根節點 let wipRoot = null; function Render(el,container) { wipRoot = { dom: container, props:{ children:[el], } } nextUnitOfWork = wipRoot; } // 一旦完成全部的工做,將整個fiber提交給dom function WorkLoop(deadline) { ... if(!nextUnitOfWork && wipRoot) { CommitRoot() } requestIdleCallback(WorkLoop) } // 將完整的fiber提交給dom function CommitRoot() { CommitWork(wipRoot.child) wipRoot = null } // 遞歸將每一個節點添加進去 function CommitWork(fiber) { if(!fiber) return; const parentDom = fiber.parent.dom; parentDom.appendChild(fiber.dom); CommitWork(fiber.child); CommitWork(fiber.sibling); }
Reconciliation
到目前爲止,咱們優化了上面自定義的HuaMu
庫,但上面只實現了添加內容,如今,咱們把更新和刪除內容也加上。而要實現更新、刪除功能,須要將render
函數中收到的元素與提交給dom
的最後的fiber tree
進行比較。所以,須要保存最後一次提交給fiber tree
的引用currentRoot
。同時,爲每一個fiber
添加alternate
屬性,記錄上一階段提交的old fiber
let currentRoot = null; function Render(el,container) { wipRoot = { ... alternate: currentRoot } ... } function CommitRoot() { ... currentRoot = wipRoot; wipRoot = null }
-
爲元素的子代建立
fiber
的同時,將old fiber
與new fiber
進行reconcile
-
經過如下三個維度進行比較
-
若是
old fiber
與new fiber
具備相同的type
,保留dom
節點並更新其props
,並設置標籤effectTag
爲UPDATE
-
type
不一樣,且爲new fiber
,意味着要建立新的dom
節點,設置標籤effectTag
爲PLACEMENT
;若爲old fiber
,則須要刪除節點,設置標籤effectTag
爲DELETION
注意:爲了更好的
Reconciliation
,React 還使用了key
,好比更快速的檢測到子元素什麼時候更改了在元素數組中的位置,這裏爲了簡潔,暫不考慮。
let deletions = null; function PerformUnitOfWork(fiber) { ... const els = fiber.props.children; // 提取 爲元素的子代建立fiber 的代碼 ReconcileChildren(fiber, els); } function ReconcileChildren(wipFiber, els) { let index = 0; let oldFiber = wipFiber.alternate && wipFiber.alternate.child; let prevSibling = null; // 爲元素的子代建立fiber 的同時 遍歷舊的fiber的子級 // undefined != null; // false // undefined !== null; // true while(index < els.length || oldFiber != null) { const el = els[index]; const sameType = oldFiber && el && el.type === oldFiber.type; let newFiber = null; // 更新節點 if(sameType) { newFiber = { type: el.type, props: el.props, parent: wipFiber, dom: oldFiber.dom, // 使用 oldFiber alternate: oldFiber, effectTag: "UPDATE", } } // 新增節點 if(!sameType && el){ newFiber = { type: el.type, props: el.props, parent: wipFiber, dom: null, // dom 設置爲null alternate: null, effectTag: "PLACEMENT", } } // 刪除節點 if(!sameType && oldFiber) { // 刪除節點沒有新的fiber,所以將標籤設置在舊的fiber上,並加入刪除隊列 [commit階段提交時,執行deletions隊列,render階段執行完清空deletions隊列] oldFiber.effectTag = "DELETION"; deletions.push(oldFiber) } if(oldFiber) { oldFiber = oldFiber.sibling; } if(index === 0) { wipFiber.child = newFiber; } else if(el) { prevSibling.sibling = newFiber; } prevSibling = newFiber; index++; } }
-
-
在
CommitWork
函數裏,根據effectTags
進行節點處理- PLACEMENT - 跟以前同樣,將dom節點添加進父節點
- DELETION - 刪除節點
- UPDATE - 更新dom節點的props
function CommitWork(fiber) { if (!fiber) return; const parentDom = fiber.parent.dom; if (fiber.effectTags === 'PLACEMENT' && fiber.dom !== null){ parentDom.appendChild(fiber.dom); } else if (fiber.effectTags === 'DELETION') { parentDom.removeChild(fiber.dom) } else if(fiber.effectTags === 'UPDATE' && fiber.dom !== null) { UpdateDom( fiber.dom, fiber.alternate.props, fiber.props ) } CommitWork(fiber.child); CommitWork(fiber.sibling); }
重點分析一下UpdateDom
函數:
-
普通屬性
- 刪除舊的屬性
- 設置新的或更改的屬性
-
特殊處理以
on
爲前綴的事件屬性- 刪除舊的或更改的事件屬性
- 添加新的事件屬性
const isEvent = key => key.startsWith("on"); const isProperty = key => key !== 'children' && !isEvent(key); const isNew = (prev, next) => key => prev[key] !== next[key]; const isGone = (prev, next) => key => !(key in next); /** * 更新dom節點的props * @param {object} dom * @param {object} prevProps 以前的屬性 * @param {object} nextProps 當前的屬性 */ function UpdateDom(dom, prevProps, nextProps) { // 刪除舊的屬性 Object.keys(prevProps) .filter(isProperty) .filter(isGone(prevProps, nextProps)) .forEach(name => { dom[name] = "" }) // 設置新的或更改的屬性 Object.keys(nextProps) .filter(isProperty) .filter(isNew(prevProps, nextProps)) .forEach(name => { dom[name] = nextProps[name] }) // 刪除舊的或更改的事件屬性 Object.keys(prevProps) .filter(isEvent) .filter(key => (!(key in nextProps) || isNew(prevProps, nextProps)(key))) .forEach(name => { const eventType = name.toLowerCase().substring(2) dom.removeEventListener( eventType, prevProps[name] ) }) // 添加新的事件屬性 Object.keys(nextProps) .filter(isEvent) .filter(isNew(prevProps, nextProps)) .forEach(name => { const eventType = name.toLowerCase().substring(2) dom.addEventListener( eventType, nextProps[name] ) }) }
如今,咱們已經實現了一個包含時間切片、fiber
的簡易 React。打開 codesandbox
看看效果吧。
Function Components
組件化對於前端的同窗應該不陌生,而實現組件化的基礎就是函數組件,相對與上面的標籤類型,函數組件有哪些不同呢?讓咱們來啾啾
function App(props) { return <h1>Hi {props.name}</h1> } const element = <App name="foo" />
若由上面實現的Huamu
庫進行轉換,應該等價於:
function App(props) { return Huamu.CreateElement("h1",null,"Hi ",props.name) } const element = Huamu.CreateElement(App, {name:"foo"})
由此,可見Function Components
的fiber
是沒有dom
節點的,並且其children
是來自於函數的運行而不是props
。基於這兩個不一樣點,咱們將其劃分爲UpdateFunctionComponent
和 UpdateHostComponent
進行處理
function PerformUnitOfWork(fiber) { const isFunctionComponent = fiber.type instanceof Function; if(isFunctionComponent) { UpdateFunctionComponent(fiber) } else { UpdateHostComponent(fiber) } // 選擇下一個執行工做單元,優先級是 child -> sibling -> parent ... } function UpdateFunctionComponent(fiber) { // TODO } function UpdateHostComponent(fiber) { if (!fiber.dom) = fiber.dom = CreateDom(fiber); const els = fiber.props.children; ReconcileChildren(fiber, els); }
-
children
來自於函數的運行而不是props
,即運行函數獲取children
function UpdateFunctionComponent(fiber) { const children = [fiber.type(fiber.props)]; ReconcileChildren(fiber,children); }
-
沒有
dom
節點的fiber
- 在添加節點時,得沿着
fiber
樹向上移動,直到找到帶有dom
節點的父級fiber
- 在刪除節點時,得繼續向下移動,直到找到帶有
dom
節點的子級fiber
function CommitWork(fiber) { if (!fiber) return; // 優化:const domParent = fiber.parent.dom; let domParentFiber = fiber.parent; while(!domParentFiber.dom) { domParentFiber = domParentFiber.parent; } const domParent = domParentFiber.dom; if (fiber.effectTags === 'PLACEMENT' && fiber.dom!=null){ domParent.appendChild(fiber.dom); } else if (fiber.effectTags === 'DELETION') { // 優化: domParent.removeChild(fiber.dom) CommitDeletion(fiber, domParent) } else if(fiber.effectTags === 'UPDATE' && fiber.dom!=null) { UpdateDom( fiber.dom, fiber.alternate.props, fiber.props ) } CommitWork(fiber.child); CommitWork(fiber.sibling); } function CommitDeletion(fiber,domParent){ if(fiber.dom){ domParent.removeChild(fiber.dom) } else { CommitDeletion(fiber.child, domParent) } }
- 在添加節點時,得沿着
最後,咱們爲Function Components
添加狀態。
Hooks
向fiber
添加一個hooks
數組,以支持useState
在同一組件中屢次調用,且跟蹤當前的hooks
索引。
let wipFiber = null let hookIndex = null function UpdateFunctionComponent(fiber) { wipFiber = fiber; hookIndex = 0 wipFiber.hooks = [] const children = [fiber.type(fiber.props)] ReconcileChildren(fiber, children) }
-
當
Function Components
組件調用UseState
時,經過alternate
屬性檢測fiber
是否有old hook
。 -
如有
old hook
,將狀態從old hook
複製到new hook
,不然,初始化狀態。 -
將
new hook
添加fiber
,hook index
遞增,返回狀態。function UseState(initial) { const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex] const hook = { state: oldHook ? oldHook.state : initial, } wipFiber.hooks.push(hook) hookIndex++ return [hook.state] }
-
UseState
還需返回一個可更新狀態的函數,所以,須要定義一個接收action
的setState
函數。 -
將
action
添加到隊列中,再將隊列添加到fiber
。 -
在下一次渲染時,獲取
old hook
的action
隊列,並代入new state
逐一執行,以保證返回的狀態是已更新的。 -
在
setState
函數中,執行跟Render
函數相似的操做,將currentRoot
設置爲下一個工做單元,以便開始新的渲染。function UseState(initial) { ... const hook = { state: oldHook ? oldHook.state : initial, queue: [], } const actions = oldHook ? oldHook.queue : [] actions.forEach(action => { hook.state = action(hook.state) }) const setState = action => { hook.queue.push(action) wipRoot = { dom: currentRoot.dom, props: currentRoot.props, alternate: currentRoot, } nextUnitOfWork = wipRoot deletions = [] } wipFiber.hooks.push(hook) hookIndex++ return [hook.state, setState] }
如今,咱們已經實現一個包含時間切片、fiber
、Hooks
的簡易 React。打開codesandbox
看看效果吧。
結語
到目前爲止,咱們從 What > How 梳理了大概的 React 知識鏈路,後面的章節咱們對文中所說起的知識點進行 Why 的探索,相信會反哺到 What 的理解和 How 的實踐。
本文原創發佈於塗鴉智能技術博客
https://tech.tuya.com/react-zheng-ti-gan-zhi/
轉載請註明出處