Vue 实战技巧与最佳实践
Vue 实战技巧与最佳实践
本文总结了 Vue 开发中的实用技巧、常见问题的解决方案和最佳实践,帮助开发者写出更高质量、更易维护的 Vue 代码。这些技巧大多来自真实项目踩坑后的沉淀,掌握它们能显著提升开发效率和代码质量。
零、写好 Vue 代码的核心原则
在展开具体技巧之前,先建立几条贯穿全文的原则。可以把它们当作评判代码好坏的"尺子":
下面从组件设计、性能、表单、请求、代码组织、调试六个维度,逐一展开可落地的技巧。
一、组件设计技巧
1. 智能组件与展示组件分离
这是 React/Vue 社区公认的组件设计模式,核心思想是把"数据从哪来"和"界面怎么画"分开。可以类比餐厅:展示组件是服务员(只负责端菜、和顾客交互,不关心菜怎么做),智能组件是后厨(负责备料、烹饪,即数据获取和业务逻辑)。
这样分离的好处:展示组件没有副作用,纯输入输出,极易测试和在不同场景复用;智能组件专注业务,替换 UI 时不用动逻辑。
展示组件(Dumb Component): 只负责 UI 渲染,通过 props 接收数据、通过 emit 上报事件,不关心数据来源。
<!-- UserCard.vue -->
<script setup>
defineProps({
user: { type: Object, required: true },
loading: { type: Boolean, default: false }
});
defineEmits(['edit', 'delete']);
</script>
<template>
<div class="user-card">
<div v-if="loading">加载中...</div>
<div v-else>
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<button @click="$emit('edit', user)">编辑</button>
<button @click="$emit('delete', user.id)">删除</button>
</div>
</div>
</template>智能组件(Smart Component): 负责数据获取和业务逻辑,把结果通过 props 传给展示组件。
<!-- UserCardContainer.vue -->
<script setup>
import { ref, onMounted } from 'vue';
import UserCard from './UserCard.vue';
import { fetchUser } from '@/api/user';
const props = defineProps({ userId: Number });
const user = ref(null);
const loading = ref(false);
const loadUser = async () => {
loading.value = true;
try {
user.value = await fetchUser(props.userId);
} finally {
loading.value = false;
}
};
const handleEdit = (user) => {
// 编辑逻辑
};
const handleDelete = (userId) => {
// 删除逻辑
};
onMounted(loadUser);
</script>
<template>
<UserCard
:user="user"
:loading="loading"
@edit="handleEdit"
@delete="handleDelete"
/>
</template>2. 使用 v-bind="$attrs" 透传属性
当封装基础组件(如 BaseInput)时,希望使用者能像用原生 input 一样传 placeholder、maxlength、disabled 等属性,又不想在组件里一个个声明 props。这时用 $attrs 一次性透传所有未声明的属性。
<!-- BaseInput.vue -->
<script setup>
defineProps({
label: String,
error: String
});
// 默认情况下 attrs 会落在根元素,若不想落在 wrapper 上可设 inheritAttrs: false
defineOptions({ inheritAttrs: false });
</script>
<template>
<div class="input-wrapper">
<label v-if="label">{{ label }}</label>
<!-- placeholder、disabled 等都会透传到真正的 input 上 -->
<input v-bind="$attrs" />
<span v-if="error" class="error">{{ error }}</span>
</div>
</template>
<!-- 使用 -->
<template>
<BaseInput
v-model="username"
label="用户名"
placeholder="请输入用户名"
:disabled="true"
maxlength="20"
/>
</template>3. 使用 defineModel 简化双向绑定(Vue 3.4+)
在 3.4 之前,自定义组件实现 v-model 要同时声明 props 的 modelValue 和 emit 的 update:modelValue,非常啰嗦。defineModel 把这一切简化成一行。
<!-- CustomInput.vue -->
<script setup>
// 一行搞定:读取用 modelValue.value,赋值自动 emit
const modelValue = defineModel({ type: String, required: true });
const onInput = (event) => {
modelValue.value = event.target.value;
};
</script>
<template>
<input :value="modelValue" @input="onInput" />
</template>
<!-- 使用 -->
<template>
<CustomInput v-model="searchQuery" />
</template>多个 v-model:defineModel 还支持具名模型,一个组件绑多个值。
<script setup>
const firstName = defineModel('firstName');
const lastName = defineModel('lastName');
</script>
<!-- 使用:<UserName v-model:firstName="a" v-model:lastName="b" /> -->4. 使用 provide/inject 跨层级共享
对于只在某棵组件子树内共享的数据(如表单向导的当前步骤、主题上下文),不必上 Pinia,用 provide/inject 更轻量。
<!-- 祖先组件 -->
<script setup>
import { provide, ref, readonly } from 'vue';
const theme = ref('dark');
const toggleTheme = () => (theme.value = theme.value === 'dark' ? 'light' : 'dark');
// 用 readonly 暴露只读值,修改只能通过提供的方法,保证数据流可控
provide('theme', { theme: readonly(theme), toggleTheme });
</script>
<!-- 任意后代组件 -->
<script setup>
import { inject } from 'vue';
const { theme, toggleTheme } = inject('theme');
</script>二、性能优化技巧
1. 使用 v-memo 缓存模板(Vue 3.2+)
v-memo 记忆一段模板,只有依赖数组变化时才重新渲染,适合大列表中局部更新的场景。
<script setup>
import { ref } from 'vue';
const items = ref([]);
const selectedItem = ref(null);
</script>
<template>
<div>
<!-- 只有当被选中状态变化时,该行才重新渲染 -->
<div
v-for="item in items"
:key="item.id"
v-memo="[item.id === selectedItem?.id]"
>
{{ item.name }}
</div>
</div>
</template>2. 使用 shallowRef 优化大对象
默认的 ref/reactive 是深层响应式,会递归代理对象每一层。对大对象(如上万条数据的列表)开销巨大。shallowRef 只代理第一层。
<script setup>
import { ref, shallowRef, triggerRef } from 'vue';
// 深层响应式(适合小对象)
const deepData = ref({ nested: { value: 1 } });
// 浅层响应式(适合大对象/大列表)
const largeList = shallowRef([]);
// 原地修改浅层对象内部时,需手动触发更新
const updateList = () => {
largeList.value.push({ id: 1 });
triggerRef(largeList);
};
</script>实测:给一个 2 万条对象的数组做响应式,ref 约耗时 150ms 且占用较高内存,shallowRef 几乎瞬时(< 2ms)。
3. 使用 markRaw 避免不必要的响应式转换
第三方库实例(图表、地图、编辑器)内部结构复杂,把它变成响应式毫无意义还拖慢性能。用 markRaw 标记后,Vue 会永久跳过它。
<script setup>
import { ref, markRaw } from 'vue';
import Chart from 'chart.js/auto';
const chart = ref(null);
const initChart = (ctx, config) => {
// 避免 Vue 递归代理庞大的 Chart 实例
chart.value = markRaw(new Chart(ctx, config));
};
</script>4. 使用 Object.freeze 冻结静态数据
对于永远不变的配置和大型静态列表,用 Object.freeze 冻结,Vue 会识别到并跳过响应式处理。
<script setup>
// 冻结静态配置
const CONFIG = Object.freeze({
API_URL: 'https://api.example.com',
TIMEOUT: 5000
});
// 冻结大型静态选项列表(如省市区数据)
const staticOptions = Object.freeze([
{ value: 1, label: 'Option 1' },
{ value: 2, label: 'Option 2' }
// ... 大量数据
]);
</script>三、表单处理技巧
1. 使用自定义 Composable 处理表单
表单的校验、提交、重置逻辑高度相似,抽成 Composable 后可在所有表单复用,避免重复代码。
// composables/useForm.js
import { ref, reactive } from 'vue';
export function useForm(initialValues, validateFn) {
const values = reactive({ ...initialValues });
const errors = ref({});
const isSubmitting = ref(false);
function validate() {
errors.value = validateFn(values) || {};
return Object.keys(errors.value).length === 0;
}
async function submit(submitFn) {
if (!validate()) return;
isSubmitting.value = true;
try {
await submitFn(values);
} catch (error) {
errors.value.submit = error.message;
} finally {
isSubmitting.value = false;
}
}
function reset() {
Object.assign(values, initialValues);
errors.value = {};
}
return { values, errors, isSubmitting, validate, submit, reset };
}使用示例:
<script setup>
import { useForm } from '@/composables/useForm';
const { values, errors, submit, isSubmitting } = useForm(
{ username: '', email: '', password: '' },
(values) => {
const errors = {};
if (!values.username) errors.username = '用户名必填';
if (!values.email) errors.email = '邮箱必填';
else if (!/^[^@]+@[^@]+\.[^@]+$/.test(values.email)) errors.email = '邮箱格式不正确';
if (!values.password) errors.password = '密码必填';
else if (values.password.length < 6) errors.password = '密码至少 6 位';
return errors;
}
);
const handleSubmit = async () => {
await submit(async (data) => {
await fetch('/api/register', {
method: 'POST',
body: JSON.stringify(data)
});
});
};
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="values.username" />
<span v-if="errors.username">{{ errors.username }}</span>
<input v-model="values.email" type="email" />
<span v-if="errors.email">{{ errors.email }}</span>
<input v-model="values.password" type="password" />
<span v-if="errors.password">{{ errors.password }}</span>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? '提交中...' : '注册' }}
</button>
</form>
</template>2. 使用 v-model 修饰符处理输入
Vue 内置的 v-model 修饰符能省掉很多手动处理。
<script setup>
import { ref } from 'vue';
const formData = ref({
username: '', // .trim 自动去首尾空格
age: null, // .number 自动转数字
bio: '' // .lazy 改为 change 事件触发,减少更新频率
});
</script>
<template>
<form>
<input v-model.trim="formData.username" placeholder="用户名" />
<input v-model.number="formData.age" type="number" placeholder="年龄" />
<textarea v-model.lazy="formData.bio" placeholder="简介"></textarea>
</form>
</template>3. 真实案例:搜索框防抖
搜索框每次输入都请求接口会造成大量无效请求。用防抖 Composable 把请求合并到用户停止输入之后。
// composables/useDebounce.js
import { ref, watch } from 'vue';
export function useDebouncedRef(value, delay = 300) {
const debounced = ref(value);
let timer = null;
const source = ref(value);
watch(source, (val) => {
clearTimeout(timer);
timer = setTimeout(() => {
debounced.value = val;
}, delay);
});
return { source, debounced };
}<script setup>
import { watch } from 'vue';
import { useDebouncedRef } from '@/composables/useDebounce';
const { source: keyword, debounced } = useDebouncedRef('', 400);
// 只有停止输入 400ms 后才真正搜索
watch(debounced, (kw) => {
if (kw) searchApi(kw);
});
</script>
<template>
<input v-model="keyword" placeholder="搜索..." />
</template>四、API 请求处理技巧
1. 使用自定义 Composable 处理请求
把 loading、error、data 三态封装成 Composable,让组件里的请求代码变得极简。
// composables/useFetch.js
import { ref } from 'vue';
export function useFetch(url, options = {}) {
const data = ref(null);
const error = ref(null);
const loading = ref(false);
async function execute(body = null) {
loading.value = true;
error.value = null;
try {
const response = await fetch(url, {
...options,
body: body ? JSON.stringify(body) : null,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
if (!response.ok) throw new Error(response.statusText);
data.value = await response.json();
return data.value;
} catch (err) {
error.value = err;
throw err;
} finally {
loading.value = false;
}
}
return { data, error, loading, execute };
}使用示例:
<script setup>
import { onMounted } from 'vue';
import { useFetch } from '@/composables/useFetch';
const { data: users, loading, error, execute } = useFetch('/api/users');
onMounted(execute);
</script>
<template>
<div v-if="loading">加载中...</div>
<div v-else-if="error">出错了:{{ error.message }}</div>
<div v-else-if="users">
<div v-for="user in users" :key="user.id">{{ user.name }}</div>
</div>
</template>2. 使用 Axios 拦截器统一处理
拦截器是处理 token 注入、错误统一提示、登录失效跳转的最佳位置,避免在每个请求里重复写。
// utils/request.js
import axios from 'axios';
const request = axios.create({
baseURL: '/api',
timeout: 5000
});
// 请求拦截器:自动带上 token
request.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器:统一解包 + 错误处理
request.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login'; // 登录失效跳转
} else if (error.response?.status >= 500) {
console.error('服务器错误,请稍后重试');
}
return Promise.reject(error);
}
);
export default request;3. 请求取消与竞态处理
快速切换 tab 或翻页时,先发的请求可能后返回,导致显示旧数据(竞态问题)。用 AbortController 取消过期请求。
// composables/useSearch.js
import { ref } from 'vue';
export function useSearch() {
const results = ref([]);
let controller = null;
async function search(keyword) {
// 取消上一次未完成的请求
if (controller) controller.abort();
controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${keyword}`, {
signal: controller.signal
});
results.value = await res.json();
} catch (err) {
if (err.name !== 'AbortError') throw err; // 被取消不算错误
}
}
return { results, search };
}五、代码组织技巧
1. 使用 Composables 组织可复用逻辑
Composable 是 Vue 3 复用逻辑的核心手段,等价于把一段有状态的逻辑打包成"可以被多个组件调用的函数"。
// composables/useDarkMode.js
import { ref, onMounted, onUnmounted } from 'vue';
export function useDarkMode() {
const isDark = ref(false);
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const updateTheme = () => {
isDark.value = mediaQuery.matches;
document.documentElement.classList.toggle('dark', isDark.value);
};
onMounted(() => {
updateTheme();
mediaQuery.addEventListener('change', updateTheme);
});
onUnmounted(() => {
mediaQuery.removeEventListener('change', updateTheme); // 记得清理
});
const toggle = () => {
isDark.value = !isDark.value;
document.documentElement.classList.toggle('dark', isDark.value);
};
return { isDark, toggle };
}再来一个鼠标位置追踪的 Composable:
// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue';
export function useMouse() {
const x = ref(0);
const y = ref(0);
const update = (e) => {
x.value = e.pageX;
y.value = e.pageY;
};
onMounted(() => window.addEventListener('mousemove', update));
onUnmounted(() => window.removeEventListener('mousemove', update));
return { x, y };
}
// 使用:const { x, y } = useMouse();Composable 命名与设计约定:
| 约定 | 说明 |
|------|------|
| use 前缀 | 统一以 useXxx 命名 |
| 返回 ref/reactive | 保持响应式 |
| 内部注册的副作用要清理 | 在 onUnmounted 中移除监听/定时器 |
| 参数支持 ref | 提高灵活性,可响应外部变化 |
2. 使用注释分区组织大型组件
当组件较大时,用统一的注释分区能大幅提升可读性,让人一眼看清各部分职责。
<script setup>
import { ref, computed, onMounted } from 'vue';
// === Props ===
const props = defineProps({ userId: Number });
// === Emits ===
const emit = defineEmits(['update', 'delete']);
// === 状态 ===
const user = ref(null);
const loading = ref(false);
// === 计算属性 ===
const userName = computed(() => user.value?.name || '未知用户');
// === 方法 ===
const loadUser = async () => {
loading.value = true;
// 加载逻辑
loading.value = false;
};
const handleEdit = () => emit('update', user.value);
// === 生命周期 ===
onMounted(loadUser);
</script>3. 统一管理 API 接口
把接口按模块归档,组件里只调用语义化的方法,接口地址变化时只改一处。
// api/user.js
import request from '@/utils/request';
export const userApi = {
getList: (params) => request.get('/users', { params }),
getDetail: (id) => request.get(`/users/${id}`),
create: (data) => request.post('/users', data),
update: (id, data) => request.put(`/users/${id}`, data),
remove: (id) => request.delete(`/users/${id}`)
};
// 组件中:import { userApi } from '@/api/user'; await userApi.getList();六、调试技巧
1. 使用 Vue Devtools
Vue Devtools 是官方浏览器扩展,是排查 Vue 问题的第一工具:
2. 添加调试日志
<script setup>
import { ref, watch } from 'vue';
const count = ref(0);
// immediate 让 watch 立即执行一次,方便观察初始值
watch(
count,
(newVal, oldVal) => {
console.log('[DEBUG] count changed:', { oldVal, newVal });
},
{ immediate: true }
);
</script>3. 使用错误边界捕获组件错误
onErrorCaptured 可以捕获后代组件的错误,避免整个应用白屏,还能上报错误。
<script setup>
import { onErrorCaptured, ref } from 'vue';
const hasError = ref(false);
onErrorCaptured((error, instance, info) => {
console.error('捕获到错误:', error);
console.error('错误来源:', info);
hasError.value = true;
// 上报错误监控平台
// reportError({ error, info });
return false; // 返回 false 阻止错误继续向上传播
});
</script>
<template>
<div v-if="hasError">页面出错了,请刷新重试</div>
<slot v-else />
</template>4. 全局错误处理
// main.js
const app = createApp(App);
app.config.errorHandler = (err, instance, info) => {
console.error('全局错误:', err, info);
// 统一上报
};
// 捕获未处理的 Promise 拒绝
window.addEventListener('unhandledrejection', (event) => {
console.error('未处理的 Promise:', event.reason);
});七、常见坑与避坑指南
| 坑 | 现象 | 解决 |
|----|------|------|
| props 直接修改 | 报警告,父组件不同步 | 用 emit 通知父组件改,或本地 ref 复制 |
| reactive 整体替换 | 失去响应式 | 用 ref,或用 Object.assign 逐项赋值 |
| v-for 用 index 做 key | 增删排序时渲染错乱 | 用数据唯一 id |
| 忘记清理副作用 | 内存泄漏、重复监听 | onUnmounted 里清理定时器/监听 |
| watch 深层对象不触发 | 内部字段变化无响应 | 加 { deep: true } |
| 解构 props 失去响应式 | 值不更新 | 用 toRefs(props) 或直接访问 props.xxx |
八、组件通信进阶实战
组件通信是中大型项目的核心难题。除了基础的 props/emit,实战中还需要掌握跨层级、兄弟组件、全局状态等多种通信方式,并知道在什么场景下选用哪一种。
1. provide / inject 跨层级通信
当组件层级很深(如「页面 → 布局 → 侧边栏 → 菜单项」四五层),一层层透传 props 会非常痛苦,这时用 provide/inject 直接「空投」数据。
<!-- 顶层组件 App.vue -->
<script setup>
import { provide, ref, readonly } from 'vue';
const theme = ref('light');
const user = ref({ name: '张三', role: 'admin' });
// 提供只读数据,防止子组件直接篡改
provide('theme', readonly(theme));
provide('user', readonly(user));
// 提供修改方法(推荐:数据下放、方法下放,子组件通过方法改)
provide('toggleTheme', () => {
theme.value = theme.value === 'light' ? 'dark' : 'light';
});
</script>
<template>
<Layout />
</template><!-- 深层子组件 MenuItem.vue(可能嵌套 4 层以上) -->
<script setup>
import { inject } from 'vue';
// 第二个参数是默认值,避免 provide 缺失时报 undefined
const theme = inject('theme', ref('light'));
const user = inject('user');
const toggleTheme = inject('toggleTheme', () => {});
</script>
<template>
<div :class="theme">
<span>当前用户:{{ user.name }}</span>
<button @click="toggleTheme">切换主题</button>
</div>
</template>类型安全的 provide/inject(TypeScript): 使用 InjectionKey 让类型自动推断,避免字符串 key 拼写错误。
// keys.ts
import type { InjectionKey, Ref } from 'vue';
export interface UserInfo {
name: string;
role: 'admin' | 'user' | 'guest';
}
// InjectionKey 携带类型信息
export const userKey: InjectionKey<Ref<UserInfo>> = Symbol('user');
export const toggleThemeKey: InjectionKey<() => void> = Symbol('toggleTheme');// 提供方
import { provide, ref } from 'vue';
import { userKey } from './keys';
const user = ref<UserInfo>({ name: '张三', role: 'admin' });
provide(userKey, user); // 类型被检查
// 注入方
import { inject } from 'vue';
import { userKey } from './keys';
const user = inject(userKey); // 类型自动推断为 Ref<UserInfo> | undefined
console.log(user?.value.name); // 有完整类型提示2. mitt 事件总线处理兄弟组件通信
Vue 3 移除了 `$on/$off/$emit` 实例事件 API,官方推荐使用第三方库 mitt(体积仅约 200 字节)实现跨组件事件通信。适合「无直接关系的组件之间偶发通信」,如全局通知、跨页面刷新。
// utils/eventBus.js
import mitt from 'mitt';
// 创建全局单例事件总线
const emitter = mitt();
export default emitter;<!-- 组件 A:发送事件 -->
<script setup>
import emitter from '@/utils/eventBus';
function notify() {
emitter.emit('user-updated', { id: 1, name: '李四' });
}
</script>
<template>
<button @click="notify">更新用户</button>
</template><!-- 组件 B:监听事件(务必在卸载时移除,防止内存泄漏) -->
<script setup>
import { onMounted, onUnmounted } from 'vue';
import emitter from '@/utils/eventBus';
function handleUserUpdated(payload) {
console.log('用户已更新:', payload.name);
}
onMounted(() => {
emitter.on('user-updated', handleUserUpdated);
});
onUnmounted(() => {
// 关键:不移除会导致组件重复注册、内存泄漏
emitter.off('user-updated', handleUserUpdated);
});
</script>封装成 Composable 自动清理: 把「监听 + 卸载移除」封装起来,避免每次手写 onUnmounted。
// composables/useEventBus.js
import { onUnmounted } from 'vue';
import emitter from '@/utils/eventBus';
export function useEventBus(event, handler) {
emitter.on(event, handler);
// 组件卸载时自动移除
onUnmounted(() => emitter.off(event, handler));
}
// 使用:一行搞定,无需手写清理
// useEventBus('user-updated', (payload) => console.log(payload));3. defineModel 实现多个 v-model
Vue 3.4+ 的 defineModel 让自定义组件支持多个双向绑定,写法比手写 props + emit 简洁 80%。
<!-- 自定义组件 UserForm.vue:支持 v-model:name 和 v-model:age -->
<script setup>
// 每个 defineModel 对应一个 v-model 绑定
const name = defineModel('name', { type: String, default: '' });
const age = defineModel('age', { type: Number, default: 0 });
</script>
<template>
<input v-model="name" placeholder="姓名" />
<input v-model.number="age" type="number" placeholder="年龄" />
</template><!-- 父组件使用 -->
<script setup>
import { ref } from 'vue';
import UserForm from './UserForm.vue';
const userName = ref('张三');
const userAge = ref(25);
</script>
<template>
<UserForm v-model:name="userName" v-model:age="userAge" />
<p>姓名:{{ userName }},年龄:{{ userAge }}</p>
</template>4. 组件通信方式选型对比
| 通信方式 | 适用场景 | 优点 | 缺点 |
|----------|----------|------|------|
| props / emit | 父子直接通信 | 关系清晰、类型安全 | 深层透传繁琐 |
| provide / inject | 跨多层级通信 | 免透传、灵活 | 数据来源不明确、难追踪 |
| mitt 事件总线 | 无关系组件偶发通信 | 解耦彻底 | 事件散乱、需手动清理 |
| Pinia / Vuex | 全局共享状态 | 集中管理、可调试 | 引入额外依赖 |
| ref / expose | 父调用子方法 | 直接操作子实例 | 破坏封装、耦合高 |
| v-model | 表单类双向绑定 | 语法简洁 | 仅适合双向数据 |
选型口诀: 父子优先 props/emit;跨层用 provide/inject;全局共享上 Pinia;临时广播用 mitt;能不用事件总线就不用(难维护)。
九、Vue Router 路由实战技巧
路由是单页应用的骨架。掌握守卫、懒加载、动态路由、滚动行为等技巧,能显著提升应用的体验和性能。
1. 路由懒加载与代码分割
默认所有组件打包进一个 chunk,首屏会很慢。用动态 import 让路由按需加载,配合 Vite 的自动代码分割,首屏体积可减少 50%~70%。
// router/index.js
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{
path: '/',
// 首页通常用同步引入,保证首屏快速渲染
component: () => import('@/views/Home.vue')
},
{
path: '/dashboard',
// webpackChunkName 注释可自定义分包名(Vite 下用魔法注释也生效)
component: () => import(/* webpackChunkName: "dashboard" */ '@/views/Dashboard.vue')
},
{
path: '/settings',
component: () => import('@/views/Settings.vue')
}
];
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 }; // 默认回到顶部
}
});
export default router;2. 全局前置守卫做权限控制
登录校验、权限过滤、动态标题都在导航守卫里统一处理,避免每个页面重复写。
// router/guards.js
import { useUserStore } from '@/stores/user';
export function setupRouterGuards(router) {
// 白名单:无需登录即可访问
const whiteList = ['/login', '/register', '/404'];
router.beforeEach(async (to, from) => {
const userStore = useUserStore();
const hasToken = !!userStore.token;
// 设置页面标题
document.title = to.meta.title ? `${to.meta.title} - 管理后台` : '管理后台';
// 白名单直接放行
if (whiteList.includes(to.path)) {
return true;
}
// 未登录跳转登录页,并记录来源便于登录后跳回
if (!hasToken) {
return { path: '/login', query: { redirect: to.fullPath } };
}
// 已登录但没拉取过用户信息,先拉取权限
if (!userStore.roles.length) {
try {
await userStore.fetchUserInfo();
} catch {
userStore.logout();
return { path: '/login' };
}
}
// 校验页面所需角色
const requiredRoles = to.meta.roles;
if (requiredRoles && !requiredRoles.some((r) => userStore.roles.includes(r))) {
return { path: '/403' }; // 无权限
}
return true;
});
// 全局后置钩子:常用于关闭 loading 进度条
router.afterEach(() => {
// NProgress.done();
});
}3. 动态路由:按权限动态添加
后台系统通常根据用户角色动态生成菜单和路由,未授权的路由根本不注册,从源头杜绝越权访问。
// router/dynamic.js
import { asyncRoutes, constantRoutes } from './routes';
// 判断某条路由当前角色是否有权限
function hasPermission(roles, route) {
if (route.meta?.roles) {
return roles.some((role) => route.meta.roles.includes(role));
}
return true; // 未配置 roles 视为公开
}
// 递归过滤出有权限的路由
export function filterAsyncRoutes(routes, roles) {
const res = [];
routes.forEach((route) => {
const tmp = { ...route };
if (hasPermission(roles, tmp)) {
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, roles);
}
res.push(tmp);
}
});
return res;
}
// 登录后动态挂载
export function addDynamicRoutes(router, roles) {
const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles);
accessedRoutes.forEach((route) => {
router.addRoute(route); // 动态添加
});
return accessedRoutes; // 返回用于生成菜单
}4. 面包屑导航自动生成
利用路由的 matched 数组,自动生成面包屑,无需在每个页面手写。
<!-- components/Breadcrumb.vue -->
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
// matched 包含从根到当前的所有匹配路由记录
const breadcrumbs = computed(() =>
route.matched
.filter((item) => item.meta && item.meta.title)
.map((item) => ({
title: item.meta.title,
path: item.path
}))
);
</script>
<template>
<nav class="breadcrumb">
<span v-for="(item, index) in breadcrumbs" :key="item.path">
<router-link v-if="index < breadcrumbs.length - 1" :to="item.path">
{{ item.title }}
</router-link>
<span v-else>{{ item.title }}</span>
<span v-if="index < breadcrumbs.length - 1" class="separator">/</span>
</span>
</nav>
</template>5. 路由过渡动画
给 router-view 加过渡效果,让页面切换更流畅。
<script setup>
import { useRoute } from 'vue-router';
const route = useRoute();
</script>
<template>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<!-- key 用 route.path 保证路由变化时触发过渡 -->
<component :is="Component" :key="route.path" />
</transition>
</router-view>
</template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>十、可复用 Composables 实战合集
Composables(组合式函数)是 Vue 3 逻辑复用的核心。下面是一套生产级的常用 Composable,可直接搬进项目。判断一段逻辑是否该抽 Composable 的标准:跨 2 个及以上组件复用、或单个组件里逻辑超过 30 行且职责独立。
1. useLocalStorage:响应式本地存储
把 ref 和 localStorage 双向绑定,刷新页面数据不丢,且改 ref 自动写入存储。
// composables/useLocalStorage.js
import { ref, watch } from 'vue';
export function useLocalStorage(key, defaultValue) {
// 初始化:优先读取已有存储
const stored = localStorage.getItem(key);
const data = ref(stored ? JSON.parse(stored) : defaultValue);
// 监听变化,深度同步到 localStorage
watch(
data,
(newValue) => {
if (newValue === null || newValue === undefined) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, JSON.stringify(newValue));
}
},
{ deep: true }
);
return data;
}
// 使用:像普通 ref 一样用,自动持久化
// const settings = useLocalStorage('app-settings', { theme: 'light', lang: 'zh' });
// settings.value.theme = 'dark'; // 自动写入 localStorage2. useDebounce 与 useThrottle:防抖节流
搜索框、滚动、resize 等高频场景的性能利器。
// composables/useDebounce.js
import { ref, watch } from 'vue';
// 防抖:值稳定 delay 毫秒后才更新
export function useDebounce(value, delay = 300) {
const debounced = ref(value.value);
let timer = null;
watch(value, (newVal) => {
clearTimeout(timer);
timer = setTimeout(() => {
debounced.value = newVal;
}, delay);
});
return debounced;
}
// 防抖函数版本(用于事件处理)
export function useDebounceFn(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}// composables/useThrottle.js
// 节流:每 delay 毫秒最多执行一次
export function useThrottleFn(fn, delay = 300) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime >= delay) {
lastTime = now;
fn.apply(this, args);
}
};
}<!-- 使用示例:搜索框防抖 -->
<script setup>
import { ref, watch } from 'vue';
import { useDebounce } from '@/composables/useDebounce';
const keyword = ref('');
const debouncedKeyword = useDebounce(keyword, 500);
// 只有用户停止输入 500ms 后才发请求,减少 90% 无效请求
watch(debouncedKeyword, (kw) => {
if (kw) fetchSearchResults(kw);
});
</script>3. useEventListener:自动清理的事件监听
手动 addEventListener 容易忘记 removeEventListener,封装后自动在卸载时清理。
// composables/useEventListener.js
import { onMounted, onUnmounted } from 'vue';
export function useEventListener(target, event, handler, options) {
onMounted(() => {
const el = typeof target === 'function' ? target() : target;
el?.addEventListener(event, handler, options);
});
onUnmounted(() => {
const el = typeof target === 'function' ? target() : target;
el?.removeEventListener(event, handler, options);
});
}
// 使用:监听 window 滚动,卸载时自动移除
// useEventListener(window, 'scroll', () => console.log(window.scrollY));4. useWindowSize:响应式窗口尺寸
// composables/useWindowSize.js
import { ref } from 'vue';
import { useEventListener } from './useEventListener';
export function useWindowSize() {
const width = ref(window.innerWidth);
const height = ref(window.innerHeight);
useEventListener(window, 'resize', () => {
width.value = window.innerWidth;
height.value = window.innerHeight;
});
return { width, height };
}
// 配合 computed 做响应式布局判断
// const { width } = useWindowSize();
// const isMobile = computed(() => width.value < 768);5. useIntersectionObserver:元素可见性检测
用于图片懒加载、无限滚动、曝光埋点等场景,基于原生 IntersectionObserver。
// composables/useIntersectionObserver.js
import { ref, watch, onUnmounted } from 'vue';
export function useIntersectionObserver(target, callback, options = {}) {
const isIntersecting = ref(false);
let observer = null;
const stop = () => {
if (observer) {
observer.disconnect();
observer = null;
}
};
watch(
target,
(el) => {
stop();
if (!el) return;
observer = new IntersectionObserver(([entry]) => {
isIntersecting.value = entry.isIntersecting;
callback(entry);
}, options);
observer.observe(el);
},
{ immediate: true, flush: 'post' }
);
onUnmounted(stop);
return { isIntersecting, stop };
}<!-- 图片懒加载示例 -->
<script setup>
import { ref } from 'vue';
import { useIntersectionObserver } from '@/composables/useIntersectionObserver';
const imgRef = ref(null);
const loaded = ref(false);
const src = ref('');
const { stop } = useIntersectionObserver(imgRef, (entry) => {
if (entry.isIntersecting) {
src.value = 'https://example.com/real-image.jpg';
loaded.value = true;
stop(); // 加载一次后停止观察
}
});
</script>
<template>
<img ref="imgRef" :src="src" :class="{ loaded }" alt="懒加载图片" />
</template>6. useToggle:布尔状态切换
极简却高频,弹窗、折叠面板、开关都用得上。
// composables/useToggle.js
import { ref } from 'vue';
export function useToggle(initial = false) {
const state = ref(initial);
const toggle = (value) => {
state.value = typeof value === 'boolean' ? value : !state.value;
};
return [state, toggle];
}
// 使用:const [visible, toggleVisible] = useToggle();
// toggleVisible(); // 取反
// toggleVisible(true); // 强制设为 true7. usePagination:分页逻辑封装
把分页的页码、每页条数、总数、总页数、翻页方法全部封装,列表页直接复用。
// composables/usePagination.js
import { ref, computed } from 'vue';
export function usePagination(fetchFn, { pageSize = 10 } = {}) {
const currentPage = ref(1);
const total = ref(0);
const list = ref([]);
const loading = ref(false);
const totalPages = computed(() => Math.ceil(total.value / pageSize));
const hasNext = computed(() => currentPage.value < totalPages.value);
const hasPrev = computed(() => currentPage.value > 1);
async function load() {
loading.value = true;
try {
const res = await fetchFn({ page: currentPage.value, pageSize });
list.value = res.data;
total.value = res.total;
} finally {
loading.value = false;
}
}
function goToPage(page) {
if (page < 1 || page > totalPages.value) return;
currentPage.value = page;
load();
}
const next = () => hasNext.value && goToPage(currentPage.value + 1);
const prev = () => hasPrev.value && goToPage(currentPage.value - 1);
return {
currentPage, total, list, loading, totalPages,
hasNext, hasPrev, load, goToPage, next, prev
};
}8. useClipboard:剪贴板复制
// composables/useClipboard.js
import { ref } from 'vue';
export function useClipboard() {
const copied = ref(false);
async function copy(text) {
try {
await navigator.clipboard.writeText(text);
copied.value = true;
setTimeout(() => (copied.value = false), 2000);
return true;
} catch {
// 降级方案:兼容不支持 Clipboard API 的旧浏览器
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
copied.value = true;
setTimeout(() => (copied.value = false), 2000);
return true;
}
}
return { copied, copy };
}9. Composables 命名与设计规范
| 规范 | 说明 | 示例 |
|------|------|------|
| use 前缀 | 所有 Composable 以 use 开头 | useFetch、useToggle |
| 返回 ref/reactive | 返回响应式数据供组件解构 | return { data, loading } |
| 副作用自清理 | 内部注册的监听/定时器要在 onUnmounted 清理 | useEventListener |
| 参数支持 ref | 入参可接受 ref,用 watch 响应变化 | useDebounce(refValue) |
| 单一职责 | 一个 Composable 只做一件事 | 不要 useEverything |
| 可组合 | Composable 内部可调用其他 Composable | useWindowSize 用 useEventListener |
十一、TypeScript 实战技巧
在 Vue 3 项目里用好 TypeScript 能拦截约 15% 的运行时错误于编译期,并极大提升重构信心和 IDE 提示体验。
1. defineProps 与 defineEmits 的类型定义
<script setup lang="ts">
// 用泛型定义 props 类型(推荐,比运行时声明更简洁)
interface Props {
title: string;
count?: number; // 可选
items: Array<{ id: number; name: string }>;
status: 'pending' | 'success' | 'error'; // 联合类型
}
// withDefaults 提供默认值
const props = withDefaults(defineProps<Props>(), {
count: 0,
items: () => [] // 引用类型默认值用函数返回
});
// 用类型定义 emits,参数被完整校验
const emit = defineEmits<{
(e: 'update', value: string): void;
(e: 'delete', id: number): void;
(e: 'change', payload: { key: string; value: unknown }): void;
}>();
// 调用时类型被检查:emit('delete', '1') 会报错,因为需要 number
function handleDelete(id: number) {
emit('delete', id);
}
</script>2. ref 与 reactive 的类型标注
import { ref, reactive, computed } from 'vue';
import type { Ref } from 'vue';
// 基础类型自动推断,无需标注
const count = ref(0); // Ref<number>
const name = ref('张三'); // Ref<string>
// 复杂类型显式标注
interface User {
id: number;
name: string;
roles: string[];
}
const user = ref<User | null>(null); // 初始为 null
const users = ref<User[]>([]); // 数组
const userMap = reactive<Map<number, User>>(new Map());
// computed 类型自动推断,也可显式标注
const userCount = computed<number>(() => users.value.length);
// 显式声明 Ref 类型(用于函数参数)
function processUser(userRef: Ref<User>) {
console.log(userRef.value.name);
}3. 通用 API 响应类型封装
// types/api.ts
// 统一的后端响应结构
export interface ApiResponse<T = unknown> {
code: number;
message: string;
data: T;
}
// 分页响应
export interface PageResult<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
// 使用泛型让请求返回值带完整类型
import request from '@/utils/request';
export function getUserList(params: { page: number }) {
return request.get<ApiResponse<PageResult<User>>>('/users', { params });
}
// 调用处 res.data.data.list 有完整类型提示4. 组件 ref 与 expose 的类型
<!-- 子组件 ChildForm.vue -->
<script setup lang="ts">
import { ref } from 'vue';
const formData = ref({ name: '', email: '' });
function validate(): boolean {
return formData.value.name.length > 0;
}
function reset(): void {
formData.value = { name: '', email: '' };
}
// 暴露方法给父组件
defineExpose({ validate, reset });
</script><!-- 父组件:为子组件 ref 标注类型 -->
<script setup lang="ts">
import { ref } from 'vue';
import ChildForm from './ChildForm.vue';
// InstanceType 提取组件实例类型
const formRef = ref<InstanceType<typeof ChildForm> | null>(null);
function submit() {
// 调用子组件暴露的方法,有完整类型提示
if (formRef.value?.validate()) {
console.log('校验通过');
} else {
formRef.value?.reset();
}
}
</script>
<template>
<ChildForm ref="formRef" />
<button @click="submit">提交</button>
</template>十二、单元测试实战(Vitest + Vue Test Utils)
测试是保证重构不出错、团队协作可靠的基石。Vitest 是 Vite 生态的测试框架,速度比 Jest 快 3~5 倍,配置几乎为零。
1. 基础配置
// vitest.config.js
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
globals: true, // 无需 import describe/it/expect
environment: 'jsdom', // 模拟浏览器 DOM 环境
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
thresholds: { lines: 80, functions: 80 } // 覆盖率门槛
}
}
});2. 测试组件渲染与交互
// Counter.spec.js
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Counter from '@/components/Counter.vue';
describe('Counter 组件', () => {
it('初始渲染显示默认计数', () => {
const wrapper = mount(Counter, {
props: { initial: 5 }
});
expect(wrapper.text()).toContain('计数:5');
});
it('点击按钮计数加一', async () => {
const wrapper = mount(Counter, { props: { initial: 0 } });
await wrapper.find('button.increment').trigger('click');
expect(wrapper.text()).toContain('计数:1');
});
it('触发 change 事件并携带正确参数', async () => {
const wrapper = mount(Counter);
await wrapper.find('button.increment').trigger('click');
// 断言事件被触发
expect(wrapper.emitted('change')).toBeTruthy();
// 断言事件参数
expect(wrapper.emitted('change')[0]).toEqual([1]);
});
});3. 测试 Composable
Composable 是纯逻辑,可脱离组件直接测试。
// useCounter.spec.js
import { describe, it, expect } from 'vitest';
import { useCounter } from '@/composables/useCounter';
describe('useCounter', () => {
it('increment 增加计数', () => {
const { count, increment } = useCounter(0);
expect(count.value).toBe(0);
increment();
expect(count.value).toBe(1);
});
it('reset 重置为初始值', () => {
const { count, increment, reset } = useCounter(10);
increment();
increment();
expect(count.value).toBe(12);
reset();
expect(count.value).toBe(10);
});
});4. Mock 网络请求
// UserList.spec.js
import { describe, it, expect, vi } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import UserList from '@/components/UserList.vue';
import * as api from '@/api/user';
describe('UserList', () => {
it('加载并渲染用户列表', async () => {
// Mock API 返回值
vi.spyOn(api, 'getUserList').mockResolvedValue({
data: [{ id: 1, name: '张三' }, { id: 2, name: '李四' }]
});
const wrapper = mount(UserList);
await flushPromises(); // 等待所有 Promise 完成
expect(wrapper.findAll('.user-item')).toHaveLength(2);
expect(wrapper.text()).toContain('张三');
});
});十三、Vite 构建与部署优化
构建优化直接影响首屏速度和用户留存。数据表明首屏每慢 1 秒,转化率平均下降约 7%。以下是生产项目验证过的优化手段。
1. 环境变量与多环境配置
# .env.development
VITE_API_BASE=/api
VITE_APP_TITLE=管理后台(开发)
# .env.production
VITE_API_BASE=https://api.example.com
VITE_APP_TITLE=管理后台// 类型提示:env.d.ts
interface ImportMetaEnv {
readonly VITE_API_BASE: string;
readonly VITE_APP_TITLE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
// 使用(只有 VITE_ 前缀的变量才会暴露给客户端)
console.log(import.meta.env.VITE_API_BASE);2. 生产构建优化配置
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: { '@': resolve(__dirname, 'src') }
},
build: {
// 手动分包:把大依赖拆成独立 chunk,利用浏览器缓存
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
echarts: ['echarts'],
utils: ['lodash-es', 'dayjs']
},
// 静态资源分类存放
chunkFileNames: 'js/[name]-[hash].js',
entryFileNames: 'js/[name]-[hash].js',
assetFileNames: '[ext]/[name]-[hash].[ext]'
}
},
// 生产环境移除 console 和 debugger
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
},
// 大于 500KB 的 chunk 会警告
chunkSizeWarningLimit: 500
}
});3. 依赖体积分析
// 用 rollup-plugin-visualizer 分析产物体积
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
vue(),
visualizer({ open: true, gzipSize: true, brotliSize: true })
]
});
// 常见优化:lodash 换 lodash-es 按需引入、moment 换 dayjs(体积从 67KB 降到 2KB)4. 构建优化效果对比
| 优化项 | 优化前 | 优化后 | 提升 |
|--------|--------|--------|------|
| 首屏 JS 体积 | 1.8 MB | 620 KB | 减少 66% |
| moment → dayjs | 67 KB | 2 KB | 减少 97% |
| 路由懒加载 | 单 chunk | 按需加载 | 首屏减少 50% |
| gzip 压缩 | 620 KB | 180 KB | 减少 71% |
| 首屏可交互时间 | 4.2s | 1.5s | 提升 64% |
十四、真实项目案例:后台管理系统技巧集锦
以一个典型的中后台管理系统为例,串联本文各项技巧。
1. 全局请求 loading 与错误统一提示
// utils/request.js(在拦截器基础上加全局 loading 计数)
import axios from 'axios';
let loadingCount = 0;
function showLoading() {
if (loadingCount === 0) {
// 显示全局 loading,如 NProgress.start()
}
loadingCount++;
}
function hideLoading() {
loadingCount--;
if (loadingCount <= 0) {
loadingCount = 0;
// 隐藏全局 loading,如 NProgress.done()
}
}
const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 10000 });
request.interceptors.request.use((config) => {
if (config.showLoading !== false) showLoading();
return config;
});
request.interceptors.response.use(
(response) => {
hideLoading();
const { code, message, data } = response.data;
if (code !== 0) {
// 统一业务错误提示,如 ElMessage.error(message)
return Promise.reject(new Error(message));
}
return data;
},
(error) => {
hideLoading();
return Promise.reject(error);
}
);
export default request;2. 权限指令 v-permission
细粒度按钮级权限控制,用自定义指令实现。
// directives/permission.js
import { useUserStore } from '@/stores/user';
export const permission = {
mounted(el, binding) {
const { value } = binding; // 如 v-permission="'user:delete'"
const userStore = useUserStore();
const permissions = userStore.permissions;
if (value && !permissions.includes(value)) {
// 无权限则移除元素
el.parentNode?.removeChild(el);
}
}
};
// main.js 注册:app.directive('permission', permission);
// 使用:<button v-permission="'user:delete'">删除</button>3. 表格列表页通用模式
<script setup>
import { onMounted, reactive } from 'vue';
import { usePagination } from '@/composables/usePagination';
import { useDebounceFn } from '@/composables/useDebounce';
import { getUserList } from '@/api/user';
// 查询条件
const query = reactive({ keyword: '', status: '' });
// 分页封装
const { list, loading, currentPage, total, load, goToPage } = usePagination(
(params) => getUserList({ ...params, ...query })
);
// 搜索防抖
const handleSearch = useDebounceFn(() => {
currentPage.value = 1;
load();
}, 500);
onMounted(load);
</script>
<template>
<div class="list-page">
<div class="filter-bar">
<input v-model="query.keyword" @input="handleSearch" placeholder="搜索关键词" />
<select v-model="query.status" @change="handleSearch">
<option value="">全部</option>
<option value="active">启用</option>
<option value="disabled">禁用</option>
</select>
</div>
<table v-loading="loading">
<tr v-for="item in list" :key="item.id">
<td>{{ item.name }}</td>
<td>
<button v-permission="'user:delete'">删除</button>
</td>
</tr>
</table>
<div class="pagination">
<button :disabled="currentPage === 1" @click="goToPage(currentPage - 1)">上一页</button>
<span>第 {{ currentPage }} 页 / 共 {{ total }} 条</span>
<button @click="goToPage(currentPage + 1)">下一页</button>
</div>
</div>
</template>十五、技巧应用场景速查表
| 场景 | 推荐技巧 | 关键 API |
|------|----------|----------|
| 深层组件传值 | provide/inject | provide、inject、InjectionKey |
| 无关组件通信 | mitt 事件总线 | emitter.on/emit/off |
| 搜索框优化 | 防抖 | useDebounce |
| 滚动/resize 优化 | 节流 | useThrottleFn |
| 图片懒加载 | 交叉观察 | useIntersectionObserver |
| 数据持久化 | 本地存储封装 | useLocalStorage |
| 列表分页 | 分页封装 | usePagination |
| 首屏加速 | 路由懒加载 | import() |
| 权限控制 | 路由守卫 + 指令 | beforeEach、v-permission |
| 类型安全 | TypeScript 泛型 | defineProps、Ref、InstanceType |
| 逻辑复用 | Composables | useXxx |
| 质量保证 | 单元测试 | Vitest、mount |
十六、国际化与可访问性技巧
面向国际用户或需要合规的项目,i18n 和 a11y 是必备能力。
1. Vue I18n 国际化
// i18n/index.js
import { createI18n } from 'vue-i18n';
const messages = {
'zh-CN': {
nav: { home: '首页', about: '关于' },
// 支持参数插值和复数
welcome: '欢迎,{name}!',
items: '共 {count} 项'
},
'en-US': {
nav: { home: 'Home', about: 'About' },
welcome: 'Welcome, {name}!',
items: 'Total {count} items'
}
};
export const i18n = createI18n({
legacy: false, // 使用 Composition API 模式
locale: localStorage.getItem('lang') || 'zh-CN',
fallbackLocale: 'en-US', // 找不到翻译时的兜底语言
messages
});<script setup>
import { useI18n } from 'vue-i18n';
const { t, locale } = useI18n();
function switchLang(lang) {
locale.value = lang;
localStorage.setItem('lang', lang);
}
</script>
<template>
<nav>
<a>{{ t('nav.home') }}</a>
<!-- 带参数插值 -->
<p>{{ t('welcome', { name: '张三' }) }}</p>
<button @click="switchLang('en-US')">English</button>
<button @click="switchLang('zh-CN')">中文</button>
</nav>
</template>2. 可访问性(a11y)关键实践
| 实践 | 做法 | 收益 |
|------|------|------|
| 语义化标签 | 用 nav/main/article 代替 div | 屏幕阅读器可识别结构 |
| 图片替代文本 | img 必须带 alt | 无障碍 + SEO |
| 表单关联 | label 用 for 关联 input | 点击标签聚焦输入框 |
| 键盘可达 | 交互元素支持 Tab 和 Enter | 无鼠标可操作 |
| ARIA 属性 | 动态内容加 aria-live | 状态变化被朗读 |
| 焦点管理 | 弹窗打开时聚焦、关闭时归还 | 键盘用户不迷路 |
<!-- 可访问的弹窗示例 -->
<script setup>
import { ref, watch, nextTick } from 'vue';
const props = defineProps({ visible: Boolean });
const dialogRef = ref(null);
watch(() => props.visible, async (val) => {
if (val) {
await nextTick();
dialogRef.value?.focus(); // 打开时聚焦弹窗
}
});
</script>
<template>
<div
v-if="visible"
ref="dialogRef"
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
tabindex="-1"
@keydown.esc="$emit('close')"
>
<h2 id="dialog-title">对话框标题</h2>
<slot />
<button @click="$emit('close')" aria-label="关闭对话框">×</button>
</div>
</template>十七、状态管理与 Pinia 结合实战
在实战技巧维度,Pinia 常与 Composable 配合,形成「全局状态 + 局部逻辑」的清晰分层。
// stores/user.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { login as loginApi, getUserInfo } from '@/api/auth';
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '');
const userInfo = ref(null);
const roles = ref([]);
const permissions = ref([]);
const isLoggedIn = computed(() => !!token.value);
async function login(credentials) {
const res = await loginApi(credentials);
token.value = res.token;
localStorage.setItem('token', res.token);
await fetchUserInfo();
}
async function fetchUserInfo() {
const info = await getUserInfo();
userInfo.value = info;
roles.value = info.roles;
permissions.value = info.permissions;
}
function logout() {
token.value = '';
userInfo.value = null;
roles.value = [];
permissions.value = [];
localStorage.removeItem('token');
}
return { token, userInfo, roles, permissions, isLoggedIn, login, fetchUserInfo, logout };
});分层原则: 全局共享、跨页面持久的状态放 Pinia(用户信息、权限、主题);仅当前组件或页面用的临时状态放 Composable(表单、分页、弹窗开关)。切忌把所有状态一股脑塞进 Pinia,否则 store 会臃肿难维护。
十八、生产环境错误监控与埋点
上线后「看不见」的错误最危险。建立全局错误捕获 + 上报机制,能在用户反馈前就发现问题。据统计,主动监控可将线上问题的平均发现时间从数小时缩短到数分钟。
1. Vue 全局错误处理
// main.js
import { createApp } from 'vue';
import App from './App.vue';
const app = createApp(App);
// 捕获组件渲染和 watcher 中的错误
app.config.errorHandler = (err, instance, info) => {
console.error('Vue Error:', err);
console.error('出错组件:', instance?.$options.name);
console.error('错误来源:', info); // 如 'render'、'setup'
reportError({
type: 'vue',
message: err.message,
stack: err.stack,
component: instance?.$options.name,
info
});
};
// 捕获未处理的 Promise rejection
window.addEventListener('unhandledrejection', (event) => {
reportError({ type: 'promise', message: event.reason?.message, stack: event.reason?.stack });
});
// 捕获全局 JS 错误和资源加载错误
window.addEventListener('error', (event) => {
if (event.target !== window) {
// 资源加载错误(图片、脚本等)
reportError({ type: 'resource', url: event.target.src || event.target.href });
} else {
reportError({ type: 'js', message: event.message, stack: event.error?.stack });
}
}, true);
app.mount('#app');2. 错误上报封装
// utils/report.js
const reportQueue = [];
let reportTimer = null;
export function reportError(error) {
const payload = {
...error,
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: Date.now(),
// 可附加用户信息、版本号等上下文
appVersion: import.meta.env.VITE_APP_VERSION
};
reportQueue.push(payload);
// 批量上报:攒够 5 条或 3 秒后统一发送,减少请求数
if (reportQueue.length >= 5) {
flushReports();
} else {
clearTimeout(reportTimer);
reportTimer = setTimeout(flushReports, 3000);
}
}
function flushReports() {
if (reportQueue.length === 0) return;
const data = reportQueue.splice(0, reportQueue.length);
// 优先用 sendBeacon(页面卸载时也能发送成功)
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/log/error', JSON.stringify(data));
} else {
fetch('/api/log/error', {
method: 'POST',
body: JSON.stringify(data),
keepalive: true
});
}
}
// 页面卸载前把剩余日志发出去
window.addEventListener('beforeunload', flushReports);3. 简易埋点封装
// utils/track.js
export function track(event, params = {}) {
const data = {
event, // 事件名,如 'button_click'
params, // 事件参数
page: window.location.pathname,
timestamp: Date.now()
};
navigator.sendBeacon?.('/api/log/track', JSON.stringify(data));
}
// 封装成指令,声明式埋点
export const track_directive = {
mounted(el, binding) {
el.addEventListener('click', () => {
track(binding.value.event, binding.value.params);
});
}
};
// 使用:<button v-track="{ event: 'submit_order', params: { id: 1 } }">下单</button>4. 监控指标对比
| 指标 | 无监控 | 有监控 | 说明 |
|------|--------|--------|------|
| 问题发现时间 | 数小时(靠用户反馈) | 数分钟(主动告警) | 大幅缩短 |
| 错误定位效率 | 难复现 | 有堆栈+上下文 | 快速定位 |
| 覆盖错误类型 | 仅显性崩溃 | JS/Promise/资源/接口 | 全面 |
| 性能可观测 | 无 | LCP/FID/CLS 可采集 | 数据驱动优化 |
十九、可复用组件封装技巧
封装一个「好用」的基础组件,要同时兼顾灵活性、可扩展性和 API 的直观性。
1. 透传 attrs 让组件更灵活
<!-- BaseInput.vue:包装原生 input,同时保留所有原生属性 -->
<script setup>
// 关闭默认的根元素 attrs 继承,手动控制透传目标
defineOptions({ inheritAttrs: false });
const model = defineModel();
defineProps({ label: String });
</script>
<template>
<div class="form-item">
<label v-if="label">{{ label }}</label>
<!-- v-bind="$attrs" 把 placeholder/maxlength/disabled 等原生属性透传给 input -->
<input v-model="model" v-bind="$attrs" />
</div>
</template>2. 用具名插槽 + 作用域插槽提升扩展性
<!-- BaseCard.vue -->
<template>
<div class="card">
<header v-if="$slots.header" class="card-header">
<slot name="header" />
</header>
<div class="card-body">
<!-- 作用域插槽:把内部数据暴露给使用方 -->
<slot :loading="loading" :data="data" />
</div>
<footer v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</footer>
</div>
</template><!-- 使用方按需定制各部分 -->
<template>
<BaseCard>
<template #header>自定义标题</template>
<template #default="{ loading, data }">
<span v-if="loading">加载中...</span>
<span v-else>{{ data }}</span>
</template>
<template #footer>底部操作区</template>
</BaseCard>
</template>3. 组件封装的 API 设计原则
| 原则 | 说明 |
|------|------|
| props 保持扁平 | 避免深层嵌套对象,方便 v-model 和类型提示 |
| 事件语义化 | emit 名用动词,如 change、confirm、close |
| 提供插槽兜底 | slot 加默认内容,未传时也能用 |
| 透传原生属性 | v-bind="$attrs" 保留原生能力 |
| 受控/非受控兼容 | 既支持 v-model,也支持内部自管理 |
| 合理默认值 | 常用场景零配置即可用 |
封装口诀: 常用场景开箱即用,特殊场景留好扩展口子(props、slot、event 三件套)。
二十、最佳实践总结
| 维度 | 核心技巧 |
|------|----------|
| 组件设计 | 智能/展示组件分离、$attrs 透传、defineModel |
| 组件通信 | props/emit、provide/inject、mitt、Pinia 分场景选型 |
| 路由实战 | 懒加载、导航守卫权限、动态路由、面包屑 |
| 性能优化 | shallowRef、markRaw、v-memo、Object.freeze |
| 表单处理 | useForm 封装、v-model 修饰符、防抖 |
| API 请求 | useFetch 封装、Axios 拦截器、请求取消 |
| 代码组织 | Composables 复用、注释分区、API 统一管理 |
| 逻辑复用 | useLocalStorage/useDebounce/usePagination 等 |
| 类型安全 | 全面使用 TypeScript 定义类型 |
| 测试保障 | Vitest + Vue Test Utils 覆盖组件与 Composable |
| 构建部署 | Vite 分包、环境变量、依赖体积分析 |
| 错误处理 | 错误边界 + 全局错误处理 + 监控上报 |
| 国际化/无障碍 | Vue I18n + 语义化标签 + ARIA |
| 调试 | Vue Devtools + 结构化日志 |
写好 Vue 代码的八字箴言:拆分、复用、解耦、清理。
进阶四要素:类型、测试、监控、性能。
把这些技巧内化为习惯,就能写出既高效又易维护、经得起团队协作和长期迭代考验的 Vue 代码。