React 總體感知

當咱們由淺入深地認知同樣新事物的時候,每每須要遵循 Why > What > How 這樣一個認知過程。它們是相輔相成、缺一不可的。而瞭解了具體的 WhatHow 以後,每每可以更加具象地回答理論層面的 Why,所以,在進入 Why 的探索以前,咱們先總體感知一下 WhatHow 兩個過程。html

What

打開 React 官網,第一眼便能看到官方給出的回答。前端

React 是用於構建用戶界面的 JavaScript 庫。node

不知道你有沒有想過,構建用戶界面的方式有千百種,爲何 React 會突出?一樣,咱們能夠從 React 哲學裏獲得迴應。react

咱們認爲, React 是用 JavaScript 構建快速響應的大型 Web 應用程序的首選方式。它在 Facebook 和 Instagram 上表現優秀。git

可見,關鍵是實現了 快速響應 ,那麼制約 快速響應 的因素有哪些呢?React 是如何解決的呢?github

How

讓咱們帶着上面的兩個問題,在遵循真實的React代碼架構的前提下,實現一個包含時間切片、fiberHooks的簡易 React,並捨棄部分優化代碼和非必要的功能,將其命名爲 HuaMu數組

注意:爲了和源碼有點區分,函數名首字母大寫,源碼是小寫。瀏覽器

CreateElement 函數

在開始以前,咱們先簡單的瞭解一下JSX,若是你感興趣,能夠關注下一篇《JSX背後的故事》。數據結構

JSX會被工具鏈Babel編譯爲React.createElement(),接着React.createElement()返回一個叫做React.ElementJS對象。架構

這麼說有些抽象,經過下面demo看下轉換先後的代碼:

// JSX 轉換前
const el = <h1 title="el_title">HuaMu<h1>;

// 轉換後的 JS 對象
const el = {
  type:"h1",
  props:{
    title:"el_title",
    children:"HuaMu",
  }
}

可見,元素是具備 typeprops 屬性的對象,而 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 以下:

Fibers

若將上圖轉化到咱們的代碼裏,咱們第一件事得找到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;
    	}
    	}

RenderCommit 階段

在上面的代碼中,咱們加入了時間切片,但它還存在一些問題,下面咱們來看看:

  • 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 fibernew fiber進行reconcile

  • 經過如下三個維度進行比較

    1. 若是old fibernew fiber具備相同的type,保留dom節點並更新其props,並設置標籤effectTagUPDATE

    2. type不一樣,且爲new fiber,意味着要建立新的dom節點,設置標籤effectTagPLACEMENT;若爲old fiber,則須要刪除節點,設置標籤effectTagDELETION

      注意:爲了更好的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進行節點處理

    1. PLACEMENT - 跟以前同樣,將dom節點添加進父節點
    2. DELETION - 刪除節點
    3. 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函數:

  • 普通屬性

    1. 刪除舊的屬性
    2. 設置新的或更改的屬性
  • 特殊處理以 on爲前綴的事件屬性

    1. 刪除舊的或更改的事件屬性
    2. 添加新的事件屬性
    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 Componentsfiber是沒有dom節點的,並且其children是來自於函數的運行而不是props。基於這兩個不一樣點,咱們將其劃分爲UpdateFunctionComponentUpdateHostComponent 進行處理

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

    1. 在添加節點時,得沿着fiber樹向上移動,直到找到帶有dom節點的父級fiber
    2. 在刪除節點時,得繼續向下移動,直到找到帶有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添加fiberhook 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還需返回一個可更新狀態的函數,所以,須要定義一個接收actionsetState函數。

  • action添加到隊列中,再將隊列添加到fiber

  • 在下一次渲染時,獲取old hookaction隊列,並代入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]
    	}

如今,咱們已經實現一個包含時間切片、fiberHooks 的簡易 React。打開codesandbox看看效果吧

結語

到目前爲止,咱們從 What > How 梳理了大概的 React 知識鏈路,後面的章節咱們對文中所說起的知識點進行 Why 的探索,相信會反哺到 What 的理解和 How 的實踐。

本文原創發佈於塗鴉智能技術博客

https://tech.tuya.com/react-zheng-ti-gan-zhi/

轉載請註明出處

相關文章
相關標籤/搜索