Vue3 源码简介

createApp 与挂载流程:从 createApp 到首次渲染

读懂 createApp 返回的 app 是什么,掌握 mount 从创建 vnode 到首次渲染的完整流程。

🎯 引言

每个 Vue3 项目都从这两行代码开始:

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

这两行代码背后发生了什么?createApp 返回的 app 到底是个什么对象?mount 调用的一瞬间,Vue 做了哪些事才让你的组件出现在页面上?

学完本篇,你将能:

  • 说清楚 createApp 返回的 app 对象上有哪些能力,它们从哪来。
  • 理解 Vue3 为什么能跑在小程序、终端等非浏览器环境。
  • 按步骤复述 mount 的执行流程。
  • 知道首次渲染时,组件从创建到显示在页面上经历了哪几步。

本篇源码基于 Vue 3.5.x(GitHub vuejs/core monorepo),主要涉及 packages/runtime-dom/src/index.tspackages/runtime-core/src/apiCreateApp.ts。建议在上一篇搭好的调试环境里打断点跟着走一遍。


🧱 createApp 入口:app 对象从哪来

这一节要回答的疑问是:我们天天在写的 app,到底是怎么被造出来的?

先回忆 Vue2 的写法:new Vue({ el: '#app', ... })。Vue2 只有一个全局的 Vue 构造函数,Vue.componentVue.use 都挂在它上面。这意味着同一个页面里的多个 Vue2 应用会共享全局注册:你注册一个全局组件,另一个应用也能看到,互相干扰。Vue3 换成了应用级实例:每次调用 createApp,都得到一个独立的 app 对象,app.componentapp.use 只影响这一个应用,应用之间互不影响。对比一下:

// Vue2:全局注册,所有应用共享
Vue.component('MyButton', MyButton);
new Vue({ el: '#app' });

// Vue3:注册在 app 上,应用之间互不影响
const app = createApp(App);
app.component('MyButton', MyButton);
app.mount('#app');

那么 createApp 是怎么造出这个 app 的?打开 packages/runtime-dom/src/index.ts,你会发现 createApp 并不是直接定义的,而是由一个 renderer 提供的。renderer(渲染器)是个新词,先用人话解释一下:渲染器就是「负责把组件画到屏幕上」的一套工具,调用 createRenderer 就能得到它。为什么要这样做,下一节专门讲。

// 简化自 packages/runtime-dom/src/index.ts
import { nodeOps } from './nodeOps'; // 浏览器平台的 DOM 操作集合
import { patchProp } from './patchProp'; // 设置元素属性(class、事件等)的工具

// 第一步:用 DOM 平台的操作方法,创建一个渲染器
const renderer = createRenderer({ ...nodeOps, patchProp });

// 第二步:你调用的 createApp,其实是渲染器提供的
export const createApp = (...args) => {
    const app = renderer.createApp(...args);

    // 给 mount 包一层,让它支持传选择器字符串(如 '#app')
    const { mount } = app; // 先拿出渲染器给的原始 mount
    app.mount = (containerOrSelector) => {
        // 把字符串 '#app' 转成真实的 DOM 元素
        const container = normalizeContainer(containerOrSelector);
        return mount(container); // 再交给原始 mount 干活
    };

    return app;
};

继续往里追,renderer.createApp 来自 packages/runtime-core/src/apiCreateApp.ts。真正的 app 对象在这里诞生:

// 简化自 packages/runtime-core/src/apiCreateApp.ts
export function createAppAPI(render) {
    // 你调用的 createApp(App),就是调用这个函数
    return function createApp(rootComponent, rootProps = null) {
        const app = {
            _component: rootComponent, // 根组件(你传进来的 App)
            _props: rootProps, // 传给根组件的 props
            _container: null, // 挂载容器,mount 时才赋值

            config: {}, // 全局配置(如错误处理)
            provides: {}, // 全局 provide 的数据

            use(plugin) {
                /* 安装插件,如 router */
            },
            mixin(mixin) {
                /* 全局混入 */
            },
            component(name, component) {
                /* 注册全局组件 */
            },
            mount(rootContainer) {
                /* 挂载,下一节细讲 */
            },
            unmount() {
                /* 卸载应用 */
            },
        };

        return app;
    };
}

看清楚了:app 就是一个普通对象,身上挂着 usemixincomponentmount 等方法,外加 _component_container 这些内部字段。你平时用的每一个 API,都只是这个对象上的一个函数,没有任何魔法。

app 上的方法大多返回 app 自身,所以 createApp(App).use(router).mount('#app') 这种链式写法是合法的。

🧱 createRenderer 与跨平台设计

上一节留了个疑问:为什么 createApp 要绕一道,从 createRenderer 生成的渲染器上拿?这一节就来回答它。先讲结论:Vue3 把「画组件的通用逻辑」和「具体的 DOM 操作」分开了。通用逻辑放在 runtime-core 里,里面一行 document 相关的代码都没有;而「怎么创建元素、设置属性、插入节点」这些具体操作,由运行平台自己提供,通过参数传给 createRenderer

具体来说:runtime-core 只负责通用逻辑,比如组件怎么创建、状态变了怎么更新;而「创建元素、设置属性、插入节点」这些具体操作,全部由运行平台提供。浏览器平台提供的这套操作就是 nodeOps

// 简化自 packages/runtime-dom/src/nodeOps.ts
export const nodeOps = {
    // 插入一个节点
    insert: (child, parent, anchor) => {
        parent.insertBefore(child, anchor || null);
    },
    // 删除一个节点
    remove: (child) => {
        const parent = child.parentNode;
        if (parent) parent.removeChild(child);
    },
    // 创建元素、创建文本节点
    createElement: (tag) => document.createElement(tag),
    createText: (text) => document.createTextNode(text),
    // ... 还有 querySelector 等,了解即可
};

可以看到,nodeOps 里就是我们熟悉的原生 DOM API。createRenderer({ ...nodeOps, patchProp }) 就是把这套浏览器操作注入进去,生成的渲染器就知道怎么在浏览器里干活了。同一套核心代码,换一套平台操作就能换个环境运行:

  • 浏览器:注入 nodeOps,就是你现在用的 Vue。
  • 小程序 / 终端 / Canvas:换一套平台操作注入进去,核心逻辑一行不改。社区里一些把 Vue 渲染到小程序或终端的方案,走的就是这条路。
读源码时看到 hostInserthostCreateElement 这类带 host 前缀的函数,它就是「平台注入的 DOM 操作」的别名,具体实现回到 nodeOps 里看即可。

🧱 app.mount 的执行流程

前面两节把 app 的来历讲清楚了,现在进入本篇的主线:app.mount('#app') 调用时,到底发生了什么?这里会出现两个新词,先解释清楚:

  • vnode(虚拟节点):一个普通 JS 对象,用来「描述」一个界面元素长什么样,比如「这是一个 div,里面有一句文字」。它只是一份渲染说明书,还不是真实的 DOM。
  • patch:拿着 vnode 这份说明书,去创建或更新真实 DOM 的过程。名字来自「打补丁」,首次渲染是整块铺上去,更新时是哪里变了补哪里。

理解了这两个词,看简化后的 mount 就很顺了:

// 简化自 packages/runtime-core/src/apiCreateApp.ts 的 mount
mount(rootContainer) {
    // 1. 用根组件生成一份「渲染说明书」(vnode)
    const vnode = createVNode(rootComponent, rootProps);

    // 2. 把 app 的上下文挂到 vnode 上,组件里的 provide/inject 靠它工作
    vnode.appContext = app._context;

    // 3. 调用 render 进入 patch,把说明书变成真实 DOM
    render(vnode, rootContainer);

    // 4. 记录容器,并在容器上打个标记,表示「这里已经挂载过了」
    app._container = rootContainer;
    rootContainer.__vue_app__ = app;

    // 5. 返回根组件实例的代理对象(下面解释)
    return vnode.component.proxy;
}

按步骤拆解:

  1. 创建 vnodecreateVNode 把根组件包装成一个 vnode。此时还没有任何 DOM 操作,只是生成了说明书。vnode 的细节第 7 篇会展开。
  2. 绑定应用上下文:上下文就是「这个应用的全局信息包」,里面有全局组件、全局配置和 provides。挂上它,组件树里的 provide/inject 才能跨层级工作。
  3. 进入渲染render 是渲染器的核心入口,内部会走 patch 流程,把 vnode 变成真实 DOM 塞进容器。
  4. 打标记:容器节点上挂 __vue_app__,Vue DevTools 靠它找到页面上的应用,它也能防止你在同一个容器上重复挂载。
  5. 返回代理对象mount 的返回值不是 app,而是根组件实例的 proxy代理(proxy)是组件实例的访问入口:你通过它读写组件的数据和方法,它在背后把操作转发到真正的实例上。你在 Options API 里用的 this,以及通过 vm.xxx 访问数据,走的就是它。
一个常见的困惑:app.mount('#app') 返回的不是 app 本身,不能拿它继续链式调用 use。想链式调用,就把插件安装写在 mount 之前。

🧱 首次渲染做了什么

上一节说到 render 内部会走 patch。首次渲染时容器是空的,没有旧 vnode,patch 就走「全新挂载」路线。那么遇到我们的组件 vnode 时,Vue 具体按什么顺序干活?这一节讲清楚。

// 简化自 packages/runtime-core/src/renderer.ts 的组件挂载流程
function mountComponent(vnode, container) {
    // 1. 创建组件实例:一个用来存放这个组件所有信息的对象
    const instance = createComponentInstance(vnode);
    vnode.component = instance;

    // 2. 执行你写的 setup(),拿到返回值(状态或渲染函数)
    setupComponent(instance);

    // 3. 建立渲染 effect,执行渲染函数,真实 DOM 在这里诞生
    setupRenderEffect(instance, vnode, container);
}

三步各司其职:

  • 创建实例instance(实例)就是一个普通的 JS 对象,用来存放这个组件的所有信息,之后 setup 的结果、生命周期、渲染状态都挂在它身上。
  • 执行 setup:调用你写的 setup(),拿到返回值,供模板使用。
  • 渲染 effecteffect 可以通俗地理解为「一个会自动重新执行的函数」。这里创建的渲染 effect 负责执行组件的渲染函数、递归 patch 子树,真实 DOM 被创建并插入容器。

这里只讲主干。两个关键细节留给后面:effect 怎么和响应式数据建立联系、数据变化后怎么重新执行,是第 4 篇的内容;patch 怎么对比和更新 vnode,是第 7 篇的内容。现在你只需要记住首次渲染的出场顺序:实例 → setup → 渲染 effect → 真实 DOM 上屏

在调试环境里验证:在 createComponentInstancesetupComponentsetupRenderEffect 各打一个断点,刷新页面,你会看到它们按上面的顺序依次命中。

⚡ 与 Vue2 初始化对比

如果你在 Vue2 源码课程里跟过 new Vue()_init 流程,可以对照这张表,快速抓住两个版本的差异:

维度Vue2Vue3
入口new Vue(options) 构造函数createApp(App) 返回应用实例
挂载 APIel 选项或 $mount('#app')app.mount('#app')
全局配置挂在全局构造函数上(Vue.use 等)挂在 app 上(app.use 等),应用间隔离
平台耦合核心代码与 web 平台耦合在单一仓库里runtime-coreruntime-dom 分离

变化主要体现在第一条和第三条:Vue3 把「一个全局 Vue」拆成了「多个相互隔离的 app」,每个应用有自己的组件注册表、插件和配置,互不干扰。


🧾 小节总结

  • createApp 返回的 app 是一个普通对象,身上有 usecomponentmixinmount 等方法,每个应用相互隔离,避免了 Vue2 全局构造函数带来的污染。
  • createAppcreateRenderer 生成的渲染器提供;平台 DOM 操作通过 nodeOps 注入,核心代码不碰 DOM,这就是 Vue3 支持跨平台渲染的关键。
  • mount 的主干四步:创建根组件 vnode → 绑定应用上下文 → render 进入 patch → 容器打上 __vue_app__ 标记,返回值是根组件实例的代理对象。
  • 首次渲染的出场顺序:创建组件实例 → 执行 setup → 建立渲染 effect → 递归 patch 生成真实 DOM。
  • effect 与 patch 的细节分别在第 4 篇和第 7 篇展开,本篇先抓住主干。

❓ 知识问答

Q1:createApp 和 Vue2 的 new Vue 本质区别是什么?

A:new Vue 创建的是组件实例,全局配置都挂在共享的 Vue 构造函数上;createApp 创建的是应用实例,配置、组件、插件都收在这个 app 内部。一个页面可以存在多个互不影响的 Vue3 应用,这在 Vue2 里很难做到。

Q2:为什么 mount 返回的是组件实例的代理对象,而不是 app

A:因为挂载完成后,你更常需要操作的是根组件本身,比如访问它的方法或状态。返回代理对象,可以直接 const vm = app.mount('#app') 后通过 vm 访问根组件暴露的内容。app 的引用你本来就握在手里,不需要 mount 再还给你。

Q3:多次调用 createApp 会怎样?

A:每次调用都得到一个全新的、独立的应用实例,各自有自己的组件注册表、插件和 provides,互不影响。微前端、一个页面挂多个 Vue 小部件这类场景,靠的就是这个能力。

Q4:同一个容器可以 mount 两次吗?

A:不可以。mount 时会检查容器上是否已有 __vue_app__ 标记,重复挂载同一个容器会收到警告且不会生效。想重新挂载,先调 app.unmount()

Q5:createApp(App) 之后还没有任何 DOM 操作,对吗?

A:对。createApp 只是创建了应用实例、记录了根组件,真正的 vnode 创建和 DOM 渲染都发生在 mount 调用时。所以 createAppmount 之间可以安全地链式安装插件、注册全局组件。


🧪 小练习

读懂主干后,动手写一个约 30 行的迷你 createApp,验证自己的理解。要求:返回带 mount 方法的 app 对象,mount 里执行根组件的 setuprender,把结果渲染到指定容器。保存为 HTML 文件用浏览器打开验证。

<div id="app"></div>
<script>
    function createApp(rootComponent) {
        // 请在这里编写代码:返回 { mount }
        // mount 需要完成:
        // 1. 执行 rootComponent.setup() 拿到状态
        // 2. 调用 rootComponent.render(state) 得到 { tag, children }
        // 3. 创建真实 DOM 并插入容器
        // 4. 在容器上挂 __vue_app__ 标记
    }

    const App = {
        setup() {
            return { message: 'hello mini vue3' };
        },
        render(state) {
            return { tag: 'h1', children: state.message };
        },
    };

    createApp(App).mount('#app');
</script>

完成后给自己加个挑战:让 render 返回的 children 支持嵌套数组(如 { tag: 'div', children: [{ tag: 'p', children: 'hi' }] }),用递归处理它,你就摸到了 patch 递归挂载的雏形。


🎉 恭喜你已经理清 createApp 到首次渲染的完整链路啦!下一篇我们进入 Vue3 响应式的核心:看看 reactive 背后的 Proxy 是如何拦截读写,把普通对象变成响应式数据的。