1.组件事件和emit的实现原理
我们再使用vue3的时候都知道,emit其实就是用来传递自定义事件,那emit是如何实现的呢?
那我们先看下面的代码
js
const MyComponent = {
name: 'MyComponent',
setup(props, { emit }) {
emit('change', 1, 2)
return () => {
return //....
}
}
}
当我们使用该组件的时候,我们可以监听由emit函数发射的自定义事件:
js
<MyComponent @change="hanlder" />
那上面代码转换为对应的虚拟DOM为:
js
const CompVnode = {
type: MyComponent,
props: {
onChange: hanlder
}
}
通过上面代码我们可以很清楚的看到,自定义事件由change被编译成了onChange的属性,并存储到了props中,这是一个约定的东西。
自定义事件的本质其实就是根据事件名称去props数据对象中寻找到对应的事件处理函数并执行,如下代码所示:
js
function mountComponent(vnode, container, anchor) {
//省略部分代码
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null
}
function emit(event, ...payload) {
const eventName = `on${event[0].toUpperCase()}${event.slice(1)}`
const handler = instance.props[eventName]
if (handler) {
handler(...payload)
} else {
console.warn('不存在该事件')
}
}
const setupContext = { attrs, emit }
}
整体实现其实并不复杂
- 实现一个emit函数并添加到setupContext对象中,那么传参的时候就可以通过setupContext取得emit函数
- 再emit函数调用的时候,我们会根据约定对时间名称转换,并再props数据中找到对应的函数处理
- 最后,调用事件处理函数并传参即可
之前我们再解析props方法中,并没有对onXXX的参数进行处理,所以我们必须对上篇中resolveProps
方法进行改造一下,代码如下:
js
function resolveProps(options, propsData) {
const props = {}
const attrs = {}
for (const key in propsData) {
if (key in options) {
if (key in options || key.startsWith('on')) { //新增判断
props[key] = propsData[key]
} else {
attrs[key] = propsData[key]
}
}
}
return [props, attrs]
}
处理方法也很简单,通过检测propsData中的key是否以on为开头,如果是,则认为该属性是组件的自定义事件,那我们就完美的处理了这个问题了,那其实emit基本实现就完成了,是不是很简单?
2.插槽的工作原理和实现
插槽,我们再工作都用过,顾名思义,就是组件的插槽会预留一个槽位,该槽位具体渲染的内容都是由用户定义的,我们还是以MyComponent
来举例,如下:
js
<template>
<header><slot name="header" /></header>
<div>
<slot name="body" />
</div>
<footer><slot name="footer" /></footer>
</template>
当再父组件中使用MyComponent
组件时,可以根据插槽名来插入自定义的内容:
js
<MyComponent>
<template #header>
<h1>我是标题</h1>
</template>
<template #body>
<section>我是内容</section>
</template>
<template #footer>
<p>我是注脚</p>
</template>
</MyComponent>
上面的模版就会被编译成如下渲染函数
js
// 父组件的渲染函数
function render() {
return {
type: MyComponent,
// 组件的 children 会被编译成一个对象
children: {
header() {
return { type: 'h1', children: '我是标题' }
},
body() {
return { type: 'section', children: '我是内容' }
},
footer() {
return { type: 'p', children: '我是注脚' }
}
}
}
}
我们可以很清楚的看到,组件模版中插槽的内容会背编译成插槽函数,而且插槽函数的返回值就是具体的插槽内容,那看到上面的结构,我们可以很自然的想法,把如果我们直接执行对应的插槽函数,是不是就可以把组件渲染出来了呢?
那我们可以看到MyComponent
组件模版就会被编译成如下结果:
js
// MyComponent 组件模板的编译结果
function render() {
return [
{
type: 'header',
children: [this.$slots.header()]
},
{
type: 'body',
children: [this.$slots.body()]
},
{
type: 'footer',
children: [this.$slots.footer()]
}
]
}
可以很清楚的看到,渲染插槽的内容的过程,就是调用插槽函数并渲染 ,所以我们只需要再对应的时机执行就可以了,插槽就依赖于setupContext
中的slot对象,如下所示
js
function mountComponent(vnode, container, anchor) {
// 省略部分代码
// 直接使用编译好的 vnode.children 对象作为 slots 对象即可
const slots = vnode.children || {}
// 将 slots 对象添加到 setupContext 中
const setupContext = { attrs, emit, slots }
}
其实slot的实现是非常简单的,只需要将编译好的 node.children 作为 slots 对象,然后将 slots 对象添加到setupContext 对象中。为了在 render 函数内和生命周期钩子函数内能够通过 this. <math xmlns="http://www.w3.org/1998/Math/MathML"> s l o t s 来访问插槽内容,我们还需要在 r e n d e r C o n t e x t 中特殊对待 slots 来访问插槽内容,我们还需要在renderContext 中特殊对待 </math>slots来访问插槽内容,我们还需要在renderContext中特殊对待slots 属性,如下面的代码所示:
js
function mountComponent(vnode, container, anchor) {
// 省略部分代码
// 直接使用编译好的 vnode.children 对象作为 slots 对象即可
const slots = vnode.children || {}
// 将 slots 对象添加到 setupContext 中
const setupContext = { attrs, emit, slots }
const instance = {
//省略其他
slots
}
const renderContext = new Proxy(instance,{
get(t,k,r){
const {state,props,slots} = t
// 新增代码,当k等于slots时,直接返回组件实例上的slots
if(k === '$slot') return slots
},
set(t,k,v,r){
}
})
}
通过上面的简单处理,那我们就完成了通过this.$slots来访问插槽的内容了。
3.生命周期为何可以多次调用?
当我们再写组件的时候,比如onMounted
,onUpdated
等,我们其实可以注册多个,这些函数会在组件被挂载之后执行,那问题再哪里呢?思考下下面的问题:
问题:A组件的setup函数中调用onMounted
会将钩子函数注册再A上,B组件的钩子函数会注册再B组件上,那么是如何实现的呢?
那其实也很简单,我们只需要维护一个currentInstance变量,存储当前组件的实力,每当组件初始化值执行setup函数之前,我们将currentInstance设置为当前组件实例,再执行组件的setup函数,那可以通过onMounted注册的钩子函数与组件实例进行关联了。
那我们就可以进行如下改造:
js
// 全局变量,存储当前正在被初始化的组件实例
let currentInstance = null
// 该方法接收组件实例作为参数,并将该实例设置为 currentInstance
function setCurrentInstance(instance) {
currentInstance = instance
}
function mountComponent(vnode, container, anchor) {
//省略其他代码
const instance = {
mounted:[]
}
const setupContext = { attrs }
setCurrentInstance(instance)
const setupResult = setup(shallowReadonly(instance.props), setupContext) // 处理setup选项
setCurrentInstance(null)
}
我们再组件实例上添加mounted数组,当多次调用onMounted函数注册不同的生命周期时,这些生命周期都会存储到instance.mounted数组中,那我们接下来只需要实现onMouted函数就可以了:
js
function onMounted(fn) {
if (currentInstance) {
// 将生命周期函数添加到 instance.mounted 数组中
currentInstance.mounted.push(fn)
} else {
console.error('onMounted 函数只能在 setup 中调用')
}
}
可以看到,整体实现非常简单直观。只需要通过 currentInstance 取得当前组件实例,并将生命周期钩子函数添加 到当前实例对象的 instance.mounted 数组中即可。另外,如果当前实例不存在,则说明用户没有在 setup 函数内调用 onMounted 函 数,这是错误的用法,因此我们应该抛出错误及其原因。
最后一步需要做的是,在合适的时机调用这些注册到 instance.mounted 数组中的生命周期钩子函数,如下面的代码所示:
js
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))
}
instance.subTree = subTree
}, {
scheduler: queueJob
})
}
可以看到,我们只需要再核实的时机,遍历instance.mounteds数组,并逐个执行该数组生命周期钩子函数即可。其他生命周期钩子函数,原理同上。