首页
壁纸
统计
友链
Search
1
Nestjs概述-中文
10 阅读
2
ExpressAPI
10 阅读
3
NestJS新手入门核心模块对比表笔记
9 阅读
4
JavaScript企业数据处理实用指南
9 阅读
5
Vue2详细笔记
8 阅读
Nodejs
Vue
Java
Msql
登录
Search
Wasnl
累计撰写
12
篇文章
累计收到
1
条评论
首页
栏目
Nodejs
Vue
Java
Msql
页面
壁纸
统计
友链
搜索到
12
篇与
的结果
2026-03-17
Vue2详细笔记
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 已更新操作更新后的 DOMbeforeDestroy实例销毁前✅ 清除定时器、取消事件监听、销毁第三方实例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 侦听器特性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 响应式系统的两个限制:不能直接添加新的响应式属性到已创建的实例不能通过数组下标直接修改元素适用场景动态添加响应式属性通过下标修改数组元素使用限制不能给 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 导入导出功能✅ 图片上传与裁剪✅ 全局错误处理与日志上报✅ 多环境配置✅ 大数据量表格处理方案
2026年03月17日
8 阅读
0 评论
0 点赞
2026-02-24
NestJS新手入门核心模块对比表笔记
NestJS新手入门核心模块对比表笔记说明:表格严格遵循NestJS官方文档定义,重点标注各模块「声明函数/接口」「核心用法」,适配快速记忆,所有接口、装饰器均与官方保持一致,如内容有问题可以联系作者修改,谢谢。模块名称官方核心定位声明函数/核心接口(必填)接口参数/核心方法官方核心装饰器执行时机官方核心作用Controllers (控制器)请求处理入口,负责接收客户端请求并返回响应无强制接口,通过「类+装饰器」声明请求装饰器参数(路由路径、请求方法);处理方法接收请求参数@Controller()、@Get()、@Post()、@Param()、@Body()、@Query()请求进入后,中间件之后路由映射、请求接收、响应返回Providers (提供者)可注入的依赖单元,封装业务逻辑(核心为Service)无接口,通过@Injectable()标记类即可声明自定义业务方法(如create、findAll),支持构造函数注入@Injectable()(必填,标记为可注入)被依赖组件(如Controller)调用时封装业务逻辑、解耦、支持依赖注入Modules (模块)应用组织单元,管理模块内组件及依赖关系无接口,通过@Module()装饰器声明类@Module()配置对象(providers、controllers、imports、exports)@Module()(必填)、@Global()(可选,全局模块)应用启动时加载划分功能边界、管理依赖导入导出、组件共享Middleware (中间件)请求处理流程中的中间层,可拦截请求/响应类式:implements NestMiddleware;函数式:直接声明函数use(req: Request, res: Response, next: NextFunction)(类式/函数式通用)@Injectable()(仅类式中间件必填)请求进入最前端,控制器之前日志记录、请求验证、跨域处理等通用逻辑Exception Filters (异常过滤器)统一处理应用中未捕获的异常,格式化异常响应implements ExceptionFiltercatch(exception: any, host: ArgumentsHost)(必填方法)@Injectable()(必填)、@Catch()(必填,指定捕获的异常类型)发生未捕获异常时统一异常响应格式、自定义异常处理逻辑Pipes (管道)处理控制器方法参数,实现参数验证、转换implements PipeTransform<T, R>(T:输入类型,R:输出类型)transform(value: T, metadata: ArgumentMetadata)(必填方法)@Injectable()(必填)控制器方法调用前,参数解析后参数验证、参数类型转换、数据格式化Guards (守卫)请求授权控制,决定请求是否能进入控制器方法implements CanActivatecanActivate(context: ExecutionContext)(必填方法,返回布尔值)@Injectable()(必填)中间件之后,管道之前权限校验、角色控制、请求合法性判断Interceptors (拦截器)面向切面编程,拦截控制器方法执行前后逻辑implements NestInterceptor<T, R>(T:输入,R:输出)intercept(context: ExecutionContext, next: CallHandler)(必填方法)@Injectable()(必填)管道之后,控制器方法执行前后日志记录、响应映射、异常映射、请求/响应拦截Custom decorators (自定义装饰器)封装装饰器逻辑,扩展Nest装饰器功能参数装饰器:createParamDecorator;装饰器组合:applyDecoratorscreateParamDecorator((data, ctx) => { ... });applyDecorators(多个装饰器)无必填装饰器,可组合官方装饰器使用被装饰的类/方法/参数初始化时简化代码、复用装饰器逻辑、扩展官方功能
2026年02月24日
9 阅读
0 评论
0 点赞
2026-02-24
NestJS 自定义 Provider
一、依赖注入(DI)基础依赖注入是控制反转(IoC) 技术,将依赖的实例化交给 Nest 运行时容器,而非手动代码创建。IOC 容器就是 Nest 负责 “接管依赖实例化” 的核心,依赖注入(DI)是 IOC 思想在 Nest 里的具体实现方式。工作三步流程标记可注入@Injectable() 装饰器 → 声明类可被 Nest IoC 容器管理。声明依赖控制器构造函数 constructor(private service: XxxService) → 声明依赖。注册提供者模块 @Module 的 providers 数组 → 绑定 Token 与实现类。容器行为实例化时自动解析依赖,递归处理依赖链(自底向上)。默认单例:创建 → 缓存 → 复用。二、标准 Provider(简写)providers: [CatsService]等价完整写法:providers: [ { provide: CatsService, useClass: CatsService, } ]provide:Token(注入令牌)useClass:实际实例化的类三、自定义 Provider 五种用法1. 值提供者:useValue注入常量、外部库、模拟对象。适合测试替换真实服务。{ provide: CatsService, useValue: mockCatsService }2. 非类 Token(字符串 / Symbol / 枚举)用字符串做令牌,配合 @Inject() 使用。{ provide: 'CONNECTION', useValue: connection }注入:constructor(@Inject('CONNECTION') connection: Connection) {}3. 类提供者:useClass动态切换实现类(如开发/生产环境)。{ provide: ConfigService, useClass: process.env.NODE_ENV === 'development' ? DevelopmentConfigService : ProductionConfigService }4. 工厂提供者:useFactory(最灵活)动态创建实例,可注入其他依赖。inject 数组 → 按顺序传入工厂函数参数。{ provide: 'CONNECTION', useFactory: (optionsProvider: MyOptionsProvider) => { return new DatabaseConnection(optionsProvider.get()); }, inject: [MyOptionsProvider] }支持可选依赖:inject: [{ token: 'OptionalProvider', optional: true }]5. 别名提供者:useExisting给已有 Provider 起别名,指向同一个单例。{ provide: 'AliasedLogger', useExisting: LoggerService }四、导出自定义 ProviderProvider 默认模块内可见,跨模块需导出:// 按 Token 导出 exports: ['CONNECTION'] // 或完整对象导出 exports: [connectionFactory]五、核心总结@Injectable() → 可被容器管理provide → 唯一 Token五大实现:useValue:常量/对象useClass:动态类useFactory:动态工厂(支持依赖)useExisting:别名类简写:providers: [Service]跨模块使用必须 exports
2026年02月24日
7 阅读
0 评论
1 点赞
2026-02-24
NestJS 内置 Pipes笔记
NestJS 内置 Pipes笔记一、概述Nest 内置 Pipe 均从 @nestjs/common 导出,用于参数验证、类型转换、默认值等场景,可直接在控制器路由、DTO、全局配置中使用。二、内置 Pipe 清单与作用1. 验证类1.1 ValidationPipe作用:最常用的全局验证管道,配合 class-validator + class-transformer 对 DTO 进行自动校验。场景:校验请求体、查询参数、路径参数的合法性。常用配置:whitelist: true:过滤掉非 DTO 定义的字段forbidNonWhitelisted: true:禁止传入多余字段transform: true:自动将原始值转为 DTO 对应类型2. 基础类型转换2.1 ParseIntPipe作用:将参数转为整数,转换失败抛出 400 Bad Request。场景:id、page、limit 等数字路径参数。2.2 ParseFloatPipe作用:转为浮点数。场景:价格、经纬度、小数类参数。2.3 ParseBoolPipe作用:转为布尔值,支持 true/false、1/0、'true'/'false'。场景:开关、状态查询参数。3. 格式/结构校验3.1 ParseUUIDPipe作用:校验参数是否为合法 UUID(v4 等),不合法则报错。场景:用户 ID、订单号等 UUID 格式参数。3.2 ParseEnumPipe作用:校验参数是否属于指定枚举。场景:状态、类型、固定选项参数。3.3 ParseArrayPipe作用:将字符串转为数组,可指定分隔符、校验每项类型。场景:批量 ID、多选参数 ?ids=1,2,3。3.4 ParseDatePipe作用:将字符串/时间戳转为 Date 对象,非法日期报错。场景:开始时间、结束时间等日期参数。4. 文件与默认值4.1 DefaultValuePipe作用:为参数提供默认值,不与其他 Pipe 冲突。场景:分页默认 page=1、limit=10。4.2 ParseFilePipe作用:上传文件校验,可校验文件类型、大小、数量等。场景:单文件/多文件上传校验。三、典型使用方式(极简示例)1. 路由参数直接使用@Get(':id') getOne(@Param('id', ParseIntPipe) id: number) {}2. 带默认值@Get() getList( @Query('page', ParseIntPipe, new DefaultValuePipe(1)) page: number, ) {}3. 枚举校验@Get('type/:type') getByType( @Param('type', new ParseEnumPipe(StatusEnum)) type: StatusEnum, ) {}4. 全局启用 ValidationPipe(推荐)// main.ts app.useGlobalPipes( new ValidationPipe({ transform: true, whitelist: true, }), );四、使用总结校验 + 转换:统一由 Pipe 完成,控制器代码更干净。失败自动抛错:返回标准 400 错误,无需手动判断。可组合:多个 Pipe 可按顺序链式使用。
2026年02月24日
3 阅读
0 评论
0 点赞
2026-02-24
Nest.js 内置 HTTP 异常笔记
Nest.js 内置 HTTP 异常笔记一、核心概念Nest.js 从 @nestjs/common 导出的所有内置 HTTP 异常均继承自 HttpException 基类,每个异常对应标准的 HTTP 状态码,用于在控制器/服务中快速抛出符合 RESTful 规范的错误响应,无需手动拼接状态码和响应格式。二、完整内置异常列表(含状态码+使用场景)异常类名HTTP 状态码核心使用场景BadRequestException400请求参数错误、格式非法(如参数校验失败)UnauthorizedException401未认证(如无 token、token 无效/过期)NotFoundException404资源不存在(如查询 ID 不存在的用户/文章)ForbiddenException403已认证但无权限操作(如普通用户访问管理员接口)NotAcceptableException406服务器无法生成客户端请求的内容格式(如客户端要求 XML 但仅支持 JSON)RequestTimeoutException408请求超时(如客户端请求未在指定时间内完成)ConflictException409资源冲突(如创建已存在的用户名、重复提交表单)GoneException410资源永久删除(如已下架的商品,无法恢复)HttpVersionNotSupportedException505不支持的 HTTP 协议版本(如客户端用 HTTP/3 但服务器仅支持 HTTP/1.1)PayloadTooLargeException413请求体过大(如上传文件超过服务器限制)UnsupportedMediaTypeException415不支持的媒体类型(如上传图片时 Content-Type 为 text/plain)UnprocessableEntityException422请求体格式正确但语义错误(如手机号格式正确但非合法号码)InternalServerErrorException500服务器内部错误(如代码逻辑异常、数据库连接失败)NotImplementedException501接口未实现(如规划中的接口暂未开发)ImATeapotException418趣味异常(RFC 2324 定义,实际极少使用,可用于测试)MethodNotAllowedException405请求方法不允许(如 GET 访问仅支持 POST 的接口)BadGatewayException502网关错误(如 Nest 作为网关转发请求时,后端服务返回无效响应)ServiceUnavailableException503服务不可用(如服务器维护、过载,暂时无法处理请求)GatewayTimeoutException504网关超时(如 Nest 转发请求时,后端服务响应超时)PreconditionFailedException412前置条件失败(如请求头中 If-Match 与服务器资源版本不匹配)三、基础使用方式1. 最简使用(仅指定错误消息)import { Controller, Get, NotFoundException } from '@nestjs/common'; @Controller('users') export class UsersController { @Get(':id') findOne(@Param('id') id: string) { const user = null; // 模拟查询不到用户 if (!user) { // 仅指定消息,error 字段会默认显示 HTTP 状态码对应的默认描述(如 "Not Found") throw new NotFoundException(`User with ID ${id} not found`); } return user; } }响应结果:{ "message": "User with ID 123 not found", "error": "Not Found", "statusCode": 404 }2. 完整使用(指定消息+原因+描述)import { Controller, Post, BadRequestException } from '@nestjs/common'; @Controller('users') export class UsersController { @Post() create(@Body() createUserDto: any) { try { // 模拟参数校验失败 if (!createUserDto.username) { throw new Error('Username is required'); } } catch (error) { throw new BadRequestException('Failed to create user', { cause: error, // 原始错误(用于日志排查,不会返回给客户端) description: 'Username is a mandatory field', // 客户端可见的详细描述 }); } } }响应结果:{ "message": "Failed to create user", "error": "Username is a mandatory field", "statusCode": 400 }四、进阶用法:自定义 HTTP 异常若内置异常无法满足需求(如自定义状态码/响应格式),可继承 HttpException 实现自定义异常:import { HttpException, HttpStatus } from '@nestjs/common'; // 自定义异常:请求频率过高(429 状态码) export class TooManyRequestsException extends HttpException { constructor(message: string = 'Too many requests', description?: string) { super( { message, error: description || 'Too Many Requests', statusCode: HttpStatus.TOO_MANY_REQUESTS, // 429 }, HttpStatus.TOO_MANY_REQUESTS, ); } } // 使用自定义异常 @Controller('api') export class ApiController { @Get() getData() { throw new TooManyRequestsException('请求过于频繁', '请等待 60 秒后重试'); } }响应结果:{ "message": "请求过于频繁", "error": "请等待 60 秒后重试", "statusCode": 429 }五、全局异常过滤器(统一响应格式)实际开发中,通常会自定义全局异常过滤器,统一所有 HTTP 异常的响应格式(如增加 timestamp 字段):import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common'; import { Response } from 'express'; @Catch(HttpException) export class HttpExceptionFilter implements ExceptionFilter { catch(exception: HttpException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const status = exception.getStatus(); const exceptionResponse = exception.getResponse() as { message: string; error: string }; response.status(status).json({ code: status, // 自定义码段 msg: exceptionResponse.message, detail: exceptionResponse.error, timestamp: new Date().toISOString(), // 错误时间 path: ctx.getRequest().url, // 请求路径 }); } } // 在 main.ts 注册全局过滤器 import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { HttpExceptionFilter } from './filters/http-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalFilters(new HttpExceptionFilter()); // 全局生效 await app.listen(3000); } bootstrap();统一后的响应示例:{ "code": 400, "msg": "Something bad happened", "detail": "Some error description", "timestamp": "2026-02-24T10:00:00.000Z", "path": "/users" }总结Nest 内置 HTTP 异常覆盖了所有常见 HTTP 状态码,每个异常对应明确的业务场景,可直接抛出无需手动处理状态码;内置异常支持自定义 message、cause(日志用)和 description(客户端可见),兼顾排查和用户体验;若内置异常不满足需求,可继承 HttpException 实现自定义异常,或通过全局过滤器统一响应格式。
2026年02月24日
7 阅读
0 评论
1 点赞
1
2
3