
那天临近下班,产品在群里丢下一句话:“咱们页面上那个上传组件太土了,能不能搞个现代点的,图片还能预览的?”
我盯着那行文字,脑袋里飞快闪过几个关键词:现代 UI、图片预览、上传组件……好嘛,这是前端的经典老题了,我决定不只是满足需求,而是认真打磨一个真正可复用、功能完备的上传组件。
于是我打开 VS Code,搭建了一个小页面。整个组件分为几个部分:文件选择器、图片预览区、校验提示和样式控制。接下来的内容,我会一步步带你还原我开发过程中的思考和细节。
这个上传组件应该做到:
为此,我大致绘制了一张流程图,理清整个上传流程:

我决定不依赖框架,仅用原生 HTML、CSS 和 JS 来实现,这样也更贴近面试中的真实考察场景。
HTML 大致结构如下:
<div class="upload-wrapper">
<label class="upload-label">
<input type="file" accept="image/*" multiple />
选择图片
</label>
<div class="preview-list" id="preview-list"></div>
<p class="hint" id="hint"></p>
</div>这段结构很简洁:
一个文件选择器(包在 label 中以美化按钮样式)
一个用于展示预览图的列表容器
一个用于提示错误的文字块
在 CSS 中,我借鉴了现代 UI 的一些风格,比如圆角、阴影、hover 效果、响应式宽度等等,力求组件在任何屏幕下都好看实用:
.upload-wrapper {
max-width: 600px;
margin: 40px auto;
padding: 20px;
border: 2px dashed #ccc;
border-radius: 12px;
background: #fafafa;
font-family: "Segoe UI", sans-serif;
}
.upload-label {
display: inline-block;
padding: 10px 20px;
background: #007bff;
color: white;
border-radius: 6px;
cursor: pointer;
margin-bottom: 20px;
transition: background 0.3s ease;
}
.upload-label:hover {
background: #0056b3;
}
.upload-label input {
display: none;
}
.preview-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.preview-item {
position: relative;
width: 120px;
height: 120px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #ddd;
background-color: #fff;
box-shadow: 0 0 5px rgba(0,0,0,0.05);
}
.preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.remove-btn {
position: absolute;
top: 4px;
right: 4px;
background: rgba(255, 0, 0, 0.8);
color: white;
border: none;
border-radius: 50%;
width: 20px;
height: 20px;
cursor: pointer;
font-size: 14px;
line-height: 18px;
}
.hint {
color: red;
margin-top: 10px;
}现代感主要通过 border-radius、box-shadow 和响应式布局实现。
到了 JS 部分,难点主要是两个:
const input = document.querySelector('input[type="file"]');
const previewList = document.getElementById('preview-list');
const hint = document.getElementById('hint');
const MAX_SIZE_MB = 2;
const MAX_COUNT = 5;
let images = [];
input.addEventListener('change', () => {
const files = Array.from(input.files);
hint.textContent = ''; // 清空提示
if (images.length + files.length > MAX_COUNT) {
hint.textContent = `最多只能上传 ${MAX_COUNT} 张图片`;
return;
}
files.forEach((file) => {
if (!file.type.startsWith('image/')) {
hint.textContent = '只能上传图片格式';
return;
}
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
hint.textContent = `图片大小不能超过 ${MAX_SIZE_MB}MB`;
return;
}
const reader = new FileReader();
reader.onload = function (e) {
const imageUrl = e.target.result;
renderPreview(imageUrl, file.name);
};
reader.onerror = function () {
hint.textContent = `预览失败:${file.name}`;
};
reader.readAsDataURL(file);
});
});
function renderPreview(src, filename) {
const wrapper = document.createElement('div');
wrapper.className = 'preview-item';
const img = document.createElement('img');
img.src = src;
img.alt = filename;
const removeBtn = document.createElement('button');
removeBtn.className = 'remove-btn';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => {
previewList.removeChild(wrapper);
images = images.filter((i) => i !== src);
});
wrapper.appendChild(img);
wrapper.appendChild(removeBtn);
previewList.appendChild(wrapper);
images.push(src);
}FileReader 是前端中处理本地文件读取的关键对象。我们通过 readAsDataURL 方法将图片转换为 base64 编码,这样就能立即用于展示。
这里最关键的一点是:FileReader 是异步的! 它并不会立刻返回结果,而是通过 onload 回调提供加载完的数据。
这点如果搞不清楚,常会出现在控制台里 reader.result 是 null 的迷之现象。
浏览器提供了非常清晰的 API:
file.type 可以直接判断 MIME 类型(如 "image/jpeg")file.size 是文件大小,单位是字节,自己换算成 MB 即可为了避免用户上传不合规的文件,我加入了前置校验逻辑。
在实际开发中,别忽视“失败场景”。
比如 FileReader 读取出错,会触发 onerror 回调。这种情况虽然罕见,但在图片损坏、浏览器兼容性差时可能会发生,不能让用户毫无反馈。
当用户不断新增或删除图片时,images 数组必须与页面上的预览项保持一一对应。这一点虽不复杂,但很容易遗漏:要删除的不仅仅是 DOM 节点,还要把对应的 Base64 URL 从数据里剔除。
于是,我在 renderPreview 外部维护了一个全局数组 images,每次渲染成功就 push 进去,删除时再 filter 一下。这样,images.length 永远能代表当前预览的张数,后续如果要上传到后端,也可以直接用它来生成 FormData。
let images = []; // 存储已添加的 base64 URL
function renderPreview(src, filename) {
// 省略创建 DOM 的部分…
removeBtn.addEventListener('click', () => {
// 先删数组
images = images.filter((i) => i !== src);
// 再删节点
previewList.removeChild(wrapper);
});
// 添加到页面和数组
previewList.appendChild(wrapper);
images.push(src);
}其实,我后来还加了一行日志输出,方便调试:“当前 images 数组:”, images,借此随时了解状态是否正常。有了这个小技巧,排查删除逻辑崩溃的概率大大降低。
一次偶然,我连点了两次选择框,发现两张完全相同的截图被推到预览区。虽然不影响展示,但如果后端不希望接收重复文件,就要在前端阻止。
解决办法也很简单:在 reader.onload 拿到 src 以后,先检查 images.includes(src),如果已经存在,就弹个提示 “不能重复添加同一张图片” 并直接 return 掉,不再插入 DOM,也不进数组。
reader.onload = function (e) {
const imageUrl = e.target.result;
if (images.includes(imageUrl)) {
hint.textContent = '这张图片已添加过啦';
return;
}
renderPreview(imageUrl, file.name);
};至此,无论用户怎么折腾,页面里都只会出现唯一的那几张照片。
我在项目初期把限制放宽到 10MB,结果一堆同事拿手机原图测试,上传前那一刻,FileReader 就几乎把整个浏览器卡死。为了防止“浏览器崩溃”,我把最大值先定在 2MB,并在 hint 区及时提示:
“温馨提示:为了更流畅的体验,单张图片建议不要超过 2MB。”
更进一步,为了彻底释放内存,每当用户删除一张图片时,我还手动将那张 img 标签的 src 设为空,再删节点:
removeBtn.addEventListener('click', () => {
images = images.filter((i) => i !== src);
img.src = ''; // 先清一下
previewList.removeChild(wrapper);
});这样就能迅速让浏览器回收那块占用的内存,尤其重要在移动端。
在这儿,我又用了一张流程图来概览整个流程。虽然看起来步骤不多,但一旦用户操作多次、删除多次,就很容易漏细节。

项目里,这套上传逻辑在多个页面都要用,我干脆把它封装成一个 ES6 class,并提供简单的 API,让调用方只需写两行就能搞定:
// upload.js
export default class ImageUploader {
constructor(options) {
this.wrapper = document.querySelector(options.selector);
this.maxCount = options.maxCount || 5;
this.maxSizeMB = options.maxSizeMB || 2;
this.init();
}
init() {
this.input = this.wrapper.querySelector('input[type="file"]');
this.previewList = this.wrapper.querySelector('.preview-list');
this.hint = this.wrapper.querySelector('.hint');
this.images = [];
this.bindEvents();
}
bindEvents() {
this.input.addEventListener('change', () => this.handleFiles());
}
handleFiles() {
const files = Array.from(this.input.files);
this.hint.textContent = '';
if (this.images.length + files.length > this.maxCount) {
this.hint.textContent = `最多只能上传 ${this.maxCount} 张图片`;
return;
}
files.forEach((file) => this.processFile(file));
}
processFile(file) {
if (!file.type.startsWith('image/')) {
this.hint.textContent = '只能上传图片格式';
return;
}
if (file.size > this.maxSizeMB * 1024 * 1024) {
this.hint.textContent = `图片大小不能超过 ${this.maxSizeMB}MB`;
return;
}
const reader = new FileReader();
reader.onload = (e) => this.onLoad(e, file);
reader.onerror = () => this.hint.textContent = `预览失败:${file.name}`;
reader.readAsDataURL(file);
}
onLoad(e, file) {
const src = e.target.result;
if (this.images.includes(src)) {
this.hint.textContent = '这张图片已添加过啦';
return;
}
this.renderPreview(src, file.name);
this.images.push(src);
}
renderPreview(src, filename) {
// 与之前相同:创建 .preview-item,插入 img 和 removeBtn,绑定删除逻辑
}
getFiles() {
// 如果要上传到后端,可直接把 images 转成 Blob,再 FormData 里用 fetch 上传
return this.images;
}
}接着,在页面里只要这样写:
import ImageUploader from './upload.js';
new ImageUploader({
selector: '.upload-wrapper',
maxCount: 5,
maxSizeMB: 2
});瞬间可读性和可维护性都飙升,其他同事也能毫无障碍地引用。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。