
字节笔记本
2026年10月7日 · 约 10 分钟读完
Pure.css 与原生 JS:手搓一个有呼吸感的上传页
做内部工具或产品原型时,"传个文件"大概是最常见的小需求:文件拖进来、点一下也能选、顺手填上文件名和日期、传给后端接口。听起来是分钟级的活,真要做得好看又健壮,会撞上一串前端经典命题:拖放事件怎么处理、原生文件选择器怎么藏、fetch 的错误怎么兜底、用户配置怎么记住。最近我把这条链路完整走了一遍,约束只有两条:样式用 Pure.css 打底,逻辑用原生 JavaScript,不引任何运行时框架和构建工具,整个页面就是一个 HTML 文件。
为什么选 Pure.css
Pure.css 是 Yahoo 团队开源的极简 CSS 框架,2022 年发布 3.0 版本。它的定位与 Bootstrap、Tailwind 完全不同:不提供成套组件,也不搞原子类体系,只有 base、buttons、forms、grids、tables、menus 六个模块,整套 pure-min.css 压缩后只有几 KB。
选它的理由很朴素。内网工具页、演示页、单文件交付的场景,不值得为一个上传页背上上百 KB 的样式库;Pure 只负责"按钮像按钮、表单像表单、栅格像栅格"的底座,视觉个性全部留给自己写;pure-form、pure-button 这些类名语义清晰,与自定义样式叠加时几乎不打架。
横向对比一下:Bootstrap 开箱即用,但样式痕迹重;Tailwind 灵活,但要么接构建链要么背 CDN 体积;从零手写 CSS 最干净,可表单与栅格的兼容细节很磨人。Pure 是中间那条路:花几 KB 换一个可靠底座,剩下的自己画。
具体到这个页面,实际用到的只有三块:pure-g 与 pure-u 栅格负责整体布局,pure-form 与 pure-form-stacked 负责表单排版,pure-button 负责按钮基线。其余视觉——渐变背景、圆角卡片、高亮态——全是手写 CSS。换句话说,Pure 解决的是"从零写起最烦"的那一小部分,而不是全部;它给的约束越少,留给设计的空间反而越大。
拖放上传:四个事件与一个隐身的 input

HTML5 拖放 API 的核心是四个事件:dragenter、dragover、dragleave、drop。四个都要在拖放区上监听,并逐个调用 preventDefault 与 stopPropagation——不阻止默认行为,浏览器会直接把拖进来的文件打开。
高亮反馈的惯用写法:dragenter/dragover 时给拖放区加高亮类,dragleave/drop 时移除。松手之前边框变色、底色微透,用户立刻知道"放这里就行"。
还有个容易忽略的前提:drop 能否触发,取决于 dragover 里有没有阻止默认行为——浏览器默认禁止投放,必须在 dragover 阶段声明"此处可放"。想进一步支持拖文件夹,单纯读 dt.files 只会得到一个空壳,得从 dataTransfer.items 里通过 webkitGetAsEntry 拿到目录入口再递归展开。
第一个坑就在这里。dragleave 在拖过拖放区子元素时会误触发,高亮反复闪烁。简单场景里 dragover 会持续触发、不断把高亮类加回来,等于用副作用掩盖了问题;更稳妥的做法是给子元素设置 pointer-events: none,或者用 dragenter/dragleave 计数器严格配对。
另一个通用技巧:原生 file input 长得丑,直接 display: none 藏掉,把整个拖放区做成点击热区,点击时手动触发 input.click(),或者用 label 的 for 属性关联。input 本体必须保留——真正的文件读取仍靠它,只是让它在界面上隐身。
FormData 加 fetch:上传与接口约定
拿到 File 对象后,上传不需要 XMLHttpRequest,也不需要任何库:
const formData = new FormData();
formData.append('file', file);
formData.append('fileName', fileName);
formData.append('fileDate', fileDate);
fetch(uploadUrl, { method: 'POST', body: formData })
.then(res => {
if (!res.ok) throw new Error('Network response was not ok');
return res.json();
})
.then(() => { /* 成功:写入列表 + Toast 提示 */ })
.catch(err => { /* 失败:列表标记 + Toast 提示 */ });
后端接口很薄。以 Express 为例,挂一个解析 multipart 的中间件,存好文件后返回 JSON 即可,形如 res.json({ message: '文件上传成功', fileName: req.file.filename })。
用 FormData 还有个反直觉点:请求体会自动按 multipart/form-data 编码,边界串由浏览器生成,因此手动给 fetch 设置 Content-Type 反而会弄丢 boundary,导致后端解析失败。什么都不设,交给浏览器就好。
这里有个必须写进肌肉记忆的坑:fetch 只有网络层错误才会 reject,404、500 都算"成功"。response.ok 检查不可省略,否则后端报错了,前端还在走成功分支。
交互迭代:从"选了就传"到"暂存再保存"
页面第一版是选完文件立刻上传。实际用起来很快发现不对:文件名、日期这些附加信息来不及填。于是改成两段式:文件先进 filesToUpload 数组暂存,列表显示"文件 1:xxx(类型)";用户填好文件名、选好日期,点"保存文件"才批量提交。
这是上传类 UI 的通用范式——暂存(staging)与提交(commit)分离。用户对"哪些文件会传出去"有确定预期,附加信息也只填一次。顺带的收益是扩展空间:既然文件先进暂存区,上传前移除某个文件、展示总体积、做并发控制都变得自然,这些用一个数组加重绘函数就够了,犯不着引入状态库。
报错提示做了两层:登录表单内嵌一块红字错误区,负责表单级错误;全局 Toast 负责上传成败这类瞬时反馈,底部浮出、三秒自动消失,动画全部交给 CSS keyframes。
弹窗与配置持久化
登录弹窗的需求写的是"纯 CSS 实现",落地版本其实是 CSS 负责样式与动画——遮罩淡入、面板滑入——display 的切换仍由 JS 控制。这其实是多数"纯 CSS 弹窗"的现状;若真想零 JS,如今更值得直接用原生 dialog 元素:showModal() 自带遮罩、焦点圈定和 Esc 关闭,比老式 hack 省心得多。
弹窗里除了用户名、密码,还放了一个"上传地址"输入框,提交后写入 localStorage;页面加载时读回并自动回填,下次打开不用重填。读写最好都包一层 try/catch:部分浏览器的无痕模式对 setItem 直接抛异常,配额超限同理;配置读不到就当没配置,页面不该因此白屏。这套做法对内网工具页完全成立,但有一条红线要守住:密码与 token 不该进 localStorage,它可被同源 XSS 脚本读取、也无法主动过期,敏感凭证走 HttpOnly Cookie 才是正解。
顺带一个时区坑:日期兜底用了 toISOString().split('T')[0],取的是 UTC 日期,东八区凌晨上传会显示成"昨天"。严谨的写法应按本地时区格式化。
移动端与视觉细节
渐变背景、半透明白卡片、大圆角、柔和投影,负责"现代感";充足留白、hover 时卡片微浮、输入框 focus 的 box-shadow 光圈,负责"呼吸感"。移动端只靠一段 max-width 600px 的媒体查询收缩内边距与字号,加上 max-width: 800px、width: 90% 的容器,就覆盖了绝大多数场景——这类页面,媒体查询足够,不必上重型方案。
四条踩坑清单
- dragleave 误触发导致高亮闪烁:dragover 反复加类是权宜之计,计数器配对更稳;
- fetch 遇到 404/500 不会 reject:必须手动检查 response.ok;
- 多文件共用一个文件名输入框:批量上传循环第一次就清空了输入框,自定义文件名只对第一个文件生效,其余回落为原始文件名,要么逐文件填写,要么把字段下沉到列表项;
- 用 window.onclick 全局关弹窗:能用但污染全局命名空间,改为监听弹窗根节点判断事件目标,并补上 Esc 关闭。
收尾
几百行 HTML,没有一行框架代码,就是一个能用的上传页。这个练习的价值不在页面本身,而在它把拖放事件、FormData、fetch 错误处理、localStorage、弹窗这些散点串成了完整链路。若要继续打磨,方向也都是现成的:粘贴上传(监听 paste 事件)、进度反馈(fetch 没有原生上传进度,可用 XMLHttpRequest 的 onprogress)、大文件分片。下次要写内部工具或原型,不妨从这样一个单文件页面起步——工具链越轻,迭代越快。



