阅读视图

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

Vue2跨组件通信方案:全局事件总线与Vuex的灵活结合

Vue2跨组件通信方案:全局事件总线与Vuex的灵活结合

前端高频面试/开发考点!一文吃透Vue2跨组件通信核心,Bus+Vuex结合用法拆解,代码可直接复制复用,新手也能快速避坑,收藏备用~

📋 目录

  • 一、核心前言(为什么需要两种方案结合?)

  • 二、全局事件总线(Bus)详解(3步落地+避坑)

  • 三、Vuex详解(Vue2状态管理核心,5大模块+4步实战)

  • 四、Bus与Vuex灵活结合(实战场景+核心优势)

  • 五、高频避坑指南(面试常考)

  • 六、核心总结(快速回顾重点)


一、核心前言

Vue2开发中,跨组件通信是绕不开的高频需求,不同组件层级(父子、兄弟、隔代、无关联)对应不同解决方案,单一方案往往有局限性:

  • props/emit:仅适合父子组件,层级嵌套多时会出现“props drilling”(props穿透),代码冗余;

  • 全局事件总线(Bus):轻量高效,但无状态管理,复杂场景难以维护;

  • Vuex:集中管理状态,适合复杂场景,但配置繁琐,简单通信成本高。

核心原则:简单通信用Bus(轻量高效),复杂状态用Vuex(统一管理),两者灵活结合,可高效解决99%的Vue2跨组件通信需求。


二、全局事件总线(Bus)详解

1. 什么是全局事件总线?

本质:通过Vue实例作为“中间桥梁”,实现任意组件间的事件传递(触发+监听),无需层层传递,轻量无依赖、无需额外安装,是简单跨组件通信的最优解。

适用场景:兄弟组件通信、隔代组件简单通信、无关联组件单次通信(如弹窗关闭、通知提示、页面刷新通知)。

2. 实现步骤(3步落地,代码可直接复制)

步骤1:创建全局Bus实例(main.js配置)
// main.js(Vue2项目)
import Vue from 'vue'
import App from './App.vue'

// 创建全局事件总线,挂载到Vue原型,所有组件可直接访问
Vue.prototype.$Bus = new Vue()

new Vue({
  el: '#app',
  render: h => h(App)
})
步骤2:发送事件(触发方组件)

通过 this.$Bus.$emit('事件名', 传递的数据) 发送事件,支持任意类型数据(对象、数组、基本类型)。

<template>
  <button @click="sendMsg" style="padding: 8px 16px; cursor: pointer;">发送消息给兄弟组件</button>
</template>

<script>
export default {
  methods: {
    sendMsg() {
      // 事件名建议语义化,避免冲突(可加组件前缀,如brother-msg)
      this.$Bus.$emit('brotherMsg', {
        content: 'Hello,兄弟组件!',
        time: new Date().toLocaleString()
      })
    }
  }
}
</script>
步骤3:监听事件(接收方组件)

通过 this.$Bus.$on('事件名', 回调函数) 监听事件,重点:必须在beforeDestroy中销毁监听,避免内存泄漏和事件多次触发。

<template>
  <div class="brother-component">
    <h4>接收兄弟组件消息:</h4>
    <p v-if="msg">{{ msg.content }}({{ msg.time }})</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      msg: null
    }
  },
  mounted() {
    // 监听事件,与发送方事件名保持一致
    this.$Bus.$on('brotherMsg', (data) => {
      this.msg = data
    })
  },
  beforeDestroy() {
    // 销毁监听,避免内存泄漏(必写!面试常考)
    this.$Bus.$off('brotherMsg')
  }
}
</script>

3. Bus核心方法速查(表格清晰记)

方法名 说明 使用示例
$emit 发送事件,传递数据 this.Bus.Bus.emit('name', data)
$on 监听事件,接收数据 this.Bus.Bus.on('name', (data)=>{})
$off 销毁监听,避免泄漏 this.Bus.Bus.off('name')

4. Bus优缺点(辩证看待)

✅ 优点

  • 轻量、简单、无依赖,接入成本极低

  • 无需额外配置,开箱即用

  • 适合简单通信场景,效率高

❌ 缺点

  • 无状态管理,无法追踪数据来源

  • 事件名易冲突,维护成本随项目变大升高

  • 不适合多组件共享、频繁修改的复杂状态


三、Vuex详解(Vue2状态管理核心)

1. 什么是Vuex?

Vue2官方状态管理库,用于集中管理所有组件的共享状态(如用户信息、购物车数据、全局设置),实现组件间状态共享和统一修改,可追踪状态变化,是中大型Vue2项目的首选方案。

适用场景:多组件共享状态、需频繁修改/追踪的复杂状态、全局状态管理(如用户登录状态、主题切换)。

2. Vuex核心概念(5大模块,面试必背)

记牢这5个模块,即可掌握Vuex核心用法,面试高频提问!

  • state:存储全局状态(类似组件的data),唯一数据源,所有组件共享;

  • mutations:修改state的唯一方式(仅支持同步操作),禁止写异步代码;

  • actions:处理异步操作(如接口请求),不能直接修改state,需通过commit调用mutations;

  • getters:对state进行加工处理(类似组件的computed),可缓存结果,避免重复计算;

  • modules:拆分模块(大型项目用),避免state过于臃肿,每个模块可拥有独立的state、mutations等。

3. 使用步骤(4步落地,实战可直接复用)

步骤1:安装Vuex(Vue2专属版本,避坑关键)

Vue2必须安装3.x版本,4.x版本仅适配Vue3,装错会直接报错!

# Vue2项目安装命令(固定版本,避免兼容问题)
npm install vuex@3.6.2 --save
步骤2:创建Vuex实例(src/store/index.js)
// src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

// 安装Vuex插件
Vue.use(Vuex)

// 创建Vuex实例
const store = new Vuex.Store({
  // 存储全局状态
  state: {
    userInfo: null, // 多组件共享:用户信息
    count: 0 // 示例:简单共享计数
  },
  // 同步修改state(仅同步操作)
  mutations: {
    setUserInfo(state, data) {
      state.userInfo = data // 只能通过mutation修改state
    },
    increment(state) {
      state.count++
    }
  },
  // 处理异步操作(如接口请求)
  actions: {
    // 模拟异步获取用户信息(实际项目替换为接口请求)
    getUserInfoAsync({ commit }, data) {
      setTimeout(() => {
        // 异步操作完成后,通过commit调用mutation修改state
        commit('setUserInfo', data)
      }, 1000)
    }
  },
  // 加工state,缓存结果
  getters: {
    // 判断用户是否登录
    isLogin(state) {
      return !!state.userInfo
    },
    // 获取计数的2倍(缓存结果,避免重复计算)
    doubleCount(state) {
      return state.count * 2
    }
  }
})

export default store
步骤3:挂载Vuex到Vue实例(main.js)
// main.js
import Vue from 'vue'
import App from './App.vue'
import store from './store' // 引入store实例
import Vuex from 'vuex'

Vue.use(Vuex)

new Vue({
  el: '#app',
  render: h => h(App),
  store // 挂载后,所有组件可通过this.$store访问Vuex
})
步骤4:组件中使用Vuex(读取/修改状态)
<template>
  <div class="vuex-demo">
    <h4>Vuex状态使用示例</h4>
    <p>当前计数:{{ $store.state.count }}</p>
    <p>计数的2倍:{{ $store.getters.doubleCount }}</p>
    <p>用户是否登录:{{ $store.getters.isLogin ? '已登录' : '未登录' }}</p>
    
    <button @click="addCount" style="margin-right: 10px; padding: 8px 16px;">增加计数</button>
    <button @click="getUserInfo" style="padding: 8px 16px;">模拟登录</button>
  </div>
</template>

<script>
export default {
  methods: {
    // 同步修改state:调用mutation(唯一方式)
    addCount() {
      this.$store.commit('increment')
    },
    // 异步修改state:调用action,由action触发mutation
    getUserInfo() {
      this.$store.dispatch('getUserInfoAsync', {
        username: 'vue2demo',
        age: 22
      })
    }
  }
}
</script>

4. Vuex优缺点(辩证看待)

✅ 优点

  • 集中管理共享状态,可追踪状态变化(调试方便);

  • 规范组件通信,避免数据混乱;

  • 适合复杂场景,维护成本低,扩展性强。

❌ 缺点

  • 配置繁琐,简单通信场景(如单次弹窗)使用成本高;

  • 小型项目无需使用,过度封装会增加冗余。


四、Bus与Vuex的灵活结合(核心重点)

1. 结合原则(实战核心)

记住一句话:简单通信用Bus,复杂状态用Vuex,两者互补,避开单一方案的弊端,提升开发效率。

  • 用Bus的场景:一次性通信、无状态依赖通信(弹窗关闭、兄弟组件单次消息、页面刷新通知);

  • 用Vuex的场景:多组件共享状态、需频繁修改/追踪的复杂状态(用户信息、购物车、全局设置)。

2. 实战结合示例(面试常考场景)

场景:用户登录成功后,用Vuex同步全局用户状态,用Bus通知所有相关组件(导航栏、个人中心)刷新页面。

// 1. 登录组件(触发登录,调用Vuex action + 发送Bus事件)
export default {
  methods: {
    login() {
      // 模拟接口请求登录,获取用户数据
      const userData = { username: 'vue2demo', role: 'admin' }
      // ① 调用Vuex action,同步用户状态到全局(复杂状态管理)
      this.$store.dispatch('getUserInfoAsync', userData)
      // ② 发送Bus事件,通知其他组件刷新(简单一次性通信)
      this.$Bus.$emit('userLoginSuccess', userData)
    }
  }
}

// 2. 导航栏组件(监听Bus事件 + 读取Vuex状态)
export default {
  data() {
    return {
      userInfo: null
    }
  },
  mounted() {
    // 监听Bus事件,接收登录成功通知,局部更新
    this.$Bus.$on('userLoginSuccess', (data) => {
      this.userInfo = data
    })
    // 初始化时,读取Vuex中的全局用户状态
    this.userInfo = this.$store.state.userInfo
  },
  beforeDestroy() {
    // 销毁Bus监听,避免内存泄漏
    this.$Bus.$off('userLoginSuccess')
  }
}

3. 结合优势(为什么要这么用?)

  • ✅ 高效:简单场景无需配置复杂Vuex,降低开发成本;复杂场景用Vuex,保证状态规范;

  • ✅ 灵活:按需选择方案,避免“一刀切”(不用为了简单通信写一堆Vuex配置);

  • ✅ 易维护:状态集中管理(Vuex),单次通信解耦(Bus),代码清晰,后期好维护。


五、高频避坑指南(面试常考,必看!)

这些坑90%的新手都会踩,收藏起来,避免踩坑!

1. Bus避坑(2个核心)

  • 事件名必须语义化,可加组件前缀(如header-close、brother-msg),避免冲突;

  • 必须在beforeDestroy中销毁监听(this.Bus.Bus.off('事件名')),否则会导致内存泄漏、事件多次触发。

2. Vuex避坑(3个核心)

  • Vue2必须安装Vuex@3.x版本,4.x仅适配Vue3,装错会直接报错;

  • mutations只能写同步代码,异步操作(如接口请求)必须放在actions中,否则无法追踪状态变化;

  • 禁止直接修改state(如this.$store.state.count = 1),必须通过mutation修改(面试高频考点)。

3. 结合避坑(2个核心)

  • 不滥用Vuex,简单通信用Bus即可,避免过度封装;

  • Bus仅用于“通知”,不传递大量复杂数据(复杂数据用Vuex存储),避免数据混乱。


六、核心总结

本文核心是「Bus+Vuex灵活结合」,记住以下4点,轻松应对Vue2跨组件通信所有场景:

  1. 全局事件总线(Bus):Vue实例作为桥梁,轻量简单,适合简单通信,重点是销毁监听

  2. Vuex:Vue2官方状态管理库,集中管理共享状态,适合复杂场景,核心是5大模块,禁止直接修改state

  3. 结合逻辑:简单通信用Bus,复杂状态用Vuex,互补使用,提升开发效率和代码可维护性;

  4. 避坑关键:Bus销毁监听、Vuex版本适配、不直接修改state、事件名语义化。

你在Vue2跨组件通信中还遇到过哪些坑?欢迎在评论区留言交流,一起避坑成长~

使用Cursor 完成 Vike + Vue 3 + Element Plus 管理后台 — 从 0 到 1 (实例与文档)

目录

  1. 项目概述
  2. 技术栈
  3. 项目初始化
  4. 目录结构
  5. 核心配置文件
  6. 服务端 — Express 服务器
  7. Vike 页面约定与 Hook 体系
  8. 状态管理 — Pinia
  9. 国际化 — Vue I18n
  10. API 层 — Alova + Axios
  11. Layout 系统
  12. Element Plus 集成(SSR 兼容)
  13. 权限系统
  14. 路由与导航
  15. 业务页面示例
  16. SSR 与 CSR 策略
  17. 关键踩坑与解决方案
  18. 开发与构建命令
  19. 生产部署

1. 项目概述

本项目是一个基于 Vike(前 vite-plugin-ssr)+ Vue 3 的企业级管理后台模板。核心思路是利用 Vike 框架的原生 Hook 体系(+config.ts+guard.ts+data.ts+Layout.vue+onCreateApp.ts)替代传统 Vue Router 的路由守卫和路由配置方式,实现:

  • SSR 首屏渲染 — 首屏数据通过 +data.ts 在服务端预取,直接输出到 HTML
  • 统一权限验证 — 通过 +guard.ts 在 SSR 阶段调用后端权限接口,无权限直接渲染 403 页面
  • 公共 Layout 可定制 — 每个页面可通过 Pinia Store 方法动态修改 Layout 标题、面包屑、顶部按钮等
  • 国际化 — Vue I18n 支持中英文切换,菜单、标题、错误页均支持多语言
  • UI 组件库 — Element Plus 全量引入,SSR 兼容

2. 技术栈

类别 技术 版本 说明
框架 Vue 3 ^3.5 Composition API
元框架 Vike ^0.4.252 SSR / 文件系统路由
Vue 适配 vike-vue ^0.9.10 Vike 的 Vue 3 适配器
UI 组件库 Element Plus ^2.9 管理后台 UI 组件
状态管理 Pinia ^3.0 Vue 3 官方状态管理
国际化 Vue I18n ^11.1 多语言支持
HTTP 请求 Alova + Axios ^3.2 / ^1.9 请求策略库 + HTTP 客户端
服务端 Express 5 ^5.2 Node.js HTTP 服务器
构建工具 Vite 7 ^7.3 开发服务器 + 打包
语言 TypeScript ^5.9 类型安全
CSS 预处理 SCSS ^1.87 样式预处理
代码规范 ESLint + typescript-eslint ^9.39 代码质量保障

3. 项目初始化

3.1 创建项目

# 创建目录
mkdir vike-zyh-test && cd vike-zyh-test

# 初始化 package.json
npm init -y

3.2 安装依赖

运行时依赖:

npm install vue vike vike-vue express compression cookie-parser sirv \
  pinia vue-i18n element-plus alova @alova/adapter-axios axios

开发依赖:

npm install -D vite @vitejs/plugin-vue typescript tsx sass \
  unplugin-auto-import unplugin-vue-components \
  @intlify/unplugin-vue-i18n cross-env \
  eslint @eslint/js eslint-plugin-vue typescript-eslint vue-eslint-parser globals \
  @types/express @types/compression @types/cookie-parser

3.3 设定 package.json Scripts

{
  "type": "module",
   "scripts": {
    "dev": "tsx server/server.ts",
    "build": "vike build",
    "preview": "vike build && cross-env NODE_ENV=production tsx server/server.ts",
    "lint": "eslint .",
    "fix": "eslint . --fix"
  },
}

关键点:开发模式使用 tsx 直接运行 TypeScript 编写的 Express 服务器,而非 vite dev。这允许我们完全掌控服务端中间件、Mock API 和渲染流程。


4. 目录结构

vike-zyh-test/
├── server/                          # Express 服务端
│   └── server.ts                    # 入口:中间件 + Mock API + Vike 渲染
├── src/
│   ├── api/                         # API 层
│   │   ├── alovaInstance.ts         # Alova 实例管理 + apiCreator 统一请求工厂
│   │   ├── createClientApi.ts       # 客户端 Alova 实例创建
│   │   ├── createServerApi.ts       # 服务端 Alova 实例创建(用于 +data.ts / +guard.ts)
│   │   ├── dashboardApi.ts          # Dashboard 业务 API
│   │   └── permissionApi.ts         # 权限业务 API
│   ├── composables/                 # 组合式函数
│   │   ├── useLayout.ts             # Layout 控制接口(setTitle / setBreadcrumbs / setHeaderActions ...)
│   │   ├── usePagination.ts         # 分页逻辑封装
│   │   └── usePermission.ts         # 权限检查(hasPermission)
│   ├── constants/                   # 常量
│   │   ├── constants.ts             # 通用常量(分页默认值、枚举等)
│   │   ├── menu.ts                  # 侧边栏菜单配置
│   │   └── permissionApis.ts        # 权限 API URL 常量(统一管理)
│   ├── directive/                   # 自定义指令
│   │   └── directive.ts             # 指令注册入口(如权限指令 v-permission)
│   ├── i18n/                        # 国际化
│   │   ├── i18n.ts                  # createI18n 工厂函数
│   │   ├── zh-CN.json               # 中文语言包
│   │   └── en-US.json               # 英文语言包
│   ├── layout/                      # Layout 组件
│   │   ├── AppSidebar.vue           # 侧边栏
│   │   └── AppHeader.vue            # 顶部导航栏
│   ├── pages/                       # Vike 文件系统路由 ★
│   │   ├── +config.ts               # 全局页面配置
│   │   ├── +onCreateApp.ts          # Vue App 创建钩子(注册 Pinia/I18n/ElementPlus)
│   │   ├── +guard.ts                # 全局路由守卫(权限验证)
│   │   ├── +Layout.vue              # 全局 Layout
│   │   ├── +Head.vue                # 全局 HTML <head>
│   │   ├── _error/                  # 错误页面(401/403/404/500)
│   │   │   └── +Page.vue
│   │   ├── index/                   # 首页 /
│   │   │   ├── +config.ts
│   │   │   ├── +data.ts             # SSR 数据预取
│   │   │   └── +Page.vue
│   │   └── permission/              # 权限管理模块
│   │       ├── +config.ts
│   │       ├── +data.ts             # SSR 数据预取(权限列表)
│   │       ├── +Page.vue            # 权限列表页
│   │       ├── add/                 # 新增权限 /permission/add
│   │       │   ├── +config.ts
│   │       │   ├── +data.ts         # 空 data,阻止继承父级
│   │       │   └── +Page.vue
│   │       └── @id/                 # 动态路由 /permission/:id
│   │           └── edit/            # 编辑权限 /permission/:id/edit
│   │               ├── +config.ts
│   │               ├── +data.ts     # 空 data,阻止继承父级
│   │               └── +Page.vue
│   ├── scss/                        # 全局样式
│   │   └── common.scss
│   ├── stores/                      # Pinia 状态管理
│   │   ├── global.ts                # 全局状态(env/lang/user)
│   │   └── layout.ts                # 布局状态(title/breadcrumbs/headerActions/sidebar)
│   └── viewComponents/              # 页面级可复用组件
│       └── permission/
│           └── PermissionForm.vue   # 权限表单组件(新增/编辑复用)
├── vite.config.ts                   # Vite 配置
├── tsconfig.json                    # TypeScript 根配置(引用子配置)
├── tsconfig.app.json                # 前端 TS 配置
├── tsconfig.node.json               # Vite 配置用 TS 配置
├── tsconfig.server.json             # 服务端 TS 配置
├── eslint.config.ts                 # ESLint 配置
└── package.json

约定说明pages/ 目录下以 + 开头的文件是 Vike 框架约定文件,分别承担配置、数据预取、守卫、布局、渲染等职责。@id 目录名表示动态路由参数。_error 为 Vike 约定的错误页面目录。


5. 核心配置文件

5.1 package.json

{
  "type": "module",
  "imports": {
    "#*": "./*",
    "#server/*": "./server/*"
  }
}
  • "type": "module" — 启用 ESM
  • "imports" — Node.js 原生子路径导入映射,配合 tsconfig.jsonpaths 实现统一的 # 前缀路径别名

5.2 vite.config.ts

import { fileURLToPath, URL } from 'node:url';
import { readdir } from 'node:fs/promises';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vike from 'vike/plugin';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite';

// 自动扫描 src/ 下的子目录,生成路径别名
const srcSubDirs = (
  await readdir(new URL('./src', import.meta.url), { withFileTypes: true })
)
  .filter((d) => d.isDirectory())
  .map(({ name }) => name);

export default defineConfig({
  plugins: [
    vue(),
    vike(),
    AutoImport({
      resolvers: [ElementPlusResolver({ importStyle: false })],
    }),
    Components({
      resolvers: [ElementPlusResolver({ importStyle: false })],
    }),
    VueI18nPlugin({ ssr: true, strictMessage: false }),
  ],
  resolve: {
    alias: {
      '#': fileURLToPath(new URL('./', import.meta.url)),
      '#src': fileURLToPath(new URL('./src', import.meta.url)),
      '#server': fileURLToPath(new URL('./server', import.meta.url)),
      // 自动生成: #api, #composables, #stores, #i18n, #layout, #pages ...
      ...Object.fromEntries(
        srcSubDirs.map((name) => [
          `#${name}`,
          fileURLToPath(new URL(`./src/${name}`, import.meta.url)),
        ]),
      ),
    },
  },
  build: { target: 'es2022' },
});

关键设计点:

配置项 说明
vike() 启用 Vike 插件,提供 SSR + 文件系统路由
ElementPlusResolver({ importStyle: false }) 禁用 样式自动导入,避免 SSR 中加载 CSS 文件报错。样式改为在 +Layout.vue 中手动 import 'element-plus/dist/index.css'
VueI18nPlugin({ ssr: true }) 开启 i18n 的 SSR 优化,编译时处理 <i18n>
路径别名自动扫描 自动读取 src/ 子目录,无需手动逐个配置别名

5.3 TypeScript 配置

项目采用三配置策略

文件 作用 module
tsconfig.app.json 前端源码 (src/) ES2022 / Bundler
tsconfig.node.json Vite 配置文件 ES2022 / Bundler
tsconfig.server.json 服务端代码 (server/) Node16 / Node16

tsconfig.app.json 中配置了所有 # 前缀的路径映射:

{
  "compilerOptions": {
    "paths": {
      "#*": ["./*"],
      "#src/*": ["./src/*"],
      "#api/*": ["./src/api/*"],
      "#stores/*": ["./src/stores/*"],
      "#i18n/*": ["./src/i18n/*"],
      "#layout/*": ["./src/layout/*"],
      "#composables/*": ["./src/composables/*"],
      "#constants/*": ["./src/constants/*"],
      "#directive/*": ["./src/directive/*"],
      "#viewComponents/*": ["./src/viewComponents/*"],
      "#server/*": ["./server/*"]
    }
  }
}

5.4 ESLint 配置

使用 ESLint 9 Flat Config,集成 typescript-eslinteslint-plugin-vue

// eslint.config.ts
import eslint from '@eslint/js';
import pluginVue from 'eslint-plugin-vue';
import tseslint from 'typescript-eslint';
import vueParser from 'vue-eslint-parser';

export default tseslint.config(
  eslint.configs.recommended,
  ...tseslint.configs.recommended,
  // Vue 文件使用 vue-eslint-parser 嵌套 typescript parser
  {
    files: ['**/*.vue'],
    languageOptions: {
      parser: vueParser,
      parserOptions: { parser: tseslint.parser },
    },
  },
  ...pluginVue.configs['flat/recommended'],
);

6. 服务端 — Express 服务器

server/server.ts 是项目入口,使用 Express 5 搭建 HTTP 服务器:

import express from 'express';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import { renderPage, createDevMiddleware } from 'vike/server';

async function startServer() {
  const app = express();

  // 1. 基础中间件
  app.use(compression());       // Gzip 压缩
  app.use(cookieParser());      // Cookie 解析
  app.disable('x-powered-by');  // 隐藏 Express 标识

  // 2. 静态文件 / Vite 开发中间件
  if (isProd) {
    app.use(sirv('dist/client'));  // 生产环境:静态文件
  } else {
    const { devMiddleware } = await createDevMiddleware({ root });
    app.use(devMiddleware);        // 开发环境:Vite HMR
  }

  // 3. Mock API(开发阶段可替换为真实后端代理)
  app.use(express.json());
  app.get('/api/v1/dashboard/stats', ...);
  app.get('/api/v1/permissions', ...);
  app.post('/api/v1/permission/check', ...);

  // 4. Vike 页面渲染 — 所有未匹配的 GET 请求
  app.get('/{*path}', async (req, res, next) => {
    const pageContext = await renderPage({
      urlOriginal: req.originalUrl,
      headersOriginal: req.headers,
      cookies: req.cookies,
    });

    if (!pageContext.httpResponse) return next();

    const { body, statusCode, headers } = pageContext.httpResponse;
    headers.forEach(([name, value]) => res.setHeader(name, value));
    res.status(statusCode).send(body);
  });

  app.listen(3000);
}

重点说明:

  1. Express 5 路由语法app.get('/{*path}', ...) — Express 5 使用命名通配符,不再支持 app.get('*', ...)
  2. pageContext 初始化headersOriginalcookies 被传入 pageContext,供 +guard.ts+data.ts 中的 SSR API 调用使用(转发原始请求头实现登录态传递)
  3. Mock API 位于 Vike 渲染之前:确保 API 请求不会被 Vike 拦截

7. Vike 页面约定与 Hook 体系

Vike 的核心理念:通过 + 前缀文件约定替代路由配置。每个约定文件承担特定职责,按以下顺序执行:

请求进入 → +guard.ts(权限验证)→ +data.ts(数据预取)→ +Page.vue(页面渲染)
                                                          ↑
                                              +Layout.vue 包裹
                                              +Head.vue 注入 <head>

7.1 +config.ts — 全局/页面级配置

全局配置 src/pages/+config.ts

import vikeVue from 'vike-vue/config';
import type { Config } from 'vike/types';

export default {
  extends: [vikeVue],   // 继承 vike-vue 默认行为
  title: 'Admin',
  passToClient: ['user', 'locale', 'permissionResult', 'routeName'],
  meta: {
    permissionUrls: {
      env: { server: true, client: true },  // 自定义配置项,服务端和客户端均可访问
    },
  },
} satisfies Config;
  • passToClient — 指定哪些 pageContext 属性传递到客户端(SSR → CSR 数据桥接)
  • meta.permissionUrls — 声明自定义页面配置项,用于权限验证

页面级配置 src/pages/permission/+config.ts

import { PERMISSION_APIS } from '../../constants/permissionApis';

export default {
  title: '权限列表',
  permissionUrls: [
    PERMISSION_APIS.LIST,
    PERMISSION_APIS.CREATE,
    PERMISSION_APIS.UPDATE,
    PERMISSION_APIS.DELETE,
  ],
};

每个页面的 +config.ts 中的 permissionUrls 会被 +guard.ts 读取,用于权限验证。权限 URL 常量统一定义在 src/constants/permissionApis.ts 中。

7.2 +onCreateApp.ts — Vue 应用创建钩子

每次渲染(SSR 和 CSR)都会执行此钩子,用于注册全局插件和指令:

import type { OnCreateAppSync } from 'vike-vue/types';
import { createPinia } from 'pinia';
import { ID_INJECTION_KEY, ZINDEX_INJECTION_KEY } from 'element-plus';
import { createI18n } from '#i18n/i18n';
import directives from '#directive/directive';

const onCreateApp: OnCreateAppSync = (pageContext) => {
  const { app } = pageContext;

  // 1. Pinia 状态管理
  app.use(createPinia());

  // 2. Vue I18n 国际化
  app.use(createI18n());

  // 3. Element Plus SSR 兼容 — 必须 provide ID 和 ZIndex
  app.provide(ID_INJECTION_KEY, { prefix: 1024, current: 0 });
  app.provide(ZINDEX_INJECTION_KEY, { current: 0 });

  // 4. 自定义指令
  Object.entries(directives).forEach(([name, directive]) => {
    app.directive(name, directive);
  });
};

export default onCreateApp;

7.3 +Layout.vue — 全局布局

公共 Layout 包裹所有页面,集成侧边栏、顶部导航、Element Plus 配置提供者:

<template>
  <el-config-provider :locale="elementLocale">
    <div class="app-layout">
      <aside v-if="layoutStore.showSidebar" :class="['app-sidebar', { collapsed: layoutStore.sidebarCollapsed }]">
        <AppSidebar :menus="defaultMenus" :collapsed="layoutStore.sidebarCollapsed" />
      </aside>
      <div class="app-main">
        <AppHeader
          v-if="layoutStore.showHeader"
          :breadcrumbs="layoutStore.breadcrumbs"
          :header-actions="layoutStore.headerActions"
          @toggle-sidebar="layoutStore.toggleSidebar()"
        />
        <main class="app-content">
          <slot />  <!-- 页面内容插入点 -->
        </main>
      </div>
    </div>
  </el-config-provider>
</template>

<script lang="ts" setup>
import 'element-plus/dist/index.css';   // 手动引入样式(SSR 兼容)
import '#scss/common.scss';

// ...组件引入与状态管理
</script>

7.4 +Head.vue — 全局 HTML Head

<template>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
</template>

7.5 +guard.ts — 路由守卫(权限验证)

核心权限验证机制,在 SSR 阶段拦截请求:

import type { GuardAsync } from 'vike/types';
import { render } from 'vike/abort';

const guard: GuardAsync = async (pageContext) => {
  const permissionUrls = (pageContext.config as any).permissionUrls;

  // 没有配置权限 URL 的页面,直接放行
  if (!permissionUrls || permissionUrls.length === 0) return;

  // SSR 时调用后台权限验证接口
  if (typeof window === 'undefined') {
    try {
      const { createDefaultAPI } = await import('#api/createServerApi');
      const port = process.env.PORT || 3000;
      const alova = createDefaultAPI({
        baseURL: `http://localhost:${port}/api/v1`,
        headers: (pageContext as any).headersOriginal,  // 转发原始请求头
      });

      const result = await alova.Post('/permission/check', {
        urls: permissionUrls,
        pagePath: pageContext.urlPathname,
      });

      if (!result?.data?.allowed) {
        throw render(403);  // 渲染 403 错误页
      }

      // 权限结果存入 pageContext,传到客户端
      (pageContext as any).permissionResult = result.data;
    } catch (error) {
      if ((error as any)?.isAbort) throw error;  // 已是 abort 直接抛出
      throw render(403);  // 异常也视为无权限
    }
  }
};

7.6 +data.ts — SSR 数据预取

在服务端获取数据,通过 useData() 在页面组件中使用:

// src/pages/index/+data.ts
import type { PageContextServer } from 'vike/types';
import { createDefaultAPI } from '#api/createServerApi';

const SSR_API_BASE = `http://localhost:${process.env.PORT || 3000}/api/v1`;

export type Data = DashboardStats;

export async function data(_pageContext: PageContextServer): Promise<Data> {
  const alova = createDefaultAPI({
    baseURL: SSR_API_BASE,
    headers: (_pageContext as any).headersOriginal,
  });

  const res = await alova.Get('/dashboard/stats');
  return res.data;
}

注意+data.ts 的继承问题 — 子路由会继承父目录的 +data.ts。如果子页面不需要父级数据,需要创建空的 +data.ts 来阻止继承:

// src/pages/permission/add/+data.ts
export type Data = Record<string, never>;
export async function data() { return {}; }

7.7 +Page.vue — 页面组件

每个目录下的 +Page.vue 即该路由对应的页面组件。通过 useData() 获取 SSR 预取数据:

<script lang="ts" setup>
import { useData } from 'vike-vue/useData';
import type { Data } from './+data';

const data = useData<Data>();  // 类型安全地获取 SSR 数据
</script>

7.8 _error/+Page.vue — 错误页面

统一的错误页面,支持 401/403/404/500:

<script lang="ts" setup>
import { usePageContext } from 'vike-vue/usePageContext';

const pageContext = usePageContext();

const errorCode = computed(() => {
  return pageContext.is404 ? 404 : (pageContext.abortStatusCode || 500);
});
</script>

+guard.tsthrow render(403) 时,Vike 会自动渲染 _error/+Page.vue 并传递 abortStatusCode: 403


8. 状态管理 — Pinia

8.1 全局状态 (global.ts)

// src/stores/global.ts
import { defineStore } from 'pinia';

export const useGlobalStore = defineStore('global', {
  state: () => ({
    env: '',
    lang: 'zh-CN',
    user: null as null | { name: string; role: string },
  }),
  actions: {
    updateEnv(env: string) { this.env = env; },
    updateLang(lang: string) { this.lang = lang; },
    updateUser(user: { name: string; role: string } | null) { this.user = user; },
  },
});

8.2 布局状态 (layout.ts)

// src/stores/layout.ts
export const useLayoutStore = defineStore('layout', {
  state: () => ({
    title: '',
    breadcrumbs: [] as BreadcrumbItem[],
    sidebarMenus: [] as MenuItem[],
    showSidebar: true,
    showHeader: true,
    sidebarCollapsed: false,
    headerActions: [] as HeaderAction[],
  }),
  actions: {
    setTitle(title: string) { this.title = title; },
    setHeaderActions(actions: HeaderAction[]) { this.headerActions = actions; },
    clearHeaderActions() { this.headerActions = []; },
    setBreadcrumbs(items: BreadcrumbItem[]) { this.breadcrumbs = items; },
    toggleSidebar() { this.sidebarCollapsed = !this.sidebarCollapsed; },
    resetLayout() {
      this.title = '';
      this.breadcrumbs = [];
      this.headerActions = [];
      this.showSidebar = true;
      this.showHeader = true;
    },
  },
});

类型定义:

export interface BreadcrumbItem {
  label: string;
  path?: string;
}

export interface MenuItem {
  label: string;
  path: string;
  icon?: string;
  children?: MenuItem[];
}

export interface HeaderAction {
  key: string;
  label: string;
  icon?: string;
  type?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'default';
  handler: () => void;
}

9. 国际化 — Vue I18n

9.1 创建 I18n 实例

// src/i18n/i18n.ts
import { createI18n as _createI18n } from 'vue-i18n';
import zhCN from '#i18n/zh-CN.json';
import enUS from '#i18n/en-US.json';

export const LANGUAGE = {
  ZH_CN: 'zh-CN',
  EN_US: 'en-US',
} as const;

export function createI18n() {
  return _createI18n({
    legacy: false,          // 使用 Composition API
    locale: LANGUAGE.ZH_CN, // 默认中文
    fallbackLocale: LANGUAGE.ZH_CN,
    messages: {
      [LANGUAGE.ZH_CN]: zhCN,
      [LANGUAGE.EN_US]: enUS,
    },
  });
}

9.2 语言包结构

// zh-CN.json
{
  "app": { "title": "管理后台" },
  "error": {
    "unauthorized": "登录已过期,请重新登录",
    "forbidden": "暂无权限访问此页面",
    "notFound": "页面不存在",
    "serverError": "服务器内部错误,请稍后重试"
  },
  "menu": {
    "home": "首页",
    "permission": "权限管理",
    "permissionList": "权限列表",
    "permissionAdd": "新增权限"
  },
  "common": { "add": "新增", "edit": "编辑", "delete": "删除", ... },
  "permission": { "name": "权限名称", "code": "权限编码", ... },
  "dashboard": { "totalPermissions": "总权限数", ... }
}

9.3 在组件中使用

<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>

<template>
  <span>{{ t('app.title') }}</span>
  <span>{{ t('menu.home') }}</span>
</template>

9.4 菜单配置与 i18n

菜单的 label 字段使用 i18n key,在渲染时通过 t() 翻译:

// src/constants/menu.ts
export const SIDEBAR_MENUS: MenuItem[] = [
  { label: 'menu.home', path: '/', icon: 'House' },
  {
    label: 'menu.permission', path: '/permission', icon: 'Lock',
    children: [
      { label: 'menu.permissionList', path: '/permission' },
      { label: 'menu.permissionAdd', path: '/permission/add' },
    ],
  },
];

10. API 层 — Alova + Axios

项目使用 Alova 作为请求策略层,底层适配 Axios。分为客户端和服务端两套实例。

10.1 核心实例管理 (alovaInstance.ts)

// src/api/alovaInstance.ts

// API 类型枚举
export const API_TYPE = { DEFAULT: 'default', LOCAL: 'local' } as const;

// 基础 URL 映射
export const API_BASE_URL = {
  [API_TYPE.DEFAULT]: '/api/v1',
  [API_TYPE.LOCAL]: '/local-api',
};

// 统一请求工厂
export function apiCreator(options: ApiOption, data?: any, customInstances?: AlovaInstances) {
  const { method = 'get', type = API_TYPE.DEFAULT, pathVariable, ...restOptions } = options;
  const instance = getAlovaInstance(customInstances, type);

  let { url = '' } = restOptions;
  if (pathVariable) url = templateUrl(url, pathVariable);  // URL 模板变量替换

  const methodName = method.charAt(0).toUpperCase() + method.slice(1);

  if (['Post', 'Put', 'Patch', 'Delete'].includes(methodName)) {
    return instance[methodName](url, data, restOptions);
  }
  return instance[methodName](url, { params: data, ...restOptions });
}

10.2 客户端 API (createClientApi.ts)

import { createAlova } from 'alova';
import VueHook from 'alova/vue';
import { axiosRequestAdapter } from '@alova/adapter-axios';

export function createClientAlova({ baseURL, timeout = 30000 }) {
  return createAlova({
    baseURL,
    timeout,
    cacheFor: null,        // 禁用缓存
    statesHook: VueHook,   // 绑定 Vue 响应式
    requestAdapter: axiosRequestAdapter(),
    responded: {
      onSuccess: async (response) => response.data,  // 自动解包 Axios 响应
      onError: (error) => { throw error; },
    },
  });
}

10.3 服务端 API (createServerApi.ts)

export function createServerAlova({ baseURL, headers, timeout = 30000 }) {
  return createAlova({
    baseURL,
    timeout,
    cacheFor: null,
    statesHook: VueHook,
    requestAdapter: axiosRequestAdapter(),
    beforeRequest(method) {
      // 转发原始请求头(携带 Cookie/Authorization 等)
      if (headers) {
        Object.assign(method.config, {
          headers: { ...method.config.headers, ...headers },
        });
      }
    },
    responded: {
      onSuccess: async (response) => response.data,
      onError: (error) => { throw error; },
    },
  });
}

客户端 vs 服务端的关键差异:服务端实例在 beforeRequest 中转发原始请求头(headersOriginal),用于传递登录态(Cookie、Token)。服务端还需要使用绝对 URLhttp://localhost:3000/api/v1)而非相对路径。

10.4 业务 API 定义

业务 API 通过 apiCreator 统一创建,例如权限 API:

// src/api/permissionApi.ts
import { apiCreator, API_TYPE } from '#api/alovaInstance';

export function fetchPermissionList(params, options?, customInstances?) {
  return apiCreator(
    { ...options, method: 'get', url: '/permissions', type: API_TYPE.DEFAULT },
    params, customInstances,
  );
}

export function createPermission(data, options?, customInstances?) {
  return apiCreator(
    { ...options, method: 'post', url: '/permissions', type: API_TYPE.DEFAULT },
    data, customInstances,
  );
}

11. Layout 系统

11.1 公共布局与页面自定义

设计理念:Layout 是全局公共的,但每个页面可以通过 Pinia Store 暴露的方法来修改布局状态。

+Layout.vue(全局布局)
    ├── AppSidebar(侧边栏 — 读取 layoutStore.sidebarMenus)
    ├── AppHeader(顶部栏 — 读取 layoutStore.breadcrumbs / headerActions)
    └── <slot />(页面内容)
            ↑
    页面在 onMounted 中调用 useLayout() 设置标题、面包屑、按钮等

11.2 useLayout 组合式函数

// src/composables/useLayout.ts
export function useLayout() {
  const layoutStore = useLayoutStore();

  onMounted(() => {
    layoutStore.resetLayout();  // 每次页面挂载时重置布局状态
  });

  return {
    setTitle(title: string) { layoutStore.setTitle(title); },
    setBreadcrumbs(items: BreadcrumbItem[]) { layoutStore.setBreadcrumbs(items); },
    setHeaderActions(actions: HeaderAction[]) { layoutStore.setHeaderActions(actions); },
    setShowSidebar(show: boolean) { layoutStore.setShowSidebar(show); },
    setShowHeader(show: boolean) { layoutStore.setShowHeader(show); },
    toggleSidebar() { layoutStore.toggleSidebar(); },
    clearHeaderActions() { layoutStore.clearHeaderActions(); },
  };
}

页面中使用示例:

<script lang="ts" setup>
import { onMounted } from 'vue';
import { useLayout } from '#composables/useLayout';
import { useI18n } from 'vue-i18n';

const { t } = useI18n();
const layout = useLayout();

onMounted(() => {
  layout.setTitle(t('menu.home'));
  layout.setBreadcrumbs([{ label: t('menu.home') }]);
  layout.setHeaderActions([
    { key: 'refresh', label: '刷新', type: 'primary', handler: () => loadData() },
  ]);
});
</script>

11.3 AppSidebar 组件

<!-- src/layout/AppSidebar.vue -->
<template>
  <div class="sidebar-menu">
    <div class="logo">
      <span class="logo-text">{{ t('app.title') }}</span>
    </div>
    <el-menu :default-active="activePath" :collapse="collapsed" @select="handleSelect">
      <template v-for="item in menus" :key="item.path">
        <el-sub-menu v-if="item.children?.length" :index="item.path">
          <template #title>
            <el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
            <span>{{ t(item.label) }}</span>
          </template>
          <el-menu-item v-for="child in item.children" :key="child.path" :index="child.path">
            {{ t(child.label) }}
          </el-menu-item>
        </el-sub-menu>
        <el-menu-item v-else :index="item.path">
          <el-icon v-if="item.icon"><component :is="item.icon" /></el-icon>
          <span>{{ t(item.label) }}</span>
        </el-menu-item>
      </template>
    </el-menu>
  </div>
</template>

<script lang="ts" setup>
import { navigate } from 'vike/client/router';

function handleSelect(index: string) {
  navigate(index);  // 使用 Vike 的 navigate 进行客户端路由跳转
}
</script>

重要:不能使用 Element Plus 的 router prop,因为它依赖 Vue Router。Vike 项目中应使用 @select 事件 + navigate() 手动导航。

11.4 AppHeader 组件

<!-- src/layout/AppHeader.vue -->
<template>
  <div class="app-header">
    <div class="header-left">
      <el-icon class="toggle-btn" @click="emit('toggle-sidebar')">
        <Fold v-if="!collapsed" /><Expand v-else />
      </el-icon>
      <el-breadcrumb separator="/">
        <el-breadcrumb-item v-for="item in breadcrumbs" :key="item.label" :to="item.path">
          {{ item.label }}
        </el-breadcrumb-item>
      </el-breadcrumb>
    </div>
    <div class="header-right">
      <!-- 页面自定义按钮区域 -->
      <el-button v-for="action in headerActions" :key="action.key" :type="action.type" @click="action.handler">
        {{ action.label }}
      </el-button>
      <!-- 用户信息 -->
      <el-dropdown>
        <span class="user-info">
          <el-icon><User /></el-icon> {{ user?.name || '未登录' }}
        </span>
      </el-dropdown>
    </div>
  </div>
</template>

12. Element Plus 集成(SSR 兼容)

在 SSR 项目中集成 Element Plus 需要解决三个问题:

12.1 CSS 加载问题

问题unplugin-vue-components 默认会自动导入组件对应的 CSS 文件,但 SSR 时 Node.js 无法处理 .css 文件。

解决方案

// vite.config.ts
Components({
  resolvers: [ElementPlusResolver({ importStyle: false })],  // 禁用自动导入样式
}),
<!-- +Layout.vue 中手动全量引入 -->
<script setup>
import 'element-plus/dist/index.css';
</script>

12.2 ID 注入问题

问题ElementPlusError: [IdInjection] Looks like you are using server rendering, you must provide a id provider

解决方案

// +onCreateApp.ts
import { ID_INJECTION_KEY, ZINDEX_INJECTION_KEY } from 'element-plus';

app.provide(ID_INJECTION_KEY, { prefix: 1024, current: 0 });
app.provide(ZINDEX_INJECTION_KEY, { current: 0 });

12.3 Locale 国际化

<!-- +Layout.vue -->
<template>
  <el-config-provider :locale="elementLocale">
    <!-- ... -->
  </el-config-provider>
</template>

<script setup>
import zhCN from 'element-plus/es/locale/lang/zh-cn';
import enUS from 'element-plus/es/locale/lang/en';

const elementLocale = computed(() => locale.value === 'en-US' ? enUS : zhCN);
</script>

13. 权限系统

13.1 权限 URL 统一管理

所有需要权限验证的 API URL 统一在 src/constants/permissionApis.ts 中管理:

// src/constants/permissionApis.ts
export const PERMISSION_APIS = {
  /** 查询权限列表 */
  LIST: 'GET /api/v1/permissions',
  /** 新增权限 */
  CREATE: 'POST /api/v1/permissions',
  /** 编辑权限 */
  UPDATE: 'PUT /api/v1/permissions',
  /** 删除权限 */
  DELETE: 'DELETE /api/v1/permissions',
} as const;

注意+config.ts 文件由 vike 的 esbuild 插件编译,不支持 Vite 路径别名(#constants/...)。因此在 +config.ts 中必须使用相对路径导入常量,而在 +Page.vue 中可正常使用 # 别名。

各页面 +config.ts 中按需声明所需的权限 URL:

// src/pages/permission/+config.ts(列表页 — 需要所有操作权限)
import { PERMISSION_APIS } from '../../constants/permissionApis';

export default {
  title: '权限列表',
  permissionUrls: [
    PERMISSION_APIS.LIST,
    PERMISSION_APIS.CREATE,
    PERMISSION_APIS.UPDATE,
    PERMISSION_APIS.DELETE,
  ],
};
// src/pages/permission/add/+config.ts(新增页 — 只需 CREATE 权限)
import { PERMISSION_APIS } from '../../../constants/permissionApis';

export default {
  title: '新增权限',
  permissionUrls: [PERMISSION_APIS.CREATE],
};

13.2 页面级权限 — +guard.ts

流程:

  1. 页面在 +config.ts 中声明 permissionUrls(引用统一常量)
  2. +guard.ts 读取该配置,在 SSR 阶段调用后端 POST /api/v1/permission/check
  3. 后端返回 { allowed: true/false, urlPermissions: { [url]: boolean } }
  4. allowed: falsethrow render(403),整页渲染错误页(如新增权限页无 CREATE 权限)
  5. allowed: true 时将 urlPermissions 写入 pageContext.permissionResult,通过 passToClient 传到客户端

13.3 按钮级权限 — usePermission

通过 usePermission() 组合式函数在组件中检查单个 URL 的权限,控制按钮 disabled 状态:

// src/composables/usePermission.ts
export function usePermission() {
  const pageContext = usePageContext();
  const permissionResult = computed(() => (pageContext as any).permissionResult || {});

  function hasPermission(url: string): boolean {
    return permissionResult.value?.urlPermissions?.[url] ?? true;
  }

  return { permissionResult, hasPermission };
}

列表页使用示例(控制添加/编辑/删除按钮):

<script setup>
import { usePermission } from '#composables/usePermission';
import { PERMISSION_APIS } from '#constants/permissionApis';

const { hasPermission } = usePermission();
const canCreate = hasPermission(PERMISSION_APIS.CREATE);
const canUpdate = hasPermission(PERMISSION_APIS.UPDATE);
const canDelete = hasPermission(PERMISSION_APIS.DELETE);
</script>

<template>
  <el-button type="primary" :disabled="!canCreate" @click="handleAdd">新增</el-button>
  <!-- 表格操作列 -->
  <el-button :disabled="!canUpdate" @click="handleEdit(row)">编辑</el-button>
  <el-button :disabled="!canDelete" @click="handleDelete(row)">删除</el-button>
</template>

编辑页使用示例(通过 canSubmit prop 控制表单保存按钮):

<PermissionForm
  :initial-data="detail"
  :is-sending="isSending"
  :can-submit="canUpdate"
  @submit="submit"
  @cancel="goBack"
/>

PermissionForm.vue 中保存按钮根据 canSubmit 属性禁用:

<el-button type="primary" :loading="isSending" :disabled="canSubmit === false" @click="submit">
  {{ t('common.save') }}
</el-button>

13.4 Mock 权限验证(server/server.ts)

开发阶段通过 Mock 接口模拟权限检查:

// 模拟无权限的 URL 列表
const DENIED_URLS = new Set([
  'POST /api/v1/permissions',   // 新增权限
  'PUT /api/v1/permissions',    // 编辑权限
]);

// pagePath + URL 命中时整页拒绝(403)
const PAGE_BLOCKED_RULES = [
  { pathPattern: /^\/permission\/add$/, url: 'POST /api/v1/permissions' },
];

app.post('/api/v1/permission/check', (req, res) => {
  const { urls = [], pagePath = '' } = req.body;
  const urlPermissions = {};
  urls.forEach((url) => { urlPermissions[url] = !DENIED_URLS.has(url); });

  // 命中 PAGE_BLOCKED_RULES 则整页拒绝
  const allowed = !PAGE_BLOCKED_RULES.some(
    (rule) => rule.pathPattern.test(pagePath) && urls.includes(rule.url) && DENIED_URLS.has(rule.url),
  );

  res.json({ code: 0, data: { allowed, urlPermissions } });
});
  • DENIED_URLS — 控制哪些 URL 返回无权限(按钮 disabled)
  • PAGE_BLOCKED_RULES — 当特定页面路径命中被拒绝的 URL 时,整页返回 403

13.5 权限流程图

用户请求页面
    │
    ▼
+guard.ts 读取 +config.ts 中的 permissionUrls(引用 PERMISSION_APIS 常量)
    │
    ├── 未配置 → 直接放行
    │
    └── 已配置 → SSR 调用 POST /api/v1/permission/check { urls, pagePath }
                    │
                    ├── allowed: false → throw render(403) → 渲染 _error/+Page.vue
                    │   (如: /permission/add 页面无 CREATE 权限 → 整页 403)
                    │
                    └── allowed: true → permissionResult 存入 pageContext
                            │
                            └── 组件中通过 usePermission().hasPermission(url) 判断
                                    │
                                    ├── true  → 按钮正常可用
                                    └── false → 按钮 disabled
                                        (如: 编辑页无 UPDATE 权限 → 保存按钮禁用)

14. 路由与导航

14.1 文件系统路由

Vike 根据 src/pages/ 目录结构自动生成路由:

目录结构 路由路径 说明
pages/index/+Page.vue / 首页
pages/permission/+Page.vue /permission 权限列表
pages/permission/add/+Page.vue /permission/add 新增权限
pages/permission/@id/edit/+Page.vue /permission/:id/edit 编辑权限(动态路由)
pages/_error/+Page.vue 错误页面 401/403/404/500

@id 是 Vike 的动态路由语法,等效于 Vue Router 的 :id。通过 pageContext.routeParams.id 获取。

14.2 客户端导航

Vike 提供 navigate 函数实现客户端路由跳转(无刷新):

import { navigate } from 'vike/client/router';

// 跳转到指定页面
navigate('/permission');

// 跳转并替换历史记录
navigate('/permission', { overwriteLastHistoryEntry: true });

+config.ts 中已设置 clientRouting: true(由 vike-vue 默认配置),启用客户端路由。


15. 业务页面示例

15.1 Dashboard 首页

文件src/pages/index/

文件 作用
+config.ts 配置标题 '首页'
+data.ts SSR 调用 /api/v1/dashboard/stats 预取统计数据
+Page.vue 通过 useData() 获取数据,展示统计卡片和操作日志表格
<script setup>
const data = useData<Data>();  // SSR 预取的数据,无需 onMounted 加载
const statCards = computed(() => [
  { key: 'total', label: t('dashboard.totalPermissions'), value: data.totalPermissions },
  // ...
]);
</script>

15.2 权限列表页

文件src/pages/permission/

文件 作用
+config.ts 配置标题 + permissionUrls(启用权限验证)
+data.ts SSR 预取第一页权限列表
+Page.vue 展示列表 + 搜索 + 分页

SSR + CSR 混合:首页数据通过 SSR 预取,后续翻页/搜索通过客户端 Alova 调用。

15.3 新增权限页

文件src/pages/permission/add/

文件 作用
+config.ts 配置标题 + permissionUrls
+data.ts 空 data 文件(阻止继承父级的 +data.ts)
+Page.vue 使用 PermissionForm 组件

关键:必须创建空的 +data.ts,否则会继承 permission/+data.ts 的数据加载逻辑,导致不需要的 API 调用甚至报错。

15.4 编辑权限页

文件src/pages/permission/@id/edit/

与新增页类似,额外通过 pageContext.routeParams.id 获取路由参数,在 onMounted 中加载详情数据:

<script setup>
const pageContext = usePageContext();
const routeParams = pageContext.routeParams as { id: string };

onMounted(() => {
  fetchDetail(routeParams.id);
});
</script>

15.5 可复用组件 — PermissionForm

src/viewComponents/permission/PermissionForm.vue 同时服务于新增和编辑页面:

<script setup>
const props = defineProps<{
  initialData?: Record<string, any>;  // 编辑时传入已有数据
  isSending?: boolean;                // 提交中状态
  canSubmit?: boolean;                // 是否有提交权限(false 时禁用保存按钮)
}>();

const emit = defineEmits<{
  submit: [data: Record<string, any>];
  cancel: [];
}>();

// 表单验证规则
const rules: FormRules = {
  name: [{ required: true, message: '请输入权限名称', trigger: 'blur' }],
  code: [{ required: true, message: '请输入权限编码', trigger: 'blur' }],
  type: [{ required: true, message: '请选择权限类型', trigger: 'change' }],
};
</script>

16. SSR 与 CSR 策略

场景 策略 实现方式
首屏数据 SSR +data.tsuseData()
权限验证 SSR +guard.tsthrow render(403)
翻页/搜索 CSR 组件内直接使用客户端 Alova
表单提交 CSR 组件内调用 API 后 navigate()
页面跳转 CSR navigate() 客户端路由
初始页面加载 SSR Express → renderPage() → HTML

数据流:

SSR 阶段:
  Express → renderPage() → +guard.ts → +data.ts → +Layout.vue + +Page.vue → HTML

CSR 阶段 (客户端路由):
  navigate() → +guard.ts (client) → +data.ts → 组件更新

17. 关键踩坑与解决方案

17.1 Express 5 路由语法变更

问题app.get('*', ...) 报错 Missing parameter name

原因:Express 5 使用新版 path-to-regexp,不再支持裸通配符

解决:改为命名通配符 app.get('/{*path}', ...)

17.2 Element Plus CSS SSR 加载失败

问题TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".css"

原因:Node.js SSR 环境无法处理 CSS 文件

解决

  • ElementPlusResolver({ importStyle: false }) 禁用自动导入样式
  • +Layout.vueimport 'element-plus/dist/index.css'(Vite 会正确处理)

17.3 Element Plus SSR ID/ZIndex 注入

问题:Hydration 失败,控制台报 IdInjectionZIndexInjection 错误

解决:在 +onCreateApp.tsapp.provide(ID_INJECTION_KEY, ...)app.provide(ZINDEX_INJECTION_KEY, ...)

17.4 服务端 API 调用使用相对 URL

问题+data.ts+guard.ts 中使用 /api/v1/xxx 相对路径在 SSR 中无法工作

原因:Node.js 中没有浏览器的 location.origin,相对 URL 无法解析

解决:SSR 中使用绝对 URL http://localhost:${process.env.PORT || 3000}/api/v1

17.5 +data.ts 的继承问题

问题/permission/add 页面继承了 /permission/+data.ts 的数据加载,导致不必要的 API 调用

原因:Vike 的 +data.ts 会沿目录树向上继承

解决:在子目录创建空的 +data.ts

export type Data = Record<string, never>;
export async function data() { return {}; }

17.6 El-Menu 的 router prop 不兼容 Vike

问题:侧边栏菜单点击无反应或报错

原因:Element Plus 的 el-menu router prop 依赖 Vue Router,Vike 项目不使用 Vue Router

解决:移除 router prop,使用 @select 事件 + navigate():

<el-menu @select="handleSelect">
  <!-- ... -->
</el-menu>

<script setup>
import { navigate } from 'vike/client/router';
function handleSelect(index: string) {
  navigate(index);
}
</script>

17.7 process.env 在客户端不可用

问题ReferenceError: process is not defined

原因+guard.ts 在客户端也会执行,但 process.env 仅在 Node.js 中可用

解决:将 process.env 访问放在 if (typeof window === 'undefined') 分支内


18. 开发与构建命令

# 开发(启动 Express + Vite HMR)
npm run dev

# 构建(生成 dist/client + dist/server)
npm run build

# 生产预览
npm run preview

# 代码检查
npm run lint

# 自动修复
npm run fix

开发环境tsx server/server.ts → Express 启动 → createDevMiddleware 注入 Vite HMR → 访问 http://localhost:3000

生产构建vike build → 输出 dist/client(静态资源)+ dist/server(SSR Bundle)


19. 生产部署

19.1 构建产物结构

执行 npm run build(即 vike build)后生成 dist/ 目录:

dist/
├── assets.json                    # 资源映射文件(Vike 内部使用)
├── client/                        # 静态资源(浏览器端)
│   └── assets/
│       ├── chunks/                # JS 代码分割块
│       ├── entries/               # 各页面入口 JS
│       └── static/               # CSS 文件
└── server/                        # SSR 服务端代码
    ├── entry.mjs                  # SSR 入口(Vike renderPage 用)
    ├── entries/                   # 各页面的 SSR 渲染逻辑
    ├── chunks/                    # 服务端公共模块
    └── package.json               # { "type": "module" }

19.2 部署方式

本项目使用 Express 作为生产服务器server/server.ts 同时处理静态文件托管和 SSR 渲染。部署步骤:

1. 构建

npm run build

2. 部署所需文件

将以下文件/目录上传到服务器:

dist/                # 构建产物(client + server)
server/server.ts     # Express 服务器入口
package.json         # 依赖声明
node_modules/        # 或在服务器上 npm install

3. 启动服务

# 方式一:直接用 tsx 运行 TypeScript(需安装 tsx)
cross-env NODE_ENV=production tsx server/server.ts

# 方式二:用 PM2 管理进程(推荐)
pm2 start "cross-env NODE_ENV=production tsx server/server.ts" --name vike-admin

# 自定义端口
cross-env NODE_ENV=production PORT=8080 tsx server/server.ts

运行原理: server/server.ts 中根据 NODE_ENV 自动切换行为:

if (isProd) {
  // 生产环境:sirv 托管 dist/client 静态文件
  const sirv = (await import('sirv')).default;
  app.use(sirv(`${root}/dist/client`));
} else {
  // 开发环境:Vite HMR 开发中间件
  const { devMiddleware } = await createDevMiddleware({ root });
  app.use(devMiddleware);
}

Vike 的 renderPage() 在生产环境会自动加载 dist/server/entry.mjs 进行 SSR 渲染。

19.3 Nginx 反向代理(可选)

如果需要通过 Nginx 暴露服务:

server {
    listen 80;
    server_name your-domain.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

19.4 Docker 部署(可选)

FROM node:20-alpine
WORKDIR /app

COPY package.json yarn.lock ./
RUN yarn install --production=false

COPY . .
RUN yarn build

ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000

CMD ["npx", "tsx", "server/server.ts"]
docker build -t vike-admin .
docker run -d -p 3000:3000 vike-admin

19.5 注意事项

事项 说明
NODE_ENV 必须设为 production,否则会尝试启动 Vite 开发中间件
Mock API 生产环境应替换为真实后端 API 代理,移除 Mock 路由
tsx 生产环境仍需 tsx 来运行 TypeScript 的 server.ts,也可预编译为 JS
端口 默认 3000,可通过 PORT 环境变量修改
dist/ 路径 server.ts 通过 __dirname + '/.. 定位 dist,部署时保持目录相对关系

本文档对应项目版本:2026-02-12 · Vike 0.4.252 · Vue 3.5 · Element Plus 2.9 · Express 5.2

Vue3文本差异对比器实现方案

Vue3文本差异对比器实现方案

本文将介绍本项目中 文本差异对比器 (Text Diff Checker) 工具的技术实现细节。该工具基于 Vue 3 框架开发,核心对比逻辑采用原生的 JavaScript 实现,通过动态加载的方式与 Vue 组件进行交互。

在线工具网址:see-tool.com/diff-checke…
工具截图:
在这里插入图片描述

1. 架构设计

为了保证核心算法的独立性和复用性,我们将 Diff 算法逻辑封装在 public/js/diff-checker.js 中,而 Vue 组件 pages/diff-checker.vue 仅负责 UI 交互和数据展示。

  • 数据层 (Core JS): 负责文本的预处理、Diff 算法计算、HTML 渲染字符串生成以及统计信息计算。
  • 视图层 (Vue): 负责用户输入、选项配置、调用核心方法并展示结果。

2. 核心算法实现 (diff-checker.js)

核心逻辑是一个基于 最长公共子序列 (LCS, Longest Common Subsequence) 的 Diff 算法。

2.1 文本预处理与并在

根据用户选择的“对比模式”,我们将输入文本分割成不同的单元:

  • 行模式 (Line): 使用 split('\n') 按换行符分割。
  • 词模式 (Word): 使用 split(/\s+/) 按空白字符分割。
  • 字符模式 (Char): 使用 split('') 逐字符分割。

同时,根据配置选项处理“忽略空格”和“忽略大小写”:

if (ignoreWhitespace) {
    processedText1 = processedText1.replace(/\s+/g, ' ').trim();
    processedText2 = processedText2.replace(/\s+/g, ' ').trim();
}
// 忽略大小写则统一转为小写

2.2 LCS 算法与回溯

使用动态规划构建 DP 表,计算最长公共子序列的长度:

// DP 表构建
for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
        if (arr1[i - 1] === arr2[j - 1]) {
            dp[i][j] = dp[i - 1][j - 1] + 1;
        } else {
            dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
        }
    }
}

构建完成后,通过回溯 (Backtrack) 找出具体的 LCS 路径。

2.3 构建 Diff 结果

根据 LCS 路径,遍历原始序列,确定哪些部分是“新增 (added)”、“删除 (removed)”或“未变 (unchanged)”。

  • 如果当前元素在 LCS 中,标记为 unchanged
  • 如果原序列中有但 LCS 中没有,标记为 removed
  • 如果新序列中有但 LCS 中没有,标记为 added

2.4 结果渲染

为了提高性能,Diff 的结果直接由 JS 生成 HTML 字符串,而不是在 Vue 中使用 v-for 渲染成千上万个 DOM 节点。生成的 HTML 包含了行号、差异标识(+/-)以及高亮样式类。

/* 生成的 HTML 结构示例 */
<div class="diff-line diff-line-removed">
  <span class="diff-line-number">1</span>
  <span class="diff-line-number"></span>
  <span class="mr-2">-</span>
  Content
</div>

3. Vue 组件实现 (diff-checker.vue)

3.1 动态加载脚本

Vue 组件在挂载或需要使用时,通过创建 <script> 标签动态加载核心 JS 文件。为了防止重复加载,我们通过检查 window.DiffChecker 是否存在来判断。

const loadDiffCheckerScript = () => {
  if (window.DiffChecker) return Promise.resolve();
  // 创建 script 标签加载 /js/diff-checker.js
  // 监听 onload 和 onerror 事件
}

3.2 调用对比

当用户点击“开始对比”时,组件收集 leftTextrightText 以及 compareModeignoreWhitespace 等选项,调用核心对象的 compare 方法:

const result = window.DiffChecker.compare(leftText.value, rightText.value, compareMode.value, {
  ignoreWhitespace: ignoreWhitespace.value,
  ignoreCase: ignoreCase.value,
  showLineNumbers: showLineNumbers.value
})

3.3 结果展示

核心方法返回的 result 对象中包含了 diffHtml(差异内容的 HTML)和 statisticsHtml(统计信息的 HTML)。Vue 组件直接使用 v-html 指令将其渲染到页面上:

<div v-if="statisticsHtml" v-html="statisticsHtml"></div>
<div ref="diffOutput" v-html="diffOutputHtml"></div>

通过这种 Vue 处理交互 + 原生 JS 处理计算密集任务的分离模式,我们既保持了前端框架的开发效率,又保证了对比功能的性能与灵活性。

Vue3 组件通信全解析

组件通信是 Vue 开发中绕不开的核心知识点,尤其是 Vue3 组合式 API 普及后,通信方式相比 Vue2 有了不少变化和优化。本文将抛开 TypeScript,用最通俗易懂的方式,带你梳理 Vue3 中所有常用的组件通信方式,从基础的父子通信到复杂的跨层级通信,每一种都配实战示例,新手也能轻松上手。

一、父子组件通信(最基础也最常用)

父子组件通信是日常开发中使用频率最高的场景,Vue3 为这种场景提供了清晰且高效的解决方案。

1. 父传子:Props

Props 是父组件向子组件传递数据的官方标准方式,子组件通过定义 props 接收父组件传递的值。

父组件(Parent.vue)

<template>
  <div class="parent">
    <h3>我是父组件</h3>
    <!-- 向子组件传递数据 -->
    <Child 
      :msg="parentMsg" 
      :user-info="userInfo"
      :list="fruitList"
    />
  </div>
</template>

<script setup>
// 引入子组件
import Child from './Child.vue'
import { ref, reactive } from 'vue'

// 定义要传递给子组件的数据
const parentMsg = ref('来自父组件的问候')
const userInfo = reactive({
  name: '张三',
  age: 25
})
const fruitList = ref(['苹果', '香蕉', '橙子'])
</script>

子组件(Child.vue)

<template>
  <div class="child">
    <h4>我是子组件</h4>
    <p>父组件传递的字符串:{{ msg }}</p>
    <p>父组件传递的对象:{{ userInfo.name }} - {{ userInfo.age }}岁</p>
    <p>父组件传递的数组:{{ list.join('、') }}</p>
  </div>
</template>

<script setup>
// 定义props接收父组件数据
const props = defineProps({
  // 字符串类型
  msg: {
    type: String,
    default: '默认值'
  },
  // 对象类型
  userInfo: {
    type: Object,
    default: () => ({}) // 对象/数组默认值必须用函数返回
  },
  // 数组类型
  list: {
    type: Array,
    default: () => []
  }
})

// 在脚本中使用props(组合式API中可直接用props.xxx)
console.log(props.msg)
</script>

2. 子传父:自定义事件(Emits)

子组件通过触发自定义事件,将数据传递给父组件,父组件通过监听事件接收数据。

子组件(Child.vue)

<template>
  <div class="child">
    <h4>我是子组件</h4>
    <button @click="sendToParent">向父组件传递数据</button>
  </div>
</template>

<script setup>
// 声明要触发的自定义事件(可选,但推荐)
const emit = defineEmits(['childMsg', 'updateInfo'])

const sendToParent = () => {
  // 触发事件并传递数据(第一个参数是事件名,后续是要传递的数据)
  emit('childMsg', '来自子组件的消息')
  emit('updateInfo', {
    name: '李四',
    age: 30
  })
}
</script>

父组件(Parent.vue)

<template>
  <div class="parent">
    <h3>我是父组件</h3>
    <!-- 监听子组件的自定义事件 -->
    <Child 
      @childMsg="handleChildMsg"
      @updateInfo="handleUpdateInfo"
    />
    <p>子组件传递的消息:{{ childMsg }}</p>
    <p>子组件更新的信息:{{ newUserInfo.name }} - {{ newUserInfo.age }}岁</p>
  </div>
</template>

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

const childMsg = ref('')
const newUserInfo = reactive({
  name: '',
  age: 0
})

// 处理子组件的消息
const handleChildMsg = (msg) => {
  childMsg.value = msg
}

// 处理子组件的信息更新
const handleUpdateInfo = (info) => {
  newUserInfo.name = info.name
  newUserInfo.age = info.age
}
</script>

二、跨层级组件通信

当组件嵌套层级较深(比如爷孙组件、跨多级组件),使用 props + emits 会非常繁琐,这时需要更高效的跨层级通信方案。

1. provide /inject(依赖注入)

provide 用于父组件(或祖先组件)提供数据,inject 用于子孙组件注入数据,支持任意层级的组件通信。

祖先组件(GrandParent.vue)

<template>
  <div class="grand-parent">
    <h3>我是祖先组件</h3>
    <Parent />
  </div>
</template>

<script setup>
import Parent from './Parent.vue'
import { ref, reactive, provide } from 'vue'

// 提供基本类型数据
const theme = ref('dark')
provide('theme', theme)

// 提供对象类型数据
const globalConfig = reactive({
  fontSize: '16px',
  color: '#333'
})
provide('globalConfig', globalConfig)

// 提供方法(支持双向通信)
provide('changeTheme', (newTheme) => {
  theme.value = newTheme
})
</script>

孙组件(Child.vue)

<template>
  <div class="child">
    <h4>我是孙组件</h4>
    <p>祖先组件提供的主题:{{ theme }}</p>
    <p>全局配置:{{ globalConfig.fontSize }} / {{ globalConfig.color }}</p>
    <button @click="changeTheme('light')">切换为亮色主题</button>
  </div>
</template>

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

// 注入祖先组件提供的数据(第二个参数是默认值)
const theme = inject('theme', 'light')
const globalConfig = inject('globalConfig', {})
const changeTheme = inject('changeTheme', () => {})
</script>

2. Vuex/Pinia(全局状态管理)

当多个不相关的组件需要共享状态,或者项目规模较大时,推荐使用官方的状态管理库,Vue3 中更推荐 Pinia(比 Vuex 更简洁)。

示例:Pinia 实现全局通信

1. 安装 Pinia

npm install pinia

2. 创建 Pinia 实例(main.js)

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

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

3. 创建 Store(stores/user.js)

import { defineStore } from 'pinia'

// 定义并导出store
export const useUserStore = defineStore('user', {
  // 状态
  state: () => ({
    username: '默认用户名',
    token: ''
  }),
  // 计算属性
  getters: {
    // 处理用户名格式
    formatUsername: (state) => {
      return `【${state.username}】`
    }
  },
  // 方法(修改状态)
  actions: {
    // 更新用户信息
    updateUserInfo(newInfo) {
      this.username = newInfo.username
      this.token = newInfo.token
    },
    // 清空用户信息
    clearUserInfo() {
      this.username = ''
      this.token = ''
    }
  }
})

4. 组件中使用 Store

<template>
  <div>
    <h3>全局状态管理示例</h3>
    <p>用户名:{{ userStore.formatUsername }}</p>
    <p>Token:{{ userStore.token }}</p>
    <button @click="updateUser">更新用户信息</button>
    <button @click="clearUser">清空用户信息</button>
  </div>
</template>

<script setup>
import { useUserStore } from '@/stores/user'

// 获取store实例
const userStore = useUserStore()

// 更新用户信息
const updateUser = () => {
  userStore.updateUserInfo({
    username: '掘金用户',
    token: '123456789'
  })
}

// 清空用户信息
const clearUser = () => {
  userStore.clearUserInfo()
}
</script>

三、其他常用通信方式

1. v-model 双向绑定

Vue3 中 v-model 支持自定义绑定属性,可实现父子组件的双向数据绑定,简化子传父的操作。

子组件(Child.vue)

<template>
  <div class="child">
    <input 
      type="text" 
      :value="modelValue" 
      @input="emit('update:modelValue', $event.target.value)"
    />
    <!-- 支持多个v-model -->
    <input 
      type="number" 
      :value="age" 
      @input="emit('update:age', $event.target.value)"
    />
  </div>
</template>

<script setup>
defineProps(['modelValue', 'age'])
const emit = defineEmits(['update:modelValue', 'update:age'])
</script>

父组件(Parent.vue)

<template>
  <div class="parent">
    <h3>父组件</h3>
    <Child 
      v-model="username"
      v-model:age="userAge"
    />
    <p>用户名:{{ username }}</p>
    <p>年龄:{{ userAge }}</p>
  </div>
</template>

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

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

2. 事件总线(mitt)

Vue3 移除了 Vue2 的 $on/$emit 事件总线,可使用第三方库 mitt 实现任意组件间的通信。

1. 安装 mitt

npm install mitt

2. 创建事件总线(utils/bus.js)

import mitt from 'mitt'
const bus = mitt()
export default bus

3. 组件 A 发送事件

<template>
  <div>
    <button @click="sendMsg">发送消息到组件B</button>
  </div>
</template>

<script setup>
import bus from '@/utils/bus'

const sendMsg = () => {
  // 触发自定义事件并传递数据
  bus.emit('msgEvent', '来自组件A的消息')
}
</script>

4. 组件 B 接收事件

<template>
  <div>
    <p>组件A传递的消息:{{ msg }}</p>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import bus from '@/utils/bus'

const msg = ref('')

// 挂载时监听事件
onMounted(() => {
  bus.on('msgEvent', (data) => {
    msg.value = data
  })
})

// 卸载时移除监听(避免内存泄漏)
onUnmounted(() => {
  bus.off('msgEvent')
})
</script>

四、通信方式选型建议

表格

通信场景 推荐方式
父传子 Props
子传父 自定义事件(Emits)/v-model
爷孙 / 跨层级 provide / inject
全局共享状态 Pinia
任意组件临时通信 mitt 事件总线

总结

  1. Vue3 中父子组件通信优先使用 Props + Emits,v-model 可简化双向绑定场景;
  2. 跨层级通信推荐 provide / inject,全局状态管理首选 Pinia
  3. 临时的任意组件通信可使用 mitt 事件总线,注意及时移除监听避免内存泄漏。

组件通信的核心是 “数据流向清晰”,无论选择哪种方式,都要保证数据的传递路径可追溯,避免滥用全局通信导致代码维护困难。希望本文能帮助你彻底掌握 Vue3 组件通信,少走弯路~

Unaipp 使用 wot UI 实现一个带数字键盘的密码输入框弹窗

最近项目里有个支付输入密码的需求,所以在这之前都是使用一个简单的输入框实现的,但是这样体验不太好。所以,这次就改成了弹窗,尝试达到类似支付宝的弹窗输入密码的形式。

前言

在 Wot UI 中是有密码输入框(wd-password-input)和数字键盘(wd-number-keyboard)两个组件的,但是在文档示例中你会发现,数字键盘是以弹窗的形式覆盖在界面顶层的。如果我们直接使用这个组件,就会出现弹窗盖在弹窗上的奇怪问题。

所以最好的方式,是改写数字键盘组件的全局样式,再将其和密码输入框组合起来,放到新的弹窗中。

防止数字键盘下沉

打开控制台管擦,我们会发现数字键盘实际上也是一个弹窗,而内部会通关组件参数 v-model:visible 进行更新。

因此,首先我们要设置 :hide-on-click-outside="false",防止数字键盘因为点击蒙版意外关闭。

<wd-keyboard
  class="pass-keyboard"
  :hide-on-click-outside="false"
  v-model:visible="showKeyboard"
  mode="custom"
  :close-text="confirmText"
  @input="onPassInput"
  @close="handlePassClose"
  @delete="onPassDelete"
></wd-keyboard>

然后我们会发现一旦点击左下角的键盘按钮,数字键盘就会被收起来,只有点击密码输入框才能弹出。显然这不是我们想要的效果,最终效果应该是数字输入框和密码输入框固定的一直显示。通过观察,弹窗的显示是通过 display 和过渡动画实现的,那么最有效的方式就是样式覆盖了

.pass-keyboard {
  :deep(.wd-popup) {
    position: relative;
    transition: none;
    display: block !important;
  }
}

我们还需要禁止初始化时,弹窗淡入淡出的动画,防止数字键盘出现延迟显示,闪烁的问题

.pass-keyboard {
  :deep(.wd-slide-up-enter),
  :deep(.wd-slide-up-leave-to) {
    transform: none;
  }
}

到这里,我们就能够让数字键盘固定到界面中,作为一个普通的组件使用了。

在悬浮面板中组合 密码输入框 和 数字键盘

现在,我们把 密码输入框 和 数字键盘同时放进 Wot IU 的底部弹窗组件(wd-popup)中,会发现两个组件没有联动起来,所以还需要配合密码输入框的焦点事件, 让数字键盘一直显示。

...
<wd-password-input
  v-model="payPassword"
  :length="maxLength"
  :gutter="10"
  :mask="true"
  :focused="showKeyboard"
  @focus="handlePasswordFocus"
/>
<wd-keyboard
  class="pass-keyboard"
  :hide-on-click-outside="false"
  v-model:visible="showKeyboard"
></wd-keyboard>

...

// 处理密码框聚焦 
function handlePasswordFocus() { 
  // 强制显示键盘
  showKeyboard.value = true; 
}

这样我们就基本完成在不弹出系统输入法的情况下,使用数字虚拟键盘输入框密码的操作了。但是到这里你会发现支付宝的密码弹窗都是自动完成后关闭的,现在我们实现的功能,不能做到自动未完成和关闭弹窗。

不过,我们可以通过自定义数字键盘,增加提交按钮,并监听点击事件实现这个操作。在 @close 我们将关闭动作传递到父组件,让父组件直接关闭最外层的弹窗就可以了。

<wd-keyboard
  class="pass-keyboard"
  :hide-on-click-outside="false"
  v-model:visible="showKeyboard"
  mode="custom"
  :close-text="confirmText"
  @input="onPassInput"
  @close="handlePassClose"
  @delete="onPassDelete"
></wd-keyboard>

// 处理关闭 - 点击确定按钮后直接关闭弹窗
function handlePassClose() {
  if (payPassword.value.length < 6) return;

  // 触发输入完成事件
  emit("input-complete", payPassword.value);
}

如果需要自动完成,那么就直接监听密码输入框的输入位数,手动调用上面的关闭事件就可以了

// 监听密码变化
watch(payPassword, (newVal) => {
  // 密码输入完成后的处理
  if (newVal.length === props.maxLength) {
    // 如果启用自动关闭
    if (props.autoConfirm) {
      // 延迟关闭,让用户能看到输入完成的效果
      setTimeout(() => {
        handlePassClose();
      }, 300);
    }
  }
});

完整实例

最后,我把这个功能封装成了一个组件,只需要在项目中引用这个组件,并且根据输入完成事件做进一步处理就行了。唯一不足的是,当密码输入错误时,不能像支付宝一样停留在弹窗输入层,只能退其次统一关闭后处理接口请求传参。

<template>
  <view>
    <wd-popup v-model="showPasswordPopup" position="bottom" round :close-on-click-overlay="true">
      <view class="pay-pass-popup">
        <div class="pass-top">
          <view class="popup-title"> {{ title }} </view>

          <!-- 密码长度提示 -->
          <view v-if="showLengthHint" class="password-length-hint">
            {{ payPassword.length }}/{{ maxLength }}
          </view>

          <!-- 密码输入框 -->
          <wd-password-input
            v-model="payPassword"
            :length="maxLength"
            :gutter="10"
            :mask="mask"
            :focused="showKeyboard"
            @focus="handlePasswordFocus"
          />
        </div>

        <wd-keyboard
          class="pass-keyboard"
          :hide-on-click-outside="false"
          v-model:visible="showKeyboard"
          mode="custom"
          :close-text="confirmText"
          @input="onPassInput"
          @close="handlePassClose"
          @delete="onPassDelete"
        ></wd-keyboard>
      </view>
    </wd-popup>
  </view>
</template>

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

// 定义Props
interface Props {
  // 弹窗标题
  title?: string;
  // 确认按钮文本
  confirmText?: string;
  // 是否显示弹窗
  visible?: boolean;
  // 密码最大长度
  maxLength?: number;
  // 是否显示密码长度提示
  showLengthHint?: boolean;
  // 是否隐藏密码(显示为圆点)
  mask?: boolean;
  // 是否自动关闭(输入完成后)
  autoConfirm?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  title: "请输入支付密码",
  confirmText: "确定",
  visible: true,
  maxLength: 6,
  showLengthHint: false,
  mask: true,
  autoConfirm: false,
});

// 定义Emits
const emit = defineEmits<{
  "input-complete": [value: string];
}>();

const payPassword = ref<string>("");
const showPasswordPopup = defineModel("visible", { default: false });
// 显示键盘
const showKeyboard = ref<boolean>(true);

// 监听密码变化
watch(payPassword, (newVal) => {
  //   console.log("当前密码:", newVal);

  // 密码输入完成后的处理
  if (newVal.length === props.maxLength) {
    // 如果启用自动关闭
    if (props.autoConfirm) {
      // 延迟关闭,让用户能看到输入完成的效果
      setTimeout(() => {
        handlePassClose();
      }, 300);
    }
  }
});

// 键盘输入处理 - 只接受数字
function onPassInput(val: string) {
  // 只接受数字输入
  if (!/^\d$/.test(val)) {
    return;
  }

  // 如果已经输入到最大长度,不再接受输入
  if (payPassword.value.length >= props.maxLength) {
    return;
  }

  // 添加数字到密码
  payPassword.value += val;
}

// 删除处理
function onPassDelete() {
  if (payPassword.value.length > 0) {
    // 删除最后一位
    payPassword.value = payPassword.value.slice(0, -1);
  }
}

// 处理密码框聚焦
function handlePasswordFocus() {
  // 强制显示键盘
  showKeyboard.value = true;
}

// 处理关闭 - 点击确定按钮后直接关闭弹窗
function handlePassClose() {
  if (payPassword.value.length < 6) return;

  // 触发输入完成事件
  emit("input-complete", payPassword.value);

  // 关闭密码输入弹窗
  //   close();
}

// 清空密码
function clearPassword() {
  payPassword.value = "";
}

// 打开弹窗
function open() {
  clearPassword();
  showPasswordPopup.value = true;
}

// 关闭弹窗
function close() {
  showPasswordPopup.value = false;
  clearPassword();
}

// 获取当前密码
function getPassword(): string {
  return payPassword.value;
}

// 暴露方法给父组件
defineExpose({
  open,
  close,
  clearPassword,
  getPassword,
});
</script>

<style lang="scss" scoped>
.pay-pass-popup {
  justify-content: center;
}

.pass-top {
  background-color: #ffffff;
  padding: 40rpx;
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.popup-title {
  font-size: 32rpx;
  font-weight: bold;
  text-align: center;
  color: #333;
}

.password-length-hint {
  font-size: 24rpx;
  text-align: center;
  color: #999;
  margin-top: -10rpx;
}

.pass-keyboard {
  padding: 40rpx 0;
  background-color: #f5f5f5;

  :deep(.wd-popup) {
    position: relative;
    transition: none;
    display: block !important;
  }

  :deep(.wd-key.wd-key--close) {
    background: linear-gradient(37deg, #ff3945 5%, #ff9c4a 80%);
    color: white;
    font-weight: bold;
  }

  :deep(.wd-key) {
    font-size: 32rpx;
    font-weight: 500;
  }

  :deep(.wd-key:active) {
    background-color: #e0e0e0;
  }

  :deep(.wd-key--close:active) {
    background: linear-gradient(37deg, #e6323d 5%, #e68c45 80%);
  }

  :deep(.wd-keyboard__keys) {
    padding: 0 8rpx;
  }

  :deep(.wd-slide-up-enter),
  :deep(.wd-slide-up-leave-to) {
    transform: none;
  }
}

:deep(.wd-password-input__item) {
  width: 45px;
  height: 40px;
  padding: 0;
  background: #f2f2f2;
  border-radius: 10px;
}
</style>

使用示例

<template>
  <ac-pass-popup
    ref="passPopupRef"
    v-model:visible="showPassPopup"
    :title="t('withdrawPage.请输入支付密码')"
    :confirmText="t('withdrawPage.提现')"
    @input-complete="onInputComplete"
  />
</template>

<script setup>
const passPopupRef = ref();

function onRequest(){
    // 接口处理
    ...
    passPopupRef.value.close();
}
</script>

结语

组件库虽然方便了大部分的开发场景,但是在某些情况下,仍然需要自行做类似的功能实现处理。

另外,该组件已经归档到项目 uniapp-vitesse-wot-one

Pinia 超进化!从此不需要 Axios

Pinia Colada 让 Vue 应用中的数据请求变得轻而易举。它构建于 Pinia 之上,彻底消除了数据请求带来的所有复杂度与样板代码。它具备完整的类型支持、可摇树优化,并且遵循与 Pinia 和 Vue 一致的设计理念:简单易上手、灵活可扩展、功能强大,还能实现渐进式接入。

640.png

核心特性

  • ⚡️ 自动缓存:智能客户端缓存,自带请求去重能力
  • 🗄️ 异步状态:简化异步状态管理逻辑
  • 🔌 插件系统:功能强大的插件扩展体系
  • ✨ 乐观更新:服务端响应返回前即可更新 UI
  • 💡 合理默认配置:开箱即用,同时保持全量可配置性
  • 🧩 内置插件:自动重新请求、加载延迟等功能一键启用
  • 📚 类型脚本支持:业界领先的 TypeScript 类型体验
    • 💨 极小包体积:基础核心仅约 2kb,且完全支持摇树优化
  • 📦 零外部依赖:除 Pinia 外无任何第三方依赖
  • ⚙️ 服务端渲染(SSR):原生支持服务端渲染

📝 注意:Pinia Colada 始终致力于持续改进和演进。我们非常欢迎大家针对现有功能或新功能方向提供反馈!同时也高度赞赏对文档、Issue、PR(代码合并请求)的贡献。

安装

npm install pinia @pinia/colada

安装你所需功能对应的插件:

import { createPinia } from 'pinia'  
import { PiniaColadafrom '@pinia/colada'  
  
app.use(createPinia())  
// 需在 Pinia 之后安装  
app.use(PiniaColada, {  
  // 可选配置项  
})

使用方式

Pinia Colada 的核心是 useQuery 和 useMutation 两个函数,分别用于数据查询和数据写入。以下是简单示例:

<script lang="ts" setup>  
import { useRoute } from 'vue-router'  
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'  
import { patchContact, getContactById } from '~/api/contacts'  
  
const route = useRoute()  
const queryCache = useQueryCache()  
  
// 数据查询  
const { data: contact, isPending } = useQuery({  
  // 缓存中该查询的唯一标识  
  key: () => ['contacts', route.params.id],  
  // 实际执行的查询逻辑  
  query: () => getContactById(route.params.id),  
})  
  
// 数据变更  
const { mutate: updateContact, isLoading } = useMutation({  
  // 实际执行的变更逻辑  
  mutation: patchContact,  
  async onSettled({ id }) {  
    // 使上述查询失效,触发数据重新请求  
    await queryCache.invalidateQueries({ key: ['contacts', id], exact: true })  
  },  
})  
</script>  
  
<template>  
  <section>  
    <p v-if="isPending">加载中...</p>  
    <ContactCard  
      v-else  
      :key="contact.id"  
      :contact="contact"  
      :is-updating="isLoading"  
      @update:contact="updateContact"  
    />  
  </section>  
</template>

想了解更多核心概念及使用方式,请查阅官方文档。 pinia-colada.esm.dev/

2025 Vue转React避坑指南:从核心思维到工程实践的完整迁移手册

从Vue3到React19的“被迫”成长之路

作为一名写了三年Vue3的“老前端”,上个月突然接到组长的通知:“咱们下个项目要用React,你带个头转过去。”说实话,我当时心里是抵触的——Vue的模板语法、响应式系统明明用得好好的,为什么要换?但当我真正动手写第一个React组件时,才发现这不是简单的“语法切换”,而是一场“思维革命”

记得那天晚上,我盯着React组件的useState钩子发呆:“为什么Vue的ref能自动更新,React却要手动setCount?”我试着用Vue的习惯写React代码——直接修改count的值,结果页面毫无反应,控制台还报了“状态未更新”的警告。那一刻,我才意识到:Vue的“响应式自动更新”是温柔的陷阱,而React的“手动触发+不可变数据”才是更底层的逻辑

接下来的日子里,我踩了不少坑:用0做条件渲染导致页面显示异常、忘记给列表加key导致控制台报警、用useEffect时没加依赖数组导致无限循环……但正是这些坑,让我真正理解了React的设计哲学——“一切皆函数,一切皆状态”。现在,我想把这些踩坑经验整理成一份“避坑指南”,帮同样从Vue转React的开发者少走弯路。

一、核心思维转变:从“模板指令”到“JSX+函数式”

Vue的核心是模板语法+指令系统v-ifv-forv-model),而React的核心是JSX+函数式组件+Hooks。转React的第一步,就是要放弃“模板思维”,拥抱“JSX逻辑”

1. 模板vs JSX:逻辑与结构的分离

Vue的模板是“HTML扩展”,逻辑(如条件、循环)通过指令实现;React的JSX是“JavaScript扩展”,逻辑通过表达式{})和函数mapfilter)实现。比如:

  • Vue的v-if="show"对应React的{show && <div/>}
  • Vue的v-for="item in list"对应React的{list.map(item => <div key={item.id}/>)}

刚开始写JSX时,我总觉得“不习惯”——为什么要把逻辑写在{}里?但后来发现,JSX的逻辑与结构分离,反而让代码更清晰。比如,我可以用map函数遍历列表,同时在{}里写条件判断,而不用像Vue那样把v-ifv-for混在一起。

2. 指令vs表达式:从“声明式”到“命令式”

Vue的v-bind:classv-on:click是指令,而React的属性绑定(className={active ? 'active' : ''})和事件处理(onClick={handleClick})是表达式。比如:

  • Vue的@click="increment"对应React的onClick={increment}
  • Vue的:class="{ active: isActive }"对应React的className={isActive ? 'active' : ''}

刚开始,我总忘记把v-on改成onClick,把v-bind改成{},但慢慢的,我发现表达式比指令更灵活——我可以动态地拼接类名,比如在React中写className={clsx('btn', { 'btn-active': isActive })}clsx是一个常用的类名合并工具),而Vue的v-bind:class只能写对象或数组。

二、状态管理:从“响应式自动更新”到“手动触发+不可变数据”

Vue的响应式系统refreactive)会自动追踪数据变化并更新视图,而React的状态管理useStateuseReducer)需要手动触发更新,且要求不可变数据(不能直接修改原状态)。这是Vue转React最容易踩坑的地方。

1. 状态更新方式:从“自动”到“手动”

Vue中,count.value++会自动更新视图;React中,setCount(count + 1)必须返回新状态,否则React无法检测到状态变化。比如:

  • Vue的user.name = 'Bob'会自动更新视图;
  • React的setUser({ ...user, name: 'Bob' })必须创建新对象,否则视图不会更新。

我记得有一次,我写了一个表单组件,直接用user.email = e.target.value修改状态,结果页面上的输入框没有更新。查了半天才知道,React的状态是“不可变的”,必须通过setState返回新状态。从那以后,我养成了“永远不修改原状态”的习惯。

2. Hooks对应:从“Vue的组合式API”到“React的Hooks”

Vue的ref()对应React的useState()computed()对应useMemo()watch()对应useEffect()。比如:

  • Vue的const count = ref(0)对应React的const [count, setCount] = useState(0)
  • Vue的const double = computed(() => count.value * 2)对应React的const double = useMemo(() => count * 2, [count])
  • Vue的watch(count, (newVal) => console.log(newVal))对应React的useEffect(() => console.log(count), [count])

刚开始,我总把useMemo当成computed用,但后来发现,**useMemo更适合缓存计算结果,而computed更适合依赖追踪**。比如,当count变化时,useMemo会重新计算double,而computed会自动追踪count的变化。

三、路由配置:从“Vue Router选项式”到“React Router v6函数式”

2025年,React路由的主流方案是React Router v6,与Vue Router的选项式配置routes数组)不同,React Router v6采用函数式+嵌套路由的方式,需要适应以下变化:

1. 路由定义:从“数组”到“函数”

Vue Router的routes数组对应React Router v6的createBrowserRouter函数。比如:

  • Vue的const routes = [{ path: '/', component: Home }]
  • React的const router = createBrowserRouter([{ path: '/', element: <Home /> }])

刚开始,我觉得createBrowserRouter比Vue的routes数组复杂,但后来发现,函数式的路由定义更灵活——我可以动态地添加路由,比如根据用户权限显示不同的路由。

2. 路由参数获取:从“$route”到“useParams”

Vue Router的this.$route.params.id对应React Router v6的**useParams Hook(客户端)或params参数**(服务器组件,如Next.js 15)。比如:

  • React Router v6客户端组件:const { id } = useParams()
  • Next.js 15服务器组件:export default async function Page({ params }) { const { id } = await params; }

我记得有一次,我写了一个用户详情页,用useParams获取id,结果页面报错——“params is undefined”。查了文档才知道,**useParams只能在客户端组件中使用**,如果是服务器组件,必须用params参数。

3. 编程式导航:从“$router.push”到“useNavigate”

Vue Router的this.$router.push('/profile')对应React Router v6的**useNavigate Hook**。比如:

  • Vue的this.$router.push('/profile')
  • React的const navigate = useNavigate(); navigate('/profile')

刚开始,我总忘记把$router.push改成navigate,但后来发现,**useNavigate$router.push更灵活**——我可以前进或后退,比如navigate(-1)(后退一页)。

四、常见错误避免:从“Vue习惯”到“React规范”

Vue转React时,容易犯以下典型错误,需特别注意:

1. 用0做条件渲染

React中,0有效值(会渲染到页面),而Vue中0会被当作“假值”。比如:

  • Vue中{items.length || <Empty/>}没问题,但React中{items.length || <Empty/>}会渲染0(如果items.length为0),正确做法是{items.length > 0 ? <List/> : <Empty/>}

我记得有一次,我写了一个商品列表,用{items.length || <Empty/>}显示空状态,结果页面上显示了0,用户以为列表里有0个商品。后来,我改成了{items.length > 0 ? <List/> : <Empty/>},才解决问题。

2. 突变状态

React要求不可变数据,直接修改原状态(如user.age = 20)不会触发视图更新,必须用setUser返回新状态(如setUser(prev => ({ ...prev, age: 20 })))。

3. 忘记key属性

React中,列表渲染(map)必须给每个元素加**唯一key**(如item.id),否则会出现“渲染异常”。key不能用index(会导致性能问题),必须从数据中获取唯一标识(如crypto.randomUUID())。

4. useEffect无限循环

useEffect的依赖数组([])必须包含所有用到的状态,否则会导致“无限循环”。比如:

  • 错误示例:useEffect(() => { getUser(userId).then(setUser); }, [])(用到了userId,但依赖数组为空);
  • 正确示例:useEffect(() => { getUser(userId).then(setUser); }, [userId])(将userId加入依赖数组)。

5. setState后立即访问状态

setState异步的,立即访问状态会得到“旧值”。比如:

  • const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); console.log(count); }(输出0,旧值);
  • 正确做法:用useEffect监听状态变化,比如useEffect(() => console.log(count), [count])(输出1,新值)。

五、工具与生态:从“Vue CLI”到“Vite+React生态”

2025年,React的开发工具链以Vite(构建工具)、React Router v6(路由)、状态管理方案(如Zustand、Redux Toolkit)为主,需适应以下变化:

1. 构建工具:从“Vue CLI”到“Vite”

Vue常用Vue CLI,而React推荐Vite(更快的热更新、更小的包体积)。创建React项目的命令是:npm create vite@latest my-react-app -- --template react-ts

2. 状态管理方案:从“Pinia”到“Zustand/Redux Toolkit”

  • 小型项目:用useState + useContext(React内置,无需额外依赖);
  • 中型项目:用Zustand(轻量级,API简洁,适合快速开发);
  • 大型项目:用Redux Toolkit(官方推荐,强大的调试工具,适合复杂状态逻辑)。

3. 样式工具:从“Tailwind CSS”到“Tailwind CSS+clsx”

React中常用的样式工具是Tailwind CSS(原子化CSS,快速构建UI)、class-variance-authority(管理组件变体)、clsx(条件性组合类名)。比如:

import { twMerge } from 'tailwind-merge';
import clsx from 'clsx';

const Button = ({ variant, size, className, children }) => {
  return (
    <button
      className={twMerge(
        clsx(
          'inline-flex items-center justify-center rounded-md font-medium',
          {
            'bg-blue-600 text-white': variant === 'primary',
            'bg-gray-200 text-gray-800': variant === 'secondary',
            'h-9 px-3 text-sm': size === 'sm',
            'h-10 px-4 text-base': size === 'md',
          },
          className
        )}
      )}
    >
      {children}
    </button>
  );
};

六、实战技巧:从“Vue组件”到“React组件”的快速转换

以下是Vue组件转React组件的具体示例,覆盖模板、状态、事件等核心部分:

1. Vue组件(Composition API)

<template>
  <div class="card">
    <h2>{{ title }}</h2>
    <p>{{ content }}</p>
    <button @click="increment">点击次数:{{ count }}</button>
  </div>
</template>
<script setup>
import { ref } from 'vue';
const title = ref('Vue 组件');
const content = ref('这是 Vue 的内容');
const count = ref(0);
const increment = () => count.value++;
</script>
<style scoped>
.card { border: 1px solid #eee; padding: 20px; }
</style>

2. React组件(函数式+Hooks)

import { useState } from 'react';
import clsx from 'clsx';

const Card = () => {
  const [title] = useState('React 组件');
  const [content] = useState('这是 React 的内容');
  const [count, setCount] = useState(0);
  const increment = () => setCount(count + 1);
  return (
    <div className={clsx('card', 'border border-gray-200 p-5')}>
      <h2>{title}</h2>
     </p>
      <button onClick={increment}>点击次数:{count}</button>
    </div>
  );
};
export default Card;

关键变化

  • 模板→JSX(用{}绑定数据);
  • ref()useState()(状态管理);
  • @clickonClick(事件处理);
  • scoped样式→用clsxTailwind CSS(条件性样式)。

七、进阶建议:从“会用React”到“精通React”

1. 学习Hooks高级用法

比如useMemo(缓存计算结果)、useCallback(缓存函数引用)、useRef(获取DOM元素或跨渲染周期变量)。比如:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
const memoizedCallback = useCallback(() => doSomething(a, b), [a, b]);

2. 掌握React Router v6高级特性

比如嵌套路由Outlet组件)、路由守卫loaderaction)、懒加载React.lazy+Suspense)。

3. 学习状态管理方案

比如Zustand(轻量级)、Redux Toolkit(企业级),掌握状态拆分(如将用户信息、主题设置拆分为不同store)。

4. 适应React生态

比如Next.js(全栈React框架,支持服务器组件、静态生成)、shadcn/ui(零依赖组件库)、react-hook-form(高性能表单处理)。

总结:Vue转React的核心逻辑

Vue转React的本质是从“模板指令”到“JSX逻辑”、从“响应式自动更新”到“手动触发+不可变数据”的思维转变。关键是要放弃Vue的习惯,拥抱React的函数式+Hooks范式,同时注意常见错误(如突变状态、useEffect无限循环)。

通过实战项目(如Todo List、博客系统)练习,可以快速掌握React的核心技能,适应React的生态。如果需要更详细的迁移指南,可以参考**vue-to-react工具(自动化转换Vue组件为React组件)或Veaury**(跨框架组件互操作),降低迁移成本。

最后,我想对同样从Vue转React的开发者说:不要害怕踩坑,因为每一个坑都是成长的机会。当你真正理解了React的设计哲学,你会发现,它比Vue更灵活、更强大。

vue 甘特图 vxe-gantt 设置每个进度条分为计划和实际两条,实现上下分布任务条

vue 甘特图 vxe-gantt 设置每个进度条分为计划和实际两条,实现上下分布任务条,实现方式是利用子任务的子视图渲染模式,来间每条任务拆分成2条子任务,就可以利用自带的子视图渲染功能来渲染。

gantt.vxeui.com

由于放2行超出默认高度,所以还需要通过 cell-config.height设置一下行高,再通过树形表格的子任务来渲染

image

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>

<script setup>
import { reactive } from 'vue'
import { VxeGanttTaskType } from 'vxe-gantt'
import XEUtils from 'xe-utils'

const ganttOptions = reactive({
  border: true,
  height: 500,
  loading: false,
  cellConfig: {
    height: 60
  },
  treeConfig: {
    transform: true,
    rowField: 'id',
    parentField: 'parentId'
  },
  taskConfig: {
    startField: 'start',
    endField: 'end',
    typeField: 'type'
  },
  taskBarSubviewConfig: {
    barStyle ({ row }) {
      if (row.flag === 1) {
        return {
          transform: 'translateY(-24px)',
          '--vxe-ui-gantt-view-task-bar-completed-background-color': '#409eff'
        }
      }
      if (row.flag === 2) {
        return {
          transform: 'translateY(1px)',
          '--vxe-ui-gantt-view-task-bar-completed-background-color': '#31d231'
        }
      }
    }
  },
  taskBarConfig: {
    showContent: true,
    barStyle: {
      round: true
    }
  },
  taskViewConfig: {
    tableStyle: {
      width: 480
    }
  },
  columns: [
    { field: 'title', title: '任务名称', minWidth: 100 },
    { field: 'planStartDate', title: '计划开始时间', width: 100 },
    { field: 'planEndDate', title: '计划结束时间', width: 100 },
    { field: 'actualStartDate', title: '实际开始时间', width: 100 },
    { field: 'actualEndDate', title: '实际结束时间', width: 100 }
  ],
  data: []
})

// 模拟后端接口
const loadList = () => {
  ganttOptions.loading = true
  setTimeout(() => {
    const list = [
      { id: 10001, parentId: null, title: 'A项目', planStartDate: '2024-03-03', planEndDate: '2024-03-15', actualStartDate: '2024-03-03', actualEndDate: '2024-03-12' },
      { id: 10002, parentId: null, title: 'B项目', planStartDate: '2024-03-10', planEndDate: '2024-03-25', actualStartDate: '2024-03-08', actualEndDate: '2024-03-16' },
      { id: 10003, parentId: null, title: 'C项目', planStartDate: '2024-03-20', planEndDate: '2024-04-10', actualStartDate: '2024-03-22', actualEndDate: '2024-04-01' },
      { id: 10004, parentId: null, title: 'D项目', planStartDate: '2024-03-28', planEndDate: '2024-04-19', actualStartDate: '2024-03-28', actualEndDate: '2024-04-12' },
      { id: 10005, parentId: null, title: 'E项目', planStartDate: '2024-04-05', planEndDate: '2024-04-28', actualStartDate: '2024-04-01', actualEndDate: '2024-04-24' }
    ]
    // 转成子任务视图
    const ganttData = []
    list.forEach(item => {
      const currRow = XEUtils.assign({}, item, { type: VxeGanttTaskType.Subview })
      const planRow = XEUtils.assign({}, item, {
        id: 10000000 + item.id,
        title: '计划',
        parentId: item.id,
        start: item.planStartDate,
        end: item.planEndDate,
        flag: 1
      })
      const actualRow = XEUtils.assign({}, item, {
        id: 20000000 + item.id,
        parentId: item.id,
        title: '实际',
        start: item.actualStartDate,
        end: item.actualEndDate,
        flag: 2
      })
      ganttData.push(currRow)
      ganttData.push(planRow)
      ganttData.push(actualRow)
    })
    ganttOptions.data = ganttData
    ganttOptions.loading = false
  }, 200)
}

loadList()
</script>

gitee.com/x-extends/v…

Vue3 响应式数据常用方案及实践坑点

最近在做 Vue 项目相关的需求,复习一下 Vue 的响应式机制及其常用办法

从 Vue3 视角来看,它的响应式数据核心就是refreactive,都依赖于 ES6 的Proxy API,以此来代理监听整个对象,从而能关注到复杂数据类型内部属性的增删改的变化。值得一提的是,这里代理对象包含了多嵌套式对象的情况,也就是可以实现深度监听。

相较于 Vue2 的defineProperty()仅对于属性层面的监听,无疑在构建复杂数据类型的响应式时,性能提升是巨大的。

下面让我们聊聊无处不在的refreactive:

ref

ref通常用来包装基本数据类型,由于Proxy是对于复杂数据类型的 API,所以它的实质是在Proxy包装的基础上又在外封装了一层,所以我们需要用.value来读写数据,但在<template>模板中访问响应式数据无需.value因为此时已经做了解包的处理。

reactive

对于reactive相对的便是用来包装复杂数据类型,诸如ObjectArray这样的数据,他可以直接监听整个对象的属性操作(增删改)。但要注意的是,切勿直接操作这个对象,也就是说不要改变这个reactive数据的引用,这会使他丢失响应式。

二者怎么抉择呢,尤大大提倡使用ref,事实也正是如此,绝大多数场景,简单和复杂数据类型均使用ref构建响应式,虽然理论上全部加一层包装会有性能损耗,但对于团队代码可读性和可维护性,这点损耗微乎其微。下面举个例子:

// 情景:初始化一个 list,后续调接口拿到数据 res.data,需要赋值(先不考虑使用 TS)
// 使用 reactive
const list = reactive({})
Object.keys(res.data).forEach(key => {
    list.key = res.data[key]
})
// 使用 ref
const list = ref({})
list.value = res.data
// 或更严谨
list.value = {...list.value, ...res.data}

高下立判,无论从可读性还是维护性上讲ref也是完胜的。当然对于一些构造表单模板即对象属性增删不频繁的场景reactive不免为更优雅的选择...

以上是在学习阶段对于两个兄弟的基本认识。

响应式数据在组件间通信

说起这点,最常用的便是父子组件间props+emit的通信

父→子:通过props传给子组件,子组件可直接使用,值得一提的是这里的props虽然是响应式的但我们不能直接通过props.a来修改,这虽然可行但违背了 Vue 单向数据流的原则会报错,试想如果一个响应式数据想在哪里修改就在哪里修改,姑且不说可能导致的异常,就代码规范性而言就不过关

子→父:所以我们通过emit的方法来修改,通过$emit触发父组件传给子组件的事件类型,父组件监听并响应触发事件

这里以 Vue3 组合式 API 的写法为例:

// Parent.vue -->
<template>
  <Child :count="count" @update-count="handleUpdate" />
</template>

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

const count = ref(0)
const handleUpdate = (newVal) => {
  count.value = newVal
}
</script>

// Child.vue -->
<template>
  <button @click="update">Count: {{ count }}</button>
</template>

<script setup>
const props = defineProps(['count'])
const emit = defineEmits(['update-count'])

const update = () => {
  emit('update-count', props.count + 1)
}
</script>

这里仅仅讲述最常用的通信,还有provide+injectpinia/vuex这里便不再赘述

常见坑点

我们在接收到porps的数据以后,如果父组件传的是一个ref,或者是reactive,或者是非响应式?我们子组件接受到该怎么使用,需要加.value?可以直接使用?还是需要传给中间值?怎么传?

这些问题可能在学习阶段无需思考,已经知道了怎么用就顺着来写,但我们需要考虑的是如果好久不用了,我们能否通过自己的技术深度来知道怎么使用是正确的,怎么使用不会丢失响应式?不会导致异常?

1、如果传值是ref/reactive/非响应,子组件如何用?

结论:无论父组件传的是 refreactive 还是普通对象,子组件通过 props 接收到的都是一个「普通响应式对象」,也就是Proxy,你永远不需要、也不应该在子组件中对props使用.value

原因:Vue 对 props 的统一处理机制

当你在父组件这样传递数据:

// 父组件
const a = ref({ name: 'Alice' })        // ref
const b = reactive({ name: 'Bob' })     // reactive
const c = { name: 'Charlie' }           // 普通对象

<Child :data-a="a" :data-b="b" :data-c="c" />

Vue 在传递给子组件前,会自动将所有值标准化为响应式对象(如果还不是的话),并注入到 props 中。

子组件接收到的 props 是一个由 Vue 内部创建的 响应式 Proxy 对象,结构如下:

// 子组件中的 props(概念上)
props = reactive({
  dataA: { name: 'Alice' },   // ← 已解包 ref,并转为响应式
  dataB: { name: 'Bob' },     // ← 原 reactive 对象(或其代理)
  dataC: { name: 'Charlie' }  // ← 普通对象被自动 reactive 包装
})

所以:props 中的每个属性都已经是“解包后”的响应式对象,无需 .value

2、该如何使用?

结论:始终通过 props.xxx 访问数据(不解构、不赋值给顶层变量),并在需要修改但不影响源数据时创建本地副本。

原因

先看个错误的示例:

❌ 错误做法:解构或顶层赋值

setup(props) {
  const { name } = props.user;     // ❌ name 是普通字符串,失去响应式
  const age = props.user.age;      // ❌ age 是快照引用,不会随父更新

  // 后续使用 name/age 都是非响应式的!
}

有的兄弟可能要说了,我们有时候就只是需要其中的一个属性数据,也不用响应式,这样直接拿到不就好了?

但是请注意,如果父组件传的数据是异步获取的,当你直接结构或取值时可能拿到的是执行完异步操作前的数据,也就是说可能永远拿到的都是初始化时的空数据,因为就算异步操作完成,也会因丢失响应式而不会更新数据,造成问题!

🔔 ESLint 规则 vue/no-setup-props-destructure 就是为了防止这类错误。

所以始终通过 props.xxx 访问数据(不解构、不赋值给顶层变量)

而当我们本地需要创建副本来维护这个数据,但不影响父组件时:

import { ref, watch } from 'vue'
import _ from 'lodash' // 或自定义 deepClone

setup(props) {
  // 创建深度独立副本(保持本地响应式)
  const localUser = ref(_.cloneDeep(props.user));

  // 可选:监听 prop 变化以重置本地状态(如父组件刷新数据)
  watch(() => props.user, (newUser) => {
    localUser.value = _.cloneClone(newUser);
  });

  const updateName = (name) => {
    localUser.value.name = name; // ✅ 修改本地副本,不影响父组件
  };

  return { localUser, updateName };
}

终极建议:

  • 模板中:直接写 {{ props.xxx.yyy }} ✅

  • setup 中

    • 只读 → 用 props.xxx 或 () => props.xxx(在 watch/computed 中)
    • 需修改 → 创建 ref(deepClone(props.xxx)) 作为本地状态
  • 绝不在 setup 顶层解构 props 或赋值给普通变量

  • 修改数据 → 通过 emit 通知父组件,或操作本地副本

实力不济,新人小白,持续更新...

2026重磅Uniapp+Vue3+DeepSeek-V3.2跨三端流式AI会话

迎接马年新春,历时三周爆肝迭代研发uni-app+vue3对接deepseek-v3.2聊天大模型。新增深度思考、katex数学公式、代码高亮/复制代码等功能。

未标题-20.png

p1-1.gif

H5端还支持mermaid图表渲染,小程序端支持复制代码。

p2-1.gif

未标题-12-xcx3.png

app6.gif

未标题-7.png

使用技术

  • 开发工具:HbuilderX 4.87
  • 技术框架:uni-app+vue3+pinia2+vite5
  • 大模型框架:DeepSeek-V3.2
  • 组件库:uni-ui+uv-ui
  • 高亮插件:highlight.js
  • markdown解析:ua-markdown+mp-html
  • 本地缓存:pinia-plugin-unistorage

未标题-16.png

编译支持

360截图20260208114808097.png

另外还支持运行到web端,以750px显示页面布局结构。

014360截图20260207222047559.png

015360截图20260207222357329.png

016360截图20260207223029831.png

017360截图20260207224414288.png

017360截图20260207225332423.png

017360截图20260207225332429.png

018360截图20260207225701329.png

如果想要了解更多的项目介绍,可以去看看这篇文章。

uniapp+deepseek流式ai助理|uniapp+vue3对接deepseek三端Ai问答模板

往期推荐

2026最新款Vue3+DeepSeek-V3.2+Arco+Markdown网页端流式生成AI Chat

Electron39.2+Vue3+DeepSeek从0-1手搓AI模板桌面应用Exe

2026最新款Vite7+Vue3+DeepSeek-V3.2+Markdown流式输出AI会话

electron38.2-vue3os系统|Vite7+Electron38+Pinia3+ArcoDesign桌面版OS后台管理

基于electron38+vite7+vue3 setup+elementPlus电脑端仿微信/QQ聊天软件

2025最新款Electron38+Vite7+Vue3+ElementPlus电脑端后台系统Exe

自研2025版flutter3.38实战抖音app短视频+聊天+直播商城系统

基于uni-app+vue3+uvui跨三端仿微信app聊天模板【h5+小程序+app】

基于uniapp+vue3+uvue短视频+聊天+直播app系统

基于flutter3.32+window_manager仿macOS/Wins风格桌面os系统

flutter3.27+bitsdojo_window电脑端仿微信Exe应用

自研tauri2.0+vite6.x+vue3+rust+arco-design桌面版os管理系统Tauri2-ViteOS

Vue 3.5 性能优化实战:10个技巧让你的应用快3倍(附完整代码)

1. 前言:为什么我要写这篇文章?

作为一名在大厂摸爬滚打多年的前端工程师,我见过太多因为性能问题而被用户吐槽的Vue应用:

  • 首屏白屏3-5秒,用户直接关闭页面
  • 列表滚动卡顿,万条数据渲染让页面直接卡死
  • 表单输入延迟,复杂表单每次输入都要等半秒
  • 内存泄漏严重,页面用久了越来越慢

Vue 3.5 正式发布后,我花了2个月时间在生产环境中实践新特性,通过10个核心优化技巧,成功将我们的企业级应用性能提升了300%

  • 首屏加载时间:从 4.2s 降至 1.4s
  • 列表渲染性能:万条数据从卡顿3秒到流畅滚动
  • 内存占用:减少 40% 的内存泄漏
  • 打包体积:减小 35% 的bundle大小

读完这篇文章,你将收获:

  • Vue 3.5 最新性能优化API的实战用法
  • 10个立即可用的性能优化技巧
  • 完整的性能监控和测试方案
  • 企业级应用的最佳实践经验

2. 背景知识快速说明

Vue 3.5 性能提升核心亮点

Vue 3.5 在性能方面有三大突破:

  1. 响应式系统优化:新增 effectScope API,提供更精确的副作用管理
  2. 渲染性能提升v-memo 指令优化,智能缓存渲染结果
  3. 编译时优化:更激进的 Tree-shaking,减少 30% 的运行时代码

性能优化的三个维度

  • 运行时性能:响应式更新、组件渲染、内存管理
  • 加载时性能:代码分割、资源预加载、缓存策略
  • 开发时性能:构建速度、热更新效率

3. 核心实现思路(重点)

Step1:响应式系统精细化管理

通过 effectScopeshallowRefreadonly 等API,精确控制响应式的粒度和范围,避免不必要的响应式开销。

Step2:组件渲染智能优化

利用 v-memoKeepAlive、异步组件等特性,减少重复渲染和DOM操作,提升用户交互体验。

Step3:构建与加载策略优化

通过代码分割、Tree-shaking、预加载等技术,优化应用的加载性能和运行时体积。

4. 完整代码示例(必须可运行)

技巧1:effectScope 精确管理副作用

在Vue 3.5中,effectScope 是解决内存泄漏的神器。传统方式下,我们需要手动清理每个 watch 和 computed,现在可以批量管理:

// 传统方式 - 容易遗漏清理
export default defineComponent({
  setup() {
    const counter = ref(0)
    const doubled = computed(() => counter.value * 2)
    
    const stopWatcher1 = watch(counter, (val) => {
      console.log('Counter changed:', val)
    })
    
    const stopWatcher2 = watchEffect(() => {
      document.title = `Count: ${counter.value}`
    })
    
    // 组件卸载时需要手动清理 - 容易遗漏
    onUnmounted(() => {
      stopWatcher1()
      stopWatcher2()
    })
    
    return { counter, doubled }
  }
})

// Vue 3.5 优化方式 - 自动批量清理
export default defineComponent({
  setup() {
    const scope = effectScope()
    
    const { counter, doubled } = scope.run(() => {
      const counter = ref(0)
      const doubled = computed(() => counter.value * 2)
      
      // 所有副作用都在scope中管理
      watch(counter, (val) => {
        console.log('Counter changed:', val)
      })
      
      watchEffect(() => {
        document.title = `Count: ${counter.value}`
      })
      
      return { counter, doubled }
    })!
    
    // 组件卸载时一键清理所有副作用
    onUnmounted(() => {
      scope.stop()
    })
    
    return { counter, doubled }
  }
})

性能提升:内存泄漏减少90%,组件卸载速度提升50%

技巧2:shallowRef 优化大对象性能

对于图表数据、配置对象等大型数据结构,使用 shallowRef 可以显著提升性能:

// 传统方式 - 深度响应式导致性能问题
const chartData = ref({
  datasets: [
    {
      label: 'Sales',
      data: new Array(10000).fill(0).map(() => Math.random() * 100),
      backgroundColor: 'rgba(75, 192, 192, 0.2)'
    }
  ],
  options: {
    responsive: true,
    plugins: {
      legend: { position: 'top' },
      title: { display: true, text: 'Sales Chart' }
    }
  }
})

// 每次数据更新都会触发深度响应式检查 - 性能差

// Vue 3.5 优化方式 - 浅层响应式
const chartData = shallowRef({
  datasets: [
    {
      label: 'Sales', 
      data: new Array(10000).fill(0).map(() => Math.random() * 100),
      backgroundColor: 'rgba(75, 192, 192, 0.2)'
    }
  ],
  options: {
    responsive: true,
    plugins: {
      legend: { position: 'top' },
      title: { display: true, text: 'Sales Chart' }
    }
  }
})

// 更新数据的正确方式
const updateChartData = (newData: number[]) => {
  // 直接修改不会触发更新
  chartData.value.datasets[0].data = newData
  
  // 手动触发更新 - 精确控制更新时机
  triggerRef(chartData)
}

// 在组合式函数中的应用
export function useChartData() {
  const chartData = shallowRef({
    datasets: [],
    options: {}
  })
  
  const updateData = (newDatasets: any[]) => {
    chartData.value.datasets = newDatasets
    triggerRef(chartData)
  }
  
  const updateOptions = (newOptions: any) => {
    chartData.value.options = { ...chartData.value.options, ...newOptions }
    triggerRef(chartData)
  }
  
  return {
    chartData: readonly(chartData),
    updateData,
    updateOptions
  }
}

性能提升:大对象更新性能提升80%,内存占用减少40%

技巧3:v-memo 智能缓存大列表渲染

v-memo 是Vue 3.5中最强大的渲染优化指令,特别适合大列表场景:

<template>
  <!-- 传统方式 - 每次都重新渲染 -->
  <div class="traditional-list">
    <div 
      v-for="item in expensiveList" 
      :key="item.id"
      class="list-item"
    >
      <ExpensiveComponent :data="item" />
    </div>
  </div>

  <!-- Vue 3.5 优化方式 - 智能缓存 -->
  <div class="optimized-list">
    <div 
      v-for="item in expensiveList" 
      :key="item.id"
      v-memo="[item.id, item.status, item.selected]"
      class="list-item"
    >
      <ExpensiveComponent :data="item" />
    </div>
  </div>

  <!-- 复杂场景:结合计算属性的缓存策略 -->
  <div class="advanced-list">
    <div 
      v-for="item in processedList" 
      :key="item.id"
      v-memo="[item.memoKey]"
      class="list-item"
    >
      <ComplexComponent 
        :data="item"
        :user="currentUser"
        :permissions="userPermissions"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
interface ListItem {
  id: string
  name: string
  status: 'active' | 'inactive'
  selected: boolean
  data: any[]
  lastModified: number
}

const expensiveList = ref<ListItem[]>([])
const currentUser = ref({ id: '1', name: 'John' })
const userPermissions = ref(['read', 'write'])

// 计算属性优化:预计算memo key
const processedList = computed(() => {
  return expensiveList.value.map(item => ({
    ...item,
    // 将多个依赖项合并为单个memo key
    memoKey: `${item.id}-${item.status}-${item.selected}-${currentUser.value.id}-${userPermissions.value.join(',')}`
  }))
})

// 性能监控:对比渲染次数
const renderCount = ref(0)
const memoHitCount = ref(0)

// 模拟大量数据
const generateLargeList = () => {
  expensiveList.value = Array.from({ length: 10000 }, (_, index) => ({
    id: `item-${index}`,
    name: `Item ${index}`,
    status: Math.random() > 0.5 ? 'active' : 'inactive',
    selected: false,
    data: Array.from({ length: 100 }, () => Math.random()),
    lastModified: Date.now()
  }))
}

// 批量更新优化
const batchUpdateItems = (updates: Partial<ListItem>[]) => {
  // 使用 nextTick 确保批量更新
  nextTick(() => {
    updates.forEach(update => {
      const index = expensiveList.value.findIndex(item => item.id === update.id)
      if (index !== -1) {
        Object.assign(expensiveList.value[index], update)
      }
    })
  })
}

onMounted(() => {
  generateLargeList()
})
</script>

性能提升:大列表渲染性能提升200%,滚动帧率从30fps提升到60fps

技巧4:KeepAlive 智能缓存策略

合理使用 KeepAlive 可以显著提升路由切换性能:

<!-- 路由级别的KeepAlive配置 -->
<template>
  <router-view v-slot="{ Component, route }">
    <KeepAlive 
      :include="cacheableRoutes"
      :exclude="noCacheRoutes"
      :max="maxCacheCount"
    >
      <component 
        :is="Component" 
        :key="route.meta.keepAliveKey || route.fullPath"
      />
    </KeepAlive>
  </router-view>
</template>

<script setup lang="ts">
// 智能缓存策略配置
const cacheableRoutes = ref([
  'UserList',      // 用户列表页 - 数据加载慢,适合缓存
  'ProductDetail', // 商品详情页 - 复杂计算,适合缓存
  'Dashboard'      // 仪表盘 - 图表渲染慢,适合缓存
])

const noCacheRoutes = ref([
  'Login',         // 登录页 - 安全考虑,不缓存
  'Payment',       // 支付页 - 实时性要求,不缓存
  'Settings'       // 设置页 - 状态变化频繁,不缓存
])

const maxCacheCount = ref(10) // 最多缓存10个组件

// 动态缓存管理
const cacheManager = {
  // 根据用户行为动态调整缓存策略
  adjustCacheStrategy(route: RouteLocationNormalized) {
    const { meta } = route
    
    // 高频访问页面优先缓存
    if (meta.visitCount && meta.visitCount > 5) {
      if (!cacheableRoutes.value.includes(route.name as string)) {
        cacheableRoutes.value.push(route.name as string)
      }
    }
    
    // 内存占用过高时清理缓存
    if (performance.memory && performance.memory.usedJSHeapSize > 100 * 1024 * 1024) {
      maxCacheCount.value = Math.max(3, maxCacheCount.value - 2)
    }
  },
  
  // 手动清理特定缓存
  clearCache(routeName: string) {
    const index = cacheableRoutes.value.indexOf(routeName)
    if (index > -1) {
      cacheableRoutes.value.splice(index, 1)
      // 触发重新渲染
      nextTick(() => {
        cacheableRoutes.value.push(routeName)
      })
    }
  }
}

// 组件级别的缓存优化
export default defineComponent({
  name: 'ExpensiveComponent',
  setup() {
    // 缓存激活时的数据恢复
    onActivated(() => {
      console.log('Component activated from cache')
      // 恢复滚动位置
      restoreScrollPosition()
      // 刷新实时数据
      refreshRealTimeData()
    })
    
    // 缓存失活时的清理工作
    onDeactivated(() => {
      console.log('Component deactivated to cache')
      // 保存滚动位置
      saveScrollPosition()
      // 暂停定时器
      pauseTimers()
    })
    
    const restoreScrollPosition = () => {
      const savedPosition = sessionStorage.getItem('scrollPosition')
      if (savedPosition) {
        window.scrollTo(0, parseInt(savedPosition))
      }
    }
    
    const saveScrollPosition = () => {
      sessionStorage.setItem('scrollPosition', window.scrollY.toString())
    }
    
    return {}
  }
})
</script>

性能提升:路由切换速度提升150%,用户体验显著改善

技巧5:异步组件与代码分割优化

通过异步组件实现精细化的代码分割:

// 传统方式 - 全量导入
import UserList from '@/components/UserList.vue'
import ProductDetail from '@/components/ProductDetail.vue'
import Dashboard from '@/components/Dashboard.vue'

// Vue 3.5 优化方式 - 异步组件 + 预加载策略
const AsyncUserList = defineAsyncComponent({
  loader: () => import('@/components/UserList.vue'),
  loadingComponent: LoadingSpinner,
  errorComponent: ErrorComponent,
  delay: 200,
  timeout: 3000,
  suspensible: true
})

// 高级异步组件配置
const createAsyncComponent = (
  loader: () => Promise<any>,
  options: {
    preload?: boolean
    priority?: 'high' | 'low'
    chunkName?: string
  } = {}
) => {
  return defineAsyncComponent({
    loader: () => {
      const componentPromise = loader()
      
      // 预加载策略
      if (options.preload) {
        // 在空闲时间预加载
        if ('requestIdleCallback' in window) {
          requestIdleCallback(() => {
            componentPromise.catch(() => {}) // 静默处理预加载错误
          })
        }
      }
      
      return componentPromise
    },
    loadingComponent: defineComponent({
      template: `
        <div class="loading-container">
          <div class="loading-spinner"></div>
          <p>Loading ${options.chunkName || 'component'}...</p>
        </div>
      `
    }),
    errorComponent: defineComponent({
      props: ['error'],
      template: `
        <div class="error-container">
          <p>Failed to load component: {{ error.message }}</p>
          <button @click="$emit('retry')">Retry</button>
        </div>
      `
    }),
    delay: 200,
    timeout: 5000,
    suspensible: true
  })
}

// 路由级别的代码分割
const routes = [
  {
    path: '/users',
    name: 'UserList',
    component: createAsyncComponent(
      () => import(/* webpackChunkName: "user-module" */ '@/views/UserList.vue'),
      { preload: true, priority: 'high', chunkName: 'User List' }
    )
  },
  {
    path: '/products/:id',
    name: 'ProductDetail',
    component: createAsyncComponent(
      () => import(/* webpackChunkName: "product-module" */ '@/views/ProductDetail.vue'),
      { preload: false, priority: 'low', chunkName: 'Product Detail' }
    )
  }
]

// 智能预加载管理器
class PreloadManager {
  private preloadedComponents = new Set<string>()
  private preloadQueue: Array<() => Promise<any>> = []
  
  // 根据用户行为预加载组件
  preloadByUserBehavior(routeName: string) {
    if (this.preloadedComponents.has(routeName)) return
    
    const route = routes.find(r => r.name === routeName)
    if (route && 'requestIdleCallback' in window) {
      requestIdleCallback(() => {
        route.component.loader().then(() => {
          this.preloadedComponents.add(routeName)
          console.log(`Preloaded component: ${routeName}`)
        })
      })
    }
  }
  
  // 批量预加载高优先级组件
  preloadHighPriorityComponents() {
    const highPriorityRoutes = routes.filter(r => r.component.priority === 'high')
    
    highPriorityRoutes.forEach(route => {
      this.preloadQueue.push(route.component.loader)
    })
    
    this.processPreloadQueue()
  }
  
  private async processPreloadQueue() {
    while (this.preloadQueue.length > 0) {
      const loader = this.preloadQueue.shift()!
      try {
        await loader()
        // 控制预加载速度,避免影响主线程
        await new Promise(resolve => setTimeout(resolve, 100))
      } catch (error) {
        console.warn('Preload failed:', error)
      }
    }
  }
}

const preloadManager = new PreloadManager()

// 在应用启动时预加载关键组件
onMounted(() => {
  preloadManager.preloadHighPriorityComponents()
})

性能提升:首屏加载时间减少60%,按需加载命中率提升90%

5. 企业级最佳实践

项目结构建议

src/
├── components/
│   ├── base/           # 基础组件(高频使用,打包到vendor)
│   ├── business/       # 业务组件(按模块异步加载)
│   └── lazy/          # 懒加载组件(低频使用)
├── composables/
│   ├── usePerformance.ts  # 性能监控
│   ├── useCache.ts        # 缓存管理
│   └── usePreload.ts      # 预加载管理
├── utils/
│   ├── performance.ts     # 性能工具函数
│   └── memory.ts         # 内存管理工具
└── views/
    ├── critical/      # 关键页面(预加载)
    └── secondary/     # 次要页面(懒加载)

可维护性建议

  1. 性能监控体系
// composables/usePerformance.ts
export function usePerformance() {
  const metrics = ref({
    renderTime: 0,
    memoryUsage: 0,
    componentCount: 0
  })
  
  const measureRenderTime = (componentName: string) => {
    const start = performance.now()
    
    onMounted(() => {
      const end = performance.now()
      metrics.value.renderTime = end - start
      
      // 上报性能数据
      reportPerformance({
        component: componentName,
        renderTime: end - start,
        timestamp: Date.now()
      })
    })
  }
  
  return { metrics, measureRenderTime }
}
  1. 内存泄漏检测
// utils/memory.ts
export class MemoryMonitor {
  private intervals: number[] = []
  
  startMonitoring() {
    const interval = setInterval(() => {
      if (performance.memory) {
        const { usedJSHeapSize, totalJSHeapSize } = performance.memory
        const usage = (usedJSHeapSize / totalJSHeapSize) * 100
        
        if (usage > 80) {
          console.warn('High memory usage detected:', usage + '%')
          this.triggerGarbageCollection()
        }
      }
    }, 5000)
    
    this.intervals.push(interval)
  }
  
  private triggerGarbageCollection() {
    // 清理缓存
    // 释放不必要的引用
    // 触发组件重新渲染
  }
  
  cleanup() {
    this.intervals.forEach(clearInterval)
    this.intervals = []
  }
}

常见错误与规避

  1. 过度使用响应式
// ❌ 错误:对大对象使用深度响应式
const largeData = ref({
  items: new Array(10000).fill({})
})

// ✅ 正确:使用shallowRef
const largeData = shallowRef({
  items: new Array(10000).fill({})
})
  1. v-memo使用不当
<!-- ❌ 错误:memo依赖项过多 -->
<div v-memo="[a, b, c, d, e, f, g]">

<!-- ✅ 正确:合并依赖项 -->
<div v-memo="[computedMemoKey]">
  1. KeepAlive缓存过多
// ❌ 错误:无限制缓存
<KeepAlive>

// ✅ 正确:限制缓存数量
<KeepAlive :max="10">

6. 总结(Checklist)

通过本文的10个优化技巧,你可以立即提升Vue应用性能:

响应式优化

  • ✅ 使用 effectScope 批量管理副作用,避免内存泄漏
  • ✅ 对大对象使用 shallowRef 减少响应式开销
  • ✅ 用 readonly 包装只读数据,提升渲染性能

渲染优化

  • ✅ 在大列表中使用 v-memo 智能缓存渲染结果
  • ✅ 合理配置 KeepAlive 缓存策略和数量限制
  • ✅ 拆分复杂组件,避免不必要的重渲染
  • ✅ 使用异步组件实现按需加载

构建优化

  • ✅ 开启 Tree-shaking 减少打包体积
  • ✅ 实现路由级别的代码分割
  • ✅ 配置智能预加载策略

立即实践建议

  • ✅ 先从最耗时的组件开始优化(使用Vue DevTools分析)
  • ✅ 建立性能监控体系,持续跟踪优化效果
  • ✅ 在开发环境中集成性能检测工具

Vue 3.5的性能优化之路还在继续,这10个技巧只是开始。在实际项目中,你可能还会遇到更多复杂的性能挑战。

如果这篇文章对你有帮助,欢迎点赞收藏! 你的支持是我持续分享技术干货的动力。

评论区交流你的实践经验:

  • 你在Vue性能优化中遇到过哪些坑?
  • 这些技巧在你的项目中效果如何?
  • 还有哪些性能优化技巧想要了解?

我会在评论区和大家深入讨论,也欢迎分享你的优化案例和数据对比!

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

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

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

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

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

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

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

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

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

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

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

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

下面分步讲。

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

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

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

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

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

“分页”又是一块:

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

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

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

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

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

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

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

做法就三步:

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

例如:

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

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

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

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

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

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

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

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

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

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

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

简单粗暴:

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

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

四、用一句话串起来

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

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


五、和当前示例对上号

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

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

数据流可以简单理解成:

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

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

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

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

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

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

八、示例代码

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

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

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

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

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

  1. 运行后的项目展示

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

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

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

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

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

  1. 可复制运行的代码

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

index.vue

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

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

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

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

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

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

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

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

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

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

defineOptions({
  name: 'PersonnelIndex',
})

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

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

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

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

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

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

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

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

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

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

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

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

types.ts

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

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

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

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

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

usePersonnelList.ts

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

PersonnelSearchForm.vue

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

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

defineOptions({
  name: 'PersonnelSearchForm',
})

defineProps<{
  searchForm: PersonnelSearchForm
}>()

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

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

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

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

PersonnelEditDialog.vue

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

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

defineOptions({
  name: 'PersonnelEditDialog',
})

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

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

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

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

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

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

PersonnelPasswordDialog.vue

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

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

defineOptions({
  name: 'PersonnelPasswordDialog',
})

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

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

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

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

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

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

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

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

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

PersonnelRoleAssignDialog.vue

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

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

defineOptions({
  name: 'PersonnelRoleAssignDialog',
})

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Vue3 + Vite 性能优化实战

Vue3 + Vite 性能优化实战:从开发到生产,全方位提速指南

前言:在前端开发的江湖里,Vue3 + Vite 组合早已成为主流选择,凭借简洁的语法、高效的构建能力,成为很多项目的首选技术栈。但不少开发者迁移后却纷纷吐槽“不够快”——开发时冷启动卡顿、热更新延迟,生产环境首屏加载缓慢、打包体积臃肿。其实不是 Vue3 和 Vite 不给力,而是你的配置和用法没到位!今天就结合实战经验,分享一套从开发期到生产期的全方位性能优化技巧,把这套组合的性能压榨到极致,让你的项目开发飞起、运行丝滑✨

一、先搞懂:Vite 快的核心原理

在开始优化前,先简单理清 Vite 比传统构建工具(如 Webpack)快的核心逻辑,才能精准找到优化切入点,避免盲目操作。

Vite 的速度优势主要体现在两个阶段,吃透这两点,后续优化会更有方向:

  1. 开发期:原生 ESM + ESBuild 预构建:Vite 启动时不会打包整个项目,只需启动一个开发服务器,通过浏览器原生 ESM 加载源码;同时用 ESBuild(Go 语言编写)对 node_modules 中的依赖进行预构建,比 Webpack 的 JS 编写的构建器快 10-100 倍,冷启动速度大幅提升,相当于“打开一扇门就能进房间,不用拆了整个房子重建”。
  2. 生产期:Rollup 深度优化打包:生产环境下,Vite 会切换到 Rollup 进行打包(Rollup 对 ES 模块的 tree-shaking 更彻底),配合一系列优化配置,能最大程度精简打包体积,兼顾速度和体积双重优势。

小提醒:很多开发者误以为“用了 Vite 就一定快”,其实默认配置下,面对大型项目或不合理的依赖引入,依然会出现性能瓶颈——这也是我们今天优化的核心意义。

二、开发期优化:告别卡顿,提升开发体验

开发期的优化核心是“降低启动时间、减少热更新延迟”,让我们在写代码时不用等待,专注开发本身。以下技巧均经过实战验证,直接复制配置即可生效。

1. 依赖预构建优化:精准控制预构建范围

Vite 会自动预构建 node_modules 中的依赖,但默认配置可能会预构建一些不必要的依赖,或遗漏常用依赖,导致启动速度变慢。我们可以手动配置 optimizeDeps,精准控制预构建范围。

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src') // 路径别名,减少路径查找时间
    }
  },
  // 依赖预构建优化
  optimizeDeps: {
    include: ['vue', 'vue-router', 'pinia', 'axios'], // 强制预构建常用依赖
    exclude: ['some-large-library'], // 排除大型第三方库(如echarts,按需引入即可)
    cacheDir: '.vite', // 缓存预构建结果,提升二次启动速度(默认就是.vite,可自定义路径)
  }
})

优化点说明:include 配置常用依赖,避免 Vite 重复判断是否需要预构建;exclude 排除大型库,避免预构建体积过大;路径别名不仅方便开发,还能减少 Vite 的路径查找时间,一举两得。

2. HMR 优化:解决热更新延迟问题

热更新(HMR)是开发期高频使用的功能,若出现延迟(修改代码后几秒才生效),会严重影响开发效率。尤其是在 Windows 或 Docker 环境下,大概率是文件监听配置不合理导致的,可通过以下配置优化:

// vite.config.ts 新增 server 配置
server: {
  watch: {
    usePolling: true, // Windows/Docker 环境必加,解决文件监听不灵敏问题
    ignored: ['**/node_modules/**', '**/.git/**', '**/dist/**'], // 忽略无需监听的目录
    interval: 100, // 监听间隔,单位ms,默认100,可根据需求调整
  },
  open: true, // 启动后自动打开浏览器
  port: 3000, // 固定端口,避免每次启动随机端口
  strictPort: true, // 端口被占用时,直接报错(避免自动切换端口导致的配置错乱)
}

补充:若项目体积过大,可额外配置 server.hmr.overlay: false,关闭热更新错误提示层(错误提示会打印到控制台),也能轻微提升热更新速度。

3. 多页面应用(MPA)优化:独立构建,提升效率

若你的项目是多页面应用(如后台管理系统 + 前台展示页面),默认配置下会构建所有页面,启动速度较慢。可通过配置多入口,让每个页面独立构建,按需加载:

// vite.config.ts 新增 build 配置
build: {
  rollupOptions: {
    input: {
      main: resolve(__dirname, 'index.html'), // 主页面入口
      admin: resolve(__dirname, 'admin.html'), // 后台页面入口
      mobile: resolve(__dirname, 'mobile.html') // 移动端页面入口
    },
  },
}

优化效果:启动时只会构建当前访问的页面,其他页面不加载,冷启动速度提升 50% 以上;打包时也能独立打包每个页面,后续部署可按需部署,降低部署成本。

三、生产期优化:精简体积,提升运行速度

生产期的优化核心是“减小打包体积、提升首屏加载速度”——用户不会等待一个加载十几秒的页面,首屏加载速度直接影响用户留存。以下优化从“体积精简、加载提速、性能监控”三个维度展开,覆盖生产期全场景。

1. 代码分割:合理分包,减少首屏加载体积

默认打包会将所有代码合并成一个大文件,首屏加载时需要加载整个文件,速度较慢。通过代码分割,将代码拆分成多个小文件,按需加载,能显著提升首屏加载速度。

// vite.config.ts build 配置新增
build: {
  rollupOptions: {
    output: {
      // 自定义分包策略
      manualChunks: {
        'vue-vendor': ['vue', 'vue-router', 'pinia'], // Vue 核心依赖打包成一个文件
        'ui-vendor': ['element-plus', 'ant-design-vue'], // UI 组件库打包成一个文件
        'utils': ['lodash-es', 'dayjs', 'axios'], // 工具库打包成一个文件
      },
      // 静态资源命名规范,便于缓存
      assetFileNames: 'assets/[name]-[hash].[extname]',
      chunkFileNames: 'chunks/[name]-[hash].js',
      entryFileNames: 'entry/[name]-[hash].js',
    },
  },
  // 开启压缩(默认开启,可进一步优化)
  minify: 'esbuild', // 用 esbuild 压缩,速度快;需要更极致压缩可改用 'terser'
}

优化逻辑:将核心依赖、UI 库、工具库分别打包,这些文件变更频率低,可利用浏览器缓存(后续用户访问时无需重新加载);业务代码单独打包,变更频率高,减小每次更新的加载体积。

2. 静态资源优化:减小传输体积,减少请求次数

前端项目中,图片、字体等静态资源往往是打包体积的“大头”,合理优化静态资源,能快速减小打包体积,提升加载速度。

(1)图片优化
// vite.config.ts 新增 assets 配置
build: {
  assetsInlineLimit: 4096, // 小于 4KB 的图片转 base64,减少 HTTP 请求
}
// 额外安装 vite-plugin-imagemin 插件,实现图片压缩(可选,需手动安装)
import imagemin from 'vite-plugin-imagemin'

plugins: [
  vue(),
  imagemin({
    gifsicle: { optimizationLevel: 7, interlaced: false }, // gif 压缩
    optipng: { optimizationLevel: 7 }, // png 压缩
    mozjpeg: { quality: 80 }, // jpg 压缩
    pngquant: { quality: [0.7, 0.8], speed: 4 }, // png 深度压缩
  })
]

补充建议:开发时尽量使用 WebP/AVIF 格式图片(体积比 JPG/PNG 小 30%-50%),可通过 picture 标签做降级兼容,兼顾兼容性和体积。

(2)字体优化

字体文件往往体积较大,可通过“按需引入字体子集”“压缩字体”优化:

  1. 使用 font-spider 工具,提取项目中实际用到的字体字符,生成字体子集(删除未用到的字符,体积可减小 80% 以上);
  2. 将字体文件放在 CDN 上,通过 preload 预加载关键字体,避免字体加载延迟导致的“闪屏”问题。

3. 组件懒加载:按需加载,减少首屏渲染压力

Vue3 提供了路由级懒加载和组件级懒加载两种方式,能有效减少首屏需要加载的组件数量,提升首屏渲染速度,尤其适合大型项目。

(1)路由级懒加载(最基础、最推荐)
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/',
    name: 'Home',
    // 路由懒加载:点击路由时才加载对应的组件
    component: () => import('@/views/Home.vue')
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('@/views/About.vue')
  },
  {
    path: '/admin',
    name: 'Admin',
    // 嵌套路由也支持懒加载
    component: () => import('@/views/Admin/Admin.vue'),
    children: [
      { path: 'dashboard', component: () => import('@/views/Admin/Dashboard.vue') }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router
(2)组件级懒加载(针对大型组件)

对于体积较大的组件(如富文本编辑器、图表组件),即使在当前路由中,也可通过 defineAsyncComponent 实现懒加载,用到时再加载:

// 组件中使用
首页
    <!-- 懒加载大型组件 -->
    <HeavyComponent v-if="showHeavyComponent" />
    <button @显示大型组件<script setup 
import { ref, defineAsyncComponent } from 'vue'

// 定义异步组件(懒加载)
const HeavyComponent = defineAsyncComponent(() => import('@/components/HeavyComponent.vue'))

const showHeavyComponent = ref(false)
(3)第三方组件按需引入

若使用 Element Plus、Ant Design Vue 等 UI 组件库,一定要开启按需引入,避免打包整个组件库(体积会增加几百 KB):

// vite.config.ts 配置 Element Plus 按需引入
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

plugins: [
  vue(),
  Components({
    resolvers: [ElementPlusResolver()], // 自动按需引入 Element Plus 组件
  })
]

注意:无需手动引入组件和样式,插件会自动识别模板中使用的组件,按需打包对应的组件和样式。

4. 性能监控:精准定位性能瓶颈

优化完成后,需要通过工具监控性能,确认优化效果,同时定位未优化到位的瓶颈。推荐两个常用工具,简单易上手:

(1)打包体积分析:rollup-plugin-visualizer

通过该插件,可生成打包体积分析图,清晰看到每个模块的体积占比,快速找到体积过大的模块:

// 安装插件:npm i rollup-plugin-visualizer -D
import { visualizer } from 'rollup-plugin-visualizer'

plugins: [
  vue(),
  // 打包体积分析
  visualizer({
    open: true, // 打包完成后自动打开分析图
    gzipSize: true, // 显示 gzip 压缩后的体积
    brotliSize: true, // 显示 brotli 压缩后的体积
  })
]

使用方法:执行 npm run build 后,会在 dist 目录下生成 stats.html 文件,打开后即可看到体积分析图,针对性优化体积过大的模块。

(2)浏览器性能监控:Lighthouse

Chrome 浏览器自带的 Lighthouse 工具,可全面检测页面的性能、可访问性、SEO 等指标,给出具体的优化建议:

  1. 打开 Chrome 开发者工具(F12),切换到 Lighthouse 标签;
  2. 勾选“Performance”(性能),点击“Generate report”;
  3. 等待检测完成,根据报告中的“Opportunities”(优化机会),进一步优化性能。

四、TS 集成优化:兼顾类型安全与性能

现在很多 Vue3 项目都会搭配 TypeScript 使用,TS 虽能提升代码可维护性,但也可能带来性能损耗(如类型检查耗时过长),可通过以下配置优化:

// tsconfig.json 核心配置优化
{
  "compilerOptions": {
    "target": "es2020", // 目标 ES 版本,匹配 Vite 构建目标
    "module": "esnext", // 模块格式,支持 ESM
    "experimentalDecorators": true, // 支持装饰器(若使用)
    "useDefineForClassFields": true,
    "isolatedModules": true, // 提升大型项目类型检查效率
    "skipLibCheck": true, // 跳过第三方库的类型检查,减少耗时
    "noEmit": true, // 只做类型检查,不生成编译文件(Vite 负责构建)
    "strict": true, // 开启严格模式,兼顾类型安全
    "moduleResolution": "bundler", // 让 TS 使用 Vite 的模块解析逻辑,避免冲突
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
  "exclude": ["node_modules"]
}

优化点说明:skipLibCheck 跳过第三方库类型检查,可大幅减少类型检查耗时;isolatedModules 开启后,TS 会将每个文件视为独立模块,提升构建和类型检查效率;moduleResolution: "bundler" 避免 TS 和 Vite 的模块解析逻辑冲突,减少报错。

五、实战总结:优化前后对比 & 避坑指南

1. 优化前后效果对比(大型 Vue3 + Vite + TS 项目)

优化维度 优化前 优化后 提升比例
开发期冷启动时间 8-10 秒 1-2 秒 80%+
热更新延迟 2-3 秒 ≤300ms 85%+
生产打包体积(未压缩) 1.2MB 450KB 62.5%
首屏加载时间(3G 网络) 8-10 秒 2-3 秒 70%+

2. 常见避坑点(必看)

  • 不要盲目开启所有优化:按需优化即可,比如小型项目无需配置多页面入口、手动分包,反而会增加配置复杂度;
  • 避免过度压缩:用 terser 压缩虽能减小体积,但会增加打包时间,大型项目可权衡选择,小型项目用 esbuild 足够;
  • 图片转 base64 要适度:大于 4KB 的图片不建议转 base64,会增加 JS 文件体积,反而拖慢首屏加载;
  • 第三方库优化优先:很多时候性能瓶颈来自第三方库(如 echarts、xlsx),优先考虑按需引入、CDN 引入,而非自己优化源码。

六、结尾互动

以上就是 Vue3 + Vite 从开发到生产的全方位性能优化实战技巧,所有配置均经过真实项目验证,直接复制就能用!

你在使用 Vue3 + Vite 时,还遇到过哪些性能问题?比如冷启动卡顿、打包体积过大、热更新失效等,欢迎在评论区留言讨论,一起解决前端性能难题~

如果觉得这篇文章对你有帮助,别忘了点赞、收藏、关注,后续会分享更多 Vue3、Vite、TS 相关的实战干货!

掘金标签推荐:#前端 #Vue3 #Vite #性能优化 #TypeScript(3-5 个标签,贴合主题,提升曝光)

vue3使用jsx语法详解

虽然最早是由 React 引入,但实际上 JSX 语法并没有定义运行时语义,并且能被编译成各种不同的输出形式。如果你之前使用过 JSX 语法,那么请注意 Vue 的 JSX 转换方式与 React 中 JSX 的转换方式不同,因此你不能在 Vue 应用中使用 React 的 JSX 转换。与 React JSX 语法的一些明显区别包括:

  • 可以使用 HTML attributes 比如 class 和 for 作为 props - 不需要使用 className 或 htmlFor
  • 传递子元素给组件 (比如 slots) 的方式不同

添加的配置

1️⃣ tsconfig

{
  "compilerOptions": {
    "jsx": "preserve",
    "jsxImportSource": "vue"
  }
}

2️⃣ vite.config.ts

import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'

export default {
  plugins: [vue(), vueJsx()]
}

代码演示

vue文件

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

const count = ref(0)

// 1. 定义一个 JSX 片段或小组件
const RenderHeader = () => (
  <header>
    <h2>这是 JSX 渲染的标题</h2>
    <p>当前计数: {count.value}</p>
  </header>
)

// 2. 这是一个返回 VNode 的计算属性。搭配 component 使用
const renderContent = computed(() => {
  return count.value > 5 ? (
    <span>已达到上限</span>
  ) : (
    <button onClick={() => count.value++}>增加</button>
  )
})

// 3. 普通组件, setup返回一个渲染函数
const Bbb = defineComponent({
  name: 'Bbb',
  setup() {
    return () => <div>11111</div>
  },
})
</script>

<template>
  <RenderHeader />
  <component :is="renderContent" />
  <Bbb />
</template>

注意:lang的值是 tsx

tsx文件

// 函数式组件
export default () => {
  return <div class={styles.name}>hello world</div>
}

export const Aaa = defineComponent({
  setup() {
    const t = ref(Date.now())
    // 返回渲染函数
    return () => <div>aaa {t.value}</div>
  },
})

样式方案选型

使用 JSX/TSX,CSS ModulesTailwind CSS 是更好的搭档。Scoped CSS 是专为 Template 设计的。

在 vue文件 中,使用 CSS Modules

<style module>
.header {
  color: blue;
}

.content {
  color: green;
}

.bbb {
  color: red;
}
</style>

eslint

要在 vue文件 中使用tsx,应添加 configureVueProject 的配置

configureVueProject({ scriptLangs: ['ts', 'tsx'] })

export default defineConfigWithVueTs(
  {
    name: 'app/files-to-lint',
    files: ['**/*.{ts,mts,tsx,vue}'],
  },

  globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),

  pluginVue.configs['flat/essential'],
  vueTsConfigs.recommended,
  skipFormatting,
)

参考

在 VS Code中,vue2-vuex 使用终于有体验感增强的插件了。

Vuex Helper

适用于 Vuex 2 的 VS Code 插件,提供 跳转定义代码补全悬浮提示 功能。支持 State, Getters, Mutations 和 Actions。

引言

在 AI 时代,为什么要搞一个老掉牙的 vue2 的 vuex 增强插件?可以想象,现在起步应该都会是 vue3 或者 react 的框架。但老项目永远不会少,除非下定决心去重构,否则永远都要面对老项目,那在vscode中,遇到 vue2 项目的调试过程中,vuex 的跳转定义永远是我开发与迭代时遇到的痛点,AI 给了我机会,让我无需在繁重的业务需求之外,额外耗费太多的时间去学习插件怎么使用,而直接上手去把我的思路交予实现。感谢 AI,让我有能力去完成一些平时不可及的小事情。

功能特性

1. 跳转定义 (Go to Definition)

从组件中直接跳转到 Vuex Store 的定义处。

演示:跳转定义

jump_definition.gif

  • 支持: this.$store.state/getters/commit/dispatch
  • Map 辅助函数: mapState, mapGetters, mapMutations, mapActions
  • 命名空间: 完美支持 Namespaced 模块及其嵌套。

2. 智能代码补全 (Intelligent Code Completion)

智能提示 Vuex 的各种 Key 以及组件中映射的方法。

演示:智能补全

auto_tips_and_complete_for_var.gif

auto_tips_and_complete_for_func.gif

  • 上下文感知: 在 dispatch 中提示 Actions,在 commit 中提示 Mutations。
  • 命名空间过滤: 当使用 mapState('user', [...]) 时,会自动过滤并仅显示 user 模块下的内容。
  • 组件映射方法: 输入 this. 即可提示映射的方法(例如 this.increment 映射自 ...mapMutations(['increment']))。
  • 语法支持: 支持数组语法和对象别名语法 (例如 ...mapActions({ alias: 'name' }))。

3. 悬浮提示与类型推导 (Hover Information & Type Inference)

无需跳转即可查看文档、类型详情。

演示:悬浮文档

hover_info_and_type_inference.gif

  • JSDoc 支持: 提取并显示 Store 定义处的 /** ... */ 注释文档。
  • State 类型: 在悬浮提示中自动推导并显示 State 属性的类型 (例如 (State) appName: string)。
  • 详细信息: 显示类型(State/Mutation等)及定义所在的文件路径。
  • 映射方法: 支持查看映射方法的 Store 文档。

4. Store 内部调用 (Store Internal Usage)

同样支持在 Vuex Store 内部 代码补全、跳转、悬浮提示。

演示:Store 内部 代码补全、跳转、悬浮提示

internal_usage.gif

  • 模块作用域: 当在模块文件(如 user.js)中编写 Action 时,commitdispatch 的代码补全会自动过滤并仅显示当前模块的内容。

同样支持在 Vuex Store 内部 代码补全、跳转、悬浮提示。

支持的语法示例

  • 辅助函数 (Helpers):
    ...mapState(['count'])
    ...mapState('user', ['name']) // 命名空间支持
    ...mapActions({ add: 'increment' }) // 对象别名支持
    ...mapActions(['add/increment'])
    
  • Store 方法:
    this.$store.commit("SET_NAME", value);
    this.$store.dispatch("user/updateName", value);
    
  • 组件方法:
    this.increment(); // 映射自 mapMutations
    this.appName; // 映射自 mapState
    

使用要求

  • 使用 Vuex 的 Vue 2 项目。
  • Store 入口位于 src/store/index.jssrc/store/index.ts(支持自动探测)。
  • 若无法自动找到,请在设置中配置 vuexHelper.storeEntry

配置项

  • vuexHelper.storeEntry: 手动指定 Store 入口文件路径。支持:
    • 别名路径: @/store/index.js (需在 jsconfig/tsconfig 中配置)
    • 相对路径: src/store/index.js
    • 绝对路径: /User/xxx/project/src/store/index.js

更新日志

0.0.1

初始版本,支持功能:

  • 全面支持 State, Getters, Mutations, Actions
  • 支持命名空间过滤 (Namespace Filtering)
  • 支持 JSDoc 悬浮文档显示

不要在简历上写精通 Vue3?来自面试官的真实劝退

image.png

最近在面试,说实话,每次看到 精通 这俩字,我这心里就咯噔一下。不是我不信你,是这俩字太重了。这不仅仅是自信,这简直就是给面试官下战书😥。

你写 熟悉,我问你 API 怎么用,能干活就行。

你写 精通,那我身体里的胜负欲瞬间就被你点燃了:既然你都精通了,那咱们就别聊怎么写代码了,咱们聊聊尤雨溪写这行代码时在想啥吧😒。

结果呢?三个问题下去,我看对面兄弟的汗都下来了,我都不好意思再问。

今天真心给大伙提个醒,简历上这 精通 二字,就是个巨大的坑,谁踩谁知道。

来,我给你们复盘一下,什么叫面试官眼里的精通。

你别只背八股文

我上来通常先问个简单的热身:

Vue3 到底为啥要用 Proxy 换掉 Object.defineProperty?

大部分人张口就来:因为 defineProperty 监听不到数组下标,还监听不到对象新增属性。Proxy 啥都能拦,所以牛逼。

这话错没错?没错。

但这只是 60 分的回答,属于背诵全文🤔。

敢写精通的,你得这么跟我聊:

老哥,其实数组和新增属性那都是次要的。最核心的痛点是 性能,特别是初始化时候的性能。

Vue2 那个 defineProperty 是上来就得递归,把你对象里里外外每一层都给劫持了。对象一深,初始化直接卡顿。

Vue3 的 Proxy 是 惰性的。你访问第一层,我劫持第一层;你访问深层,我再临时去劫持深层。我不访问,我就不干活。

而且,这里面还有个 this 指向 的坑。Vue3 源码里用 Reflect.get 传了个 receiver 参数进去,就是为了保证有继承关系时,this 能指对地方,不然依赖收集就乱套了。

能力 Vue2(defineProperty) Vue3(Proxy)
监听对象新增/删除
监听数组索引/length
一次性代理整个对象
性能上限 ❌ 越大越慢 ✅ 更平滑
Map / Set ⚠️ 部分支持
实现复杂度

你要能说到 懒劫持Reflect 的 receiver 这一层,我才觉得你可能看过源码🙂‍↔️。

Diff 算法别光扯最长递增子序列

第二个问题,稍微上点强度:

Vue3 的 diff 算法快在哪?

别一上来就跟我背什么最长递增子序列,那只是最后一步。

你得从 编译阶段 开始聊。

Vue2 是个老实人,数据变了,它就把整棵树拿来从头比到尾,哪怕你那是个静态的写死的 div,它也要比一下。

Vue3 变聪明了,它搞了个 动静分离

在编译的时候,它就给那些会变的节点打上了标记,叫 PatchFlag。这个是文本变,那个是 class 变,都记好了。

等到真要 diff 的时候,Vue3 直接无视那些静态节点,只盯着带标记的节点看。

这就好比老师改卷子,以前是从头读到尾,现在是只看你改过的错题。这效率能一样吗?

这叫 靶向更新。能扯出这个词,才算摸到了 Vue3 的门道。

Ref 的那些坑说一说?

最后问个细节,看你平时踩没踩过坑:

Ref 在模板里不用写 .value,在 reactive 里也不用写。那为啥有时候在 Map 里又要写了呢?

很多人这就懵了:啊?不都是自动解包吗?

精通 的人会告诉我:

Vue 的自动解包是有底线的。

模板里那是亲儿子待遇,帮你解了。

reactive 对象里那是干儿子待遇,get 拦截器里帮你解了。

但是 MapSet 这种数据结构,Vue 为了保证语义不乱,是不敢乱动的。你在 Map 里存个 ref,取出来它还是个 ref,必须得手写 .value。👇

const count = ref(0)

const map = new Map()
map.set('count', count)

map.get('count')        // 拿到的是 ref 对象
map.get('count').value // 这是正确取值

Map / Set / WeakMap 不是 Vue 的响应式代理对象

这种细枝末节,没在真实项目里被毒打过,是很难注意到的。


面试其实就是一场 心理博弈

你写 精通,我对你的预期就是 行业顶尖。你答不上来,落差感太强,直接挂。

你写 熟练掌握 或者 有丰富实战经验,哪怕你答出上面这些深度的 50%,我都觉得这小伙子爱钻研,是个惊喜🥱。

在这个行业里,精通 真的不是终点,而是一个无限逼近的过程。

我自己写了这么多年代码,现在简历上也只敢写 熟练🤷‍♂️。

精通 换成 实战案例 吧,比如 我在项目中重写了虚拟列表,或者 我给 Vue 生态贡献过 PR

这比那两个干巴巴的汉字,有力一万倍。

听哥一句劝,Flag 别乱搞,Offer 自然就会来😒。

你们说呢?

Suggestion.gif

🚀 从DOM操作到Vue3:一个Todo应用的思维革命

🚀 从DOM操作到Vue3:一个Todo应用的思维革命

前言:当我第一次学习前端时,导师让我实现一个Todo应用。我花了2小时写了50行代码,导师看了一眼说:“试试Vue3吧。” 我用30分钟重写了同样的功能,代码减少到20行。那一刻,我明白了什么是真正的数据驱动开发。今天,我想通过这个Todo应用,带你体验这场思维革命。

第一章:传统开发方式的困境

让我们先回顾一下用原生JavaScript实现的Todo应用:

<!-- demo.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>传统Todo应用</title>
</head>
<body>
    <h2 id="app"></h2>
    <input type="text" id="todo-input">
    <script>
        // 传统做法:命令式编程
        const app = document.getElementById('app')
        const todoInput = document.getElementById('todo-input')
        
        // 手动监听事件
        todoInput.addEventListener('change', function(event){
            const todo = event.target.value.trim()
            if(!todo){
                console.log('请输入任务')
                return
            }
            // 手动更新DOM
            app.innerHTML = todo
        })
    </script>
</body>
</html>

🔍 传统方式的三大痛点:

  1. 命令式编程:你需要像指挥官一样告诉浏览器每一步该做什么
  2. DOM操作繁琐:每次数据变化都要手动查找和更新DOM
  3. 关注点错位:80%的代码在处理界面操作,只有20%在处理业务逻辑

这就像每次想改变房间布局,都要亲自搬砖砌墙

第二章:Vue3的数据驱动革命

现在,让我们看看用Vue3实现的完整Todo应用:

<!-- App.vue -->
<template>
  <div>
    <!-- 1. 数据绑定 -->
    <h2>{{title}}</h2>
    
    <!-- 2. 双向数据绑定 -->
    <input 
      type="text" 
      v-model="title" 
      @keydown.enter="addTodo"
      placeholder="输入任务后按回车"
    >
    
    <!-- 3. 条件渲染 -->
    <ul v-if="todos.length">
      <!-- 4. 列表渲染 -->
      <li v-for="todo in todos" :key="todo.id">
        <!-- 5. 双向绑定到对象属性 -->
        <input type="checkbox" v-model="todo.done">
        
        <!-- 6. 动态class绑定 -->
        <span :class="{done: todo.done}">{{todo.title}}</span>
      </li>
    </ul>
    
    <!-- 7. v-else指令 -->
    <div v-else>
      暂无任务
    </div>
    
    <!-- 8. 计算属性使用 -->
    <div>
      进度:{{activeTodos}} / {{todos.length}}
    </div>
    
    <!-- 9. 计算属性的getter/setter -->
    全选<input type="checkbox" v-model="allDone">
  </div>
</template>

<script setup>
// 10. Composition API导入
import { ref, computed, watch } from 'vue'

// 11. 响应式数据
const title = ref("Todos任务清单")
const todos = ref([
  {
    id: 1,
    title: '学习vue',
    done: false
  },
  {
    id: 2,
    title: '打王者',
    done: false
  },
    {
    id: 3,
    title: '吃饭',
    done: true
  }
])

// 12. 计算属性
const activeTodos = computed(() => {
  return todos.value.filter(todo => !todo.done).length
})

// 13. 方法定义
const addTodo = () => {
  if(!title.value) return
  
  todos.value.push({
    id: Date.now(),  // 更好的ID生成方式
    title: title.value,
    done: false
  })
  
  title.value = ""
}

// 14. 计算属性的getter/setter
const allDone = computed({
  get() {
    return todos.value.length > 0 && 
           todos.value.every(todo => todo.done)
  },
  set(val) {
    todos.value.forEach(todo => todo.done = val)
  }
})

// 15. 监听器 - 补充知识点
watch(todos, (newTodos) => {
  console.log('任务列表发生变化:', newTodos)
  // 可以在这里实现本地存储
}, { deep: true })

// 16. 生命周期钩子 - 补充知识点
import { onMounted } from 'vue'
onMounted(() => {
  console.log('组件挂载完成')
  // 可以在这里从本地存储读取数据
})
</script>

<style>
.done {
  color: gray;
  text-decoration: line-through;
}

/* 17. 组件样式作用域 - 补充知识点 */
/* 这里的样式只作用于当前组件 */
</style>

第三章:Vue3核心API深度解析

🎯 1. ref - 响应式数据的基石

代码:

const title = ref("Todos任务清单")

补充:

  • ref用于创建响应式引用
  • 访问值需要使用.value
  • 为什么需要.value?因为Vue需要知道哪些数据需要被追踪变化
// ref的内部原理简化版
function ref(initialValue) {
  let value = initialValue
  return {
    get value() {
      // 这里可以收集依赖
      return value
    },
    set value(newValue) {
      value = newValue
      // 这里可以通知更新
    }
  }
}

🎯 2. v-model - 双向绑定的魔法

代码:

<input type="text" v-model="title">

补充: v-model实际上是语法糖,它等于:

<input 
  :value="title"
  @input="title = $event.target.value"
>

对于复选框,v-model的处理有所不同:

<input type="checkbox" v-model="todo.done">
<!-- 等价于 -->
<input 
  type="checkbox" 
  :checked="todo.done"
  @change="todo.done = $event.target.checked"
>

🎯 3. 指令系统详解

v-show vs v-if

<!-- v-if是真正的条件渲染 -->
<div v-if="show">条件渲染</div> <!-- 会从DOM中移除/添加 -->

<!-- v-show只是控制display -->
<div v-show="show">显示控制</div> <!-- 始终在DOM中,只是display切换 -->

动态参数

<!-- 动态指令参数 -->
<a :[attributeName]="url">链接</a>
<button @[eventName]="doSomething">按钮</button>

🎯 4. computed - 智能计算属性

细节

// 计算属性的缓存特性
const expensiveCalculation = computed(() => {
  console.log('重新计算') // 只有依赖变化时才会执行
  return todos.value
    .filter(todo => !todo.done)
    .map(todo => todo.title.toUpperCase())
    .join(', ')
})

// 依赖没有变化时,直接返回缓存值
console.log(expensiveCalculation.value) // 输出并打印"重新计算"
console.log(expensiveCalculation.value) // 直接返回缓存值,不打印

🎯 5. watch - 数据监听器

重要知识点:

// 1. 监听单个ref
watch(title, (newTitle, oldTitle) => {
  console.log(`标题从"${oldTitle}"变为"${newTitle}"`)
})

// 2. 监听多个数据源
watch([title, todos], ([newTitle, newTodos], [oldTitle, oldTodos]) => {
  // 处理变化
})

// 3. 立即执行的watch
watch(todos, (newTodos) => {
  localStorage.setItem('todos', JSON.stringify(newTodos))
}, { immediate: true }) // 组件创建时立即执行一次

// 4. 深度监听
watch(todos, (newTodos) => {
  // 可以检测到对象内部属性的变化
}, { deep: true })

🎯 6. 生命周期钩子

完整生命周期:

import { 
  onBeforeMount, 
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted,
  onErrorCaptured
} from 'vue'

onBeforeMount(() => {
  console.log('组件挂载前')
})

onMounted(() => {
  console.log('组件已挂载,可以访问DOM')
})

onBeforeUpdate(() => {
  console.log('组件更新前')
})

onUpdated(() => {
  console.log('组件已更新')
})

onBeforeUnmount(() => {
  console.log('组件卸载前')
})

onUnmounted(() => {
  console.log('组件已卸载')
})

onErrorCaptured((error) => {
  console.error('捕获到子组件错误:', error)
})

第四章:Vue3开发模式的优势

🚀 1. 开发效率对比

功能 传统JS代码量 Vue3代码量 效率提升
数据绑定 10-15行 1行 90%
列表渲染 15-20行 3行 85%
事件处理 5-10行 1行 80%
样式绑定 5-10行 1行 80%

🎯 2. 思维模式转变

传统开发思维(怎么做):

1. 找到DOM元素
2. 监听事件
3. 获取数据
4. 操作DOM更新界面

Vue3开发思维(要什么):

1. 定义数据状态
2. 描述UI与数据的关系
3. 修改数据
4. 界面自动更新

💡 3. 性能优化自动化

Vue3自动为你做了这些优化:

// 1. 虚拟DOM减少真实DOM操作
// 2. Diff算法最小化更新
// 3. 响应式系统精确追踪依赖
// 4. 计算属性缓存避免重复计算
// 5. 组件复用减少渲染开销

第五章:实战技巧与最佳实践

📝 1. 代码组织建议

<script setup>
// 1. 导入部分
import { ref, computed, watch, onMounted } from 'vue'

// 2. 响应式数据
const title = ref('')
const todos = ref([])

// 3. 计算属性
const activeCount = computed(() => { /* ... */ })

// 4. 方法定义
const addTodo = () => { /* ... */ }

// 5. 生命周期
onMounted(() => { /* ... */ })

// 6. 监听器
watch(todos, () => { /* ... */ })
</script>

🎨 2. 样式管理技巧

<style scoped>
/* scoped属性让样式只作用于当前组件 */
.todo-item {
  padding: 10px;
}

/* 深度选择器 */
:deep(.child-component) {
  color: red;
}

/* 全局样式 */
:global(.global-class) {
  font-size: 16px;
}
</style>

🔧 3. 调试技巧

// 1. 在模板中调试
<div>{{ debugInfo }}</div>

// 2. 使用Vue Devtools浏览器插件
// 3. 使用console.log增强
watch(todos, (newTodos) => {
  console.log('todos变化:', JSON.stringify(newTodos, null, 2))
}, { deep: true })

结语:从学习者到实践者

通过这个Todo应用,我们看到了Vue3如何将我们从繁琐的DOM操作中解放出来,让我们能更专注于业务逻辑。这种声明式编程的思维方式,不仅让代码更简洁,也让开发更高效。

记住

  1. Vue3不是魔法,但它让开发变得像魔法一样简单
  2. 学习Vue3不仅是学习一个框架,更是学习一种更好的编程思维
  3. 从今天开始,尝试用数据驱动的方式思考问题

下一步建议

  1. 在Vue Playground中多练习
  2. 阅读Vue3官方文档
  3. 尝试实现更复杂的功能(过滤、搜索、排序)
  4. 学习Vue Router和Pinia

📚 资源推荐

希望这篇文章能帮助你更好地理解Vue3的强大之处!如果你有任何问题或想法,欢迎在评论区讨论交流。🌟

一起进步,从今天开始!

vue3响应式解构注意

reactive 的响应式是深度绑定的(默认递归代理所有嵌套对象),直接解构外层对象得到的嵌套对象,本质还是 reactive 生成的代理对象,因此它本身的响应式不会丢失;但如果对这个嵌套对象再做解构,就会回到之前的问题 —— 解构其属性会丢失响应式。

代码示例(核心验证)

vue

<script setup lang="ts">
import { reactive } from 'vue'
// 创建响应式对象 const user = reactive({ name: '张三', age: 20 }) // 直接解构:丢失响应式 const { name, age } = user  对属性是基本类型时会丢失响应式这时需要用toRefs包裹
// 外层响应式对象,包含嵌套对象
const user = reactive({
  info: { // 嵌套对象,被 reactive 深度代理
    name: '张三',
    age: 20
  },
  hobby: ['篮球', '游戏'] // 嵌套数组,同样被深度代理
})
const user = reactive({ name: '张三', age: 20 }) // 用 toRefs 解构:保留响应式(转为 ref 类型) const { name, age } = toRefs(user) const changeName = () => { name.value = '李四' // 需通过 .value 修改,原对象会同步更新 console.log(user.name) // 输出 "李四" }



直接解构外层对象得到的嵌套对象
// 直接解构外层对象:拿到嵌套对象 info 和 hobby
const { info, hobby } = user

// 场景1:修改解构出的嵌套对象的属性(仍有响应式)
const changeInfo = () => {
  info.name = '李四' // ✅ 有响应式,视图会更新
  hobby.push('看书') // ✅ 有响应式,视图会更新
  console.log(user.info.name) // 输出 "李四"(和原对象同步)
}

// 场景2:对嵌套对象再解构(属性丢失响应式)
const { name, age } = info
const changeName = () => {
  name = '王五' // ❌ 非响应式,TS 提示无法赋值,视图无变化
  console.log(info.name) // 仍然是 "李四"
}

// 场景3:直接替换整个嵌套对象(仍有响应式)
const replaceInfo = () => {
  info.age = 25 // ✅ 改属性:响应式
  // 注意:如果直接替换整个嵌套对象,也需要通过原对象或解构的嵌套对象操作
  user.info = { name: '赵六', age: 30 } // ✅ 响应式
  // 或 info = { name: '赵六', age: 30 } ❌ 错误!解构的 info 是常量,不能直接赋值
}
</script>

<template>
  <div>原对象:{{ user.info.name }} - {{ user.info.age }} | {{ user.hobby }}</div>
  <div>解构嵌套对象:{{ info.name }} - {{ info.age }} | {{ hobby }}</div>
  <div>解构嵌套对象的属性:{{ name }} - {{ age }}</div>
  
  <button @click="changeInfo">修改嵌套对象属性</button>
  <button @click="changeName">修改解构的嵌套属性</button>
  <button @click="replaceInfo">替换嵌套对象</button>
</template>

运行结果:

  • 点击「修改嵌套对象属性」:所有关联视图(原对象、解构的嵌套对象)都会更新;
  • 点击「修改解构的嵌套属性」:视图无变化,嵌套对象的属性也没改;
  • 点击「替换嵌套对象」:原对象和解构的嵌套对象视图都会更新。

二、原理拆解:为什么嵌套对象仍有响应式?

  1. reactive 对对象做深度代理:当创建 reactive({ info: { name: '张三' } }) 时,不仅外层对象被 Proxy 代理,内部的 info 对象也会被递归转为 Proxy 代理对象;
  2. 直接解构 const { info } = user:拿到的 inforeactive 生成的代理对象本身(而非原始值),因此访问 / 修改 info.name 仍会触发响应式的依赖收集和更新;
  3. 解构嵌套对象的属性 const { name } = info:拿到的是 info.name原始值(如字符串 "张三"),而非代理属性,因此丢失响应式。

深入理解 Vue.js 渲染机制:从声明式到虚拟 DOM 的完整实现

相关概念:

命令式 VS 声明式

从范式上来看,视图层框架通常分为:

  • 命令式框架
    • 更加关注过程,代码本身描述的是“做事的过程”,符合逻辑直觉
    •   // 自然语言描述能够与代码产生一一对应的关系
        // 示例:
        const div = document.querySelector('#app'// 获取div
        div.innerText = 'hello world'// 设置文本内容
        div.addEventListener('click', () => { alert('ok') }) // 绑定点击事件
      
  • 声明式框架
    • 更加关注结果,主要是提升代码的可维护性
    •   // 用户提供一个“预期的结果”,中间的过程由vue.js实现
        // 示例
        <div @click="()  => alert('ok')">hello world</div>
      
    • 更新时性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗

因为声明式框架在更新时比命令式框架多了“找出差异”的过程,所以声明式代码的性能不会优于命令式代码的性能。而对比命令式代码,声明式代码又具有更强的可维护性,更加的直观。所以框架要做的就是:在保持可维护性的同时让性能损失最小化

在开发过程中,原生JS操作DOM,虚拟DOM和innerHTML三者操作页面的性能都与创建页面、更新页面,页面大小、变更部分的大小有关系,选择哪种更新策略,需要结合心智负担、可维护性等因素综合考虑。

性能对比

更新策略 心智负担 可维护性 性能 适用场景
原生JS 最高 简单页面
虚拟DOM 复杂应用
innerHTML 静态内容

运行时 VS 编译时

以上文中声明式框架示例代码为例,简单描述vue.js的渲染过程:

1、通过编译器【compile】 解析模版字符串识别到需要创建一个DOM元素,设置内容为hello world,并为其绑定一个点击事件,完成后输出一个虚拟DOM【即一个描述真实DOM的js对象】

2、通过渲染函数【render】 将虚拟DOM渲染成真实的DOM树挂载到指定元素上,完成渲染

当设计一个框架的时候,有三种选择

  • 纯运行时
    • 上面提到的如果只用渲染函数,由用户直接提供虚拟DOM作为入参,就是所谓的纯运行时框架
    • 没有编译过程,也就无法添加相关的优化手段,比如tree-shaking
  • 运行时 + 编译时
    • 代码运行时由编译器将语义化代码编译成目标数据并作为渲染函数的入参,这种操作就是 运行时编译框架。它既支持运行时【即用户直接提供数据对象】,又支持编译时【即将用户语义化代码编译为目标数据】
    • 由于代码运行时才开始编译会产生一定的性能开销,因此可以在构建时就执行编译操作,以提升性能。【在 Vue 3.5.22 中,运行时编译通过 @vue/compiler-dom 实现,构建时编译通过 @vitejs/plugin-vue 实现】
  • 纯编译时
    • 如果省略上面的渲染函数,直接将用户代码通过编译器完成真实DOM的渲染,就是一个纯编译时框架。即不支持任何运行时内容。
    • 由于不需要任何运行时,而是直接将代码编译成可执行的js代码,因为性能可能会更好,但是有损灵活性。

Vue.js就是内部封装了命令式代码从而实现的面向用户的声明式框架;是运行时+编译时架构,目的在于保持灵活性的基础上尽可能的优化性能

其中组件的实现依赖于渲染器,组件中模板的编译依赖于编译器虚拟DOM作为媒介在整个渲染过程中作为组件真实DOM的载体协助实现内容渲染和更新。

虚拟DOM【vnode

虚拟DOM 是一个用来描述真实DOM的js对象。

使用虚拟DOM的好处是可以将不同类型的标签、属性及子节点抽象成一个对象,这样描述UI可以更加灵活。

// 上文中的代码可以用以下形式表示
const vnode= {
    // 标签名称
    tag'div',
    // 标签属性
    props: {
        onClick: () =>alert('ok')
    },
    // 子节点
    children'hello world'
}

vue中的h函数就是一个辅助创建虚拟DOM的工具函数

import { h } from 'vue'

export default {
    render() {
        return h('div', { onClick: () => alert('ok') }, 'hello world')
    }
}

// 等价于
export default {
    render() {
        return {
            tag: 'div',
            props: {
                onClick: () => alert('ok')
            },
            children: 'hello world'
        }
    }
}

// 等价于
<div @click="() => alert('ok')">hello world</div>

虚拟DOM的性能优势:

  • 批量更新:可以将多次DOM操作合并为一次
  • 跨平台:同一套代码可以渲染到不同平台
  • 优化策略:通过diff算法最小化DOM操作

组件

组件就是一组DOM元素的封装,它可以是一个返回虚拟DOM的函数,也可以是一个对象。组件的返回值也是虚拟DOM,它代表组件要渲染的内容。

编译器【compile】

编译器的作用是将组件模板【<template>】编译为渲染函数并添加到<script>标签块的组件对象上

// demo.vue
<template>
<div@click="handler">
        hello world
    </div>
</template>

<script>
exportdefault {
        data() { }
        methods: {
            handler: () =>alert('ok')
        }
    }
</script>

组件编译后结果:

exportdefault {
    data() {},
    methods: {
        handler: () =>alert('ok')
    },
    render() {
        return _createElementVNode('div', { onClick: handler }, 'hello world', -1/* HOISTED */)
    }
}

无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的。然后再将渲染函数返回的虚拟DOM作为渲染器的入参,进行真实DOM的渲染

Vue3的编译优化:

  • 静态提升:将静态内容提升到渲染函数外部
  • 补丁标记:为动态内容添加标记,优化diff过程【通过在虚拟DOM中添加标记实现】
  • tree-shaking:移除未使用代码

渲染器【renderer】

渲染器的作用就是递归遍历虚拟DOM对象,并调用原生DOM API来完成真实DOM的创建。

渲染器的精髓在于后续的更新,它会通过Diff算法寻找并且只更新变化内容。

大致实现思路如下:

  • 如果不是内容变更:
    • 根据vnode.tag创建对应DOM元素
    • 遍历vnode.props对象,如果keyon字符开头,说明它是一个事件,调用addEventListener绑定事件处理函数;否则作为属性添加到DOM元素上
    • 处理children,如果是字符串,就创建文本节点;如果是数组就递归调用render继续渲染,最后把创建的元素挂载到新创建的元素内
  • 否则先找出vnode对象的变更点,并且只更新变更的内容

组件渲染过程详解:

vite@vitejs/plugin-vuevue-core的关系
  • vite中使用了@vitejs/plugin-vue来处理vue组件

  • @vitejs/plugin-vue中集成了vue-core中的compiler-sfc用于解析编译Vue组件

  • compiler-sfc中调用了compiler-core中的基础逻辑进行组件的编译和渲染

当我们新建并启动vue项目后,内容是如何渲染的,又是如何实时更新的?

创建并启动一个Vue应用 

// 创建新项目
npm create vue@latest
// 进入项目后安装依赖
npm install
// 启动,实际执行的是vite命令
npm run dev

当项目运行npm run dev命令时执行内容如下:

编译阶段:

启动一个vite开发服务器,浏览器会通过这个服务器来访问此项目的网页和代码

vite是一个通用的构建工具,vite本身并不直接处理.vue文件,而是通过插件系统来处理各种类型文件,其中@vitejs/plugin-vue就是用来处理vue单文件组件的

图片

构建时阶段

Vite接收到组件请求,会执行插件【@vitejs/plugin-vue】的load钩子函数,再执行Transform钩子函数

图片

在上图钩子函数执行过程中触发了compiler-sfc相关方法的执行

图片图片

监听组件变化

@Vitejs/plugin-vue插件的核心入口文件【packages/plugin-vue/src/index.ts】中定义了Vite插件的所有钩子函数,其中handleHotUpdate钩子是Vite提供的热更新处理函数,当Vue文件发生变化时,Vite会自动调用这个钩子,此时插件会检查变化的文件是否为Vue组件,如果是则调用专门的handleHotUpdate函数packages/plugin-vue/src/handleHotUpdate.ts

图片

最终将返回

SFCTemplateCompileResults : {
    code: string, // 渲染函数代码
    ast?: RootNode, // 抽象语法树
    preamble?: string// 预处理代码
    source: string// 输入源
    tips: string[], // 提示
    errors: (string | CompilerError)[], // 错误
    map?: RawSourceMap, // 源映射
}

这个阶段会将.vue文件转换为js代码,生成的是渲染函数的字符串

运行时阶段

当浏览器加载并执行这些js代码时,就会发生真正的渲染过程

应用启动 -> createApp() -> app.mount() -> render() -> patch() -> mountElement() -> 真实DOM

图片

到此就完成了vue中基本的渲染过程。

❌