Vue2详细笔记
侧边栏壁纸
  • 累计撰写 12 篇文章
  • 累计收到 1 条评论

Vue2详细笔记

ASN__
2026-03-17 / 0 评论 / 8 阅读 / 正在检测是否收录...

Vue2 企业级开发实战笔记

技术栈限定:Vue 2.x + Vue Router 3.x + Vuex 3.x

模块一:核心基础(必掌握)

1.1 Vue2 响应式原理(简化版)

核心作用

理解 Vue2 如何实现数据响应式,从底层认知「数据变更 → 视图更新」的机制,是高效开发与排查响应式失效问题的基础。

实现方式

Vue2 使用 Object.defineProperty 实现数据劫持,结合 发布-订阅模式

  • 递归遍历 data 对象的所有属性
  • 通过 getter 收集依赖
  • 通过 setter 触发更新

代码示例

// 简化版响应式实现
function defineReactive(obj, key, val) {
  // 递归处理嵌套对象
  observe(val);
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      console.log('收集依赖:', key);
      return val;
    },
    set(newVal) {
      if (newVal === val) return;
      console.log('触发更新:', key);
      val = newVal;
      observe(newVal);
    }
  });
}

function observe(obj) {
  if (typeof obj !== 'object' || obj === null) return;
  
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key]);
  });
}

// 使用示例
const data = { name: '张三', info: { age: 25 } };
observe(data);

data.name;        // 输出:收集依赖: name
data.name = '李四'; // 输出:触发更新: name

实战注意事项

⚠️ 易踩坑点

  • 直接添加新属性或删除属性无法触发响应式更新(需使用 $set / $delete
  • 直接通过数组下标修改元素无法触发响应式(需使用 Vue.set 或数组变异方法)

1.2 选项式 API 核心用法

1.2.1 data

核心作用:存储组件的响应式数据

export default {
  data() {
    return {
      message: 'Hello Vue',
      userInfo: {
        name: '张三',
        age: 25
      },
      list: [1, 2, 3]
    };
  }
};

最佳实践:data 必须返回一个函数(避免组件实例间共享数据)

1.2.2 methods

核心作用:定义组件的方法,处理用户交互、业务逻辑

export default {
  data() {
    return { count: 0 };
  },
  methods: {
    increment() {
      this.count++;
    },
    handleClick(event) {
      console.log('点击事件:', event);
    },
    async fetchData() {
      const res = await api.getUser();
      this.userInfo = res.data;
    }
  }
};

⚠️ 易踩坑点:methods 中不要使用箭头函数(会丢失 this 指向)

1.2.3 computed

核心作用:基于响应式数据计算衍生值,有缓存

export default {
  data() {
    return {
      firstName: '张',
      lastName: '三',
      price: 100,
      count: 2
    };
  },
  computed: {
    fullName() {
      return this.firstName + this.lastName;
    },
    totalPrice: {
      get() {
        return this.price * this.count;
      },
      set(val) {
        this.price = val / this.count;
      }
    }
  }
};

最佳实践:计算属性应纯函数,无副作用,仅用于计算

1.2.4 watch

核心作用:监听数据变化,执行异步或开销较大的操作

export default {
  data() {
    return {
      searchText: '',
      userInfo: { name: '张三' }
    };
  },
  watch: {
    searchText(newVal, oldVal) {
      if (newVal) {
        this.debounceSearch(newVal);
      }
    },
    userInfo: {
      handler(newVal) {
        console.log('用户信息变更:', newVal);
      },
      deep: true,      // 深度监听
      immediate: true   // 初始化时立即执行
    }
  },
  methods: {
    debounceSearch: _.debounce(function(keyword) {
      // 搜索逻辑
    }, 300)
  }
};

1.3 模板语法

核心指令系统

指令作用示例
v-bind动态绑定属性v-bind:src="imgUrl":src="imgUrl"
v-on绑定事件v-on:click="handleClick"@click="handleClick"
v-model双向数据绑定<input v-model="inputVal">
v-if / v-else条件渲染(真实 DOM 切换)<div v-if="isShow">内容</div>
v-show条件渲染(CSS display 切换)<div v-show="isShow">内容</div>
v-for列表渲染<li v-for="(item, index) in list" :key="item.id">{{ item.name }}</li>

过滤器(Vue2 特有)

// 定义过滤器
export default {
  filters: {
    capitalize(value) {
      if (!value) return '';
      return value.charAt(0).toUpperCase() + value.slice(1);
    },
    formatDate(value, fmt = 'YYYY-MM-DD') {
      return moment(value).format(fmt);
    }
  }
};
<!-- 使用过滤器 -->
<p>{{ message | capitalize }}</p>
<p>{{ date | formatDate('YYYY-MM-DD HH:mm') }}</p>

1.4 生命周期详解

八大常用钩子执行时机与应用场景

生命周期钩子执行时机典型应用场景
beforeCreate实例初始化后,数据观测和事件配置之前很少使用,可用于加载非响应式数据
created实例创建完成,数据已观测,但未挂载 DOM✅ 发起异步请求、初始化数据
beforeMount挂载开始前,模板编译完成,但未替换到页面很少使用
mounted实例挂载完成,DOM 已渲染✅ 操作 DOM、初始化第三方库、获取元素尺寸
beforeUpdate数据更新前,DOM 更新前可获取更新前的 DOM 状态
updated数据更新后,DOM 已更新操作更新后的 DOM
beforeDestroy实例销毁前✅ 清除定时器、取消事件监听、销毁第三方实例
destroyed实例销毁后很少使用
export default {
  data() {
    return { timer: null };
  },
  created() {
    this.fetchData();
  },
  mounted() {
    this.initChart();
    this.timer = setInterval(() => {
      console.log('定时器执行');
    }, 1000);
  },
  beforeDestroy() {
    clearInterval(this.timer);
  },
  methods: {
    fetchData() { /* 异步请求 */ },
    initChart() { /* 初始化图表 */ }
  }
};

模块二:开发高频技巧(重点详解)


2.1 组件通信方案

2.1.1 Props / $emit 传值(父子组件)

核心作用:实现父子组件间的数据传递与事件通信

Props 传值规范
// 子组件:UserCard.vue
export default {
  name: 'UserCard',
  props: {
    userId: {
      type: Number,
      required: true
    },
    userInfo: {
      type: Object,
      default: () => ({})
    },
    editable: {
      type: Boolean,
      default: false
    },
    tags: {
      type: Array,
      default: () => []
    }
  },
  methods: {
    handleEdit() {
      this.$emit('edit', this.userId);
    },
    handleDelete() {
      this.$emit('delete', { id: this.userId, confirm: true });
    }
  }
};
<!-- 父组件 -->
<template>
  <UserCard
    :user-id="123"
    :user-info="user"
    :editable="true"
    @edit="handleUserEdit"
    @delete="handleUserDelete"
  />
</template>

最佳实践

  • Props 命名使用 camelCase,在模板中使用 kebab-case
  • 始终定义 Props 的类型校验
  • 对象/数组类型的默认值必须返回函数
替代方案对比
方案优点缺点适用场景
Props / $emit单向数据流清晰,易于追踪跨多层级需层层传递父子组件直接通信
事件总线跨任意组件通信事件管理混乱,难维护小型应用跨组件通信
Vuex集中式状态管理,可追踪增加代码复杂度大型应用全局状态

2.1.2 事件总线(EventBus)

核心作用:实现任意组件间的通信,无需考虑层级关系

// 1. 创建事件总线:event-bus.js
import Vue from 'vue';
export const EventBus = new Vue();
// 2. 发送事件的组件
import { EventBus } from '@/utils/event-bus';

export default {
  methods: {
    sendMessage() {
      EventBus.$emit('message', { text: 'Hello', from: 'ComponentA' });
    }
  }
};
// 3. 接收事件的组件
import { EventBus } from '@/utils/event-bus';

export default {
  created() {
    EventBus.$on('message', this.handleMessage);
  },
  beforeDestroy() {
    // ⚠️ 关键:组件销毁前必须取消监听,防止内存泄漏
    EventBus.$off('message', this.handleMessage);
  },
  methods: {
    handleMessage(data) {
      console.log('收到消息:', data);
    }
  }
};

⚠️ 易踩坑点:忘记在 beforeDestroy 中取消事件监听,会导致内存泄漏和重复触发


2.1.3 Vuex 状态管理最佳实践

核心作用:集中管理应用全局状态,适合大型应用

// store/index.js
import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    userInfo: null,
    token: localStorage.getItem('token') || '',
    loading: false
  },
  
  getters: {
    isLoggedIn: state => !!state.token,
    userName: state => state.userInfo?.name || '游客'
  },
  
  mutations: {
    SET_TOKEN(state, token) {
      state.token = token;
      localStorage.setItem('token', token);
    },
    SET_USER_INFO(state, info) {
      state.userInfo = info;
    },
    SET_LOADING(state, status) {
      state.loading = status;
    },
    LOGOUT(state) {
      state.token = '';
      state.userInfo = null;
      localStorage.removeItem('token');
    }
  },
  
  actions: {
    async login({ commit }, credentials) {
      commit('SET_LOADING', true);
      try {
        const res = await api.login(credentials);
        commit('SET_TOKEN', res.data.token);
        commit('SET_USER_INFO', res.data.user);
        return res;
      } finally {
        commit('SET_LOADING', false);
      }
    },
    
    async fetchUserInfo({ commit, state }) {
      if (!state.token) return;
      const res = await api.getUserInfo();
      commit('SET_USER_INFO', res.data);
    },
    
    logout({ commit }) {
      commit('LOGOUT');
    }
  },
  
  modules: {
    // 按模块划分大型状态
    cart: {
      namespaced: true,
      state: { items: [] },
      mutations: {
        ADD_ITEM(state, item) {
          state.items.push(item);
        }
      },
      actions: {
        addToCart({ commit }, item) {
          commit('ADD_ITEM', item);
        }
      }
    }
  }
});
// 组件中使用
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';

export default {
  computed: {
    ...mapState(['userInfo', 'token']),
    ...mapGetters(['isLoggedIn', 'userName']),
    ...mapState('cart', ['items'])
  },
  methods: {
    ...mapMutations(['SET_LOADING']),
    ...mapActions(['login', 'logout']),
    ...mapActions('cart', ['addToCart']),
    
    async handleLogin() {
      await this.login({ username: 'admin', password: '123456' });
    }
  }
};

Vuex 最佳实践

  • 使用 namespaced: true 隔离模块
  • Mutations 只做同步状态更新,命名全大写
  • Actions 处理异步逻辑
  • Getters 用于计算派生状态
  • 使用辅助函数简化代码

2.2 复杂表单校验

核心作用

处理企业级复杂表单,包含必填校验、格式校验、自定义规则校验等

方案一:自定义表单校验
<template>
  <div class="form-container">
    <form @submit.prevent="handleSubmit">
      <div class="form-item">
        <label>用户名 *</label>
        <input v-model="form.username" @blur="validateField('username')" />
        <span v-if="errors.username" class="error">{{ errors.username }}</span>
      </div>
      
      <div class="form-item">
        <label>邮箱 *</label>
        <input v-model="form.email" @blur="validateField('email')" />
        <span v-if="errors.email" class="error">{{ errors.email }}</span>
      </div>
      
      <div class="form-item">
        <label>密码 *</label>
        <input type="password" v-model="form.password" @blur="validateField('password')" />
        <span v-if="errors.password" class="error">{{ errors.password }}</span>
      </div>
      
      <div class="form-item">
        <label>确认密码 *</label>
        <input type="password" v-model="form.confirmPassword" @blur="validateField('confirmPassword')" />
        <span v-if="errors.confirmPassword" class="error">{{ errors.confirmPassword }}</span>
      </div>
      
      <button type="submit" :disabled="isSubmitting">提交</button>
    </form>
  </div>
</template>

<script>
export default {
  data() {
    return {
      form: {
        username: '',
        email: '',
        password: '',
        confirmPassword: ''
      },
      errors: {},
      isSubmitting: false,
      rules: {
        username: [
          { required: true, message: '请输入用户名' },
          { min: 3, max: 20, message: '用户名长度3-20个字符' }
        ],
        email: [
          { required: true, message: '请输入邮箱' },
          { pattern: /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/, message: '邮箱格式不正确' }
        ],
        password: [
          { required: true, message: '请输入密码' },
          { min: 6, message: '密码至少6个字符' }
        ],
        confirmPassword: [
          { required: true, message: '请确认密码' },
          { validator: (val) => val === this.form.password, message: '两次密码不一致' }
        ]
      }
    };
  },
  methods: {
    validateField(field) {
      const rules = this.rules[field];
      const value = this.form[field];
      
      for (const rule of rules) {
        if (rule.required && !value) {
          this.$set(this.errors, field, rule.message);
          return false;
        }
        if (rule.min && value.length < rule.min) {
          this.$set(this.errors, field, rule.message);
          return false;
        }
        if (rule.max && value.length > rule.max) {
          this.$set(this.errors, field, rule.message);
          return false;
        }
        if (rule.pattern && !rule.pattern.test(value)) {
          this.$set(this.errors, field, rule.message);
          return false;
        }
        if (rule.validator && !rule.validator(value)) {
          this.$set(this.errors, field, rule.message);
          return false;
        }
      }
      
      this.$delete(this.errors, field);
      return true;
    },
    
    validateAll() {
      let isValid = true;
      Object.keys(this.rules).forEach(field => {
        if (!this.validateField(field)) {
          isValid = false;
        }
      });
      return isValid;
    },
    
    async handleSubmit() {
      if (!this.validateAll()) {
        this.$message.error('请检查表单填写');
        return;
      }
      
      this.isSubmitting = true;
      try {
        await api.register(this.form);
        this.$message.success('注册成功');
      } catch (err) {
        this.$message.error(err.message);
      } finally {
        this.isSubmitting = false;
      }
    }
  }
};
</script>
方案二:使用 VeeValidate(企业级推荐)
// 安装:npm install vee-validate@2
import Vue from 'vue';
import VeeValidate, { Validator } from 'vee-validate';
import zh_CN from 'vee-validate/dist/locale/zh_CN';

Validator.localize('zh_CN', zh_CN);
Vue.use(VeeValidate, { inject: false });

// 自定义验证规则
Validator.extend('phone', {
  getMessage: field => '请输入正确的手机号',
  validate: value => /^1[3-9]\d{9}$/.test(value)
});
<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-item">
      <label>手机号</label>
      <input 
        v-model="form.phone" 
        name="phone" 
        v-validate="'required|phone'" 
      />
      <span v-if="errors.has('phone')" class="error">{{ errors.first('phone') }}</span>
    </div>
    
    <button type="submit">提交</button>
  </form>
</template>

<script>
import { mapActions } from 'vuex';

export default {
  data() {
    return {
      form: { phone: '' }
    };
  },
  methods: {
    async handleSubmit() {
      const valid = await this.$validator.validateAll();
      if (!valid) return;
      
      await this.submitForm(this.form);
    }
  }
};
</script>
替代方案对比
方案优点缺点适用场景
自定义校验灵活可控,无额外依赖代码量大,维护成本高简单表单,特殊定制需求
VeeValidate规则丰富,国际化支持好学习成本,包体积较大中大型项目复杂表单
ElementUI Form组件库集成,开箱即用依赖 UI 组件库使用 ElementUI 的项目

2.3 路由守卫(Vue Router 3)

核心作用

拦截路由跳转,进行权限验证、登录校验、数据预加载等

// router/index.js
import Vue from 'vue';
import VueRouter from 'vue-router';
import store from '@/store';

Vue.use(VueRouter);

const routes = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { requiresAuth: false }
  },
  {
    path: '/',
    component: () => import('@/layout/MainLayout.vue'),
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/Dashboard.vue'),
        meta: { requiresAuth: true, title: '仪表盘' }
      },
      {
        path: 'users',
        name: 'Users',
        component: () => import('@/views/Users.vue'),
        meta: { 
          requiresAuth: true, 
          title: '用户管理',
          roles: ['admin', 'superadmin']
        },
        beforeEnter: (to, from, next) => {
          // 路由独享守卫
          const hasRole = to.meta.roles.includes(store.state.userInfo?.role);
          hasRole ? next() : next('/403');
        }
      }
    ]
  }
];

const router = new VueRouter({
  mode: 'history',
  routes
});

// 全局前置守卫
router.beforeEach(async (to, from, next) => {
  document.title = to.meta.title || '管理系统';
  
  const token = store.state.token;
  const requiresAuth = to.meta.requiresAuth !== false;
  
  if (requiresAuth && !token) {
    // 需要登录但未登录,跳转登录页
    next({
      path: '/login',
      query: { redirect: to.fullPath }
    });
    return;
  }
  
  if (to.path === '/login' && token) {
    // 已登录用户访问登录页,跳转首页
    next('/dashboard');
    return;
  }
  
  // 获取用户信息(如果已登录但没有用户信息)
  if (token && !store.state.userInfo) {
    try {
      await store.dispatch('fetchUserInfo');
    } catch (err) {
      store.dispatch('logout');
      next('/login');
      return;
    }
  }
  
  next();
});

// 全局后置钩子
router.afterEach((to, from) => {
  // 埋点上报、页面访问统计等
  console.log('页面跳转:', from.path, '->', to.path);
});

export default router;
组件内守卫
<script>
export default {
  beforeRouteEnter(to, from, next) {
    // 进入路由前,组件实例还没创建,无法访问 this
    next(vm => {
      // 可以通过 vm 访问组件实例
      vm.fetchData();
    });
  },
  
  beforeRouteUpdate(to, from, next) {
    // 路由更新但组件复用时调用(如 /user/1 -> /user/2)
    this.userId = to.params.id;
    this.fetchData();
    next();
  },
  
  beforeRouteLeave(to, from, next) {
    // 离开路由前调用
    if (this.hasUnsavedChanges) {
      if (confirm('有未保存的内容,确定离开吗?')) {
        next();
      } else {
        next(false);
      }
    } else {
      next();
    }
  },
  
  methods: {
    fetchData() { /* ... */ }
  }
};
</script>

2.4 Axios 封装

核心作用

统一处理请求/响应拦截、错误处理、请求取消、Token 管理等

// utils/request.js
import axios from 'axios';
import store from '@/store';
import router from '@/router';
import { Message } from 'element-ui';

// 创建 axios 实例
const service = axios.create({
  baseURL: process.env.VUE_APP_BASE_API,
  timeout: 15000
});

// 请求取消机制
const pendingMap = new Map();

const getPendingKey = (config) => {
  return [config.method, config.url, JSON.stringify(config.data || config.params)].join('&');
};

const addPending = (config) => {
  const key = getPendingKey(config);
  config.cancelToken = config.cancelToken || new axios.CancelToken(cancel => {
    if (!pendingMap.has(key)) {
      pendingMap.set(key, cancel);
    }
  });
};

const removePending = (config) => {
  const key = getPendingKey(config);
  if (pendingMap.has(key)) {
    const cancel = pendingMap.get(key);
    cancel('请求已取消');
    pendingMap.delete(key);
  }
};

// 请求拦截器
service.interceptors.request.use(
  config => {
    removePending(config);
    addPending(config);
    
    const token = store.state.token;
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`;
    }
    
    if (config.method === 'post' && config.data) {
      config.headers['Content-Type'] = 'application/json';
    }
    
    return config;
  },
  error => {
    return Promise.reject(error);
  }
);

// 响应拦截器
service.interceptors.response.use(
  response => {
    removePending(response.config);
    
    const res = response.data;
    
    if (res.code !== 200) {
      Message.error(res.message || '请求失败');
      
      if (res.code === 401) {
        store.dispatch('logout');
        router.push('/login');
      }
      
      return Promise.reject(new Error(res.message));
    }
    
    return res;
  },
  error => {
    if (axios.isCancel(error)) {
      console.log('请求取消:', error.message);
      return Promise.reject(error);
    }
    
    removePending(error.config || {});
    
    let message = '网络错误';
    
    if (error.response) {
      const { status, data } = error.response;
      
      switch (status) {
        case 400:
          message = data?.message || '请求参数错误';
          break;
        case 401:
          message = '登录已过期,请重新登录';
          store.dispatch('logout');
          router.push('/login');
          break;
        case 403:
          message = '没有权限访问';
          break;
        case 404:
          message = '请求资源不存在';
          break;
        case 500:
          message = '服务器错误';
          break;
        default:
          message = data?.message || `请求失败 (${status})`;
      }
    } else if (error.code === 'ECONNABORTED') {
      message = '请求超时,请检查网络';
    }
    
    Message.error(message);
    return Promise.reject(error);
  }
);

export default service;
// api/user.js
import request from '@/utils/request';

export function login(data) {
  return request({
    url: '/auth/login',
    method: 'post',
    data
  });
}

export function getUserInfo() {
  return request({
    url: '/user/info',
    method: 'get'
  });
}

export function updateUser(id, data) {
  return request({
    url: `/user/${id}`,
    method: 'put',
    data
  });
}

export function deleteUser(id) {
  return request({
    url: `/user/${id}`,
    method: 'delete'
  });
}

2.5 Mixin 的使用与潜在问题

核心作用

复用组件逻辑,但 Vue3 已废弃(推荐使用 Composition API)

// mixins/table.js
export default {
  data() {
    return {
      tableData: [],
      loading: false,
      pagination: {
        current: 1,
        pageSize: 10,
        total: 0
      }
    };
  },
  methods: {
    async fetchData() {
      this.loading = true;
      try {
        const res = await this.getListApi({
          page: this.pagination.current,
          pageSize: this.pagination.pageSize
        });
        this.tableData = res.data.list;
        this.pagination.total = res.data.total;
      } finally {
        this.loading = false;
      }
    },
    handlePageChange(page) {
      this.pagination.current = page;
      this.fetchData();
    },
    handleSizeChange(size) {
      this.pagination.pageSize = size;
      this.pagination.current = 1;
      this.fetchData();
    }
  },
  created() {
    if (this.getListApi) {
      this.fetchData();
    }
  }
};
<!-- 使用 mixin -->
<script>
import tableMixin from '@/mixins/table';
import { getUserList } from '@/api/user';

export default {
  mixins: [tableMixin],
  data() {
    return {
      getListApi: getUserList
    };
  }
};
</script>

⚠️ Mixin 潜在问题

  1. 命名冲突:多个 mixin 或组件有同名属性/方法,后者覆盖前者
  2. 来源不透明:难以追踪数据/方法来自哪个 mixin
  3. 隐式依赖:mixin 之间可能相互依赖,难以维护

替代方案:Vue3 Composition API、Utils 函数抽取、Renderless 组件


2.6 自定义指令开发

核心作用

封装 DOM 操作逻辑,复用低层级操作

// directives/index.js
import Vue from 'vue';

// 权限指令:v-permission="'admin'"
Vue.directive('permission', {
  inserted(el, binding) {
    const { value } = binding;
    const permissions = store.state.userInfo?.permissions || [];
    
    if (value && !permissions.includes(value)) {
      el.parentNode && el.parentNode.removeChild(el);
    }
  }
});

// 防抖指令:v-debounce="handleClick"
Vue.directive('debounce', {
  bind(el, binding) {
    let timer = null;
    const delay = binding.arg || 300;
    const handler = binding.value;
    
    el.addEventListener('click', (e) => {
      if (timer) clearTimeout(timer);
      timer = setTimeout(() => {
        handler(e);
      }, delay);
    });
  }
});

// 节流指令:v-throttle="handleScroll"
Vue.directive('throttle', {
  bind(el, binding) {
    let lastTime = 0;
    const delay = binding.arg || 300;
    const handler = binding.value;
    
    el.addEventListener('scroll', (e) => {
      const now = Date.now();
      if (now - lastTime >= delay) {
        handler(e);
        lastTime = now;
      }
    });
  }
});

// 复制指令:v-copy="text"
Vue.directive('copy', {
  bind(el, binding) {
    el.addEventListener('click', () => {
      const textarea = document.createElement('textarea');
      textarea.value = binding.value;
      document.body.appendChild(textarea);
      textarea.select();
      document.execCommand('copy');
      document.body.removeChild(textarea);
      Vue.prototype.$message.success('复制成功');
    });
  }
});

// 加载状态指令:v-loading="loading"
Vue.directive('loading', {
  bind(el, binding) {
    const loading = document.createElement('div');
    loading.className = 'v-loading';
    loading.innerHTML = '<div class="spinner"></div>';
    loading.style.display = 'none';
    loading.style.position = 'absolute';
    loading.style.top = 0;
    loading.style.left = 0;
    loading.style.right = 0;
    loading.style.bottom = 0;
    loading.style.background = 'rgba(255,255,255,0.8)';
    loading.style.display = 'flex';
    loading.style.alignItems = 'center';
    loading.style.justifyContent = 'center';
    
    el.appendChild(loading);
    el._loading = loading;
    el.style.position = 'relative';
  },
  update(el, binding) {
    el._loading.style.display = binding.value ? 'flex' : 'none';
  }
});
<template>
  <div>
    <button v-permission="'user:delete'" @click="handleDelete">删除</button>
    <button v-debounce:500="handleSubmit">提交</button>
    <button v-copy="copyText">复制</button>
    <div v-loading="loading" style="height: 200px;">内容区域</div>
  </div>
</template>

模块三:进阶优化(按需掌握)


3.1 v-for 渲染优化

核心作用

优化长列表渲染性能,避免页面卡顿

3.1.1 Key 值规范
<!-- ❌ 错误:使用 index 作为 key -->
<li v-for="(item, index) in list" :key="index">{{ item.name }}</li>

<!-- ✅ 正确:使用唯一 ID 作为 key -->
<li v-for="item in list" :key="item.id">{{ item.name }}</li>
3.1.2 虚拟列表实现
<template>
  <div 
    class="virtual-list" 
    ref="container"
    @scroll="handleScroll"
  >
    <div 
      class="list-phantom" 
      :style="{ height: totalHeight + 'px' }"
    ></div>
    
    <div 
      class="list-content"
      :style="{ transform: `translateY(${offset}px)` }"
    >
      <div
        v-for="item in visibleData"
        :key="item.id"
        class="list-item"
        :style="{ height: itemHeight + 'px' }"
      >
        {{ item.content }}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    listData: {
      type: Array,
      default: () => []
    },
    itemHeight: {
      type: Number,
      default: 50
    }
  },
  data() {
    return {
      startIndex: 0,
      endIndex: 0,
      containerHeight: 0
    };
  },
  computed: {
    totalHeight() {
      return this.listData.length * this.itemHeight;
    },
    visibleCount() {
      return Math.ceil(this.containerHeight / this.itemHeight) + 2;
    },
    visibleData() {
      return this.listData.slice(this.startIndex, this.endIndex);
    },
    offset() {
      return this.startIndex * this.itemHeight;
    }
  },
  mounted() {
    this.containerHeight = this.$refs.container.clientHeight;
    this.endIndex = this.startIndex + this.visibleCount;
  },
  methods: {
    handleScroll() {
      const scrollTop = this.$refs.container.scrollTop;
      this.startIndex = Math.floor(scrollTop / this.itemHeight);
      this.endIndex = this.startIndex + this.visibleCount;
    }
  }
};
</script>

<style scoped>
.virtual-list {
  height: 500px;
  overflow-y: auto;
  position: relative;
}
.list-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
}
.list-content {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
}
.list-item {
  padding: 0 10px;
  border-bottom: 1px solid #eee;
  display: flex;
  align-items: center;
}
</style>

3.2 计算属性 vs 方法 vs 侦听器

特性computedmethodswatch
缓存✅ 有缓存,依赖不变不重新计算❌ 无缓存,每次调用都执行-
异步❌ 不支持✅ 支持✅ 支持
返回值✅ 必须有返回值可选可选
适用场景计算衍生值,有缓存需求事件处理、业务逻辑数据变化执行异步操作
export default {
  data() {
    return {
      firstName: '张',
      lastName: '三',
      searchText: ''
    };
  },
  computed: {
    fullName() {
      return this.firstName + this.lastName;
    }
  },
  watch: {
    searchText(newVal) {
      this.debounceSearch(newVal);
    }
  },
  methods: {
    getFullName() {
      return this.firstName + this.lastName;
    },
    debounceSearch(keyword) {
      // 异步搜索
    }
  }
};

3.3 Keep-alive 缓存组件

核心作用

缓存组件实例,避免重复渲染,提升用户体验

<template>
  <div id="app">
    <keep-alive :include="cachedViews" :exclude="['Detail']" :max="10">
      <router-view />
    </keep-alive>
  </div>
</template>

<script>
export default {
  computed: {
    cachedViews() {
      return this.$store.state.tagsView.cachedViews;
    }
  }
};
</script>
<!-- 被缓存的组件会有 activated/deactivated 钩子 -->
<script>
export default {
  activated() {
    // 组件激活时调用
    console.log('组件被激活');
    this.refreshData();
  },
  deactivated() {
    // 组件停用时调用
    console.log('组件被缓存');
  }
};
</script>

3.4 打包体积优化

核心作用

减少 bundle 体积,提升加载速度

3.4.1 路由懒加载
// ❌ 静态导入(打包到主 bundle)
import Home from '@/views/Home.vue';

// ✅ 路由懒加载(代码分割)
const Home = () => import('@/views/Home.vue');

const routes = [
  { path: '/home', component: Home },
  { path: '/about', component: () => import('@/views/About.vue') }
];
3.4.2 第三方库 CDN 引入
// vue.config.js
module.exports = {
  configureWebpack: {
    externals: {
      vue: 'Vue',
      'vue-router': 'VueRouter',
      vuex: 'Vuex',
      axios: 'axios',
      'element-ui': 'ELEMENT'
    }
  }
};
<!-- public/index.html -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue-router@3.5.3/dist/vue-router.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vuex@3.6.2/dist/vuex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@0.24.0/dist/axios.min.js"></script>
3.4.3 Tree-shaking 配置
// 引入方式很重要!
// ❌ 全量引入
import _ from 'lodash';

// ✅ 按需引入(需配置 babel-plugin-component)
import { debounce, throttle } from 'lodash-es';

3.5 首屏加载优化

优化方案实现方式效果
路由懒加载() => import()减少首屏 bundle 大小
图片懒加载v-lazy 或 IntersectionObserver减少初始请求数
预加载<link rel="preload">提前加载关键资源
骨架屏首屏显示占位提升感知体验
Gzip 压缩Nginx 配置减少传输体积
缓存策略Service Worker / HTTP 缓存减少重复加载

模块四:冷门知识点(仅了解)


4.1 过滤器(Vue3 已移除)

核心概念

Vue2 提供的一种文本格式化方式,Vue3 已移除

适用场景

  • 简单的文本格式化(日期、数字、字符串处理)

使用限制

  • 只能在模板插值和 v-bind 中使用
  • 无法访问组件实例的 this(全局过滤器除外)
  • Vue3 已移除,迁移到计算属性或方法

替代方案

// Vue2 过滤器
filters: {
  formatDate(date) {
    return moment(date).format('YYYY-MM-DD');
  }
}

// Vue3 替代:计算属性
computed: {
  formattedDate() {
    return moment(this.date).format('YYYY-MM-DD');
  }
}

4.2 $set 底层原理(仅了解)

核心概念

解决 Vue2 响应式系统的两个限制:

  1. 不能直接添加新的响应式属性到已创建的实例
  2. 不能通过数组下标直接修改元素

适用场景

  • 动态添加响应式属性
  • 通过下标修改数组元素

使用限制

  • 不能给 Vue 实例或根数据对象添加根级响应式属性

4.3 内置过渡动画

核心概念

Vue 提供的 transitiontransition-group 组件,用于元素插入、更新、移除时的动画效果

少用场景

  • 频繁触发的动画(影响性能)
  • 复杂动画(推荐使用 CSS 动画库如 animate.css)

性能影响

  • 频繁的 DOM 操作可能导致布局抖动
  • 建议使用 CSS transforms 和 opacity(GPU 加速)

总结

学习路径建议

  1. 第一阶段:掌握核心基础模块(响应式原理、选项式 API、生命周期)
  2. 第二阶段:重点学习高频技巧(组件通信、表单校验、路由、Axios)
  3. 第三阶段:按需学习进阶优化(性能优化、打包优化)
  4. 第四阶段:了解冷门知识点,为 Vue3 迁移做准备

企业级项目架构建议

  • 使用 Vuex 管理全局状态
  • 统一的 Axios 封装
  • 路由守卫做权限控制
  • Mixin 谨慎使用,考虑 Utils 函数
  • 自定义指令封装常用 DOM 操作

模块五:企业级实战场景(生产环境)


5.1 企业级项目目录结构

核心作用

建立规范的项目结构,便于团队协作和代码维护

src/
├── api/                    # API 接口层
│   ├── modules/            # 按业务模块划分
│   │   ├── user.js
│   │   ├── order.js
│   │   └── goods.js
│   └── index.js            # API 统一导出
├── assets/                 # 静态资源
│   ├── images/
│   ├── styles/
│   │   ├── index.scss      # 全局样式入口
│   │   ├── variables.scss  # 样式变量
│   │   └── mixins.scss     # 样式混入
│   └── fonts/
├── components/             # 公共组件
│   ├── business/           # 业务组件
│   │   ├── UserCard.vue
│   │   └── OrderList.vue
│   └── common/             # 通用组件
│       ├── Pagination.vue
│       ├── SearchForm.vue
│       └── TableToolbar.vue
├── directives/             # 自定义指令
│   ├── permission.js
│   ├── debounce.js
│   └── index.js
├── filters/                # 过滤器
│   ├── date.js
│   ├── number.js
│   └── index.js
├── layout/                 # 布局组件
│   ├── MainLayout.vue
│   ├── Sidebar.vue
│   ├── Header.vue
│   └── TagsView.vue
├── mixins/                 # 混入
│   ├── table.js
│   ├── form.js
│   └── dialog.js
├── router/                 # 路由
│   ├── modules/            # 路由模块
│   │   ├── user.js
│   │   └── order.js
│   ├── index.js
│   └── permission.js       # 路由权限控制
├── store/                  # Vuex 状态管理
│   ├── modules/            # 业务模块
│   │   ├── user.js
│   │   ├── app.js
│   │   ├── tagsView.js
│   │   └── permission.js
│   ├── getters.js
│   └── index.js
├── utils/                  # 工具函数
│   ├── request.js          # axios 封装
│   ├── auth.js             # 认证相关
│   ├── validate.js         # 校验工具
│   ├── storage.js          # 本地存储
│   ├── date.js             # 日期处理
│   └── index.js
├── views/                  # 页面视图
│   ├── login/
│   │   └── Login.vue
│   ├── dashboard/
│   │   └── Dashboard.vue
│   ├── user/
│   │   ├── UserList.vue
│   │   └── UserDetail.vue
│   └── error/
│       ├── 404.vue
│       └── 403.vue
├── App.vue
└── main.js

最佳实践

  • 按业务模块划分目录,而非按类型
  • 公共组件与业务组件分离
  • API 层统一管理接口
  • 路由按模块懒加载

5.2 标签页导航(TagsView)完整实现

核心作用

企业级后台系统必备功能,支持多标签页切换、关闭、刷新等

// store/modules/tagsView.js
const state = {
  visitedViews: [],
  cachedViews: []
};

const mutations = {
  ADD_VISITED_VIEW(state, view) {
    if (state.visitedViews.some(v => v.path === view.path)) return;
    state.visitedViews.push({
      name: view.name,
      path: view.path,
      title: view.meta?.title || 'no-name',
      query: view.query,
      params: view.params,
      affix: view.meta?.affix || false
    });
  },
  ADD_CACHED_VIEW(state, view) {
    if (state.cachedViews.includes(view.name)) return;
    if (view.meta?.keepAlive !== false) {
      state.cachedViews.push(view.name);
    }
  },
  DEL_VISITED_VIEW(state, view) {
    const index = state.visitedViews.findIndex(v => v.path === view.path);
    if (index > -1) {
      state.visitedViews.splice(index, 1);
    }
  },
  DEL_CACHED_VIEW(state, view) {
    const index = state.cachedViews.indexOf(view.name);
    if (index > -1) {
      state.cachedViews.splice(index, 1);
    }
  },
  DEL_OTHERS_VISITED_VIEWS(state, view) {
    state.visitedViews = state.visitedViews.filter(
      v => v.affix || v.path === view.path
    );
  },
  DEL_OTHERS_CACHED_VIEWS(state, view) {
    state.cachedViews = state.cachedViews.filter(name => name === view.name);
  },
  DEL_ALL_VISITED_VIEWS(state) {
    state.visitedViews = state.visitedViews.filter(v => v.affix);
  },
  DEL_ALL_CACHED_VIEWS(state) {
    state.cachedViews = [];
  },
  UPDATE_VISITED_VIEW(state, view) {
    for (let v of state.visitedViews) {
      if (v.path === view.path) {
        Object.assign(v, view);
        break;
      }
    }
  }
};

const actions = {
  addView({ dispatch }, view) {
    dispatch('addVisitedView', view);
    dispatch('addCachedView', view);
  },
  addVisitedView({ commit }, view) {
    commit('ADD_VISITED_VIEW', view);
  },
  addCachedView({ commit }, view) {
    commit('ADD_CACHED_VIEW', view);
  },
  delView({ dispatch, state }, view) {
    return new Promise(resolve => {
      dispatch('delVisitedView', view);
      dispatch('delCachedView', view);
      resolve({
        visitedViews: [...state.visitedViews],
        cachedViews: [...state.cachedViews]
      });
    });
  },
  delVisitedView({ commit, state }, view) {
    return new Promise(resolve => {
      commit('DEL_VISITED_VIEW', view);
      resolve([...state.visitedViews]);
    });
  },
  delCachedView({ commit, state }, view) {
    return new Promise(resolve => {
      commit('DEL_CACHED_VIEW', view);
      resolve([...state.cachedViews]);
    });
  },
  delOthersViews({ dispatch, state }, view) {
    return new Promise(resolve => {
      dispatch('delOthersVisitedViews', view);
      dispatch('delOthersCachedViews', view);
      resolve({
        visitedViews: [...state.visitedViews],
        cachedViews: [...state.cachedViews]
      });
    });
  },
  delOthersVisitedViews({ commit, state }, view) {
    return new Promise(resolve => {
      commit('DEL_OTHERS_VISITED_VIEWS', view);
      resolve([...state.visitedViews]);
    });
  },
  delOthersCachedViews({ commit, state }, view) {
    return new Promise(resolve => {
      commit('DEL_OTHERS_CACHED_VIEWS', view);
      resolve([...state.cachedViews]);
    });
  },
  delAllViews({ dispatch, state }, view) {
    return new Promise(resolve => {
      dispatch('delAllVisitedViews', view);
      dispatch('delAllCachedViews', view);
      resolve({
        visitedViews: [...state.visitedViews],
        cachedViews: [...state.cachedViews]
      });
    });
  },
  delAllVisitedViews({ commit, state }) {
    return new Promise(resolve => {
      commit('DEL_ALL_VISITED_VIEWS');
      resolve([...state.visitedViews]);
    });
  },
  delAllCachedViews({ commit, state }) {
    return new Promise(resolve => {
      commit('DEL_ALL_CACHED_VIEWS');
      resolve([...state.cachedViews]);
    });
  },
  updateVisitedView({ commit }, view) {
    commit('UPDATE_VISITED_VIEW', view);
  }
};

export default {
  namespaced: true,
  state,
  mutations,
  actions
};
<!-- layout/TagsView.vue -->
<template>
  <div class="tags-view-container">
    <scroll-pane ref="scrollPane" class="tags-view-wrapper">
      <router-link
        v-for="tag in visitedViews"
        ref="tag"
        :key="tag.path"
        :class="isActive(tag) ? 'active' : ''"
        :to="{ path: tag.path, query: tag.query, params: tag.params }"
        class="tags-view-item"
        @click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
        @contextmenu.prevent.native="openMenu(tag, $event)"
      >
        {{ tag.title }}
        <span
          v-if="!isAffix(tag)"
          class="el-icon-close"
          @click.prevent.stop="closeSelectedTag(tag)"
        ></span>
      </router-link>
    </scroll-pane>
    <ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
      <li @click="refreshSelectedTag(selectedTag)">刷新</li>
      <li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">关闭</li>
      <li @click="closeOthersTags">关闭其他</li>
      <li @click="closeAllTags(selectedTag)">关闭所有</li>
    </ul>
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex';
import ScrollPane from './ScrollPane.vue';

export default {
  name: 'TagsView',
  components: { ScrollPane },
  data() {
    return {
      visible: false,
      top: 0,
      left: 0,
      selectedTag: {},
      affixTags: []
    };
  },
  computed: {
    ...mapState({
      visitedViews: state => state.tagsView.visitedViews,
      cachedViews: state => state.tagsView.cachedViews
    })
  },
  watch: {
    $route() {
      this.addTags();
      this.moveToCurrentTag();
    },
    visible(value) {
      if (value) {
        document.body.addEventListener('click', this.closeMenu);
      } else {
        document.body.removeEventListener('click', this.closeMenu);
      }
    }
  },
  mounted() {
    this.initTags();
    this.addTags();
  },
  methods: {
    ...mapActions('tagsView', [
      'addView',
      'delView',
      'delOthersViews',
      'delAllViews',
      'updateVisitedView'
    ]),
    isActive(route) {
      return route.path === this.$route.path;
    },
    isAffix(tag) {
      return tag.meta && tag.meta.affix;
    },
    initTags() {
      const routes = this.$router.options.routes;
      this.affixTags = this.filterAffixTags(routes);
      for (const tag of this.affixTags) {
        if (tag.path) {
          this.addView(tag);
        }
      }
    },
    filterAffixTags(routes, basePath = '/') {
      let tags = [];
      routes.forEach(route => {
        if (route.meta && route.meta.affix) {
          const tagPath = path.resolve(basePath, route.path);
          tags.push({
            fullPath: tagPath,
            path: tagPath,
            name: route.name,
            meta: { ...route.meta }
          });
        }
        if (route.children) {
          const tempTags = this.filterAffixTags(route.children, route.path);
          if (tempTags.length >= 1) {
            tags = [...tags, ...tempTags];
          }
        }
      });
      return tags;
    },
    addTags() {
      const { name } = this.$route;
      if (name) {
        this.addView(this.$route);
      }
      return false;
    },
    moveToCurrentTag() {
      const tags = this.$refs.tag;
      this.$nextTick(() => {
        for (const tag of tags) {
          if (tag.to === this.$route.path) {
            this.$refs.scrollPane.moveToTarget(tag);
            break;
          }
        }
      });
    },
    closeSelectedTag(view) {
      this.delView(view).then(({ visitedViews }) => {
        if (this.isActive(view)) {
          this.toLastView(visitedViews, view);
        }
      });
    },
    closeOthersTags() {
      this.$router.push(this.selectedTag);
      this.delOthersViews(this.selectedTag);
    },
    closeAllTags(view) {
      this.delAllViews(view).then(({ visitedViews }) => {
        if (visitedViews.length) {
          this.toLastView(visitedViews);
        } else {
          this.$router.push('/');
        }
      });
    },
    toLastView(visitedViews, view) {
      const latestView = visitedViews.slice(-1)[0];
      if (latestView) {
        this.$router.push(latestView.fullPath);
      } else {
        if (view.name === 'Dashboard') {
          this.refreshSelectedTag(view);
        } else {
          this.$router.push('/');
        }
      }
    },
    openMenu(tag, e) {
      const menuMinWidth = 105;
      const offsetLeft = this.$el.getBoundingClientRect().left;
      const offsetWidth = this.$el.offsetWidth;
      const maxLeft = offsetWidth - menuMinWidth;
      const left = e.clientX - offsetLeft + 15;

      if (left > maxLeft) {
        this.left = maxLeft;
      } else {
        this.left = left;
      }

      this.top = e.clientY;
      this.visible = true;
      this.selectedTag = tag;
    },
    closeMenu() {
      this.visible = false;
    },
    refreshSelectedTag(view) {
      this.$nextTick(() => {
        const { fullPath } = view;
        this.$router.replace({
          path: '/redirect' + fullPath
        });
      });
    }
  }
};
</script>

<style lang="scss" scoped>
.tags-view-container {
  height: 34px;
  width: 100%;
  background: #fff;
  border-bottom: 1px solid #d8dce5;
  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 0 3px 0 rgba(0, 0, 0, 0.04);
  .tags-view-wrapper {
    .tags-view-item {
      display: inline-block;
      position: relative;
      cursor: pointer;
      height: 26px;
      line-height: 26px;
      border: 1px solid #d8dce5;
      color: #495060;
      background: #fff;
      padding: 0 8px;
      font-size: 12px;
      margin-left: 5px;
      margin-top: 4px;
      &:first-of-type {
        margin-left: 15px;
      }
      &:hover {
        color: #409eff;
      }
      &.active {
        background-color: #409eff;
        color: #fff;
        border-color: #409eff;
        &::before {
          content: '';
          background: #fff;
          display: inline-block;
          width: 8px;
          height: 8px;
          border-radius: 50%;
          position: relative;
          margin-right: 2px;
        }
      }
    }
  }
  .contextmenu {
    margin: 0;
    background: #fff;
    z-index: 100;
    position: absolute;
    list-style-type: none;
    padding: 5px 0;
    border-radius: 4px;
    font-size: 12px;
    font-weight: 400;
    color: #333;
    box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3);
    li {
      margin: 0;
      padding: 7px 16px;
      cursor: pointer;
      &:hover {
        background: #eee;
      }
    }
  }
}
</style>

5.3 动态菜单与路由权限管理

核心作用

根据用户权限动态生成菜单和路由,实现细粒度权限控制

// store/modules/permission.js
import { asyncRoutes, constantRoutes } from '@/router';
import { getRoutes } from '@/api/user';

function hasPermission(roles, route) {
  if (route.meta && route.meta.roles) {
    return roles.some(role => route.meta.roles.includes(role));
  } else {
    return true;
  }
}

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

const state = {
  routes: [],
  addRoutes: []
};

const mutations = {
  SET_ROUTES(state, routes) {
    state.addRoutes = routes;
    state.routes = constantRoutes.concat(routes);
  }
};

const actions = {
  generateRoutes({ commit }, roles) {
    return new Promise(resolve => {
      let accessedRoutes;
      if (roles.includes('admin')) {
        accessedRoutes = asyncRoutes || [];
      } else {
        accessedRoutes = filterAsyncRoutes(asyncRoutes, roles);
      }
      commit('SET_ROUTES', accessedRoutes);
      resolve(accessedRoutes);
    });
  },
  
  generateRoutesByBackend({ commit }) {
    return new Promise((resolve, reject) => {
      getRoutes().then(response => {
        const accessedRoutes = filterAsyncRoutes(response.data, []);
        commit('SET_ROUTES', accessedRoutes);
        resolve(accessedRoutes);
      }).catch(error => {
        reject(error);
      });
    });
  }
};

export default {
  namespaced: true,
  state,
  mutations,
  actions
};
// router/permission.js
import router from './index';
import store from '@/store';
import { getToken } from '@/utils/auth';

const whiteList = ['/login', '/auth-redirect'];

router.beforeEach(async (to, from, next) => {
  const hasToken = getToken();
  
  if (hasToken) {
    if (to.path === '/login') {
      next({ path: '/' });
    } else {
      const hasGetUserInfo = store.getters.name;
      if (hasGetUserInfo) {
        next();
      } else {
        try {
          await store.dispatch('user/getInfo');
          const accessRoutes = await store.dispatch('permission/generateRoutes', store.getters.roles);
          router.addRoutes(accessRoutes);
          next({ ...to, replace: true });
        } catch (error) {
          await store.dispatch('user/resetToken');
          next(`/login?redirect=${to.path}`);
        }
      }
    }
  } else {
    if (whiteList.indexOf(to.path) !== -1) {
      next();
    } else {
      next(`/login?redirect=${to.path}`);
    }
  }
});
<!-- layout/Sidebar.vue -->
<template>
  <div class="sidebar-container">
    <el-scrollbar wrap-class="scrollbar-wrapper">
      <el-menu
        :default-active="activeMenu"
        :collapse="isCollapse"
        :background-color="variables.menuBg"
        :text-color="variables.menuText"
        :unique-opened="false"
        :active-text-color="variables.menuActiveText"
        :collapse-transition="false"
        mode="vertical"
      >
        <sidebar-item
          v-for="route in permission_routes"
          :key="route.path"
          :item="route"
          :base-path="route.path"
        />
      </el-menu>
    </el-scrollbar>
  </div>
</template>

<script>
import { mapGetters } from 'vuex';
import SidebarItem from './SidebarItem';
import variables from '@/assets/styles/variables.scss';

export default {
  name: 'Sidebar',
  components: { SidebarItem },
  computed: {
    ...mapGetters([
      'permission_routes',
      'sidebar'
    ]),
    activeMenu() {
      const route = this.$route;
      const { meta, path } = route;
      if (meta.activeMenu) {
        return meta.activeMenu;
      }
      return path;
    },
    isCollapse() {
      return !this.sidebar.opened;
    },
    variables() {
      return variables;
    }
  }
};
</script>

5.4 Excel 导入导出实战

核心作用

企业级系统必备功能,支持批量数据导入和导出

# 安装依赖
npm install xlsx file-saver -S
npm install script-loader -D
// utils/export2Excel.js
import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver';

export function export_json_to_excel({
  multiHeader = [],
  header,
  data,
  filename,
  merges = [],
  autoWidth = true,
  bookType = 'xlsx'
} = {}) {
  filename = filename || 'excel-list';
  data = [...data];
  data.unshift(header);

  for (let i = multiHeader.length - 1; i > -1; i--) {
    data.unshift(multiHeader[i]);
  }

  const ws_name = 'SheetJS';
  const wb = XLSX.utils.book_new();
  const ws = XLSX.utils.aoa_to_sheet(data);

  if (merges.length > 0) {
    if (!ws['!merges']) ws['!merges'] = [];
    merges.forEach(item => {
      ws['!merges'].push(XLSX.utils.decode_range(item));
    });
  }

  if (autoWidth) {
    const colWidth = data.map(row => row.map(val => {
      if (val == null) {
        return { wch: 10 };
      } else if (val.toString().charCodeAt(0) > 255) {
        return { wch: val.toString().length * 2 + 2 };
      } else {
        return { wch: val.toString().length + 2 };
      }
    }));
    const result = colWidth[0];
    for (let i = 1; i < colWidth.length; i++) {
      for (let j = 0; j < colWidth[i].length; j++) {
        if (result[j]['wch'] < colWidth[i][j]['wch']) {
          result[j]['wch'] = colWidth[i][j]['wch'];
        }
      }
    }
    ws['!cols'] = result;
  }

  XLSX.utils.book_append_sheet(wb, ws, ws_name);
  const wbout = XLSX.write(wb, { bookType: bookType, bookSST: false, type: 'binary' });
  saveAs(new Blob([s2ab(wbout)], { type: 'application/octet-stream' }), `${filename}.${bookType}`);
}

function s2ab(s) {
  if (typeof ArrayBuffer !== 'undefined') {
    const buf = new ArrayBuffer(s.length);
    const view = new Uint8Array(buf);
    for (let i = 0; i != s.length; ++i) view[i] = s.charCodeAt(i) & 0xFF;
    return buf;
  } else {
    const buf = new Array(s.length);
    for (let i = 0; i != s.length; ++i) buf[i] = s.charCodeAt(i) & 0xFF;
    return buf;
  }
}
// utils/importExcel.js
import * as XLSX from 'xlsx';

export function readExcel(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = (e) => {
      const data = e.target.result;
      const workbook = XLSX.read(data, { type: 'binary' });
      const firstSheetName = workbook.SheetNames[0];
      const worksheet = workbook.Sheets[firstSheetName];
      const jsonData = XLSX.utils.sheet_to_json(worksheet);
      resolve(jsonData);
    };
    reader.onerror = (error) => reject(error);
    reader.readAsBinaryString(file.raw);
  });
}

export function generateTemplate({ header = [], filename = 'template' }) {
  const data = [header];
  const ws_name = 'Sheet1';
  const wb = XLSX.utils.book_new();
  const ws = XLSX.utils.aoa_to_sheet(data);
  XLSX.utils.book_append_sheet(wb, ws, ws_name);
  XLSX.writeFile(wb, `${filename}.xlsx`);
}
<!-- 组件中使用 -->
<template>
  <div>
    <el-button @click="handleDownloadTemplate">下载模板</el-button>
    <el-upload
      class="upload-demo"
      action=""
      :auto-upload="false"
      :on-change="handleImport"
      :show-file-list="false"
      accept=".xlsx,.xls"
    >
      <el-button type="primary">导入Excel</el-button>
    </el-upload>
    <el-button type="success" @click="handleExport">导出Excel</el-button>
  </div>
</template>

<script>
import { export_json_to_excel } from '@/utils/export2Excel';
import { readExcel, generateTemplate } from '@/utils/importExcel';
import { getUserList } from '@/api/user';

export default {
  data() {
    return {
      list: []
    };
  },
  methods: {
    handleDownloadTemplate() {
      generateTemplate({
        header: ['姓名', '手机号', '邮箱', '部门'],
        filename: '用户导入模板'
      });
    },
    
    async handleImport(file) {
      try {
        const data = await readExcel(file));
        const importData = [];
        file.forEach(item => {
          importData.push({
            name: item['姓名'],
            phone: item['手机号'],
            email: item['邮箱'],
            department: item['部门']
          });
        });
        console.log('导入数据:', importData);
        this.$message.success('导入成功');
      } catch (error) {
        this.$message.error('导入失败');
      }
    },
    
    async handleExport() {
      const tHeader = ['ID', '姓名', '手机号', '邮箱', '创建时间'];
      const filterVal = ['id', 'name', 'phone', 'email', 'createTime'];
      const list = await getUserList();
      
      const data = list.map(v => filterVal.map(j => {
        if (j === 'createTime') {
          return this.parseTime(v[j]);
        }
        return v[j];
      }));
      
      export_json_to_excel({
        header: tHeader,
        data,
        filename: '用户列表',
        autoWidth: true
      });
    },
    
    parseTime(time) {
      const date = new Date(time);
      return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
    }
  }
};
</script>

5.5 图片上传与裁剪

核心作用

支持图片上传、预览、裁剪功能,适用于头像、商品图片等场景

# 安装依赖
npm install vue-cropper -S
<!-- components/ImageUpload.vue -->
<template>
  <div class="image-upload">
    <div class="image-upload__preview" v-if="imageUrl">
      <img :src="imageUrl" />
      <div class="image-upload__actions">
        <i class="el-icon-plus" @click="dialogVisible = true"></i>
        <i class="el-icon-delete" @click="handleDelete"></i>
      </div>
    </div>
    <el-upload
      v-else
      class="image-uploader"
      :show-file-list="false"
      :before-upload="beforeUpload"
      :http-request="handleUpload"
      accept="image/*"
    >
      <i class="el-icon-plus"></i>
    </el-upload>
    
    <el-dialog
      title="图片裁剪"
      :visible.sync="dialogVisible"
      width="600px"
      append-to-body
    >
      <div class="cropper-container">
        <vue-cropper
          ref="cropper"
          :img="option.img"
          :outputSize="option.size"
          :outputType="option.outputType"
          :info="option.info"
          :full="option.full"
          :canMove="option.canMove"
          :canMoveBox="option.canMoveBox"
          :original="option.original"
          :autoCrop="option.autoCrop"
          :autoCropWidth="option.autoCropWidth"
          :autoCropHeight="option.autoCropHeight"
          :fixedBox="option.fixedBox"
          :fixed="option.fixed"
          :fixedNumber="option.fixedNumber"
        ></vue-cropper>
      </div>
      <div slot="footer" class="dialog-footer">
        <el-button @click="dialogVisible = false">取 消</el-button>
        <el-button type="primary" @click="finishCrop">确 定</el-button>
      </div>
    </el-dialog>
  </div>
</template>

<script>
import { VueCropper } from 'vue-cropper';
import { uploadImage } from '@/api/common';

export default {
  name: 'ImageUpload',
  components: { VueCropper },
  props: {
    value: {
      type: String,
      default: ''
    },
    width: {
      type: Number,
      default: 200
    },
    height: {
      type: Number,
      default: 200
    },
    aspectRatio: {
      type: Array,
      default: () => [1, 1]
    }
  },
  data() {
    return {
      imageUrl: this.value,
      dialogVisible: false,
      option: {
        img: '',
        size: 1,
        outputType: 'jpeg',
        info: true,
        full: false,
        canMove: true,
        canMoveBox: true,
        original: false,
        autoCrop: true,
        autoCropWidth: this.width,
        autoCropHeight: this.height,
        fixedBox: false,
        fixed: true,
        fixedNumber: this.aspectRatio
      },
      uploadFile: null
    };
  },
  watch: {
    value(val) {
      this.imageUrl = val;
    }
  },
  methods: {
    beforeUpload(file) {
      const isJPG = file.type === 'image/jpeg' || file.type === 'image/jpg' || file.type === 'image/png';
      const isLt2M = file.size / 1024 / 1024 < 2;

      if (!isJPG) {
        this.$message.error('上传图片只能是 JPG/PNG 格式!');
        return false;
      }
      if (!isLt2M) {
        this.$message.error('上传图片大小不能超过 2MB!');
        return false;
      }
      
      this.uploadFile = file;
      const reader = new FileReader();
      reader.readAsDataURL(file);
      reader.onload = (e) => {
        this.option.img = e.target.result;
        this.dialogVisible = true;
      };
      return false;
    },
    
    handleUpload(file) {
    },
    
    finishCrop() {
      this.$refs.cropper.getCropBlob((data) => {
        const formData = new FormData();
        formData.append('file', data, 'avatar.jpg');
        
        uploadImage(formData).then(res => {
          this.imageUrl = res.data.url;
          this.$emit('input', this.imageUrl);
          this.$emit('change', this.imageUrl);
          this.dialogVisible = false;
          this.$message.success('上传成功');
        });
      });
    },
    
    handleDelete() {
      this.imageUrl = '';
      this.$emit('input', '');
      this.$emit('change', '');
    }
  }
};
</script>

<style lang="scss" scoped>
.image-upload {
  .image-upload__preview {
    position: relative;
    width: 148px;
    height: 148px;
    border: 1px dashed #d9d9d9;
    border-radius: 6px;
    cursor: pointer;
    overflow: hidden;
    
    img {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
    
    &:hover .image-upload__actions {
      display: flex;
    }
    
    .image-upload__actions {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: rgba(0, 0, 0, 0.5);
      display: none;
      align-items: center;
      justify-content: center;
      
      i {
        font-size: 24px;
        color: #fff;
        margin: 0 8px;
        cursor: pointer;
        
        &:hover {
          color: #409eff;
        }
      }
    }
  }
  
  .image-uploader {
    :deep(.el-upload) {
      border: 1px dashed #d9d9d9;
      border-radius: 6px;
      cursor: pointer;
      position: relative;
      overflow: hidden;
      transition: border-color 0.3s;
      
      &:hover {
        border-color: #409eff;
      }
      
      .el-icon-plus {
        font-size: 28px;
        color: #8c939d;
        width: 178px;
        height: 178px;
        line-height: 178px;
        text-align: center;
      }
    }
  }
  
  .cropper-container {
    height: 400px;
  }
}
</style>

5.6 全局错误处理与日志上报

核心作用

统一捕获应用错误,上报日志平台,便于问题排查

// utils/error-log.js
import Vue from 'vue';
import store from '@/store';
import { isString, isArray } from '@/utils/validate';
import settings from '@/settings';

const errorLog = store => {
  if (settings.errorLog === 'production') {
    Vue.config.errorHandler = function(err, vm, info) {
      Vue.nextTick(() => {
        store.dispatch('errorLog/addErrorLog', {
          err,
          vm,
          info,
          url: window.location.href
        });
        console.error(err, info);
      });
    };
  } else if (settings.errorLog) {
    Vue.config.errorHandler = function(err, vm, info) {
      Vue.nextTick(() => {
        store.dispatch('errorLog/addErrorLog', {
          err,
          vm,
          info,
          url: window.location.href
        });
        console.error(err, info);
      });
    };
    
    window.addEventListener('unhandledrejection', event => {
      store.dispatch('errorLog/addErrorLog', {
        err: new Error(event.reason),
        info: 'Promise',
        url: window.location.href
      });
    });
    
    window.onerror = function(message, source, lineno, colno, error) {
      store.dispatch('errorLog/addErrorLog', {
        err: error,
        info: `${message} at ${source}:${lineno}:${colno}`,
        url: window.location.href
      });
      return false;
    };
  }
};

export default errorLog;
// store/modules/errorLog.js
import { reportErrorLog } from '@/api/monitor';

const state = {
  logs: []
};

const mutations = {
  ADD_ERROR_LOG(state, log) {
    state.logs.push(log);
  },
  CLEAR_ERROR_LOG(state) {
    state.logs = [];
  }
};

const actions = {
  addErrorLog({ commit }, log) {
    commit('ADD_ERROR_LOG', log);
    reportErrorLog({
      message: log.err.message,
      stack: log.err.stack,
      info: log.info,
      url: log.url,
      userAgent: navigator.userAgent,
      timestamp: Date.now()
    }).catch(() => {});
  },
  clearErrorLog({ commit }) {
    commit('CLEAR_ERROR_LOG');
  }
};

export default {
  namespaced: true,
  state,
  mutations,
  actions
};

5.7 多环境配置

核心作用

支持开发、测试、生产等多环境配置切换

// .env.development
NODE_ENV=development
VUE_APP_BASE_API=http://dev-api.example.com
VUE_APP_TITLE=开发环境

// .env.test
NODE_ENV=production
VUE_APP_BASE_API=http://test-api.example.com
VUE_APP_TITLE=测试环境

// .env.production
NODE_ENV=production
VUE_APP_BASE_API=https://api.example.com
VUE_APP_TITLE=生产环境
// package.json
{
  "scripts": {
    "dev": "vue-cli-service serve",
    "build:test": "vue-cli-service build --mode test",
    "build:prod": "vue-cli-service build --mode production"
  }
}
// utils/request.js
const service = axios.create({
  baseURL: process.env.VUE_APP_BASE_API,
  timeout: 15000
});

5.8 大数据量表格处理方案

核心作用

处理万级以上数据的表格渲染,避免页面卡顿

方案一:虚拟滚动(推荐)
<!-- 使用 el-table-v2 或自定义虚拟列表 -->
<template>
  <div class="virtual-table-container">
    <el-table
      :data="visibleData"
      style="width: 100%"
      height="500"
      :row-key="row => row.id"
    >
      <el-table-column prop="id" label="ID" width="80" />
      <el-table-column prop="name" label="姓名" width="120" />
      <el-table-column prop="email" label="邮箱" width="200" />
      <el-table-column prop="phone" label="手机号" width="130" />
      <el-table-column prop="createTime" label="创建时间" width="180" />
    </el-table>
    
    <div 
      ref="scrollBar"
      class="scroll-bar"
      @scroll="handleScroll"
    >
      <div 
        class="scroll-content"
        :style="{ height: totalHeight + 'px' }"
      ></div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'VirtualTable',
  props: {
    data: {
      type: Array,
      default: () => []
    },
    rowHeight: {
      type: Number,
      default: 48
    }
  },
  data() {
    return {
      startIndex: 0,
      endIndex: 0,
      containerHeight: 500
    };
  },
  computed: {
    totalHeight() {
      return this.data.length * this.rowHeight;
    },
    visibleCount() {
      return Math.ceil(this.containerHeight / this.rowHeight) + 2;
    },
    visibleData() {
      return this.data.slice(this.startIndex, this.endIndex);
    },
    offset() {
      return this.startIndex * this.rowHeight;
    }
  },
  mounted() {
    this.endIndex = this.startIndex + this.visibleCount;
  },
  methods: {
    handleScroll(e) {
      const scrollTop = e.target.scrollTop;
      this.startIndex = Math.floor(scrollTop / this.rowHeight);
      this.endIndex = this.startIndex + this.visibleCount;
    }
  }
};
</script>

<style scoped>
.virtual-table-container {
  position: relative;
}
.scroll-bar {
  position: absolute;
  right: 0;
  top: 0;
  width: 100%;
  height: 500px;
  overflow-y: auto;
}
.scroll-content {
  width: 1px;
}
</style>
方案二:分页加载(常用)
export default {
  data() {
    return {
      list: [],
      loading: false,
      pagination: {
        current: 1,
        pageSize: 20,
        total: 0
      }
    };
  },
  methods: {
    async fetchData() {
      this.loading = true;
      try {
        const res = await getUserList({
          page: this.pagination.current,
          pageSize: this.pagination.pageSize
        });
        this.list = res.data.list;
        this.pagination.total = res.data.total;
      } finally {
        this.loading = false;
      }
    },
    handleCurrentChange(page) {
      this.pagination.current = page;
      this.fetchData();
    },
    handleSizeChange(size) {
      this.pagination.pageSize = size;
      this.pagination.current = 1;
      this.fetchData();
    }
  }
};

总结(补充)

完整学习路径

  1. 第一阶段:掌握核心基础模块(响应式原理、选项式 API、生命周期)
  2. 第二阶段:重点学习高频技巧(组件通信、表单校验、路由、Axios)
  3. 第三阶段:按需学习进阶优化(性能优化、打包优化)
  4. 第四阶段:实战企业级场景(TagsView、动态菜单、Excel、图片上传)
  5. 第五阶段:了解冷门知识点,为 Vue3 迁移做准备

企业级项目架构建议(完整版)

  • ✅ 使用 Vuex 管理全局状态(user、app、tagsView、permission、errorLog)
  • ✅ 统一的 Axios 封装(请求/响应拦截、错误处理、请求取消)
  • ✅ 路由守卫做权限控制(全局守卫、路由独享守卫)
  • ✅ Mixin 谨慎使用,考虑 Utils 函数
  • ✅ 自定义指令封装常用 DOM 操作
  • ✅ TagsView 多标签页导航
  • ✅ 动态菜单与路由权限管理
  • ✅ Excel 导入导出功能
  • ✅ 图片上传与裁剪
  • ✅ 全局错误处理与日志上报
  • ✅ 多环境配置
  • ✅ 大数据量表格处理方案
0

评论

博主关闭了所有页面的评论