
做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 vitelatest live-user-filter -- --template react-ts cd live-user-filter npm installReact 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-brandTailwind就会自动生成对应的颜色工具类定义--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/?results50返回的数据是一个对象核心字段在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/?results50 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] useStateUser[]([]) const [loading, setLoading] useState(true) const [error, setError] useStatestring | 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第二比较时大小写要统一全转小写第三可能要同时匹配多个字段比如姓名、城市、国家、邮箱。我封装了一个useFilteredUsersHookimport { 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 classNamerounded 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 typesearch但不同浏览器会显示不一样的清空按钮所以我干脆自己控制一个清空按钮保证跨浏览器样式统一。实现思路很简单当输入内容非空时在Input右侧显示一个“清空”图标按钮点击后清空query并让输入框重新聚焦。键盘支持上我让Escape键也能清空。这样用户除了点击按钮还能用键盘快速退出过滤状态。代码大概是input typesearch rolesearchbox aria-label搜索用户 placeholderSearch by name or city... value{query} onChange{(e) setQuery(e.target.value)} onKeyDown{(e) { if (e.key Escape) setQuery() }} classNamew-full rounded-xl border border-gray-200 px-4 py-3 pr-12 focus:border-brand focus:outline-none /pr-12是为了给清空按钮留出空间否则文字会盖在按钮下面。这个细节不用心看不出来但真碰到了就会觉得别扭。清空按钮本身可以是普通button记得加typebutton否则在表单里可能触发表单提交。4.3 骨架屏、空状态和错误兜底实时过滤组件有很多种“没有结果”的情况。初次加载时用户看到的是空白页面这非常劝退。所以我加了一个骨架屏用Tailwind的animate-pulse做一个闪烁占位让用户知道数据正在加载。骨架屏可以循环渲染8个卡片每个卡片的宽高模拟真实卡片布局。过滤结果为空时界面不能只显示一片空白要有一句友善的文案最好带上当前的搜索词。比如p classNametext-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 classNamegrid 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 classNameflex 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} loadinglazy classNameh-12 w-12 rounded-full / div classNamemin-w-0 h2 classNametruncate text-base font-semibold text-ink Highlight text{user.name} keyword{keyword} / /h2 p classNametruncate text-sm text-gray-500 Highlight text{${user.city}, ${user.country}} keyword{keyword} / /p p classNametruncate text-sm text-gray-400 Highlight text{user.email} keyword{keyword} / /p /div /articlemin-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参数同步搜索词、虚拟列表、多条件筛选这些功能做成进阶版到时候再写一篇详细记录。