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

1,239 阅读9分钟

指令的使用

  • 先来看下指令在Vue3中如何使用的,如果使用都不会,也就没有必要了解它是在Vue3中如何实现的了。
  • 来看下这个例子,通过这个例子来分析它在Vue3中如何工作的。(该例子代码原地址 )
it('should work', 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).toBe(_vnode)
      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).toBe(_vnode)
      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).toBe(_vnode)
      expect(prevVNode).toBe(_prevVnode)
    }) 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).toBe(_vnode)
      expect(prevVNode).toBe(_prevVnode)
    }) 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).toBe(_vnode)
      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).toBe(_vnode)
      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 Comp = {
      setup() {
        _instance = currentInstance
      },
      render() {
        _prevVnode = _vnode
        _vnode = withDirectives(h('div', count.value), [
          [
            dir,
            // value
            count.value,
            // argument
            'foo',
            // modifiers
            { ok: true }
          ]
        ])
        return _vnode
      }
    }

    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)
})

单侧的解读

我们平时一般会使用v-xxx:foo.ok="count",这个例子咋这样,看不懂,放弃?
别,别啊,这么快放弃,指令内部实现永远就搞不清楚了哦。

解读单侧

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)
  1. 把h(Comp)创建好的vnode通过render函数挂载到root元素下,在元素安装之前和安装之后分别执行了beforeMount和mounted钩子,并且给了4个参数,供我们开发者使用
  2. 通过count.value++ 改变响应式值后,触发Comp组件更新,会调用beforeUpdate和updated钩子
  3. 通过render(null, root) 来卸载Comp组件,会调用beforeUnmount和unmounted钩子 这确实是我们想要的使用姿势和结果

疑问解答继续

上面的解释说明了这个单侧的意图,但是应该会有和我一样有疑问的同学。

  • withDirectives方法 是tm什么鬼?

  • 说好的v-xxx:foo.ok="count"这个使用姿势呢?

  • 难道指令不需要注册吗?

  • 上面的钩子执行的时机在源码内部究竟如何实现的呢?
    等等,大哥,别急,我先上完厕所,之后大概解释下,等讲内部实现时,这些问题都会揭开的.

    1. 首选指令使用前肯定要注册
      const app = Vue.createApp(App);
      app.directive('指令名', Object/Function)
    

    这个帖子不讲注册的内部实现,所以各位同学瞅瞅就好

    1. v-xxx:foo.ok="count"这种姿势一般我们在模板中会这么写,那把这个例子改写下
    const Comp = {
        directives: {
         xxx:dir
        },
        setup() {
          _instance = currentInstance
        },
        template: `
         	<div v-xxx:foo.ok="count"></div>
        `
    }
    

    我靠,熟悉的味道来了(template,真香定律)。

    1. withDirectives 方法
    • 我们知道vue有模板编译模块和runtime-core和runtime-dom模块还有其他模块
    • 其实该单侧省略了模板编译,直接进入了render函数编写,所以有的同学看起来就难受了
    export function withDirectives<T extends VNode>(
      vnode: T,
      directives: DirectiveArguments
    ): T {
      const internalInstance = currentRenderingInstance
      if (internalInstance === null) {
        __DEV__ && warn(`withDirectives can only be used inside render functions.`)
        return vnode
      }
      const instance = internalInstance.proxy
      const bindings: DirectiveBinding[] = vnode.dirs || (vnode.dirs = [])
      for (let i = 0; i < directives.length; i++) {
        let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]
        if (isFunction(dir)) {
          dir = {
            mounted: dir,
            updated: dir
          } as ObjectDirective
        }
        bindings.push({
          dir,
          instance,
          value,
          oldValue: void 0,
          arg,
          modifiers
        })
      }
      return vnode
    }
    
    • withDirectives(vNode, [ [dir, value, argument, modifiers] ])函数接受2个参数
      第二个参数数组里面的成员,成员数组的第一个dir值必须为一个对象(虽然可以是函数,但是内部实现中,还是会转成对象){ beforeMount, mounted, beforeUpdate, updated, beforeUnmount, unmounted },值为钩子函数。
      该函数主要给我们的vnode的dirs属性添加值。

    目前为止,该单侧的解释基本说完了,终于终于终于可以讲解指令在内部如何实现的了。

    内部实现

上面的单侧告诉我们,在元素安装 更新 卸载的时候分别调用了beforeMount, mounted, beforeUpdate, updated, beforeUnmount, unmounted 这几个钩子。要想从茫茫源码中找出内部何时调用这几个钩子函数的时机(熟练使用debugger的老司机可以忽略),不妨先思考下面几个问题:

  • 该单侧通过render(h(Comp), root)来安装组件且挂载到root dom下, 那整个初始化的过程肯定先初始化Comp组件, 然后再安装Comp组件的模板?
  • 模板安装过程中, 会根据subTree vnode 来安装子vnode中的el dom元素, vnode的安装的过程中会先执行created和beforeMount钩子, 安装完毕后在执行mounted钩子?
  • 组件更新时, 会根据nextTree vnode 和 preTree vnode 进行patch, 在更新的过程中执行beforeUpdate和updated钩子?
  • 组件卸载时, 在卸载的时候执行beforeUnmount和unmounted钩子?
  • 那是不是分别在 安装 更新 卸载 这几个方法中,分别调用了这些钩子呢?
  • 可以带着以上的疑问去看源码

初始化

  1. 先看下render函数及入口代码
    const render: RootRenderFunction = (vnode, container) => {
      if (vnode == null) {
        if (container._vnode) {
          unmount(container._vnode, null, null, true)
        }
      } else {
        patch(container._vnode || null, vnode, container)
      }
      flushPostFlushCbs()
      container._vnode = vnode
    }
    
    render(h(Comp), root)

h(Comp)得到一个vnode节点,通过render函数把Comp组件安装到root dom下,Comp vnode节点描述如下

  1. 组件的安装、更新还是元素的安装、更新都会执行patch方法
const patch: PatchFn = (
    n1,
    n2,
    container,
    anchor = null,
    parentComponent = null,
    parentSuspense = null,
    isSVG = false,
    optimized = false
  ) => {
    // ...代码省略
    switch (type) {
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          processElement(
            n1,
            n2,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            optimized
          )
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          processComponent(
            n1,
            n2,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            optimized
          )
        }
    }

    // ...代码省略
}

  const processComponent = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    optimized: boolean
  ) => {
    if (n1 == null) {
      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)
    }
  }
  

当前vnode节点是组件,所以我们关心processComponent函数,该函数会处理组件的安装和组件的更新,因为当前处于安装时,所以关注下mountComponent函数

  const mountComponent: MountComponentFn = (
    initialVNode,
    container,
    anchor,
    parentComponent,
    parentSuspense,
    isSVG,
    optimized
  ) => {
    // 创建组件实例 instance

    // setupComponent(instance) 处理组件的setup逻辑及拿到render函数

    // 给instance实例添加update属性,安装和更新都会执行这个update方法
    setupRenderEffect(
      instance,
      initialVNode,
      container,
      anchor,
      parentSuspense,
      isSVG,
      optimized
    )
}

组件安装(更新也是同一个方法update函数),先看下setupRenderEffect 函数

  const setupRenderEffect: SetupRenderEffectFn = (
    instance,
    initialVNode,
    container,
    anchor,
    parentSuspense,
    isSVG,
    optimized
  ) => {
    // create reactive effect for rendering
    instance.update = effect(function componentEffect() {
      if (!instance.isMounted) {

        // beforeMount hook

        // onVnodeBeforeMount

        // 拿到组件模板中的根subTree vnode节点(这里div vnode节点的dirs有值,接下来就看他啥时候被调用,可以看下图长得啥样)

        // 安装 subTree 节点到 container dom 下
        patch(
            null,
            subTree,
            container,
            anchor,
            instance,
            parentSuspense,
            isSVG
        )

        // mounted hook

        // onVnodeMounted
      } else {
        // updateComponent 更新组件,这里先省略
      }
    }, __DEV__ ? createDevEffectOptions(instance) : prodEffectOptions)
  }

subTree vnode 节点描述 安装组件的过程中, 先拿到模板的根vnode节点, 然后在调用patch方法进行安装根vnode. 接着调用patch方法会执行processComponent(因为上图的vnode是普通元素节点, type 为 div)

  const processElement = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    optimized: boolean
  ) => {
    isSVG = isSVG || (n2.type as string) === 'svg'
    if (n1 == null) {
      mountElement(
        n2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        optimized
      )
    } else {
      patchElement(n1, n2, parentComponent, parentSuspense, isSVG, optimized)
    }
  }

可以看到,元素的安装和更新都在processElement函数中,那具有指令的vnode,是不是就在mountElement函数中执行呢?有点迫不及待了

  const mountElement = (
    vnode: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    optimized: boolean
  ) => {
    // 根据vnode 调用hostCreateElement函数来创建真实的dom元素

    // 如果vnode的孩子是文本节点则调用hostSetElementText来插入,否则就调用mountChildren方法来安装vnode的孩子

    if (dirs) { // 如果vnode有指令,则先执行created钩子
        invokeDirectiveHook(vnode, null, parentComponent, 'created')
    }

    // 处理props

    // 处理 scopeId

    if (dirs) { // 如果vnode有指令,则执行beforeMount钩子
      invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
    }

    // hostInsert(el, container, anchor) 把创建好的真实dom元素挂载到container dom下

    if (
      (vnodeHook = props && props.onVnodeMounted) ||
      needCallTransitionHooks ||
      dirs
    ) {
      queuePostRenderEffect(() => {
        vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
        needCallTransitionHooks && transition!.enter(el)
        dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted') // 等到本次事件循环结束后执行 指令的 mounted钩子
      }, parentSuspense)
    }
  }

可以看到, 原来指令的created beforeMount mounted钩子执行时机是在mountElement 安装元素的时候 挨个执行的,指令还可以使用created钩子,单侧没有写.
这也证实了一开始的想法没有错误.

组件更新

  1. count.value++ 通过改变count的值,来达到Comp组件更新(至于为什么更新,我后续可以单独开一个帖子讲解ref和effect)

  2. 组件的更新时会执行 instance.update方法

const setupRenderEffect: SetupRenderEffectFn = (
    instance,
    initialVNode,
    container,
    anchor,
    parentSuspense,
    isSVG,
    optimized
  ) => {
    // create reactive effect for rendering
    instance.update = effect(function componentEffect() {
      if(!instance.isMounted) {
        // 组件安装 代码省略
      } else {
        // beforeUpdate hook

        // onVnodeBeforeUpdate

        // 调用renderComponentRoot方法重新获取新的根 vnode节点
        const nextTree = renderComponentRoot(instance)
        if (__DEV__) {
          endMeasure(instance, `render`)
        }
        const prevTree = instance.subTree
        instance.subTree = nextTree

        // 根据新老vnode节点来更新
        patch(
          prevTree,
          nextTree,
          // parent may have changed if it's in a teleport
          hostParentNode(prevTree.el!)!,
          // anchor may have changed if it's in a fragment
          getNextHostNode(prevTree),
          instance,
          parentSuspense,
          isSVG
        )

        // updated hook

        // onVnodeUpdated
      }
    }, __DEV__ ? createDevEffectOptions(instance) : prodEffectOptions)
  }

最终还是调用patch方法 -> processElement方法 -> patchElement方法, patch和processElement方法已经说过了,接下来看下patchElement方法

  const patchElement = (
    n1: VNode,
    n2: VNode,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    optimized: boolean
  ) => {
      // invokeVNodeHook

      if (dirs) { // vnode有dirs,单侧也提供了beforeUpdate钩子,所有会执行
        invokeDirectiveHook(n2, n1, parentComponent, 'beforeUpdate')
      }

      // patchProps

      // patchChildren

      if ((vnodeHook = newProps.onVnodeUpdated) || dirs) {
        queuePostRenderEffect(() => {
            vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, n2, n1)
            dirs && invokeDirectiveHook(n2, n1, parentComponent, 'updated') // 本次事件循环结束后执行 updated钩子
        }, parentSuspense)
    }
  }

可以看出,指令的 beforeUpdate updated 钩子是在 patchElement方法中被调用的.

组件卸载

  1. 通过 render(null, root) 来卸载组件
const render: RootRenderFunction = (vnode, container) => {
    if (vnode == null) {
      if (container._vnode) {
        unmount(container._vnode, null, null, true)
      }
    } else {
      patch(container._vnode || null, vnode, container)
    }
    flushPostFlushCbs()
    container._vnode = vnode
}

组件安装完毕后,会在container dom下添加_vnode属性,现在调用render(null, root),就会执行unmount(container._vnode, null, null, true), 来看下unmount函数

  const unmount: UnmountFn = (
    vnode,
    parentComponent,
    parentSuspense,
    doRemove = false,
    optimized = false
  ) => {
      // unset ref

      // 用这个变量来判断是否调用是否钩子
      const shouldInvokeDirs = shapeFlag & ShapeFlags.ELEMENT && dirs

      // invokeVNodeHook

      if (shapeFlag & ShapeFlags.COMPONENT) {
        unmountComponent(vnode.component!, parentSuspense, doRemove) // 如果vnode是组件vnode,则会调用unmountComponent方法,最终还是会执行unmount方法的
      } else {
        if (shouldInvokeDirs) { // 调用beforeUnmount钩子
          invokeDirectiveHook(vnode, null, parentComponent, 'beforeUnmount')
        }

        // unmountChildren

        if (doRemove) { // 把vnode中el元素从父节点上删除
          remove(vnode)
        }

        if ((vnodeHook = props && props.onVnodeUnmounted) || shouldInvokeDirs) {
          queuePostRenderEffect(() => {
            vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
            shouldInvokeDirs &&
            invokeDirectiveHook(vnode, null, parentComponent, 'unmounted') // 执行 unmounted 钩子
          }, parentSuspense)
        }
      }
  }

可以看出,组件卸载时在 unmount 方法中会执行 beforeUnmount 和 unmounted钩子
unmount方法适用于卸载任意类型的vnode节点,对于本次的单测,其中的if else 分支都走到了, 调用unmountComponent方法内部还是会调用unmount方法来卸载 subTree vnode,所以会执行到 beforeUnmount和unmounted钩子.

总结

对于指令在普通vnode上:

初始化时:

在安装元素执行mountElement方法时,会调用 created beforeMount mounted 钩子

更新时:

在更新元素执行patchElement方法时,会调用 beforeUpdate updated 钩子

卸载时:

在卸载元素执行unmount方法时,会调用 beforeUnmount unmounted 钩子

指令除了可以写在普通vnode上,如果写在组件vnode上,它又是如何工作呢?

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