跳到主要内容

设计文件预览系统

需求分析

文件预览系统是企业协作、网盘、OA 系统中的核心功能模块。用户上传文件后,无需下载安装本地软件即可在浏览器中直接预览各种格式的文件。一个完善的文件预览系统需要支持多种文件格式、保证渲染质量、兼顾加载性能,并提供安全防护能力。

功能需求

功能模块支持格式核心能力
PDF 预览.pdf文字渲染、注释层、搜索高亮、缩略图导航、虚拟滚动
Office 预览.docx .xlsx .pptx服务端转换、排版还原、表格渲染、幻灯片播放
图片预览.jpg .png .webp .svg .gif缩放、旋转、拖拽、手势控制、多图轮播
视频预览.mp4 .webm .m3u8HLS 流播放、缩略图预览、进度条预览
代码预览.ts .js .py .go语法高亮、行号显示、代码折叠、主题切换
Markdown 预览.md实时渲染、TOC 目录、代码块高亮、数学公式
文本预览.txt .log .csv大文件分片加载、编码检测、表格化展示
3D 模型预览.glb .gltf .objThree.js 渲染、旋转交互、光照控制

非功能需求

面试关键点

文件预览系统的非功能需求是面试中的加分项——安全防护、大文件处理、缓存策略是面试官最关注的三个方面。

需求目标实现手段
加载性能首屏 < 2s,大文件分片加载分页渲染、虚拟滚动、Web Worker 解析
格式兼容覆盖 95% 以上常见格式前端原生 + 服务端转换兜底
安全防护防下载、防截屏、水印追溯Token 鉴权、Canvas 水印、暗水印
大文件支持支持 100MB+ 文件流畅预览流式渲染、分页加载、CDN 加速
多端适配PC 端 + 移动端手势交互响应式布局、触摸事件适配
缓存策略减少重复转换和下载转换结果缓存、CDN 缓存、浏览器缓存

整体架构

系统全景架构

预览决策流程


核心模块设计

预览容器核心设计

预览容器负责根据文件类型动态加载对应的预览器,采用策略模式实现格式路由。

preview/FilePreviewContainer.tsx
import { useMemo, lazy, Suspense } from 'react';

// 文件类型枚举
enum FileType {
PDF = 'pdf',
WORD = 'word',
EXCEL = 'excel',
PPT = 'ppt',
IMAGE = 'image',
VIDEO = 'video',
CODE = 'code',
MARKDOWN = 'markdown',
UNKNOWN = 'unknown',
}

// 文件元信息
interface FileInfo {
id: string;
name: string;
url: string;
size: number;
mimeType: string;
type: FileType;
convertedUrl?: string; // 转换后的 URL
}

// 预览器注册表(策略模式)
const viewerRegistry: Record<FileType, React.LazyExoticComponent<React.FC<ViewerProps>>> = {
[FileType.PDF]: lazy(() => import('./viewers/PDFViewer')),
[FileType.WORD]: lazy(() => import('./viewers/OfficeViewer')),
[FileType.EXCEL]: lazy(() => import('./viewers/ExcelViewer')),
[FileType.PPT]: lazy(() => import('./viewers/PPTViewer')),
[FileType.IMAGE]: lazy(() => import('./viewers/ImageViewer')),
[FileType.VIDEO]: lazy(() => import('./viewers/VideoViewer')),
[FileType.CODE]: lazy(() => import('./viewers/CodeViewer')),
[FileType.MARKDOWN]: lazy(() => import('./viewers/MarkdownViewer')),
[FileType.UNKNOWN]: lazy(() => import('./viewers/FallbackViewer')),
};

interface ViewerProps {
file: FileInfo;
watermark?: WatermarkConfig;
}

interface WatermarkConfig {
text: string;
fontSize?: number;
opacity?: number;
rotate?: number;
}

// 文件类型检测
function detectFileType(fileName: string, mimeType: string): FileType {
const ext = fileName.split('.').pop()?.toLowerCase() ?? '';

const typeMap: Record<string, FileType> = {
pdf: FileType.PDF,
doc: FileType.WORD, docx: FileType.WORD,
xls: FileType.EXCEL, xlsx: FileType.EXCEL, csv: FileType.EXCEL,
ppt: FileType.PPT, pptx: FileType.PPT,
jpg: FileType.IMAGE, jpeg: FileType.IMAGE, png: FileType.IMAGE,
gif: FileType.IMAGE, webp: FileType.IMAGE, svg: FileType.IMAGE,
mp4: FileType.VIDEO, webm: FileType.VIDEO, m3u8: FileType.VIDEO,
ts: FileType.CODE, js: FileType.CODE, py: FileType.CODE,
go: FileType.CODE, java: FileType.CODE, rs: FileType.CODE,
md: FileType.MARKDOWN, markdown: FileType.MARKDOWN,
};

return typeMap[ext] ?? FileType.UNKNOWN;
}

function FilePreviewContainer({ fileId }: { fileId: string }) {
const { file, loading, error } = useFileInfo(fileId);

const ViewerComponent = useMemo(() => {
if (!file) return null;
return viewerRegistry[file.type] ?? viewerRegistry[FileType.UNKNOWN];
}, [file?.type]);

if (loading) return <PreviewSkeleton />;
if (error || !file || !ViewerComponent) return <PreviewError />;

return (
<Suspense fallback={<PreviewSkeleton />}>
<ViewerComponent
file={file}
watermark={{ text: 'confidential', opacity: 0.15 }}
/>
</Suspense>
);
}
策略模式的好处

通过 viewerRegistry 注册表统一管理所有预览器组件,新增文件类型只需添加一个映射关系即可,无需修改主容器逻辑,符合开放封闭原则


关键技术实现

一、PDF 预览(PDF.js)

PDF.js 是 Mozilla 开源的 PDF 渲染引擎,基于 Canvas 实现高保真 PDF 渲染。

PDF.js 渲染架构

PDF.js 核心渲染实现

viewers/PDFViewer.tsx
import * as pdfjsLib from 'pdfjs-dist';
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';

// 设置 Worker 路径(Web Worker 中解析 PDF 不阻塞主线程)
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';

interface PDFViewerState {
doc: PDFDocumentProxy | null;
totalPages: number;
currentPage: number;
scale: number;
renderedPages: Set<number>; // 已渲染的页面集合
}

class PDFRenderer {
private state: PDFViewerState = {
doc: null,
totalPages: 0,
currentPage: 1,
scale: 1.5,
renderedPages: new Set(),
};

/**
* 加载 PDF 文档
* 支持 URL 和 ArrayBuffer 两种方式
*/
async loadDocument(source: string | ArrayBuffer): Promise<void> {
const loadingTask = pdfjsLib.getDocument({
url: typeof source === 'string' ? source : undefined,
data: typeof source !== 'string' ? source : undefined,
// 启用分页加载,适合大文件
disableAutoFetch: true,
disableStream: false,
// 字体资源路径
cMapUrl: '/cmaps/',
cMapPacked: true,
});

this.state.doc = await loadingTask.promise;
this.state.totalPages = this.state.doc.numPages;
}

/**
* 渲染单页 PDF
* 包含三个层:Canvas 层(内容)+ 文字层(选择)+ 注释层(链接)
*/
async renderPage(pageNum: number, container: HTMLDivElement): Promise<void> {
if (!this.state.doc || this.state.renderedPages.has(pageNum)) return;

const page: PDFPageProxy = await this.state.doc.getPage(pageNum);
const viewport = page.getViewport({ scale: this.state.scale });

// 1. Canvas 渲染层
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d')!;
// 高清屏适配
const dpr = window.devicePixelRatio || 1;
canvas.width = viewport.width * dpr;
canvas.height = viewport.height * dpr;
canvas.style.width = `${viewport.width}px`;
canvas.style.height = `${viewport.height}px`;
context.scale(dpr, dpr);

await page.render({ canvasContext: context, viewport }).promise;

// 2. 文字层 —— 用于文本选择和搜索高亮
const textContent = await page.getTextContent();
const textLayerDiv = document.createElement('div');
textLayerDiv.className = 'textLayer';
textLayerDiv.style.width = `${viewport.width}px`;
textLayerDiv.style.height = `${viewport.height}px`;

// 使用 PDF.js 内置的文字层渲染
const { TextLayer } = await import('pdfjs-dist/web/pdf_viewer.mjs');
const textLayer = new TextLayer({
textContentSource: textContent,
container: textLayerDiv,
viewport,
});
await textLayer.render();

// 3. 注释层 —— 用于超链接、表单交互
const annotations = await page.getAnnotations();
const annotationLayerDiv = this.createAnnotationLayer(annotations, viewport);

// 组装页面
const pageDiv = document.createElement('div');
pageDiv.className = 'pdf-page';
pageDiv.dataset.pageNumber = String(pageNum);
pageDiv.appendChild(canvas);
pageDiv.appendChild(textLayerDiv);
pageDiv.appendChild(annotationLayerDiv);
container.appendChild(pageDiv);

this.state.renderedPages.add(pageNum);
}

private createAnnotationLayer(
annotations: unknown[],
viewport: { width: number; height: number }
): HTMLDivElement {
const div = document.createElement('div');
div.className = 'annotationLayer';
div.style.width = `${viewport.width}px`;
div.style.height = `${viewport.height}px`;
// 处理各种注释类型(链接、表单、高亮等)
return div;
}
}

PDF 虚拟滚动(大文件优化)

对于上百页的 PDF,不能一次性渲染所有页面。需要结合 IntersectionObserver 实现虚拟滚动,只渲染可视区域附近的页面。

viewers/PDFVirtualScroll.ts
interface PageMeta {
pageNum: number;
height: number;
width: number;
rendered: boolean;
}

class PDFVirtualScroller {
private pages: PageMeta[] = [];
private observer: IntersectionObserver;
private renderer: PDFRenderer;
private buffer = 2; // 预渲染前后各 2 页

constructor(renderer: PDFRenderer, container: HTMLElement) {
this.renderer = renderer;

// IntersectionObserver 监听页面是否进入可视区域
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const pageNum = Number(entry.target.dataset.pageNumber);
if (entry.isIntersecting) {
// 渲染当前页及缓冲区页面
this.renderRange(pageNum - this.buffer, pageNum + this.buffer);
} else {
// 离开可视区域,销毁 Canvas 释放内存
this.destroyPage(pageNum);
}
});
},
{
root: container,
// 提前 200px 触发渲染,避免白屏
rootMargin: '200px 0px',
threshold: 0.01,
}
);
}

/**
* 初始化所有页面的占位容器
* 先计算每页的高度,创建等高的占位 div
*/
async init(totalPages: number): Promise<void> {
for (let i = 1; i <= totalPages; i++) {
const placeholder = document.createElement('div');
placeholder.className = 'pdf-page-placeholder';
placeholder.dataset.pageNumber = String(i);

// 设置占位高度(根据 PDF 页面尺寸计算)
placeholder.style.height = `${this.pages[i - 1]?.height ?? 800}px`;

this.observer.observe(placeholder);
}
}

private async renderRange(start: number, end: number): Promise<void> {
const clampedStart = Math.max(1, start);
const clampedEnd = Math.min(this.pages.length, end);

for (let i = clampedStart; i <= clampedEnd; i++) {
if (!this.pages[i - 1].rendered) {
const container = document.querySelector(
`[data-page-number="${i}"]`
) as HTMLDivElement;
if (container) {
await this.renderer.renderPage(i, container);
this.pages[i - 1].rendered = true;
}
}
}
}

/**
* 销毁不在可视区域的页面
* 释放 Canvas 内存,保留占位高度
*/
private destroyPage(pageNum: number): void {
const container = document.querySelector(
`[data-page-number="${pageNum}"]`
) as HTMLDivElement;
if (container && this.pages[pageNum - 1]?.rendered) {
// 保留 div 高度避免布局跳动,清空 Canvas
const canvas = container.querySelector('canvas');
if (canvas) {
const ctx = canvas.getContext('2d');
ctx?.clearRect(0, 0, canvas.width, canvas.height);
canvas.width = 0;
canvas.height = 0;
}
container.querySelector('.textLayer')?.remove();
this.pages[pageNum - 1].rendered = false;
}
}

destroy(): void {
this.observer.disconnect();
}
}
PDF 渲染常见坑
  1. 字体问题:PDF 嵌入字体时需要正确配置 cMapUrl,否则中文可能显示为方块
  2. 内存泄漏:页面切换时务必调用 page.cleanup() 释放 Canvas 资源
  3. 高清屏模糊:Canvas 必须按 devicePixelRatio 缩放,否则在 Retina 屏上模糊
  4. Worker 加载失败pdf.worker.js 必须从同源加载或正确配置 CORS

二、Office 文件预览

Office 文件(.docx / .xlsx / .pptx)无法在浏览器中直接渲染,需要通过服务端转换为浏览器可渲染的格式。

转换方案对比

方案转换目标排版还原度性能部署成本适用场景
LibreOfficeHTML / PDF85%~90%中等低(开源)中小规模、成本敏感
OnlyOffice在线编辑95%+中(Docker 部署)需要在线编辑能力
微软 Office Onlineiframe 嵌入100%高(商业授权)企业级、预算充足
前端纯解析Canvas / DOM60%~70%简单文档、只读预览
推荐方案

中小型项目推荐 LibreOffice 转 PDF + 前端 PDF.js 渲染 的组合方案,兼顾成本和效果。企业级项目推荐 OnlyOffice,支持编辑能力且部署相对简单。

服务端转换服务设计

server/convert/ConvertService.ts
import { exec } from 'child_process';
import { promisify } from 'util';
import Redis from 'ioredis';
import path from 'path';
import fs from 'fs/promises';

const execAsync = promisify(exec);

interface ConvertTask {
fileId: string;
sourceUrl: string;
sourceType: 'docx' | 'xlsx' | 'pptx' | 'doc' | 'xls' | 'ppt';
targetType: 'pdf' | 'html';
}

interface ConvertResult {
success: boolean;
convertedUrl?: string;
error?: string;
cached: boolean;
}

class ConvertService {
private redis: Redis;
private readonly CACHE_TTL = 7 * 24 * 60 * 60; // 缓存 7 天
private readonly TEMP_DIR = '/tmp/file-convert';
private readonly OUTPUT_DIR = '/data/converted';

constructor(redis: Redis) {
this.redis = redis;
}

/**
* 转换入口 —— 先查缓存,再执行转换
*/
async convert(task: ConvertTask): Promise<ConvertResult> {
// 1. 查询缓存
const cacheKey = `convert:${task.fileId}:${task.targetType}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
return { success: true, convertedUrl: cached, cached: true };
}

// 2. 下载源文件到临时目录
const sourcePath = await this.downloadFile(task.sourceUrl, task.fileId);

// 3. 执行转换
const outputPath = await this.executeConvert(
sourcePath,
task.sourceType,
task.targetType
);

// 4. 上传转换结果到 OSS/S3
const convertedUrl = await this.uploadToStorage(outputPath);

// 5. 写入缓存
await this.redis.setex(cacheKey, this.CACHE_TTL, convertedUrl);

// 6. 清理临时文件
await fs.unlink(sourcePath).catch(() => {});

return { success: true, convertedUrl, cached: false };
}

/**
* 使用 LibreOffice 执行文件转换
*/
private async executeConvert(
sourcePath: string,
sourceType: string,
targetType: string
): Promise<string> {
const outputDir = path.join(this.OUTPUT_DIR, Date.now().toString());
await fs.mkdir(outputDir, { recursive: true });

// LibreOffice headless 模式转换
const command = [
'libreoffice',
'--headless', // 无界面模式
'--invisible', // 不显示启动画面
'--nologo', // 不显示 Logo
'--nofirststartwizard', // 跳过首次启动向导
`--convert-to ${targetType}`, // 目标格式
`--outdir ${outputDir}`, // 输出目录
sourcePath, // 源文件路径
].join(' ');

// 设置超时,防止 LibreOffice 卡死
await execAsync(command, { timeout: 60_000 });

// 查找转换后的文件
const files = await fs.readdir(outputDir);
const converted = files.find((f) => f.endsWith(`.${targetType}`));
if (!converted) throw new Error('Conversion failed: output file not found');

return path.join(outputDir, converted);
}

private async downloadFile(url: string, fileId: string): Promise<string> {
const tempPath = path.join(this.TEMP_DIR, fileId);
// 下载文件到临时路径(实际项目使用流式下载)
// ...
return tempPath;
}

private async uploadToStorage(filePath: string): Promise<string> {
// 上传到 OSS/S3 并返回 CDN URL
// ...
return `https://cdn.example.com/converted/${path.basename(filePath)}`;
}
}

转换队列设计

对于高并发场景,需要引入消息队列来控制转换任务的并发数,避免 LibreOffice 进程过多导致服务器 OOM。

server/convert/ConvertQueue.ts
import Bull from 'bull';

interface ConvertJob {
fileId: string;
sourceUrl: string;
sourceType: string;
targetType: string;
callbackUrl: string;
}

// 使用 Bull 队列控制并发
const convertQueue = new Bull<ConvertJob>('file-convert', {
redis: { host: 'localhost', port: 6379 },
limiter: {
max: 5, // 最多同时 5 个转换任务
duration: 1000,
},
defaultJobOptions: {
attempts: 3, // 失败重试 3 次
backoff: {
type: 'exponential',
delay: 5000, // 指数退避,首次 5s
},
timeout: 120_000, // 单任务超时 2 分钟
removeOnComplete: 100, // 保留最近 100 条完成记录
},
});

// 任务处理器
convertQueue.process(5, async (job) => {
const { fileId, sourceUrl, sourceType, targetType } = job.data;

// 进度回调
await job.progress(10);

const convertService = new ConvertService(redis);
const result = await convertService.convert({
fileId,
sourceUrl,
sourceType: sourceType as ConvertTask['sourceType'],
targetType: targetType as ConvertTask['targetType'],
});

await job.progress(100);
return result;
});

// 监听完成事件,通知前端
convertQueue.on('completed', async (job, result) => {
// 通过 WebSocket 或回调通知前端转换完成
await notifyClient(job.data.callbackUrl, result);
});

三、图片预览

图片预览看似简单,但要做好缩放、旋转、拖拽、手势的流畅交互需要精心设计。

viewers/ImageViewer.ts
interface ImageViewerState {
scale: number;
rotation: number;
translateX: number;
translateY: number;
isDragging: boolean;
lastTouchDistance: number; // 双指缩放的上次距离
}

class ImageViewer {
private state: ImageViewerState = {
scale: 1,
rotation: 0,
translateX: 0,
translateY: 0,
isDragging: false,
lastTouchDistance: 0,
};

private container: HTMLElement;
private img: HTMLImageElement;
private readonly MIN_SCALE = 0.1;
private readonly MAX_SCALE = 10;

constructor(container: HTMLElement, src: string) {
this.container = container;
this.img = new Image();
this.img.src = src;
this.img.style.transformOrigin = 'center center';
this.container.appendChild(this.img);

this.bindEvents();
}

/**
* 滚轮缩放 —— 以鼠标位置为中心缩放
*/
private onWheel = (e: WheelEvent): void => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.min(
this.MAX_SCALE,
Math.max(this.MIN_SCALE, this.state.scale * delta)
);

// 以鼠标位置为中心缩放(关键数学计算)
const rect = this.container.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;

const scaleRatio = newScale / this.state.scale;
this.state.translateX = mouseX - scaleRatio * (mouseX - this.state.translateX);
this.state.translateY = mouseY - scaleRatio * (mouseY - this.state.translateY);
this.state.scale = newScale;

this.applyTransform();
};

/**
* 触摸手势 —— 支持双指缩放和单指拖拽
*/
private onTouchMove = (e: TouchEvent): void => {
if (e.touches.length === 2) {
// 双指缩放
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const distance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
);

if (this.state.lastTouchDistance > 0) {
const ratio = distance / this.state.lastTouchDistance;
this.state.scale = Math.min(
this.MAX_SCALE,
Math.max(this.MIN_SCALE, this.state.scale * ratio)
);
this.applyTransform();
}

this.state.lastTouchDistance = distance;
} else if (e.touches.length === 1 && this.state.isDragging) {
// 单指拖拽
this.state.translateX += e.touches[0].clientX;
this.state.translateY += e.touches[0].clientY;
this.applyTransform();
}
};

/**
* 旋转图片
*/
rotate(degree: number): void {
this.state.rotation = (this.state.rotation + degree) % 360;
this.applyTransform();
}

/**
* 适应容器大小
*/
fitToContainer(): void {
const containerRect = this.container.getBoundingClientRect();
const imgAspect = this.img.naturalWidth / this.img.naturalHeight;
const containerAspect = containerRect.width / containerRect.height;

if (imgAspect > containerAspect) {
this.state.scale = containerRect.width / this.img.naturalWidth;
} else {
this.state.scale = containerRect.height / this.img.naturalHeight;
}

this.state.translateX = 0;
this.state.translateY = 0;
this.applyTransform();
}

private applyTransform(): void {
// 使用 CSS transform 实现,GPU 加速不触发重排
this.img.style.transform = [
`translate(${this.state.translateX}px, ${this.state.translateY}px)`,
`scale(${this.state.scale})`,
`rotate(${this.state.rotation}deg)`,
].join(' ');
}

private bindEvents(): void {
this.container.addEventListener('wheel', this.onWheel, { passive: false });
this.container.addEventListener('touchmove', this.onTouchMove);
// 鼠标拖拽、touchstart、touchend 等事件绑定省略
}

destroy(): void {
this.container.removeEventListener('wheel', this.onWheel);
this.container.removeEventListener('touchmove', this.onTouchMove);
}
}

多图轮播

viewers/ImageGallery.ts
interface GalleryOptions {
images: string[];
initialIndex?: number;
loop?: boolean;
preloadCount?: number; // 预加载前后 N 张
}

class ImageGallery {
private images: string[];
private currentIndex: number;
private preloadedImages: Map<number, HTMLImageElement> = new Map();
private readonly preloadCount: number;

constructor(options: GalleryOptions) {
this.images = options.images;
this.currentIndex = options.initialIndex ?? 0;
this.preloadCount = options.preloadCount ?? 2;

// 预加载当前图片前后的图片
this.preloadAround(this.currentIndex);
}

/**
* 预加载策略 —— 预加载当前图片前后 N 张
*/
private preloadAround(index: number): void {
const start = Math.max(0, index - this.preloadCount);
const end = Math.min(this.images.length - 1, index + this.preloadCount);

for (let i = start; i <= end; i++) {
if (!this.preloadedImages.has(i)) {
const img = new Image();
img.src = this.images[i];
this.preloadedImages.set(i, img);
}
}

// 清理距离过远的缓存
for (const [cachedIndex] of this.preloadedImages) {
if (Math.abs(cachedIndex - index) > this.preloadCount + 1) {
this.preloadedImages.delete(cachedIndex);
}
}
}

next(): string | null {
if (this.currentIndex < this.images.length - 1) {
this.currentIndex++;
this.preloadAround(this.currentIndex);
return this.images[this.currentIndex];
}
return null;
}

prev(): string | null {
if (this.currentIndex > 0) {
this.currentIndex--;
this.preloadAround(this.currentIndex);
return this.images[this.currentIndex];
}
return null;
}
}

四、视频预览

视频预览需要支持 HLS 流式播放和缩略图预览。

viewers/VideoPreview.ts
import Hls from 'hls.js';

interface VideoPreviewConfig {
url: string;
thumbnailUrl?: string; // 缩略图雪碧图 URL
thumbnailInterval?: number; // 缩略图间隔(秒)
thumbnailWidth?: number;
thumbnailHeight?: number;
}

class VideoPreview {
private video: HTMLVideoElement;
private hls: Hls | null = null;
private config: VideoPreviewConfig;

constructor(container: HTMLElement, config: VideoPreviewConfig) {
this.config = config;
this.video = document.createElement('video');
this.video.controls = true;
this.video.preload = 'metadata';
container.appendChild(this.video);

this.initPlayer();
this.initThumbnailPreview(container);
}

private initPlayer(): void {
const url = this.config.url;

if (url.endsWith('.m3u8') && Hls.isSupported()) {
// HLS 流播放
this.hls = new Hls({
maxBufferLength: 30, // 最大缓冲 30s
maxMaxBufferLength: 60, // 动态缓冲上限 60s
startLevel: -1, // 自动选择起始码率
capLevelToPlayerSize: true, // 根据播放器尺寸限制码率
});
this.hls.loadSource(url);
this.hls.attachMedia(this.video);
} else if (url.endsWith('.m3u8') && this.video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari 原生支持 HLS
this.video.src = url;
} else {
// MP4 / WebM 直接播放
this.video.src = url;
}
}

/**
* 缩略图预览 —— 鼠标悬停在进度条上时显示对应帧的缩略图
* 缩略图通常由服务端生成雪碧图(Sprite Sheet)
*/
private initThumbnailPreview(container: HTMLElement): void {
if (!this.config.thumbnailUrl) return;

const tooltip = document.createElement('div');
tooltip.className = 'video-thumbnail-tooltip';
tooltip.style.display = 'none';
container.appendChild(tooltip);

const progressBar = container.querySelector('.progress-bar');
progressBar?.addEventListener('mousemove', (e: Event) => {
const mouseEvent = e as MouseEvent;
const rect = (mouseEvent.target as HTMLElement).getBoundingClientRect();
const percent = (mouseEvent.clientX - rect.left) / rect.width;
const time = percent * this.video.duration;

// 计算雪碧图中对应帧的位置
const interval = this.config.thumbnailInterval ?? 5;
const index = Math.floor(time / interval);
const cols = 10; // 雪碧图每行 10 张缩略图
const thumbW = this.config.thumbnailWidth ?? 160;
const thumbH = this.config.thumbnailHeight ?? 90;

const x = (index % cols) * thumbW;
const y = Math.floor(index / cols) * thumbH;

tooltip.style.backgroundImage = `url(${this.config.thumbnailUrl})`;
tooltip.style.backgroundPosition = `-${x}px -${y}px`;
tooltip.style.width = `${thumbW}px`;
tooltip.style.height = `${thumbH}px`;
tooltip.style.left = `${mouseEvent.clientX - rect.left - thumbW / 2}px`;
tooltip.style.display = 'block';
});
}

destroy(): void {
this.hls?.destroy();
this.video.remove();
}
}

五、代码预览

Monaco Editor 是 VS Code 的底层编辑器引擎,功能强大但体积较大(~2MB gzipped)。

viewers/MonacoCodeViewer.ts
import * as monaco from 'monaco-editor';

interface CodeViewerOptions {
container: HTMLElement;
code: string;
language: string;
readOnly?: boolean;
theme?: 'vs' | 'vs-dark' | 'hc-black';
minimap?: boolean;
}

function createCodeViewer(options: CodeViewerOptions): monaco.editor.IStandaloneCodeEditor {
const editor = monaco.editor.create(options.container, {
value: options.code,
language: options.language,
readOnly: options.readOnly ?? true,
theme: options.theme ?? 'vs-dark',
minimap: { enabled: options.minimap ?? true },
automaticLayout: true, // 自动适配容器大小
scrollBeyondLastLine: false,
fontSize: 14,
lineNumbers: 'on',
folding: true, // 代码折叠
wordWrap: 'on', // 自动换行
renderWhitespace: 'selection',
});

return editor;
}

方案选择建议

特性Monaco EditorPrism.js
体积~2MB gzipped~2KB core
语法高亮完整 VS Code 级别支持 200+ 语言
代码编辑支持不支持
代码折叠支持需要插件
搜索替换支持不支持
适用场景在线 IDE、代码编辑只读预览、博客展示

六、Markdown 预览

viewers/MarkdownViewer.ts
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import hljs from 'highlight.js';

interface MarkdownViewerOptions {
container: HTMLElement;
content: string;
sanitize?: boolean;
}

function createMarkdownViewer(options: MarkdownViewerOptions): void {
// 配置 marked 渲染器
marked.setOptions({
// 代码块语法高亮
highlight(code: string, lang: string): string {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return hljs.highlightAuto(code).value;
},
gfm: true, // GitHub Flavored Markdown
breaks: true, // 换行符转 <br>
});

// 渲染 Markdown 为 HTML
let html = marked.parse(options.content) as string;

// XSS 防护 —— 使用 DOMPurify 清洗 HTML
if (options.sanitize !== false) {
html = DOMPurify.sanitize(html, {
ADD_TAGS: ['iframe'], // 允许嵌入视频
ADD_ATTR: ['target'], // 允许 target="_blank"
});
}

options.container.innerHTML = html;
options.container.className = 'markdown-body';
}
Markdown 渲染安全

Markdown 中可以嵌入 HTML 标签,必须使用 DOMPurify 对渲染结果进行 XSS 清洗,否则恶意用户可以通过 Markdown 文件注入脚本。


七、大文件处理

大文件预览的核心挑战是避免一次性加载整个文件到内存中

Web Worker 文件解析

workers/FileParseWorker.ts
// Web Worker 文件 —— 在独立线程中解析文件,不阻塞 UI

self.onmessage = async (e: MessageEvent) => {
const { type, data } = e.data;

switch (type) {
case 'PARSE_CSV': {
// 流式解析 CSV,逐行发送到主线程
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(data as ArrayBuffer);
const lines = text.split('\n');
const batchSize = 1000;

for (let i = 0; i < lines.length; i += batchSize) {
const batch = lines.slice(i, i + batchSize);
self.postMessage({
type: 'CSV_BATCH',
rows: batch.map((line) => line.split(',')),
progress: Math.min(100, ((i + batchSize) / lines.length) * 100),
});
}

self.postMessage({ type: 'CSV_COMPLETE' });
break;
}

case 'PARSE_TEXT': {
// 大文本文件分片
const buffer = data as ArrayBuffer;
const decoder = new TextDecoder('utf-8');
const chunkSize = 64 * 1024; // 64KB 一片
const totalChunks = Math.ceil(buffer.byteLength / chunkSize);

for (let i = 0; i < totalChunks; i++) {
const slice = buffer.slice(i * chunkSize, (i + 1) * chunkSize);
const text = decoder.decode(slice, { stream: i < totalChunks - 1 });
self.postMessage({
type: 'TEXT_CHUNK',
content: text,
chunkIndex: i,
progress: ((i + 1) / totalChunks) * 100,
});
}

self.postMessage({ type: 'TEXT_COMPLETE' });
break;
}
}
};

主线程调用 Worker

utils/FileParser.ts
class FileParser {
private worker: Worker;

constructor() {
this.worker = new Worker(
new URL('../workers/FileParseWorker.ts', import.meta.url),
{ type: 'module' }
);
}

/**
* 使用 fetch + ReadableStream 流式下载大文件
* 配合 Web Worker 解析,避免主线程阻塞
*/
async parseStreamFile(
url: string,
onProgress: (progress: number) => void
): Promise<ArrayBuffer> {
const response = await fetch(url);
const contentLength = Number(response.headers.get('Content-Length') ?? 0);
const reader = response.body!.getReader();

const chunks: Uint8Array[] = [];
let receivedLength = 0;

while (true) {
const { done, value } = await reader.read();
if (done) break;

chunks.push(value);
receivedLength += value.length;
onProgress(contentLength > 0 ? (receivedLength / contentLength) * 100 : -1);
}

// 合并所有 chunk
const buffer = new Uint8Array(receivedLength);
let position = 0;
for (const chunk of chunks) {
buffer.set(chunk, position);
position += chunk.length;
}

return buffer.buffer;
}

/**
* 将文件数据发送给 Worker 处理
* 使用 Transferable Objects 避免数据拷贝
*/
parseInWorker(type: string, buffer: ArrayBuffer): void {
this.worker.postMessage({ type, data: buffer }, [buffer]); // Transferable!
}

onMessage(callback: (data: unknown) => void): void {
this.worker.onmessage = (e: MessageEvent) => callback(e.data);
}

destroy(): void {
this.worker.terminate();
}
}
Transferable Objects

使用 postMessage 的第二个参数传递 [buffer] 可以将 ArrayBuffer 的所有权转移给 Worker,而不是拷贝。这对大文件场景非常关键 —— 一个 100MB 的文件如果拷贝会额外消耗 100MB 内存和可观的序列化时间。


八、水印系统

Canvas 明水印

watermark/CanvasWatermark.ts
interface WatermarkOptions {
text: string;
fontSize?: number;
color?: string;
opacity?: number;
rotate?: number;
gapX?: number;
gapY?: number;
}

function createWatermark(options: WatermarkOptions): HTMLDivElement {
const {
text,
fontSize = 16,
color = '#000000',
opacity = 0.15,
rotate = -22,
gapX = 200,
gapY = 150,
} = options;

// 创建 Canvas 绘制单个水印
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
ctx.font = `${fontSize}px Arial`;

const metrics = ctx.measureText(text);
canvas.width = metrics.width + gapX;
canvas.height = fontSize + gapY;

ctx.font = `${fontSize}px Arial`;
ctx.fillStyle = color;
ctx.globalAlpha = opacity;
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((rotate * Math.PI) / 180);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, 0, 0);

// 创建铺满全屏的水印层
const watermarkDiv = document.createElement('div');
watermarkDiv.style.cssText = [
'position: fixed',
'top: 0',
'left: 0',
'width: 100vw',
'height: 100vh',
'pointer-events: none',
'z-index: 99999',
`background-image: url(${canvas.toDataURL()})`,
'background-repeat: repeat',
].join(';');

return watermarkDiv;
}

防篡改水印(MutationObserver)

用户可以通过 DevTools 删除水印 DOM 元素。使用 MutationObserver 监听 DOM 变动,一旦水印被删除或修改立即恢复。

watermark/WatermarkGuard.ts
class WatermarkGuard {
private watermarkEl: HTMLDivElement;
private observer: MutationObserver;
private options: WatermarkOptions;

constructor(container: HTMLElement, options: WatermarkOptions) {
this.options = options;
this.watermarkEl = createWatermark(options);
container.appendChild(this.watermarkEl);

// 监听水印元素自身的属性变化
this.observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
// 水印元素被删除
if (mutation.type === 'childList') {
const removed = Array.from(mutation.removedNodes);
if (removed.includes(this.watermarkEl)) {
// 立即重新插入水印
container.appendChild(this.watermarkEl);
}
}

// 水印样式被修改(如 display:none、opacity:0)
if (
mutation.type === 'attributes' &&
mutation.target === this.watermarkEl
) {
// 销毁旧水印,重新创建
this.watermarkEl.remove();
this.watermarkEl = createWatermark(this.options);
container.appendChild(this.watermarkEl);
}
}
});

this.observer.observe(container, {
childList: true,
attributes: true,
subtree: true,
});
}

destroy(): void {
this.observer.disconnect();
this.watermarkEl.remove();
}
}

暗水印(隐写术)

暗水印将信息隐藏在图片像素的最低有效位(LSB)中,肉眼不可见但可通过算法提取。

watermark/InvisibleWatermark.ts
class InvisibleWatermark {
/**
* 在 Canvas 图片中嵌入暗水印
* 原理:修改像素 RGB 值的最低位(LSB),肉眼不可见
*/
static encode(
canvas: HTMLCanvasElement,
message: string
): HTMLCanvasElement {
const ctx = canvas.getContext('2d')!;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;

// 将消息转为二进制
const binaryMsg = Array.from(message)
.map((char) => char.charCodeAt(0).toString(2).padStart(8, '0'))
.join('');

// 在每个像素的 R 通道最低位写入信息
for (let i = 0; i < binaryMsg.length && i * 4 < data.length; i++) {
const bit = parseInt(binaryMsg[i], 10);
// 清除最低位,再写入水印位
data[i * 4] = (data[i * 4] & 0xfe) | bit;
}

ctx.putImageData(imageData, 0, 0);
return canvas;
}

/**
* 从 Canvas 图片中提取暗水印
*/
static decode(canvas: HTMLCanvasElement, length: number): string {
const ctx = canvas.getContext('2d')!;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;

let binaryStr = '';
for (let i = 0; i < length * 8 && i * 4 < data.length; i++) {
binaryStr += (data[i * 4] & 1).toString();
}

// 二进制转字符串
let message = '';
for (let i = 0; i < binaryStr.length; i += 8) {
const byte = binaryStr.slice(i, i + 8);
message += String.fromCharCode(parseInt(byte, 2));
}

return message;
}
}

九、安全设计

Token 鉴权与防下载

security/PreviewSecurity.ts
interface PreviewToken {
fileId: string;
userId: string;
expires: number;
permissions: ('view' | 'download' | 'print')[];
}

class PreviewSecurity {
/**
* 生成带签名的临时预览 URL
* URL 有过期时间,无法分享给他人使用
*/
static generateSignedUrl(
baseUrl: string,
token: PreviewToken,
secret: string
): string {
const payload = JSON.stringify(token);
const signature = this.hmacSign(payload, secret);

return `${baseUrl}?token=${encodeURIComponent(
btoa(payload)
)}&sign=${signature}`;
}

/**
* 禁用右键菜单和常见下载快捷键
* 注意:这只是增加下载难度,无法完全阻止
*/
static disableDownload(container: HTMLElement): () => void {
// 禁用右键菜单
const onContextMenu = (e: Event): void => {
e.preventDefault();
};

// 禁用拖拽保存
const onDragStart = (e: Event): void => {
e.preventDefault();
};

// 禁用快捷键(Ctrl+S, Ctrl+P, Ctrl+Shift+I)
const onKeyDown = (e: KeyboardEvent): void => {
if (
(e.ctrlKey || e.metaKey) &&
(e.key === 's' || e.key === 'p' || e.key === 'u')
) {
e.preventDefault();
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'I') {
e.preventDefault();
}
};

container.addEventListener('contextmenu', onContextMenu);
container.addEventListener('dragstart', onDragStart);
document.addEventListener('keydown', onKeyDown);

// 返回清理函数
return () => {
container.removeEventListener('contextmenu', onContextMenu);
container.removeEventListener('dragstart', onDragStart);
document.removeEventListener('keydown', onKeyDown);
};
}

/**
* CSS 防截屏:覆盖一层半透明遮罩使截屏效果差
* 注意:无法真正阻止截屏,仅作为威慑手段
*/
static addScreenshotProtection(container: HTMLElement): void {
const overlay = document.createElement('div');
overlay.style.cssText = [
'position: absolute',
'top: 0',
'left: 0',
'width: 100%',
'height: 100%',
'pointer-events: none',
'z-index: 99998',
'mix-blend-mode: difference',
].join(';');
container.appendChild(overlay);
}

private static hmacSign(data: string, _secret: string): string {
// 实际使用 crypto.subtle.sign 或服务端签名
return 'signature';
}
}
安全的局限性

纯前端方案无法真正阻止用户下载或截屏文件。所有前端防护手段(禁用右键、禁用快捷键、CSS 遮罩)都可以被绕过。真正的安全需要结合:

  1. 服务端渲染:将文件渲染为图片流返回,前端不接触原始文件
  2. DRM 数字版权管理:视频场景使用 Widevine / FairPlay 加密
  3. 暗水印追溯:泄露时可以通过水印追踪到具体用户
  4. 审计日志:记录所有预览、下载行为用于事后追责

性能优化

优化策略全景

优化方向具体措施效果
分页/分片加载PDF 按页加载、文本按块加载、图片渐进式加载首屏加载速度提升 5~10 倍
虚拟滚动只渲染可视区域的页面/行,销毁离屏内容内存占用降低 80%+
Web Worker文件解析、Hash 计算、CSV 处理在 Worker 中执行主线程不阻塞,交互流畅
Transferable ObjectsWorker 间传递 ArrayBuffer 时转移所有权避免大数据拷贝
预加载/预转换文件列表页预加载、上传后自动触发转换预览时直接命中缓存
CDN 加速转换结果上传 CDN,就近分发减少加载延迟
离屏 Canvas使用 OffscreenCanvas 在 Worker 中渲染GPU 渲染不阻塞主线程
渐进式渲染PDF 先渲染低清版,再渲染高清版用户感知速度提升

缓存策略设计

cache/PreviewCache.ts
interface CacheStrategy {
/** 浏览器端:HTTP 缓存头 */
httpHeaders: Record<string, string>;
/** CDN 端:缓存规则 */
cdnTTL: number;
/** 服务端:转换结果缓存 */
convertCacheTTL: number;
}

const cacheStrategies: Record<string, CacheStrategy> = {
// 转换结果(PDF/HTML)—— 长期缓存
converted: {
httpHeaders: {
'Cache-Control': 'public, max-age=604800, immutable', // 7 天
'ETag': 'auto',
},
cdnTTL: 7 * 24 * 60 * 60, // CDN 缓存 7 天
convertCacheTTL: 30 * 24 * 60 * 60, // Redis 缓存 30 天
},

// 原始文件 —— 协商缓存
original: {
httpHeaders: {
'Cache-Control': 'no-cache', // 每次校验
'ETag': 'auto',
},
cdnTTL: 24 * 60 * 60, // CDN 缓存 1 天
convertCacheTTL: 0, // 不缓存
},

// 缩略图 —— 中等缓存
thumbnail: {
httpHeaders: {
'Cache-Control': 'public, max-age=86400', // 1 天
},
cdnTTL: 3 * 24 * 60 * 60, // CDN 缓存 3 天
convertCacheTTL: 7 * 24 * 60 * 60, // Redis 缓存 7 天
},
};

扩展设计

插件化架构

文件预览系统需要支持不断增加的文件格式。采用插件化设计,新格式只需开发对应的预览插件即可接入。

plugin/PreviewPlugin.ts
// 预览插件接口
interface PreviewPlugin {
/** 插件名称 */
name: string;
/** 支持的文件扩展名 */
extensions: string[];
/** 支持的 MIME 类型 */
mimeTypes: string[];
/** 优先级(数字越大优先级越高) */
priority: number;
/** 判断是否能处理该文件 */
canHandle(file: FileInfo): boolean;
/** 渲染文件到容器 */
render(file: FileInfo, container: HTMLElement): Promise<void>;
/** 销毁清理 */
destroy(): void;
}

// 插件管理器
class PreviewPluginManager {
private plugins: PreviewPlugin[] = [];

/**
* 注册预览插件
*/
register(plugin: PreviewPlugin): void {
this.plugins.push(plugin);
// 按优先级排序,优先级高的先匹配
this.plugins.sort((a, b) => b.priority - a.priority);
}

/**
* 根据文件信息查找合适的插件
*/
resolve(file: FileInfo): PreviewPlugin | null {
return this.plugins.find((p) => p.canHandle(file)) ?? null;
}

/**
* 批量注册默认插件
*/
registerDefaults(): void {
this.register(new PDFPlugin());
this.register(new ImagePlugin());
this.register(new VideoPlugin());
this.register(new CodePlugin());
this.register(new MarkdownPlugin());
// 兜底插件 —— 尝试服务端转换为 PDF
this.register(new FallbackPlugin());
}
}

多端适配方案

平台预览策略手势支持
PC Web完整功能,Monaco Editor、PDF.js 全量渲染鼠标滚轮缩放、右键菜单
Mobile H5轻量模式,Prism.js 替代 Monaco、图片按需加载双指缩放、单指拖拽、左右滑动切换
小程序调用原生文件预览 API(wx.openDocument原生手势
Electron调用系统原生预览能力 + Web 渲染混合触控板手势

常见面试问题

Q1: PDF.js 的渲染原理是什么?

答案

PDF.js 的渲染流程分为三个核心阶段:

  1. 解析阶段:在 Web Worker 中解析 PDF 文件的二进制数据。PDF 文件本质上是一种结构化的二进制格式,包含交叉引用表(xref table)、页面树(page tree)、内容流(content stream)等。Worker 解析后生成 PDFDocumentProxy 文档代理对象。

  2. 渲染阶段:通过 page.render() 方法将 PDF 页面内容绘制到 Canvas 上。PDF 的内容流包含各种绘图指令(文本、路径、图片),PDF.js 将这些指令翻译为 Canvas 2D API 调用。

  3. 交互层:在 Canvas 之上叠加两个透明的 DOM 层:

    • 文字层(TextLayer):根据 PDF 中文字的坐标信息,在对应位置创建透明的 <span> 元素,使用户可以选择和复制文字
    • 注释层(AnnotationLayer):处理 PDF 中的超链接、表单、批注等交互元素
PDF.js 渲染核心流程
// 1. 在 Worker 中加载和解析 PDF
const doc = await pdfjsLib.getDocument({ url: pdfUrl }).promise;

// 2. 获取页面代理对象
const page = await doc.getPage(1);

// 3. 计算视口(支持缩放)
const viewport = page.getViewport({ scale: 1.5 });

// 4. Canvas 渲染
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvasContext: ctx, viewport }).promise;

// 5. 文字层(支持文本选择)
const textContent = await page.getTextContent();
// 根据 textContent.items 中每个文字的 transform 矩阵
// 创建对应位置的 <span> 元素

// 6. 注释层(支持超链接点击)
const annotations = await page.getAnnotations();
// 创建对应位置的 <a> 或 <input> 元素
面试加分点

提到 PDF.js 使用 Web Worker 在后台线程解析 PDF 文件,避免阻塞主线程。渲染结果通过 OperatorList(一系列渲染指令)从 Worker 传递到主线程,由主线程在 Canvas 上执行这些指令。


Q2: 大文件预览如何做性能优化?

答案

大文件预览的核心挑战是内存和渲染性能。以下是系统性的优化策略:

1. 分片加载(Chunked Loading)

不一次性下载整个文件,而是利用 HTTP Range 请求按需加载:

分片加载实现
async function loadFileChunk(url: string, start: number, end: number): Promise<ArrayBuffer> {
const response = await fetch(url, {
headers: {
Range: `bytes=${start}-${end}`, // HTTP Range 请求
},
});
return response.arrayBuffer();
}

2. 虚拟滚动(Virtual Scrolling)

只渲染可视区域内的内容,使用 IntersectionObserver 动态加载/卸载页面。

3. Web Worker 解析

文件解析(CSV 分行、文本编码检测等)在 Worker 线程中完成,主线程保持流畅。使用 Transferable Objects 避免数据拷贝。

4. 渐进式渲染

PDF 大文件先渲染低分辨率版本(scale=0.5),用户停止滚动后再渲染高分辨率版本:

渐进式渲染
// 先低清后高清的双阶段渲染
async function progressiveRender(page: PDFPageProxy, canvas: HTMLCanvasElement): Promise<void> {
const ctx = canvas.getContext('2d')!;

// 阶段1:低分辨率快速渲染(scale=0.5)
const lowViewport = page.getViewport({ scale: 0.5 });
await page.render({ canvasContext: ctx, viewport: lowViewport }).promise;

// 阶段2:用户停止滚动 300ms 后,渲染高清版
await new Promise((resolve) => setTimeout(resolve, 300));
const highViewport = page.getViewport({ scale: 2.0 });
canvas.width = highViewport.width;
canvas.height = highViewport.height;
await page.render({ canvasContext: ctx, viewport: highViewport }).promise;
}

5. OffscreenCanvas(实验性)

在 Web Worker 中使用 OffscreenCanvas 进行 Canvas 渲染,将渲染工作完全移出主线程:

OffscreenCanvas 示例
// 主线程
const canvas = document.createElement('canvas');
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen, page: pageData }, [offscreen]);

// Worker 线程
self.onmessage = async (e: MessageEvent) => {
const { canvas } = e.data;
const ctx = canvas.getContext('2d')!;
// 在 Worker 中直接绘制 Canvas,不阻塞主线程
};
优化手段解决的问题适用场景
分片加载下载速度、内存占用所有大文件
虚拟滚动DOM 数量、内存占用PDF、长文本、大表格
Web Worker主线程阻塞文件解析、Hash 计算
渐进式渲染首屏白屏时间PDF、高清图片
OffscreenCanvasCanvas 渲染阻塞PDF、图表渲染
CDN + 缓存重复下载所有文件

Q3: Office 文件在浏览器中预览有哪些方案?各有什么优缺点?

答案

Office 文件(Word、Excel、PPT)无法被浏览器直接解析渲染,需要借助额外手段。以下是主流方案对比:

方案原理排版还原度成本适用场景
LibreOffice 转 PDF服务端用 LibreOffice 将文件转为 PDF,前端用 PDF.js 渲染85%~90%中小项目、只读预览
OnlyOfficeDocker 部署 OnlyOffice Server,前端嵌入其 Editor95%+需要在线编辑的场景
微软 Office Online嵌入微软 Office 365 在线编辑器(iframe)100%企业级、商业授权
前端纯解析使用 docx.js/SheetJS 等库在前端直接解析渲染60%~70%简单文档、预算有限
Google Docs Viewer使用 Google 的在线查看器(需文件公开访问)90%免费公开文件、不考虑隐私

推荐方案

LibreOffice 转换 + PDF.js 方案详细流程

Office 预览完整流程
class OfficePreviewService {
async preview(fileId: string, container: HTMLElement): Promise<void> {
// 1. 检查是否已有转换缓存
let convertedUrl = await this.checkCache(fileId);

if (!convertedUrl) {
// 2. 请求服务端转换(LibreOffice headless)
const result = await fetch('/api/convert', {
method: 'POST',
body: JSON.stringify({ fileId, targetType: 'pdf' }),
});
const data = await result.json() as { url: string };
convertedUrl = data.url;
}

// 3. 使用 PDF.js 渲染转换后的 PDF
const pdfRenderer = new PDFRenderer();
await pdfRenderer.loadDocument(convertedUrl);

// 4. 初始化虚拟滚动
const scroller = new PDFVirtualScroller(pdfRenderer, container);
await scroller.init(pdfRenderer.getTotalPages());
}

private async checkCache(fileId: string): Promise<string | null> {
const response = await fetch(`/api/convert/status?fileId=${fileId}`);
const data = await response.json() as { converted: boolean; url?: string };
return data.converted ? (data.url ?? null) : null;
}
}
LibreOffice 转换的局限
  1. 复杂排版:复杂的 Word 排版(分栏、嵌套表格、艺术字)转换后可能有偏差
  2. Excel 公式:动态公式不会自动计算,只保留最后一次计算的值
  3. PPT 动画:转为 PDF 后所有动画效果丢失
  4. 字体缺失:服务器需要安装对应字体,否则会 fallback 到默认字体

Q4: 如何实现文件预览的"防下载"功能?

答案

首先明确一个核心观点:纯前端无法做到 100% 防下载。只要内容在浏览器中渲染出来,用户就有办法获取(截屏、录屏、DevTools 提取 Canvas 数据等)。我们能做的是增加获取难度 + 事后追溯

防下载方案分层设计

层级措施效果难以绕过程度
L1 基础防护禁用右键、禁用 Ctrl+S、禁用拖拽阻止普通用户
L2 渲染防护使用 Canvas 渲染而非原始文件、不暴露原始 URL阻止中级用户
L3 水印追溯明水印 + 暗水印、MutationObserver 防篡改事后追溯泄露者中高
L4 服务端渲染服务端将文件渲染为图片流,前端只接收图片无法获取原始文件
L5 DRM 加密视频使用 Widevine/FairPlay 加密硬件级保护最高

完整的防下载实现

security/DownloadProtection.ts
class DownloadProtection {
/**
* L1:基础防护
*/
static applyBasicProtection(container: HTMLElement): void {
// 禁用右键
container.addEventListener('contextmenu', (e) => e.preventDefault());

// 禁用拖拽
container.addEventListener('dragstart', (e) => e.preventDefault());

// 禁用快捷键
document.addEventListener('keydown', (e) => {
const isCtrl = e.ctrlKey || e.metaKey;
if (isCtrl && ['s', 'p', 'u'].includes(e.key.toLowerCase())) {
e.preventDefault();
}
});

// 禁用选择和复制
container.style.userSelect = 'none';
container.addEventListener('copy', (e) => e.preventDefault());
}

/**
* L2:使用 Canvas 渲染代替直接展示文件
* 前端看到的只是 Canvas 像素,无法获取原始文件数据
*/
static renderToCanvas(
sourceImg: HTMLImageElement,
canvas: HTMLCanvasElement
): void {
const ctx = canvas.getContext('2d')!;
canvas.width = sourceImg.naturalWidth;
canvas.height = sourceImg.naturalHeight;
ctx.drawImage(sourceImg, 0, 0);

// 清除原始图片 URL,防止从网络面板获取
sourceImg.src = '';
sourceImg.remove();
}

/**
* L4:服务端渲染方案
* 前端请求的是渲染后的图片流,不接触原始文件
*/
static async loadFromServerRender(
fileId: string,
page: number,
canvas: HTMLCanvasElement
): Promise<void> {
// 服务端将指定页面渲染为图片返回
const url = `/api/render?fileId=${fileId}&page=${page}&token=${await getToken()}`;
const response = await fetch(url);
const blob = await response.blob();

const img = new Image();
img.src = URL.createObjectURL(blob);
await new Promise((resolve) => { img.onload = resolve; });

const ctx = canvas.getContext('2d')!;
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);

URL.revokeObjectURL(img.src);
}
}
面试总结

回答此题的关键是承认局限性,然后阐述分层防护策略:

  1. 前端手段只是提高门槛,无法绝对防护
  2. 真正有效的是服务端渲染(前端不接触原始文件)和 DRM(硬件级保护)
  3. 水印追溯是最后一道防线,即使泄露也能追责
  4. 实际项目中需要根据安全等级选择合适的防护层级,平衡安全性和用户体验

相关链接