普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月10日首页

从 0-1 轻松学会 Vue3 Composables(组合式函数),告别臃肿代码,做会封装的优雅前端

作者 SuperEugene
2026年2月10日 16:18

ps.本文中的第八条包含讲解所用到的所有代码。

一、先忘掉已知编码“模式”,想一个真实问题

假设现在要写一个人员列表页

  • 上面有搜索框(姓名、账号、手机号)
  • 中间一个表格(数据 + 分页)
  • 每一行有:编辑、分配角色、改密码、删除
  • 点编辑/改密码/分配角色会弹出对话框

如果全写在一个 .vue 文件里,会怎样?

  • <template> 还好,主要是布局
  • <script> 里会堆满:搜索表单数据、表格数据、分页、好几个弹窗的显示/隐藏、每个按钮的点击函数、每个弹窗的确认/关闭……

一个文件动不动就 500 行、几十个变量和函数,改一处要翻半天,也不好复用

所以我们要解决的是两件事:

  1. 把“逻辑”从“页面”里拆出来,让页面只负责“长什么样、点哪里”
  2. 拆出来的逻辑要能复用,比如别的页面也要“列表+分页+弹窗”时可以直接用

这种「逻辑从页面里抽出去、按功能组织、可复用」的写法,在 Vue 3 里就对应两样东西:

  • 组合式 API:用 refreactiveonMounted 等写逻辑的方式
  • 组合式函数(Composables) - 音标:/kəm'pəuzəblz/:把一段逻辑封装成一个“以 use 开头的函数”,在页面里调一下就能用

下面分步讲。

二、第一步:认识“组合式 API”(在页面里写逻辑)

以前 Vue 2 常见的是「选项式 API」:一个组件里分好几块 —— datamethodsmounted 等,逻辑按“类型”分,而不是按“功能”分。

Vue 3 的组合式 API 换了一种思路:setup(或 <script setup>)里,像写普通 JS 一样,用变量和函数把“和某块功能相关的所有东西”写在一起

例如“搜索”这一块功能,可以这样写在一起:

// 和“搜索”相关的都放一起
const searchForm = reactive({ userName: '', userAccount: '' })
const handleSearch = () => { /* 调用接口、刷新列表 */ }
const handleReset = () => { searchForm.userName = ''; ... }

“分页”又是一块:

const pagination = reactive({ currentPage: 1, pageSize: 10, total: 0 })
const handleSizeChange = (val) => { ... }
const handleCurrentChange = (val) => { ... }

这样写,同一个功能的数据和函数挨在一起,读起来是“一块一块”的,而不是 data 一堆、methods 又一堆。这就是“组合式”的意思:按逻辑块组合,而不是按选项类型分。

用到的两个基础工具要知道:

  • ref(值):存“一个会变的值”,用的时候要 .value;在模板里可以省略 .value
  • reactive(对象):存“一组会变的属性”,用的时候直接 .属性名 就行

到这里,你只需要记住:<script setup> 里用 ref/reactive + 函数,把同一块功能的逻辑写在一起,这就是“组合式 API”的用法。

三、第二步:逻辑太多时,把“一整块”搬出去

当这一页的逻辑越来越多(搜索、表格、分页、编辑弹窗、改密码弹窗、角色弹窗……),<script setup> 里会变得很长。下一步很自然:把“一整块逻辑”原样搬到一个单独的 .ts 文件里

做法就三步:

  1. 新建一个文件,比如 usePersonnelList.ts
  2. 在里面写一个函数,函数名按约定用 use 开头,比如 usePersonnelList
  3. 把原来在页面里的那一大坨(ref、reactive、所有 handleXxx)剪过去,放进这个函数里,最后 return 出页面需要用的东西

例如:

// usePersonnelList.ts
import { ref, reactive } from 'vue'

export function usePersonnelList() {
  const searchForm = reactive({ userName: '', userAccount: '' })
  const tableData = reactive([])
  const handleSearch = () => { ... }
  const handleReset = () => { ... }
  // ... 其他状态和方法

  return {
    searchForm,
    tableData,
    handleSearch,
    handleReset,
    // 页面要用啥就 return 啥
  }
}

页面里就只做一件事:调用这个函数,把 return 出来的东西拿来用

<script setup>
import { usePersonnelList } from './composables/usePersonnelList'

const {
  searchForm,
  tableData,
  handleSearch,
  handleReset,
} = usePersonnelList()
</script>

<template>
  <!-- 用 searchForm、tableData,绑定 handleSearch、handleReset -->
</template>

这种“以 use 开头、封装一块有状态逻辑、return 给组件用”的函数,官方名字就叫「组合式函数」(Composable,英文文档里会看到这个词)。

当前看到的「编码模式」核心就是:页面只负责布局和调用 useXxx(),具体逻辑都在 useXxx 里

可能有的同学看到 状态 这个词的时候不能理解,不能理解的同学我想应该同样也想不明白vuexpinia为什么叫状态管理而不叫变量管理或者常量管理或者容器管理。可以理解的同学可直接看下一步,接下来的小内容则是给不能理解的同学补补课。

讲解:首先状态和变量一样,都是存储数据的容器。区别在于状态和 UI 是 “双向绑定” 的,变量不一定。普通 JS 变量(比如 let a = 1)改了就是改了,页面不会有任何反应;但 Vue 的状态(比如 const a = ref(1))改 a.value = 2 时,页面里用到 a 的地方会自动更新 —— 这是 “状态” 最核心的特征:状态是 “活的”,和 UI 联动

简单粗暴:

  • 所以不理解的同学可以简单粗暴的将状态理解为可以引动UI变化的变量就是状态。 新手同学理解到这里就可以了,至于状态更精准的理解感兴趣的同学可以自行搜索学习。

不用过多的纠结,可以理解这个简单粗暴的定义就足够你看懂后面的讲解了。

四、用一句话串起来

  • 组合式 API:在 script 里用 ref/reactive + 函数,按“功能块”写逻辑。
  • 组合式函数:把某一整块逻辑搬进 useXxx(),页面里 const { ... } = useXxx() 拿来用。

所以:
“组合式 API”是说“怎么写逻辑”;“组合式函数”是说“把写好的逻辑封装成 useXxx,方便复用和组织”。
当前人员模块的写法,就是:用组合式 API 在 usePersonnelList 里写逻辑,在 index.vue 里只调用 usePersonnelList(),这就是官方主推的这种模式。


五、和当前示例对上号

现在的结构可以这样理解:

当前看到的 含义(小白版)
index.vue 里只有 template + 一个 usePersonnelList() 页面只负责“长什么样”和“用哪一块逻辑”
composables/usePersonnelList.ts 人员列表这一页的“所有逻辑”都在这一个函数里
components/PersonnelSearchForm.vue 把表格、弹窗拆成小组件,只负责展示和发事件
types.ts 把共用的类型(Personnel、Role、表单类型等)集中放,方便复用和改

数据流可以简单理解成:

  1. usePersonnelList() 提供:searchFormtableDatahandleSearchhandleEdit……
  2. index.vue 把这些绑到模板和子组件上(:search-form="searchForm"@search="handleSearch"
  3. 子组件只通过 props 拿数据、通过 emit 触发事件,真正的状态和请求都在 composable 里

这样就实现了:逻辑在 useXxx,页面和组件只做“接线”

六、什么时候用、怎么用(实用口诀)

  • 一个页面逻辑很多 → 先在同一文件里用组合式 API 按“功能块”写;还觉得乱,再抽成 useXxx
  • 多个页面要用同一套逻辑 → 直接写成 useXxx,在不同页面里 const { ... } = useXxx() 即可
  • 命名:这类函数统一用 use 开头,如 usePersonnelListuseMouseuseFetch
  • 文件放哪:和当前功能强相关的就放当前模块下,例如 personnel/composables/usePersonnelList.ts;全项目都要用的可以放 src/composables/ 之类

七、小结(真正从 0 到 1 的路线)

  1. 问题:页面逻辑一多就难维护、难复用。
  2. 组合式 API:用 ref/reactive + 函数,在 script 里按“功能块”组织逻辑。
  3. 组合式函数:把一整块逻辑放进 useXxx(),return 出状态和方法,页面里解构使用。
  4. 现在的模式index.vue 薄薄一层 + usePersonnelList 一坨逻辑 + 几个子组件 + types.ts,这就是 Vue 3 官方在「可复用性 → 组合式函数」里主推的写法。

八、示例代码

想看看实际运行起来什么样的同学也可自行新建一个vue3+ts的项目,复制粘贴代码到编辑器中运行起来看看。我在写这个示例代码时候所创建的项目环境:

  • node版本20.19.0
  • 使用到Element Plus组件库

我在配置代码的时候会习惯性的配置组件的自动引入,所以在代码中无需再手动引入使用到的组件,没有配置过自动引入的同学不要忘记自己补上组件的引入哦。如果在创建项目复制示例代码遇到环境问题的情况下可尝试通过对比我的开发环境解决问题,希望可以有所帮助。

  1. 结构简要说明
src/views/personnel/
├── index.vue                          # 页面入口:标题、搜索、表格、分页、弹窗挂载
├── types.ts                           # 类型定义(如 PersonnelSearchFormPersonnelEditFormRole 等)
├── composables/
│   └── usePersonnelList.ts           # 列表逻辑:搜索、分页、增删改、分配角色、改密等
└── components/
    ├── PersonnelSearchForm.vue       # 顶部搜索栏(用户名称 / 帐号 / 电话)
    ├── PersonnelEditDialog.vue       # 新增/编辑用户弹窗
    ├── PersonnelPasswordDialog.vue    # 修改密码弹窗
    └── PersonnelRoleAssignDialog.vue # 分配角色弹窗
文件 作用
index.vue 主页面,引入搜索表单、表格、分页和三个弹窗,并承接 usePersonnelList 的状态与方法。
types.ts 定义该模块用到的 TS 类型/接口。
usePersonnelList.ts 组合式函数:搜索表单、表格数据、分页、弹窗显隐、请求与事件处理(搜索/重置/增删改/分配角色/改密等)。
PersonnelSearchForm.vue 仅负责搜索表单 UI 与「搜索 / 重置」事件。
PersonnelEditDialog.vue 新增/编辑用户的表单弹窗。
PersonnelPasswordDialog.vue 修改密码的单表单项弹窗。
PersonnelRoleAssignDialog.vue 角色多选表格弹窗,用于分配角色。

数据与业务集中在 usePersonnelList.ts,页面与组件主要负责布局和调用该 composable。

  1. 运行后的项目展示

Snipaste_2026-02-10_14-10-38.png

Snipaste_2026-02-10_14-11-29.png

Snipaste_2026-02-10_14-11-41.png

Snipaste_2026-02-10_14-11-53.png

Snipaste_2026-02-10_14-12-07.png

  1. 可复制运行的代码

下面代码与前面章节一一对应:第二节的「按功能块写」体现在 usePersonnelList.ts 里搜索、分页、弹窗等逻辑块;第三节的「搬进 useXxx、return 给页面用」就是 usePersonnelList() 和其 return;第四节的数据流对应 index.vue 里解构 usePersonnelList() 并绑到模板和子组件。阅读时可按「概念 → 对应文件」对照看。

index.vue

<template>
  <div class="personnel-management">
    <!-- 页面标题 -->
    <div class="page-header">
      <div class="page-header-inner">
        <span class="page-title-accent" />
        <div>
          <h1 class="page-title">人员管理</h1>
          <p class="page-desc">管理系统用户与权限,一目了然</p>
        </div>
      </div>
    </div>

    <!-- 搜索表单 -->
    <PersonnelSearchForm
      :search-form="searchForm"
      @search="handleSearch"
      @reset="handleReset"
    />

    <!-- 数据表格 -->
    <div class="table-section">
      <div class="table-toolbar">
        <el-button type="primary" class="btn-add" @click="handleAdd">
          <span class="btn-add-icon">+</span>
          新增人员
        </el-button>
      </div>
      <el-table
        :data="tableData"
        class="personnel-table"
        style="width: 100%"
        :row-key="(row) => row.id"
        :header-cell-style="headerCellStyle"
        :row-class-name="tableRowClassName"
      >
        <el-table-column label="头像" width="96" align="center">
          <template #default="{ row }">
            <div class="avatar-wrap">
              <el-avatar :src="row.avatar" :size="44" class="user-avatar" />
            </div>
          </template>
        </el-table-column>

        <el-table-column prop="userName" label="用户名称" align="center" min-width="100" />
        <el-table-column prop="position" label="职位" align="center" min-width="100" />
        <el-table-column prop="userAccount" label="用户账号" align="center" min-width="120" />
        <el-table-column prop="userPhone" label="用户电话" align="center" min-width="120" />
        <el-table-column prop="userEmail" label="用户邮箱" align="center" min-width="160" />

        <el-table-column label="操作" width="340" fixed="right" align="center">
          <template #default="{ row }">
            <div class="table-actions">
              <el-button class="action-btn action-btn--primary" size="small" @click="handleEdit(row)">
                编辑
              </el-button>
              <el-button class="action-btn action-btn--primary" size="small" @click="handleAssignRole(row)">
                分配角色
              </el-button>
              <el-button class="action-btn" size="small" @click="handleChangePassword(row)">
                改密
              </el-button>
              <el-button class="action-btn action-btn--danger" size="small" @click="handleDelete(row)">
                删除
              </el-button>
            </div>
          </template>
        </el-table-column>
      </el-table>

      <!-- 分页 -->
      <div class="pagination-wrap">
        <el-pagination
          :current-page="pagination.currentPage"
          :page-sizes="[10, 20, 50, 100]"
          :page-size="pagination.pageSize"
          :total="pagination.total"
          layout="total, sizes, prev, pager, next, jumper"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </div>

    <!-- 编辑对话框 -->
    <PersonnelEditDialog
      v-model:visible="editDialogVisible"
      :form="editForm"
      @confirm="confirmEdit"
    />

    <!-- 修改密码对话框 -->
    <PersonnelPasswordDialog
      v-model:visible="passwordDialogVisible"
      @confirm="confirmPasswordChange"
      @close="closePasswordDialog"
    />

    <!-- 分配角色对话框 -->
    <PersonnelRoleAssignDialog
      :visible="roleDialogVisible"
      :roles="roles"
      :initial-selected-ids="
        currentRoleAssignUserId ? getInitialRoleIds(currentRoleAssignUserId) : []
      "
      @update:visible="setRoleDialogVisible"
      @confirm="confirmAssignRole"
      @close="() => setRoleDialogVisible(false)"
    />
  </div>
</template>

<script lang="ts" setup>
import { usePersonnelList } from './composables/usePersonnelList'
import PersonnelSearchForm from './components/PersonnelSearchForm.vue'
import PersonnelEditDialog from './components/PersonnelEditDialog.vue'
import PersonnelPasswordDialog from './components/PersonnelPasswordDialog.vue'
import PersonnelRoleAssignDialog from './components/PersonnelRoleAssignDialog.vue'

defineOptions({
  name: 'PersonnelIndex',
})

const {
  searchForm,
  tableData,
  pagination,
  roles,
  editForm,
  editDialogVisible,
  passwordDialogVisible,
  roleDialogVisible,
  currentRoleAssignUserId,
  getInitialRoleIds,
  handleSearch,
  handleReset,
  handleSizeChange,
  handleCurrentChange,
  handleAdd,
  handleEdit,
  confirmEdit,
  handleChangePassword,
  confirmPasswordChange,
  handleDelete,
  handleAssignRole,
  confirmAssignRole,
  setRoleDialogVisible,
  closePasswordDialog,
} = usePersonnelList()

const headerCellStyle = {
  background: 'transparent',
  color: '#5a6576',
  fontWeight: 600,
  fontSize: '12px',
}

const tableRowClassName = ({ rowIndex }: { rowIndex: number }) =>
  rowIndex % 2 === 1 ? 'row-stripe' : ''
</script>

<style scoped lang="scss">
$primary: #5b8dee;
$primary-hover: #6c9eff;
$primary-soft: rgba(91, 141, 238, 0.12);
$text: #2d3748;
$text-light: #718096;
$border: rgba(91, 141, 238, 0.15);
$danger: #e85d6a;
$danger-soft: rgba(232, 93, 106, 0.12);

.personnel-management {
  padding: 40px 48px 56px;
  min-height: 100%;
  background: linear-gradient(160deg, #fafbff 0%, #f4f6fc 50%, #eef2fa 100%);
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}

.page-header {
  margin-bottom: 32px;
  .page-header-inner {
    display: flex;
    align-items: flex-start;
    gap: 16px;
  }
  .page-title-accent {
    width: 4px;
    height: 32px;
    border-radius: 4px;
    background: linear-gradient(180deg, $primary 0%, #7ba3f5 100%);
    flex-shrink: 0;
  }
  .page-title {
    margin: 0;
    font-size: 26px;
    font-weight: 600;
    color: $text;
    letter-spacing: -0.02em;
    line-height: 1.3;
  }
  .page-desc {
    margin: 6px 0 0;
    font-size: 14px;
    color: $text-light;
    font-weight: 400;
  }
}

.table-section {
  background: #fff;
  border-radius: 16px;
  overflow: hidden;
  padding: 28px 36px 36px;
  box-shadow: 0 4px 24px rgba(91, 141, 238, 0.06), 0 1px 0 rgba(255, 255, 255, 0.8) inset;
  border: 1px solid $border;
  transition: box-shadow 0.25s ease;
  &:hover {
    box-shadow: 0 8px 32px rgba(91, 141, 238, 0.08), 0 1px 0 rgba(255, 255, 255, 0.8) inset;
  }
}

.table-toolbar {
  margin-bottom: 24px;
  .btn-add {
    font-weight: 500;
    font-size: 14px;
    border-radius: 10px;
    padding: 10px 20px;
    background: linear-gradient(135deg, $primary 0%, #6c9eff 100%);
    border: none;
    color: #fff;
    box-shadow: 0 2px 12px rgba(91, 141, 238, 0.35);
    transition: all 0.25s ease;
    &:hover {
      background: linear-gradient(135deg, $primary-hover 0%, #7ba8ff 100%);
      box-shadow: 0 4px 16px rgba(91, 141, 238, 0.4);
      transform: translateY(-1px);
    }
  }
  .btn-add-icon {
    margin-right: 6px;
    font-size: 16px;
    font-weight: 300;
    opacity: 0.95;
  }
}

.personnel-table {
  --el-table-border-color: #e8ecf4;
  --el-table-header-bg-color: transparent;
  font-size: 14px;

  :deep(.el-table__header th) {
    background: linear-gradient(180deg, #fafbff 0%, #f5f7fc 100%) !important;
    color: $text-light;
    font-weight: 600;
    font-size: 12px;
    letter-spacing: 0.03em;
    padding: 14px 0;
  }
  :deep(.el-table__body td) {
    color: $text;
    font-size: 14px;
    padding: 14px 0;
    transition: background 0.2s ease;
  }
  :deep(.el-table__row:hover td) {
    background: #f8faff !important;
  }
  :deep(.row-stripe td) {
    background: #fafbff !important;
  }
  :deep(.el-table__row.row-stripe:hover td) {
    background: #f8faff !important;
  }
  .avatar-wrap {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 52px;
    height: 52px;
    border-radius: 12px;
    background: linear-gradient(135deg, $primary-soft 0%, rgba(124, 163, 245, 0.08) 100%);
  }
  .user-avatar {
    border: none;
    background: #e8ecf4;
  }
}

.table-actions {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: 6px 12px;
  .action-btn {
    padding: 6px 12px;
    font-size: 13px;
    border-radius: 8px;
    font-weight: 500;
    border: none;
    transition: all 0.2s ease;
    &--primary {
      color: $primary;
      background: $primary-soft;
      &:hover {
        background: rgba(91, 141, 238, 0.2);
        color: $primary-hover;
      }
    }
    &--danger {
      color: $danger;
      background: $danger-soft;
      &:hover {
        background: rgba(232, 93, 106, 0.2);
        color: darken($danger, 4%);
      }
    }
    &:not(.action-btn--primary):not(.action-btn--danger) {
      color: $text-light;
      background: rgba(113, 128, 150, 0.08);
      &:hover {
        background: rgba(113, 128, 150, 0.15);
        color: $text;
      }
    }
  }
}

.pagination-wrap {
  margin-top: 24px;
  display: flex;
  justify-content: flex-end;
  :deep(.el-pagination) {
    font-size: 14px;
    font-weight: 400;
    color: $text;
    .el-pager li {
      border-radius: 8px;
      min-width: 32px;
      height: 32px;
      line-height: 32px;
      background: #f5f7fc;
      color: $text;
      transition: all 0.2s ease;
      &:hover {
        background: $primary-soft;
        color: $primary;
      }
      &.is-active {
        background: linear-gradient(135deg, $primary 0%, $primary-hover 100%);
        color: #fff;
      }
    }
    .btn-prev, .btn-next {
      border-radius: 8px;
      background: #f5f7fc;
      color: $text;
      min-width: 32px;
      height: 32px;
      &:hover:not(:disabled) {
        background: $primary-soft;
        color: $primary;
      }
    }
  }
}
</style>

types.ts

/** 人员信息 */
export interface Personnel {
  id: number
  avatar: string
  userName: string
  position: string
  userAccount: string
  userPhone: string
  userEmail: string
}

/** 角色信息 */
export interface Role {
  id: number
  name: string
}

/** 搜索表单 */
export interface PersonnelSearchForm {
  userName: string
  userAccount: string
  userPhone: string
}

/** 编辑表单 */
export interface PersonnelEditForm {
  id: number | null
  userName: string
  position: string
  userPhone: string
  userEmail: string
}

/** 分页参数 */
export interface PaginationState {
  currentPage: number
  pageSize: number
  total: number
}

usePersonnelList.ts

import { reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type {
  Personnel,
  Role,
  PersonnelSearchForm,
  PersonnelEditForm,
  PaginationState,
} from '../types'

/** 模拟数据 - 后续接入 API 时替换 */
const MOCK_PERSONNEL: Personnel[] = [
  {
    id: 1,
    avatar:
      'https://cube.elemecdn.com/3/7c/3ea6beec6434a5aaaca3b9b973136830a4afe1266d2b9a3af511687b91.png',
    userName: '张三',
    position: '销售经理',
    userAccount: 'zhangsan',
    userPhone: '13800138000',
    userEmail: 'zhangsan@example.com',
  },
  {
    id: 2,
    avatar:
      'https://cube.elemecdn.com/3/7c/3ea6beec6434a5aaaca3b9b973136830a4afe1266d2b9a3af511687b91.png',
    userName: '李四',
    position: '销售代表',
    userAccount: 'lisi',
    userPhone: '13900139000',
    userEmail: 'lisi@example.com',
  },
]

const MOCK_ROLES: Role[] = [
  { id: 1, name: '管理员' },
  { id: 2, name: '普通分销员' },
  { id: 3, name: '高级分销员' },
]

/** 模拟用户已有角色映射 */
const MOCK_USER_ROLES: Record<number, number[]> = {
  1: [1],
  2: [2],
}

export function usePersonnelList() {
  const searchForm = reactive<PersonnelSearchForm>({
    userName: '',
    userAccount: '',
    userPhone: '',
  })

  const tableData = reactive<Personnel[]>([...MOCK_PERSONNEL])

  const pagination = reactive<PaginationState>({
    currentPage: 1,
    pageSize: 10,
    total: 20,
  })

  const roles = reactive<Role[]>([...MOCK_ROLES])

  const editDialogVisible = ref(false)
  const passwordDialogVisible = ref(false)
  const roleDialogVisible = ref(false)

  const editForm = reactive<PersonnelEditForm>({
    id: null,
    userName: '',
    position: '',
    userPhone: '',
    userEmail: '',
  })

  const currentPasswordUserId = ref<number | null>(null)
  const currentRoleAssignUserId = ref<number | null>(null)

  const handleSearch = () => {
    ElMessage.success('搜索功能执行')
    // TODO: 接入 API 后调用接口
  }

  const handleReset = () => {
    searchForm.userName = ''
    searchForm.userAccount = ''
    searchForm.userPhone = ''
  }

  const handleSizeChange = (val: number) => {
    pagination.pageSize = val
    // TODO: 接入 API 后调用接口
  }

  const handleCurrentChange = (val: number) => {
    pagination.currentPage = val
    // TODO: 接入 API 后调用接口
  }

  const handleAdd = () => {
    editForm.id = null
    editForm.userName = ''
    editForm.position = ''
    editForm.userPhone = ''
    editForm.userEmail = ''
    editDialogVisible.value = true
  }

  const handleEdit = (row: Personnel) => {
    editForm.id = row.id
    editForm.userName = row.userName
    editForm.position = row.position
    editForm.userPhone = row.userPhone
    editForm.userEmail = row.userEmail
    editDialogVisible.value = true
  }

  const confirmEdit = () => {
    const isEdit = editForm.id !== null
    if (isEdit) {
      ElMessage.success('编辑成功')
      // TODO: 接入 API 后调用编辑接口并刷新列表
    } else {
      ElMessage.success('新增成功')
      // TODO: 接入 API 后调用新增接口并刷新列表
    }
    editDialogVisible.value = false
  }

  const handleChangePassword = (row: Personnel) => {
    currentPasswordUserId.value = row.id
    passwordDialogVisible.value = true
  }

  const confirmPasswordChange = (newPassword: string) => {
    ElMessage.success('密码修改成功')
    passwordDialogVisible.value = false
    currentPasswordUserId.value = null
  }

  const handleDelete = (row: Personnel) => {
    ElMessageBox.confirm('确定要删除该用户吗?', '删除确认', {
      confirmButtonText: '确定',
      cancelButtonText: '取消',
      type: 'warning',
    })
      .then(() => {
        ElMessage.success('删除成功')
        // TODO: 接入 API 后调用接口并刷新列表
      })
      .catch(() => {
        ElMessage.info('已取消删除')
      })
  }

  const getInitialRoleIds = (userId: number): number[] => {
    return MOCK_USER_ROLES[userId] ?? []
  }

  const handleAssignRole = (row: Personnel) => {
    currentRoleAssignUserId.value = row.id
    roleDialogVisible.value = true
  }

  const confirmAssignRole = (selectedIds: number[]) => {
    if (selectedIds.length === 0) {
      ElMessage.error('请至少选择一个角色')
      return false
    }
    const roleNames = selectedIds
      .map((id) => roles.find((r) => r.id === id)?.name)
      .filter(Boolean)
      .join(', ')
    ElMessage.success(`已为用户分配角色: ${roleNames}`)
    roleDialogVisible.value = false
    currentRoleAssignUserId.value = null
    return true
  }

  const setRoleDialogVisible = (visible: boolean) => {
    roleDialogVisible.value = visible
    if (!visible) currentRoleAssignUserId.value = null
  }

  const setPasswordDialogVisible = (visible: boolean) => {
    passwordDialogVisible.value = visible
    if (!visible) currentPasswordUserId.value = null
  }

  const closePasswordDialog = () => {
    passwordDialogVisible.value = false
    currentPasswordUserId.value = null
  }

  return {
    searchForm,
    tableData,
    pagination,
    roles,
    editForm,
    editDialogVisible,
    passwordDialogVisible,
    roleDialogVisible,
    currentPasswordUserId,
    currentRoleAssignUserId,
    getInitialRoleIds,
    handleSearch,
    handleReset,
    handleSizeChange,
    handleCurrentChange,
    handleAdd,
    handleEdit,
    confirmEdit,
    handleChangePassword,
    confirmPasswordChange,
    handleDelete,
    handleAssignRole,
    confirmAssignRole,
    setRoleDialogVisible,
    setPasswordDialogVisible,
    closePasswordDialog,
  }
}

PersonnelSearchForm.vue

<template>
  <div class="search-card">
    <el-form :inline="true" :model="searchForm" class="search-form">
      <el-form-item label="用户名称" prop="userName">
        <el-input
          v-model="searchForm.userName"
          placeholder="请输入"
          clearable
          size="small"
          class="search-input"
        />
      </el-form-item>
      <el-form-item label="用户帐号" prop="userAccount">
        <el-input
          v-model="searchForm.userAccount"
          placeholder="请输入"
          clearable
          size="small"
          class="search-input"
        />
      </el-form-item>
      <el-form-item label="用户电话" prop="userPhone">
        <el-input
          v-model="searchForm.userPhone"
          placeholder="请输入"
          clearable
          size="small"
          class="search-input"
        />
      </el-form-item>
      <el-form-item class="form-actions">
        <el-button type="primary" size="small" @click="emit('search')">搜索</el-button>
        <el-button size="small" @click="emit('reset')">重置</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script lang="ts" setup>
import type { PersonnelSearchForm } from '../types'

defineOptions({
  name: 'PersonnelSearchForm',
})

defineProps<{
  searchForm: PersonnelSearchForm
}>()

const emit = defineEmits<{
  search: []
  reset: []
}>()
</script>

<style scoped lang="scss">
$primary: #5b8dee;
$primary-hover: #6c9eff;
$text: #2d3748;
$text-light: #718096;
$border: rgba(91, 141, 238, 0.2);

.search-card {
  background: #fff;
  border-radius: 16px;
  padding: 16px 28px;
  margin-bottom: 24px;
  box-shadow: 0 4px 20px rgba(91, 141, 238, 0.06), 0 1px 0 rgba(255, 255, 255, 0.8) inset;
  border: 1px solid rgba(91, 141, 238, 0.12);
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
  transition: box-shadow 0.25s ease;
  &:hover {
    box-shadow: 0 6px 28px rgba(91, 141, 238, 0.08), 0 1px 0 rgba(255, 255, 255, 0.8) inset;
  }
}

.search-form {
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0 20px;
  :deep(.el-form-item) {
    margin-bottom: 0;
    margin-right: 0;
    display: inline-flex;
    align-items: center;
  }
  :deep(.el-form-item__label) {
    color: $text;
    font-weight: 500;
    font-size: 13px;
    line-height: 32px;
    height: auto;
    padding-right: 10px;
    display: inline-flex;
    align-items: center;
  }
  :deep(.el-form-item__content) {
    display: inline-flex;
    align-items: center;
    line-height: 32px;
  }
  :deep(.el-input__wrapper) {
    border-radius: 8px;
    border: 1px solid #e2e8f0;
    box-shadow: none;
    font-size: 13px;
    padding: 0 10px;
    min-height: 32px;
    transition: all 0.2s ease;
    &:hover {
      border-color: #c5d0e0;
    }
    &.is-focus {
      border-color: $primary;
      box-shadow: 0 0 0 2px rgba(91, 141, 238, 0.18);
    }
  }
  :deep(.el-input__inner) {
    height: 30px;
    line-height: 30px;
  }
  .search-input {
    width: 140px;
  }
  .form-actions {
    margin-right: 0;
    :deep(.el-button) {
      height: 32px;
      padding: 0 14px;
      font-size: 13px;
      border-radius: 8px;
    }
    :deep(.el-button--primary) {
      background: linear-gradient(135deg, $primary 0%, $primary-hover 100%);
      border: none;
      font-weight: 500;
      box-shadow: 0 2px 8px rgba(91, 141, 238, 0.3);
      transition: all 0.25s ease;
      &:hover {
        box-shadow: 0 4px 12px rgba(91, 141, 238, 0.4);
        transform: translateY(-1px);
      }
    }
    :deep(.el-button:not(.el-button--primary)) {
      color: $text;
      border: 1px solid #e2e8f0;
      background: #fff;
      transition: all 0.2s ease;
      &:hover {
        border-color: $primary;
        color: $primary;
        background: rgba(91, 141, 238, 0.06);
      }
    }
  }
}
</style>

PersonnelEditDialog.vue

<template>
  <el-dialog
    v-model="visible"
    :title="isEdit ? '编辑用户' : '新增用户'"
    width="480px"
    class="personnel-dialog"
    destroy-on-close
    @close="emit('update:visible', false)"
  >
    <el-form :model="form" label-width="90px" class="dialog-form">
      <el-form-item label="用户名称">
        <el-input v-model="form.userName" placeholder="请输入" />
      </el-form-item>
      <el-form-item label="职位">
        <el-input v-model="form.position" placeholder="请输入" />
      </el-form-item>
      <el-form-item label="用户电话">
        <el-input v-model="form.userPhone" placeholder="请输入" />
      </el-form-item>
      <el-form-item label="用户邮箱">
        <el-input v-model="form.userEmail" placeholder="请输入" />
      </el-form-item>
    </el-form>
    <template #footer>
      <div class="dialog-footer">
        <el-button @click="emit('update:visible', false)">取消</el-button>
        <el-button type="primary" @click="emit('confirm')">确定</el-button>
      </div>
    </template>
  </el-dialog>
</template>

<script lang="ts" setup>
import { computed } from 'vue'
import type { PersonnelEditForm } from '../types'

defineOptions({
  name: 'PersonnelEditDialog',
})

const props = defineProps<{
  visible: boolean
  form: PersonnelEditForm
}>()

const emit = defineEmits<{
  'update:visible': [value: boolean]
  confirm: []
}>()

const visible = computed({
  get: () => props.visible,
  set: (val) => emit('update:visible', val),
})

const isEdit = computed(() => props.form.id !== null)
</script>

<style scoped lang="scss">
$primary: #5b8dee;
$primary-hover: #6c9eff;
$text: #2d3748;
$text-light: #718096;

.personnel-dialog :deep(.el-dialog) {
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 24px 48px rgba(45, 55, 72, 0.12), 0 8px 24px rgba(91, 141, 238, 0.08);
  border: 1px solid rgba(91, 141, 238, 0.12);
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
}
.personnel-dialog :deep(.el-dialog__header) {
  padding: 24px 28px 20px;
  border-bottom: 1px solid #eef2f8;
  background: linear-gradient(180deg, #fafbff 0%, #fff 100%);
  .el-dialog__title {
    font-size: 18px;
    font-weight: 600;
    color: $text;
    letter-spacing: -0.01em;
  }
  .el-dialog__headerbtn .el-dialog__close {
    color: $text-light;
    font-size: 18px;
    &:hover {
      color: $text;
    }
  }
}
.dialog-form {
  padding: 24px 28px 0;
  :deep(.el-form-item__label) {
    color: $text;
    font-size: 14px;
    font-weight: 500;
  }
  :deep(.el-input__wrapper) {
    border-radius: 10px;
    border: 1px solid #e2e8f0;
    box-shadow: none;
    font-size: 14px;
    transition: all 0.2s ease;
    &.is-focus {
      border-color: $primary;
      box-shadow: 0 0 0 3px rgba(91, 141, 238, 0.18);
    }
  }
}
.dialog-footer {
  padding: 18px 28px 24px;
  border-top: 1px solid #eef2f8;
  background: #fafbff;
  :deep(.el-button--primary) {
    background: linear-gradient(135deg, $primary 0%, $primary-hover 100%);
    border: none;
    border-radius: 10px;
    padding: 9px 22px;
    font-size: 14px;
    font-weight: 500;
    box-shadow: 0 2px 10px rgba(91, 141, 238, 0.3);
    transition: all 0.25s ease;
    &:hover {
      box-shadow: 0 4px 14px rgba(91, 141, 238, 0.4);
      transform: translateY(-1px);
    }
  }
  :deep(.el-button:not(.el-button--primary)) {
    border-radius: 10px;
    color: $text;
    border: 1px solid #e2e8f0;
    font-size: 14px;
    background: #fff;
    &:hover {
      border-color: $primary;
      color: $primary;
      background: rgba(91, 141, 238, 0.06);
    }
  }
}
</style>

PersonnelPasswordDialog.vue

<template>
  <el-dialog
    v-model="visible"
    title="修改密码"
    width="360px"
    class="personnel-dialog"
    destroy-on-close
    @close="handleClose"
  >
    <el-form :model="form" label-width="80px" class="dialog-form">
      <el-form-item label="新密码">
        <el-input
          v-model="form.newPassword"
          type="password"
          placeholder="请输入新密码"
          show-password
        />
      </el-form-item>
    </el-form>
    <template #footer>
      <div class="dialog-footer">
        <el-button @click="handleClose">取消</el-button>
        <el-button type="primary" @click="handleConfirm">确定</el-button>
      </div>
    </template>
  </el-dialog>
</template>

<script lang="ts" setup>
import { computed, ref, watch } from 'vue'

defineOptions({
  name: 'PersonnelPasswordDialog',
})

const props = defineProps<{
  visible: boolean
}>()

const emit = defineEmits<{
  'update:visible': [value: boolean]
  confirm: [newPassword: string]
  close: []
}>()

const visible = computed({
  get: () => props.visible,
  set: (val) => emit('update:visible', val),
})

const form = ref({
  newPassword: '',
})

const handleClose = () => {
  form.value.newPassword = ''
  emit('update:visible', false)
  emit('close')
}

const handleConfirm = () => {
  emit('confirm', form.value.newPassword)
  form.value.newPassword = ''
  emit('update:visible', false)
}

watch(
  () => props.visible,
  (val) => {
    if (!val) {
      form.value.newPassword = ''
    }
  }
)
</script>

<style scoped lang="scss">
$primary: #5b8dee;
$primary-hover: #6c9eff;
$text: #2d3748;
$text-light: #718096;

.personnel-dialog :deep(.el-dialog) {
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 24px 48px rgba(45, 55, 72, 0.12), 0 8px 24px rgba(91, 141, 238, 0.08);
  border: 1px solid rgba(91, 141, 238, 0.12);
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
}
.personnel-dialog :deep(.el-dialog__header) {
  padding: 24px 28px 20px;
  border-bottom: 1px solid #eef2f8;
  background: linear-gradient(180deg, #fafbff 0%, #fff 100%);
  .el-dialog__title {
    font-size: 18px;
    font-weight: 600;
    color: $text;
    letter-spacing: -0.01em;
  }
}
.dialog-form {
  padding: 24px 28px 0;
  :deep(.el-input__wrapper) {
    border-radius: 10px;
    border: 1px solid #e2e8f0;
    box-shadow: none;
    font-size: 14px;
    transition: all 0.2s ease;
    &.is-focus {
      border-color: $primary;
      box-shadow: 0 0 0 3px rgba(91, 141, 238, 0.18);
    }
  }
}
.dialog-footer {
  padding: 18px 28px 24px;
  border-top: 1px solid #eef2f8;
  background: #fafbff;
  :deep(.el-button--primary) {
    background: linear-gradient(135deg, $primary 0%, $primary-hover 100%);
    border: none;
    border-radius: 10px;
    padding: 9px 22px;
    font-size: 14px;
    font-weight: 500;
    box-shadow: 0 2px 10px rgba(91, 141, 238, 0.3);
    transition: all 0.25s ease;
    &:hover {
      box-shadow: 0 4px 14px rgba(91, 141, 238, 0.4);
      transform: translateY(-1px);
    }
  }
  :deep(.el-button:not(.el-button--primary)) {
    border-radius: 10px;
    color: $text;
    border: 1px solid #e2e8f0;
    font-size: 14px;
    background: #fff;
    &:hover {
      border-color: $primary;
      color: $primary;
      background: rgba(91, 141, 238, 0.06);
    }
  }
}
</style>

PersonnelRoleAssignDialog.vue

<template>
  <el-dialog
    v-model="visible"
    title="分配角色"
    width="480px"
    class="personnel-dialog role-dialog"
    destroy-on-close
    @open="handleOpen"
    @close="handleClose"
  >
    <el-table
      ref="tableRef"
      :data="roles"
      class="role-table"
      style="width: 100%"
      :row-key="(row) => row.id"
      @selection-change="handleSelectionChange"
    >
      <el-table-column type="selection" width="50" />
      <el-table-column prop="id" label="角色ID" width="80" />
      <el-table-column prop="name" label="角色名称" />
    </el-table>
    <template #footer>
      <div class="dialog-footer">
        <el-button @click="handleClose">取消</el-button>
        <el-button type="primary" @click="handleConfirm">确定</el-button>
      </div>
    </template>
  </el-dialog>
</template>

<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import type { ElTable } from 'element-plus'
import type { Role } from '../types'

defineOptions({
  name: 'PersonnelRoleAssignDialog',
})

const props = defineProps<{
  visible: boolean
  roles: Role[]
  initialSelectedIds: number[]
}>()

const emit = defineEmits<{
  'update:visible': [value: boolean]
  confirm: [selectedIds: number[]]
  close: []
}>()

const visible = computed({
  get: () => props.visible,
  set: (val) => emit('update:visible', val),
})

const tableRef = ref<InstanceType<typeof ElTable>>()
const selectedIds = ref<number[]>([])

const handleSelectionChange = (selection: Role[]) => {
  selectedIds.value = selection.map((r) => r.id)
}

const handleOpen = () => {
  selectedIds.value = [...props.initialSelectedIds]
  setTableSelection()
}

const setTableSelection = () => {
  if (!tableRef.value || !props.roles.length) return

  tableRef.value.clearSelection()
  props.roles.forEach((role) => {
    if (props.initialSelectedIds.includes(role.id)) {
      tableRef.value?.toggleRowSelection(role, true)
    }
  })
}

watch(
  () => [props.visible, props.roles],
  () => {
    if (props.visible) {
      selectedIds.value = [...props.initialSelectedIds]
      // 延迟确保表格已渲染
      setTimeout(setTableSelection, 0)
    }
  },
  { flush: 'post' }
)

const handleConfirm = () => {
  emit('confirm', selectedIds.value)
  // 关闭由父级 confirmAssignRole 成功时控制
}

const handleClose = () => {
  emit('update:visible', false)
  emit('close')
}
</script>

<style scoped lang="scss">
$primary: #5b8dee;
$primary-hover: #6c9eff;
$text: #2d3748;
$text-light: #718096;

.personnel-dialog :deep(.el-dialog) {
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 24px 48px rgba(45, 55, 72, 0.12), 0 8px 24px rgba(91, 141, 238, 0.08);
  border: 1px solid rgba(91, 141, 238, 0.12);
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
}
.personnel-dialog :deep(.el-dialog__header) {
  padding: 24px 28px 20px;
  border-bottom: 1px solid #eef2f8;
  background: linear-gradient(180deg, #fafbff 0%, #fff 100%);
  .el-dialog__title {
    font-size: 18px;
    font-weight: 600;
    color: $text;
    letter-spacing: -0.01em;
  }
}
.role-dialog :deep(.el-dialog__body) {
  padding: 20px 28px;
}
.role-table {
  --el-table-border-color: #e8ecf4;
  font-size: 14px;
  :deep(.el-table__header th) {
    background: linear-gradient(180deg, #fafbff 0%, #f5f7fc 100%) !important;
    color: $text-light;
    font-weight: 600;
    font-size: 12px;
  }
  :deep(.el-table__body td) {
    color: $text;
  }
  :deep(.el-table__row:hover td) {
    background: #f8faff !important;
  }
}
.dialog-footer {
  padding: 18px 28px 24px;
  border-top: 1px solid #eef2f8;
  background: #fafbff;
  :deep(.el-button--primary) {
    background: linear-gradient(135deg, $primary 0%, $primary-hover 100%);
    border: none;
    border-radius: 10px;
    padding: 9px 22px;
    font-size: 14px;
    font-weight: 500;
    box-shadow: 0 2px 10px rgba(91, 141, 238, 0.3);
    transition: all 0.25s ease;
    &:hover {
      box-shadow: 0 4px 14px rgba(91, 141, 238, 0.4);
      transform: translateY(-1px);
    }
  }
  :deep(.el-button:not(.el-button--primary)) {
    border-radius: 10px;
    color: $text;
    border: 1px solid #e2e8f0;
    font-size: 14px;
    background: #fff;
    &:hover {
      border-color: $primary;
      color: $primary;
      background: rgba(91, 141, 238, 0.06);
    }
  }
}
</style>

以上便是对Vue3 Composables(组合式函数)的分享,欢迎大家指正讨论,与大家共勉。

昨天以前首页

TypeScript 泛型从轻松入门到看懂源码

作者 SuperEugene
2026年2月7日 02:02

从「完全不懂泛型」一路走到「看懂下面这段代码到底在干嘛」:

//此代为为VxeTable组件库Grid配置式表格数据分页示例代码部分片段
<script lang="ts" setup>
import { reactive } from 'vue'
import type { VxeGridProps, VxeGridListeners } from 'vxe-table'

interface RowVO {  
  id: number  
  name: string  
  role: string  
  sex: string  
  age: number  
  address: string
}

const gridOptions = reactive<VxeGridProps<RowVO>>({  
  showOverflow: true,  
  border: true,  
  loading: false,  
  height: 500,  
  pagerConfig: pagerVO,  
  columns: [    
    { type: 'seq', width: 70, fixed: 'left' },    
    { field: 'name', title: 'Name', minWidth: 160 },    
    { field: 'email', title: 'Email', minWidth: 160 },    
    { field: 'nickname', title: 'Nickname', minWidth: 160 },    
    { field: 'age', title: 'Age', width: 100 },    
    { field: 'role', title: 'Role', minWidth: 160 },    
    { field: 'amount', title: 'Amount', width: 140 },    
    { field: 'updateDate', title: 'Update Date', visible: false },    
    { field: 'createDate', title: 'Create Date', visible: false },  
  ],  
  data: [],
})
</script>

VxeTable组件库简介:

  • 由于这篇文章引用到了VxeTable组件库的代码,所以在这里给没接触过的小伙伴做一个简单的介绍,老司机可自行跳过。
  • VxeTable是一个基于 Vue 的表格组件库,提供表格、表单、工具栏、分页等组件,适合中后台场景。性能与功能都较强,但学习成本和按需引入的配置需要投入时间。如果你的项目以表格为核心,且需要虚拟滚动、复杂交互等功能,VxeTable 是合适的选择。感兴趣的小伙伴可以通过下方贴上的官网链接学习了解。

(PS·即使没用过VxeTable也不影响你看懂这篇文章)

官网链接:VxeTable官网

一、什么是泛型?一句话版本

泛型 = 给 “类型” 加参数。

  • 函数可以有参数:function fn(x: number) {}
  • 类型也可以有 “参数”:Array<string>

这里 Array 就是一个「带类型参数」的类型,<string> 就是「传给它的类型参数」。

用人话说:

泛型就是:我写一份通用的类型 / 函数,真正用的时候再告诉它具体用什么类型

二、最普通的一层泛型

1. 最熟悉的例子:数组

// 这俩是完全等价的
const list1: string[] = []
const list2: Array<string> = []
  • Array<T> 是一个泛型类型
  • T 是它的类型参数
  • Array<string> 表示「元素类型是 string 的数组」

2. 自己写一个泛型函数

function identity<T>(value: T): T {  
  return value
}
identity<number>(1)      // T 被替换成 number
identity<string>('hi')   // T 被替换成 string

你可以理解为:

  • 定义:identity<T>T 是一个「占位的类型」
  • 使用:identity<number> → 这次调用里「把 T 换成 number

或许有同学不理解为什么要在函数名称后面写<T>。不用纠结,这是固定的写法,就像你要使用变量,就要先声明一样,如:

let data = []
data.push(123)

如果此处没有声明data,便用不了data。同理如果不在函数名称后面写<T>声明一下这是泛型参数,TypeScript 无法识别 T 是什么,如下:

//  错误:找不到名称 'T'
function identity(value: T): T {
  return value
}
// 报错:Cannot find name 'T'

TypeScript 会把 T 当作一个未声明的类型,因此报错。

三、类型也可以是泛型:接口 /type

1. 泛型接口

// 使用时传入不同的 T
interface ApiResponse<T> {  
  code: number  
  msg: string  
  data: T
}

interface User {  
  id: number  
  name: string
}

const res1: ApiResponse<User> = {  
  code: 0,  
  msg: 'ok',  
  data: { id: 1, name: '张三' },
}

const res2: ApiResponse<string[]> = {  
  code: 0,  
  msg: 'ok',  
  data: ['a', 'b'],
}

观察:

  • ApiResponse<T> 自己并不知道 T 是啥
  • 真正用的时候写 ApiResponse<User> / ApiResponse<string[]>
  • TypeScript 在这一刻才把 T 替换掉

四、嵌套泛型:泛型里面再套泛型

其实很简单,就是「类型参数本身也是一个泛型类型」。

// 一层:数组里放字符串
Array<string>

// 两层:Promise 里放数组,数组里放字符串
Promise<Array<string>>

// 换个写法更直观
type StringArray = Array<string>
type StringArrayPromise = Promise<StringArray>

你可以这么想:

  • 第一层:Array<T>
  • 第二层:Promise<第一层>

五、回到文章最开始的例子:VxeGridProps<RowVO>

先看定义的行数据类型:

interface RowVO {  
  id: number  
  name: string  
  role: string  
  sex: string  
  age: number  
  address: string
}

然后:

const gridOptions = reactive<VxeGridProps<RowVO>>({...})

拆开理解:

  • VxeGridProps<D = any> 是 vxe-table 提供的泛型接口
  • 你写的是 VxeGridProps<RowVO>
  • 这一刻,D 就被替换成了 RowVO

也就是在这一整次使用里,可以把它脑补成:

// 伪代码,仅用于理解
interface VxeGridProps_RowVO extends VxeTableProps<RowVO> {  
  columns?: VxeGridPropTypes.Columns<RowVO>  
  proxyConfig?: VxeGridPropTypes.ProxyConfig<RowVO>  
  // ...
}

可能很多同学看到这里会感到些疑惑,怎么一会儿T一会儿D的。其实不管是T还是D都是类型变量的自定义名称,叫什么都无所谓,语法上没有任何固定含义,就像你写 JS 时给变量起名num/name/age一样,只是前端社区形成了「约定俗成的命名习惯」,用不同字母对应不同语义,让代码更易读。

字母 全称 含义/使用场景 例子
T Type 通用类型(最常用,无特殊语义时都用 T) first<T>(arr: T[])
D Default/Date 通常指 “默认类型” 或 “日期类型”(小众) 泛型接口里的默认类型:interface Config<D = string>
K KeyKey 表示对象的「键」类型 getKey<K extends string>(obj: { [k: K]: any }, key: K)
V Value 表示对象的「值」类型 Map<K, V>(TS 内置的 Map 泛型)
E Element 表示数组 / 集合的「元素」类型 Array<E>(TS 内置的数组泛型)
P Parameter 表示函数的「参数」类型 function wrap<P>(fn: (arg: P) => void, arg: P)

六、类型参数是怎么一层一层 “传下去” 的?

到这一步为了更好的理解泛型,我将带着同学们追溯源码。一起来追踪一下源码看看吧。

1. 第一层:VxeGridProps<D>

源码里(简化):

export interface VxeGridProps<D = any> extends VxeTableProps<D> {  
  columns?: VxeGridPropTypes.Columns<D>  
  proxyConfig?: VxeGridPropTypes.ProxyConfig<D>  
  // ...
}

当你用 VxeGridProps<RowVO>

  • extends VxeTableProps<D> → 变成 extends VxeTableProps<RowVO>
  • columns?: Columns<D> → 变成 columns?: Columns<RowVO>
  • proxyConfig?: ProxyConfig<D> → 变成 proxyConfig?: ProxyConfig<RowVO>

记忆:哪里写了 <D>,就会被替换成 <RowVO>

2. 第二层:Columns<D> = Column<D>[]

export namespace VxeGridPropTypes {  
  export type Column<D = any> = VxeTableDefines.ColumnOptions<D>  
  export type Columns<D = any> = Column<D>[]
}

当你用的是 Columns<RowVO> 时:

  • Columns<D>Columns<RowVO>
  • = Column<D>[] 这一行里的 D 同样被替换成 RowVO,变成:
  • Columns<RowVO> = Column<RowVO>[]

接着:

  • Column<D> = VxeTableDefines.ColumnOptions<D>
  • 也会变成:Column<RowVO> = VxeTableDefines.ColumnOptions<RowVO>

所以:

columns 的每一项类型就是 ColumnOptions<RowVO>

七、第三层:ColumnOptions<D>D 真正用在哪里?

export interface ColumnOptions<D = any> extends VxeColumnProps<D> {  
  children?: ColumnOptions<D>[]  
  slots?: VxeColumnPropTypes.Slots<D>
}

继续替换:

  • ColumnOptions<D>ColumnOptions<RowVO>
  • extends VxeColumnProps<D> → 变成 extends VxeColumnProps<RowVO>
  • children?: ColumnOptions<D>[] → 变成 children?: ColumnOptions<RowVO>[]
  • slots?: Slots<D> → 变成 slots?: Slots<RowVO>

关键点:ColumnOptions<RowVO> 本身定义了「列配置」的结构它继承的 VxeColumnProps<RowVO> + Slots<RowVO> 等地方,会在「需要行数据的回调」里用到 RowVO,比如:

formatter(params: { row: RowVO; ... })
className(params: { row: RowVO; ... })

八、我在学习时候的疑惑?

我当时并不理解TypeScript 做的是统一替换

// 把 D 换成 RowVO:
type Columns<RowVO> = Column<RowVO>[]

// 再把 Column 展开:
type Column<RowVO> = VxeTableDefines.ColumnOptions<RowVO>

// 合起来就是:
type Columns<RowVO> = VxeTableDefines.ColumnOptions<RowVO>[]

就拿文章示例的代码来看,TypeScript 会把函数体中所有的<T>都 替换成你制定的类型。

不理解的代码:

export type Column<D = any> = VxeTableDefines.ColumnOptions<D>
export type Columns<D = any> = Column<D>[]

我当特别不能理解 Column<D>[]<D>是怎么变成<RowVO>的。直到我明白了TypeScript会做统一替换,根本不是按数据传参的逻辑去做的。

九、把整个链路串起来(从外到内)

你写了:

reactive<VxeGridProps<RowVO>>({...})

于是:

VxeGridProps<D> → VxeGridProps<RowVO>
extends VxeTableProps<D> → extends VxeTableProps<RowVO>
columns?: Columns<D> → columns?: Columns<RowVO>

然后:

Columns<D> = Column<D>[] → Columns<RowVO> = Column<RowVO>[]
Column<D> = ColumnOptions<D> → Column<RowVO> = ColumnOptions<RowVO>

再往下:

ColumnOptions<D> extends VxeColumnProps<D> → ColumnOptions<RowVO> extends VxeColumnProps<RowVO>

最终效果:

  • data 的类型是:RowVO[]
  • 所有回调里涉及「行数据」的地方,类型参数是 RowVO

十、总结这次案例

  • RowVO:描述 “一行数据长什么样”
  • VxeGridProps<RowVO>:告诉表格「我的每一行数据都是 RowVO
  • 泛型参数 <RowVO> 会一层层往下传,凡是类型里写了 <D> 的地方,就会变成 <RowVO>

你现在已经不是 “不懂泛型的小白” 了,你已经能:

  • 看懂「类型参数是怎么一层一层传下去的」
  • 顺着 VxeGridProps<RowVO> → Columns<RowVO> → ColumnOptions<RowVO> 这一整条链路往下追

这就已经是非常扎实的泛型理解了。

总结

  1. 泛型的核心是「给类型加参数」,使用时再指定具体类型,如 Array<string>VxeGridProps<RowVO>
  2. 嵌套泛型的本质是「类型参数本身也是泛型」,参数会逐层传递替换(DRowVO);

以上便是对泛型的分享,欢迎大家指正讨论,与大家共勉。

你不知道的 v-on

作者 SuperEugene
2026年2月6日 14:18

v-onVue事件绑定指令,近期在使用Vxe Table组件库的时候看见了一个就职公司项目场景不常用的写法,在此分享给同样不常用或不知道的同学们。

//此处复制的是vxetbale组件库的示例代码
<vxe-grid v-bind="gridOptions" v-on="gridEvents"></vxe-grid>

const gridEvents: VxeGridListeners = { 
    pageChange ({ pageSize, currentPage }) { 
        pagerVO.currentPage = currentPage
        pagerVO.pageSize = pageSize
        loadList()
    } 
}

v-on绝大部分人只知道是vue提供的事件绑定api,通常用法:v-on:click="getInfo" 或者简写 @click="handleClick"。在上述案例代码中v-on后面直接就是="gridEvents"这并不是错误写法, 而是v-on对象式事件绑定写法。和常用的 @click="handleClick" 属于同一套事件绑定机制,仅写法形式不同。

两种写法对比

1. 单个事件(常规熟悉写法)

@v-on: 的语法糖,两种写法完全等价:

<button @click="handleClick">点击</button>
<!-- 等价于 -->
<button v-on:click="handleClick">点击</button>

2. 对象式绑定(v-on="对象" 用法)

直接通过 v-on 绑定一个事件对象,适用于多个事件绑定的场景:

<vxe-grid v-on="gridEvents"></vxe-grid>

其中 gridEvents 是一个键值对对象

  • 键:事件名(如 pageChangecellClick
  • 值:该事件对应的处理函数Vue 会自动遍历这个对象,将每个键值对解析为「v-on:事件名=处理函数」的形式完成绑定。

在代码中的实际含义

vxe-table的分页事件为例,实际定义的事件对象如下(包含TypeScript类型约束):

const gridEvents: VxeGridListeners = {
  pageChange({ pageSize, currentPage }) {
    pagerVO.currentPage = currentPage
    pagerVO.pageSize = pageSize
    loadList()
  },
}

此时 v-on="gridEvents" 完全等价于单个事件绑定的写法

<vxe-grid v-on:pageChange="gridEvents.pageChange"></vxe-grid>

如果 gridEvents 中包含多个事件Vue会自动完成所有事件的批量绑定,例如:

// 包含多个事件的处理对象
const gridEvents = {
  pageChange: (e) => { ... },
  editClosed: (e) => { ... },
  cellClick: (e) => { ... },
}

等价于手动为每个事件单独绑定:

<vxe-grid
  v-on:pageChange="gridEvents.pageChange"
  v-on:editClosed="gridEvents.editClosed"
  v-on:cellClick="gridEvents.cellClick"
></vxe-grid>

为什么使用对象式事件绑定写法?

  1. 事件多时更简洁:无需在模板中重复书写大量 v-on:xxx="xxx",仅需一个 v-on="对象" 即可完成批量绑定,简化模板代码;
  2. 便于维护:所有事件的处理函数都集中在一个对象中,事件名和对应逻辑一一对应,后续新增 / 修改 / 删除事件时,只需操作该对象,无需改动模板;
  3. 适配组件库场景vxe-tableElement Plus这类 UI 组件库的复杂组件(如表格、树形控件)通常提供大量事件,使用对象统一配置事件,代码结构会更清晰。

以上便是对v-on的分享,欢迎大家指正讨论,与大家共勉。

Promise对象、同步和异步代码、回调地狱的讲解

作者 SuperEugene
2026年2月5日 10:38

简单粗暴一点的说 Promise 就是一个为了解决异步代码的东西,它可以让代码按照你想要的顺序去执行。

我们先来说说什么是同步代码,什么是异步代码。

  • 同步代码就是按顺序执行,如:12345 按顺序往下走。只有前一条代码执行完毕之后才会去执行后一条代码。
  • 异步代码就是可以不按照顺序执行的代码,如:213465,例如:网络请求、定时器、文件的读写等均是异步的。我们直接上代码直观的去感受一下
setTimeout(() => {
  
}, 1000)

↑这是一个定时器,这是一个可以将包裹在方法体内的代码延迟 1000 毫秒以后再执行的定时器。

console.log('任务1', moment(new Date()).format('HH:mm:ss'))
setTimeout(() => {
  console.log('任务2', moment(new Date()).format('HH:mm:ss'))
}, 1000)

//moment (new Date ()).format ('HH:mm:ss') 如果你是初学者不明白 moment () 是什么,
//直接复制代码发现报错,没关系。并不影响你继续学习,这条代码的功能就是输出博主当前执行代码的时间,
//你可以删掉这条代码,只输出 ' 任务1、任务2' 即可。感兴趣的同学可以自行搜索 moment 的使用方法学习

image.png

可以看到任务 2 比任务 1 的打印时间晚了一秒

前面我们说到定时器是一个异步的代码,我们来验证一下。

setTimeout(() => {
  console.log('任务2', moment(new Date()).format('HH:mm:ss'))
}, 1000)
console.log('任务1', moment(new Date()).format('HH:mm:ss'))

按照代码自上而下的执行顺序,在控制台中应该是一秒钟以后先输出的任务 2 再输出的任务 1

image.png

从控制台的输出得知并不是这样的。代码跳过了任务 2 先输出的任务 1,然后一秒钟之后才输出的任务 2,证实定时器确实是一个异步的代码,它并没有按照顺序执行。

异步代码的好处就在于不会造成代码的堵塞

  • 例如:现在我们是交通参与者的身份开着车行驶在路上,突然前方出现了交通事故,此时边上有辅道可以绕过去,难道我们就要等到这起交通事故处理完成之后才能通行吗?当然是没有这个必要的,我们可以先通过边上的辅道绕过去,无需等到交通事故处理完毕之后再通行,如果等到交通事故处理之后再通行那就会造成交通瘫痪大堵车的结局。

还有的时候异步任务也需要同步的去执行。还是以这个交通事故举例子,例如:我们是交警,我们接到了调度中心发来的任务请求,告知我们某路段发生交通事故需要我们前往现场进行处理。在接警没多久之后调度中心又发来请求,告知我们现在警力紧张,在我们的不远处还有一起交通事故希望我们处理完手里的这个事故之后立马前往下一个路段处理第二起交通事故。那此时任务二就要等待任务一执行完毕之后才能执行。其他的交通参与者当然不需要等待交警的任务执行完毕之后才能通过,可以自行从边上绕过去。上代码,我们把这个小故事以代码的形式呈现出来

console.log('社会车辆1')
console.log('社会车辆2')
setTimeout(() => {
  console.log('交警处理事故1', moment(new Date()).format('HH:mm:ss'))
  setTimeout(() => {
    console.log('交警处理事故2', moment(new Date()).format('HH:mm:ss'))
  }, 1000)
}, 1000)
console.log('社会车辆3')
console.log('社会车辆4')

image.png

通过控制台的输出我们可以看到社会车辆 1、2、3、4 先行通过了,交警处理事故 1 之后一秒钟事故 2 才执行处理。

但其实这种写法并不美观,如果有很多事故需要处理呢,那是不是就要嵌套很多层呢。

console.log('社会车辆1')
console.log('社会车辆2')
setTimeout(() => {
  console.log('交警处理事故1', moment(new Date()).format('HH:mm:ss'))
  setTimeout(() => {
    console.log('交警处理事故2', moment(new Date()).format('HH:mm:ss'))
    setTimeout(() => {
      console.log('交警处理事故3', moment(new Date()).format('HH:mm:ss'))
      setTimeout(() => {
        console.log('交警处理事故4', moment(new Date()).format('HH:mm:ss'))
        setTimeout(() => {
          console.log('交警处理事故5', moment(new Date()).format('HH:mm:ss'))
          setTimeout(() => {
            console.log('交警处理事故6', moment(new Date()).format('HH:mm:ss'))
          }, 1000)
        }, 1000)
      }, 1000)
    }, 1000)
  }, 1000)
}, 1000)
console.log('社会车辆3')
console.log('社会车辆4')

这样的写法会导致代码的可读性非常的差,你可能说我现在一眼就能看出哪儿条输出语句对应着哪儿个代码块。没错,就以现在的这个代码是可以做到。但如果当每一个代码块中的代码复杂起来多起来了以后呢。你还能一眼就看出来吗?当然是很费力的嘛。这就是我们常说的回调地狱

Promise 的出现就可以很好的规避掉这个问题,让我们来进入 Promise 的学习吧。

new Promise()

Promise 就长这样↑

Promise 接收一个函数,在函数中接收两个参数:resolvereject

new Promise((resolve, reject) => {})

resolvereject 是由 Promise 对象传入的。resolve 直译过来的意思是:决定、解决,是在程序执行成功的时候调用的,而 reject 直译过来的意思是:拒绝,是在程序调用失败的时候调用的,故此你可以将

  • resolve 理解为成功
  • reject 理解为失败

例如:现在我们正在登陆某一个网站,当你的用户名或密码输入错误之时,身份验证不通过就会返回错误信息,此时就可以调用 reject。反之用户名和密码都正确身份认证通过了,此时便可调用 resolve

咱们先打印一下这个 Promise 对象,看看长什么样

const promiseObj = new Promise((resolve, reject) => {})
 
console.log(promiseObj)

image.png

我们看到在控制台打印出来的数据中有一个pending(等待的意思),promise 对象中有一个状态的概念。你看现在的状态是一个默认状态 pending,你可以理解为这个 promise 里的 resolvereject 一个都没有被触发。

我们来触发 resolve 参数看看状态

const promiseObj = new Promise((resolve, reject) => {
  resolve()
})
console.log(promiseObj)

image.png

观察发现 PromiseStatepending 等待变成了fulfilledfulfilled 就表示完成或者成功

我们再来触发一下 reject 参数看看状态

const promiseObj = new Promise((resolve, reject) => {
  reject()
})
console.log(promiseObj)

image.png

观察发现 PromiseState 的状态变成了rejected 表示拒绝或者失败了。

promise 对象给我们提供了三个方法,咱们自己可以如图一样通过对象点的形式来看看是不是出来 catchfinallythen 这三个方法。

简单粗暴的讲一下这三个方法。还是以登陆某一网站为例:

  • 假设用户名和密码都填写正确则会走到 then() 方法里,你可以理解为then() 方法是成功时候调用的。假设用户名和密码有一个或者全都填写错误时服务器便会返回错误信息,此时代码就会走到 catch() 方法里,你可以理解为catch()方法是在错误时候调用的。那么finally() 方法则是无论服务器返回的是 true 还是 false 都会走到 finally 里面,你可以理解为不论对错都会调用 finally。用户名密码填写正确,走完 then() 还会走 finally。用户名密码填写错误,走完 catch() 也还会走 finally()

让我们来一一验证一下:

const promiseObj = new Promise((resolve, reject) => {
  resolve('身份认证通过!')
})
promiseObj
  .then((data) => { //data就是resolve传过来的内容 名称没有规定自定义即可
    console.log(data)
  })
  .catch((error) => {
    console.log(error)
  })
  .finally(() => {
    console.log('我是finally')
  })

image.png

通过代码可以看得出这三个方法接收的都是一个回调函数。通过控制台的输出可以得知当我们调用了 resolve 时代码走到了then() 方法之后又走到了 finally() 方法,验证通过。

const promiseObj = new Promise((resolve, reject) => {
  reject('身份认证失败!')
})
promiseObj
  .then((data) => { 
    console.log(data)
  })
  .catch((error) => { //error就是reject传过来的内容  名称没有规定自定义即可
    console.log(error)
  })
  .finally(() => {
    console.log('我是finally')
  })

image.png

通过控制台的输出可以得知当我们调用了 reject 时代码走到了 catch() 方法之后又走到了 finally() 方法,验证通过。

到这儿为止你已经对 Promise 的使用有了一个简单的认识。我们现在用 Promise 来解决一下前面举例说明的小故事。看看你是否会觉得优雅很多,代码更可读。

虽然 promise 本身是同步的,但是 promise.then().catch (). finally () 这些方法中的回调是异步的,所以在这里我们就不用定时器了。

console.log('社会车辆1')
console.log('社会车辆2')
const promiseObj = new Promise((resolve, reject) => {
  resolve('交警处理事故1')
})
promiseObj
  .then((data) => {
    console.log(data)
    return new Promise((resolve, reject) => {
      resolve('交警处理事故2')
    })
  })
  .then((data) => {
    console.log(data)
    return new Promise((resolve, reject) => {
      resolve('交警处理事故3')
    })
  })
  .then((data) => {
    console.log(data)
    return new Promise((resolve, reject) => {
      resolve('交警处理事故4')
    })
  })
  .then((data) => {
    console.log(data)
  })
  .catch()
console.log('社会车辆3')
console.log('社会车辆4')

image.png

有的人会说 这也嵌套了呀。没错这确实是也嵌套了,但是只有一层,无论多少次都只有一层

promiseObj.then().then().then().then().then().then().catch()

↑这样看是不是就更直观了呢。

还有另一种写法可以给每一个.then() 都设置一个单独的 catch() 直接上代码

image.png 从这张图↑可以看到.then() 里面其实可以传递两个参数的,第一个表示成功时候的调用,第二个表示失败时候的调用

promiseObj
  .then((data) => {}, (error) => {})
  .then((data) => {}, (error) => {})
  .then((data) => {}, (error) => {})
  .then((data) => {}, (error) => {})
  .then((data) => {}, (error) => {})

↑这便是大致的一个结构

const promiseObj = new Promise((resolve, reject) => {
  resolve('交警处理事故1')
})
console.log('社会车辆1')
console.log('社会车辆2')
promiseObj
  .then(
    (data) => {
      console.log(data)
      return new Promise((resolve, reject) => {
        resolve('交警处理事故2')
      })
    },
    (error) => {
      console.log(error)
    }
  )
  .then(
    (data) => {
      console.log(data)
      let errorBtn = false
      return new Promise((resolve, reject) => {
        if (errorBtn) {
          resolve('交警处理事故3')
        } else {
          reject('交通事故处理时候遇到特殊状况')
        }
      })
    },
    (error) => {
      console.log(error)
    }
  )
  .then(
    (data) => {
      console.log(data)
      return new Promise((resolve, reject) => {
        resolve('交警处理事故4')
      })
    },
    (error) => {
      console.log(error)
    }
  )
  .then()
 
console.log('社会车辆3')
console.log('社会车辆4')

我们在交警事故处理 3 处加了一个 reject 的调用

image.png

通过控制台的输出可以得知咱们的 reject 起作用了。当交警处理事故 3 处报错时代码就终止了,后面的程序就不执行了。

以上就是本章的知识点讲解分享,感谢大家的耐心观看学习,欢迎大家在评论区讨论纠错,与大家共勉。

❌
❌