Vue3源码阅读——初始化流程
前言
接着上一篇Vue3带来了哪些更新和优化,本文跟随笔者走进Vue3的源码世界,一同探索Vue3的初始化流程。
❗️源码中有很多代码是用于处于边缘case的,我们阅读源码先关注主要分支实现的原理,不需要关注那些case,因为你关注的越多,越容易看不下去,因此笔者在文中会直接忽略。
❗️本文字数12000+,阅读完预计需要10分钟。
准备工作
- 克隆
vuejs/core到本地:git clone https://github.com/vuejs/core.git。 - 执行
pnpm i安装依赖。 - 待依赖安装完毕以后可以执行下
pnpm run dev-esm打一个runtime的包出来,打出来的包在packages/vue/dist/vue.runtime.esm-bundler.js,并且生成了sourceMap便于调试。 - 然后你就可以在
packages/vue/example下写一些demo页面来调试你想了解的源码。eg:
Vue3 初始化流程
就以这个很简单的例子来一步一步看Vue3的初始化到底做了哪些事情:
<div id="root"></div>
<script type="module">
import { h, ref, createApp } from '../../dist/vue.runtime.esm-bundler.js'
const count = ref(0)
const HelloWorld = {
name: 'HelloWorld',
render() {
return h(
'div',
{ tId: 'helloWorld' },
`hello world: count: ${count.value}`
)
}
}
const App = {
name: 'App',
render() {
return h('div', { tId: 1 }, [h('p', {}, '主页'), h(HelloWorld)])
}
}
createApp(App).mount(document.querySelector('#root'))
</script>
首先来到packages/runtime-dom/src/index.ts中的createApp方法:
export const createApp = ((...args) => {
// 先创建渲染器 再 创建app
const app = ensureRenderer().createApp(...args)
// 针对 web 重写 mount 方法,在重写后的逻辑中调用缓存的 mount
const { mount } = app
app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
// 标准化传入的挂载容器
const container = normalizeContainer(containerOrSelector)
if (!container) return
const component = app._component
if (!isFunction(component) && !component.render && !component.template) {
//
component.template = container.innerHTML
// ...
}
// clear content before mounting
container.innerHTML = ''
// 调用缓存的 mount —— 真正的挂载逻辑
const proxy = mount(container, false, container instanceof SVGElement)
// ...
return proxy
}
return app
}) as CreateAppFunction<Element>
createApp中做了以下几件事:
- 创建渲染器。
- 创建
app。 - 缓存
app.mount,重写app.mount。 - 在重写的的
mount中标准化container(可传入字符串 或者DOM对象),并判断如果根组件不是函数且没有render也没有template时,将container.innerHTML作为template。 - 调用缓存下来的
mount执行真正的挂载。 - 返回
app
此处会有一个问题:为什么要重写app.mount,而不是把上述第四点直接放在mount内部来实现❓
因为Vue.js设计的不仅局限于Web平台才可用,它的目标是支持跨平台渲染。
createApp返回的app.mount方法是一个标准的可跨平台的组件渲染流程,它不包含任何特定平台的相关逻辑,简单理解就是这里面的逻辑都是跟平台无关的。就以container来说,在web平台最终是一个DOM节点,但是在小程序或其他平台就是其他的了。因此需要重写。
接着看与createApp在同一个文件中的ensureRenderer:
// lazy create the renderer - this makes core renderer logic tree-shakable
// in case the user only imports reactivity utilities from Vue.
let renderer: Renderer<Element | ShadowRoot> | HydrationRenderer
function ensureRenderer() {
return (
renderer ||
(renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
)
}
源码中的注释告诉我们:ensureRenderer函数的作用是为了懒创建renderer——渲染器,这么做的好处是当用户只使用到响应式模块的时候,主要的渲染逻辑能够被tree-shaking,能够减少生产体积。
参数rendererOptions(简单理解就是一个包含很多DOM操作方法的对象):

这些方法的实现在packages/runtime-dom/src/nodeOps.ts,感兴趣的掘友可以去看看。接着调用packages/runtime-core/src/renderer.ts中的createRenderer方法创建renderer。

接着调用同文件中的baseCreateRenderer方法,这个方法源码差不多2000行,里面包含了生成组件vnode,vnode转变真实DOM,以及挂载、更新的逻辑。此时我们关注不了很多,只看返回的createApp:
function baseCreateRenderer(
options: RendererOptions,
createHydrationFns?: typeof createHydrationFunctions
): any {
// ...
return {
render,
hydrate,
createApp: createAppAPI(render, hydrate)
}
}
接着看packages/runtime-core/src/apiCreateApp.ts中的createAppAPI:
export function createAppAPI<HostElement>(
render: RootRenderFunction<HostElement>,
hydrate?: RootHydrateFunction
): CreateAppFunction<HostElement> {
return function createApp(rootComponent, rootProps = null) {
// 创建应用的上下文对象
const context = createAppContext()
let isMounted = false
const app: App = (context.app = {
_uid: uid++,
_component: rootComponent as ConcreteComponent,
_props: rootProps,
_container: null,
_context: context,
_instance: null,
version,
//...
mount(
rootContainer: HostElement,
isHydrate?: boolean,
isSVG?: boolean
): any {
// 真正的挂载
}
//...
})
return app
}
}
终于看到createApp的“庐山真面目”了,此处我们可以看到柯里化和闭包的身影,通过调用createAppAPI返回createApp,确定了render参数。在调用mount时就无需再传入渲染器,createApp中主要做了以下几件事情:
- 调用
createAppContext创建应用的上下文对象 。
- 返回
app对象
然后就会回到packages/runtime-dom/src/index.ts中的createApp方法,缓存app.mount并重写,然后返回app。当用户调用mount时,重写那部分逻辑之前已经分析过了,咱们接着看真正的挂载逻辑:
mount(
rootContainer: HostElement,
isHydrate?: boolean,
isSVG?: boolean
): any {
if (!isMounted) {
// 创建根组件的vnode
const vnode = createVNode(
rootComponent as ConcreteComponent,
rootProps
)
// 缓存应用的上下文对象到根vnode,并且在组件初始化挂载的时候会将其被添加到根实例上
vnode.appContext = context
// 基于根组件的vnode开箱
render(vnode, rootContainer, isSVG)
isMounted = true
app._container = rootContainer
//...
return getExposeProxy(vnode.component!) || vnode.component!.proxy
}
}
- 调用
createVNode创建根组件的vnode,createVNode其实就是h。 - 基于根组件的
vnode调用render开箱。
源码中调用的createVNode在开发环境会先转化一下参数,再调用_createVNode:
export const createVNode = (
__DEV__ ? createVNodeWithArgsTransform : _createVNode
) as typeof _createVNode
看下_createVNode的处理逻辑:

_createVNode中主要做了以下几件事情:
- 处理接收到的
type是vnode的情况 - 标准化
class编写的组件 - 标准化
class、style、props - 计算
shapeFlag - 调用
createBaseVNode
简单看下createBaseVNode吧:

createBaseVNode逻辑非常清晰:
- 创建一个
vnode,有很多属性。 - 标准化
children。 - 返回
vnode。
来看下我们这个例子根组件App的vnode长啥样:

没错,就是使用一个对象来描述我们的组件或者DOM节点。
聊几个跟vnode相关的问题:
用了vnode,就不操作DOM了吗? —— 肯定不是的,还是要操作DOM
为什么要用vnode?
- 让组件的渲染逻辑完全从真实的DOM解耦。
- 在其他的平台中重用框架的运行时(允许开发人员自定义渲染解决方案,比如IOS、Android等,不局限于浏览器)—— 跨平台。
- 可以在返回实际渲染引擎之前使用
JS以编程的方式构造、检查、克隆、操作所需要的DOM Node。
现在vnode创建好了,接下来我们看下render开箱的过程:

在render中,先判断vnode有没有,如果没有就执行卸载的逻辑,有的话调用patch:

patch 方法第一个参数n1为旧的vnode, n2为新的vnode,其他的参数我们在此不做探讨。逻辑也很简单,就是基于新vnode的type、shapeFlag来判断vnode的类型,然后调用对应的方法进行处理。我们的例子会走到switch case的default中,然后调用processComponent:

在processComponent中判断有没有旧的vnode,如果没有说明是第一次挂载,执行mountComponent,否则就是更新,执行updateComponent。我们接着看mountComponent:

mountComponent中主要干了以下几件事情:
- 调用
createComponentInstance创建组件实例。 - 调用
setupComponent设置组件的props、slots等。 - 调用
setupRenderEffect设置渲染的副作用方法。
接着我们看下setupRenderEffect:

setupRenderEffect主要干了以下几件事:
- 声明组件更新的函数(响应式的状态变更后要执行的函数)。
- 创建响应式副作用对象。
- 在组件的作用范围内收集这个响应式副作用对象作为依赖。
- 设置响应式副作用渲染函数。
- 调用这个响应式副作用对象中的
run方法。
接着我们看下packages/reactivity/src/effect.ts中effect.run做了些什么:

运行run的时候,可以控制要不要执行后续收集依赖的一步,对于本例,将会执行依赖收集,设置全局的activeEffect为当前effect后,执行组件更新的函数。然后就会调用之前传入的componentUpdateFn:

在componentUpdateFn中,做了这些事情:
- 先判断有没有定义
beforeMount钩子,如果定义了就执行。 - 调用根组件的
render函数,得到根组件的子节点vnode树,即subTree。 - 基于
subTree再次调用patch,基于render返回的vnode,再次进行渲染。把一个组件比作一个箱子,里面有可能是普通的html element(也就是可以直接渲染的),也有可能还是vue component。这里就是递归的开箱,而subTree就是当前的这个箱子(组件)装的东西,箱子(组件)只是个概念,它实际是不需要渲染的,要渲染的是箱子里面的subTree。 - 待递归开箱结束,所有的内容都已被挂载渲染显示,然后判断有没有定义
mounted钩子,如果定义了就执行。 - 到此初始化流程结束。
我们看下本例render函数执行返回的subTree:

继续看下递归的过程:
调用patch,n1为null, n2为subTree,n2的type是div,shapeFlag为17,根据之前对patch的分析,本次即将走到switch case的default中,然后调用processElement:

由于n1为null,会调用mountElement:

mountElement的逻辑大致如下:
- 根据
vnode的type创建真实的DOM节点——el - 处理
vnode的children,children有两种情况:
- 直接就是一个文本,只需要把文本设置到
el即可 - 是一个
vnode数组,此时需要调用mountChildren去处理children,并且此时container变成了el。
-
调用
hostPatchProp设置props -
待该
vnode的所有子节点处理完成,再通过调用hostInsert,将el挂载到container中。
接下来我们看下mountChildren的逻辑:

mountChildren的逻辑非常清晰:就是遍历children中的每个vnode执行patch,不断地递归。
我们可以明显看出:vue从vnode到创建真实DOM到挂载是一个深度优先遍历的过程,可以用一张流程图很清晰的看明白:

总结
如果你看到这里,相信你已经一步一步跟随笔者看完了vue3的初始化过程,简要总结一下:
笔者在文中将组件比作一个箱子,可以这么理解:我们写的组件视图部分 ,要么写template,要么写render函数,不过即使你写的是template,编译过后还是render函数。而render函数会返回vnode,因此我们说的渲染组件并不是把这个组件直接挂载到container中,而是要将vnode转变为真实DOM再挂载。从根组件render函数返回的vnode开始,一步一步patch,遇到html节点就创建,遇到组件就去patch,基于深度优先遍历的算法递归最终将所有vnode都转变成真实DOM并挂载到container。
过程中我们完全避开了其他的逻辑,比如:
- 依赖收集
- setup初始化
- 服务端渲染
- DEV 模式
- ......
这些逻辑不是不重要所以避开,是因为如果一开始你就关注太多的逻辑,你会越看越看不下去,毕竟大多数人都不是最强大脑选手。这些逻辑笔者后续还会有针对性的文章来分析,点个关注不迷路!
这是笔者第一篇源码分析类的文章,如果掘友们有什么建议,或者文中有错误,还请评论指出,谢谢!
如果本文对你有一点点帮助,点个赞支持一下吧,你的每一个【赞】都是我创作的最大动力 ^_^。
转载自:https://juejin.cn/post/7243383094774120506