當咱們由淺入深地認知同樣新事物的時候,每每須要遵循 Why > What > How 這樣一個認知過程。它們是相輔相成、缺一不可的。而瞭解了具體的 What 和 How 以後,每每可以更加具象地回答理論層面的 Why,所以,在進入 Why 的探索以前,咱們先總體感知一下 What 和 How 兩個過程。html
打開 React 官網,第一眼便能看到官方給出的回答。前端
React 是用於構建用戶界面的 JavaScript 庫。node
不知道你有沒有想過,構建用戶界面的方式有千百種,爲何 React 會突出?一樣,咱們能夠從 React 哲學裏獲得迴應。react
咱們認爲, React 是用 JavaScript 構建快速響應的大型 Web 應用程序的首選方式。它在 Facebook 和 Instagram 上表現優秀。git
可見,關鍵是實現了 快速響應 ,那麼制約 快速響應 的因素有哪些呢?React 是如何解決的呢?github
讓咱們帶着上面的兩個問題,在遵循真實的React代碼架構的前提下,實現一個包含時間切片、fiber
、Hooks
的簡易 React,並捨棄部分優化代碼和非必要的功能,將其命名爲 HuaMu
。數組
注意:爲了和源碼有點區分,函數名首字母大寫,源碼是小寫。瀏覽器
CreateElement
函數在開始以前,咱們先簡單的瞭解一下JSX
,若是你感興趣,能夠關注下一篇《JSX
背後的故事》。markdown
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
進行節點處理
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 的實踐。
本文原創發佈於塗鴉智能技術博客
轉載請註明出處