ref / reactive / 函数
<script setup> 是 Vue 3 的 语法糖 ,它让组合式 API 用起来更简洁。在 setup 里定义的变量和函数,可以 直接在模板里用 ,不需要 return 。
配合 TS,它变成了"编译期静态检查 + 运行时响应式"的组合。
这一节的所有例子都来自 jeecgboot-vue3/src 真实代码
- <script setup> 的三板斧: ref / reactive / 函数
java
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage, useNotification } from 'naive-ui';
import { useUserStore } from '/@/store/modules/user';
// ① loading 这种开关状态 → 用 ref<boolean>
const loading = ref<boolean>(false);
// ② 表单对象 → 用 reactive<LoginParams>
const loginForm = reactive<LoginParams>({
username: '',
password: '',
captcha: '',
});
// ③ store / router / notification
const userStore = useUserStore();
const { notification } = useMessage();
const router = useRouter();
// ④ 异步函数:try/catch/finally 的标准套路
async function handleLogin() {
loading.value = true; // ← script 里必须 .value
try {
const userInfo = await userStore.login({
...loginForm,
captcha: formModelRef.value?.captcha || '',
rememberMe: formModelRef.value?.rememberMe || false,
});
notification.success({
message: '登录成功',
description: `欢迎回来,${userInfo?.realname}`,
});
router.push('/home');
} catch (e) {
notification.error({
message: '登录失败',
description: (e as Error).message, // ← catch 的 e 是 unknown,需要断言
});
} finally {
loading.value = false;
}
}
</script>
<template>
<!-- ⑤ 模板里直接用 loading / loginForm,不需要 .value -->
<a-spin :spinning="loading">
<a-form :model="loginForm">
<a-input v-model:value="loginForm.username" placeholder="账号" />
<a-input-password v-model:value="loginForm.password" placeholder="密码" />
<a-button type="primary" block :loading="loading" @click="handleLogin">
登录
</a-button>
</a-form>
</a-spin>
</template>
rememberMe: formModelRef.value?.rememberMe || false
里面的 问号 ?. 是 可选链操作符(Optional Chaining) ,作用是安全访问对象的属性,防止访问 null 或 undefined 时报错。
具体行为:
formModelRef.value如果 不是 null 或 undefined ,就会读取.rememberMe的值。- 如果
formModelRef.value是 null 或 undefined ,整个表达式formModelRef.value?.rememberMe返回undefined,不会报错。 - 后面的
|| false保证当值是undefined或false时,最终rememberMe至少会是false。
问号 ?. 用于安全地访问可能不存在的对象属性,是 TypeScript/现代 JavaScript 的语法糖。
description: `欢迎回来,${userInfo?.realname}`,
如果 userInfo 是 null 或 undefined,那么 userInfo?.realname 会返回 undefined,而不会报错。
ref<T> 的最佳实践 :
java// ✅ 推荐:显式写泛型,TS 不会推断错 const loading = ref<boolean>(false); const tableData = ref<DepartItem[]>([]); // ❌ 不推荐:空数组会被推断为 never[] const tableData = ref([]); // 类型是 never[],后面 push 什么都会类型报错 // ✅ 推荐:ref 里放对象也可以,但要注意在脚本里用 .value const currentRecord = ref<DepartItem | null>(null); console.log(currentRecord.value?.departName); // 可选链,null 时不报错
java// ✅ 推荐:对象用 reactive const form = reactive<LoginParams>({ username: '', password: '' }); // ❌ 不要:把基本类型塞 reactive const state = reactive({ loading: false }); // 能用,但没必要,直接 ref 更简洁
数据类型 推荐写法 原因 表格数据列表 ref<T[]>([])接口返回后经常整体替换 当前选中行 ref<T>()经常整体赋值 表格查询条件 reactive({})属性频繁修改 分页参数 reactive({})pageNo、pageSize 经常单独改 表单数据 reactive({})字段多,双向绑定方便
java// 标量 const loading = ref(false) // 表格数据 const dataSource = ref<User[]>([]) // 当前记录 const currentRecord = ref<User>() // 查询条件 const searchForm = reactive({}) // 编辑表单 const formData = reactive({}) // 分页 const pagination = reactive({})
defineProps<T>
<script setup> 里用 defineProps 声明组件 props。带 TS 的版本和不带 TS 的版本,写法差别非常大:
<MyComp :list="dataList" :loading="isLoading" />
java
const props = defineProps({
list: {
type: Array,
default: () => [], // 如果父组件没传 list,就默认是空数组
},
loading: {
type: Boolean,
default: false, // 如果父组件没传 loading,就默认 false
},
});
作用是给组件外部传入的数据做类型和默认值校验:父组件 → 子组件的数据传递(props)
java
<script setup>
const emit = defineEmits(['change', 'submit'])
function handleClick() {
emit('change', 123) // 传值给父组件
}
</script>
<template>
<button @click="handleClick">点击</button>
</template>
java
<MyComp
@change="handleChange"
@submit="handleSubmit"
/>
function handleChange(val) {
console.log('子传来的值:', val)
}
java<script setup lang="ts"> import { defineProps, withDefaults } from 'vue'; import { DepartItem } from '/@/api/sys/model/departModel'; // ① 最基本:只声明类型,不设默认值 const props = defineProps<{ list: DepartItem[]; loading?: boolean; }>(); // ② 需要默认值 → 用 withDefaults 包一层 const props = withDefaults(defineProps<{ list: DepartItem[]; loading?: boolean; columns: number; }>(), { loading: false, columns: 3, }); </script>
defineProps<{ ... }>() 的尖括号里写的是 对象字面量类型 ,它看起来像 JS 对象但本质是 TS 类型语法
? 表示可选(父组件可以不传)
withDefaults 是"给类型+默认值"的标准写法, 必须和 defineProps<T> 配对使用
defineEmits<T> ------ 组件事件的类型写法
defineEmits 声明子组件能触发哪些事件,以及事件回调的参数类型:
java<script setup lang="ts"> import { defineEmits } from 'vue'; // 声明事件名 + 回调函数签名 const emit = defineEmits<{ (e: 'update', row: DepartItem): void; (e: 'delete', id: string): void; (e: 'close'): void; }>(); // 触发事件 emit('update', currentRow.value); // ✅ TS 会检查 currentRow 的类型是否匹配 emit('delete', row.id); // ✅ emit('close'); // ✅ </script>
java<template> <a-button @click="emit('delete', record.id)">删除</a-button> <a-button @click="emit('update', record)">编辑</a-button> </template>为什么带类型的 emit 很重要 :没有类型约束时, emit('updat', row) 这种手误只能在运行时发现。有了类型,TS 会在编译期就给你亮红
v-for + TS:模板里的类型推断
v-for 有一个非常强的特性: 只要你在 <script> 里给数组写了类型,模板里 item 的类型就会自动推断 。
java
<script setup lang="ts">
import { ref } from 'vue';
import { departListApi } from '/@/api/sys/depart';
import { DepartItem } from '/@/api/sys/model/departModel';
// 显式声明数组的元素类型
const tableData = ref<DepartItem[]>([]);
// 页面加载
onMounted(async () => {
tableData.value = await departListApi({ page: 1, pageSize: 10 });
});
</script>
<template>
<!-- ✅ 这里 item 自动被推断为 DepartItem -->
<!-- 在模板里敲 item. 会出 departName / orgCode / createTime 的字段提示 -->
<a-table :data-source="tableData">
<a-table-column v-for="col in columns" :key="col.key" :title="col.title" :data-index="col.key">
<template #bodyCell="{ record }">
<!-- record 在这里是 any(因为 Ant Design Vue 的表格 record 是动态的) -->
<!-- 如果需要强类型,在模板里用 record as DepartItem 也行 -->
<span>{{ record.departName }}</span>
</template>
</a-table-column>
</a-table>
<!-- 直接 v-for 一个带类型的数组 → item 自动推断为 DepartItem -->
<div v-for="item in tableData" :key="item.id">
<p>{{ item.departName }}</p>
<p>{{ item.orgCode }}</p>
</div>
</template>
-
v-for 不能在模板里写类型标注 ,比如 v-for="(item: DepartItem) in list" 是 语法错误 (你项目里如果有这样的代码,要删掉冒号后面的类型)
-
类型约束应该放在 <script> 里的数组声明上, ref<DepartItem\[\]>(\[\]) 就够了
-
模板里的 item 会自动推断
组合式函数(Composable)
JeecgBoot 里已经把最常用的页面逻辑抽成了 useXxx 形式的 composable。这是 <script setup> 时代最强大的代码复用方式。
看你项目里典型的 useListPage ( src/hooks/system/useListPage.ts ),它的 TS 用法就是一个非常好的范例:
javaexport function useListPage( options: UseListPageOptions // 函数参数 ):///这是函数的返回值类型声明。意思是 useListPage 返回一个对象,对象里面有这些属 { dataSource: Ref<any[]>; loading: Ref<boolean>; pagination: Ref<Pagination>; searchForm: any; loadData: (params?: Recordable) => Promise<void> → 函数 params?: Recordable 是参数(可选),类型是一个任意键值对象 => Promise<void> 表示函数返回一个 Promise,最终不返回数据(void) handleTableChange: (pag: Pagination) => void; handleReset: () => void; handleSizeChange: (size: number) => void; } { // 内部实现:统一处理表格的加载、分页、搜索条件重置 } 输入参数:options(比如分页配置、接口函数等) 输出:一个 对象,对象里包含你要在组件里用的状态和方法const { dataSource, loading, loadData } = useListPage({ pageSize: 10, api: fetchList });
页面里用它:
java<script setup lang="ts"> import { useListPage } from '/@/hooks/system/useListPage'; const { tableProps, // a-table 的全部 props(dataSource、pagination、loading、columns...) searchFormSchema, // 搜索表单的 schema onLoadData, // 触发重新加载 } = useListPage({ tableProps: { title: '部门列表', columns, actionColumn, api: departListApi, }, searchInfo: { labelWidth: 100, schemas: searchFormSchema, }, }); </script> hook 内部会: 根据传的 api 拉取数据 自动处理分页、加载状态 把 tableProps 和搜索表单 schema 封装好返回 提供 onLoadData() 用来刷新表格数据 <template> <!-- 搜索区 --> <jeecg-search-form @search="onLoadData" @reset="onLoadData" :label-width="searchForm.labelWidth"> <a-form-item v-for="item in searchFormSchema" :key="item.field" :label="item.label"> <!-- 根据 item.component 渲染不同输入框 --> </a-form-item> </jeecg-search-form> 使用 searchFormSchema 渲染表单字段 @search 和 @reset 都会触发 onLoadData(),重新拉取数据 label-width 控制表单 label 宽度 <!-- 表格区 --> <jeecg-table v-bind="tableProps" /> tableProps 是 Hook 返回的完整表格配置 包含:数据源、分页信息、加载状态、列定义、操作列等 v-bind="tableProps" 是把对象里的所有属性直接绑定到组件上 </template>
表格逻辑被抽成一个函数 ,100 个列表页都只需要 useListPage({...}) 一行
TS 类型让 tableProps.columns 的每个字段都有提示 ,减少手误
搜索 + 表格 + 分页三件套 完全解耦,不同模块之间复制粘贴成本极低
自写hook
Hook:useCounter(最经典入门版)
java
// useCounter.ts
import { ref } from 'vue';
export function useCounter(initial = 0) {
// 状态
const count = ref(initial);
// 方法:加1
const increment = () => {
count.value++;
};
// 方法:减1
const decrement = () => {
count.value--;
};
// 方法:重置
const reset = () => {
count.value = initial;
};
// 返回"状态 + 方法"
return {
count,
increment,
decrement,
reset,
};
}
java
<script setup lang="ts">
import { useCounter } from './useCounter';
const { count, increment, decrement, reset } = useCounter(10);
</script>
<template>
<div>当前值:{{ count }}</div>
<button @click="increment">+1</button>
<button @click="decrement">-1</button>
<button @click="reset">重置</button>
</template>
| 不用 Hook | 用 Hook |
|---|---|
| 每个组件都写 count + 方法 | 直接 useCounter() |
| 代码重复 | 逻辑复用 |
| 分散 | 集中管理 |
没错,你看到的本质就是封装对象。
在 Vue 3 / React / 前端世界里,"Hook" 的概念其实就是:
把状态 + 操作逻辑封装成一个函数,然后返回给组件使用。
它和普通函数的区别不是返回值类型,而是用途和约定:
java
// 普通函数
function add(a: number, b: number) {
return a + b;
}
// Hook 风格
function useCounter() {
const count = ref(0)
const increment = () => count.value++
return { count, increment }
}
- 普通函数只返回计算结果
- Hook 返回 状态 + 操作 ,并且状态是 响应式 的
- Hook 可以被组件直接绑定到模板,并随组件更新
<script setup> + TS 的 5 个高频坑
ref 在 <script> 里必须 .value ,在 <template> 里不用
java
<script setup lang="ts">
const count = ref(0);
console.log(count); // ❌ 打印的是 Ref 对象,不是 0
console.log(count.value); // ✅ 要拿值必须 .value
function inc() {
count.value++; // ✅ script 里赋值要 .value
}
</script>
<template>
<p>{{ count }}</p> <!-- ✅ template 自动解包,不用 .value -->
<a-button @click="count++">+1</a-button> <!-- ✅ template 里自增也不用 .value -->
</template>
坑 2: reactive 的整体赋值会丢失响应式
java
const state = reactive<DepartItem>({ id: '', departName: '', orgCode: '' });
// ❌ 不要这么写:整体重新赋值会丢响应式
state = { id: '1', departName: '新部门' };
// ✅ 方案 A:改用 ref
const state = ref<DepartItem>({ id: '', departName: '', orgCode: '' });
state.value = { id: '1', departName: '新部门' }; // OK
// ✅ 方案 B:Object.assign
Object.assign(state, { id: '1', departName: '新部门' }); // OK
坑 3: catch (e) 里的 e 是 unknown ,不能直接读属性
java
try {
await userStore.login(params);
} catch (e) {
// ❌ e 是 unknown,直接读 .message TS 会报错
// notification.error({ message: e.message });
// notification.error({ message: e?.message ?? '请求失败', });
// ✅ 先断言成 Error
const err = e as Error;
notification.error({ message: err.message });
// ✅ 更稳妥的写法(防御运行时 null)
if (e instanceof Error) {
notification.error({ message: e.message });
}
}
| 写法 | 含义 |
|---|---|
e.message |
直接访问,可能报错 |
e?.message |
安全访问(推荐) |
e?.message ?? 'xxx' |
有默认兜底(最佳实践) |
坑 4:模板里不能写 v-for="(item: DepartItem) in list"
java
<!-- ❌ 语法错误:模板里不能写 : DepartItem -->
<div v-for="(item: DepartItem) in list" :key="item.id">{{ item.name }}</div>
<!-- ✅ 正确写法:类型在 <script> 里标注 -->
<script setup lang="ts">
const list = ref<DepartItem[]>([]);
</script>
<template>
<div v-for="item in list" :key="item.id">{{ item.departName }}</div>
</template>
坑 5: v-model 的可选链在模板里有限制
java
<!-- ✅ v-model 不能用可选链,要确保绑定的对象存在 -->
<a-input v-model:value="formModelRef.value.username" /> <!-- ✅ -->
<a-input v-model:value="formModelRef?.username" /> <!-- ❌ 编译错误 -->
const formModelRef = ref({ username: '' })
实际结构是:
formModelRef
↓
{ value: { username: '' } }
所以:
| 写法 | 含义 |
|---|---|
| formModelRef.value | 拿到真实对象 |
| formModelRef.value.username | 访问字段 |
formModelRef是 ref → 没有 username- 真正数据在
.value里面 ?.不能参与"赋值链路"(v-model 需要 set)
✅ script 里必须 .value
console.log(userInfo.value?.realname)
❌ script 里不能省
console.log(userInfo.realname) // 错
✅ template 里自动解包
<p>{{ userInfo.realname }}</p>
java<script setup lang="ts"> import { ref } from 'vue'; import { GetUserInfoModel } from '/@/api/sys/model/userModel'; const userInfo = ref<GetUserInfoModel | null>(null); </script> <template> <!-- ✅ 推荐:用 v-if 先判空,再读属性 --> <div v-if="userInfo"> <p>{{ userInfo.realname }}</p> <p>{{ userInfo.username }}</p> </div> <!-- ❌ 模板里可以用可选链,但配合 v-if 更清晰 --> <div>{{ userInfo?.realname }}</div> </template> <div> {{ userInfo?.value?.realname }} </div>
表达式 检查谁 是否有效 userInfo?.realnameref 对象 ❌ 无意义 userInfo?.value?.realnamevalue ✅ 正确
动态组件 <component :is="xxx"> 与 TS
java
<script setup lang="ts">
import { ref, computed } from 'vue';
import FormA from './FormA.vue';
import FormB from './FormB.vue';
// 用联合类型约束当前组件
const currentTab = ref<'a' | 'b'>('a');
// 用 computed 动态返回组件
const CurrentComponent = computed(() => {
switch (currentTab.value === 'a' ? FormA : FormB);
});
</script>
<template>
<!-- 动态渲染组件用 :is 绑定 -->
<component :is="CurrentComponent" />
</template>
v-model 不能用可选链
java
<!-- ❌ 编译错误:v-model 不支持可选链 -->
<a-input v-model:value="formModelRef?.username" />
<!-- ✅ 正确写法:保证对象肯定存在 -->
<a-input v-model:value="formModelRef.value.username" />
?.在 Vue 里是有效的 JS- 但 Vue 的"ref 自动解包"不会和
?.自动组合 - 所以你必须明确
.value才能让可选链真正作用在数据上
-
ref<T> 存标量和数组 , reactive<T> 存表单/搜索条件
-
defineProps<T> + withDefaults 是 Vue 3 组件声明 props 的标准写法
-
defineEmits<T> 给事件回调加类型,杜绝拼写错误
-
v-for 的 item 类型自动推断 ,但模板里不能写 (item: Type) ,类型要写到 <script> 里的数组声明
-
catch 里的 e 是 unknown ,要先 as Error 才能读 .message
-
reactive 不能整体重新赋值 ,要么改用 ref ,要么用 Object.assign
-
Composables( useXxx ) 是 Vue 3 时代最核心的代码复用方式,JeecgBoot 的 useListPage 就是绝佳范例