阅读视图

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

我整理了一份 Vue 性能优化指南(给AI用的)

为什么做这个

说实话,这个项目是我自己用的。

工作这几年,遇到的性能问题基本都是类似的坑:接口瀑布流、bundle 越来越大、响应式乱用。每次踩完坑修好了,过段时间换个项目又踩一遍。

后来想着,干脆整理一份文档,自己查方便,也能给 AI 编码助手看(我现在用 Claude Code),这样审代码的时候能提前发现问题。

整理完发现,好像也可以分享出来,说不定有人也遇到过这些问题。

我踩过的坑

这几年写 Vue 项目(Vue 2/3 + Nuxt 都有),踩过不少坑:

接口请求变成瀑布流 一个 await 接一个 await,明明能并行的请求硬是串行了。用户抱怨页面慢,一查发现 3 个接口排队等了 750ms。

bundle 体积失控 每次加需求就往里塞代码,没人关心打包结果。等到首屏白屏 3 秒了,才发现 JavaScript 已经 300KB+。

响应式系统滥用 大对象直接 ref(),上千条商品数据,每个字段都变成响应式。渲染一卡一卡的,还以为是组件写得不好。

这些问题不是什么高深的优化,就是基本功。但忙起来就容易忽略,等出问题再改成本就高了。

怎么说呢,优化要分轻重

我发现很多人(包括以前的我)做性能优化会搞错重点。

举个例子:页面有 600ms 的请求等待时间,结果花一周优化 computed 缓存。首屏加载了 300KB 的 JavaScript,结果去优化循环少跑几次。

其实应该先解决大问题:

  1. 先干掉请求瀑布流 - 能并行就并行,该预加载就预加载
  2. 再砍 bundle 体积 - 代码分割、动态导入、tree-shaking
  3. 然后才是组件和响应式优化 - 减少不必要的渲染

我按这个思路把规则分成了 10 个类别,从 CRITICALLOW,总共 46 条。先把影响大的问题解决了,那些微优化可以慢慢来。

里面有什么

10 个类别,46 条规则:

  • 消除异步瀑布流(CRITICAL)
  • 包体积优化(CRITICAL)
  • 服务端性能(HIGH)
  • 客户端数据获取(HIGH)
  • 响应式系统优化(MEDIUM-HIGH)
  • 渲染性能(MEDIUM)
  • Vue 2 特定优化(MEDIUM)
  • Vue 3 特定优化(MEDIUM)
  • JavaScript 性能(LOW-MEDIUM)
  • 高级模式(LOW)

每条规则的格式:

  • 影响等级(CRITICAL / HIGH / MEDIUM / LOW)
  • 错误示例(我以前写过的错误代码)
  • 正确示例(后来改成什么样)
  • Vue 2/3 兼容性说明

举几个我踩过的坑

坑 1:不需要的 await 也在阻塞代码

以前写过这样的代码:

async function handleRequest(userId: string, skipProcessing: boolean) {
  // 即使 skipProcessing=true,也会等待 userData
  const userData = await fetchUserData(userId)

  if (skipProcessing) {
    // 立即返回,但前面已经浪费时间等待了
    return { skipped: true }
  }

  // 只有这个分支使用 userData
  return processUserData(userData)
}

问题是,即使 skipProcessing=true,还是会去请求 userData。白白浪费时间。

后来改成这样:

async function handleRequest(userId: string, skipProcessing: boolean) {
  if (skipProcessing) {
    return { skipped: true }
  }

  // 只在需要时才获取数据
  const userData = await fetchUserData(userId)
  return processUserData(userData)
}

其实很简单,但之前就是没注意到。

坑 2:大对象别直接用 ref

1000 条商品数据,每条 10+ 个字段,以前直接 ref()

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

// 1000 个商品,每个商品 10+ 字段,全部变成响应式
const products = ref<Product[]>([])

async function loadProducts() {
  products.value = await fetchProducts()
  // Vue 会递归遍历所有对象,添加响应式代理
}
</script>

渲染的时候卡得要命。后来发现应该用 shallowRef

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

// 只有数组本身是响应式的,内部对象保持普通对象
const products = shallowRef<Product[]>([])

async function loadProducts() {
  // 替换整个数组触发更新,无需深度响应式
  products.value = await fetchProducts()
}
</script>

shallowRef 只让数组本身响应式,内部对象保持普通对象。更新时替换整个数组就能触发响应,省了大量性能开销。

几个真实案例(我遇到过的)

案例 1:别对同一个数组循环多次

之前接手一个项目,发现同一个商品列表循环了 5 次:

// 错误:5 次独立遍历
const discounted = products.filter(p => p.discount > 0)
const inStock = products.filter(p => p.stock > 0)
const featured = products.filter(p => p.featured)
const totalValue = products.reduce((sum, p) => sum + p.price, 0)
const avgPrice = totalValue / products.length

看着就难受。后来改成一次循环:

// 正确:一次遍历
const stats = products.reduce((acc, product) => {
  if (product.discount > 0) acc.discounted.push(product)
  if (product.stock > 0) acc.inStock.push(product)
  if (product.featured) acc.featured.push(product)
  acc.totalValue += product.price
  return acc
}, { discounted: [], inStock: [], featured: [], totalValue: 0 })

const avgPrice = stats.totalValue / products.length

商品少的时候看不出来,数据一多性能差距就很明显了。

案例 2:独立的请求不要排队

用户详情页,三个互不依赖的接口,结果在串行调用:

// 错误:串行:总耗时 = 300ms + 200ms + 250ms = 750ms
const user = await fetchUser(userId)          // 300ms
const posts = await fetchUserPosts(userId)    // 200ms
const comments = await fetchUserComments(userId) // 250ms

改成并行之后:

// 正确:并行:总耗时 = max(300ms, 200ms, 250ms) = 300ms
const [user, posts, comments] = await Promise.all([
  fetchUser(userId),
  fetchUserPosts(userId),
  fetchUserComments(userId)
])

总耗时从 750ms 降到 300ms,页面快了一半多。这种优化投入产出比最高。

案例 3:长列表用 CSS content-visibility

1000+ 条评论的页面,初始渲染很慢:

<!-- 错误:所有评论立即渲染 -->
<div v-for="comment in comments" :key="comment.id">
  <CommentCard :comment="comment" />
</div>

后来加上 content-visibility

<!-- 正确:浏览器跳过屏幕外的渲染 -->
<div
  v-for="comment in comments"
  :key="comment.id"
  class="comment-item"
>
  <CommentCard :comment="comment" />
</div>

<style>
.comment-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 200px;
}
</style>

浏览器会跳过屏幕外的渲染,初始加载快了 5-10 倍,滚动也流畅多了。这个 CSS 属性真的好用。

怎么用

直接看

# 克隆仓库
git clone https://github.com/ursazoo/vue-best-practices.git

# 安装依赖
npm install

# 构建 AGENTS.md
npm run build

克隆下来,直接看 rules/ 目录下的规则文件。每个文件都是独立的,包含问题说明、代码示例和解决方案。

也可以看构建好的 AGENTS.md,把所有规则整合在一起,方便搜索。

集成到 AI 编码助手

如果你也在用 Claude Code、Cursor 这类 AI 工具写代码,可以集成进去:

npx add-skill vue-best-practices

AI 审查代码的时候,如果发现性能问题(比如请求瀑布流、过度响应式),会参考这些规则给出优化建议。我现在就是这么用的,挺方便。

Vue 2 还是 Vue 3

都支持。每条规则都标注了版本兼容性:

  • Vue 2 & 3 通用:基础的性能优化技巧
  • Vue 3 Only:用了 <script setup>shallowRefSuspense 等新特性
  • Vue 2 Only:针对 Vue 2 的特定优化(比如 Object.freeze()

老项目也能用,新项目能用得更充分。

项目地址

GitHub: github.com/ursazoo/vue…

欢迎贡献

这是个开源项目。如果你在生产环境踩过坑、有更好的优化方案,欢迎提 Issue 或 PR。

特别是:

  • 实际项目中遇到的性能问题
  • 现有规则的改进建议
  • Vue/Nuxt 新版本的优化技巧

项目信息

  • 46 条规则,10 个类别
  • 按影响程度排序(先解决大问题)
  • 支持 Vue 2/3 和 Nuxt
  • 适配 AI 编码助手

希望对你有帮助。

开源发布 🚀 | 解决 Vue Hero 动画的最后一块拼图:完美支持 v-show!

背景

在前段时间我实现了一个Vue指令,用于实现元素的跨页面动画效果: 【Hero动画】用一个指令实现Vue跨路由/组件动画

但有个遗憾一直没解决:不支持v-show指令。

最近终于有时间了,决定攻克这个技术难题,让 Hero 动画更加完整!

为什么v-show这么棘手🤔

v-if / 路由切换

v-if路由切换的情况下,使用指令的mountedbeforeUnmount钩子非常方便,只需要在挂载时注册Hero元素,在卸载前执行过渡动画即可。

// 这种很简单:挂载时注册,卸载时执行动画
const heroAnimationDirective: Directive<HTMLElement, HeroAnimationProps> = {
  mounted(el, { value }) {
    el.dataset.heroId = value.heroId;
  },
  beforeUnmount(el, { value }) { 
    heroAnimation(el, value);
  }
};

v-show 触发的变化

v-show通过display属性控制显示/隐藏,没有卸载过程,只能通过beforeUpdateupdated钩子来监听元素的变化。 核心难点:如何区分是v-show触发的显示变化,还是其他响应式数据的变化?

解决方案思路

所以我们只能手动判断是否是v-show触发的变化,只有在display属性变化时,才执行过渡动画。 大致实现步骤:

  1. mounted钩子中,将相同heroId的元素注册到一个集合中,标记为v-show组合。
  2. updated钩子中,判断display状态,从而判断是否是v-show触发的变化。

1-1.png

实现

注册Hero元素

我们先定义一个Map,用于存储heroId和对应的v-show元素集合。 并且实现注册和注销函数。

// 元素映射表 用于v-show 元素对的匹配
const heroMap = new Map<string, Set<HTMLElement>>();

/**
 * 注册Hero元素
 * @param el Hero元素
 * @param heroId Hero ID
 */
function registerHero(el: HTMLElement, heroId: string) {
  if (!heroMap.has(heroId)) {
    heroMap.set(heroId, new Set());
  }
  heroMap.get(heroId)?.add(el);
}

/**
 * 注销Hero元素
 * @param el Hero元素
 * @param heroId Hero ID
 */
function unregisterHero(el: HTMLElement, heroId: string) {
  const set = heroMap.get(heroId);
  if (set) {
    set.delete(el);
    if (set.size === 0) heroMap.delete(heroId);
  }
}

除此之外,我们还需要在元素都挂载好之后,来验证每个heroId是否有且只有2个v-show元素。

/**
 * 验证Hero元素对是否匹配
 * @param heroId Hero ID
 */
function validatePair(heroId: string) {
  const set = heroMap.get(heroId);
  if (set) {
    if (set.size === 2) {
      set.forEach(el => {
        const display = getComputedStyle(el).display;
        (el as any).__isVShowPair = true;
        (el as any).__wasHidden = display === 'none';
        // 记录原始display属性
        display !== 'none' && ((el as any).__originDisplay = display);
      });
    } else if (set?.size < 2) {
      set.forEach(el => (el as any).__isVIfPair = true);
      heroMap.delete(heroId);
    } else {
      console.error(`Hero ID "${heroId}" 有 ${set.size} 个元素,预期 2 个`);
    }
  }
}

再在指令处调用方法:

  1. mounted钩子中注册并验证元素对.
  2. updated钩子中判断是否是v-show触发的变化,从而执行过渡动画。
  3. beforeUnmount钩子中注销元素对。
const heroAnimationDirective: Directive<HTMLElement, HeroAnimationProps> = {
  mounted(el, { value }) {
    const heroId = value.heroId;
    el.dataset.heroId = heroId;
    registerHero(el, heroId);

    queueMicrotask(() => validatePair(heroId));
  },
  updated(el, { value }) {
    if (!(el as any).__isVShowPair) return
    const wasHidden = (el as any).__wasHidden;
    const display = getComputedStyle(el).display;
    // 初始display为隐藏的元素触发 避免触发两次
    if (!wasHidden) {
      heroAnimation(el, value);
    }
    // 重新记录隐藏状态
    (el as any).__wasHidden = display === 'none';
    (display !== 'none' && !(el as any).__originDisplay) && ((el as any).__originDisplay = display);
  },
  beforeUnmount(el, { value }) {
    // v-if/路由切换元素触发动画
    if ((el as any).__isVIfPair) {
      heroAnimation(el, value);
    }
    unregisterHero(el, value.heroId);
  }
};

改造动画

因为我们是在updated钩子中执行的动画,这时起始元素display属性已经被改变为none,我们需要先恢复原始值然后再执行动画。

/**
 * 执行元素的动画过渡
 * @param source 起始元素
 * @param props 动画属性
 */
async function heroAnimation(source: HTMLElement, props: HeroAnimationProps) {
  const {
    heroId,
    duration = '1s',
    timingFunction = 'ease',
    delay = '0s',
    position = 'fixed',
    zIndex = 9999,
    container = document.body
  } = props;

  // 容器
  const containerEl: HTMLElement = isRef(container)
    ? container.value ?? document.body
    : typeof container === 'string'
      ? document.querySelector(container) ?? document.body
      : container;
  const containerRect = getRect(containerEl);

  // v-show 标识
  const isVShowPair = (source as any).__isVShowPair;

  // v-show情况下,需要先显示元素,才能获取到正确的位置信息
  if (isVShowPair) {
    source.style.setProperty('display', (source as any).__originDisplay || 'block');
    await nextTick();
  }

  const rect = getRect(source);
  const clone = source.cloneNode(true) as HTMLElement;

  copyStyles(source, clone);
  // v-show 恢复隐藏
  isVShowPair && source.style.setProperty('display', 'none');
  await nextTick();

  let target: HTMLElement | null = null;

  if (isVShowPair) {
    // 从映射表中获取目标元素
    const set = heroMap.get(heroId);
    set && set.forEach(item => item !== source && (target = item));
  } else {
    target = document.querySelector(
      `[data-hero-id="${heroId}"]:not([data-clone]):not([style*="display: none"])`
    ) as HTMLElement;
  }

  if (!target) return;

  ...先前的动画逻辑
}

简单来个页面测试一下

<template>
  <button @click="flag = !flag">触发</button>
  <div class="container">
    <div
      v-show="flag"
      v-hero="animationProps"
      class="box1"
    />
    <div
      v-show="!flag"
      v-hero="animationProps" 
      class="box2"
    />
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue-demi';
import type { HeroAnimationProps } from 'vue-hero-cross';

const flag = ref(false)
const boxRef = ref<HTMLDivElement>()

const animationProps: HeroAnimationProps = {
  heroId: 'box',
  duration: '2s',
  position: 'absolute',
  container: '.container'
}
</script>

<style scoped>
.container {
  position: relative;
  width: 500px;
  height: 500px;
  border: 1px solid #000;
  border-radius: 12px;
  overflow: hidden;
}
.box1 {
  position: absolute;
  top: -50px;
  left: -50px;
  width: 200px;
  height: 200px;
  background-color: red;
  border-radius: 12px;
}

.box2 {
  position: absolute;
  bottom: -50px;
  right: -50px;
  width: 300px;
  height: 300px;
  background-color: blue;
  border-radius: 50%;
  transform: rotate(45deg);
}
</style>

看看效果:

1-2.gif

完美触发过渡😀

细节优化

快速切换优化

想到一个场景,如果快速点击按钮/切换路由,会出现什么效果。

1-3.gif

可以看到连点两下按钮后,虽然只有一个动画再执行,但是目标元素已经变化到了最初的蓝色BOX,但是动画的路径却没有变化,这明显是不符合预期的。 预期效果应该是如果目标元素已经变化了,那么动画的路径也应该变化到新的目标位置。 实现步骤:

  1. 当触发动画时,先判断是否存在正在进行的动画。
  2. 如果存在,需要先中断当前动画,然后创建一个新的动画元素。
  3. 新的动画元素需要复制当前动画元素的所有样式
  4. 新元素的位置需要设置为当前动画元素的位置
  5. 最后,新元素作为起始元素,开始新的动画。

我们先定义一个映射表,用于存储当前正在进行的动画元素。

// 正在进行的动画元素映射表
const animatingMap = new Map<string, HTMLElement>();

然后再实现中断当前动画的逻辑。

async function heroAnimation(source: HTMLElement, props: HeroAnimationProps) {
  const {
    heroId,
    duration = '1s',
    timingFunction = 'ease',
    delay = '0s',
    position = 'fixed',
    zIndex = 9999,
    container = document.body
  } = props;

  // 中断动画标识
  let isInterruptedAnimation = false;
  // 容器
  const containerEl: HTMLElement = isRef(container)
    ? container.value ?? document.body
    : typeof container === 'string'
      ? document.querySelector(container) ?? document.body
      : container;
  const containerRect = getRect(containerEl);

  // 存在正在进行的动画,需要中断
  if (animatingMap.has(heroId)) {
    // 当前动画元素
    const animatingEl = animatingMap.get(heroId) as HTMLElement;
    const animatingElStyle = window.getComputedStyle(animatingEl);

    // 克隆当前动画元素,用于新的动画
    const newSource = animatingEl.cloneNode(true) as HTMLElement;
    copyStyles(animatingEl, newSource);
    // copyStyles 函数排除了 left、top 样式,手动计算并设置当前动画元素的位置
    newSource.style.left = animatingElStyle.left;
    newSource.style.top = animatingElStyle.top;
    containerEl.appendChild(newSource);

    // 移除旧的动画元素
    containerEl.removeChild(animatingEl);
    
    source = newSource;
    isInterruptedAnimation = true;
  }

  ...

  copyStyles(source, clone);
  // v-show 恢复隐藏
  isVShowPair && source.style.setProperty('display', 'none');
  // 这时候的source是我们手动添加的 现在需要手动移除
  isInterruptedAnimation && containerEl.removeChild(source);
  await nextTick();

  ...

  containerEl.appendChild(clone);
  // 添加动画元素到映射表
  animatingMap.set(heroId, clone);

  requestAnimationFrame(() => {
    ...

    clone.addEventListener('transitionend', () => {
      ...
      // 动画结束后删除
      animatingMap.delete(heroId);
    }, { once: true });
  })
}

再看看现在的效果:

1-4.gif

这下可以实现移动到新的目标位置了😀。

动画时间优化

但这也带来了一个问题,就是动画时间。 现在中断动画后,当前动画元素过渡到新的目标位置还是需要2秒,但这不符合预期。 我们预想一个场景: 假设一个AB的动画,过渡动画时间是2000ms

  1. 前进的途中,动画播放了750ms,用户再次点击了按钮,那当前动画元素应该回到A位置,而过渡时间就是已播放的750ms
  2. 折返的途中,动画播放了500ms,用户再次点击了按钮,那当前动画元素应该回到B位置,而过渡时间就是总播放时长2000ms减去AB已过渡的250ms得到的1750ms

1-6.png

根据这个逻辑,我们需要多记录几个信息:

  1. 动画当前被重播的次数,以此来判断是前进还是折返
  2. 前进的时长,以此来计算继续前进折返的过渡时间。
  3. 动画开始时间,用于计算已播放时长。

我们修改animatingMap的类型,添加这些属性。 再添加一个方法,用于转换duration为毫秒数。

// 正在进行的动画元素映射表
interface AnimatingInfo {
  el: HTMLElement;
  count: number;
  elapsed: number;
  startTime: number;
}
const animatingMap = new Map<string, AnimatingInfo>();

/**
 * 解析动画时长
 * @param d 时长字符串或数字
 * @returns 时长(毫秒)
 */
function parseDuration(d: string | number): number {
  if (typeof d === 'number') return d
  const match = String(d).match(/^([\d.]+)\s*(s|ms)?$/)
  if (!match) return 1000
  const [, n, unit] = match
  return unit === 's' ? parseFloat(n) * 1000 : parseInt(n, 10)
}

我们再改造heroAnimation函数,来实现动画时间优化。

async function heroAnimation(source: HTMLElement, props: HeroAnimationProps) {
  const {
    heroId,
    duration = '1s',
    timingFunction = 'ease',
    delay = '0s',
    position = 'fixed',
    zIndex = 9999,
    container = document.body
  } = props;

  // 解析时长
  let durationMs = parseDuration(duration);
  ...
  const animatingInfo = animatingMap.get(heroId);
  // 存在正在进行的动画,需要中断
  if (animatingInfo) {
    const timeElapsed = performance.now() - animatingInfo.startTime;
    // 前进 还是 折返
    const isForward = animatingInfo.count % 2 === 0;

    animatingInfo.elapsed = isForward
      ? (animatingInfo.elapsed || 0) - timeElapsed
      : animatingInfo.elapsed + timeElapsed;
    
    durationMs = isForward
      ? durationMs - animatingInfo.elapsed
      : animatingInfo.elapsed;
    
    // 当前动画元素
    const animatingEl = animatingInfo.el;
    const animatingElStyle = window.getComputedStyle(animatingEl);
    ...
  }

  ...

  containerEl.appendChild(clone);
  // 更新动画元素
  const animationData = animatingInfo || {
    el: clone,
    count: 1,
    elapsed: 0,
    startTime: performance.now(),
  }
  if (animatingInfo) {
    animatingInfo.el = clone;
    animatingInfo.count++;
    animatingInfo.startTime = performance.now();
  }
  animatingMap.set(heroId, animationData);

  requestAnimationFrame(() => {
    // 改用转换后的时间
    clone.style.transition = `all ${durationMs}ms ${timingFunction} ${delay}`;
    ...
  });
}

这时我们再看看效果:

1-5.gif

这下动画时间就符合预期了🎉。

源码 和 使用

GitHub仓库

该指令的源码已经上传到github,如果对你有帮助,请点点star⭐: GitHub vue-hero-cross

npm包安装

同时,也发布到了npm,你可以通过npm install vue-hero-cross安装来直接使用: npm vue-hero-cross

🤝 参与贡献

如果你对这个项目感兴趣,欢迎:

  1. 提交 Issue 报告问题或建议。
  2. 提交 PR 添加新功能或修复 Bug。
  3. 在项目中实际使用并反馈体验。
  4. 分享给更多开发者

前端-请求接口中断处理指南

请求中断处理指南

📋 今日处理总结

处理的问题

  1. 实现 eligibleForPrizeExchange 请求中断:当打开 auto select 时,中断正在进行的 eligibleForPrizeExchange 请求
  2. 实现 target-prizes 请求中断:当关闭 auto select 时,中断正在进行的 target-prizes 请求
  3. 解决竞态条件问题:防止旧请求的结果覆盖新请求的结果
  4. 修复 loading 状态管理:确保 loading 状态能正确显示和更新

核心实现逻辑

1. 使用 AbortController + RequestId 双重保障
// 1. 定义状态管理
const abortController = ref<AbortController | null>(null);
const currentRequestId = ref<number>(0);

// 2. 在请求函数中
const fetchData = async () => {
    // 中断之前的请求
    if (abortController.value) {
        abortController.value.abort();
    }
    
    // 创建新的 AbortController 和 requestId
    const requestId = ++currentRequestId.value;
    const newAbortController = new AbortController();
    abortController.value = newAbortController;
    
    try {
        // 检查 requestId 是否仍然是最新的
        if (requestId !== currentRequestId.value) {
            return; // 已被新请求取代,直接返回
        }
        
        const signal = newAbortController.signal;
        
        // 发送请求,传递 signal
        const result = await apiCall(params, signal);
        
        // 请求完成后,再次检查 requestId
        if (requestId !== currentRequestId.value) {
            return; // 已被新请求取代,忽略结果
        }
        
        // 检查当前状态是否仍然匹配
        if (!shouldProcessResult()) {
            return; // 状态已改变,不处理结果
        }
        
        // 处理结果
        updateData(result);
        
    } catch (error) {
        // 如果是 AbortError,不需要处理错误
        if (error instanceof Error && error.name !== 'AbortError') {
            handleError(error);
        }
    } finally {
        // 只有当前请求完成时,才清理 AbortController
        if (requestId === currentRequestId.value) {
            abortController.value = null;
        }
    }
};
2. 在 Store 中传递 signal
// stores/example.ts
async function fetchData(params: any, signal?: AbortSignal) {
    const uniqueKey = `fetchData-${Date.now()}-${Math.random()}`;
    const { data, execute } = useApiFetch(url, {
        method: "POST",
        body: JSON.stringify(params),
        signal: signal,
        key: uniqueKey, // 确保每次创建新实例
    });
    
    await execute();
    
    // 处理结果...
}

// 在 catch 中处理 AbortError
catch (e: any) {
    if (e?.name === 'AbortError' || e?.message?.includes('aborted')) {
        return { success: false, data: null, message: 'Request aborted' };
    }
    // 处理其他错误...
}
3. 在 useApiFetch 中动态设置 signal
// composables/useApiFetch.ts
onRequest({ options }) {
    if (signal !== undefined && signal !== null) {
        options.signal = signal;
        // 检查 signal 是否已经被中断
        if (signal.aborted) {
            throw new DOMException('The operation was aborted.', 'AbortError');
        }
    }
}

🎯 通用处理模式

模式一:单一请求中断

场景:用户快速操作,需要中断之前的请求

const abortController = ref<AbortController | null>(null);
const currentRequestId = ref<number>(0);

const fetchData = async () => {
    // 1. 中断之前的请求
    if (abortController.value) {
        abortController.value.abort();
    }
    
    // 2. 创建新的请求标识
    const requestId = ++currentRequestId.value;
    const controller = new AbortController();
    abortController.value = controller;
    
    try {
        // 3. 检查是否仍然是最新请求
        if (requestId !== currentRequestId.value) return;
        
        // 4. 发送请求
        const result = await apiCall(controller.signal);
        
        // 5. 再次检查
        if (requestId !== currentRequestId.value) return;
        
        // 6. 处理结果
        processResult(result);
        
    } catch (error) {
        if (error.name !== 'AbortError') {
            handleError(error);
        }
    } finally {
        if (requestId === currentRequestId.value) {
            abortController.value = null;
        }
    }
};

模式二:多请求互斥中断

场景:两个不同的请求,一个触发时中断另一个

const requestAController = ref<AbortController | null>(null);
const requestBController = ref<AbortController | null>(null);

const fetchA = async () => {
    // 中断 B
    if (requestBController.value) {
        requestBController.value.abort();
        requestBController.value = null;
    }
    
    // 开始 A
    const controller = new AbortController();
    requestAController.value = controller;
    // ... 发送请求
};

const fetchB = async () => {
    // 中断 A
    if (requestAController.value) {
        requestAController.value.abort();
        requestAController.value = null;
    }
    
    // 开始 B
    const controller = new AbortController();
    requestBController.value = controller;
    // ... 发送请求
};

模式三:状态检查 + RequestId 双重验证

场景:请求结果需要匹配当前状态(如 autoSelect 状态)

const fetchData = async () => {
    const requestId = ++currentRequestId.value;
    const controller = new AbortController();
    
    try {
        const result = await apiCall(controller.signal);
        
        // 1. 检查 requestId(防止竞态条件)
        if (requestId !== currentRequestId.value) return;
        
        // 2. 检查状态(确保结果匹配当前状态)
        if (!isValidState()) return;
        
        // 3. 处理结果
        updateData(result);
        
    } catch (error) {
        // 处理错误...
    }
};

🔍 常见场景及处理方法

场景 1:搜索框输入

问题:用户快速输入,需要中断之前的搜索请求

解决方法

const searchController = ref<AbortController | null>(null);
const searchRequestId = ref<number>(0);

const handleSearch = debounce(async (keyword: string) => {
    // 中断之前的搜索
    if (searchController.value) {
        searchController.value.abort();
    }
    
    const requestId = ++searchRequestId.value;
    const controller = new AbortController();
    searchController.value = controller;
    
    try {
        if (requestId !== searchRequestId.value) return;
        
        const results = await searchApi(keyword, controller.signal);
        
        if (requestId !== searchRequestId.value) return;
        
        updateSearchResults(results);
    } catch (error) {
        if (error.name !== 'AbortError') {
            handleError(error);
        }
    }
}, 300);

场景 2:标签页切换

问题:切换标签页时,需要中断当前标签页的请求

解决方法

const tabControllers = ref<Map<string, AbortController>>(new Map());

const fetchTabData = async (tabId: string) => {
    // 中断当前标签页的请求
    const currentController = tabControllers.value.get(tabId);
    if (currentController) {
        currentController.abort();
    }
    
    // 创建新的请求
    const controller = new AbortController();
    tabControllers.value.set(tabId, controller);
    
    try {
        const data = await fetchTabDataApi(tabId, controller.signal);
        updateTabData(tabId, data);
    } catch (error) {
        if (error.name !== 'AbortError') {
            handleError(error);
        }
    }
};

场景 3:表单提交

问题:用户快速点击提交按钮,需要防止重复提交

解决方法

const submitController = ref<AbortController | null>(null);
const isSubmitting = ref<boolean>(false);

const handleSubmit = async () => {
    if (isSubmitting.value) {
        // 中断之前的提交
        if (submitController.value) {
            submitController.value.abort();
        }
    }
    
    isSubmitting.value = true;
    const controller = new AbortController();
    submitController.value = controller;
    
    try {
        await submitForm(formData, controller.signal);
        showSuccess();
    } catch (error) {
        if (error.name !== 'AbortError') {
            showError(error);
        }
    } finally {
        isSubmitting.value = false;
        submitController.value = null;
    }
};

场景 4:下拉刷新

问题:用户快速下拉刷新,需要中断之前的刷新请求

解决方法

const refreshController = ref<AbortController | null>(null);

const handleRefresh = async () => {
    // 中断之前的刷新
    if (refreshController.value) {
        refreshController.value.abort();
    }
    
    const controller = new AbortController();
    refreshController.value = controller;
    
    try {
        const data = await refreshData(controller.signal);
        updateData(data);
    } catch (error) {
        if (error.name !== 'AbortError') {
            handleError(error);
        }
    } finally {
        refreshController.value = null;
    }
};

✅ 最佳实践

1. 必须使用 RequestId

原因:防止竞态条件,确保只有最新请求的结果被使用

// ✅ 正确
const requestId = ++currentRequestId.value;
// ... 请求完成后检查
if (requestId !== currentRequestId.value) return;

// ❌ 错误:没有 requestId,无法防止竞态条件
const controller = new AbortController();

2. 多次检查 RequestId

原因:异步操作中,状态可能随时改变

// ✅ 正确:在关键步骤前都检查
const result = await apiCall();
if (requestId !== currentRequestId.value) return;

processData(result);
if (requestId !== currentRequestId.value) return;

updateUI(result);

3. 状态检查 + RequestId 双重验证

原因:确保结果不仅是最新的,还要匹配当前状态

// ✅ 正确
if (requestId !== currentRequestId.value) return;
if (!isValidState()) return; // 检查状态是否匹配

// ❌ 错误:只检查 requestId,不检查状态
if (requestId !== currentRequestId.value) return;
// 直接使用结果,可能状态已改变

4. 正确处理 AbortError

原因:AbortError 是正常的取消操作,不应该显示错误

// ✅ 正确
catch (error) {
    if (error.name !== 'AbortError') {
        handleError(error); // 只处理真正的错误
    }
}

// ❌ 错误:所有错误都处理,包括 AbortError
catch (error) {
    handleError(error); // 会显示"请求被中断"的错误提示
}

5. 在 finally 中清理 AbortController

原因:确保只有当前请求完成时才清理,避免影响新请求

// ✅ 正确
finally {
    if (requestId === currentRequestId.value) {
        abortController.value = null; // 只有当前请求完成时才清理
    }
}

// ❌ 错误:总是清理,可能影响新请求
finally {
    abortController.value = null; // 可能清理了新请求的 controller
}

6. 使用唯一的 key 创建新的 useApiFetch 实例

原因:避免 useFetch 缓存导致的问题

// ✅ 正确
const uniqueKey = `apiCall-${Date.now()}-${Math.random()}`;
const { data, execute } = useApiFetch(url, {
    signal: signal,
    key: uniqueKey, // 确保每次创建新实例
});

// ❌ 错误:使用相同的 key,可能导致缓存问题
const { data, execute } = useApiFetch(url, {
    signal: signal,
    // 没有 key,可能使用缓存的实例
});

📝 代码模板

完整模板

// 1. 定义状态
const abortController = ref<AbortController | null>(null);
const currentRequestId = ref<number>(0);
const isLoading = ref<boolean>(false);

// 2. 请求函数
const fetchData = async (params: any) => {
    // 中断之前的请求
    if (abortController.value) {
        abortController.value.abort();
    }
    
    // 创建新的请求标识
    const requestId = ++currentRequestId.value;
    const controller = new AbortController();
    abortController.value = controller;
    
    isLoading.value = true;
    
    try {
        // 检查 1:请求开始前
        if (requestId !== currentRequestId.value) {
            isLoading.value = false;
            return;
        }
        
        const signal = controller.signal;
        if (!signal) {
            isLoading.value = false;
            return;
        }
        
        // 发送请求
        const result = await store.fetchData(params, signal);
        
        // 检查 2:请求完成后
        if (requestId !== currentRequestId.value) {
            return;
        }
        
        // 检查 3:状态验证(如果需要)
        if (!isValidState()) {
            return;
        }
        
        // 检查 4:更新数据前
        if (requestId !== currentRequestId.value) {
            return;
        }
        
        // 处理结果
        updateData(result);
        
    } catch (error: any) {
        // 处理错误(忽略 AbortError)
        if (error instanceof Error && error.name !== 'AbortError') {
            handleError(error);
        }
    } finally {
        // 清理(只有当前请求完成时)
        if (requestId === currentRequestId.value) {
            abortController.value = null;
        }
        isLoading.value = false;
    }
};

// 3. 中断函数(在状态改变时调用)
const interruptRequest = () => {
    if (abortController.value) {
        abortController.value.abort();
        abortController.value = null;
        currentRequestId.value++; // 更新 requestId,确保旧请求结果被忽略
    }
};

🚨 注意事项

  1. 不要忘记传递 signal:确保 signal 正确传递到 API 调用
  2. 不要忘记检查 requestId:防止竞态条件
  3. 不要忘记检查状态:确保结果匹配当前状态
  4. 不要忘记处理 AbortError:避免显示"请求被中断"的错误
  5. 不要忘记清理 AbortController:避免内存泄漏
  6. 使用唯一的 key:避免 useFetch 缓存问题

📚 相关文件

  • pages/wallet/components/redemption/WalletSelectionSection.vue - target-prizes 中断实现
  • pages/wallet/components/RedeemWithPrizesModal.vue - eligibleForPrizeExchange 中断实现
  • stores/wallet.ts - API 调用中的 signal 传递
  • composables/useApiFetch.ts - signal 的动态设置

还在用‘Z’字图案密码?这个网站能帮你找到更复杂个性化的手机图案密码

手机图案密码全库检索Web应用

2026.01 | ming


AI Generated Art

图案密码,是我们日常生活中非常熟悉的一种验证方式。无论是手机锁屏、应用加密,还是隐私相册、银行类软件的二次验证,它都以直观、易操作的特点被广泛使用。虽然从安全性角度来说,它可能不如复杂字符密码,但图案密码在记忆门槛和操作趣味性上,有着非常独特的优势。

最近在给新手机设置图案密码的时候,我想了很久要用什么图案当密码,怎样的图案才能既美观别致,又具备足够的迷惑性,让他人难以轻易看透?于是就有了研究图案密码的想法。

通常所说的图案密码,指的是在一个 3×3 的点阵上,依次连接 4 到 9 个不重复的点所形成的路径。现在大部分软件的图案密码至少需要使用到4个点,并且每个点不能重复连接。那么我们就可以把这9个点看成数字小键盘,如下图所示:

[123456789]\begin{bmatrix} 1&2 &3 \\ 4&5 &6 \\ 7&8 &9 \end{bmatrix}

这样一来,每一个图案密码都能对应一串由 1~9 中不重复数字所组成的序列。比如经典的“Z”字形图案,对应的密码序列就是 [1, 2, 3, 5, 7, 8, 9]。因此,图案密码问题就转化为了数字序列的生成与筛选问题。

既然每个点只能使用一次,且密码长度范围为 4~9,那么总的图案数量就是从 9 个点中选取 nn 个点(nn 从 4 到 9),并对它们进行全排列的总和。用数学公式表达就是:

n=49A9n=985824\sum_{n=4}^{9} A_{9}^{n} = 985824

也就是说,一共有 985,824 种可能的图案。这个数字听起来很大,但对于现代计算机而言,完全在可遍历处理的范围内——我们完全可以通过编写筛选算法,快速找出符合特定样式或规则的图案。

如果你觉得 98 万仍然是个令人望而却步的数字,甚至担心遍历会耗时过长,那或许低估了现代计算机的运算能力。当然,我们还可以通过“去冗余”进一步简化问题。仔细观察下面这组图案:

c3.jpg

你会发现,这 8 个图案其实源自同一个“基础图形”,只不过分别经过了 90°、180°、270° 旋转,以及水平翻转、垂直翻转、沿主对角线翻转、沿副对角线翻转等对称变换。在视觉和结构上,它们属于同一类密码模式。因此,如果我们只保留每组对称图案中的“基础版本”,就可以将图案总数量减少至原来的 1/8:

9858248=123228\frac{985824}{8} = 123228

这样一来,我们只需在 12.3 万 个有效图案中进行筛选和检索,效率大大提升,也更便于用户聚焦于真正独特的密码设计。

有了想法,就开始着手设计筛选程序了,我一开始打算使用React框架开发一个Web网页的,但是由于有几年没碰React了,我的React还是5年前学的,当初学习的版本与如今的最新语法差异巨大,一时间感到无从下手。并且我也不愿再花时间重新学习它,于是就干脆决定转向 Vue3,好在有过去的前后端开发基础,学习起来并不算吃力。

更重要的是,如今有了 AI 的加持。回想五年前刚入门前后端时,无论是学习新技术还是动手开发,整个过程都相当漫长,每个新知识都要依赖文档、教程或是反复试错,过程漫长且充满不确定性。而现在,AI 极大地加速了这一进程——遇到问题随时提问,AI 给出的解答往往比许多教学视频更直接、更有针对性。在开发过程中,它的辅助更是让效率大幅提升:想要什么组件,什么布局,什么样式,直接向AI描述清楚即可。就拿这个图案密码筛选网站来说,从动手编码到基本完成,前后只用了两天时间。我的大部分精力都放在了系统架构的设计、用户交互的逻辑、筛选规则的制定、图案的绘制方式,以及如何将各个模块有机整合……这些真正需要思考的问题上。

在 AI 时代,我认为程序员的核心价值,越来越体现在对系统架构的把握与全局设计的视野上。很多人为了省事,倾向于让 AI 智能体直接生成整个应用或大型模块;在我看来,这并不是 AI 的最佳使用方式。没有扎实的技术基础,仅凭 AI 生成的项目无异于空中楼阁,难以稳固、更难以迭代。但当你对一个项目的整体流程有了清晰概念和想法,AI 就能成为你得力的助手,是你在主导AI,而不是AI在领导你。这正是“懂技术”和“不懂技术”的人在使用 AI 时的关键差异,你的技术基础,决定着你的AI生成能力的上界。工具越是强大,人的思维和视野就越显珍贵。

回到正题,你可以直接访问这个网址:narrastory.rth1.xyz/ 来在线体验这个工具。(推荐使用125%页面缩放)

c1.jpg

接下来,我将详细介绍这个工具中提供的几个筛选条件,帮助你更精准地找到心仪的图案密码。

1. 节点数量

节点数量指的是图案密码中连接的点数,范围在 4 到 9 个 之间。点数越多,图案越复杂,可能的组合也越多。

c4.jpg

2. 模式选择

在手机图案密码的设置中,通常有两种连接模式:

  • 限制模式:也是大部分手机的默认模式。当你想连接的两个点之间存在未被使用过的中间点时,系统会自动将该中间点纳入路径。 例如:连接 1→3 会自动变成 1→2→3;连接 1→9 会自动经过 5,变成 1→5→9。 这种模式虽然方便,但也限制了一些特殊“跳点”图案的生成。
  • 非限制模式:关闭限制后,你可以自由连接任意两个点,中间即使有未使用的点也不会被自动加入。 例如:可以直接连接 1→3,系统不会添加点 2

c5.jpg

3. 跳点设置

“跳点”是指在连接路径中,跳过某个中间点直接连接更远的点。例如在序列 [2,1,3] 中,从 1 连接到 3 就跳过了 2;在 [3,2,1,9] 中,从 19 跳过了 5

跳点数量反映了一个图案的“非常规”程度。一般来说,跳点越多的图案看起来越不规则,迷惑性也更强,有助于提高密码的防窥视能力。

c7.jpg

4. 线长排序

“线长”是指构成整个图案的线段总长度。我们可以依次计算相邻两点间的欧几里得距离并累加得到总长。例如下图所示:

c6.jpg

上面这个图案的线长就可以这么计算

线长=5+2+1+26.65线长 = \sqrt{5} + \sqrt{2} + 1 + 2 \approx 6.65

你可以选择“按照总线长降序排列”,让最长的图案排在前列。

5. 线条样式设置

图案密码中的线段可分为三种基本类型:

  1. 直线:水平或垂直方向(如 1→24→7
  2. 45° 斜线:斜率为 ±1(如 1→53→7
  3. 1:2斜线:斜率为 ±2 或 ±1/2(如 1→62→7

c2.jpg

你可以分别设置筛选每种线型的数量,从而控制图案的“几何风格”。比如,只包含 45° 斜线的图案会呈现出整齐的对角线美感;而混合多种线型则会让图案更富有变化。

这个工具不仅是一个密码生成器,也是一次关于排列组合、几何连接与交互设计的轻量实践。如果你对实现细节感兴趣,或希望在本地方便地修改和尝试,项目的完整代码已经开源在:

GitHub:github.com/narrastory/…

需要提醒的是,图案密码在安全性上存在固有弱点。图案密码虽然有趣易记,但如果让他人看到你的图案,即使别人未直接看清连接顺序,但仅凭图案形状,也可能在短时间内推测出可能的连接路径。因此,它更适合用于对安全性要求不高的场景,或作为辅助记忆的趣味选择。

感谢阅读,祝你探索愉快! 🔐✨

Vue3 toRef/toRefs 完全指南:作用、场景及父子组件通信实战

在Vue3组合式API开发中,reactive用于创建复杂引用类型的响应式数据,但其存在一个核心痛点——直接解构会丢失响应式。而toReftoRefs正是为解决这一问题而生的“响应式保留工具”,尤其在将reactive数据拆分传递给子组件时,是保障响应式连贯性的关键。本文将从核心作用、区别对比、典型场景三个维度,结合父子组件通信实例,彻底讲透toRef/toRefs的用法与价值。

此前我们已了解,reactive创建的响应式对象,直接解构会破坏响应式(本质是解构后得到的是普通属性值,脱离了Proxy拦截范围)。而toRef/toRefs能在拆分数据的同时,保留与原reactive对象的响应式关联,这一特性在组件通信、状态拆分场景中至关重要。

一、核心作用:保留响应式的“拆分工具”

1. toRef 的核心作用

toRef用于为reactive对象的单个属性创建一个Ref对象,核心特性的是:

  • 响应式关联保留:创建的Ref对象与原reactive对象属性“双向绑定”——修改Ref对象的.value,会同步更新原reactive对象;反之,原reactive对象属性变化,也会同步到Ref对象。
  • 非拷贝而是引用:toRef不会拷贝属性值,仅建立引用关系,避免数据冗余,尤其适合大型对象场景。
  • 支持可选属性:即使原reactive对象中该属性不存在,toRef也能创建对应的Ref对象(值为undefined),不会报错,适配动态属性场景。

语法:toRef(reactiveObj, propertyKey),第一个参数为reactive创建的响应式对象,第二个参数为属性名。

2. toRefs 的核心作用

toRefs是toRef的“批量版本”,用于将整个reactive对象拆分为多个Ref对象组成的普通对象,核心特性:

  • 批量转换:遍历reactive对象的所有可枚举属性,为每个属性创建对应的Ref对象,最终返回一个普通对象(非响应式),其属性与原reactive对象属性一一对应。
  • 响应式联动:每个拆分后的Ref对象都与原reactive对象属性保持双向关联,修改任一方向都会同步更新。
  • 解构安全:将reactive对象转为Ref对象集合后,可安全解构,解构后的属性仍保持响应式,解决了reactive直接解构丢失响应式的痛点。

语法:toRefs(reactiveObj),仅接收一个reactive创建的响应式对象参数。

3. toRef 与 toRefs 的核心区别

维度 toRef toRefs
转换范围 单个属性(精准定位) 所有可枚举属性(批量转换)
返回值类型 单个Ref对象 普通对象(属性均为Ref对象)
适用场景 仅需拆分reactive对象的部分属性 需拆分reactive对象的全部属性,或需解构使用
性能开销 极低(仅处理单个属性) 略高于toRef(遍历对象属性),但可忽略

二、典型使用场景:从基础到组件通信

1. 基础场景:解决reactive解构丢失响应式问题

这是toRef/toRefs最基础的用法,直接解构reactive对象会导致响应式失效,而通过toRef/toRefs转换后可安全解构。

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

const form = reactive({
  username: '',
  password: '',
  remember: false
});

// 错误示例:直接解构丢失响应式
const { username, password } = form;
username = 'admin'; // 仅修改普通变量,原form无变化

// 正确示例1:toRef 转换单个属性
const usernameRef = toRef(form, 'username');
usernameRef.value = 'admin'; // 原form.username同步更新为'admin'

// 正确示例2:toRefs 批量转换后解构
const { password: passwordRef, remember: rememberRef } = toRefs(form);
passwordRef.value = '123456'; // 原form.password同步更新
rememberRef.value = true; // 原form.remember同步更新

2. 核心场景:reactive数据拆分传递给子组件

在父子组件通信中,若父组件使用reactive管理聚合状态(如表单、用户信息),需将部分/全部属性传递给子组件时,toRef/toRefs能保障子组件修改后同步反馈到父组件,且不破坏响应式链路。这是日常开发中最常用的场景,分为“部分属性传递”和“全部属性传递”两种情况。

场景1:传递reactive对象的部分属性给子组件

当子组件仅需父组件reactive对象的个别属性时,用toRef精准转换对应属性,传递给子组件后,子组件修改该Ref对象,父组件原reactive对象会同步更新。

// 父组件 Parent.vue
<script setup>
import { reactive, toRef } from 'vue';
import Child from './Child.vue';

// 父组件用reactive管理用户信息
const user = reactive({
  name: '张三',
  age: 20,
  info: { height: 180 }
});

// 仅将name属性传递给子组件,用toRef保留响应式
const nameRef = toRef(user, 'name');
</script>

<template>
  <div>父组件:姓名 {{ user.name }}</div>
  <Child :name="nameRef" />
</template>

// 子组件 Child.vue
<script setup>
import { defineProps, Ref } from 'vue';

// 子组件接收Ref类型的props
const props = defineProps({
  name: {
    type: Ref,
    required: true
  }
});

// 子组件修改props(实际开发中建议通过emit触发父组件修改,此处为演示响应式关联)
const updateName = () => {
  props.name.value = '李四'; // 父组件user.name同步更新为'李四'
};
</script>

<template>
  <div>子组件:姓名 {{ name.value }}</div>
  <button @click="updateName">修改姓名</button>
</template>

注意:Vue官方建议“单向数据流”——子组件不直接修改props,应通过emit通知父组件修改。上述示例仅演示响应式关联,实际开发中可让子组件触发emit,父组件修改reactive对象,子组件通过props自动同步。

场景2:传递reactive对象的全部属性给子组件

当子组件需要父组件reactive对象的全部属性时,用toRefs批量转换后,通过展开运算符传递给子组件,子组件可直接解构使用,且保持响应式。

// 父组件 Parent.vue
<script setup>
import { reactive, toRefs } from 'vue';
import Child from './Child.vue';

// 父组件reactive聚合表单状态
const form = reactive({
  username: '',
  password: '',
  remember: false
});

// 批量转换为Ref对象集合
const formRefs = toRefs(form);
</script>

<template>
  <div>父组件:用户名 {{ form.username }}</div>
  <!-- 展开传递所有属性,子组件可按需接收 -->
  <Child v-bind="formRefs" />
</template>

// 子组件 Child.vue
<script setup>
import { defineProps, Ref } from 'vue';

// 子组件按需接收props
const props = defineProps({
  username: { type: Ref, required: true },
  password: { type: Ref, required: true },
  remember: { type: Ref, required: true }
});

// 子组件触发父组件修改(遵循单向数据流)
const handleInput = (key, value) => {
  props[key].value = value; // 父组件form同步更新
};
</script>

<template>
  <input 
    v-model="username.value" 
    placeholder="请输入用户名"
    @input="handleInput('username', $event.target.value)"
  />
  <input 
    v-model="password.value" 
    type="password" 
    placeholder="请输入密码"
    @input="handleInput('password', $event.target.value)"
  />
  <input 
    v-model="remember.value" 
    type="checkbox"
  /> 记住我
</template>

该场景的优势的是:父组件无需逐个传递属性,子组件可按需接收,且所有属性的响应式链路完整,父组件状态与子组件同步一致。

3. 进阶场景:组合式API中拆分状态逻辑

在组合式API中,常将复杂状态逻辑抽离为独立函数(Composable),函数返回reactive对象时,可通过toRefs拆分后返回,便于组件内解构使用,同时保留响应式。

// composables/useUser.js(抽离用户状态逻辑)
import { reactive, toRefs } from 'vue';

export function useUser() {
  const user = reactive({
    name: '张三',
    age: 20,
    updateName: (newName) => {
      user.name = newName;
    }
  });

  // 拆分后返回,组件可解构使用
  return { ...toRefs(user), updateName: user.updateName };
}

// 组件中使用
<script setup>
import { useUser } from '@/composables/useUser';

// 解构后仍保持响应式
const { name, age, updateName } = useUser();
updateName('李四'); // name.value同步更新为'李四'
</script>

三、避坑要点:这些细节千万别忽略

  • 仅适用于reactive对象:toRef/toRefs的核心作用是处理reactive对象的属性拆分,若用于普通对象或ref对象,虽不会报错,但无法实现响应式关联(普通对象无Proxy拦截,ref对象本身已可通过.value操作)。
  • 不触发新的响应式依赖:toRef/toRefs创建的Ref对象与原reactive对象共享同一响应式依赖,修改时不会新增依赖,仅触发原有依赖更新,性能更优。
  • 嵌套属性的处理:若reactive对象包含嵌套对象,toRef/toRefs仅对顶层属性创建Ref对象,嵌套属性仍为普通对象(需通过.value访问后再操作)。若需嵌套属性也转为Ref,可结合toRef递归处理,或直接使用ref嵌套。
  • 与ref的区别:ref是“创建新的响应式数据”,而toRef是“关联已有reactive对象的属性”,两者本质不同——ref的数据独立,toRef的数据与原对象联动。

四、总结

toRef与toRefs作为Vue3组合式API的“响应式辅助工具”,核心价值在于拆分reactive对象时保留响应式关联,解决了直接解构导致的响应式失效问题。其中,toRef适用于精准拆分单个属性,toRefs适用于批量拆分全部属性,两者在父子组件通信、状态逻辑抽离等场景中不可或缺。

尤其在父子组件传递reactive数据时,toRef/toRefs能保障数据链路的完整性,既满足子组件对数据的使用需求,又遵循Vue的单向数据流原则,是实现组件间状态协同的高效方案。掌握两者的用法与区别,能让你的响应式开发更灵活、更健壮。

Vue3 响应式系统——ref 和 reactive

一、Vue3 响应式系统概述

Vue3 响应式包 @vue/reactivity,核心由三部分构成:

数据 (Proxy Object)  —— 依赖收集 Track  —— 触发更新 Trigger  ——  Effect 执行更新

核心目标:

  • 拦截读取和设置操作
  • 收集依赖
  • 在数据变化时重新触发相关副作用

主要实现 API:

二、reactive() 执行机制

2.1 核心逻辑(核心源码)

function reactive(target) {
  return createReactiveObject(target, false, mutableHandlers)
}

function createReactiveObject(target, isReadonly, baseHandlers) {
  if (!isObject(target)) {
    return target
  }
  if (target already has proxy) return existing proxy
  const proxy = new Proxy(target, baseHandlers)
  cache proxy
  return proxy
}

Vue3 用 Proxy 拦截对象操作,比 Vue2 的 Object.defineProperty 更强(能监听属性增删)。

2.2 reactive 的 handler(简化)

const mutableHandlers = {
  get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver)
    track(target, key)
    return isObject(res) ? reactive(res) : res
  },
  set(target, key, value, receiver) {
    const oldValue = target[key]
    const result = Reflect.set(target, key, value, receiver)
    if (oldValue !== value) {
      trigger(target, key)
    }
    return result
  }
}

三、依赖收集和触发更新:track()trigger()

Vue 内部维护一个 全局的 activeEffect

let activeEffect = null

function effect(fn) {
  activeEffect = wrappedEffect(fn)
  fn() // 执行一次用于收集依赖
  activeEffect = null
}

每次读取(get)响应式数据时:

function track(target, key) {
  if (!activeEffect) return
  const depsMap = targetMap.get(target) || new Map()
  const dep = depsMap.get(key) || new Set()
  dep.add(activeEffect)
}

当数据被设置(set)时:

function trigger(target, key) {
  const depsMap = targetMap.get(target)
  const dep = depsMap?.get(key)
  dep?.forEach(effect => effect())
}
  • track 只在 读取时收集依赖
  • trigger 只在 数据修改时触发 effect 重新执行

四、ref() 的设计与区别

4.1 ref 是什么?

ref() 主要用于包装 基本类型 (对于对象引用类型内部直接调用上面的 reactive()):

const count = ref(0)

其结构本质上是:

interface RefImpl {
  value: T
}

源码核心:

function ref(rawValue) {
  return createRef(rawValue)
}

function createRef(rawValue) {
  const refImpl = { 
    _value: convert(rawValue), 
    dep: new Set(), // 区别于reactive引用类型复杂的多层嵌套数据结构封装dep,ref这里直接在实例中存放一个dep来实现
    get value() {
      trackRefValue(refImpl)
      return refImpl._value
    },
    set value(newVal) {
      if (hasChanged(newVal, refImpl._value)) {
        refImpl._value = convert(newVal)
        triggerRefValue(refImpl)
      }
    }
  }
  return refImpl
}

4.2 ref vs reactive 的本质区别

五、template / setup 中的自动 unwrap

在 Vue 模板中:

<p>{{ count }}</p>

如果 count 是一个 ref,它会 自动解包,模板中不需要写 .value。这是由编译阶段的 transform 实现的。

六、响应式系统执行流程图(简化)

reactive/ref 数据 -> Proxy getter -> track
                           │
                        effect 注册
                           │
                    数据 setter -> trigger
                           ↓
                    重新执行 effect

Vue3中v-model在表单元素双向绑定中的场景差异与绑定策略是什么?

一、文本输入框与v-model的基础绑定:双向交互的起点

在Vue3中,v-model是处理表单输入的“瑞士军刀”——它通过语法糖简化了“值绑定+事件监听”的重复工作。对于文本输入框(input[type="text"]),v-model的本质是:

  • 将表单元素的value属性绑定到组件的状态变量;
  • 监听表单元素的input事件,当用户输入时更新状态变量。

1. 基础示例:用户名输入框

我们用一个简单的用户名输入案例,直观感受双向绑定的魔力:

<script setup>
import { ref } from 'vue'  
// 用ref创建响应式变量,初始值为空字符串
const username = ref('')  
</script>

<template>
  <div class="form-item">
    <label>用户名:</label>
    <!-- v-model绑定username,实现双向同步 -->
    <input type="text" v-model="username" placeholder="请输入用户名" />
    <!-- 实时展示输入结果 -->
    <p class="tip">当前输入:{{ username }}</p>
  </div>
</template>

效果说明

  • 用户在输入框中打字时,username会自动同步更新;
  • 若通过代码修改username(比如username.value = 'Vue3'),输入框的内容也会立即更新。

2. 底层原理流程图

v-model的双向绑定逻辑可以用以下流程概括:

graph TD
A[组件状态变量(如username)] --> B[渲染到表单元素的value属性]
B --> C[用户输入触发input事件]
C --> D[Vue更新状态变量]
D --> A[重新渲染表单元素]

二、多行文本与复选框:不同场景的绑定策略

1. 多行文本(textarea):和文本框“无缝衔接”

多行文本框(textarea)的绑定逻辑与文本输入框完全一致——v-model会自动处理value属性和input事件,无需额外配置:

<script setup>
import { ref } from 'vue'  
const introduction = ref('') // 个人简介,初始为空
</script>

<template>
  <div class="form-item">
    <label>个人简介:</label>
    <textarea v-model="introduction" rows="3" placeholder="说说你的故事"></textarea>
    <p class="tip">简介预览:{{ introduction }}</p>
  </div>
</template>

2. 复选框(checkbox):两种绑定场景

复选框的绑定分为单个复选框多个复选框两种情况,核心区别在于变量类型:

  • 单个复选框:绑定布尔值(表示“是否选中”),常用于“同意条款”场景;
  • 多个复选框:绑定数组(存储选中的value值),常用于“选择爱好”场景。
示例:单个复选框(同意条款)
<script setup>
import { ref } from 'vue'  
const agree = ref(false) // 默认未同意
</script>

<template>
  <div class="form-item">
    <input type="checkbox" id="agree" v-model="agree" />
    <label for="agree">我已阅读并同意《用户协议》</label>
    <p class="tip">状态:{{ agree ? '已同意' : '未同意' }}</p>
  </div>
</template>
示例:多个复选框(选择爱好)
<script setup>
import { ref } from 'vue'  
const hobbies = ref([]) // 存储选中的爱好,初始为空数组
const hobbyList = ['阅读', ' coding', '旅行', '摄影'] // 预设爱好列表
</script>

<template>
  <div class="form-item">
    <label>爱好:</label>
    <!-- 用v-for循环生成复选框,绑定hobbies数组 -->
    <div v-for="hobby in hobbyList" :key="hobby">
      <input 
        type="checkbox" 
        :id="hobby" 
        :value="hobby" 
        v-model="hobbies" 
      />
      <label :for="hobby">{{ hobby }}</label>
    </div>
    <p class="tip">已选爱好:{{ hobbies.join('、') }}</p>
  </div>
</template>

三、单选框与下拉选择:分组与关联的艺术

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

1. 单选框(radio):用name属性分组

单选框需要通过name属性分组,确保同一组内只能选一个。v-model绑定的变量会存储选中项的value值:

<script setup>
import { ref } from 'vue'  
const gender = ref('male') // 默认选中“男”
</script>

<template>
  <div class="form-item">
    <label>性别:</label>
    <!-- name="gender" 分组,确保互斥 -->
    <input type="radio" name="gender" value="male" v-model="gender" id="male" />
    <label for="male">男</label>
    <input type="radio" name="gender" value="female" v-model="gender" id="female" />
    <label for="female">女</label>
    <p class="tip">选择性别:{{ gender }}</p>
  </div>
</template>

2. 下拉选择(select):单选与多选的区别

下拉选择框(select)的绑定逻辑与复选框类似,需根据单选/多选场景选择变量类型:

  • 单选:绑定单个值(如字符串、数字);
  • 多选:绑定数组,并添加multiple属性(按住Ctrl可多选)。
示例:下拉单选(选择城市)
<script setup>
import { ref } from 'vue'  
const city = ref('beijing') // 默认选中北京
const cityList = [ // 城市列表,含value和标签
  { value: 'beijing', label: '北京' },
  { value: 'shanghai', label: '上海' },
  { value: 'guangzhou', label: '广州' }
]
</script>

<template>
  <div class="form-item">
    <label>城市:</label>
    <select v-model="city">
      <!-- 用v-for循环生成选项 -->
      <option v-for="item in cityList" :key="item.value" :value="item.value">
        {{ item.label }}
      </option>
    </select>
    <p class="tip">当前城市:{{ city }}</p>
  </div>
</template>
示例:下拉多选(选择水果)
<script setup>
import { ref } from 'vue'  
const fruits = ref([]) // 存储选中的水果,初始为空数组
const fruitList = ['苹果', '香蕉', '橙子', '草莓']
</script>

<template>
  <div class="form-item">
    <label>喜欢的水果:</label>
    <!-- multiple属性开启多选,v-model绑定数组 -->
    <select v-model="fruits" multiple>
      <option v-for="fruit in fruitList" :key="fruit" :value="fruit">
        {{ fruit }}
      </option>
    </select>
    <p class="tip">已选水果:{{ fruits.join('、') }}</p>
  </div>
</template>

四、v-model修饰符:精准控制输入行为

Vue3为v-model提供了3个实用修饰符,用于解决常见的输入处理问题:

1. .lazy:延迟更新,减少性能消耗

默认情况下,v-model会在每一次输入(如按键、粘贴)时更新状态。对于大型表单(如长文本输入),频繁更新可能影响性能——.lazy修饰符会将更新时机延迟到失去焦点按下回车键时:

<input type="text" v-model.lazy="bio" placeholder="请输入个人简介" />

2. .number:自动转换为数字类型

用户输入的内容默认是字符串类型,若需要处理数字(如年龄、价格),.number修饰符会自动将输入值转换为Number类型:

<input type="text" v-model.number="age" placeholder="请输入年龄" />
<p>年龄类型:{{ typeof age }}</p> <!-- 输出:number -->

3. .trim:自动去除首尾空格

用于处理用户名、昵称等场景,避免用户误输入的空格影响逻辑(如“ Vue3 ”会被转换为“Vue3”):

<input type="text" v-model.trim="nickname" placeholder="请输入昵称" />
<p>昵称长度:{{ nickname.length }}</p> <!-- 去除空格后的长度 -->

课后Quiz:巩固你的理解

问题:请说明v-model在多个复选框下拉多选中的绑定规则,并解释两者的变量类型差异。

答案解析

  • 多个复选框:需将v-model绑定到数组,数组元素为选中项的value值(如hobbies: ['阅读', 'coding']);
  • 下拉多选:同样需要绑定数组,但需为select标签添加multiple属性(如<select v-model="fruits" multiple>);
  • 变量类型差异:两者均绑定数组,但复选框的valueinput标签的value属性指定,下拉多选的valueoption标签的value属性指定。

常见报错与解决方案

1. 报错:v-model cannot be used on input type="checkbox" with multiple values

  • 原因:多个复选框的v-model未绑定数组(比如绑定了布尔值);
  • 解决:将v-model绑定到一个空数组(如const hobbies = ref([]))。

2. 报错:.number modifier requires the input type to be number or text

  • 原因.number修饰符被用在非文本/数字输入类型(如checkboxradio);
  • 解决:仅在input[type="text"]input[type="number"]上使用.number

3. 报错:v-model value must be a ref when using script setup

  • 原因:v-model绑定的变量不是响应式的(未用refreactive包裹);
  • 解决:用ref创建响应式变量(如const username = ref(''))。

参考链接

Vuex 核心概念全解析:构建优雅的 Vue 应用状态管理

Vuex 核心概念全解析:构建优雅的 Vue 应用状态管理

你是否曾在 Vue 项目中遇到过这样的困扰:

  • • 组件间数据传递像“击鼓传花”,层层 props 透传令人头疼
  • • 兄弟组件通信需要借助父组件做“中转站”
  • • 多个组件依赖同一份数据,一处修改处处需要同步

今天我们就来聊聊 Vue 的官方状态管理库——Vuex,帮你彻底解决这些痛点!

一、为什么需要 Vuex?

想象一下,如果每个组件都有自己的“小账本”,当应用复杂时,数据就像散落的珍珠,难以统一管理。Vuex 就是一个“中央账本”,把数据集中存储,让状态变化变得可预测、可追踪。

二、Vuex 五大核心概念详解

1. State(状态)—— 数据仓库

State 是 Vuex 的“数据库”,存储所有需要共享的数据。

const store new Vuex.Store({
  state: {
    user: {
      name'小明',
      age25
    },
    cart: []
  }
})

特点:

  • • 响应式:State 变化,依赖它的组件自动更新
  • • 单一数据源:整个应用只有一个 store
  • • 在组件中使用:this.$store.state.user

2. Getters(计算属性)—— 数据的“加工厂”

Getters 就像 Vue 中的 computed,用于从 state 派生出新数据。

getters: {
  // 获取购物车商品总数
  cartItemCountstate => {
    return state.cart.reduce((total, item) => total + item.quantity0)
  },
  
  // 获取折扣后的价格
  discountedPrice(state) => (productId) => {
    const product = state.products.find(p => p.id === productId)
    return product.price * 0.8
  }
}

使用场景:

  • • 数据过滤、格式化
  • • 复杂计算逻辑封装
  • • 组件中调用:this.$store.getters.cartItemCount

3. Mutations(变更)—— 唯一的状态修改者

Mutations 是修改 state 的唯一途径,每个 mutation 都有一个字符串类型的“事件类型”和一个回调函数。

mutations: {
  // 添加商品到购物车
  ADD_TO_CART(state, product) {
    const existingItem = state.cart.find(item => item.id === product.id)
    if (existingItem) {
      existingItem.quantity++
    } else {
      state.cart.push({ ...product, quantity: 1 })
    }
  },
  
  // 清空购物车
  CLEAR_CART(state) {
    state.cart = []
  }
}

重要原则:

  • • 必须是同步函数
  • • 通过 store.commit('mutation名', payload) 调用
  • • 让每次状态变化都可追踪

4. Actions(动作)—— 处理异步操作的“指挥官”

Actions 可以包含任意异步操作,最终通过提交 mutation 来修改状态。

actions: {
  // 异步获取用户信息
  async fetchUser({ commit }, userId) {
    try {
      const response = await api.getUser(userId)
      commit('SET_USER', response.data// 调用 mutation
      return response.data
    } catch (error) {
      commit('SET_ERROR', error.message)
      throw error
    }
  },
  
  // 组合多个 mutation
  checkout({ commit, state }) {
    // 保存订单
    commit('CREATE_ORDER', state.cart)
    // 清空购物车
    commit('CLEAR_CART')
    // 显示成功提示
    commit('SHOW_MESSAGE''订单提交成功!')
  }
}

与 Mutation 的区别:

  • • Action 提交的是 mutation,而不是直接变更状态
  • • Action 可以包含任意异步操作
  • • 通过 store.dispatch('action名', payload) 调用

5. Modules(模块)—— 大型应用的“分治策略”

当应用复杂时,可以将 store 分割成模块,每个模块拥有自己的 state、mutations、actions、getters。

const userModule = {
  namespaced: true// 开启命名空间
  state: () => ({ userInfo: null }),
  mutations: { /* ... */ },
  actions: { /* ... */ }
}

const productModule = {
  namespaced: true,
  state: () => ({ products: [] }),
  mutations: { /* ... */ }
}

const store new Vuex.Store({
  modules: {
    user: userModule,
    product: productModule
  }
})

模块化的好处:

  • • 避免 state 对象过于臃肿
  • • 让相关功能组织在一起
  • • 调用方式:this.$store.dispatch('user/login', credentials)

三、实战:购物车完整示例

// store.js
export default new Vuex.Store({
  state: {
    cart: [],
    products: []
  },
  
  getters: {
    totalPricestate => {
      return state.cart.reduce((sum, item) => {
        return sum + (item.price * item.quantity)
      }, 0)
    }
  },
  
  mutations: {
    ADD_ITEM(state, product) {
      // ... 添加商品逻辑
    }
  },
  
  actions: {
    async loadProducts({ commit }) {
      const products = await api.getProducts()
      commit('SET_PRODUCTS', products)
    }
  }
})

四、最佳实践建议

  1. 1. 遵循单向数据流

    组件 → Actions → Mutations → State → 组件更新
    
  2. 2. 合理划分模块

    • • 按功能领域划分(user、product、order等)
    • • 大型项目考虑动态注册模块
  3. 3. 使用辅助函数简化代码:

    import { mapState, mapActions } from 'vuex'
    
    export default {
      computed: {
        ...mapState(['user''cart']),
        ...mapGetters(['totalPrice'])
      },
      methods: {
        ...mapActions(['fetchUser''addToCart'])
      }
    }
    
  4. 4. TypeScript 支持
    Vuex 4 对 TypeScript 有更好的类型支持

五、总结

Vuex 的五员大将各司其职:

  • • State:数据存储中心
  • • Getters:数据的计算加工
  • • Mutations:同步修改状态
  • • Actions:处理异步和复杂逻辑
  • • Modules:模块化管理

记住这个简单的比喻:State 是仓库,Getters 是包装部,Mutations 是仓库管理员,Actions 是采购员,Modules 是分公司。

Vuex 的学习曲线可能有点陡峭,但一旦掌握,你将拥有管理复杂应用状态的超能力!

Vue3的v-model如何实现表单双向绑定?

一、为什么需要表单输入绑定?

你有没有过这样的经历?做登录页时,想让用户输入的用户名实时显示在页面上;或者做设置页时,修改开关按钮的状态要同步到后台数据。这时候,如果手动监听每个输入框的事件、手动更新数据,代码会变得非常繁琐——比如:

<input type="text" id="username" oninput="updateUsername(event)">
function updateUsername(e) {
  this.username = e.target.value;
}

不仅要写一堆事件监听,还要处理不同表单元素的差异(比如复选框的checked属性、下拉框的selected属性)。而Vue3的表单输入绑定就是为了解决这个问题——它帮你把“输入→数据→视图”的同步逻辑封装成了一个简单的指令:v-model

二、双向绑定:Vue3的“数据-视图”同步魔法

在讲v-model之前,我们得先搞懂双向绑定的核心逻辑。简单来说,双向绑定就是:

  • 当用户修改视图(比如输入文字、点击复选框),数据自动更新;
  • 当代码修改数据(比如this.username = 'admin'),视图自动同步。

双向绑定的原理流程图

graph TD
A[用户修改视图 输入/点击] --> B[触发对应事件 input/change]
B --> C[更新数据如username 输入内容]
C --> D[Vue响应式系统检测到数据变化]
D --> E[自动更新视图显示]

举个例子:当你在输入框里敲“hello”,Vue会做这几件事:

  1. 监听输入框的input事件,拿到你输入的“hello”;
  2. username数据更新为“hello”;
  3. 响应式系统发现username变了,立刻通知输入框显示“hello”。

三、v-model指令:双向绑定的语法糖

Vue3为双向绑定提供了语法糖——v-model,它把“绑定value+监听事件”的逻辑封装成了一个指令。比如:

<input v-model="username">

等价于:

<input :value="username" @input="username = $event.target.value">

是不是简洁多了?v-model帮你省掉了手动写事件监听的麻烦,而且适用于所有表单元素。

四、v-model在不同表单元素中的应用

v-model不是只能用在文本输入框,它支持所有常见的表单元素,我们逐个看:

1. 文本输入框(input[type="text"])与多行文本(textarea)

  • 文本输入框:直接绑定字符串类型的响应式数据;
  • 多行文本(textarea):不能用插值表达式{{ message }}),必须用v-model

示例代码:

<script setup>
import { ref } from 'vue'
const username = ref('') // 字符串类型
const intro = ref('')    // 多行文本内容
</script>

<template>
  <div>
    <label>用户名:<input type="text" v-model="username"></label>
    <label>个人简介:<textarea v-model="intro" rows="3"></textarea></label>
  </div>
</template>

2. 复选框(input[type="checkbox"])

复选框分两种情况:

  • 单个复选框:绑定布尔值(true/false),表示“是否选中”;
  • 多个复选框:绑定数组,数组元素是选中的value值。

示例代码:

<script setup>
import { ref } from 'vue'
const rememberMe = ref(false) // 单个复选框(布尔值)
const hobbies = ref([])       // 多个复选框(数组)
</script>

<template>
  <div>
    <!-- 单个复选框:记住我 -->
    <label><input type="checkbox" v-model="rememberMe"> 记住我</label>
    
    <!-- 多个复选框:爱好 -->
    <label><input type="checkbox" value="reading" v-model="hobbies"> 阅读</label>
    <label><input type="checkbox" value="sports" v-model="hobbies"> 运动</label>
    <label><input type="checkbox" value="coding" v-model="hobbies"> 编程</label>
  </div>
</template>

3. 单选按钮(input[type="radio"])

单选按钮绑定字符串,值为选中的value属性。

示例代码:

<script setup>
import { ref } from 'vue'
const gender = ref('male') // 默认选中“男”
</script>

<template>
  <div>
    <label><input type="radio" value="male" v-model="gender"></label>
    <label><input type="radio" value="female" v-model="gender"></label>
  </div>
</template>

4. 下拉框(select)

下拉框的v-model绑定选中的value值,optionvalue属性对应选项值。

示例代码:

<script setup>
import { ref } from 'vue'
const city = ref('beijing') // 默认选中“北京”
</script>

<template>
  <div>
    <label>城市:
      <select v-model="city">
        <option value="beijing">北京</option>
        <option value="shanghai">上海</option>
        <option value="guangzhou">广州</option>
      </select>
    </label>
  </div>
</template>

五、数据响应式:双向绑定的底层支撑

你可能会问:“为什么数据变了,视图会自动更新?”这要归功于Vue3的响应式系统

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

Vue3用refreactive创建响应式数据,当数据变化时,Vue会自动追踪依赖(比如模板中用到username的地方),并更新对应的视图。而v-model正是利用了这个系统,让数据和视图双向同步。

比如用ref创建username

const username = ref('')

ref会把username包装成一个响应式对象,当你修改username.value(或通过v-model修改),Vue会立刻知道,并更新视图。

六、实际案例:打造一个注册表单

让我们把前面的知识点整合起来,做一个注册表单,包含用户名、密码、记住我、性别、爱好、城市,提交时打印表单数据。

完整代码(带样式)

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

// 用ref创建表单对象,包含所有字段
const form = ref({
  username: '',
  password: '',
  rememberMe: false,
  gender: 'male',
  hobbies: [],
  city: 'beijing'
})

// 提交处理函数:阻止默认刷新,打印表单数据
const handleSubmit = (e) => {
  e.preventDefault()
  console.log('表单数据:', form.value)
  // 这里可以加发送请求到后台的逻辑,比如axios.post('/api/register', form.value)
}
</script>

<template>
  <div class="register-form">
    <h2>用户注册</h2>
    <form @submit.prevent="handleSubmit">
      <!-- 用户名 -->
      <div class="form-group">
        <label for="username">用户名:</label>
        <input 
          type="text" 
          id="username" 
          v-model="form.username" 
          placeholder="请输入用户名"
          required
        >
      </div>
      
      <!-- 密码 -->
      <div class="form-group">
        <label for="password">密码:</label>
        <input 
          type="password" 
          id="password" 
          v-model="form.password" 
          placeholder="请输入密码"
          required
        >
      </div>
      
      <!-- 记住我 -->
      <div class="form-group">
        <label><input type="checkbox" v-model="form.rememberMe"> 记住登录状态</label>
      </div>
      
      <!-- 性别 -->
      <div class="form-group">
        <label>性别:</label>
        <input type="radio" value="male" v-model="form.gender"><input type="radio" value="female" v-model="form.gender"></div>
      
      <!-- 爱好 -->
      <div class="form-group">
        <label>爱好:</label>
        <input type="checkbox" value="reading" v-model="form.hobbies"> 阅读
        <input type="checkbox" value="sports" v-model="form.hobbies"> 运动
        <input type="checkbox" value="coding" v-model="form.hobbies"> 编程
      </div>
      
      <!-- 城市 -->
      <div class="form-group">
        <label for="city">城市:</label>
        <select id="city" v-model="form.city">
          <option value="beijing">北京</option>
          <option value="shanghai">上海</option>
          <option value="guangzhou">广州</option>
          <option value="shenzhen">深圳</option>
        </select>
      </div>
      
      <!-- 提交按钮 -->
      <button type="submit" class="submit-btn">注册</button>
    </form>
  </div>
</template>

<style scoped>
.register-form {
  max-width: 400px;
  margin: 20px auto;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
}
.form-group {
  margin-bottom: 15px;
}
label {
  display: block;
  margin-bottom: 5px;
}
input, select {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
}
.submit-btn {
  width: 100%;
  padding: 10px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.submit-btn:hover {
  background-color: #3aa776;
}
</style>

代码说明

  1. 表单数据管理:用ref创建form对象,把所有表单字段放在一起,方便管理;
  2. 提交处理:用@submit.prevent阻止表单默认的刷新行为,打印表单数据;
  3. 响应式同步:每个字段用v-model绑定到form对象的属性,输入时自动同步。

七、课后Quiz:巩固你的理解

来做两个小练习,检验一下学习成果~

1. 问题:v-model的语法糖本质是什么?请写出等价的原生绑定代码。

答案解析
v-model是value属性绑定 + input事件监听的语法糖。比如<input v-model="message">等价于:

<input :value="message" @input="message = $event.target.value">
  • :value="message":把数据绑定到输入框的value属性;
  • @input:监听输入事件,把输入内容更新到message

2. 问题:多个复选框如何用v-model实现多选?请写出示例代码。

答案解析
多个复选框需要绑定到数组类型的响应式数据。每个复选框的value对应数组中的元素,选中时加入数组,取消时移除。示例:

<script setup>
import { ref } from 'vue'
const hobbies = ref([]) // 数组类型
</script>

<template>
  <label><input type="checkbox" value="reading" v-model="hobbies"> 阅读</label>
  <label><input type="checkbox" value="sports" v-model="hobbies"> 运动</label>
  <label><input type="checkbox" value="coding" v-model="hobbies"> 编程</label>
</template>

比如选中“阅读”和“编程”,hobbies.value会变成['reading', 'coding']

八、常见报错与解决方案

学习过程中遇到报错别慌,以下是表单绑定常见的3个错误及解决办法:

1. 报错:v-model is not allowed on <input type="file">

  • 原因:文件输入框(type="file")的value只读的,无法通过v-model修改。
  • 解决办法:用ref获取DOM元素,监听change事件拿文件:
    <script setup>
    import { ref } from 'vue'
    const fileInput = ref(null)
    const handleFile = () => {
      const file = fileInput.value.files[0] // 获取选中的文件
      console.log('文件:', file)
    }
    </script>
    
    <template>
      <input type="file" ref="fileInput" @change="handleFile">
    </template>
    

2. 报错:Property "message" was accessed during render but is not defined

  • 原因:模板里用了message,但没在setup中定义响应式数据。
  • 解决办法:用refreactive定义message
    import { ref } from 'vue'
    const message = ref('') // 必须定义!
    

3. 报错:v-model requires a valid Vue instance

  • 原因:可能在非Vue组件中用了v-model(比如纯HTML文件没挂载Vue),或组件未正确注册。
  • 解决办法:确保在Vue组件中使用,并正确挂载应用:
    // main.js
    import { createApp } from 'vue'
    import App from './App.vue'
    createApp(App).mount('#app') // 挂载到#app元素
    

参考链接

官网表单处理文档:vuejs.org/guide/essen…

Vue3响应式API全指南:ref/reactive及衍生API的区别与最佳实践

Vue3基于Proxy重构了响应式系统,提供了一套灵活的API矩阵——核心的ref与reactive、浅响应式的shallowRef/shallowReactive、只读封装的readonly/shallowReadonly。这些API看似功能重叠,实则各有适配场景,误用易导致响应式失效或性能冗余。本文将从特性本质、核心区别、代码示例、适用场景四个维度,系统拆解六大API,帮你精准选型、规避踩坑。

一、核心基础:ref 与 reactive

ref和reactive是Vue3响应式开发的基石,均用于创建响应式数据,但针对的数据类型、访问方式有明确边界,是后续衍生API的设计基础。

1. 核心特性与区别

维度 ref reactive
支持类型 基本类型(string/number/boolean等)+ 引用类型 仅支持引用类型(对象/数组),基本类型传入无响应式效果
实现原理 封装为Ref对象(含.value属性),基本类型靠Object.defineProperty拦截.value,引用类型内部调用reactive 直接通过Proxy拦截对象的属性读取/修改,天然支持嵌套属性响应式
操作方式 脚本中需通过.value访问/修改,模板中自动解包(无需.value) 脚本、模板中均直接操作属性(无.value冗余)
解构特性 解构后丢失响应式,需用toRefs/toRef转换保留 直接解构失效,通过toRefs可将属性转为Ref对象维持响应式
响应式深度 默认深响应式(嵌套对象属性变化触发更新) 默认深响应式(嵌套对象属性变化触发更新)

2. 代码示例

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

// ref使用:基本类型+引用类型
const count = ref(0);
count.value++; // 脚本中必须用.value
console.log(count.value); // 1

const user = ref({ name: '张三', age: 20 });
user.value.age = 21; // 嵌套属性修改,触发响应式

// reactive使用:仅引用类型
const person = reactive({ name: '李四', info: { height: 180 } });
person.name = '王五'; // 直接操作属性
person.info.height = 185; // 嵌套属性深响应式

// 解构处理
const { name, age } = toRefs(user.value); // 保留响应式
name.value = '赵六'; // 触发更新

3. 适用场景

ref:优先用于基本类型响应式(如计数器、开关状态、输入框值);单独维护单个引用类型数据(无需复杂嵌套解构);组合式API中作为默认选择,灵活性更高。

reactive:适用于复杂引用类型(如用户信息、列表数据、表单聚合状态);希望避免.value冗余,追求更直观的属性操作;组件内部状态聚合管理(相关属性封装为一个对象,可读性更强)。

二、性能优化:shallowRef 与 shallowReactive

ref和reactive的深响应式会递归处理所有嵌套属性,对大型对象/第三方实例而言,可能产生不必要的性能开销。浅响应式API仅拦截顶层数据变化,专为性能优化场景设计。

1. 核心特性与区别

维度 shallowRef shallowReactive
支持类型 基本类型 + 引用类型(同ref) 仅引用类型(同reactive)
响应式深度 仅拦截.value的引用替换,嵌套属性变化不触发更新 仅拦截顶层属性变化,嵌套属性变化无响应式效果
更新触发 需替换.value引用(如shallowRef.value = 新对象);嵌套修改需用triggerRef手动触发更新 仅修改顶层属性触发更新,嵌套属性修改完全不拦截
使用成本 嵌套修改需手动触发更新,有额外编码成本 无需手动触发,但需牢记仅顶层响应式,易踩坑

2. 代码示例

import { shallowRef, shallowReactive, triggerRef } from 'vue';

// shallowRef示例
const shallowUser = shallowRef({ name: '张三', info: { age: 20 } });
shallowUser.value.info.age = 21; // 嵌套修改,无响应式
shallowUser.value = { name: '李四', info: { age: 22 } }; // 替换引用,触发更新
triggerRef(shallowUser); // 手动触发更新(嵌套修改后强制同步)

// shallowReactive示例
const shallowPerson = shallowReactive({
  name: '王五',
  info: { height: 180 }
});
shallowPerson.name = '赵六'; // 顶层修改,触发更新
shallowPerson.info.height = 185; // 嵌套修改,无响应式

3. 适用场景

shallowRef:引用类型数据仅需整体替换(如大型图表配置、第三方库实例、不可变数据);明确不需要嵌套属性响应式,追求极致性能(避免递归Proxy开销)。

shallowReactive:复杂对象仅需顶层属性响应式(如表单顶层状态、静态嵌套数据的配置对象);大型对象场景下,规避深响应式的性能损耗,且无需频繁修改嵌套属性。

注意:浅响应式API并非“银弹”,仅在明确不需要深层响应式时使用,否则易导致响应式失效问题,增加调试成本。

三、只读防护:readonly 与 shallowReadonly

在父子组件通信、全局常量管理等场景,需禁止数据被修改,此时可使用只读API。它们会拦截修改操作(开发环境抛警告),同时保留原数据的响应式特性(原数据变化时,只读数据同步更新)。

1. 核心特性与区别

维度 readonly shallowReadonly
支持类型 引用类型为主(基本类型只读无实际意义) 引用类型为主(基本类型只读无实际意义)
只读深度 深只读:顶层+所有嵌套属性均不可修改 浅只读:仅顶层属性不可修改,嵌套属性可正常修改
修改拦截 任何层级修改均被拦截,开发环境抛警告 仅顶层修改被拦截,嵌套修改无拦截、无警告
响应式保留 保留深响应式:原数据任意层级变化,只读数据同步更新 保留浅响应式:原数据变化(无论层级),只读数据同步更新

2. 代码示例

import { readonly, shallowReadonly, reactive } from 'vue';

// 原始响应式数据
const original = reactive({
  name: '张三',
  info: { age: 20 }
});

// readonly示例
const readOnlyData = readonly(original);
readOnlyData.name = '李四'; // 顶层修改,被拦截(抛警告)
readOnlyData.info.age = 21; // 嵌套修改,被拦截(抛警告)
original.name = '李四'; // 原数据变化,只读数据同步更新
console.log(readOnlyData.name); // 李四

// shallowReadonly示例
const shallowReadOnlyData = shallowReadonly(original);
shallowReadOnlyData.name = '王五'; // 顶层修改,被拦截(抛警告)
shallowReadOnlyData.info.age = 22; // 嵌套修改,正常执行(无警告)
console.log(shallowReadOnlyData.info.age); // 22

3. 适用场景

readonly:完全禁止修改的响应式数据(如全局常量配置、接口返回的不可变数据);父子组件通信的Props(Vue内部默认对Props做readonly处理,防止子组件修改父组件状态);需要严格防护数据完整性的场景。

shallowReadonly:仅需禁止顶层属性修改,嵌套属性允许微调(如父组件传递给子组件的复杂对象,子组件可修改嵌套细节但不能替换整体);追求性能优化,避免深只读的递归拦截开销(大型对象场景更明显)。

四、API选型总指南与避坑要点

1. 快速选型流程图

  1. 明确需求:是否需要响应式?→ 不需要则直接用普通变量;需要则进入下一步。
  2. 数据类型:基本类型→只能用ref;引用类型→进入下一步。
  3. 修改权限:需要禁止修改→readonly(深防护)/shallowReadonly(浅防护);允许修改→进入下一步。
  4. 响应式深度:仅需顶层响应式→shallowRef/shallowReactive;需要深层响应式→ref/reactive。
  5. 操作习惯:避免.value→reactive;接受.value或基本类型→ref。

2. 常见坑点规避

  • ref解构丢失响应式:务必用toRefs/toRef转换,而非直接解构。
  • reactive传入基本类型:无响应式效果,需改用ref。
  • 浅响应式嵌套修改失效:shallowRef需用triggerRef手动触发,shallowReactive避免依赖嵌套属性更新。
  • readonly修改原数据:只读API仅拦截对自身的修改,原数据仍可修改,需注意数据溯源。
  • ref嵌套对象修改:无需额外处理,内部已转为reactive,直接修改.value.属性即可。

五、总结

Vue3的响应式API设计围绕“灵活性”与“性能”两大核心:ref/reactive构建基础响应式能力,适配绝大多数日常场景;shallow系列API针对性优化性能,降低大型数据的响应式开销;readonly系列API保障数据安全性,适配只读场景。

核心原则是“按需选型”——无需为简单场景引入复杂API,也无需为性能牺牲开发效率。掌握各API的响应式深度、修改权限、操作方式,就能在项目中精准运用,打造高效、健壮的响应式系统。

Vue3 多主题/明暗模式切换:CSS 变量 + class 覆盖的完整工程方案(附开源代码)

文章简介

之前逛 V 站的时候刷到一个讲 JSON 格式化工具信息泄漏的帖子,有条评论说:“V 站不是人手一个工具站吗?”受此感召,我给自己做了一个工具站。

在搭建工具站的时候有做多主题、亮/暗主题切换,于是有了这篇文章。

备注:工具站当前支持的工具还不多,但已开源,也有部署在 Github page 中,文中介绍的主题切换源码也在其中,感兴趣的朋友可随意取用,后续我也会将自己要用的、感兴趣的工具集成进去。

再备注:此处介绍的多主题、模式切换是在 vue3 中实现,其他环境请感兴趣的朋友自行实现。

工具站源码地址

仓库地址:github.com/the-wind-is…

工具站地址:the-wind-is-rising-dev.github.io/endless-que…

实现原理

主题切换使用了 CSS 变量和 class 覆盖两种特性。

  • class 覆盖特性,后加载的 class 样式会覆盖之前加载的 class 样式,变量也会被覆盖。
  • CSS 变量定义时以 -- 开头,如下:
:root {
  /* ========== 品牌主色调 ========== */
  --brand-primary: #4f46e5; /* 主色:靛蓝 */
  --brand-secondary: #0ea5e9; /* 次要色:天蓝 */
  --brand-accent: #8b5cf6; /* 强调色:紫色 */
}

实现思路

  1. 首先在 :root 伪 class 下定义所有需要用到的变量,然后定义拥有相同变量的不同主题 class
  2. 切换主题时通过 document 直接设置对应主题的 class
  3. 跟随系统主题可以通过监听 (prefers-color-scheme: dark) 来切换

:root 伪 class 定义

源码在 src/themes/index.css 文件内,此处只贴出部分变量

:root {
  /* 背景与表面色 */
  --bg-primary: #f8fafc; /* 主背景 */
  --bg-secondary: #ffffff; /* 次级背景/卡片 */
  --bg-tertiary: #f1f5f9; /* 工具栏/三级背景 */
  --bg-sidebar: #e2e8f0; /* 侧边栏背景 */
}

默认主题明亮模式 class 定义

源码在 src/themes/default/light.css 文件内,此处只贴出部分变量

html.theme-default {
  /* 背景与表面色 */
  --bg-primary: #f8fafc; /* 主背景 */
  --bg-secondary: #ffffff; /* 次级背景/卡片 */
  --bg-tertiary: #f1f5f9; /* 工具栏/三级背景 */
  --bg-sidebar: #e2e8f0; /* 侧边栏背景 */
}

默认主题暗夜模式 class 定义

源码在 src/themes/default/dark.css 文件内,此处只贴出部分变量

html.theme-default.dark {
  /* 背景与表面色 */
  --bg-primary: #0f172a; /* 主背景 */
  --bg-secondary: #1e293b; /* 次级背景/卡片 */
  --bg-tertiary: #334155; /* 工具栏/三级背景 */
  --bg-sidebar: #1e293b; /* 侧边栏背景 */
}

主题切换源码

源码位置:src/themes/theme.ts

切换主题后会将当前主题保存至本地,下次打开站点时会自动加载上次设置的主题

  • 对象定义
    • Theme:用来定义主题信息
    • ThemeModel:用来定义当前模式(明亮/暗夜),以及是否跟随系统
    • ThemeConfig:用来定义当前主题与模式
  • 函数定义
    • isDarkMode:用来判断当前系统是否为暗夜模式
    • applyTheme:用来应用主题与模式
    • initializeTheme:初始化主题,用来加载之前设置的主题与模式
    • getCurrentThemeConfig:获取当前主题配置(主题与模式)
    • addDarkListener:添加暗夜模式监听
    • removeDarkListener:移除暗夜模式监听
    • changeThemeMode:切换主题模式(亮/暗模式)
    • changeTheme:切换主题,默认主题、星空主题、海洋主题等
    • getThemeList:获取支持的主题列表 备注:主题初始化、暗夜模式监听/移除监听函数需要在主页面加载时调用、设置
// 存储主题配置的键
const THEME_STORAGE_KEY = "custom-theme";

// 主题
export interface Theme {
  name: string; // 主题名称
  className: string; // 对应的 CSS 类名
}

// 模式
export interface ThemeModel {
  name: string; // 模式名称
  followSystem: boolean; // 是否跟随系统
  value: "light" | "dark"; // 模式值
}

// 主题配置
export interface ThemeConfig {
  theme: Theme; // 主题
  model: ThemeModel; // 默认主题模式
}

/**
 * 检测当前系统是否启用暗黑模式
 */
function isDarkMode() {
  return (
    window.matchMedia &&
    window.matchMedia("(prefers-color-scheme: dark)").matches
  );
}

/**
 * 应用主题
 * @param themeConfig 主题配置
 */
function applyTheme(themeConfig: ThemeConfig) {
  const className = themeConfig.theme.className;
  const mode = themeConfig.model;

  // 移除旧的主题类
  const classes = document.documentElement.className.split(" ");
  const themeClasses = classes.filter(
    (c) => !c.includes("theme-") && c !== "dark"
  );
  document.documentElement.className = themeClasses.join(" ");

  // 添加新的主题类
  document.documentElement.classList.add(className);
  // 判断是否启用暗黑模式
  if (mode.value === "dark") {
    document.documentElement.classList.add("dark");
  }

  // 存储当前主题配置
  localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(themeConfig));
}

/**
 * 初始化主题
 */
export function initializeTheme() {
  // 获取当前主题配置并应用
  const themeConfig = getCurrentThemeConfig();
  // 初始化当前主题类型
  if (themeConfig.model.followSystem) {
    themeConfig.model.value = isDarkMode() ? "dark" : "light";
  }
  applyTheme(themeConfig);
}

/**
 * 获取当前主题配置
 * @returns 主题配置
 */
export function getCurrentThemeConfig(): ThemeConfig {
  let theme: any = localStorage.getItem(THEME_STORAGE_KEY);
  return theme
    ? JSON.parse(theme)
    : {
        theme: getThemeList()[0], // 默认主题
        model: {
          name: "跟随系统",
          followSystem: true,
          value: isDarkMode() ? "dark" : "light",
        },
      };
}

/**
 * 添加暗黑模式监听
 */
export function addDarkListener() {
  // 监听暗黑模式变化, auto 模式动态切换主题
  window
    .matchMedia("(prefers-color-scheme: dark)")
    .addEventListener("change", (e) => {
      const themeConfig = getCurrentThemeConfig();
      if (!themeConfig.model.followSystem) return;
      changeThemeMode(themeConfig.model);
    });
}

/**
 * 移除暗黑模式监听
 */
export function removeDarkListener() {
  window
    .matchMedia("(prefers-color-scheme: dark)")
    .removeEventListener("change", () => {});
}

/**
 * 切换主题模式
 * @param mode 模式
 */
export function changeThemeMode(themeModel: ThemeModel) {
  const themeConfig = getCurrentThemeConfig();
  themeConfig.model = themeModel;
  if (themeModel.followSystem) {
    themeConfig.model.value = isDarkMode() ? "dark" : "light";
  }
  applyTheme(themeConfig);
}

/**
 * 切换主题
 * @param theme 主题
 */
export function changeTheme(theme: Theme) {
  const themeConfig = getCurrentThemeConfig();
  themeConfig.theme = theme;
  applyTheme(themeConfig);
}

/**
 * 获取主题列表
 * @returns 主题列表
 */
export function getThemeList(): Theme[] {
  return [
    {
      name: "默认",
      className: "theme-default",
    },
    {
      name: "星空",
      className: "theme-starry",
    },
    {
      name: "海洋",
      className: "theme-ocean",
    },
  ];
}

主题、模式手动切换组件

源码位置:src/themes/Theme.vue

组件内会自动加载站点支持的主题与模式,也会根据系统模式变化自动切换状态信息,源码内有注释,此处不赘述

<script setup lang="ts">
import { SettingOutlined, BulbFilled } from "@ant-design/icons-vue";
import { onMounted, onUnmounted, ref } from "vue";
import {
  Theme,
  getThemeList,
  getCurrentThemeConfig,
  changeTheme,
  changeThemeMode,
} from "./theme";

const themeList = ref<Theme[]>(getThemeList());
const currentTheme = ref<Theme>(getCurrentThemeConfig().theme);
const followSystem = ref<boolean>(getCurrentThemeConfig().model.followSystem);
const isLightModel = ref<boolean>(
  getCurrentThemeConfig().model.value == "light"
);

// 切换主题
function onChangeTheme(theme: Theme) {
  currentTheme.value = theme;
  changeTheme(theme);
}

// 切换跟随系统
function onFollowSystemChange() {
  followSystem.value = !followSystem.value;
  let themeConfig = getCurrentThemeConfig();
  themeConfig.model.followSystem = followSystem.value;
  changeThemeMode(themeConfig.model);
}

// 切换主题模式
function onChangeThemeModel(value: boolean) {
  isLightModel.value = value;
  let themeConfig = getCurrentThemeConfig();
  themeConfig.model.value = value ? "light" : "dark";
  changeThemeMode(themeConfig.model);
}

// 添加主题模式监听
let interval: NodeJS.Timeout | null = null;
onMounted(() => {
  // 定时更新主题信息
  interval = setInterval(() => {
    const themeConfig = getCurrentThemeConfig();
    currentTheme.value = themeConfig.theme;
    followSystem.value = themeConfig.model.followSystem;
    isLightModel.value = themeConfig.model.value == "light";
  }, 200);
});

onUnmounted(() => {
  // 移除定时更新主题信息
  interval && clearInterval(interval);
});
</script>

<template>
  <div class="theme-root center">
    <a-dropdown placement="bottom">
      <div class="theme-btn center">
        <SettingOutlined />
      </div>
      <template #overlay>
        <a-menu>
          <div
            class="theme-item"
            v-for="theme in themeList"
            :key="theme.className"
            @click="onChangeTheme(theme)"
          >
            <div class="row">
              <div
                style="width: var(--space-xl); font-size: var(--font-size-sm)"
              >
                <BulbFilled
                  class="sign"
                  v-if="theme.className == currentTheme.className"
                />
              </div>
              <div>{{ theme.name }}-主题</div>
            </div>
          </div>
          <div class="theme-model-item row">
            <a-radio
              v-model:checked="followSystem"
              @click="onFollowSystemChange()"
              >🖥️</a-radio
            >
            <a-switch
              checked-children="☀️"
              un-checked-children="🌑"
              v-model:checked="isLightModel"
              :disabled="followSystem"
              @change="onChangeThemeModel"
            />
          </div>
        </a-menu>
      </template>
    </a-dropdown>
  </div>
</template>

<style scoped>
.theme-root {
  padding: var(--space-lg);
}
.theme-btn {
  padding: var(--space-xs) var(--space-lg);
  font-size: var(--font-size-2xl);
  color: var(--brand-primary);
}
.theme-item {
  padding: var(--space-sm) var(--space-md);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  user-select: none;
  cursor: pointer;

  .sign {
    color: var(--brand-accent);
  }

  &:hover {
    background: var(--brand-secondary);
    color: var(--text-inverse);
  }

  &:active {
    background: var(--brand-primary);
    color: var(--text-inverse);
    .sign {
      color: var(--text-inverse);
    }
  }
}
.theme-model-item {
  padding: var(--space-sm) var(--space-md);
  color: var(--text-primary);
  user-select: none;
}
</style>

vue main.js 文件内容

源码位置:src/main.js

该文件内需引入 "src/themes/index.css" 文件,如下

import { createApp } from "vue";
import Antd from "ant-design-vue";
import "./themes/index.css";
import App from "./App.vue";

createApp(App).use(Antd).mount("#app");

主题初始化、模式监听

源码位置:src/App.vue

src/App.vue 文件是 vue 所有的页面基础,在此处初始化主题信息、监听模式变化比较合适。

  • 初始化主题样式只需要调用 src/themes/theme.ts 内的 initializeTheme() 函数即可
  • 监听模式变化需要在组件挂载之后,在 onMounted 函数内调用 addDarkListener() 函数即可
  • 移除监听需要在组件卸载之后,在 onUnmounted 函数内调用 removeDarkListener() 函数即可

src/App.vue 文件内 script 块部分源码如下

function initialize() {
  // 初始化主题样式
  initializeTheme();
}
initialize();
// 组件生命周期钩子
onMounted(() => {
  initialize();
  // 添加暗黑模式监听器
  addDarkListener();
});
onUnmounted(() => {
  // 移除暗黑模式监听器
  removeDarkListener();
});

仓库地址:

仓库地址:github.com/the-wind-is…

工具站地址:the-wind-is-rising-dev.github.io/endless-que…

这 10 个 Vue3 性能优化技巧很实用,但很多项目都没用上

今天来分享 10 个 Vue3 的性能优化技巧。

核心原则
减少不必要的响应式追踪
避免无谓的 DOM 操作
按需加载资源

咱也不要为了优化而优化!小项目用默认写法完全没问题,优化应在性能瓶颈出现后进行。

这些技巧不难,但都非常关键。 看完你会发现:原来 Vue3 还能这么写。


1. 使用 shallowReactive 替代 reactive

问题
reactive 会让对象里每一层都变得“敏感”——哪怕你只改了最里面的某个小字段,Vue 也会花力气去追踪它。数据一大,性能就变慢。

解决方案
对不需要深层响应的数据,使用 shallowReactive,只让最外层变成响应式的。

示例

import { shallowReactive } from 'vue';

const data = shallowReactive({
  list: [],
  meta: { total: 0 }
});

适用场景
当你从后端拿到一大坨只读数据(比如表格列表、API 响应),且不会修改嵌套属性时。


2. 用 toRefs 解构响应式对象

问题
如果你直接从 reactive 对象里解构变量(如 const { name } = state),这个 name 就变成普通变量了,修改它不会触发页面更新。

解决方案
使用 toRefs 解构,保持每个属性的响应性。

示例

const state = reactive({ name: 'Vue', age: 3 });
const { name, age } = toRefs(state); // name 和 age 依然是响应式的!

好处
在模板中可以直接写 {{ name }},不用写 {{ state.name }},代码更清爽。


3. 优先使用 watchEffect 而非 watch

区别

  • watch:你要手动指定监听谁(比如 watch(count, ...))。
  • watchEffect:你只写逻辑,Vue 自动分析里面用了哪些响应式变量,并监听它们。

示例

watchEffect(() => {
  // Vue 自动发现 count.value 被用了 → 只要 count 变,这段就执行
  localStorage.setItem('count', count.value);
});

适合场景
保存用户输入到本地缓存、根据筛选条件自动请求数据、同步状态到 URL 等。


4. 利用 <Suspense> 优雅处理异步组件

问题
动态加载组件(如通过 import())时,页面可能白屏几秒,用户体验差。

解决方案
<Suspense> 包裹异步组件,显示 loading 提示。

示例

<Suspense>
  <template #default>
    <UserProfile /> <!-- 必须是异步组件 -->
  </template>
  <template #fallback>
    <div>加载中,请稍候…</div>
  </template>
</Suspense>

注意
仅适用于异步组件(即用 defineAsyncComponent() => import(...) 定义的组件)。


5. 使用 <Teleport> 解决模态框层级问题

问题
弹窗写在组件内部,可能被父级的 overflow: hiddenz-index 限制,导致显示不全或盖不住其他内容。

解决方案
<Teleport> 把组件“传送”到 <body> 底部,脱离当前 DOM 树。

示例

<Teleport to="body">
  <Modal v-if="show" />
</Teleport>

类比
就像你在客厅写了个气球,但它实际飘到了天空——不受房间天花板限制。

常用目标to="body" 是最常见用法。


6. 自定义指令封装高频操作(如复制)

问题
复制文本、防抖点击、自动聚焦……这些功能到处都要用,每次都写一堆代码很麻烦。

解决方案
写一个自定义指令,一次定义,处处使用。

示例

app.directive('copy', {
  mounted(el, binding) {
    el.addEventListener('click', () => {
      navigator.clipboard.writeText(binding.value);
    });
  }
});

使用

<button v-copy="'要复制的内容'">点我复制</button>

好处:逻辑集中、复用性强、模板干净。


7. 用 Pinia 插件扩展 store 能力

问题
每个 store 都想加个“重置”功能?手动一个个写太重复。

解决方案
通过 Pinia 插件,一次性给所有 store 添加 $reset() 方法。

正确实现

pinia.use(({ store }) => {
  // 保存初始状态快照(深拷贝)
  const initialState = JSON.parse(JSON.stringify(store.$state));
  store.$reset = () => {
    store.$state = initialState;
  };
});

使用

const userStore = useUserStore();
userStore.$reset(); // 恢复初始状态

适用场景:表单重置、清除缓存、统一日志等。

注意:不能直接用 store.$patch(store.$state),因为 $state 是当前状态,不是初始状态!


8. v-memo 优化大型列表渲染

问题
列表有上千项,哪怕只改了一行的状态,Vue 默认会重新比对整张表,浪费性能。

解决方案
v-memo 告诉 Vue:“只有这些值变了,才需要重新渲染这一行”。

示例

<li v-for="item in list" :key="item.id" v-memo="[item.id, item.status]">
  {{ item.name }} —— 状态:{{ item.status }}
</li>

注意事项

  • 适合内容稳定、更新频率低的大列表。
  • 不要和 <transition-group> 一起用(会失效)。
  • 高频变动的列表慎用,可能适得其反。

v-memo 是 Vue 3.2+ 的功能。


9. 虚拟滚动(Virtual Scrolling)

问题
渲染 10,000 条消息?浏览器直接卡死!

解决方案
只渲染“当前可见区域”的内容,滑动时动态替换,内存和性能都省下来。

推荐库(Vue 3 兼容)

安装 & 示例(以 vueuc 为例)

npm install vueuc
<script setup>
import { VirtualList } from 'vueuc';
</script>

<template>
  <VirtualList :items="messages" :item-height="60" :bench="10">
    <template #default="{ item }">
      <MessageItem :msg="item" />
    </template>
  </VirtualList>
</template>

类比
就像微信聊天记录——你往上滑,旧消息才加载;不滑的时候,几千条其实没真画出来。


10. 路由与组件懒加载 + 图片优化

组件懒加载

原理:不是一打开网页就加载所有页面,而是“用到哪个才加载哪个”。

写法

{ path: '/about', component: () => import('./views/About.vue') }

好处:首屏加载更快,节省流量和内存。

图片优化

  • 用 WebP 格式:比 JPG/PNG 小 30%~50%,清晰度不变(现代浏览器都支持)。
  • 图片懒加载:屏幕外的图先不加载,滑到附近再加载。
  • 关键图预加载:首页 Banner 图提前加载,避免白块。

简单懒加载(原生支持)

<img src="image.jpg" loading="lazy" alt="示例图" />

兼容性提示loading="lazy" 在 Chrome/Firefox/Edge 支持良好,但 Safari 15.4 以下和 IE 不支持。若需兼容旧环境,建议搭配 IntersectionObserver 或第三方库(如 lazysizes)。


总结

技巧 解决什么问题 关键词
shallowReactive 大对象响应式开销大 浅响应
toRefs 解构丢失响应性 保持链接
watchEffect 手动监听麻烦 自动追踪
<Suspense> 异步组件白屏 加载提示
<Teleport> 弹窗被遮挡 脱离 DOM
自定义指令 重复逻辑多 一键复用
Pinia 插件 store 功能重复 全局增强
v-memo 大列表重渲染 按需更新
虚拟滚动 上万条卡顿 只渲染可见
懒加载 + 图片优化 首屏慢、流量大 按需加载

先写出清晰可维护的代码,再根据实际性能问题选择合适的优化手段!

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

解锁Vue新姿势:5种定义全局方法的实用技巧,让你的代码更优雅!

解锁Vue新姿势:5种定义全局方法的实用技巧,让你的代码更优雅!

无论你是Vue新手还是有一定经验的开发者,相信在工作中都遇到过这样的场景:多个组件需要用到同一个工具函数,比如格式化日期、权限验证、HTTP请求等。如果每个组件都单独引入,不仅代码冗余,维护起来也让人头疼。

今天我就为大家分享5种定义全局方法的实用方案,让你轻松解决这个问题!

🤔 为什么需要全局方法?

先来看一个真实的例子。假设你的项目中有三个组件都需要格式化日期:

// UserProfile.vue
methods: {
  formatDate(date) {
    return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
  }
}

// OrderList.vue  
methods: {
  formatDate(date) {
    return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
  }
}

// Dashboard.vue
methods: {
  formatDate(date) {
    return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
  }
}

发现了问题吗?同样的代码写了三遍!  这就是我们需要全局方法的原因。

📝 方案一:Vue.prototype(最经典的方式)

这是Vue 2时代最常用的方法,直接扩展Vue的原型链:

// main.js 或 plugins/global.js
import Vue from 'vue'

// 定义全局方法
Vue.prototype.$formatDate = function(date) {
  const dayjs = require('dayjs')
  return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}

Vue.prototype.$checkPermission = function(permission) {
  const user = this.$store.state.user
  return user.permissions.includes(permission)
}

// 在组件中使用
export default {
  mounted() {
    console.log(this.$formatDate(new Date()))
    if (this.$checkPermission('admin')) {
      // 执行管理员操作
    }
  }
}

优点:

  • • 使用简单,直接通过 this 调用
  • • 广泛支持,兼容性好

缺点:

  • • 污染Vue原型链
  • • 方法多了难以管理
  • • TypeScript支持需要额外声明

🎯 方案二:全局混入(适合通用逻辑)

如果你有一组相关的全局方法,可以考虑使用混入:

// mixins/globalMethods.js
export default {
  methods: {
    $showSuccess(message) {
      this.$message.success(message)
    },
    $showError(error) {
      this.$message.error(error.message || '操作失败')
    },
    $confirmAction(title, content) {
      return this.$confirm(content, title, {
        type'warning'
      })
    }
  }
}

// main.js
import Vue from 'vue'
import GlobalMixin from './mixins/globalMethods'

Vue.mixin(GlobalMixin)

// 组件中使用
export default {
  methods: {
    async deleteItem() {
      try {
        await this.$confirmAction('确认删除''确定删除该记录吗?')
        await api.deleteItem(this.id)
        this.$showSuccess('删除成功')
      } catch (error) {
        this.$showError(error)
      }
    }
  }
}

适合场景:  UI反馈、确认对话框等通用交互逻辑。

🏗️ 方案三:独立模块 + Provide/Inject(Vue 3推荐)

Vue 3提供了更优雅的解决方案:

// utils/globalMethods.js
export const globalMethods = {
  // 防抖函数
  debounce(fn, delay = 300) {
    let timer = null
    return function(...args) {
      if (timer) clearTimeout(timer)
      timer = setTimeout(() => {
        fn.apply(this, args)
      }, delay)
    }
  },
  
  // 深度拷贝
  deepClone(obj) {
    return JSON.parse(JSON.stringify(obj))
  },
  
  // 生成唯一ID
  generateId() {
    return Math.random().toString(36).substr(29)
  }
}

// main.js
import { createApp } from 'vue'
import { globalMethods } from './utils/globalMethods'

const app = createApp(App)

// 通过provide提供给所有组件
app.provide('$global', globalMethods)

// 组件中使用
import { inject } from 'vue'

export default {
  setup() {
    const $global = inject('$global')
    
    const handleInput = $global.debounce((value) => {
      console.log('搜索:', value)
    }, 500)
    
    return { handleInput }
  }
}

这是Vue 3的推荐方式,保持了良好的类型推断和代码组织。

📦 方案四:插件化封装(企业级方案)

对于大型项目,建议采用插件化的方式:

// plugins/globalMethods.js
const GlobalMethodsPlugin = {
  install(app, options) {
    // 添加全局方法
    app.config.globalProperties.$http = async (url, config) => {
      try {
        const response = await fetch(url, config)
        return await response.json()
      } catch (error) {
        console.error('请求失败:', error)
        throw error
      }
    }
    
    app.config.globalProperties.$validate = {
      email(email) {
        return /^[^\s@]+@[^\s@]+.[^\s@]+$/.test(email)
      },
      phone(phone) {
        return /^1[3-9]\d{9}$/.test(phone)
      }
    }
    
    // 添加全局属性
    app.config.globalProperties.$appName = options?.appName || 'My App'
    
    // 添加自定义指令
    app.directive('focus', {
      mounted(el) {
        el.focus()
      }
    })
  }
}

// main.js
import { createApp } from 'vue'
import GlobalMethodsPlugin from './plugins/globalMethods'

const app = createApp(App)
app.use(GlobalMethodsPlugin, {
  appName'企业管理系统'
})

// 组件中使用
export default {
  mounted() {
    // 使用全局方法
    this.$http('/api/users')
    
    // 使用验证
    if (this.$validate.email(this.email)) {
      // 邮箱有效
    }
    
    // 访问全局属性
    console.log('应用名称:'this.$appName)
  }
}

🌟 方案五:Composition API方式(最现代)

如果你使用Vue 3的Composition API,可以这样组织:

// composables/useGlobalMethods.js
import { readonly } from 'vue'

export function useGlobalMethods() {
  // 定义所有全局方法
  const methods = {
    // 金额格式化
    formatCurrency(amount) {
      return '¥' + Number(amount).toFixed(2)
    },
    
    // 文件大小格式化
    formatFileSize(bytes) {
      const units = ['B''KB''MB''GB']
      let size = bytes
      let unitIndex = 0
      
      while (size >= 1024 && unitIndex < units.length - 1) {
        size /= 1024
        unitIndex++
      }
      
      return `${size.toFixed(1)} ${units[unitIndex]}`
    },
    
    // 复制到剪贴板
    async copyToClipboard(text) {
      try {
        await navigator.clipboard.writeText(text)
        return true
      } catch {
        // 降级方案
        const textArea = document.createElement('textarea')
        textArea.value = text
        document.body.appendChild(textArea)
        textArea.select()
        document.execCommand('copy')
        document.body.removeChild(textArea)
        return true
      }
    }
  }
  
  return readonly(methods)
}

// main.js
import { createApp } from 'vue'
import { useGlobalMethods } from './composables/useGlobalMethods'

const app = createApp(App)

// 挂载到全局
app.config.globalProperties.$globalMethods = useGlobalMethods()

// 组件中使用
import { getCurrentInstance } from 'vue'

export default {
  setup() {
    const instance = getCurrentInstance()
    const $global = instance?.appContext.config.globalProperties.$globalMethods
    
    // 或者在setup中直接引入
    // const $global = useGlobalMethods()
    
    return { $global }
  },
  mounted() {
    console.log(this.$global.formatCurrency(1234.56))
  }
}

📊 5种方案对比总结

方案 适用版本 优点 缺点 推荐指数
Vue.prototype Vue 2 简单直接 污染原型链 ⭐⭐⭐
全局混入 Vue 2/3 逻辑分组 可能造成冲突 ⭐⭐⭐
Provide/Inject Vue 3 类型安全 使用稍复杂 ⭐⭐⭐⭐
插件封装 Vue 2/3 功能完整 配置复杂 ⭐⭐⭐⭐⭐
Composition API Vue 3 现代灵活 需要Vue 3 ⭐⭐⭐⭐⭐

💡 最佳实践建议

  1. 1. 按功能分类组织
// 不推荐:把所有方法堆在一个文件
// 推荐:按功能模块拆分
utils/
  ├── formatters/    # 格式化相关
  ├── validators/    # 验证相关  
  ├── http/         # 请求相关
  └── ui/           # UI交互相关
  1. 2. 添加TypeScript支持
// global.d.ts
declare module '@vue/runtime-core' {
  interface ComponentCustomProperties {
    $formatDate(date: Date) => string
    $checkPermission(permission: string) => boolean
  }
}
  1. 3. 注意性能影响
  • • 避免在全局方法中执行重逻辑
  • • 考虑使用懒加载
  • • 及时清理不再使用的方法
  1. 4. 保持方法纯净
  • • 一个方法只做一件事
  • • 做好错误处理
  • • 添加详细的JSDoc注释

🎁 福利:一个实用的全局方法库

我整理了一些常用的全局方法,你可以直接使用:

// utils/essentials.js
export const essentials = {
  // 下载文件
  downloadFile(url, filename) {
    const link = document.createElement('a')
    link.href = url
    link.download = filename
    document.body.appendChild(link)
    link.click()
    document.body.removeChild(link)
  },
  
  // 获取URL参数
  getUrlParam(name) {
    const params = new URLSearchParams(window.location.search)
    return params.get(name)
  },
  
  // 休眠函数
  sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms))
  },
  
  // 对象转FormData
  objectToFormData(obj) {
    const formData = new FormData()
    Object.keys(obj).forEach(key => {
      formData.append(key, obj[key])
    })
    return formData
  }
}

✨ 结语

掌握全局方法的定义和使用,能够让你的Vue项目更加模块化、可维护、高效。不同的方案适用于不同的场景和需求,关键是要根据项目实际情况选择最合适的方式。

记住:好的代码不是写出来的,而是设计出来的。

希望今天的分享对你有帮助!如果你有更好的方案或实践经验,欢迎在评论区留言分享。

vscode 中找settings.json 配置

在VSCode中查找和配置settings.json,最快捷的方式是通过命令面板直接打开,具体操作如下:

一、快速打开settings.json的方法

方法1:命令面板(推荐)

  1. Ctrl + Shift + P(Windows/Linux)或 Cmd + Shift + P(macOS)
  2. 输入"Preferences: Open Settings (JSON)"并回车
  3. 系统会直接打开当前生效的settings.json文件(通常是用户全局设置)

方法2:设置界面跳转

  1. Ctrl + ,打开设置UI界面
  2. 点击右上角的"打开设置(JSON)"图标(文件图标)
  3. 自动跳转到对应的JSON文件

方法3:文件路径访问

  • 用户全局设置:在资源管理器中输入对应路径(Windows:%APPDATA%\Code\User\settings.json
  • 工作区设置:项目根目录下的.vscode/settings.json(需先创建文件夹)

二、配置示例(针对"不换行整理"需求)

在打开的settings.json文件中,添加或修改以下配置:

{
  // 全局格式化设置
  "editor.formatOnSave": true,
  "editor.wordWrap": "off",
  
  // Prettier配置(如使用Prettier)
  "prettier.printWidth": 200,
  "prettier.proseWrap": "never",
  
  // 各语言默认格式化器
  "[javascript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[html]": {
    "editor.defaultFormatter": "vscode.html-language-features",
    "html.format.wrapLineLength": 0
  }
}

三、配置注意事项

  1. 保存生效:修改后按Ctrl + S保存,配置立即生效
  2. JSON格式:确保文件是合法的JSON格式(逗号、引号正确)
  3. 优先级:工作区设置(项目内)会覆盖用户全局设置
  4. 扩展依赖:如果配置了Prettier等格式化器,需确保已安装对应扩展

四、验证配置是否生效

  1. 打开一个代码文件
  2. Ctrl + S保存,观察是否按预期格式化(不自动换行)
  3. 或手动按Shift + Alt + F格式化,检查效果

如果配置后仍自动换行,可能是其他扩展或配置冲突,建议检查:

  • 是否安装了多个格式化扩展
  • 通过命令面板"Format Document With..."查看当前使用的格式化器
  • 在状态栏右下角查看当前文件使用的格式化工具

核心提示:日常使用建议通过命令面板(Ctrl+Shift+P)快速打开,这是最直接且不易出错的方式。配置时注意JSON语法正确性,保存后即可生效。

Vue项目中使用xlsx库解析Excel文件

项目中有个需求是上传Excel实现批量导入,但是解析Excel的需要前端来实现,所以用到了xlsx库

xlsx 库是一个强大的 JavaScript 库,用于处理 Excel 文件,支持:

  • 读取 .xls.xlsx 格式
  • 写入 Excel 文件
  • 解析工作表数据
  • 支持多种数据格式转换

在项目中安装 xlsx 库:

npm install xlsx
# 或者使用 yarn
yarn add xlsx
# 或者使用 pnpm
pnpm add xlsx

核心 API

import * as XLSX from 'xlsx';

// 主要方法
XLSX.read(data, options)      // 读取 Excel 数据
XLSX.readFile(filename)       // 从文件读取
XLSX.utils.sheet_to_json()    // 工作表转 JSON
XLSX.utils.sheet_to_csv()     // 工作表转 CSV
XLSX.utils.sheet_to_html()    // 工作表转 HTML

Excel 文件读取与解析

1. 使用 FileReader 读取文件

在浏览器环境中,我们需要使用 FileReader API 来读取用户上传的文件:

const readExcelFile = (file) => {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    
    reader.onload = (e) => {
      try {
        // 读取文件内容
        const data = new Uint8Array(e.target.result);
        resolve(data);
      } catch (error) {
        reject(new Error('文件读取失败'));
      }
    };
    
    reader.onerror = () => {
      reject(new Error('文件读取失败'));
    };
    
    // 以 ArrayBuffer 格式读取文件
    reader.readAsArrayBuffer(file);
  });
};

2. 解析 Excel 文件

使用 XLSX.read() 方法解析 Excel 数据:

const parseExcelData = (data) => {
  // 读取 Excel 工作簿
  const workbook = XLSX.read(data, { type: 'array' });
  
  // 获取所有工作表名称
  const sheetNames = workbook.SheetNames;
  console.log('工作表名称:', sheetNames);
  
  // 获取第一个工作表
  const firstSheetName = sheetNames[0];
  const worksheet = workbook.Sheets[firstSheetName];
  
  // 将工作表转换为 JSON
  const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
  
  return {
    workbook,
    worksheet,
    jsonData,
    sheetNames
  };
};

3. 不同数据格式的转换

// 转换为 JSON 对象(带表头)
const jsonWithHeaders = XLSX.utils.sheet_to_json(worksheet);

// 转换为 JSON 数组(不带表头)
const jsonArray = XLSX.utils.sheet_to_json(worksheet, { header: 1 });

// 转换为 CSV 字符串
const csvString = XLSX.utils.sheet_to_csv(worksheet);

// 转换为 HTML 表格
const htmlString = XLSX.utils.sheet_to_html(worksheet);

表头验证与数据提取

1. 验证表头格式

在实际应用中,我们通常需要验证 Excel 文件的表头是否符合预期格式:

const validateExcelHeaders = (jsonData, requiredHeaders) => {
  if (jsonData.length === 0) {
    throw new Error('Excel文件为空');
  }
  
  // 获取表头行(第一行)
  const headers = jsonData[0].map(header => 
    header ? header.toString().trim() : ''
  );
  
  // 检查必需表头
  const missingHeaders = requiredHeaders.filter(header =>
    !headers.includes(header)
  );
  
  if (missingHeaders.length > 0) {
    throw new Error(`缺少必需表头: ${missingHeaders.join(', ')}`);
  }
  
  return headers;
};

2. 提取数据行

const extractDataRows = (jsonData, headers) => {
  // 跳过表头行(第一行)
  const dataRows = jsonData.slice(1);
  
  return dataRows.map((row, rowIndex) => {
    const rowData = {};
    
    headers.forEach((header, colIndex) => {
      rowData[header] = row[colIndex] || '';
    });
    
    return {
      ...rowData,
      _rowNumber: rowIndex + 2 // Excel 行号(从1开始,表头为第1行)
    };
  }).filter(row => {
    // 过滤空行(所有单元格都为空)
    return Object.values(row).some(value => 
      value !== '' && value !== undefined && value !== null
    );
  });
};

3. 数据验证与清洗

const validateAndCleanData = (dataRows, validationRules) => {
  const errors = [];
  const cleanedData = [];
  
  dataRows.forEach((row, index) => {
    const rowErrors = [];
    
    // 检查每个字段
    Object.keys(validationRules).forEach(field => {
      const value = row[field];
      const rules = validationRules[field];
      
      // 必填验证
      if (rules.required && (!value || value.toString().trim() === '')) {
        rowErrors.push(`${field} 不能为空`);
      }
      
      // 类型验证
      if (value && rules.type) {
        if (rules.type === 'number' && isNaN(Number(value))) {
          rowErrors.push(`${field} 必须是数字`);
        }
        if (rules.type === 'email' && !isValidEmail(value)) {
          rowErrors.push(`${field} 格式不正确`);
        }
      }
      
      // 枚举值验证
      if (value && rules.enum && !rules.enum.includes(value)) {
        rowErrors.push(`${field} 必须是以下值之一: ${rules.enum.join(', ')}`);
      }
    });
    
    if (rowErrors.length === 0) {
      cleanedData.push(row);
    } else {
      errors.push({
        row: row._rowNumber,
        errors: rowErrors
      });
    }
  });
  
  return { cleanedData, errors };
};
❌