前端文件预览完整指南:百兆大文件、跨域安全与移动端适配

内容提要

从图片、PDF、Office、表格、文本和音视频的方案选择,深入讲解百兆文件的 Range 传输、内存预算、CORS 与鉴权、安全隔离、移动端适配、生命周期回收和异常降级,附实现代码与上线验收矩阵。

文件预览的核心,是根据文件格式、数据规模、权限和设备能力,选择合适的传输、解析和渲染方式。常见图片与音视频优先使用浏览器原生能力;需要统一交互的 PDF 使用 PDF.js;复杂 Office 和超大文件由服务端生成预览产物,前端按需展示。

本文面向需要上线文件预览功能的前端工程师。覆盖本地文件与远程私有文件、100 MB 级文件、Range、CORS、鉴权与隔离、移动端适配、取消与回收、异常降级及验收方法。文中的容量、并发和像素限制是工程起始配置,不是浏览器硬性上限;应通过目标设备实测调整。技术资料核对日期:2026-10-29。

一、先确定预览目标,再选择实现方式

先回答四个问题:文件来自用户本地还是服务端?预览要求是阅读内容还是严格保留排版?是否需要搜索、复制、打印、批注?文件是否包含敏感内容?同一个 DOCX,如果只需要阅读正文,可以转语义化 HTML;如果要求合同分页、字体和签章位置一致,就应采用经过验证的转换服务或专用查看器。

文件类型通常采用的方案规模与功能边界
JPEG / PNG / WebP 等图片img + 缩略图 + 按需原图大尺寸图片优先服务端缩放;超高清使用分块图
PDF原生查看器,或 PDF.js + Worker + 分页渲染原生交互因终端而异;大 PDF 需要 Range 与内存预算
DOCX轻量正文转 HTML;保真阅读转 PDF转换器不保证完整复刻 Word;大文件服务端转换
XLSX / XLS / CSV小表格解析后虚拟滚动;大表格服务端分页虚拟滚动只减少 DOM,不减少整个工作簿的解析内存
PPTX / PPT服务端生成 PDF 或逐页图片静态产物不能保留全部动画、音视频与交互
TXT / 日志 / 代码限量读取 + 安全文本节点 + 虚拟列表大日志需要按字节窗口或行索引;不要全量高亮
音视频audio / video;必要时转码与自适应流容器后缀不代表编解码器可播放;拖动依赖传输与封装
HTML / SVG / Markdown文本展示,或净化后放隔离域不可把用户内容作为业务站点可信 HTML 执行
ZIP / CAD / 其他格式展示元信息、受控转换、授权下载不要默认在客户端展开所有内容;专用格式需要专用能力

预览并不等于把原文件完整交给浏览器。服务端可以生成缩略图、分页图片、PDF、文本索引、表格分页数据等产物。保真、可搜索、可访问性、计算成本之间存在取舍:图片页很稳定,但文字复制与读屏需要额外的文本层。

二、推荐架构:先请求预览描述,再加载内容

用户打开文件
  → 业务 API 校验用户、租户、文件权限
  → 返回预览描述:状态、版本、格式、尺寸、渲染方式、受控地址
      ├─ 原生资源 → img / audio / video
      ├─ PDF → PDF.js 按需取字节、按页渲染
      ├─ 大表格 / 大日志 → 分页 API
      └─ Office / 特殊格式 → 转换队列 → 预览产物
  → 首屏可读
  → 按需加载后续内容
  → 关闭、切换或取消时释放资源

原文件名、扩展名和浏览器 File.type 都只能作为提示。后端需要结合内容签名、实际解析结果和允许的格式集合确定类型,再发出正确的 Content-Type。对不能确认的内容,展示元信息和下载入口,不猜测成 HTML。

// 示例业务协议;不是某个查看器的内置类型
type PreviewDescriptor = {
  fileId: string;
  version: string; // 绑定不可变内容,防止不同版本的 Range 混读
  name: string;
  mime: string;
  size: number;
  status: "processing" | "ready" | "unsupported" | "failed";
  renderer: "image" | "pdf" | "media" | "text" | "table";
  previewUrl?: string; // 自有网关或短时签名地址
  expiresAt?: string;
  pageCount?: number;
  downloadAllowed: boolean; // 仅用于 UI,后端仍必须鉴权
};

处理中的文件返回可追踪的任务状态,不让一个 HTTP 请求等待几分钟。转换任务按租户、文件内容版本和转换配置去重;产物还应记录转换器版本,便于更新引擎后重新生成。缩略图、预览页、搜索接口与原文件必须继承相同的权限边界。

三、本地预览:优先 Blob URL,明确资源所有权

用户通过 input 或拖放选择的 File 可以直接建立 Blob URL,图片和媒体通常不需要先上传。createObjectURL 不会把文件变成 Base64 字符串;但后续解码仍然消耗内存,不能把它理解为“预览任意大文件都不占内存”。对远程资源先 fetch,再 response.blob(),仍然是在完整读完响应后才得到 Blob。

Blob URL 在不再使用时需要撤销。不要刚设置 src 就立即 revoke,也不要在图片 load 后立即撤销仍需右键保存或重新打开的资源。参考 MDN:createObjectURL。

// React:替换文件或卸载时释放对应 URL。
// 销毁查看器、停止媒体等动作,由使用这个 URL 的组件负责。
import { useEffect, useState } from "react";

export function useFileUrl(file) {
  const [entry, setEntry] = useState(null);

  useEffect(() => {
    if (!file) {
      setEntry(null);
      return;
    }
    const url = URL.createObjectURL(file);
    setEntry({ file, url });
    return () => URL.revokeObjectURL(url);
  }, [file]);

  // 文件刚变更、effect 尚未执行时,不把旧 URL 当成新文件展示。
  return entry && entry.file === file ? entry.url : null;
}

Base64 的编码长度约为原始字节的 4/3,100 MiB 文件仅编码字符就约 133 MiB,还没有计入原字节、字符串表示、解码副本和渲染缓存。因此不要把大文件存进 React 状态、JSON、localStorage 或 data URL;状态里保存文件引用、页码和进度即可。本地预览失败后,也不要静默把敏感文件上传给第三方转换服务。

四、百兆文件:分别控制传输、解析、渲染

环节常见错误应控制的资源
传输fetch 完整文件 → arrayBuffer → 再开始预览按需字节、流式读取、可取消请求、有限并发
解析认为放进 Worker 就没有内存风险解压后体积、对象数量、解析时间、Worker 生命周期
渲染一次性创建几百张高分辨率 canvas可见页数量、像素总量、文本层和图片缓存
缓存一直保留全部页、Buffer 和历史文件有上限的 LRU、版本键、明确的失效与清理

一个宽 1200、高 1600 CSS 像素的页面,如果按 DPR=3 建 canvas,仅 RGBA 像素缓冲就是 1200 × 1600 × 3² × 4 = 69,120,000 字节,约 65.9 MiB。十页约 659 MiB,还没算 PDF 对象、字体、解码图片和 GPU 副本。文件只有几十 MB,也可能使手机页面被系统回收。这个计算是估算下界,不是浏览器内存报告。

Worker 的主要收益是让解析不阻塞主线程。ArrayBuffer 通过 transfer list 转移所有权可减少一次复制,但解压和解析仍需内存。把全量表格解析成几十万个对象,再把它们全部 postMessage 回主线程,往往再次制造高峰。应批量传输有限行数,并让消费速度约束生产速度。

可从以下预算开始:手机同时只渲染 1 页,桌面 1~2 页;保留可见页和少量相邻页;单张 canvas 上限先取约 400 万像素;缩放时临时用 CSS 反馈,手势结束后再清晰重绘。它们只是本文建议的调优起点,无法限制某一页内部超大图片带来的解码峰值。极端文件仍需服务端生成低分辨率预览。

五、大 PDF:URL 加载、Range 与可见页渲染配合

如果只需要简单阅读,使用浏览器内置 PDF 查看器可减少开发量;但不能假定桌面与手机的内嵌能力、工具栏、打印和错误反馈一致。需要统一分页、搜索、缩放、批注时,优先评估 PDF.js 的完整 viewer。只画 canvas 的最小示例不包含文字选择、链接、表单、无障碍和搜索能力。

PDF.js 官方建议仅渲染可见页,并要求主库与 Worker 使用相同版本。Range 能让它按需请求数据,但 PDF 的索引、共享字体、图像和页面对象可能散布在不同位置,首屏未必只需一个分块。参考 PDF.js FAQ。

// Vite 风格示例;安装并锁定经过验证的 pdfjs-dist 版本。
// 不要混用另一版本 CDN 上的 Worker。
import * as pdfjs from "pdfjs-dist";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";

pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;

export function startPdf(url) {
  return pdfjs.getDocument({
    url, // 直接传入受控 URL,不要先 fetch 整个 PDF
    withCredentials: false, // 此例使用短时签名 URL
    disableRange: false,
    disableStream: true,
    disableAutoFetch: true,
    rangeChunkSize: 256 * 1024 // 示例值,按 RTT / CDN / 文件测试
  });
}

// 组件创建时:
// const loadingTask = startPdf(descriptor.previewUrl);
// const pdf = await loadingTask.promise;
// 随后只调用当前需要的 pdf.getPage(pageNumber)。

以上配置偏向限制主动下载:禁用自动预取时还需要禁用 streaming,才符合该选项的预期行为。它可能增加请求往返、降低快速网络上的连续阅读性能;不要当成所有 PDF 的最优默认值。Range 不可用时也可能退回完整下载,无法承诺流量硬上限。参考 PDF.js 加载参数。

真正的硬性流量限制需要网关、超时、取消和降级共同实施。先给业务定义“首屏允许下载多少字节、等待多久”,超预算后提供轻量服务端预览,而不是无限等待。普通顺序流式读取并不具备任意跳页能力;Range 与格式索引才解决随机读取。PDF linearization 有助于优化访问顺序,但也不构成首屏流量保证。

// 单页渲染核心;调用方必须串行调度同一 canvas。
export function renderBoundedPage(page, canvas, cssWidth) {
  if (!(cssWidth > 0)) throw new Error("容器尚未可见");

  const base = page.getViewport({ scale: 1 });
  const viewport = page.getViewport({ scale: cssWidth / base.width });
  const maxPixels = 4_000_000;
  const outputScale = Math.min(
    window.devicePixelRatio || 1,
    2,
    Math.sqrt(maxPixels / (viewport.width * viewport.height))
  );

  canvas.width = Math.max(1, Math.floor(viewport.width * outputScale));
  canvas.height = Math.max(1, Math.floor(viewport.height * outputScale));
  canvas.style.width = viewport.width + "px";
  canvas.style.height = viewport.height + "px";

  if (!canvas.getContext("2d")) throw new Error("无法创建 Canvas");

  return page.render({
    canvas,
    viewport,
    transform: [outputScale, 0, 0, outputScale, 0, 0]
  });
}

// 翻页/缩放:先取消旧 renderTask,并等待它的 promise 结束;
// 仅忽略 RenderingCancelledException,其他异常交给错误 UI。
// 再重设 canvas 尺寸并启动新任务,避免同一画布并发渲染。
// 页面淘汰:确认无任务后 page.cleanup(),canvas.width/height = 0。
// 整份文档关闭:销毁 loadingTask(await loadingTask.destroy())。
// 全部入口还需用会话序号隔离旧异步结果,见第十二节。

生产环境还要把字符映射、标准字体、WASM 等依赖按所选 PDF.js 版本一起部署,验证资源路径与 CSP;密码文件进入独立密码输入流程,不记录密码。对于扫描件,全文检索需要 OCR;对于复杂字体、表单、电子签名外观,要用真实样本验证,不能因首页成功就判定文档完全兼容。

六、Range 要在对象存储、CDN、网关整条链路生效

GET /preview/files/f-123/v-7.pdf HTTP/1.1
Range: bytes=0-262143

HTTP/1.1 206 Partial Content
Content-Type: application/pdf
Accept-Ranges: bytes
Content-Range: bytes 0-262143/104857600
Content-Length: 262144
ETag: "f-123-v-7"

这里的 104857600 字节是 100 MiB。Content-Length 是本次响应体的长度,Content-Range 中斜线后的数字才是完整资源长度。前端不能把每次 206 的 Content-Length 当成整个文件大小。

核对三种结果:206 返回合法区间;200 可能说明服务器忽略 Range 并返回全文件;416 表示请求区间不可满足,通常应重新检查文件长度和版本,而不是原样无限重试。Accept-Ranges 只是声明,应实际请求验证。参考 MDN:HTTP Range。

对 PDF 路径通常关闭会改变表示的动态 gzip/br 转换,避免字节位置、长度以及库的 Range 能力判断出现问题。网关必须透传合法 Range、相关条件请求与 206/416 响应;不能为了转发一段内容,先把整个远程文件读进 Node Buffer。应使用有背压的流式代理,并在客户端断开时中止上游。

每一段都必须属于同一个不可变版本。可以采用带版本的对象键,并结合 ETag / If-Range 处理资源变更。涉及签名 URL 时,还要检查云存储签名规则是否覆盖 Range、HEAD 等请求;不要把只允许 GET 的签名 URL 拿去 HEAD 后,把方法不匹配误判为文件不存在。

# 网络层检查示例;只对有权访问的测试资源执行。
# 最大下载量/时间限制可避免服务器忽略 Range 时拉走整个大文件。
curl --range 0-1023 --max-filesize 2048 --max-time 10 \
  -D - -o /dev/null \
  'https://files.example.com/preview/test-v1.pdf'

# 预期:206;Content-Range: bytes 0-1023/总长度;
# Content-Length: 1024。终端请求不能验证浏览器的 CORS 执行结果。

七、跨域:先区分“嵌入”“读取”和“鉴权”

img 能显示一张跨域图片,不代表 fetch 能读取它。iframe 能打开一个跨域页面,不代表父页面能访问内部 DOM。PDF.js 通过 JavaScript 请求文件,受到 CORS 限制;原生 iframe 导航又受到嵌入策略、Cookie 和浏览器查看器行为影响。遇到白屏,先定位是哪一种访问模式。

未获得跨域许可的图片即使能够显示,画进 canvas 后通常也不能导出或读取像素。需要导出时,在设置 src 前配置 crossOrigin,并由资源服务器返回对应 CORS 头。参考 MDN:跨域图片与 Canvas。

# 文件服务实际响应示例:仅回显通过白名单校验的 Origin
Access-Control-Allow-Origin: https://app.example.com
Vary: Origin
Access-Control-Expose-Headers: Accept-Ranges, Content-Range, Content-Disposition, ETag

# 预检 OPTIONS 响应示例:按业务所需收窄
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, HEAD, OPTIONS
Access-Control-Allow-Headers: Authorization, Range, If-Range
Access-Control-Max-Age: 600

# 仅在采用跨源 Cookie 凭据模式时,在相应响应上追加:
Access-Control-Allow-Credentials: true

Content-Length 本身属于 CORS 默认可读取的响应头;Accept-Ranges、Content-Range 等则应显式暴露。使用 Cookie 凭据时,Allow-Origin 不能是 *。动态选择允许的 Origin 时应正确设置 Vary。CORS 规则来自 MDN:CORS。

格式满足条件的单一区间 Range,例如 bytes=0-1023,可属于 CORS safelist,并非每次 Range 都触发预检;Authorization、多区间 Range 或其他非 safelist 请求头仍可能触发预检。参考 MDN:Range 请求头。

上述 CORS 头还应覆盖实际的 206、401、403、404、416 等响应,便于前端读取真正的错误。OPTIONS 一般不能要求像实际 GET 那样携带用户登录凭证,但实际 GET 必须继续鉴权。CDN 缓存必须正确区分 Origin、资源版本与鉴权策略,避免把一个用户的内容交给另一个用户。

fetch(url, { mode: "no-cors" }) 不能解决可读预览,它通常得到 opaque 响应,脚本无法读取正常的状态、响应头和正文。参考 MDN:Response.type。

iframe 是否允许嵌入还要分别检查:父页面的 frame-src 限制能加载哪些子页面;被嵌页面的 frame-ancestors / X-Frame-Options 限制谁能嵌它;父页面读取子页面 DOM 又由同源策略控制。这些规则不能靠给 fetch 增加 CORS 参数解决。若站点使用 COEP / CORP,还需单独核对资源的嵌入许可;允许 CORS 并不意味着所有安全策略都已放行。

嵌入许可应在服务器响应头上配置,尤其不要误以为给页面添加一个 frame-ancestors meta 标签就生效。参考 Chrome 官方:XFO 与 CSP 嵌入控制。

八、私有文件:把访问权限落实到每一次内容请求

访问方式适合的情况需要处理的问题
同源鉴权网关企业后台、需要及时撤权的文件每个区间鉴权;流式转发;避免网关带宽成为瓶颈
短时签名 URL对象存储直连、原生图片/媒体/PDF地址相当于临时凭证;过期续期、日志脱敏、CDN 缓存策略
Authorization 请求头fetch / PDF.js 自主管理请求预检与令牌续期;原生 img、video、iframe 不能随意附加自定义头
跨站 Cookie已有且经过验证的嵌入登录方案SameSite、Secure、第三方 Cookie 限制及各端实际行为

不要因为 img 或 iframe 不能直接携带 Bearer Token,就把长期登录 Token 拼进 URL。可以由已鉴权的业务 API 签发只读、限定资源和版本的短时地址,也可以走同源网关。签名地址应避免进入埋点、错误日志和分享文案;Referrer-Policy: no-referrer 有助于减少后续请求的来源泄漏,但不会把已经出现在浏览器或服务端日志里的 URL 自动抹掉。

签名 URL 会在翻到后面的某一页、视频拖动或网络恢复时过期。恢复流程应是:确认错误确因凭证失效 → 向业务 API 重新鉴权 → 获取同一版本的新地址 → 有限次数恢复,并保留页码、缩放或播放位置。PDF.js 若不支持直接替换现有加载任务的地址,可销毁并重新打开;不要通过不断修改内部私有字段续期。

同一个 403 可能表示无权限、签名过期或资源政策拒绝,不能一律自动续期。由业务 API 提供可区分的错误码;真正撤权应立即停止后续加载。自包含签名 URL 通常会在过期前保持可用,若要求立即撤权,需要网关/边缘层每次查权限或采用可撤销的访问会话。已经传到用户设备的像素和字节无法靠撤权追回。

敏感文件可按业务要求采用 Cache-Control: private, no-store,并避免 Service Worker 把内容写进离线缓存。需要 CDN 缓存时,必须在命中缓存前验证权限,并按租户、文件版本以及水印变体正确隔离;文件删除、撤权、产物清理与缓存失效需要有一致的业务约定。

九、Office、大表格与大文本各有不同瓶颈

DOCX 转 HTML 适合阅读正文;转 PDF 更适合固定分页和打印。服务器缺少原字体、版式引擎差异、图表和公式支持差异,都可能造成分页变化。对合同等高保真场景,应建立实际文件的视觉回归样本,而不是承诺一次转换百分百一致。PPT 转图片或 PDF 会丢失动画;需要编辑、协作或交互时,应另行集成文档服务,并评估授权、部署及数据流向。

Mammoth 的职责是把 DOCX 内容转换为 HTML,它明确不负责净化不可信文档。输出的链接可能包含危险目标,不能直接注入业务页面。应增加经过维护的 HTML sanitizer、URL 白名单和隔离展示。参考 Mammoth 安全说明。

Office 转换服务应运行在低权限、受资源限制的隔离环境,禁止宏执行和不必要的网络访问;限制文件大小、解压后大小、成员数量、页数、CPU、内存和超时。外部引用与嵌入对象也属于输入。加密文件进入明确的密码流程;无法处理的内容返回原因,不把转换失败吞成一个空白页面。

ZIP 类文档的上传体积并不能代表展开成本,验证应覆盖解压后的体积和恶意文件处理。参考 OWASP:文件上传安全。

百兆 XLSX 尤其不适合默认在手机端全量解析:ZIP 内的 XML、共享字符串和单元格对象可能膨胀很多倍。先把所有行解析到内存,再做分页和虚拟滚动,只减少了展示层压力。推荐服务端提取工作表信息,按 sheet、行列窗口返回数据,前端虚拟滚动;排序、过滤、搜索也应在完整数据所在的一侧完成。

SheetJS 的常规读写 API 会把文件读入内存;其大数据文档提供了相关优化方式,但不能据此推断浏览器里的任意 XLSX 都能逐行流式解析。参考 SheetJS:Large Datasets。

表格预览还需声明语义:公式显示的是保存时的缓存结果,还是重新计算后的结果?隐藏行、合并单元格、日期系统、超长数字和科学计数法怎样显示?不要把“成功解析”当成“业务值完全正确”。CSV 可用支持引号和跨行字段的增量解析器;简单 split('\n') 无法正确处理带换行的单元格。

大日志则适合先显示有限字节。以下示例只处理远程纯文本前缀,不是通用文件下载器;假设受控接口返回文本 MIME。它在 200 回退时也只保留限定字节,关闭阅读器时可通过 AbortController 取消。网络栈可能提前缓冲更多数据,因此这是应用层保留上限,不是精确的流量计费上限。

export async function readTextPrefix(
  url,
  { signal, limit = 256 * 1024, encoding = "utf-8" } = {}
) {
  if (!Number.isSafeInteger(limit) || limit < 1) {
    throw new Error("limit 必须是正整数");
  }
  const response = await fetch(url, {
    signal,
    headers: { Range: "bytes=0-" + (limit - 1) }
  });

  if (response.status !== 200 && response.status !== 206) {
    throw new Error("文本读取失败:" + response.status);
  }
  if (!response.body) throw new Error("此环境不支持流式读取");

  const reader = response.body.getReader();
  try {
    if (!response.headers.get("Content-Type")?.startsWith("text/plain")) {
      throw new Error("返回的不是纯文本");
    }

    const decoder = new TextDecoder(encoding);
    const parts = [];
    let bytes = 0;
    let ended = false;

    while (bytes < limit) {
      const chunk = await reader.read();
      if (chunk.done) {
        ended = true;
        break;
      }
      const take = chunk.value.subarray(0, limit - bytes);
      bytes += take.byteLength;
      parts.push(decoder.decode(take, { stream: true }));
    }

    // 达到上限时不 flush,丢弃末尾可能不完整的多字节字符。
    if (ended) parts.push(decoder.decode());
    return { text: parts.join(""), bytes, reachedLimit: bytes === limit };
  } finally {
    await reader.cancel().catch(() => {});
    reader.releaseLock();
  }
}

// controller.abort() 可用于关闭/切换时取消。
// container.textContent = result.text;不能用 innerHTML。
// UI 应写“前 256 KiB 预览”,reachedLimit 不等于已证明后面还有数据。

TextDecoder 的 stream 模式可以保留跨块字符的解码状态。示例在达到预览上限时不冲刷末尾残缺字节,避免人为截断产生乱码。参考 MDN:TextDecoder.decode。

真实文本查看器还要处理 UTF-8 BOM、GB18030 等编码选择、超长单行、CRLF、范围边界与错误替换字符。读取任意中间字节时,字符可能被截断;按行跳转通常需要服务端行索引或稳定游标。100 MB JSON 不宜默认 JSON.parse 再美化整个对象,可以限量文本展示,或由服务端提供路径查询。

十、图片与音视频:文件体积和解码成本分开评估

一张 20000 × 10000 的图片,按 RGBA 估算仅解码像素就约 763 MiB,哪怕压缩文件只有十几 MB。列表和首屏应使用服务端缩略图;大图查看采用多级分辨率与瓦片,只请求可见区域。CSS 把原图显示成 300px 宽,不代表浏览器只解码 300px。

图片应提供明确尺寸或比例,避免布局跳动;根据容器和 DPR 选合适的 srcset/sizes。大 GIF、HEIC、TIFF 等需按目标浏览器验证或生成替代预览。超大图片、损坏图片和解码失败要走专门的错误状态,不能只依赖扩展名判断兼容性。

<video
  controls
  playsinline
  preload="metadata"
  poster="/preview/video-poster.jpg"
  src="/api/files/video-123/content"
></video>

playsinline 用于请求行内播放,preload 是加载提示,不能视为严格网络配额。播放通常需要用户操作,并应处理 play() 返回的 Promise 被拒绝。参考 MDN:video 元素。

MP4 是容器,内部编码是否可播还要看浏览器和设备;canPlayType 也只是能力提示,最终应处理实际播放错误。长视频可转成适合目标终端的编码,采用 HLS/DASH 等自适应方案;原生支持和 MSE 播放器支持分别验证。普通 MP4 可优化元数据布局,并通过 Range 支持拖动,但不要把它等同于自适应码率。

关闭预览时先 pause,移除媒体 src,再 load 以重置加载状态;销毁播放器及其 MSE 缓冲,随后撤销自己创建的 Blob URL。视频海报、字幕、播放列表和分片也需要鉴权;只保护入口清单却公开全部分片并不完整。

十一、安全隔离:用户文件不能继承业务页面的信任

不要对用户 HTML、Markdown 转换结果、Office 转换结果直接使用 innerHTML 或 dangerouslySetInnerHTML。纯文本通过 textContent 展示;必须展示富文本时,使用持续维护的净化库,限制标签、属性、URL 协议和外部资源。Markdown 渲染器默认是否允许原始 HTML,需要单独确认。

不可信 SVG 应按使用场景限制或栅格化,不要随意作为 object/iframe 文档加载进业务域。HTML、SVG、PDF 和 Office 的解析引擎都应持续更新;CSP、sandbox 和文件扫描属于多层防护,不能代替修复解析器漏洞。正确的 MIME、X-Content-Type-Options: nosniff 与合理的 Content-Disposition 是基础,但不能把任意恶意内容变安全。

<!-- 仅用于已经净化的静态 HTML 预览,不适用于完整 PDF/Office 查看器。
     preview-files.example.net 应与业务站点隔离,不持有业务 Cookie。 -->
<iframe
  title="文档内容预览"
  src="https://preview-files.example.net/rendered/document-123"
  sandbox=""
  referrerpolicy="no-referrer"
></iframe>

<!-- 以下应由静态预览响应作为 HTTP 头返回,不是写进此 HTML: -->
Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors https://app.example.com
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer

空 sandbox 采用严格限制。对与父页面同源的内容,同时允许 allow-scripts 与 allow-same-origin 可能使隔离失效。静态不可信内容和需要运行脚本的可信查看器应采用不同策略。参考 MDN:iframe sandbox。

需要运行 PDF.js 等脚本的查看器,应部署受控代码,按最小需要开放 script-src、worker-src、connect-src、font-src 等资源策略;这与允许用户上传的脚本执行是两回事。隔离域不共享业务 Cookie、localStorage 或宽泛 Domain Cookie;HTML 中的外部图片和字体也可能造成追踪或数据外传,应重写为受控资源或禁止加载。

跨窗口通讯使用明确的目标 origin,接收时检查 event.origin、event.source、消息结构、会话 ID 和允许的操作;不要把任意 message 当成下载或跳转指令。没有 allow-same-origin 的 sandbox 页面可能具有 opaque origin,不能只凭字符串 "null" 识别可信来源。参考 MDN:postMessage。

后端代理最好接受 fileId,再从可信存储映射对象地址;不要公开一个能抓取任意 URL 的预览代理。若业务必须接受外部 URL,需要限制协议和目标,校验 DNS 解析结果、重定向每一跳,阻止回环、内网和云元数据地址,并设置连接、响应大小及时间限制。

仅用字符串检查域名并不足以覆盖重定向、不同地址表示和 DNS 变化等情况。参考 OWASP:SSRF 防护。

隐藏下载按钮、禁用右键和前端水印无法保证内容不可复制。浏览器能展示的字节或像素,用户通常就能保存或截图。若只允许查看低清版本,后端应只发低清产物;若需要水印,应考虑服务端生成并将用户标识与产物缓存隔离。预览权限和原文件下载权限可以不同,但必须分别在服务端执行。

十二、移动端与生命周期:让预览可以中断、恢复和回收

手机适配首先是资源预算,其次才是布局。默认适宽、单页阅读、低分辨率首屏、延迟加载相邻页;弱设备关闭全量缩略图和主动全文索引。不要只按 User-Agent 判断,更不要依赖某个所有浏览器都不保证提供的内存 API;结合运行时能力检测、失败率和实际渲染耗时逐级降级。

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

<style>
  .preview {
    height: 100vh;
    height: 100dvh;
    display: flex;
    flex-direction: column;
    min-width: 0;
  }
  .preview__toolbar {
    flex: none;
    padding: 8px 12px;
    padding-top: max(8px, env(safe-area-inset-top));
  }
  .preview__body {
    flex: 1;
    min-height: 0;
    overflow: auto;
    overscroll-behavior: contain;
    padding-bottom: max(12px, env(safe-area-inset-bottom));
  }
  .preview__toolbar button {
    min-width: 44px;
    min-height: 44px;
  }
</style>

动态视口单位 dvh 可跟随浏览器界面变化;保留 100vh 作为旧环境回退。安全区域可通过 env(safe-area-inset-*) 留白,横屏时也应处理左右区域。参考 MDN:视口长度单位 与 MDN:env()。

不要禁止用户缩放。自定义双指缩放需协调浏览器缩放、页面纵向滚动和文件拖动,避免在整个页面设置 touch-action: none。横竖屏切换与容器尺寸变化后,用 ResizeObserver 触发去抖重绘,保存当前页面的阅读锚点;不必每个缩放事件都重新解码和生成高清 canvas。

iOS Safari、Android Chrome、系统 WebView 和应用内浏览器应分别验收:内嵌 PDF、Blob URL 新窗口、下载、媒体播放、文件选择和返回键行为不一定一致。新窗口功能应在用户点击时触发;异步获取链接后再 window.open 可能被拦截。无法稳定嵌入时提供受控的新页面、轻量预览或授权下载入口,且不要意外丢失登录态。

后台恢复时先确认文件版本、地址有效期和会话是否仍有效,再继续请求;visibilitychange 后可暂停非必要预取。系统可能直接回收页面,应用应能凭 fileId、version 和页码重建状态,而不是假定所有 Buffer 和 Worker 永远存活。

idle → authorizing → preparing → loading → rendering → ready
                    ↘ processing(转换中,按退避策略查状态)
各阶段可能进入:
  password-required / unsupported / expired / denied / error / cancelled

每次打开:
  sessionId += 1
  取消上一会话网络请求
  停止渲染并释放上一会话资源

每个 await 后:
  如果当前 sessionId 已变化,销毁本次结果,不写回 UI

关闭时:
  AbortController.abort()
  renderTask.cancel(),等待旧渲染结束
  loadingTask.destroy() / Worker.terminate()
  销毁媒体播放器、观察器、事件监听、定时器
  清空 canvas 和大对象引用
  URL.revokeObjectURL()

实际清理顺序按渲染器依赖安排,尤其不要在旧 renderTask 仍操作画布时重设它。AbortController 只能取消接入其 signal 的任务,不会自动终止任意第三方解析器或 CPU 循环。无法协作取消的独立 Worker 可 terminate;共享 Worker 则需设计任务级协议,不能因关闭一个文件杀掉其他文件的任务。

进度也要分层显示:“转换中”“正在传输”“正在绘制”。拿到了 100% 字节,不代表页面已经可读。流式和多 Range 请求下,准确百分比可能不可得,应使用阶段提示或不确定进度,避免伪造精确数字。错误 UI 应解释下一步,并支持键盘操作、焦点恢复、读屏提示;只有 canvas 的页面还需要文本或可访问阅读路径。

十三、失败降级与验收:不要用“打不开”概括所有问题

场景预期行为
格式不支持或编码不兼容展示原因,提供已授权的转换产物或下载
100 MiB PDF,Range 被中间层忽略识别 200 回退、限制等待,转轻量预览;记录链路问题
403 / 401区分登录过期、签名失效和权限拒绝;有限恢复,禁止循环刷新
416 / 文件版本变化重新读取描述与版本,避免拼接不同版本的字节
加密、损坏、零字节文件密码流程或明确错误;不静默白屏
Office 转换超时显示可查询的任务状态,退避重试并限制总时长
低内存、大图或复杂 PDF 页降低清晰度、减少预读,必要时服务端按页栅格化
离线、超时、429、5xx保留阅读位置,有限次数退避重试;尊重 Retry-After
切换 A → B 时 A 请求后返回A 的结果被丢弃与回收,不覆盖 B
页面关闭或路由跳转请求、Worker、渲染、监听与对象 URL 能结束生命周期

验收样本不能只有一份正常 PDF。至少准备:文本型与扫描型 PDF、首页依赖尾部索引的 PDF、超大单页、特殊字体、密码与损坏文件;包含大量共享字符串、公式和合并单元格的 XLSX;带外部引用和危险链接的文档;超高分辨率图片;不同编码与超长行的日志;不可播放编码的视频。安全样本应在隔离测试环境运行。

测试网络覆盖低带宽、高延迟、丢包、断网恢复与分段请求中途过期;权限覆盖跨租户 fileId、撤权、过期签名、CDN 缓存命中和未授权预览产物。设备至少覆盖目标范围内的桌面浏览器、iPhone、低端 Android 和实际使用的 WebView。连续打开关闭文件、快速翻页和连续缩放,比只测试首次加载更容易发现资源泄漏。

记录首屏可读时间、首屏前下载字节数、页面渲染耗时、主线程长任务、同时保留的页面/像素数、失败与降级原因、签名续期成功率。内存指标依环境能力获取,不能只看 JS heap:图片、canvas、GPU、Worker 的成本可能不完整体现在其中。检查多次关闭后的资源是否趋于稳定,而不是要求垃圾回收立刻回到同一个数值。日志不要记录原文、密码或完整签名 URL。

可以把“指定测试机与网络下,P95 首屏可读时间”“首屏前流量预算”“连续打开关闭后的内存趋势”设为验收目标。具体数字应来自业务要求与样本测试,不能用某台开发机的成绩代表所有 100 MB 文件。curl 可验证 HTTP 协议,单元测试可验证状态和数据处理,CORS、iframe、手势和真实内存表现仍需浏览器与真机验收。

十四、把方案落到三个典型场景

场景可执行的方案
100 MiB 私有 PDF,手机阅读先鉴权获取不可变版本;短时签名或同源网关;验证 Range;PDF.js 只绘制当前页并限制像素;超预算转低清分页产物;过期后重鉴权并恢复页码
100 MiB XLSX,查看明细服务端受控解压解析;生成工作表元信息与分页查询;前端仅持有窗口数据;提供服务端搜索/排序;注明公式与格式还原边界
用户选择本地照片或视频File 建立 Blob URL;原生元素预览;限制异常尺寸与解码成本;替换时停止旧资源并回收 URL;失败时显示格式/设备限制

落地顺序可以从图片、媒体和普通 PDF 开始,先建立统一的预览描述、状态机、权限和清理机制;随后为 Office 引入异步转换,为大表格和日志引入分页数据服务;最后根据真实流量与设备指标调整预读、清晰度、缓存和降级阈值。文件预览的质量取决于这些环节能否共同遵守资源与权限边界。

示例说明:本文代码用于说明关键实现方式,省略了完整 UI、业务 API 与部分构建配置;参数以锁定的依赖版本为准。已核对文中引用的官方资料,不能据此视为已完成所有浏览器与真机兼容性测试。