前言
对于现代框架来说无论是 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 组件的创建到渲染的逻辑就基本梳理了一遍。
实战操作
上述的实现代码我放在了 Gitee 仓库:
我们把代码下载下来,使用 VScode 打开,然后安装 VScode 的插件 Live Server。
然后可以看到我们编写的组件可以成功渲染到页面上了。

小结
普通组件的本质是一个对象(也有可能是一个函数,也就是函数组件,但不在本次讨论范围),这个对象下必须要有一个函数用来产出组件要渲染的虚拟DOM,我们平时写的 SFC 组件的 template 会被编译器编译为渲染函数。渲染器在渲染组件时,会先获取组件要渲染的内容,即执行组件的渲染函数并得到其返回值,也就是组件的虚拟DOM,也称为 subtree,最后再递归地调用渲染器的 patch 函数将组件的虚拟DOM 渲染出来。至此我们知道渲染器的作用是,把虚拟DOM 对象渲染为真实DOM 元素,它的工作原理是,递归地遍历虚拟DOM 对象,并调用原生 DOM API 来完成真实DOM 的创建。
组件的挂载过程详解
在上文中我们已经讲解一个组件挂载的过程中是如何把虚拟DOM 渲染成真实DOM,但具体组件挂载的过程是十分复杂的,也只有更加详细理解组件的挂载过程,才可以进行理解局部组件和全局组件的运行机制,从而理解组件库的实现原理。
一个组件在运行的过程中需要维护组件的生命周期函数、组件渲染的子树、组件是否已经被挂载、组件自身的状态等等包含与组件有关的状态信息,所以需要创建一个组件实例来进行维护,而组件实例本质上是一个对象。一个组件的挂载的时候主要经过以下过程:
- 创建组件实例对象
- 运行组件的 setup 方法,获取返回的结果设置到组件实例对象上
- 设置组件 render 函数中的 this 代理对象,通过代理对象进行获取组件 setup 方法返回的内容和其他诸如 props 的内容
- 通过 call 方法调用组件的 render 函数,将其 this 指向设置为上文第三步中的组件实例的代理对象,同时获取组件的虚拟DOM
- 将获取到的组件虚拟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 中的数据和保存在组件实例上的相关数据,所以就需要通过代理对象来实现访问了。
我们上面更加详细实现组件挂载过程的代码放在上文提到的 Gitee 仓库:
此复杂版本的代码放在了 v1 文件下:
运行还是跟上文 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 文件浏览器渲染结果如下:

我们可以看到我们已经成功运行我们复杂版本的组件挂载过程的代码,并且可以在组件里面继续引用组件,也就是相当于局部组件,并且也成功实现渲染。
小结
具体的渲染逻辑,在这里作一个小小的总结,一开始渲染根组件虚拟DOM 的时候,在 patch 函数环节会进行判断虚拟DOM 的类型是什么,字符串类型则进行元素创建,对象类型则进行组件挂载,组件挂载的过程主要是生成虚拟DOM,再通过 patch 函数进行渲染成真实DOM;虚拟DOM 类型是字符串,进行元素创建的时候,会进行虚拟DOM 的 children 内容的判断,如果是字符串则进行文本元素的创建,如果是数组则继续递归调用 patch 函数进行渲染数组元素中的虚拟DOM,那么在 patch 函数环节又会进行判断虚拟DOM 的类型继而进行不同的操作,周而复始,直至把所有的虚拟DOM 渲染完毕。
通过上文,我们已经初步了解了一个局部组件是如何引用并实现渲染的,但真实 Vue3 框架中,远要复杂很多,所以我们在下文中继续 Vue3 全局组件和局部组件的实现原理讲解。