Vue 自訂指令

除了 Vue 內建的指令(如 v-modelv-show),Vue 也允許你建立自訂指令(Custom Directives)。自訂指令主要用於需要對 DOM 元素進行底層操作的場景。

基本用法

<script setup> 中定義

v 開頭的駝峰命名變數會自動成為指令:

<script setup>
// 自動聚焦指令
const vFocus = {
  mounted: (el) => el.focus()
}
</script>

<template>
  <!-- 使用時用 kebab-case -->
  <input v-focus>
</template>

全域註冊

// main.js
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// 全域註冊指令
app.directive('focus', {
  mounted: (el) => el.focus()
})

app.mount('#app')

指令 Hooks

指令可以定義多個生命週期 hooks:

const myDirective = {
  // 元素被插入 DOM 前呼叫
  created(el, binding, vnode, prevVnode) {},

  // 元素被插入 DOM 時呼叫
  mounted(el, binding, vnode, prevVnode) {},

  // 父元件更新前呼叫
  beforeUpdate(el, binding, vnode, prevVnode) {},

  // 父元件及子元件都更新後呼叫
  updated(el, binding, vnode, prevVnode) {},

  // 元素被移除前呼叫
  beforeUnmount(el, binding, vnode, prevVnode) {},

  // 元素被移除時呼叫
  unmounted(el, binding, vnode, prevVnode) {}
}

Hook 參數

  • el:指令綁定的 DOM 元素
  • binding:包含指令資訊的物件
    • value:指令的值,如 v-my-directive="1 + 1" 中的 2
    • oldValue:之前的值(只在 beforeUpdateupdated 中可用)
    • arg:指令的參數,如 v-my-directive:foo 中的 "foo"
    • modifiers:修飾符物件,如 v-my-directive.foo.bar 中的 { foo: true, bar: true }
    • instance:使用該指令的元件實例
    • dir:指令的定義物件
  • vnode:代表綁定元素的 VNode
  • prevVnode:之前的 VNode(只在 beforeUpdateupdated 中可用)

簡寫形式

如果指令只需要在 mountedupdated 時執行相同邏輯,可以簡寫為函式:

<script setup>
const vColor = (el, binding) => {
  el.style.color = binding.value
}
</script>

<template>
  <p v-color="'red'">紅色文字</p>
  <p v-color="textColor">動態顏色</p>
</template>

指令參數和修飾符

<script setup>
const vDemo = {
  mounted(el, binding) {
    console.log('值:', binding.value)          // 表達式的值
    console.log('參數:', binding.arg)          // 冒號後的參數
    console.log('修飾符:', binding.modifiers)  // 點後的修飾符
  }
}
</script>

<template>
  <!-- v-demo:參數.修飾符="值" -->
  <div v-demo:foo.bar.baz="123">Test</div>
  <!--
    值: 123
    參數: 'foo'
    修飾符: { bar: true, baz: true }
  -->
</template>

動態參數

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

const direction = ref('left')

const vPin = {
  mounted(el, binding) {
    el.style.position = 'fixed'
    el.style[binding.arg || 'top'] = binding.value + 'px'
  },
  updated(el, binding) {
    el.style[binding.arg || 'top'] = binding.value + 'px'
  }
}
</script>

<template>
  <!-- 動態參數 -->
  <div v-pin:[direction]="200">固定在左邊 200px</div>
</template>

實用範例

點擊外部關閉

<script setup>
const vClickOutside = {
  mounted(el, binding) {
    el._clickOutside = (event) => {
      if (!el.contains(event.target)) {
        binding.value(event)
      }
    }
    document.addEventListener('click', el._clickOutside)
  },
  unmounted(el) {
    document.removeEventListener('click', el._clickOutside)
    delete el._clickOutside
  }
}
</script>

<template>
  <div v-click-outside="closeDropdown" class="dropdown">
    <button @click="isOpen = !isOpen">選單</button>
    <ul v-show="isOpen">
      <li>選項 1</li>
      <li>選項 2</li>
    </ul>
  </div>
</template>

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

const isOpen = ref(false)

function closeDropdown() {
  isOpen.value = false
}
</script>

工具提示(Tooltip)

<script setup>
const vTooltip = {
  mounted(el, binding) {
    const tooltip = document.createElement('div')
    tooltip.className = 'tooltip'
    tooltip.textContent = binding.value
    tooltip.style.cssText = `
      position: absolute;
      background: #333;
      color: white;
      padding: 5px 10px;
      border-radius: 4px;
      font-size: 12px;
      display: none;
      z-index: 1000;
    `
    document.body.appendChild(tooltip)

    el._tooltip = tooltip
    el._showTooltip = () => {
      const rect = el.getBoundingClientRect()
      tooltip.style.display = 'block'
      tooltip.style.top = rect.top - tooltip.offsetHeight - 5 + 'px'
      tooltip.style.left = rect.left + (rect.width - tooltip.offsetWidth) / 2 + 'px'
    }
    el._hideTooltip = () => {
      tooltip.style.display = 'none'
    }

    el.addEventListener('mouseenter', el._showTooltip)
    el.addEventListener('mouseleave', el._hideTooltip)
  },
  updated(el, binding) {
    if (el._tooltip) {
      el._tooltip.textContent = binding.value
    }
  },
  unmounted(el) {
    el.removeEventListener('mouseenter', el._showTooltip)
    el.removeEventListener('mouseleave', el._hideTooltip)
    if (el._tooltip) {
      el._tooltip.remove()
    }
  }
}
</script>

<template>
  <button v-tooltip="'點擊送出'">送出</button>
</template>

圖片懶載入

<script setup>
const vLazyLoad = {
  mounted(el, binding) {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            el.src = binding.value
            observer.unobserve(el)
          }
        })
      },
      { rootMargin: '50px' }
    )

    // 設定佔位圖
    el.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'

    el._observer = observer
    observer.observe(el)
  },
  unmounted(el) {
    if (el._observer) {
      el._observer.disconnect()
    }
  }
}
</script>

<template>
  <img v-lazy-load="'/path/to/image.jpg'" alt="懶載入圖片">
</template>

防抖(Debounce)

<script setup>
const vDebounce = {
  mounted(el, binding) {
    let timer = null
    const delay = binding.arg ? parseInt(binding.arg) : 300

    el._debounce = (event) => {
      clearTimeout(timer)
      timer = setTimeout(() => {
        binding.value(event)
      }, delay)
    }

    el.addEventListener('input', el._debounce)
  },
  unmounted(el) {
    el.removeEventListener('input', el._debounce)
  }
}
</script>

<template>
  <!-- 500ms 防抖 -->
  <input v-debounce:500="handleSearch" placeholder="搜尋...">
</template>

<script setup>
function handleSearch(event) {
  console.log('搜尋:', event.target.value)
}
</script>

權限控制

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

// 假設這是從 store 或 API 取得的使用者權限
const userPermissions = ref(['read', 'write'])

const vPermission = {
  mounted(el, binding) {
    const permission = binding.value
    const hasPermission = userPermissions.value.includes(permission)

    if (!hasPermission) {
      // 移除元素或顯示提示
      el.style.display = 'none'
      // 或者 el.remove()
    }
  }
}
</script>

<template>
  <button v-permission="'read'">檢視</button>
  <button v-permission="'write'">編輯</button>
  <button v-permission="'admin'">刪除</button> <!-- 會被隱藏 -->
</template>

複製到剪貼簿

<script setup>
const vCopy = {
  mounted(el, binding) {
    el._copy = async () => {
      try {
        await navigator.clipboard.writeText(binding.value)

        // 顯示成功提示
        el.classList.add('copied')
        setTimeout(() => el.classList.remove('copied'), 1500)
      } catch (err) {
        console.error('複製失敗:', err)
      }
    }

    el.addEventListener('click', el._copy)
    el.style.cursor = 'pointer'
  },
  updated(el, binding) {
    // 更新要複製的值
  },
  unmounted(el) {
    el.removeEventListener('click', el._copy)
  }
}
</script>

<template>
  <code v-copy="codeSnippet">{{ codeSnippet }}</code>
</template>

<style>
code.copied::after {
  content: ' ✓ 已複製';
  color: green;
}
</style>

拖曳

<script setup>
const vDraggable = {
  mounted(el) {
    el.style.position = 'absolute'
    el.style.cursor = 'move'

    let startX, startY, initialX, initialY

    function onMouseDown(e) {
      startX = e.clientX
      startY = e.clientY
      initialX = el.offsetLeft
      initialY = el.offsetTop

      document.addEventListener('mousemove', onMouseMove)
      document.addEventListener('mouseup', onMouseUp)
    }

    function onMouseMove(e) {
      const dx = e.clientX - startX
      const dy = e.clientY - startY

      el.style.left = initialX + dx + 'px'
      el.style.top = initialY + dy + 'px'
    }

    function onMouseUp() {
      document.removeEventListener('mousemove', onMouseMove)
      document.removeEventListener('mouseup', onMouseUp)
    }

    el.addEventListener('mousedown', onMouseDown)
    el._cleanup = () => {
      el.removeEventListener('mousedown', onMouseDown)
    }
  },
  unmounted(el) {
    if (el._cleanup) el._cleanup()
  }
}
</script>

<template>
  <div v-draggable class="draggable-box">
    拖曳我
  </div>
</template>

<style>
.draggable-box {
  padding: 20px;
  background: #42b883;
  color: white;
  border-radius: 8px;
}
</style>

在元件上使用

指令也可以用在元件上,會作用於元件的根元素:

<template>
  <!-- 指令會作用於 MyComponent 的根元素 -->
  <MyComponent v-focus />
</template>
如果元件有多個根元素,指令會被忽略並發出警告。不建議在元件上使用指令。

最佳實踐

  1. 不要過度使用:大多數情況下,使用元件或 composables 更好
  2. 適合底層 DOM 操作:如聚焦、事件監聽、第三方函式庫整合
  3. 記得清理:在 unmounted 中移除事件監聽器和定時器
  4. 保持簡單:複雜邏輯應該放在元件或 composables 中