资讯详情

动态组件与异步组件加载实战:告别v-if堆叠,实现按需渲染

📅 2026/10/7 17:24:41 | 华诺云谱 👁 阅读
动态组件与异步组件加载实战:告别v-if堆叠,实现按需渲染
前端圈子里接需求总逃不过“Tab页切换”“多步骤表单”“面板类型不确定”这类场景。我以前写这类功能基本就是v-if一排排堆下去组件一多模板里全是判断分支看着就头大。后来接触了动态组件才明白组件不只是“写在模板里用”的它还能在运行期被当作一个变量传进来跟着数据走跟着类型走。再配合异步加载整个组件体系就从“一锅烩”变成了“点菜现炒”。这篇就把我在项目中折腾动态组件和动态组件加载的那点经验完整整理一遍。动态组件Dynamic Components说白了就是模板里不写死渲染哪个组件而是通过一个变量、一套配置来指定。配合动态组件加载又能在组件真正要被使用的那一刻才去拉取对应的代码文件把体积和首屏时间都降下来。这个方案在 Vue、React 里都有对应玩法我这边主要讲 Vue 3 的场景同时思路对其他框架同样适用。适合做后台管理、低代码平台、插件化系统以及被v-if组件堆折磨过的人参考。1. 为什么需要动态组件先看一个我踩过的真实场景1.1 组件堆 vs 组件配置两种写法的本质差异当时项目里有这么一个页面左侧是不同类型的数据面板用户能在图表、表格、表单、自定义卡片之间切换。第一版我用v-if全写上去了大概长这样ChartPanel v-ifpanelType chart :datadata / TablePanel v-else-ifpanelType table :datadata / FormPanel v-else-ifpanelType form :initial-valuesdata / CustomCard v-else-ifpanelType custom :contentdata /看着能跑但问题很明显每加一种面板模板就多一行判断面板再有二级分类就要嵌套判断模板里还掺着一堆v-bind传参维护成本直线上升。而且这些组件全都被静态import进了主包不管用户用不用得到首次加载全部都拉下来。那不是“动态组件”那只是“用条件语句切换组件”。真正的动态组件是让组件的选择逻辑从模板中彻底抽离出来用一个is属性动态决定component :iscurrentPanel :datadata /这时候currentPanel是什么模板只负责渲染什么。组件身份从“编译期写死”变成了“运行期决定”这也就是component标签存在的意义。1.2 运行期决定组件身份带来的三件连锁好事组件身份延迟到运行期表面上只是写法变化实际上会给项目带来几个连锁改变。第一代码的组织方式变了。面板列表可以收敛到一个配置文件里界面里只需要一个渲染占位符。新来一个面板就在配置文件加一行主页面模板根本不用动。第二动态组件和异步加载天生是一对。因为组件对象可以运行时传入那它自然可以是一个“异步工厂函数”用到时才加载对应代码。这样首屏就不需要把几十个面板全部拉下来用户点开哪个面板浏览器再开始下载那部分代码。第三它为插件化架构打开了门。主应用不需要直接依赖各个插件只需要知道插件注册的映射关系运行时通过名字或路径把插件组件取出来。我们后来做的低代码渲染器也是靠这套逻辑支撑的。注意component标签的is既可以传字符串全局注册的组件名也可以传组件对象局部导入的组件。在 Vue 3.3 里字符串形式如果对应的是一个局部导入的组件需要额外配合resolveComponent或统一使用组件对象形式省得踩“找不到组件”的坑。1.3 动态组件加载和普通按需引入有什么不一样很多人会把“动态组件”和“动态组件加载”两个概念搞混。我的理解是动态组件解决的是“渲染哪个组件”的问题核心是运行期切换能力动态组件加载解决的是“组件的代码什么时候下载”的问题核心是性能与按需。它们可以同时使用。比如is传入一个defineAsyncComponent包出来的异步组件就同时具备动态渲染和按需加载两种能力。也可以单独用比如通过import()在某个事件触发后才下载组件代码但渲染位置上仍然是写着固定的AsyncPanel /。用生活里的事来类比动态组件像“按菜单点菜”顾客点什么后厨做什么动态组件加载像“食材别一次性全买回家接到订单再去市场买”。两个理念各管各的事组合到一起就是一套既灵活又轻量的组件体系。2. 动态组件加载的几条路线方案选型要和场景匹配2.1 最轻量的动态切换用component标签做占位渲染如果你只是需要在几个已经打包在一起的组件之间切换直接用component就够了不需要上异步。一个典型的例子是表单的分步填写第一步是基础信息第二步是公司信息第三步是确认页component :isstepComponent v-model:form-dataformData nextnextStep previousprevStep /这里的stepComponent来自一个普通导入的组件数组const steps [ defineComponent({ /* ... */ }), CompanyInfo, ConfirmPanel, ] const stepComponent computed(() steps[stepIndex.value])这种情况下所有组件都在同一个包切换只是实例的创建与销毁成本低、体验流畅。适合步骤固定、数量有限的场景。但要注意component默认只是切换渲染不会保留组件内部状态。你从第一步切到第二步再切回来第一步的表单数据如果没提升到父组件便会丢得一干二净。后面第 3 节我会专门讲 keep-alive 和状态保留的问题。2.2 真正的按需加载defineAsyncComponent参数逐项拆讲当组件数量多、体积大不想一次性打进主包时就需要动态组件加载。Vue 3 官方给的方法是defineAsyncComponent配合import()使用import { defineAsyncComponent } from vue const AsyncChartPanel defineAsyncComponent({ loader: () import(./panels/ChartPanel.vue), loadingComponent: LoadingPanel, delay: 200, timeout: 10000, errorComponent: ErrorPanel, onError(error, retry, fail, attempts) { if (attempts 3 !error.message.includes(timeout)) { retry() } else { fail() } }, })里面几个参数我逐一说下我实际用的理解loader返回 Promise 的函数通常是import()核心的组件代码拆分逻辑就在这里。loadingComponent加载期间展示的占位组件。注意加个delay200 到 300 毫秒内的快速加载不闪加载态避免页面“闪烁空白再突然出现”。timeout超过多少毫秒还没加载完成会切换到错误组件。我一般设 8 到 15 秒根据网络环境调整。errorComponent加载失败或超时展示的组件。这个一定要做不然失败就直接白屏。onError重试回调。它本身是一个有意思的设计loader 失败并不会直接停止而是回调给你由你决定是调retry()重试还是调fail()放弃。可以利用它做三次重试这种方式很适合弱网环境。如果只是快速用一个异步组件也有简写形式但没法配置加载和错误状态const AsyncChartPanel defineAsyncComponent(() import(./panels/ChartPanel.vue))这行代码实际上还引出一个更细的点import()拆出来的 chunk 在构建工具里会按注释命名。webpack 可以这样写const AsyncChartPanel defineAsyncComponent({ loader: () import(/* webpackChunkName: chart-panel */ ./panels/ChartPanel.vue), })Vite 里也可以通过build.rollupOptions.output.manualChunks去控制 chunk 归属。命名和分类可控后面分析加载性能、缓存命中都会省很多事。2.3 和路由懒加载怎么分工别把两者混为一谈很多人觉得路由懒加载已经做了动态组件加载是不是就不需要在组件里再拆异步了。其实两个粒度不一样。对比项路由懒加载动态组件加载粒度页面级别页面内的模块级别触发时机进入路由时组件渲染时典型场景每个路由页面单独打包页内 Tab、弹窗、插槽按需渲染chunk 数量控制粗粒度细粒度一个后台页面里可能要切换五六种图表面板这些面板不会路由分割它们共享同一个路由。如果全量打进主包即使这个页面一年也用不了几次图表代码还是躺在那里。我通常的做法是先做路由懒加载页面代码单独拆包进入页面之后再对页面内部的大型组件做动态组件加载。两个策略叠加主包才真正瘦下来。3. 实操做一个可配置的动态组件加载容器3.1 需求设定主应用不感知新增面板我有一个客户数据的控制台页面需要渲染多种数据面板。但面板类型会持续增加今天是折线图明天是散点图后天是原始数据表。我不可能每次加面板都去改页面模板更合理的方案是维护一份面板配置表页面中有个通用容器根据配置里的“组件路径”或“组件名”动态加载并渲染对应面板。这个思路就是插件化的雏形。所有面板都被异步加载成独立 chunk使用哪个就加载哪个。3.2 第一步配置表与动态 import 映射面板配置可以放在一个独立文件中核心是组件路径与名称的映射// panelRegistry.js const panelRegistry { line: () import(./panels/LineChartPanel.vue), scatter: () import(./panels/ScatterChartPanel.vue), table: () import(./panels/DataTablePanel.vue), form: () import(./panels/FilterFormPanel.vue), } export function getPanelComponent(type) { if (!panelRegistry[type]) { return null } return panelRegistry[type] }有人会问为什么不用全局组件名而是用路径映射这里有个很容易踩的坑全局注册的异步组件必须提前注册而且组件名如果不规范运行时才报错而路径映射写在配置表里配合 Vite 的静态分析构建时能找到所有入口打包成 chunk路径密钥不错加载可靠性更高。如果面板数量非常多手工配置表维护成本上升可以用 Vite 的import.meta.glob自动扫描目录const panels import.meta.glob(./panels/*.vue) // 返回{ ./panels/LineChartPanel.vue: () import(./panels/LineChartPanel.vue) }这样模块一多新面板只需要放进panels目录剩下的就是根据路径动态取模块的问题。Vue 单文件组件路径解析时最好用一个统一前缀做约束比如./panels/避免混入其他目录的无关文件。3.3 第二步封装通用异步容器组件有了映射关系就可以写一个通用容器DynamicPanel.vue它接收一个type属性内部根据当前类型动态生成异步组件对象script setup import { computed } from vue import { defineAsyncComponent } from vue import LoadingBox from ./LoadingBox.vue import ErrorBox from ./ErrorBox.vue import { getPanelComponent } from ./panelRegistry const props defineProps({ type: { type: String, required: true, }, panelProps: { type: Object, default: () ({}), }, }) const currentComponent computed(() { const loader getPanelComponent(props.type) if (!loader) return null return defineAsyncComponent({ loader, loadingComponent: LoadingBox, delay: 200, timeout: 10000, errorComponent: ErrorBox, onError(error, retry, fail, attempts) { if (attempts 2) { retry() } else { fail() } }, }) }) /script template component :iscurrentComponent v-bindpanelProps / /template这里有两个细节值得说明。一是每次type变化时computed重新计算得到一个新的异步组件对象component标签就会销毁旧组件、渲染新组件。如果你的面板内部有定时器、监听器等副作用离开时记得清理。二是v-bindpanelProps可以把外层配置一次性传给真正的面板组件。这样父页面完全不用关心面板内部需要哪些 props所有传参都收敛到panelProps这一个入口。面板内部如果还想往外面发事件可以通过emit或者直接在panelProps里传一个listeners对象再手动挂到子组件上不过这样容易搅乱响应式绑定我建议只传数据事件还是走监听在容器上通过defineEmits转发。3.4 第三步页面里的用法与配置驱动渲染最终页面里面板不再被显式引用渲染逻辑全部由配置驱动template div classdashboard DynamicPanel v-for(item, index) in layoutConfig :keyitem.id :typeitem.type :panel-props{ data: item.data, extra: item.extra } classdashboard-item / /div /templatelayoutConfig可能是本地二维布局也可能来自后端接口甚至编辑器自行拖拽生成。这意味着同一个渲染容器既能支撑固定布局也能支撑在线配置更新。我在这块实测下来的体会是真正的复杂度不在渲染而在“配置项在组件之间的传递规则”。建议一开始就把panelProps的数据结构定义清楚比如{ data: {}, options: {}, handlers: {} }避免后续每个插件自己发明一套传参格式。另外加载状态不能只靠LoadingBox转圈圈。如果是首屏就需要的面板建议放在onMounted之后预取一次也就是在容器挂载时手动执行一次import()让浏览器提前下载 chunk等用户切换时基本秒开。这叫做“预取”需要考虑在什么时机、何种网络策略下发起。4. 常见问题与排查经验实录4.1 动态组件切换后状态丢失怎么办动态组件默认不缓存切走了旧组件就会被销毁回来重新创建。第一次用动态组件做多步骤表单的时候我在第二步填了一堆数据切回第一步发现表单全空了当时的排查思路是先怀疑 v-model 绑错了位置最后才发现是组件实例都被销毁重建了。解决方法有三种在component外层包keep-alive缓存组件实例和内部状态把需要保留的状态全部提升到父级通过v-model:form-data或v-bind传下去用defineAsyncComponent加载的组件本身不负责状态共享状态交给 Pinia 等全局状态管理。当动态组件内部含异步加载时keep-alive会缓存已经创建的组件实例。但要注意初始没有完成的组件是不会被缓存的等待期间的加载状态组件也不会。如果并发切换多次会存在多个加载中的实例这是正常现象。4.2 异步加载失败导致的白屏动态组件加载失败不处理页面就空白一块这是最常见的用户体验事故。我排查这种问题的经验先看浏览器网络面板看 chunk 文件是否 404如果没有网络请求再确认loader里的路径是否真的存在最后看控制台有没有“Failed to resolve component”之类告警。路径方面import()用的是相对源码目录的路径不是相对于浏览器的 URL。构建工具会替你解析所以不需要担心最终产物的路径但源码路径写错构建阶段直接报错。还有一点错误组件本身不能是异步的否则错误状态下又触发了新一轮异步加载容易出现死循环式的白屏。超时和重试这块timeout的时间和onError的逻辑要配合好。实际上timeout触发后并不会自动销毁加载中的 Promise超时提示已经弹出后台import()还在等文件这会导致“错误组件已经出现过后又突然跳回正常组件”的诡异现象。建议在onError里用fail()明确结束一个不可恢复的失败流程。4.3 全局组件名和局部组件对象混用的坑开发时图省事把组件全局注册了然后动态组件里直接传组件名字符串app.component(line-chart-panel, LineChartPanel)component :isline-chart-panel /看起来简单问题在于全局注册的组件是静态导入的不管用不用都会打进主包这又回到了全量加载的老路上和动态组件加载的初衷相悖。如果组件必须全局注册尽量配合异步app.component(line-chart-panel, defineAsyncComponent(() import(./panels/LineChartPanel.vue)))这样既是全局按名查找又是异步加载。代价是组件嵌套之间只能用名字通信IDE 的自动补全会失效重构时改名字可能漏改这个成本要提前想清楚。4.4 调试技巧看清每个 chunk 到底什么时候被加载动态组件加载到最后最难的问题是性能调优。组件多了页面首屏只加载了主包切换面板时才开始加载子包我们需要知道每个包多大、什么时候被触发、加载多久。Chrome DevTools 的 Network 面板可以按 JS 类型筛选观察 chunk 的发起时间比页面交互时间晚多久。加了/* webpackChunkName: xxx */后chunk 文件名可读性高很多但定位“这个 chunk 对应哪个组件”还是不够直观。我的做法是给每个异步面板在加载完成时打一条日志记录加载耗时const startTime performance.now() loader().then(() { console.debug([DynamicPanel] ${props.type} loaded in ${performance.now() - startTime}ms) })在开发环境看面板切换时的日志能快速发现哪个面板加载异常慢。生产环境这套日志通常要移除或者通过import.meta.env.DEV控制输出。Performance 面板里缩窄到 Interaction 事件范围能看到用户点击后主线程空闲时间如果 chunk 太大导致主线程卡顿这就是动态组件加载导致的交互延迟断点会很明显。扫码类工具、大图表库、富文本组件这类重库适合单独拆成粗粒度 chunk避免一个页面里动态加载了多个重型库。调试工具不只是 DevTools构建产物的可视化分析工具也可以搭配用。Vite 项目用rollup-plugin-visualizerwebpack 项目有webpack-bundle-analyzer看打包后 chunk 分布能直观发现哪些组件被错误合并进主包、哪些异步组件没有被独立拆出。4.5 递归组件也能用动态组件吗动态组件还有一个被低估的用法递归渲染。拿树形菜单来说一个节点组件内部要渲染子节点子节点的类型可能和父节点一模一样也可能根据节点类型变化。以前写递归组件需要在组件内部显式引用自身挺绕的用动态组件时可以在组件内部放一个component :isgetNodeComponent(node) /然后返回自身或其他节点组件。这样树的节点类型就能和实际组件逻辑解耦。在我做过的一个流程编排工具里开始节点、结束节点、判断节点、执行节点各自不同但都是通过同一套递归结构串起来的。递归这块另一个值得注意的点是import()加载的异步组件如果内部又递归异步加载自己存在循环依赖风险构建时通常能检测但运行时表现诡异比如一层渲染完成后下一层突然报错。遇到这种情况把递归子节点拆成同目录下的独立文件用import.meta.glob统一加载能避免大部分循环引用排查的麻烦。写在最后的一点个人心得动态组件用到现在我最大的感受是它不只是一个 API而是一种设计思路的转变。我过去写组件总是先把所有可能性 import 进来然后在模板里穷举动态组件的思路让我反过来先问“这个组件在什么条件下、什么时刻才真正需要”然后让条件与时刻决定它何时出现、何时加载。后面做低代码平台时这套思想成了底层基础设施的基石表单里渲染什么控件、画布里渲染什么节点、页面里渲染什么数据块全部由配置表驱动主应用代码基本只认识一个component :is。我踩过的坑里最值得说的还是那句话能准确描述“哪个组件在哪个时机渲染”比会写一百个组件还重要。把组件切换逻辑收敛到配置和容器里主页面会变得非常清爽后续加面板、做权限、做灰度都方便。动态组件加载的进阶玩法还有很多比如配合服务端下发组件配置、组件版本控制、异常上报不过这都是后话了。真正该做的是从手边第一组重复的v-if开始试着把它换成一张配置表和一个干净的容器组件你也会很快体会到这套写法的好处。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑