前端文件预览完整指南:百兆大文件、跨域安全与移动端适配
从图片、PDF、Office、表格、文本和音视频的方案选择,深入讲解百兆文件的 Range 传输、内存预算、CORS 与鉴权、安全隔离、移动端适配、生命周期回收和异常降级,附实现代码与上线验收矩阵。
文件预览的核心,是根据文件格式、数据规模、权限和设备能力,选择合适的传输、解析和渲染方式。常见图片与音视频优先使用浏览器原生能力;需要统一交互的 PDF 使用 PDF.js;复杂 Office 和超大文件由服务端生成预览产物,前端按需展示。
本文面向需要上线文件预览功能的前端工程师。覆盖本地文件与远程私有文件、100 MB 级文件、Range、CORS、鉴权与隔离、移动端适配、取消与回收、异常降级及验收方法。文中的容量、并发和像素限制是工程起始配置,不是浏览器硬性上限;应通过目标设备实测调整。技术资料核对日期:2026-10-29。
一、先确定预览目标,再选择实现方式
先回答四个问题:文件来自用户本地还是服务端?预览要求是阅读内容还是严格保留排版?是否需要搜索、复制、打印、批注?文件是否包含敏感内容?同一个 DOCX,如果只需要阅读正文,可以转语义化 HTML;如果要求合同分页、字体和签章位置一致,就应采用经过验证的转换服务或专用查看器。
| 文件类型 | 通常采用的方案 | 规模与功能边界 |
|---|---|---|
| JPEG / PNG / WebP 等图片 | img + 缩略图 + 按需原图 | 大尺寸图片优先服务端缩放;超高清使用分块图 |
| 原生查看器,或 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: trueContent-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 与部分构建配置;参数以锁定的依赖版本为准。已核对文中引用的官方资料,不能据此视为已完成所有浏览器与真机兼容性测试。
