VUE 实现高性能的 PDF 在线预览

最近在实现共享 PDF 文档的需求,存在主讲人这样一个角色,上传 PDF 文档后,通知其它连接中的终端,进行实时同步展示的功能。对于这样的需求,pdf.js 成功的让我想起了它。

PDF 文档的预览,总的就是要加载速度快,尽最快的速度完成渲染,呈现给用户看,不要出现长时间的白屏或 Loading 状态的现象,另外 PDF 文档需要支持翻页等操作。具体看看一步步的实现。

VUE 实现高性能的 PDF 在线预览

文档分片下载速度

PDF 文档上传

分片上传文档,支持秒传,VUE 支持分片上传的插件一搜一大把,可以采用 vue-simple-uploader 等,具体如何实现,这里不详细论述,简单贴一下秒传校验的实现。

import SparkMD5 from 'spark-md5'; /**  * 文件秒传 MD5 校验  * @param file 上传的文件信息  */ md5File(file) {     const fileReader = new FileReader(),         blobSlice = File.prototype.slice,         chunkSize = 1024 * 1000,						// 分片大小         chunks = Math.floor(file.size / chunkSize),		// 总的分片数量         spark = new SparkMD5.ArrayBuffer();				// 三方库 SparkMD5     let currentChunk = 0;     // 加载分片     const loadNext = () => {         const start = currentChunk * chunkSize;         let end = file.size;         if (currentChunk < chunks - 1) end = start + chunkSize;         fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));     }     // 暂停文件上传     file.pause();   	// 开始校验文件MD5     loadNext();     fileReader.onload = (e) => {         spark.append(e.target.result);       	// 小于总分片, 继续加载         if (currentChunk < chunks - 1) {             currentChunk++;             loadNext();         } else {           	// 分片全部加载完成, 生成 MD5             const md5 = spark.end();           	// 开始服务端校验 MD5 ( 秒传 )             this.md5Success(md5, file);         }     };     fileReader.onerror = () => {       	// 文件读取出错, 取消上传         console.log(`文件${file.name}读取出错,请检查该文件`);         file.cancel();     }; }

VUE 实现高性能的 PDF 在线预览

文件秒传MD5校验

PDF 文档分片

一个 PDF 文档,无法一次就预览所有内容,在有限的可视区域内,只能显示有限的内容,那我们就获取能在有限区域内所能展示的那部分内容,以加快 Content Download 的速度,减少用户第一次打开时的 Loading 时间。

假设一个 PDF 文档有 1000 页,以 5 页为一片,将该文档切分成 200 个分片,首次打开默认请求第一个分片,其后根据翻页来确定是否继续加载后续的分片信息(如需刷新后仍然展示刚刚所在页,则需记录当前页,根据该值与分片的页数来确定当前属于第几个分片,进而再请求相应分片即可)。

服务端如何进行分片,则交给服务端就好了,这里就不详细说了(得注意下中文乱码的情况)。假设文件信息格式及单个分片的请求地址如下所示:

/**  * 文件信息.  * 在文件上传后即可拿到.  */ const file = {   	id: 1,     md5: 'e10adc3949ba59abbe56e057f20f883e',     total: 1000,     name: 'VUE 如何实现高性能的 PDF 在线预览',     // ... }  /**  * 请求分片.  * $http 是我针对 axios 的一些常用方法,拦截器等重新封装后工具类库  */ 

VUE 实现高性能的 PDF 在线预览

文件信息格式及分片请求地址格式

请求 PDF 分片

pdf.js 接口中,getDocument 可用于获取远程文档,返回 PDFDocumentLoadingTask 对象,该对象是一个下载远程 PDF 文档的任务,提供了一些监听方法,可通过 promise 拿到下载完成的 PDF 对象,最终会生成并返回 PDFDocumentProxy 对象,我们接下来所有的操作都是基于该代理类进行的。

注意在 PDF 文档中存在有中文时,会出现不显示的情况,控制台也会报如下的错误提示

Warning: The CMap "baseUrl" parameter must be specified, ensure that the "cMapUrl" and "cMapPacked" API parameters are provided.

主要是 PDF 文档内容存在不支持的字体,暂且引入三方字体来解决该问题

const url = `https://www.makeit.vip/${md5}-${page}.pdf?id=${fid}&token=${token}`, 			cMapUrl = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.4.456/cmaps/'; const  promise = PDFJS.getDocument({ 			url, 			cMapUrl, 			cMapPacked: true 		}).promise;

渲染 PDF 文档

因为我实现的是 PDF 一页的内容,按屏幕尺寸 100% 宽度来显示的,这样很容易高度就超出可视范围了,所以单页采用滚动形式,多页则采用按钮触发翻页的形式来展示(为了更好的适配不同尺寸的屏幕,显示的效果与主讲人完全同步),并非采用一字往下无限排,滚动翻页的形式。

/**  * 获取分片  * @param fid 文件ID  * @param md5 文件唯一标识  * @param token 授权码  * @param num 第N个分片  */ public getFragmentation( 	fid: number, 	md5: string, 	token: string, 	num = 0 ) { 	// 请求分片, 得到 promise... 	// ... 	promise.then((pdf: any) => { 	  	for (let i = 1; i <= pdf.numPages; i++) { 	  		pdf.getPage(i).then((page) => { 	  			const pagination = num * 5 + i; 	  			this.renderPage(pagination, page); 	  		}); 	  	} 	}); }   /**  * 渲染分页内容.  * @param pagination 第N页  * @param page 分页属性  */ protected renderPage( 	pagination: number, 	page: any ) { 	// 根据缩放比例, 获取文档的可视属性 	const viewport = page.getViewport({scale: 1}); 	// 创建用于渲染的Canvas元素 	const canvas = document.createElement('canvas'), 		context = canvas.getContext('2d'); 		canvas.width = viewport.width; 		canvas.height = viewport.height; 	// 渲染文档 	const renderContext = { 		canvasContext: context, 		viewport 	}; 	return page.render(renderContext).promise; }

PDF 文档翻页

在上一页/下一页的不断操作中,1000页的内容,不断的进行渲染,难不成要渲染1000个DOM出来?显然不合理,非得把浏览器给搞崩了才肯罢休吗?几十个 Canvas 就让你卡的不要不要的了。具体实现也简单,保证只显示 5 个的前提下,根据上一页或下一页的操作,增加或删除相应的 DOM即可。最后贴一下稍微完整一些的代码(稍微加了一些注释)。

  1. 获取分片
/**  * 获取分片  * @param fid 文件ID  * @param md5 文件唯一标识  * @param token 授权码  * @param num 第N个分片  * @param showPage 显示第N个分片中的第X页  * @param speaker 是否为主讲人  * @param render 是否直接渲染  * @param clear 是否清除原有内容  */ public getFragmentation( 	fid: number, 	md5: string, 	token: string, 	num: number = 0, 	showPage = 1, 	speaker?: boolean, 	render?: boolean, 	clear?: boolean ): Promise<any> { 	const url = `${process.env.VUE_APP_PROXY_SERVER}/${md5}-${num}.pdf?id=${fid}&token=${token}`, 		cMapUrl = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.4.456/cmaps/'; 	let promise = PDFJS.getDocument({ 		url, 		cMapUrl, 		cMapPacked: true 	}).promise; 	promise.then((pdf: any) => { 		/** 记录 PDFDocumentProxy 对象, 可避免重复请求已经请求过的分片 */ 		this.files.page = showPage; 		if (!this.files.pdfs) this.files.pdfs = {} as any; 		this.files.pdfs[num] = pdf; 	}); 	/** 是否执行渲染操作 */ 	if (render) { 		/** 清除 */ 		if (clear) { 			const documents = this.getContainer() as HTMLDivElement; 			if (documents) documents.innerHTML = ''; 		} 		/** 渲染 - 重新赋值 promise, 保证加载完成后的操作时序 */ 		promise = new Promise((resolve) => { 			promise.then((pdf: any) => { 				/** 开始遍历循环 */ 				for (let i = 1; i <= pdf.numPages; i++) { 					pdf.getPage(i).then((page) => { 						const pagination = num * 5 + i; 						if (!this.files.paginations[pagination]) { 							/** 存储分页信息(宽/高/ID等 - ID可用于判断是否已经渲染及清除DOM操作) */ 							this.files.paginations[pagination] = {} as any; 						} 						this.files.paginations[pagination].id = Utils.uid(); 						const renderFinish = this.renderPage(pagination, page, speaker); 						if (renderFinish) { 							renderFinish.then(() => { 								if (i === pdf.numPages) { 									/** 									 * 1. 当前为最后一页或倒数第2页, 请求下一分片 									 * 2. 当前为第一页或第2页, 请求上一分片 									 */ 									const left = showPage % 5; 									if ( 										left === 0 || 										left === 4 									) { 										/** 回调 - 请求下一个分片 */ 										if (num + 1 <= this.files.total) { 											this.getFragmentation( 												fid, 												md5, 												token, 												num + 1, 												showPage, 												speaker 											).then(() => { 												/** 												 * 下一个分片请求成功后的处理 												 * 根据剩余个数, 决定继续渲染下一分片的1页还是2页 												 */ 												this.getFragmentationSuccess( 													showPage, 													left ? 1 : 0 												); 											}); 										} 									} else if ( 										left === 1 || 										left === 2 									) { 										/** 回调 - 请求上一个分片 */ 										if (num - 1 >= 0) { 											this.getFragmentation( 												fid, 												md5, 												token, 												num - 1, 												showPage, 												speaker 											).then(() => { 												this.getFragmentationSuccess( 													showPage, 													left === 2 ? 1 : 0, 													'prev' 												); 											}); 										} 									} 									/** 									 * 渲染完成后返回. 									 * 因为我需要5页全部渲染完成后,初始化每一页上面的涂鸦功能, 									 * 所以我在最后才返回 Promise, 以保证时序的正确性. 									 * 若仅仅是展示, 没有其它功能的话, 无需返回 Promise. 									 */ 									resolve(); 								} 							}); 						} 					}); 				} 			}); 		}); 	} 	return promise; }
  1. 渲染页面
/**  * 渲染分页内容.  * @param pagination 第N页  * @param page 分页属性  * @param speaker 是否为主讲人  * @param type 类型(上下页区分)  */ protected renderPage( 	pagination: number, 	page: any, 	speaker = false, 	type = 'next' ): Promise<any> | void { 	const documents = this.getContainer() as HTMLDivElement; 	if (documents) { 		const item = this.createPage(pagination), 			pageView = page.view, 			scale = this.getScale(pageView, speaker, item), 			viewport = page.getViewport({scale}); 		if (this.files.page !== pagination) item.style.display = 'none'; 		/** 这个就是保存一些用得到的属性, 具体实现代码就不贴出来了. */ 		this.setPaginationAttrs( 			pagination, 			viewport, 			pageView, 			scale 		); 		/** 创建元素 */ 		const canvas = document.createElement('canvas'), 			context = canvas.getContext('2d'); 		canvas.width = viewport.width; 		canvas.height = viewport.height; 		item.appendChild(canvas); 		/** 判断是要插入还是追加元素 */ 		if (type === 'next') documents.appendChild(item); 		else if (documents.firstChild) documents.insertBefore(item, documents.firstChild); 		/** 渲染文档 */ 		const renderContext = { 			canvasContext: context, 			viewport 		}; 		return page.render(renderContext).promise; 	} }
  1. 获取缩放比
/**  * 获取缩放比.  * @param origin 文档原始尺寸  * @param speaker 是否为主讲人(主讲人默认以宽为基准)  * @param wrapper 画布容器(超出宽度的话, 需要手动设置高度)  * @param width 待变更元素的宽度  */ protected getScale( 	origin: any, 	speaker: boolean, 	wrapper?: HTMLDivElement, 	width?: number ): number { 	width = width ?? 0; 	const documents = this.getContainer() as HTMLDivElement; 	if (documents && !width) width = documents.offsetWidth; 	if (speaker) { 		/** 		 * 左右两边增加了一些偏移量(主讲人与普通用户大小不一样, 这个函数的代码也没啥好贴) 		 * 主讲人默认是以屏幕宽度为基准进行文档缩放的. 		 */ 		const offsetWidth = this.getOffsetWidth(width); 		return Math.round(offsetWidth / origin[2] * 100) / 100; 	} else { 		/** 		 * 非主讲人为了与主讲人显示内容一致, 默认采用高度为基准, 但有特殊情况, 		 * 就是高度保证一致的情况下, 宽度却超出了屏幕的可视区域, 这时候就要将 		 * 文档显示区域所在的容器高度进一步缩短, 保证宽度是在可视区域内, 具体 		 * 实现就看 getHeightAndScale 这个方法了. 		 */ 		const heightAndScale = this.getHeightAndScale(documents, origin), 			scale = heightAndScale.scale; 		if (wrapper && heightAndScale.height) wrapper.style.height = `${heightAndScale.height}px`; 		return scale; 	} }
  1. 判定宽度是否超出可视区域
/**  * 获取文档显示高度与缩放比例.  * @param documents  * @param origin  */ protected getHeightAndScale( 	documents?: HTMLDivElement, 	origin?: any ): { 	height: number; 	scale: number; } { 	documents = documents ?? this.getContainer() as HTMLDivElement; 	let wrapperHeight = 0, lastScale = 0; 	if (documents) { 		const size = this.files.speaker,		// 所记录的主讲人的屏幕尺寸 			width = documents.offsetWidth,		// 当前用户显示容器的可视宽度 			height = documents.offsetHeight;	// 当前用户显示容器的可视高度 		let originWidth; 		/** 获取原始文档宽度 */ 		if (!origin) { 			const pagination = this.getActivePagination(); 			originWidth = pagination.originWidth; 		} else { 			originWidth = origin[2]; 		} 		/** 		 * 计算主讲人的缩放比. 		 * 往下出现的 200 / 150 之类的常数, 为设定好的显示偏移量. 		 */ 		const speakerRatio = Math.round((size.width - 200) / originWidth * 100) / 100; 		/** 非主讲人默认以高度为基准来计算文档显示的缩放比例 */ 		lastScale = Math.round(speakerRatio / (size.height - 150) * (height - 100) * 100) / 100; 		/** 如果以高度为基准的情况下, 判定宽度是否超出可视区域 */ 		const destWidth = Math.round(originWidth * lastScale * 100) / 100, 			offsetWidth = width - (this.isSpeaker() ? 200 : 120), 			diffWidth = destWidth - offsetWidth; 		if (diffWidth > 0) { 			/** 			 * 如果超出可视区域, 重新设定缩放比, 			 * 文档内容显示所在的DIV容器, 将进一步缩小, 			 * 以保证宽度在正常的可视区域内 			 */ 			wrapperHeight = (Math.round((offsetWidth * (size.height - 150)) / (size.width - 200) * 100) / 100); 			lastScale = Math.round(offsetWidth / originWidth * 100) / 100; 		} 	} 	return { 		height: wrapperHeight, 		scale: lastScale 	}; }

总结

翻页控制的代码我就不贴出来了,与请求分片中的判定类似。总的实现,没有太大的难点,理清思路之后就很好实现了,上传速度快慢先不说,秒传校验通过的情况下,基本在 16ms 内完成 Content Download,直至页面渲染出来,整个过程大概 1s 左右,有个前提是我的实现是等 5 个分页都渲染完成后又进行了一系列的涂鸦初始化操作后的时间,不做其它处理,只做展示,速度将会更快。

promise Canvas token https axios error 文件上传 html http 浏览器 Vue ide ios 错误 OT IT
分享到:

您可能还会对下面的文章感兴趣: