普通视图

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

vite+vue2 动态路由加载方法实现

2026年4月16日 17:33

最近在改老项目,将webpack迁移到vite提高下速度 首先来看下默认静态加载路由,我们只需要在router/index.js直接配置好就可以了

dynamicRoutes_01.png

当然默认的情况 component: () => import('../views/HomeView.vue') 是这样的如果需要用@替代..需要在在vite.config.js中增加下配置

resolve: {
    alias: {
        '@': path.resolve(__dirname, 'src')
    }
},

dynamicRoutes_02.png

在webpack中动态加载使用如下,就可以了

export const loadView = (view) => {
  if (process.env.NODE_ENV === 'development') {
    return (resolve) => require([`@/views/${view}`], resolve)
  } else {
    // 使用 import 实现生产环境的路由懒加载
    return () => import(`@/views/${view}`)
  }
}

但是在vite中语法就变了,require() 是 CommonJS 语法,Vite 不支持,需要用import.meta.glob来实现,下面是相对路径使用,相对路径使用要注意当前方法引入对应根目录的层级

//代码文件顶部增加
const modules = import.meta.glob('../views/**/*')
//然后定义loadView 方法
export const loadView = (view) => {
    return modules[`../views/${viewPath}.vue`]  // 使用相对路径
}

在这里比较推荐使用相对路径来实现

//代码文件顶部增加
const modules = import.meta.glob('/src/views/**/*')
export const loadView = (view) => {
    return modules[`/src/views/${viewPath}.vue`]  // 使用绝对路径
}

说明一下如果从后端获取的动态组件路径是带.vue文件名字的可以忽略

最后又完善了一下增加了一些模糊匹配规则

/src/views/${viewName}.vue
/src/views/${viewName}/index.vue
/src/views/${viewName}/${viewName}.vue

完整的loadView 方法

export const loadView = (view) => {
    // 统一处理:无论后端是否带 .vue,都确保有后缀
    const viewName = view.replace(/\.vue$/, '')
    const possiblePaths = [
        `/src/views/${viewName}.vue`,
        `/src/views/${viewName}/index.vue`,
        `/src/views/${viewName}/${viewName}.vue`,
    ]

    for (const path of possiblePaths) {
        // const loader = modules[path]
        if (modules[path]) {
            console.log('✅ 匹配组件:', path)
            return modules[path]
        }
    }
    console.error(`未找到页面组件: ${viewName}`)
    console.log('可用页面组件:', Object.keys(modules))
    return null
}

演示demo

dynamicRoutes.gif

原文 www.liweiliang.com/1204.html

Vue<前端页面装修组件>

2026年4月16日 17:25

一个基于 Vue 2 和 Ant Design Vue 1.x 的可视化组件装修工具,支持拖拽排序、属性编辑和实时预览。

gif_1.gif

功能特性

  • 📱 移动端预览:支持自定义尺寸的移动端预览界面
  • 🎨 组件编辑:实时编辑组件属性,所见即所得
  • 📦 组件库:可扩展的组件库系统
  • 🎯 拖拽排序:支持组件的拖拽排序功能
  • 🔧 尺寸调整:可自定义预览区域尺寸,保持等比例缩放

技术栈

  • Vue 2
  • Ant Design Vue 1.x
  • vuedraggable

目录结构

src/
├── components/
│   └── DecorationBuilder/          # 装修工具主目录
│       ├── bases/                  # 基础组件
│       │   ├── Editor/             # 属性编辑器
│       │   ├── Preview/            # 移动端预览组件
│       │   │   ├── components/     # 预览组件的子组件
│       │   │   │   ├── BrowserToolbar/ # 浏览器工具栏
│       │   │   │   └── SizeEditor/     # 尺寸编辑器
│       │   └── Selector/           # 组件选择器
│       ├── config/                 # 配置文件
│       │   ├── componentTypes.js   # 组件类型定义
│       │   └── settings.js         # 全局设置
│       ├── widgets/                # 自定义组件
│       │   ├── Banner/             # 轮播图组件
│       │   ├── News/               # 新闻列表组件
│       │   └── index.js            # 组件注册表
│       └── index.vue               # 装修工具主入口
└── utils/
    ├── componentUtils.js           # 组件相关工具函数
    └── index.js                    # 通用工具函数
graph TD
    A[DecorationBuilder] --> B[bases]
    A --> C[config]
    A --> D[widgets]
    A --> E[index.vue]
    
    B --> F[Editor]
    B --> G[Preview]
    B --> H[Selector]
    
    G --> I[components]
    I --> J[BrowserToolbar]
    I --> K[SizeEditor]
    
    C --> L[componentTypes.js]
    C --> M[settings.js]
    
    D --> N[Banner]
    D --> O[News]
    D --> P[index.js]

核心组件说明

1. DecorationBuilder (主入口)

  • 文件:src/components/DecorationBuilder/index.vue
  • 功能:整合预览、编辑器和选择器组件,管理组件数据和交互逻辑

2. Preview (预览组件)

image.png

  • 文件:src/components/DecorationBuilder/bases/Preview/index.vue
  • 功能:展示移动端预览界面,支持组件拖拽排序
  • 子组件:
    • BrowserToolbar:浏览器工具栏,包含预览、添加组件、发布等功能
    • SizeEditor:尺寸编辑器,用于调整预览区域大小

3. Editor (属性编辑器)

image.png

  • 文件:src/components/DecorationBuilder/bases/Editor/index.vue
  • 功能:动态加载组件编辑器,允许编辑组件属性

4. Selector (组件选择器)

image.png

  • 文件:src/components/DecorationBuilder/bases/Selector/index.vue
  • 功能:展示所有可用组件,支持选择组件添加到预览区

配置文件说明

componentTypes.js

  • 定义组件类型枚举和元数据
  • 包含组件的显示名称、描述、图标等信息
export const COMPONENT_TYPES = {
  BANNER: 'banner',          // 轮播图
  NEWS_LIST: 'news-list'     // 新闻列表
}

export const COMPONENT_METADATA = {
  [COMPONENT_TYPES.BANNER]: {
    name: '轮播图',
    description: '支持多张图片轮播展示',
    icon: 'picture',
    category: '基础组件'
  }
  // ...
}

settings.js

  • 全局配置文件,包含预览设置等
export const PREVIEW_SETTINGS = {
  MOBILE_WIDTH: 375,         // 默认移动端宽度
  MOBILE_HEIGHT: 812         // 默认移动端高度
}

组件工具函数

文件:src/utils/componentUtils.js

主要功能:

  • getComponentMetadata():获取组件元数据
  • getAllComponentTypes():获取所有组件类型
  • getWidgetConfig():获取组件配置
  • getWidgetDefaultProps():获取组件默认属性
  • getWidgetPreview():获取预览组件
  • getWidgetEditor():获取编辑组件

组件映射关系

组件类型 组件名称 预览组件 编辑组件
banner 轮播图 BannerPreview BannerEditor
news-list 新闻列表 NewsPreview NewsEditor

数据格式说明

标准组件数据格式

[
  {
    "id": "1234567890",
    "type": "banner",
    "props": {
      "images": [
        { "url": "https://example.com/image1.jpg", "link": "" },
        { "url": "https://example.com/image2.jpg", "link": "" }
      ],
      "autoPlay": true,
      "interval": 3000,
      "dots": true,
      "arrows": false
    }
  },
  {
    "id": "0987654321",
    "type": "news-list",
    "props": {
      "title": "最新资讯",
      "news": [
        { "id": 1, "title": "新闻标题1", "date": "2026-04-16", "link": "" },
        { "id": 2, "title": "新闻标题2", "date": "2026-04-17", "link": "" }
      ],
      "showDate": true,
      "showArrow": true,
      "maxItems": 5
    }
  }
]

字段说明

  • id:组件唯一标识符,由系统自动生成
  • type:组件类型,对应 COMPONENT_TYPES 中的值
  • props:组件属性,包含所有可配置的参数

数据来源

  1. 默认数据:组件的初始默认属性来自各组件的 index.js 文件中的 defaultProps
  2. 用户配置:用户在编辑器中修改的属性会覆盖默认属性
  3. 保存/发布:最终的组件数据会以标准JSON格式保存或发布

使用方式

  • 前端渲染:通过组件类型动态加载对应的预览组件,并传入props进行渲染
  • 后端存储:可以将JSON数据存储到后端数据库中
  • 页面加载:从后端获取JSON数据后,可以直接传递给DecorationBuilder组件进行渲染

添加新组件指南

以添加一个"轮播的通知公告"组件为例:

1. 创建组件目录和文件

src/components/DecorationBuilder/widgets/ 下创建新组件目录:

NotificationBanner/
├── index.js           # 组件配置文件
├── preview.vue        # 预览组件
└── editor.vue         # 编辑组件

2. 编写组件配置文件 (index.js)

import NotificationBannerPreview from './preview.vue'
import NotificationBannerEditor from './editor.vue'
import { COMPONENT_TYPES } from '../../config/componentTypes'

export default {
  type: COMPONENT_TYPES.NOTIFICATION_BANNER,  // 需要在componentTypes.js中定义
  Preview: NotificationBannerPreview,
  Editor: NotificationBannerEditor,
  defaultProps: {
    // 组件默认属性
    notifications: [
      { id: 1, content: '通知内容1' },
      { id: 2, content: '通知内容2' }
    ],
    autoPlay: true,
    interval: 2000
  }
}

3. 编写预览组件 (preview.vue)

<template>
  <div class="notification-banner">
    <!-- 轮播的通知内容 -->
  </div>
</template>

<script>
export default {
  name: 'NotificationBannerPreview',
  props: {
    component: {
      type: Object,
      required: true
    }
  }
}
</script>

<style scoped>
/* 组件样式 */
</style>

4. 编写编辑组件 (editor.vue)

<template>
  <div class="notification-banner-editor">
    <!-- 属性编辑表单 -->
  </div>
</template>

<script>
export default {
  name: 'NotificationBannerEditor',
  props: {
    component: {
      type: Object,
      required: true
    }
  }
}
</script>

5. 注册组件类型

src/components/DecorationBuilder/config/componentTypes.js 中添加组件类型:

export const COMPONENT_TYPES = {
  // ... 现有类型
  NOTIFICATION_BANNER: 'notification-banner'  // 新增通知公告类型
}

export const COMPONENT_METADATA = {
  // ... 现有元数据
  [COMPONENT_TYPES.NOTIFICATION_BANNER]: {
    name: '通知公告',
    description: '轮播展示通知内容',
    icon: 'bell',
    category: '基础组件'
  }
}

6. 注册组件

src/components/DecorationBuilder/widgets/index.js 中导入并注册新组件:

import BannerComponent from './Banner'
import NewsComponent from './News'
import NotificationBannerComponent from './NotificationBanner'  // 导入新组件

export const widgets = [
  BannerComponent,
  NewsComponent,
  NotificationBannerComponent  // 注册新组件
]

注意事项

  1. 所有组件必须遵循相同的命名和目录结构
  2. 新组件必须在 componentTypes.js 中定义类型和元数据
  3. 预览组件和编辑组件必须正确导出
  4. 默认属性应该在组件的 index.js 中定义

Vue3 KeepAlive 深度揭秘:组件缓存的魔法是如何实现的?

2026年4月16日 11:29

Vue3 KeepAlive 深度揭秘:组件缓存的魔法是如何实现的?

本文将带你深入 Vue3 内核,从源码层面彻底搞懂 KeepAlive 组件的缓存机制、LRU 淘汰策略以及组件"失活"与"激活"的底层实现原理。

📋 文章导航


1. 为什么需要 KeepAlive?

1.1 实际业务场景

在开发后台管理系统或多标签页应用时,我们经常会遇到这样的需求:

  • 表单页面:用户填写了一半的表单,切换到其他页面查看资料,返回时期望表单数据还在
  • 列表页面:滚动到第 N 页,查看详情后返回,期望回到原来的滚动位置
  • 地图应用:地图已经缩放和平移到特定位置,切换页面后返回保持原状

1.2 没有 KeepAlive 的问题

<template>
  <button @click="currentView = 'A'">页面A</button>
  <button @click="currentView = 'B'">页面B</button>

  <!-- 普通动态组件切换 -->
  <component :is="currentView" />
</template>

<script setup>
import { ref } from "vue";
import ViewA from "./ViewA.vue";
import ViewB from "./ViewB.vue";

const currentView = ref("ViewA");
</script>

问题:当从 A 切换到 B 时,A 组件会被完全销毁(触发 onUnmounted),状态全部丢失。再切回 A 时,组件重新创建,所有数据重置。

1.3 KeepAlive 的解决方案

KeepAlive 通过组件级缓存完美解决这个问题:

  • 组件切换时不会销毁,而是进入"失活"状态
  • 组件实例、响应式数据、DOM 状态全部保留
  • 切换回来时"激活",瞬间恢复,无需重新渲染

2. KeepAlive 基础使用

2.1 基本用法

<template>
  <button
    v-for="tab in tabs"
    :key="tab"
    @click="currentTab = tab"
    :class="{ active: currentTab === tab }"
  >
    {{ tab }}
  </button>

  <!-- 使用 KeepAlive 包裹动态组件 -->
  <KeepAlive>
    <component :is="currentTab" />
  </KeepAlive>
</template>

<script setup>
import { ref } from "vue";
import Home from "./Home.vue";
import Posts from "./Posts.vue";
import Archive from "./Archive.vue";

const currentTab = ref("Home");
const tabs = ["Home", "Posts", "Archive"];
</script>

2.2 重要限制

⚠️ KeepAlive 只能缓存单个直接子节点

<!-- ❌ 错误:多个根节点 -->
<KeepAlive>
  <CompA />
  <CompB />
</KeepAlive>

<!-- ✅ 正确:使用动态组件包裹 -->
<KeepAlive>
  <component :is="activeComponent" />
</KeepAlive>

<!-- ✅ 正确:使用 v-if 切换单个组件 -->
<KeepAlive>
  <CompA v-if="showA" />
  <CompB v-else />
</KeepAlive>

3. 核心属性详解

3.1 属性一览表

属性 类型 说明
include string | RegExp | Array 只有名称匹配的组件会被缓存
exclude string | RegExp | Array 任何名称匹配的组件都不会被缓存
max number | string 最多可以缓存多少组件实例

3.2 include - 白名单缓存

<!-- 字符串形式(逗号分隔) -->
<KeepAlive include="Home,Posts">
  <component :is="currentTab" />
</KeepAlive>

<!-- 数组形式 -->
<KeepAlive :include="['Home', 'Posts']">
  <component :is="currentTab" />
</KeepAlive>

<!-- 正则表达式 -->
<KeepAlive :include="/^User/">
  <component :is="currentTab" />
</KeepAlive>

匹配规则:与组件的 name 选项进行匹配

<script>
export default {
  name: "Home", // 这个名字用于 include/exclude 匹配
  // ...
};
</script>

<!-- 或者使用 script setup -->
<script setup>
defineOptions({
  name: "Home",
});
</script>

3.3 exclude - 黑名单排除

<!-- 不缓存 Archive 组件 -->
<KeepAlive exclude="Archive">
  <component :is="currentTab" />
</KeepAlive>

<!-- 排除多个 -->
<KeepAlive :exclude="['Archive', 'Settings']">
  <component :is="currentTab" />
</KeepAlive>

3.4 max - LRU 缓存淘汰

<KeepAlive :max="5">
  <component :is="currentTab" />
</KeepAlive>

LRU (Least Recently Used) 算法

  1. 设置最大缓存数为 5
  2. 依次访问 A → B → C → D → E,全部缓存
  3. 访问 F 时,缓存已满,淘汰最久未使用的 A
  4. 访问 B,B 变为最近使用
  5. 访问 G,淘汰 C(现在 C 是最久未使用的)
缓存状态变化示意:

初始: []
访问A: [A]
访问B: [A, B]
访问C: [A, B, C]
访问D: [A, B, C, D]
访问E: [A, B, C, D, E]  ← 达到 max
访问F: [B, C, D, E, F]A 被淘汰
访问B: [C, D, E, F, B]B 移到最近使用
访问G: [D, E, F, B, G]C 被淘汰

4. 专属生命周期钩子

被 KeepAlive 缓存的组件会新增两个生命周期钩子:

4.1 生命周期对比

普通组件:          KeepAlive 缓存组件:
   onMounted           onMounted (首次)
       ↓                   ↓
   onUnmounted      onActivated (每次激活)
                          ↓
                     onDeactivated (失活)
                          ↓
                     onActivated (再次激活)
                          ↓
                     onDeactivated
                          ↓
                     onUnmounted (真正销毁时)

4.2 钩子函数详解

<script setup>
import { onMounted, onUnmounted, onActivated, onDeactivated } from "vue";

// 首次挂载时触发(仅一次)
onMounted(() => {
  console.log("组件首次挂载");
  // 适合执行一次性初始化:建立 WebSocket 连接、获取基础配置等
});

// 每次从缓存激活时触发
onActivated(() => {
  console.log("组件被激活");
  // 适合执行:恢复定时器、重新获取最新数据、恢复滚动位置等
});

// 组件被缓存时触发
onDeactivated(() => {
  console.log("组件被失活(进入缓存)");
  // 适合执行:暂停定时器、保存临时状态等
});

// 组件真正被销毁时触发(仅一次)
onUnmounted(() => {
  console.log("组件被销毁");
  // 清理工作:关闭 WebSocket、清除全局事件监听等
});
</script>

4.3 实际应用示例

<script setup>
import { ref, onActivated, onDeactivated } from "vue";

const scrollTop = ref(0);
const timer = ref(null);
const listData = ref([]);

// 激活时恢复状态
onActivated(() => {
  // 恢复滚动位置
  const container = document.querySelector(".list-container");
  if (container) {
    container.scrollTop = scrollTop.value;
  }

  // 重启定时刷新
  timer.value = setInterval(fetchLatestData, 5000);

  // 重新获取最新数据(可选)
  fetchLatestData();
});

// 失活时保存状态
onDeactivated(() => {
  // 保存滚动位置
  const container = document.querySelector(".list-container");
  if (container) {
    scrollTop.value = container.scrollTop;
  }

  // 暂停定时刷新
  if (timer.value) {
    clearInterval(timer.value);
    timer.value = null;
  }
});

async function fetchLatestData() {
  // 获取最新数据...
}
</script>

5. 底层实现原理

5.1 核心问题拆解

KeepAlive 要实现组件缓存,必须解决三个核心问题:

问题 解决方案
如何保存组件状态? 使用 Map 缓存组件的 VNode
如何识别缓存组件? 通过 shapeFlag 标记组件状态
如何让组件"隐藏"而不是销毁? 使用 move 函数将 DOM 移入隐藏容器

5.2 组件状态标记

Vue3 使用 shapeFlag 来标记 VNode 的类型和状态:

// 组件需要被缓存(进入缓存流程)
const COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8; // 256

// 组件已被缓存(从缓存恢复)
const COMPONENT_KEPT_ALIVE = 1 << 9; // 512

标记的作用

  1. COMPONENT_SHOULD_KEEP_ALIVE:告诉渲染器这个组件不应该被销毁,而是执行失活流程
  2. COMPONENT_KEPT_ALIVE:告诉渲染器这个组件来自缓存,不需要重新创建实例

5.3 缓存与隐藏机制

┌─────────────────────────────────────────────────────────────┐
│                      KeepAlive 组件                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   ┌─────────────────┐      ┌──────────────────────────┐    │
│   │   cache (Map)   │      │   storageContainer       │    │
│   │                 │      │   (隐藏的 div 容器)       │    │
│   │  key → VNode    │      │                          │    │
│   │  key → VNode    │      │  ┌──────────────────┐    │    │
│   │  key → VNode    │      │  │  被缓存的 DOM    │    │    │
│   │                 │      │  │  ┌──┐ ┌──┐ ┌──┐  │    │    │
│   └─────────────────┘      │  │  │A │ │B │ │C │  │    │    │
│                            │  │  └──┘ └──┘ └──┘  │    │    │
│   ┌─────────────────┐      │  └──────────────────┘    │    │
│   │   keys (Set)    │      │                          │    │
│   │                 │      └──────────────────────────┘    │
│   │  [A, B, C]      │                                      │
│   │  ↑  LRU 顺序    │                                      │
│   └─────────────────┘                                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

5.4 流程图解

首次渲染组件 A:
    │
    ▼
┌─────────────────┐
│  检查 cache     │
│  是否已有 A?   │
└────────┬────────┘
         │ 否
         ▼
┌─────────────────┐
│  正常创建组件 A  │
│  渲染 DOM        │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  存入 cache     │
│  A → VNode      │
│  keys.add(A)    │
└─────────────────┘

切换到组件 B:
    │
    ▼
┌─────────────────┐
│  组件 A 失活     │
│  (不是销毁!)    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  调用 _deActivate│
│  将 A 的 DOM    │
│  移入隐藏容器    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  渲染组件 B      │
└─────────────────┘

切回组件 A:
    │
    ▼
┌─────────────────┐
│  检查 cache     │
│  是否已有 A?   │
└────────┬────────┘
         │ 是
         ▼
┌─────────────────┐
│  命中缓存!      │
│  复用 VNode     │
│  复用组件实例    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  调用 _activate │
│  将 A 的 DOM    │
│  从隐藏容器移出  │
│  插入页面        │
└─────────────────┘
         │
         ▼
┌─────────────────┐
│  触发 onActivated│
└─────────────────┘

6. 源码深度解析

6.1 完整源码注释版

// packages/runtime-core/src/components/KeepAlive.ts

import {
  type VNode,
  type ComponentInternalInstance,
  type SetupContext,
  type RendererInternals,
  type RendererElement,
  type RendererNode,
  ShapeFlags,
  currentInstance,
  unmountComponent,
  callWithAsyncErrorHandling,
  onBeforeUnmount,
  type Slots,
  type FunctionalComponent,
  type Component,
  type ComponentOptions,
  type VNodeNormalizedChildren,
  type VNodeChild,
  setTransitionHooks,
  type TransitionHooks,
} from "@vue/runtime-core";

export interface KeepAliveProps {
  include?: MatchPattern;
  exclude?: MatchPattern;
  max?: number | string;
}

type MatchPattern = string | RegExp | (string | RegExp)[];

export const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,

  // 标记这是一个 KeepAlive 组件
  __isKeepAlive: true,

  props: {
    include: [String, RegExp, Array] as PropType<MatchPattern>,
    exclude: [String, RegExp, Array] as PropType<MatchPattern>,
    max: [String, Number],
  },

  setup(props: KeepAliveProps, { slots }: SetupContext) {
    // ==================== 1. 获取组件实例和渲染器方法 ====================
    const instance = currentInstance!;

    // 从组件实例中获取渲染器注入的方法
    // move: 移动 DOM 节点
    // createElement: 创建 DOM 元素
    const { move, createElement } = instance.ctx.renderer as RendererInternals<
      RendererNode,
      RendererElement
    >;

    // ==================== 2. 创建存储容器 ====================
    // storageContainer 是一个普通的 div,用于存放被失活的组件 DOM
    const storageContainer = createElement("div");

    // ==================== 3. 定义激活/失活方法 ====================

    /**
     * 失活组件:将组件的 DOM 移动到隐藏容器
     * @param vnode 被失活的组件 VNode
     * @param container 当前容器(未使用,保持一致性)
     * @param anchor 锚点(未使用)
     */
    instance.ctx.deactivate = (vnode: VNode) => {
      move(vnode, storageContainer, null, MoveType.LEAVE);
    };

    /**
     * 激活组件:将组件的 DOM 从隐藏容器移回页面
     * @param vnode 被激活的组件 VNode
     * @param container 目标容器
     * @param anchor 锚点位置
     * @param isSVG 是否是 SVG
     * @param optimized 是否优化模式
     */
    instance.ctx.activate = (
      vnode: VNode,
      container: RendererElement,
      anchor: RendererNode | null,
      isSVG: boolean,
      optimized: boolean,
    ) => {
      const vnodeComponent = vnode.component!;

      // 将 DOM 移回页面
      move(vnode, container, anchor, MoveType.ENTER, isSVG);

      // 处理过渡动画
      if (vnodeComponent.da) {
        // 延迟激活(等待延迟显示动画完成)
        queuePostRenderEffect(() => {
          vnodeComponent.da!(vnodeComponent.vnode);
        }, instance.suspense);
      }
    };

    // ==================== 4. 缓存相关变量 ====================
    const cache: Map<string, VNode> = new Map(); // 缓存容器:key -> VNode
    const keys: Set<string> = new Set(); // 记录缓存顺序,用于 LRU
    let current: VNode | null = null; // 当前正在渲染的组件
    let pendingCacheKey: string | null = null; // 待缓存的 key

    // ==================== 5. 缓存清理函数 ====================

    /**
     * 根据 key 淘汰缓存条目
     * 当缓存超过 max 时,淘汰最久未使用的组件
     */
    function pruneCacheEntry(key: string) {
      const cached = cache.get(key);
      if (!cached) return;

      // 如果当前正在渲染的组件不是要淘汰的,触发 deactivated 钩子
      if (current !== cached) {
        const comp = cached.component!;
        if (!comp.isDeactivated) {
          // 调用 deactivated 生命周期钩子
          callWithAsyncErrorHandling(
            comp.type.deactivated,
            comp,
            ErrorCodes.COMPONENT_DEACTIVATED,
          );
          comp.isDeactivated = true;
        }
      }

      // 从缓存中移除
      cache.delete(key);
      keys.delete(key);
    }

    /**
     * 清空所有缓存
     */
    function pruneCache() {
      cache.forEach((cached, key) => {
        pruneCacheEntry(key);
      });
    }

    // ==================== 6. 监听 props 变化 ====================

    // 当 include/exclude 变化时,清理不再匹配的缓存
    watch(
      () => [props.include, props.exclude],
      ([include, exclude]) => {
        // 清理不再满足 include/exclude 条件的缓存
        cache.forEach((vnode, key) => {
          const name = getName(vnode);
          if (
            name &&
            (!include || !matches(include, name)) &&
            exclude &&
            matches(exclude, name)
          ) {
            pruneCacheEntry(key);
          }
        });
      },
      { flush: "post", deep: true },
    );

    // ==================== 7. 组件卸载时清理 ====================

    onBeforeUnmount(() => {
      cache.forEach((vnode) => {
        const { shapeFlag, component } = vnode;
        // 如果组件还在激活状态,需要手动卸载
        if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
          unmountComponent(component!);
        }
      });
    });

    // ==================== 8. 核心渲染逻辑 ====================

    return () => {
      // 获取默认插槽的第一个子节点
      const rawVNode = slots.default && slots.default();

      // 如果没有子节点,直接返回
      if (!rawVNode || rawVNode.length !== 1) {
        if (__DEV__ && rawVNode && rawVNode.length > 1) {
          warn(`KeepAlive should contain exactly one component child.`);
        }
        current = null;
        return rawVNode;
      }

      // 获取内部真实组件(处理 Teleport 等包裹情况)
      const vnode = getInnerChild(rawVNode[0]);
      const comp = vnode.type as Component;

      // 获取组件名称用于 include/exclude 匹配
      const name = getName(vnode);

      // 检查是否应该缓存
      const shouldCache = !(
        name &&
        ((props.include && !matches(props.include, name)) ||
          (props.exclude && matches(props.exclude, name)))
      );

      // 获取缓存 key
      const key = vnode.key == null ? comp : vnode.key;
      const cachedVNode = cache.get(key);

      // ==================== 8.1 命中缓存 ====================
      if (cachedVNode) {
        // 复用缓存的组件实例
        vnode.component = cachedVNode.component;
        vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE;

        // 更新 LRU 顺序:先删除再添加,确保在 Set 末尾(最近使用)
        keys.delete(key);
        keys.add(key);
      }
      // ==================== 8.2 未命中缓存 ====================
      else if (shouldCache) {
        // 存入新缓存
        cache.set(key, vnode);
        keys.add(key);

        // LRU 淘汰:如果超过 max,删除最久未使用的
        if (props.max && keys.size > parseInt(props.max as string, 10)) {
          pruneCacheEntry(keys.values().next().value);
        }
      }

      // 标记组件需要被缓存(影响卸载流程)
      vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE;
      current = vnode;

      return rawVNode;
    };
  },
};

// 辅助函数:获取组件名称
function getName(vnode: VNode): string | undefined {
  return (
    (vnode.type as ComponentOptions).name ||
    (vnode.type as ComponentOptions).__name ||
    (typeof vnode.type === "function" &&
      (vnode.type as FunctionalComponent).name)
  );
}

// 辅助函数:匹配模式
function matches(pattern: MatchPattern, name: string): boolean {
  if (isArray(pattern)) {
    return pattern.some((p) => matches(p, name));
  } else if (isString(pattern)) {
    return pattern.split(",").includes(name);
  } else if (isRegExp(pattern)) {
    return pattern.test(name);
  }
  return false;
}

6.2 关键逻辑解析

6.2.1 为什么使用 Map 和 Set?
const cache: Map<string, VNode> = new Map(); // 快速查找:O(1)
const keys: Set<string> = new Set(); // 保持插入顺序,支持 LRU
  • Map:提供 O(1) 的查找效率,适合频繁读取缓存
  • Set:保持插入顺序,且可以方便地获取"第一个"元素(最久未使用)
6.2.2 LRU 淘汰实现
// 更新 LRU 顺序
keys.delete(key); // 先删除旧位置
keys.add(key); // 再添加到末尾(最近使用)

// 淘汰最久未使用的
if (max && keys.size > max) {
  pruneCacheEntry(keys.values().next().value); // 获取并删除第一个
}
6.2.3 渲染器如何配合 KeepAlive?
// packages/runtime-core/src/renderer.ts

// 在组件卸载流程中
function unmountComponent(instance) {
  const { shapeFlag } = instance.vnode;

  // 检查是否是 KeepAlive 缓存的组件
  if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
    // 不销毁,而是调用 deactivate
    const { deactivate } = instance.parent?.ctx || {};
    if (deactivate) {
      deactivate(instance.vnode);
    }
    return;
  }

  // 普通组件:正常销毁流程
  // ...
}

// 在组件挂载流程中
function mountComponent(vnode, container, anchor) {
  // 检查是否来自缓存
  if (vnode.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
    // 复用已有实例,不需要重新创建
    const instance = vnode.component;

    // 调用 activate 将 DOM 移回页面
    const { activate } = instance.parent?.ctx || {};
    if (activate) {
      activate(vnode, container, anchor);
    }
    return;
  }

  // 普通组件:正常创建流程
  // ...
}

7. 实战应用场景

7.1 多标签页缓存

<template>
  <div class="tabs">
    <div
      v-for="tab in tabs"
      :key="tab.name"
      class="tab-item"
      :class="{ active: currentTab === tab.name }"
      @click="currentTab = tab.name"
    >
      {{ tab.label }}
      <span class="close" @click.stop="closeTab(tab.name)">×</span>
    </div>
  </div>

  <div class="tab-content">
    <KeepAlive :include="cachedTabs" :max="10">
      <component :is="currentTabComponent" :key="currentTab" />
    </KeepAlive>
  </div>
</template>

<script setup>
import { ref, computed, watch } from "vue";
import UserList from "./UserList.vue";
import OrderList from "./OrderList.vue";
import Settings from "./Settings.vue";

const tabs = ref([
  { name: "UserList", label: "用户管理", component: UserList },
  { name: "OrderList", label: "订单管理", component: OrderList },
  { name: "Settings", label: "系统设置", component: Settings },
]);

const currentTab = ref("UserList");
const cachedTabs = ref(["UserList", "OrderList"]); // 只缓存特定标签

const currentTabComponent = computed(() => {
  const tab = tabs.value.find((t) => t.name === currentTab.value);
  return tab?.component;
});

function closeTab(tabName) {
  // 关闭标签时从缓存列表移除
  const index = cachedTabs.value.indexOf(tabName);
  if (index > -1) {
    cachedTabs.value.splice(index, 1);
  }
  // 切换到其他标签...
}
</script>

7.2 表单数据保持

<template>
  <KeepAlive :include="['UserForm']">
    <UserForm v-if="showForm" @submit="handleSubmit" />
    <UserDetail v-else :user="currentUser" @edit="showForm = true" />
  </KeepAlive>
</template>

<script setup>
import { ref } from "vue";
import UserForm from "./UserForm.vue";
import UserDetail from "./UserDetail.vue";

const showForm = ref(true);
const currentUser = ref(null);

function handleSubmit(userData) {
  // 提交表单后切换到详情页
  currentUser.value = userData;
  showForm.value = false;
}
</script>

7.3 列表页状态保持

<!-- ListPage.vue -->
<template>
  <div class="list-page">
    <!-- 搜索条件 -->
    <SearchForm v-model="searchParams" @search="handleSearch" />

    <!-- 列表 -->
    <div class="list-container" ref="listRef">
      <div
        v-for="item in listData"
        :key="item.id"
        class="list-item"
        @click="goToDetail(item)"
      >
        {{ item.name }}
      </div>
    </div>

    <!-- 分页 -->
    <Pagination v-model:page="page" v-model:size="pageSize" :total="total" />
  </div>
</template>

<script setup>
import { ref, onActivated, onDeactivated } from "vue";
import { useRouter } from "vue-router";

const router = useRouter();
const listRef = ref(null);

// 状态数据
const searchParams = ref({});
const listData = ref([]);
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const scrollTop = ref(0);

// 激活时恢复状态
onActivated(() => {
  // 恢复滚动位置
  if (listRef.value) {
    listRef.value.scrollTop = scrollTop.value;
  }

  // 可选:刷新数据(如果需要保持最新)
  // fetchData()
});

// 失活时保存状态
onDeactivated(() => {
  if (listRef.value) {
    scrollTop.value = listRef.value.scrollTop;
  }
});

function goToDetail(item) {
  router.push(`/detail/${item.id}`);
}

async function handleSearch() {
  // 搜索逻辑...
}
</script>

8. 性能优化建议

8.1 合理设置 max

<!-- ❌ 不设置 max,可能无限增长导致内存泄漏 -->
<KeepAlive>
  <component :is="currentTab" />
</KeepAlive>

<!-- ✅ 根据业务场景设置合理的 max -->
<KeepAlive :max="5">
  <component :is="currentTab" />
</KeepAlive>

8.2 使用 include/exclude 精确控制

<!-- 只缓存必要的组件,减少内存占用 -->
<KeepAlive :include="['UserList', 'OrderList']" :max="5">
  <component :is="currentTab" />
</KeepAlive>

8.3 避免缓存大型组件

<script setup>
// 对于包含大量数据或复杂图表的组件,考虑不缓存
defineOptions({
  name: "HeavyDataChart", // 在 exclude 中排除
});
</script>

8.4 及时清理缓存

<script setup>
import { ref, nextTick } from "vue";

const includeList = ref(["TabA", "TabB", "TabC"]);
const currentTab = ref("TabA");
const keepAliveRef = ref(null);

// 方法1:通过修改 include 排除特定组件
function clearCache(componentName) {
  const index = includeList.value.indexOf(componentName);
  if (index > -1) {
    includeList.value.splice(index, 1);
  }
}

// 方法2:使用 v-if 强制重新创建 KeepAlive(清空所有缓存)
async function clearAllCache() {
  keepAliveRef.value = false;
  await nextTick();
  keepAliveRef.value = true;
}
</script>

<template>
  <KeepAlive v-if="keepAliveRef" :include="includeList">
    <component :is="currentTab" />
  </KeepAlive>
</template>

9. 常见问题与避坑指南

9.1 组件 name 未设置导致缓存失效

<script setup>
// ❌ 错误:没有设置 name,include/exclude 无法匹配
// 组件会被缓存,但无法通过 include/exclude 控制

// ✅ 正确:显式设置 name
defineOptions({
  name: "MyComponent",
});
</script>

9.2 动态组件 key 问题

<template>
  <!-- ❌ 错误:key 变化会导致缓存失效 -->
  <KeepAlive>
    <component :is="currentTab" :key="Date.now()" />
  </KeepAlive>

  <!-- ✅ 正确:使用稳定的 key 或组件名作为 key -->
  <KeepAlive>
    <component :is="currentTab" :key="currentTab" />
  </KeepAlive>
</template>

9.3 异步组件的缓存

<script setup>
import { defineAsyncComponent } from "vue";

const AsyncComp = defineAsyncComponent(() => import("./AsyncComp.vue"));
</script>

<template>
  <!-- ✅ 异步组件也可以被缓存 -->
  <KeepAlive>
    <AsyncComp />
  </KeepAlive>
</template>

9.4 与 Transition 一起使用

<template>
  <!-- ✅ KeepAlive 应该包裹在 Transition 内部 -->
  <Transition name="fade" mode="out-in">
    <KeepAlive>
      <component :is="currentTab" />
    </KeepAlive>
  </Transition>

  <!-- ❌ 不要这样:KeepAlive 包裹 Transition -->
</template>

9.5 缓存后数据不更新问题

<script setup>
import { onActivated, ref } from "vue";

const data = ref([]);

// ✅ 在 onActivated 中刷新数据
onActivated(() => {
  // 组件从缓存激活时,重新获取最新数据
  fetchLatestData();
});

// 或者使用 watch 监听路由参数变化
import { watch } from "vue";
import { useRoute } from "vue-router";

const route = useRoute();

watch(
  () => route.params.id,
  (newId) => {
    if (newId) {
      fetchData(newId);
    }
  },
  { immediate: true },
);
</script>

10. 总结与思考

10.1 核心要点回顾

要点 说明
缓存机制 使用 Map 存储 VNode,Set 管理 LRU 顺序
状态标记 COMPONENT_SHOULD_KEEP_ALIVECOMPONENT_KEPT_ALIVE shapeFlag
隐藏实现 通过 move 函数将 DOM 移入隐藏的 div 容器
生命周期 onActivated / onDeactivated 用于状态恢复和保存
淘汰策略 LRU 算法,当缓存超过 max 时淘汰最久未使用的组件

10.2 设计思想

KeepAlive 的设计体现了 Vue3 的几个重要思想:

  1. 声明式编程:开发者只需声明要缓存的组件,无需关心实现细节
  2. 可组合性:与动态组件、Transition、异步组件无缝配合
  3. 性能优先:LRU 策略防止内存无限增长,DOM 移动而非重建保证性能
  4. 扩展性:通过 include / exclude 提供精细的控制能力

10.3 思考题

  1. 为什么 KeepAlive 使用 DOM 移动而不是 display: none

    • 提示:考虑 CSS 样式继承、布局计算、内存占用等因素
  2. 如何实现一个自定义的缓存策略(如 FIFO)?

    • 提示:研究 KeepAlive 的源码结构,尝试扩展
  3. KeepAlive 与 Pinia/Vuex 状态管理如何配合?

    • 思考:什么时候用 KeepAlive 缓存状态,什么时候用全局状态管理?
  4. 在 SSR 场景下,KeepAlive 会有什么问题?

    • 提示:服务端没有 DOM,组件如何"失活"?

📚 扩展阅读

  1. Vue3 官方文档 - KeepAlive
  2. Vue3 源码解读 - KeepAlive 实现
  3. LRU 缓存算法详解
  4. Vue3 渲染器原理

💡 如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题可以在评论区留言讨论。

AI 打字跟随优化

2026年4月16日 09:37

前文提到通过API去监听滚动容器或容器尺寸去触发打字跟随,监听用户的滚动去读取造成重排的属性来实现用户是否跟随打字实际会造成浏览器多次重排。

重排

浏览器重排(回流)是浏览器对DOM元素计算位置、尺寸、布局的过程。 浏览器解析HTML合成DOM树,解析CSS合成CSSOM树,它们会一步步合成渲染树,进行布局。页面、结构、尺寸发生变化就会再走一遍布局的计算流程,称为重排。

为什么重排会造成性能开销?

  • 当一个元素变化后,可能影响父元素、子元素、兄弟元素等,浏览器需要进行递归遍历。
  • 重排需要在浏览器主线程发生,阻塞JS、渲染。
  • 频繁重排会造成浏览器卡顿,浏览器的刷新率是60fps,每帧近16ms,一次重排就会占据一部分时间;多次重排会导致掉帧。

哪些操作会导致重排?

  • 元素几何属性发生变化。
  • 增删、移动DOM。
  • 窗口变化(resize、scroll页面等)
  • 获取布局相关属性:
    • offsetTop / offsetLeft / offsetWidth / offsetHeight

    • scrollTop / scrollHeight

    • clientTop / clientWidth

    • getComputedStyle()

    • getBoundingClientRect()

IntersectionObserver 哨兵模式

这里直接取消滚动事件的监听,在容器的最底部放一个哨兵容器,通过 IntersectionObserver 去监听哨兵在监听的父元素在可视区域的交叉值来判断用户是否滚动。让哨兵通过 scrollIntoView 直接暴露在可视区域,实现打字跟随。

<div class="chat-scroll-container" ref="scrollContainerRef">
    <div class="chat-container" id="messagesRef"></div>
    <div class="scroll-sentinel" ref="sentinelRef"></div> // 哨兵
</div>

threshold: 1 // 1 :表示全部进入,0 :露头就秒

onMounted(() => {
  const ro = new ResizeObserver(() => {
    if (enableAutoScroll.value) {
      scrollToBottom();
    }
  });
  ro.observe(chatMessagesRef.value);

  observer = new IntersectionObserver(
    (entries) => {
      const isIntersecting = entries[0].isIntersecting;
      enableAutoScroll.value = isIntersecting; 
    },
    {
      root: scrollContainerRef.value, // 监听父元素,默认为 root
      threshold: 1,       // 1表示全部进入,0 :露头就秒
      rootMargin: '10px', // 提前10px开始生效
    }
  );

  if (sentinelRef.value) observer.observe(sentinelRef.value);
});

  
  const scrollToBottom = () => {
  nextTick(() => {
    const el = sentinelRef.value;
    if (!el) return;
    if (enableAutoScroll.value) {
      sentinelRef.value.scrollIntoView({ behavior: 'instant' });
    }
  });
};

在实践过程中,如果使用 behavior: 'smooth' ,浏览器在触发 scrollToBottom 时发生的动画会频繁抖动,将哨兵挤到父容器的可视区域外,导致 IntersectionObserver 频繁触发,可能产生 bug,使用 instant 取消浏览器动画抖动,直接抵达底部,避免该情况产生。

Vue 3 defineOptions 宏,用 VuReact 编译成 React 长什么样?

作者 Ruihong
2026年4月16日 09:08

VuReact 是一个语义感知、约定驱动、支持渐进迁移的编译器,能把 Vue 3 代码一键转成标准可维护的 React 18+ 代码。

今天我们继续拆解核心 API:Vue 3 <script setup> 里的 defineOptions 宏,经过 VuReact 编译后在 React 中如何呈现?

前置约定

为了示例清爽、理解无歧义,先统一两个规则:

  1. 只保留核心逻辑,省略外层包裹与无关配置;
  2. 默认你已熟悉 Vue 3 defineOptions 的用法与语义。

编译对照:Vue defineOptions → React

1. Vue defineOptions({ name }) → React 组件命名

defineOptions 是 Vue 3 用于组件额外配置的宏,最常用就是指定组件 name。 在 React 中没有完全对应的宏,VuReact 会把 name 直接映射为组件函数名,保持语义一致。

Vue 代码

<script setup lang="ts">
  defineOptions({
    name: 'MyComponent'
  })
</script>

VuReact 编译后 React 代码

const MyComponent = () => {
  return <></>
}

export default MyComponent

defineOptions({ name }) 不会生成任何运行时 Hook,仅作为编译期信息,用来给 React 组件“起名字”,让 DevTools、调用栈保持和 Vue 一致。


2. Vue defineOptions 其他配置 → React 忽略/编译提示

defineOptions 还支持 inheritAttrscustomOptions 等配置。 由于 React 组件机制与 Vue 不同,无法直接映射,VuReact 会做保守处理:

  • inheritAttrs:React 无对应概念,直接忽略
  • customOptions:非标准配置,忽略并可在编译期提示
  • 其他扩展选项:统一忽略

Vue 代码

<script setup lang="ts">
  defineOptions({
    name: 'MyComponent',
    inheritAttrs: false
  })
</script>

VuReact 编译后 React 代码

const MyComponent = () => {
  return <></>
}

export default MyComponent
// inheritAttrs 在 React 中无直接对应,已忽略

这样处理的好处:不向 React 注入无用运行时代码,保持产物干净、符合 React 最佳实践。


3. 最佳实践:用 @vr-name 显式指定组件名

如果你希望100% 保留组件名语义,推荐使用 VuReact 官方推荐的注释约定:

<script setup lang="ts">
// @vr-name: MyComponent
</script>

编译后会稳定生成对应名称的 React 组件,比 defineOptions({ name }) 更可靠、更符合编译约定。

核心总结

  • defineOptions({ name }) → 编译为 React 组件名,无运行时开销
  • inheritAttrs 等 → React 无对应,直接安全忽略
  • 推荐用 // @vr-name: 组件名 替代,更稳定、更标准

VuReact 始终遵循:保留语义、不造多余运行时、符合 React 规范

相关资源


✨ 对你有帮助的话,欢迎 点赞 + 收藏 + 关注,持续更新 VuReact 编译原理实战~

你的 Vue 3 defineEmits(),VuReact 会编译成什么样的 React?

作者 Ruihong
2026年4月15日 18:50

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 defineEmits 宏经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中 defineEmits 的 API 用法与核心行为。

编译对照

Vue defineEmits → React props 事件回调映射

defineEmits 是 Vue 3 <script setup> 中用于声明组件自定义事件的宏,它会把事件名称和参数类型定义为函数签名。VuReact 会将它编译为 React props 的事件回调形式,并对事件名做驼峰映射。

  • Vue 代码:
<script setup lang="ts">
  defineProps<{ name?: string }>();

  const emit = defineEmits<{
    (e: 'save-item', payload: { id: string }): void;
    (e: 'update:name', value: string): void;
  }>();

  const submit = () => {
    emit('save-item', { id: '1' });
    emit('update:name', 'next');
  };
</script>
  • VuReact 编译后 React 代码:
type ICompProps = {
  name?: string;
  onSaveItem?: (payload: { id: string }) => void;
  onUpdateName?: (value: string) => void;
};

const submit = useCallback(() => {
  props.onSaveItem?.({ id: '1' });
  props.onUpdateName?.('next');
}, [props.onSaveItem, props.onUpdateName]);

从示例可以看到:Vue 的 defineEmits 不会直接编译为运行时 Hook,而是转换为 React 组件 props 中的回调函数。VuReact 会将事件名 save-item / update:name 映射为 onSaveItem / onUpdateName,并保留参数类型定义,实现了事件签名与 React props 回调的无缝对接


Vue v-model:xxx → React 双向绑定 props + 事件映射

此外,子组件中定义的 update:xxx 这类事件,通常用于实现 Vue 中父子组件的双向数据绑定,父组件会以 v-model:xxx="value" 的形式使用。VuReact 充分考虑了这种模式,能够精准地进行转换:

  • 父组件 Vue 代码:
<template>
  <Child v-model:name="current" />
</template>

<script setup>
  // @vr-name: Parent
  const current = ref('');
</script>
  • VuReact 编译后 React 代码:
const Parent = memo(() => {
  const current = useVRef('');
  return <Child name={current.value} onUpdateName={value => current.value = value} />
});

Vue emit 调用 → React props 回调调用

在 Vue 中,emit('event-name', payload) 触发组件自定义事件;在 React 中,VuReact 会把它编译为 props.onEventName?.(payload) 的调用形式。

  • Vue 代码:
<script setup lang="ts">
  const emit = defineEmits<{
    (e: 'submit', value: string): void;
  }>();

  const handleSubmit = () => {
    emit('submit', 'ok');
  };
</script>
  • VuReact 编译后 React 代码:
type ICompProps = {
  onSubmit?: (value: string) => void;
};

const handleSubmit = useCallback(() => {
  props.onSubmit?.('ok');
}, [props.onSubmit]);

VuReact 会对 emit 的事件名和参数进行类型映射,并在必要时自动为 useCallback 生成依赖数组,让 React 端的回调引用保持稳定,同时避免开发者手动维护依赖


Vue defineEmits 兼容事件名映射规则

VuReact 支持将 Vue 的短横线事件名、冒号事件名等映射为 React 的驼峰命名回调:

  • save-itemonSaveItem
  • update:nameonUpdateName
  • closeonClose

这种映射方式与 React 事件 props 习惯一致,也保持了 Vue 事件声明的语义。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

昨天以前首页

使用 IntersectionObserver + 哨兵元素实现长列表懒加载

作者 王小新_926
2026年4月15日 15:48

一、背景与痛点

在一个设备监控数据看板项目中,设备列表可能包含 300+ 个设备卡片。如果一次性渲染全部 DOM 节点,会带来明显的性能问题:

  • 首屏白屏时间长:300+ 卡片组件同时挂载,主线程阻塞
  • 内存占用高:大量 DOM 节点常驻内存
  • 交互卡顿:滚动、点击等操作响应延迟

为此,我们采用 IntersectionObserver + 哨兵元素 方案实现懒加载。

二、核心思路

整体思路可以概括为  "分页截取 + 哨兵触发"

全量数据(300+)  →  分页截取显示(每页15条)  →  哨兵进入视口时追加下一页

关键设计:

  1. 数据全量存储,视图分页截取deviceList 保存完整数据,displayDeviceList 通过 computed 计算 slice(0, end) 返回当前应显示的子集
  2. 哨兵元素:在列表末尾放置一个不可见的 DOM 元素,当它进入视口时触发加载
  3. IntersectionObserver:原生浏览器 API,高效监听元素与视口的交叉状态,零滚动事件开销

三、架构图示

┌─────────────────────────────────────────────┐
│            Vue Component (data)              │
│  deviceList: [...]         // 全量300+设备   │
│  devicePageSize: 15        // 每页条数       │
│  deviceCurrentPage: 0      // 当前页码       │
│  observer: null            // Observer实例    │
├─────────────────────────────────────────────┤
│            Computed Properties               │
│  displayDeviceList → slice(0, pageSize*page) │
│  hasMoreDevices → displayed < total          │
├─────────────────────────────────────────────┤
│            Template 渲染逻辑                 │
│  v-for="device in displayDeviceList"         │
│  ┌─── Card ───┐  ┌─── Card ───┐  ...        │
│  └────────────┘  └────────────┘              │
│  ┌─── Sentinel (ref="sentinel") ───┐         │
│  │  v-if="hasMoreDevices"          │         │
│  │  <加载更多设备...>               │         │
│  └─────────────────────────────────┘         │
└─────────────────────────────────────────────┘
         │                    ▲
         │ observe(sentinel)  │ isIntersecting
         ▼                    │
┌─────────────────────────────────────────────┐
│        IntersectionObserver                  │
│  rootMargin: '200px'   // 提前200px触发      │
│  threshold: 0.1                             │
│  → 触发 loadMoreDevices()                    │
│  → deviceCurrentPage++                       │
│  → displayDeviceList 自动更新 → DOM 更新     │
│  → $nextTick → 重新绑定哨兵                   │
└─────────────────────────────────────────────┘

四、核心代码实现

4.1 数据定义

data() {
  return {
    deviceList: [],        // 全量设备数据
    devicePageSize: 15,    // 每页条数
    deviceCurrentPage: 0,  // 当前已加载页数
    observer: null,        // IntersectionObserver 实例
  }
}

4.2 计算属性(视图截取 + 状态判断)

computed: {
  /** 当前已加载的设备列表(懒加载切片) */
  displayDeviceList() {
    const end = this.devicePageSize * this.deviceCurrentPage
    return this.deviceList.slice(0, end)
  },
  /** 是否还有更多设备可加载 */
  hasMoreDevices() {
    return this.displayDeviceList.length < this.deviceList.length
  }
}

关键点:使用 computed 而非手动维护一个 displayed 数组,确保数据源变化时自动响应更新。

4.3 哨兵元素(模板)

<!-- 设备网格容器 -->
<div class="dm-device-grid">
  <!-- 仅渲染 displayDeviceList 而非 deviceList -->
  <div v-for="device in displayDeviceList" :key="device.id" class="dm-device-card">
    <!-- 设备卡片内容 -->
  </div>
  <!-- 哨兵元素:仅在还有未加载数据时显示 -->
  <div v-if="hasMoreDevices" ref="sentinel" class="dm-lazy-sentinel">
    <i class="el-icon-loading" />
    <span>加载更多设备...</span>
  </div>
</div>

关键点v-if="hasMoreDevices" 确保数据全部加载后哨兵消失,Observer 自动停止触发。

4.4 IntersectionObserver 初始化

initObserver() {
  // 先断开旧观察器,防止重复绑定
  this.disconnectObserver()
  this.$nextTick(() => {
    const sentinel = this.$refs.sentinel
    if (!sentinel) return  // 哨兵不存在(数据已全部加载)

    this.observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          this.loadMoreDevices()
        }
      },
      {
        rootMargin: '200px',  // 提前200px触发,用户无感知
        threshold: 0.1
      }
    )
    this.observer.observe(sentinel)
  })
}

关键参数说明

  • rootMargin: '200px':哨兵距离视口还有 200px 时就触发回调,提前加载数据,实现 无感加载
  • threshold: 0.1:哨兵 10% 可见时即触发

4.5 加载更多 & 清理

/** 加载更多设备 */
loadMoreDevices() {
  if (!this.hasMoreDevices) return
  this.deviceCurrentPage++
  // 页码增加 → displayDeviceList 自动重新计算 → DOM 更新
  // Vue 响应式保证了这一链条无需手动操作
},

/** 断开观察器(组件销毁 / 切换组织时调用) */
disconnectObserver() {
  if (this.observer) {
    this.observer.disconnect()
    this.observer = null
  }
}

4.6 数据加载后重置

async loadDeviceList(organizationId) {
  this.deviceLoading = true
  try {
    const res = await fetchDeviceStatusList(params)
    this.deviceList = res.data.data || []
    // 重置懒加载分页
    this.deviceCurrentPage = 1  // 初始加载第一页
    this.$nextTick(() => {
      this.initObserver()  // 重新绑定观察器
    })
  } finally {
    this.deviceLoading = false
  }
}

4.7 生命周期钩子

mounted() {
  // ...其他初始化
  this.$nextTick(() => {
    this.initObserver()
  })
},
beforeDestroy() {
  // 清理 Observer,防止内存泄漏
  this.disconnectObserver()
}

五、数据流转全流程

用户滚动页面
    │
    ▼
IntersectionObserver 检测哨兵进入视口(提前200px)
    │
    ▼
回调触发 → loadMoreDevices()
    │
    ▼
deviceCurrentPage++ (1→2→3...)
    │
    ▼
displayDeviceList (computed) 自动重新计算
    slice(0, 15*2) → slice(0, 15*3) → ...
    │
    ▼
Vue 响应式更新 DOM(新增15个卡片)
    │
    ▼
哨兵元素被推到更下方
    │
    ▼
Observer 继续监听新位置的哨兵
    │
    ... 重复直到 hasMoreDevices === false
    │
    ▼
v-if="hasMoreDevices" = false → 哨兵从DOM移除
    │
    ▼
Observer 无目标 → 自动不再触发

六、方案优势总结

对比维度 传统 scroll 事件 本方案 (IntersectionObserver)
性能 滚动时高频触发,需 throttle/debounce 浏览器底层异步回调,零性能损耗
代码复杂度 需手动计算元素位置 getBoundingClientRect 声明式配置 rootMargin/threshold
兼容性 全兼容 IE 不支持,现代浏览器均支持
触发精度 节流后可能延迟或重复触发 精确触发一次,无重复

额外优点

  • 零依赖:纯浏览器原生 API,无需引入第三方库(如 vue-virtual-scroller)
  • 低侵入:仅需修改数据切片逻辑 + 添加哨兵元素,不改动现有卡片组件
  • 提前加载:通过 rootMargin 提前 200px 触发,用户几乎感知不到加载过程
  • 自动停止:数据全部加载后哨兵自动移除,Observer 不再触发

七、注意事项与踩坑

  1. $nextTick 必不可少initObserver 中获取 $refs.sentinel 必须在 DOM 更新后执行,所以需要 $nextTick 包裹
  2. 重置时机:切换组织 / 重新加载数据时,必须重置 deviceCurrentPage 并重新 initObserver
  3. 内存泄漏beforeDestroy 中务必调用 disconnectObserver() 清理
  4. Grid 布局兼容:哨兵元素需设置 grid-column: 1 / -1 确保占满整行,不会被挤到某一列
  5. v-if 而非 v-show:哨兵使用 v-if 控制而非 v-show,这样数据全部加载后哨兵完全从 DOM 移除,Observer 自然不再触发

「性能优化」虚拟列表极致优化实战:从原理到源码,打造丝滑滚动体验

2026年4月15日 14:35

前言

大家好,我是elk。

上篇文章我们聊了大文件的切片上传,这次再来看看另一个高频性能优化场景 —— 虚拟列表(Virtual List)

什么是虚拟列表?

虚拟列表「Virtual List」是一种前端性能优化技术,用于解决"长列表渲染"场景下,因DOM节点过多导致的页面卡顿,内存占用率高,首屏加载缓慢等问题。

核心思想是:只渲染当前视口可见的列表项,而非渲染全部列表数据。通过动态计算视口位置,复用DOM节点,实现"无限列表"的流畅渲染。

为什么需要虚拟列表?

在处理大数据量列表时,传统的渲染方式会面临两大瓶颈:

  1. DOM 节点过载:浏览器渲染 10,000 个复杂的 DOM 节点,内存消耗巨大。
  2. 布局与重绘:滚动时,大量的 DOM 节点重绘会导致帧率下降,产生明显的掉帧(Jank)。

适用业务场景

  • 大数据量列表渲染:后台管理系统的用户列表、日志列表、权限列表、数据报表等,数据量超1000条,全量渲染直接导致页面卡死、操作无响应。
  • 无限滚动场景:移动端信息流、商品列表、评论区、下拉选择器,用户持续下拉加载数据,DOM节点无限累加,最终引发页面崩溃。
  • 固定容器滚动列表:所有需要在固定高度容器内展示超长列表的业务场景。

核心原理

  • 视口计算:获取容器的可视高度,滚动距离,确定当前"可见区域"的范围
  • 数据截取:根据可见范围,计算需要渲染的列表项的起始索引和结束索引,从全部数据中截取范围内的数据,仅渲染截取后的可视数据
  • 偏移量计算:通过定位设置渲染区域的偏移量,让截取的数据精准的显现在视口内,模拟"滚动到指定位置的效果"
  • DOM复用:当滚动时,动态改变起始索引和结束索引,截取新的可视化数据,复用已渲染的DOM节点,减少DOM操作的开销

核心基础概念

  • 视口容器:用于展示列表的容器,用户的可见区域,通常设置为固定高度和overflow: auto
  • 列表项高度:单个列表项的高度,通常分为:"固定高度"和"动态高度"
  • 可见数量:可见区域中要展示的列表数量总个数,计算公式:Math.cell(视口高度 / 列表项高度)
  • 缓冲数量:在可见区域上下额外多渲染的数量,用于解决滚动时的"空白闪烁"问题。
  • 总高度:所有列表项的总高度,用于撑开容器,模拟长列表滚动(不设置,容器无法滚动)

核心知识点

主要是涉及到事件监听以及基础数据的计算和更新

基础知识点

滚动事件监听

通过监听容器的scoll事件,获取滚动距离(scrollTop),触发可见区域、起始索引、结束索引、可见列表、偏移量距离的计算

避免频繁触发滚动事件,需使用节流进行优化,避免过量计算损失性能

尺寸计算

  • 视口高度:可通过容器的「clientHeight」获得,一般定义固定高度
  • 滚动距离:通过容器滚动事件触发获得「scrollTop属性」
  • 固定高度:无需计算,自行设置的高度「itemHeight」
  • 动态高度:当容器滚动时,动态计算列表项的高度「clientHeight」,并列入缓存中

索引计算

起始索引「startIndex」

固定高度

index = Math.floor(scrollTop / ITEM_HEIGHT) 「滚动距离 / 固定单个项高度」

startIndex = Math.max(0, index - bufferCount) 「 减去缓冲个数获取真实起始索引 」

动态高度:需通过"累计高度"计算startIndex「遍历缓存的高度列表,通过二分法查找到大于等于scrollTop滚动距离的索引」

结束索引「endIndex」

index = startIndex + visibiliItemsCount + bufferCount 「起始索引 + 可见区域列表数量 + 缓冲量」

endIndex = Math.min( list.length, index )

偏移量计算

固定高度

    top = startIndex * ITEM_HEIGHT 「起始索引 * 单个项固定高度」

动态高度

top = prefixSumCache[startIndex] 「从高度缓存列表中获取当前起始索引的数据」

进阶知识点

在基础知识点上进行的优化措施,提升列表性能,优化用户体验

缓冲机制

当用户快速滚动时,如果是仅渲染可见区域内的数据,会出现"空白区域",数据未及时渲染

  • 缓存量设置1-5个,过多会增加DOM数量,削弱优化效果
  • 上方偏移量计算 startIndex + bufferCount , endIndex - bufferCount,就是确保上下都有缓冲

动态高度缓存与更新

在动态高度场景下,初始化时不知道每一项的真实高度,常见优化策略:

  • 先进行预估高度的渲染,渲染后通过nextTick获取真实高度
  • 将真实高度写入缓存,并重新计算前缀和
  • 后续滚动时,当实际高度和初始化缓存高度不匹配的时候才重新计算一次高度缓存

滚动事件节流

在滚动事件 handelScroll中使用了ticking锁和requestAnimationFrame

  • 滚动事件触发非常频繁,使用RAF可以确保浏览器在下一帧重绘前执行计算逻辑,避免掉帧,使滚动更平滑

二分查找优化索引定位

在动态高度场景下,需要根据 scrollTop 找到起始索引。如果每次都线性查找,时间复杂度 O(n)。利用 前缀和数组的单调递增特性,使用二分查找可将复杂度降至 O(log n)。

整体代码 —— 组件封装(Vue 3 + TypeScript)

以下是一个支持 动态高度缓冲区高度缓存二分查找 的完整虚拟列表组件。

<template>
  <div
    @scroll="handleScroll"
    ref="containerRef"
    :style="{ height: `${height}px` }"
    class="w-full position-relative top-0 left-0 overflow-auto"
  >
    <!-- 空状态 -->
    <div v-if="data.length === 0" class="w-full h-full flex items-center justify-center">
      <slot name="empty" />
    </div>
    <!-- 占位撑高容器 -->
    <template v-else>
      <div
        :style="{ height: `${containerHeight}px` }"
        class="w-full position-absolute top-0 left-0"
      ></div>
      <!-- 可视化容器 -->
      <div
        :style="{ transform: `translateY(${offset}px)` }"
        class="w-full position-absolute top-0 left-0"
      >
        <div
          v-for="(item, index) in visibleList"
          :key="item.id || index"
          ref="itemRef"
          :style="{ height: `${itemHeight}px` }"
          class="w-full flex items-center justify-center"
        >
          <slot name="default" :item="item" :index="index + startIndex" />
        </div>
      </div>
    </template>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watchEffect } from 'vue'
import type { PropType } from 'vue'

interface ListItem {
  id: number | string
  name: string
}

interface PropsParams {
  // 列表数据
  data: ListItem[]
  // 容器高度
  height: number
  // 项高度-预估高度
  itemHeight: number
  // 缓冲区数量
  bufferCount: number
}
const props: PropsParams = defineProps({
  data: {
    type: Array as PropType<ListItem[]>,
    default: () => [],
    required: true,
  },
  height: {
    type: Number,
    default: 250,
  },
  itemHeight: {
    type: Number,
    default: 50,
  },
  bufferCount: {
    type: Number,
    default: 5,
  },
})

// 容器ref
const containerRef = ref<HTMLDivElement>()
// 项ref
const itemRef = ref<HTMLDivElement[]>([])
// 滚动距离
const scrollTop = ref(0)

// 项高度-缓存集合
const itemHeightCache = ref<number[]>([])
// 前缀和-缓存集合
const prefixSumCache = ref<number[]>([])

// 可视化容器-开始索引
const startIndex = computed(() => {
  const index = getStartIndex(scrollTop.value)
  return Math.max(0, index - props.bufferCount)
})

// 可视化容器-结束索引
const endIndex = computed(() => {
  const index = startIndex.value + visibleCount.value + props.bufferCount * 2
  return Math.min(props.data.length, index)
})

// 撑开容器-高度
const containerHeight = computed(() => {
  return prefixSumCache.value[prefixSumCache.value.length - 1]
})
// 可视化容器-列表数量
const visibleCount = computed(() => {
  return Math.ceil(props.height / props.itemHeight)
})

// 可视化容器-渲染列表
const visibleList = computed(() => {
  return props.data.slice(startIndex.value, endIndex.value)
})

// 偏移量-计算
const offset = computed(() => {
  return prefixSumCache.value[startIndex.value]
})

/**
 * @description: 二分法-计算初始索引
 * @return {*}
 */
const getStartIndex = (scrollTop: number) => {
  let left = 0
  let right = prefixSumCache.value.length - 1
  while (left <= right) {
    const mid = Math.floor((left + right) / 2)
    if (prefixSumCache.value[mid] === scrollTop) return mid
    if (prefixSumCache.value[mid] > scrollTop) {
      right = mid - 1
    } else {
      left = mid + 1
    }
  }
  return left
}

/**
 * @description: 初始化高度
 * @return {*}
 */
const initHeight = () => {
  try {
    // 初始化项高度缓存集合
    itemHeightCache.value = props.data.map(() => props.itemHeight)
    // 初始化前缀和缓存集合
    initPrefixSum()
  } catch (error) {
    console.error('初始化高度失败:', error)
  }
}

/**
 * @description: 初始化|修改 前缀和缓存集合
 * @return {*}
 */
const initPrefixSum = (index: number = 0) => {
  try {
    prefixSumCache.value = []
    let sum = 0
    // 计算前缀和缓存集合,从索引开始计算,直到列表结束
    itemHeightCache.value.forEach((item, i) => {
      if (i >= index) {
        prefixSumCache.value.push(sum)
        sum += item
      }
    })
  } catch (error) {
    console.error('初始化前缀和缓存集合失败:', error)
  }
}

/**
 * @description: 修改项的真实高度-当高度发生变化时才更新
 * @return {*}
 */
const updateItemHeight = async () => {
  try {
    await nextTick()
    const visibleItems = itemRef.value
    if (visibleItems.length === 0) return
    let hasHeightChanged = false
    visibleItems.forEach((el, index) => {
      if (el) {
        const itemIndex = index + startIndex.value
        const itemHeight = el.clientHeight
        // const itemHeight = el.getBoundingClientRect().height
        // 只有高度变化的时候才更新缓存
        if (itemHeight !== itemHeightCache.value[itemIndex]) {
          itemHeightCache.value[itemIndex] = itemHeight
          hasHeightChanged = true
        }
        if (hasHeightChanged) {
          initPrefixSum(itemIndex)
        }
      }
    })
  } catch (error) {
    console.error('更新项目高度失败:', error)
  }
}

/**
 * @description: 处理滚动事件
 * @return {*}
 */
let ticking = false
const handleScroll = () => {
  console.log('🚀 ~ handleScroll ~ containerRef: 触发了滚动事件')
  if (!ticking) {
    requestAnimationFrame(() => {
      if (containerRef.value) {
        scrollTop.value = containerRef.value?.scrollTop || 0
        updateItemHeight()
      }
      ticking = false
    })
    ticking = true
  }
}

// 监听数据变化-更新项高度
watchEffect(() => {
  if (props.data.length > 0) {
    initHeight()
    updateItemHeight()
  }
})

// 初始化-更新项高度
onMounted(() => {
  initHeight()
  updateItemHeight()
})
</script>

<style lang="css" scoped></style>

常见问题 & 最佳实践

Q1:为什么我的虚拟列表在快速滚动时还是会白屏?

  • 缓冲区太小:适当增加 bufferCount(比如从 2 提升到 5)。
  • 动态高度更新不及时:确保在 nextTick 后获取真实高度,并重新计算前缀和。
  • 未使用 requestAnimationFrame:滚动回调中的 DOM 操作可能被延迟,导致渲染跟不上。

Q2:动态高度组件中,prefixSum 的维护很容易出错,有什么建议?

推荐使用 长度 = n+1 的前缀和数组,其中 prefixSum[0] = 0prefixSum[i] 表示前 i 项的总高度。这样:

  • 第 i 项的偏移量 = prefixSum[i]
  • 总高度 = prefixSum[n]
  • 查找 scrollTop 对应索引时,二分查找第一个大于 scrollTop 的 prefixSum[i],然后 i-1 即为起始索引。

Q3:如何支持列表项内容动态变化(比如展开/收起)?

  • 监听内容变化,调用 updateRealHeights 重新测量受影响的项。
  • 如果是通过用户交互(如点击展开),可以手动触发更新并重新构建前缀和。

Q4:除了 transform 偏移,还有别的方案吗?

也可以使用 padding-top 偏移,但 transform 性能更好(不触发重排)。推荐使用 translateY

总结

虚拟列表是前端性能优化中 性价比极高 的一类技术 —— 实现成本可控,却能将万级列表的渲染性能从秒级降到毫秒级。本文从原理到代码,覆盖了固定高度、动态高度、缓冲区、二分查找、滚动节流等关键点。

优化永无止境,如果你还想更进一步,可以探索:

  • 使用 ResizeObserver 监听每一项的尺寸变化,自动更新高度缓存。
  • 结合 IntersectionObserver 实现可视区外图片懒加载。
  • 将虚拟列表与 分页 / 懒加载数据 结合,实现真正意义上的“无限滚动”。

希望这篇文章能帮你彻底掌握虚拟列表,写出更流畅的 Web 应用。如果觉得有帮助,欢迎点赞、评论、转发~

别再用 JSON.parse 深拷贝了,聊聊 StructuredClone

作者 ErpanOmer
2026年4月15日 09:58

临近下班,我们业务线出了一个极度无语的线上 Bug。

产品侧反馈,在一个非常核心的财务表单里,用户明明选择了 2026-04-14 作为结算日期,但点击提交后,整个页面直接白屏崩溃。

我打开错误监控看了一眼日志,立刻就把组里那个刚入职不久的小伙子叫了过来。 原因极其经典:他在把表单的原始状态同步给历史快照时,为了图省事,顺手写了一段几乎所有前端都写过的代码:

// 模拟用户表单数据
const formData = {
  amount: 1000,
  date: new Date("2026-04-14"), // 用户选的结算日期(Date对象)
};

// 深拷贝
const snapshot = JSON.parse(JSON.stringify(formData));

console.log("原始:", formData.date, typeof formData.date); 
// Date object

console.log("快照:", snapshot.date, typeof snapshot.date); 
// "2026-04-14T00:00:00.000Z" string

// 后续业务代码
function calcSettlementTime(data) {
  // 这里默认 date 是 Date 对象
  return data.date.getTime();
}

// 页面直接崩溃😢
try {
  const time = calcSettlementTime(snapshot);
  console.log("时间戳:", time);
} catch (err) {
  console.error("页面崩溃:", err);
}

他满脸委屈:老大,大家平时深拷贝不都是这么写的吗?🤷‍♂️

我让他自己把这段代码在控制台跑一遍。 当他看到表单里原本好好的 Date 对象,经过这一进一出,硬生生变成了一串 ISO 格式的字符串,导致后面调用 snapshot.date.getTime() 直接抛出 TypeError 时,他自己也沉默了。

作为前端老油条,这种因为 JSON.parse(JSON.stringify()) 引发的血案,我见过太多了。 它不仅会把 Date 变成字符串,还会把 MapSet 变成空对象 {},会把 undefinedSymbol 以及函数直接活生生抹除,更别提遇到循环引用时,它会当场抛出异常让你的主线程直接崩溃。

以前,我们为了解决这个破事,不得不在每个项目里老老实实 npm install lodash,然后引入那个笨重的 cloneDeep

但现在是 2026 年了。浏览器早就原生内置了完美的终极解药——structuredClone

今天咱们不聊虚的架构,就花三分钟,把这个原生 API 的底层逻辑讲清楚。


它是怎么解决历史遗留问题的?

structuredClone 不是什么语法糖,它是浏览器底层暴露出来的 结构化克隆算法(Structured Clone Algorithm)。这就意味着,它在 C++ 引擎层面的处理逻辑,远比 JS 业务层面的递归拷贝要深得多。

看一下原生 API 的用法:

const original = {
  date: new Date(),
  set: new Set([1, 2, 3]),
  map: new Map([['key', 'value']]),
  regex: /hello/i,
  buffer: new Uint8Array([1, 2, 3]).buffer,
};

// 制造一个循环引用
original.self = original;

// 一行代码,原生搞定
const cloned = structuredClone(original);

console.log(cloned.date instanceof Date); // true
console.log(cloned.set instanceof Set);   // true
console.log(cloned.self === cloned);      // true 完美处理循环引用!

发现没有?它不仅完美保留了所有的内置对象类型,连 JSON.parse 绝对搞不定的循环引用,它都处理得游刃有余。由于是在引擎底层运行,不需要像 Lodash 那样在 JS 运行时里疯狂压栈递归,它的执行效率在大部分复杂场景下都具有压倒性优势👍👍👍。


零拷贝转移 (Transferable Objects)

如果你以为 structuredClone 只是为了少引入一个 Lodash,那你就太小看浏览器的底层野心了。

它藏着一个 90% 的前端都不知道的极其硬核的功能:内存转移(Transfer)

在前端处理音视频、WebGL、或者读取几十 MB 的大文件时,我们经常会生成巨大的 ArrayBuffer。如果你用传统的深拷贝,内存瞬间翻倍,几十兆的内存分配极容易引起页面的掉帧卡顿。

structuredClone 提供了一个极其变态的第二个参数配置:{ transfer }

// 假设这是一个极大的 50MB 数据内存块
const u8Array = new Uint8Array(1024 * 1024 * 50);
const hugeBuffer = u8Array.buffer;

// 传统的深拷贝:内存翻倍,耗时极长
// const badCopy = lodash.cloneDeep(hugeBuffer); 

// 直接内存转移
const fastClone = structuredClone(hugeBuffer, { transfer: [hugeBuffer] });

console.log(fastClone.byteLength); // 52428800 (50MB 完美转移)
console.log(hugeBuffer.byteLength); // 0 (原对象的内存地址被转移)

这段代码的核心在于:它压根没有复制数据。 它直接在内存层面,把这块 50MB 数据的所有权,从 hugeBuffer 强行转移给了 fastClone。原对象被彻底掏空(变成了 detached 状态)。

这种零拷贝机制,在结合 Web Worker 处理复杂后台计算时,是打破性能瓶颈的绝对神器。这是任何第三方 JS 库都做不到的底层API。


一些坑要讲清楚🤔

既然这么牛,是不是以后项目里所有的拷贝闭着眼睛用它就行了? 作为一个踩过无数坑的老兵,我必须点出它的几个致命死角。如果你在真实的业务架构里滥用,下场比用 JSON.parse 还要惨。

对于函数和 DOM 节点的处理

JSON.parse 遇到函数,它会默默地忽略掉,至少不报错。 但 structuredClone 很直接。只要你的对象树里藏着一个方法,或者藏着一个 DOM 节点的引用,它会直接给你抛出 DataCloneError

const objWithFunc = {
  data: 123,
  onClick: () => console.log('click')
};

// 只要带有函数,直接抛同步错误
// DOMException: () => console.log('click') could not be cloned.
const copy = structuredClone(objWithFunc); 

这就意味着,如果你要拷贝的是一个 Vue/React 的响应式组件实例,或者是带有业务方法的数据模型,绝对不能用它👋。

原型链的断裂

不管你原本是一个通过 class 实例化的多么高级的业务对象,经过 structuredClone 的洗礼后,它都会变成一个普通的纯对象(Plain Object)。

class User {
  constructor(name) { this.name = name; }
  sayHi() { console.log('hi'); }
}

const user = new User('前端');
const cloneUser = structuredClone(user);

console.log(cloneUser instanceof User); // false 
cloneUser.sayHi(); // TypeError: cloneUser.sayHi is not a function

原型链上的所有方法全部丢失。它只关心纯粹的数据,不关心你的面向对象架构。‘


需要时收藏起来⭐⭐⭐

这几年,前端的工具链卷得飞起,大家的 package.json 越来越臃肿。遇到数组去重找库,遇到时间格式化找库,遇到深拷贝也要找库。

如果你只是单纯地处理一些后端传过来的嵌套数据,或者表单的复杂配置结构,完全可以直接把 structuredClone 敲在你的代码里。不用担心兼容性,目前主流浏览器(包括 Node.js)的支持率早就达到了工业级使用的标准了。

image.png

下次 Code Review 时,别再让我看到满屏的 JSON.parse 了 (玩笑😁😁😁)。

分享完毕,谢谢大家🙌

Suggestion.gif

你的 Vue 3 生命周期,VuReact 会编译成什么样的 React?

作者 Ruihong
2026年4月15日 09:22

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的生命周期钩子经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中生命周期钩子例如 onMounted、onBeforeMount、onUpdated、onBeforeUpdate、onBeforeUnmount、onUnmounted 的 API 用法与核心行为。

编译对照

Vue onMounted() → React useMounted()

onMounted 是 Vue 3 中用于组件首次挂载后执行逻辑的生命周期钩子,适合放初始化请求、订阅启动、DOM 相关准备等操作。VuReact 会将它编译为 useMounted,让 React 端也能在组件挂载后执行一次性副作用。

  • Vue 代码:
<script setup>
  import { onMounted } from 'vue';

  onMounted(() => {
    console.log('组件已挂载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useMounted } from '@vureact/runtime-core';

useMounted(() => {
  console.log('组件已挂载');
});

从示例可以看到:Vue 的 onMounted() 被翻译为 useMounted。VuReact 提供的 useMountedonMounted 的适配 API完全模拟 Vue onMounted 的首次挂载后执行时机

Vue onBeforeMount() → React useBeforeMount()

onBeforeMount 是 Vue 3 中用于组件挂载前执行逻辑的钩子,适合放需要在布局阶段之前准备的内容。VuReact 会将它编译为 useBeforeMount,基于 React 的布局效果在挂载前执行。

  • Vue 代码:
<script setup>
  import { onBeforeMount } from 'vue';

  onBeforeMount(() => {
    console.log('组件即将挂载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useBeforeMount } from '@vureact/runtime-core';

useBeforeMount(() => {
  console.log('组件即将挂载');
});

VuReact 提供的 useBeforeMountonBeforeMount 的适配 API完全模拟 Vue onBeforeMount 的首次挂载前时机

Vue onBeforeUpdate() → React useBeforeUpdate()

onBeforeUpdate 是 Vue 3 中用于跳过首次挂载,仅在组件更新前执行的钩子,适合放变更前校验、记录旧值、提前准备等逻辑。VuReact 会将它编译为 useBeforeUpdate,并支持依赖数组以控制触发时机。

  • Vue 代码:
<script setup>
  import { reactive, onBeforeUpdate } from 'vue';

  const state = reactive({ count: 0 });

  onBeforeUpdate(() => {
    console.log('更新前,当前 count:', state.count);
  });
</script>
  • VuReact 编译后 React 代码:
import { useReactive, useBeforeUpdate } from '@vureact/runtime-core';

const state = useReactive({ count: 0 });

useBeforeUpdate(
  () => {
    console.log('更新前,当前 count:', state.count);
  },
  [state.count],
);

从示例可以看到:Vue 的 onBeforeUpdate() 被翻译为 useBeforeUpdate。VuReact 提供的 useBeforeUpdateonBeforeUpdate 的适配 API完全模拟 Vue onBeforeUpdate 的更新前触发时机。当 React 对应 API 需要依赖数组时,deps 数组可用于只在指定值变化时触发,VuReact 会在编译阶段自动分析依赖并映射到对应依赖数组,避免开发者手动管理依赖

Vue onUpdated() → React useUpdated()

onUpdated 是 Vue 3 中用于组件更新后执行逻辑的钩子,适合放读取最新渲染结果、执行后续同步等操作。VuReact 会将它编译为 useUpdated,并支持可选依赖数组来精确控制触发条件。

  • Vue 代码:
<script setup>
  import { reactive, onUpdated } from 'vue';

  const state = reactive({ count: 0 });

  onUpdated(() => {
    console.log('组件更新后,count:', state.count);
  });
</script>
  • VuReact 编译后 React 代码:
import { useReactive, useUpdated } from '@vureact/runtime-core';

const state = useReactive({ count: 0 });

useUpdated(
  () => {
    console.log('组件更新后,count:', state.count);
  },
  [state.count],
);

VuReact 提供的 useUpdatedonUpdated 的适配 API完全模拟 Vue onUpdated 的更新后执行时机。如果 React API 使用 deps 数组,VuReact 会自动分析依赖并生成对应的数组,无需开发者手动维护依赖

Vue onBeforeUnmount() → React useBeforeUnMount()

onBeforeUnmount 是 Vue 3 中用于组件卸载前执行的钩子,适合放动画停止、资源解绑、日志上报等清理前逻辑。VuReact 会将它编译为 useBeforeUnMount,在卸载前执行。

  • Vue 代码:
<script setup>
  import { onBeforeUnmount } from 'vue';

  onBeforeUnmount(() => {
    console.log('组件即将卸载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useBeforeUnMount } from '@vureact/runtime-core';

useBeforeUnMount(() => {
  console.log('组件即将卸载');
});

VuReact 提供的 useBeforeUnMountonBeforeUnmount 的适配 API完全模拟 Vue onBeforeUnmount 的卸载前时机

Vue onUnmounted() → React useUnmounted()

onUnmounted 是 Vue 3 中用于组件卸载时执行逻辑的钩子,适合放最终资源释放、异步取消、上报日志等收尾逻辑。VuReact 会将它编译为 useUnmounted,在组件卸载时执行。

  • Vue 代码:
<script setup>
  import { onUnmounted } from 'vue';

  onUnmounted(() => {
    console.log('组件已卸载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useUnmounted } from '@vureact/runtime-core';

useUnmounted(() => {
  console.log('组件已卸载');
});

VuReact 提供的 useUnmountedonUnmounted 的适配 API完全模拟 Vue onUnmounted 的卸载时机

🔗 相关资源

✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

当 Vue 3 遇上桥接模式:手把手教你优雅剥离虚拟滚动的业务大泥球

作者 guojb824
2026年4月14日 22:20

当 Vue 3 遇上桥接模式:手把手教你优雅剥离虚拟滚动的业务大泥球

摘要:本文结合 Vue3 $attrs 特性与桥接模式,详细解析如何优雅解耦虚拟滚动容器与复杂业务组件。通过拆分抽象与实现,实现属性事件无缝透传,告别臃肿代码。

在企业级前端开发中,长列表渲染是一个永远绕不开的性能瓶颈。在 Vue 技术栈中,我们经常会使用 vue3-virtual-scroll-list 这样的第三方库来实现虚拟滚动,从而保证页面在面对万条甚至十万条数据时依然如丝般顺滑。

但是,随着业务复杂度的提升,一个棘手的设计问题往往会浮出水面:如何在使用第三方虚拟滚动库时,优雅地实现基础组件与业务组件的解耦与隔离?

今天,我们就来详细拆解这个场景,并探讨在 Vue 3 下利用 $attrs 透传机制实现完美隔离的设计思路。

一、 场景痛点与需求分析

想象一下这样一个典型的开发场景:

你正在负责一个大型后台管理系统。项目中有多处需要用到虚拟滚动列表:有的是简单的文本日志列表,有的是复杂的商品卡片列表,还有的是带有各种交互按钮(点赞、删除、编辑)的用户评论列表。

为了复用代码,你决定封装一个基础虚拟滚动组件(VirtualScrollerBasic),它负责引入第三方库,设定预估高度。同时,你还需要一个基础列表项组件(ItemBasic),它负责最基本的数据渲染和样式布局。

但是,业务部门的需求是千变万化的:

  • 场景 A 的商品列表需要传入一个特殊的业务参数 customText 来显示促销信息。
  • 场景 B 的评论列表需要在点击时触发一个专属的业务事件 @customEvent
  • 场景 C 的日志列表需要在每一项的底部插入一段自定义的 DOM 结构(使用插槽)。

如果直接在基础组件里把这些业务参数和事件全部写死,基础组件就会变得无比臃肿,甚至最终沦为一个不可维护的“大泥球”。

我们的核心诉求是:基础列表和基础 Item 只关心自己该关心的事情(比如基础的布局、基础的数据 source),而业务列表和业务 Item 可以自由地增加属性、监听事件、甚至传递插槽,且这一切对基础组件来说必须是“无感”的。

二、 方案设计思路:桥接模式与职责分离

为了解决上述痛点,我们需要引入**桥接模式(Bridge Pattern)**的思想。

桥接模式的核心是“将抽象部分与实现部分分离,使它们都可以独立地变化”。在虚拟滚动的场景中:

  • 抽象部分(Abstraction):是列表的容器(如 VirtualScrollerBasic),负责虚拟滚动的核心机制、数据调度和预估高度计算。
  • 实现部分(Implementor):是具体的列表项渲染器接口,负责单条数据的 UI 展示和交互。

这两部分通过一个“桥梁”(即动态传入的 listComponent 属性)连接起来。在此基础上,业务组件只需要处理自己的业务逻辑,剩下的不属于自己范围的基础属性和事件,通过 Vue 3 的 $attrs(在组合式 API 中通过 useAttrs() 获取)完美透传给基础组件。

Vue 3 的 $attrs 有一个非常棒的特性:它不仅包含了外部传入的非 Props 属性,还包含了绑定的事件(自动转化为 onXxx 形式)。这为我们实现属性和事件的跨层透传提供了天然的便利。

设计架构图

classDiagram
    class VirtualScrollerBasic {
        +items: Array
        +listComponent: Component
        +basicText: String
        +render()
    }

    class VirtualScrollerList {
        +customText: String
        +handleCustomEvent()
        +render()
    }

    class ItemBasic {
        +index: Number
        +basicText: String
        +render()
    }

    class Item {
        +customText: String
        +handleEvent()
        +render()
    }

    VirtualScrollerBasic o-- ItemBasic : Bridge (通过 listComponent 桥接)
    VirtualScrollerBasic <|-- VirtualScrollerList : 扩展 (组合包裹)
    ItemBasic <|-- Item : 扩展 (组合包裹)

三、 Vue 3 下的代码实现

让我们来看看这套设计模式在 Vue 3 中是如何落地的。

1. 基础列表组件 (VirtualScrollerBasic.vue)

基础列表组件的职责是封装第三方库 vue3-virtual-scroll-list,并且负责将外部传入的 $attrs 整合后向下透传。

<template>
  <div class="virtual-scroller-container">
    <virtual-list
      class="virtual-list"
      :data-key="'id'"
      :data-sources="items"
      :data-component="listComponent"
      :estimate-size="50"
      :extra-props="{
        // 关键点1:合并透传所有外部传入的业务属性和业务事件(onXxx)
        ...attrs,
        // 关键点2:基础层私有的参数和事件,互不干扰
        basicText: '这是基础层参数',
        onBasicEvent: handleBasicEvent,
      }"
    >
      <template #header>
        <slot name="header"></slot>
      </template>
    </virtual-list>
  </div>
</template>

<script setup>
import { ref, useAttrs } from "vue";
import VirtualList from "vue3-virtual-scroll-list";
import ItemBasic from "./ItemBasic.vue";

const attrs = useAttrs();

const props = defineProps({
  listComponent: {
    type: Object,
    default: () => ItemBasic,
  },
});

// 关键点3:阻止属性直接绑定到根节点 div 上,防止 DOM 污染和事件重复触发
defineOptions({
  inheritAttrs: false,
});

const items = ref([
  /* 模拟数据 */
]);
const handleBasicEvent = (source) => {
  console.log("基础事件触发");
};
</script>

2. 基础 Item 组件 (ItemBasic.vue)

基础 Item 组件负责渲染列表项的最基本信息。它只关心基础的 UI 和数据结构,不知道任何关于业务层的特殊参数。

<template>
  <div class="basic-item">
    <div class="basic-content">
      <span>#{{ index }} - ID: {{ source.id }}</span>
      <span v-if="basicText" class="basic-text">({{ basicText }})</span>
      <button @click="handleClick">触发基础事件</button>
    </div>
    <!-- 留出插槽供业务层扩展 -->
    <slot name="footer"></slot>
  </div>
</template>

<script setup>
const props = defineProps({
  source: {
    type: Object,
    required: true,
  },
  index: {
    type: Number,
    default: 0,
  },
  basicText: {
    type: String,
    default: "",
  },
});

const emit = defineEmits(["basicEvent"]);

const handleClick = () => {
  emit("basicEvent", props.source);
};
</script>

<style scoped>
.basic-item {
  padding: 10px;
  border-bottom: 1px solid #eee;
}
.basic-text {
  color: #888;
  margin-left: 10px;
}
</style>

3. 业务 Item 组件 (Item.vue)

业务 Item 组件的职责是拦截并消费属于业务层的属性(customText)和事件(customEvent),并将剩下的属性通过 v-bind 透传给基础 Item 组件。

<template>
  <div class="custom-item">
    <!-- 关键点1:v-bind="attrs" 将没被当前组件消费的属性和事件透传给基础组件 -->
    <item-basic v-bind="attrs" :source="source">
      <template #footer>
        <div class="custom-footer">
          自定义footer <span v-if="customText"> - {{ customText }}</span>
          <el-button type="primary" @click="handleEvent(source)">
            触发业务事件 customEvent
          </el-button>
        </div>
      </template>
    </item-basic>
  </div>
</template>

<script setup>
import { useAttrs } from "vue";
import ItemBasic from "./ItemBasic.vue";

const attrs = useAttrs();

// 关键点2:只声明业务层自己需要消费的属性。
// 如果业务层确实需要用到基础层的参数,就需要手动传给基础组件
const props = defineProps({
  source: { type: Object, required: true }, // 点击事件需使用,所以保留
  customText: { type: String, default: "" }, // 业务专属属性
});

const emit = defineEmits(["customEvent"]);

// 关键点3:同样需要阻止属性绑定到根节点
defineOptions({
  inheritAttrs: false,
});

const handleEvent = (source) => {
  // 触发业务层专属事件
  emit("customEvent", source);
};
</script>

4. 业务列表组件 (VirtualScrollerList.vue)

在最外层的业务列表中,我们就可以像使用普通组件一样,随心所欲地传递业务参数和监听业务事件了,底层的一切复杂透传对它来说都是透明的。

<template>
  <div class="virtual-scroller-list-wrapper">
    <virtual-scroller-basic
      :list-component="Item"
      :customText="'这是通过透传传入的业务参数 customText'"
      @customEvent="handleCustomEvent"
    >
      <template #header>
        <div class="custom-header">自定义业务 Header 内容</div>
      </template>
    </virtual-scroller-basic>
  </div>
</template>

<script setup>
import VirtualScrollerBasic from "./VirtualScrollerBasic.vue";
import Item from "./Item.vue";

const handleCustomEvent = (source) => {
  alert(`业务层成功拦截 customEvent,Item ID: ${source.id}`);
};
</script>

四、 Vue 3 与 Vue 2 的实现区别解析

如果你还在使用 Vue 2,或者刚从 Vue 2 迁移过来,可能会对上面的实现感到一些疑惑。这里有必要重点强调一下 Vue 3 和 Vue 2 在透传机制上的巨大差异。

Vue 2:属性与事件是分离的

在 Vue 2 中,组件的“属性”和“事件”是严格区分开的:

  • 传递的数据和非 Props 属性会被收集到 $attrs 中。
  • 通过 @v-on 绑定的事件会被收集到 $listeners 中。

所以在 Vue 2 中,如果你想把业务组件绑定的 @customEvent 透传给底层的 vue-virtual-scroll-listextra-props,你必须手动去遍历 $listeners,把它们转换成 onXxx 格式的函数,然后再和 $attrs 合并:

// Vue 2 下的 Hack 写法
computed: {
  mergedExtraProps() {
    const listenersAsProps = {};
    for (const eventName in this.$listeners) {
      const propName = 'on' + eventName.charAt(0).toUpperCase() + eventName.slice(1);
      listenersAsProps[propName] = this.$listeners[eventName];
    }
    return { ...this.$attrs, ...listenersAsProps };
  }
}

不仅如此,在 Vue 2 中,由于底层组件接收到的 extra-props 只能以 Props 的形式被子组件接收,为了让子组件能像普通组件一样响应 @事件,我们往往还需要引入一个中间包装组件(Wrapper),利用函数式组件将 onXxx 的 Props 重新还原为真正的 $listeners 并绑定到实际渲染的组件上。

例如,我们需要定义一个 VirtualScrollerItemWrapper.vue

<script>
// Vue 2 函数式组件 Wrapper
export default {
  name: "VirtualScrollerItemWrapper",
  functional: true,
  render(h, context) {
    const { props, data } = context;
    const originalComponent = props.originalComponent; // 真实的业务组件
    const attrs = {};
    const on = {};

    // 遍历 props,将 onXxx 还原为事件监听器
    for (const key in props) {
      if (key === "originalComponent") continue;

      if (key.startsWith("on") && typeof props[key] === "function") {
        const eventName = key.charAt(2).toLowerCase() + key.slice(3);
        on[eventName] = props[key];
      } else {
        attrs[key] = props[key];
      }
    }

    return h(originalComponent, {
      attrs,
      on, // 重新绑定事件
      scopedSlots: data.scopedSlots,
    });
  },
};
</script>

然后在基础滚动组件中,我们不能直接渲染业务组件,而是必须把这个 Wrapper 传给 vue-virtual-scroll-listdata-component 属性,并将实际的业务组件通过 extra-props 传进去:

<template>
  <virtual-list
    :data-key="'id'"
    :data-sources="items"
    :data-component="VirtualScrollerItemWrapper" <!-- 使用包装组件 -->
    :estimate-size="50"
    :extra-props="{
      ...mergedExtraProps,
      originalComponent: listComponent // 将真实的渲染组件传给包装器
    }"
  />
</template>

可以看到,在 Vue 2 中为了实现这一套隔离与透传机制,代码非常冗长且绕脑。

Vue 3:大一统的 $attrs

Vue 3 进行了一次非常优雅的底层重构。它移除了 $listeners 对象,将所有通过 @event 绑定的事件,在编译时自动转换成了以 onXxx 开头的属性名(例如 @custom-event 变成了 onCustomEvent),并且统一收集到了 $attrs

正因为 Vue 3 的这个特性,我们在 VirtualScrollerBasic 中只需要写一句 ...attrs,就同时完成了属性和事件的透传!这与 vue3-virtual-scroll-list 要求的 extra-props 接收对象的 API 设计简直是天作之合。

五、 运行效果与总结

当代码运行起来后,你会看到:

  1. 列表顶部正确渲染了“自定义业务 Header 内容”。
  2. 每一项都正确渲染了基础数据(如 #0)和基础参数(如 基础参数)。
  3. 每一项的 Footer 都正确渲染了业务参数 这是通过透传传入的业务参数 customText

image.png

  1. 点击“触发业务事件 customEvent”按钮,外层的业务列表组件成功弹出了 Alert 提示框,拦截到了事件。

image-1.png

image-2.png

总结:用到的设计模式

通过这次重构,我们实际上是在前端工程中落地了以下几种经典的设计模式:

  1. 桥接模式 (Bridge Pattern):这是本文的核心架构。将“虚拟滚动容器(抽象层)”与“列表项渲染(实现层)”彻底解耦。通过 listComponent 这一桥梁连接,使得业务列表可以随意更换基础滚动机制,业务项也可以在不同的列表中复用,两者独立变化。
  2. 装饰器模式 (Decorator Pattern) / 高阶组件模式 (HOC):业务 Item 没有去修改基础 ItemBasic 的内部代码,而是通过包裹对其进行了增强(增加了业务插槽和事件),并通过 $attrs 将基础属性完美透传。
  3. 模板方法模式 (Template Method Pattern)VirtualScrollerBasic 定义了虚拟滚动的算法骨架(如何引入库、设定高度等),具体的 UI 表现通过插槽和动态组件延迟到了业务层去实现。

优雅的架构设计,往往不需要多么高深的语法,而是对框架特性(如 $attrs)的深刻理解,以及对“单一职责”原则的坚守。希望这篇文章能为你在处理复杂 Vue 组件封装时带来一些启发!

Vue条件渲染详解:v-if、v-show用法与实战指南

作者 PeterMap
2026年4月14日 19:52

在Vue开发中,页面交互往往需要根据不同的状态展示不同的内容——比如用户登录后显示个人中心,未登录时显示登录按钮;表单验证失败时显示错误提示,成功时显示提交成功信息。这种“按需显示”的需求,Vue提供了一套简洁高效的条件渲染方案,核心就是v-if、v-else、v-else-if和v-show这几个指令。今天就从基础用法到进阶技巧,全方位拆解Vue条件渲染,帮你精准掌握不同场景下的最优使用方式,写出更灵活、可维护的前端代码。

核心指令:v-if 系列——“按需渲染”的核心

v-if 是Vue中最基础、最常用的条件渲染指令,它的核心作用是:根据绑定表达式的真假,决定是否渲染当前元素及它的子元素。当表达式为真值(Truthy)时,元素会被渲染到DOM中;当表达式为假值(Falsy)时,元素会被从DOM中移除,而非简单隐藏。

1. v-if 基础用法:单个条件判断

v-if 可以直接绑定一个响应式状态,实现简单的“显示/隐藏”切换。需要注意的是,v-if 是一个指令,必须依附于某个具体的DOM元素,不能单独使用。

实战示例:根据用户登录状态显示不同内容

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

// 模拟用户登录状态:true为已登录,false为未登录
const isLogin = ref(false);

// 模拟登录操作
function login() {
  isLogin.value = true;
}

// 模拟退出登录操作
function logout() {
  isLogin.value = false;
}
</script>

<template>
  <div class="user-section">
    <!-- 已登录时显示个人中心入口 -->
    <button v-if="isLogin" @click="logout">退出登录</button>
    <span v-if="isLogin" class="user-info">欢迎回来,用户123</span>
    
    <!-- 未登录时显示登录按钮 -->
    <button v-if="!isLogin" @click="login">立即登录</button>
  </div>
</template>

<style>
.user-section {
  margin: 20px 0;
  padding: 15px;
  border: 1px solid #eee;
}
.user-info {
  margin: 0 10px;
  color: #42b983;
}
button {
  padding: 6px 12px;
  cursor: pointer;
  border: none;
  border-radius: 4px;
  background: #42b983;
  color: white;
}
button:hover {
  opacity: 0.9;
}
</style>

image.png

image.png

2. v-else:补充v-if的“否则”场景

当v-if的条件不成立时,我们可以用v-else指令添加一个“否则”的渲染区块。需要特别注意的是,v-else 必须紧跟在v-if 或 v-else-if 元素之后,不能单独存在,也不能插入其他元素,否则Vue会无法识别。

实战示例:优化登录状态显示(用v-else简化代码)

<script setup>
import { ref } from 'vue';
const isLogin = ref(false);

function toggleLogin() {
  isLogin.value = !isLogin.value;
}
</script>

<template>
  <div class="user-section">
    <button @click="toggleLogin">{{ isLogin ? '退出登录' : '立即登录' }}</button>
    <!-- v-if 和 v-else 紧跟,实现互斥显示 -->
    <div v-if="isLogin" class="user-info">
      欢迎回来,用户123<br/>
      <a href="#">进入个人中心</a>
    </div>
    <div v-else class="login-tip">
      请登录后查看更多内容 😊
    </div>
  </div>
</template>

<style>
/* 样式沿用上面的基础样式,新增提示样式 */
.login-tip {
  margin-top: 10px;
  color: #666;
  font-size: 14px;
}
</style>

3. v-else-if:多条件分支判断

当需要判断多个条件分支时,v-else-if 可以实现“if-else if-else”的逻辑,它可以连续使用多次,最终用v-else收尾(可选)。同样,v-else-if 必须紧跟在v-if 或前一个v-else-if 元素之后。

实战示例:根据用户等级显示不同权限提示

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

// 模拟用户等级:0-普通用户,1-会员,2-管理员
const userLevel = ref(1);

// 切换用户等级
function changeLevel(level) {
  userLevel.value = level;
}
</script>

<template>
  <div class="level-section">
    <button @click="changeLevel(0)">普通用户</button>
    <button @click="changeLevel(1)">会员用户</button>
    <button @click="changeLevel(2)">管理员</button>
    
    <div class="level-tip" v-if="userLevel === 0">
      普通用户:可查看基础内容,解锁更多功能请升级会员
    </div>
    <div class="level-tip" v-else-if="userLevel === 1">
      会员用户:可查看专属内容,享受优先客服服务
    </div>
    <div class="level-tip" v-else-if="userLevel === 2">
      管理员:拥有全部操作权限,可管理所有用户数据
    </div>
    <div class="level-tip" v-else>
      未知用户等级,请联系管理员
    </div>
  </div>
</template>

<style>
.level-section {
  margin: 20px 0;
  padding: 15px;
  border: 1px solid #eee;
}
.level-tip {
  margin-top: 15px;
  padding: 10px;
  border-radius: 4px;
}
.level-tip:nth-child(2) { background: #f5fafe; color: #4299e1; }
.level-tip:nth-child(3) { background: #fdf2f8; color: #9f7aea; }
.level-tip:nth-child(4) { background: #eaf6fa; color: #38b2ac; }
.level-tip:nth-child(5) { background: #faf0f5; color: #e53e3e; }
</style>

4. template 上的v-if:批量切换多个元素

v-if 必须依附于单个DOM元素,但如果我们需要同时切换多个元素的显示/隐藏,又不想额外添加一个包裹容器(比如div),就可以在template标签上使用v-if。template只是一个不可见的包装器,最终渲染的结果不会包含这个标签,完美解决“多元素切换”的需求。

实战示例:批量切换表单提示信息

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

// 模拟表单提交状态:true为提交成功,false为未提交
const submitSuccess = ref(false);

function submitForm() {
  // 模拟表单提交逻辑
  setTimeout(() => {
    submitSuccess.value = true;
  }, 1000);
}
</script>

<template>
  <form class="form" @submit.prevent="submitForm">
    <input type="text" placeholder="请输入内容" required />
    <button type="submit">提交</button>
    
    <!-- 用template包裹多个元素,批量切换显示/隐藏 -->
    <template v-if="submitSuccess">
      <div class="success-icon"></div>
      <p class="success-tip">表单提交成功!感谢您的反馈</p>
      <button type="button" @click="submitSuccess = false">重新提交</button>
    </template>
  </form>
</template>

<style>
.form {
  margin: 20px 0;
  padding: 15px;
  border: 1px solid #eee;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
input {
  padding: 8px;
}
.success-icon {
  font-size: 24px;
  color: #48bb78;
}
.success-tip {
  color: #48bb78;
  margin: 5px 0;
}
</style>

image.png

image.png

另一种选择:v-show 指令——“简单隐藏”的高效方案

除了v-if,Vue还提供了v-show指令用于条件显示元素,它的用法和v-if非常相似,都是通过绑定表达式的真假来控制元素的显示状态,但二者的底层实现和使用场景有很大区别。

v-show 的核心特点:无论条件是否成立,元素都会被渲染到DOM中,它只是通过切换元素的CSS display属性来控制显示/隐藏(display: none 或 display: 初始值),元素本身始终存在于DOM中。

v-show 基础用法

v-show 直接绑定响应式状态,语法和v-if一致,适合简单的显示/隐藏切换场景。

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

// 模拟开关状态
const isShow = ref(true);

function toggleShow() {
  isShow.value = !isShow.value;
}
</script>

<template>
  <div class="show-section">
    <button @click="toggleShow">{{ isShow ? '隐藏' : '显示' }}内容</button>
    <div v-show="isShow" class="show-content">
      这是v-show控制的内容,隐藏时只是display: none,不会从DOM中移除
    </div>
  </div>
</template>

<style>
.show-section {
  margin: 20px 0;
  padding: 15px;
  border: 1px solid #eee;
}
.show-content {
  margin-top: 10px;
  padding: 10px;
  background: #f5f5f5;
}
</style>

注意:v-show 不支持在template标签上使用,也不能和v-else搭配使用,只能单独作用于单个DOM元素。

关键对比:v-if vs v-show 怎么选?

很多初学者会混淆v-if和v-show的用法,其实二者的核心区别在于“是否从DOM中移除元素”,这也决定了它们的适用场景。下面通过表格清晰对比,帮你快速判断:

对比维度 v-if v-show
DOM存在性 条件为假时,元素从DOM中移除 无论条件真假,元素始终在DOM中
实现方式 动态创建/销毁DOM元素 切换CSS display属性
初始渲染开销 惰性渲染:条件为假时不渲染,初始开销小 无论条件如何,都会渲染,初始开销大
切换开销 创建/销毁DOM,切换开销大 仅切换CSS,切换开销小
适用场景 条件很少切换(如登录/未登录状态) 条件需要频繁切换(如弹窗、tab切换)
支持搭配 支持v-else、v-else-if,支持template 不支持v-else,不支持template

实战建议:如果需要频繁切换显示状态(比如导航菜单、弹窗),优先用v-show;如果条件切换频率低(比如用户身份判断、页面权限控制),优先用v-if,这样能减少DOM节点数量,提升页面性能。

进阶注意:v-if 与 v-for 的使用禁忌

在实际开发中,我们可能会遇到“需要过滤列表后渲染”的场景,这时容易习惯性地将v-if和v-for写在同一个元素上,但这种用法是Vue不推荐的,因为二者的优先级不明确,会导致渲染异常和性能问题。

Vue中,当v-if和v-for同时存在于一个元素上时,v-if会先执行,也就是说,Vue会先判断每个列表项是否满足v-if的条件,再进行循环渲染,这会导致v-for的循环次数增加,影响性能。

错误示例(不推荐)

<!-- 错误:v-if和v-for写在同一个元素上 -->
<ul>
  <li v-for="item in list" v-if="item.status === 1" :key="item.id">
    {{ item.name }}
  </li>
</ul>

正确做法(推荐)

方案1:先通过计算属性过滤列表,再用v-for渲染,避免v-if和v-for同元素

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

const list = ref([
  { id: 1, name: 'Vue基础', status: 1 },
  { id: 2, name: 'React基础', status: 0 },
  { id: 3, name: 'Vue条件渲染', status: 1 },
  { id: 4, name: 'JavaScript进阶', status: 0 }
]);

// 计算属性过滤状态为1的列表项
const activeList = computed(() => {
  return list.value.filter(item => item.status === 1);
});
</script>

<template>
  <ul>
    <!-- 只渲染过滤后的列表,无需v-if -->
    <li v-for="item in activeList" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>

方案2:用template包裹v-for,将v-if写在template上(适用于整体过滤列表)

<template>
  <!-- 先判断列表是否有数据,再循环渲染 -->
  <template v-if="list.length > 0">
    <ul>
      <li v-for="item in list" :key="item.id">
        {{ item.name }}
      </li>
    </ul>
  </template>
  <div v-else>
    暂无数据
  </div>
</template>

常见误区与避坑指南

  1. v-else、v-else-if 位置错误:必须紧跟在v-if或v-else-if元素之后,不能插入其他元素,否则会被Vue识别为无效指令;
  2. v-show 用于复杂组件:v-show 会始终渲染元素,即使条件为假,复杂组件的初始渲染会增加页面加载时间,建议改用v-if;
  3. v-if 用于频繁切换场景:频繁切换v-if会导致DOM频繁创建/销毁,产生性能开销,建议改用v-show;
  4. v-if 和 v-for 同元素使用:优先级混乱,导致渲染异常和性能问题,优先用计算属性过滤列表;
  5. 在template上使用v-show:v-show不支持template标签,会导致指令失效,需改为作用于具体DOM元素。

总结:条件渲染的最佳实践

Vue的条件渲染指令(v-if、v-else、v-else-if、v-show)为我们提供了灵活的页面交互方案,核心是根据场景选择合适的指令,兼顾性能和可读性。结合实战场景,提炼以下最佳实践:

  1. 简单显示/隐藏、频繁切换:用v-show,减少DOM切换开销;
  2. 条件切换少、需要销毁DOM:用v-if,减少初始渲染和DOM节点数量;
  3. 多条件分支:用v-if + v-else-if + v-else,确保指令顺序正确;
  4. 批量切换多个元素:用template包裹v-if,避免额外添加容器;
  5. 过滤列表渲染:用计算属性过滤列表后,再用v-for渲染,避免v-if和v-for同元素;
  6. 避免无效指令:v-show不搭配v-else、不用于template,v-else不单独使用。

掌握条件渲染的核心用法和场景差异,能让你在Vue开发中更灵活地控制页面展示,写出更高效、可维护的代码。条件渲染是Vue页面交互的基础,后续结合列表渲染、组件封装等知识点,还能实现更复杂的页面逻辑,解锁更多前端开发技巧。

Vue 项目结构与命名规范

作者 28256_
2026年4月14日 17:43

Vue 项目结构与命名规范

统一命名规则

  1. 普通文件夹:全小写(单单词 / 小驼峰双单词),统一、易读、兼容 URL
  2. 页面/视图文件夹:大驼峰(PascalCase),明确标识路由页面
  3. .vue 组件文件:大驼峰(PascalCase),官方推荐,与组件名保持一致
  4. JS / 工具 / 样式文件:小驼峰(camelCase),遵循 JavaScript 通用规范

官方依据


vue3-project/
├── .vscode/
├── node_modules/
├── public/
│   └── favicon.ico
├── src/
│   ├── assets/
│   │   ├── image/
│   │   │   ├── logo.png
│   │   │   └── userAvatar.png
│   │   └── styleGlobal/
│   │       ├── base.css
│   │       └── commonStyle.css
│   ├── components/
│   │   ├── common/
│   │   │   ├── Button.vue
│   │   │   └── UserInfo.vue
│   │   └── userCommon/
│   │       ├── Card.vue
│   │       └── OrderList.vue
│   ├── views/
│   │   ├── Home/
│   │   │   ├── index.vue
│   │   │   ├── HomeBanner.vue
│   │   │   ├── banner/
│   │   │   │   ├── Item.vue
│   │   │   │   └── BannerItem.vue
│   │   │   └── homeSection/
│   │   │       ├── Block.vue
│   │   │       └── SectionBlock.vue
│   │   └── UserCenter/
│   │       ├── index.vue
│   │       └── UserOrder.vue
│   ├── router/
│   │   ├── index.js
│   │   └── routeGuard.js
│   ├── store/
│   │   ├── modules/
│   │   │   ├── user.js
│   │   │   └── userInfo.js
│   │   └── index.js
│   ├── api/
│   │   ├── request.js
│   │   └── orderList.js
│   ├── utils/
│   │   ├── time.js
│   │   └── formatDate.js
│   ├── composables/
│   │   ├── index.js
│   │   └── useUser.js
│   ├── App.vue
│   └── main.js
├── .env.development
├── .env.production
├── .gitignore
├── index.html
├── package.json
├── vite.config.js
└── README.md

Vue3项目中给组件命名的方式

2026年4月14日 16:18

1.不是用插件给组件设置名称的方式

<template>
    <div><div>
</template>
<script lang="ts">
export default {
    name: "xxxx"
}
</script>

<script lang="ts" setup>

</script>
<style scoped></style>

2.通过vite-plugin-vue-setup-extend插件(推荐)

<template>
    <div><div>
</template>
<script lang="ts" setup name="xxxx">

</script>
<style scoped></style>

3.vite-plugin-vue-setup-extend安装与配置

(1)第一步

npm i vite-plugin-vue-setup-extend -D

(2)第二步配置vite.config.ts

import VueSetupExtend from "vite-plugin-vue-setup-extend";

export default defineConfig({
    plugins: [
        ...
        VueSetupExtend()
    ],
    ...
})

VueUse 全面指南|Vue3组合式工具集实战

2026年4月14日 15:57

VueUse 是基于 Vue3 Composition API 开发的实用函数集合库,由 Vue 核心团队成员主导维护,收录了200+开箱即用的工具函数,覆盖 DOM 操作、浏览器 API、响应式状态管理、性能优化等几乎所有前端开发场景。其核心理念是“拒绝重复造轮子”,将开发中常用但繁琐的逻辑(如本地存储、鼠标监听、防抖节流)封装成可复用的组合式函数,让开发者专注于业务逻辑,大幅提升开发效率。

VueUse 完美适配 Vue3,原生支持 TypeScript,支持摇树优化(Tree Shaking),按需引入不冗余,同时兼容 Vue2(需使用对应版本)和 SSR 场景,是 Vue3 项目开发的必备工具库之一。

一、VueUse 核心特点

  • Composition API 原生适配:所有函数均基于 Vue3 setup 语法和 ref/reactive 构建,API 风格与 Vue3 原生语法高度一致,上手无压力,无需额外学习成本。
  • 类型友好:全程使用 TypeScript 编写,自带完整类型定义,开发时可获得精准代码提示,减少类型错误,适配 TS 项目开发需求。
  • 模块化设计:采用按需引入机制,仅打包用到的函数,避免引入全部模块造成的体积膨胀,优化项目打包性能。
  • 场景覆盖广泛:涵盖响应式状态、浏览器能力、DOM 操作、表单控制、网络请求、性能优化等200+场景,满足日常开发99%的需求。
  • 灵活通用:支持 CDN 引入(无需打包器),适配 Vite、Webpack、Nuxt 等多种构建工具,同时支持 SSR 友好,可搭配 Vue Router、Firebase 等插件使用。
  • 中文文档完善:官方提供中文文档,每个函数均有交互式演示,查询便捷,新手可快速上手。

二、环境安装(Vue3 实战首选)

VueUse 核心包为 @vueuse/core,包含绝大多数常用工具函数;若需特定场景(如音频、地图),可安装对应子包。以下是主流安装方式,推荐使用 npm 或 pnpm:

2.1 核心包安装(必装)

// npm 安装(推荐,适配绝大多数项目)
npm install @vueuse/core -S

// yarn 安装
yarn add @vueuse/core

// pnpm 安装(高效包管理,推荐)
pnpm add @vueuse/core

2.2 特定场景子包安装(按需选择)

若需使用音频、地图、Firebase 等特定功能,可单独安装对应子包:

// 音频相关工具(如播放、录音)
npm install @vueuse/sound -S

// 地图相关工具(如高德、百度地图集成)
npm install @vueuse/map -S

// Firebase 集成工具
npm install @vueuse/firebase -S

2.3 CDN 引入(无需打包器,快速测试)

适合快速演示或无需打包的简单项目,引入后可通过 window.VueUse 访问所有函数:

<script src="https://unpkg.com/@vueuse/shared"></script>
<script src="https://unpkg.com/@vueuse/core"></script>

2.4 Nuxt 项目适配

Nuxt 3 已内置 VueUse 支持,无需单独安装,仅需在配置文件中注册模块即可实现自动引入:

// nuxt.config.ts(Nuxt 3)
export default defineNuxtConfig({
  modules: ['@vueuse/nuxt']
})

三、核心用法(按场景分类,实战必备)

VueUse 的使用逻辑简单统一:按需引入所需函数,在 setup 语法中调用,即可获得响应式结果或封装好的逻辑,无需手动处理事件绑定、销毁等冗余操作。以下按高频场景分类讲解,代码可直接复制套用。

3.1 响应式状态与本地存储(最常用)

用于处理响应式状态切换、计数器、本地存储(localStorage/sessionStorage)等场景,自动处理 JSON 序列化和响应式同步,刷新页面数据不丢失。

3.1.1 useLocalStorage(本地持久化存储)

替代原生 localStorage,返回响应式 ref 对象,修改后自动同步到本地存储,适合存储用户偏好、登录态等需要持久化的数据:

<template>
  <div>
    <p>当前主题:{{ theme }}</p>
    <button @click="theme = theme === 'light' ? 'dark' : 'light'">切换主题</button>
  </div>
</template>

<script setup lang="ts">
// 按需引入
import { useLocalStorage } from '@vueuse/core'

// 第一个参数:localStorage 键名;第二个参数:默认值
const theme = useLocalStorage('app_theme', 'light')
// 修改值时,自动同步到 localStorage
// theme.value = 'dark'
</script>

3.1.2 useSessionStorage(会话级存储)

用法与 useLocalStorage 完全一致,区别在于数据存储在 sessionStorage 中,关闭页面后自动丢失,适合存储临时数据(如表单草稿):

import { useSessionStorage } from '@vueuse/core'

// 存储临时表单数据
const tempForm = useSessionStorage('temp_form', { username: '', password: '' })

3.1.3 useToggle(布尔值切换)

快速实现布尔值切换逻辑,适合弹窗显示/隐藏、开关状态等场景:

<template>
  <button @click="toggle">{{ isShow ? '隐藏' : '显示' }}弹窗</button>
  <div v-if="isShow" class="modal">弹窗内容</div>
</template>

<script setup lang="ts">
import { useToggle } from '@vueuse/core'

// 接收默认值,返回 [状态值, 切换函数]
const [isShow, toggle] = useToggle(false)
// 也可自定义切换值(如切换主题字符串)
// const [theme, toggleTheme] = useToggle('light', ['light', 'dark'])
</script>

3.1.4 useCounter(计数器工具)

封装计数器逻辑,支持增减、重置、设置值等操作,适合数量选择、分页页码等场景:

<template>
  <div>
    <button @click="dec()">-</button>
    <span>{{ count }}</span>
    <button @click="inc()">+</button>
    <button @click="reset()">重置</button>
    <button @click="set(10)">设为10</button>
  </div>
</template>

<script setup lang="ts">
import { useCounter } from '@vueuse/core'

// 默认值为0,可指定初始值和范围(如 min:0, max:10)
const { count, inc, dec, reset, set } = useCounter(0, { min: 0, max: 10 })
</script>

3.2 浏览器能力封装(简化原生 API)

将浏览器原生 API(如鼠标监听、网络状态、窗口尺寸)封装为响应式函数,自动处理事件绑定与销毁,避免内存泄漏。

3.2.1 useMouse(鼠标位置监听)

实时获取鼠标坐标,支持限制监听范围(如某元素内),适合鼠标跟随、悬浮交互等场景:

<template>
  <div>
    <p>鼠标位置:({{ x.toFixed(0) }}, {{ y.toFixed(0) }})</p>
    <div 
      class="follow" 
      :style="{ left: `${x + 10}px`, top: `${y + 10}px` }"
    ></div>
  </div>
</template>

<script setup lang="ts">
import { useMouse } from '@vueuse/core'

// 获取鼠标x、y坐标(响应式)
const { x, y } = useMouse()
// 限制监听范围(仅在id为container的元素内监听)
// const { x, y } = useMouse({ target: document.getElementById('container') })
</script>

<style scoped>
.follow {
  position: fixed;
  width: 10px;
  height: 10px;
  background: red;
  border-radius: 50%;
}
</style>

3.2.2 useNetwork(网络状态监听)

监听用户网络连接状态(在线/离线),适合提示用户网络异常、离线缓存等场景:

<template>
  <div v-if="!isOnline" class="offline-tip">
    ❌ 网络已断开,请检查网络连接
  </div>
</template>

<script setup lang="ts">
import { useNetwork } from '@vueuse/core'

const { isOnline, downlink } = useNetwork()
// isOnline:是否在线(布尔值)
// downlink:网络速度(Mbps)
</script>

3.2.3 useDark(深色模式切换)

快速实现深色/浅色模式切换,自动同步系统主题偏好,支持自定义主题类名:

<template>
  <div>
    <h1>当前模式:{{ isDark ? '🌙 深色' : '☀️ 浅色' }}</h1>
    <button @click="toggleDark()">切换主题</button>
  </div>
</template>

<script setup lang="ts">
import { useDark, useToggle } from '@vueuse/core'

// 监听系统深色模式,同步到 html 标签的 class(默认添加 dark 类)
const isDark = useDark({
  selector: 'html',
  valueDark: 'dark',
  valueLight: ''
})
// 结合 useToggle 实现切换
const toggleDark = useToggle(isDark)
</script>

<style>
html.dark {
  background-color: #121212;
  color: #fff;
}
</style>

3.2.4 useWindowSize(窗口尺寸监听)

实时获取窗口宽高,响应式更新,适合响应式布局、适配移动端/桌面端场景:

import { useWindowSize } from '@vueuse/core'
import { computed } from 'vue'

const { width, height } = useWindowSize()
// 判断是否为移动端(屏幕宽度 < 768px)
const isMobile = computed(() => width.value < 768)

3.3 表单与输入控制(优化交互体验)

封装防抖、节流、剪贴板等常用表单交互逻辑,简化输入框搜索、复制粘贴等功能开发。

3.3.1 useDebounce(防抖输入)

对输入值进行防抖处理,延迟执行逻辑,适合搜索框、输入验证等场景,避免频繁触发请求:

<template>
  <input 
    v-model="searchInput" 
    placeholder="请输入搜索关键词"
    style="width: 300px; padding: 8px;"
  />
</template>

<script setup lang="ts">
import { ref, watch } from 'vue'
import { useDebounce } from '@vueuse/core'

const searchInput = ref('')
// 防抖处理:延迟500ms,返回防抖后的响应式值
const debouncedInput = useDebounce(searchInput, 500)

// 监听防抖后的值,触发搜索逻辑
watch(debouncedInput, (val) => {
  if (val) {
    console.log('搜索关键词:', val)
    // 调用搜索接口...
  }
})
</script>

3.3.2 useThrottle(节流控制)

限制函数执行频率,适合滚动事件、resize 事件等频繁触发的场景,优化性能:

import { useThrottle } from '@vueuse/core'

// 对窗口滚动事件进行节流,200ms内仅执行一次
const scrollY = useThrottle(window.scrollY, 200)

3.3.3 useCopyToClipboard(剪贴板操作)

简化复制文本到剪贴板的逻辑,自带复制状态反馈,无需编写原生 API 代码:

<template>
  <div>
    <input v-model="copyText" placeholder="请输入要复制的内容" />
    <button @click="copy()">{{ copied ? '已复制✅' : '点击复制' }}</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useCopyToClipboard } from '@vueuse/core'

const copyText = ref('https://vueuse.org')
// 接收复制源,返回 [复制函数, 复制状态]
const { copy, copied } = useCopyToClipboard({ source: copyText })
</script>

3.4 DOM 操作与交互(简化 DOM 操作)

封装常用 DOM 操作逻辑,自动处理元素监听、尺寸获取、拖拽等功能,避免手动操作 DOM 带来的冗余代码。

3.4.1 useScroll(滚动位置监听)

监听元素或窗口的滚动位置,适合滚动加载、回到顶部、滚动导航等场景:

<template>
  <div ref="container" class="scroll-container"&gt;
    <!-- 滚动内容 -->
  </div>
  <button @click="scrollToTop()" v-if="y > 100">回到顶部</button>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useScroll } from '@vueuse/core'

const container = ref(null)
// 监听指定元素的滚动位置(默认监听窗口)
const { y, scrollTo } = useScroll(container)

// 回到顶部
const scrollToTop = () => {
  scrollTo({ top: 0, behavior: 'smooth' })
}
</script>

3.4.2 useElementSize(元素尺寸监听)

实时获取元素的宽高,响应式更新,适合自适应布局、元素尺寸变化监听等场景:

<template>
  <div ref="box" class="box">自适应盒子</div>
  <p>盒子尺寸:{{ width }}px × {{ height }}px</p>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useElementSize } from '@vueuse/core'

const box = ref(null)
// 获取元素宽高(响应式)
const { width, height } = useElementSize(box)
</script>

3.4.3 onClickOutside(点击外部关闭)

监听元素外部的点击事件,适合弹窗、下拉菜单等场景,点击外部自动关闭:

<template>
  <button @click="isOpen = true">打开下拉菜单</button>
  <div ref="menu" v-if="isOpen" class="menu">
    下拉菜单内容
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { onClickOutside } from '@vueuse/core'

const isOpen = ref(false)
const menu = ref(null)

// 点击 menu 外部,关闭下拉菜单
onClickOutside(menu, () => {
  isOpen.value = false
})
</script>

3.5 网络请求(简化请求逻辑)

封装 fetch API,自带加载状态、错误处理,返回响应式数据,适合简单网络请求场景,可替代 axios 基础用法。

3.5.1 useFetch(通用网络请求)

<template>
  <div>
    <div v-if="isLoading">加载中...</div>
    <div v-if="error" class="error">请求失败:{{ error.message }}</div>
    <div v-if="data">{{ data.content }}</div>
  </div>
</template>

<script setup lang="ts">
import { useFetch } from '@vueuse/core'

// 发起 GET 请求,返回响应式数据、加载状态、错误信息
const { data, isLoading, error, execute } = useFetch('https://api.example.com/data', {
  method: 'GET',
  // 可选配置:请求头、参数等
  headers: { 'Content-Type': 'application/json' }
})

// 手动触发请求(如点击按钮发起请求)
// const handleFetch = () => execute()
</script>

四、VueUse 进阶技巧(实战提升)

4.1 函数组合使用

VueUse 的函数可自由组合,实现复杂功能,例如结合 useDark、useLocalStorage、useToggle 实现主题切换并持久化:

import { useDark, useToggle, useLocalStorage } from '@vueuse/core'

// 结合本地存储,持久化主题状态
const theme = useLocalStorage('app_theme', 'light')
const isDark = useDark({ valueDark: 'dark', valueLight: 'light' })
// 同步主题状态与本地存储
theme.value = isDark.value ? 'dark' : 'light'
// 切换主题时同步更新本地存储
const toggleTheme = useToggle(isDark, [false, true])
toggleTheme(() => {
  theme.value = isDark.value ? 'dark' : 'light'
})

4.2 自定义配置参数

大多数函数支持自定义配置,例如限制计数器范围、自定义本地存储键名、指定监听目标等,灵活适配业务需求:

// 1. 限制计数器范围(0-100)
const { count, inc } = useCounter(0, { min: 0, max: 100 })

// 2. 自定义本地存储键名和存储方式
const user = useLocalStorage('user_info', {}, {
  storage: sessionStorage, // 改用 sessionStorage 存储
  mergeDefaults: true // 合并默认值和存储值
})

// 3. 指定鼠标监听目标(仅在指定元素内监听)
const { x, y } = useMouse({ target: document.getElementById('container') })

4.3 避免常见误区

  • 不要全局引入所有函数:VueUse 支持摇树优化,按需引入即可,全局引入(如 import * as VueUse from '@vueuse/core')会导致打包体积膨胀。
  • 注意浏览器兼容性:部分函数(如 useBattery、useGeolocation)依赖浏览器原生 API,需做降级处理,避免在低版本浏览器中报错。
  • Vue2 适配:VueUse v12.0 及以上版本不再支持 Vue2,若使用 Vue2 项目,需安装 v11.x 版本:npm install @vueuse/core@11 -S

五、常用函数速查表(快速查询)

函数分类 常用函数 核心功能
响应式状态 useToggle、useCounter、useStorage 布尔值切换、计数器、响应式存储
浏览器能力 useMouse、useNetwork、useDark、useWindowSize 鼠标监听、网络状态、深色模式、窗口尺寸
表单控制 useDebounce、useThrottle、useCopyToClipboard 防抖、节流、剪贴板操作
DOM 操作 useScroll、useElementSize、onClickOutside 滚动监听、元素尺寸、点击外部关闭
网络请求 useFetch、useWebSocket 通用请求、WebSocket 连接

六、官方资源与学习渠道

总结:VueUse 是 Vue3 开发的“效率神器”,通过封装常用逻辑,大幅减少冗余代码,提升开发效率和代码可维护性。新手可从本文讲解的高频函数入手,结合官方文档,快速上手并应用到实际项目中,逐步掌握所有核心用法。

Vue3+Pinia实战完整版|从入门到精通,替代Vuex的状态管理首选

2026年4月14日 15:51

本文专为Vue3开发者打造,从Pinia基础认知入手,逐步讲解环境搭建、核心API用法,结合Vue3+TypeScript实战案例,覆盖日常开发99%场景,新手可直接套用代码,快速掌握Pinia全局状态管理,替代传统Vuex,提升开发效率。

核心定位:Pinia是Vue官方推荐的全局状态管理工具,2019年推出,旨在替代Vuex,采用组合式API风格,轻量、简洁且原生支持TS,适配Vue3和Vue2(本文重点聚焦Vue3+TS实战)。

一、Pinia基础认知(入门必看)

1.1 什么是Pinia

Pinia是一个用于跨组件、跨页面进行状态共享的全局状态管理库,功能与Vuex、Redux一致,但API更简洁,使用体验更贴近Vue3组合式API,本质上是Vuex5的最终实现形态——Vue官方团队在探索Vuex下一次迭代时,发现Pinia已满足大部分需求,最终决定用Pinia替代Vuex。

1.2 Pinia核心特点

  • 完整TS支持:无需手动编写复杂类型声明,原生支持类型推断,TS开发体验拉满,补全更流畅。
  • 极致轻量:压缩后体积仅1KB左右,无多余依赖,不增加项目负担。
  • 简化语法:移除Vuex中繁琐的mutations,仅保留state、getters、actions,降低学习和使用成本。
  • actions多支持:既支持同步操作,也支持异步操作(如接口请求),无需区分同步/异步逻辑。
  • 扁平化结构:无模块嵌套,只有store概念,每个store独立存在,可自由调用,无需管理复杂的命名空间。
  • 自动注册:store一旦创建,无需手动添加到全局,自动挂载,开箱即用。
  • 跨版本兼容:同时支持Vue3和Vue2,除初始化安装和SSR配置外,两者API完全一致。

1.3 Pinia与Vuex的核心区别

Pinia最初是为探索Vuex下一次迭代而设计,整合了Vuex核心团队的诸多想法,最终成为Vuex的替代方案,两者核心区别如下:

对比维度 Vuex Pinia
核心结构 State、Getters、Mutations(同步)、Actions(异步) State、Getters、Actions(同步+异步),无Mutations
版本适配 Vuex4适配Vue3,Vuex3适配Vue2,无法跨版本使用 最新版2.x,同时适配Vue3和Vue2
TS支持 需创建自定义复杂包装器,类型推断不友好 原生支持TS,类型推断完善,无需额外配置
模块结构 支持模块嵌套,需配置命名空间,逻辑繁琐 扁平化结构,无嵌套,store独立,可自由调用
注册方式 需手动注册store到全局 自动注册,创建后即可使用
API复杂度 API繁琐,需记住mutations提交、命名空间等规则 API简洁,贴近组合式API,上手成本低

1.4 适用场景

任何需要跨组件、跨页面共享状态的Vue3项目,无论是中小型项目(如个人博客、管理后台),还是大型项目(如电商平台),Pinia都能胜任,尤其适合TS开发的项目,能大幅提升开发效率和代码可维护性。

二、Vue3+Pinia环境搭建(实战第一步)

本章节以Vue3+TypeScript项目为例,讲解Pinia的安装、全局注册,步骤简洁,可直接复制命令和代码执行。

2.1 前提条件

已创建Vue3+TS项目(若未创建,执行命令:npm create vue@latest,选择TS、Pinia(可选,此处可跳过,后续手动安装))。

2.2 安装Pinia

打开终端,进入项目根目录,执行以下命令(三选一,推荐npm或yarn):

// npm 安装(推荐)
npm install pinia -S

// yarn 安装
yarn add pinia

// cnpm 安装
cnpm install pinia -S

2.3 全局注册Pinia(Vue3)

修改项目入口文件main.ts,引入并挂载Pinia实例,全局仅需配置一次:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
// 引入Pinia的createPinia方法
import { createPinia } from 'pinia'

// 创建Pinia实例
const pinia = createPinia()
// 创建Vue应用并挂载Pinia
const app = createApp(App)
app.use(pinia) // 挂载Pinia到Vue应用
app.mount('#app')

补充:Vue2中注册方式略有不同(需引入PiniaVuePlugin),本文聚焦Vue3,Vue2用法可参考文末补充说明。

三、Pinia核心用法(Vue3+TS实战)

Pinia的核心是Store(仓库),每个Store对应一个独立的状态模块,通过defineStore方法创建,包含state(状态)、getters(计算属性)、actions(业务逻辑)三部分,以下逐一讲解。

3.1 初始化Store(核心步骤)

推荐在项目根目录下创建src/store文件夹,用于存放所有Store文件,按业务模块划分(如用户模块、购物车模块),规范命名(如userStore.tscartStore.ts)。

步骤:先定义Store名称枚举(避免重复),再创建Store实例。

第一步:定义Store名称枚举(可选,推荐)

创建src/store/store-name.ts,用于统一管理Store名称,避免重复(尤其多Store场景):

// src/store/store-name.ts
// 用枚举定义Store名称,唯一且直观
export const enum Names {
  Test = 'TEST', // 测试Store名称
  User = 'USER', // 用户Store名称
  Cart = 'CART'  // 购物车Store名称
}

第二步:创建Store实例

创建src/store/index.ts(或按模块拆分,如userStore.ts),使用defineStore方法创建Store,核心包含state、getters、actions:

// src/store/index.ts
import { defineStore } from 'pinia';
import { Names } from './store-name'; // 引入Store名称枚举

// defineStore接收两个参数:
// 1. 唯一标识(必须与枚举值一致,全局唯一,不可重复)
// 2. 配置对象(包含state、getters、actions)
export const useTestStore = defineStore(Names.Test, {
  // 1. state:存储全局状态,必须是箭头函数(避免SSR数据污染,优化TS类型推导)
  state: () => {
    return {
      current: 1, // 数字类型状态
      name: '小马', // 字符串类型状态
      list: [1, 2, 3] // 数组类型状态
    };
  },

  // 2. getters:类似组件的computed,用于修饰状态,有缓存功能
  getters: {
    // 方式一:接收state作为参数(推荐,类型推断更友好)
    myGetCount(state) {
      // 缓存特性:页面多次使用,仅执行一次计算
      console.log('getters被调用');
      return state.current + 1;
    },

    // 方式二:不传递参数,使用this访问state(需指定返回值类型,否则TS推导失败)
    myGetName(): string {
      return `姓名:${this.name}`;
    },

    // 进阶:getters依赖其他getters
    myGetCombined(): string {
      return `${this.myGetName()},计数+1:${this.myGetCount}`;
    }
  },

  // 3. actions:类似组件的methods,用于修改state,支持同步和异步
  actions: {
    // 同步action:修改state(不能用箭头函数,否则this指向异常)
    setCurrentParam(num: number) {
      this.current += num; // 直接通过this访问state并修改
    },

    // 同步action:批量修改多个状态
    updateState(newCurrent: number, newName: string) {
      this.current = newCurrent;
      this.name = newName;
    },

    // 异步action:结合async/await(如接口请求)
    async fetchData() {
      // 模拟接口请求(实际开发中替换为真实接口)
      const res = await new Promise((resolve) => {
        setTimeout(() => {
          resolve({ current: 10, name: '异步更新后' });
        }, 1000);
      });
      // 异步请求成功后,修改state
      const data = res as { current: number; name: string };
      this.current = data.current;
      this.name = data.name;
    }
  },
});

3.2 组件中使用Store(核心实战)

在Vue3组件(<script setup lang="ts">)中,引入Store实例,即可访问、修改状态,调用actions,以下是完整示例。

3.2.1 基础使用(访问state、getters)

<template>
  <div class="pinia-demo">
    <h3>基础使用</h3>
    <!-- 直接访问state -->
    <p>当前计数:{{ testStore.current }}</p>
    <p>姓名:{{ testStore.name }}</p>
    <!-- 访问getters(直接当作属性使用,无需调用) -->
    <p>计数+1:{{ testStore.myGetCount }}</p>
    <p>组合getters:{{ testStore.myGetCombined }}</p>
  </div>
</template>

<script setup lang="ts">
// 1. 引入Store实例
import { useTestStore } from '@/store';

// 2. 创建Store实例(Pinia自动管理单例,多次调用返回同一个实例)
const testStore = useTestStore();
</script>

3.2.2 修改state(5种方式,实战常用)

Pinia提供多种修改state的方式,按需选择,推荐使用$patch(批量修改)和actions(业务逻辑封装)。

<template>
  <div class="pinia-demo">
    <h3>修改state</h3>
    <p>当前计数:{{ testStore.current }}</p>
    <button @click="handleDirectModify">1.直接修改</button>
    <button @click="handlePatchObj">2.$patch对象批量修改</button>
    <button @click="handlePatchFn">3.$patch函数自定义修改</button>
    <button @click="handleReplaceState">4.$state替换整个状态</button>
    <button @click="handleActionsModify">5.通过actions修改</button>
  </div>
</template>

<script setup lang="ts">
import { useTestStore } from '@/store';
const testStore = useTestStore();

// 方式1:直接修改(简单场景可用,不推荐复杂场景)
const handleDirectModify = () => {
  testStore.current++; // 直接修改单个状态
  // testStore.name = '新姓名'; // 直接修改单个状态
};

// 方式2:$patch对象形式(批量修改多个状态,推荐简单批量场景)
const handlePatchObj = () => {
  testStore.$patch({
    current: 10,
    name: '批量修改后',
    list: [4, 5, 6]
  });
};

// 方式3:$patch函数形式(自定义修改逻辑,推荐复杂场景)
const handlePatchFn = () => {
  testStore.$patch((state) => {
    state.current += 5; // 复杂计算修改
    state.list.push(7); // 数组操作
    if (state.current > 20) {
      state.name = '计数超标';
    }
  });
};

// 方式4:$state替换整个状态(需修改所有属性,不推荐常规场景)
const handleReplaceState = () => {
  testStore.$state = {
    current: 0,
    name: '替换整个状态',
    list: []
  };
};

// 方式5:通过actions修改(推荐,封装业务逻辑,便于维护和复用)
const handleActionsModify = () => {
  testStore.setCurrentParam(3); // 调用同步action
  // testStore.updateState(15, 'actions修改'); // 调用同步action
  // testStore.fetchData(); // 调用异步action
};
</script>

3.2.3 响应式解构state(关键技巧)

直接解构state会丢失响应性(Pinia的state默认用reactive处理,与Vue3 reactive解构规则一致),需使用Pinia提供的storeToRefs方法,实现响应式解构。

<template>
  <div class="pinia-demo">
    <h3>响应式解构</h3>
    <p>解构后计数:{{ current }}</p>
    <p>解构后姓名:{{ name }}</p>
    <button @click="handleChange">修改解构后的值</button>
  </div>
</template>

<script setup lang="ts">
import { useTestStore } from '@/store';
import { storeToRefs } from 'pinia'; // 引入storeToRefs

const testStore = useTestStore();

// 错误写法:直接解构,失去响应性
// const { current, name } = testStore;

// 正确写法:用storeToRefs解构,保持响应性
const { current, name } = storeToRefs(testStore);

// 修改解构后的值(需用.value,因为storeToRefs会将状态转为ref)
const handleChange = () => {
  current.value++;
  name.value = '解构后修改';
};
</script>

3.2.4 调用异步actions(实战常用)

actions支持async/await,可直接在组件中调用异步action,处理接口请求等异步逻辑,示例如下:

<template>
  <div class="pinia-demo">
    <h3>异步actions</h3>
    <p>当前计数:{{ testStore.current }}</p>
    <p>姓名:{{ testStore.name }}</p>
    <button @click="handleFetchData" :disabled="loading">
      {{ loading ? '加载中...' : '异步请求更新' }}
    </button>
  </div>
</template>

<script setup lang="ts">
import { useTestStore } from '@/store';
import { ref } from 'vue';

const testStore = useTestStore();
const loading = ref(false);

// 调用异步action
const handleFetchData = async () => {
  loading.value = true;
  try {
    await testStore.fetchData(); // 等待异步action执行完成
  } catch (err) {
    console.error('异步请求失败:', err);
  } finally {
    loading.value = false;
  }
};
</script>

3.3 多Store使用(实战场景)

Pinia无模块嵌套,多个Store独立存在,可在组件中同时引入多个Store,也可在一个Store中引入另一个Store(实现Store间通信)。

3.3.1 组件中引入多个Store

// src/store/userStore.ts(新增用户Store)
import { defineStore } from 'pinia';
import { Names } from './store-name';

export const useUserStore = defineStore(Names.User, {
  state: () => ({
    token: '',
    userInfo: { name: '游客', age: 18 }
  }),
  actions: {
    login(token: string, userInfo: any) {
      this.token = token;
      this.userInfo = userInfo;
    },
    logout() {
      this.token = '';
      this.userInfo = { name: '游客', age: 18 };
    }
  }
});

// 组件中使用多个Store
<script setup lang="ts">
import { useTestStore } from '@/store';
import { useUserStore } from '@/store/userStore';

const testStore = useTestStore();
const userStore = useUserStore();

// 调用不同Store的方法
const handleLogin = () => {
  userStore.login('abc123', { name: '小明', age: 20 });
};
</script>

3.3.2 Store间通信(一个Store调用另一个Store)

在一个Store的actions中,引入另一个Store实例,即可实现Store间的数据交互:

// src/store/cartStore.ts(购物车Store)
import { defineStore } from 'pinia';
import { Names } from './store-name';
import { useUserStore } from './userStore'; // 引入用户Store

export const useCartStore = defineStore(Names.Cart, {
  state: () => ({
    cartList: [] as { id: number; name: string; price: number }[]
  }),
  actions: {
    // 添加商品到购物车(需判断用户是否登录)
    addToCart(goods: { id: number; name: string; price: number }) {
      const userStore = useUserStore(); // 实例化用户Store
      if (!userStore.token) {
        alert('请先登录');
        return;
      }
      this.cartList.push(goods);
    }
  }
});

四、Vue3+Pinia实战案例(模拟电商场景)

结合前面的核心用法,实现一个简单的电商场景实战案例,包含「用户登录/退出」「购物车添加/删除」「全局状态共享」,整合多Store、异步actions、响应式解构等核心知识点,可直接复制到项目中使用。

4.1 实战准备(创建3个Store)

创建store-name.tsuserStore.ts(用户)、cartStore.ts(购物车)、goodsStore.ts(商品),代码如下:

// 1. store-name.ts(Store名称枚举)
export const enum Names {
  User = 'USER',
  Cart = 'CART',
  Goods = 'GOODS'
}

// 2. userStore.ts(用户Store)
import { defineStore } from 'pinia';
import { Names } from './store-name';

// 定义用户信息类型(TS类型约束)
interface UserInfo {
  name: string;
  age: number;
  avatar: string;
}

export const useUserStore = defineStore(Names.User, {
  state: () => ({
    token: localStorage.getItem('token') || '', // 持久化存储token
    userInfo: {} as UserInfo
  }),
  actions: {
    // 登录(异步,模拟接口请求)
    async login(account: string, password: string) {
      // 模拟接口请求
      const res = await new Promise((resolve) => {
        setTimeout(() => {
          resolve({
            token: 'pinia_demo_token_123',
            userInfo: { name: '小明', age: 22, avatar: 'https://picsum.photos/200/200' }
          });
        }, 1000);
      });
      const data = res as { token: string; userInfo: UserInfo };
      this.token = data.token;
      this.userInfo = data.userInfo;
      // 本地持久化token(避免页面刷新丢失)
      localStorage.setItem('token', data.token);
    },
    // 退出登录
    logout() {
      this.token = '';
      this.userInfo = {} as UserInfo;
      localStorage.removeItem('token');
    }
  }
});

// 3. goodsStore.ts(商品Store)
import { defineStore } from 'pinia';
import { Names } from './store-name';

// 商品类型约束
interface Goods {
  id: number;
  name: string;
  price: number;
  img: string;
  stock: number;
}

export const useGoodsStore = defineStore(Names.Goods, {
  state: () => ({
    goodsList: [] as Goods[] // 商品列表
  }),
  actions: {
    // 异步获取商品列表(模拟接口)
    async fetchGoodsList() {
      const res = await new Promise((resolve) => {
        setTimeout(() => {
          resolve([
            { id: 1, name: 'Vue3实战教程', price: 99, img: 'https://picsum.photos/200/200', stock: 100 },
            { id: 2, name: 'Pinia入门手册', price: 59, img: 'https://picsum.photos/200/200', stock: 50 },
            { id: 3, name: 'TS入门到精通', price: 79, img: 'https://picsum.photos/200/200', stock: 80 }
          ]);
        }, 800);
      });
      this.goodsList = res as Goods[];
    }
  }
});

// 4. cartStore.ts(购物车Store)
import { defineStore } from 'pinia';
import { Names } from './store-name';
import { useUserStore } from './userStore';
import { Goods } from './goodsStore'; // 复用商品类型

export const useCartStore = defineStore(Names.Cart, {
  state: () => ({
    cartList: [] as { goods: Goods; count: number }[] // 购物车列表(商品+数量)
  }),
  getters: {
    // 计算购物车总价格
    cartTotalPrice(state) {
      return state.cartList.reduce((total, item) => {
        return total + item.goods.price * item.count;
      }, 0);
    },
    // 计算购物车商品总数
    cartTotalCount(state) {
      return state.cartList.reduce((total, item) => total + item.count, 0);
    }
  },
  actions: {
    // 添加商品到购物车
    addToCart(goods: Goods, count: number = 1) {
      const userStore = useUserStore();
      if (!userStore.token) {
        alert('请先登录');
        return;
      }
      // 判断商品是否已在购物车中
      const existingItem = this.cartList.find(item => item.goods.id === goods.id);
      if (existingItem) {
        existingItem.count += count;
      } else {
        this.cartList.push({ goods, count });
      }
    },
    // 从购物车删除商品
    removeFromCart(goodsId: number) {
      this.cartList = this.cartList.filter(item => item.goods.id !== goodsId);
    },
    // 修改购物车商品数量
    updateCartCount(goodsId: number, count: number) {
      const item = this.cartList.find(item => item.goods.id === goodsId);
      if (item) {
        item.count = count;
      }
    },
    // 清空购物车
    clearCart() {
      this.cartList = [];
    }
  }
});

4.2 实战组件开发(3个核心组件)

4.2.1 登录组件(Login.vue)

<template>
  <div class="login-container">
    <h2>用户登录</h2>
    <div class="form-item">
      <label>账号:</label>
      <input v-model="account" type="text" placeholder="请输入账号" />
    </div>
    <div class="form-item">
      <label>密码:</label>
      <input v-model="password" type="password" placeholder="请输入密码" />
    </div>
    <button @click="handleLogin" :disabled="loading">
      {{ loading ? '登录中...' : '登录' }}
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useUserStore } from '@/store/userStore';
import { useRouter } from 'vue-router'; // 路由跳转(需配置路由)

const userStore = useUserStore();
const router = useRouter();
const account = ref('');
const password = ref('');
const loading = ref(false);

const handleLogin = async () => {
  if (!account.value || !password.value) {
    alert('请输入账号和密码');
    return;
  }
  loading.value = true;
  try {
    await userStore.login(account.value, password.value);
    alert('登录成功');
    router.push('/home'); // 登录成功跳转首页
  } catch (err) {
    alert('登录失败,请重试');
  } finally {
    loading.value = false;
  }
};
</script>

<style scoped>
.login-container {
  width: 300px;
  margin: 100px auto;
  text-align: center;
}
.form-item {
  margin: 15px 0;
  text-align: left;
}
input {
  width: 100%;
  padding: 8px;
  margin-top: 5px;
}
button {
  width: 100%;
  padding: 10px;
  background: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
button:disabled {
  background: #ccc;
  cursor: not-allowed;
}
</style>

4.2.2 首页组件(Home.vue)

<template>
  <div class="home-container">
    <header class="home-header">
      <h1>Pinia电商实战</h1>
      <div class="user-info">
        <img v-if="userInfo.avatar" :src="userInfo.avatar" alt="用户头像" class="avatar" />
        <span v-if="userInfo.name">欢迎您,{{ userInfo.name }}</span>
        <button @click="handleLogout" v-if="token">退出登录</button>
        <button @click="toLogin" v-else>去登录</button>
        <div class="cart-icon" @click="toCart">
          购物车({{ cartTotalCount }})
        </div>
      </header>

      <section class="goods-list">
        <h2>商品列表</h2>
        <div class="goods-item" v-for="goods in goodsList" :key="goods.id">
          <img :src="goods.img" alt="商品图片" class="goods-img" />
          <div class="goods-info">
            <h3>{{ goods.name }}</h3>
            <p class="price">¥{{ goods.price }}</p>
            <p class="stock">库存:{{ goods.stock }}</p>
            <button @click="addToCart(goods)">加入购物车</button>
          </div>
        </div>
      </section>
    </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue';
import { useUserStore } from '@/store/userStore';
import { useGoodsStore } from '@/store/goodsStore';
import { useCartStore } from '@/store/cartStore';
import { storeToRefs } from 'pinia';
import { useRouter } from 'vue-router';

// 实例化Store
const userStore = useUserStore();
const goodsStore = useGoodsStore();
const cartStore = useCartStore();
const router = useRouter();

// 响应式解构状态(避免失去响应性)
const { token, userInfo } = storeToRefs(userStore);
const { goodsList } = storeToRefs(goodsStore);
const { cartTotalCount, addToCart } = cartStore;

// 组件挂载时,获取商品列表
onMounted(() => {
  goodsStore.fetchGoodsList();
});

// 退出登录
const handleLogout = () => {
  userStore.logout();
  alert('退出成功');
  router.push('/login');
};

// 跳转登录页
const toLogin = () => {
  router.push('/login');
};

// 跳转购物车页
const toCart = () => {
  if (!token.value) {
    alert('请先登录');
    router.push('/login');
    return;
  }
  router.push('/cart');
};
</script>

<style scoped>
.home-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px 20px;
  border-bottom: 1px solid #eee;
}
.avatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  margin-right: 10px;
}
.user-info {
  display: flex;
  align-items: center;
}
.user-info button {
  margin-left: 20px;
  padding: 5px 10px;
  cursor: pointer;
}
.cart-icon {
  margin-left: 20px;
  cursor: pointer;
  font-weight: bold;
}
.goods-list {
  padding: 20px;
}
.goods-item {
  display: flex;
  margin: 20px 0;
  border-bottom: 1px solid #eee;
  padding-bottom: 20px;
}
.goods-img {
  width: 100px;
  height: 100px;
  margin-right: 20px;
}
.goods-info {
  flex: 1;
}
.price {
  color: #ff4400;
  font-size: 18px;
  font-weight: bold;
}
.stock {
  color: #666;
  margin: 10px 0;
}
.goods-info button {
  padding: 8px 15px;
  background: #ff4400;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

4.2.3 购物车组件(Cart.vue)

<template>
  <div class="cart-container">
    <h2>我的购物车</h2>
    <div class="cart-empty" v-if="cartList.length === 0">
      购物车为空,快去添加商品吧!
    </div>
    <div class="cart-list" v-else>
      <div class="cart-item" v-for="item in cartList" :key="item.goods.id">
        <img :src="item.goods.img" alt="商品图片" class="cart-img" />
        <div class="cart-info">
          <h3>{{ item.goods.name }}</h3>
          <p class="price">¥{{ item.goods.price }}</p>
          <div class="count-control">
            <button @click="updateCount(item.goods.id, item.count - 1)" :disabled="item.count <= 1">-</button>
            <span>{{ item.count }}</span>
            <button @click="updateCount(item.goods.id, item.count + 1)" :disabled="item.count >= item.goods.stock">+</button>
          </div>
        </div>
        <button class="delete-btn" @click="removeFromCart(item.goods.id)">删除</button>
      </div>
      <div class="cart-footer">
        <button class="clear-btn" @click="clearCart">清空购物车</button>
        <div class="total-info">
          合计:<span class="total-price">¥{{ cartTotalPrice.toFixed(2) }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useCartStore } from '@/store/cartStore';
import { useUserStore } from '@/store/userStore';
import { storeToRefs } from 'pinia';
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';

const cartStore = useCartStore();
const userStore = useUserStore();
const router = useRouter();

// 响应式解构
const { cartList, cartTotalPrice } = storeToRefs(cartStore);
const { token } = storeToRefs(userStore);
const { updateCartCount, removeFromCart, clearCart } = cartStore;

// 组件挂载时,判断是否登录
onMounted(() => {
  if (!token.value) {
    alert('请先登录');
    router.push('/login');
  }
});

// 修改商品数量
const updateCount = (goodsId: number, count: number) => {
  updateCartCount(goodsId, count);
};
</script>

<style scoped>
.cart-container {
  padding: 20px;
}
.cart-empty {
  text-align: center;
  padding: 50px;
  color: #666;
  font-size: 18px;
}
.cart-item {
  display: flex;
  align-items: center;
  margin: 20px 0;
  border-bottom: 1px solid #eee;
  padding-bottom: 20px;
}
.cart-img {
  width: 80px;
  height: 80px;
  margin-right: 20px;
}
.cart-info {
  flex: 1;
}
.price {
  color: #ff4400;
  font-weight: bold;
  margin: 10px 0;
}
.count-control {
  display: flex;
  align-items: center;
}
.count-control button {
  width: 30px;
  height: 30px;
  border: 1px solid #eee;
  background: #fff;
  cursor: pointer;
}
.count-control button:disabled {
  background: #eee;
  cursor: not-allowed;
}
.count-control span {
  width: 60px;
  text-align: center;
}
.delete-btn {
  padding: 8px 15px;
  background: #ff0000;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.cart-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 30px;
}
.clear-btn {
  padding: 8px 15px;
  background: #666;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.total-info {
  font-size: 18px;
  font-weight: bold;
}
.total-price {
  color: #ff4400;
  margin-left: 10px;
}
</style>

4.3 路由配置(router/index.ts)

配置路由,实现组件跳转,需先安装vue-router:npm install vue-router@4 -S,然后配置路由:

// src/router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
import Login from '@/views/Login.vue';
import Home from '@/views/Home.vue';
import Cart from '@/views/Cart.vue';

const routes: RouteRecordRaw[] = [
  { path: '/', redirect: '/home' },
  { path: '/login', component: Login },
  { path: '/home', component: Home },
  { path: '/cart', component: Cart }
];

const router = createRouter({
  history: createWebHistory(),
  routes
});

export default router;

4.4 入口文件配置(main.ts)

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
import router from './router' // 引入路由

const app = createApp(App)
app.use(createPinia()) // 挂载Pinia
app.use(router) // 挂载路由
app.mount('#app')

4.5 实战效果说明

  1. 未登录状态下,点击「加入购物车」「购物车图标」,会提示登录并跳转登录页;

  2. 登录成功后,跳转首页,显示用户信息,可查看商品列表、添加商品到购物车;

  3. 购物车页面可修改商品数量、删除商品、清空购物车,实时显示合计价格和商品总数;

  4. 退出登录后,清空用户状态和token,购物车状态保留(可结合持久化插件优化,见下文)。

五、Pinia进阶技巧(实战必备)

5.1 数据持久化(避免页面刷新丢失)

Pinia默认不持久化数据,页面刷新后state会重置,可使用pinia-plugin-persistedstate插件实现本地存储(localStorage/sessionStorage)。

// 安装插件
npm install pinia-plugin-persistedstate -S
// main.ts 配置插件
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' // 引入插件
import router from './router'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // 挂载插件

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

在Store中配置持久化(以购物车Store为例):

export const useCartStore = defineStore(Names.Cart, {
  state: () => ({ /* ... */ }),
  getters: { /* ... */ },
  actions: { /* ... */ },
  // 配置持久化
  persist: {
    key: 'cartStore', // 存储的key(localStorage中的key)
    storage: localStorage, // 存储方式(localStorage/sessionStorage)
    paths: ['cartList'] // 需要持久化的state字段(默认全部持久化)
  }
});

5.2 调试工具使用(Vue DevTools)

Pinia支持Vue DevTools,可实时查看Store状态、跟踪actions执行,便于调试:

  • Vue3中,安装Vue DevTools扩展,打开开发者工具,切换到「Pinia」面板,即可查看所有Store的state、getters;
  • 支持跟踪actions执行记录,可查看每次actions调用的参数和状态变化;
  • Vue3中暂不支持time-travel功能(时间回溯),Vue2中支持(需配合Vuex接口)。

5.3 模块热更新(HMR)

Pinia支持模块热更新,修改Store代码后,无需重新加载页面,即可生效,且会保留现有状态,提升开发效率,无需额外配置,Vue3项目默认支持。

六、常见问题与解决方案(实战避坑)

  • 问题1:组件中解构state后,修改值不生效? 解决方案:使用storeToRefs解构,修改时需加.value(如current.value++),直接解构会丢失响应性。
  • 问题2:actions中使用this指向异常? 解决方案:actions中的方法不能用箭头函数,需用普通函数,否则this无法指向Store实例。
  • 问题3:页面刷新后,Pinia状态丢失? 解决方案:使用pinia-plugin-persistedstate插件,配置持久化存储。
  • 问题4:多个Store之间无法通信? 解决方案:在需要通信的Store中,引入目标Store实例,即可访问其state和actions。
  • 问题5:TS类型推断失败,提示“this类型为any”? 解决方案:getters中使用this时,需指定返回值类型;actions中修改state时,确保state字段类型与赋值类型一致。
  • 问题6:Vue2中使用Pinia报错? 解决方案:Vue2中需额外引入PiniaVuePlugin,注册方式参考上传文档中的Vue2配置。

七、总结

Pinia是Vue3官方推荐的状态管理工具,相比Vuex,它更简洁、轻量、易上手,原生支持TS,完美适配组合式API,是Vue3项目的首选状态管理方案。

本文从基础认知、环境搭建、核心用法,到完整实战案例,覆盖了Pinia开发的全流程,重点讲解了Vue3+TS下的实战技巧,新手可按步骤搭建环境、编写代码,快速上手;老手可通过实战案例查漏补缺,优化项目中的状态管理逻辑。

核心要点:Store是Pinia的核心,每个Store包含state、getters、actions;修改state推荐使用$patch和actions;解构state需用storeToRefs保持响应性;结合插件可实现数据持久化,提升用户体验。

一文看懂:Vue3 watch 用 VuReact 转成 React 长啥样

作者 Ruihong
2026年4月14日 11:12

大家好,我是专注前端框架迁移、编译工具实践的掘金博主~在 Vue3 转 React 的过程中,watch 作为最常用的响应式监听 API,手动改写很容易丢失逻辑、写错依赖。

今天继续用 VuReact 工具,给大家带来 Vue3 watch → React 编译对照,全程一比一还原、保留所有行为与内链,看完直接上手迁移。


前言

先明确核心: VuReact 是能将 Vue 3 代码编译为标准、可维护 React 代码的工具 它最大亮点:编译阶段自动分析依赖、自动生成依赖追踪,完美对齐 Vue 响应式监听行为,不用手动处理 React Hooks 依赖。

本文只聚焦一个高频 API: 👉 Vue3 watch → React 等价代码 全程对照,不冗余、直接看核心。

前置约定(避免理解偏差)

为了示例清爽,先统一两点:

  1. 只保留核心逻辑,省略组件包裹、无关配置
  2. 默认你已熟悉 Vue3 watch 用法与核心行为

一、基础版:watch → useWatch

Vue 标准 watch 监听,支持 immediate、清理函数 onCleanup,VuReact 直接编译为 useWatch

Vue 源码

<script setup>
import { ref, watch } from 'vue';
const userId = ref(1);

watch(
  userId,
  async (newId, oldId, onCleanup) => {
    let cancelled = false;
    onCleanup(() => {
      cancelled = true;
    });
    const data = await fetchUser(newId);
    if (!cancelled) {
      userData.value = data;
    }
  },
  { immediate: true },
);
</script>

VuReact 编译后 React 代码

import { useVRef, useWatch } from '@vureact/runtime-core';
const userId = useVRef(1);

useWatch(
  userId,
  async (newId, oldId, onCleanup) => {
    let cancelled = false;
    onCleanup(() => {
      cancelled = true;
    });
    const data = await fetchUser(newId);
    if (!cancelled) {
      setUserData(data);
    }
  },
  { immediate: true },
);

核心要点

  • Vue watch() 直接编译为 useWatch
  • 完全保留:回调参数、immediateonCleanup 清理机制
  • 编译阶段自动分析依赖、深度追踪,无需手动管理依赖数组

二、深度监听 & 多源监听:对象/数组来源兼容

watch 监听对象内部属性、多源数组时,VuReact 同样支持 deep 与多源写法,行为完全对齐 Vue。

Vue 源码(深度监听 + 多源监听)

<script setup>
import { reactive, watch } from 'vue';
const state = reactive({
  info: { name: 'Vureact', version: '1.0' },
  count: 0,
});

// 深度监听对象内部
watch(
  () => state.info,
  (newInfo) => {
    console.log('对象内部变化:', newInfo.name);
  },
  { deep: true },
);

// 多源监听
watch([state.count, () => state.info.name], ([newCount, newName]) => {
  console.log('计数:', newCount, '名称:', newName);
});
</script>

VuReact 编译后 React 代码

import { useReactive, useWatch } from '@vureact/runtime-core';
const state = useReactive({
  info: { name: 'Vureact', version: '1.0' },
  count: 0,
});

useWatch(
  () => state.info,
  (newInfo) => {
    console.log('对象内部变化:', newInfo.name);
  },
  { deep: true },
);

useWatch([state.count, () => state.info.name], ([newCount, newName]) => {
  console.log('计数:', newCount, '名称:', newName);
});

对应关系

  • 监听函数写法、deep: true 深度监听完全保留
  • 多源数组监听直接兼容
  • 编译器自动做依赖分析,不用手动写 deps

三、一句话总结

用 VuReact 做 Vue3 → React 迁移,watch 相关规则:

  1. watchuseWatch
  2. 支持 immediate / deep / onCleanup 全部选项
  3. 支持单源、函数返回值、多源数组监听
  4. 依赖自动追踪,无需手动管理依赖数组
  5. 行为 1:1 对齐 Vue,迁移零逻辑损耗

相关资源

❤️ 觉得有用就 点赞 + 收藏 + 关注,持续更新前端迁移/编译工具实战!

一文看懂:Vue3 watchEffect 用 VuReact 转成 React 长啥样

作者 Ruihong
2026年4月14日 11:06

大家好,我是专注前端框架迁移、编译工具实践的掘金博主~最近很多同学在做 Vue3 → React 技术栈迁移,被响应式 API 对齐、依赖手动管理搞得头大,尤其是 watchEffect 这种自动依赖收集的核心 API,在 React 里很容易漏写依赖。

今天就用 VuReact 这个编译工具,直接把 Vue3 watchEffect 的各种用法一比一翻译成标准可维护的 React 代码,全程对照、看完即用。


前言

先明确核心: VuReact 是能将 Vue 3 代码编译为标准、可维护 React 代码的工具 它最大亮点:编译阶段自动分析依赖、自动生成依赖数组,完美对齐 Vue 响应式行为,不用手动维护 React Hooks 依赖。

本文只聚焦一个高频 API: 👉 Vue3 watchEffect → React 等价代码 全程对照,不冗余、直接看核心。

前置约定(避免理解偏差)

为了示例清爽,先统一两点:

  1. 只保留核心逻辑,省略组件包裹、无关配置
  2. 默认你已熟悉 Vue3 watchEffect 用法与行为

一、基础版:watchEffect → useWatchEffect

Vue 最常用的基础 watchEffect,自动收集依赖、自动触发副作用。

Vue 源码

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

watchEffect(() => {
  console.log(`当前计数是: ${count.value}`);
});
</script>

VuReact 编译后 React 代码

import { useVRef, useWatchEffect } from '@vureact/runtime-core';
const count = useVRef(0);

useWatchEffect(() => {
  console.log(`当前计数是: ${count.value}`);
}, [count.value]);

核心要点

  • Vue watchEffect() 直接编译为 useWatchEffect
  • 编译阶段自动分析依赖并生成精准依赖数组,无需手动管理
  • 完全模拟 Vue watchEffect 的自动依赖收集、清理机制、停止控制

二、带 flush 选项:post / sync 对齐渲染时机

Vue 中通过 flush: 'post' / flush: 'sync' 控制执行时机,VuReact 直接映射为专用 Hook,保持渲染时机一致。

Vue 源码(post + sync)

<script setup>
import { ref, watchEffect } from 'vue';
const width = ref(0);
const elRef = ref(null);

// DOM 更新后执行
watchEffect(
  () => {
    if (elRef.value) {
      width.value = elRef.value.offsetWidth;
    }
  },
  { flush: 'post' },
);

// 同步立即执行
watchEffect(
  () => {
    console.log(elRef.value);
  },
  { flush: 'sync' },
);
</script>

VuReact 编译后 React 代码

import { useVRef } from '@vureact/runtime-core';
import { useWatchPostEffect, useWatchSyncEffect } from '@vureact/runtime-core';

const width = useVRef(0);
const elRef = useVRef(null);

useWatchPostEffect(
  () => {
    if (elRef.value) {
      width.value = elRef.value.offsetWidth;
    }
  },
  [elRef.value, width.value, elRef.value.offsetWidth]
);

useWatchSyncEffect(
  () => {
    console.log(elRef.value);
  },
  [elRef.value]
);

对应关系

  • flush: 'post'useWatchPostEffect
  • flush: 'sync'useWatchSyncEffect
  • 执行时机、依赖追踪、副作用行为完全对齐 Vue
  • 依赖数组依旧自动生成,无需手动编写

三、一句话总结

用 VuReact 做 Vue3 → React 迁移,watchEffect 相关规则:

  1. watchEffectuseWatchEffect
  2. flush: 'post'useWatchPostEffect
  3. flush: 'sync'useWatchSyncEffect
  4. 依赖自动收集、deps 自动生成,不用手动维护
  5. 行为 1:1 对齐 Vue,迁移成本极低

相关资源

互动一下

你在 Vue 转 React 时,最头疼哪个 API? watch / computed / defineProps / defineEmits? 评论区留言,下期直接出对照编译手册

❤️ 觉得有用就 点赞 + 收藏 + 关注,持续更新前端迁移/编译工具实战!

antdv-next/x:面向 Vue 的 AI 组件体系

作者 carl_chen
2026年4月14日 09:35

写在前面

antdv-next/x 的核心价值,是让 Ant Design X 的源设计体系在 Vue 中可复用、可扩展、可落地。

如果你正在做 AI 产品,这意味着你不用从零搭一套“聊天+生成+引用+反馈”的界面体系,也不用在一致性和开发效率之间反复取舍。

antdv-next/x 把这些高频能力沉淀成可复用的 Vue 组件,让团队可以更快上线、更稳迭代。

为什么现在需要它?

传统组件库解决的是通用页面问题,但 AI 产品面临的是另一套体验挑战:

  • 回答要流式呈现,状态要可感知
  • 输入不只是文本,还包括 Prompt 组织与附件
  • 多轮会话需要上下文切换与管理
  • 输出结果需要复制、重试、反馈、引用溯源

这些能力如果每个项目都重写一遍,代价会非常高:

  • 开发周期被拉长
  • 交互风格难统一
  • 后续维护与扩展成本持续上升

antdv-next/x 带来的价值

1. 更快落地 AI 界面

开箱即用的 Vue 3 AI 组件,减少重复造轮子,把时间投入到业务差异化能力上。

2. 更统一的产品体验

提炼自 Ant Design X 的交互语言与视觉风格,并与 antdv-next 体验协同,降低“页面像拼起来的”割裂感。

3. 更灵活的场景扩展

不仅能做 Chat,还能覆盖 Agent 任务流、知识问答、附件处理、推理过程展示等复合场景。

4. 更稳的工程基础

内建 Markdown 增强渲染能力,支持流式渲染、公式、代码高亮、Mermaid;同时提供 TypeScript 类型支持,并兼容 SSR 与 Electron。

设计取向:融合,而非照搬

antdv-next/x 不是 React 方案的机械迁移,而是面向 Vue 生态的原生化实现:

  • 保留 Vue 的表达习惯(Slots、Composition API)
  • 强化可定制渲染,适应 AI 场景快速变化
  • 对齐 antdv-next 的主题与开发体验

适合谁用?

  • 已经基于 antdv-next 开发业务系统的团队
  • 正在搭建 AI 助手、Copilot、问答、Agent 类产品的团队
  • 需要“快速上线 + 长期可维护”并重的团队

写在最后

如果你希望在 Vue 生态里,以更低成本交付高质量 AI 界面,antdv-next/x 是一条非常务实的路径。

npm install @antdv-next/x

欢迎试用,也欢迎在 GitHub 提 Issue 一起共建。

❌
❌