news 2026/10/6 14:29:27

React 19 + Tailwind CSS V4 实战:构建实时用户过滤组件

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
React 19 + Tailwind CSS V4 实战:构建实时用户过滤组件

做50天50个小项目这个挑战时,我给自己定了一条规则:每个项目必须强行试用一个不熟悉的新特性,否则不做。LiveUserFilter就是我用来啃React 19和Tailwind CSS V4这两个新组合的小白鼠。说白了,这个组件就是网页里常见的搜索过滤框——输入关键字,下方的用户列表立刻实时刷新,不需要点按钮,不需要等请求。功能看起来很简单,但从数据拉取、状态管理到样式响应式,它几乎能覆盖前端日常开发的所有关键环节,非常适合当成新版本能力的试验田。

如果你正打算学习React 19和Tailwind CSS V4,又不想看一堆文档,那我建议直接动手做一个小项目。这篇文章会把我从初始化项目到打磨交互动效的全过程都写清楚,包括配置差异、过滤逻辑、数据请求、踩坑复盘,尽量做到能照着跑通。难度定位在前端入门到进阶之间,需要你有一点React基础,但没有基础的人看下来也能明白核心思路。

1. 项目背景与 LiveUserFilter 的边界定义

1.1 这个组件解决的真实痛点

用户列表页如果没有过滤能力,数据一旦多起来就是个灾难。比如后台管理系统里的用户表格,几千个账号摆在一起,想找一个叫“王磊”的人得翻好几页。传统方案是提交表单后刷新页面,或者点击“搜索”按钮再重新请求接口,等待时间虽然不算长,但体验很顿挫。

LiveUserFilter的思路是:把过滤动作从“提交后执行”改成“输入即执行”,每次键盘敲击都重新计算结果并立刻渲染到界面上。用户不需要思考“要不要点一下搜索”,所见即所得。这个交互模式在通讯录、数据表格、下拉选择器里都很常见,可作为独立组件复用小项目也非常合适,所以我把它选作挑战序列中的基础款。

更准确地说,这个组件要具备三个核心行为:第一,从远端接口拿到一批用户数据;第二,输入关键字后在前端内存中完成过滤;第三,将匹配结果和当前状态(加载、空、错误)实时呈现给用户。整个过程不需要额外引入状态管理库,直接用React的useState、useMemo和自定义Hook就能搞定。

1.2 为什么选 React 19 + Tailwind CSS V4 来练手

LiveUserFilter足够小,可以快速跑完一个完整闭环,我又能在里面验证React 19和Tailwind CSS V4这两个新版本的真实体验。React 19带来了很多新东西,比如Actions、useOptimistic、useActionState,还有内置的use()函数。但说实话,这个项目用不到那些高级API,真正会用到的是并发渲染相关的特性,比如useDeferredValue。React 19把并发渲染的基础打磨得更稳定,输入框快速打字时列表渲染不再抢占主线程,这对实时过滤场景是实打实的收益。

Tailwind CSS V4则是完全不一样的变化。V3时代我们得维护一个tailwind.config.js文件,配置content路径、extend colors、plugins等。V4直接把配置移到了CSS文件里,通过@theme指令定义设计令牌,通过CSS变量驱动工具类。对习惯了“配置即代码”的人来说,这种CSS-first的写法一开始有点不习惯,但用下来真的很顺手。后面第5章我会专门演示样式上的细节。

我建议你在复现这个项目时也用Vite而不是Webpack,原因有两个:Vite启动快,HMR热更新快,适合小项目频繁调样式;而且Tailwind V4官方推荐的方式就是通过@tailwindcss/vite插件接入,省去PostCSS配置。

1.3 完成效果与验收标准

在动手前,我给自己列了一个验收清单,避免项目越做越发散:

  • 输入姓名、城市或邮箱关键字,用户列表实时过滤。
  • 匹配的文本片段在界面上高亮显示。
  • 初次请求数据时有骨架屏,无结果显示空状态,接口异常显示错误和重试按钮。
  • 输入框支持清空操作,输入内容时不能出现键盘卡顿。
  • 页面在桌面和手机宽度下都能看,列表自动从单列切换成多列。

这些标准不高,但每一条都对应着前端工程里常见的问题。下面的章节我就按这个清单一步步展开。

2. 基于 Vite 搭建 React 19 + Tailwind CSS V4 工程

2.1 从零初始化项目

我习惯先把工程搭好再做逻辑,避免中途被环境问题打断。创建项目用的是Vite官方模板:

npm create vite@latest live-user-filter -- --template react-ts cd live-user-filter npm install

React 19目前已经在npm latest上,所以装完默认版本就是19.x。接着安装Tailwind V4和对应的Vite插件:

npm install tailwindcss @tailwindcss/vite

然后修改vite.config.ts,把Tailwind插件挂进去:

import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' export default defineConfig({ plugins: [react(), tailwindcss()], })

最后在src/index.css里写一行:

@import "tailwindcss";

到这里Tailwind就已经生效了,不用再装PostCSS插件,不用建tailwind.config.js,也不用写@tailwind base;这类三段式指令。这一点和V3差别非常大,我第一次跑起来时还以为漏了配置,检查了半天才确认就是这么简单。

2.2 Tailwind V4 的 CSS-first 配置

V4把配置收进CSS,核心思路是用原生CSS变量定义设计令牌,然后由工具类去引用这些变量。比如我想给项目定义主题色和字体,在src/index.css里写:

@import "tailwindcss"; @theme { --color-brand: #4f46e5; --color-brand-soft: #eef2ff; --font-display: "Inter", ui-sans-serif, system-ui, sans-serif; }

写完以后,页面里可以直接用bg-brand、text-brand、font-display这些类名。这就是V4的约定:@theme里定义了--color-brand,Tailwind就会自动生成对应的颜色工具类;定义--font-display,就会生成font-display字体工具类。更多样式可以在@theme里加,比如:

@theme { --shadow-card: 0 2px 12px rgb(0 0 0 / 0.06); --radius-card: 1rem; }

这样我就能用shadow-card和rounded-card,而不是记那些没有语义的shadow-lg或rounded-2xl。对于团队项目,这种语义化命名比数字命名好用得多。

2.3 组件拆分的思路

我建了这样一个目录结构:

src/ components/ SearchBox.tsx UserCard.tsx UserGrid.tsx StatusView.tsx hooks/ useUserData.ts useFilteredUsers.ts useDebouncedValue.ts types.ts App.tsx main.tsx index.css

组件拆分的粒度不需要太细,关键是职责清楚。SearchBox只负责输入、清空和键盘交互,UserCard只负责渲染单个用户卡片,UserGrid负责列表布局,StatusView统一处理加载、空和错误三种状态。数据逻辑放Hook里,这样组件就只关心“拿到什么就渲染什么”,测试和替换都方便。

3. 核心过滤逻辑与数据处理

3.1 从 randomuser.me 拉取数据的封装

实时用户过滤组件总得有个数据源。这个项目用的是randomuser.me,接口稳定、无需密钥,而且返回结构很适合做用户列表展示。请求地址是:

https://randomuser.me/api/?results=50

返回的数据是一个对象,核心字段在results数组里。我先把用户数据结构整理成自己需要的类型:

export interface User { id: string name: string city: string country: string email: string avatar: string }

然后在useUserData里封装拉取逻辑。需要注意一个细节:组件卸载或重复请求时,不能把旧的响应写到已卸载组件上,所以要用AbortController做取消。代码如下:

import { useEffect, useState } from 'react' import type { User } from '../types' const API_URL = 'https://randomuser.me/api/?results=50' interface ApiResponse { results: Array<{ login: { uuid: string } name: { first: string; last: string } location: { city: string; country: string } email: string picture: { thumbnail: string } }> } export function useUserData() { const [users, setUsers] = useState<User[]>([]) const [loading, setLoading] = useState(true) const [error, setError] = useState<string | null>(null) useEffect(() => { const controller = new AbortController() async function load() { try { setLoading(true) setError(null) const response = await fetch(API_URL, { signal: controller.signal }) if (!response.ok) throw new Error(`请求失败:${response.status}`) const data: ApiResponse = await response.json() const mapped = data.results.map((item) => ({ id: item.login.uuid, name: `${item.name.first} ${item.name.last}`, city: item.location.city, country: item.location.country, email: item.email, avatar: item.picture.thumbnail, })) setUsers(mapped) } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return setError(err instanceof Error ? err.message : '未知错误') } finally { if (!controller.signal.aborted) setLoading(false) } } load() return () => controller.abort() }, []) return { users, loading, error, retry: load } }

retry我放在返回值里,但load定义在effect内部,外部无法直接调用。更好的做法是把fetch逻辑提到Hook顶层,或者用useCallback封装。我实际实现时用了useCallback,这样错误状态里的重试按钮可以直接调用。上面这个简化版本能说明核心思路,但你可以自己补一下。

3.2 实时过滤:不只是 filter 方法

很多人写过滤,直接就是users.filter(user => user.name.includes(query))。这样功能能跑,但忽略了很多细节。第一,用户输入可能带前后空格,必须先trim;第二,比较时大小写要统一,全转小写;第三,可能要同时匹配多个字段,比如姓名、城市、国家、邮箱。

我封装了一个useFilteredUsersHook:

import { useMemo } from 'react' import type { User } from '../types' export function useFilteredUsers(users: User[], query: string) { return useMemo(() => { const keyword = query.trim().toLowerCase() if (!keyword) return users return users.filter((user) => { return [user.name, user.city, user.country, user.email] .some((field) => field.toLowerCase().includes(keyword)) }) }, [users, query]) }

为什么用some而不是把所有字段拼成一个字符串再includes?因为拼接字符串可能多消耗内存,而且如果某个字段是空字符串,拼出来会有undefined字符,容易埋坑。更重要的是some可以短路——只要某个字段命中就立即返回true,不需要匹配后续字段。在50条数据上这个差别可以忽略,但在更大列表里影响就明显了。

这里的关键点是用useMemo,保证query和users没有变化时,过滤结果可以复用,不会每次渲染都重新计算。实时过滤的逻辑本身很简单,难的是不要做无用的重复计算。

3.3 输入防抖与 React 并发更新

过滤计算如果很快,不防抖也行。但用户打字时,高频更新会触发列表组件不断重新渲染。小项目感觉不到,数据量一变大,输入框就会像PPT一样卡顿。传统方案是加防抖,比如300毫秒后再更新query。这个项目我用的不是防抖,而是React自带的useDeferredValue。

import { useDeferredValue, useState } from 'react' const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query)

deferredQuery的值会跟着query变化,但React会在合适的时候推迟更新它。这样输入框能立刻响应用户输入,而列表渲染被放在后台优先级较低的任务里。React 19的并发调度会尽量保证高优先级更新不被阻塞,所以即使过滤逻辑稍微耗时,输入手感也不会受影响。

有人可能问:防抖是不是完全不用?也不是。如果过滤操作是发请求到服务端,防抖仍然有必要,因为每敲一个字母就发一个请求,后端和流量都扛不住。但纯前端过滤场景,用useDeferredValue是更符合React风格的做法。我顺手把两种方案都放进工具函数里,需要时切换,但实际演示项目里用的是useDeferredValue。

4. 交互细节与体验打磨

4.1 高亮匹配文字的正确姿势

高亮是实时过滤组件的“门面”。输入“li”,列表里的“Li”和“li”都要被高亮出来,这样用户才能直观看到过滤结果对应在哪。很多人第一反应是用dangerouslySetInnerHTML把HTML字符串塞进元素里,比如手动拼接<mark>。这样做功能有了,但存在XSS风险,而且得处理转义,非常容易被攻击。

正确做法是让React自己渲染字符串,用split的方式切分文本。我写了一个Highlight组件:

interface HighlightProps { text: string keyword: string } export function Highlight({ text, keyword }: HighlightProps) { const normalizedKeyword = keyword.trim().toLowerCase() if (!normalizedKeyword) return text const lowerText = text.toLowerCase() const index = lowerText.indexOf(normalizedKeyword) if (index === -1) return text return ( <> {text.slice(0, index)} <mark className="rounded bg-amber-200 px-1 text-inherit"> {text.slice(index, index + keyword.length)} </mark> {text.slice(index + keyword.length)} </> ) }

这个实现匹配的是第一个出现的位置。如果你需要高亮所有匹配项,可以用正则new RegExp(...)配合replace,但不要直接用HTML字符串。更稳妥的做法是循环查找,把非匹配段和匹配段交替push到数组里,最后用map渲染。React会自动转义,安全又好维护。

给<mark>加上Tailwind类名后,高亮效果足够清晰,而且不破坏文字原有的颜色继承。

4.2 搜索框的清空与键盘操作

搜索框不能只靠浏览器默认行为。我用了input type="search",但不同浏览器会显示不一样的清空按钮,所以我干脆自己控制一个清空按钮,保证跨浏览器样式统一。

实现思路很简单:当输入内容非空时,在Input右侧显示一个“清空”图标按钮,点击后清空query并让输入框重新聚焦。键盘支持上,我让Escape键也能清空。这样用户除了点击按钮,还能用键盘快速退出过滤状态。

代码大概是:

<input type="search" role="searchbox" aria-label="搜索用户" placeholder="Search by name or city..." value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Escape') setQuery('') }} className="w-full rounded-xl border border-gray-200 px-4 py-3 pr-12 focus:border-brand focus:outline-none" />

pr-12是为了给清空按钮留出空间,否则文字会盖在按钮下面。这个细节不用心看不出来,但真碰到了就会觉得别扭。清空按钮本身可以是普通button,记得加type="button",否则在表单里可能触发表单提交。

4.3 骨架屏、空状态和错误兜底

实时过滤组件有很多种“没有结果”的情况。初次加载时,用户看到的是空白页面,这非常劝退。所以我加了一个骨架屏,用Tailwind的animate-pulse做一个闪烁占位,让用户知道数据正在加载。骨架屏可以循环渲染8个卡片,每个卡片的宽高模拟真实卡片布局。

过滤结果为空时,界面不能只显示一片空白,要有一句友善的文案,最好带上当前的搜索词。比如:

<p className="text-center text-gray-500"> 没有找到与 “{deferredQuery}” 匹配的用户 </p>

接口请求失败时,需要把错误信息展示出来,并提供一个重试按钮。重试按钮的样式可以用bg-brand和text-white,让它在页面里足够明显。这里要注意:只有在初次请求或重试时才显示错误状态,如果用户已经在输入过滤,就不要再弹错误提示打断操作。

5. Tailwind CSS V4 的样式实战

5.1 用 @theme 建立设计系统

前面提到过@theme,实际操作时我把颜色、字体、阴影、圆角都定义好,后续写类名就不用再看设计稿:

@import "tailwindcss"; @theme { --color-brand: #4f46e5; --color-brand-soft: #eef2ff; --color-surface: #f8fafc; --color-ink: #0f172a; --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; --shadow-card: 0 2px 12px rgb(0 0 0 / 0.06); --radius-card: 1rem; }

这样组件里写bg-surface、text-ink、shadow-card、rounded-card,语义非常清楚。换主题时只需要改CSS里的变量,所有用到的地方都会跟着变。V4用CSS原生变量驱动,运行时修改也比V3方便得多,未来做主题切换会省不少事。

5.2 响应式卡片网格

用户数据是列表形式,我用栅格布局做响应式:

<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> {filteredUsers.map((user) => ( <UserCard key={user.id} user={user} keyword={deferredQuery} /> ))} </div>

手机上一列,平板两列,宽屏三列,这个断点配置足够满足常见场景。卡片里我用flex布局让头像和文字垂直居中,邮箱地址用truncate类截断,避免长邮箱把卡片撑破。整体卡片样式类似:

<article className="flex items-center gap-3 rounded-card border border-gray-100 bg-white p-4 shadow-card transition hover:-translate-y-0.5 hover:shadow-lg"> <img src={user.avatar} alt={user.name} loading="lazy" className="h-12 w-12 rounded-full" /> <div className="min-w-0"> <h2 className="truncate text-base font-semibold text-ink"> <Highlight text={user.name} keyword={keyword} /> </h2> <p className="truncate text-sm text-gray-500"> <Highlight text={`${user.city}, ${user.country}`} keyword={keyword} /> </p> <p className="truncate text-sm text-gray-400"> <Highlight text={user.email} keyword={keyword} /> </p> </div> </article>

min-w-0很关键,它允许flex子项在宽度不足时收缩,配合truncate才能生效。这个组合我一开始漏了,结果邮箱和姓名都不截断,把卡片撑出了页面。

5.3 V4 新特性尝鲜:@utility 和 dark 变体

Tailwind V4除了配置变更,还支持用@utility定义自定义工具类。举个例子,我想做一个通用的卡片表面样式,可以在CSS里写:

@utility card-surface { background-color: var(--color-surface); border-radius: var(--radius-card); border: 1px solid rgb(0 0 0 / 0.06); box-shadow: var(--shadow-card); }

然后在JSX里直接使用card-surface。这样我可以把一组经常组合的样式封装成一个类,又不像V3那样要写复杂的JavaScript插件。

dark模式也变了。V4默认的dark变体是跟随系统主题,如果你希望用class控制,需要加一行指令:

@custom-variant dark (&:where(.dark, .dark *));

之后在html元素上挂dark类,组件里的dark:bg-gray-900就会生效。我当时看到文档里这行代码时愣了一下,因为V3只需要在config里设置darkMode: 'class'。V4把一切都变成了CSS自定义指令,思路统一但确实需要适应。

6. 踩坑复盘与性能经验

6.1 Tailwind V4 扫描不到动态类名

我在项目里曾想通过hover:bg-${hoverColor}动态控制卡片悬浮颜色,结果HMR热更新后,类名怎么都不生效。查了一下才知道,Tailwind的编译器是扫描源码文件的,它需要看到完整的类名文本才能生成对应工具类。像hover:bg-${hoverColor}这种运行时拼接,编译器根本猜不到最终类名,自然就不会输出CSS。

这不是V4独有的问题,V3也一样,但V4叫我特别容易踩中,因为V4删掉了原来的purge配置,默认就是自动扫描。解决方法是把所有可能的类名写成完整字符串,或者用对象映射:

const colorMap = { indigo: 'hover:bg-indigo-50', amber: 'hover:bg-amber-50', emerald: 'hover:bg-emerald-50', }

总之就是不要动态拼接类名。Tailwind不是运行时生成,这是很多人第一次使用时最容易翻车的点。

6.2 React 19 StrictMode 下的重复请求

开发模式下React 19仍然默认使用StrictMode,组件挂载后effect会执行两次。这直接导致useUserData里的fetch被调用两次,randomuser.me被请求了两遍。虽然实际上因为第一次请求被AbortController取消,界面只显示一次结果,但控制台里的网络请求记录仍然很乱。

如果你对请求次数敏感,可以用一个模块级缓存变量,同一份数据只请求一次。比如在Hook外面放一个let cacheUsers: User[] | null = null,如果已经有缓存就直接使用,不再fetch。不过这只是开发环境的问题,生产环境不会出现。为了让代码更规范,我还是在代码里检查了AbortError,然后返回。

6.3 中文输入法组合输入时的过滤异常

中文用户经常是先打拼音,再选字。拼音组合过程中,输入框的值会不断变化,比如“li”还没确认成“李”时,React的onChange就已经触发了。如果此时就用“li”去过滤用户列表,结果是“李”根本匹配不上“li”,等确认组合后才会变成正确query。这个问题在远程搜索场景下更严重,因为它会发出大量不必要请求。

我给的方案是监听CompositionEvent。在onCompositionStart时把组合标志设为true,在onCompositionEnd时设为false,过滤逻辑只在组合过程结束后执行。React也提供了e.nativeEvent.isComposing属性可以判断,但还是需要维护一个ref或state来跨事件传递状态。这个坑在英文场景完全不会暴露,但中文用户一多,还是得处理。

6.4 视觉完成并不等于组件完成

样式和功能都做完之后,我以为这个组件可以收工了,结果自己作为重度键盘用户,很快发现几个小问题:聚焦搜索框时没有明显的focus环,想要清空结果必须点按钮,Escape键没有效果。这些都不是大功能,但直接决定组件的可用性。

我后来补了几个无障碍属性:搜索框加aria-label,清空按钮加aria-label,结果列表区域加aria-busy={loading}。头像加载失败时,让onError自动替换成一个由姓名拼接的占位图,至少比碎图好看。LiveUserFilter这种组件表面上是个“小功能”,但把所有边界情况都处理好,工作量其实不小,这也是我把它放进挑战列表的原因。


最后再分享一点我个人的体会:实时过滤组件,最容易被低估的就是“实时”这两个字。单纯把用户列表渲染出来很简单,但输入时如何保持流畅、状态如何切换、中文输入怎么处理、样式怎么跟随设计系统,这些细节才是真正区分“能跑”和“好用”的地方。React 19的useDeferredValue和Tailwind CSS V4的CSS-first配置,在这个项目里都给了我实际收益。如果你也想做50天50个小项目,建议从这里入手,它不算难,但足够让你把新版本的核心机制摸一遍。后续我还会把URL参数同步搜索词、虚拟列表、多条件筛选这些功能做成进阶版,到时候再写一篇详细记录。

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

机器人系统参数管理:从全局字典到动态调参的完整设计指南

做机器人系统&#xff0c;难免会有这么一段灰头土脸的经历&#xff1a;调试了一下午&#xff0c;最后发现是某个参数名字拼错了&#xff1b;或者昨天还能正常跑&#xff0c;今天换了台电脑&#xff0c;配置文件里的一个数值莫名其妙读不出来。我这次的实验13&#xff0c;就是专…

作者头像 李华
网站建设 2026/10/6 14:27:53

用巴菲特芒格框架筛选可编程物质:材料科学的长期价值投资

把一块金属片放在热灯下&#xff0c;它没有老老实实地热胀冷缩&#xff0c;而是按照预先写入的“指令”自动折成一架纸飞机的形状&#xff1b;把一颗胶囊注入血液&#xff0c;它只在碰到特定疾病标志物时才解锁释放药物。这些场景在材料科学基础的教科书里还没有成为常驻章节&a…

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

百万行代码仓库AI理解实战:RAG与分层摘要方案

1. 百万行代码仓库的AI理解困境与破局思路 1.1 为什么“把代码全塞给AI”这条路走不通 很多人第一次尝试让AI理解大型代码仓库时&#xff0c;直觉做法就是把整个仓库打包丢给模型。我最早也这么干过&#xff0c;结果非常惨烈&#xff1a;一个中等规模的Java后端项目&#xff0…

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

从零搭建AI智能体Office套件:任务规划、工具调用与记忆管理实战

AI智能体正在从"能聊天"往"能干活"的方向快速演进&#xff0c;而Office套件恰好是检验一个智能体是否真正具备生产力的最佳试炼场。我最近花了几周时间&#xff0c;从零搭了一套面向文档处理场景的AI智能体Office套件&#xff0c;覆盖文档生成、表格分析、…

作者头像 李华
网站建设 2026/10/6 14:25:04

RV1106开发入门:超低功耗AI视觉芯片的硬核实践指南

1. 为什么RV1106不是“另一个ARM开发板”&#xff1a;从芯片架构到落地场景的硬核认知重构 瑞芯微RV1106这个型号&#xff0c;最近在边缘AI硬件圈里被反复提起&#xff0c;但很多人拿到开发板的第一反应还是——“不就是个带NPU的ARM板子&#xff1f;烧个系统、跑个YOLOv5不就完…

作者头像 李华
网站建设 2026/10/6 14:24:55

AI智能体触达保障:Agent-Reach的核心机制与工程实践

1. 项目背景与核心价值第一次听到“Agent-Reach”这个名字的时候&#xff0c;我脑子里冒出的画面是一个跑腿小哥&#xff0c;拿着订单穿梭在城市的各个角落&#xff0c;确保每一单都准确送达。后来我意识到&#xff0c;这个类比放在AI智能体&#xff08;Agent&#xff09;身上其…

作者头像 李华