defineProps、defineEmits 和 defineExpose 有什么作用?
三者都是只能在 <script setup> 里使用的编译器宏,不用导入,编译时会被替换掉。defineProps 声明组件接收的 props,defineEmits 声明会触发的事件并返回 emit 函数,两者都支持运行时声明和纯类型声明。defineExpose 决定父组件通过模板 ref 能拿到什么,因为 <script setup> 组件默认是“关闭”的,不暴露任何内部变量。
简答
写一个
<script setup>的输入框组件,要声明它接收label,在内容变化时通知父组件,还要让父组件能调用它的focus()。这三件事分别交给defineProps、defineEmits和defineExpose。它们都是编译器宏,不是普通函数。所以不用从
vue导入,只能写在<script setup>里,编译时会被处理掉。defineProps接收的值和props选项一样,defineEmits接收的值和emits选项一样,还能根据传入的声明推断类型。
defineExpose是因为<script setup>组件默认是“关闭”的:父组件通过模板 ref 拿到的实例上,看不到组件里声明的任何变量和函数。想让父组件调用focus,就要用defineExpose({ focus })显式暴露出来。要注意版本差别。Vue 3.5 起,从
defineProps解构出来的变量是响应式的,还能直接用 JavaScript 默认值语法;3.4 及以前解构会丢失响应式,类型声明的默认值要用withDefaults。另外传给这些宏的参数会被提升到模块作用域,不能引用setup里的局部变量。
先看一个完整的组件
下面是一个输入框组件,三个宏各管一件事:
<script setup>
import { ref } from 'vue';
const props = defineProps({
label: String,
modelValue: String,
});
const emit = defineEmits(['update:modelValue']);
const inputRef = ref(null);
function focus() {
inputRef.value.focus();
}
defineExpose({ focus });
</script>
<template>
<label>
{{ props.label }}
<input
ref="inputRef"
:value="props.modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</label>
</template>
为什么 defineProps 没有导入就能用?因为它不会在运行时被调用,编译器处理 <script setup> 时会把它换成组件的 props 选项,所以文档说它们“在编译时被处理掉”。defineEmits 返回一个 emit 函数,调用它就是触发事件。这种 modelValue 加 update:modelValue 的写法就是组件 v-model 的展开形式,Vue 3.4 起可以直接用 defineModel 代替,见《Vue 3 defineModel 的双向绑定边界是什么?》。
为什么参数里不能用局部变量
传给 defineProps 和 defineEmits 的参数会被提升到 setup 外面的模块作用域。下面这种写法会编译报错:
<script setup>
const defaultLabel = '用户名';
defineProps({
label: { type: String, default: defaultLabel },
});
</script>
defaultLabel 是 setup 里的局部变量,提升到模块作用域以后就找不到了。可是导入的变量没问题,因为导入本来就在模块作用域里。所以要复用常量,就把它放到另一个文件里导入进来。
运行时声明和类型声明
上面的写法叫运行时声明,传的是对象或数组。用 TypeScript 时还可以只传一个类型参数:
<script setup lang="ts">
const props = defineProps<{
label?: string
modelValue: string
}>();
const emit = defineEmits<{
'update:modelValue': [value: string]
}>();
</script>
编译器会根据类型静态分析,自动生成等价的运行时声明,所以不用写两遍。可是两种写法不能同时用在同一个宏上,比如既传类型参数又传对象,会编译报错。defineEmits 这种“事件名对应参数元组”的简洁写法从 3.3 开始支持。
解构 props 在 3.5 前后有什么不同
很多人想直接把 props 解构出来用。下面这段在不同版本里表现不一样:
<script setup>
import { watchEffect } from 'vue';
const { foo, msg = 'hello' } = defineProps(['foo', 'msg']);
watchEffect(() => {
console.log(foo);
});
</script>
Vue 3.5 起,同一个 <script setup> 里访问解构出来的 foo,编译器会自动改成 props.foo,所以 foo 变了 watchEffect 会重新执行,msg = 'hello' 也成了默认值。在 3.4 及以前,这个特性默认没开,foo 只是解构那一刻的值,watchEffect 只跑一次。这时类型声明要设默认值,只能用 withDefaults 宏。
还有一点,3.5 里把解构出来的 foo 直接传给 watch(foo, ...) 不行,因为编译后传的是一个值。要写成 watch(() => foo, ...)。props 为什么不能在子组件里改,见《为什么 Vue 提倡 props 单向数据流?》。
defineExpose 决定父组件能拿到什么
父组件这样拿子组件的实例:
<script setup>
import { ref, onMounted } from 'vue';
import MyInput from './MyInput.vue';
const field = ref(null);
onMounted(() => {
field.value.focus();
});
</script>
<template>
<MyInput ref="field" label="用户名" />
</template>
如果 MyInput 没有写 defineExpose,field.value.focus 就是 undefined。因为 <script setup> 组件默认是关闭的,通过模板 ref 或 $parent 拿到的公共实例不会暴露内部声明的任何绑定。写了 defineExpose({ focus }) 以后,父组件拿到的对象就带上了 focus。暴露的如果是 ref,会像普通实例上那样自动解包。
这样做的好处是组件对外的接口是明确列出来的,内部变量改名或删掉,不会意外破坏父组件。
常见追问
defineEmits 声明有什么用?不声明也能 emit 吗?
声明以后事件有了类型和校验,也说明了组件对外会发出哪些事件。更重要的是,没声明的事件监听器会被当成透传属性加到根元素上,可能出现原生事件和
emit各触发一次的情况,详见《Vue 的 $attrs、inheritAttrs 和透传属性是怎样工作的?》。
能在普通 <script> 或 setup() 函数里用 defineProps 吗?
不能,它们是只在
<script setup>里生效的编译器宏。在setup()函数里,props 用props选项声明,作为setup的第一个参数传进来;事件用emits选项声明,emit从第二个参数ctx里拿;要暴露方法则调用ctx.expose()。
defineExpose 和 provide 有什么区别?
defineExpose是给直接拿到这个组件 ref 的父组件用的,通常是调用focus、reset这类方法。provide是把数据交给任意深度的后代组件,方向相反,见《插槽、作用域插槽和 provide/inject 各解决什么问题?》。