- 作者简介:大家好,我是文艺理科生Owen,某车企前端开发,负责AIGC+RAG项目
- 目前在卷的技术方向:工程化系列,主要偏向最佳实践
- 希望可以在评论区交流互动,感谢支持~~~
最近项目中有个内嵌预览pdf文件的需求,考虑到vue3项目,所以决定使用 vue-pdf-embed 快速实现预览功能。
说干就干,先来个demo~
为了复现项目真实环境,demo分为前端和服务端两部分。
前端负责请求获取对应页的pdf文件流 和渲染 。
服务端负责原文件按页拆分 ,并接受请求返回对应页的pdf文件流。
服务端项目搭建
服务端依赖选型如下:
依赖名称 | 作用 |
---|---|
express | 快速、简洁且灵活的Web应用开发框架 |
pdf-lib | 创建和修改PDF文档 |
- 新建文件夹
node-pdf-demo
,安装 express 和 pdf-lib。运行pnpm i express pdf-lib
。 - 在根目录下准备一个pdf文件,示例中为
book.pdf
。 - 分别新建两个文件:
split.js
用来拆分原文件,server.js
用来封装接口。 split.js
代码如下。
简单来说,就是将原文档通过pdf-lib
读入,拷贝每一页到新的子文档实例中,并写入磁盘中。根目录下运行node split.js
。然后根目录下会有14个pdf文件了,分别对应原文档的1-14页。
js
// split.js
const fs = require('fs')
const PDFDocument = require('pdf-lib').PDFDocument;
(async function() {
// 原文件路径
const pdfPath = 'book.pdf'
// 生成每一页的pdf文件
const docmentAsBytes = fs.readFileSync(pdfPath);
// 加载pdf文件流
const pdfDoc = await PDFDocument.load(docmentAsBytes)
const numberOfPages = pdfDoc.getPages().length;
for (let i = 0; i < numberOfPages; i++) {
// 创建一个子文档实例对象
const subDocument = await PDFDocument.create();
// 拷贝对应页的文件流
const [copiedPage] = await subDocument.copyPages(pdfDoc, [i])
// 将拷贝的结果保存在刚创建的子文档实例对象中
subDocument.addPage(copiedPage);
// 保存子文档实例对象
const pdfBytes = await subDocument.save()
// 将子文档实例对象写入磁盘中,以文件形式存放在根目录下
await writePdfBytesToFile(`file-${i + 1}.pdf`, pdfBytes);
}
})()
// 将字节流写入磁盘中
async function writePdfBytesToFile(fileName, pdfBytes) {
return fs.promises.writeFile(fileName, pdfBytes);
}
server.js
代码如下。
需要重点关注的是app
,是express的实例对象。通过它,可以处理http请求,即后端封装的接口。
js
const fs = require('fs')
const express = require('express')
const app = express()
// 获取某一页的pdf文件流
app.get('/pdf/:page', async (req, res) => {
// 读入请求参数对应页码的文件流
const pdf = fs.readFileSync(`file-${+req.params.page}.pdf`)
// 将文件页码总数写入到header中,返回给前端
res.setHeader('totalPage', 14)
// 返回文件流
res.send(pdf)
})
// 启动服务器
app.listen(3000, () => {
console.log(`Server running on port 3000`);
});
- 运行
node server.js
,命令行输出Server running on port 3000
,说明服务端启动完成。

前端项目搭建
-
快速生成项目:
pnpm create vite vue3-pdf-demo --template vue
-
运行:
cd vue3-pdf-demo
pnpm install
pnpm run dev
-
项目启动完成(文案稍微修改了下~):

-
安装依赖
pnpm i vue-pdf-embed
, 启动项目pnpm dev
。 -
在
components
下新建一个vue文件,命名为PdfPreview.vue
,我们在这个文件中编写pdf预览组件。代码如下:
js
<template>
<div class="container">
<div class="pdf-box">
<VuePdfEmbed annotationLayer textLayer
@rendering-failed="handleRenderFailed" :source="doc"
class="vue-pdf-embed" />
</div>
<div class="operation">
<button @click="handleClick('prev')"><</button>
<span>{{ pageNum }}/{{ pageTotalNum }}</span>
<button @click="handleClick('next')">></button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue';
import VuePdfEmbed, { useVuePdfEmbed } from 'vue-pdf-embed';
// essential styles
import 'vue-pdf-embed/dist/style/index.css'
// optional styles
import 'vue-pdf-embed/dist/style/annotationLayer.css'
import 'vue-pdf-embed/dist/style/textLayer.css'
import axios from 'axios'
onMounted(() => {
request()
})
const pageTotalNum = ref(0); // 总页数
const pageNum = ref(1)
const pdfSource = ref('')
// 用来排查展示失败错误
const handleRenderFailed = (err) => {
console.log(err, 'err');
}
// 请求获取pdf对应页码的文件流,并转为url
const request = async () => {
pdfSource.value = ''
axios.get(`/pdf/${pageNum.value}`, {
responseType: 'blob'
}).then(res => {
pageTotalNum.value = +res.headers.totalpage
const blob = window.URL.createObjectURL(new Blob([res.data], { type: 'application/pdf' }))
pdfSource.value = blob
})
}
const handleClick = (type: string) => {
if (type === 'prev') {
pageNum.value--
} else if (type === 'next') {
pageNum.value++
}
if (pageNum.value <= 0) {
pageNum.value = pageTotalNum.value
} else if (pageNum.value > pageTotalNum.value) {
pageNum.value = 1
}
window.URL.revokeObjectURL(pdfSource.value)
nextTick(() => {
request()
})
}
const { doc } = useVuePdfEmbed({
source: pdfSource
})
// watch(() => pageNum.value, () => {
// doc.value?.destroy()
// }, {
// immediate: true
// })
</script>
<style scoped lang="scss">
.container {
width: 500px;
background-color: #999;
.pdf-box {
width: 100%;
height: 100%;
}
}
.operation {
display: flex;
justify-content: center;
align-items: center;
span {
width: 50px;
text-align: center;
}
}
button {
width: 50px;
height: 50px;
}
</style>
在组件中,通过axios
请求获取对应页码的文件流,返回类型是blob
二进制流格式。然后通过 window.URL.createObjectURL
将blob
文件流格式转为url
,绑定到vue-pdf-embed
组件上的source
属性。
- 在
App.vue
文件中引用组件,代码如下:
js
<script setup>
import PdfPreview from './components/PdfPreview.vue'
</script>
<template>
<div class="app-container">
<div class="title">
<h1>Owen Pdf Preview</h1>
<img src="/src/assets/vue.svg" alt="">
</div>
<PdfPreview msg="Owen Pdf Preview" />
</div>
</template>
<style scoped>
.app-container {
display: flex;
justify-content: space-around;
align-items: center;
width: 70%;
margin: 0 auto;
}
.title {
display: flex;
flex-direction: column;
align-items: center;
}
img {
width: 200px;
height: 200px;
}
</style>
- 其实运行到这里就算完成了。

但实际自测中,发现当进行页码切换时,会不断地将url
映射的blob
文件对象添加到缓存中,浏览器内存会越占越大,导致网页崩溃。

查阅了一些资料,有用的只有这5个。
总结下有两种方案:
- 在
onload
事件中window.URL.revoke
- this.src=''
按照建议尝试后,仍然无效。帖子中的方案都是在原生代码里使用,而在vue3中响应式变量缓存导致无法解决。
相关链接:
- 关于 URL.createObjectURL 可能导致的内存泄露的问题 #367
- Understanding Object URLS for client-side files and how to free the memory
- createobjecturl-memory-leak-in-chrome
- How to properly release memory allocated by a blob in Javascript?
- window.URL.revokeObjectURL doesn't release memory immediately
其实目的是当预览下一页的blob时,将上次的blob清空 。顺着这个思路去源码里寻找答案。 经过无数次坚持与放弃的挣扎 终于看到了 doc.value?.destroy()
销毁pdf实例的代码。

同时在文档中也看到了组合式api用法。明白了~

- 解开被注释的这段代码。通过监听当前页码的变化,当发生变化时,销毁pdf的实例。

再次测试,正常。(已翻到第3页,只有一个blob地址)

总结:本文通过一个demo实现了pdf预览功能,服务端采用express封装接口,返回对应页码的文件流。前端采用vue-pdf-embed组件,实现了pdf预览功能。最后通过前后端完整的交互流程,复现并解决了内存泄漏的问题。
参考文献:
- vue3中使用 vue-pdf-embed 实现pdf文件预览、翻页、下载等功能
- vue3 vue-pdf-embed 实现pdf预览、缩放、拖拽、旋转和左侧菜单选择
- Vue3 实现 PDF 文件在线预览功能
demo: github
日拱一卒,功不唐捐。