本篇章内容不涉及编译模块,只讲runtime环境,Vue3的底层运行原理。为什么叫大话,因为不是精讲。
创建Vue3应用
我们知道Vue3的编译之后的应用代码大概长下面这样:
const App = {
setup() {
},
render() {
return createVNode()
}
}
然后通过下面这样进行调用,就可以把整个应用程序运行起来并把视图渲染到浏览器上了。
const app = createApp(App)
app.mount("#app")
那么这个过程Vue3底层到底在干了些什么呢?
创建渲染器(renderer)
首先createApp底层是通过createRenderer创建了一个渲染器(renderer)。所谓渲染器就是把我们写的那些元素进行创建,删除,修改;元素属性的创建,删除,修改。那么不同的平台,对元素操作的API都不一样,所以在createRender的时候,就需要根据我们的平台对元素操作特性来创建渲染器。我们平时一般用到的都是Vue3默认提供的runtime-dom这个包来创建的渲染器(renderer),这个runtime-dom就是根据我们的浏览器的对元素操作的特性进行创建渲染器。
// 注意下面代码只是简单模拟,实际要复杂很多很多,但原理是一致的。
const renderer = createRenderer({
createElement,
createTextNode,
removeChild,
insertBefore,
})
createElement,createTextNode,removeChild,insertBefore等等都是浏览器的原生API。
那么这个createRenderer又长啥样的呢?
// 注意下面代码只是简单模拟,实际要复杂很多很多,但原理是一致的。
function createRenderer() {
function render() {
}
// ...
return {
// ...
createApp: createAppAPI(render)
}
}
我们可以看到createRenderer函数里面返回了一个对象,对象中,有一个重要的方法createApp,而这个createApp则是由另外一个工厂函数createAppAPI创建的。那么这个createAppAPI函数又长啥样呢?
渲染器对象中的createApp方法
// 注意下面代码只是简单模拟,实际要复杂很多很多,但原理是一致的。
function createAppAPI(render) {
return function creatApp(rootComponent) {
const app = {
mount(rootContainer) {
const vnode = createVNode(rootComponent)
render(vnode, rootContainer)
}
}
return app
}
}
我们可以看到在这个createAppAPI的工厂函数里有一个真正创建Vue3应用的creatApp方法,在这个方法里面创建了一个Vue3实例应用app,里面有一个我们在外面经常用到的mount方法。所以在外面调用那个app.mount('#app')的时候,就是根据传进来的根组件对象rootComponent创建根组件的VNode,这个rootComponent就是creatApp(App)传进来的那个App。
渲染器中的render方法
创建了根组件的VNode之后就通过渲染器里面render方法进行渲染挂载这个根组件的VNode。我们回看上面可以看到这个render方法是在createRenderer函数里面的,在返回的渲染器对象中的createApp方法是通过createAppAPI(render)传参到mount方法里面的形成一个闭包,然后在外面创建Vue3应用的时候,就可以使用了。
这个时候,我们又要详细回到渲染器函数里面看这个render函数又做了什么
// 注意下面代码只是简单模拟,实际要复杂很多很多,但原理是一致的。
function createRenderer() {
function render(vnode) {
patch(null,vnode)
}
// n1叫老的VNode,n2叫新的VNode
function patch(n1,n2) {
const { shapeFlag } = n2
switch(shapeFlag){
case 'element':
processElement(n1,n2)
case 'component':
processComponent(n1,n2)
}
}
// ...
return {
// ...
createApp: createAppAPI(render)
}
}
在这个render方法里就调用了一个patch方法。题外话,这个render方法跟react中的ReactDom.render方法很像,都是把一个应用对象渲染到节点上。
渲染器中的patch方法
patch方法根据新的VNode上的shapeflag判断执行哪个流程,很明显第一次进来的时候,n2是那个根组件的VNode,所以走的是processComponent的流程。那么processComponent里面又做了什么呢?
// 注意下面代码只是简单模拟,实际要复杂很多很多,但原理是一致的。
function processComponent(n1, n2) {
if(!n1) {
mountComponent(n2)
} else {
updateComponent(n1, n2)
}
}
processComponent里面主要是判断是否存在老VNode然后决定是走挂载组件还是更新组件,初始化的时候自然是走挂载组件分支了,也就是mountComponent(n2)。
组件初始化流程
创建组件实例
// 注意下面代码只是简单模拟,实际要复杂很多很多,但原理是一致的。
function mountComponent(initialVNode) {
const instance = createComponentInstance(initialVNode)
setupComponent(instance)
setupRenderEffect(instance)
}
mountComponent里面首先是根据当前的组件VNode创建了一个组件实例instance,然后初始化组件实例setupComponent(instance)
实现组件代理对象
// 注意下面代码只是简单模拟,实际要复杂很多很多,但原理是一致的。
function setupComponent(instance) {
initProps()
initSlots()
instance.proxy = new Proxy({ _:instance }, {
get({ _: instance}, key) {
const { setupState, props } = instance
if(hasOwn(setupState, key)) {
return setupState[key]
} else if(hasOwn(props, key)) {
return props[key]
}
}
})
instance.setupState = instance.VNode.setup()
}
在setupComponent里面主要做了props,slots的初始化,然后利用Proxy创建了一个代理对象挂在了组件的实例对象上,将来调用组件的render方法创建组件的元素VNode时,访问组件setup方法里面返回的数据,就是通过代理对象的getter方法获取。通过上面的模拟代码,我们可以清楚地看到将来访问某个key的值,那么就会去看这个key是否在setup的返回对象里还是在props里面。
那么初始化了组件的实例对象之后,就执行到了setupRenderEffect这个函数。
更新逻辑setupRenderEffect
// 注意下面代码只是简单模拟,实际要复杂很多很多,但原理是一致的。
function setupRenderEffect(instance) {
instance.update = effect(() => {
if(!instance.isMounted) {
const subTree = instance.render.call(instance.proxy)
patch(null, subTree)
instance.isMounted = true
} else {
const subTree = instance.render.call(instance.proxy)
const prevSubTree = instance.subTree
patch(prevSubTree, subTree)
}
})
}
setupRenderEffect这个函数就利用到reactivity库里的effect的这个API,effect这个API接收一个函数作为参数,并且会把这个函数包装成功一个副作用函数,并会执行一次,并返回这个函数的本身,叫runner。很明显在setupRenderEffect里面把这个返回的runner函数赋值给了组件实例上的update方法,方便将来更新的时候直接调用这个组件实例的update方法。
在这个setupRenderEffect函数里面的effect函数里第一次走的肯定是还没有挂载的这个分支if(!instance.isMounted)。在这里调用组件实例的render方法去生成组件元素的VNode,也叫subTree,然后再通过patch方法去挂载组件元素的VNode,需要说明的就是这个render方法就是我们写的Vue3应用对象上的render方法,而且我们平时写的.vue文件里的template里的内容最终也会被编译成render函数,render函数里面会调用setup或者props里的数据,就是通过call方法绑定了组件实例上的代理对象,从而通过代理对象实现对setup里返回的数据或props里的数据的获取。这个回看上面的setupComponent方法里做的事情就很清楚了。
那么我们创建了组件元素的VNode之后通过patch方法去挂载组件元素的VNode的时候,我们的流程就又回到patch方法里,所以VNode的渲染就是一个不停调用patch方法的过程。
这个时候我们再回到patch方法里。
元素初始化流程
// 注意下面代码只是简单模拟,实际要复杂很多很多,但原理是一致的。
// n1叫老的VNode,n2叫新的VNode
function patch(n1,n2) {
const { shapeFlag } = n2
switch(shapeFlag){
case '如果是元素-element':
processElement(n1,n2)
case '如果是组件-component':
processComponent(n1,n2)
}
}
这个时候还处在挂载的流程,所以n1是还不存在的,而这次渲染的是组件的元素的VNode,所以走的自然是processElement流程了。
实际是根据VNode的shapeFlag通过位运算来计算得到结果的,而VNode的shapeFlag是在创建VNode的时候,根据VNode的type类型来判断是元素还是组件。如果type是String类型那么shapeFlag就是元素类型,如果type是Object那么shapeFlag就是组件。
// 注意下面代码只是简单模拟,实际要复杂很多很多,但原理是一致的。
function processElement(n1,n2) {
if(!n1) {
mountElement(n2)
} else {
updateElment(n1, n2)
}
}
processElement里流程跟processComponent有点像,也是判断是否存在老的VNode然后走不同的分支流程。那么初始化的时候还没有老的VNode,就走mountElement,如果存在老的VNode那么走的就是updateElement了,大名鼎鼎的diff算法过程就发生在updateElement里面。我们现在是初始化流程,那么我们先看mountElement里面在做什么。
// 注意下面代码只是简单模拟,实际要复杂很多很多,但原理是一致的。
function mountElement(vnode) {
const el = hostCreateElement(vnode.type)
const { children } = vnode
if (如果孩子是文本) {
// 就直接创建文本
el.textContent = children
} else if (如果孩子是数组) {
//那么继续初始化孩子
mountChildren(vnode.children)
}
const { props } = vnode
for (const key in props) {
const val = props[key]
// 创建元素属性
hostPatchProp(el, key, null, val)
}
// 把元素插入到宿主上
hostInsert(el, container, anchor)
}
需要注意的是mountElement里面的hostCreateElement、hostPatchProp、hostInsert方法都是在创建渲染器的时候根据平台特性传进来处理元素的平台特性API。
创建元素hostCreateElement
首先根据VNode的type类型创建元素,type类型在浏览器平台下可能是div,span,p这些DOM元素类型。创建了元素之后,继续创建元素内部的孩子节点。
如果VNode的children是文本节点,那么就直接创建文本节点就可以了,如果VNode的children是数组,那么就要继续初始化孩子节点。
创建属性hostPatchProp
如果存在props那么就创建元素的属性,在浏览器平台下使用的则是setAttribute、removeAttribute 这些API进行处理。
插入到宿主元素中hostInsert
最后通过insertBefore把创建的元素插入到宿主元素中
创建孩子节点mountChildren
如果VNode的children是数组,那么就要继续初始化孩子节点。
// 注意下面代码只是简单模拟,实际要复杂很多很多,但原理是一致的。
function mountChildren(children) {
children.forEach((v) => {
patch(null, v)
})
}
在mountChildren里继续循环调用patch方法渲染孩子VNode,流程跟之前一样,值得主要的是,如果孩子里面存在组件VNode,那么在patch方法里面会继续走processComponent的组件流程继续创建组件实例,初始化,生成组件元素VNode然后继续调用patch方法渲染组件元素的VNode,如此一边又一边的循环,直到把所有的VNode都渲染完毕,这个时候页面上就呈现视图了。
至此,Vue3的初始化流程就全部完毕了。
更新流程
上面我们在将setupRenderEffect函数的时候,已经初步讲了一些,那么下面我们详细再来讲一讲这个函数。setupRenderEffect函数就是利用了effect这个响应式数据依赖收集的API创建了一个渲染函数的更新机制。
// 注意下面代码只是简单模拟,实际要复杂很多很多,但原理是一致的。
function setupRenderEffect(instance) {
instance.update = effect(() => {
if(!instance.isMounted) {
const subTree = instance.render.call(instance.proxy)
patch(null, subTree)
instance.isMounted = true
} else {
const subTree = instance.render.call(instance.proxy)
const prevSubTree = instance.subTree
patch(prevSubTree, subTree)
}
}, {
scheduler() {
queueJobs(instance.update)
}
})
}
如果不太清楚effect函数的同学,我在这里稍微补充说明一下effect这个函数,它第一个参数接收一个函数并包装成一个副作用函数,第二个参数则接收一个scheduler函数作为参数,当里面的依赖数据发生更新的时候,effect内部会再次执行副作用函数,在准备执行副作用函数之前判断是否存在scheduler,如果存在scheduler那么就不执行副作用函数了,而是执行scheduler。之所这么做是因为把所有的更新函数的执行操作都放在一个异步任务队列里面,等所有数据都更新完成再一次统一执行异步任务队列里面的更新函数,进行VNode的更新,这个也就是大名鼎鼎的nextTick函数内部执行机制原理。
那么当执行异步任务队列的里instance.update函数的时候就走到setupRenderEffect里的effect里的依赖函数里的else流程,也就是下面的代码。
const subTree = instance.render.call(instance.proxy)
const prevSubTree = instance.subTree
patch(prevSubTree, subTree)
这个时候重新执行组件实例里的render方法生成新的组件元素VNode,加上之前的VNode,这个时候,就形成了新老VNode,然后再进行调取patch方法进行渲染VNode。那么到patch方法里再走各自的processElement流程和processComponent流程,再到processElement流程里就走更新元素节点的流程,也就是Vue diff算法发生的地方,vue diff也是一个大课题,这里就不展开说了,可以查看我之前写的文章根据大崔哥的mini-vue来理解vue3中的diff算法,processComponent方法里走的就是更新组件的流程。组件更新也是一个大课题,有空再详细说说组件更新机制原理。
那么这次大话Vue3的源码解析就到这里了,主要的流程就是这些,有空再大话Vue3的其他模块,任何一个模块都可以详细展开讨论。