组合式函数 、 Hooks(Vue2 mixin 、 Vue3 composables)的实战封装

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零 ,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱"面向搜索引擎写代码"的尴尬。

一、先搞清楚一个问题:我们到底在解决什么?

写 Vue 项目久了,你一定遇到过这些场景:

  • 好几个页面都有表格 + 分页 + 搜索 ,每个页面都写一遍 currentPagepageSizetotalloadingtableData......
  • 好几个弹窗表单都要做打开/关闭、表单校验、提交、重置,每次都 copy 一坨。
  • 几乎所有接口调用都要处理 loading、error、retry,到处重复 try-catch。

核心问题就一个字:重复。

但重复本身不是最可怕的,可怕的是:

  1. 改一个逻辑要改 N 个地方(漏改一个就是 bug)
  2. 逻辑散落在 datamethodswatchmounted 各处,跟读小说一样要来回翻页
  3. 新人接手看不懂,老人自己过半年也看不懂

所以我们需要一种方式,把可复用的有状态逻辑抽出来,做到:写一次、用 N 次、改一处、全生效。

在 Vue2 时代,官方给的方案叫 Mixin

在 Vue3 时代,官方推荐的方案叫 Composables(组合式函数)

二、Vue2 Mixin:能用,但有"三宗罪"

2.1 Mixin 是什么?

简单说:Mixin 就是一个普通的 Vue 组件选项对象 ,可以包含 datamethodscomputedwatch、生命周期等任何组件选项。当你把它"混入"到一个组件里时,这些选项会和组件自身的选项合并

2.2 一个典型的例子:分页逻辑复用

假设我们有很多列表页,都需要分页,先看不用 Mixin 时你要在每个页面写的东西:

javascript 复制代码
// PageA.vue
export default {
  data() {
    return {
      tableData: [],
      currentPage: 1,
      pageSize: 10,
      total: 0,
      loading: false
    }
  },
  methods: {
    async fetchList() {
      this.loading = true
      try {
        const res = await api.getListA({
          page: this.currentPage,
          size: this.pageSize
        })
        this.tableData = res.data.list
        this.total = res.data.total
      } finally {
        this.loading = false
      }
    },
    handlePageChange(page) {
      this.currentPage = page
      this.fetchList()
    },
    handleSizeChange(size) {
      this.pageSize = size
      this.currentPage = 1
      this.fetchList()
    }
  },
  created() {
    this.fetchList()
  }
}

PageB、PageC...... 全是这套。唯一不同的就是 api.getListA 换成 api.getListB

用 Mixin 抽出来:

javascript 复制代码
// mixins/pagination.js
export default {
  data() {
    return {
      tableData: [],
      currentPage: 1,
      pageSize: 10,
      total: 0,
      loading: false
    }
  },
  methods: {
    // 子组件必须自己实现这个方法,返回接口调用的 Promise
    fetchList() {
      throw new Error('组件必须实现 fetchList 方法')
    },
    handlePageChange(page) {
      this.currentPage = page
      this.fetchList()
    },
    handleSizeChange(size) {
      this.pageSize = size
      this.currentPage = 1
      this.fetchList()
    }
  },
  created() {
    this.fetchList()
  }
}
javascript 复制代码
// PageA.vue
import paginationMixin from '@/mixins/pagination'

export default {
  mixins: [paginationMixin],
  methods: {
    async fetchList() {
      this.loading = true
      try {
        const res = await api.getListA({
          page: this.currentPage,
          size: this.pageSize
        })
        this.tableData = res.data.list
        this.total = res.data.total
      } finally {
        this.loading = false
      }
    }
  }
}

看起来不错对吧?确实能复用了。但用久了你就会遇到 Mixin 的三宗罪

2.3 Mixin 的三宗罪

第一宗:来源不明("这变量哪来的?")

html 复制代码
<template>
  <div>
    <!-- currentPage 是组件自己的?还是 mixin 带来的?还是哪个 mixin? -->
    <span>第 {{ currentPage }} 页,共 {{ total }} 条</span>
    <!-- userName 呢?是另一个 mixin 的? -->
    <span>{{ userName }}</span>
  </div>
</template>

<script>
import paginationMixin from '@/mixins/pagination'
import userMixin from '@/mixins/user'

export default {
  mixins: [paginationMixin, userMixin],
  // 你在 data、methods 里完全看不出 currentPage 和 userName 从哪来
  // IDE 也没法跳转到定义,只能靠人肉去翻 mixin 文件
}
</script>

当你引了 2-3 个 mixin,模板里用的变量来源就成了悬案。新人接手的时候更是一脸懵。

第二宗:命名冲突("我的变量被吞了")

javascript 复制代码
// mixins/pagination.js
export default {
  data() {
    return { loading: false }  // 表格加载状态
  }
}

// mixins/auth.js
export default {
  data() {
    return { loading: false }  // 权限校验加载状态
  }
}

// SomePage.vue
export default {
  mixins: [paginationMixin, authMixin],
  data() {
    return { loading: false }  // 提交按钮加载状态
  }
  // 三个 loading 打架了!
  // Vue2 的合并策略:组件自身的 data 优先,后面的 mixin 覆盖前面的
  // 最终只有一个 loading,另外两个的逻辑全乱了
  // 而且------不会报任何错误或警告!
}

这是 Mixin 最要命的问题。项目小的时候还好,项目大了、mixin 多了,命名冲突几乎是必然的,而且是静默的------不报错、不警告,直接覆盖,等你发现 bug 的时候已经不知道要查到什么时候了。

第三宗:不灵活("我想用两份分页怎么办?")

javascript 复制代码
// 如果一个页面有两个独立的表格,各自有各自的分页呢?
// Mixin 混进来就是一份,没法实例化两份
export default {
  mixins: [paginationMixin], // 只有一份 currentPage、total......
  // 第二个表格的分页数据怎么办?再写一遍?那还要 mixin 干嘛?
}

Mixin 本质上是对象合并 ,不是函数调用,所以你没法像调函数一样"new 两份出来"。

2.4 小结

能力 Mixin
能复用逻辑吗? ✅ 能
来源清晰吗? ❌ 不清晰,变量来源成谜
能避免冲突吗? ❌ 不能,静默覆盖
能多实例吗? ❌ 不能,混进来就是一份
类型推导友好吗? ❌ TypeScript 几乎没法推导

三、Vue3 Composables:函数的胜利

3.1 核心思想:一切皆函数

Vue3 的 Composition API 给了我们一个极其简单但极其强大的模式:

把有状态的逻辑写成一个普通函数,函数里用 ref/reactive 创建响应式状态,最后 return 出去。

就这么简单。没有什么新 API、新概念,就是函数

javascript 复制代码
// composables/useCounter.js ------ 最简单的例子
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)

  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  function reset() {
    count.value = initialValue
  }

  return { count, increment, decrement, reset }
}

使用的时候:

js 复制代码
<script setup>
import { useCounter } from '@/composables/useCounter'

// 调一次就是一份独立的状态
const { count: countA, increment: incrementA } = useCounter(0)
const { count: countB, increment: incrementB } = useCounter(100)

// countA 和 countB 完全独立,互不影响
</script>

<template>
  <button @click="incrementA">A: {{ countA }}</button>
  <button @click="incrementB">B: {{ countB }}</button>
</template>

3.2 对比 Mixin,三宗罪全解了

问题 Mixin Composable
来源不明 ❌ 变量凭空出现 ✅ 显式 import + 解构,清清楚楚
命名冲突 ❌ 静默覆盖 ✅ 解构时可以重命名 { count: countA }
不能多实例 ❌ 只有一份 ✅ 调多次就是多份独立状态
TS 支持 ❌ 几乎不可用 ✅ 完美推导,悬停即可看类型

3.3 命名约定

Vue 社区有一个约定俗成的规范(也是 Vue 官方文档推荐的):

  • 文件名:useXxx.jsuseXxx.ts
  • 函数名:useXxx,以 use 开头
  • 放置位置:项目中统一放在 composables/hooks/ 目录下
bash 复制代码
src/
├── composables/         # 或叫 hooks/
│   ├── useRequest.js    # 通用请求封装
│   ├── useTable.js      # 表格逻辑封装
│   ├── useForm.js       # 表单逻辑封装
│   ├── useLoading.js    # 加载状态封装
│   └── index.js         # 统一导出
├── views/
├── components/
└── ...

四、实战封装一:useRequest ------ 一切的基础

4.1 为什么先封装它?

因为 useTable 要请求数据,useForm 要提交数据,几乎所有业务逻辑都绕不开接口调用。把请求逻辑封装好了,后面的封装都会轻松很多。

4.2 先想清楚:一个接口调用需要管理哪些状态?

别急着写代码,先列需求:

markdown 复制代码
1. data    ------ 接口返回的数据
2. loading ------ 是否正在请求中(控制按钮 loading、骨架屏等)
3. error   ------ 请求失败的错误信息
4. 手动触发 / 自动触发 ------ 有的接口进页面就要调,有的要点按钮才调
5. 防重复 ------ 快速点击不要发 N 个请求

4.3 最小可用版本(V1)

先写一个最简单的版本,能跑起来:

javascript 复制代码
// composables/useRequest.js  V1 - 最小可用版
import { ref } from 'vue'

/**
 * 通用请求封装
 * @param {Function} apiFn - 接口函数,需返回 Promise
 * @param {Object} options - 配置项
 * @param {boolean} options.immediate - 是否立即执行,默认 false
 * @param {any} options.initialData - data 的初始值,默认 null
 */
export function useRequest(apiFn, options = {}) {
  const {
    immediate = false,
    initialData = null
  } = options

  const data = ref(initialData)
  const loading = ref(false)
  const error = ref(null)

  async function run(...args) {
    loading.value = true
    error.value = null
    try {
      const res = await apiFn(...args)
      data.value = res
      return res
    } catch (err) {
      error.value = err
      throw err  // 继续抛出,让调用方可以 catch
    } finally {
      loading.value = false
    }
  }

  // 如果配置了 immediate,创建时就调一次
  if (immediate) {
    run()
  }

  return { data, loading, error, run }
}

使用示例:

js 复制代码
<script setup>
import { useRequest } from '@/composables/useRequest'
import { getUserInfo } from '@/api/user'

// 场景1:进页面自动请求
const { data: userInfo, loading } = useRequest(
  () => getUserInfo(userId),
  { immediate: true }
)

// 场景2:点按钮手动触发
const { loading: submitLoading, run: submitForm } = useRequest(
  (formData) => saveUser(formData)
)

function handleSubmit() {
  submitForm({ name: 'Tom', age: 18 })
}
</script>

<template>
  <div v-loading="loading">{{ userInfo?.name }}</div>
  <button :loading="submitLoading" @click="handleSubmit">提交</button>
</template>

核心理解: useRequest 接收一个"接口函数",帮你管理 loadingdataerror 三个状态,并返回一个 run 方法让你手动触发。就这么简单。

4.4 进阶版本(V2):加上防重复和竞态处理

V1 有两个隐患:

  1. 防重复:用户快速点击提交按钮,会同时发出多个请求
  2. 竞态:用户快速切换筛选条件,先发的请求后返回,会覆盖掉后发请求的正确数据
javascript 复制代码
// composables/useRequest.js  V2 - 加防重和竞态处理
import { ref } from 'vue'

export function useRequest(apiFn, options = {}) {
  const {
    immediate = false,
    initialData = null,
    // 新增:是否在 loading 中时阻止重复调用(适用于提交类接口)
    preventRepeat = false
  } = options

  const data = ref(initialData)
  const loading = ref(false)
  const error = ref(null)

  // 用一个自增 id 来处理竞态
  // 每次调用 run 时 id + 1,回调时检查 id 是否是最新的
  // 如果不是最新的,说明在这次请求还没返回时,又发了新的请求
  // 那这次的结果就应该被丢弃
  let requestId = 0

  async function run(...args) {
    // 防重复:如果正在请求中,直接返回
    if (preventRepeat && loading.value) {
      return
    }

    const currentId = ++requestId
    loading.value = true
    error.value = null

    try {
      const res = await apiFn(...args)
      // 竞态处理:只有最新一次请求的结果才会被赋值
      if (currentId === requestId) {
        data.value = res
      }
      return res
    } catch (err) {
      if (currentId === requestId) {
        error.value = err
      }
      throw err
    } finally {
      if (currentId === requestId) {
        loading.value = false
      }
    }
  }

  if (immediate) {
    run()
  }

  return { data, loading, error, run }
}

竞态问题的具体场景,举个例子:

css 复制代码
用户操作:选"北京" → 选"上海"(很快切换)

请求时序:
  请求A(北京)发出 ──────────────────> 请求A返回(北京的数据)
  请求B(上海)发出 ────> 请求B返回(上海的数据)

如果不处理竞态:
  页面先显示上海数据(正确),然后被北京数据覆盖(错误!)

处理竞态后:
  请求A返回时发现 currentId !== requestId,丢弃结果
  页面始终显示上海数据(正确)

这个问题在实际开发中出现频率很高,但很多人意识不到。面试也经常问。

4.5 踩坑提醒

坑 1:忘了 finally 重置 loading

javascript 复制代码
// ❌ 错误写法
async function run(...args) {
  loading.value = true
  try {
    const res = await apiFn(...args)
    data.value = res
    loading.value = false  // 如果上面报错了,这行不会执行!
  } catch (err) {
    error.value = err
    // 忘了在这里也重置 loading → 页面永远转圈
  }
}

// ✅ 正确写法:用 finally
async function run(...args) {
  loading.value = true
  try {
    const res = await apiFn(...args)
    data.value = res
  } catch (err) {
    error.value = err
  } finally {
    loading.value = false  // 不管成功失败都会执行
  }
}

坑 2:immediate: true 的时候传参

javascript 复制代码
// ❌ 这样拿不到参数
const { data } = useRequest(
  (id) => getDetail(id),
  { immediate: true }
)
// immediate 调用 run() 时没传 id,接口会报错

// ✅ 用闭包把参数包进去
const { data } = useRequest(
  () => getDetail(route.params.id),
  { immediate: true }
)

五、实战封装二:useTable ------ 中后台的半壁江山

5.1 分析需求

中后台项目里,表格页面占了至少一半。一个标准的表格页面需要:

markdown 复制代码
1. 表格数据(tableData)
2. 分页状态(currentPage、pageSize、total)
3. 加载状态(loading)
4. 搜索/筛选参数(searchParams)
5. 查询方法(搜索、重置、翻页、切换每页条数)
6. 进页面自动加载第一页

5.2 完整实现

javascript 复制代码
// composables/useTable.js
import { ref, reactive, onMounted } from 'vue'

/**
 * 表格逻辑封装
 * @param {Function} apiFn - 列表接口函数
 *   接收参数格式:apiFn({ page, size, ...searchParams })
 *   返回格式约定:{ list: [], total: 0 }
 * @param {Object} options - 配置项
 */
export function useTable(apiFn, options = {}) {
  const {
    defaultPageSize = 10,
    immediate = true,
    // 让调用方可以自定义如何从接口返回值中提取 list 和 total
    // 因为每个项目的接口返回格式可能不同
    formatResult = (res) => ({
      list: res.data?.list ?? res.data?.records ?? [],
      total: res.data?.total ?? 0
    })
  } = options

  // ---- 状态定义 ----
  const tableData = ref([])
  const loading = ref(false)
  const pagination = reactive({
    currentPage: 1,
    pageSize: defaultPageSize,
    total: 0
  })

  // 搜索参数,用 reactive 方便直接 v-model 绑定表单
  const searchParams = reactive({})

  // ---- 核心方法 ----

  /** 加载数据 */
  async function fetchData() {
    loading.value = true
    try {
      const params = {
        page: pagination.currentPage,
        size: pagination.pageSize,
        ...searchParams
      }
      const res = await apiFn(params)
      const { list, total } = formatResult(res)
      tableData.value = list
      pagination.total = total
    } catch (err) {
      console.error('[useTable] fetchData error:', err)
      tableData.value = []
      pagination.total = 0
    } finally {
      loading.value = false
    }
  }

  /** 搜索(重置到第一页) */
  function search() {
    pagination.currentPage = 1
    fetchData()
  }

  /** 重置搜索条件并查询 */
  function reset() {
    // 清空 searchParams 的所有字段
    Object.keys(searchParams).forEach(key => {
      searchParams[key] = undefined
    })
    pagination.currentPage = 1
    fetchData()
  }

  /** 翻页 */
  function onPageChange(page) {
    pagination.currentPage = page
    fetchData()
  }

  /** 切换每页条数 */
  function onSizeChange(size) {
    pagination.pageSize = size
    pagination.currentPage = 1  // 切换条数要回到第一页
    fetchData()
  }

  /** 刷新当前页(不改变任何条件) */
  function refresh() {
    fetchData()
  }

  // ---- 初始化 ----
  if (immediate) {
    onMounted(() => {
      fetchData()
    })
  }

  // ---- 返回 ----
  return {
    tableData,
    loading,
    pagination,
    searchParams,
    search,
    reset,
    refresh,
    onPageChange,
    onSizeChange,
    fetchData
  }
}

5.3 使用示例(完整页面)

html 复制代码
<!-- views/UserList.vue -->
<template>
  <div class="page-container">
    <!-- 搜索区域 -->
    <el-form inline @submit.prevent="search">
      <el-form-item label="用户名">
        <el-input v-model="searchParams.username" placeholder="请输入用户名" clearable />
      </el-form-item>
      <el-form-item label="状态">
        <el-select v-model="searchParams.status" placeholder="请选择" clearable>
          <el-option label="启用" :value="1" />
          <el-option label="禁用" :value="0" />
        </el-select>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="search">查询</el-button>
        <el-button @click="reset">重置</el-button>
      </el-form-item>
    </el-form>

    <!-- 表格 -->
    <el-table :data="tableData" v-loading="loading" border>
      <el-table-column prop="username" label="用户名" />
      <el-table-column prop="email" label="邮箱" />
      <el-table-column prop="status" label="状态">
        <template #default="{ row }">
          <el-tag :type="row.status === 1 ? 'success' : 'danger'">
            {{ row.status === 1 ? '启用' : '禁用' }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="操作" width="200">
        <template #default="{ row }">
          <el-button size="small" @click="handleEdit(row)">编辑</el-button>
          <el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>

    <!-- 分页 -->
    <el-pagination
      v-model:current-page="pagination.currentPage"
      v-model:page-size="pagination.pageSize"
      :total="pagination.total"
      :page-sizes="[10, 20, 50, 100]"
      layout="total, sizes, prev, pager, next, jumper"
      @current-change="onPageChange"
      @size-change="onSizeChange"
      style="margin-top: 16px; justify-content: flex-end;"
    />
  </div>
</template>

<script setup>
import { getUserList } from '@/api/user'
import { useTable } from '@/composables/useTable'

const {
  tableData,
  loading,
  pagination,
  searchParams,
  search,
  reset,
  refresh,
  onPageChange,
  onSizeChange
} = useTable(getUserList)

// 页面自身的业务逻辑
function handleEdit(row) {
  // 打开编辑弹窗...
}

async function handleDelete(row) {
  await ElMessageBox.confirm('确认删除?')
  await deleteUser(row.id)
  ElMessage.success('删除成功')
  refresh()  // 删除后刷新当前页
}
</script>

对比一下:如果不用 useTable,这个页面的 <script> 部分至少要 80+ 行。现在核心逻辑只有 20 行左右,而且每个列表页都是同样的模式

5.4 踩坑提醒

坑 1:searchParamsreactive 还是 ref

javascript 复制代码
// 方案A:reactive(推荐)
const searchParams = reactive({})
// ✅ 优点:模板里直接 v-model="searchParams.xxx",不用 .value
// ✅ 优点:新增字段时直接 searchParams.newField = 'xxx' 就行
// ⚠️ 注意:不能整个替换 searchParams = {},要逐个清字段

// 方案B:ref
const searchParams = ref({})
// 模板里要写 searchParams.xxx(Vue 自动解 ref),看起来一样
// 但重置时可以直接 searchParams.value = {}
// 缺点:如果传给子组件,需要注意 .value 的问题

我推荐用 reactive,因为搜索参数一般不会整个替换,而是逐字段修改。

坑 2:onMounted 还是直接调用?

javascript 复制代码
// ❌ 直接调用
if (immediate) {
  fetchData()  // 此时组件可能还没挂载,某些情况下会有问题
}

// ✅ 放在 onMounted 里
if (immediate) {
  onMounted(() => {
    fetchData()
  })
}

useTable 一般在 setup 阶段调用,如果你在 fetchData 里有用到 DOM 相关的东西(比如获取表格容器高度来做自适应),直接调用就会出问题。养成好习惯,用 onMounted

坑 3:切换 pageSize 时忘了重置页码

javascript 复制代码
// ❌ 错误
function onSizeChange(size) {
  pagination.pageSize = size
  fetchData()
  // 比如当前在第 5 页,每页 10 条,共 45 条
  // 切换成每页 50 条后,第 5 页已经不存在了
  // 接口可能返回空数据甚至报错
}

// ✅ 正确
function onSizeChange(size) {
  pagination.pageSize = size
  pagination.currentPage = 1  // 一定要回到第一页!
  fetchData()
}

六、实战封装三:useForm ------ 弹窗表单的终结者

6.1 分析需求

中后台的另一个高频场景:弹窗表单(新增/编辑共用一个弹窗)。需要管理:

markdown 复制代码
1. 弹窗显隐(visible)
2. 弹窗标题(根据新增/编辑动态变化)
3. 表单数据(formData)
4. 表单校验(rules + validate)
5. 提交逻辑(loading + 调接口 + 关弹窗 + 刷新列表)
6. 重置逻辑(关弹窗时清空表单 + 清除校验状态)

6.2 完整实现

javascript 复制代码
// composables/useForm.js
import { ref, reactive, toRaw } from 'vue'

/**
 * 弹窗表单逻辑封装
 * @param {Object} options
 * @param {Function} options.getInitialData - 返回表单初始值的函数(必须是函数,避免引用污染)
 * @param {Function} options.submitApi - 提交接口函数,接收 formData 参数
 * @param {Function} options.onSuccess - 提交成功后的回调
 */
export function useForm(options = {}) {
  const {
    getInitialData = () => ({}),
    submitApi,
    onSuccess
  } = options

  // ---- 状态 ----
  const visible = ref(false)
  const isEdit = ref(false)
  const title = ref('')
  const formData = reactive(getInitialData())
  const formRef = ref(null)  // el-form 的 ref
  const submitLoading = ref(false)

  // ---- 方法 ----

  /** 打开弹窗 - 新增模式 */
  function openAdd() {
    isEdit.value = false
    title.value = '新增'
    resetFields()
    visible.value = true
  }

  /**
   * 打开弹窗 - 编辑模式
   * @param {Object} row - 当前行数据,用于回填表单
   */
  function openEdit(row) {
    isEdit.value = true
    title.value = '编辑'
    resetFields()
    // 回填数据:只填 formData 中存在的字段,避免多余字段
    Object.keys(getInitialData()).forEach(key => {
      if (row[key] !== undefined) {
        formData[key] = row[key]
      }
    })
    visible.value = true
  }

  /** 关闭弹窗 */
  function close() {
    visible.value = false
    // 延迟重置,等弹窗关闭动画结束后再清空,避免用户看到闪烁
    setTimeout(() => {
      resetFields()
    }, 300)
  }

  /** 重置表单字段到初始值 */
  function resetFields() {
    const initial = getInitialData()
    Object.keys(initial).forEach(key => {
      formData[key] = initial[key]
    })
    // 清除 el-form 的校验状态
    formRef.value?.clearValidate?.()
  }

  /** 提交表单 */
  async function submit() {
    if (!submitApi) {
      console.warn('[useForm] submitApi is not provided')
      return
    }
    // 先校验
    try {
      await formRef.value?.validate()
    } catch {
      return  // 校验不通过,直接返回
    }

    submitLoading.value = true
    try {
      // toRaw:把 reactive 对象转成普通对象再传给接口
      // 避免接口层不小心修改了响应式对象
      await submitApi(toRaw(formData))
      close()
      onSuccess?.()
    } catch (err) {
      console.error('[useForm] submit error:', err)
      // 提交失败不关弹窗,让用户可以修改后重试
    } finally {
      submitLoading.value = false
    }
  }

  return {
    visible,
    isEdit,
    title,
    formData,
    formRef,
    submitLoading,
    openAdd,
    openEdit,
    close,
    submit,
    resetFields
  }
}

6.3 使用示例(完整页面)

html 复制代码
<!-- views/UserList.vue(在前面 useTable 的基础上加入 useForm) -->
<template>
  <div class="page-container">
    <!-- 搜索区域(省略,同前面 useTable 示例) -->

    <!-- 新增按钮 -->
    <el-button type="primary" @click="openAdd" style="margin-bottom: 16px;">
      新增用户
    </el-button>

    <!-- 表格(省略,同前面 useTable 示例,编辑按钮绑定 openEdit) -->
    <el-table :data="tableData" v-loading="tableLoading" border>
      <!-- ...其他列... -->
      <el-table-column label="操作" width="200">
        <template #default="{ row }">
          <el-button size="small" @click="openEdit(row)">编辑</el-button>
        </template>
      </el-table-column>
    </el-table>

    <!-- 新增/编辑弹窗 -->
    <el-dialog v-model="visible" :title="title" width="500px" @close="close">
      <el-form
        ref="formRef"
        :model="formData"
        :rules="rules"
        label-width="80px"
      >
        <el-form-item label="用户名" prop="username">
          <el-input v-model="formData.username" placeholder="请输入用户名" />
        </el-form-item>
        <el-form-item label="邮箱" prop="email">
          <el-input v-model="formData.email" placeholder="请输入邮箱" />
        </el-form-item>
        <el-form-item label="状态" prop="status">
          <el-select v-model="formData.status" placeholder="请选择状态">
            <el-option label="启用" :value="1" />
            <el-option label="禁用" :value="0" />
          </el-select>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="close">取消</el-button>
        <el-button type="primary" :loading="submitLoading" @click="submit">
          确定
        </el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
import { useTable } from '@/composables/useTable'
import { useForm } from '@/composables/useForm'
import { getUserList, createUser, updateUser } from '@/api/user'

// ---- 表格逻辑 ----
const {
  tableData,
  loading: tableLoading,
  pagination,
  searchParams,
  search,
  reset,
  refresh,
  onPageChange,
  onSizeChange
} = useTable(getUserList)

// ---- 表单逻辑 ----
const {
  visible,
  isEdit,
  title,
  formData,
  formRef,
  submitLoading,
  openAdd,
  openEdit,
  close,
  submit
} = useForm({
  getInitialData: () => ({
    id: undefined,
    username: '',
    email: '',
    status: 1
  }),
  submitApi: (data) => {
    // 根据 isEdit 判断调新增还是编辑接口
    return isEdit.value ? updateUser(data) : createUser(data)
  },
  onSuccess: () => {
    ElMessage.success(isEdit.value ? '编辑成功' : '新增成功')
    refresh()  // 提交成功后刷新表格
  }
})

// 表单校验规则
const rules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
  ],
  status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
</script>

你看,整个页面的 <script> 部分非常清晰:

  1. useTable 管表格和分页
  2. useForm 管弹窗和表单
  3. 页面自身只需要定义校验规则和业务相关的 UI

逻辑分离、职责清晰、代码量大幅减少。

6.4 踩坑提醒

坑 1:getInitialData 为什么必须是函数?

javascript 复制代码
// ❌ 错误:直接传对象
const initialData = { username: '', email: '', status: 1 }
useForm({ getInitialData: initialData })

// 问题:每次 resetFields 重置时,拿到的都是同一个对象引用
// 如果 initialData 被修改过(比如编辑回填时),重置就不是真正的"初始值"了

// ✅ 正确:传一个函数,每次调用都返回一个全新的对象
useForm({
  getInitialData: () => ({ username: '', email: '', status: 1 })
})

这和 Vue2 里 data 必须是函数是同一个道理------避免引用污染。

坑 2:编辑回填时直接赋值整个对象

javascript 复制代码
// ❌ 错误
function openEdit(row) {
  Object.assign(formData, row)
  // 问题:row 里可能有 createdAt、updatedAt 等表单不需要的字段
  // 提交时这些多余字段会被传给接口,可能导致后端报错
}

// ✅ 正确:只回填 formData 中定义过的字段
function openEdit(row) {
  Object.keys(getInitialData()).forEach(key => {
    if (row[key] !== undefined) {
      formData[key] = row[key]
    }
  })
}

坑 3:弹窗关闭时的视觉闪烁

javascript 复制代码
// ❌ 立即重置
function close() {
  visible.value = false
  resetFields()  // 弹窗还在做关闭动画,用户会看到表单内容突然清空

// ✅ 延迟重置,等动画结束
function close() {
  visible.value = false
  setTimeout(() => {
    resetFields()
  }, 300)  // Element Plus 弹窗动画时长大约 300ms
}

七、封装的设计原则与规范

写了三个实战 composable 之后,我们总结一下通用的设计原则:

7.1 命名规范

javascript 复制代码
// ✅ 文件名和函数名保持一致
// composables/useRequest.js → export function useRequest()
// composables/useTable.js   → export function useTable()

// ✅ 返回值命名清晰
return {
  data,          // 名词:状态数据
  loading,       // 形容词:状态标记
  error,         // 名词:错误信息
  run,           // 动词:操作方法
  search,        // 动词:操作方法
  reset,         // 动词:操作方法
}

// ❌ 避免模糊命名
return {
  result,   // result 是什么?请求结果?搜索结果?
  flag,     // flag 是什么?
  handle,   // handle 什么?
  doIt,     // do what?
}

7.2 参数设计

javascript 复制代码
// ✅ 推荐:必选参数放前面,可选配置用 options 对象
export function useTable(apiFn, options = {}) {}

// ❌ 不推荐:一堆位置参数,调用时要记顺序
export function useTable(apiFn, pageSize, immediate, formatFn) {}

// ✅ 提供合理的默认值,让最简单的用法零配置
const { tableData, loading } = useTable(getUserList)
// 不传 options 也能正常工作

7.3 单一职责

javascript 复制代码
// ✅ 一个 composable 做一件事
useRequest  → 只管请求状态
useTable    → 只管表格 + 分页
useForm     → 只管表单 + 弹窗

// ❌ 不要做一个"万能"composable
usePageHelper → 又管表格、又管表单、又管权限、又管路由......
// 这种东西最终会变成新的"屎山"

7.4 组合优于继承

Composable 之间可以互相组合。比如 useTable 可以内部使用 useRequest

javascript 复制代码
// composables/useTable.js(组合版)
import { useRequest } from './useRequest'

export function useTable(apiFn, options = {}) {
  const { data, loading, run } = useRequest(apiFn)

  async function fetchData() {
    const params = { page: pagination.currentPage, size: pagination.pageSize }
    const res = await run(params)
    // 处理 res...
  }

  // ...
}

这就是组合的威力:小函数组成大函数,每一层都清晰可控。

7.5 统一导出

javascript 复制代码
// composables/index.js
export { useRequest } from './useRequest'
export { useTable } from './useTable'
export { useForm } from './useForm'
export { useLoading } from './useLoading'
// ...

使用时一行搞定:

javascript 复制代码
import { useTable, useForm } from '@/composables'

八、常见问题 FAQ

Q1:Composable 里能用生命周期钩子吗?

可以。 在 composable 内部调用 onMountedonUnmounted 等是完全合法的,前提是这个 composable 是在 setup 阶段被调用的(而不是在某个异步回调里调用)。

javascript 复制代码
// ✅ 合法
export function useWindowResize() {
  const width = ref(window.innerWidth)

  function handler() {
    width.value = window.innerWidth
  }

  onMounted(() => window.addEventListener('resize', handler))
  onUnmounted(() => window.removeEventListener('resize', handler))

  return { width }
}

Q2:Composable 之间怎么共享状态?

如果你需要在多个组件之间共享同一份状态(比如全局用户信息),有两种方式:

javascript 复制代码
// 方式1:把状态定义在函数外面(模块级别的单例)
const globalUser = ref(null)

export function useUser() {
  async function fetchUser() {
    globalUser.value = await getUserInfo()
  }
  return { user: globalUser, fetchUser }
}
// 所有组件拿到的都是同一个 globalUser

// 方式2:更复杂的全局状态,建议用 Pinia
// composable 适合组件级的有状态逻辑
// Pinia 适合跨组件/跨页面的全局状态

Q3:和 React Hooks 有什么区别?

最核心的区别:Vue composable 只在 setup 时执行一次,React Hook 每次渲染都会执行。

javascript 复制代码
// Vue:setup 只跑一次,后续数据变化靠响应式系统自动追踪
export function useCounter() {
  const count = ref(0)         // 只创建一次
  const double = computed(() => count.value * 2)  // 自动追踪
  return { count, double }
}

// React:每次渲染都会重新执行,需要 useMemo/useCallback 优化
function useCounter() {
  const [count, setCount] = useState(0)        // 每次渲染都执行
  const double = useMemo(() => count * 2, [count])  // 手动声明依赖
  return { count, double }
}

所以 Vue 的 composable 不需要担心"闭包陷阱"和"依赖数组"这些 React 特有的问题,心智负担更小。

九、总结

维度 Vue2 Mixin Vue3 Composable
来源透明性 ❌ 变量来源不明 ✅ 显式导入解构
命名冲突 ❌ 静默覆盖 ✅ 解构重命名
多实例 ❌ 不支持 ✅ 调多次即多份
TypeScript ❌ 几乎无法推导 ✅ 完美支持
组合能力 ❌ 难以互相调用 ✅ 函数随意组合
调试体验 ❌ 不知道值从哪来 ✅ 断点直接跟进函数

三个核心封装的适用场景速查:

  • useRequest:任何需要调接口的地方(基础设施,其他 composable 的地基)
  • useTable:所有列表/表格页面(中后台的半壁江山)
  • useForm:所有弹窗表单场景(新增/编辑/详情)

封装心法:

  1. 先想清楚要管理哪些状态、暴露哪些方法
  2. 参数设计:必选在前,可选用 options 对象 + 合理默认值
  3. 单一职责,小函数组合成大函数
  4. 统一命名(useXxx),统一目录(composables/),统一导出(index.js

最后想说的是:Composable 不是什么高深技术,它就是"把逻辑写成函数"------这是编程最古老、最朴素、最强大的抽象方式。

Vue3 的 Composition API 只是给了我们一个在 Vue 框架里优雅地使用这种方式的能力。把它用好,你的代码会变得更干净、更可维护、更有生命力。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

相关推荐
UrbanJazzerati1 小时前
事件传播机制详解(附直观比喻和代码示例)
前端
青青家的小灰灰1 小时前
透视 React 内核:Diff 算法、合成事件与并发特性的深度解析
前端·javascript·react.js
北冥有鱼1 小时前
JSON或代码对比的工具-vue
vue.js
乡村中医1 小时前
AI Chat实现第一步,流式输出,教你如何实现打字流
前端
程序员阿峰1 小时前
这5个CSS新特性已经强到离谱,攻城狮直呼内行
前端
阿星AI工作室2 小时前
给openclaw龙虾造了间像素办公室!实时看它写代码、摸鱼、修bug、写日报,太可爱了吧!
前端·人工智能·设计模式
Kayshen2 小时前
我用纯前端逆向了 Figma 的二进制文件格式,实现了 .fig 文件的完整解析和导入
前端·agent·ai编程
wuhen_n2 小时前
模板编译三阶段:parse-transform-generate
前端·javascript·vue.js
椰子皮啊2 小时前
音视频会议 ASR 实战:概率性识别不准问题定位与解决
前端