普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月17日首页

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

作者 angerdream
2026年3月17日 16:43

1、watch概述

watch本意是监视、观察。它的功能就是监视数据的变化。数据一旦变化,就会产生两种数据:新数据、旧数据。 如业务场景中,当订单量大多某个数时,就发放优惠卷。watch非常重要,掌握好响应式数据、computed、watch,vue写功能不会有太大问题。 在vue官网明确表达,watch可以监视以下四种数据:

  • ref定义的数据
  • reactive定义的数据
  • 函数返回一个值(getter函数
  • 包含上诉三种值的数组

2、监视ref定义的基本类型数据

  • 创建组件Fish
  • 引入ref、watch
  • 创建响应式数据name、price
  • watch函数监视price变化
  • 当price超过10,watch停止监视price变化
<template>
  <h2>鱼类:{{ name }}</h2>
  <h2>价格:{{ price }}</h2>
  <button @click="addPrice()">增加价格</button>
</template>
<script setup>
import { ref, watch } from 'vue'
let name = ref('鲫鱼');
let price = ref(5);
function addPrice() {
  price.value += 1;
}
let stopWatchPrice = watch(price, (newValue, oldValue) => {
  console.log(newValue, oldValue);
  if (newValue > 10) {
    console.log(stopWatchPrice);
    stopWatchPrice.stop();
  }
})
</script>

运行效果事例: 在这里插入图片描述 注意watch函数,监视的是price,而不是price.value。当点击按钮,price超过10,虽然数据在增加,但不再监视price。watch函数返回对象,有stop函数,调用此函数,即可解除监视。控制台打印,其结构如下:

() => {
    effect2.stop();
    if (scope && scope.active) {
      remove(scope.effects, effect2);
    }
  }

3、监视ref定义的对象类型数据

监视对象类型的数据,与基础类型的数据不同。当对象中的数据变化时,是无法监视到,但当整个数据改变时,是可以监视的。特点如下:

  • 创建组件Fish,引入ref、watch
  • 创建响应式对象fish,let fish = ref({ name: '鲫鱼', price: 5 });
  • 当改变fish.name值时,无法监视fish的变化
  • 当改变fish.price值时,无法监视fish的变化
  • 当改变整条鱼时,能够监视fish变化
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{ fish.price }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changePrice()">修改鱼价</button>
  <button @click="changeFish()">更换真个鱼</button>
</template>
<script setup>
import { ref, watch } from 'vue'
let fish = ref({ name: '鲫鱼', price: 5 });

function changeName() {
  fish.value.name += '~';
}
function changePrice() {
  fish.value.price += 1;
}
function changeFish() {
  fish.value = { name: '鲤鱼', price: 10 };
}
watch(fish, (newValue, oldValue) => {
  console.log(newValue, oldValue);
})

运行效果如下: 在这里插入图片描述 当修改响应式对象成员变量时,不会引起fish watch函数运行。原因在于watch监视的不是fish.name而是fish。那么如何才能监视fish.namefish.price数据变化呢? watch函数,它有三个参数

  • 一是监视对象
  • 二是监视回调函数,
  • 三是配置对象参数,如deep等等 只有在配置对象开启deep即可。
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{ fish.price }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changePrice()">修改鱼价</button>
  <button @click="changeFish()">更换真个鱼</button>
</template>
<script setup>
import { ref, watch } from 'vue'
let fish = ref({ name: '鲫鱼', price: 5 });

function changeName() {
  fish.value.name += '~';
}
function changePrice() {
  fish.value.price += 1;
}
function changeFish() {
  fish.value = { name: '鲤鱼', price: 10 };
}
watch(fish, (newValue, oldValue) => {
  console.log(newValue, oldValue);
}, { deep: true })
</script>

运行效果,仔细观看控制台打印的新数据、旧数据。

  • fish.name改变时,新旧数据一样
  • fish.price改变时,新旧数据一样
  • 当fish整个改变时,新旧数据不一样 效果如图: 在这里插入图片描述 注意fish.namefish.price,新旧数据是一样的。因为watch是从对象地址取到的数据。

4、watch监视函数返回一个值(getter函数)

它的功能是wath监视响应式对象中一个属性,如监视fish.name,是不允许直接监视,需要写成一个函数的形式。

  • 创建组件Fish,引入reactive, watch
  • 创建响应式对象fish,鱼的名字,鱼的体型:长度、重量
  • 分别监听鱼的名字与体型
  • 点击按钮修改鱼类,鱼的长度、鱼的重量、鱼的体型
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>鱼长度:{{ fish.body.long }}</h2>
  <h2>鱼重量:{{ fish.body.weight }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changeFishLong()">修改鱼的长度</button>
  <button @click="changeFishWeight()">修改鱼的重量</button>
  <button @click="changeFishbody()">修改鱼的体型</button>
</template>
<script setup>
import { reactive, watch } from 'vue'
let fish = reactive({ name: '鲫鱼', body: { long: 1, weight: 24 } });

function changeName() {
  fish.name += '~';
}
function changeFishLong() {
  fish.body.long += 1;
}
function changeFishWeight() {
  fish.body.weight += 1;
}
function changeFishbody() {
  fish.body = { long: 100, weight: 300 };
}
watch(() => { return fish.name }, (newValue, oldValue) => {
  console.log('监听fish.name', newValue, oldValue);
})
watch(() => { return fish.body }, (newValue, oldValue) => {
  console.log('监听fish.body', newValue, oldValue);
})
</script>

监听响应式对象中的参数,需要写成一个箭头函数,并返回监听参数即可。具体操作,看下图: 在这里插入图片描述 当点击按钮,发现只有修改鱼类、修改鱼的体型,才能监听到变化。这是因为watch监听的地址。若想要能够监听到鱼的长度、鱼的重量,需要再watch加入deep参数即可。

watch(() => { return fish.body }, (newValue, oldValue) => {
  console.log('监听fish.body', newValue, oldValue);
}, { deep: true })

在这里插入图片描述 注意点击按钮修改鱼的长度、修改鱼的重量,新旧数据是一致的。

5、watch监视含有响应式对象数组的数据

watch监视的对象是一个数组,数组内可以是ref定义基本类型数据,也可是对象,可以是函数。

watch([() => { return fish.name },() => { return fish.body }], (newValue, oldValue) => {
  console.log('监听素组', newValue, oldValue);
}, { deep: true })

由于使用配置参数deep,操作效果如下: 在这里插入图片描述

具体代码

<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>鱼长度:{{ fish.body.long }}</h2>
  <h2>鱼重量:{{ fish.body.weight }}</h2>
  <button @click="changeName()">修改鱼类</button>
  <button @click="changeFishLong()">修改鱼的长度</button>
  <button @click="changeFishWeight()">修改鱼的重量</button>
  <button @click="changeFishbody()">修改鱼的体型</button>
</template>
<script setup>
import { reactive, watch } from 'vue'
let fish = reactive({ name: '鲫鱼', body: { long: 1, weight: 24 } });

function changeName() {
  fish.name += '~';
}
function changeFishLong() {
  fish.body.long += 1;
}
function changeFishWeight() {
  fish.body.weight += 1;
}
function changeFishbody() {
  fish.body = { long: 100, weight: 300 };
}
watch([() => { return fish.name },() => { return fish.body }], (newValue, oldValue) => {
  console.log('监听素组', newValue, oldValue);
}, { deep: true })
</script>

6、总结 watch可以监视四种数据,再加上配置函数,内容多且难记。在项目中多练习几次就能熟记。

  • ref定义的数据
  • reactive定义的数据
  • 函数返回一个值(getter函数
  • 包含上诉三种值的数组 在现实开发中,第一种和第三种情况最常用。尤其第三种情况,加函数,加配置参数deep。属于重中之重。
watch(() => { return fish.body }, (newValue, oldValue) => {
  console.log('监听fish.body', newValue, oldValue);
}, { deep: true })

Vue2 vs Vue3 全面对比(含代码示例+迁移指南)

作者 远山枫谷
2026年3月17日 14:16

Vue2 vs Vue3 全面对比(含代码示例+迁移指南)

作为前端开发者,Vue框架的升级迭代一直是我们关注的重点。从2019年Vue3发布beta版,到如今Vue3成为新项目的首选,两者之间的差异不仅体现在底层实现,更贯穿了开发流程的方方面面。今天我们就来全面拆解Vue2与Vue3的核心区别,结合代码示例帮你快速吃透差异,轻松应对项目迁移与开发选型。

本文将从「核心架构」「响应式原理」「语法特性」「性能优化」「生态工具」「迁移实践」6大维度展开,覆盖日常开发中90%以上会遇到的差异点,新手可快速入门,老开发者可查漏补缺。

一、核心架构:Options API vs Composition API

这是Vue2与Vue3最本质的区别,核心在于「代码组织方式」的不同——Vue2采用Options API(选项式API),Vue3引入Composition API(组合式API),同时兼容Options API,兼顾老项目迁移与新项目开发。

1. Vue2:Options API

Options API通过「选项」划分代码逻辑,将组件的逻辑拆分为data、methods、computed、watch、生命周期钩子等选项,结构固定,入门门槛低,但在复杂组件中会出现「逻辑分散」的问题。

比如一个包含数据请求、表单校验、状态管理的复杂组件,相关逻辑会分散在data、methods、mounted等不同选项中,后期维护时需要在多个选项间来回切换,可读性和可复用性较差。

<script>
// Vue2 Options API 示例
export default {
  // 数据
  data() {
    return {
      userInfo: null,
      loading: false,
      error: ''
    }
  },
  // 计算属性
  computed: {
    isUserLoaded() {
      return !!this.userInfo
    }
  },
  // 生命周期钩子
  mounted() {
    this.getUserInfo()
  },
  // 方法
  methods: {
    async getUserInfo() {
      this.loading = true
      try {
        const res = await fetch('/api/user')
        this.userInfo = await res.json()
      } catch (err) {
        this.error = err.message
      } finally {
        this.loading = false
      }
    }
  }
}
</script>

2. Vue3:Composition API

Composition API以「功能」为核心,通过组合函数(Composable)将相关逻辑聚合在一起,打破了Options API的选项限制,让代码组织更灵活,尤其适合复杂组件和逻辑复用。

Vue3中可以通过setup函数(或

<script setup>
// Vue3 Composition API 示例(<script setup>语法糖,推荐)
import { ref, computed, onMounted } from 'vue'

// 1. 定义响应式数据(替代data)
const userInfo = ref(null)
const loading = ref(false)
const error = ref('')

// 2. 计算属性(替代computed)
const isUserLoaded = computed(() => !!userInfo.value)

// 3. 逻辑抽离(可单独抽离为组合函数,供其他组件复用)
const getUserInfo = async () => {
  loading.value = true
  try {
    const res = await fetch('/api/user')
    userInfo.value = await res.json()
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
}

// 4. 生命周期钩子(替代mounted)
onMounted(() => {
  getUserInfo()
})
</script>

核心差异总结

维度 Vue2(Options API) Vue3(Composition API)
代码组织 按选项划分(data、methods等) 按功能聚合(组合函数)
逻辑复用 依赖mixins,易出现命名冲突 组合函数,无命名冲突,复用性更强
复杂组件 逻辑分散,维护困难 逻辑聚合,可读性高
入门难度 低,结构固定 稍高,需理解组合逻辑

二、响应式原理:Object.defineProperty vs Proxy

响应式是Vue的核心特性,Vue2和Vue3的响应式实现方式完全不同,这也是Vue3性能提升的关键原因之一。两者的核心差异在于「数据劫持的方式」,Vue2基于Object.defineProperty,Vue3基于Proxy+Reflect,后者从根本上解决了前者的诸多局限性。

1. Vue2:Object.defineProperty

Vue2通过Object.defineProperty劫持对象的getter和setter方法,实现对数据变化的监听。但这种方式存在3个明显的局限性,也是开发中常遇到的痛点:

  • 无法监听对象新增/删除的属性(需通过Vue.set、Vue.delete手动触发响应);
  • 无法监听数组的索引变化和长度变化(需重写数组方法,如push、splice等);
  • 只能劫持对象的属性,无法直接劫持整个对象,初始化时需递归遍历对象所有属性,性能开销较大。
// Vue2 响应式核心实现(简化版)
function defineReactive(obj, key, value) {
  // 递归监听嵌套对象
  if (typeof value === 'object' && value !== null) {
    observe(value)
  }
  Object.defineProperty(obj, key, {
    get() {
      // 依赖收集
      track(obj, key)
      return value
    },
    set(newValue) {
      if (newValue === value) return
      value = newValue
      // 触发更新
      trigger(obj, key)
    }
  })
}

// 监听对象
function observe(obj) {
  if (typeof obj !== 'object' || obj === null) return
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])
  })
}

// 痛点示例:新增属性无法监听
const obj = { name: 'Vue2' }
observe(obj)
obj.age = 3 // 新增属性,无法触发响应式更新
Vue.set(obj, 'age', 3) // 需手动调用Vue.set

2. Vue3:Proxy + Reflect

Vue3放弃了Object.defineProperty,转而使用ES6新增的Proxy(代理)和Reflect(反射),从根本上解决了Vue2的局限性。Proxy可以直接代理整个对象,而非单个属性,同时支持监听对象的所有操作(新增、删除、数组变化等),且无需递归遍历,性能更优。

Reflect则与Proxy相辅相成,提供了一套用于操作对象的方法集合,能更优雅地处理代理过程中的对象操作,比如自动传递this上下文、统一返回操作结果等,让代码更健壮。

// Vue3 响应式核心实现(简化版)
function reactive(obj) {
  return new Proxy(obj, {
    // 读取属性时触发
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver)
      // 依赖收集
      track(target, key)
      // 懒代理:嵌套对象访问时才创建代理,减少初始化性能开销
      if (typeof result === 'object' && result !== null) {
        return reactive(result)
      }
      return result
    },
    // 设置属性时触发
    set(target, key, value, receiver) {
      const oldValue = Reflect.get(target, key, receiver)
      const result = Reflect.set(target, key, value, receiver)
      if (oldValue !== value) {
        // 触发更新
        trigger(target, key)
      }
      return result
    },
    // 删除属性时触发
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      // 触发更新
      trigger(target, key)
      return result
    }
  })
}

// 优势示例:自动监听新增/删除属性、数组变化
const obj = reactive({ name: 'Vue3' })
obj.age = 1 // 新增属性,自动触发响应
delete obj.name // 删除属性,自动触发响应

const arr = reactive([1, 2, 3])
arr.push(4) // 数组操作,自动触发响应
arr[0] = 0 // 数组索引修改,自动触发响应

响应式差异总结

特性 Vue2(Object.defineProperty) Vue3(Proxy+Reflect)
对象新增属性 不支持,需手动调用Vue.set 支持,自动监听
对象删除属性 不支持,需手动调用Vue.delete 支持,自动监听
数组索引/长度变化 不支持,需使用重写方法 支持,自动监听
嵌套对象监听 初始化时递归遍历,性能差 懒代理,访问时才监听,性能优
数据类型支持 仅支持对象/数组 支持对象、数组、Map、Set等

三、生命周期钩子:命名调整与使用方式变化

Vue3的生命周期钩子基本沿用了Vue2的逻辑,但进行了部分命名调整,同时适配Composition API的使用方式,新增了setup钩子(Composition API的入口),废弃了部分钩子。

1. 生命周期钩子对应关系

Vue2(Options API) Vue3(Options API) Vue3(Composition API,需导入)
beforeCreate beforeCreate(兼容) setup(替代,执行时机更早)
created created(兼容) setup(替代)
beforeMount beforeMount(兼容) onBeforeMount
mounted mounted(兼容) onMounted
beforeUpdate beforeUpdate(兼容) onBeforeUpdate
updated updated(兼容) onUpdated
beforeDestroy beforeUnmount(重命名) onBeforeUnmount
destroyed unmounted(重命名) onUnmounted
activated activated(兼容) onActivated
deactivated deactivated(兼容) onDeactivated

2. 核心变化说明

  • setup钩子:替代beforeCreate和created,是Composition API的入口,执行时机在beforeCreate之前,此时组件实例尚未创建,无法访问this(Vue3中Composition API不依赖this);
  • 钩子重命名:beforeDestroy → beforeUnmount,destroyed → unmounted,更贴合语义(组件卸载而非销毁);
  • Composition API中使用钩子:需从vue中导入对应的钩子函数,然后在setup中调用,支持多次调用(按调用顺序执行)。
<script setup>
// Vue3 Composition API 生命周期使用示例
import { onMounted, onBeforeUnmount, onUpdated } from 'vue'

// 组件挂载后执行
onMounted(() => {
  console.log('组件挂载完成')
})

// 组件更新前执行
onUpdated(() => {
  console.log('组件更新完成')
})

// 组件卸载前执行
onBeforeUnmount(() => {
  console.log('组件即将卸载')
})
</script>

四、模板语法:增强与简化

Vue3的模板语法基本兼容Vue2,但新增了部分实用特性,同时简化了部分语法,提升开发效率,减少冗余代码。

1. 新增特性

(1)多根节点(Fragments)

Vue2中组件模板只能有一个根节点(需用div等标签包裹),否则会报错;Vue3支持多根节点,无需额外包裹,减少DOM层级冗余,优化渲染性能。

// Vue2(错误示例:多根节点)
<template>
  <h1>Vue2</h1>
  <p>只能有一个根节点</p>
</template>

// Vue2(正确示例:需包裹div)
<template>
  <div>
    <h1>Vue2</h1>
    <p>只能有一个根节点</p>
  </div>
</template>

// Vue3(正确示例:多根节点)
<template>
  <h1>Vue3</h1>
  <p>支持多根节点,无需包裹</p>
</template>
(2)v-model 语法简化与增强

Vue2中v-model本质是:value + @input的语法糖,且只能绑定一个值;Vue3简化了v-model的使用,同时支持多值绑定、自定义修饰符,统一了组件通信的语法。

// Vue2 v-model 使用(单一绑定)
<template>
  <input v-model="value">
  // 等价于
  <input :value="value" @input="value = $event.target.value">
</template>

// Vue3 v-model 使用(多值绑定+自定义修饰符)
<template>
  // 1. 单一绑定(简化,无需手动处理$event)
  <input v-model="value">
  
  // 2. 多值绑定(绑定多个属性)
  <ChildComponent 
    v-model:name="name" 
    v-model:age="age"
  />
  
  // 3. 自定义修饰符(如v-model.trim)
  <input v-model.trim="value">
</template>
(3)动态指令参数

Vue3支持动态绑定指令参数,让指令使用更灵活,可根据数据动态切换指令的目标(如动态绑定v-bind、v-on的参数)。

<script setup>
import { ref } from 'vue'
const propName = ref('title')
const eventName = ref('click')
</script>

<template>
  // 动态绑定v-bind参数
  <div v-bind:[propName]="'Vue3动态指令'"></div>
  
  // 动态绑定v-on参数
  <button v-on:[eventName]="handleClick">点击</button>
</template>
(4)Teleport(瞬移组件)

Vue3新增Teleport组件,可将组件内容“瞬移”到页面的任意DOM节点中(如body),解决了弹窗、模态框等组件的层级问题,无需担心父组件的样式隔离影响。

<template>
  <teleport to="body">
    <div class="modal">
      这是一个弹窗,将被渲染到body中
    </div>
  </teleport>
</template>
(5)Suspense(异步组件占位)

Vue3新增Suspense组件,用于异步组件的加载占位,可在异步组件加载完成前显示loading状态,加载失败时显示错误提示,简化异步组件的处理逻辑。

<template>
  <suspense>
    <template #default>
      // 异步组件(需动态导入)
      <AsyncComponent />
    </template>
    <template #fallback>
      // 加载中占位
      <div>加载中...</div>
    </template>
  </suspense>
</template>

<script setup>
// 动态导入异步组件
const AsyncComponent = defineAsyncComponent(() => import('./AsyncComponent.vue'))
</script>

2. 废弃特性

  • v-on.native修饰符:Vue3中组件的原生事件无需使用.native修饰符,可直接绑定,若需区分组件自定义事件和原生事件,可通过emits选项声明自定义事件;
  • 过滤器(filter):Vue3废弃了过滤器,推荐使用计算属性或全局方法替代(过滤器的功能可完全通过计算属性实现,且更灵活);
  • v-bind.sync修饰符:Vue3中可用v-model:xxx替代,统一了双向绑定的语法。

五、性能优化:全方位提升

Vue3在性能上做了大量优化,相比Vue2,渲染速度提升30%+,打包体积减少约50%,主要优化点集中在编译优化、响应式优化、体积优化三个方面。

1. 编译优化

Vue3重写了模板编译逻辑,引入「静态标记(Patch Flag)」和「Block Tree」机制,实现按需更新,减少不必要的DOM操作:

  • 静态标记:编译模板时,对静态节点(不随数据变化的节点)添加标记,更新时跳过静态节点,只处理动态节点;
  • Block Tree:将模板拆分为多个Block(代码块),每个Block对应一个动态节点集合,更新时只遍历对应Block的动态节点,而非整个虚拟DOM树。

2. 响应式优化

如前文所述,Vue3使用Proxy+Reflect替代Object.defineProperty,实现以下优化:

  • 懒代理:嵌套对象只有在访问时才会创建代理,减少初始化时的性能开销;
  • 精准监听:只监听变化的属性,无需遍历整个对象,更新更高效;
  • 支持更多数据类型:Map、Set等集合类型也能实现响应式,满足更多开发场景。

3. 体积优化

Vue3支持Tree-shaking(树摇),只打包项目中用到的API,未使用的功能(如过滤器、v-on.native等)不会被打包,核心包体积从Vue2的约20KB缩减到最小10KB左右,大幅提升项目加载速度。

六、TypeScript支持:从兼容到原生

Vue2对TypeScript的支持较差,需要通过vue-class-component、vue-property-decorator等第三方库实现TS支持,且类型推导不精准,开发体验不佳;Vue3则是基于TypeScript原生开发的,天生支持TS,类型推导更精准,开发体验大幅提升。

核心差异

  • Vue2:需额外配置第三方库,类型定义不完整,组件内this指向不明确,类型推导困难;
  • Vue3:原生支持TS,Composition API的函数式写法更易推导类型,defineProps、defineEmits等宏支持泛型定义,模板中表达式的类型错误可在编译时被捕获,且核心代码的类型定义更完善。
<script setup lang="ts">
// Vue3 + TS 示例
import { ref, computed } from 'vue'

// 1. 基础类型响应式数据
const count = ref<number>(0)

// 2. 复杂类型响应式数据
interface User {
  name: string
  age: number
}
const user = ref<User | null>(null)

// 3. 计算属性类型推导
const doubleCount = computed(() => count.value * 2) // 自动推导为number类型

// 4. 组件props类型定义(defineProps宏)
const props = defineProps<{
  title: string
  count?: number // 可选属性
}>()

// 5. 组件事件类型定义(defineEmits宏)
const emit = defineEmits<{
  (e: 'change', value: number): void
}>()
</script>

七、生态与工具链:全面升级

Vue3的生态系统也同步升级,核心工具和第三方库均已适配Vue3,同时新增了更高效的开发工具,提升开发体验。

1. 核心工具

工具 Vue2 Vue3
构建工具 Vue CLI(基于Webpack) Vite(推荐,基于ESBuild,冷启动更快)、Vue CLI(兼容)
路由 vue-router@3.x vue-router@4.x(适配Composition API,支持TS)
状态管理 Vuex@3.x Pinia(推荐,更轻量、支持TS、API更简洁)、Vuex@4.x(兼容)
UI组件库 Element UI、Vuetify@2.x Element Plus、Vuetify@3.x、Ant Design Vue@3.x

2. 全局API变化

Vue3对全局API进行了重构,从“全局挂载”改为“实例化挂载”,支持多实例隔离,避免全局污染,同时简化了部分API的使用。

// Vue2 全局API使用
import Vue from 'vue'
import App from './App.vue'

// 全局注册组件
Vue.component('MyComponent', MyComponent)

// 全局注册指令
Vue.directive('my-directive', {})

// 全局配置
Vue.config.productionTip = false

// 创建实例
new Vue({
  render: h => h(App)
}).$mount('#app')

// Vue3 全局API使用(实例化方式)
import { createApp } from 'vue'
import App from './App.vue'

// 创建app实例
const app = createApp(App)

// 实例注册组件
app.component('MyComponent', MyComponent)

// 实例注册指令
app.directive('my-directive', {})

// 实例配置
app.config.productionTip = false

// 挂载实例
app.mount('#app')

八、实战迁移指南:从Vue2到Vue3

对于现有Vue2项目,无需一次性全部迁移,可采用“渐进式迁移”策略,逐步替换组件和逻辑,降低迁移成本。以下是具体迁移步骤和注意事项:

1. 迁移前准备

  • 检查依赖兼容性:升级核心依赖(Vue、vue-router、Vuex/Pinia),确保第三方组件库和插件支持Vue3(如Element UI替换为Element Plus);
  • 检查语法兼容性:移除Vue2中废弃的特性(过滤器、v-on.native、v-bind.sync等),替换为Vue3的替代方案;
  • 创建迁移分支:建议在Git中创建专门的迁移分支,避免影响主分支的正常开发。

2. 核心依赖升级命令

# 卸载Vue2相关依赖
npm remove vue vue-router vuex

# 安装Vue3相关依赖
npm install vue@3.2.x vue-router@4.x pinia@2.x

# 安装Vue3编译器(若使用Vue CLI)
npm install @vue/compiler-sfc@3.2.x -D

3. 逐步迁移组件

  • 优先迁移简单组件(如公共组件、基础组件),再迁移复杂组件;
  • 将Options API组件逐步改为Composition API(使用
  • 替换响应式数据写法:将data中的数据替换为ref/reactive,methods中的方法改为普通函数,computed/watch替换为对应的Composition API。

4. 常见迁移问题解决

  • this指向问题:Vue3的Composition API中无this,需通过ref/reactive的.value访问响应式数据;
  • 组件通信问题:将emit替换为defineEmitsemit替换为defineEmits,props替换为defineProps,parent/parent/children替换为provide/inject;
  • 事件总线问题:Vue3废弃了on/on/off/$once,可使用mitt等第三方库实现事件总线功能。

九、总结:该选择Vue2还是Vue3?

经过全面对比,Vue3在核心架构、响应式原理、性能、TS支持等方面均优于Vue2,且完全兼容Vue2的Options API,是未来的主流方向。结合实际开发场景,给出以下选型建议:

  • 新项目:优先选择Vue3 + Vite + Pinia + TS,享受更高效的开发体验和更优的性能;
  • 现有Vue2项目:若项目稳定,无需强制迁移;若需要新增复杂功能或优化性能,可采用渐进式迁移策略,逐步升级;
  • 新手学习:直接学习Vue3,Composition API的思想更贴合现代前端开发,且未来就业需求更高。

Vue3的升级不仅是技术的迭代,更是开发理念的升级——从“按选项组织代码”到“按功能组合代码”,让开发更灵活、更高效、更易维护。希望本文能帮助你全面掌握Vue2与Vue3的区别,顺利完成项目迁移和技术升级!

最后,如果你在迁移过程中遇到问题,或者有其他Vue相关的疑问,欢迎在评论区留言交流~

vue表格vxe-table实现表头合并,分组表头自定义合并

作者 卤蛋fg6
2026年3月17日 11:53

在开发后台管理系统时,经常会遇到需要展示复杂表格的场景,其中表头合并(多级表头、不规则合并)是一项常见需求。vxe-table 是一款功能强大的 Vue 表格组件,它不仅支持树形分组表头,还提供了自定义列头合并的功能,允许开发者灵活地将任意单元格进行合并,满足各种复杂的表头设计。

形分组表头 vs 自定义合并表头

vxe-table 默认支持树形分组表头,只需在列配置中定义 children 即可实现多级表头。例如:

columns: [
  { field: 'name', title: '姓名' },
  {
    title: '基本信息',
    children: [
      { field: 'sex', title: '性别' },
      { field: 'age', title: '年龄' }
    ]
  }
]

这种方式的优点是简单直观,但只能按层级自动生成表头,无法实现跨层级的任意合并(例如合并第一列的“姓名”和“性别”)。

开启自定义表头合并

定义合并表头则允许我们完全控制表头的每个单元格,通过 mergeHeaderCells 配置将任意位置的单元格合并,实现更灵活的布局。

  • 要使用自定义合并,需要在表格组件上设置两个关键属性:
  • show-custom-header:开启自定义表头渲染模式。
  • merge-header-cells:定义合并规则的数组。

mergeHeaderCells 配置详解

  • 行/列索引规则:
  • 行索引从上到下递增,0 表示最顶层的表头行。
  • 列索引从左到右递增,0 表示第一列(通常是序号列或第一列数据列)。 如果存在多级表头,最终渲染的表头行数由列配置的层级深度决定。

例如,合并第一列和第二列(两行高度)的规则为:

mergeHeaderCells: [
  { row: 0, col: 0, rowspan: 2, colspan: 1 }, // 合并第一列的两行
  { row: 0, col: 1, rowspan: 2, colspan: 1 }  // 合并第二列的两行
]

代码

image

<template>
  <div>
    <vxe-button @click="setMerge1">设置合并1</vxe-button>
    <vxe-button @click="setMerge2">设置合并2</vxe-button>
    <vxe-button status="success" @click="saveMergeData">获取合并规则</vxe-button>

    <vxe-grid  ref="gridRef" v-bind="gridOptions"></vxe-grid>
  </div>
</template>

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

const gridRef = ref()

const gridOptions = reactive({
  border: true,
  showCustomHeader: true,
  height: 400,
  mergeHeaderCells: [
    { row: 0, col: 0, rowspan: 2, colspan: 1 },
    { row: 0, col: 1, rowspan: 2, colspan: 1 },
    { row: 0, col: 2, rowspan: 1, colspan: 2 },
    { row: 0, col: 4, rowspan: 1, colspan: 2 },
    { row: 1, col: 6, rowspan: 1, colspan: 2 },
    { row: 0, col: 8, rowspan: 2, colspan: 1 }
  ],
  columns: [
    { type: 'seq', width: 70 },
    { field: 'name', title: 'Name' },
    {
      title: 'Group1',
      field: 'group1',
      headerAlign: 'center',
      children: [
        { field: 'sex', title: 'Sex' },
        { field: 'age', title: 'Age' }
      ]
    },
    {
      field: 'group3',
      title: 'Group3',
      headerAlign: 'center',
      children: [
        { field: 'attr5', title: 'Attr5' },
        { field: 'attr6', title: 'Attr6' }
      ]
    },
    {
      field: 'group6',
      title: 'Attr3',
      children: [
        { field: 'attr3', title: 'Group8', headerAlign: 'center' }
      ]
    },
    {
      field: 'group8',
      title: 'Attr4',
      children: [
        { field: 'attr4', title: 'Attr4' }
      ]
    },
    { field: 'address', title: 'Address' }
  ],
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 46, attr3: 22, attr4: 100, attr5: 66, attr6: 86, address: 'Guangzhou' },
    { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Shenzheng' },
    { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 0, attr3: 22, attr4: 0, attr5: 0, attr6: 0, address: 'Shanghai' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Guangzhou' },
    { id: 10005, name: 'Test5', role: 'Test', sex: 'Women', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Shenzheng' },
    { id: 10006, name: 'Test6', role: 'Develop', sex: 'Man', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Guangzhou' },
    { id: 10007, name: 'Test7', role: 'Designer', sex: 'Women', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Guangzhou' },
    { id: 10008, name: 'Test8', role: 'Test', sex: 'Man', age: 0, attr3: 0, attr4: 0, attr5: 0, attr6: 0, address: 'Guangzhou' }
  ]
})

const setMerge1 = () => {
  gridOptions.mergeHeaderCells = [
    { row: 0, col: 0, rowspan: 2, colspan: 1 },
    { row: 0, col: 1, rowspan: 2, colspan: 1 },
    { row: 0, col: 2, rowspan: 1, colspan: 2 },
    { row: 0, col: 4, rowspan: 1, colspan: 2 },
    { row: 1, col: 6, rowspan: 1, colspan: 2 },
    { row: 0, col: 8, rowspan: 2, colspan: 1 }
  ]
}

const setMerge2 = () => {
  gridOptions.mergeHeaderCells = [
    { row: 0, col: 0, rowspan: 2, colspan: 1 },
    { row: 0, col: 1, rowspan: 2, colspan: 1 },
    { row: 0, col: 2, rowspan: 1, colspan: 4 },
    { row: 1, col: 6, rowspan: 1, colspan: 3 }
  ]
}

const saveMergeData = () => {
  const $grid = gridRef.value
  if ($grid) {
    const mergeList = $grid.getMergeHeaderCells()
    console.log(mergeList)
  }
}
</script>

当使用自定义表头合并后,被合并的列将不支持通过拖拽调整列宽。这是因为合并后的单元格在结构上已经不是独立的列,拖拽行为难以精确定义。如果需要调整列宽,建议在合并前规划好列宽,或通过固定宽度配置。

vxetable.cn

事件监听器销毁完全指南:如何避免内存泄漏?

作者 wuhen_n
2026年3月17日 09:24

前言

我们在实际开发中可能遇到过这样的情况:打开一个网页,一开始很流畅,但后面越用越卡;尤其是切换页面后,感觉浏览器变慢了;长时间不刷新,页面最终崩溃了。

这很可能就是 内存泄漏 在作祟。

想象一下:我们有个垃圾桶,每天都在往里面扔垃圾,但从来不倒。一开始没什么问题,但一个月后,垃圾堆满了屋子,我们连站的地方都没有了。

事件监听器导致的内存泄漏,就是这样——垃圾不倒,导致越积越多。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,深入探讨事件监听器导致内存泄漏的成因、检测方法、预防措施,以及 TypeScript 如何帮助我们构建类型安全的清理策略。

为什么事件监听器会成为内存杀手?

从一个简单的例子开始

App.vue

<template>
  <div>
    <button @click="show = !show">切换组件</button>
    <ChildComponent v-if="show" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>

ChildComponent.vue

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

onMounted(() => {
  // 每次组件挂载时,都添加一个滚动监听
  window.addEventListener('scroll', () => {
    console.log('滚动位置:', window.scrollY)
  })
})
</script>

这看起来没什么问题,但实际上发生了什么呢? 每次切换组件,都会增加一个新的监听器! 当成百上千次切换后,就有上千个监听器在工作...

为什么没有自动清理?

很多人都以为只要组件销毁了,它里面的东西会自动清理。但事实是:

  • Vue 可以自动清理:组件的数据、事件、计算属性等
  • Vue 不能自动清理:window/document 上的事件、定时器、WebSocket 等

内存泄漏的危害有多大?

指标 正常状态 泄漏状态 影响
内存占用 50MB 500MB+ 页面卡顿,甚至崩溃
事件响应 即时 延迟1-2秒 用户体验差
CPU使用率 10% 60%+ 电脑发烫,风扇狂转
电池消耗 正常 快3倍 移动端灾难

三种事件注册方式及其清理

三种注册方式对比

注册方式 优点 缺点 清理方法
内联事件 简单直接 无法移除多个,污染HTML 赋值为null
属性赋值 可移除 只能绑定一个 赋值为null
addEventListener 可绑定多个,灵活 需要对应 remove removeEventListener

内联事件的清理

// 移除内联事件
const button = document.querySelector('button')
button.onclick = null

// 或者移除整个元素
button.remove()

// 更彻底:清空父元素内容
parent.innerHTML = ''  // 会移除所有子元素的事件

注:实际 Vue 开发中,不推荐直接使用内联事件,推荐使用 Vue 的事件绑定 @click 等。

属性赋值的清理

// 注册
window.onresize = handleResize
document.onkeydown = handleKeyDown
button.onclick = handleClick

// 清理
window.onresize = null
document.onkeydown = null
button.onclick = null

注:属性赋值只能有一个监听器 window.onresize = fn1 window.onresize = fn2
此时 fn2 会覆盖 fn1

addEventListener 的正确清理

function handleResize() {
  console.log('resize')
}
window.addEventListener('resize', handleResize)
window.removeEventListener('resize', handleResize)

为什么 removeEventListener 有时候不工作?

场景一:匿名函数无法移除

window.addEventListener('click', () => {})
window.removeEventListener('click', () => {})  // ❌ 错误:匿名函数无法移除

因为匿名函数每次创建时都是新的,会重复创建,因此无法移除。

场景二:capture 参数不同,无法移除

window.addEventListener('click', handleClick, true)
window.removeEventListener('click', handleClick, false)  //   ❌ 错误::capture 不同,无法移除

场景三:options 对象不同,无法移除

const options1 = { passive: true }
const options2 = { passive: true }
element.addEventListener('click', handleClick, options1)
element.removeEventListener('click', handleClick, options2)  //  ❌ 错误:不同对象,无法移除

一句话总结:removeEventListener 的参数必须和 addEventListener 完全一致才能移除。

Vue 组件中的事件清理

最基本的清理模式

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

const scrollTop = ref(0)

// 1. 使用具名函数
function handleScroll() {
  scrollTop.value = window.scrollY
}

onMounted(() => {
  // 2. 注册事件
  window.addEventListener('scroll', handleScroll)
})

onUnmounted(() => {
  // 3. 组件卸载时移除事件
  window.removeEventListener('scroll', handleScroll)
})
</script>

<template>
  <div>滚动位置: {{ scrollTop }}</div>
</template>

封装可复用的组合式函数

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

export function useEventListener(target, event, handler) {
  // 确保 target 存在
  if (!target?.addEventListener) return
  
  // 注册
  onMounted(() => {
    target.addEventListener(event, handler)
  })
  
  // 自动清理
  onUnmounted(() => {
    target.removeEventListener(event, handler)
  })
}

使用示例:

useEventListener(window, 'resize', () => {
  console.log('窗口大小变化', window.innerWidth)
})

useEventListener(document, 'visibilitychange', () => {
  console.log('页面可见性变化')
})

useEventListener(document, 'keydown', (e) => {
  if (e.key === 'Escape') {
    console.log('按下 ESC 键')
  }
})

支持多个事件的组合式函数

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

export function useWindowEvents(handlers) {
  const entries = Object.entries(handlers)
  
  onMounted(() => {
    entries.forEach(([event, handler]) => {
      window.addEventListener(event, handler)
    })
  })
  
  onUnmounted(() => {
    entries.forEach(([event, handler]) => {
      window.removeEventListener(event, handler)
    })
  })
}

使用示例:

useWindowEvents({
  resize: () => console.log('resize'),
  scroll: () => console.log('scroll'),
  click: (e) => console.log('click at', e.clientX, e.clientY)
})

返回清理函数的 Hook 模式

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

export function useResizeObserver(target) {
  const width = ref(0)
  const height = ref(0)
  
  // 创建观察者
  const observer = new ResizeObserver((entries) => {
    const entry = entries[0]
    if (entry) {
      width.value = entry.contentRect.width
      height.value = entry.contentRect.height
    }
  })
  
  // 开始观察
  const el = unref(target)
  if (el) {
    observer.observe(el)
  }
  
  // 返回清理函数
  const cleanup = () => {
    observer.disconnect()
  }
  
  // 组件卸载时自动清理
  onUnmounted(cleanup)
  
  return {
    width,
    height,
    cleanup  // 也可以手动调用
  }
}

使用示例:

const container = ref()
const { width, height } = useResizeObserver(container)

内存泄漏的检测与诊断

Chrome DevTools 内存面板使用

// 步骤1:录制内存分配时间线
// Performance 面板 → Memory 勾选 → 开始录制
// 执行可能导致泄漏的操作 → 停止录制
// 查看内存曲线:正常应该波动后回落,泄漏会持续增长

// 步骤2:拍摄堆快照
// Memory 面板 → Take heap snapshot

// 步骤3:对比快照
// 操作前后各拍一次 → 选择 Comparison 视图
// 重点查看:
// - Detached 元素(已从 DOM 移除但未被回收)
// - 增加的 EventListener 数量
// - 新增的闭包引用

// 步骤4:使用 Allocation instrumentation on timeline
// 实时记录内存分配,定位泄漏的具体代码

Performance Monitor 实时监控

// 在 DevTools 中打开 Performance Monitor(Ctrl+Shift+P 搜索)
// 关注指标:
// - JS Heap size:堆内存大小,正常应该稳定在某个范围
// - DOM Nodes:DOM 节点数量,动态内容应有增有减
// - Event Listeners:事件监听器数量,不应无限增长
// - Documents:文档数量,通常为1

// 正常情况:操作前后指标应该基本持平
// 泄漏情况:指标持续增长,不会下降

手动检测代码

// 在开发环境添加监控工具
if (import.meta.env.DEV) {
  // 每5秒输出一次内存状态
  setInterval(() => {
    console.table({
      '时间': new Date().toLocaleTimeString(),
      'JS Heap': formatBytes((performance as any).memory?.usedJSHeapSize),
      'DOM Nodes': document.querySelectorAll('*').length,
      'Event Listeners': countEventListeners(),
      'Detached Nodes': countDetachedNodes()
    })
  }, 5000)
}

function countEventListeners(): number {
  // 遍历所有 DOM 元素,统计监听器(仅限 Chrome)
  let count = 0
  const allElements = document.querySelectorAll('*')
  
  allElements.forEach(el => {
    const listeners = (el as any).getEventListeners?.()
    if (listeners) {
      count += Object.values(listeners).flat().length
    }
  })
  
  return count
}

function countDetachedNodes(): number {
  // 统计已从 DOM 移除但未被回收的元素
  const heapSnapshot = (window as any).heapSnapshot
  if (!heapSnapshot) return 0
  
  let count = 0
  // 遍历堆快照统计 detached 元素
  // 具体实现依赖 DevTools 协议
  return count
}

function formatBytes(bytes: number): string {
  if (bytes < 1024) return bytes + ' B'
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
  return (bytes / (1024 * 1024)).toFixed(2) + ' MB'
}

常见陷阱与解决方案

陷阱一:在循环中注册事件

// ❌ 错误:每秒增加一个监听器
setInterval(() => {
  window.addEventListener('resize', () => {
    console.log('resize')
  })
}, 1000)

// ✅ 正确:只注册一次
window.addEventListener('resize', () => {
  console.log('resize')
})

setInterval(() => {
  // 做其他事
}, 1000)

陷阱二:watch 中注册事件

// ❌ 错误:每次 ID 变化都增加监听器
watch(() => route.params.id, () => {
  window.addEventListener('scroll', handleScroll)
})

// ✅ 正确:只注册一次
onMounted(() => {
  window.addEventListener('scroll', handleScroll)
})

function handleScroll() {
  // 根据当前 ID 做不同处理
  if (route.params.id) {
    console.log('当前ID:', route.params.id)
  }
}

onUnmounted(() => {
  window.removeEventListener('scroll', handleScroll)
})

陷阱三:箭头函数的 this 问题

class Component {
  data = 'test'
  
  // ❌ 错误:每次调用都创建新函数
  render() {
    button.addEventListener('click', () => {
      console.log(this.data)  // 无法移除
    })
  }
  
  // ✅ 正确:使用类属性方法
  handleClick = () => {
    console.log(this.data)
  }
  
  render() {
    button.addEventListener('click', this.handleClick)
    // 可以移除
    button.removeEventListener('click', this.handleClick)
  }
}

陷阱四:第三方库不销毁

import Swiper from 'swiper'
import * as echarts from 'echarts'

let swiper = null
let chart = null

onMounted(() => {
  // ❌ 只创建不销毁
  swiper = new Swiper('.swiper', {})
  chart = echarts.init(document.getElementById('chart'))
})

onUnmounted(() => {
  // ✅ 必须调用销毁方法
  if (swiper) {
    swiper.destroy(true, true)
    swiper = null
  }
  
  if (chart) {
    chart.dispose()
    chart = null
  }
})

最佳实践清单

开发时 Checklist

  • 每个 addEventListener 都有对应的 removeEventListener
  • 清理函数是否在 onUnmounted 中调用?
  • 匿名函数是否改成了具名函数或变量引用?
  • 节流/防抖的定时器是否清理了?
  • IntersectionObserver/ResizeObserver 是否调用了 disconnect
  • 第三方库实例是否调用了 destroydispose 方法?
  • 动态添加的元素,事件是否在移除元素时清理?

代码审查 Checklist

  • 是否有在循环或高频操作中注册事件?
  • 事件回调中是否持有大量数据的引用?(可能导致内存泄漏)
  • 多个组件共享的全局事件,是否考虑了竞态条件?
  • 组件销毁时,是否清理了所有自定义事件?
  • 使用 once 选项的事件是否确实只需要执行一次?

性能监控 Checklist

  • 是否定期检查 DevTools 的 Event Listeners 数量?
  • 是否有内存泄漏的自动化测试?
  • 生产环境是否有内存监控告警?
  • 是否建立了性能基准,跟踪内存趋势?
  • 是否在关键操作前后进行了内存快照对比?

注册清理对应表

注册 清理
addEventListener removeEventListener
setInterval clearInterval
setTimeout clearTimeout
new Observer observer.disconnect()
new WebSocket websocket.close()
new Swiper swiper.destroy()
echarts.init chart.dispose()

结语

好的代码不仅要能运行,还要能优雅地停止。学会正确地清理事件监听器,是每个前端开发者从入门到进阶的必修课。

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

昨天以前首页

vue一次解决监听H5软键盘弹出和收起的兼容问题

2026年3月16日 17:47

H5软键盘弹出和收起在安卓和ios以及不同浏览器之间存在不同的表现形式,网上也找不到更全面的解决方案,为此自己研究出能兼容主流浏览器的解决方案。

Screenshot_2026-03-13-11-38-23-641_com.android.chrome.jpg

问题

在做手机评论功能的交互时,必须要通过监听软键盘弹出和收起来实现,比如实现“点击回复评论弹出键盘,收起键盘就取消回复操作,输入框清空输入值”。

在研究过程中发现有以下几个问题:

  1. 安卓非谷歌浏览器高度会变化,横屏时不会收起键盘
  2. 安卓最新谷歌浏览器高度不会变化(页面上推),横屏时会收起键盘,但会触发resize事件
  3. 安卓收起键盘后input可能并未失焦
  4. 有些浏览器可能会多次触发resize事件
  5. ios收起键盘页面会上滚

完整的代码放在最下面。

使用方式

当页面只有一个输入框的情况下使用

<script setup>
import keyboard from "./keyboard";
const vKeyboard = keyboard;

const keyboardFn = val => {
  console.log(val ? "弹出键盘" : "收起键盘")
};
</script>

<template>
    <input v-keyboard="keyboardFn" />
</template>

完整代码

const isIOS = /iphone|ipad|ipod/.test(navigator.userAgent.toLocaleLowerCase());
const originHeight =
  document.documentElement.clientHeight || document.body.clientHeight;
let scrollTop = 0;

const keyBoard = {
  mounted(el, binding, vnode) {
    const isFocus = ref(false);
    const isResiz = ref(0);
    const isChange = ref(false);
    const isHeight = ref(false);

    el.resizeFn = () => {
      const resizeHeight =
        document.documentElement.clientHeight || document.body.clientHeight;
      if (resizeHeight < originHeight) {
        isChange.value = true;
        isHeight.value = true;
      } else {
        isHeight.value = false;
      }
      if (isFocus.value) isResiz.value++;
    };
    
    if (!isIOS) {
      window.addEventListener("resize", el.resizeFn);
      // 第1种情况处理方式
      watch(isHeight, () => {
        if (isChange.value && isFocus.value && !isHeight.value) {
          binding.value(false);
        }
      });
      // 第2种情况处理方式
      watch(isResiz, () => {
        if (!isChange.value && isFocus.value && isResiz.value > 1) {
          binding.value(false);
        }
      });
    }

    el.handlerFocusin = () => {
      if (!isIOS) {
        isFocus.value = true;
        binding.value(true);
      } else {
        binding.value(true);
        scrollTop = document.documentElement.scrollTop;
      }
    };
    
    el.handlerFocusout = () => {
      if (!isIOS) {
        // 先失焦后后收起键盘的情况处理
        if (isFocus.value) {
          binding.value(false);
        }
        isFocus.value = false;
        isChange.value = false;
        isHeight.value = false;
        isResiz.value = 0;
      } else {
        binding.value(false);
        // 处理 iOS 收起软键盘页面会上滚问题
        setTimeout(() => window.scrollTo({ top: scrollTop }), 50);
      }
    };
    
    el.addEventListener("focusin", el.handlerFocusin);
    el.addEventListener("focusout", el.handlerFocusout);
  },
  
  unmounted(el) {
    window.removeEventListener("resize", el.resizeFn);
    el.removeEventListener("focusin", el.handlerFocusin);
    el.removeEventListener("focusout", el.handlerFocusout);
  }
};

export default keyBoard;

Pinia 状态管理实战 | 从 0 到 1 搭建 Vue3 项目状态层(附模块化 / 持久化)

作者 代码煮茶
2026年3月16日 17:02

Pinia 状态管理实战 | 从 0 到 1 搭建 Vue3 项目状态层(附模块化 / 持久化)

一、为什么是 Pinia?

还记得 Vuex 吗?那个陪伴我们多年的状态管理库,有着严格的 mutations、actions 分工,写起来像在写 Java——虽然严谨,但也繁琐。

// Vuex 时代的痛
mutations: {
  SET_USER(state, user) {
    state.user = user
  }
},
actions: {
  async fetchUser({ commit }) {
    const user = await api.getUser()
    commit('SET_USER', user) // 绕了一大圈
  }
}

而 Pinia 来了,它说:「简单点,写代码的方式简单点」

// Pinia 的快乐
export const useUserStore = defineStore('user', {
  state: () => ({ user: null }),
  actions: {
    async fetchUser() {
      this.user = await api.getUser() // 直接赋值,爽!
    }
  }
})

1.1 Pinia 的核心优势

特性 Vuex Pinia
mutations ✅ 必须写 ❌ 没了
TypeScript 支持 😖 痛苦 😎 原生支持
代码量 少 30%
学习曲线 陡峭 平缓
DevTools ✅ 更好

二、项目初始化:从 0 开始搭建状态层

承接上一节的 Vite 项目,我们来深度拆解状态管理。

2.1 安装 Pinia

npm install pinia
npm install pinia-plugin-persistedstate # 持久化插件(后面会讲)

2.2 在 main.ts 中注册

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

// 创建 Pinia 实例
const pinia = createPinia()

const app = createApp(App)

// 注册插件(顺序很重要:先 Pinia,后路由)
app.use(pinia)
app.use(router)

app.mount('#app')

三、Store 的两种写法:你pick哪一种?

Pinia 支持两种 Store 定义方式,就像 Vue 有 Options API 和 Composition API 一样。

3.1 Options Store(类似 Vuex 风格)

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

export const useCounterStore = defineStore('counter', {
  // state:数据源
  state: () => ({
    count: 0,
    name: '计数器'
  }),
  
  // getters:计算属性
  getters: {
    doubleCount: (state) => state.count * 2,
    // 使用 this 访问其他 getter
    displayText(): string {
      return `${this.name}: ${this.count} (翻倍后: ${this.doubleCount})`
    }
  },
  
  // actions:方法(支持同步异步)
  actions: {
    increment(amount = 1) {
      this.count += amount
    },
    async fetchAndSetCount() {
      // 模拟异步请求
      const res = await fetch('/api/count')
      const data = await res.json()
      this.count = data.count
    }
  }
})

3.2 Setup Store(Composition API 风格)⭐推荐

// stores/counter.ts (Setup Store)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // state:用 ref/reactive
  const count = ref(0)
  const name = ref('计数器')
  
  // getters:用 computed
  const doubleCount = computed(() => count.value * 2)
  const displayText = computed(() => {
    return `${name.value}: ${count.value} (翻倍后: ${doubleCount.value})`
  })
  
  // actions:普通函数
  function increment(amount = 1) {
    count.value += amount
  }
  
  async function fetchAndSetCount() {
    const res = await fetch('/api/count')
    const data = await res.json()
    count.value = data.count
  }
  
  // 必须返回所有暴露的内容
  return {
    count,
    name,
    doubleCount,
    displayText,
    increment,
    fetchAndSetCount
  }
})

为什么推荐 Setup Store?

  • 更灵活,可以组合复用逻辑
  • TypeScript 类型推导更好
  • 符合 Vue3 Composition API 的心智模型

四、模块化设计:把大象装进冰箱分几步?

企业级项目最忌讳「一个大 Store 管所有」。正确的姿势是:按业务模块拆分

4.1 推荐的项目结构

src/stores/
├── index.ts              # 统一导出
├── modules/
│   ├── user.ts           # 用户模块
│   ├── cart.ts           # 购物车模块
│   ├── product.ts        # 商品模块
│   └── app.ts            # 应用配置(主题/语言等)
├── composables/          # 可复用的组合逻辑
│   ├── useAuth.ts
│   └── useCache.ts
└── plugins/              # Pinia 插件
    └── logger.ts

4.2 用户模块(完整示例)

// stores/modules/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo, LoginParams } from '@/types/user'
import { loginApi, getUserInfoApi } from '@/api/user'
import { ElMessage } from 'element-plus'

export const useUserStore = defineStore('user', () => {
  // --- State ---
  const token = ref<string | null>(localStorage.getItem('token'))
  const userInfo = ref<UserInfo | null>(null)
  const permissions = ref<string[]>([])
  
  // --- Getters ---
  const isLoggedIn = computed(() => !!token.value)
  const userName = computed(() => userInfo.value?.name || '游客')
  const userRole = computed(() => userInfo.value?.role || 'guest')
  const hasPermission = computed(() => (perm: string) => {
    return permissions.value.includes(perm) || userRole.value === 'admin'
  })
  
  // --- Actions ---
  // 登录
  async function login(params: LoginParams) {
    try {
      const res = await loginApi(params)
      token.value = res.token
      userInfo.value = res.userInfo
      permissions.value = res.permissions || []
      
      // 同步到 localStorage
      localStorage.setItem('token', res.token)
      
      ElMessage.success('登录成功')
      return true
    } catch (error) {
      ElMessage.error('登录失败:' + (error as Error).message)
      return false
    }
  }
  
  // 登出
  function logout() {
    token.value = null
    userInfo.value = null
    permissions.value = []
    localStorage.removeItem('token')
    ElMessage.success('已退出登录')
  }
  
  // 获取用户信息
  async function fetchUserInfo() {
    if (!token.value) return
    
    try {
      const res = await getUserInfoApi()
      userInfo.value = res.userInfo
      permissions.value = res.permissions
    } catch (error) {
      console.error('获取用户信息失败:', error)
      // token 无效,自动登出
      if ((error as any).response?.status === 401) {
        logout()
      }
    }
  }
  
  // 更新用户信息
  function updateUserInfo(data: Partial<UserInfo>) {
    if (userInfo.value) {
      userInfo.value = { ...userInfo.value, ...data }
    }
  }
  
  return {
    // state
    token,
    userInfo,
    permissions,
    // getters
    isLoggedIn,
    userName,
    userRole,
    hasPermission,
    // actions
    login,
    logout,
    fetchUserInfo,
    updateUserInfo
  }
})

4.3 应用配置模块(主题/语言)

// stores/modules/app.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

type Theme = 'light' | 'dark'
type Language = 'zh' | 'en'

export const useAppStore = defineStore('app', () => {
  // 从 localStorage 读取初始值
  const getInitialTheme = (): Theme => {
    const saved = localStorage.getItem('theme') as Theme
    return saved || 'light'
  }
  
  const getInitialLanguage = (): Language => {
    const saved = localStorage.getItem('language') as Language
    return saved || 'zh'
  }
  
  // State
  const theme = ref<Theme>(getInitialTheme())
  const language = ref<Language>(getInitialLanguage())
  const sidebarCollapsed = ref(false)
  
  // Getters
  const isDark = computed(() => theme.value === 'dark')
  const currentLanguage = computed(() => language.value)
  
  // Actions
  function setTheme(newTheme: Theme) {
    theme.value = newTheme
    localStorage.setItem('theme', newTheme)
    
    // 更新 HTML 的 data-theme 属性(用于 CSS 变量)
    document.documentElement.setAttribute('data-theme', newTheme)
  }
  
  function toggleTheme() {
    setTheme(theme.value === 'light' ? 'dark' : 'light')
  }
  
  function setLanguage(lang: Language) {
    language.value = lang
    localStorage.setItem('language', lang)
  }
  
  function toggleSidebar() {
    sidebarCollapsed.value = !sidebarCollapsed.value
  }
  
  return {
    theme,
    language,
    sidebarCollapsed,
    isDark,
    currentLanguage,
    setTheme,
    toggleTheme,
    setLanguage,
    toggleSidebar
  }
})

4.4 统一导出(方便使用)

// stores/index.ts
export { useUserStore } from './modules/user'
export { useAppStore } from './modules/app'
export { useCartStore } from './modules/cart'
export { useProductStore } from './modules/product'

// 如果需要,可以创建一个组合多个 store 的 hook
import { useUserStore } from './modules/user'
import { useAppStore } from './modules/app'

export const useStore = () => ({
  user: useUserStore(),
  app: useAppStore()
})

五、持久化:让状态「记住」自己

5.1 问题场景

用户登录后刷新页面,状态丢了——这是初学者最常见的困惑。

// 刷新后,token 没了,又要重新登录
// 用户体验:???

5.2 解决方案:pinia-plugin-persistedstate

npm install pinia-plugin-persistedstate
// src/main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // 注册插件

5.3 基本用法

// stores/modules/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    token: null,
    userInfo: null
  }),
  persist: true // 一键开启持久化
})

就这么简单!默认会:

  • 使用 localStorage
  • key 为 store名(这里是 'user')
  • 自动同步整个 state

5.4 高级配置:按需持久化

有时候我们不想存所有东西(比如敏感信息、临时数据):

// stores/modules/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    token: null,
    userInfo: null,
    tempSearchKeyword: '', // 这个不想持久化
    loginTime: null
  }),
  persist: {
    key: 'user-storage', // 自定义存储 key
    storage: localStorage, // 可选 sessionStorage
    paths: ['token', 'userInfo'], // 只持久化这两个字段
    beforeRestore: (context) => {
      console.log('即将恢复状态', context)
    },
    afterRestore: (context) => {
      console.log('状态恢复完成', context)
    }
  }
})

5.5 Setup Store 的持久化写法

// stores/modules/app.ts
export const useAppStore = defineStore('app', () => {
  const theme = ref('light')
  const language = ref('zh')
  
  // ... 其他逻辑
  
  return {
    theme,
    language
  }
}, {
  persist: {
    key: 'app-settings',
    paths: ['theme', 'language'] // 只持久化主题和语言
  }
})

5.6 多标签页同步

如果你想让多个标签页的状态保持同步,可以这样配置:

// stores/modules/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    token: null
  }),
  persist: {
    storage: localStorage,
    // 监听 storage 事件,实现多标签页同步
    beforeRestore: (context) => {
      window.addEventListener('storage', (e) => {
        if (e.key === 'user-storage') {
          // 重新恢复状态
          context.store.$hydrate()
        }
      })
    }
  }
})

六、Store 组合与复用(类似 Composables)

这是 Pinia 最强大的特性之一:Store 可以像组合式函数一样复用-5

6.1 场景:多个模块需要认证逻辑

假设你的应用有多个模块都需要用到用户认证状态,不想在每个 Store 里重复写一遍登录/登出逻辑。

// stores/composables/useAuth.ts
import { ref, computed } from 'vue'

export function useAuth() {
  const isLoggedIn = ref(false)
  const username = ref('')
  
  function login(name: string) {
    isLoggedIn.value = true
    username.value = name
  }
  
  function logout() {
    isLoggedIn.value = false
    username.value = ''
  }
  
  return {
    isLoggedIn,
    username,
    login,
    logout
  }
}

6.2 在 Store 中复用

// stores/modules/user.ts
import { defineStore } from 'pinia'
import { useAuth } from '../composables/useAuth'

export const useUserStore = defineStore('user', () => {
  // 复用认证逻辑
  const { isLoggedIn, username, login, logout } = useAuth()
  
  // 扩展用户专属状态
  const userId = ref<number | null>(null)
  const avatar = ref('')
  
  // 扩展登录方法
  const loginWithId = (name: string, id: number) => {
    login(name) // 调用复用的 login
    userId.value = id
  }
  
  return {
    isLoggedIn,
    username,
    userId,
    avatar,
    login: loginWithId,
    logout
  }
})

// stores/modules/admin.ts
import { defineStore } from 'pinia'
import { useAuth } from '../composables/useAuth'

export const useAdminStore = defineStore('admin', () => {
  // 同样复用认证逻辑
  const { isLoggedIn, username, login, logout } = useAuth()
  
  // 管理员特有的状态
  const adminLevel = ref(1)
  
  return {
    isLoggedIn,
    username,
    adminLevel,
    login,
    logout
  }
})

6.3 场景:数据缓存逻辑复用

多个模块都需要缓存数据(比如商品列表、订单列表),可以封装一个通用的缓存逻辑-5

// stores/composables/useCache.ts
import { ref } from 'vue'

export function useCache<T>(key: string, fetchFn: () => Promise<T>, expireTime = 5 * 60 * 1000) {
  const cachedData = ref<T | null>(null)
  const lastFetchTime = ref<number | null>(null)
  
  const getData = async () => {
    const now = Date.now()
    
    // 如果有缓存且未过期,直接返回缓存
    if (cachedData.value && lastFetchTime.value && (now - lastFetchTime.value) < expireTime) {
      console.log(`[缓存命中] ${key}`)
      return cachedData.value
    }
    
    // 否则重新获取
    console.log(`[缓存失效] ${key},重新获取`)
    const freshData = await fetchFn()
    cachedData.value = freshData
    lastFetchTime.value = now
    return freshData
  }
  
  const clearCache = () => {
    cachedData.value = null
    lastFetchTime.value = null
  }
  
  return {
    getData,
    clearCache,
    cachedData
  }
}
// stores/modules/product.ts
import { defineStore } from 'pinia'
import { useCache } from '../composables/useCache'
import { fetchProductList } from '@/api/product'

export const useProductStore = defineStore('product', () => {
  const { getData, clearCache, cachedData } = useCache(
    'products',
    fetchProductList,
    10 * 60 * 1000 // 10分钟缓存
  )
  
  const loadProducts = async () => {
    return await getData()
  }
  
  return {
    products: cachedData,
    loadProducts,
    clearCache
  }
})

七、在组件中使用:三种姿势

7.1 基础用法(最常用)

<!-- views/Profile.vue -->
<template>
  <div class="profile">
    <h2>个人中心</h2>
    
    <div v-if="userStore.isLoggedIn">
      <el-avatar :src="userStore.userInfo?.avatar" />
      <p>用户名:{{ userStore.userName }}</p>
      <p>角色:{{ userStore.userRole }}</p>
      
      <el-button @click="handleLogout">退出登录</el-button>
    </div>
    
    <div v-else>
      <p>请先登录</p>
      <el-button @click="goToLogin">去登录</el-button>
    </div>
    
    <!-- 测试权限指令 -->
    <button v-if="userStore.hasPermission('product:edit')">
      编辑商品
    </button>
  </div>
</template>

<script setup lang="ts">
import { useUserStore } from '@/stores/modules/user'
import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'

const userStore = useUserStore()
const router = useRouter()

const handleLogout = () => {
  ElMessageBox.confirm('确认退出登录吗?', '提示', {
    type: 'info'
  }).then(() => {
    userStore.logout()
    router.push('/login')
  })
}

const goToLogin = () => {
  router.push('/login')
}
</script>

7.2 解构赋值(小心丢失响应性)

<script setup>
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia' // 重要!

const userStore = useUserStore()

// ❌ 错误:直接解构会丢失响应性
const { userName, isLoggedIn } = userStore

// ✅ 正确:使用 storeToRefs
const { userName, isLoggedIn, userInfo } = storeToRefs(userStore)

// actions 可以直接解构(不会丢失)
const { login, logout } = userStore
</script>

7.3 在路由守卫中使用

// src/router/index.ts
import { useUserStore } from '@/stores/modules/user'

router.beforeEach((to, from, next) => {
  // 需要手动获取 store 实例
  const userStore = useUserStore()
  
  if (to.meta.requiresAuth && !userStore.isLoggedIn) {
    next({ path: '/login', query: { redirect: to.fullPath } })
  } else {
    next()
  }
})

7.4 在 axios 拦截器中使用

// src/utils/request.ts
import { useUserStore } from '@/stores/modules/user'

request.interceptors.request.use((config) => {
  const userStore = useUserStore()
  
  if (userStore.token) {
    config.headers.Authorization = `Bearer ${userStore.token}`
  }
  
  return config
})

request.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      const userStore = useUserStore()
      userStore.logout() // 自动清除状态
      router.push('/login')
    }
    return Promise.reject(error)
  }
)

八、Pinia 插件开发:定制你的专属功能

8.1 日志插件:记录所有状态变化

// stores/plugins/logger.ts
import type { PiniaPluginContext } from 'pinia'

export function loggerPlugin({ store, options }: PiniaPluginContext) {
  // 订阅 state 变化
  store.$subscribe((mutation, state) => {
    console.group(`📝 [${store.$id}] 状态变化`)
    console.log('类型:', mutation.type)
    console.log('载荷:', mutation.payload)
    console.log('新状态:', state)
    console.groupEnd()
  })
  
  // 订阅 action 调用
  store.$onAction(({
    name,       // action 名称
    store,      // store 实例
    args,       // 参数
    after,      // 成功后回调
    onError     // 失败后回调
  }) => {
    console.log(`🚀 [${store.$id}] 调用 action: ${name}`, args)
    
    after(result => {
      console.log(`✅ [${store.$id}] action 成功: ${name}`, result)
    })
    
    onError(error => {
      console.error(`❌ [${store.$id}] action 失败: ${name}`, error)
    })
  })
}

8.2 注册插件

// src/main.ts
import { loggerPlugin } from './stores/plugins/logger'

const pinia = createPinia()
pinia.use(loggerPlugin) // 全局生效

8.3 自定义持久化插件

// stores/plugins/customPersist.ts
export function customPersist({ store }: PiniaPluginContext) {
  // 从 localStorage 恢复状态
  const savedState = localStorage.getItem(`pinia:${store.$id}`)
  if (savedState) {
    store.$patch(JSON.parse(savedState))
  }
  
  // 订阅变化并保存
  store.$subscribe((mutation, state) => {
    localStorage.setItem(`pinia:${store.$id}`, JSON.stringify(state))
  })
}

九、性能优化与最佳实践

9.1 避免在 getter 中返回新对象

// ❌ 错误:每次访问都返回新对象,破坏缓存
getters: {
  filteredList: (state) => {
    return state.list.filter(item => item.active) // 每次都是新数组
  }
}

// ✅ 正确:getter 本身会缓存计算结果
getters: {
  activeCount: (state) => state.list.filter(item => item.active).length
}

9.2 按需加载 Store

// 在组件中动态导入(适用于大型应用)
const useUserStore = () => import('@/stores/user').then(m => m.useUserStore)

// 或者在路由懒加载时使用
const UserModule = () => import('@/views/User.vue')

9.3 使用 shallowRef 优化大对象

import { shallowRef } from 'vue'

// 对于大型对象,不需要深度响应式
const bigData = shallowRef(null)

// 只有整体替换时才触发更新
bigData.value = await fetchLargeDataset()

9.4 重置 Store 状态

// 添加重置方法
export const useUserStore = defineStore('user', () => {
  const initialState = {
    token: null,
    userInfo: null,
    permissions: []
  }
  
  const token = ref(initialState.token)
  const userInfo = ref(initialState.userInfo)
  const permissions = ref(initialState.permissions)
  
  function $reset() {
    token.value = initialState.token
    userInfo.value = initialState.userInfo
    permissions.value = initialState.permissions
    localStorage.removeItem('token')
  }
  
  return {
    token,
    userInfo,
    permissions,
    $reset,
    // ... 其他 actions
  }
})

十、TypeScript 类型增强

10.1 为 store 添加类型

// stores/modules/user.ts
import type { UserInfo } from '@/types/user'

export interface UserState {
  token: string | null
  userInfo: UserInfo | null
  permissions: string[]
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    token: null,
    userInfo: null,
    permissions: []
  })
})

10.2 扩展 Pinia 类型(为所有 store 添加通用方法)

// types/pinia.d.ts
import 'pinia'

declare module 'pinia' {
  export interface PiniaCustomProperties {
    // 给所有 store 添加 $reset 方法
    $reset(): void
    
    // 添加自定义属性
    readonly $id: string
  }
  
  export interface PiniaCustomStateProperties<S> {
    // 给所有 state 添加 toJSON 方法
    toJSON(): S
  }
}

10.3 为插件添加类型

// stores/plugins/logger.ts
import type { PiniaPluginContext } from 'pinia'

export interface LoggerPluginOptions {
  enabled?: boolean
  filter?: (storeId: string) => boolean
}

export function loggerPlugin(options: LoggerPluginOptions = {}) {
  return (context: PiniaPluginContext) => {
    // 插件逻辑
  }
}

十一、实战演练:完整的购物车模块

让我们把学到的知识串起来,实现一个完整的购物车模块。

11.1 购物车 Store

// stores/modules/cart.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { CartItem, Product } from '@/types'
import { ElMessage } from 'element-plus'

export const useCartStore = defineStore('cart', () => {
  // --- State ---
  const items = ref<CartItem[]>([])
  const loading = ref(false)
  const lastUpdated = ref<Date | null>(null)
  
  // --- Getters ---
  const totalCount = computed(() => {
    return items.value.reduce((sum, item) => sum + item.quantity, 0)
  })
  
  const totalPrice = computed(() => {
    return items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  })
  
  const isEmpty = computed(() => items.value.length === 0)
  
  const formattedTotal = computed(() => {
    return ${totalPrice.value.toFixed(2)}`
  })
  
  // --- Actions ---
  function addItem(product: Product, quantity = 1) {
    const existing = items.value.find(item => item.id === product.id)
    
    if (existing) {
      existing.quantity += quantity
    } else {
      items.value.push({
        id: product.id,
        name: product.name,
        price: product.price,
        image: product.image,
        quantity
      })
    }
    
    lastUpdated.value = new Date()
    ElMessage.success(`已添加 ${product.name} 到购物车`)
  }
  
  function removeItem(productId: number) {
    const index = items.value.findIndex(item => item.id === productId)
    if (index > -1) {
      const removed = items.value[index]
      items.value.splice(index, 1)
      ElMessage.success(`已移除 ${removed.name}`)
    }
  }
  
  function updateQuantity(productId: number, quantity: number) {
    const item = items.value.find(item => item.id === productId)
    if (item) {
      if (quantity <= 0) {
        removeItem(productId)
      } else {
        item.quantity = quantity
      }
    }
  }
  
  function clearCart() {
    items.value = []
    ElMessage.success('购物车已清空')
  }
  
  async function checkout() {
    if (isEmpty.value) {
      ElMessage.warning('购物车是空的')
      return false
    }
    
    loading.value = true
    try {
      // 模拟提交订单
      await new Promise(resolve => setTimeout(resolve, 1500))
      
      // 提交成功后清空购物车
      clearCart()
      ElMessage.success('下单成功!')
      return true
    } catch (error) {
      ElMessage.error('下单失败,请重试')
      return false
    } finally {
      loading.value = false
    }
  }
  
  return {
    // state
    items,
    loading,
    lastUpdated,
    // getters
    totalCount,
    totalPrice,
    isEmpty,
    formattedTotal,
    // actions
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    checkout
  }
}, {
  persist: {
    key: 'shopping-cart',
    paths: ['items'], // 只持久化商品列表
    storage: localStorage
  }
})

11.2 在组件中使用

<!-- components/CartIcon.vue -->
<template>
  <el-badge :value="cartStore.totalCount" :hidden="cartStore.isEmpty">
    <el-button :icon="ShoppingCart" @click="showCartDrawer = true">
      购物车
    </el-button>
  </el-badge>
  
  <el-drawer v-model="showCartDrawer" title="购物车" size="400px">
    <div v-loading="cartStore.loading" class="cart-content">
      <template v-if="!cartStore.isEmpty">
        <div v-for="item in cartStore.items" :key="item.id" class="cart-item">
          <img :src="item.image" :alt="item.name" class="item-image">
          <div class="item-info">
            <h4>{{ item.name }}</h4>
            <p class="item-price">¥{{ item.price }}</p>
          </div>
          <div class="item-actions">
            <el-input-number
              v-model="item.quantity"
              :min="1"
              :max="99"
              size="small"
              @change="handleQuantityChange(item.id, $event)"
            />
            <el-button
              type="danger"
              :icon="Delete"
              link
              @click="cartStore.removeItem(item.id)"
            />
          </div>
        </div>
        
        <div class="cart-footer">
          <div class="total">
            <span>总计:</span>
            <span class="total-price">{{ cartStore.formattedTotal }}</span>
          </div>
          <el-button
            type="primary"
            :loading="cartStore.loading"
            @click="handleCheckout"
          >
            结算
          </el-button>
        </div>
      </template>
      
      <el-empty v-else description="购物车空空如也" />
    </div>
  </el-drawer>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ShoppingCart, Delete } from '@element-plus/icons-vue'
import { useCartStore } from '@/stores/modules/cart'
import { ElMessageBox } from 'element-plus'

const cartStore = useCartStore()
const showCartDrawer = ref(false)

const handleQuantityChange = (productId: number, quantity: number) => {
  cartStore.updateQuantity(productId, quantity)
}

const handleCheckout = async () => {
  ElMessageBox.confirm('确认提交订单吗?', '提示', {
    type: 'info'
  }).then(async () => {
    const success = await cartStore.checkout()
    if (success) {
      showCartDrawer.value = false
    }
  })
}
</script>

<style scoped lang="scss">
.cart-content {
  padding: 20px;
  height: 100%;
  display: flex;
  flex-direction: column;
}

.cart-item {
  display: flex;
  align-items: center;
  padding: 12px 0;
  border-bottom: 1px solid #eee;
  
  .item-image {
    width: 60px;
    height: 60px;
    object-fit: cover;
    border-radius: 4px;
    margin-right: 12px;
  }
  
  .item-info {
    flex: 1;
    
    h4 {
      margin: 0 0 4px;
      font-size: 14px;
    }
    
    .item-price {
      margin: 0;
      color: #f56c6c;
      font-weight: bold;
    }
  }
  
  .item-actions {
    display: flex;
    align-items: center;
    gap: 8px;
  }
}

.cart-footer {
  margin-top: auto;
  padding-top: 20px;
  border-top: 2px solid #eee;
  
  .total {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;
    font-size: 16px;
    
    .total-price {
      color: #f56c6c;
      font-size: 20px;
      font-weight: bold;
    }
  }
}
</style>

十二、总结与进阶

12.1 Pinia 核心要点回顾

概念 作用 类比
State 存储数据 组件的 data
Getter 计算派生状态 组件的 computed
Action 修改状态的方法 组件的 methods
Plugin 扩展功能 全局混入
Store 上述内容的容器 一个模块

12.2 什么时候用 Pinia?

  • ✅ 多个组件共享同一份数据
  • ✅ 数据需要跨路由持久化
  • ✅ 有复杂的业务逻辑需要复用
  • ✅ 需要 DevTools 调试状态变化
  • ❌ 简单的父子组件通信(用 props/emit 就够了)

12.3 下一步学习方向

  1. Pinia + Vue Query:服务端状态管理
  2. Pinia + WebSocket:实时数据同步
  3. Pinia 源码阅读:理解响应式原理
  4. 自定义插件开发:根据项目需求定制

12.4 写在最后

从 Vuex 到 Pinia,不仅仅是 API 的简化,更是对「状态管理应该简单」这一理念的回归。就像 Evan You 说的:

"Pinia 成功地在保持清晰的设计分离的同时,提供了简单、小巧且易于上手的 API。"

掌握 Pinia,不是为了炫技,而是为了让代码更清晰、维护更简单。现在,去重构你项目里的状态管理吧!🚀

v-once和v-memo完全指南:告别不必要的渲染,让应用飞起来

作者 wuhen_n
2026年3月16日 09:10

前言

在日常开发中,我们可能遇到过这样的情况:写了一个 Vue 应用,数据量稍微大一点,页面就开始卡顿;用户只是点击了一个按钮,整个页面都要重新渲染;明明大部分内容都没变,却感觉应用像“老了十岁”一样慢。这是为什么呢?

Vue 的响应式系统很智能,但它也有“过度反应”的时候。就像我们只是拍了拍桌子,整个办公室的人都站起来看看发生了什么——这显然是一种浪费。

v-oncev-memo 就是来解决这个问题的。它们像两个聪明的“保安”,告诉 Vue:“这部分内容不用每次都检查,它没变” 和 “这部分内容只有在特定条件变化时才需要检查”。

本文将从最基础的概念讲起,用最通俗的语言,配合完整的代码示例,帮助我们彻底掌握这两个性能优化神器。

为什么要关注不必要的渲染

从一个简单的例子开始

我们先来看一个简单的例子:

<template>
  <div>
    <!-- 动态内容:会变化 -->
    <h2>当前计数:{{ count }}</h2>
    <button @click="count++">点我增加</button>
    
    <!-- 静态内容:永远不会变 -->
    <footer>
      <p>© 2026 我的公司. 版权所有</p>
      <p>联系方式:contact@example.com</p>
      <p>地址:xxx</p>
    </footer>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

这段代码看起来没什么,但实际上会发生了什么呢?

每次点击按钮是,count 都会变化,整个组件都会重新渲染。包括那个 永远不会变 的页脚。

虽然 Vue 的虚拟 DOM 会最终发现页脚没变,不会更新真实的 DOM,但这个过程仍然需要:

  • 执行渲染函数
  • 创建新的虚拟 DOM
  • 和旧的虚拟 DOM 进行对比
  • 确认没有变化,跳过更新

这就像我们每天早上去公司,尽管保安每天都会看到我们,但他们仍然每天都要重新核对我们的身份信息,这是一种不必要的浪费。

Vue 的默认更新机制

响应式数据变化
    ↓
组件重新渲染函数执行
    ↓
生成新的虚拟 DOM 树
    ↓
与旧虚拟 DOM 进行 diff 比较
    ↓
计算出需要更新的真实 DOM
    ↓
执行 DOM 更新

不必要的渲染有多"贵"?

我们先看一段数据:

组件规模 一次不必要的渲染耗时 每天10万次操作 额外开销
小型组件(50个节点) 0.5ms 50,000ms 50秒
中型组件(200个节点) 2ms 200,000ms 3.3分钟
大型组件(1000个节点) 10ms 1,000,000ms 16.7分钟

想象一下,用户每天要多等十几分钟,就因为应用在“瞎忙活”。

什么是不必要的渲染?

简单来说就是:渲染的结果和上一次 完全一样,但过程却重复执行了。

// 这是一个"不必要的渲染"的典型案例
const App = {
  template: `
    <div>
      <!-- 这部分每次都会重新计算,但结果永远一样 -->
      <div>{{ getStaticData() }}</div>
      
      <!-- 这部分确实需要更新 -->
      <div>{{ dynamicData }}</div>
    </div>
  `,
  
  methods: {
    getStaticData() {
      console.log('我被调用了!') // 其实只需要调用一次
      return '永远不变的内容'
    }
  }
}

问题:即使大部分内容没变,渲染函数仍会执行,虚拟 DOM 树仍会创建,diff 算法仍需遍历。

v-once:一次渲染,终身躺平

v-once 是什么?

v-once 是 Vue 提供的一个指令,它的作用就像它的名字一样:只渲染一次。之后无论数据怎么变化,这部分内容都不会再更新。

用生活化的比喻理解v-once

想象一下,我们正在装修房子:

  • 普通渲染:每天都要重新粉刷一遍墙壁,尽管颜色没变
  • v-once 渲染:装修一次,以后再也不动它

v-once 的基本用法

<template>
  <div>
    <!-- 普通内容:每次count变化都会更新 -->
    <p>当前计数:{{ count }}</p>
    
    <!-- v-once内容:只渲染一次,之后永远不变 -->
    <p v-once>初始计数:{{ count }}</p>
    
    <button @click="count++">增加计数</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

运行效果

  • 首次加载:两个都显示“0”
  • 点击按钮:上面变成“1”,下面还是“0”
  • 继续点击:上面一直变,下面永远是“0”

v-once的工作原理

让我们用流程图来理解:

首次渲染
    ↓
遇到 v-once 指令
    ↓
正常渲染内容
    ↓
将生成的虚拟DOM缓存起来
    ↓
打上"静态标记"
    ↓
─────────────────
    ↓
后续更新时
    ↓
遇到 v-once 标记
    ↓
直接返回缓存的虚拟DOM
    ↓
跳过所有更新逻辑

v-once 的实现机制

// 简化版的 v-once 实现原理
function processOnceNode(vnode) {
  if (vnode.shapeFlag & ShapeFlags.COMPONENT_ONCE) {
    // 如果是组件,标记为静态组件
    vnode.isStatic = true
    return vnode
  }
  
  // 如果是元素,创建静态节点
  const staticNode = createStaticVNode(
    vnode.children,
    vnode.props
  )
  
  // 后续更新直接返回缓存的静态节点
  return staticNode
}

v-once 的适用场景

场景一:页脚版权信息等纯静态内容

<!-- 页脚版权信息,永远不变 -->
<footer v-once>
  <p>© 2026 我的公司. All rights reserved.</p>
  <p>ICP备案号:xxxxx</p>
  <div class="contact">
    <p>邮箱:contact@example.com</p>
    <p>电话:400-123-4567</p>
  </div>
</footer>

场景二:一次性初始数据

<template>
  <div class="user-profile">
    <!-- 用户 ID 只在创建时显示,后续不变 -->
    <div v-once class="user-meta">
      <span>用户ID:{{ userId }}</span>
      <span>注册时间:{{ registerDate }}</span>
      <span>会员等级:{{ initialLevel }}</span>
    </div>
    
    <!-- 动态更新的内容 -->
    <div class="user-points">
      当前积分:{{ points }}
      <button @click="points++">签到</button>
    </div>
  </div>
</template>

场景三:复杂的静态组件

<template>
  <div class="dashboard">
    <!-- 左侧:帮助文档组件,完全静态,只需加载一次 -->
    <HelpDocumentation v-once class="sidebar" />
    
    <!-- 右侧:动态更新的内容 -->
    <div class="main-content">
      <DashboardCharts :data="liveData" />
      <RealTimeLogs :logs="systemLogs" />
    </div>
  </div>
</template>

场景四:与 v-for 配合优化列表

<template>
  <div class="data-table">
    <!-- 表格头部完全静态 -->
    <div v-once class="table-header">
      <div class="col">姓名</div>
      <div class="col">年龄</div>
      <div class="col">部门</div>
      <div class="col">操作</div>
    </div>
    
    <!-- 动态列表项 -->
    <div v-for="item in list" :key="item.id" class="table-row">
      <div class="col">{{ item.name }}</div>
      <div class="col">{{ item.age }}</div>
      <div class="col">{{ item.department }}</div>
      <div class="col">
        <button @click="edit(item.id)">编辑</button>
      </div>
    </div>
  </div>
</template>

v-once 的使用注意事项

注意事项 说明 示例
失去响应性 v-once 内的所有数据绑定都变成静态,不再响应更新 <div v-once>{{ count }}</div> 永远不会更新
子树全静态 v-once 作用于元素时,其所有子元素也变为静态 整个组件树都会静态化
避免滥用 只在真正不需要更新的地方使用,否则会导致数据和视图不一致 动态内容不能用 v-once
组件中使用 组件上加 v-once,整个组件只会渲染一次 <ComplexChart v-once />

v-once 性能收益实测

测试环境

  • 页面包含 200 个静态节点
  • 每秒触发 10 次更新
  • 运行 60 秒
指标 未优化 使用 v-once 提升
渲染函数调用次数 60,000 次 600 次 99%
虚拟 DOM 创建 60,000 次 600 次 99%
内存分配 850MB 85MB 90%
CPU 使用率 65% 8% 88%
平均帧率 45fps 60fps 33%

v-memo:有条件地记忆渲染

为什么要 v-memo?

v-once 虽然好,但它的缺点也很明显:要么永远更新,要么永远不更新。现实开发中,我们经常遇到这样的情况:

  • 列表项的大部分内容稳定,但少数字段会变
  • 组件的大部分数据不变,但需要响应某些特定变化

这时候就需要 v-memo 了。

v-memo 是什么?

v-memo 是 Vue 3.2+ 引入的新指令,它可以接受一个依赖数组,只有当数组中的值变化时,才会重新渲染。

用生活化的比喻理解 v-memo

想象一下,我们在公司里:

  • 普通员工:领导一喊,所有人都站起来(不管是不是叫自己)
  • v-memo 员工:只有听到自己名字才站起来

v-memo的基本用法

<template>
  <div 
    v-for="item in items" 
    :key="item.id"
    v-memo="[item.id, item.price, item.stock]"
  >
    <!-- 只有当 item.id、item.price 或 item.stock 变化时才重新渲染 -->
    <h3>{{ item.name }}</h3>
    <p>价格:{{ item.price }}</p>
    <p>库存:{{ item.stock }}</p>
    <button @click="toggleFavorite(item.id)">
      {{ item.isFavorite ? '取消收藏' : '收藏' }}
    </button>
  </div>
</template>

v-memo的工作原理

让我们用流程图来理解:

首次渲染
    ↓
计算依赖数组的值
    ↓
缓存这些值和生成的虚拟DOM
    ↓
─────────────────
    ↓
后续更新触发
    ↓
重新计算依赖数组的新值
    ↓
和缓存的值比较
    ↓
有变化?→ 是 → 重新渲染,更新缓存
    ↓       
    否
    ↓
直接返回缓存的虚拟DOM
    ↓
跳过所有更新逻辑

v-memo 工作机制的三阶段

1. 依赖收集阶段

  • 编译时解析依赖数组
  • 建立响应式依赖图谱
  • 为每个节点创建 memo 缓存

2. 缓存对比阶段

  • 重新渲染前计算依赖数组的新值
  • 与缓存的上次值进行浅比较
  • 若未变化 → 直接复用缓存的 VNode 树
  • 若已变化 → 重新生成 VNode 并更新缓存

3. 虚拟 DOM 跳过

  • 完全跳过该节点的 diff 计算
  • 不触发子树的渲染函数
  • 直接复用真实 DOM

v-memo的实战场景

场景一:超大规模商品列表

想象一个电商网站的商品列表,有1万件商品:

<template>
  <div class="product-list">
    <div 
      v-for="product in products" 
      :key="product.id"
      v-memo="[
        product.id, 
        product.price, 
        product.stock, 
        product.isFavorite
      ]"
      class="product-item"
    >
      <img :src="product.image" :alt="product.name" />
      <h3>{{ product.name }}</h3>
      <p class="price">¥{{ product.price }}</p>
      <p class="stock">库存: {{ product.stock }}件</p>
      <p class="sales">销量: {{ product.sales }}件</p>
      <p class="rating">评分: {{ product.rating }}分</p>
      <button 
        @click="toggleFavorite(product.id)"
        :class="{ active: product.isFavorite }"
      >
        {{ product.isFavorite ? '已收藏' : '收藏' }}
      </button>
    </div>
  </div>
</template>

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

// 生成1万件商品
const products = ref(
  Array.from({ length: 10000 }, (_, i) => ({
    id: i,
    name: `商品 ${i}`,
    price: Math.floor(Math.random() * 1000),
    stock: Math.floor(Math.random() * 100),
    sales: Math.floor(Math.random() * 1000),
    rating: (Math.random() * 5).toFixed(1),
    image: `https://picsum.photos/200/150?random=${i}`,
    isFavorite: false
  }))
)

function toggleFavorite(id) {
  const product = products.value.find(p => p.id === id)
  product.isFavorite = !product.isFavorite
  // ✅ 只有被点击的那一项会重新渲染
}
</script>

优化效果:

  • 用户点击收藏时,只有被点击的商品重新渲染
  • 后台更新价格时,只有价格变化的商品重新渲染
  • 其他 9999 件商品完全不动

场景二:复杂计算缓存

<template>
  <div class="dashboard">
    <!-- 只有当原始数据或用户设置变化时才重新计算 -->
    <div 
      class="dashboard-content"
      v-memo="[rawData.version, userSettings.theme]"
    >
      <DashboardHeader />
      
      <!-- 这里的数据需要复杂计算 -->
      <DataVisualization :data="processedData" />
      <StatsCards :stats="computedStats" />
      <ActivityChart :chart-data="chartData" />
    </div>
  </div>
</template>

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

const rawData = ref(fetchData()) // 10MB的原始数据
const userSettings = ref({ theme: 'light', language: 'zh' })

// 复杂计算:处理10MB数据
const processedData = computed(() => {
  console.log('正在处理数据...') // 我们希望这个不要频繁执行
  return rawData.value.map(item => ({
    ...item,
    processed: heavyComputation(item)
  }))
})

// 当用户切换主题时,不应该重新计算processedData
// 但上面的v-memo确保了这一点:只有rawData.version或userSettings.theme变化时才重新渲染
</script>

场景三:聊天消息列表

<template>
  <div class="chat-messages">
    <div 
      v-for="msg in messages" 
      :key="msg.id"
      v-memo="[msg.id, msg.content, msg.timestamp, msg.isRead]"
      class="message"
      :class="{ 'message-self': msg.senderId === currentUserId }"
    >
      <img :src="msg.avatar" class="avatar" />
      <div class="content">
        <div class="sender">{{ msg.senderName }}</div>
        <div class="text">{{ msg.content }}</div>
        <div class="time">{{ formatTime(msg.timestamp) }}</div>
      </div>
      <div class="status">
        <span v-if="msg.isRead">已读</span>
        <span v-else-if="msg.isSending">发送中...</span>
        <span v-else-if="msg.isFailed">发送失败</span>
      </div>
    </div>
  </div>
</template>

<script setup>
const messages = ref([])

// 新消息到来时,只有新消息会渲染
// 已读状态变化时,只有那条消息会更新
// 其他消息完全不动
</script>

场景四:选中状态高亮

<template>
  <div class="image-gallery">
    <div 
      v-for="image in images" 
      :key="image.id"
      v-memo="[selectedId === image.id]"
      class="image-item"
      :class="{ selected: selectedId === image.id }"
      @click="selectedId = image.id"
    >
      <img :src="image.thumbnail" :alt="image.title" />
      <div class="overlay">
        <h4>{{ image.title }}</h4>
        <button @click.stop="download(image.id)">下载</button>
      </div>
    </div>
  </div>
</template>

<script setup>
const selectedId = ref(null)

// 点击时,只有之前选中的和当前选中的两个图片会重新渲染
// 其他9998张图片完全不动
</script>

v-memo 依赖项选择的黄金法则

  • 精准包含:只放那些真正会影响渲染的字段
  • 避免冗余:不要把整个对象放进去
  • 稳定依赖:不要用 Date.now() 这种每次都变的值
  • 版本控制:复杂对象可以用版本号

选择决策树

graph TD
    Start[遇到一个组件/元素] --> Question1{内容永远不变吗?}
    Question1 -->|是| A[用 v-once]
    Question1 -->|否| Question2{是长列表?<br>(>500项)}
    
    Question2 -->|否| B[暂时不需要优化]
    Question2 -->|是| Question3{更新频率高吗?}
    
    Question3 -->|低| C[保持现状]
    Question3 -->|高| Question4{能否精确控制更新?}
    
    Question4 -->|否| D[考虑虚拟滚动]
    Question4 -->|是| E[用 v-memo 精确优化]

v-once vs v-memo,如何选择?

特性对比表

对比维度 v-once v-memo
适用版本 Vue 2+ Vue 3.2+
更新策略 永不更新 条件更新
依赖声明 显式数组
学习难度 ⭐⭐⭐
适用场景 纯静态内容 大部分稳定的动态内容
代码侵入性

组合使用示例

<template>
  <div class="app">
    <!-- 1. 完全静态的头部 -->
    <header v-once>
      <AppLogo />
      <AppTitle />
      <NavigationMenu />
    </header>
    
    <!-- 2. 动态列表,但有条件更新 -->
    <div class="content">
      <div 
        v-for="item in items" 
        :key="item.id"
        v-memo="[item.id, item.updatedAt]"
      >
        <!-- 2.1 每个列表项内部的静态部分 -->
        <div v-once class="item-static">
          <img :src="item.avatar" />
          <span>ID: {{ item.id }}</span>
        </div>
        
        <!-- 2.2 每个列表项内部的动态部分 -->
        <div class="item-dynamic">
          <h3>{{ item.title }}</h3>
          <p>{{ item.content }}</p>
          <span>点赞: {{ item.likes }}</span>
        </div>
      </div>
    </div>
    
    <!-- 3. 完全静态的页脚 -->
    <footer v-once>
      <Copyright />
      <ContactInfo />
    </footer>
  </div>
</template>

性能收益对比

场景 优化前 v-once v-memo
静态页脚 每次更新都渲染 0次更新 不适用
收藏按钮点击 整个列表重绘 不适用 只更新单个项
价格批量更新 整个列表重绘 不适用 只更新价格变化项
列表项1000条 120ms 不适用 35ms

常见陷阱与解决方案

v-memo 依赖遗漏

<!-- ❌ 错误:遗漏了关键依赖 -->
<div 
  v-for="item in items"
  v-memo="[item.id]"
>
  {{ item.name }}  <!-- 当name变化时,这里不会更新! -->
  <span :class="{ active: item.isActive }">
    {{ item.status }}
  </span>
</div>

<!-- ✅ 正确:包含所有依赖 -->
<div 
  v-for="item in items"
  v-memo="[item.id, item.name, item.isActive, item.status]"
>
  {{ item.name }}
  <span :class="{ active: item.isActive }">
    {{ item.status }}
  </span>
</div>

在错误的位置使用 v-memo

<!-- ❌ 错误:在父容器上使用v-memo -->
<ul v-memo="[items.length]">
  <li v-for="item in items" :key="item.id">
    {{ item.name }}
  </li>
</ul>
<!-- 结果:items.length不变时,整个列表都不更新 -->
<!-- 但item.name变化时也不会更新! -->

<!-- ✅ 正确:在v-for的项上使用 -->
<ul>
  <li 
    v-for="item in items" 
    :key="item.id"
    v-memo="[item.id, item.name]"
  >
    {{ item.name }}
  </li>
</ul>

滥用v-once导致bug

<!-- ❌ 错误:动态内容用了v-once -->
<div v-once>
  <h3>当前用户:{{ username }}</h3>  <!-- 永远不会更新! -->
  <button @click="logout">退出登录</button>
</div>

<!-- ✅ 正确:只静态化真正静态的部分 -->
<div>
  <h3>当前用户:{{ username }}</h3>  <!-- 动态 -->
  <div v-once>操作面板</div>  <!-- 静态 -->
  <button @click="logout">退出登录</button>  <!-- 动态 -->
</div>

最佳实践清单

什么时候用 v-once?

  • 版权信息、页脚
  • 表格表头
  • 静态导航菜单
  • 一次性初始数据
  • 复杂的静态组件(帮助文档、使用说明)

什么时候用 v-memo?

  • 超长列表(>500项)
  • 高频更新的区域隔离
  • 选中状态切换
  • 复杂计算的缓存
  • 聊天消息列表

优化检查清单

  • v-memo 的依赖数组包含了所有影响渲染的字段
  • 避免在 v-memo 中使用 Date.now()Math.random()
  • v-memo 正确放在 v-for 的项上,而不是父容器
  • v-once 只用于真正静态的内容
  • 组合使用时逻辑清晰
  • 用性能工具验证了优化效果

性能优化的哲学

  1. 优化不是炫技:用数据和用户体感说话
  2. 适度原则:不是所有地方都需要优化
  3. 持续演进:性能优化是过程,不是终点
  4. 量化的力量:没有数据的优化是盲目的

结语

v-oncev-memo 是 Vue 提供的两个强大的优化工具,但它们不是银弹。真正的性能优化,是在理解业务场景的基础上,选择合适的技术,验证优化效果,持续改进的过程。让该更新的更新,该躺平的躺平,这才是 Vue 性能优化的真谛!

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

React 快速入门:Vue 开发者指南

作者 Lee川
2026年3月15日 18:21

React 快速入门:Vue 开发者指南

通过对比 Vue 和 React,快速掌握 React 核心概念


一、项目结构对比

1.1 依赖管理

React (package.json):

{
  "dependencies": {
    "react": "^19.2.0",
    "react-dom": "^19.2.0"
  }
}

Vue (package.json):

{
  "dependencies": {
    "vue": "^3.4.0"
  }
}

关键差异:

  • React 分两个包:react(核心库)+ react-dom(DOM 渲染器)
  • Vue 只需要一个包
  • React 设计更通用,支持多平台(Web、Native 等)

1.2 入口文件

React (main.jsx):

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

Vue (main.js):

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

createApp(App).mount('#app')

对比:

  • React 需要显式调用 render()
  • Vue 更简洁,一行完成创建和挂载
  • React 的 StrictMode 提供开发时检查

二、JSX:React 的模板语法

2.1 什么是 JSX?

JSX = JavaScript + XML,允许在 JS 中直接写 HTML 标签。

示例:

function App() {
  const name = "vue";
  return (
    <h1 className="title">Hello {name}!</h1>
  )
}

2.2 JSX vs Vue 模板

特性 React JSX Vue 模板
类名 className class
插值 {name} {{ name }}
事件 onClick={handler} @click="handler"
条件 {condition && <div />} v-if="condition"
列表 {items.map(i => <li />)} v-for="i in items"

2.3 JSX 的本质

JSX 代码:

const element = <h2>标题</h2>

编译后等价于:

const element2 = React.createElement('h2', null, '标题')

为什么使用 JSX?

  1. 更直观,接近 HTML
  2. 完整的 JavaScript 能力
  3. 更好的编辑器支持

三、组件基础

3.1 React 组件

// 函数就是组件
function App() {
  return <h1>Hello React!</h1>
}

export default App

关键点:

  • 组件是函数
  • 返回 JSX
  • 组件名必须大写(区分 HTML 标签)

3.2 组件组合

function Header() {
  return <header><h1>首页</h1></header>
}

function Articles() {
  return <div>文章列表</div>
}

function App() {
  return (
    <>
      <Header />
      <Articles />
    </>
  )
}

Fragment (<>):不会创建额外 DOM 节点,类似 Vue 的 <template>

3.3 Props 传递

// 父组件
function App() {
  return <UserProfile name="张三" />
}

// 子组件
function UserProfile({ name }) {
  return <h1>欢迎,{name}!</h1>
}

对比 Vue:

<template>
  <UserProfile :name="'张三'" />
</template>

<script setup>
const props = defineProps({ name: String })
</script>

四、状态管理:useState

4.1 基本用法

import { useState } from 'react';

function App() {
  const [name, setName] = useState("vue");
  
  return <h1>Hello {name}!</h1>
}

解析:

  • useState 返回数组:[状态值,更新函数]
  • "vue" 是初始值
  • 调用 setName() 会触发重新渲染

4.2 多个状态

function App() {
  const [name, setName] = useState("vue");
  const [todos, setTodos] = useState([
    { id: 1, title: "学习 react" },
    { id: 2, title: "学习 node" },
  ]);
  const [isLoggedIn, setIsLoggedIn] = useState(false);
  
  return (
    <>
      <h1>Hello {name}!</h1>
      {isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
    </>
  )
}

4.3 不可变更新

// ❌ 错误:直接修改
todos.push(newTodo);
setTodos(todos);

// ✅ 正确:创建新数组
setTodos([...todos, newTodo]);

// ✅ 更新对象
setUser({ ...user, age: 26 });

为什么?

  • React 使用浅比较检测变化
  • 不可变数据更可预测
  • 支持并发特性

对比 Vue:

<script setup>
const todos = ref([])
// Vue 支持直接修改
todos.value.push(newTodo)
</script>

Vue 使用 Proxy 自动追踪变化,React 要求不可变更新。


五、事件处理

5.1 基本用法

function App() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    setCount(count + 1);
  };
  
  return <button onClick={handleClick}>+1</button>
}

关键点:

  • 事件名驼峰命名:onClick(不是 onclick
  • 传递函数引用:onClick={handleClick}
  • 不是调用:onClick={handleClick()}

5.2 事件传参

function App() {
  const handleDelete = (id) => {
    console.log('删除:', id);
  };
  
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id} onClick={() => handleDelete(todo.id)}>
          {todo.title}
        </li>
      ))}
    </ul>
  )
}

使用箭头函数传参,简单直观。

5.3 对比 Vue

特性 React Vue
语法 onClick={handler} @click="handler"
阻止默认行为 e.preventDefault() .prevent 修饰器
事件对象 自动传递 $event

六、条件渲染

6.1 三元运算符

{isLoggedIn ? <div>已登录</div> : <div>未登录</div>}

6.2 逻辑与运算符

{isLoggedIn && <div>已登录</div>}

6.3 对比 Vue

React:

{count > 0 ? <p>{count}</p> : <p>无数据</p>}

Vue:

<p v-if="count > 0">{{ count }}</p>
<p v-else>无数据</p>

设计哲学:

  • React:使用 JavaScript 原生语法
  • Vue:使用模板指令

七、列表渲染

7.1 使用 map

function App() {
  const todos = [
    { id: 1, title: "学习 react" },
    { id: 2, title: "学习 node" },
  ];
  
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          {todo.title}
        </li>
      ))}
    </ul>
  )
}

7.2 key 的重要性

// ✅ 正确:使用唯一 ID
<li key={todo.id}>

// ❌ 错误:使用索引
<li key={index}>

为什么需要 key?

  • 帮助 React 识别元素
  • 优化虚拟 DOM diff
  • 避免不必要的重新渲染

7.3 对比 Vue

React:

{todos.map(todo => <li key={todo.id}>{todo.title}</li>)}

Vue:

<li v-for="todo in todos" :key="todo.id">
  {{ todo.title }}
</li>

八、完整示例

import { useState } from 'react';
import './App.css';

function App() {
  // 状态管理
  const [name, setName] = useState("vue");
  const [todos, setTodos] = useState([
    { id: 1, title: "学习 react", done: false },
    { id: 2, title: "学习 node", done: false },
    { id: 3, title: "学习 js", done: false },
  ]);
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  // 事件处理
  const toggleLogin = () => {
    setIsLoggedIn(!isLoggedIn);
  }

  // JSX 元素
  const element = <h2>JSX 是 React 的语法扩展</h2>

  return (
    <> 
      {element}
      <h1>Hello <span className="title">{name}!</span></h1>
      
      {/* 条件渲染 + 列表渲染 */}
      {todos.length > 0 ? (
        <ul>
          {todos.map((todo) => (
            <li key={todo.id}>
              {todo.title}
            </li>
          ))}
        </ul>
      ) : (<div>暂无待办事项</div>)}
      
      {/* 条件渲染 */}
      {isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
      
      {/* 事件绑定 */}
      <button onClick={toggleLogin}>
        {isLoggedIn ? "退出登录" : "登录"}
      </button>
    </>
  )
}

export default App

代码要点:

  1. 使用 useState 管理三个状态
  2. 三元运算符实现条件渲染
  3. map 方法实现列表渲染
  4. 箭头函数处理事件
  5. Fragment (<>) 包裹多个元素

九、核心差异总结

9.1 设计哲学

方面 React Vue
定位 库 (Library) 框架 (Framework)
模板 JSX (JavaScript) 模板语法 (HTML-like)
状态更新 不可变 可变
学习曲线 较陡峭 较平缓
灵活性 中等

9.2 代码对比

React:

import { useState } from 'react';

function App() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  )
}

Vue:

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

const count = ref(0)
</script>

<template>
  <div>
    <p>计数:{{ count }}</p>
    <button @click="count++">+1</button>
  </div>
</template>

9.3 关键记忆点

  1. JSX 用 {} 插值,不是 {{ }}
  2. 类名用 className,不是 class
  3. 事件用 onClick,不是 @click
  4. 状态不可变更新,不能直接修改
  5. 列表需要 key,使用唯一 ID
  6. 条件用三元运算符,不是 v-if

十、常见陷阱

陷阱 1:直接修改状态

// ❌ 错误
count = count + 1;

// ✅ 正确
setCount(count + 1);

陷阱 2:忘记 key

// ❌ 错误
{items.map(item => <div>{item.name}</div>)}

// ✅ 正确
{items.map(item => <div key={item.id}>{item.name}</div>)}

陷阱 3:混淆 class

// ❌ 错误
<div class="container">

// ✅ 正确
<div className="container">

陷阱 4:事件立即执行

// ❌ 错误
<button onClick={handleClick()}>

// ✅ 正确
<button onClick={handleClick}>

十一、学习建议

11.1 学习路线

第 1 周:基础

  • JSX 语法
  • 组件定义
  • useState

第 2 周:进阶

  • 事件处理
  • 条件/列表渲染
  • useEffect

第 3 周:生态

  • React Router
  • 状态管理
  • UI 组件库

11.2 思维转换

从 Vue 到 React,需要转变:

  1. 从模板到 JSX:接受"一切皆 JavaScript"
  2. 从可变到不可变:习惯创建新对象
  3. 从指令到函数:用原生语法替代指令

11.3 选择建议

选 React 如果:

  • JavaScript 基础好
  • 需要灵活性
  • 想开发跨平台应用

选 Vue 如果:

  • 快速上手
  • 喜欢完整方案
  • 主要开发 Web 应用

总结

React 核心要点:

  1. ✅ JSX 是 JavaScript 扩展,不是 HTML
  2. ✅ 组件是函数,返回 JSX
  3. ✅ useState 管理状态,不可变更新
  4. ✅ 事件用 onClick,传递函数引用
  5. ✅ 条件用三元运算符,列表用 map
  6. ✅ key 帮助优化渲染,必须提供

最后的话:

React 和 Vue 都是优秀框架,没有绝对好坏。理解差异,选择适合的,持续学习才是关键。

资源推荐:

祝你学习顺利! 🚀

数字孪生大屏必看:Cesium 3D 模型选中交互,3 种高亮效果拿来就用!

作者 李剑一
2026年3月15日 16:23

接前文,3D模型加载到页面以后肯定要执行各种各样的操作,模型在大屏上的主要作用是执行相应的建筑物交互。

问题

这里需要注意3D模型的加载仍然存在一些问题。

首先是如果你使用的GLTF文件,在某些特殊情况下可能导致模型的网格加载异常,会出现无法选中的情况。

这一点我目前没发现有什么太好的解决方案,所以这里采用GLB文件规避掉了这个问题。

image.png

如果你手里只有GLTF文件,可以考虑使用 Blender 进行一下转换。

另外模型本身离地高度都是 0 的话可能存在无法选中的问题,所以这里建议背景设置离地高度为 -0.5,普通模型正常为 0

还有就是模型选中以后的显示问题。

解决方案

模型无法选中和选择错误的问题通过两个方案进行规避,一个是height离地高度,一个是pickable拾取

首先设置背景的离地高度为 -0.5,普通可以选中的模型离地高度为 0

image.png

另外设置一下 pickPriority拾取优先级pickable是否可拾取

最后就是设置模型的选中效果,这里我简单写了三种效果给大家选择,可以自行决定。

实际代码

模型添加的时候增加拾取优先级参数:

const buildingEntity = viewer.entities.add({
    id: options.id, // 唯一ID,点击交互时识别核心
    name: options.properties.name || options.id, // 建筑名称(可选)
    position: position,
    orientation: orientation, // 控制模型朝向
    pickPriority: options.pickPriority, // (核心)添加拾取优先级
    pickable: options.pickable,  // (核心)允许拾取
    model: {
        uri: options.modelUrl, // glTF/glb 模型路径
        scale: options.scale || 1.0, // 保证模型真实比例(建模时单位为米)
        minimumPixelSize: 0, // 取消最小像素限制,模型随地图缩放正常变化
        maximumScale: 20000, // 最大缩放限制
        runAnimations: false, // 静态建筑关闭动画(节省性能)
        clampToGround: true, // 贴地(自动适配地形高度,可选)
    },
    properties: options.properties || {}, // 绑定自定义属性(如状态接口)
});

这里的参数其实在模型选择那里可以再增加一层判断。

模型选中方法主要有三种:

// 1. 轮廓线方案
/**
 * 选中指定模型
 * @param {Cesium.Entity} entity 要选中的模型实体
 * @param {Function} onSelect 选中回调(如展示状态弹窗)
 * @param {Function} onUnselect 取消选中回调(用于先取消之前的选中)
 */
const selectModel = (entity, onSelect, onUnselect) => {
    // 取消之前的选中状态(包括回调执行)
    if (selectedEntity) {
        unselectModel(onUnselect);
    }

    // 校验是否为模型实体
    if (!entity || !entity.model) {
        console.warn('❌ 选中的不是模型实体');
        return;
    }

    // 标记为当前选中实体
    selectedEntity = entity;

    // 轮廓线高亮(更醒目,性能略高,需 Cesium 1.90+)
    entity.model.outlineColor = Cesium.Color.RED;
    entity.model.outlineWidth = 2;
    entity.model.outline = true;

    // 执行选中回调(绑定业务逻辑)
    if (typeof onSelect === 'function') {
        onSelect(entity);
    }
};

如果没有特殊要求,轮廓线方案其实非常简单实用。

// 2. 颜色高亮,修改模型材质
originalModelMaterial = entity.model.color || Cesium.Color.WHITE.clone();
entity.model.color = Cesium.Color.fromCssColorString('#fb0528').withAlpha(0.8);
// 强制刷新
viewer.scene.requestRender();

这里需要注意,修改模型材质一定要执行强制刷新

// 3. 模型遮罩效果
viewer.entities.add({
    position: entity.position,
    orientation: entity.orientation,
    model: {
        uri: entity.model.uri, // 复用同一个模型文件
        scale: 0.35, // 稍微大一点
        color: Cesium.Color.fromCssColorString('#409EFF').withAlpha(0.5), // 半透明蓝
        silhouetteColor: Cesium.Color.BLUE, // 可选:配合轮廓
        silhouetteSize: 2.0
    }
});

这种效果也非常不错,复用模型文件稍大一号,让他完美的遮住原始模型,给出一个透明色作为材质,显得很有科技感。

总结

模型设计的时候推荐大家优先使用 GLB 格式替代 GLTF 规避网格加载异常问题,

另外通过pickPriority(拾取优先级)和pickable(是否可拾取)参数,从逻辑层面控制模型的交互规则,彻底解决 点错模型、点不到模型 的问题。

后续增加相关图标的单击和操作,实现小型设备的交互。

Vue3 组件库实战(七):从本地到 NPM:版本管理与自动化发布指南(下)

作者 小祸
2026年3月15日 10:39

请添加图片描述

从本地到 NPM(下):版本管理与自动化发布指南

写在前面:在完成了核心组件的打包构建与测试后(详见《从本地到 NPM(上):工程化构建与打包指南》),我们的组件库 my-antd-ui 正式进入了最后也是最关键的阶段——版本发布。这不仅是把代码传到网上那么简单,更是要建立一套规范、透明、可回溯的版本管理体系。


四、如何发布?(流程篇)

手动改版本号和写更新日志太低效。在 Monorepo 项目中,手动管理几十个子包的版本号简直是噩梦。我们引入了版本管理利器:Changesets

1. 什么是 Changesets?

它是一个专门处理版本控制变更记录的工具。它将“记录改动”与“执行发布”解耦,让版本迭代变得像流水线一样精准。

2. 为什么我们需要它?

  • 拒绝手动修改:不用再去每个 package.json 里手动填版本号。
  • 自动化日志:它会自动收集你的改动信息,生成漂亮的 CHANGELOG.md
  • 关联性同步:如果你改了 utils 包,它会自动提醒你受影响的 components 包是否也需要升级。

3. 核心配置速览

我们的配置文件位于 .changeset/config.json,它是整个发布系统的“大脑”:

{
  "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
  "changelog": "@changesets/cli/changelog", // 指定生成 CHANGELOG.md 的方式
  "commit": false, // 执行 version 命令时是否自动提交 git commit
  "fixed": [], // 强制同步版本的包组(组内任一包更新,全组同步版本)
  "linked": [], // 关联版本的包组(版本保持一致,但仅在有变更时更新)
  "access": "restricted", // 发布权限:public(公开) 或 restricted(私有)
  "baseBranch": "master", // 项目主分支,用于对比变更
  "updateInternalDependencies": "patch", // 内部依赖更新时的版本提升策略
  "ignore": [] // 忽略版本管理的包
}
🧪 深度解析:updateInternalDependencies

假设我们有两个包,一个依赖另一个:

pkg-a @ version 1.0.0
pkg-b @ version 1.0.0
  depends on pkg-a at range `^1.0.0

当我们同时为两者发布 Patch (补丁) 变更(1.0.1)时:

  • 设置为 "patch" (默认)pkg-bpkg-a 的依赖会强制更新为 ^1.0.1。这是一种积极更新策略,确保内部依赖总是指向最新补丁。
  • 设置为 "minor"pkg-bpkg-a 的依赖将保持 ^1.0.0。因为变更只是 Patch 级别,未达到 Minor 阈值,所以依赖范围不移动。

4. 规范流程与后续建议

在实际开发中,建议遵循以下“三部曲”:

  1. Record (记录变更): 运行 npx changeset。它会启动交互式菜单:

    • 选包:按 空格 勾选本次有变动的包。
    • 定级:选择变更等级。Major (大变动/不兼容)、Minor (新功能)、Patch (修复 Bug)。
    • 写总结:输入简明的变更描述(建议使用中文)。 随后它会在 .changeset/ 目录下生成一个随机命名的 .md 文件。这个文件就是你发布前的**“存证”**。
  2. Version (版本提升): 在准备发布前,运行 npx changeset version。工具会“消费”掉刚才生成的那些 .md 存证文件,自动更新相关包的 package.json 版本号,并同步生成/更新 CHANGELOG.md

  3. Publish (正式发布): 运行 pnpm build 确保产物最新,然后执行 npx changeset publish 将你的组件库一键推送到 NPM 仓库。


五、实战演练:发布一个 Patch 补丁版本

在完成文档更新或修复微小 Bug 后,我们需要发布一个 Patch(补丁)版本。以下是本次实战的真实操作记录:

1. 记录变更 (Record)

执行 npx changeset。在交互式菜单中:

  • 选包:按 空格 勾选 @my-antd-ui/components, @my-antd-ui/theme, @my-antd-ui/utils 等所有受影响的包。
  • 定级:选择 patch
  • 总结:输入 更新发布文档

2. 提升版本 (Version)

执行 npx changeset version

  • 版本更新:所有相关子包的 package.json 版本号从 1.0.0 统一提升至 1.0.1
  • 日志同步:每个包的 CHANGELOG.md 都自动插入了本次变更的中文说明。

3. 构建与发布 (Build & Publish)

# 执行 Monorepo 一键构建
pnpm build

# 执行正式发布
npx changeset publish

⚠️ 发布常见错误排查 (Troubleshooting)

1. ENEEDAUTH - 授权失败

现象error ENEEDAUTH 原因:指向了国内镜像源(如淘宝镜像),或者未在当前终端登录 NPM 账号。 解决

  1. 切换官方源npm config set registry https://registry.npmjs.org/
  2. 执行登录npm login

2. 402 Payment Required - 作用域权限

现象:发布以 @xxx/ 开头的包时报错。 解决:在发布命令后添加参数,或在 .changeset/config.json 中配置 "access": "public"

npx changeset publish --access public

3. 403 Forbidden - 2FA 验证失败

现象error E403 Forbidden - Two-factor authentication... is required 解决:使用 Granular Access Token(勾选 "Bypass 2FA requirement" 选项)并配置到本地 npm。

npm config set //registry.npmjs.org/:_authToken=YOUR_TOKEN_HERE

📦 组件库使用指南

发布成功后,用户可以通过以下方式使用你的组件库:

1. 全局注册

import { createApp } from 'vue'
import MyAntdUI from '@my-antd-ui/components'
import '@my-antd-ui/theme/index.css'

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

2. 按需引入

<script setup lang="ts">
import { MyButton, MyInput, MyMessage } from '@my-antd-ui/components'
import { formatDate } from '@my-antd-ui/utils'

const handleClick = () => {
  MyMessage.success('操作成功!')
}
</script>

<template>
  <div>
    <MyButton type="primary" @click="handleClick">点击我</MyButton>
    <MyInput v-model="value" placeholder="请输入内容" />
  </div>
</template>

🏁 结语:构建生产级组件库的"四大支柱"

发布组件库不是简单的代码搬运,而是一场关于标准化自动化的架构实践。

  1. 统一构建(Standardized Build):基于 Vite 库模式确保产物跨环境兼容。
  2. 极智体验(Developer Experience):通过自动生成 .d.tsglobal.d.ts 扩展提升开发感。
  3. 版本契约(Versioning Contract):引入 Changesets 规范迭代流程。
  4. 质量守卫(Quality Gate):依托 GitHub Actions 将质量固化。

工程化不是为了解决现在的问题,而是为了预防未来的灾难。 当你学会从“写一个组件”转向“经营一个生态”时,你就已经完成了从普通开发者向系统架构师的跃迁。


相关阅读:

Vue3 组件库实战(五):Icon 图标组件的设计与实现

作者 小祸
2026年3月15日 10:36

请添加图片描述

Vue3 组件库实战:Icon 图标组件的设计与实现

本文将带你深入理解一个企业级 Icon 组件的设计思路和实现细节,适合 Vue 3 初学者阅读。

📖 目录


为什么需要 Icon 组件

在现代 Web 应用中,图标无处不在:按钮上的勾选图标、导航栏的菜单图标、提示信息的警告图标等等。如果每次使用图标都要手写 SVG 代码或者引入图片,会带来以下问题:

  1. 代码冗余:每个地方都要复制粘贴相同的 SVG 代码
  2. 维护困难:如果要统一修改图标样式,需要改很多地方
  3. 不够灵活:很难动态控制图标的大小、颜色等属性
  4. 不够规范:团队成员可能使用不同来源的图标,导致风格不统一

因此,我们需要一个统一的 Icon 组件来解决这些问题。


组件设计思路

我们的 Icon 组件基于以下设计原则:

1. 简单易用

<!-- 只需要一个 name 属性就能使用图标 -->
<MyIcon name="check" />

2. 高度可定制

<!-- 支持自定义大小和颜色 -->
<MyIcon name="home" :size="24" color="#409eff" />

3. 扩展性强

<!-- 如果内置图标不够用,可以通过插槽自定义 -->
<MyIcon :size="24">
  <svg><!-- 自定义 SVG --></svg>
</MyIcon>

核心功能实现

让我们逐步拆解这个组件的实现,看看每一部分是如何工作的。

第一步:定义组件属性(Props)

const props = defineProps({
  // 图标名称
  name: {
    type: String as PropType<string>,
    default: undefined,
  },
  // 图标大小,支持数字(px)或字符串(如 '2em')
  size: {
    type: [Number, String] as PropType<number | string>,
    default: undefined,
  },
  // 图标颜色
  color: {
    type: String,
    default: undefined,
  },
})

解释:

  • name:用户通过这个属性指定要显示哪个图标,比如 "check""home"
  • size:控制图标大小,可以传数字(会自动加 px 单位)或字符串(如 "2em"
  • color:控制图标颜色,支持任何 CSS 颜色值(如 "#409eff""red" 等)

为什么 size 要支持两种类型?

  • 传数字更方便:<MyIcon :size="24" />
  • 传字符串更灵活:<MyIcon size="2em" /> 可以使用相对单位

第二步:创建图标映射表

// 首先从 Ant Design Icons 导入需要的图标
import {
  CheckOutlined,
  CloseOutlined,
  InfoCircleOutlined,
  SearchOutlined,
  // ... 更多图标
} from '@ant-design/icons-vue'

// 创建一个映射表,将简单的名称映射到实际的图标组件
const iconMap: Record<string, Component> = {
  'check': CheckOutlined,
  'close': CloseOutlined,
  'info': InfoCircleOutlined,
  'search': SearchOutlined,
  'user': UserOutlined,
  'setting': SettingOutlined,
  'home': HomeOutlined,
  'delete': DeleteOutlined,
  'edit': EditOutlined,
  'plus': PlusOutlined,
  'minus': MinusOutlined,
  'up': UpOutlined,
  'down': DownOutlined,
  'left': LeftOutlined,
  'right': RightOutlined,
  'loading': LoadingOutlined,
  'check-circle': CheckCircleOutlined,
  'close-circle': CloseCircleOutlined,
  'exclamation-circle': ExclamationCircleOutlined,
  'warning': WarningOutlined,
}

解释:

这个映射表是整个组件的核心!它的作用是:

  1. 简化使用:用户只需要记住简单的名称(如 "check"),而不需要记住完整的组件名(CheckOutlined
  2. 统一管理:所有可用的图标都在这里定义,方便维护和扩展
  3. 类型安全:使用 TypeScript 的 Record<string, Component> 类型,确保映射的值都是 Vue 组件

什么是 Record 类型?

Record<string, Component> 是 TypeScript 的一个工具类型,表示:

  • 键(key)是字符串类型
  • 值(value)是 Component 类型(Vue 组件)

相当于:

{
  [key: string]: Component
}

第三步:计算图标样式

const iconStyle = computed<CSSProperties>(() => {
  const style: CSSProperties = {}

  if (props.size) {
    // 如果是数字,添加 px 单位;否则直接使用字符串值
    style.fontSize
      = typeof props.size === 'number' ? `${props.size}px` : props.size
  }

  if (props.color) {
    style.color = props.color
  }

  return style
})

解释:

这是一个计算属性(computed),它会根据 props 动态生成 CSS 样式对象。

为什么使用 computed?

  1. 响应式:当 props.sizeprops.color 变化时,样式会自动更新
  2. 缓存:只有依赖的数据变化时才重新计算,提高性能
  3. 类型安全:使用 CSSProperties 类型,确保生成的样式对象符合 CSS 规范

代码逻辑详解:

// 1. 创建一个空的样式对象
const style: CSSProperties = {}

// 2. 如果用户传了 size 属性
if (props.size) {
  // 判断 size 是数字还是字符串
  style.fontSize = typeof props.size === 'number'
    ? `${props.size}px`  // 数字:24 → "24px"
    : props.size         // 字符串:直接使用 "2em"
}

// 3. 如果用户传了 color 属性
if (props.color) {
  style.color = props.color  // 直接设置颜色
}

// 4. 返回最终的样式对象
return style

为什么用 fontSize 控制图标大小?

因为 Ant Design Icons 是基于字体图标(Icon Font)的原理,图标的大小由 font-size 控制,颜色由 color 控制。

第四步:获取对应的图标组件

const iconComponent = computed(() => {
  if (props.name && iconMap[props.name]) {
    return iconMap[props.name]
  }
  return null
})

解释:

这也是一个计算属性,用于根据用户传入的 name 查找对应的图标组件。

代码逻辑:

  1. 检查用户是否传了 name 属性
  2. 检查 iconMap 中是否存在这个名称的图标
  3. 如果都满足,返回对应的图标组件
  4. 否则返回 null(表示没有找到图标)

为什么要返回 null?

因为在模板中,我们会根据 iconComponent 是否为 null 来决定是渲染图标还是使用插槽内容。

第五步:渲染模板

<template>
  <span :class="ns.b()" :style="iconStyle">
    <!-- 如果指定了 name 属性,渲染对应的 Ant Design 图标 -->
    <component :is="iconComponent" v-if="iconComponent" />
    <!-- 否则使用插槽,允许自定义图标内容 -->
    <slot v-else />
  </span>
</template>

解释:

这是组件的渲染逻辑,让我们逐行分析:

1. 外层容器
<span :class="ns.b()" :style="iconStyle">
  • 使用 <span> 作为容器(行内元素,不会独占一行)
  • :class="ns.b()" 是 BEM 命名规范的工具函数,会生成类名 my-icon
  • :style="iconStyle" 应用我们计算好的样式(大小和颜色)
2. 动态组件渲染
<component :is="iconComponent" v-if="iconComponent" />

这是 Vue 的动态组件语法:

  • <component :is="xxx" /> 可以动态渲染不同的组件
  • v-if="iconComponent" 只有当找到对应图标时才渲染
  • 相当于:如果用户传了 name="check",就渲染 <CheckOutlined /> 组件

为什么不直接写 <CheckOutlined />

因为我们不知道用户会传什么 name,需要根据 name 动态决定渲染哪个图标组件。

3. 插槽后备内容
<slot v-else />
  • <slot /> 是 Vue 的插槽语法,允许用户传入自定义内容
  • v-else 表示:如果没有找到对应的图标(iconComponentnull),就使用插槽内容

使用场景:

<!-- 场景 1:使用内置图标 -->
<MyIcon name="check" />  <!-- 渲染 CheckOutlined -->

<!-- 场景 2:使用自定义图标 -->
<MyIcon :size="24">
  <svg><!-- 自定义 SVG --></svg>
</MyIcon>  <!-- 渲染插槽内容 -->

使用示例

基础用法

<template>
  <!-- 最简单的用法 -->
  <MyIcon name="check" />

  <!-- 设置大小 -->
  <MyIcon name="home" :size="24" />

  <!-- 设置颜色 -->
  <MyIcon name="user" color="#409eff" />

  <!-- 同时设置大小和颜色 -->
  <MyIcon name="setting" :size="32" color="red" />
</template>

在按钮中使用

<template>
  <button>
    <MyIcon name="check" :size="16" />
    <span>确认</span>
  </button>

  <button>
    <MyIcon name="close" :size="16" />
    <span>取消</span>
  </button>
</template>

<style scoped>
button {
  display: flex;
  align-items: center;
  gap: 8px;
}
</style>

使用自定义图标

<template>
  <MyIcon :size="24" color="#67c23a">
    <svg viewBox="0 0 1024 1024" fill="currentColor">
      <path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448..." />
    </svg>
  </MyIcon>
</template>

注意: 自定义 SVG 时,使用 fill="currentColor" 可以让图标继承父元素的 color 属性。

动态切换图标

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

const isVisible = ref(false)
</script>

<template>
  <button @click="isVisible = !isVisible">
    <MyIcon :name="isVisible ? 'up' : 'down'" />
    <span>{{ isVisible ? '收起' : '展开' }}</span>
  </button>
</template>

最佳实践

1. 统一图标大小

在实际项目中,建议定义统一的图标大小规范:

// constants.ts
export const ICON_SIZE = {
  SMALL: 16,
  MEDIUM: 20,
  LARGE: 24,
  XLARGE: 32,
}
<template>
  <MyIcon name="check" :size="ICON_SIZE.MEDIUM" />
</template>

2. 使用语义化的颜色

// theme.ts
export const ICON_COLOR = {
  PRIMARY: '#409eff',
  SUCCESS: '#67c23a',
  WARNING: '#e6a23c',
  DANGER: '#f56c6c',
  INFO: '#909399',
}
<template>
  <MyIcon name="check-circle" :color="ICON_COLOR.SUCCESS" />
  <MyIcon name="close-circle" :color="ICON_COLOR.DANGER" />
</template>

3. 封装常用图标组合

<!-- SuccessIcon.vue -->
<template>
  <MyIcon name="check-circle" :size="20" color="#67c23a" />
</template>

<!-- ErrorIcon.vue -->
<template>
  <MyIcon name="close-circle" :size="20" color="#f56c6c" />
</template>

4. 添加无障碍支持

<template>
  <MyIcon
    name="delete"
    role="img"
    aria-label="删除"
  />
</template>
  • role="img":告诉屏幕阅读器(如视障用户使用的读屏软件)这个元素是一个图标,而非普通文本或装饰性元素。
  • aria-label="删除":为图标提供文字描述。因为图标本身没有文字内容,屏幕阅读器读到该元素时会朗读"删除",帮助视障用户理解图标的含义。
  • 由于组件使用了 <script setup>,Vue 3 会自动将未声明的 attrs(如 rolearia-label)透传到根元素 <span> 上,无需额外处理。

技术要点总结

1. TypeScript 类型定义

// PropType 用于定义 props 的类型
type: String as PropType<string>
type: [Number, String] as PropType<number | string>

// CSSProperties 用于定义 CSS 样式对象的类型
const style: CSSProperties = {}

// Record 用于定义对象映射的类型
const iconMap: Record<string, Component> = {}

2. Vue 3 Composition API

// computed:计算属性,自动缓存和响应式更新
const iconStyle = computed(() => { /* ... */ })

// defineProps:定义组件属性
const props = defineProps({ /* ... */ })

// defineOptions:定义组件选项(如 name)
defineOptions({ name: 'MyIcon' })

3. 动态组件渲染

<!-- 根据变量动态渲染不同的组件 -->
<component :is="iconComponent" />

4. 插槽(Slot)

<!-- 允许父组件传入自定义内容 -->
<slot />

5. 条件渲染

<!-- v-if 和 v-else 实现条件渲染 -->
<component :is="iconComponent" v-if="iconComponent" />
<slot v-else />

扩展思考

如何添加新图标?

只需要在 iconMap 中添加新的映射:

import { SmileOutlined } from '@ant-design/icons-vue'

const iconMap: Record<string, Component> = {
  // ... 现有图标
  'smile': SmileOutlined,  // 添加新图标
}

如何支持图标旋转动画?

可以添加一个 spin 属性:

const props = defineProps({
  // ... 现有属性
  spin: {
    type: Boolean,
    default: false,
  },
})
<template>
  <span
    :class="[ns.b(), { 'is-spin': spin }]"
    :style="iconStyle"
  >
    <!-- ... -->
  </span>
</template>

<style>
.my-icon.is-spin {
  animation: icon-spin 1s linear infinite;
}

@keyframes icon-spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}
</style>

如何支持图标点击事件?

组件本身不需要处理,父组件直接绑定即可:

<MyIcon name="delete" @click="handleDelete" />

Vue 会自动将事件绑定到组件的根元素(<span>)上。


总结

通过这个 Icon 组件的实现,我们学到了:

  1. 组件设计原则:简单易用、高度可定制、扩展性强
  2. TypeScript 类型系统:PropType、CSSProperties、Record 等类型的使用
  3. Vue 3 核心特性:Composition API、computed、动态组件、插槽
  4. 工程化思维:通过映射表统一管理图标,提高可维护性

这个组件虽然代码不多(约 110 行),但包含了很多实用的设计模式和最佳实践,非常适合作为学习 Vue 3 组件开发的案例。

希望这篇文章能帮助你更好地理解组件的设计与实现!


相关资源


从“DOM 操作”到“数据驱动”:Vue 如何重塑前端开发思维

作者 Lee川
2026年3月14日 18:22

从“DOM 操作”到“数据驱动”:Vue 如何重塑前端开发思维

导读:在传统的 Web 开发中,我们习惯于像“外科医生”一样精准地操作每一个 DOM 节点;而在 Vue 的世界里,我们更像是“指挥官”,只需关注数据的变化,剩下的交给框架。本文将通过深度剖析一段现代 Vue 3 待办事项(Todo List)代码,对比传统 demo.html 的实现缺陷,带你深入理解 Vue 的核心开发哲学与代码美学。


一、传统开发的困境:被 DOM 绑架的逻辑

假设我们手头有一份传统的 demo.html 文件(基于原生 JavaScript 或 jQuery 实现)。在这类文件中,实现一个待办事项列表通常意味着:

  1. 手动获取元素document.getElementById('input'), querySelectorAll('li')
  2. 繁琐的事件监听addEventListener('click', ...)addEventListener('keydown', ...)
  3. 直接的 DOM 操作:添加任务时 createElementappendChild;完成任务时 classList.toggle;统计数量时遍历 DOM 节点计数。
  4. 状态同步噩梦:数据变了要手动改 DOM,DOM 变了要手动改数据。一旦遗漏,页面显示与数据不一致的 Bug 随之而来。

这种“命令式”编程让开发者陷入了细节的泥潭:代码耦合严重、维护困难、性能隐患大


二、Vue 的革命:代码深度解析

当我们转向你提供的这段 Vue 3 <script setup> 代码时,会发现一种截然不同的优雅。让我们逐行拆解,看看 Vue 是如何通过响应式系统声明式渲染计算属性来解决传统痛点的。

2.1 响应式基石:ref 与数据焦点

import {ref, computed} from 'vue'

// 响应式数据
const title = ref();
const todos = ref([
  { id:1, title:'吃鸡', done:true },
  { id:2, title:'睡觉', done:true }
]);
  • 传统做法:你需要定义一个数组变量,然后每次修改它时,都要记得去更新页面上的列表。
  • Vue 做法:使用 ref() 将普通变量包裹成响应式引用
    • titletodos 不再是普通变量,而是带有“魔法”的数据容器。
    • 核心逻辑:正如代码注释所言,“vue focus 标题数据业务,修改数据,余下的 dom 更新 vue 替我们做了”。你只需要关心 title.value 是什么,todos.value 里有什么,完全不需要知道页面上有几个 <li> 标签。
    • 访问机制:在 <script> 中通过 .value 访问真实数据(如 title.value),而在 <template> 中 Vue 会自动解包,直接使用 {{ title }}

2.2 声明式渲染:模板即逻辑

<h2>{{ title }}</h2>
<input type="text" v-model="title" @keydown.enter="addTodo">

<ul v-if="todos.length">
  <li v-for="todo in todos" :key="todo.id">
      <input type="checkbox" v-model="todo.done">
      <span :class="{done: todo.done}">{{ todo.title }}</span> 
  </li>
</ul>
<div v-else>
  暂无计划
</div>

这段模板代码展示了 Vue 三大指令的精妙配合,彻底摒弃了手动操作 DOM:

A. 双向绑定 v-model
  • 代码v-model="title"v-model="todo.done"
  • 解析:这是 Vue 最强大的特性之一。
    • 在输入框中,它将输入内容与 title 变量绑定。用户打字,title 自动变;代码修改 title,输入框自动变。
    • 在复选框中,它将勾选状态与 todo.done 绑定。
    • 对比传统:传统写法需要监听 input 事件更新变量,监听变量变化更新 input 值,代码量翻倍且容易出错。Vue 一行搞定。
B. 事件修饰符 @keydown.enter
  • 代码@keydown.enter="addTodo"
  • 解析
    • @v-on: 的缩写,用于监听事件。
    • .enter事件修饰符,意为“只在按下回车键时触发”。
    • 优势:无需在 JS 中写 if (event.key === 'Enter') 判断逻辑,语义清晰,代码极简。注释中提到“不用 addEventListener”,正是指这种声明式绑定的便捷性。
C. 条件与列表渲染 v-if / v-for / :key
  • 代码v-if="todos.length"v-for="todo in todos" :key="todo.id"
  • 解析
    • 智能空状态v-ifv-else 实现了“有数据显示列表,无数据显示提示”的逻辑切换,无需手动 display: none
    • 高效循环v-for 根据 todos 数组自动生成 <li>
    • Key 的作用:key="todo.id" 是 Vue 优化渲染的关键。它给每个节点发了“身份证”,当数组顺序变化或删除项时,Vue 能精准复用 DOM 节点,而不是暴力销毁重建,极大提升性能。
D. 动态 Class 绑定 :class
  • 代码:class="{done: todo.done}"
  • 解析
    • :v-bind: 的缩写。
    • 这是一个对象语法:当 todo.donetrue 时,应用 done 类(灰色删除线);为 false 时,不应用。
    • 数据驱动视图:你不需要写 element.classList.add('done'),只需改变数据 todo.done = true,样式自动生效。

2.3 性能与逻辑的升华:computed 计算属性

代码中两处使用了 computed,这是区分新手与高手的关键。

场景一:统计未完成数量
// 依赖于 todos 响应式数据的计算属性
const active = computed(() => {
  return todos.value.filter(todo => !todo.done).length
})
  • 模板调用{{ active }} / {{ todos.length }}
  • 深度分析
    • 缓存机制:注释写道“computed 缓存 性能优化 只有 todos 变化时才会重新计算”。如果用户只是在输入框打字(触发组件重渲染),但未改变 todos 数组,active 不会重新执行 filter,直接返回缓存结果。
    • 对比劣势方案:如果在模板中直接写 {{ todos.filter(...).length }},每次组件更新(哪怕无关)都会重新遍历数组,浪费性能。
    • 逻辑复用:复杂的过滤逻辑被封装在 JS 中,模板保持干净。
场景二:全选/全不选的高级技巧
const allDone = computed({
  get() {
    return todos.value.every(todo => todo.done)
  },
  set(val) {
    todos.value.forEach(todo => todo.done = val)
  }
})
  • 模板调用<input type="checkbox" v-model="allDone">
  • 深度分析:这是 computed读写模式(Getter/Setter)。
    • **Get **(读):当页面渲染时,检查是否所有任务都完成了 (every)。如果是,全选框自动勾选。
    • **Set **(写):当用户点击全选框时,触发 set,将所有任务的 done 状态设为 val
    • 神奇之处:一个 v-model 同时实现了“状态同步”和“批量修改”。传统 JS 需要分别编写“检查所有状态更新全选框”和“监听全选框更新所有状态”两段逻辑,极易出现不同步 Bug。Vue 将其收敛为一个计算属性,逻辑严密且优雅。

2.4 业务逻辑封装:addTodo 函数

const addTodo = () => {
  if(!title.value) return; // 数据校验
  todos.value.push({
    id: Date.now(), // 使用时间戳生成唯一 ID,比 Math.random() 更可靠
    title: title.value,
    done: false
  })
  // 注意:这里没有操作 DOM!
  // 只要 push 进数组,Vue 会自动在页面上添加一个新的 <li>
}
  • 纯粹的数据操作:函数内部没有任何 document 相关代码。
  • ID 策略:使用 Date.now() 生成唯一 ID,配合 :key 确保列表渲染稳定。
  • 自动响应push 操作触发 Vue 的响应式系统,视图自动更新。

三、思维跃迁:从“怎么做”到“是什么”

通过这段代码,我们可以清晰地看到 Vue 带来的思维转变:

维度 传统 DOM 操作 (demo.html) Vue 数据驱动 (当前代码)
关注点 How:怎么找到元素?怎么添加类名?怎么监听事件? What:数据是什么?状态是什么?
状态同步 手动双向同步,易出错 自动双向绑定 (v-model)
列表渲染 手动循环创建/删除节点 声明式循环 (v-for),自动 Diff
复杂逻辑 分散在事件回调中,难以维护 封装在 computed 中,自动缓存
代码量 多且冗余 少而精悍
可维护性 低,牵一发而动全身 高,逻辑与视图分离

核心心法总结

  1. 数据是唯一真理:不要直接操作 DOM。想改变页面?先改变数据。
  2. 声明式优于命令式:告诉 Vue 你想要什么结果(v-if, v-for),而不是告诉它一步步怎么做。
  3. 计算属性是性能利器:涉及复杂推导或频繁使用的数据,务必使用 computed 利用缓存。
  4. 组合式 API 的内聚性<script setup> 让相关逻辑(如 todos, active, addTodo)聚集在一起,代码组织更符合人类思维。

四、结语

这段看似简单的 Todo List 代码,实则是现代前端开发哲学的缩影。它展示了 Vue 如何通过响应式系统将开发者从繁琐的 DOM 操作中解放出来,让我们能专注于业务逻辑本身。

demo.html 的“手动挡”到 Vue 的“自动挡”,不仅仅是语法的升级,更是开发效率与代码质量质的飞跃。当你习惯了“修改数据即修改视图”的思维模式后,你会发现,构建复杂的交互应用变得前所未有的简单、高效且充满乐趣。

这,就是 Vue 赋予我们的超能力。

Flow Render: 像调用异步函数一样渲染 UI 组件

作者 sxq
2026年3月14日 17:18

Flow Render 提供了一种基于 Promise 的 UI 渲染方式,让你可以像调用异步函数一样渲染组件,并等待用户交互结果

它将分散的状态、回调和组件层级重新组织为线性的 async/await 控制流,让复杂的交互逻辑变得直观且易于维护。

const result = await render(Component)

✨ 核心特性

  • Promise 驱动的 UI 渲染:像调用异步函数一样等待组件的结果
  • 支持任意组件 Promise 化:新组件或现有组件都能接入,无需侵入式改造
  • 控制流集中管理:交互逻辑按顺序书写,避免状态分散和回调嵌套
  • 支持上下文完整继承:继承 theme、i18n、store 等应用上下文
  • 实例隔离,用完即销毁:每次渲染都是独立实例,互不干扰,组件状态自动重置
  • 支持全局与局部渲染:既可挂载在应用根节点,也可绑定到局部组件生命周期

📦 Framework Support

Framework Package
React @flow-render/react (也支持 React Native)
Vue @flow-render/vue
Preact @flow-render/preact
Svelte @flow-render/svelte
Solid @flow-render/solid

🚀 快速开始(React)

第一步:安装

npm i @flow-render/react

第二部:挂载容器

在应用根节点放一个 <Viewport/>,所有动态渲染的组件都会出现在这里。

import { Viewport } from '@flow-render/react'

function App () {
  return (
    <>
      <YourApp/>
      <Viewport/> {/* 动态组件都渲染在这里 */}
    </>
  )
}

第三步:定义组件

Flow Render 支持两种编写组件的模式,你可以根据场景自由选择。

执行器模式(推荐)

组件内部直接声明并使用 resolve / reject 回调,类似 new Promise((resolve, reject)=>...) 的执行器风格。

import { type PromiseResolvers } from '@flow-render/react'

interface Props extends PromiseResolvers<boolean> {
  title: string
}

function ConfirmDialog ({ title, resolve, reject }: Props) {
  return (
    <dialog open>
      <div>{title}</div>
      <div>
        <button onClick={() => resolve(true)}>是</button>
        <button onClick={() => resolve(false)}>否</button>
        <button onClick={() => reject(new Error('取消'))}>取消</button>
      </div>
    </dialog>
  )
}

渲染时自动注入回调:

import { render } from '@flow-render/react'

const result = await render(ConfirmDialog, {
  title: '你确定吗?'
})

适配器模式(灵活强大)

适配器模式让你可以将任意组件的 props 与 Promise 动态关联。你只需提供一个函数,该函数接收 resolve 和 reject,并返回组件的 props。这种方式不仅兼容现有组件,还能实现更复杂的逻辑,例如根据外部数据决定 props、条件渲染、动态绑定等。

interface Props {
  title: string
  onYes: () => void
  onNo: () => void
  onCancel: () => void
}

function ConfirmDialog (props: Props) {
  return (
    <dialog open>
      <div>{props.title}</div>
      <div>
        <button onClick={props.onYes}></button>
        <button onClick={props.onNo}></button>
        <button onClick={props.onCancel}>取消</button>
      </div>
    </dialog>
  )
}

适配器模式渲染时,可以通过适配器函数建立 Promise 与组件回调的关联:

import { render } from '@flow-render/react'

const result = await render(ConfirmDialog, (resolve, reject) => {
  return {
    title: '你确定吗?',
    onYes: () => resolve(true),
    onNo: () => resolve(false),
    onCancel: () => reject(),
  }
})

全局渲染器(默认)

默认情况下,render() 渲染出的动态组件生命周期不跟随调用它的组件,而是跟随全局 Viewport

这意味着:

  • 即使触发渲染的组件已卸载,动态组件仍可继续存在
  • 适合全局弹窗、确认框、选择器、异步引导流程等场景

若希望动态组件在当前页面或当前组件卸载时自动销毁,请使用局部渲染器


局部渲染器

使用 useRenderer() 可以创建一个与当前组件生命周期绑定的局部渲染器。

适用场景:

  • 页面级弹窗
  • 需跟随局部区域销毁的交互
  • 希望自定义渲染位置
import { useRenderer } from '@flow-render/react'

function Page () {
  const [render, Viewport] = useRenderer()

  return (
    <div>
      <button onClick={() => render(ConfirmDialog)}>打开</button>
      <Viewport/>
    </div>
  )
}

Page 卸载时,局部渲染器中未完成的渲染任务也会一并结束。


自定义渲染器

开发组件库业务子系统时,你可能希望对外暴露自己的渲染入口,而不是让用户依赖默认渲染器。此时可使用 createRenderer() 创建独立实例。

// your-lib/index.ts

import { createRenderer } from '@flow-render/react'

const [render, Viewport] = createRenderer()

export function LibProvider (props) {
  return (
    <>
      {props.children}
      <Viewport/>
    </>
  )
}

export function openDialog () {
  return render(Dialog)
}

这样用户使用时只需接入库提供的 Provider 和对应的方法,无需了解关于 Flow Render 的任何逻辑:

import { LibProvider, openDialog } from 'your-lib'

function App () {
  return (
    <LibProvider>
      <UserApp/>
      <button onClick={() => openDialog()}>打开</button>
    </LibProvider>
  )
}

这样便将渲染能力封装在库内部,对外提供更稳定、统一的 API。


取消渲染

手动取消渲染

某些高级场景下,你可能需要从外部中断 UI 流程,例如:

  • 超时自动关闭
  • 路由切换时终止
  • 用户主动取消整个流程

由于 render() 返回标准 Promise,你可以在适配器中自行暴露取消能力:

let cancel: () => void

const promise = render(Component, (resolve, reject) => {
  cancel = () => reject(new Error('Cancelled'))

  return {
    resolve,
    reject,
  }
})

// 需要时调用
cancel()

自动取消渲染

Viewport 卸载时(例如全局 Viewport 随应用销毁,或局部 Viewport 随组件销毁),所有未完成的渲染任务会自动 reject。如有必要可以通过 isCancelError 判断错误是否由自动取消引起。

import { render, isCancelError } from '@flow-render/react'

try {
  await render(Component)
} catch (error) {
  if (isCancelError(error)) {
    // 处理自动取消
    return
  }

  throw error
}

适用场景

Flow Render 特别适合以下交互:

  • 确认框 / 提示框
  • 表单弹窗
  • 选择器
  • 向导流程
  • 登录拦截
  • 权限确认
  • 任何需要“等待用户完成某一步再继续”的 UI 逻辑

例如,你可以将原本分散的交互写成线性流程:

async function postForm () {
  // 第一步:确认
  const confirmed = await render(ConfirmDialog, {
    title: '确认提交?'
  })

  if (!confirmed) {
    return
  }

  // 第二步:填写表单
  const formData = await render(FormDialog)

  // 第三步:提交
  await submit(formData)
}

相比传统的状态驱动写法,这种方式更易阅读、复用和维护。


设计理念

Flow Render 并非要替代框架原有的组件模型,而是为异步 UI 交互流程提供更自然的表达方式:

  1. 按需动态渲染
  2. 展示 UI 并等待用户操作
  3. 获取结果后继续后续逻辑

这几件事可以组织在同一段 async / await 代码中。

对于跨组件、跨层级、跨流程的交互,这种写法往往更直观。


Github: github.com/flow-render…

用 AI 实现图片懒加载,这也太简单了!

作者 wing98
2026年3月14日 16:20

在前端摸爬滚打了8年,以前做的主要是B端项目,所以很少能接触到性能优化方面的需求。

最近我们面向C端用户的产品首页图片比较多,产品在给老板演示时,发现图片加载速度很慢。

之前虽然设置了图片缓存,但架不住用户首次打开;而且之前的分页在最近一次调整中临时去掉了,导致首页需要加载50张高清大图,产品也没压缩,不卡才怪。当然也有我的锅,去掉分页后没有做懒加载。

所以,我决定对首页图片进行懒加载优化,不就是计算滚动top设置图片src吗?我都懒得写。

然后,我果断给TRAE下达了任务。

看着TRAE吭哧吭哧的干活,我悠闲的喝了一口咖啡。喝完之后,TRAE也差不多干完活了,我刷新了浏览器后滚动鼠标到底部,图片才开始加载。完美!

我开始看TRAE的代码,封装的真好啊,不过咋有点看不懂?IntersectionObserver是啥,Observer倒有点眼熟,连在一起是真不知道。

于是我点开了MDN的文档,被迫学习了一下!发现IntersectionObserver是一个非常方便的API,它可以监听元素是否进入视口。都不需要前端自己计算滚动top了,浏览器自己就可以监听。

果然科技是在进步的,就如我们现在指挥AI干活。

TRAE封装的代码如下(lazyload.ts):

/** * 图片懒加载工具函数 * 实现首次只渲染可视区域图片,滚动后加载其它图片 */interface LazyLoadOptions {  threshold?: number  rootMargin?: string}/** * 创建IntersectionObserver观察器 * @param callback 回调函数 * @param options 配置选项 * @returns IntersectionObserver实例 */const createObserver = (  callback: (entries: IntersectionObserverEntry[]) => void,  options: LazyLoadOptions = {}): IntersectionObserver => {  const defaultOptions: IntersectionObserverInit = {    threshold: options.threshold ?? 0.1,    rootMargin: options.rootMargin ?? '0px',  }  return new IntersectionObserver(callback, defaultOptions)}/** * 加载图片 * @param img 图片元素 */const loadImage = (img: HTMLImageElement): void => {  if (!img || !img.dataset.src) {    return  }  img.src = img.dataset.src  img.removeAttribute('data-src')}/** * 初始化图片懒加载 * @param selector 图片选择器 * @param options 配置选项 * @returns IntersectionObserver实例 */export const initLazyLoad = (  selector: string = 'img[lazy]',  options: LazyLoadOptions = {}): IntersectionObserver => {  const images = document.querySelectorAll<HTMLImageElement>(selector)    if (images.length === 0) {    console.warn('未找到需要懒加载的图片元素')    return createObserver(() => {})  }  const observer = createObserver((entries) => {    entries.forEach((entry) => {      if (entry.isIntersecting) {        const img = entry.target as HTMLImageElement        loadImage(img)        observer.unobserve(img)      }    })  }, options)  images.forEach((img) => {    if (img.dataset.src) {      observer.observe(img)    }  })  return observer}/** * 手动加载单张图片 * @param img 图片元素或图片元素ID */export const loadSingleImage = (img: HTMLImageElement | string): void => {  const imageElement = typeof img === 'string'     ? document.querySelector<HTMLImageElement>(img)     : img  if (imageElement) {    loadImage(imageElement)  }}/** * 销毁懒加载观察器 * @param observer IntersectionObserver实例 */export const destroyLazyLoad = (observer: IntersectionO

从“手工砌砖”到“魔法蓝图”:响应式驱动界面的诞生与实战

作者 Lee川
2026年3月14日 15:44

从“手工砌砖”到“魔法蓝图”:响应式驱动界面的诞生与实战

在编程的世界里,用户界面(UI)的构建方式经历了一场从“体力活”到“智力活”的深刻革命。这场革命的核心,就是从**“命令式地操作 DOM”转向“声明式地数据驱动”**。

为了让你彻底理解这一变革,我们将穿越时空,通过具体的代码对比,看看曾经的开发者是如何在“泥潭”中挣扎,而现在的我们又是如何利用响应式系统轻松驾驭界面的。


第一章:蛮荒时代——“手工砌砖”的痛苦

在互联网的早期(或者在使用原生 JavaScript/jQuery 的时代),浏览器只是一个简单的文档查看器。如果你想让界面上的文字变一下,或者增加一行列表,你必须像一个泥瓦匠一样,亲手去搬动每一块“砖头”(DOM 节点)。

1.1 场景:做一个简单的计数器

需求:页面上有一个数字显示当前计数,还有一个按钮,每点一次,数字加 1。

❌ 过去的做法(命令式 DOM 操作)

在那个年代,你的思维过程是这样的:

  1. 我要去 HTML 里找到那个显示数字的元素。
  2. 我要监听按钮的点击事件。
  3. 点击发生时,我要拿到当前的数字。
  4. 把数字加 1。
  5. 最关键的一步:我要手动把新数字写回那个元素里。

代码示例(原生 JavaScript):

<!-- 1. 定义 HTML 结构 -->
<div id="app">
  <h1 id="count-display">0</h1>
  <button id="increment-btn">点击加 1</button>
</div>

<script>
  // 2. 手动获取 DOM 元素(就像去仓库找砖头)
  const countDisplay = document.getElementById('count-display');
  const incrementBtn = document.getElementById('increment-btn');

  // 3. 定义一个变量存数据
  let count = 0;

  // 4. 手动绑定事件
  incrementBtn.addEventListener('click', () => {
    // 业务逻辑:数据加 1
    count = count + 1;
    
    // ⚠️ 痛苦之源:手动更新视图!
    // 如果忘了写这一行,界面永远不会变,但数据已经变了(状态不一致)
    // 如果页面有10个地方显示这个 count,你得改10次!
    countDisplay.innerText = count; 
    
    console.log("手动更新了 DOM,好累...");
  });
</script>
💡 痛点分析
  • 关注点偏移:你本该思考“点击后业务逻辑是什么”,却被迫花费大量精力在 getElementByIdinnerText 这些繁琐的 DOM 操作上。
  • 容易出错:如果你修改了 count 却忘了更新 countDisplay,界面就错了(数据与视图不同步)。
  • 难以维护:如果后来需求变了,要在三个不同的地方显示这个数字,你就得在三处地方都写上 xxx.innerText = count。代码变得像蜘蛛网一样乱。

第二章:黎明时刻——“魔法蓝图”的降临

随着 Vue、React 等框架的出现,世界变了。我们不再手动操作 DOM,而是引入了一位“管家”(响应式系统)。

核心理念你只管修改数据,界面自动会变。 你只需要画一张“蓝图”(模板),告诉框架:“这里显示 count”。至于 count 变了怎么更新界面?那是框架的事,与你无关。

2.1 同样的场景:计数器

需求:同上。

✅ 现在的做法(声明式 + 响应式)

现在的思维过程是这样的:

  1. 定义一个响应式数据 count
  2. 在模板里直接写 {{ count }}(这就是蓝图)。
  3. 点击时,只修改 count 的值。
  4. 结束。剩下的交给框架。

代码示例(Vue 3 风格):

<!-- 引入 Vue 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

<div id="app">
  <!-- 1. 声明式模板:直接告诉 Vue 这里显示 count -->
  <!-- 不需要给 h1 起 id,也不需要手动找它 -->
  <h1>{{ count }}</h1>
  
  <!-- 2. 事件绑定:点击直接调用函数 -->
  <button @click="increment">点击加 1</button>
</div>

<script>
  const { createApp, ref } = Vue;

  createApp({
    setup() {
      // 3. 定义响应式数据 (ref)
      // 这是一个有“魔法”的变量,它被修改时,所有用到它的地方都会收到通知
      const count = ref(0);

      // 4. 定义方法
      const increment = () => {
        // ⚡️ 核心时刻:只改数据!
        count.value++; 
        
        // 🎉 奇迹发生:
        // 你完全不需要写 document.getElementById...
        // 你完全不需要写 innerText = ...
        // Vue 检测到 count 变了,自动把页面上的 {{ count }} 更新为最新值
        console.log("数据已变,界面自动同步,真爽!");
      };

      // 把数据和方法暴露给模板使用
      return {
        count,
        increment
      };
    }
  }).mount('#app');
</script>
🚀 先进在哪里?
  1. 代码量减半:不需要找节点,不需要手动赋值。
  2. 单向数据流:数据是唯一的真理来源(Single Source of Truth)。你永远不会遇到“数据是 5,界面显示 4”这种 Bug。
  3. 可维护性极强:哪怕你在页面上写了 100 个 {{ count }},你也只需要改一次 count.value,所有地方瞬间同步更新。

第三章:进阶实战——列表的动态增删

如果说计数器只是热身,那么列表的动态增删才是真正体现“手工砌砖”与“魔法蓝图”差距的战场。

3.1 场景:待办事项列表

需求:有一个输入框,输入内容后回车,列表增加一项;点击列表项,该项删除。

❌ 过去的痛苦(原生 JS 实现逻辑推演)

如果用原生 JS 做这个,你需要处理:

  1. 监听输入框的 keydown 事件。
  2. 获取输入值,判空。
  3. 创建新的 li 元素 (document.createElement('li'))。
  4. 设置 li 的文本内容。
  5. 难点:给这个新生成的 li 里的“删除按钮”绑定点击事件(事件委托或直接绑定)。
  6. li 插入到 ul 中 (ul.appendChild(li))。
  7. 更难的是删除:点击删除时,要找到这个 li 对应的父节点,把它移除 (parent.removeChild(child)), 同时还要更新你内存里的数组数据,保持同步。

稍微想象一下代码长度:至少需要 30-40 行逻辑严密的 DOM 操作代码,稍有不慎就会内存泄漏或事件绑定失效。

✅ 现在的优雅(Vue 响应式实现)

在响应式世界里,我们只关心数组的变化。

<div id="todo-app">
  <h2>待办事项</h2>
  
  <!-- 双向绑定:输入框直接绑定到 newItem 变量 -->
  <input v-model="newItem" @keyup.enter="addTodo" placeholder="输入任务回车添加" />
  
  <!-- 列表渲染:v-for 指令 -->
  <!-- 意思是:items 数组里有几个元素,就生成几个 li -->
  <ul>
    <li v-for="(item, index) in items" :key="item.id">
      {{ item.text }} 
      <button @click="removeTodo(index)">删除</button>
    </li>
  </ul>
  
  <p v-if="items.length === 0">暂无任务,太轻松了!</p>
</div>

<script>
  const { createApp, ref } = Vue;

  createApp({
    setup() {
      const newItem = ref('');
      // 响应式数组
      const items = ref([
        { id: 1, text: '学习响应式原理' },
        { id: 2, text: '编写代码示例' }
      ]);

      // 添加逻辑:只操作数组
      const addTodo = () => {
        if (!newItem.value.trim()) return;
        // 往数组里 push 一个对象
        items.value.push({
          id: Date.now(),
          text: newItem.value
        });
        newItem.value = ''; // 清空输入框,界面自动清空
        
        // 🎉 此时:
        // 1. 新的 <li> 自动出现在列表中
        // 2. 删除按钮自动绑好了事件
        // 3. 如果列表从空变有,"暂无任务"提示自动消失
        // 全程无需触碰 DOM!
      };

      // 删除逻辑:只操作数组
      const removeTodo = (index) => {
        // 从数组里 splice 掉一项
        items.value.splice(index, 1);
        
        // 🎉 此时:
        // 对应的 <li> 自动从页面上移除
        // 事件监听器自动被清理(防止内存泄漏)
      };

      return {
        newItem,
        items,
        addTodo,
        removeTodo
      };
    }
  }).mount('#todo-app');
</script>

3.2 深度解析:为什么这很“先进”?

  1. 心智负担极低

    • 过去:你要同时维护“内存里的数组”和“页面上的 DOM 列表”,确保它们永远一致。这就像一边开车一边还要自己铺路。
    • 现在:你只维护“数组”。页面是数组的投影。数组变了,投影自然变。你只需要关注业务数据。
  2. 自动的事件管理

    • 在原生 JS 中,动态添加的 DOM 元素,你需要重新绑定事件,或者使用复杂的事件委托。
    • 在 Vue 中,@click 写在模板里,无论列表怎么变,新生成的元素天然就带着事件监听器,删除元素时监听器也自动销毁。
  3. 条件渲染的自动化

    • 注意代码中的 <p v-if="items.length === 0">
    • 当数组为空时,这段 HTML 自动出现;当数组有数据时,它自动消失。你不需要写 if/else 去控制 display: noneremoveChild

第四章:总结——从小白到架构师的思维跃迁

通过上面的对比,我们可以清晰地看到响应式驱动界面带来的巨大飞跃:

特性 传统 DOM 操作 (过去) 响应式数据驱动 (现在)
核心动作 查找节点 -> 修改属性 -> 插入/删除节点 修改数据变量
关注点 How (如何实现界面变化) What (数据应该是什么状态)
同步机制 手动同步,易出错 自动同步,永不失联
代码复杂度 随功能线性甚至指数增长 保持简洁,逻辑清晰
适合人群 需要精通底层细节的专家 专注于业务逻辑的开发者

给小白的建议

如果你刚开始学习前端,请忘掉 document.getElementById忘掉 innerHTML忘掉 手动添加事件监听器。

试着培养一种新的直觉:

  1. 数据先行:先想清楚我的页面需要哪些数据(比如 count, userList, isVisible)。
  2. 模板声明:在 HTML 里用 {{ }}v-for 把这些数据“画”出来。
  3. 事件驱动:在按钮点击时,只负责修改那些数据。

当你习惯了这种**“数据流动,界面随之起舞”**的感觉时,你就真正掌握了现代前端开发的精髓。这不仅仅是学会了一个框架,更是掌握了一种更高效、更优雅的构建数字世界的方法。

Pinia 高效指南:状态管理的最佳实践与性能陷阱

作者 wuhen_n
2026年3月13日 09:00

前言

在 Vue3 生态中,Pinia 已经成为官方推荐的状态管理库。它以其极简的 API、完美的 TypeScript 支持和与 Composition API 的无缝集成,彻底改变了我们管理全局状态的方式。然而,再好的工具如果使用不当,也会带来性能问题和维护噩梦。

本文将深入探讨 Pinia 的核心设计哲学,从基础的类型安全定义到高级性能优化,从常见陷阱到测试策略,帮助你在实际项目中真正驾驭这个强大的工具。

为什么我们需要Pinia?

从一个真实场景开始

想象我们正在开发一个电商网站,有这样一个需求:

<!-- 头部组件:显示用户名和购物车数量 -->
<template>
  <header>
    <div>欢迎您,{{ username }}</div>
    <div>购物车({{ cartCount }})</div>
  </header>
</template>

<!-- 商品列表组件:用户点击加入购物车 -->
<template>
  <div v-for="product in products">
    <h3>{{ product.name }}</h3>
    <button @click="addToCart(product)">加入购物车</button>
  </div>
</template>

<!-- 购物车组件:显示已选商品 -->
<template>
  <div v-for="item in cartItems">
    {{ item.name }} x {{ item.quantity }}
  </div>
</template>

这时候问题来了:当用户在商品列表页点击"加入购物车"时:

  • 头部组件需要更新购物车数量
  • 购物车组件需要显示新加的商品
  • 用户信息可能在多个地方使用

如果没有状态管理,我们可能会使用 事件总线props 层层传递,这样组件之间通信会变得极其复杂。

Pinia是什么?

简单来说,Pinia就是一个 中央数据仓库

┌─────────────────┐
│   Pinia Store   │
│  (数据仓库)      │
├─────────────────┤
│  用户信息        │
│  购物车数据      │
│  主题设置        │
└─────────────────┘
      ▲    ▲    ▲
      │    │    │
┌─────┴────┴────┴─────┐
│    所有组件直接访问  │
└─────────────────────┘

Pinia vs Vuex:为什么选Pinia?

在 Vue2 中,类似的功能我们通常使用 Vuex4 进行管理,为什么不继续使用 Vuex4 ,而要改用 Pinia 呢?让我们做个简单对比:

Vuex4 写法 - 繁琐的模板代码

const store = createStore({
  state: { count: 0 },
  mutations: {          // 为什么要多一层?
    increment(state) {
      state.count++
    }
  },
  actions: {            // 又要一层?
    increment({ commit }) {
      commit('increment')
    }
  }
})

Pinia 写法 - 简单直观

const useStore = defineStore('main', {
  state: () => ({ count: 0 }),
  actions: {
    increment() {
      this.count++      // 直接修改state,不需要mutations
    }
  }
})

Pinia的核心优势:

  • 更少的代码:比 Vuex4 少了 30% - 40% 的模板代码
  • 更好的 TypeScrip t支持:不用额外写类型定义
  • 更简单的API:只有stategettersactions
  • 模块化:每个 store 都是独立的,不需要额外的 module

快速上手 - 第一个Pinia Store

安装和配置

首先,我们需要在 Vue3 项目中安装 Pinia

npm install pinia
# 或者
yarn add pinia

然后在 main.js 中注册:

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

const app = createApp(App)
const pinia = createPinia()  // 创建Pinia实例

app.use(pinia)  // 使用Pinia
app.mount('#app')

创建第一个 Store

src/stores 目录下创建一个 counter.js 文件:

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

// 定义并使用一个store
export const useCounterStore = defineStore('counter', {
  // state:存储数据的地方
  state: () => ({
    count: 0,
    name: '计数器'
  }),
  
  // getters:计算属性,相当于computed
  getters: {
    // 自动推导返回类型
    doubleCount: (state) => state.count * 2,
    
    // 带参数的getter(返回一个函数)
    multiply: (state) => (times) => state.count * times
  },
  
  // actions:修改state的方法
  actions: {
    // 普通修改
    increment() {
      this.count++
    },
    
    // 带参数修改
    add(amount) {
      this.count += amount
    },
    
    // 异步操作
    async fetchAndSet() {
      // 模拟API调用
      const res = await fetch('/api/count')
      const data = await res.json()
      this.count = data.count
    }
  }
})

在组件中使用Store

现在,在任何组件中都可以使用这个计数器了:

<!-- Counter.vue -->
<template>
  <div class="counter">
    <h2>{{ store.name }}</h2>
    <p>当前值: {{ store.count }}</p>
    <p>双倍值: {{ store.doubleCount }}</p>
    <p>乘以3: {{ store.multiply(3) }}</p>
    
    <button @click="store.increment()">+1</button>
    <button @click="store.add(5)">+5</button>
    <button @click="handleAsync">异步获取</button>
  </div>
</template>

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

// 获取store实例
const store = useCounterStore()

// 异步操作
async function handleAsync() {
  await store.fetchAndSet()
}
</script>

深入理解 - Store的三个核心部分

State:数据存储

创建 state

State 就是存储数据的地方,类似于组件的 data 选项:

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    // 基础信息
    id: null,
    name: '',
    email: '',
    
    // 复杂数据
    preferences: {
      theme: 'light',
      language: 'zh-CN',
      notifications: true
    },
    
    // 集合类型
    permissions: [],
    
    // 状态标志
    isLoading: false,
    lastLogin: null
  })
})

访问和修改 state

// 获取store
const userStore = useUserStore()

// ✅ 读取state
console.log(userStore.name)
console.log(userStore.preferences.theme)

// ✅ 直接修改state(最简单的方式)
userStore.name = '张三'
userStore.preferences.theme = 'dark'

// ✅ 批量修改(推荐,只触发一次更新)
userStore.$patch({
  name: '李四',
  email: 'lisi@example.com'
})

// ✅ 更灵活的批量修改
userStore.$patch((state) => {
  state.name = '王五'
  state.preferences.theme = 'dark'
  state.permissions.push('admin')
})

// ✅ 重置state到初始值
userStore.$reset()

Getter:计算属性

创建 Getter

Getter 类似于组件的 computed 属性,用于派生出新的数据:

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    firstName: '张',
    lastName: '三',
    todos: [
      { text: '学习Pinia', done: true },
      { text: '写代码', done: false }
    ]
  }),
  
  getters: {
    // 基础getter
    fullName: (state) => `${state.firstName}${state.lastName}`,
    
    // 使用其他getter
    introduction: (state) => {
      return `我是${state.firstName}${state.lastName}`
    },
    
    // 带参数的getter(返回函数)
    getTodoByStatus: (state) => (done) => {
      return state.todos.filter(todo => todo.done === done)
    },
    
    // 统计完成数量
    completedCount: (state) => {
      return state.todos.filter(todo => todo.done).length
    },
    
    // 进度百分比
    progress: (state) => {
      const completed = state.todos.filter(todo => todo.done).length
      const total = state.todos.length
      return total === 0 ? 0 : Math.round((completed / total) * 100)
    }
  }
})

在组件中使用 getters

<template>
  <div>
    <h3>{{ userStore.fullName }}</h3>
    <p>进度: {{ userStore.progress }}%</p>
    
    <!-- 使用带参数的getter -->
    <div v-for="todo in userStore.getTodoByStatus(false)">
      {{ todo.text }} (未完成)
    </div>
  </div>
</template>

Action:业务逻辑

创建 action

Action 是修改 state 的地方,可以包含异步操作:

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    loading: false,
    error: null
  }),
  
  actions: {
    // 同步action
    setUser(user) {
      this.user = user
    },
    
    // 带参数的同步action
    updateUserInfo({ name, email }) {
      if (this.user) {
        this.user.name = name
        this.user.email = email
      }
    },
    
    // 异步action
    async login(credentials) {
      this.loading = true
      this.error = null
      
      try {
        // 调用登录API
        const response = await fetch('/api/login', {
          method: 'POST',
          body: JSON.stringify(credentials)
        })
        
        if (!response.ok) {
          throw new Error('登录失败')
        }
        
        const data = await response.json()
        this.user = data.user
        
        // 可以返回数据给组件
        return data.user
      } catch (error) {
        this.error = error.message
        throw error // 抛出错误,让组件处理
      } finally {
        this.loading = false
      }
    },
    
    // 组合多个action
    async logout() {
      try {
        await fetch('/api/logout')
      } finally {
        // 重置所有状态
        this.$reset()
      }
    }
  }
})

在组件中使用 action

import { useUserStore } from '@/stores/user'
import { ref } from 'vue'

const userStore = useUserStore()
const email = ref('')
const password = ref('')
const errorMsg = ref('')

async function handleLogin() {
  try {
    await userStore.login({
      email: email.value,
      password: password.value
    })
    // 登录成功,跳转到首页
    router.push('/dashboard')
  } catch (error) {
    errorMsg.value = error.message
  }
}

组合式风格 - 更现代的写法

从 Vue3 开始,组合式 API 成为主流。Pinia 也支持用组合式风格定义 store

基础组合式 Store

// stores/user.js (组合式风格)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  // ========== State:用ref定义 ==========
  const user = ref(null)
  const token = ref(localStorage.getItem('token'))
  const loading = ref(false)
  const error = ref(null)
  
  // ========== Getters:用computed定义 ==========
  const isLoggedIn = computed(() => !!token.value && !!user.value)
  
  const fullName = computed(() => {
    if (!user.value) return ''
    return `${user.value.lastName}${user.value.firstName}`
  })
  
  const isAdmin = computed(() => user.value?.role === 'admin')
  
  // 返回函数的getter
  const hasPermission = (permission) => {
    return computed(() => user.value?.permissions?.includes(permission))
  }
  
  // ========== Actions:普通函数 ==========
  function setUser(userData) {
    user.value = userData
  }
  
  async function login(credentials) {
    loading.value = true
    error.value = null
    
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(credentials)
      })
      
      const data = await response.json()
      user.value = data.user
      token.value = data.token
      
      // 保存到localStorage
      localStorage.setItem('token', data.token)
      
      return data.user
    } catch (err) {
      error.value = err.message
      throw err
    } finally {
      loading.value = false
    }
  }
  
  function logout() {
    user.value = null
    token.value = null
    localStorage.removeItem('token')
  }
  
  // 返回所有内容
  return {
    // state
    user,
    token,
    loading,
    error,
    
    // getters
    isLoggedIn,
    fullName,
    isAdmin,
    hasPermission,
    
    // actions
    setUser,
    login,
    logout
  }
})

为什么推荐组合式风格?

选项式风格:数据和逻辑分离

defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: { double: (state) => state.count * 2 },
  actions: { increment() { this.count++ } }
})

组合式风格:相关代码在一起,更易维护

defineStore('counter', () => {
  // 所有的相关代码都在这里
  const count = ref(0)
  const double = computed(() => count.value * 2)
  
  function increment() {
    count.value++
  }
  
  return { count, double, increment }
})

组合式风格的优势:

  • 更好的代码组织:相关的逻辑放在一起
  • 更容易复用:可以提取公共逻辑到组合式函数
  • 更灵活的TypeScript支持

实用技巧 - 解决常见问题

解构陷阱:为什么不能用解构?

这是新手很容易犯的错误:

import { useUserStore } from '@/stores/user'

// ❌ 错误:解构会失去响应式
const { name, email } = useUserStore()

// 当store中的name变化时,这里的name不会更新!

原理示意图

Store (响应式对象)
  ├── name (响应式属性)
  ├── email (响应式属性)
  └── login (普通函数)

直接解构:
const { name } = store
name --> 变成了普通变量,失去响应式

正确解构:storeToRefs

import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()

// ✅ 正确:使用storeToRefs保持响应式
const { name, email, isAdmin } = storeToRefs(userStore)

// ✅ actions可以直接解构(它们不是响应式的)
const { login, logout } = userStore

// 现在name是ref,修改会自动更新
console.log(name.value)  // 注意要加.value

storeToRefs 做了什么

// 简单理解它的原理
function storeToRefs(store) {
  const refs = {}
  
  for (const key in store) {
    const value = store[key]
    
    // 如果是响应式数据,转换为ref
    if (isRef(value) || isReactive(value)) {
      refs[key] = toRef(store, key)
    }
    // actions被忽略,保持原样
  }
  
  return refs
}

批量更新:避免多次渲染

// ❌ 错误:多次修改导致多次渲染
function addItems(items) {
  for (const item of items) {
    this.items.push(item)  // 触发一次渲染
    this.total += item.price  // 又触发一次
    this.count++  // 又一次触发
  }
}

// ✅ 正确:使用$patch批量更新
function addItems(items) {
  this.$patch((state) => {
    // 在$patch内部的所有修改只触发一次更新
    for (const item of items) {
      state.items.push(item)
      state.total += item.price
      state.count++
    }
  })
}

// ✅ 或者:先计算再赋值
function addItems(items) {
  const newItems = [...this.items, ...items]
  const total = newItems.reduce((sum, i) => sum + i.price, 0)
  
  // 一次性更新
  this.items = newItems
  this.total = total
  this.count = newItems.length
}

大型数据性能优化

当需要存储大量数据时:

// stores/data.js
import { defineStore } from 'pinia'
import { shallowRef } from 'vue'

export const useDataStore = defineStore('data', () => {
  // ❌ 如果数据很大,ref会让所有属性都变成响应式
  const bigData = ref(fetchHugeDataset())
  
  // ✅ 使用shallowRef,只跟踪引用变化,内部属性不跟踪
  const bigDataOptimized = shallowRef(fetchHugeDataset())
  
  // 更新时整体替换
  function updateData(newData) {
    bigDataOptimized.value = newData  // 触发更新
    // 修改内部属性不会触发更新
    // bigDataOptimized.value[0].name = 'test' ❌ 不会触发渲染
  }
  
  return { bigDataOptimized, updateData }
})

避免在循环中使用store

<!-- ❌ 错误:每次循环都创建一个store实例 -->
<template>
  <div v-for="user in users" :key="user.id">
    <UserCard :store="useUserStore(user.id)" />
  </div>
</template>

解决方案:使用store工厂或传递ID

// stores/user.js
export const useUserStore = defineStore('user', () => {
  const users = ref(new Map()) // 用Map存储多个用户
  
  function getUser(id) {
    if (!users.value.has(id)) {
      users.value.set(id, null)
    }
    return computed({
      get: () => users.value.get(id),
      set: (value) => users.value.set(id, value)
    })
  }
  
  async function fetchUser(id) {
    const user = await api.getUser(id)
    users.value.set(id, user)
  }
  
  return { getUser, fetchUser }
})

// 在组件中使用
const userStore = useUserStore()
const user = userStore.getUser(props.userId)

watchEffect(() => {
  if (!user.value) {
    userStore.fetchUser(props.userId)
  }
})

循环依赖

// ❌ 错误:两个store相互引用
// storeA.js
export const useAStore = defineStore('a', () => {
  const bStore = useBStore()  // 依赖B
  const data = ref(bStore.someData)
  return { data }
})

// storeB.js
export const useBStore = defineStore('b', () => {
  const aStore = useAStore()  // 依赖A
  const data = ref(aStore.someData)
  return { data }
})

解决方案:提取共享逻辑

// 创建共享store:storeShared.js
export const useSharedStore = defineStore('shared', () => {
  const sharedData = ref({})
  return { sharedData }
})

// storeA.js
export const useAStore = defineStore('a', () => {
  const shared = useSharedStore()
  const data = computed(() => shared.sharedData.a)
  return { data }
})

// storeB.js
export const useBStore = defineStore('b', () => {
  const shared = useSharedStore()
  const data = computed(() => shared.sharedData.b)
  return { data }
})

Store 组合:1+1 > 2

一个 Store 中使用另一个 Store

// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useProductStore } from './product'

export const useCartStore = defineStore('cart', () => {
  // 引入其他store
  const userStore = useUserStore()
  const productStore = useProductStore()
  
  // state
  const items = ref([])
  const coupon = ref(null)
  
  // getters - 组合多个store的数据
  const cartItems = computed(() => {
    return items.value.map(item => {
      // 从商品store获取详细信息
      const product = productStore.getProductById(item.productId)
      return {
        ...item,
        product,
        subtotal: product.price * item.quantity
      }
    })
  })
  
  const total = computed(() => {
    return cartItems.value.reduce((sum, item) => sum + item.subtotal, 0)
  })
  
  const canCheckout = computed(() => {
    // 同时依赖多个store
    return userStore.isLoggedIn && items.value.length > 0
  })
  
  // actions
  function addItem(productId, quantity = 1) {
    const existing = items.value.find(i => i.productId === productId)
    
    if (existing) {
      existing.quantity += quantity
    } else {
      items.value.push({ productId, quantity })
    }
    
    // 调用其他store的action
    productStore.reduceStock(productId, quantity)
  }
  
  async function checkout() {
    if (!canCheckout.value) {
      throw new Error('不能结算')
    }
    
    // 使用用户信息和购物车数据创建订单
    const order = {
      userId: userStore.user.id,
      items: items.value,
      total: total.value,
      coupon: coupon.value
    }
    
    // 调用订单API
    const result = await api.createOrder(order)
    
    // 清空购物车
    items.value = []
    
    return result
  }
  
  return {
    items,
    coupon,
    cartItems,
    total,
    canCheckout,
    addItem,
    checkout
  }
})

共享逻辑复用:工厂模式

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

/**
 * 创建一个通用的列表管理store
 * @param {string} id store的唯一标识
 * @param {Object} options 配置选项
 */
export function createListStore(id, options) {
  return defineStore(id, () => {
    // state
    const items = ref([])
    const loading = ref(false)
    const error = ref(null)
    const filters = ref({})
    
    // getters
    const total = computed(() => items.value.length)
    
    const filteredItems = computed(() => {
      let result = items.value
      
      // 应用自定义过滤逻辑
      if (options.filter) {
        result = result.filter(item => options.filter(item, filters.value))
      }
      
      return result
    })
    
    // actions
    async function fetchItems(params) {
      loading.value = true
      error.value = null
      filters.value = params || {}
      
      try {
        const data = await options.fetch(params)
        items.value = data
      } catch (err) {
        error.value = err.message
        throw err
      } finally {
        loading.value = false
      }
    }
    
    async function addItem(data) {
      if (!options.create) {
        throw new Error('create method not implemented')
      }
      
      const newItem = await options.create(data)
      items.value.push(newItem)
      return newItem
    }
    
    async function updateItem(id, data) {
      if (!options.update) {
        throw new Error('update method not implemented')
      }
      
      const updated = await options.update(id, data)
      const index = items.value.findIndex(i => i.id === id)
      if (index !== -1) {
        items.value[index] = updated
      }
      return updated
    }
    
    async function deleteItem(id) {
      if (!options.delete) {
        throw new Error('delete method not implemented')
      }
      
      await options.delete(id)
      items.value = items.value.filter(i => i.id !== id)
    }
    
    return {
      items,
      loading,
      error,
      filters,
      total,
      filteredItems,
      fetchItems,
      addItem,
      updateItem,
      deleteItem
    }
  })
}

// 使用工厂创建具体的store
// stores/users.js
import { createListStore } from './factories/createListStore'
import { userApi } from '@/api/user'

export const useUserStore = createListStore('users', {
  fetch: userApi.getUsers,
  create: userApi.createUser,
  update: userApi.updateUser,
  delete: userApi.deleteUser,
  filter: (user, filters) => {
    if (filters.keyword && !user.name.includes(filters.keyword)) {
      return false
    }
    if (filters.role && user.role !== filters.role) {
      return false
    }
    return true
  }
})

// 在组件中使用
const userStore = useUserStore()
await userStore.fetchItems({ keyword: '张' })

黄金法则与最佳实践

Store设计原则

原则 说明 示例
按业务划分 每个store管理一个业务领域 user、product、cart
扁平化 避免嵌套,保持简单 不要用modules
单一职责 一个store只做一件事 购物车不处理订单
可组合 store之间可以互相使用 购物车使用商品和用户

性能优化原则

原则 说明 示例
使用 storeToRefs 只解构需要的响应式数据 const { name } = storeToRefs(store)
actions 直接解构 actions 不是响应式的 const { login } = store
批量更新 $patch 批量更新,减少触发更新次数 store.$patch({ ... })
大型数据用 shallowRef 避免深度响应式 const data = shallowRef([])
避免循环依赖 store 之间不要相互引用 使用共享 store 解耦
按需加载 路由级别拆分 store 只在需要时 import

代码组织原则

推荐的 store 文件结构

stores/
├── index.js              # 统一导出
├── user.js               # 用户相关
├── product.js            # 商品相关
├── cart.js               # 购物车相关
└── factories/            # 工厂函数
    └── createListStore.js

推荐的 store 内部结构

export const useStore = defineStore('id', () => {
  // 1. state (ref)
  const data = ref(null)
  
  // 2. getters (computed)
  const computedData = computed(() => data.value)
  
  // 3. actions (functions)
  function action() {}
  
  // 4. return
  return { data, computedData, action }
})

常见错误检查清单

  • 是不是直接解构了 store
  • 是不是忘了用 storeToRefs
  • 是不是在循环中创建 store 实例?
  • 是不是有循环依赖?
  • 是不是用了太多响应式?
  • 是不是在 getter 中做了异步操作?

最终建议

Pinia 的成功在于它的简单类型安全。但简单不等于随意,类型安全不等于复杂。在实际项目中:

  1. 从简单的 store 开始,不要一开始就追求完美设计
  2. 遵循组合式风格,它更适合 Vue 3 的生态
  3. 注意性能陷阱,特别是 storeToRefs 的使用
  4. 充分利用 TypeScript,让类型系统帮你发现错误
  5. 测试核心逻辑,特别是涉及异步操作的 actions

结语

Pinia 只是工具,不是目标,不要为了用而用,而是要在真正需要共享状态的地方使用它。好的状态管理应该让业务代码更清晰,而不是增加复杂度。

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

VUE3 中的 Axios 二次封装与请求策略

作者 wuhen_n
2026年3月13日 08:59

前言

在现代前端应用中,网络请求是不可或缺的一部分。Axios 作为最流行的 HTTP 客户端,以其简洁的 API 和强大的功能赢得了开发者的青睐。然而,直接在每个组件中使用 Axios 会导致大量的代码冗余、错误处理混乱、难以维护等问题。

因此,我们需要对 Axios 进行二次封装,其核心价值在于:统一处理、集中配置、复用逻辑,把复杂的事情变得简单,把重复的事情变得自动化。

本文将从零开始,深入探讨如何构建一个健壮、易用、类型安全的请求层,涵盖拦截器、请求取消、重试机制、缓存策略等高级特性。

为什么要封装 Axios?

没有封装的代码长什么样?

在本文开篇之前,我们先来看一个没有封装的 Axios 请求是什么样的:

// 用户页面
async function getUser() {
  try {
    const res = await axios.get('http://localhost:3000/api/users', {
      headers: { token: localStorage.getItem('token') },
      timeout: 10000
    })
    user.value = res.data
  } catch (err) {
    if (err.response?.status === 401) {
      router.push('/login')
    }
    console.error('获取用户失败', err)
  }
}

// 商品页面
async function getProduct() {
  try {
    const res = await axios.get('http://localhost:3000/api/products', {
      headers: { token: localStorage.getItem('token') },
      timeout: 10000
    })
    product.value = res.data
  } catch (err) {
    if (err.response?.status === 401) {
      router.push('/login')
    }
    console.error('获取商品失败', err)
  }
}

这段代码有哪些问题呢?

  • 每个请求都需要重复配置 headerstimeout 等重复配置项
  • 每个请求都要重复获取和处理 tokenlocalStorage.getItem('token')
  • 每个请求都要写 try/catch 等错误处理
  • 当需要修改请求配置时,与之相关的所有文件都要修改

封装之后的代码长什么样?

二次封装后,所有的重复配置都只需要写一次:

// request.js
const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000
})

// 使用
import request from './request'
request.get('/users')
request.get('/products')

封装的核心价值

  • 统一配置:一次配置,到处使用
  • 统一处理:token 自动添加、错误统一处理
  • 复用逻辑:loading状态、重试机制等都可复用
  • 易于维护 :修改一处,生效全局

从零开始构建我们的请求层

第一层:基础配置

创建一个 request.js 文件,这是所有请求的基础:

// request.js
import axios from 'axios'

// 1. 创建axios实例
const request = axios.create({
  // 基础URL - 通过环境变量区分开发/生产
  baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api',
  
  // 超时时间 - 10秒后自动断开
  timeout: 10000,
  
  // 请求头 - 默认配置
  headers: {
    'Content-Type': 'application/json'
  }
})

export default request

第二层:拦截器

拦截器就像机场的安检通道,每个请求和其响应都要经过检查:请求拦截器/响应拦截器:

// request.js
import { useUserStore } from '@/stores/user'

// 请求拦截器 - 请求发出前的处理
request.interceptors.request.use(
  (config) => {
    // 1. 获取用户token
    const userStore = useUserStore()
    
    // 2. 如果用户已登录,自动添加token
    if (userStore.token) {
      config.headers.Authorization = `Bearer ${userStore.token}`
    }
    
    // 3. GET请求添加时间戳,防止浏览器缓存
    if (config.method === 'get') {
      config.params = {
        ...config.params,
        _t: Date.now()
      }
    }
    
    return config
  },
  (error) => {
    // 请求配置出错时的处理
    return Promise.reject(error)
  }
)

// 响应拦截器 - 收到响应后的处理
request.interceptors.response.use(
  (response) => {
    // 直接返回数据部分,简化使用
    return response.data
  },
  (error) => {
    // 统一的错误处理
    if (error.response) {
      // 服务器返回了错误状态码
      switch (error.response.status) {
        case 401: // 未授权
          const userStore = useUserStore()
          userStore.logout() // 清除用户信息
          router.push('/login') // 跳转到登录页
          break
        case 403: // 禁止访问
          ElMessage.error('没有权限执行此操作')
          break
        case 404: // 资源不存在
          ElMessage.error('请求的资源不存在')
          break
        case 500: // 服务器错误
          ElMessage.error('服务器开小差了,请稍后再试')
          break
        default:
          ElMessage.error(error.response.data?.message || '请求失败')
      }
    } else if (error.request) {
      // 请求发出去了,但没有收到响应
      ElMessage.error('网络连接失败,请检查网络设置')
    } else {
      // 请求配置出错
      ElMessage.error('请求配置错误')
    }
    
    return Promise.reject(error)
  }
)

第三层:Loading 状态自动化

当我们在发送请求时,手动控制 loading 状态会很麻烦,可以让拦截器帮我们自动处理:

// stores/loading.js
import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useLoadingStore = defineStore('loading', () => {
  const count = ref(0) // 当前正在进行的请求数
  const isLoading = computed(() => count.value > 0) // 是否显示loading
  
  function add() {
    count.value++
  }
  
  function remove() {
    if (count.value > 0) {
      count.value--
    }
  }
  
  return { isLoading, add, remove }
})

在 request.js中,我们就可以使用上述 lodaing :

// request.js - 修改拦截器
import { useLoadingStore } from '@/stores/loading'

request.interceptors.request.use((config) => {
  // 如果不是手动禁用了loading
  if (!config.headers?.disableLoading) {
    const loadingStore = useLoadingStore()
    loadingStore.add()
  }
  return config
})

request.interceptors.response.use(
  (response) => {
    if (!response.config.headers?.disableLoading) {
      const loadingStore = useLoadingStore()
      loadingStore.remove()
    }
    return response
  },
  (error) => {
    if (!error.config?.headers?.disableLoading) {
      const loadingStore = useLoadingStore()
      loadingStore.remove()
    }
    return Promise.reject(error)
  }
)

在组件中使用:

<template>
  <div>
    <!-- 自动显示/隐藏loading -->
    <div v-if="loadingStore.isLoading" class="loading">加载中...</div>
    <div v-else>
      <!-- 页面内容 -->
    </div>
  </div>
</template>

<script setup>
import { useLoadingStore } from '@/stores/loading'

const loadingStore = useLoadingStore()

// 发起请求会自动显示loading
async function fetchData() {
  await request.get('/users')
}
</script>

实战技巧 - 解决常见痛点

场景1:请求取消,告别重复请求

当用户在使用搜索功能时,首先在搜索框输入"手机"发送搜索请求,此时请求还没返回;又将输入变成了"手机号",重新发送一次请求。此时应该取消第一个请求,只保留最新的一次请求:

// utils/CancelRequest.js
class CancelRequest {
  constructor() {
    // 存储所有pending状态的请求
    this.pendingMap = new Map()
  }
  
  // 生成请求的唯一标识
  getRequestKey(config) {
    const { method, url, params, data } = config
    return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&')
  }
  
  // 添加请求到pending列表
  addPending(config) {
    const key = this.getRequestKey(config)
    
    // 如果已有相同的请求,取消它
    if (this.pendingMap.has(key)) {
      const abort = this.pendingMap.get(key)
      abort() // 取消请求
      this.pendingMap.delete(key)
    }
    
    // 创建新的AbortController
    const controller = new AbortController()
    config.signal = controller.signal
    
    // 保存取消函数
    this.pendingMap.set(key, () => controller.abort())
  }
  
  // 请求完成后,从pending列表移除
  removePending(config) {
    const key = this.getRequestKey(config)
    if (this.pendingMap.has(key)) {
      this.pendingMap.delete(key)
    }
  }
  
  // 取消所有pending请求(这在页面切换时很有用)
  cancelAll() {
    this.pendingMap.forEach(cancel => cancel())
    this.pendingMap.clear()
  }
}

export const cancelRequest = new CancelRequest()

在拦截器中使用:

// request.js
import { cancelRequest } from './utils/CancelRequest'

request.interceptors.request.use((config) => {
  // 如果没有禁用取消功能
  if (!config.headers?.disableCancel) {
    cancelRequest.addPending(config)
  }
  return config
})

request.interceptors.response.use(
  (response) => {
    cancelRequest.removePending(response.config)
    return response
  },
  (error) => {
    // 如果是手动取消的请求,不抛出错误
    if (axios.isCancel(error)) {
      console.log('请求已取消')
      return new Promise(() => {}) // 返回pending的Promise
    }
    
    if (error.config) {
      cancelRequest.removePending(error.config)
    }
    return Promise.reject(error)
  }
)

// 路由切换时,取消所有请求
router.beforeEach((to, from, next) => {
  cancelRequest.cancelAll()
  next()
})

场景2:自动重试,提升用户体验

当网络不稳定时,我们需要自动重试功能,让用户无感知地完成操作,而不是简单地返回一句“网络异常,请稍后重试”:

// utils/retry.js
/**
 * 带重试功能的请求
 * @param {Function} requestFn 请求函数
 * @param {Object} options 配置选项
 */
export async function retryRequest(requestFn, options = {}) {
  const {
    retries = 3,           // 最大重试次数
    delay = 1000,          // 初始延迟(毫秒)
    factor = 2,            // 延迟增长倍数
    maxDelay = 30000,      // 最大延迟
    retryCondition = (error) => {
      // 默认重试条件:网络错误 或 5xx服务器错误
      return !error.response || error.response.status >= 500
    }
  } = options
  
  let attempt = 0
  
  while (attempt <= retries) {
    try {
      return await requestFn()
    } catch (error) {
      attempt++
      
      // 最后一次尝试失败,抛出错误
      if (attempt > retries) {
        throw error
      }
      
      // 检查是否应该重试
      if (!retryCondition(error)) {
        throw error
      }
      
      // 计算等待时间(指数退避)
      const waitTime = Math.min(delay * Math.pow(factor, attempt - 1), maxDelay)
      
      console.log(`请求失败,${waitTime}ms后第${attempt}次重试...`)
      
      // 等待后继续循环
      await new Promise(resolve => setTimeout(resolve, waitTime))
    }
  }
}

// 使用示例
async function fetchImportantData() {
  return retryRequest(
    () => request.get('/important-data'),
    {
      retries: 5,
      delay: 2000,
      onRetry: (attempt, error) => {
        // 可以在这里记录日志或通知用户
        console.log(`第${attempt}次重试`, error)
      }
    }
  )
}

场景3:数据缓存,减少不必要的请求

当用户频繁查看某个商品详情时,每次都要发送一次请求,这样既浪费资源,又慢,因此我们可以将数据缓存起来:

// utils/cache.js
class RequestCache {
  constructor() {
    this.cache = new Map()
  }
  
  /**
   * 设置缓存
   * @param {string} key 缓存键
   * @param {any} data 缓存数据
   * @param {number} ttl 过期时间(毫秒)
   */
  set(key, data, ttl = 60000) {
    this.cache.set(key, {
      data,
      expire: Date.now() + ttl
    })
  }
  
  /**
   * 获取缓存
   * @param {string} key 缓存键
   */
  get(key) {
    const item = this.cache.get(key)
    
    // 没有缓存
    if (!item) return null
    
    // 检查是否过期
    if (Date.now() > item.expire) {
      this.cache.delete(key)
      return null
    }
    
    return item.data
  }
  
  // 清除特定缓存
  delete(key) {
    this.cache.delete(key)
  }
  
  // 清除所有缓存
  clear() {
    this.cache.clear()
  }
}

export const requestCache = new RequestCache()

// 封装带缓存的请求
async function requestWithCache(url, options = {}) {
  const { cacheTTL = 60000, ...restOptions } = options
  
  // 只有GET请求才使用缓存
  if (restOptions.method && restOptions.method !== 'GET') {
    return request(url, restOptions)
  }
  
  // 生成缓存键
  const cacheKey = `${url}:${JSON.stringify(restOptions.params)}`
  
  // 检查缓存
  const cached = requestCache.get(cacheKey)
  if (cached) {
    console.log('使用缓存数据:', cacheKey)
    return cached
  }
  
  // 发起真实请求
  const data = await request(url, restOptions)
  
  // 存入缓存
  requestCache.set(cacheKey, data, cacheTTL)
  
  return data
}

TypeScript 加持 - 让代码更可靠

自定义类型系统

// types/api.d.ts
// 通用响应格式
export interface ApiResponse<T = any> {
  code: number        // 业务状态码
  message: string     // 提示信息
  data: T            // 实际数据
  timestamp?: number  // 时间戳
}

// 分页参数
export interface PaginationParams {
  page: number        // 当前页码
  pageSize: number    // 每页条数
  sort?: string       // 排序字段
  order?: 'asc' | 'desc' // 排序方式
}

// 分页结果
export interface PaginatedResult<T> {
  list: T[]           // 数据列表
  total: number       // 总条数
  page: number        // 当前页码
  pageSize: number    // 每页条数
  totalPages: number  // 总页数
}

// 扩展的请求配置
export interface RequestConfig {
  url: string
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
  data?: any
  params?: any
  headers?: Record<string, string>
  
  // 自定义选项
  disableLoading?: boolean  // 是否禁用loading
  disableCancel?: boolean   // 是否禁用自动取消
  cacheTTL?: number        // 缓存时间(毫秒)
  retries?: number         // 重试次数
}

创建类型安全的API模块

// api/user.ts
import request from '@/request'
import type { PaginationParams, PaginatedResult } from '@/types/api'

// 用户类型定义
export interface User {
  id: number
  name: string
  email: string
  avatar: string
  role: 'admin' | 'user'
  status: 'active' | 'inactive'
  createdAt: string
  updatedAt: string
}

export interface CreateUserDto {
  name: string
  email: string
  password: string
  role?: 'admin' | 'user'
}

export interface UpdateUserDto extends Partial<CreateUserDto> {
  status?: 'active' | 'inactive'
}

export interface UserListParams extends PaginationParams {
  keyword?: string
  role?: string
  status?: string
}

// 用户API模块
export const userApi = {
  // 获取用户列表
  getList: (params: UserListParams) => 
    request.get<PaginatedResult<User>>('/users', { params }),
  
  // 获取单个用户
  getDetail: (id: number) => 
    request.get<User>(`/users/${id}`),
  
  // 创建用户
  create: (data: CreateUserDto) => 
    request.post<User>('/users', data),
  
  // 更新用户
  update: (id: number, data: UpdateUserDto) => 
    request.put<User>(`/users/${id}`, data),
  
  // 删除用户
  delete: (id: number) => 
    request.delete(`/users/${id}`),
  
  // 修改状态
  updateStatus: (id: number, status: User['status']) => 
    request.patch(`/users/${id}/status`, { status })
}

在组件中使用

<script setup lang="ts">
import { ref } from 'vue'
import { userApi } from '@/api/user'
import type { User, UserListParams } from '@/api/user'

const users = ref<User[]>([])
const loading = ref(false)

const params = ref<UserListParams>({
  page: 1,
  pageSize: 10,
  keyword: ''
})

async function loadUsers() {
  loading.value = true
  try {
    const result = await userApi.getList(params.value)
    users.value = result.list
  } finally {
    loading.value = false
  }
}

// 完全的类型提示和自动补全!
async function handleCreate() {
  const newUser = await userApi.create({
    name: '张三',
    email: 'zhangsan@example.com',
    password: '123456',
    role: 'user'
  })
  users.value.push(newUser)
}
</script>

封装的度 - 如何把握封装分寸?

封装层次图

graph TB
    subgraph "业务层"
        A[业务组件]
    end
    
    subgraph "API层"
        B[API模块]
    end
    
    subgraph "基础层"
        C[请求实例]
        D[拦截器]
        E[工具函数]
    end
    
    A --> B
    B --> C
    C --> D
    D --> E

封装原则

原则一:够用即可

不要过度设计,根据项目规模选择合适的封装程度:

// ✅ 小型项目:简单封装就够了
const request = axios.create({ baseURL: '/api' })

// ✅ 中型项目:添加拦截器、类型定义
request.interceptors.response.use(/* 错误处理 */)

// ✅ 大型项目:完整的缓存、重试、监控机制

原则二:可配置性

提供出口,让特殊场景可以绕过封装:

// 通过配置项控制
await request.get('/important-data', {
  headers: {
    disableLoading: true,  // 不显示loading
    disableCancel: true,   // 不自动取消
    disableRetry: true     // 不重试
  }
})

原则三:渐进增强

从简单开始,逐步完善:

// 第一阶段:基础封装
export const api = {
  getUser: () => request.get('/user')
}

// 第二阶段:添加类型
export const api = {
  getUser: (): Promise<User> => request.get('/user')
}

// 第三阶段:添加高级特性
export const api = {
  getUser: () => retryRequest(
    () => requestWithCache('/user'),
    { retries: 3 }
  )
}

封装的检查清单

检查项 是否必需 说明
基础配置 baseURL、超时、请求头
错误处理 统一错误提示、状态码处理
Token管理 自动附加、过期处理
Loading状态 推荐 提升用户体验
TypeScript 推荐 类型安全、开发体验
请求取消 看场景 搜索、标签切换等
数据缓存 看场景 频繁访问的静态数据
自动重试 看场景 网络不稳定时

完整目录结构

src/
├── api/
│   ├── index.ts           # API统一出口
│   ├── user.ts            # 用户模块
│   ├── product.ts         # 商品模块
│   └── order.ts           # 订单模块
├── utils/
│   ├── request.ts         # 请求核心
│   ├── cache.ts           # 缓存工具
│   ├── retry.ts           # 重试工具
│   └── cancel.ts          # 取消工具
├── types/
│   └── api.d.ts           # 类型定义
└── stores/
    └── loading.ts         # loading状态

最终建议

Axios 封装没有标准答案,关键在于根据项目规模和团队习惯找到平衡点

  • 小型项目:简单的拦截器 + 类型定义就够了
  • 中型项目:需要请求取消、错误统一处理
  • 大型项目:完整的缓存、重试、监控机制

结语

封装不是为了炫技,而是为了让代码更简单,让开发更高效。一个好的封装应该让 90% 的场景变得简单,同时给 10% 的特殊场景留出出口。希望这篇文章能帮助我们构建适合自己的请求层。记住,最好的封装是让使用它的人感受不到封装的存在。

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

Vue3 接入 Google 登录:极简教程

作者 wing98
2026年3月14日 15:10

公司目前做的是一款激光雕刻产品,主要用于出口海外,需要开发一个web社区网站用于桌面端模型生成后发布到社区进行分享和交流。说到出海产品,google第三方登录是必须接入的,这两天和后端一起开发完成了此功能,现将流程大概梳理如下。

首先当然是看Google的OAuth 2.0文档,了解其流程和参数。

文档地址:developers.google.com/identity/pr…

第一步:在Google API Console创建 OAuth 2.0 凭据

第二步:接入方式和流程

我们可以看到文档提供了多种应用类型的接入方式,对于web来说主要是红框里的两种。

1、适用于服务器端 Web 应用

我们目前采用的方式,需要后端存储google用户信息。

接入流程:前端唤起 Google 授权 → 前端获取授权码 → 后端用授权码换 token → 验证用户信息并返回自有 token

2、适用于 JavaScript Web 应用

主要前端完成google登录,后端只需要接收前端返回的token进行验证即可。

接入流程:用户点击登录 → 授权 -> 前端直接获得id_token + 用户信息 -> id_token 发给后端验证 → 完成登录。

另外对于前端交互来说均有两种可供选择:

1、popup模式:在当前页面弹窗授权,体验更友好。

2、redirect模式:跳转新页面授权后重定向回来。

popup模式,采用vue3-google-login第三方依赖。需要注意的点:

1、前端测试通过code换取access_token时,postman需要设置请求头“Content-Type: application/x-www-form-urlencoded”,否则会报错“invalid_grant”。

2、Google凭据那里不需要配置重定向URI,且后端用code换取access_token所传的参数redirect_uri应该为“postmessage”,否则会报错“redirect_uri_mismatch”。

3、code不能重复使用。

以下是直接可用的前端代码(popup模式):

GoogleLoginBtn.vue

<template>  <GoogleLogin    :client-id="googleClientId"    popup-type="CODE"    :callback="handleGoogleSuccess"    :error="handleGoogleError"  >    <img class="google-login-icon" src="@/assets/icons/google.png" alt="google-login">    <button v-if="false" class="google-login-button" type="button">      <span class="google-mark">G</span>      <span class="google-label">{{ buttonLabel }}</span>    </button>  </GoogleLogin></template><script setup lang="ts">import { ElMessage } from 'element-plus'import { GoogleLogin, type CallbackTypes } from 'vue3-google-login'import { useUserStore } from '@/stores/user'interface Props {  buttonLabel?: string}interface Emits {  (e: 'success'): void  (e: 'error'): void}withDefaults(defineProps<Props>(), {  buttonLabel: 'Google 账号快捷登录',})const emit = defineEmits<Emits>()const userStore = useUserStore()const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID as string | undefinedconst handleGoogleSuccess = async (response: CallbackTypes.CodePopupResponse) => {  if (!response?.code) {    ElMessage.error('未获取到 Google 授权码')    emit('error')    return  }  const success = await userStore.userLoginByGoogleCode(response.code)  if (success) {    emit('success')    return  }  emit('error')}const handleGoogleError = (_error: unknown) => {  ElMessage.error('Google 授权失败,请重试')  emit('error')}</script><style scoped lang="scss">.google-login-button {  width: 176px;  height: 44px;  border: 1px solid #d9d9d9;  border-radius: 10px;  background: #fff;  display: flex;  align-items: center;  justify-content: center;  gap: 8px;  cursor: pointer;  transition: all 0.2s ease;}.google-login-button:hover {  border-color: #c7c7c7;  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);  transform: translateY(-1px);}.google-mark {  font-size: 18px;  font-weight: 700;  color: #ea4335;}.google-label {  font-size: 13px;  color: #333;  white-space: nowrap;}.google-login-icon {  width: 48px;  height: 48px;  cursor: pointer;}</style>

redirect模式,期间由于踩了上面提的popup模式的坑,也改过一版redirect模式,没有采用第三方依赖。

以下是直接可用的前端代码(redirect模式):

GoogleLoginBtn.vue

<template>  <button class="google-login-trigger" type="button" @click="handleGoogleLogin">    <img class="google-login-icon" src="@/assets/icons/google.png" alt="google-login" />    <span class="sr-only">{{ buttonLabel }}</span>  </button></template><script setup lang="ts">import { ElMessage } from 'element-plus'interface Props {  buttonLabel?: string}interface Emits {  (e: 'success'): void  (e: 'error'): void}withDefaults(defineProps<Props>(), {  buttonLabel: 'Google 账号快捷登录',})const emit = defineEmits<Emits>()const GOOGLE_STATE_KEY = 'google_oauth_state'const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID as string | undefinedconst googleRedirectUri = (import.meta.env.VITE_GOOGLE_REDIRECT_URI as string | undefined)  || `${window.location.origin}/front/community/home`const createOAuthState = () => {  if (window.crypto?.randomUUID) {    return window.crypto.randomUUID()  }  return `${Date.now()}_${Math.random().toString(36).slice(2)}`}const handleGoogleLogin = () => {  if (!googleClientId) {    ElMessage.error('未配置 Google Client ID,无法登录')    emit('error')    return  }  const state = createOAuthState()  sessionStorage.setItem(GOOGLE_STATE_KEY, state)  const query = new URLSearchParams({    client_id: googleClientId,    redirect_uri: googleRedirectUri,    response_type: 'code',    scope: 'openid profile email',    state,  })  window.location.assign(`https://accounts.google.com/o/oauth2/v2/auth?${query.toString()}`)}</script><style scoped lang="scss">.google-login-trigger {  display: inline-flex;  align-items: center;  justify-content: center;  border: none;  background: transparent;  padding: 0;  cursor: pointer;}.google-login-icon {  width: 48px;  height: 48px;  cursor: pointer;}.sr-only {  position: absolute;  width: 1px;  height: 1px;  padding: 0;  margin: -1px;  overflow: hidden;  clip: rect(0, 0, 0, 0);  white-space: nowrap;  border: 0;}</style>

App.vue

<template>  <Layout :show-top-bar="showTopBar">    <router-view />  </Layout></template><script setup lang="ts">import Layout from './components/Layout.vue'import { useRoute, useRouter } from 'vue-router'import { computed, onMounted, nextTick, ref, watch } from 'vue'import { useI18n } from 'vue-i18n'import { ElMessage } from 'element-plus'import { useUserStore } from '@/stores/user'const route = useRoute()const router = useRouter()const userStore = useUserStore()const { t } = useI18n()const isProcessingGoogleOAuth = ref(false)const GOOGLE_STATE_KEY = 'google_oauth_state'const showTopBar = computed(() => {  const from = route.query.from as string  localStorage.setItem('from', from || 'community')  return from !== 'pc-home'})const getSingleQueryValue = (value: unknown) => {  if (Array.isArray(value)) {    return value[0] || ''  }  return typeof value === 'string' ? value : ''}const clearGoogleOAuthQuery = async () => {  const nextQuery = { ...route.query }  delete nextQuery.code  delete nextQuery.scope  delete nextQuery.authuser  delete nextQuery.prompt  delete nextQuery.state  delete nextQuery.error  delete nextQuery.error_description  await router.replace({    path: route.path,    query: nextQuery,  })}const processGoogleOAuthCallback = async () => {  const code = getSingleQueryValue(route.query.code)  const oauthError = getSingleQueryValue(route.query.error)  const incomingState = getSingleQueryValue(route.query.state)  if ((!code && !oauthError) || isProcessingGoogleOAuth.value) {    return  }  isProcessingGoogleOAuth.value = true  try {    if (oauthError) {      ElMessage.error(`Google OAuth failed: ${oauthError}`)      return    }    const expectedState = sessionStorage.getItem(GOOGLE_STATE_KEY)    sessionStorage.removeItem(GOOGLE_STATE_KEY)    if (expectedState && expectedState !== incomingState) {      ElMessage.error('Google OAuth state validation failed')      return    }    const success = await userStore.userLoginByGoogleCode(code)    if (success) {      ElMessage.success(t('auth.loginSuccess'))    }  } finally {    await clearGoogleOAuthQuery()    isProcessingGoogleOAuth.value = false  }}watch(  () => route.fullPath,  () => {    void processGoogleOAuthCallback()  },  { immediate: true })onMounted(async () => {  await nextTick()  const from = route.query.from as string  console.log('route.query.from:', from)})</script><style scoped>* {  margin: 0;  padding: 0;  box-sizing: border-box;}body {  font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;  line-height: 1.5;  color: #333;  background-color: #f5f5f5;}.content-placeholder {  text-align: center;  padding: 60px 20px;  color: #666;}.content-placeholder h1 {  font-size: 36px;  margin-bottom: 20px;  color: #333;}</style>

在 Vue 2.6 微前端架构中,我们为什么放弃了 Vuex 管理页面状态?

2026年3月14日 13:01

在 Vue 2.6 微前端架构中,我们为什么放弃了 Vuex 管理页面状态

背景:一个越来越"重"的页面

我们团队用 single-spa 搭了一套微前端架构,主技术栈是 Vue 2.6 + Element UI。系统里有不少复杂页面——转化漏斗分析详情、行为数据分析仪表盘、事件流程分析……这类页面的共同特点是:

  • 组件层级深,一个页面拆成 10+ 子组件很常见
  • 组件间通信频繁,筛选条件变了、Tab 切了、日期选了,好几个组件要同步响应
  • 状态生命周期跟页面走,进来要初始化,离开要清干净

一开始我们用 Vuex 管这些状态,很快就发现不对劲。

Vuex 管页面状态,哪里不对?

第一个问题:状态残留。 用户从漏斗详情页跳到事件分析页,再跳回来,Vuex 里上一次的筛选条件还在。你说用 beforeDestroy 里手动 reset?可以,但每个页面都要写一遍,写漏了就是 bug。

第二个问题:命名空间膨胀。 每个复杂页面一个 Vuex module,funnelDetail/setFilterseventAnalysis/setFiltersbehaviorDashboard/setFilters……全局 store 越来越臃肿,而这些 module 99% 的时间都不需要存在。

第三个问题:Vuex 的仪式感太重。 改一个状态要经过 commit → mutation → state,对于页面内部的交互状态来说,这个链路完全多余。筛选条件变了就该直接改,不需要走 mutation 审计。

我们试过的其他方案

provide / inject——只能传数据,不能传事件。组件 A 想通知组件 B "筛选变了,你该刷新了",provide/inject 做不到。

全局 EventBus($micRootBus ——我们微前端里有一个全局事件总线。但拿它做页面内通信,三个致命问题:

// 1. 命名冲突:漏斗详情和事件分析都有 filter:change
this.$micRootBus.$emit('filter:change', filters) // 谁的 filter?

// 2. 内存泄漏:每个 $on 都要手动 $off,页面销毁时漏一个就泄漏
beforeDestroy() {
  this.$micRootBus.$off('funnelDetail:filter:change', this.handler1)
  this.$micRootBus.$off('funnelDetail:tab:change', this.handler2)
  this.$micRootBus.$off('funnelDetail:date:change', this.handler3)
  // ... 8 个地方全要清,漏一个就寄
}

// 3. 边界模糊:事件扩散到全局,debug 时不知道谁在监听

组件 data + props 层层传递——5 层组件传一个筛选条件,中间 3 层只是当传话筒。经典的 props drilling 地狱。

每个方案都差点意思。我们需要的是一个页面级别的运行时上下文——状态、通信、副作用,全部限定在当前页面的作用域里,页面销毁时一键回收。

于是我们造了 vue-page-store

核心思路很简单:用一个隐藏的 Vue 实例承载响应式 state + computed getters,再加一个闭包隔离的事件总线,生命周期绑定在一起。

npm install vue-page-store

定义一个页面级 Store

import { definePageStore } from 'vue-page-store'

export const useFunnelStore = definePageStore('funnelDetail', {
  state: () => ({
    filters: { dateRange: [], platform: '' },
    loading: false,
    funnelSteps: [],
  }),

  getters: {
    isReady() {
      return !this.loading && this.funnelSteps.length > 0
    },
  },

  actions: {
    async fetchData() {
      this.loading = true
      try {
        this.funnelSteps = await api.getFunnelSteps(this.filters)
      } finally {
        this.loading = false
      }
    },
  },
})

API 风格完全对齐 Pinia:state / getters / actions,用过 Pinia 的人零学习成本。

组件中使用

const store = useFunnelStore()

// 直接读
store.filters
store.isReady

// 直接改
store.filters = newFilters

// 调 action
store.fetchData()

// 批量更新
store.$patch({ loading: true, filters: newFilters })

没有 commit,没有 mutation,没有 mapState。直接属性访问,直接赋值。

页面内通信:作用域隔离的事件

这是 vue-page-store 和 Pinia 最大的区别。我们内置了一个页面作用域级的事件总线

// 组件 A —— 发射事件
store.$emit('filter:change', newFilters)

// 组件 B —— 监听事件
const off = store.$on('filter:change', (filters) => {
  this.applyFilters(filters)
})

重点来了: _listeners 是闭包内的私有变量,每个 store 实例独立一份。 漏斗详情的 filter:change 和事件分析的 filter:change 完全隔离,互不干扰。

为什么不拆成独立的 EventBus?因为生命周期要跟 store 绑定。$destroy 的时候自动清空所有 listeners:

store.$destroy = () => {
  // 清空事件 —— 不会泄漏
  Object.keys(_listeners).forEach(key => delete _listeners[key])
  // 销毁 Vue 实例 —— 回收 watchers
  vm.$destroy()
  // 移除注册 —— 下次进来是全新的
  storeRegistry.delete(id)
}

调用方(子组件)只需要注入 store 就能通信,不需要感知全局 Bus,不需要手动 $off,不需要加命名前缀。

页面销毁:一行代码全部回收

// 页面根组件
beforeDestroy() {
  useFunnelStore().$destroy()
}

state、getters、watchers、事件监听——全部清干净。下次进这个页面,又是一个全新的 store。

它不是 Pinia 的替代品

这一点必须说清楚。vue-page-store 解决的是 Vuex / Pinia 覆盖不到的那个中间地带:

Vuex Pinia vue-page-store
作用域 全局 全局 页面级
生命周期 应用级 应用级 页面级($destroy 回收)
事件通信 内置 emit/emit/on(作用域隔离)
Vue 2.6 支持 ⚠️ 需 @vue/composition-api ✅ 原生支持
适合管什么 用户信息、权限、全局配置 同左 复杂页面内部状态

推荐组合:Vuex 管全局,vue-page-store 管页面。 各管各的,互不干扰。

声明式 watch:页面级副作用的自动管理

除了状态和事件,页面里还有一类东西需要管理——副作用。比如"查询时间范围变了,自动判断是否按小时查询":

export const useFunnelStore = definePageStore('funnelDetail', {
  state: () => ({ /* ... */ }),

  getters: {
    isQueryByHour() {
      const range = this.filters?.dateRange
      return (new Date(range[1]) - new Date(range[0])) / 3600000 <= 24
    },
  },

  watch: {
    'isQueryByHour'(val) {
      if (!val) this.tabTime = 'hour'
    },
  },
})

声明式写法,定义的时候绑上去,$destroy 的时候跟着 Vue 实例一起销毁。不需要手动 $watch 再手动 unwatch

实现原理:100 行代码

核心实现非常简单,整个库不到 200 行,核心逻辑 100 行出头:

  1. new Vue({ data: { $$state }, computed }) —— 一个隐藏的 Vue 实例,承载响应式和 computed
  2. Object.defineProperty 代理 —— 把 state 和 getters 暴露到 store 对象上
  3. 闭包内的 _listeners 对象 —— 作用域隔离的事件总线
  4. storeRegistry Map —— 保证同一个 id 只有一个实例

没有黑魔法,没有额外依赖,gzip 后不到 3KB。

最后

如果你也在用 Vue 2.6 + 微前端架构,遇到了页面级状态管理的痛点,可以试试:

npm install vue-page-store

Vue 3 项目推荐用 Pinia,这个库专为 Vue 2.6 场景设计。

如果对你有帮助,欢迎 star ⭐️,有问题直接提 issue。

❌
❌