Vue 高阶技巧实战
·
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
};
}
更多推荐
所有评论(0)