Vue3疑问系列(2) — 在component vnode上绑定指令,指令是如何工作的?

2,848 阅读6分钟

前言

指令除了可以在普通元素上使用,也可以在组件上使用,作用在组件上,其实本质是subTree vnode 会继承 components vnode的指令dirs, 这样普通subTree vnode有了dirs属性,在普通subTree vnode安装,更新,卸载的时候 便会执行相应的钩子,这在Vue3 疑问系列(1) — 在普通vnode上绑定指令,指令是如何工作的?已经解释过了。

友情提醒:

看这篇文章前,建议你先看下 Vue3 疑问系列(1) — 在普通vnode上绑定指令,指令是如何工作的?

如果你学习过vue3的源码,可以忽略.

在组件上指令的使用

要学会看懂单侧,vue3和element-plus都有单侧,单侧我觉得是最好的学习文档。
下面看下本次用来解释原理的单侧(该单测代码原地址 )

it('should work on component vnode', async () => {
    const count = ref(0)

    function assertBindings(binding: DirectiveBinding) {
      expect(binding.value).toBe(count.value)
      expect(binding.arg).toBe('foo')
      expect(binding.instance).toBe(_instance && _instance.proxy)
      expect(binding.modifiers && binding.modifiers.ok).toBe(true)
    }

    const beforeMount = jest.fn(((el, binding, vnode, prevVNode) => {
      expect(el.tag).toBe('div')
      // should not be inserted yet
      expect(el.parentNode).toBe(null)
      expect(root.children.length).toBe(0)

      assertBindings(binding)

      expect(vnode.type).toBe(_vnode!.type)
      expect(prevVNode).toBe(null)
    }) as DirectiveHook)

    const mounted = jest.fn(((el, binding, vnode, prevVNode) => {
      expect(el.tag).toBe('div')
      // should be inserted now
      expect(el.parentNode).toBe(root)
      expect(root.children[0]).toBe(el)

      assertBindings(binding)

      expect(vnode.type).toBe(_vnode!.type)
      expect(prevVNode).toBe(null)
    }) as DirectiveHook)

    const beforeUpdate = jest.fn(((el, binding, vnode, prevVNode) => {
      expect(el.tag).toBe('div')
      expect(el.parentNode).toBe(root)
      expect(root.children[0]).toBe(el)

      // node should not have been updated yet
      // expect(el.children[0].text).toBe(`${count.value - 1}`)

      assertBindings(binding)

      expect(vnode.type).toBe(_vnode!.type)
      expect(prevVNode!.type).toBe(_prevVnode!.type)
    }) as DirectiveHook)

    const updated = jest.fn(((el, binding, vnode, prevVNode) => {
      expect(el.tag).toBe('div')
      expect(el.parentNode).toBe(root)
      expect(root.children[0]).toBe(el)

      // node should have been updated
      expect(el.children[0].text).toBe(`${count.value}`)

      assertBindings(binding)

      expect(vnode.type).toBe(_vnode!.type)
      expect(prevVNode!.type).toBe(_prevVnode!.type)
    }) as DirectiveHook)

    const beforeUnmount = jest.fn(((el, binding, vnode, prevVNode) => {
      expect(el.tag).toBe('div')
      // should be removed now
      expect(el.parentNode).toBe(root)
      expect(root.children[0]).toBe(el)

      assertBindings(binding)

      expect(vnode.type).toBe(_vnode!.type)
      expect(prevVNode).toBe(null)
    }) as DirectiveHook)

    const unmounted = jest.fn(((el, binding, vnode, prevVNode) => {
      expect(el.tag).toBe('div')
      // should have been removed
      expect(el.parentNode).toBe(null)
      expect(root.children.length).toBe(0)

      assertBindings(binding)

      expect(vnode.type).toBe(_vnode!.type)
      expect(prevVNode).toBe(null)
    }) as DirectiveHook)

    const dir = {
      beforeMount,
      mounted,
      beforeUpdate,
      updated,
      beforeUnmount,
      unmounted
    }

    let _instance: ComponentInternalInstance | null = null
    let _vnode: VNode | null = null
    let _prevVnode: VNode | null = null

    const Child = (props: { count: number }) => {
      _prevVnode = _vnode
      _vnode = h('div', props.count)
      return _vnode
    }

    const Comp = {
      setup() {
        _instance = currentInstance
      },
      render() {
        return withDirectives(h(Child, { count: count.value }), [
          [
            dir,
            // value
            count.value,
            // argument
            'foo',
            // modifiers
            { ok: true }
          ]
        ])
      }
    }

    const root = nodeOps.createElement('div')
    render(h(Comp), root)

    expect(beforeMount).toHaveBeenCalledTimes(1)
    expect(mounted).toHaveBeenCalledTimes(1)

    count.value++
    await nextTick()
    expect(beforeUpdate).toHaveBeenCalledTimes(1)
    expect(updated).toHaveBeenCalledTimes(1)

    render(null, root)
    expect(beforeUnmount).toHaveBeenCalledTimes(1)
    expect(unmounted).toHaveBeenCalledTimes(1)
 })

该单侧和 '在普通vnode上绑定指令,指令是如何工作的?'一文中的单侧差不多。
单侧意图告诉我们:

  1. render(h(Comp), root) 安装组件时,会分别执行 beforeMount mounted钩子函数
  2. count.value++ 组件更新时,会分别执行 beforeUpdate updated钩子函数
  3. render(null, root) 组件卸载时 会分别执行 beforeUnmount unmounted钩子函数

本篇文章的目的就是要探索在源码内部何时执行这些钩子的,知道了内部实现,以后使用指令时会更加得心应手.

考虑到部分同学,对render函数比较陌生,我也对该单侧进行了 template 的改写,这样就能直观地看懂该单侧了.

<script>
    import { ref, render, h } from 'vue'
    const count = ref(0)
    const root = document.getElementById('app')

    const Child = {
        props: {
            type: Number,
            default: 0
        },
        template: `
            <div>{{ count }}</div>
        `
    }

    const Comp = {
        directives: {
            xxx: {
                beforeMount,
                mounted,
                beforeUpdate,
                updated,
                beforeUnmount,
                unmounted
            }
        },
        template: `
            <Child v-xxx:foo.ok="count" />
        `
    }

    render(h(Comp), root)
</script>

这么改写完,就没有理由看不懂了吧.
嗯,那就进入正题,看看源码内部究竟何时调用这些钩子呢?

内部实现

初始化

    render(h(Comp), root)
  1. render函数 -> patch函数 -> processComponent函数[处理Comp组件] -> mountComponent函数[安装Comp组件] -> instance.update函数【拿到subTree Child vnode节点 -> patch函数 -> processComponent函数[处理Child组件] -> mountComponent函数[安装Child组件] -> instance.update函数[拿到subTree div vnode节点,此时div vnode继承了 Child vnode的dirs] -> 安装完subTree div vnode节点[subTree div vnode el 赋值给 subTree Child vnode el上]] -> 安装完subTree Child vnode节点[subTree Child vnode el赋值给comp vnode el] ] 】-> Comp vnode安装完[comp vnode el挂载到root下]
  • 组件vnode节点都会安装他的subTree vnode
  • 普通元素vnode children属性可能有值,有值都会递归地安装他的孩子vnode
  • 所以上面调用栈就很长了
  • 关于组件如何patch到浏览器,后面会单独写一篇文章介绍[从模板 -> render -> vnode -> diff -> 真实dom[el创建、属性、事件如何添加到el上]]
  1. 这里重点说下获取组件下subTree的方法,该方法内部会使subTree vnode 继承 组件vnode 的dirs
export function renderComponentRoot(
  instance: ComponentInternalInstance
): VNode {
  const {
    type: Component,
    vnode,
    proxy,
    withProxy,
    props,
    propsOptions: [propsOptions],
    slots,
    attrs,
    emit,
    render,
    renderCache,
    data,
    setupState,
    ctx
  } = instance

  let result
  currentRenderingInstance = instance
  if (__DEV__) {
    accessedAttrs = false
  }
  try {
    let fallthroughAttrs
    if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
      // withProxy is a proxy with a different `has` trap only for
      // runtime-compiled render functions using `with` block.
      const proxyToUse = withProxy || proxy
      result = normalizeVNode(
        render!.call(
          proxyToUse,
          proxyToUse!,
          renderCache,
          props,
          setupState,
          data,
          ctx
        )
      )
      fallthroughAttrs = attrs
    } else {
      // functional
      const render = Component as FunctionalComponent
      // in dev, mark attrs accessed if optional props (attrs === props)
      if (__DEV__ && attrs === props) {
        markAttrsAccessed()
      }
      result = normalizeVNode(
        render.length > 1
          ? render(
              props,
              __DEV__
                ? {
                    get attrs() {
                      markAttrsAccessed()
                      return attrs
                    },
                    slots,
                    emit
                  }
                : { attrs, slots, emit }
            )
          : render(props, null as any /* we know it doesn't need it */)
      )
      fallthroughAttrs = Component.props
        ? attrs
        : getFunctionalFallthrough(attrs)
    }

    // attr merging
    // in dev mode, comments are preserved, and it's possible for a template
    // to have comments along side the root element which makes it a fragment
    let root = result
    let setRoot: ((root: VNode) => void) | undefined = undefined
    if (
      __DEV__ &&
      result.patchFlag > 0 &&
      result.patchFlag & PatchFlags.DEV_ROOT_FRAGMENT
    ) {
      ;[root, setRoot] = getChildRoot(result)
    }

    if (Component.inheritAttrs !== false && fallthroughAttrs) {
      const keys = Object.keys(fallthroughAttrs)
      const { shapeFlag } = root
      if (keys.length) {
        if (
          shapeFlag & ShapeFlags.ELEMENT ||
          shapeFlag & ShapeFlags.COMPONENT
        ) {
          if (propsOptions && keys.some(isModelListener)) {
            // If a v-model listener (onUpdate:xxx) has a corresponding declared
            // prop, it indicates this component expects to handle v-model and
            // it should not fallthrough.
            // related: #1543, #1643, #1989
            fallthroughAttrs = filterModelListeners(
              fallthroughAttrs,
              propsOptions
            )
          }
          root = cloneVNode(root, fallthroughAttrs)
        } else if (__DEV__ && !accessedAttrs && root.type !== Comment) {
          const allAttrs = Object.keys(attrs)
          const eventAttrs: string[] = []
          const extraAttrs: string[] = []
          for (let i = 0, l = allAttrs.length; i < l; i++) {
            const key = allAttrs[i]
            if (isOn(key)) {
              // ignore v-model handlers when they fail to fallthrough
              if (!isModelListener(key)) {
                // remove `on`, lowercase first letter to reflect event casing
                // accurately
                eventAttrs.push(key[2].toLowerCase() + key.slice(3))
              }
            } else {
              extraAttrs.push(key)
            }
          }
          if (extraAttrs.length) {
            warn(
              `Extraneous non-props attributes (` +
                `${extraAttrs.join(', ')}) ` +
                `were passed to component but could not be automatically inherited ` +
                `because component renders fragment or text root nodes.`
            )
          }
          if (eventAttrs.length) {
            warn(
              `Extraneous non-emits event listeners (` +
                `${eventAttrs.join(', ')}) ` +
                `were passed to component but could not be automatically inherited ` +
                `because component renders fragment or text root nodes. ` +
                `If the listener is intended to be a component custom event listener only, ` +
                `declare it using the "emits" option.`
            )
          }
        }
      }
    }

    // inherit directives
    if (vnode.dirs) {
      if (__DEV__ && !isElementRoot(root)) {
        warn(
          `Runtime directive used on component with non-element root node. ` +
            `The directives will not function as intended.`
        )
      }
      root.dirs = root.dirs ? root.dirs.concat(vnode.dirs) : vnode.dirs
    }
    // inherit transition data
    if (vnode.transition) {
      if (__DEV__ && !isElementRoot(root)) {
        warn(
          `Component inside <Transition> renders non-element root node ` +
            `that cannot be animated.`
        )
      }
      root.transition = vnode.transition
    }

    if (__DEV__ && setRoot) {
      setRoot(root)
    } else {
      result = root
    }
  } catch (err) {
    handleError(err, instance, ErrorCodes.RENDER_FUNCTION)
    result = createVNode(Comment)
  }
  currentRenderingInstance = null

  return result
}

const subTree = (instance.subTree = renderComponentRoot(instance))

该函数很长,但是我们也没有必要全部看完,只要知道该函数的传参是当前组件的实例,返回值是组件render函数返回的vnode即可

    // inherit directives
    if (vnode.dirs) {
      if (__DEV__ && !isElementRoot(root)) {
        warn(
          `Runtime directive used on component with non-element root node. ` +
            `The directives will not function as intended.`
        )
      }
      root.dirs = root.dirs ? root.dirs.concat(vnode.dirs) : vnode.dirs
    }

对于本单侧,child组件的vnode是有dirs,但是他模板的div vnode是没有dirs值的
上面这段代码就是把child vnode 的dirs给了 div vnode dirs

3.普通 subTree vnode继承了组件vnode的dirs值,那普通 subTree vnode安装、更新、卸载时,何时调用那些钩子我就不重复讲解了。
不了解的可以查看Vue3 疑问系列(1) — 在普通vnode上绑定指令,指令是如何工作的?

更新

卸载

可以参考Vue3 疑问系列(1) — 在普通vnode上绑定指令,指令是如何工作的?中的组件卸载

总结

指令用在普通vnode和组件vnode上,是如何工作的,都讲完了.
组件vnode上指令工作的原理就是subTree vnode 继承了component vnode dirs的值,其他就和普通vnodes上指令工作的原理一样了.

下篇: Vue3疑问系列(3) — v-show指令是如何工作的?