先说个我踩过的真实场景:产品丢过来一个需求,"用户上传的文件别传到服务器,点一下就能在页面里看内容"。当时我第一反应是<iframe :src="url">一嵌就完事了,结果产品紧接着补了一句"支持 txt、PDF、Word、Excel 和图片"。那个下午我从"这活三小时"变成了"这活得排三天"。这套东西就是 FileViewer,一个纯前端预览项目,我用 Vue2 完整做了一遍 demo。它不是某个现成组件库的封装,而是自己搭一套"按文件类型自动分派渲染器"的架子,所有解析都跑在浏览器里,文件一个字节都不离开用户的机器。
如果你正在 Vue2 项目里做附件预览、知识库文档展示、后台系统的文件查看器这类功能,或者你是个想搞明白"浏览器到底能解析多少格式"的前端开发,这篇内容基本能让你少走两三天弯路。我会把类型判定、编码识别、pdfjs 的 worker 配置、Vue2 响应式的坑、objectURL 的内存回收,以及包体积怎么压,全部拆开讲一遍,代码都是能直接抄的骨架。
1. 纯前端预览这套方案,赚在哪、亏在哪
1.1 文件不出浏览器,省掉的是整条上传链路
很多人做文件预览的第一反应是"传后端,转成 PDF 或者图片再返回来",这套方案在十年前是主流,因为浏览器能力弱。但它的代价被严重低估了:你得有一个文件存储、一个转换服务、一套回调轮询或者 WebSocket 通知、一个临时文件清理策略,还要考虑转换失败的重试。更别说用户传的是合同、病历、财务报表这种敏感内容,多一次上传就多一份合规压力。
纯前端预览把这条链路整根砍掉。用户在<input type="file">选完文件,你拿到的是一个File对象,它本身就是Blob的子类,可以直接被FileReader读、被URL.createObjectURL引用、被TextDecoder解码。整个过程零网络请求,首屏感知速度基本等于本地磁盘读取速度。对于一个 30MB 的 PDF,走服务器方案光上传就得等十几秒,纯前端方案是"选中即出画面"。
这个特性在某些场景里是刚需而不是加分项。比如内网离线环境、比如涉及个人隐私的证件核对页、比如 Electron 打包的桌面端应用。我做过一个离线档案检索工具,整个应用跑在没有外网的内网机器上,后端只有静态文件服务,预览能力必须全部落在前端,那时候才真正体会到"文件不出浏览器"不是一句宣传语,而是有没有这个功能的生死线。
1.2 三大代价:包体积、格式覆盖度、内存
天下没有免费的午餐,纯前端预览的账要算清楚。
第一笔账是包体积。pdfjs 打包后 gzip 大概 350KB,SheetJS 大概 250KB,docx-preview 带着 JSZip 也要 200KB 上下。如果这几个库全量同步引入到主 chunk,你的首屏体积直接多出 800KB,在弱网环境下是很明显的。解决办法后面会讲,核心思路是"按需加载 + 代码分割",用户不点预览就不下载。
第二笔账是格式覆盖度。纯前端解析靠的是浏览器里跑 JS 去啃二进制格式,能啃动的和啃不动的差别很大。PDF 有官方维护的 pdfjs,保真度很高;docx 和 xlsx 本质是 zip 包加 XML,解压后自己渲染也能做到七八成;但老版本的.doc、.ppt是 OLE 复合文档格式,纯前端基本无解。我试过几个开源方案,能读出部分文本,但排版全丢。这类格式的现实做法就是降级成"提示用户下载后用本地软件打开",别硬扛。
第三笔账是内存。浏览器标签页的内存是有上限的,尤其在移动端。一个 200MB 的视频用URL.createObjectURL是没问题的,因为浏览器会用磁盘缓存做支撑;但如果你用FileReader.readAsDataURL把它转成 base64,内存里会实实在在多出 270MB 的字符串(base64 膨胀约 33%),页面直接卡死。这是新手最容易犯的错误,我在第一次做的时候也中招了。
1.3 什么场景适合上,什么场景别硬上
判断标准其实很简单:文件格式是否是"开放且结构清晰"的。
适合上纯前端预览的场景:附件大多是图片、PDF、docx、xlsx、txt、markdown、代码文件;用户对排版保真度要求是"能看清内容",不是"像素级还原";部署环境有离线或隐私要求;后台系统不想为预览单独维护一套转换服务。
不适合的场景:需要预览 CAD 图纸、PSD、AI、视频剪辑工程文件这类专业格式;需要严肃的排版还原(比如合同必须和原件一模一样,那还是走服务端转 PDF 更稳);文件体积普遍在 500MB 以上;需要对预览内容做水印、禁止下载这类强控制(纯前端做水印,懂技术的人随便就能绕过)。
我个人的经验是,把纯前端预览定位成"第一层能力",覆盖 80% 的常见格式,剩下的 20% 用一个统一的兜底渲染器引导用户下载。这个组合的投入产出比是最高的,比追求 100% 覆盖要划算得多。
2. 先把类型分派做对:FileViewer 的注册表架构
2.1 为什么不用一长串 if-else
最开始我写的是最朴素的方式:
if (ext === 'pdf') { ... } else if (ext === 'docx') { ... } else if (['png','jpg','jpeg'].includes(ext)) { ... }写完第三个格式我就知道要出事。这个写法有三个致命问题:一是新增格式要改主组件,主组件会越来越臃肿;二是异步加载逻辑没法优雅挂上去,每个分支都要写一遍import();三是类型判定逻辑散落在各处,图片的扩展名列表在一处、mime 判定在另一处,改起来容易漏。
正确的做法是注册表 + 匹配器链。每个渲染器负责三件事:声明自己能处理什么、提供自己的匹配函数、提供自己的懒加载入口。主组件只做一件事:遍历注册表,找到第一个匹配的渲染器,动态渲染它。
// src/components/FileViewer/renderers/registry.js const registry = [ { name: 'pdf', match: ctx => ctx.ext === 'pdf', loader: () => import('./PdfRenderer.vue') }, { name: 'office', match: ctx => ['docx', 'xlsx', 'xls', 'csv'].includes(ctx.ext), loader: () => import('./OfficeRenderer.vue') }, { name: 'image', match: ctx => ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ctx.ext), loader: () => import('./ImageRenderer.vue') }, { name: 'media', match: ctx => ['mp4', 'webm', 'mp3', 'wav', 'ogg'].includes(ctx.ext), loader: () => import('./MediaRenderer.vue') }, { name: 'text', match: ctx => ['txt', 'log', 'json', 'xml', 'md', 'js', 'css', 'html'].includes(ctx.ext), loader: () => import('./TextRenderer.vue') }, // 兜底必须放最后,且 match 永远返回 true { name: 'fallback', match: () => true, loader: () => import('./FallbackRenderer.vue') } ] export function resolveRenderer(ctx) { return registry.find(r => r.match(ctx)) || registry[registry.length - 1] }这个结构有个隐藏好处:顺序即优先级。你现在可能觉得"pdf 就一个扩展名,哪儿来的优先级",但等业务变复杂了就知道了。比如以后要加一个"加密 PDF 专用渲染器",你只要插在普通 pdf 渲染器前面,match 函数里判断ctx.encrypted就行,主组件一行不用改。这种"新需求只改局部"的特性,是组件能否长期维护的分水岭。
主组件里的动态渲染用 Vue2 的异步组件写法,注意 Vue2 和 Vue3 在这里是不一样的:Vue3 用defineAsyncComponent包装,Vue2.6 直接用一个返回 Promise 的工厂函数就行。
<!-- index.vue --> <template> <div class="file-viewer"> <div v-if="loading" class="fv-loading">解析中...</div> <component v-else-if="renderer" :is="renderer" :file="file" :ctx="ctx" @error="onError" /> <div v-else class="fv-error">无法识别的文件类型</div> </div> </template> <script> import { resolveRenderer } from './renderers/registry' import { buildContext } from './utils/detect' export default { name: 'FileViewer', props: { file: { type: [File, Blob], required: true }, fileName: { type: String, default: '' } }, data() { return { renderer: null, ctx: null, loading: true } }, watch: { file: 'init', fileName: 'init' }, created() { this.init() }, methods: { async init() { this.loading = true this.renderer = null const ctx = await buildContext(this.file, this.fileName) this.ctx = ctx const target = resolveRenderer(ctx) this.renderer = target.loader this.loading = false }, onError(e) { this.$emit('error', e) } } } </script>2.2 三种类型判定各自的盲区
类型判定这件事,看着简单,实际上是个三选一的取舍:扩展名、MIME、魔数,每个都有盲区。
扩展名最方便,但完全不可信,用户把a.zip改成a.pdf你一点办法都没有。不过在预览场景里,扩展名的可信度反而比想象中高,因为用户改扩展名通常是为了绕过上传限制,而不是为了骗你的预览器。
MIME 来自file.type,是操作系统根据扩展名推断塞给浏览器的,所以它本质上还是扩展名的衍生。问题在于识别率:.md、.log、.vue、.ts这些文件在很多系统上file.type是空字符串,你如果直接依赖它就会掉进坑里。另外不同系统对同一扩展名的 MIME 也不一致,xlsx有application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,也有个别系统给出application/zip。
魔数(文件头字节)最靠谱,但要读文件内容,而且是异步的。PDF 的头部是25 50 44 46(也就是%PDF),PNG 是89 50 4E 47,JPEG 是FF D8 FF。麻烦的是 docx 和 xlsx,它们和普通 zip 一样都是50 4B 03 04,只靠前四个字节区分不出来,得进一步解压看内部有没有word/或xl/目录。
我最后采用的是组合策略,优先级从高到低:
| 判定层 | 数据来源 | 可信度 | 是否异步 | 适用场景 |
|---|---|---|---|---|
| 扩展名 | 文件名后缀 | 中 | 否 | 快速初判、构造上下文 |
| MIME | file.type | 中低 | 否 | 扩展名为空时的补位 |
| 魔数 | 文件头字节 | 高 | 是 | 关键格式的最终确认 |
| 内部结构 | 解压后目录 | 高 | 是 | docx/xlsx 与 zip 的区分 |
实际写的时候,先用扩展名建立 ctx,用 MIME 补位,然后只对"高风险格式"做魔数校验。所谓高风险格式,指的是 PDF 和 Office 这两类——因为它们的渲染器加载成本最高,如果判定错了,用户会白等几秒钟然后看到报错。图片和文本的渲染器很轻,判定错了代价小,其实可以不做魔数校验,省一次文件读取。
// utils/detect.js const MAGIC = [ { ext: 'pdf', test: b => b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46 }, { ext: 'zip', test: b => b[0] === 0x50 && b[1] === 0x4b && b[2] === 0x03 && b[3] === 0x04 }, { ext: 'png', test: b => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 }, { ext: 'jpg', test: b => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff }, { ext: 'gif', test: b => b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 }, { ext: 'webp', test: b => b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 } ] export function sniff(arrayBuffer) { const head = new Uint8Array(arrayBuffer.slice(0, 8)) const hit = MAGIC.find(m => m.test(head)) return hit ? hit.ext : '' }注意:
arrayBuffer.slice(0, 8)这一步不能省。大文件的arrayBuffer可能是几百 MB,new Uint8Array(arrayBuffer)会创建一个巨大的视图,虽然是零拷贝但还是会干扰 GC 判断。先切再建视图是个好习惯。
2.3 渲染器统一接口长什么样
注册表定好之后,渲染器之间必须遵守同一套契约,否则主组件没法统一处理加载态、错误态和销毁。我约定的接口很简单:
props: { file: { type: Blob, required: true }, // 原始文件对象 ctx: { type: Object, required: true } // 类型上下文,含 ext、mime、name、size }, emits: ['error', 'loaded'], methods: { destroy() {} // 主动释放资源,主组件会在切换文件前调用 }ctx里我固定塞这么几个字段:ext(规范化后的小写扩展名)、mime、name(原始文件名)、size(字节数)、sniffed(魔数判定结果)、url(惰性生成的 objectURL)。这些字段基本够所有渲染器用了。
关于destroy的调用时机,我踩过一个坑:一开始我是靠beforeDestroy钩子让每个渲染器自己清理,但动态组件在切换时,Vue 的销毁是异步的,等到子组件beforeDestroy执行的时候,新的渲染器可能已经开始渲染了,两个渲染器短暂共存会导致 canvas 争抢。后来改成主组件在watch: file里主动调用旧渲染器的destroy,等它 resolve 之后再切换,就再也没出过问题。
async init() { // 先清理旧的 if (this.rendererRef && this.rendererRef.destroy) { await this.rendererRef.destroy() } // 再走新的流程 }3. 分类型落地:每条格式路线的真实成本
3.1 txt 与代码文件:编码判断比读取更麻烦
文本文件的读取本身一行代码就够:FileReader.readAsText(file)。但中文项目里这句话大概率会让你看到一堆乱码,因为readAsText默认用 UTF-8 解码,而国内不少环境导出的 txt、csv、log 是 GBK 或 GB18030 编码。
我的处理流程是这样的:先按 BOM 判断,再按 UTF-8 严格解码试,失败退回 GBK。这个顺序不是随便定的,BOM 判断成本最低且绝对准确,UTF-8 严格模式(fatal: true)能覆盖绝大多数现代文件,只有真的解不出合法 UTF-8 序列时才退到 GBK。
export function decodeText(arrayBuffer) { const bytes = new Uint8Array(arrayBuffer) // UTF-8 BOM if (bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF) { return new TextDecoder('utf-8').decode(bytes.subarray(3)) } // UTF-16 LE / BE if (bytes[0] === 0xFF && bytes[1] === 0xFE) { return new TextDecoder('utf-16le').decode(bytes.subarray(2)) } if (bytes[0] === 0xFE && bytes[1] === 0xFF) { return new TextDecoder('utf-16be').decode(bytes.subarray(2)) } // 无 BOM:UTF-8 严格模式试解码 try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes) } catch (e) { // 兜底 GBK,覆盖国内常见导出文件 try { return new TextDecoder('gbk').decode(bytes) } catch (e2) { return new TextDecoder('utf-8').decode(bytes) // 最后兜底,允许乱码 } } }这里有个细节值得说:网上很多方案会让你引入jschardet做编码探测,我实测下来不太划算。jschardet压缩后也有 100KB 左右,而且它在短文本上的准确率并不高——少于 100 个字节的内容它基本靠猜。而TextDecoder是浏览器原生的,gbk这个 label 在 Chrome、Edge、Firefox、Safari 上全都支持,零体积零依赖,准确率还更高。这个取舍在包体积敏感的项目里很关键。
代码文件(.js、.vue、.json等)可以直接复用文本渲染器,只是外层套一个语法高亮。高亮库我建议用highlight.js的 core 版本,只注册你需要的语言,全量引入的话会多出 300KB 以上。
3.2 图片、音视频:objectURL 是唯一正解
图片和音视频预览没有任何技术难度,唯一的坑在于用哪种 URL。选项有两个:FileReader.readAsDataURL转 base64,或者URL.createObjectURL生成 blob URL。
结论很明确:永远用 objectURL。理由有三条。第一,base64 编码会让数据膨胀 33%,一个 5MB 的图片变成 6.7MB 的字符串常驻内存;第二,base64 是同步可读的,但生成过程要走完整的文件读取,大文件会有明显延迟,而 objectURL 是同步返回的,几乎是瞬时;第三,base64 字符串会出现在 DOM 的src属性里,如果你有日志上报或者 DOM 快照,整个文件内容都会被带出去,这在隐私场景下是灾难。
import { createObjectUrl } from '../utils/url' export default { name: 'ImageRenderer', props: ['file', 'ctx'], data() { return { url: '' } }, created() { this.url = createObjectUrl(this.file) }, methods: { destroy() { // url 由统一管理器回收,这里只需要归还引用 this.url = '' } } }音视频有一点要注意:浏览器的媒体解码能力是有边界的。.mp4如果用的是 H.265 编码,Chrome 默认播不了,只会显示黑屏。.mov、.avi、.wmv这些格式在浏览器里的支持度也很差。这类情况我的做法是监听<video>的error事件,一旦触发就降级到兜底渲染器,提示用户下载播放。别指望前端能解决编解码问题,那是内核层面的事。
3.3 PDF:pdfjs 的 worker 配置是第一道坎
PDF 是最值得投入的格式,因为 pdfjs 的成熟度极高,渲染质量接近原生阅读器。但它有个出名的坑:worker 文件找不到。如果你只是import * as pdfjsLib from 'pdfjs-dist',然后调getDocument,控制台大概率会报Setting up fake worker failed,然后 PDF 是渲染出来了,但主线程被完全阻塞,文件一多页面就卡成 PPT。
原因是 pdfjs 把解析工作放在 Web Worker 里跑,而 worker 脚本需要作为一个独立文件被加载,打包工具默认不会帮你处理。解决方案有好几种,我推荐的是把 worker 文件放进静态目录,这个方案最稳,不受打包器版本和配置差异影响。
具体做法:从node_modules/pdfjs-dist/build/里把pdf.worker.min.js拷到项目的public/(Vue CLI 项目是public/,老项目可能是static/)目录下,然后在入口处指定路径。
// 主线程侧 import * as pdfjsLib from 'pdfjs-dist' // 注意版本号要对齐,不写版本可能在发版缓存更新时出问题 pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js' pdfjsLib.getDocument({ data: arrayBuffer }).promise.then(pdfDoc => { this.pdfDoc = pdfDoc this.totalPages = pdfDoc.numPages this.renderPage(1) })提示:
pdfjs-dist的版本和pdf.worker.min.js必须严格一致,混用不同版本会出现诡异的解析错误。项目里的package.json最好锁死小版本,^号在这种场景下风险不小。
渲染单页的核心逻辑要处理三个问题:canvas 尺寸设置、缩放级别、以及渲染任务的取消。第三点最关键,后面讲 Vue2 坑的时候会详细说。
async renderPage(pageNum) { const page = await this.pdfDoc.getPage(pageNum) const viewport = page.getViewport({ scale: this.scale }) const canvas = this.$refs.canvas const ctx = canvas.getContext('2d') canvas.width = viewport.width canvas.height = viewport.height canvas.style.width = viewport.width + 'px' canvas.style.height = viewport.height + 'px' this.renderTask = page.render({ canvasContext: ctx, viewport }) await this.renderTask.promise }canvas.width和canvas.style.width要同时设置,前者决定画布的实际像素分辨率,后者决定显示尺寸。在高分屏(devicePixelRatio 为 2 或 3)上,如果只设 style 不设 width,画面会糊成一团。如果想做到视网膜级别的清晰度,应该把 scale 乘以window.devicePixelRatio,然后用 style 把尺寸缩回去。这个技巧我是在做打印预览的时候才意识到,之前一直以为 pdfjs 渲染质量就这样了。
3.4 docx / xlsx:本质是解压一个 zip
Office 的新格式(docx、xlsx、pptx)从 2007 版开始全部是 OOXML 格式,文件本身就是个 zip 包,里面的word/document.xml存正文,xl/worksheets/sheet1.xml存表格数据,[Content_Types].xml存结构声明。所以纯前端解析 Office 文件,本质上就是"解压 + 解析 XML + 渲染 DOM"。
docx 我推荐docx-preview,它的还原度在这个领域里是最好的,段落样式、表格、图片、页眉页脚都能处理,而且支持分页渲染。用法很直接:
import { renderAsync } from 'docx-preview' export default { name: 'DocxRenderer', props: ['file', 'ctx'], async mounted() { try { await renderAsync(this.file, this.$refs.container, null, { className: 'docx-preview-wrap', inWrapper: true, ignoreWidth: false, ignoreHeight: true }) this.$emit('loaded') } catch (e) { this.$emit('error', e) } } }ignoreHeight: true这个配置值得说一下。默认情况下它会把每一页渲染成固定高度,视觉上更像 Word 的分页视图,但遇到内容溢出的文档就会出现奇怪的空白。如果你只是想让用户"读到内容",设成 true 让它自然流式排版,可读性反而更好。
xlsx 用 SheetJS(npm 包名xlsx)。核心就两步:读成 workbook,再把 sheet 转成 HTML 或 JSON。
import * as XLSX from 'xlsx' const wb = XLSX.read(arrayBuffer, { type: 'array', cellDates: true }) const sheetName = wb.SheetNames[0] const sheet = wb.Sheets[sheetName] const html = XLSX.utils.sheet_to_html(sheet, { id: 'fv-sheet' })cellDates: true必须加,否则日期会变成一串数字序列号(Excel 内部的日期是自 1900 年 1 月 1 日起的天数)。这个坑我见过太多次了,同事跑来问"为什么表格里的日期显示成 45123",答案就是这个。
但 SheetJS 社区版有个硬伤:读不到单元格样式。字体、颜色、边框、条件格式全都没有,你拿到的只有值和合并信息。所以在做的过程中要提前跟产品对齐预期,"能看清数据"和"长得像 Excel"是两回事。真要还原样式,要么上商业版,要么在服务端用别的方案转换。我的做法是在表格外面加一层自己的样式,比如斑马纹、固定表头、单元格对齐,让它看起来像个像样的数据表,用户体验反而比生硬复刻 Excel 样式更好。
大表格还有性能问题。一个 5 万行的 xlsx,sheet_to_html生成的 DOM 节点会让浏览器直接卡住好几秒。这种情况建议改成sheet_to_json拿数据,然后用虚拟滚动只渲染可视区域的行,性能差异是数量级的。
3.5 兜底渲染器:预览不了也是一种合格答案
兜底渲染器看起来是最没技术含量的东西,但它决定了整个组件的"下限体验"。用户拿到一个.psd文件,你给一个空白页面或者一串报错,他会觉得你的系统坏了;你给一个清晰的提示、一个文件图标、文件大小和修改时间,再加一个下载按钮,他会觉得"这个功能就是这样的"。
<template> <div class="fv-fallback"> <div class="fv-fallback-icon">FILE</div> <div class="fv-fallback-name">{{ ctx.name }}</div> <div class="fv-fallback-meta"> {{ prettySize(ctx.size) }} · {{ ctx.ext || '未知格式' }} </div> <p class="fv-fallback-tip">当前格式暂不支持在线预览,请下载后使用本地应用打开。</p> <button class="fv-fallback-btn" @click="download">下载文件</button> </div> </template>这里有个心理层面的设计技巧:提示语要说"暂不支持",不要说"不支持"。一字之差,用户对产品的容错度完全不同。另外下载按钮要真的能下载,用URL.createObjectURL加<a download>就够了,别做成一个假的按钮。
4. Vue2 挖的坑,我一个个填过来的
4.1 响应式陷阱:$set 不是可选项
Vue2 的响应式基于Object.defineProperty递归遍历 data 里的字段做 getter/setter 劫持,这意味着对象新增的属性和数组通过索引赋的值不会触发视图更新。Vue3 换成了 Proxy,这个问题就不存在了。如果你从 Vue3 项目切到 Vue2,这是最容易忘的一条。
我在 FileViewer 里踩的具体场景是这样的:ctx对象初始只声明了ext和name,后来在异步判定流程里补了ctx.encrypted = true、ctx.pages = 12,结果子组件里v-if="ctx.encrypted"死活不生效,控制台也没报错,调试了半天才想起来是响应式的问题。
两种解法,我更推荐第二种。第一种是用this.$set(this.ctx, 'encrypted', true),能解决问题但写起来啰嗦,而且容易漏。第二种是一开始就把所有可能用到的字段在 data 里声明好,用不到就设成null,让 Vue 在初始化时就完成劫持。
data() { return { ctx: { ext: '', mime: '', name: '', size: 0, sniffed: '', encrypted: false, pages: 0, // 先把坑占上 url: '', error: null } } }还有一种情况要注意:Object.assign(this.ctx, newCtx)这种写法。它看起来像是在更新已有对象,但如果newCtx里有原来没声明过的 key,那些 key 依然不是响应式的。安全的做法是this.ctx = Object.assign({}, this.ctx, newCtx),整体替换触发 setter。
4.2 objectURL 不回收,页面会慢慢变卡
URL.createObjectURL(blob)创建的 URL 会一直持有对 Blob 的引用,直到你显式调用URL.revokeObjectURL(url)或者页面被关闭。浏览器不会自动回收它,即使对应的 DOM 元素已经被移除。
这个特性在文件预览场景下就是内存杀手。用户连续预览十个 PDF,如果每个 PDF 都生成了一个 objectURL 而没释放,那十个文件的数据就全部驻留在内存里。我做压力测试的时候,连续切换 30 个 10MB 左右的 PDF,Chrome 的内存占用从 200MB 涨到 1.2GB,页面开始明显掉帧。
解决方案是集中管理 URL 生命周期。我在每个渲染器实例上挂一个_urls数组,所有创建出来的 URL 都登记进去,在destroy时统一回收。
// utils/url.js export function createUrlManager() { const urls = [] return { create(blob) { const url = URL.createObjectURL(blob) urls.push(url) return url }, revokeAll() { while (urls.length) { URL.revokeObjectURL(urls.pop()) } } } }关键点是revokeAll里用了pop而不是forEach,因为forEach过程中改数组容易出问题,而且这样能保证数组被清空。另外不要 revoke 正在使用的 URL,如果你在图片还在显示的时候就 revoke 了,图片会立刻变成裂图。所以回收动作一定要放在切换文件、组件销毁这两个明确的时机,而不是随手写在某个异步回调里。
4.3 pdfjs 的 renderTask 与 canvas 复用之争
这是我遇到过最玄学的一个报错:Cannot use the same canvas during multiple render() operations。翻译过来就是同一个 canvas 被两个渲染任务同时占用。
触发条件很具体:用户快速连续翻页,或者 Quick 切换文件。前一个page.render()还没跑完,后一个就开始了,两个任务抢同一个 canvas。pdfjs 内部有个锁,检测到冲突就直接抛错。
解法是保存 renderTask 引用,新任务开始前先取消旧的。
async renderPage(pageNum) { if (this.renderTask) { try { this.renderTask.cancel() } catch (e) { // 任务可能已经自然结束,忽略 } this.renderTask = null } const page = await this.pdfDoc.getPage(pageNum) const viewport = page.getViewport({ scale: this.scale }) const canvas = this.$refs.canvas const ctx = canvas.getContext('2d') canvas.width = viewport.width canvas.height = viewport.height this.renderTask = page.render({ canvasContext: ctx, viewport }) try { await this.renderTask.promise } catch (err) { // 取消是正常流程,不要当错误上报 if (err && err.name !== 'RenderingCancelledException') { this.$emit('error', err) } } finally { this.renderTask = null } }两个细节。第一,cancel()之后promise会 reject 一个RenderingCancelledException,这是预期行为,不能当成错误,否则你的错误监控里会塞满这种噪音。第二,finally里要把this.renderTask置空,否则下次进来会 cancel 一个已结束的任务,虽然不报错但逻辑上是脏的。
还有一个相关的坑:Vue2 的 keep-alive 与 canvas 不兼容。如果 FileViewer 被包在<keep-alive>里,组件失活时 DOM 会被移出文档流,canvas 的内容会丢失(这是 canvas 的固有行为,不是 Vue 的问题)。再次激活时需要在activated钩子里重新渲染当前页。
activated() { if (this.pdfDoc && this.currentPage) { this.$nextTick(() => this.renderPage(this.currentPage)) } }4.4 那条 unload 警告到底要不要管
控制台里出现Permissions policy violation: unload is not allowed in this document的时候,我第一反应是"我代码里没用 unload 啊"。排查了一圈发现是第三方库和浏览器扩展干的。
这条警告的背景是 Chrome 在逐步弃用unload事件,因为它会阻塞页面的前进后退缓存(bfcache),影响浏览器的返回体验。Chrome 从 115 版本左右开始对声明了Permissions-Policy: unload=()的页面禁用这个事件,触发时就会打印这条警告。
结论是:如果你的代码里没有主动注册unload监听,这条警告可以忽略,不影响任何功能。想让它消失的话,可以在自己的代码里搜一遍addEventListener('unload'和window.onunload,把清理逻辑改成监听pagehide或者visibilitychange。这两个事件的语义更准确,也是官方推荐的替代方案。
// 不推荐 window.addEventListener('unload', this.cleanup) // 推荐 window.addEventListener('pagehide', this.cleanup) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') this.cleanup() })在 FileViewer 这个场景下,cleanup主要是回收 objectURL 和取消进行中的解析任务。用pagehide替代有个额外好处:移动端切后台的时候unload经常不触发,pagehide和visibilitychange的触发率高得多,清理逻辑更可靠。
5. 从能跑到能用:大文件、按需加载与异常兜底
5.1 按需加载把首屏体积压下来
前面算过这笔账,pdfjs 加 SheetJS 加 docx-preview 全量同步引入,主 chunk 会多出 800KB 左右。对一个后台管理系统来说,这个代价可能还能接受,但对 C 端页面就是灾难。
解决办法是利用 webpack 的动态 import 做代码分割。注册表里loader: () => import('./PdfRenderer.vue')这个写法,配合 Vue2 的异步组件,效果就是用户不点预览,这个 chunk 就不会下载。Vue CLI 默认配置下,每个import()会生成一个独立的 chunk 文件。
这里有几个优化点值得注意。第一,chunk 命名。默认生成的文件名是0.js、1.js这种,出问题排查起来很痛苦。用魔法注释起个名字:
loader: () => import(/* webpackChunkName: "fv-pdf" */ './PdfRenderer.vue')第二,预加载策略。如果用户大概率会预览 PDF,可以在组件挂载后空闲时提前 prefetch:
import(/* webpackPrefetch: true */ './PdfRenderer.vue')不过 prefetch 会消耗用户的流量,在移动端要慎用。第三,pdfjs 的预构建产物。pdfjs-dist有pdf.js和pdf.min.js两个版本,后者已经压缩过,别再用 terser 压一遍,会出问题。
我实测的数据:优化前主包 1.8MB(gzip 后 620KB),把三个重型库拆出去之后,主包降到 1.1MB(gzip 后 380KB),首屏加载时间从 3.2 秒降到 1.9 秒。这个提升在弱网环境下感知非常明显。
5.2 大文件别一次读完
FileReader.readAsArrayBuffer会一次性把整个文件读进内存。对于一个 300MB 的视频文件,这一步直接就能让页面白屏。所以对于大文件,一定要走流式或者切片。
第一层策略是按类型分流。图片、音视频这类根本不需要读内容,用 objectURL 直接引用就够了,浏览器会用磁盘缓存处理,内存占用极小。真正需要读内容的是 PDF、Office、文本这三类。
第二层策略是限制读取上限。文本文件可以只读前 2MB:
async function readTextPreview(file, limit = 2 * 1024 * 1024) { const slice = file.slice(0, Math.min(file.size, limit)) const buf = await slice.arrayBuffer() const text = decodeText(buf) return file.size > limit ? text + '\n\n...(文件过大,仅显示前 2MB 内容)' : text }第三层策略是Web Worker 解析。SheetJS 解析一个 10MB 的 xlsx 会阻塞主线程 2 到 3 秒,期间页面完全无响应,滚动都动不了。把这部分扔进 Worker 就完全不一样了:
// workers/xlsx.worker.js importScripts('https://cdn.example.com/xlsx.full.min.js') self.onmessage = function (e) { const { buffer } = e.data const wb = XLSX.read(buffer, { type: 'array', cellDates: true }) const sheet = wb.Sheets[wb.SheetNames[0]] const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false }) self.postMessage({ rows }) }注意 Worker 里不能用 npm 的模块系统(除非打包器特别配置),用importScripts加载 UMD 版本是最省事的。数据传回主线程用postMessage,如果数据量大,用Transferable Objects可以做到零拷贝。
5.3 异常路径要考虑得比正常路径更细
纯前端解析的失败率比服务端转换高,因为你要面对的是用户手里各种乱七八糟的文件。我整理了一份异常清单,每一条都真实遇到过。
| 异常类型 | 触发场景 | 处理方式 |
|---|---|---|
| 加密文档 | PDF 设了打开密码 | 捕获PasswordException,提示输入密码 |
| 损坏文件 | 下载中断导致文件不完整 | 捕获解析异常,降级到兜底渲染器 |
| 空文件 | 大小为 0 | 直接提示"文件为空",不启动解析 |
| 超大文件 | 超过阈值,如 50MB | 提示"文件过大,建议下载查看" |
| 类型误判 | 扩展名被改过 | 魔数校验失败后按实际类型处理 |
| 编码异常 | 混合编码的日志文件 | 分段解码,无法解码部分用占位符替换 |
| 浏览器不支持 | 老版本浏览器缺 API | 特性检测,不支持时直接降级 |
加密 PDF 是个典型例子,值得单独说一下。pdfjs 在getDocument时会返回一个PasswordException,你可以捕获它然后弹一个输入框,把用户输入的密码通过getDocument({ data, password })再传一次。这个小功能在合同管理系统里特别实用,因为很多商务合同确实带密码。
try { const pdfDoc = await pdfjsLib.getDocument({ data: buffer }).promise this.pdfDoc = pdfDoc } catch (err) { if (err && err.name === 'PasswordException') { this.needPassword = true // 切到密码输入界面 } else if (err && err.name === 'InvalidPDFException') { this.$emit('error', new Error('文件已损坏或不完整的 PDF')) } else { this.$emit('error', err) } }另一个经验是错误提示要给到人话。把InvalidPDFException直接打到界面上,用户看不懂。我总结了一套映射表:"文件已损坏,建议重新下载"、"该文件需要密码才能打开"、"当前浏览器暂不支持该格式的在线预览"、"文件过大,请下载后查看"。这几句话覆盖了 95% 的异常场景,比堆栈信息有用得多。
6. 复现清单:目录结构和关键代码骨架
把上面讲的东西串起来,一个可用的 FileViewer 在 Vue2 项目里的目录结构大概是这样:
src/components/FileViewer/ ├── index.vue # 主组件,负责类型判定和渲染器分派 ├── renderers/ │ ├── registry.js # 注册表,顺序即优先级 │ ├── TextRenderer.vue # txt / 代码 / 日志 │ ├── MarkdownRenderer.vue # md,需要 DOMPurify 过滤 │ ├── ImageRenderer.vue # 图片 │ ├── MediaRenderer.vue # 音频 / 视频 │ ├── PdfRenderer.vue # PDF,依赖 pdfjs-dist │ ├── OfficeRenderer.vue # docx / xlsx,按 ext 再分派 │ └── FallbackRenderer.vue # 兜底 ├── utils/ │ ├── detect.js # 扩展名 / MIME / 魔数三段式判定 │ ├── decode.js # 文本编码识别 │ ├── url.js # objectURL 生命周期管理 │ └── size.js # 文件大小格式化 └── workers/ └── xlsx.worker.js # 大表格解析线程外层调用极其简单,就三行:
<template> <FileViewer :file="currentFile" :file-name="currentFileName" @error="handlePreviewError" /> </template> <script> import FileViewer from '@/components/FileViewer/index.vue' export default { components: { FileViewer }, data() { return { currentFile: null, currentFileName: '' } }, methods: { onFileChange(e) { const f = e.target.files[0] if (!f) return this.currentFileName = f.name this.currentFile = f }, handlePreviewError(err) { this.$message.error(err.message || '预览失败') } } } </script>依赖清单,按重要性排序:pdfjs-dist(PDF)、xlsx(Excel)、docx-preview(Word)、highlight.js(代码高亮,可选)、markdown-it+dompurify(Markdown,如果你要支持的话)。全部加起来 gzip 后大概 900KB,但如果做好按需加载,用户的首次加载成本是接近零的。
Markdown 渲染这里我必须强调一下DOMPurify。Markdown 允许内联 HTML,如果你用v-html直接渲染用户提供的内容,那就是一个标准 XSS 漏洞。任何走v-html的地方都要过一遍净化:
import MarkdownIt from 'markdown-it' import DOMPurify from 'dompurify' const md = new MarkdownIt({ html: true, linkify: true }) const raw = md.render(text) const safe = DOMPurify.sanitize(raw, { ALLOWED_TAGS: ['p','br','strong','em','code','pre','ul','ol','li','h1','h2','h3','h4','table','thead','tbody','tr','th','td','a','img','blockquote'], ALLOWED_ATTR: ['href', 'src', 'alt', 'title'] })白名单比黑名单安全得多。你很难穷举所有危险标签和属性,但你可以精确控制允许哪些。
最后分享一个我在实际项目里养成的习惯:给每个渲染器都写一个降级开关。通过 props 传一个disable数组进来,比如:disable="['pdf', 'office']",被禁用的格式直接走兜底渲染器。这个设计在灰度发布的时候特别有用——新上线的 PDF 渲染器如果出问题,不用发版就能通过配置关掉,用户看到的是"下载查看"而不是报错页面。这个小设计救过我一次线上事故,值得提前加上。