DOM元素导出图片与PDF:多种方案对比与实现

背景

在日常前端开发中,经常会有把页面的 DOM 元素作为 PNG 或者 PDF 下载到本地的需求。例如海报功能,简历导出功能等等。在我们自家的产品「代码小抄」中,就使用了 html2canvas 来实现代码片段导出为图片:

是不是还行,大家如果想要分享代码片段,可以试试,非常好用。那有哪些方法可以实现下载 DOM 元素到本地呢?目前收集到的有:

  • 通过 html2canvas 、dom-to-image 等第三方库实现

  • 通过 Puppeteer 启动一个 node 服务实现

  • canvas 原生绘制

这些方式是真实项目会使用的方式,针对不同场景可以使用不同的方法,下面看一下每种方法如何实现、使用场景和优缺点,

方案 1 - html2canvas

html2canvas 专门用于解析 DOM 结构及其关联的 CSS 样式,进而将网页内容渲染为 Canvas 元素的 JavaScript 库,下面是下载元素为 PNG 的示例代码:

/**
 * 下载 dom 元素为图片
 * @param elementId DOM 元素id
 * @param fileName 下载图片的文件名
 * @returns
 */
export const downloadDOMElementAsImage = async (elementId: string, fileName: string) => {
  const element = document.getElementById(elementId) as HTMLElement;
  if (!element) return message.warn('无法找到 DOM 元素');
  try {
    // 将 DOM 元素转换为 canvas
    const canvas = await html2canvas(element, {
      useCORS: true,
      allowTaint: true,
      // 提高清晰度
      scale: 2,
      backgroundColor: 'transparent',
    });
    // 将 canvas 转换为数据 URL
    const dataUrl = canvas.toDataURL('image/png');
    // 创建一个临时的 <a> 元素,设置其 href 为数据 URL 并设置 download 属性
    const link = document.createElement('a');
    link.style.visibility = 'hidden';
    link.href = dataUrl;
    link.download = fileName;

    // 将 <a> 元素添加到 DOM,触发点击事件,然后从 DOM 中移除
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  } catch (error: any) {
    message.error('无法将 DOM 元素转换为图片并下载', error);
  }
  element.style.transform = 'scale(1)';
};

通过 html2canvas ,我们封装了一个下载页面 DOM 为图片的方法,然后就可以很方便的调用方法进行页面元素的下载

使用场景

适用于需要将复杂的 DOM 结构(包括样式、背景图像、字体等)渲染为图片的场景。它可以捕获大部分 CSS 样式和 HTML 内容

优缺点

优点:

  • 使用非常简单,支持大多数 css

  • 内置跨域解决方案

  • 可以通过 ignoreElements 过滤指定 DOM,这在处理复杂 DOM 结构的时候非常有用

缺点:

  • 下载的图可能不清晰

  • 库比较大

  • 计算耗时,性能不好

  • 部分特殊的样式可能不支持,存在兼容性问题

方案 2 - dom-to-image

dom-to-image 是一个用 JavaScript 编写的库,可以将任意 DOM 节点转换为矢量(SVG)或光栅(PNG 或 JPEG)图像。它和 html2canvas 一样也是基于 canvas 封装的库。看一下生成 PNG 的示例代码:

/**
 * 下载 DOM 元素为高质量图片
 * @param elementId DOM 元素id
 * @param fileName 下载图片的文件名
 * @param sc 缩放比
 * @returns
 */
export const downloadDOMElementAsImage = async (elementId: string, fileName: string, sc = 3) => {
  const element = document.getElementById(elementId) as HTMLElement;
  if (!element || !window || !document) return message.warning("无法找到 DOM 元素");
  const messageKey = "loading";
  message.loading({
    content: "正在下载...",
    duration: 0,
    key: messageKey,
  });
  try {
    const clone = element.cloneNode(true) as HTMLElement;
    document.body.appendChild(clone);
    // 临时增加元素尺寸以提高分辨率
    const originalWidth = element.offsetWidth;
    const originalHeight = element.offsetHeight;
    const scale = sc; // 增加缩放因子以提高分辨率

    // 设置相对定位,zIndex 为 -1
    clone.style.position = "relative";
    // clone.style.zIndex = "-1";
    clone.style.transform = `scale(${scale})`;
    clone.style.transformOrigin = "top left";

    const dataUrl = await domtoimage.toPng(clone, {
      width: originalWidth * scale,
      height: originalHeight * scale,
      style: {
        transform: `scale(${scale})`,
        transformOrigin: "top left",
        width: `${originalWidth}px`,
        height: `${originalHeight}px`,
      },
      cacheBust: true,
      quality: 1,
      bgcolor: "transparent",
    });

    // 创建下载链接
    const link = document.createElement("a");
    link.href = dataUrl;
    link.download = fileName;
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    message.destroy(messageKey);
    setTimeout(() => {
      document.body.removeChild(clone);
    }, 500);
  } catch (e: any) {
    message.destroy(messageKey);
    console.error("下载失败", e.message);
    message.error("下载失败: " + e.message);
  }
};

可以看到使用也非常简单,我们可以通过 sc 参数来控制下载图片的清晰度和大小。

使用场景

如果对项目大小有要求,希望文本排版支持度高,需要稳定的文字、图片渲染能力或者处理结构化数据的能力,可以使用 dom-to-image

优缺点

优点:

  • 库比较轻量

  • 适用于需要多格式导出的场景

缺点:

  • 需要手动处理跨域

方案 3.1 - Puppeteer

上面两种方法虽然可以在 web 端生成图片,但是如果需要:

  • 兼容多端,

  • 同时支持生成 PNG 和 PDF,并且要求非常清晰,

  • 兼容图片跨域

  • 兼容所有 css

  • 对项目体积有要求

那我们就可以使用 Puppeteer 来实现,它可以解决上面所有的问题。Puppeteer 是一个强大的 Node.js 库,用于控制 Chrome 或 Chromium 浏览器来帮我们生成想要的 PNG 或者 PDF,下面我们就使用 express + Puppeteer 来快速实现一个图片下载服务:

// node 服务 app.js 示例代码
import cors from "cors";
import express from "express";
import puppeteer from "puppeteer";

const app = express();
app.use(cors());

app.use(express.json());

app.get("/", (req, res) => {
  res.send("面试刷题,我只用面试鸭~");
});

app.post("/download", async (req, res) => {
  const { url, quality, format, filename, domId, type } = req.body;

  if (!url || !filename || !domId || !type) {
    return res.status(400).send("Missing required parameters");
  }

  try {
    // 启动浏览器
    const browser = await puppeteer.launch();
    // 新建一个页面
    const page = await browser.newPage();
    // 设置默认一分钟超时
    await page.setDefaultNavigationTimeout(60000);
    // 打开页面
    await page.goto(url, { waitUntil: "networkidle0" });

    if (type === "png") {
      // 等待元素加载
      await page.waitForSelector(`#${domId}`);

      // 等待元素加载
      await page.waitForSelector(`#${domId}`);

      // 截取指定元素的截图
      const element = await page.$(`#${domId}`);
      console.log(element, "element");

      const imageBuffer = await element.screenshot({
        type: "jpeg",
        quality: parseInt(quality), // 仅适用于 jpeg
        // omitBackground: true,
      });
      await browser.close();
      res.contentType("image/jpeg");
      res.attachment(filename + ".jpeg");
      // 返回二进制数据给前端
      res.send(Buffer.from(imageBuffer, "binary"));
    } else if (type === "pdf") {
      const pdf = await page.pdf({
        format: format || "A4",
        printBackground: true,
        pageRanges: "1-" + (req.body.pages || "1"),
      });
      res.contentType("application/pdf");
      res.attachment("resume.pdf");
      // 返回二进制数据给前端
      res.send(Buffer.from(pdf));
    } else {
      res.status(400).send("Invalid type");
    }

    await browser.close();
  } catch (error) {
    console.error(error);
    res.status(500).send("Internal Server Error");
  }
});

const PORT = 3001;
app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

通过 Puppeteer 我们可以很方便的进行屏幕截取,因为它就是通过游览器内核来实现,所以它能完全还原展示效果

使用场景

如果需要下载图片和 PDF,对图片清晰度还有要求,页面元素还比较复杂,生成图片和 PDF 需要多端支持等等,可以使用 Puppeteer 来实现

优缺点

优点:

  • 高度还原视图:Puppeteer 使用的是无头 Chrome 浏览器,所以它生成的 PDF 和截图与用户在浏览器中看到的内容几乎完全一致

  • 丰富的 API: Puppeteer 提供了超多 API,基本可以解决所有遇到的问题,相关文档地址:puppeteer/docs/api.md at v1.5.0 · puppeteer/puppeteer · GitHub,非常多的 API

  • 支持最新的 css:由于 Puppeteer 使用的是 Chrome 浏览器,它支持所有现代 Web 特性,因此它在处理复杂网页时非常有优势

  • 跨域资源支持: Puppeteer 通常以无头模式运行,这种模式下浏览器跨域访问的限制会放宽

缺点:

  • 需要部署服务:Puppeteer 需要在服务器端运行,需要一个后端环境来支持它。

  • 资源消耗大: 由于 Puppeteer 启动的是一个完整的 Chrome 浏览器实例,因此它的资源消耗相对较大,可能会影响服务器的性能

  • 额外的学习成本:如果团队都对 Puppeteer 不了解,可能需要额外的学习和维护成本

但是如果使用 Puppeteer 去生产环境使用,可能还会有同时处理大量请求导致服务资源被消耗光,甚至导致下载服务奔溃的情况,这时候我们就可以使用 puppeteer-cluster 来实现请求队列, 使用队列系统来管理请求,确保同时只处理一定数量的请求,其他请求则排队等待

方案 3.2 - puppeteer-cluster

代码示例:

// 源码:https://github.com/chaseFunny/pdf-png-downloader
import cors from "cors";
import express from "express";
import { Cluster } from "puppeteer-cluster";

const app = express();
app.use(cors());
app.use(express.json());

let cluster;

// 初始化 cluster
async function initCluster() {
  cluster = await Cluster.launch({
    concurrency: Cluster.CONCURRENCY_CONTEXT,
    maxConcurrency: 2, // 最大并发数,可以根据服务器资源调整
    puppeteerOptions: {
      headless: true,
      args: ["--no-sandbox", "--disable-setuid-sandbox"],
    },
  });

  // 定义任务处理函数
  await cluster.task(async ({ page, data: { url, domId, type, quality, format, pages } }) => {
    await page.goto(url, { waitUntil: "networkidle0" });

    if (type === "png") {
      await page.waitForSelector(`#${domId}`);
      const element = await page.$(`#${domId}`);
      return await element.screenshot({
        type: "jpeg",
        quality: parseInt(quality),
      });
    } else if (type === "pdf") {
      return await page.pdf({
        format: format || "A4",
        printBackground: true,
        pageRanges: "1-" + (pages || "1"),
      });
    }
  });

  console.log("Cluster initialized");
}

initCluster();

app.get("/", (req, res) => {
  res.send("面试刷题,我只用面试鸭~");
});

app.post("/download", async (req, res) => {
  const { url, quality, format, filename, domId, type, pages } = req.body;

  if (!url || !filename || !domId || !type) {
    return res.status(400).send("Missing required parameters");
  }

  try {
    const result = await cluster.execute({ url, domId, type, quality, format, pages });

    if (type === "png") {
      res.contentType("image/png");
      res.attachment(filename + ".png");
      res.send(Buffer.from(result));
    } else if (type === "pdf") {
      res.contentType("application/pdf");
      res.attachment(filename + ".pdf");
      res.send(Buffer.from(result));
    } else {
      res.status(400).send("Invalid type");
    }
  } catch (error) {
    console.error(error);
    res.status(500).send("Internal Server Error");
  }
});

const PORT = 3001;
app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

// 优雅关闭
process.on("SIGINT", async () => {
  console.log("Closing cluster...");
  await cluster.close();
  process.exit(0);
});

我们使用 puppeteer-cluster 创建了一个浏览器实例池,有如下优点:

  • 可以更有效地处理并发请求,它会自动把接受的请求加入队列,保证所有请求都会进行处理。

  • 将 PDF 和 PNG 生成的逻辑移到了 cluster.task 中,这样可以重用浏览器实例,提高效率

  • 设置了最大并发数(maxConcurrency),可以根据服务器资源进行调整,避免资源耗尽

**注意:**在生产环境,我们可能需要在 puppeteerOptions 的 executablePath 设置具体的 chrome 路径,保证服务能找到 chorme

方案 4 - canvas 原生绘制

参考代码:

//获取canvas元素
var canvas = document. getElementById( 'poster')
var ctx = canvas getContext ('2d')
// 设置canvas宽高
canvas.width = 600
canvas.height = 800
// 绘制背景
ctx.fillStyle = '#ff6600'
ctx. fillRect(0, 0, canvas.width, canvas.height)
// 绘制文字
ctx. font = 'bold 48px Arial'
ctx. fillStyle = '#ffffff•
ctx.textAlign = 'center'
ctx. fillText ('*HEd', canvas.width / 2, 120)
ctx-font = '24px Arial'
ctx.fillText('这里是副标题
canvas. width / 2, 180)
// 绘制图片
var img = new Image()
img. onload = function ()
{
ctx. drawImage(img, 100, 250, 400, 400)
}
img.src ='图片地址'

canvas 虽然高性能,但是工作量大,一般生产环境不会使用

总结

在实际开发中,面对不同场景我们会使用不同的方案,那我们公司的线上项目为例:在我们的「面试鸭」和「编程导航」的生成海报功能都是使用了 html2canvas 来生成海报,因为它要比 Puppeteer 快,能够让用户更快拿到海报图,在「老鱼简历」中,我们使用 Puppeteer 来导出简历,这样导出的简历和看到的更加一致,并且清晰度更加高。

上面的代码都在仓库:GitHub - chaseFunny/pdf-png-downloader,还提供了简单的页面方便大家体验调试

相关推荐
学不会•1 小时前
css数据不固定情况下,循环加不同背景颜色
前端·javascript·html
EasyNTS2 小时前
H.264/H.265播放器EasyPlayer.js视频流媒体播放器关于websocket1006的异常断连
javascript·h.265·h.264
活宝小娜3 小时前
vue不刷新浏览器更新页面的方法
前端·javascript·vue.js
程序视点3 小时前
【Vue3新工具】Pinia.js:提升开发效率,更轻量、更高效的状态管理方案!
前端·javascript·vue.js·typescript·vue·ecmascript
coldriversnow3 小时前
在Vue中,vue document.onkeydown 无效
前端·javascript·vue.js
我开心就好o3 小时前
uniapp点左上角返回键, 重复来回跳转的问题 解决方案
前端·javascript·uni-app
开心工作室_kaic4 小时前
ssm161基于web的资源共享平台的共享与开发+jsp(论文+源码)_kaic
java·开发语言·前端
刚刚好ā4 小时前
js作用域超全介绍--全局作用域、局部作用、块级作用域
前端·javascript·vue.js·vue
沉默璇年6 小时前
react中useMemo的使用场景
前端·react.js·前端框架
yqcoder6 小时前
reactflow 中 useNodesState 模块作用
开发语言·前端·javascript