感謝你們又被個人標題黨騙進來了😂。這是我最近幾個月親身探尋過的一趟旅途,感覺頗深。途中也遇到許多困難,但堅持到底,相信最終必定會讓各位小夥伴受益不淺,不枉此行!前端
同時也感恩你們對以前一個系列文章的喜歡和修正😘,提了很多的建議和問題,我也會用心寫個人每一系列文章,與你們共同成長!!vue
跪求點贊、關注、Star!更多文章猛戳 ->node
以前面試三部曲簡明地梳理了前端知識結構體系,淺嘗輒止。這個系列則要進一步研究和領會 內在的奧妙。今天打算以一個比較新穎的角度切入,深刻地梳理下 React
的內部實現。react
1. 有利於你們在 React 平常業務使用中更加駕輕就熟;git
2. 也可將領會到的思想融會貫通,拓展到其它領域;github
那如何深刻研究一個玩具呢?最好的方式即是: 拆解 - 重組裝。web
衆所周知,React
是一款很是神奇的 Web UI
框架,得益於強大的架構,邏輯層 與 視圖層 的解耦,使其思想及開發模式能夠很好地移植到其它平臺,例如 React-Native
。因此今天,我打算跳出常規的 Web-DOM
,目標是 以類 React 的模式來進行 Web 遊戲開發。其實就是咱們須要實現一套 React
上層,並把底層對接 WebGL API
。期待這樣不一樣的視角,能給你們帶來新的啓發與幫助。😄面試
Tips:算法
WebGL
不是本文關注的重點,使用到的 API 也很是有限,並不須要你具有相關的知識儲備。編程
做爲一名前端,咱們須要實現一個個展現給用戶的頁面。所以視圖層即是咱們的工做之始。前端領域不斷地快速發展,最終都是爲了解答 如何更高效地開發更完美的頁面。而 React
就是答案之一,其中 JSX
即是重要的第一站。
那什麼是 JSX
呢?
JSX
就是在 JS
環境中約定的一種類 HTML
或 XML
的 動態模板語法,有着極高的 可讀性 與 可拓展性,目的是 爲了能更便捷地使用 JS 搭建視圖結構與佈局。
// 這就是 JSX
const jsx = <JSX>Hello World</JSX>
複製代碼
這是一種新全新的 JS
語法,不屬於標準,成功地把類 HTML
的標籤型模板語法引入 JS 中,創造了一種全新高效的開發模式。但即便最新版的 V8 引擎也沒法支持,那怎麼執行呢?鑰匙就是 預編譯!得益於 Babel
的強大,咱們能夠經過 預編譯,將代碼編譯成瀏覽器看得懂的 JS
。
這是 Babel
的一款插件,主要的功能就是編譯 JSX
,直接配置便可 (對編譯感興趣的童鞋,能夠繼續深刻下了解下 Babel
的編譯原理,這裏就不做展開了)。
// 打包工具中的 babel loader 配置
use: {
loader: 'babel-loader',
options: {
plugins: [["transform-jsx", {
"function": "ReactWebGL.createElement",
"useVariables": true
}]],
}
}
複製代碼
配置完畢,咱們先來寫段 JSX
試試。因爲咱們不使用 DOM
了,視圖層被直接繪製於 canvas
上,就天然不能使用常規的 HTML
標籤了。咱們先來定個容器標籤 (<Container>
)。
const jsx = (
<Container name="parent"> ReactWebGL Hello World <Container name="child"> Child </Container> </Container>
)
複製代碼
咦。秒報錯。先無論,咱們來看下編譯後的文件:
var jsx = ReactGL.createElement({
elementName: Container,
attributes: {
name: 'parent'
},
children: [
"ReactWebGL Hello World",
ReactWebGL.createElement({
elementName: Container,
attributes: {
name: "child"
},
children: ["Child"]
}),
]
});
複製代碼
原來如此,其實 Babel
作的,就是把上面的 JSX
模板代碼, 解析並提取出標籤的信息後,轉換成常規的函數形式。這個函數就是咱們配置中指定的 ReactWebGL.createElement
。
接下來咱們來看下報錯。很明顯,是由於在上下文中有一些變量沒有進行定義,那接下來咱們先定義上:
// 因爲編譯是直接 變量傳遞
// 所以標籤名做爲一個變量須要先定義爲 string;
const Container = 'Container'
// 匹配配置中的 `ReactWebGL.createElement`
const ReactWebGL = {
createElement(tag) {
console.log(tag)
}
}
複製代碼
來到第二站: 什麼是 虛擬DDM 呢?
顧名思義,它並非真正的視圖元素,而是將真實的視圖元素抽象爲一個 Javascript 對象,包含完整描述了一個真實元素的全部信息,但並不具有渲染功能。同時因爲 DOM
自己即是 樹形結構,所以使用 Javascript
對象便能很好對整個頁面結構進行描述。
剛纔 JSX
編譯後,參數即是一個最簡單的 虛擬DOM 對象(咱們稱爲 VNode
):
// 一個最簡單的 VNode
{
// 類型
type: 'Container'
// 屬性
props: {
name: 'parent'
}
// 子級列表
children: [...]
}
複製代碼
這其實只是一個普通的 Javascript
對象,那爲何要設計它呢?可能不少人都會有這種觀念: 虛擬DOM 快啊,diff 算法很是厲害,直接操做真實的 DOM 消耗很大。
掐指一算,事情並無那麼簡單,聽我細細道來🧐。想象下,當出現如下場景時:
初次渲染:
解析 JSX
,生成 虛擬DOM 樹,而後通過各類計算,最終調用 DOM
繪製一個個視圖元素;
很明顯,咱們經過 HTML
或 innerHTML
直接建立元素會更快,且白屏時間更短,多了上層的 計算消耗與內存消耗,反而是一種 性能損耗;
極小更新:
須要修改一個標題文案,調用 setState
觸發更新。此時,React
並不知道新的 state
會引發多大的變更,須要通過全局逐一的 diff
肯定變更的元素再觸發更新。
相較而言,獲取對應標題元素修改,省去 diff
,會更直接高效。那這麼說來,虛擬DOM 反而變慢了,那爲何還要用它呢?
若是你的場景是 大量且分散 地更新頁面中的元素,那 虛擬DOM 能大大減小其業務邏輯的複雜度,達到一個比較高的消耗性價比。直接操做 DOM
元素,一來邏輯複雜,代碼健壯性弱;二來錯誤的操做反而可能致使更差的性能。
因此快慢並非衡量 虛擬DOM 價值的因素,其價值更多在於:
虛擬DOM 實際上是一種 犧牲最小性能與空間,換取 架構優化 的方式,能較大提高項目的可拓展性與可維護性;
對 DOM
的操做進行 集中化管理,更加安全穩定且高效;
渲染 與 邏輯 的解耦,高度的 組件化 與 模塊化,提高了代碼複用性及開發效率;
虛擬DOM 是一種抽象化的對象,能夠對接不一樣的渲染層,完成 跨平臺渲染。例如 RN / SSR
等方案;
JSX
僅僅是一種 獨立的動態模板語法,經過編譯轉化爲 虛擬DOM,跟 React
並沒有耦合。所以能夠將其 接入到任意環境或者框架,例如咱們也能夠在 Vue
中使用 JSX
。
聊完 虛擬DOM,咱們迴歸主線。須要先來設計一個最簡單的 VNode
結構,以剛纔 Babel
編譯出的結構爲基礎,加上額外的值,方便渲染。
// VNode 定義
interface VNode {
// 標籤類型
type: any,
// 標籤屬性
props: { [key: string]: any },
// 子級列表
children: VNode[],
// 惟一標識
key: string
// 獲取視圖元素
ref: any
// 視圖元素
elm: any
// 文本內容
text: string | number | undefined
}
// VNode 生產函數
function createVNode(type, props, ref, key, children, elm, text) {
return {
type,
props,
children,
ref,
key,
elm,
text,
}
}
複製代碼
如今定義了 VNode
後,咱們就能夠開始編寫對應的生成函數(createElement
)了。
這個函數就是傳說中的 h
函數,用於 模板 到 虛擬DOM 之間的橋樑。在如今的大多數主流 虛擬DOM 庫中,都擁有該函數。在 React
中,它是經過 Babel
將 JSX
編譯成 h
函數,即 React.createElement
。而在 Vue
中,則是經過 vue-loader
將 <template>
編譯成其 h
函數。該函數主要功能是:
加工生成完整的 虛擬DOM樹,用於後面的渲染。
function createElement(tag) {
const { elementName: type, attributes: data, children } = tag
const { key, ref, ...props } = data
// 處理文本內容
let text
// 處理子級列表中的 string or number
// 一樣轉換爲 VNode
if (children && children.length) {
let i, l = children.length
for (i = 0; i < l; ++i) {
const child = children[i]
if (['string', 'number'].includes(typeof child)) {
if (type === 'Text') {
// 基於 WebGL 的須要,區別於 DOM
// 這裏新增一個 <Text> 標籤,vnode.type === 'Text'
// 需特殊處理
if (text === undefined) text = ''
text += String(child)
} else {
// 非標籤的文字節點, vnode.type === undefined
// 例如 <Container>Text</Container>
// 中間的 Text 實際上是一樣須要一個文字元素
children[i] = vnode(
undefined,
{},
undefined,
undefined,
undefined,
undefined,
String(children[i])
)
}
}
}
}
return vnode(type, props, ref, key, children, undefined, text)
}
複製代碼
執行,Perfect!打印下 JSX
,能夠看到一棵轉換後的 VNode Tree
,而且包含咱們模板標籤中的全部完整信息。
圖1. VNode Tree
爲了更好地 解耦視圖層,咱們把須要用到的一些與 WebGL
相關的 API
簡單包裹下:
const Api = {
// 根據 標籤類型 建立 視圖元素
createElement(vnode) {
return new PIXI[vnode.type]()
},
// 建立、設置 文本元素
createTextElement(vnode) {
const { text: content = '', style } = vnode
return new PIXI.Text(content, style)
},
setTextContent(elm, content) {
if (elm && ['string', 'number'].includes(typeof content)) {
elm.text = content
}
},
// 獲取父級
parentNode(elm) {
return elm && elm.parent
},
// 添加、刪除子級
appendChild(parent, child) {
parent.addChild(child)
},
removeChild(parent, child) {
if (child && child.parent) {
parent.removeChild(child)
}
},
// 獲取下一個兄弟元素
nextSibling(elm) {
const parent = Api.parentNode(elm)
if (parent) {
const index = parent.children.indexof(elm)
return parent.children[index + 1]
} else {
return undefined
}
},
// 插入到指定元素以前
insertBefore(parentElm, newElm, referenceElm) {
if (referenceElm) {
const refIndex = parentElm.children.indexOf(referenceElm)
parentElm.addChildAt(newElm, refIndex)
} else {
Api.appendChild(parentElm, newElm)
}
},
}
複製代碼
這一部分能夠稱爲 對接層,能夠對接到各個平臺,若是使用原生 DOM
的 API
,則就是 Web
渲染,有點相似於 react-dom
所完成的事。
爲了演示方便,咱們就使用 pixi.js
來做爲 渲染接口。這裏與 WebGL
庫無關,能夠對接到 任意渲染框架。
有了 接口層 API 與 VNode
後,能夠開始 建立真實視圖元素,並同時根據 vnode.props
同步屬性並綁定事件。最後 遞歸建立子級:
// 根據 vnode 建立 視圖元素
function createElm(vnode) {
const { children, type, text, props } = vnode
// vnode 的 type 爲字符串時,表示其爲 元素節點
// 依照 type 建立 視圖元素
if (type && typeof type === 'string') {
// 調用接口
vnode.elm = Api.createElement(vnode)
// 遞歸建立子級元素,並添加到父元素中
if (Array.isArray(children)) {
// 建立子級
let i, l = children.length
for (i = 0; i < l; ++i) {
Api.appendChild(vnode.elm, createElm(children[i]))
}
}
} else if (type === undefined && text) {
// 被非 <Text> 包裹的文字節點
vnode.elm = Api.createTextElement(vnode)
}
// 元素建立成功時,執行設置屬性
if (vnode.elm) setProps(vnode.elm, props)
return vnode.elm
}
// 屬性處理
// 將 虛擬DOM 上的屬性同步設置到 上面建立的真實元素 elm 上
function setProps(elm, props) {
if (elm && typeof props === 'object') {
const keys = Object.keys(props)
let l = keys.length, i
for (i = 0; i < l; i++) {
const key = keys[i]
const value = props[key]
if (key.startsWith('on')) {
// 事件綁定
if (typeof value === 'function') {
const evName = key.substring(2).toLowerCase()
elm.on(evName, value)
}
} else {
// 屬性設置
elm[key] = value
}
}
}
}
複製代碼
最後,咱們能夠開始渲染 VNode
:
function render(vnode, parent) {
// 根據 vnode 建立出對應的元素
const elm = createElm(vnode)
// 並添加到容器中便可
Api.appendChild(parent, elm)
return elm
}
複製代碼
大功告成,到這裏咱們已經成功完成把 JSX
的初次渲染了。那下一步的需求就是: 如何更新視圖呢? 這裏就是傳說中的 Diff
算法的用武之地了!
說到 虛擬DOM 就立刻能提到其核心的 diff
算法。當咱們經過 setState
去更新組件時,是從新生成一棵全新的完整 虛擬DOM樹,此時就須要對比 新舊兩棵樹的差別點,再針對性更新。也就是一種 計算得出兩個對象差別 的算法。
這種 diff
算法其實使用的場景不少,例如咱們很熟悉的代碼版本控制。先後兩份提交的代碼須要比對出差別點,而後進行更新保存。只不過這裏的 代碼文件 變成了 虛擬DOM。因爲 虛擬DOM 至關複雜,包含很是多的屬性,而且可能擁有很是深的層級,所以若是用常規的循環遞歸去比較,時間複雜度爲 O(n^3)
,這性能是沒法接受的。
所以天才工程師們基於 Web 視圖渲染的一些特徵,在比對上經過 制定規則,選擇性取捨,大大優化了算法的效率。包含如下三種優化策略:
因爲在大部分 Web 視圖渲染中,咱們不多會去跨層級移動元素,移動元素一般出如今同層級的列表中,所以這裏能夠有一個優化策略:
只作同層級的比對,忽略跨層級的元素移動。
傳統的 diff
算法須要兩層循環,每兩個節點之間都須要進行對比。而制定了同層比對後,節點只須要跟同一層級的節點進行比對,以下圖所示。
圖2. diff 比對策略
此時,性能已經大大的提高了,時間複雜度優化成 O(n^2)
。另外,若是真出現跨層級移動時,會直接將舊元素刪除,在新的位置從新建立,也能保證更新的準確行。但可能會致使狀態的丟失。
雖然咱們作了同層比對的優化,但此時有一個問題:
例如圖2,當 C1
/ C2
交換位置,咱們在循環比對時,因爲它們均屬於相同類型的節點,單單經過 type 並沒有法正確區分,沒法識別出位置的移動。只能作到把 C1
修改爲 C2
,把 C2
修改爲 C1
。這樣不只會 損耗性能,並且可能致使 狀態丟失。
最優的方式應該就是: 把 C1
與 C2
正確交換位置。關鍵點就在於: 如何正確識別與區分節點。所以這裏便引入了 key
做爲惟一標識,用 type
+ key
即可確切地識別出節點的準確位置,從而將時間複雜度優化成了 O(n)
。
在複雜度方面,已經有了有效的優化。接下來,即是從邏輯層進行優化。
首先一個最大的損耗就是: 沒法準肯定位目標節點,須要 樹遍歷 尋找更新的目標節點。
如圖2,咱們只要更新 D
節點,卻必須從最根級的 A
節點 逐層 diff
,完整比對新舊兩棵 虛擬樹,這裏有明顯的無謂損耗。若是將 D
節點 抽離成一個獨立的模塊,則能夠只調用 D
節點 自身的 diff
。 所以便引入了 組件模式,可以 碎片化 虛擬DOM。
若是咱們確實須要同時更新 A
/ D
節點呢?其實左邊分支 B1
節點並不須要更新,不須要 diff
。若是能讓 B1
節點擁有一個標識標識本身爲非更新目標,在更新流中能夠 主動打斷更新流。那就能夠只 diff
A
-> B2
-> D
這條線了。這裏,即是咱們熟知的 shouldComponentUpdate
。
首先咱們先來梳理下兩個 VNode diff
可能出現的狀況:
圖3. diff 流程圖
根據這個流程圖,咱們能夠先從入口開始實現。
function diff(oldVNode, newVNode) {
if (isSameVNode(oldVNode, newVNode)) {
// 開始 diff
// diffVNode
...
} else {
// 新節點替換舊節點
// replaceVNode
...
}
}
// 根據 type || key 判斷是否爲同類型節點
function isSameVNode(oldVNode, newVNode) {
return oldVNode.key === newVNode.key && oldVNode.type === newVNode.type
}
複製代碼
replaceVNode
)function replaceVNode(oldVNode, newVNode) {
// 移除舊元素
const { elm: oldElm } = oldVNode
const parent = Api.parentNode(oldElm)
Api.removeChild(parent, oldElm)
// 建立新元素,並添加到父級中
const newElm = createElm(newVNode)
Api.appendChild(parent, newElm)
}
複製代碼
diffVNode
)根據上面的流程圖,咱們也能明白這裏咱們要作如下這些事:
比對屬性與事件 (diffProps
);
遞歸比對子級列表: 這裏也有三種狀況;
新舊節點 均有子級列表,則進入 列表比對 (diffChildren
);
舊節點沒有 子級,新節點有 子級,則 直接 新增 新子級 (addVNodes
);
舊節點有 子級,新節點沒有 子級,則 直接 刪除 舊子級 (removeVNodes
);
根據以上梳理,咱們先來實現 diffVNode
這個函數。
function diffVNode(oldVNode, newVNode) {
const { elm, children: oldChild, text: oldText, props: oldProps } = oldVNode
const { children: newChild, text: newText, props: newProps } = newVNode
if (oldVNode === newVNode || !elm) return
// 已判斷爲同一節點,目的爲 更新元素
// 所以直接複用舊元素
newVNode.elm = elm
// 比對屬性與事件
diffProps(elm, oldProps, newProps)
const hasOldChild = !!(oldChild && oldChild.length)
const hasNewChild = !!(newChild && newChild.length)
// 判斷爲 元素節點 或者 文字節點
if (newText === undefined) {
// 元素節點
// 判斷如何更新子級
if (hasOldChild && hasNewChild) {
// 新舊節點均存在子級列表時,直接 diff 列表
if (oldChild !== newChild) {
// diff 列表
diffChildren(elm, oldChild, newChild)
}
} else if (hasNewChild) {
// 舊節點 不包含子級,而新節點包含子級
// 則直接新增新子級
addVNodes(elm, null, newChild, 0, newChild.length - 1)
} else if (hasOldChild) {
// 新子級不包含元素,而舊節點包含子級
// 則須要刪除舊子級
removeVNodes(elm, oldChild, 0, oldChild.length - 1)
} else if (oldText !== undefined) {
// 當新舊均無子級
// 這裏有可能存在 <Text> 標籤,且新內容爲空
// 所以直接清空舊元素文字
Api.setTextContent(elm, '')
}
} else if (oldText !== newText) {
// 文字節點
// 當新舊文字內容不一樣時,直接修改內容
Api.setTextContent(elm, newText)
}
}
// 更新屬性
function diffProps(elm, oldProps, newProps) {
if (oldProps === newProps || !elm) return
if (typeof oldProps === 'object' && typeof newProps === 'object') {
let keys = Object.keys(oldProps), i, l = keys.length
// 重置被刪除的舊屬性
for (i = 0; i < l; i++) {
const key = keys[i]
const oldValue = oldProps[key], newValue = newProps[key]
if (key.startsWith('on')) {
/* * 當存在舊事件,且新舊值不一致時 * 事件解綁 */
if (typeof oldValue === 'function' && oldValue !== newValue) {
const evName = key.substring(2).toLowerCase()
elm.off(evName, oldValue)
}
} else {
/* * 屬性被賦值時會被自動重置 * 只須要重置被刪除的屬性便可 */
if (newValue === undefined) {
// 元素屬性默認值
elm[key] = DEFAULT_PROPS[key]
}
}
}
// 設置新屬性
setProps(elm, newProps)
}
}
複製代碼
diffChildren
)其實比對屬性、事件都是相對簡單的,而 子級列表的比對,纔是整個 diff 算法中最核心且最考驗性能的部分,所以這裏的列表比對算法決定了整個更新渲染的性能。在 虛擬DOM 剛出現時,使用的是比較簡單的 深度優先(DFS) + 排序比對 的方式。後來出現了更爲高效且沿用至今的 兩端比對算法 + Key值比對,直接把 diff
的效率提升了一個層級,且更好理解,有三種優先級不一樣的比對策略:
優先重新舊列表的 兩端 的 四個節點 開始進行 兩兩比對;
若是均不匹配,則嘗試 key 值比對;
如 key 值 匹配上,則移動並更新節點;
如 未匹配上,則在對應的位置上 新增新節點;
最後所有比對完後,列表中 剩餘的節點 執行 刪除或新增;
這裏這麼說你們是否是一臉懵🤣。沒事,先稍微理解下就行。咱們接下來直接用動畫來更直觀地看下兩端比較算法的具體過程。這裏就以剛纔 圖2 的子級列表爲例,即:
oldChildren 包含 5 個子級節點: [A, B, C, D, E]
;
newChildren 的子級修改成: [F, C, B, D, A]
;
Tips:
如新舊列表中的 A,表明這兩個節點爲 同類型節點,即節點的
type / key
均相等;
圖4. diff 第一輪循環
1. 優先重新舊列表的兩端正向開始,不相同: A !== F
且 E !== A
;
2. 兩端交叉比對,發現 舊列表的第一項與新列表的末項相同 (isSameVNode
):
把舊列表中的第一項 移動 到最後一項;
繼續遞歸 diff 新舊 A
;
圖5. diff 第二輪循環
1. 一樣,優先從兩端正向比對,B !== F & E !== D
;
2. 兩端交叉比對,B !== D & E !== F
;
3. 進入 key 比對:
固定取 新列表首項 (F
);
循環與舊列表 key列表 逐項比對,均 沒法匹配;
所以爲 新節點,直接 建立並添加到舊列表首項;
圖6. diff 第三輪循環
1. 兩端正向、交叉比對,不匹配;
2. 進入 key 比對;
固定取 新列表首項 (C
);
循環與舊列表逐項比對,匹配 到舊列表第二項;
則把舊列表中的第二項 移動到首項,並繼續 遞歸繼續 diff 新舊 C
;
圖7. diff 第四五輪循環
1. 首項比對,匹配成功;
遞歸繼續 diff 新舊 B
;
移動下標,進入下一輪比對;
2. 一樣首項比對匹配;
遞歸繼續 diff 新舊 D
;
移動下標,進入下一輪比對;
3. 新列表循環已結束,刪除 舊列表中的剩餘節點;
通過了五輪的比對,舊列表已經被成功更新。爲了包含咱們上面解釋的三種策略,舉例時用的是複雜度較高,較少出現的場景。在平常業務中,大部分都會更簡單,性能表現會更好。接下來,咱們把這個算法用代碼實現下,採用 雙列表遊標 + while 循環 的方式:
function diffChildren(parentElm, oldChild, newChild) {
/** * 更新子級列表 * 雙列表遊標 + while */
// 初始化遊標
let oldStartIdx = 0, newStartIdx = 0
let oldEndIdx = oldChild.length - 1, newEndIdx = newChild.length - 1
// 列表首尾節點
let oldStartVNode = oldChild[0], oldEndVNode = oldChild[oldEndIdx]
let newStartVNode = newChild[0], newEndVNode = newChild[newEndIdx]
let oldKeyToIdx, idxInOld, elmToMove, before
/** * 當起始遊標 < 終止遊標時, * 表示列表中仍有未 diff 的節點 * 進入循環 */
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
/** * 1. 排除非有效的節點 * 剔除列表中包含的 undefined || false || null */
if (oldStartVNode == null) {
oldStartVNode = oldChild[++oldStartIdx]
} else if (oldEndVNode == null) {
oldEndVNode = oldChild[--oldEndIdx]
} else if (newStartVNode == null) {
newStartVNode = newChild[++newStartIdx]
} else if (newEndVNode == null) {
newEndVNode = newChild[--newEndIdx]
} else if (isSameVNode(oldStartVNode, newStartVNode)) {
/** * 2. 正反向兩兩比對列表首項與末項匹配成功 * 移動遊標,遞歸 diff 兩個節點 * 均未匹配上,則進入 3. key 值比對 */
diff(oldStartVNode, newStartVNode)
oldStartVNode = oldChild[++oldStartIdx]
newStartVNode = newChild[++newStartIdx]
} else if (isSameVNode(oldEndVNode, newEndVNode)) {
diff(oldEndVNode, newEndVNode)
oldEndVNode = oldChild[--oldEndIdx]
newEndVNode = newChild[--newEndIdx]
} else if (isSameVNode(oldStartVNode, newEndVNode)) {
Api.insertBefore(parentElm, oldStartVNode.elm, Api.nextSibling(oldEndVNode.elm))
diff(oldStartVNode, newEndVNode)
oldStartVNode = oldChild[++oldStartIdx]
newEndVNode = newChild[--newEndIdx]
} else if (isSameVNode(oldEndVNode, newStartVNode)) {
Api.insertBefore(parent, oldEndVNode.elm, oldStartVNode.elm)
diff(oldEndVNode, newStartVNode)
oldEndVNode = oldChild[--oldEndIdx]
newStartVNode = newChild[++newStartIdx]
} else {
/** * 3. 兩端比對均不匹配 * 進入 key 值比對 */
// 根據剩餘的舊列表建立 key list
if (!oldKeyToIdx) {
oldKeyToIdx = createKeyList(oldChild, oldStartIdx, oldEndIdx)
}
// 判斷新列表項的 key值 是否存在
idxInOld = oldKeyToIdx[newStartVNode.key || '']
if (!idxInOld) {
/* * 4. 新 key 值在舊列表中不存在 * 直接將該節點插入 */
Api.insertBefore(parentElm, createElm(newStartVNode), oldStartVNode.elm)
newStartVNode = newChild[++newStartIdx]
} else {
/* * 5. 新 key 在舊列表中存在時 * 繼續判斷是否爲同類型節點 */
elmToMove = oldChild[idxInOld]
if (isSameVNode(elmToMove, newStartVNode)) {
/* * 6. 新舊節點類型一致 * key 有效,直接移動並 diff */
Api.insertBefore(parentElm, elmToMove.elm, oldStartVNode.elm)
diff(elmToMove, newStartVNode)
// 清空舊列表項
// 後續的比對能夠直接跳過
oldChild[idxInOld] = undefined
} else {
/* * 7. 新舊節點類型不一致 * key 效,直接建立元素並插入 */
Api.insertBefore(parentElm, createElm(newStartVNode), oldStartVNode.elm)
}
newStartVNode = newChild[++newStartIdx]
}
}
}
/* * 8. 當有遊標列表爲空時,則結束循環,進入策略3 * 當 舊列表爲空 時,則建立並插入新列表中的剩餘節點 * 當 新列表爲空 時,則刪除舊列表中的剩餘節點 */
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
// 新增節點
const vnode = newChild[newEndIdx + 1]
before = vnode ? vnode.elm : null
addVNodes(parentElm, before, newChild, newStartIdx, newEndIdx)
} else {
// 刪除節點
removeVNodes(parentElm, oldChild, oldStartIdx, oldEndIdx)
}
}
}
複製代碼
恭喜童鞋們~ 咱們完成了傳說中的 兩端比對算法
咯🥳。其實只要按比對的優先級把邏輯理清楚,逐一執行判斷,思路仍是比較清晰的。
經過上面真正的代碼編寫以及瞭解實現原理後,咱們其實能從中找到一些好的實踐方式,從而優化咱們的代碼,提升性能。
props
的傳遞JSX
中標籤能夠傳遞屬性,最簡單的方式就是 值傳遞:
const tmp = <Container text="text" data={{ a: 1 }}>Text</Container>
複製代碼
因爲在比對的時候,在判斷 props
是否發生變化時,採用 全等 的比較。所以,當出現值是 引用對象 時,若是直接像上面這樣, data
寫在 JSX
中,則每次 diff
時 data
的值都是全新的對象,不會相等。即便對象屬性所有一致,但每次均須要循環比對每一項屬性。
所以建議:
Object
、Array
、Function
等,直接使用 引用傳遞:const data = { a: 1 }
const tmp = <Container text="text" data={data}>Text</Container>
複製代碼
這樣的建議能夠有效下降 diff
時的性能損耗,當場景複雜時,收益就比較可觀了。
diff
算法採用的 同層比對 的策略,所以若是是跨層級的移動,就會 從新建立新節點並刪除原來的節點,並非真正的移動。因此保證 渲染樹結構穩定 能夠有效提升性能。
儘可能避免節點的 跨層級移動;
如沒法避免,則須要考慮 狀態同步 的問題;
同層移動一樣也會在 diff
時須要額外的循環比對,應該減小沒必要要的頻繁移動;
當須要列表中 VNode
的 同層移動 時,加上惟一標識 key
能有效提升 diff
性能,避免元素的 重渲染。
注意確保 VNode.key
在 diff
先後的 一致,這樣纔可有效提高性能,避免使用 index
、 Math.random
、時間戳等值;
即便在非循環列表渲染時,給標籤添加 key
值一樣會生效,不一樣的 key
會致使節點被斷定爲非同類節點,從而進行替換;
複用性高 且須要 頻繁更新 的節點抽離成 組件,會使 VNode Tree
碎片化,從而能更有效地進行 局部更新,減小觸發 diff
的節點數量,提升性能且提升代碼複用率;
但因爲組件的建立和 diff
相比普通節點來講更爲 複雜,須要執行例如生命週期,組件比對 等,因此須要 合理規劃,避免 過度組件化 致使 內存的浪費和影響性能,一些 複用率低的靜態元素 直接使用元素節點更爲合理;
受篇幅所限,本文暫且完成到這裏。先來總結回顧下咱們完成的部分:
1. JSX
是一種 動態模板語法,經過 Babel
編譯爲 createElement
函數;
2. createElement
將 JSX
轉換爲 虛擬Dom(VNode),包含完整的標籤信息 (類型、屬性、子級列表);
3. 虛擬DOM 是一種 犧牲最小性能與空間,換取 架構優化 的方式,其 組件化 以及 解耦 的思想,提升了項目的拓展性、複用性與可維護性,同時爲 跨平臺渲染 奠基基礎;
4. 經過實現 createElm
與 render
,完成 VNode
的 初次渲染;
5. Diff
是一種 計算得出兩個 VNode 差別 的算法,使用 同層比對、惟一標識、組件模式 優化了算法比對性能,將時間複雜度下降爲 O(n)
;
6. 列表比對 (diffChildren
) 採用 兩端比對算法 + Key值比對 算法,大大提升了 Diff
效率;
7. 實現 diffVNode
、diffProps
、diffChildren
,完成 VNode
的 動態更新;
咱們完成了框架最核心的 JSX
- Render
- Diff
的渲染更新的主機制,奠基了最底層的基礎。在下一篇文章中,咱們將繼續基於此完成 組件化(Component
)、組件更新(setState
) 以及 生命週期。但願能幫助你們更瞭解 React,掌握一些優秀的編程思惟。
這篇文章所完成的類 React
框架是過去幾個月我本身在 Web
遊戲方面的嘗試和探索,目的是指望能 下降傳統前端工程師開發遊戲的門檻 和 引入前端開發模式提高開發效率,使 前端開發 與 遊戲開發 造成必定程度的優點互補。固然這個目標很大,也並不簡單,規劃中仍然有許多工做要作。有興趣的小夥伴移步 github 查看完整代碼:
最後,咱們去年下半年在廈門創辦了一家技術公司,主要是在 遊戲領域 和 K12 STEAM 教育 方向上的努力。很是歡迎 有興趣想深刻了解 或者 想跟我探討 的小夥伴直接聯繫我!🥳~~
Tips:
看博主寫得這麼辛苦下,跪求點贊、關注、Star!更多文章猛戳 ->
郵箱: 159042708@qq.com 微信/QQ: 159042708