18_第_12_章_组件的实现原理
约 13767 字大约 46 分钟
2026-10-05
开场白:为什么需要组件化?
在上一篇中,我们着重讲解了渲染器的基本原理与实现。渲染器主要负责将虚拟 DOM 渲染为真实 DOM —— 我们只需要用虚拟 DOM 来描述最终呈现的内容即可。
但是,当我们编写比较复杂的页面时,用来描述页面结构的虚拟 DOM 的代码量会变得越来越多,或者说页面模板会变得越来越大。
这时候,我们就需要组件化的能力。
比喻:组件化就像搭乐高。一大块乐高没法直接搭城堡,必须先拼成一块一块小积木(房子、树、桥),然后再组合起来。每个小积木就是一个组件。
有了组件,我们就可以:
- 将一个大的页面拆分为多个部分
- 每一个部分都可以作为单独的组件
- 这些组件共同组成完整的页面
组件化的实现同样需要渲染器的支持。从本章开始,我们就来详细讨论 Vue.js 中的组件化。
12.1 渲染组件
组件长啥样?
我们先来看看,从用户的角度,一个 Vue.js 组件长什么样。
从用户的角度看,一个有状态组件就是一个选项对象,长这样:
// MyComponent 是一个组件,它的值是一个选项对象
const MyComponent = {
name: 'MyComponent',
data() {
return { foo: 1 }
}
}我们逐行看:
const MyComponent = { ... }:定义一个组件,本质上就是一个普通的 JavaScript 对象。name: 'MyComponent':给组件起个名字(可选,方便调试)。data() { return { foo: 1 } }:data是一个函数,返回组件的初始状态(这里是{ foo: 1 })。注意:必须是函数!这样每次创建组件实例时都会得到一份独立的数据(防止多个组件共享同一份数据)。
但是,渲染器眼里,组件又是啥?
如果从渲染器内部的角度看,组件则是一个特殊类型的虚拟 DOM 节点。
我们回忆一下,前面讲过的虚拟 DOM 是怎么描述普通标签的:
// 该 vnode 用来描述普通标签
const vnode = {
type: 'div'
// ...
}type 属性是标签名称 'div'。
为了描述片段(多个根节点的 vnode),让 type 为 Fragment:
// 该 vnode 用来描述片段
const vnode = {
type: Fragment
// ...
}为了描述文本节点,让 type 为 Text:
// 该 vnode 用来描述文本节点
const vnode = {
type: Text
// ...
}那怎么描述组件呢?答案很简单 —— 把 type 设成组件选项对象本身:
// 该 vnode 用来描述组件,type 属性存储组件的选项对象
const vnode = {
type: MyComponent
// ...
}结论:一句话总结,组件对用户是选项对象,对渲染器是一种 vnode.type === object 的特殊 vnode。
渲染器如何处理组件?
我们之前实现的 patch 函数需要增加一个分支,用来处理组件类型的 vnode:
function patch(n1, n2, container, anchor) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
// 作为普通元素处理
} else if (type === Text) {
// 作为文本节点处理
} else if (type === Fragment) {
// 作为片段处理
} else if (typeof type === 'object') {
// vnode.type 的值是选项对象,作为组件来处理
if (!n1) {
// 挂载组件
mountComponent(n2, container, anchor)
} else {
// 更新组件
patchComponent(n1, n2, anchor)
}
}
}四个分支里,新增的是 typeof type === 'object' 这一支。进去之后再按 !n1 分一次流:没有旧 vnode 就走 mountComponent 挂载,有旧 vnode 就走 patchComponent 更新。
用一张流程图看 patch 的判断流程:
组件必须包含 render 函数
渲染器有能力处理组件后,下一步我们要设计组件在用户层面的接口。
一个组件必须包含一个渲染函数,即 render 函数,并且渲染函数的返回值必须是虚拟 DOM。
比喻:渲染函数就像是组件的“施工图纸”,它告诉框架“我要盖成什么样”。
const MyComponent = {
// 组件名称,可选
name: 'MyComponent',
// 组件的渲染函数,其返回值必须为虚拟 DOM
render() {
// 返回虚拟 DOM
return {
type: 'div',
children: `我是文本内容`
}
}
}我们逐行看:
name: 'MyComponent':组件名(可选,调试用)。render() { return { ... } }:render是渲染函数,返回值必须是 vnode。这里返回的是一个<div>标签,文字内容是我是文本内容。
mountComponent:真正干活的函数
我们用一个 vnode 描述组件,然后用渲染器来渲染它:
// 用来描述组件的 VNode 对象,type 属性值为组件的选项对象
const CompVNode = {
type: MyComponent
}
// 调用渲染器来渲染组件
renderer.render(CompVNode, document.querySelector('#app'))渲染器中真正完成组件渲染任务的是 mountComponent 函数:
function mountComponent(vnode, container, anchor) {
// 通过 vnode 获取组件的选项对象,即 vnode.type
const componentOptions = vnode.type
// 获取组件的渲染函数 render
const { render } = componentOptions
// 执行渲染函数,获取组件要渲染的内容,即 render 函数返回的虚拟 DOM
const subTree = render()
// 最后调用 patch 函数来挂载组件所描述的内容,即 subTree
patch(null, subTree, container, anchor)
}整段代码只做了三件事:从 vnode 上拿到组件选项对象,从选项对象里取出 render,把 render() 的返回值当作普通 vnode 挂到容器里 —— 其中第一个参数传 null,表示这是首次挂载(没有旧的可以复用)。
用一张流程图看 mountComponent 的工作流程:
这样,我们就实现了最基本的组件化方案。
12.2 组件状态与自更新
给组件加上“自己的数据”
在上一节中,我们完成了组件的初始渲染。但这只是骨架 —— 真实的组件需要有自己的状态(数据)。
小细节:组件“自己拥有的数据”,当它变化时,组件会自动重新渲染。这就是 Vue.js 响应式的魅力。这个概念第 3 章的【状态】那张卡已经建过卡了,这里只作回顾。
比喻:组件状态就像是一个“记分牌”。比赛进行时,记分牌上的数字会变 —— 你不需要手动去擦黑板上的数字,记分牌自己会更新。
我们尝试给组件加上状态:
const MyComponent = {
name: 'MyComponent',
// 用 data 函数来定义组件自身的状态
data() {
return {
foo: 'hello world'
}
},
render() {
return {
type: 'div',
children: `foo 的值是: ${this.foo}` // 在渲染函数内使用组件状态
}
}
}我们逐行看:
data() { return { foo: 'hello world' } }:定义组件的初始状态,foo的初始值是字符串'hello world'。render()里的this.foo:在渲染函数里通过this.foo访问这个状态。注意是this.foo,不是直接foo—— 因为状态数据被绑定到了this上。
实现组件状态的初始化
下面我们改造 mountComponent,让组件能“拥有”自己的状态:
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
const { render, data } = componentOptions
// 调用 data 函数得到原始数据,并调用 reactive 函数将其包装为响应式数据
const state = reactive(data())
// 调用 render 函数时,将其 this 设置为 state,
// 从而 render 函数内部可以通过 this 访问组件自身状态数据
const subTree = render.call(state, state)
patch(null, subTree, container, anchor)
}我们逐行看:
const { render, data } = componentOptions:从选项对象中同时取出render和data。const state = reactive(data()):调用data()拿到原始数据,然后用reactive包一层变成响应式数据。这一步是核心 ——reactive会让数据变化时自动触发更新。const subTree = render.call(state, state):调用render。用.call(state, state)把this绑定成state,这样render里的this.foo就能拿到响应式数据了。
实现组件自身状态的初始化需要两个步骤:
- 通过组件的选项对象取得
data函数并执行,然后调用reactive函数将data函数返回的状态包装为响应式数据; - 在调用
render函数时,将其this的指向设置为响应式数据state,同时将state作为render函数的第一个参数传递。
经过这两步,我们就实现了对组件自身状态的支持,以及在渲染函数内访问组件自身状态的能力。
实现自更新:状态变了页面自动跟着变
当组件自身状态发生变化时,我们需要有能力触发组件更新 —— 这就叫组件的自更新。
回忆一下第 2 篇响应式系统的内容 —— 我们有 effect 函数,可以把一段代码“包”成副作用函数,让它自动追踪响应式数据的变化。
这里,我们把整个渲染任务包装到一个 effect 中:
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
const { render, data } = componentOptions
const state = reactive(data())
// 将组件的 render 函数调用包装到 effect 内
effect(() => {
const subTree = render.call(state, state)
patch(null, subTree, container, anchor)
})
}看 effect(() => { ... }) 这一层:
- 把
render的调用包在effect里。 effect会自动追踪这次调用中用到的响应式数据(这里是state.foo)。- 一旦
state.foo变化,effect会自动重新执行整个渲染流程。
这样,一旦组件自身的响应式数据发生变化,组件就会自动重新执行渲染函数,从而完成更新。
但是!这里有个性能问题:
⚠️
effect的执行是同步的。如果多次修改响应式数据,会导致渲染函数执行多次。这实际上是没有必要的!
比如说:
state.foo = 1
state.foo = 2
state.foo = 3理想情况下,我们希望渲染只执行一次(最终结果是 foo = 3),但实际上 effect 会跑 3 次。
所以我们需要设计一个机制:无论对响应式数据进行多少次修改,副作用函数都只会重新执行一次。
调度器:把多次更新合并成一次
为了实现“多次修改只触发一次更新”,我们需要一个调度器(scheduler)。
小细节:当副作用函数需要重新执行时,我们不立即执行它,而是把它缓冲到一个微任务队列里。等到执行栈清空后,再从队列里取出来执行。这就是第 2 篇【可调度性】那张卡讲的东西。
比喻:调度器就像快递员 —— 不是每收到一个包裹就立刻派送,而是先攒一波到仓库里,等攒够了再统一派送。这样效率高多了。
具体实现如下:
// 任务缓存队列,用一个 Set 数据结构来表示,这样就可以自动对任务进行去重
const queue = new Set()
// 一个标志,代表是否正在刷新任务队列
let isFlushing = false
// 创建一个立即 resolve 的 Promise 实例
const p = Promise.resolve()
// 调度器的主要函数,用来将一个任务添加到缓冲队列中,并开始刷新队列
function queueJob(job) {
// 将 job 添加到任务队列 queue 中
queue.add(job)
// 如果还没有开始刷新队列,则刷新之
if (!isFlushing) {
// 将该标志设置为 true 以避免重复刷新
isFlushing = true
// 在微任务中刷新缓冲队列
p.then(() => {
try {
// 执行任务队列中的任务
queue.forEach(job => job())
} finally {
// 重置状态
isFlushing = false
queue.clear()
}
})
}
}我们逐行看:
const queue = new Set():用Set数据结构当任务队列。Set天然自动去重 —— 同一个任务加多次也只会存在一份。let isFlushing = false:标记位,表示“是否正在刷新队列”,避免重复调度。const p = Promise.resolve():一个立即 resolve 的 Promise 实例。p.then(...)会把回调放到微任务队列里。function queueJob(job):这是调度器的主入口。把任务加到队列,并触发刷新。if (!isFlushing):如果还没开始刷新,就刷新一次(否则说明已经在刷新了,不用重复触发)。p.then(() => { queue.forEach(job => job()) }):等当前同步代码跑完后,在微任务里把所有任务跑一遍。finally { isFlushing = false; queue.clear() }:无论成功失败,最后都要重置状态、清空队列。
为什么要用微任务(microtask),而不是宏任务(macrotask)?
微任务比宏任务执行时机更早(微任务在当前同步代码结束后立即执行,宏任务要等下一轮事件循环)。这样用户能更快看到更新后的页面。
有了 queueJob 函数之后,我们可以在创建渲染副作用时使用它:
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
const { render, data } = componentOptions
const state = reactive(data())
effect(() => {
const subTree = render.call(state, state)
patch(null, subTree, container, anchor)
}, {
// 指定该副作用函数的调度器为 queueJob 即可
scheduler: queueJob
})
}我们把 queueJob 作为 effect 的第二个参数(scheduler 选项)传进去。effect 在重新执行副作用之前,会先调用 scheduler,所以渲染任务就不会立即执行,而是被加到队列里,等微任务时一次性跑完。
用一张流程图看调度器的工作流程:
当前的缺陷:每次更新都“重新挂载”
上面的实现还有一个严重的缺陷。
看 effect 里这行:
patch(null, subTree, container, anchor)第一个参数总是 null!这意味着,每次更新发生时都会进行全新的挂载,而不会打补丁(不会复用旧的 DOM)。
⚠️ 这样写不对。正确的做法应该是:每次更新时,都拿新的 subTree 与上一次渲染的 subTree 进行打补丁。
这就需要引入一个非常重要的概念 —— 组件实例。
12.3 组件实例与组件的生命周期
组件实例:组件的“档案袋”
组件实例(component instance):
一个对象,用来存放组件运行过程中的所有信息。比如:注册了哪些生命周期函数、上次渲染的子树(subTree)、组件是否已经被挂载、组件自身的状态(data)等等。
比喻:组件实例就像一个“档案袋”。每个组件都有一个专属的档案袋,里面装着它的各种“资料”:注册了哪些事件、上次干了啥活、现在啥状态…… 框架要查组件的啥信息,直接翻这个档案袋就行。
我们改造一下 mountComponent,引入组件实例:
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
const { render, data } = componentOptions
const state = reactive(data())
// 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
const instance = {
// 组件自身的状态数据,即 data
state,
// 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
isMounted: false,
// 组件所渲染的内容,即子树(subTree)
subTree: null
}
// 将组件实例设置到 vnode 上,用于后续更新
vnode.component = instance
effect(() => {
// 调用组件的渲染函数,获得子树
const subTree = render.call(state, state)
// 检查组件是否已经被挂载
if (!instance.isMounted) {
// 初次挂载,调用 patch 函数第一个参数传递 null
patch(null, subTree, container, anchor)
// 重点:将组件实例的 isMounted 设置为 true,这样当更新发生时就不会再次进行挂载操作,
// 而是会执行更新
instance.isMounted = true
} else {
// 当 isMounted 为 true 时,说明组件已经被挂载,只需要完成自更新即可,
// 所以在调用 patch 函数时,第一个参数为组件上一次渲染的子树,
// 意思是,使用新的子树与上一次渲染的子树进行打补丁操作
patch(instance.subTree, subTree, container, anchor)
}
// 更新组件实例的子树
instance.subTree = subTree
}, { scheduler: queueJob })
}实例上最关键的是 isMounted(分流挂载 / 更新)和 subTree(提供可以拿来打补丁的“上一次子树”),state 则是组件自己的数据。
用一张流程图看挂载 vs 更新的判断:
实际我们可以在需要的时候,任意地在组件实例 instance 上添加需要的属性。但需要注意的是,应该尽可能保持组件实例轻量,以减少内存占用。
生命周期钩子:组件在关键时刻喊你一声
组件有自己的“一生”:从出生(创建)到长大(挂载)到更新到销毁,每个阶段都有一些关键时刻。Vue.js 允许我们在这些时刻注册回调函数,这些回调就叫生命周期钩子。
生命周期钩子(lifecycle hooks):
组件在特定时刻会触发的函数。你可以注册这些函数,让框架在那个时刻“喊你一声”。
比喻:生命周期就像人的一生 —— 出生、上学、毕业、工作、退休,每个阶段都有重要时刻。生命周期钩子就像“入学通知”、“毕业典礼”,到了那个时刻学校/单位就会通知你。
常见的生命周期钩子:
| 钩子名 | 触发时机 |
|---|---|
beforeCreate | 组件刚创建,data 还没初始化 |
created | 组件创建完毕,data 已经初始化 |
beforeMount | 即将挂载到 DOM |
mounted | 已挂载到 DOM |
beforeUpdate | 数据变了,准备重新渲染 |
updated | 重新渲染完毕 |
beforeUnmount | 卸载前 |
unmounted | 卸载后 |
小细节:本章为了聚焦“创建 → 挂载 → 更新”这条主线,不展开讲卸载流程 —— 表格最后两个钩子(beforeUnmount、unmounted)属于卸载阶段,下面代码里只实现了前六个钩子的调用时机。
怎么实现生命周期钩子呢?其实很简单 —— 从组件选项对象中取出钩子函数,在合适的时机调用。
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
// 从组件选项对象中取得组件的生命周期函数
const { render, data, beforeCreate, created, beforeMount, mounted, beforeUpdate, updated } = componentOptions
// 在这里调用 beforeCreate 钩子
beforeCreate && beforeCreate()
const state = reactive(data())
const instance = {
state,
isMounted: false,
subTree: null
}
vnode.component = instance
// 在这里调用 created 钩子
created && created.call(state)
effect(() => {
const subTree = render.call(state, state)
if (!instance.isMounted) {
// 在这里调用 beforeMount 钩子
beforeMount && beforeMount.call(state)
patch(null, subTree, container, anchor)
instance.isMounted = true
// 在这里调用 mounted 钩子
mounted && mounted.call(state)
} else {
// 在这里调用 beforeUpdate 钩子
beforeUpdate && beforeUpdate.call(state)
patch(instance.subTree, subTree, container, anchor)
// 在这里调用 updated 钩子
updated && updated.call(state)
}
instance.subTree = subTree
}, { scheduler: queueJob })
}我们重点关注钩子的调用位置:
beforeCreate && beforeCreate():在 data 初始化之前调用。xxx && xxx()是一种简写:左边是函数才调用,否则不调用(因为钩子是可选的)。created && created.call(state):在 data 初始化之后调用。用.call(state)把this绑定到响应式数据上。beforeMount && beforeMount.call(state):在首次 patch 之前调用。mounted && mounted.call(state):在首次 patch 之后调用(此时组件已挂载到 DOM)。beforeUpdate/updated:在更新分支的 patch 前后调用。
用一张时间线图看生命周期的流转:
这就是组件生命周期的实现原理。
小细节:由于可能存在多个同样的组件生命周期钩子(例如来自 mixins 中的生命周期钩子),因此实际 Vue.js 中通常会把钩子序列化为一个数组,但核心原理不变。
12.4 props 与组件的被动更新
props 是啥?
props(properties):
组件的“外来数据” —— 由父组件传给子组件。
比喻:props 就像点菜时服务员递给厨房的小票。小票上写着“红烧肉一份、不要辣”,厨房(子组件)就照着小票做菜。小票(props)只能看,不能改 —— 厨房要改菜谱,得让服务员重新开张单。
在虚拟 DOM 层面,组件的 props 与普通 HTML 标签的属性差别不大。假设我们有如下模板:
<MyComponent title="A Big Title" :other="val" />这段模板对应的虚拟 DOM 是:
const vnode = {
type: MyComponent,
props: {
title: 'A Big Title',
other: this.val
}
}可以看到,模板与虚拟 DOM 几乎是“同构”的。
在编写组件时,我们需要显式地指定组件会接收哪些 props 数据:
const MyComponent = {
name: 'MyComponent',
// 组件接收名为 title 的 props,并且该 props 的类型为 String
props: {
title: String
},
render() {
return {
type: 'div',
children: `count is: ${this.title}` // 访问 props 数据
}
}
}看 props 选项:
props: { title: String }:声明这个组件期待接收一个叫title的 props,类型是String。this.title:在渲染函数里通过this访问这个 props(注意this既能访问 data 也能访问 props)。
所以,对于一个组件来说,有两部分关于 props 的内容:
- 为组件传递的
props数据,即组件的vnode.props对象。 - 组件选项对象中定义的
props选项,即MyComponent.props对象。
resolveProps:解析出最终的 props
我们需要结合这两个对象,解析出组件在渲染时需要用到的 props 数据:
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
// 从组件选项对象中取出 props 定义,即 propsOption
const { render, data, props: propsOption /* 其他省略 */ } = componentOptions
beforeCreate && beforeCreate()
const state = reactive(data())
// 调用 resolveProps 函数解析出最终的 props 数据与 attrs 数据
const [props, attrs] = resolveProps(propsOption, vnode.props)
const instance = {
state,
// 将解析出的 props 数据包装为 shallowReactive 并定义到组件实例上
props: shallowReactive(props),
isMounted: false,
subTree: null
}
vnode.component = instance
// 省略部分代码
}
// resolveProps 函数用于解析组件 props 和 attrs 数据
function resolveProps(options, propsData) {
const props = {}
const attrs = {}
// 遍历为组件传递的 props 数据
for (const key in propsData) {
if (key in options) {
// 如果为组件传递的 props 数据在组件自身的 props 选项中有定义,则将其视为合法的 props
props[key] = propsData[key]
} else {
// 否则将其作为 attrs
attrs[key] = propsData[key]
}
}
// 最后返回 props 与 attrs 数据
return [ props, attrs ]
}看 resolveProps 函数:
const props = {}和const attrs = {}:准备两个空对象,分别用来装“真正的 props”和“额外的属性”。for (const key in propsData):遍历父组件传过来的所有属性。if (key in options):如果这个 key 在组件自己声明的 props 选项里,就放到props对象里(这是合法的 props)。else { attrs[key] = ... }:否则放到attrs里 —— attrs 就是“组件没声明但还是传过来了”的属性。return [props, attrs]:返回两个对象。
举个例子。父组件这么写:
<MyComponent title="hello" class="big" custom-info="abc" />子组件声明了 props: { title: String }。那 resolveProps 的结果就是:
{ props: { title: 'hello' }, attrs: { class: 'big', 'custom-info': 'abc' } }两点需要注意:
- 在 Vue.js 3 中,没有定义在
MyComponent.props选项中的props数据将存储到attrs对象中。 - 上述实现中没有包含默认值、类型校验等内容的处理。实际上,这些内容也都是围绕
MyComponent.props以及vnode.props这两个对象展开的,实现起来并不复杂。
props 变化会触发组件更新
props 本质上是父组件的数据。当 props 发生变化时,会触发父组件重新渲染。
假设父组件的模板如下:
<template>
<MyComponent :title="title"/>
</template>其中,响应式数据 title 的初始值为字符串 "A Big Title"。首次渲染时,父组件的虚拟 DOM 为:
// 父组件要渲染的内容
const vnode = {
type: MyComponent,
props: {
title: 'A Big Title'
}
}当响应式数据 title 发生变化时,父组件的渲染函数会重新执行。假设 title 变成 "A Small Title",那新产生的虚拟 DOM 为:
const vnode = {
type: MyComponent,
props: {
title: 'A Small Title'
}
}父组件会进行自更新。在更新过程中,渲染器发现父组件的 subTree 包含组件类型的虚拟节点,所以会调用 patchComponent 函数完成子组件的更新。
被动更新(passive update):
由父组件自更新所引起的子组件更新。我们把这种更新叫作“被动”更新 —— 因为不是子组件自己的状态变了,是它爹(父组件)变了。
在 patchComponent 中我们需要做两件事:
- 检测子组件是否真的需要更新(props 可能没变)。
- 如果需要更新,则更新子组件的
props、slots等内容。
patchComponent 函数的具体实现:
function patchComponent(n1, n2, anchor) {
// 获取组件实例,即 n1.component,同时让新的组件虚拟节点 n2.component 也指向组件实例
const instance = (n2.component = n1.component)
// 获取当前的 props 数据
const { props } = instance
// 调用 hasPropsChanged 检测为子组件传递的 props 是否发生变化,如果没有变化,则不需要更新
if (hasPropsChanged(n1.props, n2.props)) {
// 调用 resolveProps 函数重新获取 props 数据
const [ nextProps ] = resolveProps(n2.type.props, n2.props)
// 更新 props
for (const k in nextProps) {
props[k] = nextProps[k]
}
// 删除不存在的 props
for (const k in props) {
if (!(k in nextProps)) delete props[k]
}
}
}
function hasPropsChanged(
prevProps,
nextProps
) {
const nextKeys = Object.keys(nextProps)
// 如果新旧 props 的数量变了,则说明有变化
if (nextKeys.length !== Object.keys(prevProps).length) {
return true
}
// 只有有不相等的 props,则说明有变化
for (let i = 0; i < nextKeys.length; i++) {
const key = nextKeys[i]
if (nextProps[key] !== prevProps[key]) return true
}
return false
}我们逐行看 patchComponent:
const instance = (n2.component = n1.component):把新 vnode 的 component 也指向同一个实例。这样下次还能找到它。const { props } = instance:取出实例上的 props。if (hasPropsChanged(n1.props, n2.props)):判断新旧 props 是否变了。没变就不更新!const [ nextProps ] = resolveProps(...):变了就重新解析。for (const k in nextProps) { props[k] = nextProps[k] }:把新值更新到 instance.props 上(因为instance.props是shallowReactive,这一赋值会自动触发组件更新)。for (const k in props) { if (!(k in nextProps)) delete props[k] }:把新 vnode 里没有的旧 props 删掉(比如原来传了title,现在父组件去掉这个属性了,那子组件也得跟着删)。
hasPropsChanged 干了啥?
- 先看数量:新旧 props 的 key 数量不一样,肯定有变化。
- 再看每个 key 的值:只要有一个不相等,就有变化。
- 都检查完还没发现差异,就返回
false(没变化)。
两点需要注意:
- 需要将组件实例添加到新的组件
vnode对象上(n2.component = n1.component),否则下次更新时将无法取得组件实例。 instance.props对象本身是浅响应的(即shallowReactive)。因此,在更新组件的props时,只需要设置instance.props对象下的属性值即可触发组件重新渲染。
在上面的实现中,我们没有处理 attrs 与 slots 的更新。attrs 的更新本质上与更新 props 的原理相似。而 slots 会在后续章节中讲解。
渲染上下文:让 this 既能访问 data 又能访问 props
由于 props 数据与组件自身的状态数据都需要暴露到渲染函数中,并使得渲染函数能够通过 this 访问它们,因此我们需要封装一个渲染上下文对象。
渲染上下文(renderContext):
一个代理对象,拦截对数据的读写,让 this 既能拿到 data 也能拿到 props。
比喻:渲染上下文就像一个“万能小秘书”。你在公司里喊他“小秘,我要那个文档”,他自动从你桌上的文件夹(state)找,找不到再去档案室(props)找。
具体实现:
function mountComponent(vnode, container, anchor) {
// 省略部分代码
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null
}
vnode.component = instance
// 创建渲染上下文对象,本质上是组件实例的代理
const renderContext = new Proxy(instance, {
get(t, k, r) {
// 取得组件自身状态与 props 数据
const { state, props } = t
// 先尝试读取自身状态数据
if (state && k in state) {
return state[k]
} else if (k in props) { // 如果组件自身没有该数据,则尝试从 props 中读取
return props[k]
} else {
console.error('不存在')
}
},
set (t, k, v, r) {
const { state, props } = t
if (state && k in state) {
state[k] = v
} else if (k in props) {
console.warn(`Attempting to mutate prop "${k}". Props are readonly.`)
} else {
console.error('不存在')
}
}
})
// 生命周期函数调用时要绑定渲染上下文对象
created && created.call(renderContext)
// 省略部分代码
}重点看 Proxy:
new Proxy(instance, { ... }):创建一个代理对象,包装真实的 instance。所有对renderContext.xxx的访问都会经过这个代理的get/set。get(t, k, r):当代码里写this.foo时触发。逻辑是:- 先从
state(data)里找; - 找不到再从
props里找; - 都找不到就报错。
- 先从
set(t, k, v, r):当代码里写this.foo = newVal时触发。逻辑是:- 如果是 state 的 key,就更新 state;
- 如果是 props 的 key,警告并阻止(props 是只读的!);
- 都不是就报错。
created.call(renderContext):把this绑定成renderContext,所以created里写this.foo也能正常拿到 data/props。
实际上,除了组件自身的数据以及 props 数据之外,完整的组件还包含 methods、computed 等选项中定义的数据和方法,这些内容都应该在渲染上下文对象中处理。
12.5 setup 函数的作用与实现
setup 是组合式 API 的“入口”
组件的 setup 函数是 Vue.js 3 新增的组件选项。它和 Vue.js 2 中存在的其他组件选项都不一样。
setup 函数(setup function):
组件选项之一,专门为组合式 API 服务。用来建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等。
比喻:传统选项(data / methods / computed / watch 散落在组件的不同地方)就像是工具箱里的工具东一件西一件。setup 函数就像是一个大工作台,把所有的工具都集中在一处,方便你组织代码。
setup 函数的两个关键特性:
- 只执行一次:在组件整个生命周期中,
setup只在挂载时执行一次。 - 返回值有两种:
- 返回一个函数 → 该函数将作为组件的
render函数 - 返回一个对象 → 对象中的数据将暴露给模板使用
- 返回一个函数 → 该函数将作为组件的
两种用法
用法一:返回一个函数(作为 render)
const Comp = {
setup() {
// setup 函数可以返回一个函数,该函数将作为组件的渲染函数
return () => {
return { type: 'div', children: 'hello' }
}
}
}这种方式常用于组件不是以模板来表达其渲染内容的情况。
⚠️ 如果组件以模板来表达其渲染的内容,那么
setup函数不可以再返回函数,否则会与模板编译生成的渲染函数产生冲突。
用法二:返回一个对象(暴露数据给模板)
const Comp = {
setup() {
const count = ref(0)
// 返回一个对象,对象中的数据会暴露到渲染函数中
return {
count
}
},
render() {
// 通过 this 可以访问 setup 暴露出来的响应式数据
return { type: 'div', children: `count is: ${this.count}` }
}
}看重点:
setup()里用ref(0)创建响应式数据,并放进返回的对象。render()里通过this.count访问 —— 这就是setup暴露出来的。
setup 接收两个参数
setup 函数接收两个参数。第一个参数是 props 数据对象,第二个参数也是一个对象,通常称为 setupContext:
const Comp = {
props: {
foo: String
},
setup(props, setupContext) {
props.foo // 访问传入的 props 数据
// setupContext 中包含与组件接口相关的重要数据
const { slots, emit, attrs, expose } = setupContext
// ...
}
}setupContext(setup context):
setup 函数的第二个参数(一个对象),与组件接口相关的重要数据都装在里面。
setupContext 里有四个重要成员:
slots:组件接收到的插槽(后面章节讲)。emit:一个函数,用来发射自定义事件。attrs:12.4 节介绍过,那些没声明成 props 的“额外属性”。expose:一个函数,用来显式地对外暴露组件数据。
⚠️ 需要说明的是,在本书编写时,与
expose相关的 API 设计仍然在讨论中(详见 Vue.js RFC #210),后续可能会调整。
通常情况下,不建议将 setup 与 Vue.js 2 中其他组件选项混合使用(例如 data、watch、methods 等选项,我们称之为“传统”组件选项)。这是因为在 Vue.js 3 的场景下,更加提倡组合式 API,setup 函数就是为组合式 API 而生的。混用组合式 API 的 setup 选项与传统组件选项并不是明智的选择,因为这样会带来语义和理解上的负担。
setup 的实现
我们围绕上述能力来实现 setup:
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
// 从组件选项中取出 setup 函数
let { render, data, setup, props: propsOption /* 省略其他选项 */ } = componentOptions
beforeCreate && beforeCreate()
const state = data ? reactive(data()) : null
const [props, attrs] = resolveProps(propsOption, vnode.props)
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null
}
// setupContext,由于我们还没有讲解 emit 和 slots,所以暂时只需要 attrs
const setupContext = { attrs }
// 调用 setup 函数,将只读版本的 props 作为第一个参数传递,避免用户意外地修改 props 的值,
// 将 setupContext 作为第二个参数传递
const setupResult = setup(shallowReadonly(instance.props), setupContext)
// setupState 用来存储由 setup 返回的数据
let setupState = null
// 如果 setup 函数的返回值是函数,则将其作为渲染函数
if (typeof setupResult === 'function') {
// 报告冲突
if (render) console.error('setup 函数返回渲染函数,render 选项将被忽略')
// 将 setupResult 作为渲染函数
render = setupResult
} else {
// 如果 setup 的返回值不是函数,则作为数据状态赋值给 setupState
setupState = setupResult
}
vnode.component = instance
const renderContext = new Proxy(instance, {
get(t, k, r) {
const { state, props } = t
if (state && k in state) {
return state[k]
} else if (k in props) {
return props[k]
} else if (setupState && k in setupState) {
// 渲染上下文需要增加对 setupState 的支持
return setupState[k]
} else {
console.error('不存在')
}
},
set (t, k, v, r) {
const { state, props } = t
if (state && k in state) {
state[k] = v
} else if (k in props) {
console.warn(`Attempting to mutate prop "${k}". Props are readonly.`)
} else if (setupState && k in setupState) {
// 渲染上下文需要增加对 setupState 的支持
setupState[k] = v
} else {
console.error('不存在')
}
}
})
// 省略部分代码
}看核心几行:
const setupContext = { attrs }:构造 setupContext 对象。暂时只放了attrs,后面会补emit、slots。const setupResult = setup(shallowReadonly(instance.props), setupContext):调用 setup 函数。- 第一个参数是
shallowReadonly(instance.props)(只读的 props)—— 用shallowReadonly包一层,是为了防止用户在 setup 里意外修改 props。 - 第二个参数是 setupContext。
- 第一个参数是
if (typeof setupResult === 'function'):判断返回值类型。- 如果是函数 → 当作 render 用;
- 如果不是函数 → 当作数据对象用。
if (render) console.error(...):如果组件已经有 render 了,setup 又返回函数,就报警告(防止冲突)。renderContext增加setupState分支:渲染上下文现在能找 3 个地方 —— state / props / setupState。优先级是 state > props > setupState。
几点需要注意:
setupContext是一个对象,由于我们还没有讲解关于emit和slots的内容,因此setupContext暂时只包含attrs。- 我们通过检测
setup函数的返回值类型来决定应该如何处理它。如果它的返回值为函数,则直接将其作为组件的渲染函数。这里需要注意的是,为了避免产生歧义,我们需要检查组件选项中是否已经存在render选项,如果存在,则需要打印警告信息。 - 渲染上下文
renderContext应该正确地处理setupState,因为setup函数返回的数据状态也应该暴露到渲染环境。
12.6 组件事件与 emit 的实现
组件之间除了“父传子”(props),还有“子传父”的需求 —— 子组件怎么通知父组件“我这里发生了点事儿”?
先认下“事件”和 emit 这两个词
事件(event):
子组件主动通知父组件“我这里发生了点事儿”的一套机制,在 Vue.js 里由 emit 这个函数发起。
比喻:小孩在家里摔倒了,妈妈听不见 —— 怎么办?小孩喊一声:“妈妈!”(这就是 emit)。妈妈听到后跑过来处理(这就是监听)。Vue.js 里的“emit”就是这么个意思:子组件主动喊一嗓子,父组件在远处听着。
emit 的写法
那 emit 具体怎么写呢?看下面这段代码:
const MyComponent = {
name: 'MyComponent',
setup(props, { emit }) {
// 发射 change 事件,并传递给事件处理函数两个参数
emit('change', 1, 2)
return () => {
return // ...
}
}
}我们逐行看:
const MyComponent = { ... }:定义一个组件(选项对象)。setup(props, { emit }) { ... }:setup 的第二个参数叫setupContext,里面包含emit。我们解构出emit。emit('change', 1, 2):发射一个叫'change'的事件,并且传了两个参数1和2。父组件那边的处理函数会收到这两个参数。return () => { return ... }:返回渲染函数(可以先忽略,重点看 emit)。
当使用这个组件时,父组件可以监听这个事件:
<MyComponent @change="handler" />我们逐行看:
<MyComponent />:使用 MyComponent 组件。@change="handler":@change是v-on:change的简写,意思是“监听 change 事件”,由handler函数来处理。
上面这段模板,最终会被编译成下面这个虚拟 DOM:
const CompVNode = {
type: MyComponent,
props: {
onChange: handler
}
}看核心两行:
type: MyComponent:vnode 的type是组件对象本身(前面章节讲过,组件就是一种 vnode)。props: { onChange: handler }:注意!模板里的@change在编译后变成了onChange(驼峰命名)。这个onChange直接被存到了props里。
结论:自定义事件 change 被编译成名为 onChange 的属性,并存储在 props 数据对象中。 这是一种“约定”,也可以理解为 Vue.js 的一个小规则。框架设计者也可以按自己的期望来设计事件的编译结果,但 Vue.js 选择的就是这种约定。
emit 的实现
接下来看看 emit 怎么实现。本质就是:根据事件名称去 props 对象中找对应的处理函数并执行。
function mountComponent(vnode, container, anchor) {
// 省略部分代码
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null
}
// 定义 emit 函数,它接收两个参数
// event: 事件名称
// payload: 传递给事件处理函数的参数
function emit(event, ...payload) {
// 根据约定对事件名称进行处理,例如 change --> onChange
const eventName = `on${event[0].toUpperCase() + event.slice(1)}`
// 根据处理后的事件名称去 props 中寻找对应的事件处理函数
const handler = instance.props[eventName]
if (handler) {
// 调用事件处理函数并传递参数
handler(...payload)
} else {
console.error('事件不存在')
}
}
// 将 emit 函数添加到 setupContext 中,用户可以通过 setupContext 取得 emit 函数
const setupContext = { attrs, emit }
// 省略部分代码
}我们逐行看核心部分:
function emit(event, ...payload) { ... }:定义 emit 函数。- 第一个参数
event是事件名(比如'change')。 - 第二个参数
...payload是剩余参数语法,把所有传进来的额外参数收成一个数组(比如emit('change', 1, 2)里,payload 就是[1, 2])。 const eventName = ...这一行:用模板字符串(反引号包裹)拼接出on+ 事件名首字母大写。原代码是`on${event[0].toUpperCase() + event.slice(1)}`:其中event[0].toUpperCase()把'c'变成'C',event.slice(1)拿到'hange',拼起来就是'onChange'。
- 第一个参数
const handler = instance.props[eventName]:去 props 里找'onChange'这个键,找到就拿出来,找不到就是undefined。if (handler) { handler(...payload) }:如果有处理函数,就调用它。...payload是展开运算符,把数组[1, 2]又拆成两个参数1, 2传过去。else { console.error('事件不存在') }:没找到就给个错误提示。const setupContext = { attrs, emit }:把 emit 函数放到 setupContext 里,组件里就能setup(props, { emit })这样拿出来用了。
不过这里有一个坑需要处理。
⚠️ 坑在哪?我们在前面讲
props的时候说过,任何没有显式声明为props的属性,都会跑到attrs里去(而不是props)。这就会导致:父组件用@change="handler"时,如果子组件没有显式声明props: ['onChange'](一般也不会声明),那handler就被放到了attrs里 ——instance.props里就找不到了,emit 就失效了。
为了解决这个坑,我们需要在解析 props 的时候对事件类型的 props 做特殊处理:
function resolveProps(options, propsData) {
const props = {}
const attrs = {}
for (const key in propsData) {
// 以字符串 on 开头的 props,无论是否显式地声明,都将其添加到 props 数据中,而不是添加到 attrs 中
if (key in options || key.startsWith('on')) {
props[key] = propsData[key]
} else {
attrs[key] = propsData[key]
}
}
return [ props, attrs ]
}我们逐行看:
for (const key in propsData):遍历所有父组件传过来的属性。if (key in options || key.startsWith('on')):判断条件有两个,用||(或)连起来。key in options→ 这个 key 在组件显式声明的 props 里(正常的 prop 走这条)。key.startsWith('on')→ 这个 key 以'on'开头(事件类属性的特征,比如onChange、onClick)。- 满足任意一个 → 放到
props里。
else { attrs[key] = propsData[key] }:都不满足,才放到attrs里。
结论:这样处理之后,无论子组件有没有显式声明 onChange,父组件传过来的 onChange 都会被放到 props 里,emit 就能正常找到它了。
emit 这一节从头到尾只做了一件事 —— 把事件名转成约定好的形式,去 props 里找到处理函数并调用:
12.7 插槽的工作原理与实现
除了 props 和 emit,组件还有另一种更灵活的通信方式 —— 插槽(slots)。
插槽是个啥?
插槽(slots):
父组件往子组件里“塞”内容的方式。子组件在模板里挖几个“槽位”,父组件决定往里面塞什么内容。
比喻 1(酒店房间里的“插座”):墙上有几个预留的孔位(叫 header、body、footer),酒店不关心你插什么电器,你想插台灯就插台灯,想插充电器就插充电器。
比喻 2(工厂里的“插槽模具”):模具上挖了几个位置(header、body、footer),具体用什么材料填充由“买家”决定。
子组件模板:挖槽位
先来看一个有插槽的子组件模板:
<template>
<header><slot name="header" /></header>
<div>
<slot name="body" />
</div>
<footer><slot name="footer" /></footer>
</template><header>、<div>、<footer>:三个“容器”,里面各挖了一个槽。<slot name="header" />:挖了一个叫header的槽位。父组件可以传一段 HTML 给它。<slot name="body" />和<slot name="footer" />:另外两个槽位。
父组件模板:填内容
当父组件使用这个组件时,可以往槽里塞东西:
<MyComponent>
<template #header>
<h1>我是标题</h1>
</template>
<template #body>
<section>我是内容</section>
</template>
<template #footer>
<p>我是注脚</p>
</template>
</MyComponent><MyComponent>...</MyComponent>:使用 MyComponent 组件。<template #header>...</template>:用template标签 +#header(v-slot:header的简写)指定这块内容是给 header 槽的。#body、#footer:分别给 body 和 footer 槽填内容。
插槽在编译后变成了啥?
上面这段父组件模板会被编译成下面这个渲染函数:
// 父组件的渲染函数
function render() {
return {
type: MyComponent,
// 组件的 children 会被编译成一个对象
children: {
header() {
return { type: 'h1', children: '我是标题' }
},
body() {
return { type: 'section', children: '我是内容' }
},
footer() {
return { type: 'p', children: '我是注脚' }
}
}
}
}我们逐行看:
type: MyComponent:vnode 的 type 是 MyComponent 组件对象。children: { header() {...}, body() {...}, footer() {...} }:注意!组件的children不是数组,而是一个对象,每个 key 是一个槽名,对应的 value 是一个函数(叫插槽函数)。
小细节:插槽函数的返回值就是具体的插槽内容(虚拟 DOM)。函数化包装是因为插槽内容只在真正用到的时候才会执行,这跟 React 中 render props 的概念非常相似。
子组件模板编译后变成了啥?
MyComponent 组件的模板,则会被编译为下面这个渲染函数:
// MyComponent 组件模板的编译结果
function render() {
return [
{
type: 'header',
children: [this.$slots.header()]
},
{
type: 'body',
children: [this.$slots.body()]
},
{
type: 'footer',
children: [this.$slots.footer()]
}
]
}this.$slots.header():调用 header 槽位的插槽函数,得到一个 vnode 节点。children: [this.$slots.header()]:把插槽函数的返回值放进 children 数组。
结论:渲染插槽内容的过程,就是调用插槽函数并渲染由其返回的内容的过程。
插槽的实现:超级简单
在运行时,插槽依赖 setupContext 中的 slots 对象:
function mountComponent(vnode, container, anchor) {
// 省略部分代码
// 直接使用编译好的 vnode.children 对象作为 slots 对象即可
const slots = vnode.children || {}
// 将 slots 对象添加到 setupContext 中
const setupContext = { attrs, emit, slots }
}const slots = vnode.children || {}:直接用编译好的 vnode.children 作为 slots 对象。就这么简单,不需要任何额外处理,因为 vnode.children 已经是{ header() {...}, body() {...} }的结构了。const setupContext = { attrs, emit, slots }:把 slots 也放到 setupContext 里。
为了让用户在 render 函数内和生命周期钩子内能通过 this.$slots 来访问插槽内容,我们还需要在 renderContext 中特殊对待 $slots 属性:
function mountComponent(vnode, container, anchor) {
// 省略部分代码
const slots = vnode.children || {}
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
// 将插槽添加到组件实例上
slots
}
// 省略部分代码
const renderContext = new Proxy(instance, {
get(t, k, r) {
const { state, props, slots } = t
// 当 k 的值为 $slots 时,直接返回组件实例上的 slots
if (k === '$slots') return slots
// 省略部分代码
},
set (t, k, v, r) {
// 省略部分代码
}
})
// 省略部分代码
}改动只有两处:把 slots 存到 instance.slots 上,再在 renderContext 的 get 里加一个 $slots 分支 —— 它是个特殊键,需要单独处理(前面 state / props / setupState 都是普通键)。
整体数据流图:
12.8 注册生命周期
在 Vue.js 3 中,有一部分组合式 API(Composition API)是用来注册生命周期钩子函数的,比如 onMounted、onUpdated 等。看下面这段代码:
import { onMounted } from 'vue'
const MyComponent = {
setup() {
onMounted(() => {
console.log('mounted 1')
})
// 可以注册多个
onMounted(() => {
console.log('mounted 2')
})
// ...
}
}import { onMounted } from 'vue':从 vue 包导入onMounted这个函数。onMounted(() => {...}):在 setup 里调用onMounted,传一个回调函数进去。这个回调函数会在组件挂载完毕之后被自动调用。- 可以注册多个:可以多次调用
onMounted,所有回调都会被注册,组件挂载时它们会依次执行。
疑问:在 A 组件的 setup 里调用 onMounted 会把这个钩子注册到 A 组件上;在 B 组件的 setup 里调用 onMounted 会注册到 B 组件上 —— 这是怎么做到的呢?
解法:用全局变量 currentInstance
currentInstance(当前组件实例):
一个模块级的全局变量,用来存着“当前正在被初始化的那个组件实例”。
比喻:你去银行办业务,进门先取号(号上写着你是几号窗口的)。柜员叫号时只看当前柜台上放着的那个号 —— 那个号就是 currentInstance。onMounted 就像你取的号一样,它知道当前正在被初始化的组件实例是哪个,于是就把钩子注册到那个实例上。
我们维护一个全局变量 currentInstance,用它来存储当前组件实例:
// 全局变量,存储当前正在被初始化的组件实例
let currentInstance = null
// 该方法接收组件实例作为参数,并将该实例设置为 currentInstance
function setCurrentInstance(instance) {
currentInstance = instance
}let currentInstance = null:模块级全局变量,初始为null。function setCurrentInstance(instance) { currentInstance = instance }:一个 setter 函数,用来切换currentInstance的值。
在 mountComponent 里切换 currentInstance
有了 currentInstance 变量之后,我们就在 mountComponent 里进入 setup 之前切换为当前实例,退出 setup 之后重置:
function mountComponent(vnode, container, anchor) {
// 省略部分代码
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
slots,
// 在组件实例中添加 mounted 数组,用来存储通过 onMounted 函数注册的生命周期钩子函数
mounted: []
}
// 省略部分代码
// setup
const setupContext = { attrs, emit, slots }
// 在调用 setup 函数之前,设置当前组件实例
setCurrentInstance(instance)
// 执行 setup 函数
const setupResult = setup(shallowReadonly(instance.props), setupContext)
// 在 setup 函数执行完毕之后,重置当前组件实例
setCurrentInstance(null)
// 省略部分代码
}改动就三处:instance 上加一个 mounted: [] 数组存钩子(因为可以注册多个);进入 setup 之前 setCurrentInstance(instance),setup 执行完再 setCurrentInstance(null)。setup 里每调一次 onMounted,钩子就被 push 到此刻的 currentInstance 上 —— 所以最后重置那一步必须有,否则钩子会注册到上一个组件身上。
执行流程图:
onMounted 函数的实现
组件实例的维护搞定了,接下来看 onMounted 本身的实现:
function onMounted(fn) {
if (currentInstance) {
// 将生命周期函数添加到 instance.mounted 数组中
currentInstance.mounted.push(fn)
} else {
console.error('onMounted 函数只能在 setup 中调用')
}
}if (currentInstance):判断当前是否在 setup 里。如果currentInstance不为null,说明确实在 setup 中。currentInstance.mounted.push(fn):把生命周期函数 push 到当前实例的mounted数组。else { console.error(...) }:如果不在 setup 中调用,给个错误提示。
结论:整体实现非常简单直观 —— 通过 currentInstance 拿到当前实例,把钩子函数 push 到实例的 mounted 数组里。
在合适的时机调用钩子
最后一步,在组件挂载完毕时遍历 instance.mounted 并依次执行:
function mountComponent(vnode, container, anchor) {
// 省略部分代码
effect(() => {
const subTree = render.call(renderContext, renderContext)
if (!instance.isMounted) {
// 省略部分代码
// 遍历 instance.mounted 数组并逐个执行即可
instance.mounted && instance.mounted.forEach(hook => hook.call(renderContext))
} else {
// 省略部分代码
}
instance.subTree = subTree
}, {
scheduler: queueJob
})
}if (!instance.isMounted):判断是首次挂载(不是更新)。instance.mounted && instance.mounted.forEach(...):遍历数组依次执行。hook.call(renderContext):用renderContext作为this调用钩子函数,这样钩子里this.xxx也能访问到 state、props 等。
- 放在
effect里:因为我们用了响应式的 effect 副作用函数来渲染。!instance.isMounted这个分支只在首次渲染时执行一次,刚好是“组件挂载完毕”的时机。
**结论:**对于除 mounted 以外的生命周期钩子函数(比如 onUpdated、onUnmounted),其原理完全一样:组件实例上加一个数组,在合适的时机遍历执行。
12.9 总结
在本章中,我们首先讨论了如何使用虚拟节点来描述组件。使用虚拟节点的 vnode.type 属性来存储组件对象,渲染器根据虚拟节点的该属性的类型来判断它是否是组件。如果是组件,则渲染器会使用 mountComponent 和 patchComponent 来完成组件的挂载和更新。
接着,我们讨论了组件的自更新。我们知道,在组件挂载阶段,会为组件创建一个用于渲染其内容的副作用函数。该副作用函数会与组件自身的响应式数据建立响应联系。当组件自身的响应式数据发生变化时,会触发渲染副作用函数重新执行,即重新渲染。但由于默认情况下重新渲染是同步执行的,这导致无法对任务去重,因此我们在创建渲染副作用函数时,指定了自定义的调用器。该调度器的作用是,当组件自身的响应式数据发生变化时,将渲染副作用函数缓冲到微任务队列中。有了缓冲队列,我们即可实现对渲染任务的去重,从而避免无用的重新渲染所导致的额外性能开销。
然后,我们介绍了组件实例。它本质上是一个对象,包含了组件运行过程中的状态,例如组件是否挂载、组件自身的响应式数据,以及组件所渲染的内容(即 subtree)等。有了组件实例后,在渲染副作用函数内,我们就可以根据组件实例上的状态标识,来决定应该进行全新的挂载,还是应该打补丁。
而后,我们讨论了组件的 props 与组件的被动更新。副作用自更新所引起的子组件更新叫作子组件的被动更新。我们还介绍了渲染上下文(renderContext),它实际上是组件实例的代理对象。在渲染函数内访问组件实例所暴露的数据都是通过该代理对象实现的。
之后,我们讨论了 setup 函数。该函数是为了组合式 API 而生的,所以我们要避免将其与 Vue.js 2 中的“传统”组件选项混合使用。setup 函数的返回值可以是两种类型,如果返回函数,则将该函数作为组件的渲染函数;如果返回数据对象,则将该对象暴露到渲染上下文中。
emit 函数包含在 setupContext 对象中,可以通过 emit 函数发射组件的自定义事件。通过 v-on 指令为组件绑定的事件在经过编译后,会以 onXxx 的形式存储到 props 对象中。当 emit 函数执行时,会在 props 对象中寻找对应的事件处理函数并执行它。
随后,我们讨论了组件的插槽。它借鉴了 Web Component 中 <slot> 标签的概念。插槽内容会被编译为插槽函数,插槽函数的返回值就是向槽位填充的内容。<slot> 标签则会被编译为插槽函数的调用,通过执行对应的插槽函数,得到外部向槽位填充的内容(即虚拟 DOM),最后将该内容渲染到槽位中。
最后,我们讨论了 onMounted 等用于注册生命周期钩子函数的方法的实现。通过 onMounted 注册的生命周期函数会被注册到当前组件实例的 instance.mounted 数组中。为了维护当前正在初始化的组件实例,我们定义了全局变量 currentInstance,以及用来设置该变量的 setCurrentInstance 函数。
名词速查
| 词 | 一句话 |
|---|---|
组件实例 instance | 组件的“档案袋”,装组件运行中的全部状态信息 |
| 生命周期钩子 | 组件在特定时刻触发的函数,框架到点“喊你一声” |
props | 父组件传给子组件的“外来数据”,只能看不能改 |
| 被动更新 | 父组件自更新引起的子组件更新(不是自己状态变了) |
渲染上下文 renderContext | 组件实例的 Proxy,让 this 找得到 data、props、setupState、$slots |
setup 函数 | 组合式 API 的入口,只执行一次,可返回函数或对象 |
setupContext | setup 的第二个参数,装 slots / emit / attrs / expose |
emit | 子组件“喊”父组件一声的函数,按事件名去 props 里找处理函数 |
插槽 slots | 子组件挖槽位、父组件填内容的通信方式,编译后是 { 槽名() { ... } } |
currentInstance | 模块级全局变量,记着“当前正在初始化哪个组件实例” |
本章小结
- 组件 = 特殊的 vnode:
vnode.type是组件对象本身,渲染器用mountComponent/patchComponent处理它。 mountComponent:组件挂载的“主函数”,负责创建实例、解析 props、调用 setup、创建 effect。- 组件实例
instance:组件的“档案袋”,包含 state / props / slots / isMounted / subTree / mounted 等。 - 组件状态 + 自更新:data 通过
effect+ 调度器实现自动重新渲染,调度器把多次更新合并到微任务队列。 - 生命周期钩子:选项式 API 用
mounted: function() {...};组合式 API 用onMounted(fn)注册到instance.mounted数组。 props与被动更新:父组件更新引起子组件更新叫“被动更新”;resolveProps解析出 props 和 attrs;事件名onXxx始终放入 props。renderContext(渲染上下文):组件实例的 Proxy,让this既能访问 data 又能访问 props / setupState /$slots。setup函数:组合式 API 入口;可返回函数(render)或对象(暴露数据);接收(props, setupContext)。emit:子传父的机制;emit 函数根据事件名找props.onXxx并调用;@change编译成onChange存到 props。- 插槽(slots):父传“模板片段”的机制;编译后 vnode.children 是
{ name() { return vnode } }形式;renderContext中$slots特殊处理。 currentInstance+onMounted:组合式 API 注册生命周期的关键 —— 全局变量记录当前实例,钩子 push 到instance.mounted,挂载时遍历执行。
恭喜! 读完这一章,你已经掌握了 Vue.js 组件系统的核心原理。下一篇(第 13 章)我们会继续深入:异步组件(按需加载,性能更优)、函数式组件(无状态轻量组件)—— 让你的组件系统更强大。
