欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

深入理解Runtime:Vue3中的核心秘密

最编程 2024-01-18 21:09:09
...

Hi!各位好久不见,我是 S_G

前面的两篇帖子:通过实现最简reactive,学习Vue3响应式核心都用过ref和computed,但你懂它的原理吗? (没看过可以去看下哈)收获了不小的成就感,xdm的反响让我有了继续更文的动力。

为了建立一个完整的知识体系,这篇帖子将继续探究Vue真正的核心:runtime 。这是会困扰很多人的地方,也是Vue代码量聚集程度最高的模块之一,难度要高上不少。但是本着能把东西讲懂的原则,我们还是循序渐进的实现这个模块。文中可能会不时的穿插一些源码,以及源码的地址如果感兴趣可以继续深入学习

鉴于复杂程度和篇幅问题,这一篇只会探究组件的挂载相关内容(这堆就够多了),组件的更新不会涉及。因为内容很多比如diff之类的,全写在一篇里面显得颇为仓促,所以会在下一节继续探究更新的相关操作

阅读建议:

runtime模块是Vue的核心之一,在难度上要高于先前学习的reactivity模块,因此学习此篇帖子的知识点的时候需要慢慢来,建议先移步到帖子末尾的总结-阅读总结部分,在此我对阅读本篇帖子提供了一些思路

全篇约17500余字,覆盖runtime模块的核心内容,学习完此篇内容,你能够充分理解Vue框架原理。无论是在团队中,亦或是在面试都能让你在这方面脱颖而出

破冰

1. 总览

前两篇我们完善了reactivity模块,对于响应式系统相信大家已经有了一个基本的认知了,也能够清晰的认识到一个数据是如何被包装成响应式对象,以及在视图中一个数据的变化是如何被及时更新的。如果已经忘记了,可以先去看看 通过实现最简reactive,学习Vue3响应式核心 这一篇,可以帮你正确且快速的建立起响应式系统的知识!

现在我们来看下这幅图,这是Vue3模块间关系的结构图。其实你会发现Vue的核心部分其实不就是三个部分嘛!compilerruntimereactivity 。我们已经完成了reactivity,接下来需要攻破的就是compilerruntime了。那这两个模块都干了什么呢?

1. Compiler

Compiler直接能够见文生意了,也就是编译。所谓编译模块就是帮助我们进行组件的编译的。举个例子哈,我们来看下面的代码

<template>
</template>
<script setup>
</script>
<style>
</style>

这是Vue项目中再正常不过的一个SFC的代码了

SFC:Single File Component 单文件组件指扩展名为.vue的文件

但是系统真的能理解你的template写的是什么吗?恐怕不太行,所以需要对模板进行一个编译,这部分要涉及一大坨编译原理部分的内容,并不是本篇主要探究的知识,如果xdm想要了解,可以把评论活跃起来,我随后研究一下!

编译后的内容就可以被Vue的创建函数的API所理解所使用了。

2. Runtime

那么问题来了,模板被编译了之后,肯定需要挂载,需要更新等 一系列的流程吧!不然就只是编译出来了内容你不处理也不行呀!

别急!runtime就是做这个的

runtime模块会对render的内容做出处理并帮我们挂载到页面上,如果不熟悉创建组件的知识点可以结合着下面的内容及官方文档创建一个 Vue 应用 | Vue.js学习一下

文档给出的示例是这样写的

//// App.vue
<template>
    <div id="app">
        1
    </div>
</template>

import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)
app.mount("#app")

注意!这个App.vue也是个SFC。所以编译模块也还是要介入的。编译模块会对这个模板进行解析,并得出能够被runtime模块所消费的数据

编译后的结果就是这样的

import { createApp } from 'vue'
// 编译后
const App = {
    render(proxy) {
        return h('div', {id: "app"}, 1)
    }
}

const app = createApp(App)
app.mount("#app")

编译后的产物是一个对象,其中有一个属性就是render函数了。之后在调用app实例的mount方法时,runtime就会通过调用编译产物的render方法进行节点的挂载!

至于render里面调用的h函数是什么我们等下再说。但到此为止相信你已经理解了runtimecompiler模块分别是干什么的了

2. 熟悉

关于组件的创建(比如createApp)模块相关的内容,我认为大多数xdm都会忽略这部分的学习,所以在继续推进实现API之前,要真正熟悉并理解相关的API的使用方式

  1. createApp应用实例 API | Vue.js

    用处:创建应用,需要接收两个参数

    • 第一个参数是根组件
    • 第二个参数是传递给根组件的props

    函数会返回一个实例对象,其中包含着用于挂载节点的mount函数,还有关于插件系统的use函数等...

  2. h渲染函数 API | Vue.js

h 函数是渲染的关键,用于创建虚拟的DOM节点,官网举了很多例子。熟练的掌握h函数可以像React一样更灵活的使用语法,非常有意思。

import { h } from 'vue'
// class 与 style 可以像在模板中一样
// 用数组或对象的形式书写
h('div', { class: [foo, { bar }], style: { color: 'red' } })

// 事件监听器应以 onXxx 的形式书写
h('div', { onClick: () => {} })

// children 可以是一个字符串
h('div', { id: 'foo' }, 'hello')

// 没有 prop 时可以省略不写
h('div', 'hello')
h('div', [h('span', 'hello')])

// children 数组可以同时包含 vnode 和字符串
h('div', ['hello', h('span', 'hello')])
  1. setup组合式 API: setup() | Vue.js

setup函数是V3中CompositionAPI的入口,可以说在这里是你编写组件逻辑的地方,它能够接收两个参数:

  • 第一个是props,组件间传递的属性
  • 第二个是context,该组件独属的上下文,基于此可以获取该组件相关的:attrsslotsemitexpose属性,它们都是干什么的就不细说了,还是看文档吧!

如果对这些内容感到陌生一定要去看一下,这是V3的基础知识。如果对这部分知识点不熟悉,后面的内容是无法继续进行的!组合式 API:setup() | Vue.js (vuejs.org)

3. 理论准备

让我们把视角切换回本标题开始的那张图上,很容易发现我们所说的三大核心之一的runtime模块被划分为了两个部分,分别是runtime-domruntime-core

有个疑问:为什么用来渲染的runtime模块会被进行划分?

首先明确!Vue编写的代码是能够跨平台使用的,比如你可以使用Vue来开发Web微信小程序(uniapp)native等....

明确了能够跨平台使用,那么思考一下:为什么能跨平台使用?

好!好极了!为什么可以跨平台呀?就拿Web小程序来说创建元素的方式都是不一样的呀!怎么可能使用同一套东西来创建节点。

  • 对,因为它根本就不是使用同一套东西创建的节点,那么是利用的什么东西来跨平台呢?

    • 利用的就是我们上面看到的runtime-core。这个模块是渲染的核心,无论你使用的是什么平台都能够正确的利用你所提供的操作方法创建节点并挂载页面,其中所应用的技术就是虚拟节点技术(vNode),虚拟节点并不关心是什么平台,而是对该节点的一个特征描述。
  • 那么你提供的操作方法都放在了哪里呢?

    • 就是runtime-dom!对于Web来说就是runtime-dom,对于其他平台比如小程序runtime-wxnativeruntime-shared,其他的就不一一列举了。

那么回到开始的那个问题:为什么要对runtime模块进行划分?现在你明白了吗!

现在我们明确了(鉴于跨平台特性,以Web平台为例):

runtime模块要分为两个部分:

  • runtime-core模块是真正的渲染核心,它并不关心是什么平台,其内部应用了虚拟节点技术,对你所要渲染的节点进行了特征描述,为跨平台提供了可能。
  • runtime-dom模块是平台提供的节点操作模块,在这个模块中定义了类似于节点的创建,插入,删除或者节点的属性修改等方法,随后调用runtime-core模块的方法继续进行后面的流程

由一个创建的流程开始,引出相关API的实现

import { createApp } from '@vue/runtime-dom'

<div id="app"></div>

const App = {
    setup(props, context) {
        const state = reactive({
            name: 'SG',
            age: 21,
        })
        
        return {
            state
        }
    },
    
    render(proxy) {
        return h('div', { style: { color: 'red' } }, [
            h('p', 'SG'),
            h('p', 'PG'),
            h(
              'button',
              {
                onClick: () => {
                  console.log(proxy.state)
                  proxy.state.name = 'PG'
                },
              },
              proxy.state.name,
            ),
          ])
        },
    }
}


createApp(App, { school: "TJUT" }).mount("#app")

上面的流程中进行了如下的流程:

  • 创建出一个组件:它拥有render方法和setup方法
    • render方法调用了h函数创建节点数据
    • setup函数中调用响应式API创建响应式数据,然后返回该响应式数据
  • 创建一个应用,随后将#app作为容器进行了挂载

那么我们从外至里,像剥洋葱一样,一层层的来实现它

先映入眼帘的就是createApp方法了,这部分最先涉及到创建应用部分的内容,因此顺水推舟,先来探究runtime-dom的实现机制

runtime-dom

在上一节已经明确:runtime-dom模块主要的能力就是提供操作节点的方法给runtime-core模块,因此你可以理解runtime-dom其实就是系统在调用runtime-core模块的一个缓冲区。真正的核心实现是在core模块完成的。程序猿通过runtime-dom中的createApp函数调用core模块里的方法来实现这个过程

1. createApp

createApp函数是Vue中创建应用的函数,因此需要接收一个组件以及应用于这个组件的属性作为参数。

实际上Vue应用并不是在runtime-domcreateApp中被创建的,而是调用了runtime-core模块的相关API创建应用,这个API叫做createRenderer,官方称之为自定义渲染器,它会返回一个有着createApp函数作为属性的对象

注意:虽然都叫createApp但是做的事情是不一样的,注意区分!!!

由于易混,文中提到createApp函数的时候我会注明模块归属

image.png

所以runtime-dom模块中的createApp函数的目的:

就是调用runtime-corecreateRenderer函数,并利用它返回的createApp方法创建实例,随后利用这个createApp方法返回的对象中的mount函数挂载页面。

那么目的明确,而且也不难,所以就直接来实现一下吧!

import { createRenderer } from '@vue/runtime-core'
function createApp(rootComponent, rootProps) {

    //// 创建应用,createRender方法会返回一个 有着creatApp作为方法的 对象
    //// 调用createApp才是真正的创建了一个App
    const app = createRenderer(..平台相关的操作..).createApp(rootComponent, rootProps)
    
    //// 拿到App实例上的mount方法,准备挂载页面
    const { mount } = app
    
    //// 向外提供mount方法,实际上这个方法是调用的runtime-core创建的实例提供的mount方法
    app.mount = function(container) {
        //// 拿到容器
        container = document.querySelector(container)
        //// 清空模板
        container.innerHTML = ''
        //// 调用runtime-core模块创建的应用实例所返回的mount方法
        //// 这个过程会发生虚拟DOM到真实DOM的转变
        mount(container)
    }
}

由代码可示runtime-dom模块中的createApp其实就做了两件事情:

  • 调用createRender函数并传入这个平台所支持的节点操作方法,然后调用渲染器返回的createApp方法,传入根组件和应用于这个组件的属性,至此一个Vue的应用实例被创建好了!
  • 调用由createApp构造的应用实例上返回的mount方法,这个过程会发生虚拟DOM真实DOM的转变并挂载页面,至此你所创建的组件已经被成功挂载到页面上了

2. rendererOptions

在调用createRenderer的时候,传入了当前平台对于节点的操作方法,那么这些方法到底是什么呢?这一小节来看一下!

首先想一想,对于一个节点都有什么操作方法呀!

  • DOM相关的创建、删除、查询要有吧,节点的content文本内容更新要有吧
  • 节点的属性、style、class、event的创建和更新要有吧

起个名字就叫rendererOptions:渲染相关的配置项

那么就可以对这两部分内容分别处理了

1. 节点操作:nodeOps

其实就是一揽子节点操作的方法,这部分很简单,很快就OK啦!

const doc = (typeof document !== 'undefined' ? document : null)

const nodeOps = {
    //// 查询
    querySelector: (selector) => doc.querySelector(selector),

    //// 元素创建
    createElement: (tagName) => doc.createElement(tagName),
    
    //// 节点移除
    remove: (child) => {
      const parent = child.parentNode
      if (parent) {
         parent.removeChild(child)
      }
    },
    
    //// 插入节点
    insert: (child, parent, anchor) => {
      parent.insertBefore(child, anchor || null)
    },
   
    //// 设置元素的文本内容
    setElementText: (el, text) => (el.textContent = text),
    
    //// 文本节点操作
    createText: (text) => doc.createTextNode(text),
    setText: (node, text) => (node.nodeValue = text),
}

不求多,就先把能用到的操作写上就好了

2. 属性操作:patchProps

import { patchAttr } from './modules/attrs'
import { patchClass } from './modules/class'
import { patchStyle } from './modules/styles'
import { patchEvent } from './modules/event'

//// 如果是以on开头的文本就返回true(绑定的事件)
const isOn = v => /^on[^a-z]/.test(v)

const patchProps = (el, key, prevProps, nextProps) => {
    if(key === 'class') {
        //// 类名的修改
        patchClass(el, nextValue)
    } else if(key === 'style') {
        //// 样式的修改
        patchStyle(el, prevValue, nextValue)
    } else if(isOn(key)) {
        //// 使用isOn判断是否是一个事件的绑定
        //// 因为绑定的事件都是以on开头 --> onClick
        patchEvent(el, key, nextValue)
    } else {
        //// 属性的修改
        patchAttr(el, key, nextValue)
    }
}

由于节点的修改可能会涉及styleclasseventattr等等,所以我们需要对这些地方分别进行处理

patchClass

const patchClass = (el, value) => {
    if(value == null) {
        //// 如果value为空移除class属性
        el.removeAttribute("class")
    } else {
        //// 添加属性
        el.classNames = value
    }
}

patchStyle

源码中还对部分内容根据特定场景进行了自定义的preFix,感兴趣可以去看看runtime-dom/src/modules/style.ts

const patchStyle = (el, prev, next) => {
    const style = el.style 
    const isCssString = isString(next)
    
    if(prev && next == null) {
        //// next为null,并且前一时刻还是有值的
        //// 那就意味着要移除
        el.removeAttribute("style")
    } else {
        if(isCssString) {
            //// 如果是个css文本内容
            //// 使用cssText设置值
            style.cssText = next
        } else {
            //// 移除旧属性
            if(prev) {
                for(const k in prev) {
                    if(next[k] == null) {
                        style[k] = '' 
                    }
                }
            }
        
            //// 添加新属性或更改值
            if(next) {
                for(const k in next) {
                    const p = next[k]
                    style[k] = p
                }
            }
        }
    }
}

patchEvent

//// 在这个模块需要对事件进行处理
//// 因为可能要涉及到事件的更新(更换监听事件)
//// 所以我们需要对函数进行一个缓存,先看这个没有缓存的

const patchEvent = (el, key, value) {
    //// key 传递进来的都是监听函数的名称:onClick、onLoad
    const evnetName = key.slice(2).toLowerCase()
    if(value) {
        //// 添加事件
        el.addEventListener(eventName, value)
    } else {
        //// 内容为空移除事件
        el.removeEventListener(eventName, ???)
    }
}

drama了吧!你要移除事件,那之前的事件是什么呢?

所以知道我们刚开始说的为什么要对事件进行缓存了吧

那怎么进行一个缓存呢?利用一个外部变量?

当然不行,整个系统又不是只有你一个事件。那看来可以将事件的缓存挂载到这个模板节点本身嘛!那么就可以创建一个对象利用对象引用数据类型的特性,对事件进行缓存对更改进行同步

好了!且看我如何操作!

const patchEvent = (el, key, value) => {
    //// 将invoker挂载到el节点上,它是一个对象,因为el被绑定的事件不见得只有一个
    //// 下次再patch的时候就直接取出这个挂载的属性即可
    //// 如果没有这个属性那就是需要创建这个invoker了
    const invokers = el._vei || (el._vei = {}) // vei == Vue Event Invoker
    
    //// 查看这个事件是否存在
    const existingInvoker = invokers[key]
    
    if(existingInvoker && value) {
        //// 这个invoker存在且value也是存在的
        //// 表明需要对事件进行更新
        existingInvoker.value = value
    } else {
        // 获取事件名称 onClick -> click
        const eventName = key.slice(2).toLowerCase()
    
        //// 没有创建过Invoker 或者 没有 value
        //// 这意味着需要创建Invoker或者移除监听事件
        if(value) {
            //// 创建Invoker
            let invoker = (invokers[key] = createInvoker(value))
            el.addEventListener(eventName, invoker)
        } else {
            //// value为空,移除事件 并 将invoker置为undefined则自动会被回收
            //// 利用invoker缓存即可
            el.removeEventListener(eventName, existingInvoker)
            existingInvoker[key] = undefined
        }
    }

}

//// 利用invoker.value对事件本身进行缓存
//// 事件调用的时候也是调用缓存 invoker.value() 本身
//// 这个value就是回调
function createInvoker(value) {
    const invoker = e => {
        invoker.value(e)
    }
    
    //// 缓存
    invoker.value = value 
    return invoker 
}

patchAttr

const patchAttr = (el, key, value) => {
    if(value == null) {
        el.removeAttribute(key)
    } else {
        el.setAttribute(key, value)
    }
}

现在对于节点的操作和更新时做的patch流程在runtime-dom模块就定义完成了,接下来的内容就全权交给runtime-core即可了

导出rendererOptions

//// 由于patchProps是个函数,要将其都变成对象再导出
export const rendererOptions = Object.assign({patchProps}, nodeOps)

runtime-core

再回看runtime-dom模块的createApp都用了哪些东西

  • createRenderer函数用了吧!
  • createRenderer返回的createApp函数用了吧!
  • 这个createApp函数返回的mount函数用了吧!

看来还没少用,那就依次来实现一下吧。但在继续推进之前,我希望能先明确各个函数的需求背景,清楚了各个函数需要干什么,才能知道为什么要这么写!

1. 理论准备

  1. createApp

在该函数中需要返回一个App的对象实例,其中包含我们前面写到的mount方法,其实createApp函数的目的就是为了创建一个应用的实例,然后将这个实例返回出去。

要注意的是,在我们后面的实现中并不会参照源码补全 全部的属性和方法,这是因为有些东西我们当前用不到也并不需要,同时还会带来理解成本。来看一下应用实例上都有什么!

  1. mount

mount函数中,要进行的操作实际上就有两个步骤

  1. 调用createVNode函数创建根组件的虚拟DOM节点,该createVNode函数会返回一个虚拟节点对象

  2. 调用render渲染器函数,将虚拟节点挂载到提前已给定的容器中,这个render并不是mount函数内置的,而是createRenderer函数内的方法。

    实际上createApp函数的实现逻辑并不是在createRenderer函数中定义的,而是调用的createAppAPI函数创建的createApp 这个函数

    函数返回函数?

    这很明显就是HOF的产物嘛!如果你翻看Vue3的源码会很明显,高阶函数的实践几乎无处不在,包括我们在实现reactive函数的时候也是由HOF去做的嘛!

在这里只需有个印象,清楚createApp函数的逻辑不在createRenderer中,这个render函数是由createRenderer传递给创建createApp的函数即可。在后续实现功能的时候如果你看不懂为什么要这么做,可以回看一下这个讲解,相信会明白用意

将创建createApp函数的逻辑分离开来,让代码的职责分化更加明确,createRenderer函数所在的文件被称为renderer,主管的内容就是渲染相关的操作。而创建应用实例的操作被分离出去也就可以理解了

  1. createRenderer

铺垫完了前两个函数,就轮到说了半天的自定义渲染器函数了。

在源码中这是最让人“血脉喷张”的部分了!

诶好像没问题?不对再看看!2000多行的一个函数,吓得要命????

createRenderer函数是进行主要的渲染逻辑以及组件有关的patch 流程的函数,前言提到createApp函数中的mount挂载函数所调用的这个render渲染器函数就是createRenderer函数所传递的。

简单画一个他们之间的模块调用,可以参照一下

暂时不关心renderpatch流程是什么样的,因为它会复杂得多,先把图中加上前面涉及到的名词能够对照清楚就可以继续进行下一步了。

2. 具体实现

这部分可能读起来有点乱,如果没看懂希望可以多看几次一定可以看懂,最重要的就是要结合上下文,将这个知识链串起来

1. createRenderer & createApp

源码中是利用 baseCreateRenderer函数创建的这个渲染器函数,这是因为除了我们想要实现的createRenderer 之外,Vue还利用baseCreateRenderer的逻辑实现了createHydrationRenderer函数,它的核心逻辑也是baseCreateRenderer的逻辑,所以利用HOF,对这堆逻辑进行复用,createHydrationRenderer 函数是SSR中用于“注水”的函数,并不是我们要实现的内容,因此不对此进行研究!

所以这里和源码的出入点之一就是为了方便:将baseCreateRenderer的逻辑直接写入createRenderer ,因此我后续书写的代码中也不会出现baseCreateRenderer函数

好了,言归正传!

在这个函数中我们需要返回一个有着createApp函数作为属性的对象,这个函数就是在runtime-dom中调用的创建应用的函数。因此可以先把架子搭起来。

function createRenderer(rendererOptions) {
    return {
        createApp: () => {}
    }
}

在创建应用的时候我们传入了两个属性,分别是根组件和赋予根组件的props,因此createApp函数可以接收到两个参数

function createRenderer(rendererOptions) {
    return {
        createApp: (rootComponent, rootProps) => {}
    }
}

如果你留心前面的内容就会发现我说createApp函数的逻辑是由createAppApi函数创建的,并且这个函数的目的就是为了返回一个有着任意多个属性和方法的app实例,这个实例其中包括usemount方法等。

如果忘记了,可以翻看一下本节的理论准备和后面的导图部分

所以继续进行拆分

// render.ts
function createRenderer(rendererOptions) {
    return {
        createApp: createAppApi
    }
}

// createAppApi
function createAppApi() {
    return function createApp(rootComponent, rootProps) {
        const app = {
            _props: rootProps,
            _component: rootComponent,
            _container: null,
            mount(container) {
                // do somethings what...
            }
        }
    
        return app
    }
}

至此,createApp函数的框架就搭建完毕,有了createApp函数的基础之上才能继续补全核心的createRenderer函数。

下一步就是创建mount函数

2. mount

曾记否:挂载appmount

runtime-dom中的mount函数通过调用这个模块的mount函数,以达到挂载的需求

其实这个函数的目的就有两个:

  1. 创建虚拟节点
  2. 利用createRenderer函数传递的render函数,渲染内容到页面上

把前面写过的函数拿过来

function createAppApi() {
    return function createApp(rootComponent, rootProps) {
        const app = {
            _props: rootProps,
            _component: rootComponent,
            _container: null,
            mount(container) {
                // do somethings what...
            }
        }
    
        return app
    }
}

要拿到createRenderer函数的render方法就需要利用HOF将渲染函数交给createAppApi函数,这样就可以在mount函数中利用到它

// render.ts
function createRenderer(rendererOptions) {
    function render(vNode, container) {
        // do somethings what...
    }
    return {
        createApp: createAppApi(render)
    }
}

// createAppApi
function createAppApi(render) {
    return function createApp(rootComponent, rootProps) {
        const app = {
            _props: rootProps,
            _component: rootComponent,
            _container: null,
            mount(container) {
                /**
                 * 1. 创建虚拟节点
                 * 2. 利用render函数挂载
                 * 3. 缓存container到实例上
                 */
                const vNode = createVNode(cootComponent, rootProps)
                render(vNode, container)
                app._container = container
            }
        }
        
        return app
    }
}

现在,你就可以利用createVNode函数创建虚拟节点,利用render函数挂载页面了。 那现在面对未知的两个函数,他们分别是怎么做的呢? 别急,下面分别来看

3. createVNode

前面我们提到Vue利用虚拟节点技术,不仅仅减少了Dom的操作频率,提高了性能,同时还为跨平台提供了可能。

createVNode函数就是创建虚拟节点的函数,在这个函数中会对每个节点都进行类型的标记,这个标记也是vue后续进行类型判定的一个根本依据

你可能会对上面说的感觉云里雾里,什么是标记节点类型?

所以在继续编写代码之前,先来写出我们常用来标记的类型,以及在Vue中是如何来标记类型的

  1. ELEMENT 常见的元素,反映到DOM中就是div、span、a....
  2. FUNCTIONAL_COMPONENT 函数形式的组件
  3. STATEFUL_COMPONENT 有状态信息的组件
  4. TEXT_CHILDREN 后代元素是文本内容的数组
  5. ARRAY_CHILDREN 后代元素是数组
  6. COMPONENT = STATEFUL_COMPONENT | FUNCTIONAL_COMPONENT

这些都是比较常见的类型,在Vue中不同的类型就会有不同的处理方式,比如文本就直接创建节点后挂载就好了,比如是组件就需要继续进行后代的遍历创建等....

Vue是如何将这些标记正确注明的呢?

这个标记就是shapFlagsVue通过修改虚拟节点上的shapFlags属性对节点的类型进行记录。

那也不能直接就让shapFlags = ELEMENT吧,那如果它还有后代,假设是一个数组,里面有若干个文本属性,那你岂不是还要对shapFlags进行修改,再让它等于shapFlags = ELEMENT + ARRAY_CHILDREN 这样似的,这样做你觉得好吗?

(为什么要标记子代的类型,后面会用到,先mark一下,不要忘记)

所以Vue采取了一个更为优雅的类型标识方案就是:位运算

先铺垫一下位运算

位运算是按照二进制进行操作的。其中常见的操作包括 按位与(&)、按位或(|)、左移(<<)、右移(>>) 等

  1. 左移:对于一个二进制数字10(十进制的2)来说:左移就相当于将这个二进制数字向左移动一位,末位补零。也就是说经过左移一位后,二进制10变为了100(4)
  2. 右移:对于右移来说,就与左移相反了,对于一个二进制数字10来说,经过右移一位后二进制数字10变为了1
  3. 按位与:在两个二进制数的相应位上执行与的操作,只有当相同位置上的都为1的时候才为1,否则为0。比如二进制数字100和10经过按位与后结果为 000。
  4. 按位或:对于按位或来说,就与按位与相反了,对于二进制数字100和10来说,经过按位或后结果为110

通过上面的铺垫,下面来重写一下类型

export const enum ShapeFlags {
  ELEMENT = 1, // 1
  FUNCTIONAL_COMPONENT = 1 << 1, // 10
  STATEFUL_COMPONENT = 1 << 2, // 100
  TEXT_CHILDREN = 1 << 3,  // 1000
  ARRAY_CHILDREN = 1 << 4, // 10000
}

 除了上面的几种类型外,还有刚刚提到的COMPONENT 那它是怎么标记的呢?

COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT, // 110

对运算进行 按位或 操作 “|” ,这样STATEFUL_COMPONENTFUNCTIONAL_COMPONENT 分别是二进制100和10,按位或后就变成了 110,这样操作之后,我们就获得了一个拥有两种类型标识的计算结果

如果需要对后代进行标记,那只需要按位或一下对应的类型标识即可轻松的标记出两种类型。

不过,你可能会想这有什么用呢?给你举个例子就明白了

我现在正在进行某元素的挂载,我需要检测我当前的节点是否有子代,并且子代是否是一个文本类型的内容。

  • 如果子代是一个文本内容的节点,我直接就创建个文本节点直接挂载就OK了。
  • 如果子代不是个文本节点,我就需要继续对子代创建虚拟节点后重新挂载。

这就要求我们需要在父节点就标记出后代是什么样的,这时候shapFlags的作用就显而易见了。那如何检测呢?利用按位与!

假设一个div节点有一个后代是一个数组则可以如此操作:

// 标记
shapFlags = ShapeFlags.ELEMENT | ShapeFlags.ARRAY_CHILDREN // 10001

// 检测
shapFlags & ShapFlags.TEXT_CHILDREN  // 00000

利用按位或 方便的同时标记出当前的节点是一个元素它的后代是一个数组

利用按位与 轻松判别出是否存在目标类型

这下相信你一定能明白了Vue中是如何对节点的类型进行判别的。

对类型判别是一个非常重要的事情,元素如何进行创建和正确的更新都离不开类型的判别。

ShapFlags可以说是虚拟节点中的一大要点,也几乎是业界公认的进行类别判定的一个最佳实践,理解了这个知识点就能让你更轻松地阅读源码中的相关内容,同时也能为你在实际的项目开发中对类型操作以及代码质量的优化提供一定的思路。

那接下来就编写一下createVNode函数的逻辑吧

// type 就是 调用函数时候传递的 rootComponent,即根组件,根组件可以是一个组件或者是div等元素节点
function createVNode(type, props, children = null) {
    // 首先要判断是一个组件还是一个元素,同时打上标记
    const shapFlags = isString(type)
        ? ShapFlags.ELEMENT
        : isObject(type)
            ? ShapFlags.STATEFUL_COMPONENT
            : 0
     
     const vnode = {
         __v_isVnode: true,
        type,
        props,
        children,
        el: null,
        key: props && props.key, 
        shapeFlags,
     }
     
     // 格式化子代节点
     children && normalizeChildren(vnode, children)
     
     return vnode
}

function normalizeChildren(vNode, children) {
    let type = 0
    if(isArray(children)) {
        // 如果子代节点是一个数组
        type = ShapFlags.ARRAY_CHILDREN
    } else {
        // 如果子代节点是一个文本
        type = ShapFlags.TEXT_CHILDREN
    }
    
    // 做按位或运算,同时标记子元素的节点类型
    vNode.shapFlags |= type
}

这样的话,创建虚拟节点的操作就结束了,其实虚拟节点本身会记录很多东西。

如果你感兴趣虚拟节点的其他属性可以继续阅读源码 vuejs/core/vnode134行开始的VNode接口有关内容,了解全部有关虚拟节点的属性

4. h

看到这里相信你已经初步了解了h函数的基本用法和好玩儿的操作,那么下面来探究一下h函数是如何实现的

官网中对h函数的描述很简洁:就是用来创建虚拟节点的函数。

经过上一节的学习,我们已经知道了虚拟节点是什么东西,以及Vue是如何对虚拟节点进行分类标识的。所以对于h函数的产物大家一定不陌生(就是虚拟节点)。同时由于h函数的根本职责就是创建虚拟节点,所以仍然是可以复用我们第三节的createVNode函数的

把前面理论准备有关于h函数用法的代码拿过来

import { h } from 'vue'
// class 与 style 可以像在模板中一样
// 用数组或对象的形式书写
h('div', { class: [foo, { bar }], style: { color: 'red' } })

// 事件监听器应以 onXxx 的形式书写
h('div', { onClick: () => {} })

// children 可以是一个字符串
h('div', { id: 'foo' }, 'hello')

// 没有 prop 时可以省略不写
h('div', 'hello')
h('div', [h('span', 'hello')])

// children 数组可以同时包含 vnode 和字符串
h('div', ['hello', h('span', 'hello')])

h('div', 'SG', 'PG', 'C', 'M')

可以看到h函数的参数传递可以是两个或者三个或者可以更多

  • 第一个参数是元素的类型,或者也可以是虚拟节点对象
  • 第二个参数可以是子元素或者应用于该元素的属性
  • 第三个参数乃至其余的参数都是子元素

尽管h函数可以传递若干个参数,但如果你有若干个子元素的话最好还是利用数组将多个子元素包裹起来进行传递。

清楚了我们需要干什么之后,现在开始编写代码

/**
 * type 表示 元素或者虚拟节点
 * propsOrChildren h 函数第二个参数可以是子元素或者props
 * children 表示 子元素
 */
function h(type, propsOrChildren, children) {
    // 由于传参数量不确定,所以要对参数进行判定,分别处理
    const l = arguments.length
    if(l == 2) {
         // 形参数量为2的情况下,可能的情况是type + props或者type + children
         if(isObject(propsOrChildren) && !isArray(propsOrChildren)) {
             // 表明这是一个对象并且不是一个数组类型,需要判断这个对象是否是一个虚拟节点
             /// isVnode 函数就是判断这个对象是否存在__v_isVnode属性,从而判断是否是一个虚拟节点
             if(isVnode(propsOrChildren)) {
                 // 这表明第二个参数是一个虚拟节点
                 // 因此调用h函数将这个虚拟节点作为children传入,用于创建整体的虚拟节点
                 return createVNode(type, null, [propsOrChildren])
             }
             
             // 对象不是一个虚拟节点,因此这个propsOrChildren就是一个传递给元素的props
             return createVNode(type, propsOrChildren)
         } else {
             // 说明是元素的子节点
             return createVNode(type, null, propsOrChildren)
         }
    } else {
        if(l > 3) {
            // 如果参数超过三个那就表明这是多个子元素,所以截取成为一个数组
            children = Array.prototype.slice.call(argument, 2)
        } else if(l == 3 && isVnode(children)) {
            // 单个子元素
            children = [children]
        }
        // 生成虚拟节点
        return createVNode(type, propsOrChildren, children)
    }
}

h函数除了可以自定义创建虚拟节