javascript
<transition name="fade" mode="out-in">
<component :is="tabList[currentTab].component"></component>
</transition>
// 这样 会立即执行
import TabA from './tab-a.vue'
import TabB from './tab-b.vue'
// defineAsyncComponent异步组件 这样不会立即加载
// 不会立即加载,只有组件被渲染时才去请求对应的 chunk
const TabA = defineAsyncComponent({
loader: () => import('./tab-a.vue'),
loadingComponent: () => null, // 我们自己处理 loading
onError: () => {},
})
const TabB = defineAsyncComponent({
loader: () => import('./tab-b.vue'),
loadingComponent: () => null,
onError: () => {},
})
const currentTab = ref(0)
const tabList = [
{ label: '用户管理', icon: 'fas fa-users', component: TabA },
{ label: '数据统计', icon: 'fas fa-chart-bar', component: TabB },
]
用 defineAsyncComponent + 动态 import() 就行,组件代码在真正被渲染时 才会加载和初始化。
index.vue
vue
<transition name="fade" mode="out-in">
<!-- KeepAlive 缓存已加载的组件,避免来回切换重复请求 -->
<component :is="tabList[currentTab].component"></component>
</transition>
<script setup>
import { ref, shallowRef } from 'vue'
import { defineAsyncComponent } from 'vue'
// 不会立即加载,只有组件被渲染时才去请求对应的 chunk
const TabA = defineAsyncComponent({
loader: () => import('./tab-a.vue'),
loadingComponent: () => null, // 我们自己处理 loading
onError: () => {},
})
const TabB = defineAsyncComponent({
loader: () => import('./tab-b.vue'),
loadingComponent: () => null,
onError: () => {},
})
const currentTab = ref(0)
const loading = ref(false)
// 用 shallowRef 避免深层响应式代理异步组件对象
const tabList = shallowRef([
{ label: '用户管理', icon: 'fas fa-users', component: TabA },
{ label: '数据统计', icon: 'fas fa-chart-bar', component: TabB },
])
</script>
区别在哪:
| 写法 | 行为 |
|---|---|
import TabA from './tab-a.vue' |
页面一打开,tab-a.vue 立刻被下载、解析、执行 ,里面的 setup 全部跑完,不管你切不切到它 |
defineAsyncComponent(() => import('./tab-a.vue')) |
页面打开时什么都不发生 ,只有 <component :is> 实际渲染到它时,才发起网络请求加载这个 chunk,加载完才执行 setup |
| 执行顺序: |
页面打开 → 只加载 index.vue 自身的代码
→ TabA / TabB 的代码根本没下载
点击"用户管理" → 请求 tab-a.vue 的 chunk → 下载完 → 执行 setup → 渲染
点击"数据统计" → 请求 tab-b.vue 的 chunk → 下载完 → 执行 setup → 渲染
再切回"用户管理" → KeepAlive 命中缓存,直接显示,不重新加载
shallowRef 包裹 tabList 是因为异步组件对象不需要深层响应式代理,用 shallowRef 避免不必要的开销。
这个emit是什么意思 submit email payload password 是哪来的 为啥要怎么写
javascript
<script setup lang="ts">
const emit = defineEmits({
submit(payload: { email: string, password: string }) {
// 通过返回值为 `true` 还是为 `false` 来判断
// 验证是否通过
}
})
</script>
先把你的代码和官方文档对应起来,再逐个词解释。
1. 这段代码到底在干嘛?
你的代码(来自官方文档):
vue
<script setup lang="ts">
const emit = defineEmits({
submit(payload: { email: string, password: string }) {
// 通过返回值为 `true` 还是为 `false` 来判断
// 验证是否通过
}
})
</script>
对应官方文档的"事件校验"部分:对象形式的 defineEmits,其中事件对应的值是一个校验函数 ,接收 emit 时传进来的参数,返回 true/false 表示是否合法。
所以这段代码的含义:
- "我这个组件可以触发一个叫
submit的自定义事件。" - "并且,当有人
emit('submit', payload)时,我会用这个函数检查参数 :payload 必须长这样{ email: string, password: string },你可以在函数里写更多业务校验;返回 true 表示合法,false 表示不合法。"
2. 这些名字都是哪来的?
emit
defineEmits的返回值。官方说明:在<script setup>里,不能用$emit,要用defineEmits()返回的这个函数。- 习惯上把它命名为
emit,只是命名约定,不是硬性要求:const emit = defineEmits(...)- 后面在逻辑里调用:
emit('submit', { email: 'a@b.com', password: '123' })
submit / email / password
- 自定义事件名:
submit- 完全是你自己起的,只是大家习惯表单提交叫
submit。 - 可以叫别的,比如:
save、login、change,只要和父组件@事件名对应即可。 - 官方示例中也是用
submit作为事件名。
- 完全是你自己起的,只是大家习惯表单提交叫
- 载荷里的字段名:
email、password- 也完全是你自己定义的"数据结构"。
- 校验函数的参数类型
payload: { email: string, password: string }只是在 TS 层面约束:当这个事件被触发时,传进来的"载荷"必须长这样。 - 真正要不要做更复杂的校验(比如非空、长度、格式),可以在那个函数里写。
payload
- 不是 Vue 的关键字/保留字,只是一个常用变量名,表示"事件的载荷(携带的数据)"。
- 官方文档示例里也是这么叫的:
submit(payload: { email: string, password: string })。 - 你完全可以改成别的名字,比如:
ts
const emit = defineEmits({
submit(data: { email: string, password: string }) {
// 这里的 data 就是事件触发时传进来的那份数据
}
})
关键: 校验函数的参数,就是将来你在组件里调用 emit('submit', xxx) 时传的第二个参数(xxx 会被当作这个 payload 传进去)。
3. 为什么要这么写?(结合官方文档)
官方文档"事件校验"这一节明确说明了:
- 对象形式的
defineEmits,每个事件可以配一个校验函数; - 校验函数的参数就是抛出事件时传给
emit的内容; - 返回
true表示事件参数合法,false表示不合法。
你这段代码就属于这种用法,只是多了 TS 类型标注。
3.1 三个层面的作用
- 声明组件会触发哪些事件(文档作用)
- 声明了
submit事件,别人(或未来的你)一看就知道这个组件可能触发submit。 - 官方也建议"声明所有要触发的事件",当作组件的接口文档。
- 带 TS 类型的事件签名
- 类型
{ email: string, password: string }让 TS 知道:- 触发这个事件时,第二个参数必须是这个形状;
- 父组件的事件处理函数参数也会被推断成这个类型。
- 运行时的校验函数(返回 true/false)
- 你可以在函数里写:
- 业务校验:比如必填、长度、格式;
- 调试日志:不合法时
console.warn;
- 如果返回
false,Vue 会在开发模式下给你一个警告(说明这次触发的参数不合规)。
3.2 和"纯类型声明"的区别
官方还给了另一种纯类型声明写法:
ts
// 纯类型声明(没有运行时校验,只做 TS 类型检查)
const emit = defineEmits<{
(e: 'change', id: number): void
(e: 'update', value: string): void
}>()
对比你给的对象写法:
ts
// 带运行时校验 + 类型声明
const emit = defineEmits({
submit(payload: { email: string, password: string }) {
// 返回 true/false
}
})
- 对象形式:既有 TS 类型,又有运行时校验函数。
- 纯类型形式:只有 TS 类型,没有运行时校验。
4. 父组件那边怎么接?
你这段代码是在子组件里声明事件。父组件那边通常长这样:
vue
<!-- 父组件 -->
<template>
<!-- 监听子组件的 submit 事件 -->
<LoginForm @submit="handleSubmit" />
</template>
<script setup lang="ts">
function handleSubmit(payload: { email: string; password: string }) {
// 这里 payload 就是子组件 emit('submit', payload) 传出来的对象
console.log(payload.email, payload.password)
}
</script>
子组件里触发事件:
ts
function doSubmit() {
emit('submit', { email: 'a@b.com', password: '123' })
}
5. 小结(一问一答版)
emit是什么?
defineEmits()返回的函数,用来触发本组件声明的自定义事件。submit是什么?
自定义事件的名字,你自己起的,和父组件@submit对应即可。email/password是什么?
你给这个事件定义的"载荷"里的字段名,表示这个事件会带一份数据,包含这两个字段。payload是什么?
校验函数的参数名,代表"事件触发时传进来的那份数据",只是一个常用变量名,可以改名。- 为什么要这样写?
为了:- 声明组件会抛出哪些事件(文档化);
- 用 TS 标注事件参数类型;
- 在对象形式的写法中,还可以做运行时的参数校验(返回 true/false)。
如果你愿意,我可以给你补一个完整的小示例:一个子组件LoginForm.vue+ 一个父组件App.vue,用你这段defineEmits写法,让你直接跑起来看效果。
e: 'change', id: number 这个e 是哪来的 id 是哪来的 value是哪来的?
这三个全是你自己定义的名字,没有一个来自 Vue。
先看这段代码的本质
ts
const emit = defineEmits<{
(e: 'change', id: number): void
(e: 'update', value: string): void
}>()
去掉 Vue 的 defineEmits<...>() 外壳,里面就是一个普通的 TS 函数重载签名:
ts
// 声明一个函数的类型:它有两种调用方式
declare function emit: {
(e: 'change', id: number): void
(e: 'update', value: string): void
}
仅此而已。没有任何 Vue 的魔法。
逐个解释
e
事件名参数 。你写成 e、event、name、aaa 都行:
ts
// 这三行完全等价
(e: 'change', id: number): void
(event: 'change', id: number): void
(随便起: 'change', id: number): void
它就是一个形参名,类型是字面量 'change',意思是"当第一个参数是字符串 'change' 时,第二个参数必须是 number"。
之所以习惯用 e,因为:
- 它是 event 的首字母
- 只是一个短小的占位符,没人会在函数体里用到它(因为函数签名没有函数体,只是类型声明)
id / value
载荷参数 。就是将来你调用 emit() 时传的第二个参数。
你叫 id、叫 value、叫 payload、叫 data,都行,它们只是形参的名字 ,真正重要的是后面的类型 (number、string)。
名字是怎么"生效"的
关键在这里------这些名字会直接影响你调用 emit 时 TS 的提示:
ts
// 你的声明
const emit = defineEmits<{
(e: 'change', id: number): void
(e: 'update', value: string): void
}>()
// ---- 调用时 ----
// ✅ 第一个参数写 'change',TS 会提示第二个参数叫 id,类型是 number
emit('change', 123)
// ❌ 类型错误:第二个参数应该是 number,你传了 string
emit('change', 'hello') // TS 报错
// ✅ 第一个参数写 'update',TS 会提示第二个参数叫 value,类型是 string
emit('update', 'hello')
// ❌ 类型错误:第二个参数应该是 string,你传了 number
emit('update', 123) // TS 报错
你在编辑器里敲 emit('change', 的那一刻,VSCode 会弹出提示:
id: number
这个 id 就是你签名里写的那个名字。如果你写成了 userId,提示就会变成 userId: number。
换个名字写,完全没问题
ts
const emit = defineEmits<{
(eventName: 'change', userId: number): void
(eventName: 'update', newText: string): void
}>()
// 调用时 TS 提示变成 userId / newText,功能一模一样
emit('change', 123)
emit('update', 'hello')
和父组件的关系
这些名字不会传到父组件去。父组件的 handler 参数名是父组件自己起的:
vue
<!-- 子组件:声明 -->
<script setup lang="ts">
const emit = defineEmits<{
(e: 'change', id: number): void
}>()
</script>
<!-- 父组件:接收,参数名随便起 -->
<template>
<Child @change="(myId) => console.log(myId)" />
<!-- ^^^^ 和子组件的 id 没有任何关系 -->
</template>
一句话总结
e、id、value 全是你自己写的形参名 ,不是 Vue 的关键字。它们唯一的作用是:当你在编辑器里调用 emit('事件名', ...) 时,TS 根据第一个参数匹配到对应的重载签名,然后把你写的形参名作为提示显示出来,同时检查第二个参数的类型是否正确。