使用 Puppeteer 匯出聲享 PPT
聲享是一個基於ThinkJS 開發的線上製作 PPT 平臺。聲享製作的 PPT 支援程式碼高亮、圖片上傳、神奇效果等功能,同時你可以在聲享收藏自己喜歡的 PPT 、對自己的 PPT 進行分類管理。其中有一個 PDF 匯出的功能,可以將自己製作的 PPT 匯出成 PDF 儲存到本地。
功能實現比較簡單,只是提供了一個頁面,使用者需要手動去列印成 PDF。這個方案存在一些問題:
- 由於使用了 iframe 懶載入導致未載入的 iframe 無法正常顯示。
- 該種方案只能列印所有頁面的初始狀態。如果頁面中存在切換動畫,可能會丟失部分 PPT 資訊。
- 需要使用者手動操作,提高了使用難度。
如果是前端來生成 PDF,這些問題基本可以得到解決,但是開發量比較大而且存在一個效率問題。如果 PPT 頁面存在多個 iframe,PDF 的生成時間過長會讓使用者長時間等待,明顯不太合適。最終還是決定服務端來生成 PDF,才有了後來 Puppeteer 的嘗試。
Puppeteer

什麼是 Puppeteer 呢?官方給的解釋是:
Puppeteer is a Node library which provides a high-level API to control Chrome or Chromium over theDevTools Protocol. Puppeteer runsheadless by default, but can be configured to run full (non-headless) Chrome or Chromium.
簡而言之,這貨是一個提供高階 API 的 node 庫,能夠通過 devtool 控制 headless 模式的 Chrome 或者 Chromium,它可以在 headless 模式下模擬任何的人為操作。通過它我們可以實現:
- 生成頁面的截圖或者 PDF
- 抓取 SPA(單頁應用)並生成預渲染內容(即“SSR”(伺服器端渲染))。
- 自動提交表單,進行 UI 測試,鍵盤輸入等。 ...
通過 Puppeteer,我們可以直接使用 Chrome 把我們需要的內容匯出為 PDF。對比以前的實現方式有以下優點:
- 不需要使用者手動操作,服務端生成 PDF 後直接以郵件的方式傳送給使用者。
- PPT 中的動畫可以模擬使用者翻頁的動作觸發,然後以初始、結束兩張 PDF 的方式展示,不會丟失 PPT 內容。
- 不需要考慮圖片/ iframe 跨域等問題。
可以說 Puppeteer 完美的解決來我們一期 PDF 匯出存在的問題。
解決方案
我們基本的實現思路是:
- 開啟一個正常的 PPT 播放頁,獲取需要列印的 DOM 元素並翻頁 。
- 重複第一步操作直至到最後一頁 。
- 清空頁面內容並將前兩步獲得的頁面內容依次填充到當前頁面(為什麼要依次填充會在後面解釋)。
對應上述方案實現的部分程式碼如下:
- 通過 Puppeteer 開啟指定的頁面
// 測試時建議headless設定為false,以便可以直觀看到頁面效果 this.browser = await puppeteer.launch({headless: this.isDebug}); this.page = await this.browser.newPage(); await this.page.goto('https://xxxxx.com', { waitUntil:'networkidle2' }); 複製程式碼
- 開啟頁面後可以通過 Puppeteer 模擬使用者翻頁操作,每次翻頁後快取需要列印的 DOM 元素字串。
let canNext; let i = 0; const content = {}; do { canNext = await this.page.$('.navigate-right.enabled'); const iframes = await this.page.$$('.PluginPage.present iframe').length; content[i++] = { iframe: iframes, domStr: await this.page.$eval('.RevealViewPort', el => el.outerHTML) } if (canNext) { await this.page.click('.navigate-right'); // 等待翻頁動畫 await this.page.waitFor(1000); } } while (canNext); 複製程式碼
- 獲取到要列印的所有頁面 DOM 後,替換掉原來的頁面內容。因為 $evaluate 方法中不支援呼叫外部變數所以只能以傳參的方式使用。
this.page.evaluate(domStr => document.body.innerHTML = domStr, content); 複製程式碼
- 呼叫生成 PDF 的 API
this.page.pdf({ path: path.join(think.ROOT_PATH, 'runtime/xxx.pdf'), format: 'A4', landscape: true, printBackground: true //如果要顯示背景,此屬性要設定為true }) 複製程式碼
- 使用 nodemailer 傳送郵件給使用者。這一步如果想使用本地的 SMTP 服務請用 nodemailer 的 2.7.5 的版本,此版本後這項功能被刪除了。
let transporter = nodemailer.createTransport({ host: 'smtp.ym.163.com', port: 994, secure: true, auth: { user: '[email protected]', pass: 'xxx' } }); transporter.sendMail({ from: '[email protected]', to: '[email protected]',, subject: '【聲享】xxx', attachments: [{ filename: 'xxx.pdf', path: path.join(think.ROOT_PATH, 'runtime/xxx.pdf'), contentType: 'application/pdf' }] }) 複製程式碼
開發中需要注意的問題
- 使用者登入 使用 Puppeteer 開啟頁面相當於你新啟動了一個瀏覽器例項,頁面中的 seession 和 cookie 是空的。而列印所用的頁面需要用到使用者資訊,所以我們登入了一個超管帳號來執行列印操作。在 ThinkJS 中可以通過中介軟體來實現這項功能。在訪問頁面的時候通過引數校驗判斷是否是列印而開啟的頁面,如果是則登入超管帳號。
// 開啟指定頁面時通過校驗後面引數判斷是否以超管登入 module.exports = options => { return async (ctx, next) => { const { token, ctime } = ctx.query; const md5Str = tockenGenerator(); if (md5Str === token) { await ctx.session('userInfo', adminUser); } return next(); }; }; 複製程式碼
- Puppeteer 啟動
如果服務端是執行在 root 許可權下,在啟動 Puppeteer 時要新增 --no-sandbox 引數,否則 Chrome/Chromium 會啟動失敗。詳情見 Running as root without — no-sandbox is not supported 。這個許可權問題在linux以root使用者使用 Chrome 的時候同樣適用。
this.browser = await puppeteer.launch({args:['--no-sandbox']}); 複製程式碼
- iframe 無法載入
聲享支援頁面內嵌入 iframe,在列印的時候碰到一個問題。如果同時在頁面上插入 iframe 過多,後面的 iframe 會直接卡住不再載入。所以 iframe 最好分批插入或者一個一個插入,同時設定10秒來載入iframe。 如果想精確控制 iframe 也可以使用 API 等待 iframe 完全載入再執行後續操作。
for (let i = 0; i < pages.length; i++) { const page = pages[i]; await this.page.$evaluate(content => { const divDom = document.createElement('div'); divDom.innerHTML = content; document.body.appendChild(divDom.childNodes[0]) }, page.domStr); if (page.iframe) await this.page.waitFor(10000 * page.iframe); } 複製程式碼