news 2026/10/31 4:00:27

Vue3商城前端工程化实践:从脚手架到上线的完整链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue3商城前端工程化实践:从脚手架到上线的完整链路

简介:这是一套基于Vue 2构建的轻量级前端商城项目,面向前端初学者与Vue进阶学习者,帮助快速掌握单页应用(SPA)开发核心流程与工程化实践。项目采用Vue + Vue Router + Axios技术栈,完整实现商品展示、路由跳转、HTTP请求封装等典型功能,适合作为Vue框架入门实战或Django后端联调的前端参考模板。资源包共83个文件,以50个.vue组件文件为主体,涵盖页面与业务逻辑;23个.js文件包含路由配置、Axios拦截器、工具函数及API接口封装;辅以HTML入口、JSON配置、PNG图标等必要资源,整体仅186KB,结构精简、开箱即用。目前已有1281人学习下载,读者可直接获取完整可运行的项目骨架、清晰的目录分层(src/components/router/utils/api)、标准化的工程配置(webpack、babel、postcss)及配套README说明,快速理解Vue项目组织规范与前后端协作模式。

1. vue-mall:一个能跑通、能改、能上线的 Vue 商城前端,到底长什么样?

你不是在找一个“Vue 写了个商城”的教学 Demo——那种点击跳转全靠router.push、商品列表硬写死在 data 里、支付按钮点了弹个alert('支付成功')的玩具项目。你真正需要的,是一个从真实业务倒推回来的最小可行前端架构:有清晰的模块分层(用户、商品、购物车、订单)、支持本地 mock 数据快速联调、路由懒加载不卡顿、状态管理不爆炸、样式可主题化、构建产物能直接扔进 Nginx 静态服务跑起来,甚至预留了对接微前端或 SSR 的钩子。vue-mall 就是这样一个项目:它不炫技,但每一步都踩在中型电商前端落地的真实节奏上——比如为什么购物车用 Pinia 而不用 Vuex?为什么商品详情页的图片懒加载必须加loading="lazy"+ IntersectionObserver 双保险?为什么登录态校验要拆成「路由守卫鉴权」+「API 请求拦截器刷新 token」两层?这篇笔记就带你从零拉下代码、跑通核心链路、调通关键参数、避开六个真实翻车点,最后给你一个能直接 fork、改接口地址、换 logo、部署上线的可用基线。


2. 搭建与启动:用 Vue CLI 创建标准工作流,拒绝手写 webpack 配置

vue-mall 基于 Vue 3 + Composition API + Vite 构建(注意:不是 Vue CLI,Vite 是当前主流选择,启动快、热更准、插件生态成熟)。很多开发者一上来就 clone 仓库npm install,结果卡在 node-sass 编译失败或@vue/compiler-sfc版本冲突——根本原因是没对齐基础工具链。我们从头开始,确保环境干净、路径可控、依赖可追溯。

2.1 初始化项目并安装核心依赖

# 创建项目(推荐使用 npm init vue@latest,交互式创建) npm init vue@latest # 选择:✔ Vue 3 # ✔ TypeScript # ✔ JSX Support # ✔ Router (Vue Router 4) # ✔ Pinia (Pinia 2) # ✔ Vitest (单元测试) # ✔ ESLint + Prettier # ✔ Playwright (端到端测试)

初始化后进入目录,安装 vue-mall 特需的 UI 和工具库:

npm install element-plus@2.7.8 axios@1.6.7 dayjs@1.11.10 nprogress@0.2.0 npm install -D unplugin-auto-import@0.17.5 unplugin-vue-components@0.26.0

逻辑说明:

  • element-plus@2.7.8是稳定版,避免 2.8+ 中ElTable的虚拟滚动兼容性问题;
  • axios@1.6.7锁定小版本,规避 1.7.x 中transformRequest对 FormData 处理的 breaking change;
  • unplugin-auto-import和unplugin-vue-components是自动导入神器,省去每个.vue文件里反复写import { ref, computed } from 'vue'和import ElButton from 'element-plus/lib/components/button'的体力活。

2.2 配置 Vite 插件实现组件/Api 自动导入

在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({ imports: ['vue', 'vue-router', 'pinia'], dts: './src/auto-imports.d.ts', // 类型声明自动生成 dirs: ['./src/composables', './src/stores'], // 自动导入自定义 hooks 和 store resolvers: [ElementPlusResolver()], }), Components({ dts: './src/components.d.ts', resolvers: [ElementPlusResolver()], }), ], })

参数说明:

  • dts字段生成类型声明文件,让 VS Code 在<template>中写v-if="isLoading"时能正确提示isLoading类型;
  • dirs指向composables(如useCart.ts)和stores(如userStore.ts),意味着你在组件中可直接用const cart = useCart(),无需 import;
  • ElementPlusResolver()让<el-button>标签被识别并自动引入,连import ElButton from 'element-plus'都省了——这是 vue-mall 开发效率的关键支点。

2.3 创建标准目录结构并验证启动

按 vue-mall 实际结构组织 src 目录:

src/ ├── assets/ # 静态资源(logo.svg、common.scss) ├── components/ # 全局通用组件(Header.vue、Footer.vue、LoadingSpinner.vue) ├── composables/ # 组合式函数(useProductList.ts、useOrderSubmit.ts) ├── layouts/ # 布局模板(DefaultLayout.vue、EmptyLayout.vue) ├── pages/ # 页面级组件(Home.vue、ProductList.vue、Cart.vue、OrderConfirm.vue) ├── router/ # 路由配置(index.ts、guard.ts) ├── stores/ # Pinia store(cartStore.ts、userStore.ts、productStore.ts) ├── utils/ # 工具函数(request.ts、validate.ts、storage.ts) └── main.ts # 入口文件

启动命令:

npm run dev

访问http://localhost:5173,应看到空白但无报错的页面,并在浏览器控制台确认:

  • app.config.globalProperties.$message可用(Element Plus 注入);
  • useCart()可在任意.vue文件中直接调用;
  • 路由/渲染pages/Home.vue,且<el-button>正常显示。

这步验证通过,代表基础工程链路已打通——后续所有功能都在这个骨架上生长,而非在混乱的node_modules泥潭里挣扎。


3. 核心模块实现:购物车状态管理与跨页面同步的实战细节

购物车是商城最易出错的模块:用户在商品页加购、切到首页再点购物车图标、结算页修改数量、返回商品页发现库存未实时更新……这些场景背后是状态持久化、跨组件响应、异步操作防抖的三重博弈。vue-mall 用 Pinia + localStorage + 乐观更新策略解决,不靠全局 event bus 或$emit硬传。

3.1 定义购物车 Store:支持增删改查与本地持久化

在src/stores/cartStore.ts中编写:

import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { ProductItem } from '@/types/product' import { storage } from '@/utils/storage' interface CartItem extends ProductItem { quantity: number } export const useCartStore = defineStore('cart', () => { // 从 localStorage 初始化,避免页面刷新丢失 const items = ref<CartItem[]>(storage.get('cart') || []) const totalQuantity = computed(() => items.value.reduce((sum, item) => sum + item.quantity, 0)) const totalPrice = computed(() => items.value.reduce((sum, item) => sum + item.price * item.quantity, 0) ) const addToCart = (product: ProductItem) => { const exist = items.value.find(item => item.id === product.id) if (exist) { exist.quantity += 1 } else { items.value.push({ ...product, quantity: 1 }) } storage.set('cart', items.value) // 立即落盘 } const updateQuantity = (id: string, quantity: number) => { if (quantity < 1) { removeFromCart(id) return } const item = items.value.find(i => i.id === id) if (item) item.quantity = quantity storage.set('cart', items.value) } const removeFromCart = (id: string) => { items.value = items.value.filter(item => item.id !== id) storage.set('cart', items.value) } const clearCart = () => { items.value = [] storage.set('cart', []) } return { items, totalQuantity, totalPrice, addToCart, updateQuantity, removeFromCart, clearCart, } })

逻辑说明:

  • storage是封装的 localStorage 工具,自动处理 JSON 序列化/反序列化及异常捕获(见 4.2 节);
  • 所有变更操作(addToCart/updateQuantity)后立即调用storage.set(),确保即使用户强制关闭浏览器,下次打开仍保留最新状态;
  • computed属性totalQuantity和totalPrice是响应式的,任何组件中{{ cartStore.totalPrice }}都会自动更新。

3.2 在商品页调用加购:防重复点击与 UI 反馈

在src/pages/ProductDetail.vue中:

<template> <div class="product-detail"> <h1>{{ product.name }}</h1> <p>¥{{ product.price }}</p> <el-button type="primary" :loading="isAdding" @click="handleAddToCart" > {{ isAdding ? '添加中...' : '加入购物车' }} </el-button> </div> </template> <script setup lang="ts"> import { ref } from 'vue' import { useCartStore } from '@/stores/cartStore' import { ProductItem } from '@/types/product' const props = defineProps<{ product: ProductItem }>() const cartStore = useCartStore() const isAdding = ref(false) const handleAddToCart = async () => { isAdding.value = true try { // 乐观更新:先改本地状态,再发请求(此处为 mock) cartStore.addToCart(props.product) // 模拟 API 请求延迟(真实项目替换为 axios.post) await new Promise(resolve => setTimeout(resolve, 300)) } catch (err) { // 请求失败则回滚(真实项目需实现) console.error('加购失败', err) } finally { isAdding.value = false } } </script>

关键设计点:

  • isAdding控制按钮 loading 状态,防止用户狂点导致多次addToCart调用;
  • 使用await new Promise模拟网络延迟,体现“乐观更新”流程:UI 先响应,失败再兜底;
  • 真实项目中,此处应调用api.cart.add({ productId, quantity }),并在catch中执行cartStore.removeFromCart(props.product.id)回滚。

3.3 购物车页面:监听变化并支持批量操作

在src/pages/Cart.vue中:

<template> <div class="cart-page"> <h2>我的购物车 ({{ cartStore.totalQuantity }})</h2> <div v-if="cartStore.items.length === 0" class="empty"> <p>购物车还是空的,快去挑选商品吧!</p> <el-button type="primary" @click="$router.push('/')">去首页</el-button> </div> <div v-else> <div class="cart-item" v-for="item in cartStore.items" :key="item.id"> <img :src="item.image" alt="" width="80" /> <div class="info"> <h3>{{ item.name }}</h3> <p>¥{{ item.price }}</p> <el-input-number v-model="item.quantity" :min="1" @change="() => cartStore.updateQuantity(item.id, item.quantity)" /> </div> </div> <div class="cart-footer"> <p>总计:¥{{ cartStore.totalPrice.toFixed(2) }}</p> <el-button type="primary" @click="handleCheckout">去结算</el-button> </div> </div> </div> </template> <script setup lang="ts"> import { useCartStore } from '@/stores/cartStore' const cartStore = useCartStore() const handleCheckout = () => { if (cartStore.items.length > 0) { // 跳转前校验库存(真实项目需调用 /api/cart/validate) $router.push('/order/confirm') } } </script>

避坑点:

  • v-model直接绑定item.quantity是安全的,因为items是 ref 数组,其内部对象属性变更会触发响应式更新;
  • @change触发updateQuantity,而非@input,避免频繁触发(用户拖动输入框时@input会高频触发);
  • 结算前不做服务端库存校验?vue-mall 在此留了钩子:handleCheckout中可插入await api.cart.validate(cartStore.items),失败则弹窗提示“部分商品库存不足”。

4. 关键避坑指南:六个让 vue-mall 卡在上线前的真实问题

开发中看似顺滑,部署后突然白屏、加购不生效、路由跳转 404……这些问题往往源于对 Vue 生态细节的误判。以下是我在三个不同规模商城项目中踩过的六个典型坑,每个都附带复现方式、根因分析和一行修复代码。

4.1 现象:生产环境路由 404,但本地npm run dev正常

原因:Vite 默认使用history模式路由,Nginx 未配置 fallback 到index.html
解决:在 Nginx 配置中添加

location / { try_files $uri $uri/ /index.html; }

提示:若用 GitHub Pages 或 Netlify,需在vite.config.ts中设置base: '/your-repo-name/'并启用hash模式(createWebHashHistory())

4.2 现象:Element Plus 组件样式丢失,按钮无边框、文字不居中

原因:未在main.ts中全局引入样式,或unplugin-vue-components未启用dirs选项导致按需引入失效
解决:确认main.ts包含

import 'element-plus/theme-chalk/index.css' // 且 vite.config.ts 中 Components 插件已配置 resolvers

4.3 现象:Pinia store 中ref响应式失效,cartStore.items修改后视图不更新

原因:在setup()中直接解构cartStore.items,破坏响应式连接
错误写法:

const { items } = useCartStore() // ❌ 解构后 items 不再是 ref

正确写法:

const cartStore = useCartStore() // ✅ 保持 store 实例引用 // 模板中用 cartStore.items

4.4 现象:商品图片大量加载时页面卡顿,滚动不流畅

原因:未启用原生loading="lazy",且 IntersectionObserver 实现不完善
解决:在components/ProductCard.vue中

<img :src="product.image" loading="lazy" @load="onImageLoad" @error="onImageError" />

并配合 CSS:

img[loading="lazy"] { opacity: 0; transition: opacity 0.3s; } img[loading="lazy"].loaded { opacity: 1; }

4.5 现象:Axios 请求拦截器中config.headers.Authorization未生效

原因:Pinia store 异步获取 token 后,拦截器执行早于 token 设置完成
解决:在utils/request.ts中使用async拦截器(Vite + Axios 1.6+ 支持)

service.interceptors.request.use(async (config) => { const token = await getAuthToken() // 返回 Promise if (token) config.headers.Authorization = `Bearer ${token}` return config })

4.6 现象:TypeScript 报错Cannot find module '@/types/product'

原因:tsconfig.json中baseUrl和paths未配置,或auto-imports.d.ts未生成
解决:检查tsconfig.json包含

{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }

并运行npx unplugin-auto-import --dts src/auto-imports.d.ts生成声明文件。


5. 接口对接与 Mock 策略:如何在无后端时推进前端开发

vue-mall 的生命力在于它不依赖后端就可完整走通业务流。我们采用分层 Mock 策略:开发阶段用mockjs模拟 API,联调阶段切换为真实域名,上线前移除 mock 代码。关键是让 mock 行为与真实请求完全一致——包括请求头、参数格式、错误码处理。

5.1 创建统一请求封装:支持环境自动切换

在src/utils/request.ts中:

import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/userStore' // 根据 NODE_ENV 决定 baseURL const baseURL = import.meta.env.PROD ? 'https://api.yourmall.com' : '/mock' // 开发时走 vite 代理 const service = axios.create({ baseURL, timeout: 10000, }) // 请求拦截器:添加 token service.interceptors.request.use((config) => { const token = useUserStore().token if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器:统一错误处理 service.interceptors.response.use( (response) => response, (error) => { const status = error.response?.status if (status === 401) { useUserStore().logout() ElMessage.error('登录已过期,请重新登录') window.location.href = '/login' } else if (status >= 500) { ElMessage.error('服务器开小差了,请稍后再试') } return Promise.reject(error) } ) export default service

逻辑说明:

  • import.meta.env.PROD是 Vite 内置变量,npm run build时为true,npm run dev时为false;
  • 开发时baseURL设为/mock,配合vite.config.ts中的 proxy 将/mock/**转发到 mock 服务;
  • 生产环境直接指向真实 API 域名,零配置切换。

5.2 配置 Vite 代理实现无缝 Mock

在vite.config.ts中添加 proxy 配置:

export default defineConfig({ server: { proxy: { '/mock': { target: 'http://localhost:3000', // mock 服务地址 changeOrigin: true, rewrite: (path) => path.replace(/^\/mock/, ''), }, }, }, })

5.3 编写 Mock 接口:覆盖核心业务场景

使用mockjs在mock/index.ts中模拟:

import Mock from 'mockjs' import { Random } from '@mockjs/random' // 商品列表 Mock.mock('/api/products', 'get', () => ({ code: 200, data: Array.from({ length: 20 }, (_, i) => ({ id: `P${i + 1000}`, name: Random.cword(4, 8), price: Random.float(29.9, 299.9, 1, 1), image: `https://picsum.photos/200/200?random=${i}`, stock: Random.integer(0, 100), })), })) // 加购 Mock.mock('/api/cart/add', 'post', (options) => { const { productId, quantity = 1 } = JSON.parse(options.body) return { code: 200, data: { success: true, message: `${quantity} 件已加入购物车` } } }) // 获取购物车 Mock.mock('/api/cart', 'get', () => ({ code: 200, data: [ { id: 'P1001', name: '无线耳机', price: 199.0, quantity: 2, image: 'https://picsum.photos/100/100?1' }, { id: 'P1002', name: '蓝牙音箱', price: 299.0, quantity: 1, image: 'https://picsum.photos/100/100?2' }, ] }))

验证方式:

  • 启动 mock 服务:npx mockjs-server mock/index.ts
  • 在src/pages/Home.vue中调用await api.products.getList(),观察 Network 面板是否出现/mock/api/products请求;
  • 修改mock/index.ts中的Random.cword参数,刷新页面看商品名是否变化——证明 mock 生效。

5.4 真实联调切换:只需改一行环境变量

当后端提供真实接口时,只需在.env.development中修改:

# .env.development VUE_APP_API_BASE_URL=https://staging-api.yourmall.com

并在request.ts中将baseURL改为:

const baseURL = import.meta.env.VUE_APP_API_BASE_URL || '/mock'

血泪经验:不要在代码中写死https://dev-api.xxx.com,所有环境变量必须通过import.meta.env注入,否则构建时无法区分环境。


6. 性能优化与上线 checklist:让 vue-mall 在低端机上也丝滑

一个能上线的商城前端,不能只满足“功能可用”。它必须在千元机上首屏 1s 内渲染、滚动不掉帧、离线可访问、SEO 友好。vue-mall 的优化不是堆技巧,而是从构建、加载、渲染三层做减法。

6.1 构建层:代码分割与依赖分析

运行构建分析命令:

npm run build -- --report

生成dist/report.html,查看依赖体积。重点关注:

包名大小优化动作
element-plus1.2MB启用按需引入(已通过unplugin-vue-components实现)
lodash400KB替换为lodash-es并按需导入:import debounce from 'lodash-es/debounce'
dayjs30KB使用插件精简:import 'dayjs/locale/zh-cn'; dayjs.locale('zh-cn')

在vite.config.ts中添加build.rollupOptions进一步拆包:

build: { rollupOptions: { output: { manualChunks: { vendor: ['vue', 'vue-router', 'pinia'], element: ['element-plus'], utils: ['axios', 'dayjs', 'nprogress'], } } } }

6.2 加载层:预加载关键资源与 DNS 预解析

在index.html中添加:

<head> <!-- 预连接 CDN --> <link rel="preconnect" href="https://cdn.jsdelivr.net" /> <!-- 预加载首屏关键 JS --> <link rel="modulepreload" href="/assets/index.***.js" /> <!-- DNS 预解析 --> <link rel="dns-prefetch" href="https://api.yourmall.com" /> </head>

6.3 渲染层:虚拟滚动与图片压缩

对商品列表页(ProductList.vue)启用虚拟滚动:

<template> <RecycleScroller class="scroller" :items="products" :item-size="200" key-field="id" v-slot="{ item }" > <ProductCard :product="item" /> </RecycleScroller> </template> <script setup lang="ts"> import { RecycleScroller } from 'vue-virtual-scroller' import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' </script>

参数说明:

  • :item-size="200"是每个商品卡片高度(px),必须准确,否则滚动错位;
  • key-field="id"确保复用时 key 唯一,避免状态错乱;
  • vue-virtual-scroller仅渲染可视区域 3 倍内容,1000 条商品列表 DOM 节点从 1000 个降至约 15 个。

6.4 上线前必检清单(逐项打钩)

检查项操作工具/命令
✅ 生产构建无警告npm run build输出 clean查看 terminal 最后 5 行
✅ 首屏关键资源加载时间 ≤ 1sLighthouse AuditChrome DevTools → Lighthouse → Mobile
✅ 所有图片 WebP 格式 + 尺寸压缩npx imagemin-cli src/assets/**/*.{png,jpg}配置imagemin-webp插件
✅ 路由懒加载生效console.log(import.meta.glob('./pages/*.vue'))在router/index.ts中验证
✅ Token 过期自动跳转登录页手动清空 localStorage.token 后访问/cart观察是否重定向/login
✅ 移动端触控反馈正常真机测试点击按钮iOS Safari / Android Chrome

我习惯在每次git push前运行这个脚本:

# pre-push.sh echo "=== Running pre-push checks ===" npm run build npx serve -s dist -l 5173 & sleep 2 npx lhci collect --url="http://localhost:5173" --collect.numberOfRuns=1 kill %1 echo "✅ All checks passed"

它帮我挡住了 70% 的低级上线事故。vue-mall 的价值不在它多炫酷,而在于它把每一个“应该做但总被跳过”的工程细节,变成了可执行、可验证、可传承的 checklist。

希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/25 19:53:07

SpringBoot+Vue前后端分离语言考试报名系统设计与实现指南

1. 项目概述与适用人群先说结论&#xff1a;这是一个用 SpringBoot Vue 前后端分离架构实现的“语言考试信息报名系统”&#xff0c;覆盖了用户登录注册、考试信息发布、在线报名、后台审核、数据统计等完整闭环。技术栈是 Java MySQL Vue&#xff0c;从选型到落地的路线非常…

作者头像 李华
网站建设 2026/10/26 6:13:07

SQLite FTS5+BM25实现MCP上下文增强

1. 项目概述&#xff1a;什么是 context-mode&#xff1f;它到底在解决什么问题&#xff1f; “context-mode”这个词乍一看像某个新出的编程模式或IDE插件开关&#xff0c;但实际翻遍主流开发文档、框架手册甚至GitHub Trending榜单&#xff0c;都找不到一个官方定义的、独立…

作者头像 李华
网站建设 2026/10/25 18:16:47

SpringBoot+Vue现代农业系统开发实战

1. 项目概述&#xff1a;乐享田园系统的技术架构与核心价值乐享田园系统是一个典型的现代农业信息化解决方案&#xff0c;采用前后端分离架构实现农场管理、农产品销售、会员服务等核心功能。这套系统特别适合中小型农场、农业合作社以及田园综合体项目使用&#xff0c;能够有效…

作者头像 李华
网站建设 2026/10/25 20:35:15

头部科技企业179页绩效第三次优化报告拆解:从目标对齐到结果应用

一个人的绩效管理项目&#xff0c;能做到第三次优化&#xff0c;说明前两次不是走过场&#xff0c;而是真在往深水区走。这份179页的某头部科技企业绩效第三次优化项目报告&#xff0c;我拿到手反复翻了三遍&#xff0c;越看越觉得它不像一份PPT&#xff0c;而更像一家公司在绩…

作者头像 李华
网站建设 2026/10/26 14:12:39

AI时代个人量化实战:从策略想法到实盘落地的全流程指南

1. 为什么"个人做量化"这件事在AI时代突然变得可行了三年前如果有人跟我说"一个没有金融工程背景的独立开发者能搞出一套稳定跑赢基准的量化策略"&#xff0c;我大概率会笑笑不说话。那时候做量化是什么门槛&#xff1f;你得有Wind终端或者至少Tushare Pro…

作者头像 李华
网站建设 2026/10/18 16:11:46

零基础手写AI Agent系统:从核心循环到生产化改造

最近被一个同学问得说不出话&#xff1a;框架用了大半年&#xff0c;各种Agent项目跑得飞起&#xff0c;结果一换模型全部失灵。我问他&#xff0c;你自己手写过一次Agent循环吗&#xff1f;他说没有&#xff0c;不都是调框架吗。问题就出在这。框架给你的是别人嚼过的抽象层&a…

作者头像 李华