L36 · 组件实例与渲染流程
🎯 本节目标:理解 Vue 3 组件从创建到挂载到更新的完整流程
📦 本节产出:完整的组件生命周期心智模型 + 理解 setup 的执行时机
🔗 前置钩子:L31-L35 的响应式 + VDOM + 调度器
🔗 后续钩子:L37 将讲 Composition API 的设计决策本节继续以 Vue 3.5.43 的 VDOM 组件运行时为参照。普通应用通过公开 API 使用这些能力,内部字段不是稳定的业务接口。下面的流程图省略异步组件、Suspense 与 SSR 的额外分支。
1. 组件渲染全流程
2. 组件实例内部结构
每个组件实例都有自己的状态、渲染关系与副作用作用域。下面是选取的字段,不是供应用复制实现的完整接口:
| 字段 | 作用 |
|---|---|
| uid / type / parent | 实例编号、组件定义与父实例 |
| vnode | 代表该组件的 VNode |
| subTree | 该组件 render 返回的 VNode 子树 |
| props / attrs / slots / emit | 组件输入、未声明属性、插槽与事件出口 |
| setupState / data | setup 返回对象的处理结果、Options API data;setup 也可以返回 render 函数 |
| render / effect | 渲染函数与驱动组件更新的 ReactiveEffect |
| update / job | 同步运行 effect 的入口,与用于排队的更新任务;两者不应混为一谈 |
| scope | 当前组件的 EffectScope |
| bm / m / bu / u / bum / um | 对应生命周期回调列表,可由多次注册或 mixin 等产生 |
| provides / refs | 注入上下文与模板 ref |
组件 job 的 id 通常取 uid,父组件通常先创建,所以更新队列可先处理父任务,再处理子任务。3.5.43 使用 queueJob 插入与 PRE 标记等规则,不只是刷新前做一次全队列 sort。component.ts、renderer.ts
3. 挂载流程详解
关键细节
- setup 对每个组件实例在创建时执行;该实例普通响应式更新只重跑 render,不重新运行 setup。销毁后重建或挂载另一实例会再次执行 setup。
- render 在渲染 effect 中读取响应式依赖;setup 中创建状态本身,不等于已经收集了 render 的所有依赖。
- 同步子组件的 mounted 先于父组件;异步组件和 Suspense 子树不包含在这一保证里。mounted 表示挂载流程完成,不表示页面所有资源已加载或已绘制。
普通同步父子组件:
Parent setup → Parent beforeMount → Parent render
→ Child setup → Child beforeMount → Child render + patch
→ Child mounted
Parent mounted如果 mount 的容器本身不在 document 中,也不能仅凭 mounted 认定节点已显示在屏幕上。onMounted
4. 更新流程详解
哪些情况下子组件可以跳过更新
父组件重渲染不意味着每个子组件都要重渲染。运行时会结合 props 值、插槽、指令、编译提示等判断;子组件自身读取的响应式状态变化仍可独立触发更新。
<!-- 固定的字符串属性值 -->
<ChildComp msg="固定消息" />
<!-- 动态绑定表达式;变量叫 staticString 也不自动等于编译期常量 -->
<ChildComp :msg="message" />动态绑定重新求值后若值未变,通常可避免对应的 props 更新;但不能据此保证子组件在所有条件下都不更新。第 7 节用“父状态变化、子 props 稳定”和“子自身状态变化”分别观察。
5. 生命周期完整图
NOTE
KeepAlive 从活动视图移出组件并保留缓存时是 deactivation,不是最终卸载;再次取出缓存会 activated。缓存被淘汰或 KeepAlive 自身卸载时,组件仍会真正 unmount。onActivated 也会在首次挂载触发,onDeactivated 也参与最终卸载,不应把它们当作 mounted/unmounted 的简单替代。KeepAlive 生命周期
各钩子的使用建议
| 钩子 | 典型用途 | 注意事项 |
|---|---|---|
setup | 初始化状态、注册钩子 | 当前组件 DOM 尚未挂载 |
onMounted | 操作 DOM、初始化第三方库 | 同步子组件已 mounted;该钩子不在 SSR 执行 |
onBeforeUpdate | 读取旧 DOM 状态(如滚动位置) | 很少用 |
onUpdated | DOM 已更新后的操作 | 修改影响自身渲染的状态可能造成更新循环 |
onBeforeUnmount | 清理准备 | DOM 还在 |
onUnmounted | 移除事件监听、定时器、WebSocket |
6. setup 的特殊地位
Options API 与 setup 同时存在时,setup 先于 beforeCreate / created 执行;在 setup 被调用之前,props 与 slots 已经初始化。不能把 setup 机械地写成“beforeCreate 与 created 之间”,也不能等同于这两个钩子的简单合并。beforeCreate 的执行顺序
import { defineComponent, ref, onMounted } from 'vue'
export default defineComponent({
beforeCreate() { console.log('Options beforeCreate') },
created() { console.log('Options created') },
setup() {
console.log('setup')
const count = ref(0)
onMounted(() => console.log('DOM 已挂载'))
return { count }
},
})
// 创建实例时,前三条日志依次是 setup → Options beforeCreate → Options created。<script setup> 的编译原理
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const count = ref(0)
onMounted(() => console.log('已挂载'))
</script>
<template><button @click="count++">{{ count }}</button></template>script setup 的主体会进入实例的 setup 执行范围,模板可以使用顶层绑定。这并不意味着编译结果必定是 return { 所有变量 }:不同模式可能返回经过处理的绑定,也可能让内联 render 闭包直接访问它们。普通 script 的模块顶层则按模块执行,不是每个实例都重新运行。
另外,script setup 组件默认不把这些绑定全部公开给父组件的模板 ref;要公开特定成员,使用 defineExpose。模板可访问与外部实例公开接口是两件事。script setup
7. 从代码到像素:完整链路
运行一个实例生命周期实验
保存到 L31 的独立实验目录,把 index.html 的 script src 改成 /lifecycle-demo.ts,保留 L35 的 vite.config.ts(Vue 编译标志),继续使用 L33 的 Vite 命令。这里使用真实 Vue 3.5.43,不扩展 mini 的组件系统:
// labs/mini-reactivity/lifecycle-demo.ts
import {
createApp, defineComponent, h, ref, watch,
onBeforeMount, onMounted, onBeforeUpdate, onUpdated,
onBeforeUnmount, onUnmounted, onScopeDispose,
} from 'vue'
const log = (message: string) => console.log(message)
const Child = defineComponent({
props: { msg: { type: String, required: true } },
setup(props) {
log('Child setup')
const count = ref(0)
watch(count, value => log(`Child watch ${value}`))
onBeforeMount(() => log('Child beforeMount'))
onMounted(() => log('Child mounted'))
onBeforeUpdate(() => log('Child beforeUpdate'))
onUpdated(() => log('Child updated'))
onBeforeUnmount(() => log('Child beforeUnmount'))
onUnmounted(() => log('Child unmounted'))
const onResize = () => log('Child resize')
onMounted(() => window.addEventListener('resize', onResize))
onScopeDispose(() => {
window.removeEventListener('resize', onResize)
log('Child scope disposed')
})
return () => {
log('Child render')
return h('section', [
h('p', `${props.msg} / 子计数 ${count.value}`),
h('button', { onClick: () => { count.value++ } }, '子组件增加'),
])
}
},
})
const Parent = defineComponent({
beforeCreate() { log('Parent beforeCreate') },
created() { log('Parent created') },
setup() {
log('Parent setup')
const count = ref(0)
const visible = ref(true)
onBeforeMount(() => log('Parent beforeMount'))
onMounted(() => log('Parent mounted'))
onBeforeUpdate(() => log('Parent beforeUpdate'))
onUpdated(() => log('Parent updated'))
return () => {
log('Parent render')
return h('main', [
h('h1', '组件实例实验'),
h('p', `父计数 ${count.value}`),
h('button', { onClick: () => { count.value++ } }, '父组件增加'),
h('button', { onClick: () => { visible.value = !visible.value } }, visible.value ? '卸载子组件' : '重新创建子组件'),
visible.value ? h(Child, { msg: '固定消息' }) : null,
])
}
},
})
createApp(Parent).mount('#app')先观察 setup 与 Options 钩子的顺序。点“父组件增加”,子 props 不变且没有动态插槽,子 render 可跳过;点“子组件增加”,子自身依赖仍会触发更新。卸载后再创建会出现新的 Child setup,子计数重置,说明“setup 一次”只限同一个实例。卸载期间 scope 清理移除 resize listener;应当由谁创建外部资源,谁登记相应清理。
8. 本节总结
检查清单
- [ ] 能画出组件从 setup → render → mount 的完整流程
- [ ] 理解组件实例包含的核心属性(uid、subTree、effect、hooks)
- [ ] 理解更新流程:数据变化 → queueJob → render → patch → DOM
- [ ] 知道嵌套组件的 mounted 顺序(子先于父)
- [ ] 理解每个生命周期钩子的调用时机和适用场景
- [ ] 理解 setup 的实例执行范围及其先于 Options beforeCreate/created 的顺序
- [ ] 理解
<script setup>的编译原理
Git 提交
git add .
git commit -m "L36: 组件实例 + 渲染流程 + 生命周期 + script setup 编译"🔗 → 下一节
L37 将回答一个设计哲学问题:Composition API 如何组织复用逻辑,它与仍受支持的 Options API 各有什么适用场景?