本文同步在我的博客shymean.com上,歡迎關注node
在前面兩篇文章中,咱們研究了VNode的基礎知識,瞭解瞭如何使用VNode描述並渲染視圖,實現了遞歸diff和循環diff兩種方案,並在循環diff中給出了一種簡單的調度器實現方案。本文將緊接上兩篇文章,一步一步思考並實現將VNode封裝成組件。git
本系列文章列表以下github
排在後面文章內會大量採用前面文章中的一些概念和代碼實現,如createVNode
、diffChildren
、doPatch
等方法,所以建議逐篇閱讀,避免給讀者形成困惑。本文相關示例代碼均放在github上,若是發現問題,煩請指正。web
在以前出現的createRoot
等測試方法中,隱約已經出現了這種封裝VNode的設計,咱們用上一篇文章中的例子來解釋一下(雖然我並不想把相同的代碼再粘貼一遍~)app
// 咱們暫且假設這就是一個組件
function createRoot(data) {
let list = createList(data.list)
let title = createFiber('h1', {}, [data.title])
let root = createFiber('div', {}, [title, list])
return root
}
// 同理,這是一個createRoot組件依賴的list子組件
function createList(list) {
let listItem = list.map((item) => {
return createFiber('li', {
key: item
}, [item])
})
return createFiber('ul', {
class: 'list-simple',
}, listItem)
}
// 初始化組件
let root = createRoot({
title: 'hello fiber',
list: [1, 2, 3]
})
// 初始化應用
root.$parent = {
$el: app
}
diff(null, root, (patches) => {
doPatch(patches)
})
// 更新應用
btn.onclick = function () {
let root2 = createRoot({
title: 'title change',
list: [3, 2]
})
diff(root, root2, (patches) => {
doPatch(patches)
root = root2
})
}
複製代碼
在前面的測試代碼中,爲了方便理解,咱們將diff
、doPatch
等接口都暴露出來了;在實際應用中,咱們應該儘量地減小暴露的接口,避免額外的學習成本,所以咱們將這段代碼封裝一下。框架
首先咱們合併createRoot
、createList
和初始化root節點等邏輯,將其封裝爲一個叫作App
的類,並約定調用new App().render()
會返回與前面createRoot
相同的root節點dom
class App {
state = {
title: 'hello component',
list: [1, 2, 3]
}
// render方法返回的是一個vnode節點,並由Root實例維護渲染和更新該節點須要的數據及方法
render() {
// 根據state時初始化數據
let { list, title } = this.state
let listItem = list.map((item) => {
return createFiber('li', {
key: item
}, [item])
})
let ul = createFiber('ul', {
class: 'list-simple',
}, listItem)
let head = createFiber('h1', {}, [title])
return createFiber('div', {}, [head, ul])
}
}
複製代碼
根據慣例,咱們約定將組件的數據放在state
中。而後咱們再提供一個統一的初始化方法,封裝初始化應用的相關邏輯,取名爲renderDOM
異步
// 將根組件節點渲染到頁面上
function renderDOM(root, dom, cb) {
if (!root.$parent) {
root.$parent = {
$el: dom
}
}
// 初始化時直接使用同步diff
let patches = diffSync(null, root)
doPatch(patches)
cb && cb()
}
複製代碼
正常狀況下,咱們按照下面方式調用,就能夠正常初始化應用了函數
let root = new App().render()
renderDOM(root, document.getElementById("app"), () => {
console.log('inited')
})
複製代碼
看起來咱們的App
類並無提供什麼實質性的幫助,由於如今咱們只是將createRoot
方法變成了App.proptotype.render
方法而已。學習
接下來看看更新數據的邏輯,咱們但願在App
中可以維護數據更新的邏輯,下面的代碼邏輯爲點擊h1
標籤時,預期可以修改其標題值,因此咱們在初始化該標籤的時候註冊changeTitle
事件
// render
let head = createFiber('h1', {
onClick: this.changeTitle
}, [title])
複製代碼
遵循UI = f(state)
的原則,咱們但願修改state,而後將改動的state同步到視圖上
changeTitle = () => {
this.state.title = 'change title from click handler'
// todo
}
複製代碼
在前兩篇文章測試更新的例子中,咱們會經過新的數據從新調用createRoot
,獲取新的vnode,而後diff(old, newVNode)
,而後進行doPatch
changeTitle = () => {
this.state.title = 'change title from click handler' // 更新數據
let old = this.vnode
let root = this.render() // 只不過是將`createRoot`換成了`this.render`
diff(old, root, (patches) => {
doPatch(patches) // 確實也可以實現視圖的更新
})
}
複製代碼
嗨呀好氣,在renderDOM
中的封裝都白費了!!
儘管咱們能夠再提供一個相似於updateDOM
的方法來處理更新的邏輯,但歸根結底都沒有實現真正的封裝。換言之,咱們不該該在應用代碼中手動調用render
。到底該如何封裝這一堆包含邏輯的VNode呢?
我對於組件的理解是: 將一堆VNode
節點進行封裝。
回頭看一看doPatch
的源碼,咱們好像只處理了真實DOM:根據VNode.type
建立DOM節點,而後將其插入到父節點上。咱們爲什麼不試一試擴展type
呢?
假設咱們按照下面的方法初始化應用
// 如今root.type === App
let root = createFiber(App, {}, [])
renderDOM(root, document.getElementById("app"))
複製代碼
按照這種調用規則,在內部,咱們會執行實例App、初始化應用、將視圖渲染到頁面上。咱們將這種type
爲類的節點取名爲組件節點。
// 根據type判斷是是否爲自定義組件
function isComponent(type) {
return typeof type === 'function'
}
複製代碼
在以前的diff
方法中,咱們都是提早經過諸如createRoot
等方法獲取了整個VNode節點樹,但對於組件節點而言,其子節點列表並非簡單地經過createFiber
傳入的children
屬性得到,而是須要經過在diff過程當中,經過調用new App().render()
方法動態獲取獲得。
基於這個問題,咱們須要在修改以前的diffFiber
方法,增長初始化組件實例、調用組件實例的render
方法、更新組件節點的子節點列表
function diffFiber(oldNode, newNode, patches) {
// 組件節點的子節點是在diff過程當中動態生成的
function renderComponentChildren(node) {
let instance = node.$instance
// 咱們約定render函數返回的是單個節點
let child = instance.render()
// 爲render函數中的節點更新fiber相關的屬性
node.children = bindFiber(node, [child])
// 保存父節點的索引值,插入真實DOM節點
child.index = node.index
}
if (!oldNode) {
// 當前節點與其子節點都將插入
if (isComponent(newNode.type)) {
let component = newNode.type
let instance = new component()
instance.$vnode = newNode // 組件實例保存節點
newNode.$instance = instance // 節點保存組件實例
renderComponentChildren(newNode)
}
patches.push({ type: INSERT, newNode })
}
// ... 更新的時候下面再處理
}
複製代碼
咱們根據node.type
初始化了組件實例,並將其綁定到組件節點的$instance
屬性上,而後在renderComponentChildren
方法中,經過調用$instance.render()
方法,獲取子節點列表,因爲咱們採用的是循環diff策略,所以還須要在bindFiber
方法中爲節點添加fiber相關屬性
function bindFiber(parent, children) {
let firstChild
return children.map((child, index) => {
child.$parent = parent // 每一個子節點保存對父節點的引用
child.index = index
if (!firstChild) {
parent.$child = child // 父節點保存對於第一個子節點的引用
} else {
firstChild.$sibling = child // 保存對於下一個兄弟節點的引用
}
firstChild = child
return child
})
}
複製代碼
OK,回到正題,經過node.children = instance.render()
方法,咱們爲組件節點關聯了子節點列表,在後面的performUnitWork
的diff任務中,程序將可以遍歷動態插入的子節點,從而收集到相關的patch
。
須要注意的第一個問題是:咱們在這裏拋棄了組件節點本來的children
屬性
// 在renderComponentChildren中,咱們重置了fiber.children屬性,致使 [child1, child2]丟失
createFiber(App, {}, [child1, child2])
複製代碼
在Vue中,這種子節點被稱爲slot
;在React中,這種節點爲props.children
。這種設計能夠用來實現HOC
(Higher Order Component),在後面的文章中會詳細介紹與實現。
須要注意的第二個問題是:此處咱們也建立了一個INSERT
的patch
,但與元素節點不一樣的是,組件節點的type是一個類,並不能直接經過createDOM
的方法進行實例,咱們應該如何處理這種組件節點的patch呢?
我在這裏嘗試過幾種作法
$el
屬性,這種作法會修改真實的DOM結果,建議不採納$el
引用子節點的$el
,這要求組件render
方法返回的是一個單元素節點;或者組件節點的$el
引用父節點的$el
,一樣這要求組件的父級節點也是一個元素節點。這兩種方法都必須修改doPatch
的insertDOM
方法,判斷相關的臨界條件(如子節點插入組件節點等)$el
爲null
,不掛載任何DOM實例;反之,當子節點插入到組件節點時,插入到組件節點的第一個真實DOM節點便可第三種作法應該是最容易理解與實現的,所以本文采用了這個策略處理組件節點,首先咱們在建立createDOM
時直接爲DOM節點返回null
function createDOM(node) {
let type = node.type
return isComponent(type) ?
createComponent(node) :
isTextNode(type) ?
document.createTextNode(node.props.nodeValue) :
document.createElement(type)
}
// 建立組件的DOM節點,在最後的策略中,決定讓組件節點不攜帶任何DOM實例,及vnode.$el = null
function createComponent(node) {
return null
}
複製代碼
而後在處理INSERT
操做時,交給其向上第一個DOM父級元素節點和向下第一個DOM字節元素節點處理,同理REMOVE
操做也須要這麼處理
// 從父節點向上找到第一個元素節點
function findLatestParentDOM(node) {
let parent = node.$parent
while (parent && isComponent(parent.type)) {
parent = parent.$parent
}
return parent
}
// 從當前節點向下找到第一個元素節點
function findLatestChildDOM(node) {
let child = node
while (isComponent(child.type)) {
child = child.$child
}
return child
}
// 插入節點,統一處理元素節點與組件節點的INSERT操做
function insertDOM(newNode) {
let parent = findLatestParentDOM(newNode)
let parentDOM = parent && parent.$el
if (!parentDOM) return
let child = findLatestChildDOM(newNode)
let el = child && child.$el
let after = parentDOM.children[newNode.index]
after ? parentDOM.insertBefore(el, after) : parentDOM.appendChild(el)
}
// 刪除節點
function removeDOM(oldNode) {
let parent = findLatestParentDOM(oldNode)
let child = findLatestChildDOM(oldNode)
parent.$el.removeChild(child.$el)
}
複製代碼
至此,咱們完成了組件的初始化操做,總結一下
diffFiber
的方法中,咱們判斷組件節點,並根據node.type
獲取組件實例,經過調用實例的render
方法獲取組件節點的子節點,最後更新fiber相關屬性,方便在performUnitWork
中能夠經過fiber.$child
遍歷render方法中動態加入的節點doPatch
中,咱們決定不設置組件節點的$el
屬性,對於INSERT
和REMOVE
類型的patch,咱們將其交給父DOM節點和子元素節點進行處理通過上面的處理,在儘量少地改變原有diff
和doPatch
方法的狀況下,咱們擴展了vnode.type
屬性,並增長了組件類型的節點。這是十分有意義的一步,基於這個經驗咱們還能夠實現Function Component
、Fragment
等多種類型的組件形式。
解決了初始化的問題後,接下來看看組件更新時的狀況。理想狀況下,當改變數據時,咱們但願可以直接更新視圖,而不是像篇頭那樣手動diff
,所以咱們增長一個公共的setState
方法,方便全部組件統一處理更新邏輯,爲此咱們將setState
方法放在公共的Component
基類上。
在setState
中,咱們將diff
和doPatch
方法,與初始化不一樣的是,更新時的diff操做是經過調度器管理異步實現的,所以咱們再增長一個回調函數,方便在視圖更新完畢後通知應用。
因爲在diff節點時須要從根節點開始進行對比,所以咱們經過appRoot
全局變量保持對於應用根節點的引用。
function renderDOM(root, dom, cb) {
if (!appRoot) {
appRoot = root // 保存整個應用根節點的引用
}
// ...
}
複製代碼
而後實現setState
方法
class Component {
setState(newState, cb) {
this.nextState = Object.assign({}, this.state, newState) // 保存更新後的屬性
// 從根節點開始進行diff,當碰見Component時,須要使用新的props和props更新節點
diff(appRoot, appRoot, (patches) => {
doPatch(patches)
cb && cb()
})
}
}
class App extends Component {
// 封裝diff
changeTitle = () => {
this.setState({
title: 'change title from click handler1'
}, () => {
// 組件更新完畢後會調用該回調
console.log('done1')
})
}
// ... 省略其餘方法如render等
}
複製代碼
能夠看見與以前的diff(root1,root2)
方法不一樣的是,咱們在這裏diff的新舊子節點都是appRoot
,換言之,節點的變化是在diff
過程當中才觸發的。爲了實現這個目的,在setState
中,咱們將更新後的數據掛載到this.nextState
上,在diff
過程當中,咱們須要檢測組件節點是否存在該屬性,若是存在,則應該使用新的state
調用render
方法,再diff先後state渲染的新舊子節點列表。
咱們來補全diffFiber
方法
function performUnitWork(fiber, patches) {
let oldFiber = fiber.oldFiber
// 在diffFiber中會更新組件節點的children屬性,所以須要在此處提早保留舊的子節點列表
let oldChildren = oldFiber && oldFiber.children || []
// 任務一:對比當前新舊節點,收集變化
diffFiber(oldFiber, fiber, patches)
// 任務二:爲新節點中children的每一個元素找到須要對比的舊節點,設置oldFiber屬性,方便下個循環繼續執行performUnitWork
diffChildren(oldChildren, fiber.children, patches)
//...
}
function diffFiber(oldNode, newNode, patches) {
if(!oldNode){
// ... 上面初始化章節的相關邏輯
}else {
// 更新時
if (isComponent(newNode.type)) {
// 組件節點須要判斷狀態是否發生了變化,若是已變化,則須要對比新舊組件子節點
let instance = oldNode.$instance
// 更新時,複用組件實例
newNode.$instance = oldNode.$instance // 複用組件實例
let nextState = instance.nextState
if (nextState) {
instance.state = nextState // 在此處更新組件的state
instance.nextState = null
renderComponentChildren(newNode) // 從新調用render方法,綁定新的子節點
} else {
// 未進行任何修改,則直接使用以前的子節點
newNode.children = oldNode.children
}
} else {
// ..上一篇fiber與循環diff中元素節點的相關邏輯
}
}
}
複製代碼
能夠看見,對於元素節點而言,更新是將改動的屬性應用的DOM節點實例上;對於組件而言,咱們須要更新組件的state
,而後從新調用組件的render
方法獲取新的children
子節點。
至此,咱們就完成了組件state更新的封裝。整理一下流程
this.setState(newState)
方法將須要更新的狀態掛載到this.nextState
上diffFiber
中,對於組件節點,咱們根據newState
更新組件實例的state
並從新調用render
方法,而後更新組件節點的子節點列表,進入後面的diffChildren
流程doPatch
更新變化,因爲組件節點的DOM實例自己就不存在,咱們甚至不須要修改doPatch
中的任何代碼須要注意的是,因爲咱們是在performUnitWork
中才更新了組件實例的state
,所以能夠將setState
也理解爲異步執行,這個緣由致使咱們在調用setState
中沒法直接觀察到state的變化。關於fiber調度器相關問題,能夠閱讀上一篇文章:Fiber與循環diff
changeTitle = () => {
this.setState({
title: 'change title from click handler1'
}, () => {
console.log('done1') // 不會被執行
})
console.log(this.state.title) // 原來的hello component
this.setState({
title: 'change title from click handler2'
}, () => {
// 此時訪問到的纔是更新後的state.title
// 因爲調度器實現了重複調用`diff`時會重置上一次的diff流程,所以上面的done1永遠沒法執行
console.log('done2')
})
console.log(this.state.title) // 一樣是原來的 hello component
}
複製代碼
在上面的組件更新邏輯處理中,咱們只是單純地根據了組件實例是否攜帶newState
屬性進行處理,實際上在下面幾種場景下,咱們須要額外考慮是否更新的問題
對於第一個問題,咱們能夠參照元素節點的UPDATE
,經過判斷state值的是否改變來決定是否設置this.nextState
// 實現一個淺比較
function shallowCompare(a, b) {
if (Object.is(a, b)) return true
const keysA = Object.keys(a)
const keysB = Object.keys(b)
if (keysA.length !== keysB.length) return false
const hasOwn = Object.prototype.hasOwnProperty
for (let i = 0; i < keysA.length; i++) {
if (!hasOwn.call(b, keysA[i]) ||
!Object.is(a[keysA[i]], b[keysA[i]])) {
return false
}
}
return true
}
class Component {
setState(newState, cb) {
// 保存須要更新的狀態
let nextState = Object.assign({}, this.state, newState)
// 判斷新舊屬性是否相同
if (!shallowCompare(nextState, this.state)) {
this.nextState = nextState
// 調用diff和doPatch
}
}
}
複製代碼
對於第二個問題,咱們能夠爲組件提供一個shouldComponentUpdate
的接口,在編寫組件時若是實現了該方法,則在diffFiber
時會調用根據其返回值判斷當前組件節點是否須要更新
let shouldUpdate = instance.shouldComponentUpdate ? instance.shouldComponentUpdate() : true
if (nextState && shouldUpdate) {
// ... 更新state,調用render
}
複製代碼
在render
方法中,咱們能夠經過組件的state
屬性初始化相關的VNode節點,並經過setState
更新數據,同時觸發視圖的更新。但若是某些VNode節點依賴於外部數據源,則setState
就無能爲力了,在下面的例子中,點擊countButton
並不會觸發視圖的更新
let outerCount = 0
class App extends Component {
addCount = () => {
outerCount++
}
render(){
let countButton = createFiber('h1', {
onClick: this.addCount
}, [outerCount + ' times click'])
let children = [countButton]
let vnode = createFiber('div', {
class: 'page'
}, children)
return vnode
}
}
複製代碼
爲此,咱們須要實現一個forceUpdate
的接口處理這個問題,與上面「減小沒必要要的更新」章節相反的是,在調用forceUpdate
時,不管是否存在nextState
或者shouldComponentUpdate
返回了什麼值,咱們都會強制調用render方法,由於咱們爲組件實例額外實現一個_force
屬性。
// 因爲setState和forceUpdate都須要這個方法,咱們將其抽出來
function diffRoot(cb) {
// 從根節點開始進行diff,當碰見Component時,須要使用新的props和props更新節點
diff(appRoot, appRoot, (patches) => {
doPatch(patches)
cb && cb()
})
}
class Component {
// 當render方法中依賴了一些外部變量時,咱們沒法直接經過this.setState()方法來觸發render方法的更新
// 所以須要提供一個forceUpdate的方法,強制執行render
forceUpdate() {
this._isforce = true
diffRoot(() => {
this._isforce = false
})
}
}
複製代碼
而後修改diffFiber
中的更新判斷
if ((nextState && shouldUpdate) || instance._isforce) {
// ... 調用render
}
複製代碼
這樣,咱們就實現了組件的強制更新,修改前面的測試代碼,點擊按鈕,就能夠看見在修改外部數據時同時更新視圖。
addCount = () => {
outerCount++
this.forceUpdate()
}
複製代碼
固然,在封裝組件時,更建議不要使用這種外部數據來源,組件應該保持足夠的獨立性,這樣能夠方便複用與遷移。
當組件依賴外部數據時,咱們能夠經過狀態管理或context
、props
等方案進行管理,在下一篇文章中,咱們將介紹組件除了state
以外的其餘數據來源與實現方法。
組件是如今web應用中必不可少的一部分,大量的UI框架均是基於組件搭建的,只有瞭解了組件的設計思路,才能更好的使用與開發組件。
本文主要從封裝VNode開始思考如何封裝組件,而後經過擴展vnode.type
設計了組件節點,而後根據組件節點的子節點是動態添加和更新的特性,經過改動diffFiber
和doPatch
方法,完成了組件節點的初始化、掛載與更新。最後給出了減小組件節點更新的兩個方法:shallowCompare
與shouldComponentUpdate
,以及強制更新組件的forceUpdate
方法。
較前面兩篇文章相比,本文改動的代碼很少,主要是考慮了關於組件設計的一些思路,並在儘量少地修改原代碼的前提下擴展支持組件節點,在前面文章的基礎之上,思考並給出了本身關於組件的一些實現思路,若是發現問題,煩請指正。
在下一篇文章中,將實現如props
等組件特性,研究組件之間通訊、HOC
組件設計等問題,思考如何正確地封裝組件。