React 實踐揭祕之旅,中高級前端必備(上)

感謝你們又被個人標題黨騙進來了😂。這是我最近幾個月親身探尋過的一趟旅途,感覺頗深。途中也遇到許多困難,但堅持到底,相信最終必定會讓各位小夥伴受益不淺,不枉此行!前端

同時也感恩你們對以前一個系列文章的喜歡和修正😘,提了很多的建議和問題,我也會用心寫個人每一系列文章,與你們共同成長!!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 也很是有限,並不須要你具有相關的知識儲備。編程

第一站: 穿越之門 - JSX

做爲一名前端,咱們須要實現一個個展現給用戶的頁面。所以視圖層即是咱們的工做之始。前端領域不斷地快速發展,最終都是爲了解答 如何更高效地開發更完美的頁面。而 React 就是答案之一,其中 JSX 即是重要的第一站。

那什麼是 JSX 呢?

JSX 就是在 JS 環境中約定的一種類 HTMLXML 的 動態模板語法,有着極高的 可讀性 與 可拓展性,目的是 爲了能更便捷地使用 JS 搭建視圖結構與佈局。

// 這就是 JSX
const jsx = <JSX>Hello World</JSX>
複製代碼

這是一種新全新的 JS 語法,不屬於標準,成功地把類 HTML 的標籤型模板語法引入 JS 中,創造了一種全新高效的開發模式。但即便最新版的 V8 引擎也沒法支持,那怎麼執行呢?鑰匙就是 預編譯!得益於 Babel 的強大,咱們能夠經過 預編譯,將代碼編譯成瀏覽器看得懂的 JS

babel-plugin-transform-jsx

這是 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)
    }
}
複製代碼

第二站: 天空之城 - Virtual DOM

來到第二站: 什麼是 虛擬DDM 呢?

顧名思義,它並非真正的視圖元素,而是將真實的視圖元素抽象爲一個 Javascript 對象,包含完整描述了一個真實元素的全部信息,但並不具有渲染功能。同時因爲 DOM 自己即是 樹形結構,所以使用 Javascript 對象便能很好對整個頁面結構進行描述。

剛纔 JSX 編譯後,參數即是一個最簡單的 虛擬DOM 對象(咱們稱爲 VNode):

// 一個最簡單的 VNode
{
    // 類型
    type: 'Container'
    // 屬性
    props: { 
        name: 'parent'
    }
    // 子級列表
    children: [...]
}
複製代碼

這其實只是一個普通的 Javascript 對象,那爲何要設計它呢?可能不少人都會有這種觀念: 虛擬DOM 快啊,diff 算法很是厲害,直接操做真實的 DOM 消耗很大。

掐指一算,事情並無那麼簡單,聽我細細道來🧐。想象下,當出現如下場景時:

  • 初次渲染:

    • 解析 JSX,生成 虛擬DOM 樹,而後通過各類計算,最終調用 DOM 繪製一個個視圖元素;

    • 很明顯,咱們經過 HTMLinnerHTML 直接建立元素會更快,且白屏時間更短,多了上層的 計算消耗與內存消耗,反而是一種 性能損耗

  • 極小更新:

    • 須要修改一個標題文案,調用 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)了。

第三站: 轉換之橋 - createElement

這個函數就是傳說中的 h 函數,用於 模板 到 虛擬DOM 之間的橋樑。在如今的大多數主流 虛擬DOM 庫中,都擁有該函數。在 React 中,它是經過 BabelJSX 編譯成 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

爲了更好地 解耦視圖層,咱們把須要用到的一些與 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)
        }
    },

}
複製代碼

這一部分能夠稱爲 對接層,能夠對接到各個平臺,若是使用原生 DOMAPI,則就是 Web 渲染,有點相似於 react-dom 所完成的事。

爲了演示方便,咱們就使用 pixi.js 來做爲 渲染接口。這裏與 WebGL 庫無關,能夠對接到 任意渲染框架

第四站: 創造之柱 - Render

有了 接口層 APIVNode 後,能夠開始 建立真實視圖元素,並同時根據 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 算法的用武之地了!

第五站: 時空之匙 - Diff

說到 虛擬DOM 就立刻能提到其核心的 diff 算法。當咱們經過 setState 去更新組件時,是從新生成一棵全新的完整 虛擬DOM樹,此時就須要對比 新舊兩棵樹的差別點,再針對性更新。也就是一種 計算得出兩個對象差別 的算法。

這種 diff 算法其實使用的場景不少,例如咱們很熟悉的代碼版本控制。先後兩份提交的代碼須要比對出差別點,而後進行更新保存。只不過這裏的 代碼文件 變成了 虛擬DOM。因爲 虛擬DOM 至關複雜,包含很是多的屬性,而且可能擁有很是深的層級,所以若是用常規的循環遞歸去比較,時間複雜度爲 O(n^3),這性能是沒法接受的。

所以天才工程師們基於 Web 視圖渲染的一些特徵,在比對上經過 制定規則,選擇性取捨,大大優化了算法的效率。包含如下三種優化策略:

1. 同層比對策略

因爲在大部分 Web 視圖渲染中,咱們不多會去跨層級移動元素,移動元素一般出如今同層級的列表中,所以這裏能夠有一個優化策略:

只作同層級的比對,忽略跨層級的元素移動

傳統的 diff 算法須要兩層循環,每兩個節點之間都須要進行對比。而制定了同層比對後,節點只須要跟同一層級的節點進行比對,以下圖所示。

圖2. diff 比對策略

此時,性能已經大大的提高了,時間複雜度優化成 O(n^2)。另外,若是真出現跨層級移動時,會直接將舊元素刪除,在新的位置從新建立,也能保證更新的準確行。但可能會致使狀態的丟失。

2. 惟一標識策略

雖然咱們作了同層比對的優化,但此時有一個問題:

例如圖2,當 C1 / C2 交換位置,咱們在循環比對時,因爲它們均屬於相同類型的節點,單單經過 type 並沒有法正確區分,沒法識別出位置的移動。只能作到把 C1 修改爲 C2,把 C2 修改爲 C1。這樣不只會 損耗性能,並且可能致使 狀態丟失

最優的方式應該就是: 把 C1C2 正確交換位置。關鍵點就在於: 如何正確識別與區分節點。所以這裏便引入了 key 做爲惟一標識,用 type + key 即可確切地識別出節點的準確位置,從而將時間複雜度優化成了 O(n)

3. 組件模式策略

在複雜度方面,已經有了有效的優化。接下來,即是從邏輯層進行優化。

首先一個最大的損耗就是: 沒法準肯定位目標節點,須要 樹遍歷 尋找更新的目標節點。

如圖2,咱們只要更新 D節點,卻必須從最根級的 A節點 逐層 diff,完整比對新舊兩棵 虛擬樹,這裏有明顯的無謂損耗。若是將 D節點 抽離成一個獨立的模塊,則能夠只調用 D節點 自身的 diff。 所以便引入了 組件模式,可以 碎片化 虛擬DOM

若是咱們確實須要同時更新 A / D 節點呢?其實左邊分支 B1 節點並不須要更新,不須要 diff。若是能讓 B1 節點擁有一個標識標識本身爲非更新目標,在更新流中能夠 主動打斷更新流。那就能夠只 diff A -> B2 -> D 這條線了。這裏,即是咱們熟知的 shouldComponentUpdate

Diff 的實現

首先咱們先來梳理下兩個 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
}
複製代碼

1. 當新舊節點不一樣時,直接替換 (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)
}
複製代碼

2. 當新舊節點爲同一節點時,開始正式的比對 (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 !== FE !== 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

第4、五輪比對:

圖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)
        }
    }
}
複製代碼

恭喜童鞋們~ 咱們完成了傳說中的 兩端比對算法 咯🥳。其實只要按比對的優先級把邏輯理清楚,逐一執行判斷,思路仍是比較清晰的。

實踐建議

經過上面真正的代碼編寫以及瞭解實現原理後,咱們其實能從中找到一些好的實踐方式,從而優化咱們的代碼,提升性能。

1.props的傳遞

JSX 中標籤能夠傳遞屬性,最簡單的方式就是 值傳遞:

const tmp = <Container text="text" data={{ a: 1 }}>Text</Container>
複製代碼

因爲在比對的時候,在判斷 props 是否發生變化時,採用 全等 的比較。所以,當出現值是 引用對象 時,若是直接像上面這樣, data 寫在 JSX 中,則每次 diffdata 的值都是全新的對象,不會相等。即便對象屬性所有一致,但每次均須要循環比對每一項屬性。

所以建議:

  • 當屬性值爲 引用對象 時,如 ObjectArrayFunction等,直接使用 引用傳遞:
const data = { a: 1 }
const tmp = <Container text="text" data={data}>Text</Container>
複製代碼
  • 同理,當數據須要修改時,不要直接修改源對象,而是應該 遵循數據不可變原則,生成一個新對象。

這樣的建議能夠有效下降 diff 時的性能損耗,當場景複雜時,收益就比較可觀了。

2. 渲染樹結構穩定

diff 算法採用的 同層比對 的策略,所以若是是跨層級的移動,就會 從新建立新節點並刪除原來的節點,並非真正的移動。因此保證 渲染樹結構穩定 能夠有效提升性能。

  • 儘可能避免節點的 跨層級移動

  • 如沒法避免,則須要考慮 狀態同步 的問題;

  • 同層移動一樣也會在 diff 時須要額外的循環比對,應該減小沒必要要的頻繁移動;

3. key 的使用

當須要列表中 VNode同層移動 時,加上惟一標識 key 能有效提升 diff 性能,避免元素的 重渲染

  • 注意確保 VNode.keydiff 先後的 一致,這樣纔可有效提高性能,避免使用 indexMath.random、時間戳等值;

  • 即便在非循環列表渲染時,給標籤添加 key 值一樣會生效,不一樣的 key 會致使節點被斷定爲非同類節點,從而進行替換;

4. 組件化

  • 複用性高 且須要 頻繁更新 的節點抽離成 組件,會使 VNode Tree 碎片化,從而能更有效地進行 局部更新,減小觸發 diff 的節點數量,提升性能且提升代碼複用率;

  • 但因爲組件的建立和 diff 相比普通節點來講更爲 複雜,須要執行例如生命週期,組件比對 等,因此須要 合理規劃,避免 過度組件化 致使 內存的浪費和影響性能,一些 複用率低的靜態元素 直接使用元素節點更爲合理;

第六站: 休憩之地 - 總結概括

受篇幅所限,本文暫且完成到這裏。先來總結回顧下咱們完成的部分:

  • 1. JSX 是一種 動態模板語法,經過 Babel 編譯爲 createElement 函數;

  • 2. createElementJSX 轉換爲 虛擬Dom(VNode),包含完整的標籤信息 (類型、屬性、子級列表);

  • 3. 虛擬DOM 是一種 犧牲最小性能與空間,換取 架構優化 的方式,其 組件化 以及 解耦 的思想,提升了項目的拓展性、複用性與可維護性,同時爲 跨平臺渲染 奠基基礎;

  • 4. 經過實現 createElmrender,完成 VNode初次渲染

  • 5. Diff 是一種 計算得出兩個 VNode 差別 的算法,使用 同層比對、惟一標識、組件模式 優化了算法比對性能,將時間複雜度下降爲 O(n)

  • 6. 列表比對 (diffChildren) 採用 兩端比對算法 + Key值比對 算法,大大提升了 Diff 效率;

  • 7. 實現 diffVNodediffPropsdiffChildren,完成 VNode動態更新

咱們完成了框架最核心的 JSX - Render - Diff 的渲染更新的主機制,奠基了最底層的基礎。在下一篇文章中,咱們將繼續基於此完成 組件化(Component)組件更新(setState) 以及 生命週期。但願能幫助你們更瞭解 React,掌握一些優秀的編程思惟。

這篇文章所完成的類 React 框架是過去幾個月我本身在 Web 遊戲方面的嘗試和探索,目的是指望能 下降傳統前端工程師開發遊戲的門檻引入前端開發模式提高開發效率,使 前端開發 與 遊戲開發 造成必定程度的優點互補。固然這個目標很大,也並不簡單,規劃中仍然有許多工做要作。有興趣的小夥伴移步 github 查看完整代碼:

react-webgl.js ->

最後,咱們去年下半年在廈門創辦了一家技術公司,主要是在 遊戲領域K12 STEAM 教育 方向上的努力。很是歡迎 有興趣想深刻了解 或者 想跟我探討 的小夥伴直接聯繫我!🥳~~

Tips:

看博主寫得這麼辛苦下,跪求點贊、關注、Star!更多文章猛戳 ->

郵箱: 159042708@qq.com 微信/QQ: 159042708

祝福#感恩#武漢加油##RIP KOBE#

相關文章
相關標籤/搜索