为 vue 自定义指令对象封装工厂函数实现分离闭包,添加 bindingMounted 钩子

533 阅读4分钟

问题提出:

  • 问题一:在使用vue自定义指令实现 input 输入框按下 enter 键触发按钮点击功能时,我需要实现一个按键锁的功能,但是发现在多个 input 标签上使用同一个指令时,他们的会共用一个 data,我们希望在每一个地方使用这个指令时都能定义独立的 data 使用。
  • 问题二:vue自定义指令的 mounted 钩子指的是在绑定元素的父组件及他自己的所有子节点都挂载完成后调用,而不是我们印象中的全部组件挂载完成。这就导致了一个问题,我将 button 的 ref 作为指令绑定值,mounted触发时 button 还未挂载。
    后来发现这一关键步骤被加入到了 updated 钩子,我的意思是指令会深度监听绑定值,当 button 挂载完成时就会触发 updated 钩子函数。但是光使用 updated 钩子又导致一个问题,因为指令还会监听目前绑定的元素,例如当 input 内容改变时也会触发 updated,这肯定是我们不希望看到的!我们希望可以定义一个新的钩子,只当所有需要等待挂载的组件挂载完成则触发一次。

下面是一个指令对象的 demo,这里只是举个例子,不需要关注实际会不会产生问题:

// 这是最开始对自定义 data 的使用
import { type Directive } from 'vue'

export const enterButtons: Directive = (() => {
  const data = {
    locked: false
  }
  return {
    // 全局共享 data,会导致问题!
    updated(el: HTMLInputElement, binding) {
      // 将会多次触发,导致问题!
      el.onkeydown = (e) => {
        if (data.locked) return
        data.locked = true
        if (e.key === 'Enter') binding.value.ref.click()
      }
      el.onkeyup = () => {
        data.locked = false
      }
    }
  }
})()

解决方案:

  • 对于问题一:因为自定义对象的各个方法第一个参数都是 el 对象,可以根据 el 的不同分辨各处不同的调用
  • 对于问题二:添加 bindingMounted 钩子,然后再将其融合加入 updated 钩子函数

下面是我的 directives.ts 源码:

import { type ObjectDirective } from 'vue'

class AutoInitMap<K extends WeakKey, V> extends WeakMap {
  createDefaultValue: () => V
  constructor(createDefaultValue: () => V) {
    super()
    this.createDefaultValue = createDefaultValue
  }
  get(key: K): V {
    // 如果 get 时键值对不存在则自动设置默认值
    if (!super.has(key)) super.set(key, this.createDefaultValue())
    return super.get(key)
  }
}

const useSeparatedDirectives = <T>(
  storeSetup: () => T,
  callback: (
    store: T
  ) => ObjectDirective & { bindingMounted?: ObjectDirective['updated'] }
) => {
  const isBindingMountedMap = new AutoInitMap(() => false) // el -> 其挂载是否完成 (boolean)
  // middleObj 表示要返回的指令对象,
  // 实际上它在这里只是用来获取键,最后再将其钩子函数全部自动指向真实对象的钩子函数
  const middleObj = callback(storeSetup())
  let newCallback = callback // 指令对象的工厂函数
  const objMap = new AutoInitMap(() => newCallback(storeSetup()))

  // 如果定义了 bindingMounted 钩子函数,则要将其加入 updated 钩子函数
  if ('bindingMounted' in middleObj) {
    // 将 bindingMounted 内容与 updated 内容合并为新的 updated
    const newlUpdated = (
      oldBindingMounted: ObjectDirective['updated'],
      oldUpdated: ObjectDirective['updated'],
      ...args: Parameters<NonNullable<ObjectDirective['updated']>>
    ) => {
      const el = args[0] // 指令所在的元素
      const binding = args[1] // 指令绑定的内容
      // 如果挂载未完成
      if (!isBindingMountedMap.get(el)) {
        if (
          (() => {
            // 数组
            if (Array.isArray(binding.value)) {
              if (binding.value.every((ins) => ins)) return true
            } /* 对象 */ else if ('nodes' in binding.value) {
              if (binding.value.nodes) return true
            } /* 单个 */ else {
              if (binding.value) return true
            }
            return false
          })()
        ) {
          // 挂载完成标记并触发 bindingMounted 回调
          isBindingMountedMap.set(el, true)
          oldBindingMounted!(...args)
        }
      }
      // 如果定义了 updated,那么也要触发
      if (oldUpdated) oldUpdated(...args)
    }
    
    // 修正 middleObj 的 keys
    delete middleObj.bindingMounted
    middleObj.updated = () => {}
    // 更新 newCallback
    newCallback = (data) => {
      const res = callback(data)
      const oldBindingMounted = res.bindingMounted
      delete res.bindingMounted
      const oldUpdated = res.updated
      res.updated = (...args) =>
        newlUpdated(oldBindingMounted, oldUpdated, ...args)
      return res
    }
  }
  
  // 修改 middleObj 的钩子函数,使其自动转发到不同 el 对应的内部指令对象的对应钩子函数
  for (const key in middleObj) {
    ;(middleObj as any)[key] = (...args: any[]) => {
      ;(objMap.get(args[0]) as any)[key](...args)
    }
  }
  return middleObj
}

bindingMounted 钩子的实现就不细说了,因为我不会说🤯,这个只是实现起来比较麻烦,但是思路难点在于分离闭包

分离闭包大体思路如下: 在不同处使用的指令都会触发实际在 main.ts 中注册的 middleObj 的各个生命周期回调函数,在这些回调函数中可以根据传入的 el 不同,触发其单独的对应钩子函数 b326bbf697ad1175ea052d5ca60c1ab6.png

使用示例:

首先定义 enterButton 指令对象:

export const enterButton = useSeparatedDirectives(
  () => {
    const locked = false
    let cnt = 0
    const logCnt = () => console.log(cnt++)
    return {
      locked,
      logCnt
    }
  },
  (data) => {
    return {
      bindingMounted(el: HTMLInputElement, binding) {
        console.log('mouted: ', el, binding.value)
        el.addEventListener('keydown', (e) => {
          if (data.locked) return
          data.locked = true
          if (e.key === 'Enter') {
            e.preventDefault()
            binding.value.click()
            data.logCnt()
          }
        })
        el.addEventListener('keyup', () => (data.locked = false))
      }
    }
  }
)

然后在 main.ts 注册:

import { enterButton } from './utils/directives'

app.directive('enterButton', enterButton)

App.vue 测试:

<script setup lang="ts">
import { useTemplateRef } from 'vue'

const buttonRef1 = useTemplateRef('buttonRef1')
const buttonRef2 = useTemplateRef('buttonRef2')
</script>

<template>
  <input type="text" v-enterButton="buttonRef1" />
  <button @click="console.log('button1')" ref="buttonRef1"></button>
  <input type="text" v-enterButton="buttonRef2" />
  <button @click="console.log('button2')" ref="buttonRef2"></button>
</template>

<style scoped></style>

运行输出
首先挂载完成正确输出
mouted: <input type="text"> <button></button>
mouted: <input type="text"> <button></button>
分别在两个 input 输入框中按下两次 enter 键输出: button1
0
button1
1
button2
0
button2
1
可以看到他们的内部数据已经是正确分离的了

bindingMounted 支持的所有元素绑定方式

  • 单个: 如上面的实例,v-enterButton="buttonRef1"
  • 数组: [buttonRef1, buttonRef2]
  • 对象:{ nodes: buttonRef1, ... } 或 { nodes: [buttonRef1, buttonRef2], ... }

下载

pnpm install separated-directives