在线体验:geekformat.com/zh-CN/pdf/split
PDF 拆分看起来像一个功能,真正做起来其实至少有四种完全不同的需求:
- 自由选页:点选几页,导出成一份新 PDF
- 范围拆分:输入页码范围,按分组拆出去
- 单页导出:每页单独导成一个 PDF 并打包 ZIP
- 均分打包:把整份 PDF 平均拆成几份并打包下载
这篇文章直接按完整实现链路来拆,把一套能上线的 PDF 拆分工具讲清楚。
整体执行流程
flowchart TBA["上传 PDF"] --> B["pdf.js 读取总页数"]B --> C["逐页生成缩略图"]C --> D["选择拆分模式"]D --> E["自由选页 / 范围输入 / 每页导出 / 均分份数"]E --> F["开始拆分"]F --> G["pdf-lib 复制目标页"]G --> H["单文件结果 or 多文件结果"]H --> I["单 PDF 直接下载"]H --> J["JSZip 打包 ZIP 下载"]
这个工具最大的难点,不是导出,而是怎么让这四种模式共用同一个工作区。
一、模式先收紧成四种
type SplitMode = 'free' | 'range' | 'single' | 'chunks'interface SplitResult {type: 'single' | 'zip'url?: stringzipUrl?: stringfileName?: stringfileCount?: numbersizeLabel?: string
}const [pdfFile, setPdfFile] = useState<File | null>(null)
const [thumbnails, setThumbnails] = useState<(string | null)[]>([])
const [pageCount, setPageCount] = useState(0)
const [mode, setMode] = useState<SplitMode>('free')
const [selectedPages, setSelectedPages] = useState<Set<number>>(new Set())
const [highlightedPages, setHighlightedPages] = useState<Set<number>>(new Set())
关键点有两个:
selectedPages用于自由选页highlightedPages用于范围模式和均分模式的视觉提示
二、PDF 页码范围 / 均分拆分:核心工具函数
范围解析
function parseRanges(input: string, max: number): number[][] {const segments = input.split(',').map((item) => item.trim()).filter(Boolean)const result: number[][] = []for (const segment of segments) {const match = segment.match(/^(\d+)(?:-(\d+))?$/)if (!match) continueconst from = parseInt(match[1], 10)const to = match[2] ? parseInt(match[2], 10) : fromif (from < 1 || to > max || from > to) continueconst pages: number[] = []for (let page = from; page <= to; page += 1) {pages.push(page)}result.push(pages)}return result
}
均分计算
function calcChunks(total: number, count: number) {const result: { from: number; to: number; count: number }[] = []const base = Math.floor(total / count)const extra = total % countlet start = 1for (let index = 0; index < count; index += 1) {const size = base + (index < extra ? 1 : 0)result.push({ from: start, to: start + size - 1, count: size })start += size}return result
}
三、上传后立即生成缩略图
PDF 拆分如果没有缩略图,用户很容易选错页。这里上传之后直接跑一遍 pdf.js,把每页预览先做出来:
const loadPdf = useCallback(async (file: File) => {const lib = (window as { pdfjsLib: PdfjsLib }).pdfjsLibconst buffer = await file.arrayBuffer()const doc = await lib.getDocument({ data: buffer }).promiseconst total = doc.numPagessetPageCount(total)setThumbnails(Array(total).fill(null))setSelectedPages(new Set(Array.from({ length: total }, (_, index) => index + 1)))for (let pageNumber = 1; pageNumber <= total; pageNumber += 1) {const page = await doc.getPage(pageNumber)const viewport = page.getViewport({ scale: 0.35 })const canvas = document.createElement('canvas')canvas.width = viewport.widthcanvas.height = viewport.heightconst context = canvas.getContext('2d')if (!context) continueawait page.render({ canvasContext: context, viewport }).promiseconst url = canvas.toDataURL('image/jpeg', 0.7)setThumbnails((prev) => {const next = [...prev]next[pageNumber - 1] = urlreturn next})}
}, [])
四、模式切换时,高亮页要自动联动
范围模式和均分模式的价值,除了导出,更重要的是用户能先看见"哪些页会被拆出去"。
useEffect(() => {if (mode === 'range') {const groups = parseRanges(rangeInput, pageCount)setHighlightedPages(new Set(groups.flat()))} else if (mode === 'chunks') {setHighlightedPages(new Set(Array.from({ length: pageCount }, (_, index) => index + 1)),)} else {setHighlightedPages(new Set())}
}, [mode, rangeInput, pageCount])
五、真正的拆分逻辑,统一在一个 handler 里
const handleSplit = useCallback(async () => {if (!pdfFile) returnconst { PDFDocument } = await import('pdf-lib')setProcessing(true)try {if (mode === 'free') {const pages = Array.from(selectedPages).sort((a, b) => a - b)if (pages.length === 0) returnconst bytes = await pdfFile.arrayBuffer()const src = await PDFDocument.load(bytes)const out = await PDFDocument.create()for (const pageNumber of pages) {const [copied] = await out.copyPages(src, [pageNumber - 1])out.addPage(copied)}const pdfBytes = await out.save()const blob = new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' })setResult({ type: 'single', url: URL.createObjectURL(blob), fileName: 'split.pdf' })} else if (mode === 'range') {const groups = parseRanges(rangeInput, pageCount)if (groups.length === 0) returnconst bytes = await pdfFile.arrayBuffer()const src = await PDFDocument.load(bytes)if (groups.length === 1) {const out = await PDFDocument.create()for (const pageNumber of groups[0]) {const [copied] = await out.copyPages(src, [pageNumber - 1])out.addPage(copied)}const pdfBytes = await out.save()const blob = new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' })setResult({ type: 'single', url: URL.createObjectURL(blob), fileName: 'range_split.pdf' })} else {const JSZip = (await import('jszip')).defaultconst zip = new JSZip()for (let index = 0; index < groups.length; index += 1) {const out = await PDFDocument.create()for (const pageNumber of groups[index]) {const [copied] = await out.copyPages(src, [pageNumber - 1])out.addPage(copied)}const pdfBytes = await out.save()zip.file(`part_${index + 1}.pdf`, pdfBytes)}const zipBlob = await zip.generateAsync({ type: 'blob' })setResult({ type: 'zip', zipUrl: URL.createObjectURL(zipBlob), fileCount: groups.length })}} else if (mode === 'single') {const JSZip = (await import('jszip')).defaultconst zip = new JSZip()const bytes = await pdfFile.arrayBuffer()const src = await PDFDocument.load(bytes)for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {const out = await PDFDocument.create()const [copied] = await out.copyPages(src, [pageNumber - 1])out.addPage(copied)const pdfBytes = await out.save()zip.file(`page_${String(pageNumber).padStart(3, '0')}.pdf`, pdfBytes)}const zipBlob = await zip.generateAsync({ type: 'blob' })setResult({ type: 'zip', zipUrl: URL.createObjectURL(zipBlob), fileCount: pageCount })} else if (mode === 'chunks') {const chunkList = calcChunks(pageCount, chunks)const JSZip = (await import('jszip')).defaultconst zip = new JSZip()const bytes = await pdfFile.arrayBuffer()const src = await PDFDocument.load(bytes)for (let index = 0; index < chunkList.length; index += 1) {const { from, to } = chunkList[index]const out = await PDFDocument.create()for (let pageNumber = from; pageNumber <= to; pageNumber += 1) {const [copied] = await out.copyPages(src, [pageNumber - 1])out.addPage(copied)}const pdfBytes = await out.save()zip.file(`part_${index + 1}_p${from}-${to}.pdf`, pdfBytes)}const zipBlob = await zip.generateAsync({ type: 'blob' })setResult({ type: 'zip', zipUrl: URL.createObjectURL(zipBlob), fileCount: chunkList.length })}} finally {setProcessing(false)}
}, [pdfFile, mode, selectedPages, rangeInput, pageCount, chunks])
六、自由选页模式:Set 状态管理
自由模式里,用户会不断点选和取消点选。这里直接用 Set<number>,状态切换非常干净:
const togglePage = useCallback((pageNumber: number) => {if (mode !== 'free') returnsetSelectedPages((prev) => {const next = new Set(prev)if (next.has(pageNumber)) next.delete(pageNumber)else next.add(pageNumber)return next})
}, [mode])
七、为什么多结果导出一定要接 JSZip
只要进入下面两类场景:
- 每页单独导出
- 一次拆出多份 PDF
就不适合一个个触发浏览器下载了。最稳妥的处理就是打包成 ZIP 一次下载。
也就是说:
- 单结果输出,用
Blob URL - 多结果输出,用
JSZip
八、常见踩坑总结
只有导出,没有可视化缩略图
页数一多,用户很容易选错。
范围模式只校验输入,不做高亮反馈
用户无法确认最终拆分范围。
每页拆分还强行逐个下载
浏览器会连弹很多次下载,体验很差。
四种模式各写一套页面
逻辑重复,维护成本会迅速上涨。
常见问题 FAQ
PDF 拆分后清晰度会变差吗?
不会。拆分只是复制页面,不涉及任何重新编码,原 PDF 清晰度完整保留。
拆分过程会上传文件到服务器吗?
不会。所有操作在浏览器本地完成,文件不会上传到任何服务器。
可以指定页码范围拆分吗?
可以。使用范围模式,输入 1-3,5,8-10 这样的格式即可拆分成多个 PDF。
均分模式会把 PDF 平均分成几份?
可以。将 PDF 平均拆成 N 份,每份页数尽可能均衡,适合需要分发材料的场景。
单页导出会打包成 ZIP 吗?
是的。每页单独导成一个 PDF 文件,全部打包成 ZIP 一次下载,避免多次点击。
支持多少页的 PDF 拆分?
建议单次操作控制在 200 页以内,以获得最佳性能。
PDF 拆分的关键,不是会不会 copyPages,而是能不能把"预览、选页、模式切换、单结果下载、批量 ZIP 下载"统一成一条稳定链路。
只要你把这条链路搭顺,四种拆分模式完全可以在一个工作区里自然共存。

本文详解前端基于 pdf.js、pdf-lib、JSZip 实现的 PDF 拆分工具,覆盖自由选页、范围拆分、单页导出、均分打包四种主流模式,包含核心代码、交互逻辑、缩略图生成及踩坑要点,所有操作均在浏览器本地完成。