文件预览常常以一个很小的需求出现:用户点击附件,在页面里看一眼内容。
真正开始实现后,问题会迅速变多。PDF、Word、Excel、图片、日志和压缩包并不共享同一种浏览器能力;文件可能来自对象存储,也可能来自需要鉴权的接口;有些文件能直接渲染,有些只能下载或交给第三方服务;更不用说跨域、内存释放、大文件和敏感数据。
因此,文件预览不适合长期停留在"某个页面里塞一个 iframe"的阶段。更好的方式是把它设计成一个独立能力:业务页面只负责提供文件,预览平台负责识别、加载、渲染、降级和清理。
本文以 Vue 3 + TypeScript 为例,拆解一套可以迁移到管理后台、内容系统、协同工具和知识库中的文件预览方案。
一、先划清边界:前端预览层不是什么
在开始编码前,先明确前端预览层的职责:
- 统一文件输入。
- 根据格式选择渲染器。
- 管理加载、失败、重试、下载等交互状态。
- 处理浏览器侧资源,例如 Blob URL 和中断请求。
它不应该独自承担下面的责任:
- 文件权限校验。
- 恶意文件检测。
- 大型 Office 文件转换。
- 任意第三方 URL 的代理访问。
- 敏感文件交给外部预览服务的合规判断。
这些问题需要文件服务、网关或后端转换服务参与。前端平台做得再漂亮,也不能用"隐藏下载按钮"代替权限控制。
二、一个可复用的架构
把预览能力拆成五层,后续扩展格式或更换文件服务时就不会牵动业务页面。
scss
业务页面 / iframe / 独立预览页
|
v
Preview Service
open() / close() / retry()
|
v
Preview State
URL、Blob、文件名、类型、状态
|
v
Type Resolver
根据显式类型、MIME、扩展名分发
|
v
Renderers
PDF / Office / Image / Text / Archive
|
v
File Access Layer
URL、鉴权、代理、超时、取消请求
这里最关键的原则是:业务代码只传文件,不感知渲染细节。
调用方不必知道 PDF 使用什么库、DOC 是否需要第三方服务、文本文件如何请求,只需要使用一个稳定接口:
php
preview.open({
url: fileUrl,
fileName: 'design-spec.pdf',
})
对于需要先经过业务接口鉴权的文件,也保持相同形态:
csharp
const blob = await fetchProtectedFile(fileId)
preview.open({
blob,
fileName: 'design-spec.pdf',
})
URL 和 Blob 最终都进入同一份预览状态,渲染层自然不需要区分文件来源。
三、第一步:设计稳定的输入与状态模型
预览平台的公开 API 越小,越容易在多个项目中复用。下面是一组足够实用的类型定义:
typescript
export type PreviewStatus =
| 'idle'
| 'loading'
| 'ready'
| 'error'
| 'unsupported'
export type PreviewKind =
| 'pdf'
| 'docx'
| 'spreadsheet'
| 'image'
| 'text'
| 'archive'
| 'office-online'
| 'unknown'
export interface PreviewInput {
url?: string
blob?: Blob
fileName?: string
fileType?: string
}
export interface PreviewState {
visible: boolean
status: PreviewStatus
url: string
fileName: string
fileType: string
kind: PreviewKind
errorMessage: string
isBlob: boolean
}
有三个细节值得注意:
fileType和kind不完全相同。前者可以是xlsx、jpg这类原始类型,后者是渲染层真正关心的分类。status不要只用loading: boolean表示。error、unsupported和ready是不同的用户决策点。- 输入允许调用方明确传入
fileName与fileType,不要完全依赖 URL。很多签名 URL 没有扩展名,或带有复杂查询参数。
文中会用到两个纯工具函数。它们不需要放进 composable,保持为普通函数更容易测试和复用:
typescript
export function getFileName(url: string): string {
try {
const { pathname } = new URL(url, window.location.origin)
return decodeURIComponent(pathname.split('/').pop() || 'unnamed-file')
}
catch {
return 'unnamed-file'
}
}
export function getExtension(fileName: string): string {
const normalized = fileName.trim()
const index = normalized.lastIndexOf('.')
return index > -1 ? normalized.slice(index + 1).toLowerCase() : ''
}
四、第二步:统一 URL 与 Blob,并正确回收资源
Blob 是受保护文件预览的常见输入方式。浏览器无法直接把 Blob 交给大多数渲染组件,需要先通过 URL.createObjectURL() 创建临时地址。
它同时也是最容易造成内存泄漏的地方。
下面的 composable 将 URL 和 Blob 统一成一个状态模型,并处理关闭动画和重复打开文件的竞态问题:
javascript
import { reactive, readonly } from 'vue'
export function useFilePreview() {
const state = reactive<PreviewState>({
visible: false,
status: 'idle',
url: '',
fileName: '',
fileType: '',
kind: 'unknown',
errorMessage: '',
isBlob: false,
})
let objectUrl: string | undefined
let clearTimer: number | undefined
function releaseObjectUrl() {
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
objectUrl = undefined
}
}
function reset() {
releaseObjectUrl()
Object.assign(state, {
visible: false,
status: 'idle',
url: '',
fileName: '',
fileType: '',
kind: 'unknown',
errorMessage: '',
isBlob: false,
})
}
function open(input: PreviewInput) {
window.clearTimeout(clearTimer)
reset()
if (input.blob) {
objectUrl = URL.createObjectURL(input.blob)
state.url = objectUrl
state.fileName = input.fileName || 'unnamed-file'
state.fileType = input.fileType || input.blob.type
state.isBlob = true
}
else if (input.url) {
state.url = input.url
state.fileName = input.fileName || getFileName(input.url)
state.fileType = input.fileType || getExtension(state.fileName)
}
else {
throw new Error('PreviewInput must contain either url or blob.')
}
state.kind = resolvePreviewKind(state.fileName, state.fileType)
state.status = state.kind === 'unknown' ? 'unsupported' : 'loading'
state.visible = true
}
function close() {
state.visible = false
// 让关闭动画完成,避免内容瞬间消失。
clearTimer = window.setTimeout(reset, 250)
}
function setReady() {
state.status = 'ready'
}
function setError(error: unknown) {
state.status = 'error'
state.errorMessage = error instanceof Error ? error.message : '文件加载失败'
}
return {
state: readonly(state),
open,
close,
setReady,
setError,
dispose: reset,
}
}
这段代码有两个容易忽略的点。
第一,close() 不能简单地立即 revokeObjectURL()。如果弹窗正在执行关闭动画,浏览器可能短暂显示空白内容。延迟清理可以避免闪烁。 第二,新的 open() 必须取消旧的清理定时器。否则用户快速关闭后又打开新文件,旧定时器可能误删新文件的状态。
如果预览实例绑定在某个组件内,应在组件卸载时调用 dispose();如果它是全局单例,则应由承载预览弹窗的根组件负责销毁。
五、第三步:格式识别不是一个 split('.')
扩展名是最便捷的线索,但不可靠。更合理的优先级是:
sql
调用方显式指定的类型
>
服务端返回的 MIME 类型
>
文件名扩展名
>
unknown
可以先把常见类型归并成渲染类型:
typescript
const imageExtensions = new Set([
'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg',
])
const textExtensions = new Set([
'txt', 'md', 'json', 'xml', 'csv', 'log', 'yaml', 'yml',
])
export function resolvePreviewKind(
fileName: string,
fileType = '',
): PreviewKind {
const type = fileType.toLowerCase()
const ext = getExtension(fileName).toLowerCase()
const value = type || ext
if (value === 'pdf' || value === 'application/pdf')
return 'pdf'
if (value === 'docx'
|| value.includes('wordprocessingml.document'))
return 'docx'
if (['xls', 'xlsx'].includes(value)
|| value.includes('spreadsheet'))
return 'spreadsheet'
if (value.startsWith('image/') || imageExtensions.has(value))
return 'image'
if (value.startsWith('text/')
|| ['application/json', 'application/xml'].includes(value)
|| textExtensions.has(value))
return 'text'
if (['zip', 'application/zip'].includes(value))
return 'archive'
return 'unknown'
}
这个函数的职责是决定"交给哪个渲染器",不是验证文件安全性。
例如,攻击者可以把 HTML 文件重命名为 photo.jpg。真正的格式校验应在服务端做,包括响应 Content-Type、文件魔数、文件大小、压缩包解压大小和恶意内容扫描。
六、第四步:一个格式对应一个渲染策略
一个成熟的预览平台不应该把所有格式都塞进 iframe。不同格式的合理策略并不相同。
| 文件类型 | 推荐策略 | 需要注意的问题 |
|---|---|---|
| PDF 渲染组件或浏览器内置预览 | CORS、分页、缩放、大文件 | |
| DOCX | 前端 Office 渲染器或服务端转 PDF | 复杂排版兼容性 |
| XLS/XLSX | 表格渲染器或服务端转换 | 公式、图表、超大工作簿 |
| DOC/PPT | 服务端转换或受控第三方预览 | 浏览器原生支持很弱 |
| 图片 | <img> 或图片查看器 |
大图、SVG 安全、旋转缩放 |
| 文本/日志 | fetch() 后使用 <pre> |
字符集、文件大小、搜索高亮 |
| ZIP | 读取目录并展示文件列表 | 不要默认解压和渲染内部所有文件 |
在 Vue 中,预览容器只做分发,不把每种格式的加载逻辑揉成一个大组件:
ini
<script setup lang="ts">
const props = defineProps<{
state: PreviewState
}>()
const emit = defineEmits<{
ready: []
error: [error: Error]
download: []
}>()
</script>
<template>
<PdfPreview
v-if="props.state.kind === 'pdf'"
:src="props.state.url"
@ready="emit('ready')"
@error="emit('error', $event)"
/>
<DocxPreview
v-else-if="props.state.kind === 'docx'"
:src="props.state.url"
@ready="emit('ready')"
@error="emit('error', $event)"
/>
<SpreadsheetPreview
v-else-if="props.state.kind === 'spreadsheet'"
:src="props.state.url"
@ready="emit('ready')"
@error="emit('error', $event)"
/>
<ImagePreview
v-else-if="props.state.kind === 'image'"
:src="props.state.url"
:alt="props.state.fileName"
@ready="emit('ready')"
@error="emit('error', $event)"
/>
<TextPreview
v-else-if="props.state.kind === 'text'"
:src="props.state.url"
@ready="emit('ready')"
@error="emit('error', $event)"
/>
<ArchivePreview
v-else-if="props.state.kind === 'archive'"
:src="props.state.url"
@ready="emit('ready')"
@error="emit('error', $event)"
/>
<UnsupportedPreview
v-else
:file-name="props.state.fileName"
@download="emit('download')"
/>
</template>
这种拆分有三个好处:
- 新增格式不会改坏已有分支。
- 每个渲染器都能拥有自己的加载、错误和销毁逻辑。
- 重型渲染器可以用异步组件按需加载,避免用户只看图片却下载整套 Office 依赖。
七、文本和压缩包:不要忘记取消过期请求
文本和压缩包通常需要主动请求文件内容。用户在文件列表中快速切换时,旧请求可能晚于新请求返回,从而覆盖当前界面。
最简单可靠的解决方式是 AbortController:
javascript
import { onBeforeUnmount, ref, watch } from 'vue'
export function useTextPreview(url: Ref<string>) {
const content = ref('')
const loading = ref(false)
const error = ref('')
let controller: AbortController | undefined
watch(url, async (nextUrl, _, onCleanup) => {
if (!nextUrl)
return
controller?.abort()
const requestController = new AbortController()
controller = requestController
onCleanup(() => requestController.abort())
loading.value = true
error.value = ''
content.value = ''
try {
const response = await fetch(nextUrl, {
signal: requestController.signal,
credentials: 'omit',
})
if (!response.ok)
throw new Error(`HTTP ${response.status}`)
content.value = await response.text()
}
catch (err) {
if ((err as DOMException).name !== 'AbortError') {
error.value = err instanceof Error ? err.message : '文本加载失败'
}
}
finally {
// 只有当前请求仍是最新请求时,才允许它修改界面状态。
if (controller === requestController) {
loading.value = false
}
}
}, { immediate: true })
onBeforeUnmount(() => controller?.abort())
return { content, loading, error }
}
对于 ZIP,建议只读取目录信息,例如文件名、未压缩大小和修改时间。不要在用户没有明确操作时自动展开、解压和预览内部文件。RAR、7z、TAR 等格式需要不同解析器,不能因为它们都属于"压缩包"就宣称拥有相同支持能力。
八、跨域:开发代理不是生产方案
文本、压缩包、Office 渲染器通常需要使用 fetch() 或 XHR 读取文件。只要文件域名不同,就会受到 CORS 限制。
本地开发可以通过 Vite 代理方便调试:
php
export default defineConfig({
server: {
proxy: {
'/file-proxy': {
target: 'https://files.example.com',
changeOrigin: true,
rewrite: path => path.replace(/^/file-proxy/, ''),
},
},
},
})
但这不是生产方案。生产环境通常有两条更可靠的路径:
- 文件服务直接配置精确的 CORS 响应头,并使用短时效签名 URL。
- 由业务后端或网关提供文件代理,统一鉴权、审计、响应头和域名白名单。
第二种方案尤其需要防范 SSRF。代理接口不能接受任意 url 后直接请求,至少要限制协议、端口、目标域名、重定向次数和文件大小。
可以将前端访问层抽象出来,让渲染器不关心代理规则:
javascript
export async function fetchPreviewFile(
url: string,
signal?: AbortSignal,
) {
const previewUrl = getPreviewUrl(url)
const response = await fetch(previewUrl, {
signal,
credentials: 'omit',
headers: { Accept: '*/*' },
})
if (!response.ok) {
throw new Error(`File request failed: ${response.status}`)
}
return response
}
getPreviewUrl() 可以在不同项目中替换为"直接返回签名 URL""映射到网关地址"或"开发环境代理地址",预览组件不需要变化。
九、第三方 Office 预览的正确打开方式
老版本 DOC、PPT 等格式在浏览器端很难稳定解析,第三方 Office 预览服务确实能降低接入门槛。
但它有一个无法绕过的前提:第三方服务必须能访问原始文件 URL。也就是说,文件通常需要具备公网可达性,且其 URL 可能暴露给外部服务。
如果确定要使用,至少做到两件事:
javascript
export function createOfficePreviewUrl(fileUrl: string) {
const params = new URLSearchParams({
src: fileUrl,
})
return `https://view.officeapps.live.com/op/embed.aspx?${params}`
}
- 使用
URLSearchParams,避免手动encodeURIComponent()后再被重复编码。 - 把第三方预览做成可选降级策略,而不是默认策略。
对于合同、个人信息、财务资料等敏感文件,更合适的路径是服务端鉴权后转换为 PDF 或图片,再通过自有域名提供预览。
十、状态设计:失败不是边缘情况
用户能感知到的预览状态至少包括:
rust
idle -> loading -> ready
|
+-> error -> retry -> loading
|
+-> unsupported -> download
将状态集中在容器层,所有渲染器只上报 ready 或 error:
ini
<template>
<div class="preview-shell">
<LoadingState v-if="state.status === 'loading'" />
<ErrorState
v-else-if="state.status === 'error'"
:message="state.errorMessage"
@retry="reload"
@download="download"
/>
<UnsupportedState
v-else-if="state.status === 'unsupported'"
:file-name="state.fileName"
@download="download"
/>
<PreviewRenderer
v-else-if="state.visible"
:state="state"
@ready="setReady"
@error="setError"
@download="download"
/>
</div>
</template>
这样做的价值不只是 UI 统一。后续接入埋点时,也可以直接记录"文件类型、文件大小、加载耗时、失败阶段和渲染器名称",快速判断问题来自网络、权限、文件格式还是第三方服务。
十一、独立预览页:让其他系统也能复用
除了弹窗组件,可以提供一个独立预览页,供 iframe、新窗口或其他前端应用接入:
ini
/file-preview
?url=https%3A%2F%2Ffiles.example.com%2Fguide.pdf
&fileName=guide.pdf
&fileType=pdf
&title=文档预览
&hideDownload=1
建议只暴露少量参数:
| 参数 | 用途 |
|---|---|
url |
文件地址,必填 |
fileName |
展示名称和类型识别依据 |
fileType |
明确指定格式 |
title |
页面标题 |
hideDownload |
仅控制交互层是否显示下载按钮 |
mode |
可选,例如 inline、dialog、office |
这里仍然要强调:hideDownload=1 不是权限控制。只要浏览器拿到了有效 URL,用户依然可能直接请求该文件。真正的下载权限必须由文件服务校验。
十二、上线前检查清单
在把预览能力接入更多项目之前,建议逐项检查:
- URL、Blob、受保护文件三种输入是否都能工作。
- 每次切换文件、关闭弹窗和卸载组件时,Blob URL 是否被释放。
- 文本和归档请求是否支持超时与取消。
- 文件类型判断是否有显式类型和 MIME 类型兜底。
- 超大文本、Excel、PDF 和压缩包是否有大小阈值。
- SVG、HTML 等可能承载脚本的文件是否有单独策略。
- CORS 是否由生产文件服务或网关保证,而不是依赖开发代理。
- 代理接口是否限制域名并防范 SSRF。
- 第三方 Office 预览是否有开关和敏感文件限制。
- 错误、重试、下载和不支持格式是否都有明确体验。
- 是否记录预览成功率、耗时和失败原因。
结语
文件预览平台的价值,不在于列出多少个"支持的扩展名",而在于能否将文件来源、格式差异、加载状态和安全边界组织成稳定的结构。
一个值得复用的方案通常具有这些特征:
- 用统一状态接住 URL 和 Blob。
- 用格式分发替代万能 iframe。
- 将渲染、获取文件和业务权限拆开。
- 将错误、下载和不支持格式当作主流程的一部分。
- 将跨域、鉴权、大文件和第三方服务当作架构问题,而不是组件细节。
当预览能力从一个页面里的附件按钮,成长为多个产品共享的基础组件时,前期多做一点分层,后续新增格式、替换存储服务、调整鉴权规则,都会轻松很多。