Skip to content

L16 · 自定义指令 + 主题系统 ​

🎯 本节目标:创建自定义指令(v-focus、v-permission、v-tooltip),实现明/暗主题切换
📦 本节产出:支持明暗主题的任务管理系统 + 可复用自定义指令库
🔗 前置钩子:L15 的完整功能集
🔗 后续钩子:L17 将为核心组件、Composable 和 Store 编写测试

1. 自定义指令 ​

1.1 什么时候用自定义指令 ​

经验法则: 同一段底层 DOM 行为需要在多个元素上复用时,可以考虑指令。单处聚焦用模板 ref 也很直接;能用模板表达的显隐优先用 v-if / v-show。

1.2 指令生命周期 ​

钩子可以读取元素、绑定信息和 VNode;prevVnode 只在更新钩子中可用。钩子的参数示意如下:

typescript
// 指令钩子签名
{
  mounted(el, binding, vnode, prevVnode) {
    // el: 指令绑定的 DOM 元素
    // binding.value: v-xxx="value" 中的 value
    // binding.arg: v-xxx:arg 中的 arg
    // binding.modifiers: v-xxx.mod 中的 { mod: true }
    // binding.oldValue: 更新前的值(仅 beforeUpdate / updated 中可用)
  }
}

2. 实战指令一:v-focus ​

typescript
// src/directives/vFocus.ts
import type { Directive } from 'vue'

export const vFocus: Directive<HTMLElement> = {
  mounted(el) {
    // 如果元素本身是 input/textarea,直接聚焦
    if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
      el.focus()
    } else {
      // 如果是容器元素,找到内部的第一个 input
      const input = el.querySelector<HTMLElement>('input, textarea')
      input?.focus()
    }
  }
}

下面两个是备选用法,页面中同时放置多个自动聚焦元素时,最后聚焦的元素会获得焦点。局部使用需导入指令(或完成 §6 的全局注册):

vue
<script setup lang="ts">
import { vFocus } from '@/directives/vFocus'
</script>

<template>
  <!-- 自动聚焦 -->
  <input v-focus placeholder="页面加载后自动获得焦点" />

  <!-- 也可以用在容器上 -->
  <div v-focus>
    <label>搜索</label>
    <input placeholder="这个 input 会被自动聚焦" />
  </div>
</template>

3. 实战指令二:v-permission ​

这里演示按角色显示按钮。把当前角色作为响应式绑定值传入,切换角色后 Vue 才能触发指令更新;直接读取 localStorage 不会建立响应式依赖。

typescript
// src/directives/vPermission.ts
import type { Directive } from 'vue'

interface PermissionBinding {
  required: string | string[]
  granted: string[]
}

function applyPermission(el: HTMLElement, value: PermissionBinding) {
  const required = Array.isArray(value.required) ? value.required : [value.required]
  el.hidden = !required.some(role => value.granted.includes(role))
}

export const vPermission: Directive<HTMLElement, PermissionBinding> = {
  mounted(el, binding) { applyPermission(el, binding.value) },
  updated(el, binding) { applyPermission(el, binding.value) },
}
vue
<script setup lang="ts">
import { ref } from 'vue'
import { vPermission } from '@/directives/vPermission'

// 仅用于演示;正式项目由登录状态提供角色
const roles = ref<string[]>(['editor'])
</script>

<template>
  <button v-permission="{ required: 'admin', granted: roles }" class="danger-btn">
    删除所有数据
  </button>
  <button v-permission="{ required: ['admin', 'editor'], granted: roles }">
    编辑任务
  </button>
</template>

本例由指令独占管理 hidden,不要在同一元素上再绑定 hidden 或用 CSS 覆盖它。删除 DOM 节点同样不能增加权限安全性,还会干扰 Vue 后续更新。

WARNING

前端权限控制 ≠ 安全! v-permission 只是 UI 层隐藏元素,用户可以通过 DevTools 恢复元素或直接调 API 绕过。所有敏感操作必须在后端验证权限,前端指令只是用户体验优化。


4. 实战指令三:v-click-outside ​

下拉菜单、弹窗的典型需求——点击外部区域时关闭。

typescript
// src/directives/vClickOutside.ts
import type { Directive } from 'vue'

type OutsideCallback = (event: MouseEvent) => void
const handlers = new WeakMap<HTMLElement, {
  callback: OutsideCallback
  listener: (event: MouseEvent) => void
}>()

export const vClickOutside: Directive<HTMLElement, OutsideCallback> = {
  mounted(el, binding) {
    const state = {
      callback: binding.value,
      listener(event: MouseEvent) {
        if (!event.composedPath().includes(el)) state.callback(event)
      },
    }
    handlers.set(el, state)
    document.addEventListener('click', state.listener, true)
  },
  updated(el, binding) {
    const state = handlers.get(el)
    if (state) state.callback = binding.value
  },
  unmounted(el) {
    const state = handlers.get(el)
    if (state) document.removeEventListener('click', state.listener, true)
    handlers.delete(el)
  },
}
vue
<script setup lang="ts">
import { ref } from 'vue'
import { vClickOutside } from '@/directives/vClickOutside'

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

<template>
  <div v-click-outside="closeDropdown" class="dropdown-wrapper">
    <button @click="isOpen = !isOpen">菜单 ▼</button>

    <div v-if="isOpen" class="dropdown-menu">
      <a href="#">选项 1</a>
      <a href="#">选项 2</a>
      <a href="#">选项 3</a>
    </div>
  </div>
</template>

5. 指令参数和修饰符 ​

指令绑定在包含触发按钮和菜单的容器上,点击按钮属于内部点击,不会在捕获阶段先关闭再被按钮重新打开。

Vue 指令支持参数(:arg)和修饰符(.modifier):

vue
<!-- v-directive:arg.modifier="value" -->
<div v-tooltip:top.delay="'这是提示'">悬停查看</div>

v-tooltip 实战 ​

本例只支持 top / bottom,用于按钮等可聚焦元素,支持鼠标悬停、键盘聚焦和 Escape 关闭。它不做屏幕边缘避让;复杂浮层应另行处理定位。

typescript
// src/directives/vTooltip.ts
import type { Directive } from 'vue'

interface TooltipState {
  update: (text: string, bottom: boolean, delayed: boolean) => void
  cleanup: () => void
}
const tooltips = new WeakMap<HTMLElement, TooltipState>()

export const vTooltip: Directive<HTMLElement, string> = {
  mounted(el, binding) {
    const tooltip = document.createElement('div')
    tooltip.id = `tooltip-${crypto.randomUUID()}`
    tooltip.setAttribute('role', 'tooltip')
    tooltip.hidden = true
    tooltip.style.cssText = `
      position: fixed; padding: 6px 12px;
      background: #333; color: #fff; font-size: 12px;
      border-radius: 6px; white-space: nowrap;
      pointer-events: none; z-index: 9999;
    `
    document.body.appendChild(tooltip)
    const originalDescription = el.getAttribute('aria-describedby')
    el.setAttribute('aria-describedby', [originalDescription, tooltip.id].filter(Boolean).join(' '))

    let bottom = binding.arg === 'bottom'
    let delayed = Boolean(binding.modifiers.delay)
    let timer: ReturnType<typeof setTimeout> | undefined
    tooltip.textContent = binding.value

    function positionTooltip() {
      const rect = el.getBoundingClientRect()
      tooltip.style.left = `${rect.left + rect.width / 2}px`
      tooltip.style.top = `${bottom ? rect.bottom + 8 : rect.top - 8}px`
      tooltip.style.transform = bottom ? 'translateX(-50%)' : 'translate(-50%, -100%)'
    }
    function show() {
      clearTimeout(timer)
      timer = setTimeout(() => {
        positionTooltip()
        tooltip.hidden = false
      }, delayed ? 500 : 0)
    }
    function hide() {
      clearTimeout(timer)
      tooltip.hidden = true
    }
    function onKeydown(event: KeyboardEvent) {
      if (event.key === 'Escape') hide()
    }

    el.addEventListener('mouseenter', show)
    el.addEventListener('mouseleave', hide)
    el.addEventListener('focus', show)
    el.addEventListener('blur', hide)
    el.addEventListener('keydown', onKeydown)
    window.addEventListener('scroll', hide, true)
    window.addEventListener('resize', hide)
    tooltips.set(el, {
      update(text, nextBottom, nextDelayed) {
        tooltip.textContent = text
        bottom = nextBottom
        delayed = nextDelayed
        if (!tooltip.hidden) positionTooltip()
      },
      cleanup() {
        hide()
        el.removeEventListener('mouseenter', show)
        el.removeEventListener('mouseleave', hide)
        el.removeEventListener('focus', show)
        el.removeEventListener('blur', hide)
        el.removeEventListener('keydown', onKeydown)
        window.removeEventListener('scroll', hide, true)
        window.removeEventListener('resize', hide)
        tooltip.remove()
        if (originalDescription === null) el.removeAttribute('aria-describedby')
        else el.setAttribute('aria-describedby', originalDescription)
      },
    })
  },
  updated(el, binding) {
    tooltips.get(el)?.update(binding.value, binding.arg === 'bottom', Boolean(binding.modifiers.delay))
  },
  unmounted(el) {
    tooltips.get(el)?.cleanup()
    tooltips.delete(el)
  },
}
vue
<script setup lang="ts">
import { vTooltip } from '@/directives/vTooltip'
</script>

<template>
  <!-- 基础用法 -->
  <button aria-label="保存更改" v-tooltip="'保存更改'">💾</button>

  <!-- 指定方向 -->
  <button aria-label="固定" v-tooltip:bottom="'向下弹出'">📌</button>

  <!-- 延迟显示 -->
  <button aria-label="计时" v-tooltip.delay="'悬停 500ms 后显示'">⏱️</button>

  <!-- 组合 -->
  <button aria-label="查看提示" v-tooltip:top.delay="'延迟 + 顶部'">🔮</button>
</template>

6. 全局注册 ​

以下注册语句加到 L11 的 main.ts 中,放在已有 app.mount() 之前,保留 Pinia、Router 和样式初始化;不要再创建第二个 app。

typescript
// src/main.ts
import { vFocus } from '@/directives/vFocus'
import { vPermission } from '@/directives/vPermission'
import { vClickOutside } from '@/directives/vClickOutside'
import { vTooltip } from '@/directives/vTooltip'

// 使用 main.ts 中已有的 app
// 全局注册后,所有组件中都可以直接使用
app.directive('focus', vFocus)
app.directive('permission', vPermission)
app.directive('click-outside', vClickOutside)
app.directive('tooltip', vTooltip)

全局注册 vs 局部导入:

方式写法适用场景
全局注册app.directive('focus', vFocus)高频使用的指令
局部导入import { vFocus } from '...'低频使用、按需加载

<script setup> 中局部导入命名规则: 变量名必须以 v 开头(如 vFocus),Vue 会自动识别为自定义指令。


7. 主题系统 ​

7.1 CSS 变量定义 ​

css
/* src/assets/themes.css */

/* 浅色主题(默认) */
:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f8f9fa;
  --bg-tertiary: #e9ecef;
  --text-primary: #2c3e50;
  --text-secondary: #6c757d;
  --text-muted: #6c757d;
  --border-color: #e0e0e0;
  --accent: #42b883;
  --accent-hover: #36a373;
  --danger: #e74c3c;
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
  --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.1);
}

/* 深色主题 */
[data-theme="dark"] {
  --bg-primary: #1a1a2e;
  --bg-secondary: #16213e;
  --bg-tertiary: #0f3460;
  --text-primary: #e0e0e0;
  --text-secondary: #a0a0a0;
  --text-muted: #9ca3af;
  --border-color: #2a2a4a;
  --accent: #42b883;
  --accent-hover: #5dd9a3;
  --danger: #ff6b6b;
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4);
}

/* 过渡动画:切换主题时平滑变化 */
body {
  background: var(--bg-secondary);
  color: var(--text-primary);
  transition: background 0.3s ease, color 0.3s ease;
}

7.2 Theme Composable ​

typescript
// src/composables/useTheme.ts
import { computed, inject, onMounted, onUnmounted, provide, ref, watchEffect } from 'vue'
import type { InjectionKey } from 'vue'
import { useLocalStorage } from './useLocalStorage'

export type Theme = 'light' | 'dark' | 'system'
const themeKey: InjectionKey<ReturnType<typeof createTheme>> = Symbol('app-theme')

function isTheme(value: unknown): value is Theme {
  return value === 'light' || value === 'dark' || value === 'system'
}

// 本课程是浏览器 SPA;由根组件调用一次,统一管理 DOM 和系统监听器
function createTheme() {
  const theme = useLocalStorage<Theme>('app-theme', 'system', isTheme)
  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
  const systemDark = ref(mediaQuery.matches)
  const resolvedTheme = computed<'light' | 'dark'>(() =>
    theme.value === 'system' ? (systemDark.value ? 'dark' : 'light') : theme.value
  )
  const isDark = computed(() => resolvedTheme.value === 'dark')

  function onSystemChange(event: MediaQueryListEvent) {
    systemDark.value = event.matches
  }
  onMounted(() => mediaQuery.addEventListener('change', onSystemChange))
  onUnmounted(() => mediaQuery.removeEventListener('change', onSystemChange))
  watchEffect(() => {
    document.documentElement.dataset.theme = resolvedTheme.value
    document.documentElement.style.colorScheme = resolvedTheme.value
  })

  function setTheme(value: Theme) { theme.value = value }
  function toggleTheme() { setTheme(isDark.value ? 'light' : 'dark') }
  return { theme, resolvedTheme, isDark, toggleTheme, setTheme }
}

export function provideTheme() {
  const context = createTheme()
  provide(themeKey, context)
  return context
}

export function useTheme() {
  const context = inject(themeKey)
  if (!context) throw new Error('请先在 App.vue 中调用 provideTheme()')
  return context
}

在 main.ts 导入 @/assets/themes.css。在 App.vue 的 <script setup> 中导入并调用 provideTheme(),再把下一节的 <ThemeToggle /> 放进导航栏。多个子组件调用 useTheme() 会拿到同一个上下文。L14 的简化 ThemeKey 示例和 L11 的演示 UI Store 此时无需同时启用。

matchMedia().matches 本身不是响应式数据,所以要在 change 事件中更新 systemDark;只重复读取一个已经缓存的 computed 无法跟随系统变化。

7.3 主题切换组件 ​

vue
<!-- src/components/ui/ThemeToggle.vue -->
<script setup lang="ts">
import { useTheme, type Theme } from '@/composables/useTheme'

const { isDark, theme, setTheme } = useTheme()
const options: { key: Theme; icon: string; label: string }[] = [
  { key: 'light', icon: '☀️', label: '浅色' },
  { key: 'dark', icon: '🌙', label: '深色' },
  { key: 'system', icon: '💻', label: '跟随系统' },
]
</script>

<template>
  <div class="theme-toggle">
    <!-- 简洁模式:点击切换 -->
    <button
      class="toggle-btn"
      @click="setTheme(isDark ? 'light' : 'dark')"
      :title="isDark ? '切换到浅色' : '切换到深色'"
    >
      <span class="icon">{{ isDark ? '🌙' : '☀️' }}</span>
    </button>

    <!-- 完整模式:三选一 -->
    <div class="theme-options">
      <button
        v-for="opt in options"
        :key="opt.key"
        :class="['theme-opt', { active: theme === opt.key }]"
        @click="setTheme(opt.key)"
        :aria-pressed="theme === opt.key"
      >
        {{ opt.icon }} {{ opt.label }}
      </button>
    </div>
  </div>
</template>

<style scoped>
.toggle-btn {
  background: var(--bg-tertiary);
  border: 1px solid var(--border-color);
  border-radius: 8px;
  padding: 8px 12px;
  cursor: pointer;
  font-size: 1.2rem;
  transition: background 0.2s;
}

.toggle-btn:hover {
  background: var(--accent);
  color: white;
}

.theme-options {
  display: flex;
  gap: 4px;
  background: var(--bg-tertiary);
  padding: 4px;
  border-radius: 10px;
}

.theme-opt {
  padding: 6px 14px;
  border: none;
  background: none;
  border-radius: 8px;
  cursor: pointer;
  font-size: 0.8rem;
  color: var(--text-secondary);
  transition: all 0.2s;
}

.theme-opt.active {
  background: var(--bg-primary);
  color: var(--text-primary);
  box-shadow: var(--shadow);
  font-weight: 600;
}
</style>

7.4 把现有页面接入主题 ​

定义变量后,L06、L09 和 L13 的硬编码颜色不会自动变化。下面是当前应用需要的增量样式:把每段追加到指定文件的原样式末尾,保留布局、尺寸和交互样式。尤其要同时替换卡片背景和文字,否则深色主题的浅色文字会叠在浅色背景上。

先在 src/assets/themes.css 末尾补充原生表单控件和导航颜色;确保 main.ts 在 main.css 之后导入 themes.css:

css
/* src/assets/themes.css:追加 */
input, select, textarea {
  background: var(--bg-primary);
  color: var(--text-primary);
  border-color: var(--border-color);
}
input::placeholder, textarea::placeholder { color: var(--text-muted); }
button { color: var(--text-primary); }
.app-nav .nav-link { color: var(--text-secondary); }
.app-nav .nav-link.router-link-exact-active { color: var(--accent); }

@media (prefers-reduced-motion: reduce) {
  body { transition: none; }
}

在 KanbanView.vue 的 <style scoped> 末尾追加。激活列的混色也必须使用当前主题背景,不能继续混入固定的浅灰色:

css
/* src/views/KanbanView.vue:追加到 style scoped */
.kanban-page { color: var(--text-primary); }
.kanban-column { background: var(--bg-tertiary); }
.kanban-column.is-active {
  background: color-mix(in srgb, var(--column-color) 12%, var(--bg-tertiary));
}
.kanban-card {
  background: var(--bg-primary);
  color: var(--text-primary);
  box-shadow: var(--shadow);
}
.kanban-card:hover { box-shadow: var(--shadow-lg); }
.kanban-subtitle, .card-category, .card-date { color: var(--text-secondary); }
.column-empty {
  color: var(--text-muted);
  border-color: var(--border-color);
}
.kanban-card.is-done { opacity: 1; }
.kanban-card.is-done .card-title { color: var(--text-secondary); }

在 TodoItem.vue 的原样式末尾追加,覆盖普通、完成、编辑与悬停状态:

css
/* src/components/todo/TodoItem.vue:追加到 style scoped */
.todo-item {
  background: var(--bg-primary);
  color: var(--text-primary);
  border-color: var(--border-color);
}
.todo-item:hover { box-shadow: var(--shadow-lg); }
.todo-text { color: var(--text-primary); }
.todo-date { color: var(--text-secondary); }
.todo-item.is-done { background: var(--bg-secondary); opacity: 1; }
.todo-item.is-done .todo-text { color: var(--text-secondary); }
.todo-item.is-editing { border-color: var(--accent); }
.edit-input {
  background: var(--bg-primary);
  color: var(--text-primary);
  border-color: var(--border-color);
}
.edit-input:focus { border-color: var(--accent); }
.toggle-btn:hover, .edit-btn:hover, .delete-btn:hover { background: var(--bg-tertiary); }

统计面板与筛选按钮也有浅色背景,分别追加以下覆盖:

css
/* src/components/todo/TodoStats.vue:追加到 style scoped */
.stats-panel { background: var(--bg-tertiary); }
.stat-value { color: var(--text-primary); }
.stat-label { color: var(--text-secondary); }
.progress-bar { background: var(--border-color); }
.progress-fill { background: var(--accent); }
css
/* src/components/todo/TodoFilter.vue:追加到 style scoped */
.filter-buttons { background: var(--bg-tertiary); }
.filter-btn { color: var(--text-secondary); }
.filter-btn.active {
  background: var(--bg-primary);
  color: var(--accent);
  box-shadow: var(--shadow);
}

输入框边框、拖拽列表空状态和标签管理器的选中色继续接入变量:

css
/* src/components/todo/TodoInput.vue:追加到 style scoped */
.todo-input { border-color: var(--border-color); }
.todo-input:focus { border-color: var(--accent); }
css
/* src/components/todo/DraggableTodoList.vue:追加到 style scoped */
.drag-handle, .empty-state { color: var(--text-secondary); }
css
/* src/components/todo/TagManager.vue:追加到 style scoped */
.color-dot.active { border-color: var(--text-primary); }

CategorySidebar、TagSelector 和 TagManager 的主要容器已经在 L12 使用带浅色回退值的变量,现可自动跟随。分类、标签和优先级仍保留各自成对设置的业务颜色;后续修改这些颜色时,要一起检查文字和底色。

完成后分别在明、暗主题检查任务文字、编辑输入、统计值、筛选按钮和看板各列,再切到“跟随系统”验证实时变化。刷新后还应恢复所选主题。


8. 指令 vs Composable 选型对照 ​

需求用指令用 Composable
自动聚焦 inputv-focus 可复用单处也可用模板 ref + onMounted
权限按钮显隐v-permission 可统一规则computed + v-if 通常更直接
点击外部关闭✅ v-click-outside⚠️ 需要 ref + 事件
鼠标位置❌✅ useMousePosition
本地存储❌✅ useLocalStorage
防抖输入✅ v-debounce✅ useDebouncedRef
图片懒加载指令可封装观察器简单图片先考虑原生 loading="lazy"
主题切换❌✅ useTheme

总结:需要直接操作 DOM 元素用指令,需要管理响应式状态用 Composable。


9. 本节总结 ​

检查清单 ​

  • [ ] 理解自定义指令的 7 个生命周期钩子
  • [ ] 能实现 v-focus、v-permission、v-click-outside、v-tooltip
  • [ ] 能使用指令参数(:arg)和修饰符(.modifier)
  • [ ] 能用 CSS 变量实现明/暗主题切换 + 系统跟随
  • [ ] 知道何时用指令 vs 何时用 Composable
  • [ ] 能全局注册和局部导入自定义指令

Git 提交 ​

bash
git add .
git commit -m "L16: 自定义指令库 + 明暗主题系统"

🔗 → 下一节 ​

L17 将为核心组件、Composable 和 Pinia Store 编写 Vitest 单元测试,建立核心行为的回归检查;指令清理和主题跟随可作为扩展测试。