◆

Vue3 createApp 创建应用实例

2023-02-02 01:370

createApp 解决了什么问题

Vue2 用 new Vue() 创建根实例,所有的插件、全局组件、全局指令、原型扩展都挂在 Vue 这个全局构造函数上:

// Vue 2
Vue.use(VueRouter);
Vue.component('MyButton', MyButton);
Vue.prototype.$http = axios;

new Vue({ render: h => h(App) }).$mount('#app');

这套设计在一个页面只有一个 Vue 应用时没问题,但一旦同页面要跑两个应用(微前端、把 Vue 组件嵌进老项目、同页面多个独立挂载点),麻烦就来了:两个应用共享同一份全局配置,A 应用注册的全局组件 B 也能用,A 改了 Vue.config 会影响 B,测试用例之间的全局状态也会互相污染。

Vue3 的 createApp 把这些配置从全局搬到了应用实例上。每次调用 createApp 得到一个独立的实例,配置只对这个实例生效:

import { createApp } from 'vue';
import App from './App.vue';

const app = createApp(App);
app.mount('#app');

两个应用互不影响

这是最直接的收益:

const app1 = createApp(AppOne);
app1.component('SharedBtn', RedButton);
app1.mount('#app-one');

const app2 = createApp(AppTwo);
app2.component('SharedBtn', BlueButton); // 同名不冲突
app2.mount('#app-two');

app1 里的 SharedBtn 是红色按钮,app2 里是蓝色,两者完全隔离。Vue2 里做不到这一点。

常用的实例方法

createApp 返回的 app 实例上的方法都返回实例本身,所以可以链式调用:

import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';

createApp(App)
  .use(createPinia())
  .use(router)
  .component('MyIcon', MyIcon)
  .directive('focus', {
    mounted(el) {
      el.focus();
    },
  })
  .provide('apiBase', '/admin-api')
  .mount('#app');

各个方法的作用:

  • use(plugin, options):安装插件。同一个插件重复 use 只会生效一次,Vue 内部有去重
  • component(name, comp):注册全局组件。只传 name 是取回已注册的组件
  • directive(name, def):注册全局指令
  • provide(key, value):应用级别的依赖注入,所有后代组件都能 inject,不需要在根组件里写 provide
  • mixin(mixin):注册全局 mixin,组合式 API 时代基本不该用了
  • mount(selector | element):挂载,返回根组件实例(不是 app 实例,链式调用要注意这个断点)
  • unmount():卸载应用,会触发所有组件的卸载钩子

挂载的行为细节

mount 的参数可以是选择器字符串也可以是真实 DOM 元素。它的行为和 Vue2 的 $mount 有个重要差异:Vue3 会把渲染结果追加进容器内部,Vue2 是替换掉容器元素本身。

<div id="app">这里的内容会被清空</div>

Vue3 挂载后,#app 这个 div 仍然存在,组件内容作为它的子节点。容器原有的 innerHTML 会被清掉——除了在 SSR 水合(hydration)模式下,那时原有内容是服务端渲染出来的,需要被复用。

另一个细节是 app.config 必须在 mount 之前配置好,挂载之后再改不生效:

const app = createApp(App);

// 全局错误处理,必须在 mount 前设置
app.config.errorHandler = (err, instance, info) => {
  console.error('[vue error]', err, info);
  // 上报到监控平台
};

// 全局属性,相当于 Vue2 的 Vue.prototype.$xxx
app.config.globalProperties.$formatDate = d => new Date(d).toLocaleString();

// 编译器选项,只在使用运行时编译版本时有效
app.config.compilerOptions.isCustomElement = tag => tag.startsWith('my-');

app.mount('#app');

globalProperties 在模板里可以直接用 $formatDate(x),但在 <script setup> 里访问不到,得通过 getCurrentInstance() 取,比较别扭。所以更推荐用 provide/inject 或者干脆 import 一个工具函数,只有需要兼容老代码时才用 globalProperties。

createApp 内部做了什么

大致流程是这样的:

createApp 实际上是 runtime-dom 包里的一个函数,它先调用 ensureRenderer() 拿到渲染器。渲染器由 createRenderer(rendererOptions) 创建,rendererOptions 里是一组平台相关的 DOM 操作方法(createElement、insert、remove、patchProp 等)。这一层抽象是为了跨平台——换一套 options 就能渲染到 canvas 或者原生应用。

然后 createAppAPI(render) 返回真正的 createApp 函数,它创建 appContext(存放全局组件、指令、provides、config 的容器)和 app 实例对象。

app.mount(container) 被调用时:先用 createVNode(rootComponent, rootProps) 把根组件转成 vnode,把 appContext 挂到这个 vnode 上(这就是为什么全局注册的东西整棵树都能访问到),再调 render(vnode, container) 走进 patch 流程完成首次渲染,最后把 container 记录在 app._container 上供 unmount 使用。

有一点值得注意:app.mount 返回的是根组件的代理实例,不是 app 实例。所以下面这样写会拿到错的东西:

// 错误:app 是根组件实例,没有 unmount 方法
const app = createApp(App).mount('#app');
app.unmount(); // TypeError

// 正确:分开写
const app = createApp(App);
const vm = app.mount('#app');
app.unmount();

关于 unmount 和微前端

unmount() 会递归触发所有组件的 onUnmounted,清空容器,并把 app 标记为已卸载。这在微前端里是必需的——子应用切走时必须调用 unmount,否则定时器、事件监听、ResizeObserver 都会残留,多次进出同一个子应用会累积泄漏。

典型的微前端生命周期封装:

let app = null;

export async function mount(props) {
  app = createApp(App);
  app.use(router).use(createPinia());
  app.mount(props.container?.querySelector('#app') ?? '#app');
}

export async function unmount() {
  app?.unmount();
  app = null; // 断开引用,让实例能被 GC
}

app = null 这一步别漏。unmount() 只是卸载了组件树,app 对象本身还持有 appContext,模块级变量一直引用着它的话整块内存回收不了。

另外同一个 app 实例卸载后不能再 mount,必须重新 createApp。

💬留言板0

还没有留言,来发表第一条吧~

PAGE LOAD / LIVEVue3 createApp 创建应用实例加载中