在调用 createApp 时,Vue 为我们做了那些工作?
寻找入口 ¶
先看一下Vue3的源码目录:
packages目录下的包就是Vue3的所有源码了,编译之后会在每个工程包下面生成一个dist目录,里面就是编译后的文件。 这里我框出了vue包,这个大家都熟悉,打开vue包下的package.json文件,可以看到unpkg字段指向了dist/vue.global.js文件,这个文件就是Vue3的全局版本,我们可以直接在浏览器中引入这个文件来使用Vue3。 代码逻辑基本上都是相同的,用打包后的文件来分析源码,可以更加直观的看到源码的逻辑,因为Vue在设计的时候会考虑其他平台,如果直接通过源码来查看会有额外的心智负担。 具体如何使用每个打包后的文件,可以查看vue包下的README.md文件,如果只是想分析源码,且不想那么麻烦,可以直接使用dist/vue.global.js文件。 如果想了解Vue3的目录结构和模块划分可以使用vue.esm-bundler.js文件,这个文件是Vue3的ESM版本,会通过import来引入其他模块,这样就可以直接看到Vue3的模块划分。 本系列就会通过vue.esm-bundler.js文件来分析Vue3的源码,并且会通过边分析边动手的方式来学习Vue3的源码。
使用 ¶
我们先来看一下Vue3的使用方式:
import {createApp} from 'vue'
import App from './App.vue'
const app = createApp(App)
app.mount('#app')
在Vue3中,我们需要使用createApp来创建一个应用实例,然后使用mount方法将应用挂载到某个DOM节点上。 createApp是从vue包中导出的一个方法,它接收一个组件作为参数,然后返回一个应用实例。
入口 createApp ¶
从vue的package.json可以看到,module字段指向了dist/vue.esm-bundler.js文件,这个文件是Vue3的ESM版本,我们可以直接使用import来引入Vue3。 而createApp方法并不在这个包中,而是在runtime-dom包中,这个文件是直接全部导出runtime-dom包中的内容:
export * from '@vue/runtime-dom';
不用怀疑@vue/runtime-dom指向的就是runtime-dom包,使用esm版本就直接找xxx.esm-bundler.js文件,使用cjs版本就直接找xxx.cjs.js文件,后面不会再提到这个问题。 打开runtime-dom.esm-bundler.js文件,可以看到createApp方法:
import { } from '@vue/runtime-core';
export * from '@vue/runtime-core';
import { } from '@vue/shared';
// ... 省略n多代码
function createApp(...args) {
// ...
}
export {createApp};
可以看到runtime-dom包中还引用了runtime-core包和shared包,现在找到入口文件了,在分析直接可以先搭建一个简单的代码分析和测试的环境,这样方便自己验证并且可以直接看到代码的执行结果。 demo环境可以直接在本地搭建,也可以使用codesandbox、stackblitz等在线环境,这里使用codesandbox,后续demo的代码都会放在codesandbox上,文末会有链接。 当然大家也可以直接在本地搭建一个demo环境,这里就不再赘述了。
源码分析 ¶
上面的环境都准备好了之后就可以直接开始分析Vue3的源码了,我们先来看一下createApp方法的实现;
createApp ¶
const createApp = (...args) => {
const app = ensureRenderer().createApp(...args);
const {mount} = app;
app.mount = (containerOrSelector) => {
// ...
};
return app;
}
createApp方法接收一个组件作为参数,然后调用ensureRenderer方法; 这个方法的作用是确保渲染器存在,如果不存在就创建一个渲染器,然后调用渲染器的createApp方法,这个方法的作用是创建一个应用实例,然后将这个应用实例返回,相当于一个单例模式。
let renderer;
const ensureRenderer = () => renderer || (renderer = createRenderer(rendererOptions));
这里的rendererOptions是一些渲染器的配置,主要的作用是用来操作DOM的,这里不做过多的介绍,后面会有专门的文章来介绍。 现在先简单的来认识一下rendererOptions,这个里面会有两个方法后面会用到:
const rendererOptions = {
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null);
},
createText: text => document.createTextNode(text),
}
现在我们先简单的动手实现一下createApp方法,新建一个runtime-dom.js文件,然后内容如下:
import { createRenderer } from "./runtime-core";
const createApp = (...args) => {
const rendererOptions = {
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null);
},
createText: (text) => document.createTextNode(text)
};
const app = createRenderer(rendererOptions).createApp(...args);
const { mount } = app;
app.mount = (containerOrSelector) => {
//...后面分析再补上
};
return app;
};
export { createApp };
现在可以看到我们在实现createApp方法的时候,直接调用了createRenderer方法,这个方法是创建渲染器的方法,这个方法的实现在runtime-core包中; 所以我们需要补上runtime-core包中的createRenderer方法的实现;
createRenderer ¶
createRenderer源码实现如下:
function createRenderer(options) {
return baseCreateRenderer(options);
}
// implementation
function baseCreateRenderer(options, createHydrationFns) {
// 省略 n 多代码,都是函数定义,并会立即执行,暂时对结果不会有影响
return {
render,
hydrate,
createApp: createAppAPI(render, hydrate)
};
}
createRenderer内部返回baseCreateRenderer方法的执行结果,这个方法的作用会返回render、hydrate、createApp三个方法; 而我们最后需要调用的createApp方法就是在这三个方法中的其中一个,而createApp方法的是通过createAppAPI方法创建的,同时剩下的两个方法render和hydrate也是在createAppAPI方法中被调用的,所以我们还需要看一下createAppAPI方法的实现;
createAppAPI ¶
createAppAPI方法的实现如下:
function createAppContext() {
return {
app: null,
config: {
isNativeTag: NO,
performance: false,
globalProperties: {},
optionMergeStrategies: {},
errorHandler: undefined,
warnHandler: undefined,
compilerOptions: {}
},
mixins: [],
components: {},
directives: {},
provides: Object.create(null),
optionsCache: new WeakMap(),
propsCache: new WeakMap(),
emitsCache: new WeakMap()
};
}
// 这个变量是用来统计创建的应用实例的个数
let uid$1 = 0;
function createAppAPI(render, hydrate) {
// 返回一个函数,这里主要是通过闭包来缓存上面传入的参数
return function createApp(rootComponent, rootProps = null) {
// rootComponent 就是我们传入的根组件,这里会做一些校验
// 如果传递的不是一个函数,那么就做一个浅拷贝
if (!isFunction(rootComponent)) {
rootComponent = Object.assign({}, rootComponent);
}
// rootProps 就是我们传入的根组件的 props,这个参数必须是一个对象
if (rootProps != null && !isObject(rootProps)) {
(process.env.NODE_ENV !== 'production') && warn(`root props passed to app.mount() must be an object.`);
rootProps = null;
}
// 创建上下文对象,在上面定义,就是返回一个对象
const context = createAppContext();
// 通过 use 创建的插件都存在这里
const installedPlugins = new Set();
// 是否已经挂载
let isMounted = false;
// 创建 app 对象
const app = (context.app = {
_uid: uid$1++,
_component: rootComponent,
_props: rootProps,
_container: null,
_context: context,
_instance: null,
version,
get config() {
// ...
},
set config(v) {
// ...
},
use(plugin, ...options) {
// ...
},
mixin(mixin) {
// ...
},
component(name, component) {
// ...
},
directive(name, directive) {
// ...
},
mount(rootContainer, isHydrate, isSVG) {
// ...
},
unmount() {
// ...
},
provide(key, value) {
// ...
}
});
// 返回 app 对象
return app;
};
}
看到这里,我们就可以知道,createApp方法的实现其实就是在createAppAPI方法中返回一个函数,这个函数就是createApp方法; 这个方法并没有多么特殊,就是返回了一堆对象,这些对象就是我们在使用createApp方法时,可以调用的方法; 这里可以看到我们常用的use、mixin、component、directive、mount、unmount、provide等方法都是在app对象上的,也是通过这个函数制造并返回的; 现在我们继续完善我们的学习demo代码,现在新建一个runtime-core.js文件夹,然后把上面的代码复制进去; 但是我们不能全都都直接照搬,上面的对象这么多的属性我们只需要保留mount,因为还需要挂载才能看到效果,demo代码如下:
function createRenderer(options) {
// 先省略 render 和 hydrate 方法的实现,后面会讲到
return {
render,
hydrate,
createApp: createAppAPI(render, hydrate)
};
}
function createAppAPI(render, hydrate) {
return function createApp(rootComponent, rootProps = null) {
// 省略参数校验
rootComponent = Object.assign({}, rootComponent);
// 省略上下文的创建
const context = {
app: null
}
// 忽略其他函数的实现,只保留 mount 函数和私有变量
let isMounted = false;
const app = (context.app = {
_uid: uid$1++,
_component: rootComponent,
_props: rootProps,
_container: null,
_context: context,
_instance: null,
mount(rootContainer, isHydrate, isSVG) {
// ...
},
});
return app;
};
}
这样我们就完成了createApp函数的简化版实现,接下来我们就可以开始挂载了;
mount 挂载 ¶
上面我们已经学习到了createApp函数的实现,现在还需要通过mount方法来挂载我们的根组件,才能验证我们的demo代码是否正确; 我们在调用createApp方法时,会返回一个app对象,这个对象上有一个mount方法,我们需要通过这个方法来挂载我们的根组件; 在这之前,我们看到了createApp的实现中重写了mount方法,如下:
const createApp = (...args) => {
// ...省略其他代码
// 备份 mount 方法
const { mount } = app;
// 重写 mount 方法
app.mount = (containerOrSelector) => {
// 获取挂载的容器
const container = normalizeContainer(containerOrSelector);
if (!container)
return;
// _component 指向的是 createApp 传入的根组件
const component = app._component;
// 验证根组件是否是一个对象,并且有 render 和 template 两个属性之一
if (!isFunction(component) && !component.render && !component.template) {
// __UNSAFE__
// Reason: potential execution of JS expressions in in-DOM template.
// The user must make sure the in-DOM template is trusted. If it's
// rendered by the server, the template should not contain any user data.
// 确保模板是可信的,因为模板可能会有 JS 表达式,具体可以翻译上面的注释
component.template = container.innerHTML;
}
// clear content before mounting
// 挂载前清空容器
container.innerHTML = '';
// 正式挂载
const proxy = mount(container, false, container instanceof SVGElement);
// 挂载完成
if (container instanceof Element) {
// 清除容器的 v-cloak 属性,这也就是我们经常看到的 v-cloak 的作用
container.removeAttribute('v-cloak');
// 设置容器的 data-v-app 属性
container.setAttribute('data-v-app', '');
}
// 返回根组件的实例
return proxy;
};
return app;
}
上面重写的mount方法中,其实最主要的做的是三件事:
- 获取挂载的容器
- 调用原本的mount方法挂载根组件
- 为容器设置vue的专属属性
现在到我们动手实现一个简易版的mount方法了;
// 备份 mount 方法
const { mount } = app;
// 重写 mount 方法
app.mount = (containerOrSelector) => {
// 获取挂载的容器
const container = document.querySelector(containerOrSelector);
if (!container)
return;
const component = app._component;
container.innerHTML = '';
// 正式挂载
return mount(container, false, container instanceof SVGElement);
};
这里的挂载其实还是使用的是createApp函数中的mount方法,我们可以看到mount方法的实现如下:
function mount(rootContainer, isHydrate, isSVG) {
// 判断是否已经挂载
if (!isMounted) {
// 这里的 #5571 是一个 issue 的 id,可以在 github 上搜索,这是一个在相同容器上重复挂载的问题,这里只做提示,不做处理
// #5571
if ((process.env.NODE_ENV !== 'production') && rootContainer.__vue_app__) {
warn(`There is already an app instance mounted on the host container.\n` +
` If you want to mount another app on the same host container,` +
` you need to unmount the previous app by calling `app.unmount()` first.`);
}
// 通过在 createApp 中传递的参数来创建虚拟节点
const vnode = createVNode(rootComponent, rootProps);
// store app context on the root VNode.
// this will be set on the root instance on initial mount.
// 上面有注释,在根节点上挂载 app 上下文,这个上下文会在挂载时设置到根实例上
vnode.appContext = context;
// HMR root reload
// 热更新
if ((process.env.NODE_ENV !== 'production')) {
context.reload = () => {
render(cloneVNode(vnode), rootContainer, isSVG);
};
}
// 通过其他的方式挂载,这里不一定指代的是服务端渲染,也可能是其他的方式
// 这一块可以通过创建渲染器的源码可以看出,我们日常在客户端渲染,不会使用到这一块,这里只是做提示,不做具体的分析
if (isHydrate && hydrate) {
hydrate(vnode, rootContainer);
}
// 其他情况下,直接通过 render 函数挂载
// render 函数在 createRenderer 中定义,传递到 createAppAPI 中,通过闭包缓存下来的
else {
render(vnode, rootContainer, isSVG);
}
// 挂载完成后,设置 isMounted 为 true
isMounted = true;
// 设置 app 实例的 _container 属性,指向挂载的容器
app._container = rootContainer;
// 挂载的容器上挂载 app 实例,也就是说我们可以通过容器找到 app 实例
rootContainer.__vue_app__ = app;
// 非生产环境默认开启 devtools,也可以通过全局配置来开启或关闭
// __VUE_PROD_DEVTOOLS__ 可以通过自己使用的构建工具来配置,这里只做提示
if ((process.env.NODE_ENV !== 'production') || __VUE_PROD_DEVTOOLS__) {
app._instance = vnode.component;
devtoolsInitApp(app, version);
}
// 返回 app 实例,这里不做具体的分析
return getExposeProxy(vnode.component) || vnode.component.proxy;
}
// 如果已经挂载过则输出提示消息,在非生产环境下
else if ((process.env.NODE_ENV !== 'production')) {
warn(`App has already been mounted.\n` +
`If you want to remount the same app, move your app creation logic ` +
`into a factory function and create fresh app instances for each ` +
`mount - e.g. `const createMyApp = () => createApp(App)``);
}
}
通过上面的一通分析,其实挂载主要就是用的两个函数将内容渲染到容器中;
- createVNode 创建虚拟节点
- render 渲染虚拟节点
我们这里就实现一个简易版的mount函数,来模拟挂载过程,代码如下:
function mount(rootContainer, isHydrate) {
// createApp 中传递的参数在我们这里肯定是一个对象,所以这里不做创建虚拟节点的操作,而是模拟一个虚拟节点
const vnode = {
type: rootComponent,
children: [],
component: null,
}
// 通过 render 函数渲染虚拟节点
render(vnode, rootContainer);
// 返回 app 实例
return vnode.component
}
虚拟节点 ¶
虚拟节点在Vue中已经是非常常见的概念了,其实就是一个js对象,包含了dom的一些属性,比如tag、props、children等等; 在Vue3中维护了一套自己的虚拟节点,大概信息如下:
export interface VNode {
__v_isVNode: true;
__v_skip: true;
type: VNodeTypes;
props: VNodeProps | null;
key: Key | null;
ref: Ref<null> | null;
scopeId: string | null;
children: VNodeNormalizedChildren;
component: ComponentInternalInstance | null;
suspense: SuspenseBoundary | null;
dirs: DirectiveBinding[] | null;
transition: TransitionHooks<null> | null;
el: RendererElement | null;
anchor: RendererNode | null;
target: RendererNode | null;
targetAnchor: RendererNode | null;
staticCount: number;
shapeFlag: ShapeFlags;
patchFlag: number;
dynamicProps: string[] | null;
dynamicChildren: VNode[] | null;
appContext: AppContext | null;
}
完整的type信息太多,这里就只贴VNode的相关定义,而且这些在Vue的实现中也没有那么简单,这一章不做具体的分析,只是做一个简单的概念介绍;
render ¶
render函数是在讲createRenderer的时候出现的,是在baseCreateRenderer中定义的,具体源码如下:
function baseCreateRenderer(options, createHydrationFns) {
// ...
// 创建 render 函数
const render = (vnode, container, isSVG) => {
// 如果 vnode 不存在,并且容器是发生过渲染,那么将执行卸载操作
if (vnode == null) {
// container._vnode 指向的是上一次渲染的 vnode,在这个函数的最后一行
if (container._vnode) {
unmount(container._vnode, null, null, true);
}
}
// 执行 patch 操作,这里不做具体的分析,牵扯太大,后面会单独讲
else {
patch(container._vnode || null, vnode, container, null, null, null, isSVG);
}
// 刷新任务队列,通常指代的是各种回调函数,比如生命周期函数、watcher、nextTick 等等
// 这里不做具体的分析,后面会单独讲
flushPreFlushCbs();
flushPostFlushCbs();
// 记录 vnode,现在的 vnode 已经是上一次渲染的 vnode 了
container._vnode = vnode;
};
// ...
return {
render,
hydrate,
createApp: createAppAPI(render, hydrate)
};
}
render函数的主要作用就是将虚拟节点渲染到容器中,unmount函数用来卸载容器中的内容,patch函数用来更新容器中的内容; 现在来实现一个简易版的render函数:
const render = (vnode, container) => {
patch(container._vnode || null, vnode, container);
// 记录 vnode,现在的 vnode 已经是上一次渲染的 vnode 了
container._vnode = vnode;
}
unmount函数不是我们这次主要学习的内容,所以这里不做具体的分析; patch函数是Vue中最核心的函数,这次也不做具体的分析,后面会单独讲,但是要验证我们这次的学习成果,所以我们需要一个只有挂载功能的patch函数,这里我们就自己实现一个简单的patch函数;
patch ¶
patch函数的主要作用就是将虚拟节点渲染到容器中,patch函数也是在baseCreateRenderer中定义的; patch函数这次就不看了,因为内部的实现会牵扯到非常多的内容,这次只是它的出现只是走个过场,后面会单独讲; 我们这次的目的只是验证我们这次源码学习的成成果,所以我们只需要一个只有挂载功能的patch函数,这里我们就自己实现一个简单的patch函数;
// options 是在创建渲染器的时候传入的,还记得在 createApp 的实现中,我们传入了一个有 insert 和 createText 方法的对象吗?不记得可以往上翻翻
const { insert: hostInsert, createText: hostCreateText} = options;
// Note: functions inside this closure should use `const xxx = () => {}`
// style in order to prevent being inlined by minifiers.
/**
* 简易版的实现,只是删除了一些不必要的逻辑
* @param n1 上一次渲染的 vnode
* @param n2 当前需要渲染的 vnode
* @param container 容器
* @param anchor 锚点, 用来标记插入的位置
*/
const patch = (n1, n2, container, anchor = null) => {
// 上一次渲染的 vnode 和当前需要渲染的 vnode 是同一个 vnode,那么就不需要做任何操作
if (n1 === n2) {
return;
}
// 获取当前需要渲染的 vnode 的类型
const { type } = n2;
switch (type) {
// 如果是文本节点,那么就直接创建文本节点,然后插入到容器中
case Text:
processText(n1, n2, container, anchor);
break;
// 还会有其他的类型,这里不做具体的分析,后面会单独讲
// 其他的情况也会有很多种情况,这里统一当做是组件处理
default:
processComponent(n1, n2, container, anchor);
}
};
patch函数的主要作用就是将虚拟节点正确的渲染到容器中,这里我们只实现了文本节点和组件的渲染,其他的类型的节点,后面会单独讲; 而我们在使用createApp的时候,通常会传入一个根组件,这个根组件就会走到processComponent函数中; 所以我们这里还需要实现了一个简单的processComponent函数;
const processComponent = (n1, n2, container, anchor) => {
if (n1 == null) {
mountComponent(n2, container, anchor);
}
// else {
// updateComponent(n1, n2, optimized);
// }
};
processComponent函数也是定义在baseCreateRenderer中的,这里还是和patch函数一样,只是实现了一个简单的功能,后面会单独讲; processComponent函数做了两件事,一个是挂载组件,一个是更新组件,这里我们只实现了挂载组件的功能; 挂载组件是通过mountComponent函数实现的,这个函数也是定义在baseCreateRenderer中的,但是我们这次就不再继续深入内部调用了,直接实现一个简易的:
const mountComponent = (initialVNode, container, anchor) => {
// 通过调用组件的 render 方法,获取组件的 vnode
const subTree = initialVNode.type.render.call(null);
// 将组件的 vnode 渲染到容器中,直接调用 patch 函数
patch(null, subTree, container, anchor);
};
这样我们就实现了一个简易版的挂载组件的功能,这里我们只是简单的调用了组件的render方法,render方法会返回一个vnode,然后调用patch函数将vnode渲染到容器中; 现在回头看看patch函数,还差一个processText函数没有实现,这个函数也是定义在baseCreateRenderer中的,这个比较简单,下面的代码就是实现的processText函数:
const processText = (n1, n2, container, anchor) => {
if (n1 == null) {
hostInsert((n2.el = hostCreateText(n2.children)), container, anchor);
}
// else {
// const el = (n2.el = n1.el);
// if (n2.children !== n1.children) {
// hostSetText(el, n2.children);
// }
// }
};
我这里屏蔽掉了更新的操作,这里只管挂载,这里的hostInsert和hostCreateText函数就是在我们实现简易patch函数的时候,在patch函数实现的上面,通过解构赋值获取的,没印象可以回去看看;
验证 ¶
现在我们已经实现了一个简易版的createApp函数,并且我们可以通过createApp函数创建一个应用,然后通过mount方法将应用挂载到容器中; 我们可以通过下面的代码来验证一下:
import { createApp } from "./runtime-dom";
const app = createApp({
render() {
return {
type: "Text",
children: "hello world"
};
}
});
app.mount("#app");
源码在codesandbox上面,可以直接查看:codesandbox.io/s/gallant-s…
总结 ¶
我们通过阅读Vue3的源码,了解了Vue3的createApp函数的实现,createApp函数是Vue3的入口函数,通过createApp函数我们可以创建一个应用; createApp的实现是借助了createRenderer函数,createRenderer的实现就是包装了baseCreateRenderer; baseCreateRenderer函数是一个工厂函数,通过baseCreateRenderer函数我们可以创建一个渲染器; baseCreateRenderer函数接收一个options对象,这个options对象中包含了一些渲染器的配置,比如insert、createText等; 这些配置是在runtime-dom中实现的,runtime-dom中的createApp函数会将这些配置透传递给baseCreateRenderer函数,然后baseCreateRenderer函数会返回一个渲染器,这个渲染器中有一个函数就是createApp; createApp函数接收一个组件,然后返回一个应用,这个应用中有一个mount方法,这个mount方法就是用来将应用挂载到容器中的; 在createApp中重写了mount方法,内部的实现是通过调用渲染器的mount方法; 这个mount方法是在baseCreateRenderer函数中实现的,baseCreateRenderer函数中的mount方法会调用patch函数; patch函数内部会做很多的事情,虽然我们这里只实现了挂载的逻辑,但是也是粗窥了patch函数的内部一些逻辑; 最后我们实现了一个精简版的createApp函数,通过这个函数我们可以创建一个应用,然后通过mount方法将应用挂载到容器中,这个过程中我们也了解了Vue3的一些实现细节; 这次就到这里,下次我们会继续深入了解Vue3的源码,希望大家能够多多支持,谢谢大家!
作者:田八
来源:https://juejin.cn/post/7188566053293654075
共0条评论