Vue3探秘系列— directive:指令的实现原理(十一)

178 阅读9分钟

前言

Vue3探秘系列文章链接:

不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)

不止响应式:Vue3探秘系列— 组件更新会发生什么(二)

不止响应式:Vue3探秘系列— diff算法的完整过程(三)

不止响应式:Vue3探秘系列— 组件的初始化过程(四)

终于轮到你了:Vue3探秘系列— 响应式设计(五)

计算属性:Vue3探秘系列— computed的实现原理(六)

侦听属性:Vue3探秘系列— watch的实现原理(七)

生命周期:Vue3探秘系列— 钩子函数的执行过程(八)

依赖注入:Vue3探秘系列— provide 与 inject 的实现原理(九)

Vue3探秘系列— Props:初始化与更新流程(十)

Vue3探秘系列— directive:指令的实现原理(十一)

Hello~大家好。我是秋天的一阵风

对于指令,相信大家都不陌生。指令是带有 v- 前缀的特殊 attribute,用于扩展 HTML 元素的行为。比如我们经常使用的 v-if, v-for, v-show, v-model等等,除此之外,更多Vue的内置指令 ,你可以在官网查看:内置指令

Vue之所以提供内置指令给用户,原因是希望用户尽可能的不用直接操作DOM,但是这不意味着你不能操作DOM

在某些场景下,我们希望可以直接手动地去操作DOM节点,比如当某个DOM节点挂载到页面的时候去做一些事情。

为了支持这些场景,Vue允许我们自定义指令,作用在普通的DOM元素上。

想要实现一个自定义指令有两种方式,分别是全局注册组件内部局部注册

// 全局注册
import Vue from 'vue'
const app = Vue.createApp({})
// 注册全局 v-test 指令
app.directive('test', {
  // 挂载的钩子函数
  mounted(el) {
    console.log("mounted")
  }
})
//局部注册
directives: {
  test: {
    mounted(el) {
      console.log("mounted")
    }
  }
}

然后我们就可以在模板中使用这个指令了:<input v-test />。当input输入框挂载到页面时,会打印"mounted"

了解了指令的功能和用法,我们接下来探讨它的实现原理。

一、指令的定义

一个自定义指令由一个包含类似组件生命周期钩子对象来定义。钩子函数会接收到指令所绑定元素作为其参数。

比如定义一个 v-log 指令,这个指令做的事情就是在指令的各个生命周期去输出一些log信息:

const logDirective = {
  beforeMount() {
    console.log('log directive before mount')
  },
  mounted() {
     console.log('log directive mounted')
  },
  beforeUpdate() {
    console.log('log directive before update')
  },
  updated() {
    console.log('log directive updated')
  },
  beforeUnmount() {
    console.log('log directive beforeUnmount')
  },
  unmounted() {
    console.log('log directive unmounted')
  }
}

然后你可以在创建应用后注册它:

import { createApp } from 'vue'
import App from './App'
const app = createApp(App)
app.directive('log', logDirective)
app.mount('#app')

接着在 App 组件中使用这个指令:

<template>
  <p v-if="flag">{{ msg }}</p>
  <input v-else v-log v-model="text"/>
  <button @click="flag=!flag">toggle</button>
</template>
<script>
  export default {
    data() {
      return {
        flag: true,
        msg: 'Hello Vue',
        text: ''
      }
    }
  }
</script>

当你点击按钮后,会先执行指令定义的 beforeMountmounted 钩子函数,然后你在input输入框中输入一些内容,会执行 beforeUpdate updated 钩子函数,然后你再次点击按钮,会执行beforeUnmountunmounted 钩子函数。

所以指令的本质其实就是在适当的时机去执行钩子函数中的逻辑。

二、指令的注册

我们在开头提到过,自定义指令支持全局注册组件内局部注册,我们来探究下注册的实现原理,首先是全局注册:

1. 全局注册

全局注册是通过 app.directive 方法去注册的,比如

app.directive('test', {
  // 挂载的钩子函数
  mounted(el) {
    console.log("mounted")
  }
})

我们来看directive方法的实现:

function createApp(rootComponent, rootProps = null) {
  const context = createAppContext()
  const app = {
    _component: rootComponent,
    _props: rootProps,
    directive(name, directive) {
      if ((process.env.NODE_ENV !== 'production')) {
        validateDirectiveName(name)
      }
      if (!directive) {
        // 没有第二个参数,则获取对应的指令对象
        return context.directives[name]
      }
      if ((process.env.NODE_ENV !== 'production') && context.directives[name]) {
        // 重复注册的警告
        warn(`Directive "${name}" has already been registered in target app.`)
      }
      context.directives[name] = directive
      return app
    }
  }
  return app
}

  1. validateDirectiveName : 首先会对传入的指令名称进行校验,这里主要是校验注册的指令名与内置的指令名是否有冲突: 'bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text'

  2. 如果不传第二个参数指令对象,表示这是一次指令的获取;

  3. 指令全局注册方法的实现非常简单,就是把指令对象注册到 app 对象创建的全局上下文context.directives中,并用 name 作为 key

2. 局部注册

局部注册的方式,它是直接在组件对象中定义的,比如:

directives: {
  test: {
    mounted(el) {
      console.log("mounted")
    }
  }
}

因此全局注册和局部注册的区别是,一个保存在 appContext 中,一个保存在组件对象的定义中。

三、指令的应用

我们接下来就要分析指令是如何生效的,我们看下案例代码中模板编译生成的render函数是什么样子的:

import { resolveDirective as _resolveDirective, createVNode as _createVNode, withDirectives as _withDirectives, openBlock as _openBlock, createBlock as _createBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _directive_focus = _resolveDirective("focus")
  return _withDirectives((_openBlock(), _createBlock("input", null, null, 512 /* NEED_PATCH */)), [
    [_directive_focus]
  ])
}

这里的核心就是_withDirectives函数,我们会将编译生成的vnode传入这个函数。

除此之外,还有一个_resolveDirective函数,这个函数的作用是对指令进行解析,因为我们之前在指令注册的时候将定义的指令对象已经保存下来了。这个函数就是根据指令名称去找到对应的指令对象。

1. resolveDirective 获取指令对象

const DIRECTIVES = 'directives';
function resolveDirective(name) {
  return resolveAsset(DIRECTIVES, name)
}
function resolveAsset(type, name, warnMissing = true) {
  // 获取当前渲染实例
  const instance = currentRenderingInstance || currentInstance
  if (instance) {
    const Component = instance.type
    const res =
      // 局部注册
      resolve(Component[type], name) ||
      // 全局注册
      resolve(instance.appContext[type], name)
    if ((process.env.NODE_ENV !== 'production') && warnMissing && !res) {
      warn(`Failed to resolve ${type.slice(0, -1)}: ${name}`)
    }
    return res
  }
  else if ((process.env.NODE_ENV !== 'production')) {
    warn(`resolve${capitalize(type.slice(0, -1))} ` +
      `can only be used in render() or setup().`)
  }
}
function resolve(registry, name) {
  return (registry &&
    (registry[name] ||
      registry[camelize(name)] ||
      registry[capitalize(camelize(name))]))
}

可以看到,resolveDirective 内部调用了 resolveAsset 函数,传入的类型名称为 directives 字符串。

resolveAsset 内部先通过 resolve函数解析局部注册的资源,由于我们传入的是 directives,所以就从组件定义对象上的 directives 属性中查找对应 name 的指令,如果查找不到则通过 instance.appContext,也就是我们前面提到的全局的 appContext,根据其中的 name查找对应的指令。

所以 resolveDirective 的实现很简单,优先查找组件是否局部注册该指令,如果没有则看是否全局注册该指令,如果还找不到则在非生产环境下报警告,提示用户没有解析到该指令。

如果你平时在开发工作中遇到这个警告,那么你很可能就是没有注册这个指令,或者是 name 写得不对。

注意,在 resolve 函数实现的过程中,它会先根据name匹配,如果失败则把 name 变成驼峰格式继续匹配,还匹配不到则把 name 首字母大写后继续匹配,这么做是为了让用户编写指令名称的时候可以更加灵活,所以需要多判断几步用户可能编写的指令名称的情况。

2. withDirectives

function withDirectives(vnode, directives) {
  const internalInstance = currentRenderingInstance
  if (internalInstance === null) {
    (process.env.NODE_ENV !== 'production') && warn(`withDirectives can only be used inside render functions.`)
    return vnode
  }
  const instance = internalInstance.proxy
  const bindings = 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
      }
    }
    bindings.push({
      dir,
      instance,
      value,
      oldValue: void 0,
      arg,
      modifiers
    })
  }
  return vnode
}

withDirectives 函数第一个参数是 vnode,第二个参数是指令构成的数组,因为一个元素节点上是可以应用多个指令的。

withDirectives 的功能核心就是给vnode增加一个 dirs 数组,里面存放的是这个元素节点所有的指令对象。

首先对directives进行循环遍历,得到指令对象的值 value、参数 arg、修饰符modifiers等等,然后构建一个新的binding对象,push进入bindings数组中,也就是dirs数组。

这么做的目的是在元素的生命周期中知道运行哪些指令相关的钩子函数,以及在运行这些钩子函数的时候,还可以往钩子函数中传递一些指令相关的参数。

3. 执行钩子函数

接下来就是在元素不同的生命周期中执行对应的钩子函数。

(1) 元素挂载时:mountElement

首先,我们来看元素挂载时候会执行哪些指令的钩子函数。通过前面章节的学习我们了解到,一个元素的挂载是通过执行 mountElement 函数完成的,我们再来回顾一下它的实现:

const mountElement = (vnode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
  let el
  const { type, props, shapeFlag, dirs } = vnode
  // 创建 DOM 元素节点
  el = vnode.el = hostCreateElement(vnode.type, isSVG, props && props.is)
  if (props) {
    // 处理 props,比如 class、style、event 等属性
  }
  if (shapeFlag & 8 /* TEXT_CHILDREN */) {
    // 处理子节点是纯文本的情况
    hostSetElementText(el, vnode.children)
  } else if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
    // 处理子节点是数组的情况,挂载子节点
    mountChildren(vnode.children, el, null, parentComponent, parentSuspense, isSVG && type !== 'foreignObject', optimized || !!vnode.dynamicChildren)
  }
  if (dirs) {
    invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
  }
  // 把创建的 DOM 元素节点挂载到 container 上
  hostInsert(el, container, anchor)


  if (dirs) {
    queuePostRenderEffect(()=>{ 
      invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
    })
  }
}

可以直观地看到,在元素插入到容器之前会执行指令的 beforeMount 钩子函数,在插入元素之后,会通过 queuePostRenderEffect 的方式执行指令的 mounted 钩子函数。

钩子函数的执行,是通过调用 invokeDirectiveHook 方法完成的,我们来看它的实现:

function invokeDirectiveHook(vnode, prevVNode, instance, name) {
  const bindings = vnode.dirs
  const oldBindings = prevVNode && prevVNode.dirs
  for (let i = 0; i < bindings.length; i++) {
    const binding = bindings[i]
    if (oldBindings) {
      binding.oldValue = oldBindings[i].value
    }
    const hook = binding.dir[name]
    if (hook) {
      callWithAsyncErrorHandling(hook, instance, 8 /* DIRECTIVE_HOOK */, [
        vnode.el,
        binding,
        vnode,
        prevVNode
      ])
    }
  }
}

  1. invokeDirectiveHook 函数有四个参数,第一个和第二个参数分别代表新旧 vnode,第三个参数是组件实例 instance,第四个参数是钩子名称 name

  2. 找到dirs数组,遍历这个数组,这个数组里存放着该元素绑定的指定对象集合

  3. 根据name找到对应的钩子函数,并且执行。

  4. 除此之外在执行的时候,还传入了一些响应的参数,包括元素的 DOM 节点 elbinding 对象新旧 vnode,这就是我们在执行指令钩子函数的时候,可以访问到这些参数的原因。

另外我们注意到,mounted 钩子函数会用 queuePostRenderEffect 包一层执行,这么做和组件的初始化过程执行 mounted 钩子函数一样,在整个应用 render 完毕后,同步执行 flushPostFlushCbs 的时候执行元素指令的 mounted 钩子函数。

(2) 元素更新时: patchElement函数

一个元素的更新是通过执行 patchElement 函数,我们再来回顾一下它的实现:

const patchElement = (n1, n2, parentComponent, parentSuspense, isSVG, optimized) => {
  const el = (n2.el = n1.el)
  const oldProps = (n1 && n1.props) || EMPTY_OBJ
  const newProps = n2.props || EMPTY_OBJ
  const { dirs } = n2
  // 更新 props
  patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense, isSVG)
  const areChildrenSVG = isSVG && n2.type !== 'foreignObject'
    if (dirs) {
	invokeDirectiveHook(n2, n1, parentComponent, ‘beforeUpdate’)
    }
    // 更新子节点
    patchChildren(n1, n2, el, null, parentComponent, parentSuspense, areChildrenSVG)

    if (dirs) {
    	queuePostRenderEffect(() = >{
    		invokeDirectiveHook(vnode, null, parentComponent, ‘updated’)
    	})
    }
}

这一次,我们添加了元素指令调用的相关代码,可以直观地看到,在更新子节点之前会执行指令的beforeUpdate钩子函数,在更新完子节点之后,会通过 queuePostRenderEffect 的方式执行指令的 updated 钩子函数。

(3) 元素卸载时: unmount 函数

一个元素的卸载是通过执行 unmount 函数,我们再来回顾一下它的实现:

const unmount = (vnode, parentComponent, parentSuspense, doRemove = false) => {
  const { type, props, children, dynamicChildren, shapeFlag, patchFlag, dirs } = vnode
  let vnodeHook
  if ((vnodeHook = props && props.onVnodeBeforeUnmount)) {
    invokeVNodeHook(vnodeHook, parentComponent, vnode)
  }
  const shouldInvokeDirs = shapeFlag & 1 /* ELEMENT */ && dirs
  if (shapeFlag & 6 /* COMPONENT */) {
    unmountComponent(vnode.component, parentSuspense, doRemove)
  }
  else {
    if (shapeFlag & 128 /* SUSPENSE */) {
      vnode.suspense.unmount(parentSuspense, doRemove)
      return
    }
    if (shouldInvokeDirs) {
      invokeDirectiveHook(vnode, null, parentComponent, 'beforeUnmount')
    }
    // 卸载子节点
    if (dynamicChildren &&
      (type !== Fragment ||
        (patchFlag > 0 && patchFlag & 64 /* STABLE_FRAGMENT */))) {
      unmountChildren(dynamicChildren, parentComponent, parentSuspense)
    }
    else if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
      unmountChildren(children, parentComponent, parentSuspense)
    }
    if (shapeFlag & 64 /* TELEPORT */) {
      vnode.type.remove(vnode, internals)
    }
    // 移除 DOM 节点
    if (doRemove) {
      remove(vnode)
    }
  }
  if ((vnodeHook = props && props.onVnodeUnmounted) || shouldInvokeDirs) {
    queuePostRenderEffect(() => {
      vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
      if (shouldInvokeDirs) {
        invokeDirectiveHook(vnode, null, parentComponent, 'unmounted')
      }
    }, parentSuspense)
  }
}

unmount 方法的主要思路就是用递归的方式去遍历删除自身节点和子节点。

可以看到,在移除元素的子节点之前会执行指令的 beforeUnmount 钩子函数,在移除子节点和当前节点之后,会通过 queuePostRenderEffect 的方式执行指令的 unmounted 钩子函数。

总结

本篇文章探讨了指令的定义、注册和执行流程,我们明白了其实指令的原理就是在元素的不同时期去执行对应的钩子函数,实现起来是非常简单的。