createApp 与挂载流程:从 createApp 到首次渲染
🎯 引言
每个 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.ts 和 packages/runtime-core/src/apiCreateApp.ts。建议在上一篇搭好的调试环境里打断点跟着走一遍。
🧱 createApp 入口:app 对象从哪来
这一节要回答的疑问是:我们天天在写的 app,到底是怎么被造出来的?
先回忆 Vue2 的写法:new Vue({ el: '#app', ... })。Vue2 只有一个全局的 Vue 构造函数,Vue.component、Vue.use 都挂在它上面。这意味着同一个页面里的多个 Vue2 应用会共享全局注册:你注册一个全局组件,另一个应用也能看到,互相干扰。Vue3 换成了应用级实例:每次调用 createApp,都得到一个独立的 app 对象,app.component、app.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 就是一个普通对象,身上挂着 use、mixin、component、mount 等方法,外加 _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 渲染到小程序或终端的方案,走的就是这条路。
hostInsert、hostCreateElement 这类带 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;
}
按步骤拆解:
- 创建 vnode:
createVNode把根组件包装成一个 vnode。此时还没有任何 DOM 操作,只是生成了说明书。vnode 的细节第 7 篇会展开。 - 绑定应用上下文:上下文就是「这个应用的全局信息包」,里面有全局组件、全局配置和
provides。挂上它,组件树里的provide/inject才能跨层级工作。 - 进入渲染:
render是渲染器的核心入口,内部会走 patch 流程,把 vnode 变成真实 DOM 塞进容器。 - 打标记:容器节点上挂
__vue_app__,Vue DevTools 靠它找到页面上的应用,它也能防止你在同一个容器上重复挂载。 - 返回代理对象:
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(),拿到返回值,供模板使用。 - 渲染 effect:
effect可以通俗地理解为「一个会自动重新执行的函数」。这里创建的渲染 effect 负责执行组件的渲染函数、递归 patch 子树,真实 DOM 被创建并插入容器。
这里只讲主干。两个关键细节留给后面:effect 怎么和响应式数据建立联系、数据变化后怎么重新执行,是第 4 篇的内容;patch 怎么对比和更新 vnode,是第 7 篇的内容。现在你只需要记住首次渲染的出场顺序:实例 → setup → 渲染 effect → 真实 DOM 上屏。
createComponentInstance、setupComponent 和 setupRenderEffect 各打一个断点,刷新页面,你会看到它们按上面的顺序依次命中。⚡ 与 Vue2 初始化对比
如果你在 Vue2 源码课程里跟过 new Vue() 的 _init 流程,可以对照这张表,快速抓住两个版本的差异:
| 维度 | Vue2 | Vue3 |
|---|---|---|
| 入口 | new Vue(options) 构造函数 | createApp(App) 返回应用实例 |
| 挂载 API | el 选项或 $mount('#app') | app.mount('#app') |
| 全局配置 | 挂在全局构造函数上(Vue.use 等) | 挂在 app 上(app.use 等),应用间隔离 |
| 平台耦合 | 核心代码与 web 平台耦合在单一仓库里 | runtime-core 与 runtime-dom 分离 |
变化主要体现在第一条和第三条:Vue3 把「一个全局 Vue」拆成了「多个相互隔离的 app」,每个应用有自己的组件注册表、插件和配置,互不干扰。
🧾 小节总结
createApp返回的app是一个普通对象,身上有use、component、mixin、mount等方法,每个应用相互隔离,避免了 Vue2 全局构造函数带来的污染。createApp由createRenderer生成的渲染器提供;平台 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 调用时。所以 createApp 和 mount 之间可以安全地链式安装插件、注册全局组件。
🧪 小练习
读懂主干后,动手写一个约 30 行的迷你 createApp,验证自己的理解。要求:返回带 mount 方法的 app 对象,mount 里执行根组件的 setup 和 render,把结果渲染到指定容器。保存为 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 是如何拦截读写,把普通对象变成响应式数据的。
