前言:
本篇文章是《Vue.js设计与实现》第 12 章 组件的实现原理 笔记,其中的代码和图片来源于本书,用于记录学习收获并且分享。
一、组件
组件允许我们将 UI 划分为独立的、可重用的部分,并且可以对每个部分进行单独的思考
二、如何去表示和渲染一个组件
1.表示一个组件(选项对象):
对于使用者来说,可以使用一个选项对象去表示一个组件:
const MyComponent = {
//组件名称
name: 'MyComponent',
...
}
为了能够去描述组件内部的内容并返回虚拟DOM,需要在组件vnode中包含一个渲染函数render
const MyComponent = {
//组件名称
name:'MyComponent',
render(){
//返回虚拟DOM
return {
type:'div',
children: '内容'
}
}
}
使用vnode描述时,我们将选项对象作为vnode的type:
const vnode = {
type: MyComponent,
}
2.渲染一个组件:
对渲染器的patch函数进行调整,增加一个判断,当vnode的type为对象时证明是一个组件,应该使用渲染组件的方法进行处理。
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) {
//Fragment组件节点
...
} else if (typeof type === 'object') {
if (!n1) {
//挂载组件的方法
mountComponent(n2, container, anchor)
} else {
//更新组件的方法
patchComponent(n1, n2, anchor)
}
}
}
在mountCompoent中
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
const { render } = componentOptions
const subTree = render()
patch(null, subTree, container, anchor)
}
通过vnode.type获取到选项对象,取到选项对象中的render函数再将其中返回的虚拟DOM放入patch中去挂载。
三、完善选项对象和渲染器去支持组件的功能
一个vue组件应该具备的基础功能有:
- 状态(组件内部的数据、属性和方法等)
- 组件实例
- 生命周期
- 参数props
- 组件传参emits
- 插槽
这些功能都需要完善对组件选项对象和渲染器的功能才能够支持
四、组件的状态和自更新
1.使用选项对象去描述组件的状态
我们在选项对象中添加一个data函数用于定义组件的状态,并在render函数中使用
const MyComponent = {
//组件名称
name:'MyComponent',
data() {
return {
foo: 'hello world'
}
},
render(){
return {
type:'div',
children: `foo 的值是: ${this.foo}`
}
}
}
如上定义了一个数据foo,然后在render中进行使用。
2.渲染
调整mountComponent函数
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
const { render, data } = componentOptions
const state = reactive(data())
const subTree = render.call(state, state)
effect(() => {
const subTree = render.call(state, state)
patch(null, subTree, container, anchor)
})
}
在mountComponent函数中获取到data,为了能够在状态变化时更新渲染组件,需要将data中的数据转化为一个响应式数据,并且使用effect将调用渲染的操作放入副作用函数中,这样只要数据变化就会更新渲染。为了能够在render函数中使用this访问到状态数据,使用render.call(state, state)处理state。
3.使用任务缓存队列去处理重复渲染
为了防止状态数据多次变化就会去重复触发更新,我们需要使用任务队列的方式进行去重
const p = Promise.resolve()
const queue = new Set()
let isFlushing = false
function queueJob(job) {
queue.add(job)
if (!isFlushing) {
isFlushing = true
p.then(() => {
try {
queue.forEach(jon => job())
} finally {
isFlushing = false
}
})
}
}
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
const { render, data } = componentOptions
const state = reactive(data())
const subTree = render.call(state, state)
effect(() => {
const subTree = render.call(state, state)
patch(null, subTree, container, anchor)
},
{
scheduler: queueJob
}
)
}
代码如上,我们让副作用函数不是立即执行,而是去使用调度器,副作用函数添加到一个任务队列中,使用Set对其进行去重,然后在任务队列执行时将isFlushing置为false,暂时不执行此次更新,当任务队列执行完之后置为true,再去执行下一个队列的渲染任务,这样就避免了重复的副作用函数执行。
五、组件的实例以及生命周期
为什么需要组件实例?
上一节中实现的组件存在问题,我们调用patch函数传入的第一个参数是null,这意味着每一次的更新,组件都会进行一次全新的挂载,但是理想的做法应该是:
更新组件时我们应该只去更新上一次组件的状态即可,这就需要我们去维护组件最新的实例,其中添加isMounted标识可以让我们根据其值去判断组件是挂载还是更新,从而可以确定生命周期钩子函数的调用时机
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
const { render, data } = componentOptions
const state = reactive(data())
const instance = {
state,
isMounted: false,
subTree: null,
}
vnode.component = instance
effect(() => {
const subTree = render.call(state, state)
if (!instance.isMounted) {
patch(null, subTree, container, anchor)
instance.isMounted = true
} else {
patch(instance.subTree, subTree, container, anchor)
}
instance.subTree = subTree
}, {
scheduler: queueJob
})
}
我们在mountComponent中添加了instance常量去表示组件实例,其中储存了组件实例的信息:
- state:组件状态
- isMounted:标识组件是否挂载
- subTree:组件渲染函数返回的虚拟DOM
在实例中还添加isMounted标识的原因在于: 有了在实例中维护的isMounted标识,就可以判断组件是挂载还是更新,从而可以确定生命周期钩子函数的调用时机
生命周期
先来回顾一下Vue的生命周期钩子:
确认如何在mountComponent中适当的时机去处理生命周期:
beforeCreate: 取得选项式对象componentOptions之后:created: 组件状态state处理完成之后,获得组件实例之后;beforeMount:isMounted为false 组件调用patch进行首次挂载之前;mounted:组件调用patch进行挂载之后,isMounted为true;beforeUpdate:isMounted为true,再次调用patch对组件进行更新之前;updated:isMounted为true再次调用patch对组件进行更新之后;
选项对象中传入生命周期函数
const MyComponent = {
//组件名称
name: 'MyComponent',
beforeCreate:()=>{},
created:()=>{}
... 省略部分代码
}
mountComponent中通过合适的时机调用选项对象中获得的钩子函数
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
const { render, data } = 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)
//mounted钩子
mounted && mounted.call(state)
instance.isMounted = true
} else {
//beforeUpdate钩子
beforeUpdate && beforeUpdate.call(state)
patch(instance.subTree, subTree, container, anchor)
//updated 钩子
updated && updated.call(state)
}
instance.subTree = subTree
}, {
scheduler: queueJob
})
}
六、组件参数props以及被动更新
处理props
组件的参数由两部分构成,一部分是组件内部对props及其类型的定义,另一部分是在使用组件过程中传入的props的值。 假设有如下组件:
<MyComponent title="A Big Title" :other="val" />
1.选项式对象以及vnode
const MyComponent = {
name: 'MyComponent',
//定义了props及其类型
props: {
title: String
},
render() {
return {
type: 'div',
children: `count is: ${this.title}` // 访问 props 数据
}
}
}
const vnode = {
type: MyComponent,
//传入的props数据
props: {
title: 'A big Title',
other: this.val
}
}
2.渲染
在mountComponent中处理props,由于组件中未定义的参数会被接收到atters中
,需要添加一个处理props的函数resolveProps用于区分props和atters
function mountComponent(vnode, container, anchor) {
//省略代码...
//解析props和attrs
const [props, attrs] = resolveProps(propsOption, vnode.props)
const instance = {
state,
//将得到的props定义到组件实例上
props: shallowReactive(props),
isMounted: false,
subTree: null,
}
//省略代码...
}
function resolveProps(options, propsData) {
const props = {}
const attrs = {}
for (const key in propsData) {
if ((options && key in options) || key.startsWith('on')) {
props[key] = propsData[key]
} else {
attrs[key] = propsData[key]
}
}
return [ props, attrs ]
}
被动更新
我们给子组件传递的props中的值实际上是来自于父组件,当父组件中的响应式数据变化时,父组件会进行更新,此时会调用patchComponent对子组件也进行更新。这种因为父组件更新引起的子组件的更新就叫做被动更新。
子组件被动更新时props不一定产生变化了,所以我们需要去判断props是否改变再更新。
新增一个hasPropsChanged函数去判断新旧props,主要采取判断数量是否变化或者遍历的方法去对比
function hasPropsChanged(
prevProps,
nextProps
) {
const nextKeys = Object.keys(nextProps)
//判断新旧props数量是否相等 不相等不用遍历对比
if (nextKeys.length !== Object.keys(prevProps).length) {
return true
}
//遍历对比
for (let i = 0; i < nextKeys.length; i++) {
const key = nextKeys[i]
return nextProps[key] !== prevProps[key]
}
return false
}
根据对比结果在patchComponent中更新props:
function patchComponent(n1, n2, anchor) {
//赋值给组件实例
const instance = (n2.component = n1.component)
//获取到的props
const { props } = instance
if (hasPropsChanged(n1.props, n2.props)) {
//对旧的props进行更新
const [ nextProps, nextAttrs ] = resolveProps(n2.type.props, n2.props)
for (const k in nextProps) {
props[k] = nextProps[k]
}
for (const k in props) {
if (!(k in nextProps)) delete props[k]
}
}
}
在更新过程中同时需要旧子组件的实例赋值给新的子组件,确保下次更新时获得子组件
处理渲染函数中需要使用this访问组件状态和props的情况
新增一个上下文对象renderContext用于处理这种情况
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 {
console.error('不存在')
}
},
set (t, k, v, r) {
const { state, props } = t
if (state && k in state) {
state[k] = v
} else if (k in props) {
props[k] = v
} else {
console.error('不存在')
}
}
})
...
// created生命周期函数调用时绑定渲染上下文对象
created && created.call(renderContext)
代码如上所示,我们使用proxy对instance进行了代理这样,当在渲染函数中或者生命周期函数内部使用this读取组件状态时,会优先从实例中获取,没有的情况下再去从props中获取
七、setup函数
setup函数是Vue3中新增的用于处理组合式API的方法。
1.setup函数不同的返回值
- 返回一个函数: 该函数会直接作为组件的render函数
const Comp = {
setup() {
return () => {
return { type: 'div', children: 'hello' }
}
}
}
- 返回一个对象: 对象中的数据可以在组件的render函数中使用
const Comp = {
setup() {
const msg = ref('title')
return () => {
return { type: 'div', children: 'hello' }
},
render(){
return { type: 'div', children: `msg is: ${this.msg}` }
}
}
}
setup函数的参数
- props:给组件传递的props数据对象
- setupContext:保存和组件接口相关的数据和方法:如slots、emit、attrs、expose等
上述功能的实现
由于setup函数只在组件挂载的时候运行一次所以我们只需要处理mountComponent
function mountComponent(vnode, container, anchor) {
let componentOptions = vnode.type
let { render, data, setup } = 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,
slots,
mounted: []
}
let setupState = null
if (setup) {
const setupContext = { attrs }
const setupResult = setup(shallowReadonly(instance.props), setupContext)
let setupState = null
if (typeof setupResult === 'function') {
if (render) console.error('setup 函数返回渲染函数,render 选项将被忽略')
render = setupResult
} else {
setupState = setupContext
}
}
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) {
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) {
props[k] = v
} else if (setupState && k in setupState) {
setupState[k] = v
} else {
console.error('不存在')
}
}
})
// created
created && created.call(renderContext)
// 省略部分代码
}
代码如上所:
- 首先从组件的选项对象中拿到setup函数;
- 通过resolveProps解析得到props和attr;
- 判断setup函数的返回值: 若返回值为函数,则直接将返回值作为组件的render函数,否则将返回值放入到setupState中,并且在加入到上下文对象renderContext中,从而能够在组件的渲染函数中通过this去访问到setup函数返回的对象中的数据。
八、组件事件和emit
emit的使用过程
假设有选项对象
const MyComponent = {
name: 'MyComponent',
setup(props, { emit }) {
emit('change', 1, 2)
return () => {
return // ...
}
}
}
组件
<MyComponent @change="handler" />
该组件会被编译成如下虚拟DOM
const CompVNode = {
type: MyComponent,
props: {
onChange: handler
}
}
从上面的代码可以观察得出,emit调用一个自定义事件,其过程就是去props中寻找对应的事件处理对象并执行,额外需要处理的就是自定义事件名称change会被编译成onChange
实现
对mountComponent进行处理,
在其中增加一个emit函数,从instance.props中取得事件处理函数并执行:
function mountComponent(vnode, container, anchor) {
//省略部分代码...
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
}
//定义emit
function emit(event, ...payload) {
const eventName = `on${event[0].toUpperCase() + event.slice(1)}`
const handler = instance.props[eventName]
if (handler) {
handler(...payload)
} else {
console.error('事件不存在')
}
}
const setupContext = { attrs, emit }
// 省略部分代码
}
由于on开头的事件在我们使用组件时是不会在内部的props中去定义的,按照之前实现的逻辑,事件会被放入到attrs中,这样我们就不能在instance.props中取得事件处理函数,所以我们需要在区分props和attrs的函数resolveProps中做处理,将on开头的事件放入其返回的props中
function resolveProps(options, propsData) {
const props = {}
const attrs = {}
for (const key in propsData) {
if ((options && key in options) || key.startsWith('on')) {
props[key] = propsData[key]
} else {
attrs[key] = propsData[key]
}
}
return [ props, attrs ]
}
九、插槽
1.插槽的使用
假设有一个组件 MyComponent 提供了如下插槽
<template>
<header><slot name="header" /></header>
<div>
<slot name="body" />
</div>
<footer><slot name="footer" /></footer>
</template>
其会被编译成如下如下渲染函数:
function render() {
return [
{
type: 'header',
children: [this.$slots.header()]
},
{
type: 'body',
children: [this.$slots.body()]
},
{
type: 'footer',
children: [this.$slots.footer()]
},
]
}
在使用插槽时:
<MyComponent>
<template #header>
<h1>我是标题</h1>
</template>
<template #body>
<section>我是内容</section>
</template>
<template #footer>
<p>我是注脚</p>
</template>
</MyComponent>
当在编译MyComponent会被编译成如下渲染函数:
// 父组件的渲染函数
function render() {
return {
type: MyComponent,
// 组件的 children 会被编译成一个对象
children: {
header() {
return { type: 'h1', children: '我是标题' }
},
body() {
return { type: 'section', children: '我是内容' }
},
footer() {
return { type: 'p', children: '我是注脚' }
}
}
}
}
当组件使用了插槽时其中的内容会被编译为插槽函数,函数的返回值就是插槽内容
2.实现插槽
通过观察上面的渲染结果,我们可以得到处理插槽的思路
- 首先需要获取到插槽对象,即
vnode.children - 然后将插槽对象添加到组件实例上
- 最后通过
renderContext处理,使得在组件渲染函数中可以通过this访问到插槽对象中的数据 在mountComponent中具体的过程如下:
function mountComponent(vnode, container, anchor) {
//省略部分代码...
//获取插槽对象
const slots = vnode.children || {}
//将slots对象放入上下文setupContext
const setupContext = { attrs, emit, slots }
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
//插槽添加到实例上
slots
}
const setupContext = { attrs, emit }
const renderContext = new Proxy(instance, {
get(t, k, r) {
const { state, props, slots } = t
//如果key是$slots直接返回实例上的slots
if (k === '$slots') return slots
if (state && k in state) {
return state[k]
} else if (k in props) {
return props[k]
} else if (setupState && k in 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) {
props[k] = v
} else if (setupState && k in setupState) {
setupState[k] = v
} else {
console.error('不存在')
}
}
})
// 省略部分代码
}
十、注册生命周期
在setup函数中,我们可以使用onMounted等API来注册生命周期钩子函数,并且可以允许多个相同生命周期钩子函数的存在。
1.在不同组件中调用注册钩子函数的API如何区别
const MyComponent = {
setup() {
onMounted(() => {
console.log('mounted 1')
})
// 可以注册多个
onMounted(() => {
console.log('mounted 2')
})
// ...
}
}
onMounted函数并不是在组件中被单独定义的,而是在不同的组件中执行时,钩子函数都会被注册到当前组件上,如何实现呢。 为了能够使得onMounted会被正确的挂载到调用它的组件内部,需要定义一个全局的变量currentInstance用于维护当前的组件实例,使其与onMounted产生关联,如下所示。
// 全局变量,存储当前正在被初始化的组件实例
let currentInstance = null
// 该方法接收组件实例作为参数,并将该实例设置为 currentInstance
function setCurrentInstance(instance) {
currentInstance = instance
}
此时再调整mounteComponent函数考虑钩子函数的执行
function mountComponent(vnode, container, anchor) {
// 省略部分代码
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
slots,
mounted: []
}
const setupContext = { attrs, emit, slots }
const prevInstance = setCurrentInstance(instance)
const setupResult = setup(shallowReadonly(instance.props), setupContext)
setCurrentInstance(null)
//省略部分代码
}
代码如上:在调用setup函数之前需要将currentInstance设置为当前实例,再执行setup之后将其清空。
2.onMounted如何将其中的内容维护进组件的实例中
只需要拿到组件实例再push进实例对应的钩子函数数组即可
function onMounted(fn) {
if (currentInstance) {
// 将生命周期函数添加到 instance.mounted 数组中
currentInstance.mounted.push(fn)
} else {
console.error('onMounted 函数只能在 setup 中调用')
}
}
在合适的时机调用注册的钩子函数
在mountComponent的effect中,在组件虚拟DOM被patch处理之后遍历调用
function mountComponent(vnode, container, anchor) {
//省略部分代码
effect(() => {
const subTree = render.call(renderContext, renderContext)
if (!instance.isMounted) {
beforeMount && beforeMount.call(renderContext)
patch(null, subTree, container, anchor)
instance.isMounted = true
mounted && mounted.call(renderContext)
instance.mounted && instance.mounted.forEach(hook => hook.call(renderContext))
} else {
//省略部分代码
instance.subTree = subTree
}, {
scheduler: queueJob
})
}
其他用于注册生命周期钩子的API的处理类似于onMounted
总结
-
组件表示和渲染:
- 使用选项对象表示组件,包括组件名称和渲染函数。
- 在渲染器的
patch函数中,通过判断vnode的类型为对象来来区分组件。 - 通过
mountComponent函数挂载组件,获取选项对象并调用render函数生成虚拟DOM,进行patch操作。
-
概述一个组件应该有的基础功能:
- 状态管理、组件实例、生命周期、参数props、组件传参emits和插槽等。
-
组件的状态和自更新:
- 在选项对象中使用
data函数定义组件状态,并在render函数中使用。 - 在
mountComponent函数中将data数据转为响应式,并使用effect函数监听数据变化,自动更新渲染。 - 使用任务缓存队列处理重复渲染,避免状态数据多次变化导致的重复渲染。
- 在选项对象中使用
-
组件实例和生命周期管理:
- 用组件实例维护组件最新状态,包括
state、isMounted和subTree等信息。 - 通过在适当时机调用生命周期钩子函数,实现组件生命周期管理。
- 用组件实例维护组件最新状态,包括
-
组件参数props和被动更新:
- 通过解析
props和attrs区分处理,并将props放入组件实例中。 - 对比新旧props判断是否需要更新组件,避免不必要的更新。
- 使用
Proxy代理组件实例,确保在render函数中通过this访问组件状态和props。
- 通过解析
-
setup函数:
setup函数可以返回函数作为组件的render函数,也可以返回对象用于组件内部使用。setup函数接收props和setupContext两个参数,setupContext中包含attrs、emit、slots等信息。- 在
mountComponent函数中处理setup函数的返回值,并将其加入渲染上下文中,使render函数中通过this访问setup函数返回的数据。
-
组件事件和emit:
- emit函数用于触发组件事件,通过props中的事件处理函数调用。
- 在
mountComponent函数中定义emit函数,从instance.props中获取事件处理函数并执行。
-
插槽机制:
- 插槽函数返回插槽内容,并在组件渲染函数中通过
this.$slots访问。 - 在
mountComponent函数中获取插槽对象,并将其添加到组件实例和渲染上下文中,确保在渲染函数中通过this访问插槽数据。
- 插槽函数返回插槽内容,并在组件渲染函数中通过
-
注册生命周期钩子函数:
- 通过全局变量
currentInstance维护当前组件实例,使生命周期钩子函数正确关联组件实例。 - 将生命周期函数添加到实例的相应数组中,实现多个相同生命周期钩子函数的注册。
- 在
mountComponent的effect中,合适时机调用注册的生命周期钩子函数。
- 通过全局变量
通过这些步骤,实现了Vue组件化的基本功能和生命周期管理,使得组件能够正常渲染、更新和处理各种功能需求。