【Vue.js 3.0源码】指令完整的生命周期

94 阅读4分钟

自我介绍:大家好,我是吉帅振的网络日志(其他平台账号名字相同);前端开发工程师,工作4年,去过上海、北京,经历创业公司,进过大厂,现在郑州敲代码。

一、前言

Vue.js 的核心思想之一是数据驱动,数据是 DOM 的映射。在大部分情况下,不用操作 DOM 的,但是这并不意味着你不能操作 DOM。有些时候,我们希望手动去操作某个元素节点的 DOM,比如当这个元素节点挂载到页面的时候通过操作底层的 DOM 来做一些事情。为了支持这个需求,Vue.js提供了指令的功能,它允许我们自定义指令,作用在普通的 DOM 元素上。举个聚焦输入框的例子,我们希望在页面加载时,输入框自动获得焦点,我们可以全局注册一个 v-focus 指令:

import Vue from 'vue'
const app = Vue.createApp({})

// 注册全局 v-focus 指令
app.directive('focus', {
  // 挂载的钩子函数
  mounted(el) {
    el.focus()
  }
})

//当然,我们也可以在组件内部局部注册:
directives: {
  focus: {
    mounted(el) {
      el.focus()
    }
  }
}

二、指令的定义

指令本质上就是一个 JavaScript 对象,对象上挂着一些钩子函数,我们可以举个例子来说明,比如我定义一个 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>

点击按钮后,会先执行指令定义的 beforeMount 和 mounted 钩子函数,然后在 input 输入框中输入一些内容,会执行 beforeUpdate 和 updated 钩子函数,再次点击按钮,会执行 beforeUnmount 和 unmounted 钩子函数。所以一个指令的定义,无非就是在合适的钩子函数中编写一些相关的处理逻辑。

三、指令的注册

所谓注册,其实就是把指令的定义保存到相应的地方,未来使用的时候我可以从保存的地方拿到它。指令的注册和组件一样,可以全局注册,也可以局部注册。我们来分别看一下它们的实现原理。我们来了解全局注册的方式,它是通过 app.directive 方法去注册的,比如:

app.directive('focus', {
  // 挂载的钩子函数
  mounted(el) {
    el.focus()
  }
})

我们来看 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
}

directive 是 app 对象上的一个方法,它接受两个参数,第一个参数是指令的名称,第二个参数就是指令对象。指令全局注册方法的实现非常简单,就是把指令对象注册到 app 对象创建的全局上下文 context.directives 中,并用 name 作为 key。这里有几个细节要注意一下,validateDirectiveName 是用来检测指令名是否和内置的指令(如 v-model、v-show)冲突;如果不传第二个参数指令对象,表示这是一次指令的获取;指令重复注册会报警告。接下来,来了解局部注册的方式,它是直接在组件对象中定义的,比如:

directives: {
  focus: {
    mounted(el) {
      el.focus()
    }
  }
}

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

四、指令的应用

指令的应用过程,我们以 v-focus 指令为例,在组件中使用这个指令:<input v-focus />。先看这个模板编译后生成的 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]
  ])
}

再来看看如果不使用 v-focus,单个 input 编译生成后的 render 函数是怎样的:

import { createVNode as _createVNode, openBlock as _openBlock, createBlock as _createBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createBlock("input"))
}

对比两个编译结果可以看到,区别在于如果元素节点使用指令,那么它编译生成的 vnode 会用 withDirectives 包装一层。在分析 withDirectives 函数的实现之前先来看指令的解析函数 resolveDirective,因为前面我们已经了解指令的注册其实就是把定义的指令对象保存下来,那么 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 首字母大写后继续匹配,这么做是为了让用户编写指令名称的时候可以更加灵活,所以需要多判断几步用户可能编写的指令名称的情况。

五、总结

了解指令是如何定义、如何注册,以及如何应用的。指令就是给我们提供了在一个元素的生命周期中注入代码的途径,它的本身实现很简单。

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿