Element Plus 组件库相关技术揭秘:1. Vue3 组件库的设计和实现原理

15,759 阅读41分钟

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

前言

对于现代框架来说无论是 Vue 还是 React 组件化都是一项必不可少的能力。通过组件,我们可以将一个很大很复杂的页面拆分成多个部分,然后每个部分又可以拆分成若干个小部分,这些部分都可以是一个个单独的组件,通过这些组件,就可以像堆积木那样把一个复杂页面进行堆砌出来。我们大部分的网站系统很多的功能需求都是相同的,比如说弹框、日历、表单,那么就不需要每个网站都重新写一套功能相同的代码,所以就有了很多开源的组件库,比如 Element Plus 组件库。

所谓组件化就是抽离各个页面公共的部分(包括HTML结构、CSS样式和 JavaScript 逻辑),将其进行封装成一个独立的部分,当修改此部分代码时其他所有引用到它的页面都会发生改变,从而达到解耦和逻辑复用。

对于 Vue 来说,组件的表现形式有两种,一种是以 .vue 为后缀的单文件组件(single-file-component 简称:SFC),一种是 JSX 形式的组件,它们的形式不一样,它们具体使用方式和运行原理也有一些区别,接下来我们将会进行详说。一个 Vue 组件在使用前有可能需要先被“注册”,这样 Vue 才能在执行渲染函数时找到其对应的实现。组件注册有两种方式:全局注册和局部注册。 那么为什么要进行全局注册和局部注册呢?全局注册的组件为什么能在每一个组件中都能进行使用呢?要了解这些,就需要我们对 Vue3 的底层运作机制进行深入的了解,进而才能更好理解组件库的设计和实现原理。

接下来,我们将围绕 Vue3 组件的实现原理,进行讲解一些 Vue3 的底层知识,主要是围绕一个组件是如何从实现到渲染到页面的过程进行讲解。

Vue3 组件的实现原理

一个项目就算再大,也是存在一条核心思路的,Vue3 亦是如此,接下来我们讲围绕一个组件的运作机制为核心进行剖析。

Vue 组件的本质

其实一个普通 Vue 组件的本质就是一个 JavaScript 对象,如下面代码所示:

// App 是一个组件,它的值是一个对象
const App = {
    name: 'App',
    setup() {
       return {
        count: 520
       } 
    },
    // 一般在 SFC 的模式组件下我们是不用写 render 选项的,render 选项是由 template 进行编译生成
    render() {
        return createVNode('div', { class: 'red' }, 'Hi Vue3 Component param count is:' + this.count)
    }
}

一般在 SFC 的模式组件下我们是不用写 render 选项的,render 选项是由 template 进行编译生成。我们写了一个组件之后,通过下面的方式进行调用。

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

在 createApp 函数内部主要的过程就是把我们写的组件生成一个虚拟 DOM,然后再通过渲染器把虚拟 DOM 进行渲染到页面上。接下来我们需要去了解渲染器相关的知识。

Vue3 渲染器核心逻辑

createApp 函数是渲染器返回的一个方法,主要是创建一个 Vue3 应用实例。渲染器(renderer)是通过 createRenderer 函数创建,createRenderer 函数主要返回一个渲染器对象。createRender 函数基本结构如下:

// 创建渲染器
function createRenderer(options) {
    // 渲染函数,主要是把一个虚拟 DOM 渲染到某一个元素节点上
    function render(vnode, container) {
        // 具体通过 patch 函数进行渲染
        patch(null, vnode, container, null, null)
    }
    // 补丁函数
    function patch(n1, n2, container) {
		// 根据虚拟DOM 的类型不同进行不同的操作
    }
    // 返回渲染器对象
    return {
        createApp: createAppAPI(render)
    }
}

渲染器的作用就是把虚拟DOM 渲染为真实DOM,所以渲染器需要把我们写的那些元素进行创建、删除、修改和元素属性的创建、删除、修改。那么不同的平台,对元素操作的 API 都不一样,所以在执行 createRenderer 函数的时候,就需要根据不同平台对元素操作特性 API 来创建渲染器。我们平时一般用到的都是 Vue3 默认提供的 runtime-dom 这个包来创建的渲染器(renderer),runtime-dom 包就是根据浏览器的对元素操作的特有的DOM API 进行创建渲染器。runtime-dom 创建渲染器的主要过程如下:

// 创建元素
function createElement(type) {
    return document.createElement(type)
}
// 插入元素
function insert(child, parent, anchor) {
    parent.insertBefore(child, anchor || null)
}
// 创建元素文本
function setElementText (el, text) {
    el.textContent = text
}
// 创建渲染器
const renderer = createRenderer({
    createElement,
    insert,
    setElementText
})
// 创建 Vue3 应用
export function createApp(...args) {
    return renderer.createApp(...args)
}

从上面的代码我们可以看到创建渲染器的时候是把操作原生 DOM 的创建元素、插入元素、创建文本元素的 API 包装成一个个函数,然后作为参数传递给创建渲染器的函数进行创建一个针对 DOM 平台的渲染器。

创建 Vue3 应用实例对象

我们平时一般都是这样创建一个 Vue3 应用的:const app = createApp(App),根据上面的代码我们可以知道这个 createApp 函数是创建渲染器函数 createRenderer 返回的对象中的 createApp 方法,而 createApp 方法又是通过 createAppAPI 函数创建的,接下来,我们来看看 createAppAPI 函数的具体实现。

// 创建 Vue3 应用实例对象
function createAppAPI(render) {
    return function createApp(rootComponent) {
        // 创建 Vue3 应用实例对象
        const app = {
            // 实例挂载方法
            mount(rootContainer) {
                // 创建根组件虚拟DOM
                const vnode = createVNode(rootComponent)
                // 把根组件的虚拟DOM 渲染到 #app 节点上
                render(vnode, rootContainer)
            }
        }
        return app
    }
}

我可以看到具体创建 Vue3 应用实例对象的 createAppAPI 函数是一个闭包函数,主要通过闭包进行缓存渲染器内的 render 方法,接下来就是返回一个具体创建 Vue3 应用实例对象的 createApp 方法, const app = createApp(App) 中的 createApp 方法就来自于此。createApp 方法主要返回一个对象,对象里面就包含创建 Vue3 实例对象之后进行挂载的 mount 方法,在 createApp 方法的参数中接收根组件对象,然后 mount 方法挂载的时候,创建根组件的虚拟DOM,再把根组件的虚拟DOM 通过渲染器中的 render 方法进行渲染到具体元素节点上,我们一般就是 id 为 app 的元素上。

小结

至此我们可以对我们平时进行以下方式创建 Vue3 应用实例对象的过程作一个小小的总结。

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

我们一般先创建一个根组件 App 对象,然后通过 createApp 函数进行创建一个 Vue3 应用实例对象。具体的背后逻辑就是根据渲染平台的特性进行创建一个渲染器实例对象,在渲染器的实例对象中有具体创建 Vue3 的应用实例对象的方法,通过此方法创建的 Vue3 应用实例对象中拥有一个可以进行挂载的 mount 方法,在 mount 方法里面创建根组件的虚拟DOM。有了虚拟DOM,那么就进行虚拟DOM 的渲染,具体就是通过渲染器中的 render 方法,渲染到具体的真实DOM 节点上。很明显我们一般默认都是把根组件的虚拟DOM 渲染到ID 为 app 的 DOM 节点上。

接下来我们讨论具体的渲染过程,也就是一个组件到底怎么渲染到页面上的。

通过上文我们知道 Vue3 的在挂载的过程其实就是对根组件进行创建一个虚拟DOM,然后再对这个根组件的虚拟DOM 进行渲染到页面上。那么我们就有必要先对什么是虚拟DOM 进行了解。

什么是虚拟DOM?

所谓虚拟DOM其实就是一个描述真实DOM信息和结构的 JavaScript 对象。 我们要去更改视图,先要去更改这个JavaScript 对象,然后再通过渲染器去渲染这个 JavaScript 对象使之成为真实 DOM。为什么不使用真实DOM 呢?是因为真实 DOM 的节点信息太多,不利于数据遍历比较,而使用虚拟 DOM,我们只需要记录必要的数据信息就可以了 。

我们上面的根组件的 render 函数里面的表达式是:

render() {
    return createVNode('div', { class: 'red' }, 'Hi Vue3 Component')
}

其实这个在 template 中的声明式表达式对应的内容是:

<div class='red'>Hi Vue3 Component</div>

通过编译之后就变成上面 render 函数中的样子了。

我们来看一下 createVNode 函数做了什么事情:

export function createVNode(type, props, children) {
    const vnode = {
        type, // 虚拟DOM 类型
        props, // props 属性
        children, // 虚拟DOM 的孩子元素
        component: null, // 虚拟DOM 的组件实例
        key: props && props.key, // 虚拟DOM 的 key
        el: null // 真实DOM 元素
    }
    return vnode
}

createVNode 函数其实就是创建一个 VNode 对象。 上面 render 函数中创建的虚拟DOM 对象是这样的:

const vnode = {
    type: 'div', // 字符串类型
    props: { class: 'red' }, // props 属性
    children: 'Hi Vue3 Component param count is:520', // 虚拟DOM 的孩子元素
    component: null, // 虚拟DOM 的组件实例
    key: null, // 虚拟DOM 的 key
    el: null // 真实DOM 元素
}

在挂载根组件的时候是创建的根组件虚拟DOM 则是这样的:

const vnode = {
    type: App, // 对象类型
    props: null, // props 属性
    children: null, // 虚拟DOM 的孩子元素
    component: null, // 虚拟DOM 的组件实例
    key: null, // 虚拟DOM 的 key
    el: null // 真实DOM 元素
}

我们可以看到会有不同类型的虚拟DOM,主要是两种:元素类型(字符串类型)和组件类型(对象类型),另外还有片段类型,文本类型。在对虚拟DOM 进行渲染的过程中,就会根据不同类型的虚拟DOM 进行不同策略的处理,这个区分的过程主要发生在 patch 函数中。

patch 函数

patch 函数主要根据不同的类型的虚拟DOM、是否有新老虚拟DOM 进行不同的操作。

// 补丁函数, n1 旧虚拟DOM, n2 新虚拟DOM,container 渲染的节点
function patch(n1, n2, container) {
    const { type } = n2
    if(typeof type === 'string') {
        // 作为普通元素进行处理
        if (!n1) {
            // 创建节点
            mountElement(n2, container)
        } else {
            // 更新节点
        }
    } else if(typeof type === 'object') {
        // 如果是 type 是对象,那么就作为组件进行处理
        if(!n1) {
            // 挂载组件
            mountComponent(n2, container)
        } else {
            // 更新组件
        }
    }
}

具体会进行以下操作

  • 如果新虚拟DOM 的类型是 'string' 类型,且不存在老虚拟DOM 则进行创建节点操作
  • 如果新虚拟DOM 的类型是 'string' 类型,且存在老虚拟DOM 则进行节点更新操作
  • 如果新虚拟DOM 的类型是 'object' 类型,且不存在老虚拟DOM 则进行组件挂载操作
  • 如果新虚拟DOM 的类型是 'object' 类型,且存在老虚拟DOM 则进行组件更新操作

那么在 Vue3 初始化的时候,首先是对根组件的虚拟DOM 进行渲染,到了 patch 函数阶段,根组件的虚拟DOM 的 type 类型是 'object',并且是第一次挂载所以不存在老虚拟DOM,所以进行的操作便是挂载组件,也就是组件的初始化。

组件渲染的过程

一个组件最核心的功能就是生成虚拟DOM,生成虚拟DOM 之后再通过渲染器把虚拟DOM 渲染到具体的真实DOM。

// 组件挂载
function mountComponent(vnode, container) {
    // 获取组件的 setup、render 方法
    const { setup, render } = vnode.type 
    // 运行组件对象的 setup 方法,获取返回结果
    const setupResult = setup()
    // 通过组件的实例的 render 函数生成子树,通过 call 方法设置 render 函数中的 this 指向组件 setup 返回的结果,让 render 函数能够访问组件自身的状态数据
    const subTree = render.call(setupResult)
    // 调用 patch 把虚拟DOM 渲染成真实DOM
    patch(null, subTree, container)
}

通过上面简单的代码,我们可以知道组件挂载是通过获取组件的 setup、render 方法;然后运行组件对象的 setup 方法,获取返回结果;再通过组件的实例的 render 函数生成子树,通过 call 方法设置 render 函数中的 this 指向组件 setup 返回的结果,让 render 函数能够访问组件自身的状态数据;最后把虚拟DOM 渲染成真实DOM。

那么具体是怎么把虚拟DOM 渲染成真实DOM 的呢? 上面通过根组件 render 函数生成的子树,其实就是下面这个 VNode:

const vnode = {
    type: 'div', // 字符串类型
    props: { class: 'red' }, // props 属性
    children: 'Hi Vue3 Component param count is:520', // 虚拟DOM 的孩子元素
    component: null, // 虚拟DOM 的组件实例
    key: null, // 虚拟DOM 的 key
    el: null // 真实DOM 元素
}

通过调用 patch 把上面的虚拟DOM 渲染成真实DOM,那么就继续回到 patch 函数的逻辑中,通过上文我们可以知道当 vnode 的 type 属性值为 'string' 时就会调用 mountElement 方法进行节点创建。那么我们接下来就去了解一下 mountElement 方法的逻辑。

具体怎么把虚拟DOM 渲染成真实DOM呢?代码逻辑如下:

// 具体怎么把虚拟DOM 渲染成真实DOM 
function mountElement(vnode, container) {
    // 使用 vnode.type 作为标签名称创建 DOM 元素
    const el = (vnode.el = hostCreateElement(vnode.type))
    // 获取 children 内容
    const { children } = vnode
    if(typeof children === 'string') {
        // 如果 children 是字符串,则说明它是元素的文本节点
        hostSetElementText(el, children)
    } else if(Array.isArray(children)) {
        // 如果 children 是数组则进行循环创建
        children.forEach((v) => {
            // 递归调用 patch 函数渲染子节点,使用上面新创建的当前元素 el 作为挂载点
            patch(null, v, el)
        })
    }
    // 将元素插入到挂载点下
    hostInsert(el, container)
}

主要是通过使用 vnode.type 作为标签名称创建 DOM 元素,然后获取 children 内容,并判断 children 内容,如果是字符串类型的话,则说明它是元素的文本节点,那么就进行文本元素节点的创建,如果 children 是数组则进行循环递归调用 patch 函数渲染子节点,并使用上面新创建的当前元素 el 作为挂载点,最后把创建的元素插入到挂载点下。值得注意的是创建元素、创建文本元素、插入元素这些动作不同的平台会有不同的 API 实现,所以这些 API 则由创建渲染器的时候作为参数进行传递,这样就可以实现不同的平台实现不同的渲染器了。这也是 Vue3 可以实现自定义渲染器的原理。

至此一个 Vue3 组件的创建到渲染的逻辑就基本梳理了一遍。

实战操作

上述的实现代码我放在了 GitHub 仓库:github.com/amebyte/min…

我们把代码下载下来,使用 VScode 打开,然后安装 VScode 的插件 Live Server。

Live-Server.png

通过 VScode 的插件 Live Server 打开 v0 文件下的 index.html 文件。

open-with-live-server.png

然后可以看到我们编写的组件可以成功渲染到页面上了。

v0-render-result.png

小结

普通组件的本质是一个对象(也有可能是一个函数,也就是函数组件,但不在本次讨论范围),这个对象下必须要有一个函数用来产出组件要渲染的虚拟DOM,我们平时写的 SFC 组件的 template 会被编译器编译为渲染函数。渲染器在渲染组件时,会先获取组件要渲染的内容,即执行组件的渲染函数并得到其返回值,也就是组件的虚拟DOM,也称为 subtree,最后再递归地调用渲染器的 patch 函数将组件的虚拟DOM 渲染出来。至此我们知道渲染器的作用是,把虚拟DOM 对象渲染为真实DOM 元素,它的工作原理是,递归地遍历虚拟DOM 对象,并调用原生 DOM API 来完成真实DOM 的创建。

组件的挂载过程详解

在上文中我们已经讲解一个组件挂载的过程中是如何把虚拟DOM 渲染成真实DOM,但具体组件挂载的过程是十分复杂的,也只有更加详细理解组件的挂载过程,才可以进行理解局部组件和全局组件的运行机制,从而理解组件库的实现原理。

一个组件在运行的过程中需要维护组件的生命周期函数、组件渲染的子树、组件是否已经被挂载、组件自身的状态等等包含与组件有关的状态信息,所以需要创建一个组件实例来进行维护,而组件实例本质上是一个对象。一个组件的挂载的时候主要经过以下过程:

  1. 创建组件实例对象
  2. 运行组件的 setup 方法,获取返回的结果设置到组件实例对象上
  3. 设置组件 render 函数中的 this 代理对象,通过代理对象进行获取组件 setup 方法返回的内容和其他诸如 props 的内容
  4. 通过 call 方法调用组件的 render 函数,将其 this 指向设置为上文第三步中的组件实例的代理对象,同时获取组件的虚拟DOM
  5. 将获取到的组件虚拟DOM 通过 patch 方法渲染成真实DOM

更加详细实现代码及注释如下:

// 组件挂载
function mountComponent(vnode, container) {
    // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
    const instance = {
        vnode,
        type: vnode.type,
        setupState: null, // 组件自身的状态数据,即 setup 的返回值
        isMounted: false, // 用来表示组件是否已经被挂载,初始值为 false
        subTree: null, // 组件所渲染的内容,即子树 (subTree)
        update: null, // 更新函数
        render: null, // 组件渲染函数
        proxy: null, // 组件代理对象
    }
    // 将组件实例设置到 vnode 上,用于后续更新
    vnode.component = instance
    const { setup, render } = instance.type
    // 运行组件对象的 setup 方法,获取返回结果
    const setupResult = setup()
    if(typeof setupResult === 'object') {
        // 如果组件的 setup 方法返回的是一个对象,则通过 proxyRefs 方法处理之后设置到 instance 的 setupState 属性上
        // proxyRefs 转换 ref 类型省去 .value 繁琐操作
        instance.setupState = proxyRefs(setupResult)
    } else {
        // 返回的值还有可能是函数,这里不作展开分析了
    }
    // 设置 render 函数中的 this 代理对象,通过 call 方法设置 render 函数中的 this 指向此 Proxy 代理对象
    instance.proxy = new Proxy({ _:instance }, {
        get({ _: instance}, key) {
            if(key in instance.setupState) {
                // 如果获取的 key 存在 instance.setupState 上则返回 instance.setupState 对应的值
                return instance.setupState[key]
            }
            // 其他可以是 props, slots 等
        }
    })
    // 把组件对象上的 render 函数赋值给组件实例的 render 属性
    instance.render = render
    // 将渲染任务包装到一个 effect 中,这样组件自身状态发生变化时,组件便能进行自动触发更新;另外 effect 函数会返回一个 runner 函数,把返回的 runner 函数设置到组件实例对象上 update 属性上,后续更新则可以直接调用组件实例上的 update 方法了
    instance.update = effect(() => {
        // 如果 isMounted 为 false 则是组件挂载阶段
        if(!instance.isMounted) {
            // 通过组件的实例的 render 函数生成子树
            const subTree = (instance.subTree = instance.render.call(instance.proxy))
            // 把虚拟DOM 渲染到对应的节点上
            patch(null, subTree, container)
            // 把生成的真实DOM 设置到虚拟DOM 的真实DOM 属性 el 上,后续如果没有变化,则不需要再次生成
            instance.vnode.el = subTree.el
            // 表示组件挂载完成
            instance.isMounted = true
        } else {
            // 组件更新阶段
        }
    })
}

更详细的具体过程:

首先创建一个组件实例对象,包含组件自身的状态数据,即 setup 的返回的对象、组件是否已经被挂载、组件所渲染的内容,即子树、更新函数、组件渲染函数等。

执行组件的 setup 方法,获取返回结果,如果返回结果是一个对象,则通过则通过 proxyRefs 方法处理之后设置到 instance 的 setupState 属性上 。注意,proxyRefs 函数主要是为了转换 ref 类型省去 .value 繁琐操作 。另外 setup 返回的值还有可能是函数,如果是函数则作为组件的渲染函数,这里不作展开分析了。

设置 render 函数中的 this 代理对象,通过 call 方法设置 render 函数中的 this 指向此 Proxy 代理对象。这样在 render 函数中通过 this 获取的值则由上述设置的 Proxy 代理对象进行处理;在 Proxy 中会判断获取值的 key 是否在 setup 返回的对象中,如果在则返回对应的值;如果不在则进行其他条件分支的判断,比如判断获取值的 key 是否在 props 中,等其他代理值的获取。

将渲染任务的副作用函数包装到一个 effect 中,这样组件自身状态发生变化时,组件便能进行自动触发更新;另外 effect 函数会返回一个 runner 函数,把返回的 runner 函数设置到组件实例对象上 update 属性上,后续更新则可以直接调用组件实例上的 update 方法了。

在渲染任务的副作用函数中,先进行判断组件是否已经进行挂载,如果没有则进行挂载操作。通过组件的实例的 render 函数生成子树,且在调用 render 函数时通过 call 方法将其 this 的指向设置为上面设置的组件代理对象 instance.proxy;这样在渲染函数内部就可以访问 setup 方法中返回的状态数据了。再通过 patch 函数来挂载组件的生成的子树,即组件 render 函数返回的虚拟DOM。再把生成的真实DOM 设置到 vnode 的 el 属性上,后续如果没有变化,则不需要再次生成。最后设置组件实例的 instance.isMounted = true 表示组件挂载完成。

为什么要创建一个 Proxy 代理对象,让组件的 render 函数中的 this 指向它?

我们上文中的第一版是直接设置 render 函数中的 this 指向 setup 返回的对象,但这样的话,render 函数中就只能获取到 setup 中返回的数据了,而实际中 render 函数中还需要获取 props 中的数据和保存在组件实例上的相关数据,所以就需要通过代理对象来实现访问了。

实战操作

我们上面更加详细实现组件挂载过程的代码放在上文提到的 GitHub 仓库:github.com/amebyte/min…

此复杂版本的代码放在了 v1 文件下:

v1.png

运行还是跟上文 v0 版本一样,通过 VScode 插件 Live Server 运行 index.html 文件。不过此运行之前先要在根目录通过 npm install @vue/reactivity 初始化安装 Vue3 的响应式依赖包,因为我们上文中说到的 Vue3 响应式函数:proxyRefs 和 effect 我们是没有实现的,我们是通过引用 Vue3 中的 @vue/reactivity 包下的相关函数来实现。Vue3 在模块的拆分和设计上做得非常合理,模块之间的耦合度非常低,很多模块可以独立安装使用,而不需要依赖完整的 Vue3 运行时,比如我们这里使用到的 @vue/reactivity 模块,在后续的文章我们也会进行相关知识的讨论,这里先不作过多的介绍。

import { ref } from '../node_modules/@vue/reactivity/dist/reactivity.esm-browser.js'
import { createVNode } from './vnode.js'
import { Component } from './Component.js'
export const App = {
    name: 'App',
    setup() {
       const count = ref(520) 
       return {
        count
       }
    },
    // 一般在 SFC 的模式组件下我们是不用写 render 选项的,render 选项是由 template 进行编译生成
    render() {
        return createVNode('div', {}, [
            createVNode('p', {}, 'Hi Vue3 Component param count is:' + this.count),
            createVNode(Component)
        ])
    }
}

我们手动引入 node_modules 目录下的 @vue/reactivity 包中的 ref 响应式函数,在组件的 setup 方法中创建了一个 count 变量并返回出去,另外我们在引入了一个自定义组件,并在 render 函数中第一个创建虚拟DOM 的函数的 children 属性中创建一个数组类型的 children,其中一个虚拟DOM 则是又创建了一个组件类型的虚拟DOM。

自定义组件 Component 代码:

import { ref } from '../node_modules/@vue/reactivity/dist/reactivity.esm-browser.js'
import { createVNode } from './vnode.js'
export const Component = {
    name: 'Component',
    setup() {
       const txt = ref('Component') 
       return {
        txt
       }
    },
    // 一般在 SFC 的模式组件下我们是不用写 render 选项的,render 选项是由 template 进行编译生成
    render() {
        return createVNode('div', {}, 'Hi Vue3 Component param txt is:' + this.txt)
    }
}

通过 VScode 插件 Live Server 运行 index.html 文件浏览器渲染结果如下:

v1-render-result.png

我们可以看到我们已经成功运行我们复杂版本的组件挂载过程的代码,并且可以在组件里面继续引用组件,也就是相当于局部组件,并且也成功实现渲染。

小结

具体的渲染逻辑,在这里作一个小小的总结,一开始渲染根组件虚拟DOM 的时候,在 patch 函数环节会进行判断虚拟DOM 的类型是什么,字符串类型则进行元素创建,对象类型则进行组件挂载,组件挂载的过程主要是生成虚拟DOM,再通过 patch 函数进行渲染成真实DOM;虚拟DOM 类型是字符串,进行元素创建的时候,会进行虚拟DOM 的 children 内容的判断,如果是字符串则进行文本元素的创建,如果是数组则继续递归调用 patch 函数进行渲染数组元素中的虚拟DOM,那么在 patch 函数环节又会进行判断虚拟DOM 的类型继而进行不同的操作,周而复始,直至把所有的虚拟DOM 渲染完毕。

通过上文,我们已经初步了解了一个局部组件是如何引用并实现渲染的,但真实 Vue3 框架中,远要复杂很多,所以我们在下文中继续 Vue3 全局组件和局部组件的实现原理讲解。

Vue3 全局组件和局部组件的实现原理

设置当前运行的组件实例

我们先从一道 Vue3 面试题开始本小节的内容:

Vue3 面试题:为什么组件实例只可以在 setup 方法中获取,设计原理是什么?

在运行组件的 setup 方法之前,会把当前运行的组件实例设置在一个全局的变量中:currentInstance,运行 setup 方法结束之后就会把当前的全局组件实例变量 currentInstance 设置为空。所以组件实例只可以在 setup 方法中获取。

定义一个当前运行的组件实例的全局变量:

// 当前运行的组件实例
export let currentInstance = null

当前运行的组件实例的全局变量的设置:

const { setup, render } = instance.type
// 设置当前的组件实例
currentInstance = instance
// 运行组件对象的 setup 方法,获取返回结果
const setupResult = setup()
// 设置当前组件当前组件实例为空
currentInstance = null

所以综上所述,我们可以知道当 setup 方法运行之后,全局当前组件实例的变量就被设置为了 null,因此也就只能在 setup 方法里面进行获取当前组件实例了。

设置当前正在渲染的的组件实例

在执行组件 render 函数获取子树的时候,就代表这个组件正在渲染,也就是在这个时候进行设置当前正在渲染的的组件实例。

先定义一个当前运行的渲染组件实例的全局变量:

// 当前运行的渲染组件实例
export let currentRenderingInstance = null

在执行组件 render 函数获取子树的时候进行设置当前运行的渲染组件实例:

instance.update = effect(() => {
    if(!instance.isMounted) {
        // 通过组件的实例的 render 函数生成子树
        const subTree = (instance.subTree = renderComponentRoot(instance))
        // 省略相关代码
    } else {

    }
})

我们接着看 renderComponentRoot 函数做了什么事情:

function renderComponentRoot(
    instance
  ) {
    const { proxy, render } = instance
    let result
    // 返回上一个正在渲染的实例对象
    const prev = setCurrentRenderingInstance(instance)
    result = render.call(proxy)
    // 再设置当前的渲染对象上一个,具体场景是嵌套循环渲染的时候,渲染完子组件,再返回去渲染父组件
    setCurrentRenderingInstance(prev)
    return result
}
// 设置正在渲染的组件实例
function setCurrentRenderingInstance(instance) {
    // 把上一个正在渲染的实例对象赋值给一个临时变量
    const prev = currentRenderingInstance
    // 设置当前正在渲染的组件实例给全局变量
    currentRenderingInstance = instance
    // 返回上一个正在渲染的实例对象
    return prev
}

通过上述代码我们可以知道 Vue3 组件获取子树的时候,会设置当前正在渲染的组件实例给一个全局变量——currentRenderingInstance,并且把前一个正在渲染的组件实例赋值给一个临时变量,等获取到组件子树之后,又把临时变量中的组件实例赋值回给全局变量——currentRenderingInstance。

小结

那么这样设计的原理是什么呢?

因为一个组件主要的生命周期的过程就是创建、挂载、更新,而挂载和更新都是需要进行渲染操作,所以挂载和更新也可以归属于渲染阶段,那么这样一来,也就只要两个阶段了,就是创建和渲染阶段。这样在创建阶段获取到的就是创建阶段设置的组件实例,渲染阶段获取到的就是渲染阶段设置的组件实例。这也是为什么在上述的代码中要在 setup 执行之后要把全局变量 currentInstance 设置为空,因为同一时间内只能是创建或渲染阶段。

为了区别理解分别把组件创建阶段 setup 中设置的全局变量组件实例的名称命名为:currentInstance,渲染阶段获取组件子树的时候设置的全局变量组件实例的名称命名为:currentRenderingInstance。在获取当前组件实例的 API —— getCurrentInstance 中是优先获取全局变量 currentInstance 的值,如果全局变量 currentInstance 没有值则获取全局变量 currentRenderingInstance 的值。这样在 setup 方法通过 API getCurrentInstance 获取到的是currentInstance 的值。那么什么时候获取到的值是 currentRenderingInstance 的值呢?在函数组件中通过 API getCurrentInstance 获取到的则是 currentRenderingInstance 的值,因为函数组件是没有生命周期这些状态的。

那么为什么设置 currentRenderingInstance 值的时候,需要把上一个正在渲染的实例对象赋值给一个临时变量,再设置当前正在渲染的组件实例,等到获取组件子树完毕之后,又把上一个正在渲染的实例对象赋值回给全局变量 currentRenderingInstance 呢?这是因为组件渲染是深度优先的,当前父组件可能还没渲染完毕,然后就进入子组件进行渲染,在获取子组件的虚拟DOM 的时候需要把全局变量 currentRenderingInstance 设置为子组件的实例对象,等获取子组件的虚拟DOM 完毕之后,就要把父组件的渲染实例对象设置回去。

在我们了解完组件的实例对象的设置和设计原理的前置基础知识之后,我们就可以进步去了局部组件和全局组件的实现原理了。

局部组件的实现原理

我们知道组件的本质就是一个对象,所以不管局部组件和全局组件,它们也是一个对象。在前面的章节我们已经稍微了解一下局部组件的一些知识了,前面的章节我们已经实现在组件里面进行引用组件,这种方式其实是相当于 JSX 的组件引用方式。而在 SFC 的 Vue 组件里面进行引用组件则有所不同,在 SFC 的组件里面一个 Vue 组件在使用前需要先被“注册”,这样 Vue 才能在执行渲染函数时找到其对应的实现。

局部组件注册使用如下所示:

import ComponentA from './ComponentA.js'

export default {
  components: {
    ComponentA
  },
  setup() {
    // ...
  }
}

在组件的 components 选项里面进行了注册就可以在 template 中进行使用了。

<template>
    <div><ComponentA></ComponentA></div>
</template>

<ComponentA></ComponentA> 标签最终会被编译成 const comp = resolveComponent('ComponentA') ,通过 resolveComponent 函数就可以获取到组件 ComponentA 的对象内容了,这样就可以创建组件虚拟DOM 的了。

那么 resolveComponent 函数是怎么实现的呢?

export function resolveComponent(name) {
    // 获取当前组件的实例对象
    const instance = currentRenderingInstance || currentInstance
    if (instance) {
      // 通过组件实例获取组件对象,也就是 type 属性值
      const Component = instance.type
      // 局部注册
      const res = resolve(Component.components, name) 
      return res
    }
}
// 获取组件对象
function resolve(registry, name) {
    return (
      registry &&
      registry[name]
    )
}

通过上面的代码我们可以看到注册局部组件到使用的原理,其实很简单,先把要在 template 中要使用到的组件对象注册到组件对象的 components 属性上,然后在渲染函数 render 中通过当前的组件实例对象获取组件对象的 components 属性,看看有没有对应的组件对象,有则把对应的组件对象获取到然后进行创建组件的虚拟DOM。

全局组件的实现原理

我们可以使用 Vue3 应用实例的 app.component() 方法,让组件在当前 Vue 应用中全局可用。可以通过以下方式进行使用:

import ComponentA from './ComponentA.vue'
app.component('ComponentA', ComponentA)

这个时候我们要回到创建 Vue3 应用实例的模块代码上来。在创建 Vue3 应用实例之前会先创建一个应用上下文对象。

const context = createAppContext() 

createAppContext 创建的内容如下:

function createAppContext() {
    return {
        app: null,
        config: {},
        mixins: [],
        components: {},
        directives: {},
        provides: Object.create(null),
    }
}

其中包含 config,app,components 等,而 components 则是我们这期重点要了解的。那么具体是怎么设置的呢?我们接着看下面具体的实现代码:

function createAppAPI(render) {
    return function createApp(rootComponent) {
        const context = createAppContext()
        const app = {
            // 注册全局组件方法
            component(name, component) {
                // 把组件注册到 Vue3 应用实例上下文对象的 components 属性上
                context.components[name] = component
                return app
            },
            mount(rootContainer) {
                const vnode = createVNode(rootComponent)
                // 把应用实例的上下文对象设置到根组件的虚拟DOM 的 appContext 属性上
                vnode.appContext = context
                render(vnode, rootContainer)
            }
        }
        return app
    }
}

通过上面的代码我们可以知道注册全局组件的时候,是把组件注册到 Vue3 应用实例上下文对象的 components 属性上,最后应用实例的上下文对象会设置到根组件的 vnode 的 appContext 属性上。

接下来会在每一个组件初始化的时候会进行设置组件实例对象上的 appContext 属性,如下面代码所示:

// 初始化一个组件的上下文对象
const emptyAppContext = createAppContext()
// 组件挂载
function mountComponent(vnode, container, parent) {
    // 组件的 appContext 继承父组件的 appContext 或者虚拟DOM 的 appContext,如果都不存在则创建一个空的上下文对象
    const appContext =
          (parent ? parent.appContext : vnode.appContext) || emptyAppContext
    // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
    const instance = {
        vnode,
        type: vnode.type,
        appContext,
        // 省略
    }
}

我们可以看到在组件挂载的时候会对组件实例对象上 appContext 属性进行初始化操作,组件的 appContext 继承父组件的 appContext 或者虚拟DOM 的 appContext,如果都不存在则创建一个空的上下文对象。这样每一个组件的上下文对象都会包含根组件设置的上下文内容,这样每一个组件都可以通过组件实例对象上的 appContext 属性获取根组件设置的上下文内容。

我们在上文也已经提到了全局注册的组件在 render 函数中也是通过 resolveComponent 函数进行获取对应的组件对象的,具体怎么获取的,如下:

export function resolveComponent(name) {
    const instance = currentRenderingInstance || currentInstance
    if (instance) {
      const Component = instance.type
      const res = 
            // 获取局部注册的组件
            resolve(Component.components, name) ||
            // 获取全局注册的组件
            resolve(instance.appContext.components, name)
      return res
    }
}

根据上面的代码我们可以知道 resolveComponent 会优先获取局部注册的组件,如果获取不到再获取全局注册的组件。

实战操作

在上面我提供的 GitHub 仓库:github.com/amebyte/min… 中的文件夹 v2 中的代码则实现了完整的代码和详细的注释。

我们看一下调用的过程:

import { createApp } from './mini-vue3.js';
import { App } from './App.js';
import { RouterView } from './router-view.js';
const app = createApp(App)
// 注册全局组件
app.component('RouterView', RouterView)
app.mount(document.querySelector("#app"));

我们可以看到这个调用的过程,跟我们 Vue3 应用创建调用的过程是一致的,同时我们注册了一个全局组件。

然后我们主要来看看 App.js 里面是怎么调用的:

import { ref } from '../node_modules/@vue/reactivity/dist/reactivity.esm-browser.js'
import { resolveComponent } from './resolveAssets.js'
import { createVNode } from './vnode.js'
import { Component } from './Component.js'
export const App = {
    name: 'App',
    components: {
        // 注册局部组件
        Component
    },
    setup() {
       const count = ref(520) 
       return {
        count
       }
    },
    render() {
        // 获取局部组件
        const comp = resolveComponent('Component')
        // 获取全局组件
        const RouterView = resolveComponent('RouterView')
        return createVNode('div', {}, [
            createVNode('p', {}, 'Hi Vue3 Component param count is:' + this.count),
            createVNode(comp),
            createVNode(RouterView)
        ])
    }
}

我们可以看到在 App 组件的 components 选项中注册了一个局部组件,在 render 函数中会获取需要用到的局部或者全局的组件对象,不管是局部还是全局的组件对象都是通过 resolveComponent 函数来获取的,具体怎么获取,我们上面已经进行了说明了。获取到对应的组件对象就可以创建相应的组件虚拟DOM 了。

全局组件 RouterView 的内容:

import { ref } from '../node_modules/@vue/reactivity/dist/reactivity.esm-browser.js'
import { createVNode } from './vnode.js'
export const RouterView = {
    name: 'RouterView',
    setup() {
       const txt = ref('全局组件') 
       return {
        txt
       }
    },
    // 一般在 SFC 的模式组件下我们是不用写 render 选项的,render 选项是由 template 进行编译生成的
    render() {
        return createVNode('div', {}, 'RouterView param txt is:' + this.txt)
    }
}

局部组件 Component 的内容:

import { ref } from '../node_modules/@vue/reactivity/dist/reactivity.esm-browser.js'
import { createVNode } from './vnode.js'
export const Component = {
    name: 'Component',
    setup() {
       const txt = ref('局部组件') 
       return {
        txt
       }
    },
    // 一般在 SFC 的模式组件下我们是不用写 render 选项的,render 选项是由 template 进行编译生成
    render() {
        return createVNode('div', {}, 'Hi Vue3 Component param txt is:' + this.txt)
    }
}

接下来我们通过 VScode 插件 Live Server 运行 index.html 文件。

v2.png

浏览器渲染结果如下:

v2-render-result.png

至此我们已经实现了全局组件和局部组件的注册及成功渲染到页面。

Vue3 组件库的设计与实现

Vue3 全局组件注册,除了上面说到的使用 app.component() 进行注册以外,还可以使用插件进行注册。例如下面这种方式:

import { RouterView } from './router-view.js';
RouterView.install = app => app.component('RouterView', RouterView)

使用插件通过 app.component() 可以注册一到多个全局组件。那么 Vue3 的插件原理又是怎么样的呢?

插件原理

插件 (Plugins) 是一种能为 Vue 添加全局功能的工具代码。下面是如何安装一个插件的示例:

import { createApp } from 'vue'
const app = createApp({})
app.use(myPlugin, {
    /* 可选的选项 */
})

我们可以看到插件是在 Vue3 应用实例对象上的一个方法,所以我们要去看 use 函数具体的实现:

function createAppAPI(render) {
    return function createApp(rootComponent) {
        // 插件注册池
        const installedPlugins = new Set()
        // 创建 Vue3 应用实例对象
        const app = (context.app = {
            // 注册插件方法
            use(plugin, ...options) {
                if (installedPlugins.has(plugin)) {
                  // 已经注册过的插件不运行再进行注册
                  console.warn(`Plugin has already been applied to target app.`)
                } else if (plugin && typeof plugin.install === 'function') {
                  // 如果插件对象的 install 属性是一个函数,那么就通过调用插件对象的 install 方法进行插件注册
                  installedPlugins.add(plugin)
                  plugin.install(app, ...options)
                } else if (typeof plugin === 'function') {
                  // 如果插件对象本身是一个函数,那么就直接执行插件本身进行插件注册
                  installedPlugins.add(plugin)
                  plugin(app, ...options)
                }
                return app
            },
            // 省略相关代码
        })
        return app
    }
}

在创建 Vue3 应用实例对象之前先创建一个插件安装池的变量用来保存已经安装过的插件,如果已经安装过的插件则不能再进行安装。一个插件可以是一个拥有 install() 方法的对象,也可以直接是一个安装函数本身。安装函数会接收到安装它的应用实例和传递给 app.use() 的额外选项作为参数,执行安装函数之前会把插件往插件池变量中添加。

因为插件的安装函数可以接受到 Vue3 的应用实例对象,就可以通过 Vue3 的应用实例对象获取其他的方法,进行一些操作,比如:

  1. 通过 app.component()app.directive() 注册一到多个全局组件或自定义指令。

  2. 通过 app.provide() 使一个资源可被注入进整个应用。

  3. app.config.globalProperties 中添加一些全局实例属性或方法。

  4. 一个可能上述三种都包含了的功能库 (例如 vue-router)。

组件库实现原理

接下来我们简单实现一个组件库。

首先我们编写两个组件。

组件库 Button 组件:

import { ref } from '../node_modules/@vue/reactivity/dist/reactivity.esm-browser.js'
import { createVNode } from './vnode.js'
export const Button = {
    name: 'Button',
    setup() {
       const txt = ref('组件库 Button 组件') 
       return {
        txt
       }
    },
    render() {
        return createVNode('div', {}, 'Button param txt is:' + this.txt)
    }
}

组件库 Icon 组件 :

import { ref } from '../node_modules/@vue/reactivity/dist/reactivity.esm-browser.js'
import { createVNode } from './vnode.js'
export const Icon = {
    name: 'Icon',
    setup() {
       const txt = ref('组件库 Icon 组件') 
       return {
        txt
       }
    },
    render() {
        return createVNode('div', {}, 'Icon param txt is:' + this.txt)
    }
}

组件库编写:

import { Button } from './Button.js';
import { Icon } from './Icon.js';
// 为每个组件安装插件
Button.install = app => app.component('Button', Button)
Icon.install = app => app.component('Icon', Icon)
// 组件库
const components = [
    Button,
    Icon
]
// 是否已安装标识
const INSTALLED_KEY = Symbol('INSTALLED_KEY')
// 组件库插件
export const ElementPlus = {
    install(app) {
        // 如果该组件库已经安装过了,则不进行安装
        if (app[INSTALLED_KEY]) return;
        // 将标识值设置为 true,表示已经安装了
        app[INSTALLED_KEY] = true;
        // 循环组件库中的每个组件进行安装
        components.forEach((c) => app.use(c));
    }
}

通过上面的代码我们可以知道 Vue3 组件库的基本实现原理:就是为每一个组件进行安装一个插件,然后通过插件进行组件的全局安装,再把所有的组件设置到一个数组中组成一个组件库,再编写一个组件库插件,在组件库插件里面进行循环数组组件库里的每一个组件,因为每一个组件都拥有插件所需的 install() 方法,所以每一个组件又是一个插件,又可以调用 use() 方法进行安装,最后就会执行每一个组件的 install() 方法,然后进行进行组件的全局安装,这样组件库里面的每个组件都将被注册到全局组件中去了。

这样设计的目的是为了自动化进行注册组件,不然一个组件库有几十个组件,不进行自动化注册的话,让用户一个个手动进行引入进行注册,则需要写大量重复的代码。现在设计成一个组件库插件,然后通过安装插件的方式进行注册,这样用户只需要引入一个组件库插件,然后安装一下就可以了,大大减少了代码量

实战操作

在上面我提供的 GitHub 仓库:github.com/amebyte/min… 中的文件夹 v3 中的代码则实现了完整的代码和详细的注释。

我们看一下调用的过程:

import { createApp } from './mini-vue3.js';
import { App } from './App.js';
import { ElementPlus } from './element-plus.js';
const app = createApp(App)
// 安装组件库
app.use(ElementPlus)
app.mount(document.querySelector("#app"));

App.js 中的调用过程:

import { ref } from '../node_modules/@vue/reactivity/dist/reactivity.esm-browser.js'
import { resolveComponent } from './resolveAssets.js'
import { createVNode } from './vnode.js'
export const App = {
    name: 'App',
    setup() {
       const count = ref(520) 
       return {
        count
       }
    },
    render() {
        // 获取组件库 Button 组件
        const Button = resolveComponent('Button')
        // 获取组件库 Icon 组件
        const Icon = resolveComponent('Icon')
        return createVNode('div', {}, [
            createVNode('p', {}, 'Hi Vue3 Component param count is:' + this.count),
            createVNode(Button),
            createVNode(Icon)
        ])
    }
}

接下来我们通过 VScode 插件 Live Server 运行 index.html 文件。

v3.png

浏览器渲染结果如下:

v3-render-result.png

至此我们手动编写的组件库也成功渲染到页面上了。

总结

此文章的篇幅虽然有点长,但我们始终围绕一个组件的运作机制为核心进行剖析。我们手写实现了一个 Vue3 组件是如何从诞生到渲染到页面上的整个过程,其实就是 Vue3 初始化的过程都做了什么。从中我们可以知道 Vue3 组件的本质是一个对象,最终是为了产生要渲染到页面的虚拟DOM,在渲染虚拟DOM 的过程中会根据虚拟DOM 的类型进行不通过的操作,主要是如果是组件类型那就进行组件的初始化,如果是元素类型那么就进行元素的创建。最后会把所有的虚拟DOM 都会渲染到页面上。

接着我们又手写实现了 Vue3 全局组件和局部组件的实现原理。全局组件是把组件注册到根组件上下文对象的 components 中,局部组件是把组件注册到所需组件的 components 选项中,在组件进行初始化的时候都会继承父组件的上下文对象,这样就可以在每一个组件中都能获取到全局组件了。SFC 组件中 template 中引用到的局部组件和全局组件最终都会编译成通过 resolveComponent 函数进行加载对应的组件,而在 resolveComponent 函数中则是通过当前的组件实例对象先进行获取局部组件,获取不到再进行获取全局组件。

在了解了 Vue3 全局组件和局部组件的实现原理之后,我们就可以了解组件库的实现原理了。组件库的基本实现原理就是为每一个组件安装一个插件所需的 install() 方法,在插件方法里面再进行把组件安装到全局组件中,然后把所有的组件设置到一个组件库中也就是一个数组,再添加一个组件库的插件,在组件库的插件中循环组件库,得每一个组件都是一个插件,再进行每一个组件的插件安装,最终把所有的组件都安装到全局组件中去。

最后最说明一下,上文所有的代码实现都在这个 GitHub 仓库:github.com/amebyte/min… 中,我都写了非常详细的注释,希望能帮助到每一个有需要的同学。

部分注释代码截图:

github-code.png

我们可以看到提供的代码是有非常详细的注释的,希望能帮助到你。

此文章的实现代码仓库:github.com/amebyte/ele…

欢迎关注本专栏,了解更多 Element Plus 组件库知识

本专栏文章:

1. Vue3 组件库的设计和实现原理

2. 组件库工程化实战之 Monorepo 架构搭建

3. ESLint 核心原理剖析

4. ESLint 技术原理与实战及代码规范自动化详解

5. 从终端命令解析器说起谈谈 npm 包管理工具的运行原理

6. CSS 架构模式之 BEM 在组件库中的实践

7. 组件实现的基本流程及 Icon 组件的实现

8. 为什么组件库或插件需要定义 peerDependencies

9. 组件开发中 Vue3 相关知识的应用与解析及 Button 组件的实现

10. CSS 系统颜色和暗黑模式的关系及意义

11. 深入理解组件库中SCSS和CSS变量的架构应用和实践

12. 组件 v-model 的封装实现原理及 Input 组件的核心实现

13. 深入理解 Vue3 的 v-model 及自定义指令的实现原理

14. React 和 Vue 都离不开的表单验证工具库 async-validator 之策略模式的应用

15. Form 表单的设计与实现

16. 组件库的打包原理与实践详解