把组件改成动态加载,页面却只输出一行 [object Promise],而且控制台一个报错都没有——这是从 Vue 2 迁移到 Vue 3 时相当典型的一个坑。原因不在打包器,也不在 import() 本身,而是 Vue 3 移除了一项旧的自动识别机制:裸的工厂函数不再被当作异步组件,而是被当成函数组件同步执行。本文沿着运行时源码把整条链路拆开,给出 defineAsyncComponent 的正确写法、完整配置,以及什么时候根本不该用异步组件。

ℹ️
适用范围
Vue 3.x(源码结论基于 vue.esm-browser.js 3.5.x 逐行验证),纯前端项目、原生 ESM 项目与 Vite / webpack 打包器项目均适用。文中结论针对运行时行为,与具体业务代码无关。

一、问题现象

很多从 Vue 2 迁移过来的开发者会照搬原来的异步组件写法:把返回 import() 的工厂函数直接放进 components 选项。

javascript 错误写法(Vue 3)
export default {
  components: {
    MyComp: () => import('./MyComp.vue'),   // ❌ Vue 3 中不再被识别为异步组件
  },
  template: `<MyComp />`,
}

运行后不会抛错,但页面上会渲染出这样一行纯文本:

text 页面输出
[object Promise]

换成静态导入则一切正常:

javascript 静态导入(正常)
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 2Vue 3
() => import() 放进 components自动识别为异步组件识别为函数组件
动态加载的正确入口工厂函数即可必须 defineAsyncComponent() 包装
异步组件的运行时标记内部异步分支包装对象上的 __asyncLoader
出错时的表现—静默渲染 [object Promise]
⚠️ 注意:这不是配置问题,也不是 import() 语法不被支持。Vue 3 只是不再「猜」你的意图,把类型判断交给了显式的包装函数。

三、[object Promise] 的产生链条

以 Vue 官方发行版源码 vue.esm-browser.js 为准,从 VNode 创建到文本渲染,整条链路可以逐行验证。

从工厂函数到一行文本
components 里传入裸函数
↓
无 __asyncLoader → shapeFlag 2(函数组件)
↓
函数被同步调用,返回 Promise
↓
normalizeVNode 兜底:String(Promise)
↓
Text 节点输出 [object Promise]

第 1 步:函数被识别为函数组件,而不是异步组件

createVNode 依据 type 的形态给 VNode 打上 shapeFlag:对象是状态组件(4),函数是函数组件(2)。

javascript runtime-core / createVNode
// 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 标记:

javascript runtime-core / apiAsyncComponent
export const isAsyncWrapper = (i) => !!(i.type as ComponentOptions).__asyncLoader

只有经过 defineAsyncComponent() 包装、带有 __asyncLoader 的组件对象才走异步加载流程。裸函数没有这个标记,于是落入 shapeFlag 2。

第 2 步:函数组件被同步调用,返回 Promise

renderComponentRoot 的函数组件分支会把组件函数直接当 render 函数调用。注意这里是同步调用,没有任何 await:

javascript runtime-core / componentRenderUtils
} else {
  const render2 = Component;   // () => import(...) 被同步执行
  result = normalizeVNode(render2(props, null));
}

() => import('./MyComp.vue') 同步执行后返回的是一个 Promise,而不是 VNode。

第 3 步:Promise 不是 VNode,被兜底转成文本节点

normalizeVNode 会依次尝试注释、Fragment、VNode 三种情况,都不匹配时走 fallback,把值 String() 后塞进 Text 节点:

javascript runtime-core / vnode
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(或配置对象),返回一个包装组件:

javascript Options API 局部注册
import { defineAsyncComponent } from 'vue';

export default {
  components: {
    MyComp: defineAsyncComponent(() => import('./MyComp.vue')),  // ✅
  },
  template: `<MyComp />`,
}

另外两种常见注册方式

在 <script setup> 里直接定义,或用 app.component() 全局注册,写法同样成立:

vue script setup / 全局注册
<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

完整配置:延迟加载 + 占位 + 超时 + 重试

javascript defineAsyncComponent 高级配置
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 异步组件:如何选

维度静态 importdefineAsyncComponent
加载时机模块解析阶段,浏览器并行预取依赖图组件渲染时才发请求,串行滞后
渲染体验一次到位resolve 前为空;配了 loadingComponent 且超过 delay 才有占位
代码复杂度一行多一层包装 + 加载/错误状态处理
核心价值解决依赖加载解决延迟加载 + 打包器 chunk 分割
静态导入解决的是「依赖加载」,异步组件解决的是「延迟加载」。需要的是前者时,不要用后者。

用静态导入(大多数场景)

  • 组件无条件全量渲染(没有 v-if / tab / 路由切换),马上就要用
  • 原生 ESM 项目(无打包器):静态导入能利用浏览器并行拉取整棵依赖图,动态 import() 反而把并行拆成串行
  • 本地工具 / 组件体量小,加载成本趋近于零

用 defineAsyncComponent

  • 组件是条件渲染的(tab 页、路由页、弹窗内容),且大概率不显示
  • 组件引入重型依赖(图表库、富文本、Monaco 等),需要减小首屏体积
  • 打包器项目需要真正的代码分割:每条 import() 会成为一个独立 chunk
  • 需要对慢加载组件做 loading / error 占位与失败重试
⚠️ 负优化提醒:把所有组件无差别包一层 defineAsyncComponent,在不渲染、无打包器、体量小的场景下只会增加请求串行度与状态复杂度,没有任何收益。

六、常见误区清单

误区真相处理
从 Vue 2 平移写法,() => import() 直接放进 componentsVue 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)。