前言
Vue3探秘系列文章链接:
不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)
不止响应式:Vue3探秘系列— diff算法的完整过程(三)
计算属性:Vue3探秘系列— computed的实现原理(六)
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>
当你点击按钮后,会先执行指令定义的 beforeMount
和 mounted
钩子函数,然后你在input
输入框中输入一些内容,会执行 beforeUpdate
和 updated
钩子函数,然后你再次点击按钮,会执行beforeUnmount
和 unmounted
钩子函数。
所以指令的本质其实就是在适当的时机去执行钩子函数中的逻辑。
二、指令的注册
我们在开头提到过,自定义指令支持全局注册和组件内局部注册,我们来探究下注册的实现原理,首先是全局注册:
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
}
-
validateDirectiveName
: 首先会对传入的指令名称进行校验,这里主要是校验注册的指令名与内置的指令名是否有冲突:'bind,cloak,else-if,else,for,html,if,model,on,once,pre,show,slot,text'
-
如果不传第二个参数指令对象,表示这是一次指令的获取;
-
指令全局注册方法的实现非常简单,就是把指令对象注册到
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
])
}
}
}
-
invokeDirectiveHook
函数有四个参数,第一个和第二个参数分别代表新旧vnode
,第三个参数是组件实例instance
,第四个参数是钩子名称name
。 -
找到
dirs
数组,遍历这个数组,这个数组里存放着该元素绑定的指定对象集合 -
根据
name
找到对应的钩子函数,并且执行。 -
除此之外在执行的时候,还传入了一些响应的参数,包括元素的
DOM 节点
el
,binding 对象
,新旧 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
钩子函数。
总结
本篇文章探讨了指令的定义、注册和执行流程,我们明白了其实指令的原理就是在元素的不同时期去执行对应的钩子函数,实现起来是非常简单的。