阅读视图

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

一个普通Word文档,为什么99%的开源编辑器都"认怂"了?我们选择正面硬刚

先上一张图:

图片

这个是 Word 中我们高频使用的文档案例,在合同,公文,档案等各个场景中都能看见,但是我测试了市面上10多个主流开源的富文本/文档编辑器,没有一个能完整把上面的样式 1: 1 解析出来,99%解析的效果都是这样:

图片

其实在很多在线文档系统里,DOCX 导入后的效果之所以容易失真,是因为它们通常只保留了最表层的字号、颜色和段落,而丢失了真正决定版式的细节:

  • 分散对齐
  • 字符缩放
  • 字间距
  • 精确行距
  • 文档网格
  • 页面尺寸与页边距
  • 中西文混排规则

在 Web 编辑器领域,中文排版长期被忽视。大多数编辑器仅关注英文排版模型,导致中文文档出现标点溢出、行距不均、分散对齐缺失等问题。

为了解决这个痛点,我们花了半年时间做技术研究和验证,终于实现了一套高精度Docx解析算法,支持各种复杂的Word样式排版的解析渲染,并能在Web端实时编辑。

图片

没错,它就是 jitword,对标 Word 排版效果,原生支持中文排版规范,实现高保真文档导入导出。

老规矩,先上地址:

开源sdk: github.com/jitOffice/j…

JitWord 从底层重新设计了排版引擎,原生支持 GB/T 标点压缩、分散对齐、字符缩放、网格行距等专业排版特性,并实现了与 Word 格式的高保真双向互转。(虽然目前还达不到100%精度,但实测已经是业内top3的方案了)

下面是我们设计的高精度docx解析的技术架构:

图片大家可以参考一下,下面我会和大家详细分享一下我们实现的方案细节。

核心排版能力

一、分散对齐 — 像 Word 一样均匀分布每个字符

图片

传统 Web 编辑器只有左对齐、居中、右对齐、两端对齐四种模式。JitWord 额外实现了 分散对齐(Distribute) ,这是中文公文和正式文档中的必备排版方式。

实现原理:

  • 精确计算每行可用宽度与文本实际宽度的差值
  • 将差值均匀分配到每个字符间隙中:间距 = (行宽 - 文本宽) / (字符数 - 1)
  • 实时响应窗口缩放和字体变化,通过 ResizeObserver 动态重排
  • 三重 CSS 保障:text-align: justify + text-align-last: justify + text-justify: inter-character

效果:  每个字符等间距分布,行首行尾严格对齐,无论段落宽度如何变化都保持均匀美观。


二、字符缩放 — 灵活调整字符宽度比例

图片

支持 33% 到 200% 共 8 档水平缩放预设,可在不改变字号的前提下调整文本密度。

技术方案:

  • 使用 CSS transform: scaleX() 实现无损缩放
  • 自动补偿缩放后的布局宽度,确保分散对齐等特性不受影响
  • 导出 Word 时精确映射到 w:rPr > w:w 字符缩放属性

应用场景:  表格单元格内容过长时压缩显示、标题需要加宽强调效果、模拟 Word 中的字符缩放格式。


三、CJK 排版四件套 — 原生中文排版规范支持

JitWord 内置四项核心 CJK 排版特性,可从 Word 文档中自动识别并还原:

特性 作用 技术实现
严格折行 防止句号、逗号等标点出现在行首 line-break: strict + 东亚换行规则检测
标点压缩 连续标点(如 」、) 自动挤压间距 CSS text-spacing-trim: normal (渐进增强)
字距控制 保持 CJK 字符等宽边界 font-kerning: none 禁用西文字距调整
中英文自动间距 中文与英文/数字之间自动添加间距 CSS text-autospace: normal (渐进增强)

导入兼容性:  从 Word 文档的 <w:documentLayout> 配置中自动提取 characterSpacingControldoNotWrapTextWithPunctnoPunctuationKerningbalanceSingleByteDoubleByteWidth 等属性,精确映射到对应的 CSS 排版规则。


四、字间距精细调整

支持以 磅值(pt)  为单位的字间距调整,与 Word 完全一致:

  • 预设 9 档:从紧缩 -2pt 到加宽 5pt
  • 快捷键支持:每次增减 0.5pt,范围 -5pt ~ 10pt
  • 导出 Word 时精确转换为 twentieths of a point(Word 原生单位)

五、网格行距 — 公文排版标准

图片

支持 Word 文档网格(Document Grid)特性,段落基线自动对齐到文档网格,完美还原政府公文 "每页固定行数" 的排版要求。

高保真文档互转

DOCX 导入 — 五阶段 IR 管线

图片

JitWord 采用自研的中间表示(IR)架构,实现从 Word 到编辑器的高保真格式转换:

DOCX 文件 → XMLAST 解析 → DocIR 中间表示 → JitWord JSON 映射 → Schema 合规校验

关键能力:

  • 格式完整保留段落对齐、字间距、字符缩放、行高、缩进等属性逐一映射
  • CJK 属性提取自动识别文档级排版设置(标点压缩、折行规则、网格配置)
  • 图片异步持久化嵌入图片自动提取、上传到服务端,支持降级到 Base64
  • 智能降级docx4js 为主引擎,mammoth.js 作为兼容性备选
  • 诊断报告导入后生成详细报告,标注不支持的特性和有损转换项

DOCX 导出 — 精确格式输出

编辑器内容反向导出为标准 Word 文档:

  • 对齐方式精确映射(含分散对齐 AlignmentType.DISTRIBUTE
  • 字间距从 pt 转换为 Word 的 twips 单位(ptValue × 20
  • 字符缩放转换为 Word 百分比(0-400%)
  • 支持浮动图片、复杂表格、有序/无序列表、代码块
  • 数学公式支持:LaTeX 自动转换为 Word OMML 格式

PDF 导出 — 像素级还原

自研的 PDF 导出引擎,确保所见即所得:

  • 逐元素分页精确计算每个元素的垂直空间占用,智能分页
  • 双渲染策略优先使用 SVG foreignObject(更好的字体支持),自动降级到 Canvas 渲染
  • 保真度校验导出后自动采样校验画布内容,检测空白或异常渲染并触发重试
  • 布局锁定导出时等待字体加载、图片加载、DOM 稳定后再截图
  • 图表/脑图静态化ECharts 图表和脑图自动转换为静态图片嵌入

单位体系统一

全链路采用 磅值(pt)  作为标准单位,与 Word 原生体系一致:

场景 单位 转换关系
编辑器内部 pt 基准单位
CSS 渲染 px 1pt = 1.333px
Word 文档 twips 1pt = 20 twips
导入兼容 half-points 1pt = 2 half-points

与其他 Web 编辑器的对比

能力 JitWord 通用富文本编辑器 在线协作文档
分散对齐 原生支持 不支持 部分支持
字符缩放 33%-200% 不支持 不支持
标点压缩 自动识别 不支持 不支持
严格折行 智能启用 不支持 基础支持
网格行距 完整支持 不支持 不支持
DOCX 高保真导入 五阶段 IR 管线 基础 HTML 转换 有损导入
DOCX 导出 精确格式映射 有限支持 有损导出
PDF 导出保真度 像素级 + 双渲染 浏览器打印 服务端渲染

最后总结一下

JitWord 从排版引擎层面解决了中文 Web 排版的核心痛点,通过自研的分散对齐算法、CJK 排版规范支持、五阶段 IR 导入管线和像素级 PDF 导出,实现了 Web 端对 Word 排版效果的真正对标

图片

无论是政府公文的严格格式要求,还是企业文档的专业排版需求,我们都能提供开箱即用的解决方案。

当然我们还在持续迭代优化,打造更高精度,更智能的AI协同文档系统,让个人和企业能更低成本将传统 Office “搬到”线上。

大家有好的建议随时交流反馈~

拒绝 rem 计算!Vue3 大屏适配,我用 vfit 一行代码搞定

大家好,我是 RayChart,vfit.js、raychart.js 作者,8 年专注 Vue3 大屏适配、Web3D、数字孪生、数据可视化实战开发,长期分享可直接落地的前端效率工具与实战教程。

每次接到 1920×1080 标准大屏设计稿,最让人头疼的永远是适配
rem 要不停换算、百分比布局易乱、手动 scale 要写一堆监听与居中逻辑,坑多还容易出bug。

今天给大家带来我自研的 Vue3 轻量大屏适配库 —— vfit,真正做到:
不用计算、不用换算、不用调复杂布局,3 分钟接入,设计稿写多少 px,代码就写多少。


一、3 分钟极速接入(复制即用)

1. 安装依赖

npm install vfit

2. 全局配置(main.ts)

import { createApp } from 'vue'
import App from './App.vue'
import { createFitScale } from 'vfit'
import 'vfit/style.css' // 必须引入,否则组件失效

const app = createApp(App)

app.use(createFitScale({
  target'#app',
  designWidth1920,    // 设计稿宽度
  designHeight1080,   // 设计稿高度
  scaleMode'auto'     // 自动适配模式,直接用
}))

app.mount('#app')

配置完成,你的页面已经具备自动等比缩放 + 窗口居中能力,任意拖拽窗口都不会变形、不会错位。


二、核心神器:FitContainer 精准定位

做大屏最痛的不是缩放,而是组件坐标还原
vfit 提供的 <FitContainer> 组件,直接解决 90% 布局痛点:

设计稿 30px → 代码直接写 30,无需任何比例计算

<template>
  <div class="screen-wrapper">
    <!-- 标题:水平居中 -->
    <FitContainer :top="50" :left="0" :right="0">
      <h1 style="text-align: center">数据可视化大屏</h1>
    </FitContainer>

    <!-- 左侧图表:直接使用设计稿坐标 -->
    <FitContainer :top="100" :left="30">
      <ChartComponent />
    </FitContainer>

    <!-- 右侧列表:吸附边缘,自动适配 -->
    <FitContainer :top="100" :right="30">
      <ListComponent />
    </FitContainer>
  </div>
</template>

核心优势

  • 支持 top / left / right / bottom / z 五维定位
  • 自动按设计稿比例计算位置
  • 4K 屏、笔记本屏、拼接屏效果完全一致
  • 无需媒体查询、无需 rem、无需手写 CSS 计算

三、实战避坑指南(必看)

  1.  样式必须引入
    忘记引入 vfit/style.css 会导致 FitContainer 失效,布局直接混乱。
  2.  层级冲突处理
    FitContainer 默认有层级,弹窗被覆盖时可手动指定:
<FitContainer :z="999">
  1.  right / bottom 特殊逻辑
  • left:按设计稿比例自动缩放
  • right:不乘缩放,保持吸附屏幕边缘
    专为大屏展示优化,视觉更稳定。

四、适用场景

  • Vue3 数据可视化大屏
  • 数字孪生项目
  • 监控中心、控制台页面
  • 多端自适应、拼接屏项目
  • 不想写复杂适配逻辑的前端项目

vfit 不是功能最繁杂的,但最简单、最稳定、最适合生产环境,让你把时间花在 ECharts、3D 渲染、业务逻辑上,而不是算像素。


五、项目资源

GitHub:github.com/v-plugin/vf…
官方文档:vfit.raychart.cn


🎁 粉丝专属福利

关注我的微信公众号 RayChart
后台回复关键词:vfit
立即免费领取:
✅ vfit 完整可运行项目模板
✅ 10 套大厂可视化大屏源码
✅ 数字孪生项目素材包
✅ 一对一技术问题答疑

公众号持续更新:Vue3 大屏适配、Web3D、3D 模型压缩、全景预览、自研效率工具、数字孪生实战干货,所有内容均可直接复制到项目使用。

Vue生命周期与keep-alive实战理解

Vue 生命周期与 keep-alive:我用真实项目终于搞清楚了

"生命周期"这个词在 Vue 教程里会很早出现,但很多人学了之后还是一知半解。 因为光看文档,没有感觉。这篇文章想用真实项目代码,帮你把它们"落地"。


先说一个语言上的误导

"生命周期" 这个词,听起来像是整个项目从打开到关闭的一段大流程。

但实际上,它属于每一个组件。

正确的理解是:

每个 Vue 组件实例,都有自己的出生、挂载、更新、离开、缓存激活的过程。 这些阶段,Vue 会自动调用对应的钩子函数,给你一个"插队"的机会。


🗓️ 最常用的几个生命周期钩子

created —— 数据准备好了,但页面还没画出来

✅ 适合做什么:
- 发起不依赖 DOM 的数据请求
- 初始化变量
- 读取 Vuex / props

mounted —— 页面已经渲染出来了,DOM 摸得到了

✅ 适合做什么:
- 操作 $refs(真实 DOM)
- 绑定滚动、键盘、resize 事件
- 初始化第三方 JS 库(图表、播放器、WebSocket 等)

beforeDestroy / destroyed —— 组件要销毁了

✅ 适合做什么:
- 清除定时器
- 解绑事件监听
- 关闭 WebSocket
- 释放资源

activated —— keep-alive 缓存的页面"重新回来了"

✅ 适合做什么:
- 每次回到这个页面时,重新拉数据
- 恢复某些需要刷新的状态

deactivated —— keep-alive 缓存的页面"离开了,但没销毁"

✅ 适合做什么:
- 暂停正在播放的音频
- 停止轮询
- 暂停实时监听

🔁 keep-alive 会改变什么

keep-alive 是 Vue 的内置组件,作用是缓存被包住的组件实例

没有 keep-alive 的情况下,离开一个页面 = 销毁这个组件。

有了 keep-alive,离开页面之后组件不一定被销毁,而是被"冷冻"起来:

首次进入缓存页:
  created → mounted → activated

离开缓存页(不销毁):
  deactivated

再次进入缓存页:
  activated  ← 直接从这里开始,跳过了 created 和 mounted!

这就是为什么你会在缓存相关的组件里,经常看到 activated 而不是 created


🔧 用真实项目代码来拆解

根实例的 created:应用一启动就执行一次

// src/main.js
new Vue({
  router,
  store,
  render: h => h(App),
  created() {
    // 这里的 created 只在"整个应用开机"时执行一次
    store.commit('businessCoulmn/setWorkDetail', '');        // 重置业务状态
    store.commit('instantMessaging/setScreenfullBut', false); // 重置 IM 全屏状态
    store.commit('siteBar/addMenu', router);                  // 注入动态菜单
  },
}).$mount('#app');

这里的 created 是根实例的生命周期,属于"整个应用开机初始化",不属于任何页面。


登录页的 created + mounted:先清状态,再做页面效果

created:
  - 清空本地存储
  - 重置菜单和 tab
  - 清空 token

mounted:
  - 启动背景动画效果

典型用法:

  • created 适合"先把旧状态清干净"
  • mounted 适合"页面展示出来之后做的事情"

IM 页面入口:created 做初始化

created:
  1. 拿老师的聊天账号
  2. 拿 IM 配置
  3. 初始化 TIM SDK
  4. 绑定 TIM 事件
  5. 登录 IM

为什么用 created 而不是 mounted

因为这些步骤不需要操作 DOM,提前在 created 里做,能更快完成初始化。


IM 布局组件:mounted 初始化 WebSocket

mounted:
  - 初始化 WebSocket 连接

beforeDestroy:
  - 关闭 WebSocket
  - 停掉轮询

mounted 之所以适合初始化 WebSocket,是因为:

  • WebSocket 有时候需要操作 DOM
  • mounted 保证页面已经渲染,更安全

beforeDestroy 负责配套的清理工作,避免资源泄漏。


ConversationList 用 activated 而不是 created,是因为……

// src/components/.../ConversationList/index.vue
activated() {
  this.handleGetWechatStudentList(); // 每次"回到页面"时刷新数据
}

为什么不用 created

因为这个组件被 keep-alive 包着。

  • 第一次进来会走 created
  • 切走再切回来,created 不会重新触发
  • activated 每次回来都会触发

所以这里的逻辑是:

created  → 第一次初始化
activated → 每次"回来"时刷新数据

这是 keep-alive 场景下非常经典的写法。


MessageWindow 用 deactivated,是因为……

// src/components/.../MessageWindow/index.vue
deactivated() {
  this.handlePauseAudio(); // 离开页面时暂停音频
}

为什么不用 beforeDestroy

因为这个组件被缓存了,切走页面时不是真正销毁,不会触发 beforeDestroy

会触发的是 deactivated,专门处理"缓存页离开时的收尾动作"。


🔄 用一次完整流程来理解

1. 用户打开项目
   └─ main.js 创建根实例
   └─ 根实例 created 执行(初始化菜单、重置 IM 状态)

2. 用户登录
   └─ login.vue created 清空旧数据
   └─ login.vue mounted 显示背景动画

3. 进入 IM 聊天页
   └─ instant-messaging/index.vue created 做 IM 初始化
   └─ Layout/index.vue mounted 初始化 WebSocket

4. 用户切走页面
   └─ 缓存页面 → 触发 deactivated(不销毁)
   └─ 非缓存页面 → 触发 beforeDestroy(销毁)

5. 用户切回 IM 页面
   └─ 触发 activated
   └─ ConversationList 重新拉数据

⚠️ 不要混淆的三个东西

概念 是什么
生命周期 组件的出生、挂载、更新、离开、重现的各个阶段
watch 监听某个数据变化,不是生命周期
keep-alive 缓存机制,它会改变组件的生命周期表现

keep-alive 的存在,让 deactivated / activated 有意义。

没有 keep-alive 包着的组件,这两个钩子永远不会触发。


🏁 一句话总结

Vue 生命周期不是"整个项目统一跑一遍的流程"
而是"每个组件实例自己的阶段变化"

keep-alive 改变的是"离开""回来"的行为:
  - 离开时不销毁 → deactivated
  - 回来时不重建 → activated

所以:
  created / mounted    → 用于首次初始化
  activated            → 用于每次回来时刷新
  deactivated          → 用于离开时收尾
  beforeDestroy        → 用于真正销毁时清理

这是 Vue2 学习系列第四篇。

下一篇:Vue、SPA、MPA 傻傻分不清?一篇弄清楚三者的关系。

Home双router-view与布局切换逻辑

Home.vue 里为什么有两个 router-view?我看了三遍才读懂

如果你第一次看到 Home.vue 里写了两个 <router-view>,可能会困惑:这不是重复了吗? 其实不是。这两个出口,各守一个"平行世界"。


先看那段让人迷惑的代码

<template>
  <fragment>
    <el-container class="cus-root-container">

      <!-- 第一个 router-view:只在即时聊天全屏时出现 -->
      <router-view
        :key="$route.path"
        v-if="$route.path == '/review-service/instant-messaging'
              && $store.state.instantMessaging.bScreenfullBut == true"
      />

      <!-- 后台壳区域:只在非全屏时显示 -->
      <site-bar v-if="!$store.state.instantMessaging.bScreenfullBut" />

      <el-container class="cus-right-wrap"
        v-if="!$store.state.instantMessaging.bScreenfullBut">
        <el-header class="cus-header">
          <personal-bar />
          <tabs-bar />
        </el-header>
        <el-main class="cus-main">
          <keep-alive :exclude="excludelist">
            <!-- 第二个 router-view:后台正常模式的内容出口 -->
            <router-view :key="$route.path" />
          </keep-alive>
        </el-main>
      </el-container>

    </el-container>
  </fragment>
</template>

初看很乱,但把它分成两块就很清楚了。


🗺️ 一张图搞懂两个出口

Home.vue
├─ 🖥️ 全屏分支(上面那个 router-view)
│   └─ 条件:路由 = IM 页 && 全屏状态 = true
│   └─ 效果:聊天页面完全接管屏幕,没有侧边栏和顶部栏
│
└─ 🖥️ 普通后台分支(下面那个 router-view)
    └─ 条件:全屏状态 = false
    └─ 效果:正常后台布局,有侧边栏、顶部栏、标签页
    └─ 外面套了 keep-alive 做页面缓存

它们永远不会同时出现。 因为 v-if 条件互斥:

  • 全屏状态为 true → 上面显示,下面隐藏
  • 全屏状态为 false → 下面显示,上面隐藏

📺 第一个 router-view:即时聊天全屏模式

触发条件

当前路由是 /review-service/instant-messaging
+ Vuex 里 bScreenfullBut == true

两个条件同时满足,才会走这个出口。

它解决什么问题

即时聊天页面有一个**"全屏按钮"**。

用户点击全屏后,希望聊天窗口占满整个屏幕,不要有侧边菜单、顶部栏这些干扰。

但这个操作不是跳转到另一个页面,而是原地切换布局。

所以需要一个"纯净出口"——只渲染聊天页面本身,不带任何后台壳。

这就是第一个 router-view 存在的意义。


📋 第二个 router-view:日常后台模式

<keep-alive :exclude="excludelist">
  <router-view :key="$route.path" />
</keep-alive>

这是最常用的那个出口。

整个后台系统 90% 的页面都走这里渲染。

为什么外面套了 keep-alive?

后台系统的页面有一个特点:

用户在列表页设了很多筛选条件
→ 跳去详情页看了一会儿
→ 返回列表页
→ 希望筛选条件还在!

如果没有 keep-alive,每次返回都会重新创建组件,状态全丢。

加了 keep-alive 之后,组件实例被缓存起来,切回去时状态原封不动


为什么要有 excludelist?

keep-alive 虽然好用,但不是所有页面都适合缓存。

有些页面每次进入都应该是"全新"的,比如:

  • 编辑页:如果缓存了上次编辑的数据,会出现"脏数据"问题
  • 详情页:缓存后可能显示旧记录,而不是当前要看的那条
  • 表单创建页:上次填的内容不该保留

这些页面的 name 会被写进 excludelist,从而绕过缓存,每次都重建。


🔑 两个 router-view 都写了 :key="$route.path",为什么?

这是一个很容易忽略的细节。

如果不加 :key,Vue 在某些路由切换时会复用旧的组件实例,导致:

  • 生命周期钩子没有重新触发
  • 旧数据没清空
  • 表单状态异常

加了 :key="$route.path" 之后:

  • 每个路由路径对应一个唯一标识
  • 路由一变,key 就变,Vue 就知道需要重新创建这个组件
  • 页面切换更干净、更可控

🔄 路由和 Home.vue 的关系

你可能会看到很多路由这样配置:

{
  path: '/some-path',
  component: () => import('../views/Home.vue'),  // 注意这里是 Home.vue
  children: [
    {
      path: '',
      component: () => import('../views/some-page.vue'),
    }
  ]
}

这里的意思是:

  • 进入这个路由,先用 Home.vue 作为壳
  • Home.vue 再通过 router-viewsome-page.vue 渲染出来

所以:

router/index.js → 决定"哪个页面进 Home"
Home.vue       → 决定"这个页面以什么布局显示"

路由配置管"谁进来",Home.vue 管"怎么展示"。


✅ 怎么判断一个页面走哪个出口

看到一个页面时,可以按这个逻辑判断:

if (路由是 IM 页 && 全屏状态 == true) {
  走第一个 router-view → 全屏,没有后台壳
} else {
  走第二个 router-view → 正常后台模式,有侧边栏顶部栏
}

🏁 一句话总结

两个 router-view 不是重复,是两种布局的切换开关:
上面 → 即时聊天全屏专用出口
下面 → 普通后台模式 + keep-alive 缓存
条件互斥,永远不会同时工作

这是 Vue2 学习系列第三篇。

下一篇:Vue 生命周期 + keep-alive 实战,用真实项目代码来拆解。

src-components调用链与即时聊天组件树

src/components 里的文件,没人引用就是一堆废纸

很多 Vue 初学者以为,把文件放到 src/components 文件夹里,就会自动生效。 其实完全不是。这篇文章就来说清楚:组件怎么才算"真正被用到"。


🤔 先说一个常见误区

初学时,很多人会这样理解:

"我把组件放到 src/components,它就可以在模板里用了。"

错。

src/components 只是一个约定俗成的存放目录,不是自动生效的魔法文件夹。

放在里面的组件,如果没有人引用它,它就永远不会渲染,永远不会执行。


✅ 组件真正"生效",只有两种方式

方式一:全局注册(所有页面都能直接用)
方式二:局部引用(谁需要谁 import,谁 import 谁才能用)

🌐 全局注册是什么感觉

有些组件在整个项目里到处都要用,比如:

  • 顶部栏
  • 左侧菜单
  • 标签页
  • 全局提示

这类组件写进全局注册之后,任何页面不用单独 import,直接在模板里写标签就能用:

<site-bar />
<personal-bar />
<tabs-bar />
<base-tip />

全局注册的核心逻辑(以常见写法为例):

// src/components/globalComponents.js
import Vue from 'vue'
import SiteBar from './SiteBar/index.vue'

const globalComponents = {
  install(Vue) {
    Vue.component('SiteBar', SiteBar)
    // ... 其他全局组件
  }
}

export default globalComponents
// src/main.js
import globalComponents from './components/globalComponents'
Vue.use(globalComponents) // 在这里注册进去

这样,SiteBar 就变成了整个项目都能用的"公共组件"。


🔗 局部引用:大多数组件的使用方式

大部分业务组件不会全局注册。

它们是:谁需要,谁去 import,谁 import 才能用。

以即时聊天模块为例,它的组件树是一层一层嵌套引用的:

src/views/review-service/instant-messaging/index.vue
  └─ <im-layout />            ← 这个页面 import 了 Layout

src/components/.../Layout/index.vue
  ├─ <menu-bar />             ← Layout 里 import 了 MenuBar
  ├─ <conversation-list />    ← 会话列表(menuBar 切换到 Tab 1 时)
  ├─ <contacts-list />        ← 通讯录(menuBar 切换到 Tab 2 时)
  ├─ <message-window />       ← 有当前会话时显示
  ├─ <welcome-page />         ← 没有会话时显示
  ├─ <business-column />      ← 右侧业务栏
  └─ <image-previewer />      ← 图片预览

src/components/.../BusinessColumn/index.vue
  ├─ <BaseInfo />             ← 学员基本信息
  ├─ <SpeechList />           ← 个人话术
  └─ <WokNotice />            ← 工作通知

这就是典型的局部引用调用链:一层引用一层,没被引用到的,永远不会渲染。


🗂️ 即时聊天模块,每一层在做什么

页面入口层

instant-messaging/index.vue 是聊天功能的入口页面。

它主要负责"初始化",不负责画界面:

  • 获取老师的聊天账号
  • 拉取 IM 配置
  • 初始化 TIM SDK
  • 绑定 TIM 事件
  • 登录 IM

初始化完毕之后,它把 <im-layout /> 渲染出来,让布局层接管页面。


布局层

Layout/index.vue 是聊天页面的"骨架"。

它负责把聊天界面的各个区域分好:

  • 左侧菜单栏(MenuBar)
  • 根据 Tab 切换的会话列表 / 通讯录
  • 中间的消息窗口(有会话时显示)/ 欢迎页(无会话时显示)
  • 右侧业务栏
  • 图片预览浮层
  • 音频通知元素

这层是"结构性组件",它不负责具体业务,只负责把各个子组件摆放到正确的位置。


业务栏层

BusinessColumn/index.vue 是聊天右侧的业务信息区域。

它会根据当前会话和业务类型,切换显示:

  • 学员基本信息(BaseInfo)
  • 个人话术列表(SpeechList)
  • 工作通知(WokNotice)

🔎 怎么快速找到一个组件在哪里被调用

以后看代码时,不要在 src/components 文件夹里盲搜。

最快的方式是直接搜组件名

# 找 BaseInfo 被哪里引用了
rg -n "BaseInfo|<BaseInfo|import BaseInfo" src

# 找 ImLayout 被哪里引用了
rg -n "ImLayout|<im-layout|import ImLayout" src

搜到之后,从父组件往子组件方向追,一层一层拆开,很快就清楚了。


🧠 整个应用的调用链长这样

main.js
└─ 注册全局组件、插件、store、router

App.vue
└─ <router-view />(总出口)

Home.vue(后台壳)
└─ <site-bar />(全局)
└─ <tabs-bar />(全局)
└─ <router-view />(后台页面出口)

即时聊天入口页
└─ <im-layout />(局部引入)

Layout
└─ <ConversationList /><MessageWindow /><BusinessColumn />...

BusinessColumn
└─ <BaseInfo /><SpeechList /><WokNotice />

从最上面的 main.js 到最深的 BaseInfo,是完整的一条引用链。

断掉任何一层的引用,下面的组件就不会渲染。


🏁 一句话总结

src/components 不是入口目录,组件不会自动生效。
组件必须被 import 或全局注册后才会激活。
搞清楚"谁引用了谁",比知道"目录里有什么"更重要。

这是 Vue2 学习系列第二篇。

下一篇:Home.vue 里为什么有两个 <router-view>?它们各管什么?

VUE-组件命名与注册机制

Vue2 组件四个"名字",我曾经傻傻分不清楚

这是我在学习 Vue2 真实项目时踩过的一个坑,花了不少时间才搞清楚。记录下来,希望对你也有用。


先说我当时有多懵

我第一次看到一个 Vue 组件的使用方式时,脑袋是懵的:

import ImLayout from '@/components/.../Layout/index.vue';
components: { ImLayout }
<im-layout />
name: 'im-layout'

四个地方,都像是在说同一件事,又感觉哪里不对。

它们到底是什么关系?


🎯 直接给结论

在 Vue 2 里,组件能不能在模板里使用,关键看三件事:

1. 组件有没有被 import 进来
✅ 2. 有没有在 components 里注册
✅ 3. 模板里有没有用对标签名

name,只是组件自己的**"身份名片"**,不负责让你用它。


📦 四个"名字",分开说

第一个:import ImLayout from ...

这是 JavaScript 层面的变量名,你把一个文件"抱进来",给它取个名字方便后续引用。

// 你也可以叫它 ChatLayout,只要后面跟着改
import ChatLayout from '@/components/.../Layout/index.vue';

名字本身不固定,你起的什么,后面就叫什么。


第二个:components: { ImLayout }

这是在当前页面注册组件,相当于告诉 Vue:

"我这个页面可以使用 ImLayout,它对应的实现就是刚才 import 的那个文件。"

完整写法等价于:

components: {
  ImLayout: ImLayout  // 键名就是你之后在模板里写的标签名
}

第三个:<im-layout />

这是在模板里真正调用组件的地方。

Vue 会自动把注册名 ImLayout 对应到 im-layout(驼峰 → 短横线),所以这两种写法都可以:

<ImLayout />
<im-layout />

项目里更常见的是带短横线的写法(kebab-case)。


第四个:name: 'im-layout'

这是组件给自己贴的名字,主要用于:

用途 说明
keep-alive 缓存控制 exclude/include 通过 name 识别组件
Vue Devtools 显示 调试时能看到有意义的组件名,而不是一堆 <Anonymous>
递归组件 组件在模板里引用自己时需要用 name

name 删掉,页面照样能正常渲染。 因为真正决定能不能用的是 components 注册。


🔍 用真实项目做个例子

假设你有一个即时聊天模块,在页面入口里这样写:

// 第一步:把文件引进来,起名叫 ImLayout
import ImLayout from '@/components/review-service/instant-messaging/Layout/index.vue';

export default {
  // 第二步:在当前页面注册这个组件
  components: { ImLayout },
};
<!-- 第三步:在模板里使用它 -->
<im-layout />

这能正常工作,是因为:

  1. import 把文件拿进来了
  2. components 把这个组件注册进了当前页面
  3. Vue 自动把 ImLayoutim-layout 对应起来

跟组件内部写没写 name: 'im-layout' 无关。


🚫 三个常见误区

❌ 误区一:文件名决定标签名

不对。index.vue 只是文件名,不能自动决定你在模板里写什么。

真正决定的是 components 注册时用的键名。


❌ 误区二:name 就是注册名

不对。name 是组件自我描述,注册是靠 components


❌ 误区三:import 名字必须和标签一致

不对。import 只是变量名,标签名取决于 components 如何注册。

只要注册正确,模板里 <ImLayout /><im-layout /> 都可以。


🗺️ 遇到组件时,用这个顺序判断

📌 1. 它有没有被 import?
📌 2. 它有没有写进 components?
📌 3. 模板里有没有用对标签名?
📌 4. name 只在缓存、调试、递归这几个场景才有意义

把这四步想明白,就不会再混淆了。


🏁 一句话总结

import   → 把文件拿进来(JS 变量名)
components → 注册进当前页面(决定能用什么标签)
<im-layout> → 在模板里调用组件
name     → 组件的自我描述(和能不能用无关)

这是我学习 Vue2 真实项目时的第一篇总结。学得慢没关系,搞清楚一个是一个。

下一篇:src/components 里的文件,到底从哪里被调用?

uniapp uview-plus 自定义动态验证

以前写的验证都是这样固定的

const rules = ref({ bedUnitTidy: [{ required: true, message: '请选择', trigger: 'change' }]})

单选按钮这些选项是从接口里读出来的数据,所以现在用了动态验证,现在记录下来供自己以后参考

<up-form class="p24 bgf" :model="form" :rules="rules" ref="uFormRef" labelWidth="200" labelPosition="top":borderBottom="true">

    <up-form-item v-for="(item,index) in recordData" :key="index" :label="item.text"
        :prop="`inspectionItems.${item.value}`" required
        :rules="[{ required: true, message: '请选择' + item.text, trigger: 'change' }]">
        <up-radio-group v-model="form.inspectionItems[item.value]">
                <up-radio label="是" name="1">
                </up-radio>
                <up-radio label="否" name="0">
                </up-radio>
        </up-radio-group>
    </up-form-item>
</up-form>

const submitForm = () => {
    uFormRef.value.validate().then(res => {
        console.log(res, '成功');
        handleSubmit()
    }).catch(err => {
        console.log(err, '校验失败');
    })
}

之前一直验证失败是prop路径写错,现在查资料总结到:v-model 绑哪里 prop 就写哪里的完整路径,验证是form表单,所有项应该在form里,之前问题是在于,prop绑定的循环体里的数据,现在通过重组数据,拿到数据项后,放到form对象里,然后在up-form-item 上绑定rules 和prop解决了问题,每天进步一点点,加油!! image.png

从0开始设计一个树和扁平数组的双向同步方案

从0开始设计一个树和扁平数组的双向同步方案

背景:在前端开发中,展示和操作大型树形结构(如十万级节点的文件树、组织架构图)时,传统递归渲染 DOM 会导致严重的性能瓶颈。为了结合虚拟滚动技术,我们需要将树“扁平化”为一维数组。本文将从0开始,推导并设计一个支持“逻辑树”与“视图扁平数组”实时、高效双向同步的方案。


1. 核心操作提炼

在开始设计数据结构之前,我们先明确业务需求。一个完备的树和数组双向同步方案,必须支持以下核心操作:

  1. 初始化构建:将原始树结构转换为扁平数组,并建立辅助索引。
  2. 树添加子节点:在指定节点的子节点列表末尾添加新节点,并同步到数组。
  3. 树添加兄弟节点:在指定节点之后添加一个兄弟节点,并同步到数组。
  4. 树删除节点:删除指定节点及其所有子孙节点,并同步到数组。
  5. 树移动节点:将某棵子树从一个父节点移动到另一个父节点下(拖拽操作),并同步到数组。
  6. 树修改节点属性:修改节点的非结构属性(如名称、展开状态等),并触发视图更新。

2. 数据结构设计

为了让上述操作在树和数组中都能高效执行,我们需要精心设计节点的数据结构,并引入辅助索引(空间换时间)。

2.1 节点结构 (TreeNode)

最基础的树节点通常只包含 idchildren

interface TreeNode {
  id: string | number;
  children: TreeNode[];
  // ... 其他业务数据属性
}

但在我们的双向同步方案中,为了实现高效的查找和回溯,仅有这两个属性是远远不够的。在接下来的算法设计中,我们会根据具体的操作场景,一步步引入并添加必要的辅助属性和辅助 Map 索引(空间换时间)。

2.2 两大数据载体

  1. Tree (Array):原始的逻辑树结构,用于维护业务层级关系。
  2. flatArray (Array):基于 DFS 遍历生成的扁平数组,直接绑定到 UI 虚拟滚动组件上用于渲染。

2.3 辅助索引的引入思路

仅仅依靠树和数组依然不够,如果每次操作都要去遍历寻找目标节点,性能会大打折扣。为了将查找复杂度降至 O(1)O(1),我们会在接下来的算法设计中,根据具体的操作场景,一步步引入并建立必要的辅助 Map 索引(空间换时间)。


3. 算法与逻辑构思

接下来,我们针对提炼的每一个操作,进行详细的算法与逻辑设计。

3.1 初始化构建

逻辑构思:首先需要将一棵树展开为一维数组。在遍历过程中,为了支撑后续的高效查找,我们自然地引入前两个辅助索引:

  1. treeNodeMap (Map<id, TreeNode>):将节点 ID 映射到节点对象的引用,保证后续任何操作都能 O(1)O(1) 定位节点。
  2. flatIndexMap (Map<id, index>):记录节点在 flatArray 中的数组下标,用于后续在数组中快速进行切片(Splice)操作。

引入树节点属性 subTreeSize: 为了能在扁平数组中快速确定一棵子树占据的切片范围,我们需要为每个树节点引入一个核心字段 subTreeSize(以该节点为根的子树的节点总数,包含自身)。在 DFS(深度优先遍历)生成的扁平数组中,一棵子树的所有节点是绝对连续的。subTreeSize 就是这段连续区间的长度。

算法设计:采用深度优先遍历 (DFS)

  • 时间复杂度O(N)O(N),其中 NN 为树中节点的总数。需要遍历每个节点一次。
  1. 遍历过程中,将节点引用存入 treeNodeMap
  2. 将节点 pushflatArray 中,并将此时的数组长度(减1)作为 index 存入 flatIndexMap
  3. 在 DFS 回溯阶段,自底向上累加子节点的 subTreeSize,最终得出每个节点的正确规模。
function initTreeFlat(tree: TreeFlatNode[]) {
  treeData.value = tree;
  flatArray.value = [];
  treeNodeMap.clear();
  flatIndexMap.clear();
  siblingIndexMap.clear();

  const traverse = (
    nodes: TreeFlatNode[],
    parentId: TreeNodeId | null | undefined = null,
  ) => {
    let currentLevelSize = 0;
    nodes.forEach((node, index) => {
      node.parentId = parentId;
      if (!node.children) node.children = [];

      treeNodeMap.set(node.id, node);
      siblingIndexMap.set(node.id, index);

      flatArray.value.push(node);
      flatIndexMap.set(node.id, flatArray.value.length - 1);

      let childrenSize = 0;
      if (node.children && node.children.length > 0) {
        childrenSize = traverse(node.children, node.id);
      }

      node.subTreeSize = 1 + childrenSize;
      currentLevelSize += node.subTreeSize;
    });
    return currentLevelSize;
  };

  if (treeData.value && treeData.value.length > 0) {
    traverse(treeData.value);
  }

  return flatArray.value;
}

3.2 树添加子节点(追加到末尾)

逻辑构思:逻辑树中,只需往 children 里 push;但在扁平数组中,新节点应该紧挨着该父节点整棵现有子树的末尾插入。 算法设计

  • 时间复杂度O(N)O(N),主要受限于 flatArraysplice 插入操作和后续所有节点在 flatIndexMap 中的更新遍历。
  1. 树更新:通过 treeNodeMap 找到父节点,往 children 追加新节点。更新新节点的 subTreeSize = 1。向上回溯更新所有祖先的 subTreeSize += 1
  2. 寻找数组插入点
    • 若父节点无子节点:插入点 = flatIndexMap.get(parentId) + 1
    • 若有子节点:找到最后一个子节点 prev。插入点 = flatIndexMap.get(prev.id) + prev.subTreeSize
  3. 数组更新:使用 flatArray.splice(插入点, 0, newNode) 插入。
  4. 索引更新:新节点记入 flatIndexMap。由于数组元素后移,遍历 flatArray 从插入点之后的所有节点,将其在 flatIndexMap 中的值 +1
// 辅助函数:向上更新祖先节点的 subTreeSize
const updateSubTreeSizeUpwards = (node: TreeFlatNode, delta: number) => {
  let current: TreeFlatNode | null | undefined = node;
  while (current) {
    if (current.subTreeSize !== undefined) {
      current.subTreeSize += delta;
    }
    if (current.parentId) {
      current = treeNodeMap.get(current.parentId);
    } else {
      current = null;
    }
  }
};

// 辅助函数:更新指定索引之后的 flatIndexMap
const updateFlatIndexMap = (startIndex: number) => {
  for (let i = startIndex; i < flatArray.value.length; i++) {
    const node = flatArray.value[i];
    flatIndexMap.set(node.id, i);
  }
};

// 辅助函数:扁平化并注册新节点及其子树
const flattenAndRegister = (
  node: TreeFlatNode,
  parentId: TreeNodeId | null | undefined,
  flatList: TreeFlatNode[],
) => {
  node.parentId = parentId;
  if (!node.children) node.children = [];

  flatList.push(node);
  treeNodeMap.set(node.id, node);

  let size = 1;
  if (node.children.length > 0) {
    node.children.forEach((child, index) => {
      siblingIndexMap.set(child.id, index);
      size += flattenAndRegister(child, node.id, flatList);
    });
  }
  node.subTreeSize = size;
  return size;
};

const addChildNode = (currentNode: TreeFlatNode, newNode: TreeFlatNode) => {
  if (!currentNode.children) currentNode.children = [];

  // 1. 树更新:添加到父节点的 children
  currentNode.children.push(newNode);
  const siblingIndex = currentNode.children.length - 1;
  siblingIndexMap.set(newNode.id, siblingIndex);

  // 2. 准备新节点(及其可能包含的子树)的扁平数组
  const newFlatNodes: TreeFlatNode[] = [];
  flattenAndRegister(newNode, currentNode.id, newFlatNodes);

  // 3. 寻找数组插入点并更新数组
  // 插入点在 currentNode 现有的子树之后
  const insertIndex =
    (flatIndexMap.get(currentNode.id) as number) +
    (currentNode.subTreeSize as number);
  flatArray.value.splice(insertIndex, 0, ...newFlatNodes);

  // 4. 索引更新与向上回溯
  updateFlatIndexMap(insertIndex);
  updateSubTreeSizeUpwards(currentNode, newNode.subTreeSize as number);

  triggerUpdate();
};

3.3 树添加兄弟节点

逻辑构思:与添加子节点类似,区别在于插入位置是紧跟在目标兄弟节点的子树之后。 在更新逻辑树时,我们需要把新节点插入到父节点的 children 数组的特定位置,这就需要知道当前节点的父节点,以及当前兄弟节点的索引。

引入树节点属性 parentId: 为了能够在操作时(如添加兄弟节点、删除节点)方便地向上找到父节点,我们需要在初始化阶段为每个树节点注入 parentId 属性(根节点的 parentId 可以为 null)。

引入新索引 siblingIndexMap: 为了避免每次去 children 数组里执行 O(N)O(N) 的查找来获取当前兄弟节点的索引,我们需要引入第三个辅助索引(在实际开发中,它同样需要在初始化时收集,并在其他增删操作中同步维护):3. siblingIndexMap (Map<id, index>):记录节点在父节点 children 数组中的位置。

算法设计

  • 时间复杂度O(N)O(N),同样受限于数组插入时的移位操作,以及后续节点在相关 Map 索引中的更新。
  1. 树更新:通过 parentId 找到父节点,通过 siblingIndexMap 瞬间找到当前节点在 children 中的位置,在其后插入新节点。向上回溯更新祖先 subTreeSize += 1
  2. 寻找数组插入点
    • 插入点 = flatIndexMap.get(当前节点.id) + 当前节点.subTreeSize
  3. 数组更新flatArray.splice(插入点, 0, newNode)
  4. 索引更新:后续节点 flatIndexMap+1,同时将新节点记入 siblingIndexMap,并更新插入位置之后所有兄弟节点的 siblingIndexMap(值 +1)。
const addSiblingNode = (currentNode: TreeFlatNode, newNode: TreeFlatNode) => {
  const parentId = currentNode.parentId;
  let parentNode: TreeFlatNode | null | undefined = null;
  let childrenArray: TreeFlatNode[] | null = null;

  if (parentId) {
    parentNode = treeNodeMap.get(parentId);
    childrenArray = parentNode!.children!;
  } else {
    childrenArray = treeData.value;
  }

  // 1. Insert into children array
  const currentSiblingIndex = siblingIndexMap.get(currentNode.id) as number;
  const insertIndexInChildren = currentSiblingIndex + 1;
  childrenArray.splice(insertIndexInChildren, 0, newNode);

  // 2. Update sibling indices for subsequent siblings
  for (let i = insertIndexInChildren; i < childrenArray.length; i++) {
    siblingIndexMap.set(childrenArray[i].id, i);
  }

  // 3. Prepare flat list
  const newFlatNodes: TreeFlatNode[] = [];
  flattenAndRegister(newNode, parentId, newFlatNodes);

  // 4. Insert into flatArray
  // Insert after currentNode's subtree
  const insertIndex =
    (flatIndexMap.get(currentNode.id) as number) +
    (currentNode.subTreeSize as number);
  flatArray.value.splice(insertIndex, 0, ...newFlatNodes);

  // 5. Update maps and sizes
  updateFlatIndexMap(insertIndex);
  if (parentNode) {
    updateSubTreeSizeUpwards(parentNode, newNode.subTreeSize as number);
  }

  triggerUpdate();
};

3.4 树删除节点(批量删除策略)

逻辑构思:如果逐个删除子树节点,由于 Array.prototype.splice 的底层实现需要将删除位置之后的所有元素向前移动以填补空缺,每次删除一个元素都会产生 O(N)O(N) 的移位开销(NN 为数组总长度)。如果子树包含 MM 个节点,逐个删除会导致大量的重复移位操作,总时间复杂度将退化为 O(N×M)O(N \times M),性能极差。利用 DFS 连续性特性,我们直接在扁平数组中“切掉”这一整段,只需要进行一次 O(N)O(N) 的数组移位操作即可。 算法设计

  • 时间复杂度O(N)O(N),一次性 splice 删除了 MM 个节点,产生了 O(N)O(N) 的数组元素前移开销,以及遍历更新剩余节点索引的 O(N)O(N) 开销。
  1. 获取规模:待删除节点数 count = node.subTreeSize
  2. 数组更新:起点 startIndex = flatIndexMap.get(node.id)。直接执行 flatArray.splice(startIndex, count)
  3. 树与索引更新
    • treeNodeMap 移除这 count 个节点。
    • 从父节点的 children 中移除目标节点,更新后续兄弟节点的 siblingIndexMap(值 -1)。
    • 祖先节点的 subTreeSize -= count
    • 遍历数组剩余元素,更新后续节点的 flatIndexMap(值 -count)。
const deleteNode = (node: TreeFlatNode) => {
  const { id, subTreeSize, parentId } = node;
  const startIndex = flatIndexMap.get(id) as number;

  // 1. Remove from flatArray
  flatArray.value.splice(startIndex, subTreeSize as number);

  // 2. Remove from parent's children
  if (parentId) {
    const parent = treeNodeMap.get(parentId)!;
    const index = parent.children!.findIndex((c) => c.id === id);
    if (index > -1) {
      parent.children!.splice(index, 1);
      // Update sibling indices
      for (let i = index; i < parent.children!.length; i++) {
        siblingIndexMap.set(parent.children![i].id, i);
      }
    }
    updateSubTreeSizeUpwards(parent, -(subTreeSize as number));
  } else {
    // Root node
    if (treeData.value) {
      const index = treeData.value.findIndex((c) => c.id === id);
      if (index > -1) {
        treeData.value.splice(index, 1);
        // Update sibling indices
        for (let i = index; i < treeData.value.length; i++) {
          siblingIndexMap.set(treeData.value[i].id, i);
        }
      }
    }
  }

  // 3. Update flatIndexMap
  updateFlatIndexMap(startIndex);

  // 4. Cleanup maps
  // Ideally we should recursively delete from maps, but for now simple delete is okay
  // as long as we don't reuse IDs or query deleted nodes.
  treeNodeMap.delete(id);
  flatIndexMap.delete(id);
  siblingIndexMap.delete(id);

  triggerUpdate();
};

3.5 树移动节点 (Cut & Paste)

逻辑构思:移动本质上是“先删后加”,但为了保留对象的引用和内部状态(避免触发大量的 UI 卸载/重挂载),我们采用“剪切-粘贴”策略。 算法设计

  • 时间复杂度O(N)O(N),相当于执行了一次删除和一次添加,需要进行两次数组元素的移位操作和索引更新。
  1. 剪切 (Detach)
    • 规模 count = node.subTreeSize。起点 oldIndex = flatIndexMap.get(node.id)
    • 提取子树:subTreeNodes = flatArray.splice(oldIndex, count)
    • 更新原父链的 subTreeSize -= count,更新原位置后续节点的 flatIndexMap。清理原父节点的 children
  2. 粘贴 (Attach)
    • 按照“添加节点”的逻辑计算出新的插入点 newIndex
    • 整体插入:flatArray.splice(newIndex, 0, ...subTreeNodes)
    • 更新新父链的 subTreeSize += count,更新新位置后续节点的 flatIndexMap。更新新父节点的 children
const moveNode = (
  node: TreeFlatNode,
  targetNode: TreeFlatNode,
  placement: "before" | "after" | "inner",
) => {
  if (!node || !targetNode) return;

  let current: TreeFlatNode | null | undefined = targetNode;
  while (current) {
    if (current.id === node.id) {
      throw new Error("Cannot move a node into itself or its descendants");
    }
    if (current.parentId) {
      current = treeNodeMap.get(current.parentId);
    } else {
      current = null;
    }
  }

  const { id, subTreeSize, parentId: oldParentId } = node;
  const oldIndex = flatIndexMap.get(id) as number;

  // 1. Cut (Detach)
  const subTreeNodes = flatArray.value.splice(oldIndex, subTreeSize as number);

  if (oldParentId) {
    const oldParent = treeNodeMap.get(oldParentId)!;
    const childIndex = oldParent.children!.findIndex((c) => c.id === id);
    if (childIndex > -1) {
      oldParent.children!.splice(childIndex, 1);
      for (let i = childIndex; i < oldParent.children!.length; i++) {
        siblingIndexMap.set(oldParent.children![i].id, i);
      }
    }
    updateSubTreeSizeUpwards(oldParent, -(subTreeSize as number));
  } else {
    const childIndex = treeData.value.findIndex((c) => c.id === id);
    if (childIndex > -1) {
      treeData.value.splice(childIndex, 1);
      for (let i = childIndex; i < treeData.value.length; i++) {
        siblingIndexMap.set(treeData.value[i].id, i);
      }
    }
  }

  // 2. Paste (Attach)
  let newParentId: TreeNodeId | null | undefined = null;
  let newParent: TreeFlatNode | null | undefined = null;
  let insertIndexInChildren = 0;
  let newFlatIndex = 0;
  let childrenArray: TreeFlatNode[] | null = null;

  if (placement === "inner") {
    newParentId = targetNode.id;
    newParent = targetNode;
    if (!newParent.children) newParent.children = [];
    childrenArray = newParent.children;
    insertIndexInChildren = childrenArray.length;

    newFlatIndex =
      (flatIndexMap.get(targetNode.id) as number) +
      (targetNode.subTreeSize as number);
    if (oldIndex < (flatIndexMap.get(targetNode.id) as number)) {
      newFlatIndex -= subTreeSize as number;
    }
  } else {
    newParentId = targetNode.parentId;
    if (newParentId) {
      newParent = treeNodeMap.get(newParentId);
      childrenArray = newParent!.children!;
    } else {
      childrenArray = treeData.value;
    }

    const targetSiblingIndex = siblingIndexMap.get(targetNode.id) as number;
    insertIndexInChildren =
      placement === "before" ? targetSiblingIndex : targetSiblingIndex + 1;

    if (placement === "before") {
      newFlatIndex = flatIndexMap.get(targetNode.id) as number;
    } else {
      newFlatIndex =
        (flatIndexMap.get(targetNode.id) as number) +
        (targetNode.subTreeSize as number);
    }

    if (oldIndex < (flatIndexMap.get(targetNode.id) as number)) {
      newFlatIndex -= subTreeSize as number;
    }
  }

  childrenArray.splice(insertIndexInChildren, 0, node);
  for (let i = insertIndexInChildren; i < childrenArray.length; i++) {
    siblingIndexMap.set(childrenArray[i].id, i);
  }

  flatArray.value.splice(newFlatIndex, 0, ...subTreeNodes);

  node.parentId = newParentId;

  if (newParent) {
    updateSubTreeSizeUpwards(newParent, subTreeSize as number);
  }

  updateFlatIndexMap(Math.min(oldIndex, newFlatIndex));

  triggerUpdate();
};

3.6 树修改节点属性

逻辑构思:仅修改非结构属性,不影响树形态。 算法设计:通过 treeNodeMapO(1)O(1) 复杂度拿到节点引用,直接修改。由于 flatArray 中保存的是同一个对象的引用,借助 Vue/React 的响应式机制,UI 会自动局部更新。

  • 时间复杂度O(1)O(1),通过 Map 瞬间定位,修改属性即完成操作,没有额外的遍历或数组移位开销。
const handleEdit = (data) => {
  data.label = "new value";
  emit("update:treeData", [...treeData.value]);
};

4. 虚拟滚动的实现与结合

完成了底层数据的双向同步后,我们在视图层引入虚拟滚动(Virtual Scrolling)以彻底解决 DOM 节点过多的性能问题。

4.1 结合 vue-virtual-scroll-list 实现虚拟滚动

在实际开发中,我们通常不需要手写虚拟滚动逻辑,可以直接借助成熟的第三方库(如 vue-virtual-scroll-list)来实现。

使用 vue-virtual-scroll-list 非常简单,我们只需要将维护好的扁平数组 flatArray 传递给组件即可:

<template>
  <virtual-list
    class="tree-virtual-list"
    :data-key="'id'"
    :data-sources="flatArray"
    :data-component="TreeNodeComponent"
    :estimate-size="30"
  />
</template>

<script setup>
import VirtualList from "vue-virtual-scroll-list";
import TreeNodeComponent from "./TreeNodeComponent.vue";
// ... 维护 flatArray 的逻辑
</script>

<style scoped>
.tree-virtual-list {
  height: 500px;
  overflow-y: auto;
}
</style>

4.2 结合双向同步方案的优势

  1. 直接驱动:上述设计的 flatArray 就是一个一维的响应式数组。每次发生增删改移操作后,flatArray 会实时发生变化(长度变化或元素变更)。
  2. 自动映射:虚拟滚动组件直接监听 flatArray 的长度变化,重新计算 totalHeight 和视图区间,开发者无需手动干预 DOM。
  3. 层级视觉还原:在渲染截取出来的节点时,可以结合节点在树中的层级关系动态计算内边距,在视觉上完美还原树的结构。
  4. 折叠/展开逻辑:树的折叠和展开,本质上就是对该节点的子树进行“批量删除”和“重新添加子节点”的操作。复用上述的 3.4 和 3.2 逻辑,配合虚拟滚动,即使展开包含几万个节点的目录,也只是一次数组 splice 操作,界面不会有任何卡顿。

完整代码

将数据结构和树的操作封装成组合式函数,便于复用。

useTreeFlat.ts源码:

import { ref, watch } from "vue";
import type {
  TreeFlatNode,
  TreeNodeMap,
  FlatIndexMap,
  SiblingIndexMap,
  TreeNodeId,
} from "./types";

export const useTreeFlat = (props: any, emit?: any) => {
  const flatArray = ref<TreeFlatNode[]>([]);
  const treeData = ref<TreeFlatNode[]>([]); // Store reference to the source tree array
  const treeNodeMap: TreeNodeMap = new Map();
  const flatIndexMap: FlatIndexMap = new Map();
  const siblingIndexMap: SiblingIndexMap = new Map();

  // Helper to update ancestor sizes
  const updateSubTreeSizeUpwards = (node: TreeFlatNode, delta: number) => {
    let current: TreeFlatNode | null | undefined = node;
    while (current) {
      if (current.subTreeSize !== undefined) {
        current.subTreeSize += delta;
      }
      if (current.parentId) {
        current = treeNodeMap.get(current.parentId);
      } else {
        current = null;
      }
    }
  };

  // Helper to update flatIndexMap for nodes after a certain index
  const updateFlatIndexMap = (startIndex: number) => {
    for (let i = startIndex; i < flatArray.value.length; i++) {
      const node = flatArray.value[i];
      flatIndexMap.set(node.id, i);
    }
  };

  // Recursive function to flatten a node and its children,
  // setting metadata and populating maps for NEW nodes.
  const flattenAndRegister = (
    node: TreeFlatNode,
    parentId: TreeNodeId | null | undefined,
    flatList: TreeFlatNode[],
  ) => {
    node.parentId = parentId;
    if (!node.children) node.children = [];

    flatList.push(node);
    treeNodeMap.set(node.id, node);
    // Note: flatIndexMap will be set after we insert into flatArray to be correct.

    let size = 1;
    if (node.children.length > 0) {
      node.children.forEach((child, index) => {
        siblingIndexMap.set(child.id, index);
        size += flattenAndRegister(child, node.id, flatList);
      });
    }
    node.subTreeSize = size;
    return size;
  };

  const initTreeFlat = (tree: TreeFlatNode[]) => {
    treeData.value = tree;
    flatArray.value = [];
    treeNodeMap.clear();
    flatIndexMap.clear();
    siblingIndexMap.clear();

    const traverse = (
      nodes: TreeFlatNode[],
      parentId: TreeNodeId | null | undefined = null,
    ) => {
      let currentLevelSize = 0;
      nodes.forEach((node, index) => {
        node.parentId = parentId;
        if (!node.children) node.children = [];

        treeNodeMap.set(node.id, node);
        siblingIndexMap.set(node.id, index);

        flatArray.value.push(node);
        flatIndexMap.set(node.id, flatArray.value.length - 1);

        let childrenSize = 0;
        if (node.children && node.children.length > 0) {
          childrenSize = traverse(node.children, node.id);
        }

        node.subTreeSize = 1 + childrenSize;
        currentLevelSize += node.subTreeSize;
      });
      return currentLevelSize;
    };

    if (treeData.value && treeData.value.length > 0) {
      traverse(treeData.value);
    }

    return flatArray.value;
  };

  // Watch props for changes
  if (props && props.treeData) {
    watch(
      () => props.treeData,
      (newVal) => {
        const newStr = JSON.stringify(newVal);
        const oldStr = JSON.stringify(treeData.value);
        if (newStr !== oldStr) {
          const newData = JSON.parse(newStr);
          initTreeFlat(newData);
        }
      },
      { immediate: true, deep: true },
    );
  }

  const triggerUpdate = () => {
    treeData.value = [...treeData.value];
    if (emit) {
      emit("update:treeData", treeData.value);
    }
  };

  const addChildNode = (currentNode: TreeFlatNode, newNode: TreeFlatNode) => {
    if (!currentNode.children) currentNode.children = [];

    // 1. Add to parent's children
    currentNode.children.push(newNode);
    const siblingIndex = currentNode.children.length - 1;
    siblingIndexMap.set(newNode.id, siblingIndex);

    // 2. Prepare flat list for new node(s)
    const newFlatNodes: TreeFlatNode[] = [];
    flattenAndRegister(newNode, currentNode.id, newFlatNodes);

    // 3. Insert into flatArray
    // Insert after the last node of currentNode's EXISTING subtree.
    // currentNode.subTreeSize currently includes existing children (before this new one is fully accounted for in the loop? No, subTreeSize is property).
    // Wait, updateSubTreeSizeUpwards is called AFTER. So currentNode.subTreeSize is the OLD size.
    // So insertion point is flatIndexMap[currentNode.id] + currentNode.subTreeSize.
    const insertIndex =
      (flatIndexMap.get(currentNode.id) as number) +
      (currentNode.subTreeSize as number);
    flatArray.value.splice(insertIndex, 0, ...newFlatNodes);

    // 4. Update maps and sizes
    updateFlatIndexMap(insertIndex);
    updateSubTreeSizeUpwards(currentNode, newNode.subTreeSize as number);

    triggerUpdate();
  };

  const addSiblingNode = (currentNode: TreeFlatNode, newNode: TreeFlatNode) => {
    const parentId = currentNode.parentId;
    let parentNode: TreeFlatNode | null | undefined = null;
    let childrenArray: TreeFlatNode[] | null = null;

    if (parentId) {
      parentNode = treeNodeMap.get(parentId);
      childrenArray = parentNode!.children!;
    } else {
      childrenArray = treeData.value;
    }

    // 1. Insert into children array
    const currentSiblingIndex = siblingIndexMap.get(currentNode.id) as number;
    const insertIndexInChildren = currentSiblingIndex + 1;
    childrenArray.splice(insertIndexInChildren, 0, newNode);

    // 2. Update sibling indices for subsequent siblings
    for (let i = insertIndexInChildren; i < childrenArray.length; i++) {
      siblingIndexMap.set(childrenArray[i].id, i);
    }

    // 3. Prepare flat list
    const newFlatNodes: TreeFlatNode[] = [];
    flattenAndRegister(newNode, parentId, newFlatNodes);

    // 4. Insert into flatArray
    // Insert after currentNode's subtree
    const insertIndex =
      (flatIndexMap.get(currentNode.id) as number) +
      (currentNode.subTreeSize as number);
    flatArray.value.splice(insertIndex, 0, ...newFlatNodes);

    // 5. Update maps and sizes
    updateFlatIndexMap(insertIndex);
    if (parentNode) {
      updateSubTreeSizeUpwards(parentNode, newNode.subTreeSize as number);
    }

    triggerUpdate();
  };

  const deleteNode = (node: TreeFlatNode) => {
    const { id, subTreeSize, parentId } = node;
    const startIndex = flatIndexMap.get(id) as number;

    // 1. Remove from flatArray
    flatArray.value.splice(startIndex, subTreeSize as number);

    // 2. Remove from parent's children
    if (parentId) {
      const parent = treeNodeMap.get(parentId)!;
      const index = parent.children!.findIndex((c) => c.id === id);
      if (index > -1) {
        parent.children!.splice(index, 1);
        // Update sibling indices
        for (let i = index; i < parent.children!.length; i++) {
          siblingIndexMap.set(parent.children![i].id, i);
        }
      }
      updateSubTreeSizeUpwards(parent, -(subTreeSize as number));
    } else {
      // Root node
      if (treeData.value) {
        const index = treeData.value.findIndex((c) => c.id === id);
        if (index > -1) {
          treeData.value.splice(index, 1);
          // Update sibling indices
          for (let i = index; i < treeData.value.length; i++) {
            siblingIndexMap.set(treeData.value[i].id, i);
          }
        }
      }
    }

    // 3. Update flatIndexMap
    updateFlatIndexMap(startIndex);

    // 4. Cleanup maps
    // Ideally we should recursively delete from maps, but for now simple delete is okay
    // as long as we don't reuse IDs or query deleted nodes.
    // For completeness, we should probably clear entries for all descendants.
    // But since they are removed from flatArray and parent's children, they are effectively gone.
    treeNodeMap.delete(id);
    flatIndexMap.delete(id);
    siblingIndexMap.delete(id);

    triggerUpdate();
  };

  const moveNode = (
    node: TreeFlatNode,
    targetNode: TreeFlatNode,
    placement: "before" | "after" | "inner",
  ) => {
    if (!node || !targetNode) return;

    let current: TreeFlatNode | null | undefined = targetNode;
    while (current) {
      if (current.id === node.id) {
        throw new Error("Cannot move a node into itself or its descendants");
      }
      if (current.parentId) {
        current = treeNodeMap.get(current.parentId);
      } else {
        current = null;
      }
    }

    const { id, subTreeSize, parentId: oldParentId } = node;
    const oldIndex = flatIndexMap.get(id) as number;

    // 1. Cut (Detach)
    const subTreeNodes = flatArray.value.splice(
      oldIndex,
      subTreeSize as number,
    );

    if (oldParentId) {
      const oldParent = treeNodeMap.get(oldParentId)!;
      const childIndex = oldParent.children!.findIndex((c) => c.id === id);
      if (childIndex > -1) {
        oldParent.children!.splice(childIndex, 1);
        for (let i = childIndex; i < oldParent.children!.length; i++) {
          siblingIndexMap.set(oldParent.children![i].id, i);
        }
      }
      updateSubTreeSizeUpwards(oldParent, -(subTreeSize as number));
    } else {
      const childIndex = treeData.value.findIndex((c) => c.id === id);
      if (childIndex > -1) {
        treeData.value.splice(childIndex, 1);
        for (let i = childIndex; i < treeData.value.length; i++) {
          siblingIndexMap.set(treeData.value[i].id, i);
        }
      }
    }

    // 2. Paste (Attach)
    let newParentId: TreeNodeId | null | undefined = null;
    let newParent: TreeFlatNode | null | undefined = null;
    let insertIndexInChildren = 0;
    let newFlatIndex = 0;
    let childrenArray: TreeFlatNode[] | null = null;

    if (placement === "inner") {
      newParentId = targetNode.id;
      newParent = targetNode;
      if (!newParent.children) newParent.children = [];
      childrenArray = newParent.children;
      insertIndexInChildren = childrenArray.length;

      newFlatIndex =
        (flatIndexMap.get(targetNode.id) as number) +
        (targetNode.subTreeSize as number);
      if (oldIndex < (flatIndexMap.get(targetNode.id) as number)) {
        newFlatIndex -= subTreeSize as number;
      }
    } else {
      newParentId = targetNode.parentId;
      if (newParentId) {
        newParent = treeNodeMap.get(newParentId);
        childrenArray = newParent!.children!;
      } else {
        childrenArray = treeData.value;
      }

      const targetSiblingIndex = siblingIndexMap.get(targetNode.id) as number;
      insertIndexInChildren =
        placement === "before" ? targetSiblingIndex : targetSiblingIndex + 1;

      if (placement === "before") {
        newFlatIndex = flatIndexMap.get(targetNode.id) as number;
      } else {
        newFlatIndex =
          (flatIndexMap.get(targetNode.id) as number) +
          (targetNode.subTreeSize as number);
      }

      if (oldIndex < (flatIndexMap.get(targetNode.id) as number)) {
        newFlatIndex -= subTreeSize as number;
      }
    }

    childrenArray.splice(insertIndexInChildren, 0, node);
    for (let i = insertIndexInChildren; i < childrenArray.length; i++) {
      siblingIndexMap.set(childrenArray[i].id, i);
    }

    flatArray.value.splice(newFlatIndex, 0, ...subTreeNodes);

    node.parentId = newParentId;

    if (newParent) {
      updateSubTreeSizeUpwards(newParent, subTreeSize as number);
    }

    updateFlatIndexMap(Math.min(oldIndex, newFlatIndex));

    triggerUpdate();
  };

  const moveUp = (node: TreeFlatNode) => {
    const parentId = node.parentId;
    const childrenArray = parentId
      ? treeNodeMap.get(parentId)!.children!
      : treeData.value;
    const siblingIndex = siblingIndexMap.get(node.id) as number;
    if (siblingIndex > 0) {
      const targetNode = childrenArray[siblingIndex - 1];
      moveNode(node, targetNode, "before");
    }
  };

  const moveDown = (node: TreeFlatNode) => {
    const parentId = node.parentId;
    const childrenArray = parentId
      ? treeNodeMap.get(parentId)!.children!
      : treeData.value;
    const siblingIndex = siblingIndexMap.get(node.id) as number;
    if (siblingIndex < childrenArray.length - 1) {
      const targetNode = childrenArray[siblingIndex + 1];
      moveNode(node, targetNode, "after");
    }
  };

  return {
    treeData,
    initTreeFlat,
    addChildNode,
    addSiblingNode,
    deleteNode,
    moveNode,
    moveUp,
    moveDown,
    flatArray,
  };
};

实现效果

image-5.png

结语

本方案通过引入 subTreeSize 字段并利用 DFS 的连续性原理,将复杂的树形结构拓扑变更,巧妙地降维成了简单的一维数组切片(Splice)操作。结合辅助 Map 索引换取时间,配合视图层的虚拟滚动,最终构建出了一个高性能、逻辑清晰的树与数组实时双向同步架构。

v0.dev 支持 RSC 了!AI 生成全栈组件离我们还有多远?

如果你最近还在把 Vercel 的 v0.dev 当作一个单纯的“Tailwind 代码生成器”或者“UI 画板”,那你可能要重新审视这个工具了。

最近,v0.dev 迎来了一个低调但绝对称得上是里程碑式的更新:它开始支持生成 React Server Components (RSC)

这意味着什么?这意味着 AI 正在跨越那条隐形的红线——从纯粹的“前端视觉层”生成,正式将触角伸向了“服务端逻辑”。今天,我们就来聊聊这个变化为什么如此重要,以及距离我们真正实现“一句话生成全栈组件”,到底还有多远。


从“画皮”到“入骨”:RSC 给 v0 带来了什么?

在过去,当你对 v0 说“给我一个用户资料卡片”时,它会极其聪明地组合 Tailwind CSS 和 shadcn/ui,给你一个漂亮的界面。但里面的数据是死的:John Doe, johndoe@example.com。要想把它用到生产环境,你还得自己写数据获取逻辑、处理 Loading 状态。

但支持 RSC 之后,游戏规则改变了。

现在,v0 可以直接生成一个异步的 React 组件。它不仅仅知道怎么“画”出这个组件,它还知道怎么“喂”饱这个组件。AI 可以直接在组件顶部写出服务端的 Fetch 逻辑,甚至直接连接数据库(如果你提供了足够的上下文)。

以前的 v0 是前端切图仔,现在的 v0 是初级全栈工程师。

RSC 将数据获取和 UI 渲染收敛到了同一个文件中,这种心智模型不仅对人类开发者友好,对 LLM(大型语言模型)来说更是简直完美。AI 不再需要在多个文件之间跳转来维护状态,只需在单文件中顺水推舟地完成“请求 -> 处理 -> 渲染”的闭环。


距离真正的“AI 生成全栈”,我们还差几步?

既然 v0 已经迈出了服务端数据获取的第一步,那么离真正的“一句话生成完整业务线”(不仅能看,能读,还能写、能交互),我们还有多远?

客观看待,技术上已经非常接近,但要达到工程级的可靠性,还需要跨越以下三个关卡:

1. 从“读(RSC)”到“写(Server Actions)”

RSC 解决了“看”的问题(Read),但真正的全栈组件需要“动”(Write)。用户提交表单、点赞、删除一条记录,这些都需要通过状态变更来实现。

React 已经给出了答案:Server Actions

可以预见,v0 的下一步必然是深度集成 Server Actions。当你要求“生成一个带提交功能的登录表单”时,AI不仅能写出 UI,还能自动生成底层的 action.ts,处理数据验证(如 Zod)并模拟数据库写入。一旦这个闭环打通,AI 生成单文件全栈组件(Single-File Full-Stack Component)将成为常态。

2. 数据库与上下文感知 (Context Awareness)

现在的 AI 生成大多还是“盲人摸象”。它不知道你的数据库表结构长什么样。

要生成真正的全栈组件,AI 需要深度理解你的代码库上下文。它必须知道你的 Prisma Schema 或者 Drizzle Schema。未来的 v0(或 Cursor 等工具)必然会增加一种机制,让你可以轻易地将数据库结构作为上下文注入。

“根据我的 User 表,生成一个支持分页和关键字搜索的后台管理表格。” —— 这才是终极形态。

3. 鉴权、边界与安全性 (Security & Edge Cases)

这是目前 AI 最大的软肋。AI 为了让代码跑起来,往往会忽略安全性。

在全栈组件中,谁来保证这个请求是被授权的?谁来防止 SQL 注入或越权访问?如果 AI 自动生成的服务端逻辑没有正确包裹 requireAuth() 或进行权限校验,这将是灾难性的。在 AI 生成全栈代码普及之前,基于 AI 的自动化安全审计工具必须先成熟起来。


结语:产品工程师的黄金时代

v0 支持 RSC,只是一个微小的版本更新,但它是一个明确的信号:UI 和后端的边界正在被 AI 暴力的抹平。

我们正在从“手写每一行代码的工匠”,变成“拼装智能组件的架构师”。对于开发者来说,这意味着我们不再需要把时间浪费在无聊的增删改查和像素级对齐上,而是可以将全部精力投入到业务逻辑、用户体验和产品创新上。

AI 生成全栈组件离我们不远了,也许就在下一次的 Next.js Conf 上,我们就能见证它的完全体。

你,准备好了吗?

🐾 我是404星球的猫

💻✨ 探索前端无界,拥抱AI未来,我们下篇见~

👇 关注我,解锁技术交叉新视野

组件测试策略:测试 Props、事件和插槽

前言:为什么组件测试要关注 Props、事件和插槽?

组件的本质:输入与输出

<template>
  <!-- Props 是输入 -->
  <ChildComponent 
    :user="userData"
    :showDetails="true"
    @update="handleUpdate"
    @delete="handleDelete"
  >
    <!-- 插槽也是输入 -->
    <template #header>
      <h1>标题</h1>
    </template>
  </ChildComponent>
</template>

组件的测试的关注点

  1. Props 输入是否正确渲染
  2. 事件输出是否正确触发
  3. 插槽内容是否正确分发

为什么这三个要素最重要?

要素 作用 测试重点
Props 父组件向子组件传递数据 组件是否能正确接收并渲染数据
事件 子组件向父组件通信 交互是否能正确触发事件
插槽 父组件控制子组件内容 内容是否能被正确分发和渲染

测试 Props - 验证输入

Props 测试的核心

给子组件输入数据,看它能不能正确显示。

最简单的 Props 测试

我们先来看一个简单的 Props 组件:

<!-- Greeting.vue -->
<template>
  <h1>Hello, {{ name }}!</h1>
</template>

<script setup>
defineProps(['name'])
</script>

其对应的测试:

// Greeting.spec.js
import { mount } from '@vue/test-utils'
import Greeting from './Greeting.vue'

describe('Greeting', () => {
  it('显示传入的名字', () => {
    // 传入 name="张三"
    const wrapper = mount(Greeting, {
      props: { name: '张三' }
    })
    
    // 验证是否显示了"Hello, 张三!"
    expect(wrapper.text()).toBe('Hello, 张三!')
  })
})

测试多种 Props 值

我们再来看一个有多种 Props 值的组件:

<!-- Button.vue -->
<template>
  <button 
    :class="['btn', `btn-${type}`]"
    :disabled="disabled"
  >
    {{ text }}
  </button>
</template>

<script setup>
defineProps({
  text: String,
  type: { type: String, default: 'primary' },
  disabled: { type: Boolean, default: false }
})
</script>

其对应的测试:

// Button.spec.js
import { mount } from '@vue/test-utils'
import Button from './Button.vue'

describe('Button', () => {
  it('显示正确的文字', () => {
    const wrapper = mount(Button, {
      props: { text: '点击我' }
    })
    expect(wrapper.text()).toBe('点击我')
  })
  
  it('默认类型是 primary', () => {
    const wrapper = mount(Button, {
      props: { text: '按钮' }
    })
    expect(wrapper.classes()).toContain('btn-primary')
  })
  
  it('可以设置 type 为 danger', () => {
    const wrapper = mount(Button, {
      props: { text: '删除', type: 'danger' }
    })
    expect(wrapper.classes()).toContain('btn-danger')
  })
  
  it('disabled 属性可以禁用按钮', () => {
    const wrapper = mount(Button, {
      props: { text: '按钮', disabled: true }
    })
    expect(wrapper.attributes('disabled')).toBeDefined()
  })
})

测试复杂 Props 对象

我们再来看一个复杂 Props 对象的组件:

<!-- UserCard.vue -->
<template>
  <div class="user-card">
    <h3>{{ user.name }}</h3>
    <p>{{ user.email }}</p>
    <span v-if="showBadge" class="badge">{{ user.status }}</span>
  </div>
</template>

<script setup>
defineProps({
  user: {
    type: Object,
    required: true
  },
  showBadge: Boolean
})
</script>

其对应的测试:

// UserCard.spec.js
import { mount } from '@vue/test-utils'
import UserCard from './UserCard.vue'

const mockUser = {
  name: '张三',
  email: 'zhangsan@example.com',
  status: 'active'
}

describe('UserCard', () => {
  it('显示用户信息', () => {
    const wrapper = mount(UserCard, {
      props: { user: mockUser }
    })
    
    expect(wrapper.text()).toContain('张三')
    expect(wrapper.text()).toContain('zhangsan@example.com')
  })
  
  it('showBadge 为 true 时显示状态', () => {
    const wrapper = mount(UserCard, {
      props: { 
        user: mockUser,
        showBadge: true 
      }
    })
    
    expect(wrapper.find('.badge').exists()).toBe(true)
    expect(wrapper.find('.badge').text()).toBe('active')
  })
  
  it('showBadge 为 false 时不显示状态', () => {
    const wrapper = mount(UserCard, {
      props: { 
        user: mockUser,
        showBadge: false 
      }
    })
    
    expect(wrapper.find('.badge').exists()).toBe(false)
  })
})

测试 Props 变化后的响应

当 Props 发生变化时,如何测试呢?

<!-- ProgressBar.vue -->
<template>
  <div class="progress-bar">
    <div class="progress-fill" :style="{ width: percent + '%' }"></div>
  </div>
</template>

<script setup>
defineProps(['percent'])
</script>

其对应的测试:

// ProgressBar.spec.js
import { mount } from '@vue/test-utils'
import ProgressBar from './ProgressBar.vue'

describe('ProgressBar', () => {
  it('根据 percent 设置宽度', () => {
    const wrapper = mount(ProgressBar, {
      props: { percent: 50 }
    })
    
    const fill = wrapper.find('.progress-fill')
    expect(fill.attributes('style')).toContain('width: 50%')
  })
  
  it('percent 变化时宽度也跟着变', async () => {
    const wrapper = mount(ProgressBar, {
      props: { percent: 50 }
    })
    
    // 修改 props
    await wrapper.setProps({ percent: 80 })
    
    const fill = wrapper.find('.progress-fill')
    expect(fill.attributes('style')).toContain('width: 80%')
  })
})

测试事件 - 验证输出

事件测试的核心

触发组件的事件交互,看它能不能正确发出事件。

基础事件测试

我们来看一个基础事件组件:

<!-- SubmitButton.vue -->
<template>
  <button @click="handleClick" :disabled="disabled">
    {{ text }}
  </button>
</template>

<script setup>
const props = defineProps({
  text: String,
  disabled: Boolean
})

const emit = defineEmits(['click'])

const handleClick = () => {
  emit('click', 'button clicked')
}
</script>

其对应的测试:

// SubmitButton.spec.js
import { mount } from '@vue/test-utils'
import SubmitButton from './SubmitButton.vue'

describe('SubmitButton', () => {
  it('点击时触发 click 事件', async () => {
    const wrapper = mount(SubmitButton, {
      props: { text: '提交' }
    })
    
    // 模拟点击
    await wrapper.trigger('click')
    
    // 验证事件被触发
    expect(wrapper.emitted('click')).toBeTruthy()
    
    // 验证事件参数
    expect(wrapper.emitted('click')[0]).toEqual(['button clicked'])
  })
  
  it('禁用时点击不触发事件', async () => {
    const wrapper = mount(SubmitButton, {
      props: { text: '提交', disabled: true }
    })
    
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('click')).toBeFalsy()
  })
})

测试多个事件

我们再来看一个有多个事件的组件:

<!-- SearchInput.vue -->
<template>
  <div>
    <input 
      v-model="value"
      @input="handleInput"
      @keyup.enter="handleEnter"
      @focus="handleFocus"
      @blur="handleBlur"
    />
    <button @click="handleClear" v-if="value">清除</button>
  </div>
</template>

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

const value = ref('')
const emit = defineEmits(['input', 'search', 'focus', 'blur', 'clear'])

const handleInput = (e) => {
  value.value = e.target.value
  emit('input', value.value)
}

const handleEnter = () => {
  emit('search', value.value)
}

const handleFocus = () => emit('focus')
const handleBlur = () => emit('blur')
const handleClear = () => {
  value.value = ''
  emit('clear')
}
</script>

其对应的测试:

// SearchInput.spec.js
import { mount } from '@vue/test-utils'
import SearchInput from './SearchInput.vue'

describe('SearchInput', () => {
  it('输入时触发 input 事件', async () => {
    const wrapper = mount(SearchInput)
    const input = wrapper.find('input')
    
    await input.setValue('vue')
    
    expect(wrapper.emitted('input')).toBeTruthy()
    expect(wrapper.emitted('input')[0]).toEqual(['vue'])
  })
  
  it('按回车时触发 search 事件', async () => {
    const wrapper = mount(SearchInput)
    const input = wrapper.find('input')
    
    await input.setValue('vue')
    await input.trigger('keyup.enter')
    
    expect(wrapper.emitted('search')).toBeTruthy()
    expect(wrapper.emitted('search')[0]).toEqual(['vue'])
  })
  
  it('获得焦点时触发 focus 事件', async () => {
    const wrapper = mount(SearchInput)
    const input = wrapper.find('input')
    
    await input.trigger('focus')
    
    expect(wrapper.emitted('focus')).toBeTruthy()
  })
  
  it('失去焦点时触发 blur 事件', async () => {
    const wrapper = mount(SearchInput)
    const input = wrapper.find('input')
    
    await input.trigger('blur')
    
    expect(wrapper.emitted('blur')).toBeTruthy()
  })
  
  it('点击清除按钮触发 clear 事件', async () => {
    const wrapper = mount(SearchInput)
    const input = wrapper.find('input')
    
    await input.setValue('test')
    await wrapper.find('button').trigger('click')
    
    expect(wrapper.emitted('clear')).toBeTruthy()
  })
})

测试事件顺序

当值发生变化时,如果确定事件的测试顺序呢?

<!-- Counter.vue -->
<template>
  <div>
    <span>{{ count }}</span>
    <button @click="increment">+1</button>
  </div>
</template>

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

const count = ref(0)
const emit = defineEmits(['change'])

const increment = () => {
  const oldValue = count.value
  count.value++
  emit('change', oldValue, count.value)
}
</script>

其对应的测试:

// Counter.spec.js
import { mount } from '@vue/test-utils'
import Counter from './Counter.vue'

describe('Counter', () => {
  it('点击时触发 change 事件,参数是旧值和新值', async () => {
    const wrapper = mount(Counter)
    
    await wrapper.find('button').trigger('click')
    
    expect(wrapper.emitted('change')).toBeTruthy()
    expect(wrapper.emitted('change')[0]).toEqual([0, 1])
    
    await wrapper.find('button').trigger('click')
    
    expect(wrapper.emitted('change')[1]).toEqual([1, 2])
  })
})

测试插槽 - 验证内容分发

插槽测试的核心

给组件填充内容,看它能不能正确显示。

测试默认插槽

我们先来看一个默认插槽的组件:

<!-- Card.vue -->
<template>
  <div class="card">
    <div class="content">
      <slot></slot>  <!-- 默认插槽 -->
    </div>
  </div>
</template>

其对应的测试:

// Card.spec.js
import { mount } from '@vue/test-utils'
import Card from './Card.vue'

describe('Card', () => {
  it('显示默认插槽的内容', () => {
    const wrapper = mount(Card, {
      slots: {
        default: '<p class="custom">自定义内容</p>'
      }
    })
    
    expect(wrapper.find('.custom').exists()).toBe(true)
    expect(wrapper.find('.custom').text()).toBe('自定义内容')
  })
})

测试具名插槽

我们再来看一个具名插槽的组件:

<!-- Layout.vue -->
<template>
  <div class="layout">
    <header>
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

其对应的测试:

// Layout.spec.js
import { mount } from '@vue/test-utils'
import Layout from './Layout.vue'

describe('Layout', () => {
  it('渲染所有插槽', () => {
    const wrapper = mount(Layout, {
      slots: {
        header: '<h1>页面标题</h1>',
        default: '<p>主要内容</p>',
        footer: '<p>版权信息</p>'
      }
    })
    
    expect(wrapper.find('header h1').text()).toBe('页面标题')
    expect(wrapper.find('main p').text()).toBe('主要内容')
    expect(wrapper.find('footer p').text()).toBe('版权信息')
  })
  
  it('没有插槽时不显示对应的区域', () => {
    const wrapper = mount(Layout, {
      slots: {
        default: '<p>内容</p>'
      }
    })
    
    expect(wrapper.find('header').exists()).toBe(true)
    expect(wrapper.find('header').text()).toBe('')
    expect(wrapper.find('footer').exists()).toBe(true)
    expect(wrapper.find('footer').text()).toBe('')
  })
})

测试作用域插槽

我们再来看一个作用域插槽的组件:

<!-- DataTable.vue -->
<template>
  <table>
    <tbody>
      <tr v-for="item in data" :key="item.id">
        <td>
          <!-- 作用域插槽,把数据传给父组件 -->
          <slot name="cell" :item="item">
            {{ item.name }}  <!-- 默认内容 -->
          </slot>
        </td>
      </tr>
    </tbody>
  </table>
</template>

<script setup>
defineProps({
  data: Array
})
</script>

其对应的测试:

// DataTable.spec.js
import { mount } from '@vue/test-utils'
import DataTable from './DataTable.vue'

const mockData = [
  { id: 1, name: '张三', age: 25 },
  { id: 2, name: '李四', age: 30 }
]

describe('DataTable', () => {
  it('默认插槽显示 name', () => {
    const wrapper = mount(DataTable, {
      props: { data: mockData }
    })
    
    const cells = wrapper.findAll('td')
    expect(cells[0].text()).toBe('张三')
    expect(cells[1].text()).toBe('李四')
  })
  
  it('自定义插槽显示 age', () => {
    const wrapper = mount(DataTable, {
      props: { data: mockData },
      slots: {
        cell: `
          <template #cell="{ item }">
            <span class="age">{{ item.age }}</span>
          </template>
        `
      }
    })
    
    const ages = wrapper.findAll('.age')
    expect(ages[0].text()).toBe('25')
    expect(ages[1].text()).toBe('30')
  })
})

完整示例 - 表单组件测试

我们来看一个复杂的表单组件:

<!-- LoginForm.vue -->
<template>
  <form @submit.prevent="handleSubmit">
    <div>
      <input 
        v-model="username"
        placeholder="用户名"
        @blur="validateUsername"
      />
      <span v-if="usernameError" class="error">{{ usernameError }}</span>
    </div>
    
    <div>
      <input 
        v-model="password"
        type="password"
        placeholder="密码"
        @blur="validatePassword"
      />
      <span v-if="passwordError" class="error">{{ passwordError }}</span>
    </div>
    
    <div>
      <label>
        <input type="checkbox" v-model="remember" />
        记住我
      </label>
    </div>
    
    <button type="submit" :disabled="!isValid">
      {{ loading ? '登录中...' : '登录' }}
    </button>
    
    <div class="footer">
      <slot name="footer"></slot>
    </div>
  </form>
</template>

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

const username = ref('')
const password = ref('')
const remember = ref(false)
const loading = ref(false)

const usernameError = ref('')
const passwordError = ref('')

const emit = defineEmits(['submit', 'loading-change'])

const validateUsername = () => {
  if (!username.value) {
    usernameError.value = '用户名不能为空'
  } else if (username.value.length < 3) {
    usernameError.value = '用户名至少3个字符'
  } else {
    usernameError.value = ''
  }
}

const validatePassword = () => {
  if (!password.value) {
    passwordError.value = '密码不能为空'
  } else if (password.value.length < 6) {
    passwordError.value = '密码至少6个字符'
  } else {
    passwordError.value = ''
  }
}

const isValid = computed(() => {
  return !usernameError.value && !passwordError.value && 
         username.value && password.value
})

const handleSubmit = async () => {
  if (!isValid.value) return
  
  loading.value = true
  emit('loading-change', true)
  
  await new Promise(resolve => setTimeout(resolve, 1000))
  
  emit('submit', {
    username: username.value,
    password: password.value,
    remember: remember.value
  })
  
  loading.value = false
  emit('loading-change', false)
}
</script>

其对应的测试:

// LoginForm.spec.js
import { mount } from '@vue/test-utils'
import LoginForm from './LoginForm.vue'

describe('LoginForm', () => {
  // 1. Props 测试(这里没有 Props,跳过)
  
  // 2. 事件测试
  describe('Events', () => {
    it('提交时触发 submit 事件', async () => {
      const wrapper = mount(LoginForm)
      
      await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
      await wrapper.find('input[placeholder="密码"]').setValue('password123')
      await wrapper.find('button[type="submit"]').trigger('click')
      
      expect(wrapper.emitted('submit')).toBeTruthy()
      expect(wrapper.emitted('submit')[0]).toEqual([{
        username: 'testuser',
        password: 'password123',
        remember: false
      }])
    })
    
    it('表单无效时不触发 submit', async () => {
      const wrapper = mount(LoginForm)
      
      await wrapper.find('button[type="submit"]').trigger('click')
      
      expect(wrapper.emitted('submit')).toBeFalsy()
    })
    
    it('提交时触发 loading-change 事件', async () => {
      const wrapper = mount(LoginForm)
      
      await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
      await wrapper.find('input[placeholder="密码"]').setValue('password123')
      await wrapper.find('button[type="submit"]').trigger('click')
      
      expect(wrapper.emitted('loading-change')).toBeTruthy()
      expect(wrapper.emitted('loading-change')[0]).toEqual([true])
      
      // 等待异步完成
      await new Promise(resolve => setTimeout(resolve, 1100))
      
      expect(wrapper.emitted('loading-change')[1]).toEqual([false])
    })
  })
  
  // 3. 插槽测试
  describe('Slots', () => {
    it('显示 footer 插槽内容', () => {
      const wrapper = mount(LoginForm, {
        slots: {
          footer: '<a href="/register">注册新账号</a>'
        }
      })
      
      expect(wrapper.find('.footer a').text()).toBe('注册新账号')
    })
  })
  
  // 4. 验证逻辑测试
  describe('Validation', () => {
    it('用户名太短时显示错误', async () => {
      const wrapper = mount(LoginForm)
      const usernameInput = wrapper.find('input[placeholder="用户名"]')
      
      await usernameInput.setValue('a')
      await usernameInput.trigger('blur')
      
      expect(wrapper.text()).toContain('用户名至少3个字符')
    })
    
    it('用户名正确时清除错误', async () => {
      const wrapper = mount(LoginForm)
      const usernameInput = wrapper.find('input[placeholder="用户名"]')
      
      await usernameInput.setValue('a')
      await usernameInput.trigger('blur')
      expect(wrapper.text()).toContain('用户名至少3个字符')
      
      await usernameInput.setValue('abc')
      await usernameInput.trigger('blur')
      expect(wrapper.text()).not.toContain('用户名至少3个字符')
    })
    
    it('密码太短时显示错误', async () => {
      const wrapper = mount(LoginForm)
      const passwordInput = wrapper.find('input[placeholder="密码"]')
      
      await passwordInput.setValue('123')
      await passwordInput.trigger('blur')
      
      expect(wrapper.text()).toContain('密码至少6个字符')
    })
  })
  
  // 5. 按钮状态测试
  describe('Submit Button', () => {
    it('表单无效时按钮禁用', async () => {
      const wrapper = mount(LoginForm)
      const button = wrapper.find('button[type="submit"]')
      
      expect(button.attributes('disabled')).toBeDefined()
    })
    
    it('表单有效时按钮启用', async () => {
      const wrapper = mount(LoginForm)
      
      await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
      await wrapper.find('input[placeholder="密码"]').setValue('password123')
      
      // 触发验证
      await wrapper.find('input[placeholder="用户名"]').trigger('blur')
      await wrapper.find('input[placeholder="密码"]').trigger('blur')
      
      const button = wrapper.find('button[type="submit"]')
      expect(button.attributes('disabled')).toBeUndefined()
    })
    
    it('提交时按钮文字变成"登录中..."', async () => {
      const wrapper = mount(LoginForm)
      
      await wrapper.find('input[placeholder="用户名"]').setValue('testuser')
      await wrapper.find('input[placeholder="密码"]').setValue('password123')
      await wrapper.find('input[placeholder="用户名"]').trigger('blur')
      await wrapper.find('input[placeholder="密码"]').trigger('blur')
      
      const button = wrapper.find('button[type="submit"]')
      expect(button.text()).toBe('登录')
      
      await button.trigger('click')
      
      expect(button.text()).toBe('登录中...')
    })
  })
})

常见问题与解决方案

问题一:异步更新导致断言失败

// ❌ 错误:没有等待 DOM 更新
it('updates count', () => {
  wrapper.vm.count++
  expect(wrapper.find('.count').text()).toBe('1') // 可能失败
})

// ✅ 正确:使用 await
it('updates count', async () => {
  wrapper.vm.count++
  await wrapper.vm.$nextTick()
  expect(wrapper.find('.count').text()).toBe('1')
})

问题二:插槽内容没有正确渲染

// ❌ 错误:没有正确传递插槽内容
const wrapper = mount(DataTable, {
  slots: {
    'cell-status': '<span class="status">{{ props.value }}</span>'
  }
})

// ✅ 正确:使用模板字符串
const wrapper = mount(DataTable, {
  slots: {
    'cell-status': `
      <template #cell-status="{ value }">
        <span class="status">{{ value }}</span>
      </template>
    `
  }
})

问题三:事件发射次数断言错误

// ❌ 错误:只检查存在性
expect(wrapper.emitted('click')).toBeTruthy()

// ✅ 正确:检查具体次数和参数
expect(wrapper.emitted('click')).toHaveLength(1)
expect(wrapper.emitted('click')?.[0]).toEqual([expectedData])

问题四:过度依赖实现细节

// ❌ 错误:测试内部方法
it('calls validateForm method', () => {
  const validateSpy = vi.spyOn(wrapper.vm, 'validateForm')
  // ...
  expect(validateSpy).toHaveBeenCalled()
})

// ✅ 正确:测试用户可见的行为
it('shows validation error when form is invalid', async () => {
  // 操作表单
  // 断言错误消息出现
})

组件测试的最佳实践

Props 测试

  • 基础渲染
  • 默认值
  • 不同值
  • Props 变化后的响应
  • 边界情况(空值、长文本)

事件测试

  • 事件是否触发
  • 事件参数是否正确
  • 事件触发次数
  • 事件顺序
  • 条件触发(禁用时)

插槽测试

  • 默认插槽内容
  • 具名插槽内容
  • 作用域插槽的 props
  • 没有插槽时的行为

结语

组件测试不是测试每一行代码,而是测试组件的行为是否符合预期。 Props 是输入,事件是输出,插槽是扩展点。把握这三个核心,就能写出高效、可靠的组件测试。

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

Vue3 单元测试实战:从组合式函数到组件

前言

在软件开发中,测试常常被视为“有时间再做”的奢侈品。然而,当项目规模扩大、团队人员变动、需求频繁变更时,没有测试的代码库会逐渐变成难以维护的"遗留系统"。

为什么需要测试?

一个没有测试的项目会怎样?

场景1:重构时的不安全感

当我们改完某个模块的代码后,怎么知道有没有破坏原有功能呢?此时我们只能手动点击测试,但只要漏掉一个边界情况就出 Bug。

场景2:新人接手代码

当团队来了新人后,第一件事是需要熟悉项目的代码。但如果没有测试,就没有相关的文档;想要理解一个函数的边界情况就会很困难。

场景3:上线前的焦虑

每次发布都要花好几个小时手工测试一遍。

测试的投资回报率

阶段 没有测试 有测试 收益
开发阶段 手动测试每个功能 保存时自动运行 节省 30% 时间
重构阶段 不敢改代码 改完运行测试 重构效率提升 200%
Code Review reviewer 手动验证 看测试用例理解 时间缩短 50%
上线阶段 每次提心吊胆 测试通过就上线 信心 100%

测试策略金字塔

        /\
       /  \      E2E 测试 (少量)
      /    \     模拟真实用户操作,成本高
     /------\
    /        \   组件测试 (适量)
   /          \  测试组件交互和渲染
  /------------\
 /              \ 单元测试 (大量)
/                \ 测试函数和组合式函数,速度快

原则:底层测试越多,上层测试越少
单元测试:60-70%
组件测试:20-30%
E2E 测试:5-10%

Vitest 快速上手

为什么选择 Vitest?

Vitest 可以与 Vite 的无缝集成,同一套配置、同一套插件、同一套别名。

Jest 的痛点

  • 需要配置 babel-jest、vue-jest、jest-serializer-vue
  • 与 Vite 的别名、插件不共享
  • 速度慢,尤其是冷启动

Vitest 的优势

  • 与 Vite 共享配置,零配置迁移
  • 多线程并发执行,速度快
  • 支持 ES Module 开箱即用
  • 与 Jest 几乎相同的 API

安装 Vitest

# 安装 Vitest
npm install --save-dev vitest

# 安装 Vue 测试工具
npm install --save-dev @vue/test-utils

# 安装 jsdom(浏览器环境模拟)
npm install --save-dev jsdom

Vite 配置集成

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

export default defineConfig({
  plugins: [vue()],
  test: {
    globals: true,  // 启用全局 API(describe, it, expect)
    environment: 'jsdom',  // 模拟浏览器环境
    include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],  // 测试文件匹配模式
    coverage: {  // 测试覆盖率配置
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: ['src/**/*.{js,ts,vue}'],
      exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts']
    },
    testTimeout: 5000,  // 测试超时时间
    setupFiles: ['./test/setup.ts']  // 全局 setup 文件
  }
})

添加测试脚本

// package.json
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}

第一个测试

假设我们有这样一个函数:

// src/utils/math.js
export function add(a, b) {
  return a + b
}

其对应的测试:

// src/utils/math.test.js
import { describe, it, expect } from 'vitest'
import { add } from './math'

describe('math.js', () => {
  it('1 + 1 应该等于 2', () => {
    expect(add(1, 1)).toBe(2)
  })
  
  it('负数相加', () => {
    expect(add(-1, -2)).toBe(-3)
  })
})

测试组合式函数

最简单的组合式函数

我们先看一个简单的组合式函数:

// composables/useCounter.js
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => count.value = initialValue
  
  return { count, increment, decrement, reset }
}

其对应的测试:

// composables/__tests__/useCounter.spec.js
import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'

describe('useCounter', () => {
  it('初始值应该是0', () => {
    const { count } = useCounter()
    expect(count.value).toBe(0)
  })
  
  it('可以设置初始值', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })
  
  it('增加1', () => {
    const { count, increment } = useCounter(5)
    increment()
    expect(count.value).toBe(6)
  })
  
  it('减少1', () => {
    const { count, decrement } = useCounter(5)
    decrement()
    expect(count.value).toBe(4)
  })
  
  it('重置', () => {
    const { count, increment, reset } = useCounter(5)
    increment()
    increment()
    expect(count.value).toBe(7)
    reset()
    expect(count.value).toBe(5)
  })
})

带 computed 的组合式函数

我们再来看一个带 computed 的组合式函数:

// composables/useDouble.js
import { ref, computed } from 'vue'

export function useDouble(initialValue = 0) {
  const value = ref(initialValue)
  const double = computed(() => value.value * 2)
  
  const setValue = (newValue) => value.value = newValue
  
  return { value, double, setValue }
}

其对应的测试:

// composables/__tests__/useDouble.spec.js
import { describe, it, expect } from 'vitest'
import { useDouble } from '../useDouble'

describe('useDouble', () => {
  it('double 应该是 value 的两倍', () => {
    const { value, double } = useDouble(3)
    expect(double.value).toBe(6)
  })
  
  it('value 变化时 double 也跟着变', () => {
    const { value, double, setValue } = useDouble(3)
    setValue(5)
    expect(double.value).toBe(10)
    
    value.value = 7
    expect(double.value).toBe(14)
  })
})

带生命周期的组合式函数

我们再来看一个带生命周期的组合式函数:

// composables/useWindowWidth.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useWindowWidth() {
  const width = ref(window.innerWidth)
  
  const updateWidth = () => {
    width.value = window.innerWidth
  }
  
  onMounted(() => {
    window.addEventListener('resize', updateWidth)
  })
  
  onUnmounted(() => {
    window.removeEventListener('resize', updateWidth)
  })
  
  return { width }
}

其对应的测试:

// composables/__tests__/useWindowWidth.spec.js
import { describe, it, expect, vi } from 'vitest'
import { useWindowWidth } from '../useWindowWidth'

// 辅助函数:让生命周期钩子执行
function withSetup(composable) {
  let result
  
  const app = createApp({
    setup() {
      result = composable()
      return () => {}
    }
  })
  
  app.mount(document.createElement('div'))
  
  return [result, app]
}

describe('useWindowWidth', () => {
  it('初始宽度是当前窗口宽度', () => {
    window.innerWidth = 1024
    const [result, app] = withSetup(() => useWindowWidth())
    expect(result.width.value).toBe(1024)
    app.unmount()
  })
  
  it('窗口大小变化时更新宽度', () => {
    window.innerWidth = 1024
    const [result, app] = withSetup(() => useWindowWidth())
    
    // 模拟窗口变化
    window.innerWidth = 800
    window.dispatchEvent(new Event('resize'))
    
    expect(result.width.value).toBe(800)
    app.unmount()
  })
  
  it('组件卸载时移除监听器', () => {
    const removeSpy = vi.spyOn(window, 'removeEventListener')
    
    const [result, app] = withSetup(() => useWindowWidth())
    app.unmount()
    
    expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function))
  })
})

测试组件

安装 Vue Test Utils

npm install --save-dev @vue/test-utils

最简单的组件

我们来看一个最简单的组件:

<!-- Counter.vue -->
<template>
  <div>
    <span class="count">{{ count }}</span>
    <button @click="increment">增加</button>
    <button @click="decrement">减少</button>
  </div>
</template>

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

const count = ref(0)

const increment = () => count.value++
const decrement = () => count.value--
</script>

其对应的测试:

// __tests__/Counter.spec.js
import { mount } from '@vue/test-utils'
import Counter from '../Counter.vue'

describe('Counter', () => {
  it('初始显示 0', () => {
    const wrapper = mount(Counter)
    expect(wrapper.find('.count').text()).toBe('0')
  })
  
  it('点击增加按钮后变成 1', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('button:first-child').trigger('click')
    expect(wrapper.find('.count').text()).toBe('1')
  })
  
  it('点击减少按钮后变成 -1', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('button:last-child').trigger('click')
    expect(wrapper.find('.count').text()).toBe('-1')
  })
})

带 Props 的组件

我们再来看一个带 Props 的组件:

<!-- Greeting.vue -->
<template>
  <div>
    <h1>Hello, {{ name }}!</h1>
    <p v-if="showMessage">欢迎使用我们的应用</p>
  </div>
</template>

<script setup>
defineProps({
  name: String,
  showMessage: Boolean
})
</script>

其对应的测试:

// __tests__/Greeting.spec.js
import { mount } from '@vue/test-utils'
import Greeting from '../Greeting.vue'

describe('Greeting', () => {
  it('显示名字', () => {
    const wrapper = mount(Greeting, {
      props: { name: '张三' }
    })
    expect(wrapper.text()).toContain('Hello, 张三!')
  })
  
  it('showMessage 为 true 时显示欢迎语', () => {
    const wrapper = mount(Greeting, {
      props: { name: '张三', showMessage: true }
    })
    expect(wrapper.text()).toContain('欢迎使用我们的应用')
  })
  
  it('showMessage 为 false 时不显示欢迎语', () => {
    const wrapper = mount(Greeting, {
      props: { name: '张三', showMessage: false }
    })
    expect(wrapper.text()).not.toContain('欢迎使用我们的应用')
  })
})

带事件的组件

我们再来看一个带事件的组件:

<!-- SubmitButton.vue -->
<template>
  <button @click="handleClick" :disabled="disabled">
    {{ text }}
  </button>
</template>

<script setup>
const props = defineProps({
  text: String,
  disabled: Boolean
})

const emit = defineEmits(['submit'])

const handleClick = () => {
  emit('submit', 'button clicked')
}
</script>

其对应的测试:

// __tests__/SubmitButton.spec.js
import { mount } from '@vue/test-utils'
import SubmitButton from '../SubmitButton.vue'

describe('SubmitButton', () => {
  it('点击时触发 submit 事件', async () => {
    const wrapper = mount(SubmitButton, {
      props: { text: '提交' }
    })
    
    await wrapper.trigger('click')
    
    // 检查事件是否触发
    expect(wrapper.emitted()).toHaveProperty('submit')
    
    // 检查事件参数
    expect(wrapper.emitted('submit')[0]).toEqual(['button clicked'])
  })
  
  it('禁用时点击不触发事件', async () => {
    const wrapper = mount(SubmitButton, {
      props: { text: '提交', disabled: true }
    })
    
    await wrapper.trigger('click')
    
    expect(wrapper.emitted('submit')).toBeUndefined()
  })
})

Mock 外部依赖

为什么要 Mock?

真实开发中,组件往往依赖:

  • API 请求
  • 组合式函数
  • 第三方库

测试时我们也许不能发送真实请求给后端,因此需要使用 Mock 模拟真实数据。

Mock 组合式函数

我们先来看一个 Mock 组合式函数:

<!-- UserProfile.vue -->
<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else-if="error">错误: {{ error.message }}</div>
    <div v-else>
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
    </div>
  </div>
</template>

<script setup>
import { useUser } from '@/composables/useUser'

const props = defineProps({ userId: Number })
const { user, loading, error } = useUser(props.userId)
</script>

其对应的测试:

// __tests__/UserProfile.spec.js
import { mount } from '@vue/test-utils'
import { vi } from 'vitest'

// 先 Mock 模块
vi.mock('@/composables/useUser')

// 再导入(Mock 后的版本)
import { useUser } from '@/composables/useUser'
import UserProfile from '../UserProfile.vue'

describe('UserProfile', () => {
  it('加载中显示 loading', () => {
    // 设置 Mock 返回值
    useUser.mockReturnValue({
      user: ref(null),
      loading: ref(true),
      error: ref(null)
    })
    
    const wrapper = mount(UserProfile, {
      props: { userId: 1 }
    })
    
    expect(wrapper.text()).toContain('加载中...')
  })
  
  it('加载成功显示用户信息', () => {
    useUser.mockReturnValue({
      user: ref({ name: '张三', email: 'zhangsan@example.com' }),
      loading: ref(false),
      error: ref(null)
    })
    
    const wrapper = mount(UserProfile, {
      props: { userId: 1 }
    })
    
    expect(wrapper.text()).toContain('张三')
    expect(wrapper.text()).toContain('zhangsan@example.com')
  })
  
  it('加载失败显示错误', () => {
    useUser.mockReturnValue({
      user: ref(null),
      loading: ref(false),
      error: ref({ message: '用户不存在' })
    })
    
    const wrapper = mount(UserProfile, {
      props: { userId: 999 }
    })
    
    expect(wrapper.text()).toContain('错误: 用户不存在')
  })
})

Mock API 请求

我们再来看一个 Mock API 请求:

// composables/useApi.js
import { ref } from 'vue'

export function useApi() {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)
  
  const fetchData = async (url) => {
    loading.value = true
    error.value = null
    
    try {
      const response = await fetch(url)
      data.value = await response.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }
  
  return { data, loading, error, fetchData }
}

其对应的测试:

// __tests__/useApi.spec.js
import { describe, it, expect, vi } from 'vitest'
import { useApi } from '../useApi'

describe('useApi', () => {
  it('请求成功', async () => {
    // Mock fetch
    const mockData = { id: 1, name: '测试' }
    global.fetch = vi.fn().mockResolvedValue({
      json: vi.fn().mockResolvedValue(mockData)
    })
    
    const { fetchData, data, loading, error } = useApi()
    
    // 初始状态
    expect(loading.value).toBe(false)
    expect(data.value).toBe(null)
    
    // 请求中
    const promise = fetchData('/api/test')
    expect(loading.value).toBe(true)
    
    // 等待请求完成
    await promise
    
    expect(loading.value).toBe(false)
    expect(data.value).toEqual(mockData)
    expect(error.value).toBe(null)
  })
  
  it('请求失败', async () => {
    global.fetch = vi.fn().mockRejectedValue(new Error('网络错误'))
    
    const { fetchData, data, loading, error } = useApi()
    
    await fetchData('/api/test')
    
    expect(loading.value).toBe(false)
    expect(data.value).toBe(null)
    expect(error.value).toBeInstanceOf(Error)
  })
})

测试最佳实践

测试行为,而非实现细节

不好的测试:关注内部实现细节

it('calls validateEmail function', () => {
  const validateSpy = vi.spyOn(LoginForm.methods, 'validateEmail')
  // ... 测试
  expect(validateSpy).toHaveBeenCalled()
})

好的测试:关注用户可见的行为

it('shows error message when email is invalid', async () => {
  const wrapper = mount(LoginForm)
  
  await wrapper.find('input[type="email"]').setValue('invalid-email')
  await wrapper.find('button[type="submit"]').trigger('click')
  
  expect(wrapper.text()).toContain('请输入有效的邮箱地址')
})

测试公共 API,而非私有状态

// composables/useCounter.ts
export function useCounter() {
  const count = ref(0)  // 内部状态
  const increment = () => count.value++
  
  // ✅ 测试公共 API
  return {
    count,      // 只读状态(通过 ref 暴露)
    increment   // 方法
  }
}

// ✅ 好的测试
it('increments count when increment is called', () => {
  const { count, increment } = useCounter()
  increment()
  expect(count.value).toBe(1)
})

测试边界情况和错误场景

it('handles empty list', () => {
  const wrapper = mount(ProductList, {
    props: { products: [] }
  })
  expect(wrapper.text()).toContain('暂无商品')
})

it('handles extremely long text', () => {
  const longText = 'a'.repeat(1000)
  const wrapper = mount(ProductCard, {
    props: { title: longText }
  })
  // 测试是否被截断或换行
})

it('handles API timeout', async () => {
  vi.mocked(fetch).mockImplementationOnce(
    () => new Promise(resolve => setTimeout(resolve, 10000))
  )
  
  const { fetchData, loading } = useApi()
  const promise = fetchData('/api/test')
  
  expect(loading.value).toBe(true)
  
  // 模拟超时
  await expect(promise).rejects.toThrow('Timeout')
})

测试描述要清晰

不好的测试描述

it('works correctly')
it('handles state')
it('test button')

好的测试描述

it('提交按钮在表单提交时禁用')
it('密码少于6位时显示错误')
it('登录成功后跳转到首页')

保持测试独立

describe('UserStore', () => {
  beforeEach(() => {
    // 每个测试前重置状态
    vi.clearAllMocks()
    localStorage.clear()
  })
  
  it('test 1', () => {})
  it('test 2', () => {})  // 不受 test 1 影响
})

测试覆盖率

配置测试覆盖率

// vite.config.js
export default {
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      include: ['src/**/*.{js,ts,vue}'],
      exclude: [
        'src/**/*.test.ts',
        'src/**/*.spec.ts',
        'src/main.ts',
        'src/router/**'
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80
      }
    }
  }
}

查看覆盖率

npm run test:coverage

# 输出:
File              | % Stmts | % Branch | % Funcs | % Lines
------------------|---------|----------|---------|--------
src/composables/  |   85.71 |    75.00 |   90.00 |   85.71
src/components/   |   72.50 |    66.67 |   80.00 |   72.50
src/utils/        |  100.00 |   100.00 |  100.00 |  100.00

常见问题与解决方案

问题一:组合式函数中的生命周期不执行

// ❌ 错误:直接调用
const result = useWindowWidth()

// ✅ 正确:使用 withSetup
const [result, app] = withSetup(() => useWindowWidth())

问题二:异步测试超时

// ❌ 错误:没有等待异步操作
it('fetches data', () => {
  const { fetchData } = useApi()
  fetchData('/api/test')
  expect(data.value).toBeDefined() // 可能还没返回
})

// ✅ 正确:等待异步操作
it('fetches data', async () => {
  const { fetchData, data } = useApi()
  await fetchData('/api/test')
  expect(data.value).toBeDefined()
})

问题三:测试 DOM 更新

// ❌ 错误:没有等待 DOM 更新
it('updates count', () => {
  wrapper.vm.count++
  expect(wrapper.find('.count').text()).toBe('1') // 可能失败
})

// ✅ 正确:使用 nextTick 或 async
it('updates count', async () => {
  wrapper.vm.count++
  await nextTick()
  expect(wrapper.find('.count').text()).toBe('1')
})

问题四:Mock 没有被正确应用

// ❌ 错误:import 在 mock 之前
import { useUser } from './useUser'
vi.mock('./useUser') // 太晚了,模块已经加载

// ✅ 正确:mock 在 import 之前
vi.mock('./useUser')
import { useUser } from './useUser'

测试的最佳实践

测试优先级

  1. 核心业务逻辑(组合式函数)→ 必须测试
  2. 关键用户路径(组件)→ 必须测试
  3. 错误边界 → 必须测试
  4. UI 细节 → 可选

检查清单

  • 每个组合式函数都有单元测试
  • 每个关键组件都有组件测试
  • 测试覆盖了成功和失败两种情况
  • 测试描述清晰,说明预期行为
  • Mock 了外部依赖
  • 测试可以独立运行
  • CI 中自动运行测试

结语

测试不是为了 100% 覆盖率,而是为了重构时的信心。 一个没有测试的项目,重构就是重写;一个有测试的项目,重构是优化。代码是写给人看的,测试是写给未来的自己看的!

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

学习笔记--vue3 watchEffect监听的各种姿势用法和总结

watchEffect 监听不同数据源

watchEffect 会自动追踪在其回调函数中使用的所有响应式依赖,无需显式指定数据源。

1. 监听单个 ref

import { ref, watchEffect } from 'vue'

const count = ref(0)

// 自动追踪 count
watchEffect(() => {
  console.log('count 值:', count.value)
  // 当 count 变化时自动执行
})

// 修改值会触发
count.value++ // 输出: count 值: 1

2. 监听多个 ref

import { ref, watchEffect } from 'vue'

const count = ref(0)
const name = ref('John')
const age = ref(18)

// 自动追踪所有使用的 ref
watchEffect(() => {
  console.log(`姓名: ${name.value}, 年龄: ${age.value}, 计数: ${count.value}`)
  // 当 name、age 或 count 任何一个变化时都会执行
})

// 任何修改都会触发
count.value++  // 触发
name.value = 'Jane'  // 触发
age.value = 20  // 触发

3. 监听单个 reactive

import { reactive, watchEffect } from 'vue'

const state = reactive({
  count: 0,
  name: 'John'
})

// 方式1:直接使用整个对象(会深度追踪所有属性)
watchEffect(() => {
  console.log('state 整体:', state)
  // 当 state 的任何属性变化时都会触发
})

// 方式2:只追踪特定属性(性能更好)
watchEffect(() => {
  console.log('count 值:', state.count)
  // 只有当 state.count 变化时才触发
})

// 修改会触发
state.count++  // 触发方式1和方式2
state.name = 'Jane'  // 只触发方式1

4. 监听多个 reactive

import { reactive, watchEffect } from 'vue'

const user = reactive({
  name: 'John',
  age: 18
})

const settings = reactive({
  theme: 'dark',
  language: 'zh'
})

// 自动追踪所有使用的 reactive 属性
watchEffect(() => {
  console.log(`用户: ${user.name}, ${user.age}岁`)
  console.log(`设置: ${settings.theme}主题, ${settings.language}语言`)
  // 当 user.name、user.age、settings.theme、settings.language 任一变化时触发
})

// 修改会触发
user.name = 'Jane'  // 触发
settings.theme = 'light'  // 触发

5. 混合监听 ref 和 reactive

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

const count = ref(0)
const user = reactive({
  name: 'John',
  info: {
    age: 18,
    city: 'Beijing'
  }
})

// 自动追踪所有使用的响应式数据
watchEffect(() => {
  console.log(`计数: ${count.value}`)
  console.log(`用户: ${user.name}`)
  console.log(`年龄: ${user.info.age}`)
  console.log(`城市: ${user.info.city}`)
  // 依赖:count.value、user.name、user.info.age、user.info.city
})

// 任何依赖变化都会触发
count.value++  // 触发
user.name = 'Jane'  // 触发
user.info.age = 20  // 触发
user.info.city = 'Shanghai'  // 触发

6. 监听嵌套 reactive 对象

import { reactive, watchEffect } from 'vue'

const state = reactive({
  user: {
    profile: {
      name: 'John',
      address: {
        city: 'Beijing',
        street: 'Main St'
      }
    }
  }
})

// 深度追踪:会自动追踪所有访问的嵌套属性
watchEffect(() => {
  console.log('城市:', state.user.profile.address.city)
  console.log('街道:', state.user.profile.address.street)
  // 只追踪 city 和 street 的变化
})

// 修改嵌套属性会触发
state.user.profile.address.city = 'Shanghai'  // 触发
state.user.profile.address.street = 'Nanjing Rd'  // 触发

// 修改未追踪的属性不会触发
state.user.profile.name = 'Jane'  // 不会触发(未在回调中使用)

7. watchEffect 的清理和停止

import { ref, watchEffect } from 'vue'

const count = ref(0)

// watchEffect 返回停止函数
const stop = watchEffect((onCleanup) => {
  console.log('count:', count.value)
  
  // 清理函数:在重新运行前或停止时执行
  onCleanup(() => {
    console.log('清理副作用')
    // 用于取消请求、清除定时器等
  })
})

// 停止监听
stop()

8. 异步 watchEffect

import { ref, watchEffect } from 'vue'

const id = ref(1)
const data = ref(null)

watchEffect(async (onCleanup) => {
  let cancelled = false
  
  onCleanup(() => {
    cancelled = true
  })
  
  // 模拟异步请求
  const response = await fetch(`/api/data/${id.value}`)
  if (!cancelled) {
    data.value = await response.json()
  }
})

9. 控制执行时机

import { ref, watchEffect } from 'vue'

const count = ref(0)

// flush: 'pre' (默认) - 组件更新前执行
watchEffect(() => {
  console.log('pre:', count.value)
}, {
  flush: 'pre'
})

// flush: 'post' - 组件更新后执行
watchEffect(() => {
  console.log('post:', count.value)
}, {
  flush: 'post'
})

// flush: 'sync' - 同步执行
watchEffect(() => {
  console.log('sync:', count.value)
}, {
  flush: 'sync'
})

10. watchEffect vs watch 对比

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

const count = ref(0)
const state = reactive({ name: 'John', age: 18 })

// watch: 显式指定数据源
watch(count, (newVal, oldVal) => {
  console.log('watch - count:', newVal, oldVal)
})

watch(
  [() => state.name, () => state.age],
  ([newName, newAge], [oldName, oldAge]) => {
    console.log('watch - name/age:', newName, newAge)
  }
)

// watchEffect: 自动追踪依赖
watchEffect(() => {
  console.log('watchEffect - count:', count.value)
  console.log('watchEffect - name/age:', state.name, state.age)
  // 自动追踪 count.value、state.name、state.age
})

// 执行时机
// watch: 懒执行,只有数据变化时才执行
// watchEffect: 立即执行一次,然后依赖变化时执行

实际应用示例

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

// 用户搜索示例
const searchKeyword = ref('')
const filters = reactive({
  category: 'all',
  sortBy: 'date',
  priceRange: [0, 1000]
})
const results = ref([])

// 自动搜索:任何搜索条件变化时自动执行
watchEffect(async () => {
  console.log('搜索条件变化,重新获取数据')
  
  // 构建查询参数
  const params = {
    keyword: searchKeyword.value,
    category: filters.category,
    sortBy: filters.sortBy,
    minPrice: filters.priceRange[0],
    maxPrice: filters.priceRange[1]
  }
  
  // 模拟 API 请求
  const response = await fetch(`/api/search?${new URLSearchParams(params)}`)
  results.value = await response.json()
})

// 任何条件变化都会触发搜索
searchKeyword.value = 'vue'  // 触发搜索
filters.category = 'books'   // 触发搜索
filters.sortBy = 'rating'    // 触发搜索
filters.priceRange = [0, 500] // 触发搜索

总结对比

特性 watch watchEffect
数据源 显式指定 自动追踪依赖
执行时机 懒执行(首次不执行) 立即执行
旧值获取 ✅ 可以获取 ❌ 无法获取
监听多个 需要数组 自动收集
嵌套对象 需要 deep 选项 自动深度追踪(访问到的属性)
性能优化 更精确控制 自动优化

最佳实践

  1. 使用 watchEffect 当

    • 不需要获取旧值
    • 依赖关系简单且自动
    • 需要立即执行副作用
  2. 使用 watch 当

    • 需要获取旧值
    • 需要精确控制监听的数据源
    • 需要懒执行(首次不执行)
  3. 性能优化

    // ❌ 避免:访问过多属性导致频繁执行
    watchEffect(() => {
      console.log(state)  // 任何属性变化都触发
    })
    
    // ✅ 推荐:只访问需要的属性
    watchEffect(() => {
      console.log(state.name, state.age)  // 只有这些属性变化才触发
    })
    

# 学习笔记--vue3 watch监听的各种姿势用法和总结

学习笔记--vue3 watch监听的各种姿势用法和总结

在 Vue 3 中,watch 监听不同数据源的方式有所不同

1. 监听单个 ref

import { ref, watch } from 'vue'

const count = ref(0)

// 直接传入 ref
watch(count, (newVal, oldVal) => {
  console.log('count 变化:', newVal, oldVal)
})

// 或者使用 getter 函数
watch(() => count.value, (newVal, oldVal) => {
  console.log('count 变化:', newVal, oldVal)
})

2. 监听多个 ref

import { ref, watch } from 'vue'

const count = ref(0)
const name = ref('John')

// 方式1:使用数组
watch([count, name], ([newCount, newName], [oldCount, oldName]) => {
  console.log('count 变化:', newCount, oldCount)
  console.log('name 变化:', newName, oldName)
})

// 方式2:使用 getter 数组
watch(
  [() => count.value, () => name.value],
  ([newCount, newName], [oldCount, oldName]) => {
    console.log('多个数据变化')
  }
)

3. 监听单个 reactive

import { reactive, watch } from 'vue'

const state = reactive({
  count: 0,
  name: 'John'
})

// ❌ 错误:直接传入 reactive 对象无法监听到内部属性的变化
watch(state, (newVal, oldVal) => {
  console.log('不会触发') // 深度监听时才会触发
})

// ✅ 正确:使用 getter 函数监听特定属性
watch(
  () => state.count,
  (newVal, oldVal) => {
    console.log('count 变化:', newVal, oldVal)
  }
)

// ✅ 深度监听整个 reactive 对象
watch(
  () => state,
  (newVal, oldVal) => {
    console.log('state 任何属性变化都会触发')
  },
  { deep: true }
)

4. 监听多个 reactive 数据

import { reactive, watch } from 'vue'

const state1 = reactive({ count: 0 })
const state2 = reactive({ name: 'John' })

// 方式1:使用 getter 数组
watch(
  [() => state1.count, () => state2.name],
  ([newCount, newName], [oldCount, oldName]) => {
    console.log('数据变化')
  }
)

// 方式2:深度监听整个 reactive 对象(不推荐)
watch(
  [() => state1, () => state2],
  ([newState1, newState2], [oldState1, oldState2]) => {
    // 注意:oldState1 和 newState1 指向同一个对象
    console.log('状态变化')
  },
  { deep: true }
)

5. 混合监听 ref 和 reactive

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

const count = ref(0)
const state = reactive({ name: 'John', age: 18 })

watch(
  [count, () => state.name, () => state.age],
  ([newCount, newName, newAge], [oldCount, oldName, oldAge]) => {
    console.log('混合数据变化')
  }
)

6. 监听响应式对象的属性

import { reactive, watch } from 'vue'

const user = reactive({
  info: {
    name: 'John',
    address: {
      city: 'Beijing'
    }
  }
})

// 监听嵌套属性
watch(
  () => user.info.address.city,
  (newVal, oldVal) => {
    console.log('城市变化:', newVal, oldVal)
  }
)

// 深度监听整个对象
watch(
  () => user,
  (newVal, oldVal) => {
    console.log('user 任何变化')
  },
  { deep: true }
)

总结对比

数据源 监听方式 注意事项
单个 ref watch(count, callback) 直接传入即可
多个 ref watch([ref1, ref2], callback) 使用数组形式
单个 reactive 属性 watch(() => state.prop, callback) 必须使用 getter
多个 reactive 属性 watch([() => state.prop1, () => state.prop2], callback) 使用 getter 数组
整个 reactive watch(() => state, callback, { deep: true }) 必须深度监听

最佳实践建议

  1. 优先使用 getter 函数,特别是监听 reactive 对象的属性
  2. 避免深度监听大型对象,可能会影响性能
  3. 注意旧值的引用问题:对于 reactive 对象,旧值可能与新值相同(因为引用未变)

最新版vue3+TypeScript开发入门到实战教程之路由详解

1、概述

网站是有许多单页面组成,页面并非孤立,而是可以相互跳转。以下是官网给的定义: Vue Router 是 Vue.js 官方的路由管理器,用于构建单页面应用(SPA)。它的核心价值在于:在不刷新页面的情况下,根据 URL 的变化动态渲染不同的组件,实现流畅的页面切换体验。 假设网站有四个页面,主页,a、b、c,网站可以从主页分别跳转到a、b、c是三个页面。也可跳回主页。这些跳转信息,称作路由信息,管理路由信息完成跳转称作路由器。路由四大要素:

  • 路由管理器,统一管理路由
  • 路由信息,记录组件与路由的对应关系
  • 跳转标签与跳转方法,用于跳转指定路由
  • 路由跳转后,指定组件显示位置

2、 基本路由导航实例

  • 创建主页,主页含有标题、导航、路由跳转子页面显示位置
  • 创建三个子页面,Fish、Cat、Bird
  • 创建路由器,挂载路由器
  • 创建路由信息

2.1创建路由器、路由信息、挂载路由器

2.1.1创建路由器、路由信息

const routes = [
  { path: '/fish', component: Fish },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }  // 动态路由
]
const router = createRouter(
  {
    history: createWebHistory(),
    routes: routes
  }
)

路由信息routes,注意routerroutes区别。routes包含path与component。

  • path是路径,浏览器地址,url如:http://localhost:5173/bird,访问bird页面
  • component组件,路径path对应的组件 路由器的创建,包含路由信息与history。history有两种模式:
  • createWebHistory。传统模式,url美观,seo友好
  • createWebHashHistory 。hash模式,url地址含有#,不美观,兼容性好

2.1.2挂载路由器

挂载路由器,要在创建vue实例后,挂载路由。vue实例是在main.ts中创建。

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

2.2路由基本切换效果

在这里插入图片描述

首先打开页面,框内为空。分别点击按钮,跳转到响应页面,内容出现在边框内,注意url地址变化。

2.2.1 目录文件结构

在这里插入图片描述

2.2.2 main.ts源码

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

2.2.1 router/index.ts源码

import { createRouter,createWebHistory } from "vue-router";
import Fish from "@/view/Fish.vue";
import Cat from "@/view/Cat.vue";
import Bird from "@/view/Bird.vue";
console.log(createRouter);
const routes = [
  { path: '/fish', component: Fish },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }  // 动态路由
]
const router = createRouter(
  {
    history: createWebHistory(),
    routes: routes
  }
)
export default router;

2.2.1 App源码

<template>
  <div class="app">
    <router-link to="/fish">跳转到鱼</router-link>
    <router-link to="/cat">跳转到猫</router-link>
    <router-link to="/bird">跳转到鸟</router-link>
    <div class="content">
    <router-view></router-view>
    </div>
  </div>
</template>
<script setup lang="ts">
</script>

2.2.1 Fish、cat、Bird源码

Fish

<template>
  <div>
    <h1>会游泳的鲫鱼</h1>
  </div>
</template>
<script setup lang="ts">
</script>

Cat

<template>
  <div>
    <h1>爱吃老鼠的猫</h1>
  </div>
</template>
<script setup lang="ts">
</script>

Bird

<template>
  <div>
    <h1>翱翔天空的小鸟</h1>
  </div>
</template>
<script setup lang="ts">
</script>

2.3路由的两个注意点

  • 路由组件,如Fish、Cat等,应存放在pages或者views文件夹内,而非components文件夹内
  • 点击导航按钮,路由的切换,是旧页面组件的销毁,新页面组件创建的过程。

3、路由的工作模式

路由的工作模式有两种,在创建路由时,必须给定模式 -history -hash history是传统模式,优点是URL更加美观,更接近传统网站URL。缺点是后期项目上线,后台服务器需配合处理路径问题,否则报404错误。一般用history较多,如b站。 hash兼容性更好,不需要服务器后台处理路径问题。缺点是url带有#,不美观,且SEO优化方面差,后端项目常用。 以下是hash实例,与history不同之处在创建路由时,用createWebHashHistory 函数指定hash模式: router/index.ts代码

import { createRouter,createWebHashHistory } from "vue-router";
import Fish from "@/view/Fish.vue";
import Cat from "@/view/Cat.vue";
import Bird from "@/view/Bird.vue";
console.log(createRouter);
const routes = [
  { path: '/fish', component: Fish },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }  // 动态路由
]
const router = createRouter(
  {
    history: createWebHashHistory(),
    routes: routes
  }
)
export default router;

运行效果: 在这里插入图片描述 注意路径带有#

4、路由跳转To的三种用法与路由命名

router-link有三种用法,以跳转为例Fish,重新配置Fish组件路由信息,给Fish路由命名为yu。 如下:{ name:'yu',path: '/fish', component: Fish }。 router-link有三种方式可以跳转到Fish组件

    <router-link :to="{name:'yu'}">跳转到鱼</router-link>
    <router-link :to="{path:'fish'}"">跳转到鱼</router-link>
    <router-link :to="/fish">跳转到鱼</router-link>

三种跳转方式各有利弊,常用第二种方式,便于路由传参。

CDN图片服务与动态参数优化

前言

在现代Web应用中,图片已经不再是简单的静态资源,而是需要根据设备、网络、浏览器能力动态优化的核心内容。CDN图片服务提供了强大的动态处理能力,结合前端的智能参数拼接,可以实现图片加载的极致优化。

一个典型的电商场景

  • 商品详情页有10张SKU图片
  • 每张图片需要支持不同尺寸(缩略图、详情图、放大镜图)
  • 需要兼容不支持WebP的老旧浏览器
  • 要求在秒级完成切换,不卡顿

本文将深入探讨如何利用 CDN 图片服务,配合前端策略,打造一个高性能、自适应、可扩展的图片系统。

CDN 图片服务是什么?

CDN 图片服务如何工作

CDN 服务:同一个图片地址,可以动态调整,加参数就能变:

https://cdn.example.com/product.jpg?width=400&quality=80&format=webp

上述地址会一个返回 400px宽、质量80、WebP格式的图片。

主流云服务商的参数格式

  • 阿里云OSS:?x-oss-process=image/resize,w_400/quality,q_80/format,webp
  • 七牛云:?imageView2/2/w/400/q/80/format/webp
  • 腾讯云COS:?imageMogr2/thumbnail/400x/quality/80/format/webp

核心处理操作

操作类型 参数 说明 示例
缩放 resize,w_400 按宽度等比缩放 /resize,w_400
裁剪 crop,w_400,h_400 从中心裁剪固定尺寸 /crop,w_400,h_400
格式转换 format,webp 转换为WebP/AVIF /format,webp
质量调整 quality,q_80 设置压缩质量(1-100) /quality,q_80
锐化 sharpen,s_100 图片锐化处理 /sharpen,s_100
水印 watermark,text_xxx 添加文字/图片水印 /watermark,text_SAMPLE

动态参数拼接 - 让每张图都量身定制

检测设备信息

// utils/device.js
export function getDeviceInfo() {
  // 设备像素比(Retina屏需要更高清的图)
  const dpr = window.devicePixelRatio || 1
  
  // 屏幕宽度
  const screenWidth = window.screen.width
  
  // 网络类型
  const connection = navigator.connection
  const networkType = connection?.effectiveType || '4g'
  const isSlowNetwork = ['slow-2g', '2g'].includes(networkType)
  
  // 是否移动设备
  const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent)
  
  return { dpr, screenWidth, networkType, isSlowNetwork, isMobile }
}

// 使用
const device = getDeviceInfo()
console.log(device)
// { dpr: 3, screenWidth: 390, isSlowNetwork: false, isMobile: true }

计算最佳图片尺寸

// utils/imageCalculator.js
export function calculateImageSize(targetWidth, deviceInfo) {
  const { dpr, isSlowNetwork, isMobile } = deviceInfo
  
  // 基础尺寸 = 目标宽度 × 像素比
  let width = Math.ceil(targetWidth * dpr)
  
  // 慢速网络下降级
  if (isSlowNetwork) {
    width = Math.floor(width * 0.7)
  }
  
  // 计算质量
  let quality = 80
  if (isSlowNetwork) {
    quality = 60
  } else if (isMobile) {
    quality = 75
  }
  
  // 确定格式
  const format = supportsWebP() ? 'webp' : 'jpg'
  
  return { width, quality, format }
}

检测 WebP 支持

// utils/webpDetect.js
let webpSupported = null

export function supportsWebP() {
  if (webpSupported !== null) return webpSupported
  
  // 创建一个1x1的WebP图片测试
  const canvas = document.createElement('canvas')
  canvas.width = 1
  canvas.height = 1
  const dataURL = canvas.toDataURL('image/webp')
  
  webpSupported = dataURL.indexOf('image/webp') === 5
  return webpSupported
}

CDN URL构建器

// utils/cdnUrl.js
export function buildCDNUrl(baseUrl, imageKey, options) {
  const { width, quality, format } = options
  
  // 阿里云OSS格式
  const params = [
    `resize,w_${width}`,
    `quality,q_${quality}`,
    format !== 'jpg' ? `format,${format}` : null
  ].filter(Boolean).join('/')
  
  return `${baseUrl}/${imageKey}?x-oss-process=image/${params}`
}

// 使用示例
const device = getDeviceInfo()
const size = calculateImageSize(400, device)
const url = buildCDNUrl('https://cdn.example.com', 'product.jpg', size)

// 结果:https://cdn.example.com/product.jpg?x-oss-process=image/resize,w_800/quality,q_80/format,webp

WebP兼容检测 - 让浏览器自己选

为什么需要检测?

不是所有浏览器都支持 WebP,比如 iOS Safari 14 之前不支持,因此直接使用 WebP 会显示不出来,我们需要让浏览器自己告诉服务器它支持什么格式。

服务端检测(推荐)

// Node.js 后端中间件
app.use((req, res, next) => {
  const accept = req.headers['accept'] || ''
  const supportsWebP = accept.includes('image/webp')
  const supportsAVIF = accept.includes('image/avif')
  
  // 把结果存起来,方便后面用
  res.locals.supportsWebP = supportsWebP
  res.locals.supportsAVIF = supportsAVIF
  
  next()
})

// 在返回HTML时注入
app.get('/', (req, res) => {
  res.render('index', {
    supportsWebP: res.locals.supportsWebP,
    supportsAVIF: res.locals.supportsAVIF
  })
})

前端检测(备选)

// 如果后端拿不到,前端也能检测
export function checkWebPSupport() {
  return new Promise((resolve) => {
    const img = new Image()
    img.onload = () => resolve(true)
    img.onerror = () => resolve(false)
    // 一个1x1的WebP图片的Base64编码
    img.src = 'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA=='
  })
}

动态选择格式

// composables/useImageFormat.js
import { ref } from 'vue'

export function useImageFormat() {
  const format = ref('jpg')
  
  async function detect() {
    // 优先检测AVIF(最新,压缩率最高)
    const avifSupported = await checkAVIFSupport()
    if (avifSupported) {
      format.value = 'avif'
      return
    }
    
    // 其次WebP
    const webpSupported = await checkWebPSupport()
    if (webpSupported) {
      format.value = 'webp'
      return
    }
    
    // 最后JPEG
    format.value = 'jpg'
  }
  
  detect()
  
  return { format }
}

域名分片 - 突破浏览器并发限制

为什么需要域名分片?

浏览器对同一域名的并发请求数有限制(通常为6-8个)。当页面需要同时加载大量图片时,这些请求会排队等待,导致加载缓慢。

问题示例

// 20张图片使用同一个域名
const urls = images.map(img => `https://cdn.example.com/${img}.jpg`)
// 浏览器最多同时下载6张,剩下14张等待

域名分片实现

// utils/cdnSharding.js
export class CDNSharding {
  constructor(baseDomain, shardCount = 4) {
    // 生成多个子域名
    // 0.cdn.example.com, 1.cdn.example.com, ...
    this.domains = []
    for (let i = 0; i < shardCount; i++) {
      this.domains.push(`https://${i}${baseDomain}`)
    }
    this.current = 0
  }
  
  // 轮询分配
  getUrl(imagePath) {
    const domain = this.domains[this.current % this.domains.length]
    this.current++
    return `${domain}${imagePath}`
  }
  
  // 基于图片ID的一致性分配(同一个图片始终用同一个域名,利于缓存)
  getUrlConsistent(imagePath, imageId) {
    const index = imageId % this.domains.length
    return `${this.domains[index]}${imagePath}`
  }
  
  // 基于路径哈希分配
  getUrlByHash(imagePath) {
    let hash = 0
    for (let i = 0; i < imagePath.length; i++) {
      hash = ((hash << 5) - hash) + imagePath.charCodeAt(i)
      hash = hash & hash
    }
    const index = Math.abs(hash) % this.domains.length
    return `${this.domains[index]}${imagePath}`
  }
}

// 使用
const sharding = new CDNSharding('.cdn.example.com', 4)

// 原来:一个域名
const oldUrls = images.map(img => `https://cdn.example.com/${img}`)

// 现在:4个域名
const newUrls = images.map(img => sharding.getUrlByHash(img))

DNS预解析优化

<!-- 在HTML头部添加DNS预解析 -->
<head>
  <link rel="dns-prefetch" href="//0.cdn.example.com">
  <link rel="dns-prefetch" href="//1.cdn.example.com">
  <link rel="dns-prefetch" href="//2.cdn.example.com">
  <link rel="dns-prefetch" href="//3.cdn.example.com">
  
  <!-- 预连接(包含TCP握手) -->
  <link rel="preconnect" href="//0.cdn.example.com">
  <link rel="preconnect" href="//1.cdn.example.com">
</head>

性能对比

图片数量 单域名 3个分片 4个分片
10张 2.8秒 1.5秒 1.2秒
20张 5.2秒 2.8秒 2.1秒
50张 12秒 6秒 4.5秒

图片上传组件 - 前端压缩再上传

为什么要前端压缩?

如果我们直接将原始图片(5MB)上传到服务器和 CDN,会非常慢!

但如果我们将图片在前端压缩后(500KB),再上传到服务器和 CDN,就会非常快了!

使用浏览器压缩库

安装

npm install browser-image-compression

使用

<!-- ImageUploader.vue -->
<template>
  <div class="uploader">
    <div class="dropzone" @drop="handleDrop" @dragover.prevent>
      <input type="file" @change="handleFileSelect" accept="image/*">
      <p>点击或拖拽图片上传</p>
    </div>
    
    <div v-if="compressing" class="progress">
      压缩中... {{ progress }}%
    </div>
    
    <div v-if="preview" class="preview">
      <img :src="preview" alt="preview">
      <button @click="upload">上传</button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import imageCompression from 'browser-image-compression'

const file = ref(null)
const preview = ref('')
const compressing = ref(false)
const progress = ref(0)

// 压缩配置
const options = {
  maxSizeMB: 1,           // 最大1MB
  maxWidthOrHeight: 1920, // 最大1920px
  useWebWorker: true,     // 使用Web Worker,不卡主线程
  fileType: 'image/webp', // 转成WebP
  initialQuality: 0.8     // 质量80%
}

async function handleFileSelect(event) {
  const rawFile = event.target.files[0]
  if (!rawFile) return
  
  compressing.value = true
  
  try {
    // 压缩图片
    const compressedFile = await imageCompression(rawFile, options)
    file.value = compressedFile
    
    // 预览
    preview.value = URL.createObjectURL(compressedFile)
    
    console.log(`压缩前: ${rawFile.size} bytes`)
    console.log(`压缩后: ${compressedFile.size} bytes`)
    console.log(`节省: ${(1 - compressedFile.size/rawFile.size)*100}%`)
    
  } catch (error) {
    console.error('压缩失败', error)
  } finally {
    compressing.value = false
  }
}

async function upload() {
  if (!file.value) return
  
  const formData = new FormData()
  formData.append('image', file.value)
  
  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData
  })
  
  const result = await response.json()
  console.log('上传成功:', result.url)
}
</script>

实战:电商SKU图片切换的秒级加载优化

问题分析

电商商品详情页的 SKU 图片切换是一个典型性能挑战:

  • 用户点击不同规格(颜色、尺寸)时,需要切换对应图片
  • 要求切换无延迟,体验流畅
  • 图片需要同时满足缩略图、主图、放大镜等多种尺寸需求

预加载策略

// composables/useSKUImages.js
import { ref } from 'vue'

export function useSKUImages() {
  const images = ref([])
  const currentIndex = ref(0)
  
  // 预加载队列
  const preloadQueue = []
  
  // 加载所有SKU图片
  async function loadSKUs(productId) {
    const response = await fetch(`/api/products/${productId}/skus`)
    const skus = await response.json()
    
    images.value = skus.map(sku => ({
      id: sku.id,
      thumbnail: buildCDNUrl(sku.key, { width: 200, quality: 70 }),
      main: buildCDNUrl(sku.key, { width: 800, quality: 80 }),
      zoom: buildCDNUrl(sku.key, { width: 1600, quality: 90 })
    }))
    
    // 预加载第一张图片
    preloadImages(0, 3)
  }
  
  // 预加载指定范围的图片
  function preloadImages(start, count) {
    for (let i = start; i < start + count && i < images.value.length; i++) {
      const img = images.value[i]
      
      // 用 link 标签预加载
      const link = document.createElement('link')
      link.rel = 'preload'
      link.as = 'image'
      link.href = img.main
      document.head.appendChild(link)
    }
  }
  
  // 切换SKU
  function switchSKU(index) {
    if (index === currentIndex.value) return
    
    currentIndex.value = index
    
    // 预加载后面几张
    if (index + 2 < images.value.length) {
      preloadImages(index + 1, 2)
    }
  }
  
  return {
    images,
    currentIndex,
    currentImage: computed(() => images.value[currentIndex.value]),
    loadSKUs,
    switchSKU
  }
}

完整的SKU图片组件

<template>
  <div class="sku-images">
    <!-- 缩略图列表 -->
    <div class="thumbnails">
      <div
        v-for="(img, idx) in images"
        :key="img.id"
        class="thumbnail"
        :class="{ active: currentIndex === idx }"
        @click="switchSKU(idx)"
      >
        <img :src="img.thumbnail" :alt="'SKU ' + idx">
      </div>
    </div>
    
    <!-- 主图区域 -->
    <div class="main-image">
      <img
        :src="currentImage?.main"
        :srcset="`
          ${currentImage?.thumbnail} 200w,
          ${currentImage?.main} 800w,
          ${currentImage?.zoom} 1600w
        `"
        sizes="(max-width: 768px) 100vw, 50vw"
        @mouseenter="showZoom = true"
        @mouseleave="showZoom = false"
        @mousemove="handleMouseMove"
      >
    </div>
    
    <!-- 放大镜 -->
    <div v-if="showZoom" class="zoom-lens" :style="lensStyle">
      <img :src="currentImage?.zoom" :style="zoomImageStyle">
    </div>
  </div>
</template>

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

const props = defineProps({
  productId: String
})

const { images, currentIndex, currentImage, loadSKUs, switchSKU } = useSKUImages()
const showZoom = ref(false)
const mousePos = ref({ x: 0, y: 0 })

onMounted(() => {
  loadSKUs(props.productId)
})

const lensStyle = computed(() => ({
  left: `${mousePos.value.x}px`,
  top: `${mousePos.value.y}px`
}))

const zoomImageStyle = computed(() => ({
  transform: `translate(${-mousePos.value.x * 2}px, ${-mousePos.value.y * 2}px)`
}))

function handleMouseMove(e) {
  const rect = e.target.getBoundingClientRect()
  mousePos.value = {
    x: e.clientX - rect.left,
    y: e.clientY - rect.top
  }
}
</script>

最佳实践清单

实施步骤

  1. 接入CDN服务

    • 阿里云OSS / 七牛云 / 腾讯云COS
    • 配置图片处理参数
  2. 动态参数优化检测设备DPR、屏幕宽度、网络类型

    • 计算最佳图片尺寸
    • 动态生成CDN URL
  3. 格式兼容处理

    • 检测浏览器支持的格式
    • 优先AVIF → WebP → JPEG
    • 服务端通过Accept头判断
  4. 域名分片

    • 生成3-4个子域名
    • 轮询或哈希分配图片
    • 添加DNS预解析
  5. 上传优化

    • 前端压缩图片
    • 使用Web Worker不卡UI

优化策略矩阵

策略 适用场景 收益 实现成本
动态尺寸参数 所有图片 减少50-70%体积
WebP/AVIF转换 现代浏览器 额外减少30-50%
域名分片 批量图片加载 提升30-50%并发
客户端压缩 用户上传图片 减少90%上传时间
智能预加载 SKU/轮播图 切换无延迟

结语

CDN图片优化的核心是**"按需供给"**——不给任何设备加载它不需要的像素,不给任何网络传输它不需要的字节。通过动态参数、格式转换、智能预加载的组合,让图片资源真正做到"恰如其分"。

记住:用户不会因为图片加载快而赞美你,但一定会因为加载慢而离开你

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

响应式图片的工程化实践:srcset与picture

前言

在移动优先和多设备并存的今天,一张图片要在不同尺寸、不同分辨率的屏幕上都能完美展示,是一项极具挑战性的任务。一个简单的<img src="photo.jpg">会导致:

  • Retina屏上图片模糊:1x图在2x屏上被拉伸
  • 移动端加载超大图片:下载了PC端的大图,浪费流量
  • 横竖屏切换时构图不当:竖屏显示的图片被强行裁剪

响应式图片技术正是为解决这些问题而生。本文将深入探讨srcsetpicture的核心原理,并通过Vue组件封装和Vite插件实现,建立一套工程化的响应式图片解决方案。

为什么需要响应式图片?

传统方式:一张图片走天下

<img src="photo.jpg" alt="风景">

传统方式的问题

  • iPhone SE (小屏) → 下载 5MB 的大图 → 浪费
  • iPad (中屏) → 下载 5MB 的大图 → 还行
  • MacBook (大屏) → 下载 5MB 的大图 → 刚好
  • Retina 屏幕 → 下载 5MB 的普通图 → 模糊

设备像素比(DPR)

什么是设备像素比

**设备像素比(Device Pixel Ratio)**是物理像素与逻辑像素的比值:

// 获取当前设备的像素比
const dpr = window.devicePixelRatio || 1;
console.log(dpr); // 普通屏: 1, Retina屏: 2, 高端屏: 3或更高

设备像素比的典型值范围

  • 普通屏幕:DPR = 1
  • Retina屏幕:DPR = 2 / 3
  • 4K屏幕:DPR = 3+

为什么需要关注DPR?

当我们在CSS中设置width: 100px时,在 DPR=2 的屏幕上,实际需要 200 个物理像素来渲染。如果只提供 100px 的图片,就会被拉伸模糊。

三个核心问题

问题1:屏幕大小不同

  • 手机小屏:不需要大图
  • 平板中屏:需要中等图
  • 电脑大屏:需要高清图

问题2:像素密度不同

  • 普通屏:1x 图就够了
  • Retina 屏:需要 2x 图
  • 高端屏:需要 3x 图

问题3:屏幕方向不同

  • 横屏:适合宽幅风景
  • 竖屏:适合高耸人像

srcset - 让浏览器自己选

x描述符(根据像素密度)

告诉浏览器:我有 1x、2x、3x 三个版本:

<img 
  src="photo-1x.jpg"
  srcset="
    photo-1x.jpg 1x,
    photo-2x.jpg 2x,
    photo-3x.jpg 3x
  "
  alt="风景"
>

浏览器在解析时,就会自动选择:

  • iPhone 14 Pro (DPR=3) → 加载 photo-3x.jpg
  • iPhone SE (DPR=2) → 加载 photo-2x.jpg
  • 普通电脑 (DPR=1) → 加载 photo-1x.jpg

w描述符(根据屏幕宽度)

<img 
  src="photo-400w.jpg"
  srcset="
    photo-400w.jpg 400w,
    photo-800w.jpg 800w,
    photo-1200w.jpg 1200w
  "
  sizes="
    (max-width: 600px) 100vw,
    (max-width: 1200px) 50vw,
    800px
  "
  alt="风景"
>

sizes是怎么计算的?

sizes属性告诉浏览器在不同视口宽度下,图像的实际显示宽度,如:

sizes="
  (max-width: 600px) 100vw,   /* 小屏幕:图片占满视口宽度 */
  (max-width: 1200px) 50vw,   /* 中屏幕:图片占视口一半 */
  800px                        /* 大屏幕:图片固定800px */
"

其计算逻辑如下:

  1. 浏览器检查 sizes:sizes: "(max-width: 600px) 100vw, ..."
  2. 匹配条件 (max-width: 600px) 满足:图片宽度 = 100vw = 375px
  3. 考虑 DPR (iPhone SE DPR=2):实际需要 = 375px × 2 = 750px 的图片
  4. 从 srcset 中选择最接近的:400w 太小,1200w 太大 → 选择 800w

picture - 让开发者控制

什么时候需要 picture?

srcset 可以解决图片大小问题,但不能解决构图问题。比如:横屏时,我们需要展示完整的风景;竖屏时,我们需要展示裁剪后的人像,此时 picture 就派上用场了!

picture 的元素的结构

<picture>
  <!-- 针对宽屏的横图 -->
  <source 
    media="(min-width: 1200px)" 
    srcset="hero-wide.jpg"
  >
  <!-- 针对平板的方图 -->
  <source 
    media="(min-width: 768px)" 
    srcset="hero-square.jpg"
  >
  <!-- 针对手机的竖图 -->
  <source 
    media="(max-width: 767px)" 
    srcset="hero-tall.jpg"
  >
  <!-- 降级方案 -->
  <img src="hero-fallback.jpg" alt="Hero image">
</picture>

浏览器会按顺序检查 <source> 元素,选择第一个匹配的媒体条件。

不同格式降级

picture还可以根据浏览器支持的格式提供不同的降级方案:

<picture>
  <!-- 优先使用AVIF(压缩率最高) -->
  <source srcset="image.avif" type="image/avif">
  <!-- 其次使用WebP(广泛支持) -->
  <source srcset="image.webp" type="image/webp">
  <!-- 降级到JPEG(兜底) -->
  <img src="image.jpg" alt="Fallback">
</picture>

srcset vs picture 选择策略

场景 推荐方案 原因
不同分辨率(2x/3x屏) srcset + x描述符 简单直接,浏览器自动选择
不同视口宽度 srcset + w描述符 + sizes 精确控制加载尺寸
不同构图/裁剪 picture + media 艺术指导需求
不同格式降级 picture + type 渐进增强,兼容老旧浏览器

Vue 组件封装:<ResponsiveImage>的设计与实现

组件设计

<!-- ResponsiveImage.vue -->
<template>
  <picture v-if="usePicture">
    <!-- 为每种格式生成 source -->
    <source
      v-for="source in pictureSources"
      :key="source.type"
      :type="source.type"
      :srcset="source.srcset"
      :media="source.media"
    >
    <!-- 兜底图 -->
    <img :src="fallbackSrc" :alt="alt" loading="lazy">
  </picture>
  
  <img
    v-else
    :src="src"
    :srcset="srcsetString"
    :sizes="sizes"
    :alt="alt"
    loading="lazy"
  >
</template>

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

const props = defineProps({
  // 基础配置
  src: String,           // 原图地址
  alt: String,           // 替代文本
  
  // 响应式配置
  widths: {
    type: Array,
    default: () => [400, 800, 1200]
  },
  formats: {
    type: Array,
    default: () => ['webp', 'avif']
  },
  sizes: {
    type: String,
    default: '100vw'
  },
  
  // 艺术指导
  mobile: String,        // 手机版图片
  tablet: String,        // 平板版图片
  desktop: String        // 桌面版图片
})

// 判断是否使用 picture 模式
const usePicture = computed(() => {
  return props.mobile || props.tablet || props.desktop
})

// 生成 srcset 字符串
const generateSrcset = (basePath, widths, format) => {
  return widths
    .map(w => `${basePath}-${w}w.${format} ${w}w`)
    .join(', ')
}

// picture 模式的 sources
const pictureSources = computed(() => {
  const sources = []
  
  // 为每种格式生成 source
  props.formats.forEach(format => {
    // 桌面版
    if (props.desktop) {
      sources.push({
        media: '(min-width: 1200px)',
        srcset: generateSrcset(props.desktop, props.widths, format),
        type: `image/${format}`
      })
    }
    
    // 平板版
    if (props.tablet) {
      sources.push({
        media: '(min-width: 768px) and (max-width: 1199px)',
        srcset: generateSrcset(props.tablet, props.widths, format),
        type: `image/${format}`
      })
    }
    
    // 手机版
    if (props.mobile) {
      sources.push({
        media: '(max-width: 767px)',
        srcset: generateSrcset(props.mobile, props.widths, format),
        type: `image/${format}`
      })
    }
  })
  
  return sources
})

// 兜底图片
const fallbackSrc = computed(() => {
  return props.desktop || props.tablet || props.mobile || props.src
})

// 非 picture 模式的 srcset
const srcsetString = computed(() => {
  if (usePicture.value) return ''
  return generateSrcset(props.src, props.widths, 'jpg')
})
</script>

组件使用示例

<template>
  <!-- 方案1:普通响应式图片 -->
  <ResponsiveImage
    src="/images/photo.jpg"
    :widths="[400, 800, 1200]"
    sizes="(max-width: 600px) 100vw, 50vw"
    alt="风景"
  />
  
  <!-- 方案2:艺术指导(不同屏幕不同构图) -->
  <ResponsiveImage
    mobile="/images/hero-mobile.jpg"
    tablet="/images/hero-tablet.jpg"
    desktop="/images/hero-desktop.jpg"
    :widths="[400, 800, 1200]"
    alt="英雄图"
  />
</template>

自动生成多尺寸图片 - Vite 插件

为什么需要插件生成?

假如我们需要手动为每张图片生成:

  • photo-400w.jpg
  • photo-800w.jpg
  • photo-1200w.jpg
  • photo-400w.webp
  • photo-800w.webp
  • photo-1200w.webp
  • photo-400w.avif
  • photo-800w.avif
  • photo-1200w.avif

相当于一张图片就要配置 9 个文件;随着图片数量的增加,这将是一场噩梦!

插件原理与设计

  1. 识别项目中的图片导入
  2. 根据配置生成多种尺寸和格式
  3. 注入对应的 srcset 信息

Vite插件完整实现

/// vite-plugin-responsive-images.js
import sharp from 'sharp'
import { glob } from 'fast-glob'

export default function responsiveImagesPlugin(options) {
  const {
    widths = [400, 800, 1200],
    formats = ['webp', 'avif'],
    quality = 80
  } = options
  
  return {
    name: 'vite-plugin-responsive-images',
    
    async buildStart() {
      // 找到所有图片
      const files = await glob('src/assets/images/**/*.{jpg,jpeg,png}')
      
      console.log(`📸 找到 ${files.length} 张图片`)
      
      for (const file of files) {
        // 为每个尺寸和格式生成图片
        for (const width of widths) {
          for (const format of formats) {
            const outputPath = file
              .replace('src/assets', 'dist/assets')
              .replace(/\.(jpg|jpeg|png)$/, `-${width}w.${format}`)
            
            await sharp(file)
              .resize(width, null, { withoutEnlargement: true })
              .toFormat(format, { quality })
              .toFile(outputPath)
          }
        }
      }
      
      console.log('✅ 图片生成完成')
    }
  }
}

配置插件

// vite.config.js
import responsiveImages from './vite-plugin-responsive-images'

export default {
  plugins: [
    responsiveImages({
      widths: [400, 800, 1200, 1600],
      formats: ['webp', 'avif'],
      quality: 75
    })
  ]
}

性能对比:不同方案下的图片加载体积

测试数据对比

基于典型电商商品详情页的测试结果:

图片类型 原始大小 WebP AVIF 节省空间
商品主图 (1200×1200) 850KB 320KB 210KB 62%-75%
商品缩略图 (400×400) 120KB 45KB 28KB 62%-77%
轮播大图 (1920×1080) 1.2MB 480KB 320KB 60%-73%

响应式方案加载体积对比

设备 传统单图 仅WebP 响应式srcset 响应式+WebP+AVIF
iPhone SE (375pt) 下载1200w图 (850KB) 下载1200w图 (320KB) 下载400w图 (120KB) 下载400w WebP (45KB)
iPad (768pt) 下载1200w图 (850KB) 下载1200w图 (320KB) 下载800w图 (280KB) 下载800w WebP (98KB)
MacBook Pro 下载1200w图 (850KB) 下载1200w图 (320KB) 下载1200w图 (850KB) 下载1200w WebP (320KB)
平均节省 基准 62% 51% 80%

加载性能指标提升

指标 优化前 优化后 提升
LCP (最大内容绘制) 3.2s 1.4s 56%
图片请求数 12 8 33%
总图片体积 4.2MB 1.1MB 74%
移动端数据消耗 4.2MB/次访问 0.6MB/次访问 86%

最佳实践清单

配置建议

图片尺寸断点:
├─ 400w:手机小屏
├─ 800w:手机大屏/平板
├─ 1200w:笔记本电脑
├─ 1600w:台式机
└─ 2000w:4K 屏幕

图片格式优先级:
├─ AVIF(最新,压缩率最高)
├─ WebP(广泛支持)
└─ JPEG/PNG(兜底)

sizes 设置:
├─ 手机:(max-width: 600px) 100vw
├─ 平板:(max-width: 1200px) 50vw
└─ 电脑:800px

实施策略选择矩阵

场景 技术方案 关键配置
普通内容图片 srcset + sizes 提供3-5种宽度,设置合理sizes
图标/Logo srcset + x描述符 提供1x/2x/3x版本
不同构图需求 picture + media 针对断点设计不同裁剪
现代格式降级 picture + type AVIF → WebP → JPEG
用户上传内容 动态生成 + CDN处理 根据设备实时转换

实施清单

  • 所有图片提供 3-5 种尺寸
  • 生成 WebP 和 AVIF 格式
  • 使用 <picture> 实现格式降级
  • 设置正确的 sizes 属性
  • 关键图片设置 loading="eager"
  • 非关键图片设置 loading="lazy"
  • 使用 Vite 插件自动生成多尺寸

结业

用户可能不会注意到图片加载很快,但一定会注意到图片加载很慢。响应式图片优化,是对用户体验最深情的告白。

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

别再手动写 loading 了!封装一个自动防重提交的 Hook

每次提交表单都要写 loading = truedisabled = true.finally(() => loading = false)
你不是在写业务,你是在重复造轮子。

在日常开发中,我们无数次面对这样的场景:

  • 用户点击“提交订单”
  • 点击“发送验证码”
  • 点击“保存设置”

而为了防止重复点击,你不得不:

  1. 定义一个 loading 状态;
  2. 在点击时设为 true
  3. 禁用按钮;
  4. 发起请求;
  5. 成功或失败后,再设回 false

一段逻辑,复制粘贴十次。

更糟的是——一旦忘记写 .finally,按钮就永远禁用;一旦并发请求没处理好,照样重复提交。

今天,我们就用 一个自定义 Hook,彻底终结这种体力劳动。


手动管理 loading 的三大痛点

1. 代码冗余

const [submitting, setSubmitting] = useState(false);

const handleSubmit = async () => {
  if (submitting) return;
  setSubmitting(true);
  try {
    await submitForm();
  } finally {
    setSubmitting(false); // 忘记这行?按钮就废了
  }
};

每个按钮都要写一遍,毫无意义。

2. 无法天然防重

即使你写了 if (submitting) return,如果用户快速双击,在 setSubmitting(true) 异步更新前,两次点击仍可能触发两次请求。

3. 状态分散,难以维护

多个按钮?多个表单?每个都要独立管理状态,逻辑割裂。


解法:封装一个 useSubmitLock Hook

我们要实现的效果:

const [handleSubmit, isSubmitting] = useSubmitLock(async (formData) => {
  await api.submitOrder(formData);
  message.success('下单成功!');
});

return (
  <button disabled={isSubmitting} onClick={() => handleSubmit(data)}>
    {isSubmitting ? '提交中...' : '立即下单'}
  </button>
);

一行调用,自动加锁、自动解锁、自动防重、自动透传参数!


实现原理:Promise 锁 + 状态同步

// React + TypeScript 版本(JS 可轻松转写)
import { useState, useCallback } from 'react';

type AsyncFunction<T extends any[], R> = (...args: T) => Promise<R>;

export const useSubmitLock = <T extends any[], R>(
  asyncFn: AsyncFunction<T, R>
) => {
  const [isLocked, setIsLocked] = useState(false);

  const wrappedFn = useCallback(
    async (...args: T): Promise<R | undefined> => {
      if (isLocked) {
        console.warn('操作正在进行中,请勿重复提交');
        return; // 直接拦截,不执行函数
      }

      setIsLocked(true);
      try {
        const result = await asyncFn(...args);
        return result;
      } finally {
        setIsLocked(false); // 无论成功失败,一定解锁
      }
    },
    [isLocked, asyncFn]
  );

  return [wrappedFn, isLocked] as const;
};

关键设计亮点:

特性 说明
闭包锁 isLockedtrue 时,直接 return,不执行原函数
自动 finally 解锁 即使接口报错、用户中断,也不会卡死
泛型支持 完美透传参数和返回值类型
无副作用 不依赖全局状态,每个调用独立隔离

使用场景全覆盖

场景 1:表单提交

const [submitForm, submitting] = useSubmitLock(api.createPost);

场景 2:发送验证码

const [sendCode, sending] = useSubmitLock(phoneApi.sendSmsCode);
// 按钮文案可结合倒计时:{sending ? '发送中...' : '获取验证码'}

场景 3:删除确认操作

const [confirmDelete, deleting] = useSubmitLock(api.deleteUser);
// 防止用户狂点“确定”导致多次删除

场景 4:组合多个异步操作

const [handlePay, paying] = useSubmitLock(async (orderId) => {
  await api.createPayment(orderId);
  await trackEvent('pay_clicked');
  window.location.href = '/payment';
});

注意事项 & 进阶建议

1. 不要用于需要“取消”的操作

此 Hook 适用于“提交即不可逆”的场景。如果是上传、下载等可取消任务,应使用 AbortController

2. 与防重 Token 不冲突

useSubmitLock前端体验层防护,后端仍需配合 Token 或幂等设计做最终校验。

3. Vue 用户怎么办?

同样可封装为 Composable:

// Vue 3 + Composition API
import { ref } from 'vue';

export function useSubmitLock(asyncFn) {
  const isLocked = ref(false);
  
  const wrappedFn = async (...args) => {
    if (isLocked.value) return;
    isLocked.value = true;
    try {
      return await asyncFn(...args);
    } finally {
      isLocked.value = false;
    }
  };

  return { execute: wrappedFn, isLocked };
}

使用:

const { execute: submit, isLocked } = useSubmitLock(api.submit);

更进一步:自动绑定到按钮?

你可以再封装一个 <SubmitButton> 组件:

const SubmitButton = ({ onClick, children, ...props }) => {
  const [handler, loading] = useSubmitLock(onClick);
  return (
    <button
      disabled={loading}
      onClick={handler}
      {...props}
    >
      {loading ? '处理中...' : children}
    </button>
  );
};

// 使用
<SubmitButton onClick={submitOrder}>提交订单</SubmitButton>

从此,防重提交,零成本集成。


结语

优秀的工程师,不是写更多代码,而是让重复的事不再发生

一个小小的 useSubmitLock,背后是对用户体验的尊重,对代码洁癖的坚持,更是对“DRY 原则”的践行。

下次当你又要写第 101 次 loading = true 时,停下来问问自己:
“这事,能不能一次解决?”

把这个 Hook 加到你的工具库里,团队效率提升 10%。

欢迎收藏、转发,拯救还在手写 loading 的同事!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

前端如何实现“无感刷新”Token?90% 的人都做错了

刷新 Token 不是“过期就重新登录”,而是让用户毫无感知地继续使用
可惜,大多数项目还在用 401 跳登录 粗暴处理——这根本不是用户体验,这是放弃治疗。

在现代 Web 应用中,用户登录后通常会获得一对 Token:

  • Access Token(短期有效,如 15 分钟)
  • Refresh Token(长期有效,如 7 天)

当 Access Token 过期时,理想状态是:前端自动用 Refresh Token 换取新 Token,并重试原请求——整个过程用户无感,页面不跳转、操作不中断。

但现实呢?

“Token 过期 → 弹出登录框 → 用户骂一句‘怎么又登出了’ → 关掉页面走人。”

今天,我们就来彻底搞懂:如何真正实现“无感刷新”Token?为什么 90% 的实现都有致命缺陷?


错误做法一:在每个接口里手动判断 401

// 千万别这么写!
fetch('/api/user')
  .then(res => {
    if (res.status === 401) {
      // 重新登录 or 刷新 token?
      window.location.href = '/login';
    }
  });

问题在哪?

  • 每个接口都要重复写逻辑;
  • 如果多个请求同时 401,会触发多次刷新,甚至多次跳登录;
  • 完全无法做到“无感”

错误做法二:全局拦截 401 后直接刷新 Token 并重试一次

这是目前最“主流”的错误方案:

// 伪代码:看似聪明,实则危险
axios.interceptors.response.use(
  res => res,
  async (error) => {
    if (error.response.status === 401) {
      const newToken = await refreshToken(); // 获取新 token
      saveToken(newToken);
      
      // 用新 token 重试原请求
      return axios(error.config);
    }
  }
);

表面看没问题,但隐藏三大坑:

坑 1:并发请求雪崩

当页面刚加载,10 个接口同时发起,而此时 Token 已过期 ——
→ 10 个请求全部返回 401 → 触发 10 次 refreshToken() → 后端收到 10 个刷新请求!

后果:

  • 后端可能拒绝重复刷新(安全策略);
  • Refresh Token 被提前消耗,后续真失效;
  • 用户反而被踢下线。

坑 2:Refresh Token 泄露风险

如果前端把 Refresh Token 存在 localStorage,一旦 XSS 攻击成功,攻击者可长期盗用账号。

安全最佳实践:Refresh Token 应仅存于 HttpOnly Cookie,前端不可读!

但上述方案要求前端“拿到新 token”,这就逼你把 Refresh Token 暴露给 JS —— 安全与功能不可兼得?

坑 3:无限重试死循环

如果 refreshToken() 本身也返回 401(比如 Refresh Token 也过期了),
→ 重试原请求 → 又 401 → 再刷新 → 再 401 → ……
浏览器卡死,内存飙升。


正确方式:用“锁机制 + 队列 + 安全存储”三位一体

要实现真正的无感刷新,必须同时解决:

  1. 并发控制(只刷一次)
  2. 安全存储(Refresh Token 不暴露给 JS)
  3. 失败兜底(Refresh 失败时优雅降级)

第一步:后端配合 —— Refresh Token 存 HttpOnly Cookie

HTTP/1.1 200 OK
Set-Cookie: refreshToken=abc123; HttpOnly; Secure; SameSite=Strict; Path=/auth

前端永远拿不到 refreshToken,但每次请求会自动携带。

第二步:前端实现“单例刷新锁 + 请求队列”

let isRefreshing = false;
let refreshPromise = null;
const failedQueue = [];

// 重试队列中的请求
const processQueue = (error, token = null) => {
  failedQueue.forEach(({ resolve, reject }) => {
    if (error) {
      reject(error);
    } else {
      resolve(token);
    }
  });
  failedQueue.length = 0;
};

axios.interceptors.response.use(
  response => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // 已在刷新中,将请求加入队列,等待新 token
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then(token => {
          originalRequest.headers['Authorization'] = `Bearer ${token}`;
          return axios(originalRequest);
        });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        // 调用刷新接口(后端从 Cookie 读 refreshToken)
        const { data } = await axios.post('/auth/refresh');
        const newAccessToken = data.accessToken;

        // 通知所有排队的请求
        processQueue(null, newAccessToken);

        // 重试当前请求
        originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
        return axios(originalRequest);
      } catch (refreshError) {
        // 刷新失败:清空本地身份,跳转登录
        clearAuth();
        processQueue(refreshError, null);
        window.location.href = '/login';
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
        refreshPromise = null;
      }
    }

    return Promise.reject(error);
  }
);

关键设计解析:

机制 作用
isRefreshing 确保同一时间只发起一次刷新
failedQueue 队列 缓存所有因 401 失败的请求,等新 token 到手后批量重试
_retry 标记 防止重试后的请求再次进入刷新逻辑
HttpOnly Cookie 保护 Refresh Token 不被 XSS 窃取

安全补充:前端 Token 存储建议

Token 类型 推荐存储方式 原因
Access Token 内存(JS 变量)或 sessionStorage 短期有效,避免持久化泄露
Refresh Token HttpOnly Cookie 前端不可读,防 XSS

切勿将任何 Token 存入 localStorage!这是 XSS 攻击的黄金目标。


如何测试你的刷新逻辑?

  1. 手动将 Access Token 设为过期;
  2. 快速点击多个按钮,触发并发请求;
  3. 观察 Network 面板:
    • 是否只调用了一次 /auth/refresh
    • 所有原请求是否最终成功?
  4. 模拟 Refresh Token 失效,是否跳转登录?

结语

“无感刷新 Token”不是炫技,而是对用户体验和系统安全的基本尊重。
那些让用户频繁重新登录的产品,不是技术做不到,而是没把用户当回事

真正的专业,藏在细节里:
一个锁、一个队列、一个 HttpOnly Cookie —— 就是 10% 正确方案 与 90% 错误实现的分水岭。

你的项目还在用“401 就跳登录”吗?是时候升级了。

欢迎转发给那个总说“Token 过期就让用户重新登录”的同事。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

纯 HTML/CSS/JS 实现的高颜值登录页,还会眨眼睛!少女心爆棚!

演示效果

演示效果

上周,产品经理说:“我们的登录页太冷了,像银行系统。”

我心想:不就是个输入框 + 按钮?能有多冷?

直到我看到数据——用户平均停留 8 秒,跳出率67%。

那一刻我意识到:在体验经济时代,登录页不是入口,而是第一印象。

于是,我花了 2 小时,用纯 HTML/CSS/JS 写了一个“会呼吸”的登录页:

  • 背景是流动的樱花渐变
  • 四个守护精灵会转头看你
  • 眼球能精准追踪鼠标,还会眨眼
  • 输入用户名时,左边两个“保镖”会 Q 弹靠近

上线三天后,用户停留时长涨到22 秒,注册转化率提升 34%。

今天,我就把这份“有温度的代码”开源出来,并告诉你:前端,也可以很浪漫。


一、为什么登录页值得认真做?

很多人觉得:“登录页只是跳板,做完就行。”

但用户心理是这样的:

  • 第一眼看到页面 → 判断产品调性
  • 如果冰冷、机械、无趣 → “这产品大概也不 care 我”
  • 如果温暖、灵动、有细节 → “他们连登录页都这么用心,功能肯定靠谱”

登录页,是你和用户的第一次约会。

而我们的目标,不是“能用”,而是——让用户多看一眼,再看一眼。


二、核心设计:四个“樱花守护者”

整个页面的灵魂,是左侧那四个圆滚滚的“保镖”。
它们不是静态插图,而是有生命的小精灵

  • 配色柔和:浅粉、薰衣草紫、玫瑰粉、奶白,拒绝刺眼荧光
  • 眼神灵动:双眼中带高光,随鼠标移动,幅度明显但不夸张
  • 微交互反馈:聚焦用户名时,左边两位“凑近偷看”;聚焦密码时,右边两位“紧张张望”
  • 呼吸感动画:背景渐变流动 + 装饰云朵飘过 + 腮红微微闪烁

这一切,只用了 300 行原生代码,零框架、零依赖


三、关键技术点拆解(附核心代码)

1. 眼球追踪:让“看”变得真实

很多人做视线追踪,只动头部。但真正打动人的是眼睛

// 鼠标移动时,计算相对位置
const xPercent = (mouseX / windowWidth) - 0.5;
const yPercent = (mouseY / windowHeight) - 0.5;

// 【关键】眼球移动幅度拉大到 12px(原常见实现仅 3–4px)
allEyes.forEach(eye => {
  const moveX = xPercent * 12; // ← 让眼神“明显在追你”
  const moveY = yPercent * 6;
  eye.style.transform = `translate(${moveX}px, ${moveY}px)`;
});

效果:用户一眼就能感知“它在看我”,产生情感连接。

2. 头部微转:增加层次感

头部转动幅度小、方向交替,避免“集体僵尸舞”:

// 不同保镖朝向微调,制造错落感
const rotateY = xPercent * 10 * (index % 2 === 0 ? 1 : -1);
avatar.style.transform = `rotateY(${rotateY}deg) rotateX(${-yPercent * 8}deg)`;

3. 输入聚焦反馈:Q 弹靠近

当用户输入时,对应保镖“凑近关心”:

usernameInput.addEventListener('focus', () => {
  g1.style.transform = 'scale(1.15) rotateY(12deg)';
  g2.style.transform = 'scale(1.15) rotateY(-12deg)';
});

这种“拟人化”反馈,让用户感觉“有人在陪我”。

4. 视觉氛围:流动的樱花宇宙

  • 背景linear-gradient(135deg, #ffd1dc, #e0bbe4, #d291bc) + animation: gradientFlow
  • 装饰:飘动的 ❤、✿、☁,用 opacity: 0.6 + pointer-events: none 避免干扰
  • 字体Pacifico(手写体标题) + Quicksand(圆润正文),瞬间可爱度拉满

四、为什么它有效?背后的心理学

  • 拟人效应(Anthropomorphism):人类天生对“有眼睛”的物体产生信任
  • 微交互反馈:让用户感到“我的操作被看见了”
  • 色彩心理学:粉色系传递安全、温柔、包容的情绪
  • 动效节奏:慢速流动(15s 渐变)+ 快速响应(眼球追踪),张弛有度

这不是“花里胡哨”,而是用设计语言说“欢迎你”


五、完整代码已开源,复制即用!

我把整个页面打包成一个 单 HTML 文件,无需构建、无需依赖,打开即运行。

5 分钟,让你的登录页从“工具”变成“体验”。

<!DOCTYPE html>
<html lang="zh-CN">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Sakura Login | 樱花守护</title>
  <!-- 引入可爱字体 -->
  <link href="https://fonts.googleapis.com/css2?family=Pacifico&family=Quicksand:wght@400;500;600;700&display=swap"
    rel="stylesheet">
  <style>
    :root {
      /* 提取自您提供的 CSS */
      --bg-start: #ffd1dc;
      --bg-mid: #e0bbe4;
      --bg-end: #d291bc;
      --text-main: #5a3d5c;
      --text-dim: #8a6d8b;
      --accent-pink: #ff69b4;
      --accent-light: #ffb6c1;
      --white-glass: rgba(255, 255, 255, 0.85);

      /* 保镖专属柔和色系 */
      --guard-1: #ffcce0;
      /* 浅粉 */
      --guard-2: #e6c2ff;
      /* 浅紫 */
      --guard-3: #ff99ac;
      /* 玫瑰粉 */
      --guard-4: #fff0f5;
      /* 薰衣草白 */
    }

    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      /* 核心背景:樱花渐变 */
      background: linear-gradient(135deg, var(--bg-start), var(--bg-mid), var(--bg-end));
      background-size: 200% 200%;
      animation: gradientFlow 15s ease infinite;

      color: var(--text-main);
      min-height: 100vh;
      display: flex;
      justify-content: center;
      align-items: center;
      font-family: 'Quicksand', sans-serif;
      overflow: hidden;
      position: relative;
    }

    @keyframes gradientFlow {
      0% {
        background-position: 0% 50%;
      }

      50% {
        background-position: 100% 50%;
      }

      100% {
        background-position: 0% 50%;
      }
    }

    /* --- 背景装饰 (提取自您的代码) --- */
    .decoration-container {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      pointer-events: none;
      z-index: 0;
      overflow: hidden;
    }

    .heart,
    .flower,
    .cloud {
      position: absolute;
      opacity: 0.6;
    }

    .heart {
      color: rgba(255, 105, 180, 0.4);
      font-size: 24px;
      animation: float 8s infinite ease-in-out;
    }

    .flower {
      color: rgba(255, 215, 0, 0.4);
      font-size: 28px;
      animation: rotate 20s infinite linear;
    }

    .cloud {
      color: rgba(255, 255, 255, 0.7);
      font-size: 50px;
      animation: drift 30s infinite linear;
    }

    @keyframes float {

      0%,
      100% {
        transform: translateY(0) rotate(0deg);
      }

      50% {
        transform: translateY(-20px) rotate(10deg);
      }
    }

    @keyframes rotate {
      0% {
        transform: rotate(0deg);
      }

      100% {
        transform: rotate(360deg);
      }
    }

    @keyframes drift {
      0% {
        transform: translateX(-100px);
      }

      100% {
        transform: translateX(calc(100vw + 100px));
      }
    }

    /* --- 主体容器 --- */
    .container {
      position: relative;
      z-index: 10;
      display: flex;
      width: 900px;
      max-width: 95%;
      background: var(--white-glass);
      backdrop-filter: blur(15px);
      -webkit-backdrop-filter: blur(15px);
      border: 2px solid rgba(255, 255, 255, 0.6);
      border-radius: 30px;
      box-shadow: 0 15px 35px rgba(90, 61, 92, 0.15);
      overflow: hidden;
    }

    /* 左侧保镖区域 */
    .guards-panel {
      flex: 1.2;
      display: grid;
      grid-template-columns: repeat(2, 1fr);
      grid-template-rows: repeat(2, 1fr);
      padding: 30px;
      gap: 20px;
      background: rgba(255, 255, 255, 0.3);
      border-right: 1px solid rgba(255, 255, 255, 0.5);
      position: relative;
    }

    .guard {
      position: relative;
      display: flex;
      justify-content: center;
      align-items: center;
      perspective: 1000px;
      cursor: pointer;
    }

    .guard-avatar {
      width: 90px;
      height: 90px;
      border-radius: 50%;
      display: flex;
      justify-content: center;
      align-items: center;
      position: relative;
      background: #fff;
      border: 3px solid #fff;
      box-shadow: 0 8px 20px rgba(90, 61, 92, 0.1);
      transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
      overflow: hidden;
      will-change: transform;
    }

    /* 保镖配色 */
    .guard-1 .guard-avatar {
      background: var(--guard-1);
      box-shadow: 0 8px 20px rgba(255, 204, 224, 0.6);
    }

    .guard-2 .guard-avatar {
      background: var(--guard-2);
      box-shadow: 0 8px 20px rgba(230, 194, 255, 0.6);
    }

    .guard-3 .guard-avatar {
      background: var(--guard-3);
      box-shadow: 0 8px 20px rgba(255, 153, 172, 0.6);
    }

    .guard-4 .guard-avatar {
      background: var(--guard-4);
      box-shadow: 0 8px 20px rgba(255, 240, 245, 0.6);
    }

    .guard:hover .guard-avatar {
      transform: scale(1.15) !important;
      z-index: 20;
      box-shadow: 0 12px 30px rgba(255, 105, 180, 0.3);
    }

    /* 机械眼结构 (适配可爱风) */
    .visor {
      width: 65%;
      height: 22%;
      background: rgba(255, 255, 255, 0.5);
      border-radius: 12px;
      position: relative;
      display: flex;
      justify-content: space-around;
      align-items: center;
      padding: 0 4px;
      border: 1px solid rgba(255, 255, 255, 0.8);
      box-shadow: inset 0 2px 4px rgba(90, 61, 92, 0.05);
    }

    .eye {
      width: 12px;
      height: 12px;
      border-radius: 50%;
      background: var(--text-main);
      /* 深紫色眼珠 */
      position: relative;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
      transition: transform 0.1s ease-out;
      will-change: transform;
    }

    /* 眼神高光 */
    .eye::after {
      content: '';
      position: absolute;
      width: 4px;
      height: 4px;
      border-radius: 50%;
      background: #fff;
      top: 20%;
      left: 20%;
      opacity: 0.9;
    }

    /* 腮红/状态灯 */
    .blush {
      position: absolute;
      bottom: 18px;
      width: 8px;
      height: 5px;
      border-radius: 50%;
      background: rgba(255, 105, 180, 0.4);
      filter: blur(1px);
      animation: blinkBlush 3s infinite;
    }

    @keyframes blinkBlush {

      0%,
      100% {
        opacity: 0.4;
      }

      50% {
        opacity: 0.8;
      }
    }

    /* 右侧表单区域 */
    .login-panel {
      flex: 1;
      padding: 40px;
      display: flex;
      flex-direction: column;
      justify-content: center;
      position: relative;
      background: rgba(255, 255, 255, 0.4);
    }

    .login-header {
      text-align: center;
      margin-bottom: 30px;
    }

    .login-header h2 {
      font-family: 'Pacifico', cursive;
      font-size: 38px;
      font-weight: 400;
      margin-bottom: 8px;
      background: linear-gradient(90deg, var(--accent-pink), var(--bg-end));
      -webkit-background-clip: text;
      background-clip: text;
      color: transparent;
      letter-spacing: 1px;
      text-shadow: 0 2px 10px rgba(255, 105, 180, 0.2);
    }

    .login-header p {
      font-size: 15px;
      color: var(--text-dim);
      line-height: 1.5;
    }

    .form-group {
      margin-bottom: 20px;
      position: relative;
    }

    .form-group label {
      display: block;
      color: var(--text-main);
      font-size: 13px;
      margin-bottom: 8px;
      font-weight: 600;
      letter-spacing: 0.5px;
      margin-left: 5px;
    }

    .form-group input {
      width: 100%;
      padding: 14px 18px;
      background: rgba(255, 255, 255, 0.7);
      border: 2px solid #ffd1dc;
      border-radius: 15px;
      color: var(--text-main);
      font-size: 15px;
      outline: none;
      transition: all 0.3s;
      font-family: 'Quicksand', sans-serif;
    }

    .form-group input:focus {
      background: #fff;
      border-color: var(--accent-pink);
      box-shadow: 0 0 0 4px rgba(255, 105, 180, 0.15);
      transform: translateY(-2px);
    }

    .form-group input::placeholder {
      color: #c49bb8;
    }

    .actions {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 25px;
      font-size: 13px;
      color: var(--text-dim);
      padding: 0 5px;
    }

    .actions label {
      display: flex;
      align-items: center;
      cursor: pointer;
      color: var(--text-dim);
    }

    .actions input[type="checkbox"] {
      margin-right: 6px;
      accent-color: var(--accent-pink);
      cursor: pointer;
      width: 16px;
      height: 16px;
    }

    .actions a {
      color: var(--accent-pink);
      text-decoration: none;
      font-weight: 600;
      transition: color 0.3s;
    }

    .actions a:hover {
      color: var(--bg-end);
      text-decoration: underline;
    }

    button {
      width: 100%;
      padding: 16px;
      background: linear-gradient(90deg, var(--accent-pink), var(--bg-end));
      color: white;
      border: none;
      border-radius: 18px;
      font-weight: 700;
      font-size: 18px;
      cursor: pointer;
      position: relative;
      overflow: hidden;
      transition: all 0.3s;
      box-shadow: 0 8px 20px rgba(255, 105, 180, 0.4);
      letter-spacing: 1px;
      font-family: 'Quicksand', sans-serif;
    }

    button:hover {
      transform: translateY(-3px);
      box-shadow: 0 12px 25px rgba(255, 105, 180, 0.5);
      filter: brightness(1.05);
    }

    button:active {
      transform: translateY(1px);
    }

    button::after {
      content: '';
      position: absolute;
      top: 0;
      left: -100%;
      width: 100%;
      height: 100%;
      background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
      transition: 0.5s;
    }

    button:hover::after {
      left: 100%;
    }

    /* 响应式 */
    @media (max-width: 768px) {
      .container {
        flex-direction: column;
        width: 90%;
      }

      .guards-panel {
        grid-template-columns: repeat(4, 1fr);
        padding: 20px;
        border-right: none;
        border-bottom: 1px solid rgba(255, 255, 255, 0.5);
      }

      .guard-avatar {
        width: 60px;
        height: 60px;
      }

      .visor {
        width: 60%;
        height: 20%;
      }

      .eye {
        width: 8px;
        height: 8px;
      }

      .login-panel {
        padding: 30px;
      }

      .login-header h2 {
        font-size: 32px;
      }
    }
  </style>
</head>

<body>

  <!-- 背景装饰 -->
  <div class="decoration-container">
    <!-- 动态生成一些装饰物 -->
    <div class="heart" style="top: 10%; left: 10%;"></div>
    <div class="heart" style="top: 20%; right: 15%; animation-delay: -2s;"></div>
    <div class="flower" style="top: 60%; left: 5%; animation-delay: -5s;"></div>
    <div class="flower" style="bottom: 15%; right: 10%;"></div>
    <div class="cloud" style="top: 5%; left: -10%;"></div>
    <div class="cloud" style="top: 40%; right: -5%; animation-delay: -15s;"></div>
  </div>

  <div class="container">
    <!-- 左侧:四个樱花守护精灵 -->
    <div class="guards-panel">
      <div class="guard guard-1" id="g1">
        <div class="guard-avatar">
          <div class="visor">
            <div class="eye" id="e1-1"></div>
            <div class="eye" id="e1-2"></div>
          </div>
          <div class="blush"></div>
        </div>
      </div>
      <div class="guard guard-2" id="g2">
        <div class="guard-avatar">
          <div class="visor">
            <div class="eye" id="e2-1"></div>
            <div class="eye" id="e2-2"></div>
          </div>
          <div class="blush"></div>
        </div>
      </div>
      <div class="guard guard-3" id="g3">
        <div class="guard-avatar">
          <div class="visor">
            <div class="eye" id="e3-1"></div>
            <div class="eye" id="e3-2"></div>
          </div>
          <div class="blush"></div>
        </div>
      </div>
      <div class="guard guard-4" id="g4">
        <div class="guard-avatar">
          <div class="visor">
            <div class="eye" id="e4-1"></div>
            <div class="eye" id="e4-2"></div>
          </div>
          <div class="blush"></div>
        </div>
      </div>
    </div>

    <!-- 右侧:登录表单 -->
    <div class="login-panel">
      <div class="login-header">
        <h2>Welcome Love</h2>
        <p>请输入您的信息,开启梦幻之旅</p>
      </div>

      <form onsubmit="event.preventDefault();">
        <div class="form-group">
          <label for="username">用户名</label>
          <input type="text" id="username" placeholder="Your Name" autocomplete="off">
        </div>

        <div class="form-group">
          <label for="password">密码</label>
          <input type="password" id="password" placeholder="••••••••" autocomplete="off">
        </div>

        <div class="actions">
          <label>
            <input type="checkbox"> 记住我
          </label>
          <a href="#">忘记密码?</a>
        </div>

        <button type="submit">立即登录</button>
      </form>
    </div>
  </div>

  <script>
    const guards = document.querySelectorAll('.guard');
    const allEyes = document.querySelectorAll('.eye');
    const usernameInput = document.getElementById('username');
    const passwordInput = document.getElementById('password');

    // --- 增强的视线追踪逻辑 ---
    document.addEventListener('mousemove', (e) => {
      const mouseX = e.clientX;
      const mouseY = e.clientY;
      const windowWidth = window.innerWidth;
      const windowHeight = window.innerHeight;

      const xPercent = (mouseX / windowWidth) - 0.5;
      const yPercent = (mouseY / windowHeight) - 0.5;

      guards.forEach((guard, index) => {
        const avatar = guard.querySelector('.guard-avatar');

        // 头部转动保持不变 (柔和)
        const rotateY = xPercent * 10 * (index % 2 === 0 ? 1 : -1);
        const rotateX = -yPercent * 8;

        avatar.style.transform = `rotateY(${rotateY}deg) rotateX(${rotateX}deg)`;
      });

      // 【修改点】眼球移动幅度大幅增加:从 4px 改为 12px
      // 现在左右移动非常明显,能一眼看出眼神在跟随
      allEyes.forEach(eye => {
        const moveX = xPercent * 12;  // 之前是 4,现在是 12
        const moveY = yPercent * 6;   // 上下也稍微增加一点,保持自然比例
        eye.style.transform = `translate(${moveX}px, ${moveY}px)`;
      });
    });

    // 输入框焦点交互 (Q弹可爱效果)
    usernameInput.addEventListener('focus', () => {
      const g1 = document.getElementById('g1').querySelector('.guard-avatar');
      const g2 = document.getElementById('g2').querySelector('.guard-avatar');
      g1.style.transform = 'scale(1.15) rotateY(12deg)';
      g2.style.transform = 'scale(1.15) rotateY(-12deg)';
    });

    usernameInput.addEventListener('blur', () => {
      document.getElementById('g1').querySelector('.guard-avatar').style.transform = '';
      document.getElementById('g2').querySelector('.guard-avatar').style.transform = '';
    });

    passwordInput.addEventListener('focus', () => {
      const g3 = document.getElementById('g3').querySelector('.guard-avatar');
      const g4 = document.getElementById('g4').querySelector('.guard-avatar');
      g3.style.transform = 'scale(1.15) rotateY(12deg)';
      g4.style.transform = 'scale(1.15) rotateY(-12deg)';
    });

    passwordInput.addEventListener('blur', () => {
      document.getElementById('g3').querySelector('.guard-avatar').style.transform = '';
      document.getElementById('g4').querySelector('.guard-avatar').style.transform = '';
    });
  </script>
</body>

</html>

结语:前端,不止于逻辑

我们总在讨论性能、架构、工程化,
却忘了——代码也可以传递情感

一个会眨眼的保镖,
一段流动的樱花背景,
一句“Welcome Love”的问候,

可能比十个埋点、百行优化,更能留住一个人。

今天,就给你的登录页,加一点温度吧。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

❌