普通视图

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

Vue keep-alive 原理全解析(Vue2+Vue3适配)

2026年4月21日 11:55

Vue keep-alive 原理全解析(Vue2+Vue3适配)

Vue 中的 keep-alive 是一个内置抽象组件,核心作用是缓存组件实例,避免组件频繁创建和销毁,从而提升页面切换性能、保留组件状态(如表单输入、滚动位置)。它本身不会渲染 DOM,也不会出现在组件层级中,仅作为“容器”负责管理其包裹的组件的生命周期。

与 v-show(通过 CSS 控制显隐)、v-if(控制组件挂载/卸载)不同,keep-alive 是通过缓存组件实例实现状态保留,组件卸载时不会被销毁,而是被缓存到内存中,再次渲染时直接复用缓存的实例,无需重新执行 created、mounted 等生命周期钩子,这也是它提升性能的核心原因。

一、keep-alive 核心底层原理

keep-alive 的底层实现依赖 Vue 的组件生命周期钩子和缓存容器,核心逻辑分为“缓存存储”“缓存匹配”“实例复用”三个步骤,Vue2 和 Vue3 原理一致,仅底层 API 和缓存容器细节有细微差异。

1. 核心机制:缓存容器 + 生命周期拦截

keep-alive 内部维护了一个缓存对象(缓存容器) ,用于存储被包裹组件的实例,同时拦截组件的生命周期,修改其默认的挂载/卸载行为:

  • 当组件首次被渲染时,keep-alive 会将组件实例存入缓存容器,同时阻止组件的 destroy 钩子执行(避免实例被销毁);
  • 当组件被切换(路由跳转、v-if 切换)时,组件不会被卸载,而是被“缓存”起来,DOM 会被隐藏(并非删除);
  • 当组件再次被渲染时,keep-alive 会从缓存容器中取出之前缓存的实例,直接复用,无需重新创建,同时触发对应的缓存生命周期钩子。

2. 底层缓存容器实现(Vue2 vs Vue3)

keep-alive 的缓存容器本质是一个对象(或 Map),用于存储组件实例,key 通常是组件的 name(或内部生成的唯一标识),value 是组件实例本身,不同版本的实现略有差异:

// Vue2 底层缓存容器(简化版)
const cache = Object.create(null) // 用对象存储缓存,key为组件name,value为实例

// Vue3 底层缓存容器(简化版)
const cache = new Map() // 用Map存储缓存,key为组件name或唯一标识,value为实例

注意:keep-alive 缓存的是组件实例,而非 DOM 元素;DOM 元素会随着组件实例的缓存被保留,再次渲染时直接插入页面,避免重新渲染 DOM 的开销。

3. 生命周期拦截与重写

Vue 组件默认的生命周期是:创建(created)→ 挂载(mounted)→ 卸载(destroyed)。keep-alive 会拦截组件的 mounted 和 destroyed 钩子,并重写其行为:

  • 首次渲染:组件正常执行 created → mounted,执行完毕后,keep-alive 将实例存入缓存,同时标记组件为“已缓存”;
  • 缓存后再次渲染:不执行 created、mounted 钩子(避免重复初始化),直接复用缓存实例,触发 activated 钩子;
  • 组件被切换隐藏:不执行 destroyed 钩子(避免实例销毁),仅触发 deactivated 钩子,实例被保留在缓存中;
  • 缓存被清除:实例才会执行 destroyed 钩子,彻底销毁。

注意:只有被 keep-alive 包裹的组件,才会拥有 activated 和 deactivated 两个专属生命周期钩子,未被包裹的组件不会触发这两个钩子。

二、keep-alive 核心属性(控制缓存范围)

keep-alive 提供 3 个核心属性,用于控制缓存的组件范围,避免缓存过多组件导致内存占用过大,这是使用 keep-alive 时的关键配置,也是避免滥用缓存的核心:

1. include(白名单)

仅缓存名称匹配 include 的组件,支持字符串(逗号分隔)、数组、正则表达式。组件名称需与组件的 name 选项一致(不可省略),否则无法匹配。

<!-- 字符串:缓存 name 为 Home、About 的组件 -->
<keep-alive include="Home,About">
  <router-view />
</keep-alive>

<!-- 数组:缓存 Home、About 组件 -->
<keep-alive :include="['Home', 'About']">
  <router-view />
</keep-alive>

2. exclude(黑名单)

不缓存名称匹配 exclude 的组件,用法与 include 一致,优先级高于 include(若组件同时在两个名单中,以 exclude 为准,不缓存)。

<!-- 不缓存 name 为 Login 的组件 -->
<keep-alive exclude="Login">
  <router-view />
</keep-alive>

3. max(缓存数量限制)

限制缓存的组件实例数量,当缓存的实例数量超过 max 时,会按照“LRU(最近最少使用)”策略,删除最久未使用的缓存实例,避免内存泄漏。Vue2.5+ 新增该属性,Vue3 完全兼容。

<!-- 最多缓存 3 个组件实例,超过则删除最久未使用的 -->
<keep-alive :max="3">
  <router-view />
</keep-alive>

注意:LRU 策略是 keep-alive 内置的缓存淘汰机制,核心逻辑是“最近使用的组件优先保留,最久未使用的组件优先淘汰”,适用于需要缓存多个组件但担心内存占用的场景。

三、keep-alive 缓存逻辑细节

1. 缓存的匹配规则

keep-alive 匹配组件时,优先使用组件的 name 选项作为匹配依据,若组件未设置 name(或 name 为空),则无法被 include/exclude 匹配,也无法被缓存(Vue3 中未设置 name 的组件会被默认命名,但仍建议显式设置)。

注意:路由组件的 name 需与路由配置中的 name 保持一致,否则 include/exclude 无法匹配路由组件。

2. 组件状态的保留机制

keep-alive 缓存的是组件实例,因此组件内的 data 数据、表单输入、滚动位置等状态都会被保留:

  • 表单输入:缓存后再次进入组件,输入框中的内容不会丢失;
  • 滚动位置:缓存后再次进入组件,页面滚动条会停留在上一次离开时的位置;
  • 数据状态:组件内的 data 数据不会被重置,仍保持上一次的状态。

注意:若需要重置组件状态(如再次进入时清空表单),可在 activated 钩子中手动重置数据,因为 activated 钩子每次组件被激活时都会触发。

3. 动态组件与 keep-alive 的结合

keep-alive 常与动态组件(component 标签 + is 属性)结合使用,实现组件切换时的缓存:

<keep-alive include="ComponentA,ComponentB">
  <component :is="currentComponent" />
</keep-alive>

<script setup>
import { ref } from 'vue'
import ComponentA from './ComponentA.vue'
import ComponentB from './ComponentB.vue'

const currentComponent = ref('ComponentA') // 切换组件
</script>

此时,ComponentA 和 ComponentB 切换时,都会被缓存,避免频繁创建和销毁,提升切换流畅度。

4. Vue3 专属实战示例(可直接复制复用)

以下示例均适配 Vue3 组合式 API(

示例1:Vue3 路由缓存(最常用,配合 router-view)

<!-- App.vue 中使用,缓存指定路由组件 -->
<template>
  <div id="app">
    <router-link to="/home">首页</router-link>
    <router-link to="/list">列表页</router-link>
    <router-link to="/login">登录页</router-link>
    
    <!-- 缓存 Home、List 组件,排除 Login 组件 -->
    <keep-alive include="Home,List" exclude="Login" :max="2">
      <router-view />
    </keep-alive>
  </div>
</template>

<script setup>
// 无需额外引入,Vue3 内置 keep-alive
</script>

// 路由组件示例(List.vue,需显式设置name)
<template>
  <div>
    <h2>列表页</h2>
    <input v-model="keyword" placeholder="搜索关键词" />
    <ul>
      <li v-for="item in list" :key="item.id">{{ item.name }}</li>
    </ul>
  </div>
</template>

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

// 必须显式设置组件name,否则keep-alive无法匹配
defineOptions({
  name: 'List'
})

const keyword = ref('')
const list = ref([
  { id: 1, name: 'Vue3 keep-alive 实战' },
  { id: 2, name: 'Vue3 组合式 API 用法' }
])

// 组件被激活时触发(每次进入都执行)
onActivated(() => {
  console.log('列表页被激活,可执行刷新数据等操作')
})

// 组件被缓存隐藏时触发
onDeactivated(() => {
  console.log('列表页被缓存,可执行清理操作')
})
</script>

示例2:Vue3 动态组件缓存(配合 component 标签)

<template>
  <div>
    <button @click="currentComponent = 'UserInfo'">用户信息</button>
    <button @click="currentComponent = 'UserSetting'">用户设置</button>
    
    <!-- 缓存 UserInfo、UserSetting 两个动态组件,限制最多缓存2个 -->
    <keep-alive include="UserInfo,UserSetting" :max="2">
      <component :is="currentComponent" />
    </keep-alive>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import UserInfo from './UserInfo.vue'
import UserSetting from './UserSetting.vue'

const currentComponent = ref('UserInfo')
</script>

// UserInfo.vue(需显式设置name)
<script setup>
defineOptions({
  name: 'UserInfo'
})
// 组件内容省略...
</script>

// UserSetting.vue(需显式设置name)
<script setup>
defineOptions({
  name: 'UserSetting'
})
// 组件内容省略...
</script>

示例3:Vue3 缓存组件状态重置(activated 钩子用法)

<template>
  <keep-alive include="FormPage">
    <component :is="currentComponent" />
  </keep-alive>
</template>

<script setup>
import { ref } from 'vue'
import FormPage from './FormPage.vue'

const currentComponent = ref('FormPage')
</script>

// FormPage.vue(缓存后重置表单状态)
<template>
  <form>
    <input v-model="form.name" placeholder="姓名" />
    <input v-model="form.age" placeholder="年龄" />
  </form>
</template>

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

defineOptions({
  name: 'FormPage'
})

const form = ref({
  name: '',
  age: ''
})

// 每次进入组件(被激活),重置表单状态
onActivated(() => {
  form.value = {
    name: '',
    age: ''
  }
})
</script>

示例4:Vue3 手动清除 keep-alive 缓存

<template>
  <div>
    <button @click="clearCache">清除列表页缓存</button>
    <keep-alive include="Home,List" ref="keepAliveRef">
      <router-view />
    </keep-alive>
  </div>
</template>

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

// 获取keep-alive实例
const keepAliveRef = ref(null)

// 手动清除指定组件的缓存(List组件)
const clearCache = () => {
  // cache 是keep-alive内部的缓存容器(Vue3为Map)
  const cache = keepAliveRef.value.cache
  // 遍历缓存,删除name为List的组件实例
  for (const [key, value] of cache.entries()) {
    if (value.type.name === 'List') {
      cache.delete(key)
      // 触发组件销毁(可选)
      value.component?.unmount()
    }
  }
}
</script>

说明:示例中所有组件均显式设置 name(Vue3 组合式 API 用 defineOptions 定义),确保 keep-alive 的 include/exclude 能正常匹配;所有代码可直接复制,替换组件名称和内容即可适配自身项目。

四、Vue3 keep-alive 核心缓存策略(重点)

Vue3 中 keep-alive 并非单一缓存逻辑,而是通过内置规则+属性配置+手动干预实现多层缓存控制,核心分为四大缓存策略,覆盖日常开发全场景,每类策略均对应底层逻辑和实战用法,避免缓存滥用和内存问题。

1. 全量默认缓存策略(基础无配置)

这是 keep-alive 最基础的缓存策略,不配置任何属性时默认生效,核心是缓存所有被包裹的组件实例,无筛选、无数量限制,适合仅需缓存单个组件的极简场景。

核心逻辑:组件首次挂载后存入内部 Map 缓存容器,切换时不销毁实例、仅隐藏 DOM,再次激活直接复用,全程仅执行一次 created、mounted 钩子。

<!-- 全量缓存示例:缓存 router-view 内所有路由组件 -->
<keep-alive>
  <router-view />
</keep-alive>

对应实战场景:适合单个路由组件缓存(如仅首页缓存),可将示例1中 include="Home,List" exclude="Login" :max="2" 简化为无任何属性配置,即 ,需注意避免多组件场景使用。

注意:该策略不适合多组件场景,会无限制占用内存,频繁切换多组件时严禁直接使用,必须搭配范围控制属性。

2. 范围筛选缓存策略(精准控制)

通过 include(白名单)exclude(黑名单) 两个属性实现精准筛选,是企业级开发最常用的策略,解决“只缓存需要保留状态的组件”核心需求,二者优先级:exclude > include。

(1)白名单缓存策略(include)

仅缓存组件 name 匹配的组件,未匹配组件完全不缓存,每次切换都会重新创建销毁,适合指定少数核心页面缓存。

<!-- 仅缓存 Home、List 两个路由组件 -->
<keep-alive :include="['Home','List']">
  <router-view />
</keep-alive>

对应实战示例:参考“示例1:Vue3 路由缓存”,其中 include="Home,List" 就是典型的白名单策略,仅缓存首页和列表页,排除登录页,贴合企业级路由缓存高频场景,与示例中配置完全匹配。

(2)黑名单缓存策略(exclude)

排除指定组件,其余被包裹组件全部缓存,适合大部分组件需要缓存、仅少数组件无需缓存的场景。

<!-- 缓存所有组件,排除 Login、Detail 组件 -->
<keep-alive exclude="Login,Detail">
  <router-view />
</keep-alive>

对应实战示例:可基于示例1修改,将 include="Home,List" 改为 exclude="Login",即可实现“缓存所有路由组件,仅排除登录页”,与示例1的路由缓存场景一致,适配大部分页面需缓存的业务需求。

关键要求:该策略依赖组件 name,Vue3 组合式API中必须用 defineOptions 显式声明 name,自动生成的默认name易匹配失败。

3. LRU 淘汰缓存策略(内存优化)

通过 max 属性配合内置 LRU(最近最少使用) 算法实现,是 Vue3 自带的内存保护策略,专门解决多组件缓存导致的内存溢出问题。

核心逻辑:设定最大缓存数量,当缓存实例数超过 max 值时,自动删除最久未被激活使用的组件缓存,保留近期高频使用的组件实例,平衡性能与内存占用。

<!-- 最多缓存3个组件,超出则触发LRU淘汰 -->
<keep-alive :include="['Home','List','User','Setting']" :max="3">
  <router-view />
</keep-alive>

对应实战示例:参考“示例2:Vue3 动态组件缓存”,其中 :max="2" 就是LRU淘汰策略的应用,限制缓存UserInfo和UserSetting两个组件,若新增组件切换(如新增UserCenter),会自动淘汰最久未使用的组件,与示例配置完全对应。

4. 手动干预缓存策略(灵活控制)

属于进阶策略,突破内置属性限制,通过 ref 获取 keep-alive 实例,直接操作内部 Map 缓存容器,实现手动清除指定/全部缓存,适合需要动态重置缓存的场景(如退出登录、表单提交后清空缓存)。

核心逻辑:Vue3 中 keep-alive 实例暴露 cache 属性(Map 类型),可通过遍历、删除键值对实现手动清缓存,还可配合组件 unmount 彻底销毁实例。

<template>
  <button @click="clearTargetCache">清空列表页缓存</button>
  <button @click="clearAllCache">清空全部缓存</button>
  <keep-alive ref="keepAliveRef" include="Home,List,User">
    <router-view />
  </keep-alive>
</template>

<script setup>
import { ref } from 'vue'
const keepAliveRef = ref(null)

// 手动清除指定组件(List)缓存
const clearTargetCache = () => {
  const cacheMap = keepAliveRef.value?.cache
  if (!cacheMap) return
  for (const [key, instance] of cacheMap.entries()) {
    if (instance.type.name === 'List') {
      cacheMap.delete(key)
      // 彻底销毁组件实例
      instance.component?.unmount()
    }
  }
}

// 手动清空所有缓存
const clearAllCache = () => {
  const cacheMap = keepAliveRef.value?.cache
  if (!cacheMap) return
  cacheMap.clear()
}
</script>

对应实战示例:完全匹配“示例4:Vue3 手动清除 keep-alive 缓存”,示例4中通过 ref 获取 keep-alive 实例、删除List组件缓存,与本策略“手动干预缓存”的核心逻辑、代码实现完全一致,可直接复制示例4代码适配自身项目。

缓存策略使用优先级(推荐)

日常开发优先按这个顺序选择,兼顾性能与易用性: 范围筛选策略(include/exclude)→ LRU淘汰策略(max)→ 手动干预策略 → 默认全量策略

策略与示例对应总结(无偏差):范围筛选策略(include/exclude)对应示例1(路由缓存)、示例2(动态组件缓存);LRU淘汰策略(max)对应示例2;手动干预策略对应示例4(手动清缓存);默认全量策略可基于示例1简化配置实现,各类策略与示例精准匹配,无偏差。

五、Vue2 与 Vue3 keep-alive 核心差异

keep-alive 的核心原理和用法在 Vue2 和 Vue3 中基本一致,主要差异集中在底层实现和部分细节,不影响日常使用:

对比维度 Vue2 Vue3
缓存容器 使用普通对象(Object)存储 使用 Map 存储,性能更优,支持更灵活的 key 类型
组件 name 要求 必须显式设置 name,否则无法匹配缓存 未显式设置 name 时,会自动生成默认名称(基于组件文件路径),但仍建议显式设置
生命周期钩子 activated/deactivated 钩子在组件内直接定义 选项式 API 用法与 Vue2 一致;组合式 API 中需使用 onActivated、onDeactivated 钩子
底层实现 基于 Vue 实例的 $destroy 方法拦截 基于组件的 unmount 生命周期拦截,与 Composition API 适配更友好
缓存策略拓展 仅基础筛选+LRU,无便捷手动清缓存方式 支持直接操作 Map 缓存,手动清缓存更便捷

注意:Vue3 中,keep-alive 不支持包裹多个根节点的组件,否则会抛出警告并失效,需确保被包裹的组件只有一个根节点。

六、常见使用场景与注意事项

1. 常见使用场景

  • 路由切换场景:如首页、列表页、详情页切换,缓存列表页状态(避免重新请求数据、重置滚动位置);
  • 动态组件切换场景:如标签页、步骤条,缓存每个标签/步骤的组件状态;
  • 表单场景:如长表单分页填写,缓存已填写的表单数据,避免切换分页时数据丢失。

2. 缓存策略专属注意事项

  • 范围策略必配name:使用include/exclude时,Vue3组合式API必须用defineOptions声明name,禁止依赖自动生成name;
  • LRU策略max值合理设置:max数值建议按业务高频页面数量设定,一般设3-5即可,不宜过大或过小;
  • 手动清缓存需彻底:删除缓存后建议调用unmount销毁实例,避免残留实例导致内存泄漏;
  • 禁止策略冲突:不同时配置冲突的include和exclude,避免缓存不生效;
  • 动态路由缓存适配:动态路由组件需保证name固定,否则范围策略匹配失效;
  • 缓存状态按需重置:即便用了缓存策略,仍需在onActivated钩子中处理状态重置,避免旧数据干扰。

3. 通用关键注意事项

  • 避免过度缓存:不要缓存所有组件,尤其是一次性使用、无需保留状态的组件(如登录页),否则会增加内存占用,反而影响性能;
  • 缓存组件的生命周期差异:被缓存的组件,created、mounted 仅执行一次,后续渲染仅触发 activated,卸载仅触发 deactivated;
  • 避免缓存带定时器/事件监听的组件:若组件内有定时器、事件监听,需在 deactivated 钩子中清除,在 activated 钩子中重新初始化,避免内存泄漏;
  • Vue3 多根组件限制:keep-alive 包裹的组件必须是单根节点,否则缓存失效并抛出警告。

🔔 如何实现一个优雅的通知中心?(Vue 3 + 消息队列实战)

作者 晴天丨
2026年4月21日 09:40

前言

一个完善的通知系统可以显著提升用户体验,让用户及时了解:

  • 新评论回复
  • 文章被点赞
  • 系统公告
  • 签到奖励

今天分享如何实现一个优雅的通知中心!

功能设计

通知类型

// src/types/notification.ts
export type NotificationType = 
  | 'comment'      // 评论通知
  | 'reply'         // 回复通知
  | 'like'          // 点赞通知
  | 'follow'        // 关注通知
  | 'system'        // 系统通知
  | 'achievement'   // 成就通知

export interface Notification {
  id: string
  type: NotificationType
  title: string
  content: string
  avatar?: string
  link?: string
  read: boolean
  createTime: number
}

核心实现

1. 通知服务

// src/services/notification.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Notification, NotificationType } from '@/types/notification'

export const useNotificationStore = defineStore('notification', () => {
  const notifications = ref<Notification[]>([])
  
  // 加载通知
  function loadNotifications() {
    const data = localStorage.getItem('blog_notifications')
    if (data) {
      notifications.value = JSON.parse(data)
    }
  }
  
  // 保存通知
  function saveNotifications() {
    localStorage.setItem('blog_notifications', JSON.stringify(notifications.value))
  }
  
  // 添加通知
  function addNotification(notification: Omit<Notification, 'id' | 'read' | 'createTime'>) {
    const newNotification: Notification = {
      ...notification,
      id: `notif_${Date.now()}_${Math.random().toString(36).slice(2)}`,
      read: false,
      createTime: Date.now()
    }
    
    notifications.value.unshift(newNotification)
    saveNotifications()
    
    // 触发浏览器通知
    if (Notification.permission === 'granted') {
      new Notification(newNotification.title, {
        body: newNotification.content,
        icon: newNotification.avatar
      })
    }
    
    return newNotification
  }
  
  // 标记已读
  function markAsRead(id: string) {
    const notification = notifications.value.find(n => n.id === id)
    if (notification) {
      notification.read = true
      saveNotifications()
    }
  }
  
  // 全部已读
  function markAllAsRead() {
    notifications.value.forEach(n => {
      n.read = true
    })
    saveNotifications()
  }
  
  // 删除通知
  function deleteNotification(id: string) {
    const index = notifications.value.findIndex(n => n.id === id)
    if (index > -1) {
      notifications.value.splice(index, 1)
      saveNotifications()
    }
  }
  
  // 未读数量
  const unreadCount = computed(() => {
    return notifications.value.filter(n => !n.read).length
  })
  
  // 按类型分组
  const groupedNotifications = computed(() => {
    const groups: Record<NotificationType, Notification[]> = {
      comment: [],
      reply: [],
      like: [],
      follow: [],
      system: [],
      achievement: []
    }
    
    notifications.value.forEach(n => {
      groups[n.type].push(n)
    })
    
    return groups
  })
  
  // 请求通知权限
  async function requestPermission() {
    if ('Notification' in window) {
      const permission = await Notification.requestPermission()
      return permission === 'granted'
    }
    return false
  }
  
  loadNotifications()
  
  return {
    notifications,
    unreadCount,
    groupedNotifications,
    addNotification,
    markAsRead,
    markAllAsRead,
    deleteNotification,
    requestPermission
  }
})

2. 通知中心组件

<!-- src/components/notification/NotificationCenter.vue -->
<template>
  <el-popover
    v-model:visible="visible"
    placement="bottom-end"
    :width="360"
    trigger="click"
  >
    <template #reference>
      <div class="notification-trigger">
        <el-badge :value="unreadCount" :hidden="unreadCount === 0" :max="99">
          <el-button :icon="Bell" circle />
        </el-badge>
        <!-- 红点提醒 -->
        <span v-if="hasNewNotification" class="new-dot" />
      </div>
    </template>
    
    <template #default>
      <div class="notification-center">
        <!-- 头部 -->
        <div class="header">
          <h3>通知中心</h3>
          <el-button 
            v-if="unreadCount > 0" 
            text 
            size="small"
            @click="handleMarkAllRead"
          >
            全部已读
          </el-button>
        </div>
        
        <!-- 标签页 -->
        <el-tabs v-model="activeTab" class="notification-tabs">
          <el-tab-pane label="全部" name="all" />
          <el-tab-pane label="评论" name="comment" />
          <el-tab-pane label="点赞" name="like" />
          <el-tab-pane label="系统" name="system" />
        </el-tabs>
        
        <!-- 通知列表 -->
        <div class="notification-list">
          <div 
            v-for="notification in filteredNotifications"
            :key="notification.id"
            class="notification-item"
            :class="{ unread: !notification.read }"
            @click="handleClick(notification)"
          >
            <el-avatar 
              :src="notification.avatar || defaultAvatar" 
              :size="40"
            />
            
            <div class="content">
              <div class="title">{{ notification.title }}</div>
              <div class="message">{{ notification.content }}</div>
              <div class="time">{{ formatTime(notification.createTime) }}</div>
            </div>
            
            <div class="actions">
              <el-button 
                v-if="!notification.read"
                text 
                size="small"
                @click.stop="handleMarkRead(notification.id)"
              >
                标记已读
              </el-button>
              <el-button 
                text 
                size="small"
                @click.stop="handleDelete(notification.id)"
              >
                删除
              </el-button>
            </div>
          </div>
          
          <el-empty 
            v-if="filteredNotifications.length === 0"
            description="暂无通知"
          />
        </div>
      </div>
    </template>
  </el-popover>
</template>

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { Bell } from '@element-plus/icons-vue'
import { useNotificationStore } from '@/services/notification'
import type { Notification } from '@/types/notification'
import { ElMessage } from 'element-plus'

const notificationStore = useNotificationStore()
const visible = ref(false)
const activeTab = ref('all')

const unreadCount = computed(() => notificationStore.unreadCount)
const hasNewNotification = computed(() => unreadCount.value > 0)

const defaultAvatar = '/default-avatar.png'

const filteredNotifications = computed(() => {
  if (activeTab.value === 'all') {
    return notificationStore.notifications
  }
  return notificationStore.notifications.filter(n => n.type === activeTab.value)
})

function formatTime(timestamp: number) {
  const date = new Date(timestamp)
  const now = new Date()
  const diff = now.getTime() - date.getTime()
  
  if (diff < 60000) return '刚刚'
  if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
  if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
  if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`
  
  return date.toLocaleDateString()
}

function handleClick(notification: Notification) {
  notificationStore.markAsRead(notification.id)
  
  if (notification.link) {
    window.location.href = notification.link
  }
  
  visible.value = false
}

function handleMarkRead(id: string) {
  notificationStore.markAsRead(id)
}

function handleMarkAllRead() {
  notificationStore.markAllAsRead()
  ElMessage.success('已全部标记为已读')
}

function handleDelete(id: string) {
  notificationStore.deleteNotification(id)
}

// 监听新通知
watch(() => notificationStore.unreadCount, (newCount, oldCount) => {
  if (newCount > oldCount) {
    // 播放提示音
    const audio = new Audio('/notification.mp3')
    audio.play().catch(() => {})
  }
})

onMounted(() => {
  notificationStore.requestPermission()
})
</script>

<style scoped>
.notification-trigger {
  position: relative;
  display: inline-block;
}

.new-dot {
  position: absolute;
  top: 0;
  right: 0;
  width: 8px;
  height: 8px;
  background: #f56c6c;
  border-radius: 50%;
  animation: pulse 2s infinite;
}

@keyframes pulse {
  0%, 100% { transform: scale(1); opacity: 1; }
  50% { transform: scale(1.2); opacity: 0.8; }
}

.notification-center {
  margin: -12px;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  border-bottom: 1px solid var(--el-border-color);
}

.header h3 {
  margin: 0;
  font-size: 16px;
}

.notification-tabs {
  padding: 0 8px;
}

.notification-list {
  max-height: 400px;
  overflow-y: auto;
  padding: 8px;
}

.notification-item {
  display: flex;
  gap: 12px;
  padding: 12px;
  border-radius: 8px;
  cursor: pointer;
  transition: background 0.2s;
}

.notification-item:hover {
  background: var(--el-fill-color-light);
}

.notification-item.unread {
  background: var(--el-color-primary-light-9);
}

.notification-item.unread::before {
  content: '';
  position: absolute;
  left: 4px;
  top: 50%;
  transform: translateY(-50%);
  width: 6px;
  height: 6px;
  background: var(--el-color-primary);
  border-radius: 50%;
}

.content {
  flex: 1;
  min-width: 0;
}

.title {
  font-weight: 600;
  margin-bottom: 4px;
}

.message {
  font-size: 13px;
  color: var(--el-text-color-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.time {
  font-size: 12px;
  color: var(--el-text-color-placeholder);
  margin-top: 4px;
}

.actions {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
</style>

使用示例

<!-- 在 Header 中使用 -->
<template>
  <header>
    <div class="header-content">
      <!-- 其他内容 -->
      <NotificationCenter />
    </div>
  </header>
</template>

<script setup lang="ts">
import NotificationCenter from '@/components/notification/NotificationCenter.vue'
import { useNotificationStore } from '@/services/notification'

const notificationStore = useNotificationStore()

// 模拟收到新评论
function simulateNewComment() {
  notificationStore.addNotification({
    type: 'comment',
    title: '新评论',
    content: '用户"前端小白"评论了你的文章《Vue 3 入门指南》',
    avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=user1',
    link: '/article/vue3-guide'
  })
}
</script>

浏览器通知

// 在需要时请求权限并发送通知
async function sendBrowserNotification(title: string, options?: NotificationOptions) {
  if ('Notification' in window && Notification.permission === 'granted') {
    new Notification(title, {
      icon: '/logo.png',
      badge: '/badge.png',
      ...options
    })
  }
}

💡 进阶功能

  • 接入 WebSocket 实现实时推送
  • 添加通知免打扰模式
  • 支持通知折叠和展开

你的 Vue TransitionGroup 组件,VuReact 会编译成什么样的 React 代码?

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

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

前置约定

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

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

编译对照

TransitionGroup:列表过渡动画

<TransitionGroup> 是 Vue 中用于为列表项的插入、移除和重排提供过渡动画的内置组件,是 <Transition> 的列表版本。

基础列表过渡

  • Vue 代码:
<template>
  <TransitionGroup name="list" tag="ul">
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
    </li>
  </TransitionGroup>
</template>
  • VuReact 编译后 React 代码:
import { TransitionGroup } from '@vureact/runtime-core';

<TransitionGroup name="list" tag="ul">
  {items.map((item) => (
    <li key={item.id}>{item.name}</li>
  ))}
</TransitionGroup>

从示例可以看到:Vue 的 <TransitionGroup> 组件被编译为 VuReact Runtime 提供的 TransitionGroup 适配组件,可理解为「React 版的 Vue TransitionGroup」。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue <TransitionGroup> 的行为,实现列表过渡动画
  2. 列表支持:专门为列表项的进入、离开和移动提供动画支持
  3. 容器标签:通过 tag 属性指定列表容器元素
  4. key 要求:列表项必须提供稳定的 key 属性

对应的 CSS 样式

.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

.list-enter-active {
  opacity: 1;
  transform: translateX(0);
  transition: all 0.5s ease;
}

.list-leave-active {
  opacity: 0;
  transform: translateX(30px);
  transition: all 0.5s ease;
}

列表重排与移动动画

<TransitionGroup> 支持列表项重排时的平滑移动动画,通过 moveClass 属性实现。

  • Vue 代码:
<template>
  <TransitionGroup name="list" tag="ul" move-class="list-move">
    <li v-for="item in items" :key="item.id">
      {{ item.name }}
    </li>
  </TransitionGroup>
</template>
  • VuReact 编译后 React 代码:
<TransitionGroup name="list" tag="ul" moveClass="list-move">
  {items.map((item) => (
    <li key={item.id}>{item.name}</li>
  ))}
</TransitionGroup>

移动动画 CSS

/* 移动动画类 */
.list-move {
  transition: all 0.5s ease;
}

/* 离开动画需要绝对定位 */
.list-leave-active {
  position: absolute;
}

移动动画原理

  1. FLIP 技术:使用 First-Last-Invert-Play 技术实现平滑移动
  2. 位置计算:计算元素新旧位置差异,应用反向变换
  3. 平滑过渡:通过 CSS 过渡实现位置变化的动画效果
  4. 性能优化:使用 transform 属性实现高性能动画

自定义容器元素

通过 tag 属性可以指定列表的容器元素类型。

  • Vue 代码:
<template>
  <TransitionGroup name="fade" tag="div" class="item-list">
    <div v-for="item in items" :key="item.id" class="item">
      {{ item.name }}
    </div>
  </TransitionGroup>
</template>
  • VuReact 编译后 React 代码:
<TransitionGroup name="fade" tag="div" className="item-list">
  {items.map((item) => (
    <div key={item.id} className="item">
      {item.name}
    </div>
  ))}
</TransitionGroup>

tag 属性作用

  1. 容器类型:指定渲染的 HTML 元素类型(div、ul、ol 等)
  2. 语义化:使用合适的语义化标签
  3. 样式控制:方便应用容器样式
  4. 结构清晰:保持清晰的 DOM 结构

继承 Transition 功能

<TransitionGroup> 继承了 <Transition> 的所有功能,支持相同的属性和钩子。

  • Vue 代码:
<template>
  <TransitionGroup 
    name="slide" 
    tag="div"
    :duration="500"
    @enter="onEnter"
    @leave="onLeave"
  >
    <div v-for="item in items" :key="item.id">
      {{ item.name }}
    </div>
  </TransitionGroup>
</template>
  • VuReact 编译后 React 代码:
<TransitionGroup
  name="slide"
  tag="div"
  duration={500}
  onEnter={onEnter}
  onLeave={onLeave}
>
  {items.map((item) => (
    <div key={item.id}>{item.name}</div>
  ))}
</TransitionGroup>

继承的功能

  1. 自定义类名:支持 enter/leave 相关的自定义类名
  2. JavaScript 钩子:支持所有过渡生命周期钩子
  3. 持续时间:支持 duration 属性控制动画时长
  4. CSS 控制:支持 css 属性控制是否应用 CSS 过渡

编译策略总结

VuReact 的 TransitionGroup 编译策略展示了完整的列表过渡转换能力

  1. 组件直接映射:将 Vue <TransitionGroup> 直接映射为 VuReact 的 <TransitionGroup>
  2. 属性完全支持:支持 nametagmoveClass 等所有属性
  3. 列表渲染转换:将 v-for 转换为 map 函数调用
  4. 动画功能继承:继承所有 <Transition> 的动画功能

注意事项

  1. key 必须:列表项必须提供稳定的 key,否则动画可能异常
  2. CSS 要求:必须在 *-enter-active*-leave-active 中设置过渡外观
  3. 移动动画:离开动画需要设置 position: absolute

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动实现列表过渡动画逻辑。编译后的代码既保持了 Vue 的列表过渡语义和动画效果,又符合 React 的组件设计模式,让迁移后的应用保持完整的列表过渡能力。

🔗 相关资源


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

VUE开发环境配置基础(构建工具→单文件组件SFC→css预处理器sass→eslint)及安装脚手架

作者 RONIN
2026年4月20日 17:44

VUE开发环境配置基础(构建工具→单文件组件SFC→css预处理器sass→eslint)

一、构建工具

作用:

打包压缩、转换(.vue文件转换成浏览器能识别的html、css、js)

内置了web服务器可进行热更新

  • webpack构建工具
  • vite构建工具

使用:

  1. npm init -y初始化项目,生成package.json文件
  2. npm i vite -D(npm install vite -D)安装vite构建工具(-S生产环境,-D局部安装/开发环境,-G全局安装)

安装之后可以使用vite命令启动内置web服务器

但开发中一般会在package.json文件中配置dev、build

"scripts": {
    "dev": "vite --host",
    "build": "vite build",
    "preview": "vite preview",
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
  },

 

  1. 执行npm run dev命令启动内置web服务器
  2. 执行npm run build打包项目,打包后会在根目录下生成一个dist文件夹,该文件夹下存放的就是打包压缩后的包

二、单文件组件SFC(.vue结尾的文件)包括<template><script><style>

  1. npm i @vitejs/plugin-vue -S 安装vite构建工具解析.vue文件的插件

根目录下创建vite.config.js文件,配置集成该插件

import vue from '@vitejs/plugin-vue' // vite构建工具解析 .vue文件的插件
import {defineConfig} from 'vite'//defineConfig方法,编写代码会有提示
export default defineConfig({
    plugins:[vue()] // 集成插件
})

2. npm i vue -S 安装vue框架

main.js

// import {createApp} from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'
import { createApp } from 'vue'

// import App from './App.js'
import App from './App.vue'

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

App.vue

<!-- 模板 -->
<template>
    <div class="g-wrapper">
        <h2>单文件组件 SFC</h2>
        <p>{{message}}</p>
        <table>
            <tr>
                <th>序号</th>
                <th>名称</th>
                <th>价格</th>
            </tr>

            <!--  绑定key目的: 虚拟dom diff算法能够快速找到列表项,对列表项进行高效操作 -->
            <tr v-for="item,index in list" :key="item.id">
                 <td>{{item.id}}</td>
                 <td>{{item.name}}</td>
                 <td>{{item.price}}</td>
            </tr>
        </table>
        <ComA></ComA>
    </div>
</template>

<!-- js代码 -->
<script>
import ComA from './components/ComA.vue'
export default {
    components:{
        ComA
    },
    data() {
        return {
            message:'根组件App.vue',
            list:[
                {id:1001,name:'javascript编程',price:99.89},
                {id:1002,name:'css编程',price:89.89},
                {id:1003,name:'vue编程',price:178.88},
            ]
        }
    }
}
</script>

<!-- css样式 -->
<style scoped>
.g-wrapper{
    width: 1200px;
    margin: 100px auto;
}

.g-wrapper table{
    width: 100%;
    text-align: center;
}

.g-wrapper table tr td,th{
    border-bottom: 1px dotted gray;
    line-height: 40px;
}
</style>

ComA.vue

<template>
  <div class="g-wrapper">
    <h2>组件ComA</h2>
  </div>
</template>

<script>
export default {};
</script>

<!-- scoped :样式作用域只在当前组件生效 -->
<style scoped>
h2 {
  color: red;
}
</style>

三、css预处理器less,sass,stylus

sass(两个版本:sass、scss。scss是sass的升级版,完全兼容css)

官网:www.sass.hk/

npm i sass -D 安装sass(vite构建工具内置了sass库,安装后不需要配置)

1. 导入样式可以在main.js入口文件中导入,也可以在某个模块中导入

scss文件导入总结:

  • main.js入口文件:import  ‘./text.scss’
  • .vue文件:@import  url(./text.scss)
  • .scss同级文件:@import  ‘./text.scss’

main.js

import { createApp } from 'vue'
// 模块化导入样式
import "./assets/scss/text.scss";

import App from './App.vue'
createApp(App).mount('#app')

.vue文件

<style scoped>
// 导入样式
@import url(../assets/sass/test.scss);
</style>

2. scss语法

变量$btn、混合器@mixin可单独封装一个文件,便于维护

1>.定义变量:

$变量名:值

$c: blue; // scss定义变量
$h: 200px;
$btnH: 40px;
2>.嵌套语法:
//css写法
// .g-container {
//   background-color: pink;
//   height: $h;
// }

// .g-container h2 {
//     font-size: 18px;
//     color: $c;
// }

// 嵌套语法
.g-container {
  background-color: pink;
  height: $h;
  h2 {
    font-size: 18px;
    color: $c;
  }
}
3>.混合器:样式封装

定义混合器@mixin 混合器名{}(相当于函数function 函数名)

@mixin btn1{
    display: inline-block;
    //封装样式
    width: 100px;
    height: $btnH;
    text-align: center;
    line-height: $btnH;
    border: none;
    outline: none;
    background-color: skyblue;
    border-radius: 5px;
}

使用封装的混合器(@include 混合器名)

.m-a1 {
    color: blue;
    margin: 10px;
    @include btn1;
  }
4>.鼠标悬停(伪类&)
//css写法
// .m-a1:hover{
//   background-color: #3eb8e9;
// }

.m-a1 {
  color: blue;
  margin: 10px;
  @include btn1;
  &:hover {
    background-color: #3eb8e9;
  }
}
5>.控制指令@if

@if 表达式返回值不是false或者null时,条件成立,输出(}内的代码。

@if 声明后面可以跟多个@else if 声明,或者一个@else 声明。

$type: monster;

P{
@if $type == ocean {color: blue;}
@else if $type == matador {color: red;}
@else if $type == monster {color: green;}
@else {color: black;}
}

更多语法参官网

3.单文件组件中使用scss(lang=”scss”)

<style lang="scss" scoped>
// 模块化导入样式
/*@import url(../assets/sass/test.scss); */

.g-container{
  background-color: pink;
  h2{
    color:red;
  }
  div{
    width: 100px;
    height: 40px;
    background-color: skyblue;
  }
}
</style>

四、eslint一个语法规则和代码风格的检查工具,保证写出语法正确、风格统一的代码

官网:eslint.nodejs.cn/

手动集成:

  1. npm i eslint -D(yarn add eslint -D) 安装eslint
  2. npx eslint --init 初始化项目eslint,生成.eslintrc.js配置文件
/* eslint-disable no-undef */

module.exports = {
    "env": {
        "browser"true,
        "es2021"true
    },

    "extends": [
        "eslint:recommended",
        "plugin:vue/vue3-essential"
    ],

    "overrides": [
    ],

    "parserOptions": {
        "ecmaVersion""latest",
        "sourceType""module"
    },

    "plugins": [
        "vue"
    ],

    "rules": {//自定义规则
        // semi: ['error', 'never'],  // 使用分号结束报错
        // quotes: ['error', 'single'],  // 使用单引号报错
        // eqeqeq: ['error', 'always'],// 使用===,不能使用==
        // 'vue/no-unused-vars': 'error',
    }
}

3. npm i eslint-plugin-vue 安装检查单文件组件的插件 4. vscode搜索安装ESLint插件,自动检测,不符合规则会报错

  1. npm i pretter eslint-config-prettier -D(yarn add pretter eslint-config-prettier -D)安装eslint格式化插件,格式化时自动改正
  2. 根目录下创建配置.prettierrc.json格式化规则文件
{
    "tabWidth": 4,
    "useTabs": false,
    "semi": false,
    "singleQuote": true,
    "TrailingComma": "all",
    "bracketSpacing": true,
    "jsxBracketSameLine": false,
    "arrowParens": "avoid"
}

脚手架(create-vite、vue-cli、create-vue、quasar-cli)

1. create-vite

npm create vite@latest(yarn create vite)安装脚手架命令

2. vue-cli

npm i -g @vue-cli安装脚手架命令

vue create project1创建项目

3. create-vue(vue官方的项目脚手架工具,内置了vite构建工具)项目开发中使用的脚手架

npm init vue@latest安装脚手架命令,根据预设生成相应的配置文件

npm install(npm i) 安装依赖

image.png npm run dev运行

目录结构介绍

image.png

4. quasar-cli项目开发中使用的脚手架

关于quasar要求:

  • Node 12+用于Quasar CLI与Webpack,Node 14+用于Quasar CLI与Vite。
  • Yarn v1(强烈推荐),PNPM,或NPM。

npm i -g @quasar/cli 安装脚手架命令

npm init quasar 初始化quasar根据预设生成相应的配置文件 image.png 此时回车,会生成项目文件和目录 image.png 提示安装项目依赖,选择yes回车 image.png quasar dev(npm run dev)运行

vue2、vue3区别之混入mixins和过滤器filter

作者 RONIN
2026年4月20日 17:17

一、混入mixins

一个包含组件选项的对象数组(可复用),这些选项都将被混入到当前组件的实例中

属性相同时,原组件中的属性会覆盖混入的属性。

vue2多使用

作用:将组件公共的数据方法和生命周期函数提取出来,封装到一个独立对象中,被其它所有组件共享。

实现:

1.MyMixins.js定义混入对象(1.定义混入对象 2.在vue组件中通过mixins选项接收要混入的对象数组 3.使用)

export const mixins1 = {
  data() {
      return {
          message:'这是混入的message'
      }
  },

  methods: {
      plus(){
          console.log('这是混入的plus >>>>')
      }
  },
}

2.App.js引入接收使用

import { mixins1 } from "./mixins/MyMixins.js";

export default {
  mixins: [mixins1],

  data() {
    return {
      title"混入技术",
      vcolor"red",
      message:'这是组件app中message'
    };
  },

  methods: {
    bindUpdateColor() {
      this.vcolor = "blue";
    },
  },

  /*html*/

  template: `<div>
                <h2>{{title}}</h2>
                <p>{{ message }}</p>
                <button @click="plus">确定</button>
            </div>
            `,
};

二、过滤器filter

全局方法,本质是一个函数。

vue2中使用,vue3没有filter过滤器

注册:Vue.filter(过滤器名称,过滤器函数)

调用:  <p>{{  参数|过滤器名称 }}</p>

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>过滤器</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
    <!-- <script src="./vue.js"></script> -->
</head>

<body>
    <div id="app"></div>
    <script>
        const root = {
            el:'#app',
            data: {
                title'过滤器'
            },

            /*html 调用*/
            template:`<div>
                    {{title}}
                    <p>{{ title|msgFilter }}</p>  
                 </div>`
        }

         //注册
         Vue.filter('msgFilter',(t)=>{
            const data =  new Date()
            return data.toLocaleTimeString()
        })

        // 创建vue实例
        new Vue(root)
    </script>
</body>
</html>

属性透传attribute、vue实例对象方法$nextTick()、虚拟dom与浏览器渲染机制

作者 RONIN
2026年4月20日 17:10

一、属性透传attribute

  • 指的是传递给一个组件,但没有通过props或emits接收,常见的如class、style、id
  • 透传的样式会自动被添加到子组件根元素上,如果子组件已经有一个样式,透传的样式会和已有样式合并。
  • 如果透传的是一个点击事件,也会自动被添加到子组件根元素上,如果子组件自身也有点击事件,点击时透传过来的事件和其本身的事件都会触发。
  • 透传的属性可以通过{{$attrs}}拿到
  • 属性透传只会透传到根元素上,如果有多个根节点,vue不知道要透传到哪个节点,需要通过v-bind=”$attrs”绑定到要透传到的那个节点上,否则会抛出警告。
  • 属性透传是可以禁用的,通过inheritAttrs: false可以阻止透传

例:

目录

image.png

1.新建assets样式文件夹,样式文件style.css

.large{
    color: red;
}
.small{
    background-color: pink;
}

2.在index.html中引入样式文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>首页</title>
    <link rel="stylesheet" href="./assets/css/style.css">
</head>

<body>
    <div id="app"></div>
    <script type="module" src="./src/main.js"></script>
</body>
</html>

父组件App.js

import Child from "./Child.js";
export default {
  components: {
    Child,
  },

  data() {
    return {
      title: "attribute属性透传",
      count:0
    };
  },

  /*html*/
  template: `<div style="width:400px;height:400px;background-color:skyblue;">
                <h2>{{title}}</h2>
                <p>{{count}}</p>
                 <Child class="large" @click="count++" ></Child>
            </div>`,
};

子组件Child.js

export default {
  data() {
    return {
      num10,
    };
  },

  inheritAttrs: true, // 阻止透传

  /*html*/
  template: `
              <button @click="num++" class="small">子按钮{{num}}</button>
              <main v-bind="$attrs">多个根节点</main>
          `,
};


二、vue实例对象方法$nextTick()

可以通过this.$nextTick(()=>{})访问,回调函数会在dom节点渲染完成后执行

如:正常操作dom节点是在mounted生命周期中操作,nextTick()方法可以实现在created生命周期中操作dom节点

export default {
  data() {
    return {
      title"vue实例对象的 $nextTick()",
      count:10
    };
  },

  created() {
      this.$nextTick(()=>{
        //回调函数,模板界面异步更新完成后执行
        const pEle = document.querySelector('#countP')
        console.log('bindPlus >> ',pEle.innerHTML);
      })
  },

  methods: {
    bindPlus(){
      this.count++
      // 验证, count数据变化,通知依赖更新界面是一个异步过程
      const pEle = document.querySelector('#countP')
      console.log('bindPlus >> ',pEle.innerHTML);//是更新之前的值,说明是异步更新的

      //模板界面异步更新完成后执行nextTick回调函数中代码
      this.$nextTick(()=>{
        const pEle = document.querySelector('#countP')
        console.log('bindPlus >> ',pEle.innerHTML); //是更新之后的值
      })
    }
  },

  /*html*/
  template: `<div>
                <h2>{{title}}</h2>
                <p id="countP">{{count}}</p>
                <button @click="bindPlus">加一 </button>
            </div>`,
};

三、虚拟dom、浏览器渲染机制

整个html文档是一个dom对象,整个html由dom节点对象构成。

浏览器渲染机制:

  1. 解析HTML生成dom树,同时解析CSS文档构建CSSOM树
  2. DOM树和CSSOM树关联起来生成渲染树(RenderTree)
  3. 浏览器按照渲染树进行重排重绘

重绘:CSS 样式改变(如:visibility,背景色的改变),使浏览器需要根据新的属性进行绘制

重排:对DOM的修改引发了DOM几何元素的变化(如:改变元素高度),渲染树需要重新计算,重新生成布局,重新排列元素。

重绘不一定导致重排,但重排一定会导致重绘

 

操作真实dom会引起重排重绘,vue框架操作的是虚拟dom(本质上就是一个普通的JS对象。是模拟真实dom得到的一个JS对象)

将真实dom多次操作在虚拟dom上完成,再将虚拟dom映射到真实dom,完成一次重排重绘,提高渲染效率。

我们在vue中写的template模板,vue编译过程中,会调用render()函数将其编译成虚拟dom树,后映射挂载成真实dom,然后重排重绘显示给用户

vue提供了一个h()方法,用于创建虚拟dom(vnode)

import { h } from 'vue'
render(){
  return[

        h('div')

        h('div', { id"foo" })

        h('div', { id"foo", class"bar", style: { color'red' }, onClick: () => { } },'标  题', [/*child*/])

  ]
}

vue自定义指令与自定义插件

作者 RONIN
2026年4月20日 16:57

一、自定义指令

  • vue内置指令:指带有v-前缀的特殊属性
  • 自定义指令:指包含类似组件生命周期钩子函数的特殊对象(钩子函数会接收到指令所绑定元素作为其参数)

 

const mydirective = {
自定义指令钩子:

// 在绑定元素的 attribute 前,或事件监听器应用前调用
created(el,binding,vnode, prevvnode){},

// 在元素被插入到 DOM 前调用
beforeMount(el,binding, vnode, prevvnode){}

// 在绑定元素的父组件及他自己的所有子节点都挂载完成后调用
mounted(el,binding, vnode, prevvnode){},

// 绑定元素的父组件更新前调用
beforeupdate(el,binding, vnode, prevvnode){},

// 在绑定元素的父组件及他自己的所有子节点都更新后调用
updated(el,binding, vnode, prevvnode){},

// 绑定元素的父组件卸载前调用
beforeUnmount(el,binding,vnode,prevvnode){}

// 绑定元素的父组件卸载后调用
unmounted(el,binding, vnode, prevvnode){}
}

自定义指令钩子函数参数说明:

el :指令绑定到的元素。可以用于直接操作 DOM。

binding :一个对象,包含属性:

value :传递给指令的值。

oldvalue :之前的值,仅在 beforeupdate 和 updated 中可用。无论值是否更改,它都可用。

arg:传递给指令的参数(如果有的话)。

modifiers :一个包含修饰符的对象(如果有的话)。

instance :使用该指令的组件实例。

dir :指令的定义对象。

vnode:代表绑定元素的底层VNode。

prevNode :之前的渲染中代表指令所绑定元素的VNode。仅在 beforeupdate 和 updated 钩子中可用.。

实现:

1.App.js(1.定义指令对象 2.通过directive注册指令(全局注册,局部注册) 3.使用)

/**
 * 自定义指令
 *    指令: v-特殊属性
 *          vue内置指令:  v-html  v-text v-pre v-bind v-on v-if v-show
 *    自定义指令: 包含组件生命周期函数的特殊对象
 *               1. 特定对象
 *               2. 组件生命周期函数
 *  
 *   实现:
 *      1. 定义指令对象
 *        const foucs = {
 *              created(el,binding){},
 *              mounted(el,binding){},
 *              unmounted(el,binding){}
 *         }
 *      2. 注册指令
 *          全局注册
 *             指令可以整个应用所有标签使用
 *             const app = creatApp()
 *             app.directive('foucs',focus)
 *          局部注册
 *             只在当前注册的组件标签中使用
 *             const App = {
 *                  components:{}
 *                  directives:{
 *                      foucs:foucs
 *                  }
 *              }
 *
 */

// v-focus自动获取焦点
const focus = {
  mounted(el, binding) {
    el.focus();
  },
};

// v-red使作用的元素内容为红色

const red = {
  mounted(el, binding) {
    el.style.color = "red";
  },
};

// v-color根据指令值,设置指令作用元素内容颜色
const color = {
  mounted(el, binding) {
    el.style.color = binding.value;
  },

  updated (el,binding) {
    el.style.color = binding.value;
  }
}

export default {
//局部注册
  directives: {
    focus,
    red,
    color,
  },

  data() {
    return {
      title: "自定义指令",
      vcolor: "red",
    };
  },

  methods: {
    bindUpdateColor() {
      this.vcolor = "blue";
      console.log(this.vcolor);
    },
  },

  /*html*/
  template: `<div>
                <h2>{{title}}</h2>
                <input type="text" v-focus>
                
                <p v-red>内容</p>

                <p v-color="vcolor">v-color指令内容</p>

                <!--<p v-color="’blue’">v-color指令内容</p>-->

                <button @click="bindUpdateColor">确定</button>
            </div>
            `,
};

main.js

// 使用vue3 ES模块构建版本
import { createApp } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'
// 导入根组件
import App from './App.js'
// createApp(App).mount('#app')
const app = createApp(App)

// v-focus自动获取焦点
const focus = {
  mounted(el, binding) {
    el.focus();
  },
};

//全局注册
app.directive('focus',focus)
app.mount('#app')

二、自定义插件

  • 自定义插件指拥有install()方法的对象,是一种为vue添加全局功能的工具代码。可以在里面注册全局的组件或指令,然后集成到vue全局对象中,全局使用。

 

install(app, options){}方法参数说明:

app: vue应用实例

options: 可选参数对象

 

实现:

main.js导入集成插件

// 使用vue3 ES模块构建版本

import { createApp } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'
import { Myplugin } from "./plugins/MyPlugin.js";

// 导入根组件
import App from './App.js'
// createApp(App).mount('#app')
const app = createApp(App);

app.use(Myplugin); //集成插件
app.mount("#app")

MyPlugin.js定义插件(1.定义插件 2.通过app.use集成插件 3.使用)

export const Myplugin = {
    // app: vue应用实例
    // options: 可选参数对象

    install(app, options) {
      // 封装插件功能
      // 封装全局组件, 封装全局指令

      // 注册组件
      // const ButtonCouter = {
      //  data() {
      //     return {
      //       title: "按钮",
      //     };
      //   },
      //   template: `<button>{{title}}</button>`,
      // }
      // app.component("ButtonCouter",ButtonCouter)

      // 注册组件
      app.component("ButtonCouter", {
        data() {
          return {
            title"按钮",
          };
        },
        template`<button>{{title}}</button>`,
      });

      //注册指令
      app.directive("color", {
        mounted(el, binding) {
          el.style.color = binding.value;
        },

        updated(el, binding) {
          el.style.color = binding.value;
        },
      });
 
      //使一个资源可被注入整个应用app.provide()
    },
  };

App.js使用

export default {
  data() {
    return {
      title: "自定义指令",
    };
  },

  /*html*/
  template: `<div>
                <h2>{{title}}</h2>
                <button-couter></button-couter>
                <p v-color="'blue'">插件定义的指令内容</p>
            </div>
            `,
};

属性透传attribute与性能优化组件(component、异步组件、keep-alive/Suspense/Teleport/Transition)

作者 RONIN
2026年4月20日 16:40

一、属性透传attribute

  • 指的是传递给一个组件,但没有通过props或emits接收,常见的如class、style、id
  • 透传的样式会自动被添加到子组件根元素上,如果子组件已经有一个样式,透传的样式会和已有样式合并。
  • 如果透传的是一个点击事件,也会自动被添加到子组件根元素上,如果子组件自身也有点击事件,点击时透传过来的事件和其本身的事件都会触发。
  • 透传的属性可以通过{{$attrs}}拿到
  • 属性透传只会透传到根元素上,如果有多个根节点,vue不知道要透传到哪个节点,需要通过v-bind=”$attrs”绑定到要透传到的那个节点上,否则会抛出警告。
  • 属性透传是可以禁用的,通过inheritAttrs: false可以阻止透传

例:

目录

image.png

1.新建assets样式文件夹,样式文件style.css

.large{
    color: red;
}
.small{
    background-color: pink;
}

2.在index.html中引入样式文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>首页</title>
    <link rel="stylesheet" href="./assets/css/style.css">
</head>

<body>
    <div id="app"></div>
    <script type="module" src="./src/main.js"></script>
</body>
</html>

父组件App.js

import Child from "./Child.js";
export default {
  components: {
    Child,
  },

  data() {
    return {
      title: "attribute属性透传",
      count:0
    };
  },

  /*html*/
  template: `<div style="width:400px;height:400px;background-color:skyblue;">
                <h2>{{title}}</h2>
                <p>{{count}}</p>
                 <Child class="large" @click="count++" ></Child>
            </div>`,
};

子组件Child.js

export default {
  data() {
    return {
      num10,
    };
  },

  inheritAttrs: true, // 阻止透传

  /*html*/
  template: `
              <button @click="num++" class="small">子按钮{{num}}</button>
              <main v-bind="$attrs">多个根节点</main>
          `,
};

二、性能优化组件

详细介绍六个性能优化组件,其他组件官网自行学习

1.动态组件(<component :is=””></component>改变is属性绑定的值即可)

例如tab栏切换

父组件App.js

import Home from "./Home.js";
import Category from "./Category.js";
import Cart from "./Cart.js";
import My from "./My.js";
/*
 *  点击tab选项 内容区域切换为对应组件
 *    1. tab选项绑定点击事件
 *    2. 切换组件
 */

export default {
  components: {
    Home,
    Category,
    Cart,
    My,
  },

  data() {
    return {
      title: "动态组件",
      currentTab:'home',
      list:[
        {name:'home',title:'首页'},
        {name:'category',title:'分类'},
        {name:'cart',title:'购物车'},
        {name:'my',title:'我的'},
      ]
    };
  },

  methods: {
    onTabChange(tabName){
      this.currentTab = tabName
    }
  },

  /*html*/
  template: `<div class="g-container">
                 <div class="g-content">
                      <component :is="currentTab"></component>
                 </div>

                 <ul class="g-footer">
                    <li v-for="item in list" @click="onTabChange(item.name)" :class="{active:currentTab==item.name}">{{item.title}}</li>
                 </ul>
            </div>`,
};

样式文件style.css

*{padding: 0;margin: 0;}

ul,li{
    list-style: none;
}

.g-container{
    height: 100vh;
    width: 100%;
    display: flex;
    flex-direction: column;
}

.g-container .g-content{
    flex:1
}

.g-container .g-footer{
    height: 60px;
    background-color: skyblue;
    display: flex;
    justify-content: space-around;
    align-items: center;
}

.active{
   color: red;    
}

子组件Home.js(其他子组件同此组件)

export default {
  data() {
    return {
      title"首页",
    };
  },

  /*html*/
  template: `<div>
                  <h2>{{title}}</h2>
              </div>`,
};

2.异步组件(服务端定义的组件,通过网络异步获取到前端,再注册使用。通过defineAsyncComponent方法获取组件)

App.js

import { defineAsyncComponent } from "https://unpkg.com/vue@3/dist/vue.esm-browser.js";

/**
 *   异步组件: 服务端定义的组件,通过网络异步获取到前端,再注册使用
 *   同步组件: 前端客户端定义组件
 */

const AsyncChild = defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    //模拟网络接口
    setTimeout(() => {
      //异步获取后端定义的异步组件
      const asyncComponent = {
        template`<p>我是异步组件</p>`,
      };

      resolve(asyncComponent);
    }, 2000);
  });
});

export default {
  components: {
    AsyncChild,
  },

  data() {
    return {
      title"异步组件",
    };
  },

  methods: {},

  /*html*/
  template: `<div style="width:400px;height:400px;background-color:skyblue;">
                <h2>{{title}}</h2>

                <p>---异步组件----</p>
                <async-child></async-child>
            </div>`,
};

3.内置组件--缓存组件keep-alive/Suspense/传送门Teleport/过渡动画Transition

直接使用,无需注册

1>. 缓存组件,结合动态组件使用(<keep-alive include=””></keep-alive> 通过 include可以配置哪些组件需要缓存,需要注意的是,include中填写的并不是组件注册时的名称,是定义组件时name选项定义的组件名称)

注:存组件添加之后组件生命周期钩子函数也不会执行了,但是有另外两个钩子函数会执行:activated激活deactivated失活

使用示例:b组件,a组件会销毁,再次切换到a组件时组件会重新创建,但有时是不需要重新创建的,即切换回来原数据还存在。

父组件App.js

import Home from "./components/Home.js";
import Category from "./components/Category.js";
import Cart from "./components/Cart.js";
import My from "./components/My.js";
/**
 *  点击tab选项 内容区域切换为对应组件
 *    1. tab选项绑定点击事件
 *    2. 切换组件
 */
export default {
  name:'App',
  components: {
    Home,
    Category,
    Cart,
    My,
  },

  data() {
    return {
      title: "动态组件",
      currentTab: "home",
      list: [
        { name: "home", title: "首页" },
        { name: "category", title: "分类" },
        { name: "cart", title: "购物车" },
        { name: "my", title: "我的" },
      ],
    };
  },

  methods: {
    onTabChange(tabName) {
      this.currentTab = tabName;
    },
  },

  /*html*/
  template: `<div class="g-container">
                 <div class="g-content">
                      <!--数组写法-->
                      <!--<keep-alive :include="['Home','Category','Cart']">-->

                      <!--字符串写法-->
                      <keep-alive include="Home,Category,Cart">
                          <component :is="currentTab"></component>
                      </keep-alive>
                 </div>

                 <ul class="g-footer">
                    <li v-for="item in list" @click="onTabChange(item.name)" :class="{active:currentTab==item.name}">{{item.title}}</li>
                 </ul>
            </div>`,
};

子组件Home.js

export default {
  name:'Home',
  data() {
    return {
      title"首页",
    };
  },

  created() {
    console.log("home created ");
  },

  mounted() {
    console.log("home mounted ");
  },

  activated() {
    console.log("home activated ");
  },

  deactivated() {
    console.log("home deactivated ");
  },

  unmounted() {
    console.log("home unmounted ");
  },

  /*html*/

  template: `<div>
                  <h2>{{title}}</h2>
                  <input type="text" name="message">
              </div>`,

};

子组件Category.js

export default {
  name:'Category',
  data() {
    return {
      title"分类",
      list: [],
    };
  },
  
  created() {
    console.log("category created ");
  },

  mounted() {
    console.log("category mounted ");
  },

  activated() {
    console.log("category activated ");

    // 调用接口获取分类列表数据 fetch返回promise对象,

    fetch("https://api.yuguoxy.com/api/shop/list?pageSize=4")
      .then((response) => {
        return response.json();
      })
      .then((data) => {
        console.log(data);
        this.list = data.resultInfo.list;
      });
  },

  deactivated() {
    console.log("category deactivated ");
  },

  unmounted() {
    console.log("category unmounted ");
  },

  /*html*/
  template: `<div>
                  <h2>{{title}}</h2>
                  <ul>
                    <li v-for="item in list">
                       <img :src="item.picture" style="width:100px;"/>
                       <p>{{item.shop}}</p>
                    </li>
                  </ul>
              </div>`,
};

2>.Suspense组件,结合插槽使用。(当网络请求时间较长,请求的内容暂时没有获取到。等待时渲染一个加载状态,获取到之后展示获取到的内容)

App.js

import { defineAsyncComponent } from "https://unpkg.com/vue@3/dist/vue.esm-browser.js";

/**
 *   异步组件: 服务端定义的组件,通过网络异步获取到前端,再注册使用
 *   同步组件: 前端客户端定义组件
 */

const AsyncChild = defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    //模拟网络接口
    setTimeout(() => {
      //异步获取后端定义的异步组件
      const asyncComponent = {
        template: `<p>我是异步组件</p>`,
      };
      resolve(asyncComponent);
    }, 2000);
  });
});
 
export default {
  components: {
    AsyncChild,
  },

  data() {
    return {
      title: "异步组件",
    };
  },

  methods: {},
  /*html*/
  template: `<div style="width:400px;height:400px;background-color:skyblue;">
                <h2>{{title}}</h2>

                <p>---异步组件----</p>
                <!-- Suspense 作用: 首先显示 名为fallback的插槽内容,当异步组件加载完成后,显示异步组件  -->

                <Suspense>
                     <async-child></async-child>

                     <template #fallback>
                         <p>加载中...</p>
                     </template>
                </Suspense>
            </div>`,
};

3>.传送门Teleport

实际应用场景:没有传送门时嵌套的css样式太深(#app div box model),可以使用传送门减少嵌套层数(#app model)

父组件App.js

import Dialog from "./Dialog.js";

export default {
  components: {
    Dialog,
  },

  data() {
    return {
      title: "父组件",
      show: false, // 控制对话框隐藏显示
    };
  },

  /*html*/
  template: `<div style="width:400px;height:400px;background-color:skyblue;">
                <h2>{{title}}</h2>
                <button @click="show=true">添加用户</button>

                <!--传送到body标签下面-->
                <Teleport to="body">
                    <Dialog v-if="show" @closeDialog="show=false"></Dialog>
                </Teleport>
            </div>
            `,
};

子组件Dialog.js

export default {
  emits: ["closeDialog"], // 接收事件
  data() {
    return {};
  },

  methods: {
    bindConfirm(){
       // 1. 获取表单输入框内容
       // 2. 调用添加用户接口,保存用户数据到服务端
       // 3. 保存用户成功,关闭对话框
          this.$emit('closeDialog')
    }
  },

  /*html*/
  template: `<div class="box">
              <div class="modal">
                  <!-- header -->
                  <div class="header">
                      <p class="title">标题</p>
                      <p class="close" @click="$emit('closeDialog')">x</p>
                  </div>

                  <!-- 内容区域 -->
                  <div class="content">
                      <form>
                          <input type="text" name="username" placeholder="请输入用户名">  
                          <input type="text" name="password" placeholder="请输入密码">  
                      </form>
                  </div>

                  <!-- 底部区域 -->
                  <div class="footer">
                      <button @click="bindConfirm">确定</button>
                  </div>
              </div>
            </div>`,
};

4>.过渡动画<Transition name=””>

没有定义name,样式默认v-;定义了name=”a”,样式为a-

.v-enter-active,

.v-leave-active {transition: opacity 0.5s ease;}

.v-enter-from,

.v-leave-to {opacity: 0;}

父组件App.js

import Dialog from "./Dialog.js";

export default {
  components: {
    Dialog,
  },

  data() {
    return {
      title: "父组件",
      show: true, // 控制对话框隐藏显示
    };
  },

  /*html*/
  template: `<div style="width:400px;height:400px;background-color:skyblue;">
                <h2>{{title}}</h2>
                <button @click="show=!show">切换</button>

                <!--过渡动画效果-->
                <Transition> 
                   <p v-if="show">过度动画效果</p>
                </Transition> 

                <Transition name="fade"> 
                   <Dialog v-if="show" @closeDialog="show=false"></Dialog>
                </Transition> 
            </div>
            `,
};

子组件Dialog.js

export default {
  emits: ["closeDialog"], // 接收事件
  data() {
    return {};
  },

  methods: {
    bindConfirm(){
       // 1. 获取表单输入框内容
       // 2. 调用添加用户接口,保存用户数据到服务端
       // 3. 保存用户成功,关闭对话框
          this.$emit('closeDialog')
    }
  },

  /*html*/
  template: `<div class="box">
              <div class="modal">

                  <!-- header -->
                  <div class="header">
                      <p class="title">标题</p>
                      <p class="close" @click="$emit('closeDialog')">x</p>
                  </div>

                  <!-- 内容区域 -->

                  <div class="content">
                      <form>
                          <input type="text" name="username" placeholder="请输入用户名">  
                          <input type="text" name="password" placeholder="请输入密码">  
                      </form>

                  </div>

                  <!-- 底部区域 -->
                  <div class="footer">
                      <button @click="bindConfirm">确定</button>
                  </div>
              </div>
            </div>`,
};

样式style.css

/* 下面我们会解释这些 class 是做什么的 */

.v-enter-active,

.v-leave-active {
  transition: opacity 0.5s ease;
}

.v-enter-from,

.v-leave-to {
  opacity: 0;
}


.fade-enter-active,

.fade-leave-active {
  transition: opacity 1s ease;
}

.fade-enter-from,

.fade-leave-to {
  opacity: 0;
}
昨天 — 2026年4月20日首页

后台管理项目中关于新增、编辑弹框使用的另一种展示形式

作者 只会写Bug
2026年4月20日 13:45

目前大家项目中使用的弹框是以什么形式展现的呢?不知还记不记得以前在使用layui时,使用的layer.open中的iframe形式的弹框。本文编写的就是复刻这一形式的弹框类型,感兴趣的话可以接着往下看哦。

业务背景:目前公司写的大多数页面是这种弹框的类型(都是基于一个老项目的vue2.0版本的模板开发,还有引入jQuery),弹框是基于layer.open二次封装实现的,所以后面我就自己仿照写了一个简易版本直接引入的,去掉不必要的依赖。

废话不多说直接上效果图! 9c830be5-c7a1-4c6d-918f-02e1e0565e81.png 可以全屏及拖动。目前我感觉比较麻烦的是需要维护更多的路由

代码展示: 在main.js中全局引入 31b8f2b4-dd0c-4f2b-b24d-78891be2d3c7.png页面使用: 列表页面中的新增及编辑按钮

// 新增及编辑按钮
const handleAdd = (row) => {
  openDialog(
    {
      title: row? "编辑" : "新增",
      path: "/#/testPageadd",
      width: "800px",
      height: "600px",
      fullscreen: true,
      drag: true,
      close: true,
    },
    (res) => {
      console.log(res, "resres");
    },
  );
};

新增testPageadd页面中回调事件:

//取消
const handleClose = (res) => {
  closeDialog();
};
//确定
const handleSubmit = async () => {
  closeDialog({ valid: true });
};

openDialog完整代码:

!(function (W) {
  ("use strict");
  const keyframes = `.zDialog{display: inline-block;box-sizing: border-box;border-radius: 6px;} 
    @keyframes zcentre_in {
    0% {
        opacity: 0;
         transform: scale(0);
        -webkit-transform: scale(0);
        -moz-transform: scale(0);
        -ms-transform: scale(0);
        -o-transform: scale(0);
    }100% {
        opacity: 1;
        transform: scale(1);
        -webkit-transform: scale(1);
        -moz-transform: scale(1);
        -ms-transform: scale(1);
        -o-transform: scale(1);
        }
    }
    @keyframes zcentre_out {
    0% {
        opacity: 1;
         transform: scale(1);
        -webkit-transform: scale(1);
        -moz-transform: scale(1);
        -ms-transform: scale(1);
        -o-transform: scale(1);
    }100% {
        opacity: 0;
        transform: scale(0);
        -webkit-transform: scale(0);
        -moz-transform: scale(0);
        -ms-transform: scale(0);
        -o-transform: scale(0);
        display:none
        }
    }`;
  // 创建style标签
  const stylekeyframes = document.createElement("style");
  // 设置style属性
  stylekeyframes.type = "text/css";
  // 将 keyframes样式写入style内
  stylekeyframes.innerHTML = keyframes;
  // 将style样式存放到head标签
  document.head.appendChild(stylekeyframes);
  // 样式合集
  let style = {
    Dialog: `position: fixed;top: 0;left: 0;height: 100vh;width: 100vw;overflow: hidden;`, // 主体样式
    Dialog2: `position: absolute;overflow: hidden;`, // 主体样式2
    Dialog3: `top: 50%;transform: translateY(-50%);`, // 主体样式3
    titleStyle: `justify-content: space-between;`,
    titleText: `padding: 10px 20px;width:0;flex:1;font-size: 16px;box-sizing: border-box;`,
    titleClose: `margin: 10px 20px;text-align: right;cursor: pointer;`,
    full: `margin: 10px 0;text-align: right;cursor: pointer;`,
    shade: `position: fixed;top: 0;bottom: 0;left: 0px;right: 0;`,
    iframe: `width: 100%;border: none; `,
  };
  //缓存常用字符
  var doms = [
    "zDialog",
    "zDialog-title",
    "zDialog-iframe",
    "zDialog-content",
    "zDialog-btn",
    "zDialog-close",
    "zDialog-iframe-box",
  ];
  // 弹框框数组
  let openArray = [];
  // 默认方法。
  let zDialog = {
    index: window.zDialog && window.zDialog.v ? 100000 : 0,
    open: "",
  };
  class openClass {
    constructor(setings, callback) {
      this.index = ++zDialog.index;
      this.dialogId = doms[0] + this.index;
      let csetings = JSON.parse(JSON.stringify(setings));
      this.setingsTop = setings.top;
      this.config = {
        v: "1.0.0",
        zIndex: 19961025,
        index: 0,
        closeShow: true, // 是否显示关闭
        needShade: true, // 遮罩
        shadoClick: false, // 遮罩关闭
        shadoColor: "rgba(0, 0, 0, .5)", // 遮罩颜色
        animationTime: 300, // 动画时间
        dtitleshow: true, // 弹框标题显示隐藏
        drag: false, // 拖拽
        fullscreen: false, // 全屏
        isFullscreen: false, // 是否全屏
        time: null, // 动画时间
        top: "100px", // 离顶高度
        left: "100px", // 离左宽度
        width: "800px", // 宽
        height: "600px", // 高
        close: false, // 关闭执行(点击右上角关闭也执行回调)
      };
      if (csetings.top && typeof csetings.top == "number") {
        csetings.top = csetings.top + "px";
      }
      if (csetings.width && typeof csetings.width == "number") {
        csetings.width = csetings.width + "px";
      }
      if (csetings.height && typeof csetings.height == "number") {
        csetings.height = csetings.height + "px";
      }
      this.config = { ...this.config, ...csetings };
      this.callback = callback;
      document.body
        ? this.creat()
        : setTimeout(function () {
            this.createanimation();
            this.creat();
          }, 30);
    }
    creat() {
      if (!this.config.path) {
        alert("请填写路径参数(path)");
        return;
      }
      // 判断黑白
      let scheme = localStorage.getItem("vueuse-color-scheme");
      const dark = "dark";
      let dialogBg = scheme == dark ? "rgba(41,34.2,24,0)" : "#fff"; //弹框背景
      let titleBg = scheme == dark ? "rgb(33.2, 61.4, 90.5)" : "#eee"; //标题背景
      let closeBg = scheme == dark ? "#fff" : "#000"; //标题背景

      // 添加动画样式 js创建@keyframes
      const closeSvg = `<svg t="1703816731858" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6721" width="14" height="14"><path d="M927.435322 1006.57265l-415.903813-415.903814L95.627695 1006.57265a56.013982 56.013982 0 1 1-79.20377-79.231777l415.903814-415.875807L16.423925 95.58926A56.013982 56.013982 0 0 1 95.627695 16.357483l415.903814 415.903813L927.435322 16.357483a55.985975 55.985975 0 1 1 79.175763 79.231777L590.763286 511.465066l415.847799 415.875807a55.985975 55.985975 0 1 1-79.175763 79.231777z" fill="${closeBg}" p-id="6722"></path></svg>`;
      const fullScreenSvg = `<svg t="1703816632687" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5441" width="16" height="16"><path d="M704 1024v-128h192v-192h128v320h-320z m192-808.064L215.936 896H448v128H0V576h128v232.064L808.064 128H576V0h448v448h-128V215.936zM128 320H0V0h320v128H128v192z" fill="${closeBg}" p-id="5442"></path></svg>`;

      const titleImgBgc =
        scheme == dark ? "" : `background: rgba(95, 119, 255, 0.51);`;

      this.config.time = this.config.animationTime / 1000;
      // 创建主体盒子
      let zDialogBox = `
            <div class="${doms[0]} ${doms[0] + this.index}" style='${
              style.Dialog2
            }z-index:${this.config.zIndex + this.index};animation: zcentre_in ${
              this.config.time
            }s;width: ${this.config.width};height: ${
              this.config.height
            };background: ${dialogBg}'>
                <div class='${doms[1] + this.index}' style='${
                  style.titleStyle
                }${titleImgBgc}display:${this.config.dtitleshow ? "flex" : "none"};user-select: none;'>
                    <div class='title${this.index}' style='${
                      style.titleText
                    };cursor: ${this.config.drag ? "move" : "initial"}'>${
                      this.config.title || "标题"
                    }</div>
                    <div class='fullScreen${this.index}' style='${
                      style.full
                    };display:${this.config.fullscreen ? "block" : "none"};user-select: none;'>${fullScreenSvg}</div>
                    <div class='close${this.index}' style='${
                      style.titleClose
                    };display:${this.config.closeShow ? "block" : "none"};user-select: none;'>${closeSvg}</div>
                </div>
                <div class="${
                  doms[6] + this.index
                }" style='background: var(--container-box-bg-color)'><iframe class="${
                  doms[2] + this.index
                }" style="${style.iframe}" src='${this.config.path}'></iframe></div>
            </div>`;
      openArray.push({ index: this.index, dialog: this });
      let div = document.createElement("div");
      div.id = doms[0] + this.index;
      this.config.needShade &&
        (div.style =
          style.Dialog + `z-index:` + (this.config.zIndex + this.index));
      !this.config.needShade && (div.style.position = "fixed");
      !this.config.needShade && (div.style.top = "0px");
      div.innerHTML = zDialogBox;
      if (W.parent) {
        W.parent.document.body.appendChild(div);
      } else {
        document.body.appendChild(div);
      }
      // 拖动
      let querySelector = div.querySelector(".title" + this.index);
      querySelector.onmousedown = (event) => {
        this.config.drag && this.move(event);
      };
      //关闭
      let querySelector2 = div.querySelector(".close" + this.index);
      querySelector2.onclick = () => {
        this.cancel();
      };
      // 全屏
      let querySelector3 = div.querySelector(".fullScreen" + this.index);
      querySelector3.onclick = () => {
        this.config.isFullscreen = !this.config.isFullscreen;
        this.isFullscreen();
      };

      // 执行计算宽度的方法
      this.calculatedHeight();
      W.addEventListener("resize", () => {
        this.calculatedHeight("resize");
      });
      this.config.needShade && this.shadeo();
    }
    // 添加遮罩
    shadeo() {
      setTimeout(() => {
        // 添加背景板,根据needShade判断是否显示蒙版 true/false
        let divs = document.getElementById(doms[0] + this.index);
        let div = [divs][0];
        let shade = document.createElement("div");
        shade.id = doms[2] + this.index;
        let shadeStyle =
          style.shade + "z-index:" + (this.config.zIndex + this.index - 1);
        shade.style = shadeStyle;
        shade.style.background = this.config.shadoColor;
        shade.onclick = () => {
          this.config.shadoClick && zDialog.close();
        };
        div.appendChild(shade);
      }, this.config.time - 100);
    }
    cancel() {
      if (this.config.close) {
        zDialog.close({ close: true });
      } else {
        zDialog.close();
      }
    }
    // 回调函数
    callback() {
      config.close && config.close();
    }
    // 拖动
    move(event) {
      let div = document.getElementsByClassName(doms[0] + this.index);
      let moveElement = div[0];
      let windowHeight = W.innerHeight;
      let windowWidth = W.innerWidth;
      document.onmousemove = function (ent) {
        let evt = ent || window.event;
        // 获取鼠标移动的坐标位置
        let ele_top = evt.clientY - event.offsetY;
        let ele_left = evt.clientX - event.offsetX;
        // 将移动的新的坐标位置进行赋值
        // 限制拖动范围
        if (ele_top < 0) {
          ele_top = 0;
        }
        if (ele_left < 0) {
          ele_left = 0;
        }
        // 右边和右下也限制拖动范围
        if (ele_top > windowHeight - moveElement.clientHeight) {
          ele_top = windowHeight - moveElement.clientHeight;
        }
        if (ele_left > windowWidth - moveElement.clientWidth) {
          ele_left = windowWidth - moveElement.clientWidth;
        }

        moveElement.style.top = ele_top + "px";
        moveElement.style.left = ele_left + "px";
      };
      document.onmouseup = function (ent) {
        document.onmousemove = function () {
          return false;
        };
      };
    }

    // 全屏
    isFullscreen() {
      let div = document.getElementsByClassName(doms[0] + this.index);
      let windowHeight = W.innerHeight;
      let windowWidth = W.innerWidth;
      if (this.config.isFullscreen) {
        div[0].style.width = "100%";
        div[0].style.height = "100%";
        div[0].style.top = "0px";
        div[0].style.left = "0px";
      } else {
        div[0].style.width = this.config.width;
        div[0].style.height = this.config.height;
        div[0].style.left = this.config.left;
        div[0].style.top = this.config.top;
      }
    }

    // 计算整个宽度,左右居中
    calculatedHeight(type) {
      let windowHeight = W.innerHeight;
      let windowWidth = W.innerWidth;
      let div = document.getElementsByClassName(doms[0] + this.index);
      if (div && div[0]) {
        let left = (windowWidth - div[0].clientWidth) / 2;
        div[0].style.left = left + "px";
        this.config.left = left + "px";
        // 计算iframe 的高度
        let title = document.getElementsByClassName(doms[1] + this.index);
        let t_h = title[0].clientHeight;
        let all = div[0].clientHeight;
        let iframeh2 = document.getElementsByClassName(doms[6] + this.index);
        let iframeh = document.getElementsByClassName(doms[2] + this.index);
        // 距离顶部
        if (!this.setingsTop) {
          let top = (windowHeight - div[0].clientHeight) / 2;
          div[0].style.top = top + "px";
          this.config.top = top + "px";
        } else {
          div[0].style.top = this.config.top;
        }
        if (!type) {
          iframeh[0].style.opacity = 0;
        }
        if (iframeh[0].attachEvent) {
          // IE 浏览器使用 attachEvent 方法
          iframeh[0].attachEvent("onload", function () {
            iframeh[0].style.opacity = 1;
          });
        } else {
          // 非 IE 浏览器使用 onload 事件
          iframeh[0].onload = function () {
            iframeh[0].style.opacity = 1;
          };
        }
        iframeh2[0].style.height = all - t_h + "px";
        iframeh[0].style.height = all - t_h + "px";
      }
    }
  }
  // 关闭当前
  zDialog.close = function (rcode) {
    let aindex = openArray.pop();
    let nindex = aindex.index;
    if (rcode && aindex.dialog.callback) {
      rcode && aindex.dialog.callback(rcode);
    }
    try {
      W.removeEventListener("resize", () => {});
    } catch (error) {}
    close(nindex);
  };
  // 关闭全部弹框
  zDialog.closeAll = function (callback) {
    openArray.forEach((ele) => {
      close(ele.index);
    });
    openArray = [];
    callback && callback();
  };
  // 根据索引关闭弹框
  close = function (index) {
    let div = document.getElementsByClassName(doms[0] + index);
    if (div && div[0]) {
      div[0].style.animation = "zcentre_out 0.3s";
    }
    setTimeout(() => {
      if (W.parent) {
        let re = W.parent.document.getElementById(doms[0] + index);
        W.parent.document.body.removeChild(re);
      } else {
        let re = document.getElementById(doms[0] + index);
        document.body.removeChild(re);
      }
    }, 200);
  };
  zDialog.open = function (deliver, callback) {
    let z = new openClass(deliver, callback);
    return z.index;
  };
  // 多层嵌套 只在父级添加
  if (W.parent.zDialog) {
    zDialog = W.parent.zDialog;
    zDialog.open = W.parent.zDialog.open;
    zDialog.close = W.parent.zDialog.close;
  }
  //暴露模块
  W.zDialog = zDialog;
  W.openDialog = zDialog.open;
  W.closeDialog = zDialog.close;
})(window);

至此结束!!!谢谢观看!!!

前端请求三部曲:Ajax / Fetch / Axios 演进与 Vue 工程化封装

作者 忆往wu前
2026年4月20日 12:33

从 Ajax → Fetch → Axios:前端网络请求演进史与工程化封装

前言

本篇是 Vue项目实战三板斧系列第一篇,专门聊聊前端最基础的网络请求。

不少同学上来就用 axios,会写但不太明白它到底是怎么来的。 这篇我就带大家简单走一遍进化路线:从最原始的 Ajax,到原生 Fetch,再到我们现在常用的 Axios,一步步看清它们的优缺点,最后一起封装一套简洁、好维护的工程化请求方案。 不求花里胡哨,只求看完能真正理解“我们为什么要这么写请求”。  

一、最原始的网络请求:原生 XMLHttpRequest

要说网络请求,老祖宗必须是 XMLHttpRequest,也就是我们常说的 Ajax。 它实现了页面不刷新就能拿数据,在当年简直是黑科技。

1.1 原生手写 Ajax(最底层写法)

// 1. 创建一个 ajax 实例
const xhr = new XMLHttpRequest();

// 2. 配置请求:请求方式、地址、异步(true)
xhr.open('GET','/api/data',true);

// 3. 监听请求状态变化(旧版常用写法)
xhr.onreadystatechange = function(){
  // readyState === 4 表示请求完成
  if(xhr.readyState === 4){
    // status 200~299 代表请求成功
    if(xhr.status >= 200 && xhr.status < 300){
      // 把后端返回的 JSON 字符串转成对象
      const result = JSON.parse(xhr.responseText);
      console.log('请求成功',result)
    }else{
      console.log('请求失败',xhr.status);
    }
  }
}

// 网络异常、跨域失败时触发
xhr.onerror = function(){
  console.log('网络异常或跨域错误')
}

// 4. 发送请求
xhr.send()

1.2 简单封装一下 Ajax

原生写法太啰嗦,我们简单封装一版,方便复用。

// 封装一个自己的 ajax 函数
function myajax(options) {
  // 1. 创建请求实例
  const xhr = new XMLHttpRequest()

  // 2. 解构配置参数,给默认值
  const {
    method = 'GET',  // 默认 GET 请求
    url,             // 请求地址
    data = null,     // 参数(这里演示无参)
    success,         // 成功回调
    error            // 失败回调
  } = options

  // 3. 初始化请求,转大写防止小写出错
  xhr.open(method.toUpperCase(), url, true)

  /*
    旧写法:onreadystatechange 需要判断 readyState
    新写法:onload 等价于 readyState=4,直接用更简单
  */
  xhr.onload = function () {
    // 判断 HTTP 状态码是否成功
    if (xhr.status >= 200 && xhr.status < 300) {
      // 解析后端返回的 JSON
      const res = JSON.parse(xhr.responseText)
      // 有成功回调就执行
      success && success(res)
    } else {
      // 失败把状态码抛出去
      error && error(xhr.status)
    }
  }

  // 网络异常触发
  xhr.onerror = function () {
    error && error('网络异常或跨域')
  }

  // 发送请求(这里不传参数,避免 GET 报错)
  xhr.send()
}

1.3 Ajax 的缺点(为啥我们不用它了)

缺点一:配置繁琐,全手动判断

详细解释:每发送一个请求,都要重复创建 XMLHttpRequest 实例、调用 open 配置请求、监听状态/错误、调用 send 发送请求,步骤多且冗余。而且要手动判断 readyState 请求状态、手动判断 status HTTP状态码、手动执行 JSON.parse 解析后端返回的字符串,没有任何自动处理逻辑,代码量极大,每写一个请求都要重复大量代码。

缺点二:回调一多直接回调地狱

详细解释: Ajax基于回调函数处理结果,一旦遇到连续多个依赖请求(比如先获取用户ID,再用ID获取详情,再用详情获取订单),就需要在success回调里嵌套下一个myajax请求。代码会层层嵌套、缩进不断加深,可读性极差,后期根本无法维护和修改,这就是典型的回调地狱问题。

  
// 回调地狱示例
myajax({
  url:'/api/user',
  success(res){
    // 第一层回调
    myajax({
      url:`/api/detail?id=${res.id}`,
      success(res){
        // 第二层回调
        myajax({
          url:`/api/order?did=${res.detailId}`,
          success(res){
            // 第三层回调,代码彻底混乱
          }
        })
      }
    })
  }
})
缺点三:没有拦截器、没有超时、没有统一处理

详细解释: 原生XHR没有全局请求/响应拦截机制,每个请求都要单独写错误处理、单独加请求头、单独处理返回结果。比如要给所有接口加token,必须在每个 xhr.open 之后,手动写 setRequestHeader ;想要设置请求超时,需要额外写定时器手动中断请求,无法做到一处配置、全局生效。

缺点四:不支持 Promise

详细解释: 原生Ajax不支持Promise语法,无法使用 async/await 、 then/catch 这种现代化异步写法,只能用传统回调函数。异步流程完全不可控,代码书写不优雅,也无法和现代前端的异步语法接轨,和后续的Fetch、Axios生态完全脱节。

总结:理解底层即可,真实项目没人直接写原生 Ajax。

 

二、现代浏览器原生:Fetch API

时代在进步,浏览器终于看不下去了,推出了Fetch。基于 Promise,告别回调,写法清爽多了。不用从头开始造,省时省力。

2.1 GET 请求(带参数拼接)

// 定义参数
const params = {
  id: 123,
  name: "text"
}

// 把对象转成 ?id=123&name=text 这种格式
const query = new URLSearchParams(params).toString();

// 发送请求
fetch(`/api/user?${query}`)
  .then(res => {
    // fetch 很坑:只有网络失败才 reject,404/500 依然走 then
    if (!res.ok) throw new Error("请求失败:" + res.status)
    // 解析 JSON
    return res.json()
  })
  .then(data => {
    console.log("获取数据成功", data)
  })
  .catch(err => {
    console.error("请求异常", err)
  })

2.2 POST 请求

// fetch 的 post 请求
fetch("/api/user", {
  method: "POST",
  headers: {
    // 必须声明传递 JSON 格式
    "Content-Type": "application/json"
  },
  // 对象转 JSON 字符串
  body: JSON.stringify({
    username: "admin",
    password: "123456"
  })
})
  .then(res => {
    if (!res.ok) throw new Error(res.status)
    return res.json()
  })
  .then(data => {
    console.log("请求成功", data)
  })
  .catch(err => {
    console.error("请求失败", err)
  }) 

2.3 async/await 语法糖更香

// 用 async/await 让代码看起来像同步
async function fetchData() {
  try {
    // 请求参数
    const postData = {
      username: "zhangsan",
      password: "123456"
    }

    // 发送请求
    const response = await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(postData)
    })

    // 判断请求是否成功
    if (!response.ok) {
      throw new Error("请求失败,状态码:" + response.status)
    }

    // 解析数据
    const result = await response.json()
    console.log("请求成功", result)
  } catch (error) {
    // 统一捕获错误
    console.error("请求错误", error)
  }
}

// 执行
fetchData();

2.4 简单封装一版 Fetch

// 封装一个通用的 fetch 请求函数
async function request(url, options = {}) {
  // 解构参数
  const { method = 'GET', data, headers = {}, ...rest } = options;
  // 方法转大写
  const upperMethod = method.toUpperCase();
  // 最终请求地址
  let fetchUrl = url;

  // 配置 fetch 参数
  let fetchOptions = {
    method,
    headers,
    ...rest
  }

  // GET 请求:参数拼接到地址栏
  if (upperMethod === 'GET' && data) {
    const queryStr = new URLSearchParams(data).toString()
    fetchUrl += `?${queryStr}`
  }

  // POST/PUT/DELETE 处理 JSON 格式
  if (['POST', 'PUT', 'DELETE'].includes(upperMethod) && data) {
    // 设置请求头
    fetchOptions.headers['Content-Type'] = 'application/json';
    // 转 JSON 字符串
    fetchOptions.body = JSON.stringify(data)
  }

  try {
    // 发送请求
    const res = await fetch(fetchUrl, fetchOptions)
    // 判断状态
    if (!res.ok) { throw new Error(`请求错误:${res.status}`) }
    // 解析并返回数据
    return await res.json()
  } catch (err) {
    // 打印并抛出异常,外部可以继续 catch
    console.error('请求失败', err)
    throw err
  }
}

2.5 Fetch 有哪些硬伤?

硬伤一:网络错误才 reject,404 / 500 依然走 then,必须手动判断

详细解释: Fetch 的“成功”只看网络是否发出去,只要浏览器收到了 HTTP 响应,哪怕是 401、404、500 错误, fetch  依然认为请求“成功”,会走进  then  而不是  catch 。 所以你必须每次手动判断  res.ok ,否则会把错误当正常数据处理,导致页面报错。

// 不写这句,404/500 不会进 catch
if (!res.ok) throw new Error("请求失败")
硬伤二:没有请求、响应拦截器,所有逻辑必须手写重复

详细解释: Fetch 原生不支持拦截器。如果你想给所有接口加 token、加请求头、统一处理返回值、统一报错,每个 fetch 都要写一遍,无法像 axios 那样全局配置一次到处生效。

// 每个请求都要重复写一遍
headers: {
  "Content-Type": "application/json",
  Authorization: "Bearer " + token
}

 

硬伤三:无法取消请求,没有 abort 方案(必须额外用 AbortController)

详细解释: 原生 fetch 自身不支持取消请求。想要取消必须手动搭配  AbortController ,写一堆额外代码,切换页面、重复请求时无法自动中断,容易造成内存泄漏、重复请求、旧数据覆盖新数据等问题。

硬伤四:没有自带超时处理,超时要自己写定时器包装

详细解释: Axios 直接配置  timeout: 5000  就可以自动超时中断。Fetch 没有超时配置,想实现超时必须自己包一层  Promise.race  +  setTimeout ,每个请求都要重复造轮子,非常麻烦。

硬伤五:请求 body 不会自动处理,必须手动 JSON.stringify

详细解释: Axios 会自动帮你把对象转成 JSON、自动加  Content-Type: application/json 。 Fetch 完全不处理,你必须手动:

body: JSON.stringify(data)
headers: { "Content-Type": "application/json" }

少一句后端就收不到数据,非常容易漏写。

硬伤六:无法监听请求进度(上传/下载进度很难实现)

详细解释: Axios 自带  onUploadProgress  可以直接监听上传进度做进度条。 Fetch 原生不支持,只能通过  ReadableStream  自己手动解析流,实现复杂、成本极高,普通项目基本没法用。

结论:小 demo 能用,中大型项目顶不住。

三、项目主流方案:Axios 全面上手

前面我们了解了:

  • 最底层:XMLHttpRequest,功能强但写起来巨麻烦
  • 现代原生:Fetch,语法好看,但能力残缺

那有没有一个东西,既保留 XHR 的强大能力,又拥有 Fetch 的 Promise 优雅语法,还把所有坑都填了?

它就是我们现在前端项目的事实标准 —— Axios。

重点来了: Axios 并不是什么新底层技术,它本质上就是对原生 XMLHttpRequest 再次封装、增强、Promise 化之后的终极工具库。 相当于把我们刚才手写的简陋 myajax、简陋 fetch 封装,做到了工业级极致。

它解决了所有痛点:支持 Promise、自动处理 JSON、拦截器、取消请求、超时、进度监听…… 所以现在 Vue、React、小程序、Node 项目里,大家几乎都默认用 Axios。

3.1 基础使用

import axios from 'axios'

// 完整写法
axios({
  method: 'get',
  url: '/user',
  params: { id: 10 }
})
  .then(res => {
    // axios 自动帮你解析了 JSON,直接拿 data
    console.log(res.data)
  })
  .catch(err => {
    console.log('请求失败', err)
  }) 

3.2 简写 GET / POST

// 简写 GET
axios.get('/user', {
  params: { id: 10 }
}).catch(err => {
  console.log(err)
})

// 简写 POST(自动处理 JSON,不用自己 stringify)
axios.post('/login', {
  username: 'admin',
  password: '123456'
}).catch(err => {
  console.log(err)
})

3.3 async/await 优雅版

// 登录请求
async function login() {
  try {
    const res = await axios.post('/login', {
      username: 'admin',
      password: '123456'
    })
    console.log(res.data)
  } catch (err) {
    // 请求失败、状态码错误都会进这里
    console.log('请求失败', err)
  }
}

 

四、工程化核心:Axios 二次封装(重点)

真实项目里,我们不会到处直接写 axios.get, 必须封装一次,统一处理:token、超时、状态码、错误提示。

4.1 封装 request.js

import axios from 'axios'

// 创建 axios 实例
const request = axios.create({
  baseURL: '/api',    // 统一接口前缀
  timeout: 5000       // 超时时间 5 秒
})

// =================== 请求拦截器 ===================
request.interceptors.request.use(config => {
  // 从本地拿到 token
  const token = localStorage.getItem('token')
  // 如果有 token,就加到请求头里
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  // 必须 return config
  return config
})

// =================== 响应拦截器 ===================
request.interceptors.response.use(
  res => {
    // 直接返回后端数据,页面不用再 .data
    return res.data
  },
  err => {
    // 统一错误提示
    console.log('请求出错', err)
    // 抛出异常,让页面可以自己 catch 处理
    return Promise.reject(err)
  }
)

// 导出实例,页面引入使用
export default request

这一封装,好处直接拉满:

- 统一 baseURL,后期改地址只改一处

- 所有接口自动带 token,不用每个请求写

- 统一错误处理,不用每个接口 catch

- 响应直接返回 data,代码更干净

 

五、接口模块化管理(真正工程化)

封装完 axios 还不够,工程化必须接口模块化。

5.1 按业务拆分文件

src/
└── api/
    ├── request.js   # axios 封装
    ├── user.js      # 用户相关接口
    ├── goods.js     # 商品相关接口
    └── order.js     # 订单相关接口

5.2 user.js 示例

import request from './request'

// 登录接口
export function loginApi(data) {
  return request({
    url: '/login',
    method: 'post',
    data
  })
}

// 获取用户信息
export function getUserInfo() {
  return request({
    url: '/user/info',
    method: 'get'
  })
}

5.3 组件中使用

import { loginApi } from '@/api/user'

async function login() {
  try {
    const res = await loginApi({
      username: 'admin',
      password: '123456'
    })
    console.log('登录成功', res)
  } catch (err) {
    console.log('登录失败')
  }
}

优点:

- 接口统一管理,便于维护

- 页面逻辑更干净

- 方便 mock、方便重复调用

六、在 Vue3 组件中实战使用

Vue

<template>
  <div>
    <button @click="getUser">获取用户信息</button>
  </div>
</template>

<script setup>
import { getUserInfo } from '@/api/user'
import { ref } from 'vue'

const userInfo = ref({})

// 获取数据
const getUser = async () => {
  try {
    const res = await getUserInfo()
    userInfo.value = res
  } catch (err) {
    console.log('请求失败')
  }
}
</script>

可以看到,组件中已经完全看不到底层的  axios  调用,只需要调用封装好的接口方法即可完成数据请求。代码更加简洁清晰,职责更加单一,真体现了前端工程化低耦合、高复用、易维护的优势。

七、总结

这一篇我们完整走完了前端请求进化之路:

1. Ajax(XMLHttpRequest):底层基石,所有请求的根

2. Fetch:浏览器原生 Promise 方案,但能力有限

3. Axios:基于 XHR 深度封装,现代前端工程化最佳实践

工具一直在变,但核心思路没变:

从繁琐难用,到语法简化,再到功能完善。 Axios 之所以成为主流,正是因为它在原生 XHR 的基础上做了大量贴心封装,让我们不用再重复处理各种细节。

而封装和工程化的意义,也远不止“省事”这么简单:

统一的配置、统一的错误处理、按模块拆分接口,本质上都是为了让代码更简洁、更好维护、更容易协作。 一个项目是否规范,往往从请求层就能看出来。

搞懂这些来龙去脉,以后再写接口、做封装,就不再是机械复制代码,而是真正知道自己在做什么、为什么这么做。

这也是 Vue 项目工程化的第一步。 下一篇我们继续三板斧第二篇:VueRouter 路由与路由守卫,配合今天的 token 实现登录鉴权。

你的 Vue KeepAlive 组件,VuReact 会编译成什么样的 React 代码?

作者 Ruihong
2026年4月20日 10:11

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

前置约定

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

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

编译对照

KeepAlive:组件缓存

<KeepAlive> 是 Vue 中用于缓存组件实例的内置组件,可以在动态切换组件时保留组件状态,避免重新渲染和数据丢失。

基础 KeepAlive 使用

  • Vue 代码:
<template>
  <KeepAlive>
    <component :is="currentView" />
  </KeepAlive>
</template>
  • VuReact 编译后 React 代码:
import { KeepAlive } from '@vureact/runtime-core';

<KeepAlive>
  <Component is={currentView} />
</KeepAlive>

从示例可以看到:Vue 的 <KeepAlive> 组件被编译为 VuReact Runtime 提供的 KeepAlive 适配组件,可理解为「React 版的 Vue KeepAlive」。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue <KeepAlive> 的行为,实现组件实例缓存
  2. 状态保持:缓存被移除的组件实例,避免状态丢失
  3. 性能优化:减少不必要的组件重新渲染
  4. React 适配:在 React 环境中实现 Vue 的缓存语义

带 key 的 KeepAlive

为了确保缓存正确工作,建议为动态组件提供稳定的 key

  • Vue 代码:
<template>
  <KeepAlive>
    <component :is="currentComponent" :key="componentKey" />
  </KeepAlive>
</template>
  • VuReact 编译后 React 代码:
<KeepAlive>
  <Component is={currentComponent} key={componentKey} />
</KeepAlive>

key 的重要性

  1. 缓存标识key 用于标识和匹配缓存实例
  2. 稳定切换:确保组件切换时能正确命中缓存
  3. 性能优化:避免不必要的缓存创建和销毁
  4. 最佳实践:始终为动态组件提供稳定的 key

包含与排除控制

<KeepAlive> 支持通过 includeexclude 属性精确控制哪些组件需要缓存。

include:包含特定组件

  • Vue 代码:
<template>
  <KeepAlive :include="['ComponentA', 'ComponentB']">
    <component :is="currentView" />
  </KeepAlive>
</template>
  • VuReact 编译后 React 代码:
<KeepAlive include={['ComponentA', 'ComponentB']}>
  <Component is={currentView} />
</KeepAlive>

exclude:排除特定组件

  • Vue 代码:
<template>
  <KeepAlive :exclude="['GuestPanel', /^Temp/]">
    <component :is="currentView" />
  </KeepAlive>
</template>
  • VuReact 编译后 React 代码:
<KeepAlive exclude={['GuestPanel', /^Temp/]}>
  <Component is={currentView} />
</KeepAlive>

匹配规则

  1. 字符串匹配:精确匹配组件名
  2. 正则表达式:匹配符合模式的组件名
  3. 数组组合:支持字符串和正则的数组组合
  4. key 匹配:同时尝试匹配组件名和缓存 key

最大缓存实例数

通过 max 属性可以限制最大缓存数量,避免内存过度使用。

  • Vue 代码:
<template>
  <KeepAlive :max="3">
    <component :is="currentTab" />
  </KeepAlive>
</template>
  • VuReact 编译后 React 代码:
<KeepAlive max={3}>
  <Component is={currentTab} />
</KeepAlive>

缓存淘汰策略

  1. LRU 算法:淘汰最久未访问的缓存实例
  2. 内存管理:自动清理超出限制的缓存
  3. 性能平衡:在内存使用和性能之间取得平衡
  4. 智能管理:根据访问频率智能管理缓存

缓存生命周期

<KeepAlive> 缓存的组件有特殊的生命周期,可以通过相应的 Hook 监听。

激活与停用生命周期

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

onActivated(() => {
  console.log('组件被激活');
});

onDeactivated(() => {
  console.log('组件被停用');
});
</script>
  • VuReact 编译后 React 代码:
import { useActived, useDeactivated } from '@vureact/runtime-core';

function MyComponent() {
  useActived(() => {
    console.log('组件被激活');
  });

  useDeactivated(() => {
    console.log('组件被停用');
  });

  return <div>组件内容</div>;
}

生命周期事件

  1. useActived:组件从缓存中恢复显示时触发
  2. useDeactivated:组件被缓存时触发
  3. 首次渲染:组件首次渲染时也会触发 activated
  4. 最终卸载:组件最终被销毁时触发 deactivated

编译策略总结

VuReact 的 KeepAlive 编译策略展示了完整的组件缓存转换能力

  1. 组件直接映射:将 Vue <KeepAlive> 直接映射为 VuReact 的 <KeepAlive>
  2. 属性完全支持:支持 includeexcludemax 等所有属性
  3. 生命周期适配:将 Vue 生命周期 Hook 转换为 React Hook
  4. 缓存语义保持:完全保持 Vue 的缓存行为和语义

KeepAlive 的工作原理

  1. 实例缓存:组件切出时保留实例在内存中
  2. 状态保持:保持组件的所有状态和数据
  3. DOM 保留:保留组件的 DOM 结构
  4. 智能恢复:切回时快速恢复之前的实例

性能优化策略

  1. 按需缓存:只缓存真正需要的组件
  2. 内存管理:智能管理缓存内存使用
  3. 快速恢复:优化缓存恢复性能
  4. 垃圾回收:及时清理不再需要的缓存

注意事项

  1. 单一子节点<KeepAlive> 只能有一个直接子节点
  2. 组件类型:只能缓存组件元素,不能缓存普通元素
  3. key 要求:缺少稳定 key 时会降级为非缓存渲染

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动实现组件缓存逻辑。编译后的代码既保持了 Vue 的缓存语义和性能优势,又符合 React 的组件设计模式,让迁移后的应用保持完整的组件缓存能力。

🔗 相关资源


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

你的 Vue slot 插槽,VuReact 会编译成什么样的 React 代码?

作者 Ruihong
2026年4月20日 09:17

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

前置约定

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

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

编译对照

默认插槽:<slot>

默认插槽是 Vue 中最基本的插槽形式,用于接收父组件传递的默认内容。

  • Vue 代码:
<!-- 子组件 Child.vue -->
<template>
  <div class="container">
    <slot></slot>
  </div>
</template>

<!-- 父组件使用 -->
<Child>
  <p>这是插槽内容</p>
</Child>
  • VuReact 编译后 React 代码:
// 子组件 Child.jsx
function Child(props) {
  return (
    <div className="container">
      {props.children}
    </div>
  );
}

// 父组件使用
<Child>
  <p>这是插槽内容</p>
</Child>

从示例可以看到:Vue 的 <slot> 元素被编译为 React 的 children prop。VuReact 采用 children 编译策略,将插槽出口转换为 React 的标准 children 接收方式,完全保持 Vue 的默认插槽语义——接收父组件传递的子内容并渲染。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue 默认插槽的行为,实现内容分发
  2. React 原生支持:使用 React 标准的 children 机制,无需额外适配
  3. 语法简洁:Vue 的 <slot> 简化为 {children} 表达式
  4. 性能优化:直接使用 React 的原生机制,无运行时开销

具名插槽:<slot name="xxx">

具名插槽允许组件定义多个插槽出口,父组件可以通过名称指定内容插入位置。

  • Vue 代码:
<!-- 子组件 Layout.vue -->
<template>
  <div class="layout">
    <header>
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

<!-- 父组件使用 -->
<Layout>
  <template #header>
    <h1>页面标题</h1>
  </template>
  
  <p>主要内容</p>
  
  <template #footer>
    <p>版权信息</p>
  </template>
</Layout>
  • VuReact 编译后 React 代码:
// 子组件 Layout.jsx
function Layout(props) {
  return (
    <div className="layout">
      <header>{props.header}</header>
      <main>{props.children}</main>
      <footer>{props.footer}</footer>
    </div>
  );
}

// 父组件使用
<Layout
  header={<h1>页面标题</h1>}
  footer={<p>版权信息</p>}
>
  <p>主要内容</p>
</Layout>

从示例可以看到:Vue 的具名插槽 <slot name="xxx"> 被编译为 React 的 props。VuReact 采用 props 编译策略,将具名插槽出口转换为组件的命名 props,完全保持 Vue 的具名插槽语义——通过不同的 prop 名称区分不同的插槽内容。

编译规则

  1. 插槽名映射<slot name="header">header prop
  2. 默认插槽<slot>children prop
  3. props 接收:在组件函数参数中解构接收所有插槽 props

作用域插槽:<slot :prop="value">

作用域插槽允许子组件向插槽内容传递数据,实现更灵活的渲染控制。

  • Vue 代码:
<!-- 子组件 List.vue -->
<template>
  <ul>
    <li v-for="(item, i) in props.items" :key="item.id">
      <slot :item="item" :index="i"></slot>
    </li>
  </ul>
</template>

<!-- 父组件使用 -->
<List :items="users">
  <template v-slot="slotProps">
    <div class="user-item">
      {{ slotProps.index + 1 }}. {{ slotProps.item.name }}
    </div>
  </template>
</List>
  • VuReact 编译后 React 代码:
// 子组件 List.jsx
function List(props) {
  return (
    <ul>
      {props.items.map((item, index) => (
        <li key={item.id}>
          {props.children?.({ item, index })}
        </li>
      ))}
    </ul>
  );
}

// 父组件使用
<List 
  items={users}
  children={(slotProps) => (
    <div className="user-item">
      {slotProps.index + 1}. {slotProps.item.name}
    </div>
  )}
/>

从示例可以看到:Vue 的作用域插槽被编译为 React 的函数 children。VuReact 采用 函数 children 编译策略,将作用域插槽出口转换为接收参数的函数,完全保持 Vue 的作用域插槽语义——子组件通过函数调用向父组件传递数据,父组件通过函数参数接收数据并渲染。

编译规则

  1. 插槽属性转换<slot :item="item" :index="i"> → 函数参数 { item, index }
  2. 函数调用:在渲染位置调用 children() 函数并传递数据
  3. 可选链保护:使用 ?. 避免未提供插槽内容时的错误

具名作用域插槽:<slot name="xxx" :prop="value">

具名作用域插槽结合了具名插槽和作用域插槽的特性。

  • Vue 代码:
<!-- 子组件 Table.vue -->
<template>
  <table>
    <thead>
      <tr>
        <slot name="header" :columns="props.columns"></slot>
      </tr>
    </thead>
    <tbody>
      <tr v-for="row in props.data" :key="row.id">
        <slot name="body" :row="row" :columns="props.columns"></slot>
      </tr>
    </tbody>
  </table>
</template>

<!-- 父组件使用 -->
<Table :columns="tableColumns" :data="tableData">
  <template #header="headerProps">
    <th v-for="col in headerProps.columns" :key="col.id">
      {{ col.title }}
    </th>
  </template>
  
  <template #body="bodyProps">
    <td v-for="col in bodyProps.columns" :key="col.id">
      {{ bodyProps.row[col.field] }}
    </td>
  </template>
</Table>
  • VuReact 编译后 React 代码:
// 子组件 Table.jsx
function Table(props) {
  return (
    <table>
      <thead>
        <tr>
          {props.header?.({ columns: props.columns })}
        </tr>
      </thead>
      <tbody>
        {props.data.map((row) => (
          <tr key={row.id}>
            {props.body?.({ row: props.row, columns: props.columns })}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

// 父组件使用
<Table
  columns={tableColumns}
  data={tableData}
  header={(headerProps) => (
    <>
      {headerProps.columns.map((col) => (
        <th key={col.id}>{col.title}</th>
      ))}
    </>
  )}
  body={(bodyProps) => (
    <>
      {bodyProps.columns.map((col) => (
        <td key={col.id}>{bodyProps.row[col.field]}</td>
      ))}
    </>
  )}
/>

编译策略

  1. 具名函数 props:具名作用域插槽转换为函数 props
  2. 参数传递:正确传递作用域参数
  3. Fragment 包装:多个元素使用 Fragment 包装
  4. 类型安全:保持 TypeScript 类型定义的完整性

插槽默认内容

Vue 支持在插槽定义处提供默认内容,当父组件没有提供插槽内容时显示。

  • Vue 代码:
<!-- 子组件 Button.vue -->
<template>
  <button class="btn">
    <slot>
      <span class="default-text">点击我</span>
    </slot>
  </button>
</template>
  • VuReact 编译后 React 代码:
// 子组件 Button.jsx
function Button(props) {
  return (
    <button className="btn">
      {props.children || <span className="default-text">点击我</span>}
    </button>
  );
}

默认内容处理规则

  1. 条件渲染:使用 || 运算符检查 children 是否存在
  2. 默认值提供:当 children 为 falsy 值时渲染默认内容
  3. React 模式:使用标准的 React 条件渲染模式

动态插槽名

Vue 支持动态的插槽名称,用于更灵活的插槽选择。

  • Vue 代码:
<!-- 子组件 DynamicSlot.vue -->
<template>
  <div>
    <slot :name="dynamicSlotName"></slot>
  </div>
</template>
  • VuReact 编译后 React 代码:
// 子组件 DynamicSlot.jsx
function DynamicSlot(props) {
  return (
    <div>
      {props[dynamicSlotName]}
    </div>
  );
}

动态插槽处理

  1. 计算属性名:使用对象计算属性语法接收动态插槽
  2. 运行时确定:插槽名在运行时确定

编译策略总结

VuReact 的 <slot> 编译策略展示了完整的插槽系统转换能力

  1. 默认插槽:转换为 React 的 children
  2. 具名插槽:转换为组件的命名 props
  3. 作用域插槽:转换为函数 children 或函数 props
  4. 默认内容:支持插槽默认内容
  5. 动态插槽:支持动态插槽名称

插槽类型映射表

Vue 插槽类型 React 对应形式 说明
<slot> children 默认插槽,作为组件的子元素
<slot name="xxx"> xxx prop 具名插槽,作为组件的属性
<slot :prop="value"> 函数 children 作用域插槽,作为接收参数的函数
<slot name="xxx" :prop="value"> 函数 xxx prop 具名作用域插槽,作为函数属性

性能优化策略

  1. 静态插槽优化:对于静态插槽内容,编译为静态 JSX
  2. 函数缓存:对于作用域插槽,智能缓存渲染函数
  3. 按需生成:根据实际使用情况生成最简化的代码
  4. 类型推导:支持在 TypeScript 中智能推导插槽的类型定义

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写插槽逻辑。编译后的代码既保持了 Vue 的语义和灵活性,又符合 React 的组件设计模式,让迁移后的应用保持完整的内容分发能力。

🔗 相关资源


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

Vue v-slot → 用 VuReact 转换后变成这样的 React 代码

作者 Ruihong
2026年4月19日 20:39

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

前置约定

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

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

编译对照

v-slot / #:基础插槽使用

v-slot(简写为 #) 是 Vue 中用于定义和使用插槽的指令,用于实现组件的内容分发和复用。

默认插槽

  • Vue 代码:
<!-- 父组件 -->
<MyComponent>
  <template #default>
    <p>默认插槽内容</p>
  </template>
</MyComponent>

<!-- 或简写 -->
<MyComponent>
  <p>默认插槽内容</p>
</MyComponent>
  • VuReact 编译后 React 代码:
// 父组件
<MyComponent>
  <p>默认插槽内容</p>
</MyComponent>

从示例可以看到:Vue 的默认插槽被直接编译为 React 的 children。VuReact 采用 children 编译策略,将模板插槽转换为 React 的标准 children 传递方式,完全保持 Vue 的默认插槽语义——将内容作为子元素传递给组件。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue 默认插槽的行为,实现内容分发
  2. React 原生支持:使用 React 标准的 children 机制,无需额外适配
  3. 语法简化:Vue 的 <template #default> 简化为直接传递子元素
  4. 性能优化:直接使用 React 的原生机制,无运行时开销

具名插槽

Vue 支持多个具名插槽,用于更灵活的内容分发。

基础具名插槽

  • Vue 代码:
<!-- 父组件 -->
<Layout>
  <template #header>
    <h1>页面标题</h1>
  </template>
  
  <template #main>
    <p>主要内容区域</p>
  </template>
  
  <template #footer>
    <p>页脚信息</p>
  </template>
</Layout>
  • VuReact 编译后 React 代码:
// 父组件
<Layout 
  header={<h1>页面标题</h1>}
  main={<p>主要内容区域</p>}
  footer={<p>页脚信息</p>}
/>

从示例可以看到:Vue 的具名插槽被编译为 React 的 props。VuReact 采用 props 编译策略,将具名插槽转换为组件的 props 属性,完全保持 Vue 的具名插槽语义——通过不同的 prop 名称区分不同的插槽内容。


作用域插槽

Vue 的作用域插槽允许子组件向父组件传递数据,实现更灵活的渲染控制。

基础作用域插槽

  • Vue 代码:
<!-- 父组件 -->
<DataList :items="users">
  <template #item="slotProps">
    <div class="user-item">
      <span>{{ slotProps.user.name }}</span>
      <span>{{ slotProps.user.age }}岁</span>
    </div>
  </template>
</DataList>

<!-- 子组件 DataList.vue -->
<template>
  <ul>
    <li v-for="item in props.items" :key="item.id">
      <slot name="item" :user="item"></slot>
    </li>
  </ul>
</template>
  • VuReact 编译后 React 代码:
// 父组件
<DataList 
  items={users}
  item={(slotProps) => (
    <div className="user-item">
      <span>{slotProps.user.name}</span>
      <span>{slotProps.user.age}岁</span>
    </div>
  )}
/>

// 子组件 DataList.jsx
function DataList(props) {
  return (
    <ul>
      {props.items.map((itemData) => (
        <li key={itemData.id}>
          {props.item?.({ user: itemData })}
        </li>
      ))}
    </ul>
  );
}

从示例可以看到:Vue 的作用域插槽被编译为 React 的函数 props。VuReact 采用 函数 props 编译策略,将作用域插槽转换为接收参数的函数 prop,完全保持 Vue 的作用域插槽语义——子组件通过函数调用向父组件传递数据,父组件通过函数参数接收数据并渲染。


动态插槽名

Vue 支持动态的插槽名称,用于更灵活的插槽选择。

  • Vue 代码:
<BaseLayout>
  <template #[dynamicSlotName]>
    动态插槽内容
  </template>
</BaseLayout>
  • VuReact 编译后 React 代码:
<BaseLayout 
  {...{ [dynamicSlotName]: "动态插槽内容" }}
/>

编译策略

  1. 计算属性名:使用对象计算属性语法 { [key]: value }
  2. 对象展开:通过对象展开语法应用到组件上
  3. 运行时处理:动态插槽名需要在运行时确定

插槽默认内容

Vue 支持在插槽定义处提供默认内容,当父组件没有提供插槽内容时显示。

  • Vue 代码:
<!-- 子组件 Button.vue -->
<template>
  <button class="btn">
    <slot>
      <span>默认按钮文本</span>
    </slot>
  </button>
</template>
  • VuReact 编译后 React 代码:
// 子组件 Button.jsx
function Button(props) {
  return (
    <button className="btn">
      {props.children || <span>默认按钮文本</span>}
    </button>
  );
}

默认内容处理规则

  1. children 检查:检查 children 是否存在
  2. 默认值渲染:当 children 为 falsy 值时渲染默认内容
  3. React 兼容:使用标准的 React 条件渲染模式

编译策略总结

VuReact 的 v-slot 编译策略展示了完整的插槽系统转换能力

  1. 默认插槽:转换为 React 的 children
  2. 具名插槽:转换为组件的 props
  3. 作用域插槽:转换为函数 props
  4. 动态插槽:支持动态插槽名称
  5. 默认内容:支持插槽默认内容

插槽类型映射表

Vue 插槽类型 React 对应形式 说明
默认插槽 children 作为组件的子元素
具名插槽 prop 作为组件的属性
作用域插槽 函数prop 作为接收参数的函数属性
动态插槽 计算属性 使用对象计算属性语法

性能优化策略

  1. 静态插槽优化:对于静态插槽内容,编译为静态 JSX
  2. 函数缓存:对于作用域插槽,智能缓存渲染函数
  3. 按需生成:根据实际使用情况生成最简化的代码
  4. 类型推导:智能推导插槽的类型定义

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写插槽逻辑。编译后的代码既保持了 Vue 的语义和灵活性,又符合 React 的组件设计模式,让迁移后的应用保持完整的内容分发能力。

🔗 相关资源


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

你的 Vue v-model,VuReact 会编译成什么样的 React 代码?

作者 Ruihong
2026年4月19日 20:19

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

前置约定

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

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

编译对照

v-model:基础表单双向绑定

v-model 是 Vue 中用于实现表单输入元素双向数据绑定的语法糖,它结合了 v-bindv-on 的功能。

文本输入框

  • Vue 代码:
<input v-model="keyword" />
  • VuReact 编译后 React 代码:
<input
  value={keyword.value}
  onChange={(value) => {
    keyword.value = value;
  }}
/>

从示例可以看到:Vue 的 v-model 指令被编译为 React 的受控组件模式。VuReact 采用 受控组件编译策略,将模板指令转换为 valueonChange 的组合,完全保持 Vue 的双向绑定语义——实现数据与视图的同步更新。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue v-model 的行为,实现双向数据绑定
  2. 受控组件模式:使用 React 标准的受控组件实现
  3. 事件处理:自动处理输入事件和值更新
  4. 响应式集成:与 Vue 的响应式系统无缝集成

不同输入类型的 v-model

Vue 的 v-model 会根据输入元素的类型自动适配,VuReact 也保持了这种智能适配能力。

复选框

  • Vue 代码:
<input type="checkbox" v-model="checked" />
<input type="checkbox" value="vue" v-model="frameworks" />
  • VuReact 编译后 React 代码:
<input
  type="checkbox"
  checked={checked.value}
  onChecked={(e) => {
    checked.value = e.target.checked;
  }}
/>
<input
  type="checkbox"
  value="vue"
  checked={frameworks.value}
  onChange={(e) => {
    frameworks.value = e.target.checked;
  }}
/>

单选按钮

  • Vue 代码:
<input type="radio" value="male" v-model="gender" />
<input type="radio" value="female" v-model="gender" />
  • VuReact 编译后 React 代码:
<input
  type="radio"
  value="male"
  checked={gender.value === 'male'}
  onChange={() => { gender.value = 'male' }}
/>

<input
  type="radio"
  value="female"
  checked={gender.value === 'female'}
  onChange={() => { gender.value = 'female' }}
/>

下拉选择框

  • Vue 代码:
<select v-model="selected">
  <option value="a">选项A</option>
  <option value="b">选项B</option>
</select>
  • VuReact 编译后 React 代码:
<select
  value={selected.value}
  onChange={(e) => {
    selected.value = e.target.value;
  }}
>
  <option value="a">选项A</option>
  <option value="b">选项B</option>
</select>

v-model 修饰符

Vue 的 v-model 支持多种修饰符,用于控制数据更新的时机和格式。

.lazy 修饰符

  • Vue 代码:
<input v-model.lazy="message" />
  • VuReact 编译后 React 代码:
<input
  value={message.value}
  onBlur={(e) => {
    message.value = e.target.value;
  }}
/>

.number 修饰符

  • Vue 代码:
<input v-model.number="age" />
  • VuReact 编译后 React 代码:
<input
  value={age.value}
  onChange={(e) => {
    age.value = Number(e.target.value);
  }}
/>

.trim 修饰符

  • Vue 代码:
<input v-model.trim="username" />
  • VuReact 编译后 React 代码:
<input
  value={username.value}
  onChange={(e) => {
    username.value = e.target.value?.trim();
  }}
/>

修饰符组合

  • Vue 代码:
<input v-model.lazy.trim="search" />
  • VuReact 编译后 React 代码:
<input
  value={search.value}
  onBlur={(e) => {
    search.value = e.target.value?.trim();
  }}
/>

组件 v-model

Vue 3 对组件的 v-model 进行了重大改进,支持多个 v-model 绑定和自定义修饰符。

基础组件 v-model

  • Vue 代码:
<!-- 父组件 -->
<CustomInput v-model="inputValue" />

<!-- 子组件 CustomInput.vue -->
<script setup lang="ts">
  const props = defineProps(['modelValue']);
  const emits = defineEmits(['update:modelValue']);
</script>

<template>
  <input :value="props.modelValue" @input="(e) => emits('update:modelValue', e.target.value)" />
</template>
  • VuReact 编译后 React 代码:
// 父组件
<CustomInput
  modelValue={inputValue.value}
  onUpdateModelValue={(value) => {
    inputValue.value = value;
  }}
/>;

// 子组件 CustomInput.tsx
type ICustomInputProps = {
  modelValue?: any;
  onUpdateModelValue?: (...args: any[]) => any;
}

function CustomInput(props: ICustomInputProps) {
  return (
    <input value={props.modelValue} onChange={(e) => props.onUpdateModelValue?.(e.target.value)} />
  );
}

带参数的 v-model

  • Vue 代码:
<UserForm v-model:name="userName" v-model:email="userEmail" />
  • VuReact 编译后 React 代码:
<UserForm
  name={userName.value}
  onUpdateName={(value) => {
    userName.value = value;
  }}
  email={userEmail.value}
  onUpdateEmail={(value) => {
    userEmail.value = value;
  }}
/>

编译策略总结

VuReact 的 v-model 编译策略展示了完整的双向绑定转换能力

  1. 基础表单元素:将各种输入类型的 v-model 转换为对应的受控组件
  2. 修饰符支持:完整支持 .lazy.number.trim 等修饰符
  3. 组件 v-model:支持组件级别的双向绑定,包括多个 v-model 和自定义修饰符
  4. 事件映射:智能映射 Vue 事件到 React 事件(inputonChange 等)
  5. 类型安全:保持 TypeScript 类型定义的完整性

不同类型元素的编译映射

元素类型 Vue 事件 React 事件 值属性
input[type="text"] input onChange value
textarea input onChange value
input[type="checkbox"] change onChange checked
input[type="radio"] change onChange checked
select change onChange value

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写表单绑定逻辑。编译后的代码既保持了 Vue 的语义和便利性,又符合 React 的表单处理最佳实践,让迁移后的应用保持完整的表单交互能力。

🔗 相关资源


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

6.响应式系统比对:通过 Vue3 响应式库写 React 应用

作者 Cobyte
2026年4月20日 09:04

前言

鉴于 Vue3 已经把响应式库进行了独立,也就是 @vue/reactivity,既然 Mobx 也是一个响应式库都可以应用在 React 上,那么 @vue/reactivity 可不可以也应用在 React 上呢?很显然是可以的,社区里也有很多关于这么方面的实践。那么我们这里也提供一个参考 Mobx 实现的版本。

跟 Mobx 对比的话,@vue/reactivity 就相当于 mobx 库,所以我们只需要参考 mobx-react-lite 实现一个 vue-react-lite 即可。

实现 vue-react-lite

我们通过上一篇文章可以知道 Mobx 是通过 mobx-react-lite 实现与 React 进行链接的,其中最重要的函数就是 observer,那么我们也在 vue-react-lite 中实现一个 observer 函数。根据我们前篇所学的知识知道 observer 是一个高阶函数,所以我们初步把 observer 的基础架构搭建出来。

function observer(baseComponent) {
    return (props) => {
        return baseComponent(props)
    }
}

接下来我们知道 Mobx 中是通过 Reaction 这个订阅者中介来实现不同组件函数的代理的,而在 @vue/reactivity 中的跟 Reaction 相同角色的的则是 ReactiveEffect,那么我们就可以通过它来实现我们想要的功能。

代码实现如下:

import { useState, useRef } from "react"
import { ReactiveEffect  } from "@vue/reactivity"
function observer(baseComponent) {
    return (props) => {
        const [, setState] = useState()
        const admRef = useRef(null)
        if (!admRef.current) {
            admRef.current = new ReactiveEffect(() => {
                return baseComponent(props)
            }, () => {
                setState(Symbol())
            })
        }
        const effect = admRef.current
        return effect.run()
    }
}

那么我们就通过 ReactiveEffect 实现了一个跟 mobx-react-lite 中的 observer 一样的功能的函数。

如果大家对 Vue3 的 effect 函数熟悉的话,我们上述 observer 的实现过程跟 Vue3 的 effect 实现很类似的。我们可以回顾一下 Vue3 的 ReactiveEffect 类的功能,它本质是一个订阅者中介,跟 Vue2 的 Watcher 类是一样的角色。ReactiveEffect 的第一个参数就是具体的订阅者函数,而第二个参数则是一个叫 scheduler 的回调函数,在更新的时候如果存在 scheduler 回调函数则执行 scheduler 回调函数,否则执行第一个参数的函数。基于这个原理,我们就在 ReactiveEffect 的第二个参数中设置执行 React 的更新 setState(Symbol()),同时 ReactiveEffect 上存在一个 run 方法,需要通过手动执行进行初始化。

应用 vue-react-lite

那么我们上面通过 ReactiveEffect 实现了 observer 函数,这样我们就可以在 React 中应用 Vue3 的数据响应式库了。下面我们来测试一下:

import { reactive } from "@vue/reactivity";
import { observer } from "./vue-react-lite"

const proxy = reactive({ name: 'Cobyte', secondsPassed: 0 })

const TimerView = observer(({ proxy }) => <span>the content run in `@vue/reactivity` is "Seconds passed: {proxy.secondsPassed}"</span>)

function App() {
  return (
    <TimerView proxy={proxy}></TimerView>
  );
}

setInterval(() => {
  proxy.secondsPassed +=1
}, 1000)

export default App;

打印结果如下:

tutieshi_640x195_5s.gif

我们发现已经成功把 @vue/reactivity 库应用到 React 中了。

根据 Mobx 的启发实现 Vue 数据响应式的 OOP

我们知道 Mobx 的写法是更倾向 OOP 的,同时是严格遵守单向数据流,所以我们也可以在通过 Vue 响应式库提供的 shallowRef API 实现 OOP。

import { reactive, shallowRef } from "@vue/reactivity"
import { observer } from "./vue-react-lite"

class DataService {
  constructor(val) {
    this.r = shallowRef(val)
  }
  get count() {
    return this.r.value
  }
  setCount(val) {
    this.r.value = val
  }
}
const dataService = new DataService(0)
const TimerView = observer(({ proxy }) => <span>the content run in @vue/reactivity is "Seconds passed: {proxy.count}"</span>)

function App() {
  return (
    <TimerView proxy={dataService}></TimerView>
  );
}

setInterval(() => {
  dataService.setCount(Date.now())
}, 1000)

export default App;

但上述方式还是不能堵住别人可以通过直接修改对象的方式更改响应式的值,从而打破单向数据流的规则。

例如下面的例子:

setInterval(() => {
    dataService.r.value = Date.now()
}, 1000)

那么为了堵住这个漏洞,我们可以通过私有变量来解决:

class DataService {
  #r
  constructor(val) {
    this.#r = shallowRef(val)
  }
  get count() {
    return this.#r.value
  }
  setCount(val) {
    this.#r.value = val
  }
}
const dataService = new DataService(0)

这个时候我们就不能通过直接修改对象的方式更改响应式的值了。

setInterval(() => {
    dataService.#r.value = Date.now()
}, 1000)

我们上述这种方式比较适合基本数据类型的情况,如果是引用类型的话,就不太适用了。如果是引用类型我们不可能在上面写那么多属性访问器,我们可以像 Vue2 那样把所有的响应式数据代理到 Vue 的实例对象上,然后可以通过 this 进行访问。

修改如下:

import { shallowRef } from "@vue/reactivity";
class DataService {
  #r
  constructor(val) {
    this.#r = shallowRef(val)
    // 像 Vue2 一样把响应式数据代理到实例对象上
    return new Proxy(this, {
      get(target, key) {
        // 如果是响应式数据就返回响应式数据
        if (target.#r.value[key]) {
          return target.#r.value[key]
        } else {
          // 如果是自身的属性就返回自身属性,例如 setState
          return target[key]
        }
      },
      set(target, key, val) {
        throw new Error('请通过 setState 方法进行更新')
      }
    })
  }
  setState(val) {
    this.#r.value = val
  }
}

const dataService = new DataService({ name: 'Cobyte', date: '2024-03-22', now: { time: 123 } })
const TimerView = observer(({ proxy, now }) => <span>the content run in @vue/reactivity "author: {proxy.name}, the date is: {proxy.date} now is {proxy.now.time}"</span>)

function App() {
  return (
    <TimerView proxy={dataService} now={dataService.now}></TimerView>
  );
}

setInterval(() => {
  dataService.setState({ name: '掘金签约作者', date: '2024年3月22日', now: { time: Date.now() }})
}, 1000)

export default App;

我们通过把响应式数据代理到实例对象上,优化了引用类型的使用方式。

tutieshi_640x284_4s.gif

至此,我们受 Mobx 的启发实现了在 React 中使用 Vue3 的响应式数据库,同时跟 Mobx、Flux、Redux 一样实现单向数据流。不过我们目前采用的是最新的技术私有变量,这个方案目前兼容性并不好,但作为技术交流也可以给大家一个启发。

为什么 Vue 可以通过重新运行组件 render 函数进行更新?

我们在前篇文章通过相对比较简洁的代码实现了 Mobx 的核心原理,同时对比了同时响应式的 Vue 和 Mobx 的最大设计区别,在 Vue 中创建的响应式数据,是可以随意在任何地方通过普通属性访问器进行修改的,但 Mobx 中则不提倡这种可以随意修改 state 的方式,在 Mobx 中希望开发者通过 actions 来改变 state,本质是像 React 那样通过一个函数来修改 state,或者说是遵循 Flux 和 Redux 的单向数据流思想。同时 Mobx 中的订阅者中介 Reaction 和 Vue 中的订阅者中介实现则有比较大的区别,主要是因为 Mobx 主要的设计受 React 的影响,在更新的时候需要特别的设置,而不像 Vue 那样直接重新运行副作用函数就可以了,这个说到底也是因为 React 不是靠依赖追踪来实现响应式的缘故。

那么问题就来了,为什么 Vue 可以通过重新运行组件 render 函数进行更新,而 React 则不行?当然 React 在普通情况下,你在更新的时候是不知道哪个组件函数需要更新,但我们通过 Mobx 就可以实现了依赖收集,就可以知道更新的时候那些组件函数需要重新执行,但即便这样 React 也不能通过重新执行组件函数来实现更新,这是为什么呢?

一个组件要渲染到页面上需要哪些必备条件呢?我们先看看下面的一个 React 应用的渲染例子:

ReactDOM.render(App, document.getElementById("root")

那么从上述的 React 应用渲染的例子我们可以知道,一个组件渲染到页面上是一定要知道渲染到哪个元素容器中的,这一点无论是 React 还是 Vue 都是一样的。如果仅仅只是执行一个组件函数是不能实现渲染的,所以在实现 Mobx 的 Reaction 的时候,不能像 Vue 的订阅者中介那样实现。那么为什么在 Vue 中可以通过重新运行组件 render 函数进行更新呢,或者是直接重新运行组件函数进行更新呢?

这是因为在 Vue 中被收集到订阅者记录变量中的函数,并不是组件的 render 函数,而是一个高阶函数,在高阶函数内部才最后执行组件的 render 函数。我们这里以 Vue3 中的情况进分析,在 Vue3 中最后处理组件 render 函数的地方是在 setupRenderEffect 函数中,下面是 setupRenderEffect 的简洁实现代码结构。

function setupRenderEffect(instance, initialVNode, container, anchor, parentSusp) {
    const componentUpdateFn = () => {
        if (!instance.isMounted) {
            // 初始化走这里
            const subTree = (instance.subTree = renderComponentRoot(instance))
            // 通过 patch 函数进行挂载,第三个参数就要挂载的HTML容器
            patch(
                null,
                subTree,
                container, // 目标挂载点
                anchor,
                instance,
                parentSuspense,
                isSVG
            )
            instance.isMounted = true
        } else {
            // 更新走这里
            // 重新执行组件 render 函数
            const nextTree = renderComponentRoot(instance)
            // 上一次的生成的虚拟DOM为旧的虚拟DOM
            const prevTree = instance.subTree
            instance.subTree = nextTree
            // 更新也是通过 patch 函数进行挂载,也同样需要提供挂载的HTML容器,也就是第三个参数
            patch(
                prevTree,
                nextTree,
                // parent may have changed if it's in a teleport
                hostParentNode(prevTree.el!)!, // 更新的时候也需要提供渲染的目标挂载HTML元素
                // anchor may have changed if it's in a fragment
                getNextHostNode(prevTree),
                instance,
                parentSuspense,
                isSVG
            )
        }
    }
    // 从这我们可以看到被收集的依赖并不是组件的 render 函数,而是一个包装函数 componentUpdateFn
    const effect = (instance.effect = new ReactiveEffect(
      componentUpdateFn,
      () => queueJob(update), // 调度函数 scheduler,最后还是执行 update 方法
      instance.scope // track it in component's effect scope
    ))
    // 初始化的时候需要执行 run 方法
    const update = (instance.update = () => effect.run())
    // 执行
    update()
}

我们从上面的 Vue3 的 setupRenderEffect 的简洁实现代码中可以看到在 Vue 中所谓收集依赖的依赖并不是组件的渲染函数,而是一个包装函数,在包装函数中在初始化和更新阶段都是通过执行组件的 render 函数获得组件的虚拟DOM,然后再通过 patch 函数进行渲染挂载到具体的元素节点下。而在 Vue 的内部中是可以获取到具体需要渲染挂载的元素节点的,而我们在 React 的应用层首先是无法通过组件函数获得需要挂载的元素节点的,其次 React 的更新流程本质上就跟 Vue 这类型通过依赖收集的数据响应式框架不一样。

总结

本文受 Mobx 启发,利用 @vue/reactivity 的 ReactiveEffect 实现了类似 mobx-react-lite 的 observer 高阶函数,成功将 Vue 响应式库集成到 React 中,实现了单向数据流和依赖追踪。同时,通过私有变量和 Proxy 代理优化了 OOP 风格下的响应式数据访问,避免了直接修改状态。最后,从底层机制解释了 Vue 能够直接重新运行组件 render 函数更新,而 React 不能的根本原因:Vue 的依赖收集针对的是包含 patch 挂载逻辑的包装函数,可获取具体渲染容器;React 的更新流程不依赖此类追踪,且组件函数层面无法获取挂载节点。这揭示了两种框架在设计哲学与实现机制上的本质差异。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

昨天以前首页

PDF无限制预览!Jit-Viewer V1.5.0开源文档预览神器正式发布

作者 徐小夕
2026年4月19日 21:30

下面和大家分享一下最近我们开源的文档预览SDK——Jit-Viewer,昨天刚发布 1.5.0 版本,和大家分享一下最新的功能更新。

图片

如果你是开发文档预览功能的开发者,一定经历过这种崩溃:txt文档预览乱码、PDF只能看前5页、大文件加载卡顿,代码文件预览毫无章法。

为了帮大家解决这些真实的使用痛点,提升开发体验,我们这段时间优化了 Jit-Viewer 开源文档预览SDK。上周刚帮不少开发者解决了PDF预览受限的问题——终于能完整查看所有PDF文档了。

今天,Jit-Viewer V1.5.0 正式发布,4大核心更新,让文档预览开发更高效、更省心。

文档地址:jitword.com/jit-viewer.…

开源地址:github.com/jitOffice/j…

这次更新,我们重点带来了以下功能:

1. 支持txt多编码格式预览兼容  

图片

之前很多开发者反馈,txt文档预览经常出现乱码,尤其是非UTF-8编码的文件,调试起来特别麻烦,浪费大量时间。

这次更新,我们优化了txt文档解析逻辑,全面兼容ANSI、UTF-8、GBK等多种常见编码格式,不管你导入的txt文件是什么编码,都能正常显示,再也不用手动转换编码、反复调试,帮大家节省更多开发时间。

2. 支持PDF文件完整预览,告别5页限制  

图片

这是本次更新最受期待的功能!之前版本的Jit-Viewer,PDF文件只能预览前5页,对于需要完整预览长文档的开发者来说,实用性大打折扣,很多场景下根本无法满足需求。

图片

这次我们彻底突破了这个限制,底层重构了PDF渲染能力,支持PDF文件全页完整预览,不管是几页的PDF,都能一次性加载完成,搭配原有缩放、翻页功能,完美适配各类PDF预览场景,再也不用为了查看完整PDF额外集成其他工具。

3. 优化SDK预览性能,搭载高性能文件预览引擎  

我们知道,开发者在集成文档预览SDK时,最在意的就是性能——大文件加载慢、切换页面卡顿,都会影响产品体验。这次更新,我们重新设计了文件预览引擎,优化了文件加载、渲染的全流程,大幅提升了预览速度和稳定性,即使是大文档,也能快速加载、流畅切换,不会出现卡顿、崩溃的情况,同时降低了资源占用,让你的应用运行更流畅。

4. 支持代码文件高亮预览  

针对开发类场景,我们新增了代码文件高亮预览功能。不管是Java、Python、JavaScript,还是HTML、CSS等常见编程语言,导入后都能自动识别语言类型,实现语法高亮,代码结构清晰可见,再也不用看着杂乱无章的纯文本代码发愁,尤其适合需要在应用中集成代码预览功能的开发者,大幅提升使用体验。

市面上很多商业文档预览SDK,只解决“能预览”的问题,而 Jit-Viewer 想解决的是“好用、省心、适配多场景”。

这次V1.5.0的更新,本质上是在“轻量高效”的核心定位上,进一步突破场景限制、优化使用体验——让复杂的文档预览开发,变得更简单,让不同需求的开发者,都能快速集成、高效使用,不用再为各类预览问题额外消耗精力。

简单来说,Jit-Viewer 是一个纯前端的文件预览引擎。不需要后端转换服务,不需要安装任何插件,几行代码就能让浏览器具备"专业软件"的预览能力。图片目前 jit-viewer 已经支持了:

  • docx / ppt / pdf / excel
  • csv
  • html
  • markdown
  • txt
  • 代码文件(如js,css, java, go, c#, php, ts等)
  • 音频 / 视频
  • CAD
  • 3D模型
  • OFD(国产格式)

同时我们还在持续迭代优化,帮助大家仅通过几行代码,就能让自己的web系统轻松拥有多种文档预览的能力。

github:github.com/jitOffice/j…

Vue v-bind 转 React:VuReact 怎么处理?

作者 Ruihong
2026年4月19日 15:30

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

前置约定

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

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

编译对照

v-bind / ::基础属性绑定

v-bind(简写为 :)是 Vue 中用于动态绑定 HTML 属性、组件 propsclassstyle 的指令。

  • Vue 代码:
<img :src="imageUrl" :class="imageCls" />
  • VuReact 编译后 React 代码:
<img src={imageUrl} className={imageCls} />

从示例可以看到:Vue 的 :src:class 指令被编译为 React 的标准属性语法。VuReact 采用 属性直接编译策略,将模板指令转换为 React 的 JSX 属性,完全保持 Vue 的属性绑定语义——动态地将变量值绑定到元素属性。


class 和 style 的动态绑定

Vue 支持复杂的 classstyle 绑定表达式,VuReact 通过运行时辅助函数处理这些复杂场景。

动态 class 绑定

  • Vue 代码:
<div :class="['card', active && 'is-active', error ? 'has-error' : '']" />
  • VuReact 编译后 React 代码:
import { dir } from '@vureact/runtime-core';

<div className={dir.cls(['card', active && 'is-active', error ? 'has-error' : ''])} />

动态 style 绑定

  • Vue 代码:
<div :style="{ color: textColor, fontSize: size + 'px', 'background-color': bgColor }" />
  • VuReact 编译后 React 代码:
import { dir } from '@vureact/runtime-core';

<div style={dir.style({ color: textColor, fontSize: size + 'px', backgroundColor: bgColor })} />

从示例可以看到:复杂的 class 和 style 绑定被编译为使用 dir.cls()dir.style() 辅助函数。VuReact 采用 复杂绑定运行时处理策略,将 Vue 的复杂表达式转换为运行时函数调用,完全保持 Vue 的动态样式语义

运行时辅助函数的工作原理

  1. dir.cls()

    • 处理数组、对象、字符串等多种 class 格式
    • 自动过滤 falsy 值(false、null、undefined、'')
    • 合并重复的 class 名称
    • 生成最终的 className 字符串
  2. dir.style()

    • 处理对象格式的样式
    • 自动转换 kebab-case 为 camelCase(background-colorbackgroundColor
    • 处理带单位的数值(自动添加 px 等)
    • 生成 React 兼容的 style 对象

编译策略详解

// Vue: :class="{ active: isActive, 'text-danger': hasError }"
// React: className={dir.cls({ active: isActive, 'text-danger': hasError })}

// Vue: :class="[isActive ? 'active' : '', errorClass]"
// React: className={dir.cls([isActive ? 'active' : '', errorClass])}

// Vue: :style="style"
// React: style={dir.style(style)}

无参数 v-bind:对象展开

Vue 支持无参数的 v-bind,用于将整个对象展开为元素的属性。

  • Vue 代码:
<Comp v-bind="props">点击</Comp>
  • VuReact 编译后 React 代码:
import { dir } from '@vureact/runtime-core';

<Comp {...dir.keyless(props)}>点击</Comp>

从示例可以看到:无参数的 v-bind 被编译为使用 dir.keyless() 辅助函数和对象展开语法。VuReact 采用 对象展开编译策略,将 Vue 的对象绑定转换为 React 的对象展开,完全保持 Vue 的对象属性绑定语义

dir.keyless() 辅助函数的作用

  1. 属性冲突处理:处理对象属性与已有属性的冲突
  2. 特殊属性转换:自动转换 classclassNameforhtmlFor
  3. 样式对象处理:识别并正确处理 style 对象
  4. 事件处理:识别并转换事件属性(@clickonClick

布尔属性绑定

Vue 对布尔属性有特殊处理,VuReact 也保持了这种语义。

  • Vue 代码:
<button :disabled="isLoading">提交</button>
<input :checked="isChecked" />
<option :selected="isSelected">选项</option>
  • VuReact 编译后 React 代码:
<button disabled={isLoading}>提交</button>
<input checked={isChecked} />
<option selected={isSelected}>选项</option>

动态属性名绑定

Vue 支持使用动态表达式作为属性名,但不建议这么做,不过 VuReact 也能正确处理。

  • Vue 代码:
<div :[dynamicAttr]="value">内容</div>
  • VuReact 编译后 React 代码:
<div {...{ [dynamicAttr]: value }}>内容</div>

编译策略

  1. 计算属性名:使用对象计算属性语法 { [key]: value }
  2. 对象展开:通过对象展开语法应用到元素上

编译策略总结

VuReact 的 v-bind 编译策略展示了完整的属性绑定转换能力

  1. 基础属性映射:将 Vue 属性绑定精确映射到 React JSX 属性
  2. 复杂样式处理:通过运行时辅助函数支持复杂的 class 和 style 绑定
  3. 对象展开支持:完整支持无参数 v-bind 的对象展开语义
  4. 布尔属性处理:正确处理布尔属性的特殊行为
  5. 动态属性名:支持动态表达式作为属性名
  6. 组件 props 转换:正确处理组件间的 props 传递

性能优化策略

  1. 按需导入:只有使用复杂绑定时才导入 dir 辅助函数
  2. 缓存优化:智能缓存相同表达式的处理结果
  3. 编译期优化:对于简单表达式,直接生成内联逻辑

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写属性绑定逻辑。编译后的代码既保持了 Vue 的语义和功能,又符合 React 的属性处理最佳实践,让迁移后的应用保持完整的 UI 表现能力。

🔗 相关资源


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

Vue插槽用法全解析(Vue2+Vue3适配)| 组件复用必备

2026年4月19日 10:49

Vue插槽(Slot)是组件间内容分发的核心机制,用于解决“父组件向子组件传递模板片段”的需求,实现组件的灵活复用与结构解耦。简单来说,插槽就是子组件中预留的“内容占位符”,占位符的具体内容由父组件决定,子组件仅负责固定布局和逻辑,让组件既能保持统一风格,又能灵活适配不同场景。

本文将详细讲解Vue插槽的核心概念、3种核心用法(默认插槽、具名插槽、作用域插槽),明确Vue2与Vue3的语法差异,提供可直接复制的实战示例,同时梳理常见问题,兼顾新手入门与实战开发需求。

一、插槽核心基础(必懂)

插槽的核心逻辑可类比为“函数传参”:父组件向子组件传递“模板内容”(相当于函数参数),子组件通过<slot>标签(相当于函数接收参数的位置)接收并渲染内容,最终实现“子组件定结构、父组件定内容”的复用效果。

核心要点:

  • 插槽内容可是任意合法模板(文本、标签、组件等),不局限于简单文本;
  • 插槽内容的作用域:插槽内容定义在父组件,因此只能访问父组件的数据,无法直接访问子组件的数据(需用作用域插槽解决);
  • Vue2与Vue3插槽核心功能一致,仅在具名插槽、作用域插槽的语法上有差异,下文将分别标注适配版本。

二、Vue插槽3种核心用法(实战重点)

按“基础到复杂”排序,默认插槽适用于简单内容分发,具名插槽适用于多区域内容分发,作用域插槽适用于子组件向父组件传递数据后,父组件自定义渲染内容。

1. 默认插槽(匿名插槽)—— 最简单的内容分发

默认插槽是最基础的插槽形式,子组件中仅定义一个无名称的<slot>标签,父组件传入的所有未命名内容,都会自动填充到这个插槽中。Vue2与Vue3用法基本一致。

实战示例(Vue2+Vue3通用)

// 1. 子组件(SlotDefault.vue)—— 定义默认插槽
<template>
  <div class="slot-container">
    <!-- 插槽出口:未命名,即为默认插槽 --&gt;
    &lt;slot&gt;
      <!-- 后备内容(默认内容):父组件未传入内容时显示 -->
      这是默认插槽的后备内容(父组件未传内容时显示)
    </slot>
  </div>
</template>

<style scoped>
.slot-container {
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
}
</style>

// 2. 父组件 —— 使用默认插槽
<template>
  <div>
    <h3>默认插槽用法</h3>
    <!-- 方式1:传入简单文本 -->
    <SlotDefault>父组件传入的简单文本内容</SlotDefault>

    <!-- 方式2:传入复杂内容(标签+组件) -->
    <SlotDefault>
      <span style="color: #42b983;">父组件传入的带样式文本</span>
      <button>父组件传入的按钮</button>
      <!-- 传入其他组件 -->
      <OtherComponent />
    </SlotDefault>

    <!-- 方式3:不传入内容(显示子组件的后备内容) -->
    <SlotDefault />
  </div>
</template>

<script setup>
// Vue3 需引入子组件
import SlotDefault from './SlotDefault.vue'
import OtherComponent from './OtherComponent.vue'
</script>

// Vue2 脚本写法(父组件)
<script>
import SlotDefault from './SlotDefault.vue'
import OtherComponent from './OtherComponent.vue'
export default {
  components: { SlotDefault, OtherComponent }
}
</script>

说明:子组件<slot>标签内的内容为“后备内容”,仅当父组件未传入任何插槽内容时才会显示,传入内容后会自动替换后备内容。

2. 具名插槽 —— 多区域内容精准分发

当子组件需要多个不同的内容占位区域(如页面布局的头部、主体、底部)时,默认插槽无法满足需求,此时需使用具名插槽。通过给<slot>标签添加name属性命名,父组件可精准将内容分发到对应插槽,Vue2与Vue3语法差异较大。

实战示例(Vue2 vs Vue3)

// 1. 子组件(SlotNamed.vue)—— 定义具名插槽(Vue2+Vue3通用)
<template>
  <div class="layout">
    <!-- 头部插槽:name="header" -->
    <slot name="header">默认头部</slot>
    
    <!-- 主体插槽:name="main" -->
    <slot name="main">默认主体</slot>
    
    <!-- 底部插槽:name="footer" -->
    <slot name="footer">默认底部</slot>
  </div>
</template>

<style scoped>
.layout {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.layout > div { padding: 10px; border: 1px solid #eee; }
</style>

// 2. 父组件使用 —— Vue2 写法
<template>
  <SlotNamed>
    <!-- 用 slot 属性指定插槽名称,已废弃(Vue2.6+推荐用v-slot) -->
    <div slot="header">Vue2 头部内容(自定义)</div>
    <div slot="main">Vue2 主体内容(自定义)</div>
    <div slot="footer">Vue2 底部内容(自定义)</div>
    
    <!-- Vue2.6+ 推荐写法:template + v-slot -->
    <template v-slot:header>
      <div>Vue2.6+ 头部内容(自定义)</div>
    </template>
    <template v-slot:main>
      <div>Vue2.6+ 主体内容(自定义)</div>
    </template>
    <template v-slot:footer>
      <div>Vue2.6+ 底部内容(自定义)</div>
    </template>
  </SlotNamed>
</template>

// 3. 父组件使用 —— Vue3 写法(核心:废弃slot属性,统一用v-slot)
<template>
  <SlotNamed>
    <!-- 语法:template + v-slot:插槽名,可简写为 #插槽名 -->
    <template #header>
      <div>Vue3 头部内容(自定义)</div>
    </template>
    <template #main>
      <div>Vue3 主体内容(自定义)</div>
    </template>
    <template #footer>
      <div>Vue3 底部内容(自定义)</div>
    </template>
    
    <!-- 未命名内容,自动分发到默认插槽(若子组件有默认插槽) -->
    <div>默认插槽内容(未命名)</div>
  </SlotNamed>
</template>

<script setup>
import SlotNamed from './SlotNamed.vue'
</script>

关键差异:Vue2支持slot属性和v-slot两种写法,Vue3仅支持v-slot(简写为#),且必须配合<template>标签使用(默认插槽可省略<template>)。

3. 作用域插槽 —— 子传父数据+父自定义渲染

默认插槽和具名插槽,只能实现“父组件向子组件传递内容”,无法让插槽内容访问子组件的数据。作用域插槽解决了这一问题:子组件通过v-bind将自身数据绑定到<slot>标签上(称为“插槽属性”),父组件接收这些数据后,可根据子组件数据自定义插槽内容的渲染方式。

核心场景:子组件有数据(如列表数据),但渲染样式由父组件决定(如列表项可渲染为文字、按钮、卡片)。

实战示例(Vue2 vs Vue3)

// 1. 子组件(SlotScoped.vue)—— 绑定子组件数据(Vue2+Vue3通用)
<template>
  <div class="list">
    <!-- 子组件数据:列表数组 -->
    <div v-for="(item, index) in list" :key="index">
      <!-- 绑定子组件数据到插槽::item="item" :index="index" -->
      <slot :item="item" :index="index"&gt;
        <!-- 后备内容父组件未自定义渲染时显示 -->
        {{ item.name }}(默认渲染)
      </slot>
    </div>
  </div>
</template>

<script setup>
// Vue3 脚本
import { ref } from 'vue'
const list = ref([
  { id: 1, name: 'Vue基础', type: '前端' },
  { id: 2, name: '插槽用法', type: '前端' },
  { id: 3, name: '路由跳转', type: '前端' }
])
</script>

// Vue2 脚本(子组件)
<script>
export default {
  data() {
    return {
      list: [
        { id: 1, name: 'Vue基础', type: '前端' },
        { id: 2, name: '插槽用法', type: '前端' },
        { id: 3, name: '路由跳转', type: '前端' }
      ]
    }
  }
}
</script>

// 2. 父组件使用 —— Vue2 写法
<template>
  <SlotScoped>
    <!-- 方式1:slot-scope 接收插槽属性(Vue2.6-) -->
    <div slot-scope="slotProps">
      索引:{{ slotProps.index }} | 名称:{{ slotProps.item.name }}
    </div>
    
    <!-- 方式2:v-slot 接收(Vue2.6+ 推荐,可解构) -->
    <template v-slot:default="slotProps">
      <!-- 解构简化:直接提取item和index -->
      <template v-slot:default="{ item, index }">
        索引{{ index + 1 }}:{{ item.name }}({{ item.type }})
      </template>
    </template>
  </SlotScoped>
</template>

// 3. 父组件使用 —— Vue3 写法(核心:废弃slot-scope,统一用v-slot接收)
<template>
  <SlotScoped>
    <!-- 方式1:完整写法,接收所有插槽属性 -->
    <template #default="slotProps">
      索引:{{ slotProps.index }} | 名称:{{ slotProps.item.name }}
    </template>
    
    <!-- 方式2:解构简化(推荐),可设置默认值避免报错 -->
    <template #default="{ item = { name: '默认名称' }, index = 0 }">
      索引{{ index + 1 }}:{{ item.name }}({{ item.type }})
      <button @click="handleClick(item.id)">查看详情</button>
    </template>
  </SlotScoped>
</template>

<script setup>
import SlotScoped from './SlotScoped.vue'
const handleClick = (id) => {
  console.log('查看ID为', id, '的详情')
}
</script>

关键差异:Vue2用slot-scopev-slot接收插槽属性,Vue3仅用v-slot接收,且支持ES6解构赋值,可设置默认值提升组件健壮性。

三、Vue2与Vue3插槽语法差异汇总

为方便快速区分和迁移,整理核心差异如下,重点关注Vue3的语法规范:

插槽类型 Vue2 语法 Vue3 语法 核心差异
默认插槽 直接在子组件标签内写内容;支持

Vue v-on 在 React 中 VuReact 会如何实现?

作者 Ruihong
2026年4月19日 10:34

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

前置约定

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

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

编译对照

v-on / @:基础事件绑定

v-on(简写为 @)是 Vue 中用于绑定事件监听器的指令,用于响应用户交互。

  • Vue 代码:
<button @click="increment">+1</button>
  • VuReact 编译后 React 代码:
<button onClick={increment}>+1</button>

从示例可以看到:Vue 的 @click 指令被编译为 React 的 onClick 属性。VuReact 采用 事件属性编译策略,将模板指令转换为 React 的标准事件属性,完全保持 Vue 的事件绑定语义——当按钮被点击时,调用 increment 函数。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue v-on 的行为,实现事件监听功能
  2. 命名转换:Vue 的 @click 转换为 React 的 onClick(camelCase 命名)
  3. 函数传递:直接传递函数引用,保持事件处理逻辑
  4. React 原生支持:使用 React 标准的事件系统,无需额外适配

带事件修饰符:高级事件处理

Vue 的事件系统支持丰富的修饰符,用于控制事件行为。VuReact 通过运行时辅助函数处理这些修饰符。

  • Vue 代码:
<button @click.stop.prevent="submit">Submit</button>
  • VuReact 编译后 React 代码:
import { dir } from '@vureact/runtime-core';

<button onClick={dir.on('click.stop.prevent', submit)}>Submit</button>

从示例可以看到:带修饰符的 Vue 事件被编译为使用 dir.on() 辅助函数。VuReact 采用 修饰符运行时处理策略,将复杂的修饰符组合转换为运行时函数调用,完全保持 Vue 的事件修饰符语义

编译策略详解

// Vue: @click.stop.prevent="handler"
// React: onClick={dir.on('click.stop.prevent', handler)}

// Vue: @keyup.enter="search"
// React: onKeyUp={dir.on('keyup.enter', search)}

// Vue: @click.capture="captureHandler"
// React: onClickCapture={dir.on('click.capture', captureHandler)}

运行时辅助函数 dir.on() 的工作原理

  1. 解析修饰符:解析事件名称和修饰符字符串
  2. 创建包装函数:根据修饰符创建事件处理包装函数
  3. 应用修饰符逻辑:在包装函数中实现修饰符对应的行为
  4. 调用原始处理器:最终调用开发者提供的事件处理函数

内联事件处理与参数传递

Vue 支持在模板中直接编写内联事件处理逻辑,VuReact 也能正确处理。

  • Vue 代码:
<button @click="count++">增加</button>
<button @click="sayHello('world')">打招呼</button>
<button @click="handleEvent($event, 'custom')">带事件对象</button>
  • VuReact 编译后 React 代码:
<button onClick={() => count.value++}>增加</button>
<button onClick={() => sayHello('world')}>打招呼</button>
<button onClick={(event) => handleEvent(event, 'custom')}>带事件对象</button>

编译策略

  1. 表达式转换:将 Vue 模板表达式转换为 JSX 箭头函数
  2. 事件对象处理:Vue 的 $event 转换为 React 的事件参数
  3. 参数传递:保持函数调用的参数顺序和值
  4. 响应式更新:自动处理 .value 访问(对于 ref/computed 等变量)

defineEmits 事件与组件通信

对于组件自定义事件,VuReact 也有相应的编译策略。

  • Vue 代码:
<!-- 父组件 -->
<Child @custom-event="handleCustom" />

<!-- 子组件 Child.vue -->
<template>
  <button @click="emits('custom-event', data)">触发事件</button>
</template>

<script setup>
const emits = defineEmits(['custom-event']);
</script>
  • VuReact 编译后 React 代码:
// 父组件使用
<Child onCustomEvent={handleCustom} />;

// 子组件 Child.jsx
function Child(props) {
  return <button onClick={() => props.onCustomEvent?.(data)}>触发事件</button>;
}

编译规则

  1. 事件名转换kebab-case 转换为 camelCasecustom-eventonCustomEvent
  2. emit 调用转换$emit() 转换为 props 回调调用
  3. 可选链保护:添加 ?. 可选链操作符,避免未定义错误
  4. 类型安全:保持 TypeScript 类型定义的一致性

编译策略总结

VuReact 的事件编译策略展示了完整的事件系统转换能力

  1. 基础事件映射:将 Vue 事件指令精确映射到 React 事件属性
  2. 修饰符支持:通过运行时辅助函数完整支持 Vue 事件修饰符
  3. 内联处理:正确处理模板中的内联事件表达式
  4. 自定义事件:支持组件间的自定义事件通信
  5. 类型安全:保持 TypeScript 类型定义的完整性

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写事件处理逻辑。编译后的代码既保持了 Vue 的语义和功能,又符合 React 的事件处理最佳实践,让迁移后的应用保持完整的交互能力。

🔗 相关资源


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

❌
❌