在浏览器本地预览用户选择的图片

在浏览器本地预览用户选择的图片

依据 Using files from web applications 翻译整理,原作者为 MDN contributors / Mozilla Contributors。原页最后修改日期为 2026 年 10 月 7 日;本文于 2026 年 10 月 9 日核对。本文聚焦本地选择与图片预览;源页后续上传、PDF 与视频示例不在本篇讨论范围内。

网页可以通过 File API 读取用户主动选择的本地文件。常见入口有两个:<input type="file"> 和拖放区域。两种入口最终都可以把 File 对象交给同一套预览逻辑,不必为“选择文件”和“拖进文件”各写一套实现。MDN 也指出 File API 可在 Web Workers 中使用;本文示例聚焦 DOM 中的本地图片预览。

本地图片预览流程:选择或拖放产生File列表,检查数量和大小后创建对象URL,显示图片,移除预览时撤销对象URL
本地预览的数据流和资源生命周期。未完纪自绘示意图,不是浏览器运行截图。

取得用户选择的 File 列表

最简单的文件选择控件如下。multiple 允许一次选择多个文件,控件的 files 属性返回 FileList:

<input type="file" id="input" multiple>
const input = document.getElementById("input");
input.addEventListener("change", () => {
  const files = input.files;
  const count = files.length;
  const first = count ? files[0] : null;
});

FileList.length 告诉我们有多少个文件,索引访问可取得其中一个 File。需要检查空列表,不能假定用户每次都会选中文件。File API 不提供任意遍历用户硬盘的能力;这里的访问范围来自用户在选择器或拖放操作中主动交给页面的文件。

文件名、大小与媒体类型分别说明什么

属性 含义 使用边界
name 文件名,不包含完整路径 作为不可信文本显示,不插入 HTML 字符串
size 以字节计的大小 压缩后的文件大小不等于解码后的内存占用
type MIME 类型;不能确定时可能为空字符串 可作界面提示和初筛,不能代替内容安全验证

原文用遍历相加计算总字节数,再按 1024 的幂换算显示单位。它直接对总字节数求对数,零字节时会碰到 log(0)。下面是编者修订,先处理零和非有限值;这里只用于界面显示,不改变文件内容:

function formatBytes(bytes) {
  if (!Number.isFinite(bytes) || bytes < 0) return "未知大小";
  if (bytes === 0) return "0 B";
  const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
  const exponent = Math.min(
    Math.floor(Math.log(bytes) / Math.log(1024)),
    units.length - 1,
  );
  return (bytes / 1024 ** exponent).toFixed(exponent === 0 ? 0 : 2)
    + " " + units[exponent];
}

让选择入口可用,也能被键盘操作

原文给出两种自定义入口。第一种把文件输入控件设为 display: none,再在可聚焦的按钮点击处理器中调用其 click() 方法。按钮应使用 type="button",避免意外提交所在表单。第二种用 label 的 for 指向文件控件的 id,不需要 JavaScript 转发点击。

如果采用第二种方式,不要把控件设成 display: none 或 visibility: hidden,否则键盘访问会受影响。可以使用原文给出的“视觉隐藏”方式,并在标签上显示焦点:

<input id="fileElem" type="file" multiple accept="image/*"
       class="visually-hidden">
<label for="fileElem">选择图片</label>
.visually-hidden {
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  height: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
  width: 1px;
}
input.visually-hidden:is(:focus, :focus-within) + label {
  outline: thin dotted;
}

原文提醒过不同浏览器对文件控件焦点反馈的差异,因此实际页面仍应检查键盘焦点是否可见。下面的完整整理版保留可见的原生输入控件,减少额外界面代码。

把拖放也接到同一条处理流程

要让一个区域接受拖放,应监听 dragenter、dragover 和 drop。阻止这些事件的默认行为,可以避免浏览器把拖入的文件当作导航目标。真正的文件列表位于 drop 事件的 dataTransfer.files 中。

for (const name of ["dragenter", "dragover", "drop"]) {
  dropzone.addEventListener(name, (event) => {
    event.preventDefault();
    event.stopPropagation();
  });
}
dropzone.addEventListener("drop", (event) => {
  renderFiles(event.dataTransfer.files);
});

因此,统一处理函数应显式接受文件列表。原文对象 URL 示例使用 this.files 配合输入控件事件;将它直接用于拖放时,this 不再是同一个控件。改成 renderFiles(files) 后,两个入口的关系更清楚。

FileReader 和对象 URL 是两种预览路线

原文首先演示 FileReader.readAsDataURL(file):为每张图片创建一个读取器,在 load 回调中把 reader.result 赋给图片的 src。这是异步读取;得到的 data: URL 将文件内容编码在字符串里。

const reader = new FileReader();
reader.addEventListener("load", () => {
  img.src = reader.result;
});
reader.readAsDataURL(file);

以上保留核心机制,不是完整的错误处理模板。需要考虑读取失败、取消和视图已被替换的情况。原文还在图片对象上挂了对应的 File 以备后续上传使用;本地预览不需要这种上传关联。

第二条路线是对象 URL:URL.createObjectURL(file) 返回一个引用该文件的 URL,可直接交给图片元素。即使对同一个 File 调用多次,每次也会产生不同 URL,每一个都需要管理生命周期。

const url = URL.createObjectURL(file);
img.src = url;
// 图片被移除、用户也不再需要使用它时:
URL.revokeObjectURL(url);

文档卸载时浏览器会自动清理关联资源,但长时间运行的页面不能一直等到卸载。应在替换、清空或移除预览时主动撤销。也不应在刚创建 URL 时立即撤销;原文特别提醒,即使图片已触发 load,立即撤销仍可能使“在新标签页打开”或“另存图片”等交互失效。

一个带资源清理的完整整理版

下例是基于原文机制重写的编者修订版,不是原文逐字代码。它统一两种输入方式,以文本节点显示文件信息,限制一次最多 12 张、每张不超过 10 MiB,并在图片解码后拒绝超过 2400 万像素的图片。这些数值是演示策略,应用应根据实际设备能力调整。

<label for="images">选择要在本地预览的图片</label>
<input id="images" type="file" multiple
       accept="image/jpeg,image/png,image/webp">
<div id="dropzone">也可以把图片拖到这里</div>
<button id="clear" type="button">清空预览</button>
<p id="status" role="status">尚未选择文件。</p>
<ul id="previews"></ul>
const input = document.getElementById("images");
const dropzone = document.getElementById("dropzone");
const previews = document.getElementById("previews");
const status = document.getElementById("status");
const urls = new Set();
const types = new Set(["image/jpeg", "image/png", "image/webp"]);
const MAX_FILES = 12;
const MAX_BYTES = 10 * 1024 * 1024;
const MAX_PIXELS = 24_000_000;

function clearPreviews() {
  previews.replaceChildren();
  for (const url of urls) URL.revokeObjectURL(url);
  urls.clear();
}

function renderFiles(fileList) {
  const files = Array.from(fileList);
  clearPreviews();
  status.textContent = files.length
    ? "已选择 " + files.length + " 个文件;本例只预览前 " + MAX_FILES + " 个。"
    : "尚未选择文件。";

  for (const file of files.slice(0, MAX_FILES)) {
    const item = document.createElement("li");
    const info = document.createElement("p");
    info.textContent = file.name + " — " + formatBytes(file.size);
    item.append(info);
    previews.append(item);

    if (!types.has(file.type) || file.size === 0 || file.size > MAX_BYTES) {
      info.append("(已跳过:类型或文件大小不符合本示例规则)");
      continue;
    }

    const img = document.createElement("img");
    img.alt = file.name + " 的本地预览";
    img.height = 120;
    let url;
    try {
      url = URL.createObjectURL(file);
      urls.add(url);
    } catch {
      info.append("(无法创建预览资源)");
      continue;
    }

    function rejectImage(message) {
      img.remove();
      URL.revokeObjectURL(url);
      urls.delete(url);
      info.append("(" + message + ")");
    }

    img.addEventListener("error", () => {
      if (item.isConnected) rejectImage("图片解码失败");
    }, { once: true });
    img.addEventListener("load", () => {
      if (!item.isConnected) return;
      if (img.naturalWidth * img.naturalHeight > MAX_PIXELS) {
        rejectImage("图片像素数量超过限制");
      }
      // 正常图片保留 URL,直到替换或清空预览。
    }, { once: true });
    item.append(img);
    img.src = url;
  }
}

input.addEventListener("change", () => {
  renderFiles(input.files);
  input.value = ""; // 允许再次选择同一文件
});
for (const name of ["dragenter", "dragover", "drop"]) {
  dropzone.addEventListener(name, (event) => {
    event.preventDefault();
    event.stopPropagation();
  });
}
dropzone.addEventListener("drop", (event) => {
  if (event.dataTransfer) renderFiles(event.dataTransfer.files);
});
document.getElementById("clear").addEventListener("click", () => {
  clearPreviews();
  input.value = "";
  status.textContent = "尚未选择文件。";
});

该实现还需要前面的 formatBytes 函数。HTML 和 JavaScript 块是可复制到自己项目中评估的示例。实际接入时,应在目标浏览器和设备上检查焦点可见性、内存占用、解码失败和资源清理行为。

边界:预览成功不等于文件可信

accept 只用于提示文件选择器,拖放也未必遵守这一提示。file.type 可为空或不准确;一个文件能被图片解码器显示,也不表示它适合被服务器存储或公开提供。本篇没有上传动作,源页后半部的上传示例不在研究范围内,不能把它接上就当作完整上传安全方案。

数量和字节限制能降低资源压力,但压缩图片可能在解码时膨胀;像素检查发生在解码后,不能承诺事前限制解码峰值。面对不可信、大批量文件,还需要更完整的资源策略与错误处理。页面中的其他脚本同样可能接触用户交给页面的文件对象;本地预览示例并不保证整个集成页面没有网络行为。

动态插入文件名应使用 textContent 或文本节点,避免把文件名当 HTML 解析。本文示例使用 textContent 显示文件名,不把文件名拼入 HTML 字符串;本地预览逻辑没有上传请求。对未知页面脚本和目标应用环境仍需单独审查。

来源与许可:Using files from web applications,Mozilla Contributors;源页标注 ©1998–2026 individual contributors,最后修改于 2026 年 10 月 7 日。依MDN 版权说明,文档内容按 CC BY-SA 2.5 或以后版本提供,文档翻译整理部分遵循同一许可。MDN 说明:2010 年 8 月 20 日及之后加入的代码样例为 CC0,此前样例按 MIT;Yari 迁移后无法可靠判定每段样例的加入日期,因此本文不把所有代码样例统一标成 CC0 或 MIT。中文翻译与原创示意图另行取得发布授权,代码样例归属仍依适用的源条款。修改包括本地预览范围、中文整理、零字节处理、统一选择/拖放入口、对象 URL 清理及安全边界说明。保留以上归属,不表示 Mozilla 对修订版作出背书。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容