阅读视图

发现新文章,点击刷新页面。

性能优化之实战指南:让你的 Vue 应⽤跑得飞起

Vue 性能优化实战指南:让你的 Vue 应⽤跑得飞起

1. 列表项 key 属性:被你误解最深的 Vue 知识点

兄弟们,key 这个属性估计是 Vue 里被误解最多的东⻄了。很多同学以为随便给个 index 就完事了,结果性能炸裂还不知道为啥。

1.1 key 的作⽤到底是什么?

Vue 的虚拟 DOM diff 算法通过 key 来判断节点是否可以复用。没有 key 或者 key 重复,Vue 会强制复用 DOM,导致性能下降甚至状态混乱。

<!-- ❌ 错误:用 index 做 key -->
<template>
  <div>
    <div v-for="(item, index) in list" :key="index">
      {{ item.name }}
      <input v-model="item.value" />
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      list: [
        { name: '张三', value: '' },
        { name: '李四', value: '' },
        { name: '王五', value: '' }
      ]
    }
  }
}
</script>

问题: 当你删除第一个元素时,Vue 会"以为"后面的元素只是变了位置,于是把第二个元素的 DOM 复用给第一个,第三个复用给第二个...结果输入框里的值全乱了!

<!-- ✅ 正确:用唯一标识做 key -->
<template>
  <div>
    <div v-for="item in list" :key="item.id">
      {{ item.name }}
      <input v-model="item.value" />
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      list: [
        { id: 1, name: '张三', value: '' },
        { id: 2, name: '李四', value: '' },
        { id: 3, name: '王五', value: '' }
      ]
    }
  }
}
</script>

1.2 什么时候必须用 key?

<!-- 1. v-for 必须用 -->
<template>
  <div v-for="item in list" :key="item.id">{{ item.name }}</div>
</template>

<!-- 2. 条件渲染多个元素时建议用 -->
<template>
  <div v-if="showForm" :key="1">表单A</div>
  <div v-else :key="2">表单B</div>
</template>

1.3 key 选择指南

// ✅ 好的 key
:key="item.id"              // 唯一标识,最佳选择
:key="item.uuid"            // 如果有 UUID 更好
:key="`${item.type}_${item.id}`"  // 组合唯一标识

// ❌ 不好的 key
:key="index"                // 列表会出问题
:key="Math.random()"        // 每次都变,失去复用意义
:key="item.name"            // 可能重复

1.4 小贴士

  • 列表只有渲染,不会增删改查,用 index 也问题不大
  • 列表会动态变化,必须用唯一标识
  • 表格、聊天、购物车这种场景,key 选错了会出大问题
  • 调试时可以用 Vue DevTools 看 diff 结果,key 对不对一目了然

2. 架构级优化:从源头解决性能问题

前面讲的都是"术",现在讲"道"。架构级优化能让你的应用从根本上快起来。

2.1 代码分割:把大蛋糕切成小块

现代打包工具(Webpack、Vite)都支持代码分割,把代码拆成多个小块,按需加载。

2.1.1 路由级别代码分割

这是最常见的优化方式,每个路由一个 chunk。

// ❌ 一次性加载所有路由组件
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import Profile from '@/views/Profile.vue'
import Settings from '@/views/Settings.vue'

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
  { path: '/profile', component: Profile },
  { path: '/settings', component: Settings }
]
// ✅ 路由懒加载
const routes = [
  {
    path: '/',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    component: () => import('@/views/About.vue')
  },
  {
    path: '/profile',
    component: () => import('@/views/Profile.vue')
  },
  {
    path: '/settings',
    component: () => import('@/views/Settings.vue')
  }
]

打包效果:

  • 首屏只加载 home.js
  • 用户访问 /about 时才加载 about.js
  • 首屏体积从 2MB 降到 300KB,首屏时间缩短 60%+
2.1.2 组件级别代码分割

某些大型组件(如富文本编辑器、图表库)可以按需加载。

<template>
  <div>
    <button @click="showEditor = true">打开编辑器</button>

    <!-- 条件加载大型组件 -->
    <Editor v-if="showEditor" @close="showEditor = false" />
  </div>
</template>

<script>
export default {
  components: {
    Editor: () => import('@/components/Editor.vue')
  },
  data() {
    return {
      showEditor: false
    }
  }
}
</script>
2.1.3 动态导入

更灵活的按需加载方式。

// 点击按钮时才加载某个模块
async function loadFeature() {
  if (needsAdvancedFeatures) {
    const { default: AdvancedModule } = await import('@/features/advanced')
    AdvancedModule.init()
  }
}

// 根据条件加载不同的实现
async function getChartLibrary() {
  if (useECharts) {
    const echarts = await import('echarts')
    return echarts
  } else {
    const chartjs = await import('chart.js')
    return chartjs
  }
}
2.1.4 第三方库分割

某些第三方库可以单独打包。

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          priority: 10
        },
        elementUI: {
          test: /[\\/]node_modules[\\/]element-ui[\\/]/,
          name: 'elementUI',
          priority: 20
        },
        commons: {
          name: 'commons',
          minChunks: 2,
          priority: 5,
          reuseExistingChunk: true
        }
      }
    }
  }
}

2.2 路由级别优化

除了代码分割,路由本身也有优化空间。

2.2.1 路由懒加载 + 预加载
// 路由配置
const routes = [
  {
    path: '/',
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    component: () => import(/* webpackPrefetch: true */ '@/views/About.vue')
  },
  {
    path: '/profile',
    component: () => import(/* webpackPreload: true */ '@/views/Profile.vue')
  }
]

区别:

  • webpackPrefetch:空闲时预加载,适合"可能访问"的路由
  • webpackPreload:立即预加载,适合"即将访问"的路由
2.2.2 路由组件缓存

使用 keep-alive 缓存路由组件,避免重复渲染。

<template>
  <div id="app">
    <!-- 缓存所有路由组件 -->
    <keep-alive>
      <router-view />
    </keep-alive>

    <!-- 或者只缓存特定路由 -->
    <keep-alive :include="['Home', 'Profile']">
      <router-view />
    </keep-alive>

    <!-- 排除某些路由 -->
    <keep-alive :exclude="['Login', 'Register']">
      <router-view />
    </keep-alive>
  </div>
</template>
// 组件内配合使用
export default {
  name: 'Home',  // 必须有 name 才能被 include/exclude 匹配
  data() {
    return {
      list: []
    }
  },
  activated() {
    // 从缓存恢复时调用
    console.log('组件被激活')
    this.fetchData()
  },
  deactivated() {
    // 组件被缓存时调用
    console.log('组件被停用')
  }
}
2.2.3 路由守卫优化
// ❌ 重复获取数据
router.beforeEach(async (to, from, next) => {
  // 每次导航都获取用户信息
  const user = await fetchUser()
  next()
})

// ✅ 缓存用户信息
let cachedUser = null
let lastFetchTime = 0
const CACHE_DURATION = 5 * 60 * 1000 // 5分钟

router.beforeEach(async (to, from, next) => {
  const now = Date.now()

  if (!cachedUser || now - lastFetchTime > CACHE_DURATION) {
    cachedUser = await fetchUser()
    lastFetchTime = now
  }

  next()
})

2.3 状态管理优化

2.3.1 Vuex 模块化
// ❌ 所有的 state 都在一个大对象里
const store = new Vuex.Store({
  state: {
    user: {},
    products: [],
    cart: [],
    orders: [],
    settings: {},
    // ... 越来越多
  }
})
// ✅ 模块化管理
const user = {
  namespaced: true,
  state: () => ({ currentUser: null }),
  mutations: { SET_USER(state, user) { state.currentUser = user } },
  actions: { async fetchUser({ commit }) { /* ... */ } }
}

const products = {
  namespaced: true,
  state: () => ({ list: [] }),
  mutations: { SET_PRODUCTS(state, list) { state.list = list } }
}

const store = new Vuex.Store({
  modules: { user, products, cart, orders }
})
2.3.2 按需注册模块
// 动态注册模块
router.beforeEach(async (to, from, next) => {
  if (to.matched.some(record => record.meta.requiresAdmin)) {
    await store.registerModule('admin', adminModule)
  }
  next()
})

// 离开时卸载模块
router.afterEach((to, from) => {
  if (!to.matched.some(record => record.meta.requiresAdmin)) {
    if (store.hasModule('admin')) {
      store.unregisterModule('admin')
    }
  }
})

2.4 组件设计原则

2.4.1 组件粒度
<!-- ❌ 组件太大,职责不清 -->
<template>
  <div class="user-list">
    <div v-for="user in users" :key="user.id">
      <img :src="user.avatar">
      <div>{{ user.name }}</div>
      <div>{{ user.email }}</div>
      <button @click="follow(user)">关注</button>
      <button @click="block(user)">拉黑</button>
      <button @click="sendMessage(user)">发消息</button>
    </div>
  </div>
</template>
<!-- ✅ 拆分成多个小组件 -->
<template>
  <UserList :users="users">
    <template #default="{ user }">
      <UserCard :user="user">
        <template #actions>
          <UserActions :user="user" />
        </template>
      </UserCard>
    </template>
  </UserList>
</template>

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <Avatar :src="user.avatar" />
    <UserInfo :name="user.name" :email="user.email" />
    <slot name="actions" />
  </div>
</template>

<!-- UserActions.vue -->
<template>
  <div class="actions">
    <button @click="$emit('follow')">关注</button>
    <button @click="$emit('block')">拉黑</button>
    <button @click="$emit('message')">发消息</button>
  </div>
</template>
2.4.2 避免不必要的渲染
<template>
  <div>
    <!-- ❌ 每次父组件更新都会重新渲染 -->
    <ExpensiveComponent :data="heavyData" />

    <!-- ✅ 使用计算属性缓存 -->
    <ExpensiveComponent :data="processedData" />

    <!-- ✅ 使用 v-once 只渲染一次 -->
    <div v-once>{{ staticContent }}</div>

    <!-- ✅ 使用 shouldComponentUpdate(Vue 2)或 computed(Vue 3) -->
    <ExpensiveComponent v-if="shouldRender" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      heavyData: largeData,
      someOtherData: []
    }
  },
  computed: {
    processedData() {
      return this.heavyData.map(item => ({
        ...item,
        formatted: this.format(item)
      }))
    },
    shouldRender() {
      return this.heavyData.length > 0
    }
  },
  methods: {
    format(item) {
      // 昂贵的计算
      return item.value.toFixed(2)
    }
  }
}
</script>

3. 服务端渲染 SSR:SEO 和首屏性能的双刃剑

SSR(Server-Side Rendering)能在服务器端渲染 Vue 组件,直接返回 HTML,对 SEO 和首屏加载都有巨大提升。

3.1 SSR vs CSR

对比项 CSR(客户端渲染) SSR(服务端渲染)
SEO ❌ 搜索引擎爬虫难以抓取 ✅ 直接返回 HTML,SEO 友好
首屏时间 ⚠️ 需要加载 JS 后才能渲染 ✅ 首屏直接显示 HTML
服务器压力 ✅ 低,只提供静态资源 ⚠️ 高,需要渲染页面
开发复杂度 ✅ 简单 ⚠️ 复杂,需要考虑同构
交互响应 ✅ 客户端即时响应 ⚠️ 需要注水(hydration)

3.2 Nuxt.js 快速上手

Nuxt.js 是 Vue 的 SSR 框架,开箱即用。

# 创建 Nuxt 项目
npx create-nuxt-app my-app

cd my-app
npm run dev
3.2.1 页面自动路由
pages/
├── index.vue          # / 路由
├── about.vue          # /about 路由
└── users/
    ├── index.vue      # /users 路由
    └── _id.vue       # /users/:id 路由
3.2.2 数据获取
<!-- pages/index.vue -->
<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else>
      <h1>{{ post.title }}</h1>
      <p>{{ post.content }}</p>
    </div>
  </div>
</template>

<script>
export default {
  // 服务器端渲染前获取数据
  async asyncData({ params, $axios }) {
    const post = await $axios.$get(`/api/posts/${params.id}`)
    return { post }
  },

  // 或者在客户端获取数据
  async fetch({ store, $axios }) {
    const posts = await $axios.$get('/api/posts')
    store.commit('posts/SET_POSTS', posts)
  },

  data() {
    return {
      loading: false,
      post: {}
    }
  }
}
</script>
3.2.3 SEO 优化
<template>
  <div>
    <h1>{{ post.title }}</h1>
  </div>
</template>

<script>
export default {
  async asyncData({ $axios, params }) {
    const post = await $axios.$get(`/api/posts/${params.id}`)
    return { post }
  },

  head() {
    return {
      title: this.post.title,
      meta: [
        { hid: 'description', name: 'description', content: this.post.excerpt },
        { hid: 'og:title', property: 'og:title', content: this.post.title },
        { hid: 'og:image', property: 'og:image', content: this.post.image }
      ]
    }
  }
}
</script>

3.3 Vue SSR 手动配置

如果你不想用 Nuxt,可以手动配置 Vue SSR。

3.3.1 服务端入口
// server.js
const express = require('express')
const { createSSRApp } = require('vue')
const { renderToString } = require('@vue/server-renderer')

const server = express()

server.get('*', async (req, res) => {
  const app = createSSRApp({
    data: () => ({ url: req.url }),
    template: `<div>访问的 URL 是:{{ url }}</div>`
  })

  const appContent = await renderToString(app)

  const html = `
    <!DOCTYPE html>
    <html>
      <head><title>Vue SSR</title></head>
      <body>
        <div id="app">${appContent}</div>
      </body>
    </html>
  `

  res.end(html)
})

server.listen(3000)
3.3.2 客户端入口
// client.js
import { createSSRApp } from 'vue'
import { createApp } from 'vue'

const app = createSSRApp({
  data: () => ({ url: window.location.pathname }),
  template: `<div>访问的 URL 是:{{ url }}</div>`
})

app.mount('#app')

3.4 静态站点生成(SSG)

如果你的内容是静态的,可以用静态站点生成,比 SSR 更简单。

// nuxt.config.js
export default {
  // 启用静态生成
  generate: {
    routes: ['/post/1', '/post/2', '/post/3']
  }
}

// 或者动态生成
export default {
  generate: {
    async routes() {
      const posts = await fetchPosts()
      return posts.map(post => `/post/${post.id}`)
    }
  }
}

3.5 SSR 性能优化

3.5.1 缓存渲染结果
const LRU = require('lru-cache')
const ssrCache = new LRU({
  max: 1000,
  maxAge: 1000 * 60 * 15 // 15分钟
})

async function renderPage(url) {
  // 检查缓存
  const cached = ssrCache.get(url)
  if (cached) {
    return cached
  }

  // 渲染页面
  const html = await renderToString(app)

  // 缓存结果
  ssrCache.set(url, html)

  return html
}
3.5.2 流式渲染
const { renderToStream } = require('@vue/server-renderer')

server.get('*', async (req, res) => {
  const stream = renderToStream(app)

  res.write('<!DOCTYPE html><html><head>...')

  // 流式输出
  stream.pipe(res, { end: false })

  stream.on('end', () => {
    res.end('</html>')
  })
})
3.5.3 避免在服务端执行客户端代码
<template>
  <div>
    <!-- ❌ 服务端没有 window -->
    <div>{{ window.innerWidth }}</div>

    <!-- ✅ 使用 process.client 判断 -->
    <div v-if="process.client">{{ window.innerWidth }}</div>
    <div v-else>服务端渲染</div>

    <!-- ✅ 或者在 mounted 中获取 -->
    <div>{{ screenWidth }}</div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      screenWidth: 0
    }
  },
  mounted() {
    // mounted 只在客户端执行
    this.screenWidth = window.innerWidth
  }
}
</script>

3.6 SSR 踩过的坑

3.6.1 状态同步问题
// ❌ 服务端和客户端状态不一致
export default {
  async asyncData() {
    // 服务端获取数据
    const data = await fetchData()
    return { data }
  },
  mounted() {
    // 客户端又获取一次,可能导致冲突
    this.fetchData()
  }
}

// ✅ 统一状态管理
export default {
  async asyncData({ store }) {
    await store.dispatch('fetchData')
    return { data: store.state.data }
  },
  computed: {
    data() {
      return this.$store.state.data
    }
  }
}
3.6.2 Cookie 处理
// ❌ 服务端访问不到 document.cookie
async function fetchUser() {
  const cookie = document.cookie // 报错
}

// ✅ 通过上下文传递 cookie
async function fetchUser(context) {
  const cookie = context.req.headers.cookie
  // 使用 cookie 发送请求
}
3.6.3 异步组件处理
<template>
  <div>
    <!-- SSR 时异步组件不会渲染 -->
    <AsyncComponent />
  </div>
</template>

<script>
export default {
  components: {
    // ✅ 使用 SSR 友好的异步组件
    AsyncComponent: defineAsyncComponent({
      loader: () => import('./AsyncComponent.vue'),
      loadingComponent: LoadingComponent,
      errorComponent: ErrorComponent,
      delay: 200,
      timeout: 3000
    })
  }
}
</script>

3.7 是否需要 SSR?

需要 SSR 的情况:

  • 内容需要 SEO(博客、新闻、电商)
  • 首屏加载时间要求极高
  • 社交媒体分享需要预览卡片

不需要 SSR 的情况:

  • 内部管理系统
  • 社交媒体应用(如 Twitter)
  • 游戏或富交互应用

总结

Vue 性能优化是一个系统工程,需要从多个层面入手:

  1. key 属性要选对,用唯一标识,别用 index
  2. 代码分割是标配,路由懒加载、组件按需加载
  3. 架构设计要合理,模块化、职责单一、避免过度渲染
  4. SSR 看场景使用,SEO 和首屏是刚需就上,否则别自找麻烦
  5. 监控要跟上,用 Vue DevTools、Lighthouse、Web Vitals 持续优化

最后,如果你觉得这篇⽂章对你有帮助,点个赞呗!如果觉得有问题,评论区喷我,我抗揍。

JitWord Office预览引擎:如何用Vue3+Node.js打造丝滑的PDF/Excel/PPT嵌入方案

ps:老规矩,先上地址,github地址:jitword sdk

最近很多用户反馈了需要支持Office预览功能,于是我们加班加点,在Jitword 协同AI文档上支持了一键预览Office文件的功能:

image.png

目前 jitword 已全面支持如下文件类型的解析预览:

  • Markdown文件
  • Docx文件
  • PDF文件
  • Excel文件
  • PPT文件
  • JSON文件
  • HTML文件

接下来我会详细和大家分享一下功能和技术实现,给大家提供一个技术参考。

往期精彩:

拒绝重复造轮子?我们偏偏花365天,用Vue3写了款AI协同的Word编辑器

项目背景:为什么我们要造这个轮子?

image.png

作为一个协同文档项目,JitWord一直在探索轻量级的办公解决方案。最近社区反复提出"Office预览"需求,但是我们面临一个选择:

方案 优点 缺点
OnlyOffice/Collabora 功能完整,支持编辑 部署重(2GB+镜像),加载慢(3-5s),样式难定制
微软/谷歌预览API 接入简单 数据出境,自定义域名受限,免费额度有限
自研预览引擎 轻量、可控、体验统一 开发成本高,需持续维护

我们的决策:自研轻量级预览引擎,专注"预览+文档编排"场景。

下面分享一下我们的技术方案。

架构设计:三层解耦模型

┌─────────────────────────────────────────┐
│           协同层 (Collaboration)         │
│    批注Canvas + 用户体系 + 实时同步        │
├─────────────────────────────────────────┤
│           嵌入层 (Embedding)             │
│    Vue3组件 + 响应式布局 + 主题同步        │
├─────────────────────────────────────────┤
│           解析层 (Parsing)               │
│    PDF.js / SheetJS / PPTX解析器         │
└─────────────────────────────────────────┘

核心技术实现

PDF预览:PDF.js深度优化

问题:原版PDF.js加载大文件时卡顿,内存占用高。

优化方案

// pdf-loader.js
import * as pdfjsLib from 'pdfjs-dist';

class PDFPreviewEngine {
  constructor(container, options = {}) {
    this.container = container;
    this.pdfDoc = null;
    this.scale = options.scale || 1.5;
    this.chunkSize = options.chunkSize || 256 * 1024; // 256KB分片
  }

  async load(url) {
    // 分片加载:只加载可视区域附近的页面
    const loadingTask = pdfjsLib.getDocument({
      url,
      rangeChunkSize: this.chunkSize,
      disableAutoFetch: true, // 关键:禁用自动全量加载
    });

    this.pdfDoc = await loadingTask.promise;
    return this.renderVisiblePages();
  }

  async renderVisiblePages() {
    const viewportHeight = this.container.clientHeight;
    const pages = [];
    
    // 只渲染可视区域 + 上下各缓冲1页
    for (let i = 1; i <= this.pdfDoc.numPages; i++) {
      const page = await this.pdfDoc.getPage(i);
      const viewport = page.getViewport({ scale: this.scale });
      
      // 虚拟列表逻辑:计算页面是否在视口内
      if (this.isPageInViewport(i, viewport.height)) {
        pages.push(this.renderPage(page, viewport));
      }
    }
    
    return Promise.all(pages);
  }

  renderPage(page, viewport) {
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    canvas.height = viewport.height;
    canvas.width = viewport.width;

    return page.render({
      canvasContext: context,
      viewport: viewport
    }).promise.then(() => canvas);
  }
}

关键优化点

  1. disableAutoFetch: true:禁用PDF.js的自动全量加载
  2. rangeChunkSize:设置分片大小,配合HTTP Range请求
  3. 虚拟列表渲染:只渲染可视区域,100MB+PDF也能流畅滚动

Excel预览:SheetJS + 自研渲染器

问题:SheetJS解析后如何高效渲染?如何保留公式计算?

方案架构

Excel文件 (.xlsx)
    ↓
SheetJS解析 → Workbook对象
    ↓
数据转换层 (Data Transformer)
    ↓
Vue3表格组件 (Virtual Table)

核心代码

// excel-parser.js
import XLSX from 'xlsx';

class ExcelPreviewEngine {
  parse(buffer) {
    const workbook = XLSX.read(buffer, { 
      type: 'array',
      cellFormula: true,      // 保留公式
      cellNF: true,           // 保留数字格式
      cellStyles: true        // 保留样式
    });
    
    return this.transformWorkbook(workbook);
  }

  transformWorkbook(workbook) {
    return workbook.SheetNames.map(name => {
      const worksheet = workbook.Sheets[name];
      const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
      
      return {
        name,
        data,
        merges: this.parseMerges(worksheet['!merges']), // 合并单元格
        formulas: this.extractFormulas(worksheet),      // 公式映射
        colWidths: worksheet['!cols']?.map(c => c.wpx) || []
      };
    });
  }

  extractFormulas(worksheet) {
    const formulas = {};
    for (const [cell, value] of Object.entries(worksheet)) {
      if (value && value.f) { // value.f 是公式字符串
        formulas[cell] = value.f;
      }
    }
    return formulas;
  }
}

前端渲染组件(Vue3 + 虚拟滚动):

<!-- ExcelPreview.vue -->
<template>
  <div class="excel-preview" ref="container">
    <div class="sheet-tabs">
      <button 
        v-for="sheet in sheets" 
        :key="sheet.name"
        :class="{ active: currentSheet === sheet.name }"
        @click="switchSheet(sheet.name)"
      >
        {{ sheet.name }}
      </button>
    </div>
    
    <VirtualTable
      :data="currentData"
      :formulas="currentFormulas"
      :col-widths="currentColWidths"
      :row-height="28"
      @cell-click="handleCellClick"
    />
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import VirtualTable from './VirtualTable.vue';
import { evaluateFormula } from './formula-engine'; // 自研公式计算引擎

const props = defineProps({
  workbook: Object
});

const currentSheet = ref(props.workbook[0]?.name);
const currentData = computed(() => {
  const sheet = props.workbook.find(s => s.name === currentSheet.value);
  return sheet?.data || [];
});

// 公式实时计算
const computedValues = computed(() => {
  const result = {};
  const formulas = props.workbook.find(s => s.name === currentSheet.value)?.formulas || {};
  
  for (const [cell, formula] of Object.entries(formulas)) {
    try {
      result[cell] = evaluateFormula(formula, currentData.value);
    } catch (e) {
      result[cell] = '#ERROR';
    }
  }
  
  return result;
});
</script>

公式计算引擎(简化版):

// formula-engine.js
export function evaluateFormula(formula, data) {
  // 移除开头的=
  const expr = formula.replace(/^=/, '');
  
  // 单元格引用解析:A1 → data[0][0]
  const cellRef = expr.match(/([A-Z]+)(\d+)/g);
  if (!cellRef) return evaluateExpression(expr);
  
  let evalExpr = expr;
  for (const ref of cellRef) {
    const { col, row } = parseCellRef(ref);
    const value = data[row - 1]?.[col] || 0;
    evalExpr = evalExpr.replace(ref, value);
  }
  
  return evaluateExpression(evalExpr);
}

// 支持常用函数
const FUNCTIONS = {
  SUM: (args) => args.reduce((a, b) => Number(a) + Number(b), 0),
  AVERAGE: (args) => FUNCTIONS.SUM(args) / args.length,
  MAX: (args) => Math.max(...args),
  MIN: (args) => Math.min(...args),
  // ... 200+函数实现
};

PPT预览:XML解析 + Vue3幻灯片组件

技术选型:不渲染为图片,而是解析为可交互的组件树

// pptx-parser.js
import JSZip from 'jszip';

class PPTXParser {
  async parse(arrayBuffer) {
    const zip = await JSZip.loadAsync(arrayBuffer);
    
    // 解析核心XML
    const [contentTypes, presentation, slideMasters] = await Promise.all([
      zip.file('[Content_Types].xml').async('string'),
      zip.file('ppt/presentation.xml').async('string'),
      zip.file('ppt/slideMasters/slideMaster1.xml').async('string')
    ]);

    const parser = new DOMParser();
    const presDoc = parser.parseFromString(presentation, 'application/xml');
    
    // 提取幻灯片列表
    const slideIds = Array.from(presDoc.querySelectorAll('sldId')).map(s => s.getAttribute('id'));
    
    // 并行解析所有幻灯片
    const slides = await Promise.all(
      slideIds.map((id, index) => this.parseSlide(zip, index + 1))
    );
    
    return { slides, slideCount: slides.length };
  }

  async parseSlide(zip, slideNum) {
    const slideXml = await zip.file(`ppt/slides/slide${slideNum}.xml`).async('string');
    const doc = new DOMParser().parseFromString(slideXml, 'application/xml');
    
    // 提取形状、文本、图片
    const shapes = Array.from(doc.querySelectorAll('sp')).map(sp => ({
      type: this.getShapeType(sp),
      x: this.emuToPx(sp.querySelector('off')?.getAttribute('x')),
      y: this.emuToPx(sp.querySelector('off')?.getAttribute('y')),
      width: this.emuToPx(sp.querySelector('ext')?.getAttribute('cx')),
      height: this.emuToPx(sp.querySelector('ext')?.getAttribute('cy')),
      text: this.extractText(sp),
      style: this.extractStyle(sp)
    }));

    // 提取动画时序
    const animations = this.parseAnimations(doc);
    
    return { shapes, animations, transition: this.parseTransition(doc) };
  }

  emuToPx(emu) {
    return Math.round(parseInt(emu) / 9525); // 1px = 9525 EMU
  }
}

Vue3幻灯片渲染组件

<!-- SlideViewer.vue -->
<template>
  <div class="slide-viewer" :style="slideStyle">
    <TransitionGroup name="slide">
      <div 
        v-for="(shape, index) in currentSlide.shapes" 
        :key="index"
        class="shape"
        :style="shapeStyle(shape)"
        v-show="isShapeVisible(index)"
      >
        <TextShape v-if="shape.type === 'text'" :content="shape.text" :style="shape.style" />
        <ImageShape v-else-if="shape.type === 'image'" :src="shape.src" />
        <TableShape v-else-if="shape.type === 'table'" :data="shape.data" />
      </div>
    </TransitionGroup>
    
    <!-- 动画控制 -->
    <div class="animation-controls">
      <button @click="prevAnimation" :disabled="currentStep === 0">上一步</button>
      <span>{{ currentStep + 1 }} / {{ totalSteps }}</span>
      <button @click="nextAnimation" :disabled="currentStep >= totalSteps - 1">下一步</button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import TextShape from './shapes/TextShape.vue';
import ImageShape from './shapes/ImageShape.vue';
import TableShape from './shapes/TableShape.vue';

const props = defineProps({
  slide: Object
});

const currentStep = ref(0);

// 根据动画时序计算可见形状
const isShapeVisible = (shapeIndex) => {
  if (!props.slide.animations) return true;
  const triggerStep = props.slide.animations[shapeIndex]?.triggerStep || 0;
  return currentStep.value >= triggerStep;
};

const nextAnimation = () => {
  if (currentStep.value < totalSteps.value - 1) {
    currentStep.value++;
  }
};

const totalSteps = computed(() => {
  if (!props.slide.animations) return 1;
  return Math.max(...props.slide.animations.map(a => a.triggerStep)) + 1;
});
</script>

嵌入层:与文档流的完美融合

核心挑战:如何让Office预览组件像<img>标签一样自然嵌入文档?

解决方案contenteditable + Shadow DOM隔离

// embed-manager.js
class OfficeEmbedManager {
  constructor(editor) {
    this.editor = editor; // 富文本编辑器实例
    this.embeds = new Map();
  }

  insertEmbed(type, fileUrl, position) {
    // 生成唯一ID
    const embedId = `embed-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
    
    // 在编辑器中插入占位符
    const placeholder = document.createElement('div');
    placeholder.className = 'office-embed-placeholder';
    placeholder.dataset.embedId = embedId;
    placeholder.dataset.type = type;
    placeholder.contentEditable = false; // 关键:防止编辑器干扰
    
    // 使用Shadow DOM隔离样式
    const shadow = placeholder.attachShadow({ mode: 'open' });
    
    // 根据类型渲染对应组件
    const app = createApp(getPreviewComponent(type), {
      src: fileUrl,
      onReady: (api) => this.embeds.set(embedId, api)
    });
    
    app.mount(shadow);
    
    // 插入到编辑器指定位置
    this.editor.insertNodeAt(position, placeholder);
    
    return embedId;
  }

  // 协同批注:将坐标映射到Office内容
  addAnnotation(embedId, x, y, content) {
    const embed = this.embeds.get(embedId);
    if (!embed) return;
    
    // 将屏幕坐标转换为文档相对坐标
    const rect = embed.getBoundingClientRect();
    const relativeX = (x - rect.left) / rect.width;
    const relativeY = (y - rect.top) / rect.height;
    
    // 根据类型做语义化定位
    const location = embed.resolveLocation(relativeX, relativeY);
    
    return {
      embedId,
      location, // 如:{ type: 'cell', ref: 'B5' } 或 { type: 'page', num: 3 }
      content,
      timestamp: Date.now()
    };
  }
}

性能数据与优化技巧

加载性能对比

文件类型 文件大小 OnlyOffice 我们的方案 提升
PDF 50MB 4.2s 0.8s 5.2x
Excel 10MB (10万行) 3.8s 1.1s 3.5x
PPT 20MB (50页) 5.1s 1.5s 3.4x

关键优化技巧

1. Web Worker卸载解析

// excel-worker.js
self.onmessage = async (e) => {
  const { buffer, sheetName } = e.data;
  
  // 在Worker线程解析,不阻塞主线程
  const workbook = XLSX.read(buffer, { type: 'array' });
  const sheet = workbook.Sheets[sheetName];
  const data = XLSX.utils.sheet_to_json(sheet, { header: 1 });
  
  self.postMessage({ data, formulas: extractFormulas(sheet) });
};

2. 虚拟滚动(Excel大数据)

<VirtualList
  :items="flattenedData"
  :item-height="28"
  :buffer="5"
  v-slot="{ item, index }"
>
  <TableRow :cells="item" :row-index="index" />
</VirtualList>

3. 图片懒加载(PDF/PPT)

const imageObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src; // 真正加载
      imageObserver.unobserve(img);
    }
  });
});

我们提供了一个开源SDK版本,大家可以轻松集成到项目里使用:

github:github.com/MrXujiang/j…

总结与展望

这套方案的核心价值在于轻量与可控

  • 轻量:前端包体积<500KB,无需重型服务器
  • 可控:源码支持二次开发,模块化解耦设计
  • 协同:与文档系统深度集成,而非孤立的预览窗口

未来规划

  1. WebAssembly加速:将公式计算用Rust重写,编译为WASM
  2. Rag知识库:支持文档即知识的Rag动态知识库功能
  3. AI增强:PDF自动摘要、Excel智能分析

如果大家有好的方案,欢迎随时交流反馈~

往期精彩:

拒绝重复造轮子?我们偏偏花365天,用Vue3写了款AI协同的Word编辑器

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

同学们好,我是 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)的实战封装

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

JSON或代码对比的工具-vue

Vue 中JSON或代码对比的插件和工具

先看效果

duibi.png

实现方案
// 1、安装依赖
"vue-diff": "^1.2.4"

///2、main.ts 注册组件
import VueDiff from 'vue-diff';
import 'vue-diff/dist/index.css';

app.use(VueDiff);


///3、页面使用组件
<template>
  <el-dialog title="对比 ">
    <Diff
      mode="split"
      theme="light"
      language="json"
      :prev="oldStr"
      :current="newStr"
      style="height: 500px; margin-top: 20px; overflow-y: auto"
    />
  </el-dialog>
</template>

<script setup>

const obj = {
  room_rule: {
    player_limit: 4,
    hand_ready: 2,
    limit_same_ip: 1,
    game_srcj: 0,
    continue_game: 0,
    start_left_time: 10,
    share_gps: 2,
    yu_yin: 0,
    name: {
      nameStr: "张三",
      level: "大师",
      info: {
        name: "张三",
        desc: "这是个人描述",
        sub: {
          name: "张三",
          desc: "这是个人描述",
        },
      },
    },
    room_name: "欢乐2048",
    room_desc: "无房卡,房卡,金币",
  },
};

const obj1 = {
  room_rule: {
    player_limit: 4,
    hand_ready: 2,
    limit_same_ip: 1,
    game_srcj: 0,
    start_left_time: 10,
    type: 0,
    share_gps: 2,
    yu_yin: 0,
    name: {
      nameStr: "张三",
      level: "大师",
      info: {
        name: "张三000",
        desc: "这是个人描述",
        sub: {
          name: "张三111111",
          desc: "这是个人描述33333",
        },
      },
    },
  },
};

const oldStr = JSON.stringify(obj, null, 2);
const newStr = JSON.stringify(obj1, null, 2);
</script>

<style lang='scss' scoped>
</style>

实现方案
// 1、安装依赖
"vue-diff": "^1.2.4"

///2、main.ts 注册组件
import VueDiff from 'vue-diff';
import 'vue-diff/dist/index.css';

app.use(VueDiff);


///3、页面使用组件
<template>
  <el-dialog title="对比 ">
    <Diff
      mode="split"
      theme="light"
      language="json"
      :prev="oldStr"
      :current="newStr"
      style="height: 500px; margin-top: 20px; overflow-y: auto"
    />
  </el-dialog>
</template>

<script setup>

const obj = {
  room_rule: {
    player_limit: 4,
    hand_ready: 2,
    limit_same_ip: 1,
    game_srcj: 0,
    continue_game: 0,
    start_left_time: 10,
    share_gps: 2,
    yu_yin: 0,
    name: {
      nameStr: "张三",
      level: "大师",
      info: {
        name: "张三",
        desc: "这是个人描述",
        sub: {
          name: "张三",
          desc: "这是个人描述",
        },
      },
    },
    room_name: "欢乐2048",
    room_desc: "无房卡,房卡,金币",
  },
};

const obj1 = {
  room_rule: {
    player_limit: 4,
    hand_ready: 2,
    limit_same_ip: 1,
    game_srcj: 0,
    start_left_time: 10,
    type: 0,
    share_gps: 2,
    yu_yin: 0,
    name: {
      nameStr: "张三",
      level: "大师",
      info: {
        name: "张三000",
        desc: "这是个人描述",
        sub: {
          name: "张三111111",
          desc: "这是个人描述33333",
        },
      },
    },
  },
};

const oldStr = JSON.stringify(obj, null, 2);
const newStr = JSON.stringify(obj1, null, 2);
</script>

<style lang='scss' scoped>
</style>

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

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

深入理解 Vue 依赖收集:从源码角度拆解响应式的核心

在 Vue 的响应式系统中,“依赖收集” 是贯穿整个数据驱动视图的核心环节。很多开发者日常使用datacomputedwatch时只知其然,却不知其所以然 —— 为什么修改数据视图会自动更新?为什么computed能精准缓存?这一切的背后,都是依赖收集机制在起作用。

本文将从 Vue 2 的源码出发,层层拆解依赖收集的完整流程,带你从 “使用层” 走向 “原理层”,真正理解 Vue 响应式的底层逻辑。

一、先搞懂:什么是 “依赖”?

在开始源码分析前,我们先明确核心概念:

  • 依赖:本质上是 “使用了某个响应式数据的执行函数”,比如渲染组件的render函数、computed的计算函数、watch的回调函数。
  • 依赖收集:在响应式数据被读取时,记录下 “哪些函数依赖了这个数据”;当数据被修改时,找到这些记录的函数并执行,最终实现 “数据变 → 视图更”。

简单来说,依赖收集的核心目标是:建立 “响应式数据” 与 “使用数据的函数” 之间的映射关系

二、核心角色:依赖收集的 3 个关键模块

Vue 2 的依赖收集主要依赖三个核心模块,我们先认识它们:

表格

模块 作用 核心源码位置
Observer 将普通对象 / 数组转为响应式(给属性添加 get/set) src/core/observer/index.js
Dep 依赖管理器:存储某个响应式数据的所有依赖 src/core/observer/dep.js
Watcher 依赖的载体:封装需要执行的函数(如 render、computed) src/core/observer/watcher.js

三者的关系可以总结为:

Observer 给数据加 get/set 钩子 → 读取数据时触发 get,通过Dep收集Watcher → 修改数据时触发 set,通过Dep通知所有Watcher执行。

三、源码拆解:依赖收集的完整流程

3.1 第一步:响应式数据的初始化(Observer)

首先,Vue 会通过Observer类将data中的数据转为响应式,核心是给每个属性定义getter/setter

核心源码(简化版):

javascript

运行

// src/core/observer/index.js
class Observer {
  constructor(value) {
    this.value = value
    this.dep = new Dep() // 给对象/数组本身创建Dep
    def(value, '__ob__', this) // 给数据添加__ob__属性,标记为响应式
    
    if (Array.isArray(value)) {
      // 处理数组的响应式(重写push/pop等方法)
      this.observeArray(value)
    } else {
      // 处理对象的响应式:遍历属性并定义get/set
      this.walk(value)
    }
  }

  // 遍历对象属性,定义响应式
  walk(obj) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  // 遍历数组,给每个元素做响应式处理
  observeArray(items) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

// 核心:给单个属性定义get/set
export function defineReactive(
  obj,
  key,
  val,
  customSetter,
  shallow
) {
  // 每个响应式属性都有一个专属的Dep实例
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // 保留原有的get/set
  const getter = property && property.get
  const setter = property && property.set

  // 递归处理子属性,保证深层数据也是响应式
  let childOb = !shallow && observe(val)

  // 定义新的getter/setter
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    // 读取属性时触发:依赖收集的入口
    get: function reactiveGetter() {
      const value = getter ? getter.call(obj) : val
      
      // 关键:如果当前有活跃的Watcher,就收集依赖
      if (Dep.target) {
        dep.depend() // 1. 让Dep记录当前Watcher
        if (childOb) {
          // 2. 给对象/数组本身也收集依赖(处理数组/对象整体变更)
          childOb.dep.depend()
          if (Array.isArray(value)) {
            // 3. 数组特殊处理:遍历子元素收集依赖
            dependArray(value)
          }
        }
      }
      return value
    },
    // 修改属性时触发:通知依赖更新
    set: function reactiveSetter(newVal) {
      const value = getter ? getter.call(obj) : val
      // 新旧值相同则不处理
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // 新值也要做响应式处理
      childOb = !shallow && observe(newVal)
      // 关键:通知所有依赖更新
      dep.notify()
    }
  })
}

核心要点

  • 每个响应式属性都会创建一个Dep实例,专属管理该属性的依赖;
  • getter中触发依赖收集,setter中触发依赖更新;
  • 不仅处理单个属性,还会递归处理子对象 / 数组,保证深层响应式。

3.2 第二步:依赖管理器(Dep)

Dep是依赖的 “容器”,核心作用是存储和管理某个数据的所有Watcher,提供depend(收集)和notify(通知)两个核心方法。

核心源码(简化版):

javascript

运行

// src/core/observer/dep.js
export default class Dep {
  static target: ?Watcher; // 静态属性,存储当前活跃的Watcher
  id: number; // 唯一标识
  subs: Array<Watcher>; // 存储依赖的Watcher数组

  constructor() {
    this.id = uid++
    this.subs = []
  }

  // 添加一个Watcher到依赖列表
  addSub(sub: Watcher) {
    this.subs.push(sub)
  }

  // 移除一个Watcher
  removeSub(sub: Watcher) {
    remove(this.subs, sub)
  }

  // 核心:收集依赖(让Dep和Watcher互相记录)
  depend() {
    if (Dep.target) {
      // 调用当前Watcher的addDep方法,双向绑定
      Dep.target.addDep(this)
    }
  }

  // 核心:通知所有Watcher更新
  notify() {
    // 复制一份依赖列表,避免更新过程中列表变化
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      // 调用Watcher的update方法
      subs[i].update()
    }
  }
}

// 全局唯一的Dep.target栈(处理嵌套Watcher,比如computed嵌套)
Dep.target = null
const targetStack = []

// 入栈:设置当前活跃的Watcher
export function pushTarget(target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

// 出栈:恢复上一个Watcher
export function popTarget() {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

核心要点

  • Dep.target是全局唯一的,始终指向 “当前正在执行的 Watcher”;
  • depend()方法不是直接添加 Watcher,而是调用Watcher.addDep(),实现 Dep 和 Watcher 的双向记录(避免重复收集);
  • 用栈结构targetStack处理嵌套场景(比如组件嵌套、computed 嵌套)。

3.3 第三步:依赖载体(Watcher)

Watcher是 “依赖” 的具体载体,每个 Watcher 对应一个需要执行的函数(比如组件的render函数、computed的计算函数)。

核心源码(简化版):

javascript

运行

// src/core/observer/watcher.js
export default class Watcher {
  constructor(
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    if (isRenderWatcher) {
      vm._watcher = this
    }
    vm._watchers.push(this)
    
    // 处理配置项(比如lazy、deep、sync)
    if (options) {
      this.lazy = !!options.lazy // computed用
      this.deep = !!options.deep // 深度监听用
      this.sync = !!options.sync // 同步更新用
    } else {
      this.lazy = this.deep = this.sync = false
    }
    
    this.cb = cb // 更新回调
    this.id = uid++ // 唯一标识
    this.deps = [] // 存储当前Watcher依赖的Dep
    this.newDeps = [] // 临时存储新依赖(用于依赖清理)
    this.depIds = new Set() // 去重
    this.newDepIds = new Set()
    
    // 解析表达式/函数,得到最终要执行的函数
    this.getter = typeof expOrFn === 'function' ? expOrFn : parsePath(expOrFn)
    
    // 非lazy模式(比如render、watch)立即执行get,触发依赖收集
    this.value = this.lazy ? undefined : this.get()
  }

  // 核心:执行getter并收集依赖
  get() {
    // 1. 将当前Watcher入栈,设置为Dep.target
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      // 2. 执行getter(比如render函数),触发数据的getter
      // 此时数据的getter会检测到Dep.target,从而收集当前Watcher
      value = this.getter.call(vm, vm)
    } catch (e) {
      // 错误处理
    } finally {
      // 3. 深度监听处理
      if (this.deep) {
        traverse(value)
      }
      // 4. 出栈,恢复Dep.target
      popTarget()
      // 5. 清理无用的依赖
      this.cleanupDeps()
    }
    return value
  }

  // 核心:添加Dep到Watcher(与Dep.depend()配合)
  addDep(dep: Dep) {
    const id = dep.id
    // 避免重复收集
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        // 让Dep也记录当前Watcher
        dep.addSub(this)
      }
    }
  }

  // 清理无用依赖(比如数据从视图中移除后,不再监听)
  cleanupDeps() {
    // 省略清理逻辑...
  }

  // 核心:响应式数据更新时,触发Watcher更新
  update() {
    if (this.lazy) {
      // computed:标记为脏值,下次访问时重新计算
      this.dirty = true
    } else if (this.sync) {
      // 同步更新:立即执行run
      this.run()
    } else {
      // 异步更新(Vue默认):加入队列,批量更新
      queueWatcher(this)
    }
  }

  // 执行getter并触发回调
  run() {
    const value = this.get()
    if (value !== this.value || this.deep) {
      const oldValue = this.value
      this.value = value
      // 执行回调(比如watch的回调函数)
      this.cb.call(this.vm, value, oldValue)
    }
  }

  // computed专用:计算并返回最新值
  evaluate() {
    this.value = this.get()
    this.dirty = false
  }

  // 重新收集依赖
  depend() {
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }
}

核心要点

  • Watcher.get()是触发依赖收集的关键:先将自身设为Dep.target,再执行getter(比如render函数),此时render中用到的所有响应式数据都会触发getter,从而收集当前 Watcher;

  • 不同类型的 Watcher 有不同的更新策略:

    • 渲染 Watcher(render):异步更新,加入队列批量执行;
    • 计算 Watcher(computed):懒更新(lazy: true),只有访问时才重新计算;
    • 侦听 Watcher(watch):可配置同步 / 异步,支持深度监听。

3.4 第四步:完整流程梳理(以组件渲染为例)

结合上面的源码,我们用流程图梳理组件渲染时的依赖收集完整流程:

预览

查看代码

组件初始化

创建渲染Watcher

生成失败,请重试

graph TD
    A[组件初始化] --> B[创建渲染Watcher]
    B --> C[执行Watcher.get()]
    C --> D[pushTarget:设置Dep.target为当前Watcher]
    D --> E[执行render函数]
    E --> F[读取响应式数据,触发getter]
    F --> G[Dep.depend():收集依赖]
    G --> H[Watcher.addDep():双向绑定Dep和Watcher]
    H --> I[render执行完成]
    I --> J[popTarget:恢复Dep.target]
    J --> K[依赖收集完成:数据→Dep→Watcher映射建立]
    L[修改响应式数据] --> M[触发setter]
    M --> N[Dep.notify():通知所有Watcher]
    N --> O[Watcher.update():执行更新]
    O --> P[重新执行render,更新视图]

组件初始化

创建渲染Watcher

生成失败,请重试

豆包

你的 AI 助手,助力每日工作学习

四、特殊场景的依赖处理

4.1 数组的依赖收集

数组的响应式处理和对象不同(因为数组的索引无法被Object.defineProperty拦截),Vue 重写了pushpopsplice等 7 个数组方法,核心逻辑:

javascript

运行

// src/core/observer/array.js
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

// 重写的7个方法
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

methodsToPatch.forEach(function (method) {
  // 保留原方法
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator(...args) {
    // 执行原方法
    const result = original.apply(this, args)
    // 获取数组的__ob__(Observer实例)
    const ob = this.__ob__
    // 处理新增元素(push/unshift/splice),转为响应式
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // 关键:通知依赖更新
    ob.dep.notify()
    return result
  })
})

核心:数组的依赖收集在数组本身的__ob__.dep中,修改数组时调用ob.dep.notify()触发更新。

4.2 computed 的依赖收集

computed的 Watcher 是 “懒 Watcher”(lazy: true),特点:

  1. 初始化时不立即执行get,只有首次访问时才触发;
  2. 依赖的数据更新时,只标记dirty: true,不立即重新计算;
  3. 下次访问computed属性时,才重新计算并缓存结果。

核心逻辑:

javascript

运行

// src/core/instance/state.js
function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        // 只有脏值时才重新计算
        watcher.evaluate()
      }
      // 收集渲染Watcher到computed的依赖中
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

五、常见问题与面试考点

5.1 为什么 Vue 不能检测数组索引和长度的变化?

  • 数组索引的get/set虽然能被Object.defineProperty拦截,但考虑到性能成本(数组元素可能很多),Vue 放弃了这种方式;
  • 数组长度的set也无法被有效拦截,且修改长度的场景较少;
  • 解决方案:使用 Vue 提供的变异方法(push/splice 等)或Vue.set

5.2 为什么修改对象的新属性视图不更新?

  • 因为对象初始化时,只有已定义的属性被添加了get/set,新属性没有;
  • 解决方案:使用Vue.set(obj, key, value)this.$set,本质是给新属性添加get/set并触发依赖更新。

5.3 依赖收集为什么要双向记录(Dep→Watcher 和 Watcher→Dep)?

  • 避免重复收集:通过depIdsnewDepIds去重;
  • 方便依赖清理:组件销毁时,Watcher 可以遍历自己的deps,从 Dep 中移除自身;
  • 支持依赖更新:Watcher 可以通过deps重新收集依赖(比如computeddepend方法)。

六、总结

Vue 的依赖收集机制是响应式系统的灵魂,核心可以总结为 3 点:

  1. 核心链路Observer给数据加get/set → 读取数据时Dep收集Watcher → 修改数据时Dep通知Watcher执行 → 视图更新;
  2. 核心角色Observer(响应式标记)、Dep(依赖容器)、Watcher(依赖载体)三者协同工作;
  3. 性能优化:通过懒更新(computed)、异步队列(渲染 Watcher)、依赖清理等方式,保证响应式的高效性。

理解依赖收集,不仅能帮你解决日常开发中的响应式问题,更能让你从底层理解 Vue 的设计思想。希望本文能让你对 Vue 的响应式系统有更深入的认识~

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

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

Vue3和Uniapp的爱恨情仇:小白也能懂的跨端秘籍

Vue3 与 UniApp 开发经验分享:跨端开发的选择与实践

最近不少刚接触前端的朋友问我,Vue3 和 UniApp 是不是竞争对手?

其实完全不是,我自己两个都在项目里用过,今天就从实际开发角度聊聊它们的区别、踩过的坑,以及怎么选。

先明确两者的定位

简单说:

  • Vue3 是一个纯 Web 前端框架,主要用来写浏览器里跑的 H5 页面、Web 应用等。
  • UniApp 是基于 Vue3 封装的跨端框架,它用 Vue3 的语法,但能把同一套代码编译到 H5、微信小程序、支付宝小程序、App、鸿蒙等多个平台。

举个实际例子:

如果你用 Vue3 写微信小程序,得额外用 Taro 这类框架做适配; 但用 UniApp 写,代码写完直接选平台打包就行,这是最直观的区别。

核心差别一:构建工具不一样

Vue3 的构建流程

Vue3 默认用 ViteWebpack,我一般用 Vite,创建项目很简单:

 # 创建 Vue3 项目
 npm create vite@latest my-vue-app -- --template vue
 cd my-vue-app
 npm install
 npm run dev

但如果你想把 Vue3 项目打包成 App,得额外加 CapacitorCordova,步骤会多一些:

 # 1. 先打包成 H5
 npm run build
 
 # 2. 引入 Capacitor
 npm install @capacitor/core @capacitor/cli
 npx cap init my-app com.example.myapp
 
 # 3. 添加 Android 平台
 npm install @capacitor/android
 npx cap add android
 
 # 4. 同步代码并编译
 npx cap sync
 npx cap open android  # 打开 Android Studio 编译安装包

UniApp 的构建流程

UniApp 官方推荐用 HBuilderX,也支持 CLI 方式。我用 HBuilderX 比较多,打包流程很直接:

  1. HBuilderX 里打开项目,点击顶部“发行”;
  2. 选你要打包的平台(比如“微信小程序”“App-云打包”);
  3. 填一下基本信息(比如 App 名称、证书),点“打包”就行。

如果用 CLI 方式,创建和运行也很简单:

 # 创建 UniApp 项目
 npx degit dcloudio/uni-preset-vue#vite my-uniapp
 cd my-uniapp
 npm install
 npm run dev:h5  # 运行 H5
 npm run dev:mp-weixin  # 运行微信小程序

核心差别二:API 不一样

Vue3 用的是 Web API

Vue3 里发请求、操作页面元素,用的都是浏览器原生 API 或第三方库,比如 axios

 // Vue3 里发请求(仅 H5 可用)
 import axios from 'axios'
 
 async function getUserInfo() {
   try {
     // 还要处理跨域问题,比如在 vite.config.js 里配代理
     const res = await axios.get('https://api.example.com/user/info')
     console.log(res.data)
   } catch (err) {
     console.error(err)
   }
 }

但这些代码放到小程序里会报错,因为小程序没有 axios,也没有 document 对象。

UniApp 用的是 uni.* API

UniApp 封装了一套跨端 API ,不管在哪个平台都能用,比如发请求:

 // UniApp 里发请求(全平台通用)
 async function getUserInfo() {
   try {
     const res = await uni.request({
       url: 'https://api.example.com/user/info',
       method: 'GET'
     })
     console.log(res.data)
   } catch (err) {
     console.error(err)
   }
 }

再比如获取用户信息,Vue3 里可能要调浏览器的 navigator,但 UniApp 直接用:

 // UniApp 获取用户信息(以微信小程序为例)
 uni.getUserProfile({
   desc: '用于完善用户资料',
   success: (res) => {
     console.log(res.userInfo)
   }
 })

而且 UniApp 的 API 报错信息比较明确,调试起来比 Vue3 适配多端时省心。

核心差别三:页面路由写法不一样

Vue3 用 Vue Router

Vue3 的路由需要自己配置,先安装 vue-router

 npm install vue-router@4

然后在 src/router/index.js 里写配置:

 // Vue3 路由配置
 import { createRouter, createWebHistory } from 'vue-router'
 import Home from '../views/Home.vue'
 import Cart from '../views/Cart.vue'
 
 const routes = [
   {
     path: '/',
     name: 'Home',
     component: Home
   },
   {
     path: '/cart',
     name: 'Cart',
     component: Cart
   }
 ]
 
 const router = createRouter({
   history: createWebHistory(),
   routes
 })
 
 export default router

最后在 main.js 里挂载:

 import { createApp } from 'vue'
 import App from './App.vue'
 import router from './router'
 
 createApp(App).use(router).mount('#app')

UniApp 用 pages.json

UniApp 不需要自己装路由插件,直接在 pages.json 里配置就行:

 // UniApp pages.json 配置
 {
   "pages": [
     {
       "path": "pages/index/index",
       "style": {
         "navigationBarTitleText": "首页",
         "navigationBarBackgroundColor": "#ff0000"
       }
     },
     {
       "path": "pages/cart/cart",
       "style": {
         "navigationBarTitleText": "购物车",
         "navigationStyle": "default"
       }
     }
   ],
   "globalStyle": {
     "navigationBarTextStyle": "white"
   }
 }

页面跳转也很简单,直接用 uni.navigateTo

 // UniApp 页面跳转
 uni.navigateTo({
   url: '/pages/cart/cart'
 })

另外,UniApp 支持三种页面文件:

  • .vue:通用文件,全平台能用;
  • .nvue:原生渲染文件,App 端性能更好;
  • .uvue:鸿蒙专用文件,编译后接近原生性能。

核心差别四:生态不一样

Vue3 的生态

Vue3npm 包,生态非常丰富,比如做 3D 可以用 Three.js,做工具函数可以用 VueUse

 // Vue3 里用 Three.js
 import * as THREE from 'three'
 
 const scene = new THREE.Scene()
 const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
 const renderer = new THREE.WebGLRenderer()
 renderer.setSize(window.innerWidth, window.innerHeight)
 document.body.appendChild(renderer.domElement)

但这些包很多是为 Web 端设计的,放到小程序或 App 中可能用不了。

UniApp 的生态

UniApp 有自己的插件市场,里面的插件都是跨端适配好的,比如支付可以用 uni-pay,地图可以用 uni-map

 <template>
   <view>
     <uni-map :latitude="39.908823" :longitude="116.397470" :scale="14"></uni-map>
   </view>
 </template>

不过插件市场的数量肯定不如 npm 多,一些特别小众的功能可能找不到现成的插件。

什么时候选 UniApp?什么时候选 Vue3?

选 UniApp 的场景

我之前帮一个创业团队做过项目,他们需要同时做微信小程序、App 和 H5,预算有限,开发周期也紧。用 Vue3 的话得分别开发三端,至少要 2-3 个开发;用 UniApp 一个人就能搞定,代码写完直接打包,开发周期缩短了一半。

另外,如果项目需要高频迭代,比如外卖小程序,今天改满减活动,明天改商品列表,UniApp 改一次代码所有平台同步,测试一次就行,效率很高。

还有对 App 性能有要求的场景,用 UniApp 的 .nvue.uvue 文件,能调用原生组件,滑动长列表比纯 Vue3 写的 H5 套壳 App 流畅很多。

选 Vue3 的场景

如果只做 Web 端,比如企业官网、后台管理系统,选 Vue3 更合适。UniApp 为了跨端会有一些额外的代码开销,而且 Vue3 可以随便用 npm 上的 Web 插件,比如做复杂的 3D 交互、数据可视化,Vue3 比 UniApp 灵活很多。

还有做图形密集型应用,比如手机游戏,UniApp 的性能跟不上,得用 Vue3 配合专业的游戏引擎。

最后总结

根据我的经验:

  • 要做小程序、App、H5 多端,选 UniApp;
  • 只做 Web 端,或者需要复杂的 Web 交互,选 Vue3。

而且先学 Vue3 再学 UniApp 很快,因为语法基本一样,就是多了 uni.* API 和 pages.json 配置。

两者不是竞争对手,而是可以搭配用的:用 Vue3 打好前端基础,用 UniApp 拓展跨端场景,这样开发起来更顺手。

如果你有具体的项目场景,也可以留言,我可以帮你分析一下用哪个更合适。

Vue实例与数据绑定

Vue实例与数据绑定

如果说Vue是一座大厦,那么Vue实例就是这座大厦的地基。地基打得牢,大厦才能稳。

在上一篇文章中,我们成功搭建了开发环境,并写出了第一个Vue应用。今天,让我们深入理解Vue的核心——Vue实例与数据绑定。

📌 写作约定:本系列文章以 Vue 3 <script setup> 语法糖 为主要讲解方式,这是Vue 3.2+官方推荐的写法。同时会顺带介绍Vue 2和Vue 3 Options API的写法作为对比,帮助大家理解演进过程和维护老项目。


一、Vue实例:应用的"大脑"

每个Vue应用都从一个Vue实例开始。你可以把它想象成应用的"大脑",它管理着数据、方法和整个应用的生命周期。

1.1 创建Vue实例

在Vue 3中,创建应用实例的方式:

import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)
app.mount('#app')

1.2 组件的"五脏六腑"

一个完整的Vue组件可以包含以下部分。先看Vue 3 <script setup>语法糖写法(推荐):

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

// =================== 数据:组件的"记忆" ===================
const count = ref(0)
const user = ref({ name: '张三', age: 25 })
const items = ref(['苹果', '香蕉', '橙子'])

// =================== 计算属性:组件的"派生数据" ===================
const doubleCount = computed(() => count.value * 2)
const fullName = computed(() => `${user.value.name}(${user.value.age}岁)`)

// =================== 侦听器:组件的"观察员" ===================
watch(count, (newVal, oldVal) => {
  console.log(`count从${oldVal}变成了${newVal}`)
})

// =================== 方法:组件的"行为" ===================
const increment = () => {
  count.value++
}

const greet = (name) => {
  return `你好,${name}!`
}

// =================== 生命周期钩子 ===================
onMounted(() => {
  console.log('DOM挂载完成')
})
</script>

对比Vue 3 Options API写法

<script>
export default {
  data() {
    return {
      count: 0,
      user: { name: '张三', age: 25 },
      items: ['苹果', '香蕉', '橙子']
    }
  },
  computed: {
    doubleCount() {
      return this.count * 2
    },
    fullName() {
      return `${this.user.name}(${this.user.age}岁)`
    }
  },
  watch: {
    count(newVal, oldVal) {
      console.log(`count从${oldVal}变成了${newVal}`)
    }
  },
  methods: {
    increment() {
      this.count++
    },
    greet(name) {
      return `你好,${name}!`
    }
  },
  mounted() {
    console.log('DOM挂载完成')
  }
}
</script>

对比Vue 2写法(已过时,了解即可):

<script>
export default {
  data() {
    return {
      count: 0
    }
  },
  computed: {
    doubleCount() {
      return this.count * 2
    }
  },
  methods: {
    increment() {
      this.count++
    }
  },
  mounted() {
    console.log('DOM挂载完成')
  }
}
</script>

1.3 三种写法对比总结

特性 Vue 3 <script setup> Vue 3 Options API Vue 2
代码量 最少 较多 较多
this 不需要 需要 需要
类型推断 优秀 一般
学习曲线 中等
官方推荐 ✅ 推荐 兼容维护 ❌ 已停止维护

1.4 关于this的烦恼

<script setup>语法糖中,不需要使用this,直接使用响应式变量即可:

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

const count = ref(0)

const increment = () => {
  count.value++        // ✅ 直接访问
  console.log(count.value)
}

const log = () => {
  console.log(count.value)
}

const doBoth = () => {
  increment()          // ✅ 直接调用
  log()
}
</script>

而在Options API中,需要通过this访问:

export default {
  data() {
    return { count: 0 }
  },
  methods: {
    increment() {
      this.count++      // 需要this
      this.log()        // 需要this
    },
    log() {
      console.log(this.count)
    }
  }
}

Options API的常见陷阱:箭头函数没有自己的this

export default {
  data() {
    return { count: 0 }
  },
  methods: {
    // ❌ 错误:箭头函数的this不指向Vue实例
    wrongIncrement: () => {
      this.count++      // 报错!
    },
    // ✅ 正确:普通函数
    correctIncrement() {
      this.count++
    }
  }
}

💡 <script setup>的优势:彻底告别this的烦恼,代码更简洁,类型推断更友好。


二、生命周期:Vue实例的"人生旅程"

每个Vue实例都有完整的生命周期——从创建到销毁,就像人的一生。理解生命周期,你就能在正确的时机做正确的事。

2.1 生命周期全景图

┌─────────────────────────────────────────────────────────────┐
│                      Vue 3 生命周期                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  创建阶段                                                    │
│  ┌─────────────┐                                            │
│  │ setup()     │  ← <script setup>中的代码直接执行           │
│  └─────────────┘    相当于 beforeCreate + created           │
│                                                             │
│  挂载阶段                                                    │
│  ┌─────────────┐    ┌─────────────┐                         │
│  │ onBefore    │───▶│ onMounted   │                         │
│  │ Mount       │    │             │                         │
│  └─────────────┘    └─────────────┘                         │
│       │                    │                                 │
│       │              DOM已挂载                              │
│       │              可访问DOM元素                           │
│       │              适合发起网络请求                        │
│                                                             │
│  更新阶段(数据变化时触发)                                    │
│  ┌─────────────┐    ┌─────────────┐                         │
│  │ onBefore    │───▶│ onUpdated   │                         │
│  │ Update      │    │             │                         │
│  └─────────────┘    └─────────────┘                         │
│                           │                                 │
│                      DOM已更新                              │
│                                                             │
│  卸载阶段                                                    │
│  ┌─────────────┐    ┌─────────────┐                         │
│  │ onBefore    │───▶│ onUnmounted │                         │
│  │ Unmount     │    │             │                         │
│  └─────────────┘    └─────────────┘                         │
│                           │                                 │
│                      实例已销毁                              │
│                      清理定时器、事件监听器                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

2.2 常用生命周期钩子

Vue 3 <script setup> 写法(推荐):

<script setup>
import { ref, onMounted, onUpdated, onUnmounted } from 'vue'

const count = ref(0)
let timer = null

// =================== setup阶段:代码直接执行 ===================
// 相当于 created,数据已初始化,可访问响应式数据
console.log('组件创建完成')

// =================== onMounted:DOM已经渲染完成 ===================
onMounted(() => {
  console.log('DOM挂载完成,可以访问DOM元素')
  timer = setInterval(() => {
    console.log('定时器运行中...')
  }, 1000)
})

// =================== onUpdated:数据变化导致DOM更新后 ===================
onUpdated(() => {
  console.log('DOM更新完成')
})

// =================== onUnmounted:组件已卸载 ===================
onUnmounted(() => {
  console.log('组件已卸载')
  clearInterval(timer)    // 重要:清理定时器
})
</script>

对比Vue 3 Options API写法

<script>
export default {
  data() {
    return { count: 0 }
  },
  created() {
    console.log('组件创建完成')
  },
  mounted() {
    console.log('DOM挂载完成')
  },
  updated() {
    console.log('DOM更新完成')
  },
  beforeUnmount() {    // Vue 3改名了
    console.log('组件即将卸载')
  },
  unmounted() {        // Vue 3改名了
    console.log('组件已卸载')
  }
}
</script>

对比Vue 2写法

<script>
export default {
  data() {
    return { count: 0 }
  },
  created() {
    console.log('组件创建完成')
  },
  mounted() {
    console.log('DOM挂载完成')
  },
  beforeDestroy() {    // Vue 2叫这个
    console.log('组件即将销毁')
  },
  destroyed() {        // Vue 2叫这个
    console.log('组件已销毁')
  }
}
</script>

2.3 生命周期钩子对照表

<script setup> Options API (Vue 3) Options API (Vue 2) 触发时机
代码直接执行 created created 实例创建完成
onBeforeMount beforeMount beforeMount DOM挂载前
onMounted mounted mounted DOM挂载完成
onBeforeUpdate beforeUpdate beforeUpdate 数据变化DOM更新前
onUpdated updated updated DOM更新完成
onBeforeUnmount beforeUnmount beforeDestroy 实例卸载前
onUnmounted unmounted destroyed 实例卸载后

2.4 使用场景速查

场景 推荐钩子 示例
发起API请求 onMounted 或直接执行 获取初始数据
操作DOM onMounted 初始化图表库
设置定时器 onMounted 轮询、倒计时
清理定时器 onUnmounted 防止内存泄漏
监听窗口事件 onMounted + onUnmounted resize、scroll

三、响应式数据:Vue的"魔法"

响应式数据是Vue最核心的特性,它让数据和视图自动保持同步。

3.1 响应式原理简介

Vue 3使用Proxy实现响应式,Vue 2使用Object.defineProperty

  • Vue 2:给对象的每个属性装"监控器",新增属性需要用Vue.set()
  • Vue 3:给整个对象请"管家",新增属性自动响应式

3.2 ref vs reactive

Vue 3 <script setup> 写法

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

// =================== ref:万能选择 ===================
const count = ref(0)
const name = ref('张三')
const user = ref({ age: 25 })    // 对象也可以用ref

// 访问和修改需要 .value
console.log(count.value)         // 读取
count.value++                    // 修改
user.value.age = 26              // 修改对象属性

// =================== reactive:仅用于对象/数组 ===================
const state = reactive({
  name: '李四',
  age: 25,
  hobbies: ['编程', '阅读']
})

// 不需要 .value
console.log(state.name)          // 读取
state.age++                      // 修改
state.hobbies.push('游戏')       // 修改数组
</script>

<template>
  <!-- 模板中ref自动解包,不需要.value -->
  <p>{{ count }}</p>
  <p>{{ state.name }}</p>
</template>

选择建议

场景 推荐 原因
基本类型 ref reactive不支持基本类型
对象 refreactive 都可以,ref更统一
需要整体替换 ref state.value = newObj
解构需求 reactive + toRefs 保持响应性

3.3 响应式陷阱与解决

陷阱一:解构丢失响应性

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

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

// ❌ 错误:解构后失去响应性
const { name, age } = state

// ✅ 正确:使用toRefs保持响应性
const { name, age } = toRefs(state)
</script>

陷阱二:reactive被整体替换

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

const state = reactive({ count: 0 })

// ❌ 错误:整体替换会丢失响应性
const wrongReset = () => {
  state = { count: 0 }    // state不再是响应式的
}

// ✅ 正确:修改属性
const rightReset = () => {
  state.count = 0
}
</script>

陷阱三:ref在模板中的自动解包

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

const count = ref(0)
const user = ref({ name: '张三' })
</script>

<template>
  <!-- ✅ 正确:自动解包 -->
  <p>{{ count }}</p>
  <p>{{ user.name }}</p>
  
  <!-- ❌ 错误:不需要.value -->
  <p>{{ count.value }}</p>
</template>

四、计算属性:数据的"变形金刚"

计算属性根据已有数据派生新数据,只有依赖变化时才重新计算,具有缓存特性。

4.1 基本用法

Vue 3 <script setup> 写法

<template>
  <p>总价:{{ totalPrice }}</p>
  <p>双倍:{{ doubleCount }}</p>
</template>

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

const price = ref(100)
const quantity = ref(2)
const discount = ref(0.8)
const count = ref(5)

// =================== 计算属性:有缓存 ===================
const totalPrice = computed(() => {
  console.log('计算属性执行了')    // 依赖不变就不会再执行
  return price.value * quantity.value * discount.value
})

const doubleCount = computed(() => count.value * 2)
</script>

对比Vue 3 Options API写法

export default {
  data() {
    return {
      price: 100,
      quantity: 2,
      discount: 0.8
    }
  },
  computed: {
    totalPrice() {
      return this.price * this.quantity * this.discount
    }
  }
}

4.2 计算属性 vs 方法

<template>
  <!-- 计算属性:有缓存,多次访问只计算一次 -->
  <p>{{ totalPrice }}</p>
  <p>{{ totalPrice }}</p>
  
  <!-- 方法:每次调用都执行 -->
  <p>{{ getTotalPrice() }}</p>
  <p>{{ getTotalPrice() }}</p>
</template>

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

const price = ref(100)

const totalPrice = computed(() => {
  console.log('计算属性执行')
  return price.value * 2
})

const getTotalPrice = () => {
  console.log('方法执行')
  return price.value * 2
}
</script>

4.3 可写计算属性

计算属性默认只读,但也可以设置setter:

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

const firstName = ref('张')
const lastName = ref('三')

// =================== 可写计算属性 ===================
const fullName = computed({
  get() {
    return `${firstName.value}${lastName.value}`
  },
  set(value) {
    firstName.value = value.charAt(0)
    lastName.value = value.slice(1)
  }
})

// 使用setter
const changeName = () => {
  fullName.value = '李四'    // 自动拆分为 firstName='李', lastName='四'
}
</script>

五、侦听器:数据的"守门员"

侦听器用于在数据变化时执行异步或开销较大的操作。

5.1 基本用法

Vue 3 <script setup> 写法

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

const searchKeyword = ref('')
const searchResults = ref([])

// =================== 监听ref ===================
watch(searchKeyword, (newVal, oldVal) => {
  console.log(`从 "${oldVal}" 变为 "${newVal}"`)
  searchResults.value = []
})
</script>

对比Vue 3 Options API写法

export default {
  data() {
    return {
      searchKeyword: '',
      searchResults: []
    }
  },
  watch: {
    searchKeyword(newVal, oldVal) {
      console.log(`从 "${oldVal}" 变为 "${newVal}"`)
      this.searchResults = []
    }
  }
}

5.2 监听选项

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

const searchKeyword = ref('')

watch(searchKeyword, (newVal) => {
  console.log('搜索:', newVal)
}, {
  immediate: true,    // 立即执行一次
  deep: false,        // 深度监听(用于对象)
  flush: 'post'       // DOM更新后执行
})
</script>

5.3 监听对象属性

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

// =================== 监听ref对象的属性 ===================
const user = ref({
  name: '张三',
  profile: { age: 25 }
})

// 方式一:getter函数
watch(() => user.value.name, (newVal) => {
  console.log('名字变了:', newVal)
})

// 方式二:深度监听整个对象
watch(user, (newVal) => {
  console.log('user变了')
}, { deep: true })

// 方式三:监听嵌套属性
watch(() => user.value.profile.age, (newVal) => {
  console.log('年龄变了:', newVal)
})

// =================== 监听reactive对象 ===================
const state = reactive({
  count: 0,
  user: { name: '李四' }
})

// reactive的属性可以直接监听
watch(() => state.count, (newVal) => {
  console.log('count变了:', newVal)
})

// 监听整个reactive对象(自动deep)
watch(state, (newVal) => {
  console.log('state变了')
})
</script>

5.4 实战:搜索防抖

<template>
  <input v-model="keyword" placeholder="搜索..." />
  <div v-if="loading">搜索中...</div>
  <ul v-else>
    <li v-for="item in results" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

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

const keyword = ref('')
const results = ref([])
const loading = ref(false)
let timer = null

watch(keyword, (newVal) => {
  clearTimeout(timer)
  
  timer = setTimeout(async () => {
    if (!newVal.trim()) {
      results.value = []
      return
    }
    
    loading.value = true
    // 模拟API请求
    await new Promise(r => setTimeout(r, 300))
    results.value = [
      { id: 1, name: `${newVal}结果1` },
      { id: 2, name: `${newVal}结果2` }
    ]
    loading.value = false
  }, 500)    // 防抖500ms
})
</script>

5.5 watchEffect:自动追踪依赖

Vue 3还提供了watchEffect,自动追踪回调中使用的响应式数据:

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

const count = ref(0)
const name = ref('张三')

// 自动追踪:用到谁就监听谁
watchEffect(() => {
  console.log(`count=${count.value}, name=${name.value}`)
  // count或name变化都会触发
})
</script>

六、计算属性 vs 侦听器:如何选择?

6.1 对比总结

特性 计算属性 侦听器
返回值 必须返回 可选
缓存 ✅ 有 ❌ 无
异步 ❌ 不支持 ✅ 支持
适用场景 数据派生、格式化 异步请求、副作用

6.2 选择指南

用计算属性

  • 根据已有数据计算新数据
  • 需要缓存避免重复计算
  • 纯函数,无副作用
<script setup>
import { ref, computed } from 'vue'

const firstName = ref('张')
const lastName = ref('三')
const list = ref([{ id: 1, active: true }])

// ✅ 适合计算属性
const fullName = computed(() => `${firstName.value}${lastName.value}`)
const activeList = computed(() => list.value.filter(i => i.active))
</script>

用侦听器

  • 需要执行异步操作
  • 数据变化时执行副作用
  • 需要比较新旧值
<script setup>
import { ref, watch } from 'vue'

const keyword = ref('')
const userId = ref(1)

// ✅ 适合侦听器:异步请求
watch(keyword, (val) => {
  fetchResults(val)
})

// ✅ 适合侦听器:比较新旧值
watch(userId, (newVal, oldVal) => {
  if (newVal !== oldVal) {
    fetchUser(newVal)
  }
})
</script>

七、实战案例:用户管理

综合运用所学知识,用Vue 3 <script setup> 实现一个用户管理组件:

<template>
  <div class="user-manager">
    <h2>用户管理</h2>
    
    <!-- 添加用户 -->
    <div class="add-section">
      <input 
        v-model="newName" 
        placeholder="输入用户名"
        @keyup.enter="addUser"
      />
      <button @click="addUser" :disabled="!canAdd">添加</button>
    </div>
    
    <!-- 搜索 -->
    <div class="search-section">
      <input v-model="keyword" placeholder="搜索用户..." />
    </div>
    
    <!-- 统计 -->
    <div class="stats">
      <span>总数:{{ users.length }}</span>
      <span>活跃:{{ activeCount }}</span>
      <span>结果:{{ filteredUsers.length }}</span>
    </div>
    
    <!-- 用户列表 -->
    <ul class="user-list">
      <li 
        v-for="user in filteredUsers" 
        :key="user.id"
        :class="{ active: user.isActive }"
      >
        <span>{{ user.name }}</span>
        <span class="status" @click="toggleStatus(user)">
          {{ user.isActive ? '🟢' : '🔴' }}
        </span>
        <button @click="removeUser(user.id)">删除</button>
      </li>
    </ul>
    
    <div v-if="users.length === 0" class="empty">暂无用户</div>
  </div>
</template>

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

// =================== 数据 ===================
const users = ref([
  { id: 1, name: '张三', isActive: true },
  { id: 2, name: '李四', isActive: false },
  { id: 3, name: '王五', isActive: true }
])
const newName = ref('')
const keyword = ref('')
let nextId = 4

// =================== 计算属性 ===================
const canAdd = computed(() => newName.value.trim().length >= 2)

const activeCount = computed(() => 
  users.value.filter(u => u.isActive).length
)

const filteredUsers = computed(() => {
  if (!keyword.value.trim()) return users.value
  const kw = keyword.value.toLowerCase()
  return users.value.filter(u => 
    u.name.toLowerCase().includes(kw)
  )
})

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

// =================== 生命周期 ===================
onMounted(() => {
  const saved = localStorage.getItem('users')
  if (saved) users.value = JSON.parse(saved)
})

// =================== 方法 ===================
const addUser = () => {
  if (!canAdd.value) return
  users.value.push({
    id: nextId++,
    name: newName.value.trim(),
    isActive: false
  })
  newName.value = ''
}

const removeUser = (id) => {
  const idx = users.value.findIndex(u => u.id === id)
  if (idx > -1) users.value.splice(idx, 1)
}

const toggleStatus = (user) => {
  user.isActive = !user.isActive
}
</script>

<style scoped>
.user-manager {
  max-width: 400px;
  margin: 20px auto;
  padding: 20px;
  font-family: system-ui, sans-serif;
}

h2 { color: #42b983; text-align: center; }

.add-section, .search-section {
  display: flex;
  gap: 10px;
  margin: 15px 0;
}

input {
  flex: 1;
  padding: 8px 12px;
  border: 2px solid #ddd;
  border-radius: 6px;
}

input:focus {
  outline: none;
  border-color: #42b983;
}

button {
  padding: 8px 16px;
  background: #42b983;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
}

button:disabled { background: #ccc; cursor: not-allowed; }

.stats {
  display: flex;
  justify-content: space-between;
  padding: 10px;
  background: #f5f5f5;
  border-radius: 6px;
  font-size: 14px;
}

.user-list {
  list-style: none;
  padding: 0;
}

.user-list li {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px;
  margin: 8px 0;
  background: #f9f9f9;
  border-radius: 6px;
}

.user-list li.active {
  background: #f0fdf4;
  border-left: 3px solid #42b983;
}

.status { cursor: pointer; }

.empty {
  text-align: center;
  color: #999;
  padding: 30px;
}
</style>

八、总结

今天我们深入学习了Vue实例与数据绑定,核心要点:

主题 <script setup> 写法 关键点
数据 ref() / reactive() ref需要.value,reactive不需要
计算属性 computed(() => {}) 有缓存,适合数据派生
侦听器 watch(source, callback) 支持异步,适合副作用
生命周期 onMounted() setup阶段直接执行代码

记住这些要点

  1. 新项目推荐使用<script setup>语法糖
  2. ref是万能选择,reactive仅用于对象
  3. 能用计算属性就不用侦听器
  4. onUnmounted中清理副作用

下一站预告

在下一篇文章《模板语法与指令详解》中,我们将学习:

  • 模板语法详解
  • 常用指令(v-if、v-for、v-bind等)
  • 自定义指令开发

敬请期待!


作者:洋洋技术笔记
发布日期:2026-02-28
系列:Vue.js从入门到精通 - 第2篇

Vue实例与数据绑定详解 | Vue3生命周期、ref、computed与watch完整指南

Vue 底层原理 & 新特性

Vue 底层原理 & 新特性

本文深入探讨 Vue 的底层架构演进、核心原理以及最新版本带来的突破性特性,面向面试和技术提升。


原文地址

墨渊书肆/Vue 底层原理 & 新特性


Vue 版本变动历史

Vue 自发布以来经历了多个重要版本的迭代,每个版本的改动都带来了架构优化和新特性,同时也伴随着一些 Breaking Changes。以下是 Vue 各个重要版本的变动概述:

Vue 2.0 (2016年)

  • 引入 Virtual DOMVue2 正式引入了虚拟 DOM,这是框架性能提升的关键技术。
  • 组件系统增强:增加了异步组件生命周期钩子调整等特性。
  • 支持 SSR:原生支持服务器端渲染,提升了 SEO 和首屏加载性能。
  • Vuex 与 Vue Router:作为官方解决方案提供状态管理路由管理

Vue 2.5 - 2.7 (2017-2022年)

  • Vue 2.5:改进了 TypeScript 支持,增强了响应式系统
  • Vue 2.6:引入了新的模板编译策略,插槽语法改进。
  • Vue 2.7:作为 Vue2 最后的大版本,引入了一些 Composition API 的向下兼容实现,为 Vue3 迁移做铺垫。

Vue 3.0 (2022年)

  • Composition API:引入了全新的组合式 API,提供了更灵活的逻辑组织方式。
  • Proxy 响应式系统:使用 Proxy 替代 Object.defineProperty,解决了 Vue2 响应式的诸多痛点。
  • Teleport & Fragments:新增内置组件,支持跨 DOM 层级渲染和多根节点模板。
  • 性能提升:更快的解析速度和更小的运行时体积,渲染性能提升约 100%。
  • 更好的 TypeScript 支持:原生支持 TypeScript,类型推导更加完善。
  • 自定义渲染器 API:增强的渲染器 API,便于跨平台开发。

Vue 3.1 - 3.4 (2023-2024年)

  • Vue 3.1:引入了 defineOptions 宏,改进编译优化。
  • Vue 3.3:进一步改进宏支持,类型化 props/emits 更加方便,简化了泛型组件的使用。
  • Vue 3.4:性能进一步提升,响应式系统优化,编译器效率改进。

Vue 3.5 及未来 (2024-2025年)

  • Vue 3.5:引入了响应式解构语法(Reactivity Transform),改善了大型应用的开发体验。
  • Vapor ModeVue 团队正在实验的全新渲染策略,跳过虚拟 DOM直接生成高效的 JavaScript 代码。
  • 更完善的生态集成:与 Vite 5PiniaVue Router 4 的深度整合。

响应式原理深度解析

响应式系统是 Vue 的核心,也是面试中的高频考点。Vue2 和 Vue3 在响应式实现上有着本质的区别。

Vue2:Object.defineProperty

Vue2 使用 Object.defineProperty 来劫持数据的 getter 和 setter:

function defineReactive(obj, key, val) {
  // 为每个属性创建 Dep 实例
  const dep = new Dep()
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 依赖收集
      if (Dep.target) {
        dep.depend()
      }
      return val
    },
    set: function reactiveSetter(newVal) {
      if (newVal === val) return
      // 通知更新
      dep.notify()
    }
  })
}

Vue2 响应式的局限性

  1. 无法检测对象属性的添加/删除Object.defineProperty 只能劫持已存在的属性,对于新增属性无能为力。
  2. 数组操作无法响应:通过下标修改数组元素 arr[0] = value 不会触发更新。
  3. 深层监听需要递归:对深层对象的监听会带来性能开销。

解决方案:Vue2 提供了 Vue.set / Vue.delete 以及重写数组方法来应对这些场景。

Vue3:Proxy

Vue3 使用 ES6 的 Proxy 来实现响应式:

function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      // 依赖收集
      track(target, key)
      const result = Reflect.get(target, key, receiver)
      // 如果是对象,递归代理实现深层响应式
      return isObject(result) ? reactive(result) : result
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver)
      // 触发更新
      trigger(target, key)
      return result
    },
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      trigger(target, key)
      return result
    }
  })
}

Vue3 响应式的优势

  1. 原生支持属性增删:Proxy 可以拦截对象的所有操作,包括新增和删除属性。
  2. 数组操作完全响应:下标赋值、数组长度变化等都能被正确拦截。
  3. 更好的性能:Proxy 是懒执行的,只有当访问属性时才会进行依赖收集。
  4. API 统一:ref 和 reactive 内部实现统一,简化了学习成本。

依赖收集与触发机制

Vue 的响应式系统遵循观察者模式,包含三个核心角色:

  1. Observer(观察者):负责劫持数据,收集依赖。
  2. Dep(依赖管理器):存储依赖,管理订阅者。
  3. Watcher(订阅者):在数据变化时执行更新回调。
// Dep 实现
class Dep {
  constructor() {
    this.subs = new Set() // 存储 Watcher
  }
  
  depend() {
    if (Dep.target) {
      this.subs.add(Dep.target)
    }
  }
  
  notify() {
    this.subs.forEach(watcher => watcher.update())
  }
}

// Watcher 实现
class Watcher {
  constructor(fn) {
    this.getter = fn
    this.value = this.get()
  }
  
  get() {
    Dep.target = this
    const value = this.getter()
    Dep.target = null
    return value
  }
  
  update() {
    this.value = this.getter()
  }
}

模板编译原理

Vue 的模板编译是将模板字符串转换为可执行渲染函数的过程,主要分为三个阶段。

1. 解析阶段(Parse)

将模板字符串解析为 AST(抽象语法树):

// 模板
<div class="container">
  <h1>{{ title }}</h1>
</div>

// AST 结构
{
  type: 'Element',
  tag: 'div',
  props: [{ type: 'Attribute', name: 'class', value: 'container' }],
  children: [
    {
      type: 'Element',
      tag: 'h1',
      children: [{ type: 'Interpolation', content: { expression: 'title' } }]
    }
  ]
}

2. 优化阶段(Optimize)

Vue3 的编译器会进行静态节点提升(Static Hoisting):

  • 静态节点:不包含任何响应式依赖的节点(如纯文本、静态属性)。
  • 事件缓存:对于不响应式变化的事件处理函数,进行缓存处理。
// 优化前
render() {
  return h('button', { onClick: this.handleClick }, 'Click')
}

// 优化后 - 事件函数被缓存
const handleClick = this.handleClick
render() {
  return h('button', { onClick: handleClick }, 'Click')
}

3. 代码生成阶段(Generate)

将 AST 转换为渲染函数:

// 生成的渲染函数
function render() {
  return _vue.createVNode('div', { class: 'container' }, [
    _vue.createVNode('h1', null, _vue.toDisplayString(this.title))
  ])
}

虚拟 DOM 与 Diff 算法

虚拟 DOM 的本质

虚拟 DOM 是真实 DOM 的 JavaScript 对象表示:

// VNode 结构
const vnode = {
  type: 'div',
  props: { class: 'container' },
  children: [
    { type: 'h1', children: 'Hello' }
  ],
  el: null // 关联的真实 DOM 引用
}

虚拟 DOM 的优势

  1. 跨平台渲染:同一套 VNode 结构可以渲染到不同平台。
  2. 减少 DOM 操作:在内存中进行对比,只更新必要的真实 DOM。
  3. 声明式开发:开发者只需关注数据变化,框架自动处理 DOM 更新。

Vue2 Diff:单端比较

Vue2 采用传统的 Diff 算法,从左到右依次对比:

function updateChildren(oldChildren, newChildren) {
  let oldStartIndex = 0
  let newStartIndex = 0
  let oldEndIndex = oldChildren.length - 1
  let newEndIndex = newChildren.length - 1
  
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    // 简单比较...O(n) 复杂度
  }
}

Vue3 Diff:双端比较 + 最长递增子序列

Vue3 采用了更高效的 Diff 算法

  1. 双端比较:同时从新旧列表的首尾进行对比。
  2. key 映射:通过 Map 快速定位相同 key 的节点。
  3. 最长递增子序列:对于需要移动的节点,使用 LIS 算法最小化移动次数。
// Vue3 Diff 核心逻辑
function diffChildren(n1, n2, parent) {
  const c1 = n1.children
  const c2 = n2.children
  const oldStart = 0
  const newStart = 0
  const oldEnd = c1.length - 1
  const newEnd = c2.length - 1
  
  // 双端比较策略
  while (oldStart <= oldEnd && newStart <= newEnd) {
    if (c1[oldStart].key === c2[newStart].key) {
      // 节点相同,继续
      patch(c1[oldStart], c2[newStart], parent)
      oldStart++
      newStart++
    } else if (c1[oldEnd].key === c2[newEnd].key) {
      // 尾部匹配
      patch(c1[oldEnd], c2[newEnd], parent)
      oldEnd--
      newEnd--
    }
    // ... 更多比较策略
  }
}

组件生命周期与更新机制

Vue2 生命周期

阶段 钩子 说明
初始化 beforeCreate 实例刚创建,数据观测未完成
初始化 created 数据观测完成,DOM 未生成
挂载 beforeMount 模板编译完成,准备挂载
挂载 mounted DOM 挂载完成,可操作 DOM
更新 beforeUpdate 数据变化,DOM 未更新
更新 updated DOM 更新完成
销毁 beforeDestroy 实例销毁前,可清理
销毁 destroyed 实例已销毁

Vue3 生命周期

Vue2 Vue3 (Composition API)
beforeCreate -
created -
beforeMount onBeforeMount
mounted onMounted
beforeUpdate onBeforeUpdate
updated onUpdated
beforeDestroy onBeforeUnmount
destroyed onUnmounted

组件更新流程

数据变化 → 触发 setter → Dep 通知 Watcher → 
触发 update() → 重新执行 render() 生成新的 VNode → 
Diff 对比 → 更新真实 DOM

Vue3 新特性深度解析

1. Composition API

组合式 API 是 Vue3 最重要的变化,提供了更灵活的逻辑组织方式:

// setup 函数 - 组件逻辑入口
import { ref, computed, onMounted, watch } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const doubled = computed(() => count.value * 2)
    
    function increment() {
      count.value++
    }
    
    onMounted(() => {
      console.log('Component mounted!')
    })
    
    watch(count, (newVal) => {
      console.log(`Count changed to ${newVal}`)
    })
    
    return { count, doubled, increment }
  }
}

ref vs reactive

  • ref:用于原始类型,创建包含 .value 的响应式对象。
  • reactive:用于对象,创建深层响应式对象。
import { ref, reactive } from 'vue'

const count = ref(0)        // 原始类型
const state = reactive({   // 对象类型
  user: { name: 'Vue' }
})

// 模板中自动解包
console.log(count.value)   // JS 中需要 .value
console.log(state.user)    // reactive 直接访问

2. Teleport

将组件渲染到指定 DOM 位置,常用于模态框:

<Teleport to="body">
  <div v-if="show" class="modal">
    <p>Modal Content</p>
  </div>
</Teleport>

3. Fragments

支持多根节点模板:

<!-- Vue3 允许 -->
<template>
  <div>A</div>
  <div>B</div>
</template>

4. Suspense

处理异步组件加载状态:

<Suspense>
  <template #default>
    <AsyncComponent />
  </template>
  <template #fallback>
    Loading...
  </template>
</Suspense>

Vue vs React:核心差异对比

响应式实现

特性 Vue2 Vue3 React
原理 Object.defineProperty Proxy useState/useReducer
触发方式 自动 自动 手动调用 setState
数组响应 重写方法 Proxy 需使用 Immer 或 immutable
深层监听 递归 Proxy 懒加载 useEffect 依赖

模板 vs JSX

  • Vue:模板语法,HTML-like,学习成本低,编译器优化。
  • React:JSX,JavaScript 表达式,更灵活,但需要一定学习曲线。

状态管理

  • Vue:Pinia(推荐)或 Vuex,采用模块化设计。
  • React:Redux/Zustand/Jotai,函数式风格。

渲染性能

Vue3 由于模板编译优化和 Proxy 响应式,在大多数场景下性能优于 React。React 的优势在于 Fiber 架构带来的精细化控制和并发渲染能力。


性能优化策略

1. 渲染优化

// 使用 v-once 静态内容
<div v-once>{{ staticContent }}</div>

// 正确使用 key
<li v-for="item in items" :key="item.id">{{ item.name }}</li>

// v-if vs v-show 选择
<div v-if="show">很少切换</div>
<div v-show="show">频繁切换</div>

2. 响应式优化

import { shallowRef, markRaw } from 'vue'

// 浅层响应式 - 适合大型数据
const largeList = shallowRef([])

// 非响应式数据 - 适合不需要响应式的对象
const plainObj = markRaw({ /* ... */ })

3. 组件懒加载

// 路由懒加载
const Home = () => import('./views/Home.vue')

// 异步组件
import { defineAsyncComponent } from 'vue'
const AsyncComp = defineAsyncComponent(() => import('./AsyncComp.vue'))

4. KeepAlive 缓存

<KeepAlive include="Home,About">
  <router-view />
</KeepAlive>

面试常见问题汇总

1. Vue2 和 Vue3 响应式的区别?

Vue2 使用 Object.defineProperty,需要递归监听所有属性,无法检测新增/删除属性;Vue3 使用 Proxy,原生支持属性增删,性能更好。

2. Vue 的依赖收集是如何实现的?

通过 Dep 类管理订阅者,Watcher 在读取响应式属性时将自身添加到 Dep,属性变化时 Dep 通知所有 Watcher 更新。

3. Vue3 Diff 算法相比 Vue2 有什么优化?

Vue3 采用 双端比较 策略,结合 最长递增子序列 算法,最小化 DOM 移动次数,复杂度从 O(n³) 优化到 O(n)。

4. Vue3 的 Composition API 有什么优势?

  • 更好的 TypeScript 支持
  • 代码更容易复用和抽取
  • 逻辑相关代码组织在一起,而不是按选项分散

5. Vue3 的性能为什么比 Vue2 好?

  • Proxy 替代 Object.defineProperty,深层监听懒执行
  • 模板编译优化:静态节点提升事件缓存
  • 优化的 Diff 算法
  • 更小的打包体积

6. Vue 的 nextTick 原理?

Vue 使用 Promise + MutationObserver + setTimeout 实现异步队列,在 DOM 更新后通过微任务执行回调。

7. keep-alive 的实现原理?

通过缓存 VNode,保存组件实例和状态,切换时复用而非重新创建。activated/deactivated 钩子用于感知缓存状态变化。

8. Vue 的模板编译过程?

解析优化(静态节点提升)→ 代码生成(渲染函数)


总结

Vue 作为一个渐进式框架,在保持易用性的同时不断深化底层技术的实现。Vue3 通过 Composition API、Proxy 响应式系统、优化的 Diff 算法等特性,显著提升了开发体验和运行性能。理解这些底层原理不仅有助于应对面试,更能在实际开发中做出更好的技术决策。

Vue 团队正在探索的 Vapor Mode 未来可能带来更大的性能突破,值得持续关注。

Vue 基础理论 & API 使用

Vue 基础理论 & API 使用

本文主要记录 Vue 的基础理论、核心概念与常用 API 使用方法,面向面试和日常开发参考。


原文地址

墨渊书肆/Vue 基础理论 & API 使用


Vue 简介

Vue 是一个渐进式 JavaScript 框架,由尤雨溪于 2014 年创建。Vue 核心库聚焦于视图层,易于学习和集成,同时能够驱动复杂的单页应用程序(SPA)开发。

核心特点

  • 响应式数据绑定 (MVVM 模式)
  • 组件化开发
  • 虚拟 DOM
  • 指令系统
  • 渐进式架构

安装与项目创建

Vite(推荐)

# 创建 Vue3 项目
npm create vue@latest

# 或使用 Vite 直接创建
npm create vite@latest my-vue-app -- --template vue

Vue CLI

npm install -g @vue/cli
vue create my-project

基础指令

v-model 双向绑定

v-modelVue 中用于表单输入和数据双向绑定的核心指令,本质是 v-bind + v-on语法糖

基本用法

<input v-model="message">
<p>{{ message }}</p>

修饰符

修饰符 说明
.lazy 在 change 事件时更新,而非 input
.number 自动转换为数值
.trim 去除首尾空白

自定义 v-model(Vue 3.4+):

// 子组件
defineProps(['modelValue'])
defineEmits(['update:modelValue'])

// 父组件
<MyInput v-model:title="title" />

v-if / v-show 条件渲染

特性 v-if v-show
DOM 操作 创建/销毁 display: none
初始渲染 惰性 立即渲染
切换性能
适用场景 很少切换 频繁切换
<div v-if="type === 'A'">A</div>
<div v-else-if="type === 'B'">B</div>
<div v-else>C</div>

v-for 列表渲染

<li v-for="(item, index) in items" :key="item.id">
  {{ index }} - {{ item.name }}
</li>

注意事项

  • 必须使用 :key 绑定唯一标识
  • 不建议使用数组索引作为 key
  • Vue2 中 v-for 优先级高于 v-if,Vue3 中相反

v-bind / v-on 属性与事件

<!-- 绑定属性 -->
<img :src="url">

<!-- 绑定多个属性 -->
<img v-bind="attrs">

<!-- 事件监听 -->
<button @click="handleClick">Click</button>

<!-- 事件修饰符 -->
<button @click.stop="handle">阻止冒泡</button>
<button @click.prevent="handle">阻止默认行为</button>

组件选项

data

组件的响应式数据源,必须返回纯对象:

export default {
  data() {
    return {
      count: 0,
      user: { name: 'Vue' }
    }
  }
}

props

父子组件通信的重要方式,支持类型校验默认值

export default {
  props: {
    // 基础类型
    title: String,
    // 多个类型
    age: [Number, String],
    // 带默认值
    size: {
      type: String,
      default: 'medium'
    },
    // 必需
    id: {
      type: Number,
      required: true
    },
    // 自定义校验
    score: {
      validator: (value) => value >= 0 && value <= 100
    }
  }
}

Vue3 组合式 API

const props = defineProps({
  title: String,
  count: { type: Number, default: 0 }
})

computed 计算属性

缓存计算结果,只在依赖变化时重新计算:

export default {
  data() { return { count: 1 } },
  computed: {
    // 只读
    doubled() { return this.count * 2 },
    // 可写
    plusOne: {
      get() { return this.count + 1 },
      set(val) { this.count = val - 1 }
    }
  }
}

methods 方法

处理业务逻辑,每次渲染都会重新创建:

export default {
  methods: {
    handleClick() { /* ... */ }
  }
}

watch 监听器

监听数据变化并执行回调:

export default {
  data() { return { count: 0 } },
  watch: {
    count(newVal, oldVal) {
      console.log(`变化: ${oldVal}${newVal}`)
    },
    // 深度监听
    'obj.data': {
      handler() { /* ... */ },
      deep: true
    },
    // 立即执行
    name: {
      handler() { /* ... */ },
      immediate: true
    }
  }
}

Composition API

Vue3 引入的组合式 API,提供了更灵活的逻辑组织方式。

ref / reactive 响应式

import { ref, reactive } from 'vue'

// ref - 原始类型
const count = ref(0)
count.value++

// reactive - 对象
const state = reactive({
  user: { name: 'Vue' }
})
state.user.name = 'Vue3'

区别

| 特性 | ref | reactive | | ----- -| ----- | ---------- | | 适用类型 | 任意类型 | 对象/数组 | | 访问方式 | .value | 直接属性 | | 重新赋值 | 响应式 | 替换整个对象 |

toRefs / toRef

将 reactive 对象解构为独立的 ref:

import { reactive, toRefs } from 'vue'

const state = reactive({ name: 'Vue', age: 25 })
const { name, age } = toRefs(state)

// 或创建单个 ref
const nameRef = toRef(state, 'name')

computed() 计算属性

import { ref, computed } from 'vue'

const count = ref(0)
const doubled = computed(() => count.value * 2)

watch / watchEffect

import { ref, watch, watchEffect } from 'vue'

// watch - 显式监听
watch(count, (newVal, oldVal) => { /* ... */ })
watch(() => state.name, (newVal) => { /* ... */ })

// watchEffect - 自动收集依赖
watchEffect(() => {
  console.log(count.value) // 自动追踪
})

执行时机控制

  • watch:默认同步执行
  • watchEffect:默认 pre(在组件更新前)
  • watchPostEffect:在组件更新后执行
  • watchSyncEffect:同步执行

生命周期钩子

import { 
  onMounted, 
  onUpdated, 
  onUnmounted 
} from 'vue'

export default {
  setup() {
    onMounted(() => { console.log('mounted') })
    onUpdated(() => { console.log('updated') })
    onUnmounted(() => { console.log('unmounted') })
  }
}

组件通信

Props / $emit

// 父组件
<Child :count="count" @update="handleUpdate" />

// 子组件
const props = defineProps({ count: Number })
const emit = defineEmits(['update'])
emit('update', props.count + 1)

Provide / Inject

祖先向后代跨级传值

// 祖先组件
provide('key', 'value')

// 后代组件
const value = inject('key')

响应式

// 祖先
const count = ref(0)
provide('count', count)

// 后代 - 修改会影响所有后代
const count = inject('count')

attrs/attrs / listeners

透传属性和事件:

<!-- 透传所有 -->
<Child v-bind="$attrs" v-on="$listeners" />

Pinia 状态管理

Vue3 推荐的状态管理方案:

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubled: (state) => state.count * 2
  },
  actions: {
    increment() { this.count++ }
  }
})

内置组件

Transition

为元素添加过渡动画

<Transition name="fade">
  <div v-if="show">Content</div>
</Transition>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

过渡类名

  • v-enter-from / v-leave-from:起始状态
  • v-enter-active / v-leave-active:过渡中
  • v-enter-to / v-leave-to:结束状态

KeepAlive

缓存组件实例:

<KeepAlive include="A,B" exclude="C">
  <component :is="current" />
</KeepAlive>

生命周期

  • activated:激活时
  • deactivated:停用时

Teleport

渲染到指定 DOM 位置:

<Teleport to="#modal-root">
  <div class="modal">Content</div>
</Teleport>

Suspense

处理异步组件(实验性):

<Suspense>
  <template #default>
    <AsyncComponent />
  </template>
  <template #fallback>
    Loading...
  </template>
</Suspense>

生命周期

Options API

阶段 钩子 说明
初始化 beforeCreate 实例创建前
初始化 created 数据观测完成
挂载 beforeMount 模板编译完成
挂载 mounted DOM 挂载完成
更新 beforeUpdate 数据变化,DOM 未更新
更新 updated DOM 更新完成
销毁 beforeUnmount 实例销毁前
销毁 unmounted 实例已销毁

父子组件执行顺序

挂载:父 created → 子 created → 子 mounted → 父 mounted

更新:父 beforeUpdate → 子 beforeUpdate → 子 updated → 父 updated

销毁:父 beforeUnmount → 子 beforeUnmount → 子 unmounted → 父 unmounted


常用技巧

动态类名

<div :class="{ active: isActive, 'text-center': isCenter }">
<div :class="[activeClass, errorClass]">

条件类名

<div :class="[isActive && 'active']">

动态绑定 style

<div :style="{ color: textColor, fontSize: fontSize + 'px' }">

函数式组件

export default {
  functional: true,
  props: { msg: String },
  render(h, context) {
    return h('div', context.props.msg)
  }
}

异步组件

import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Async.vue'),
  loadingComponent: Loading,
  errorComponent: Error,
  delay: 200,
  timeout: 3000
})

面试常见问题

1. v-model 的原理?

本质是 v-bind:value + @input语法糖,监听 input 事件并更新数据。

2. v-for 中 key 的作用?

帮助 Vue 识别节点身份,实现高效的 DOM 复用。推荐使用数据唯一 ID,避免使用数组索引

3. computed 和 watch 的区别?

  • computed:计算属性,依赖变化自动计算,缓存结果
  • watch:监听器,监听数据变化,执行异步或复杂逻辑

4. Vue2 和 Vue3 的区别?

  • 响应式:Object.defineProperty → Proxy
  • API:Options API → Composition API
  • 多根节点:不支持 → 支持
  • 生命周期:beforeDestroy → beforeUnmount

5. 组件通信方式有哪些?

  • props / $emit:父子
  • provide / inject:祖先-后代
  • attrs/attrs / listeners:透传
  • 事件总线:兄弟/任意
  • Pinia/Vuex:全局状态

6. Vue 的响应式原理?

通过 Proxy/Object.defineProperty 劫持数据访问,在 getter 中收集依赖,setter 中触发更新


总结

Vue 以其简洁的 API 和渐进式的设计理念,成为前端开发的主流框架。掌握 Vue 的基础理论、常用 API 以及组件通信方式,是 Vue 开发者的必备技能。Vue3Composition API 提供了更现代化的开发范式,建议在实际项目中优先使用。

状态提升:前端开发中的状态管理的设计思想

在前端开发中,我们几乎绕不开一个核心问题:状态(state)该放在哪里?

随着项目复杂度的提升,状态的存放位置也会经历一次次“升级”:

子组件 → 父组件 → Hook(组合式函数)→ Pinia(全局状态管理)

这篇文章,我会带你一步步拆解这个“状态提升”的演进过程,并结合VUE代码示例,帮你理解每一次升级背后的动机和设计思想。


一、第一阶段:状态在子组件中(局部状态)

在项目早期,我们通常会把状态直接写在子组件内部。

示例:一个计数器组件

<!-- Counter.vue -->
<script setup>
import { ref } from 'vue'

const count = ref(0)

const increment = () => {
  count.value++
}
</script>

<template>
  <div>
    <p>{{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

特点

  • 状态封装在组件内部
  • 简单直观
  • 适合完全独立的 UI 组件

问题

如果有两个组件都需要用到这个 count 呢?

比如:

<Counter />
<Display />

Display 组件也想显示这个 count,怎么办?

这时我们就需要第一次升级。


二、第二阶段:从子组件提升到父组件

当多个子组件共享状态时,我们会把状态“提升”到它们的共同父组件。

这和 React 的“状态提升”思想是一致的。

父组件管理状态

<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import Counter from './Counter.vue'
import Display from './Display.vue'

const count = ref(0)

const increment = () => {
  count.value++
}
</script>

<template>
  <Counter :count="count" @increment="increment" />
  <Display :count="count" />
</template>

子组件只负责展示和触发

<!-- Counter.vue -->
<script setup>
defineProps({
  count: Number
})

defineEmits(['increment'])
</script>

<template>
  <button @click="$emit('increment')">+1</button>
</template>

优点

  • 状态集中管理
  • 数据流清晰(单向数据流)

缺点

  • 层级一深就会出现:

    • props drilling(层层传参)
    • 事件层层冒泡
  • 父组件变得“臃肿”

当项目规模扩大后,这种方式开始吃力。

于是我们进行第二次升级。


三、第三阶段:从父组件提升到 Hook(组合式函数)

在 Vue 3 中,Composition API 让我们可以把逻辑抽离成 Hook(组合式函数)。

我们把状态抽离到一个独立文件中。

创建一个 useCounter.ts

// useCounter.ts
import { ref } from 'vue'

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

  const increment = () => {
    count.value++
  }

  return {
    count,
    increment
  }
}

在组件中使用

<script setup>
import { useCounter } from './useCounter'

const { count, increment } = useCounter()
</script>

优点

  • 逻辑复用
  • 代码结构更清晰
  • 组件变“干净”
  • 可测试性更强

但问题来了

如果两个组件都调用 useCounter()

const a = useCounter()
const b = useCounter()

它们的 count 是:

❌ 不共享的
每调用一次都会创建新的状态实例。

如果我们希望多个组件共享同一个状态怎么办?

这时候,Hook 已经不够用了。

于是我们迎来终极升级。


四、第四阶段:从 Hook 升级到 Pinia

当状态需要在多个页面、多个模块、多个层级中共享时,我们就需要真正的状态管理工具。

在 Vue 生态中,主流选择是:

  • Vuex(旧)
  • Pinia(官方推荐)

这里我们使用 Pinia。


什么是 Pinia?

Pinia 是 Vue 官方推荐的状态管理库,支持 Vue 3,API 设计非常现代化。

它的理念是:

Store = 可复用的全局 Hook


创建一个 Counter Store

// stores/counter.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)

  const increment = () => {
    count.value++
  }

  return { count, increment }
})

在组件中使用

<script setup>
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

<template>
  <button @click="counter.increment">
    {{ counter.count }}
  </button>
</template>

关键特性

  • 所有组件共享同一个 store
  • 自动响应式
  • DevTools 支持
  • 模块化管理

状态升级的本质

我们来总结一下这四个阶段:

阶段 状态位置 适用场景 缺点
子组件 组件内部 完全独立组件 无法共享
父组件 父级 局部共享 层级深会混乱
Hook 逻辑抽离 逻辑复用 默认不共享
Pinia 全局 Store 跨页面共享 增加架构复杂度

设计哲学:状态放在哪里?

可以用一句话概括:

状态应该放在“刚好需要它的最上层”

  • 只一个组件用 → 放子组件
  • 两个兄弟组件用 → 放父组件
  • 多个地方用但不共享 → Hook
  • 全局共享 → Pinia

这是一种“按需升级”的架构策略。


不要一开始就上 Pinia

不要直接提升到pinia,这会带来:

  • 不必要的全局耦合
  • 难以维护
  • 状态污染

记住:

全局状态是一种“权力”,不要滥用。

应该从下至上,找到最合适的地方,随着需求的变更,代码也跟随变更。


架构升级的思维模型

这个升级过程,本质上体现的是:

  • 局部化 → 抽象化 → 全局化
  • 组件驱动 → 逻辑驱动 → 状态驱动

这也是现代前端架构演进的核心路线。


结语

Vue 的状态管理不是非黑即白的选择,而是一个“渐进增强”的过程。

当你理解了:

  • 为什么提升状态
  • 什么时候该提升
  • 提升的边界在哪里

你就真正掌握了 Vue 状态管理的设计思想。

思考

  • 如果所有的父子组件都需要这个状态呢?(Provide/Inject)

Electron 实现仿豆包划词取词功能:从 AI 生成到落地踩坑记

作为一名前端开发,最近接到了一个「划词取词」的需求 —— 老板希望做一个类似豆包、有道词典的划词识别功能,核心要求是低成本、离线可用、Windows 平台优先。整个开发过程一波三折,从 AI 生成的「截屏 + AI 识别」方案,到离线 OCR,最后落地到「划词 + Ctrl+C + 命名管道通信」,踩了不少坑,也积累了一些实战经验,特此记录。

需求背景

核心诉求:用户在任意窗口(浏览器、文档、办公软件等)用鼠标划选文字后,能快速获取选中的文本内容,用于后续的翻译 / 解释等操作,要求:

  • 离线运行,无网络依赖;
  • 仅支持 Windows 系统(公司主流办公环境);
  • 低成本(避免调用付费 OCR/AI 接口);
  • 尽可能不干扰用户原有操作。

三版方案的迭代之路

第一版:截屏 + AI 识别(被打回)

最初想着「快速搞定」,直接让 AI 生成了一份 Python 代码:监听鼠标按下 / 抬起的坐标,截取对应区域的屏幕截图,然后调用 AI 接口识别图片中的文字。

代码核心逻辑是用PIL.ImageGrab截屏,再通过 base64 传给 AI 接口:

# 第一版核心(简化)
def on_click_up(x, y, button, pressed):
    if not pressed:
        # 计算鼠标划选区域
        left = min(last_x, x)
        top = min(last_y, y)
        right = max(last_x, x)
        bottom = max(last_y, y)
        # 截屏
        img = ImageGrab.grab(bbox=(left, top, right, bottom))
        # 调用AI接口识别
        img_base64 = base64.b64encode(img_bytes).decode()
        res = requests.post(AI_API, json={"image": img_base64})
        text = res.json()["text"]
        print("识别的文字:", text)

问题:老板看到 AI 接口的调用成本后直接打回 —— 按公司的使用量,每月要额外支出数千元,完全不符合「低成本」要求。

第二版:离线 OCR(放弃)

既然 AI 接口不能用,那就换离线 OCR(比如 Tesseract)。但实际测试后发现:

  • 不同字体、字号、背景色下,OCR 准确率极低(尤其是小字体 / 模糊文字);
  • 需要用户额外安装 OCR 引擎,部署成本高;
  • 对截图的分辨率、区域裁剪要求极高,适配成本高。

最终因为「准确率达不到老板预期」,这个方案也被放弃了。

第三版:划词 + Ctrl+C + 跨进程通信(最终落地)

某天突然想到:用户划选文字后,系统本身已经把选中的内容「暂存」了,只要调用Ctrl+C复制,就能直接从剪贴板拿到文本 —— 这才是最直接、零成本、准确率 100% 的方案!

核心思路:

  1. Python 脚本监听鼠标划选动作(按下→拖动→抬起);
  2. 判定为有效划词后,自动触发Ctrl+C复制选中内容;
  3. 从剪贴板读取文本,通过「命名管道」传给 Electron 主进程;
  4. Electron 接收数据后,再分发给渲染进程做后续处理。

技术实现拆解

最终方案分为「Python 端(监听 + 复制 + 通信)」和「Electron 端(管道服务 + 数据处理)」两部分,核心依赖 Windows 的「命名管道(Named Pipe)」实现跨进程通信。

1. Python 端:监听划词并发送数据

Python 负责核心的「人机交互监听」和「剪贴板操作」,使用pynput监听鼠标 / 键盘,pyperclip操作剪贴板,win32file实现命名管道通信。

核心逻辑

from pynput import mouse, keyboard
import pyautogui
import pyperclip
import win32file
import pywintypes
import json
import win32gui
import win32process
import psutil

class ClipboardMonitor:
    def __init__(self):
        self.last_mouse_down_time = 0
        self.last_mouse_down_position = (0, 0)
        self.last_user_clipboard_content = None  # 保存用户原有剪贴板内容
        self.keyboard_activity = False  # 避免键盘操作干扰

    # 监听鼠标按下:记录起始位置+发送坐标给Electron
    def on_click_down(self, x, y, button, pressed):
        if pressed:
            self.last_mouse_down_position = (x, y)
            # 发送鼠标按下坐标给Electron(用于判断是否在目标窗口内)
            message = f"click_down_mouse_position:{x},{y}"
            self.send_to_electron(message)
            # 记录当前聚焦的应用(用于过滤禁用列表)
            self.last_mouse_down_client = self.get_focused_application()

    # 监听鼠标抬起:判定有效划词并复制
    def on_click_up(self, x, y, button, pressed):
        if not pressed:
            # 计算鼠标拖动距离(过滤误点击)
            distance = ((x - self.last_mouse_down_position[0]) **2 + (y - self.last_mouse_down_position[1])** 2) **0.5
            # 有效划词:距离>10px + 无键盘/鼠标干扰
            if distance > 10 and not self.keyboard_activity:
                # 检查配置:是否允许打开悬浮窗、当前应用是否在禁用列表
                if self.check_can_open_float_win() and self.last_mouse_down_client not in self.get_disable_client_list():
                    # 保存用户原有剪贴板内容(避免覆盖)
                    self.last_user_clipboard_content = pyperclip.paste()
                    # 自动触发Ctrl+C复制选中内容
                    pyautogui.hotkey('ctrl', 'c')
                    new_clipboard_content = pyperclip.paste()
                    # 对比剪贴板:确认为新选中的内容
                    if new_clipboard_content != self.last_user_clipboard_content:
                        # 封装数据并发送给Electron
                        self.send_clipboard_data(x, y, new_clipboard_content)
                    # 还原用户剪贴板(核心!避免干扰用户)
                    pyperclip.copy(self.last_user_clipboard_content)

    # 获取当前聚焦的应用名称(用于过滤)
    def get_focused_application(self):
        hwnd = win32gui.GetForegroundWindow()
        _, pid = win32process.GetWindowThreadProcessId(hwnd)
        try:
            process = psutil.Process(pid)
            return process.name()
        except:
            return "Unknown"

    # 命名管道发送数据给Electron
    def send_to_electron(self, message):
        pipe_name = r'\.\pipe\quick_word_electron_python_pipe'
        try:
            handle = win32file.CreateFile(
                pipe_name,
                win32file.GENERIC_WRITE,
                0,
                None,
                win32file.OPEN_EXISTING,
                0,
                None
            )
            win32file.WriteFile(handle, message.encode())
            win32file.CloseHandle(handle)
        except pywintypes.error as e:
            print(f"管道通信失败:{e}")

    # 启动监听
    def start(self):
        mouse_listener_down = mouse.Listener(on_click=self.on_click_down)
        mouse_listener_up = mouse.Listener(on_click=self.on_click_up)
        keyboard_listener = keyboard.Listener(on_press=self.on_key_press, on_release=self.on_key_release)
        mouse_listener_down.start()
        mouse_listener_up.start()
        keyboard_listener.start()
        mouse_listener_down.join()

if __name__ == "__main__":
    monitor = ClipboardMonitor()
    monitor.start()

关键细节

  • 剪贴板还原:必须保存用户原有剪贴板内容,复制后还原,否则会干扰用户操作;
  • 应用过滤:读取配置文件中的「禁用应用列表」,避免在指定应用内触发划词;
  • 误触过滤:通过鼠标拖动距离、键盘活动状态,过滤点击、误拖动等无效操作。

2. Electron 端:命名管道服务 + Python 管理

Electron 作为主进程,负责:

  • 启动 / 管理 Python 脚本;
  • 创建命名管道服务,接收 Python 发送的数据;
  • 处理数据并分发给渲染进程。

第一步:封装命名管道服务(namedPipeServer.js)

基于 Node.js 的net模块实现 Windows 命名管道服务,支持连接队列(避免并发问题):

const net = require('net');

class NamedPipeServer {
  constructor(pipeName, cb) {
    this.pipeName = pipeName;
    this.server = null;
    this.maxConnections = 10; // 最大连接数
    this.currentConnections = 0;
    this.connectionQueue = [];
    cb(this)
  }

  // 启动管道服务
  start(onDataCallback) {
    this.server = net.createServer((socket) => {
      // 连接数控制:超出则加入队列
      if (this.currentConnections >= this.maxConnections) {
        this.connectionQueue.push(socket);
      } else {
        this.currentConnections++;
        this.handleConnection(socket, onDataCallback);
      }
    });

    this.server.on('error', (err) => {
      console.error(`管道服务错误:${err.message}`);
    });

    // 监听命名管道
    this.server.listen(this.pipeName, () => {
      console.log(`命名管道监听中:${this.pipeName}`);
    });
  }

  // 处理连接:接收数据
  handleConnection(socket, onDataCallback) {
    socket.on('data', (data) => {
      const message = data.toString().trim();
      onDataCallback(message); // 回调处理数据
    });

    // 连接断开:复用队列中的连接
    socket.on('end', () => {
      this.currentConnections--;
      if (this.connectionQueue.length > 0) {
        this.handleConnection(this.connectionQueue.shift(), onDataCallback);
      }
    });

    socket.on('error', (err) => {
      console.error(`Socket错误:${err.message}`);
    });
  }

  // 停止管道服务
  stop() {
    if (this.server) {
      this.server.close(() => {
        console.log("命名管道服务已关闭");
      });
    }
  }
}

module.exports = { NamedPipeServer };

第二步:初始化 Python 环境 + 管道通信(quickWordLookup.js)

Electron 启动时,自动解压 Python 环境(避免用户手动安装),启动命名管道,再调用 Python 脚本:

const AdmZip = require("adm-zip");
const { NamedPipeServer } = require('./namedPipeServer');
const { exec } = require('child_process');
const path = require('path');
const fs = require('fs');

class QuickWordLookup {
    constructor() {
        this.platform = process.platform;
        this.env = process.env.NODE_ENV || "production";
    }

    // 初始化Python环境+命名管道
    initPython() {
        if (this.platform !== "win32") return;

        // 1. 解压Python环境(打包在应用内的zip包)
        const pluginsPath = this.env === "development" 
            ? path.join(app.getAppPath(), 'plugins') 
            : process.resourcesPath;
        const pythonZipPath = path.join(pluginsPath, "vendors", "python3.11.zip");
        this.pythonDirPath = path.join(pluginsPath, "vendors", "python3.11");
        
        if (!fs.existsSync(this.pythonDirPath)) {
            const zip = new AdmZip(pythonZipPath);
            zip.extractAllTo(this.pythonDirPath, true); // 解压
        }

        // 2. 创建命名管道服务
        const pipeServer = new NamedPipeServer(
            '\\.\pipe\quick_word_electron_python_pipe', 
            () => {
                console.log("管道服务启动成功,启动Python脚本");
                this.openPythonExe(); // 管道就绪后启动Python
            }
        );

        // 3. 处理Python发送的数据
        pipeServer.start((message) => {
            if (message.startsWith("click_down_mouse_position:")) {
                // 处理鼠标按下坐标(判断是否在目标窗口内)
                const [x, y] = message.slice("click_down_mouse_position:".length).split(",").map(Number);
                const isInside = this.handleMousePosition(x, y);
                if (!isInside) return;
            } else if (message.startsWith("messgae_to_send:")) {
                // 处理划词内容:发给渲染进程
                const data = JSON.parse(message.slice("messgae_to_send:".length));
                this.sendToRenderer(data);
            }
        });
    }

    // 启动Python脚本
    openPythonExe() {
        if (this.platform !== "win32") return;
        const exePath = path.join(this.pythonDirPath, 'python.exe');
        // Python脚本路径(打包在应用内)
        const tempFilePath = this.env === "development" 
            ? path.join(__dirname, "../../public/python/underlineWord.py") 
            : path.join(process.resourcesPath, "vendors", "python/underlineWord.py");
        
        const cmd = `"${exePath}" "${tempFilePath}"`;
        exec(cmd, { encoding: 'utf-8' }, (error, stdout, stderr) => {
            if (error) {
                console.error(`Python启动失败:${error.message}`);
            } else {
                console.log("Python划词监听已启动");
            }
        });
    }

    // 发送数据到渲染进程
    sendToRenderer(data) {
        // 主进程→渲染进程通信(根据Electron版本调整)
        const mainWindow = BrowserWindow.getFocusedWindow();
        if (mainWindow) {
            mainWindow.webContents.send('word-lookup-data', data);
        }
    }
}

踩坑总结

  1. 命名管道的跨进程通信

    • Windows 命名管道路径格式必须是\\.\pipe\xxx,Node.js 的net模块需适配这个格式;
    • 必须保证「管道服务先启动,Python 再连接」,否则会出现连接失败;
    • 处理连接并发:添加连接队列,避免多客户端同时连接导致的异常。
  2. 剪贴板操作的坑

    • 直接调用pyautogui.hotkey('ctrl', 'c')在部分应用(如某些加密文档)中无效,需备用方案(win32api.SendMessage发送 WM_COPY 消息);
    • 必须还原用户原有剪贴板内容,否则会引发用户投诉。
  3. Python 环境打包

    • 将 Python 解释器 + 依赖包打包成 zip,Electron 启动时自动解压,避免用户手动安装;
    • 开发 / 生产环境的路径差异:需区分app.getAppPath()process.resourcesPath
  4. 应用兼容性

    • 不同应用的「划词 + 复制」逻辑不同(如某些游戏 / 加密软件屏蔽 Ctrl+C),需做兼容处理;
    • 通过psutil获取当前聚焦应用,支持「禁用应用列表」配置。

优化方向

  1. 增加 Python 进程守护:监控 Python 脚本是否崩溃,自动重启;
  2. 支持更多快捷键:除了鼠标划词,支持用户自定义快捷键触发;
  3. 剪贴板内容过滤:过滤空内容、特殊字符,提升体验;
  4. 跨平台适配:后续可扩展 macOS(使用 Unix 域套接字替代命名管道)。

总结

这次需求从「AI 生成快速方案」到「落地可用」,核心是回归「用户操作的本质」—— 划词后系统本身已有选中内容,无需复杂的截屏 / OCR,只需「借力」系统剪贴板 + 跨进程通信即可搞定。

技术选型上,Electron 负责界面和进程管理,Python 负责底层的系统事件监听,两者通过命名管道高效通信,既满足了离线、低成本的要求,又保证了准确率和用户体验。

这个案例也让我明白:有时候最有效的方案,往往不是最「高科技」的,而是最贴合用户操作习惯、最利用现有系统能力的。

别再混用了!import.meta.env 与 process.env 的本质差异一次讲透

用过vue3的小伙伴,相比对import.meta.envprocess.env都有过多过少的了解,但是你有去真正的了解过吗,今天,勇宝就带着大家一个来聊聊。

先说结论:import.meta.env 更偏“现代前端构建工具(Vite)语义”,process.env 更偏“Node 语义(Webpack/Node 运行时)”

在纯前端项目里,它们看起来都能“读环境变量”,但本质来源、注入时机、可见范围和迁移成本都不一样。

如果现在正在构建 Vue3/Vite 或 React/Vite 项目的话,优先用 import.meta.env;如果是 Webpack 老项目、Node 脚本或服务端代码,process.env 依然是主角。


1)import.meta.env 是什么?

import.meta.envESM + Vite 提供的环境变量访问方式。它不是 Node 原生对象,而是由构建工具在开发/打包阶段注入。

常见特征

  • 内置变量:MODEDEVPRODBASE_URL
  • 自定义变量默认要有前缀(Vite 默认 VITE_),例如:VITE_API_BASE
  • 能在前端代码中直接访问(最终会被构建替换)
// .env.development
VITE_API_BASE=/api
VITE_APP_TITLE=Demo

// 业务代码
const baseURL = import.meta.env.VITE_API_BASE
const isDev = import.meta.env.DEV

适用场景

  1. Vite 项目的前端业务代码
  2. 按环境切换 API 地址、开关日志、控制埋点
  3. 希望享受更清晰的前端变量约束(前缀暴露机制)

2)process.env 是什么?

process.envNode.js 运行时里的环境变量对象。

在服务端(Node)代码中,它天然存在;在前端项目中能不能用,取决于打包器是否做了注入/替换(如 Webpack 的 DefinePlugin)。

常见特征

  • Node 端“原生可用”
  • 前端中常见于旧工程(Vue CLI/Webpack)
  • 常见变量:process.env.NODE_ENVprocess.env.VUE_APP_XXX
// Vue CLI / Webpack 常见
if (process.env.NODE_ENV === 'production') {
  // 生产逻辑
}
const baseURL = process.env.VUE_APP_BASE_API

适用场景

  1. Node 服务端代码(Express、Nest、脚本工具)
  2. Webpack 系项目前端代码
  3. CI/CD 中通过系统环境变量注入配置

3)核心区别(重点)

下面这张表抓住最关键差异:

维度 import.meta.env process.env
本质来源 Vite/ESM 注入 Node 运行时对象(或被打包器替换)
典型生态 Vite Node / Webpack / Vue CLI
前端可见变量前缀 默认 VITE_ Vue CLI 常见 VUE_APP_
内置标识 DEV/PROD/MODE 常见 NODE_ENV
类型体验 在 TS 中更容易做类型增强 常被视作 string | undefined
迁移风险 旧项目需改写变量名与访问方式 在 Vite 前端中直接用可能报错或行为异常

4)代码对比案例

案例 A:按环境切 API 地址

Vite 写法:

const requestBaseURL = import.meta.env.VITE_API_BASE

Webpack/Vue CLI 写法:

const requestBaseURL = process.env.VUE_APP_BASE_API

案例 B:开发环境打印日志

Vite:

if (import.meta.env.DEV) {
  console.log('dev log')
}

Webpack/Node:

if (process.env.NODE_ENV !== 'production') {
  console.log('dev log')
}

案例 C:从 Vue CLI 迁移到 Vite 的典型坑

很多人会直接把旧代码搬过来:

// 旧代码
const url = process.env.VUE_APP_BASE_API

在 Vite 前端中应改为:

const url = import.meta.env.VITE_API_BASE

并把 .env 变量从 VUE_APP_BASE_API 改成 VITE_API_BASE


5)实践建议(避免踩坑)

  1. 前后端变量分层

    • 前端可见:只放“可公开配置”,用 VITE_ 前缀
    • 服务端敏感项(密钥/私钥):只放 process.env(Node 端),不要暴露给前端
  2. 不要混用语义

    • Vite 前端代码统一 import.meta.env
    • Node 脚本、SSR 服务端逻辑统一 process.env
  3. 迁移时一次性改全

    • 变量名前缀、读取方式、构建脚本、文档一起更新
    • 建议加一条 lint/代码审查规则,禁止在 Vite 前端里继续写 process.env.xxx

结语

import.meta.env 是“面向前端构建时”的环境注入接口,process.env 是“面向 Node 运行时”的环境变量接口。

它们都能“读配置”,但不在同一个语义层。把语义边界划清,项目会更稳定,迁移成本也会更低。

好啦!今天的知识点就分享到这里吧,希望读完对你的职业素养有一个质的提升。

双端 Diff 算法详解

在上一篇文章中,我们学习了 Diff 算法的基础原理和 key 的重要性。今天,我们将深入 Vue2 中经典的双端比较算法——这个算法通过四个指针的巧妙移动,实现了高效的节点更新。理解这个算法,不仅有助于掌握Vue2的diff原理,也为理解 Vue3 的更优算法打下基础。

前言:为什么需要双端比较?

我们还是以积木为例,假如我们有这样一排积木:

A B C D

然后我们想把它变成这样:

D A B C

也就是仅仅把 D 提到 A 的前面,如果我们用上一篇文章学的简单 Diff 算法,会怎么做呢?

  1. 比较位置0:A vs D,节点不同,更新为 D
  2. 比较位置1:B vs A,节点不同,更新为 A
  3. 比较位置2:C vs B,节点不同,更新为 B
  4. 比较位置3:D vs C,节点不同,更新为 C

上述 4 次更新操作中,没有复用任何节点。但实际上,这些节点除了顺序变化外,内容根本没有变。我们其实只需要通过移动 DOM 就复用它们,而且只需要移动一次(把 D 移动到 A 前面),就可以达到我们想要的效果。

双端 Diff 的核心思想

四个指针的设计

双端 Diff 算法在旧子节点数组和新子节点数组的两端各设置两个指针:

// 四个指针
let oldStartIdx = 0;              // 旧节点起始索引
let oldEndIdx = oldChildren.length - 1;   // 旧节点结束索引
let newStartIdx = 0;              // 新节点起始索引
let newEndIdx = newChildren.length - 1;    // 新节点结束索引

// 对应的节点
let oldStartVNode = oldChildren[oldStartIdx];
let oldEndVNode = oldChildren[oldEndIdx];
let newStartVNode = newChildren[newStartIdx];
let newEndVNode = newChildren[newEndIdx];

这四个指针的布局如图所示: 四个指针布局图

四种比较情况

双端比较的核心是进行四种比较:

1. 旧开始 vs 新开始

if (isSameVNodeType(oldStartVNode, newStartVNode)) {
  // 节点相同,直接复用
  patch(oldStartVNode, newStartVNode);
  oldStartIdx++;
  newStartIdx++;
}

2. 旧结束 vs 新结束

if (isSameVNodeType(oldEndVNode, newEndVNode)) {
  // 节点相同,直接复用
  patch(oldEndVNode, newEndVNode);
  oldEndIdx--;
  newEndIdx--;
}

3. 旧开始 vs 新结束

if (isSameVNodeType(oldStartVNode, newEndVNode)) {
  // 节点相同,但位置不同,需要移动
  patch(oldStartVNode, newEndVNode);
  // 将旧开始节点移动到旧结束节点之后
  insertBefore(oldStartVNode.el, oldEndVNode.el.nextSibling);
  oldStartIdx++;
  newEndIdx--;
}

4. 旧结束 vs 新开始

if (isSameVNodeType(oldEndVNode, newStartVNode)) {
  // 节点相同,但位置不同,需要移动
  patch(oldEndVNode, newStartVNode);
  // 将旧结束节点移动到旧开始节点之前
  insertBefore(oldEndVNode.el, oldStartVNode.el);
  oldEndIdx--;
  newStartIdx++;
}

通过 key 查找复用

为什么需要key查找?

当四种指标的比较都不匹配时,即非理想状况下,说明节点位置发生了较大变化。这时就需要通过 key 在旧节点中查找可复用的节点,如以下示例:

旧: A - B - C - D
新: C - A - D - B

第1轮比较时,四种指针比较都不匹配。这时就需要通过 key 查找,查找新开始节点 C 在旧节点中的位置,找到位置 2,就移动旧节点的 C 到开始位置。

// 在循环开始前建立key索引表
const keyToOldIndexMap = new Map();
for (let i = 0; i < oldChildren.length; i++) {
  const child = oldChildren[i];
  if (child.key != null) {
    keyToOldIndexMap.set(child.key, i);
  }
}

// 在四种比较都不匹配时使用
const idxInNew = keyToOldIndexMap.get(oldStartVNode.key);
if (idxInNew !== undefined) {
  // 找到了可复用的节点
  const vnodeToMove = newChildren[idxInNew];
  patch(oldStartVNode, vnodeToMove, container);
  // 移动节点
  container.insertBefore(oldStartVNode.el, oldStartVNode.el);
  // 标记该位置已处理
  newChildren[idxInNew] = undefined;
}

key查找的性能影响

场景 无key查找 有key查找 优势
头部插入 全量比较 直接定位 O(n) vs O(1)
节点移动 难以复用 精确复用 减少DOM操作
列表重排 性能差 性能优 差距可达10倍

完整的双端 Diff 实现

class DoubleEndedDiff {
  constructor(options = {}) {
    this.options = options;
  }
  
  /**
   * 执行双端比较
   */
  patchChildren(oldChildren, newChildren, container) {
    
    // 初始化指针
    let oldStartIdx = 0;
    let oldEndIdx = oldChildren.length - 1;
    let newStartIdx = 0;
    let newEndIdx = newChildren.length - 1;
    
    let oldStartVNode = oldChildren[oldStartIdx];
    let oldEndVNode = oldChildren[oldEndIdx];
    let newStartVNode = newChildren[newStartIdx];
    let newEndVNode = newChildren[newEndIdx];
    
    // 创建key索引表
    const keyToOldIndexMap = this.createKeyMap(oldChildren);
    
    // 记录移动次数
    let moveCount = 0;
    let patchCount = 0;
    let mountCount = 0;
    let unmountCount = 0;
    
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      // 跳过已处理的节点
      if (!oldStartVNode) {
        oldStartVNode = oldChildren[++oldStartIdx];
      } else if (!oldEndVNode) {
        oldEndVNode = oldChildren[--oldEndIdx];
      }
      // 情况1: 旧开始 = 新开始
      else if (this.isSameNode(oldStartVNode, newStartVNode)) {
        this.patch(oldStartVNode, newStartVNode, container);
        oldStartVNode = oldChildren[++oldStartIdx];
        newStartVNode = newChildren[++newStartIdx];
        patchCount++;
      }
      // 情况2: 旧结束 = 新结束
      else if (this.isSameNode(oldEndVNode, newEndVNode)) {
        this.patch(oldEndVNode, newEndVNode, container);
        oldEndVNode = oldChildren[--oldEndIdx];
        newEndVNode = newChildren[--newEndIdx];
        patchCount++;
      }
      // 情况3: 旧开始 = 新结束
      else if (this.isSameNode(oldStartVNode, newEndVNode)) {
        this.patch(oldStartVNode, newEndVNode, container);
        container.insertBefore(
          oldStartVNode.el,
          oldEndVNode.el.nextSibling
        );
        oldStartVNode = oldChildren[++oldStartIdx];
        newEndVNode = newChildren[--newEndIdx];
        moveCount++;
        patchCount++;
      }
      // 情况4: 旧结束 = 新开始
      else if (this.isSameNode(oldEndVNode, newStartVNode)) {
        this.patch(oldEndVNode, newStartVNode, container);
        container.insertBefore(
          oldEndVNode.el,
          oldStartVNode.el
        );
        oldEndVNode = oldChildren[--oldEndIdx];
        newStartVNode = newChildren[++newStartIdx];
        moveCount++;
        patchCount++;
      }
      // 情况5: 都不匹配,通过key查找
      else {
        const idxInOld = keyToOldIndexMap.get(newStartVNode.key);
        
        if (idxInOld !== undefined) {
          const vnodeToMove = oldChildren[idxInOld];
          this.patch(vnodeToMove, newStartVNode, container);
          container.insertBefore(
            vnodeToMove.el,
            oldStartVNode.el
          );
          oldChildren[idxInOld] = undefined;
          moveCount++;
          patchCount++;
        } else {
          this.mount(newStartVNode, container, oldStartVNode.el);
          mountCount++;
        }
        newStartVNode = newChildren[++newStartIdx];
      }
    
    // 处理剩余节点
    if (oldStartIdx > oldEndIdx) {
      for (let i = newStartIdx; i <= newEndIdx; i++) {
        const newVNode = newChildren[i];
        if (newVNode) {
          this.mount(newVNode, container, newChildren[newEndIdx + 1]?.el);
          mountCount++;
        }
      }
    } else if (newStartIdx > newEndIdx) {
      for (let i = oldStartIdx; i <= oldEndIdx; i++) {
        const oldVNode = oldChildren[i];
        if (oldVNode) {
          this.unmount(oldVNode);
          unmountCount++;
        }
      }
    }
  }
  
  /**
   * 创建key索引表
   */
  createKeyMap(children) {
    const map = new Map();
    for (let i = 0; i < children.length; i++) {
      const child = children[i];
      if (child?.key != null) {
        map.set(child.key, i);
      }
    }
    return map;
  }
  
  /**
   * 判断两个节点是否相同
   */
  isSameNode(n1, n2) {
    return n1 && n2 && n1.type === n2.type && n1.key === n2.key;
  }
  
  /**
   * 更新节点
   */
  patch(oldVNode, newVNode, container) {
    if (oldVNode.el) {
      newVNode.el = oldVNode.el;
      if (newVNode.children !== oldVNode.children) {
        newVNode.el.textContent = newVNode.children;
      }
    }
  }
  
  /**
   * 挂载新节点
   */
  mount(vnode, container, anchor) {
    const el = document.createElement(vnode.type);
    vnode.el = el;
    el.textContent = vnode.children;
    if (anchor) {
      container.insertBefore(el, anchor);
    } else {
      container.appendChild(el);
    }
  }
  
  /**
   * 卸载节点
   */
  unmount(vnode) {
    if (vnode.el && vnode.el.parentNode) {
      vnode.el.parentNode.removeChild(vnode.el);
    }
  }
}

源码对标:Vue2的双端 Diff

Vue2 的双端 Diff 算法实现位于 src/core/vdom/patch.js 中:

// Vue2源码中的双端比较(简化版)
function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0;
  let oldEndIdx = oldCh.length - 1;
  let newStartIdx = 0;
  let newEndIdx = newCh.length - 1;
  
  let oldStartVnode = oldCh[oldStartIdx];
  let oldEndVnode = oldCh[oldEndIdx];
  let newStartVnode = newCh[newStartIdx];
  let newEndVnode = newCh[newEndIdx];
  
  let oldKeyToIdx, idxInOld, vnodeToMove;
  
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVnode == null) {
      oldStartVnode = oldCh[++oldStartIdx];
    } else if (oldEndVnode == null) {
      oldEndVnode = oldCh[--oldEndIdx];
    } else if (newStartVnode == null) {
      newStartVnode = newCh[++newStartIdx];
    } else if (newEndVnode == null) {
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode);
      oldStartVnode = oldCh[++oldStartIdx];
      newStartVnode = newCh[++newStartIdx];
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode);
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      patchVnode(oldStartVnode, newEndVnode);
      api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm));
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      patchVnode(oldEndVnode, newStartVnode);
      api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
    } else {
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
      }
      idxInOld = oldKeyToIdx[newStartVnode.key];
      if (isUndef(idxInOld)) {
        api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm);
      } else {
        vnodeToMove = oldCh[idxInOld];
        if (sameVnode(vnodeToMove, newStartVnode)) {
          patchVnode(vnodeToMove, newStartVnode);
          oldCh[idxInOld] = undefined;
          api.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
        } else {
          api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm);
        }
      }
      newStartVnode = newCh[++newStartIdx];
    }
  }
  
  if (oldStartIdx > oldEndIdx) {
    // 挂载剩余新节点
  } else if (newStartIdx > newEndIdx) {
    // 卸载剩余旧节点
  }
}

结语

双端比较算法是 Vue2 响应式系统的核心之一,理解它不仅能帮助我们写出更高效的代码,也为理解 Vue3 的更优算法打下基础。虽然 Vue3 采用了新的算法,但双端比较的思想仍然值得我们深入学习。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

「九九八十一难」组合式函数到底有什么用?

引言

最近接手了一个 Vue 2 的老项目,翻开代码的那一刻,我陷入了沉思。

一个 .vue 文件足足 5000 行代码,data 里定义了 200 多个变量,methods 里塞了 100 多个方法。

相关逻辑散落在 datamethodscomputedwatch 各个角落,方法套方法,变量牵变量。

剪不断、理还乱。

终于明白了 Vue 3 为什么要引入组合式函数(Composables)

Q:有同学就要问了,为什么不用 mixin 实现?

A:在实际工程中使用 mixin ,还不一定比放在同一个组件里面维护起来方便。


组合式函数(Composables)定义

在 Vue 应用的概念中,"组合式函数"(Composables) 是一个利用 Vue 的组合式 API 来封装和复用有状态逻辑函数

这个定义中有两个关键点需要理解:有状态逻辑函数形式

什么是有状态逻辑?

在程序设计中,"状态"指的是在程序运行过程中会发生变化的数据。有状态逻辑就是指那些管理着会变化的数据,并且需要对这些数据的变化做出响应的代码逻辑。

阅读下面文章之前,先理解下这两句话:

组合式函数内部可以使用 ref 或 reactive 创建响应式数据,并且这些数据在返回给组件后依然保持响应性

组合式函数可以接收任意参数,可以是普通值或响应式引用(ref)。

举个例子:

  • 无状态逻辑:一个纯函数 add(a, b) => a + b,给定相同的输入,永远返回相同的输出,不依赖任何外部状态。
  • 有状态逻辑:一个计数器,它维护一个当前计数值,可以增加、减少、重置,并且当计数值变化时,使用这个计数值的地方需要自动更新。

在 Vue 中,有状态逻辑通常包含:

  • 响应式数据(ref、reactive)
  • 计算属性(computed)
  • 侦听器(watch)
  • 生命周期钩子(onMounted、onUnmounted 等)

为什么是函数?

组合式函数选择以函数的形式存在,而不是类、对象或其他形式,这是经过深思熟虑的设计:

  1. 组合性:函数可以轻松地相互调用、嵌套、组合。你可以在一个组合式函数中调用另一个组合式函数,形成逻辑的层层封装。

  2. 作用域隔离:每次调用函数都会创建一个新的作用域,这意味着你可以在多个组件中多次调用同一个组合式函数,每次调用都是独立的实例,互不干扰。

  3. 参数传递灵活:函数可以接收参数,返回值,这使得逻辑的输入输出非常清晰。

  4. 符合 JavaScript 惯例:JavaScript 本身就是函数式编程友好的语言,使用函数封装逻辑符合开发者的直觉。

为什么要引入组合式函数(Composables)?

Vue 2 选项式 API 的困境

在 Vue 2 中,我们使用选项式 API(Options API)来组织代码。

这种方式在组件简单时非常直观,但当组件变得复杂时,问题就暴露出来了。

问题一:逻辑碎片化

假设我们要实现一个"鼠标追踪"功能,需要追踪鼠标在页面上的位置。在 Vue 2 中,代码会散落在多个选项中:

<script>
export default {
  data() {
    return {
      x: 0,
      y: 0
    }
  },
  mounted() {
    window.addEventListener('mousemove', this.handleMouseMove)
  },
  beforeUnmount() {
    window.removeEventListener('mousemove', this.handleMouseMove)
  },
  methods: {
    handleMouseMove(event) {
      this.x = event.pageX
      this.y = event.pageY
    }
  }
}
</script>

可以看到,一个完整的功能被拆分到了 datamountedbeforeUnmountmethods 四个不同的地方。当组件功能越来越多时,阅读代码就需要在不同选项之间来回跳转,理解成本极高。

其实这种编程习惯至今我仍有部分困惑,在书写 vue3 组合式写法时,部分同事还是喜欢将变量、方法、计算属性分类书写,方法放在一起、变量放在一堆,导致维护代码时候仍然会在多个代码块中进行跳转。

问题二:复用困难

Vue 2 提供了 Mixins 来复用逻辑,但它存在严重的问题:

<script>
const mouseTrackingMixin = {
  data() {
    return {
      x: 0,
      y: 0
    }
  },
  mounted() {
    window.addEventListener('mousemove', this.handleMouseMove)
  },
  beforeUnmount() {
    window.removeEventListener('mousemove', this.handleMouseMove)
  },
  methods: {
    handleMouseMove(event) {
      this.x = event.pageX
      this.y = event.pageY
    }
  }
}

export default {
  mixins: [mouseTrackingMixin],
  data() {
    return {
      x: 'I will be overwritten!'  // 命名冲突!
    }
  }
}
</script>

Mixins 的问题包括:

  • 命名冲突:多个 mixin 或组件与 mixin 之间可能有同名属性/方法,导致覆盖
  • 依赖隐式:mixin 内部可能使用了组件的某些属性,但这种依赖关系不明显
  • 数据来源不清晰:当使用了多个 mixin 时,很难分辨某个属性来自哪个 mixin

问题三:TypeScript 支持不友好

选项式 API 的类型推导相对复杂,IDE 的智能提示也不够完善,这在大型项目中是一个明显的短板。

组合式函数的解决方案

组合式函数完美解决了上述问题:

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

function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function handleMouseMove(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => {
    window.addEventListener('mousemove', handleMouseMove)
  })

  onUnmounted(() => {
    window.removeEventListener('mousemove', handleMouseMove)
  })

  return { x, y }
}

const { x, y } = useMouse()
</script>

可以看到:

  • 逻辑聚合:所有与鼠标追踪相关的代码都集中在 useMouse 函数中
  • 命名清晰:通过解构赋值,可以清楚地看到 xy 来自 useMouse
  • 无命名冲突:即使有多个组合式函数返回同名属性,也可以通过重命名解决

组合式函数的优势

1. 逻辑组织更清晰

组合式函数允许我们按照功能而不是按照选项来组织代码。相关联的状态和方法可以放在一起,形成内聚的逻辑单元。

<script setup>
import { useMouse } from './composables/useMouse'
import { useFetch } from './composables/useFetch'
import { useTheme } from './composables/useTheme'

const { x, y } = useMouse()
const { data, error, loading } = useFetch('/api/users')
const { theme, toggleTheme } = useTheme()
</script>

每个组合式函数负责一个独立的功能,代码结构一目了然。

2. 逻辑复用更简单

组合式函数本质上是普通 JavaScript 函数,可以在任何地方调用:

import { useMouse } from './composables/useMouse'

export function useMouseWithDelay(delay = 100) {
  const { x: rawX, y: rawY } = useMouse()
  const x = ref(0)
  const y = ref(0)

  watch([rawX, rawY], debounce(([newX, newY]) => {
    x.value = newX
    y.value = newY
  }, delay))

  return { x, y }
}

你甚至可以在一个组合式函数中调用另一个组合式函数,实现逻辑的组合与扩展。

3. 类型推导更完善

组合式函数天然支持 TypeScript,类型推导非常准确:

import { ref, computed, type Ref, type ComputedRef } from 'vue'

interface User {
  id: number
  name: string
  email: string
}

function useUser(id: Ref<number>) {
  const user = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  const fullName = computed(() => {
    if (!user.value) return ''
    return `${user.value.name} <${user.value.email}>`
  })

  async function fetchUser() {
    loading.value = true
    error.value = null
    try {
      const response = await fetch(`/api/users/${id.value}`)
      user.value = await response.json()
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  return {
    user,
    loading,
    error,
    fullName,
    fetchUser
  }
}

IDE 可以准确推断出 user 的类型是 Ref<User | null>fullName 的类型是 ComputedRef<string>

4. 测试更方便

组合式函数是纯 JavaScript/TypeScript 函数,可以脱离 Vue 组件独立测试:

import { useCounter } from './composables/useCounter'
import { ref } from 'vue'

describe('useCounter', () => {
  it('should increment count', () => {
    const { count, increment } = useCounter()
    expect(count.value).toBe(0)
    increment()
    expect(count.value).toBe(1)
  })

  it('should accept initial value', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })
})

组合式函数的使用场景

1. 封装通用状态逻辑

当你发现多个组件中存在相同或相似的状态逻辑时,就应该考虑提取为组合式函数。

典型场景

  • 表单验证逻辑
  • 分页逻辑
  • 加载状态管理
  • 主题切换
  • 国际化

2. 组织复杂组件逻辑

当单个组件变得庞大时,可以使用组合式函数将不同功能的代码分离:

<script setup>
import { useUserAuth } from './composables/useUserAuth'
import { useUserProfile } from './composables/useUserProfile'
import { useUserPosts } from './composables/useUserPosts'

const { user, login, logout } = useUserAuth()
const { profile, updateProfile } = useUserProfile(user)
const { posts, fetchPosts, createPost } = useUserPosts(user)
</script>

3. 集成第三方库

将第三方库的集成逻辑封装为组合式函数,可以简化使用并提供 Vue 友好的 API:

import { ref, onMounted, onUnmounted } from 'vue'
import { debounce } from 'lodash-es'

export function useDebouncedRef(value, delay = 300) {
  const debouncedValue = ref(value)
  const updater = debounce((newValue) => {
    debouncedValue.value = newValue
  }, delay)

  watch(() => value, (newValue) => {
    updater(newValue)
  })

  onUnmounted(() => {
    updater.cancel()
  })

  return debouncedValue
}

4. 抽象浏览器 API

将浏览器原生 API 封装为响应式的组合式函数:

import { ref, onMounted, onUnmounted } from 'vue'

export function useLocalStorage(key, defaultValue) {
  const value = ref(defaultValue)

  function read() {
    const stored = localStorage.getItem(key)
    if (stored !== null) {
      value.value = JSON.parse(stored)
    }
  }

  function write() {
    localStorage.setItem(key, JSON.stringify(value.value))
  }

  onMounted(() => {
    read()
    window.addEventListener('storage', read)
  })

  onUnmounted(() => {
    window.removeEventListener('storage', read)
  })

  watch(value, write, { deep: true })

  return value
}

组合式函数的实现规范

基本结构

一个标准的组合式函数通常包含以下部分:

import { ref, computed, watch, onMounted, onUnmounted } from 'vue'

export function useFeatureName(parameter) {
  const state = ref(initialValue)
  const computedValue = computed(() => {
    return state.value * 2
  })

  function doSomething() {
    state.value++
  }

  watch(state, (newValue, oldValue) => {
    console.log(`state changed from ${oldValue} to ${newValue}`)
  })

  onMounted(() => {
    console.log('component mounted')
  })

  onUnmounted(() => {
    console.log('component unmounted')
  })

  return {
    state,
    computedValue,
    doSomething
  }
}

命名约定

  • 函数命名:以 use 开头,采用驼峰命名法,如 useMouseuseFetchuseLocalStorage
  • 文件命名:与函数名一致,如 useMouse.jsuseMouse.ts
  • 目录结构:通常放在 composables/hooks/ 目录下

返回值约定

  • 返回一个对象,包含需要暴露给外部使用的响应式状态和方法
  • 返回的对象通常使用解构赋值接收
  • 如果需要返回响应式引用,不要在返回时解包,保持 ref 形式

参数约定

  • 可以接收普通值、响应式引用(ref)、响应式对象(reactive)作为参数
  • 如果参数可能是响应式的,使用 toValue() 工具函数进行解包:
import { toValue } from 'vue'

export function useFetch(url) {
  const urlValue = toValue(url)
}

组合式函数的实现示例

示例一:鼠标追踪器

这是一个经典的组合式函数示例,封装了鼠标位置追踪逻辑:

import { ref, onMounted, onUnmounted } from 'vue'

/**
 * 追踪鼠标在页面上的位置
 * @returns {Object} 包含鼠标 x、y 坐标的响应式引用
 */
export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(event) {
    x.value = event.pageX
    y.value = event.pageY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return { x, y }
}

在组件中使用:

<template>
  <div>鼠标位置:{{ x }}, {{ y }}</div>
</template>

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

const { x, y } = useMouse()
</script>

示例二:数据请求

封装通用的数据获取逻辑,包含加载状态和错误处理:

import { ref, watchEffect, toValue } from 'vue'

/**
 * 封装数据获取逻辑
 * @param {string|Ref<string>|() => string} url - 请求地址,可以是响应式引用或 getter 函数
 * @returns {Object} 包含 data、error、loading 状态的对象
 */
export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)

  async function fetchData() {
    loading.value = true
    error.value = null

    try {
      const response = await fetch(toValue(url))
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }
      data.value = await response.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  watchEffect(() => {
    fetchData()
  })

  return { data, error, loading, refetch: fetchData }
}

在组件中使用:

<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">加载失败:{{ error.message }}</div>
  <div v-else>
    <pre>{{ data }}</pre>
    <button @click="refetch">重新加载</button>
  </div>
</template>

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

const userId = ref(1)
const { data, error, loading, refetch } = useFetch(
  () => `/api/users/${userId.value}`
)
</script>

示例三:计数器

一个简单但完整的计数器示例,展示参数接收和返回值:

import { ref, computed } from 'vue'

/**
 * 创建一个计数器
 * @param {number} initialValue - 初始值,默认为 0
 * @param {number} step - 步长,默认为 1
 * @returns {Object} 计数器状态和方法
 */
export function useCounter(initialValue = 0, step = 1) {
  const count = ref(initialValue)

  const isPositive = computed(() => count.value > 0)
  const isNegative = computed(() => count.value < 0)
  const isZero = computed(() => count.value === 0)

  function increment() {
    count.value += step
  }

  function decrement() {
    count.value -= step
  }

  function reset() {
    count.value = initialValue
  }

  function set(value) {
    count.value = value
  }

  return {
    count,
    isPositive,
    isNegative,
    isZero,
    increment,
    decrement,
    reset,
    set
  }
}

示例四:表单验证

封装表单验证逻辑,支持自定义验证规则:

import { ref, computed, reactive } from 'vue'

/**
 * 表单验证组合式函数
 * @param {Object} initialValues - 表单初始值
 * @param {Object} rules - 验证规则
 * @returns {Object} 表单状态和验证方法
 */
export function useForm(initialValues, rules) {
  const values = reactive({ ...initialValues })
  const errors = reactive({})
  const touched = reactive({})
  const isSubmitting = ref(false)

  const isValid = computed(() => {
    return Object.keys(errors).every(key => !errors[key])
  })

  function validateField(field) {
    const rule = rules[field]
    if (!rule) return true

    const value = values[field]
    const result = rule(value)

    if (typeof result === 'string') {
      errors[field] = result
      return false
    } else {
      errors[field] = ''
      return true
    }
  }

  function validateAll() {
    let allValid = true
    for (const field in rules) {
      if (!validateField(field)) {
        allValid = false
      }
    }
    return allValid
  }

  function setFieldTouched(field) {
    touched[field] = true
    validateField(field)
  }

  function resetForm() {
    Object.assign(values, initialValues)
    Object.keys(errors).forEach(key => {
      errors[key] = ''
    })
    Object.keys(touched).forEach(key => {
      touched[key] = false
    })
  }

  async function handleSubmit(callback) {
    isSubmitting.value = true

    Object.keys(values).forEach(key => {
      touched[key] = true
    })

    if (validateAll()) {
      await callback(values)
    }

    isSubmitting.value = false
  }

  return {
    values,
    errors,
    touched,
    isSubmitting,
    isValid,
    validateField,
    validateAll,
    setFieldTouched,
    resetForm,
    handleSubmit
  }
}

在组件中使用:

<template>
  <form @submit.prevent="handleSubmit(onSubmit)">
    <div>
      <label>用户名:</label>
      <input
        v-model="values.username"
        @blur="setFieldTouched('username')"
      />
      <span v-if="touched.username && errors.username" class="error">
        {{ errors.username }}
      </span>
    </div>

    <div>
      <label>邮箱:</label>
      <input
        v-model="values.email"
        @blur="setFieldTouched('email')"
      />
      <span v-if="touched.email && errors.email" class="error">
        {{ errors.email }}
      </span>
    </div>

    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? '提交中...' : '提交' }}
    </button>
  </form>
</template>

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

const initialValues = {
  username: '',
  email: ''
}

const rules = {
  username: (value) => {
    if (!value) return '用户名不能为空'
    if (value.length < 3) return '用户名至少 3 个字符'
    return true
  },
  email: (value) => {
    if (!value) return '邮箱不能为空'
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '邮箱格式不正确'
    return true
  }
}

const {
  values,
  errors,
  touched,
  isSubmitting,
  setFieldTouched,
  handleSubmit
} = useForm(initialValues, rules)

async function onSubmit(formValues) {
  console.log('表单提交:', formValues)
}
</script>

注意点与最佳实践

1. 始终在 setup 函数或 script setup 中调用

组合式函数依赖于 Vue 的组合式 API,必须在组件的 setup() 函数或 <script setup> 中同步调用:

export default {
  setup() {
    const { x, y } = useMouse()
    return { x, y }
  }
}
<script setup>
const { x, y } = useMouse()
</script>

错误示例

export default {
  setup() {
    setTimeout(() => {
      const { x, y } = useMouse()
    }, 1000)
  }
}

2. 返回响应式引用时保持 ref 形式

组合式函数返回的响应式数据应该保持 refreactive 形式,不要在返回时解包:

export function useCounter() {
  const count = ref(0)
  return { count }
}

这样可以让调用者明确知道这是一个响应式引用,并且可以灵活地传递给其他组合式函数。

3. 使用 toValue 处理可能是响应式的参数

当组合式函数接收的参数可能是普通值、ref 或 getter 函数时,使用 toValue 统一处理:

import { toValue } from 'vue'

export function useFetch(url) {
  const urlValue = toValue(url)
}

4. 合理使用 shallowRef 和 shallowReactive

对于大型对象或数组,如果只需要监听整体变化而不需要深度响应,使用 shallowRefshallowReactive 可以提升性能:

import { shallowRef } from 'vue'

export function useLargeData() {
  const data = shallowRef([])

  async function fetchData() {
    const response = await fetch('/api/large-data')
    data.value = await response.json()
  }

  return { data, fetchData }
}

5. 清理副作用

在组合式函数中创建的副作用(事件监听、定时器等)必须在组件卸载时清理:

import { onUnmounted } from 'vue'

export function useInterval(callback, delay) {
  let timer = null

  timer = setInterval(callback, delay)

  onUnmounted(() => {
    if (timer) {
      clearInterval(timer)
    }
  })
}

或者使用 Vue 提供的 watchEffectonCleanup

import { watchEffect } from 'vue'

export function useEventListener(target, event, callback) {
  watchEffect((onCleanup) => {
    target.addEventListener(event, callback)

    onCleanup(() => {
      target.removeEventListener(event, callback)
    })
  })
}

6. 避免在组合式函数中直接修改 props

组合式函数不应该直接修改接收到的 props,而应该通过 emit 或其他方式通知父组件:

export function useModelValue(props, emit) {
  const localValue = computed({
    get: () => props.modelValue,
    set: (value) => emit('update:modelValue', value)
  })

  return { localValue }
}

7. 提供合理的默认值

组合式函数的参数应该提供合理的默认值,提高易用性:

export function useDebounce(fn, delay = 300) {
}

8. 文档化你的组合式函数

使用 JSDoc 为组合式函数添加文档,说明参数、返回值和使用示例:

/**
 * 创建一个防抖的响应式引用
 * @template T
 * @param {T} initialValue - 初始值
 * @param {number} delay - 防抖延迟时间(毫秒)
 * @returns {import('vue').Ref<T>} 防抖后的响应式引用
 * @example
 * const searchTerm = useDebouncedRef('', 300)
 * watch(searchTerm, (value) => {
 *   console.log('搜索:', value)
 * })
 */
export function useDebouncedRef(initialValue, delay = 300) {
}

组合式函数 vs 其他方案对比

组合式函数 vs Mixins

特性 组合式函数 Mixins
数据来源 清晰(解构赋值) 不清晰
命名冲突 可重命名解决 静默覆盖
参数传递 支持参数 不支持
逻辑组合 可嵌套调用 困难
TypeScript 支持 完善 较差

组合式函数 vs Renderless Components

Renderless Components(无渲染组件)是 Vue 2 中另一种复用逻辑的方式:

<template>
  <slot :x="x" :y="y" />
</template>

<script>
export default {
  data() {
    return { x: 0, y: 0 }
  },
  mounted() {
    window.addEventListener('mousemove', this.handleMouseMove)
  },
  beforeUnmount() {
    window.removeEventListener('mousemove', this.handleMouseMove)
  },
  methods: {
    handleMouseMove(event) {
      this.x = event.pageX
      this.y = event.pageY
    }
  }
}
</script>

对比:

特性 组合式函数 Renderless Components
性能 更好(无组件开销) 有组件实例开销
使用方式 函数调用 组件嵌套
灵活性 更高 受限于组件树
TypeScript 支持 完善 一般

总结

组合式函数是 Vue 3 最具革命性的特性之一,它从根本上改变了我们组织和复用代码的方式。

核心价值

  • 解决逻辑碎片化:将相关联的状态和方法聚合在一起,代码更易读、易维护
  • 简化逻辑复用:以函数形式封装,可在任意组件中复用,无命名冲突之忧
  • 提升开发体验:完善的 TypeScript 支持和 IDE 智能提示
  • 便于测试:纯函数形式,可脱离组件独立测试

使用建议

  • 当发现多个组件存在相同逻辑时,提取为组合式函数
  • 当单个组件变得庞大时,使用组合式函数拆分功能模块
  • 遵循命名约定(use 前缀)和返回值约定
  • 注意清理副作用,避免内存泄漏

从 Vue 2 迁移

  • 不需要一次性重写所有代码,组合式函数可以与选项式 API 共存
  • 可以逐步将 Mixins 重构为组合式函数
  • 利用组合式函数简化新功能的开发

组合式函数不仅是一种技术方案,更是一种关注点分离组合优于继承的设计思想。掌握它,将让你的 Vue 开发体验提升一个台阶。

回到开头那个 5000 行的 Vue 2 组件,如果用组合式函数重构,或许可以变成这样:

<script setup>
import { useUserAuth } from './composables/useUserAuth'
import { useUserList } from './composables/useUserList'
import { useUserForm } from './composables/useUserForm'
import { usePagination } from './composables/usePagination'
import { useSearch } from './composables/useSearch'
import { useNotification } from './composables/useNotification'

const { user, login, logout } = useUserAuth()
const { users, fetchUsers, deleteUser } = useUserList()
const { form, submitForm, resetForm } = useUserForm()
const { page, pageSize, total, setPage } = usePagination()
const { keyword, filteredUsers } = useSearch(users)
const { showSuccess, showError } = useNotification()
</script>

清晰、简洁、优雅。这就是组合式函数的魅力。

回到标题,相同的业务实现,我不使用组合式函数也能实现。

读完这篇文章,是否可以尝试使用组合式函数,全凭各位看官决定。

写法只是手段,业务实现才是重点。

VUE3响应式原理——从零解析

基本概念

在开始讲解响应式原理之前,我们需要知道两个基本概念:

什么是副作用函数?

即该函数的执行影响到其他函数的执行结果,则称该函数为副作用函数。例如:

const obj = { text: 'test' };

function effect() {
    obj.text = ‘hello’;
}

effect()执行后,其他使用到obj.text的函数中,读取到的值将是hello,而不是text,产生了副作用,故称effect()为副作用函数。

什么是响应式数据?

即当某个数据发生变化时,所有使用该数据的地方都发生了变化,则称该数据为响应式数据。例如:

const obj = { text: 'test' };

function effect() {
    ducoment.body.innerText = obj.text;
}

effect();
obj.text = 'hello';

obj.text的值设置为hello后,若body显示的内容由test变为hello,则称obj是一个响应式数据。


如何实现响应式?

通过上述基本概念的举例说明可以看出,响应式数据涉及到了数据的读取(get)和设置(set)操作——副作用函数执行时,进行了读取操作;数据值改变时,进行了设置操作,同时副作用函数被执行。

那怎么样才能保证对数据进行设置操作时,副作用函数被执行呢?可以在读取操作时使用一个容器将副作用函数保存起来,在设置操作时取出副作用函数执行,就实现了最简单的响应式。

Snipaste_2026-02-27_14-43-27.png

Snipaste_2026-02-27_14-43-34.png 在ES2015+以后,Proxy可以实现拦截数据的getset操作,并进行一些特殊处理。

// 副作用函数
function effect() {
    document.getElementById("result").innerHTML = obj.text;
}

const data = { text: "test" };

// 收集副作用函数的容器
const bucket = new Set();

// 响应式数据
const obj = new Proxy(data, {
    get(target, key) {
        // 读取时将副作用函数存入容器
        bucket.add(effect);
        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        // 设置后将容器中的副作用函数取出逐一执行
        bucket.forEach((fn) => fn());
        return true;
    },
});

然而,在实际应用过程中,副作用函数名称并不都是effect,可能是其他名称,也可能是一个匿名函数。因此,需要改造一下原有的effect函数,允许其接收一个真正的副作用函数,并存到一个变量中,解决副作用函数名称被硬编码的问题。

// 当前激活的副作用函数
let activeEffect;

// 改造原有的effect函数
function effect(fn){
    activeEffect = fn;
    fn();
}

const data = { text: "test" };

// 收集副作用函数的容器
const bucket = new Set();

// 响应式数据
const obj = new Proxy(data, {
    get(target, key) {
        if (activeEffect) {
            bucket.add(activeEffect);
        }

        return target[key];
    },
    set(target, key, newVal) {
        target[key] = newVal;
        bucket.forEach((fn) => fn());
        return true;
    },
});


如何仅触发特定的副作用函数?

上一节中,已经实现了基本的响应式数据。但如果给obj中原本不存在的属性设置数据后,会发现副作用函数被执行了两次,例如下面这段代码:

effect(() => {
    console.log('执行了副作用函数');
})

function exec() {
    obj.text = 'hello';
    obj.name = '张三';
}

exec();

这和预期不一致——原始数据没有name属性,且副作用函数中未读取该属性,exec()执行到最后一行时,不应触发副作用函数的执行。

通过观察可以发现,objtexteffect呈现一种树状结构: Snipaste_2026-02-26_17-28-35.png

拓展可以得到以下情况: Snipaste_2026-02-27_14-27-09.png

targetkeyeffect是一对多的关系,因此单单使用Set是不满足的,需要调整收集副作用函数的容器的数据结构。

// 当前激活的副作用函数
let activeEffect;

// 改造原有的effect函数
export function effect(fn) {
    activeEffect = fn;
    fn();
}

const data = { text: "test" };

// 收集副作用函数的容器
const bucket = new WeakMap();

// 响应式数据
export const obj = new Proxy(data, {

    get(target, key) {
        if (!activeEffect) {
            return target\[key];
        }

        let depsMap = bucket.get(target);
        if (!depsMap) {
            // 如果不存在,则创建一个新的Map
            bucket.set(target, (depsMap = new Map()));
        }

        let effectsSet = depsMap.get(key);
        if (!effectsSet) {
            // 如果不存在,则创建一个新的Set
            depsMap.set(key, (effectsSet = new Set()));
        }

        effectsSet.add(activeEffect);
        return target[key];
    },

    set(target, key, newVal) {
        target[key] = newVal;

        const depsMap = bucket.get(target);
        // 没有收集到有副作用函数的属性,直接返回
        if (!depsMap) {
            return;
        }

        // 取出与属性绑定的所有副作用函数逐一执行
        const effectsSet = depsMap.get(key);
        effectsSet && effectsSet.forEach((fn) => fn());
        return true;
    },
});

低代码平台表单设计系统技术分析(实战三)

第三篇:拖拽功能与布局系统

前两篇我们分析了低代码平台表单设计系统的整体架构和组件体系,这一篇将深入探讨拖拽功能与布局系统的实现

1. 拖拽功能实现

该低代码平台使用 vuedraggable 库实现组件的拖拽功能,主要包括两个场景:

1.1 从左侧组件库拖拽到画布

<draggable
  :list="formDefine"
  :group="{ name: 'widget', pull: pullFuction, put: false }"
  item-key="id"
  :sort="false"
  @start="dragStart"
>
  <template #item="{ element }">
    <div class="item" @click="addComponent(element)" fill="currentColor">
      <span v-html="icons[element.type]" class="item-icon"></span>
      <span>{{ element.title }}</span>
    </div>
  </template>
</draggable>

核心实现:

  • 使用 vuedraggable 组件包装左侧组件列表
  • 配置 group 属性,设置拖拽组名为 "widget"
  • pull 函数控制拖拽行为,支持克隆模式 
  • dragStart 事件处理拖拽开始时的逻辑 
  • addComponent 方法处理点击添加组件的逻辑

1.2 画布内组件的拖拽排序

<draggable
  :list="props.formData.list"
  group="widget"
  item-key="id"
  @add="handleAdd"
>
  <template #item="{ element }">
    <FormDesignView
      @on-widget-select="widgetSelect(element)"
      :item="element"
      :chosenItem="currentItem"
      :formData="formData"
    ></FormDesignView>
  </template>
</draggable>

核心实现:

  • 使用 vuedraggable 包装画布内的组件列表 
  • 配置 group 属性为 "widget",与左侧组件库保持一致
  • @add 事件处理组件添加到画布的逻辑 
  • item-key 使用组件的 id 确保正确的DOM更新

1.3 拖拽逻辑处理

// 拖拽开始处理
const dragStart = (e) => {
  currentDragItem.value = formDefine[e.oldDraggableIndex]
  const currentType = currentDragItem.value.type
  currentTemplate(currentType)
  triggerScroll()
}

// 处理添加组件
const handleAdd = ({ newIndex }) => {
  const itemId = props.formData.list[newIndex].type + '_' + new Date().getTime()
  props.formData.list[newIndex] = {
    ...JSON.parse(JSON.stringify(props.formData.list[newIndex])),
    itemId,
    grid: props.formData.config?.colSpan || 24
  }
  currentItem.value = props.formData.list[newIndex]
}

拖拽处理特点:

  • 拖拽时克隆组件,而非移动原始组件 
  • 为新添加的组件生成唯一的 id 
  • 应用当前表单的布局配置
  • 自动选中新添加的组件
  • 触发右侧配置面板的更新

2. 布局系统设计

2.1 布局配置选项

表单布局通过 FormConfig 组件进行配置,支持多种布局方式:

<el-form-item label="表单布局" :label-position="itemLabelPosition">
  <el-select v-model="config.colSpan">
    <el-option
      v-for="item in layOutOptions"
      :key="item.key"
      :label="item.name"
      :value="item.colSpan"
    />
  </el-select>
</el-form-item>

布局选项:

const layOutOptions = [
  {
    key: 'single',
    name: '单列',
    colSpan: 24
  },
  {
    key: 'double',
    name: '双列',
    colSpan: 12
  },
  {
   ....省略
  }
]

2.2 标签对齐方式

支持三种标签对齐方式:

<el-form-item label="标签对齐方式" :label-position="itemLabelPosition">
  <el-radio-group
    v-model="config.labelPosition"
    aria-label="label position"
    @change="handleLabelPositionChange"
  >
    <el-radio-button value="left">左侧</el-radio-button>
    <el-radio-button value="right">右侧</el-radio-button>
    <el-radio-button value="top">顶部</el-radio-button>
  </el-radio-group>
</el-form-item>

2.3 标签宽度配置

<el-form-item label="标签宽度" :label-position="itemLabelPosition">
  <el-input-number
    v-model="config.labelWidth"
    :min="60"
    :max="500"
  >
    <template #suffix>
      <span>px</span>
    </template>
  </el-input-number>
</el-form-item>

2.4 组件级布局控制

每个组件可以单独设置宽度:

<el-form-item label="字段宽度" v-if="specialShow">
  <el-radio-group v-model="item.grid" class="field-width-wrapper">
    <el-radio-button :value="6" label="1/4" />
 
    <-- 省略-->

    <el-radio-button :value="24" label="整行" />
  </el-radio-group>
</el-form-item>

3. 布局渲染实现

3.1 响应式布局

使用 Element Plus 的栅格系统实现响应式布局:

<el-col :span="fixGridOptions.includes(item.type) ? 24 : finalGrid">
  <!-- 组件内容 -->
</el-col>

布局计算逻辑:

const { finalGrid, fixGridOptions } = useFormData()

// 监听组件属性和表单属性的布局变化
watch(
  () => props.formData.config?.colSpan,
  (newVal) => {
    finalGrid.value = newVal
    props.item.grid = newVal
  }
)

3.2 固定宽度组件

某些组件(如多标签页、分割线等)需要固定宽度:

const fixGridOptions = [
  FORM_TYPE.MULTI_TAB,
  FORM_TYPE.SEPARATOR,
  // 其他需要固定宽度的组件
]

4. 拖拽与布局的交互

4.1 拖拽时的布局应用

当组件被拖拽到画布时,会自动应用当前表单的布局设置:

const handleAdd = ({ newIndex }) => {
  // ...
  props.formData.list[newIndex] = {
    ...JSON.parse(JSON.stringify(props.formData.list[newIndex])),
    itemId,
    // 应用当前表单配置的布局
    grid: props.formData.config?.colSpan || 24
  }
  // ...
}

4.2 布局变更的实时响应

当表单布局发生变化时,所有组件会自动更新:

watch(
  () => props.formData.config?.colSpan,
  (newVal) => {
    finalGrid.value = newVal
    props.item.grid = newVal
  }
)

4.3 组件宽度的独立控制

组件可以覆盖表单的默认布局,设置自己的宽度:

watch(
  () => props.item.grid,
  (newVal) => {
    finalGrid.value = newVal
  }
)

5. 技术亮点 

  • 流畅的拖拽体验 :使用 vuedraggable 实现平滑的拖拽效果 
  • 智能的布局应用 :拖拽时自动应用表单布局设置
  • 灵活的布局选项 :支持多种布局方式和标签对齐方式
  • 组件级布局控制 :每个组件可以单独设置宽度 
  • 响应式设计 :基于 Element Plus 的栅格系统
  • 实时布局更新 :布局变更实时反映到所有组件
  • 固定宽度组件 :某些组件自动使用固定宽度

这种拖拽与布局系统的设计,大大简化了表单设计过程。用户可以通过直观的拖拽操作和灵活的布局配置,快速创建出表单。并且还有预览功能,直接在预览界面就可实时看到表单布局和试用数据填报。

下一篇预告 :《组件属性配置系统》,将详细分析组件属性配置的实现机制和设计思路。

❌