组件挂载机制 -> 从创建到响应式更新
在上一章中,我们实现了 createApp API,建立了应用的初始化和挂载机制。然而,当我们尝试挂载一个组件时,会发现组件并没有真正渲染到页面上。这是因为我们还缺少组件挂载的核心逻辑。
本章将深入组件挂载的完整流程,实现从组件创建、实例化、渲染,到响应式更新的整个生命周期。
问题场景
让我们先看一个典型的组件使用场景:
<div id="app"></div>
<script type="module">
import { h, createApp, ref } from '../dist/vue.esm.js'
const Comp = {
setup() {
const count = ref(0)
return {
count
}
},
render() {
return h('div', this.count)
}
}
createApp(Comp).mount('#app')
</script>
</body>
当我们运行这段代码时,会发现组件并没有渲染到页面上。这是因为我们的 patch 函数还不知道如何处理组件类型的虚拟节点。
识别组件类型
扩展 ShapeFlags
首先,我们需要在创建虚拟节点时识别组件类型。在 createVNode 中增加对组件的支持:
/**
* 标准化 children,确保数字等类型转换为字符串
*/
function normalizeChildren(children) {
if (isNumber(children)) {
children = String(children)
}
return children
}
/**
* 创建虚拟节点,支持元素和组件类型
*/
export function createVNode(type, props?, children?) {
children = normalizeChildren(children)
let shapeFlag
// 根据 type 的类型设置 shapeFlag
if (isString(type)) {
// 普通 HTML 元素
shapeFlag = ShapeFlags.ELEMENT
}
else if (isObject(type)) {
// 有状态组件(包含 setup、render 等方法的对象)
shapeFlag = ShapeFlags.STATEFUL_COMPONENT
}
const vnode = {
type,
props,
children,
shapeFlag
// ...
}
return vnode
}
设计要点:
- 通过
ShapeFlags.STATEFUL_COMPONENT标记组件类型 - 区分元素 (
isString) 和组件 (isObject) - 为后续的
patch分发提供类型信息
实现组件渲染流程
patch 函数中的组件处理
组件的渲染与元素、文本节点一样,都需要经过挂载和更新两个阶段。让我们在 patch 函数中添加组件的处理逻辑:
function patch(n1, n2, container, anchor = null) {
// 如果新旧节点完全相同,无需处理
if (n1 === n2)
return
// 如果节点类型发生变化,先卸载旧节点
if (n1 && !isSameVNodeType(n1, n2)) {
unmount(n1)
n1 = null
}
/**
* 根据虚拟节点类型分发处理:文本、元素、组件
*/
const { shapeFlag, type } = n2
switch (type) {
case Text:
processText(n1, n2, container, anchor)
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
// 处理普通 DOM 元素
processElement(n1, n2, container, anchor)
}
else if (shapeFlag & ShapeFlags.COMPONENT) {
// 处理组件
processComponent(n1, n2, container, anchor)
}
}
}
核心设计:
- 通过
shapeFlag位运算快速判断节点类型 - 统一的处理流程:
processText、processElement、processComponent - 每个
process函数内部再区分挂载和更新
processComponent 的实现
创建 processComponent 函数来处理组件的挂载和更新:
/**
* 组件实例结构
*/
function createComponentInstance(vnode) {
const { type } = vnode
return {
type, // 组件对象(包含 setup、render 等)
vnode, // 组件的虚拟节点
render: null, // 渲染函数
setupState: null, // setup 返回的响应式状态
props: {}, // 组件 props
attrs: {}, // 组件 attrs
subTree: null, // 子树(render 函数返回的虚拟节点)
isMounted: false // 是否已挂载
}
}
/**
* 挂载组件(初始版本)
*/
function mountComponent(vnode, container, anchor) {
const instance = createComponentInstance(vnode)
// 执行 setup 并获取响应式状态
const setupResult = proxyRefs(vnode.type.setup())
instance.setupState = setupResult
instance.render = vnode.type.render
// 调用 render 函数生成子树
const subTree = instance.render.call(instance.setupState)
// 渲染子树到页面
patch(null, subTree, container, anchor)
instance.subTree = subTree
}
/**
* 处理组件的挂载和更新
*/
function processComponent(n1, n2, container, anchor) {
if (n1 == null) {
// 首次挂载
mountComponent(n2, container, anchor)
}
}
至此,组件已经可以成功挂载到页面了。
实现响应式更新
问题:响应式数据变化不触发重新渲染
让我们尝试添加一个按钮来更新组件状态:
<body>
<div id="app"></div>
<button id="btn">count++</button>
<script type="module">
import { h, createApp, ref } from '../dist/vue.esm.js'
const Comp = {
setup() {
const count = ref(0)
// 点击按钮增加计数
btn.onclick = () => {
count.value++
}
return {
count
}
},
render() {
return h('div', this.count)
}
}
createApp(Comp).mount('#app')
</script>
</body>
问题分析:
当我们点击按钮时,count.value 确实发生了变化,但页面并没有更新。这是因为:
- 响应式数据变化时,只有包裹在
ReactiveEffect中的函数才会重新执行 - 我们的
render函数目前并没有被ReactiveEffect包裹
解决方案:将组件渲染逻辑包裹在 effect 中
核心思路: 将 render 调用放入 ReactiveEffect,使其成为响应式副作用函数。
function mountComponent(vnode, container, anchor) {
const instance = createComponentInstance(vnode)
const setupResult = proxyRefs(vnode.type.setup())
instance.setupState = setupResult
instance.render = vnode.type.render
// 将渲染逻辑包装为响应式副作用
const componentUpdateFn = () => {
// 调用 render 生成子树,this 指向 setupState
const subTree = instance.render.call(instance.setupState)
// 渲染到页面
patch(null, subTree, container, anchor)
}
// 创建响应式 effect
const effect = new ReactiveEffect(componentUpdateFn)
effect.run()
}
工作原理:
ReactiveEffect会在首次执行时收集依赖- 当
count.value被读取时,会触发依赖收集 - 当
count.value变化时,会自动重新执行componentUpdateFn
问题:重复渲染导致 DOM 累加
现在响应式更新可以工作了,但有一个新问题:每次更新都会在页面上追加新的 DOM 元素,而不是更新现有元素。
原因分析:
function componentUpdateFn() {
const subTree = instance.render.call(instance.setupState)
// 问题:每次都传递 null 作为旧节点,导致总是执行挂载而非更新
patch(null, subTree, container, anchor)
}
区分挂载与更新
设计思路
我们需要在 componentUpdateFn 中区分两种情况:
- 首次挂载:
n1 = null,执行挂载逻辑 - 响应式更新:
n1 = prevSubTree,执行 patch 更新
完整实现
function mountComponent(vnode, container, anchor) {
const instance = createComponentInstance(vnode)
const setupResult = proxyRefs(vnode.type.setup())
instance.setupState = setupResult
instance.render = vnode.type.render
const componentUpdateFn = () => {
if (!instance.isMounted) {
// === 首次挂载 ===
const subTree = instance.render.call(instance.setupState)
patch(null, subTree, container, anchor)
// 保存子树和挂载状态
instance.subTree = subTree
instance.isMounted = true
}
else {
// === 响应式更新 ===
const prevSubTree = instance.subTree
const subTree = instance.render.call(instance.setupState)
// 传入旧节点进行 diff 更新
patch(prevSubTree, subTree, container, anchor)
// 更新子树引用
instance.subTree = subTree
}
}
const effect = new ReactiveEffect(componentUpdateFn)
effect.run()
}
设计要点:
- 使用
isMounted标志位区分挂载和更新 - 首次挂载时保存
subTree供后续更新使用 - 更新时传入
prevSubTree和新的subTree进行 diff
代码重构:单一职责原则
架构优化
为了提高代码的可维护性,我们将组件实例相关的逻辑抽取到独立模块中:
- 组件实例创建:
createComponentInstance - 组件初始化:
setupComponent
创建 component.ts
在 packages/runtime-core/src/component.ts 中:
import { proxyRefs } from '@vue/reactivity'
/**
* 创建组件实例
* @param vnode - 组件的虚拟节点
* @returns 组件实例对象
*/
export function createComponentInstance(vnode) {
const { type } = vnode
const instance = {
type, // 组件选项对象
vnode, // 组件的虚拟节点
render: null, // 渲染函数
setupState: null, // setup 返回的响应式状态
props: {}, // 组件 props
attrs: {}, // 非 prop 的 attribute
subTree: null, // 组件的子树虚拟节点
isMounted: false // 是否已完成挂载
}
return instance
}
/**
* 初始化组件实例
* @param instance - 组件实例
*/
export function setupComponent(instance) {
const { type } = instance
// 执行 setup 并使用 proxyRefs 自动解包 ref
const setupResult = proxyRefs(type.setup())
instance.setupState = setupResult
// 绑定 render 函数
instance.render = type.render
}
设计优势:
- 关注点分离:组件实例管理独立于渲染逻辑
- 可测试性:可以单独测试组件实例的创建和初始化
- 可扩展性:后续添加生命周期、props 处理等功能时有清晰的入口
优化后的 mountComponent
import { createComponentInstance, setupComponent } from './component'
function mountComponent(vnode, container, anchor) {
// 1. 创建组件实例
const instance = createComponentInstance(vnode)
// 2. 初始化组件(执行 setup、绑定 render)
setupComponent(instance)
// 3. 设置响应式副作用
const componentUpdateFn = () => {
if (!instance.isMounted) {
// 首次挂载
const subTree = instance.render.call(instance.setupState)
patch(null, subTree, container, anchor)
instance.subTree = subTree
instance.isMounted = true
}
else {
// 响应式更新
const prevSubTree = instance.subTree
const subTree = instance.render.call(instance.setupState)
patch(prevSubTree, subTree, container, anchor)
instance.subTree = subTree
}
}
const effect = new ReactiveEffect(componentUpdateFn)
effect.run()
}
总结
至此,我们完成了 Vue 组件挂载和响应式更新的核心机制:
1. 组件类型识别
- 通过
ShapeFlags.STATEFUL_COMPONENT标记组件类型 - 在
patch函数中分发到processComponent
2. 组件实例化
createComponentInstance:创建组件实例setupComponent:执行 setup、绑定 render
3. 响应式渲染
- 使用
ReactiveEffect包裹渲染逻辑 - 响应式数据变化时自动触发重新渲染
4. 挂载与更新区分
- 通过
isMounted标志区分 - 首次挂载:
patch(null, subTree) - 响应式更新:
patch(prevSubTree, subTree)
这套机制奠定了 Vue 组件系统的基础,后续我们将在此基础上实现组件的 props等特性。