Vue 高阶技巧实战

一、组合式函数(Composables)

1.1 异步状态管理

// composables/useAsync.ts
import { ref, computed } from 'vue';

interface AsyncState<T> {
  data: T | null;
  error: Error | null;
  loading: boolean;
}

export function useAsync<T>(
  asyncFn: () => Promise<T>,
  immediate = true
) {
  const data = ref<T | null>(null);
  const error = ref<Error | null>(null);
  const loading = ref(false);

  const execute = async () => {
    loading.value = true;
    error.value = null;
    try {
      data.value = await asyncFn();
    } catch (e) {
      error.value = e as Error;
    } finally {
      loading.value = false;
    }
  };

  const refresh = () => execute();

  if (immediate) execute();

  return {
    data: computed(() => data.value),
    error: computed(() => error.value),
    loading: computed(() => loading.value),
    execute,
    refresh
  };
}

// 使用
const { data: user, loading, error, refresh } = useAsync(
  () => fetch('/api/user').then(r => r.json())
);

1.2 自动保存表单

// composables/useAutoSave.ts
import { ref, watch, onUnmounted } from 'vue';
import { debounce } from 'lodash-es';

export function useAutoSave<T>(
  source: () => T,
  saveFn: (data: T) => Promise<void>,
  delay = 3000
) {
  const isSaving = ref(false);
  const lastSaved = ref<Date | null>(null);
  const hasUnsavedChanges = ref(false);

  const debouncedSave = debounce(async () => {
    isSaving.value = true;
    hasUnsavedChanges.value = false;
    try {
      await saveFn(source());
      lastSaved.value = new Date();
    } finally {
      isSaving.value = false;
    }
  }, delay);

  const stop = watch(source, () => {
    hasUnsavedChanges.value = true;
    debouncedSave();
  }, { deep: true });

  const saveNow = async () => {
    debouncedSave.flush();
  };

  onUnmounted(() => {
    stop();
    debouncedSave.flush();
  });

  return { isSaving, lastSaved, hasUnsavedChanges, saveNow };
}

// 使用
const form = ref({ title: '', content: '' });
const { isSaving, hasUnsavedChanges } = useAutoSave(
  () => form.value,
  (data) => api.saveDraft(data)
);

1.3 无限滚动

// composables/useInfiniteScroll.ts
import { ref, onMounted, onUnmounted } from 'vue';

export function useInfiniteScroll(
  loadMore: () => Promise<boolean>,
  options: { threshold?: number; immediate?: boolean } = {}
) {
  const { threshold = 100, immediate = true } = options;
  const containerRef = ref<HTMLElement | null>(null);
  const loading = ref(false);
  const finished = ref(false);
  const error = ref<Error | null>(null);

  const check = async () => {
    if (loading.value || finished.value) return;

    const el = containerRef.value;
    if (!el) return;

    const { scrollTop, scrollHeight, clientHeight } = el;
    if (scrollHeight - scrollTop - clientHeight > threshold) return;

    loading.value = true;
    error.value = null;

    try {
      const hasMore = await loadMore();
      if (!hasMore) finished.value = true;
    } catch (e) {
      error.value = e as Error;
    } finally {
      loading.value = false;
    }
  };

  const onScroll = () => check();

  onMounted(() => {
    containerRef.value?.addEventListener('scroll', onScroll);
    if (immediate) check();
  });

  onUnmounted(() => {
    containerRef.value?.removeEventListener('scroll', onScroll);
  });

  return { containerRef, loading, finished, error, check };
}

// 使用
const items = ref<Item[]>([]);
const page = ref(1);

const { containerRef, loading, finished } = useInfiniteScroll(
  async () => {
    const res = await api.getList(page.value);
    items.value.push(...res.data);
    page.value++;
    return res.hasMore;
  }
);

二、渲染函数与 JSX

2.1 动态表格列

// components/DynamicTable.tsx
import { defineComponent, PropType } from 'vue';

interface Column<T> {
  key: string;
  title: string;
  width?: number;
  render?: (row: T) => JSX.Element;
}

export default defineComponent(<T extends Record<string, any>>(props: {
  data: T[];
  columns: Column<T>[];
}) => {
  return () => (
    <table class="dynamic-table">
      <thead>
        <tr>
          {props.columns.map(col => (
            <th key={col.key} style={{ width: col.width + 'px' }}>
              {col.title}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {props.data.map((row, idx) => (
          <tr key={idx}>
            {props.columns.map(col => (
              <td key={col.key}>
                {col.render ? col.render(row) : row[col.key]}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
});

// 使用
<DynamicTable
  data={users}
  columns={[
    { key: 'name', title: '姓名' },
    { key: 'status', title: '状态', render: (row) => (
      <span class={row.status === 1 ? 'tag-success' : 'tag-error'}>
        {row.status === 1 ? '启用' : '禁用'}
      </span>
    )},
    { key: 'action', title: '操作', render: (row) => (
      <button onClick={() => handleEdit(row)}>编辑</button>
    )}
  ]}
/>

2.2 递归菜单组件

// components/RecursiveMenu.tsx
import { defineComponent, PropType } from 'vue';

interface MenuItem {
  id: string;
  label: string;
  icon?: string;
  children?: MenuItem[];
}

const MenuItemComponent = defineComponent({
  props: {
    item: { type: Object as PropType<MenuItem>, required: true },
    level: { type: Number, default: 0 }
  },
  setup(props) {
    const expanded = ref(false);
    const hasChildren = computed(() =>
      props.item.children && props.item.children.length > 0
    );

    return () => (
      <div class="menu-item" style={{ paddingLeft: props.level * 16 + 'px' }}>
        <div
          class="menu-label"
          onClick={() => hasChildren.value && (expanded.value = !expanded.value)}
        >
          {props.item.icon && <i class={props.item.icon} />}
          <span>{props.item.label}</span>
          {hasChildren.value && (
            <i class={expanded.value ? 'arrow-down' : 'arrow-right'} />
          )}
        </div>
        {hasChildren.value && expanded.value && (
          <div class="menu-children">
            {props.item.children!.map(child => (
              <MenuItemComponent key={child.id} item={child} level={props.level + 1} />
            ))}
          </div>
        )}
      </div>
    );
  }
});

export default defineComponent({
  props: {
    items: { type: Array as PropType<MenuItem[]>, required: true }
  },
  setup(props) {
    return () => (
      <div class="recursive-menu">
        {props.items.map(item => (
          <MenuItemComponent key={item.id} item={item} />
        ))}
      </div>
    );
  }
});

三、自定义指令

3.1 权限指令

// directives/vPermission.ts
import { Directive } from 'vue';
import { useUserStore } from '@/stores/user';

export const vPermission: Directive<HTMLElement, string | string[]> = {
  mounted(el, binding) {
    const userStore = useUserStore();
    const required = Array.isArray(binding.value)
      ? binding.value
      : [binding.value];

    const hasPermission = required.some(p => userStore.permissions.includes(p));

    if (!hasPermission) {
      el.remove();
    }
  }
};

// 注册
app.directive('permission', vPermission);

// 使用
<button v-permission="'user:create'">创建用户</button>
<button v-permission="['user:edit', 'user:admin']">编辑</button>

3.2 拖拽排序

// directives/vSortable.ts
import { Directive } from 'vue';

interface SortableOptions {
  onUpdate: (oldIndex: number, newIndex: number) => void;
}

export const vSortable: Directive<HTMLElement, SortableOptions> = {
  mounted(el, binding) {
    const { onUpdate } = binding.value;
    let draggedItem: HTMLElement | null = null;

    el.querySelectorAll('.sortable-item').forEach((item) => {
      const htmlItem = item as HTMLElement;
      htmlItem.draggable = true;

      htmlItem.addEventListener('dragstart', (e) => {
        draggedItem = htmlItem;
        htmlItem.classList.add('dragging');
        e.dataTransfer!.effectAllowed = 'move';
      });

      htmlItem.addEventListener('dragend', () => {
        htmlItem.classList.remove('dragging');
        draggedItem = null;

        const items = [...el.querySelectorAll('.sortable-item')];
        const newIndex = items.indexOf(htmlItem);
        const oldIndex = parseInt(htmlItem.dataset.index || '0');
        if (oldIndex !== newIndex) onUpdate(oldIndex, newIndex);
      });
    });

    el.addEventListener('dragover', (e) => {
      e.preventDefault();
      const afterElement = getDragAfterElement(el, e.clientY);
      if (draggedItem) {
        afterElement
          ? el.insertBefore(draggedItem, afterElement)
          : el.appendChild(draggedItem);
      }
    });
  }
};

function getDragAfterElement(container: HTMLElement, y: number) {
  const draggableElements = [...container.querySelectorAll('.sortable-item:not(.dragging)')];

  return draggableElements.reduce<{ offset: number; element: Element | null }>(
    (closest, child) => {
      const box = child.getBoundingClientRect();
      const offset = y - box.top - box.height / 2;
      if (offset < 0 && offset > closest.offset) {
        return { offset, element: child };
      }
      return closest;
    },
    { offset: Number.NEGATIVE_INFINITY, element: null }
  ).element;
}

// 使用
<ul v-sortable="{ onUpdate: handleSort }">
  <li
    v-for="(item, index) in list"
    :key="item.id"
    :data-index="index"
    class="sortable-item"
  >
    {{ item.name }}
  </li>
</ul>

3.3 懒加载图片 + 淡入

// directives/vLazy.ts
import { Directive } from 'vue';

const imageCache = new Map<string, HTMLImageElement>();

export const vLazy: Directive<HTMLImageElement, string> = {
  mounted(el, binding) {
    el.classList.add('lazy-image');

    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          const src = binding.value;

          if (imageCache.has(src)) {
            el.src = src;
            el.classList.add('loaded');
          } else {
            const img = new Image();
            img.onload = () => {
              imageCache.set(src, img);
              el.src = src;
              el.classList.add('loaded');
            };
            img.src = src;
          }

          observer.unobserve(el);
        }
      });
    }, { rootMargin: '50px' });

    observer.observe(el);
  }
};

// CSS
// .lazy-image { opacity: 0; transition: opacity 0.3s; }
// .lazy-image.loaded { opacity: 1; }

// 使用
<img v-lazy="item.highResUrl" :src="item.placeholderUrl" />

四、组件通信高级模式

4.1 事件总线(替代方案)

// utils/eventBus.ts
import { ref, watch, type Ref } from 'vue';

type EventCallback<T = any> = (payload: T) => void;

class EventBus {
  private events = new Map<string, Set<EventCallback>>();

  on<T>(event: string, callback: EventCallback<T>): () => void {
    if (!this.events.has(event)) {
      this.events.set(event, new Set());
    }
    this.events.get(event)!.add(callback);

    return () => this.off(event, callback);
  }

  off<T>(event: string, callback: EventCallback<T>): void {
    this.events.get(event)?.delete(callback);
  }

  emit<T>(event: string, payload: T): void {
    this.events.get(event)?.forEach(cb => cb(payload));
  }

  // 与 ref 联动
  useEvent<T>(event: string): Ref<T | undefined> {
    const state = ref<T>();
    this.on<T>(event, (payload) => { state.value = payload; });
    return state;
  }
}

export const bus = new EventBus();

// 组件 A
const unsubscribe = bus.on('user:login', (user) => {
  console.log('用户登录:', user);
});

// 组件 B
bus.emit('user:login', { id: 1, name: 'Tom' });

// 清理
onUnmounted(unsubscribe);

4.2 Provide/Inject 类型安全

// types/injectionKeys.ts
import { InjectionKey, Ref } from 'vue';

export interface FormContext {
  model: Record<string, any>;
  rules: Record<string, any[]>;
  errors: Ref<Record<string, string>>;
  validate: () => Promise<boolean>;
  validateField: (field: string) => Promise<void>;
}

export const FormKey: InjectionKey<FormContext> = Symbol('form');

// Form.vue
import { provide } from 'vue';
import { FormKey, type FormContext } from './types';

const formContext: FormContext = {
  model: reactive({}),
  rules: {},
  errors: ref({}),
  async validate() { /* ... */ return true; },
  async validateField(field) { /* ... */ }
};

provide(FormKey, formContext);

// FormItem.vue
import { inject } from 'vue';
import { FormKey } from './types';

const form = inject(FormKey);
if (!form) throw new Error('FormItem must be used inside Form');

// 现在 form 有完整类型提示
form.validateField('username');

4.3 跨层级状态共享(轻量级)

// composables/useSharedState.ts
import { ref, computed, type Ref, type ComputedRef } from 'vue';

const store = new Map<string, Ref<any>>();

export function useSharedState<T>(
  key: string,
  initialValue: T
): [Ref<T>, (value: T) => void] {
  if (!store.has(key)) {
    store.set(key, ref<T>(initialValue));
  }

  const state = store.get(key) as Ref<T>;

  const setState = (value: T) => {
    state.value = value;
  };

  return [state, setState];
}

// 组件 A(任意层级)
const [count, setCount] = useSharedState('globalCount', 0);

// 组件 B(任意层级)
const [count] = useSharedState('globalCount', 0);
// count 与组件 A 同步

五、性能优化技巧

5.1 虚拟列表

<!-- components/VirtualList.vue -->
<template>
  <div ref="containerRef" class="virtual-list" @scroll="onScroll">
    <div class="phantom" :style="{ height: totalHeight + 'px' }"></div>
    <div class="content" :style="{ transform: `translateY(${offsetY}px)` }">
      <div
        v-for="item in visibleItems"
        :key="item.id"
        class="item"
        :style="{ height: itemHeight + 'px' }"
      >
        <slot :item="item" />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts" generic="T extends { id: string | number }">
const props = defineProps<{
  items: T[];
  itemHeight: number;
  buffer?: number;
}>();

const containerRef = ref<HTMLElement>();
const scrollTop = ref(0);

const totalHeight = computed(() => props.items.length * props.itemHeight);

const visibleRange = computed(() => {
  const buffer = props.buffer || 5;
  const start = Math.floor(scrollTop.value / props.itemHeight);
  const visibleCount = Math.ceil((containerRef.value?.clientHeight || 0) / props.itemHeight);

  return {
    start: Math.max(0, start - buffer),
    end: Math.min(props.items.length, start + visibleCount + buffer)
  };
});

const visibleItems = computed(() => {
  const { start, end } = visibleRange.value;
  return props.items.slice(start, end);
});

const offsetY = computed(() => visibleRange.value.start * props.itemHeight);

const onScroll = () => {
  scrollTop.value = containerRef.value?.scrollTop || 0;
};
</script>

<style scoped>
.virtual-list {
  position: relative;
  overflow-y: auto;
  height: 400px;
}
.phantom {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}
.content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
}
</style>

// 使用
<VirtualList :items="10000条数据" :itemHeight="50">
  <template #default="{ item }">
    <div>{{ item.name }}</div>
  </template>
</VirtualList>

5.2 函数式组件 + 缓存

// components/ExpensiveCell.ts
import { defineComponent, h } from 'vue';

export default defineComponent({
  props: ['value', 'formatter'],
  setup(props) {
    // 纯函数式组件,无响应式开销
    return () => h('span', {
      class: 'cell-value',
      innerHTML: props.formatter
        ? props.formatter(props.value)
        : props.value
    });
  }
});

// 配合 v-memo 使用
<tr v-for="row in rows" :key="row.id" v-memo="[row.status, row.amount]">
  <td><ExpensiveCell :value="row.name" /></td>
  <td><ExpensiveCell :value="row.amount" :formatter="formatMoney" /></td>
  <td>{{ row.status }}</td>
</tr>

5.3 异步组件 + 加载状态

<script setup>
import { defineAsyncComponent } from 'vue';
import Skeleton from './Skeleton.vue';

const HeavyChart = defineAsyncComponent({
  loader: () => import('./HeavyChart.vue'),
  loadingComponent: Skeleton,
  delay: 200,        // 延迟显示 loading,避免闪烁
  timeout: 10000,    // 超时时间
  errorComponent: () => h('div', '加载失败,请刷新'),
  suspensible: false
});
</script>

<template>
  <HeavyChart :data="chartData" />
</template>

5.4 计算属性缓存策略

// 大数据量筛选优化
import { ref, computed, type ComputedRef } from 'vue';

const items = ref<Item[]>([]);
const filterText = ref('');
const sortKey = ref('name');

// 错误:每次访问都重新计算
const filtered = computed(() => {
  return items.value
    .filter(i => i.name.includes(filterText.value))
    .sort((a, b) => a[sortKey.value].localeCompare(b[sortKey.value]));
});

// 正确:分层缓存,减少重复计算
const filteredItems: ComputedRef<Item[]> = computed(() => {
  if (!filterText.value) return items.value;
  return items.value.filter(i =>
    i.name.toLowerCase().includes(filterText.value.toLowerCase())
  );
});

const sortedItems: ComputedRef<Item[]> = computed(() => {
  return [...filteredItems.value].sort(
    (a, b) => a[sortKey.value].localeCompare(b[sortKey.value])
  );
});

六、TypeScript 高级类型

6.1 组件 Props 类型推导

// types/component.ts
import { ExtractPropTypes, ExtractPublicPropTypes } from 'vue';

// 从组件推导 Props 类型
const buttonProps = {
  type: { type: String as PropType<'primary' | 'danger'>, default: 'primary' },
  size: { type: String as PropType<'small' | 'medium' | 'large'>, default: 'medium' },
  disabled: Boolean,
  onClick: Function as PropType<(e: MouseEvent) => void>
};

type ButtonProps = ExtractPropTypes<typeof buttonProps>;
// ButtonProps = { type: 'primary' | 'danger', size: ..., disabled: boolean, onClick?: ... }

// 使用
const props = defineProps<ButtonProps>();

6.2 响应式对象类型工具

// types/reactive.ts
import { UnwrapRef, Reactive } from 'vue';

// 深度解包响应式类型
type DeepUnwrap<T> = T extends Reactive<infer U>
  ? DeepUnwrap<U>
  : T extends object
  ? { [K in keyof T]: DeepUnwrap<T[K]> }
  : T;

// 使用
const state = reactive({
  user: { name: 'Tom', age: 25 },
  list: [{ id: 1 }]
});

type StateType = DeepUnwrap<typeof state>;
// StateType = { user: { name: string, age: number }, list: { id: number }[] }

6.3 严格的事件类型

<script setup lang="ts">
// 定义精确的事件类型
const emit = defineEmits<{
  'update:modelValue': [value: string];
  'change': [value: string, oldValue: string];
  'confirm': [];
  'cancel': [reason: 'user' | 'timeout'];
}>();

// 类型安全调用
emit('change', 'new', 'old');     // ✓
emit('change', 'new');            // ✗ 参数不足
emit('cancel', 'network');        // ✗ 类型不匹配
</script>

七、Vue Router 技巧

7.1 路由守卫组合

// router/guards.ts
import { Router } from 'vue-router';
import { useUserStore } from '@/stores/user';

export function setupGuards(router: Router) {
  // 白名单
  const whiteList = ['/login', '/404'];

  router.beforeEach(async (to, from, next) => {
    const userStore = useUserStore();

    // 1. 判断是否需要登录
    if (to.meta.requiresAuth && !userStore.token) {
      return next(`/login?redirect=${encodeURIComponent(to.fullPath)}`);
    }

    // 2. 动态加载路由
    if (userStore.token && !userStore.routesLoaded) {
      const routes = await userStore.loadMenus();
      routes.forEach(route => router.addRoute(route));
      return next({ ...to, replace: true });
    }

    // 3. 权限校验
    if (to.meta.permission && !userStore.hasPermission(to.meta.permission)) {
      return next('/403');
    }

    // 4. 页面标题
    document.title = to.meta.title ? `${to.meta.title} - 后台系统` : '后台系统';

    next();
  });

  // 错误处理
  router.onError((err) => {
    console.error('路由错误:', err);
    // 动态导入失败,刷新页面
    if (err.message.includes('Failed to fetch dynamically imported module')) {
      window.location.reload();
    }
  });
}

7.2 路由缓存控制

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <keep-alive :include="cachedViews">
      <component :is="Component" :key="route.fullPath" />
    </keep-alive>
  </router-view>
</template>

<script setup>
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';

const route = useRoute();
const cachedViews = ref([]);

// 根据路由 meta 自动管理缓存
watch(() => route.name, (name) => {
  if (route.meta?.keepAlive && !cachedViews.value.includes(name)) {
    cachedViews.value.push(name);
  }
}, { immediate: true });

// 手动清除缓存
const removeCache = (name) => {
  const idx = cachedViews.value.indexOf(name);
  if (idx > -1) cachedViews.value.splice(idx, 1);
};
</script>

7.3 滚动行为

// router/index.ts
const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    // 返回按钮恢复位置
    if (savedPosition) return savedPosition;

    // 锚点跳转
    if (to.hash) {
      return { el: to.hash, behavior: 'smooth' };
    }

    // 新页面滚动到顶部
    return { top: 0, behavior: 'smooth' };
  }
});

八、Pinia 进阶

8.1 模块化 Store

// stores/modules/user.ts
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', () => {
  // State
  const token = ref(localStorage.getItem('token') || '');
  const userInfo = ref<UserInfo | null>(null);

  // Getters
  const isLoggedIn = computed(() => !!token.value);
  const displayName = computed(() => userInfo.value?.nickname || userInfo.value?.username);

  // Actions
  async function login(credentials: LoginForm) {
    const res = await api.login(credentials);
    token.value = res.token;
    localStorage.setItem('token', res.token);
    await fetchUserInfo();
  }

  async function fetchUserInfo() {
    userInfo.value = await api.getUserInfo();
  }

  function logout() {
    token.value = '';
    userInfo.value = null;
    localStorage.removeItem('token');
  }

  // 插件:持久化
  function $subscribePlugin() {
    watch(token, (val) => {
      localStorage.setItem('token', val);
    });
  }

  return {
    token, userInfo,
    isLoggedIn, displayName,
    login, fetchUserInfo, logout
  };
});

8.2 Store 组合

// stores/useAppStore.ts
import { useUserStore } from './modules/user';
import { usePermissionStore } from './modules/permission';

export function useAppStore() {
  const user = useUserStore();
  const permission = usePermissionStore();

  // 组合逻辑
  const canAccess = computed(() => {
    return (route: string) => {
      if (!user.isLoggedIn) return false;
      return permission.hasRoute(route);
    };
  });

  async function init() {
    if (user.token) {
      await Promise.all([
        user.fetchUserInfo(),
        permission.fetchMenus()
      ]);
    }
  }

  return { user, permission, canAccess, init };
}

8.3 插件:操作日志

// stores/plugins/logger.ts
import { PiniaPluginContext } from 'pinia';

export function loggerPlugin({ store }: PiniaPluginContext) {
  const startTime = Date.now();

  store.$onAction(({ name, store, args, after, onError }) => {
    const actionStart = Date.now();
    console.log(`[${store.$id}] ${name} start`, args);

    after((result) => {
      console.log(
        `[${store.$id}] ${name} success (${Date.now() - actionStart}ms)`,
        result
      );
    });

    onError((error) => {
      console.error(
        `[${store.$id}] ${name} error (${Date.now() - actionStart}ms)`,
        error
      );
    });
  });

  store.$subscribe((mutation, state) => {
    console.log(`[${store.$id}] state change:`, mutation);
  });
}

// main.ts
import { createPinia } from 'pinia';
import { loggerPlugin } from './stores/plugins/logger';

const pinia = createPinia();
pinia.use(loggerPlugin);
app.use(pinia);

九、动画与过渡

9.1 列表过渡 + 拖拽

<template>
  <TransitionGroup
    name="list"
    tag="ul"
    @before-leave="beforeLeave"
  >
    <li
      v-for="item in items"
      :key="item.id"
      :data-id="item.id"
      draggable="true"
      @dragstart="onDragStart"
      @dragenter.prevent
      @dragover.prevent
      @drop="onDrop"
    >
      {{ item.name }}
    </li>
  </TransitionGroup>
</template>

<script setup>
const items = ref([
  { id: 1, name: 'Item 1' },
  { id: 2, name: 'Item 2' },
  { id: 3, name: 'Item 3' }
]);

let draggedId = null;

const onDragStart = (e) => {
  draggedId = +e.target.dataset.id;
  e.dataTransfer.effectAllowed = 'move';
};

const onDrop = (e) => {
  const dropId = +e.target.closest('li').dataset.id;
  const fromIdx = items.value.findIndex(i => i.id === draggedId);
  const toIdx = items.value.findIndex(i => i.id === dropId);

  const [removed] = items.value.splice(fromIdx, 1);
  items.value.splice(toIdx, 0, removed);
};

// 保持离开动画正确
const beforeLeave = (el) => {
  const { marginLeft, marginTop, width, height } = getComputedStyle(el);
  el.style.left = `${el.offsetLeft - parseFloat(marginLeft)}px`;
  el.style.top = `${el.offsetTop - parseFloat(marginTop)}px`;
  el.style.width = width;
  el.style.height = height;
};
</script>

<style scoped>
.list-move {
  transition: transform 0.3s ease;
}
.list-enter-active,
.list-leave-active {
  transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}
.list-leave-active {
  position: absolute;
}
</style>

9.2 数字滚动动画

<!-- components/CountUp.vue -->
<template>
  <span>{{ displayValue }}</span>
</template>

<script setup>
const props = defineProps({
  end: { type: Number, required: true },
  duration: { type: Number, default: 2000 },
  decimals: { type: Number, default: 0 }
});

const displayValue = ref('0');
let rafId = null;

const animate = () => {
  const startTime = performance.now();
  const startValue = 0;

  const step = (currentTime) => {
    const elapsed = currentTime - startTime;
    const progress = Math.min(elapsed / props.duration, 1);

    // easeOutQuart
    const ease = 1 - Math.pow(1 - progress, 4);
    const current = startValue + (props.end - startValue) * ease;

    displayValue.value = current.toFixed(props.decimals);

    if (progress < 1) {
      rafId = requestAnimationFrame(step);
    }
  };

  rafId = requestAnimationFrame(step);
};

onMounted(animate);
onUnmounted(() => cancelAnimationFrame(rafId));
</script>

// 使用
<CountUp :end="9999" :duration="3000" />

十、调试与开发工具

10.1 开发时 API Mock

// mock/setup.ts
if (import.meta.env.DEV) {
  const { worker } = await import('./browser');
  worker.start({
    onUnhandledRequest: 'bypass' // 未匹配请求直接放行
  });
}

// mock/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', ({ request }) => {
    const url = new URL(request.url);
    const page = url.searchParams.get('page');

    return HttpResponse.json({
      data: Array.from({ length: 10 }, (_, i) => ({
        id: i + 1 + (page - 1) * 10,
        name: `User ${i + 1}`
      })),
      total: 100
    });
  }),

  http.post('/api/login', async ({ request }) => {
    const body = await request.json();

    if (body.username === 'admin') {
      return HttpResponse.json({ token: 'fake-token', user: { name: 'Admin' } });
    }

    return new HttpResponse(null, { status: 401 });
  })
];

10.2 性能标记

// utils/perf.ts
export function mark(name: string) {
  if (import.meta.env.DEV) {
    performance.mark(`${name}-start`);
  }
}

export function measure(name: string) {
  if (import.meta.env.DEV) {
    performance.mark(`${name}-end`);
    performance.measure(name, `${name}-start`, `${name}-end`);

    const entries = performance.getEntriesByName(name);
    console.log(`[Perf] ${name}: ${entries[0].duration.toFixed(2)}ms`);
  }
}

// 使用
mark('table-render');
// ...渲染大量数据
measure('table-render');

10.3 组件调试信息

<script setup>
const props = defineProps(['data']);

// 开发时自动打印
if (import.meta.env.DEV) {
  watch(() => props.data, (val) => {
    console.log(`[${getCurrentInstance()?.type.name}] data changed:`, val);
  }, { deep: true });
}
</script>

十一、表单处理进阶

11.1 动态表单验证

<script setup lang="ts">
interface Field {
  key: string;
  label: string;
  type: 'input' | 'select' | 'date';
  rules: Array<{ required?: boolean; message: string; validator?: (val: any) => boolean }>;
  options?: Array<{ label: string; value: any }>;
}

const props = defineProps<{ fields: Field[] }>();

const form = reactive<Record<string, any>>({});
const errors = reactive<Record<string, string>>({});

// 初始化表单值
props.fields.forEach(f => {
  form[f.key] = '';
});

const validate = async () => {
  let valid = true;
  props.fields.forEach(field => {
    const value = form[field.key];
    for (const rule of field.rules) {
      if (rule.required && !value) {
        errors[field.key] = rule.message;
        valid = false;
        break;
      }
      if (rule.validator && !rule.validator(value)) {
        errors[field.key] = rule.message;
        valid = false;
        break;
      }
      errors[field.key] = '';
    }
  });
  return valid;
};

const submit = async () => {
  if (await validate()) {
    emit('submit', { ...form });
  }
};
</script>

<template>
  <form @submit.prevent="submit">
    <div v-for="field in fields" :key="field.key" class="form-item">
      <label>{{ field.label }}</label>
      <input v-if="field.type === 'input'" v-model="form[field.key]" />
      <select v-else-if="field.type === 'select'" v-model="form[field.key]">
        <option v-for="opt in field.options" :key="opt.value" :value="opt.value">
          {{ opt.label }}
        </option>
      </select>
      <span v-if="errors[field.key]" class="error">{{ errors[field.key] }}</span>
    </div>
    <button type="submit">提交</button>
  </form>
</template>

11.2 表单重置与脏检查

// composables/useForm.ts
import { ref, reactive, watch, isRef } from 'vue';

export function useForm<T extends Record<string, any>>(initialValues: T) {
  const original = JSON.parse(JSON.stringify(initialValues));
  const values = reactive({ ...initialValues });
  const isDirty = ref(false);
  const isSubmitting = ref(false);

  const stop = watch(
    () => JSON.stringify(values),
    (newVal) => {
      isDirty.value = newVal !== JSON.stringify(original);
    }
  );

  const reset = () => {
    Object.keys(original).forEach(key => {
      (values as any)[key] = isRef(original[key]) ? original[key].value : original[key];
    });
    isDirty.value = false;
  };

  const setValues = (newValues: Partial<T>) => {
    Object.assign(values, newValues);
  };

  return {
    values,
    isDirty,
    isSubmitting,
    reset,
    setValues
  };
}

// 使用
const { values, isDirty, reset } = useForm({
  name: '',
  email: '',
  age: 18
});

// 离开页面前提示
onBeforeRouteLeave((to, from, next) => {
  if (isDirty.value) {
    const ok = confirm('有未保存的更改,确定离开?');
    next(ok);
  } else {
    next();
  }
});

十二、文件上传处理

12.1 大文件分片上传

// composables/useUploader.ts
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB

export function useUploader() {
  const progress = ref(0);
  const uploading = ref(false);

  const upload = async (file: File) => {
    uploading.value = true;
    const chunks = Math.ceil(file.size / CHUNK_SIZE);
    const fileHash = await calcHash(file);

    // 秒传检查
    const { exists, uploaded } = await api.checkFile(fileHash);
    if (exists) return { url: uploaded };

    const uploadedChunks = new Set<number>(uploaded || []);

    for (let i = 0; i < chunks; i++) {
      if (uploadedChunks.has(i)) continue;

      const start = i * CHUNK_SIZE;
      const end = Math.min(start + CHUNK_SIZE, file.size);
      const chunk = file.slice(start, end);

      const formData = new FormData();
      formData.append('chunk', chunk);
      formData.append('hash', fileHash);
      formData.append('index', String(i));
      formData.append('total', String(chunks));

      await api.uploadChunk(formData, {
        onUploadProgress: (e) => {
          const chunkProgress = (e.loaded / e.total) * (1 / chunks);
          const baseProgress = i / chunks;
          progress.value = Math.floor((baseProgress + chunkProgress) * 100);
        }
      });
    }

    // 合并分片
    const result = await api.mergeChunks(fileHash, file.name);
    uploading.value = false;
    return result;
  };

  return { upload, progress, uploading };
}

// 计算文件 hash
async function calcHash(file: File): Promise<string> {
  const buffer = await file.arrayBuffer();
  const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
  return Array.from(new Uint8Array(hashBuffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

12.2 拖拽上传区域

<script setup>
const isDragging = ref(false);
const fileList = ref<File[]>([]);

const onDrop = (e: DragEvent) => {
  isDragging.value = false;
  const files = Array.from(e.dataTransfer?.files || []);
  fileList.value.push(...files);
  emit('files', fileList.value);
};

const onDragOver = (e: DragEvent) => {
  e.preventDefault();
  isDragging.value = true;
};

const onDragLeave = () => {
  isDragging.value = false;
};

const removeFile = (index: number) => {
  fileList.value.splice(index, 1);
};
</script>

<template>
  <div
    class="drop-zone"
    :class="{ dragging: isDragging }"
    @drop="onDrop"
    @dragover="onDragOver"
    @dragleave="onDragLeave"
  >
    <p>拖拽文件到此处,或 <span class="click">点击上传</span></p>
    <input type="file" multiple hidden @change="e => fileList.push(...e.target.files)" />

    <ul class="file-list">
      <li v-for="(file, i) in fileList" :key="file.name + i">
        {{ file.name }} ({{ (file.size / 1024).toFixed(1) }} KB)
        <button @click="removeFile(i)">删除</button>
      </li>
    </ul>
  </div>
</template>

<style scoped>
.drop-zone {
  border: 2px dashed #ccc;
  padding: 40px;
  text-align: center;
  transition: all 0.3s;
}
.drop-zone.dragging {
  border-color: #409eff;
  background: #f0f9ff;
}
</style>

十三、WebSocket 实时通信

13.1 自动重连封装

// composables/useWebSocket.ts
export function useWebSocket(url: string) {
  const ws = ref<WebSocket | null>(null);
  const status = ref<'connecting' | 'open' | 'closed' | 'error'>('closed');
  const message = ref<any>(null);
  let reconnectTimer: ReturnType<typeof setTimeout>;
  let reconnectCount = 0;
  const MAX_RECONNECT = 5;

  const connect = () => {
    status.value = 'connecting';
    ws.value = new WebSocket(url);

    ws.value.onopen = () => {
      status.value = 'open';
      reconnectCount = 0;
    };

    ws.value.onmessage = (e) => {
      try {
        message.value = JSON.parse(e.data);
      } catch {
        message.value = e.data;
      }
    };

    ws.value.onclose = () => {
      status.value = 'closed';
      if (reconnectCount < MAX_RECONNECT) {
        reconnectCount++;
        reconnectTimer = setTimeout(connect, 3000 * reconnectCount);
      }
    };

    ws.value.onerror = () => {
      status.value = 'error';
    };
  };

  const send = (data: any) => {
    if (ws.value?.readyState === WebSocket.OPEN) {
      ws.value.send(typeof data === 'string' ? data : JSON.stringify(data));
    }
  };

  const close = () => {
    clearTimeout(reconnectTimer);
    ws.value?.close();
  };

  onMounted(connect);
  onUnmounted(close);

  return { status, message, send, close };
}

13.2 心跳检测

// 在 useWebSocket 中加入
const heartbeat = {
  interval: 30000,
  timer: null as ReturnType<typeof setInterval> | null,
  start() {
    this.timer = setInterval(() => {
      send({ type: 'ping' });
    }, this.interval);
  },
  stop() {
    this.timer && clearInterval(this.timer);
  }
};

// 在 onopen 中启动
ws.value.onopen = () => {
  status.value = 'open';
  reconnectCount = 0;
  heartbeat.start();
};

// 在 onclose/onerror 中停止
heartbeat.stop();

十四、图表与可视化

14.1 ECharts 自适应封装

<script setup>
import * as echarts from 'echarts';

const chartRef = ref<HTMLElement>();
let chartInstance: echarts.ECharts | null = null;

const initChart = () => {
  if (!chartRef.value) return;
  chartInstance = echarts.init(chartRef.value);
  chartInstance.setOption(props.option);
};

// 响应式
const resizeObserver = new ResizeObserver(() => {
  chartInstance?.resize();
});

onMounted(() => {
  initChart();
  if (chartRef.value) resizeObserver.observe(chartRef.value);
});

onUnmounted(() => {
  resizeObserver.disconnect();
  chartInstance?.dispose();
});

// 数据更新
watch(() => props.option, (opt) => {
  chartInstance?.setOption(opt, true);
}, { deep: true });
</script>

<template>
  <div ref="chartRef" class="chart-container"></div>
</template>

14.2 大数据量表格虚拟滚动

<script setup lang="ts">
const props = defineProps<{
  data: any[];
  rowHeight: number;
  visibleCount: number;
}>();

const containerRef = ref<HTMLElement>();
const scrollTop = ref(0);

const startIndex = computed(() => {
  return Math.floor(scrollTop.value / props.rowHeight);
});

const visibleData = computed(() => {
  return props.data.slice(startIndex.value, startIndex.value + props.visibleCount + 2);
});

const offsetY = computed(() => startIndex.value * props.rowHeight);

const onScroll = () => {
  scrollTop.value = containerRef.value?.scrollTop || 0;
};
</script>

<template>
  <div ref="containerRef" class="virtual-table" @scroll="onScroll">
    <div class="phantom" :style="{ height: data.length * rowHeight + 'px' }"></div>
    <div class="content" :style="{ transform: `translateY(${offsetY}px)` }">
      <div
        v-for="item in visibleData"
        :key="item.id"
        class="row"
        :style="{ height: rowHeight + 'px' }"
      >
        <slot :item="item" />
      </div>
    </div>
  </div>
</template>

十五、权限与路由守卫

15.1 按钮级权限

// directives/vAuth.ts
import { Directive } from 'vue';

export const vAuth: Directive = {
  mounted(el, binding) {
    const permissions = useUserStore().permissions;
    const hasAuth = permissions.includes(binding.value);
    if (!hasAuth) {
      el.style.display = 'none';
      // 或者 el.remove();
    }
  }
};

// 使用
<button v-auth="'user:create'">新增</button>
<button v-auth="['user:edit', 'admin']">编辑</button>

15.2 路由懒加载 + 加载状态

// router/index.ts
const lazyLoad = (path: string) => {
  return defineAsyncComponent({
    loader: () => import(`@/views/${path}.vue`),
    loadingComponent: () => h('div', { class: 'page-loading' }, '加载中...'),
    errorComponent: () => h('div', { class: 'page-error' }, '加载失败'),
    delay: 200,
    timeout: 10000
  });
};

const routes = [
  { path: '/dashboard', component: lazyLoad('Dashboard') },
  { path: '/user', component: lazyLoad('user/Index') }
];

十六、国际化处理

16.1 简单 i18n 实现

// i18n/index.ts
import { ref, computed } from 'vue';

const messages = {
  zh: {
    hello: '你好',
    welcome: '欢迎,{name}'
  },
  en: {
    hello: 'Hello',
    welcome: 'Welcome, {name}'
  }
};

const locale = ref('zh');

export const t = (key: string, params?: Record<string, string>) => {
  let msg = messages[locale.value][key] || key;
  if (params) {
    Object.keys(params).forEach(k => {
      msg = msg.replace(`{${k}}`, params[k]);
    });
  }
  return msg;
};

export const currentLocale = computed(() => locale.value);
export const setLocale = (l: string) => { locale.value = l; };

16.2 日期数字本地化

export const formatDate = (date: Date, options?: Intl.DateTimeFormatOptions) => {
  return new Intl.DateTimeFormat(locale.value, options).format(date);
};

export const formatNumber = (num: number, options?: Intl.NumberFormatOptions) => {
  return new Intl.NumberFormat(locale.value, options).format(num);
};

// 使用
formatDate(new Date()); // 2024/1/15
formatNumber(1234567.89); // 1,234,567.89

十七、打印与导出

17.1 页面局部打印

export function printElement(el: HTMLElement) {
  const printWindow = window.open('', '_blank');
  if (!printWindow) return;

  const styles = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
    .map(s => s.outerHTML)
    .join('');

  printWindow.document.write(`
    <html>
      <head>${styles}</head>
      <body>${el.outerHTML}</body>
    </html>
  `);

  printWindow.document.close();
  printWindow.focus();
  setTimeout(() => {
    printWindow.print();
    printWindow.close();
  }, 250);
}

17.2 表格导出 Excel

export function exportTableToExcel(headers: string[], rows: any[][], filename: string) {
  const csvContent = [
    headers.join(','),
    ...rows.map(row =>
      row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')
    )
  ].join('\n');

  const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
  const link = document.createElement('a');
  link.href = URL.createObjectURL(blob);
  link.download = `${filename}.csv`;
  link.click();
  URL.revokeObjectURL(link.href);
}

十八、图片处理

18.1 图片压缩上传

export function compressImage(file: File, maxWidth = 1920, quality = 0.8): Promise<Blob> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.src = URL.createObjectURL(file);
    img.onload = () => {
      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d')!;

      let { width, height } = img;
      if (width > maxWidth) {
        height = (height * maxWidth) / width;
        width = maxWidth;
      }

      canvas.width = width;
      canvas.height = height;
      ctx.drawImage(img, 0, 0, width, height);

      canvas.toBlob((blob) => {
        blob ? resolve(blob) : reject(new Error('压缩失败'));
        URL.revokeObjectURL(img.src);
      }, 'image/jpeg', quality);
    };
    img.onerror = reject;
  });
}

18.2 图片预览组件

<script setup>
const props = defineProps<{ src: string }>();
const visible = ref(false);
const scale = ref(1);

const zoomIn = () => { scale.value = Math.min(scale.value + 0.2, 3); };
const zoomOut = () => { scale.value = Math.max(scale.value - 0.2, 0.5); };
const reset = () => { scale.value = 1; };

const onWheel = (e: WheelEvent) => {
  e.preventDefault();
  e.deltaY < 0 ? zoomIn() : zoomOut();
};
</script>

<template>
  <img :src="src" class="preview-thumb" @click="visible = true" />

  <Teleport to="body">
    <div v-if="visible" class="preview-overlay" @click="visible = false">
      <img
        :src="src"
        class="preview-img"
        :style="{ transform: `scale(${scale})` }"
        @click.stop
        @wheel="onWheel"
      />
      <div class="preview-toolbar" @click.stop>
        <button @click="zoomOut">-</button>
        <span>{{ Math.round(scale * 100) }}%</span>
        <button @click="zoomIn">+</button>
        <button @click="reset">重置</button>
      </div>
    </div>
  </Teleport>
</template>

十九、暗黑模式

19.1 系统偏好检测

// composables/useDarkMode.ts
export function useDarkMode() {
  const isDark = ref(false);

  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
  isDark.value = mediaQuery.matches;

  mediaQuery.addEventListener('change', (e) => {
    isDark.value = e.matches;
    updateTheme();
  });

  const toggle = () => {
    isDark.value = !isDark.value;
    updateTheme();
  };

  const updateTheme = () => {
    document.documentElement.classList.toggle('dark', isDark.value);
    localStorage.setItem('theme', isDark.value ? 'dark' : 'light');
  };

  // 初始化
  const saved = localStorage.getItem('theme');
  if (saved) {
    isDark.value = saved === 'dark';
    updateTheme();
  }

  return { isDark, toggle };
}

19.2 CSS 变量切换

:root {
  --bg-color: #ffffff;
  --text-color: #333333;
  --border-color: #e0e0e0;
}

.dark {
  --bg-color: #1a1a1a;
  --text-color: #e0e0e0;
  --border-color: #333333;
}

body {
  background: var(--bg-color);
  color: var(--text-color);
}

二十、性能监控

20.1 页面性能指标采集

export function collectPerformance() {
  window.addEventListener('load', () => {
    setTimeout(() => {
      const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
      const paint = performance.getEntriesByType('paint');

      const metrics = {
        // DNS 查询时间
        dns: nav.domainLookupEnd - nav.domainLookupStart,
        // TCP 连接时间
        tcp: nav.connectEnd - nav.connectStart,
        // 首字节时间
        ttfb: nav.responseStart - nav.requestStart,
        // DOM 解析时间
        domParse: nav.domInteractive - nav.responseEnd,
        // 首屏时间
        fcp: paint.find(p => p.name === 'first-contentful-paint')?.startTime,
        // 加载完成时间
        load: nav.loadEventEnd - nav.startTime
      };

      // 上报
      api.reportMetrics(metrics);
    }, 0);
  });
}

20.2 长任务监控

export function observeLongTasks() {
  if (!('PerformanceObserver' in window)) return;

  const observer = new PerformanceObserver((list) => {
    list.getEntries().forEach((entry) => {
      if (entry.duration > 50) {
        console.warn('长任务警告:', {
          duration: entry.duration,
          startTime: entry.startTime
        });
      }
    });
  });

  observer.observe({ entryTypes: ['longtask'] });
}

二十一、其他实用技巧

21.1 防抖输入框

<script setup>
const searchText = ref('');
const debouncedSearch = useDebounceFn((val) => {
  emit('search', val);
}, 500);

watch(searchText, (val) => {
  debouncedSearch(val);
});
</script>

21.2 复制到剪贴板

export async function copyToClipboard(text: string): Promise<boolean> {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    const textarea = document.createElement('textarea');
    textarea.value = text;
    textarea.style.position = 'fixed';
    textarea.style.opacity = '0';
    document.body.appendChild(textarea);
    textarea.select();
    const result = document.execCommand('copy');
    document.body.removeChild(textarea);
    return result;
  }
}

21.3 全屏切换

export function useFullscreen() {
  const isFullscreen = ref(false);

  const toggle = async () => {
    if (!document.fullscreenElement) {
      await document.documentElement.requestFullscreen();
    } else {
      await document.exitFullscreen();
    }
  };

  document.addEventListener('fullscreenchange', () => {
    isFullscreen.value = !!document.fullscreenElement;
  });

  return { isFullscreen, toggle };
}

21.4 倒计时钩子

export function useCountdown(seconds: number) {
  const remaining = ref(seconds);
  const isRunning = ref(false);
  let timer: ReturnType<typeof setInterval>;

  const start = () => {
    if (isRunning.value) return;
    isRunning.value = true;
    remaining.value = seconds;
    timer = setInterval(() => {
      remaining.value--;
      if (remaining.value <= 0) {
        clearInterval(timer);
        isRunning.value = false;
      }
    }, 1000);
  };

  const stop = () => {
    clearInterval(timer);
    isRunning.value = false;
  };

  onUnmounted(stop);

  return { remaining, isRunning, start, stop };
}

// 使用:验证码倒计时
const { remaining, isRunning, start } = useCountdown(60);
// <button @click="start" :disabled="isRunning">
//   {{ isRunning ? `${remaining}s后重试` : '获取验证码' }}
// </button>

21.5 本地存储封装

export function useStorage<T>(key: string, defaultValue: T) {
  const stored = localStorage.getItem(key);
  const data = ref<T>(stored ? JSON.parse(stored) : defaultValue);

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

  const remove = () => {
    localStorage.removeItem(key);
    data.value = defaultValue;
  };

  return { data, remove };
}

// 使用
const { data: userSettings, remove: clearSettings } = useStorage('settings', {
  theme: 'light',
  fontSize: 14
});

21.6 页面可见性监听

export function usePageVisibility() {
  const isVisible = ref(!document.hidden);

  const onChange = () => {
    isVisible.value = !document.hidden;
  };

  document.addEventListener('visibilitychange', onChange);
  onUnmounted(() => document.removeEventListener('visibilitychange', onChange));

  return isVisible;
}

// 使用:页面不可见时暂停轮询
const visible = usePageVisibility();
watch(visible, (v) => {
  v ? startPolling() : stopPolling();
});

21.7 网络状态监听

export function useNetwork() {
  const isOnline = ref(navigator.onLine);
  const connection = ref<any>(null);

  const updateConnection = () => {
    isOnline.value = navigator.onLine;
    connection.value = (navigator as any).connection;
  };

  window.addEventListener('online', updateConnection);
  window.addEventListener('offline', updateConnection);

  onUnmounted(() => {
    window.removeEventListener('online', updateConnection);
    window.removeEventListener('offline', updateConnection);
  });

  return { isOnline, connection };
}

21.8 键盘快捷键

export function useKeyShortcut(keyMap: Record<string, () => void>) {
  const onKeydown = (e: KeyboardEvent) => {
    const key = [
      e.ctrlKey ? 'Ctrl+' : '',
      e.shiftKey ? 'Shift+' : '',
      e.altKey ? 'Alt+' : '',
      e.key.toUpperCase()
    ].join('');

    if (keyMap[key]) {
      e.preventDefault();
      keyMap[key]();
    }
  };

  window.addEventListener('keydown', onKeydown);
  onUnmounted(() => window.removeEventListener('keydown', onKeydown));
}

// 使用
useKeyShortcut({
  'Ctrl+S': () => saveDocument(),
  'Ctrl+Shift+P': () => openCommandPalette(),
  'Escape': () => closeModal()
});

21.9 滚动到指定元素

export function scrollToElement(selector: string, offset = 0) {
  const el = document.querySelector(selector);
  if (!el) return;

  const top = el.getBoundingClientRect().top + window.scrollY - offset;
  window.scrollTo({ top, behavior: 'smooth' });
}

// 使用
scrollToElement('#comment-section', 80); // 留出 header 高度

21.10 多选框全选逻辑

export function useSelectAll<T extends { id: string | number }>(items: Ref<T[]>) {
  const selected = ref<Set<string | number>>(new Set());

  const isAllSelected = computed(() => {
    return items.value.length > 0 && items.value.every(i => selected.value.has(i.id));
  });

  const isIndeterminate = computed(() => {
    const count = items.value.filter(i => selected.value.has(i.id)).length;
    return count > 0 && count < items.value.length;
  });

  const toggleAll = () => {
    if (isAllSelected.value) {
      items.value.forEach(i => selected.value.delete(i.id));
    } else {
      items.value.forEach(i => selected.value.add(i.id));
    }
  };

  const toggleItem = (id: string | number) => {
    selected.value.has(id) ? selected.value.delete(id) : selected.value.add(id);
  };

  return {
    selected,
    isAllSelected,
    isIndeterminate,
    toggleAll,
    toggleItem
  };
}

更多推荐