runtime-core中的虚拟节点

1,903 阅读9分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

hello 大家好,🙎🏻‍♀️🙋🏻‍♀️🙆🏻‍♀️

我是一个热爱知识传递,正在学习写作的作者,ClyingDeng 凳凳!

我们知道runtime-dom内部功能其实是 将渲染时所需要节点操作的 API (即rendererOptions) 传入到runtime-core中。

上篇文章我们可以大概知道 runtime-dom 中节点操作属性API有哪些,并试着实现部分API功能。runtime-core 是实现与平台无关的运行时功能(即runtime-core中的节点渲染)。

本文讲述的内容是:实现 runtime-core 中的createAppAPI,完成虚拟节点的创建,以及render中的挂载所需参数的获取。

将API传入到runtime-core中

我们再来看下上篇文章的例子:

<div id="app"></div>
<script src="./runtime-dom.global.js"></script>
<script>

        let { createApp, h, ref } = VueRuntimeDOM
        function useCounter() {
            const count = ref(0)
            const add = () => {
                count.value++
            }
            return { count, add }
        }
        let App = {
            props: {
                title: {}
            },
            setup() {
                let { count, add } = useCounter()
                return { count, add }
            },
            // 每次更新重新调用render方法
            render(proxy) {
                return h('h1', { onClick: this.add, title: proxy.title }, 'hello dy' + this.count)
            }
        }
        let app = createApp(App, { title: 'dy' }) // 组件 组件参数
        app.mount('#app')
</script>

用户通过解析runtime-dom中的 createApp,在createApp中接收组件和组件传入的属性参数,对根节点进行初始化渲染。

那这样的话,我们在runtime-dom需要对外暴露一个createApp方法,内部实现根据相应参数实现真实节点的初始化工作。

/**
 * 
 * @param component 组件
 * @param rootProps 传入的属性
 */
export const createApp = (component, rootProps = null) => { }
// 将runtime-core中所有API导出
export * from '@vue/runtime-core'

createRenderer 初始化

此时,我们就需要创建一个渲染器createRenderer。在渲染器中实现一个mount挂载的方法。看下面的结构:

export function createRenderer(rendererOptions) {
    // 虚拟节点转化成真实节点 渲染到容器中
    const render = (vnode, container) => {
    }
    // 创建节点相关的api mount use mixin
    const createAppAPI = (render) => {
        return (rootComponent, rootProps) => {
            let app = {
                mount(container) { },
                use() { },
                mixin() { },
                component() { }
            }
            return app
        }
    }
    return {
        render,
        createApp: createAppAPI(render)
    }
}

createAppAPI中是组件相关的API,比如mount、use、mixin等,在createAppAPI内部我们肯定还需要元素挂载实现的render方法。

我们可以看到官网中的createRenderer API,中对外暴露了 render 和 createApp两种方法,其实runtime-core中内部也是这样实现的。

image.png

当然我们只是先将空架子搭建起来,后面还需要一步步填充功能。

createApp 内部实现

把上面例子来出来:

let app = createApp(App, { title: 'dy' }) // 组件 组件参数
app.mount('#app')

继续回到createApp这个方法:

createRenderer实现一个渲染器,我们通过 createRenderer 可以解构其中对外暴露的方法,在 createApp 内部进行初始化,最后将其返回,用户再通过mount对元素进行挂载。

const { createApp } = createRenderer(rendererOptions)
let app = createApp(component, rootProps) // 渲染器完成 将其返回

在初始化的时候我们通过用户传入的#app,进行初始化。在执行用户mount前,我们在createApp中存在实际元素挂载功能的mount,我们就需要先将其保存下来,在执行用户mount时,在其内部进行实际的元素挂载(即app.mount内部执行我们实际mount功能)。

createApp内部:

let { mount } = app // 自己的
app.mount = (containerOrSelector) => { // 用户传入的 #app
        const container = nodeOps.querySelector(containerOrSelector)
        if (!container) return 
        container.innerHTML = ''
        mount(container)
    }

在挂载的时候,我们需要清空当前节点的内部元素,再将其挂载。

完整的createApp:

export const createApp = (component, rootProps = null) => {
    const { createApp } = createRenderer(rendererOptions) // 将core中的createApp解构出来
    // 创建一个渲染器 把功能传递给runtime-core
    let app = createApp(component, rootProps)
    let { mount } = app // 调用的是core中的mount
    // app.mount 用户初始化的mount ==> app.mount("#app");
    // 在用户的app.mount中再去执行core中的mount ==> core中就会存在domAPI  需要渲染的组件 目标属性,最后再去挂载到容器中
    /**
     * containerOrSelector 用户传入的容器
     */
    app.mount = (containerOrSelector) => { // 用户传入的 #app
        const container = nodeOps.querySelector(containerOrSelector)
        if (!container) return // 不为空直接返回
        // 在挂载之前清空
        container.innerHTML = ''
        // 调用的是core中的mount  处理节点后将container传入mount
        mount(container)
    }
    return app
}

这样我们 createRenderer 就可以拿到节点操作的DOM API(rendererOptions)、需要渲染的组件(component)、目标属性(rootProps)和需要挂载的位置-容器(container)。createRenderer也就可以独立出来,放到runtime-core中,在core中我们单独去实现元素的渲染。

runtime-core

视角转到我们core中,在core中我们需要实现一个渲染器。目录结构如下:

image.png

我们主要实现createRenderer这个功能对外暴露的两个API,可以先来看看createApp中的 createAppAPI 。

image.png

createAppAPI

在 createAppAPI 中,我们目前需要实现真实元素的挂载。

说的通俗一点:挂载的核心就是根据传入的组件对象,创造一个组件的虚拟节点,再将这个虚拟节点渲染到容器中。

具体分两步走:

mount(container) {
    // 1:创建组件虚拟节点
    const vnode = cerateVNode(rootComponent, rootProps)// h函数
    // 2:虚拟节点渲染到容器中
    render(vnode, container)
}

cerateVNode 创建组件虚拟节点

我们首先要知道,虚拟节点是一个数据对象,用一个数据结构来对元素进行描述,当前元素是组件还是元素、它的字节点、携带的参数等有哪些。

类型表示

我们需要判断传过来的type是组件还是元素,改如何表示呢?🤔🤔🤔

在源码中,通过ShapeFlags这个枚举类就已经将类型描述的非常清楚了。我们可以查看源码中的packages/shared/src/shapeFlags.ts这个文件:👇👇👇

export const enum ShapeFlags {
  ELEMENT = 1, // 元素
  FUNCTIONAL_COMPONENT = 1 << 1, // 函数式组件    010
  STATEFUL_COMPONENT = 1 << 2, // 普通组件        0100
  TEXT_CHILDREN = 1 << 3, // children 是文本      01000
  ARRAY_CHILDREN = 1 << 4, // children 是 数组 
  SLOTS_CHILDREN = 1 << 5, // children 是 插槽
  TELEPORT = 1 << 6, // teleport 组件
  SUSPENSE = 1 << 7, // suspense 组件
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8, // keep-alive
  COMPONENT_KEPT_ALIVE = 1 << 9, // kept alive
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT // 组件
}

大部分类型我都进行了注释。

有人可能不太理解1 << 1这种表达方式,这种是二进制的表示方法,1 << 1表示1向左移动一位,实际值就是1,同样,那么1 << 2实际值就是4,依此类推。
image.png

这种写法的好处就是,我们需要判断一个值它是什么类型时,可以直接与对应的类型值相与。

举个例子:

问:如何判断当前值011(3) 既是元素又是函数式组件?

理解:用当前值011分别与ShapeFlags.ELEMENT、ShapeFlags.FUNCTIONAL_COMPONENT相与,如果不等于0就是包含。

答:

image.png

3与元素、函数式组件相与均不为0,所以011即时元素也是函数式组件。

同样,如果想表示该类型即时元素也是函数式组件的话,我们可以将两者类型进行组合(即相或)。

ShapeFlags.ELEMENT | ShapeFlags.FUNCTIONAL_COMPONENT就表示既是元素也是函数式组件。

虚拟节点创建

我们调用传入的参数类型这种h函数接收的参数:节点类型type、参数props、子节点children。

h('h1', { onClick: this.add, title: proxy.title }, 'hello dy' )

首先,我们需要判断传入过来的type是元素还是组件。我们这边就先判断是否是对象,如果是对象,那么节点类型就是组件,如果是字符串,那么该节点就是一个元素。

isObject(type) ? ShapeFlags.COMPONENT : isString(type) ? ShapeFlags.ELEMENT : 0

通过类型判断,我们就可以拿到该节点的类型,除此之外,我们还需要它的参数、子节点、组件、真实节点等,并需要加上是否是虚拟节点的标志。

之所以会说runtime-core中是与平台无关的运行时,是因为不管是浏览器、测试环境,都会先将其转成可描述的虚拟节点vnode,再去进行节点对比、渲染等操作。

虚拟节点的创建功能:

export function cerateVNode(type, props, children = null) { 
    // type如果是字符串 元素 ;  type是对象或者函数 == 组件
    const shapeFlag = isObject(type) ? ShapeFlags.COMPONENT : isString(type) ? ShapeFlags.ELEMENT : 0
    // 浏览器或者测试环境都会转成vnode
    const vnode = {
        __v_isVNode: true,  // 是否是虚拟节点
        type, // 节点类型
        props,
        children,
        shapeFlag,
        key: props && props.key, // patch对比需要
        component: null,// 如果是组件的虚拟节点 需要保存组件的实例
        el: null//真实节点
    }
    if (children) {
        // 告诉节点 是什么样的子节点
        // 稍后渲染虚拟节点的时候,可以判断儿子是数组就循环渲染
        vnode.shapeFlag = vnode.shapeFlag | (isString(children) ? ShapeFlags.TEXT_CHILDREN : ShapeFlags.ARRAY_CHILDREN)
    }
    // vnode 描述出来当前是个什么节点,包含的子节点的类型是什么
    return vnode
}

这样我们创建出来的 vnode 就可以描述当前的节点类型了。

render 虚拟节点渲染到容器中

我们拿到虚拟节点后,接下来要做的就是将其渲染到容器中,即 render 的实现。

与之前vue类似,通过patch来实现节点的渲染和更新。我们这次主要实现的是节点的初始化,所以patch传入的老的节点应该为null。

/**
 * 老的虚拟节点 
 * 新的虚拟节点
 * 容器
 */
patch(null, vnode, container)

通过传入的新老vnode和当前的容器,我们需要对新老虚拟节点进行对比,然后判断新节点的类型,进行不同类型的节点创建。

const patch = (n1, n2, container) => {
    if (n1 === n2) return
    const { shapeFlag } = n2
    // 传入的是元素
    if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(n1, n2, container)
    }
    // 组件
    else if (shapeFlag & ShapeFlags.COMPONENT) {
        processComponent(n1, n2, container)
    }
}

目前,我们可以先以组件初始化为例:

const processComponent = (n1, n2, container) => {
    if (n1 === null) {
        // 组件初始化
        mountComponent(n2, container)
    } else {
        // 组件的更新
    }
}

在处理组件的函数中判断,如果没有老虚拟节点则需要进行组件初始化操作,否则我们就需要进行组件的更新操作。

/**
 * 
 * @param initialVNode 组件的虚拟节点
 * @param container 容器
 */
const mountComponent = (initialVNode, container) => {
    // 组件的挂载
    console.log(initialVNode, container);
}

我们可以在组件初始化渲染中看看我们是否拿到上述案例中用户传入的虚拟节点和容器:

image.png 可以看到我们成功获取到了当前的虚拟节点,此时的type就是用户传入的对象props、setup、render;shapeFlag是6,是因为当前节点是组件(函数式组件和普通组件的组合-ShapeFlags.COMPONENT)。

元素的挂载也是同样的判断流程:

image.png

我们也可以看到源码中,不仅仅是对元素和组件情况,完整的vue框架还需要考虑节点类型是文本、注释、静态节点、代码片段等等情况。
image.png

获取到所需参数后,接下来的工作就是创建真实节点。将节点渲染到容器中,就需要用到我们之前实现的节点操作的API-rendererOptions。

// createRenderer函数
  const {
    insert: hostInsert, // 插入节点
    remove: hostRemove, // 删除节点
    patchProp: hostPatchProp,
    createElement: hostCreateElement, // 创建元素
    createText: hostCreateText, // 创建文本
    createComment: hostCreateComment, // 注释
    setText: hostSetText,    // 设置文本中的内容
    setElementText: hostSetElementText, // 设置文本内容
    parentNode: hostParentNode, // 获取父节点
    nextSibling: hostNextSibling, // 兄弟节点
    setScopeId: hostSetScopeId = NOOP, // 设置scope id
    cloneNode: hostCloneNode,
    insertStaticContent: hostInsertStaticContent
  } = rendererOptions

rendererOptions中的节点操作API结构出来,在组件、元素的挂载和更新函数中,根据不同情况创建元素节点、插入节点等DOM操作。

接下来的工作就是根据组件的虚拟节点创建一个真实节点,渲染到容器中。在此主要分为两步骤:1、给组件创建一个组件的实例;2、需要给组件的实例进行赋值操作。然后render渲染到页面上。让我们下篇文章再见分晓吧!👋👋👋

感兴趣的朋友可以关注 手写vue3系列 专栏或者点击关注作者ClyingDeng哦(●'◡'●)!。 如果不足,请多指教。