1.5K Star!Open File Viewer 深度解析:让 OA/ERP 附件预览不再开新窗口,110+ 文件格式一网打尽
做业务系统的同学都有一个绕不开的痛点:附件预览。
OA 系统里有合同 PDF、Excel 报表、Word 方案;ERP 里有图纸 CAD、压缩包、邮件 eml;网盘里有图片、视频、3D 模型;开发平台里有源码、日志、Markdown。每一个格式背后都是一套独立的预览方案:PDF 用 pdf.js,Office 用服务端转换或 Office Online,图片视频靠浏览器原生,CAD 要接专业 SDK......最后页面被一堆 iframe、新窗口、第三方服务切得支离破碎,主题不统一、容器控不住、微前端里还会莫名卡死。
xushanpei/open-file-viewer 就是冲着这个痛点来的:一个框架无关(framework-agnostic)的嵌入式文件预览 SDK,把上面所有这些格式塞进你提供的「一个稳定容器」里,并且同一套核心能力同时支持 Vanilla JS、React、Vue、Svelte。
仓库地址:https://github.com/xushanpei/open-file-viewer
当前数据:⭐ 1.5K+ 、MIT 协议 、TypeScript 实现,Monorepo 多包架构。
本文带你从架构设计到关键源码细节,把这个项目扒个底朝天。
一、它到底解决了什么
README 里一句话点题:
Open File Viewer is a file preview SDK for modern web applications. It brings PDFs, Office documents, images, audio and video, archives, emails, drawings, 3D files, GIS data, and source code into one controlled container.
关键词是 controlled container(受控容器)。它和传统方案最大的区别:
| 传统做法 | Open File Viewer |
|---|---|
| 每个格式开一个 iframe / 新窗口 | 全部渲染进你指定的 DOM 容器 |
| 主题、工具栏、状态各自为政 | 统一的加载/错误/不支持/下载/工具栏/主题 |
| React 项目硬塞 jQuery 插件 | core 与框架无关,React/Vue/Svelte 只是薄壳 |
| 微前端里 JSZip 永久卡加载 | 自动检测沙箱并修复 setImmediate |
| 大文件直接拖垮页面 | 文本大文件保护、懒渲染、分页 |
它的定位不是「又一个 PDF.js Demo」,而是一个能随业务长期演进的文件预览基座。
二、核心架构:一个 createViewer 引擎
整个 SDK 的入口只有一行:
ts
import { createViewer } from "@open-file-viewer/core";
const viewer = createViewer({
container: "#viewer",
file: fileOrUrl,
fileName: "contract.pdf",
width: "100%",
height: "70vh",
fit: "contain",
toolbar: true,
theme: "auto",
plugins
});
createViewer(options) 返回 FileViewer,对外暴露 reload / next / previous / goTo / goToPage / getCurrentIndex / resize / destroy。
看完 packages/core/src/viewer.ts(约 1659 行),它的引擎设计有几个非常值得借鉴的工程细节。
1. renderToken:用「自增令牌」防竞态
多文件切换、reload、 destroyed 时,最怕旧渲染覆盖新渲染。它用了一个自增 token:
ts
let renderToken = 0;
let renderAbortController: AbortController | undefined;
const renderFile = async (file, token = ++renderToken) => {
if (destroyed || token !== renderToken) return; // 不是最新请求,直接丢弃
destroyPreviewInstance(currentInstance);
currentInstance = undefined;
renderAbortController?.abort(); // 中止上一次渲染
const abortController = new AbortController();
renderAbortController = abortController;
viewport.replaceChildren();
setLoading(true);
// ...
const plugin = await findPlugin(plugins, file);
if (destroyed || token !== renderToken) return; // 中途被取代,放弃
const nextInstance = await plugin.render({ /* ctx 携带 signal */ });
// ...
};
每次新渲染都 ++renderToken,异步回调里反复校验 token === renderToken,确保只有最后一次请求的结果会落盘 。同时每个 ctx 都带着 signal: abortController.signal,插件内部可以监听 abort 提前退出。
2. 插件选择:findPlugin 顺序匹配
ts
async function findPlugin(plugins, file) {
for (const plugin of plugins) {
if (await plugin.match(file)) return plugin;
}
return fallbackPlugin(); // 没有任何插件命中 → 兜底
}
注意 createViewer 内部会把 fallbackPlugin() 永远追加到插件列表末尾([...(options.plugins || []), fallbackPlugin()]),所以即使宿主没配 fallback,也不会白屏。
3. 容器自适应:ResizeObserver + 主题
ts
const resizeObserver = observeResize(container, resize);
// resize 时把 viewport 尺寸传给当前实例的 instance.resize(size)
尺寸支持 px / % / vh / vw / rem / calc(),容器一变就自动重排。主题 light / dark / auto,auto 走 prefers-color-scheme。
4. 打印:把预览「搬进」隐藏 iframe
viewer.ts 里有一段相当硬核的打印实现(printPreview + waitForPrintResources):它创建一个隐藏 iframe,把 viewport 的 DOM 深拷贝过去,等待图片、样式表、Canvas(copyCanvasContent 把 canvas 像素 drawImage 到新 canvas)就绪后调用 iframe 的 print()。还设了 15 秒资源等待超时和 5 分钟清理超时------这是「应用级」SDK 才会考虑的细节。
三、插件协议:每个格式一个 match + render
整个 SDK 的灵魂是 PreviewPlugin 接口(packages/core/src/types.ts):
ts
export interface PreviewPlugin {
name: string;
match: (file: PreviewFile) => boolean | Promise<boolean>;
render: (ctx: PreviewContext) => Promise<PreviewInstance> | PreviewInstance;
}
export interface PreviewInstance {
resize?: (size: PreviewSize) => void;
goToPage?: (page: number) => boolean; // 1-based 分页
command?: (command: PreviewCommand) => void | boolean;
canCommand?: (command: PreviewCommand) => boolean;
preparePrint?: () => void | Promise<void>;
destroy: () => void; // 必须清理事件/URL/Canvas/WebGL
}
约定很克制:插件只回答两个问题 ------「这文件归我吗(match)」和「怎么渲染进 ctx.viewport」(render)。render 返回的 instance 必须实现 destroy() 清理副作用(object URL、定时器、WebGL 上下文)。
30+ 插件一览(packages/core/src/plugins/)
| 类别 | 插件 | 代表格式 |
|---|---|---|
| 图像 | imagePlugin() |
jpg/png/gif/webp/avif/svg/bmp/tiff/heic/heif |
| 视频 | videoPlugin() |
mp4/webm/mov/mkv/flv/wmv/m3u8/m2ts |
| 音频 | audioPlugin() |
mp3/wav/flac/opus/mid/wma |
| 文本/代码 | textPlugin() |
txt/md/json/yaml/xml/csv/js/ts/vue/py/go/rs/sql/sh... |
| PDF/电子书 | pdfPlugin() / epubPlugin() / xpsPlugin() |
pdf/epub/xps/oxps |
| Office | officePlugin() |
doc/docx/xls/xlsx/ppt/pptx/wps/et/dps/odt |
| OFD | ofdPlugin() |
ofd(国产版式文档) |
| 压缩包 | archivePlugin() |
zip/rar/7z/tar/gz/tgz/bz2/xz |
| 邮件 | emailPlugin() |
eml/msg/mbox |
| 绘图/白板 | drawingPlugin() |
drawio/excalidraw/tldraw |
| 思维导图 | xmindPlugin() |
xmind |
| CAD/工程 | cadPlugin() |
dxf/dwg/dwf /step/iges/ifc/skp/sldprt/gds/oas/oasis |
| 3D | model3dPlugin() |
gltf/glb/obj/stl/fbx/3mf/usd/usdz |
| GIS | gisPlugin() |
geojson/topojson/kml/kmz/gpx/shp |
| 资源识别 | assetPlugin() |
ttf/woff2/psd/ai/sqlite/wasm/parquet/avro |
| 兜底 | fallbackPlugin() |
所有未命中格式 |
插件顺序很重要 :第一个匹配的插件渲染文件。比如 csv 既能匹配 textPlugin 也能匹配 officePlugin,想表格化预览就把 officePlugin 放前面。fallbackPlugin 是终止性的,不能放在原生插件之前。
四、最有技术含量的亮点:微前端 setImmediate 死穴修复
这是我在 README 和源码里最想单拎出来讲的一段------因为它踩的是真实生产事故。
问题来源:qiankun#2589
在 qiankun / micro-app 这类微前端沙箱里,子应用卸载时会拆除 window 的 message 事件监听 。而 JSZip 依赖的 setimmediate polyfill 正是靠 window.message 来驱动回调的。结果:
JSZip.loadAsync永远不 resolve → 所有基于 zip 的预览(docx / xlsx / pptx / epub / ofd)永久卡在「加载中」。
createViewer() 在初始化时主动检测沙箱标志:
ts
function isMicroFrontendSandbox(win) {
const flags = win;
return Boolean(
flags.__POWERED_BY_QIANKUN__ ||
flags.__MICRO_APP_ENVIRONMENT__ ||
flags.__POWERED_BY_WUJIE__ ||
flags.__GARFISH__
);
}
一旦命中,就安装一个不依赖 window 监听的调度器 (packages/core/src/sandbox-compat.ts):
ts
function createScheduler(win) {
let nextHandle = 1;
const pending = new Map();
// MessageChannel 的 port 是模块私有,沙箱卸载 window message 监听也破坏不了它
const channel = new MessageChannel();
channel.port1.onmessage = (e) => {
const task = pending.get(e.data);
if (task) { pending.delete(e.data); task(); }
};
return {
schedule(callback, args) {
const handle = nextHandle++;
pending.set(handle, () => callback(...args));
channel.port2.postMessage(handle); // 用 MessageChannel 自身端口驱动,绕开 window
return handle;
},
cancel(handle) { pending.delete(handle); }
};
}
export function ensureSafeAsyncScheduling(win = window) {
if (!win || !isMicroFrontendSandbox(win)) return;
if (win.setImmediate?.[SAFE_SET_IMMEDIATE_FLAG]) return;
const scheduler = createScheduler(win);
const safeSetImmediate = (cb, ...args) => scheduler.schedule(cb, args);
safeSetImmediate[SAFE_SET_IMMEDIATE_FLAG] = true;
win.setImmediate = safeSetImmediate; // 覆盖成 MessageChannel 版
win.clearImmediate = (h) => scheduler.cancel(h);
}
巧思在于:用 MessageChannel 自己的 port 来驱动 setImmediate,而不是 window 的 message 事件。微前端沙箱拆的是 window 上的监听,动不了模块私有的 channel port,于是 zip 类预览在沙箱里也能正常 resolve。0.1.27 之前需要手动在子应用入口打补丁,现在 createViewer 全自动。
五、文件检测与归一化:detect.ts
normalizeFile(source, fileName, mimeType) 把 File / Blob / string(URL) / ArrayBuffer 统一成 PreviewFile:
ts
export interface PreviewFile {
source: PreviewSource;
name: string;
extension: string;
mimeType: string;
size?: number;
url?: string;
blob?: Blob;
}
它内部维护了一张超完整的扩展名→MIME 映射表 (覆盖图像/音视频/文档/代码/压缩包/CAD/3D/GIS/字体等几百个条目),getExtension() 还能处理 URL 里的 ?/#/! 后缀。
一个精细的设计:显式 MIME 优先于扩展名 。如果你传 mimeType: "text/plain",它会把 .md 当纯文本展示;传 text/markdown 才渲染。用 WeakSet<PreviewFile> 记录哪些文件带了显式 MIME,避免重复判断。
isTextLike() 则用「MIME 白名单 + 扩展名白名单 + 无扩展名文件名白名单(README/CHANGELOG/Dockerfile...)+ 词干白名单(readme/changelog/license...)」四重规则判断是不是文本,决定了走代码高亮还是二进制预览。
六、四框架适配层:core 共享,壳子极薄
Monorepo 结构:
packages/
core/ # 框架无关核心 + 全部插件
react/ # React 适配
vue/ # Vue 适配
svelte/ # Svelte 适配
适配层非常薄。以 Vue (packages/vue/src/index.ts,211 行)为例,它就是个 defineComponent:
vue
<script setup lang="ts">
import { OpenFileViewer } from "@open-file-viewer/vue";
import { imagePlugin, pdfPlugin, officePlugin, textPlugin } from "@open-file-viewer/core";
import "@open-file-viewer/core/style.css";
import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url";
const plugins = [ imagePlugin(), textPlugin(), pdfPlugin({ workerSrc: pdfWorkerSrc }), officePlugin() ];
</script>
<template>
<OpenFileViewer :file="file" :file-name="file.name" width="100%" height="640px"
fit="contain" toolbar theme="auto" :plugins="plugins" />
</template>
Vue 适配层做的事:
- 把所有 props(
file/files/width/height/fit/zoom/plugins/toolbar/theme/...)透传给createViewer; - 用
watch([...所有相关 props], mount)在属性变化时销毁旧 viewer 重建; onMounted(mount)挂载、onBeforeUnmount里viewer.destroy()清理;- 通过
Teleport把自定义工具栏 slot 渲染进 viewer 创建的 toolbar 容器; expose({ goToPage })暴露少量命令式 API。
React / Svelte 同理,只是把 watch + onBeforeUnmount 换成对应的生命周期。这种「核心在 core,框架壳只做生命周期桥接」的拆分,是它敢宣称支持四种框架的根本原因------新增第五种框架只需再写一个薄壳。
七、复杂格式策略:渐进增强,不追求一步到位
对简单格式(图片/音视频/文本),浏览器原生就能渲染,直接本地渲染。对复杂格式,README 明确是「渐进增强(progressive enhancement)」路线------先进入受控预览路径,再逐步集成 WASM / 专用解析器 / 服务端转换。
PDF:pdfjs-dist + 远程回退策略
pdfPlugin({ workerSrc }) 集成 pdf.js。它有个精细的远程 PDF 回退脚本策略 webFallbackScripts:
"auto"(默认):跨域 PDF URL 给allow-scripts但保持沙箱;同源 URL 默认禁脚本,避免脚本执行 + 同源访问叠加;"never":严格沙箱;"always":只信任同源 PDF 端点。
另外还检测加密 PDF (isEncryptedError + createEncryptedFallback),给出专门的「文件已加密」提示而非白屏。
Office:本地 OLE 解析 + 服务端转换双路
officePlugin() 对现代 docx/xlsx/pptx 用前端解析渲染;对老版 ppt/pps(PowerPoint 二进制格式),用本地 OLE 解析恢复幻灯片几何、定位文本、master 位图、JPEG/PNG/TIFF 资源、常见压缩 EMF/WMF 图元------不上传文件 。要像素级保真时,用 officePlugin({ convert }) 走服务端转换。
CAD:三路渲染 + 外部引擎接管
cadPlugin() 最复杂(源码 10 万字节级),提供三档:
- 高保真 WebGL 路径 :
webglDwg在 Worker 里解析 DWG,绘制图层/块/填充/线型/文字;失败会显式报错而非静默切 SVG; - 内置 LibreDWG WASM 路径:自动尝试 LibreDWG 解析 DWG 模型空间线框,不可靠且有缩略图就显示缩略图,再不行退到元数据/版本提示/结构探测/转换指引;
- 外部增强路径 :
cadPlugin({ binaryRenderer })让你接入自有 CAD 引擎(CADViewer / MxCAD / 后端转 PNG/PDF/SVG/DXF),binaryRenderer优先级最高,返回实例即完全接管。
八、工具栏、主题与 i18n
toolbar: true 开启默认工具栏(多文件导航/zoom/rotate/download/fullscreen/print/search)。可以深度定制:
ts
createViewer({
container: "#viewer",
file,
toolbar: {
order: ["search", "zoom-out", "zoom-in", "rotate-right", "download", "fullscreen"],
labels: { download: "Download" },
icons: { download: '<svg viewBox="0 0 24 24"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14"/></svg>' },
actions: [
{ id: "approve", label: "Approve", onClick(ctx) { openApprovalDialog(ctx.file); } }
]
// 或完全接管:render(ctx) { return customBar; }
},
plugins
});
工具栏 render(ctx) 上下文暴露 file/index/length/previous()/next()/goToPage()/command()/download()/fullscreen()/print()/search()。React/Vue/Svelte 还提供框架原生 slot(#toolbar / renderToolbar / slot="toolbar"),图标按钮自动生成 .ofv-toolbar-icon、.ofv-toolbar-label 类方便样式覆盖。
i18n 在 messages.ts:defaultMessages 内置 zh-CN / en-US 两套,覆盖 loading / 不支持 / 下载 / 文本行数 / LRC 歌词 / Office 旧版二进制 / PDF 加密等上百个 key。可以通过 locale 或 messages 覆盖。
九、快速上手
bash
pnpm add @open-file-viewer/core @open-file-viewer/vue
pnpm add pdfjs-dist # 用 PDF 时才需要
ts
import { createViewer, imagePlugin, videoPlugin, audioPlugin, textPlugin,
pdfPlugin, officePlugin, archivePlugin, emailPlugin, drawingPlugin,
xmindPlugin, cadPlugin, model3dPlugin, gisPlugin, fallbackPlugin }
from "@open-file-viewer/core";
import "@open-file-viewer/core/style.css";
import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url";
const viewer = createViewer({
container: "#viewer",
file: fileOrUrl,
fileName: "contract.pdf",
width: "100%",
height: "70vh",
fit: "contain",
toolbar: true,
theme: "auto",
plugins: [
imagePlugin(), videoPlugin(), audioPlugin(), textPlugin(),
pdfPlugin({ workerSrc: pdfWorkerSrc }), officePlugin(), archivePlugin(),
emailPlugin(), drawingPlugin(), xmindPlugin(), cadPlugin(),
model3dPlugin(), gisPlugin(), fallbackPlugin()
]
});
挂载前想先判断支不支持?用 isPreviewSupported(source, plugins, { fileName, mimeType })------它和 createViewer 走完全一样的归一化 + 顺序 match ,但不挂载 DOM、不调 render(),fallbackPlugin 不算原生支持。
Roadmap(版本节奏)
| 版本 | 重点 |
|---|---|
| 0.1.x | 核心插件系统、容器内预览、四框架集成、基础多格式 |
| 0.2.x | 工具栏、主题、图像交互、PDF 搜索、统一状态、兜底 |
| 0.3.x | Markdown/代码阅读器、增强 Office 表格与文档 |
| 0.4.x | OFD、邮件、压缩包、绘图文件、国内高频格式增强 |
| 0.5.x | CAD/3D/GIS、专用解析器、服务端转换协作 |
| 1.0.0 | 稳定 API、完整文档站、视觉回归测试、插件开发指南 |
十、总结:为什么值得关注
- 真正框架无关:不是「React 版 + 复制粘贴 Vue 版」,而是 core 一份、四框架薄壳桥接生命周期。换技术栈不丢预览能力。
- 容器优先:所有内容进你给的 DOM,不开新窗口、不抢焦点,主题工具栏统一,契合 OA/ERP/网盘/低代码等「附件中心」场景。
- 插件协议极简 :
match + render两个方法即可扩展新格式,行为易替换、裁剪、增强。 - 踩过真实生产坑 :微前端
setImmediate死穴用 MessageChannel 调度器根治,这是文档里敢写「qiankun#2589」的底气。 - 工程素养在线:renderToken 防竞态、AbortController 销毁、ResizeObserver 自适应、打印资源等待超时、大文件保护、显式 MIME 优先------都是「能上线」的 SDK 才会考虑的细节。
- 渐进增强不画饼:复杂格式先入受控路径,CAD/Office 留好 WASM / 服务端转换 / 外部引擎接管口子。
一句话:如果你在做任何需要附件预览的业务系统,它目前是这个赛道里架构最干净、框架覆盖最全、微前端坑填得最实的开源选择之一。 顺手去 GitHub 点个 star,就是作者持续迭代最直接的动力。