Skip to content

自定义指令

Vue 提供了一系列内置指令,用来帮助程序员高效地操作渲染模板结构。例如:v-modelv-bindv-onv-for 等。如果内置的指令无法满足我们对底层 DOM 的操作要求,Vue 还允许程序员自定义指令。

语法格式

每个自定义指令都是一个包含了指令生命周期函数对象。例如,下面的代码自定义了一个名为 v-focus 的指令,当被控制的 input 元素插入 DOM 中之后,会自动执行 mounted 这个指令生命周期函数:

vue
<script setup>
// 在模板中启用 v-focus
const vFocus = {
  // 注意:形参中的 el 是当前指令所绑定到的 DOM 元素
  mounted: (el) => el.focus()
}
</script>

<template>
  <input v-focus />
</template>

🚨 注意 🚨

自定义指令的名字是以 v 开头的小驼峰变量名。在模板中使用的时候需要改成连字符的形式。

全局注册自定义指令

在 SFC 中通过如下方式声明的自定义指令,仅限在当前组件中使用:

vue
<script setup>
const vXXX = {
  // ...指令的生命周期函数
}
</script>

如果某个自定义指令需要在多个 SFC 之间共享,则可以通过 app.directive() 将其注册为全局自定义指令。例如:

js
const app = createApp({})

// 使 v-focus 在所有组件中都可用
app.directive('focus', {
  mounted: (el) => el.focus()
})

🚨 注意 🚨

如果全局自定义指令和 SFC 中的自定义指令名字相同,根据就近原则,SFC 中的自定义指令会被应用。

自定义指令的生命周期函数

每个自定义指令,都可以按需声明和使用如下的指令生命周期函数

js
const myDirective = {
  // 在绑定元素的 attribute 前
  // 或事件监听器应用前调用
  created(el, binding, vnode) {
    // 下面会介绍各个参数的细节
  },
  // 在元素被插入到 DOM 前调用
  beforeMount(el, binding, vnode) {},
  // 挂载完成后调用
  mounted(el, binding, vnode) {},
  // 所属组件更新前调用
  beforeUpdate(el, binding, vnode, prevVnode) {},
  // 所属组件/所有子节点更新后调用
  updated(el, binding, vnode, prevVnode) {},
  // 元素卸载前调用
  beforeUnmount(el, binding, vnode) {},
  // 元素卸载后调用
  unmounted(el, binding, vnode) {}
}

我们发现,指令生命周期函数的名称,与组件的生命周期函数名称一模一样。这是 Vue3 为了减轻程序员的记忆负担有意为之。

首次挂载后/更新后自动获取焦点

当 input 首次挂载完成后,让 input 自动获取焦点;当 num 数值更新后,也需要让 input 自动获取焦点:

vue
<script setup>
import { ref } from 'vue'

const vFocus = {
  mounted: (el) => {
    el.focus()
  },
  updated(el) {
    el.focus()
  }
}

const num = ref(0)
</script>

<template>
  <input type="text" v-model="num" v-focus />
  <button @click="num++">+1</button>
</template>

简化形式

对于自定义指令来说,一个很常见的情况是仅仅需要在 mountedupdated 上实现相同的行为,除此之外并不需要其他钩子。这种情况下我们可以直接用一个函数来定义指令,如下所示:

vue
<script setup>
import { ref } from 'vue'

// 完整写法:
// const vFocus = {
//   mounted: (el) => {
//     el.focus()
//     console.log('mounted')
//   },
//   updated(el) {
//     console.log('updated')
//     el.focus()
//   }
// }

// 简化写法:
// 如果仅仅用到了 mounted 和 updated 钩子,
// 而且两个钩子中的代码逻辑相同,
// 则可以把对象简写为函数的形式,
// 这个函数会在 mounted 和 updated 期间被执行
const vFocus = (el) => {
  el.focus()
}

const num = ref(0)
</script>

<template>
  <input type="text" v-model="num" v-focus />
  <button @click="num++">+1</button>
</template>

钩子参数

自定义指令的生命周期函数可以至多接收 4 个参数,其中前两个参数最重要,它们分别是 elbinding

  • el 是当前指令所绑定到的 DOM 元素的实例;
  • binding 是当前指令绑定到元素上时,所携带的额外参数项,例如 v-model.lazy="info"

接下来我们重点学习第二个参数 binding 的基本使用。

binding.value

binding.value 是通过 = 传递给自定义指令的值。例如 v-color="'red'",这个字符串字面量 'red' 就是传递给指令的 value 值:

vue
<script setup>
const vColor = {
  // 元素首次挂载完成后,执行此函数
  mounted(el, binding) {
    el.style.color = binding.value
  }
}
</script>

<template>
  <h1 v-color="'red'">自定义指令</h1>
</template>

相应的,也可以传入响应式的数据作为 binding.value 的值,响应式数据的变化会触发所属组件的 rerender。当所属的组件更新渲染完毕后,会触发自定义指令的 updated 生命周期函数:

vue
<script setup>
import { ref } from 'vue'

const color = ref('#ff0000')

const vColor = {
  // 元素首次挂载完成后,执行此函数
  mounted(el, binding) {
    el.style.color = binding.value
  },
  // 元素所属的组件更新完成后,执行此函数
  updated(el, binding) {
    el.style.color = binding.value
  }
}
</script>

<template>
  <input type="color" v-model="color" />
  <h1 v-color="color">自定义指令</h1>
</template>

binding.oldValue

binding.oldValue 用来获取更新渲染前传入自定义指令的旧值。仅限在 beforeUpdateupdated 钩子中使用:

js
const vColor = {
  // 元素首次挂载完成后,执行此函数
  mounted(el, binding) {
    el.style.color = binding.value
  },
  beforeUpdate(el, binding) {
    console.log('---beforeUpdate---')
    console.log(binding.oldValue, binding.value)
  },
  // 元素所属的组件更新完成后,执行此函数
  updated(el, binding) {
    el.style.color = binding.value
    console.log('---updated---')
    console.log(binding.oldValue, binding.value)
  }
}

binding.modifiers

binding.modifiers 是一个对象,用来接收用户传入的修饰符。例如 v-color.foo.bar 指令,通过 binding.modifiers 得到的对象是:

js
{ foo: true, bar: true }

单个修饰符

vue
<script setup>
import { ref } from 'vue'
const color = ref('#ff0000')

const vColor = (el, binding) => {
  // 默认设置文字颜色
  let propName = 'color'
  if (binding.modifiers.bg) {
    // 如果提供了 .bg 修饰符,则设置背景颜色
    propName = 'backgroundColor'
  }
  el.style[propName] = binding.value
}
</script>

<template>
  <input type="color" v-model="color" />
  <!-- 如果没有指定 .bg 的修饰符,则设置文字颜色 -->
  <h1 v-color="color">自定义指令</h1>
  <!-- 如果指定了 .bg 修饰符,则设置背景颜色 -->
  <h3 v-color.bg="'cyan'">自定义指令</h3>
</template>

多个修饰符

我们还可以让 v-color 指令支持 darken 修饰符,用来把颜色值的亮度降低 30%,示例代码如下:

vue
<script setup>
import { ref } from 'vue'
const color = ref('#ff0000')

const vColor = (el, binding) => {
  let propName = 'color'
  if (binding.modifiers.bg) {
    propName = 'backgroundColor'
  }
  // 传入的颜色值
  let colorValue = binding.value
  // 如果提供了 .darken 修饰符,则把传入的颜色转为 hsl 模式(色相、饱和度、亮度),
  // 再把 l 亮度降低 30%
  if (binding.modifiers.darken) {
    colorValue = `hsl(from ${colorValue} h s calc(l - 30))`
  }
  el.style[propName] = colorValue
}
</script>

<template>
  <input type="color" v-model="color" />
  <!-- 如果没有指定 .bg 的修饰符,则设置文字颜色 -->
  <h1 v-color="color">自定义指令</h1>
  <!-- 如果指定了 .bg 修饰符,则设置背景颜色 -->
  <h3 v-color.bg.darken="'cyan'">自定义指令</h3>
</template>

binding.arg

binding.arg 用来接收传递给自定义指令的参数。例如 v-color:baz 中,参数 binding.arg 的值是 baz。其中参数名永远是 arg,参数值是 : 后面的内容 baz

vue
<template>
  <!-- 静态的 arg 参数 -->
  <h2 v-style:color="'red'">Hello Liu Longbin.</h2>
  <h3 v-style:background-color="'cyan'">Hello World.</h3>

  <hr />

  <!-- 动态的 arg 参数 -->
  <button @click="propName = 'color'">修改前景色</button>
  <button @click="propName = 'background-color'">修改背景色</button>

  <h2 v-style:[propName]="'red'">Hello Liu Longbin.</h2>
</template>

<script setup>
import { ref } from 'vue'
const propName = ref('color')

const vStyle = (el, binding) => {
  el.style = {}
  el.style[binding.arg] = binding.value
}
</script>

对象子面量

如果自定义指令需要传入多个值,我们可以传入对象/数组格式的“字面量”,或者传入一个返回对象/数组格式数据的“表达式”。

vue
<template>
  <h2 v-style="{ color: 'red', backgroundColor: 'cyan', textAlign: 'center' }">Hello</h2>
</template>

<script setup>
const vStyle = (el, binding) => {
  const obj = binding.value
  if (typeof obj === 'object') {
    for (let key in obj) {
      el.style[key] = obj[key]
    }
  }
}
</script>

封装 v-lazy 图片懒加载指令

🌹 温馨提示 🌹

请各位同学自己准备 3 张以上的大图(最好每张图片大于 3M),方便演示出图片懒加载的延迟效果。

把准备好的大图片,放到 /public 目录下。

封装 LazyImage 组件的基础布局

新建 LazyImage.vue 组件,并初始化如下的 SFC 组件结构:

vue
<script setup>
import { ref } from 'vue'
const imgList = ref(['1.jpg', '2.jpg', '3.jpg'])
</script>

<template>
  <div class="top-box"></div>

  <!-- 循环渲染图片列表 -->
  <img class="photo" v-for="item in imgList" :src="item" />
</template>

<style scoped>
.top-box {
  width: 100%;
  height: 1000px;
  background-color: green;
}

.photo {
  width: 100%;
  height: 220px;
  margin-top: 100px;
}
</style>

TIP

当前过 v-for 指令循环渲染的 <img /> 元素并没有开启懒加载的效果。 在网速较慢的情况下,图片很久才能被完全展示出来,导致页面上出现空白的情况。

渲染 1px 的透明图片和 Loading 的背景效果

为了实现懒加载的效果,当我们渲染图片时,默认为 src 属性指定一张仅有 1px 的透明图片,这样的好处是图片加载快而且透明,能够把 Loading 的背景效果展示出来。所以把资料中的 1x1.png 图片、loading.gif 图片和bad.jpg图片拷贝到 /src/assets/ 目录下,供后续使用。

改造 LazyImage.vue 组件如下:

vue
<script setup>
import { ref } from 'vue'
const imgList = ref(['image/1.jpg', 'image/2.jpg', 'image/3.jpg'])
</script>

<template>
  <div class="top-box"></div>

  <!-- 循环渲染图片列表 -->
  <!-- 1. 为 <img> 标签写死了 loading 类名和 src 指向的 1px 透明图片 -->
  <img class="photo loading" v-for="item in imgList" src="../assets/1x1.png" />
</template>

<style scoped>
.top-box {
  width: 100%;
  height: 1000px;
  background-color: green;
}

.photo {
  width: 100%;
  height: 220px;
  margin-top: 100px;
}

.loading {
  /* 2. 通过背景图展示 Loading 效果 */
  background: #fbfbfb url('../assets/loading.gif') no-repeat center;
  background-size: 140px;
}
</style>

基于自定义指令渲染 Loading 效果

自定义 v-lazy 指令,当元素将要被插入 DOM 时,在指令的 beforeMount 生命周期函数中,以编程的方式设置 <img> 元素的 class 类名和 src 图片地址:

vue
<script setup>
import { ref } from 'vue'
// 1. 导入默认要渲染的 1px 的透明图片
import defaultImg from '../assets/1x1.png'
const imgList = ref(['image/1.jpg', 'image/2.jpg', 'image/3.jpg'])

// 2. 定义 vLazy 指令
const vLazy = {
  // 绑定了 v-lazy 的 img 图片,
  // 在将要被插入 DOM 前,为其添加 loading 类名并设置默认图片
  beforeMount(el) {
    el.classList.add('loading')
    el.src = defaultImg
  }
}
</script>

<template>
  <div class="top-box"></div>

  <!-- 3. 使用 v-lazy 指令 -->
  <img class="photo" v-for="item in imgList" v-lazy />
</template>

<style scoped>
.top-box {
  width: 100%;
  height: 1000px;
  background-color: green;
}

.photo {
  width: 100%;
  height: 220px;
  margin-top: 100px;
}

.loading {
  background: #fbfbfb url('../assets/loading.gif') no-repeat center;
  background-size: 140px;
}
</style>

交叉观察器 API

交叉观察器 API(Intersection Observer API)提供了一种异步检测目标元素祖先元素或顶级文档的视口相交情况变化的方法。调用 IntersectionObserver 构造函数,创建交叉观测器:

js
const observer = new IntersectionObserver(cb, options)

其中:

  • cb 是一个回调函数,当检测到元素视口的相交情况变化时,就会触发 cb 回调函数的执行;

  • options 是一个可选参数,如果没有指定则表示元素浏览器视口相交 >=1px 时,立即执行 cb 回调;

示例代码如下:

vue
<script setup>
import { ref } from 'vue'
// 导入默认要渲染的 1px 的透明图片
import defaultImg from '../assets/1x1.png'
const imgList = ref(['image/1.jpg', 'image/2.jpg', 'image/3.jpg'])

// 1. 创建交叉观察对象
const observer = new IntersectionObserver(
  (entries) => {
    // entries 是一个数组,存放着元素和视口相交的状态信息。
    // 当判断到元素和视口相交时,有几个元素满足相交条件,
    // entries 中就有几个相交的状态信息
    entries.forEach((item) => {
      // 判断元素是否和视口相交
      if (item.isIntersecting) {
        // 如果满足相交的条件了,就让元素的背景变成红色
        item.target.style.backgroundColor = 'red'
        // 3. 相交1次之后,不再监听当前元素是否和视口相交
        observer.unobserve(item.target)
      }
    })
  },
  // 交叉的阈值:
  // 1 表示元素完整显示到视口中,才算相交
  // 0.5 表示至少元素的一半在视口中可见,才算相交
  // 0 表示元素只要有1个像素在视口中可见,就算相交
  { threshold: 0 }
)

// 定义 vLazy 指令
const vLazy = {
  // 绑定了 v-lazy 的 img 图片,
  // 在将要被插入 DOM 前,为其添加 loading 类名并设置默认图片
  beforeMount(el) {
    el.classList.add('loading')
    el.src = defaultImg
    // 2. 调用 .observe(元素) 方法,检测元素和视口是否相交
    observer.observe(el)
  }
}
</script>

<template>
  <div class="top-box"></div>

  <!-- 使用 v-lazy 指令 -->
  <img class="photo" v-for="item in imgList" v-lazy />
</template>

<style scoped>
.top-box {
  width: 100%;
  height: 1000px;
  background-color: green;
}

.photo {
  width: 100%;
  height: 220px;
  margin-top: 100px;
}

.loading {
  background: #fbfbfb url('../assets/loading.gif') no-repeat center;
  background-size: 140px;
}
</style>

相交后为 src 赋值真实的图片路径

通过 v-lazy="图片地址" 把真实的图片路径以 binding.value 的形式传入自定义指令:

html
<img class="photo" v-for="item in imgList" v-lazy="item" />

在自定义指令的 beforeMount 钩子函数中,把接收到的真实路径挂载为 el 元素的 dataset 数据:

js
// 定义 vLazy 指令
const vLazy = {
  beforeMount(el, binding) {
    el.classList.add('loading')
    el.src = defaultImg
    // 把真实路径挂载到元素的 dataset 中
    el.dataset.src = binding.value
    observer.observe(el)
  }
}

检测到相交之后,把 dataset.src 取出并设置为元素的 src 属性值:

js
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((item) => {
      if (item.isIntersecting) {
        // 把 dataset.src 的值赋值给图片元素的 src 属性值
        item.target.src = item.target.dataset.src
        // 移除元素身上的 loading 类名
        observer.unobserve(item.target)
      }
    })
  },
  { threshold: 0 }
)

懒加载成功 Or 加载失败

图片懒加载成功

我们可以监听图片的 load 事件,当图片加载成功后会触发此事件。在事件处理函数中,我们需要移除 loading 的类名和 data-src 的自定义属性:

js
// 创建交叉观察对象
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((item) => {
      // 判断元素是否和视口相交
      if (item.isIntersecting) {
        item.target.src = item.target.dataset.src
        observer.unobserve(item.target)

        // 图片加载成功后,移除 loading 类名和 data-src 属性
        item.target.addEventListener('load', () => {
          item.target.classList.remove('loading')
          item.target.removeAttribute('data-src')
        })
      }
    })
  },
  { threshold: 0 }
)

图片懒加载失败

当图片懒加载失败后,会触发 error 事件处理函数。我们需要监听懒加载图片的 error 事件,在事件回调中设置 src 指向一张1px 的透明的默认图片,并为图片元素添加 lazy-error 的 class 类名,通过此类名设置懒加载失败的背景图:

js
// 创建交叉观察对象
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((item) => {
      if (item.isIntersecting) {
        item.target.src = item.target.dataset.src
        observer.unobserve(item.target)

        item.target.addEventListener('load', () => {
          item.target.classList.remove('loading')
          item.target.removeAttribute('data-src')
        })

        // 监听图片加载 error 的事件
        item.target.addEventListener('error', () => {
          item.target.src = defaultImg
          item.target.classList.remove('loading')
          item.target.classList.add('lazy-error')
        })
      }
    })
  },
  { threshold: 0 }
)

声明 lazy-error 类名如下:

css
.lazy-error {
  background: #fbfbfb url('../assets/bad.jpg') no-repeat center;
  background-size: 30px;
}

断网情况下的图片懒加载优化

在导入 1x1.pngloading.gifbad.jpg 时,可以提供 ?inline 的后缀,明确告诉 vite 导入的是内联的图片资源。这时候 vite 会把这三个图片转为 base64 格式进行导入,而非导入文件的 URL 路径。

base64 格式的图片的优势是:跟随网页一同被下载下来,当断网情况下图片加载失败的时候,能够保证这 3 张图片正常可用。从而正常显示图片懒加载失败的页面效果。

天不生夫子,万古长如夜