代码示例:
TypeScript
<template #main>
<!-- 标签页 -->
<el-tabs class="capital-options-tabs" v-model="activeName">
<el-tab-pane v-for="tab in tabs" :key="tab.name" :label="tab.label" :name="tab.name" lazy>
<div class="tab-content" v-loading="tab.loadingRef?.value">
<CapitalOptionTable
:key="tab.name"
:data="tab.dataRef.value"
@add="handleAdd"
@save="handleSave"
@delete="handleDelete"
@remove="handleDelete" />
</div>
</el-tab-pane>
</el-tabs>
</template>
lazy 是 el-tab-pane 的专用 prop
它跟 Vue 3 本身没关系,是 Element Plus 组件库 为 el-tab-pane 定义的一个属性。
分清楚几层概念
| 名称 | 归属 | 含义 |
|---|---|---|
lazy(el-tab-pane 的 prop) |
Element Plus | 该 pane 首次激活前不渲染 |
v-if / v-show |
Vue 3 核心 | 条件渲染 / 条件显示 |
lazy(<Suspense> 相关、路由懒加载等) |
Vue 3 / Vue Router | 另一套概念,跟这里无关 |
所以这里的 lazy 是组件库 API ,不是 Vue 的语言特性。你换成别的 UI 库(比如 Ant Design Vue 的 a-tab-pane),它可能没有这个 prop,行为也不同。
Vue 3 里 lazy 出现在哪些地方
顺带理一下,避免混淆------Vue 3 生态里叫 lazy 的有好几处,彼此独立:
-
<Suspense>+ 异步组件 :defineAsyncComponent本身不算lazy,但常被叫"懒加载组件" -
Vue Router :
() => import('./Foo.vue')叫路由懒加载,也没有名为lazy的配置项 -
v-model.lazy:这是修饰符,作用是"change 时才同步,而不是 input 时",跟渲染时机毫无关系 -
Element Plus
el-tab-pane的lazy:就是你现在用的这个,控制"要不要延迟渲染"
Element Plus 里 lazy 的等价写法
el-tab-pane 的 lazy 本质是把内部渲染逻辑变成:
vue
<!-- 概念示意 -->
<div v-if="!lazy || loaded || active" v-show="active">
<slot />
</div>
所以它也可以被理解为:"首次激活前用 v-if 挡着" 。如果你哪天想手动控制,完全可以不用 lazy,自己在 pane 里包一层:
vue
<el-tab-pane v-for="tab in tabs" :name="tab.name" :label="tab.label">
<div v-if="visited[tab.name]">
<CapitalOptionTable ... />
</div>
</el-tab-pane>
效果类似,只是 Element Plus 帮你把 visited(loaded)这套状态管好了。
小结
-
lazy属于 Element Plus 的el-tab-pane,不是 Vue 3 语言能力 -
想确认一个 prop 是谁的,看它写在哪个标签上、查对应组件文档即可
-
换库就可能没有这个 prop,但都可以用
v-if自己实现同样的"延迟渲染"
1. lazy 是什么意思?
el-tab-pane 默认(不写 lazy)时,即使没被激活,也会被渲染到 DOM 中 ,只是用 v-show 隐藏。
加上 lazy 之后,逻辑等价于:
vue
<!-- Element Plus 内部大致实现 -->
<div v-if="shouldBeRender" v-show="active">
<slot />
</div>
其中 shouldBeRender = !lazy || loaded || active。
也就是说:
-
未激活前 :不渲染(
v-if为 false,DOM 里没有) -
第一次激活后 :渲染并标记
loaded = true -
切走后 :不再销毁,改用
v-show隐藏(保留 DOM 和组件状态)
所以 lazy 是"延迟到首次激活才渲染",不是"每次切换都重建"。
2. 可以不 lazy 吗?
可以,去掉就行。但结合你代码里的注释,要权衡:
| 不 lazy | lazy(当前) | |
|---|---|---|
| 首屏渲染 | 7 个 el-table 全实例化 + 渲染(用 v-show 藏起来) |
只渲染当前 1 个 |
| 首屏性能 | 差(el-table 不轻) |
好 |
| 切换闪烁 | 少(组件早已存在,列宽已计算) | 可能有一次布局抖动(首次挂载) |
| 内存占用 | 7 份表格实例常驻 | 用过才常驻 |
你代码里这段注释:
表格切换闪烁的原因:el-table-column 没有固定宽度,导致切换标签页时表格重新计算列宽产生视觉变化
说明你已经踩到过切换抖动的坑 。lazy 会加剧这个问题------首次切到某个 tab 时组件才 mounted,el-table 要重新测量列宽,闪烁感更明显。
所以这里其实是一个取舍:
-
想要首屏快、内存省 → 保留
lazy,用"固定列宽 + 占位空列"这套方案压抖动(你已经在做了) -
想要切换绝对顺滑、不闪 → 去掉
lazy,但接受首屏渲染 7 个表格的成本
以"资金设置"这种配置页(7 个 tab、每个表也就几十行)来看,去掉 lazy 也是完全可接受的,甚至体验更好。你不妨去掉试试,对比一下切换和首屏表现。
3. v-loading 能直接放在 el-tab-pane 上吗?
能挂,但不建议,实际上会失效或看不见。
原因有三层:
① 指令绑定到组件时,作用在"根元素"上
Vue 3 里给组件加指令,指令的 el 拿到的是组件根元素。而 el-tab-pane 的根元素是条件渲染的:
text
<div v-if="shouldBeRender" v-show="active"> ... </div>
lazy 未激活时 v-if 为 false,根本没有真实 DOM,指令初始化时拿到的可能是注释节点,遮罩插不进去。
② 即使挂上了,容器高度可能是 0
v-loading 的遮罩是按容器尺寸撑开的。如果 el-tab-pane 的根元素里没内容(数据还没回来),高度就是 0,遮罩"看不见"。
③ 加上 v-show 时挂载没意义
未激活的 pane 是 display: none,loading 遮罩自然也看不见。
所以正确做法:包一层容器
vue
<el-tab-pane
v-for="tab in tabs"
:key="tab.name"
:label="tab.label"
:name="tab.name"
lazy>
<div class="tab-content" v-loading="tab.loadingRef.value">
<CapitalOptionTable ... />
</div>
</el-tab-pane>
scss
.tab-content {
height: 100%;
min-height: 200px; // 关键:保证空数据时也有高度,遮罩才可见
}
这样 v-loading 作用在一个稳定存在的 div 上,且这个 div 有明确高度,遮罩才能正常工作。
小结
-
lazy= 首次激活才渲染,之后用v-show保留 -
去掉
lazy可行,但权衡首屏性能 vs 切换顺滑度;你的"固定列宽"方案已经缓解了抖动,去掉 lazy 会进一步变好 -
v-loading别直接挂el-tab-pane:组件根元素条件渲染 + 高度不定,会导致指令失效或遮罩不可见;包一层有高度的 div 是最稳妥的写法
这里加 lazy 的必要性
TypeScript
<script setup lang="ts">
/**
* 进度监控-选项卡
*/
defineOptions({
name: "progress-ctrl-tabs"
});
import type { TabsPaneContext } from "element-plus";
import {
ProgressCtrlTabsApply,
ProgressCtrlTabsAssay,
ProgressCtrlTabsHandover,
ProgressCtrlTabsReport
} from "../components";
import { useProgressCtrlStore } from "../stores";
// ========== 状态管理(Store) ==========
const progressCtrlStore = useProgressCtrlStore();
// ========== 事件处理 ==========
/**
* 切换 tab 页
* @param tab tab 页
* @param event 事件
*/
const handleTabClick = async (tab: TabsPaneContext, event: Event) => {
progressCtrlStore.activeTabName = tab.paneName as typeof progressCtrlStore.activeTabName;
};
</script>
<template>
<div class="tabs">
<el-tabs :model-value="progressCtrlStore.activeTabName" type="border-card" @tab-click="handleTabClick">
<el-tab-pane label="受理信息" name="apply">
<ProgressCtrlTabsApply />
</el-tab-pane>
<el-tab-pane label="交接信息" name="handover">
<ProgressCtrlTabsHandover />
</el-tab-pane>
<el-tab-pane label="检测信息" name="assay">
<ProgressCtrlTabsAssay />
</el-tab-pane>
<el-tab-pane label="报告信息" name="report">
<ProgressCtrlTabsReport />
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped lang="scss">
.tabs {
.el-tabs {
border: none;
// 高度需要减去上下边框宽度
// height: calc(100% - 2px);
// 使用 box-sizing: border-box; 就不用考虑边框宽度
box-sizing: border-box;
height: 528px;
:deep(.el-tabs__content) {
margin: 0;
padding: 0;
}
.el-tab-pane {
height: 500px;
}
}
}
</style>
需要加,而且收益比之前那个页面更大
为什么这里更值得加
对比一下两个页面的差异:
| 资金设置页 | 这个进度监控页 | |
|---|---|---|
| 每个 tab 内容 | 一个 el-table |
四个业务子组件(Apply / Handover / Assay / Report) |
| 子组件复杂度 | 中等 | 通常更重(表单、明细、表格、可能还有各自请求) |
| 是否固定高度 | 否 | 是(528px / 500px) |
| 未激活 tab 是否可见 | 否 | 否 |
不加 lazy 的后果 :4 个子组件会在 tabs 挂载时全部实例化并渲染 ,哪怕用户只看了第一个。这些子组件的 onMounted、内部 watch、immediate 逻辑会一起触发------如果 ProgressCtrlTabsHandover 等在 onMounted 里发请求,就会一次性打 4 个接口。
所以这里加 lazy 的收益(首屏性能、减少无谓请求)比资金设置页更明显。
怎么加
直接加 lazy 即可,行为和之前讲的一样:首次激活才渲染,之后用 v-show 保留。
vue
<el-tab-pane label="受理信息" name="apply" lazy>
<ProgressCtrlTabsApply />
</el-tab-pane>
<el-tab-pane label="交接信息" name="handover" lazy>
<ProgressCtrlTabsHandover />
</el-tab-pane>
<!-- ...其余同理 -->
需要注意的点
1. 首次切换会有一次渲染成本
因为 lazy 是"延迟到首次激活才挂载",用户第一次点"交接信息"时,ProgressCtrlTabsHandover 才 mounted。如果这个组件内部在 onMounted 里请求数据、或渲染较重,会有短暂空白/卡顿。
建议 :配合 loading 或骨架屏。比如子组件自己管 loading,或者在切换时给内容区一个占位。这跟前面资金设置页加的 v-loading 是同一思路。
2. 这里用 :model-value + @tab-click,不是 v-model
vue
<el-tabs :model-value="progressCtrlStore.activeTabName" @tab-click="handleTabClick">
这是"受控"写法------activeName 存在 store 里,点击时通过 handleTabClick 写回 store,再由 :model-value 驱动视图。
这个写法能工作 ,但有一处要留意:@tab-click 只在用户点击标签时触发,不会 在代码里直接改 progressCtrlStore.activeTabName 时同步(其实会------因为 :model-value 绑的就是它)。所以受控本身没问题。
不过更推荐直接用 v-model,它内部会自动处理 update:modelValue:
vue
<el-tabs v-model="progressCtrlStore.activeTabName" ...>
这样就不用写 handleTabClick 了,TabsPaneContext 的导入也能删掉。当然如果 activeTabName 的类型需要收窄(你用了 as typeof ...),或者有额外的埋点/日志逻辑,那就保留 @tab-click。
补充:
lazy只影响首次渲染时机,与"受控 / 非受控"写法无关,两种情况都能加。
3. 固定高度下,未渲染的 pane 不影响布局
你给 .el-tab-pane 设了 height: 500px。加了 lazy 后,未激活的 pane 里没有子组件 DOM,但 pane 本身仍在(el-tabs__content 里),高度由内容决定------空的 pane 高度可能是 0。不过这没关系,因为同一时间只有一个 pane 可见。
如果你担心初次切换时高度跳变,可以给 .el-tab-pane 保留 min-height,或让子组件自己撑满。
结论
加 lazy,尤其这里 4 个子组件都可能是重组件。配合:
-
子组件内部的 loading / 骨架屏,缓解首次切换的空白感
-
顺手考虑把
:model-value+@tab-click简化为v-model(非必须)