VNode与Component
在前面两篇文章中,我们研究了VNode的基础知识,了解了如何使用VNode描述并渲染视图,实现了递归diff和循环diff两种方案,并在循环diff中给出了一种简单的调度器实现方案。本文将紧接上两篇文章,一步一步思考并实现将VNode封装成组件。
本系列文章列表如下
排在后面文章内会大量采用前面文章中的一些概念和代码实现,如createVNode
、diffChildren
、doPatch
等方法,因此建议逐篇阅读,避免给读者造成困惑。本文相关示例代码均放在github上,如果发现问题,烦请指正。
一个关于封装VNode的问题
在之前出现的createRoot
等测试方法中,隐约已经出现了这种设计,我们用上一篇文章中的例子来解释一下(虽然我并不想把相同的代码再粘贴一遍~)
// 我们暂且假设这就是一个组件
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节点
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的type
我对于组件的理解是: 将一堆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呢?
我在这里尝试过几种做法
- 为组件的子节点创建一个额外的包裹DOM节点,作为组件节点的
$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
属性进行处理,实际上在下面几种场景下,我们需要额外考虑是否更新的问题
- 当数据新旧state相同时,也许并不需要重新调用render方法
- 开发者希望根据某些数据手动控制组件是否渲染
对于第一个问题,我们可以参照元素节点的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
组件设计等问题,思考如何正确地封装组件。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。