普通视图

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

后台权限与菜单渲染:基于路由和后端返回的几种实现方式

作者 SuperEugene
2026年3月2日 10:32

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

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

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

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

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

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

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

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

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

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

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

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

一、先搞清楚:权限到底分几层?

很多人一上来就想"我要做权限控制",但连权限分几层都没理清楚。我们先建立一个清晰的分层认知:

层级 控制什么 典型实现方式
路由级权限 用户能不能访问某个页面 路由守卫 + 动态路由
菜单级权限 侧边栏显示哪些菜单项 后端返回菜单 / 前端根据角色过滤
按钮级权限 页面内某个按钮是否可见/可点 自定义指令 / 组件封装
接口级权限 后端接口是否允许调用 后端网关/中间件拦截(前端兜底)

关键认识:前端权限控制本质上是"体验优化",真正的安全屏障在后端。 前端做的事情是:不该看的别让用户看到,不该点的别让用户点到。但如果有人绕过前端直接调接口,后端必须自己挡住。

二、路由级权限:从静态到动态的三种方案

方案一:最朴素的路由守卫 —— 路由 meta + 全局前置守卫

适用场景:角色简单(比如只有 admin 和 user 两种),页面不多。

思路:所有路由在前端写死,通过 meta 字段标记需要的角色,在全局路由守卫里做判断。

完整示例

路由配置:

// router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Layout from '@/layout/index.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/login',
    component: () => import('@/views/login.vue'),
    meta: { requiresAuth: false }
  },
  {
    path: '/',
    component: Layout,
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        component: () => import('@/views/dashboard.vue'),
        meta: { requiresAuth: true, roles: ['admin', 'user'] }
      },
      {
        path: 'user-manage',
        component: () => import('@/views/user-manage.vue'),
        meta: { requiresAuth: true, roles: ['admin'] }
      },
      {
        path: 'order-list',
        component: () => import('@/views/order-list.vue'),
        meta: { requiresAuth: true, roles: ['admin', 'user'] }
      }
    ]
  },
  {
    path: '/403',
    component: () => import('@/views/403.vue')
  }
]

const router = new VueRouter({ routes })

export default router

全局守卫:

// router/permission.js
import router from './index'
import store from '@/store'

router.beforeEach(async (to, from, next) => {
  const token = store.getters.token

  // 1. 去登录页:有 token 就跳首页,没有就放行
  if (to.path === '/login') {
    token ? next('/') : next()
    return
  }

  // 2. 没有 token,去登录
  if (!token) {
    next(`/login?redirect=${to.path}`)
    return
  }

  // 3. 有 token,但用户信息还没拉取(刷新页面的场景)
  if (!store.getters.userInfo) {
    try {
      await store.dispatch('user/getUserInfo')
    } catch (error) {
      // token 过期或无效,清除后跳登录
      await store.dispatch('user/logout')
      next(`/login?redirect=${to.path}`)
      return
    }
  }

  // 4. 检查角色权限
  if (to.meta.roles) {
    const userRole = store.getters.role
    if (to.meta.roles.includes(userRole)) {
      next()
    } else {
      next('/403')
    }
  } else {
    next()
  }
})

这种方案的优缺点

优点:简单直观,5 分钟就能写完,小项目完全够用。

缺点

  • 所有路由都注册了,只是守卫拦着不让进。用户在浏览器地址栏敲地址虽然会被拦截,但路由本身是存在的。
  • 角色和路由的对应关系写死在前端,改权限就得改代码、重新发版。
  • 菜单渲染还得另外写一套过滤逻辑。

踩坑点

坑 1:刷新页面时 userInfo 丢失。 Vuex 的状态刷新后就没了,所以守卫里必须有"重新获取用户信息"这一步。很多人一开始忘了这一步,导致刷新后直接跳登录页。

坑 2:next() 多次调用。 在一个守卫函数里,next() 只应该被调用一次。如果你的 if-else 分支写得不够严谨,可能会出现 next() 被调用多次的情况,导致诡异的跳转。上面示例里每个分支都 return 了,就是为了避免这个问题。


方案二:动态路由 —— 前端存完整路由表,登录后按角色过滤

适用场景:角色较多,但角色和权限的对应关系前端可以维护。

思路:前端维护一份"完整路由表"和一份"基础路由表"。用户登录后,根据角色从完整路由表中过滤出有权限的路由,通过 router.addRoutes()(Vue Router 3)或 router.addRoute()(Vue Router 4)动态添加。

完整示例

先把路由分成两份:

// router/routes.js

// 基础路由 —— 所有人都能访问
export const constantRoutes = [
  {
    path: '/login',
    component: () => import('@/views/login.vue'),
    hidden: true  // 菜单里不显示
  },
  {
    path: '/403',
    component: () => import('@/views/403.vue'),
    hidden: true
  }
]

// 动态路由 —— 需要根据角色过滤
export const asyncRoutes = [
  {
    path: '/',
    component: () => import('@/layout/index.vue'),
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        component: () => import('@/views/dashboard.vue'),
        meta: { title: '首页', icon: 'home', roles: ['admin', 'user', 'editor'] }
      }
    ]
  },
  {
    path: '/system',
    component: () => import('@/layout/index.vue'),
    meta: { title: '系统管理', icon: 'setting', roles: ['admin'] },
    children: [
      {
        path: 'user',
        component: () => import('@/views/system/user.vue'),
        meta: { title: '用户管理', roles: ['admin'] }
      },
      {
        path: 'role',
        component: () => import('@/views/system/role.vue'),
        meta: { title: '角色管理', roles: ['admin'] }
      }
    ]
  },
  {
    path: '/content',
    component: () => import('@/layout/index.vue'),
    meta: { title: '内容管理', icon: 'document' },
    children: [
      {
        path: 'article',
        component: () => import('@/views/content/article.vue'),
        meta: { title: '文章管理', roles: ['admin', 'editor'] }
      },
      {
        path: 'comment',
        component: () => import('@/views/content/comment.vue'),
        meta: { title: '评论管理', roles: ['admin'] }
      }
    ]
  }
]

过滤函数:

// utils/permission.js

/**
 * 判断用户角色是否匹配路由要求
 */
function hasPermission(route, role) {
  if (route.meta && route.meta.roles) {
    return route.meta.roles.includes(role)
  }
  // 没有设置 roles 的路由,默认所有人可访问
  return true
}

/**
 * 递归过滤路由表
 * 注意:这里要深拷贝,不能污染原始路由表
 */
export function filterAsyncRoutes(routes, role) {
  const result = []

  routes.forEach(route => {
    // 浅拷贝一份,避免修改原对象
    const tmp = { ...route }

    if (hasPermission(tmp, role)) {
      // 如果有子路由,递归过滤
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, role)
      }
      result.push(tmp)
    }
  })

  return result
}

在 Vuex 中集成(也可以用 Pinia,思路一样):

// store/modules/permission.js
import { constantRoutes, asyncRoutes } from '@/router/routes'
import { filterAsyncRoutes } from '@/utils/permission'

const state = {
  routes: [],        // 最终的完整路由(用于渲染菜单)
  addedRoutes: []    // 动态添加的部分
}

const mutations = {
  SET_ROUTES(state, routes) {
    state.addedRoutes = routes
    state.routes = constantRoutes.concat(routes)
  }
}

const actions = {
  generateRoutes({ commit }, role) {
    return new Promise(resolve => {
      let accessedRoutes

      // admin 拥有全部权限,直接用完整路由表
      if (role === 'admin') {
        accessedRoutes = asyncRoutes
      } else {
        accessedRoutes = filterAsyncRoutes(asyncRoutes, role)
      }

      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

在路由守卫里动态添加:

// router/permission.js
import router from './index'
import store from '@/store'

const whiteList = ['/login', '/403']

router.beforeEach(async (to, from, next) => {
  const token = store.getters.token

  if (token) {
    if (to.path === '/login') {
      next('/')
      return
    }

    // 判断是否已经生成过动态路由
    const hasRoutes = store.getters.addedRoutes && store.getters.addedRoutes.length > 0

    if (hasRoutes) {
      next()
    } else {
      try {
        // 获取用户信息(含角色)
        const { role } = await store.dispatch('user/getUserInfo')

        // 根据角色生成可访问路由
        const accessRoutes = await store.dispatch('permission/generateRoutes', role)

        // 动态添加路由(Vue Router 3 用 addRoutes,4 用 addRoute)
        // Vue Router 3:
        router.addRoutes(accessRoutes)

        // Vue Router 4 的写法:
        // accessRoutes.forEach(route => {
        //   router.addRoute(route)
        // })

        // 用 replace 确保 addRoutes 生效后再跳转
        // hack:{ ...to } 会重新解析路由,确保新加的路由能匹配到
        next({ ...to, replace: true })
      } catch (error) {
        await store.dispatch('user/logout')
        next(`/login?redirect=${to.path}`)
      }
    }
  } else {
    if (whiteList.includes(to.path)) {
      next()
    } else {
      next(`/login?redirect=${to.path}`)
    }
  }
})

踩坑点

坑 1:next({ ...to, replace: true }) 是必须的。 这一行容易被忽略。addRoutes 是异步生效的,如果你直接 next(),此时新路由可能还没注册完,就会匹配到 404。next({ ...to, replace: true }) 相当于"用当前目标地址重新走一次路由匹配",此时新路由已经注册好了。

坑 2:刷新页面后动态路由丢失。 addRoutes 添加的路由在刷新后就没了(因为是运行时加的,不是写死在 router 实例化时的)。所以守卫里用 hasRoutes 标志位来判断,如果没了就重新走一遍 generateRoutes → addRoutes 的流程。

坑 3:过滤路由时污染原始数据。 filterAsyncRoutes 一定要拷贝一份再操作。如果你直接改 asyncRoutes 里的对象,下次退出登录换个角色重新登录,过滤就乱了——因为原始路由表已经被改过了。


方案三:完全由后端控制路由表 —— 前端动态生成路由

适用场景:大型后台系统、权限管理非常灵活、角色和菜单由运营/管理员后台配置。

思路:后端返回当前用户有权限的菜单/路由数据(JSON),前端拿到后转换成 Vue Router 能识别的路由对象,然后动态添加。

后端返回的数据长什么样(典型格式)

[
  {
    "id": 1,
    "parentId": 0,
    "path": "/dashboard",
    "component": "views/dashboard",
    "name": "Dashboard",
    "meta": { "title": "首页", "icon": "home" }
  },
  {
    "id": 2,
    "parentId": 0,
    "path": "/system",
    "component": "layout/index",
    "name": "System",
    "meta": { "title": "系统管理", "icon": "setting" },
    "children": [
      {
        "id": 3,
        "parentId": 2,
        "path": "user",
        "component": "views/system/user",
        "name": "UserManage",
        "meta": { "title": "用户管理" }
      },
      {
        "id": 4,
        "parentId": 2,
        "path": "role",
        "component": "views/system/role",
        "name": "RoleManage",
        "meta": { "title": "角色管理" }
      }
    ]
  }
]

注意:后端返回的 component 是一个字符串路径,不是真正的组件。前端需要自己把这个字符串映射成 () => import(...) 的动态导入。

核心:字符串转组件的映射函数

// utils/route-helper.js

// 方式一:用 import() 的动态拼接
// 注意:Webpack 的 import() 不支持完全动态的字符串,必须有一部分是静态的
function loadComponent(componentPath) {
  // 这里 '@/' 是静态前缀,后面拼动态部分,Webpack 才能正确分析
  return () => import(`@/${componentPath}.vue`)
}

// 方式二(更推荐):维护一个显式映射表,更可控
const componentMap = {
  'layout/index': () => import('@/layout/index.vue'),
  'views/dashboard': () => import('@/views/dashboard.vue'),
  'views/system/user': () => import('@/views/system/user.vue'),
  'views/system/role': () => import('@/views/system/role.vue'),
  'views/content/article': () => import('@/views/content/article.vue'),
  // ...根据项目页面逐步维护
}

function loadComponentByMap(componentPath) {
  const loader = componentMap[componentPath]
  if (!loader) {
    console.warn(`[路由警告] 找不到组件: ${componentPath},将渲染 404 页面`)
    return () => import('@/views/404.vue')
  }
  return loader
}

/**
 * 把后端返回的路由数据转换成 Vue Router 格式
 */
export function transformRoutes(backendRoutes) {
  return backendRoutes.map(route => {
    const tmp = { ...route }

    // 字符串组件路径 → 真实组件
    if (tmp.component) {
      tmp.component = loadComponentByMap(tmp.component)
    }

    // 递归处理子路由
    if (tmp.children && tmp.children.length > 0) {
      tmp.children = transformRoutes(tmp.children)
    }

    return tmp
  })
}

在权限 store 中使用:

// store/modules/permission.js
import { constantRoutes } from '@/router/routes'
import { transformRoutes } from '@/utils/route-helper'
import { getUserMenus } from '@/api/user'

const actions = {
  async generateRoutes({ commit }) {
    // 从后端获取当前用户的菜单/路由数据
    const { data: backendRoutes } = await getUserMenus()

    // 将后端数据转换成 Vue Router 路由对象
    const accessedRoutes = transformRoutes(backendRoutes)

    commit('SET_ROUTES', accessedRoutes)
    return accessedRoutes
  }
}

路由守卫的写法和方案二基本一样,只是 generateRoutes 不再需要传角色了——后端已经帮你过滤好了。

踩坑点

坑 1:Webpack 的 import() 不能用完全动态的变量。 比如 import(componentPath) 这样写是不行的,Webpack 需要至少一个静态的目录前缀来确定搜索范围。所以要么写成 import(`@/views/${componentPath}.vue`),要么像上面那样用映射表。Vite 的场景下可以用 import.meta.glob 来实现更优雅的批量导入,后面会提到。

坑 2:后端返回的树形结构可能是扁平的。 有些后端返回的不是嵌套好的 tree,而是一个带 parentId 的扁平数组。这时候你需要先在前端组装成树形结构:

/**
 * 扁平数组 → 树形结构
 */
export function buildTree(flatList) {
  const map = {}
  const tree = []

  // 第一遍:建立 id → item 的映射
  flatList.forEach(item => {
    map[item.id] = { ...item, children: [] }
  })

  // 第二遍:根据 parentId 挂到父节点的 children 下
  flatList.forEach(item => {
    const node = map[item.id]
    if (item.parentId === 0) {
      tree.push(node)
    } else {
      const parent = map[item.parentId]
      if (parent) {
        parent.children.push(node)
      }
    }
  })

  return tree
}

坑 3:Vite 环境下 import() 的写法不同。 如果你用的是 Vite(Vue 3 项目大概率是),可以用 import.meta.glob 来做组件映射:

// Vite 专用写法
const modules = import.meta.glob('@/views/**/*.vue')

function loadComponent(componentPath) {
  const key = `/src/${componentPath}.vue`
  const loader = modules[key]
  if (!loader) {
    console.warn(`[路由警告] 找不到组件: ${componentPath}`)
    return modules['/src/views/404.vue']
  }
  return loader
}

import.meta.glob 返回的本身就是 { 路径: () => import(...) } 的映射对象,天然适合做这个事情,而且不需要手动维护映射表。


三种方案对比总结

维度 方案一:meta 守卫 方案二:前端过滤 方案三:后端返回
复杂度 ⭐⭐ ⭐⭐⭐
灵活度 低,改权限要发版 中,角色固定时够用 高,运营后台可动态配置
安全性 路由全暴露 路由全暴露(只是不添加) 前端只有有权限的路由
菜单渲染 需另外过滤 过滤后的路由即菜单 后端数据即菜单
适合场景 内部小工具 中型项目 大型后台 / SaaS

我的建议:如果你的项目超过 10 个菜单项,或者权限角色超过 3 种,直接上方案三。前期多花半天时间,后期能省几周的维护成本。

三、菜单渲染:路由即菜单 vs 菜单和路由分离

方式一:路由即菜单

这是最常见的做法——侧边栏菜单直接根据路由表渲染。方案二和方案三天然支持这种方式:过滤后的路由表就是菜单数据。

<!-- layout/Sidebar.vue -->
<template>
  <div class="sidebar">
    <template v-for="route in menuRoutes">
      <!-- 只有一个子菜单或没有子菜单:直接渲染为菜单项 -->
      <router-link
        v-if="!route.children || route.children.length <= 1"
        :key="route.path"
        :to="route.children ? route.children[0].path : route.path"
        class="menu-item"
      >
        <i :class="route.meta?.icon" />
        <span>{{ route.meta?.title || route.children?.[0]?.meta?.title }}</span>
      </router-link>

      <!-- 多个子菜单:渲染为可展开的菜单组 -->
      <div v-else :key="route.path" class="submenu">
        <div class="submenu-title">
          <i :class="route.meta?.icon" />
          <span>{{ route.meta?.title }}</span>
        </div>
        <router-link
          v-for="child in route.children.filter(c => !c.hidden)"
          :key="child.path"
          :to="`${route.path}/${child.path}`"
          class="menu-item"
        >
          <span>{{ child.meta?.title }}</span>
        </router-link>
      </div>
    </template>
  </div>
</template>

<script>
export default {
  computed: {
    menuRoutes() {
      // 从 store 拿过滤后的路由,排除 hidden 的
      return this.$store.getters.routes.filter(r => !r.hidden)
    }
  }
}
</script>

优点:菜单和路由保持一致,不会出现"菜单有但页面 404"或"页面有但菜单没显示"的错位问题。

缺点:菜单的层级、排序完全受路由结构限制。如果产品经理说"这个页面属于 A 模块,但菜单要放在 B 模块下面",你就麻烦了。

方式二:菜单和路由分离

后端分别返回两套数据:一套是菜单数据(控制侧边栏显示),一套是权限标识(控制路由注册和按钮权限)。

// 后端返回的菜单数据(只关心展示)
const menus = [
  {
    title: '首页',
    icon: 'home',
    path: '/dashboard'
  },
  {
    title: '运营中心',    // 这是一个虚拟的分组,不对应任何路由
    icon: 'operation',
    children: [
      { title: '文章管理', path: '/content/article' },
      { title: '订单列表', path: '/order/list' }   // 注意:订单本来在"订单模块",但菜单放在了"运营中心"
    ]
  }
]

// 后端返回的权限标识(控制路由和按钮)
const permissions = [
  'dashboard',
  'content:article',
  'content:article:edit',
  'content:article:delete',
  'order:list',
  'order:detail'
]

优点:菜单的展示结构完全灵活,不受路由层级约束。

缺点:要同时维护菜单和路由两套东西,且必须保证菜单的 path 和路由的 path 对得上,否则会出现点菜单跳 404 的情况。

我的建议:除非产品对菜单的展示结构有特殊要求,否则优先用"路由即菜单"。简单就是美。

四、按钮级权限:自定义指令 vs 组件封装

这是权限控制里最细粒度的一层。典型场景:同一个页面,管理员能看到"编辑"和"删除"按钮,普通用户只能看到"查看"。

方式一:自定义指令 v-permission

思路:写一个自定义指令,绑定在按钮上。指令内部检查当前用户的权限列表,如果没权限就把这个 DOM 元素移除。

// directives/permission.js
import store from '@/store'

export default {
  // Vue 2 写法
  inserted(el, binding) {
    const { value: requiredPermission } = binding
    const permissions = store.getters.permissions  // 用户的权限标识列表

    if (!requiredPermission) return

    // 支持传单个字符串或数组
    const requiredList = Array.isArray(requiredPermission)
      ? requiredPermission
      : [requiredPermission]

    // 检查用户是否拥有所需权限中的至少一个
    const hasPermission = requiredList.some(p => permissions.includes(p))

    if (!hasPermission) {
      // 没权限:移除 DOM 元素
      el.parentNode && el.parentNode.removeChild(el)
    }
  }

  // Vue 3 写法(钩子名不同):
  // mounted(el, binding) { ... }  // 对应 Vue 2 的 inserted
}

全局注册:

// main.js
import permissionDirective from '@/directives/permission'

// Vue 2
Vue.directive('permission', permissionDirective)

// Vue 3
app.directive('permission', permissionDirective)

使用:

<template>
  <div>
    <button v-permission="'content:article:edit'" @click="handleEdit">
      编辑
    </button>
    
    <button v-permission="'content:article:delete'" @click="handleDelete">
      删除
    </button>

    <!-- 也支持传数组:拥有其中任意一个权限即可 -->
    <button v-permission="['content:article:edit', 'content:article:publish']">
      编辑或发布
    </button>
  </div>
</template>

踩坑点

坑 1(非常重要):用 v-if 还是操作 DOM? 很多人觉得指令里直接 removeChild 太粗暴了。确实,这种方式有个问题:一旦移除了就不会再回来。如果你的权限数据是异步获取的,指令执行时权限还没拿到,按钮就被误删了。

解决方案有两种:

  1. 确保权限数据一定在组件渲染前就位(在路由守卫里获取完用户信息再放行)。
  2. 不用 removeChild,改成 el.style.display = 'none',然后在 update 钩子里重新检查。

坑 2:指令方式无法与 v-if / v-show 配合。 如果你在同一个元素上同时用了 v-permissionv-if,逻辑会变得混乱。建议二选一。

方式二:组件封装 <Permission>

思路:封装一个函数式组件,通过插槽来控制内容的渲染。

<!-- components/Permission.vue -->
<script>
export default {
  name: 'Permission',
  functional: true,   // Vue 2 函数式组件,性能更好
  props: {
    value: {
      type: [String, Array],
      required: true
    }
  },
  render(h, context) {
    const { value } = context.props
    const permissions = context.parent.$store.getters.permissions

    const requiredList = Array.isArray(value) ? value : [value]
    const hasPermission = requiredList.some(p => permissions.includes(p))

    // 有权限则渲染插槽内容,否则渲染空
    return hasPermission ? context.children : null
  }
}
</script>

Vue 3 的 Composition API 写法:

<!-- components/Permission.vue (Vue 3) -->
<template>
  <slot v-if="hasPermission" />
</template>

<script setup>
import { computed } from 'vue'
import { useStore } from 'vuex'  // 或者 import { usePermissionStore } from '@/stores/permission'

const props = defineProps({
  value: {
    type: [String, Array],
    required: true
  }
})

const store = useStore()

const hasPermission = computed(() => {
  const permissions = store.getters.permissions
  const requiredList = Array.isArray(props.value) ? props.value : [props.value]
  return requiredList.some(p => permissions.includes(p))
})
</script>

使用:

<template>
  <div>
    <Permission value="content:article:edit">
      <button @click="handleEdit">编辑</button>
    </Permission>

    <Permission :value="['content:article:delete']">
      <button @click="handleDelete">删除</button>
    </Permission>
  </div>
</template>

方式三(补充):直接用函数判断

有时候权限逻辑比较复杂(比如同时要判断角色 + 数据归属),指令和组件都不太方便。这时候最朴素的 v-if + 工具函数反而最好用:

// utils/permission.js
import store from '@/store'

export function hasPermission(permission) {
  const permissions = store.getters.permissions
  const requiredList = Array.isArray(permission) ? permission : [permission]
  return requiredList.some(p => permissions.includes(p))
}

export function hasRole(role) {
  return store.getters.role === role
}
<template>
  <div>
    <!-- 简单场景 -->
    <button v-if="hasPermission('content:article:edit')" @click="handleEdit">
      编辑
    </button>

    <!-- 复杂场景:不仅要有权限,还要是自己的文章 -->
    <button
      v-if="hasPermission('content:article:edit') && article.authorId === userId"
      @click="handleEdit"
    >
      编辑
    </button>
  </div>
</template>

<script>
import { hasPermission } from '@/utils/permission'

export default {
  methods: {
    hasPermission
  }
}
</script>

三种方式对比

维度 自定义指令 组件封装 函数 + v-if
简洁性 ⭐⭐⭐ 一行搞定 ⭐⭐ 需要包一层 ⭐⭐ 需要导入函数
灵活性 低,只能控制显隐 ⭐⭐⭐ 可组合复杂逻辑
响应式 需手动处理 天然响应式 天然响应式
推荐场景 纯显隐控制 团队规范统一 复杂业务逻辑

我的实战建议:项目中三种可以并存。简单的用指令,需要统一规范的用组件,复杂条件的用函数。别非要"只用一种"——工具是为业务服务的。

五、完整的权限流程串联

最后,我们把上面所有内容串起来,看一个完整的权限控制流程是怎么跑的:

用户打开浏览器,访问 /dashboard
        │
        ▼
  路由守卫拦截,检查 token
        │
    ┌───┴───┐
    │ 无token │──────→ 跳转 /login
    └───┬───┘
        │ 有token
        ▼
  是否已拉取用户信息?
        │
    ┌───┴───┐
    │  还没有  │──────→ 调接口获取 userInfo + permissions
    └───┬───┘
        │ 已有
        ▼
  是否已生成动态路由?
        │
    ┌───┴───┐
    │  还没有  │──────→ 调接口获取菜单数据
    └───┬───┘         → transformRoutes 转换
        │             → router.addRoute 注册
        │             → next({ ...to, replace: true })
        │ 已有
        ▼
  正常进入页面
        │
        ▼
  侧边栏根据 store 里的 routes 渲染菜单
        │
        ▼
  页面内按钮根据 permissions 做显隐控制

在代码层面,一个典型项目的文件组织大概是这样的:

src/
├── router/
│   ├── index.js          # 创建 router 实例,只注册 constantRoutes
│   ├── routes.js         # constantRoutes 和 asyncRoutes(方案二用)
│   └── permission.js     # 全局路由守卫
├── store/
│   └── modules/
│       ├── user.js       # 用户信息、token、登录/登出
│       └── permission.js # 路由/权限数据、generateRoutes
├── api/
│   └── user.js           # getUserInfo、getUserMenus 等接口
├── directives/
│   └── permission.js     # v-permission 自定义指令
├── components/
│   └── Permission.vue    # 权限组件(可选)
├── utils/
│   ├── permission.js     # hasPermission 工具函数
│   └── route-helper.js   # transformRoutes、buildTree
└── layout/
    ├── index.vue         # 整体布局
    └── Sidebar.vue       # 侧边栏菜单

六、常见问题 FAQ

Q1:退出登录后需要做什么清理?

// store/modules/user.js
async logout({ commit, dispatch }) {
  await logoutApi()               // 调后端登出接口
  commit('SET_TOKEN', '')         // 清 token
  commit('SET_USER_INFO', null)   // 清用户信息

  // 重点:重置路由!
  // Vue Router 3 没有 removeRoute,通常的做法是重新创建 router 实例
  resetRouter()

  // 清除 permission store
  dispatch('permission/resetRoutes', null, { root: true })
}

resetRouter 的实现(Vue Router 3 的经典 hack):

// router/index.js
const createRouter = () => new VueRouter({
  routes: constantRoutes
})

const router = createRouter()

export function resetRouter() {
  const newRouter = createRouter()
  router.matcher = newRouter.matcher  // 用新 matcher 替换旧的,相当于清除了动态路由
}

export default router

Vue Router 4 就优雅多了,有 router.removeRoute() 可以用。

Q2:Token 过期怎么处理?

建议在 axios 响应拦截器里统一处理:

// utils/request.js
service.interceptors.response.use(
  response => response,
  error => {
    if (error.response?.status === 401) {
      // token 过期或无效
      // 避免多个请求同时触发多次弹窗
      if (!isRefreshing) {
        isRefreshing = true
        MessageBox.confirm('登录已过期,请重新登录', '提示', {
          confirmButtonText: '重新登录',
          cancelButtonText: '取消',
          type: 'warning'
        }).then(() => {
          store.dispatch('user/logout').then(() => {
            location.reload()   // 简单粗暴但有效:刷新页面让路由守卫重新走流程
          })
        }).finally(() => {
          isRefreshing = false
        })
      }
    }
    return Promise.reject(error)
  }
)

Q3:同一个页面需要根据权限展示不同的布局怎么办?

不要用 v-permission(它是非此即彼的),用函数方式更灵活:

<template>
  <div>
    <!-- 管理员看到完整表单 -->
    <FullForm v-if="hasRole('admin')" />
    <!-- 普通用户看到精简表单 -->
    <SimpleForm v-else />
  </div>
</template>

总结

  1. 权限分层:路由级、菜单级、按钮级、接口级,各有各的实现方式,别混在一起。
  2. 路由方案选型:小项目用 meta 守卫,中项目用前端过滤,大项目让后端返回路由表。
  3. 菜单渲染:优先"路由即菜单",除非有特殊展示需求才分离。
  4. 按钮权限:指令、组件、函数三种方式可以并存,按场景选择。
  5. 前端权限只是体验优化,后端一定要有自己的鉴权,不要把安全寄托在前端。

权限这块东西不难,但坑很多,而且大多数坑只有在刷新页面、切换角色、token 过期这些"非正常路径"才会暴露出来。所以写完权限逻辑后,一定要多测这几个场景:

  • 刷新页面后,菜单和路由是否正常
  • 直接输入 URL 访问无权限页面,是否正确拦截
  • 退出登录 → 换角色登录,菜单是否正确更新
  • Token 过期后的操作,是否平滑跳转登录页

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

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

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

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

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

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

作者 SuperEugene
2026年3月2日 09:10

同学们好,我是 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 时你要在每个页面写的东西:

// 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 抽出来:

// 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()
  }
}
// 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 的三宗罪

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

<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,模板里用的变量来源就成了悬案。新人接手的时候更是一脸懵。

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

// 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 的时候已经不知道要查到什么时候了。

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

// 如果一个页面有两个独立的表格,各自有各自的分页呢?
// 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、新概念,就是函数

// 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 }
}

使用的时候:

<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/ 目录下
src/
├── composables/         # 或叫 hooks/
│   ├── useRequest.js    # 通用请求封装
│   ├── useTable.js      # 表格逻辑封装
│   ├── useForm.js       # 表单逻辑封装
│   ├── useLoading.js    # 加载状态封装
│   └── index.js         # 统一导出
├── views/
├── components/
└── ...

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

4.1 为什么先封装它?

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

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

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

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

4.3 最小可用版本(V1)

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

// 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 }
}

使用示例:

<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. 竞态:用户快速切换筛选条件,先发的请求后返回,会覆盖掉后发请求的正确数据
// 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 }
}

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

用户操作:选"北京" → 选"上海"(很快切换)

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

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

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

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

4.5 踩坑提醒

坑 1:忘了 finally 重置 loading

// ❌ 错误写法
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 的时候传参

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

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

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

5.1 分析需求

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

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

5.2 完整实现

// 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 使用示例(完整页面)

<!-- 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

// 方案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 还是直接调用?

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

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

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

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

// ❌ 错误
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 分析需求

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

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

6.2 完整实现

// 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 使用示例(完整页面)

<!-- 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 为什么必须是函数?

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

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

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

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

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

// ❌ 错误
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:弹窗关闭时的视觉闪烁

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

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

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

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

7.1 命名规范

// ✅ 文件名和函数名保持一致
// 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 参数设计

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

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

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

7.3 单一职责

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

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

7.4 组合优于继承

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

// 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 统一导出

// composables/index.js
export { useRequest } from './useRequest'
export { useTable } from './useTable'
export { useForm } from './useForm'
export { useLoading } from './useLoading'
// ...

使用时一行搞定:

import { useTable, useForm } from '@/composables'

八、常见问题 FAQ

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

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

// ✅ 合法
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 之间怎么共享状态?

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

// 方式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 每次渲染都会执行。

// 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,你的电子学友,我们下一篇干货见~

弹窗与抽屉组件封装:如何做一个全局可控的 Dialog 服务

作者 SuperEugene
2026年3月2日 08:51

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

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

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

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

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

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

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

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

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

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

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

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

一、先说痛点:你一定经历过的"弹窗地狱"

1.1 最原始的写法

大多数人刚接触 Vue 时,弹窗都是这么写的:

<template>
  <div>
    <el-button @click="showDialog = true">打开弹窗</el-button>

    <el-dialog v-model="showDialog" title="提示">
      <p>确定要删除吗?</p>
      <template #footer>
        <el-button @click="showDialog = false">取消</el-button>
        <el-button type="primary" @click="handleConfirm">确定</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const showDialog = ref(false)

const handleConfirm = () => {
  console.log('用户点了确定')
  showDialog.value = false
}
</script>

这段代码能跑,也没错。但问题是——一个页面如果有 5 个弹窗呢?

你会看到:

const showDeleteDialog = ref(false)
const showEditDialog = ref(false)
const showDetailDialog = ref(false)
const showConfirmDialog = ref(false)
const showUploadDialog = ref(false)

模板里 5 个 <el-dialog>,script 里 5 组 ref + handler。这就是我说的 "弹窗地狱"

1.2 痛点总结

问题 表现
状态散落 每个弹窗一个 ref,页面一复杂就找不到谁控制谁
模板臃肿 <template> 里堆满了弹窗代码,实际页面逻辑被淹没
复用困难 同样的确认弹窗,A 页面写一遍,B 页面再写一遍
流程断裂 想在弹窗确认后继续执行逻辑,需要靠回调层层传递

你有没有想过——能不能像调函数一样调弹窗?

// 梦想中的写法
const result = await dialog.confirm('确定要删除吗?')
if (result) {
  await deleteItem(id)
}

这就是我们今天要做的事。

二、设计思路:从"模板驱动"到"命令式调用"

2.1 两种思维模式

Vue 的核心是声明式——你在模板里写好结构,数据变了,视图自动更新。弹窗用 v-model 控制显隐,这是标准的声明式用法。

但弹窗这个场景比较特殊,它更接近一个**"一次性动作":打开 → 用户操作 → 关闭,然后就没了。它更适合命令式**——我告诉你打开,你告诉我结果。

模式 适合场景 弹窗场景的体验
声明式(模板驱动) 持久存在的 UI,如表单、列表 需要维护额外状态,模板臃肿
命令式(函数调用) 一次性交互,如确认框、通知 调用简洁,流程连贯

2.2 核心设计

我们要封装的 Dialog 服务,核心就三件事:

  1. 选项配置:通过一个配置对象描述弹窗长什么样(标题、内容、按钮文案等)
  2. 回调支持:点确定/取消时能触发对应的回调函数
  3. Promise 化:让弹窗的结果可以被 await,融入异步流程

三、第一步:封装基础 Dialog 组件

先别急着搞全局服务,我们从一个配置式的基础弹窗组件开始。

3.1 定义配置类型

// types/dialog.ts

export interface DialogOptions {
  /** 弹窗标题 */
  title?: string
  /** 弹窗内容,可以是字符串,也可以是 VNode */
  content?: string | VNode
  /** 确认按钮文案 */
  confirmText?: string
  /** 取消按钮文案 */
  cancelText?: string
  /** 是否显示取消按钮 */
  showCancel?: boolean
  /** 弹窗宽度 */
  width?: string | number
  /** 点击确认的回调 */
  onConfirm?: () => void | Promise<void>
  /** 点击取消的回调 */
  onCancel?: () => void
  /** 弹窗关闭后的回调(无论确认还是取消) */
  onClosed?: () => void
}

为什么要定义类型? 不是为了装,是为了让调用方有提示。你用 dialog.confirm() 时,IDE 能告诉你可以传什么参数,这是实实在在提升效率的事。

3.2 基础弹窗组件

<!-- components/BaseDialog.vue -->
<template>
  <el-dialog
    v-model="visible"
    :title="options.title || '提示'"
    :width="options.width || '420px'"
    :close-on-click-modal="false"
    @closed="handleClosed"
  >
    <!-- 内容区域 -->
    <div class="dialog-content">
      <!-- 如果 content 是字符串,直接渲染 -->
      <template v-if="typeof options.content === 'string'">
        {{ options.content }}
      </template>
      <!-- 如果是 VNode,用 component 渲染 -->
      <component v-else :is="options.content" />
    </div>

    <!-- 底部按钮 -->
    <template #footer>
      <el-button
        v-if="options.showCancel !== false"
        @click="handleCancel"
      >
        {{ options.cancelText || '取消' }}
      </el-button>
      <el-button
        type="primary"
        :loading="confirmLoading"
        @click="handleConfirm"
      >
        {{ options.confirmText || '确定' }}
      </el-button>
    </template>
  </el-dialog>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type { DialogOptions } from '@/types/dialog'

const props = defineProps<{
  options: DialogOptions
}>()

const visible = ref(false)
const confirmLoading = ref(false)

/** 打开弹窗 */
const open = () => {
  visible.value = true
}

/** 关闭弹窗 */
const close = () => {
  visible.value = false
}

/** 点击确认 */
const handleConfirm = async () => {
  if (props.options.onConfirm) {
    try {
      confirmLoading.value = true
      // 支持 onConfirm 返回 Promise,按钮自动 loading
      await props.options.onConfirm()
    } finally {
      confirmLoading.value = false
    }
  }
  close()
}

/** 点击取消 */
const handleCancel = () => {
  props.options.onCancel?.()
  close()
}

/** 弹窗关闭动画结束后 */
const handleClosed = () => {
  props.options.onClosed?.()
}

defineExpose({ open, close })
</script>

3.3 踩坑点:v-model vs closed 事件的时机

这里要特别注意一个细节:@close@closed 是两个不同的事件。

  • @close:弹窗开始关闭时触发(动画还没结束)
  • @closed:弹窗关闭动画完全结束后触发

为什么用 @closed 而不是 @close 因为如果你在 @close 里就销毁组件或清理数据,用户会看到弹窗内容"闪一下空白"再消失,体验很差。等动画结束再清理,过渡才是丝滑的。

四、第二步:回调模式的使用方式

有了基础组件,我们先看看回调模式怎么用:

<template>
  <div>
    <el-button @click="handleDelete">删除</el-button>
    <BaseDialog ref="dialogRef" :options="dialogOptions" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import BaseDialog from '@/components/BaseDialog.vue'

const dialogRef = ref()
const dialogOptions = ref({})

const handleDelete = () => {
  dialogOptions.value = {
    title: '确认删除',
    content: '删除后不可恢复,确定要继续吗?',
    confirmText: '删除',
    onConfirm: async () => {
      await api.deleteItem(123)
      ElMessage.success('删除成功')
      fetchList() // 刷新列表
    },
    onCancel: () => {
      console.log('用户取消了')
    }
  }
  dialogRef.value.open()
}
</script>

这已经比最初的写法好多了——弹窗的配置和业务逻辑写在一起,不用到处找 ref。但还是有两个问题:

  1. 模板里还是要放一个 <BaseDialog />
  2. 逻辑被"打断"了——你得把确认后的操作塞进 onConfirm 回调里

如果删除操作后面还有其他逻辑呢?回调套回调,又开始嵌套了。

所以我们需要 Promise 化。

五、第三步:Promise 化——让弹窗像 await 一样丝滑

5.1 核心思路

Promise 化的核心思想非常简单:

创建一个 Promise,把它的 resolve 和 reject 交给弹窗的确认和取消按钮。

用户点确认 → resolve(),点取消 → reject()resolve(false)

// 伪代码,感受一下
function confirm(content) {
  return new Promise((resolve, reject) => {
    打开弹窗({
      content,
      onConfirm: () => resolve(true),
      onCancel: () => resolve(false)
    })
  })
}

5.2 实现 useDialog 组合式函数

这是整篇文章最核心的代码,我们一步步拆:

// composables/useDialog.ts

import { createApp, ref, h, type VNode, type Component } from 'vue'
import BaseDialog from '@/components/BaseDialog.vue'
import type { DialogOptions } from '@/types/dialog'
import ElementPlus from 'element-plus'

/**
 * 命令式调用弹窗
 * 内部原理:动态创建一个 Vue 应用实例,挂载到临时 DOM 节点上
 */
function createDialog(options: DialogOptions): Promise<boolean> {
  return new Promise((resolve) => {
    // 1. 创建一个容器节点
    const container = document.createElement('div')
    document.body.appendChild(container)

    // 2. 记录是否已经 resolve,防止重复调用
    let resolved = false

    const safeResolve = (val: boolean) => {
      if (resolved) return
      resolved = true
      resolve(val)
    }

    // 3. 合并选项:把 Promise 的 resolve 注入到回调中
    const mergedOptions: DialogOptions = {
      ...options,
      onConfirm: async () => {
        // 如果用户传了自己的 onConfirm,先执行
        if (options.onConfirm) {
          await options.onConfirm()
        }
        safeResolve(true)
      },
      onCancel: () => {
        options.onCancel?.()
        safeResolve(false)
      },
      onClosed: () => {
        options.onClosed?.()
        // 动画结束后,清理 DOM 和 Vue 实例
        app.unmount()
        container.remove()
      }
    }

    // 4. 创建 Vue 应用实例并挂载
    const app = createApp({
      setup() {
        const dialogRef = ref()

        // 挂载后自动打开弹窗
        const onMounted = () => {
          // 用 nextTick 确保 DOM 已就绪
          setTimeout(() => dialogRef.value?.open(), 0)
        }

        return () =>
          h(BaseDialog, {
            ref: dialogRef,
            options: mergedOptions,
            onVnodeMounted: onMounted
          })
      }
    })

    // 5. 注册 Element Plus(因为是独立的 app 实例)
    app.use(ElementPlus)
    app.mount(container)
  })
}

5.3 关键踩坑:独立 App 实例的样式和插件问题

这里有一个非常容易踩的坑,很多文章不会告诉你:

通过 createApp 创建的实例,和你主应用是完全隔离的!

这意味着:

  • 主应用注册的 Element Plus,新实例里用不了
  • 主应用的 provide/inject,新实例里拿不到
  • 主应用的全局组件、指令,新实例里没有

所以你会看到代码里有一行 app.use(ElementPlus)——这不是多余的,是必须的

如果你的项目用了 Pinia、Vue Router、自定义插件,且弹窗里要用到,也得在新实例里注册:

// 如果弹窗组件里要用 store 或 router
import { createPinia } from 'pinia'
import router from '@/router'

app.use(createPinia())
app.use(router)
app.use(ElementPlus)

更优雅的做法:把主应用用到的插件列表抽出来,封装一个 installPlugins 函数,让主应用和弹窗实例共用:

// plugins/index.ts
import type { App } from 'vue'
import ElementPlus from 'element-plus'
import { createPinia } from 'pinia'

export function installPlugins(app: App) {
  app.use(createPinia())
  app.use(ElementPlus)
  // 其他插件...
}

六、第四步:封装成全局 Dialog 服务

6.1 暴露友好的 API

// services/dialog.ts

import { type VNode } from 'vue'
import type { DialogOptions } from '@/types/dialog'
import { createDialog } from '@/composables/useDialog'

/**
 * 全局 Dialog 服务
 * 用法:
 *   await dialog.confirm('确定删除?')
 *   await dialog.alert('操作成功')
 *   await dialog.open({ title: '自定义', content: h(MyComponent) })
 */
const dialog = {
  /**
   * 确认弹窗(有确定和取消按钮)
   * 返回 true 表示用户点了确认,false 表示取消
   */
  confirm(
    content: string | VNode,
    options?: Partial<DialogOptions>
  ): Promise<boolean> {
    return createDialog({
      title: '确认',
      content,
      showCancel: true,
      ...options
    })
  },

  /**
   * 提示弹窗(只有确定按钮)
   * 用户点确定后 resolve
   */
  alert(
    content: string | VNode,
    options?: Partial<DialogOptions>
  ): Promise<boolean> {
    return createDialog({
      title: '提示',
      content,
      showCancel: false,
      ...options
    })
  },

  /**
   * 完全自定义弹窗
   * 传入完整的配置对象
   */
  open(options: DialogOptions): Promise<boolean> {
    return createDialog(options)
  }
}

export default dialog

6.2 实际业务中使用

现在来看看调用有多舒服:

<template>
  <div class="user-list">
    <el-table :data="userList">
      <el-table-column prop="name" label="姓名" />
      <el-table-column prop="email" label="邮箱" />
      <el-table-column label="操作">
        <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>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import dialog from '@/services/dialog'

const userList = ref([/* ... */])

/** 删除用户——注意看,流程多清晰 */
const handleDelete = async (row) => {
  // 第一步:询问用户
  const confirmed = await dialog.confirm(
    `确定要删除用户「${row.name}」吗?删除后不可恢复。`,
    { title: '删除确认', confirmText: '确认删除' }
  )

  // 第二步:用户取消,直接 return
  if (!confirmed) return

  // 第三步:调接口删除
  try {
    await api.deleteUser(row.id)
    ElMessage.success('删除成功')
    fetchUserList()
  } catch (err) {
    ElMessage.error('删除失败,请重试')
  }
}

/** 批量删除——串行多个弹窗也很自然 */
const handleBatchDelete = async (ids: number[]) => {
  const step1 = await dialog.confirm(`即将删除 ${ids.length} 条记录`)
  if (!step1) return

  const step2 = await dialog.confirm(
    '此操作不可逆,是否已经备份相关数据?',
    { title: '二次确认', confirmText: '已备份,继续删除' }
  )
  if (!step2) return

  await api.batchDelete(ids)
  await dialog.alert('批量删除完成')
  fetchUserList()
}
</script>

对比一下之前的写法——没有额外的 ref,没有模板里的 <el-dialog>,流程像读文章一样从上往下。这就是 Promise 化的威力。

七、进阶:在弹窗里渲染自定义组件

确认框只是最简单的场景。实际业务中,弹窗里经常要放表单详情甚至是一个完整的子页面

7.1 渲染自定义组件

import { h } from 'vue'
import EditUserForm from '@/components/EditUserForm.vue'

const handleEdit = async (row) => {
  const confirmed = await dialog.open({
    title: '编辑用户',
    width: '600px',
    content: h(EditUserForm, {
      userId: row.id,
      // 可以通过 props 传值给弹窗内的组件
    }),
    onConfirm: async () => {
      // 这里怎么拿到表单数据?继续看下面
    }
  })
}

7.2 踩坑:弹窗和内部组件的通信

这是一个高频踩坑点:弹窗的确认按钮在外面,表单在里面,点确认时要拿到表单数据并校验——这个数据怎么传出来?

方案一:通过 ref 拿子组件实例(不推荐)

createApp 方案中,你很难直接拿到弹窗内部组件的 ref,因为是动态创建的。

方案二:通过事件 / 回调传递(推荐)

改造一下,让自定义组件通过回调把数据"交"出来:

// 用一个中间变量承接表单组件的数据和方法
const formActions = { validate: null, getData: null }

const confirmed = await dialog.open({
  title: '编辑用户',
  width: '600px',
  content: h(EditUserForm, {
    userId: row.id,
    // 表单组件挂载后,把自己的方法暴露出来
    onReady: (actions) => {
      formActions.validate = actions.validate
      formActions.getData = actions.getData
    }
  }),
  onConfirm: async () => {
    // 先校验
    const valid = await formActions.validate()
    if (!valid) throw new Error('校验不通过') // 抛错可以阻止弹窗关闭
    // 再提交
    const data = formActions.getData()
    await api.updateUser(row.id, data)
    ElMessage.success('更新成功')
  }
})

表单组件那边:

<!-- components/EditUserForm.vue -->
<template>
  <el-form ref="formRef" :model="form" :rules="rules">
    <el-form-item label="姓名" prop="name">
      <el-input v-model="form.name" />
    </el-form-item>
    <el-form-item label="邮箱" prop="email">
      <el-input v-model="form.email" />
    </el-form-item>
  </el-form>
</template>

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

const props = defineProps<{
  userId: number
}>()

const emit = defineEmits<{
  ready: [actions: { validate: () => Promise<boolean>; getData: () => any }]
}>()

const formRef = ref()
const form = ref({ name: '', email: '' })

const rules = {
  name: [{ required: true, message: '请输入姓名' }],
  email: [{ required: true, message: '请输入邮箱' }]
}

onMounted(async () => {
  // 加载用户数据
  const data = await api.getUser(props.userId)
  form.value = data

  // 把校验和取数方法暴露给外部
  emit('ready', {
    validate: () => formRef.value.validate().catch(() => false),
    getData: () => ({ ...form.value })
  })
})
</script>

7.3 踩坑:onConfirm 抛错阻止关闭

注意上面 onConfirm 里的 throw new Error('校验不通过')。我们需要改造一下 BaseDialog 的确认逻辑,让它支持"校验不通过时不关闭":

// BaseDialog.vue 中修改 handleConfirm
const handleConfirm = async () => {
  if (props.options.onConfirm) {
    try {
      confirmLoading.value = true
      await props.options.onConfirm()
    } catch (e) {
      // onConfirm 抛错了,不关闭弹窗,只取消 loading
      confirmLoading.value = false
      return // 注意这里 return 了,不会走到下面的 close()
    } finally {
      confirmLoading.value = false
    }
  }
  close()
}

这个设计非常实用onConfirm 正常执行完 → 自动关闭;onConfirm 抛错 → 不关闭,用户可以修改后重试。

八、同理可得:抽屉(Drawer)服务

抽屉和弹窗的封装思路完全一致,只是底层组件从 el-dialog 换成 el-drawer。我们可以复用同一套逻辑:

// services/drawer.ts

import { createApp, ref, h } from 'vue'
import BaseDrawer from '@/components/BaseDrawer.vue'
import type { DrawerOptions } from '@/types/drawer'

export interface DrawerOptions {
  title?: string
  content?: string | VNode | Component
  /** 抽屉方向 */
  direction?: 'rtl' | 'ltr' | 'ttb' | 'btt'
  /** 抽屉宽度/高度 */
  size?: string | number
  onConfirm?: () => void | Promise<void>
  onCancel?: () => void
  onClosed?: () => void
}

function createDrawer(options: DrawerOptions): Promise<boolean> {
  // 和 createDialog 几乎一模一样
  // 只是内部渲染的是 BaseDrawer 组件
  return new Promise((resolve) => {
    const container = document.createElement('div')
    document.body.appendChild(container)

    let resolved = false
    const safeResolve = (val: boolean) => {
      if (resolved) return
      resolved = true
      resolve(val)
    }

    const mergedOptions = {
      ...options,
      onConfirm: async () => {
        await options.onConfirm?.()
        safeResolve(true)
      },
      onCancel: () => {
        options.onCancel?.()
        safeResolve(false)
      },
      onClosed: () => {
        options.onClosed?.()
        app.unmount()
        container.remove()
      }
    }

    const app = createApp({
      setup() {
        const drawerRef = ref()
        const onMounted = () => {
          setTimeout(() => drawerRef.value?.open(), 0)
        }
        return () =>
          h(BaseDrawer, {
            ref: drawerRef,
            options: mergedOptions,
            onVnodeMounted: onMounted
          })
      }
    })

    app.use(ElementPlus)
    app.mount(container)
  })
}

const drawer = {
  open(options: DrawerOptions): Promise<boolean> {
    return createDrawer(options)
  }
}

export default drawer

使用方式:

import drawer from '@/services/drawer'
import UserDetail from '@/components/UserDetail.vue'

const handleViewDetail = async (row) => {
  await drawer.open({
    title: '用户详情',
    size: '40%',
    direction: 'rtl',
    content: h(UserDetail, { userId: row.id })
  })
}

九、终极优化:抽取公共逻辑,一个工厂搞定

你会发现,Dialog 和 Drawer 的 create 函数长得几乎一样。我们可以抽一个工厂函数:

// composables/createOverlayService.ts

import { createApp, ref, h, type Component } from 'vue'
import ElementPlus from 'element-plus'

interface OverlayOptions {
  onConfirm?: () => void | Promise<void>
  onCancel?: () => void
  onClosed?: () => void
  [key: string]: any
}

/**
 * 覆盖层服务工厂
 * @param OverlayComponent 底层组件(BaseDialog 或 BaseDrawer)
 */
export function createOverlayService<T extends OverlayOptions>(
  OverlayComponent: Component
) {
  return function create(options: T): Promise<boolean> {
    return new Promise((resolve) => {
      const container = document.createElement('div')
      document.body.appendChild(container)

      let resolved = false
      const safeResolve = (val: boolean) => {
        if (resolved) return
        resolved = true
        resolve(val)
      }

      const mergedOptions: T = {
        ...options,
        onConfirm: async () => {
          await options.onConfirm?.()
          safeResolve(true)
        },
        onCancel: () => {
          options.onCancel?.()
          safeResolve(false)
        },
        onClosed: () => {
          options.onClosed?.()
          app.unmount()
          container.remove()
        }
      }

      const app = createApp({
        setup() {
          const overlayRef = ref()
          const onMounted = () => {
            setTimeout(() => overlayRef.value?.open(), 0)
          }
          return () =>
            h(OverlayComponent, {
              ref: overlayRef,
              options: mergedOptions,
              onVnodeMounted: onMounted
            })
        }
      })

      app.use(ElementPlus)
      app.mount(container)
    })
  }
}

然后 Dialog 和 Drawer 服务各只需要几行:

// services/dialog.ts
import { createOverlayService } from '@/composables/createOverlayService'
import BaseDialog from '@/components/BaseDialog.vue'
import type { DialogOptions } from '@/types/dialog'

const createDialog = createOverlayService<DialogOptions>(BaseDialog)

export default {
  confirm: (content, options?) => createDialog({ title: '确认', content, showCancel: true, ...options }),
  alert: (content, options?) => createDialog({ title: '提示', content, showCancel: false, ...options }),
  open: (options) => createDialog(options)
}
// services/drawer.ts
import { createOverlayService } from '@/composables/createOverlayService'
import BaseDrawer from '@/components/BaseDrawer.vue'
import type { DrawerOptions } from '@/types/drawer'

const createDrawer = createOverlayService<DrawerOptions>(BaseDrawer)

export default {
  open: (options) => createDrawer(options)
}

十、踩坑汇总与最佳实践

10.1 高频踩坑清单

# 原因 解决方案
1 弹窗里 Element Plus 组件不渲染 createApp 创建的是独立实例,没注册 Element Plus 新实例里也要 app.use(ElementPlus)
2 弹窗里拿不到 Pinia store 数据 独立实例没有注册 Pinia 新实例里也要 app.use(pinia)
3 关闭弹窗时内容"闪空" @close 而不是 @closed 里清理数据 使用 @closed 事件做清理
4 弹窗关闭后 DOM 节点没清理 忘了 container.remove() onClosedapp.unmount() + container.remove()
5 确认按钮点了弹窗就关了,但接口还没调完 onConfirm 没有 await 异步操作 onConfirm 支持返回 Promise,按钮自动 loading
6 表单校验失败弹窗也关了 没有处理 onConfirm 中的错误 onConfirm 抛错时 return 不调用 close()
7 多次快速点击打开了多个弹窗 没做防重复打开的控制 加一个标志位或用防抖
8 Promise 被 resolve 了两次 点确认后又触发了关闭按钮的逻辑 safeResolve 加标志位防止重复 resolve

10.2 选型建议

场景 推荐方案 原因
简单确认/提示 dialog.confirm() / dialog.alert() 一行代码搞定
弹窗内有简单表单 dialog.open() + h(FormComponent) 组件传 props + onReady 暴露方法
弹窗内有复杂页面级组件 还是考虑声明式 <el-dialog> 太复杂的组件动态创建会有各种边界问题
全局统一的删除确认 封装 useDeleteConfirm Hook 进一步收敛,一处修改全局生效

10.3 不要过度设计

最后说一句大实话:不是所有弹窗都需要命令式调用

如果一个弹窗:

  • 内部有很重的组件(富文本编辑器、地图等)
  • 需要和父组件频繁通信
  • 生命周期内要维护大量状态

那老老实实写在模板里,用 v-model 控制,反而更稳。

命令式服务最适合的场景是:轻量级、一次性、确认类的交互。别拿着锤子看什么都是钉子。

十一、完整项目结构一览

src/
├── components/
│   ├── BaseDialog.vue          # 基础弹窗组件
│   └── BaseDrawer.vue          # 基础抽屉组件
├── composables/
│   └── createOverlayService.ts # 覆盖层服务工厂函数
├── services/
│   ├── dialog.ts               # Dialog 服务(confirm / alert / open)
│   └── drawer.ts               # Drawer 服务
├── types/
│   ├── dialog.ts               # Dialog 配置类型定义
│   └── drawer.ts               # Drawer 配置类型定义
└── plugins/
    └── index.ts                # 插件统一注册

总结

阶段 做了什么 解决了什么问题
原始写法 v-if + ref 控制 能用,但状态散乱、模板臃肿
配置式组件 把选项抽成对象传入 复用性提升,但模板里还得放组件
回调模式 onConfirm / onCancel 逻辑集中了,但回调嵌套还是烦
Promise 化 await 接收结果 流程清晰如读代码,告别回调地狱
全局服务 dialog.confirm() 一行调用 任何地方都能用,零模板侵入
工厂抽象 Dialog/Drawer 共用一套创建逻辑 代码精简,扩展方便(Popover 服务也能用)

从"弹窗地狱"到"一行 await",核心就是三个关键词:配置化、回调化、Promise 化

掌握这套封装思路,不仅仅是弹窗——任何"打开 → 交互 → 关闭"的场景(抽屉、气泡确认、全屏预览等),都可以用同样的套路搞定。


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

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

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

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

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

昨天 — 2026年3月1日首页

表单最佳实践:从 v-model 到自定义表单组件(含校验)

作者 SuperEugene
2026年3月1日 15:57

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

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

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

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

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

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

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

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

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

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

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

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

一、v-model 到底是什么?——先把"语法糖"这三个字吃透

很多人写了好几年 Vue,天天用 v-model,但如果问一句:"v-model 的本质是什么?",不少人只能说出"双向绑定"四个字就卡住了。

一句话结论:v-model 是一颗语法糖,它帮你把"传值进去 + 监听变化抛出来"这两步合成了一步。

1.1 在原生元素上:v-model = :value + @input

先看最基础的用法:

<template>
  <input v-model="username" />
</template>

<script setup>
import { ref } from 'vue'
const username = ref('')
</script>

这段代码等价于:

<template>
  <input :value="username" @input="username = $event.target.value" />
</template>

<script setup>
import { ref } from 'vue'
const username = ref('')
</script>

看到了吗?Vue 帮你做了两件事:

  1. username 的值通过 :value 绑定到 input 上(数据 → 视图)
  2. 监听 input 的 input 事件,拿到用户输入的新值,赋回给 username(视图 → 数据)

这就是所谓的"双向绑定"——但本质上并不神秘,就是一个 prop 传入 + 一个事件抛出的简写。

踩坑提醒: 不同的原生表单元素,v-model 背后绑定的属性和事件是不一样的:

元素 绑定的属性 监听的事件
<input type="text"> value input
<input type="checkbox"> checked change
<select> value change
<textarea> value input

所以当你用原生 checkbox 配合 v-model 时,它走的是 checked + change,别和 text input 搞混。

1.2 一个常见的新手困惑:v-model 和 :value 能一起写吗?

不能。 写了 v-model 就不要再手动写 :value,因为 v-model 已经包含了 :value 的行为。如果你两个都写,Vue 会在控制台警告你冲突。

<!-- ❌ 错误写法 -->
<input v-model="username" :value="username" />

<!-- ✅ 二选一 -->
<input v-model="username" />
<!-- 或者 -->
<input :value="username" @input="username = $event.target.value" />

二、Vue 3 自定义组件的 v-model——规则变了,别用 Vue 2 的老习惯

如果说原生元素上的 v-model 是"开胃菜",那自定义组件上的 v-model 才是日常业务中真正高频使用的。而且 Vue 3 对 v-model 的机制做了重大改动,这里是很多从 Vue 2 迁移过来的同学最容易踩坑的地方。

2.1 Vue 2 vs Vue 3 的对比

特性 Vue 2 Vue 3
默认 prop 名 value modelValue
默认事件名 input update:modelValue
多个 v-model ❌ 不支持(要用 .sync ✅ 原生支持
.sync 修饰符 ❌ 移除了,用 v-model:xxx 替代

2.2 最基础的自定义组件 v-model

场景: 封装一个自定义输入框组件 MyInput

子组件 MyInput.vue

<template>
  <div class="my-input-wrapper">
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      class="my-input"
    />
  </div>
</template>

<script setup>
defineProps({
  modelValue: {
    type: String,
    default: ''
  }
})

defineEmits(['update:modelValue'])
</script>

父组件使用:

<template>
  <MyInput v-model="username" />
  <p>你输入的是:{{ username }}</p>
</template>

<script setup>
import { ref } from 'vue'
import MyInput from './MyInput.vue'

const username = ref('')
</script>

拆解: 父组件写 v-model="username",Vue 3 会自动展开为:

<MyInput :modelValue="username" @update:modelValue="username = $event" />

所以子组件需要做两件事:

  1. modelValue 这个 prop 接收值
  2. 变化时通过 $emit('update:modelValue', newValue) 把新值抛出去

踩坑提醒: 事件名必须是 update:modelValue,中间是冒号,不是横杠、不是驼峰拼接。很多人写成 updateModelValue 或者 update-model-value,都是错的。

2.3 多个 v-model——Vue 3 的杀手级特性

Vue 2 时代,一个组件只能有一个 v-model,如果要双向绑定多个值,得用 .sync 修饰符,写起来很割裂。Vue 3 直接支持多个 v-model,优雅多了。

场景: 一个用户信息组件,同时需要双向绑定姓名和年龄。

子组件 UserFields.vue

<template>
  <div class="user-fields">
    <label>
      姓名:
      <input
        :value="name"
        @input="$emit('update:name', $event.target.value)"
      />
    </label>
    <label>
      年龄:
      <input
        type="number"
        :value="age"
        @input="$emit('update:age', Number($event.target.value))"
      />
    </label>
  </div>
</template>

<script setup>
defineProps({
  name: { type: String, default: '' },
  age: { type: Number, default: 0 }
})

defineEmits(['update:name', 'update:age'])
</script>

父组件使用:

<template>
  <UserFields v-model:name="form.name" v-model:age="form.age" />
  <p>姓名:{{ form.name }},年龄:{{ form.age }}</p>
</template>

<script setup>
import { reactive } from 'vue'
import UserFields from './UserFields.vue'

const form = reactive({
  name: '',
  age: 0
})
</script>

v-model:name="form.name" 展开后就是 :name="form.name" @update:name="form.name = $event"。规则和默认的 v-model 一样,只是把 modelValue 换成了你自己指定的 prop 名。

2.4 defineModel()——Vue 3.4+ 的终极简化

从 Vue 3.4 开始,defineModel() 正式转正(之前是实验性 API)。它让自定义组件的 v-model 写法大幅简化。

改造上面的 MyInput.vue

<template>
  <div class="my-input-wrapper">
    <input v-model="model" class="my-input" />
  </div>
</template>

<script setup>
const model = defineModel({ type: String, default: '' })
</script>

没有了手动 defineProps + defineEmits,也不用自己写 $emitdefineModel() 返回的是一个 ref,你直接用 v-model 绑定到原生 input 上就行,它会自动帮你处理和父组件之间的双向通信。

多个 v-model 也支持:

<script setup>
const name = defineModel('name', { type: String, default: '' })
const age = defineModel('age', { type: Number, default: 0 })
</script>

选型建议:

  • 如果你的项目已经是 Vue 3.4+,强烈推荐用 defineModel(),代码量少、可读性好、不容易出错。
  • 如果项目还在 Vue 3.3 及以下,老老实实用 defineProps + defineEmits 的经典写法。
  • 别在生产项目里用实验性 API,等转正了再上。

三、表单组件拆分的实战思路——什么时候该拆?怎么拆?

知道了 v-model 的原理,接下来聊聊实战中最常遇到的问题:表单越写越长,该怎么拆?

3.1 不拆的代价

先看一个典型的"不拆"写法——一个订单表单,所有字段怼在一个组件里:

<template>
  <el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
    <!-- 基本信息 -->
    <el-form-item label="订单名称" prop="name">
      <el-input v-model="form.name" />
    </el-form-item>
    <el-form-item label="订单类型" prop="type">
      <el-select v-model="form.type">
        <el-option label="普通" value="normal" />
        <el-option label="加急" value="urgent" />
      </el-select>
    </el-form-item>
    <!-- 收货信息 -->
    <el-form-item label="收货人" prop="receiver">
      <el-input v-model="form.receiver" />
    </el-form-item>
    <el-form-item label="手机号" prop="phone">
      <el-input v-model="form.phone" />
    </el-form-item>
    <el-form-item label="地址" prop="address">
      <el-input v-model="form.address" />
    </el-form-item>
    <!-- 商品信息 -->
    <el-form-item label="商品名称" prop="product">
      <el-input v-model="form.product" />
    </el-form-item>
    <el-form-item label="数量" prop="quantity">
      <el-input-number v-model="form.quantity" :min="1" />
    </el-form-item>
    <el-form-item label="备注" prop="remark">
      <el-input v-model="form.remark" type="textarea" />
    </el-form-item>
    <el-form-item>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, reactive } from 'vue'

const formRef = ref()
const form = reactive({
  name: '', type: '', receiver: '', phone: '',
  address: '', product: '', quantity: 1, remark: ''
})
const rules = {
  name: [{ required: true, message: '请输入订单名称', trigger: 'blur' }],
  type: [{ required: true, message: '请选择订单类型', trigger: 'change' }],
  receiver: [{ required: true, message: '请输入收货人', trigger: 'blur' }],
  phone: [
    { required: true, message: '请输入手机号', trigger: 'blur' },
    { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
  ],
  address: [{ required: true, message: '请输入地址', trigger: 'blur' }],
  product: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
  quantity: [{ required: true, message: '请输入数量', trigger: 'change' }]
}

const handleSubmit = async () => {
  await formRef.value.validate()
  console.log('提交数据:', form)
}
</script>

这还只是 8 个字段。现实业务中,一个表单二三十个字段是常态,加上联动逻辑、动态显隐、异步校验……一个文件轻松上千行。

问题在哪?

  • 改一个区块的字段,要在一大坨模板里翻半天
  • 校验规则和字段分离,对应关系全靠 prop 字符串"人肉匹配"
  • 无法复用——收货信息这一块在别的页面也要用,你只能复制粘贴

3.2 拆分原则:按业务区块拆,不是按字段拆

核心原则:一个子表单组件 = 一个业务含义的区块。

以上面的订单表单为例,天然可以拆成三块:

  1. 基本信息 —— OrderBasicInfo.vue
  2. 收货信息 —— ReceiverInfo.vue
  3. 商品信息 —— ProductInfo.vue

3.3 拆分后的代码实现

子组件 ReceiverInfo.vue(收货信息区块):

<template>
  <el-form-item label="收货人" prop="receiver">
    <el-input :model-value="modelValue.receiver" @update:model-value="updateField('receiver', $event)" />
  </el-form-item>
  <el-form-item label="手机号" prop="phone">
    <el-input :model-value="modelValue.phone" @update:model-value="updateField('phone', $event)" />
  </el-form-item>
  <el-form-item label="地址" prop="address">
    <el-input :model-value="modelValue.address" @update:model-value="updateField('address', $event)" />
  </el-form-item>
</template>

<script setup>
const props = defineProps({
  modelValue: {
    type: Object,
    required: true
  }
})

const emit = defineEmits(['update:modelValue'])

const updateField = (field, value) => {
  emit('update:modelValue', { ...props.modelValue, [field]: value })
}
</script>

关键点解读:

  1. 子组件通过 modelValue 接收整个区块的数据对象(不是单个字段)
  2. 更新时,用展开运算符创建一个新对象{ ...props.modelValue, [field]: value }
  3. 整体抛出给父组件,让父组件拿到新值去更新

⚠️ 这里有一个非常重要的踩坑点:为什么不能直接修改 props?

你可能想:props.modelValue 是个对象,我直接 props.modelValue.receiver = '张三' 不行吗?

技术上可以,Vue 不会报错(对象是引用传递)。但这是一个非常坏的习惯。 原因:

  • 违反了 Vue 的"单向数据流"原则——数据应该从父组件流向子组件,子组件想改数据,应该通过事件通知父组件去改
  • 当组件层级变深、多个子组件共享同一份数据时,直接修改 props 会导致"数据在哪被改的"完全无法追踪
  • 在使用 Vue DevTools 调试时,直接改 props 不会触发事件记录,等于"偷偷改了但没人知道"

结论:永远通过 emit 通知父组件修改,哪怕多写几行代码。

父组件 OrderForm.vue(组装):

<template>
  <el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
    <h3>基本信息</h3>
    <OrderBasicInfo v-model="basicInfo" />

    <h3>收货信息</h3>
    <ReceiverInfo v-model="receiverInfo" />

    <h3>商品信息</h3>
    <ProductInfo v-model="productInfo" />

    <el-form-item>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, reactive, computed } from 'vue'
import OrderBasicInfo from './OrderBasicInfo.vue'
import ReceiverInfo from './ReceiverInfo.vue'
import ProductInfo from './ProductInfo.vue'

const formRef = ref()

const basicInfo = ref({ name: '', type: '' })
const receiverInfo = ref({ receiver: '', phone: '', address: '' })
const productInfo = ref({ product: '', quantity: 1, remark: '' })

// 组装完整表单数据(用于提交和校验)
const form = computed(() => ({
  ...basicInfo.value,
  ...receiverInfo.value,
  ...productInfo.value
}))

const rules = {
  name: [{ required: true, message: '请输入订单名称', trigger: 'blur' }],
  receiver: [{ required: true, message: '请输入收货人', trigger: 'blur' }],
  phone: [
    { required: true, message: '请输入手机号', trigger: 'blur' },
    { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
  ],
  // ...其他规则
}

const handleSubmit = async () => {
  await formRef.value.validate()
  console.log('提交数据:', form.value)
}
</script>

3.4 用 defineModel() 简化子组件(Vue 3.4+)

如果项目版本允许,子组件可以进一步简化:

<!-- ReceiverInfo.vue(Vue 3.4+ 简化版)-->
<template>
  <el-form-item label="收货人" prop="receiver">
    <el-input v-model="model.receiver" />
  </el-form-item>
  <el-form-item label="手机号" prop="phone">
    <el-input v-model="model.phone" />
  </el-form-item>
  <el-form-item label="地址" prop="address">
    <el-input v-model="model.address" />
  </el-form-item>
</template>

<script setup>
const model = defineModel({ type: Object, required: true })
</script>

注意: defineModel() 返回的 ref 本质上会对对象的属性修改进行追踪并自动触发 update:modelValue。但如果你需要更细粒度的控制(比如只在某些条件下才允许更新),还是用 defineProps + defineEmits 的显式写法更合适。

四、Element Plus 表单封装实战——来点真正的项目级经验

上面讲了拆分的基本思路,下面来看在 Element Plus 体系下,实际项目中常用的几个封装模式。

4.1 踩坑重灾区:el-form 的 prop 路径与嵌套对象

当表单数据是嵌套结构时,el-form-itemprop 需要写成路径形式,否则校验不生效。

<template>
  <el-form :model="form" :rules="rules" ref="formRef">
    <!-- ❌ 错误:prop 写成 "name",但数据在 form.basic.name -->
    <el-form-item label="姓名" prop="name">
      <el-input v-model="form.basic.name" />
    </el-form-item>

    <!-- ✅ 正确:prop 要写完整路径 -->
    <el-form-item label="姓名" prop="basic.name">
      <el-input v-model="form.basic.name" />
    </el-form-item>
  </el-form>
</template>

<script setup>
import { reactive } from 'vue'

const form = reactive({
  basic: { name: '', age: 0 },
  contact: { phone: '', email: '' }
})

const rules = {
  // 规则的 key 也要用完整路径
  'basic.name': [{ required: true, message: '请输入姓名', trigger: 'blur' }],
  'basic.age': [{ required: true, message: '请输入年龄', trigger: 'blur' }]
}
</script>

踩坑总结:

  • prop 的值必须和 form 对象中的路径一一对应
  • rules 对象的 key 也必须用相同的路径
  • 如果 prop 和实际数据路径对不上,校验静默失败——不报错、不提示、就是不校验,非常难排查

4.2 封装一个通用的表单弹窗组件

这是项目中使用频率最高的模式之一——点击按钮弹出表单弹窗,填写后提交。

<!-- FormDialog.vue -->
<template>
  <el-dialog
    :model-value="visible"
    @update:model-value="$emit('update:visible', $event)"
    :title="title"
    width="600px"
    :close-on-click-modal="false"
    @closed="handleClosed"
  >
    <el-form
      ref="formRef"
      :model="formData"
      :rules="rules"
      label-width="100px"
      @submit.prevent
    >
      <slot :form="formData" />
    </el-form>
    <template #footer>
      <el-button @click="$emit('update:visible', false)">取消</el-button>
      <el-button type="primary" :loading="loading" @click="handleConfirm">
        确定
      </el-button>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref, watch } from 'vue'

const props = defineProps({
  visible: { type: Boolean, default: false },
  title: { type: String, default: '表单' },
  rules: { type: Object, default: () => ({}) },
  initialData: { type: Object, default: () => ({}) }
})

const emit = defineEmits(['update:visible', 'confirm'])

const formRef = ref()
const formData = ref({})
const loading = ref(false)

// 每次打开弹窗时,用 initialData 初始化表单
watch(() => props.visible, (val) => {
  if (val) {
    formData.value = JSON.parse(JSON.stringify(props.initialData))
  }
})

const handleConfirm = async () => {
  try {
    await formRef.value.validate()
    loading.value = true
    emit('confirm', { ...formData.value })
  } catch {
    // 校验未通过,不做处理
  } finally {
    loading.value = false
  }
}

const handleClosed = () => {
  formRef.value?.resetFields()
}
</script>

父组件使用:

<template>
  <el-button @click="dialogVisible = true">新增用户</el-button>

  <FormDialog
    v-model:visible="dialogVisible"
    title="新增用户"
    :rules="rules"
    :initial-data="defaultForm"
    @confirm="handleConfirm"
  >
    <template #default="{ form }">
      <el-form-item label="用户名" prop="username">
        <el-input v-model="form.username" />
      </el-form-item>
      <el-form-item label="邮箱" prop="email">
        <el-input v-model="form.email" />
      </el-form-item>
    </template>
  </FormDialog>
</template>

<script setup>
import { ref } from 'vue'
import FormDialog from './FormDialog.vue'

const dialogVisible = ref(false)
const defaultForm = { username: '', email: '' }

const rules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
  ]
}

const handleConfirm = async (formData) => {
  // 调用接口提交
  console.log('提交的数据:', formData)
  dialogVisible.value = false
}
</script>

这个封装的核心设计:

设计点 为什么这么做
initialData 深拷贝 避免编辑时直接修改原始数据,取消后数据被"污染"
@closedresetFields 关闭动画结束后再清理,避免用户看到表单闪烁
:close-on-click-modal="false" 防止用户填了一半误触遮罩关闭
@submit.prevent 防止表单内按回车触发页面刷新
通过 slot 传入表单项 表单内容由调用方决定,弹窗组件只管"壳"和"行为"

踩坑提醒: resetFields() 只会重置到 el-form 初始挂载时的值,不是清空为空字符串。所以如果你在弹窗打开后才给 formData 赋值,resetFields 可能重置到的是空对象而不是你期望的初始值。这就是为什么我们用 watchvisible 变为 true 时就立即赋值——确保表单挂载时就有正确的初始数据。

4.3 编辑与新增共用同一个弹窗

实际业务中,新增和编辑往往共用一个表单弹窗,只是初始数据不同。

<template>
  <el-button @click="handleAdd">新增</el-button>
  <el-button @click="handleEdit(mockData)">编辑</el-button>

  <FormDialog
    v-model:visible="dialogVisible"
    :title="isEdit ? '编辑用户' : '新增用户'"
    :rules="rules"
    :initial-data="currentForm"
    @confirm="handleConfirm"
  >
    <template #default="{ form }">
      <el-form-item label="用户名" prop="username">
        <el-input v-model="form.username" :disabled="isEdit" />
      </el-form-item>
      <el-form-item label="邮箱" prop="email">
        <el-input v-model="form.email" />
      </el-form-item>
    </template>
  </FormDialog>
</template>

<script setup>
import { ref, computed } from 'vue'
import FormDialog from './FormDialog.vue'

const dialogVisible = ref(false)
const editingItem = ref(null)

const isEdit = computed(() => !!editingItem.value)

const defaultForm = { username: '', email: '' }
const currentForm = computed(() =>
  isEdit.value ? { ...editingItem.value } : { ...defaultForm }
)

const mockData = { id: 1, username: '张三', email: 'zhangsan@example.com' }

const handleAdd = () => {
  editingItem.value = null
  dialogVisible.value = true
}

const handleEdit = (item) => {
  editingItem.value = item
  dialogVisible.value = true
}

const rules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
  ]
}

const handleConfirm = async (formData) => {
  if (isEdit.value) {
    console.log('更新数据:', { id: editingItem.value.id, ...formData })
  } else {
    console.log('新增数据:', formData)
  }
  dialogVisible.value = false
}
</script>

五、表单校验的那些事——从基础到自定义

5.1 Element Plus 校验的基本运作方式

Element Plus 的表单校验基于 async-validator 这个库。理解它的工作流程:

用户输入 → 触发 trigger 事件(blur/change)→ 根据 prop 查找对应的 rules → 执行校验 → 显示/隐藏错误提示

5.2 自定义校验器(validator)

内置规则(required、min、max、pattern 等)能覆盖大多数场景,但碰到复杂逻辑就得用自定义 validator。

const rules = {
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    {
      validator: (rule, value, callback) => {
        if (value.length < 8) {
          callback(new Error('密码至少8位'))
        } else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) {
          callback(new Error('密码需包含大小写字母和数字'))
        } else {
          callback()
        }
      },
      trigger: 'blur'
    }
  ],
  confirmPassword: [
    { required: true, message: '请确认密码', trigger: 'blur' },
    {
      validator: (rule, value, callback) => {
        if (value !== form.password) {
          callback(new Error('两次密码不一致'))
        } else {
          callback()
        }
      },
      trigger: 'blur'
    }
  ]
}

注意事项:

  • callback 必须调用,不管校验通过还是失败。忘记调用会导致校验"卡死"——按钮一直 loading,表单提交不了也不报错
  • 校验通过时调用 callback()(不传参数)
  • 校验失败时调用 callback(new Error('错误信息'))

5.3 异步校验(如:检查用户名是否已存在)

const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    {
      validator: async (rule, value, callback) => {
        try {
          const { data } = await checkUsernameApi(value)
          if (data.exists) {
            callback(new Error('用户名已被占用'))
          } else {
            callback()
          }
        } catch {
          callback(new Error('校验失败,请稍后再试'))
        }
      },
      trigger: 'blur'
    }
  ]
}

踩坑提醒: 异步校验如果不做防抖,用户每输入一个字符都会发请求。建议给异步校验加一个 debounce

import { debounce } from 'lodash-es'

const checkUsername = debounce(async (value, callback) => {
  try {
    const { data } = await checkUsernameApi(value)
    data.exists ? callback(new Error('用户名已被占用')) : callback()
  } catch {
    callback(new Error('校验失败,请稍后再试'))
  }
}, 500)

const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { validator: (rule, value, callback) => checkUsername(value, callback), trigger: 'blur' }
  ]
}

5.4 动态表单项的校验

动态增删的表单项(比如"添加更多联系人"),校验规则需要跟着走。

<template>
  <el-form :model="form" ref="formRef" label-width="100px">
    <div v-for="(contact, index) in form.contacts" :key="index" class="contact-row">
      <el-form-item
        :label="'联系人' + (index + 1)"
        :prop="'contacts.' + index + '.name'"
        :rules="[{ required: true, message: '请输入姓名', trigger: 'blur' }]"
      >
        <el-input v-model="contact.name" />
      </el-form-item>
      <el-form-item
        label="电话"
        :prop="'contacts.' + index + '.phone'"
        :rules="[
          { required: true, message: '请输入电话', trigger: 'blur' },
          { pattern: /^1[3-9]\d{9}$/, message: '格式不正确', trigger: 'blur' }
        ]"
      >
        <el-input v-model="contact.phone" />
      </el-form-item>
      <el-button @click="removeContact(index)" type="danger" text>删除</el-button>
    </div>
    <el-button @click="addContact" type="primary" plain>添加联系人</el-button>
    <el-form-item>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, reactive } from 'vue'

const formRef = ref()
const form = reactive({
  contacts: [{ name: '', phone: '' }]
})

const addContact = () => {
  form.contacts.push({ name: '', phone: '' })
}

const removeContact = (index) => {
  form.contacts.splice(index, 1)
}

const handleSubmit = async () => {
  try {
    await formRef.value.validate()
    console.log('提交数据:', form.contacts)
  } catch {
    console.log('校验未通过')
  }
}
</script>

踩坑要点:

  • :prop 必须是动态的,格式为 '数组名.' + index + '.字段名'
  • 校验规则可以直接写在 el-form-item:rules 上,不用全放在 el-form:rules
  • v-for 一定要绑定 :key,且最好不要用 index 作为 key(删除中间项时会导致校验状态错乱)。推荐给每个 contact 加一个唯一 id

改进后的做法:

import { nanoid } from 'nanoid'

const addContact = () => {
  form.contacts.push({ id: nanoid(), name: '', phone: '' })
}
<div v-for="(contact, index) in form.contacts" :key="contact.id">

六、常见踩坑汇总

写了这么多,最后把文中提到的和额外的高频坑点汇总一下,方便速查:

# 踩坑点 现象 正确做法
1 v-model 和 :value 同时写 控制台警告,行为异常 二选一,不要混用
2 Vue 3 事件名写错 子组件修改不生效 必须是 update:modelValue
3 子组件直接修改 props 对象 短期没问题,长期数据流混乱 通过 emit 通知父组件修改
4 prop 路径和数据路径不匹配 校验静默失败 prop 值必须对应 form 对象的完整路径
5 validator 忘记调用 callback 表单提交"卡死" 无论通过还是失败都要调用 callback
6 resetFields 重置到空而非初始值 编辑弹窗关闭后数据异常 确保表单挂载时就有正确初始数据
7 动态表单 v-for 用 index 做 key 删除项后校验状态错乱 用唯一 id 作为 key
8 异步校验没做防抖 疯狂发请求 用 debounce 包装异步校验
9 弹窗表单 close-on-click-modal 填了一半误触关闭 设为 false
10 表单内按回车刷新页面 原生 form 默认提交行为 @submit.prevent

七、总结

回顾整篇文章的知识脉络:

v-model 本质(语法糖)
    ├─ 原生元素::value + @input
    └─ 自定义组件
         ├─ Vue 3 经典写法:defineProps + defineEmits
         ├─ Vue 3.4+ 简化:defineModel()
         └─ 多个 v-model:v-model:xxx
              │
              ▼
表单组件拆分
    ├─ 按业务区块拆分
    ├─ 子组件通过 v-model 和父组件通信
    └─ 永远不要直接修改 props
              │
              ▼
Element Plus 表单封装
    ├─ 嵌套对象的 prop 路径
    ├─ 通用表单弹窗组件
    └─ 新增/编辑共用弹窗
              │
              ▼
表单校验
    ├─ 自定义 validator
    ├─ 异步校验 + 防抖
    └─ 动态表单项校验

表单是前端日常工作中占比最大的 UI 模式之一。把 v-model 的本质搞清楚,把组件拆分的边界想明白,把 Element Plus 的校验机制摸透——这三件事做到了,你在日常表单开发中就能做到写得快、改得动、不踩坑


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

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

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

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

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

昨天以前首页

从 Vue2 到 Vue3:语法差异与迁移时最容易懵的点

作者 SuperEugene
2026年2月27日 16:00

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

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

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

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

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

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

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

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

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

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

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

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

一、为什么要写这篇文章?

Vue3 已经是官方默认推荐版本,但很多团队的存量项目仍然在 Vue2 上跑。即便你已经开始用 Vue3 了,也很可能是"Options API 的写法 + <script setup> 的壳"——形式换了,思维没换。

这篇文章不讲玄学的底层原理,只讲一个核心问题

日常写代码到底该怎么选、为什么这么选、踩坑会踩在哪?

我们会把 Vue2 的 data / props / computed / methods / watch / 生命周期 和 Vue3 的 Composition API 做一次逐项对照,每一项都给出完整的代码示例和踩坑说明。

二、先建立一个全局视角:Options API vs Composition API

在动手对比之前,先花 30 秒看一张对照表,心里有个全貌:

关注点 Vue2(Options API) Vue3(Composition API / <script setup>
响应式数据 data() ref() / reactive()
接收外部参数 props 选项 defineProps()
计算属性 computed 选项 computed() 函数
方法 methods 选项 普通函数声明
侦听器 watch 选项 watch() / watchEffect()
生命周期 created / mounted … onMounted / onUnmounted …
模板访问 this.xxx 直接用变量名(<script setup> 自动暴露)

一句话总结:Vue2 按"选项类型"组织代码(数据放一块、方法放一块);Vue3 按"逻辑关注点"组织代码(一个功能的数据+方法+侦听可以放在一起)。

三、逐项对比 + 完整示例 + 踩坑点

3.1 响应式数据:data()ref() / reactive()

Vue2 写法

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ user.name }} - {{ user.age }}</p>
    <button @click="add">+1</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      count: 0,
      user: {
        name: '张三',
        age: 25
      }
    }
  },
  methods: {
    add() {
      this.count++
      this.user.age++
    }
  }
}
</script>

Vue2 里一切都挂在 this 上,data() 返回的对象会被 Vue 内部用 Object.defineProperty 做递归劫持,所以你只要 this.count++,视图就会更新。简单粗暴,上手友好。

Vue3 写法(<script setup>

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ user.name }} - {{ user.age }}</p>
    <button @click="add">+1</button>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

// 基本类型 → 用 ref
const count = ref(0)

// 对象类型 → 用 reactive
const user = reactive({
  name: '张三',
  age: 25
})

function add() {
  count.value++   // ← 注意:ref 在 JS 里要 .value
  user.age++      // ← reactive 对象不需要 .value
}
</script>

踩坑重灾区

坑 1:ref.value 到底什么时候要加?

这是从 Vue2 转过来最高频的困惑,记住一个口诀:

模板里不加,JS 里要加。

<template>
  <!-- 模板中直接用,Vue 会自动解包 -->
  <p>{{ count }}</p>
</template>

<script setup>
import { ref } from 'vue'
const count = ref(0)

// JS 中必须 .value
console.log(count.value) // 0
count.value++
</script>

为什么模板里不用加?因为 Vue 的模板编译器遇到 ref 时会自动帮你插入 .value,这是编译期的语法糖。但在 <script> 里你是在写原生 JS,Vue 管不到,所以必须手动 .value

坑 2:refreactive 到底选哪个?

这是社区吵了很久的问题。我的实战建议(也是 Vue 官方文档推荐的倾向):

场景 推荐 原因
基本类型(number / string / boolean) ref() reactive() 不支持基本类型
对象/数组,且不会被整体替换 reactive() 不用到处写 .value,更清爽
对象/数组,但可能被整体替换 ref() reactive() 整体替换会丢失响应性
拿不准的时候 ref() 全部用 ref 不会出错,reactive 有限制

坑 3:reactive 的解构陷阱 —— 这个真的会坑到你

<script setup>
import { reactive } from 'vue'

const user = reactive({ name: '张三', age: 25 })

// ❌ 错误:解构后变量失去响应性!
let { name, age } = user
age++  // 视图不会更新,因为 age 现在只是一个普通的数字 25

// ✅ 正确做法1:不解构,直接用
user.age++

// ✅ 正确做法2:用 toRefs 解构
import { toRefs } from 'vue'
const { name: nameRef, age: ageRef } = toRefs(user)
ageRef.value++  // 视图会更新(注意变成了 ref,需要 .value)
</script>

为什么会这样?因为 reactive 的响应性是挂在对象的属性访问上的(基于 Proxy),一旦你把属性值解构出来赋给一个新变量,那个新变量只是一个普通的 JS 值,和原来的 Proxy 对象已经没有关系了。

坑 4:reactive 整体替换会丢失响应性

<script setup>
import { reactive, ref } from 'vue'

let state = reactive({ list: [1, 2, 3] })

// ❌ 错误:整体替换,模板拿到的还是旧的那个对象
state = reactive({ list: [4, 5, 6] })  
// 此时模板绑定的引用还指向旧对象,视图不会更新

// ✅ 正确做法1:修改属性而不是替换对象
state.list = [4, 5, 6]  // 这样是OK的

// ✅ 正确做法2:需要整体替换的场景,改用 ref
const state2 = ref({ list: [1, 2, 3] })
state2.value = { list: [4, 5, 6] }  // 没问题,视图正常更新
</script>

这也是我建议"拿不准就用 ref"的原因——ref 不存在这个问题,因为你永远是通过 .value 赋值,Vue 能追踪到。


3.2 Props:props 选项defineProps()

Vue2 写法

<!-- 子组件 UserCard.vue -->
<template>
  <div class="card">
    <h3>{{ name }}</h3>
    <p>年龄:{{ age }}</p>
    <p>是否VIP:{{ isVip ? '是' : '否' }}</p>
  </div>
</template>

<script>
export default {
  props: {
    name: {
      type: String,
      required: true
    },
    age: {
      type: Number,
      default: 18
    },
    isVip: {
      type: Boolean,
      default: false
    }
  },
  mounted() {
    // 通过 this 访问
    console.log(this.name, this.age)
  }
}
</script>
<!-- 父组件中使用 -->
<UserCard name="李四" :age="30" is-vip />

Vue3 写法(<script setup>

<!-- 子组件 UserCard.vue -->
<template>
  <div class="card">
    <h3>{{ name }}</h3>
    <p>年龄:{{ age }}</p>
    <p>是否VIP:{{ isVip ? '是' : '否' }}</p>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'

// defineProps 是编译器宏,不需要 import
const props = defineProps({
  name: {
    type: String,
    required: true
  },
  age: {
    type: Number,
    default: 18
  },
  isVip: {
    type: Boolean,
    default: false
  }
})

onMounted(() => {
  // 不再有 this,直接用 props 对象
  console.log(props.name, props.age)
})
</script>

如果你用 TypeScript,还可以用纯类型声明的写法,更加简洁:

<script setup lang="ts">
interface Props {
  name: string
  age?: number
  isVip?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  age: 18,
  isVip: false
})
</script>

踩坑重灾区

坑 1:defineProps 不需要 import,但 IDE 可能会报红

definePropsdefineEmitsdefineExpose 这些都是编译器宏(compiler macro),在编译阶段就被处理掉了,运行时并不存在。所以不需要 import

如果你的 ESLint 报 'defineProps' is not defined,那是 ESLint 配置问题,需要在 .eslintrc 里配置:

// .eslintrc.js
module.exports = {
  env: {
    'vue/setup-compiler-macros': true
  }
}

或者升级到较新版本的 eslint-plugin-vue(v9+),它默认已经支持了。

坑 2:Props 解构也会丢失响应性(Vue 3.2 及以前)

<script setup>
const props = defineProps({ count: Number })

// ❌ Vue 3.2及以前:解构会丢失响应性
const { count } = props  // count 变成普通值,父组件更新后这里不会变

// ✅ 保持响应性的做法
import { toRefs } from 'vue'
const { count: countRef } = toRefs(props)
// 或者直接用 props.count
</script>

好消息:Vue 3.5+ 引入了响应式 Props 解构(Reactive Props Destructure),如果你的项目版本够新,可以直接解构:

<script setup>
// Vue 3.5+ 可以直接解构,自动保持响应性
const { count = 0 } = defineProps({ count: Number })
// count 是响应式的,可以直接在模板中用
</script>

但如果你的项目还在 3.4 或更早版本上,老老实实用 props.counttoRefs 是最稳的。


3.3 Computed:computed 选项computed() 函数

Vue2 写法

<template>
  <div>
    <p>原价:{{ price }} 元</p>
    <p>折后价:{{ discountedPrice }} 元</p>
    <input v-model="fullName" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      price: 100,
      discount: 0.8,
      firstName: '张',
      lastName: '三'
    }
  },
  computed: {
    // 只读计算属性
    discountedPrice() {
      return (this.price * this.discount).toFixed(2)
    },
    // 可读可写计算属性
    fullName: {
      get() {
        return this.firstName + this.lastName
      },
      set(val) {
        // 假设第一个字是姓,后面是名
        this.firstName = val.charAt(0)
        this.lastName = val.slice(1)
      }
    }
  }
}
</script>

Vue3 写法

<template>
  <div>
    <p>原价:{{ price }} 元</p>
    <p>折后价:{{ discountedPrice }} 元</p>
    <input v-model="fullName" />
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const price = ref(100)
const discount = ref(0.8)
const firstName = ref('张')
const lastName = ref('三')

// 只读计算属性 —— 传一个 getter 函数
const discountedPrice = computed(() => {
  return (price.value * discount.value).toFixed(2)
})

// 可读可写计算属性 —— 传一个对象
const fullName = computed({
  get: () => firstName.value + lastName.value,
  set: (val) => {
    firstName.value = val.charAt(0)
    lastName.value = val.slice(1)
  }
})
</script>

踩坑重灾区

坑 1:computed 里千万别做"副作用"操作

这条 Vue2 和 Vue3 都一样,但很多人还是会犯:

// ❌ 错误示范:在 computed 里修改别的状态、发请求、操作 DOM
const total = computed(() => {
  otherState.value = 'changed'  // 副作用!
  fetch('/api/log')             // 副作用!
  return items.value.reduce((sum, item) => sum + item.price, 0)
})

// ✅ computed 应该是纯函数,只根据依赖算出一个值
const total = computed(() => {
  return items.value.reduce((sum, item) => sum + item.price, 0)
})

computed 的设计初衷就是"根据已有状态派生出新状态",它有缓存机制——只有依赖变了才重新计算。如果你往里面塞副作用,会导致不可预测的执行时机和执行次数。

坑 2:别把 computed 和 methods 搞混了

Vue2 老手可能觉得"computed 和 method 返回的值不是一样吗",但核心区别是缓存

<script setup>
import { ref, computed } from 'vue'

const list = ref([1, 2, 3, 4, 5])

// computed:有缓存,list 不变就不会重新执行
const total = computed(() => {
  console.log('computed 执行了')
  return list.value.reduce((a, b) => a + b, 0)
})

// 普通函数:每次模板渲染都会重新执行
function getTotal() {
  console.log('function 执行了')
  return list.value.reduce((a, b) => a + b, 0)
}
</script>

<template>
  <!-- 假设模板里用了3次 -->
  <p>{{ total }} {{ total }} {{ total }}</p>
  <!-- computed 只会打印1次 log,函数会打印3次 -->
  <p>{{ getTotal() }} {{ getTotal() }} {{ getTotal() }}</p>
</template>

结论:需要缓存、依赖响应式数据派生值的用 computed;需要执行某个动作(点击事件等)的用普通函数。


3.4 Methods:methods 选项 → 普通函数

Vue2 写法

<template>
  <div>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
    <button @click="incrementBy(5)">+5</button>
    <button @click="reset">重置</button>
  </div>
</template>

<script>
export default {
  data() {
    return { count: 0 }
  },
  methods: {
    increment() {
      this.count++
    },
    incrementBy(n) {
      this.count += n
    },
    reset() {
      this.count = 0
      this.logAction('reset')  // 方法之间互相调用
    },
    logAction(action) {
      console.log(`[${new Date().toLocaleTimeString()}] 执行了: ${action}`)
    }
  }
}
</script>

Vue2 的 methods 是一个选项对象,所有方法平铺在里面,互相调用要通过 this

Vue3 写法

<template>
  <div>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
    <button @click="incrementBy(5)">+5</button>
    <button @click="reset">重置</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}

function incrementBy(n) {
  count.value += n
}

function logAction(action) {
  console.log(`[${new Date().toLocaleTimeString()}] 执行了: ${action}`)
}

function reset() {
  count.value = 0
  logAction('reset')  // 直接调用,不需要 this
}
</script>

关键差异说明

Vue3 里没有 methods 这个概念了——就是普通的 JavaScript 函数。在 <script setup> 中声明的函数会自动暴露给模板,不需要额外 return。

这带来几个实质性的好处:

  1. 不再需要 this:函数直接闭包引用变量,没有 this 指向问题
  2. 可以用箭头函数:Vue2 的 methods 里不建议用箭头函数(会导致 this 指向错误),Vue3 随意用
  3. 方法可以和相关数据放在一起:不用再在 datamethods 之间跳来跳去
<script setup>
import { ref } from 'vue'

// ———— 计数器相关逻辑 ————
const count = ref(0)
const increment = () => count.value++  // 箭头函数完全OK
const reset = () => (count.value = 0)

// ———— 用户信息相关逻辑 ————
const username = ref('')
const updateUsername = (name) => (username.value = name)
</script>

看到没?数据和操作数据的方法紧挨在一起,按"功能"而不是按"类型"组织。这就是 Composition API 的核心思想——当组件逻辑复杂的时候,不用在 datacomputedmethodswatch 之间反复横跳。


3.5 Watch:watch 选项watch() / watchEffect()

Vue2 写法

<script>
export default {
  data() {
    return {
      keyword: '',
      user: { name: '张三', age: 25 }
    }
  },
  watch: {
    // 基础用法
    keyword(newVal, oldVal) {
      console.log(`搜索词变了:${oldVal} → ${newVal}`)
      this.doSearch(newVal)
    },
    // 深度侦听
    user: {
      handler(newVal) {
        console.log('user 变了', newVal)
      },
      deep: true,
      immediate: true  // 创建时立即执行一次
    }
  },
  methods: {
    doSearch(kw) { /* ... */ }
  }
}
</script>

Vue3 写法

<script setup>
import { ref, reactive, watch, watchEffect } from 'vue'

const keyword = ref('')
const user = reactive({ name: '张三', age: 25 })

// ——— watch:和 Vue2 类似,显式指定侦听源 ———

// 侦听 ref
watch(keyword, (newVal, oldVal) => {
  console.log(`搜索词变了:${oldVal} → ${newVal}`)
  doSearch(newVal)
})

// 侦听 reactive 对象的某个属性(注意:要用 getter 函数)
watch(
  () => user.age,
  (newAge, oldAge) => {
    console.log(`年龄变了:${oldAge} → ${newAge}`)
  }
)

// 侦听整个 reactive 对象(自动深度侦听)
watch(user, (newVal) => {
  console.log('user 变了', newVal)
})

// 加选项:立即执行
watch(keyword, (newVal) => {
  doSearch(newVal)
}, { immediate: true })

// ——— watchEffect:自动收集依赖,不用指定侦听源 ———
watchEffect(() => {
  // 回调里用到了哪些响应式数据,就自动侦听哪些
  console.log(`当前搜索词:${keyword.value},用户:${user.name}`)
})

function doSearch(kw) { /* ... */ }
</script>

watch vs watchEffect 怎么选?

特性 watch watchEffect
需要指定侦听源 否(自动收集依赖)
能拿到 oldValue 不能
默认是否立即执行 否(可设 immediate: true 是(创建时立即执行一次)
适合场景 需要精确控制"侦听谁"、需要新旧值对比 "用到啥就侦听啥",简化写法

我的实战建议:大多数场景用 watch,因为它意图更明确——看代码就知道你在侦听什么。watchEffect 适合那种"把几个数据凑一起做点事、不关心谁变了"的简单场景。

踩坑重灾区

坑 1:侦听 reactive 对象的属性,必须用 getter 函数

const user = reactive({ name: '张三', age: 25 })

// ❌ 错误:直接写 user.age,这只是传了个数字 25 进去
watch(user.age, (val) => { /* 永远不会触发 */ })

// ✅ 正确:传一个 getter 函数
watch(() => user.age, (val) => { console.log(val) })

原因很简单:user.age 在传参时就已经求值了,得到数字 25——一个普通的数字不是响应式的,Vue 没法侦听它。用 () => user.age 则是传了一个函数,Vue 每次执行这个函数时都会触发 Proxy 的 get 拦截,从而建立依赖追踪。

坑 2:watch 的清理——组件卸载后还在跑?

// 在 <script setup> 顶层调用的 watch 会自动与组件绑定
// 组件卸载时自动停止,不用手动处理
watch(keyword, (val) => { /* ... */ })

// 但如果你在异步回调或条件语句里创建 watch,就需要手动停止
let stop
setTimeout(() => {
  stop = watch(keyword, (val) => { /* ... */ })
}, 1000)

// 需要停止时调用
// stop()
</script>

3.6 生命周期:选项式 → 组合式

对照表

Vue2(Options API) Vue3(Composition API) 说明
beforeCreate 不需要(setup 本身就是) <script setup> 的代码就运行在这个时机
created 不需要(setup 本身就是) 同上
beforeMount onBeforeMount() DOM 挂载前
mounted onMounted() DOM 挂载后
beforeUpdate onBeforeUpdate() 数据变了、DOM 更新前
updated onUpdated() DOM 更新后
beforeDestroy onBeforeUnmount() 卸载前(注意改名了!)
destroyed onUnmounted() 卸载后(注意改名了!)

完整示例

<!-- Vue2 -->
<script>
export default {
  data() {
    return { timer: null }
  },
  created() {
    console.log('created: 可以访问数据了')
    this.fetchData()
  },
  mounted() {
    console.log('mounted: DOM 准备好了')
    this.timer = setInterval(() => {
      console.log('tick')
    }, 1000)
  },
  beforeDestroy() {
    clearInterval(this.timer)
    console.log('beforeDestroy: 清理定时器')
  }
}
</script>
<!-- Vue3 -->
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const timer = ref(null)

// <script setup> 中的顶层代码 ≈ created
console.log('setup: 可以访问数据了')
fetchData()

onMounted(() => {
  console.log('onMounted: DOM 准备好了')
  timer.value = setInterval(() => {
    console.log('tick')
  }, 1000)
})

onBeforeUnmount(() => {
  clearInterval(timer.value)
  console.log('onBeforeUnmount: 清理定时器')
})

async function fetchData() { /* ... */ }
</script>

踩坑重灾区

坑 1:beforeDestroyonBeforeUnmount,名字改了!

Vue3 把 destroy 相关的钩子全部改名为 unmount

  • beforeDestroyonBeforeUnmount
  • destroyedonUnmounted

如果你用 Options API 写 Vue3 组件(是的,Vue3 也支持 Options API),那对应的选项名也变了:beforeUnmountunmounted

坑 2:不要在 setup 顶层做 DOM 操作

<script setup>
// ❌ 这里 DOM 还没挂载!
document.querySelector('.my-el')  // null

// ✅ DOM 操作要放到 onMounted 里
import { onMounted } from 'vue'
onMounted(() => {
  document.querySelector('.my-el')  // OK
})
</script>

<script setup> 的顶层代码执行时机等同于 beforeCreate + created,这时候 DOM 还不存在。


3.7 Emits:this.$emit()defineEmits()

Vue2 写法

<!-- 子组件 -->
<script>
export default {
  methods: {
    handleClick() {
      this.$emit('update', { id: 1, name: '新名称' })
      this.$emit('close')
    }
  }
}
</script>

<!-- 父组件 -->
<ChildComponent @update="onUpdate" @close="onClose" />

Vue3 写法

<!-- 子组件 -->
<script setup>
const emit = defineEmits(['update', 'close'])

// 或者带类型校验(TypeScript)
// const emit = defineEmits<{
//   (e: 'update', payload: { id: number; name: string }): void
//   (e: 'close'): void
// }>()

function handleClick() {
  emit('update', { id: 1, name: '新名称' })
  emit('close')
}
</script>

<!-- 父组件(用法不变) -->
<ChildComponent @update="onUpdate" @close="onClose" />

Vue3 要求显式声明组件会触发哪些事件。这不仅仅是规范,还有一个实际好处:Vue3 会把未声明的事件名当作原生 DOM 事件处理。如果你不声明 emits,给组件绑定 @click,这个 click 会直接穿透到子组件的根元素上。

四、一个完整的实战对比:Todo List

最后,用一个麻雀虽小五脏俱全的 Todo List,把上面所有知识点串起来。

Vue2 版本

<template>
  <div class="todo-app">
    <h2>待办清单(共 {{ activeCount }} 项未完成)</h2>
    <div class="input-bar">
      <input
        v-model="newTodo"
        @keyup.enter="addTodo"
        placeholder="输入待办事项..."
      />
      <button @click="addTodo" :disabled="!canAdd">添加</button>
    </div>
    <ul>
      <li v-for="todo in filteredTodos" :key="todo.id">
        <input type="checkbox" v-model="todo.done" />
        <span :class="{ done: todo.done }">{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">删除</button>
      </li>
    </ul>
    <div class="filters">
      <button @click="filter = 'all'">全部</button>
      <button @click="filter = 'active'">未完成</button>
      <button @click="filter = 'completed'">已完成</button>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      newTodo: '',
      nextId: 1,
      filter: 'all',
      todos: []
    }
  },
  computed: {
    canAdd() {
      return this.newTodo.trim().length > 0
    },
    activeCount() {
      return this.todos.filter(t => !t.done).length
    },
    filteredTodos() {
      if (this.filter === 'active') return this.todos.filter(t => !t.done)
      if (this.filter === 'completed') return this.todos.filter(t => t.done)
      return this.todos
    }
  },
  watch: {
    todos: {
      handler(newTodos) {
        localStorage.setItem('todos', JSON.stringify(newTodos))
      },
      deep: true
    }
  },
  created() {
    const saved = localStorage.getItem('todos')
    if (saved) {
      this.todos = JSON.parse(saved)
      this.nextId = this.todos.length
        ? Math.max(...this.todos.map(t => t.id)) + 1
        : 1
    }
  },
  methods: {
    addTodo() {
      if (!this.canAdd) return
      this.todos.push({
        id: this.nextId++,
        text: this.newTodo.trim(),
        done: false
      })
      this.newTodo = ''
    },
    removeTodo(id) {
      this.todos = this.todos.filter(t => t.id !== id)
    }
  }
}
</script>

Vue3 版本

<template>
  <div class="todo-app">
    <h2>待办清单(共 {{ activeCount }} 项未完成)</h2>
    <div class="input-bar">
      <input
        v-model="newTodo"
        @keyup.enter="addTodo"
        placeholder="输入待办事项..."
      />
      <button @click="addTodo" :disabled="!canAdd">添加</button>
    </div>
    <ul>
      <li v-for="todo in filteredTodos" :key="todo.id">
        <input type="checkbox" v-model="todo.done" />
        <span :class="{ done: todo.done }">{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">删除</button>
      </li>
    </ul>
    <div class="filters">
      <button @click="filter = 'all'">全部</button>
      <button @click="filter = 'active'">未完成</button>
      <button @click="filter = 'completed'">已完成</button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch } from 'vue'

// ———— 状态 ————
const newTodo = ref('')
const filter = ref('all')
const todos = ref([])
let nextId = 1

// ———— 初始化(等同于 created) ————
const saved = localStorage.getItem('todos')
if (saved) {
  todos.value = JSON.parse(saved)
  nextId = todos.value.length
    ? Math.max(...todos.value.map(t => t.id)) + 1
    : 1
}

// ———— 计算属性 ————
const canAdd = computed(() => newTodo.value.trim().length > 0)

const activeCount = computed(() => {
  return todos.value.filter(t => !t.done).length
})

const filteredTodos = computed(() => {
  if (filter.value === 'active') return todos.value.filter(t => !t.done)
  if (filter.value === 'completed') return todos.value.filter(t => t.done)
  return todos.value
})

// ———— 侦听器 ————
watch(todos, (newTodos) => {
  localStorage.setItem('todos', JSON.stringify(newTodos))
}, { deep: true })

// ———— 方法 ————
function addTodo() {
  if (!canAdd.value) return
  todos.value.push({
    id: nextId++,
    text: newTodo.value.trim(),
    done: false
  })
  newTodo.value = ''
}

function removeTodo(id) {
  todos.value = todos.value.filter(t => t.id !== id)
}
</script>

对比两个版本你会发现:模板部分完全一样,变化全在 <script> 里。这也是 Vue3 设计的一个巧妙之处——模板语法几乎没有 breaking change,迁移成本主要在 JS 逻辑层。

五、迁移时的高频"懵圈"清单

最后汇总一下,从 Vue2 迁到 Vue3,最容易懵的点:

序号 懵圈点 一句话解惑
1 ref.value 什么时候加? 模板里不加,JS 里加
2 ref 还是 reactive 拿不准就全用 ref,不会出错
3 reactive 解构丢失响应性 toRefs() 解构,或者不解构
4 this 去哪了? 没有了,<script setup> 里直接用变量和函数
5 defineProps / defineEmits 要 import 吗? 不用,它们是编译器宏
6 beforeDestroy 不生效了? 改名了,叫 onBeforeUnmount
7 created 里的逻辑放哪? 直接写在 <script setup> 顶层
8 watch 侦听 reactive 属性无效? 要用 getter 函数 () => obj.prop
9 watchwatchEffect 选哪个? 大多数场景用 watch,意图更清晰
10 组件暴露方法给父组件怎么办? defineExpose({ methodName })

六、结语

Vue3 的 Composition API 不是为了"炫技"而存在的,它解决的是一个非常现实的问题:当组件逻辑变复杂后,Options API 的代码会像面条一样——数据在上面,方法在下面,watch 在中间,改一个功能要上下反复跳。

Composition API 让你可以按逻辑关注点把代码组织在一起,甚至抽成可复用的 composables(组合式函数),这才是它真正的威力所在。

但说实话,不需要一步到位。Vue3 完全兼容 Options API,你可以:

  1. 新组件用 <script setup> + Composition API
  2. 老组件维护时逐步迁移
  3. 复杂逻辑才抽 composables,简单组件怎么顺手怎么来

技术服务于业务,够用、好维护,就是最好的选择。


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

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

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

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

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

❌
❌