前言
接着上一篇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 作者:Lvzl