相信身為前端工程師,一定多少會遇到下載頁面的需求。在這一年多的開發經驗裡就遇到了形形色色至少五次類似的需求,逼人把這個技能刻在心底呀~ 當然在實作之前先了解 spec 到底需要的是什麼,是要開啟列印視窗、直接下載檔案,需不需要先預覽等等,以下分享幾個曾實作過的方法與區別。

Print01

🖨 利用「列印」來實踐,下載要自己另存

以開發來說,只需要前端就能實踐,最簡單快速的方法就是利用 window.print 原生的列印功能去實作,但這個方法會跳出列印的視窗,且需要自己另存新檔才能真正的下載起來,也許使用上不是那麼便利,接著介紹兩種截然不同的方法來做。

Print02

1. window.print() + window.document.write

利用 window.print() 可以將 html 寫進頁面當中,但要讓畫面長的美麗會很~辛~苦!

  • window.print() 跳出列印功能的視窗
  • window.open()可開啟連結至一個新的指定頁面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

const handlePrintPage = () => {
// 要寫進去的內容
const divElem = document.getElementById('printArea').innerHTML;

const printWindow = window.open('', '', 'height=400,width=800');
printWindow.document.write(
'<html><head><title></title><link rel="stylesheet" type="text/css" href="/style/style.css"></head><body>'
);
printWindow.document.write('<antd/dist/antd.css>');
printWindow.document.write('</head><body >');
printWindow.document.write(divElem);
printWindow.document.write('</body></html>');

setTimeout(function () {
printWindow.print();
}, 100);

window.onfocus = function () {
window.close();
};
};

2. React to print

👉 react-to-print

當然如果你的專案是 React 的話,有許多套件可以直接使用,其中 react-to-print 就非常方便,因為它可以將網頁上已經寫好的 component 直接列印出來,讓前端只需要再依據 A4 格式去調整列印的排版,當需求是先預覽後列印的話這個方法超級省事。

以下是起手式的範例參考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

import { useRef } from 'react';
import { useReactToPrint } from 'react-to-print';

const ReactToPrintSample = () => {
const ref = useRef(null);
const handlePrint = useReactToPrint({
content: () => ref.current
});

return (
<div>
<button onClick={handlePrint}>列印 PDF</button>
<PrintArea ref={ref}>要列印的內容</PrintArea>
</div>
);
};

export default ReactToPrintSample;

上述的方法都會呼叫出瀏覽器的列印功能視窗,那有沒有方法可以直接「下載起來」,但又「不透過 Server 拿取檔案」呢?這個需求當初卡了我好多天,後來找到了兩個套件搭配的方式來實踐,但是這個方法坑超級多😉

🖼 利用「截圖」來實踐檔案下載

1. html2canvas + jsPDF

👉 html2canvas
👉 jsPDF

html2canvas 會將網頁裡的 Components 以類似「截圖」為一個個 canvas 元素的方式加入畫面。讓我們可以把網頁內容轉換為「圖片」,例如 PNG 格式,這樣可以進行保存、下載、分享。很適合用在局部截圖處理或是圖檔下載 。而 jsPDF 則是負責「生成 pdf」的這個動作,整個流程就是透過 html2canvas 把畫面截圖並一張張加入 jsPDF 生成的 pdf 頁面。聽起來沒有很難但實際寫起來卻很複雜,當初也是參考了很多教學,踩了很多坑才成功產生我們要的畫面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// html2canvas 搭載 jsPDF

const generatePDF = async () => {
const input = printRef.current!;
try {
// 將 HTML 轉為 canvas
const canvas = await html2canvas(input, {
allowTaint: true,
useCORS: true,
backgroundColor: '#FFFFFF',
});

let leftHeight = canvas.height;
const a4Width = 595.28;
const a4Height = 841.89;
const a4HeightRef = Math.floor((canvas.width / a4Width) * a4Height);

let position = 0;
const pdf = new jsPDF('p', 'pt', 'a4');

const createImpl = async (canvas) => {
// 略過這部分,因為這邊的程式碼很複雜並涉及到畫面渲染。
// 主要是計算要渲染的高度和寬度等。

// 當有多頁時,添加新頁
if (position !== 0) {
pdf.addPage();
}

// 處理頁碼
const pageCount = pdf.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
pdf.setPage(i);
pdf.setFontSize(10);
const pageWidth = pdf.internal.pageSize.width;
const pageHeight = pdf.internal.pageSize.height;
pdf.text(i.toString(), pageWidth - 40, pageHeight - 20);
}

// 添加圖片到 PDF
pdf.addImage(
canvasDraw.toDataURL('image/jpeg', 1.0),
'JPEG',
0,
10,
a4Width,
(a4Width / canvasDraw.width) * height
);

leftHeight -= height;
position += height;

if (leftHeight > 0) {
setTimeout(() => createImpl(canvas), 500);
} else {
// 最後一頁
pdf.save('report.pdf');
}
};

if (leftHeight < a4HeightRef) {
// 僅有一頁的情況
pdf.addImage(canvas.toDataURL('image/jpeg', 1.0), 'JPEG', 0, 0, a4Width, (a4Width / canvas.width) * leftHeight);
pdf.save('report.pdf');
} else {
// 處理多頁情況
await createImpl(canvas);
}

} catch (err) {
console.error("Error generating PDF:", err);
}
};

不過故事不是只到這裡就結束,這個方法在電腦裝置上能夠很順的運行,雖然 canvas 耗時,十頁有圖文、表格的 pdf 大概會跑 10 秒左右,但裝置換到了手機上就 GG,特別是使用 iphone 只要頁面超過 7-8 頁,下載起來的 pdf 就會是全黑的,至今仍找不到原因,只能推測可能是記憶體和性能問題。

📁 還是後端產 pdf 最香!

為了徹底解決這個問題,誕生了下面這個方法,也是我們專案中最後採用的方法:透過後端去實踐。改成後端來產生 pdf,說香不是因為我就可以甩鍋,畢竟我們主管還是大膽的讓我繼續寫下去 😀

1. PdfMake(Node js 環境)

👉 PdfMake

在後端 Node js 環境以 Pdfmake 完成 pdf 的實踐方法不難,主要是在排版會比較花時間,畢竟沒有直覺的畫面可以參考,需要透過打測試 API 的方式來預覽排版結果,一開始不太習慣,但完成之後前端就可以輕鬆的在任何情境透過 API 取得 pdf 檔案,這個速度實在是比 html2canvas 快太多了。

以下是後端程式碼範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// pdfService 

// 定義字體檔案
const fonts = {
Roboto: {
normal: 'fonts/Roboto-Regular.ttf',
bold: 'fonts/Roboto-Medium.ttf',
italics: 'fonts/Roboto-Italic.ttf',
bolditalics: 'fonts/Roboto-MediumItalic.ttf'
}
};

// 定義 PDF 內容
const docDefinition = {
content: [
'這是一個範例 PDF 文件。',
{
table: {
// 表頭
headerRows: 1,
body: [
['欄位1', '欄位2', '欄位3'],
['資料1', '資料2', '資料3'],
['資料1', '資料2', '資料3']
]
}
}
]
};

// pdf to file
const buffer = await new Promise((resolve) => {
const pdfDoc = pdfSetting.createPdfKitDocument(docDefinition);
const chunks = [];

pdfDoc.on('data', (chunk) => {
chunks.push(chunk);
});

pdfDoc.on('end', () => {
const bufferTest = Buffer.concat(chunks);
resolve(bufferTest);
});

pdfDoc.end();
});

return { buffer };


// 回傳格式處理
const result = await pdfService.generatePDF(your data from db);

const fileName = `report_${data.name}.pdf`;

// 設定回應標頭
ctx.set('Content-Disposition', `attachment; filename=${fileName}`);
ctx.set('Content-Type', 'application/pdf');

return finalize(ctx, 200, result.buffer);

以下是前端串接以取回檔案範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

const downloadPDFandSetOverlay = (programCode) {
getReportDataFile(programCode)
.then(response => {
const { data, headers } = response;
const blob = new Blob([data], { type: 'application/pdf' });
const url = window.URL.createObjectURL(blob);

const fileName = extractFileName(headers['content-disposition']) || 'file.pdf';

triggerDownload(url, fileName);
setOverlayContent({ text: '下載完成'});
})
.catch(error => {
setOverlayContent({ text: '發生了一點問題,請重試' });
});
}

// 觸發下載
const triggerDownload = (url, fileName) => {
const downloadLink = document.createElement('a');
downloadLink.href = url;
downloadLink.download = fileName;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
}

// 設定檔案名
const extractFileName = (contentDisposition) => {
if (contentDisposition) {
const matches = contentDisposition.match(/filename\s*=\s*([^;\n]+)/i);
if (matches && matches.length > 1) {
return matches[1].replace(/["']/g, '');
}
}
return null;
}


💬 參考資料:

  1. 使用 html2canvas+jsPDF 实现纯前端 html 导出 pdf