Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Runtime 核心原理,Vue3真正的烫手山芋 #7

Open
HardenSG opened this issue May 10, 2023 · 0 comments
Open

Runtime 核心原理,Vue3真正的烫手山芋 #7

HardenSG opened this issue May 10, 2023 · 0 comments

Comments

@HardenSG
Copy link
Owner

HardenSG commented May 10, 2023

Runtime

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

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

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

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

阅读建议:

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

全篇约17500余字,覆盖runtime模块的核心内容

破冰

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(rootComponent, rootProps) {
                /**
                 * 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函数除了可以自定义创建虚拟节点以外;在compiler编译的产物中所生成的render函数的返回内容也是由h函数调用生成的虚拟节点,因此h函数的用处非常大。

现在有了这一节的基础,就可以进行挂载方面的学习了

5. render & 补全createRenderer

mount函数中,我们通过createVNode函数创建了根组件的虚拟节点,随后通过render函数对节点进行挂载。这一小节,我们来实现render函数,并补全第一小节的createRenderer函数,这将会是一场硬仗,准备好开始喽!

render函数将会接收两个参数

  1. 根组件虚拟节点
  2. 挂载的容器

render函数将作为一个跳板,将核心逻辑交给真正的patch流程,由patch函数执行处理和挂载逻辑

function render(vNode, container) {
    /**
     *  vNode 为当前的根组件虚拟节点
     *  container为当前的根标签挂载的容器
     */ 
    patch(null, vNode, container)
}
1. patch

patch函数会接收三个参数

  1. n1:旧虚拟节点
  2. n2:当前的虚拟节点
  3. container:容器

实际上源码中的patch函数接收若干个参数,除了我们写出的三个之外,还有其余的参数,如:

不过没关系我们不会去关注额外的参数对当前逻辑的混淆,尽量以最易懂的形式将思路讲清楚

当我们在调用mount函数的时候,mount函数中的render函数会将逻辑派发到patch函数。这时候render函数传递给patch的入参分别为根组件的虚拟节点和挂载的容器,由patch函数对节点的类型进行判断并分类处理。

/**
 * n1 先前的旧虚拟节点
 * n2 当前的虚拟节点
 * container 当前的挂载容器
 */
function patch(n1, n2, container) {
    if(n1 === n2) {
        // 如果两颗节点是相同的
        // 没有patch更新的必要性
        return 
    }
}

思考一下我们会处理的节点都有什么呢?

  • 元素应该会有吧
  • 还可能会有组件
  • 还有可能是文本节点

好了先不去管文本节点,我们先实现前两个。要对虚拟节点判别出类型,这如何做呢?

别忘了虚拟节点中的shapFlags属性!

function patch(n1, n2, container) {
    if(n1 === n2) {
        return 
    }
    
    const { shapFlags, type } = n2
    if(shapFlags & ShapFlags.ELEMENT) {
        // 当前的虚拟节点是一个元素 
        processElement(n1, n2, container)
    } else if(shapFlags & ShapFlags.COMPONENT) {
        // 当前的虚拟节点是一个组件
        processComponent(n1, n2, container)
    }
}

利用按位与运算我们很方便的就判别出了节点的类型。并针对不同的类型采用不同的处理函数,接下来先来看看节点类型为元素的节点是如何被处理的

// processElement
function processElement(n1, n2, container) {
    if(n1 == null) {
        // 初次挂载
        mountElement(n2, container)
    } else {
        // 前一个结点不为空,因此这是更新流程
        // do somthings what....
    }
}

处理函数将挂载的逻辑交给了mountElement函数,通过mountElement函数将先前创建的虚拟节点过渡转变为真实的节点,这一步就需要使用到runtime-dom模块提供的rendererOptions中的节点操作函数

// 在createRenderer函数中解构出来操作函数
funciton createRenderer(rendererOptions) {
    const {
        insert: hostInsert,
        remove: hostRemove,
        patchProps: hostPatchProps,
        createElement: hostCreateElement,
        createText: hostCreateText,
        setText: hostSetText,
        setElementText: hostSetElementText,
      } = rendererOptions
    //....
}

那么有了节点操作的属性之后,现在就可以来编写mountElement函数了

2. 元素挂载
// mountElement
function mountElement(vnode, container) {
    // 都是虚拟节点的属性,忘记了可以回看一下第三小节
    const { props, shapFlags, type, children } = vnode
    // 创建目标元素并初始化模板
    const el = (vnode.el = hostCreateElement(type))
    
    // 给模板添加属性,利用patchProps
    if(props) {
        for(const k in props) {
            hostPatchProps(el, k, null, props[k])
        }
    }
    
    // 检查标记,通过标记判定是否有子元素,以及子元素的类型
    // 仍然可以利用按位与运算
    if(shapFlags & ShapFlags.TEXT_CHILDREN) {
        // 有子元素,且子元素为文本节点
        // 那直接渲染到父元素上即可
        hostSetElementText(el, children)
    } else if (shapFlags & ShapFlags.ARRAY_CHILDREN) {
        // 有子元素,且子元素为一个数组
        ??????
    }
    
    // 将模板插入容器中
    hostInsert(el, container)
}

现在问题来了:对于子元素是一个数组的时候如何进行处理?

你需要思考以下的问题

  • 这个数组可能是一组文本元素的节点,这种情况只需要遍历创建文本节点后挂载即可
  • 这个数组可能是一组h函数创建的虚拟节点数组,这种情况需要依次遍历并且再次进行patch流程进行挂载
  • 这个数组可能是一组文本节点混合h函数创建的虚拟节点数组,这种情况需要进行分别辨识

vue将这个问题的处理函数命名为mountChildren,这个函数的主要目的就是遍历这个数组然后利用创建虚拟节点的函数(createVNode)创建虚拟节点,并利用这个虚拟节点重新进行patch流程。

所以回到我们前面说的,这就是为什么我在说patch函数的时候要强调在render函数调用patch传递的参数是根组件的虚拟节点和根容器。就是因为不仅仅根组件的节点可以进行patch流程,任意的元素或者文本节点都可以进行patch流程,由patch统筹调度,所以对于这一点要务必区分清楚。

那来写一下mountChildren函数吧

// mountElement
function mountElement(vnode, container) {
    const { props, shapeFlags, type, children } = vNode
    .....
    else if (shapFlags & ShapFlags.ARRAY_CHILDREN) {
        // 有子元素,且子元素为一个数组
        mountChildren(children, container)
    }
    
    // 将模板插入容器中
    hostInsert(el, container)
}

// mountChildren
function mountChildren(children, container) {
    for(let i = 0; i < children.length; i++) {
        // 通过调用 normalizeVNode函数 格式化虚拟节点
        const child = normalizeVNode(children[i])
        patch(null, child, container)
    }
}

normalizeVNode函数是为了解决我们前面所思考的数组元素类型的问题,不论元素是文本内容还是虚拟节点,这个函数最终都会返回一个虚拟节点,所以起名为normalize,翻译过来就是格式化。

funciton normalizeVNode(child) {
    // 如果child入参是一个对象的话那它就是一个虚拟节点
    if(isObject(child)) {
        // 如果该组件已经挂载就要调用cloneVNode函数对其进行拷贝
        return child.el == null     
                 ? child
                 : cloneVNode(child)
    }
    // 不是一个虚拟节点,则为文本内容
    // 创建文本的虚拟节点并返回
    return createVNode('text', null, String(child))
}
  • 如果child是一个对象那则表明child是一个虚拟节点,这时候要根据它是否已经挂载做出分别的处理。

    如果未挂载直接返回这个虚拟节点就好了,如果已经挂载了就要使用cloneVNode函数将当前已经挂载的虚拟节点进行一个拷贝。其实就是将当前虚拟节点的全部内容进行复制

  • 如果不是一个对象那就表明确实是一个文本内容,那就只需要创建一个文本的虚拟节点就好了,但是这里和源码中的处理还是有一些出入的,源码并不是直接createVNode('text', null, child) 而是将text替换成了一个Symbol标记。
    同时这个Symbol也可以用来在patch函数中进行文本类别的鉴定。

根据上面的总结,下面对normalizeVNode函数再进行一点修改

 const Text = Symbol.for('v-txt')
 funciton normalizeVNode(child) {
    // 如果child入参是一个对象的话那它就是一个虚拟节点
    if(isObject(child)) {
        // 如果该组件已经挂载就要调用cloneVNode函数对其进行拷贝
        return child.el == null     
                 ? child
                 : cloneVNode(child)
    }
    // 不是一个虚拟节点,则为文本内容
    // 创建文本的虚拟节点并返回
    return createVNode(Text, null, String(child))
}

所以patch函数还会处理文本节点,因此我们先前写的patch函数貌似不太够用了,因为没有对文本节点的内容做出处理。所以再来修改一下patch函数

function patch(n1, n2, container) {
    if(n1 === n2) {
        return 
    }
    
    const { shapFlags, type } = n2
    
    switch(type) {
        case Text: {
            // 利用Symbol标记的Text-鉴别出当前的虚拟节点是一个文本
            // 调用文本的处理函数进行处理即可
            processText(n1, n2, container)
            break;
        }
        
        default: {
            if(shapFlags & ShapFlags.ELEMENT) {
                // 当前的虚拟节点是一个元素 
                processElement(n1, n2, container)
            } else if(shapFlags & ShapFlags.COMPONENT) {
                // 当前的虚拟节点是一个组件
                processComponent(n1, n2, container)
            }
        }
    }
}
// processText
function processText(n1, n2, container) {
    if(n1 == null) {
        // 表明当前的文本是初次挂载
        // 利用调用平台提供的创建函数创建文本节点
        // 利用插入函数插入容器中即可
        hostInsert((n2.el = hostCreateText(n2.children)), container)
    } else {
        // 表明当前的文本内容是进行更新操作
        // do somthing what....
    }
}

现在的patch函数就具备了区分出当前的虚拟节点到底是组件或是元素亦或是文本了,除了processComponent处理组件的函数我们没有讲解以外,创建元素的processElement函数和处理文本内容的processText函数我们已经讲解的差不多了。

3. 组件挂载

在介绍完了元素和文本内容的挂载之后,现在来学习一下,组件的挂载如何来处理。

通过patch函数能够将节点类型为组件的虚拟节点通过调用processComponent函数进行挂载。

那么什么情况下节点类型会被判定为一个组件呢?

还记得我们在第三小节的createVNode函数中对shapFlags的判定依据

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

可以看到是否对类型判定为组件是根据创建虚拟节点时候的type作为依据的。

这个type是什么呢?

  • mount函数中通过调用createVNode函数,创建根组件的虚拟节点,因此这个type就是rootComponent根组件,rootComponent可能是一个组件(对象)或者是一个元素(标签类型的字符串)
  • h函数中调用createVNode函数创建虚拟节点,这个type可能是一个组件或者一个元素

我们暂不考虑其他,实际上这个type可以是vue中处理的所有类型

好!知道了怎么判别为一个组件之后那就需要对组件进行处理了

// processComponent
function processComponent(n1, n2, container) {
    // 处理组件的挂载或者更新
    if(n1 == null) {
        // 将挂载的逻辑交给mountComponent函数
        mountComponent(n2, container)
    } else {
        // 更新组件
        // do somethings what....
    }
}

对于挂载来说,processComponent函数的目的像前面写的processElement函数一样,将挂载的逻辑交给了mountComponent函数

那么接下来我们需要分析一下,mountComponent函数都需要完成哪些事情

  • 对一个组件来讲,它首先要拥有属于自己的一个实例,后续的操作都要去使用这个实例

  • 组件可能会有状态的,也就是定义在setup中的状态,所以要尝试解析数据到实例上

  • 组件有自己的render函数,因此想要真正的渲染我们就需要调用它的render函数,将虚拟节点转变为真实的节点。其实组件不也是由一个又一个的元素构成的嘛!所以挂载组件无非也就是patch组件的虚拟节点的过程。

    同时为了确保状态更新的同时能够同步更新视图,我们要对其进行包裹副作用,这样就能确保在状态变更的时候,依赖能够重新执行

    如果不记得副作用、依赖是什么可以看一下通过实现最简reactive,学习Vue3响应式核心 - 掘金 这一篇帖子,快速的建立一套响应式系统的基础

那现在清晰多了,我们来尝试写一下这段逻辑

function mountComponent(initialVNode, container) {
    // 通过调用createComponentInstance函数创建组件实例
    // 同时记录下虚拟节点中的component
    const instance = (initialVNode.component = createComponentInstance(initialVNode))
    
    // 调用setupComponent函数,尝试将状态解析到实例中
    setupComponent(instance)
    
    // 调用setupRenderEffect函数,创建effect并且还要调用render函数进行patch流程
    setupRenderEffect(instance, container)
}

😂现在又多出了三个未知函数 createComponentInstancesetupComponentsetupRenderEffect

没关系,我们依次来实现吧!

  1. createComponentInstance

函数需要利用虚拟节点创建属于当前组件的实例对象

实际上源码中实例的属性相当多

以我的能力,无法完全补全全部的属性,所以也就不去纠结所有的属性的作用

感兴趣可以翻一下源码部分:runtime-core/component.ts 自470行开始的 ComponentInternalInstance 实例

function createComponentInstance(vnode) {
    const instance = {
        vnode,
        type: vnode.type,
        props: {},
        attrs: {},
        slots: {},
        ctx: null,
        proxy: null,
        subTree: null,
        setupState: {},
        isMounted: false
    }
    
    // 初始化上下文属性ctx
    // ctx 上下文属性用于内部操作
    instance.ctx = { _: instance }
    
    return instance
}

ctx 属性用于存储当前组件的实例信息,也用于内部操作;同时ctx上下文属性可以被子组件访问到。

  1. setupComponent

不一定所有的组件都是具备状态,具备setup函数的

所以我们要对其进行判断是否是一个有状态的组件,应用的操作就是和先前一样的按位与运算

function setupComponent(instance) {
    const { props, children } = instance.vnode
    
    // 初始化props和children,在源码中是通过调用init函数实现的,不过我们最简化实现
    instance.props = props
    instance.children = children
    
    // 利用按位与操作判定当前组件是否是一个具有状态的组件
    const isStateful = instance.vnode.shapFlags & ShapFlags.STATEFUL_COMPONENT
    if(isStateful) {
        setupStatefulComponent(instance)
    }
}

如果组件是一个有状态的组件,所以我们需要执行setup函数并且拿到其中定义的状态(或函数) 或者 准备好即将被调用用于组件渲染的render函数。这个部分的逻辑被命名为setupStatefulComponent

setup 函数是也被允许返回一个函数的,这个函数将被作为组件的渲染函数,用于组件的渲染。

具体信息可以参照官网:与渲染函数一起使用

import { h, ref } from 'vue'

export default {
  setup() {
    const count = ref(0)
    return () => h('div', count.value)
  }
}
// setupStatefulComponent
function setupStatefulComponent(instance) {
    const Component = instance.type
    const { setup } = Component
    
    if(setup) {
        // 有setup函数
        
        // 创建出上下文,其实就是setup函数的第二个参数
        const setupContext = createSetupContext(instance)
        // 将准备好的参数作为入参调用setup函数
        // 对于setup函数的入参感到困惑的,建议还是赶快去看一下官网的setup函数部分
        const setupResult = setup(instance.props, setupContext)
            
        // 将返回的结果作为入参用来调用 handleSetupResult 函数处理状态赋予的逻辑
        handleSetupResult(setupResult)
     } else {
        // 没有setup函数
        finishComponentSetup(instance)
     }
}

// createSetupContext
function createSetupContext(instance) {
    // 函数会返回一个对象,这个对象用来作为setup函数的第二个入参
    return {
        attrs: instance.attrs,
        slots: instance.slots,
        props: instance.props,
        emit: () => {},
        expose: () => {},
    }
}
  • setupStatefulComponent函数中,如果组件存在setup函数,那么接下来会调用setup函数并将返回值作为handleSetupResult函数的入参,这个函数用来处理状态的保存
  • 如果组件不存在setup函数,那就需要进行render函数的准备,以便后续通过调用render函数获取虚拟节点。准备render函数的这个函数命名为finishComponentSetup
  • 那下面分别再来实现一下handleSetupResultfinishComponentSetup函数
// handleSetupResult
function handleSetupResult(instance, setupResult) {
    // 如果返回值是一个函数,这表明setup函数没有返回状态,而是返回了一个render函数
    if(isFunction(setupResult)) {
        // 那么这个组件的渲染函数就是setup函数返回的这个函数
        instance.render = setupResult
    } else if(isObject(setupResult)) {
        // 这说明setup函数返回的是一个状态对象
        // 将状态信息赋予实例的setupState属性
        instance.setupState = setupResult
    }
    
    // 为组件准备渲染函数
    finishComponentSetup(instance)
}
function finishComponentSetup(instance) {
    const Component = instance.type 
    if(!instance.render) {
        if(!Component.render && Component.template) {
            // 编译,这部分涉及到编译模块,不做过多介绍
        }
        
        // 拿到最终的render函数,挂载到实例上
        instance.render = Component.render
    }
}

对应的解释一下finishComponentSetup函数:

如果组件的实例没有render函数,那么就需要去准备render函数了。

  • 如果Component(instance.type 其实就是组件的虚拟节点)没有render函数并且虚拟节点上有template这个属性(就是SFC中的模板template书写元素的地方),那就可以通过compiler编译模块对模板进行编译。得到一个渲染函数
  • 相反则直接对render进行赋值即可

经过此番操作之后我们就正确准备好了render函数!

  1. setupRenderEffect

通过前面两个函数

  • 我们已经成功创建出了一个组件的实例
  • 同时尝试将存在setup函数的组件的状态挂载到自身的组件实例上。
  • 并且已经正确的准备好了render函数

那么下一步就是通过调用组件的render函数,获取到组件的虚拟节点,进行patch的流程。同时为了确保在组件中状态更新的时候能够重新更新视图,我们还要为这段逻辑的外层包上一个effect副作用,这样能确保在状态更新的时候这个函数能够重新执行。

因为如果render函数中用到了响应式的状态,这个effect函数就会被添加到对应状态的依赖数组中。状态变更reactivity模块会自动重新执行依赖数组中的函数

如果感到稀里糊涂还是重温一遍 通过实现最简reactive,学习Vue3响应式核心 - 掘金 ,确保有响应式基础再来学习这部分

// setupRenderEffect函数接收两个入参
// 第一个是组件的实例,第二个是被挂载的容器
function setupRenderEffect(instance, container) {
    // 创建副作用逻辑的函数
    const componentUpdateFn = () => {
        if(!instance.isMounted) {
            // 这代表组件还未挂载,所以要进行挂载流程
            // do somthings what....
        } else {
            // 组件已经挂载,进入更新流程
        }
    }
    
    // 将更新函数挂载到实例上
    instance.update = effect(componentUpdateFn)
}

现在我们需要执行组件实例上的render函数来获取到虚拟节点,获取到虚拟节点后进入patch流程。

不过现在有一个严重的问题:

组件的render函数是可以接收到一个参数的。这个参数可以获取到在组件中定义的状态(也就是setup函数返回的状态信息),以及propsappContext

并且话又说回来了,setup函数的返回值是被我们挂载到实例的setupState属性上的。这和我们日常开发中,在模板中使用的方式不一样(模板会被编译成组件的render函数)

<script>
    export default {
        setup() {
            const name = ref('SG')
            
            return {
                name
            }
        }
    }
</script>

// 正常情况
<template>
    <div>{{ name }}</div>
</template>

// 挂载到setupState且不做处理
<template>
    <div>{{ setupState.name }}</div>
</template>

那我们如何操作才能组织好这个参数呢?

先来确认下这一点:

render函数中的这个入参是可以访问到组件的实例的,同时在获取一些深层数据,比如setupState中的状态属性的时候也不需要我们深层访问:proxy.setupState.name 只需要这样proxy.name 即可
都这么说了,猜到了是怎么做的吗?

没错就是proxy代理,在我们调用setupComponent函数尝试解析setup数据到实例的时候就对我们状态的访问做处理了

// setupStatefulComponent
function setupStatefulComponent(instance) {
    // 将被代理的结果挂载到proxy上,后续就直接取出proxy即可
    // 代理的对象就是我们之前用来缓存实例信息的ctx上下文对象
    // 这个ctx拥有自身实例的所有属性
    instance.proxy = new Proxy(instance.ctx, PublicInstanceProxyHandles as any)

  // .....
}

Vuegettersetter的逻辑提取到了新的文件中 PublicInstanceProxyHandles

// PublicInstanceProxyHandles 
const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k)
const PublicInstanceProxyHandles = {
    get({_:instance}, key) {
        // 获取到实例属性 本篇只以props和setupState为例
        const { props, setupState } = instance
        
        if(key[0] == '$') {
            // 以$开头的属性一般都是供给内部使用的
            // 在源码中对这里进行了更细的操作,为了方便我们就直接拒绝访问了
            return 
        }
        
        // 对访问的属性进行归属判断
        if(hasOwn(setupState, key)) {
            // 属性存在于状态对象中
            return setupState[key]
        } else if(hasOwn(props, key)) {
            // 属性存在于props中
            return props[key]
        } else {
            return undefined
        }
    },
    set({_: instance}, key, value) {
        // 修改值
        const { props, setupState } = instance

        if (hasOwn(setupState, key)) {
          setupState[key] = value
        } else if (hasOwn(props, key)) {
          props[key] = value
        }
    
        return true
    }
}

实际上源码中对于代理的属性还有比如contextdata

这个data就是v2中的data属性,此举是为了向下兼容,如果你对这部分的源码感兴趣,请移步:runtime-core/componentPublicInstance.ts 内容不算多,难度还好,此处不做过多介绍了!

那么我们再需要访问实例相应属性中的内容的时候,就可以使用instance.proxy 代替通过一层层的获取对应属性了

接下来补全先前没写完的setupRenderEffect函数,有了proxy代理之后,我们就可以直接取出这个属性作为render函数的入参了

function setupRenderEffect(instance, container) {
    const componentUpdateFn = () => {
        if(!instance.isMounted) {
            // 这代表组件还未挂载,所以要进行挂载流程
            
            // 获取到实例的状态的代理,以此作为render函数的入参
            const proxyToUse = instance.proxy
            
            // 调用render函数获取虚拟节点,同时初始化实例的subTree属性
            const subTree = (instance.subTree = instance.render.call(proxyToUse, proxyToUse))
        
            // 进入patch流程
            patch(null, subTree, container)
        
            // 修改是否挂载属性
            instance.isMounted = true
        } else {
            // 组件已经挂载,进入更新流程
        }
    }

    // 将更新函数挂载到实例上
    instance.update = effect(componentUpdateFn)
}

现在你就可以成功的挂载一个组件了!😍

3. 完整代码

下面我们来把runtime-core模块中我们刚刚所讲的核心代码写出来,方便xdm再捋一遍

1. renderer

import { ShapeFlags } from '@vue/shared'
import { createAppApi } from './apiCreateApp'
import { createComponentInstance, setupComponent } from './component'
import { effect } from '@vue/reactivity'
import { Text, normalizeVNode } from './vnode'

export function createRenderer(rendererOptions) {
  const {
    insert: hostInsert,
    remove: hostRemove,
    patchProps: hostPatchProps,
    createElement: hostCreateElement,
    createText: hostCreateText,
    setText: hostSetText,
    setElementText: hostSetElementText,
  } = rendererOptions

  /**
   *  创建effect调用render
   */
  const setupRenderEffect = (instance, container) => {
      const componentUpdateFn = () => {
        if(!instance.isMounted) {
            // 这代表组件还未挂载,所以要进行挂载流程
            
            // 获取到实例的状态的代理,以此作为render函数的入参
            const proxyToUse = instance.proxy
            
            // 调用render函数获取虚拟节点,同时初始化实例的subTree属性
            const subTree = (instance.subTree = instance.render.call(proxyToUse, proxyToUse))
        
            // 进入patch流程
            patch(null, subTree, container)
        
            // 修改是否挂载属性
            instance.isMounted = true
        } else {
            // 组件已经挂载,进入更新流程
        }
    }

    // 将更新函数挂载到实例上
    instance.update = effect(componentUpdateFn)
  }

  /** 挂载组件
   *  组件的渲染流程
   */
   function mountComponent(initialVNode, container) {
        // 通过调用createComponentInstance函数创建组件实例
        // 同时记录下虚拟节点中的component
        const instance = (initialVNode.component = createComponentInstance(initialVNode))

        // 调用setupComponent函数,尝试将状态解析到实例中
        setupComponent(instance)

        // 调用setupRenderEffect函数,创建effect并且还要调用render函数进行patch流程
        setupRenderEffect(instance, container)
    }

  /** 处理组件
   */
    function processComponent(n1, n2, container) {
        // 处理组件的挂载或者更新
        if(n1 == null) {
            // 将挂载的逻辑交给mountComponent函数
            mountComponent(n2, container)
        } else {
            // 更新组件
            // do somethings what....
        }
    }

  /**
   *  挂载子节点
   */
   function mountChildren(children, container) {
        for(let i = 0; i < children.length; i++) {
            // 通过调用 normalizeVNode函数 格式化虚拟节点
            const child = normalizeVNode(children[i])
            patch(null, child, container)
        }
    }

  /**
   *  挂载元素
   */
function mountElement(vnode, container) {
    // 都是虚拟节点的属性,忘记了可以回看一下第三小节
    const { props, shapFlags, type, children } = vnode
    // 创建目标元素并初始化模板
    const el = (vnode.el = hostCreateElement(type))
    
    // 给模板添加属性,利用patchProps
    if(props) {
        for(const k in props) {
            hostPatchProps(el, k, null, props[k])
        }
    }
    
    // 检查标记,通过标记判定是否有子元素,以及子元素的类型
    // 仍然可以利用按位与运算
    if(shapFlags & ShapFlags.TEXT_CHILDREN) {
        // 有子元素,且子元素为文本节点
        // 那直接渲染到父元素上即可
        hostSetElementText(el, children)
    } else if (shapFlags & ShapFlags.ARRAY_CHILDREN) {
        // 有子元素,且子元素为一个数组
        mountChildren(children, container)
    }
    
    // 将模板插入容器中
    hostInsert(el, container)
}

  /**
   *  处理元素的挂载或者更新
   */
 function processElement(n1, n2, container) {
    if(n1 == null) {
        // 初次挂载
        mountElement(n2, container)
    } else {
        // 前一个结点不为空,因此这是更新流程
        // do somthings what....
    }
 }

  /**
   *  处理文本的挂载或更新
   */
function processText(n1, n2, container) {
    if(n1 == null) {
        // 表明当前的文本是初次挂载
        // 利用调用平台提供的创建函数创建文本节点
        // 利用插入函数插入容器中即可
        hostInsert((n2.el = hostCreateText(n2.children)), container)
    } else {
        // 表明当前的文本内容是进行更新操作
        // do somthing what....
    }
}

/**用于初始化的渲染或者后续的组件更新
 */
function patch(n1, n2, container) {
    if(n1 === n2) {
        return 
    }
    
    const { shapFlags, type } = n2
    
    switch(type) {
        case Text: {
            // 利用Symbol标记的Text-鉴别出当前的虚拟节点是一个文本
            // 调用文本的处理函数进行处理即可
            processText(n1, n2, container)
            break;
        }
        
        default: {
            if(shapFlags & ShapFlags.ELEMENT) {
                // 当前的虚拟节点是一个元素 
                processElement(n1, n2, container)
            } else if(shapFlags & ShapFlags.COMPONENT) {
                // 当前的虚拟节点是一个组件
                processComponent(n1, n2, container)
            }
        }
    }
}

  /**渲染器
   * It'S THE CORE OF "runtime-core" MODULES
   * @param vNode
   * @param container
   */
  const render = (vNode, container) => {
    patch(null, vNode, container)
  }

  return {
    createApp: createAppApi(render),
  }
}

2. vnodes

import { ShapeFlags, isArray, isObject, isString } from '@vue/shared'

// 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
}

// 检测是否是一个虚拟节点
export const isVnode = (v) => v.__v_isVnode

export const Text = Symbol('v-txt')

// 格式化虚拟节点
funciton normalizeVNode(child) {
    // 如果child入参是一个对象的话那它就是一个虚拟节点
    if(isObject(child)) {
        // 如果该组件已经挂载就要调用cloneVNode函数对其进行拷贝
        return child.el == null     
                 ? child
                 : cloneVNode(child)
    }
    // 不是一个虚拟节点,则为文本内容
    // 创建文本的虚拟节点并返回
    return createVNode(Text, null, String(child))
}

3. apiCreateApp

import { createVNode } from './vnode'

export 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(rootComponent, rootProps)
        render(vNode, container)
        app._container = container
      },
    }

    return app
  }
}

4. h

import { isArray, isObject } from '@vue/shared'
import { createVNode, isVnode } from './vnode'

/**
 * 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)
    }
}

5. component

import { ShapeFlags, isFunction, isObject } from '@vue/shared'
import { PublicInstanceProxyHandles } from './componentPublicInstance'

/**
 * 创建实例
 * @param vnode
 * @returns
 */
export function createComponentInstance(vnode) {
    const instance = {
        vnode,
        type: vnode.type,
        props: {},
        attrs: {},
        slots: {},
        ctx: null,
        proxy: null,
        subTree: null,
        setupState: {},
        isMounted: false
    }
    
    // 初始化上下文属性ctx
    // ctx 上下文属性用于内部操作
    instance.ctx = { _: instance }
    
    return instance
}

/**
 * 启动
 * @param vnode
 */
export function setupComponent(instance) {
    const { props, children } = instance.vnode
    
    // 初始化props和children,在源码中是通过调用init函数实现的,不过我们最简化实现
    instance.props = props
    instance.children = children
    
    // 利用按位与操作判定当前组件是否是一个具有状态的组件
    const isStateful = instance.vnode.shapFlags & ShapFlags.STATEFUL_COMPONENT
    if(isStateful) {
        setupStatefulComponent(instance)
    }
}

/**
 *  调用setup方法,并且用返回值填充setupState 和 render(形参)
 */
function setupStatefulComponent(instance) {
    const Component = instance.type
    const { setup } = Component
    
    if(setup) {
        // 有setup函数
        
        // 创建出上下文,其实就是setup函数的第二个参数
        const setupContext = createSetupContext(instance)
        // 将准备好的参数作为入参调用setup函数
        // 对于setup函数的入参感到困惑的,建议还是赶快去看一下官网的setup函数部分
        const setupResult = setup(instance.props, setupContext)
            
        // 将返回的结果作为入参用来调用 handleSetupResult 函数处理状态赋予的逻辑
        handleSetupResult(setupResult)
     } else {
        // 没有setup函数
        finishComponentSetup(instance)
     }
}

function handleSetupResult(instance, setupResult) {
    // 如果返回值是一个函数,这表明setup函数没有返回状态,而是返回了一个render函数
    if(isFunction(setupResult)) {
        // 那么这个组件的渲染函数就是setup函数返回的这个函数
        instance.render = setupResult
    } else if(isObject(setupResult)) {
        // 这说明setup函数返回的是一个状态对象
        // 将状态信息赋予实例的setupState属性
        instance.setupState = setupResult
    }
    
    // 为组件准备渲染函数
    finishComponentSetup(instance)
}

//// render的调用并不在这
function finishComponentSetup(instance) {
    const Component = instance.type 
    if(!instance.render) {
        if(!Component.render && Component.template) {
            // 编译,这部分涉及到编译模块,不做过多介绍
        }
        
        // 拿到最终的render函数,挂载到实例上
        instance.render = Component.render
    }
}

/**
 *  创建setup的第二个形参(上下文)
 */
function createSetupContext(instance) {
    // 函数会返回一个对象,这个对象用来作为setup函数的第二个入参
    return {
        attrs: instance.attrs,
        slots: instance.slots,
        props: instance.props,
        emit: () => {},
        expose: () => {},
    }
}

6. componentPublicInstance

import { hasOwn } from '@vue/shared'

export const PublicInstanceProxyHandles = {
    get({_:instance}, key) {
        // 获取到实例属性 本篇只以props和setupState为例
        const { props, setupState } = instance
        
        if(key[0] == '$') {
            // 以$开头的属性一般都是供给内部使用的
            // 在源码中对这里进行了更细的操作,为了方便我们就直接拒绝访问了
            return 
        }
        
        // 对访问的属性进行归属判断
        if(hasOwn(setupState, key)) {
            // 属性存在于状态对象中
            return setupState[key]
        } else if(hasOwn(props, key)) {
            // 属性存在于props中
            return props[key]
        } else {
            return undefined
        }
    },
    set({_: instance}, key, value) {
        // 修改值
        const { props, setupState } = instance

        if (hasOwn(setupState, key)) {
          setupState[key] = value
        } else if (hasOwn(props, key)) {
          props[key] = value
        }
    
        return true
    }
}

总结

阅读总结

实际上从我初步阅读源码(vuejs/core + mini-vue)再到整理出一些主体代码 再到将一些内容整理成文字的形式展现出来能感觉到是比较乱的,对应到本篇帖子来讲可能多少在内容的渐进上有一些轻微瑕疵不过好在对于我写的地方都有详细的解释,所以理解起来是问题不大的,况且鉴于我对自己的认知的写作风格来讲是比较啰嗦且渐进式的,所以放心读吧,一定是一步步引导式从0到1实现的!

除此之外包括在文中我也有强调:对于学习这种知识点需要确保能够联想到上下文,这是理解文章的必须点。要做到阅读完能够对这个函数有印象,再次见到知道这是干什么的,会有什么入参,有什么返回值。如此才能更加顺畅的阅读完本篇有关于 Vue runtime核心内容的知识点!

写下这段阅读总结可以帮助xdm在阅读本篇的时候更加快速的理解本篇内容的核心知识,

同时如果是在一刷的时候感觉比较混乱生涩,这段阅读总结也能够提示你要多多联想上下文。这就像阅读一篇小说一样,上下文能够让你对剧情衔接的更加顺畅,所以如果没有读懂,建议再读一遍!

知识点总结

这篇帖子我们由创建一个组件挂载页面作为切入点

runtime-dom模块对core模块的调用及runtime-dom模块函数实现

再到runtime-core模块函数的实现作为 整篇帖子的线索

  1. runtime-dom模块中我们提供了一套在WEB环境下对于节点的操作方法rendererOptions,借用于此runtime-core模块才能利用这个平台提供的方法创建出我们想要的节点

  2. runtime-core模块中以createRenderer函数作为切入点,它需要返回一个创建Vue应用实例的函数:createApp,这个应用实例会返回一个拥有若干个方法和属性的实例对象,我们会利用其中的mount函数来挂载页面

    1. 在调用mount函数的时候需要做的两件事分别是创建组件的虚拟节点和利用createRenderer函数提供的render函数挂载页面

    2. createVNode函数用于创建虚拟节点,虚拟节点技术是Vue实现高性能和跨平台的支撑点。虚拟节点会利用shapFlags配合位运算标记出节点及后代的类型,针对不同的类型,渲染函数会采取不同的处理方式

    3. 在渲染函数render中,会将接收到的虚拟节点作为调用patch函数的入参,render函数利用patch函数进行节点的类别判定及分别处理

    4. patch函数是进行节点的类型判别和派发处理函数的调度函数,除了render函数为了挂载根组件可以调用patch函数以外,其他任意的处理函数都可以为了处理节点而调用patch函数,实际上我们在代码中也是这样做的。

    5. 根据不同的节点类型在我们的代码中,处理函数被分为了三种(源码更全面,我们不考虑):processTextprocessElementprocessElement

      1. processText函数所面临的处理内容就是单纯的文本节点,这种情况下只需要利用平台提供的文本节点创建函数直接创建后插入目标容器中即可
      2. processElement函数所面临的处理内容就是一般的元素(在WEB中就是div....)由于元素类型可能存在子代元素,因此在这个过程中我们需要对子代进行分别的处理,这个处理指的是:如果是简单的文本节点那么直接插入容器即可;如果后代元素是数组的内容,且这个数组的元素无论是不是文本节点我们都要再对此进行额外的处理
      3. processComponent函数所面临的处理内容就是组件,身为组件就需要具备组件自身的实例,利用createComponentInstance函数创建出组件实例之后,后续的操作就是尝试解析setup函数到实例中,并且准备好实例的render函数,因为组件挂载的虚拟节点的来源就是render函数的返回值。

知识点的总结其实就是再将上面的实现过程进行一个粗略的路线分析,可以配合这个路线分析再看一遍内容,相信一定会有新的理解。

其实本来是想做个模块调用图的,不过感到有点写乏了,先放下了😭😭

往期推荐:

通过实现最简reactive,学习Vue3响应式核心

都用过ref和computed,但你懂它的原理吗?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant