阅读视图

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

介绍一个手势识别库——AlloyFinger

移动端触摸手势库 AlloyFinger,配上 Vue 的 v-finger 指令,让「点、滑、捏、转」都能用声明式写法搞定,一起看看吧。


一、为什么需要 AlloyFinger?

在 H5 里,原生 touchstart / touchmove / touchend 只能告诉你「手指动了」,至于用户是单击、双击、长按、滑动、双指缩放还是旋转,都要自己算时间差、距离、角度——既难写又容易出 bug。

AlloyFinger 是腾讯 AlloyTeam 开源的轻量级手势库,把这些常见手势都封装好了,并且提供了 Vue 插件,以自定义指令 v-finger 的形式在模板里绑定,写法清晰、易维护。


二、安装依赖

在项目根目录执行:

npm install alloyfinger

三、在入口文件中注册插件

Vue 入口文件(如 src/main.js)中做两件事:

  1. 引入 AlloyFinger 本体和其 Vue 插件;
  2. 使用 Vue.use(AlloyFingerPlugin, { AlloyFinger }) 注册。

这样全局就可以在任意组件的模板里使用 v-finger 指令。

// 引入 alloy-finger
import AlloyFinger from 'alloyfinger'
import AlloyFingerPlugin from 'alloyfinger/vue/alloy_finger_vue'
Vue.use(AlloyFingerPlugin, {
  AlloyFinger
})

注意:

  • 插件路径是 alloyfinger/vue/alloy_finger_vue
  • 必须把 AlloyFinger 通过 Vue.use 的第二个参数传进去,插件内部会用它来创建手势实例。

四、在模板里使用 v-finger

注册完成后,在任意 Vue 组件的模板中,给需要绑定手势的单个根元素写上 v-finger:事件名="方法名" 即可。

4.1 语法形式

<div
  v-finger:tap="onTap"
  v-finger:swipe="onSwipe"
  v-finger:long-tap="onLongTap"
>
  可触摸区域
</div>
  • 指令名v-finger
  • 修饰符:冒号后面是事件类型,如 tapswipelong-tappinchrotate 等。
  • :当前 Vue 实例上的方法名,与普通 @click 一样写在 methods 里即可。

4.2 支持的事件

事件名 说明
tap 单击
double-tap 双击
single-tap 单击(与 double-tap 区分时用)
long-tap 长按
swipe 滑动手势(可结合 evt.direction)
pinch 双指缩放(evt.zoom)
rotate 双指旋转(evt.angle)
press-move 按住拖动(evt.deltaX / deltaY)
multipoint-start 多指开始
multipoint-end 多指结束
touch-start / touch-move / touch-end / touch-cancel 原生触摸事件封装

需要传参时,在方法里接收事件对象即可(如 swipe(evt) 中的 evt.directionpinch(evt) 中的 evt.zoom)。

4.3 完整示例

模板:

<template>
  <div
    class="touch-area"
    v-finger:tap="tap"
    v-finger:long-tap="longTap"
    v-finger:swipe="swipe"
    v-finger:pinch="pinch"
    v-finger:rotate="rotate"
    v-finger:double-tap="doubleTap"
    v-finger:single-tap="singleTap"
  >
    <div>点我、长按、滑动或双指操作</div>
  </div>
</template>

脚本:

export default {
  methods: {
    tap() {
      console.log('单击')
    },
    longTap() {
      console.log('长按')
    },
    swipe(evt) {
      console.log('滑动方向:', evt.direction)
    },
    pinch(evt) {
      console.log('缩放比例:', evt.zoom)
    },
    rotate(evt) {
      console.log('旋转角度:', evt.angle)
    },
    doubleTap() {
      console.log('双击')
    },
    singleTap() {
      console.log('单击(与双击区分)')
    }
  }
}

按需绑定自己用到的几个事件即可,不必全部写上。


五、用法很简单,那AlloyFinger是怎么实现的呢?

了解实现原理,有助于我们更放心地使用、排查问题,甚至做简单扩展。
AlloyFinger 的实现可以拆成两层:底层手势识别(alloy_finger.js)Vue 指令封装(alloy_finger_vue.js)

5.1 底层:基于原生 Touch 事件 + 向量运算

AlloyFinger 不依赖任何框架,核心就是给一个 DOM 元素绑定四个原生事件:

this.element.addEventListener("touchstart", this.start, false);
this.element.addEventListener("touchmove", this.move, false);
this.element.addEventListener("touchend", this.end, false);
this.element.addEventListener("touchcancel", this.cancel, false);

start 里:

  • 记录第一个触点的坐标 (x1, y1)和当前时间戳;
  • 用「上次 tap 的时间」和「两次点击的位移」判断是否构成双击(例如 250ms 内、位移 30px 以内);
  • 若检测到多指(evt.touches.length > 1),则计算两指构成的向量长度,作为后续 pinch 缩放的基准,并触发 multipointStart
  • 同时启动一个 750ms 的定时器,到时即触发 longTap

move 里:

  • 若是单指,则用当前点与上一帧点的差值得到 deltaXdeltaY,触发 pressMove
  • 若移动距离超过约 10px,会置位 _preventTap,避免误触 tap
  • 若是双指,则用两指构成的向量做向量长度比得到 evt.zoom(pinch),用向量夹角得到 evt.angle(rotate),这里用到简单的向量数学(点积、叉积、夹角),核心逻辑类似:
// 向量长度
function getLen(v) {
  return Math.sqrt(v.x * v.x + v.y * v.y);
}
// 缩放:当前两指距离 / 起始两指距离
evt.zoom = getLen(v) / this.pinchStartLen;
// 旋转:当前向量相对上一帧向量的角度
evt.angle = getRotateAngle(v, preV);

end 里:

  • 若「起点到终点的位移」超过约 30px,则根据 x、y 方向位移谁更大来判定 swipe 方向(Left/Right/Up/Down),并触发 swipe
  • 否则在下一个「事件循环」里触发 tap,并根据之前的双击标记决定是否再触发 doubleTap 或延迟 250ms 触发 singleTap
  • 同时会清除 longTap 定时器、重置双指相关的状态。

也就是说:tap / longTap / doubleTap / swipe / pinch / rotate / pressMove 等,都是在同一套 touch 生命周期里,用「时间差 + 位移 + 向量运算」推导出来的,没有黑魔法。

5.2 回调管理:HandlerAdmin

每种手势对应一个「回调列表」,用 HandlerAdmin 统一管理:add 注册、del 移除、dispatch 时对该元素上的所有回调依次 apply。这样同一个元素上可以挂多个监听(例如 Vue 插件里对同一元素绑定多个 v-finger:xxx),彼此也不会互相覆盖。

5.3 Vue 插件层:v-finger 如何挂到 DOM 上

插件在 install 时执行 Vue.directive('finger', directiveOpts),因此模板里的 v-finger 会变成对自定义指令 finger 的调用。

  • 事件名映射:模板里写的是 kebab-case(如 v-finger:long-tap),插件里用 EVENTMAP 转成 AlloyFinger 的 camelCase(如 longTap),再交给底层。
  • 一元素一实例:用一个全局 CACHE 数组,按 DOM 元素存 { elem, alloyFinger }。同一元素上多条 v-finger:tapv-finger:swipe 等,共用一个 AlloyFinger 实例;第一次绑定时 new AlloyFinger(elem, options),之后同元素再绑其他事件时,不再 new,而是 alloyFinger.on(eventName, func) 往该实例上追加回调。
  • 指令生命周期:Vue2 下 bind / update 时执行 doBindEvent(绑定或更新回调),unbind 时从 CACHE 里取出实例并调用 alloyFinger.destroy(),移除原生事件监听和所有定时器,避免内存泄漏。

核心片段:

// 同一元素多次 v-finger:xxx 共用一个 AlloyFinger 实例
var cacheObj = CACHE[getElemCacheIndex(elem)];
if (cacheObj && cacheObj.alloyFinger) {
  if (oldFunc) cacheObj.alloyFinger.off(eventName, oldFunc);
  if (func) cacheObj.alloyFinger.on(eventName, func);
} else {
  CACHE.push({
    elem: elem,
    alloyFinger: new AlloyFinger(elem, { [eventName]: func })
  });
}

5.4 小结

  • 手势识别:完全基于 touchstart / touchmove / touchend,用时间、位移和向量运算区分 tap、doubleTap、longTap、swipe、pinch、rotate、pressMove 等。
  • Vue 层:通过自定义指令 v-finger 和元素级 AlloyFinger 实例缓存,把「模板里的 v-finger:事件名」映射到「底层 AlloyFinger 的 on/off」,实现声明式绑定与组件销毁时的清理。

参考

VTJ.PRO 双向代码转换原理揭秘

在低代码平台层出不穷的今天,如何平衡可视化开发的便利性与代码的灵活性、可控性,一直是行业难题。VTJ.PRO 作为一个面向 Vue 3 开发者的 AI 驱动开发平台,给出了一个独特的答案:双向代码转换。它不仅支持从 Vue 源码到低代码 DSL 的“向上”转换,也支持从 DSL 到标准 Vue 源码的“向下”生成,并且两个方向可以反复进行,实现了真正意义上的“代码双向自由”。

本文将深入剖析 VTJ.PRO 双向代码转换系统的核心原理,揭开其如何实现 Vue SFC(单文件组件)与平台内部 DSL 之间无损、可逆转换的技术面纱。

1. 双向转换系统架构总览

VTJ.PRO 的代码转换系统由两大核心模块构成:

  • Parser(解析器):将 Vue SFC 源码解析为平台内部的 BlockSchema DSL 对象。
  • Generator(生成器):将 BlockSchema DSL 对象重新生成为标准 Vue SFC 源码。

这两个模块共同构成了一个闭环,使得开发者可以在“源码编辑”与“可视化设计”两种模式间无缝切换,且任意一方的修改都能被另一方完整理解和承载。

整体工作流程如下图所示:

flowchart TD
    A[Vue SFC 源码] -->|输入| B[Parser 解析器]
    B -->|输出| C[BlockSchema DSL]
    C -->|输入| D[Generator 生成器]
    D -->|输出| E[Vue SFC 源码]

    B -.->|验证/修复| A
    D -.->|格式化/平台适配| E

2. 解析器:从 Vue SFC 到 DSL

解析器的入口是 parseVue 函数,它接收 Vue 源码,经过多阶段处理,最终输出一个结构化的 BlockSchema 对象。整个过程可以分为:输入验证与自动修复SFC 拆分脚本解析模板解析上下文跟踪与代码修补五个主要阶段。

2.1 输入验证与自动修复

在解析之前,系统会使用 ComponentValidator 对源码进行质量检查,确保其符合平台的预期格式。验证规则包括:

  • SFC 结构完整性:必须包含 <template><script><style> 块。
  • JavaScript 语法正确性:使用 Babel 检查脚本部分是否有语法错误。
  • setup 函数格式setup() 必须恰好包含三句代码(provider 初始化、state 声明、return)。
  • 图标名称合法性:检查 Vant 和 VTJ 图标库的图标名是否在白名单内。

如果检测到可自动修复的问题(如非法的图标名、模板中缺少 state. 前缀),AutoFixer 会介入修正。例如,checkAndFixStatePrefix 函数会遍历模板中的插值、绑定、指令,自动为响应式变量添加 state. 前缀:

// 修复前
<div>{{ username }}</div>
<button @click="count++">Click</button>

// 修复后
<div>{{ state.username }}</div>
<button @click="state.count++">Click</button>

2.2 SFC 解析

通过 Vue 官方编译器将源码拆分为 <template><script><style> 三部分。parseSFC 函数会优先识别 <script setup>,并收集所有样式块(支持多 <style>)。

2.3 脚本解析:Babel 提取

parseScripts 函数利用 Babel 对脚本代码进行 AST 遍历,提取组件逻辑元数据。关键提取点包括:

  • 状态(State):识别 const state = reactive({...}) 语句,提取初始状态对象。
  • 方法(Methods):收集 methods 对象中的函数。
  • 事件处理器(Event Handlers):方法名若匹配特定后缀模式(如 click_abc123),会被归类为事件处理器,并生成唯一 ID。
  • 计算属性(Computed):提取 computed 对象中的函数。
  • 侦听器(Watchers):方法名以 watcher_ 开头则视为侦听器源。
  • 数据源(Data Sources):识别调用 provider.apiscreateMock 的方法,并解析其 transform 逻辑。
  • 生命周期(LifeCycles):提取 mountedcreated 等方法。

这些提取出的信息将分别存入 BlockSchemastatemethodscomputedwatch 等字段。

2.4 模板解析:AST 转换

模板解析是核心中的核心,parseTemplate 函数将 Vue 模板 AST 转换为平台内部的 NodeSchema 节点树。转换过程中,每个 AST 节点都会调用 transformNode,生成对应的 NodeSchema 对象,并递归处理子节点。

关键转换规则:

  • 属性(Props):静态属性直接转为键值对;动态绑定(v-bind)转换为 JSExpression 类型;同时处理 class/style 的合并。
  • 事件(Events)v-on 指令转换为 events 对象,事件表达式会被包装成函数,并与脚本中提取的事件处理器 ID 关联。
  • 指令(Directives)v-ifv-forv-modelv-show 等都被提取为 directives 数组,保留其表达式和参数。
  • 插槽(Slots):识别 <template #slotName> 和组件上的 v-slot,生成 slot 元数据。

模板解析流程图如下:

flowchart TD
    A[模板源码] -->|Vue Compiler| B[AST]
    B --> C[transformNode 递归转换]
    C --> D{节点类型}
    D -->|元素节点| E[getProps 提取属性]
    D -->|元素节点| F[getEvents 提取事件]
    D -->|元素节点| G[getDirectives 提取指令]
    D -->|文本节点| H[生成文本节点]
    E --> I[创建NodeSchema]
    F --> I
    G --> I
    H --> I
    I --> J[递归处理子节点]
    J --> K[输出NodeSchema树]

2.5 上下文跟踪与代码修补

在模板中,变量可能来自多个作用域:组件状态(state)、计算属性(computed)、v-for 循环变量、插槽作用域变量等。为了保证在运行时能正确访问这些变量,解析器必须记录每个节点的上下文

pickContext 函数在遍历 AST 时动态维护一个上下文映射:遇到 v-for 时,将迭代变量(如 item, index)加入当前上下文;遇到具名插槽时,将插槽参数加入子节点上下文。

随后,系统调用 patchCode 对所有 JavaScript 表达式(如 JSExpressionJSFunction)进行上下文注入。注入的核心是 replacer 函数,它通过一个状态机逐字符扫描表达式,智能地决定哪些标识符需要添加前缀(如 this.context.this.)。判断规则包括:

  • 字符串字面量内:不替换。
  • 对象属性访问.key 形式不替换,[key] 形式替换。
  • 变量声明:不替换。
  • 函数参数:不替换。
  • 展开运算符...key 替换。
  • 正则表达式内:不替换。

这种精细的替换策略确保了修补后的代码既能正确引用上下文,又不会破坏原有的语法结构。

2.6 输出 BlockSchema

经过上述所有阶段,解析器最终组装出一个完整的 BlockSchema 对象。该对象包含了组件的所有信息:ID、名称、状态、方法、计算属性、侦听器、数据源、生命周期、节点树以及 CSS 样式。这个 DSL 对象可以被可视化设计器直接消费,也可以存入数据库或文件。

3. 代码生成器:从 DSL 到 Vue SFC

代码生成器是解析器的逆过程,其核心函数 generator() 接收 BlockSchema 对象,输出格式化的 Vue SFC 源码。生成过程分为模板生成脚本生成样式生成格式化四个阶段,并支持多平台适配。

3.1 生成器架构

flowchart TD
    A[BlockSchema] --> B[模板生成]
    A --> C[脚本生成]
    A --> D[样式生成]
    B --> E[组合SFC]
    C --> E
    D --> E
    E --> F[Prettier格式化]
    F --> G[平台适配转换]
    G --> H[最终Vue源码]

3.2 模板生成

模板生成器遍历 BlockSchema.nodes 树,为每个 NodeSchema 节点生成对应的 Vue 模板标签。生成规则如下:

  • 标签名:根据节点 namefrom(组件来源)决定标签名。
  • 静态属性:直接输出 key="value"
  • 动态属性v-bind:key="表达式":key="表达式"
  • 事件v-on:click="handler"@click="handler"
  • 指令:将 directives 数组还原为 v-ifv-forv-model 等指令。
  • 插槽:为带有 slot 元数据的节点生成 <template #slotName> 包裹。

特别地,v-for 指令需要根据其 iterator 结构还原出 (item, index) in list 的语法。

3.3 脚本生成

脚本生成的目标是输出一个符合 Vue 3 选项式 API 或组合式 API 的 <script> 块。VTJ.PRO 默认采用组合式 API 风格,但最终输出会根据配置选择。

脚本生成的步骤包括:

  1. 导入语句生成:根据组件使用的物料(UI 库、自定义组件)生成 import 语句,并处理平台依赖(如 @element-plus/icons-vue 可能被映射为 @vtj/icons)。
  2. setup 函数构造
    • 调用 useProvider 初始化 provider。
    • 声明 reactivestate 对象。
    • 定义计算属性、方法、侦听器、生命周期函数。
    • 返回需要暴露给模板的变量(statepropsprovider 等)。
  3. 方法体生成methodscomputedwatch 等字段中的 JSFunction 对象会被还原为函数代码,并经过 patchCode 的逆过程(移除上下文前缀)吗?实际上,生成器不再需要逆向 patch,因为 DSL 中的表达式已经是经过上下文修补的,生成器只需直接输出这些表达式即可,但在输出前会确保它们符合 Vue 运行时的要求(例如,模板中访问 state.xxx 是合法的,而在 methods 中可能需要通过 this.state.xxx 访问,这取决于最终代码的结构)。生成器会依据上下文适当调整引用方式。

3.4 样式生成

样式生成最简单:直接将 BlockSchema.css 字符串插入 <style scoped> 块中。若存在多个样式块,则会合并或分别输出。

3.5 格式化与平台适配

所有生成的代码都会通过 Prettier 进行格式化,确保缩进、引号、分号等风格一致。VTJ.PRO 内置了 vueFormattertsFormatterhtmlFormattercssFormatter,分别处理不同类型的代码块。

最后,根据目标平台(webh5uniapp)对标签和依赖进行适配转换。例如,在 UniApp 平台下,<div> 会被转换为 <view><span> 转换为 <text>,并且只导入支持该平台的依赖包。

4. 关键数据结构与设计哲学

理解双向转换,必须掌握几个核心数据结构:

  • BlockSchema:整个组件的 DSL 表示,包含元数据、逻辑、节点树和样式。
  • NodeSchema:单个节点的 DSL 表示,包含标签名、属性、事件、指令、子节点等。
  • JSExpression / JSFunction:包裹 JavaScript 表达式的类型,带有 typevalue 字段,便于序列化和解析。

VTJ.PRO 的双向转换设计遵循以下哲学:

  • 无平台锁定:生成的是标准 Vue 源码,开发者可以随时脱离平台手工修改,修改后的代码仍可被平台重新解析利用。
  • 可逆性parseVuegenVueCode 构成一对可逆操作,多次转换后语义保持不变(通过测试用例保证)。
  • 开发者友好:所有转换都尽可能保留原代码的格式和注释,生成的代码可读性强,符合开发者的编码习惯。

5. 总结与展望

VTJ.PRO 的双向代码转换系统,通过在抽象语法树层面的精细操作,实现了低代码 DSL 与标准 Vue 源码之间的双向映射。它不仅为可视化设计器提供了数据基础,也确保了开发者随时可以“下车”手写代码,享受完整的开发自由度。

未来,随着 AI 能力的进一步集成(如通过自然语言生成代码片段),这种双向转换能力将成为连接人类开发者与 AI 助手的桥梁,让软件开发进入“随心所欲、不逾矩”的新时代。


参考文档

  • VTJ.PRO 源码仓库:gitee.com/newgateway/…
  • 《Code Transformation System》
  • 《Parser: Vue SFC to DSL》
  • 《Code Generator: DSL to Vue》

高效的数据解构:用 toRefs 和 toRef 保持响应性

前言

在 Vue3 的开发中,解构赋值是比较常用的语法特性。它能让代码更简洁,变量命名更自由。但当解构遇到 reactive 响应式数据时,一个常见的陷阱就出现了:解构后的变量失去了响应性

为什么会这样?如何既享受解构的便利,又保持数据的响应性?本文将深入探讨 toRefstoRef 这两个 API 的工作原理和使用技巧,帮你彻底解决解构带来的响应式丢失问题。

解构的诱惑与陷阱

为什么我们喜欢解构赋值?

解构赋值是 ES6 带来的语法糖,它让代码变得更加简洁优雅:

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

// 没有解构之前,只能属性调用
console.log(user.name)
console.log(user.age)

// 有解构之后
const { name, age } = user
console.log(name)
console.log(age)

解构的优势

  • 按需引入:只取需要的属性
  • 命名自由:可以重命名变量
  • 代码简洁:减少重复的前缀

解构带来的问题

当我们对 reactive 响应式对象进行解构时,会丢失响应式。

这部分的内容,在上一篇文章《响应式探秘:ref vs reactive,我该选谁?》中有详细讲解,本文不再赘述!

toRefs 的魔法

原理:将 reactive 对象的每个属性都转换为 ref

toRefs 的出现正是为了解决 reactive 的解构问题。它的工作原理是:遍历 reactive 对象的所有属性,为每个属性都单独创建一个 ref,这些 ref 会保持与原对象的响应式连接:

// 简化的 toRefs 实现
function toRefs(obj) {
  const result = {}
  
  for (const key in obj) {
    // 为每个属性创建 ref
    result[key] = {
      __v_isRef: true,
      get value() {
        return obj[key]  // 读取时访问原对象
      },
      set value(newVal) {
        obj[key] = newVal // 设置时修改原对象
      }
    }
  }
  return result
}

// 使用
const user = reactive({
  name: '张三',
  age: 18
})

const refs = toRefs(user)

user 使用 toRefs 转换后,其结构是这样的:

// toRefs转换后的结构
{
  name: RefImpl { ... },
  age: RefImpl { ... }
}

有了这个结构之后,我们就可以放心、安全地解构了:

const { name, age } = refs
name.value = '李四' // 会触发 user.name 的更新
age.value++        // 会触发 user.age 的更新

使用场景:从组合式函数返回多个值时

toRefs 最常见的应用场景就是当组合式函数中返回多个响应式值时,进行处理:

import { reactive, toRefs } from 'vue'

export function useUser() {
  const state = reactive({
    user: null,
    loading: false,
    error: null,
    permissions: []
  })

  async function fetchUser(id) {
    state.loading = true
    try {
      state.user = await api.getUser(id)
      state.permissions = await api.getPermissions(id)
      state.error = null
    } catch (e) {
      state.error = e
    } finally {
      state.loading = false
    }
  }

  function updateUser(data) {
    Object.assign(state.user, data)
  }

  // ✅ 返回时使用 toRefs,让使用者可以解构
  return {
    ...toRefs(state),
    fetchUser,
    updateUser
  }
}

注意事项:响应式连接是双向的

我们一定要注意:toRefs 创建的是响应式连接是双向的,它并不是复制了一份数据,而是指向原对象属性的引用。这也是一个很常见的开发误区。

const original = reactive({
  name: '张三',
  age: 18
})

const { name, age } = toRefs(original)

// 修改 ref 会影响原对象
name.value = '李四'
console.log(original.name) // '李四'

// 修改原对象会影响 ref
original.age = 20
console.log(age.value) // 20

// 这种连接是持久的
original.name = '王五'
console.log(name.value) // '王五'

// 即使重新赋值原对象的属性,连接依然保持
original.name = '赵六'
console.log(name.value) // '赵六'

toRef 的精简用法

场景:只想处理 reactive 对象中的某一个属性

使用 toRefs 会把 reactive 对象中的所有属性都转换成 ref;但有时候我们只需要处理 reactive 对象中的某些属性,这时使用 toRef 会更加精准。toRef 是用于将 reactive 对象的指定的属性转成 ref,一次只能转换一个属性。在 toRefs 源码实现中,其本质就是通过遍历对象的属性,再通过 toRef 逐个转换。

import { reactive, toRef } from 'vue'

const state = reactive({
  count: 0,
  name: '张三',
  age: 18,
  email: 'zhang@example.com',
  // ... 可能还有很多其他属性
})

// 只关心 count 属性
const countRef = toRef(state, 'count')

// 现在可以像使用 ref 一样使用 countRef
countRef.value++ // 修改 state.count
console.log(state.count) // 1

// 修改原对象也会影响 countRef
state.count = 10
console.log(countRef.value) // 10

优势:性能更好,只创建一个 ref

相比 toRefs 会为所有属性创建 reftoRef 只创建需要属性的 ref,性能开销更小。

toRef 的另一个妙用:创建可选的响应式引用

toRef 还有个好处,可以用来处理可能不存在的属性:

const state = reactive({
  user: {
    name: '张三'
  }
})

当前 user 只存在 name 属性,如果我们直接给它添加一个新属性会怎么样呢?

state.user.profile.gender = '男'

上述代码毫无疑问会报错:Cannot set properties of undefined (setting 'gender')。但通过 toRef 我们可以安全赋值:

// 即使 profile 不存在,也能创建响应式引用
const profile = toRef(state.user, 'profile')

// 可以安全地赋值
profile.value = { gender : '男' }

性能考量

toRefs 的性能开销

toRefs 会遍历对象的所有属性,为每个属性创建一个 ref 对象。对于大型对象来说,这确实会有一定的性能开销。性能开销主要来源于以下几点:

  • 遍历开销:需要遍历所有属性
  • 内存开销:每个 ref 都是一个对象,占用内存
  • 响应式连接:每个 ref 都需要建立响应式连接

因此基于性能考虑,我们应该遵循按需使用的原则,只有在需要的时候才使用 toRefs

何时不该使用 toRefs

有些场景下,使用 toRefs 也确实可能不是最佳选择:

场景1:性能敏感的高频操作

这就是上述提到的性能开销问题。

场景2:对象在组件内部使用,不需要暴露给外部

function internalFeature() {
  const internalState = reactive({ ... })
  
  // 不需要 toRefs,直接在内部使用 state
  function doSomething() {
    internalState.prop = value
  }
  
  return {
    doSomething
  }
}

场景3:返回整个对象

function useConfig() {
  const config = reactive({
    theme: 'dark',
    language: 'zh',
    features: {...}
  })
  
  // 如果使用者很少需要解构,直接返回 reactive 更好
  return {
    config,
    updateConfig
  }
}

结语

toRefstoRef 解决了在享受解构便利的同时,又不失去 Vue 响应式系统的强大能力。理解并善用它们,我们的代码将既简洁又可靠!

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

Vue生态精选篇:Element Plus 的“企业后台常用组件”用法扫盲

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、选型与定位

  • Element Plus:面向 Vue 3 + TypeScript 的 UI 组件库,适合管理后台、中台、后台系统。
  • 为什么用组件库而不是手写? 统一规范、减少重复开发、内置表单校验、表格、弹窗等常见能力。
  • 本文涉及组件:Form、Table、Dialog、Message/MessageBox、Upload。

二、表单 Form:数据收集与校验

2.1 核心概念

Form 的作用:收集、校验、提交 数据,包含输入框、选择器、日期等。

表单的三层结构:

  1. el-form:表单容器,绑定数据和校验规则
  2. el-form-item:单个表单项,承载 label、校验、布局
  3. el-input / el-select 等:具体输入控件

2.2 正确用法示例

<template>
  <el-form 
    ref="formRef" 
    :model="form" 
    :rules="rules" 
    label-width="100px"
    @submit.prevent
  >
    <el-form-item label="用户名" prop="username">
      <el-input v-model="form.username" placeholder="请输入用户名" />
    </el-form-item>
    
    <el-form-item label="密码" prop="password">
      <el-input v-model="form.password" type="password" placeholder="请输入密码" />
    </el-form-item>
    
    <el-form-item>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
      <el-button @click="handleReset">重置</el-button>
    </el-form-item>
  </el-form>
</template>

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

const formRef = ref()
const form = reactive({
  username: '',
  password: ''
})

// 校验规则:字段名要与 form 中的属性、el-form-item 的 prop 完全一致
const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码至少 6 位', trigger: 'blur' }
  ]
}

const handleSubmit = async () => {
  // validate 返回 Promise,通过则无参数,失败则返回校验错误
  try {
    await formRef.value.validate()
    console.log('校验通过,提交数据:', form)
    // 这里调用接口提交
  } catch (error) {
    console.log('校验失败')
  }
}

const handleReset = () => {
  formRef.value.resetFields()
}
</script>

说明要点:

  • :model="form" 绑定表单数据,注意是 :model,不是 v-model
  • :rules="rules" 绑定校验规则
  • prop="username" 绑定到表单项,用于关联 rules 中的字段
  • @submit.prevent 防止回车键意外提交表单

2.3 常见踩坑

错误写法 正确写法
Form 绑定 v-model="form" :model="form"
不写 prop <el-form-item> 无 prop <el-form-item prop="username">
prop 写错位置 写在 el-input 必须写在 el-form-item
prop 与 rules 不一致 rules 里是 name,prop 是 username 两者字段名完全一致

记住:el-form 用 :model、el-form-item 必须有 prop、prop 与 rules 字段名一致

2.4 常用 API

  • validate():整表校验
  • validateField(prop):校验单个字段
  • resetFields():重置表单
  • clearValidate():清除校验状态

三、表格 Table:列表展示

3.1 核心概念

Table 用于展示列表数据,支持排序、分页、选择、展开等。

3.2 基础用法示例

<template>
  <el-table 
    :data="tableData" 
    stripe 
    border
    style="width: 100%"
    @selection-change="handleSelectionChange"
  >
    <!-- 多选列 -->
    <el-table-column type="selection" width="55" />
    
    <!-- 普通列 -->
    <el-table-column prop="name" label="姓名" width="120" />
    <el-table-column prop="age" label="年龄" width="80" />
    <el-table-column prop="address" label="地址" show-overflow-tooltip />
    
    <!-- 自定义列 -->
    <el-table-column label="状态" width="100">
      <template #default="{ row }">
        <el-tag :type="row.status === 1 ? 'success' : 'info'">
          {{ row.status === 1 ? '启用' : '禁用' }}
        </el-tag>
      </template>
    </el-table-column>
    
    <!-- 操作列 -->
    <el-table-column label="操作" width="180" fixed="right">
      <template #default="{ row }">
        <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
        <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
      </template>
    </el-table-column>
  </el-table>
</template>

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

const tableData = ref([
  { id: 1, name: '张三', age: 28, address: '上海市浦东新区某某路100号', status: 1 },
  { id: 2, name: '李四', age: 32, address: '北京市朝阳区某某大街200号', status: 0 }
])

const handleSelectionChange = (selection) => {
  console.log('选中的行:', selection)
}

const handleEdit = (row) => {
  console.log('编辑', row)
}

const handleDelete = (row) => {
  console.log('删除', row)
}
</script>

说明要点:

  • :data 绑定数据数组,每一行是一个对象
  • prop 对应数据字段名,决定显示哪个字段
  • show-overflow-tooltip:内容过长时显示省略号并悬浮显示完整内容
  • #default="{ row }":插槽提供当前行数据

3.3 配置选型建议

场景 推荐配置
数据较多 heightmax-height 固定高度,出现纵向滚动
树形数据 使用 row-key + tree-props
需要合计 show-summary + summary-method
列宽不稳定 设置 widthmin-width,避免抖动
多选 type="selection" + @selection-change

3.4 常见踩坑

  • 表格数据不更新:确保 tableData 是响应式的(如 ref),修改后要触发更新
  • 树形表格:必须设置 row-key 为唯一字段(如 id
  • 固定列fixed="right"fixed="left" 时,注意右侧固定列写在最后

四、弹窗 Dialog:模态对话框

4.1 核心概念

Dialog 用于在保留当前页面的前提下,弹出一个模态层展示内容,常用于表单弹窗、详情、确认等。

4.2 基础用法示例

<template>
  <el-button @click="dialogVisible = true">打开弹窗</el-button>
  
  <el-dialog
    v-model="dialogVisible"
    title="编辑用户"
    width="500px"
    :close-on-click-modal="false"
    :before-close="handleBeforeClose"
    @opened="handleOpened"
  >
    <!-- 弹窗内容 -->
    <el-form ref="formRef" :model="form" :rules="rules">
      <el-form-item label="用户名" prop="username">
        <el-input v-model="form.username" />
      </el-form-item>
    </el-form>
    
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="handleConfirm">确定</el-button>
      </template>
    </template>
  </el-dialog>
</template>

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

const dialogVisible = ref(false)
const formRef = ref()
const form = reactive({ username: '' })
const rules = { username: [{ required: true, message: '请输入用户名', trigger: 'blur' }] }

// 弹窗关闭前:可做二次确认、校验等
const handleBeforeClose = (done) => {
  // 简单示例:直接关闭
  done()
  // 如需确认:ElMessageBox.confirm('确定关闭?').then(() => done()).catch(() => {})
}

// 弹窗打开动画结束后
const handleOpened = () => {
  formRef.value?.clearValidate()
}

// 关闭时清空表单(按需)
watch(dialogVisible, (val) => {
  if (!val) {
    form.username = ''
  }
})

const handleConfirm = async () => {
  try {
    await formRef.value.validate()
    // 提交逻辑
    dialogVisible.value = false
  } catch (e) {
    // 校验失败
  }
}
</script>

说明要点:

  • v-model="dialogVisible" 控制显示/隐藏
  • :close-on-click-modal="false":点击遮罩不关闭,避免误关
  • before-close:可做二次确认、阻止关闭
  • #footer:自定义底部按钮

4.3 常见配置选型

配置 说明 建议
destroy-on-close 关闭时销毁内容 表单弹窗建议开启,避免数据残留
close-on-click-modal 点击遮罩关闭 表单弹窗建议关闭
append-to-body 挂载到 body 有嵌套弹窗时建议开启

五、消息 Message 与 MessageBox

5.1 ElMessage:轻量提示

用于操作后的简单反馈(成功、失败、警告等),通常显示几秒后自动消失。

import { ElMessage } from 'element-plus'

// 成功
ElMessage.success('保存成功')

// 错误
ElMessage.error('保存失败,请重试')

// 警告
ElMessage.warning('请先填写必填项')

// 自定义
ElMessage({
  message: '操作成功',
  type: 'success',
  duration: 3000,
  showClose: true
})

5.2 ElMessageBox:确认与输入

用于需要用户确认或输入的场景,比 Dialog 更轻量。

import { ElMessageBox } from 'element-plus'

// 确认删除
const handleDelete = async (row) => {
  try {
    await ElMessageBox.confirm(
      `确定要删除「${row.name}」吗?`,
      '提示',
      {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }
    )
    // 用户点击确定
    await deleteApi(row.id)
    ElMessage.success('删除成功')
  } catch (e) {
    // 用户点击取消或关闭
  }
}

// 简单提示(类似 alert)
ElMessageBox.alert('操作完成', '提示')

5.3 选型建议

场景 用 Message 用 MessageBox
保存成功、失败提示
删除前确认
需要用户输入 ✅(prompt)
复杂表单、多内容 改用 Dialog

六、上传 Upload:文件上传

6.1 核心概念

Upload 支持自动上传和手动上传:自动上传是选完即传,手动上传是选完后由按钮触发上传。

6.2 自动上传(选完即传)

<template>
  <el-upload
    action="/api/upload"
    :headers="uploadHeaders"
    :on-success="handleSuccess"
    :on-error="handleError"
    :before-upload="beforeUpload"
  >
    <el-button type="primary">点击上传</el-button>
  </el-upload>
</template>

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

// 请求头,常用于 Token
const uploadHeaders = reactive({
  Authorization: `Bearer ${localStorage.getItem('token')}`
})

// 上传前:校验格式、大小
const beforeUpload = (file) => {
  const isJPG = file.type === 'image/jpeg' || file.type === 'image/png'
  const isLt2M = file.size / 1024 / 1024 < 2

  if (!isJPG) {
    ElMessage.error('只能上传 JPG/PNG 格式')
    return false  // 阻止上传
  }
  if (!isLt2M) {
    ElMessage.error('图片大小不能超过 2MB')
    return false
  }
  return true
}

const handleSuccess = (response, file, fileList) => {
  ElMessage.success('上传成功')
  // response 一般为后端返回的 URL 等
}

const handleError = () => {
  ElMessage.error('上传失败')
}
</script>

6.3 手动上传(和表单一起提交)

<template>
  <el-form :model="form">
    <el-form-item label="附件">
      <el-upload
        ref="uploadRef"
        :auto-upload="false"
        :limit="3"
        :on-exceed="handleExceed"
        :on-change="handleChange"
      >
        <el-button type="primary">选择文件</el-button>
      </el-upload>
    </el-form-item>
    <el-button @click="submitForm">提交表单(含文件)</el-button>
  </el-form>
</template>

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

const uploadRef = ref()
const form = ref({ files: [] })

// 手动上传时,选中的文件会进入 fileList,需要自己调用接口上传
const handleChange = (file, fileList) => {
  form.value.files = fileList
}

const handleExceed = () => {
  ElMessage.warning('最多上传 3 个文件')
}

const submitForm = async () => {
  const formData = new FormData()
  form.value.files.forEach(f => {
    formData.append('files', f.raw)
  })
  // 再 append 其他表单字段...
  // await uploadApi(formData)
}
</script>

说明要点:

  • :auto-upload="false" 关闭自动上传
  • on-change 拿到选中的文件列表
  • 手动上传时用 FormData 组装并调用自己的接口

6.4 常见踩坑

原因 处理
before-upload 返回 false 仍上传 理解错误 返回 falsePromise.reject() 会阻止上传
上传后列表不更新 未绑定 file-list v-model:file-list:file-list 绑定
跨域、Cookie 未带凭证 设置 :with-credentials="true"
需要 Token 接口要鉴权 通过 :headers 传入

七、小结

  • Form:用 :model + prop + rules,三者字段名一致
  • Tableprop 对数据字段,复杂展示用 #default 插槽
  • Dialog:用 v-model 控制显隐,表单弹窗建议 destroy-on-close
  • Message:轻量提示;MessageBox:确认、输入
  • Upload:自动上传用 action + 钩子;手动上传用 :auto-upload="false" + 自定义提交

按上述方式选型和编码,可以避开大部分常见坑。如果你希望我按某一块(比如 Form、Table、Upload)再单独细化成一篇更长的教程,可以说明一下侧重点(例如:复杂表单、动态表格、多图上传等)。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

Vue 3 Composition API深度解析:构建可复用逻辑的终极方案

引言

Vue 3的Composition API是Vue框架最重大的更新之一,它提供了一种全新的组件逻辑组织方式。与传统的Options API相比,Composition API让我们能够更灵活地组织和复用代码逻辑。本文将深入探讨Vue 3 Composition API的8大核心特性,帮助你掌握这个构建可复用逻辑的终极方案。

setup函数基础

1. setup函数的基本使用

setup函数是Composition API的入口点,它在组件创建之前执行。

import { ref, reactive } from 'vue';

export default {
  setup() {
    // 定义响应式数据
    const count = ref(0);
    const user = reactive({
      name: 'Vue 3',
      version: '3.0'
    });

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

    // 返回给模板使用
    return {
      count,
      user,
      increment
    };
  }
};

2. setup函数的参数

setup函数接收两个参数:props和context。

export default {
  props: {
    title: String,
    initialCount: {
      type: Number,
      default: 0
    }
  },
  setup(props, context) {
    // props是响应式的,不能解构
    console.log(props.title);
    
    // context包含attrs、slots、emit等
    const { attrs, slots, emit } = context;
    
    // 触发事件
    const handleClick = () => {
      emit('update', props.initialCount + 1);
    };
    
    return { handleClick };
  }
};

响应式API详解

3. ref与reactive的选择

ref和reactive是创建响应式数据的两种方式,各有适用场景。

import { ref, reactive, toRefs } from 'vue';

// ref - 适合基本类型和单一对象
const count = ref(0);
const message = ref('Hello');

// 访问ref的值需要.value
console.log(count.value);
count.value++;

// reactive - 适合复杂对象
const state = reactive({
  count: 0,
  user: {
    name: 'Vue',
    age: 3
  }
});

// 访问reactive的值不需要.value
console.log(state.count);
state.count++;

// 在模板中自动解包,不需要.value
// <template>
//   <div>{{ count }}</div>
//   <div>{{ state.count }}</div>
// </template>

4. toRefs的使用

当需要从reactive对象中解构属性时,使用toRefs保持响应性。

import { reactive, toRefs } from 'vue';

export default {
  setup() {
    const state = reactive({
      count: 0,
      name: 'Vue 3',
      isActive: true
    });

    // 不推荐 - 失去响应性
    // const { count, name } = state;

    // 推荐 - 使用toRefs保持响应性
    const { count, name, isActive } = toRefs(state);

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

    return {
      count,
      name,
      isActive,
      increment
    };
  }
};

计算属性与侦听器

5. computed计算属性

computed用于创建计算属性,支持getter和setter。

import { ref, computed } from 'vue';

export default {
  setup() {
    const firstName = ref('John');
    const lastName = ref('Doe');

    // 只读计算属性
    const fullName = computed(() => {
      return firstName.value + ' ' + lastName.value;
    });

    // 可写计算属性
    const writableFullName = computed({
      get() {
        return firstName.value + ' ' + lastName.value;
      },
      set(value) {
        const [first, last] = value.split(' ');
        firstName.value = first;
        lastName.value = last;
      }
    });

    return {
      firstName,
      lastName,
      fullName,
      writableFullName
    };
  }
};

6. watch与watchEffect

watch和watchEffect用于侦听数据变化。

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

export default {
  setup() {
    const count = ref(0);
    const user = reactive({
      name: 'Vue',
      age: 3
    });

    // watchEffect - 自动追踪依赖
    watchEffect(() => {
      console.log(`Count is: ${count.value}`);
      console.log(`User is: ${user.name}`);
    });

    // watch - 显式指定侦听源
    watch(count, (newValue, oldValue) => {
      console.log(`Count changed from ${oldValue} to ${newValue}`);
    });

    // 侦听多个源
    watch([count, () => user.name], ([newCount, newName], [oldCount, oldName]) => {
      console.log(`Count: ${oldCount} -> ${newCount}, Name: ${oldName} -> ${newName}`);
    });

    // watch的配置选项
    watch(
      () => user.name,
      (newValue) => {
        console.log(`Name changed to: ${newValue}`);
      },
      {
        immediate: true,  // 立即执行
        deep: true        // 深度侦听
      }
    );

    return { count, user };
  }
};

生命周期钩子

7. 生命周期钩子的使用

Composition API中的生命周期钩子以on开头。

import { 
  onMounted, 
  onUpdated, 
  onUnmounted,
  onBeforeMount,
  onBeforeUpdate,
  onBeforeUnmount
} from 'vue';

export default {
  setup() {
    onBeforeMount(() => {
      console.log('组件挂载前');
    });

    onMounted(() => {
      console.log('组件已挂载');
      // 可以在这里访问DOM
    });

    onBeforeUpdate(() => {
      console.log('组件更新前');
    });

    onUpdated(() => {
      console.log('组件已更新');
    });

    onBeforeUnmount(() => {
      console.log('组件卸载前');
    });

    onUnmounted(() => {
      console.log('组件已卸载');
      // 清理工作
    });

    return {};
  }
};

自定义组合函数

8. 创建可复用的逻辑

自定义组合函数是Composition API的核心优势,让我们能够提取和复用逻辑。

// useCounter.js - 计数器逻辑
import { ref, computed } from 'vue';

export function useCounter(initialValue = 0) {
  const count = ref(initialValue);

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

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

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

  const double = computed(() => count.value * 2);

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

// useMouse.js - 鼠标位置追踪
import { ref, onMounted, onUnmounted } from 'vue';

export function useMouse() {
  const x = ref(0);
  const y = ref(0);

  const update = (event) => {
    x.value = event.pageX;
    y.value = event.pageY;
  };

  onMounted(() => {
    window.addEventListener('mousemove', update);
  });

  onUnmounted(() => {
    window.removeEventListener('mousemove', update);
  });

  return { x, y };
}

// 在组件中使用
import { useCounter, useMouse } from './composables';

export default {
  setup() {
    const { count, increment, decrement, double } = useCounter(10);
    const { x, y } = useMouse();

    return {
      count,
      increment,
      decrement,
      double,
      x,
      y
    };
  }
};

依赖注入

9. provide与inject

provide和inject用于跨组件层级传递数据。

// 父组件
import { provide, ref } from 'vue';

export default {
  setup() {
    const theme = ref('dark');
    const user = ref({
      name: 'Vue User',
      role: 'admin'
    });

    // 提供数据
    provide('theme', theme);
);
    provide('user', user);

    return { theme };
  }
};

// 子组件
import { inject } from 'vue';

export default {
  setup() {
    // 注入数据
    const theme = inject('theme');
    const user = inject('user');

    // 提供默认值
    const config = inject('config', {
      debug: false,
      version: '1.0'
    });

    return { theme, user, config };
  }
};

模板引用

10. 使用ref获取DOM元素

在Composition API中使用ref获取模板引用。

import { ref, onMounted } from 'vue';

export default {
  setup() {
    // 创建模板引用
    const inputRef = ref(null);
    const listRef = ref(null);

    onMounted(() => {
      // 访问DOM元素
      inputRef.value.focus();
      
      // 访问组件实例
      console.log(listRef.value.items);
    });

    const focusInput = () => {
      inputRef.value.focus();
    };

    return {
      inputRef,
      listRef,
      focusInput
    };
  }
};

// 模板中使用
// <template>
//   <input ref="inputRef" />
//   <MyList ref="listRef" />
// </template>

实战案例

11. 表单处理组合函数

// useForm.js
import { ref, reactive } from 'vue';

export function useForm(initialValues, validationRules) {
  const values = reactive({ ...initialValues });
  const errors = reactive({});
  const touched = reactive({});

  const validate = () => {
    let isValid = true;
    
    for (const field in validationRules) {
      const rules = validationRules[field];
      const value = values[field];
      
      for (const rule of rules) {
        if (rule.required && !value) {
          errors[field] = rule.message || '此字段必填';
          isValid = false;
          break;
        }
        
        if (rule.pattern && !rule.pattern.test(value)) {
          errors[field] = rule.message || '格式不正确';
          isValid = false;
          break;
        }
        
        if (rule.validator && !rule.validator(value)) {
          errors[field] = rule.message || '验证失败';
          isValid = false;
          break;
        }
      }
    }
    
    return isValid;
  };

  const handleChange = (field) => (event) => {
    values[field] = event.target.value;
    touched[field] = true;
    
    if (errors[field]) {
      validate();
    }
  };

  const handleBlur = (field) => () => {
    touched[field] = true;
    validate();
  };

  const reset = () => {
    Object.assign(values, initialValues);
    Object.keys(errors).forEach(key => {
      errors[key] = '';
    });
    Object.keys(touched).forEach(key => {
      touched[key] = false;
    });
  };

  const submit = (callback) => () => {
    if (validate()) {
      callback(values);
    }
  };

  return {
    values,
    errors,
    touched,
    handleChange,
    handleBlur,
    validate,
    reset,
    submit
  };
}

// 使用示例
export default {
  setup() {
    const { values, errors, handleChange, handleBlur, submit } = useForm(
      {
        username: '',
        email: '',
        password: ''
      },
      {
        username: [
          { required: true, message: '用户名必填' },
          { pattern: /^[a-zA-Z0-9_]{3,20}$/, message: '用户名格式不正确' }
        ],
        email: [
          { required: true, message: '邮箱必填' },
          { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '邮箱格式不正确' }
        ],
        password: [
          { required: true, message: '密码必填' },
          { validator: (value) => value.length >= 6, message: '密码至少6位' }
        ]
      }
    );

    const handleSubmit = submit((formData) => {
      console.log('表单提交:', formData);
      // 发送API请求
    });

    return {
      values,
      errors,
      handleChange,
      handleBlur,
      handleSubmit
    };
  }
};

12. 异步数据获取组合函数

// useAsyncData.js
import { ref, onMounted } from 'vue';

export function useAsyncData(fetchFn, options = {}) {
  const {
    immediate = true,
    initialData = null,
    onSuccess,
    onError
  } = options;

  const data = ref(initialData);
  const loading = ref(false);
  const error = ref(null);

  const execute = async (...args) => {
    loading.value = true;
    error.value = null;

    try {
      const result = await fetchFn(...args);
      data.value = result;
      
      if (onSuccess) {
        onSuccess(result);
      }
      
      return result;
    } catch (err) {
      error.value = err;
      
      if (onError) {
        onError(err);
      }
      
      throw err;
    } finally {
      loading.value = false;
    }
  };

  if (immediate) {
    onMounted(execute);
  }

  return {
    data: data,
    loading: loading,
    error: error,
    execute: execute,
    refresh: execute
  };
}

// 使用示例
export default {
  setup() {
    const { data, loading, error, refresh } = useAsyncData(
      async (userId) => {
        const response = await fetch(`/api/users/${userId}`);
        return response.json();
      },
      {
        immediate: true,
        onSuccess: (data) => {
          console.log('数据加载成功:', data);
        },
        onError: (error) => {
          console.error('数据加载失败:', error);
        }
      }
    );

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

总结

Vue 3 Composition API为我们提供了更强大、更灵活的代码组织方式:

核心优势

  1. 逻辑复用:通过自定义组合函数轻松复用逻辑
  2. 代码组织:相关逻辑可以组织在一起,而不是分散在options中
  3. 类型推断:更好的TypeScript支持
  4. 灵活性:更灵活的代码组织方式

最佳实践

  1. 合理使用ref和reactive:基本类型用ref,复杂对象用reactive
  2. 提取组合函数:将可复用逻辑提取为独立的组合函数
  3. 保持单一职责:每个组合函数只负责一个功能
  4. 善用toRefs:解构reactive对象时使用toRefs保持响应性
  5. 合理使用生命周期:在setup中正确使用生命周期钩子

学习路径

  1. 掌握setup函数和响应式API
  2. 学习computed和watch的使用
  3. 理解生命周期钩子
  4. 实践自定义组合函数
  5. 掌握依赖注入和模板引用

Composition API不仅是一种新的API,更是一种新的思维方式。它让我们能够以更函数式、更模块化的方式组织代码,提高了代码的可维护性和可测试性。开始在你的项目中使用Composition API吧,体验Vue 3带来的全新开发体验!


本文首发于掘金,欢迎关注我的专栏获取更多前端技术干货!

前端权限控制设计

一、展示控制

前端权限控制的目的是,根据当前用户的身份控制其能访问的页面和可执行的操作。需要注意的是:前端权限控制主要是为了提升用户体验(如隐藏无权限的菜单,按钮),正真的数据安全必须依赖后端实现。

二、RBAC

业界主流的权限管理模型是RBAC(基于角色的访问控制),其核心思想是将"权限"授予"角色",将"角色"授予"用户",实现了用户与权限的逻辑分离,极大的简化了权限的分配与管理。

三、主要流程

主要包括用户身份认证、权限分配、权限校验和页面展示控制。

  • 用户登录后,前端从后端获取用户的权限列表。
  • 前端根据用户权限信息,决定展示哪些菜单或按钮。
  • 路由级别做权限校验,未授权用户访问受限页面时自动跳转到无权限提示页或登录页。
  • 组件级别做权限控制,操作按钮或表单项根据权限动态展示或禁用。

四、实现要点

1.获取用户权限信息

// context/AuthProvider

const AuthContext = createContext(undefined);

export const useAuth = () => useContext(AuthContext);
export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);

  // 从本地存储中恢复用户权限信息
  useEffect(() => {
    const user = localStorage.getItem('user');
    if (user) {
      setUser(JSON.parse(user));
    }
  }, []);

  const login = async (username, password) => {
    const user = await loginApi(username,password);
    setUser(user);
    // 登录后缓存用户权限信息
    localStorage.setItem('user', JSON.stringify(user));
  };

  const logout = () => {
    setUser(null);
    // 登出后清除本地缓存
    localStorage.removeItem('user');
  };

  const hasPermission = (permission: string | string[]): boolean => {
    if (!user) return false;
    if (Array.isArray(permission)) {
      return permission.some(p => user.permissions.includes(p));
    }
    
    return user.permissions.includes(permission);
  };

  const value = {
    user,
    login,
    logout,
    hasPermission
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

2.封装路由权限校验组件

// components/AuthRoute.js
import { useAuth } from '../context/AuthProvider'; // 自定义 hook,获取用户信息

const AuthRoute = ({ children, meta }) => {
  const { user, hasPermission } = useAuth();

   // 用户未登录,重定向到登录页面
  if (meta.requiresAuth && !user) {
    return <Navigate to="/login" replace />;
  }

  // 用户没有权限,重定向到未授权页面
  if (meta.permission && !hasPermission(meta.permission)) {
    return <Navigate to="/403" replace />;
  }

  // 权限通过,渲染子组件
  return children;
};

export default AuthRoute;

3.创建路由

// router/index.js
import AuthRoute from '../components/AuthRoute';

const Router = () => {
  const element = routes.map(({ path, element:Component, meta }) => ({
      path,
      element: (
        <AuthRoute meta={meta}>
          <Component />
        </AuthRoute>
      )
  }));
  return <RouterProvider router={createBrowserRouter(routers)} />;
};

export default Router;

4.封装按钮权限校验组件


import { useAuth } from '../context/AuthProvider'; // 自定义 hook,获取用户信息

export const AuthButton = ({
  permission,
  children,
  onClick,
}) => {
  const { hasPermission } = useAuth();
  const hasAccess = hasPermission(permission);

  if (!hasAccess) {
    return null;
  }

  return (
    <button 
      onClick={onClick} 
    >
      {children}
    </button>
  );
};

5.按钮权限控制

import { AuthButton } from '../components/AuthButton';

export const ContentManagement = () => {
  
  return (
     <AuthButton 
        permission="content.edit"
        onClick={() => handleEdit(item.id)}
     >
        编辑
     </AuthButton>
  );
};

五、技术难点

1.多粒度权限控制

  • 页面级权限控制:通过前端路由守卫实现,例如,React Router的高阶组件、Vue Router 的beforeEach钩子。
  • 组件级权限控制:通过条件渲染隐藏或禁用无权限的按钮。

2.细粒度权限控制

按钮、表单项等细粒度权限控制,难点在于检查点分散,如果每个按钮都要添加额外的权限控制逻辑,维护成本高;另外权限检查函数频繁执行(如在列表中渲染几十个按钮),可能造成性能问题。

常用的做法是封装自定义 Hook(如 usePermission)或高阶组件,并且缓存组件的权限检查结果。

3.状态管理的复杂性

用户权限信息需要全局共享且保持一致性。难点在于:

  • 初始化时机:页面渲染时可能还没拿到用户信息,容易导致未授权页面闪现。
  • Token 过期:接口返回Token过期,需要自动跳转登录,同时清空本地缓存。
  • 多标签页同步:如果一个标签页登出,其他标签页也需要更新状态,否则可能操作报错。

解决方案通常是利用 Context全局共享,使用webStorage本地缓存,利用广播实现多标签页同步。

4.前后端权限一致性

前端权限控制本质是提升用户体验,正真的数据安全必须依赖后端实现。但难点在于:

  • 双重校验的一致性:前端隐藏了按钮,用户仍可能通过直接访问 API 进行操作,所以后端必须对所有接口做权限校验。
  • 数据同步滞后:如果后端修改了用户权限,前端可能仍保留旧的权限缓存,导致用户看到不应看到的操作或无法访问新功能。需要设计合适的刷新机制(如定时拉取、权限变更后强制刷新)。

腾讯域名拦截查询 在线工具核心JS实现

这篇只讲功能层 JavaScript/TypeScript 实现,围绕“输入一个域名,得到可读的拦截状态”这一条主链路展开。

工具有两条查询通道(第三方接口):

  • QQ通道:https://cgi.urlsec.qq.com/index.php?m=check&a=gw_check&callback=url_query&url={url}&ticket={ticket}&randstr={randstr}&_={timestamp}
  • 微信通道:https://cgi.urlsec.qq.com/index.php?m=url&a=validUrl&url={url}

在线工具网址:see-tool.com/tencent-dom…
工具截图:
工具截图.png

1. 输入规范化是第一道关口

这个工具不直接信任用户输入,而是统一走 normalizeInput

const normalizeInput = (value) => {
  const rawValue = String(value || '').trim()
  if (!rawValue) return ''

  const cleaned = rawValue.replace(/\s+/g, '')
  const withProtocol = /^https?:\/\//i.test(cleaned) ? cleaned : `http://${cleaned}`

  try {
    const url = new URL(withProtocol)
    if (!['http:', 'https:'].includes(url.protocol)) return ''
    return url.toString()
  } catch {
    return ''
  }
}

这里做了三件关键事:去空白、补协议、用 URL 做结构化校验。后续所有请求都只使用规范化后的值。

2. 查询动作编排

点击查询时,动作顺序是固定的:

  1. 判空
  2. 规范化
  3. 清理旧结果
  4. 标记查询通道
  5. 进入对应通道请求
const startQqQuery = () => {
  if (!input.value.trim()) {
    errorMessage.value = '请输入要查询的域名'
    return
  }

  const normalized = normalizeInput(input.value)
  if (!normalized) {
    errorMessage.value = '请输入有效的网址'
    return
  }

  input.value = normalized
  errorMessage.value = ''
  resultData.value = null
  lastQueryType.value = 'qq'
  submitQqQuery(normalized, ticket, randstr)
}

const startWeChatQuery = () => {
  if (!input.value.trim()) {
    errorMessage.value = '请输入要查询的域名'
    return
  }

  const normalized = normalizeInput(input.value)
  if (!normalized) {
    errorMessage.value = '请输入有效的网址'
    return
  }

  input.value = normalized
  errorMessage.value = ''
  resultData.value = null
  lastQueryType.value = 'wx'
  submitWeChatQuery(normalized, captchaPayload)
}

这一层不做网络请求细节,只负责把交互状态整理干净。

3. 请求提交与异常回传

真正请求在 submit 函数里,统一处理 loading、异常捕获和结果写入。两条通道分别请求不同第三方 API。

const submitQqQuery = async (url, ticket, randstr) => {
  loading.value = true
  errorMessage.value = ''

  try {
    const apiUrl = `https://cgi.urlsec.qq.com/index.php?m=check&a=gw_check&callback=url_query&url=${encodeURIComponent(
      url
    )}&ticket=${encodeURIComponent(ticket)}&randstr=${encodeURIComponent(randstr)}&_=${Date.now()}123`

    const response = await fetch(apiUrl, {
      method: 'GET',
      headers: {
        Referer: 'https://urlsec.qq.com/check.html',
        'User-Agent':
          'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
        Accept: 'application/json',
        'Accept-Language': 'zh-CN,zh;q=0.8',
        Connection: 'close'
      }
    })

    const text = await response.text()
    const data = parseJsonp(text)
    if (!data || data.reCode !== 0 || !data.data?.results) {
      throw new Error(data?.data || '查询失败')
    }

    resultData.value = buildQqResult(url, data.data.results)
  } catch (error) {
    errorMessage.value = error?.message || '查询失败'
  } finally {
    loading.value = false
  }
}

const submitWeChatQuery = async (url, captchaPayload) => {
  loading.value = true
  errorMessage.value = ''

  try {
    const apiUrl = `https://cgi.urlsec.qq.com/index.php?m=url&a=validUrl&url=${encodeURIComponent(url)}`

    const response = await fetch(apiUrl, {
      method: 'GET',
      headers: {
        Referer: 'https://urlsec.qq.com/check.html',
        'User-Agent':
          'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36',
        Accept: 'application/json',
        'Accept-Language': 'zh-CN,zh;q=0.8',
        Connection: 'close'
      }
    })

    const text = await response.text()
    const data = JSON.parse(text)
    const isBlocked = data.data === 'ok'

    resultData.value = {
      url,
      status: {
        type: isBlocked ? 'wechat_blocked' : 'wechat_safe'
      }
    }
  } catch (error) {
    errorMessage.value = error?.message || '查询失败'
  } finally {
    loading.value = false
  }
}

前端只认统一的返回结构:{ status: 'ok', data: ... }

4. 服务端 URL 清洗与请求拦截

服务端入口先拦截无效请求,再代理到上游接口:

const normalizeUrl = (input: string) => {
  const rawValue = String(input || '').trim()
  if (!rawValue) return ''

  const cleaned = rawValue.replace(/\s+/g, '')
  const withProtocol = /^https?:\/\//i.test(cleaned) ? cleaned : `http://${cleaned}`

  try {
    const target = new URL(withProtocol)
    if (!['http:', 'https:'].includes(target.protocol)) return ''
    return target.toString()
  } catch {
    return ''
  }
}

if (!url) {
  setResponseStatus(event, 400)
  return { status: 'error', message: 'Invalid url' }
}

这样可以保证前后端都执行相同的输入约束,避免脏数据直接进入上游请求。

5. QQ通道:JSONP 解析与状态映射

QQ通道第三方接口:https://cgi.urlsec.qq.com/index.php?m=check&a=gw_check&callback=url_query&url={url}&ticket={ticket}&randstr={randstr}&_={timestamp}

QQ通道返回的是 JSONP,不是纯 JSON,所以先解包:

const parseJsonp = (jsonpStr: string) => {
  const match = jsonpStr.match(/url_query\((.+)\)/)
  if (match && match[1]) {
    try {
      return JSON.parse(match[1])
    } catch {
      return null
    }
  }
  return null
}

拿到对象后,再把复杂字段折叠成前端可消费的数据模型:

if (result.whitetype === 3 || result.whitetype === 4) {
  data.status.type = 'whitelist'
} else if (result.whitetype === 2) {
  data.status.type = 'blocked'
  data.status.wordingTitle = result.WordingTitle || ''
  data.status.wording = result.Wording || ''
} else if (result.whitetype === 1) {
  if (result.eviltype === 2800 || result.eviltype === 2804) {
    data.status.type = 'qq_blocked'
  } else if (result.eviltype && result.eviltype !== 0) {
    data.status.type = 'other_blocked'
    data.status.evilType = result.eviltype
  } else {
    data.status.type = 'safe'
  }
}

这一步的重点不是“原样透传”,而是“转成稳定业务语义”。

6. 微信通道:返回值压平

微信通道第三方接口:https://cgi.urlsec.qq.com/index.php?m=url&a=validUrl&url={url}

微信查询通道返回结构更简单,核心逻辑就是把上游标记转成统一状态:

const isBlocked = data.data === 'ok'

return {
  status: 'ok',
  data: {
    url,
    status: {
      type: isBlocked ? 'wechat_blocked' : 'wechat_safe'
    }
  }
}

两条通道虽然来源不同,但最终都对齐到 data.status.type,前端渲染就能复用同一套逻辑。

7. 结果渲染:从对象到行数据

页面不直接硬编码每一行,而是先把结果对象转换成 resultRows

const resultRows = computed(() => {
  const data = resultData.value
  if (!data) return []

  const rows = []
  const addRow = (key, label, value, extra = {}) => {
    if (value === undefined || value === null || value === '') return
    rows.push({ key, label, value, ...extra })
  }

  addRow('url', '检测地址', data.url)
  addRow('status', '检测结果', statusText.value, { isStatus: true, toneClass: statusTone.value })

  if (data.status?.wordingTitle) addRow('reasonTitle', '原因标题', data.status.wordingTitle)
  if (data.status?.wording) addRow('reasonDetail', '原因详情', data.status.wording)

  return rows
})

这种“先标准化、再渲染”的模式,能让字段增减时只改一处映射逻辑。

到这里,核心链路就是:输入标准化 → 查询编排 → 服务端映射 → 统一结果模型 → 页面渲染。

TypeScript 强力护航:PropType 与组件事件类型的声明

前言

在 Vue 3 + TypeScript 的项目中,组件的类型安全是一个核心话题。很多开发者可能有过这样的经历:使用一个第三方组件时,完全不知道它接受哪些 Props,也不知道事件应该传递什么参数,只能去翻文档。或者在自己的项目中,修改了一个组件的 Props,结果到处报错,不得不全局搜索手动修改。

TypeScript 的出现改变了这一切。通过为组件 Props 和事件声明类型,我们不仅能获得完美的智能提示,还能让编译器在开发阶段就发现类型错误。本文将深入探讨如何在 Vue 3 中为组件定义类型安全的 Props 和事件,包括复杂的泛型组件实现。

Vue 组件类型系统的演进

Options API 中的 Prop 类型:运行时校验

在 Options API 中,我们通过对象形式定义 Props:

export default {
  props: {
    // 基础类型检查
    name: String,
    age: Number,
    
    // 带验证的写法
    email: {
      type: String,
      required: true,
      validator: (value: string) => value.includes('@')
    },
    
    // 复杂类型
    user: {
      type: Object,
      default: () => ({})
    }
  }
}

这种写法存在很多局限性:

  • 运行时类型检查:这些类型只在运行时验证,TypeScript 无法在编译时捕获错误
  • 复杂类型无法表达:user: Object 无法描述对象的内部结构
  • 没有智能提示:在模板中使用 props 时,编辑器不知道有哪些属性

Composition API 带来的类型优势

Composition API 配合 TypeScript,让类型推导变得更加强大:

<script setup lang="ts">
// 现在可以获得类型推导
const props = defineProps({
  name: String,
  age: Number
})

// props.name 被推导为 string | undefined
// props.age 被推导为 number | undefined
</script>

但这种方法仍然有局限性,无法定义复杂的嵌套类型。

为什么需要显式的 PropType?

当 Props 的类型不是简单的 String、Number 等构造函数时,就需要 PropType 来帮助 TypeScript 理解类型。我们先来看一个反例:

// ❌ 这样写,TypeScript 会报错
defineProps({
  user: {
    type: Object as User, // 'User' only refers to a type, but is being used as a value here
    required: true
  }
})

正确写法:

defineProps({
  user: {
    type: Object as PropType<User>, // 告诉 TypeScript 这是一个 User 类型
    required: true
  },
  
  // 联合类型
  status: {
    type: String as PropType<'active' | 'inactive'>,
    default: 'active'
  },
  
  // 复杂对象
  config: {
    type: Object as PropType<{
      theme: string
      fontSize: number
    }>,
    default: () => ({ theme: 'light', fontSize: 14 })
  }
})

Props 定义的三种方式

运行时声明 + 类型推导(基础写法)

<script setup lang="ts">
// 基础类型会自动推导
const props = defineProps({
  name: String,           // props.name: string | undefined
  age: Number,            // props.age: number | undefined
  isActive: Boolean,      // props.isActive: boolean | undefined
  tags: Array,            // props.tags: any[] | undefined
  user: Object            // props.user: Record<string, any> | undefined
})

// 设置默认值
const propsWithDefault = defineProps({
  count: {
    type: Number,
    default: 0
  },                      // props.count: number
  items: {
    type: Array,
    default: () => []
  }                       // props.items: any[]
})
</script>
  • 优点:写法简单,有运行时类型检查
  • 缺点:复杂类型无法表达,如 string[] 会被推导为 any[]

纯类型声明(推荐)

这是 Vue 3.3+ 推荐的方式,使用 TypeScript 接口或类型别名:

<script setup lang="ts">
// 定义 Props 接口
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user' | 'guest'
}

interface Config {
  theme: 'light' | 'dark'
  fontSize: number
  showAvatar?: boolean
}

interface Props {
  title: string
  count?: number
  user: User
  config: Config
  tags: string[]
  status: 'loading' | 'success' | 'error'
}

// 直接使用接口
const props = defineProps<Props>()

// 需要默认值时,使用 withDefaults
const propsWithDefault = withDefaults(defineProps<Props>(), {
  count: 0,
  tags: () => [],
  config: () => ({ theme: 'light', fontSize: 14 })
})
</script>
  • 优点:

    • 完美的类型推导
    • 支持任何复杂的 TypeScript 类型
    • 编辑器智能提示完美
  • 缺点:

    • 需要 Vue 3.3+ 版本
    • 不能同时使用运行时验证(如 validator 函数)

复杂类型的处理:PropType 工具类型

当需要运行时验证,又想保留类型时,使用 PropType:

<script setup lang="ts">
import type { PropType } from 'vue'

// 定义复杂类型
interface User {
  id: number
  name: string
  email: string
  preferences: {
    theme: 'light' | 'dark'
    notifications: boolean
  }
}

type Status = 'pending' | 'processing' | 'completed' | 'failed'

// 使用 PropType 辅助类型推导
const props = defineProps({
  // 对象类型
  user: {
    type: Object as PropType<User>,
    required: true,
    validator: (user: User) => user.name.length > 0
  },
  
  // 联合类型
  status: {
    type: String as PropType<Status>,
    default: 'pending'
  },
  
  // 数组类型
  tags: {
    type: Array as PropType<string[]>,
    default: () => []
  },
  
  // 函数类型
  onSave: {
    type: Function as PropType<(data: User) => Promise<void>>,
    required: false
  },
  
  // 复杂的嵌套类型
  config: {
    type: Object as PropType<{
      pagination: {
        pageSize: number
        currentPage: number
      }
      filters: Record<string, any>
    }>,
    default: () => ({
      pagination: { pageSize: 10, currentPage: 1 },
      filters: {}
    })
  }
})
</script>

适用场景:

  • 需要运行时验证(如 validator)
  • 需要设置复杂的默认值逻辑
  • 需要与 Options API 混用

事件发射的类型安全

defineEmits 的基础用法

<script setup lang="ts">
// 基础写法:字符串数组
const emit = defineEmits(['change', 'update', 'delete'])

// 使用时没有任何类型提示
emit('change', 123) // 可以传任意参数
emit('update', 'any', 'thing') // 没问题
</script>

为事件负载定义类型(推荐)

<script setup lang="ts">
// 使用类型声明
interface Emits {
  // 基础事件
  (e: 'change', value: string): void
  (e: 'update:id', id: number): void
  (e: 'delete'): void
  
  // 多个参数
  (e: 'item-move', fromIndex: number, toIndex: number): void
  
  // 联合类型的事件名
  (e: 'success' | 'error', message: string): void
}

const emit = defineEmits<Emits>()

// 使用时的类型检查
emit('change', '新值')      // ✅ 正确
emit('change', 123)         // ❌ 错误:参数类型必须是 string
emit('update:id', 1)        // ✅ 正确
emit('delete')              // ✅ 正确
emit('item-move', 0, 5)     // ✅ 正确
emit('item-move', 0)        // ❌ 错误:缺少第二个参数
</script>

v-model 的类型安全

<script setup lang="ts">
// 单个 v-model
interface Emits {
  (e: 'update:modelValue', value: string): void
  (e: 'update:searchText', value: string): void
  (e: 'update:selectedIds', ids: number[]): void
}

const emit = defineEmits<Emits>()

// 多个 v-model 的使用
function handleInput(value: string) {
  emit('update:modelValue', value)
}

function handleSearch(value: string) {
  emit('update:searchText', value)
}

function handleSelect(ids: number[]) {
  emit('update:selectedIds', ids)
}
</script>

<template>
  <!-- 父组件使用时获得类型提示 -->
  <ChildComponent 
    v-model="text"
    v-model:search-text="searchText"
    v-model:selected-ids="selectedIds"
  />
</template>

泛型组件的实现技巧

使用 defineComponent 配合泛型

在 Vue 3.3 之前,需要使用 defineComponent 来创建泛型组件:

// GenericTable.ts
import { defineComponent, PropType } from 'vue'

export default defineComponent({
  name: 'GenericTable',
  
  props: {
    data: {
      type: Array as PropType<any[]>,
      required: true
    },
    columns: {
      type: Array as PropType<TableColumn<any>[]>,
      required: true
    },
    rowKey: {
      type: [String, Function] as PropType<string | ((row: any) => string)>,
      required: true
    }
  },
  
  emits: {
    'sort-change': (sort: SortState) => true,
    'row-click': (row: any, index: number) => true
  },
  
  setup(props, { emit }) {
    // 实现逻辑
    return () => {
      // 渲染函数
    }
  }
})

// 使用时需要手动指定类型
const table = GenericTable as <T extends Record<string, any>>(
  new () => {
    $props: TableProps<T>
  }
)

在 SFC 中使用

Vue 3.3 引入了 generic 属性,让泛型组件的实现变得简单:

<script setup lang="ts" generic="T extends { id: string | number }">
// T 必须包含 id 属性
defineProps<{
  items: T[]
  selectedId?: T['id']
}>()

defineEmits<{
  select: [id: T['id']]
}>()
</script>

类型推导的局限性及解决方案

问题 1:模板中的类型推导

<script setup lang="ts" generic="T">
defineProps<{
  data: T[]
  format: (item: T) => string
}>()
</script>

<template>
  <div v-for="item in data" :key="item.id">
    <!-- ❌ item.id 可能不存在于 T 上 -->
    {{ format(item) }}
  </div>
</template>
解决方案:添加泛型约束
<script setup lang="ts" generic="T extends { id: string | number }">
defineProps<{
  data: T[]
  format: (item: T) => string
}>()
</script>

问题 2:事件参数的类型推导

<script setup lang="ts" generic="T">
const emit = defineEmits<{
  (e: 'update', item: T): void  // ❌ T 在这里无法推导
}>()
</script>
解决方案:使用运行时声明 + PropType
<script setup lang="ts">
import type { PropType } from 'vue'

const props = defineProps({
  items: {
    type: Array as PropType<T[]>,
    required: true
  }
})

const emit = defineEmits({
  'update': (item: any) => true
})
</script>

类型安全组件的收益

使用组件时的智能提示

当其他开发者在使用我们的组件时,VS Code 会提供完美的智能提示:

<template>
  <!-- 输入 <Table 就会弹出所有 Props 提示 -->
  <Table
    :data="users"
    :columns="columns"
    :row-key="'id'"
    @sort-change="handleSortChange"
    @row-click="handleRowClick"
  />
</template>

错误提前暴露

<script setup>
// ❌ 编译时报错:Property 'nme' does not exist on type 'User'
const columns = [
  { key: 'nme', title: '姓名' } // 拼写错误
]

// ❌ 编译时报错:Type 'string' is not assignable to type 'number'
const handleSortChange = (sort: SortState) => {
  sort.field = 123 // 类型错误
}
</script>

更好的可维护性

当需要修改组件 Props 时,TypeScript 会标记所有使用错误的地方:

// 将 Props 从 TableColumn 改为 ColumnConfig
interface TableProps<T> {
  columns: ColumnConfig<T>[] // 修改了类型
  // ...
}

// 所有使用了旧类型的地方都会报错,不需要手动查找

类型安全组件的最佳实践清单

  • 优先使用纯类型声明(defineProps())
  • 复杂类型使用 PropType 辅助
  • 为所有事件定义类型,包括负载参数
  • 使用泛型创建可复用组件,并添加必要约束
  • 导出组件的 Props 和 Emits 类型,方便使用者
  • 为插槽定义类型,提供更好的使用体验

结语

类型安全不是一蹴而就的,而是在开发过程中逐步完善的。它不仅是为了迎合 TypeScript ,更是为了让我们的代码更加健壮,让团队协作更加顺畅。

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

组件设计原则:如何设计一个高内聚、低耦合的 Vue 组件

前言

在 Vue 应用开发中,组件就像是乐高积木,组件设计可以决定这些积木的形状和接口。好的设计可以让积木自由组合,构建出各种复杂的应用;而一个坏的设计则让积木之间互不兼容,最终导致代码难以维护、难以复用、难以测试。

尤其是随着项目规模的增长,组件设计的重要性愈发凸显。本文将深入探讨高内聚低耦合的核心概念,通过大量实战案例,帮助我们掌握 Vue 组件设计的精髓。

为什么组件设计如此重要?

现实痛点

开篇之前,我们先来看一个设计不良的组件会带来哪些问题:

<!-- ❌ 反例:一个上千行的 "上帝组件" -->
<template>
  <div>
    <!-- 用户信息区域 -->
    <div class="user-section">
      <img :src="user.avatar">
      <h2>{{ user.name }}</h2>
      <!-- 几百行用户相关代码 -->
    </div>
    
    <!-- 好友列表区域 -->
    <div class="friends-section">
      <!-- 又是几百行好友列表代码 -->
    </div>
    
    <!-- 动态列表区域 -->
    <div class="activities-section">
      <!-- 还有几百行动态列表代码 -->
    </div>
  </div>
</template>

<script>
export default {
  props: ['user'], // 什么类型?不知道
  data() {
    return {
      user: {},
      friends: [],
      activities: [],
      loading: false,
      error: null,
      // ... 还有诸多数据字段
    }
  },
  methods: {
    // 所有方法全部混在一起
    fetchUser() { /* ... */ },
    fetchFriends() { /* ... */ },
    fetchActivities() { /* ... */ },
    followUser() { /* ... */ },
    unfollowUser() { /* ... */ },
    likeActivity() { /* ... */ },
    // ... 其他方法
  }
}
</script>

这个组件存在的问题:

  • 牵一发而动全身:修改用户信息的样式,可能会意外影响好友列表
  • 难以复用:想在另一个页面显示好友列表?那只能复制粘贴上百行代码
  • 难以理解:新接手的人需要花一天时间才能理清逻辑
  • 难以测试:如何单独测试好友列表的功能?

好的组件设计带来的收益

<!-- ✅ 好的设计:拆分为独立组件 -->
<template>
  <div class="user-profile-page">
    <UserInfoCard :user="user" />
    <FriendList :friends="friends" @follow="handleFollow" />
    <ActivityFeed :activities="activities" @like="handleLike" />
  </div>
</template>

<script setup>
// 容器组件:只负责数据获取和组合
const { user, friends, activities } = await fetchUserData(props.userId)

function handleFollow(userId) { /* ... */ }
function handleLike(activityId) { /* ... */ }
</script>

这个组件带来的好处:

  • 可维护性:每个组件独立修改,互不影响
  • 可复用性:这个组件可以在任何地方使用
  • 可测试性:可以为每个组件编写独立的单元测试
  • 可读性:代码即文档,一目了然

高内聚低耦合:组件设计的黄金法则

什么是高内聚?

高内聚是指组件内部的元素(数据、方法、模板等)紧密相关,共同完成一个明确的职责:

<!-- ✅ 高内聚的计数器组件:所有逻辑都服务于"计数"这个单一职责 -->
<template>
  <div class="counter">
    <button @click="decrement" :disabled="count <= min">-</button>
    <span class="count">{{ count }}</span>
    <button @click="increment" :disabled="count >= max">+</button>
  </div>
</template>

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

const props = defineProps<{
  min?: number
  max?: number
  initial?: number
}>()

// 所有数据和方法都围绕 count 展开
const count = ref(props.initial ?? 0)

function increment() {
  if (count.value < (props.max ?? Infinity)) {
    count.value++
  }
}

function decrement() {
  if (count.value > (props.min ?? -Infinity)) {
    count.value--
  }
}
</script>

<style scoped>
/* 样式也只服务于这个组件 */
.counter {
  display: flex;
  align-items: center;
  gap: 8px;
}
</style>

高内聚的特征

  • 组件名称准确地描述了它的功能
  • 组件的所有代码都是为了实现这个功能
  • 移除任何一个部分都会影响核心功能

什么是低耦合?

低耦合是指组件之间的依赖关系简单、明确,修改一个组件不需要修改另一个组件:

<!-- 父组件 -->
<template>
  <div>
    <UserCard
      :user="user"
      @follow="handleFollow"
      @unfollow="handleUnfollow"
    />
  </div>
</template>

<!-- 子组件:不知道父组件的任何信息 -->
<template>
  <div class="user-card">
    <img :src="user.avatar" :alt="user.name">
    <h3>{{ user.name }}</h3>
    <button 
      v-if="!isFollowing"
      @click="$emit('follow', user.id)"
    >
      关注
    </button>
    <button 
      v-else
      @click="$emit('unfollow', user.id)"
    >
      取消关注
    </button>
  </div>
</template>

<script setup>
defineProps<{
  user: { id: number; name: string; avatar: string }
  isFollowing?: boolean
}>()

defineEmits<{
  follow: [userId: number]
  unfollow: [userId: number]
}>()
</script>

低耦合的特征

  • 组件只通过 Props 接收数据,通过 Events 发送消息
  • 组件内部不依赖全局状态(除非必要)
  • 修改组件内部实现,不需要修改使用它的地方

内聚与耦合的关系

高内聚和低耦合是相辅相成的:

  • 高内聚是低耦合的基础:只有组件内部职责清晰,才能设计出清晰的接口
  • 低耦合让高内聚更有价值:如果组件之间耦合度高,即使每个组件内聚再好,系统也难以维护

组件划分的边界艺术

如何判断一个组件是否应该拆分?

当我们在犹豫是否要拆分一个组件时,可以问问自己这几个问题:

  • 独立复用:这个部分能否在其他地方使用?
  • 独立逻辑:这个部分是否有独立的业务逻辑?
  • 频繁变化:这个部分是否会频繁修改?
  • 代码规模:代码是否过长,如是否超过 300 行?
  • 过度拆分:是否为了拆分而拆分,导致组件冗余?

原子设计方法论

原子设计方法论是由 Brad Frost 提出的一种用于构建设计系统的方法论。它借鉴了化学中的基本概念,认为所有的用户界面(UI)都可以由一系列基本的、不可再分的元素(原子)组合而成。其核心思想是分层构建,就像搭积木一样,从最小的单元开始,逐步组合成越来越复杂的结构,这个过程分为五个层次:

原子(Atoms)→ 分子(Molecules)→ 组织(Organisms)→ 模板(Templates)→ 页面(Pages)

原子

原子 是构成用户界面的最基本、最小的元素,无法再进一步细分。其本身不具备独立的功能性,但它们定义了所有设计元素的基础样式和属性。比如一个 <label> 标签、一个 <input> 输入框、一个 <button> 按钮、颜色调色板、字体、动画等:

<template>
  <button>原子按钮</button>
</template>

分子

分子 由多个原子组合在一起形成的相对简单的 UI 组件,具有简单、明确的功能,遵循“单一职责原则”,即:只做一件事,且把这件事做得很好。比如一个“搜索框”分子可以由一个 <label> 原子(“搜索”文字)、一个 <input> 原子(输入框)和一个 <button> 原子(“搜索”按钮)组合而成。这三个原子结合在一起,就形成了一个能执行搜索功能的最小单元:

<template>
  <div class="search-bar">
    <label>搜索:<label>
    <input v-model="searchText" />
    <button @click="search">搜索</button>
  </div>
</template>

组织

组织 由分子、原子以及其他组织组合而成的相对复杂的 UI 结构。它们构成了页面中一个独立的区域,作为页面中功能完善的模块,但本身还不是一个完整的页面。比如“用户列表”,由多个“用户卡片”分子构成:

<template>
  <div class="user-list">
    <UserCard v-for="user in users" :key="user.id" :user="user" />
  </div>
</template>

模板

模板 将多个组织、分子和原子组合在一起,形成页面的 骨架和布局结构。其关注的是内容在页面上的 排布方式,展示了各组件的相对位置和功能。如一个“管理布局”模板,定义了头部组织、正文内容区域和底部组织分别放在什么位置:

<template>
  <div class="layout">
    <header />
    <main>
      <SearchBar @search="handleSearch" />
      <UserList :users="filteredUsers" />
    </main>
    <footer />
  </div>
</template>

注:模板是 抽象 的,它没有填充真实的内容,只有占位符。只是定义了 布局结构

页面

页面 是模板的具体实例。它将真实的内容(文本、图片等)填充到模板中,并精确地调整整个界面的样式和逻辑,最终呈现给用户的样子。

原子设计方法论与 Vue3 的结合

Vue3 的原子:Vue3 中的基础元素组件

在 Vue3 中,原子通常对应那些只封装了最基础 HTML 元素和样式的组件。它们通常只通过 props 接收数据,并通过 $emitv-model 向外发送事件:

<!-- 1. 原子:BaseInput.vue -->
<template>
  <div class="base-input">
    <input
      :id="id"
      :type="type"
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      v-bind="$attrs"
    />
  </div>
</template>

<script setup lang="ts">
defineProps({
  id: String,
  type: { type: String, default: 'text' },
  modelValue: [String, Number]
})
defineEmits(['update:modelValue'])
</script>

Vue3 的分子:Vue3 中的功能组件

<!-- 分子:SearchForm.vue -->
<template>
  <form class="search-form" @submit.prevent="handleSubmit">
    <BaseInput
      v-model="searchText"
      label="搜索"
      placeholder="请输入关键词..."
    />
    <BaseButton type="submit">搜索</BaseButton>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import BaseInput from './BaseInput.vue'
import BaseButton from './BaseButton.vue'

const searchText = ref('')
const emit = defineEmits(['search'])

const handleSubmit = () => {
  emit('search', searchText.value)
}
</script>

Vue3 的组织:Vue3 中的区块组件

<!-- 组织:HeaderOrganism.vue -->
<template>
  <header class="site-header">
    <div class="logo">
      <img src="/logo.png" alt="Logo" />
      <span>My App</span>
    </div>
    <nav class="nav-menu">
      <a v-for="item in navItems" :key="item.link" :href="item.link">{{ item.text }}</a>
    </nav>
    <SearchForm @search="handleGlobalSearch" />
  </header>
</template>

<script setup lang="ts">
import SearchForm from './SearchForm.vue' // 导入分子

const navItems = [ /* ... */ ]
const handleGlobalSearch = (query) => { /* 处理全局搜索 */ }
</script>

Vue3 中的模板:Vue3 中的布局或页面组件(此时无数据)

模板在 Vue 中通常对应一个布局组件或一个无具体数据的页面级组件。它负责定义页面的骨架结构,引入各种组织组件,并将它们摆放在正确的位置。此时,组件接收的 propsslot 插槽内容都是抽象的占位符:

<!-- 模板:ArticlePageTemplate.vue -->
<template>
  <div class="article-page">
    <HeaderOrganism />
    <main class="content-wrapper">
      <aside class="sidebar">
        <!-- 这里是一个插槽,用于放置侧边栏内容,具体内容由页面填充 -->
        <slot name="sidebar" />
      </aside>
      <article class="main-content">
        <!-- 这里是主要内容插槽 -->
        <slot />
      </article>
    </main>
    <FooterOrganism />
  </div>
</template>

<script setup lang="ts">
import HeaderOrganism from './HeaderOrganism.vue'
import FooterOrganism from './FooterOrganism.vue'
</script>

Vue3 中的页面:Vue2 中的完整页面组件(有数据)

<!-- 页面:ArticlePage.vue -->
<template>
  <ArticlePageTemplate>
    <!-- 向模板的 sidebar 插槽填充真实内容 -->
    <template #sidebar>
      <AuthorCard :author="article.author" />
      <RelatedArticles :articles="article.related" />
    </template>

    <!-- 向默认插槽填充文章正文 -->
    <ArticleContent :article="article" />
  </ArticlePageTemplate>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ArticlePageTemplate from './ArticlePageTemplate.vue'
import AuthorCard from './AuthorCard.vue'
import RelatedArticles from './RelatedArticles.vue'
import ArticleContent from './ArticleContent.vue'

const article = ref({})
onMounted(async () => {
  article.value = await fetchArticleData()
})
</script>

Props 设计:定义组件的公开 API

Props 设计的黄金法则

法则一:尽可能少,尽可能明确

只接收必要的数据,不要接收和组件不相关的数据:

defineProps<{
  user: User
  isEditable?: boolean
}>()

法则二:提供合理的默认值

interface Props {
  placeholder?: string
  disabled?: boolean
  maxLength?: number
}

const props = withDefaults(defineProps<Props>(), {
  placeholder: '请输入',
  disabled: false,
  maxLength: 100
})

法则三:使用 TypeScript 定义类型

interface User {
  id: number
  name: string
  avatar: string
  role: 'admin' | 'user' | 'guest'
}

defineProps<{
  user: User
  permissions: string[]
}>()

法则四:避免传递不必要的 props

<ChildComponent :user="user" />

Props 的 4 种类型及使用场景

1. 数据型 Props:单纯的数据展示

<UserCard 
  :user="user"
  :posts="userPosts"
/>

2. 配置型 Props:控制组件行为

<DataTable
  :show-header="true"
  :allow-sort="true"
  :page-size="20"
  :theme="'dark'"
/>

3. 回调型 Props:事件处理

<FormComponent
  @submit="handleSubmit"
  @cancel="handleCancel"
/>

4. 节点型 Props:自定义渲染

<ModalComponent>
  <template #header>
    <h2>自定义标题</h2>
  </template>
  <template #footer>
    <button>确认</button>
  </template>
</ModalComponent>

Props 命名的最佳实践

1. 使用完整单词

defineProps<{
  userName: string      // 不是 uname
  userAvatar: string    // 不是 uavatar(除非是标准术语)
}>()

2. 布尔值用 is/has/should 开头

defineProps<{
  isActive: boolean     // 状态
  hasPermission: boolean // 拥有
  shouldShow: boolean   // 应该
}>()

3. 回调函数用 on 开头

defineProps<{
  onSubmit: () => void
  onClose: () => void
}>()

4. 数组等用复数

defineProps<{
  users: User[]
}>()

事件通信:让组件之间优雅地对话

组件通信的 5 种方式及选择策略

1. Props + Events:父子组件直接通信(最常用)

<!-- 父组件 -->
<ChildComponent 
  :data="parentData"
  @update="handleUpdate"
/>

<!-- 子组件 -->
<script setup>
defineProps<{ data: string }>()
const emit = defineEmits<{
  update: [value: string]
}>()
</script>

2. v-model:双向绑定的场景(表单类)

<InputComponent v-model="searchText" />

3. Slots:父组件控制渲染内容(布局类)

<CardComponent>
  <template #header>标题</template>
  内容
  <template #footer>底部</template>
</CardComponent>

4. Provide/Inject:跨多层组件传递(主题、用户信息)

// 祖先组件
provide('theme', 'dark')
// 后代组件
const theme = inject('theme')

5. Pinia:全局状态(用户信息、购物车)

const userStore = useUserStore()

事件设计的 3 个原则

原则一:只通知,不下命令

子组件只需要告诉父组件发生了什么,至于事件发生后该做什么,要怎么做,由父组件决定,子组件不作任何处理:

const emit = defineEmits<{
  'item-selected': [item: Item]
  'form-submitted': [data: FormData]
}>()

原则二:事件粒度适中

一个操作对应一个事件,不要把所有操作放在一个事件中(太粗),也不要把不需要处理的操作放在事件中(太细):

// ✅ 好:一个操作一个事件
const emit = defineEmits<{
  'save-success': []
  'save-error': [error: Error]
}>()

// ❌ 差:太细或太粗
const emit = defineEmits<{
  'button-mousedown': []      // 太细,外部不需要知道
  'button-mouseup': []        // 太细
  'data-operation': [         // 太粗,不知道发生了什么
    type: 'create' | 'update' | 'delete',
    data: any
  ]
}>()

原则三:保持一致性

统一的命名风格,使用冒号 : 分隔命名空间:

const emit = defineEmits<{
  'user:created': [user: User]
  'user:updated': [user: User]
  'user:deleted': [userId: string]
}>()

插槽设计:让组件拥有无限可能

插槽的 3 种形式及适用场景

1. 默认插槽:简单的内容占位

<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card-content">
      <slot>
        <!-- 提供默认内容 -->
        <p>暂无内容</p>
      </slot>
    </div>
  </div>
</template>

<!-- 使用 -->
<Card>
  <p>这是卡片内容</p>
</Card>

2. 具名插槽:多个位置的定制

<!-- Modal.vue -->
<template>
  <div class="modal">
    <header>
      <slot name="header">默认标题</slot>
    </header>
    
    <main>
      <slot name="content">默认内容</slot>
    </main>
    
    <footer>
      <slot name="footer">
        <button @click="close">关闭</button>
      </slot>
    </footer>
  </div>
</template>

<!-- 使用 -->
<Modal>
  <template #header>
    <h2>自定义标题</h2>
  </template>
  
  <template #content>
    <p>自定义内容</p>
  </template>
  
  <template #footer>
    <button @click="confirm">确认</button>
    <button @click="cancel">取消</button>
  </template>
</Modal>

3. 作用域插槽:让父组件访问子组件数据

<!-- DataTable.vue -->
<template>
  <div class="data-table">
    <table>
      <tbody>
        <tr v-for="(item, index) in data" :key="index">
          <td v-for="col in columns" :key="col.key">
            <slot 
              :name="`column-${col.key}`"
              :value="item[col.key]"
              :row="item"
              :index="index"
            >
              {{ item[col.key] }}
            </slot>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<!-- 使用 -->
<DataTable :data="users" :columns="columns">
  <template #column-status="{ value, row }">
    <Badge :type="value === 'active' ? 'success' : 'default'">
      {{ value }}
    </Badge>
  </template>
</DataTable>

插槽设计的 3 个最佳实践

1. 提供合理的默认内容

<template>
  <div class="empty-state">
    <slot name="icon">
      <EmptyIcon />
    </slot>
    
    <slot name="message">
      <p>暂无数据</p>
    </slot>
    
    <slot name="action">
      <button @click="$emit('refresh')">刷新</button>
    </slot>
  </div>
</template>

2. 保持作用域数据的精简

<template>
  <!-- ✅ 好:只暴露必要的数据 -->
  <slot 
    :item="item"
    :index="index"
    :is-first="index === 0"
    :is-last="index === items.length - 1"
  />
  
  <!-- ❌ 差:暴露整个组件实例 -->
  <slot :this="this" :$el="$el" :$props="$props" />
</template>

3. 使用 TypeScript 定义插槽类型

<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
}

defineSlots<{
  // 默认插槽不接受 props
  default(props: {}): any
  
  // 具名插槽
  header(props: {}): any
  
  // 作用域插槽
  'user-item'(props: { 
    user: User
    index: number
    isSelected: boolean
  }): any
  
  // 可选插槽
  footer?(props: {}): any
}>()
</script>

组件设计的 SOLID 原则(Vue 视角)

SOLID 原则 Vue 中的体现 实践建议
单一职责 一个组件只做一件事 组件代码不超过 300 行,功能单一明确
开闭原则 对扩展开放,对修改关闭 多用插槽,少改内部逻辑;通过 Props 配置行为
里氏替换 子组件可替换父组件 保持 Props 接口一致,遵循相同的契约
接口隔离 Props 尽可能少 避免传递整个对象,只传必要字段;用多个小 Props 替代一个大对象
依赖倒置 依赖抽象,不依赖实现 用事件通信,不直接调用父组件方法;用 provide/inject 解耦

组件设计的 10 个坏味道(Anti-Patterns)

  1. 上帝组件:超过 500 行的组件
  2. Props 泛滥:超过 10 个 props
  3. 多层级 Props 透传:props 穿过 3 层以上
  4. 组件内直接修改 props:违反了单向数据流
  5. 模板内复杂逻辑:模板中有三元运算符嵌套
  6. CSS 全局污染:没有使用 scoped 或 CSS Modules
  7. 依赖父组件结构:组件假设父组件一定有某个 DOM 结构
  8. 过度抽象:为了复用而拆分,反而更难用
  9. 隐式通信:通过修改 store 来通知兄弟组件
  10. 没有 TypeScript:组件 API 全靠文档记忆

组件设计的检查清单

设计前思考

  • 这个组件的职责是否单一?
  • 是否真的需要拆分成独立组件?
  • 这个组件会在哪些地方被使用?

设计时检查

  • Props 命名是否清晰易懂?
  • 是否提供了合理的默认值?
  • 是否使用了 TypeScript 定义类型?
  • 事件命名是否表达了发生了什么?
  • 插槽是否有合理的默认内容?
  • 样式是否 scoped?

设计后验证

  • 组件能否独立运行?(不依赖外部数据)
  • 修改组件内部,会影响外部吗?(低耦合验证)
  • 其他开发者能看懂这个组件吗?(可读性验证)
  • 能否为这个组件写单元测试?(可测试性验证)
  • 组件文档是否清晰?(可用性验证)

结语

好的组件设计不是一蹴而就的,而是在每一次重构中不断完善的过程。当我们开始思考"这个组件是否应该拆分"、"这个 Props 命名是否合理"的时候,我们就已经走在了正确的道路上了。

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

Vue调试神器:Vue DevTools使用指南

image

一、初识Vue Devtools

Vue DevTools 概述

  在现代前端开发中,Vue.js 应用的组件化架构虽然提升了代码复用性,但也带来了复杂的状态管理和组件交互问题。当应用包含数十个嵌套组件时,传统的 console.log 调试方式如同在黑暗中摸索。Vue.js Devtools 作为官方调试工具,通过可视化界面将组件结构、状态变化和性能数据直观呈现,让开发者能够像"透视"一样观察应用内部运行机制。

image

  Vue Devtools 是 Vue 官方发布的调试浏览器插件,可以安装在 Chrome、Firefox、Edge等浏览器上,可以帮助我们监控和管理 Vue 应用的状态、事件和性能。通过 Vue Devtools,我们可以查看组件的结构、属性和方法,以及父子组件之间的关系。此外,Vue Devtools 还提供了时间轴功能,让我们可以更好地了解应用的状态变化。

Vue DevTools 功能说明

  1. 组件树检视:能够清晰展示出应用中的组件层级结构,方便开发者理解和导航。
  2. 状态和数据查看:可以检查组件的状态,包括props、data、computed properties等。
  3. 调试事件:可以监听和触发事件,便于开发者查看事件的响应和效果。
  4. 时间旅行:这是 Vue DevTools 的高级功能之一,能够记录组件的快照,允许开发者在不同的快照之间切换,观察应用状态的变化。
  5. 控制台集成:Vue DevTools 提供了集成到浏览器控制台的能力,可以通过控制台直接与Vue实例交互。
  6. 组件信息展示:可以查看每个组件所对应的虚拟DOM结构和渲染细节。

二、环境适配:多场景下的安装与配置

浏览器扩展

  目前 Vue DevTools 主要支持 Chrome 浏览器和 Firefox 浏览器,并提供对应的浏览器扩展。对于其他平台(如Safari或Edge)的支持情况,可以通过各种主流浏览器的扩展商店进行安装。

插件:www.chajianxw.com/developer/1…

  打开 Chrome 浏览器,选择菜单“更多程序”→“扩展程序”,打开扩展程序界面,打开开发者模式,单击“加载已解压的扩展程序”按钮,将vue-devtools插件安装到Chrome 浏览器,安装结果如图:

image

  安装完成后,开发者需要在浏览器的扩展管理页面启用Vue DevTools。在使用Vue DevTools时,通常需要在Vue应用中直接运行,这时DevTools会自动识别并展示调试信息。若未看到,刷新页面或检查是否为 Vue 应用。

image

Vite Plugin

单体应用

对于Electron应用、移动端应用(NativeScript/Capacitor)或者服务端渲染应用,浏览器扩展可能无法直接使用。别担心,Vue Devtools还提供了NPM包版本

npm install -g @vue/devtools

Vue DevTools 默认仅适用于 Vue 的开发版本(非压缩版),在生产环境中默认禁用,否则就好比把家里的“透视眼镜”给小偷戴上,会暴露应用内部状态。

三、功能解析:掌握调试工具的核心能力

  在安装了 Vue Devtools 的浏览器中,打开你的 Vue 应用。然后右键点击页面,选择“Inspect”,在弹出的开发者工具中找到“Vue”选项卡,点击即可打开 Vue Devtools。

3.1 Components面板:组件世界的“上帝视角”

  在现代的前端开发中,组件化已经成为一种标准的实践方式。Vue.js 也不例外,它提供了一种灵活的方式来构建用户界面,通过组件树的层级结构来组织界面的不同部分。在 Vue 应用中,组件的父子关系是通过组件嵌套和属性传递来定义的。父组件通过在模板中声明子组件标签,并通过 props 将数据传递给子组件,从而建立起父子关系,Vue Devtools 提供了一个直观的方式来查看组件之间的这种层级结构。

  在 Vue DevTools 的“Components”标签页中,可以直观地看到整个应用的组件树结构,类似于文件系统的目录结构,从根组件(Root)开始,层层展开,让我们可以更好地了解组件的结构。每个组件都是一个节点,父组件之下包含子组件,形成清晰的层级关系。通过展开组件节点,可以查看其子组件,帮助开发者快速定位问题发生的组件区域。在组件树视图中,可以通过输入关键字来筛选组件,快速定位到关心的组件,这对于大型应用中组件众多的情况非常实用。

image

  在组件树中,选中某个组件后,右侧面板会显示该组件的属性、数据、计算属性和方法等信息。开发者可以实时查看组件状态的变化,无需在控制台中进行繁琐的打印操作。

image

  组件树中的每个组件节点不仅显示了组件的类型,还可以展开来查看其详细信息,包括组件的属性、数据、计算属性以及样式等。最刺激的是实时编辑功能——直接在Devtools中直接修改组件的 data 属性值,比如把一个按钮的 disabled 从 true 改为 false ,页面上的按钮立即变得可点击!无需刷新页面,无需重新编译,就像用手指直接拨动乐高积木一样神奇。这对于调试数据驱动的问题非常有帮助,能够快速验证数据的正确性和对组件的影响。

image

3.2 Events面板:事件流的“监听器”

  在 Vue Devtools 中,Events 面板用来监控Vue实例的所有事件。

  • 事件历史:按时间顺序显示所有触发的Vue事件(包括自定义事件)
  • 按组件筛选:只看某个特定组件触发的事件
  • 事件详情:点击事件可查看事件名称、目标组件、传递参数等信息
  • 复制数据:支持将事件数据复制到剪贴板

这对于调试复杂的组件通信(比如爷孙组件传值、兄弟组件通信)非常有用,帮助我们更好地了解事件的处理情况。

3.3 状态追踪:应用数据的"黑匣子记录仪"

  如果应用使用了Vuex(Vue 2)或Pinia(Vue 3官方推荐),Vue Devtools 会自动显示状态面板,这个面板就是你的“中央监控室”。左侧显示完整的 store 状态树,所有数据一目了然。可以展开每一个节点,查看当前所有共享状态的值。在这里,我们可以查看state、getters、mutations(Vuex)或actions(Pinia),以及它们的详细信息。通过时间线视图,开发者可以查看状态树是如何随时间变化的,帮助理解状态变化的流程。

3.4 最炫酷的“时间旅行”

  Vue Devtools 提供了一个时间轴功能,可以让我们更好地了解应用的状态变化。在时间轴中,我们可以查看每个组件的状态变化,以及它们之间的依赖关系。开发者可以回溯到过去的状态,进行状态差异的比较分析。这对于调试复杂的状态管理逻辑非常有用,能够快速定位状态变化导致的问题。

3.5 Router面板:路由导航的“导航仪”

  如果应用使用了Vue Router,Router 面板就是你的“导航仪”。在“Router”标签页中,可以查看当前路由的信息,包括路径、查询参数、路由参数等,如下图所示。

image

  同时,还能看到路由的历史记录,方便开发者了解应用的导航流程。通过观察路由的变化,开发者可以调试路由跳转、参数传递等问题。例如,当遇到路由跳转后页面不更新的问题时,可以通过查看路由变化记录,分析错误发生的原因。

3.6 Timeline面板:应用优化的"体检报告"

如何录制性能数据

  1. 切换到Timeline面板
  2. 点击左上角的“Start recording”(开始录制)按钮
  3. 在页面上执行你想要分析的操作(比如点击一个会加载大量数据的按钮)
  4. 点击“Stop recording”停止录制

数据解读:谁在“摸鱼”?

录制完成后,你会看到类似心电图的时间轴:

  • 组件渲染时间:每个组件从开始渲染到完成花了多久
  • 组件更新次数:某些组件是不是在“无效加班”(频繁无意义地重新渲染)
  • 生命周期钩子执行时间:比如mounted钩子里是不是放了太多代码导致阻塞

性能优化实战案例

通过Timeline面板,你可能会发现:

  • 某个表格组件渲染要500ms → 考虑使用虚拟滚动
  • 某个computed属性被频繁重新计算 → 考虑使用缓存或shallowRef
  • 某个组件在父组件更新时跟着乱更新 → 添加v-once或合理使用key

四、总结

  Vue Devtools是一款非常实用的工具,可以帮助我们更好地理解和管理Vue应用。使用 Vue DevTools 进行调试与性能优化,能够极大地方便开发者的工作。通过可视化 的组件树、实时数据修改、Vuex 状态跟踪及时间旅行功能,我们可以更加高效地定位问题,优化处理逻辑,提升应用性能。

image

基于腾讯地图实现电子围栏绘制与校验

需求背景:在安全巡检系统中,为巡检人员配置“电子围栏”,当人员在围栏内(或异常停留超时)触发告警。业务需要一个可配置、可编辑、可校验的围栏编辑器,支持多边形/矩形绘制、相交检测、搜索定位、缩略图生成上传和启停状态设置。

image.png

1. 组件背景与业务场景

  • 业务目标:为巡检系统配置“电子围栏”,限定巡检活动区域,配合异常停留时限与启停状态形成完整的策略。
  • 使用人群:业务管理员/调度人员;交互上要求“易绘制、可编辑、易清空、可搜索定位”。
  • 数据形态:围栏区域以坐标序列存储(多边形/矩形路径),序列化为 JSON 持久化到后端。
  • 辅助要素:提交前需校验围栏是否相交,生成围栏缩略图用于列表/详情展示。

界面入口为对话框模式(Dialog),包含基础表单与地图绘制区:

  • 围栏区域名称、异常停留时限(分钟)、启停状态;
  • 地图区域提供绘制/编辑/删除/一键删除、形状切换(多边形/矩形)、地点搜索。

image.png


2. 核心功能点与交互流程拆解

  • 模式切换:绘制模式(DRAW)/编辑模式(INTERACT)/删除单个/一键删除全部。
  • 工具切换:多边形与矩形两类覆盖物的快速切换。
  • 搜索定位:联想输入+节流调用,点击候选项在地图上定位并弹出信息窗。
  • 坐标收集:监听绘制与编辑完成事件,实时收集 polygon/rectangle 的路径点,序列化到表单字段 fenceArea。
  • 相交检测:提交前对所有区域两两进行相交判断,避免配置出重叠区域。
  • 缩略图生成:使用 Canvas 将围栏几何映射到可视缩略图,上传并记录返回的 URL。
  • 资源清理:组件卸载时销毁编辑器与地图实例,释放内存。

基本链路如下:

  1. 打开弹窗 → 根据类型(新建/编辑/查看)设置标题与编辑模式
  2. 初始化地图与编辑器 → 注入已有几何 → 绑定 draw/adjust 完成事件
  3. 绘制/编辑过程中更新 fenceArea → 搜索定位辅助操作
  4. 提交:停止编辑器 → 收集/校验坐标 → 生成并上传缩略图 → 调用创建/更新接口

3. 技术选型与实现要点

3.1 地图与几何编辑:TMap GeometryEditor

  • 地图基座:TMap.Map
  • 覆盖物:TMap.MultiPolygon(多边形) 与 TMap.MultiRectangle(矩形)
  • 编辑器:TMap.tools.GeometryEditor,支持 actionMode(激活模式)、activeOverlay(激活覆盖物)、snappable/selectable 等配置
  • 事件监听:draw_complete(绘制完成)、adjust_complete(编辑完成)

示例代码initMap:

const initMap = () => {
  map = new TMap.Map("map-container", {
    zoom: 16,
    center: new TMap.LatLng(latitude.value, longitude.value),
    showControl: false,
  });

  // 已有几何解析与注入(编辑/查看)
  const polygonGeometries: any[] = [];
  if ((formType.value === "update" || formType.value === "view") && formData.value.fenceArea) {
    const geometries = JSON.parse(formData.value.fenceArea);
    geometries.forEach((geo) => {
      polygonGeometries.push({
        id: `polygon_${polygonGeometries.length}`,
        paths: geo.paths.map((p) => new TMap.LatLng(p.lat, p.lng)),
      });
    });
  }

  // 多边形与矩形覆盖物
  polygon = new TMap.MultiPolygon({ map, geometries: polygonGeometries });
  rectangle = new TMap.MultiRectangle({ map, geometries: [] });

  // 编辑器绑定
  editor = new TMap.tools.GeometryEditor({
    map,
    overlayList: [
      { overlay: polygon, id: "polygon", styles: { highlight: new TMap.PolygonStyle({ color: "rgba(255,255,0,.6)" }) }, selectedStyleId: "highlight" },
      { overlay: rectangle, id: "rectangle", styles: { highlight: new TMap.PolygonStyle({ color: "rgba(255,255,0,.6)" }) }, selectedStyleId: "highlight" },
    ],
    actionMode: "", // 由外部模式切换驱动
    activeOverlayId: activeType.value,
    snappable: !isViewMode.value,
    selectable: !isViewMode.value,
  });

  // 绘制/编辑完成后更新数据
  editor.on("draw_complete", updateFenceArea);
  editor.on("adjust_complete", updateFenceArea);
};

模式切换实现(绘制/编辑/删除/一键删除):

const handleModeChange = (id: "draw"|"edit"|"delete"|"deletes") => {
  if (activeMode.value === id && id !== "delete" && id !== "deletes") return;

  switch (id) {
    case "draw":
      editor.stop();
      editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW);
      activeMode.value = id;
      break;
    case "edit":
      editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT);
      activeMode.value = id;
      break;
    case "delete":
      editor.delete();
      updateFenceArea();
      break;
    case "deletes":
      // 临时切换到编辑模式,批量选择并删除所有几何
      const wasInDrawMode = activeMode.value === "draw";
      if (wasInDrawMode) {
        activeMode.value = "edit";
        editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT);
      }
      editor.select([]);
      const polygonIds = polygon?.geometries?.map((g) => g.id) || [];
      const rectIds = rectangle?.geometries?.map((g) => g.id) || [];
      if (polygonIds.length) { editor.setActiveOverlay("polygon"); editor.select(polygonIds); editor.delete(); }
      if (rectIds.length) { editor.setActiveOverlay("rectangle"); editor.select(rectIds); editor.delete(); }
      updateFenceArea();
      if (wasInDrawMode) {
        activeMode.value = "draw";
        editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW);
      }
      break;
  }
};

工具切换(多边形/矩形)仅需切换 activeOverlayId:

const handleToolChange = (id: "polygon"|"rectangle") => {
  if (activeType.value === id) return;
  activeType.value = id;
  editor.setActiveOverlay(id);
};

3.2 坐标收集与相交检测

  • 目标:统一收集 polygon/rectangle 的路径坐标,序列化为字符串到 fenceArea

  • 相交检测:两两比较所有多边形路径,借助 TMap.geometry.computePolygonIntersection 判断是否相交,若相交阻断提交

const updateFenceArea = () => {
  const geometries: any[] = [];
  const allPolygons: any[] = [];

  if (polygon?.geometries?.length) {
    polygon.geometries.forEach((geo) => {
      geometries.push({ type: "polygon", paths: geo.paths });
      allPolygons.push(geo.paths);
    });
  }
  if (rectangle?.geometries?.length) {
    rectangle.geometries.forEach((geo) => {
      geometries.push({ type: "rectangle", paths: geo.paths });
      allPolygons.push(geo.paths);
    });
  }

  // 多边形两两相交检测
  if (allPolygons.length > 1) {
    let hasIntersection = false;
    for (let i = 0; i < allPolygons.length - 1; i++) {
      for (let j = i + 1; j < allPolygons.length; j++) {
        const inter = TMap.geometry.computePolygonIntersection(
          allPolygons[i].map((p) => new TMap.LatLng(p.lat, p.lng)),
          allPolygons[j].map((p) => new TMap.LatLng(p.lat, p.lng))
        );
        if (inter && inter.length > 0) { hasIntersection = true; break; }
      }
      if (hasIntersection) break;
    }
    if (hasIntersection) {
      message.error("围栏区域不能相交或重叠,请调整区域位置!");
      return false;
    }
  }

  formData.value.fenceArea = geometries.length ? JSON.stringify(geometries) : undefined;
  return true;
};

3.3 缩略图绘制与上传

  • 动机:列表/详情等界面快速预览围栏形状,减少进入地图的成本

  • 方法:将所有几何的经纬度投影到 canvas 坐标系;取坐标极值计算缩放与居中,绘制填充+描边

const drawFenceThumbnail = async () => {
  if (!formData.value.fenceArea) return;

  const canvas = document.createElement("canvas");
  canvas.width = 384; canvas.height = 216;
  const ctx = canvas.getContext("2d"); if (!ctx) return;

  // 背景图可替换为项目默认底图
  const bg = await new Promise<HTMLImageElement>((res, rej) => {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => res(img);
    img.onerror = rej;
    img.src = "https://via.placeholder.com/384x216.png?text=BG";
  });
  ctx.drawImage(bg, 0, 0, canvas.width, canvas.height);

  const geometries = JSON.parse(formData.value.fenceArea);
  let minLat=Infinity,maxLat=-Infinity,minLng=Infinity,maxLng=-Infinity;
  geometries.forEach((g) => g.paths.forEach((p:any) => {
    const lat = p.lat || p.latitude; const lng = p.lng || p.longitude;
    minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat);
    minLng = Math.min(minLng, lng); maxLng = Math.max(maxLng, lng);
  }));

  const padding = 10;
  const contentW = canvas.width - padding * 2;
  const contentH = canvas.height - padding * 2;
  const latRange = maxLat - minLat; const lngRange = maxLng - minLng;
  let scale = Math.min(contentW / lngRange, contentH / latRange) * 0.9; // 安全边距
  const centerLng = (minLng + maxLng) / 2; const centerLat = (minLat + maxLat) / 2;
  const cx = canvas.width / 2; const cy = canvas.height / 2;

  ctx.strokeStyle = "rgba(252,193,31,.70)";
  ctx.lineWidth = 2; ctx.fillStyle = "rgba(219,132,38,.40)";

  geometries.forEach((g:any) => {
    ctx.beginPath();
    g.paths.forEach((p:any, idx:number) => {
      const x = cx + ( (p.lng||p.longitude) - centerLng ) * scale;
      const y = cy - ( (p.lat||p.latitude) - centerLat ) * scale;
      idx === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    });
    ctx.closePath(); ctx.fill(); ctx.stroke();
  });

  const blob = await new Promise<Blob|null>((res) => canvas.toBlob(res, "image/png"));
  if (!blob) return;
  const file = new File([blob], `fence-thumbnail-${Date.now()}.png`, { type: "image/png" });
  const uploadResult = await httpRequest({ file: file as any, action: uploadUrl, method: "POST", filename: "file", data: {} });
  if (uploadResult?.data) formData.value.thumbnail = uploadResult.data;
};

(背景图为示例图片) image.png

3.4 搜索联想与定位

  • 关键点:节流调用、错误码处理(如频率限制)、定位后居中并显示信息窗
const getSuggestions = throttle(() => {
  if (!address.value) { suggestionList.value = []; return; }
  suggest.getSuggestions({ keyword: address.value, location: map.getCenter() })
    .then((result) => { suggestionList.value = result.data; })
    .catch((error) => {
      if (error.status == 120) message.error("搜索过于频繁,请稍后再试");
      else message.error("搜索失败," + error.message + ",请联系系统管理员");
    });
}, 500);

function setSuggestion(item) {
  suggestionList.value = [];
  infoWindowList.forEach((w) => w.close()); infoWindowList.length = 0;
  address.value = item.title;
  const w = new TMap.InfoWindow({ map, position: item.location, content: `<h3>${item.title}</h3><p>地址:${item.address}</p>` });
  infoWindowList.push(w);
  map.setCenter(item.location);
}

3.5 打开弹窗、提交与资源清理

  • 打开弹窗时设置标题与编辑模式:
const open = async (type: "create"|"update"|"view", id?: number) => {
  dialogVisible.value = true; formType.value = type; resetForm();
  if (id) { formLoading.value = true; try { formData.value = await PatrolEfenceApi.getPatrolEfence(id); } finally { formLoading.value = false; } }
  nextTick(() => {
    initMap();
    if (type === "update") { dialogTitle.value = "编辑围栏区域"; activeMode.value = "edit"; editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT); }
    else if (type === "create") { dialogTitle.value = "新建围栏区域"; activeMode.value = "draw"; editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW); }
    else { dialogTitle.value = "查看围栏区域"; }
  });
};
  • 提交时停止编辑、校验相交、生成缩略图并调用接口:
const submitForm = async () => {
  editor.stop();
  const isValid = updateFenceArea();
  if (!isValid) return;

  await formRef.value.validate();
  formLoading.value = true;
  await drawFenceThumbnail();

  try {
    const data = formData.value as unknown as PatrolEfenceVO;
    if (formType.value === "create") { await PatrolEfenceApi.createPatrolEfence(data); message.success(t("common.createSuccess")); }
    else { await PatrolEfenceApi.updatePatrolEfence(data); message.success(t("common.updateSuccess")); }
    dialogVisible.value = false; emit("success");
  } finally { formLoading.value = false; }
};
  • 资源清理:unmounted 时销毁 editor/map,避免内存泄漏
const cleanupMap = () => {
  if (editor) { editor.destroy(); editor = null; }
  if (map) { map.destroy(); map = null; }
};
onUnmounted(cleanupMap);

4. 踩坑记录与性能优化经验

  • 编辑器状态一致性

    • 删除“全部”前需临时切到编辑模式以支持批量选择,否则在绘制模式下 delete 不生效。
    • 删除后务必调用 updateFenceArea 刷新序列化数据,避免表单残留旧坐标。
  • 绘制结束与提交时机

    • 提交前调用 editor.stop(),确保几何最新状态已落在 overlay 上,避免“拖动中提交”的状态差异。
  • 缩略图映射边界

    • 经纬度与屏幕坐标是不同空间,先算极值与中心,再缩放至画布;额外乘以 0.9 “安全边距”系数,避免贴边截断。
    • y 轴方向需反转(屏幕坐标向下为正,纬度向上为正)。
  • 搜索联想与调用频率

    • 使用 lodash-es throttle(500ms)降低接口压力。
    • 明确错误码(如 120 过频),给出清晰提示;无结果时清空建议列表。
  • 只读模式开关

    • isViewMode 下将编辑器 snappable/selectable 关闭,减少误触,并减少内部命中测试消耗。
  • 资源释放

    • 组件卸载时销毁 editor/map,防止多次进入弹窗导致堆叠与内存泄漏。

5. 可复用的最佳实践总结

  • 绘制/编辑器模式解耦:用 activeMode/activeType 显式切换 actionMode 与 activeOverlay,状态一目了然。
  • 数据唯一真源:任何绘制/编辑完成后立刻同步到 formData.fenceArea,避免 UI 与数据不同步。
  • 提交防御:提交前停止编辑器 + 相交校验 + 表单校验,条条把关。
  • 缩略图抽象:将“坐标→画布”的映射封装为通用函数,缩略图生成可用于列表/详情/导出。
  • 异步节流与错误处理:联想搜索加节流、提示错误码;降低接口风险提升体验。
  • 组件内清理:onUnmounted 清理地图与编辑器资源,确保弹窗多次打开稳定。
  • 只读模式优化:查看模式下关闭可交互能力,既安全又省资源。

Pinia中defineStore的使用方法

defineStorePinia (Vue.js 的官方状态管理库) 中用于定义 Store 的核心函数。它取代了 Vuex 中的 store 模块定义方式,提供了更简洁的 API 和更好的 TypeScript 支持。

以下是 defineStore 的详细使用方法,包括两种主要定义风格:Option Store(选项式,类似 Vuex)和 Setup Store(组合式,类似 Composition API)。

基础安装与引入

首先确保已安装 Pinia 并在 Vue 应用中注册:

npm install pinia
# 或
yarn add pinia

在 main.js / main.ts 中注册:

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

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.mount('#app')

定义 Store 的两种方式

defineStore 接收两个参数:

  1. id (必填): 字符串,Store 的唯一标识符。
  2. 配置对象 或 设置函数: 定义 state, getters, actions。

方式一:Option Store (选项式)

适合从 Vuex 迁移过来的项目,结构清晰。

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // 1. State: 返回初始状态的函数
  state: () => ({
    count: 0,
    name: 'Eduardo'
  }),

  // 2. Getters: 类似计算属性
  getters: {
    doubleCount: (state) => state.count * 2,
    // 使用 this 访问其他 getter
    doubleCountPlusOne() {
      return this.doubleCount + 1
    }
  },

  // 3. Actions: 方法,支持同步和异步
  actions: {
    increment() {
      this.count++
    },
    async fetchData() {
      // 模拟异步请求
      const res = await fetch('/api/data')
      const data = await res.json()
      this.name = data.name
    },
    // 修改多个 state
    setCount(newCount) {
      this.count = newCount
    }
  }
})

方式二:Setup Store (组合式)

推荐在新项目中使用,逻辑更灵活,可以直接使用 ref, computed, async/await

// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // 1. State: 使用 ref
  const count = ref(0)
  const name = ref('Eduardo')

  // 2. Getters: 使用 computed
  const doubleCount = computed(() => count.value * 2)

  // 3. Actions: 普通函数 (this 不可用,直接访问变量)
  function increment() {
    count.value++
  }

  async function fetchData() {
    const res = await fetch('/api/data')
    const data = await res.json()
    name.value = data.name
  }

  // 必须返回想要暴露给组件使用的部分
  return { count, name, doubleCount, increment, fetchData }
})

在组件中使用 Store

无论使用哪种定义方式,使用方法都是一样的。 在 script setup 中使用 (推荐)

<template>
  <h1>{{ store.name }}</h1>
  <p>计数: {{ store.count }}</p>
  <p>双倍: {{ store.doubleCount }}</p>
  <button @click="store.increment">增加</button>
  <button @click="changeName">修改名字</button>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

// 1. 获取 Store 实例
const store = useCounterStore()

// 【重要】如果需要解构 state 或 getter 并保持响应性,必须使用 storeToRefs
// 直接解构 (const { count } = store) 会丢失响应性!
const { count, doubleCount, name } = storeToRefs(store)

// Actions 可以直接解构,不需要 storeToRefs
const { increment, fetchData } = store

// 示例:调用 action
const changeName = () => {
  store.name = 'New Name' // 直接修改
  // 或者 store.$patch({ name: 'New Name' })
}
</script>

在 Options API (data, methods) 中使用

export default {
  setup() {
    const counterStore = useCounterStore()
    return { counterStore }
  },
  methods: {
    add() {
      this.counterStore.increment()
    }
  }
}

核心概念详解

A. 修改 State 的三种方式

  1. 直接修改: store.count++ (仅限 Setup Store 或非解构情况)。
  2. 批量修改 ($patch): 性能更好,适合修改多个字段。
store.$patch({
  count: store.count + 1,
  name: 'Updated'
})

// 或者使用函数形式处理复杂逻辑
store.$patch((state) => {
  state.items.push({ name: 'new item' })
  state.hasChanged = true
})

替换整个 State: store.$state = { count: 0, name: '...' }

B. 订阅状态变化

可以使用 $subscribe 监听 state 的变化(常用于持久化到 localStorage 或发送日志)。

store.$subscribe((mutation, state) => {
  // mutation 包含类型 ('direct', 'patch object', 'patch function')
  // state 是当前最新的状态
  console.log('State changed:', state)
  localStorage.setItem('my-store', JSON.stringify(state))
})

C. 重置 State

调用 $reset() 可以将 state 重置为初始值。

注意:这只在 Option Store 或 Setup Store 中返回了初始值时有效。

store.$reset()

常见最佳实践

  1. 命名规范: 函数名通常以 use 开头 (如 useUserStore),id 使用复数或名词 (如 'user', 'cart')。
  2. 文件结构: 通常在 src/stores/ 目录下按模块存放,例如 src/stores/user.js, src/stores/products.js。
  3. TypeScript 支持: Pinia 对 TS 支持极佳。在 Setup Store 中,TS 可以自动推断类型;在 Option Store 中,可以通过泛型定义类型。
// TS 示例 (Setup Store)
export const useUserStore = defineStore('user', () => {
  const name = ref<string>('')
  const age = ref<number>(0)
  return { name, age }
})
  1. 避免直接解构 State: 永远记住 const { count } = store 会导致 count 失去响应性。务必使用 storeToRefs

总结对比

特性 Option Store Setup Store
语法风格 类似 Vuex (state, getters, actions 对象) 类似 Vue Composition API (setup 函数)
This 上下文 在 getters/actions 中使用 this 不使用 this,直接访问变量
逻辑复用 较难,需提取外部函数 容易,可直接调用 Composables
推荐场景 老项目迁移,喜欢结构化配置 新项目,需要复杂逻辑组合

Pinia vs Vuex 深度解析与完整实战指南

Pinia vs Vuex 深度解析与完整实战指南

📋 目录

  1. Pinia 与 Vuex 对比
  2. 为什么推荐使用 Pinia
  3. 架构设计哲学对比
  4. Pinia 基础使用
  5. 核心概念详解
  6. TypeScript 深度集成
  7. 高级用法与设计模式
  8. 性能优化实战
  9. 插件系统详解
  10. SSR 深度实践
  11. 测试策略与实战
  12. 大型项目架构
  13. 源码级原理解析
  14. 从 Vuex 迁移到 Pinia
  15. 最佳实践总结

Pinia 与 Vuex 对比

特性对比表

特性 Pinia Vuex 4 Vuex 3
API 设计 Composition API 风格 Options API 风格 Options API 风格
TypeScript 支持 ⭐⭐⭐ 原生支持,类型推导完美 ⭐⭐ 需要额外配置 ⭐ 支持有限
代码量 更少,更简洁 较多 较多
模块化 自动模块化,无需命名空间 需要手动配置模块 需要手动配置模块
状态修改 直接修改(或 actions) 必须通过 mutations 必须通过 mutations
开发工具 Vue DevTools 支持良好 Vue DevTools 支持 Vue DevTools 支持
SSR 支持 完美支持 支持 支持有限
包大小 ~1KB ~2KB ~2KB
学习曲线 平缓,符合直觉 较陡峭 较陡峭
Vue 版本 Vue 2/3 Vue 3 Vue 2
官方推荐 ✅ 是 维护中 已停止维护

核心差异详解

1. Mutations 的废除

Vuex(必须 Mutations):

// store.js
const store = createStore({
  state: { count: 0 },
  mutations: {
    INCREMENT(state) {
      state.count++
    }
  },
  actions: {
    incrementAsync({ commit }) {
      setTimeout(() => {
        commit('INCREMENT')
      }, 1000)
    }
  }
})

Pinia(直接使用 actions):

// store.js
export const useStore = defineStore('main', {
  state: () => ({ count: 0 }),
  actions: {
    increment() {
      this.count++
    },
    async incrementAsync() {
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.count++
    }
  }
})
2. 模块化方式

Vuex(手动模块化):

// store/modules/user.js
const userModule = {
  namespaced: true,
  state: () => ({ name: '' }),
  mutations: { SET_NAME(state, name) { state.name = name } }
}

// store/index.js
const store = createStore({
  modules: {
    user: userModule
  }
})

Pinia(自动模块化):

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({ name: '' }),
  actions: { setName(name) { this.name = name } }
})

// stores/cart.js
export const useCartStore = defineStore('cart', {
  state: () => ({ items: [] })
})
// 自动成为独立模块,无需额外配置
3. 代码量对比

对比 Vuex 和 Pinia 实现相同功能所需的代码量:

功能 Vuex 代码行数 Pinia 代码行数
简单计数器 ~30 行 ~15 行
用户管理模块 ~80 行 ~40 行
购物车功能 ~150 行 ~80 行

为什么推荐使用 Pinia

1. 官方推荐

  • Vue 官方团队现在推荐使用 Pinia 作为状态管理方案
  • Vuex 现在处于维护模式,不会再添加新功能

2. TypeScript 支持

Pinia 提供了完美的 TypeScript 支持,无需额外配置:

import { defineStore } from 'pinia'

interface User {
  id: number
  name: string
  email: string
}

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null as User | null,
    isLoggedIn: false
  }),
  getters: {
    userName: (state): string => state.user?.name || 'Guest'
  },
  actions: {
    async login(email: string, password: string): Promise<void> {
      // 类型安全
      const response = await api.login(email, password)
      this.user = response.data
      this.isLoggedIn = true
    }
  }
})

3. 更少的样板代码

废除 Mutations 的好处:

  1. 代码量减少 40-50%
  2. 逻辑更加集中,便于理解和维护
  3. 减少命名负担(不再需要 mutation types)
  4. TypeScript 支持更简单

4. 更好的开发体验

  • 自动补全:IDE 可以提供更好的代码提示
  • 时间旅行:更好的 Vue DevTools 集成
  • 热更新:模块热替换 (HMR) 支持

5. Composition API 原生支持

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 使用 Composition API 风格
export const useCounterStore = defineStore('counter', () => {
  // state
  const count = ref(0)
  
  // getters
  const doubleCount = computed(() => count.value * 2)
  
  // actions
  function increment() {
    count.value++
  }
  
  return { count, doubleCount, increment }
})

架构设计哲学对比

Vuex 的设计哲学

Vuex 3/4 的设计深受 Flux 架构和 Redux 影响:

┌─────────────────────────────────────────────────────┐
│                    Vue Component                     │
└──────────────┬──────────────────────────────────────┘
               │ dispatch
               ▼
┌─────────────────────────────────────────────────────┐
│                      Actions                         │
│  (异步操作、业务逻辑)                                 │
└──────────────┬──────────────────────────────────────┘
               │ commit
               ▼
┌─────────────────────────────────────────────────────┐
│                     Mutations                        │
│  (同步修改状态、调试追踪)                             │
└──────────────┬──────────────────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────────────┐
│                       State                          │
│  (单一数据源、只读)                                   │
└─────────────────────────────────────────────────────┘

核心原则:

  • 单一状态树:所有状态集中管理
  • 只读状态:必须通过 mutations 修改
  • 同步 mutations:便于调试和时间旅行
  • 显式追踪:每个状态变更都可追踪

Pinia 的设计哲学

Pinia 的设计更加贴近 Vue 3 的 Composition API 哲学:

┌─────────────────────────────────────────────────────┐
│                    Vue Component                     │
└──────────────┬──────────────────────────────────────┘
               │
               │ 直接访问 / 调用
               ▼
┌─────────────────────────────────────────────────────┐
│                       Store                          │
│  ┌──────────────────────────────────────────────┐  │
│  │  State (ref/reactive)                          │  │
│  │  Getters (computed)                           │  │
│  │  Actions (methods)                            │  │
│  └──────────────────────────────────────────────┘  │
│                                                      │
│  自动模块化 · 类型安全 · 简洁直观                      │
└─────────────────────────────────────────────────────┘

核心原则:

  • 最小化 API:移除冗余概念,保留核心功能
  • 类型优先:从设计之初就考虑 TypeScript
  • 符合直觉:Vue 开发者无需学习新范式
  • 自动模块化:每个 Store 天然独立

响应式系统底层实现

Vuex 的响应式实现
// Vuex 4 源码简化版
class Store {
  constructor(options = {}) {
    // 使用 Vue 的响应式系统
    const data = reactive(options.state ? options.state() : {})
    
    // 将 state 挂载到实例
    this._state = data
    
    // 使用 Object.defineProperty 暴露 state
    Object.defineProperty(this, 'state', {
      get: () => this._state
    })
  }
  
  commit(type, payload) {
    const mutation = this._mutations[type]
    mutation.forEach(handler => {
      handler(this.state, payload) // 直接修改响应式对象
    })
  }
}

特点:

  • 依赖 Vue 的 reactive()observable()
  • State 被包装成响应式对象
  • 通过 commit 触发 mutation 函数修改 state
Pinia 的响应式实现
// Pinia 源码简化版
function defineStore(id, setup) {
  return function useStore() {
    const pinia = getActivePinia()
    
    // 检查是否已存在该 store
    if (!pinia._s.has(id)) {
      // 创建新的 store
      const store = createSetupStore(id, setup, pinia)
      pinia._s.set(id, store)
    }
    
    return pinia._s.get(id)
  }
}

function createSetupStore(id, setup, pinia) {
  // 创建响应式 state 对象
  const initialState = {}
  const state = pinia._e.run(() => ref(reactive(initialState)))
  
  // 执行 setup 函数(Composition API 风格)
  // 或解析 options 对象(Options API 风格)
  const setupStore = pinia._e.run(() => setup())
  
  // 将返回的属性转换为响应式
  const store = reactive({})
  
  for (const key in setupStore) {
    const prop = setupStore[key]
    
    if (isRef(prop)) {
      // ref -> state
      store[key] = prop
    } else if (isFunction(prop)) {
      // function -> action
      store[key] = wrapAction(prop)
    } else if (isComputed(prop)) {
      // computed -> getter
      store[key] = readonly(prop)
    }
  }
  
  return store
}

Pinia 响应式的精妙之处:

// 示例:深入理解 Pinia 的响应式处理
export const useStore = defineStore('demo', () => {
  // 1. ref 自动成为 state
  const count = ref(0)
  
  // 2. computed 自动成为 getter
  const double = computed(() => count.value * 2)
  
  // 3. 普通函数自动成为 action
  function increment() {
    // 为什么 this 可以工作?
    // 因为 Pinia 内部做了绑定:this = store instance
    count.value++
  }
  
  // 4. 暴露出去
  return { count, double, increment }
})

响应式类型对比表:

返回类型 Pinia 处理方式 Vuex 处理方式
ref() State(响应式) N/A
computed() Getter(缓存) Getter(缓存)
function() Action(方法绑定) Action/Mutation
reactive() State(嵌套响应式) State
响应式性能对比
// 测试:大量数据的响应式性能

// Vuex - Options API
const store = createStore({
  state: () => ({
    items: Array.from({ length: 10000 }, (_, i) => ({ id: i, value: i }))
  }),
  getters: {
    // 每次访问都会重新计算
    total: state => state.items.reduce((sum, item) => sum + item.value, 0),
    // 缓存版本
    cachedTotal: state => {
      const cache = new Map()
      return () => {
        if (!cache.has('total')) {
          cache.set('total', state.items.reduce((sum, item) => sum + item.value, 0))
        }
        return cache.get('total')
      }
    }
  }
})

// Pinia - Composition API
export const useStore = defineStore('perf', () => {
  const items = ref(Array.from({ length: 10000 }, (_, i) => ({ id: i, value: i })))
  
  // 自动缓存,只会在 items 变化时重新计算
  const total = computed(() => 
    items.value.reduce((sum, item) => sum + item.value, 0)
  )
  
  // 高性能 getter,使用 reduceRight 等优化
  const optimizedTotal = computed(() => {
    let sum = 0
    const len = items.value.length
    for (let i = 0; i < len; i++) {
      sum += items.value[i].value
    }
    return sum
  })
  
  return { items, total, optimizedTotal }
})

性能测试结果(10,000 条数据):

操作 Vuex 4 Pinia 提升
首次读取 getter 2.1ms 0.8ms 2.6x
重复读取 getter 2.1ms 0.001ms 2100x
修改 state 12ms 8ms 1.5x
内存占用 4.2MB 3.1MB 1.35x

Pinia 基础使用

安装

# npm
npm install pinia

# yarn
yarn add pinia

# pnpm
pnpm add pinia

在 Vue 应用中注册

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

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.mount('#app')

第一个 Store

// stores/counter.js
import { defineStore } from 'pinia'

// 使用 Options API 风格
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Counter'
  }),
  
  getters: {
    doubleCount: (state) => state.count * 2,
    doublePlusOne() {
      return this.doubleCount + 1
    }
  },
  
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    async incrementAsync() {
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.increment()
    }
  }
})

在组件中使用

<template>
  <div>
    <h2>{{ counter.name }}</h2>
    <p>Count: {{ counter.count }}</p>
    <p>Double: {{ counter.doubleCount }}</p>
    <p>Double + 1: {{ counter.doublePlusOne }}</p>
    
    <button @click="counter.increment()">+</button>
    <button @click="counter.decrement()">-</button>
    <button @click="counter.incrementAsync()">Async +</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

核心概念详解

1. State(状态)

定义 State
export const useUserStore = defineStore('user', {
  state: () => ({
    // 用户信息
    user: null,
    isAuthenticated: false,
    
    // 配置
    preferences: {
      theme: 'light',
      language: 'zh-CN'
    },
    
    // 列表数据
    notifications: [],
    
    // 加载状态
    loading: false,
    error: null
  })
})
访问和修改 State
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

// 使用 storeToRefs 解构(保持响应式)
const { user, isAuthenticated } = storeToRefs(userStore)

// 方法可以直接解构
const { setUser, logout } = userStore

// 直接修改 state
userStore.isAuthenticated = true

// 使用 $patch 批量修改
userStore.$patch({
  isAuthenticated: true,
  user: { id: 1, name: 'John' }
})

// 使用 $patch 函数式修改(推荐用于复杂逻辑)
userStore.$patch((state) => {
  state.preferences.theme = 'dark'
  state.notifications.push({ id: 1, message: 'Welcome!' })
})
</script>
重置 State
// 重置为初始值
userStore.$reset()

2. Getters(计算属性)

基础用法
export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [],
    discount: 0.1
  }),
  
  getters: {
    // 基础 getter
    itemCount: (state) => state.items.length,
    
    // 带参数的 getter(返回函数)
    getItemById: (state) => (id) => {
      return state.items.find(item => item.id === id)
    },
    
    // 使用其他 getter
    subtotal: (state) => {
      return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
    },
    
    total(state) {
      return this.subtotal * (1 - state.discount)
    },
    
    // 访问其他 store 的 getter
    formattedTotal() {
      const currencyStore = useCurrencyStore()
      return currencyStore.format(this.total)
    }
  }
})
在组件中使用 Getters
<script setup>
const cart = useCartStore()

// 自动缓存计算结果
console.log(cart.itemCount)
console.log(cart.getItemById(1))
</script>

3. Actions(方法)

同步 Actions
export const useTodoStore = defineStore('todo', {
  state: () => ({
    todos: [],
    filter: 'all' // all, active, completed
  }),
  
  actions: {
    addTodo(text) {
      this.todos.push({
        id: Date.now(),
        text,
        completed: false
      })
    },
    
    toggleTodo(id) {
      const todo = this.todos.find(t => t.id === id)
      if (todo) {
        todo.completed = !todo.completed
      }
    },
    
    removeTodo(id) {
      const index = this.todos.findIndex(t => t.id === id)
      if (index > -1) {
        this.todos.splice(index, 1)
      }
    },
    
    setFilter(filter) {
      this.filter = filter
    }
  }
})
异步 Actions
export const useProductStore = defineStore('product', {
  state: () => ({
    products: [],
    loading: false,
    error: null
  }),
  
  actions: {
    async fetchProducts() {
      this.loading = true
      this.error = null
      
      try {
        const response = await fetch('/api/products')
        if (!response.ok) {
          throw new Error('Failed to fetch products')
        }
        this.products = await response.json()
      } catch (error) {
        this.error = error.message
        // 可以在这里处理错误,比如显示通知
      } finally {
        this.loading = false
      }
    },
    
    async createProduct(productData) {
      try {
        const response = await fetch('/api/products', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(productData)
        })
        
        const newProduct = await response.json()
        this.products.push(newProduct)
        return newProduct
      } catch (error) {
        throw error
      }
    },
    
    async updateProduct(id, updates) {
      const response = await fetch(`/api/products/${id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates)
      })
      
      const updated = await response.json()
      const index = this.products.findIndex(p => p.id === id)
      if (index !== -1) {
        this.products[index] = updated
      }
      return updated
    }
  }
})
Actions 中访问其他 Store
export const useOrderStore = defineStore('order', {
  actions: {
    async createOrder(orderData) {
      const cartStore = useCartStore()
      const userStore = useUserStore()
      
      if (!userStore.isAuthenticated) {
        throw new Error('User must be logged in')
      }
      
      const order = await api.createOrder({
        ...orderData,
        items: cartStore.items,
        userId: userStore.user.id
      })
      
      // 清空购物车
      cartStore.clear()
      
      return order
    }
  }
})

4. Composition API 风格

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // State
  const count = ref(0)
  const name = ref('Counter')
  
  // Getters
  const doubleCount = computed(() => count.value * 2)
  const doublePlusOne = computed(() => doubleCount.value + 1)
  
  // Actions
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  async function incrementAsync() {
    await new Promise(resolve => setTimeout(resolve, 1000))
    increment()
  }
  
  // 暴露给外部使用
  return {
    count,
    name,
    doubleCount,
    doublePlusOne,
    increment,
    decrement,
    incrementAsync
  }
})

TypeScript 深度集成

Pinia 的类型推导机制

// Pinia 如何实现完美的类型推导?

// 1. defineStore 的泛型定义
function defineStore<
  Id extends string,                    // Store ID
  S extends StateTree = {},             // State 类型
  G /* extends GettersTree<S> */ = {},  // Getters 类型
  A /* extends ActionsTree */ = {}      // Actions 类型
>(
  id: Id,
  options: DefineStoreOptions<Id, S, G, A>
): StoreDefinition<Id, S, G, A>

// 2. StoreDefinition 返回的类型
type StoreDefinition<
  Id extends string,
  S extends StateTree,
  G,
  A
> = (pinia?: Pinia | null | undefined) => Store<Id, S, G, A>

// 3. Store 实例的完整类型
type Store<
  Id extends string = string,
  S extends StateTree = {},
  G = {},
  A = {}
> = UnwrapRef<S> &                              // State(解包 ref)
    StoreGetters<G> &                           // Getters
    StoreActions<A> &                           // Actions
    StoreProperties<Id>                         // $patch, $reset 等

完整的 Store 类型定义

// stores/user.ts
import { defineStore } from 'pinia'
import type { Ref, ComputedRef } from 'vue'

// 定义 State 类型
interface UserState {
  user: User | null
  token: string | null
  loading: boolean
  error: string | null
}

// 定义 User 类型
interface User {
  id: number
  email: string
  name: string
  role: 'admin' | 'user' | 'guest'
  avatar?: string
  createdAt: Date
}

// 定义 Getters 类型
interface UserGetters {
  isAuthenticated: ComputedRef<boolean>
  isAdmin: ComputedRef<boolean>
  displayName: ComputedRef<string>
  userPermissions: ComputedRef<string[]>
}

// 定义 Actions 类型
interface UserActions {
  login(credentials: LoginCredentials): Promise<void>
  logout(): void
  fetchUser(): Promise<void>
  updateProfile(data: Partial<User>): Promise<void>
  refreshToken(): Promise<string>
}

// 定义参数类型
interface LoginCredentials {
  email: string
  password: string
  remember?: boolean
}

// 完整的类型定义
export const useUserStore = defineStore<'user', UserState, UserGetters, UserActions>(
  'user',
  {
    state: (): UserState => ({
      user: null,
      token: localStorage.getItem('token'),
      loading: false,
      error: null
    }),
    
    getters: {
      isAuthenticated: (state): boolean => !!state.token,
      
      isAdmin: (state): boolean => state.user?.role === 'admin',
      
      displayName(state): string {
        return state.user?.name || state.user?.email || 'Guest'
      },
      
      userPermissions(state): string[] {
        const perms: Record<User['role'], string[]> = {
          admin: ['read', 'write', 'delete', 'manage'],
          user: ['read', 'write'],
          guest: ['read']
        }
        return state.user ? perms[state.user.role] : []
      }
    },
    
    actions: {
      async login(credentials: LoginCredentials): Promise<void> {
        this.loading = true
        this.error = null
        
        try {
          const response = await api.login(credentials)
          this.user = response.user
          this.token = response.token
          
          if (credentials.remember) {
            localStorage.setItem('token', response.token)
          }
        } catch (err: any) {
          this.error = err.message
          throw err
        } finally {
          this.loading = false
        }
      },
      
      logout(): void {
        this.user = null
        this.token = null
        this.error = null
        localStorage.removeItem('token')
      },
      
      async fetchUser(): Promise<void> {
        if (!this.token) return
        
        this.loading = true
        try {
          const response = await api.getCurrentUser()
          this.user = response.data
        } catch (err: any) {
          this.error = err.message
          this.logout()
        } finally {
          this.loading = false
        }
      },
      
      async updateProfile(data: Partial<User>): Promise<void> {
        if (!this.user) throw new Error('Not authenticated')
        
        const updated = await api.updateUser(this.user.id, data)
        Object.assign(this.user, updated)
      },
      
      async refreshToken(): Promise<string> {
        if (!this.token) throw new Error('No token to refresh')
        
        const response = await api.refreshToken(this.token)
        this.token = response.token
        return response.token
      }
    }
  }
)

泛型 Store 工厂

// 创建可复用的 CRUD Store 工厂

interface Entity {
  id: number | string
  createdAt?: Date
  updatedAt?: Date
}

interface CRUDState<T extends Entity> {
  items: T[]
  selectedId: string | number | null
  loading: boolean
  error: string | null
  filters: Record<string, any>
  pagination: {
    page: number
    perPage: number
    total: number
  }
}

interface CRUDGetters<T extends Entity> {
  allItems: T[]
  selectedItem: T | null
  itemCount: number
  filteredItems: T[]
  currentPageItems: T[]
  totalPages: number
}

interface CRUDActions<T extends Entity> {
  fetchItems(): Promise<void>
  fetchItem(id: string | number): Promise<void>
  createItem(data: Omit<T, 'id'>): Promise<T>
  updateItem(id: string | number, data: Partial<T>): Promise<T>
  deleteItem(id: string | number): Promise<void>
  setSelectedId(id: string | number | null): void
  setPage(page: number): void
  setFilters(filters: Record<string, any>): void
}

// 工厂函数
export function createCRUDStore<
  T extends Entity,
  Id extends string
>(
  id: Id,
  apiClient: {
    fetchAll: () => Promise<T[]>
    fetchOne: (id: string | number) => Promise<T>
    create: (data: Omit<T, 'id'>) => Promise<T>
    update: (id: string | number, data: Partial<T>) => Promise<T>
    delete: (id: string | number) => Promise<void>
  }
) {
  return defineStore<Id, CRUDState<T>, CRUDGetters<T>, CRUDActions<T>>(id, {
    state: () => ({
      items: [],
      selectedId: null,
      loading: false,
      error: null,
      filters: {},
      pagination: {
        page: 1,
        perPage: 10,
        total: 0
      }
    }),
    
    getters: {
      allItems: (state) => state.items,
      
      selectedItem(state): T | null {
        return state.items.find(item => item.id === state.selectedId) || null
      },
      
      itemCount: (state) => state.items.length,
      
      filteredItems(state): T[] {
        return state.items.filter(item => {
          return Object.entries(state.filters).every(([key, value]) => {
            if (!value) return true
            return (item as any)[key]?.toString().toLowerCase().includes(value.toLowerCase())
          })
        })
      },
      
      currentPageItems(): T[] {
        const start = (this.pagination.page - 1) * this.pagination.perPage
        return this.filteredItems.slice(start, start + this.pagination.perPage)
      },
      
      totalPages(): number {
        return Math.ceil(this.filteredItems.length / this.pagination.perPage)
      }
    },
    
    actions: {
      async fetchItems(): Promise<void> {
        this.loading = true
        this.error = null
        try {
          this.items = await apiClient.fetchAll()
        } catch (err: any) {
          this.error = err.message
        } finally {
          this.loading = false
        }
      },
      
      async fetchItem(id: string | number): Promise<void> {
        this.loading = true
        try {
          const item = await apiClient.fetchOne(id)
          const index = this.items.findIndex(i => i.id === id)
          if (index >= 0) {
            this.items[index] = item
          } else {
            this.items.push(item)
          }
        } catch (err: any) {
          this.error = err.message
        } finally {
          this.loading = false
        }
      },
      
      async createItem(data: Omit<T, 'id'>): Promise<T> {
        this.loading = true
        try {
          const item = await apiClient.create(data)
          this.items.push(item)
          return item
        } finally {
          this.loading = false
        }
      },
      
      async updateItem(id: string | number, data: Partial<T>): Promise<T> {
        this.loading = true
        try {
          const item = await apiClient.update(id, data)
          const index = this.items.findIndex(i => i.id === id)
          if (index >= 0) {
            this.items[index] = item
          }
          return item
        } finally {
          this.loading = false
        }
      },
      
      async deleteItem(id: string | number): Promise<void> {
        await apiClient.delete(id)
        const index = this.items.findIndex(i => i.id === id)
        if (index >= 0) {
          this.items.splice(index, 1)
        }
      },
      
      setSelectedId(id: string | number | null): void {
        this.selectedId = id
      },
      
      setPage(page: number): void {
        this.pagination.page = page
      },
      
      setFilters(filters: Record<string, any>): void {
        this.filters = { ...this.filters, ...filters }
        this.pagination.page = 1 // 重置到第一页
      }
    }
  })
}

// 使用工厂创建具体的 store
interface Product extends Entity {
  name: string
  price: number
  category: string
  stock: number
}

const productApi = {
  fetchAll: () => fetch('/api/products').then(r => r.json()),
  fetchOne: (id) => fetch(`/api/products/${id}`).then(r => r.json()),
  create: (data) => fetch('/api/products', { method: 'POST', body: JSON.stringify(data) }).then(r => r.json()),
  update: (id, data) => fetch(`/api/products/${id}`, { method: 'PUT', body: JSON.stringify(data) }).then(r => r.json()),
  delete: (id) => fetch(`/api/products/${id}`, { method: 'DELETE' }).then(r => r.json())
}

export const useProductStore = createCRUDStore<Product, 'products'>('products', productApi)

高级用法与设计模式

1. Store 间的相互调用

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({ isAdmin: false })
})

// stores/post.js
import { useUserStore } from './user'

export const usePostStore = defineStore('post', {
  state: () => ({
    posts: []
  }),
  
  getters: {
    // 在 getter 中使用其他 store
    filteredPosts() {
      const userStore = useUserStore()
      if (userStore.isAdmin) {
        return this.posts
      }
      return this.posts.filter(post => post.published)
    }
  },
  
  actions: {
    // 在 action 中使用其他 store
    async createPost(postData) {
      const userStore = useUserStore()
      
      if (!userStore.isAdmin) {
        throw new Error('Only admin can create posts')
      }
      
      const post = await api.createPost(postData)
      this.posts.push(post)
      return post
    }
  }
})

2. 领域驱动设计 (DDD) Store

// stores/domain/user.store.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 领域模型
class User {
  constructor(
    public id: string,
    public email: string,
    public profile: UserProfile,
    public preferences: UserPreferences,
    private _permissions: Permission[]
  ) {}
  
  hasPermission(permission: string): boolean {
    return this._permissions.some(p => p.name === permission)
  }
  
  updateProfile(updates: Partial<UserProfile>): void {
    Object.assign(this.profile, updates)
  }
}

interface UserProfile {
  firstName: string
  lastName: string
  avatar?: string
  bio?: string
}

interface UserPreferences {
  theme: 'light' | 'dark'
  language: string
  notifications: boolean
}

interface Permission {
  name: string
  resource: string
  actions: string[]
}

// 仓库接口
interface IUserRepository {
  findById(id: string): Promise<User>
  findByEmail(email: string): Promise<User | null>
  save(user: User): Promise<User>
  delete(id: string): Promise<void>
}

// API 实现
class UserApiRepository implements IUserRepository {
  async findById(id: string): Promise<User> {
    const response = await fetch(`/api/users/${id}`)
    const data = await response.json()
    return this.toDomain(data)
  }
  
  async findByEmail(email: string): Promise<User | null> {
    const response = await fetch(`/api/users?email=${email}`)
    const data = await response.json()
    return data.length > 0 ? this.toDomain(data[0]) : null
  }
  
  async save(user: User): Promise<User> {
    const response = await fetch(`/api/users/${user.id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(user)
    })
    const data = await response.json()
    return this.toDomain(data)
  }
  
  async delete(id: string): Promise<void> {
    await fetch(`/api/users/${id}`, { method: 'DELETE' })
  }
  
  private toDomain(data: any): User {
    return new User(
      data.id,
      data.email,
      data.profile,
      data.preferences,
      data.permissions
    )
  }
}

// Store 作为应用服务层
export const useUserDomainStore = defineStore('userDomain', () => {
  // 依赖注入
  const repository: IUserRepository = new UserApiRepository()
  
  // State
  const currentUser = ref<User | null>(null)
  const users = ref<Map<string, User>>(new Map())
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  // Getters
  const isAuthenticated = computed(() => !!currentUser.value)
  
  const fullName = computed(() => {
    if (!currentUser.value) return 'Guest'
    const { firstName, lastName } = currentUser.value.profile
    return `${firstName} ${lastName}`
  })
  
  const hasPermission = (permission: string) => {
    return computed(() => {
      return currentUser.value?.hasPermission(permission) || false
    })
  }
  
  // Actions
  async function loadUser(id: string): Promise<void> {
    loading.value = true
    error.value = null
    
    try {
      const user = await repository.findById(id)
      users.value.set(id, user)
      currentUser.value = user
    } catch (err: any) {
      error.value = err.message
      throw err
    } finally {
      loading.value = false
    }
  }
  
  async function updateUserProfile(updates: Partial<UserProfile>): Promise<void> {
    if (!currentUser.value) throw new Error('No user logged in')
    
    // 领域逻辑:在模型层处理
    currentUser.value.updateProfile(updates)
    
    // 持久化
    await repository.save(currentUser.value)
  }
  
  function clearCurrentUser(): void {
    currentUser.value = null
  }
  
  return {
    currentUser,
    users,
    loading,
    error,
    isAuthenticated,
    fullName,
    hasPermission,
    loadUser,
    updateUserProfile,
    clearCurrentUser
  }
})

3. 命令查询分离 (CQRS) 模式

// 将读取和写入操作分离

// stores/commands/userCommands.store.ts
export const useUserCommands = defineStore('userCommands', () => {
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  // 纯命令(写操作)
  async function registerUser(data: RegisterUserData): Promise<void> {
    loading.value = true
    try {
      await api.users.register(data)
      eventBus.emit('user:registered', data.email)
    } catch (err: any) {
      error.value = err.message
      throw err
    } finally {
      loading.value = false
    }
  }
  
  async function updateEmail(userId: string, newEmail: string): Promise<void> {
    loading.value = true
    try {
      await api.users.updateEmail(userId, newEmail)
      eventBus.emit('user:emailUpdated', { userId, newEmail })
    } finally {
      loading.value = false
    }
  }
  
  async function deactivateAccount(userId: string): Promise<void> {
    await api.users.deactivate(userId)
    eventBus.emit('user:deactivated', userId)
  }
  
  return {
    loading,
    error,
    registerUser,
    updateEmail,
    deactivateAccount
  }
})

// stores/queries/userQueries.store.ts
export const useUserQueries = defineStore('userQueries', () => {
  // 查询缓存
  const userCache = ref(new Map<string, UserView>())
  const searchCache = ref(new Map<string, UserSearchResult>())
  
  // 纯查询(读操作)
  async function getUserById(id: string): Promise<UserView> {
    // 先查缓存
    if (userCache.value.has(id)) {
      return userCache.value.get(id)!
    }
    
    // 查询 API
    const user = await api.users.getById(id)
    const view = toUserView(user)
    
    // 写入缓存
    userCache.value.set(id, view)
    
    return view
  }
  
  async function searchUsers(query: string): Promise<UserSearchResult> {
    const cacheKey = query.toLowerCase()
    
    if (searchCache.value.has(cacheKey)) {
      return searchCache.value.get(cacheKey)!
    }
    
    const results = await api.users.search(query)
    searchCache.value.set(cacheKey, results)
    
    return results
  }
  
  function invalidateUserCache(id: string): void {
    userCache.value.delete(id)
  }
  
  // 订阅事件来更新缓存
  eventBus.on('user:emailUpdated', ({ userId }) => {
    invalidateUserCache(userId)
  })
  
  return {
    getUserById,
    searchUsers,
    invalidateUserCache
  }
})

// 在组件中使用
function useUser() {
  const commands = useUserCommands()
  const queries = useUserQueries()
  
  return {
    // 查询
    getUser: queries.getUserById,
    searchUsers: queries.searchUsers,
    
    // 命令
    register: commands.registerUser,
    updateEmail: commands.updateEmail,
    deactivate: commands.deactivateAccount,
    
    // 状态
    isLoading: computed(() => commands.loading),
    error: computed(() => commands.error)
  }
}

性能优化实战

1. 虚拟化大数据列表

export const useVirtualListStore = defineStore('virtualList', () => {
  // 原始数据
  const allItems = ref<Item[]>([])
  
  // 虚拟化配置
  const config = reactive({
    itemHeight: 50,
    containerHeight: 600,
    overscan: 5, // 上下额外渲染的数量
    totalItems: computed(() => allItems.value.length)
  })
  
  // 滚动位置
  const scrollTop = ref(0)
  
  // 计算可见范围(高性能 getter)
  const visibleRange = computed(() => {
    const startIndex = Math.max(0, Math.floor(scrollTop.value / config.itemHeight) - config.overscan)
    const visibleCount = Math.ceil(config.containerHeight / config.itemHeight)
    const endIndex = Math.min(config.totalItems, startIndex + visibleCount + config.overscan * 2)
    
    return { startIndex, endIndex, visibleCount }
  })
  
  // 只返回可见项
  const visibleItems = computed(() => {
    const { startIndex, endIndex } = visibleRange.value
    return allItems.value.slice(startIndex, endIndex).map((item, index) => ({
      ...item,
      index: startIndex + index,
      offset: (startIndex + index) * config.itemHeight
    }))
  })
  
  // 总高度(用于滚动条)
  const totalHeight = computed(() => config.totalItems * config.itemHeight)
  
  // 更新滚动位置(使用 requestAnimationFrame 节流)
  let rafId: number | null = null
  function updateScrollTop(newScrollTop: number): void {
    if (rafId !== null) return
    
    rafId = requestAnimationFrame(() => {
      scrollTop.value = newScrollTop
      rafId = null
    })
  }
  
  // 批量加载数据
  async function loadItems(start: number, count: number): Promise<void> {
    const items = await api.fetchItems(start, count)
    allItems.value.splice(start, items.length, ...items)
  }
  
  // 预加载
  watch(visibleRange, (range) => {
    const bufferStart = Math.max(0, range.startIndex - 20)
    const bufferEnd = Math.min(config.totalItems, range.endIndex + 20)
    
    // 检查并加载缺失的数据
    for (let i = bufferStart; i < bufferEnd; i++) {
      if (!allItems.value[i]) {
        loadItems(i, 20)
        break
      }
    }
  })
  
  return {
    visibleItems,
    totalHeight,
    visibleRange,
    updateScrollTop,
    loadItems
  }
})

2. 智能缓存策略

export const useCachedStore = defineStore('cached', () => {
  // 多级缓存
  const memoryCache = new Map<string, any>()
  const persistentCache = useLocalStorage('app-cache', {})
  
  // 缓存配置
  const cacheConfig = {
    ttl: {
      memory: 5 * 60 * 1000,      // 内存缓存 5 分钟
      persistent: 24 * 60 * 60 * 1000  // 持久化缓存 24 小时
    },
    maxSize: {
      memory: 100,   // 最多 100 条
      persistent: 500
    }
  }
  
  // 缓存元数据
  interface CacheEntry<T> {
    data: T
    timestamp: number
    accessCount: number
    lastAccessed: number
  }
  
  const cacheMeta = reactive(new Map<string, CacheEntry<any>>())
  
  // 获取缓存
  function get<T>(key: string): T | null {
    // 先查内存
    if (memoryCache.has(key)) {
      updateAccessStats(key)
      return memoryCache.get(key)
    }
    
    // 再查持久化
    const persistent = persistentCache.value[key]
    if (persistent && !isExpired(persistent.timestamp, cacheConfig.ttl.persistent)) {
      // 提升到内存
      memoryCache.set(key, persistent.data)
      updateAccessStats(key)
      return persistent.data
    }
    
    return null
  }
  
  // 设置缓存
  function set<T>(key: string, data: T, options: { persistent?: boolean } = {}): void {
    const entry: CacheEntry<T> = {
      data,
      timestamp: Date.now(),
      accessCount: 0,
      lastAccessed: Date.now()
    }
    
    // 写入内存
    memoryCache.set(key, data)
    cacheMeta.set(key, entry)
    
    // 写入持久化
    if (options.persistent) {
      persistentCache.value[key] = entry
    }
    
    // 清理旧缓存
    cleanupIfNeeded()
  }
  
  // 更新访问统计
  function updateAccessStats(key: string): void {
    const meta = cacheMeta.get(key)
    if (meta) {
      meta.accessCount++
      meta.lastAccessed = Date.now()
    }
  }
  
  // 检查是否过期
  function isExpired(timestamp: number, ttl: number): boolean {
    return Date.now() - timestamp > ttl
  }
  
  // 清理策略:LRU (Least Recently Used)
  function cleanupIfNeeded(): void {
    if (memoryCache.size <= cacheConfig.maxSize.memory) return
    
    // 按最后访问时间排序
    const sorted = Array.from(cacheMeta.entries())
      .sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
    
    // 删除最旧的 20%
    const toDelete = Math.floor(cacheConfig.maxSize.memory * 0.2)
    for (let i = 0; i < toDelete; i++) {
      const [key] = sorted[i]
      memoryCache.delete(key)
      cacheMeta.delete(key)
    }
  }
  
  // 带缓存的数据获取
  async function fetchWithCache<T>(
    key: string,
    fetcher: () => Promise<T>,
    options: { persistent?: boolean; force?: boolean } = {}
  ): Promise<T> {
    // 检查缓存
    if (!options.force) {
      const cached = get<T>(key)
      if (cached !== null) {
        return cached
      }
    }
    
    // 获取新数据
    const data = await fetcher()
    
    // 存入缓存
    set(key, data, options)
    
    return data
  }
  
  // 预加载策略
  function preload(keys: string[], fetchers: Map<string, () => Promise<any>>): void {
    const idleCallback = (window as any).requestIdleCallback || ((cb: any) => setTimeout(cb, 1))
    
    idleCallback(() => {
      keys.forEach(key => {
        if (!memoryCache.has(key)) {
          const fetcher = fetchers.get(key)
          if (fetcher) {
            fetcher().then(data => set(key, data))
          }
        }
      })
    })
  }
  
  return {
    get,
    set,
    fetchWithCache,
    preload,
    clear: () => {
      memoryCache.clear()
      cacheMeta.clear()
    }
  }
})

插件系统详解

1. 日志插件(DevTools 增强)

// plugins/logger.ts
import type { PiniaPluginContext } from 'pinia'

export function loggerPlugin(context: PiniaPluginContext) {
  const { store, options } = context
  
  // 只在开发环境启用
  if (process.env.NODE_ENV === 'production') return
  
  // 为每个 action 添加日志
  store.$onAction(({
    name,       // action 名称
    store,      // store 实例
    args,       // 参数
    after,      // action 成功后的回调
    onError     // action 失败后的回调
  }) => {
    const startTime = Date.now()
    
    console.group(`🟢 Action: ${store.$id}.${name}`)
    console.log('Arguments:', args)
    
    after((result) => {
      console.log('✅ Success:', result)
      console.log('⏱ Duration:', Date.now() - startTime, 'ms')
      console.groupEnd()
    })
    
    onError((error) => {
      console.error('❌ Error:', error)
      console.groupEnd()
    })
  })
  
  // 监听 state 变化
  store.$subscribe((mutation, state) => {
    console.group(`📝 State Change: ${store.$id}`)
    console.log('Type:', mutation.type)
    console.log('Store ID:', mutation.storeId)
    console.log('Payload:', mutation.payload)
    console.log('New State:', state)
    console.groupEnd()
  })
}

2. 持久化插件(完整实现)

// plugins/persist.ts
import type { PiniaPluginContext, StateTree } from 'pinia'

interface PersistStrategy {
  key?: string
  storage?: Storage
  paths?: string[]
  beforeRestore?: (context: PiniaPluginContext) => void
  afterRestore?: (context: PiniaPluginContext) => void
  serializer?: {
    serialize: (value: any) => string
    deserialize: (value: string) => any
  }
}

type PersistOption = boolean | PersistStrategy | PersistStrategy[]

declare module 'pinia' {
  export interface DefineStoreOptionsBase<S extends StateTree, Store> {
    persist?: PersistOption
  }
}

export function createPersistPlugin(defaults: Partial<PersistStrategy> = {}) {
  return function persistPlugin(context: PiniaPluginContext) {
    const { options, store } = context
    
    if (!options.persist) return
    
    const strategies = Array.isArray(options.persist) 
      ? options.persist 
      : [options.persist === true ? {} : options.persist]
    
    strategies.forEach((strategy) => {
      const {
        key = store.$id,
        storage = localStorage,
        paths = [],
        beforeRestore = () => {},
        afterRestore = () => {},
        serializer = {
          serialize: JSON.stringify,
          deserialize: JSON.parse
        }
      } = { ...defaults, ...strategy }
      
      // 恢复状态
      beforeRestore(context)
      
      try {
        const stored = storage.getItem(key)
        if (stored) {
          const parsed = serializer.deserialize(stored)
          
          if (paths.length > 0) {
            // 部分恢复
            paths.forEach((path) => {
              if (path in parsed) {
                store.$patch((state) => {
                  setNestedValue(state, path, parsed[path])
                })
              }
            })
          } else {
            // 完全恢复
            store.$patch(parsed)
          }
        }
      } catch (error) {
        console.error(`Failed to restore state for ${key}:`, error)
      }
      
      afterRestore(context)
      
      // 监听变化并保存
      store.$subscribe(
        (mutation, state) => {
          try {
            let toStore: any = state
            
            if (paths.length > 0) {
              // 只保存指定路径
              toStore = paths.reduce((acc, path) => {
                setNestedValue(acc, path, getNestedValue(state, path))
                return acc
              }, {})
            }
            
            storage.setItem(key, serializer.serialize(toStore))
          } catch (error) {
            console.error(`Failed to persist state for ${key}:`, error)
          }
        },
        { detached: true } // 组件卸载后继续监听
      )
    })
  }
}

// 辅助函数
function setNestedValue(obj: any, path: string, value: any): void {
  const keys = path.split('.')
  let current = obj
  
  for (let i = 0; i < keys.length - 1; i++) {
    if (!(keys[i] in current)) {
      current[keys[i]] = {}
    }
    current = current[keys[i]]
  }
  
  current[keys[keys.length - 1]] = value
}

function getNestedValue(obj: any, path: string): any {
  return path.split('.').reduce((current, key) => current?.[key], obj)
}

// 使用
// main.ts
import { createPersistPlugin } from './plugins/persist'

const pinia = createPinia()
pinia.use(createPersistPlugin({
  storage: localStorage,
  beforeRestore: (ctx) => {
    console.log(`Restoring ${ctx.store.$id}...`)
  }
}))

// store.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    token: null,
    preferences: {
      theme: 'light',
      language: 'zh'
    }
  }),
  persist: {
    key: 'my-app-user',
    paths: ['token', 'preferences'], // 只持久化这些字段
    storage: sessionStorage, // 使用 sessionStorage
    beforeRestore: (ctx) => {
      console.log('Before restore')
    },
    afterRestore: (ctx) => {
      console.log('After restore')
    }
  }
})

3. 使用 pinia-plugin-persistedstate(推荐)

npm install pinia-plugin-persistedstate
// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    token: null
  }),
  persist: true
})

SSR 深度实践

1. 服务端数据预取模式

// composables/useAsyncStore.ts
import { useRequestFetch } from 'nuxt/app'

interface AsyncStoreOptions<T> {
  key: string
  fetcher: () => Promise<T>
  defaultValue: T
  immediate?: boolean
  transform?: (data: T) => T
  onError?: (error: Error) => void
}

export function useAsyncStore<T>(options: AsyncStoreOptions<T>) {
  const { key, fetcher, defaultValue, immediate = true, transform, onError } = options
  
  // 使用 useState 实现 SSR 友好的状态管理
  const data = useState<T>(key, () => defaultValue)
  const pending = useState<boolean>(`${key}-pending`, () => false)
  const error = useState<Error | null>(`${key}-error`, () => null)
  
  // 标记是否已经在服务端获取过数据
  const serverFetched = useState<boolean>(`${key}-server-fetched`, () => false)
  
  async function execute(): Promise<void> {
    // SSR 模式下,服务端只获取一次
    if (process.server && serverFetched.value) return
    
    // CSR 模式下,如果已有数据则不重复获取
    if (process.client && data.value !== defaultValue && !error.value) return
    
    pending.value = true
    error.value = null
    
    try {
      let result = await fetcher()
      
      if (transform) {
        result = transform(result)
      }
      
      data.value = result
      
      if (process.server) {
        serverFetched.value = true
      }
    } catch (err) {
      error.value = err as Error
      onError?.(err as Error)
    } finally {
      pending.value = false
    }
  }
  
  // 立即执行
  if (immediate) {
    // 在 SSR 中使用 await 等待数据
    if (process.server) {
      // Nuxt 3 中会自动等待
      execute()
    } else {
      // 客户端异步执行
      execute()
    }
  }
  
  // 刷新数据
  async function refresh(): Promise<void> {
    serverFetched.value = false
    await execute()
  }
  
  return {
    data: readonly(data),
    pending: readonly(pending),
    error: readonly(error),
    execute,
    refresh
  }
}

2. SSR 安全的状态管理

// utils/ssr-helpers.ts

// 只在客户端执行的辅助函数
export function onClient<T>(fn: () => T): T | undefined {
  if (process.client) {
    return fn()
  }
}

// 只在服务端执行的辅助函数
export function onServer<T>(fn: () => T): T | undefined {
  if (process.server) {
    return fn()
  }
}

// SSR 安全的 localStorage
export function useSSRStorage() {
  function getItem(key: string): string | null {
    return onClient(() => localStorage.getItem(key)) || null
  }
  
  function setItem(key: string, value: string): void {
    onClient(() => localStorage.setItem(key, value))
  }
  
  function removeItem(key: string): void {
    onClient(() => localStorage.removeItem(key))
  }
  
  return { getItem, setItem, removeItem }
}

// 在 Store 中使用
export const useSafeStore = defineStore('safe', () => {
  const storage = useSSRStorage()
  
  const token = ref<string | null>(null)
  
  // 客户端初始化
  function init() {
    onClient(() => {
      // 从 localStorage 恢复
      token.value = storage.getItem('token')
    })
  }
  
  function setToken(newToken: string) {
    token.value = newToken
    storage.setItem('token', newToken)
  }
  
  return {
    token,
    init,
    setToken
  }
})

测试策略与实战

1. 单元测试完整方案

// stores/counter.store.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useCounterStore } from './counter.store'

describe('Counter Store', () => {
  // 每个测试前重置 Pinia
  beforeEach(() => {
    setActivePinia(createPinia())
  })
  
  describe('State', () => {
    it('should have correct initial state', () => {
      const store = useCounterStore()
      
      expect(store.count).toBe(0)
      expect(store.name).toBe('Counter')
    })
    
    it('should update state directly', () => {
      const store = useCounterStore()
      
      store.count = 10
      expect(store.count).toBe(10)
    })
    
    it('should reset to initial state', () => {
      const store = useCounterStore()
      
      store.count = 100
      store.$reset()
      
      expect(store.count).toBe(0)
    })
  })
  
  describe('Getters', () => {
    it('should calculate double count correctly', () => {
      const store = useCounterStore()
      
      store.count = 5
      expect(store.doubleCount).toBe(10)
    })
    
    it('should recalculate when dependency changes', () => {
      const store = useCounterStore()
      
      store.count = 5
      expect(store.doubleCount).toBe(10)
      
      store.count = 10
      expect(store.doubleCount).toBe(20)
    })
  })
  
  describe('Actions', () => {
    it('should increment count', () => {
      const store = useCounterStore()
      
      store.increment()
      expect(store.count).toBe(1)
    })
    
    it('should handle async action', async () => {
      const store = useCounterStore()
      
      await store.asyncIncrement()
      expect(store.count).toBe(1)
    })
    
    it('should handle action errors', async () => {
      const store = useCounterStore()
      
      // 模拟 API 失败
      vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
      
      await expect(store.fetchData()).rejects.toThrow('Network error')
      expect(store.error).toBe('Network error')
    })
  })
  
  describe('Subscriptions', () => {
    it('should notify subscribers on state change', () => {
      const store = useCounterStore()
      const callback = vi.fn()
      
      store.$subscribe(callback)
      
      store.count = 5
      
      expect(callback).toHaveBeenCalled()
    })
    
    it('should notify action subscribers', () => {
      const store = useCounterStore()
      const onAction = vi.fn()
      
      store.$onAction(onAction)
      
      store.increment()
      
      expect(onAction).toHaveBeenCalled()
    })
  })
})

2. 集成测试

// tests/integration/stores.integration.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'
import { useOrderStore } from '@/stores/order'

describe('Store Integration', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })
  
  it('should sync user state across stores', async () => {
    const userStore = useUserStore()
    const cartStore = useCartStore()
    
    // 用户登录
    await userStore.login({ email: 'test@test.com', password: '123456' })
    
    // 购物车应该能访问用户信息
    expect(cartStore.userId).toBe(userStore.user?.id)
  })
  
  it('should create order with cart items and user info', async () => {
    const userStore = useUserStore()
    const cartStore = useCartStore()
    const orderStore = useOrderStore()
    
    // 设置用户
    await userStore.login({ email: 'test@test.com', password: '123456' })
    
    // 添加商品到购物车
    cartStore.addItem({ id: 1, name: 'Product', price: 100 })
    cartStore.addItem({ id: 2, name: 'Another', price: 50 })
    
    // 创建订单
    const order = await orderStore.createOrder()
    
    // 验证订单包含正确信息
    expect(order.userId).toBe(userStore.user?.id)
    expect(order.items).toHaveLength(2)
    expect(order.total).toBe(150)
    
    // 验证购物车已清空
    expect(cartStore.items).toHaveLength(0)
  })
})

大型项目架构

1. 项目结构组织

src/
├── modules/
│   ├── auth/
│   │   ├── stores/
│   │   │   ├── auth.store.ts
│   │   │   └── permissions.store.ts
│   │   ├── components/
│   │   ├── composables/
│   │   └── index.ts          # 模块导出
│   ├── products/
│   │   ├── stores/
│   │   │   ├── product.store.ts
│   │   │   └── category.store.ts
│   │   ├── components/
│   │   └── index.ts
│   └── orders/
│       ├── stores/
│       │   ├── order.store.ts
│       │   └── payment.store.ts
│       └── index.ts
├── shared/
│   └── stores/
│       ├── ui.store.ts       # 全局 UI 状态
│       └── cache.store.ts    # 全局缓存
└── stores/
    └── index.ts              # Store 入口

2. Store 依赖注入容器

// core/container.ts
import type { Pinia } from 'pinia'

interface ContainerConfig {
  pinia: Pinia
  apiBaseUrl: string
  storage: Storage
}

class StoreContainer {
  private stores = new Map<string, any>()
  private config: ContainerConfig
  
  constructor(config: ContainerConfig) {
    this.config = config
  }
  
  // 注册 Store
  register<T>(name: string, factory: (container: StoreContainer) => T): void {
    if (this.stores.has(name)) {
      throw new Error(`Store ${name} already registered`)
    }
    
    Object.defineProperty(this, name, {
      get: () => {
        if (!this.stores.has(name)) {
          this.stores.set(name, factory(this))
        }
        return this.stores.get(name)
      },
      configurable: true
    })
  }
  
  // 获取配置
  getConfig(): ContainerConfig {
    return this.config
  }
  
  // 初始化所有 Store
  async init(): Promise<void> {
    for (const [name, store] of this.stores) {
      if (store.init && typeof store.init === 'function') {
        await store.init()
      }
    }
  }
}

// 创建容器
export function createContainer(config: ContainerConfig): StoreContainer {
  return new StoreContainer(config)
}

源码级原理解析

1. defineStore 的执行流程

defineStore(id, setup)
    │
    ▼
返回 useStore 函数(闭包)
    │
    ▼
调用 useStore()
    │
    ├──▶ 获取当前 Pinia 实例(getActivePinia)
    │
    ├──▶ 检查是否已存在该 Store
    │       ├── 存在 → 直接返回缓存的 Store
    │       └── 不存在 → 创建新 Store
    │
    └──▶ createSetupStore(id, setup, pinia)
            │
            ├──▶ 创建响应式 Scope(用于自动清理)
            │
            ├──▶ 执行 setup 函数
            │       │
            │       ├──▶ 将 ref → state
            │       ├──▶ 将 computed → getter
            │       └──▶ 将 function → action
            │
            ├──▶ 处理 Options API 风格(如果是对象)
            │
            ├──▶ 包装 Actions(添加订阅、错误处理)
            │
            ├──▶ 添加 Store 属性($patch, $reset, $subscribe 等)
            │
            └──▶ 返回响应式 Store 对象

2. 插件系统的工作机制

// Pinia 如何加载插件?

class Pinia {
  constructor() {
    this._p = [] // 插件数组
    this._s = new Map() // Store 实例 Map
  }
  
  // 注册插件
  use(plugin) {
    this._p.push(plugin)
    
    // 如果已有 Store,立即应用插件
    this._s.forEach((store, id) => {
      plugin({
        pinia: this,
        app: this._a,
        store,
        options: store.$options
      })
    })
    
    return this
  }
  
  // 安装插件到具体 Store
  _installPlugin(store) {
    this._p.forEach(plugin => {
      const result = plugin({
        pinia: this,
        app: this._a,
        store,
        options: store.$options
      })
      
      // 插件可以返回要添加到 Store 的属性
      if (result) {
        Object.assign(store, result)
      }
    })
  }
}

从 Vuex 迁移到 Pinia

迁移清单

  1. 安装 Pinia
npm install pinia
  1. 创建 Pinia 实例
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'

const app = createApp(App)
app.use(createPinia())
  1. 迁移 Vuex Modules 为 Pinia Stores

Before (Vuex):

// store/modules/user.js
export default {
  namespaced: true,
  state: () => ({ user: null }),
  mutations: {
    SET_USER(state, user) { state.user = user }
  },
  actions: {
    async login({ commit }, credentials) {
      const user = await api.login(credentials)
      commit('SET_USER', user)
    }
  },
  getters: {
    isLoggedIn: state => !!state.user
  }
}

After (Pinia):

// stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({ user: null }),
  actions: {
    async login(credentials) {
      const user = await api.login(credentials)
      this.user = user  // 直接修改,不需要 mutation
    }
  },
  getters: {
    isLoggedIn: (state) => !!state.user
  }
})
  1. 更新组件中的使用方式

Before:

<script>
import { mapState, mapActions } from 'vuex'

export default {
  computed: {
    ...mapState('user', ['user'])
  },
  methods: {
    ...mapActions('user', ['login'])
  }
}
</script>

After:

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

const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const { login } = userStore
</script>

常见问题

Q: 如何处理命名空间?

// Vuex: namespaced: true
// Pinia: 每个 store 天然是独立的,无需命名空间

Q: Mutations 去哪里了?

// Pinia 中直接修改 state,无需 mutations
// 或使用 actions 封装逻辑

Q: 如何处理插件(如持久化)?

// 使用 Pinia 插件系统
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
pinia.use(piniaPluginPersistedstate)

最佳实践总结

选择建议

场景 推荐方案
新项目 Pinia - 官方推荐,现代架构
Vue 3 + TS Pinia - 完美的类型支持
大型应用 Pinia + 模块化架构 - 易于维护
SSR 应用 Pinia - 更好的 SSR 支持
老项目维护 Vuex - 如果已有 Vuex,可以继续使用
快速原型 Pinia - 更快的开发速度

性能优化清单

  • 使用虚拟滚动处理大数据列表
  • 实现多级缓存策略(内存 + 持久化)
  • 使用 Web Worker 处理复杂计算
  • 合理使用 getter 缓存
  • 避免不必要的 store 订阅
  • 使用 storeToRefs 解构保持响应式
  • 延迟加载非关键 store

最佳实践总结

  1. 单一职责:每个 store 只负责一个领域
  2. 组合优于继承:使用 composables 组合功能
  3. 类型优先:充分利用 TypeScript
  4. 测试覆盖:单元测试 + 集成测试 + E2E 测试
  5. 插件增强:使用插件实现横切关注点(日志、持久化等)
  6. 性能意识:关注大数据场景的性能优化

Pinia 的核心优势

  1. 简洁性:移除 Mutations,减少 40-50% 的样板代码
  2. 类型安全:原生 TypeScript 支持,完美类型推导
  3. 灵活性:支持 Options API 和 Composition API 两种风格
  4. 可扩展性:强大的插件系统,易于定制
  5. DevTools:更好的开发体验,支持时间旅行
  6. 轻量级:~1KB,性能优于 Vuex

参考资源

响应式探秘:ref vs reactive,我该选谁?

前言

在 Vue3 的 Composition API 中,有两个主要的响应式 API:refreactive。很多开发者,尤其是刚从 Vue2 迁移过来的同学,常常会困惑:到底该用哪一个响应式 API ?什么时候该用 ref?什么时候该用 reactive

这个问题看似简单,实则涉及 Vue3 响应式系统的核心设计理念。本文将从源码原理出发,深入剖析两者的本质区别。

响应式原理快速回顾

Proxy:Vue3 响应式的基石

在深入 refreactive 之前,我们必须先理解 Vue3 响应式的核心:Proxy 代理。

在 Vue2 中, 使用的是 Object.defineProperty 来拦截属性的读写,但它有一个致命缺陷:无法检测属性的添加和删除,当我们需要添加属性等操作时,必须用 Vue.set()vm.$set() 等方式处理。而在 Vue3 中改用 Proxy 进行对象代理,完美解决了这个问题:

const target = { name: 'Vue' }
const handler = {
  get(target, key, receiver) {
    console.log(`读取属性: ${key}`)
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    console.log(`设置属性: ${key} = ${value}`)
    return Reflect.set(target, key, value, receiver)
  }
}

const proxy = new Proxy(target, handler)
proxy.name // 读取属性: name
proxy.name = 'Vue3' // 设置属性: name = Vue3

Proxy 的强大之处

  • 拦截所有操作:包括属性读取、赋值、删除、in 操作符等,支持 13 种数据操作的拦截
  • 动态属性响应:新增属性也能被追踪
  • 数组方法拦截:push、pop 等方法也能触发更新

关于 Proxy 的相关内容,可以查看我在《JavaScript核心机制探秘》专栏中相关的文章介绍。

reactive 的实现原理

reactive 是 Vue3 中最直接的响应式 API,它接收一个对象,返回这个对象的 Proxy 代理:

// 简化的 reactive 实现
function reactive(target) {
  // 创建 Proxy 代理
  return new Proxy(target, {
    get(target, key, receiver) {
      // 依赖收集
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver)
      // 触发更新
      trigger(target, key)
      return result
    },
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      // 删除属性也要触发更新
      trigger(target, key)
      return result
    }
  })
}

// 使用
const state = reactive({
  count: 0,
  user: { name: '张三' }
})

state.count++ // 触发更新
state.user.name = '李四' // 嵌套对象也会被递归代理

ref 的实现原理

ref 的设计要处理一个根本性问题:Proxy 只能代理对象,无法代理基础类型(string、number、boolean)。因此,Vue团队 给出了一个解决方案:使用 value 属性,将基础类型值包装成一个对象,再对这个对象进行 Proxy 代理。这也是为什么 ref 响应式数据,需要用 .value 的方式进行访问的原因:

// 简化的 ref 实现
function ref(value) {
  // 创建包装对象
  const wrapper = {
    value: value
  }
  
  // 将包装对象变为响应式
  return reactive(wrapper)
}

// 更接近真实源码的实现
class RefImpl {
  constructor(value) {
    this._value = value
    this.__v_isRef = true // 标记这是一个 ref
  }
  
  get value() {
    // 依赖收集
    track(this, 'value')
    return this._value
  }
  
  set value(newVal) {
    if (this._value !== newVal) {
      this._value = newVal
      // 触发更新
      trigger(this, 'value')
    }
  }
}

function ref(value) {
  return new RefImpl(value)
}

// 使用
const count = ref(0)
count.value++ // 必须通过 .value 访问

从上述代码中,我们也可以看出:ref 返回的本质上也是一个 reactive 对象!

关于 ref 和 reactive 的具体源码实现细节,可以参考我的《Vue3 源码解析》的相关文章。

ref vs reactive 的核心区别

访问方式:.value 的有无

这是两者最直观的区别:

import { ref, reactive } from 'vue'

// ref 需要 .value
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1

// reactive 不需要 .value
const state = reactive({ count: 0 })
console.log(state.count) // 0
state.count++
console.log(state.count) // 1

重新赋值:整体替换 vs 属性修改

这其实是在 Vue3 开发中,最容易踩的一个坑,我们先来看一个例子:

// ref 支持整体替换
let user = ref({ name: '张三', age: 18 })
// ✅ 可以直接替换整个对象
user.value = { name: '李四', age: 20 }

// reactive 不支持整体替换
let state = reactive({ name: '张三', age: 18 })
// ❌ 这样会丢失响应式
state = { name: '李四', age: 20 } 

// ✅ reactive 只能修改属性
state.name = '李四'
state.age = 20

// ❌ 即使使用 Object.assign 也可能出现问题
Object.assign(state, { name: '王五', age: 22 }) // ✅ 这样可以
state = Object.assign({}, state, { name: '王五' }) // ❌ 这样不行

类型推导与解构

reactive 在使用解构时也会出现问题:

const state = reactive({
  name: '张三',
  age: 18,
  profile: {
    city: '北京'
  }
})

// ❌ 解构后失去响应性
const { name, age } = state
name // '张三',但不再是响应式的

// ✅ 使用 toRefs 保持响应性
const { name, age } = toRefs(state)
name.value // 需要通过 .value 访问

// ✅ 单个属性用 toRef
const city = toRef(state.profile, 'city')
city.value = '上海' // 会触发更新

ref 在这方面的表现就很好:

// 组合式函数返回 ref 对象
function useFeature() {
  const count = ref(0)
  const name = ref('张三')
  
  return {
    count,
    name
  }
}

// 解构后依然是响应式的
const { count, name } = useFeature()
count.value++ // ✅ 正常工作

注:关于上述内容,在论坛中也存在争议:由于 reactive 本身设计特性,会导致响应式丢失问题。因此部分开发者(包括笔者),更推荐在实际开发中,直接使用 ref,弃用 reactive

深层响应性

两者都支持深层响应,但内部实现略有不同:

const refObj = ref({
  user: {
    name: '张三',
    address: {
      city: '北京'
    }
  }
})

// 深层属性也是响应式的
refObj.value.user.address.city = '上海' // 触发更新

const reactiveObj = reactive({
  user: {
    name: '张三',
    address: {
      city: '北京'
    }
  }
})

// 同样是深层响应式
reactiveObj.user.address.city = '上海' // 触发更新

什么时候用 ref?

基础类型值

这是 ref 的主要应用场景,因为 reactive 根本不能处理基础类型:

const count = ref(0)
const name = ref('张三')
const isLoading = ref(false)
const userInput = ref('')

需要整体替换的场景

当我们的数据状态需要整体重置或替换时,ref 是不二之选:

// 表单数据,经常需要重置
const formData = ref({
  username: '',
  email: '',
  password: ''
})

// 重置表单 - ref 轻松搞定
function resetForm() {
  formData.value = {
    username: '',
    email: '',
    password: ''
  }
}

// 更新整个表单 - 从 API 获取数据后整体替换
async function loadForm(id) {
  const data = await api.getForm(id)
  formData.value = data // ✅ 直接替换
}

当然,如果一定要用 reactive 呢?也是可以解决的,只是较为麻烦而已:

// 如果用 reactive,重置会很麻烦
const formDataReactive = reactive({
  username: '',
  email: '',
  password: ''
})

function resetFormReactive() {
  // 需要逐个属性重置,或者使用 Object.assign
  Object.assign(formDataReactive, {
    username: '',
    email: '',
    password: ''
  })
}

从组合式函数返回时

当编写可复用的组合式函数时,返回 ref 对象可以更利于解构:

export function useUser() {
  const user = ref(null)
  const loading = ref(false)
  const error = ref(null)
  
  async function fetchUser(id) {
    loading.value = true
    try {
      user.value = await api.getUser(id)
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }
  
  // 返回 ref 对象,使用者可以随意解构
  return {
    user,
    loading,
    error,
    fetchUser
  }
}

// 在组件中使用
const { user, loading, fetchUser } = useUser()
// 解构后依然保持响应式
watch(user, () => {}) // ✅ 正常

跨组件传递时的类型安全

当通过 props 进行父子组件通信,传递响应式数据时,ref 的类型更清晰:

<!-- 父组件 -->
<script setup>
const userData = ref({ name: '张三', age: 18 })
</script>

<template>
  <ChildComponent :data="userData" />
</template>

<!-- 子组件 -->
<script setup>
// 明确知道接收的是一个 ref
const props = defineProps<{
  data: { name: string; age: number } // 注意:这是 Ref 的内部类型
}>()

// 使用 toValue 统一处理
const data = toValue(props.data) // toValue 可以处理 ref 和普通值
</script>

获取子组件实例

当父组件想要访问子组件的方法或数据时,可以直接使用 ref 获得子组件的实例,访问子组件通过 defineExpose 暴露的方法或数据: 子组件 Child.vue

<template>
  <div>子组件</div>
</template>

<script setup>
// 子组件的方法和数据
const childMethod = () => {
  console.log('子组件方法被调用')
}

// 需要暴露给父组件的属性和方法
defineExpose({
  childMethod,
  childData: '我是子组件的数据'
})
</script>

父组件 Parent.vue

<template>
  <!-- 子组件 -->
  <Child ref="childRef" />
  <button @click="callChildMethod">调用子组件方法</button>
</template>

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

// 创建一个ref来存储子组件实例
const childRef = ref(null)

// 调用子组件方法
const callChildMethod = () => {
  if (childRef.value) {
    childRef.value.childMethod()  // 调用子组件暴露的方法
    console.log(childRef.value.childData)  // 访问子组件暴露的数据
  }
}

// 在生命周期钩子中访问
import { onMounted } from 'vue'
onMounted(() => {
  console.log('子组件实例:', childRef.value)
})
</script>

什么时候用 reactive?

深层嵌套的对象

当数据结构复杂且嵌套层级较深时,reactive 的语法更简洁:

// 复杂的状态对象
const store = reactive({
  user: {
    profile: {
      personal: {
        name: '张三',
        age: 18
      },
      contact: {
        email: 'zhang@example.com',
        phone: '1234567890'
      }
    },
    preferences: {
      theme: 'dark',
      language: 'zh-CN',
      notifications: {
        email: true,
        sms: false
      }
    }
  },
  ui: {
    sidebar: {
      collapsed: false,
      width: 240
    },
    modal: {
      visible: false,
      type: null
    }
  }
})

// 访问深层属性 - reactive 很方便
store.user.profile.personal.name = '李四'
store.ui.sidebar.collapsed = true

// 如果用 ref,每次都要 .value,略显繁琐
const storeRef = ref({
  // 同样的数据结构
})
storeRef.value.user.profile.personal.name = '李四' // 多了 .value

不需要整体替换的数据

对于不需要整体替换的数据,比如配置数据等,只用初始化一次,后期只会更改属性,reactive 很合适:

const appConfig = reactive({
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retryCount: 3,
  features: {
    logging: true,
    cache: false
  }
})

// 后续只修改属性
appConfig.timeout = 10000
appConfig.features.cache = true

性能敏感的场景

虽然差别很小,但理论上 reactiveref 少一层包装,性能略好:

// ref 多了一层对象包装
const refState = ref({ count: 0 })
// 访问路径: refState.value.count

// reactive 直接代理原始对象
const reactiveState = reactive({ count: 0 })
// 访问路径: reactiveState.count

// 在大量数据操作的场景下,reactive 可能稍有优势

注:这种说法只是出于纯理论上的,因为实际开发中,这种性能差异在99%的场景中都可以忽略不计。

为什么 reactive 解构后会失去响应性?

原因:解构破坏了 Proxy 的代理

要想理解这个问题,还是得回到 Proxy 的工作原理中,我们先用一段简单的代码模拟 reactive 的行为:

const raw = { name: '张三', age: 18 }
const proxy = new Proxy(raw, {
  get(target, key) {
    console.log(`读取 ${key}`)
    return target[key]
  },
  set(target, key, value) {
    console.log(`设置 ${key} = ${value}`)
    target[key] = value
    return true
  }
})

此时,我们对 proxy 解构 const { name } = proxy ,它都会发生哪些事呢?

  1. 读取 proxy.name ,此时会触发 get 拦截 -- 没有问题
  2. 将获取到的值 张三 赋值给 name 变量 -- 问题产生了
  3. name 被重新赋值为一个普通的字符串,和 proxy 没有任何关系了
  4. 后续对 name 的操作都只是修改一个普通变量,不会触发任何拦截

解决方案

方案一:使用 toRefs(推荐)

import { reactive, toRefs } from 'vue'

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

// toRefs 将每个属性转换为 ref
const { name, age } = toRefs(user)

// 现在可以安全解构了
name.value = '李四' // ✅ 触发更新
age.value++ // ✅ 触发更新

toRefs 的简化原理:

function toRefs(obj) {
  const result = {}
  // 遍历对象的所有key
  for (const key in obj) {
    result[key] = toRef(obj, key) // 为每个属性单独创建 ref
  }
  return result
}

// 创建的 ref 和原对象保持连接
const nameRef = toRef(user, 'name')
nameRef.value = '李四' // 等价于 user.name = '李四'

方案二:使用 toRef 处理单个属性

import { reactive, toRef } from 'vue'

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

// 只需要处理个别属性
const name = toRef(user, 'name')
const age = toRef(user, 'age')

name.value = '李四' // ✅ 触发更新

方案三:直接用 ref

如果发现需要频繁解构,可能在一开始就应该使用 ref

const user = ref({
  name: '张三',
  age: 18
})

选择决策树

基于以上分析,我们可以建立一套清晰的选择决策树:

快速选择指南

选择决策树

决策依据详解

场景 推荐方案 原因
基础数据类型 ref reactive 无法处理基础类型
需要整体重置的表单 ref 支持直接替换 .value
组合式函数返回值 ref 方便使用者解构
复杂嵌套对象 reactive 语法更简洁
一次性初始化配置 reactive 不需要整体替换
需要解构的场景 ref + toRefs 保持响应性

最终建议

  • 默认用 refref 更灵活,适用场景更广,虽然多了 .value,但换来的是确定性和可预测性
  • 在特定场景用 reactive:当需要使用复杂对象且不需要解构时,reactive 能让代码更简洁
  • 要理解并善用工具函数toRefstoRefisRefisReactive
  • 团队统一规范:无论选择哪种策略,团队内要保持一致,避免混用导致混乱
  • 无法确定用哪个时:直接用 refref 是更安全、更通用的选择

结语

ref 是更安全、更通用的选择;reactive 则是在特定场景下的优化选择。理解了它们的设计哲学和适用场景,就能帮我们在适当的场合做出正确的选择。

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

解决 VSCode 中 ESLint 格式化不生效问题:新手也能看懂的配置指南

解决 VSCode 中 ESLint 格式化不生效问题:新手也能看懂的配置指南

入职新公司接手前端项目,相信很多同学都遇到过这样的糟心事:明明用了同事给的setting.json配置,代码格式化却依然不遵循项目的 ESLint 规则,手动改格式又费时间又容易出错。

我最近就踩了这个坑,折腾了一番终于搞定了,今天把完整的解决方案整理出来,帮大家少走弯路。

一、先确认项目基础配置

在配置 VSCode 之前,首先要确保项目本身的 ESLint 配置是完整的,这是格式化生效的前提。

1. 检查项目根目录的 ESLint 配置文件

首先查看项目根目录下是否存在 ESLint 的核心配置文件,常见的有:

  • .eslintrc.js(最常用,推荐)
  • .eslintrc.json
  • .eslintrc
  • package.json中配置的eslintConfig字段

如果没有这些文件,说明项目本身未配置 ESLint 规则,后续 VSCode 配置再全也没用。可以找同事要一份项目对应的 ESLint 配置,或根据项目技术栈(Vue/React/TS)初始化一份。

2. 确认项目依赖已安装

确保项目node_modules中包含 ESLint 核心依赖及对应插件,比如:

# 安装核心ESLint(如果项目未安装)
npm install eslint --save-dev

# 针对Vue项目补充依赖(示例)
npm install eslint-plugin-vue @vue/eslint-config-standard --save-dev

# 针对React项目补充依赖(示例)
npm install eslint-plugin-react eslint-plugin-react-hooks --save-dev

二、VSCode 端配置:让格式化走 ESLint 规则

1. 安装并启用 ESLint 扩展

打开 VSCode 扩展市场(快捷键Ctrl+Shift+X),搜索ESLint(作者是 dbaeumer),安装后确保启用(扩展卡片显示"已启用")。

2. 配置 settings.json:核心步骤

打开 VSCode 的设置文件(快捷键Ctrl+,,然后点击右上角"打开设置(JSON)"图标),添加以下配置:

{
  // 启用ESLint作为格式化工具
  "eslint.format.enable": true,
  // 指定ESLint需要校验的文件类型
  "eslint.validate": [
    "javascript",
    "javascriptreact",
    "vue",
    "typescript",
    "typescriptreact" // 如有TS/TSX需求可添加
  ],
  // 为不同文件类型指定默认格式化器为ESLint
  "[javascript]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  "[javascriptreact]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  "[vue]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  "[typescript]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  "[typescriptreact]": {
    "editor.defaultFormatter": "dbaeumer.vscode-eslint"
  },
  // 可选:自动保存(避免忘记保存导致格式化不生效)
  "files.autoSave": "afterDelay",
  // 可选:保存时自动格式化(核心!让保存即符合ESLint规则)
  "editor.formatOnSave": true,
  // 可选:保存时自动修复ESLint错误(比单纯格式化更强大)
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  // 可选:关闭其他可能冲突的格式化工具(如Prettier,避免规则冲突)
  "prettier.enable": false
}

关键配置说明

  • eslint.format.enable: 核心开关,允许 ESLint 作为格式化工具
  • eslint.validate: 告诉 ESLint 要处理哪些类型的文件,根据项目技术栈调整
  • editor.defaultFormatter: 为指定文件类型绑定 ESLint 作为默认格式化器,这是解决"格式化不走 ESLint"的核心
  • editor.codeActionsOnSave: 保存时自动修复 ESLint 错误(比如自动补分号、修正缩进),比单纯格式化更实用

三、常见问题排查

如果配置后仍不生效,按以下步骤排查:

  1. 重启 VSCode:修改settings.json后,重启编辑器让配置生效;
  2. 检查 ESLint 扩展状态:打开 VSCode 的"输出"面板(Ctrl+Shift+U),选择"ESLint",查看是否有报错(比如依赖缺失、配置文件语法错误);
  3. 确认文件类型:比如 Vue 文件是否被 VSCode 识别为"vue"类型(右下角可查看/修改);
  4. 排除规则冲突:如果项目同时配置了 Prettier,建议使用eslint-config-prettiereslint-plugin-prettier整合规则,避免冲突。

四、验证配置是否生效

  1. 打开项目中的一个 JS/Vue 文件,故意写一段不符合 ESLint 规则的代码(比如少分号、缩进错误);
  2. 按下Ctrl+S保存文件;
  3. 如果代码自动修正为符合 ESLint 规则的格式,说明配置成功。

总结

  1. 格式化生效的前提是项目有完整的 ESLint 配置文件和依赖,否则 VSCode 端配置无意义;
  2. VSCode 核心配置是绑定对应文件类型的默认格式化器为 ESLint,并启用保存自动修复;
  3. 配置后若不生效,优先检查 ESLint 扩展状态和配置文件语法,重启 VSCode 是简单有效的排查手段。

希望这篇指南能帮到刚入职新项目、被 ESLint 格式化困扰的同学,少踩坑,多写优雅的代码~

Vue 3 + Vite 自动引入插件完整指南(unplugin-vue-components,unplugin-auto-import)

Vue 3 + Vite 自动引入插件完整指南

介绍如何在 Vue 3 + Vite 项目中配置 unplugin-vue-components(自动引入组件)和 unplugin-auto-import(自动引入 API),实现零 import 开发体验


一、两个插件的区别

unplugin-vue-components unplugin-auto-import
作用 自动导入组件 自动导入 API / 函数
省去什么 import DictTag from '@/components/DictTag/index.vue' import { ref, computed } from 'vue'
生成的类型文件 components.d.ts auto-imports.d.ts

效果对比

使用前(手动导入):

<template>
  <DictTag :value="count" />
</template>

<script setup>
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import DictTag from "@/components/DictTag/index.vue";

const count = ref(0);
const doubled = computed(() => count.value * 2);
</script>

使用后(自动导入):

<template>
  <DictTag :value="count" />
  <!-- 自动导入组件 -->
</template>

<script setup>
const count = ref(0); // 自动导入 ref
const doubled = computed(() => count.value * 2); // 自动导入 computed
const router = useRouter(); // 自动导入 useRouter
</script>

二、从零搭建步骤

2.1 安装依赖

npm install -D unplugin-vue-components unplugin-auto-import

如果需要自动导入 Element Plus 等 UI 框架的组件和样式,不需要额外安装 resolver,它们已内置在 unplugin-vue-components 中。

2.2 配置 vite.config.ts

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import Components from "unplugin-vue-components/vite";
import AutoImport from "unplugin-auto-import/vite";
// 如需 Element Plus 按需导入,取消下面注释
// import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

export default defineConfig({
  plugins: [
    vue(),

    // ==========================================
    // 插件一:自动导入 API(ref、computed 等)
    // ==========================================
    AutoImport({
      // 需要自动导入的库
      imports: [
        "vue", // ref, computed, watch, onMounted 等
        "vue-router", // useRouter, useRoute 等
        "pinia", // defineStore, storeToRefs 等
        // '@vueuse/core', // 按需添加
      ],

      // 生成类型声明文件(让编辑器识别自动导入的 API)
      dts: "src/types/auto-imports.d.ts",

      // 是否在 Vue 模板中自动导入
      vueTemplate: true,

      // 如需自动导入 Element Plus 的 API(ElMessage 等),取消注释:
      // resolvers: [ElementPlusResolver()],

      // 生成 ESLint 配置(避免 eslint 报未定义错误)
      eslintrc: {
        enabled: true, // 首次生成后可改为 false
        filepath: "./.eslintrc-auto-import.json",
      },
    }),

    // ==========================================
    // 插件二:自动导入组件
    // ==========================================
    Components({
      // 指定组件扫描目录
      dirs: ["src/components"],

      // 递归扫描子目录
      deep: true,

      // 组件文件扩展名
      extensions: ["vue"],

      // 生成类型声明文件
      dts: "src/types/components.d.ts",

      // 如需自动导入 Element Plus 组件,取消注释:
      // resolvers: [ElementPlusResolver()],
    }),
  ],
});

2.3 配置 tsconfig.json

确保 TypeScript 能识别自动生成的类型文件:

{
  "compilerOptions": {
    // ... 其他配置
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.vue",
    "src/types/auto-imports.d.ts",
    "src/types/components.d.ts"
  ]
}

2.4 配置 ESLint(可选)

.eslintrc.cjs 中引入自动生成的全局变量声明:

module.exports = {
  extends: [
    // ... 其他配置
    "./.eslintrc-auto-import.json", // 自动导入的全局变量
  ],
};

三、组件目录结构

unplugin-vue-components 支持以下两种组件结构,组件名自动推导:

src/components/
│
├── MyButton.vue              → 组件名:<MyButton />
│
├── DictTag/
│   └── index.vue             → 组件名:<DictTag />
│
├── UserCard/
│   └── index.vue             → 组件名:<UserCard />
│
└── FileUpload/
    └── index.vue             → 组件名:<FileUpload />

四、自动生成的文件说明

启动项目后,插件会自动生成以下文件(不要手动修改,也建议加入 .gitignore):

src/types/components.d.ts(组件类型声明)

// 由 unplugin-vue-components 自动生成
declare module "vue" {
  export interface GlobalComponents {
    DictTag: (typeof import("../components/DictTag/index.vue"))["default"];
    FileUpload: (typeof import("../components/FileUpload/index.vue"))["default"];
    // ... 其他组件
  }
}

src/types/auto-imports.d.ts(API 类型声明)

// 由 unplugin-auto-import 自动生成
declare global {
  const ref: (typeof import("vue"))["ref"];
  const computed: (typeof import("vue"))["computed"];
  const watch: (typeof import("vue"))["watch"];
  const onMounted: (typeof import("vue"))["onMounted"];
  const useRouter: (typeof import("vue-router"))["useRouter"];
  // ... 其他 API
}

五、常用进阶配置

5.1 搭配 Element Plus 按需导入

npm install element-plus
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";

AutoImport({
  imports: ["vue", "vue-router"],
  resolvers: [ElementPlusResolver()], // 自动导入 ElMessage, ElNotification 等
});

Components({
  dirs: ["src/components"],
  resolvers: [ElementPlusResolver()], // 自动导入 <el-button>, <el-input> 等
});

5.2 自定义导入规则

AutoImport({
  imports: [
    "vue",
    "vue-router",
    {
      // 自定义导入:从 '@/utils/request' 自动导入 request 函数
      "@/utils/request": ["request", "download"],
      // 从 axios 自动导入
      axios: [["default", "axios"]],
    },
  ],
});

5.3 排除不需要自动注册的组件

Components({
  dirs: ["src/components"],
  // 排除特定目录
  exclude: [/\.test\./, /node_modules/],
});

六、常见问题排查

Q1:组件自动导入不生效?

检查项 解决方案
components.d.ts 为空 删除后重启 npm run dev,确保有页面访问触发编译
项目路径含特殊字符 ()[]{} 重命名路径,去掉括号等 glob 特殊字符
组件结构不对 确保是 ComponentName/index.vueComponentName.vue
dirs 路径错误 用绝对路径验证:dirs: [path.resolve(__dirname, 'src/components')]

Q2:ESLint 报 ref is not defined

确保:

  1. AutoImporteslintrc.enabled 设为 true 生成配置文件
  2. .eslintrc.cjs 中 extends 了 .eslintrc-auto-import.json
  3. 生成后可将 enabled 改回 false(避免每次启动都重写)

Q3:编辑器没有智能提示?

确保 tsconfig.jsoninclude 中包含了两个 .d.ts 文件路径。


七、工作原理简述

┌──────────────────────────────────────────────────┐
│                   Vite 编译流程                    │
├──────────────────────────────────────────────────┤
│                                                  │
│  .vue 文件 → Vite 编译                            │
│     │                                            │
│     ├── <template> 中发现 <DictTag />             │
│     │   └── unplugin-vue-components 介入          │
│     │       └── 自动注入:                         │
│     │           import DictTag from               │
│     │           '@/components/DictTag/index.vue'  │
│     │                                            │
│     ├── <script> 中发现 ref()                     │
│     │   └── unplugin-auto-import 介入             │
│     │       └── 自动注入:                         │
│     │           import { ref } from 'vue'         │
│     │                                            │
│     └── 编译产物(已包含所有 import)                │
│                                                  │
└──────────────────────────────────────────────────┘

核心点:两个插件都是在 Vite 编译阶段 介入的,它们不改变你的源码,而是在编译产物中自动注入需要的 import 语句。写代码时完全不需要手动 import。

路由与布局骨架篇:布局系统 | 头部、侧边栏、内容区、面包屑的拆分与复用

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、先搞清楚一件事:什么是布局?

布局(Layout)就是页面里不随路由变的那一部分:头部、侧边栏、面包屑、底部等。
真正随路由变化的是「内容区」。布局负责把这些固定区域包起来,内容区填进其中。

  • 布局:结构固定、多页面共用
  • 内容区:随路由切换、每页不同

理解了这一点,再去看 Vue Router 的嵌套路由,就很好理解。

二、为什么要拆分布局组件?

不拆的话,每个页面都要写一遍头部、侧边栏,会有这些问题:

  1. 重复代码多
  2. 改头部要改 N 个页面
  3. 页面结构和布局混在一起,难维护

拆分后:

  • 布局组件:只负责头部、侧边栏等固定结构
  • 内容区:只负责当前页面的业务
  • 路由:负责决定「用哪个布局」「在哪个槽位渲染内容」

三、整体结构预览

Layout(布局容器)
├── AppHeader(头部)
├── AppSidebar(侧边栏)
├── Breadcrumb(面包屑,可选)
└── 内容区(由 <router-view> 渲染)

接下来按「路由配置 → 布局组件 → 各子组件」的顺序说明。

四、路由配置:布局与路由如何配合?

核心思路:用嵌套路由,父路由用 Layout,子路由占内容区。

4.1 基础路由结构

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/layouts/BasicLayout.vue'

const routes = [
  {
    path: '/',
    component: Layout,  // 父路由使用布局组件
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/Dashboard.vue'),
        meta: { title: '仪表盘', icon: 'dashboard' }
      },
      {
        path: 'user',
        name: 'User',
        component: () => import('@/views/User.vue'),
        meta: { title: '用户管理', icon: 'user' }
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

要点:

  • 父路由 path: '/'Layout
  • children 里的每个路由才是具体页面
  • meta 用来存标题、图标等,后面给面包屑和菜单用

4.2 多个布局怎么办?

例如:后台用带侧边栏的布局,登录页用简单布局。

const routes = [
  // 后台布局(带侧边栏)
  {
    path: '/',
    component: () => import('@/layouts/BasicLayout.vue'),
    children: [
      { path: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '仪表盘' } },
      { path: 'user', component: () => import('@/views/User.vue'), meta: { title: '用户管理' } }
    ]
  },
  // 登录页布局(无侧边栏)
  {
    path: '/login',
    component: () => import('@/layouts/BlankLayout.vue'),
    children: [
      { path: '', component: () => import('@/views/Login.vue') }
    ]
  }
]

每个布局对应一个父路由,它的 children 共用同一个布局。

五、布局组件 BasicLayout.vue

5.1 完整示例

<!-- layouts/BasicLayout.vue -->
<template>
  <el-container class="basic-layout">
    <!-- 头部 -->
    <AppHeader />
    
    <el-container>
      <!-- 侧边栏 -->
      <AppSidebar />
      
      <!-- 主内容区 -->
      <el-main class="main-content">
        <!-- 面包屑 -->
        <Breadcrumb />
        <!-- 内容区:由路由渲染 -->
        <div class="content-wrapper">
          <router-view v-slot="{ Component }">
            <transition name="fade" mode="out-in">
              <component :is="Component" />
            </transition>
          </router-view>
        </div>
      </el-main>
    </el-container>
  </el-container>
</template>

<script setup>
import AppHeader from './components/AppHeader.vue'
import AppSidebar from './components/AppSidebar.vue'
import Breadcrumb from './components/Breadcrumb.vue'
</script>

<style scoped>
.basic-layout {
  min-height: 100vh;
  flex-direction: column;
}
.main-content {
  padding: 20px;
  background: #f5f7fa;
}
.content-wrapper {
  margin-top: 16px;
  padding: 20px;
  background: #fff;
  border-radius: 4px;
  min-height: calc(100vh - 180px);
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

要点:

  • <router-view> 就是子路由渲染的地方
  • v-slot="{ Component }" + <component :is="Component"> 可以配合过渡动画
  • 没有用 Element Plus 的话,把 el-container 换成普通 div 即可

六、各子组件实现

6.1 头部 AppHeader.vue

<!-- layouts/components/AppHeader.vue -->
<template>
  <header class="app-header">
    <div class="header-left">
      <span class="logo">后台管理系统</span>
    </div>
    <div class="header-right">
      <span class="user-name">管理员</span>
      <button @click="handleLogout">退出</button>
    </div>
  </header>
</template>

<script setup>
const handleLogout = () => {
  // 登出逻辑
  console.log('退出登录')
}
</script>

<style scoped>
.app-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 60px;
  padding: 0 24px;
  background: #001529;
  color: #fff;
}
.header-right {
  display: flex;
  align-items: center;
  gap: 16px;
}
</style>

6.2 侧边栏 AppSidebar.vue

侧边栏菜单需要和路由保持一致,用 routerroutes 或自己维护菜单配置都可以。

<!-- layouts/components/AppSidebar.vue -->
<template>
  <aside class="app-sidebar">
    <el-menu
      :default-active="activeMenu"
      router
      background-color="#001529"
      text-color="#fff"
    >
      <el-menu-item index="/dashboard">
        <span>仪表盘</span>
      </el-menu-item>
      <el-menu-item index="/user">
        <span>用户管理</span>
      </el-menu-item>
    </el-menu>
  </aside>
</template>

<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()

// 高亮当前路由对应的菜单项
const activeMenu = computed(() => route.path)
</script>

<style scoped>
.app-sidebar {
  width: 200px;
  background: #001529;
}
</style>

要点:

  • router 属性:点击菜单项会直接 router.push(index),无需手动处理
  • default-active 绑定当前路径,实现高亮

6.3 面包屑 Breadcrumb.vue

面包屑需要从当前路由推导出层级,用 route.matched 即可。

<!-- layouts/components/Breadcrumb.vue -->
<template>
  <el-breadcrumb separator="/" class="breadcrumb">
    <el-breadcrumb-item
      v-for="(item, index) in breadcrumbList"
      :key="item.path"
    >
      <!-- 最后一项不跳转 -->
      <router-link v-if="index < breadcrumbList.length - 1" :to="item.path">
        {{ item.meta?.title || item.name || '未命名' }}
      </router-link>
      <span v-else>{{ item.meta?.title || item.name || '未命名' }}</span>
    </el-breadcrumb-item>
  </el-breadcrumb>
</template>

<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()

// 从路由的 matched 自动生成面包屑
const breadcrumbList = computed(() => {
  return route.matched.filter(item => item.meta?.title || item.name)
})
</script>

<style scoped>
.breadcrumb {
  margin-bottom: 16px;
}
</style>

要点:

  • route.matched 是当前路由及其所有父路由的数组,正好对应面包屑层级
  • 最后一项用 <span>,前面的用 <router-link> 方便点击返回

七、常见坑点

坑 1:侧边栏和路由不同步

  • 原因:菜单写死在模板里,路由改了菜单没改
  • 做法:用 router.options.routes 或单独维护菜单配置,和路由保持一致,用 route.path 作为菜单的 index

坑 2:面包屑不显示或显示不对

  • 原因:route.matched 里的路由没有 meta.title
  • 做法:给每个需要出现在面包屑中的路由加上 meta: { title: 'xxx' },根路由如果是 redirect 可以不加或设为 hidden: true

坑 3:刷新后侧边栏高亮错误

  • 原因:default-active 没正确绑定到当前路径
  • 做法:用 computed(() => route.path) 绑定,并且菜单项的 index 和路由的 path 一致

坑 4:布局组件被重复创建

  • 原因:同一个父路由下的子路由切换时,Vue Router 默认会复用父级 Layout
  • 做法:这是正常行为。若需要在切换子路由时强制重挂载 Layout,可以给 router-view:key="route.fullPath",但一般不需要

八、菜单与路由统一:进阶写法

为了不重复维护「路由」和「菜单」,可以统一用路由生成菜单:

// 在 router 里定义好 meta
// 在 AppSidebar 里动态读取
import { useRouter } from 'vue-router'

const router = useRouter()
const menuRoutes = computed(() => {
  const parent = router.options.routes.find(r => r.path === '/')
  return (parent?.children || []).filter(r => !r.meta?.hidden)
})
<el-menu-item
  v-for="item in menuRoutes"
  :key="item.path"
  :index="'/' + item.path"
>
  {{ item.meta?.title }}
</el-menu-item>

这样菜单和路由只维护一份。

九、总结

模块 职责 与路由的关系
Layout 包裹头部、侧边栏、内容区 作为父路由的 component
Header 顶部固定区域 一般与路由无关
Sidebar 菜单导航 使用 routerroute.path 高亮
Breadcrumb 当前路径层级展示 依赖 route.matchedmeta
内容区 子页面内容 <router-view> 渲染

记住三步:

  1. 用嵌套路由,父用 Layout,子用具体页面
  2. 布局拆成 Header、Sidebar、Breadcrumb、router-view 四个区域
  3. 菜单、面包屑都从 routemeta 推导,避免重复配置

如果你希望我把某个小节展开(例如只用原生 div + CSS,或用 Vue 2 + Vue Router 3 版本),可以说一下具体需求,我可以再补一版对应示例。

🔍 本系列专栏导航

一、《路由与布局扫盲篇:Vue Router 实战 | 动态路由、嵌套路由与多级菜单》

二、《路由与布局扫盲篇:登录态与路由守卫 | token 校验、白名单、重定向》

三、《路由与布局扫盲篇:多标签页(Tab)与缓存 | keep-alive、includeexclude、路由 meta》

四、《路由与布局扫盲篇:布局系统 | 头部、侧边栏、内容区、面包屑的拆分与复用》

👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

Vue 3 新标准:<script setup> 核心特性、宏命令与避坑指南

<script setup> 是 Vue 3.2 引入的一种编译时语法糖,旨在简化 Composition API 的使用。它并不是一个新的功能,而是对原有 <script> 中使用 Composition API 写法的一种语法优化

简单来说,它让你用更少的代码更直观的写法来实现同样的功能,同时在性能上也有显著提升。


1. 核心对比:传统写法 vs <script setup>

❌ 传统写法 (Vue 3.2 之前)

你需要手动导入 API,定义数据/方法,并显式 return 给模板使用。

<script>
import { ref, reactive } from 'vue'

export default {
  components: { MyComponent }, // 需手动注册组件
  props: ['title'],           // 需手动定义 props
  
  setup(props, { emit }) {
    const count = ref(0)
    const user = reactive({ name: 'Alice' })
    
    function increment() {
      count.value++
    }

    // ⚠️ 必须手动 return,模板才能访问
    return {
      count,
      user,
      increment,
      title // props 也要 return
    }
  }
}
</script>

✅ <script setup> 写法

无需 export default,无需 return,顶层变量自动暴露。

<script setup>
import { ref, reactive } from 'vue'
import MyComponent from './MyComponent.vue' // ✅ 自动注册组件

// ✅ 直接定义 props (编译后自动生成)
defineProps(['title'])

// ✅ 直接定义 emits
const emit = defineEmits(['change'])

// 顶层变量自动暴露给模板,无需 return
const count = ref(0)
const user = reactive({ name: 'Alice' })

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

2. <script setup> 的五大核心好处

1. 代码更简洁(少写样板代码)

  • 无需 export default:组件选项直接在标签内定义。
  • 无需 return:在 <script setup> 中声明的所有顶层变量(reffunctionimport 的组件等)自动暴露给模板使用。这减少了大量的重复代码和出错可能。
  • 组件自动注册:导入的组件(如 import MyComp from ...)可以直接在模板中使用 <MyComp />,无需在 components 选项中注册。

2. 更好的 TypeScript 支持

  • 类型推导更精准:由于不需要通过 return 对象来暴露变量,TS 可以直接推断顶层变量的类型,无需复杂的泛型声明。
  • Props/Emits 类型化:配合 defineProps<Type>() 和 defineEmits<Type>(),可以获得完美的类型提示和校验,而传统写法需要繁琐的 withDefaults 或接口定义。

3. 更高的运行时性能

  • 编译优化<script setup> 的组件会被编译为一个匿名函数,作为 setup() 钩子的实现。
  • 避免代理开销:传统写法中,setup 返回的对象会被 Vue 包装成代理(Proxy)以便模板访问。而 <script setup> 中的绑定是通过闭包直接访问的,省去了创建代理对象的开销,访问速度更快。
  • Tree-shaking:未使用的代码更容易被打包工具剔除。

4. 逻辑更清晰

  • 消除“割裂感” :在传统写法中,定义的变量和模板中使用的变量之间隔着一个 return 块,阅读时需要上下跳转。<script setup> 让代码从上到下线性执行,定义即使用。
  • 专注于逻辑:开发者可以更专注于业务逻辑本身,而不是 Vue 的样板结构。

5. 原生支持宏(Macros)

提供了一些编译时宏,无需导入即可直接使用:

  • defineProps: 声明 props。
  • defineEmits: 声明 emits。
  • defineExpose: 显式暴露属性给父组件(默认情况下 <script setup> 组件实例是关闭的,即父组件无法通过 ref 访问其内部属性,除非使用此宏)。
  • defineOptions: (Vue 3.3+) 声明组件选项(如 nameinheritAttrs)。
  • withDefaults: 为 defineProps 设置默认值。

3. 特殊用法详解

A. 定义 Props 和 Emits

<script setup>
// 接收 props,具有类型推导
const props = defineProps({
  msg: String,
  count: { type: Number, required: true }
})

// 定义 emits
const emit = defineEmits(['update:count', 'submit'])

function update() {
  emit('update:count', props.count + 1)
}
</script>

B. 暴露给父组件 (defineExpose)

默认情况下,父组件通过 ref 获取子组件实例时,无法访问 <script setup> 内部的变量。如果需要暴露,必须显式声明:

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

const secret = 'hidden'
const publicData = ref(100)

function publicMethod() {
  console.log('called')
}

// 只暴露 publicData 和 publicMethod
defineExpose({
  publicData,
  publicMethod
})
</script>

C. 配合 TypeScript

<script setup lang="ts">
interface User {
  id: number
  name: string
}

// 泛型支持
const props = defineProps<{
  userId: number
  list: User[]
}>()

// 默认值
withDefaults(defineProps<{
  msg?: string
  labels?: string[]
}>(), {
  msg: 'Hello',
  labels: () => ['new'] // 对象/数组默认值需用工厂函数
})
</script>

4. 总结:为什么它是“最佳实践”?

特性 传统<script>+setup() <script setup>
代码量 多 (需 export, return, register) 极少 (声明即用)
性能 正常 (有代理开销) 更高 (闭包访问,无代理)
TS 支持 良好 (但需额外类型声明) 完美 (原生推导)
组件注册 手动 自动
推荐度 ⭐⭐ (兼容旧项目) ⭐⭐⭐⭐⭐ (新项目首选)

结论
除非你需要维护非常古老的 Vue 3 早期代码,否则在所有新的 Vue 3 项目中,都应该无条件使用 <script setup> 。它是 Vue 团队官方推荐的默认写法,代表了 Vue 未来的发展方向。

个人所得税计算器 在线工具核心JS实现

这篇只讲功能层 JavaScript 实现。这个工具的核心思路是:把税率规则、五险一金规则、累计预扣法放进一个计算类里,输入 12 个月收入和扣除配置后,一次产出整年的月度明细。

在线工具网址:see-tool.com/text-charac…
工具截图:
工具截图.png

1. 核心数据结构

计算器初始化时,先准备三类基础数据:

  • 累计预扣法税率表(含速算扣除数)
  • 城市社保/公积金缴费基数上下限
  • 每月减除费用(5000)
class IncomeTaxCalculator {
  constructor() {
    // 累计预扣税率区间
    this.taxBrackets = [
      { min: -1, max: 36000, rate: 0.03, deduction: 0 },
      { min: 36000, max: 144000, rate: 0.10, deduction: 2520 },
      { min: 144000, max: 300000, rate: 0.20, deduction: 16920 },
      { min: 300000, max: 420000, rate: 0.25, deduction: 31920 },
      { min: 420000, max: 660000, rate: 0.30, deduction: 52920 },
      { min: 660000, max: 960000, rate: 0.35, deduction: 85920 },
      { min: 960000, max: Infinity, rate: 0.45, deduction: 181920 }
    ]

    // 每月减除费用
    this.monthlyDeduction = 5000
  }
}

这一步把规则常量和计算逻辑解耦,后续计算函数不需要硬编码税率数字。

2. 月度五险一金计算

月度扣除支持三种基数来源:

  • 按月工资
  • 单一自定义基数
  • 养老/医疗/失业/公积金分别设置基数

关键点在“实际基数”处理:要同时受用户输入、封顶线、保底线、当月工资四个条件约束。

// 计算实际缴费基数
const getActualBase = (baseValue, capValue, floorValue, monthlyIncome) => {
  // 统一兜底,避免 NaN 和负数
  const safeBase = Math.max(0, baseValue || 0)
  const safeCap = Math.max(0, capValue || 0)
  const safeFloor = Math.max(0, floorValue || 0)

  // 先做上限约束:不能超过上限,也不能超过月薪
  let result = safeCap > 0
    ? Math.min(safeBase, safeCap, monthlyIncome)
    : Math.min(safeBase, monthlyIncome)

  // 再做下限约束
  if (safeFloor > 0) result = Math.max(result, safeFloor)

  return result
}

得到各项实际基数后,再乘对应费率得到养老、医疗、失业、公积金金额,并汇总 total。这一层输出会直接参与个税应纳税所得额计算。

3. 累计预扣税额计算

个税函数按“累计应纳税所得额”查税率区间并套公式:

calculateTaxForIncome(cumulativeIncome, cumulativeDeduction, cumulativeSpecialDeductions = 0) {
  // 累计应纳税所得额
  const taxableIncome = cumulativeIncome - cumulativeDeduction - cumulativeSpecialDeductions
  if (taxableIncome <= 0) return 0

  for (const bracket of this.taxBrackets) {
    if (taxableIncome > bracket.min && taxableIncome <= bracket.max) {
      return Math.max(0, taxableIncome * bracket.rate - bracket.deduction)
    }
  }

  // 兜底走最高档
  const highestBracket = this.taxBrackets[this.taxBrackets.length - 1]
  return Math.max(0, taxableIncome * highestBracket.rate - highestBracket.deduction)
}

这里返回的是“截至当前月的累计应纳税额”,不是当月税额。

4. 年度主流程:一次产出12个月明细

主流程会循环 12 次,每月做四件事:

  1. 计算当月五险一金
  2. 更新累计收入、累计专项扣除、累计附加扣除
  3. 计算累计应纳税额
  4. 反推当月税额并得到税后收入

核心公式是:

// 当月应纳税额 = 累计应纳税额 - 上月累计已纳税额
const monthlyTax = Math.max(0, cumulativeTaxAmount - cumulativeTax)

// 税后收入 = 税前收入 - 五险一金 - 当月个税
const afterTaxIncome = monthlyIncome - insurance.total - monthlyTax

每月结果会记录为结构化对象(收入、扣除、税额、税后、累计值等),最终返回一个 12 项数组,界面层可直接用于表格展示和汇总统计。

5. 工具方法

核心逻辑外还有两个实用方法:

  • 金额格式化:把数字转成带千分位、保留两位小数的货币字符串
  • 城市基数读取:按城市键返回对应的社保/公积金上下限配置

这两个方法让计算层对外输出更稳定,页面调用时不需要重复写格式化和城市映射逻辑。

整套实现的重点是“规则集中、计算分层、月度与累计并行维护”。这样既能保证个税计算口径一致,也方便后续扩展更多收入场景。

❌