引言
在现代Web开发中,前端框架和UI库的选择对项目的成功至关重要。Element Plus作为基于Vue 3的优秀UI组件库,凭借其丰富的组件、良好的文档和活跃的社区,成为许多企业级项目的首选。本文将从零开始,详细解析如何使用Element Plus构建一个完整的前端项目,并涵盖从项目搭建、开发实践到性能优化的完整流程。
一、项目初始化与环境搭建
1.1 技术栈选择
在开始之前,我们需要明确技术栈:
- 前端框架: Vue 3 (Composition API)
- UI库: Element Plus
- 构建工具: Vite (推荐) 或 Vue CLI
- 路由: Vue Router 4
- 状态管理: Pinia (推荐) 或 Vuex
- 样式预处理: Sass/SCSS
- 代码规范: ESLint + Prettier
- 版本控制: Git
1.2 使用Vite创建项目
Vite是新一代前端构建工具,具有极快的启动速度和热更新效率。
# 使用npm创建项目
npm create vite@latest my-element-project -- --template vue
# 进入项目目录
cd my-element-project
# 安装依赖
npm install
1.3 安装Element Plus及相关依赖
# 安装Element Plus
npm install element-plus --save
# 安装Vue Router
npm install vue-router@4
# 安装Pinia
npm install pinia
# 安装Sass支持
npm install -D sass
# 安装图标库(可选)
npm install @element-plus/icons-vue
1.4 项目结构规划
推荐的项目结构如下:
my-element-project/
├── public/ # 静态资源
├── src/
│ ├── assets/ # 静态资源(图片、字体等)
│ ├── components/ # 公共组件
│ ├── views/ # 页面视图
│ ├── router/ # 路由配置
│ ├── store/ # 状态管理(Pinia)
│ ├── utils/ # 工具函数
│ ├── services/ # API服务
│ ├── App.vue # 根组件
│ └── main.ts # 入口文件
├── .env.development # 开发环境变量
├── .env.production # 生产环境变量
├── .eslintrc.cjs # ESLint配置
├── .prettierrc # Prettier配置
├── vite.config.ts # Vite配置
└── package.json
1.5 配置Vite和Element Plus
vite.config.ts 配置:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 3000,
open: true,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
build: {
outDir: 'dist',
assetsDir: 'assets',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
'element-plus': ['element-plus'],
'vue': ['vue', 'vue-router', 'pinia']
}
}
}
}
})
main.ts 配置Element Plus:
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
const app = createApp(App)
// 全局注册Element Plus
app.use(ElementPlus)
// 全局注册图标组件
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 注册路由和状态管理
app.use(router)
app.use(createPinia())
app.mount('#app')
二、路由与状态管理配置
2.1 Vue Router 4配置
src/router/index.ts:
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
// 路由懒加载
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: { title: '首页' }
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '仪表盘', requiresAuth: true }
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录' }
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
}
})
// 路由守卫 - 权限控制
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
const requiresAuth = to.meta.requiresAuth as boolean
if (requiresAuth && !token) {
next({ name: 'Login', query: { redirect: to.fullPath } })
} else if (to.name === 'Login' && token) {
next({ name: 'Dashboard' })
} else {
next()
}
})
export default router
2.2 Pinia状态管理配置
src/store/user.ts (用户状态管理):
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User } from '@/types'
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(localStorage.getItem('token'))
const isAuthenticated = computed(() => !!token.value)
const username = computed(() => user.value?.username || '')
const login = async (credentials: { username: string; password: string }) => {
try {
// 模拟API调用
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
})
const data = await response.json()
if (data.success) {
token.value = data.token
user.value = data.user
localStorage.setItem('token', data.token)
return true
}
return false
} catch (error) {
console.error('登录失败:', error)
return false
}
}
const logout = () => {
token.value = null
user.value = null
localStorage.removeItem('token')
}
const fetchUserInfo = async () => {
if (!token.value) return
try {
const response = await fetch('/api/user/info', {
headers: { 'Authorization': `Bearer ${token.value}` }
})
const data = await response.json()
if (data.success) {
user.value = data.user
}
} catch (error) {
console.error('获取用户信息失败:', error)
}
}
return {
user,
token,
isAuthenticated,
username,
login,
logout,
fetchUserInfo
}
})
src/store/app.ts (应用状态管理):
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAppStore = defineStore('app', () => {
const sidebarCollapsed = ref(false)
const theme = ref<'light' | 'dark'>('light')
const loading = ref(false)
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
const setTheme = (newTheme: 'light' | 'dark') => {
theme.value = newTheme
document.documentElement.setAttribute('data-theme', newTheme)
}
const setLoading = (value: boolean) => {
loading.value = value
}
return {
sidebarCollapsed,
theme,
loading,
toggleSidebar,
setTheme,
setLoading
}
})
三、核心组件开发实践
3.1 登录页面实现
src/views/Login.vue:
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<div class="card-header">
<h2>系统登录</h2>
</div>
</template>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
label-width="80px"
@submit.prevent="handleLogin"
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入用户名"
:prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
show-password
clearable
/>
</el-form-item>
<el-form-item label="记住我">
<el-switch v-model="loginForm.remember" />
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
native-type="submit"
style="width: 100%"
>
登录
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<el-link type="primary">忘记密码?</el-link>
<el-link type="success" @click="$router.push('/register')">注册账号</el-link>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loginFormRef = ref<FormInstance>()
const loading = ref(false)
const loginForm = reactive({
username: '',
password: '',
remember: false
})
const loginRules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在3到20个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
]
}
const handleLogin = async () => {
if (!loginFormRef.value) return
try {
const valid = await loginFormRef.value.validate()
if (!valid) return
loading.value = true
const success = await userStore.login({
username: loginForm.username,
password: loginForm.password
})
if (success) {
ElMessage.success('登录成功')
// 记住登录状态
if (loginForm.remember) {
localStorage.setItem('rememberedUsername', loginForm.username)
} else {
localStorage.removeItem('rememberedUsername')
}
// 跳转到原页面或首页
const redirect = (route.query.redirect as string) || '/dashboard'
router.push(redirect)
} else {
ElMessage.error('用户名或密码错误')
}
} catch (error) {
ElMessage.error('登录失败,请稍后重试')
} finally {
loading.value = false
}
}
// 页面加载时检查记住的用户名
const rememberedUsername = localStorage.getItem('rememberedUsername')
if (rememberedUsername) {
loginForm.username = rememberedUsername
loginForm.remember = true
}
</script>
<style scoped lang="scss">
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.card-header {
text-align: center;
color: #409EFF;
}
.login-footer {
display: flex;
justify-content: space-between;
margin-top: 16px;
}
</style>
3.2 仪表盘页面实现
src/views/Dashboard.vue:
<template>
<div class="dashboard">
<!-- 顶部统计卡片 -->
<el-row :gutter="20" class="stats-row">
<el-col :xs="24" :sm="12" :md="6" v-for="stat in stats" :key="stat.title">
<el-card class="stat-card" shadow="hover">
<div class="stat-content">
<div class="stat-icon" :style="{ background: stat.color }">
<el-icon :size="24"><component :is="stat.icon" /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-title">{{ stat.title }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="20" class="chart-row">
<el-col :xs="24" :md="12">
<el-card class="chart-card">
<template #header>
<div class="card-header">
<span>月度销售趋势</span>
<el-button type="primary" size="small" @click="refreshChart">
刷新
</el-button>
</div>
</template>
<div ref="lineChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card class="chart-card">
<template #header>
<div class="card-header">
<span>产品分布</span>
<el-button type="primary" size="small" @click="refreshPieChart">
刷新
</el-button>
</div>
</template>
<div ref="pieChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
<!-- 数据表格 -->
<el-card class="table-card">
<template #header>
<div class="card-header">
<span>最近订单</span>
<el-button type="primary" size="small" @click="handleAdd">
新增订单
</el-button>
</div>
</template>
<el-table
v-loading="tableLoading"
:data="tableData"
stripe
border
style="width: 100%"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="orderNo" label="订单号" width="180" />
<el-table-column prop="customer" label="客户" />
<el-table-column prop="amount" label="金额">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold;">
¥{{ row.amount.toFixed(2) }}
</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 编辑/新增对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="500px"
@close="resetForm"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="订单号" prop="orderNo">
<el-input v-model="form.orderNo" placeholder="自动生成" disabled />
</el-form-item>
<el-form-item label="客户" prop="customer">
<el-input v-model="form.customer" placeholder="请输入客户名称" />
</el-form-item>
<el-form-item label="金额" prop="amount">
<el-input-number
v-model="form.amount"
:precision="2"
:step="100"
:min="0"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" placeholder="请选择状态" style="width: 100%">
<el-option label="待处理" value="pending" />
<el-option label="处理中" value="processing" />
<el-option label="已完成" value="completed" />
<el-option label="已取消" value="cancelled" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import * as echarts from 'echarts'
import { useAppStore } from '@/store/app'
// 图表引用
const lineChartRef = ref<HTMLElement>()
const pieChartRef = ref<HTMLElement>()
let lineChart: echarts.ECharts | null = null
let pieChart: echarts.ECharts | null = null
// 状态管理
const appStore = useAppStore()
// 统计数据
const stats = ref([
{ title: '总订单', value: '1,234', icon: 'Document', color: '#409EFF' },
{ title: '总金额', value: '¥125,680', icon: 'Money', color: '#67C23A' },
{ title: '活跃客户', value: '89', icon: 'User', color: '#E6A23C' },
{ title: '今日新增', value: '12', icon: 'Plus', color: '#F56C6C' }
])
// 表格数据
const tableLoading = ref(false)
const tableData = ref([
{ id: 1, orderNo: 'ORD202311001', customer: '张三', amount: 1250.00, status: 'completed', createdAt: '2023-11-01 10:30:00' },
{ id: 2, orderNo: 'ORD202311002', customer: '李四', amount: 890.50, status: 'processing', createdAt: '2023-11-02 14:20:00' },
{ id: 3, orderNo: 'ORD202311003', customer: '王五', amount: 2300.00, status: 'pending', createdAt: '2023-11-03 09:15:00' },
{ id: 4, orderNo: 'ORD202311004', customer: '赵六', amount: 560.00, status: 'completed', createdAt: '2023-11-04 16:45:00' },
{ id: 5, orderNo: 'ORD202311005', customer: '钱七', amount: 1800.00, status: 'cancelled', createdAt: '2023-11-05 11:30:00' }
])
// 分页
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(50)
// 对话框
const dialogVisible = ref(false)
const dialogTitle = ref('新增订单')
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const form = reactive({
id: null as number | null,
orderNo: '',
customer: '',
amount: 0,
status: 'pending'
})
const rules: FormRules = {
customer: [
{ required: true, message: '请输入客户名称', trigger: 'blur' }
],
amount: [
{ required: true, message: '请输入金额', trigger: 'blur' },
{ type: 'number', min: 0, message: '金额必须大于0', trigger: 'blur' }
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' }
]
}
// 方法
const getStatusType = (status: string) => {
const map: Record<string, string> = {
pending: 'info',
processing: 'warning',
completed: 'success',
cancelled: 'danger'
}
return map[status] || 'info'
}
const getStatusText = (status: string) => {
const map: Record<string, string> = {
pending: '待处理',
processing: '处理中',
completed: '已完成',
cancelled: '已取消'
}
return map[status] || status
}
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('zh-CN')
}
const handleSelectionChange = (selection: any[]) => {
console.log('选中的行:', selection)
}
const handleAdd = () => {
dialogTitle.value = '新增订单'
form.id = null
form.orderNo = `ORD${Date.now()}`
form.customer = ''
form.amount = 0
form.status = 'pending'
dialogVisible.value = true
}
const handleEdit = (row: any) => {
dialogTitle.value = '编辑订单'
Object.assign(form, row)
dialogVisible.value = true
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm(
`确定要删除订单 ${row.orderNo} 吗?`,
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
// 模拟删除
tableData.value = tableData.value.filter(item => item.id !== row.id)
ElMessage.success('删除成功')
} catch (error) {
// 用户取消
}
}
const resetForm = () => {
if (formRef.value) {
formRef.value.resetFields()
}
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
const valid = await formRef.value.validate()
if (!valid) return
submitLoading.value = true
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
if (form.id) {
// 编辑
const index = tableData.value.findIndex(item => item.id === form.id)
if (index !== -1) {
tableData.value[index] = { ...form }
}
ElMessage.success('编辑成功')
} else {
// 新增
const newOrder = {
...form,
id: Date.now(),
createdAt: new Date().toLocaleString('zh-CN')
}
tableData.value.unshift(newOrder)
ElMessage.success('新增成功')
}
dialogVisible.value = false
} catch (error) {
ElMessage.error('操作失败')
} finally {
submitLoading.value = false
}
}
const handleSizeChange = (val: number) => {
pageSize.value = val
// 重新加载数据
loadTableData()
}
const handleCurrentChange = (val: number) => {
currentPage.value = val
// 重新加载数据
loadTableData()
}
const loadTableData = async () => {
tableLoading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 500))
// 这里可以调用API获取数据
console.log(`加载第 ${currentPage.value} 页,每页 ${pageSize.value} 条`)
} finally {
tableLoading.value = false
}
}
// 图表初始化
const initLineChart = () => {
if (!lineChartRef.value) return
lineChart = echarts.init(lineChartRef.value)
const option = {
tooltip: {
trigger: 'axis'
},
legend: {
data: ['销售额', '订单数']
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['1月', '2月', '3月', '4月', '5月', '6月']
},
yAxis: [
{
type: 'value',
name: '销售额',
position: 'left'
},
{
type: 'value',
name: '订单数',
position: 'right'
}
],
series: [
{
name: '销售额',
type: 'line',
smooth: true,
data: [120, 132, 101, 134, 90, 230],
itemStyle: { color: '#409EFF' }
},
{
name: '订单数',
type: 'line',
yAxisIndex: 1,
smooth: true,
data: [220, 182, 191, 234, 290, 330],
itemStyle: { color: '#67C23A' }
}
]
}
lineChart.setOption(option)
}
const initPieChart = () => {
if (!pieChartRef.value) return
pieChart = echarts.init(pieChartRef.value)
const option = {
tooltip: {
trigger: 'item'
},
legend: {
orient: 'vertical',
left: 'left'
},
series: [
{
name: '产品分布',
type: 'pie',
radius: '50%',
data: [
{ value: 1048, name: '电子产品' },
{ value: 735, name: '家居用品' },
{ value: 580, name: '服装' },
{ value: 484, name: '食品' },
{ value: 300, name: '其他' }
],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
}
pieChart.setOption(option)
}
const refreshChart = () => {
if (lineChart) {
const newData = Array.from({ length: 6 }, () => Math.floor(Math.random() * 300) + 100)
lineChart.setOption({
series: [{ data: newData }]
})
}
}
const refreshPieChart = () => {
if (pieChart) {
const newData = [
{ value: Math.floor(Math.random() * 1000) + 500, name: '电子产品' },
{ value: Math.floor(Math.random() * 800) + 400, name: '家居用品' },
{ value: Math.floor(Math.random() * 600) + 300, name: '服装' },
{ value: Math.floor(Math.random() * 500) + 200, name: '食品' },
{ value: Math.floor(Math.random() * 400) + 100, name: '其他' }
]
pieChart.setOption({
series: [{ data: newData }]
})
}
}
// 响应式图表
const handleResize = () => {
if (lineChart) lineChart.resize()
if (pieChart) pieChart.resize()
}
onMounted(() => {
// 初始化图表
setTimeout(() => {
initLineChart()
initPieChart()
}, 100)
// 监听窗口大小变化
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
// 销毁图表实例
if (lineChart) {
lineChart.dispose()
lineChart = null
}
if (pieChart) {
pieChart.dispose()
pieChart = null
}
// 移除事件监听
window.removeEventListener('resize', handleResize)
})
</script>
<style scoped lang="scss">
.dashboard {
padding: 20px;
}
.stats-row {
margin-bottom: 20px;
}
.stat-card {
height: 100%;
.stat-content {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #303133;
}
.stat-title {
font-size: 14px;
color: #909399;
}
}
.chart-row {
margin-bottom: 20px;
}
.chart-card {
min-height: 350px;
.chart-container {
width: 100%;
height: 300px;
}
}
.table-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
四、API服务封装与请求管理
4.1 Axios配置与拦截器
src/services/axios.ts:
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type AxiosError } from 'axios'
import { ElMessage, ElLoading } from 'element-plus'
import { useUserStore } from '@/store/user'
// 创建Axios实例
const service: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
service.interceptors.request.use(
(config: AxiosRequestConfig) => {
const userStore = useUserStore()
// 添加认证Token
if (userStore.token) {
config.headers = {
...config.headers,
Authorization: `Bearer ${userStore.token}`
}
}
// 添加请求ID(用于日志追踪)
config.headers['X-Request-ID'] = Date.now().toString()
// 显示全局加载状态(可选)
if (config.showLoading !== false) {
// 可以在这里添加全局loading逻辑
}
return config
},
(error: AxiosError) => {
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const { data, status, config } = response
// 处理成功响应
if (status === 200) {
// 如果后端返回统一格式,可以在这里处理
if (data.code !== undefined && data.code !== 200) {
// 业务错误
ElMessage.error(data.message || '请求失败')
return Promise.reject(new Error(data.message || '请求失败'))
}
// 显示成功消息(可选)
if (config.showSuccess) {
ElMessage.success(data.message || '操作成功')
}
return data.data || data
}
return response
},
(error: AxiosError) => {
const { response, config } = error
// 处理HTTP错误
if (response) {
const { status, data } = response
switch (status) {
case 400:
ElMessage.error('请求参数错误')
break
case 401:
// 未授权,清除token并跳转登录页
const userStore = useUserStore()
userStore.logout()
ElMessage.warning('登录已过期,请重新登录')
// 可以在这里跳转到登录页
break
case 403:
ElMessage.error('没有权限访问该资源')
break
case 404:
ElMessage.error('请求的资源不存在')
break
case 500:
ElMessage.error('服务器内部错误')
break
case 502:
ElMessage.error('网关错误')
break
case 503:
ElMessage.error('服务不可用')
break
case 504:
ElMessage.error('网关超时')
break
default:
ElMessage.error(`请求失败: ${status}`)
}
} else if (error.request) {
// 请求已发出,但没有收到响应
ElMessage.error('网络连接失败,请检查网络')
} else {
// 发送请求时出错
ElMessage.error('请求配置错误')
}
return Promise.reject(error)
}
)
// 扩展Axios配置类型
declare module 'axios' {
interface AxiosRequestConfig {
showLoading?: boolean
showSuccess?: boolean
}
}
export default service
4.2 API服务封装
src/services/api/user.ts:
import service from '@/services/axios'
import type { User, LoginParams, LoginResponse } from '@/types'
export const login = (params: LoginParams): Promise<LoginResponse> => {
return service.post('/auth/login', params, { showSuccess: true })
}
export const getUserInfo = (): Promise<User> => {
return service.get('/user/info')
}
export const logout = (): Promise<void> => {
return service.post('/auth/logout')
}
export const updateUser = (data: Partial<User>): Promise<User> => {
return service.put('/user/update', data, { showSuccess: true })
}
src/services/api/order.ts:
import service from '@/services/axios'
import type { Order, OrderListParams, OrderListResponse } from '@/types'
export const getOrders = (params: OrderListParams): Promise<OrderListResponse> => {
return service.get('/orders', { params })
}
export const getOrderById = (id: number): Promise<Order> => {
return service.get(`/orders/${id}`)
}
export const createOrder = (data: Partial<Order>): Promise<Order> => {
return service.post('/orders', data, { showSuccess: true })
}
export const updateOrder = (id: number, data: Partial<Order>): Promise<Order> => {
return service.put(`/orders/${id}`, data, { showSuccess: true })
}
export const deleteOrder = (id: number): Promise<void> => {
return service.delete(`/orders/${id}`, { showSuccess: true })
}
4.3 类型定义
src/types/index.ts:
// 用户相关类型
export interface User {
id: number
username: string
email: string
role: string
avatar?: string
createdAt: string
}
export interface LoginParams {
username: string
password: string
}
export interface LoginResponse {
token: string
user: User
}
// 订单相关类型
export interface Order {
id: number
orderNo: string
customer: string
amount: number
status: 'pending' | 'processing' | 'completed' | 'cancelled'
createdAt: string
updatedAt?: string
}
export interface OrderListParams {
page?: number
pageSize?: number
status?: string
customer?: string
startDate?: string
endDate?: string
}
export interface OrderListResponse {
list: Order[]
total: number
page: number
pageSize: number
}
// API响应类型
export interface ApiResponse<T = any> {
code: number
message: string
data: T
timestamp: number
}
五、性能优化策略
5.1 代码分割与懒加载
路由懒加载(已在路由配置中实现):
// 路由懒加载示例
const routes: RouteRecordRaw[] = [
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '仪表盘', requiresAuth: true }
},
{
path: '/report',
name: 'Report',
component: () => import('@/views/Report.vue'),
meta: { title: '报表' }
}
]
组件懒加载:
<template>
<div>
<!-- 懒加载图表组件 -->
<Suspense>
<template #default>
<LazyChartComponent v-if="showChart" />
</template>
<template #fallback>
<el-skeleton :rows="5" animated />
</template>
</Suspense>
</div>
</template>
<script setup lang="ts">
import { defineAsyncComponent, ref } from 'vue'
// 异步组件
const LazyChartComponent = defineAsyncComponent(() =>
import('@/components/ChartComponent.vue')
)
const showChart = ref(false)
// 延迟加载
setTimeout(() => {
showChart.value = true
}, 1000)
</script>
5.2 组件按需引入
vite.config.ts 配置自动按需引入:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
imports: ['vue', 'vue-router', 'pinia']
}),
Components({
resolvers: [ElementPlusResolver()],
dirs: ['src/components']
})
]
})
安装依赖:
npm install -D unplugin-auto-import unplugin-vue-components
修改main.ts(移除全局引入):
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
// 移除全局引入Element Plus
// import ElementPlus from 'element-plus'
// import 'element-plus/dist/index.css'
const app = createApp(App)
// app.use(ElementPlus) // 移除这行
app.use(router)
app.use(createPinia())
app.mount('#app')
5.3 图片与静态资源优化
vite.config.ts 配置图片优化:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import viteImagemin from 'vite-plugin-imagemin'
export default defineConfig({
plugins: [
vue(),
viteImagemin({
mozjpeg: {
quality: 80
},
png: {
quality: 80
},
webp: {
quality: 80
},
gif: {
optimizationLevel: 3
}
})
],
build: {
assetsDir: 'assets',
rollupOptions: {
output: {
manualChunks: {
'element-plus': ['element-plus'],
'vue': ['vue', 'vue-router', 'pinia'],
'echarts': ['echarts']
}
}
}
}
})
安装图片优化插件:
npm install -D vite-plugin-imagemin
5.4 缓存策略
Service Worker缓存(PWA支持):
src/sw.js:
const CACHE_NAME = 'element-app-v1'
const urlsToCache = [
'/',
'/index.html',
'/assets/logo.png',
'/assets/icon-192.png',
'/assets/icon-512.png'
]
// 安装Service Worker
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('Opened cache')
return cache.addAll(urlsToCache)
})
)
})
// 拦截请求并返回缓存
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// 缓存命中,返回缓存
if (response) {
return response
}
// 缓存未命中,从网络获取
return fetch(event.request).then((response) => {
// 检查响应是否有效
if (!response || response.status !== 200 || response.type !== 'basic') {
return response
}
// 克隆响应
const responseToCache = response.clone()
caches.open(CACHE_NAME)
.then((cache) => {
cache.put(event.request, responseToCache)
})
return response
})
})
)
})
// 清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName)
}
})
)
})
)
})
注册Service Worker(main.ts):
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then((registration) => {
console.log('SW registered: ', registration)
})
.catch((registrationError) => {
console.log('SW registration failed: ', registrationError)
})
})
}
5.5 性能监控
性能监控工具:
// src/utils/performance.ts
export class PerformanceMonitor {
private static instance: PerformanceMonitor
private metrics: Map<string, number[]> = new Map()
static getInstance(): PerformanceMonitor {
if (!PerformanceMonitor.instance) {
PerformanceMonitor.instance = new PerformanceMonitor()
}
return PerformanceMonitor.instance
}
// 记录性能指标
record(metricName: string, value: number): void {
if (!this.metrics.has(metricName)) {
this.metrics.set(metricName, [])
}
this.metrics.get(metricName)!.push(value)
// 限制存储数量
if (this.metrics.get(metricName)!.length > 100) {
this.metrics.get(metricName)!.shift()
}
}
// 获取平均值
getAverage(metricName: string): number {
const values = this.metrics.get(metricName)
if (!values || values.length === 0) return 0
const sum = values.reduce((a, b) => a + b, 0)
return sum / values.length
}
// 获取所有指标
getAllMetrics(): Record<string, number[]> {
const result: Record<string, number[]> = {}
this.metrics.forEach((values, key) => {
result[key] = [...values]
})
return result
}
// 清空指标
clear(): void {
this.metrics.clear()
}
}
// 使用Performance API监控
export const measurePerformance = (name: string, fn: () => Promise<any>) => {
const start = performance.now()
return fn().finally(() => {
const end = performance.now()
const duration = end - start
// 记录到监控器
PerformanceMonitor.getInstance().record(name, duration)
// 控制台输出
console.log(`${name} 耗时: ${duration.toFixed(2)}ms`)
// 如果耗时过长,发出警告
if (duration > 1000) {
console.warn(`${name} 执行时间过长: ${duration.toFixed(2)}ms`)
}
})
}
// 监控页面加载性能
export const monitorPageLoad = () => {
if ('PerformanceObserver' in window) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'navigation') {
const navEntry = entry as PerformanceNavigationTiming
console.log('页面加载性能:')
console.log(`DNS查询: ${navEntry.domainLookupEnd - navEntry.domainLookupStart}ms`)
console.log(`TCP连接: ${navEntry.connectEnd - navEntry.connectStart}ms`)
console.log(`请求响应: ${navEntry.responseEnd - navEntry.requestStart}ms`)
console.log(`DOM解析: ${navEntry.domContentLoadedEventEnd - navEntry.domContentLoadedEventStart}ms`)
console.log(`总加载时间: ${navEntry.loadEventEnd - navEntry.startTime}ms`)
// 记录到监控器
PerformanceMonitor.getInstance().record('pageLoad', navEntry.loadEventEnd - navEntry.startTime)
}
}
})
observer.observe({ entryTypes: ['navigation'] })
}
}
六、部署与CI/CD
6.1 构建配置优化
vite.config.ts 生产环境配置:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
build: {
outDir: 'dist',
assetsDir: 'assets',
sourcemap: false,
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // 移除console.log
drop_debugger: true // 移除debugger
}
},
rollupOptions: {
output: {
manualChunks: {
'element-plus': ['element-plus'],
'vue': ['vue', 'vue-router', 'pinia'],
'echarts': ['echarts']
}
}
}
},
// 环境变量
define: {
'process.env': {}
}
})
6.2 Docker部署
Dockerfile:
# 构建阶段
FROM node:18-alpine as builder
WORKDIR /app
# 复制package.json和package-lock.json
COPY package*.json ./
# 安装依赖
RUN npm ci --only=production
# 复制源代码
COPY . .
# 构建项目
RUN npm run build
# 生产阶段
FROM nginx:alpine
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制nginx配置
COPY nginx.conf /etc/nginx/nginx.conf
# 暴露端口
EXPOSE 80
# 启动nginx
CMD ["nginx", "-g", "daemon off;"]
nginx.conf:
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# 缓存配置
map $uri $static_cache {
~*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ 1y;
default 0;
}
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires $static_cache;
add_header Cache-Control "public, immutable";
add_header Vary "Accept-Encoding";
}
# HTML文件不缓存
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# API代理(如果需要)
location /api/ {
proxy_pass http://backend:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 单页应用路由支持
location / {
try_files $uri $uri/ /index.html;
}
}
}
6.3 GitHub Actions CI/CD
.github/workflows/deploy.yml:
name: Deploy to Production
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Run tests
run: npm run test
- name: Build project
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: dist
path: dist/
deploy:
needs: build-and-test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Download build artifacts
uses: actions/download-artifact@v3
with:
name: dist
path: dist/
- name: Deploy to server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/element-app
git pull origin main
npm ci
npm run build
docker-compose down
docker-compose up -d --build
七、最佳实践与常见问题
7.1 代码规范与团队协作
ESLint配置 (.eslintrc.cjs):
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended'
],
parser: 'vue-eslint-parser',
parserOptions: {
ecmaVersion: 2021,
sourceType: 'module',
parser: '@typescript-eslint/parser'
},
plugins: ['vue', '@typescript-eslint'],
rules: {
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off',
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
}
}
Prettier配置 (.prettierrc):
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"printWidth": 100,
"vueIndentScriptAndStyle": false
}
7.2 常见问题解决方案
问题1:Element Plus样式覆盖
// 全局样式覆盖
:root {
--el-color-primary: #409EFF;
--el-color-success: #67C23A;
--el-color-warning: #E6A23C;
--el-color-danger: #F56C6C;
--el-color-info: #909399;
}
// 深度选择器覆盖
:deep(.el-button) {
border-radius: 4px;
}
// 特定组件样式
.dashboard {
:deep(.el-card__header) {
background: #f5f7fa;
border-bottom: 1px solid #ebeef5;
}
}
问题2:路由跳转报错
// 解决Vue Router 4的导航重复问题
router.beforeEach((to, from, next) => {
// 避免重复导航
if (to.name === from.name && to.fullPath === from.fullPath) {
next(false)
return
}
// 正常导航
next()
})
问题3:内存泄漏
// 组件卸载时清理资源
import { onBeforeUnmount } from 'vue'
export default {
setup() {
const timer = setInterval(() => {
console.log('定时器运行中')
}, 1000)
// 清理定时器
onBeforeUnmount(() => {
clearInterval(timer)
})
return {}
}
}
7.3 安全最佳实践
XSS防护:
// 使用DOMPurify进行HTML净化
import DOMPurify from 'dompurify'
export const sanitizeHTML = (html: string): string => {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'title', 'target']
})
}
// 在Vue中使用
const safeHtml = computed(() => {
return sanitizeHTML(userInput.value)
})
CSRF防护:
// 在请求头中添加CSRF Token
service.interceptors.request.use((config) => {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
if (csrfToken) {
config.headers['X-CSRF-TOKEN'] = csrfToken
}
return config
})
八、总结
本文详细介绍了使用Element Plus从零搭建前端项目的完整流程,涵盖了:
- 项目初始化:使用Vite创建项目,配置Element Plus、Vue Router和Pinia
- 核心组件开发:实现了登录页面和仪表盘页面,包含表单验证、图表展示和数据表格
- API服务封装:使用Axios进行请求管理,添加拦截器处理认证和错误
- 性能优化:代码分割、懒加载、按需引入、图片优化和缓存策略
- 部署与CI/CD:Docker部署配置和GitHub Actions自动化流程
- 最佳实践:代码规范、常见问题解决方案和安全防护
通过遵循这些步骤和最佳实践,您可以构建一个高性能、可维护的企业级前端应用。记住,前端开发是一个持续优化的过程,建议定期进行性能分析和代码审查,以确保应用始终保持最佳状态。
九、扩展阅读
希望这份指南能帮助您在前端开发道路上走得更远!
