组件渲染过程
组件允许我们将UI划分为独立的,可复用的部分。每个组件都有自己的生命周期,包括初始化、挂载、更新和销毁等阶段。
从用户的角度,一个有状态组件就是一个选项对象。 如果从渲染器的内部实现来看,一个组件则是一个特殊类型的虚拟DOM节点,在vnode中,vnode.type是一个组件选项对象。
const componentVnode = {
type: componentOptions,
}
组件的构成
组件在渲染中必须包含一个 render
函数,并且 render
函数必须返回值是虚拟DOM节点。 如果组件没有定义 render
函数,vue会编码组件的模板字符串为 render
函数。
const MyComponent = {
name: 'MyComponent',
props: {
msg: {
type: String,
default: '默认消息',
},
},
render() {
return {
type: 'div',
children: `我是文本内容`,
}
}
}
props 与组件的被动更新
组件中需要关心的props有两部分:
- 为组件传递的 props 数据,即组件的
vnode.props
对象; - 组件选项对象中定义的 props 选项,即 MyComponent.props 对象。
function resolveProps(propsOptions, propsData) {
const props = {};
const attrs = {};
// 遍历为组件传递的 props 数据
for (const key in propsData) {
// 如果 propsOptions 中不存在该属性,则将其添加到 attrs 对象中
// 以字符串 on 开头的 props,无论是否显式地声明,都将其添加到 props 数据中,而不是添加到 attrs 中
if (key in propsOptions || key.startsWith('on')) {
// 如果为组件传递的 props 数据在组件自身的 props 选项中有定义,则将其视为合法的 props
props[key] = propsData[key];
} else {
// 否则将其作为 attrs
attrs[key] = propsData[key];
}
}
return [props, attrs];
}
vue里将组件选项中定义的props对象和 为组件传递的 props 数据 进行合并, 合并规则如下:
- 如果组件选项中定义的props对象中不存在该属性,则将其添加到 attrs 对象中;
- 如果组件选项中定义的props对象中存在该属性,则将其视为合法的 props,将其添加到 props 对象中;
props 是父组件的响应式数据,当父组件的响应式数据发生变化时,父组件会自动更新。在更新过程中,渲染器发现父组件的subTree中包含了组件类型的虚拟节点,所以会调用patchComponent函数进行子组件的更新。 由父组件更新引发的子组件更新,被称为子组件的被动更新。
当发生子组件的被动更新时, 渲染器要做的是:
- 检测子组件是否需要更新,因为子组件的props可能是不变的;
- 如果子组件需要更新, 则更新子组件的props,slots等内容;
function patchComponent(n1, n2, anchoг) {
// 获取组件实例,即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 数据包装为 shalloReactive 并定义到组件实例上
// 更新 props
for (const k in nextProps) {
props[k] = nextProps[k]
}
// 删除不存在的 props
for (const k in props) {
if (!(k in nextProps)) delete props [k]
}
}
}
上面是子组件的被动更新的实现过程,需要注意:
- 需要将组件实例添加到新的组件vnode对象上,即n2.component = n1.component,否则在后续的更新过程中,渲染器无法获取到组件实例,导致更新失败。
- instance.props 对象是浅响应式的(shallowReactive) ,当组件的props 发生变化时,子组件会自动更新。
setup 函数的作用与实现
组件的 setup 函数是 Vue.js 3 新增的组件选项,它有别于 Vue.js 2 中存在的其他组件选项。setup函数主要用于配置组合式API。
在组件的生命周期中,setup函数只会在被挂载时执行一次,它的返回值可以有两种情况:
- 返回一个函数,该函数将作为组件的 render 函数:
function setup(props, context) {
// 组件的 setup 函数可以返回一个函数,该函数将作为组件的 render 函数
return function render() {
// 组件的 render 函数可以访问组件的 props 数据
return h('div', {}, props.msg)
}
}
这种方式常用于组件写tsx或jsx的情况,比写模板内容更灵活。如果组件写模板内容,setup函数不可以再返回函数,否则会与模板编译生成的渲染函数冲突。
- 如返回一个对象,该对象中包含的数据将暴露给模板使用:
function setup(props, context) {
const count = ref(0)
// 组件的 setup 函数可以返回一个对象,该对象中包含的数据将暴露给模板使用
return {
count,
}
}
function render() {
// 通过 this 可以访问 setup 暴露出来的响应式数据
return { type: 'div', children: `count is: ${this.count}` }
}
setup 函数接收两个参数。第一个参数是props 数据对象,第二个参数也是一个对象, 通常称为setupContext, 它包含了组件实例的一些属性和方法,如:
- attrs: 组件的 attrs 对象,包含了组件的非 props 属性,上面的resolveProps函数中已经介绍过;
- slots: 组件的 slots 对象,包含了组件的插槽内容;
- emit: 组件的 emit 方法,用于触发组件的自定义事件;
- expose: 组件的 expose 方法,用于暴露组件的公共方法;
组件事件与emit 的实现
emit 用来发射组件的自定义事件,如下面的代码所示:
function setup(props, context) {
const emit = context.emit
function handleClick() {
emit('change', 1, 2)
}
return {
handleClick,
}
}
当使用该组件时,我们可以监听由 emit 函数发射的自定义事件:
<MyСomponent @change="handler" />
上面这段模板对应的虚拟 DOM为:
const CompVNode = {
type: MyComponent,
props: {
onChange: handler
}
}
由上面可知,自定义事件change被编译成名为onChange事件了,并存储在props数据对象中。这是vue内部的约定,会把组件中自定义事件编译成 onEventName,放到props中。
插槽的工作原理与实现
组件的插槽是批组件会预留一个槽位,用于接收父组件传递的插槽内容,如下面给出的MyComponent 组件的模板所示:
<!-- MyComponent 组件模板 -->
<template>
<header><slot name="header" :msg="msg" /></header>
<div>
<slot name="body" />
</div>
<footer><slot name="footer" /></footer>
</template>
当父组件中使用 MyComponent 组件时,可以传递插槽内容,如下面所示:
<MyComponent>
<template #header="{msg}">
<h1>{{msg}}</h1>
</template>
<template #body>
<p>这是 MyComponent 组件的内容</p>
</template>
<template #footer>
<p>Footer</p>
</template>
</MyComponent>
上面的父组件的模板会被编译如下的渲染函数:
// 父组件的渲染函数
function render(){
return {
type: MyComponent,
// 组件的 children 会被编译成一个对象
children: {
header() {
return { type: 'h1', children:'Hello World'}
},
body() {
return { type: 'p', children:'这是 MyComponent 组件的内容'}
},
footer(){
return { type: 'p', children:'Footer'}
}
}
}
}
由父组件的渲染函数可以看出,组件模板中的插槽内容会被编译为插槽函数,而插槽函数的返回值就是具体的插槽内容(虚拟 DOM)。
组件 MyComponent 的模板则会被编译为如下渲染函数:
// MyComponent 组件模板的编译结果
function render(){
return [
{ type: 'header', children: [this.$slots.header({msg: this.msg})] },
{ type: 'div', children: [this.$slots.body()] },
{ type: 'footer', children: [this.$slots.footer()] },
]
}
可以看到,渲染插槽内容的过程,就是调用插槽函数并渲染由基返回的内容的过程。
子组件给插槽函数传递数据时, 可以在插槽函数中通过参数接收父组件传递的数据。这也就是插槽函数的参数。
注册生命周期
在vue3中有部分组合式API可以注册组件的生命周期钩子函数,如onMounted、onUpdated、onUnmounted等。 看下面代码:
import { onMounted } from 'vue'
function setup(props, context) {
onMounted(() => {
console.log('组件挂载完成1')
})
// 可以注册多个
onMounted(() => {
console.log('组件挂载完成2')
})
}
在setup函数中注册的生命周期钩子函数会在组件挂载完成时被调用。
这里存在一个问题,就是setup中如何知道注册钩子函数到哪个一个组件实例上?
vue内部维护了一个变量currentInstance, 用来指向当前正在渲染的组件实例,每当初始化组件并执行组件的setup函数前, 先将当前组件实例赋值给currentInstance,再执行组件的setup函数,这样就可以在setup函数中通过currentInstance来获取当前正在被初始化的组件实例,从而将那些通过onMounted函数注册的钩子函数与组件实例进行关联。
// 全局变量,存储当前正在被初始化的组件实例
let currentInstance = null
// 该方法接收组件实例作为参数,并将该实例设置为 currentInstance
function setCurrentInstance(instance) {
currentInstance = instance
}
function onMounted(fn) {
if (currentInstance) {
// 将生命周期函数添加到 instance.mounted 数组中
currentInstance.mounted.push(fn)
} else {
console.error('onMounted 函数只能在setup 中调用')
}
}
可以看到,在onMounted函数中,我们判断了currentInstance是否存在,如果不存在,则抛出错误。这是因为onMounted函数只能在setup函数中调用,而setup函数只能在组件实例初始化时调用。
组件的挂载过程(简化源码)
缓存任务队列
// 任务缓存队列,用一个 Set 数据结构来表示,这样就可以自动对任务进行去重
const queue = new Set()
// 一个标志,代表是否正在刷新任务队列
let isFlushing = false
// 创建一个立即 resolve 的 Promise 实例
const nextTickPromise = Promise.resolve()
// 调度器的主要函数,用来将一个任务添加到缓冲队列中,并开始刷新队列
function queueJob(job) {
// 将 job 添加到任务队列 queue 中
queue.push(job);
// 如果还没有开始刷新队列,则刷新之
if (!isFlushing) {
// 将该标志设置为 true 以避免重复刷新
isFlushing = true;
// 在微任务中刷新缓冲队列
nextTickPromise.then(() => {
try {
// 执行所有任务
job.forEach(job => job());
} finally {
// 清空任务队列,并将标志重置为 false
isFlushing = false;
// 清空任务队列
job.length = 0;
}
});
}
}
组件的挂载过程
// 简化版本的组件挂载过程
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type;
const { render, data, beforeCreate, created, beforeMount, mounted
props: propsOption,
setup,
} = componentOptions;
// 调用 beforeCreate 钩子函数,如果存在的话
beforeCreate && beforeCreate()
// 调用data函数得到原始数据,并调用reactive函数将其转换为响应式数据
const state = reactive(data());
// 调用 resolveProps 函数解析出最终的 props 数据与 attrs 数据
const [props, attrs] = resolveProps (propsOption, vnode.props);
// 直接使用编译好的 vnode.children 对象作为 slots 对象即可
const slots = vnode.children || {}
// 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态信息
const instance = {
// // 组件自身的状态数据,即 data
state,
// 将解析出的 props 数据包装为 shalloReactive 并定义到组件实例上
props: shallowReactive(props),
// 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
isMounted: false,
// 组件所渲染的内容,即子树(subTree)
subTree: null,
slots,
}
// 定义 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) {
// 调用事件处理函数,并传递 payload 参数
handler(...payload)
} else {
console.warn(`不存在事件处理函数: ${eventName}`)
}
}
// setupContext 是一个对象,包含了组件实例的一些属性和方法
const setupContext = {
attrs,
slots,
emit,
}
// 在调用 setup 函数之前,设置当前组件实例
setCurrentInstance(instance)
// 调用 Setup 函数,将只读版本的 props 作为第一个参数传递,避免用戶意外地修改 props 的值,
// 将 setupContext 作为第二个参数传递
const setupResult = setup(shallowReadonly(instance.props), setupContext)
// 在 setup 函数执行完毕之后,重置当前组件实例
setCurrentInstance(null)
// setupState 用来存储由 setup 返回的数据
let setupState = null
// 如果 setup 函数的返回值是函数,则将其作为渲染函数
if (isFunction(setupResult)) {
// 报告冲突
if (render) console.error('setup 函数返回渲染函数,render 选项将被忽略)
// 将 setupResult 作为渲染函数
render = setupResult
} else {
// 如果 setup 的返回值不是函数,则作为数据状态赋值给 setupState
setupState = setupResult
}
// 将组件实例设置到 vnode 上,用于后续更新
vnode.component = instance;
// 创建渲染上下文对象,本质上是组件实例的代理
const renderContext = new Proxy(instance, {
get(t, k, r)) {
// 取得组件自身状态与 props 数据
const { state, props, slots } = t;
// 当k 的值为 $slots 时,直接返回组件实例上的 slots
if (k === '$slots') return slots;
if (state && k in state) {
// 先尝试读取自身状态数据
return state[k]
} else if (props && k in props) {
// 如果组件自身没有该数据,则尝试从 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 (props && k in props) {
// props[k] = v
console.error('不能直接修改 props')
} else if (setupState && k in setupState) {
// 如果 setupState 有该数据,则直接赋值
setupState[k] = v
} else {
console.error('不存在')
}
},
})
// 调用 created 钩子函数,如果存在的话
created && created.call(renderContext)
// 调用effect函数,将render函数包装为响应式函数,
// 当响应式数据发生变化时,会自动触发render函数重新执行
effect(function render() {
// 调用render函数,将this绑定为响应式数据,将响应式数据渲染为虚拟DOM节点
// 调用组件的渲染函数,获得子树
const subTree = render.call(renderContext, state);
// 检查组件是否已经被挂载
if (!instance.isMounted) {
// 调用 beforeMount 钩子函数,如果存在的话
beforeMount && beforeMount.call(renderContext)
// 初次挂载,调用 patch 函数第一个参数传递 null
patch(null, subTree, container, anchor);
// 重点:将组件实例的isMounted 设置为 true,这样当更新发生时就不会再次进行挂载操作,
// 而是直接调用 patch 函数进行更新
instance.isMounted = true;
// 调用 mounted 钩子函数,如果存在的话
// mounted && mounted.call(renderContext)
// 调用 mounted 钩子函数数组中的每个函数
instance.mounted && instance.mounted.forEach(fn => fn.call(renderContext))
} else {
// 调用 beforeUpdate 钩子函数,如果存在的话
beforeUpdate && beforeUpdate.call(renderContext)
// 当isMounted 为 true 时,说明组件已经被挂载,只需要完成自更新即可,
// 所以在调用 patch 函数时,第一个参数为组件上一次渲染的子树,
// 意思是,使用新的子树与上一次渲染的子树进行打补丁操作
patch(instance.subTree, subTree, container, anchor);
// 调用 updated 钩子函数,如果存在的话
updated && updated.call(renderContext)
}
// 更新组件实例的子树
instance.subTree = subTree;
}, {
// 当响应式数据发生变化时,调用scheduler函数将render函数添加到任务队列中,
// 并在微任务中刷新任务队列
// effect是同步执行的,添加到缓存队列中,并去重,也可以避免多次执行副作用函数。
scheduler: queueJob,
});
}