Vue3源码07: 故事要从createApp讲起

994 阅读12分钟

推荐阅读

Vue3源码

微前端源码

React18源码


theme: v-green highlight: atom-one-dark

读完前面的文章,相信大家已经能对Vue3的响应式原理有比较深入的掌握。但仅仅掌握响应式原理是不够的,我认为Vue33大支柱。

  • 其一,就是前面讲的响应式系统;
  • 其二,是将我们编写的templatejsx代码转化为虚拟Node的过程,这个过程由compiler-domcompiler-core提供的compiler函数实现,该函数的返回值是一个render函数,在后续的文章中我统一称这个render函数为编译render
  • 其三,是我们将虚拟Node转化为真实Noderender函数,在后续的文章中我统一称这个render函数为渲染render

注:

  1. 由于本文分析的源码都是runtime-domruntime-core中的内容,如无特别说明,本文提到的render函数都指渲染render函数。
  2. 上面提到的虚拟Node,常常称作虚拟DOM,二者含义一样,真实Node真实DOM同理。

其实上面关于这3大支柱的描述,已经高度概括了整个Vue3框架的核心功能。本文会以一个简短的案例开始,引出createApp函数实现,在这个分析的过程中,会讲到runtime-domruntime-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-domcompiler-core的文章中进行细致的讲解,本文先不提。

编写不需要编译转化的代码

要理解程序的正常运行,少不了要使用虚拟Node,我们将程序改造成如下形式:

<!--代码片段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函数,这两者都暂时不提,后续的文章中都会详细介绍。

事实上,代码片段2RootComponent对象的编译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>
// 此处省略若干代码...

我们将代码做了一系列的精简后,发现三个重点:

  1. 真正的Vue应用对象,是执行ensureRenderer().createApp(...args)创建的,而ensureRenderer函数内部调用了createRenderer函数。这个createRenderer函数位于runtime-core中;
  2. 在调用函数createRender函数的时候,传入了参数rendererOptions,这些参数是一些操作DOM节点和DOM节点属性的具体方法。
  3. 创建了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也就是上文代码片段5const 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,而该函数的返回值是一个对象appapp其实就是我们创建的Vue应用,app上有很多属性和方法,代表了Vue应用对象所具备的信息和能力。

mount方法

就如代码片段1中所表示的那样,创建一个Vue应用完成后的第一个操作就是调用mount方法进行挂载,其他内容我们可以暂时忽略,先关注appmount方法实现:

// 代码片段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中省略了很多和开发阶段相关的代码,可以概括为这样几项主要工作:

  1. 将根组件对象rootComponent(代码片段4中的传入的值RootComponent)转化成虚拟Node;
  2. 调用render函数,将这个虚拟Node转化成真实Node并挂载到rootContainer所指向的元素上。那这里的render函数来自哪里呢?从代码片段8不难发现,是通过参数传入的,那这个参数从哪里来呢,我们再回到代码片段7发现正是函数baseCreateRenderer内部声明的render函数。
  3. 调用getExposeProxy函数得到一个代理对象并返回。

至于如何将组件对象转化成虚拟Node,以及render函数的具体实现,本文都不继续深入,因为这两者都是一个比较大的新的话题,需要新的文章来阐述。下面分析一下这里的getExposeProxy函数,因为这个函数和我们前面讲的响应式系统相关,而对于响应式系统已经深入掌握过了,理解这个函数应该会比较容易。

getExposeProxy

// 代码片段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.exposedpublicPropertiesMap具有的属性,否则就返回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>
// 代码片段2,文件名:Child.vue
export default {
  expose: ['publicData', 'publicMethod'],
  data() {
    return {
      publicData: 'foo',
      privateData: 'bar'
    }
  },
  methods: {
    publicMethod() {
      /* ... */
    },
    privateMethod() {
      /* ... */
    }
  }
}

关于ref的这种特殊用法,大家可以在官方文档中查阅出更详细的内容,在这里需要知道,如果子组件给expose属性设置了值,则父组件只能拿到expose所声明的这些属性对应的值。这也就是为什么代码片段10中要有这样一个代理对象,反过来我们也知道了保护子组件的内容不被父组件随意访问的机制的实现原理。

总结

本文先抛出一个具体案例,再从createApp讲起,跟随函数调用栈,提到了编译render、渲染render两个函数,分析了createRenderercreateAppAPImountgetExposeProxy等函数实现。到这里大家可以理解创建一个Vue应用的基本过程。本文为分析渲染render函数的具体实现打下了基础,关于渲染render函数的具体实现我将在下一篇文章中正是开始介绍,敬请朋友们期待。

写在最后

读完文章觉得有收获的朋友们,可以做下面几件事情支持:

  • 如果点赞,点在看,转发可以让文章帮助到更多需要帮助的人;
  • 如果是微信公众号的作者,可以找我开通白名单转载我的原创文章;

最后,请朋友们关注我的微信公众号: 杨艺韬,可以获取我的最新动态。