← 返回主页

第13课: 组合式函数与自定义指令

组合式函数(Composables)

组合式函数是利用Vue组合式API封装和复用有状态逻辑的函数。

useLocalStorage

// composables/useLocalStorage.js
import { ref, watch } from 'vue'

export function useLocalStorage(key, defaultValue) {
  const storedValue = localStorage.getItem(key)
  const data = ref(storedValue ? JSON.parse(storedValue) : defaultValue)

  watch(data, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue))
  }, { deep: true })

  return data
}
<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage'

const user = useLocalStorage('user', { name: '', age: 0 })
const theme = useLocalStorage('theme', 'light')
</script>

useDebounce

// composables/useDebounce.js
import { ref, watch } from 'vue'

export function useDebounce(value, delay = 300) {
  const debouncedValue = ref(value.value)
  let timeout

  watch(value, (newValue) => {
    clearTimeout(timeout)
    timeout = setTimeout(() => {
      debouncedValue.value = newValue
    }, delay)
  })

  return debouncedValue
}
<script setup>
import { ref } from 'vue'
import { useDebounce } from '@/composables/useDebounce'

const searchText = ref('')
const debouncedSearch = useDebounce(searchText, 500)

watch(debouncedSearch, (value) => {
  console.log('搜索:', value)
  // 执行搜索请求
})
</script>

useEventListener

// composables/useEventListener.js
import { onMounted, onUnmounted } from 'vue'

export function useEventListener(target, event, callback) {
  onMounted(() => {
    target.addEventListener(event, callback)
  })

  onUnmounted(() => {
    target.removeEventListener(event, callback)
  })
}
<script setup>
import { ref } from 'vue'
import { useEventListener } from '@/composables/useEventListener'

const x = ref(0)
const y = ref(0)

useEventListener(window, 'mousemove', (e) => {
  x.value = e.pageX
  y.value = e.pageY
})
</script>

useAsync

// composables/useAsync.js
import { ref } from 'vue'

export function useAsync(asyncFunction) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)

  async function execute(...args) {
    loading.value = true
    error.value = null

    try {
      data.value = await asyncFunction(...args)
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }

  return { data, error, loading, execute }
}
<script setup>
import { useAsync } from '@/composables/useAsync'

const fetchUsers = async () => {
  const response = await fetch('/api/users')
  return response.json()
}

const { data: users, error, loading, execute } = useAsync(fetchUsers)

// 执行请求
execute()
</script>

useIntersectionObserver

// composables/useIntersectionObserver.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useIntersectionObserver(target, options = {}) {
  const isVisible = ref(false)
  let observer

  onMounted(() => {
    observer = new IntersectionObserver(([entry]) => {
      isVisible.value = entry.isIntersecting
    }, options)

    if (target.value) {
      observer.observe(target.value)
    }
  })

  onUnmounted(() => {
    if (observer) {
      observer.disconnect()
    }
  })

  return isVisible
}
<template>
  <div ref="elementRef">
    {{ isVisible ? '可见' : '不可见' }}
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'

const elementRef = ref(null)
const isVisible = useIntersectionObserver(elementRef)
</script>

自定义指令基础

自定义指令用于直接操作DOM元素。

全局注册

// main.js
const app = createApp(App)

app.directive('focus', {
  mounted(el) {
    el.focus()
  }
})

app.mount('#app')

局部注册

<script setup>
const vFocus = {
  mounted: (el) => el.focus()
}
</script>

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

指令钩子

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) {}
}

指令参数

app.directive('color', {
  mounted(el, binding) {
    // binding.value: 指令的值
    // binding.arg: 指令的参数
    // binding.modifiers: 指令的修饰符对象

    el.style.color = binding.value
  }
})
<template>
  <!-- v-color="'red'" -->
  <p v-color="'red'">红色文本</p>

  <!-- v-color:background="'blue'" -->
  <p v-color:background="'blue'">蓝色背景</p>

  <!-- v-color.important="'green'" -->
  <p v-color.important="'green'">绿色重要文本</p>
</template>

实用自定义指令示例

v-loading加载指令

// directives/loading.js
export const vLoading = {
  mounted(el, binding) {
    if (binding.value) {
      el.classList.add('loading')
      el.style.position = 'relative'

      const spinner = document.createElement('div')
      spinner.className = 'spinner'
      spinner.innerHTML = '加载中...'
      el.appendChild(spinner)
    }
  },
  updated(el, binding) {
    if (binding.value) {
      if (!el.querySelector('.spinner')) {
        const spinner = document.createElement('div')
        spinner.className = 'spinner'
        spinner.innerHTML = '加载中...'
        el.appendChild(spinner)
      }
    } else {
      const spinner = el.querySelector('.spinner')
      if (spinner) {
        spinner.remove()
      }
    }
  }
}
<template>
  <div v-loading="isLoading">
    内容区域
  </div>
</template>

v-lazy图片懒加载

// directives/lazy.js
export const vLazy = {
  mounted(el, binding) {
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        el.src = binding.value
        observer.unobserve(el)
      }
    })

    observer.observe(el)
    el._observer = observer
  },
  unmounted(el) {
    if (el._observer) {
      el._observer.disconnect()
    }
  }
}
<template>
  <img v-lazy="imageUrl" alt="懒加载图片" />
</template>

v-permission权限指令

// directives/permission.js
export const vPermission = {
  mounted(el, binding) {
    const { value } = binding
    const userPermissions = ['read', 'write'] // 从store获取

    if (value && !userPermissions.includes(value)) {
      el.parentNode?.removeChild(el)
    }
  }
}
<template>
  <button v-permission="'admin'">删除</button>
  <button v-permission="'write'">编辑</button>
</template>

v-click-outside点击外部

// directives/clickOutside.js
export 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
  }
}
<template>
  <div v-click-outside="closeMenu">
    菜单内容
  </div>
</template>

<script setup>
function closeMenu() {
  console.log('点击了外部')
}
</script>

v-copy复制指令

// directives/copy.js
export const vCopy = {
  mounted(el, binding) {
    el.addEventListener('click', () => {
      const text = binding.value
      navigator.clipboard.writeText(text).then(() => {
        console.log('复制成功:', text)
      })
    })
  }
}
<template>
  <button v-copy="'要复制的文本'">点击复制</button>
</template>

组合式函数最佳实践

自定义指令最佳实践

实战示例:表单验证组合式函数

// composables/useForm.js
import { reactive, computed } from 'vue'

export function useForm(initialValues, rules) {
  const form = reactive({ ...initialValues })
  const errors = reactive({})

  function validate(field) {
    const rule = rules[field]
    if (!rule) return true

    const value = form[field]
    let error = ''

    if (rule.required && !value) {
      error = rule.message || '此字段必填'
    } else if (rule.min && value.length < rule.min) {
      error = `最少${rule.min}个字符`
    } else if (rule.pattern && !rule.pattern.test(value)) {
      error = rule.message || '格式不正确'
    }

    errors[field] = error
    return !error
  }

  function validateAll() {
    let isValid = true
    for (const field in rules) {
      if (!validate(field)) {
        isValid = false
      }
    }
    return isValid
  }

  const isValid = computed(() => {
    return Object.values(errors).every(error => !error)
  })

  return {
    form,
    errors,
    validate,
    validateAll,
    isValid
  }
}
<script setup>
import { useForm } from '@/composables/useForm'

const { form, errors, validate, validateAll, isValid } = useForm(
  { username: '', email: '', password: '' },
  {
    username: { required: true, min: 3 },
    email: {
      required: true,
      pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
      message: '邮箱格式不正确'
    },
    password: { required: true, min: 6 }
  }
)

function handleSubmit() {
  if (validateAll()) {
    console.log('提交表单', form)
  }
}
</script>

练习

  1. 创建一个useToggle组合式函数,实现布尔值切换
  2. 创建一个v-tooltip自定义指令,实现提示框功能
  3. 创建一个useWindowSize组合式函数,监听窗口大小变化
  4. 创建一个v-draggable自定义指令,实现元素拖拽