vue3源码系列(五)——创建虚拟dom(createVNode, h)

4,189 阅读4分钟

前言

在之前的几篇文章中,我们介绍了vue3的响应式模块的源码,接下来将介绍vue3dom渲染相关的源码。vue3的每一个模块代码都可以单独打包的,例如前面的相应式模块就在reactive文件夹中。而现在介绍的dom渲染的相关的源码在runtime-core和runtime-dom这两个文件夹中。其中runtime-core存放的是运行时核心实例相关代码(与平台无关),runtime-dom存放的是操作dom的一些API,及事件处理。

入口

import { createApp } from "vue";
import App from "./App.vue";
const app = createApp(App);

app.mount("#app");

以上是通过vite创建一个vue项目的main.ts文件。带着createApp(App)做了什么?app.mount("#app")又做了什么?这两个问题阅读本文。以下仅为源码核心代码

// rendener.ts文件
const render = (vnode, container) => { // 将虚拟节点 转化成真实节点渲染到容器中
    // 后续还有更新 patch  包含初次渲染 还包含更新
    patch(null, vnode, container);// 后续更新 prevNode nextNode container
}
 
return {
    createApp: createAppAPI(render), // 创建一个api createApp
    render
}



import { createVNode } from "./createVNode";

export function createAppAPI(render) {
    return (rootComponent, rootProps) => { // rootComponent:我们传入的App,为组件类型,rootProps:createApp方法的第二个参数,表示根prop参数(一般为空)
        let isMounted = false;
        const app = { // app对象上有很多方法,如:mount, use, mixin,components,unmount,directive等,这里只介绍mount
            mount(container) { // container为传入的#app,表示组件渲染到#app的容器中
                // 1.创造组件虚拟节点 
                let vnode = createVNode(rootComponent, rootProps); // h函数
                // 2.挂载的核心就是根据传入的组件对象 创造一个组件的虚拟节点 ,在将这个虚拟节点渲染到容器中
                render(vnode, container)
                if (!isMounted) {
                    isMounted = true;
                }
            }
        }
        return app
    }
}

以上可知,createApp(App)会返回一个提供应用上下文的应用实例,该应用实例挂载的整个组件树共享同一个上下文。运行app.mount('#app')实际上会运行createVNode方法,拿传入的rootComponent(跟组件)去创造相应的虚拟dom。接下来讲解createVNode方法。

createVNode

createVNode方法主要功能就是创造虚拟dom,在介绍之前先看看js中的与、或运算

export const enum ShapeFlags {
  ELEMENT = 1, // 元素
  FUNCTIONAL_COMPONENT = 1 << 1, // 函数式组件  向左移1位 00000010
  STATEFUL_COMPONENT = 1 << 2, // 普通组件      向左移2位 00000100
  TEXT_CHILDREN = 1 << 3, // 孩子是文本         向左移3位 00001000
  ARRAY_CHILDREN = 1 << 4, // 孩子是数组        向左移4位 00010000
  SLOTS_CHILDREN = 1 << 5, // 组件插槽          向左移5位 00100000
  TELEPORT = 1 << 6, // teleport组件            向左移6位 01000000
  SUSPENSE = 1 << 7, // suspense组件            向左移7位 10000000
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT 	// 组件   00000100 | 00000010 = 00000110
}
// createVNode.ts文件
export function createVNode(type, props, children = null) { // type: 为对象时,表示类型为组件,为字符串时,表示为元素  props:表示要渲染的这个dom上的属性


    // 虚拟节点就是 用一个对象来描述信息的  

    // & | 
    const shapeFlag = isObject(type) ?
        ShapeFlags.COMPONENT :
        isString(type) ?
        ShapeFlags.ELEMENT :
        0

    const vnode = { // 跨平台    虚拟dom的格式
        __v_isVNode: true,  // 标志这个是一个虚拟dom
        type, // 代表要渲染的dom的类型
        shapeFlag, // 之后渲染dom的类型的标志
        props, // 储存渲染的dom上的属性
        children,
        key: props && props.key, // key值, dom的唯一标识
        component: null, // 如果是组件的虚拟节点要保存组件的实例
        el: null, // 虚拟节点对应的真实节点
    }
    if(children){
        // 告诉此节点 是什么样的儿子 
        // 稍后渲染虚拟节点的时候 可以判断儿子是数组 就循环渲染
        vnode.shapeFlag =  vnode.shapeFlag | (isString(children) ? ShapeFlags.TEXT_CHILDREN:ShapeFlags.ARRAY_CHILDREN)
    }
    // vnode 就可以描述出来 当前他是一个什么样的节点 儿子是什么样的
    return vnode; // createApp(App)
}

以上createVNode方法就是来创建虚拟dom的。虚拟dom创造好了之后,就要渲染真实dom了,接下来我们回到createAppAPI方法中:

export function createAppAPI(render) {
    return (rootComponent, rootProps) => {
        let isMounted = false;
        const app = {
            mount(container) {
                // 1.创造组件虚拟节点 
                let vnode = createVNode(rootComponent, rootProps); // h函数
                // 2.挂载的核心就是根据传入的组件对象 创造一个组件的虚拟节点 ,在将这个虚拟节点渲染到容器中
                render(vnode, container) // 渲染根据传入的App组件,渲染dom
                if (!isMounted) {
                    isMounted = true;
                }
            }
        }
        return app
    }
}


// render函数
const render = (vnode, container) => { // 将虚拟节点 转化成真实节点渲染到容器中
        // 后续还有更新 patch  包含初次渲染 还包含更新
        patch(null, vnode, container);// 后续更新 prevNode nextNode container
    }

在运行createVNode方法生成虚拟dom后,调用render函数去渲染dom,而render方法是通过patch(下文将会介绍)方法去渲染dom的。下图为虚拟dom的大致结构:

截图_16686751627517.png

h方法

熟悉vue的小伙伴肯定对h方法不陌生吧,有时候业务需要的时候,我们会使用它去创造虚拟dom。其实在源码中,h函数也是调用createVNode方法去生成虚拟dom的。看源码之前我们先看看一般我们是怎么使用h函数的。

h('div',{color:red})
h('div',h('span'))
h('div','hello')
h('div',['hello','hello'])
// 从上面示例可知h函数的参数个数是不固定的,且含义是不固定的,只能确定第一个参数是要渲染的dom元素
export function h(type, propsOrChildren, children) {
    let l = arguments.length; // 获取h函数参数的个数
    if (l === 2) { // 参数个数为2的情况
        if (isObject(propsOrChildren) && !Array.isArray(propsOrChildren)) { // 当参数个数为2,判断第二个参数的类型,当为object并且不是数组时,第二个参数代表的含义有两种
            if (isVNode(propsOrChildren)) {  
                return createVNode(type, null, [propsOrChildren])// 第二个参数为虚拟dom时,格式为:h('div',h('span'))
            }
            return createVNode(type, propsOrChildren);  // 当第二个参数为props时, 格式为: h('div',{color:red})
        } else {
            return createVNode(type, null, propsOrChildren); // 当第二个参数为非对象或者为数组时, 第二个参数代表孩子节点, 格式为: h('div','hello')   h('div',['hello','hello'])
        }
    } else {
        if (l > 3) { // 当参数个数大于3的情况
            children = Array.prototype.slice.call(arguments, 2); // 获取第三个(包含第三个)参数,并组成一个数组
        } else if (l === 3 && isVNode(children)) { // 参数长度为3,并且类型为虚拟dom,把它包装成数组格式
            children = [children] 
        }
        return createVNode(type, propsOrChildren, children);
    }
}

总结

createVNode是vue3源码中创建虚拟dom的方法,而h函数是暴露给开发者使用来自己创造虚拟dom的,其内部主要是对开发者传入的参数进行转化,最后依然是调用createVNode去创建虚拟dom。