1. 这不是题库是前端团队筛选“真 Vue 开发者”的筛子2024年我参与了17场前端岗位终面其中12场由我主面。每次打开候选人简历看到“熟练掌握 Vue”这行字我心里都默默划个问号——不是怀疑能力而是知道这句话背后可能藏着三种人一种是能用 Vue 写出可维护、可扩展、可调试的业务系统的工程师一种是能跑通 demo、调通 API、改好样式但遇到响应式失效或组件通信卡壳就查文档半小时的熟练工还有一种是把 Vue 官网教程抄三遍、把面试题背五轮、一问“为什么”就眼神飘忽的应试者。而今年所有被录用的候选人无一例外在被问到“Vue 3 的响应式系统为什么不用 Object.defineProperty”时能立刻画出 Proxy 的拦截逻辑图并顺手写出一个最小化 reactive 实现在被问到“router.push 后页面没更新你第一步排查什么”时不翻文档、不 Google直接说“先看是否用了 keep-alive name 冲突再查路由守卫里有没有 next() 漏写最后确认组件是否被缓存导致 setup() 没重执行。”——这才是“高频面试题”真正的筛选逻辑它不考你会不会背答案而考你有没有把 Vue 当成一个可拆解、可调试、可干预的运行时系统来理解。关键词Vue、前端、面试题表面看是知识罗列实则是一套隐性能力评估体系。它覆盖的不是“会不会用”而是“能不能控”能否控制响应式触发时机、能否控制组件生命周期节奏、能否控制虚拟 DOM diff 策略、能否控制打包产物结构。比如“Vue 3 的 Composition API 相比 Options API 优势在哪”这个问题90% 的回答会停留在“逻辑复用更方便”“TS 支持更好”这种表层但真正拉开差距的是那个补了一句“因为 setup() 执行时机早于 beforeCreate且返回的 ref/reactive 对象天然具备响应式代理链路避免了 Options API 中 data() 返回对象需经 defineProperty 逐层劫持的性能损耗和递归陷阱”的候选人。这就是“高频”背后的真相它高频是因为高频出现在真实协作场景中——当你的组件在生产环境偶发丢失响应、当你的 pinia store 在 SSR 下状态错乱、当你的 vite 构建产物体积突然暴涨 30%你调用的每一个 API、写的每一行代码都在重复这些“面试题”所指向的底层机制。所以这篇内容不叫“Vue 面试题汇总”它叫《Vue 运行时解剖手册》。我们不按“选择题/简答题/编程题”分类而是按 Vue 应用从启动、渲染、交互到销毁的完整生命周期切片把每一道高频题还原成一个真实故障现场、一次调试过程、一段可验证的代码实验。你不需要背答案你需要建立一套自己的 Vue 调试心智模型——就像老司机听发动机声音就能判断故障点资深 Vue 开发者看一眼控制台 warning 就能定位响应式断点。2. 启动阶段从 createApp() 到首屏渲染那些被忽略的初始化链路2.1 createApp() 不是起点是“运行时上下文”的注册入口几乎所有面试都会问“Vue 2 和 Vue 3 的入口函数区别”标准答案是“new Vue() → createApp()”。但这个答案只答对了 10%。真正关键的是createApp() 返回的 app 实例本质是一个运行时上下文容器Runtime Context它不直接创建应用而是为后续所有插件、组件、指令、配置项提供统一的注册与注入通道。我们来看一段极易被忽略的源码级事实// node_modules/vue/runtime-core/dist/runtime-core.cjs.js (简化) function createAppAPI(render) { return function createApp(rootComponent, rootProps null) { // 1. 创建应用上下文 const app { _component: rootComponent, _props: rootProps, _container: null, _context: { app: app, config: createAppConfig(), // ← 关键config 是独立对象 components: new Map(), directives: new Map(), mixins: [], provides: new Map() }, use(plugin, ...options) { /* 注册插件 */ }, mount(container) { /* 渲染入口 */ } } return app } }注意_context.config是一个独立对象而非全局共享。这意味着每个 app 实例拥有完全隔离的配置空间。这也是为什么你可以同时存在多个 Vue 应用如微前端场景它们的 globalProperties、errorHandler、compilerOptions 互不影响。而 Vue 2 的Vue.config是全局单例修改一处全站生效——这正是 Vue 3 解决的架构痛点。提示当面试官问“如何实现多实例共存”别只答“createApp() 多次调用”要指出app.config的隔离性设计以及app.use()插件注册的局部作用域特性。这是判断你是否看过源码的关键信号。2.2 mount() 执行前的三道隐形关卡模板编译、依赖收集、DOM 准备很多人以为app.mount(#app)就是渲染开始其实中间埋着三条关键链路第一关模板编译Template Compilation如果你用的是.vue单文件组件SFC 编译器会在构建时将template编译为render函数但如果你用的是字符串模板如app.component(xxx, { template: div{{ msg }}/div })Vue 会在mount()时动态调用compile()进行运行时编译。这个过程消耗 CPU且无法被 tree-shaking —— 这就是为什么 Vue 官方强烈建议禁用运行时编译器runtime-onlybuild并用vite-plugin-vue在构建时完成所有编译。第二关响应式初始化Reactivity Initializationmount()前Vue 会遍历rootComponent的setup()函数执行其中的ref()、reactive()、computed()等调用建立初始响应式依赖图。这里有个经典陷阱export default { setup() { const count ref(0) // ❌ 错误在 setup 中直接调用异步函数其内部的 ref 赋值不会触发响应式 setTimeout(() { count.value 1 // ✅ 此时 count 已被 track能触发更新 }, 100) return { count } } }原因在于setTimeout回调执行时setup()已结束count的effect已被收集完毕赋值能触发更新但如果换成await fetch().then(...)且fetch返回 Promisethen回调中的赋值就可能因effect未激活而失效——这引出了onBeforeMount的必要性。第三关DOM 容器校验Container Validationmount()会严格检查传入的container是否为有效 DOM 元素且不能是html或body标签Vue 3 明确禁止挂载到根节点。更隐蔽的是如果容器内已有 HTML 内容Vue 默认会执行“水合Hydration”而非重新渲染。这意味着 SSR 生成的 HTML 必须与客户端虚拟 DOM 完全一致否则会触发 Hydration mismatch warning 并降级为客户端重绘——这正是“Vue 打包后布局异常”的常见根源之一。注意当被问到“mount() 做了什么”不要只答“挂载到 DOM”必须拆解为“编译模板→初始化响应式→校验容器→执行水合或首次渲染”四步。我曾见过候选人因答不出“水合”概念直接被判定缺乏 SSR 实战经验。2.3 全局 API 的陷阱app.config.globalProperties vs app.provideVue 3 废弃了Vue.prototype改用app.config.globalProperties注入全局属性。但很多人不知道它与app.provide()存在本质差异特性app.config.globalPropertiesapp.provide()作用域全局所有组件实例均可访问类似 Vue 2 的 $xxx仅限当前 app 实例及其子组件支持跨层级 inject类型安全TS 中需手动声明declare module vue/runtime-coreTS 自动推导无需额外声明覆盖行为后注册覆盖先注册如多次app.config.globalProperties.$http axios1同名 key 会报错provide 重复 key 抛异常适用场景全局工具方法$message, $confirm依赖注入Router, Store, i18n实际项目中我坚持用app.provide()注入 Pinia store 和 Router而只用globalProperties注入 UI 组件库的$message。因为前者需要类型安全和明确依赖关系后者只需快速调用。这个选择背后是对“全局污染”与“依赖显式化”的权衡——而面试官想听到的正是这种权衡逻辑。3. 响应式核心Proxy 的拦截边界与 effect 的调度艺术3.1 Proxy 不是万能的它无法拦截哪些操作为什么“Vue 3 用 Proxy 替代 Object.defineProperty”是必问题但高阶追问永远是“Proxy 拦截不了什么Vue 怎么补足” 这直接检验你是否真正理解响应式原理。Proxy 的三大不可拦截操作属性存在性检测in 操作符const obj reactive({ a: 1 }) console.log(a in obj) // true但 Proxy 无法拦截 in 操作 // Vue 的补丁重写了 has() handler并在 reactive 包装时添加 __v_isRef 标识for...in 循环for (let key in obj) { /* key 不会被 track */ } // Vue 的补丁重写 ownKeys() handler返回所有 key并在 get() 中标记迭代Object.keys() / Object.getOwnPropertyNames()这些静态方法绕过 Proxy直接读取原始对象。Vue 的解决方案是在 reactive 包装时将原始对象冻结Object.freeze并劫持这些静态方法的返回值。源码中可见// node_modules/vue/reactivity/dist/reactivity.cjs.js function createReactiveObject(target, toProxy, toRaw, handlers, shallow) { if (target[ReactiveFlags.RAW]) { return target } // ⬇️ 关键为防止 Object.keys() 绕过Vue 在 reactive 对象上定义了 Symbol.iterator const proxy new Proxy(target, handlers) target[ReactiveFlags.RAW] proxy proxy[ReactiveFlags.IS_REACTIVE] true return proxy }但最致命的遗漏是Proxy 无法监听原型链上的属性变化。例如const parent reactive({ p: 1 }) const child reactive({ __proto__: parent }) // ❌ child.p 不会响应式Vue 3 的解决方案是禁止设置__proto__并在开发模式下警告。这解释了为什么你在 Vue 项目中几乎没见过Object.setPrototypeOf()的使用——它被框架主动规避了。实操心得当发现某个属性修改不触发更新第一反应不该是“是不是没用 ref”而应检查1. 是否在 Proxy 无法拦截的操作中如in判断2. 是否修改了原型链属性3. 是否在setup()外部直接修改了 reactive 对象此时 effect 未激活。3.2 effect() 的调度策略为什么有时更新延迟如何强制同步effect()是响应式系统的引擎但它的默认调度是异步微任务microtask。这意味着const count ref(0) effect(() { console.log(count changed:, count.value) }) count.value // 输出延迟到下一个 tick console.log(after increment) // 先输出输出顺序是after increment count changed: 1这是因为trigger()触发更新时会将runnereffect 执行函数推入queue然后在nextTick()的微任务中批量执行。这种设计带来两大好处1. 避免重复计算同一响应式对象多次修改只触发一次 effect2. 保证 DOM 更新与数据更新的原子性所有 effect 执行完再统一 patch。但某些场景需要同步执行比如表单校验用户输入后需立即反馈错误不能等下一个 tick动画帧同步requestAnimationFrame 中需精确控制状态变更时机。Vue 提供了flush: sync选项effect(() { validateForm() }, { flush: sync }) // 立即执行不进队列更底层的控制是scheduler函数effect(() { // ... }, { scheduler: (job) { // 自定义调度立即执行、加防抖、或推入 requestIdleCallback job() } })我在线上项目中用scheduler实现了一个防抖校验effect(() { if (form.dirty) validate() }, { scheduler: debounce((job) job(), 300) })这比在watch中写setTimeout更优雅因为effect的依赖追踪是自动的而watch需手动指定依赖源。3.3 ref 与 reactive 的本质区别不是语法糖是响应式粒度的设计哲学很多教程说 “ref 用于基本类型reactive 用于对象”这严重误导。真相是ref()创建的是一个带 .value 包装的对象其本质是ReactiveRef类实例内部包含dep依赖集合和__v_isRef: true标识reactive()创建的是一个Proxy 代理对象直接拦截属性访问。关键差异在于响应式穿透Reactivity Inheritanceconst obj reactive({ a: 1 }) const arr reactive([obj]) console.log(arr[0].a) // ✅ 响应式穿透修改 obj.a 会触发 arr 的更新 const r ref({ a: 1 }) const arr2 reactive([r]) console.log(arr2[0].value.a) // ❌ 需要 .value且 arr2[0] 本身不是 reactiveref的设计初衷是解决模板中访问响应式变量的语法一致性。在template中{{ count }}和{{ obj.a }}无需区分ref.value或reactive.aVue 编译器会自动解包ref。而reactive对象在模板中直接使用属性名即可。但更重要的是ref 支持跨组件传递并保持响应式。这是reactive做不到的// Parent.vue const count ref(0) Child :countcount / // Child.vue defineProps({ count: { type: Object as PropTypeRefnumber } // ✅ 类型安全 }) // 子组件中修改 count.value父组件立即响应而如果传reactive({ count: 0 })子组件需props.count.count 1且类型声明复杂易出错。因此ref是 Vue 为解决“响应式数据跨组件流动”这一核心问题而设计的基础设施远不止“包装基本类型”那么简单。4. 组件通信从 props/emits 到 provide/inject一条渐进式信任链4.1 props 的深层约束为什么 required: true 仍可能 undefinedprops: { msg: { type: String, required: true } }看似万无一失但线上仍常出现Cannot read property xxx of undefined。原因有三第一父组件传值时机晚于子组件初始化!-- Parent.vue -- Child :msgdata.msg / script setup const data ref({ msg: }) // 数据在 mounted 后才通过 API 获取 /script此时Child的setup()执行时props.msg为空字符串String 类型默认值但若data.msg是null或undefined且props未设default则props.msg为undefined—— 即使required: trueVue 也只在props解析阶段校验不保证运行时始终有效。第二v-model 的双向绑定陷阱!-- Child.vue -- input v-modelprops.msg / !-- ❌ 错误props 是只读的 -- !-- 正确 -- input :valueprops.msg input$emit(update:msg, $event.target.value) /Vue 3 的v-model编译为:modelValueupdate:modelValue但开发者常误写为直接绑定props.xxx导致set操作失败。第三TS 类型与运行时校验的鸿沟interface Props { msg: string // TS 认为必填 } const props definePropsProps() // 但运行时 props.msg 可能为 undefined因 TS 类型擦除解决方案是双重保障const props defineProps({ msg: { type: String as PropTypestring, required: true, default: // 运行时兜底 } }) // 模板中使用时加空值判断 div v-ifprops.msg{{ props.msg }}/div经验我在三个项目中因props未设default导致白屏最终统一规范所有required: true的 props 必须配default且default值需符合业务语义如id用0name用list用[]。4.2 provide/inject 的信任边界何时该用何时该禁用provide/inject常被批评为“隐式依赖”但它在特定场景不可替代跨多层组件的状态共享如主题色、语言包父组件向子孙组件注入控制权如 Table 组件向 Column 注入排序方法SSR 环境下的上下文透传如 Nuxt 的useRequestEvent()。但它的危险在于inject 的值不是响应式。这是最大误区// Parent.vue const theme ref(dark) provide(theme, theme) // 提供 ref // Child.vue const theme inject(theme) // 接收的是 ref 对象不是值 console.log(theme.value) // ✅ 正确 console.log(theme) // ❌ 是 RefImpl 对象非响应式Vue 官方文档强调provide的值可以是响应式对象但inject返回的是原值。因此正确做法是// 提供时解包 provide(theme, readonly(theme)) // 或 provide(theme, theme) // 使用时保持响应式 const theme inject(theme) if (theme) { watch(theme, (newVal) { /* 响应式监听 */ }) }更安全的实践是提供一个组合式函数Composable// composables/useTheme.js export function provideTheme(themeRef) { provide(theme, { theme: themeRef, setTheme: (val) themeRef.value val }) } export function useTheme() { const themeContext inject(theme) if (!themeContext) throw new Error(useTheme must be used within ThemeProvider) return themeContext }这样既保持类型安全又封装了响应式逻辑。我在大型后台系统中用此模式实现了权限上下文usePermission、国际化上下文useI18n彻底避免了props层层透传的灾难。4.3 Teleport 的真实价值不是“移除 DOM”是“解耦渲染上下文”Teleport to#modal常被理解为“把弹窗 DOM 移到 body 下”这太浅。它的核心价值是将子组件的渲染上下文Rendering Context与挂载位置Mounting Location解耦。这意味着Teleport 内部的组件其this.$parent仍是 Teleport 的父组件而非#modal的父元素Teleport 的v-if控制的是“是否渲染”而非“是否挂载”——即使to目标不存在Teleport 内容仍会创建 VNode 并执行setup()Teleport 支持嵌套且每个 Teleport 有自己的to目标形成多层渲染上下文。实战中我用 Teleport 解决了两个棘手问题问题1Dialog 组件的 z-index 冲突传统 Dialog 用position: fixed但当父容器有transform或overflow: hidden时fixed 定位失效。Teleport 到body后Dialog 脱离父容器 CSS 上下文z-index 全局生效。问题2Canvas 组件的事件捕获某数据可视化项目中Canvas 需监听mousemove事件但父容器有pointer-events: none。将 Canvas 用 Teleport 渲染到body事件捕获不受父容器影响。关键认知Teleport 不是 DOM 操作 API而是 Vue 渲染管线的“上下文切换开关”。当你需要组件脱离当前 CSS/事件/层级上下文时它才是终极方案。5. 路由与状态管理从 router.push 到 pinia.store一次完整的用户旅程追踪5.1 router.push 的三重校验为什么跳转失败却不报错router.push(/user/123)看似简单但失败时控制台常静默。原因在于 Vue Router 的容错设计第一重导航守卫Navigation GuardsbeforeEach、beforeRouteUpdate等守卫中若忘记调用next()或next(false)导航会挂起。但 Vue Router 不抛异常只在开发模式下打印 warning[Vue Router] Uncaught error during route navigation生产环境则静默失败。解决方案是所有守卫必须确保next()被调用且用try/catch包裹异步逻辑router.beforeEach(async (to, from, next) { try { await checkAuth() next() } catch (err) { next(/login) } })第二重路由解析Route Resolution如果目标路径/user/123未在routes中定义Vue Router 会触发router.onError()但默认不处理。需主动监听router.onError((error) { console.error(Route error:, error) // 可上报监控或跳转 404 })第三重组件加载Component Loading动态导入组件失败如网络中断、chunk 404时router.push会 resolve但页面空白。Vue Router 提供component: () import(./User.vue).catch(() NotFound)但更健壮的是用defineAsyncComponentimport { defineAsyncComponent } from vue const User defineAsyncComponent({ loader: () import(./User.vue), loadingComponent: Loading, errorComponent: Error, delay: 200, timeout: 5000 })实操技巧我在所有项目中启用router.isReady().then(() app.mount(#app))确保路由准备就绪后再挂载应用避免router.push在app.mount前调用导致的静默失败。5.2 Pinia 的 store 设计哲学为什么它比 Vuex 更适合组合式 APIPinia 的核心优势不是“更轻量”而是“store 即 composition function”。对比 Vuex维度VuexPinia状态定义state: () ({ count: 0 })需函数返回state: () ({ count: 0 })同 Vuex但支持直接state.count 1Gettergetters: { double: state state.count * 2 }无参数getters: { double: (state) state.count * 2 }支持this访问其他 getterActionactions: { increment({ commit }) { commit(SET_COUNT, 1) } }需 commitactions: { increment() { this.count } }直接修改 state关键突破在于Pinia store 的 actions 可以直接访问this上的 state、getters、其他 actions形成闭包式作用域。这使得逻辑复用变得自然// stores/user.ts export const useUserStore defineStore(user, () { const token refstring() const profile refUser | null(null) const login async (cred: Credentials) { const res await api.login(cred) token.value res.token profile.value res.profile } const logout () { token.value profile.value null } // ✅ 直接复用无需额外参数 const isLogin computed(() !!token.value) return { token, profile, login, logout, isLogin } })而 Vuex 中actions与mutations分离mutations只能同步修改actions需 dispatch导致逻辑割裂。Pinia 的设计让 store 成为一个自包含的“业务逻辑单元”这正是 Composition API 所倡导的“逻辑关注点分离”。5.3 SSR 与 CSR 的状态同步为什么 store.state 在服务端和客户端不一致这是 Vue 3 Pinia SSR 项目的头号难题。典型现象服务端渲染的页面客户端 hydration 后状态重置。根本原因服务端创建的 store 实例与客户端新创建的 store 实例内存隔离。服务端 store 的 state 不会自动同步到客户端。解决方案分三步1. 服务端序列化 state// server-entry.ts import { renderToString } from vue/server-renderer import { createStore } from vuex // 或 pinia import App from ./App.vue export async function render(url) { const store createPinia() const app createSSRApp(App) app.use(store) // 预取数据 await store.dispatch(user/fetchProfile) const html await renderToString(app) const state JSON.stringify(store.state.value) // 序列化 return { html, state // 传给模板 } }2. 客户端注入 state!-- template.html -- scriptwindow.__INITIAL_STATE__ {{ state }}/script// client-entry.ts const store createPinia() // 从 window 注入初始状态 if (window.__INITIAL_STATE__) { store.state.value JSON.parse(window.__INITIAL_STATE__) }3. 防止客户端重复请求// stores/user.ts export const useUserStore defineStore(user, () { const profile refUser | null(null) const fetchProfile async () { // SSR 环境下服务端已请求客户端跳过 if (import.meta.env.SSR profile.value) return profile.value await api.getProfile() } return { profile, fetchProfile } })这套机制称为“状态水合State Hydration”它是 SSR 项目稳定性的基石。我在一个日活百万的电商后台中因未做 state hydration导致用户登录态在客户端丢失被投诉 37 次后才修复——教训深刻。6. 构建与部署从 vite.config.ts 到 CDN 资源加载生产环境的隐形战场6.1 vite.config.ts 的五大反直觉配置为什么 dev 正常build 后报错Vite 的默认配置在开发时很友好但生产构建常暴露隐藏问题1. resolve.alias 的路径终结符陷阱// vite.config.ts resolve: { alias: { : path.resolve(__dirname, src) // ❌ 缺少 / // ✅ 正确/: path.resolve(__dirname, src/) } }缺少结尾/会导致import /utils解析为src/utils.js而import /utils/解析为src/utils/index.js构建时可能找不到模块。2. build.rollupOptions.external 的副作用build: { rollupOptions: { external: [vue] // ❌ 将 vue 排除但 vue.runtime.esm-bundler.js 仍被引入 } }正确做法是用build.lib模式构建第三方库或用optimizeDeps.exclude。3. assetsInclude 的 glob 模式限制// ❌ 不生效assetsInclude: [**/*.m3u8] // ✅ 正确assetsInclude: [**/*.m3u8, **/*.ts]Vite 的assetsInclude仅支持简单 glob不支持**递归匹配需显式列出。4. css.codeSplitting 的 chunk 冲突开启css.codeSplitting后不同组件的 CSS 可能被打包到同一 chunk导致样式污染。解决方案是用build.rollupOptions.output.manualChunks手动分包manualChunks: { vendor: [vue, pinia, vue-router], ui: [element-plus, element-plus/icons-vue] }5. build.sourcemap 的生产泄露风险build.sourcemap: true会生成.map文件暴露源码结构。生产环境应设为hidden上传 sourcemap 到监控平台或false。经验我维护的 12 个项目中8 个因resolve.alias路径错误导致构建失败平均排查时间 2.3 小时。现在所有新项目都用vite-plugin-checker实时校验配置。6.2 m3u8 播放的 Vue 实践为什么 video 标签不支持必须用 hls.jsvideo srcxxx.m3u8在 Chrome 中无效因为 m3u8 是 HLS 协议的播放列表需 JavaScript 解析并分片加载。Vue 中集成 hls.js 的关键点1. 动态创建 Hls 实例template video refvideoRef classvideo-player / /template script setup import Hls from hls.js import { onMounted, onUnmounted, ref } from vue const videoRef refHTMLVideoElement | null(null) const hls refHls | null(null) onMounted(() { if (videoRef.value Hls.isSupported()) { hls.value new Hls() hls.value.loadSource(https://example.com/stream.m3u8) hls.value.attachMedia(videoRef.value) } }) onUnmounted(() { hls.value?.destroy() }) /script2. 处理加载状态hls.value?.on(Hls.Events.MANIFEST_PARSED, () { videoRef.value?.play() // 自动播放需用户手势触发 }) hls.value?.on(Hls.Events.ERROR, (event, data) { if (data.fatal) { switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: hls.value?.recoverNetworkError() break case Hls.ErrorTypes.MEDIA_ERROR: hls.value?.recoverMediaError() break default: hls.value?.destroy() } } })3. Vue 3 的响应式适配Hls 实例本身不是响应式需用shallowRef包装并在onBeforeUnmount中清理const hls shallowRefHls | null(null) onBeforeUnmount(() { hls.value?.destroy() hls.value null })注意vue播放m3u8是高频搜索词但答案绝不是“用 video 标签”而是“用 hls.js Vue 生命周期管理”。我曾见候选人因答“video 标签支持 m3u8”被当场终止面试。6.3 微前端 qiankun 的 Vue 集成为什么子应用必须导出生命周期函数qiankun 要求子应用导出bootstrap、mount、unmount三个函数这不是约定而是沙箱隔离的强制接口。Vue 子应用的标准导出// entry.js import { createApp } from vue import App from ./App.vue let instance null export async function bootstrap() { console.log(app bootstraped) } export async function mount(props) { const { container } props instance createApp(App) instance.mount(container ? container.querySelector(#app) : #app) } export async function unmount() { instance?.unmount() instance null }关键点mount中的container是 qiankun 注入的 DOM 节