0


Puppeteer将动态html页面生成pdf(终极解决方案)

开通掘金好几年一直没有写文章,近一年经常有朋友问我将动态的h5/vue/react/原生js 页面转成pdf,我觉得有必要写个文章,给大家提供一套经过多个项目验证的完整解决方案的思路;觉得有用可以点赞支持一下;

目前将html页面转成pdf文件的主流方式

1.不论是哪种方式,只要是将h5/vue/react/原生js 页面生成pdf,都会遇到的问题

1.各个浏览器、手机兼容性问题;
2.内容截断问题; 包括不限于 echart图表截断、动态table行截断问题
3.业务关系紧密的内容和描述需要尽可能放在一起打印
4.生成动态内容pdf等问题
5.批量下载pdf稳定性问题
6.如果是大文件 前端等待时间较长,如果关闭页面生成失败

2.针对以上问题的解决方案

方案1.前端生成 页面转pdf工具 + nb-fe-pdf算法

页面转pdf工具,比如:htmlToCavas/window.print/jspdf

  • 这个方案可以解决内容截断和生成动态内容,但是有以下问题* 各个浏览器、手机兼容性问题;* 批量下载pdf稳定性问题* 如果是大文件 前端等待时间较长,如果关闭页面生成失败

方案2.node端 node + Puppeteer + nb-fe-pdf算法(推荐)

  • 这个方案可以解决以1到6所有问题,并且经过多个项目的验证,不管是用vue/react还是别的框架,pc端还是H5端,ui框架用的elementui/vant/antd等,只要最终渲染结果是 DOM 结构都可以理想的实现分页下载;
  • 同步方案:* 适用于并发比较小(10左右),要下载的内容比较少(1M左右)时以内的场景
  • 异步方案(墙裂推荐)* 适用于各种场景,高并发、大文件的情况也适用;* 需要处理队列中的任务状态,成功要做什么、失败了要做什么等等

3.nb-fe-pdf算法

3.1 nb-fe-pdf算法思想

分页效果图

nb-fe-pdf算法思想图

  • nb-fe-pdf算法是在页面dom结构生成完成之后,根据标记,将页面分成一个个模块,计算这些模块的高度,将一个个模块合理的放到A4纸中;
  • 类似于拼图游戏,这个拼图游戏是要将一个个模块合理的放到A4纸中;
  • 上图例子中,模块1可能处于pdf页面的尾部,标题1和文本1可能在上一页,说明1可能被分到下一页了, 说明1是描述文本1,我们希望他们放在一起,给模块1所在的外层div加一个flag标记;
  • 最终分页问题转化为将一个个flag,合理的放到A4纸中

view层约定-普通模块(高度是固定的)

  • 给业务关系比较紧密的模块的外层元素,加上 class=“page-splite-flag”
 <div><div class="page-splite-flag">模块1<title>标题1</title><div><p>文本1</p><p>文本2</p><p>文本3</p></div><p>说明1</p></div><div class="page-splite-flag">模块2动态table2 <xx-tabel>我有多少行,取决于数据库有多少条数据 </xx-table></div> <div class="page-splite-flag">模块3<title>标题2</title><div id="echarts1">饼状图、柱状图</div></div></div> 

view层约定 - 带有table的模块(高度未知,根据数据多少来展示)

    • 默认ui组件是基于elementui* 如果table内容的长度是动态的 引入cardTable 组件,用slot的方式在对应的信息放进去;* 如果table长度不是动态的就可以不用
  • 其实在算法层,最终用到的是"card-table"、“card-table-top-wraper”、“card-table-wraper”、“card-table-bom-wraper” 这些class类名,意味着不论什么ui框架的table组件,或者是原生table,只要按照这种结构,给对应的位置加上这些class名,就可以正确的完成动态table分页;
// cardTable.vue
<template><div class="card-table page-splite-flag"><div class="card-table-top-wraper"><slot name="card-table-header" /></div><div class="card-table-wraper"><slot name="card-table" /></div><div class="card-table-bom-wraper"><slot name="card-table-footer" /></div></div>
</template> 
// usage.vue
 <card-table ><template #card-table-header><div><h3>投资产品选择</h3></div><h4 style="padding-left: 20px">定投产品总览</h4></template><template #card-table><ts-table:table-data="regularList":table-head="regularHead":table-title-obj="{ hide: true }":paginationHide="true"/></template> <template #card-table-footer> <p>表格说明信息1</p> <p>表格说明信息2</p> <p>...</p></template></card-table> 

3.2 nb-fe-pdf使用方式

安装

nbnpm i nb-fe-pdf -S // nbnpm 下载(内部下载)
or 
npm install nb-fe-pdf -S // 无法使用nbnpm,可以用npm或yarn

源码地址:
https://gitlab.newbanker.cn/nbnpm/nb-fe-pdf (内部访问)
or
https://www.npmjs.com/package/nb-fe-pdf 

参数说明

export interface PrintParmas {moduleMap: ModuleMap | ModuleInfo; // moduleMap是由多个ModuleInfo 组成的selectModule?: string[]; // 要下载的模块名["analy", "pension"]injectClass?: BaseClass; // injectClass不传默认是elementui的el-tablecallback?: Function; // 分页执行完毕的回调函数
}

const moduleInfo: ModuleInfo = {moduleId: "#print-analy-wraper", // 模块id,给每个要下载的组合所有页面的根元素加上idpageInfo: {title?: string; // 模块标题needTpl?: boolean; // 是否需要头尾模板,默认为falsedefaultType?: PrintType; // 模板类型,需要needTpl为true,waterMark?: boolean // 是否需要水印, 默认为falsewaterMarkConfig: { // 需要waterMark为truewaterMarkId: string; // 要做水印的根元素idwaterMarkContent: string; // 水印内容}; };}

// 模板类型
export enum PrintType {NORMAL_TYPE = "NORMAL_TYPE", // 无头无尾HEADER_TYPE = "HEADER_TYPE", // 有头无尾FOOTER_TYPE = "FOOTER_TYPE", // 无头有尾HEADER_FOOTER_TYPE = "HEADER_FOOTER_TYPE", // 有头有尾
} 

适配不同的UI框架

  • 在pageInfo 传入对应ui框架的talbe 的injectClass类名即可
 // defaut use elementui table component classnamesstatic cardTableTBHeaderWraper = "el-table__header-wrapper"; // table header wraper classnamestatic cardElRowClass = "el-table__row"; // table body rowclassnamestatic elTableBodyWraper = "el-table__body-wrapper"; // table body wraper classname

const injectClass = {cardTableTBHeaderWraper: 've-table-header' cardElRowClass: 've-table-body-tr',elTableBodyWraper: 've-table-body'
} 

快速开始-下载单个模块语法

// javascript 引用方式
import { Print } from 'nb-fe-pdf/lib/src'

// typescript 引用方式
import { Print } from 'nb-fe-pdf'

语法 new Print(moduleInfo) // 下载单个模块
也就是
new Print({moduleId: "#print-operate-report-wrapper", pageInfo: {defaultType: 'HEADER_TYPE',needTpl: true,},}) 

下载单个模块 - demo

<section><!-- 页眉页脚模板 --><pdf-tpl><pdf-tpl/><!-- 正文部分用自定义id包裹 用以分页 --><div id="print-operate-report" class="页面样式"> <!-- ※必须※ node中会根据查询isPDFVisible是否存在来判断页面是否加载完毕 然后继续向下执行 --> <div v-if="isVisible" id="isPDFVisible"></div> <!-- 使用时,只要是待分页的模块都需要用page-splite-flag包裹起来 (table类型除外,参考下方) --> <div class="page-splite-flag 页面样式">XXXXX</div> <!-- 表格的包裹方式区别于其它 --> <card-table>         <template #card-table-header>             <!-- 若有表格标题部分 用#card-table-header包裹-->         </template>         <template #card-table>             <!-- table部分 用#card-table包裹-->             <ve-table XXXX/>         </template> </card-table></div>
</section>
<script> import PDFTpl from '@/components/PDFTpl/index.vue'import CardTable from '@/components/CardTable'import { Print } from '@/modules/nb-fe-pdf/lib/index'data () {return {isResponseSuccess: true,isVisible: false,}},async mounted () {try {// 渲染页面的相关请求await 请求1 请求2....} catch (err) {// 有JS异常时将isResponseSuccess置为falsethis.isResponseSuccess = false} finally {if (this.isResponseSuccess) {this.handleGeneratePDF()setTimeout(() => {this.isVisible = true}, 600)}}}methods:{handleGeneratePDF () {// 生成PDF相关new Print({moduleId: '#print-operate-report', // 自定义页面idpageInfo: {defaultType: 'HEADER_TYPE',// 页眉页脚类型:HEADER_TYPE有头无尾;NORMAL_TYPE 无头无尾;FOOTER_TYPE无头有尾;HEADER_FOOTER_TYPE有头有尾needTpl: true,waterMark: true, // 是否需要水印, 默认为falsewaterMarkConfig: {waterMarkContent: this.pra,waterMarkId: 'print-operate-report', //需要做水印的元素的id},},})}} </script>
 <style lang="css"> @import 'nb-fe-pdf/print.css'; </style> 
// 写在公用方法中
export constdownloadPdf = (blobData, downloadFileName) => {const link = document.createElement('a')const url = window.URL.createObjectURL(new Blob([blobData], { type: "application/pdf,charset=utf-8" }))link.style.display = 'none'link.href = urllink.setAttribute('download', downloadFileName)document.body.appendChild(link)link.click()
}

export const downLoadPdf = ( params: object,onDownloadProgress: OnDownloadProgress // 可选 ) => {return request.get(`/pdfUploadUrl?${qs.stringify(params)}`, {baseURL: "/amc-pdf-server/api/pdf/v1",timeout: 300000,responseType: "blob", // 一定要加onDownloadProgress,});
}; 

下载多个模块语法

new Print(PrintParmas)
也就是
new Print({ // 下载多个模块[selectModule],moduleMap, [injectClass],[callback: () =>{ console.log('分页算法执行完毕')}] 
}) 

下载多个模块demo

/**
 * string1: 组合名称1
 * string2: 组合名称1的页面的根元素id
*/
const moduleMap:Map<string1, string2> = new Map([['analy'{moduleId: "#print-analy-wraper",pageInfo: {defaultType: PrintType.HEADER_TYPE,needTpl: true,},},],["pension",{moduleId: "#print-pension-wraper",pageInfo: {defaultType: PrintType.HEADER_TYPE,needTpl: true,},},],["base",{moduleId: "#print-base-wraper",pageInfo: {needTpl: true,defaultType: PrintType.HEADER_TYPE,},},]);

const selectModule = ["family", "invest"]

const injectClass = {cardTableTBHeaderWraper: 've-table-header'cardElRowClass: 've-table-body-tr',elTableBodyWraper: 've-table-body'
}

/**
 * selectModule 当前要下载的页面组合名称
 * moduleMap 所有要下载的页面组合
 * injectClass 
*/

new Print({ // 下载多个模块selectModule,moduleMap,injectClass,[callback: () =>{ console.log('分页算法执行完毕')}] 
}) 

添加页眉、页脚、A4大小图片

引入css print css 样式 @import ‘nb-fe-pdf/print.css’;

pageInfo中的defaultType,有以下四种类型
export enum PrintType {NORMAL_TYPE = "NORMAL_TYPE", // 默认无头无尾 HEADER_TYPE = "HEADER_TYPE", // 有头无尾 FOOTER_TYPE = "FOOTER_TYPE", // 无头有尾 HEADER_FOOTER_TYPE = "HEADER_FOOTER_TYPE", // 有头有尾 
}

设置页面页脚优先级
元素class设置 PrintType > this.pageInfo.defaultType > PrintType.NORMAL_TYPE;

- 封面页设置
<planCover class="page-splite-flag FOOTER_TYPE"/>

- 根据PrintType,打印A4纸大小的图片
<img src="xx.png" class="print-img-wraper"/> 

3.3 源码说明

  • Print class* 根据传入的要打印的模块启动dfs搜索
  • DfsChild class* 负责根据标识获取分页所需要的信息
  • SplitePage class* 负责计算pdf分页和table分页
  • PdfPage class* 负责生成每个pdf页面
  • Compose class* 负责将每个pdf页面放到原来根元素的位置

3.4 使用nb-fe-pdf注意点

①需要分页的原页面自定义样式要保证和page-splite-flag同级或在包裹内,table类型同上,否则样式不生效 ②page-splite-flag之间不能嵌套 ③如果分页出现切分异常的问题 ,可以检查原页面的自定义样式,不建议使用margin来设置竖向样式,例如margin-top、margin-bottom建议替换成paddingXX。当然也可以使用margin,但要保证用BFC解决外边距重叠问题

3.5 关于nb-fe-pdf常问的问题

为什么要在dom层做分页?

  • 可选择的做分页的地方有:vdom层、ast层、真实dom层
  • 如果在vue/react的vdom层做,由于不同的框架对vdom的处理方式不同,vue 用tamplate语法,是对组件做了依赖收集,经过vdom diff,vdom patch,最终vdom和dom有对应关系;react是jsx语法,fiber结构的vdom,分片渲染,最终vdom和dom有对应关系,但是结构不一样,无法复用算法,所以pass;
  • 在ast层做,不同框架用的ast解析器不一样,需要根据不同的解析器做计算,所以pass;
  • 真实dom层,不管是什么框架,最终渲染结果都是dom,可以统一计算;

如此大量的操作DOM会有性能问题吗?比如频繁导致回流影响页面渲染?

  • 只要操作dom元素的位置,肯定会产生回流的,主要是要将回流的次数控制在不影响页面加载的范围内;

  • 在nb-fe-pdf对元素的操作是批量的,批量读取元素属性,批量append元素,并且这些元素在读取阶段,并没有放到浏览器渲染队列里面,只存储在内存中,在批量append 元素完会统一放到渲染队列中,统一渲染,尽可能避免刷新渲染队列,以免频繁引起回流;

    为什么要加类似class=“page-split-pdf” flag 标记,为什么标记不能嵌套?

  • 利用html 双标签的闭合关系可以确定一个区域,这个区域我们叫模块,可以将业务关系紧密的放在这个区域内

  • 一个flag标记描述的是一个模块,会根据这个标记计算这个区域的高度(offsetHeight + marginTop + marginBottom),如果flag标记存在嵌套关系,在计算的时候会重复计算,没有意义,会导致产生分页问题;

为什么上下相邻的模块,不建议上模块使用marginBottom,下模块使用marginTop来控制模块之间的间距?

  • 在标准文档流中,两个上下相邻的模块,如果上模块marginBttom: 50px; 下模块marginTop:50px,渲染结果这两个模块之间的上下距离是50px,这就是css的margin塌陷问题,而根据dfs计算结果,他两个模块之间是100px;
  • margin-top、margin-bottom建议替换成paddingXX。当然也可以使用margin,但要保证用BFC解决外边距重叠问题

下载pdf空白

1.页面有权限,在puppteer访问路由的时候被拦截了
2.请求是否添加 responseType: ‘Blob’
3.走内网的时候 需要配置nginx 内网协议、内网ip、内网端口* x-forwarded-proto (http、https、

    s
   
   
    c
   
   
    h
   
   
    e
   
   
    m
   
   
    e
   
   
    )
   
   
    ∗
   
   
    x
   
   
    −
   
   
    f
   
   
    o
   
   
    r
   
   
    w
   
   
    a
   
   
    r
   
   
    d
   
   
    e
   
   
    d
   
   
    −
   
   
    h
   
   
    o
   
   
    s
   
   
    t
   
   
    (
   
   
    d
   
   
    o
   
   
    m
   
   
    a
   
   
    i
   
   
    n
   
   
    、
   
   
    i
   
   
    p
   
   
    :
   
   
    p
   
   
    o
   
   
    r
   
   
    t
   
   
    、
   
  
  
   scheme)* x-forwarded-host (domain、ip:port、
  
 
scheme)∗x−forwarded−host(domain、ip:port、http_host),如果配置成域名需要在docker容器中配置hosts解析到内网IP。

分页不对

1.margin 塌陷影响的
2.table分页有问题* 图1

4.node 端代码

同步方式

  • cluster 集群 + puppteer
  • 当请求到达node端的时候,node端会在puppeteer打开请求链接中的页面,生成当前页面的文件流,将生成的文件流上传oss或者返回到前端;

异步方式

    • cluster 集群 + puppteer + redis MQ
  • 当请求到达node端的时候,会将传递的链接放到redis 任务队列MQ中;消费任务队列MQ队列中的数据,生成文件流;* 如果生成成功,则将文件流上传到oss,删除队列中的数据;* 如果生成失败,会每隔10s进行一次重试,重试3次依旧失败,标记为faild;

代码实现

多进程和进程守护

// package.json"dev:local": "cross-env NODE_ENV=development nodemon./src/app.js--watch src/",// 本地开发,只会启动一个进程"dev": "cross-env NODE_ENV=production --max_old_space_size=2048 nodemon./src/app.js--watch src/",// 会启动多个进程,前台启动的方式"start": "cross-env NODE_ENV=production pm2 start ./pm2.json", // pm2后台启动方式"status": "pm2 status", // 查看进程状态"reload": "pm2 reload ./pm2.json", "kill": "pm2 kill -n amcPdfServer","stop": "pm2 stop amcPdfServer" 

开启cluster集群

  • 会进行心跳检测,如果超过三次没有回应,说明子进程有异常,挂掉,会kill子进程,主进程重新fork一个子进程
// app.js
const cluster = require('cluster')
const worker = require('./worker')
const logger = require('./logger')
const uuid = require('uuid')
const { getWrokerNums, log, bitToMB } = require('./utils')
const { maxDiedMemory, redisClusterOpts, workerNums } = require('config')
// let workerNums = getWrokerNums()
const processMemorySize = bitToMB(process.memoryUsage().rss)

if (cluster.isMaster) {log({type: 'info',msg: `redisClusterOpts: ${JSON.stringify(redisClusterOpts)}`,})log({ type: 'info', msg: 'workerNums: ' + workerNums })for (let i = 0; i < workerNums; i++) {createWorker()}cluster.on('exit', (worker, code, signal) => {log({type: 'error',msg: 'worker.process.pid: ' + worker.process.pid + ' died',})setTimeout(() => {createWorker()}, 5000)})function createWorker() {// 创建子进程进行心跳监控let worker = cluster.fork()let uid = uuid.v4()log({ type: 'info', msg: `new worker ===> ${worker.process.pid}`, uid })let missed = 0 // 没有回应的ping次数let timer = setInterval(() => {process.send('ping#' + worker.process.pid)if (missed === 3) {clearInterval(timer)log({type: 'error',msg: `worker.process.pid: ${worker.process.pid} -> died down, processMemorySize 为${processMemorySize}MB`,uid,})process.kill(worker.process.pid)return}missed++worker.send({ type: `ping#${worker.process.pid}`, uid })}, 10000)worker.on('message', ({ type, uid }) => {// 确认心跳回应if (type === `pong#${worker.process.pid}`) {// log({ type: 'info', msg: `pong#${worker.process.pid}`, uid, cancel: true })missed--}})worker.on('exit', () => {clearInterval(timer)})}
} else {// 异常处理process.on('uncaughtExceptionMonitor', (err, origin) => {log({type: 'error',msg: `uncaughtExceptionMonitorerr: ${JSON.stringify(err,)}, origin: ${origin}`,})})// 回应心跳信息process.on('message', ({ type, uid }) => {if (type === `ping#${process.pid}`) {// log({ type: 'info', msg: `ping#${process.pid}`, uid })process.send({ type: `pong#${process.pid}`, uid })}})log({msg: `worker: ${process.pid} 运行内存为: ${processMemorySize}MB`,})if (processMemorySize > maxDiedMemory) {log({type: 'error',msg: `检测到内存过大,内存为 process.memoryUsage().rss: ${processMemorySize}`,})process.exit(1)}worker.server()
} 

初始化任务队列

  • 同步方式不需要此方法
// queue.js
const initQueue = () => {global.qname = taskQueueNameconst queue = new Queue(taskQueueName, {prefix: `${PDF_DOWNLOAD_CLUSTER_QUEUE}`,...setRedis(),limiter: {max: 20000,duration: 1000,},defaultJobOptions: {attempts: 3,removeOnComplete: !isProd(),backoff: {type: 'fixed',delay: 10000,},timeout: TaskTimeout,},})queue.on('failed', jobFaild)return queue
} 

消费队列里面的数据

  • 同步方式不需要此方法
// handleJob.js 
const { concurrency } = require('config')
const { upload } = require('../controller/baseController-qianhai')
const { onVisit } = require('../middleware/pupMiddle')queue.process(concurrency, async (job, done) => {const { url, uid, id, token, originUrl } = job.datalog({ msg: `process: job.url ${JSON.stringify(job.data)}`, uid })try {const pdfBuffer = await onVisit({ url, uid })await upload({ pdfBuffer, id, token, uid, originUrl })done()} catch (err) {log({type: 'error',msg: 'process inner err msg: ' + err,uid,})done(err)}})}
}

module.exports = {processJob,
} 

启动puppeteer

const puppeteer = require('puppeteer')
const { default: axios } = require('axios')
const { puppeteerOpt, MAX_WSE } = require('config')
const { log, resolve, formatFetchUrl, getHost, errorInfo } = require('../utils')
const path = require('path')

const WSE_LIST = [] //存储browserWSEndpoint列表

async function onInit() {try {for (var i = 0; i < MAX_WSE; i++) {const browser = await puppeteer.launch(puppeteerOpt)WSE_LIST[i] = await browser.wsEndpoint()}log({msg:`chrome headless broswer ready, WSE_LIST:` + JSON.stringify(WSE_LIST),})} catch (err) {log({type: 'error',msg: 'puppeteer launch error ' + err,})}
}

async function onVisit({ url, uid }) {const downloadUrl = urllet pagetry {let browserWSEndpoint = randomEndpoint()console.log('browserWSEndpoint', browserWSEndpoint)let browsertry {browser = await puppeteer.connect({ browserWSEndpoint })} catch (err) {log({msg: `step3 puppeteer connected connect err: ${err}`,uid,})browserWSEndpoint = randomEndpoint(browserWSEndpoint)browser = await puppeteer.connect({ browserWSEndpoint })}log({msg: `step3 puppeteer connected success, browserWSEndpoint: ${browserWSEndpoint}`,uid,})page = await browser.newPage()log({ msg: `step4 puppeteer newPage`, uid })// await page.setCacheEnabled(false)log({ msg: `downloadUrl ==> ${downloadUrl}`, uid })await page.goto(downloadUrl, {timeout: 2000000,waitUntil: ['load', 'domcontentloaded', 'networkidle0'],})log({ msg: ` step5 page.goto ${downloadUrl} `, uid })await page.content()log({ msg: `step6 page content contentTime`, uid })const wfRes = await page.waitForSelector('#isPDFVisible', {timeout: 2000000,}).catch((e) => {logger.info('catch isPDFVisible page.waitForSelector', e)})log({ msg: `step7 isPDFVisible`, uid })const pdfBuffer = await page.pdf({printBackground: true,// path: path.resolve(__dirname, `../../static/pdf/${filename}`),})log({ msg: `step8 pdf done success`, uid })await page.close()return pdfBuffer} catch (err) {log({ type: 'error', msg: err, uid });(await page) && page.close()throw new Error(err)}
}

function randomEndpoint(point) {let tmp = Math.floor(Math.random() * MAX_WSE)let browserWSEndpoint = WSE_LIST[tmp]if (point) {const list = WSE_LIST.slice().filter((i) => i !== point)let tmp = Math.floor(Math.random() * list.length)browserWSEndpoint = WSE_LIST[tmp]return browserWSEndpoint}// browserWSEndpoint = browserWSEndpoint.slice(0, browserWSEndpoint.length - 10)return browserWSEndpoint
}

function sleep(time) {return new Promise((resolve) => {setTimeout(() => {resolve()}, time)})
}

module.exports = {onInit,onVisit,
} 
= Math.floor(Math.random() * list.length)browserWSEndpoint = WSE_LIST[tmp]return browserWSEndpoint}// browserWSEndpoint = browserWSEndpoint.slice(0, browserWSEndpoint.length - 10)return browserWSEndpoint
}

function sleep(time) {return new Promise((resolve) => {setTimeout(() => {resolve()}, time)})
}

module.exports = {onInit,onVisit,
} 

最后

整理了一套《前端大厂面试宝典》,包含了HTML、CSS、JavaScript、HTTP、TCP协议、浏览器、VUE、React、数据结构和算法,一共201道面试题,并对每个问题作出了回答和解析。

有需要的小伙伴,可以点击文末卡片领取这份文档,无偿分享

部分文档展示:



文章篇幅有限,后面的内容就不一一展示了

有需要的小伙伴,可以点下方卡片免费领取

标签: html 前端 ui

本文转载自: https://blog.csdn.net/web22050702/article/details/127088122
版权归原作者 前端开发小司机 所有, 如有侵权,请联系我们删除。

“Puppeteer将动态html页面生成pdf(终极解决方案)”的评论:

还没有评论