设计文件预览系统
需求分析
文件预览系统是企业协作、网盘、OA 系统中的核心功能模块。用户上传文件后,无需下载安装本地软件即可在浏览器中直接预览各种格式的文件。一个完善的文件预览系统需要支持多种文件格式、保证渲染质量、兼顾加载性能,并提供安全防护能力。
功能需求
| 功能模块 | 支持格式 | 核心能力 |
|---|---|---|
| PDF 预览 | .pdf | 文字渲染、注释层、搜索高亮、缩略图导航、虚拟滚动 |
| Office 预览 | .docx .xlsx .pptx | 服务端转换、排版还原、表格渲染、幻灯片播放 |
| 图片预览 | .jpg .png .webp .svg .gif | 缩放、旋转、拖拽、手势控制、多图轮播 |
| 视频预览 | .mp4 .webm .m3u8 | HLS 流播放、缩略图预览、进度条预览 |
| 代码预览 | .ts .js .py .go 等 | 语法高亮、行号显示、代码折叠、主题切换 |
| Markdown 预览 | .md | 实时渲染、TOC 目录、代码块高亮、数学公式 |
| 文本预览 | .txt .log .csv | 大文件分片加载、编码检测、表格化展示 |
| 3D 模型预览 | .glb .gltf .obj | Three.js 渲染、旋转交互、光照控制 |
非功能需求
文件预览系统的非功能需求是面试中的加分项——安全防护、大文件处理、缓存策略是面试官最关注的三个方面。
| 需求 | 目标 | 实现手段 |
|---|---|---|
| 加载性能 | 首屏 < 2s,大文件分片加载 | 分页渲染、虚拟滚动、Web Worker 解析 |
| 格式兼容 | 覆盖 95% 以上常见格式 | 前端原生 + 服务端转换兜底 |
| 安全防护 | 防下载、防截屏、水印追溯 | Token 鉴权、Canvas 水印、暗水印 |
| 大文件支持 | 支持 100MB+ 文件流畅预览 | 流式渲染、分页加载、CDN 加速 |
| 多端适配 | PC 端 + 移动端手势交互 | 响应式布局、触摸事件适配 |
| 缓存策略 | 减少重复转换和下载 | 转换结果缓存、CDN 缓存、浏览器缓存 |
整体架构
系统全景架构
预览决策流程
核心模块设计
预览容器核心设计
预览容器负责根据文件类型动态加载对应的预览器,采用策略模式实现格式路由。
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 核心渲染实现
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 实现虚拟滚动,只渲染可视区域附近的页面。
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 嵌入字体时需要正确配置
cMapUrl,否则中文可能显示为方块 - 内存泄漏:页面切换时务必调用
page.cleanup()释放 Canvas 资源 - 高清屏模糊:Canvas 必须按
devicePixelRatio缩放,否则在 Retina 屏上模糊 - Worker 加载失败:
pdf.worker.js必须从同源加载或正确配置 CORS
二、Office 文件预览
Office 文件(.docx / .xlsx / .pptx)无法在浏览器中直接渲染,需要通过服务端转换为浏览器可渲染的格式。
转换方案对比
| 方案 | 转换目标 | 排版还原度 | 性能 | 部署成本 | 适用场景 |
|---|---|---|---|---|---|
| LibreOffice | HTML / PDF | 85%~90% | 中等 | 低(开源) | 中小规模、成本敏感 |
| OnlyOffice | 在线编辑 | 95%+ | 高 | 中(Docker 部署) | 需要在线编辑能力 |
| 微软 Office Online | iframe 嵌入 | 100% | 高 | 高(商业授权) | 企业级、预算充足 |
| 前端纯解析 | Canvas / DOM | 60%~70% | 快 | 无 | 简单文档、只读预览 |
中小型项目推荐 LibreOffice 转 PDF + 前端 PDF.js 渲染 的组合方案,兼顾成本和效果。企业级项目推荐 OnlyOffice,支持编辑能力且部署相对简单。
服务端转换服务设计
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。
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);
});
三、图片预览
图片预览看似简单,但要做好缩放、旋转、拖拽、手势的流畅交互需要精心设计。
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);
}
}
多图轮播
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 流式播放和缩略图预览。
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(重量级)
- Prism.js(轻量级)
Monaco Editor 是 VS Code 的底层编辑器引擎,功能强大但体积较大(~2MB gzipped)。
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;
}
Prism.js 是轻量级语法高亮库(~2KB core),适合只需要阅读展示的场景。
import Prism from 'prismjs';
// 按需加载语言支持
import 'prismjs/components/prism-typescript';
import 'prismjs/components/prism-python';
import 'prismjs/components/prism-go';
// 行号插件
import 'prismjs/plugins/line-numbers/prism-line-numbers';
interface PrismViewerOptions {
container: HTMLElement;
code: string;
language: string;
}
function createPrismViewer(options: PrismViewerOptions): void {
const pre = document.createElement('pre');
pre.className = `language-${options.language} line-numbers`;
const code = document.createElement('code');
code.className = `language-${options.language}`;
code.textContent = options.code;
pre.appendChild(code);
options.container.appendChild(pre);
// 高亮渲染
Prism.highlightElement(code);
}
方案选择建议
| 特性 | Monaco Editor | Prism.js |
|---|---|---|
| 体积 | ~2MB gzipped | ~2KB core |
| 语法高亮 | 完整 VS Code 级别 | 支持 200+ 语言 |
| 代码编辑 | 支持 | 不支持 |
| 代码折叠 | 支持 | 需要插件 |
| 搜索替换 | 支持 | 不支持 |
| 适用场景 | 在线 IDE、代码编辑 | 只读预览、博客展示 |
六、Markdown 预览
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 中可以嵌入 HTML 标签,必须使用 DOMPurify 对渲染结果进行 XSS 清洗,否则恶意用户可以通过 Markdown 文件注入脚本。
七、大文件处理
大文件预览的核心挑战是避免一次性加载整个文件到内存中。
Web Worker 文件解析
// 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
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();
}
}
使用 postMessage 的第二个参数传递 [buffer] 可以将 ArrayBuffer 的所有权转移给 Worker,而不是拷贝。这对大文件场景非常关键 —— 一个 100MB 的文件如果拷贝会额外消耗 100MB 内存和可观的序列化时间。
八、水印系统
Canvas 明水印
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 变动,一旦水印被删除或修改立即恢复。
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)中,肉眼不可见但可通过算法提取。
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 鉴权与防下载
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 遮罩)都可以被绕过。真正的安全需要结合:
- 服务端渲染:将文件渲染为图片流返回,前端不接触原始文件
- DRM 数字版权管理:视频场景使用 Widevine / FairPlay 加密
- 暗水印追溯:泄露时可以通过水印追踪到具体用户
- 审计日志:记录所有预览、下载行为用于事后追责
性能优化
优化策略全景
| 优化方向 | 具体措施 | 效果 |
|---|---|---|
| 分页/分片加载 | PDF 按页加载、文本按块加载、图片渐进式加载 | 首屏加载速度提升 5~10 倍 |
| 虚拟滚动 | 只渲染可视区域的页面/行,销毁离屏内容 | 内存占用降低 80%+ |
| Web Worker | 文件解析、Hash 计算、CSV 处理在 Worker 中执行 | 主线程不阻塞,交互流畅 |
| Transferable Objects | Worker 间传递 ArrayBuffer 时转移所有权 | 避免大数据拷贝 |
| 预加载/预转换 | 文件列表页预加载、上传后自动触发转换 | 预览时直接命中缓存 |
| CDN 加速 | 转换结果上传 CDN,就近分发 | 减少加载延迟 |
| 离屏 Canvas | 使用 OffscreenCanvas 在 Worker 中渲染 | GPU 渲染不阻塞主线程 |
| 渐进式渲染 | PDF 先渲染低清版,再渲染高清版 | 用户感知速度提升 |
缓存策略设计
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 天
},
};
扩展设计
插件化架构
文件预览系统需要支持不断增加的文件格式。采用插件化设计,新格式只需开发对应的预览插件即可接入。
// 预览插件接口
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 的渲染流程分为三个核心阶段:
-
解析阶段:在 Web Worker 中解析 PDF 文件的二进制数据。PDF 文件本质上是一种结构化的二进制格式,包含交叉引用表(xref table)、页面树(page tree)、内容流(content stream)等。Worker 解析后生成
PDFDocumentProxy文档代理对象。 -
渲染阶段:通过
page.render()方法将 PDF 页面内容绘制到 Canvas 上。PDF 的内容流包含各种绘图指令(文本、路径、图片),PDF.js 将这些指令翻译为 Canvas 2D API 调用。 -
交互层:在 Canvas 之上叠加两个透明的 DOM 层:
- 文字层(TextLayer):根据 PDF 中文字的坐标信息,在对应位置创建透明的 <span> 元素,使用户可以选择和复制文字
- 注释层(AnnotationLayer):处理 PDF 中的超链接、表单、批注等交互元素
// 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 渲染,将渲染工作完全移出主线程:
// 主线程
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、高清图片 |
| OffscreenCanvas | Canvas 渲染阻塞 | PDF、图表渲染 |
| CDN + 缓存 | 重复下载 | 所有文件 |
Q3: Office 文件在浏览器中预览有哪些方案?各有什么优缺点?
答案:
Office 文件(Word、Excel、PPT)无法被浏览器直接解析渲染,需要借助额外手段。以下是主流方案对比:
| 方案 | 原理 | 排版还原度 | 成本 | 适用场景 |
|---|---|---|---|---|
| LibreOffice 转 PDF | 服务端用 LibreOffice 将文件转为 PDF,前端用 PDF.js 渲染 | 85%~90% | 低 | 中小项目、只读预览 |
| OnlyOffice | Docker 部署 OnlyOffice Server,前端嵌入其 Editor | 95%+ | 中 | 需要在线编辑的场景 |
| 微软 Office Online | 嵌入微软 Office 365 在线编辑器(iframe) | 100% | 高 | 企业级、商业授权 |
| 前端纯解析 | 使用 docx.js/SheetJS 等库在前端直接解析渲染 | 60%~70% | 无 | 简单文档、预算有限 |
| Google Docs Viewer | 使用 Google 的在线查看器(需文件公开访问) | 90% | 免费 | 公开文件、不考虑隐私 |
推荐方案:
LibreOffice 转换 + PDF.js 方案详细流程:
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;
}
}
- 复杂排版:复杂的 Word 排版(分栏、嵌套表格、艺术字)转换后可能有偏差
- Excel 公式:动态公式不会自动计算,只保留最后一次计算的值
- PPT 动画:转为 PDF 后所有动画效果丢失
- 字体缺失:服务器需要安装对应字体,否则会 fallback 到默认字体
Q4: 如何实现文件预览的"防下载"功能?
答案:
首先明确一个核心观点:纯前端无法做到 100% 防下载。只要内容在浏览器中渲染出来,用户就有办法获取(截屏、录屏、DevTools 提取 Canvas 数据等)。我们能做的是增加获取难度 + 事后追溯。
防下载方案分层设计:
| 层级 | 措施 | 效果 | 难以绕过程度 |
|---|---|---|---|
| L1 基础防护 | 禁用右键、禁用 Ctrl+S、禁用拖拽 | 阻止普通用户 | 低 |
| L2 渲染防护 | 使用 Canvas 渲染而非原始文件、不暴露原始 URL | 阻止中级用户 | 中 |
| L3 水印追溯 | 明水印 + 暗水印、MutationObserver 防篡改 | 事后追溯泄露者 | 中高 |
| L4 服务端渲染 | 服务端将文件渲染为图片流,前端只接收图片 | 无法获取原始文件 | 高 |
| L5 DRM 加密 | 视频使用 Widevine/FairPlay 加密 | 硬件级保护 | 最高 |
完整的防下载实现:
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);
}
}
回答此题的关键是承认局限性,然后阐述分层防护策略:
- 前端手段只是提高门槛,无法绝对防护
- 真正有效的是服务端渲染(前端不接触原始文件)和 DRM(硬件级保护)
- 水印追溯是最后一道防线,即使泄露也能追责
- 实际项目中需要根据安全等级选择合适的防护层级,平衡安全性和用户体验
相关链接
- PDF.js 官方文档
- MDN - Web Workers API
- MDN - IntersectionObserver
- MDN - MutationObserver
- MDN - Canvas API
- MDN - ReadableStream
- MDN - OffscreenCanvas
- Monaco Editor
- Prism.js
- OnlyOffice
- LibreOffice
- HLS.js
- marked - Markdown 解析器
- DOMPurify - XSS 防护
- 文件预览 - 虚拟 DOM 与 Diff 算法 - 渲染优化相关
- 设计视频播放器 SDK - 视频预览深入方案
- 设计大文件上传系统 - 大文件处理相关
- 设计图片处理 CDN 服务 - 图片处理与 CDN 缓存
- Web Worker 优化 - Worker 性能优化详解