- Vue3源码01 : 代码管理策略-monorepo
- Vue3源码02: 项目构建流程和源码调试方法
- Vue3源码03: Vue3响应式核心原理
- Vue3源码04: Vue3响应式系统源码实现1/2
- Vue3源码05 : Vue3响应式系统源码实现(2/2)
- Vue3源码06: reactive、ref相关api源码实现
读完前面的文章,相信大家已经能对Vue3
的响应式原理有比较深入的掌握。但仅仅掌握响应式原理是不够的,我认为Vue3
有3大支柱。
- 其一,就是前面讲的响应式系统;
- 其二,是将我们编写的
template
、jsx
代码转化为虚拟Node的过程,这个过程由compiler-dom
、compiler-core
提供的compile函数实现,该函数返回一个render
函数,在后续的文章中我统一称这个render
函数为编译render; - 其三,是我们将虚拟Node转化为真实Node的
render
函数,在后续的文章中我统一称这个render
函数为渲染render;
“注:
- 由于本文分析的源码都是
runtime-dom
、runtime-core
中的内容,如无特别说明,本文提到的render
函数都指渲染render
函数。 - 上面提到的虚拟Node,常常称作虚拟DOM,二者含义一样,真实Node和真实DOM同理。
”
其实上面关于这3大支柱的描述,已经高度概括了整个Vue3
框架的核心功能。本文会以一个简短的案例开始,引出createApp
函数实现,在这个分析的过程中,会讲到runtime-dom
和runtime-core
之间的代码协作关系,以及createApp
函数的具体实现逻辑,实现逻辑讲到对render
函数的调用为止。至于render
函数的实现细节会在后续多篇文章中进行逐步分析。
案例-初始化一个Vue3应用
在实际开发中我们通常会用下面的来初始化一个Vue
应用:
// 代码片段1
import { createApp } from 'vue'
// import the root component App from a single-file component.
import App from './App.vue'
const vueApp = createApp(App)
vueApp.mount('#app')
简单的几行代码,实际上有很多工作要做,因为首先要把App.vue
的内容转化成虚拟Node,在编译完成后,代码片段1中传给函数createApp
的参数App
是一个组件对象。而vueApp
是一个对象,该对象有一个方法是mount
,该函数的功能就是把组件对象App
转化为虚拟Node,进而将该虚拟Node转化成真实Node并让其挂载到#app
所指向的DOM
元素下面。关于编译的过程,将来会在分析compiler-dom
和compiler-core
的文章中进行细致的讲解,本文先不提。
编写不需要编译转化的代码
要理解程序的正常运行,少不了要使用虚拟Node,我们将程序改造成如下形式:
代码语言:javascript复制<!--代码片段2-->
<html>
<body>
<div id="app1"></div>
</body>
<script src="./runtime-dom.global.js"></script>
<script>
const { createApp, h } = VueRuntimeDOM
const RootComponent = {
render(){
return h('div','杨艺韬喜欢研究源码')
}
}
createApp(RootComponent).mount("#app1")
</script>
</html>
最明显的变化就是我们在直接定义组件对象,而不需要通过编译把App.vue
文件的内容转化成组件对象,同时在组件对象中手写了一个编译render
函数,也不需要继续编译把template
转化成编译render
函数来。注意这里这里涉及两个编译过程,一个是.vue
文件转化成组件对象的编译过程,另一个编译过程是将组件对象中所涉及的template
转化成编译render
函数,这两者都暂时不提,后续的文章中都会详细介绍。
事实上,代码片段2中RootComponent
对象的编译render
函数会在某个时机执行,具体在哪里执行,我们在本文分析createApp
内部实现的时候进行解释。
初识编译编译render函数
但是我们知道Vue3
一个重要的特点是可以自由控制哪些数据具备响应式的能力,这就离不开我们的setup
方法。我们把代码片段2进一步转化成如下形式:
<!--代码片段3-->
<html>
<body>
<div id="app" haha="5"></div>
</body>
<script src="./runtime-dom.global.js"></script>
<script>
const { createApp, h, reactive } = VueRuntimeDOM
const RootComponent = {
setup(props, context){
let relativeData = reactive({
name:'杨艺韬',
age: 60
})
let agePlus = ()=>{
relativeData.age
}
return {relativeData, agePlus}
},
render(proxy){
return h('div', {
onClick: proxy.agePlus,
innerHTML:`${proxy.relativeData.name}已经${proxy.relativeData.age}岁了,点击这里继续增加年龄`
} )
}
}
createApp(RootComponent).mount("#app")
</script>
</html>
我们从代码片段3中可以发现,setup
方法的返回值,可以在编译render
函数中通过prxoy
参数获取到。大家可能会觉得这种写法有些冗余,确实是这样。因为这里的编译render
函数本身就是Vue2
的产物。在Vue3
中我们可以直接这样写,代码变化如下:
<!--代码片段4-->
<html>
<body>
<div id="app" haha="5"></div>
</body>
<script src="./runtime-dom.global.js"></script>
<script>
const { createApp, h, reactive } = VueRuntimeDOM
const RootComponent = {
setup(props, context){
let relativeData = reactive({
name:'杨艺韬',
age: 60
})
let agePlus = ()=>{
relativeData.age
}
return ()=>h('div', {
onClick: agePlus,
innerHTML:`${relativeData.name}已经${relativeData.age}岁了,点击这里继续增加年龄`
} )
}
}
createApp(RootComponent).mount("#app")
</script>
</html>
在实际开发中,一般来说setup
的返回值,要么是一个对象,要么是一个返回jsx
的函数,这里的jsx
代码会在编译阶段转化成类似代码片段4的形式,这种情况下这些代码所在文件格式是tsx
。而如果是返回对象,通常是在.vue
文件中编写了template
代码。这两种形式都可以采用,但需要知道的是template
会有编译时的静态分析,提升性能,而jsx
则更加灵活。
小结
上面我们简要介绍了在Vue3
中一些简单的组件编码形式,理解了传递给函数createApp
的组件对象在实际工作中是如何发挥基础作用的。下面我们就进入createApp
函数的实现。在分析createApp
的时候,有时候会再次回顾上文提到的一些运行效果,让这些运行效果和具体源码对照起来,更容易加深对Vue3
的理解。
createApp的代码实现
createApp的外包装
我们先将视线移到core/packages/runtime-dom
目录下的index.ts
文件中去,会发现对外暴露了很多API
,但是没关系,我们先看我们今天的主角createApp
,其他暂时忽略,将来再单独介绍其他暴露的API
的具体含义:
// 代码片段5
// 此处省略若干代码...
import { nodeOps } from './nodeOps'
import { patchProp } from './patchProp'
// 此处省略若干代码...
const rendererOptions = extend({ patchProp }, nodeOps)
// 此处省略若干代码...
function ensureRenderer() {
return (
renderer ||
(renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
)
}
// 此处省略若干代码...
export const createApp = ((...args) => {
const app = ensureRenderer().createApp(...args)
// 此处省略若干代码...
const { mount } = app
app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
// 此处省略若干代码...
const proxy = mount(container, false, container instanceof SVGElement)
// 此处省略若干代码...
return proxy
}
return app
}) as CreateAppFunction<Element>
// 此处省略若干代码...
我们将代码做了一系列的精简后,发现三个重点:
- 真正的
Vue
应用对象,是执行ensureRenderer().createApp(...args)
创建的,而ensureRenderer
函数内部调用了createRenderer
函数。这个createRenderer
函数位于runtime-core
中; - 在调用函数
createRender
函数的时候,传入了参数rendererOptions
,这些参数是一些操作DOM
节点和DOM
节点属性的具体方法。 - 创建了
Vue
应用对象app
后,重写了其mount
方法,重写的mount
方法内部,做了些跟浏览器强相关的操作,比如清空DOM
节点。接着又调用了重写前的mount
方法进行挂载操作。
总之,runtime-dom
真正提供的能力是操作浏览器平台DOM
节点。而跟平台无关的动作全部在runtime-core
完成,有些朋友可能会疑惑,怎么就跟平台无关了,我们不是传递了操作具体DOM
节点的方法rendererOptions
给了runtime-core
暴露的方法了吗。正是因为操作真实浏览器DOM
的方法是通过参数传递过去的,所以这里也可以是其他平台操作节点的具体方法。也就是说,runtime-core
只知道需要对某些节点进行增添、修改、删除,但这些节点是浏览器DOM
还是其他平台的节点都不会关系,参数传来的是什么,runtime-core
就调用什么,这就是所谓的和平台无关,其实在实际编码中完全可以借鉴这种分层的编码思想。
createRenderer
接下来我们就进入core/packages/runtime-core/src/render.ts
中的createRenderer
函数:
// 代码片段6
export function createRenderer<
HostNode = RendererNode,
HostElement = RendererElement
>(options: RendererOptions<HostNode, HostElement>) {
return baseCreateRenderer<HostNode, HostElement>(options)
}
我们接着进入函数baseCreateRenderer
,该函数2000多行代码,我对其进行了大量精简:
// 代码片段7
function baseCreateRenderer(
options: RendererOptions,
createHydrationFns?: typeof createHydrationFunctions
): any {
// 此处省略2000行左右的代码...
return {
render,
hydrate,
createApp: createAppAPI(render, hydrate)
}
}
代码片段7中省略了绝大部分代码,我只留下了返回值。实际上,函数baseCreateRenderer
可以说是整个runtime-core
的核心,因为所有的关于虚拟Node
转化成真实Node
的逻辑都包括在了该函数中,常常提起的diff
算法也包括在其中。本文暂时不会分析baseCreateRenderer
函数内部的逻辑,贴合主题,只关注这里的createApp
对应的值createAppAPI(render, hydrate)
,实际上createAppAPI(render, hydrate)
返回的是一个函数。这里的createApp
也就是上文代码片段5中const app = ensureRenderer().createApp(...args)
的createApp
。
createAppAPI
我们进入位于core/packages/runtime-core/src/apiCreateApp.ts
中的函数createAppAPI
:
// 代码片段8
export function createAppAPI<HostElement>(
render: RootRenderFunction,
hydrate?: RootHydrateFunction
): CreateAppFunction<HostElement> {
return function createApp(rootComponent, rootProps = null) {
// 此处省略若干代码...
const app: App = (context.app = {
_uid: uid ,
_component: rootComponent as ConcreteComponent,
_props: rootProps,
_container: null,
_context: context,
_instance: null,
version,
get config() {
return context.config
},
set config(v) {
// 此处省略若干代码...
},
use(plugin: Plugin, ...options: any[]) {
// 此处省略若干代码...
},
mixin(mixin: ComponentOptions) {
// 此处省略若干代码...
},
component(name: string, component?: Component): any {
// 此处省略若干代码...
},
directive(name: string, directive?: Directive) {
// 此处省略若干代码...
},
mount(
rootContainer: HostElement,
isHydrate?: boolean,
isSVG?: boolean
): any {
// 此处省略若干代码...
},
unmount() {
// 此处省略若干代码...
},
provide(key, value) {
// 此处省略若干代码...
}
})
// 此处省略若干代码...
return app
}
}
从代码片段8中可以看出,createAppAPI
函数返回了一个函数createApp
,而该函数的返回值是一个对象app
,app
其实就是我们创建的Vue
应用,app
上有很多属性和方法,代表了Vue
应用对象所具备的信息和能力。
mount方法
就如代码片段1中所表示的那样,创建一个Vue
应用完成后的第一个操作就是调用mount
方法进行挂载,其他内容我们可以暂时忽略,先关注app
的mount
方法实现:
// 代码片段9
mount(
rootContainer: HostElement,
isHydrate?: boolean,
isSVG?: boolean
): any {
if (!isMounted) {
const vnode = createVNode(
rootComponent as ConcreteComponent,
rootProps
)
vnode.appContext = context
// 此处省略若干代码...
if (isHydrate && hydrate) {
hydrate(vnode as VNode<Node, Element>, rootContainer as any)
} else {
render(vnode, rootContainer, isSVG)
}
isMounted = true
app._container = rootContainer
;(rootContainer as any).__vue_app__ = app
// 此处省略若干代码...
return getExposeProxy(vnode.component!) || vnode.component!.proxy
} // 此处省略若干代码...
}
代码片段9中省略了很多和开发阶段相关的代码,可以概括为这样几项主要工作:
- 将根组件对象
rootComponent
(代码片段4中的传入的值RootComponent
)转化成虚拟Node; - 调用
render
函数,将这个虚拟Node转化成真实Node并挂载到rootContainer
所指向的元素上。那这里的render
函数来自哪里呢?从代码片段8不难发现,是通过参数传入的,那这个参数从哪里来呢,我们再回到代码片段7发现正是函数baseCreateRenderer
内部声明的render
函数。 - 调用
getExposeProxy
函数得到一个代理对象并返回。
至于如何将组件对象转化成虚拟Node,以及render函数的具体实现,本文都不继续深入,因为这两者都是一个比较大的新的话题,需要新的文章来阐述。下面分析一下这里的getExposeProxy
函数,因为这个函数和我们前面讲的响应式系统相关,而对于响应式系统已经深入掌握过了,理解这个函数应该会比较容易。
getExposeProxy
代码语言:javascript复制// 代码片段10
export function getExposeProxy(instance: ComponentInternalInstance) {
if (instance.exposed) {
return (
instance.exposeProxy ||
(instance.exposeProxy = new Proxy(proxyRefs(markRaw(instance.exposed)), {
get(target, key: string) {
if (key in target) {
return target[key]
} else if (key in publicPropertiesMap) {
return publicPropertiesMap[key](instance)
}
}
}))
)
}
}
代码片段10的核心就在于这个新创建的Proxy
实例。而这个Proxy
初始化的对象是proxyRefs(markRaw(instance.exposed))
的执行结果。我们先不管instance.exposed
具体是什么含义,但从程序逻辑来看可以这样理解,如果通过instance.exposeProxy
获取数据,只能获取instance.exposed
或publicPropertiesMap
具有的属性,否则就返回undefined
。至于这里为什么先调用markRaw
再调用proxyRefs
,是因为proxyRefs
内部做了条件判断,如果传入的对象本身就是响应式的就直接返回了,所以需要先处理成非响应式的对象。而这里的proxyRefs
是为了访问原始值的响应式对象的值的时候不用再写.value
,这在上一篇文章中已经分析过。
ref的特殊用法
那这个instance.exposed
到底是什么呢?我们先来看看ref
获取子组件的内容的实践应用:
// 代码片段11
<script>
import Child from './Child.vue'
export default {
components: {
Child
},
mounted() {
// this.$refs.child will hold an instance of <Child />
}
}
</script>
<template>
<Child ref="child" />
</template>
代码语言:javascript复制// 代码片段2,文件名:Child.vue
export default {
expose: ['publicData', 'publicMethod'],
data() {
return {
publicData: 'foo',
privateData: 'bar'
}
},
methods: {
publicMethod() {
/* ... */
},
privateMethod() {
/* ... */
}
}
}
关于ref
的这种特殊用法,大家可以在官方文档中查阅出更详细的内容,在这里需要知道,如果子组件给expose
属性设置了值,则父组件只能拿到expose
所声明的这些属性对应的值。这也就是为什么代码片段10中要有这样一个代理对象,反过来我们也知道了保护子组件的内容不被父组件随意访问的机制的实现原理。
总结
本文先抛出一个具体案例,再从createApp
讲起,跟随函数调用栈,提到了编译render
、渲染render
两个函数,分析了createRenderer
、createAppAPI
、mount
、getExposeProxy
等函数实现。到这里大家可以理解创建一个Vue
应用的基本过程。本文为分析渲染render
函数的具体实现打下了基础,关于渲染render
函数的具体实现我将在下一篇文章中正式开始介绍,敬请朋友们期待。