前端知库

前端怎样导出 Excel、预览 PDF?数据量大时为什么要放进 Worker?

导出 Excel 常用 SheetJS:把数组转成工作表、放进工作簿,再写成文件触发下载;只要表格文本时也可以按 RFC 4180 拼 CSV。预览 PDF 常用 pdf.js:getDocument 加载文档,getPage 取页,按视口尺寸渲染到 canvas,解析在它自己的 worker 里进行,服务器支持 Range 时还能分块加载。几万行的导出会卡住主线程,所以放进 Web Worker,生成好的数据交回主线程下载。

参考答案

简答

比如后台的订单列表要“导出 Excel”,合同要在页面里直接预览 PDF。这两件事都能在浏览器里完成,常用的库分别是 SheetJS 和 pdf.js。

SheetJS 的流程是三步:json_to_sheet 把对象数组转成工作表,book_new 和 book_append_sheet 组成工作簿,writeFile 写成文件,浏览器里会提示用户下载。如果只要纯文本表格,也可以自己拼 CSV,但要按 RFC 4180 处理字段里的逗号、引号和换行。pdf.js 的流程是 getDocument 加载文档,getPage 取某一页,按 getViewport 的尺寸把页面渲染到 canvas 上。

数据量大时,生成工作簿是一段很长的同步计算,主线程被占住,页面就卡死。SheetJS 官方给的做法是放进 Web Worker:Worker 生成文件,再把数据或 object URL 交回主线程触发下载,因为 writeFile 在 Worker 里用不了。pdf.js 本身就把 PDF 解析放在单独的 worker 文件里。

要注意,SheetJS 官方说明 npm 上的 xlsx 已经过时,要从它的 CDN 安装。pdf.js 的 worker 文件要和主库版本完全一致;PDF 页数多时,官方建议只创建和渲染可见的页面。

用 SheetJS 导出 Excel

SheetJS 官方的导出教程分三步,先把整理好的数据转成工作表,再放进工作簿,最后写成文件:

JavaScript
import { utils, writeFileXLSX } from 'xlsx';

const rows = [
  { 商品: '键盘', 数量: 2 },
  { 商品: '鼠标', 数量: 1 },
];
const worksheet = utils.json_to_sheet(rows);
const workbook = utils.book_new();
utils.book_append_sheet(workbook, worksheet, '订单');
writeFileXLSX(workbook, '订单.xlsx');

文档说明,writeFile 在浏览器里会尝试提示用户下载文件,在 Node.js 里则写到本地目录。writeFileXLSX 是只支持 XLSX 格式的精简版,只导出 XLSX 的网站用它能减小打包体积;要导出 XLS、XLSB 等其他格式时,用通用的 writeFile。数据本来就是二维数组时,可以用 aoa_to_sheet。

安装时有个坑。SheetJS 文档说,npm 公共仓库上的 xlsx 已经过时,最新版本停在 0.18.5,官方以自己的 CDN 为准,安装命令是 npm i --save https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz。

只要文本表格时可以拼 CSV

如果用户只需要能用 Excel 打开的表格,不要样式、不要多个工作表,可以直接拼 CSV。难点在转义,RFC 4180 规定:含有换行、双引号或逗号的字段要用双引号包起来,字段内部的双引号要再写一个双引号转义。下面的例子同时演示了传给 Worker 时“转移”数据的效果:

JavaScript
// 按 RFC 4180 转义:含逗号、引号、换行的字段用双引号包住,内部引号写两遍。
function csvField(value) {
  const text = String(value);
  return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
function toCsv(rows) {
  return rows.map((row) => row.map(csvField).join(',')).join('\r\n');
}

const csv = toCsv([
  ['商品', '备注'],
  ['键盘', '含"快递"费'],
  ['鼠标', '红色,无线'],
]);
console.log(csv.split('\r\n')[1]); // 键盘,"含""快递""费"
console.log(csv.split('\r\n')[2]); // 鼠标,"红色,无线"

// 转移 ArrayBuffer 后,原来的引用就不能再用了。
const buffer = new ArrayBuffer(1024);
const moved = structuredClone(buffer, { transfer: [buffer] });
console.log(buffer.byteLength); // 0
console.log(moved.byteLength); // 1024

“红色,无线”里有逗号,不加引号的话,Excel 会把它拆成两列。“含"快递"费”里的引号写成两个,读取时才知道它是内容而不是字段的结尾。行与行之间用 \r\n,这也是 RFC 4180 规定的换行。

编码也要留意。SheetJS 文档提到,为了和 Excel 兼容,它输出的 CSV 总是在开头带 UTF-8 字节顺序标记(BOM)。自己拼 CSV 时可以照做,在内容最前面加上码点为 U+FEFF 的字符。

为什么大数据量要放进 Worker

生成工作簿、写成 XLSX 都是同步计算,数据越多耗时越长。这段时间主线程没空处理点击和渲染,页面就卡住了。SheetJS 的 Worker 文档说得很直接:库在浏览器里运行时,网站可能会冻结,Worker 可以把重活挪出去,计算仍然在本地进行,数据不会发到服务器。主线程卡顿的原理见《Web Worker 可以解决哪些主线程卡顿?》。

可是 Worker 里不能直接下载文件,文档明确写了 XLSX.writeFile 在 Web Worker 里用不了。官方示例的做法是:Worker 收到数据、生成工作簿,用 write 得到文件数据,再生成 object URL 发回主线程,由主线程创建链接触发下载。

JavaScript
// worker.js
import { utils, write } from 'xlsx';

self.onmessage = ({ data }) => {
  const workbook = utils.book_new();
  utils.book_append_sheet(workbook, utils.json_to_sheet(data.rows), '订单');
  const file = write(workbook, { type: 'array', bookType: 'xlsx' });
  const blob = new Blob([file], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
  self.postMessage({ url: URL.createObjectURL(blob) });
};

type: 'array' 让 write 返回 ArrayBuffer,文档里列出的输出类型还有 base64、二进制字符串等。

如果不想生成 object URL,也可以把 ArrayBuffer 直接发回主线程。默认 postMessage 会复制一份数据,传几十 MB 时复制本身也慢。把它放进转移列表,内存就直接移交给接收方,MDN 说明转移后原来那个线程里的 buffer 不能再用了,所以例子里转移后 byteLength 变成了 0。

用 pdf.js 预览 PDF

pdf.js 分成几层:core 层负责解析和解释二进制的 PDF,对应 pdf.worker.mjs;display 层在 core 之上提供渲染 API,对应 pdf.mjs。所以 PDF 的解析本来就在 worker 里进行,使用时要指定 worker 文件的地址。官方 hello world 示例的核心是:

JavaScript
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs';

const pdf = await pdfjsLib.getDocument({ url: '/contract.pdf' }).promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 1.5 });
const outputScale = window.devicePixelRatio || 1;

const canvas = document.getElementById('the-canvas');
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
canvas.style.width = Math.floor(viewport.width) + 'px';
canvas.style.height = Math.floor(viewport.height) + 'px';

const transform = outputScale !== 1 ? [outputScale, 0, 0, outputScale, 0, 0] : null;
page.render({ canvasContext: canvas.getContext('2d'), transform, viewport });

为什么 canvas 的宽高要乘 devicePixelRatio?高分屏上一个 CSS 像素对应多个物理像素,canvas 只按 CSS 尺寸画会发虚,所以画布按物理像素放大,再用 transform 把绘制坐标也放大。官方例子还提醒,同一个 canvas 不能同时画两页。

另外两个细节来自官方 FAQ:pdf.js 和 pdf.worker.js 必须是完全相同的版本;页面很多时,每页渲染都要分配几 MB 内存,建议只创建和渲染可见的页面。

大 PDF 怎样不用等全部下载完

pdf.js 的 FAQ 说,在浏览器和服务器都支持的情况下,它会自动用 HTTP Range 请求获取还没加载的部分,所以不用下载完整文件就能显示文档。Range 请求本身见《HTTP Range 请求适合什么场景?》。

getDocument 有几个相关参数,定义在 pdf.js 的 API 源码注释里:disableRange 默认 false,服务器支持部分内容请求时按块获取;rangeChunkSize 是每次 Range 请求的最大字节数,默认 65536;disableAutoFetch 默认 false,开启 Range 时 pdf.js 即使当前页用不到也会继续预取后面的数据。PDF 放在其他域名下时,服务器还要配好跨域响应头,官方示例的注释也提醒了这一点。

已经拿到本地 File 对象时,读成 ArrayBuffer 传给 getDocument({ data }) 就行,文件对象和 object URL 的用法见《File、Blob 和 object URL 怎样用于文件预览?》。

常见追问

导出的数据要不要交给后端生成?

看数据在哪里。数据已经在页面上时,前端生成省掉一次请求。数据要从数据库全量查出时,前端得先把所有数据拉下来再生成,这种情况交给后端生成文件,前端只负责下载更合适,大文件下载的做法见《大文件下载和流式下载怎样做?a[download]、Range 断点和 Service Worker 各适合什么场景?》。

为什么 Worker 里不能直接调用 writeFile 下载?

下载要通过页面创建链接、触发点击,Worker 里没有 DOM。SheetJS 文档明确说 writeFile 在 Worker 里不能用,所以 Worker 只负责生成文件数据或 object URL,交给主线程去下载。

pdf.js 渲染出来的文字能选中复制吗?

只渲染 canvas 的话不能,canvas 上只是像素。官方文档介绍,pdf.js 的 viewer 层建在 display 层之上,是 Firefox 等内置 PDF 查看器的界面,可以作为自己定制查看器的起点。