侧边栏壁纸
博主头像
前端自习室博主等级

折腾是进步的阶梯

  • 累计撰写 30 篇文章
  • 累计创建 0 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录
vue

Vue3 入门参考(一):渲染

lumozx
2023-09-27 / 0 评论 / 0 点赞 / 65 阅读 / 62416 字

注:本文使用vue版本为3.3.4

Vue3出来好久了,Vue2将在2023年底停止维护,我们可以预见,在不久的将来Vue2将会淡出开发者的视野,Vue3接替了Vue2的地位。这意味这技术新一轮的淘汰开始了,总抱着一门框架不松手,最终会被拍在沙滩上,所以自身也需要拥抱变化,同时做到知其然,还要知其所以然。

如果自己没有进步的话,每天早上都会被菜醒,跟社区人对线都没有底气。

本文以Vue3.3.4版本作为目标,来探究Vue3的原理,首先需要大家熟悉Vue3的基本API,这样可以更快地入门。

结构

Vue3在结构上采用了单仓库(monorepo)的方式来管理包,这种做法使得代码管理更加清晰明了。每个包都负责实现一个核心功能,这种模块化的方式使得开发与测试更加便捷。这正是前面提到的组合思想的体现。

相较于之前的Options API,Vue3引入了Composition API,这使得我们不再需要将同一逻辑分散到不同地方,而是可以将逻辑抽取出来,复用在不同组件中。当然,Options API仍然得到支持,使得在快速开发过程中能够获得一些优势。

对于熟悉Vue2的人来说,会了解到Vue2的响应式核心是基于observe的Watcher运行机制实现的,具体实现是使用Object.defineProperty进行劫持。然而,在Vue3中,响应式使用了ES较新的Proxy,运行机制转变为了基于收集effec。

此外,Vue3还进行了大量优化,例如通过收束纯函数、增加树摇机制来减少包体积以及通过静态提升和diff算法变化等方式。

需要注意的是,如果阅读代码的话,会发现Vue3使用了pnpm作为包管理工具,因此依赖安装和启动都使用了pnpm。

$ pnpm install # 安装依赖
$ pnpm run dev # 开启 vue dev 环境 watch
$ pnpm run serve # 启动 example 示例的服务器,进入packages/vue/examples/**调试

渲染

挂载

让我们深入思考一个简单的问题:如何使用Vue3?

此时,有人可能会提到,Vue3的初始化方式从之前的new Vue改为了createApp,挂载方式也采用了链式调用,并使用返回值中的mount方法来实现。

然而,这仅仅是表象。那么,在这个变化背后,Vue3实际上进行了哪些操作呢?我们一起来看一下源码。

export const createApp = ((...args) => {
  const app = ensureRenderer().createApp(...args)
   // ...
  const { mount } = app
  app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
    const container = normalizeContainer(containerOrSelector)
    //...
    const proxy = mount(container, false, container instanceof SVGElement)
    // ...
    return proxy
  }
  return app
}) 

我们可以看到,mount是ensureRenderer().createApp(...args)所暴露出来的,虽然它后面针对mount做了一层包装和逻辑变更,但容器(container)仍然是作为参数被传递进去的,尽管它经过了normalizeContainer的包装。

在这里,如果containerOrSelector接受选择器的话,那么会返回被选中的具体元素。让我们先来看一下ensureRenderer是什么样的。

let renderer: Renderer<Element | ShadowRoot> | HydrationRenderer
function ensureRenderer() {
  return (
    renderer ||
    (renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
  )
}

似乎已经进行了一次缓存操作。如果存在renderer,则直接返回。我们可以看到renderer的定义,它接受的类型还有一个HydrationRenderer。我们可以在同一文件中找到createSSRApp,其逻辑类似于createApp。

让我们回到renderer。它是通过createRenderer返回的。那么,createRenderer是怎么做的呢?

export function createRenderer<
  HostNode = RendererNode,
  HostElement = RendererElement
>(options: RendererOptions<HostNode, HostElement>) {
  return baseCreateRenderer<HostNode, HostElement>(options)
}

function baseCreateRenderer(
  options: RendererOptions,
  createHydrationFns?: typeof createHydrationFunctions
): any {
    // ...2000行代码
  return {
    render,
    hydrate,
    createApp: createAppAPI(render, hydrate)
  }

我们注意到,这个函数调用实际上是在baseCreateRenderer中完成的,而baseCreateRenderer内部包含了多达2000行的代码。它的最终输出结果为createApp和render。createApp是由createAppAPI所创建并返回的。那么我们看一下createAppAPI。

export function createAppAPI<HostElement>(
  render: RootRenderFunction<HostElement>,
  hydrate?: RootHydrateFunction
): CreateAppFunction<HostElement> {
  return function createApp(rootComponent, rootProps = null) {
    const app: App = (context.app = {
      _uid: uid++,
      _component: rootComponent as ConcreteComponent,
      _props: rootProps,
      mount(
        rootContainer: HostElement,
        isHydrate?: boolean,
        isSVG?: boolean
      ): any {
      },
    // unmount等属性
    return app
    })
  }
}

在剔除了一些其他属性和逻辑之后,我们可以发现,他其实就是返回了一个mount方法。这个mount方法接收一个DOM作为参数,最终将页面挂载到选择的HTML元素上。让我们一起来看看这是如何实现的。

mount(
     rootContainer: HostElement,
     isHydrate?: boolean,
     isSVG?: boolean
   ): any {
     if (!isMounted) {
       const vnode = createVNode(rootComponent, rootProps)
       vnode.appContext = context
       render(vnode, rootContainer, isSVG)
       isMounted = true
       return getExposeProxy(vnode.component!) || vnode.component!.proxy
     } 
   }

在精简了SSR代码和一些开发工具后,我们可以发现背后的逻辑流程大致如此。尽管mount确实具有返回值,但通过深入代码,我们发现挂载实际上是通过render来实现的。render函数将由createVNode创建的虚拟节点挂载到传入的DOM上。这里的rootComponent对应于App.vue脚本标签中的大对象以及其他挂载函数(当然,也存在函数式的情况)。而rootProps实际上是createApp的第二个参数,它们通过计算出虚拟节点,然后通过render进行挂载。这里的rootContainer就是我们传入的DOM节点‘#app’。

创建VNode

整个渲染其实可以说是VNode的更新,VNode顾名思义,虚拟节点,是vue描述节点的数据结构,那么他是怎么创建出来的呢?

我们看看createVNode的代码

function _createVNode(
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,  // vnode 类型,可以是元素、组件或空动态组件
  props: (Data & VNodeProps) | null = null,  // 属性和 vnode props,可以为空
  children: unknown = null,  // 子节点,可以为空
  patchFlag: number = 0,  // patch 标志,默认为 0
  dynamicProps: string[] | null = null,  // 动态属性数组,可以为空
  isBlockNode = false  // 是否是块节点,默认为 false
): VNode {
  // 如果类型为空或为 NULL_DYNAMIC_COMPONENT
  if (!type || type === NULL_DYNAMIC_COMPONENT) {
    type = Comment  // 将类型设置为注释节点
  }

  // 如果传入的类型已经是一个 vnode
  if (isVNode(type)) {
    // 创建一个 vnode 的克隆,可能在类似 <component :is="vnode"/> 的情况下发生
    const cloned = cloneVNode(type, props, true /* mergeRef: true */)
    // 如果存在子节点,规范化子节点并合并到克隆的 vnode 中
    if (children) {
      normalizeChildren(cloned, children)
    }
    // 如果启用了块树并且不是块节点,并且当前存在块
    if (isBlockTreeEnabled > 0 && !isBlockNode && currentBlock) {
      // 如果克隆的节点是组件,替换当前块中的相同组件节点,否则将克隆节点添加到当前块中
      if (cloned.shapeFlag & ShapeFlags.COMPONENT) {
        currentBlock[currentBlock.indexOf(type)] = cloned
      } else {
        currentBlock.push(cloned)
      }
    }
    cloned.patchFlag |= PatchFlags.BAIL  // 设置 patchFlag,表示该 vnode 需要特殊处理
    return cloned  // 返回克隆的 vnode
  }

  // 类组件的规范化处理
  if (isClassComponent(type)) {
    type = type.__vccOpts
  }

  // 类和样式的规范化处理
  if (props) {
    // 对于响应式或代理对象,需要克隆它以启用变异
    props = guardReactiveProps(props)!
    let { class: klass, style } = props
    // 如果 class 不是字符串,规范化 class
    if (klass && !isString(klass)) {
      props.class = normalizeClass(klass)
    }
    // 如果样式是对象,需要克隆它,因为它们可能被改变
    if (isObject(style)) {
      if (isProxy(style) && !isArray(style)) {
        style = extend({}, style)
      }
      props.style = normalizeStyle(style)
    }
  }

  // 将 vnode 类型信息转成shapeFlag
  const shapeFlag = isString(type)
    ? ShapeFlags.ELEMENT
    : __FEATURE_SUSPENSE__ && isSuspense(type)
    ? ShapeFlags.SUSPENSE
    : isTeleport(type)
    ? ShapeFlags.TELEPORT
    : isObject(type)
    ? ShapeFlags.STATEFUL_COMPONENT
    : isFunction(type)
    ? ShapeFlags.FUNCTIONAL_COMPONENT
    : 0
  // 创建基本的 vnode 并返回
  return createBaseVNode(
    type,
    props,
    children,
    patchFlag,
    dynamicProps,
    shapeFlag,
    isBlockNode,
    true
  )
}

最后调用了createBaseVNode,那么createBaseVNode是什么?

function createBaseVNode(
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,  // vnode 类型,可以是元素、组件或空动态组件
  props: (Data & VNodeProps) | null = null,  // 属性和 vnode props,可以为空
  children: unknown = null,  // 子节点,可以为空
  patchFlag = 0,  // patch 标志,默认为 0
  dynamicProps: string[] | null = null,  // 动态属性数组,可以为空
  shapeFlag = type === Fragment ? 0 : ShapeFlags.ELEMENT,  // 形状标志,默认为 ELEMENT,如果类型是 Fragment 则为 0
  isBlockNode = false,  // 是否是块节点,默认为 false
  needFullChildrenNormalization = false  // 是否需要完整的子节点规范化,默认为 false
) {
  // 创建一个 vnode 对象
  const vnode = {
    __v_isVNode: true,
    __v_skip: true,
    type,  // vnode 类型
    props,  // 属性和 vnode props
    key: props && normalizeKey(props),  // 规范化后的 key
    ref: props && normalizeRef(props),  // 规范化后的 ref
    scopeId: currentScopeId,  // 当前作用域 ID
    slotScopeIds: null,
    children,  // 子节点
    component: null,
    suspense: null,
    ssContent: null,
    ssFallback: null,
    dirs: null,
    transition: null,
    el: null,
    anchor: null,
    target: null,
    targetAnchor: null,
    staticCount: 0,
    shapeFlag,  // 形状标志
    patchFlag,  // patch 标志
    dynamicProps,  // 动态属性数组
    dynamicChildren: null,
    appContext: null,
    ctx: currentRenderingInstance  // 当前渲染实例的上下文
  } as VNode

  // 如果需要完整的子节点规范化
  if (needFullChildrenNormalization) {
    normalizeChildren(vnode, children)
    // 规范化 suspense 子节点
    if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
      ;(type as typeof SuspenseImpl).normalize(vnode)
    }
  } else if (children) {
    // 编译后的元素 vnode - 如果传入了子节点,可能的类型是字符串或数组
    vnode.shapeFlag |= isString(children)
      ? ShapeFlags.TEXT_CHILDREN
      : ShapeFlags.ARRAY_CHILDREN
  }


  // 跟踪 vnode 以构建块树
  if (
    isBlockTreeEnabled > 0 &&
    // 避免块节点跟踪自身
    !isBlockNode &&
    // 存在当前父块
    currentBlock &&
    // 存在 patch 标志表示该节点在更新时需要 patch。
    // 组件节点也应该始终被 patch,因为即使组件不需要更新,它也需要将实例保留到下一个 vnode,以便稍后可以正确卸载。
    (vnode.patchFlag > 0 || shapeFlag & ShapeFlags.COMPONENT) &&
    // EVENTS 标志仅用于 hydration,如果它是唯一的标志,由于处理程序缓存,vnode 不应被视为动态的。
    vnode.patchFlag !== PatchFlags.HYDRATE_EVENTS
  ) {
    currentBlock.push(vnode)
  }

  return vnode  // 返回 vnode
}

我们可以看到,createVNode 实际上是对数据进行了封装,同时引入了 shapeFlag 参数。然后,它调用了 createBaseVNode。这里需要特别提醒的是,shapeFlag 的逻辑至关重要,它的存在不仅是内置组件的逻辑分流节点,还对所有组件类型(函数式和组件式)的逻辑分流节点起着关键作用。通过它,createBaseVNode 才得以呈现出不同的处理逻辑。

还有一点需要我们关注的是,这里的 type 参数就是我们传入的App对象,或者是函数组件。请注意不要被参数名称所误导。如果 type 是对象类型,那么 shapeFlag 的值将是 STATEFUL_COMPONENT;如果它是函数式组件,那么 shapeFlag 则为 FUNCTIONAL_COMPONENT。

render

在之前的讲述中,我们提到了通过createVNode生成VNode节点,然后通过render方法将这个VNode挂载到对应的HTML元素上。我们上文只介绍了如何生成VNode,而没有详细说明render方法的具体作用。那么接下来,我们将详细探讨render的作用和实现方式。

const render: RootRenderFunction = (vnode, container, isSVG) => {
  if (vnode == null) {
    // 如果 vnode 为空
    if (container._vnode) {
      // 如果容器已经存在 vnode,卸载现有的 vnode
      unmount(container._vnode, null, null, true)
    }
  } else {
    // 如果 vnode 不为空,使用 patch 函数进行更新或挂载
    patch(container._vnode || null, vnode, container, null, null, null, isSVG)
  }
  // 将容器的 vnode 设置为当前 vnode
  container._vnode = vnode
}

从上面的逻辑来看,我们vnode显然是存在的,所以不走卸载逻辑,那么就应该走patch逻辑,但是这里patch是更新逻辑怎么回事?我们还刚刚创建一个vue,是初始化,怎么是更新呢?

显而易见,在vue中,初始化和更新是一个相同的逻辑。初始化,其实是一个全量的更新。那么patch的逻辑是什么呢?

const patch: PatchFn = (
  n1,// n1是老节点
  n2, // n2是新节点
  container,// container是需要挂载的HTML容器
  anchor = null,// anchor是参考元素,这个可能不太好理解,类比的话,类似dom里面进行插入操作,需要提供父节点以及插入到目标元素后面等信息,anchor起到参考定位的作用
  parentComponent = null// parentComponent是父组件
  isSVG = false,
  slotScopeIds = null,
  optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
) => {
  // 节点相同不处理
  if (n1 === n2) {
    return
  }

  // 对于类型不同的新老节点,直接进行卸载
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1)
    unmount(n1, parentComponent, parentSuspense, true)
    n1 = null
  }
  // 如果新 vnode 的 patchFlag 是 BAIL,表示不需要优化,将 optimized 设置为 false
  if (n2.patchFlag === PatchFlags.BAIL) {
    optimized = false
    n2.dynamicChildren = null
  }

  const { type, ref, shapeFlag } = n2
  switch (type) {
    case Text:
      // 文本
      processText(n1, n2, container, anchor)
      break
    // 注释
    case Comment:
      processCommentNode(n1, n2, container, anchor)
      break
    //  Static Fragment等
    default:
      // 处理DOM
      if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      // 处理模板
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        processComponent(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else if (...) {
      //  内置组件
      }
  }
  // 如果有ref,就设置ref
  if (ref != null && parentComponent) {
    setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
  }
}

这段注释已经解释得非常清楚,我们可以按照初始化流程,n1 为 null,n2 是由 App.vue 的大对象生成的 vnode,也就是 createBaseVNode 的返回值,container 是 '#app' 的 DOM,其他都是 null。

这里的逻辑会获取 n2 的 type 和 shapeFlag,也就是 createBaseVNode 处理 App.vue 的大对象的结果。正如之前提到,type 是 App.vue 的大对象,因此是一个对象组件,shapeFlag 就是 STATEFUL_COMPONENT。

好的,我们现在已经获取到了 n2 的对应参数。在 switch 语句中,我们进入了 default 分支,接着执行 if-else 语句,最终到达处理模板这一步。然后会执行以下方法。

processComponent(
  n1,
  n2,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  isSVG,
  slotScopeIds,
  optimized
)

我们进入这个方法,看看是做什么的。

 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
  ) => {
    // ...
  	// 如果旧 vnode 不存在,挂载组件
    if (n1 == null) {
	  // 如果新 vnode 具有 COMPONENT_KEPT_ALIVE 标志,激活组件
      if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
       // KeepAlive 逻辑
      } else {
		// 否则,挂载组件
        mountComponent(
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
      }
    } else {
	  // 如果旧 vnode 存在,更新组件
      updateComponent(n1, n2, optimized)
    }
  }

这代码佐证了我们的想法,在vue中,初始化就是全量更新,在这里如果 n1 不存在,就走mountComponent逻辑。好的,我们看看mountComponent是干什么的。

const mountComponent: MountComponentFn = (
  initialVNode,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  isSVG,
  optimized
) => {
  const instance: ComponentInternalInstance =
    compatMountInstance ||
    (initialVNode.component = createComponentInstance(
      initialVNode,
      parentComponent,
      parentSuspense
    ))


  // 为 keepAlive 注入 renderer 内部方法
  if (isKeepAlive(initialVNode)) {
    ;(instance.ctx as KeepAliveContext).renderer = internals
  }

  // 解析 setup 上下文的 props 和 slots
  if (!(__COMPAT__ && compatMountInstance)) {
    setupComponent(instance)
  }

  // setup() 是异步的。在继续之前,该组件依赖于异步逻辑被解析
  if (__FEATURE_SUSPENSE__ && instance.asyncDep) {
    parentSuspense && parentSuspense.registerDep(instance, setupRenderEffect)

    // 如果不是 hydration,为它提供一个占位符
    if (!initialVNode.el) {
      const placeholder = (instance.subTree = createVNode(Comment))
      processCommentNode(null, placeholder, container!, anchor)
    }
    return
  }

  // 设置副作用函数
  setupRenderEffect(
    instance,
    initialVNode,
    container,
    anchor,
    parentSuspense,
    isSVG,
    optimized
  )
}

这段代码做了什么呢?首先,根据App.vue的大对象VNode,它创建了一个组件实例(component instance)。这个组件实例就是我们通过getCurrentInstance获取的那个对象。接着,它将这个实例作为参数传入名为setupComponent的函数。从名称就可以看出,这个函数是用来执行实例中的setup函数的。之后,它处理了一些针对异步setup的操作,然后调用了名为setupRenderEffect的函数。从名称可以看出,这个函数是一个副作用函数,用于执行render操作。让我们逐一了解这些步骤。

首先是createComponentInstance函数。它的作用是创建一个组件实例,与其说是创建,不如说是初始化。代码很简单,只是用来初始化组件实例,没有太多的逻辑。

export function createComponentInstance(
  vnode: VNode,
  parent: ComponentInternalInstance | null,
  suspense: SuspenseBoundary | null
) {
  // 获取组件类型
  const type = vnode.type as ConcreteComponent
  // 继承父组件的应用程序上下文,或者如果是根组件,则继承根 vnode 的应用程序上下文
  const appContext =
    (parent ? parent.appContext : vnode.appContext) || emptyAppContext

  // 创建组件内部实例
  const instance: ComponentInternalInstance = {
    uid: uid++, // 组件实例唯一标识符
    vnode, // 组件的 vnode
    type, // 组件类型
    parent, // 父组件实例
    appContext, 
    root: null!, // 根实例,稍后会立即设置
    next: null, // 下一个节点
    subTree: null!, // 子树,会在创建后立即同步设置
    effect: null!, // 副作用函数
    update: null!, // 更新函数,会在创建后立即同步设置
    scope: new EffectScope(true /* detached */), // 副作用作用域
    render: null, // 渲染函数
    proxy: null, // 代理对象
    exposed: null, // 暴露的对象
    exposeProxy: null, // 暴露的代理对象
    withProxy: null, // 使用的代理对象
    provides: parent ? parent.provides : Object.create(appContext.provides), // 提供的属性
    accessCache: null!, // 访问缓存
    renderCache: [], // 渲染缓存

    // 本地解析的资源
    components: null, // 组件
    directives: null, // 指令

    // 解析的 props 和 emits 选项
    propsOptions: normalizePropsOptions(type, appContext), // 规范化的 props 选项
    emitsOptions: normalizeEmitsOptions(type, appContext), // 规范化的 emits 选项

    // emit 方法
    emit: null!, // 立即设置
    emitted: null, // 发射的事件记录

    // props 的默认值
    propsDefaults: EMPTY_OBJ,

    // inheritAttrs
    inheritAttrs: type.inheritAttrs, // 是否继承属性

    // 状态属性
    ctx: EMPTY_OBJ, 
    data: EMPTY_OBJ, 
    props: EMPTY_OBJ, 
    attrs: EMPTY_OBJ, 
    slots: EMPTY_OBJ, 
    refs: EMPTY_OBJ, 
    setupState: EMPTY_OBJ, // setup 函数的返回值
    setupContext: null, // setup 函数的上下文

    attrsProxy: null, // 属性代理对象
    slotsProxy: null, // 插槽代理对象

    // 悬挂相关
    suspense, // 悬挂边界
    suspenseId: suspense ? suspense.pendingId : 0, // 悬挂 ID
    asyncDep: null, // 异步依赖
    asyncResolved: false, // 是否异步已解析

    // 生命周期钩子函数
    // 这里不使用枚举是因为它会导致计算属性
    isMounted: false, // 是否已挂载
    isUnmounted: false, // 是否已卸载
    isDeactivated: false, // 是否已失活
    bc: null, // beforeCreate 钩子函数
    c: null, // created 钩子函数
    bm: null, // beforeMount 钩子函数
    m: null, // mounted 钩子函数
    bu: null, // beforeUpdate 钩子函数
    u: null, // updated 钩子函数
    um: null, // unmounted 钩子函数
    bum: null, // beforeUnmount 钩子函数
    da: null, // deactivated 钩子函数
    a: null, // activated 钩子函数
    rtg: null, // renderTracked 钩子函数
    rtc: null, // renderTriggered 钩子函数
    ec: null, // errorCaptured 钩子函数
    sp: null // serverPrefetch 钩子函数
  }
  
   instance.ctx = { _: instance }
  
  // 如果存在自定义元素特殊处理函数,调用它
  if (vnode.ce) {
    vnode.ce(instance)
  }

  // 设置 emit 方法
  instance.emit = emit.bind(null, instance)

  return instance  // 返回组件实例
}

上述代码就是他的返回属性,绝大部分值都是空值,或者空对象,因为他的作用仅仅是初始化一个instance对象而已,至于填充,那是后面的流程。

然后我们可以看 setupComponent 他做了什么。

export function setupComponent(
  instance: ComponentInternalInstance,
  isSSR = false
) {
  // 标记当前是否在 SSR 组件的 setup 阶段
  isInSSRComponentSetup = isSSR

  // 从组件 vnode 中获取 props 和 children
  const { props, children } = instance.vnode

  // 判断组件是否是有状态组件
  const isStateful = isStatefulComponent(instance)

  // 初始化 props
  initProps(instance, props, isStateful, isSSR)
  
  // 初始化插槽
  initSlots(instance, children)

  // 如果组件是有状态组件,调用 setupStatefulComponent 进行设置
  const setupResult = isStateful
    ? setupStatefulComponent(instance, isSSR)
    : undefined

  // 结束 SSR 组件的 setup 阶段
  isInSSRComponentSetup = false
  
  // 返回 setup 的结果
  return setupResult
}

这里面提到了有状态组件,什么是有状态,在纯函数的文章里,我们拿React Hook讲过,组件会自己保存自己的信息,不像函数组件每次调用都会初始化。在Vue中,所有的模板组件都被认为是有状态的。那么,setupStatefulComponent做了什么呢?我们进去代码看看。

function setupStatefulComponent(
  instance: ComponentInternalInstance,
  isSSR: boolean
) {
  // App.vue的大对象 
  const Component = instance.type as ComponentOptions
  instance.accessCache = Object.create(null)
  instance.proxy = markRaw(new Proxy(instance.ctx, PublicInstanceProxyHandlers))
  const { setup } = Component
  // 如果有setup
  if (setup) {
    // 根据setup的参数决定入参需要不需要计算上下文
    const setupContext = (instance.setupContext =
      setup.length > 1 ? createSetupContext(instance) : null)
    // 执行setup
    const setupResult = callWithErrorHandling(
      setup,
      instance,
      ErrorCodes.SETUP_FUNCTION,
      [__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext]
    )
    
    if (isPromise(setupResult)) {
      setupResult.then(unsetCurrentInstance, unsetCurrentInstance)
      if (isSSR) {
        // 只有异步SSR才有返回值
        return setupResult
          .then((resolvedResult: unknown) => {
            handleSetupResult(instance, resolvedResult, isSSR)
          })
          .catch(e => {
            handleError(e, instance, ErrorCodes.SETUP_FUNCTION)
          })
      } else if (__FEATURE_SUSPENSE__) {
        // 结果挂载到asyncDep
        instance.asyncDep = setupResult
        if (__DEV__ && !instance.suspense) {
          const name = Component.name ?? 'Anonymous'
        }
      } 
    } else {
      handleSetupResult(instance, setupResult, isSSR)
    }
  } else {
    finishComponentSetup(instance, isSSR)
  }
}

从这里可以看出,在正常开发下,setupStatefulComponent的返回值是不会用到的,因为他只有在异步setup并且是SSR的情况下才会有返回值,从而也说明 setupComponent 只有是SSR的情况下才会有返回值,那么非SSR的情况下,setup的返回值怎么处理呢?

如果是Promise的情况下,是挂载到实例的asyncDep下的( instance.asyncDep = setupResult),如果不是异步的情况,会进入到 handleSetupResult 方法。

handleSetupResult 会分SSR和非SSR,如果setup的返回值是一个函数,SSR会挂载到instance.ssrRender,而非SSR,会挂载到instance.render上。

是的,这就是实例中render函数的由来。

那么我们都知道,setup还可以返回对象,作为data等值的覆盖,返回对象如何处理呢?

如果返回的是一个对象,同时是vnode的话,会多抛出一个警告,然后会将这个对象挂载到instance.setupState并进行响应式化。

在 handleSetupResult 的最后 依然会调用 finishComponentSetup

也就是无论如何都会调用 finishComponentSetup。

finishComponentSetup 的逻辑很简单,我们一般在模板文件中写vue的时候,可能会写render函数,那么finishComponentSetup会检测到,如果实例上没有render,那么就拿模板的render赋值给实例,这里需要区分一下有俩render函数,一个是实例上的,可以(注意,这个是可以,不是必须,setup返回值不是函数的话就不会生成实例上的render)由setup生成的,一个是在模板文件里面写的option API 的render。如果实例存在render,也就是setup能生成render,那么就忽略模板中的render,如果实例到现在都没有render,(setup没生成或者根本没setup),那么拿模板的render来用,如果模板也没有render呢,那就用模板编译一个出来(这个下一节会讲),把模板中的template编译成render,先赋值给模板的render,再赋值给实例的render。

当然也有例外,比如没有编译器的时候,实例render就会进行兜底,给一个空函数。

finishComponentSetup的作用就是让实例上的render应有尽有,模板的render可以没有(没有编译函数的情况),但实例上的render一定存在,即使是空函数。

好的我们说完了setupComponent,一句话概况,就是在日常的开发逻辑上,setupComponent往实例上挂载了render函数或者setupState。

我们应该说 setupRenderEffect了。

 const setupRenderEffect: SetupRenderEffectFn = (
    instance,
    initialVNode,
    container,
    anchor,
    parentSuspense,
    isSVG,
    optimized
  ) => {
   	const componentUpdateFn = () => {
      // 如果组件尚未挂载
      if (!instance.isMounted) {
        // 渲染组件的子树,获取新的 VNode 树
        const subTree = (instance.subTree = renderComponentRoot(instance))
        // 挂载 vnode
        patch(null, subTree, container, anchor, instance, parentSuspense, isSVG)
        // 把渲染生成的子树根 DOM 节点存储到 el 属性上
        initialVNode.el = subTree.el
        instance.isMounted = true
      }
      else {
        // 省略200行代码
      }
    }
   // 创建一个ReactiveEffect实例,用于处理组件的副作用
   const effect = (instance.effect = new ReactiveEffect(
      componentUpdateFn, // 副作用函数,处理组件的更新逻辑
      () => queueJob(update),// 调度函数,将更新任务加入队列中
      instance.scope // 副作用函数的作用域,用于追踪依赖关系
    ))
    // 赋值 effect 执行函数给 update
    const update: SchedulerJob = (instance.update = () => effect.run())
	// 设置update函数的唯一标识符为组件实例的唯一ID
    update.id = instance.uid
    // 执行
    update()
}

setupRenderEffect 的逻辑有300行,不过我们这次说的初始化,因此相关逻辑就这么多,核心就是调用了 renderComponentRoot 来生成 subTree,然后再把 subTree 挂载到 container 中。其实 renderComponentRoot 的核心工作就是执行 instance.render 方法,我们在前面已经讨论过了,无论如何实例上都会有render方法的。

好的现在焦点来到了render函数上。

render函数到底是是什么,一般业务上来讲,render是template模板编译出来的,那么他具体是什么样,执行了他就可以得到一个渲染树。

直接打断点看看。

这个是官方弹窗的实例,例子不重要,编译后的render很重要。

我们可以看到render返回了一个 createElementBlock的返回值。

createElementBlock 是什么?

export function createElementBlock(
  type: string | typeof Fragment,
  props?: Record<string, any> | null,
  children?: any,
  patchFlag?: number,
  dynamicProps?: string[],
  shapeFlag?: number
) {
  return setupBlock(
    createBaseVNode(
      type,
      props,
      children,
      patchFlag,
      dynamicProps,
      shapeFlag,
      true /* isBlock */
    )
  )
}

虽然被包装过,但我们看到了 createBaseVNode !这个我们前面探讨过,返回的是一个vnode,也就是说,render 的本质,是一个返回vnode的函数!

怪不得在 handleSetupResult 里面,如果setup返回对象是一个vnode的话,会抛出警告,警告说,vnode需要使用函数返回。也就是说,如果setup返回vnode的话,会让你包装成函数返回,而如果setup返回一个函数的话,那个函数就render。

render返回vnode。

上面示例中,需要注意的返回的vnode的type是Fragment,接下来又要执行patch,这次执行的patch的type不是App.vue的大对象了,而是Fragment。

也就是Switch 中的 processFragment。为了防止遗忘,我这里贴一些对应代码。

 case Fragment:
  processFragment(
    n1,
    n2,
    container,
    anchor,
    parentComponent,
    parentSuspense,
    isSVG,
    slotScopeIds,
    optimized
  )
  break

上次是走了default,这次进入了对应的case。执行了 processFragment。我们看看他做了什么。

 const processFragment = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
  ) => {
    const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(''))!
    const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(''))!

    let { patchFlag, dynamicChildren, slotScopeIds: fragmentSlotScopeIds } = n2

    if (n1 == null) {
      hostInsert(fragmentStartAnchor, container, anchor)
      hostInsert(fragmentEndAnchor, container, anchor)
      mountChildren(
        n2.children as VNodeArrayChildren,
        container,
        fragmentEndAnchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
    }
    // ...非创建
  }

上面是创建相关的代码,fragment 在vue中是一块区域,就是用template标签所处的区域,类似react的 <>空标签。

从创建的逻辑来说,首先会将当前的vnode的el初始化一个空的文字节点。hostCreateText的本质是document.createText。

然后将vnode的anchor也做同样的处理。之后将他们插入#app的dom上,hostInsert 本质是 parent.insertBefore(child, anchor || null)的封装。

插入两个空文字节点后,开始跨在子节点。也就是执行 mountChildren。

我们看看mountChildren 做了什么。

const mountChildren: MountChildrenFn = (
  children,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  isSVG,
  slotScopeIds,
  optimized,
  start = 0
) => {
  for (let i = start; i < children.length; i++) {
    const child = (children[i] = optimized
      ? cloneIfMounted(children[i] as VNode)
      : normalizeVNode(children[i]))
    patch(
      null,
      child,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  }
}

朴实无华,循环遍历子节点,然后放到patch中执行。

好的,我们再次来到了patch,由于实例的结构是Fragment包裹这html,所以这次的type是一个button,是一个标签名,或者说是一个字符串,因此在patch中,Switch走到了default,然后通过ShapeFlags来判断逻辑。由于是一个字符串,因此ShapeFlags是ShapeFlags.ELEMENT。

可能有人有问题:你怎么知道type是button呢?答案当然是传进来的。还记得打断点的截图吗?

这个地方传了child,而createElementVNode就是createBaseVNode的别名(export { createBaseVNode as createElementVNode })

所以在patch中逻辑走到了ShapeFlags.ELEMENT里面。也就是

if (shapeFlag & ShapeFlags.ELEMENT) {
  processElement(
    n1,
    n2,
    container,
    anchor,
    parentComponent,
    parentSuspense,
    isSVG,
    slotScopeIds,
    optimized
  )
}

也就是执行了processElement逻辑。processElement 的逻辑也很简单。

function processElement(n1, n2, container, anchor, parentComponent) {
  if (!n1) {
	// 如果旧虚拟节点不存在,表示是初次渲染,调用mountElement进行挂载
    mountElement(n2, container, anchor);
  } else {
    // 如果旧虚拟节点存在,表示是更新,调用patchElement进行更新
    updateElement(n1, n2, container, anchor, parentComponent);
  }
}

分为挂载和更新。众所周知 n1 是null,我们来看mountElement。

const mountElement = (
  vnode: VNode, //虚拟节点
  container: RendererElement, // 渲染容器
  anchor: RendererNode | null, // 插入位置
  parentComponent: ComponentInternalInstance | null, // 父组件实例
  parentSuspense: SuspenseBoundary | null, // 父 Suspense
  isSVG: boolean,
  slotScopeIds: string[] | null, //  插槽作用域ID数组
  optimized: boolean
) => {
  let el: RendererElement;
  let vnodeHook: VNodeHook | undefined | null;
  const { type, props, shapeFlag, transition, dirs } = vnode;

  // 创建元素节点
  el = vnode.el = hostCreateElement(
    vnode.type as string,
    isSVG,
    props && props.is,
    props
  );

  // 挂载子节点,先挂载子节点,因为有些属性可能依赖于子节点内容的渲染,例如`<select value>`
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    hostSetElementText(el, vnode.children as string);
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    mountChildren(
      vnode.children as VNodeArrayChildren,
      el,
      null,
      parentComponent,
      parentSuspense,
      isSVG && type !== 'foreignObject',
      slotScopeIds,
      optimized
    );
  }

  // 执行指令的created钩子
  if (dirs) {
    invokeDirectiveHook(vnode, null, parentComponent, 'created');
  }

  // 设置作用域ID
  setScopeId(el, vnode, vnode.scopeId, slotScopeIds, parentComponent);

  // 挂载props属性
  if (props) {
    for (const key in props) {
      if (key !== 'value' && !isReservedProp(key)) {
        hostPatchProp(
          el,
          key,
          null,
          props[key],
          isSVG,
          vnode.children as VNode[],
          parentComponent,
          parentSuspense,
          unmountChildren
        );
      }
    }

    // 处理特殊情况,设置DOM元素的value属性
    if ('value' in props) {
      hostPatchProp(el, 'value', null, props.value);
    }

    // 执行props的onVnodeBeforeMount钩子
    if ((vnodeHook = props.onVnodeBeforeMount)) {
      invokeVNodeHook(vnodeHook, parentComponent, vnode);
    }
  }

  // 执行指令的beforeMount钩子
  if (dirs) {
    invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount');
  }

  // 处理过渡效果
  const needCallTransitionHooks =
    (!parentSuspense || (parentSuspense && !parentSuspense.pendingBranch)) &&
    transition &&
    !transition.persisted;

  // 执行过渡效果的beforeEnter钩子
  if (needCallTransitionHooks) {
    transition!.beforeEnter(el);
  }

  // 将元素插入到DOM中
  hostInsert(el, container, anchor);

  // 执行props的onVnodeMounted钩子、过渡效果的enter钩子和指令的mounted钩子
  if (
    (vnodeHook = props && props.onVnodeMounted) ||
    needCallTransitionHooks ||
    dirs
  ) {
    queuePostRenderEffect(() => {
      vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode);
      needCallTransitionHooks && transition!.enter(el);
      dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted');
    }, parentSuspense);
  }
};

hostCreateElement 就是document.createElement 和document.createElementNS的封装,也就是创建了一个DOM。

之后会处理 hostSetElementText,看到这里有人会问,他的shapeFlag不是ELEMENT吗,怎么会走到 TEXT_CHILDREN的逻辑里面,其实看好了,上文中大部分不是 && 运算,而是 & 运算。也就是说 ELEMENT & ARRAY_CHILDREN 不为 0 就行。

有的同学会提出疑问,那么按理说当前shapeFlag就是ELEMENT所代表的的枚举,但经过打断点,发现并非ELEMENT,这是因为 createBaseVNode 让父节点的 shapeFlag 进行了偏移,如果拉回去看会有这段代码,我截图放到下面。

如果存在子节点,会让父元素进行偏移,也就是 |= 运算,偏移结果视子节点类型为准。因此会出现与枚举不同的情况。

回到正题。

hostSetElementText 是对 el.textContent = text 的封装,因此会将节点文字赋值上去。

那么问题来了,如果子节点是数组节点呢?答案是进入mountChildren,而mountChildren做了什么呢?上文我们解析过了。就是一个for循环,循环patch。

后面处理了props属性。在https://github.com/vuejs/core/issues/1318之前,都是先props后children的,但存在一些bug,后改成了先children后props,顺带一提,这个issues是ant-design-vue的作者提出的。

最后 执行hostInsert ,hostInsert之前提到过,是 parent.insertBefore(child, anchor || null)的封装。 也就是将el挂载到 #app 所在的dom下面。

至此,初始化结束。

更新

在前面讲setupComponent 的时候,我们提到了有状态组件,当时提到过,组件会自己保存自己的信息,这些自己的信息就是状态,而这些状态是在下次执行的时候可以保存下来的。

那么问题来了,什么叫下次执行。

当然很明确,就是更新。

这里有一个盲点,就是模板组件的状态是可以保存下来的。因为他有状态,那么没状态的呢?

换句话说,函数组件呢?

显而易见,vue没有react那样健全的hook,所以函数组件的状态是保存不下来的。

我们使用一个例子来加深印象。

我们知道,defineComponent 是可以接受一个setup函数的,所以下面是一个setup返回的jsx,也就是返回了一个render函数。他的状态是可以被保存的。

import { ref, defineComponent } from 'vue';

export default defineComponent(() => {
  let num = ref(0);
  const add = () => {
    num.value ++;
  };
  
  return () => (
    <div>
      <button onClick={add}>
        { num.value }
      </button>
    </div>
  )
});

但下面的函数,是一个render函数,当对应的数据或者状态更新的时候,整个函数都会被执行,也就是说num永远是0,从视觉上来看都不会变,因为当 0更新成1的时候,触发了render再次更新,从而让num再次初始化为0

import { ref } from 'vue';

export default () => {
  let num = ref(0);
  const add = () => {
    num.value ++;
  };
  return (
    <div>
      <button onClick={add}>
        { num.value }
      </button>
    </div>
  )
}

这里可能就会有人问了。为什么更新会重新执行render而不是重新执行setup呢?

实际上上文的代码已经指出了原因,只是当时的思路是挂载,而不是更新,因此没有往这个逻辑上靠。

首先,我们强调过,经过setupStatefulComponent的操作,vue执行了setup,并把返回的函数挂载到render上,这个时候render就是一个不纯的函数,他依赖setup中的变量。

而逻辑往下走,走到了setupRenderEffect里面,setupRenderEffect会定义一个函数componentUpdateFn,这个函数会执行render。

然后生成一个副作用函数,这个副作用函数类似vue2的watch + immediate(副作用函数与watch有些许不同,他会立即执行,同时副作用函数对响应式数据操作后(get,set)才会收集依赖,并当有依赖变化的时候再次执行,这里面有特殊处理,之后会讲),作用是当这个componentUpdateFn里面的依赖有变动的时候,就会重新执行componentUpdateFn。

因为componentUpdateFn里面会执行render,因此我们可以认为,当render的依赖有变化的时候,会重新执行componentUpdateFn,然后重新执行render。

可以还有人有疑问,上面的render没有对外依赖,他只是自己有一个响应式变量,而不是函数外的响应式,从这种意义上来说,上面的render就是一个纯函数。那么内部的响应式数据变化也算依赖变化吗?

是的!vue的依赖收集从来都是只要响应式数据变化,就执行对应的响应式,而不论这个响应式数据在哪。

因此上面的例子可以抽象为下面的vue3逻辑

<script setup>
import { ref,effect} from 'vue'
let state 
effect(() => {
  state = ref(0)
  console.log(`state: ${state.value}`) // 第一次执行是 0, state.value ++后,打印出来还是0
});
state.value ++
</script>

state是一个内部响应数据,只不过暴露出来让外界可以修改,修改后,依然是疼得初始值。

对于一开始的问题,我们可以这么回答:之所以重新执行render而不重新执行setup,是因为被副作用函数收集依赖的就是render函数,而不是setup函数。

理解到了render的不同,因此我们回到整体的思路。

componentUpdateFn

我们刚刚说了,组件的更新其实就是componentUpdateFn的再次执行render,但整个逻辑显而易见不是一句话带过的,所以需要我们看一下具体逻辑。

 const setupRenderEffect: SetupRenderEffectFn = (
    instance,
    initialVNode,
    container,
    anchor,
    parentSuspense,
    isSVG,
    optimized
  ) => {
  function componentUpdateFn() {
    if (!instance.isMounted) {
      // 初始化
    }
    else {
	  // updateComponent
	  // 此部分代码由组件自身状态的变化触发(next: null)
	  // 或者父组件调用 processComponent(next: VNode)触发
	  let { next, bu, u, parent, vnode } = instance; // 解构获取实例中的属性和状态
	  let originNext = next; // 缓存原始的 next 状态
	  let vnodeHook: VNodeHook | null | undefined;
	
	  // 在预生命周期钩子期间禁止组件效果递归
	  toggleRecurse(instance, false);
	  if (next) {
    	// 如果 next 不为 null,更新元素引用
	    next.el = vnode.el;
    	// 更新组件实例
	    updateComponentPreRender(instance, next, optimized);
	  } else {
    	next = vnode; // 否则使用当前 vnode
	  }

	  // beforeUpdate 钩子
	  if (bu) {
    	// 调用 beforeUpdate 钩子函数
	    invokeArrayFns(bu);
	  }
	  // onVnodeBeforeUpdate 钩子
	  if ((vnodeHook = next.props && next.props.onVnodeBeforeUpdate)) {
    	invokeVNodeHook(vnodeHook, parent, next, vnode);
	  }
	  if (
    	__COMPAT__ &&
	    isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)
	  ) {
    	instance.emit('hook:beforeUpdate'); // 发射钩子事件(兼容处理)
	  }
	  toggleRecurse(instance, true); // 允许组件效果递归

	  // 渲染
	  const nextTree = renderComponentRoot(instance); // 渲染组件的根节点

	  const prevTree = instance.subTree; // 缓存之前的子树
	  instance.subTree = nextTree; // 更新实例的子树

	  patch(
    	prevTree,
	    nextTree,
	    hostParentNode(prevTree.el!)!,
	    getNextHostNode(prevTree),
    	instance,
	    parentSuspense,
    	isSVG
	  ); // patch更新 DOM 节点

	  next.el = nextTree.el; // 更新 next 的元素引用
	  if (originNext === null) {
    	// 自我触发的更新。在 HOC 的情况下,更新父组件的 vnode 元素引用
	    // HOC 由父实例的 subTree 指向子组件的 vnode 表示
	    updateHOCHostEl(instance, nextTree.el);
	  }
	  // updated 钩子
	  if (u) {
    	// 调用 updated 钩子函数
	    queuePostRenderEffect(u, parentSuspense);
	  }
	  // onVnodeUpdated 钩子
	  if ((vnodeHook = next.props && next.props.onVnodeUpdated)) {
    	queuePostRenderEffect(
	      () => invokeVNodeHook(vnodeHook!, parent, next!, vnode),
    	  parentSuspense
	    );
	  }
	}
  }
  // 其他。。
}

我们看到,在更新逻辑里面,依然使用renderComponentRoot执行了render,并且同样的,挂载到实例的subTree上,但在这之前,将之前的subTree给保留了下来,然后使用了patch,将新旧subTree一起传入进去。

接下来就是看过很多次的patch流程。但这一次,n1是有值的。所以逻辑我们需要重新捋一下。

  const patch: PatchFn = (
    n1,
    n2,
    container,
    anchor = null,
    parentComponent = null,
    parentSuspense = null,
    isSVG = false,
    slotScopeIds = null,
    optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
  ) => {
   // 如果 n1 存在且与 n2 的节点类型不同
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1) // 获取 n1 的下一个节点作为锚点
    unmount(n1, parentComponent, parentSuspense, true) // 卸载 n1 节点
    n1 = null // 将 n1 置为 null,表示已卸载
  }
  const { type, shapeFlag } = n2; // 获取新的节点的 type 和 shapeFlag
  switch (type) {
    case Text:
      processText(n1, n2, container); // 处理文本节点
      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:
      processFragment(n1, n2, container);
      break;
    default:
      // shapeFlag 判断
      if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(n1, n2, container, anchor, parentComponent); // 处理普通元素节点
      } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
        processComponent(n1, n2, container, parentComponent); // 处理组件节点
      }
  }
}

大体没啥差距,依然是根据类型不同做分流,不过我们可以看到,在开头判断了一次类型是否相同,如果新老节点类型不同,那么直接卸载旧节点,新节点按照挂载逻辑走。

那么判断类型逻辑看起来是相当重要的,他决定了这个组件是走挂载逻辑还是更新逻辑。

export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
   // 判断两个VNode是否具有相同的类型和key
  return n1.type === n2.type && n1.key === n2.key
}

剔除了一些逻辑,我们看到上面的代码,他们的 type 和 key 都相同就会被视为类型相同节点。

显而易见,更新一个节点的速度是快于卸载旧节点+ 重新创建新节点的。

这也提示了我们之前忽略的优化思路:当组件的key是随机数或者时间戳,他总要走卸载旧节点+ 重新创建新节点的流程,这样是增加vue渲染时间的,是一个需要优化的点。

而type我们之前强调过,虽然是 type 但作为模板类型的组件来说,type 就是模板里面script标签的大对象,虽然里面的属性会被更改,但是由于是引用类型,因此他们的对象是相同的,地址并没有变。

对于dom来说,type是他们的标签名称,因此如果标签名称相同,那么就是type相同。

好的,我们假设类型相同,这样n1就保留下来了,对应模板类型的组件,会走processComponent逻辑,而对于DOM的话,会走processElement逻辑。

processElement

我们看一下processElement的逻辑。processElement在更新的时候调用patchElement。

而patchElement的删减代码如下

const patchElement = (
  n1: VNode,
  n2: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  const el = (n2.el = n1.el!) // 获取元素节点
  let { patchFlag, dynamicChildren, dirs } = n2 // 获取新VNode的标记、动态子节点和指令
  // 考虑到用户可能会克隆编译生成的VNode,需将旧节点的patchFlag也考虑在内
  patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS
  const oldProps = n1.props || EMPTY_OBJ // 获取旧节点的属性对象
  const newProps = n2.props || EMPTY_OBJ // 获取新节点的属性对象
  let vnodeHook: VNodeHook | undefined | null

  // 在beforeUpdate钩子中禁用递归更新
  parentComponent && toggleRecurse(parentComponent, false)
  if ((vnodeHook = newProps.onVnodeBeforeUpdate)) {
    invokeVNodeHook(vnodeHook, parentComponent, n2, n1)
  }
  if (dirs) {
    invokeDirectiveHook(n2, n1, parentComponent, 'beforeUpdate')
  }
  parentComponent && toggleRecurse(parentComponent, true)

  const areChildrenSVG = isSVG && n2.type !== 'foreignObject' // 检查子节点是否为SVG元素
  if (dynamicChildren) {
    patchBlockChildren(
      n1.dynamicChildren!, // 旧动态子节点
      dynamicChildren, // 新动态子节点
      el, // 父元素
      parentComponent, // 父组件实例
      parentSuspense, // 父Suspense
      areChildrenSVG, // 子节点是否为SVG元素
      slotScopeIds // 插槽作用域id列表
    )
  } else if (!optimized) {
    // 完整差异更新
    patchChildren(
      n1, // 旧VNode
      n2, // 新VNode
      el, // 父元素
      null,
      parentComponent, // 父组件实例
      parentSuspense, // 父Suspense
      areChildrenSVG, // 子节点是否为SVG元素
      slotScopeIds,
      false
    )
  

  if (patchFlag > 0) {
    // 存在patchFlag表示该元素的渲染代码由编译器生成,可以采取快速路径。
    // 在这个路径中,旧节点和新节点在模板中的确切位置是相同的
    if (patchFlag & PatchFlags.FULL_PROPS) {
      // 元素属性包含动态键,需要完整差异更新
      patchProps(
        el, // 元素节点
        n2, // 新VNode
        oldProps, // 旧属性
        newProps, // 新属性
        parentComponent, // 父组件实例
        parentSuspense, // 父Suspense
        isSVG // 是否为SVG元素
      )
    } else {
      // class属性
      // 当元素具有动态类绑定时,将匹配到该标志。
      if (patchFlag & PatchFlags.CLASS) {
        if (oldProps.class !== newProps.class) {
          hostPatchProp(el, 'class', null, newProps.class, isSVG)
        }
      }

      // style属性
      // 当元素具有动态样式绑定时,将匹配到该标志。
      if (patchFlag & PatchFlags.STYLE) {
        hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG)
      }

      // 属性
      // 当元素具有动态prop/attr绑定(除class和style之外的绑定)时,将匹配到该标志。
      // 动态prop/attr的键保存在dynamicProps中,用于更快的迭代。
      // 注意:像:[foo] =“bar”的动态键将导致此优化中断,需要通过完整差异更新来取消旧键
      if (patchFlag & PatchFlags.PROPS) {
        // 如果标志存在,则dynamicProps必须是非空的
        const propsToUpdate = n2.dynamicProps!
        for (let i = 0; i < propsToUpdate.length; i++) {
          const key = propsToUpdate[i]
          const prev = oldProps[key]
          const next = newProps[key]
          // #1471 强制进行差异更新
          if (next !== prev || key === 'value') {
            hostPatchProp(
              el, // 元素节点
              key, // 属性名
              prev, // 旧值
              next, // 新值
              isSVG, // 是否为SVG元素
              n1.children as VNode[], // 旧的子节点数组(如果有)
              parentComponent, // 父组件实例
              parentSuspense, // 父Suspense
              unmountChildren // 卸载子节点的函数
            )
          }
        }
      }
    }

    // 文本节点
    // 当元素只有动态文本子节点时,将匹配到该标志。
    if (patchFlag & PatchFlags.TEXT) {
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children as string)
      }
    }
  } else if (!optimized && dynamicChildren == null) {
    // 未优化,完整差异更新
    patchProps(
      el, // 元素节点
      n2, // 新VNode
      oldProps, // 旧属性
      newProps, // 新属性
      parentComponent, // 父组件实例
      parentSuspense, // 父Suspense
      isSVG // 是否为SVG元素
    )
  }

  if ((vnodeHook = newProps.onVnodeUpdated) || dirs) {
    queuePostRenderEffect(() => {
      vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, n2, n1)
      dirs && invokeDirectiveHook(n2, n1, parentComponent, 'updated')
    }, parentSuspense)
  }
}

从上文的逻辑,我们看到是先更新子节点,然后更新props,同mountElement的顺序保持一致。这里面用到了dynamicChildren优化。我们按照全量更新的逻辑走。

这里面进入了patchChildren函数,我们可以看看这里面是什么

const patchChildren: PatchChildrenFn = (
  n1, // 旧VNode
  n2, // 新VNode
  container, // 渲染容器元素
  anchor, // 插入锚点
  parentComponent, // 父组件实例
  parentSuspense, // 父Suspense
  isSVG, // 是否为SVG元素
  slotScopeIds, // 插槽作用域id列表
  optimized = false // 是否为优化过的更新,默认为false
) => {
  const c1 = n1 && n1.children // 旧VNode的子节点
  const prevShapeFlag = n1 ? n1.shapeFlag : 0 // 旧VNode的形状标志
  const c2 = n2.children // 新VNode的子节点

  const { patchFlag, shapeFlag } = n2 // 新VNode的patchFlag和shapeFlag
  // 快速路径
  if (patchFlag > 0) {
    if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
      // 这可以是全key标记的或混合key标记的(一些用了key,一些没用)
      // patchFlag的存在意味着子节点保证是数组
      patchKeyedChildren(
        c1 as VNode[], // 旧的key标记子节点数组
        c2 as VNodeArrayChildren, // 新的VNode数组
        container, // 渲染容器元素
        anchor, // 插入锚点
        parentComponent, // 父组件实例
        parentSuspense, // 父Suspense
        isSVG, // 是否为SVG元素
        slotScopeIds, // 插槽作用域id列表
        optimized // 是否为优化过的更新
      )
      return
    } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
      // 无key标记
      patchUnkeyedChildren(
        c1 as VNode[], // 旧的无key标记子节点数组
        c2 as VNodeArrayChildren, // 新的VNode数组
        container, // 渲染容器元素
        anchor, // 插入锚点
        parentComponent, // 父组件实例
        parentSuspense, // 父Suspense
        isSVG, // 是否为SVG元素
        slotScopeIds, // 插槽作用域id列表
        optimized // 是否为优化过的更新
      )
      return
    }
  }

  // 子节点有3种可能性:文本、数组或无子节点。
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 文本子节点快速路径
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 旧子节点为数组
      unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
    }
    if (c2 !== c1) {
      hostSetElementText(container, c2 as string)
    }
  } else {
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 旧子节点为数组
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 两个数组,执行完整差异更新
        patchKeyedChildren(
          c1 as VNode[], // 旧的key标记子节点数组
          c2 as VNodeArrayChildren, // 新的VNode数组
          container, // 渲染容器元素
          anchor, // 插入锚点
          parentComponent, // 父组件实例
          parentSuspense, // 父Suspense
          isSVG, // 是否为SVG元素
          slotScopeIds, // 插槽作用域id列表
          optimized // 是否为优化过的更新
        )
      } else {
        // 没有新子节点,只需要卸载旧子节点
        unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
      }
    } else {
      // 旧子节点为文本或null
      // 新子节点为数组或null
      if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        hostSetElementText(container, '')
      }
      // 如果是数组,就挂载新的子节点
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        mountChildren(
          c2 as VNodeArrayChildren, // 新的VNode数组
          container, // 渲染容器元素
          anchor, // 插入锚点
          parentComponent, // 父组件实例
          parentSuspense, // 父Suspense
          isSVG, // 是否为SVG元素
          slotScopeIds, // 插槽作用域id列表
          optimized // 是否为优化过的更新
        )
      }
    }
  }
}

我们可以看到,节点类型只会有三种:

  • 文本节点

  • 数组节点

  • 空节点

而这里面根据新旧节点的类型不同,就会进行不同的逻辑。

下方为旧节点,右边为新节点

文本节点

数组节点

空节点

文本节点

替换文本

清空旧子节点,挂载数组节点

清空旧子节点

数组节点

卸载旧节点,设置文字

dif算法

卸载旧子节点

空节点

设置文字

挂载数组节点

什么都不做

可以看到,上面的逻辑还是很清晰的,唯一的难点就是diff算法了,这个算法会单独去讲,他们的逻辑与Vue2少许不同。

之后的逻辑是patchProps,他的逻辑实际上是一个新旧props的比较,然后根据新的props来使用不同的行为,需要注意的是,这里的props是作用在dom上,因此需要将props转为dom属性,并设置具体默认值和边界情况。如果是设置在组件上,那么需要将class以及style、ref、key等单独处理,on开头的设置为事件,布尔值转为具体字段,其他的设置为dom的属性。

processComponent

如果是组件更新呢?我们进入processComponent。

同DOM类似,在更新的时候调用updateComponent。

// n1 旧的VNode n2 新的VNode optimized 是否为优化过的更新
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
    ) {
      // 异步且仍在挂起状态 - 只更新props和slots
      // 因为组件的用于渲染的响应式effect还没有设置好
      updateComponentPreRender(instance, n2, optimized) // 更新组件的props和slots

      return
    } else {
      // 正常的更新
      instance.next = n2
      // 如果子组件也在队列中,将其移除,避免在同一个刷新中多次更新同一个子组件。
      invalidateJob(instance.update)
      // instance.update 是响应式effect。
      instance.update() // 执行组件的响应式更新函数
    }
  } else {
    // 不需要更新,只需复制属性
    n2.el = n1.el
    instance.vnode = n2
  }
}

删减过一些开发环境下的逻辑,整个函数还是比较简单,主要是通过shouldUpdateComponent,判断是否更新,因为有的vnode的值变化不需要更新子组件,换句话说,父组件的更新,如果子组件没有依赖那些数据,就不更新子组件。

最后调用了update函数,在componentUpdateFn的时候我们提到,update是实际上是就是执行了 effect.run(),而effect就是ReactiveEffect创建的副作用函数,就是再走一遍自己的渲染流程,执行了一遍componentUpdateFn重新渲染,但这次,实例上会多出一个next属性,里面存放的是新的vnode。

那么让我们回过头来看看有了next后,componentUpdateFn如何执行。这次只贴重点逻辑

const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
  function componentUpdateFn() {
    if (!instance.isMounted) {
      // 初始化组件
    }
    else {
      // 更新组件
      let { next, vnode } = instance
      if (next) {
		// 如果 next 不为 null,更新元素引用
        next.el = vnode.el
        // 更新组件实例信息
        updateComponentPreRender(instance, next, optimized)
      } else {
        next = vnode
      }
      //... 
      next.el = nextTree.el // 此时instance中的next由于updateComponentPreRender会清除,但由于next是解构的,并且如果不存在就会被赋值为vnode,所以以下的next可以认为是最新的vnode
      // 其他
    }
  }
  // 其他
}

触发了componentUpdateFn后,由于实例上有next,所以 next.el = vnode.el,next的le会被补充上el属性,因为el是mountElement时候根据vnode产生的,然后挂载在vonode上,不是vnode产生的,所以n2也就是next是没有el属性的,需要将原始el赋值给他。

然后进入updateComponentPreRender逻辑。

const updateComponentPreRender = (
  instance: ComponentInternalInstance,
  nextVNode: VNode,
  optimized: boolean
) => {
  nextVNode.component = instance // 将新的VNode关联到组件实例上
  const prevProps = instance.vnode.props // 记录旧的props
  instance.vnode = nextVNode // 更新组件实例的VNode
  instance.next = null // 将next置为null,重置nextt

  // 更新组件的props和slots
  updateProps(instance, nextVNode.props, prevProps, optimized)
  updateSlots(instance, nextVNode.children, optimized)

  pauseTracking() // 暂停追踪响应式属性的变化
  // props的更新可能已经触发了预先刷新的观察者。
  // 在渲染更新之前将它们刷新。
  flushPreFlushCbs() // 刷新在渲染之前需要提前执行的回调
  resetTracking() // 重置追踪响应式属性的状态
}

在updateComponentPreRender里面,会重新赋值一次nextVNode.component,尽管在其他地方处理过了,但为了函数纯度,这里再处理一次,然后保存了之前的props,然后将next赋值给vnode,让vnode成为最新,然后清除实例上的next,这也是使用next来判断是否有更新的标志——当实例上有next的时候(注意是实例,因为在componentUpdateFn的逻辑上,实例即使没有next也会被创造一个next常量)说明需要更新,更新准备好后清除实例上的next。

总结

以上是整个更新流程,我们来一个例子梳理一下,假设有以下的代码。

<template>
  <div>
    hello world
    <hello :msg="msg" />
    <button @click="changeMsg">修改 msg</button>
  </div>
</template>
<script>
import { ref } from 'vue'
export default {
  setup () {
    const msg = ref('你好')
    function changeMsg() {
      msg.value = '你好世界'
    }
    return {
      msg,
      changeMsg
    }
  }
}
</script>

// hello.vue
<template>
  <div>
    {{msg}}
  </div>
</template>
<script>
export default {
  props: {
    msg: String
  }
}
</script>

是一个App.vue,内部一个hello组件,用于显示传入的msg,msg的状态通过按钮进行改变。

1、我们点击按钮,触发了App.vue 的 msg修改,由于组件会被编译成render,因此可以说render依赖的msg变量发生了变化,因此触发了componentUpdateFn.

2、但因为实例上没有next,所以会跳过updateComponentPreRender,直接进行获取子树的vnode,然后进入patch流程.

3、patch流程中,type是div,所以进入processElement,然后进入patchElement.

4、然后因为存在dynamicChildren,进入patchBlockChildren,然后根据dynamicChildren循环patch。

5、dynamicChildren包括hello.vue,,因此patch的时候会进入processComponent逻辑。

6、然后进入updateComponent函数,然后将hello组件的实例的next赋值为最新的vnode,然后主动调用hello实例的update,再次进入hello的componentUpdateFn。

7、但这次有next,并且实例App.vue变成了hello.vue(因为是调用的hello的update),有了next,就在updateComponentPreRender,将vnode、slots、props进行更新。

8、调用完updateComponentPreRender后,就需要调用renderComponentRoot来获取新的子树vnode,我们之前提到过renderComponentRoot就是执行了render,我们在updateComponentPreRender修改了props,所以render使用的props是最新的。

9、此时获取子树vnode也是最新的(没有使用上文的next变成的vnode,而是只使用了部分字段,然后新生成一个),所以没有el,保存为实例下的subTree,之前的subTree单独保存了下来,然后两个vnode进去patch。

10、patch进入processElement,然后进入patchElement,此时实例上的el被之前的subTree的el赋值,然后根据类型,进行更新,这里是文字,所以会走到文字的快速处理逻辑,通过hostSetElementText直接替换旧值。之后经过一些守卫逻辑,此次更新结束。

可能有人说,这没有进入diff算法,这个是有原因的的,在第4步,如果是数组循环(比如v-for)并且是开发环境开启了热更新,那么会将dynamicChildren设置为null,从而不进入patchBlockChildren,而是进入patchChildren,然后根据patchChildren对新旧节点的类型比较,进入patchKeyedChildren进行diff算法,然后使用patch进行更新。

如果是非热更新和非开发环境下,那么数组循环将会在第4步,循环patch的时候,由于整个数组是一个Fragment,从而进入patch的时候,进入processFragment,然后processFragment存在与mountChildren对应的patchChildren。然后根据patchChildren对新旧节点的类型比较,进入patchKeyedChildren,也就是进入了diff算法。

小结

上面是大体逻辑,而实际上关于逻辑优化等细节问题我们并没有涉及到,以及最终的diff算法,我也打算放到后面一节来探讨,这只是我们的开始,虽然很艰难,但从现在开始追踪vue3这种优秀的前端库并不算晚,Vue2淘汰在即(虽然可能会如同jQuery一样长久不衰),抱着守旧的思想肯定是不行的,所以需要学习的东西还有很多。我们依然在路上。

0
博主关闭了所有页面的评论