Vue3源码阅读——初始化流程

前言

接着上一篇Vue3带来了哪些更新和优化,本文跟随笔者走进Vue3的源码世界,一同探索Vue3的初始化流程。

❗️源码中有很多代码是用于处于边缘case的,我们阅读源码先关注主要分支实现的原理,不需要关注那些case,因为你关注的越多,越容易看不下去,因此笔者在文中会直接忽略。

❗️本文字数12000+,阅读完预计需要10分钟。

准备工作

  1. 克隆vuejs/core到本地:git clone https://github.com/vuejs/core.git
  2. 执行pnpm i安装依赖。
  3. 待依赖安装完毕以后可以执行下pnpm run dev-esm打一个runtime的包出来,打出来的包在packages/vue/dist/vue.runtime.esm-bundler.js,并且生成了sourceMap便于调试。
  4. 然后你就可以在packages/vue/example下写一些demo页面来调试你想了解的源码。eg:
    Vue3源码阅读——初始化流程

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中做了以下几件事:

  1. 创建渲染器。
  2. 创建app
  3. 缓存app.mount,重写app.mount
  4. 在重写的的mount中标准化container(可传入字符串 或者 DOM对象),并判断如果根组件不是函数且没有render也没有template时,将container.innerHTML作为template
  5. 调用缓存下来的mount执行真正的挂载。
  6. 返回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操作方法的对象):

Vue3源码阅读——初始化流程

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

Vue3源码阅读——初始化流程

接着调用同文件中的baseCreateRenderer方法,这个方法源码差不多2000行,里面包含了生成组件vnodevnode转变真实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中主要做了以下几件事情:

  1. 调用createAppContext创建应用的上下文对象 。

    Vue3源码阅读——初始化流程

  2. 返回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
    }
  }
  1. 调用createVNode创建根组件的vnodecreateVNode其实就是h
  2. 基于根组件的vnode调用render开箱。

源码中调用的createVNode在开发环境会先转化一下参数,再调用_createVNode

export const createVNode = (
  __DEV__ ? createVNodeWithArgsTransform : _createVNode
) as typeof _createVNode

看下_createVNode的处理逻辑:

Vue3源码阅读——初始化流程

_createVNode中主要做了以下几件事情:

  1. 处理接收到的typevnode的情况
  2. 标准化class编写的组件
  3. 标准化class、style、props
  4. 计算shapeFlag
  5. 调用createBaseVNode

简单看下createBaseVNode吧:

Vue3源码阅读——初始化流程

createBaseVNode逻辑非常清晰:

  1. 创建一个vnode,有很多属性。
  2. 标准化children
  3. 返回vnode

来看下我们这个例子根组件App的vnode长啥样:

Vue3源码阅读——初始化流程

没错,就是使用一个对象来描述我们的组件或者DOM节点。

聊几个跟vnode相关的问题:

用了vnode,就不操作DOM了吗? —— 肯定不是的,还是要操作DOM

为什么要用vnode?

  1. 让组件的渲染逻辑完全从真实的DOM解耦。
  2. 在其他的平台中重用框架的运行时(允许开发人员自定义渲染解决方案,比如IOS、Android等,不局限于浏览器)—— 跨平台
  3. 可以在返回实际渲染引擎之前使用JS以编程的方式构造、检查、克隆、操作所需要的DOM Node

现在vnode创建好了,接下来我们看下render开箱的过程:

Vue3源码阅读——初始化流程

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

Vue3源码阅读——初始化流程

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

Vue3源码阅读——初始化流程

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

Vue3源码阅读——初始化流程

mountComponent中主要干了以下几件事情:

  1. 调用createComponentInstance创建组件实例。
  2. 调用setupComponent设置组件的propsslots等。
  3. 调用setupRenderEffect设置渲染的副作用方法。

接着我们看下setupRenderEffect

Vue3源码阅读——初始化流程

setupRenderEffect主要干了以下几件事:

  1. 声明组件更新的函数(响应式的状态变更后要执行的函数)。
  2. 创建响应式副作用对象。
  3. 在组件的作用范围内收集这个响应式副作用对象作为依赖。
  4. 设置响应式副作用渲染函数。
  5. 调用这个响应式副作用对象中的run方法。

接着我们看下packages/reactivity/src/effect.tseffect.run做了些什么:

Vue3源码阅读——初始化流程

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

Vue3源码阅读——初始化流程

componentUpdateFn中,做了这些事情:

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

我们看下本例render函数执行返回的subTree

Vue3源码阅读——初始化流程

继续看下递归的过程:
调用patchn1nulln2subTreen2typedivshapeFlag17,根据之前对patch的分析,本次即将走到switch casedefault中,然后调用processElement

Vue3源码阅读——初始化流程

由于n1null,会调用mountElement:

Vue3源码阅读——初始化流程

mountElement的逻辑大致如下:

  1. 根据vnodetype创建真实的DOM节点——el
  2. 处理vnodechildrenchildren有两种情况:
  • 直接就是一个文本,只需要把文本设置到el即可
  • 是一个vnode数组,此时需要调用mountChildren去处理children,并且此时container变成了el
  1. 调用hostPatchProp设置props

  2. 待该vnode的所有子节点处理完成,再通过调用hostInsert,将el挂载到container中。

接下来我们看下mountChildren的逻辑:

Vue3源码阅读——初始化流程

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

Vue3源码阅读——初始化流程

总结

如果你看到这里,相信你已经一步一步跟随笔者看完了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

(0)
上一篇 2023年6月12日 上午10:42
下一篇 2023年6月12日 上午10:53

相关推荐

发表回复

登录后才能评论