引言
组件间的通信一直是我们开发过程中非常常见的场景,今天主要讲的是Vue3中的父子组件通信。
本篇统一采用vue3的
setup
语法糖 +ts
写法进行说明,并结合Vue2的写法进行对照。
使用方法
假设我们有两个组件<Father />
、<Child />
。
父传子Props
-
在子组件中定义要接受的数据(及其类型)。
html<script setup lang="ts"> defineProps<{ msg?: string, count: number }>() </script>
在Vue3的setup中定义
props
时,我们需要使用defineProps
这个宏定义api。可能有些刚接触Vue3的同学不太清楚什么叫宏定义api ,简单来说,就类似于window.setTimeout
一样,是全局注入的api,可以直接setTimeout()
进行使用。当然实际上,对于
defineProps
的处理是在模板编译的时候处理的。在使用的时候,一般是3种写法, 第1种比较规范的写法,也就是如上所示,用ts的泛型来定义props需要传入的字段及其类型。
第2种写法:(和第一种写法一样)
html<script setup> defineProps({ msg: { type: String, required: false }, count: { type: Number, required: true } }) </script>
第3种是不用ts的写法,如下所示:
html<script setup> defineProps(['msg', 'count']) </script>
但是这种写法会没有类型提示,并且所有的参数都会变成可传的。
对应的Vue2/3中
Option API
写法如下:html<script> export default { props: ['msg', 'count'] } </script>
-
在父组件中引入子组件。
jsimport Child from './Child.vue'
-
父组件给子组件传递所需数据。
html<template> <Child msg="123" :count="123" /> </template>
注意 :prop
前不写冒号:
的时候,默认传递给子组件是字符串 ,添加冒号如:propA='123'
的时候,就是两个引号中间的值(或者变量)对应的类型。
子传父emit
-
子组件中定义要向父组件传递的函数名,以及参数。(除了第一个参数是事件名,后面的全是会传递给父组件的参数)此处需要使用
defineEmits
这个宏定义api。jsconst emit = defineEmits<{ (e: 'change', id: number): void }>() // or 不用ts写法 const emit = defineEmits(['change'])
-
父组件定义接受来自子组件发送数据的函数。
tsfunction handleChange(id: number) { // 自己的逻辑 }
此处的参数要和
defineEmits
中对应上。 -
父组件绑定自定义事件到子组件上。
html<template> <Child msg="123" :count="123" @change="handleChange" /> </template>
-
子组件中触发
emit
,父组件触发回调函数。
Child子组件:html<template> <div> <button @click="handleClick">Change</button> </div> </template> <script setup lang="ts"> const emit = defineEmits<{ (e: 'change', id: number): void }>() function handleClick() { emit('change', 123) } </script>
或者下面这种写法:
html<template> <div> <button @click="$emit('change', 123)">Change</button> </div> </template>
注意 :在template中直接使用
emit
的时候,需要加上$
也就是$emit()
。
原理分析
那我们在使用这些宏定义的API,如defineProps
、defineEmits
的时候有没有想一下他们是如何实现的呢,通过源码我们来举例分析一下:
模板编译
既然是宏定义API,我们书写的时候不需要引入或者声明这个方法,那说明要么是在使用之前注入的,要么就是在模板编译的时候识别到代码进行转换的,我们带着问题去看看Vue的源码中是如何实现的。
首先我们全局搜索一下defineProps
,发现在packages/compiler-sfc/src/script/defineProps.ts
有这么两行声明:
typescript
export const DEFINE_PROPS = 'defineProps'
export const WITH_DEFAULTS = 'withDefaults'
同样在这个文件中,有两个函数叫做processDefineProps
、processWithDefaults
引用了这个宏变量。
typescript
export function processDefineProps(
ctx: ScriptCompileContext,
node: Node,
declId?: LVal
) {
if (!isCallOf(node, DEFINE_PROPS)) {
return processWithDefaults(ctx, node, declId)
}
if (ctx.hasDefinePropsCall) {
ctx.error(`duplicate ${DEFINE_PROPS}() call`, node)
}
ctx.hasDefinePropsCall = true
ctx.propsRuntimeDecl = node.arguments[0]
// register bindings
if (ctx.propsRuntimeDecl) {
for (const key of getObjectOrArrayExpressionKeys(ctx.propsRuntimeDecl)) {
if (!(key in ctx.bindingMetadata)) {
ctx.bindingMetadata[key] = BindingTypes.PROPS
}
}
}
// call has type parameters - infer runtime types from it
if (node.typeParameters) {
if (ctx.propsRuntimeDecl) {
ctx.error(
`${DEFINE_PROPS}() cannot accept both type and non-type arguments ` +
`at the same time. Use one or the other.`,
node
)
}
ctx.propsTypeDecl = node.typeParameters.params[0]
}
if (declId) {
// handle props destructure
if (declId.type === 'ObjectPattern') {
processPropsDestructure(ctx, declId)
} else {
ctx.propsIdentifier = ctx.getString(declId)
}
}
return true
}
大家可以看到这段代码其实就是在解析我们在代码中声明的defineProps
,另外个函数就是处理withDefault
。这个函数又是在packages/compiler-sfc/src/compileScript.ts
中的 compileScript
函数中调用的。
这个函数上面有一段注释:
* Compile
<script setup>
* It requires the whole SFC descriptor because we need to handle and merge
* normal
<script>
+<script setup>
if both are present.
其实大家不用看代码也能通过注释和函数名知道,这个函数就是在使用setup
语法糖的时候解析我们的代码。从而去判断我们代码中使用的宏定义,并生成相关的数据。(这里有诸多模板编译的一些知识,就不再继续深入讨论)。
转化成虚拟DOM
在模板编译的过程中,会经历一系列的变化,最终转换成虚拟DOM也就是VNode
。
模板 -> 模板AST -> Javascript AST -> 代码生成 -> 渲染函数 -> VNode
组件实例化和渲染
在上一步被编译成VNode
之后,我们就会执行组件的实例化。在这个过程中,Vue会将父组件传递的属性值赋值给子组件的props
。
我们都知道在Vue3中会使用统一的创建函数createApp
对我们的App进行创建和挂载。其实createApp
中主要是调用ensureRenderer
函数,而ensureRenderer
实际上真正调用的是createRenderer
,最终执行的是baseCreateRenderer
函数。
typescript
export const createApp = ((...args) => {
const app = ensureRenderer().createApp(...args)
const { mount } = app
app.mount = (containerOrSelector: Element | string): any => {
const container = normalizeContainer(containerOrSelector)
if (!container) return
const component = app._component
if (!isFunction(component) && !component.render && !component.template) {
component.template = container.innerHTML
}
// clear content before mounting
container.innerHTML = ''
const proxy = mount(container)
container.removeAttribute('v-cloak')
return proxy
}
return app
}) as CreateAppFunction<Element>
typescript
const rendererOptions = {
patchProp, // 处理 props 属性
...nodeOps // 处理 DOM 节点操作
}
// lazy create the renderer - this makes core renderer logic tree-shakable
// in case the user only imports reactivity utilities from Vue.
let renderer: Renderer | HydrationRenderer
let enabledHydration = false
function ensureRenderer() {
return renderer || (renderer = createRenderer(rendererOptions))
}
typescript
export function createRenderer<
HostNode = RendererNode,
HostElement = RendererElement
>(options: RendererOptions<HostNode, HostElement>) {
return baseCreateRenderer<HostNode, HostElement>(options)
}
这里可能有很多函数,但是不重要 ,大家只需要记住createApp
-> baseCreateRenderer
这个结果
在packages/runtime-core/src/renderer.ts
中的baseCreateRenderer
函数中,如果识别到执行的是一个组件,那么就会调用名为mountComponent
的内部函数,最终执行 setupComponent
方法对组件进行初始化。
typescript
//packages/runtime-core/src/component.ts
export function setupComponent(
instance: ComponentInternalInstance,
isSSR = false
) {
isInSSRComponentSetup = isSSR
const { props, children } = instance.vnode
const isStateful = isStatefulComponent(instance)
initProps(instance, props, isStateful, isSSR)
initSlots(instance, children)
const setupResult = isStateful
? setupStatefulComponent(instance, isSSR)
: undefined
isInSSRComponentSetup = false
return setupResult
}
我们可以看到,最终在setupComponent
中完成了对Props
的初始化。