文件在线预览这四个字,落到需求文档里往往只有一行:点击可查看附件。真动起手来,尤其在 uni-app 这种一套代码跑多端的框架里,你会发现这一行字的背后压着至少五种端侧能力差异、三个文件格式家族,还有一堆只在特定机型上才复现的古怪毛病。同一个 PDF,在 App 里调个原生接口两行代码就能弹出来,在小程序里要先下载到临时目录再判断大小限制,到了 H5 又得自己拉一个渲染引擎把每一页画到 canvas 上。更别提 docx、xlsx、pptx 这一票 Office 文档,端上根本没有原生解析能力,要么扔给服务端转,要么在前端硬啃。
我这篇东西想聊的就是这套跨平台文件在线预览解决方案到底该怎么搭。它解决的核心问题是:让业务层只调用一个方法,传进去一个文件地址和一个文件名,剩下的类型判断、平台分流、降级策略、缓存复用、异常兜底全部由底层封装吃掉。适合正在做 uni-app 项目、被附件预览需求反复折磨的前端同学,也适合想提前把技术方案定下来、避免后期推倒重来的技术负责人。哪怕你只是刚接触 uni-app,还没写过一行条件编译,读完也应该能照着搭出一个能跑起来的版本。
1. 先把各端的能力底牌摊开看
动手写代码之前,我最建议做的一件事是把每个端到底"天生会什么"列清楚。这件事看起来像废话,但它直接决定了你的架构是三层降级还是五层降级,也决定了你要不要引入服务端。很多人一上来就想着"统一转成 PDF 最省事",结果发现转换服务、存储、带宽三笔成本压下来,比多写几百行前端代码贵得多。
1.1 三类文件,三种完全不同的处理命运
我习惯把要预览的文件分成三类,这个分类方式比按后缀分要实用得多,因为它直接对应技术路线。
第一类是端上原生就能打开的格式,主要是 PDF 和 Office 三件套。App 端有uni.openDocument,小程序端也有同名能力,它们本质上都是把文件交给系统的文档查看器去处理。这类格式的特点是"不用你管渲染",你只要把文件送到一个本地可访问的路径上就行,代价是样式完全不可控,工具栏、页码、缩放都是系统说了算。
第二类是端上能原生渲染但需要你自己搭壳的格式,典型是图片、音频、视频。图片用uni.previewImage就能拿到手势缩放、左右滑动、长按保存这一整套交互,视频音频用原生video、audio组件即可。这类几乎零成本,但很多人反而在这里做了过度设计,非要自己写一个图片查看器,纯属浪费。
第三类是端上完全无能为力的格式,比如 markdown、csv、各种代码文件、cad 图纸、压缩包内的清单。这些必须靠前端自己解析成 DOM 或者靠服务端转成通用格式。我见过有项目为了预览一个 csv 引入了整套表格组件,其实用split切一下渲染成 HTML table 就够了。
注意:分类的标准不是"文件后缀是什么",而是"这个端有没有能力把它变成人能看的像素"。同一个后缀在不同端可能落在不同类别里,比如 svg 在 H5 是原生渲染,在小程序的部分版本里就得转成 base64 图片。
1.2 一张表看清各端真实底牌
下面这张表是我按实际项目经验整理的,不同框架版本、不同系统版本会有细微差异,但大方向不会变。
| 能力项 | App 端 | 小程序端 | H5 端 |
|---|---|---|---|
| 原生打开 PDF | 支持,openDocument | 支持,有大小与域名限制 | 依赖浏览器,移动端体验割裂 |
| 原生打开 doc/docx | 支持 | 部分支持 | 不支持,浏览器无内置解析 |
| 原生打开 xls/xlsx | 支持 | 部分支持 | 不支持 |
| 本地文件读取 | 完整文件系统 API | 仅临时文件与用户目录 | 仅能通过 fetch 拿网络文件 |
| 自定义渲染能力 | 可用 webview 承载 | 受限,分包与体积敏感 | 完全自由 |
| 跨域限制 | 无 | 需配置下载域名白名单 | 受同源策略严格约束 |
| 大文件承受力 | 强 | 弱,超过阈值直接失败 | 中等,受内存限制 |
这张表里最值得盯的是"自定义渲染能力"和"跨域限制"两行。App 端能塞 webview,意味着你可以把整个 H5 渲染方案原封不动搬进去;小程序端体积敏感,引一个几百 KB 的 PDF 渲染库进去,主包立刻告急;H5 端跨域是绕不过去的坎,任何需要fetch二进制流再解析的方案,都必须先解决 CORS。
1.3 我为什么放弃了"一律转 PDF"的偷懒方案
最开始我也是"一律转 PDF"派。逻辑很顺:所有文件服务端用开源办公套件转成 PDF,前端只处理 PDF 一种格式,代码量直接砍掉一大半。真跑起来三个月后我发现三个问题。
一是转换保真度。复杂排版的 docx 转出来经常表格错位、公式变图片、字体回退成系统默认,用户第一反应是"你们系统坏了",而不是"转换有损"。二是冷启动延迟。一个 30 页的文档首次转换要等到 5 秒以上,用户早就点了返回。三是成本结构。转换是 CPU 密集型任务,并发一上来机器就顶不住,而实际上 80% 的预览请求都集中在少数几个热门文件上,完全可以通过缓存摊平,但缓存又带来了文件版本更新的失效问题。
最后我选的是分层降级:端上有能力的优先用原生能力,端上没能力的走前端轻量解析,前端也搞不定的才落到服务端转换,并且服务端转换结果做长缓存。这个顺序的核心逻辑是"把成本推到最靠近用户的地方",能本地解决的绝不走网络,能走网络的绝不动 CPU。
2. 架构设计:一个入口,三层降级
架构这块我踩过最大的坑是"每个页面各写各的"。A 页面写了 App 端逻辑,B 页面复制一份改了两行,C 页面干脆只做了 H5。等到要统一加一个"下载失败重试"的功能,得改十几个文件。所以第二版我做的第一件事就是把预览能力收敛成一个单一入口。
2.1 统一调用契约怎么设计
入口函数的入参设计直接决定了后续扩展的难易。我的做法是把参数分成"必须要有的"和"可选但强烈建议传的"两组。
必须的只有两个:文件地址和文件名。文件名不能省,因为它承担了三个职责——提取后缀做类型判断、作为下载后本地文件的命名依据、作为页面标题展示。我见过只传 URL 的实现,结果 URL 上带了签名参数,后缀被?token=xxx截断,类型判断直接失效。
可选的包括:文件大小(用于提前拦截超大文件)、是否允许下载(有些预览场景不希望用户保存原件)、业务标识(用于埋点和缓存键)、鉴权头(H5 端跨域下载时需要)。函数返回一个 Promise,统一 resolve 一个状态枚举,而不是抛出各种五花八门的错误,这样调用方只需要处理一种异常分支。
// utils/preview.js export const PreviewResult = { SUCCESS: 'success', UNSUPPORTED: 'unsupported', DOWNLOAD_FAIL: 'download_fail', FORBIDDEN: 'forbidden' } export function previewFile(options) { const { url, name, size = 0, allowDownload = true, bizId = '' } = options // ... 后续分流 }这里有个设计取舍值得说:不要在这个入口里做任何 UI 层面的操作。加载弹窗、错误提示、跳转页面全部交给调用方。原因是不同业务对交互的要求差别很大,有的页面希望全屏 loading,有的只希望按钮转圈,入口函数一旦绑死了 UI 就失去了复用性。
2.2 类型识别:后缀、MIME、文件头三重校验
只靠后缀判断类型是不够的,这一点在实际项目里被反复验证。用户上传时把.xlsx改成.pdf的情况真实存在,服务端返回的 Content-Type 又经常被统一设成application/octet-stream。我的做法是三级判定,优先级从高到低。
第一级是文件头魔数,最可靠。PDF 开头是25 50 44 46(即%PDF),docx/xlsx/pptx 这类新版 Office 格式本质是 zip 包,开头是50 4B 03 04,老版 doc/xls/ppt 是 OLE 复合文档,开头是D0 CF 11 E0。读前 8 个字节基本就能定性。第二级是响应头里的 Content-Type,第三级才是文件后缀。三者冲突时以文件头为准。
这种校验在 H5 端很好做,因为文件本来就要 fetch 成 ArrayBuffer 交给渲染引擎,顺手读前几个字节零成本。App 和小程序端就麻烦一些,得先下载再读。所以我做了一个折中:端上先用后缀快速分流,真正进入渲染阶段时再用文件头做二次确认,如果确认失败就降级到服务端转换。这样既保证了绝大多数请求的响应速度,又兜住了脏数据。
function sniffType(buffer) { const b = new Uint8Array(buffer.slice(0, 8)) const hex = Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('') if (hex.startsWith('25504446')) return 'pdf' if (hex.startsWith('504b0304')) return 'ooxml' // docx/xlsx/pptx if (hex.startsWith('d0cf11e0')) return 'ole' // doc/xls/ppt if (hex.startsWith('89504e47')) return 'image' if (hex.startsWith('ffd8ff')) return 'image' return 'unknown' }提示:
504b0304只能说明这是个 zip 容器,docx、xlsx、pptx、epub、jar 甚至普通压缩包都符合。要进一步区分,得解析 zip 的中央目录,看里面有没有word/、xl/、ppt/前缀的条目。这一步在小程序端做成本偏高,我一般直接跳过,靠后缀兜底。
2.3 分层降级链路与决策顺序
完整的决策链路我是这么排的,从上到下依次尝试,前面的失败才走后面:
- 图片、音视频、纯文本 → 直接端内渲染,链路最短。
- PDF → 端上有原生能力就原生打开,没有就走前端渲染引擎。
- Office 文档 → 端上有原生能力就原生打开,没有就走服务端转换或前端直渲。
- 其他格式 → 服务端转换,转不了就提示下载。
这个顺序里有个反直觉的点:Office 文档我优先走原生而不是前端直渲。原因很实际,前端的 docx 渲染库对复杂样式(分栏、页眉页脚、文本框、图表)的支持度有限,渲染出来经常缺胳膊少腿,而系统文档查看器虽然样式不可控,但至少内容完整。只有在网页端这种完全没有原生能力的地方,才值得上前端渲染,而且要做好"样式会有损失"的心理准备。
降级链路的实现我用条件编译来做,而不是运行时判断,这样各端的代码在打包时就被裁剪掉了,不会把 H5 的渲染库打进小程序的包里。
export async function previewFile(options) { const { url, name } = options const ext = (name.split('.').pop() || '').toLowerCase() // #ifdef APP-PLUS return previewInApp(url, name, ext) // #endif // #ifdef MP return previewInMiniProgram(url, name, ext) // #endif // #ifdef H5 return previewInWeb(url, name, ext) // #endif }条件编译的写法有个细节:// #ifdef必须是行注释形式,写在代码块里没问题,写进模板里要用 HTML 注释。而且三个分支如果都写成return,中间的代码在打包后会出现"函数体里没返回值"的告警,因为被裁剪掉的分支留下了空壳。我一般会在函数末尾补一个兜底 return,把这类告警摁掉。
3. 落地实操:从下载到渲染的每一段
前面聊的是"应该怎么想",这一节聊"具体怎么写"。我会按端来拆,每段都给关键代码和我实际踩过的坑。这些代码不是完整可直接运行的成品,但核心逻辑和参数是验证过的,你可以照着改。
3.1 App 端:下载、缓存与原生打开的正确姿势
App 端看起来最简单,uni.downloadFile拿到临时路径,uni.openDocument打开,两行搞定。但真实项目里有两个细节必须处理,否则一定会在某些安卓机型上翻车。
第一个是文件后缀。downloadFile返回的tempFilePath是一个随机命名的临时文件,往往没有后缀。openDocument依赖fileType参数来选定打开方式,如果你不传fileType,系统可能拿不到正确的文档处理器,表现就是"点了没反应"或者"提示文件损坏"。所以fileType必须显式传,值就是你从文件名解析出来的后缀,docx、xlsx、pdf这些。
第二个是临时文件的生命周期。临时目录会被系统定期清理,一个会话里反复预览同一个文件每次都重新下载,既费流量又慢。我的做法是把下载后的文件另存到应用的用户目录下,按 URL 的哈希值命名,下次进来先查本地有没有,有就直接用。
// App 端核心流程 async function previewInApp(url, name, ext) { const localPath = await ensureLocalFile(url, name) // 命中缓存直接返回 return new Promise((resolve, reject) => { uni.openDocument({ filePath: localPath, fileType: ext === 'doc' || ext === 'xls' || ext === 'ppt' ? ext : ext, showMenu: true, success: () => resolve(PreviewResult.SUCCESS), fail: () => resolve(PreviewResult.UNSUPPORTED) }) }) }showMenu: true这个参数我建议开着。它会在文档查看器右上角提供一个可以另存的菜单,用户能借此把文件存到系统下载目录,省了你做"下载"按钮的一大圈事。当然如果你的业务明确不允许保存原件,就关掉。
缓存目录的选择也要注意。存在_doc目录下会被系统备份机制扫到,文件一多就拖慢启动;存在_downloads目录下是官方推荐的放下载文件的位置。清理策略我用的是"LRU + 总量上限":记录每个文件的最后访问时间,总量超过 200MB 时按时间从旧到新删,直到低于阈值。这个 200MB 不是拍脑袋,实测下来大部分项目的附件平均在 2-5MB,40 到 100 个文件的缓存量能覆盖一周内的重复访问。
3.2 小程序端:域名白名单和临时文件的坑
小程序端的预览能力其实和 App 端很像,一样是下载加打开,但多了三道枷锁,每一道都能让你卡半天。
第一道是下载域名白名单。你要下载的文件所在域名必须提前配置进去,而且不支持动态配置。这就带来一个很现实的问题:如果你的文件存在对象存储上,用了多个不同的桶或者多个 CDN 域名,每一个都得配。我踩过一次坑,测试环境用的是临时桶域名,上线时忘了同步配置,结果线上所有附件预览全部失败。
第二道是临时文件的大小与数量限制。不同平台版本的限制不一样,但普遍在几十兆到一百多兆的量级,而且同一时间占用的临时文件总量也有上限。一个用户连续预览十几个大文件,后面的就可能下载失败。我的处理是下载前先判断大小,超过阈值直接提示"文件过大,请在电脑端查看",而不是让它下到一半失败;下载完成后如果已经没有别的用途,主动调文件系统管理器删掉旧文件。
第三道是打开后的返回状态。openDocument的成功回调只代表"成功唤起了文档查看器",不代表用户真的看到了内容。有些平台上用户可能在实际加载时就退出了。这块没法精确感知,所以埋点上我只记录"唤起成功",不统计"阅读时长"这类指标。
// 小程序端:先查缓存,再下载,最后打开 async function previewInMiniProgram(url, name, ext) { const fs = uni.getFileSystemManager() const cachePath = `${uni.env.USER_DATA_PATH}/preview_${hash(url)}.${ext}` try { fs.accessSync(cachePath) return openDoc(cachePath, ext) } catch (e) { // 不存在,继续走下载 } const res = await uni.downloadFile({ url }) if (res.statusCode !== 200) return PreviewResult.DOWNLOAD_FAIL fs.saveFileSync(res.tempFilePath, cachePath) return openDoc(cachePath, ext) }注意:
USER_DATA_PATH下的文件不会随小程序会话结束自动清理,长期不清理会占用用户存储空间。我一般会在应用启动时做一次目录扫描,把超过 7 天没访问的预览缓存删掉,这个清理动作放在启动后的空闲时机执行,不要放在首屏。
3.3 H5 端:PDF 渲染引擎的落地细节
H5 端是工作量最大的一块,因为完全没有原生能力可用。PDF 我选的是pdfjs-dist,这个引擎成熟、社区活跃、支持分页懒加载,坑主要是三个。
第一个坑是 worker 路径。这个库默认会起一个 Web Worker 去解析 PDF,避免阻塞主线程。但如果 worker 文件没被打包工具正确处理,控制台就会报一个"缺少 worker"的警告然后退回主线程解析,大文件直接卡死页面。解决办法有两个:一是把 worker 的地址显式指向一个可访问的 CDN 路径;二是干脆关掉 worker,用disableWorker: true,代价是解析时会短暂卡顿。我建议在生产环境用第一种,在开发环境用第二种避免路径问题。
第二个坑是清晰度。canvas 默认按 CSS 像素渲染,在 2 倍屏 3 倍屏上文字会糊成一片。标准做法是读取window.devicePixelRatio,把 canvas 的实际宽高乘以这个倍数,再用 CSS 把显示尺寸缩回去。
const dpr = window.devicePixelRatio || 1 const viewport = page.getViewport({ scale: 1.5 }) canvas.width = viewport.width * dpr canvas.height = viewport.height * dpr canvas.style.width = `${viewport.width}px` canvas.style.height = `${viewport.height}px` const ctx = canvas.getContext('2d') ctx.scale(dpr, dpr) await page.render({ canvasContext: ctx, viewport }).promise这里的scale: 1.5是渲染倍率,不是显示倍率。它决定了 PDF 内容被解析成多大尺寸的位图。设太小两倍屏还是糊,设太大内存暴涨。我实测下来,A4 页面的 PDF 在scale取 1.5 到 2 之间视觉效果和性能比较平衡。
第三个坑是内存。每一页渲染出来都是一块位图,A4 尺寸在 2 倍屏下大约是 1240×1754 像素,按 RGBA 每像素 4 字节算,单页就是 8.7MB 左右。如果你一次性把 100 页全渲染出来,光位图就吃掉 870MB,移动端浏览器必然崩。所以必须做分页懒加载 + 及时释放:只渲染视口附近的几页,滚出可视区的页面调用page.cleanup()释放资源,并且把 canvas 的宽高设为 0 来触发内存回收。
3.4 Office 文档:前端直渲的边界在哪里
H5 端遇到 docx,有两条路:前端库直渲,或者服务端转 PDF。我的原则是看文档复杂度,但这在运行时判断不了,所以退而求其次,按业务场景选:如果是内部系统、用户上传的文档以简单文本为主,前端直渲够用且响应快;如果是对外产品、文档格式五花八门,老老实实走服务端。
前端直渲 docx 我用docx-preview这个库,它的思路是把 docx 的 XML 结构翻译成 DOM,保留段落、表格、图片、基础样式的还原度还不错。用法不复杂,拿到 ArrayBuffer 扔进去就行。
import { renderAsync } from 'docx-preview' const res = await fetch(fileUrl) const buffer = await res.arrayBuffer() await renderAsync(buffer, document.getElementById('doc-container'), null, { className: 'docx', inWrapper: true, ignoreWidth: false, ignoreHeight: true, breakPages: true, renderHeaders: true })这里面有三个参数值得解释。ignoreWidth: false表示按 A4 宽度渲染,设为 true 会按内容自适应宽度,长表格不会横向溢出,但排版和原件差异会变大。breakPages: true会插入分页符视觉分隔,观感更接近原件。renderHeaders: true渲染页眉页脚,但这块在复杂文档里容易错位,如果对还原度要求不高可以关掉。
至于 xlsx,docx-preview不管这个格式,得换用表格解析库把每个 sheet 转成 table。这里的取舍是:不要试图还原样式。Excel 的合并单元格、条件格式、图表、数据透视表,前端还原的成本极高收益极低。我的做法是只渲染单元格的值和基础的合并信息,样式统一用干净的表格样式覆盖,然后提供"下载原件"入口。用户看 Excel 的核心诉求是看数据,不是看配色。
注意:前端渲染 Office 文档存在 XSS 风险。docx 里可以嵌入超链接、外链图片甚至脚本片段,渲染到 DOM 时必须做一层净化,把
on*事件属性和javascript:协议的链接过滤掉。这一层很多示例代码里都没有,但线上产品必须加。
3.5 服务端兜底:转换服务的参数怎么定
服务端转换我用的是一台独立的转换节点,跑开源办公套件的无头模式,接收文件地址、输出 PDF,结果落对象存储并记录缓存键。
单次转换的耗时和页数强相关,我实测的参考值是:10 页以内的简单文档 1 到 2 秒,50 页以内 3 到 5 秒,100 页以上可能到 10 秒开外,遇到复杂表格和大量图片会再翻一倍。所以超时阈值我设的是 60 秒,超过直接返回失败提示用户下载原件,而不是让它挂着耗资源。
队列和并发也要算一下。假设转换节点的 CPU 是 8 核,办公套件的无头进程单实例大约占 1 到 1.5 核,考虑内存和 IO 抖动,我一般把并发控制在核数 / 2,也就是 4 个并发。再多的话单次转换耗时反而会因为资源竞争变长,总吞吐不升反降。
缓存键的设计有个容易忽略的点:不能用文件 URL 直接做键。对象存储的签名 URL 每次请求都带不同的过期参数,同一个文件每次生成的 URL 都不一样,缓存永远命中不了。正确的做法是用文件在业务库里的唯一 ID 加上版本号做键,文件一旦被替换版本号递增,旧缓存自然失效。这个方案还顺带解决了"文件更新了但用户看到旧内容"的经典问题。
4. 预览网关:被低估的一层
很多人把预览当成纯前端问题,等到用户量上来才发现真正的瓶颈在服务端。我后来单独抽了一层预览网关出来,专门处理鉴权、转换调度和缓存,前后端的分工一下就清晰了。
4.1 临时授权链接怎么设计
直接把文件的永久地址吐给前端是很危险的,任何人拿到链接就能无限次下载。我的做法是前端拿着业务 token 请求网关,网关校验通过后返回一个带签名和过期时间的临时链接,有效期设 10 分钟。
链接里带的签名通常是对"文件 ID + 过期时间戳 + 一个服务端密钥"做哈希。校验时服务端重新算一遍,对得上且没过期才放行。这样即使用户把链接分享出去,10 分钟后也就失效了。过期时间的取值是个权衡:太短,用户打开页面磨蹭一会儿链接就废了,尤其是 H5 端网络慢的时候;太长,泄露风险变大。10 分钟是我在几个项目里验证过的比较舒服的值,配合"预览失败自动刷新链接重试一次"的逻辑,基本不会让用户感知到过期。
另外还有一个细节:网关返回的链接要不要带文件名。带上的好处是下载时用户能看到有意义的文件名,坏处是中文文件名需要 URL 编码,处理不当会乱码。我的做法是在 URL 路径里带一个经过编码的文件名,同时在响应头里设置Content-Disposition,值是filename*=UTF-8''加上编码后的文件名,这样各端拿到的都是正确的中文名。
4.2 转换任务的排队与降级
转换请求不是每次都同步等待,我的策略是分级处理。
小于 20 页的文档,同步转换,接口直接返回 PDF 地址,前端等到结果再渲染,用户感知是"点了之后转圈两三秒"。大于 20 页的,走异步:接口立刻返回一个任务 ID,前端拿着 ID 轮询状态,轮询间隔 1 秒,最多 40 次(也就是 40 秒),超时就提示"文档较大,正在处理中,请稍后重试"。这个设计的好处是不会因为几个超大文档把连接池占满,影响其他用户的即时请求。
还有一种降级是我后来才加的:转换失败的文档,直接返回原文件地址让用户下载。之前的设计是转换失败就报错,用户什么都拿不到。改了之后虽然体验还是不好,但至少用户能把文件拿到手,自己想办法打开。这个改动上线后,相关客诉下降了一大截。
4.3 存储与带宽的成本控制
转换出来的 PDF 存在哪,这个问题看起来小,实际影响不小。我的做法是存对象存储,和原文件放在同一个区域,避免跨区传输。文件名用原文件的哈希值加版本号,天然去重。
带宽这块,如果转换后的 PDF 直接走公网访问,热门文件的流量费会很可观。所以我在网关前面加了一层缓存,把最近访问过的 PDF 缓存在离用户近的节点上,命中率通常能到七成以上。这个改动不复杂,但对成本的影响很直接。
5. 踩坑实录与问题速查表
这一节列的每一条都是我真金白银踩出来的,有些问题排查花了大半天,最后发现原因就一行代码。
5.1 高频问题对照表
| 现象 | 最可能的根因 | 处理方向 |
|---|---|---|
| App 端点开没反应 | 没传fileType或临时文件丢了后缀 | 显式传 fileType,下载后重命名带后缀 |
| 小程序下载报域名不合法 | 下载域名未加入白名单 | 补齐所有可能用到的域名 |
| H5 端 PDF 白屏 | worker 路径错误或文件跨域失败 | 检查控制台报错,改用绝对路径 worker |
| PDF 文字模糊 | canvas 未按设备像素比放大 | 按 dpr 放大画布并缩回显示尺寸 |
| 大 PDF 页面崩溃 | 一次性渲染全部页面导致内存溢出 | 分页懒加载加主动释放 |
| 中文文件名变乱码 | URL 编码缺失或重复编码 | 统一用 encodeURIComponent,响应头用 UTF-8 编码声明 |
| 预览的是旧版文件 | 缓存键用了签名 URL | 改用业务 ID 加版本号做键 |
| 安卓首次能开后续打不开 | 临时目录被清理或缓存目录容量超限 | 加 LRU 清理与重新下载逻辑 |
5.2 三个最磨人的顽疾
第一个是中文文件名乱码。这个问题看起来简单,实际上有三个环节都能出问题:前端拼 URL 时编码了一次,网关解析时又解码了一次,存对象存储时按原始字节存了一遍,最后取出来编码就对不上了。我的排查方法是逐环节打印原始字节,确认在哪一步发生了偏移。最终的统一方案是:URL 路径里的文件名一律encodeURIComponent一次,服务端只解码一次,响应头里单独用filename*声明,这三处对齐之后再没出过问题。
第二个是跨域。H5 端要用fetch拿二进制流,就必须让文件所在域返回正确的 CORS 头。麻烦在于签名 URL 经常被 CDN 缓存,缓存的时候如果没把Origin加入缓存键,某些用户的请求就会拿到不带 CORS 头的响应,表现为"有的人能看有的人不能看"。解决办法是在 CDN 配置里把Origin和Access-Control-Request-Method纳入缓存键,或者在预览网关上做一层代理,前端只跟同源的网关打交道,由网关去取文件。后者更稳,代价是多一跳。
第三个是 iOS 上的 PDF 行为不一致。移动端浏览器对 PDF 的处理策略各不相同,有的会直接内置阅读器打开,有的会下载,有的干脆显示一片空白。我最后干脆放弃了"让浏览器自己处理"的幻想,H5 端一律用渲染引擎画到 canvas 上,行为完全可控。这个决定让代码量增加了一些,但省掉了无穷无尽的机型适配。
5.3 我的避坑清单
- 任何涉及文件的操作,先判大小再动手,别等下到一半失败。
- 所有网络请求都要有超时,转换接口我设 60 秒,下载我设 30 秒,超时就给明确的失败提示。
- 缓存一定要有清理策略,"只写不删"的缓存迟早把用户设备塞满。
- 别在预览入口里做 UI,保持它是个纯函数式的服务。
- 文件类型判断一定要有兜底分支,遇到完全认不出的格式,直接给下载入口,别让用户卡死在加载中。
- 埋点要区分"打开成功"和"用户真的看了",前者可以自动采集,后者只能靠停留时长估算。
6. 性能与体验的最后打磨
功能跑通只是及格线,真正让用户觉得"这东西好用"的,是那些看起来不起眼的细节。
6.1 首屏时间的拆解与优化
一次完整的 PDF 预览,时间花在三个地方:取授权链接、下载文件、解析并渲染首屏。我做过一次统计,在正常网络下这三段大概是 100ms、600ms、400ms 的量级,下载占了大头。
优化下载的做法有两个。一是网关直接给 CDN 地址,不要让请求绕到应用服务器再重定向。二是预热,在用户点击列表项进入详情页的时候就开始预取文件,等用户点"预览"按钮时文件往往已经在本地缓存里了。这个改动让首屏时间平均缩短了一半以上,成本只是提前发起了一次请求。
渲染这块的优化重点是只渲染首屏可见的页面。用户打开文档第一眼只能看到一页,后面几页的渲染完全可以延后到滚动时再触发。我一般会先渲染第一页,然后在requestIdleCallback里预渲染第二、三页,滚动时再按需加载其余部分。
6.2 大文件的三道防线
超过 50MB 的文件,我设了三道防线。第一道在下载前,根据文件大小字段直接判断,超限就提示"请在电脑端打开",并给出下载入口。第二道在下载中,如果下载进度条超过 30 秒没有明显推进,主动中断并提示网络异常。第三道在渲染时,如果检测到页数超过 200 页,强制切换到服务端转换的方案,前端只负责展示转换后的 PDF。
这三道防线听起来像是给用户设置障碍,但实际体验比"让它慢慢下然后崩掉"要好得多。用户最讨厌的不是"不支持",而是"卡在那里不知道在干嘛"。
6.3 那些让人好感度上升的细节
预览页面的返回逻辑要处理好。App 端打开系统文档查看器后,用户按返回键会回到应用的预览入口页,如果这时候入口页还在转圈等待,体验就很怪。所以要在唤起成功后立刻把 loading 关掉。
再比如文档标题,一定要用真实文件名,而不是"文件预览"这种通用词。用户开了好几个标签页的时候,标题是唯一能区分它们的线索。
还有就是失败文案。之前我用的是"预览失败,请重试",后来改成区分具体原因:"文件格式不支持,可下载后使用本地应用打开"、"网络异常,请检查网络后重试"、"文件较大,正在处理中"。改完之后用户重复点击的比例明显下降,因为他们知道该干嘛了。
我在几个项目里把这套方案跑下来,最大的感受是:文件预览这事的复杂度不在某一端的实现,而在多端之间保持一致的行为预期。用户不会关心你用的是原生能力还是前端渲染,他只关心同一个文件在这个页面能打开、在那个页面也应该能打开。所以入口收敛、降级顺序固定、失败提示统一,这三件事的价值远大于某一端做到极致。真要我给个建议,就是先把入口函数和类型判断这两块打磨扎实,剩下的按端慢慢补,别一上来就追求全格式覆盖。