上一篇文章咱們主要實現了 JSX 在 WebGL
上的渲染與更新,對 虛擬DOM 和 Diff 有了更深的瞭解,但相比於咱們使用的 React
,還缺少了之中很重要的一環 --- 組件模式。前端
想必你們能認同,React組件(Component
)具備 強大的功能,高拓展性和高解耦性,在其基礎上構建的各類 UI
組件框架徹底改變了傳統的 Web 開發模式,成爲了Web 中的大型複雜應用提供了一種很好的構建模式和保障,也讓咱們的開發效率也有了質的變化。node
在這篇文章中,咱們會在上一篇的實現基礎上,加入 React
組件模式,並在實現的過程當中適時的去講解一些原理和思惟,也有利於你們 由淺入深的理解 和 編程思惟上的提高。react
因爲下篇是徹底以上篇做爲基礎的。因此若是你還沒看過上篇,請優先猛戳:git
React 實踐揭祕之旅,中高級前端必備(上) ->github
代碼複用性,向來是編程領域一個核心的理念。咱們最常使用 函數、類 方式進行代碼的封裝,但這裏有個痛點: web
Web 中 UI 與 邏輯 的分離的特性,致使較難優雅地整合封裝。編程
一般咱們須要引 JS
、CSS
,再在 HTML
中按規定書寫結構,最後再初始化庫。整個過程十分割裂,且不優雅。數組
而 React Component
幫咱們解決了這個痛點,即保持了 動態UI 與 邏輯 的分離,又有了全新的整合方式。從而讓 UI 組件變得很是的 高效易用,只須要在結構中以標籤的形式引入便可。瀏覽器
咱們就繼續在上篇文章的基礎上,加入 Component
的特性。基於 JSX
的變量傳遞,咱們只須要實現一個 Component
類,並針對性地去完成組件的 渲染和更新 便可。緩存
TIPs:因爲組件的加入,這時咱們的 虛擬DOM(VNode) 就包含了兩種類型: 組件節點(compVNode) 和 元素節點(elVNode)。後續都會以此區分,便於講解。
// 組件基類 class Component { // 經過保持三份不一樣的時期的快照 // 有利於對組件的狀態及屬性的管理和追蹤 // 屬性 public __prevProps public props = {} public __nextProps // 狀態 public __prevState public state = {} public __nextState // 儲存當前組件渲染出的 VNode public __vnode // 儲存 組件節點 public __component constructor(props) { // 初始化參數 this.props = props } public render(): any { return null } public __createVNode() { // 該方法用於封裝從新執行 render 的邏輯 // state 與 props 狀態變動的邏輯 this.__prevProps = this.props this.__prevState = this.state this.props = this.__nextProps this.state = this.__nextState this.__nextState = this.__nextProps = undefined // 從新執行 render 生成 VNode this.__vnode = this.render() return this.__vnode } }
有了這個類後,咱們就可使用 React
的方式繼承出 自定義組件 進行渲染:
class App extends Component { constructor(props) { super(props) this.state = { content: 'ReactWebGL Component, Hello World', } } public render() { return ( <Container name="parent"> {this.state.content} </Container> ) } } render(<App />, game.stage)
因爲咱們以前提過,JSX
是以 變量傳遞 的形式編譯的。所以將 <App />
轉換成 VNode
後,其 vnode.type
的值即是 App
這個類,並非相似 div
那樣的 字符串標籤名。因此接下來咱們須要實現一個 組件初始化 的函數(Component
)。這個函數的主要功能是:
實例化組件並獲取組件 render
方法中返回的 元素節點。
// 渲染組件 function renderComponent(compVNode) { const { type: Comp, props } = compVNode let instance = compVNode.instance // 當 instance 已經存在時,則不須要從新建立實例 if (!instance) { // 傳入 props,初始化組件實例 // 支持 類組件 或者 函數組件 if (Comp.prototype && Comp.prototype.render) { instance = new Comp(props) } else { instance = new Component(props) instance.constructor = Comp instance.render = () => instance.constructor(props) } // 初次渲染時 // 未來的屬性與狀態其實即是與當前值一致 instance.__nextProps = props instance.__nextState = instance.state } // 調用 render 獲取 VNode const vnode = instance.__createVNode() // 組件、元素、實例之間保持相互引用,有利於雙向鏈接整棵虛擬樹 instance.__component = compVNode compVNode.instance = instance compVNode.vnode = vnode vnode.component = compVNode return vnode }
接下來咱們只須要在 createElm
的函數加入: 當傳入的爲 組件節點 時調用函數初始化生成 元素節點,後續只須要繼續原有的邏輯繼續建立,便能正確渲染組件。
function createElm(vnode) { // 當爲組件時,初始化組件 // 從新賦值成 元素節點 if (typeof vnode.type === 'function') { vnode = renderComponent(vnode) } // 維持原有邏輯 ... return vnode.elm }
保存執行,頁面中已經能正確的渲染出 <App>
組件了。延續以前的邏輯,在完成初次渲染後,接下來就是組件的更新。這就是咱們最常使用的 this.setState
了。
在上一篇文章中,咱們實現了 虛擬DOM 更新函數 diff
。參數爲 新舊虛擬節點(oldVNode
、newVNode
)。因此組件更新的原理也同樣:
獲取組件實例前後渲染的新舊 VNode
再觸發 diff
函數。
在剛纔 Component
的渲染中,咱們已經把 render
生成的 VNode
保存在 this.__vnode
上,這即是初始化時生成的 舊虛擬節點(oldVNode
)。因此咱們要作的就是:
setState
中 更新狀態,調用 render
生成 新虛擬節點(newVNode
),觸發 diff
更新。
所以咱們在類上新增兩個方法: setState
和 __update
:
class Component { // 其他方法 ... // 更新函數 public __update = () => { // 臨時存儲 舊虛擬節點 (oldVNode) const oldVNode = this.__vnode this.__nextProps = this.props if (!this.__nextState) this.__nextState = this.state // 從新生成 新虛擬節點(newVNode) this.__vnode = this.__createVNode() // 調用 diff,更新 VNode diff(oldVNode, this.__vnode) } // 更新狀態 public setState(partialState, callback?) { // 合併狀態, 暫存於 即將更新態 中 if (typeof partialState === 'function') { partialState = partialState(this.state) } this.__nextState = { ...this.state, ...partialState, } // 調用更新,並執行回調 this.__update() callback && callback() } }
到這裏咱們能夠看出: setState
封裝了 diff
方法。但因爲 diff
的複雜度,性能的優化會是一個咱們須要着重考慮的點。每執行一次 setState
,就須要從新生成 newVNode
進行 diff
。所以,當組件很是複雜時或者連續更新時,可能會致使 主進程的阻塞,形成頁面的卡死。
這裏咱們須要有兩個優化:
setState
異步化,避免阻塞主進程;setState
合併,屢次連續調用會被最終合併成一次;
爲了這個優化,咱們首先須要一個更新隊列功能:
咱們先來實現個 異步執行隊列:
// 更新異步化,採用屬於微任務的 Promise,兼容性使用 setTimeout // 這裏使用微任務,能夠保證宏任務的優先執行 // 保證例如 UI渲染 等更爲重要的任務,避免頁面卡頓; const defer = typeof Promise === 'function' ? Promise.prototype.then.bind(Promise.resolve()) : setTimeout // 更新隊列 const updateQueue: any[] = [] // 隊列更新 API export function enqueueRender(updater) { // 將全部 updater 同步推入更新隊列中 // 爲實例添加一個屬性 __dirty,標識是否處於待更新狀態 // 初始 和 更新完畢,該值會被置爲 false // 推入隊列時,標記爲 true if ( !updater.__dirty && (updater.__dirty = true) && updateQueue.push(updater) === 1 ) { // 異步化沖洗隊列 // 最終只執行一次沖洗 defer(flushRenderQueue) } } // 合併一次循環中屢次 updater function flushRenderQueue() { if (updateQueue.length) { // 排序更新隊列 updateQueue.sort() // 循環隊列出棧 let curUpdater = updateQueue.pop() while (curUpdater) { // 當組件處於 待更新態 時,觸發組件更新 // 若是該組件已經被更新完畢,則該狀態爲 false // 則後續的更新均再也不執行 if (curUpdater.__dirty) { // 調用組件自身的更新函數 curUpdater.__update() // 執行 callback flushCallback(curUpdater) } curUpdater = updateQueue.pop() } } } // 執行緩存在 updater.__setStateCallbacks 上的回調 function flushCallback(updater) { const callbacks = updater.__setStateCallbacks let cbk if (callbacks && callbacks.length) { while (cbk = callbacks.shift()) cbk.call(updater) } }
完成這個方法後,咱們來修改下上面的 setState
函數:
class Component { // 其他方法 ... public setState(partialState = {}, callback?) { // 合併狀態, 暫存於 即將更新態 中 // 處理參數爲函數的形式 if (typeof partialState === 'function') { partialState = partialState(this.state, this.props) } this.__nextState = { ...this.state, ...partialState, } // 緩存回調 callback && this.__setStateCallbacks.push(callback) // 把組件自身先推入更新隊列 enqueueUpdate(this) } }
此時,因爲 更新隊列 爲異步的,所以當屢次連續調用 setState
時,組件的狀態會被 同步合併,待所有完成後,纔會進入更新隊列的沖洗並最終只執行一次組件更新。
React 中組件還有個更新的方法: forceUpdate(callback)
,該方法的功能實際上是與 this.setState({}, callback)
相同的,惟一有個須要注意的點就是: 觸發的更新不須要通過 shouldComponentUpdate
的判斷,實現只須要加個標識位便可。
回到性能優化這個點,從這裏的簡單實現咱們能夠看出: 雖然異步化了更新流程,但本質上仍然沒有解決 複雜的組件 diff
帶來長時間執行阻塞主進程。我記得之前文章有說過: 最有效的性能優化方式就是 異步、任務分割 和 緩存策略。
經過把同步的代碼執行變成異步,把串行變成並行,能夠有效提升 執行的時間利用率 和 保證代碼優先級。從這裏能夠延伸出兩種優化方向:
diff
自己就較爲複雜,還要須要處理好主進程與線程之間的交互,會致使複雜度極高,但也並不是不可行,後續也許是個優化方向。將本來會阻塞主進程的 大塊邏輯執行進行拆解,分割成一個個小任務。從而能夠在邏輯中找到合適的時機點 分段執行,即 不會阻塞主進程,又可讓代碼快速高效的執行,最大化利用物理資源。
Facebook 的大神們選擇了這條優化方向,這就是 React 16 新引入的 Fiber
理念的最主要目的。上面咱們實現的 diff
中,有着一個很大的障礙:
一棵完整 虛擬DOM樹 更新,必須一次性更新完成,中間沒法被暫停,也沒法被分割。
而 Fiber
最主要的功能就是 指針映射,保存上一個更新的組件與下一步須要更新的組件,從而完成 可暫停可重啓。計算進程的運行時間,利用瀏覽器的 requestIdleCallback
與 requestAnimationFrame
接口,當有優先級更高的任務時,優先執行,暫停下一個組件的更新。待空閒時再重啓更新。
Fiber
算是一種編程思想,在其它語言中也有許多應用(Ruby Fiber
)。核心思想是:
任務拆分和協同,主動把執行權交給主線程,使主線程有時間空擋處理其餘高優先級任務。
但實現複雜度較高,爲了本文便於理解,暫時並無引入。等之後有機會咱們再來一塊兒深挖 Fiber
的實現,也許能成爲更多使用場景的性能優化手段。
在上篇文章中,咱們優先實現了 diffVNode
方法用於更新 元素節點,但組件節點的更新與元素節點的更新是不一樣的。當出現組件嵌套的狀況時,咱們就須要一個新的方法(diffComponent
)用於組件節點的更新。
與 元素節點 不一樣,組件節點之間的更新重要的是重渲染,相似於咱們上面的 setState
。
複用已建立好的組件實例,根據新的 狀態(state
)與 屬性(props
) 從新執行 render
生成 元素節點,再遞歸比對,
也就是說,咱們須要在 diffVNode
外圍再作一層判斷處理:
function diff(oldVNode, newVNode) { if (isSameVNode(oldVNode, newVNode)) { if (typeof oldVNode.type === 'function') { // 組件節點 diffComponent(oldVNode, newVNode) } else { // 元素節點, // 直接執行比對 diffVNode(oldVNode, newVNode) } } else { // 新節點替換舊節點 ... } } // 組件比對 function diffComponent(oldCompVNode, newCompVNode) { const { instance, vnode: oldVNode, elm } = oldCompVNode const { props: nextProps } = newCompVNode if (instance && oldVNode) { instance.__dirty = false // 更新狀態和屬性 instance.__nextProps = nextProps if (!instance.__nextState) instance.__nextState = instance.state // 複用舊組件實例和元素 newCompVNode.instance = instance newCompVNode.elm = elm // 使用新屬性、新狀態,舊組件實例 // 從新生成 新虛擬DOM const newVNode = initComponent(newCompVNode) // 遞歸觸發 diff diff(oldVNode, newVNode) } }
組件有一個至關重要的特徵,即是具備 生命週期。不一樣的函數鉤子對應了組件 從初始化到銷燬 的各個關鍵時間點。主要是爲了讓業務方有能力 插入組件的渲染工做流 中,編寫業務邏輯。咱們先來簡單梳理下最新 React 組件的生命週期:
constructor
state
;static getDerivedStateFromProps(nextProps, prevState)
state
和 props
,而 props
由父級傳入,組件自己並沒有法直接修改,所以惟一的常見需求就是: 根據父級傳入的 props
動態修改 state
。該生命週期就是爲此而生;你們可能會有疑問: 該方法爲何爲靜態方法? 而不是常規的實例方法呢?
render()
state
和 props
,生成 虛擬DOM;componentDidMount()
static getDerivedStateFromProps(nextProps, prevState)
shouldComponentUpdate(nextProps, nextState)
diff
優化策略中有提到: 爲了減小 無謂的更新消耗,賦予組件一個能夠 主動中斷更新流 的 API
。根據參數中的 更新屬性 和 更新狀態,業務方自行判斷是否須要繼續往下執行 diff
,從而能有效地提高 更新性能;你們記得 React 中有種組件叫 純組件(PureComponent
) 吧,其實這個類繼承於普通的 Component
上封裝的,能夠減小多餘的 render
,提高性能。
shouldComponentUpdate
函數設定更新條件: 僅當 props
和 state
發生改變時,纔會觸發更新。這裏使用了 Object
淺層比對,也就是僅作第一層比對,即 1. key 是否徹底匹配;2. value 是否全等; 因此若是須要超過一層的數據變更,純組件即沒法正確更新了;render()
getSnapshotBeforeUpdate(prevProps, prevState)
componentWillUpdate
,觸發時機點: 在數據狀態已更新,最新 VNode
已生成,但 真實元素還未被更新;componentDidUpdate(prevProps, prevState, snapshot)
setState
時必須加條件,避免無限循環;componentWillUnmount()
咱們也按這樣的目標來在咱們的 Component
上實現這些生命週期,那如何更好的組織生命週期呢?這裏我考慮到的是:
組件做爲元素的容器,生命週期的本質實際上是 其渲染出的元素節點的生命週期。
也就是說,關鍵點仍是在於 元素在視圖中的工做流,什麼時候被掛載 - 更新 - 卸載。因此爲了更好的維護性和拓展性,更理想的方式應該是 爲元素節點統一添加生命週期,而不是單獨爲組件,這樣可大大下降複雜度,增長可拓展性。
那咱們第一步先根據上面須要的生命週期來理一下須要哪些時機:
create
);insert
);willupdate
);update
);willremove
);原理就很簡單了,只須要在 VNode
工做流中的對應時期調用相應的生命週期函數便可。那咱們如今 VNode
上新增一個屬性 hooks
,用於 儲存 對應的生命週期函數:
interface VNode { ... hooks: { create?: (vnode) => void insert?: (vnode) => void willupdate?: (oldVNode, newVNode) => void update?: (oldVNode, newVNode) => void willremove?: (vnode) => void } }
新增一個觸發的方法(fireVNodeHook
):
function fireVNodeHook(vnode, name, ...data) { // 根據 生命週期名稱 // 執行儲存在 VNode 上的對應函數便可 const { hooks: _hooks } = vnode if (_hooks) { hook = _hooks[name] hook && hook(...data) } }
有了這層基礎方法後,咱們只須要分別在以前所寫的 渲染與更新 流程中的各個函數適時地觸發就好了。
create
該時機是在 元素被建立後,但還未被掛載以前。因爲咱們以前將邏輯統一收歸爲 createElm
,所以只須要在該函數末尾統一加入觸發便可。
function createElm(vnode) { // 建立元素邏輯 ... // 觸發 虛擬DOM 上儲存的 鉤子函數 fireVNodeHook(vnode, 'create', vnode) return vnode.elm }
insert
元素被掛載到視圖 上的時機。從元素的角度來看,就是被 append
到父級中的時機點。這個時機點比較分散,但也比較好加入,找到咱們使用 Api
中的 append
方法加入,總共有三個地方:
render
函數中加入對 根節點 的觸發;createElm
函數中加入對 全部子級 的觸發;diffChildren
列表比對中 新增列表項 的觸發;willupdate
與 update
更新以前 與 更新以後,對應的即是咱們的 diff
函數。因爲最終均需走到 diffVNode
中,所以只須要在 diffVNode
開頭和末尾觸發便可。
willremove
元素被卸載時,其實與 insert
相似,只須要關注 Api
中 removeChild
的調用時機便可。在 diff
列表比對期間,當新列表中不存在時,咱們須要刪除舊列表中的元素,也就是以前寫的業務函數 removeVNodes
。
因爲 元素節點 纔是貫穿整棵 虛擬DOM 渲染與更新的關鍵,所以咱們上面先實現的是對 元素節點 的生命週期觸發。可是咱們最終須要是 組件節點 的生命週期。因爲 組件節點 與 元素節點 爲一一對應的 上下層級關係,所以這裏咱們還須要作一層轉接:
把組件節點的生命週期賦值給其生成的元素節點。
首先咱們先來爲組件定義上生命週期,並定義一箇中轉對象 __hooks
,實現 組件節點週期與元素節點週期的轉換:
class Component { public __hooks = { // 元素節點 插入時機,觸發 didMount insert: () => this.componentDidMount(), // 元素節點 更新以前,觸發 getSnapshotBeforeUpdate willupdate: (vnode) => { this.__snapshot = this.getSnapshotBeforeUpdate(this.__prevProps, this.__prevState) }, // 元素節點 更新以後, 觸發 didUpdate update: (oldVNode, vnode) => { this.componentDidUpdate(this.__prevProps, this.__prevState, this.__snapshot) this.__snapshot = undefined }, // 元素節點 卸載以前, 觸發 willUnmount willremove: (vnode) => this.componentWillUnmount(), } // 默認生命週期函數 // getDerivedStateFromProps(nextProps, state) public getSnapshotBeforeUpdate(prevProps, prevState) { return undefined } public shouldComponentUpdate(nextProps, nextState) { return true } public componentDidMount() { } public componentDidUpdate(prevProps, prevState, snapshot) { } public componentWillUnmount() { } }
而後咱們只須要在 __createVNode
方法中將 this.__hooks
賦值給生成出的 VNode
便可:
class Component { ... public __createVNode() { // ... this.__vnode = this.render() // 賦值給對應的 元素節點, // 實現該 元素節點 與 組件 之間生命週期的綁定 this.__vnode.hooks = this.__hooks return this.__vnode } }
最後,你們可能發現咱們還有兩個鉤子沒有實現: getDerivedStateFromProps
和 shouldComponentUpdate
,這是由於這兩個生命週期會影響到更新結果,所以須要 深刻到更新流程中,沒法單純的經過 元素節點 的生命週期來實現。
但其實也很簡單,就是在 更新以前,須要根據這兩個函數的返回結果,適當調整下更新邏輯便可:
// Componet 中的 __update 方法 class Component { // ... public __update = () => { // 臨時存儲 舊虛擬節點 (oldVNode) const oldVNode = this.__vnode this.__nextProps = this.props if (!this.__nextState) this.__nextState = this.state // 執行 getDerivedStateFromProps // 更新 state 狀態 const cls = this.constructor if (cls.getDerivedStateFromProps) { const state = cls.getDerivedStateFromProps(this.__nextProps, this.state) if (state) { this.__nextState = Object.assign(this.__nextState, state) } } // 在 diff 以前調用 shouldComponentUpdate 進行判斷 // true: 生成新 VNode,繼續 diff // false: 清空狀態 if (this.shouldComponentUpdate(this.props, this.__nextState)) { // 從新生成 新虛擬節點(newVNode) this.__vnode = this.__createVNode() // 調用 diff,更新 VNode diff(oldVNode, this.__vnode) } else { // 清空狀態更新 this.__nextProps = this.__nextState = undefined } // 剛纔 異步更新隊列 中標識的組件 待更新狀態 // 在更新完後置爲 false this.__dirty = false } }
組件更新還有另一個地方,即 diffComponent
,也須要加入上述相似的執行和判斷。完成這部分代碼後,咱們來簡單測試個 DEMO:
<App>
、<BBB txt={this.state.txt} />
正確渲染;<App> setState
,<BBB>
文字元素正確更新;
<p style="text-align: center;font-weight: bold;">圖1. 生命週期演示DEMO</p>
在這系列文章中,咱們實現了 React 最核心的部分: JSX、組件、渲染、更新。咱們基於 動手實踐 的方式,按部就班地探討了一些原理與策略,得出一些最佳實踐。相信走完這遍旅程後,你們能對 React 有了更深層次的瞭解,可以給到各位小夥伴啓發與幫助。其實我也同樣,也是在這個旅程中跟你們一塊兒共同窗習,共同成長。
還有許多模塊,如 Context
、Refs
、Fragment
和 一些全局API,如 cloneElement
等,還有代碼中一些更嚴謹的判斷及邊界狀況的處理,並無在文章中體現,主要是因爲這些部分更多的是純邏輯的擴展,同時也是爲了便於理解。若是童鞋們有興趣,能夠到 github 中查看完整版代碼:
另外我也想稍微嘮嗑下關於 React-WebGL 這個想法。
近階段我接觸了一些 Web 遊戲的開發,有了一些從前端開發者出發的思考與理解。在遊戲開發領域,傳統的遊戲開發者有着一套與前端領域徹底不一樣的思惟編程模式。隨着 Web 的發展,使他們須要拓展到 Js 的環境中。因此出現了一系列的遊戲引擎庫,本質上是從其它平臺的庫移植過來的。當我從一個前端開發者的角度在進行開發時,其實並非說入門難,學習成本高,而是給個人感受是: 相似用純原生 js 在寫頁面,以爲效率低下。因此這也是 React-WebGL 的出發點,指望能將如今 Web 中更優秀的理念運用到遊戲開發,甚至找到一種更高效的開發模式,提高效率,完善生態。
固然,這僅僅只是一個起點, 遊戲開發 與 界面開發 確實有着許多異同點,如何找到一種更現代化更高效的 Web 遊戲開發模式,這還須要很長的一段旅程。我也一直在思考,一直在摸索,相信能有一些好玩的東西。沒有嘗試,沒有努力,就千萬別在起點就放棄了。有什麼問題,有什麼想法,直接找我一塊兒探討哈。🙃~~
Tips:看博主寫得這麼辛苦下,跪求點贊、關注、Star!更多文章猛戳 ->
郵箱: 159042708@qq.com 微信/QQ: 159042708
[祝福#感恩#武漢加油##RIP KOBE#]()