阅读视图

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

告别"移动端重构噩梦":TinyPro移动端适配上线!

本文由TinyPro贡献者王晨光同学原创。

一、背景:让 TinyPro 真正“走到掌心里”

TinyPro 是一套基于 TinyVue 打造的前后端分离后台管理系统,支持菜单配置、国际化、多页签、权限管理等丰富特性。 TinyPro 在桌面端具备良好的体验和模块化架构,但随着移动办公、平板展示等场景增多,移动端体验的短板逐渐显现:

  • 页面缩放不均衡,布局出现溢出或错位;
  • 模态框在小屏上遮挡内容;
  • 图表和表格在横屏与竖屏间切换时无法自适应;
  • 操作区过于密集,不符合触控习惯。

为此启动了 TinyPro 移动端适配项目,目标是在不破坏现有结构的前提下,实现“一次开发,跨端流畅”的体验。

二、技术选型与总体架构

本次移动端适配要求在复杂的中后台系统中实现「一次开发,多端自适应」,既要保证样式灵活,又要维持可维护性和构建性能。

在技术选型阶段,综合评估了三种常见方案:

方案 优点 缺点
纯 CSS 媒体查询 简单直接、依赖少 样式分散、逻辑重复、维护困难
TailwindCSS 响应式类 社区成熟、类名直观、生态完善 样式表体积大、断点固定、不够灵活
UnoCSS 原子化方案 按需生成、性能极轻、断点与变体完全可定制 需要自行配置规范与规则体系

最终选择了 UnoCSS + Less 的混合架构

  • UnoCSS:负责通用布局、间距、排版等高频样式,原子化写法提升开发效率;
  • Less 媒体查询:用于模态框、导航栏等复杂场景的精细控制;
  • 统一断点配置:集中管理屏幕尺寸分级,保持视觉一致性;
  • 自定义变体(max-<bp>:支持“桌面端优先”策略,通过 max-width 实现移动端自适应,样式逻辑更直观。

UnoCSS:轻量、灵活、即时生成

UnoCSS 是一个 按需生成的原子化 CSS 引擎,最大的特点是 零冗余与高度可定制。 不同于 TailwindCSS 的预编译方式,UnoCSS 会在构建阶段根据实际使用的类名即时生成样式规则,从而显著提升构建性能与灵活性.

在配置中通过 presetMini()presetAttributify() 组合使用,使开发者既可以写:

<div class="p-4 text-center bg-gray-100 max-md:p-2"></div>

也可以使用属性化语法:

<div p="4" text="center" bg="gray-100" max-md:p="2"></div>

presetMini 提供轻量原子类体系,presetAttributify 则允许以声明式方式书写样式,更直观、组件化友好。

断点配置与响应式策略

TinyPro 的适配核心之一,是在 uno.config.ts 中建立统一的断点体系,并通过自定义 max-<bp> 前缀实现“桌面端优先”的响应式策略。

const breakpoints = {
  sm: '641px',     // 手机(小屏)
  md: '769px',     // 平板竖屏
  lg: '1025px',    // 平板横屏 / 小型笔电
  xl: '1367px',    // 常规笔电
  '2xl': '1441px', // 高清笔电
  '3xl': '1921px', // 桌面大屏
}

并通过自定义 variants 扩展 max-<bp> 前缀:

variants: [
    (matcher) => {
      const match = matcher.match(/^max-([a-z0-9]+):/)
      if (match) {
        const bp = match[1]
        const value = breakpoints[bp]
        if (!value) return
        return {
          matcher: matcher.replace(`max-${bp}:`, ''),
          parent: `@media (max-width: ${value})`,
        }
      }
    },
  ]

让开发者能自然地书写:

<div class="w-1/2 max-md:w-full"></div>

含义:

默认宽度为 50%,在宽度小于 769px 的设备上改为 100%。

TinyPro 采用「桌面端优先(max-width)」的布局策略:默认以桌面端布局为基础,在移动设备上再进行针对性优化。相比常见的「移动端优先(min-width)」方式,这种做法更符合中后台系统的特性,同时让 UnoCSS 的断点逻辑更直观,并确保主屏体验的稳定性。

三、样式与编码策略

  • 优先级

    • 简单场景:使用 UnoCSS 原子类。
    • 复杂样式:使用 Less 媒体查询。
  • 布局与滚动

    • 首页及核心业务模块完成适配,小屏模式下侧边栏默认收起、导航栏折叠,确保主要内容可见。
    • 页面主要容器避免横向滚动,必要时在小屏下开启局部横向滚动。
    • 表格与大区块在不同断点下自动调整宽度、栅格与间距,小屏下支持横向滚动;分页与密度支持响应式控制。

    布局与滚动.gif

  • 图表自适应

    • 图表组件接入 resize 监听,在侧边栏展开/收起、窗口缩放、语言切换等场景下保持自适应。
    • 小屏下使用 vw 宽度与较小字号,保证图表展示效果与可读性。

    图表自适应.gif

  • 表单与模态框

    • 接入 useResponsiveSize(),控制弹窗在小屏下铺满显示,大屏保持固定宽度。
    • 表单项在不同断点下动态调整排布与间距,优化触控体验。

    表单与模态框.gif

  • 导航与交互

    • 小屏下隐藏导航栏非关键元素,操作聚合到"折叠菜单"。
    • 移动端默认收起侧边菜单栏,提升主要内容展示区域。

    导航与交互.gif

  • 性能优化

    • responsive.ts 中对 resize 事件处理增加节流机制,避免窗口缩放等场景下的频繁无效渲染。

四、常用代码片段

  1. 基于栅格系统 + 响应式断点工具类,通过为 tiny-row 和 tiny-col 添加不同屏幕宽度下的样式规则,实现自适应布局:
<tiny-layout>
    <tiny-row class="flex justify-center max-md:flex-wrap">
        <tiny-col class="w-1/4 max-md:w-1/2 max-sm:w-full max-md:mb-4">···</tiny-col>
        ···
        <tiny-col class="w-1/4 max-md:w-1/2 max-sm:w-full max-md:mb-4">···</tiny-col>
    </tiny-row>
</tiny-layout>

<div class="theme-line flex max-sm:grid max-sm:grid-cols-4 max-sm:gap-2">
  <div···
  </div>
</div>
  1. 基于 响应式工具类 + 自定义响应式 Hook,解决(1)对话框宽度自适应;(2)表格尺寸和密度自适应;(3)逻辑层响应式控制
<template>
  <section class="p-4 sm:p-6 lg:p-8 max-sm:text-center">
    <tiny-dialog :width="modalSize">...</tiny-dialog>
  </section>
</template>

<script setup lang="ts">
import { useResponsiveSize } from '@/hooks/responsive'
const { modalSize } = useResponsiveSize() // 小屏 100%,大屏 768px
</script>
<template>
  <div class="container">
    <tiny-grid ref="grid" :fetch-data="fetchDataOption" :pager="pagerConfig" :size="gridSize" :auto-resize="true" align="center">
      ···
    </tiny-grid>
  </div>
</template>

<script setup lang="ts">
import { useResponsiveSize } from '@/hooks/responsive'
const { gridSize } = useResponsiveSize() // 小屏为mini grid,大屏为medium grid
</script>
  1. 通过 useResponsive 获取屏幕断点状态 sm/md/lg,如:在模板中结合 v-if="!lg" 控制分隔线的渲染,从而实现了小屏下纵向菜单才显示分隔线的效果
<template>
  <ul class="right-side" :class="{ open: menuOpen }">
    <!-- 小屏下才显示分隔线 -->
    <li v-if="!lg">
      <div class="divider"></div>
    </li>
    ···
  </ul>
</template>

<script lang="ts" setup>
import { useResponsive } from '@/hooks/responsive'
const { lg } = useResponsive()
</script>

五、结语

通过本次移动端适配, TinyPro 实现了“从桌面到掌心”的统一体验: 开发者可以继续沿用熟悉的组件体系与布局方式,同时享受 UnoCSS 带来的原子化灵活性与性能优势。在不改变核心架构的前提下,TinyPro 变得更轻盈、更顺滑,也更符合移动时代的使用场景。

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyPro源码:github.com/opentiny/ti…

欢迎进入代码仓库 Star🌟TinyPro、TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

Dialog组件状态建模规则

本文所说的组件状态建模规则,特别适用于:Dialog 生命周期长、渲染早于数据的组件

核心设计目标

UI 状态建模(template)的第一目标不是语义最精确,而是结构稳定、可渲染、可推导

简单说,template绑定的变量初始值不能为undefined或者null,最好是预定义的空模板。

二、基础概念划分(这是地基)

区分三种“状态层级”

层级 例子 规则
UI 结构状态 表单字段、列表项、dialog 内容 必须结构稳定
UI 行为状态 visible / loading / disabled 可 boolean / enum
业务数据状态 接口返回对象 可 null / undefined

template建模只会和UI结构状态和行为状态有关,和业务数据状态无关。

三、最重要的规则(90% 的坑在这里)

规则 1:**template 绑定的数据,禁止null,推荐属性确定的空数据结构

不推荐

const element = ref(null)
{{ element.id }}

推荐

const element = ref({
  id: '',
  name: '',
})

理由不是“防报错”这么简单,而是:

render / computed / watch(immediate)
会在“业务数据尚未准备好”之前运行

规则 2:null 表示“概念不存在”,而 UI 中很少真的“不存在”

状态 推荐建模
UI状态尚未准备好 空的属性确定的数据结构
业务对象不存在 null
接口失败 error state

四、关于 computed / watch 的建模规则

规则 3:template绑定的computed = 一开始就要有稳定的数据结构

computed从undefined或者null变化为{id:'xxx'},这就称作不稳定

// 不稳定
const id = computed(() => props.element.id)

稳定方案一(首选)

props.element = { id: '' }

稳定方案二(兜底)

const id = computed(() => props.element?.id ?? '')

方案二是 防御,不是建模优雅

规则 4:watch(immediate) 必须当作“setup 同步代码”对待

watch(
  () => props.element,
  (el) => {
    // 这里 ≈ setup 中直接访问
  },
  { immediate: true }
)

所以规则是:

凡是会被 watch(immediate) 读取的数据
都必须在 setup 结束前是安全的

安全的意思是watch的回调函数中需要用guard子句排除到props.element是undefined或者null这种情况。不然会报错。

规则 5:composable 永远假设“调用方是不可靠的”

useSomething(element)

composable 内部必须:

  • guard 参数
  • 不假设结构存在
  • 不信任生命周期顺序
if (!element || !element.id) return

这是 composable 的防御职责 如果你在组件内部写满 if (!xxx) return
那说明状态模型有问题

规则 6:弹框类组件 = 提前存在,延后可见

visible = false // 控制显示
element = {id:"", ...}    // 内容占位

不要用 visible = false 的同时element=ref(null)

这里又一次说明null和空数据结构的区别:null表示不存在,空数据结构表示存在,但内容未准备好。不存在的就不能正常渲染,空的数据结构是可以正常渲染的。

相关知识

vue组件首次渲染执行任务顺序 vue列表渲染设计决策

Vue 2 与 Vue 3 的全面对比分析

Vue 2 和 Vue 3 是 Vue 框架的两个主要版本,Vue 3 在性能、架构、开发体验和类型支持等方面做了重大升级,是 Vue 官方推荐的长期维护版本。

以下是 Vue 2 与 Vue 3 的全面对比分析,涵盖核心差异、新特性、迁移建议,帮助你快速掌握两者区别。


🌟 一、核心区别概览

对比项 Vue 2 Vue 3
底层实现 基于 Object.defineProperty(响应式) 基于 Proxy(响应式)
组件语法 选项式 API(data, methods, computed 支持 组合式 API(Composition API) + 选项式 API
性能 一般 ⭐⭐⭐⭐⭐(提升 30%~50%)
Tree Shaking 不支持 ✅ 支持(按需引入,体积更小)
TypeScript 支持 一般 ✅ 原生支持(官方推荐)
多根组件 ❌ 不支持 ✅ 支持(<template> 可以有多个根节点)
生命周期钩子 beforeCreate, created, mounted 新增 onMounted, onBeforeUnmount 等(组合式写法)
全局 API Vue.use(), Vue.component() 改为 createApp() 全局创建应用
构建工具 Vue CLI 支持 Vite(默认推荐)
生态 成熟但逐渐淘汰 新一代生态(如 @vueuse, Vite

🧩 二、关键差异详解

1. ✅ 响应式系统:Proxy 代替 Object.defineProperty

项目 Vue 2 Vue 3
响应式原理 Object.defineProperty Proxy
限制 无法检测新增/删除属性,无法监听数组索引变化 支持动态添加/删除属性,支持监听数组索引
示例:动态添加属性
// Vue 2 ❌ 无法响应
this.obj.newProp = 'hello'; // 不会触发视图更新

// Vue 3 ✅ 可响应
this.obj.newProp = 'hello'; // ✅ 视图会更新

🔍 优势:Vue 3 的响应式系统更强大、更灵活、更高效。


2. ✅ 组合式 API(Composition API)——核心升级

✅ Vue 2:选项式 API(逻辑分散)

<!-- Counter.vue (Vue 2) -->
<template>
  <div>
    <p>计数: {{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

<script>
export default {
  data() {
    return { count: 0 };
  },
  methods: {
    increment() {
      this.count++;
    }
  },
  computed: {
    doubleCount() {
      return this.count * 2;
    }
  },
  watch: {
    count(newVal) {
      console.log('count changed:', newVal);
    }
  }
};
</script>

❗ 问题:逻辑按“选项”拆分,复用困难。


✅ Vue 3:组合式 API(逻辑聚合)

<!-- Counter.vue (Vue 3) -->
<template>
  <div>
    <p>计数: {{ count }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

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

// 响应式数据
const count = ref(0);

// 方法
const increment = () => {
  count.value++;
};

// 计算属性
const doubleCount = computed(() => count.value * 2);

// 监听
watch(count, (newVal) => {
  console.log('count changed:', newVal);
});
</script>

✅ 优势:

  • 逻辑按功能组织(如“计数逻辑”集中在一个地方)
  • 更易复用(可通过 useCount() 封装)
  • 更适合大型项目

3. ✅ 支持多根组件(Multi-root Components)

<!-- Vue 3 支持 -->
<template>
  <header>头部</header>
  <main>主体</main>
  <footer>底部</footer>
</template>

✅ Vue 2:必须包裹一个根 <div>,否则报错。


4. ✅ 新的 setup 语法糖(<script setup>

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

const count = ref(0);
</script>

✅ 优势:

  • 无需 export default,代码更简洁
  • 自动暴露变量给模板使用
  • vite 配合完美

5. ✅ 更好的 TypeScript 支持

// Vue 3 支持类型推导
const count = ref<number>(0); // 类型安全
const doubleCount = computed(() => count.value * 2);

✅ Vue 3 官方构建工具(Vite)和 IDE(VSCode)对 TS 支持极佳。


6. ✅ 支持 Tree Shaking(按需引入)

Vue 3 采用模块化设计,支持 Tree Shaking,只打包用到的代码。

// 只引入需要的 API
import { ref, reactive } from 'vue';

⚠️ Vue 2:Vue 对象是全局的,无法按需打包。


7. ✅ 全局 API 改为 createApp()

// Vue 2
Vue.use(Vuex);
Vue.component('MyComp', MyComp);
Vue.config.productionTip = false;

// Vue 3
import { createApp } from 'vue';
const app = createApp(App);
app.use(Vuex);
app.component('MyComp', MyComp);
app.mount('#app');

✅ 更清晰、更可控,支持多个应用实例。


🔄 三、迁移建议(从 Vue 2 → Vue 3)

步骤 说明
1. 使用 vue-next 工具 npm install -g @vue/cli + vue upgrade
2. 检查兼容性 使用 Vue 3 Migration Helper
3. 逐步替换语法 优先使用 setup + Composition API
4. 升级依赖 vuexvuex@nextvue-routervue-router@4
5. 测试 重点测试 v-model$refs$emit 等用法变化

✅ 推荐:新项目直接使用 Vue 3 + Vite + TypeScript


📌 四、适用场景建议

场景 推荐版本
新项目开发 Vue 3(强烈推荐)
老项目维护 ✅ 逐步迁移到 Vue 3
快速原型开发 ✅ Vue 3 + Vite
团队有 Vue 2 经验 ✅ 用 setup 语法过渡学习
需要极致性能 ✅ Vue 3(响应式 + Tree Shaking)

✅ 总结:一句话对比

维度 Vue 2 Vue 3
响应式 Object.defineProperty Proxy(更强)
语法 选项式 API 支持组合式 API + setup
性能 一般 ⭐⭐⭐⭐⭐(提升显著)
类型支持 一般 ✅ 原生支持 TypeScript
构建 Vue CLI ✅ Vite(默认推荐)
生态 成熟但逐渐淘汰 新一代生态(如 @vueuse

🎯 最终建议:

新项目一律使用 Vue 3,尤其是:

  • 需要高性能
  • 有 TypeScript 需求
  • 团队希望代码更可维护、可复用

Vue和React的全面对比分析

Vue 和 React 是当前前端开发中最主流的两个框架(或库),它们在设计理念、语法结构、生态和开发体验上既有相似之处,也有显著差异。以下是 Vue 与 React 的全面对比分析,帮助你理解两者的核心区别,适用于项目选型与技术决策。


🌟 一、核心定位差异

项目 Vue React
类型 渐进式前端框架 UI 库(视图层库)
定位 提供完整解决方案(模板 + 组件 + 路由 + 状态管理) 仅关注“视图层”,需搭配其他库使用
是否必须搭配其他库 否(可独立使用) 是(需搭配 React Router、Redux、Zustand 等)

简单理解

  • Vue 像“全栈式框架”,开箱即用。
  • React 像“乐高积木”,你需要自己拼装。

📚 二、语法与开发方式对比

对比项 Vue(2.x / 3.x) React(函数式 + JSX)
模板语法 支持 HTML 模板 + 指令(v-if, v-for, v-model 使用 JSX(JavaScript + HTML 混合)
组件结构 单文件组件 .vue(含 <template>, <script>, <style> 通常为 .js.tsx 文件
数据绑定 双向绑定(v-model 单向数据流 + 手动 setStateuseState
状态管理 内置 datacomputedwatch;可选 Vuex / Pinia 依赖外部状态库(Redux、MobX、Zustand)
生命周期 选项式 API(mounted, updated)或组合式 API(setup useEffect, useRef, useState 等 Hook

示例对比:显示一个计数器

✅ Vue 2.x(选项式 API)

<!-- Counter.vue -->
<template>
  <div>
    <p>计数: {{ count }}</p>
    <button @click="count++">+1</button>
  </div>
</template>

<script>
export default {
  data() {
    return { count: 0 };
  }
};
</script>

✅ React(函数式 + Hook)

// Counter.jsx
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>计数: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

export default Counter;

🔍 对比点

  • Vue 更贴近 HTML,适合前端新手。
  • React 更“JavaScript 化”,适合有 JS 基础的开发者。

🧩 三、开发体验对比

维度 Vue React
学习成本 ⭐⭐⭐⭐☆(低) ⭐⭐⭐☆☆(中)
开发效率 ✅ 高(模板 + 双向绑定) ✅ 高(JSX + Hook)
可维护性 ✅ 好(单文件组件 + 模板清晰) ✅ 好(逻辑可复用,Hook 强大)
调试工具 ✅ DevTools 强大,支持组件树、状态追踪 ✅ DevTools 支持良好,但需额外配置
类型支持 ✅ Vue 3 支持 TypeScript(推荐) ✅ 原生支持 TypeScript(更成熟)

🛠 四、生态系统对比

项目 Vue React
官方工具 Vue CLI、Vite、Vite Plugin Create React App、Vite、Next.js
路由 Vue Router React Router
状态管理 Vuex、Pinia(官方推荐) Redux、MobX、Zustand、Recoil
UI 库 Element Plus、Naive UI、Ant Design Vue Ant Design、Material UI、Chakra UI
SSR 支持 Nuxt.js(官方) Next.js(极强)
移动端开发 Vue Native、uni-app(跨平台) React Native

亮点

  • Vue 的 uni-app 支持 一套代码多端运行(H5、小程序、App)。
  • React 的 Next.js 在服务端渲染(SSR)、SEO、静态站点生成方面非常强大。

📈 五、适用场景推荐

场景 推荐技术
快速搭建后台管理系统 ✅ Vue(Element Plus + Vue Router)
企业级大型应用(中后台) ✅ React(Next.js + Redux/Zustand)
小程序 / 跨平台 App ✅ Vue(uni-app)或 React(Taro)
高性能、复杂交互应用(如社交平台) ✅ React(React + TypeScript + Zustand)
新手入门 / 快速原型开发 ✅ Vue(语法直观,易上手)

📌 总结:一句话对比

维度 Vue React
风格 模板驱动 + 渐进式 JavaScript 驱动 + 函数式
学习曲线 平缓 中等偏上
灵活性 中等 极高
生态成熟度 良好 极强
社区活跃度 高(尤其国内) 极高(全球)

✅ 选择建议

你的需求 推荐技术
快速开发、团队协作、中后台系统 Vue
构建大型 SPA、需 SSR、SEO 优化 React + Next.js
跨平台开发(App + 小程序) Vue(uni-app)
想深度掌控代码、拥抱函数式编程 React
项目团队有 JS 基础、追求极致性能 React

vue首次渲染完整时间线

错误的预判: watch(immediate)只有在openDialog事件执行之后,v-model变为true的时候才会执行回调。

一、watch(immediate)的执行时机

阶段 发生的事
setup 创建响应式、watch、computed
watch(immediate) 就在 setup 过程中执行
render 读取 template 表达式
beforeMount 准备挂载
mounted DOM 已经插入

👉 watch(immediate) 比 render 还早

所以:

  • 不受 v-if 影响
  • 也不等 DOM

下面的行为都不受组件内部的v-if的影响

行为 执行阶段
watch(immediate) setup
模板中 {{ xxx }} 被访问 render 阶段
v-for source 被读取 render 阶段

二、组件内部的v-if和组件外部的v-if的本质区别

情况 A:v-if 在组件外部(控制组件是否存在)

<Child v-if="show" />

show === false 时:

项目 是否发生
创建组件实例
执行 setup
注册 watch
render template
读取任何表达式

👉 Child 在运行期完全不存在

情况 B:v-if 在组件内部(控制局部渲染)

<template>
  <div v-if="show">
    {{ foo }}
  </div>
</template>

show === false 时:

项目 是否发生
创建组件实例
执行 setup
watch(immediate)
render() 调用
读取 foo ❌(因为分支不走)

👉 组件存在,但该分支在 render 中被跳过

结论

  • 外部的v-if,整个组件的实例都不存在,setup阶段都不会执行
  • 内部的v-if, 只有v-if的部分的render不会执行,其余的操作都会执行。

vue首次渲染

Vue 3 组件首次渲染完整时间线

顺序 官方钩子 / 阶段 发生的事
1 setup 初始化状态、注册 watch / computed
2 watch(immediate) 在 setup 执行期间立即触发
3 render(内部) 执行 render 函数,读取 template 表达式
4 onBeforeMount DOM 挂载前
5 mount(内部过程) 创建并插入真实 DOM
6 onMounted DOM 已插入页面

👉 这里的「mount」是内部过程,不是钩子

Vue 中后台表格选型(Element/VXE/AntD):我在真实项目里踩过的坑,比 Demo 多得多

时间:2026/01/22

如果你的项目出现过这些情况:

  • 表格一加固定列就开始样式错位
  • demo 跑得完美,上线后不断改 bug
  • 合并单元格 + 虚拟滚动总会存在样式问题或者性能问题

别怀疑自己代码水平,90% 是选型问题。

在 Vue 生态里,表格组件的真正难点从来不是"有没有某个功能",而是:

当固定列、多级表头、单元格合并、虚拟滚动这些能力叠加时,它还能不能稳定工作。

这篇文章基于真实业务测试,对比 Element Plus / VXE / Ant Design Vue / TanStack 四大方案,只讲工程实践,不堆功能清单

一、结论前置:不同场景怎么选

场景 推荐方案 原因
小中型项目 + 追求稳定交付 Element Plus Table / Ant Design Vue 默认观感稳定,开箱即用
大数据量(1k 行以上) VXE Grid / Table V2 内建虚拟滚动,性能无压力
高度定制 + 团队能力强 TanStack Table+TanStack Virtual headless 架构,完全自主可控
表格是核心业务 + 复杂交互 VXE Grid 企业级能力最完整

一个底线必须明确:

一旦业务出现「合并 + 固定 + 虚拟滚动」组合,选型阶段不谨慎,后期一定持续返工。

二、真正拉开差距的 6 个关键点

1️⃣ 默认观感:不是"好看",而是"稳定"

很多人评价表格只看 UI 美不美,但工程上更重要的是:默认状态能不能直接上线

  • Element Plus / Ant Design Vue:边框、hover、斑马纹开箱即用,不需要额外调样式
  • VXE Grid:默认不启用 hover 高亮,新手容易误判为"交互不完整"
  • TanStack:完全 headless,所有样式自己写

个人测试中: 同样的需求,用 Element Plus 1 天交付,用 TanStack 可能要 3 天才能调好样式。

如果团队 UI 能力有限,默认观感稳定比可定制性更重要。

2️⃣ 滚动条体验:最容易被低估的致命伤

在这些场景组合下:

  • 固定表头 + 固定右列 + 纵向滚动

滚动条的同步性、对齐度、视觉一致性会直接影响可用性。

个人测试结论:

  • Element Plus 的 Scrollbar 在复杂固定列场景下UI最好
  • Ant / VXE 的滚动条看起来怪怪的,特别是表头

表格组件最容易被低估的不是功能,而是滚动条体验。

3️⃣ 虚拟滚动:1k 行数据的生存线

当数据量达到 1000 行以上

  • Element Plus Table / Ant Design Vue Table 已明显卡顿
  • 是否支持虚拟滚动,直接决定组件还能不能继续用
方案 行虚拟滚动 列虚拟滚动
Element Plus Table
Element Plus Table V2
Ant Design Vue Table
VXE Grid
TanStack Table 需接入 @tanstack/virtual 需接入 @tanstack/virtual

⚠️ Table V2 的坑:虚拟滚动开启后,单元格合并、固定列的组合行为会出现意外 bug。

虚拟滚动不是加分项,而是复杂中后台表格的生存线。

4️⃣ 单元格合并:真正难的是"组合行为"

合并本身不难实现,难的是它要和这些能力同时存在:

  • hover 高亮
  • 行选中 / 多选
  • 固定列边框

典型失败表现:

  • hover 背景不协调(合并区域的子单元格没有高亮)
  • 合并区域选中时复选框异常

个人测试中:

  • Element Plus Table:用 span-method,小规模场景稳定
  • Ant Design Vue也很nice
  • Table V2:简单 demo 没问题,复杂组合会集中暴露 bug
  • VXE Grid:内建合并能力最完善,边界情况处理最好
  • 行选中同单元格一起合并都不支持

如果你的表格需要「合并 + 固定列 + 虚拟滚动」同时存在,务必先做完整测试再选型。

5️⃣ 树形表格 + 懒加载:

树形表格看起来简单,但要支持懒加载 + 展开状态管理

  • Ant Design Vue:官方不支持树表懒加载(这是硬伤)
  • 其他方案:element-plus和vex内建支持

6️⃣ 列筛选:复杂时应该"脱离表头"

大部分表格组件的列筛选都只支持:

  • 简单选项列表
  • 单个输入框

当筛选条件开始出现:

  • 日期范围选择
  • 多条件联动(如:省市区联动)
  • 复杂的数值区间

更合理的做法是:独立查询区,而不是死磕表头。

方案 内建筛选能力
Element Plus Table 仅选项列表
Ant Design Vue Table 内建筛选 + 自定义
VXE Grid 筛选能力强,但复杂时需额外 UI 库
TanStack 完全自研

三、完整对比表(供选型参考)

维度 Element Plus Table Element Plus Table V2 Ant Design Vue Table VXE Grid TanStack Table
默认观感 ✅ 稳定 ✅ 稳定 ✅ 稳定(Ant 风格) ⚠️ hover 默认未开 ❌ 完全自研
滚动条体验 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐(自研)
行虚拟滚动 需接入 virtual
列虚拟滚动 需接入 virtual
单元格合并 span-method ⚠️ 坑多 rowSpan / colSpan ✅ 内建 自研
树形 + 懒加载 需自研 ❌ 不支持 自研
列筛选 仅选项 自研 内建 内建 自研
个性化列 自研 自研 自研 内建 toolbar 状态内建

四、工具链落地:从选型到上线

第一步:验证核心组合场景

不要只跑 demo,必须测试这些组合:

  1. 固定列 + 多级表头 + 单元格合并
  2. 虚拟滚动 + 树形结构 + 懒加载
  3. 1000 行数据 + 筛选 + 排序

测试清单:

// 1. 固定列对齐
- 横向滚动时左右固定列是否有阴影/边框
- 滚动条是否同步
- hover 高亮是否完整

// 2. 单元格合并
- 合并区域 hover 是否错位
- 固定列边框是否断裂
- 选中逻辑是否正常

// 3. 虚拟滚动
- 快速滚动时是否白屏
- 固定列是否错位
- 合并单元格是否异常

阶段总结

验证完这些组合场景,至少能排除 50% 的不适配方案。

第二步:评估团队能力与时间成本

团队情况 推荐方案
前端 2-3 人,追求快速交付 Element Plus / Ant Design Vue
有专职 UI 开发,追求定制化 TanStack + 自研
表格是核心业务,需要企业级能力 VXE Grid
大数据量 + 性能要求高 Table V2 / VXE Grid

真实项目经验:

  • 用 Element Plus Table和Ant Design Vue 做普通后台,1 周交付
  • 用 TanStack 做同样需求,3 周才稳定(样式 + 交互全自研)
  • 用 VXE Grid 做复杂报表,2 周交付(但学习成本稍高)

阶段总结

选型不仅是技术问题,更是时间成本 + 团队能力的权衡。

第三步:建立组件封装规范

无论选哪个方案,都要做二次封装:

<!-- 错误示范:直接用原始组件 -->
<el-table :data="tableData" ...>
  <el-table-column prop="name" .../>
</el-table>

<!-- 正确示范:封装业务组件 -->
<business-table
  :columns="columns"
  :data-source="dataSource"
  :row-key="rowKey"
/>

封装收益:

  • 统一默认配置(如 hover / 边框 / 斑马纹)
  • 统一 loading / error 处理
  • 统一分页逻辑
  • 后续替换方案成本低

阶段总结

二次封装不是过度设计,而是降低后期返工成本的必要手段。

五、如果让我重新选型

基于真实项目经验,我会这样选:

场景 1:普通中后台(CRUD 为主)

  • 首选:Element Plus TableAnt Desing Vue
  • 理由:稳定、文档全、生态好、招人容易

场景 2:数据量大(1k 行以上)

  • 首选:VXE Grid
  • 理由:虚拟滚动稳定、企业级能力完整

场景 3:高度定制(如数据可视化平台)

  • 首选:TanStack Table
  • 理由:headless 架构,完全可控

场景 4:预算充足 + 复杂交互

  • 可考虑:AG Grid(付费版)
  • 理由:企业级方案最成熟(但本文不展开)

场景5:在已有Element Plus或者Ant Design Vue的情况下,需要处理大数据

  • 可考虑再接入VXE Gid,甚至可能还要接入完整的Vxe UI,此外还要评估带来的css的副作用

但有一个底线:

一旦出现「合并 + 固定 + 虚拟滚动」组合,务必先做完整测试。 选型阶段省的时间,后期会加倍还回来。

六、常见问题速查

Q1:Table V2 和 VXE Grid 怎么选?

  • Table V2:Element 生态统一,学习成本低,但复杂组合有坑
  • VXE Grid:企业级能力最完整,但学习曲线陡、文档不如 Element 友好

Q2:一定要用虚拟滚动吗?

  • 数据量 < 500 行:不需要
  • 数据量 500-1000 行:建议用
  • 数据量 > 1000 行:必须用

Q3:TanStack 适合新手吗?

不适合。它是"表格引擎",不是"表格组件",所有 UI 要自己写。

Q4:已经用了不合适的方案,怎么办?

  • 如果只是样式问题:二次封装兜底
  • 如果是能力缺失:评估迁移成本,必要时重构
  • 如果是性能问题:优先上虚拟滚动或分页

参考链接

在线示例: astonishing-peony-a9d523.netlify.app 源码仓库: github.com/parade0393/…

最后提醒:

表格选型没有"最好",只有"最合适"。

但如果你不想后期持续返工,选型阶段多花 2 天做完整测试,绝对值得。

欢迎在评论区分享你踩过的坑 👇

Vue2 后台管理系统整体缩放方案:基于 pxtorem 的最佳实践

使用 pxtorem 实现 Vue2 后台管理系统整体布局缩放的完整方案

适用场景:Vue2 + Element UI / Ruoyi 等传统后台管理系统
核心目标:在不破坏组件定位、不影响弹窗/浮层体验的前提下,实现“系统级整体缩放”


一、背景说明

笔者正在开发一款公司后台管理系统,由于该系统中的表单、表格数据字段十分庞大,默认的布局UI放不下那么多字段,用户在使用时经常需要缩放页面。所以这里我想能不能实现系统级的整体项目缩放,从而让用户使用更舒服

在后台管理系统中,表单字段多、表格列密集是非常常见的场景。随着业务复杂度提升,常规的布局方式逐渐暴露出几个明显问题:

  • 页面横向空间不足,需要频繁左右滚动
  • 浏览器缩放会影响字体清晰度与组件交互体验
  • UI 组件在缩放后容易出现错位(尤其是弹窗、下拉框)

用户的真实诉求并不是“放大字体”,而是“在同一屏内看到更多信息”

二、效果对比

默认布局 UI

字段密集,信息承载能力有限

默认布局


缩放后的布局 UI

在不改变业务结构的前提下,同屏可展示更多字段,尤其在多列表格场景中效果明显

缩放布局


三、方案选型与思路分析

在实现过程中,我一共尝试过 两种思路

❌ 方案一:CSS transform: scale() 整体缩放

实现方式

  • html / body 外包裹一层 wrapper
  • 使用 transform: scale() 对整个页面进行缩放

存在的问题

  • position: fixed / absolute 定位全部失真
  • ❗ Element UI 的 Dialog / Popover / Select 等组件定位错误
  • ❗ 实际缩放的是“视觉”,而非“布局”

结论:该方案不适合复杂后台系统,仅适用于展示型页面。


⭐ 方案二:postcss-pxtorem + 动态 rem(最终方案)

核心思想

  • 将整个项目中的 px 统一转为 rem,再通过动态修改根节点 font-size,实现真正意义上的“布局缩放”。

四、原理解析

4.1 为什么 pxtorem 在 Vue2 中可行?

在 Vue2 项目中:

  • UI 框架(如 Element UI)样式大多来源于 静态 CSS 文件
  • 构建阶段通过 Webpack + PostCSS 统一处理样式

这使得我们可以在 编译阶段

px  →  rem

再在 运行阶段

动态调整 html { font-size }

从而达到:一次转换,全局缩放


五、具体实现步骤

⚠️ 本方案 仅适用于 Vue2 项目
Vue3(如 Element Plus / Naive UI)因大量使用 JS 动态注入样式,不完全适用


5.1 安装依赖

npm install postcss-pxtorem@5.1.5 --save-dev

✅ 推荐使用 5.1.5,与 Vue2 + Webpack 兼容性最佳


5.2 配置 vue.config.js

module.exports = {
  css: {
    loaderOptions: {
      postcss: {
        plugins: [
          require('postcss-pxtorem')({
            rootValue: 16,              // 1rem = 16px(基准值)
            propList: ['*'],            // 转换所有属性
            selectorBlackList: [],      // 不参与转换的选择器
            minPixelValue: 0,           // 0px 以上全部转换
            exclude: /node_modules/i,   // 忽略 node_modules(可按需调整)
          })
        ]
      }
    }
  }
}

实践建议

  • 如果希望 UI 库也参与缩放,可移除 exclude
  • 或使用白名单方式精细控制
  • 关于参数的更多信息您可以直接访问官方文档:npm:postcss-pxtorem

5.3 在 main.js 中动态控制缩放比例

const html = document.documentElement
html.style.fontSize = '8px'  // 1rem = 8px(整体缩小 50%)

🔷 另外,在5.3的main.js中配置时,你也可以直接设置rootValue32,表示将每32px按照1rem的格式进行转换,和第3步的实现效果是一样的,可以按照需求选择

六、Ruoyi 动态主题不生效问题

6.1 问题原因

Ruoyi 的主题切换逻辑:

  • 使用 JS 动态生成 CSS
  • 直接插入到 <style> 标签中
  • 绕过了 PostCSS 编译阶段

导致:

部分样式仍然是 px,缩放失效


6.2 解决方案(核心代码)

src/components/ThemePicker/index.vue 中:

updateStyle(style, oldCluster, newCluster) {
  let newStyle = style

  oldCluster.forEach((color, index) => {
    newStyle = newStyle.replace(new RegExp(color, 'ig'), newCluster[index])
  })

  // 新增代码补丁:将 px 转为 rem , 这里的32就是同设置rootValue为32一样的道理,缩放1/2
  newStyle = newStyle.replace(/(\d+(.\d+)?)px/g, (match, p1) => {
    return (parseFloat(p1) / 32).toFixed(5) + 'rem'
  })

  return newStyle
}

七、总结建议

✅ 本方案的优势

  • 真正的“系统级布局缩放”
  • 不影响 Element UI 弹窗、浮层定位
  • 对业务代码零侵入

❌ 局限性

  • 不适合 Vue3 动态样式体系
  • 对第三方动态注入样式需额外处理

📌 适用场景

  • Vue2 后台管理系统
  • 表格字段密集型系统
  • 对信息密度有较高要求的中后台项目

一行代码解决文本溢出提示:Vue 3 + Element Plus 打造智能 v-ellipsis-tooltip 指令

前言

在 B 端业务开发中,表格和列表是出现频率极高的场景。我们经常遇到这样的需求: “当文本内容过长导致显示省略号时,鼠标悬停显示完整内容的 Tooltip;如果文本未溢出,则不显示 Tooltip。”

通常的做法是:

  1. 给元素设置 CSS 省略样式。
  2. 套一层 el-tooltip
  3. 通过 disabled 属性控制是否显示。

但是,手动计算 disabled 状态非常繁琐,需要获取 DOM 元素判断 scrollWidth > clientWidth,如果在表格中使用,每个单元格都要写一套逻辑,代码重复率极高且难以维护。

今天,我们来封装一个 Vue 3 自定义指令 v-ellipsis-tooltip,彻底解决这个问题。

核心思路

我们的目标是实现一个指令,挂载到元素上即可自动检测溢出并挂载 Tooltip。

核心步骤如下:

  1. 检测溢出:比较元素的 scrollWidthclientWidth
  2. 动态渲染:如果溢出,使用 Vue 的 h 函数和 render 函数动态创建一个 ElTooltip 组件。
  3. 状态管理:使用 WeakMap 存储每个 DOM 元素对应的 Tooltip 实例和状态,防止内存泄漏。
  4. 响应式更新:利用 ResizeObserver 监听元素尺寸变化,实时更新 Tooltip 状态。

代码实现

以下是完整的指令实现代码。注意项目中使用了 unplugin-auto-import,所以 hDirectiveBinding 等 API 是自动导入的。如果你没有配置自动导入,请手动补充 import。

import type { ElTooltipProps } from 'element-plus'
import type { Directive, DirectiveBinding } from 'vue'
import { ElTooltip } from 'element-plus'
import { render, h } from 'vue' // 如果没有自动导入,需要手动引入 h

type TooltipValue = string | (Partial<ElTooltipProps> & { content?: string, observe?: boolean })

interface TooltipContext {
  container: HTMLElement
  binding: DirectiveBinding<TooltipValue>
  observer?: ResizeObserver
}

// 使用 WeakMap 存储上下文,避免直接修改 DOM 对象类型和使用 any
const contextMap = new WeakMap<HTMLElement, TooltipContext>()

/**
 * 核心渲染逻辑:根据溢出状态和配置渲染 Tooltip
 */
const renderTooltip = (el: HTMLElement, binding: DirectiveBinding<TooltipValue>) => {
  const { value, instance } = binding
  // 1. 检测溢出
  // scrollWidth > clientWidth 说明水平方向溢出
  // scrollHeight > clientHeight 说明垂直方向溢出(针对多行省略场景)
  const isOverflow = el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight

  // 2. 解析配置
  let content = ''
  let props: Partial<ElTooltipProps> = {}

  if (typeof value === 'string') {
    content = value
  }
  else if (value && typeof value === 'object') {
    content = value.content ?? ''
    props = value
  }

  // 如果没有提供 content,回退到元素文本
  if (!content) {
    content = el.textContent || ''
  }

  // 3. 创建 Virtual Tooltip
  // 利用 Element Plus 的 virtualTriggering 能力,将 Tooltip 绑定到当前元素
  const vnode = h(ElTooltip, {
    virtualTriggering: true,
    virtualRef: el,
    placement: 'top',
    ...props,
    content,
    disabled: props.disabled ?? !isOverflow, // 优先使用用户配置,否则根据溢出状态自动控制
  })

  // 注入上下文以继承全局配置(如 Element Plus 的 ConfigProvider)
  if (instance && instance.$) {
    vnode.appContext = instance.$.appContext
  }

  // 4. 渲染到内存中的 container
  const ctx = contextMap.get(el)
  if (ctx) {
    render(vnode, ctx.container)
  }
}

/**
 * 管理 ResizeObserver 的启用/禁用
 */
const manageObserver = (el: HTMLElement, binding: DirectiveBinding<TooltipValue>, ctx: TooltipContext) => {
  // 支持通过指令值或修饰符开启监听
  const shouldObserve = (typeof binding.value === 'object' && binding.value?.observe) || binding.modifiers.observe

  if (shouldObserve) {
    if (ctx.observer) {
      return
    }
    // 当元素尺寸变化时,重新检测溢出状态
    ctx.observer = new ResizeObserver(() => renderTooltip(el, ctx.binding))
    ctx.observer.observe(el)
  }
  else {
    if (!ctx.observer) {
      return
    }
    ctx.observer.disconnect()
    ctx.observer = undefined
  }
}

export const vEllipsisTooltip: Directive<HTMLElement, TooltipValue, 'observe'> = {
  mounted(el: HTMLElement, binding: DirectiveBinding<TooltipValue>) {
    const ctx: TooltipContext = {
      container: document.createElement('div'), // 创建一个游离的 div 作为渲染容器
      binding,
      observer: undefined,
    }
    contextMap.set(el, ctx)

    manageObserver(el, binding, ctx)
    renderTooltip(el, binding)
  },

  updated(el: HTMLElement, binding: DirectiveBinding<TooltipValue>) {
    const ctx = contextMap.get(el)
    if (!ctx) {
      return
    }
    ctx.binding = binding
    manageObserver(el, binding, ctx)
    renderTooltip(el, binding)
  },

  beforeUnmount(el: HTMLElement) {
    const ctx = contextMap.get(el)
    if (!ctx) {
      return
    }
    ctx.observer?.disconnect()
    render(null, ctx.container) // 卸载组件,触发 unmounted 生命周期
    contextMap.delete(el)
  },
}

使用方法

1. 基础用法

最简单的场景,直接加上 v-ellipsis-tooltip。注意元素本身需要有 CSS 省略样式(overflow: hidden; text-overflow: ellipsis; white-space: nowrap;)。

<div 
  class="truncate w-200px" 
  v-ellipsis-tooltip
>
  这段文字很长很长,如果超出会显示省略号,并且鼠标悬停会有 Tooltip。
</div>

2. 自定义内容

如果你希望 Tooltip 显示的内容与元素文本不同,可以传入字符串。

<div 
  class="truncate w-200px" 
  v-ellipsis-tooltip="'这是自定义的 Tooltip 内容'"
>
  显示的文本...
</div>

3. 传递 Element Plus Props

需要配置 placementeffect 等属性时,传入对象即可。

<div 
  class="truncate w-200px" 
  v-ellipsis-tooltip="{ 
    content: '深色主题提示', 
    effect: 'dark', 
    placement: 'bottom' 
  }"
>
  显示的文本...
</div>

4. 响应式监听 (ResizeObserver)

如果容器宽度是动态变化的(例如拖拽改变列宽),普通的检测可能只在 mounted 时生效。加上 .observe 修饰符,让指令监听元素尺寸变化,实时更新 Tooltip 状态。

<div 
  class="truncate" 
  style="width: 50%"
  v-ellipsis-tooltip.observe
>
  宽度变化时会自动重新计算是否溢出
</div>

遇到的坑与细节

  1. Context 丢失问题:在使用 render 函数手动渲染组件时,新组件会丢失当前的 appContext,导致无法获取全局配置(如 Element Plus 的 localez-index 配置)。解决方案是将 vnode.appContext 指向 instance.$.appContext
  2. Virtual Triggering:Element Plus 的 ElTooltip 支持 virtual-triggering 模式,这使得我们可以不改变 DOM 结构,直接将 Tooltip 逻辑附加到现有元素上,非常适合指令封装。
  3. 内存泄漏:一定要在 beforeUnmount 中销毁 ResizeObserverrender(null, container),并清理 WeakMap

总结

通过这个指令,我们成功将“溢出检测”与“Tooltip 显示”逻辑解耦,保持了模板的整洁。在表格、卡片列表等密集展示数据的场景下,极大地提升了开发效率和用户体验。


希望这篇文章对你有帮助!如果觉得有用,请点赞收藏支持一下~

Vue 列表渲染设计决策表(v-for source)

使用场景:

  • v-for
  • computed → v-for

一、v-for 数据源设计(最核心)

问题 必须满足 正确做法 错误做法 后果
v-for 的 source 初始值是什么? 必须可遍历 [] / {} / Map() undefined / null / false 列表语义无法建立
source 的类型是否稳定? 类型不可跃迁 [] → [...] undefined → [] diff 通道缺失
是否依赖 ?. 比如 v-for = listItem in object?.list object?.list不能是undefined 初始值:object=reactive({list:[]}) 初始值:object=ref(undefined) 列表语义无法建立

二、computed + v-for 决策

场景 推荐 不推荐 原因
computed 作为 v-for source 返回 [] 返回 undefined undefind会导致未建立列表语义结构
computed 内部判空 ?. ?? [] if (!x) return 避免短路,短路会导致v-for source为undefined
computed 首次执行 访问完整结构 return {list:[]}这样的预定义稳定结构 v-for source只会在首次建立列表语义结构,即使source值变化了,也不会再重新建立语义结构
v-for 绑定 computedList computed?.list v-for source不能依赖?.,因为可能返回undefined,会导致未定义列表结构语义

三、看到v-for检查设计列表

  • v-for 的 source 第一次 render 是不是数组 / 对象?

  • 是否存在 undefined → array 的路径?

  • 是否用了 ?. 直接喂给 v-for?

  • computed 是否可能 return 非遍历值?

四、可以反复使用的代码模版

const state = reactive({
  list: [],
  loading: true
})

onMounted(async () => {
  state.list = await fetchList()
  state.loading = false
})
<template>
  <div v-if="state.loading">loading...</div>
  <div v-else>
    <div v-for="item in state.list" :key="item.id" />
  </div>
</template>

五、关于v-for的统一心智模型

你可以把 Vue 渲染分成三层:

① 编译期(决定结构)
② 首次 render(建立语义)
③ diff 更新(只做比较)

v-for 的“可遍历语义”只在第 ② 步建立一次

如果你在第 ② 步给了:

  • undefined
  • null
  • false

👉 后面改不回来了,即使v-for source变了也不会重新建立

结果:列表一定始终渲染不出来

编译期报错的后果

如果在编译期v-for source = souceObject.property,而且source初始值为null,那么必然报错Can not read property of null (reading property),编译期报错会导致白屏

Vue 3自定义指令如何赋能表单自动聚焦与防抖输入的高效实现?

自定义指令在表单中的扩展

自动聚焦指令(v-focus)

在表单交互中,自动聚焦是一个常见的需求,尤其是在用户打开页面或弹窗时,希望输入框自动获得焦点。Vue 3允许我们通过自定义指令轻松实现这个功能。

<script setup>
// 定义v-focus指令
const vFocus = {
  mounted: (el) => {
    // 当元素挂载到DOM时自动聚焦
    el.focus()
  }
}
</script>

<template>
  <div>
    <h2>用户登录</h2>
    <input v-focus type="text" placeholder="请输入用户名" />
    <input type="password" placeholder="请输入密码" />
  </div>
</template>

这个指令比HTML原生的autofocus属性更强大,因为它不仅在页面加载时生效,还能在元素动态插入到DOM时自动聚焦,比如在弹窗组件中。

防抖输入指令(v-debounce)

在处理搜索输入等场景时,我们不希望用户每输入一个字符就立即发起请求,这会导致频繁的API调用和性能问题。防抖指令可以帮助我们延迟处理输入事件,直到用户停止输入一段时间后再执行。

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

// 定义防抖指令
const vDebounce = {
  mounted: (el, binding) => {
    let timeoutId
    const delay = binding.value || 500 // 默认500ms延迟
    
    // 监听输入事件
    el.addEventListener('input', (e) => {
      clearTimeout(timeoutId)
      timeoutId = setTimeout(() => {
        // 触发自定义事件,传递输入值
        el.dispatchEvent(new CustomEvent('debounce-input', { 
          detail: e.target.value 
        }))
      }, delay)
    })
  }
}

const searchQuery = ref('')

const handleSearch = (e) => {
  searchQuery.value = e.detail
  console.log('发起搜索:', searchQuery.value)
  // 这里可以添加实际的API调用逻辑
}
</script>

<template>
  <div>
    <input 
      v-debounce="300" 
      type="text" 
      placeholder="请输入搜索关键词"
      @debounce-input="handleSearch"
    />
    <p>搜索关键词: {{ searchQuery }}</p>
  </div>
</template>

这个防抖指令接收一个可选的延迟参数,默认500毫秒。当用户输入时,指令会在用户停止输入指定时间后触发自定义的debounce-input事件,我们可以在组件中监听这个事件来处理实际的搜索逻辑。

表单提交的事件处理与性能优化

避免过度渲染的策略

在处理表单提交时,我们需要注意避免不必要的组件渲染。以下是一些常用的优化策略:

  1. 使用v-once指令:对于不需要更新的静态内容,使用v-once可以让Vue只渲染一次,之后不再重新渲染。
<template>
  <div v-once>
    <h2>用户注册</h2>
    <p>请填写以下信息完成注册</p>
  </div>
  <!-- 表单内容 -->
</template>
  1. 使用计算属性处理复杂逻辑:将复杂的计算逻辑放在计算属性中,而不是模板中,这样可以缓存计算结果,避免重复计算。
<script setup>
import { ref, computed } from 'vue'

const password = ref('')
const confirmPassword = ref('')

// 计算属性:检查密码是否匹配
const isPasswordMatch = computed(() => {
  return password.value && password.value === confirmPassword.value
})
</script>

<template>
  <div>
    <input v-model="password" type="password" placeholder="请输入密码" />
    <input v-model="confirmPassword" type="password" placeholder="请确认密码" />
    <p :style="{ color: isPasswordMatch ? 'green' : 'red' }">
      {{ isPasswordMatch ? '密码匹配' : '密码不匹配' }}
    </p>
  </div>
</template>
  1. 使用watch监听变化:对于需要在数据变化时执行异步操作的场景,使用watch而不是在模板中直接处理。
<script setup>
import { ref, watch } from 'vue'

const email = ref('')
const isEmailAvailable = ref(true)

// 监听邮箱变化,检查邮箱是否已注册
watch(email, async (newEmail) => {
  if (newEmail) {
    // 模拟API调用
    const response = await fetch(`/api/check-email?email=${newEmail}`)
    isEmailAvailable.value = await response.json()
  }
})
</script>

<template>
  <div>
    <input v-model="email" type="email" placeholder="请输入邮箱" />
    <p v-if="email" :style="{ color: isEmailAvailable ? 'green' : 'red' }">
      {{ isEmailAvailable ? '邮箱可用' : '邮箱已被注册' }}
    </p>
  </div>
</template>

表单提交的优化处理

在处理表单提交时,我们需要防止用户重复提交,同时优化提交过程中的性能。

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

const formData = ref({
  username: '',
  password: ''
})
const isSubmitting = ref(false)

const handleSubmit = async () => {
  if (isSubmitting.value) return // 防止重复提交
  
  isSubmitting.value = true
  
  try {
    // 模拟API提交
    await new Promise(resolve => setTimeout(resolve, 1500))
    console.log('表单提交成功:', formData.value)
    alert('注册成功!')
  } catch (error) {
    console.error('表单提交失败:', error)
    alert('注册失败,请稍后重试')
  } finally {
    isSubmitting.value = false
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <input 
      v-model="formData.username" 
      type="text" 
      placeholder="请输入用户名"
      required
    />
    <input 
      v-model="formData.password" 
      type="password" 
      placeholder="请输入密码"
      required
    />
    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? '提交中...' : '注册' }}
    </button>
  </form>
</template>

这个示例中,我们使用isSubmitting状态来防止用户重复提交,同时在提交过程中禁用按钮并显示加载状态,提升用户体验。

往期文章归档
免费好用的热门在线工具

动态表单渲染

根据条件显示/隐藏字段

在实际应用中,我们经常需要根据用户的选择动态显示或隐藏某些表单字段。Vue 3的条件渲染指令可以轻松实现这个功能。

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

const userType = ref('personal') // personal: 个人用户, company: 企业用户
const formData = ref({
  username: '',
  password: '',
  companyName: '',
  companyAddress: ''
})
</script>

<template>
  <form>
    <select v-model="userType">
      <option value="personal">个人用户</option>
      <option value="company">企业用户</option>
    </select>
    
    <input 
      v-model="formData.username" 
      type="text" 
      placeholder="请输入用户名"
      required
    />
    <input 
      v-model="formData.password" 
      type="password" 
      placeholder="请输入密码"
      required
    />
    
    <!-- 企业用户专属字段 -->
    <div v-if="userType === 'company'">
      <input 
        v-model="formData.companyName" 
        type="text" 
        placeholder="请输入企业名称"
        required
      />
      <input 
        v-model="formData.companyAddress" 
        type="text" 
        placeholder="请输入企业地址"
        required
      />
    </div>
    
    <button type="submit">注册</button>
  </form>
</template>

动态生成表单字段

在更复杂的场景中,我们可能需要根据后端返回的配置动态生成整个表单。这时可以结合v-for和动态组件来实现。

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

// 模拟后端返回的表单配置
const formConfig = ref([
  {
    type: 'text',
    label: '用户名',
    name: 'username',
    placeholder: '请输入用户名',
    required: true
  },
  {
    type: 'password',
    label: '密码',
    name: 'password',
    placeholder: '请输入密码',
    required: true
  },
  {
    type: 'email',
    label: '邮箱',
    name: 'email',
    placeholder: '请输入邮箱',
    required: false
  },
  {
    type: 'select',
    label: '用户类型',
    name: 'userType',
    options: [
      { value: 'personal', label: '个人用户' },
      { value: 'company', label: '企业用户' }
    ],
    required: true
  }
])

const formData = ref({})

// 计算属性:检查表单是否完整
const isFormValid = computed(() => {
  return formConfig.value.every(field => {
    if (!field.required) return true
    return formData.value[field.name] && formData.value[field.name].trim() !== ''
  })
})

const handleSubmit = () => {
  if (isFormValid.value) {
    console.log('表单提交:', formData.value)
    alert('表单提交成功!')
  } else {
    alert('请填写所有必填字段!')
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <div v-for="field in formConfig" :key="field.name" class="form-group">
      <label>{{ field.label }} {{ field.required ? '*' : '' }}</label>
      
      <input 
        v-if="field.type === 'text' || field.type === 'password' || field.type === 'email'"
        :type="field.type"
        :placeholder="field.placeholder"
        v-model="formData[field.name]"
      />
      
      <select v-else-if="field.type === 'select'" v-model="formData[field.name]">
        <option 
          v-for="option in field.options" 
          :key="option.value"
          :value="option.value"
        >
          {{ option.label }}
        </option>
      </select>
    </div>
    
    <button type="submit" :disabled="!isFormValid">提交</button>
  </form>
</template>

<style scoped>
.form-group {
  margin-bottom: 1rem;
}

label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: bold;
}

input, select {
  width: 100%;
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
}

button {
  padding: 0.5rem 1rem;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}
</style>

课后Quiz

问题1:如何在Vue 3中创建一个自定义指令,实现输入框的防抖功能?

答案解析:

<script setup>
const vDebounce = {
  mounted: (el, binding) => {
    let timeoutId
    const delay = binding.value || 500
    
    el.addEventListener('input', (e) => {
      clearTimeout(timeoutId)
      timeoutId = setTimeout(() => {
        el.dispatchEvent(new CustomEvent('debounce', { 
          detail: e.target.value 
        }))
      }, delay)
    })
  }
}

const handleDebounce = (e) => {
  console.log('防抖输入:', e.detail)
}
</script>

<template>
  <input v-debounce="300" @debounce="handleDebounce" placeholder="请输入内容" />
</template>

这个防抖指令通过监听输入事件,使用setTimeout延迟处理,每次输入时清除之前的定时器,确保只有在用户停止输入指定时间后才会触发处理函数。

问题2:在动态表单渲染中,如何根据不同的字段类型渲染不同的输入组件?

答案解析: 可以使用v-ifv-else-ifv-else指令结合v-for来实现:

<template>
  <div v-for="field in fields" :key="field.name">
    <input 
      v-if="field.type === 'text'"
      type="text"
      v-model="formData[field.name]"
    />
    
    <input 
      v-else-if="field.type === 'password'"
      type="password"
      v-model="formData[field.name]"
    />
    
    <select 
      v-else-if="field.type === 'select'"
      v-model="formData[field.name]"
    >
      <option 
        v-for="option in field.options" 
        :key="option.value"
        :value="option.value"
      >
        {{ option.label }}
      </option>
    </select>
    
    <!-- 可以继续扩展其他字段类型 -->
  </div>
</template>

这种方法可以根据字段的type属性动态渲染不同的输入组件,实现灵活的动态表单。

常见报错解决方案

1. 自定义指令无法生效

错误现象: 自定义指令在模板中使用后没有产生预期效果。

可能原因:

  • 指令名称注册错误:在<script setup>中,自定义指令需要以v开头的驼峰命名,如vFocus,在模板中使用v-focus
  • 钩子函数使用错误:比如在created钩子中操作DOM,此时元素还未挂载到DOM树中。
  • 指令作用在组件上:自定义指令默认作用于组件的根元素,如果组件有多个根元素,可能会导致意外行为。

解决方案:

  • 确保指令名称正确注册,在<script setup>中使用v开头的驼峰命名。
  • 在正确的钩子函数中操作DOM,如mountedupdated
  • 避免在组件上使用自定义指令,或者确保组件只有一个根元素。

2. 动态表单渲染性能问题

错误现象: 当表单字段较多时,渲染速度慢,用户输入卡顿。

可能原因:

  • 不必要的响应式更新:表单数据的每个字段都被设置为响应式,导致频繁的更新。
  • 复杂的计算属性:在计算属性中执行复杂的逻辑,导致每次更新都需要大量计算。
  • 没有合理使用v-forkey属性:导致Vue无法正确跟踪元素的变化,进行不必要的DOM操作。

解决方案:

  • 使用markRaw标记不需要响应式的静态数据,如表单配置。
  • 优化计算属性,将复杂逻辑拆分为多个简单的计算属性,或者使用watch处理异步逻辑。
  • 确保v-forkey属性使用唯一且稳定的值,如字段的name属性。

3. 表单提交重复触发

错误现象: 用户点击提交按钮后,表单被多次提交。

可能原因:

  • 没有防止重复提交的机制:用户快速点击按钮导致多次触发提交事件。
  • 异步操作没有正确处理:在提交过程中,状态没有及时更新,导致用户可以再次点击。

解决方案:

  • 使用一个状态变量(如isSubmitting)来标记提交状态,在提交过程中禁用按钮。
  • finally块中重置提交状态,确保无论成功还是失败都能恢复按钮状态。
const handleSubmit = async () => {
  if (isSubmitting.value) return
  
  isSubmitting.value = true
  
  try {
    // 提交逻辑
  } catch (error) {
    // 错误处理
  } finally {
    isSubmitting.value = false
  }
}

参考链接:

Vue避坑:v-for中ref绑定失效?函数Ref优雅破局

在 Vue 开发中,ref 是最常用的响应式 API 之一,用于绑定 DOM 元素或普通数据。但在 v-for 循环场景中,直接绑定 ref 会出现复用冲突、定位混乱等问题。函数 Ref(Function Ref)作为 Vue 提供的解决方案,能精准处理循环中的 ref 绑定。本文将拆解 v-for 中 ref 的痛点,详解函数 Ref 的原理、用法及最佳实践。

一、v-for 中直接绑定 ref 的痛点

常规场景下,我们通过 ref="xxx" 绑定单个 DOM 元素,再通过 ref.value 访问。但在 v-for 循环中,直接绑定固定名称的 ref 会导致所有循环项共享同一个 ref,无法单独定位某一项元素。

<!-- 错误示例:所有列表项共享同一个 ref -->
<template>
  <ul>
    <li v-for="item in list" :key="item.id" ref="listItem">
      {{ item.name }}
    </li>
  </ul>
</template>

<script setup>
import { ref } from 'vue';
const listItem = ref(null); // 仅能获取最后一个 li 元素
const list = ref([{ id: 1, name: '项1' }, { id: 2, name: '项2' }]);
</script>

上述代码中,循环生成的多个 li 元素均绑定到 listItem,最终 ref.value 只会指向最后一个渲染的元素,无法区分和操作单个循环项,这就是直接绑定 ref 的核心痛点。

二、函数 Ref:v-for 场景的专属解决方案

2.1 什么是函数 Ref?

函数 Ref 是将 ref 绑定值设为一个函数,该函数会在元素渲染、更新或卸载时被调用,接收当前元素(或组件实例)作为参数。通过函数逻辑,可实现对循环项 ref 的精准管理。

核心优势:避免 ref 名称冲突,能为每个循环项单独绑定 ref 并存储,支持精准定位单个元素。

2.2 基础用法:存储循环项 Ref

最常用场景是将每个循环项的 ref 存储到数组或对象中,通过索引或唯一标识关联,实现单独访问。

<template>
  <ul>
    <li 
      v-for="(item, index) in list" 
      :key="item.id" 
      :ref="el => (listItems[index] = el)" // 函数 Ref 绑定
    >
      {{ item.name }}
    </li>
  </ul>
  <button @click="focusItem(0)">聚焦第一项</button>
</template>

<script setup>
import { ref } from 'vue';
const list = ref([
  { id: 1, name: '项1' },
  { id: 2, name: '项2' },
  { id: 3, name: '项3' }
]);
// 用数组存储每个循环项的 ref
const listItems = ref([]);

// 操作指定项的 DOM 元素
const focusItem = (index) => {
  listItems.value[index]?.focus(); // 精准定位第一项并聚焦
};
</script>

代码解析:通过箭头函数将当前 el(li 元素)赋值给 listItems 数组对应索引位置,listItems 数组会与循环项一一对应,从而实现对单个元素的操作。

2.3 进阶用法:结合唯一标识存储

若循环项存在唯一标识(如 id),可使用对象存储 ref,以 id 为键,避免索引变化导致的 ref 错位(如列表排序、删除项场景)。

<template>
  <ul>
    <li 
      v-for="item in list" 
      :key="item.id" 
      :ref="el => { if (el) itemRefs[item.id] = el; else delete itemRefs[item.id]; }"
    >
      {{ item.name }}
    </li>
  </ul>
  <button @click="scrollToItem(2)">滚动到 id=2 的项</button>
</template>

<script setup>
import { ref, reactive } from 'vue';
const list = ref([
  { id: 1, name: '项1' },
  { id: 2, name: '项2' },
  { id: 3, name: '项3' }
]);
// 用对象存储,键为 item.id
const itemRefs = reactive({});

// 根据 id 操作元素
const scrollToItem = (id) => {
  itemRefs[id]?.scrollIntoView({ behavior: 'smooth' });
};
</script>

代码解析:函数中判断 el 是否存在(元素渲染时 el 存在,卸载时为 null),存在则存入对象,不存在则删除对应键,避免对象中残留已卸载元素的 ref,同时通过 id 定位,不受列表顺序变化影响。

三、函数 Ref 的执行时机与注意事项

3.1 执行时机

  • 元素渲染时:函数被调用,el 为当前 DOM 元素/组件实例,可执行存储逻辑。
  • 元素更新时:若元素重新渲染(如数据变化),函数会再次调用,el 为更新后的元素。
  • 元素卸载时:函数被调用,el 为 null,需清理存储的 ref,避免内存泄漏。

3.2 核心注意事项

  • 避免使用箭头函数以外的函数声明:若使用普通函数,this 指向可能异常(尤其非 <script setup> 场景),建议优先用箭头函数。
  • 清理卸载元素的 ref:元素卸载时 el 为 null,需及时删除数组/对象中对应的 ref,避免存储无效引用。
  • 配合 v-if 时的处理:若循环项中包含 v-if,元素可能条件性渲染,需确保函数 Ref 能处理 el 为 null 的场景,避免报错。
  • 组件 ref 绑定:若循环的是自定义组件,el 会指向组件实例,可访问组件暴露的属性和方法(需通过 defineExpose 暴露)。

四、常见场景实战案例

4.1 批量操作循环项 DOM

需求:批量设置循环项的样式,或批量获取元素尺寸。

<template>
  <div class="item-list">
    <div 
      v-for="(item, index) in list" 
      :key="item.id" 
      :ref="el => (itemEls[index] = el)"
      class="item"
    >
      {{ item.content }}
    </div>
  </div>
  <button @click="setAllItemsRed">所有项设为红色</button>
</template>

<script setup>
import { ref } from 'vue';
const list = ref([{ id: 1, content: '内容1' }, { id: 2, content: '内容2' }]);
const itemEls = ref([]);

const setAllItemsRed = () => {
  itemEls.value.forEach(el => {
    if (el) el.style.color = 'red';
  });
};
</script>

4.2 组件循环中的 Ref 调用

需求:循环自定义组件,通过 ref 调用组件方法。

<!-- 父组件 -->
<template>
  <custom-item 
    v-for="item in list" 
    :key="item.id" 
    :ref="el => (compRefs[item.id] = el)"
    :data="item"
  />
  <button @click="callCompMethod(1)">调用 id=1 组件的方法</button>
</template>

<script setup>
import { reactive } from 'vue';
import CustomItem from './CustomItem.vue';
const list = ref([{ id: 1, data: '数据1' }, { id: 2, data: '数据2' }]);
const compRefs = reactive({});

const callCompMethod = (id) => {
  compRefs[id]?.handleClick(); // 调用子组件暴露的方法
};
</script>

<!-- 子组件 CustomItem.vue -->
<script setup>
import { defineProps, defineExpose } from 'vue';
const props = defineProps(['data']);
const handleClick = () => {
  console.log('子组件方法执行', props.data);
};
// 暴露方法供父组件调用
defineExpose({ handleClick });
</script>

五、总结

函数 Ref 是 Vue 为解决 v-for 中 ref 绑定问题提供的优雅方案,通过函数逻辑实现循环项 ref 的精准存储与管理,规避了常规绑定的冲突与错位问题。在实际开发中,需根据场景选择数组或对象存储 ref,注意清理无效引用,同时结合执行时机处理边界场景。

掌握函数 Ref 后,能轻松应对循环中的 DOM 操作、组件交互等需求,大幅提升 Vue 项目中循环场景的开发效率与代码健壮性。

scopeId 别再手动捞,可以“反手掏”:Vue3 组件迁移时的样式继承避坑指南

前言

在 Vue3 或 Nuxt3 项目中,为了保证业务平稳,我们经常需要做 “组件渐进式迁移” 。最直观的思路就是通过 v-if/v-else 来动态切换新老组件。

然而,当你满心欢喜地写下切换逻辑后,现实往往会给你一记响亮的耳光:父组件定义的样式(如布局宽度、外边距等)在切换到新组件时突然消失了。  同时,控制台会跳出那个令人头疼的警告:

Extraneous non-props attributes (class) were passed to component but could not be automatically inherited...

今天,我们就来拆解这个关于 Fragment(多根节点)Scoped CSS Hash 与 Nuxt 自动导入组件 纠缠在一起的“深坑”。


一、 案发现场:为什么样式消失了?

在 Vue3 中,Scoped CSS 的原理是给组件的根节点注入一个特殊的属性标识:data-v-xxxx(即 scopeId)。

  1. Fragment 破坏了继承:当你使用 v-if/v-else 切换两个组件时,Vue 会将其视为一个 Fragment(多根节点)。因为“不敢确定”该把父组件的 Hash 挂载到哪个候选节点上,Vue 索性放弃自动继承。
  2. 被屏蔽的 scopeId:你可能会想:“我不依赖自动继承,手动拿到这个 Hash 挂上去总行了吧?” 但你会发现 useAttrs() 里压根没有这个 data-v-hash
  3. Nuxt 的“组件黑盒” :在 Nuxt3 中,很多模块(如 vue3-carousel-nuxt)是自动全局注册的。它们没有显式导出对象,导致你无法直接在 <script> 里引用它们来做组件分发。

二、 曾经的偏门:手动“捞” scopeId

面对困境,很多开发者会尝试从组件实例里强行“捞取”私有属性:

javascript

import { getCurrentInstance } from 'vue';
const instance = getCurrentInstance();
// 强行捞取父组件注入的私有 scopeId
const parentScopeId = instance.vnode.scopeId; 

请谨慎使用此类代码。

然后在模板里手动绑定:

vue

<div v-if="isNew" :[parentScopeId]="''">...</div>

请谨慎使用此类代码。

避坑提醒:虽然 getCurrentInstance 在 uni-app 等小程序开发(获取节点 .in(proxy))中是刚需,但 Vue3 官方文档正有意将其“隐名埋姓”。手动捞取 vnode.scopeId 这种私有属性不仅累,还面临版本升级后属性变更的“崩盘”风险。


三、 破局:反手一“掏”,回归正道

经过实测,最靠谱的方案不是去“补救” Fragment,而是将逻辑上的“碎片化根节点”还原为“动态单根节点”

  1. 反手掏:利用 resolveComponent

既然 Nuxt 自动导入了组件但没给我导出对象,我们可以利用 Vue3 官方提供的运行时寻址 API —— resolveComponent

javascript

import { resolveComponent, computed } from 'vue';

// 动态获取那些“没被包显式导出”的全局组件引用
const NewCarousel = resolveComponent('Carousel'); 
const OldCarousel = resolveComponent('OldCarousel');

const ActiveCarousel = computed(() => (isNew ? NewCarousel : OldCarousel));

请谨慎使用此类代码。

  1. 重构渲染树

抛弃 v-if/v-else,回归内置的 <component :is>

vue

<template>
  <!-- 
    在 Vue3 中,<component :is> 承载的动态组件被视为一个逻辑上的 Single Root(单根节点)。
    此时,父组件的 Hash 样式会自动、精准地注入,无需任何 hack 操作。
  -->
  <component :is="ActiveCarousel" v-bind="$attrs">
    <slot />
  </component>
</template>

请谨慎使用此类代码。


四、 深度总结:顺应框架的本能

通过这次实操,我总结了两个核心认知:

  1. API 的层级性getCurrentInstance 虽然强大,但在业务逻辑中应被视为“最后一道防线”。与其通过私有属性去“偷”那个消失的 Hash,不如利用官方标准的 resolveComponent 夺回组件的引用权。
  2. 单根节点的力量:在处理 Scoped 样式继承时,动态组件占位符(component :is)的优先级和稳定性远高于模板指令(v-if/v-else)。

手动“捞” ,是与框架的内部实现对抗;反手“掏” ,是顺应 Vue 3 的渲染机制本能。在复杂的 Nuxt3 架构下,这才是实现组件无感迁移的最优解。


:如果你也遇到了 Vue3 样式继承失效的“灵异事件”,或者正在为 Nuxt 组件库没有导出而苦恼,希望这个方案能帮你少走弯路。欢迎在评论区一起探讨 Vue 3 的底层黑科技!

Vue 必学:Composition/Options API 选型指南+组合式函数最佳实践

在 Vue 生态中,Options API 和 Composition API 是两种核心的代码组织方式。Options API 作为 Vue 2 的默认 API,凭借直观的选项划分降低了新手入门门槛;而 Composition API 则在 Vue 3 中推出,以逻辑组合为核心,解决了大型项目中的代码复用与维护难题。本文将系统对比二者的优劣势,并深入探讨自定义组合式函数(Composables)的最佳实践、命名规范与类型声明,为 Vue 项目开发提供选型与编码参考。

一、Composition API 与 Options API 优劣势对比

二者的核心差异源于代码组织逻辑:Options API 按功能划分选项(如 data、methods、computed),Composition API 按业务逻辑划分代码块,各自适配不同的项目场景。

1.1 Options API 优劣势

优势

  • 入门门槛低,直观易懂:Options API 采用固定的选项结构,data 定义状态、methods 定义方法、computed 定义计算属性,新手能快速理解各部分功能,无需关心代码组织的逻辑关联,上手成本极低。
  • 代码结构规整,约定大于配置:固定的选项划分使代码具有统一的风格,团队协作时无需额外约定,可直接根据选项定位代码位置,适合小型项目或多人快速上手的场景。
  • 兼容 Vue 2 生态,迁移成本低:作为 Vue 2 的默认 API,拥有成熟的生态工具与社区案例,现有 Vue 2 项目可无缝沿用,如需迁移到 Vue 3,Options API 仍可正常使用,无需大规模重构。

劣势

  • 逻辑碎片化,维护成本高:当组件功能复杂时,同一业务逻辑的代码会分散在 data、methods、computed、watch 等多个选项中,形成“碎片化”代码。例如,一个表单提交功能的状态、验证方法、提交逻辑可能分布在不同选项,排查问题时需跨选项跳转,随着代码量增加,维护难度呈指数级上升。
  • 代码复用能力有限:Options API 主要通过混入(Mixins)实现代码复用,但 Mixins 存在明显缺陷:命名冲突风险、逻辑来源不清晰、依赖关系隐式化,多个 Mixins 叠加时,难以追踪状态与方法的归属,排查问题时耗时耗力。
  • 类型推断支持弱:在 TypeScript 中,Options API 的选项式结构难以实现精准的类型推断,需额外通过 Vue.extend 或装饰器补充类型定义,代码冗余且易出现类型错误。

1.2 Composition API 优劣势

优势

  • 逻辑聚合,维护性强:Composition API 允许将同一业务逻辑的状态、方法、计算属性、监听逻辑聚合在一个代码块中(通常通过 setup 函数或
  • 灵活的代码复用:基于逻辑聚合特性,可将通用逻辑封装为组合式函数(Composables),在多个组件中复用。与 Mixins 不同,组合式函数的逻辑来源清晰,无命名冲突风险,且支持传递参数实现逻辑定制,复用能力更强大、灵活。
  • 出色的 TypeScript 支持:Composition API 天生适配 TypeScript,setup 函数、响应式 API(如 ref、reactive)均可实现精准的类型推断,无需额外冗余代码,能充分发挥 TypeScript 的类型校验能力,减少运行时错误。
  • 逻辑拆分与组合更灵活:支持将复杂逻辑拆分为多个小型逻辑单元,再根据需求组合使用,既保证了单一逻辑的职责清晰,又能灵活适配不同组件的功能需求,适合大型复杂项目。

劣势

  • 入门门槛较高:相比 Options API 固定的选项结构,Composition API 需理解响应式 API(ref、reactive、toRefs 等)、生命周期钩子的写法变化,且需手动组织逻辑结构,新手可能出现逻辑混乱的问题。
  • 代码风格不统一风险:逻辑组织的灵活性可能导致团队内部代码风格差异,若缺乏统一规范,不同开发者的逻辑拆分方式不同,反而降低代码可读性。
  • 小型项目冗余:对于简单组件(如仅展示数据的静态组件),使用 Composition API 会增加代码量(如 ref 包裹状态、return 暴露属性),反而不如 Options API 简洁。

1.3 选型建议

  • 选择 Options API:小型项目、新手团队、Vue 2 迁移项目、组件逻辑简单且无需复用的场景。
  • 选择 Composition API:大型复杂项目、需要大量逻辑复用的场景、使用 TypeScript 开发的项目、组件逻辑需拆分组合的场景。

二、自定义组合式函数(Composables)最佳实践

组合式函数是 Composition API 的核心复用载体,本质是封装通用逻辑的函数,命名通常以“use”开头(如 useRequest、useForm),返回需要暴露的状态与方法。遵循最佳实践可保证组合式函数的可复用性、可维护性与易用性。

2.1 核心原则

  • 单一职责原则:一个组合式函数只封装一项核心逻辑(如 useRequest 仅处理请求逻辑,useForm 仅处理表单逻辑),避免将多个无关逻辑混入同一函数,确保函数体积小、职责清晰,便于复用与维护。
  • 响应式传递:函数内部使用 ref、reactive 创建的响应式状态,需通过 return 暴露给组件,组件可直接使用并响应状态变化;若接收外部参数,需确保参数为响应式对象(或通过 toRefs 转换),避免丢失响应式特性。
  • 无副作用优先:尽量使组合式函数纯函数化,若必须包含副作用(如请求、DOM 操作、定时器),需在函数内部处理副作用的清理(如清除定时器、取消请求),避免内存泄漏。
  • 逻辑隔离:组合式函数内部逻辑应与组件解耦,不依赖组件的实例(如避免使用 this),仅通过参数接收外部依赖,通过返回值提供能力,确保可在任意组件、甚至非组件环境(如 Pinia)中复用。

2.2 实现要点

(1)副作用清理

包含副作用的组合式函数,需使用 onUnmounted、onDeactivated 等生命周期钩子清理副作用。例如,定时器、事件监听、网络请求等,需在组件卸载时销毁,避免内存泄漏。

// useTimer.ts
import { ref, onUnmounted } from 'vue';

export function useTimer(initialDelay = 1000) {
  const count = ref(0);
  let timer: number | null = null;

  // 启动定时器
  const startTimer = () => {
    timer = window.setInterval(() => {
      count.value++;
    }, initialDelay);
  };

  // 停止定时器
  const stopTimer = () => {
    if (timer) {
      window.clearInterval(timer);
      timer = null;
    }
  };

  // 组件卸载时清理定时器
  onUnmounted(() => {
    stopTimer();
  });

  return { count, startTimer, stopTimer };
}

(2)参数可选与默认值

为提高灵活性,组合式函数的参数应支持可选配置,并设置合理默认值,允许组件根据需求覆盖默认配置。

// useRequest.ts
import { ref, onUnmounted } from 'vue';
import axios, { AxiosRequestConfig } from 'axios';

interface UseRequestOptions extends AxiosRequestConfig {
  autoRun?: boolean; // 是否自动触发请求
}

export function useRequest(url: string, options: UseRequestOptions = {}) {
  const { autoRun = true, ...axiosConfig } = options;
  const data = ref(null);
  const loading = ref(false);
  const error = ref(null);

  const fetchData = async () => {
    loading.value = true;
    try {
      const res = await axios.get(url, axiosConfig);
      data.value = res.data;
      error.value = null;
    } catch (err) {
      error.value = err;
      data.value = null;
    } finally {
      loading.value = false;
    }
  };

  // 自动触发请求
  if (autoRun) {
    fetchData();
  }

  return { data, loading, error, fetchData };
}

(3)避免命名冲突

组合式函数返回的状态与方法需命名清晰,避免与组件内部变量、其他组合式函数的返回值重名。可通过前缀、语义化命名区分,例如 useForm 返回的表单状态可命名为 formValue、formErrors,而非 value、errors。

2.3 命名规范

(1)函数命名

  • 必须以“use”开头,遵循驼峰命名法(camelCase),明确标识为组合式函数,便于开发者识别与导入。示例:useRequest、useForm、useScrollPosition。
  • 命名需语义化,准确反映函数封装的逻辑,避免模糊命名。例如,useTimer 比 useUtil 更清晰,useFormValidator 比 useFormCheck 更精准。

(2)文件命名

  • 单个组合式函数的文件,命名与函数名一致,后缀为 .ts(TypeScript)或 .js。示例:useTimer.ts、useRequest.ts。
  • 多个相关组合式函数可放在同一个文件夹下,通过 index.ts 导出,便于批量导入。例如:在 composables/form/ 目录下存放 useForm.ts、useFormValidator.ts,通过 index.ts 聚合导出。

(3)返回值命名

  • 返回的状态与方法需语义化,与函数封装的逻辑强关联。例如,useScrollPosition 返回 scrollX、scrollY(滚动坐标)、updateScrollPosition(更新坐标方法)。
  • 避免使用简写、模糊词汇,如不用 val 代替 value,不用 handle 代替具体动作(如 submit、clear)。

三、组合式函数的类型声明

在 TypeScript 中,合理的类型声明能提升组合式函数的易用性,避免类型错误,同时提供良好的 IDE 提示。以下是常见场景的类型声明方法。

3.1 基础类型声明

对于简单组合式函数,直接通过类型注解声明参数与返回值类型,确保类型精准。

// useCounter.ts
import { ref, Ref } from 'vue';

// 声明参数类型
interface UseCounterOptions {
  initialValue?: number;
  step?: number;
}

// 声明返回值类型
interface UseCounterReturn {
  count: Ref<number>;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
  const { initialValue = 0, step = 1 } = options;
  const count = ref<number>(initialValue);

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

  const decrement = () => {
    count.value -= step;
  };

  const reset = () => {
    count.value = initialValue;
  };

  return { count, increment, decrement, reset };
}

3.2 泛型类型声明

当组合式函数需适配多种数据类型时,使用泛型(Generic)声明,提高函数的灵活性与复用性。例如,封装一个通用的列表请求函数,支持不同类型的列表数据。

// useList.ts
import { ref, Ref } from 'vue';
import axios from 'axios';

interface UseListOptions<T> {
  url: string;
  autoRun?: boolean;
  formatData?: (rawData: any) => T[]; // 数据格式化函数
}

interface UseListReturn<T> {
  list: Ref<T[]>;
  loading: Ref<boolean>;
  error: Ref<Error | null>;
  fetchList: () => Promise<void>;
}

export function useList<T = any>(options: UseListOptions<T>): UseListReturn<T> {
  const { url, autoRun = true, formatData = (raw) => raw.data } = options;
  const list = ref<T[]>([]) as Ref<T[]>;
  const loading = ref<boolean>(false);
  const error = ref<Error | null>(null);

  const fetchList = async () => {
    loading.value = true;
    try {
      const res = await axios.get(url);
      list.value = formatData(res.data);
      error.value = null;
    } catch (err) {
      error.value = err as Error;
      list.value = [];
    } finally {
      loading.value = false;
    }
  };

  if (autoRun) {
    fetchList();
  }

  return { list, loading, error, fetchList };
}

使用时可指定具体类型,获得精准的类型提示:

// 声明列表项类型
interface User {
  id: number;
  name: string;
  age: number;
}

// 使用泛型组合式函数
const { list, loading } = useList<User>({
  url: '/api/users',
  formatData: (raw) => raw.users // 类型校验:确保返回 User[] 类型
});

// list 自动推断为 Ref<User[]>,IDE 提供 User 属性提示
list.value.forEach(user => {
  console.log(user.name);
});

3.3 响应式类型处理

组合式函数中常用 ref、reactive 创建响应式状态,类型声明需注意以下几点:

  • ref 类型:通过 ref(initialValue) 声明,若初始值为 null/undefined,需明确类型(如 ref<User | null>(null))。
  • reactive 类型:直接为 reactive 传递接口类型,例如 const form = reactive({ name: '', age: 0 })。
  • toRefs 类型:当需解构 reactive 对象时,使用 toRefs 保持响应式,类型自动继承原对象类型,例如 const { name, age } = toRefs(form),name 自动推断为 Ref。

四、总结

Options API 与 Composition API 并非对立关系,而是适配不同场景的技术方案:Options API 适合简单场景与新手入门,Composition API 则更擅长解决大型项目的逻辑复用与维护问题。自定义组合式函数作为 Composition API 的核心复用载体,需遵循单一职责、响应式传递、副作用清理等原则,配合规范的命名与精准的类型声明,才能充分发挥其灵活性与可复用性。

在实际开发中,建议根据项目规模、团队技术栈(是否使用 TypeScript)、逻辑复杂度选择合适的 API 方案,并制定统一的组合式函数开发规范,提升团队协作效率与代码质量。

Vue3架构设计——调度系统

调度本义是指控制一系列任务的执行顺序/编排规划。Vue3 的调度系统使其能够做到**“批量更新、不重复渲染、任务执行顺序可控”** 。

Vue 的调度系统 = 副作用执行顺序 + 去重 + 批量刷新

所有响应式变化,最终都不会“立刻执行”,而是被“调度”

一、Vue 为什么需要调度系统?

如果没有调度,会发生什么?

state.a++
state.b++
state.c++

如果每次 set 都立即触发:

render()
render()
render()

造成后果:

  • 性能问题
  • 顺序不可控
  • DOM 不断更改,页面抖动

所以,Vue 的目标是:

state.a++
state.b++
state.c++
↓
render()  // 只执行一次(Scheduler 存在的意义)

二、调度系统的数据结构

源码中的位置:packages/runtime-core/src/scheduler.ts

运行时(runtime)调度,对 effect 进行 “统一执行管理”。

调度系统不关心数据,只关心:

2.1 Job 的本质

type SchedulerJob = Function & {
  id?: number
  flags?: number
}
  • 没有 id,直接 push 进队列
  • 有 id,按照顺序通过二分查找插入到合适的位置

Job ≈ effect.run / component update

2.2 核心队列

const queue: SchedulerJob[] = []

所有待执行任务,都会进这个队列。

2.3 任务去重

const queued = new Set<SchedulerJob>()

同一个 job,一个 flush 周期只会进队一次

三、调度入口:queueJob

export function queueJob(job: SchedulerJob) {
  if (!queued.has(job)) {
    queued.add(job)
    queue.push(job)
    queueFlush()
  }
}
  1. 去重(比如说 count++ 多次,最终的更新只需要一次)
  2. 入队
  3. 触发 flush

四、flush:真正执行的地方

function queueFlush() {
  if (!isFlushing) {
    isFlushing = true
    resolvedPromise.then(flushJobs)
  }
}

Vue 的调度基于 microtask(Promise.then)

所以:

同步代码 → 全跑完
↓
flushJobs(统一执行)

五、flushJobs 的核心逻辑

function flushJobs() {
  try {
    // 批量执行 所有 job 集中执行一次
    for (let i = 0; i < queue.length; i++) {
      const job = queue[i]
      job()
    }
  } finally {
    queue.length = 0
    queued.clear()
    isFlushing = false
  }
}

六、组件更新的调度

每个组件都有一个 render effect

const effect = new ReactiveEffect(componentUpdateFn)

scheduler 被设置为:

scheduler = () => queueJob(update) // UI 更新
state change
 ↓
trigger
 ↓
component render effect.scheduler
 ↓
queueJob(update)
 ↓
flushJobs(异步更新)
 ↓
update() → render()

七、computed / watch 在调度系统中的位置

7.1 computed 的 scheduler

scheduler = () => {
  dirty = true
  trigger(computed.dep)
}

computed 的任务调度不进 scheduler 队列(queueJob),只影响依赖它的 effect

7.2 watch 的 scheduler

scheduler = () => {
  queueJob(job)
}

watch 直接进入调度系统(具体进入哪个优先层级取决于 flush ,默认为 queueJob)

八、flush: pre / post / sync

Vue 的调度系统 不是一个队列,而是三个层级

三种 flush 模式

8.1 pre 队列(默认 watch)

queuePreFlushCb(job)

用于:

  • watch
  • beforeUpdate

8.2 post 队列(DOM 后)

queuePostFlushCb(job)

用于:

  • watch(flush: 'post')
  • onMounted / onUpdated

8.3 执行顺序

flushPreFlushCbs
 ↓
flushJobs(组件更新)
 ↓
flushPostFlushCbs

九、nextTick 的本质

export function nextTick(fn?) {
  return fn
    ? resolvedPromise.then(fn)
    : resolvedPromise
}

所以 nextTick 本质是:等当前调度周期 flush 完(在原本调度系统 Promise.then(调度任务队列) 的后面又拼接了一个 .then(nextTick任务)

DOM 更新会在原本的调度系统中,所以 nextTick 在开发中一般用于获取最新的 DOM 。

十、简单示例

watch(state, () => console.log('watch'))

state.count++

console.log('sync')

nextTick(() => console.log('tick'))

执行顺序:

sync
watch
render
tick

十一、为什么 Vue 不用 setTimeout / requestAnimationFrame?

Vue 的目标是:“同步代码结束后,立刻统一刷新”

Vue 中的 deep、v-deep 和 >>> 有什么区别?什么时候该用?

“你用 Element Plus 写了个按钮,想改下 hover 颜色,结果死活不生效!最后查了半天,发现得加个 :deep() 才行”

其实,这是 Vue 中一个非常常见的坑:样式作用域冲突。那为什么用 UI 库时,加上 :deep()::v-deep>>>后,样式就能生效呢?

它们是什么?有什么区别?什么时候该用哪个?

一、先说背景

我们在 Vue 单文件组件(.vue 文件)里写样式时,通常会加上 scoped 属性:

<template>
  <el-button>点我</el-button>
</template>

<style scoped>
.el-button {
  background: red;
}
</style>

加了 scoped 后,Vue 会自动给这个组件里的所有元素加上一个唯一的属性(比如 data-v-123456),然后把 CSS 选择器也加上这个属性,变成:

.el-button[data-v-123456] {
  background: red;
}

这样做的好处是:样式只作用于当前组件,不会污染全局。、

但问题来了:Element Plus 的 <el-button> 组件内部结构,是在它自己的组件里定义的。也就是说,你写的 .el-button 元素,其实是 Element Plus 渲染出来的子组件,它身上没有你当前组件的 data-v-xxx 属性!

所以你的样式根本匹配不到它,自然就失效了。


二、那怎么办?

为了解决这个问题,Vue 提供了样式穿透(style penetration)的语法,让你能穿透当前组件的作用域,去影响子组件内部的元素。

Vue 社区出现过三种写法:

写法 适用版本 状态
>>> Vue 2(某些预处理器支持) 已废弃/不推荐
::v-deep Vue 2 + Vue 3(兼容写法) 过渡方案
:deep() Vue 3.0+(推荐) 官方推荐

下面我们一个个拆解。


1. >>>:曾经的快捷方式,但问题很多

早期 Vue2 时代,很多人用:

<style scoped>
.parent >>> .child {
  color: blue;
}
</style>

它的意思是:从 .parent 开始,穿透到所有后代中的 .child

但问题在于:

  • Sass/Less 等预处理器不认 >>>,会报错。
  • 不是标准 CSS 语法。
  • Vue3 已经明确不再支持。

所以现在基本可以忘掉它了。


2. ::v-deep:Vue2 到 Vue3 的桥梁

为了兼容预处理器,Vue 引入了 ::v-deep

<style scoped lang="scss">
.parent ::v-deep(.child) {
  color: blue;
}
</style>

或者更常见的写法:

.parent {
  ::v-deep(.child) {
    color: blue;
  }
}

它在 Vue2 和 Vue3 中都能用,算是一个安全的过渡方案。

但注意:在 Vue3 中,官方文档已经明确建议使用 :deep() 替代它


3. :deep():Vue3 的标准答案

Vue3 引入了更简洁、更符合 CSS 规范的伪类函数写法:

<style scoped>
:deep(.el-button) {
  background: red !important;
}
</style>

或者配合父级选择器:

<style scoped>
.my-wrapper :deep(.el-input__inner) {
  border-radius: 10px;
}
</style>

优点

  • 语法清晰,像原生 CSS。
  • 支持所有预处理器(Sass/Less/Stylus)。

:deep() 本质上是一个编译时转换,Vue 在构建时会把它展开成带 data-v-xxx 的复杂选择器,从而实现穿透。


三、怎么正确修改 Element Plus 的样式?

举个真实例子:你想把 Element Plus 的输入框圆角改成 8px。

错误写法(不生效):

<style scoped>
.el-input__inner {
  border-radius: 8px;
}
</style>

正确写法:

<template>
  <div class="my-form">
    <el-input v-model="value" />
  </div>
</template>

<style scoped>
.my-form :deep(.el-input__inner) {
  border-radius: 8px;
}
</style>

为什么要加 .my-form 这个父级?
避免全局污染!如果直接写 :deep(.el-input__inner),那么这个页面里所有 Element 输入框都会被改掉。加上父级限定,就能精准控制范围。

本文首发于公众号:程序员大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

vite.config.js 8 大核心模块,一文吃透

一、Vite 是什么?—— 面向未来的前端构建工具

Vite(法语意为“快”)是由 Vue 作者尤雨溪创建的新型前端构建工具。它利用浏览器原生支持 ES 模块(ESM)的能力,在开发环境下实现了极快的冷启动和热更新;而在生产环境中,则通过预构建依赖和 Rollup 打包输出高性能代码。

vite.config.js 作为 Vite 工程的核心配置文件,定义了整个项目的运行规则、编译逻辑和部署方案,是连接 Vite 核心能力与项目实际需求的桥梁,一份完善的 vite.config.js 能够让前端工程化流程更高效、更规范。

二、核心概念对比:Vite vs Webpack

虽然 Vite 和 Webpack 都用于构建前端应用,但它们的设计哲学完全不同,核心概念的差异直接决定了两者的使用体验和性能表现:

概念 Webpack Vite
Entry(入口) 显式配置 entry 字段,从 JS 入口开始递归解析依赖 默认以 index.html 为入口,开发时按需加载 ESM 模块,生产环境可显式配置 HTML 入口
Chunk(代码块) 构建阶段静态分析生成 chunks 开发时无需生成 chunk,生产构建依托 Rollup 实现动态代码拆分
Loader(转换器) 使用 loader 处理非 JS 资源(如 babel-loader, sass-loader) 无明确 Loader 概念,通过插件机制 + 内置转换器处理特殊资源,更灵活高效
Plugin(插件) 插件监听生命周期钩子扩展功能 插件系统强大,支持开发、构建双模式介入,兼容部分 Rollup 插件
Output(输出) 输出 bundle 到指定目录,需额外配置优化 生产环境输出优化后的静态资源,内置多种打包优化策略,配置更简洁

关键区别:开发与生产环境的差异化处理

Webpack:开发和生产环境均走完整的打包流程,所有模块需提前编译合并为 bundle 文件,项目体积越大,启动和更新速度越慢。

Vite:

  1. 开发环境:基于浏览器 ESM 直接运行,不进行全量打包,仅对浏览器请求的模块进行即时编译,响应速度极快。
  2. 生产环境:使用 Rollup 进行完整打包,产出经过代码压缩、树摇优化、资源分类的静态资源,兼顾性能与兼容性。

这正是 Vite 能够实现“秒级启动”的根本原因,既保证了开发体验,又满足了生产环境的部署要求。

三、vite.config.js 核心模块配置实战

vite.config.js 采用模块化导出方式,支持根据环境动态返回配置,下面将按照功能模块拆解配置逻辑,详细说明各部分的配置目的和实现方式。

模块一:环境初始化与多环境配置

这是配置文件的前置步骤,核心是获取当前环境变量,实现不同环境下的差异化配置,依赖 Vite 内置的 loadEnv 方法。

配置逻辑

  1. 接收 Vite 传入的 mode 参数,该参数对应启动/构建命令中的环境(如 development、production)。
  2. 通过 loadEnv 加载对应环境的配置文件(如 .env.development、.env.production)。
  3. 定义不同环境下的公共路径、输出目录等核心配置,实现环境隔离。

代码实现

import { defineConfig, loadEnv } from 'vite';
import path from 'path';
import rimraf from 'rimraf';

// 生成时间戳,用于生产环境版本隔离
function createFileDate () {
  const today = new Date();
  const y = today.getFullYear();
  const m = today.getMonth() + 1 > 9 ? today.getMonth() + 1 : '0' + (today.getMonth() + 1);
  const d = today.getDate() > 9 ? today.getDate() : '0' + today.getDate();
  const h = today.getHours() > 9 ? today.getHours() : '0' + today.getHours();
  const M = today.getMinutes() > 9 ? today.getMinutes() : '0' + today.getMinutes();
  return y + '' + m + '' + d + '' + h + '' + M;
}

export default ({ mode }) => {
  // 第一步:加载环境变量,指定环境配置文件所在目录
  const env = loadEnv(mode, path.join(process.cwd(), './env'));
  const fileDateDir = createFileDate();
  
  // 第二步:定义多环境核心配置项
  // 生产环境 CDN 公共路径
  const prodPublicPath = `https://yyt.com/resources/ph7/${fileDateDir}/`;
  // 测试环境本地公共路径
  const testPublicPath = '/ph7/';
  
  // 第三步:生产环境前置清理旧构建产物
  if (mode === 'production') {
    rimraf(path.join(process.cwd(), './dist'), (err) => {
      if (err) console.error('清理 dist 目录失败:', err);
    });
  }

  // 返回最终配置
  return defineConfig({
    // 配置公共基础路径,根据环境切换
    base: mode === 'production' ? prodPublicPath : testPublicPath,
    // 其他核心配置...
  });
};

配置说明

  1. loadEnv 第一个参数为环境模式,第二个参数为环境配置文件目录,会自动加载该目录下 .env.${mode} 格式的文件。
  2. 生产环境构建前通过 rimraf 清理旧的 dist 目录,避免旧资源残留导致部署问题。
  3. 公共路径 base 用于配置打包后资源的根路径,生产环境配置 CDN 地址,测试环境配置本地子路径,解决资源 404 问题。

模块二:生产构建配置(build)

该模块是 vite.config.js 的核心之一,用于定义生产环境打包的输出规则、优化策略,所有配置均放在 build 字段下,依托 Rollup 实现打包能力。

配置逻辑

  1. 配置差异化输出目录,实现生产环境版本隔离。
  2. 开启输出目录自动清空,避免手动清理遗漏。
  3. 配置 Rollup 打包参数,包括入口、代码块输出规则、静态资源分类输出规则。
  4. 配置插件实现打包后资源自动拷贝,满足本地部署需求。
  5. 配置 SourceMap 生成规则,兼顾调试与安全。

代码实现

return defineConfig({
  // 其他配置...
  build: {
    // 1. 差异化输出目录:生产环境带时间戳,测试环境简易目录
    outDir: mode === 'production' ? `dist/cdn/${fileDateDir}` : 'dist',
    // 2. 打包前自动清空 outDir 对应的目录
    emptyOutDir: true,
    // 3. 是否生成 SourceMap:开发环境生成,生产环境关闭(安全+减小体积)
    sourcemap: mode === 'development',
    // 4. Rollup 打包详细配置
    rollupOptions: {
      // 配置打包入口:指定 index.html 作为入口文件
      input: {
        main: path.resolve(__dirname, 'index.html')
      },
      // 配置输出规则
      output: {
        // 入口代码块输出规则:输出到 assets/js 目录,添加 hash 后缀
        entryFileNames: 'assets/js/[name]-[hash].js',
        // 公共/异步代码块输出规则:与入口代码块统一目录
        chunkFileNames: 'assets/js/[name]-[hash].js',
        // 静态资源分类输出规则:按文件类型拆分目录
        assetFileNames: ({ name }) => {
          if (name.endsWith('.css')) {
            return 'assets/css/[name]-[hash][extname]';
          }
          if (name.endsWith('.html')) {
            return 'assets/html/[name]-[hash][extname]';
          }
          if (
            name.endsWith('.png') ||
            name.endsWith('.jpg') ||
            name.endsWith('.jpeg') ||
            name.endsWith('.svg')
          ) {
            return 'assets/img/[name]-[hash][extname]';
          }
          if (
            name.endsWith('.xls') ||
            name.endsWith('.xlsx') ||
            name.endsWith('.csv') ||
            name.endsWith('.pdf')
          ) {
            return 'assets/files/[name]-[hash][extname]';
          }
          if (
            name.endsWith('.ttf') ||
            name.endsWith('.eot') ||
            name.endsWith('.woff') ||
            name.endsWith('.otf')
          ) {
            return 'assets/fonts/[name]-[hash][extname]';
          }
          // 默认输出目录
          return 'assets/[name]-[hash][extname]';
        }
      },
      // 5. Rollup 插件配置:打包后资源拷贝
      plugins: [
        copy({
          targets: [
            {
              src: [
                `dist/cdn/${fileDateDir}/json`,
                `dist/cdn/${fileDateDir}/locales`,
                `dist/cdn/${fileDateDir}/index.html`
              ],
              dest: 'dist/local'
            }
          ],
          // 打包完成后执行拷贝
          hook: 'writeBundle',
          // 扁平化目录结构,避免多级嵌套
          flatten: true
        })
      ]
    }
  },
});

配置说明

  1. outDir 定义打包输出目录,生产环境使用带时间戳的目录名,实现版本隔离,防止旧资源缓存导致线上问题。
  2. Vite 生产打包(vite build)时,默认会给静态资源文件(CSS/图片/字体等)添加内容哈希,规则是:文件名格式:[name].[hash].[ext](比如 app.8a3b2.js)。对于普通 JS 文件,需通过配置 entryFileNames/chunkFileNames 手动添加 hash。
  3. assetFileNames 实现静态资源分类,将 CSS、图片、办公文件、字体分别放入对应目录,便于部署和运维排查。
  4. rollupOptions.plugins 中配置 rollup-plugin-copy,在打包完成后将核心资源拷贝到 dist/local,满足本地测试部署需求。

模块三:静态资源扩展配置(assetsInclude)

Vite 有默认支持的静态资源类型,对于一些特殊格式的文件(如 xlsx、pdf),需要通过 assetsInclude 扩展识别,确保打包时能正确处理这些资源。

配置逻辑

  1. 以数组形式列出需要扩展的静态资源后缀。
  2. 配置在顶层字段中,全局生效。

代码实现

return defineConfig({
  // 其他配置...
  // 扩展静态资源类型识别
  assetsInclude: [
    '**/*.xlsx',
    '**/*.xls',
    '**/*.csv',
    '**/*.pdf',
    '**/*.png',
    '**/*.jpg',
    '**/*.svg'
  ],
});

配置说明

  1. 通配符 **/ 表示匹配所有目录下的对应文件。
  2. 配置后,这些特殊格式文件可以通过 import 引入,打包时会按照 build.rollupOptions.output 中的规则输出到对应目录。

模块四:插件配置(plugins)

插件是 Vite 扩展功能的核心载体,通过配置不同插件,可以实现 Vue 解析、JSX 支持、HTML 优化等功能,所有插件配置在 plugins 数组中,按需求引入并初始化。

配置逻辑

  1. 安装所需插件(如 @vitejs/plugin-vue)。
  2. 在配置文件中导入插件。
  3. plugins 数组中初始化插件,传入必要的配置参数。

代码实现

import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
import { createHtmlPlugin } from 'vite-plugin-html';
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';

return defineConfig({
  // 其他配置...
  plugins: [
    // 1. 解析 Vue 单文件组件(.vue),Vue 项目必备
    vue({
      template: {
        transformAssetUrls: {
          video: ['src', 'poster'],
          source: ['src'],
          img: ['src'],
          image: ['xlink:href', 'href'],
          use: ['xlink:href', 'href'],
          a: ['downloadHref']
        }
      }
    }),
    // 2. 支持 Vue JSX/TSX 语法解析
    vueJsx({}),
    // 3. HTML 优化插件:压缩 HTML、动态注入数据
    createHtmlPlugin({
      minify: true,
      entry: 'src/main.js',
      inject: {
        data: {}
      }
    }),
    // 4. SVG 图标管理插件:生成 SVG Sprite,实现图标复用
    createSvgIconsPlugin({
      iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
      symbolId: 'g-icon-[name]'
    })
  ],
});

配置说明

  1. @vitejs/plugin-vue:Vue 项目核心插件,用于解析 .vue 单文件组件,transformAssetUrls 配置用于修正模板中资源的路径解析。
  2. @vitejs/plugin-vue-jsx:支持 JSX/TSX 语法,满足个性化编码需求,无需额外配置即可使用。
  3. vite-plugin-html:生产环境自动压缩 HTML,支持动态注入数据到 HTML 中,提升页面加载性能。
  4. vite-plugin-svg-icons:将指定目录下的 SVG 图标生成 Sprite,在项目中可通过 <svg><use xlink:href="#g-icon-xxx"></use></svg> 复用,避免图标重复引入。

模块五:路径别名配置(resolve.alias)

在大型项目中,相对路径(如 ../../../src/components/Modal)会降低开发效率和代码可维护性,通过 resolve.alias 配置路径别名,可简化模块引入路径。

配置逻辑

  1. 借助 path.resolve 解析绝对路径。
  2. resolve.alias 中定义别名与对应目录的映射关系。

代码实现

return defineConfig({
  // 其他配置...
  resolve: {
    // 优先解析 browser 字段和 module 字段
    mainFields: ['browser', 'module'],
    // 路径别名配置
    alias: {
      '@': path.resolve('./src'), // 映射 src 目录
      '@LC': path.resolve('../lib-components/src'), // 映射外部组件库目录
      '#': path.resolve('./types'), // 映射类型定义目录
      'td-print': path.resolve('./node_modules/td-print/index.js') // 映射特定模块
    }
  },
});

配置说明

  1. 配置后,可使用 @/components/Modal 替代 ../../../src/components/Modal,简化路径书写。
  2. 别名不仅支持目录映射,还支持单个文件映射(如 td-print):
    • td-print 是自定义模块别名,对应项目中 ./node_modules/td-print/index.js(前端打印相关第三方库);
    • 不配置别名时需写完整路径 import print from './node_modules/td-print/index.js',配置后可直接 import print from 'td-print',简化导入、提升可读性。
  3. path.resolve 用于生成绝对路径,避免不同操作系统下的路径分隔符问题。
  4. 补充:路径别名需配合编辑器配置(如 tsconfig.json/jsconfig.jsoncompilerOptions.paths),实现代码提示和跳转。

模块六:CSS 预处理器配置(css)

Vite 内置支持 SCSS、Less 等 CSS 预处理器,只需安装对应的依赖,再通过 css.preprocessorOptions 配置预处理器参数,即可正常使用。

配置逻辑

  1. 安装 SCSS 依赖(sass,注意不是 node-sass)。
  2. css.preprocessorOptions.scss 中配置编译参数、抑制弃用警告等。

代码实现

return defineConfig({
  // 其他配置...
  css: {
    preprocessorOptions: {
      scss: {
        // 使用现代编译器 API,提升兼容性和编译性能
        api: 'modern-compiler',
        // 抑制 import 相关的弃用警告,保持构建日志整洁
        silenceDeprecations: ['import']
      }
    }
  },
});

配置说明

  1. 使用 SCSS 前需安装依赖:npm install sass --save-dev
  2. api: 'modern-compiler' 指定使用现代编译器 API,替代旧的 node-sass 编译器,提升编译速度和兼容性。
  3. silenceDeprecations 用于抑制不必要的弃用警告,避免构建日志被冗余信息覆盖。

模块七:本地开发服务器配置(server)

该模块用于配置本地开发服务器的相关参数,包括端口、跨域代理、主机访问权限等,核心是通过 proxy 配置解决本地开发的接口跨域问题。

配置逻辑

  1. 配置服务器端口和主机访问权限。
  2. 通过 proxy 配置接口转发规则,将前端请求转发到后端服务。
  3. 配置 changeOrigin 实现跨域模拟,配置 rewrite 修正请求路径。

代码实现

return defineConfig({
  // 其他配置...
  server: {
    // 配置本地开发服务器端口
    port: 8387,
    // 允许外部设备访问(如手机、同一局域网的其他电脑)
    host: true,
    // 跨域代理配置
    proxy: {
      // 匹配以 /charm 开头的请求
      '/charm': {
        // 后端服务目标地址
        target: 'http://10.1.11.11:58***/',
        // 开启跨域模拟:修改请求头中的 Origin 为目标地址
        changeOrigin: true,
        // 路径重写:此处保持原路径不变,可根据需求修改
        rewrite: (path) => path.replace(/^\/charm/, '/charm')
      },
      // 可配置多个代理规则
      '/g-filestore': {
        target: 'http://10.5.11.11:8***/',
        changeOrigin: true
      }
    }
  },
});

配置说明

  1. port 配置本地开发端口,避免与其他服务端口冲突。
  2. host: true 允许外部设备访问,方便在手机上调试移动端页面。
  3. proxy 中的 target 为后端服务地址,changeOrigin: true 是解决跨域的核心,通过修改请求头的 Origin 模拟同源请求。
  4. rewrite 用于修正请求路径,若前端请求路径与后端接口路径不一致,可通过该配置进行调整。

模块八:全局常量注入配置(define)

通过 define 可以在项目中注入全局常量,这些常量会在打包时被静态替换,无需手动引入即可在代码中直接使用,适用于埋点、版本号、CDN 路径拼接等场景。

配置逻辑

  1. define 中定义全局常量,注意字符串类型需要使用 JSON.stringify 包裹。
  2. 在项目代码中直接访问该常量。

代码实现

return defineConfig({
  // 其他配置...
  define: {
    // 注入时间戳全局常量,用于版本标识
    'import.meta.env.VITE_APP_LOCAL_HASH': JSON.stringify(fileDateDir)
  },
});

配置说明

  1. define 中的键名建议遵循 import.meta.env.XXX 格式,与 Vite 内置环境变量格式保持一致。
  2. 字符串类型必须使用 JSON.stringify 包裹,否则打包时会被当作变量解析,导致报错。
  3. 在项目代码中可直接使用:console.log(import.meta.env.VITE_APP_LOCAL_HASH),打包后会被静态替换为对应的时间戳字符串。

四、Vite 的构建流程

尽管 Vite 采用了与 Webpack 不同的底层机制,但它依然遵循清晰的构建流程,分为开发环境和生产环境两个阶段:

1. 开发环境构建流程

  1. 初始化参数:解析 vite.config.js,合并命令行参数,加载环境变量和插件。
  2. 启动开发服务器:创建 HTTP 服务,监听指定端口,开启 WebSocket 通信(用于热更新)。
  3. 确定入口:加载根目录下的 index.html,自动修正其中的资源路径和公共基础路径。
  4. 按需编译模块:浏览器请求某个模块时,Vite 实时对该模块进行编译(如 Vue SFC 解析、TS 转 JS),修正依赖路径后返回给浏览器。
  5. 热更新(HMR):监听项目文件变化,仅重新编译修改的单个模块,通过 WebSocket 向浏览器推送更新通知,浏览器直接替换对应模块,无需全页刷新。
  6. 接口代理:根据 server.proxy 配置,将前端请求转发到后端服务,解决跨域问题。

2. 生产环境构建流程

  1. 环境准备:解析 mode 参数,加载对应环境变量,清理旧的打包产物。
  2. 初始化编译器:合并 vite.config.js 中的 build 配置,初始化 Rollup 编译器,注册所有插件。
  3. 解析入口与依赖:以 index.html 为入口,递归分析所有模块的依赖关系,构建完整的依赖图谱。
  4. 模块编译与优化:对所有模块进行编译转换,执行 Tree Shaking 剔除死代码,进行代码压缩和混淆。
  5. 组装与输出资源:将模块组装为入口代码块、公共代码块,按照配置的输出规则将静态资源写入指定目录。
  6. 后续自动化操作:执行插件的 writeBundle 钩子(如资源拷贝),生成最终的打包产物,完成构建。

五、Vite 的核心优势与适用场景

核心优势

  1. 极速启动:利用浏览器原生 ESM,无需全量打包,开发环境启动速度远超传统打包工具。
  2. 快速热更新:仅更新修改的单个模块,热更新响应无延迟,大幅提升开发效率。
  3. 丰富的插件生态:支持 Vue、React、TypeScript 等主流技术栈,兼容部分 Rollup 插件,扩展能力强。
  4. 开箱即用:内置 TypeScript、JSX、CSS 预处理器等支持,无需额外复杂配置。
  5. 高度可配置:vite.config.js 提供完善的配置项,可满足各类项目的工程化需求。
  6. 优化的生产打包:基于 Rollup 实现,产出的静态资源体积小、性能优,满足生产环境部署要求。

适用场景

  1. 新一代 SPA/MPA 项目开发。
  2. 前端组件库开发。
  3. 内部中后台系统、管理平台开发。
  4. 需要快速迭代的原型项目。
  5. 注重开发者体验的团队和项目。

六、总结

vite.config.js 作为 Vite 项目的核心配置文件,涵盖了环境配置、打包输出、插件扩展、本地开发等多个模块,一份完善的配置能够让前端工程化流程更规范、更高效。

Vite 凭借“开发环境按需编译、生产环境 Rollup 打包”的差异化策略,既解决了传统打包工具的性能瓶颈,又满足了生产环境的部署要求,是现代前端开发的优质选择。掌握 vite.config.js 的配置逻辑,能够充分发挥 Vite 的核心优势,助力项目高效开发与部署。

vue2+vue3 Table表格合并

之前在写表格合并的时候非常痛苦,弄不明白合并的具体逻辑,我这里直接贴上通用方法,只需要配置合并规则就可以了,在这里不扯那么多过程,你完全可以拷贝回去立马能用。

vue2 表格合并

<el-table
      :data="tableData"
     :span-method="(param)=>objectSpanMethod(param,tableData)"
      border
      style="width: 100%">
      <el-table-column
        prop="id"
        label="ID"
        width="180">
      </el-table-column>
      <el-table-column
        prop="name"
        label="姓名">
      </el-table-column>
      <el-table-column
        prop="amount1"
        sortable
        label="数值 1">
      </el-table-column>
      <el-table-column
        prop="amount2"
        sortable
        label="数值 2">
      </el-table-column>
      <el-table-column
        prop="amount3"
        sortable
        label="数值 3">
      </el-table-column>
    </el-table>
<script>
function filterArray(item) {
  const valueArray = this.rule.filter(prop => {
    return item[prop] === this.data[prop]
  })
  if (valueArray.length === this.rule.length) {
    return true
  } else {
    return false
  }
}
  export default {
    data() {
      return {
        tableData: [{
          id: '12987122',
          name: '王小虎',
          amount1: '234',
          amount2: '3.2',
          amount3: 10
        }, {
          id: '12987123',
          name: '王小虎',
          amount1: '165',
          amount2: '4.43',
          amount3: 12
        }, {
          id: '12987124',
          name: '王小虎',
          amount1: '324',
          amount2: '1.9',
          amount3: 9
        }, {
          id: '12987125',
          name: '王小虎',
          amount1: '621',
          amount2: '2.2',
          amount3: 17
        }, {
          id: '12987126',
          name: '王小虎',
          amount1: '539',
          amount2: '4.1',
          amount3: 15
        }],
        spanRule: {
            rule: {
              0: ['department_name']   //表示第一列的合并规则
            }
      }
      };
    },
    methods: {
      // 表格合并
          objectSpanMethod({ row, column, rowIndex, columnIndex }, item) {
            if (Object.keys(this.spanRule.rule).includes(columnIndex.toString())) {
              // filter验证数组
              const currentTable = {
                rule: this.spanRule.rule[columnIndex],
                data: item[rowIndex]
              }
              // 该单元格是否被合并 true 合并, false : 不合并
              let chooseSpan = false
              if (rowIndex !== 0) {
                chooseSpan = filterArray.call(currentTable, item[rowIndex - 1])
              }
              if (chooseSpan) {
                return {
                  rowspan: 0,
                  colspan: 0
                }
              } else {
                return {
                  rowspan: item.filter(filterArray, currentTable).length,
                  colspan: 1
                }
              }
            }
          },
    }
  };
</script>


vue3 表格合并

vue3 hooks文件内容


// 定义通用类型(支持任意表格数据类型)
export interface TableSpanRule {
    rule: Record<string, string[]>; // 列索引 → 合并字段列表
}

// 表格合并Hook
export function useTableSpan<T = Record<string, any>>(spanRule: TableSpanRule) {

    const filterArray = (
        currentTable: { rule: string[]; data: T },
        item: T
    ): boolean => {
        const valueArray = currentTable.rule.filter((prop) => {
            return item[prop] === currentTable.data[prop];
        });
        return valueArray.length === currentTable.rule.length;
    };

    const objectSpanMethod = (
        param: {
            row: T;
            column: T;
            rowIndex: number;
            columnIndex: number;
        },
        tableData: T[]
    ) => {
        const { columnIndex, rowIndex } = param;
        // 判断当前列是否在合并规则中
        if (Object.keys(spanRule.rule).includes(columnIndex.toString())) {
            const currentTable = {
                rule: spanRule.rule[columnIndex],
                data: tableData[rowIndex]
            };
            let chooseSpan = false;
            // 非第一行时验证是否需要合并
            if (rowIndex !== 0) {
                chooseSpan = filterArray(currentTable, tableData[rowIndex - 1]);
            }
            // 需要合并则隐藏当前单元格,否则设置合并行数
            if (chooseSpan) {
                return {
                    rowspan: 0,
                    colspan: 0
                };
            } else {
                return {
                    rowspan: tableData.filter((i) => filterArray(currentTable, i)).length,
                    colspan: 1
                };
            }
        }
        // 非合并列返回默认值
        return {
            rowspan: 1,
            colspan: 1
        };
    };

    return {
        objectSpanMethod
    };
}

vue3 表格合并

<el-table
      :data="tableData"
     :span-method="(param)=>objectSpanMethod(param,tableData)" //这里非常重要,tableData字段是表格的数据
      border
      style="width: 100%">
      <el-table-column 
        prop="day"
        label="day"
        width="180">
      </el-table-column>
      <el-table-column
        prop="domainName"
        label="domainName">
      </el-table-column>
      <el-table-column
        prop="allPurchaseCount"
        sortable
        label="allPurchaseCount">
      </el-table-column>
      <el-table-column
        prop="allPurchaseValue"
        sortable
        label="allPurchaseValue">
      </el-table-column>
      <el-table-column
        prop="gaAmountUsd"
        sortable
        label="交易额">
      </el-table-column>
    </el-table>
const tableCol = [  //表格列
  {
    label: t('localeAudience.datetime'),
    prop: 'day',
    width: 120,
    sortable: "custom",
    formatter: (row: any, column: any, text: any) => {
      return text || "-";
    },
  },
  {
    label: t('localeAudience.domain'),
    width: 120,
    prop: 'domainName',
    'show-overflow-tooltip': true,
  },
  {
    label: t('localeAudience.allorders'),
    sortable: "custom",
    width: 120,
    prop: 'allPurchaseCount',
  },
  {
    label: t('localeAudience.allamount'),
    sortable: "custom",
    width: 140,
    prop: 'allPurchaseValue',
  },
  {
    label: '交易额',
    sortable: "custom",
    width: 180,
    prop: 'gaAmountUsd',
  },
];
const tableData = [ // 1.表格数据
  {
    day: '2023-08-01',
    domainName: 'example.com',
    allPurchaseCount: 10,
    allPurchaseValue: 1000,
    gaAmountUsd: 500,
  },
  {
    day: '2023-08-01',
    domainName: 'example.com',
    allPurchaseCount: 5,
    allPurchaseValue: 500,
    gaAmountUsd: 250,
  },
  {
    day: '2023-08-02',
    domainName: 'example.com',
    allPurchaseCount: 8,
    allPurchaseValue: 800,
    gaAmountUsd: 400,
  },
];
// 2. 定义列合并规则
const spanRule = {
  rule: {
    0: ['day','domainName','allPurchaseCount','allPurchaseValue'], //表示第1列的合并规则
    1: ['day','domainName','allPurchaseCount','allPurchaseValue'], //表示第2列的合并规则
    2: ['day','domainName','allPurchaseCount','allPurchaseValue'], //表示第3列的合并规则
    3: ['day','domainName','allPurchaseCount','allPurchaseValue'], //表示第4列的合并规则
  }
};

// 3. 使用表格合并Hook
const { objectSpanMethod } = useTableSpan(spanRule);

大文档也能丝滑流式渲染?试试Markstream-Vue,体验前所未有的Markdown流畅感!

为什么选择Markstream-Vue?只因它“流”得够快!

  • 🚀 真·流式渲染,支持超大文档、实时预览,边写边看不卡顿
  • 🏆 彻底解决传统Markdown组件渲染慢、卡顿、内存暴涨等痛点
  • 🧩 组件化设计,Vue 3项目即插即用,API极简
  • 🎨 支持代码高亮、公式、流程图等丰富内容,体验无缝流畅
  • 🔥 SSR/静态站点/移动端全兼容,性能拉满

真实场景,极致体验

  • 技术文档、知识库、长篇小说、实时协作编辑器……再大的内容都能流畅渲染
  • 支持内容“边加载边渲染”,让用户体验“所见即所得”的极致流畅

3步上手,流式体验立享

  1. 安装依赖
pnpm add markstream-vue
  1. 引入组件
<Markstream :source="longMarkdown" stream />
  1. 享受流式渲染的丝滑体验!

你的Star,是我持续优化的最大动力!

👉 GitHub地址


使用uniapp vue2开发微信小程序时,图片处理插件

vue3处理插件

参考juejin.cn/post/738574…

因为上面的文章中提出的例子在vue2中并不生效, 因此单独写了一个针对vue2使用的loader.

实现1: 通过字符串替换方式处理

这个方式的缺点是较为死板, 无法处理模板字符串和表达式相关, 但是对于src=xxx的类型有较好的匹配

 module.exports = function (source) {
  console.log("----customLoader original content----", source);
  function replaceImageSrcInVue(content) {
    
    content = content.replace(
      /(<template[\s\S]*?>)([\s\S]*?)(<\/template>)/,
      (match, start, middle, end) => {
        // 替换 <image ... src="..." ...>
        const replaced = middle.replace(
          /(<image\b[^>]*?\bsrc=)(['"])([^'"]+)\2/gi,
          (imgMatch, prefix, quote, src) => {
            // 只替换非 http/https 开头的 src
            if (/^https?:\/\//.test(src)) return imgMatch;
            console.log(
              "----customLoader src----",
              imgMatch,
              "  prefix:",
              prefix,
              "   src:",
              src,
            );
            return `${prefix}${quote}${"https://www.xxx.com/"}${src}${quote}`;
          },
        );
        return start + replaced + end;
      },
    );
    return content;
  }
  return replaceImageSrcInVue(source);
};

实现2: 基于ast

这个模式的优点是可以精确匹配到image对应的src属性, 还可以对于绑定src的属性中的模板字符串和字符串类型进行处理, 比如说以下代码, 同时也可以很方便扩展到其他类型的元素中, 比如video等.

:src="isActive ? `${activeHost}/logo.png` : '/staticHost/logo.png'"

依赖编译器版本为yarn add -D @@vue/compiler-sfc@3.5.26

详细实现方式如下:

const compiler = require("@vue/compiler-sfc");

module.exports = function (source) {
  const options = this.getOptions();
  let { publicPath: staticHost, sourceDir } = options || {};
  if (staticHost.endsWith("/")) {
    staticHost = staticHost.slice(0, -1);
  }
  try {
    const sfc = compiler.parse(source, {
      templateParseOptions: { parseMode: "sfc" },
    });
    if (!sfc.descriptor.template) {
      return source;
    }
    let content = sfc.descriptor.template.content;
    const ast = sfc.descriptor.template.ast;
    const tempLen = "<template>".length; // 10, loc是基于整个文件的偏移量,需要减去前面的长度
    const traverseAst = (node) => {
      if (!node) return;
      if (node.children && node.children.length) {
        for (let i = node.children.length - 1; i >= 0; i--) {
          traverseAst(node.children[i]);
        }
      }
      const doReplace = (loc, oldValue) => {
        if (oldValue.startsWith(sourceDir)) {
          const newValue =
            '"' + oldValue.replace(sourceDir, `${staticHost}/`) + '"';
          content =
            content.slice(0, loc.start.offset - tempLen) +
            newValue +
            content.slice(loc.end.offset - tempLen);
        }
      };
      if (node.type === 1 && node.tag === "image") {
        // console.log("Found <image> node:", node);
        const srcAttr = node.props.find(
          (prop) => prop.name === "src" && prop.type === 6,
        );
        if (srcAttr) {
          console.log("Original src value:", srcAttr);
          const srcValue = srcAttr.value.content;
          const loc = srcAttr.value.loc;
          doReplace(loc, srcValue);
        } else {
          const bindSrcAttr = node.props.find(
            (prop) =>
              prop.name === "bind" &&
              prop.type === 7 &&
              prop.rawName === ":src",
          );
          // console.log("Bind src attribute:", bindSrcAttr);
          if (!bindSrcAttr) return;

          const ast = bindSrcAttr.exp.ast;
          const loc = bindSrcAttr.exp.loc;
          // 处理简单的模板字符串情况, 只需要遍历处理template和字符串类型就可以
          // 这里可能包含的类型为三目预算符和逻辑运算符
          const traverseBindAst = (bindNode, loc) => {
            if (!bindNode) return;
            // 逻辑运算符|| 或者 &&
            if (bindNode.type === "LogicalExpression") {
              traverseBindAst(bindNode.right, loc);
              traverseBindAst(bindNode.left, loc);
            } else if (bindNode.type === "ConditionalExpression") {
              // 三目运算符
              traverseBindAst(bindNode.alternate, loc);
              traverseBindAst(bindNode.consequent, loc);
              traverseBindAst(bindNode.test, loc);
            } else if (bindNode.type === "TemplateLiteral") {
              // 模板字符串类型
              if (bindNode.quasis && bindNode.quasis.length > 0) {
                const indexLoc = bindNode.quasis[0].loc;
                const value = bindNode.quasis[0].value.cooked;
                if (value.startsWith(sourceDir)) {
                  const newValue = value.replace(sourceDir, `${staticHost}/`);
                  content =
                    content.slice(
                      0,
                      loc.start.offset - tempLen + indexLoc.start.index - 1,
                    ) + // -1 是因为模板字符串的 ` 符号占位
                    newValue +
                    content.slice(
                      loc.start.offset - tempLen + indexLoc.end.index - 1,
                    );
                }
              }
            } else if (bindNode.type === "StringLiteral") {
              // 字符串类型
              const indexLoc = bindNode.loc;
              const value = bindNode.value;
              if (value.startsWith(sourceDir)) {
                const newValue = value.replace(sourceDir, `${staticHost}/`);
                content =
                  content.slice(
                    0,
                    loc.start.offset - tempLen + indexLoc.start.index, // 这里不减是需要保留 "" 符号
                  ) +
                  newValue +
                  content.slice(
                    loc.start.offset - tempLen + indexLoc.end.index - 2,
                  ); // -2 是因为字符串的 "" 符号占位
              }
            }
          };
          traverseBindAst(ast, loc);
        }
      }
    };
    traverseAst(ast);
    // 替换 template 内容
    const loc = sfc.descriptor.template.loc;
    const newSource = source.slice(0, loc.start.offset) + content + source.slice(loc.end.offset);
    return newSource;
  } catch (err) {
    console.error("Error parsing SFC:", err);
    return source;
  }
}

在vue.config.js中的用法

chainWebpack: (config) => {
      config.module
        .rule("vue")
        .use("vue-loader")
        .end()
        .use("customLoader")
        .loader(path.resolve(__dirname, "./customLoader.js"))
        .options({
          publicPath: "https://xxx.com",
          sourceDir: '/staticHost/',
        })
        .end();
  }

ps

如果遇到报错this.getConfig不存在, 则可以把config配置项写到load.js里面中...

❌