阅读视图

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

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里面中...

🚀 告别繁琐配置!这款 Vue 云上传组件让文件上传变得如此简单

🚀 告别繁琐配置!这款 Vue 云上传组件让文件上传变得如此简单

前端开发中,文件上传功能几乎是每个项目都绕不开的需求。但你是否也曾为对接腾讯云COS、华为云OBS、阿里云OSS而头疼?是否也曾为分片上传、断点续传、进度显示等功能而熬夜加班?

今天,我要向大家推荐一款开箱即用、功能强大的 Vue 云上传组件 —— vue-cloud-upload,它将彻底改变你对文件上传的认知!

✨ 为什么选择 vue-cloud-upload?

🎯 痛点一:三大云平台 SDK 对接繁琐

传统做法:

  • 需要分别学习腾讯云、华为云、阿里云的 SDK 文档
  • 每个平台的 API 调用方式各不相同
  • 临时凭证获取逻辑需要自己实现
  • 代码冗余,维护成本高

vue-cloud-upload 的解决方案:

<template>
  <CloudUpload
    cloudType="tencent"
    :cloudConfig="cloudConfig"
    v-model="fileList"
    @success="handleSuccess"
  />
</template>

<script>
import COS from 'cos-js-sdk-v5';
import CloudUpload, { setExternalCOS } from 'vue-cloud-upload';

setExternalCOS(COS);

export default {
  data() {
    return {
      cloudConfig: {
        bucket: "your-bucket",
        region: "ap-guangzhou",
        path: "uploads/",
        getTempCredential: this.getTempCredential
      }
    };
  }
};
</script>

只需三步:

  1. 安装对应云平台的 SDK
  2. 配置云平台参数
  3. 引入组件即可使用!

🎯 痛点二:大文件上传体验差

传统做法:

  • 大文件上传容易失败
  • 网络波动需要重新上传
  • 用户无法看到上传进度
  • 用户体验极差

vue-cloud-upload 的解决方案:

  • 自动分片上传:大文件自动切分成小块上传
  • 断点续传:网络中断后可继续上传,无需重新开始
  • 实时进度显示:上传进度实时更新,用户一目了然
  • 分片大小可配置:根据网络环境灵活调整

🎯 痛点三:文件预览功能缺失

传统做法:

  • 上传后只能看到文件名
  • 无法预览图片、PDF、视频等内容
  • 需要额外开发预览功能
  • 增加开发成本

vue-cloud-upload 的解决方案:

  • 📸 图片预览:支持图片缩放、旋转、全屏查看
  • 📄 PDF 预览:直接在线查看 PDF 文档
  • 🎬 视频播放:内置视频播放器,支持在线播放
  • 🎵 音频播放:支持音频文件在线播放
  • 📝 TXT 预览:文本文件直接查看内容

🌟 核心特性一览

1️⃣ 三大云平台无缝对接

  • 🅰️ 腾讯云 COS
  • 🅱️ 华为云 OBS
  • 🅾️ 阿里云 OSS

2️⃣ 丰富的功能特性

功能 说明
多文件上传 支持同时上传多个文件
拖拽上传 支持拖拽文件到上传区域
文件类型限制 可限制上传文件类型
文件大小限制 可限制单个文件大小
上传进度显示 实时显示上传进度
文件列表管理 支持查看、删除已上传文件
附件回显 支持通过文件 key 回显附件
自定义样式 支持自定义上传组件样式
丰富的事件回调 支持上传成功、失败、进度等事件

3️⃣ 灵活的配置选项

cloudConfig: {
  bucket: "your-bucket",           // 桶名
  region: "ap-guangzhou",          // 地域
  path: "uploads/",                // 上传目录
  getTempCredential: async () => { // 获取临时凭证
    const response = await fetch('/api/sts');
    return await response.json();
  }
}

4️⃣ 多种文件 key 生成策略

  • uuid:使用 UUID 生成唯一文件名
  • name:使用原始文件名
  • uuid+name:使用 UUID + 原始文件名(默认)
  • customKey:自定义函数生成文件 key

📦 快速开始

安装组件

npm install vue-cloud-upload

安装对应云平台 SDK

# 腾讯云 COS
npm install cos-js-sdk-v5

# 华为云 OBS
npm install esdk-obs-browserjs

# 阿里云 OSS
npm install ali-oss

基础使用示例

<template>
  <div>
    <CloudUpload
      cloudType="tencent"
      :cloudConfig="cloudConfig"
      v-model="fileList"
      :multiple="true"
      :limit="5"
      :maxSize="100"
      @success="handleSuccess"
      @error="handleError"
      @progress="handleProgress"
    />
  </div>
</template>

<script>
import COS from 'cos-js-sdk-v5';
import "vue-cloud-upload/dist/vue-cloud-upload.css";
import CloudUpload, { setExternalCOS } from 'vue-cloud-upload';

setExternalCOS(COS);

export default {
  components: { CloudUpload },
  data() {
    return {
      fileList: [],
      cloudConfig: {
        bucket: "your-bucket",
        region: "ap-guangzhou",
        path: "uploads/",
        getTempCredential: this.getTempCredential
      }
    };
  },
  methods: {
    async getTempCredential() {
      const response = await fetch('/api/sts');
      return await response.json();
    },
    handleSuccess(result, file) {
      console.log('上传成功:', result.url);
    },
    handleError(error, file) {
      console.error('上传失败:', error);
    },
    handleProgress(percent, file) {
      console.log('上传进度:', percent);
    }
  }
};
</script>

🎨 功能演示

各类文件上传

各类型文件上传.png

上传进度展示

上传进度.png

丰富的参数配置

参数配置.png

视频预览

视频预览.png

图片预览

图片预览.png

PDF 预览

pdf预览.png

💡 实战场景

场景一:企业级文件管理系统

<CloudUpload
  cloudType="aliyun"
  :cloudConfig="cloudConfig"
  v-model="fileList"
  :multiple="true"
  :limit="10"
  :maxSize="500"
  listType="picture-card"
  :previewConfig="{
    image: true,
    pdf: true,
    video: true,
    audio: true
  }"
/>

场景二:图片上传组件

<CloudUpload
  cloudType="tencent"
  :cloudConfig="cloudConfig"
  v-model="imageList"
  accept=".jpg,.jpeg,.png,.gif"
  :maxSize="10"
  listType="picture-card"
  :keyType="'uuid'"
/>

场景三:文档上传组件

<CloudUpload
  cloudType="huawei"
  :cloudConfig="cloudConfig"
  v-model="docList"
  accept=".pdf,.doc,.docx,.xls,.xlsx"
  :maxSize="50"
  listType="text"
/>

🔮 未来规划

组件正在持续迭代中,以下功能正在开发中:

  • 🔄 图片添加水印
  • 🔄 图片无损压缩
  • 🔄 视频首帧截取
  • 🔄 Office 文档在线预览(Word, Excel, PowerPoint)
  • 🔄 更多云存储平台支持

📊 项目数据

  • ⭐ GitHub Stars:持续增长中
  • 📦 NPM 下载量:月下载量稳步上升
  • 🎯 支持平台:腾讯云、华为云、阿里云
  • 📝 文档完善度:详细的使用文档和示例
  • 🐛 问题响应:快速响应和修复

🤝 贡献与支持

如果你觉得这个组件对你有帮助,欢迎:

  • 给项目一个 ⭐️ Star
  • 提交 Issue 和 Pull Request
  • 分享给你的同事和朋友

📚 完整文档

更多详细的使用文档和 API 说明,请查看:

📧 联系方式

商务合作请通过邮箱联系:shazhoulen@outlook.com


vue-cloud-upload —— 让文件上传变得更简单!

如果你正在为文件上传功能而烦恼,不妨试试这个组件,相信它会给你带来惊喜!🎉


相关推荐:

深挖 van-list:一次分页加载问题的完整排查

太长不看版

问题:切换筛选项时,如果不滚动页面,loading 会一直显示,但滚动后再切换就正常。

原因

  1. processingData 判断逻辑有问题:当数据量刚好等于 pageSize 时,错误地判断 finished = false
  2. van-list 的 watch 机制:当 finishedfalse 变成 false 时不会触发加载
  3. 两个问题叠加导致切换筛选后没有触发数据加载

解决方案:修改 processingData 的判断逻辑,用 累积数据量 >= total 代替 list.length < pageSize


背景

最近在做会员详情页,有个余额明细的列表,可以切换筛选项(全部/充值/消费)。看起来很简单的功能,结果遇到了一个莫名其妙的问题。

项目技术栈

  • Vue 3 + Vant 4
  • 分页加载用的 van-list 组件

问题现象

  • 进入页面,默认显示"全部"筛选的数据(10 条,total=10)
  • 不滑动页面,直接点击"充值"或"消费"筛选,一直 loading,没有数据
  • 但如果先滑动页面,再切换筛选,正常

第一次遇到这种问题,完全摸不着头脑。为什么滑不滑动页面会有区别?


临时修复

因为要上线,我需要快速解决这个问题。看了代码发现切换筛选时会调用 resetListParams()

const resetListParams = () => {
  nextTick(() => {
    state.list = []
    queryParams.page = 1
    scrollView?.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
    state.finished = false
  })
  showLoadingToast({
    message: '加载中...',
    forbidClick: true,
    duration: 0
  })
}

我猜测可能是因为没有触发数据加载,于是加了一行手动调用 getList()

const resetListParams = () => {
  nextTick(() => {
    state.list = []
    queryParams.page = 1
    scrollView?.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
    state.finished = false
    getList()  // 加了这一行
  })
  showLoadingToast({
    message: '加载中...',
    forbidClick: true,
    duration: 0
  })
}

结果:能用,切换筛选正常了。

但我心里还是觉得不踏实:为什么原本的代码不行?滑动页面后就正常?其他用 van-list 的地方会不会也有这个问题?


深入排查

上线后,我打算搞清楚这个问题。

第一步:重现问题

把之前加的 getList() 注释掉,重新测试:

const resetListParams = () => {
  nextTick(() => {
    state.list = []
    queryParams.page = 1
    scrollView?.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
    state.finished = false
    // getList()  // 注释掉,重现问题
  })
  showLoadingToast({
    message: '加载中...',
    forbidClick: true,
    duration: 0
  })
}

问题重现

  • 不滑动页面,直接切换筛选,一直 loading
  • 滑动页面后,再切换筛选,正常

第二步:确认请求没发起

打开开发者工具,Network 面板里确实没有新的请求。所以问题不是请求失败,而是根本没发起请求

第三步:分析为什么滑动就正常

看一下 van-list 的用法:

<van-list
  v-model:loading="state.listLoading"
  :finished="state.finished"
  :offset="30"
  @load="getList"
  :finished-text="state.list?.length ? '加载完成' : ''"
>
  <div
    class="member-item"
    v-for="item in state.list"
    :key="item.orderNo"
  >
    <!-- 列表项内容 -->
  </div>
</van-list>

我知道 van-list 会在滚动到底部时触发 @load 事件加载更多数据。

但现在的问题是:切换筛选后,我已经在 resetListParams() 里设置了 state.finished = false,按理说 van-list 应该要重新加载数据才对。为什么不滚动的话,@load 就不会触发呢?难道 van-list 只能通过滚动来触发加载,没有其他方式吗?

我开始怀疑是 finished 状态的问题。

第四步:检查 finished 的判断逻辑

第一次加载数据的代码:

CustomerApiFetch.customerCardBalanceChangeListPost(params)
  .then((res) => {
    const { total, list } = res  // total=10, list.length=10
    const { data, finished } = processingData(state.list, list, queryParams)
    state.list = [...data]
    state.total = total
    state.finished = finished
  })

processingData 是项目的公共 hook,用于处理分页数据:

export const processingData = (data, list, param) => {
  const newData = param.page === 1 ? list : [...data, ...list]
  const finished = list.length < param?.pageSize  // 判断逻辑
  if (!finished) {
    param.page++
  }
  return { data: newData, finished }
}

发现问题

当第一次加载时:

  • list.length = 10(返回 10 条数据)
  • param.pageSize = 10
  • finished = 10 < 10 = false(错误)

但实际上,total 也是 10,说明数据已经全部加载完了,finished 应该是 true 才对!

第五步:理解 van-list 的触发机制

现在我明白了问题的一半:finished 被错误地判断为 false

但还有一个疑问:为什么滑动页面后就正常了?

我去看了 van-list 的源码(node_modules/vant/lib/list/List.js):

// 第 124 行:监听 props 变化
(0, import_vue.watch)(() => [props.loading, props.finished, props.error], check);

// 第 143 行:监听滚动事件
(0, import_use.useEventListener)("scroll", check, {
  target: scroller,
  passive: true
});

原来 van-list 有两种触发 check() 的方式

  1. watch 监听 props 变化:当 finishedloadingerror 改变时
  2. scroll 事件监听:用户滚动时

再看 check() 函数的实现(第 58-85 行):

const check = () => {
  (0, import_vue.nextTick)(() => {
    if (loading.value || props.finished || props.disabled || props.error ||
        (tabStatus == null ? void 0 : tabStatus.value) === false) {
      return;  // 如果 finished=true,直接返回
    }

    // 计算是否滚动到边缘
    // ...

    if (isReachEdge) {
      loading.value = true;
      emit("update:loading", true);
      emit("load");  // 触发 @load 事件
    }
  });
};

现在全部串起来了

第六步:对比两种情况

情况 1:滑动页面后切换(正常)

1. 第一次加载
   ├─ 返回 10 条数据,total=10
   ├─ processingData 判断:list.length(10) < pageSize(10) = false
   └─ state.finished = false(错误判断)

2. 用户滑动页面
   ├─ 触发 scroll 事件
   ├─ van-list 调用 check()
   ├─ 检测到滚动到底部
   └─ 触发 @load

3. 第二次加载(page=2)
   ├─ 返回空数组(因为只有 10 条数据)
   ├─ processingData 判断:list.length(0) < pageSize(10) = true
   └─ state.finished = true(被意外纠正了)

4. 用户切换筛选
   ├─ resetListParams() 设置 state.finished = false
   ├─ finished: true 变成 false(状态改变了!)
   ├─ van-list 的 watch 被触发
   └─ 自动触发 @load

情况 2:不滑动直接切换(问题)

1. 第一次加载
   ├─ 返回 10 条数据,total=10
   ├─ processingData 判断:list.length(10) < pageSize(10) = false
   └─ state.finished = false(错误判断)

2. 用户直接切换筛选(没有滚动)
   ├─ resetListParams() 设置 state.finished = false
   ├─ finished: false 变成 false(状态没变!)
   ├─ van-list 的 watch 不触发(Vue watch 机制:值没变就不触发)
   ├─ scroll 事件也没有(用户没滚动)
   └─ 没有任何方式触发 @load

3. 结果
   ├─ showLoadingToast() 已经显示
   ├─ 但没有请求发起
   └─ loading 永远不会关闭

搞明白了


根本原因

问题有两个层面:

1. processingData 的判断逻辑有缺陷

const finished = list.length < param?.pageSize

这个判断在以下情况下是错误的

场景 list.length pageSize total 实际状态 判断结果 是否正确
还有数据 10 10 30 未完成 false 正确
刚好加载完 10 10 10 已完成 false 错误
最后一页不足 5 10 15 已完成 true 正确

当返回的数据量刚好等于 pageSize,且已经是全部数据时,finished 会被错误地判断为 false。

2. van-list 的触发机制

van-list 的 @load 有两种触发方式:

触发方式 触发条件 使用场景
watch 监听 finishedloadingerror 状态改变 状态切换(true 和 false 互相切换)
scroll 事件 用户滚动到底部 正常的分页加载

当 finished 从 false 变成 false 时

  • watch 不会触发(Vue 的 watch 机制,值没变就不触发)
  • scroll 也不会触发(用户没滚动)
  • 结果:没有任何方式触发 @load

3. 两个问题叠加

processingData 错误判断
  |
  v
finished = false(应该是 true
  |
  v
用户切换筛选
  |
  v
resetListParams 设置 finished = false
  |
  v
finished: false 变成 false(状态没变)
  |
  v
van-list  watch 不触发
  |
  v
没有 scroll 事件
  |
  v
@load 不触发
  |
  v
一直 loading

解决方案对比

方案 1:手动调用 getList()(临时方案)

const resetListParams = () => {
  nextTick(() => {
    state.list = []
    queryParams.page = 1
    state.finished = false
    getList()  // 手动调用
  })
  showLoadingToast({ ... })
}

优点

  • 快速修复,立即上线

缺点

  • 治标不治本,finished 的判断还是错的
  • 其他 10 个使用 processingData 的页面也有同样的问题

适用场景:紧急上线,先解决问题


方案 2:修复 processingData 判断逻辑

修改 src/hooks/processingData.ts

/**
 * @function 处理分页数据
 * @param { Array } data 保存的数据
 * @param { Array } list 接口请求回来的数据
 * @param { Object } param 请求接口的分页数据
 * @param { Number } total 数据总数
 * @return { data } 处理后的数据
 * @return { finished } 数据是否全部请求完
 */
export const processingData = (data, list, param, total) => {
  const newData = param.page === 1 ? list : [...data, ...list]
  const finished = newData.length >= total  // 使用累积数据量判断
  if (!finished) {
    param.page++
  }
  return { data: newData, finished }
}

然后更新所有 10 个调用的文件,传入 total 参数:

// 修改前
const { data, finished } = processingData(state.list, list, queryParams)

// 修改后
const { data, finished } = processingData(state.list, list, queryParams, total)

优点

  • 从根源解决问题
  • 所有使用分页加载的页面都受益
  • finished 状态永远准确
  • 不需要在 resetListParams 里手动调用 getList()

缺点

  • 需要修改 10 个文件
  • 需要测试所有相关页面

适用场景:彻底解决问题,消除技术债


方案 3:强制触发 watch(hack 方案)

const resetListParams = () => {
  nextTick(() => {
    state.list = []
    queryParams.page = 1

    // 先设置为 true,再设置为 false,强制触发 watch
    state.finished = true
    nextTick(() => {
      state.finished = false  // true 变成 false,触发 van-list 的 watch
    })
  })
  showLoadingToast({ ... })
}

优点

  • 不需要改 processingData
  • 不需要改其他文件
  • 利用了 van-list 的 watch 机制

缺点

  • 非常 hack,不优雅
  • finished 的判断还是错的
  • 状态闪烁(true 变成 false)可能有副作用

最终选择

我选择方案 2:修复 processingData 判断逻辑


踩坑总结

  1. 公共 hook 的判断逻辑要严谨

    • list.length < pageSize 看起来对,但有边界情况
    • 应该用 累积数据量 >= total 来判断
    • 边界情况很容易被忽略
  2. 了解组件库的触发机制很重要

    • 不要只会用,要知道原理
    • van-list 的 watch + scroll 两种触发方式
    • 状态改变和滚动事件的区别
  3. 临时方案要知道只是临时的

    • 昨晚加 getList() 是为了上线
    • 但不能一直用临时方案
    • 要找时间深入研究,彻底解决

一些想法

昨晚为了赶上线,我就直接加了 getList() 就完事了。当时就想着"能用就行",但心里总觉得哪里不对。

今天重新看这个问题,发现还挺有意思的:

  • processingData 的判断逻辑有问题
  • van-list 的 watch 机制
  • 两个问题叠加就出现了

要不是"滑动就正常"这个线索,我估计还得调试更久。就是这个奇怪的现象让我发现,滑动前后 finished 的状态不一样,顺着这个思路才找到根本原因。

还有就是看源码真的有用。之前我就只会用 van-list,知道有 finished@load,但完全不知道它内部怎么工作的。看了源码才明白 watch 和 scroll 两种触发方式,也搞清楚了为什么 false 变成 false 不会触发。

关于临时方案和彻底修复,我觉得都需要吧。昨晚的临时方案让我能按时上线,今天的深入研究让我理解了问题本质。不能因为有临时方案就不去研究,也不能因为追求完美就一直不上线。

深入浅出 TinyEditor 富文本编辑器系列4:基础使用示例

你好,我是 Kagol,个人公众号:前端开源星球

TinyEditor 是一个基于 Quill 2.0 的富文本编辑器,在 Quill 基础上扩展了丰富的模块和格式,功能强大、开箱即用。

本文是《深入浅出 TinyEditor 富文本编辑器系列》文章的第4篇,主要介绍 TinyEditor 的基础使用示例。

本文提供了 TinyEditor 入门的综合示例,涵盖了面向初学者开发者的基本实现模式和常见用例。

快速开始实现

初始化 TinyEditor 最基本的方法是通过容器元素和配置选项创建新实例:

import FluentEditor from '@opentiny/fluent-editor'
 
const editor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      ['bold', 'italic', 'underline'],
      ['link', 'blockquote'],
      [{ list: 'ordered' }, { list: 'bullet' }]
    ]
  }
})

核心架构概述

TinyEditor 扩展了 Quill.js,提供了增强的模块和功能。该架构采用模块化设计,每个功能都作为独立模块实现:

image.png

基本配置示例

基本文本编辑器设置

创建带有基本格式化工具的简单文本编辑器:

const basicEditor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      ['undo', 'redo'],
      ['bold', 'italic', 'strike', 'underline'],
      [{ script: 'super' }, { script: 'sub' }],
      [{ color: [] }, { background: [] }],
      [{ list: 'ordered' }, { list: 'bullet' }],
      ['link', 'blockquote', 'code-block']
    ]
  }
})

带表格的高级编辑器

对于需要表格功能的更复杂文档:

import { generateTableUp } from '@opentiny/fluent-editor'
import { defaultCustomSelect,TableMenuSelect, TableSelection, TableUp } from 'quill-table-up'
 
FluentEditor.register({ 'modules/table-up': generateTableUp(TableUp) }, true)
 
const advancedEditor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      ['undo', 'redo', 'format-painter', 'clean'],
      [
        { header: [false, 1, 2, 3, 4, 5, 6] },
        { size: ['12px', '14px', '16px', '18px', '24px', '32px'] },
        'bold', 'italic', 'strike', 'underline'
      ],
      [{ color: [] }, { background: [] }],
      [{ align: ['', 'center', 'right', 'justify'] }],
      [{ 'table-up': [] }],
      ['link', 'blockquote']
    ],
    'table-up': {
      customSelect: defaultCustomSelect,
      modules: [
        { module: TableSelection },
        { module: TableMenuSelect },
      ],
    },
  }
})

模块配置模式

协同编辑设置

通过 WebSocket provider 启用实时协作:

FluentEditor.register('modules/collaborative-editing', CollaborationModule, true)
 
const collaborativeEditor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
      'collaborative-editing': {
        deps: {
          Y,
          Awareness,
          QuillBinding,
          QuillCursors,
          WebsocketProvider,
          IndexeddbPersistence,
        },
        provider: {
          type: 'websocket',
          options: {
            serverUrl: 'wss://ai.opentiny.design/tiny-editor/',
            roomName: ROOM_NAME,
          },
        },
        awareness: {
          state: {
            name: `userId:${Math.random().toString(36).substring(2, 15)}`,
            color: `rgb(${Math.floor(Math.random() * 255)},${Math.floor(Math.random() * 255)},${Math.floor(Math.random() * 255)})`,
          },
        },
        cursors: {
          template: `
              <span class="${CURSOR_CLASSES.SELECTION_CLASS}"></span>
              <span class="${CURSOR_CLASSES.CARET_CONTAINER_CLASS}">
                <span class="${CURSOR_CLASSES.CARET_CLASS}"></span>
              </span>
              <div class="${CURSOR_CLASSES.FLAG_CLASS}">
                <small class="${CURSOR_CLASSES.NAME_CLASS}"></small>
              </div>
          `,
          hideDelayMs: 500,
          hideSpeedMs: 300,
          transformOnTextChange: true,
        },
      },
  }
})

文件上传配置

配置带有自定义 MIME 类型限制的文件上传:

const editorWithUpload = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      ['bold', 'italic'],
      ['image', 'video', 'link']
    ],
    'uploader': {
      mimetypes: [
        'image/jpeg',
        'image/png',
        'image/gif',
        'application/pdf'
      ],
      handler(range: Range, files: File[]) {
        return files.map((_, i) => i % 2 === 0 ? false : 'https://developer.mozilla.org/static/media/chrome.5e791c51c323fbb93c31.svg')
      },
      fail(file: File, range: Range) {
        this.quill.updateContents(new Delta().retain(range.index).delete(1).insert({ image: 'https://developer.mozilla.org/static/media/edge.741dffaf92fcae238b84.svg' }))
      },
    },
  }
})

常见使用场景

内容初始化

创建编辑器时设置初始内容:

const initialContent = `
<h1>Document Title</h1>
<p>This is a <strong>sample</strong> document with <em>formatted</em> text.</p>
<ul>
  <li>First item</li>
  <li>Second item</li>
</ul>
<blockquote>Important quote here</blockquote>
`
 
const editor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    toolbar: ['bold', 'italic', 'blockquote']
  }
})
 
// 在初始化后设置内容
editor.clipboard.dangerouslyPasteHTML(0, initialContent)

事件处理

监听编辑器事件以实现自定义功能:

const editor = new FluentEditor('#editor', {
  theme: 'snow'
})
 
// 监听文本变化
editor.on('text-change', (delta, oldDelta, source) => {
  console.log('Text changed:', delta)
})
 
// 监听选择变化
editor.on('selection-change', (range, oldRange, source) => {
  if (range) {
    console.log('User selected text:', range)
  } else {
    console.log('User lost focus')
  }
})

样式与主题

自定义主题应用

使用 snow 主题应用自定义样式:

const styledEditor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    toolbar: [
      [{ header: [1, 2, 3, false] }],
      ['bold', 'italic', 'underline'],
      [{ color: [] }, { background: [] }]
    ]
  },
  placeholder: 'Start typing your document...'
})

国际化设置

配置多语言支持:

const i18nEditor = new FluentEditor('#editor', {
  theme: 'snow',
  modules: {
    'i18n': {
      lang: 'zh-CN',
      fallback: 'en-US'
    },
    toolbar: ['bold', 'italic', 'link']
  }
})
 
// 动态切换语言
editor.getModule('i18n').setLanguage('en-US')

集成示例

Vue.js 集成

<template>
  <div>
    <div ref="editorRef" class="editor-container"></div>
  </div>
</template>
 
<script setup>
import { ref, onMounted } from 'vue'
import FluentEditor from '@opentiny/fluent-editor'
 
const editorRef = ref()
let editor
 
onMounted(() => {
  editor = new FluentEditor(editorRef.value, {
    theme: 'snow',
    modules: {
      toolbar: ['bold', 'italic', 'link']
    }
  })
})
</script>

React 集成

import { useEffect, useRef } from 'react'
import FluentEditor from '@opentiny/fluent-editor'
 
function EditorComponent() {
  const editorRef = useRef()
  const editorInstanceRef = useRef()
 
  useEffect(() => {
    editorInstanceRef.current = new FluentEditor(editorRef.current, {
      theme: 'snow',
      modules: {
        toolbar: ['bold', 'italic', 'link']
      }
    })
 
    return () => {
      editorInstanceRef.current = null
    }
  }, [])
 
  return <div ref={editorRef} className="editor-container" />
}

最佳实践

  1. 始终指定主题 - 'snow' 主题提供默认 UI
  2. 配置工具栏模块 - 定义用户可用的工具
  3. 处理内容初始化 - 在编辑器创建后设置初始内容
  4. 实现事件监听器 - 响应用户交互和内容变化
  5. 使用适当的清理 - 卸载组件时销毁编辑器实例

这些示例为使用 TinyEditor 构建复杂的富文本应用程序提供了基础。从基本设置开始,根据需要逐步添加更复杂的功能。

联系我们

GitHub:github.com/opentiny/ti…(欢迎 Star ⭐)

官网:opentiny.github.io/tiny-editor

个人博客:kagol.github.io/blogs/

小助手微信:opentiny-official

公众号:OpenTiny

Vue3中如何优雅实现支持多绑定变量和修饰符的双向绑定组件?

一、自定义input/select等基础表单组件(v-model配合props/emit)

1.1 双向绑定的核心原理

Vue3中组件的双向绑定本质是propsemit的语法糖。在Vue3.4+版本,官方推荐使用defineModel()宏简化实现,而低版本则需要手动处理属性与事件的传递。

1.2 自定义Input组件

方式一:使用defineModel宏(Vue3.4+推荐)

<!-- CustomInput.vue -->
<script setup>
// defineModel自动处理props和emit的双向绑定
const model = defineModel()
</script>

<template>
  <input 
    v-model="model" 
    placeholder="请输入内容" 
    class="custom-input"
  />
</template>

<style scoped>
.custom-input {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
}
</style>

父组件使用:

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

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

<template>
  <div>
    <CustomInput v-model="inputValue" />
    <p class="mt-2">输入结果:{{ inputValue }}</p>
  </div>
</template>

方式二:手动处理props与emit(兼容低版本)

<!-- CustomInputLegacy.vue -->
<script setup>
// 接收父组件传递的value
const props = defineProps(['modelValue'])
// 定义更新事件
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  <input 
    :value="props.modelValue" 
    @input="emit('update:modelValue', $event.target.value)"
    placeholder="请输入内容"
    class="custom-input"
  />
</template>

父组件使用方式与defineModel版本完全一致。

1.3 自定义Select组件

<!-- CustomSelect.vue -->
<script setup>
const model = defineModel()
// 接收选项配置
const props = defineProps({
  options: {
    type: Array,
    required: true,
    default: () => []
  },
  placeholder: {
    type: String,
    default: '请选择'
  }
})
</script>

<template>
  <select v-model="model" class="custom-select">
    <option value="" disabled>{{ props.placeholder }}</option>
    <option 
      v-for="option in props.options" 
      :key="option.value" 
      :value="option.value"
    >
      {{ option.label }}
    </option>
  </select>
</template>

<style scoped>
.custom-select {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
  background-color: white;
}
</style>

父组件使用:

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

const selectedValue = ref('')
const selectOptions = [
  { value: 'vue', label: 'Vue.js' },
  { value: 'react', label: 'React' },
  { value: 'angular', label: 'Angular' }
]
</script>

<template>
  <div>
    <CustomSelect 
      v-model="selectedValue" 
      :options="selectOptions" 
      placeholder="选择前端框架"
    />
    <p class="mt-2">选中值:{{ selectedValue }}</p>
  </div>
</template>

1.4 多v-model绑定

Vue3支持在单个组件上绑定多个v-model,通过指定参数区分:

<!-- UserForm.vue -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>

<template>
  <div class="flex gap-2">
    <input v-model="firstName" placeholder="姓" class="custom-input" />
    <input v-model="lastName" placeholder="名" class="custom-input" />
  </div>
</template>

父组件使用:

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

const userFirstName = ref('')
const userLastName = ref('')
</script>

<template>
  <div>
    <UserForm 
      v-model:first-name="userFirstName" 
      v-model:last-name="userLastName" 
    />
    <p class="mt-2">姓名:{{ userFirstName }} {{ userLastName }}</p>
  </div>
</template>

1.5 处理v-model修饰符

自定义组件也可以支持v-model修饰符,比如实现首字母大写:

<!-- CustomInputWithModifier.vue -->
<script setup>
const [model, modifiers] = defineModel({
  set(value) {
    // 处理capitalize修饰符
    if (modifiers.capitalize && value) {
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
    return value
  }
})
</script>

<template>
  <input v-model="model" placeholder="请输入内容" class="custom-input" />
</template>

父组件使用:

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

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

<template>
  <div>
    <CustomInputWithModifier v-model.capitalize="inputValue" />
    <p class="mt-2">处理后的值:{{ inputValue }}</p>
  </div>
</template>

二、复合表单组件的封装(如带验证的输入框、日期选择器)

2.1 带验证的输入框

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

封装一个集成验证逻辑的输入框组件,支持多种验证规则:

<!-- ValidatedInput.vue -->
<script setup>
import { ref, computed } from 'vue'
const model = defineModel()
const props = defineProps({
  rules: {
    type: Object,
    default: () => ({})
  },
  label: {
    type: String,
    default: ''
  }
})

const showError = ref(false)
const errorMessage = ref('')

// 验证输入值
const validate = (value) => {
  showError.value = false
  errorMessage.value = ''

  // 必填验证
  if (props.rules.required && !value) {
    showError.value = true
    errorMessage.value = props.rules.requiredMessage || '此字段为必填项'
    return false
  }

  // 最小长度验证
  if (props.rules.minLength && value.length < props.rules.minLength) {
    showError.value = true
    errorMessage.value = props.rules.minLengthMessage || 
      `最少需要输入${props.rules.minLength}个字符`
    return false
  }

  // 邮箱格式验证
  if (props.rules.email && value) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    if (!emailRegex.test(value)) {
      showError.value = true
      errorMessage.value = props.rules.emailMessage || '请输入有效的邮箱地址'
      return false
    }
  }

  return true
}

// 失去焦点时触发验证
const handleBlur = () => {
  validate(model.value)
}

// 输入时清除错误提示
const handleInput = () => {
  showError.value = false
  errorMessage.value = ''
}
</script>

<template>
  <div class="validated-input">
    <label v-if="props.label" class="input-label">{{ props.label }}</label>
    <input 
      v-model="model" 
      @blur="handleBlur" 
      @input="handleInput"
      :class="{ 'input-error': showError }"
      class="custom-input"
      :placeholder="props.label || '请输入内容'"
    />
    <div v-if="showError" class="error-message">{{ errorMessage }}</div>
  </div>
</template>

<style scoped>
.validated-input {
  margin-bottom: 16px;
}
.input-label {
  display: block;
  margin-bottom: 4px;
  font-size: 14px;
  font-weight: 500;
}
.input-error {
  border-color: #ff4d4f;
}
.error-message {
  margin-top: 4px;
  font-size: 12px;
  color: #ff4d4f;
}
</style>

父组件使用:

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

const email = ref('')
const emailRules = {
  required: true,
  requiredMessage: '邮箱不能为空',
  email: true,
  emailMessage: '请输入有效的邮箱地址'
}
</script>

<template>
  <ValidatedInput 
    v-model="email" 
    label="邮箱地址" 
    :rules="emailRules" 
  />
</template>

2.2 日期选择器组件

封装一个支持格式化和范围选择的日期选择器:

<!-- DatePicker.vue -->
<script setup>
import { ref, computed } from 'vue'
const model = defineModel()
const props = defineProps({
  format: {
    type: String,
    default: 'YYYY-MM-DD'
  },
  placeholder: {
    type: String,
    default: '选择日期'
  }
})

// 格式化显示的日期
const formattedDate = computed(() => {
  if (!model.value) return ''
  const date = new Date(model.value)
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  return `${year}-${month}-${day}`
})

// 处理日期变化
const handleDateChange = (e) => {
  model.value = e.target.value
}
</script>

<template>
  <div class="date-picker">
    <input 
      type="date" 
      :value="formattedDate" 
      @change="handleDateChange"
      :placeholder="props.placeholder"
      class="custom-input"
    />
    <p v-if="model.value" class="mt-2">选中日期:{{ formattedDate }}</p>
  </div>
</template>

父组件使用:

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

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

<template>
  <DatePicker v-model="selectedDate" />
</template>

三、表单组件库的设计思路(扩展性与通用性)

3.1 可配置化设计原则

  1. 原子化props设计:将组件的每个可配置项拆分为独立props,如placeholderdisabledsize
  2. 默认值与覆盖机制:为props提供合理默认值,同时允许用户通过props覆盖
  3. 类型安全:使用TypeScript定义props类型,提供更好的开发体验

3.2 插槽的灵活运用

通过插槽增强组件的扩展性:

<!-- CustomInputWithSlot.vue -->
<script setup>
const model = defineModel()
</script>

<template>
  <div class="input-group">
    <slot name="prefix"></slot>
    <input v-model="model" class="custom-input" />
    <slot name="suffix"></slot>
  </div>
</template>

父组件使用插槽:

<CustomInputWithSlot v-model="value">
  <template #prefix>
    <span class="prefix-icon">📧</span>
  </template>
  <template #suffix>
    <button @click="clearInput">清除</button>
  </template>
</CustomInputWithSlot>

3.3 样式定制方案

  1. CSS变量主题:使用CSS变量定义主题色、间距等
:root {
  --input-border-color: #ddd;
  --input-focus-color: #409eff;
  --input-error-color: #ff4d4f;
}
  1. 类名穿透:允许用户通过class props传递自定义样式类
  2. Scoped样式与全局样式结合:组件内部使用scoped样式,同时提供全局样式类供用户覆盖

3.4 事件系统设计

  1. 原生事件透传:使用v-bind="$attrs"透传原生事件
  2. 自定义事件:定义组件特有的事件,如validate-successvalidate-fail
  3. 事件命名规范:采用kebab-case命名,如update:model-value

3.5 组件组合策略

  1. 基础组件与复合组件分离:将基础的Input、Button等与复合的Form、FormItem分离
  2. 依赖注入:使用provideinject实现跨组件通信,如表单验证状态的共享
  3. 高阶组件:通过高阶组件增强基础组件的功能,如添加防抖、节流等

课后Quiz

问题1:如何在Vue3中实现组件的双向绑定?请分别写出Vue3.4+和低版本的实现方式。

答案解析

  • Vue3.4+推荐使用defineModel()宏:
<script setup>
const model = defineModel()
</script>
<template>
  <input v-model="model" />
</template>
  • 低版本手动处理props与emit:
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>
<template>
  <input 
    :value="props.modelValue" 
    @input="emit('update:modelValue', $event.target.value)" 
  />
</template>

父组件统一使用v-model="value"绑定。

问题2:如何让自定义组件支持多个v-model绑定?请给出示例代码。

答案解析: 通过为defineModel()指定参数实现多v-model绑定:

<!-- 子组件 -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>
<template>
  <input v-model="firstName" placeholder="姓" />
  <input v-model="lastName" placeholder="名" />
</template>

父组件使用:

<CustomComponent 
  v-model:first-name="userFirstName" 
  v-model:last-name="userLastName" 
/>

问题3:在设计表单组件库时,如何保证组件的扩展性和通用性?

答案解析

  1. 可配置props:将组件的每个可配置项拆分为独立props,提供合理默认值
  2. 插槽机制:使用插槽允许用户插入自定义内容
  3. 样式定制:使用CSS变量、类名穿透等方式支持样式定制
  4. 事件透传:透传原生事件,同时定义自定义事件
  5. 组合设计:基础组件与复合组件分离,使用依赖注入和高阶组件增强功能

常见报错解决方案

报错1:[Vue warn]: Missing required prop: "modelValue"

产生原因:自定义组件使用了v-model,但父组件未绑定值,或子组件未正确定义props。 解决办法

  • 确保父组件使用v-model="value"绑定响应式变量
  • 子组件正确使用defineModel()或声明modelValue prop

报错2:[Vue warn]: Invalid prop: type check failed for prop "modelValue". Expected String, got Number

产生原因:v-model绑定的变量类型与子组件期望的prop类型不匹配。 解决办法

  • 检查父组件绑定变量的类型,确保与子组件prop类型一致
  • 子组件中使用.number修饰符或在defineModel()中指定类型

报错3:[Vue warn]: Extraneous non-emits event listeners (update:modelValue) were passed to component

产生原因:子组件未声明update:modelValue事件,或使用了片段根节点导致事件无法自动继承。 解决办法

  • 使用defineModel()宏自动处理事件声明
  • 或手动使用defineEmits(['update:modelValue'])声明事件

参考链接

vue3 打包dist后 nginx 代理部署 浏览器单独访问其中一个路由 报404

Vue 3 打包部署 Nginx 404 问题解决方案

这是Vue 3 SPA应用部署到Nginx时最常见的问题。当用户直接访问非根路径(如 /about/user/123)时,Nginx会在服务器上查找对应的物理文件,但Vue应用是单页面应用,这些路由实际上是由前端路由控制的。

1. 问题根源

Vue 3应用打包后:

  • 只有 index.html是真实存在的物理文件
  • 其他路由如 /about/contact是前端路由
  • 当直接访问这些路由时,Nginx在服务器上找不到对应的 about.html文件
  • 因此返回 404 错误

2. 完整解决方案

2.1 方案一:Nginx 配置(推荐)

核心思路:让Nginx对所有找不到的路径都返回 index.html,由Vue Router处理路由

server {
    listen 80;
    server_name your-domain.com;  # 你的域名
    root /usr/share/nginx/html;   # Vue打包文件所在目录
    index index.html;

    # 处理Vue路由 - 核心配置
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 静态资源缓存优化
    location ~* .(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    # Gzip压缩
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types
        application/atom+xml
        application/geo+json
        application/javascript
        application/x-javascript
        application/json
        application/ld+json
        application/manifest+json
        application/rdf+xml
        application/rss+xml
        application/xhtml+xml
        application/xml
        font/eot
        font/otf
        font/ttf
        image/svg+xml
        text/css
        text/javascript
        text/plain
        text/xml;
}

关键配置说明

  • try_files $uri $uri/ /index.html;:按顺序尝试查找文件

    1. 先找 $uri(请求的路径)
    2. 再找 $uri/(目录)
    3. 都找不到就返回 /index.html(由Vue处理路由)

2.2 方案二:Docker部署配置

如果你使用Docker部署,Nginx配置:

# Dockerfile
FROM nginx:alpine

# 复制打包文件
COPY dist/ /usr/share/nginx/html/

# 复制自定义Nginx配置
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# nginx.conf
server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

    # 核心配置:处理Vue路由
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 静态资源优化
    location ~* .(js|css|png|jpg|jpeg|gif|ico|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

2.3 方案三:Vue Router 配置检查

确保你的Vue Router配置正确:

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  // 关键:使用createWebHistory,而不是createWebHashHistory
  history: createWebHistory(process.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'Home',
      component: () => import('@/views/Home.vue')
    },
    {
      path: '/about',
      name: 'About',
      component: () => import('@/views/About.vue')
    },
    {
      path: '/:pathMatch(.*)*',  // 404页面
      name: 'NotFound',
      component: () => import('@/views/NotFound.vue')
    }
  ]
})

export default router

重要:生产环境必须使用 createWebHistory,而不是 createWebHashHistory(URL带#号的那种)。

2.4 方案四:Vue 项目配置检查

检查 vite.config.jsvue.config.js

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  base: '/',  // 确保base路径正确
  build: {
    outDir: 'dist',
    assetsDir: 'assets'
  }
})
// vue.config.js (Vue CLI)
module.exports = {
  publicPath: '/',  // 确保publicPath正确
  outputDir: 'dist',
  assetsDir: 'assets'
}

3. 部署验证步骤

3.1 本地验证打包结果

打包前先验证:

# 打包
npm run build

# 查看dist目录结构
ls -la dist/
# 应该看到类似:
# index.html
# assets/
#   index-xxx.js
#   index-xxx.css

3.2 本地测试部署

可以使用 serve测试打包结果:

# 安装serve
npm install -g serve

# 在dist目录启动服务
serve -s dist

# 访问 http://localhost:3000
# 测试直接访问路由:http://localhost:3000/about

3.3 部署到Nginx后的验证

部署后测试:

  1. 访问根路径http://your-domain.com(应该正常)
  2. 直接访问路由http://your-domain.com/about(应该正常)
  3. 刷新页面http://your-domain.com/about(应该正常)

4. 常见问题排查

4.1 问题:配置了try_files但还是404

排查步骤

  1. 检查Nginx配置是否生效

    nginx -t  # 检查配置语法
    nginx -s reload  # 重新加载配置
    
  2. 检查Nginx错误日志

    tail -f /var/log/nginx/error.log
    
  3. 检查文件权限

    chmod -R 755 /usr/share/nginx/html/
    

4.2 问题:静态资源404

解决方案:确保静态资源路径正确

location /assets/ {
    alias /usr/share/nginx/html/assets/;
    expires 1y;
    add_header Cache-Control "public, immutable";
}

4.3 问题:开发环境正常,生产环境404

原因

  • 开发环境使用webpack-dev-server,自带路由处理
  • 生产环境需要Nginx配置支持

解决:按照上面的Nginx配置进行设置

5. 进阶配置

5.1 子路径部署

如果你的应用部署在子路径下(如 http://domain.com/myapp/):

// vite.config.js
export default defineConfig({
  base: '/myapp/',
  // ...
})
server {
    listen 80;
    server_name domain.com;
    root /usr/share/nginx/html;
    
    location /myapp/ {
        alias /usr/share/nginx/html/;
        try_files $uri $uri/ /myapp/index.html;
        index index.html;
    }
}

5.2 多环境配置

# 开发环境
server {
    listen 8080;
    server_name dev.domain.com;
    # 开发环境配置
}

# 生产环境
server {
    listen 80;
    server_name domain.com;
    # 生产环境配置
}

6. 一键部署脚本

#!/bin/bash
# deploy.sh

echo "开始部署Vue应用..."

# 1. 打包
echo "打包Vue应用..."
npm run build

# 2. 备份旧版本
if [ -d "/usr/share/nginx/html" ]; then
    echo "备份旧版本..."
    tar -czf /tmp/vue-app-backup-$(date +%Y%m%d%H%M%S).tar.gz /usr/share/nginx/html
fi

# 3. 复制新版本
echo "复制新版本到Nginx目录..."
sudo cp -r dist/* /usr/share/nginx/html/

# 4. 设置权限
echo "设置文件权限..."
sudo chown -R nginx:nginx /usr/share/nginx/html/
sudo chmod -R 755 /usr/share/nginx/html/

# 5. 重启Nginx
echo "重启Nginx服务..."
sudo nginx -t && sudo nginx -s reload

echo "部署完成!"

7. 总结

Vue 3 SPA部署404问题的核心解决方案

  1. Nginx配置try_files $uri $uri/ /index.html;
  2. Vue Router配置:使用 createWebHistory
  3. 打包配置:确保 base路径正确
  4. 文件权限:确保Nginx有读取权限

按照上述配置部署后,直接访问任何路由都能正常工作。

**

❌