阅读视图

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

useStorage:本地数据持久化利器

image

前言

一、基础概念

1.1 什么是本地存储

  在Web开发中,本地存储是指将数据存储在客户端浏览器中,以便在不同的页面或会话之间保持数据的持久性。本地存储可以帮助我们存储用户的偏好设置、临时数据以及其他需要在用户关闭浏览器后仍然存在的数据。对浏览器来说,使用 Web Storage 存储键值对比存储 Cookie 方式更直观,而且容量更大,它包含两种:localStorage 和 sessionStorage

Cookie localStorage sessionStorage
数据的生命期 一般由服务器生成,可设置失效时间。
如果在浏览器端生成Cookie,默认是关闭浏览器后失效
除非被清除,否则永久保存,
可变相设置失效时间
仅在当前会话下有效,
关闭页面或浏览器后被清除
存放数据大小 4K左右 一般为5MB
与服务器端通信 每次都会携带在HTTP头中,
如果使用cookie保存过多数据会带来性能问题
仅在客户端(即浏览器)中保存,
不参与和服务器的通信
易用性 源生的Cookie接口不友好,需要自己封装 源生接口可以接受,亦可再次封装

1.2 useStorage 简介

  useStorage 是 Vue 用于数据持久化的核心工具,它能够自动将响应式数据同步到 localStorage 或 sessionStorage 中。这个功能对于需要保存用户偏好设置、表单数据或应用状态的场景特别有用。这样,我们就可以在Vue组件中方便地使用本地存储来持久化数据,提供更好的用户体验和数据管理能力。

// hooks/useStorage.ts
/**
 * 获取传入的值的类型
 */
const getValueType = (value: any) => {
    const type = Object.prototype.toString.call(value)
    return type.slice(8, -1)
}

export const useStorage = (type: 'sessionStorage' | 'localStorage' = 'sessionStorage') => {
    /**
     * 存储数据
     * @param key
     * @param value
     */
    const setStorage = (key: string, value: any) => {
        const valueType = getValueType(value)
        window[type].setItem(key, JSON.stringify({type: valueType, value}))
    }
    /**
     * 获取某个存储数据
     * @param key
     */
    const getStorage = (key: string) => {
        const value = window[type].getItem(key)
        if (value) {
            const {value: val} = JSON.parse(value)
            return val
        } else {
            return value
        }
    }

    /**
     * 清除某个存储数据
     * @param key
     */
    const removeStorage = (key: string) => {
        window[type].removeItem(key)
    }

    /**
     * 清空所有存储数据,如果需要排除某些数据,可以传入 excludes 来排除
     * @param excludes 排除项。如:clear(['key']),这样 key 就不会被清除
     */
    const clear = (excludes?: string[]) => {
        // 获取排除项
        const keys = Object.keys(window[type])
        const defaultExcludes = ['dynamicRouter', 'serverDynamicRouter']
        const excludesArr = excludes ? [...excludes, ...defaultExcludes] : defaultExcludes
        const excludesKeys = excludesArr ? keys.filter((key) => !excludesArr.includes(key)) : keys
        // 排除项不清除
        excludesKeys.forEach((key) => {
            window[type].removeItem(key)
        })
        // window[type].clear()
    }

    return {
        setStorage,
        getStorage,
        removeStorage,
        clear
    }
}

二、使用帮助

2.1 用法

<script setup lang="ts">
import { useStorage } from "@/hooks/useStorage";

const { setStorage, getStorage, removeStorage, clear } = useStorage();
// const { setStorage, getStorage, removeStorage, clear } = useStorage('localStorage');
</script>

  useStorage 提供了四个核心函数来操作数据,如下表所示。

方法名 简要说明
setStorage 存储数据。将要用于引用的键名作为第一个参数传递,将要保存的值作为第二个参数传递。
getStorage 获取某个存储数据
removeStorage 清除某个存储数据
clear 清除所有缓存数据,如果需要排除某些数据,可以传入 excludes 来排除,如:clear(['key']),这样 key 就不会被清除

2.2 储存数据

  使用 setStorage 方法可以将数据进行持久化存储,例如:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { setStorage } = useStorage();
setStorage('accessToken', 'Bearer ' + response.data.result.accessToken);
</script>

  这里,accessToken是键,Bearer + response.data.result.accessToken 是对应的值。除此以外,支持非字符串类型存取值:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { setStorage } = useStorage();
  
setStorage('key', { name: 'Jok' })
</script>

  注意:由于 localStorage 操作的是字符串,如果存储的是JSON对象,需要先使用 JSON.stringify() 将其转换为字符串,取回时再使用 JSON.parse() 还原。

2.3 取出数据

  获取存储的数据则使用 getStorage 方法:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { getStorage } = useStorage();
const accessToken = getStorage('accessToken');
</script>

2.4 删除数据

  如果需要移除某个键值对,可以调用 removeStorage 方法:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { removeStorage } = useStorage();
removeStorage('key')
</script>

2.5 更改数据

  要更新已存储的数据,同样使用 setStorage 方法,覆盖原有的值:

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { setStorage } = useStorage();
getStorage('accessToken', '更改后' + response.data.result.accessToken);
</script>

2.6 清除数据

<script setup lang="ts">
import {useStorage} from "@/hooks/useStorage";

const { clear } = useStorage();
clear()
</script>

三、总结

  Vue 中使用 localStorage 可以方便地在用户关闭和重新打开浏览器时保持应用状态,解决像 Cookie 那样需要刷新才能获取新值的问题。合理运用 localStorage 和 sessionStorage,可以在不增加服务器负担的情况下,提供更好的用户体验。

image

Vue3 子传父全解析:从基础用法到实战避坑

在 Vue3 开发中,组件通信是绕不开的核心场景,而子传父作为最基础、最常用的通信方式之一,更是新手入门必掌握的知识点。不同于 Vue2 的 $emit 写法,Vue3 组合式 API(<script setup>)简化了子传父的实现逻辑,但也有不少细节和进阶技巧需要注意。

本文将抛开 TypeScript,用最通俗的语言 + 可直接复制的实战代码,从基础用法、进阶技巧、常见场景到避坑指南,全方位讲解 Vue3 子传父,新手看完就能上手,老手也能查漏补缺。

一、核心原理:子组件触发事件,父组件监听事件

Vue3 子传父的核心逻辑和 Vue2 一致:子组件通过触发自定义事件,将数据传递给父组件;父组件通过监听该自定义事件,接收子组件传递的数据

关键区别在于:Vue3 <script setup> 中,无需通过 this.$emit 触发事件,而是通过 defineEmits 声明事件后,直接调用 emit 函数即可,语法更简洁、更直观。

先记住核心流程,再看具体实现:

  1. 子组件:用 defineEmits 声明要触发的自定义事件(可选但推荐);
  2. 子组件:在需要传值的地方(如点击事件、接口回调),调用 emit('事件名', 要传递的数据)
  3. 父组件:在使用子组件的地方,通过 @事件名="处理函数" 监听事件;
  4. 父组件:在处理函数中,接收子组件传递的数据并使用。

二、基础用法:最简洁的子传父实现(必学)

我们用一个「子组件输入内容,父组件实时显示」的简单案例,讲解基础用法,代码可直接复制到项目中运行。

1. 子组件(Child.vue):声明事件 + 触发事件

<template>
  <div class="child">
    <h4>我是子组件</h4>
    <!-- 输入框输入内容,触发input事件,传递输入值 -->
    <input 
      type="text" 
      v-model="childInput" 
      @input="handleInput"
      placeholder="请输入要传递给父组件的内容"
    />
    <!-- 按钮点击,传递固定数据 -->
    <button @click="handleClick" style="margin-top: 10px;">
      点击向父组件传值
    </button>
  </div>
</template>

<script setup>
// 1. 声明要触发的自定义事件(数组形式,元素是事件名)
// 可选,但推荐声明:增强代码可读性,IDE会有语法提示,避免拼写错误
const emit = defineEmits(['inputChange', 'btnClick'])

// 子组件内部数据
const childInput = ref('')

// 输入框变化时,触发事件并传递输入值
const handleInput = () => {
  // 2. 触发事件:第一个参数是事件名,第二个参数是要传递的数据(可选,可多个)
  emit('inputChange', childInput.value)
}

// 按钮点击时,触发事件并传递固定对象
const handleClick = () => {
  emit('btnClick', {
    name: '子组件',
    msg: '这是子组件通过点击按钮传递的数据'
  })
}
</script>

2. 父组件(Parent.vue):监听事件 + 接收数据

<template>
  <div class="parent">
    <h3>我是父组件</h3>
    <p>子组件输入的内容:{{ parentMsg }}</p>
    <p>子组件点击传递的数据:{{ parentData }}</p>
    
    <!-- 3. 监听子组件声明的自定义事件,绑定处理函数 -->
    <Child 
      @inputChange="handleInputChange"
      @btnClick="handleBtnClick"
    />
  </div>
</template>

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

// 父组件接收数据的容器
const parentMsg = ref('')
const parentData = reactive({
  name: '',
  msg: ''
})

// 4. 处理子组件触发的inputChange事件,接收传递的数据
const handleInputChange = (val) => {
  // val 就是子组件emit传递过来的值(childInput.value)
  parentMsg.value = val
}

// 处理子组件触发的btnClick事件,接收传递的对象
const handleBtnClick = (data) => {
  // data 是子组件传递的对象,直接解构或赋值即可
  parentData.name = data.name
  parentData.msg = data.msg
}
</script>

3. 核心细节说明

  • defineEmits 是 Vue3 内置的宏,无需导入,可直接使用;
  • emit 函数的第一个参数必须和 defineEmits 中声明的事件名一致(大小写敏感),否则父组件无法监听到;
  • emit 可传递多个参数,比如 emit('event', val1, val2),父组件处理函数可对应接收 (val1, val2) => {}
  • 父组件监听事件时,可使用 @事件名(简写)或 v-on:事件名(完整写法),效果一致。

三、进阶用法:优化子传父的体验(实战常用)

基础用法能满足简单场景,但在实际开发中,我们还会遇到「事件校验」「双向绑定」「事件命名规范」等需求,这部分进阶技巧能让你的代码更规范、更健壮。

1. 事件校验:限制子组件传递的数据类型

通过 defineEmits 的对象形式,可对事件传递的数据进行类型校验,避免子组件传递错误类型的数据,提升代码可靠性(类似 props 校验)。

<script setup>
// 对象形式声明事件,key是事件名,value是校验函数(参数是子组件传递的数据,返回boolean)
const emit = defineEmits({
  // 校验inputChange事件传递的数据必须是字符串
  inputChange: (val) => {
    return typeof val === 'string'
  },
  // 校验btnClick事件传递的数据必须是对象,且包含name和msg属性
  btnClick: (data) => {
    return typeof data === 'object' && 'name' in data && 'msg' in data
  }
})

// 若传递的数据不符合校验,控制台会报警告(不影响代码运行,仅提示)
const handleInput = () => {
  emit('inputChange', 123) // 传递数字,不符合校验,控制台报警告
}
</script>

2. 双向绑定:v-model 简化子传父(高频场景)

很多时候,子传父是为了「修改父组件的数据」,比如表单组件、开关组件,这时可使用 v-model 简化代码,实现父子组件双向绑定,无需手动声明事件和处理函数。

Vue3 中,v-model 本质是「语法糖」,等价于 :modelValue="xxx" @update:modelValue="xxx = $event"

优化案例:子组件开关,父组件显示状态

<!-- 子组件(Child.vue) -->
<template>
  <div class="child">
    <h4>子组件开关</h4>
    <button @click="handleSwitch">
      {{ isOpen ? '关闭' : '打开' }}
    </button>
  </div>
</template>

<script setup>
// 1. 接收父组件通过v-model传递的modelValue
const props = defineProps(['modelValue'])
// 2. 声明update:modelValue事件(固定命名,不可修改)
const emit = defineEmits(['update:modelValue'])

// 子组件内部使用父组件传递的值
const isOpen = computed(() => props.modelValue)

// 开关切换,触发事件,修改父组件数据
const handleSwitch = () => {
  emit('update:modelValue', !isOpen.value)
}
</script>
<!-- 父组件(Parent.vue) -->
<template>
  <div class="parent">
    <h3>父组件:{{ isSwitchOpen ? '开关已打开' : '开关已关闭' }}</h3>
    <!-- 直接使用v-model,无需手动监听事件 -->
    <Child v-model="isSwitchOpen" />
  </div>
</template>

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

const isSwitchOpen = ref(false)
</script>

扩展:多个 v-model 双向绑定

Vue3 支持给同一个子组件绑定多个 v-model,只需给 v-model 加后缀,对应子组件的propsemit 即可。

<!-- 父组件 -->
<Child 
  v-model:name="parentName" 
  v-model:age="parentAge" 
/>

<!-- 子组件 -->
<script setup>
// 接收多个v-model传递的props
const props = defineProps(['name', 'age'])
// 声明对应的update事件
const emit = defineEmits(['update:name', 'update:age'])

// 触发事件修改父组件数据
emit('update:name', '新名字')
emit('update:age', 25)
</script>

3. 事件命名规范:提升代码可读性

在实际开发中,遵循统一的事件命名规范,能让团队协作更高效,推荐以下规范:

  • 事件名采用「kebab-case 短横线命名」(和 HTML 事件命名一致),比如 input-change 而非 inputChange
  • 事件名要语义化,体现事件的用途,比如 form-submit(表单提交)、delete-click(删除点击);
  • 双向绑定的事件固定为 update:xxx,xxx 对应 props 名,比如 update:nameupdate:visible

四、实战场景:子传父的常见应用

结合实际开发中的高频场景,给大家补充 3 个常用案例,覆盖大部分子传父需求。

场景1:子组件表单提交,父组件接收表单数据

<!-- 子组件(FormChild.vue) -->
<template>
  <div class="form-child">
    <input v-model="form.name" placeholder="请输入姓名" />
    <input v-model="form.age" type="number" placeholder="请输入年龄" />
    <button @click="handleSubmit">提交表单</button>
  </div>
</template>

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

const emit = defineEmits(['form-submit'])

const form = reactive({
  name: '',
  age: ''
})

const handleSubmit = () => {
  // 表单校验(简化)
  if (!form.name || !form.age) return alert('请填写完整信息')
  // 提交表单数据给父组件
  emit('form-submit', form)
  // 提交后重置表单
  form.name = ''
  form.age = ''
}
</script>

场景2:子组件关闭弹窗,父组件控制弹窗显示/隐藏

<!-- 子组件(ModalChild.vue) -->
<template>
  <div class="modal" v-if="visible">
    <div class="modal-content">
      <h4>子组件弹窗</h4>
      <button @click="handleClose">关闭弹窗</button>
    </div>
  </div>
</template>

<script setup>
const props = defineProps(['visible'])
const emit = defineEmits(['close-modal'])

const handleClose = () => {
  // 触发关闭事件,通知父组件隐藏弹窗
  emit('close-modal')
}
</script>

场景3:子组件列表删除,父组件更新列表

<!-- 子组件(ListChild.vue) -->
<template>
  <div class="list-child">
    <div v-for="item in list" :key="item.id">
      {{ item.name }}
      <button @click="handleDelete(item.id)">删除</button>
    </div>
  </div>
</template>

<script setup>
const props = defineProps(['list'])
const emit = defineEmits(['delete-item'])

const handleDelete = (id) => {
  // 传递要删除的id给父组件,由父组件更新列表
  emit('delete-item', id)
}
</script>

五、常见坑点避坑指南(新手必看)

很多新手在写子传父时,会遇到「父组件监听不到事件」「数据传递失败」等问题,以下是最常见的 4 个坑点,帮你快速避坑。

坑点1:事件名大小写不一致

子组件 emit('inputChange'),父组件 @inputchange="handle"(小写),会导致父组件监听不到事件。

解决方案:统一采用 kebab-case 命名,子组件 emit('input-change'),父组件 @input-change="handle"

坑点2:忘记声明事件(defineEmits)

子组件直接调用 emit('event'),未用 defineEmits 声明事件,虽然开发环境可能不报错,但生产环境可能出现异常,且 IDE 无提示。

解决方案:无论事件是否需要校验,都用 defineEmits 声明(数组形式即可)。

坑点3:传递复杂数据(对象/数组)时,父组件修改后影响子组件

子组件传递对象/数组给父组件,父组件直接修改该数据,会影响子组件(因为引用类型传递的是地址)。

解决方案:父组件接收数据后,用 JSON.parse(JSON.stringify(data)) 深拷贝,或用 reactive + toRaw 处理,避免直接修改原始数据。

坑点4:v-model 双向绑定时报错,提示「modelValue 未定义」

原因:子组件未接收 modelValue props,或未声明 update:modelValue 事件。

解决方案:确保子组件 defineProps(['modelValue'])defineEmits(['update:modelValue']) 都声明。

六、总结:子传父核心要点回顾

Vue3 子传父的核心就是「事件触发 + 事件监听」,记住以下 3 个核心要点,就能应对所有场景:

  1. 基础写法:defineEmits 声明事件 → emit 触发事件 → 父组件 @事件名 监听;
  2. 进阶优化:事件校验提升可靠性,v-model 简化双向绑定,遵循 kebab-case 命名规范;
  3. 避坑关键:事件名大小写一致、必声明事件、复杂数据深拷贝、v-model 对应 props 和 emit 命名正确。

子传父是 Vue3 组件通信中最基础的方式,掌握它之后,再学习父传子(props)、跨层级通信(provide/inject)、全局通信(Pinia)会更轻松。

前端侦探:我是如何挖掘出网站里 28 个"隐藏商品"的?

前端侦探:我是如何挖掘出网站里 28 个"隐藏商品"的?

免责声明:本文仅供技术交流与学习,请勿利用文中技术手段对他人的服务器造成压力或进行恶意爬取。所有测试数据均来自公开接口。

🕵️‍♂️ 从一个好奇心开始

前几天逛一个数字产品合租平台(nf.video)时,我发现它首页只孤零零地挂着 6 个商品:Netflix、Disney+、Spotify 等常见的全家桶。

作为一个前端开发者,我的直觉告诉我:事情没这么简单

通常这类平台为了 SEO 或者后台管理的统一性,数据库里往往躺着更多商品,只是因为库存、策略原因被前端"隐藏"了。今天就带大家通过浏览器控制台(Console),用几招前端调试技巧,扒出那些藏在代码背后的秘密。


🔍 第一层:摆在明面上的数据

首先,我们看看普通用户能看到什么。打开控制台,简单查一下 DOM:

// 获取首页所有商品卡片
const cards = document.querySelectorAll('.platFormItem');
console.log(`首页可见商品数: ${cards.length}`);
// 输出: 6

确实只有 6 个。这建立了我们的"基准线"。如果后面我们找到了多于 6 个的数据,那就说明有"隐藏款"。


🎣 第二层:Vue Router 拦截术

点击商品卡片会跳转到购买页。通常我们会看 Network 面板找链接,但这个网站是 SPA(单页应用),点击是路由跳转。

为了不真的跳走(跳走就得退回来,麻烦),我们可以利用 Vue Router 的全局前置守卫来做一个"钩子"。我们想知道点击卡片后,路由到底想带我们去哪?

我们可以直接在控制台注入这段代码:

// 假设挂载在 app 上的 router 实例(视具体项目而定,通常在 vueApp.config 或 __vue_app__ 中)
// 这里演示思路
const router = document.querySelector('#app').__vue_app__.config.globalProperties.$router;

// 👮‍♂️ 注册一个拦截守卫
router.beforeEach((to, from, next) => {
    console.log(`🎯 捕获到目标路由: ${to.fullPath}`);
    console.log(`📦 参数 ID: ${to.params.id}`);
    
    // ✋ next(false) 阻止实际跳转,我们就停在当前页
    next(false); 
});

然后在页面上点击一个"苹果商店"的卡片:

Console 输出: 🎯 捕获到目标路由: /buy/31 📦 参数 ID: 31

Bingo!我们摸清了路由规则:/buy/:id。这意味着商品是以 ID 为索引的。


🕵️ 第三层:Performance API 里的蛛丝马迹

页面加载完了,Network 面板里的请求都被冲掉了或者很难找。这时,浏览器原生的 Performance API 就像一个黑匣子,记录了所有发生过的资源请求。

我想看看前端到底请求了哪些 API 接口:

// 筛选所有 XMLHttpRequest 或 Fetch 请求
const apiRequests = performance.getEntriesByType('resource')
  .filter(e => e.initiatorType === 'xmlhttprequest' || e.initiatorType === 'fetch')
  .map(e => e.name);

console.table(apiRequests);

在一堆日志里,我发现了这几个有趣的接口:

  • /api/applets/goods/get/homeManage (首页数据,估计就那 6 个)
  • /api/applets/goods/get/categoryGoods (分类商品?这个听起来有戏!)

我尝试手动调用了一下这个 categoryGoods 接口:

fetch('/api/applets/goods/get/categoryGoods')
  .then(res => res.json())
  .then(data => console.log(`拿到所有商品数: ${data.data.length}`));
// 输出: 27

27 个! 远超首页的 6 个。

通过分析返回的 JSON,我看到了大量首页没展示的商品:

  • ID 20: MagSafe 三合一无线充
  • ID 96: 银河次时代智能 NAS (这啥黑科技?)
  • ID 111: Typora 正版授权

到这里,如果是普通用户可能就满足了。但作为程序员,我注意到 ID 并不连续。最大的 ID 是 113,但中间缺了很多数字。

那些消失的 ID 去哪了?


🚀 第四层:ID 暴力枚举与深度挖掘

既然知道了 API 模式是 /api/applets/goods/get/:id,且 ID 是数字。那我能不能写个脚本,把 1 到 200 的 ID 全扫一遍?

这就像是在玩"扫雷"。

// 简单的并发探测脚本
async function scanHiddenGoods(maxId) {
    const hiddenGoods = [];
    
    console.log(`🚀 开始扫描 ID 1 - ${maxId}...`);
    
    const promises = [];
    for (let id = 1; id <= maxId; id++) {
        const p = fetch(`/8081/api/applets/goods/get/${id}`)
            .then(res => res.json())
            .then(res => {
                // 如果接口返回成功且有数据
                if (res.code === 10000 && res.data) {
                    return { id, name: res.data.goodsName, price: res.data.price };
                }
                return null;
            })
            .catch(() => null);
        promises.push(p);
    }

    const results = await Promise.all(promises);
    return results.filter(Boolean); // 过滤掉 null
}

// 让我们跑一下
scanHiddenGoods(200).then(goods => {
    console.table(goods);
    console.log(`🎉 共发现商品: ${goods.length} 个`);
});

几秒钟后,控制台打出了一张长长的表格。

结果令人震惊:

除了刚才分类列表里的 27 个,我又挖出了 8 个"幽灵商品"。这些商品连分类 API 都不返回,完全是"隐形"的,只有通过 ID 直达才能看到:

ID 名称 这居然也有?
18 GPT Plus 可能因为合规问题隐藏
26 Midjourney 只能直接访问购买
50 Runway 那个文生视频的 AI
105 Codex 编程神器

这些商品很可能是测试下架的、或者是仅限内部/老客户通过链接购买的。


📝 总结

通过这次探索,我们发现了网站里共有 34 个有效商品,而首页只展示了 17%

回顾一下我们的"作案工具":

  1. DOM 解析:看清表象。
  2. Vue Router 守卫:拦截路由,探知路径规则。
  3. Performance API:回溯历史请求,定位关键后端接口。
  4. Promise.all 并发探测:暴力枚举,发现离散数据。

前端开发不仅仅是画页面,善用浏览器提供的调试工具,我们可以对正在运行的应用有更深层的理解(或者单纯是为了满足好奇心 😉)。


如果你觉得这个分析过程有趣,欢迎点赞收藏!

文件16进制查看器核心JS实现

文件16进制查看器核心JS实现

本文将介绍基于 Vue 3 和 Nuxt 3 实现的“文件16进制查看器”的核心技术方案。该工具主要用于在浏览器端直接查看任意文件(包括二进制文件)的十六进制编码,所有文件处理均在前端完成,不涉及后端上传。

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

1. 核心工具函数 (utils/file-hex-viewer.js)

我们将核心的文件处理和格式化逻辑封装在 utils/file-hex-viewer.js 中,主要包括文件大小格式化、二进制转换十六进制字符串以及文件名生成。

1.1 文件大小格式化 (formatFileSize)

用于将字节数转换为人类可读的格式(如 KB, MB)。

export function formatFileSize(bytes, units = ['Bytes', 'KB', 'MB', 'GB', 'TB']) {
  if (!Number.isFinite(bytes) || bytes < 0) return `0 ${units[0] || 'Bytes'}`
  if (bytes === 0) return `0 ${units[0] || 'Bytes'}`

  const k = 1024
  const index = Math.floor(Math.log(bytes) / Math.log(k))
  const value = Math.round((bytes / Math.pow(k, index)) * 100) / 100
  const unit = units[index] || units[units.length - 1] || 'Bytes'
  return `${value} ${unit}`
}

1.2 二进制转十六进制 (bytesToHex)

这是本工具的核心转换函数。它接收一个 Uint8Array,并根据传入的 format 参数(支持 spacenospaceuppercase)生成对应的十六进制字符串。对于 space 格式,每16个字节会自动换行,方便阅读。

export function bytesToHex(uint8Array, format = 'space') {
  if (!uint8Array || !uint8Array.length) return ''
  const useUppercase = format === 'uppercase'
  const useSpace = format === 'space'
  let hexString = ''

  for (let i = 0; i < uint8Array.length; i++) {
    // 将每个字节转换为2位十六进制字符串
    let hex = uint8Array[i].toString(16).padStart(2, '0')
    
    if (useUppercase) {
      hex = hex.toUpperCase()
    }
    
    if (useSpace) {
      hexString += `${hex} `
      // 每16个字节插入一个换行符
      if ((i + 1) % 16 === 0) {
        hexString += '\n'
      }
    } else {
      hexString += hex
    }
  }

  return hexString.trim()
}

1.3 导出文件名生成 (buildHexFileName)

根据原文件名和当前的格式设置,生成导出文件的名称(后缀为 .hex.HEX)。

export function buildHexFileName(originalName, format = 'space') {
  if (!originalName) return `file${format === 'uppercase' ? '.HEX' : '.hex'}`
  const lastDot = originalName.lastIndexOf('.')
  const baseName = lastDot > 0 ? originalName.slice(0, lastDot) : originalName
  const extension = format === 'uppercase' ? '.HEX' : '.hex'
  return `${baseName}${extension}`
}

2. 文件读取与处理逻辑

在前端实现十六进制查看器的核心是利用 HTML5 的 FileReader API 读取文件内容为 ArrayBuffer,然后转换为 Uint8Array 进行处理。

const processFile = (file) => {
  const reader = new FileReader()
  
  reader.onload = (event) => {
    try {
      const buffer = event.target.result
      const bytes = new Uint8Array(buffer)
      // 调用工具函数生成 Hex 字符串
      const hex = bytesToHex(bytes, 'space') 
      // 更新视图...
    } catch (error) {
      console.error('Process failed:', error)
    }
  }
  
  reader.onerror = () => {
    console.error('Read error')
  }
  
  // 读取文件为 ArrayBuffer
  reader.readAsArrayBuffer(file)
}

3. 导出与下载功能

为了让用户可以将十六进制编码保存到本地,我们利用 Blob 对象和 URL.createObjectURL 创建临时的下载链接,实现纯前端下载。

const downloadHexFile = (hexContent, originalName, format) => {
  if (!hexContent) return

  const fileName = buildHexFileName(originalName, format)
  // 创建包含 Hex 内容的 Blob
  const blob = new Blob([hexContent], { type: 'text/plain' })
  const url = URL.createObjectURL(blob)
  
  // 创建临时链接并触发下载
  const link = document.createElement('a')
  link.href = url
  link.download = fileName
  document.body.appendChild(link)
  link.click()
  
  // 清理
  document.body.removeChild(link)
  URL.revokeObjectURL(url)
}

总结

该方案的核心在于通过 utils/file-hex-viewer.js 封装纯粹的格式化和转换逻辑,并结合浏览器原生的 FileReaderBlob API 完成文件的读取与导出,实现了一个轻量级且高效的纯前端文件十六进制查看工具。

Vben Admin管理系统集成微前端wujie-(三)终

  1. # Vben Admin管理系统集成qiankun微服务(一)
  2. # Vben Admin管理系统集成qiankun微服务(二)

一、前言

本篇是vben前端框架集成微服务的第3篇,前段时间写了vue-vben-admin集成qiankun的两篇文章,收到了大家不少建议,文章还遗留了一个问题就是多tab标签不支持状态保持,借助AI虽然也实现的相应方案,但是对vben的package包修改内容较多(后续同步主框架较为繁琐),并且修改代码健状性不好评估。抱歉暂停了进一步完善实现方案,目前先保持基本功能是ok。

近期也尝试wujie微前端框架发现能满足我当前的所有诉求,所以有了本篇的文章内容,前两篇文章的功能和问题在本文中都已支持,选择wujie原因是支持以下两个功能:

  • 天然支持保活模式alive=true,与vben中route中Keeplive参数绑定,能支持状态保持的配置。
  • wujie实现逻辑是iframe框架模式,对子应改造较小,如果不要支持主应用传参子应用可以不用改造或少量改造。

下面分步实施集成功能:

二、主应用调整

1.安装wujie和wujie-vue3

# 安装wujie
pnpm i wujie
# 安装wujie-vue3
pnpm i wujie-vue3

2. 清除沙箱数据实现

主应用src下添加wujie文件夹并添加index.ts文件,两个函数实现功能是清理沙箱缓存数据,保证在”退出登录重新打开“样式不会异常,refreshApp函数为后续单个页签关闭提供备用支持。 index.ts,文件内容如下:

interface HTMLIframeElementWithContentWindow extends HTMLIFrameElement {
  contentWindow: Window;
}

// refreshApp 主应用可以通过下述方法,主动清除指定子应用的沙箱缓存
const refreshApp = (name = '') => {
  if (!name) {
    console.error('refreshApp方法必须传入子应用的name属性');
    return;
  }

  // 这里的window应该是顶级窗口,也就是主应用的window
  const SUB_FRAME = window.document.querySelector(
    `iframe[name=${name}]`,
  ) as HTMLIframeElementWithContentWindow;

  if (!SUB_FRAME) {
    console.warn(`未找到${name}子应用,跳过刷新`);
    return;
  }

  const SUB_WINDOW = SUB_FRAME.contentWindow;
  const SUB_IDMAP = SUB_WINDOW.__WUJIE?.inject?.idToSandboxMap; // 沙箱Map对象
  SUB_IDMAP.clear();
};

// 主应用中清除所有已激活的子应用沙箱缓存
const refreshAllApp = () => {
  // 找到所有无界子应用的iframe
  const ALL_SUB_IFRAME = window.document.querySelectorAll(
    'iframe[data-wujie-flag]',
  );

  if (ALL_SUB_IFRAME.length === 0) {
    console.warn('未找到任何子应用,跳过刷新');
    return;
  }

  // 拿到这些iframe里面的contentWindow
  const ALL_SUB_WINDOW = [...ALL_SUB_IFRAME].map(
    (v) => (v as HTMLIframeElementWithContentWindow).contentWindow,
  );

  // 依次执行清除
  ALL_SUB_WINDOW.forEach((v) => v.__WUJIE?.inject?.idToSandboxMap?.clear());
};

export { refreshAllApp, refreshApp };

主应用/src/layouts/basic.vue 程序主界面,在头部引入上述文件并在相应位置调用清除沙箱方法

# 引用
import { refreshAllApp } from '#/wujie/index';

# 退出时清理
// logout
async function handleLogout() {
  await authStore.logout(false);
  refreshAllApp();
}

3. 添加微服务通用页面wujie.vue

在主应用 /apps/web-caipu/src/views/_core下添加wujie.vue页面,页面内容如:

<script setup lang="ts">
import { ref } from 'vue';
import { useRoute } from 'vue-router';

import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';

import WujieVue from 'wujie-vue3';

const useStore = useUserStore();
const accessStore = useAccessStore();
const route = useRoute();

// props通信
const props = ref({
  userinfo: useStore.userInfo,
  token: accessStore.accessToken,
  preferences,
});
// 加时缀是强制刷新
const appUrl = ref(`http://localhost:5667/app${route.path}?t=${Date.now()}`);
const keepLive = route.meta?.keepAlive;
</script>
<template>
  <div class="sub-app-container">
    <WujieVue
      width="100%"
      height="100%"
      :name="appUrl"
      :url="appUrl"
      :alive="keepLive"
      :props="props"
    />
  </div>
</template>
<style scoped>
.sub-app-container {
  width: 100%;
  height: 100%;
  background: white;
}
</style>

<style scoped>
.sub-app-container {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: white;
  border-radius: 8px;
}
</style>

聪明的你,一定知道实现的逻辑。其中子应用的地址测试写localhost:5667,后面会集成配置文件中,至此主应用改造完成。

三、子应用改造

子应用基本不用改,只要改/Users/wgh/code/caipu-vben-admin/apps-micro/web-antd/src/bootstrap.ts文件即可

image.png 在49行添加如下代码,代码不用解释,之前一样的实现逻辑。


 // 初使化存储之后赋值,避免路由判断跳转到登录页
  if (window.__POWERED_BY_WUJIE__) {
    // props 接收
    const props = window.$wujie?.props; // {data: xxx, methods: xxx}
    const useStore = useUserStore();
    const accessStore = useAccessStore();
    useStore.setUserInfo(props.userInfo);
    accessStore.setAccessToken(props.token);
    updatePreferences(props.preferences);
    // window.$wujie?.bus.$on('wujie-theme-update', (theme: any) => {
    //   alert('wujie-theme-update');
    //   updatePreferences(theme);
    // });
    window.addEventListener('wujie-theme-update', (theme: any) => {
      updatePreferences(theme.detail);
    });
  }

四。新增路由配置

在主应用路由中配置子应用一个测试路由 /app/basic/test,

image.png

为测试在子应用状态保持,我在页面中添加一个测试文本框 ,测试内容不会随着切tab页签而重新加载,浏览器的前进后退也不会出错。

image.png

上述功能已集成在前端程序里,如果我的文章对你有帮助,感谢给我点个🌟

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,
)

参考

❌