Code前端首页关于Code前端联系我们

Vue3 标签页切换总出问题?component: is到底怎么用才顺手又高效?

terry 43分钟前 阅读数 15 #Vue
文章标签 Vue3动态组件

很多刚接触Vue3的同学,一碰到需要动态切换组件的场景——比如电商后台的订单管理标签页、个人中心的编辑模块、在线表单的步骤引导,第一反应可能是用v-if/v-else-if/v-else堆一堆代码,结果写着写着标签页一多就混乱,改个组件名要在十几个地方重复替换,性能还不太行,这时候,官方文档里提过的<component :is="xxx">就会被翻出来,但用起来又可能遇到一堆小问题:为什么切换后组件的状态会重置?Teleport为什么在动态组件里失效?能不能绑定子组件的props和emit?Transition怎么结合着用?别着急,今天咱们就从最基础的概念开始,把这些问题一个一个捋清楚,还会聊点进阶技巧,让你以后用动态组件再也不卡壳。

component: is 到底是什么?新手别把它想得太复杂

很多教程上来就给一堆术语,什么“动态组件挂载器”“内置组件”,其实新手完全可以换个简单的理解方式:<component :is>就是个“组件插座”——你把要插的“组件插头”(也就是组件的引用或者注册过的组件名)绑定给is属性,Vue就会自动把对应的组件插到这个插座上,插座位置不变,插头可以随时换。

那它和v-if系列有什么本质区别呢?最直观的就是代码量,假设你有5个订单管理标签页:未付款、待发货、已发货、已收货、退款售后,用v-if的话,模板里要写5个单独的组件标签,每个套一层条件判断;用component: is的话,只需要写1个<component>,剩下的全靠is属性控制,但更重要的是灵活性,比如以后要加一个“待评价”标签页,用v-if就得在模板最后再加一个标签和条件,用component: is只需要把新组件加到配置数组里就行,不用动模板核心逻辑。

基础用法三步走:从0搭一个不踩坑的标签页

讲概念不如动手写,咱们现在用Vue3 + <script setup>(毕竟现在写Vue3基本都用这个语法糖了)搭一个最常见的订单管理标签页,顺便把基础用法讲明白。

第一步:准备好要切换的子组件

首先得有“组件插头”对吧?咱们先建3个最简单的子组件:OrderUnpaid.vue、OrderPendingShip.vue、OrderShipped.vue不用太复杂,就是显示当前标签页的名称和一个可以修改的状态变量,方便后面测试状态重置的问题。

<!-- OrderUnpaid.vue -->
<template>
  <div class="order-box">
    <h3>未付款订单</h3>
    <p>当前测试状态:{{ testCount }}</p>
    <button @click="testCount++">点我加1</button>
  </div>
</template>
<script setup>
import { ref } from 'vue'
const testCount = ref(0)
</script>
<style scoped>
.order-box { padding: 20px; border: 1px solid #eee; margin-top: 10px; }
button { margin-top: 10px; padding: 5px 10px; cursor: pointer; }
</style>

另外两个子组件和这个差不多,只是h3的标题改一下就行,比如OrderPendingShip.vue改“待发货订单”,OrderShipped.vue改“已发货订单”。

第二步:在父组件里配置标签页和动态组件

接下来在父组件里操作,核心逻辑是:

  1. 引入并注册子组件(<script setup>里引入了就相当于局部注册了,不用像以前Vue2那样写components选项);
  2. 准备一个标签页配置数组,里面可以存标签名、对应的组件引用、甚至图标地址、权限标识这些扩展信息;
  3. 用一个ref变量记录当前选中的标签页;
  4. 模板里用v-for渲染标签按钮,点击按钮切换选中状态;
  5. 最后加一个<component :is="当前选中标签对应的组件">。
<!-- Parent.vue -->
<template>
  <div class="parent-box">
    <!-- 标签按钮组 -->
    <div class="tab-btns">
      <button
        v-for="tab in tabList"
        :key="tab.key"
        :class="{ active: currentTab.key === tab.key }"
        @click="currentTab = tab"
      >
        {{ tab.label }}
      </button>
    </div>
    <!-- 动态组件插座 -->
    <component :is="currentTab.component"></component>
  </div>
</template>
<script setup>
import { ref } from 'vue'
// 引入子组件
import OrderUnpaid from './OrderUnpaid.vue'
import OrderPendingShip from './OrderPendingShip.vue'
import OrderShipped from './OrderShipped.vue'
// 标签页配置数组,key最好用唯一值,别用索引(虽然索引这里能用,但如果标签页顺序变了或者增删了会出问题)
const tabList = ref([
  { key: 'unpaid', label: '未付款', component: OrderUnpaid },
  { key: 'pendingShip', label: '待发货', component: OrderPendingShip },
  { key: 'shipped', label: '已发货', component: OrderShipped }
])
// 当前选中的标签页,默认第一个
const currentTab = ref(tabList.value[0])
</script>
<style scoped>
.parent-box { max-width: 800px; margin: 20px auto; padding: 0 20px; }
.tab-btns { display: flex; gap: 10px; border-bottom: 1px solid #eee; padding-bottom: 10px; }
.tab-btns button { border: none; padding: 8px 16px; background: #f5f5f5; border-radius: 4px; cursor: pointer; transition: all 0.2s; }
.tab-btns button.active { background: #42b983; color: white; }
</style>

现在你可以运行一下这个代码,点击标签按钮,对应的子组件会自动切换,是不是比v-if清爽多了?不过等等,你有没有发现一个小问题:比如先在“未付款订单”里点几次按钮,把testCount加到5,然后切换到“待发货订单”再切回来,testCount又变成0了!这就是新手最常踩的第一个坑——动态组件切换后状态重置。

踩坑预警!常见5个问题及解决方案

刚才的状态重置问题只是其中之一,咱们再把新手用<component :is>时最容易遇到的问题整理出来,每个问题都给一个详细的解决方案和原理说明,帮你从根上避免踩坑。

问题1:切换组件后,子组件的状态(ref/reactive/computed)全没了

现象:刚才测试的testCount就是典型例子,切换回来就重置为初始值;如果子组件里有表单输入框,输了一堆内容切换后再回来也会清空。 原因:默认情况下,Vue每次切换动态组件时,都会销毁旧组件,创建新组件——销毁时旧组件的响应式数据、事件监听器、生命周期钩子都会被清理掉,创建时又会重新初始化所有东西,所以状态自然就没了。 解决方案:用Vue的另一个内置组件<keep-alive>把<component :is>包起来!<keep-alive>的作用是缓存不活跃的组件实例,不会销毁它们,只是把它们从DOM树上“摘下来”存到内存里,下次切换回来再“挂上去”,响应式数据、表单内容、滚动位置(这个后面单独说)都会保留。

那具体怎么加呢?很简单,修改一下父组件的模板:

<!-- 父组件Parent.vue的模板修改部分 -->
<keep-alive>
  <component :is="currentTab.component"></component>
</keep-alive>

现在再测试一下,testCount加到5切换后再回来,还是5,完美!不过<keep-alive>也不是万能的,它会占用内存,如果缓存的组件太多太复杂,可能会导致页面卡顿,所以最好加几个限制属性:

  • include:字符串、正则表达式或数组,只缓存匹配到的组件(组件名要大写开头,或者用defineOptions在<script setup>里指定name属性,因为<script setup>里引入的组件默认name是文件名去掉后缀的大驼峰,但有时候文件名不太规范,最好手动指定);
  • exclude:字符串、正则表达式或数组,不缓存匹配到的组件;
  • max:数字,最多缓存多少个组件实例,超过的话会自动销毁最早缓存的那个(LRU算法)。

比如刚才的例子,咱们只想缓存未付款和待发货的,不想缓存已发货的,而且最多缓存2个,那就可以这样写: 首先在不想缓存的OrderShipped.vue里加defineOptions指定name:

<!-- OrderShipped.vue的script setup部分 -->
<script setup>
import { ref } from 'vue'
defineOptions({
  name: 'OrderShipped' // 必须大驼峰,include/exclude匹配的就是这个name
})
const testCount = ref(0)
</script>

然后修改父组件的<keep-alive>:

<!-- 父组件Parent.vue的keep-alive修改部分 -->
<keep-alive include="OrderUnpaid,OrderPendingShip" :max="2">
  <component :is="currentTab.component"></component>
</keep-alive>

现在再测试:未付款加5,待发货加3,已发货加2,切换回来后,未付款和待发货的数还在,已发货的数会重置,符合预期,另外还要注意,<keep-alive>包裹的组件会多两个生命周期钩子:

  • onActivated:组件被“挂上去”(激活)时调用,第一次创建时也会在onMounted之后调用;
  • onDeactivated:组件被“摘下来”(失活)时调用,不会销毁,所以onUnmounted不会触发。 这两个钩子很有用,比如可以在onActivated里刷新组件的实时数据,在onDeactivated里暂停定时器或者取消请求,避免内存泄漏。

问题2:用了<keep-alive>,子组件的滚动位置还是没保留

现象:比如子组件里有一个很长的列表,你滚动到中间了,切换到其他组件再切回来,滚动条又回到顶部了。 原因:<keep-alive>只能缓存组件的内存状态,不能直接缓存DOM的滚动位置——因为Vue是虚拟DOM驱动的,组件失活时虚拟DOM不会更新,而DOM的滚动位置是真实DOM的属性,除非浏览器有自动恢复的机制(比如前进后退时),否则切换回来后真实DOM被重新挂载(或者说重新渲染?不对,keep-alive的组件实例还在,DOM也是存着的?哦等下,这个要分情况:如果是<component :is>直接包裹在<div>里,那切换回来时DOM是直接复用的,但如果包裹在<transition>里,或者是用了<router-view>加<keep-alive>,情况可能不一样?不对,刚才咱们的基础例子如果加个长列表,其实切换回来滚动位置应该还在?哦不对,可能我记错了,应该是只有当组件的根元素是单个可滚动元素时,<keep-alive>才会自动保留滚动位置?或者说,有些浏览器不会自动保留?为了保险起见,不管什么情况,最好手动处理滚动位置的缓存。 解决方案:利用<keep-alive>的onActivated和onDeactivated钩子,手动保存和恢复滚动位置。

具体做法是:在子组件里给可滚动的元素加个ref,失活时用ref获取scrollTop/scrollLeft并保存到一个变量里(可以用ref,也可以用localStorage/sessionStorage,刷新页面也能保留),激活时再把保存的scrollTop/scrollLeft赋值给ref对应的元素。

比如修改OrderUnpaid.vue,加一个长列表:

<!-- OrderUnpaid.vue修改后 -->
<template>
  <div class="order-box">
    <h3>未付款订单</h3>
    <!-- 可滚动的列表容器,加ref -->
    <div class="scroll-list" ref="scrollListRef">
      <div v-for="item in 50" :key="item" class="list-item">订单 {{ item }}</div>
    </div>
    <p>当前测试状态:{{ testCount }}</p>
    <button @click="testCount++">点我加1</button>
  </div>
</template>
<script setup>
import { ref, onActivated, onDeactivated } from 'vue'
// 可滚动容器的ref
const scrollListRef = ref(null)
// 保存滚动位置的ref,初始值0
const savedScrollTop = ref(0)
const testCount = ref(0)
// 失活时保存滚动位置
onDeactivated(() => {
  if (scrollListRef.value) {
    savedScrollTop.value = scrollListRef.value.scrollTop
  }
})
// 激活时恢复滚动位置
onActivated(() => {
  if (scrollListRef.value) {
    // 这里用nextTick很重要!因为激活后DOM可能还没完全更新,直接赋值可能没用
    nextTick(() => {
      scrollListRef.value.scrollTop = savedScrollTop.value
    })
  }
})
</script>
<style scoped>
.order-box { padding: 20px; border: 1px solid #eee; margin-top: 10px; }
.scroll-list { height: 200px; overflow-y: auto; margin-bottom: 10px; border: 1px solid #ddd; }
.list-item { padding: 8px; border-bottom: 1px solid #f0f0f0; }
button { margin-top: 10px; padding: 5px 10px; cursor: pointer; }
</style>

对了,这里忘了引入nextTick,<script setup>里要记得加import { ref, onActivated, onDeactivated, nextTick } from 'vue'!现在再测试一下,滚动到列表中间切换,再切回来,滚动位置就保留了。

问题3:怎么给动态组件绑定props和emit?和普通组件一样吗?

现象:很多新手会觉得动态组件是“特殊组件”,绑定props和emit会不会不一样?比如会不会要加个什么属性? 答案:完全一样!动态组件只是个“插座”,真正工作的还是插上去的“组件插头”,所以绑定props、emit、插槽、事件修饰符这些,都和绑定普通组件一模一样。

那咱们来演示一下:给刚才的子组件加一个props(比如显示当前用户的ID),加一个emit(比如点击按钮时通知父组件加一个全局提示),然后在父组件里绑定。

首先修改OrderUnpaid.vue:

<!-- OrderUnpaid.vue的script setup修改部分 -->
<script setup>
import { ref, onActivated, onDeactivated, nextTick } from 'vue'
// 定义props
const props = defineProps({
  userId: {
    type: String,
    required: true
  }
})
// 定义emit
const emit = defineEmits(['showGlobalTip'])
const scrollListRef = ref(null)
const savedScrollTop = ref(0)
const testCount = ref(0)
// 修改按钮的点击事件,加emit
const handleClick = () => {
  testCount.value++
  emit('showGlobalTip', `用户${props.userId}在未付款订单里加了1!`)
}
// 失活和激活的钩子保持不变
onDeactivated(() => {
  if (scrollListRef.value) {
    savedScrollTop.value = scrollListRef.value.scrollTop
  }
})
onActivated(() => {
  if (scrollListRef.value) {
    nextTick(() => {
      scrollListRef.value.scrollTop = savedScrollTop.value
    })
  }
})
</script>

然后修改OrderUnpaid.vue的模板部分的按钮:

<button @click="handleClick">点我加1</button>

接下来修改父组件Parent.vue:

  1. 加一个全局提示的ref变量;
  2. 加一个处理全局提示的函数;
  3. 给标签页配置数组加一个userId属性(或者直接在父组件里定义一个通用的userId);
  4. 给<component :is>绑定userId props和showGlobalTip emit;
  5. 模板里加一个全局提示的显示区域。
<!-- Parent.vue修改后 -->
<template>
  <div class="parent-box">
    <!-- 全局提示区域 -->
    <div v-if="globalTip" class="global-tip">{{ globalTip }}</div>
    <!-- 标签按钮组保持不变 -->
    <div class="tab-btns">
      <button
        v-for="tab in tabList"
        :key="tab.key"
        :class="{ active: currentTab.key === tab.key }"
        @click="currentTab = tab"
      >
        {{ tab.label }}
      </button>
    </div>
    <!-- 动态组件插座,绑定props和emit -->
    <keep-alive include="OrderUnpaid,OrderPendingShip" :max="2">
      <component 
        :is="currentTab.component"
        :userId="currentUserId"
        @showGlobalTip="handleShowGlobalTip"
      ></component>
    </keep-alive>
  </div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import OrderUnpaid from './OrderUnpaid.vue'
import OrderPendingShip from './OrderPendingShip.vue'
import OrderShipped from './OrderShipped.vue'
// 全局提示的ref
const globalTip = ref('')
// 模拟当前用户ID
const currentUserId = ref('')
// 标签页配置数组保持不变
const tabList = ref([
  { key: 'unpaid', label: '未付款', component: OrderUnpaid },
  { key: 'pendingShip', label: '待发货', component: OrderPendingShip },
  { key: 'shipped', label: '已发货', component: OrderShipped }
])
const currentTab = ref(tabList.value[0])
// 处理全局提示的函数
const handleShowGlobalTip = (msg) => {
  globalTip.value = msg
  // 3秒后关闭提示
  setTimeout(() => {
    globalTip.value = ''
  }, 3000)
}
// 模拟登录后获取用户ID
onMounted(() => {
  currentUserId.value = 'u_123456'
})
</script>
<style scoped>
.parent-box { max-width: 800px; margin: 20px auto; padding: 0 20px; position: relative; }
.global-tip { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); padding: 10px 20px; background: #42b983; color: white; border-radius: 4px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); z-index: 9999; }
.tab-btns { display: flex; gap: 10px; border-bottom: 1px solid #eee; padding-bottom: 10px; }
.tab-btns button { border: none; padding: 8px 16px; background: #f5f5f5; border-radius: 4px; cursor: pointer; transition: all 0.2s; }
.tab-btns button.active { background: #42b983; color: white; }
</style>

现在再测试:点击“未付款订单”的按钮,父组件会收到emit,显示全局提示,userId也会正确传递到子组件里(你可以在子组件的h3下面加一行<p>当前用户ID:{{ userId }}</p>看看),插槽也是一样的,比如给OrderUnpaid.vue加一个默认插槽和一个具名插槽,然后在父组件的<component :is>里加插槽内容,完全没问题,这里就不演示了。

问题4:Teleport为什么在动态组件里失效?

现象:如果动态组件里用了<Teleport to="body">,切换组件后再切回来,Teleport挂载的内容会不会出问题?比如有时候会消失,有时候会重复挂载? 原因:这个问题其实不是动态组件的锅,是Teleport和<keep-alive>配合使用时的一个小问题——当组件被<keep-alive>失活时,Teleport的内容默认不会被移除,而是会留在目标容器(比如body)里;当组件再次激活时,Teleport又会重新挂载一次,导致重复出现。 解决方案:利用<keep-alive>的onActivated和onDeactivated钩子,手动控制Teleport的显示和隐藏,或者给Teleport加一个disabled属性,结合激活/失活状态控制。

最简单的方法是加一个disabled属性:在子组件里定义一个isTeleportDisabled的ref,失活时设为true,激活时设为false。

咱们来演示一下:修改OrderUnpaid.vue,加一个Teleport:

<!-- OrderUnpaid.vue修改后 -->
<template>
  <div class="order-box">
    <h3>未付款订单</h3>
    <p>当前用户ID:{{ userId }}</p>
    <div class="scroll-list" ref="scrollListRef">
      <div v-for="item in 50" :key="item" class="list-item">订单 {{ item }}</div>
    </div>
    <p>当前测试状态:{{ testCount }}</p>
    <button @click="handleClick">点我加1</button>
    <!-- Teleport,加disabled属性 -->
    <Teleport to="body" :disabled="isTeleportDisabled">
      <div class="teleport-content">这是从OrderUnpaid teleport到body的内容</div>
    </Teleport>
  </div>
</template>
<script setup>
import { ref, onActivated, onDeactivated, nextTick } from 'vue'
const props = defineProps({
  userId: {
    type: String,
    required: true
  }
})
const emit = defineEmits(['showGlobalTip'])
const scrollListRef = ref(null)
const savedScrollTop = ref(0)
const testCount = ref(0)
// 控制Teleport是否禁用的ref
const isTeleportDisabled = ref(true)
const handleClick = () => {
  testCount.value++
  emit('showGlobalTip', `用户${props.userId}在未付款订单里加了1!`)
}
onDeactivated(() => {
  if (scrollListRef.value) {
    savedScrollTop.value = scrollListRef.value.scrollTop
  }
  // 失活时禁用Teleport,内容会回到原来的位置(但因为组件失活了,原来的位置也看不见,相当于隐藏了)
  isTeleportDisabled.value = true
})
onActivated(() => {
  if (scrollListRef.value) {
    nextTick(() => {
      scrollListRef.value.scrollTop = savedScrollTop.value
    })
  }
  // 激活时启用Teleport,内容会重新挂载到body
  isTeleportDisabled.value = false
})
</script>
<style scoped>
/* 原来的样式保持不变,加一个teleport-content的样式(因为teleport到body了,所以不能加scoped!) */
</style>
<style>
.teleport-content { position: fixed; bottom: 20px; right: 20px; padding: 10px 20px; background: #35495e; color: white; border-radius: 4px; }
</style>

现在再测试:切换到未付款订单,右下角会有Teleport的内容;切换到其他订单,内容会消失;再切回来,内容会重新出现,不会重复,完美!

问题5:Transition怎么和动态组件结合着用?实现平滑的切换效果

现象:用v-if/v-else加Transition很简单,那用动态组件加Transition呢?会不会有什么不一样? 答案:稍微有点不一样,但也不难!因为默认情况下,动态组件切换时,旧组件会立刻销毁,新组件会立刻创建,所以Transition的enter和leave动画可能会重叠或者失效,这时候需要给Transition加一个mode属性。 Transition的mode属性有两个常用值:

  • out-in:旧组件先执行leave动画,离开DOM树后,新组件再执行enter动画,进入DOM树;
  • in-out:新组件先执行enter动画,进入DOM树后,旧组件再执行leave动画,离开DOM树。 对于标签页切换这种场景,out-in是最常用的,不会出现两个组件重叠的情况。

那咱们来演示一下:给刚才的动态组件加一个从左到右或者淡入淡出的切换效果,首先修改父组件的模板,把<keep-alive>和<component :is>包在<Transition>里,加mode="out-in"和name="fade"(name属性用来定义CSS动画类名):

<!-- Parent.vue的模板修改部分 -->
<Transition name="fade" mode="out-in">
  <keep-alive include="OrderUnpaid,OrderPendingShip" :max="2">
    <!-- 注意:Transition的直接子元素必须有一个唯一的key!不然Vue可能会复用同一个DOM元素,导致动画失效 -->
    <component 
      :key="currentTab.key"
      :is="currentTab.component"
      :userId="currentUserId"
      @showGlobalTip="handleShowGlobalTip"
    ></component>
  </keep-alive>
</Transition>

哦对了,这里有个非常重要的点!Transition的直接子元素(不管是<keep-alive>还是<component :is>)必须有一个唯一的、变化的key!不然Vue会认为这是同一个元素,不会触发动画,刚才咱们的currentTab有一个唯一的key,所以把key绑定到<component :is>上就行。

然后在父组件的style里加淡入淡出的CSS动画:

<!-- Parent.vue的style scoped修改部分 -->
<style scoped>
/* 原来的样式保持不变 */
.fade-enter-active, .fade-leave-active {
  transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
  opacity: 0;
}
</style>

如果想用从左到右的切换效果,可以这样写CSS:

.fade-enter-active, .fade-leave-active {
  transition: all 0.3s ease;
}
.fade-enter-from {
  transform: translateX(-20px);
  opacity: 0;
}
.fade-leave-to {
  transform: translateX(20px);
  opacity: 0;
}

现在再测试一下,切换标签页时会有平滑的淡入淡出或者从左到右的效果,非常美观!

进阶技巧:让component: is更好用的3个小妙招

掌握了基础用法和常见坑的解决方案,咱们再来聊几个进阶技巧,让你用动态组件的时候更加顺手,代码更加优雅。

技巧1:用组件名的字符串代替组件引用

刚才咱们的标签页配置数组里用的是组件的引用(比如OrderUnpaid),其实也可以用组件的注册名的字符串(比如'OrderUnpaid')——前提是组件必须是全局注册的,或者在父组件的components选项里注册过(不过<script setup>里不能直接写components选项,除非用defineOptions)。

比如把刚才的父组件改成全局注册子组件: 首先在main.js或者main.ts里全局注册:

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import OrderUnpaid from './OrderUnpaid.vue'
import OrderPendingShip from './OrderPendingShip.vue'
import OrderShipped from './OrderShipped.vue'
const app = createApp(App)
// 全局注册子组件,注册名要大驼峰
app.component('OrderUnpaid', OrderUnpaid)
app.component('OrderPendingShip', OrderPendingShip)
app.component('OrderShipped', OrderShipped)
app.mount('#app')

然后修改父组件的标签页配置数组,把component改成字符串:

<!-- Parent.vue的script setup修改部分 -->
<script setup>
import { ref, onMounted } from 'vue'
// 不需要再引入子组件了!
// import OrderUnpaid from './OrderUnpaid.vue'
// import OrderPendingShip from './OrderPendingShip.vue'
// import OrderShipped from './OrderShipped.vue'
const globalTip = ref('')
const currentUserId = ref('')
// component改成字符串
const tabList = ref([
  { key: 'unpaid', label: '未付款', component: 'OrderUnpaid' },
  { key: 'pendingShip', label: '待发货', component: 'OrderPendingShip' },
  { key: 'shipped', label: '已发货', component: 'OrderShipped' }
])
const currentTab = ref(tabList.value[0])
// 其他函数保持不变
const handleShowGlobalTip = (msg) => {
  globalTip.value = msg
  setTimeout(() => {
    globalTip.value = ''
  }, 3000)
}
onMounted(() => {
  currentUserId.value = 'u_123456'
})
</script>

这种写法有什么好处呢?比如标签页配置可以从后端接口获取,后端返回的是字符串的组件名,前端直接用就行,不用提前引入所有组件(不过要注意全局注册的组件太多会影响首屏加载速度,所以如果组件很多,最好用懒加载)。

技巧2:结合defineAsyncComponent实现动态组件的懒加载

刚才说到全局注册太多组件会影响首屏加载速度,那怎么解决呢?可以用Vue的defineAsyncComponent函数实现动态组件的懒加载——也就是只有当组件被第一次用到时,才会去加载对应的JS文件,不用的组件不会加载,大大减少首屏的体积。

那具体怎么写呢?很简单,修改父组件的标签页配置数组,把component改成defineAsyncComponent包裹的懒加载函数:

<!-- Parent.vue的script setup修改部分 -->
<script setup>
import { ref, onMounted, defineAsyncComponent } from 'vue'
// 不需要再提前引入子组件了!
const globalTip = ref('')
const currentUserId = ref('')
// 用defineAsyncComponent懒加载子组件
const tabList = ref([
  { 
    key: 'unpaid', 
    label: '未付款', 
    component: defineAsyncComponent(() => import('./OrderUnpaid.vue')) 
  },
  { 
    key: 'pendingShip', 
    label: '待发货', 
    component: defineAsyncComponent(() => import('./OrderPendingShip.vue')) 
  },
  { 
    key: 'shipped', 
    label: '已发货', 
    component: defineAsyncComponent(() => import('./OrderShipped.vue')) 
  }
])
const currentTab = ref(tabList.value[0])
// 其他函数保持不变
const handleShowGlobalTip = (msg) => {
  globalTip.value = msg
  setTimeout(() => {
    globalTip.value = ''
  }, 3000)
}
onMounted(() => {
  currentUserId.value = 'u_123456'
})
</script>

现在你可以打开浏览器的开发者工具,切换到Network面板,刷新页面,你会发现只有App.vue、main.js、Parent.vue这些核心文件被加载了,子组件的JS文件没有被加载;当你第一次点击“未付款订单”以外的标签页时,对应的子组件JS文件才会被加载。

defineAsyncComponent还可以加一些配置,比如加载时的占位组件、加载失败时的错误组件、超时时间等,让用户体验更好:

// 引入一个加载占位组件和错误组件
import LoadingComponent from './LoadingComponent.vue'
import ErrorComponent from './ErrorComponent.vue'
// 配置defineAsyncComponent
const AsyncOrderUnpaid = defineAsyncComponent({
  loader: () => import('./OrderUnpaid.vue'),
  loadingComponent: LoadingComponent,
  errorComponent: ErrorComponent,
  delay: 200, // 延迟200ms再显示加载占位组件,避免网络快时闪一下
  timeout: 3000 // 超时3秒显示错误组件
})
// 标签页配置数组用配置好的AsyncOrderUnpaid
const tabList = ref([
  { key: 'unpaid', label: '未付款', component: AsyncOrderUnpaid },
  // 其他组件也可以这样配置
])

技巧3:用递归组件+component: is实现多级菜单的动态渲染

刚才咱们聊的都是同级组件的切换,其实<component :is>还可以结合递归组件实现多级菜单的动态渲染——比如电商后台的侧边栏菜单,可能有一级、二级、三级甚至更多级,用v-for嵌套的话,嵌套多少级就要写多少层v-for,非常麻烦;用递归组件+component: is的话,不管嵌套多少级,只需要写一个递归组件就行。

时间关系,这里就不写完整的代码了,只说一下核心思路:

  1. 准备一个菜单配置数组,每个菜单项里有menuId、menuName、menuType(菜单类型,folder'是文件夹,'page'是页面)、children(子菜单数组)、component(如果是页面的话,对应的组件引用或字符串)等信息;
  2. 写一个递归组件SideMenuItem.vue,里面判断当前菜单项的menuType:
    • 如果是'folder',就渲染一个可以展开/收起的文件夹,里面再用v-for渲染children,每个child用<SideMenuItem>递归渲染;
    • 如果是'page',就渲染一个菜单按钮,点击按钮后用事件总线或者Pinia/Vuex通知父组件(或者布局组件)切换动态组件;
  3. 布局组件里用<component :is="currentMenu.component">渲染当前选中的页面组件。

这种写法非常灵活,不管菜单有多少级,配置数组怎么变,核心代码都不用改,非常适合用来做后台管理系统的侧边栏菜单。

什么时候该用component: is,什么时候该用v-if?

聊了这么多,最后咱们来总结一下:什么时候该用<component :is>,什么时候该用v-if/v-else-if/v-else?

  • 用component: is的场景:
    1. 需要切换的组件数量较多(比如3个以上);
    2. 组件的切换逻辑比较灵活,可能需要从后端配置;
    3. 希望代码更简洁、更易维护;
    4. 需要结合<keep-alive>缓存组件状态;
    5. 需要结合defineAsyncComponent实现懒加载;
    6. 需要结合递归组件实现多级结构的动态渲染。
  • 用v-if/v-else-if/v-else的场景:
    1. 需要切换的组件数量很少(比如2个);
    2. 组件的切换逻辑非常简单,不需要灵活配置;
    3. 不需要缓存组件状态,每次切换都要重新初始化;
    4. 组件之间有互斥的逻辑,或者需要在切换时执行一些复杂的逻辑(比如销毁旧组件前做一些清理工作,不过component: is也可以用onUnmounted或者onDeactivated)。

其实很多时候,这两种写法都可以用,但从代码的可维护性和扩展性来看,<component :is>通常是更好的选择,尤其是当组件数量较多或者切换逻辑可能变化的时候。

好了,今天关于Vue3 <component :is>就聊到这里,从基础用法到常见坑的解决方案,再到进阶技巧,应该覆盖了大部分使用场景,如果你还有其他问题,欢迎在评论区留言讨论!

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门