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 潜在问题:
- 命名冲突:多个 mixin 或组件有同名属性/方法,后者覆盖前者
- 来源不透明:难以追踪数据/方法来自哪个 mixin
- 隐式依赖: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 侦听器
| 特性 | computed | methods | watch |
|---|---|---|---|
| 缓存 | ✅ 有缓存,依赖不变不重新计算 | ❌ 无缓存,每次调用都执行 | - |
| 异步 | ❌ 不支持 | ✅ 支持 | ✅ 支持 |
| 返回值 | ✅ 必须有返回值 | 可选 | 可选 |
| 适用场景 | 计算衍生值,有缓存需求 | 事件处理、业务逻辑 | 数据变化执行异步操作 |
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 响应式系统的两个限制:
- 不能直接添加新的响应式属性到已创建的实例
- 不能通过数组下标直接修改元素
适用场景
- 动态添加响应式属性
- 通过下标修改数组元素
使用限制
- 不能给 Vue 实例或根数据对象添加根级响应式属性
4.3 内置过渡动画
核心概念
Vue 提供的 transition 和 transition-group 组件,用于元素插入、更新、移除时的动画效果
少用场景
- 频繁触发的动画(影响性能)
- 复杂动画(推荐使用 CSS 动画库如 animate.css)
性能影响
- 频繁的 DOM 操作可能导致布局抖动
- 建议使用 CSS transforms 和 opacity(GPU 加速)
总结
学习路径建议
- 第一阶段:掌握核心基础模块(响应式原理、选项式 API、生命周期)
- 第二阶段:重点学习高频技巧(组件通信、表单校验、路由、Axios)
- 第三阶段:按需学习进阶优化(性能优化、打包优化)
- 第四阶段:了解冷门知识点,为 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();
}
}
};总结(补充)
完整学习路径
- 第一阶段:掌握核心基础模块(响应式原理、选项式 API、生命周期)
- 第二阶段:重点学习高频技巧(组件通信、表单校验、路由、Axios)
- 第三阶段:按需学习进阶优化(性能优化、打包优化)
- 第四阶段:实战企业级场景(TagsView、动态菜单、Excel、图片上传)
- 第五阶段:了解冷门知识点,为 Vue3 迁移做准备
企业级项目架构建议(完整版)
- ✅ 使用 Vuex 管理全局状态(user、app、tagsView、permission、errorLog)
- ✅ 统一的 Axios 封装(请求/响应拦截、错误处理、请求取消)
- ✅ 路由守卫做权限控制(全局守卫、路由独享守卫)
- ✅ Mixin 谨慎使用,考虑 Utils 函数
- ✅ 自定义指令封装常用 DOM 操作
- ✅ TagsView 多标签页导航
- ✅ 动态菜单与路由权限管理
- ✅ Excel 导入导出功能
- ✅ 图片上传与裁剪
- ✅ 全局错误处理与日志上报
- ✅ 多环境配置
- ✅ 大数据量表格处理方案
评论