文概述:如何使用 nodejs 在服務端將 html 批量轉成 pdf 并客戶端下載。
目標一:使用 node 在服務端實現 html 批量轉成 pdf
分為兩步:
自己轉(存疑)?或者找個中間件。自己轉的話。。。算了,找插件。
網上搜到了 html-pdf,看了下周下載量接近 7W,版本迭代 21 個,最近更新時間 latest,不錯,整體滿足心里預期。
api使用如圖:
提供了三種接口:文件、stream 流、buffer。不同返回值類型,使用 toFile()需要 2 個參數:文件地址(末尾要有文件名)、回調函數。
toFile()結果會在指定文件夾生成 pdf 文件;
toStream()、toBuffer()可以在回調里拿到pdf文件數據流
目標二:將 PDF 傳到客戶端
怎么傳輸?
經過分析歸納,可以濃縮為 3 個點:
以下分析可以略過直接到結果:
分析:測試用的是 express 框架,那么可以排除原生 res.end()(存疑),為啥排除它?有 send 我干嘛還用 end;將 pdf 傳向客戶端又不是下載或寫入文件,排除 createWriteStream()方法;PDF 文件可以自己跑過去嗎?肯定不行!如果啥都不動能不能成功下載文件?也可以。鏈接下載,好像挺萬能。base64 能用嗎?。。。好像是圖片在用!不知道對文件好使不好使(存疑)。
篩選后:
傳輸方法:res.send()、管道 pipe();
傳輸內容:stream 流、buffer、下載鏈接;
使用下載鏈接:
經過分析了,下載鏈接肯定能實現這個功能!為什么呢?只要把 pdf 的文件路徑用變量存下來,然后返回給前端,拿著絕對路徑地址模擬點擊下載應該就能實現!但這種方法并沒有在真正意義上跟要傳輸的內容打交道,所以還值得繼續探索。
最終決定使用,管道pipe()發送stream流輸出PDF文件流到客戶端
目標三:客戶端可以成功接收并自動下載
網上搜索有:Windows.open()方法,iframe 等。感覺都有點偏。
本文使用a標簽實現,其他方法另行嘗試。
ajax + a標簽
$.ajax({
url:'url',
responseType:'blob',
success (res) {
console.log(res.toString('utf-8'));
// 創建 blob 對象,解析流數據
// , {
// 如何后端沒返回下載文件類型,則需要手動設置:type: 'application/pdf;chartset=UTF-8' 表示下載文檔為 pdf,如果是 word 則設置為 msword,excel 為 excel
// type: 'application/pdf;chartset=UTF-8'
// }
const blob = new Blob([res]);
const a = document.createElement('a')
// // 兼容 webkix 瀏覽器,處理 webkit 瀏覽器中 href 自動添加 blob 前綴,默認在瀏覽器打開而不是下載
const URL = window.URL || window.webkitURL
// // 根據解析后的 blob 對象創建 URL 對象
const herf = URL.createObjectURL(blob)
// 下載鏈接
// a.href = 'url'
a.href = herf
// // 下載文件名,如果后端沒有返回,可以自己寫 a.download = '文件.pdf'
a.download = '文件.pdf'
// document.body.appendChild(a)
a.click()
// document.body.removeChild(a)
// 在內存中移除 URL 對象
window.URL.revokeObjectURL(herf)
}
})
傳統jQuery接收數據流后,需要使用new Blob([res])再處理才能繼續使用,
而,fetch也需要,不過簡便多了
fetch+ a標簽
//1.請求
let res = await fetch([地址]);
//2.解析
let data = res.blob()
//3.創建a標簽
let eleA = document.createElement('a')
//4.創建鼠標事件對象
let e = document.createEvent("MouseEvents")
//5.初始化事件對象
e.initEvent("click", false, false)
?
?
document.body.appendChild(eleA)
eleA.download = 'index.zip'
eleA.href = data
//給指定的元素,執行事件 click 事件
eleA.dispatchEvent(e)
document.body.removeChild(eleA)
報錯:
eleA.href直接使用 blob 數據作為下載鏈接是不行的,必須使用 window.URL 對象,果然無知者無畏,更改后的代碼如下:
//1.請求
let res=await fetch([地址]);
//2.解析
let data=await res.blob();
const a = document.createElement('a')
const URL = window.URL || window.webkitURL
const herf = URL.createObjectURL(data)
a.href = herf
a.download = '文件.zip'
a.click()
window.URL.revokeObjectURL(herf)
使用 oFile() 測試
server.get('/', async (req, res) => {
toPdf(res);
});
?
?
function toPdf (res) {
let archive = archiver('zip', {
zlib: { level: 9 } // 設置壓縮級別
});
archive.on('error', function(err){
throw err;
});
archive.pipe(res);
for (let i = 0; i < 100; i++) {
let html = `<div style="width: 100px;height:100px;background:#fff;color:red;font-size:16px;font-weight:bold">這是第 <span>${i}</span> 個 pdf</div>`;
pdf.create(html, options).toFile(`./static/${i}.pdf`, () => {
archive.glob('static/*')
archive.finalize()
})
}
}
看命令窗口偶爾還會報 Queue Closed 錯誤;
觀察發現程序一邊轉 pdf,一邊下載,而且是按照順序轉換下載有 0.1.2.3...,最后壓縮返回,這個過程循環很少時發現沒問題,但次數增加很多后如20,100次,當for循環到最后一次時,直接執行archiver.finalize(),完。。。結束了,所以造成Queue Closed原因是沒限制archiver.finalize()執行時機???
找證據:
官方文檔顯示:
// finalize the archive (ie we are done appending files but streams have to finish yet)
// 完成存檔
// 'close', 'end' or 'finish' may be fired right after calling this method so register to them beforehand
// 調用此方法后,可能會立即觸發“ close”,“ end”或“ finish”,因此請事先注冊
archive.finalize();
果然,,
添加結束條件:
pdf.create(html, options).toFile(`./static/${i}.pdf`, () => {
if (i === 99) {
archive.glob('static/*')
archive.finalize()
}
})
執行成功,Queue Closed沒出現,并且在瀏覽器自動下載了一個壓縮包,打開后
發現只有 91 個文件,缺少了最后幾個文件,
for 循環明明執行了 100 次。百思不得解,開始我以為跟網絡有關系,畢竟有傳言只要(網絡)夠快什么(隊列關閉)錯誤都追不上你。后來我發現用筆記本執行程序這個錯誤會頻繁出現,
查看了 html-pdf 源碼猜測會不會是同步在阻塞,導致循環結束后 PDF 生成文件還未完成導致 Queue Closed,怎么解決呢,閉包!具體原理未知(存疑),
經過修改
((html, i) => {
pdf.create(html).toFile(`./static/${i}.pdf`, () => {
if (i === 49) {
archive.glob('static/*')
archive.finalize();
}
})
})(html, i)
成功!!!打開壓縮文件查看文件總數,
一切正常,Good!
使用 toStream()測試
(function(html, i) {
pdf.create(html).toStream((err, stream) => {
archive.append(stream, { name: `${i}.pdf`});
if (i === 99) {
archive.finalize();
}
});
})(html, i)
我去,第一次跑果然有問題,反復執行多次總是缺少前幾個文件
第一次跑,少了第一個文件 1.pdf:
第二次,循環執行 50 次,結果只有 45 個成功,前 5 個失敗:
命令行打印也證明了這一點。。。每次失敗個數竟然還不同,但還好有個規律它們都是前幾個
。。。
繼續執行,竟然還有其他報錯類型。。。無語。。
大致意思是:輸入資源必須是 Stream 流或者 Buffer,可能是在使用 archiver.append 時塞入了 undefined 之類的,給它上個保險,如果值存在才執行
stream && archive.append(stream, { name: `${i}.pdf`});
綜合問題共有 2 個:1.并不是所有 html 片段都進行了轉換 PDF 的操作,可能會隨機出現遺漏,比如 45.46.突然就到 48;2.即使所有 html 都進行了轉換操作,還總是缺少前面幾個文件。
問題一解決:
html-pdf 源碼如下圖
研究發現 html-pdf 的 toStream 應該是一個異步方法,查看源碼后 stream.on('end')也證明了這一點。由于不是順序生成 stream 流,那么最后一個流生成并不代表所有都完成,所以當用 i===99 判斷結束就有問題,可能會跳過某一個不執行轉換PDF需要len=50來避免,然而添加過后,每個文件都進行了PDF轉換,但是stream顯示為undefined,進而PDF文件也總是少前面幾個,導致 i=99 出現時還有好多 toStream 沒有完成,SO 第一個問題就出現了;(注:不過異步操作也因此避免同步帶來方法一的問題:隊列關閉錯誤。)
原因找到,解決辦法就是添加變量手動強行控制進程:
如圖,len 初始 100,減為 0 時代表所有都已經轉換,可以結束
let len = 100;
然后,開始計數,不到最后一個完成 stream 流轉換不結束
pdf.create(html).toStream((err, stream) => {
len--;
stream && archive.append(stream, { name: `${i}.pdf`});
if (len === 0) {
archive.finalize();
}
});
命令行打印下錯誤信息看看怎么回事控制臺
問題二解決:
先打印下錯誤信息 console.log('err:', err)
暴露了,PDF generation timeout. 一個 timeout 已經夠了,能夠說明很多問題,html-pdf 提供了轉換超時限制,時間超出 timeout 自然無法成功轉換成 stream 流輸出,解決辦法更簡單:
官方文檔,給出了一個配置項 options:{}對象,其中就有 timeout 設置,我們可以視情況放大此參數,
// 一分鐘內轉 pdf 不成功,則視為失敗
let options = {
timeout: 60000
}
pdf.create(html, options).toStream((err, stream) => {
。。。。。。
});
繼續測試,終于完成。。。
結語:
1.本文走了許多彎路,踩了多多個坑;
2.文中標記存疑的地方依然有很多,都是等待去學習理解的地方;
3.對于名詞、問題的解釋描述不夠精準透徹,需要深度挖掘對知識點的認知;
4.對問題的解決方式不夠標準、熟練,這才是造成多走彎路的原因;
5.雖然解決問題才是我們的最終目的,但是仍需追求解決方式的多樣化,找到問題的根源 格物致知 才能給自己醍醐灌頂之感;
文轉載自微信公眾號「全棧修仙之路」,作者阿寶哥。轉載本文請聯系全棧修仙之路公眾號。
在日常工作中,文件上傳是一個很常見的功能。在上傳文件時,我們可以選擇上傳單個文件,也可以通過設置 multiple 屬性來上傳多個文件。
本文阿寶哥將介紹如何上傳目錄及如何壓縮目錄并上傳,壓縮目錄的功能是通過 JSZip 這個庫來實現。利用這個庫還可以實現在線預覽 ZIP 文件的功能,感興趣的小伙伴可以閱讀 JavaScript 如何在線解壓 ZIP 文件? 這篇文章。下面我們先來介紹如何實現壓縮目錄并上傳的功能。
1.1 選擇目錄
在瀏覽器端,要實現壓縮目錄并上傳的功能。首先我們要先實現選擇目錄的功能,要實現該功能,我們可以直接使用 HTMLInputElement 元素的 webkitdirectory 屬性:
<input type="file" id="uploadFile" webkitdirectory />
當設置了 webkitdirectory 屬性之后,我們就可以選擇目錄了。當阿寶哥選擇了 useAxios 目錄之后,就會顯示以下確認框:
點擊上傳按鈕之后,我們就可以獲取文件列表。列表中的文件對象上含有一個 webkitRelativePath 屬性,用于表示當前文件的相對路徑。在進行目錄壓縮的時候,我們就會使用到該屬性。
雖然通過 webkitdirectory 屬性可以很容易地實現選擇目錄的功能,但在實際項目中我們還需要考慮它的兼容性。比如在 IE 11 以下的版本就不支持該屬性,其它瀏覽器的兼容性如下圖所示:
(圖片來源 —— https://caniuse.com/?search=webkitdirectory)
1.2 壓縮目錄
在 JavaScript 如何在線解壓 ZIP 文件? 這篇文章中,阿寶哥介紹了在瀏覽器端如何使用 JSZip 這個庫實現在線解壓 ZIP 文件的功能。JSZip 這個庫除了可以解析 ZIP 文件之外,它還可以用來創建和編輯 ZIP 文件。這里阿寶哥基于 JSZip 庫提供的 API,封裝了一個 generateZipFile 函數:
function generateZipFile(
zipName, files,
options = { type: "blob", compression: "DEFLATE" }
) {
return new Promise((resolve, reject) => {
const zip = new JSZip();
for (let i = 0; i < files.length; i++) { // 添加目錄中包含的文件
zip.file(files[i].webkitRelativePath, files[i]);
}
zip.generateAsync(options).then(function (blob) { // 生成zip文件
zipName = zipName || Date.now() + ".zip";
const zipFile = new File([blob], zipName, {
type: "application/zip",
});
resolve(zipFile);
});
});
}
在以上代碼中,我們使用 file(name, data [,options]) 方法,把目錄中的文件依次添加到 zip 對象中,然后再通過 generateAsync 方法來生成 ZIP 文件。在生成 ZIP 文件時,我們可以設置該文件的類型。這里我們設置的默認類型為 blob 類型,除了支持 blob 類型之外,它還支持 base64、uint8array 和 arraybuffer 等類型。
1.3 上傳壓縮 ZIP 文件
在壓縮目錄生成 ZIP 文件之后,我們就可以通過 XMLHttpRequest 或 fetch API 來上傳壓縮文件。下面阿寶哥將以 axios 為例,來實現文件上傳的功能。
html 代碼
<input type="file" id="uploadFile" webkitdirectory />
<button id="submit" onclick="uploadFile()">上傳文件</button>
js 代碼
const uploadFileEle = document.querySelector("#uploadFile");
const uploadOptions = { needZip = true };
const request = axios.create({
baseURL: "http://localhost:3000/",
timeout: 5000,
});
async function uploadFile({ needZip } = uploadOptions) {
if (!uploadFileEle.files.length) return;
let fileList = uploadFileEle.files;
if (needZip) { // 對目錄進行ZIP壓縮
let webkitRelativePath = fileList[0].webkitRelativePath;
let zipFileName = webkitRelativePath.split("/")[0] + ".zip";
fileList = [await generateZipFile(zipFileName, fileList)];
}
uploadFiles({ // 上傳文件列表
url: "/upload/multiple",
files: fileList,
});
}
在 uploadFile 函數中,如果有啟用目錄壓縮功能,我們就會調用 generateZipFile 函數生成 ZIP 文件,如果沒有的話,就會直接調用 uploadFiles 函數來上傳目錄中的所有文件,當然你也可以對文件列表進行過濾,比如限制文件類型或文件的大小等。
下面我們來看一下 uploadFiles 函數的具體實現:
function uploadFiles({ url, files, fieldName = "file" }) {
if (!url || !files.length) return;
let formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append(fieldName, files[i], files[i].name);
}
return request.post(url, formData);
}
在 uploadFiles 函數中,我們通過創建 FormData 對象來保存文件的信息,然后通過 request(axios 實例)來執行上傳操作。
2.1 接收 ZIP 文件
在服務端要實現文件上傳功能也比較簡單,這里阿寶哥以 koa 為例來實現文件上傳的功能。如果你對 koa 還不了解的話,建議你先大致瀏覽一下 koa 的官方文檔。
const path = require("path");
const Koa = require("koa");
const cors = require("@koa/cors");
const multer = require("@koa/multer");
const Router = require("@koa/router");
const app = new Koa();
const router = new Router();
const UPLOAD_DIR = path.join(__dirname, "/public/upload");
const storage = multer.diskStorage({
destination: async function (req, file, cb) { // 設置文件的存儲目錄
cb(null, UPLOAD_DIR);
},
filename: function (req, file, cb) { // 設置文件名
cb(null, `${file.originalname}`);
},
});
const multerUpload = multer({ storage });
router.get("/", async (ctx) => {
ctx.body = "壓縮文件目錄上傳示例(阿寶哥)";
});
router.post(
"/upload/multiple",
multerUpload.fields([
{
name: "file",
},
]),
async (ctx, next) => {
ctx.body = {
status: "success",
msg: "文件上傳成功",
};
}
);
// 注冊中間件
app.use(cors());
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, () => {
console.log("app starting at port 3000");
});
在以上代碼中,我們通過 @koa/multer 這個中間件來處理文件上傳,對該中間件感興趣的小伙伴,可以自行閱讀官方文檔。接下來,我們來繼續討論另一個問題 —— 如何接收目錄并按照文件目錄結構進行存放?
2.2 接收文件目錄
前面我們已經知道,當 input[type="file"] 使用了 webkitdirectory 屬性之后,返回 File 對象的 webkitRelativePath 屬性就會存放當前文件相對于當前目錄的相對路徑:
因此當我們在服務端處理文件目錄上傳的功能時,我們就可以通過該屬性來創建對應的目錄結構,具體的處理邏輯如下所示:
const fse = require("fs-extra");
const storage = multer.diskStorage({
destination: async function (req, file, cb) {
// 把useAxios@demo.vue中的@替換為路徑分隔符
let relativePath = file.originalname.replace(/@/g, path.sep);
let index = relativePath.lastIndexOf(path.sep);
let fileDir = path.join(UPLOAD_DIR, relativePath.substr(0, index)); // 生成文件路徑
await fse.ensureDir(fileDir); // 確保當前目錄存在
cb(null, fileDir);
},
filename: function (req, file, cb) {
let parts = file.originalname.split("@"); // 對路徑進行拆分
cb(null, `${parts[parts.length - 1]}`); // 獲取文件名
},
});
為什么 originalname 文件原始名稱會包含 @ 符號呢?這樣因為使用 useAxios/demo.vue 這種路徑形式時,是不能獲取到完整的路徑名稱,只能獲取到文件名。為了解決這個問題,阿寶哥在上傳文件時,手動把文件相對路徑中的 / 符號替換為 @ 然后再進行上傳,對應的處理邏輯如下:
function uploadFiles({ url, files, fieldName = "file" }) {
if (!url || !files.length) return;
let formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append(fieldName, files[i], files[i].webkitRelativePath.replace(/\//g, "@"));
}
return request.post(url, formData);
}
好的,壓縮目錄上傳和目錄上傳已經介紹完了,感興趣的小伙伴可以動手試試看。由于完整的示例代碼內容比較多,阿寶哥就不放具體的代碼了。有需要的小伙伴,可以訪問以下地址瀏覽示例代碼。
https://gist.github.com/semlinker/af57349c16d203cc2ec845d4b5a6b445
注意:以上代碼僅供參考,請根據實際業務進行調整。
本文阿寶哥介紹了如何利用 input[type="file"] 元素的 webkitdirectory 屬性來實現選擇目錄的功能,然后利用 JSZip 這個庫來實現目錄壓縮,最后通過 axios 來上傳目錄壓縮后的 ZIP 文件 。此外,阿寶哥還介紹了如何使用 koa 來實現接收目錄并按照文件目錄結構進行存放的功能。
JSZip 官方文檔
MDN - webkitdirectory
JavaScript 如何在線解壓 ZIP 文件?
*請認真填寫需求信息,我們會在24小時內與您取得聯系。