VNode与Component

3,099 阅读9分钟

本文同步在个人博客shymean.com上,欢迎关注

在前面两篇文章中,我们研究了VNode的基础知识,了解了如何使用VNode描述并渲染视图,实现了递归diff和循环diff两种方案,并在循环diff中给出了一种简单的调度器实现方案。本文将紧接上两篇文章,一步一步思考并实现将VNode封装成组件。

本系列文章列表如下

排在后面文章内会大量采用前面文章中的一些概念和代码实现,如createVNodediffChildrendoPatch等方法,因此建议逐篇阅读,避免给读者造成困惑。本文相关示例代码均放在github上,如果发现问题,烦请指正。

一个关于封装VNode的问题

在之前出现的createRoot等测试方法中,隐约已经出现了这种封装VNode的设计,我们用上一篇文章中的例子来解释一下(虽然我并不想把相同的代码再粘贴一遍~)

// 我们暂且假设这就是一个组件
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
    })
}

在前面的测试代码中,为了方便理解,我们将diffdoPatch等接口都暴露出来了;在实际应用中,我们应该尽可能地减少暴露的接口,避免额外的学习成本,因此我们将这段代码封装一下。

首先我们合并createRootcreateList和初始化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),在后面的文章中会详细介绍与实现。

需要注意的第二个问题是:此处我们也创建了一个INSERTpatch,但与元素节点不同的是,组件节点的type是一个类,并不能直接通过createDOM的方法进行实例,我们应该如何处理这种组件节点的patch呢?

我在这里尝试过几种做法

  • 为组件的子节点创建一个额外的包裹DOM节点,作为组件节点的$el属性,这种做法会修改真实的DOM结果,建议不采纳
  • 组件节点的$el引用子节点的$el,这要求组件render方法返回的是一个单元素节点;或者组件节点的$el引用父节点的$el,同样这要求组件的父级节点也是一个元素节点。这两种方法都必须修改doPatchinsertDOM方法,判断相关的临界条件(如子节点插入组件节点等)
  • 组件节点的$elnull,不挂载任何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属性,对于INSERTREMOVE类型的patch,我们将其交给父DOM节点和子元素节点进行处理

经过上面的处理,在尽可能少地改变原有diffdoPatch方法的情况下,我们扩展了vnode.type属性,并增加了组件类型的节点。这是十分有意义的一步,基于这个经验我们还可以实现Function ComponentFragment等多种类型的组件形式。

更新组件节点

解决了初始化的问题后,接下来看看组件更新时的情况。理想情况下,当改变数据时,我们希望能够直接更新视图,而不是像篇头那样手动diff,因此我们增加一个公共的setState方法,方便所有组件统一处理更新逻辑,为此我们将setState方法放在公共的Component基类上。

setState中,我们将diffdoPatch方法,与初始化不同的是,更新时的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()
}

当然,在封装组件时,更建议不要使用这种外部数据来源,组件应该保持足够的独立性,这样可以方便复用与迁移。

当组件依赖外部数据时,我们可以通过状态管理或contextprops等方案进行管理,在下一篇文章中,我们将介绍组件除了state之外的其他数据来源与实现方法。

小结

组件是现在web应用中必不可少的一部分,大量的UI框架均是基于组件搭建的,只有了解了组件的设计思路,才能更好的使用与开发组件。

本文主要从封装VNode开始思考如何封装组件,然后通过扩展vnode.type设计了组件节点,然后根据组件节点的子节点是动态添加和更新的特性,通过改动diffFiberdoPatch方法,完成了组件节点的初始化、挂载与更新。最后给出了减少组件节点更新的两个方法:shallowCompareshouldComponentUpdate,以及强制更新组件的forceUpdate方法。

较前面两篇文章相比,本文改动的代码不多,主要是考虑了关于组件设计的一些思路,并在尽可能少地修改原代码的前提下扩展支持组件节点,在前面文章的基础之上,思考并给出了自己关于组件的一些实现思路,如果发现问题,烦请指正。

在下一篇文章中,将实现如props等组件特性,研究组件之间通信、HOC组件设计等问题,思考如何正确地封装组件。