jeecgboot vue TS & 模板化 04

ref / reactive / 函数

<script setup> 是 Vue 3 的 语法糖 ,它让组合式 API 用起来更简洁。在 setup 里定义的变量和函数,可以 直接在模板里用 ,不需要 return 。

配合 TS,它变成了"编译期静态检查 + 运行时响应式"的组合。

这一节的所有例子都来自 jeecgboot-vue3/src 真实代码

  1. <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) ,作用是安全访问对象的属性,防止访问 nullundefined 时报错。

具体行为:

  1. formModelRef.value 如果 不是 null 或 undefined ,就会读取 .rememberMe 的值。
  2. 如果 formModelRef.valuenull 或 undefined ,整个表达式 formModelRef.value?.rememberMe 返回 undefined,不会报错。
  3. 后面的 || false 保证当值是 undefinedfalse 时,最终 rememberMe 至少会是 false

问号 ?. 用于安全地访问可能不存在的对象属性,是 TypeScript/现代 JavaScript 的语法糖。

description: `欢迎回来,${userInfo?.realname}`,

如果 userInfonullundefined,那么 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 用法就是一个非常好的范例:

java 复制代码
export 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?.realname ref 对象 ❌ 无意义
userInfo?.value?.realname value ✅ 正确

动态组件 <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 就是绝佳范例

相关推荐
晓13131 小时前
【Cocos Creator 2.x】篇——第二章 入门
javascript·游戏引擎
AI_零食2 小时前
鸿蒙PC Electron跨平台应用开发:24时区时间表应用详解
前端·华为·electron·开源·harmonyos·鸿蒙
Electrolux3 小时前
[onlyoffice-v9]纯前端怎么实现编辑预览office
前端·javascript·github
VidDown3 小时前
Webhook 调试器:让第三方回调“原形毕露”
java·开发语言·javascript·编辑器·postman
码云之上3 小时前
聊聊如何设计一个高效、稳定的 Node.js 接入层
前端·后端·node.js
kyriewen4 小时前
我读了一遍 Babel 编译后的 async/await,终于搞懂了它的原理(附 20 行手写实现)
前端·javascript·面试
IT_陈寒4 小时前
Vite项目build后路由404了?你可能漏了这个小配置
前端·人工智能·后端
lichenyang4534 小时前
AI 聊天从纯文本到结构化卡片:SSE done 帧携带 card + 历史记录卡片恢复实战
前端
梦曦i5 小时前
@meng-xi/vite-plugin v0.1.5:告别手动 import,精简工具层
前端