把组件改成动态加载,页面却只输出一行 [object Promise],而且控制台一个报错都没有——这是从 Vue 2 迁移到 Vue 3 时相当典型的一个坑。原因不在打包器,也不在 import() 本身,而是 Vue 3 移除了一项旧的自动识别机制:裸的工厂函数不再被当作异步组件,而是被当成函数组件同步执行。本文沿着运行时源码把整条链路拆开,给出 defineAsyncComponent 的正确写法、完整配置,以及什么时候根本不该用异步组件。
vue.esm-browser.js 3.5.x 逐行验证),纯前端项目、原生 ESM 项目与 Vite / webpack 打包器项目均适用。文中结论针对运行时行为,与具体业务代码无关。一、问题现象
很多从 Vue 2 迁移过来的开发者会照搬原来的异步组件写法:把返回 import() 的工厂函数直接放进 components 选项。
export default {
components: {
MyComp: () => import('./MyComp.vue'), // ❌ Vue 3 中不再被识别为异步组件
},
template: `<MyComp />`,
}
运行后不会抛错,但页面上会渲染出这样一行纯文本:
[object Promise]
换成静态导入则一切正常:
import MyComp from './MyComp.vue'; // ✅ 正常
export default {
components: { MyComp },
template: `<MyComp />`,
}
二、根本原因:Vue 3 的破坏性变更
Vue 2 会把 components: { xxx: () => import('./x.vue') } 这种工厂函数自动识别为异步组件,内部走 Vue.component 的异步处理逻辑。Vue 3 移除了这项自动识别:动态加载必须显式调用 defineAsyncComponent() 包装,否则工厂函数会被当成函数组件(Functional Component)同步执行。
| 对比项 | Vue 2 | Vue 3 |
|---|---|---|
() => import() 放进 components | 自动识别为异步组件 | 识别为函数组件 |
| 动态加载的正确入口 | 工厂函数即可 | 必须 defineAsyncComponent() 包装 |
| 异步组件的运行时标记 | 内部异步分支 | 包装对象上的 __asyncLoader |
| 出错时的表现 | — | 静默渲染 [object Promise] |
import() 语法不被支持。Vue 3 只是不再「猜」你的意图,把类型判断交给了显式的包装函数。三、[object Promise] 的产生链条
以 Vue 官方发行版源码 vue.esm-browser.js 为准,从 VNode 创建到文本渲染,整条链路可以逐行验证。
第 1 步:函数被识别为函数组件,而不是异步组件
createVNode 依据 type 的形态给 VNode 打上 shapeFlag:对象是状态组件(4),函数是函数组件(2)。
// shapeFlag 4 = STATEFUL_COMPONENT,2 = FUNCTIONAL_COMPONENT
const shapeFlag = isString(type) ? 1 : isSuspense(type) ? 128
: isTeleport(type) ? 64 : isObject(type) ? 4
: isFunction(type) ? 2 : 0;
而 Vue 3 判断「是不是异步组件」的唯一依据是包装对象上的 __asyncLoader 标记:
export const isAsyncWrapper = (i) => !!(i.type as ComponentOptions).__asyncLoader
只有经过 defineAsyncComponent() 包装、带有 __asyncLoader 的组件对象才走异步加载流程。裸函数没有这个标记,于是落入 shapeFlag 2。
第 2 步:函数组件被同步调用,返回 Promise
renderComponentRoot 的函数组件分支会把组件函数直接当 render 函数调用。注意这里是同步调用,没有任何 await:
} else {
const render2 = Component; // () => import(...) 被同步执行
result = normalizeVNode(render2(props, null));
}
() => import('./MyComp.vue') 同步执行后返回的是一个 Promise,而不是 VNode。
第 3 步:Promise 不是 VNode,被兜底转成文本节点
normalizeVNode 会依次尝试注释、Fragment、VNode 三种情况,都不匹配时走 fallback,把值 String() 后塞进 Text 节点:
export function normalizeVNode(child) {
if (child == null || typeof child === 'boolean') {
return createVNode(Comment)
} else if (isArray(child)) {
return createVNode(Fragment, null, child.slice())
} else if (isVNode(child)) {
return cloneIfMounted(child)
} else {
// String(Promise) === "[object Promise]"
return createVNode(Text, null, String(child))
}
}
Promise 无法匹配任何 VNode 类型,String(Promise) 得到 [object Promise],最终作为文本节点渲染到页面。
这也解释了为什么不报错:整条路径都是合法的渲染流程,只是语义完全不是开发者想要的。
—— 静默失败往往比抛异常更难排查
四、正确写法:defineAsyncComponent
把工厂函数交给 defineAsyncComponent() 即可。它接收一个返回 Promise 的 loader(或配置对象),返回一个包装组件:
import { defineAsyncComponent } from 'vue';
export default {
components: {
MyComp: defineAsyncComponent(() => import('./MyComp.vue')), // ✅
},
template: `<MyComp />`,
}
另外两种常见注册方式
在 <script setup> 里直接定义,或用 app.component() 全局注册,写法同样成立:
<script setup>
import { defineAsyncComponent } from 'vue'
const AdminPage = defineAsyncComponent(() => import('./AdminPage.vue'))
</script>
<template>
<AdminPage />
</template>
// 全局注册(main.js)
app.component('MyComponent', defineAsyncComponent(() =>
import('./components/MyComponent.vue')
))
包装组件做了什么
- 返回名为
AsyncComponentWrapper的对象,带__asyncLoader: load标记,shapeFlag 为4,走正常组件渲染流程 - 只有真正渲染到页面时才调用
load()执行import(),resolve 后挂载真实组件,并把 props / slots 透传给内部组件 - 自动解包原生 ESM 模块:源码中检测到
comp.__esModule或Symbol.toStringTag === 'Module'时自动取.default
完整配置:延迟加载 + 占位 + 超时 + 重试
import { defineAsyncComponent } from 'vue';
const MyComp = defineAsyncComponent({
loader: () => import('./MyComp.vue'),
delay: 200, // 200ms 后才显示 loading,避免快速加载时闪烁
timeout: 10000, // 超时后显示 errorComponent
loadingComponent: LoadingSpinner,
errorComponent: ErrorBox,
onError(err, retry, fail, attempts) {
if (attempts <= 3) retry(); // 失败自动重试
else fail();
},
});
delay 默认 200ms,它是「loading 组件显示前」的等待时间,不是组件的加载耗时,也不是空白期长度;没配 loadingComponent 就没有占位。timeout 默认 undefined(永不超时),只有显式设置才会触发超时错误。onError 的第四个参数 attempts 从 1 开始计数(源码传的是 retries + 1)。suspensible 默认 true,因此异步组件默认受 <Suspense> 控制。五、静态导入 vs 异步组件:如何选
| 维度 | 静态 import | defineAsyncComponent |
|---|---|---|
| 加载时机 | 模块解析阶段,浏览器并行预取依赖图 | 组件渲染时才发请求,串行滞后 |
| 渲染体验 | 一次到位 | resolve 前为空;配了 loadingComponent 且超过 delay 才有占位 |
| 代码复杂度 | 一行 | 多一层包装 + 加载/错误状态处理 |
| 核心价值 | 解决依赖加载 | 解决延迟加载 + 打包器 chunk 分割 |
静态导入解决的是「依赖加载」,异步组件解决的是「延迟加载」。需要的是前者时,不要用后者。
用静态导入(大多数场景)
- 组件无条件全量渲染(没有
v-if/ tab / 路由切换),马上就要用 - 原生 ESM 项目(无打包器):静态导入能利用浏览器并行拉取整棵依赖图,动态
import()反而把并行拆成串行 - 本地工具 / 组件体量小,加载成本趋近于零
用 defineAsyncComponent
- 组件是条件渲染的(tab 页、路由页、弹窗内容),且大概率不显示
- 组件引入重型依赖(图表库、富文本、Monaco 等),需要减小首屏体积
- 打包器项目需要真正的代码分割:每条
import()会成为一个独立 chunk - 需要对慢加载组件做 loading / error 占位与失败重试
defineAsyncComponent,在不渲染、无打包器、体量小的场景下只会增加请求串行度与状态复杂度,没有任何收益。六、常见误区清单
| 误区 | 真相 | 处理 |
|---|---|---|
从 Vue 2 平移写法,() => import() 直接放进 components | Vue 3 不再自动识别,会被当函数组件 | 一律用 defineAsyncComponent() 包装 |
| 以为写错了会报错 | 实际是静默渲染出 [object Promise] 文本 | 看到这行字直接去查组件注册方式 |
| 所有组件都异步化 | 不渲染、无打包器、体量小时是纯负优化 | 按上一节的选型原则做减法 |
手动实现 loader 时漏掉 .default | 原生 ESM 模块需要取默认导出 | 交给 defineAsyncComponent 自动解包,别手写 |
| 把路由懒加载和组件注册混为一谈 | Vue Router 会自己处理返回 Promise 的 loader | 路由配置里 component: () => import() 是合法的,不必再包一层;出错的是 components: {} 注册 |
[object Promise] 时,先搜所有 components 注册与 defineComponent 调用 → 再看该组件是不是被写成返回 import() 的裸函数 → 最后确认是否漏了 defineAsyncComponent。绝大多数情况到第二步就能收网。总结
这个坑的本质是类型判断方式的变更:Vue 2 靠「工厂函数」这个形态去猜,Vue 3 靠 __asyncLoader 这个显式标记。少了包装,函数组件分支就把 Promise 当渲染结果,normalizeVNode 再把它转成文本——全程合法、全程静默。修法只有一行,但真正要记住的是选型:依赖加载用静态导入,延迟加载才用异步组件。
看见 [object Promise],先别怀疑打包器,去查组件有没有被 defineAsyncComponent 包装。
参考资料:Vue 3 官方文档「异步组件」与「Global API - defineAsyncComponent」;运行时结论基于 vuejs/core 的 runtime-core/src/apiAsyncComponent.ts、vnode.ts、createVNode.ts 源码核对(3.5.x)。