Vue核心特性06,动态组件与keep-alive:解锁组件切换与状态缓存的核心逻辑

2025博客之星年度评选已开启 10w+人浏览 1.9k人参与

在Vue开发中,组件切换是高频需求——小到标签页切换、表单步骤跳转,大到复杂页面的视图切换。如果单纯通过v-if/v-else手动控制组件显隐,不仅代码冗余,还会面临一个关键问题:组件切换时状态丢失。比如,用户在表单步骤1填写的内容,切换到步骤2后再返回,之前的输入就没了。这时候,动态组件keep-alive就成了绝佳搭档,前者实现灵活的组件切换,后者负责缓存组件状态。今天就带大家吃透这对组合的核心逻辑与实战用法。

一、动态组件:让组件“动”起来的基础

动态组件的核心作用是:通过一个“占位符”,根据数据变化动态渲染不同的组件,无需手动编写多个v-if/v-else判断。Vue为我们提供了内置的标签,配合is属性即可实现。

1. 基本用法:3步实现组件切换

假设我们有两个组件:Home(首页)和About(关于页),需要实现点击按钮切换显示。

第一步:定义需要切换的组件(局部/全局注册均可)


<script setup>
// 导入组件
import Home from './Home.vue'
import About from './About.vue'
</script>

第二步:用标签作为占位符,通过is属性绑定“当前要渲染的组件”


<template>
  &lt;div&gt;
    <!-- 切换按钮 -->
    <button @click="currentComponent = 'Home'">首页</button>
    <button @click="currentComponent = 'About'"&gt;关于页&lt;/button&gt;
    
    <!-- 动态组件占位符:is绑定组件名/组件实例 -->
    <component :is="currentComponent"></component>
  </div>
</template>

第三步:定义响应式数据,控制当前渲染的组件


<script setup>
import { ref } from 'vue'
// 响应式数据:存储当前要渲染的组件名
const currentComponent = ref('Home')
</script>

这里要注意:is属性的值可以是“组件名字符串”(需确保组件已注册),也可以是“组件实例对象”(直接导入后赋值即可)。比如将currentComponent初始化为ref(Home),效果完全一致。

2. 动态组件的“痛点”:切换时组件重新创建与销毁

看似完美的切换逻辑,藏着一个默认行为:每次切换组件时,前一个组件会被销毁(触发unmounted钩子),新组件会被重新创建(触发mounted钩子)

我们可以在Home组件中添加生命周期钩子验证:


<script setup>
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
  console.log('Home组件创建')
})
onUnmounted(() => {
  console.log('Home组件销毁')
})
</script>

点击切换到About再切回Home,控制台会输出:Home组件销毁 → About组件创建 → Home组件创建。这就意味着,组件内的状态(比如输入框值、滚动位置)会在切换时丢失——这正是我们开头提到的问题。

二、keep-alive:组件状态的“缓存容器”

为了解决动态组件切换时的状态丢失问题,Vue提供了内置的组件。它的核心作用是:缓存被包裹的组件实例,避免组件在切换时重复创建和销毁。被缓存的组件,其状态会被保留,再次渲染时直接复用之前的实例。

1. 基本用法:用keep-alive包裹动态组件

只需将标签包裹在内部,就能开启缓存:


<template>
  <div>
    <button @click="currentComponent = 'Home'">首页</button>
    <button @click="currentComponent = 'About'">关于页</button>
    
    <!-- keep-alive包裹动态组件,开启缓存 -->
    <keep-alive>
      <component :is="currentComponent"></component>
    </keep-alive>
  </div>
</template>

此时再切换组件,会发现Home组件的onUnmounted钩子不再触发——组件没有被销毁,而是被缓存起来了。再次切回Home时,也不会触发onMounted,而是直接复用缓存的实例。

2. keep-alive的核心属性:精准控制缓存范围

默认情况下,会缓存所有被包裹的组件。如果需要精准控制“哪些组件要缓存”“哪些不要”,可以通过两个核心属性实现:

(1)include:指定需要缓存的组件(白名单)

值可以是:组件名字符串(多个用逗号分隔)、正则表达式、数组。只有组件名匹配的组件才会被缓存。


<!-- 只缓存Home组件 -->
<keep-alive include="Home">
  <component :is="currentComponent"></component>
</keep-alive>

<!-- 缓存Home和About组件(逗号分隔) -->
<keep-alive include="Home,About">...</keep-alive>

<!-- 正则表达式(需用v-bind) -->
<keep-alive :include="/Home|About/">...</keep-alive>

<!-- 数组(需用v-bind) -->
<keep-alive :include="['Home','About']">...</keep-alive>
(2)exclude:指定不需要缓存的组件(黑名单)

用法和include完全一致,只是逻辑相反——匹配的组件不会被缓存。


<!-- 不缓存About组件,其他组件缓存 -->
<keep-alive exclude="About">
  <component :is="currentComponent"></component>
</keep-alive>
(3)max:限制缓存组件的最大数量

当缓存的组件数量超过max时,Vue会自动销毁“最久未使用”的组件实例,避免内存占用过多。


<!-- 最多缓存2个组件 -->
<keep-alive max="2">
  <component :is="currentComponent"></component>
</keep-alive>

3. 缓存组件的生命周期:activated与deactivated

被keep-alive缓存的组件,不会触发mounted和unmounted(因为组件没有被创建/销毁)。为了让我们能感知组件的“进入”和“离开”,Vue提供了两个专属生命周期钩子:

  • activated:组件被激活(从缓存中取出并渲染)时触发

  • deactivated:组件被停用时(切换离开,进入缓存)触发

修改Home组件的钩子:


<script setup>
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
  console.log('Home组件被激活(显示)')
})
onDeactivated(() => {
  console.log('Home组件被停用(隐藏,进入缓存)')
})
</script>

此时切换组件,控制台会输出:Home组件被停用 → About组件被激活(首次创建时会触发mounted)→ 切回时Home组件被激活。通过这两个钩子,我们可以在组件显示/隐藏时执行特定逻辑(比如刷新数据、重置滚动位置等)。

三、实战场景:动态组件+keep-alive的典型用法

1. 标签页切换(保留表单输入状态)

比如用户在“个人信息”标签页填写了姓名,切换到“账号安全”标签页后再返回,姓名输入框的值依然保留。核心代码:


<template>
  <div class="tabs">
    <div class="tab-buttons">
      <button 
        v-for="tab in tabs" 
        :key="tab.name"
        @click="activeTab = tab.name"
        :class="{ active: activeTab === tab.name }"
      >{{ tab.label }}</button>
    </div>
    <keep-alive>
      <component :is="activeTab"></component>
    </keep-alive>
  </div>
</template>

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

const activeTab = ref('PersonalInfo')
const tabs = [
  { name: 'PersonalInfo', label: '个人信息' },
  { name: 'AccountSecurity', label: '账号安全' }
]
</script>

2. 列表页→详情页:保留列表滚动位置

用户在列表页滚动到第20条数据,点击进入详情页,返回列表页时依然停留在第20条位置。核心思路:用keep-alive缓存列表页组件,在activated钩子中恢复滚动位置,deactivated钩子中保存滚动位置。


<!-- 列表页组件 List.vue -->
<script setup>
import { ref, onActivated, onDeactivated } from 'vue'

// 保存滚动位置
const scrollTop = ref(0)
const listContainer = ref(null) // 列表容器DOM引用

// 组件被激活时,恢复滚动位置
onActivated(() => {
  if (listContainer.value) {
    listContainer.value.scrollTop = scrollTop.value
  }
})

// 组件被停用时,保存当前滚动位置
onDeactivated(() => {
  if (listContainer.value) {
    scrollTop.value = listContainer.value.scrollTop
  }
})
</script>

<template>
  <div class="list-container" ref="listContainer">
    <div class="list-item" v-for="item in 100" :key="item">{{ item }}</div>
  </div>
</template>

然后在路由切换时(假设用Vue Router),用keep-alive包裹路由组件即可:


<!-- App.vue -->
<template>
  <router-view v-slot="{ Component }">
    <keep-alive include="List">
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>

四、注意事项:避免踩坑的关键细节

  1. keep-alive只缓存组件实例,不缓存DOM结构:组件的DOM会被卸载,但实例(数据、状态)会被保留。再次激活时,会基于缓存的实例重新渲染DOM。

  2. props传递正常生效:动态组件通过传递的props,在缓存后依然会正常更新。比如,userId变化时,缓存的组件也能接收到新值。

  3. 避免过度缓存:如果组件包含大量DOM或复杂数据,过度缓存会占用过多内存。可以通过max属性限制缓存数量,或用exclude排除不需要缓存的组件。

  4. Vue3与Vue2的差异:Vue3中,只能包裹单个根组件(动态组件本身是单个根节点);Vue2中可以包裹多个组件,但通常还是配合动态组件使用。另外,Vue3的setup语法中,生命周期钩子需要手动导入,而Vue2是选项式API直接定义。

五、总结:核心逻辑梳理

动态组件与keep-alive是Vue中“组件切换与状态缓存”的黄金组合,核心逻辑可以总结为:

动态组件():实现“根据数据动态渲染不同组件”,简化切换逻辑;
keep-alive:包裹动态组件,实现“组件实例缓存”,避免切换时状态丢失;
activated/deactivated:缓存组件的专属生命周期,用于处理组件激活/停用后的逻辑。

无论是标签页、步骤条,还是列表与详情页的切换,只要需要保留组件状态,就可以用这对组合来解决。掌握它们的用法,能让你的Vue项目更灵活、用户体验更流畅~

將以下app.vue改成可以自動偵測手機使用的響應式頁面: <template> <div class="app-container"> <!-- 认证状态界面 --> <div v-if="!isAuthenticated" class="auth-container"> <div class="auth-switcher"> <button @click="authView = 'login'" :class="{ active: authView === 'login' }">登入</button> <button @click="authView = 'register'" :class="{ active: authView === 'register' }">註冊</button> </div> <component :is="authView" @login-success="handleLoginSuccess($event)" @register-success="authView = 'login'" class="auth-component" /> </div> <!-- 主应用界面 --> <div v-else> <!-- 顶部固定菜单 - 添加患者相關匯入按钮 --> <div class="fixed-menu"> <button class="external-link">醫仙</button> <button @click="setCurrentView('mrcrud')" :class="{ active: currentView === 'mrcrud' }">醫案匯入</button> <button @click="setCurrentView('dncrud')" :class="{ active: currentView === 'dncrud' }">病態匯入</button> <button @click="setCurrentView('encrud')" :class="{ active: currentView === 'encrud' }">效驗匯入</button> <button @click="setCurrentView('tncrud')" :class="{ active: currentView === 'tncrud' }">論治匯入</button> <button @click="setCurrentView('diancrud')" :class="{ active: currentView === 'diancrud' }">辨證匯入</button> <button @click="setCurrentView('fncrud')" :class="{ active: currentView === 'fncrud' }">人物匯入</button> <button @click="setCurrentView('pncrud')" :class="{ active: currentView === 'pncrud' }">方劑匯入</button> <button @click="setCurrentView('mncrud')" :class="{ active: currentView === 'mncrud' }">本草匯入</button> <button @click="setCurrentView('sncrud')" :class="{ active: currentView === 'sncrud' }">典籍匯入</button> <!-- 新增患者相關匯入按钮 --> <button @click="setCurrentView('prncrud')" :class="{ active: currentView === 'prncrud' }">其他匯入</button> <button @click="setCurrentView('mreditor')" :class="{ active: currentView === 'mreditor' }">醫案編輯</button> <button @click="setCurrentView('mrassociator')" :class="{ active: currentView === 'mrassociator' }">醫案關聯</button> <button @click="setCurrentView('mrquery')" :class="{ active: currentView === 'mrquery' }">醫案查詢</button> <div class="user-info"> <span v-if="username" class="username-display" @click="goToProfile">{{ username }}</span> <button @click="handleLogout" class="logout-btn">登出</button> </div> </div> <!-- 动态组件展示区域 --> <div class="content-wrapper"> <keep-alive :include="cachedComponents"> <component :is="currentViewComponent" :key="currentView" ref="currentComponent" @reset-profile="resetProfileData" /> </keep-alive> </div> </div> </div> </template> <script> // 导入主应用组件 import mrassociator from "./components/mrassociator.vue"; import mrquery from "./components/mrquery.vue"; import mreditor from "./components/mreditor.vue"; import dncrud from "./components/dncrud.vue"; import encrud from "./components/encrud.vue"; import tncrud from "./components/tncrud.vue"; import mncrud from "./components/mncrud.vue"; import mrcrud from "./components/mrcrud.vue"; import sncrud from "./components/sncrud.vue"; import pncrud from "./components/pncrud.vue"; import fncrud from "./components/fncrud.vue"; import diancrud from "./components/diancrud.vue"; // 导入新增的患者相关组件 import prncrud from "./components/prncrud.vue"; // 导入认证组件和个人资料组件 import Register from './legalization/register.vue'; import Login from './legalization/login.vue'; import Profile from './legalization/profile.vue'; export default { components: { // 主应用组件 mrassociator, mrquery, mreditor, dncrud, encrud, tncrud, mncrud, mrcrud, sncrud, pncrud, fncrud, diancrud, prncrud, // 注册患者相关组件 // 认证和个人资料组件 register: Register, login: Login, profile: Profile }, data() { return { // 主应用状态 currentView: "mrassociator", cachedComponents: [ "mrassociator", "mrquery", "mreditor", "dncrud", "encrud", "tncrud", "mncrud", "mrcrud", "sncrud", "pncrud", "fncrud", "diancrud", "prncrud", // 新增患者相关组件缓存 "profile" ], // 认证状态 isAuthenticated: false, authView: 'login', // 用户名状态 username: "", profileResetCallback: null }; }, computed: { currentViewComponent() { return this.currentView; } }, methods: { // 主应用视图切换方法 setCurrentView(viewName) { if (viewName === 'mreditor') { const editor = this.$refs.currentComponent; if (editor && editor.hasActiveMedicalRecord && editor.hasActiveMedicalRecord()) { const confirmReset = window.confirm('編輯欄位仍有資料,按確定則進入輸入新醫案流程,否則進入編輯頁面保留資料。'); if (confirmReset) { editor.resetAll && editor.resetAll(); } } } if (this.currentViewComponent && this.currentViewComponent.beforeLeave) { this.currentViewComponent.beforeLeave(); } this.currentView = viewName; localStorage.setItem("lastActiveView", viewName); }, // 跳转到个人资料页 goToProfile() { this.setCurrentView('profile'); }, // 恢复最后查看的视图 restoreLastView() { const lastView = localStorage.getItem("lastActiveView"); if (lastView && this.cachedComponents.includes(lastView)) { this.currentView = lastView; } }, // 处理登录成功 handleLoginSuccess(userData) { this.isAuthenticated = true; this.username = userData.username || "使用者"; localStorage.setItem('username', this.username); this.setCurrentView('profile'); }, // 重置所有子组件状态 resetChildComponents() { try { if (this.$refs.currentComponent) { if (this.$refs.currentComponent.resetAll) { this.$refs.currentComponent.resetAll(); } this.$refs.currentComponent.$children.forEach(child => { if (child.resetAll) { child.resetAll(); } }); } } catch (error) { console.error('重置子组件时出错:', error); } }, // 重置个人资料数据 resetProfileData() { if (this.$refs.currentComponent && this.$refs.currentComponent.resetProfileData) { this.$refs.currentComponent.resetProfileData(); } }, // 注销处理方法 async handleLogout() { const confirmLeave = window.confirm('登出後所有編輯欄位中的資料將被清除,是否確定登出?'); if (!confirmLeave) return; sessionStorage.removeItem("medicalRecordState", "mrcrudState"); sessionStorage.removeItem("prncrudState"); // 新增:清除患者相关状态 this.resetChildComponents(); this.resetProfileData(); try { const authToken = localStorage.getItem('authToken'); const getCookie = (name) => { const cookieValue = document.cookie.match( `(^|;)\\s*${name}\\s*=\\s*([^;]+)` ); return cookieValue ? cookieValue.pop() : ''; }; const csrfToken = getCookie('csrftoken'); const response = await fetch("api/logout/", { method: "POST", headers: { "X-CSRFToken": csrfToken, "Authorization": `Token ${authToken}` }, credentials: "include" }); // 触发全局登出事件 const logoutEvent = new Event('logout'); window.dispatchEvent(logoutEvent); if (response.ok) { localStorage.removeItem("authToken"); localStorage.removeItem("username"); localStorage.removeItem("lastActiveView"); this.isAuthenticated = false; this.username = ""; this.authView = 'login'; } } catch (error) { console.error(`登出錯誤: ${error.message}`); } } }, mounted() { this.isAuthenticated = !!localStorage.getItem('authToken'); if (this.isAuthenticated) { this.username = localStorage.getItem('username') || "使用者"; this.restoreLastView(); } } }; </script> <style scoped> .app-container { display: flex; flex-direction: column; min-height: 100vh; } .fixed-menu { position: fixed; top: 0; left: 0; width: 100%; background: #2c3e50; padding: 10px; display: flex; gap: 10px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); z-index: 100; flex-wrap: wrap; } .fixed-menu button { padding: 8px 16px; border: none; border-radius: 4px; background: #3498db; color: white; cursor: pointer; transition: background 0.3s; white-space: nowrap; } .fixed-menu button:hover { background: #2980b9; } .fixed-menu button.active { background: #e74c3c; font-weight: bold; } .fixed-menu button.external-link { background: #0094ff; cursor: default; } .fixed-menu button.external-link:hover { background: #0094ff; } .content-wrapper { flex: 1; margin-top: 60px; padding: 20px; background: #f5f5f5; height: calc(100vh - 80px); overflow-y: auto; } .auth-container { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; background: #f5f7fa; } .auth-switcher { margin-bottom: 20px; display: flex; gap: 15px; } .auth-switcher button { padding: 12px 24px; border: none; border-radius: 6px; background: #e0e7ff; cursor: pointer; transition: all 0.3s; font-size: 16px; min-width: 100px; } .auth-switcher button.active { background: #6366f1; color: white; font-weight: bold; box-shadow: 0 4px 6px -1px rgba(99, 102, 241, 0.3); } .auth-component { width: 100%; max-width: 450px; padding: 30px; background: white; border-radius: 12px; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); } .user-info { margin-left: auto; display: flex; align-items: center; gap: 10px; } .username-display { color: white; font-weight: 500; padding: 0 8px; cursor: pointer; transition: text-decoration 0.3s; } .username-display:hover { text-decoration: underline; } .logout-btn { background: #ef4444 !important; margin-right: 15px; } .logout-btn:hover { background: #dc2626 !important; } </style>
11-16
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

canjun_wen

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值