likes
comments
collection
share

Vue 源码解析(九):renderer

作者站长头像
站长
· 阅读数 10

Vue renderer 源码解析

引入

rendererVue负责渲染组件的模块,是Vue的核心模块之一。组件的挂载、卸载和更新逻辑都位于该模块中。该模块将DOM结构抽象为虚拟节点(vnode),并封装了一系列处理虚拟节点的操作,从而实现DOM的更新。

渲染组件的入口:render 函数

render函数的作用是渲染组件,当我们使用app.mount挂载组件时就会调用该函数来渲染组件,因此该函数实际上是渲染组件的入口。

一般来说,render接受两个参数,即:

  • vnode:虚拟节点,表示组件真实DOM结构和其他属性的js对象,组件的templatesetup函数、props等所有的属性都会存储在这个对象当中,从而可以通过render函数渲染组件
  • container:表示组件所要挂载的地方,通常是一个dom元素

在源码中,render函数会在app.mountapp.unmount中被调用,即组件挂载和卸载时调用:

// 省略无关代码
const app:App = (context.app = {
  mount(
    rootContainer: HostElement,
    isHydrate?: boolean,
    isSVG?: boolean
  ): any {
    render(vnode, rootContainer, isSVG) // 把根组件的 vnode 挂载到根元素上
  },
  unmount() {
  	render(null, app._container) // vnode 传 null,即把根组件从根元素上卸载
  }
})

通过查看render函数源码,我们可以看到:

  • vnodenull时:会通过调用unmount函数卸载根元素下的组件
  • vnode不为空:通过patch函数将vnode挂载到container
// render 函数源码
const render: RootRenderFunction = (vnode, container, isSVG) => {
  if (vnode == null) {
    if (container._vnode) {
      unmount(container._vnode, null, null, true)
    }
  } else {
    // vnode 不为空,调用 patch 函数对 vnode 进行挂载
    patch(container._vnode || null, vnode, container, null, null, null, isSVG)
  }
  flushPreFlushCbs()
  flushPostFlushCbs()
  container._vnode = vnode // 把更新后的 vnode 赋值给 container,作为下次 patch 的旧 vnode
}

接下来,我们介绍patch函数,看看Vuepatch的过程中做了哪些事情。

更新组件:patch 函数

patch函数会在组件创建或是更新时被调用,其目的是根据编译器生成的vnodetypepatchFlag调用不同的处理函数,找出新旧vnode的不同(diff)并进行更新(打补丁),从而提升DOM更新的性能。

const patch: PatchFn = (
  n1,
  n2,
  container,
  anchor = null,
  parentComponent = null,
  parentSuspense = null,
  isSVG = false,
  slotScopeIds = null,
  optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
) => {
  // vnode 为同一节点无需 patch,直接返回
  if (n1 === n2) {
    return
  }

  // patching & not same type, unmount old tree
  // 新旧节点类型不同,无需对旧节点进行复用或比对子节点,直接卸载旧节点,之后挂载新节点即可
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1)
    unmount(n1, parentComponent, parentSuspense, true)
    n1 = null
  }
	
  // PatchFlag 是 BAIL 模式,说明在 diff 过程中不使用编译器提供的信息,直接跳出优化模式
  if (n2.patchFlag === PatchFlags.BAIL) {
    optimized = false
    n2.dynamicChildren = null
  }
	
  // 根据新节点类型和 shapeFlag 的不同,分别调用不同的处理程序
  const { type, ref, shapeFlag } = n2
  switch (type) {
    case Text:
      processText(n1, n2, container, anchor) // 文本类型
      break
    case Comment:
      processCommentNode(n1, n2, container, anchor) // 注释类型
      break
    case Static:
      if (n1 == null) {
        mountStaticNode(n2, container, anchor, isSVG) // 静态节点
      } else if (__DEV__) {
        patchStaticNode(n1, n2, container, isSVG)
      }
      break
    case Fragment: // Fragment 类型,即一组没有父节点的子节点,例如:<p>子节点1</p><a>子节点2</a>
      processFragment(
        n1,
        n2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
      break
    default:
      // 根据 shapeFlag 类型调用不同的处理程序
      if (shapeFlag & ShapeFlags.ELEMENT) { // HTML 元素类型
        ...
      } else if (shapeFlag & ShapeFlags.COMPONENT) { // 组件类型
        processComponent(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else if (shapeFlag & ShapeFlags.TELEPORT) { // Teleport 类型
        ...
      } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) { // Suspense 类型
        ...
      } else if (__DEV__) {
        ...
      }
  }

  // set ref
  if (ref != null && parentComponent) {
    setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
  }
}

通过阅读patch函数的源码可以看到,它根据新节点的类型和shapeFlag的不同,分别调用不同的处理程序进行处理。下面我们就挑选其中的processComponent函数,看看组件类型的vnode是如何被处理的。

处理组件类型虚拟节点

processComponent分为两种情况执行:

  • n1为空时:执行挂载节点函数mountComponent
  • 不为空:执行更新节点函数updateComponent
const processComponent = (
  n1: VNode | null,
  n2: VNode,
  container: RendererElement,
  anchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  n2.slotScopeIds = slotScopeIds
  if (n1 == null) {
    // 处理 Keep-alive
    if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
      ;(parentComponent!.ctx as KeepAliveContext).activate(
        n2,
        container,
        anchor,
        isSVG,
        optimized
      )
    } else {
      // 挂载节点
      mountComponent(
        n2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        optimized
      )
    }
  } else {
    // 更新节点
    updateComponent(n1, n2, optimized)
  }
}

mountComponent

mountComponent会首先通过调用createComponentInstance创建vnode对应的组件实例instance,再调用setupRenderEffect设置组件更新时执行的副作用。

const mountComponent: MountComponentFn = (
  initialVNode,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  isSVG,
  optimized
) => {
  // 2.x compat may pre-create the component instance before actually
  // mounting
  const compatMountInstance =
    __COMPAT__ && initialVNode.isCompatRoot && initialVNode.component
  const instance: ComponentInternalInstance =
    compatMountInstance ||
    (initialVNode.component = createComponentInstance( // 创建组件实例
      initialVNode,
      parentComponent,
      parentSuspense
    ))
	
  // 设置组件更新时的副作用
  setupRenderEffect(
    instance,
    initialVNode,
    container,
    anchor,
    parentSuspense,
    isSVG,
    optimized
  )
}

setupRenderEffect用来设置组件更新的副作用。我们知道在Vue中响应式变量更改时,会触发副作用重新执行,这是Vue响应式的核心。组件的更新同样依赖响应式变量,因此它的更新同样是一个副作用,当依赖的变量的值更改时,会触发组件更新的副作用,并异步更新组件。

在这部分源码中,声明了组件更新的副作用effect,它的run方法是componentUpdateFn,这里面涉及到组件更新的核心逻辑;同时副作用通过queueJob函数作为调度器,来实现异步执行update函数(即effectrun方法)。函数最后执行update方法,来启动副作用,从而执行componentUpdateFn中的相关逻辑。

const setupRenderEffect: SetupRenderEffectFn = (
  instance,
  initialVNode,
  container,
  anchor,
  parentSuspense,
  isSVG,
  optimized
) => {
  // 副作用的 run 函数,执行副作用实际就是执行这个函数
  const componentUpdateFn = () => {
    // 暂时省略函数体
  }

  // create reactive effect for rendering
  const effect = (instance.effect = new ReactiveEffect(
    componentUpdateFn,
    () => queueJob(update),
    instance.scope // track it in component's effect scope
  ))

  const update: SchedulerJob = (instance.update = () => effect.run())
  
  update() // 执行副作用的 run 方法,即 componentUpdateFn 函数
}

componentUpdateFn函数较长,概括来说它主要做了两件事:

  1. 执行onBeforeMountonMountedonBeforeUpdateonUpdated生命周期钩子
  2. 通过renderComponentRoot函数,执行组件的render方法,从而得到组件的新vnode,并通过patch函数挂载或是更新vnode
// 仅保留了部分核心代码
const componentUpdateFn = () => {
  if (!instance.isMounted) {
    // 挂载 vnode
    const subTree = (instance.subTree = renderComponentRoot(instance))
    patch(
      null,
      subTree,
      container,
      anchor,
      instance,
      parentSuspense,
      isSVG
    )
  } else {
    // 更新 vnode
    const nextTree = renderComponentRoot(instance)
    const prevTree = instance.subTree
    instance.subTree = nextTree

    patch(
      prevTree,
      nextTree,
      hostParentNode(prevTree.el!)!,
      getNextHostNode(prevTree),
      instance,
      parentSuspense,
      isSVG
    )
  }
}

至此,组件的vnode会通过patch函数进行挂载或更新,这个过程可能会调用patch的其他处理函数,例如HTML元素或是Fragment等类型的处理器。

updateComponent

这部分的核心逻辑是通过patchFlag判断是否需要更新组件,如果不需要更新则直接复制属性到新的vnode上;如果需要则调用intance.update方法触发更新的副作用。

  const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
    const instance = (n2.component = n1.component)!
    if (shouldUpdateComponent(n1, n2, optimized)) {
      if (
        __FEATURE_SUSPENSE__ &&
        instance.asyncDep &&
        !instance.asyncResolved
      ) {
        // async & still pending - just update props and slots
        // since the component's reactive effect for render isn't set-up yet
        updateComponentPreRender(instance, n2, optimized)
        return
      } else {
        // normal update
        instance.next = n2
        // in case the child component is also queued, remove it to avoid
        // double updating the same child component in the same flush.
        invalidateJob(instance.update)
        // instance.update is the reactive effect.
        instance.update()
      }
    } else {
      // no update needed. just copy over properties
      n2.el = n1.el
      instance.vnode = n2
    }
  }

对 HTML 元素进行更新:patchElement

componentUpdateFn中我们可以看到,当组件挂载或更新时会调用patch函数,因此patch函数是可能会被递归调用的。由于组件更新的本质还是DOM元素的更新,因此patch函数的递归调用到最后一定会处理到HTML类型的vnode,因此会调用patchElement函数。

这个部分也正是Vue执行diff来更新DOM的核心部分,要弄清楚Vuediff过程就必须要了解patchElement函数都做了些什么。

处理 props

当我们创建一个虚拟节点时,需要三个参数:typepropschildren。当代码进行到这里时,我们已经保证了新旧节点的类型是相同的,那么我们只需要分别对propschildrenpatch

我们首先介绍对于props(这里的props是广义的,包括classstyleprops)的处理过程。

在正式介绍之前,我们先简单介绍一下Vue的编译。Vue是一门编译时+运行时的框架,因此Vue在编译时会生成编译时信息用来在运行时提供优化信息

Vue 源码解析(九):renderer

例如这样一段Vue模板代码,在编译后会生成如下的vnode

Vue 源码解析(九):renderer

其中红框内的1是编译器提供的编译时信息——patchFlagpatchFlag可以在运行时告诉Vue这个vnode的哪个部分是可变的;下面的512 NEED_PATCH同样也是patchFlag,它可以告诉Vue这个vnodeprops是不可变的,但是非props的部分(例如v-xxx指令)存在变动,因此需要对non-props的部分做patch

// patchFlag > 0 表示编译器提供的编译时信息,可以进行优化
if (patchFlag > 0) {
  // props 存在动态 key,需要对 props 做全量 diff。例如 <HelloWorld :[foo]="bar" /> 就存在动态 key
  if (patchFlag & PatchFlags.FULL_PROPS) {
    patchProps(
      el,
      n2,
      oldProps,
      newProps,
      parentComponent,
      parentSuspense,
      isSVG
    )
  } else {
    // 存在动态 class,需要对 class 做 patch
    if (patchFlag & PatchFlags.CLASS) {
      if (oldProps.class !== newProps.class) {
        hostPatchProp(el, 'class', null, newProps.class, isSVG)
      }
    }

    // 存在动态 style,需要对 style 做 patch
    if (patchFlag & PatchFlags.STYLE) {
      hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG)
    }

    // 存在动态 props/attr,需要对 prop 做 patch
    if (patchFlag & PatchFlags.PROPS) {
      const propsToUpdate = n2.dynamicProps!
      for (let i = 0; i < propsToUpdate.length; i++) {
        const key = propsToUpdate[i]
        const prev = oldProps[key]
        const next = newProps[key]
        if (next !== prev || key === 'value') {
          // 对 prop 做 patch
          hostPatchProp(
            el,
            key,
            prev,
            next,
            isSVG,
            n1.children as VNode[],
            parentComponent,
            parentSuspense,
            unmountChildren
          )
        }
      }
    }
  }

  // 有动态的 text children,需要对 text 做 patch
  if (patchFlag & PatchFlags.TEXT) {
    if (n1.children !== n2.children) {
      hostSetElementText(el, n2.children as string)
    }
  }
}

通过让patchFlagPatchFlags的每一类做&运算,可以判断vnode哪些类需要被patch,从而可以只对可能发生变动的部分进行patch,提升了patch过程的性能。

处理 children

在进行编译的时候,除了会生成patchFlag之外,还会生成dynamicChildren用来表示vnode的子节点中的可变部分,从而在对子节点进行patch时可以不用做全量diff,而只用对可能发生改变的子节点进行patch

<script setup>
import { ref } from 'vue'

const v = ref(0)
</script>

<template>
  <div id="demo">
    <!-- dynamic children -->
    <div>{{ v }}</div>
    <!-- hoisted node -->
    <div>
      <p>1</p>
    </div>
  </div>
</template>

Vue 源码解析(九):renderer

因此当编译时提供了dynamic children信息时,源码会调用patchBlockChildren只对那些动态子节点做patch,而无需全量diff,从而节省了系统的性能。

尽管如此,这样并不意味着全量diff不会发生,因为有些情况下(例如元素之间的顺序发生改变)编译器无法给我们提供信息,这使得我们必须要对子节点做全量的diffdiff算法是对子节点进行patch的核心内容,也是面试的常考重点,这部分我将放到下一篇文章来进行介绍(因为实在是写不动了)。

// 存在 dynamic children
if (dynamicChildren) {
  // 只对 dynamic children 做 patch
  patchBlockChildren(
    n1.dynamicChildren!,
    dynamicChildren,
    el,
    parentComponent,
    parentSuspense,
    areChildrenSVG,
    slotScopeIds
  )
} else if (!optimized) {
  // 当关闭优化模式时会做全量 diff
  patchChildren(
    n1,
    n2,
    el,
    null,
    parentComponent,
    parentSuspense,
    areChildrenSVG,
    slotScopeIds,
    false
  )
}

至此,我们看到了Vue是如何对HTML元素进行更新的:其核心内容是对propschildren分别进行diff,在过程中会利用编译时提供的patchFlagdynamic children来简化diff的路径,只对可能发生更改的地方进行patch,从而提升了diff的性能。

总结

  • 挂载组件的过程:app.mount -> render() -> patch ->根据不同类别调用不同的处理程序进行挂载,例如mountComponentmountElement等。在mountComponent中会通过setupRenderEffect设置组件更新的副作用
  • 更新组件的过程:触发副作用执行queueJob(update) -> 异步执行update函数 -> 执行副作用函数componentUpdateFn -> 调用renderComponentRoot创建新vnode -> 调用patch更新vnode
  • 好的封装:Vuerenderer的核心逻辑都封装为了函数,放在renderer.ts/baseCreateRenderer函数中,该函数只向外暴露render函数,其他逻辑都只能通过render函数进入。这种封装将renderer的逻辑完全封闭,不会被外界改变,是一种好的封装。
  • 好的抽象:VueDOM节点都抽象为了vnode,同时对于真实DOM的修改(例如对于节点的插入、删除等操作)并不会直接使用DOM API,而是把DOM API进一步封装为insertremovecreateText等工具方法。这使得虚拟节点的使用场景不会局限于浏览器场景,大大增加了代码的可读性以及可扩展性。