Vue 实战技巧与最佳实践

中等 🟡Vue 生态
6 个标签
预计阅读时间:106 分钟
Vue实战技巧最佳实践代码规范ComposablesTypeScript

Vue 实战技巧与最佳实践

本文总结了 Vue 开发中的实用技巧、常见问题的解决方案和最佳实践,帮助开发者写出更高质量、更易维护的 Vue 代码。这些技巧大多来自真实项目踩坑后的沉淀,掌握它们能显著提升开发效率和代码质量。

零、写好 Vue 代码的核心原则

在展开具体技巧之前,先建立几条贯穿全文的原则。可以把它们当作评判代码好坏的"尺子":

单一职责:一个组件、一个函数只做一件事。组件超过 300 行、一个方法超过 50 行,就该考虑拆分。
关注点分离:UI 渲染、业务逻辑、数据请求应各自独立,方便测试和复用。
可复用优先:重复出现两次以上的逻辑,就抽成 Composable 或组件。
可预测性:数据流向清晰,避免"这个值到底在哪改的"这类问题。

下面从组件设计、性能、表单、请求、代码组织、调试六个维度,逐一展开可落地的技巧。

一、组件设计技巧

1. 智能组件与展示组件分离

这是 React/Vue 社区公认的组件设计模式,核心思想是把"数据从哪来"和"界面怎么画"分开。可以类比餐厅:展示组件是服务员(只负责端菜、和顾客交互,不关心菜怎么做),智能组件是后厨(负责备料、烹饪,即数据获取和业务逻辑)。

这样分离的好处:展示组件没有副作用,纯输入输出,极易测试和在不同场景复用;智能组件专注业务,替换 UI 时不用动逻辑。

展示组件(Dumb Component): 只负责 UI 渲染,通过 props 接收数据、通过 emit 上报事件,不关心数据来源。

vueCode
<!-- 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 传给展示组件。

vueCode
<!-- 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 一次性透传所有未声明的属性。

vueCode
<!-- 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 把这一切简化成一行。

vueCode
<!-- 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 还支持具名模型,一个组件绑多个值。

vueCode
<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 更轻量。

vueCode
<!-- 祖先组件 -->
<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 记忆一段模板,只有依赖数组变化时才重新渲染,适合大列表中局部更新的场景。

vueCode
<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 只代理第一层。

vueCode
<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 会永久跳过它。

vueCode
<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 会识别到并跳过响应式处理。

vueCode
<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 后可在所有表单复用,避免重复代码。

javascriptCode
// 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 };
}

使用示例:

vueCode
<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 修饰符能省掉很多手动处理。

vueCode
<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 把请求合并到用户停止输入之后。

javascriptCode
// 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 };
}
vueCode
<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,让组件里的请求代码变得极简。

javascriptCode
// 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 };
}

使用示例:

vueCode
<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 注入、错误统一提示、登录失效跳转的最佳位置,避免在每个请求里重复写。

javascriptCode
// 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 取消过期请求。

javascriptCode
// 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 复用逻辑的核心手段,等价于把一段有状态的逻辑打包成"可以被多个组件调用的函数"。

javascriptCode
// 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:

javascriptCode
// 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. 使用注释分区组织大型组件

当组件较大时,用统一的注释分区能大幅提升可读性,让人一眼看清各部分职责。

vueCode
<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 接口

把接口按模块归档,组件里只调用语义化的方法,接口地址变化时只改一处。

javascriptCode
// 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 问题的第一工具:

Components 面板:检查组件树、实时查看/修改 props 与状态。
Timeline 面板:追踪事件、生命周期、性能。
Pinia 面板:查看 store 状态、时间旅行调试。

2. 添加调试日志

vueCode
<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 可以捕获后代组件的错误,避免整个应用白屏,还能上报错误。

vueCode
<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. 全局错误处理

javascriptCode
// 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 直接「空投」数据。

vueCode
<!-- 顶层组件 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>
vueCode
<!-- 深层子组件 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 拼写错误。

typescriptCode
// 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');
typescriptCode
// 提供方
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 字节)实现跨组件事件通信。适合「无直接关系的组件之间偶发通信」,如全局通知、跨页面刷新。

javascriptCode
// utils/eventBus.js
import mitt from 'mitt';

// 创建全局单例事件总线
const emitter = mitt();

export default emitter;
vueCode
<!-- 组件 A:发送事件 -->
<script setup>
import emitter from '@/utils/eventBus';

function notify() {
  emitter.emit('user-updated', { id: 1, name: '李四' });
}
</script>

<template>
  <button @click="notify">更新用户</button>
</template>
vueCode
<!-- 组件 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。

javascriptCode
// 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%。

vueCode
<!-- 自定义组件 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>
vueCode
<!-- 父组件使用 -->
<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%。

javascriptCode
// 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. 全局前置守卫做权限控制

登录校验、权限过滤、动态标题都在导航守卫里统一处理,避免每个页面重复写。

javascriptCode
// 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. 动态路由:按权限动态添加

后台系统通常根据用户角色动态生成菜单和路由,未授权的路由根本不注册,从源头杜绝越权访问。

javascriptCode
// 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 数组,自动生成面包屑,无需在每个页面手写。

vueCode
<!-- 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 加过渡效果,让页面切换更流畅。

vueCode
<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 自动写入存储。

javascriptCode
// 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'; // 自动写入 localStorage

2. useDebounce 与 useThrottle:防抖节流

搜索框、滚动、resize 等高频场景的性能利器。

javascriptCode
// 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);
  };
}
javascriptCode
// 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);
    }
  };
}
vueCode
<!-- 使用示例:搜索框防抖 -->
<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,封装后自动在卸载时清理。

javascriptCode
// 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:响应式窗口尺寸

javascriptCode
// 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。

javascriptCode
// 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 };
}
vueCode
<!-- 图片懒加载示例 -->
<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:布尔状态切换

极简却高频,弹窗、折叠面板、开关都用得上。

javascriptCode
// 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); // 强制设为 true

7. usePagination:分页逻辑封装

把分页的页码、每页条数、总数、总页数、翻页方法全部封装,列表页直接复用。

javascriptCode
// 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:剪贴板复制

javascriptCode
// 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 的类型定义

vueCode
<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 的类型标注

typescriptCode
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 响应类型封装

typescriptCode
// 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 的类型

vueCode
<!-- 子组件 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>
vueCode
<!-- 父组件:为子组件 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. 基础配置

javascriptCode
// 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. 测试组件渲染与交互

javascriptCode
// 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 是纯逻辑,可脱离组件直接测试。

javascriptCode
// 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 网络请求

javascriptCode
// 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. 环境变量与多环境配置

bashCode
# .env.development
VITE_API_BASE=/api
VITE_APP_TITLE=管理后台(开发)

# .env.production
VITE_API_BASE=https://api.example.com
VITE_APP_TITLE=管理后台
typescriptCode
// 类型提示: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. 生产构建优化配置

javascriptCode
// 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. 依赖体积分析

javascriptCode
// 用 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 与错误统一提示

javascriptCode
// 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

细粒度按钮级权限控制,用自定义指令实现。

javascriptCode
// 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. 表格列表页通用模式

vueCode
<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 国际化

javascriptCode
// 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
});
vueCode
<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 | 状态变化被朗读 |

| 焦点管理 | 弹窗打开时聚焦、关闭时归还 | 键盘用户不迷路 |

vueCode
<!-- 可访问的弹窗示例 -->
<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 配合,形成「全局状态 + 局部逻辑」的清晰分层。

javascriptCode
// 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 全局错误处理

javascriptCode
// 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. 错误上报封装

javascriptCode
// 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. 简易埋点封装

javascriptCode
// 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 让组件更灵活

vueCode
<!-- 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. 用具名插槽 + 作用域插槽提升扩展性

vueCode
<!-- 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>
vueCode
<!-- 使用方按需定制各部分 -->
<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 代码的八字箴言:拆分、复用、解耦、清理。

1.拆分:大组件拆小,大函数拆细,单一职责。
2.复用:重复逻辑抽 Composable,重复 UI 抽组件。
3.解耦:UI、逻辑、请求分层,互不依赖。
4.清理:副作用有始有终,用完即弃防泄漏。

进阶四要素:类型、测试、监控、性能。

1.类型:TypeScript 全覆盖,把错误拦在编译期。
2.测试:核心逻辑有单测,重构不慌。
3.监控:线上错误主动上报,问题早发现。
4.性能:构建分包 + 懒加载 + 缓存,首屏够快。

把这些技巧内化为习惯,就能写出既高效又易维护、经得起团队协作和长期迭代考验的 Vue 代码。