
字节笔记本
2026年10月7日 · 约 10 分钟读完
React 文章发布表单实战:从最小可用到体验打磨
做一个「发布文章」的页面,听上去是前端里最没有悬念的题目:几个输入框加一个按钮。但真要从零把它打磨到顺手,会接连撞上受控组件、复合组件、浏览器文件对象与本地持久化这一整串知识点。最近就把这样一个表单从三十行的最小可用版本,一路迭代到功能完整、交互顺手的发布界面,中间踩了 shadcn/ui 新手几乎必踩的一个坑,也顺手做了多图预览与标签历史建议。这篇按迭代顺序复盘。

最小可用版:两个状态加一个按钮
第一版只求能用:标题输入框、内容文本域、发布按钮,外加两行 useState。标题与内容都是受控组件,值与输入事件全部交给 React 管理;点击按钮时先把字段汇总打印出来,为将来接入真实的提交逻辑留好接口。
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
<Textarea value={content} onChange={(e) => setContent(e.target.value)} />布局用一个 max-w-2xl 容器居中,阅读宽度就此收住。这个版本没有任何业务逻辑,却确立了表单的基本形态:所有字段受控,提交入口只有一个函数。后面无论加多少字段,都是往这套骨架上添东西。受控写法的好处此刻还不明显,等草稿开关要按内容动态切换按钮文案、等标签历史要读取当前输入时,你会庆幸所有状态都握在自己手里。
完整的发布表单还缺什么
对照任何正经的内容平台,只收标题和正文的编辑器都远远不够。第二轮补齐五类字段:
- 分类:单选下拉,归属到技术、生活这类一级栏目;
- 标签:逗号分隔的多个关键词,提交时拆成数组;
- 封面图:file 输入,accept 限定图片类型;
- 发布日期:datetime-local 控件,为定时发布留位;
- 草稿开关:勾选后按钮文案从「发布文章」切成「保存草稿」。
这一轮还有两个不起眼的细节:每个字段都配了 Label 并用 htmlFor 关联,无障碍与点击热区同时受益;容器从 2xl 放宽到 3xl,给新增字段腾出空间。发布函数也顺势把标签字符串拆成数组、把文件对象一并带上,数据结构先行定型,后面接后端就不用再动表单层。顺带一提,发布日期与草稿开关其实是一对:前者决定何时见刊,后者决定内容是否就绪,两者组合出立即发布、定时发布、存草稿三种常见状态,字段虽小,语义要想清。
第一个坑:Select 没报错,下拉却出不来
分类字段上线后第一次翻车:组件不报错、界面也渲染了,点击后下拉列表却死活不出现。排查后发现,代码把 shadcn/ui 的 Select 当原生 select 写了——直接往里塞 option,用 onChange 收值。
shadcn/ui 的 Select 不是原生控件的换皮,它基于 Radix UI 封装,是由多个子组件按固定结构拼装的复合组件:触发器、当前值、选项容器、选项各司其职,取值走 onValueChange 而不是 onChange。照结构重新组装,下拉立刻正常。原因也不难理解:Radix 拿到 children 后按槽位解析,只认自家导出的那几个子组件,随手写进去的 option 会被直接忽略,既不渲染也不报错。
<Select onValueChange={setCategory} value={category}>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="technology">技术</SelectItem>
</SelectContent>
</Select>这类复合组件在 shadcn/ui 里到处都是,Tabs、Dialog、DropdownMenu 都是这个模式。它最迷惑人的地方在于:结构写错并不报错,只是功能静默失效。所以用之前先过一遍文档给出的组装结构,比凭直觉堆标签省时间。
封面图:多选、本地预览与删除
接下来把单张特色图片升级成可多选的封面图。核心是浏览器提供的 URL.createObjectURL:每个 File 对象都能换成一个本地 blob URL,img 标签直接拿来渲染,文件还没上传到服务器就能看到真图。
const handleImageUpload = (e) => {
const files = Array.from(e.target.files);
const newImages = files.map((file) => ({
file,
preview: URL.createObjectURL(file),
}));
setCoverImages((prev) => [...prev, ...newImages]);
};
const removeImage = (index) => {
setCoverImages((prev) => prev.filter((_, i) => i !== index));
};file input 加上 multiple 属性即可一次选多张;预览区把缩略图排成一行,每张右上角一个红色删除按钮,点击按索引过滤即可。预览与上传其实是两件事:blob URL 只活在当前页面会话里,真正把文件送进对象存储发生在提交时,届时用 FormData 包好 file 字段交给接口就行,表单层不必感知。另有一个必须补的坑:createObjectURL 会持续占用内存,删除图片或组件卸载时应调用 URL.revokeObjectURL 释放,否则反复增删图片时内存会悄悄涨上去。
把文件输入框藏进 + 号框
功能对了,观感还差一口气。原生 file input 在不同浏览器里长相各异,一行「未选择任何文件」与精致表单格格不入。改造思路是经典的隐藏 input 加触发器:真正的 input 用 hidden 类藏起来,useRef 拿到引用;视觉上只留一个虚线边框的方框,中间一个加号图标,悬停时边框与图标加深。点击方框调用隐藏 input 的 click 方法,系统选图面板照常弹出。
const fileInputRef = useRef(null);
const triggerFileInput = () => fileInputRef.current.click();已选缩略图与加号框并排排在同一行里,选一张多一个缩略图,末尾永远跟着虚线框,动线一目了然。
标签历史:localStorage 与 200 毫秒的容让
最后一轮优化标签输入的效率:用过一次的标签自动记下,下次聚焦输入框时弹出历史标签,点一下直接补进输入框。
存取不复杂:组件挂载时用 useEffect 从 localStorage 读出历史数组;发布时把本次输入的标签去重合并写回。建议列表用绝对定位挂在输入框下方,不挤压其他字段的布局。要留神的是健壮性:读回的字符串要过 JSON.parse,格式变化时会抛异常,包一层 try-catch 降级成空数组是成本很低的保险。
真正的细节在 blur 上。若输入框失焦就立刻隐藏建议,你永远点不到它——blur 事件先于列表项的 click 触发,列表一消失,click 便落空。办法是延迟两百毫秒再隐藏:
const handleTagBlur = () => {
setTimeout(() => setShowTagSuggestions(false), 200);
};
这 200 毫秒就是留给用户点击的窗口。更现代的写法是在列表项上监听 mousedown 并调用 preventDefault,让输入框压根不失焦,连延迟都不需要;但 200 毫秒容让的思路同样值得记住,它解释了大量「下拉点不到」类 bug 的成因。另外,这个定时器在组件卸载时应当清理,否则晚到的隐藏会操作一个已经不存在的状态。
走到这里之后
回顾几轮迭代,一个发布表单浓缩了四块基本功:受控组件是地基;Radix 系复合组件要按结构组装,而不是凭直觉堆标签;File 与 blob URL 打开了本地预览的门;localStorage 则是最轻量的用户习惯记忆。这套骨架同样适用于商品编辑、Issue 模板、资料填写等一切多字段加附件加状态的表单场景。再往后还有一串自然的扩展:换富文本或 Markdown 编辑器、发布前预览、SEO 字段、自动保存,多人场景再补权限与审核流。但那些都是骨架上的加法——先把最小可用版立起来,再一轮一轮按反馈打磨,界面就是这样长出来的。



