3-Vue源码之【createApp】

1,034 阅读6分钟

前言

编译篇 简单学习完,上文最后的 runtimeDom,实际是 runtime-dom/index.ts 文件中所导出的内容。我们点进去会发现一个非常眼熟的 API, createAppruntime-dom 导出了该 API,我们先忘记之前的编译篇,从头开始,从一般创建的 main.tscreateApp 开始,看看 Vue 都做了什么处理。

// main.ts 例子
const { reactive, createApp } = Vue
createApp(App).mount('#app')

createApp

export const createApp = (...args: any) => {
  /**
   * 【重要且长】放后面分析
   * baseCreateRenderer 接收一个选项对象,该对象主要包含了 nodeOps(真实DOM操作) 和 patchProp(属性的操作)
   */
  const app = baseCreateRenderer(extend({ patchProp }, nodeOps)).createApp(...args)

  const { mount } = app
  // 重写app的mount方法
  app.mount = (containerOrSelector: string) => {
    const container = document.querySelector(containerOrSelector)

    if (!container) return

    const component = app._component
    if (!isFunction(component) && !component.template && !component.render) {
      // 如果当前 app 实例中不存在 模板,函数,render方法 ,那么则将 container 处的模板赋值给他
      component.template = container.innerHTML
    }

    // 清空 container 的内容(因为 container 之前的内容为未解析的模板内容,浏览器无法识别的)
    container.innerHTML = ''

    // 调用app的mount方法,并返回 exposeProxy 提供给外部使用
    const proxy = mount(container, false, false)
  }

  return app
}

我们发现,其实 runtimeDom 导出的 createApp 实际上只是拦截重写了 mount 方法的 baseCreateRenderer().createApp()

重写的目的也很明了,这样对用户来说 mount 只需要传递一个 选择器

<div id="app">
  <p v-for="item in [1,2]">{{ item }}</p>
</div>

<script>
   // 像这种情况,createApp的参数不为 组件,那么久会从 #app 中获取 innerHTML 作为 template 属性使用
   createApp({})..mount('#app')

   // 忽略 #app 的模板,使用 App 组件渲染
   createApp(App)..mount('#app')

  // 忽略 #app 的模板,使用 template 渲染, render 同理
   createApp({template:`<span>111</span>`})..mount('#app')
   createApp({render(){return }})..mount('#app')
</script>

baseCreateRenderer

该方法在源码里有 2000+ 行,重要性不言而喻。但他的 参数返回值 非常简单清晰,他接收一个 options , 并返回 { render, createApp } 对象

其作用就是根据 VNode 生成 真实DOM,所以其实内部 2000 多行代码都是对各种类型的 VNode 的处理

function baseCreateRenderer(options: RendererOptions) {
  // 首先在上面我们提到了,options 里主要是 nodeOps(真实DOM操作) 和 patchProp(属性的操作)
  const {
    patchProp: hostPatchProp,
    insert: hostInsert, // 插入方法  (child, parent, anchor) => parent.insertBefore(child, anchor || null)
    remove: hostRemove, // 移除方法
    createElement: hostCreateElement, // 创建元素节点
    createText: hostCreateText, // 创建文本节点
    createComment: hostCreateComment, // 创建注释节点
    setText: hostSetText, // 设置文本
    setElementText: hostSetElementText,
    parentNode: hostParentNode,
    nextSibling: hostNextSibling,
    setScopeId: hostSetScopeId = NOOP,
    insertStaticContent: hostInsertStaticContent,
  } = options

  /**
   * 重点方法(一般称作打补丁)
   * 会根据 vnode 的类型,去选择如何创建真实节点
   *
   * @param n1 旧VNode
   * @param n2 新VNode
   * @param container 包裹 n2 的 元素容器
   * @param anchor  // 下一个兄弟节点
   * @param parentComponent // 父组件实例, 和 container 感觉上类似,但是区别很大,这是一个组件实例,不是元素
   * @param parentSuspense  // 暂不考虑,suspense情况
   * @param isSVG  // 暂不考虑
   * @param slotScopeIds
   * @returns
   */
  const patch = (n1, n2, container, anchor = null, parentComponent = null) => {
    // 如果前一次更新 和 当前更新的2个 VNode 相同,那么就不用变化,直接返回
    if (n1 == n2) {
      return
    }

    // n1 不为空 且 2个vnode 不同(key 或者 type 不一样)
    // 则需要卸载旧的真实DOM
    if (n1 && !isSameVNodeType(n1, n2)) {
      // 获取 旧节点 的下一个兄弟节点
      anchor = getNextHostNode(n1)
      // 卸载 n1
      unmount(n1, parentComponent, parentSuspense, true)
      n1 = null
    }

    const { type, ref, shapeFlag } = n2

    switch (type) {
      case 'Text':
        processText(n1, n2, container, anchor)
        break

      case 'Fragment':
        processFragment(n1, n2, container, anchor, parentComponent, parentSuspense, false, null, false)
        break

      default:
        // 【注】 ShapeFlags 是在创建虚拟DOM的时候,保存到Vnode上的
        // type 为 object 的那种,或者 function 的都会进入到这里
        // 因为在 patch 之前的 createVnode 里已经将该 type 类型的 ShapeFlags 要么设为 STATEFUL_COMPONENT,要么设为 FUNCTIONAL_COMPONENT
        if (shapeFlag & ShapeFlags.COMPONENT) {
          processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, false, slotScopeIds, false)
        } else if (shapeFlag & ShapeFlags.ELEMENT) {
          processElement(n1, n2, container, anchor, parentComponent, parentSuspense, false, slotScopeIds, false)
        }
    }
  }

  // 处理文本,如果 旧节点为null,那么就用 createTextNode 创建一个新的文本节点
  const processText: ProcessTextOrCommentFn = (n1, n2, container, anchor) => {
    if (n1 == null) {
      // 用 n2.children 是因为,在 createTextVNode 时,是用 text 塞进 children 的
      // anchor 为兄弟节点,用来帮助 insertBefore 插入正确的位置
      hostInsert((n2.el = hostCreateText(n2.children as string)), container, anchor as any)
    } else {
      // 如果 旧节点已存在,那么直接改写 文本的 nodeValue 即可
      const el = (n2.el = n1.el!)
      if (n2.children !== n1.children) {
        hostSetText(el as any, n2.children as string)
      }
    }
  }

  const render: Function = (vnode: any, container: any, isSVG: boolean) => {
    if (vnode == null) {
      if (container._vnode) {
        unmount(container._vnode, null, null, true)
      }
    } else {
      patch(container._vnode || null, vnode, container, null, null, null, isSVG)
    }

    container._vnode = vnode
  }

  return {
    render,
    createApp: createAppAPI(render),
  }
}

重点看返回的 render ,会发现哪怕是 createApp 也是将 render 作为参数传递给了 createAppAPI,所以这里 2000 多行代码可以看作是以 render 方法作为起始点。 render 方法调用 patchpatch 在根据 vnode 类型去选择要如何创建真实DOM。且创建时候,会根据 n1(旧 VNode) 是否存在来判断是 首次加载 还是 更新 ,会有不同的操作,如:处理文本的更新只是简单的替换 nodeValue

有些重要的方法,比如 processComponent 的实现,因为涉及的东西有些多,准备在后续文章中讲解。


createAppAPI

接下来我们在瞅瞅 createApp 是如何创建的,他由 createAppAPI 接收了 render 方法之后返回。

export const createAppAPI = (render: Function) => {
  return function createApp(rootComponent: any, rootProps: any = null) {
    if (!isFunction(rootComponent)) {
      // 浅拷贝rootComponent
      rootComponent = { ...rootComponent }
    }

    if (rootProps != null && !isObject(rootProps)) {
      rootProps = null // rootProps 必须为 object
    }

    // 创建app的上下文,返回的对象保存了该app的全局组件,指令,provides等属性
    const context = createAppContext()

    // 尚未挂载
    let isMounted = false

    const app: any = (context.app = {
      _uid: uid++, // 标识ID,一直递增
      _component: rootComponent, // 如果拿 createApp(App) 举例,那么这个便是我们传递的 App 组件,最爷爷的组件
      _props: rootProps,
      _container: null,
      // context可以通过.app 获取到 app, app 也可以通过 _context 获取到 context
      _context: context,
      _instance: null,

      // 注册组件 or 返回组件
      component(name: string, component?: any): any {
        if (!component) {
          return context.components[name]
        }

        context.components[name] = component
        return app
      },

      // 挂载到某个节点上
      mount(rootContainer: any, isHydrate?: boolean, isSVG?: boolean): any {
        if (!isMounted) {
          // 按照目前我的例子, rootComponent 为 { setup ,template }
          // 最终 vnode 的 type 也会为 { setup ,template }
          const vnode = createVNode(rootComponent, rootProps)

          vnode.appContext = context

          // render 是在 baseCreateRenderer 中创建的
          // 用于将 vnode 转换成真实DOM并渲染到页面上
          render(vnode, rootContainer, isSVG)

          // 设置成已挂载
          isMounted = true
          app._container = rootContainer
          ;(rootContainer as any).__vue_app__ = app

          return getExposeProxy(vnode.component!) || vnode.component!.proxy
        }
      },

      // 卸载
      unmount() {
        if (isMounted) {
          // 卸载 container 上的真实DOM,传递 null 给 render 即可卸载
          render(null, app._container)
          delete app._container.__vue_app__
        }
      },
    })

    return app
  }
}

createAppAPI 返回的 createApp 接收一个 rootComponent ,根据我们的使用经验,rootComponent 可以是一个 组件,setup, render,所以在上面先判断 rootComponent 是否是一个方法,如果不是方法,那么只可能是一个对象,浅拷贝赋值这个对象。

createApp(App)
createApp({ setup() {} })
createApp({ render() {} })

接着便是熟练的老操作 ———— 创建 context, vue 在很多地方都有上下文做关联。这里也不例外,在这个 appContext 中保存了我们成为全局的一些东西。比如 组件,指令, mixin 等。

然后在将 context 和 app 相互关联。这样保证了 app 可以通过 ._context 访问到 context。 context 也可以通过 .app 访问到 app。

接着就是最重点的 mount 方法。我们在最上面说到我们经常使用的 createApp(注意有好几个createApp 别弄混了) 便是重写了这里的 mount 方法 const proxy = mount(container, false, false)

我们可以看到,真正 mount 方法其实是主要是做了这 2 步骤:

  1. 使用 rootComponent 创建了一个 虚拟 DOM
  2. 再用 render 去将虚拟 DOM 转换成真实 DOM 渲染到界面上

创建 VNode,实际上也是返回一个 JS 对象,这个 JS 对象是用来描述真实DOM 的,在生成真实DOM 的 patch 方法内会使用 VNode 的 typeshapeFlag 来判断要创建什么类型的真实DOM


总结

之前背八股文那会,背过一道面试题: 从模板-> 真实 DOM 的过程,现在真正瞅完了下源码,才对这一系列过程有个大概性的了解:

  1. 从模板解析(parse)成 AST 对象,AST 对象在转换(transform, codegen)成带 createVNode 的 render 函数字符串。

  2. 在通过 new Function('Vue', code)(runtimeDom) 创建出了 虚拟DOM

  3. 最终,通过 render 内的 patch 方法去利用 虚拟DOM 生成 真实DOM 最后渲染到界面上。