一文讲明白页面导出为HTML实现原理与步骤

近期做了一个"简单"需求(至少产品经理是这么认为的):把报告页导出为HTML,方便用户分享。

接到这个需求我一开始是拒绝的,因为之前做过导出为PDF,我自然而然要往这个方向引导。
FE :导出PDF不好吗?方便查看、编辑,还更加规范;
PM :不行,用户要求导出HTML
FE :可以跟用户说导出PDF效果更好呀,格式更加通用,HTML在不同浏览器上展示可能会有差异;
PM :不行,用户明确要求导出HTML,快点做吧,下个迭代就上线!

battle失败,again~

既然挣扎失败,那就默默开工吧。

页面分析

希望"导出"的是报告页,更准确来说是一个报告模板页,类似于BI报表。里面有文字、表格、图片以及最重要的ECharts统计图。

因此需要导出的HTML需要保证上述元素都正常展示,样式不能乱,内容不能缺。

技术调研

接下来针对页面中的每一种元素设计导出方案。

文字及表格导出

文字和表格导出最重要的内容和样式不缺失。内容好处理,待页面渲染完成再拿outerHtml即可。而样式的处理就复杂一些了,因为SPA中大部分的样式是以独立的样式文件存放在Server中,然后以<link rel="stylesheet">标签动态引入。

所以关键点就是解决外部样式文件注入的问题。这里已经有很多成熟的方案了,什么?你没见过此类场景?回想一下现在烂大街的微前端框架,哪一个不是把<link rel="stylesheet">转换为<style>标签插入到HTML中!

笔者在这里另辟蹊径,踩在巨人的肩膀上实现css的内联化。方案的核心就是借助一个库:rrweb-snapshot。这个库是社区流行的用于完整记录并回放用户网页操作的开源库rrweb使用的子库,rrweb-snapshot提供了两个核心方法:

snapshot

snapshot will traverse the DOM and return a stateful and serializable data structure which can represent the current DOM view.

There are several things will be done during snapshot:

  1. Inline some DOM states into HTML attributes, e.g, HTMLInputElement's value.
  2. Turn script tags into noscript tags to avoid scripts being executed.
  3. Try to inline stylesheets to make sure local stylesheets can be used.
  4. Make relative paths in href, src, CSS to be absolute paths.
  5. Give an id to each Node, and return the id node map when snapshot finished.
rebuild

rebuild will build the DOM according to the taken snapshot.

There are several things will be done during rebuild:

  1. Add data-rrid attribute if the Node is an Element.
  2. Create some extra DOM node like text node to place inline CSS and some states.
  3. Add data-extra-child-index attribute if Node has some extra child DOM.

通过这两个方法可以实现无损的<link rel="stylesheet">转换为<style>效果。

图片导出

一般图片在SPA项目中的引入方式为相对路径,浏览器会自动添加 location.host拼接完整的请求路径获取图片资源,如下面的引入方式:

最终浏览器会请求 ${location.host}/src/assets/demo.png地址。

只要能拿到图片资源,剩下的事情就好办了,笔者给出了两种处理方法:

  1. 上传到自有图床里(云厂商提供的对象存储服务就很适合做图床,然后用新的图床地址替换掉 <img src=''/>的旧地址
  2. 本地下载图片后直接转成 base64文本,以纯文本形式注入到HTML中。

在下面的实现章节,笔者会对这两个方法都给出实现。

Echarts图导出

ECharts图形是基于 CanvasSvg的。如果是svg那很方便,无需转换。如果是Canvas(这也是ECharts默认的渲染器)那就麻烦一点,需要多做一层转换。转换思路是:
Canvas => Image => OSS存储 或 base64转码。

第二步之后的处理逻辑完全跟上一节的 图片导出 一致。

沙箱渲染

看到这里相信读者们已经察觉到一个问题:上面的处理都是不可逆的,一旦完成会严重影响当前页面的功能。

所以完成上述步骤前我们需要一个什么?没错,一个沙箱,与外部页面完全隔离的沙箱环境,在里面做的任何操作都不会影响到外面页面。

什么沙箱能承载上述能力呢?

------iframe

实现

这里为了脱敏,笔者使用 TraeBuilder模块快速搭建了一个 vite + react-ts + react-router的demo项目。
这里给Trae打一个小小广告,国内版支持了豆包3.5和 deepseek R1 模型,对于日常编程能起到一定帮助,最推荐的还是国际版,支持更多优秀的模型,而且最最重要的一点是,目前免费,可以白嫖^_^,下图是国际版支持的大模型列表:

话不多说,我们按照上面的顺序逐个实现导出方法。

导出带格式的文本

借助rrweb-snapshot 实现:

js 复制代码
import { snapshot, rebuild } from "rrweb-snapshot";

// ...省略上面的代码

const $doc = window.document;
const serializedNodeId = snapshot($doc, {
  inlineStylesheet: true,
  inlineImages: true,
  recordCanvas: true,
  preserveWhiteSpace: true,
});
if (!serializedNodeId) {
  return reject("serializedNodeId为空");
}

rebuild(serializedNodeId, {
  doc: $doc,
  cache: undefined as any,
  mirror: undefined as any,
});

导出图片实现

直接贴代码:

js 复制代码
// 下载图片并转换为 Blob
export const downloadImage = async (url: string): Promise<Blob> => {
  const response = await fetch(url);
  return response.blob();
};

// 图片上传到 OSS 的函数
export const uploadToOSS = async (blob: Blob): Promise<string> => {
  // 这里替换为实际的上传逻辑
  const formData = new FormData();
  formData.append("file", blob);

  // 替换为你自己的图床地址
  const response = await fetch("https://oss.image.com/upload", {
    method: "POST",
    body: formData,
  });

  const { url } = await response.json();
  return url;
};

 /**
   * 图片的第一种处理方式,将上传到图床,更换导出后的src,使其可以正常访问
   * 优点:处理方便,导出的html文件体积较小
   * 缺点:下载的html需要联网才能正常访问图片
   * @param doc
   */
  const processImages = async (doc: Document): Promise<void> => {
    const images = doc.getElementsByTagName("img");

    for (const img of Array.from(images)) {
      try {
        const originalSrc = img.src;
        if (originalSrc.startsWith("http")) {
          const blob = await downloadImage(originalSrc);
          const newUrl = await uploadToOSS(blob);
          img.src = newUrl;
        }
      } catch (error) {
        console.error("处理图片失败:", error);
      }
    }
  };
  
  /**
   * 图片的第二种处理方式,将其转为 base64, 注入到html中
   * 优点:离线也能访问
   * 缺点:会造成导出的html文件体积很大
   * @param doc
   */
  const processImages2Base64 = async (doc: Document): Promise<void> => {
    const images = doc.getElementsByTagName("img");

    for (const img of Array.from(images)) {
      try {
        const originalSrc = img.src;
        if (originalSrc.startsWith("http") || originalSrc.startsWith("https")) {
          const blob = await downloadImage(originalSrc);
          const reader = new FileReader();
          const base64Url = await new Promise<string>((resolve) => {
            reader.onloadend = () => resolve(reader.result as string);
            reader.readAsDataURL(blob);
          });
          img.src = base64Url;
        }
      } catch (error) {
        console.error("处理图片失败:", error);
      }
    }
  };

上面给出了两种导出方式的实现,大家可以自取。

PS:上面的方法是使用 Trae 的Chat模式生成的,感兴趣的小伙伴也可以试一下

导出ECharts

导出ECharts其实就是处理Canvas,因此方法名取为processCanvas

js 复制代码
  const processCanvas = async (doc: Document): Promise<void> => {
    const canvases = doc.getElementsByTagName("canvas");

    for (const canvas of Array.from(canvases)) {
      try {
        // 创建新的图片元素
        const img = doc.createElement("img");
        // 将 canvas 转换为 base64 图片
        const dataUrl = canvas.toDataURL("image/png");
        img.src = dataUrl;

        // 复制 canvas 的样式和属性
        const width = chartRef.current?.getWidth();
        const height = chartRef.current?.getHeight();
        img.width = width ?? canvas.width;
        img.height = height ?? canvas.height;

        img.style.cssText = canvas.style.cssText;
        if (canvas.id) img.id = canvas.id;
        if (canvas.className) img.className = canvas.className;

        // 替换 canvas
        canvas.parentNode?.replaceChild(img, canvas);
      } catch (error) {
        console.error("处理 canvas 失败:", error);
      }
    }
  };

沙箱渲染

通过动态创建一个 iframe,然后在iframe里完成页面的渲染。

js 复制代码
// 暂存iframe,方便在导出完成后删除 iframe
const iframeRef = useRef<HTMLIFrameElement>(null);

const createIframe = () => {
  // 创建并设置 iframe
  const iframe = document.createElement("iframe");
  iframe.style.position = "absolute";
  iframe.style.zIndex = "-1";
  iframe.width = `${document.body.clientWidth}px`;
  iframe.height = `${document.body.clientHeight}px`;
  iframe.src = location.href;
  document.body.appendChild(iframe);
  iframeRef.current = iframe;
  return iframe;
};

完整代码

js 复制代码
import { useLayoutEffect, useRef } from "react";
import { Card, Space, Image, Table, Tag, Button, message } from "antd";
import type { TableProps } from "antd";
import { snapshot, rebuild } from "rrweb-snapshot";
import echarts from "@/utils/echarts";
import { sleep } from "@/utils/index";
import { uploadToOSS, downloadImage } from "@/utils/image";
import demoImg from "@/assets/demo.png";
import { tableData, echartsOption } from "./constants";
import type { DataType } from "./types";
import s from "./index.module.scss";

const columns: TableProps<DataType>["columns"] = [
  {
    title: "Name",
    dataIndex: "name",
    key: "name",
    render: (text) => <a>{text}</a>,
  },
  {
    title: "Age",
    dataIndex: "age",
    key: "age",
  },
  {
    title: "Address",
    dataIndex: "address",
    key: "address",
  },
  {
    title: "Tags",
    key: "tags",
    dataIndex: "tags",
    render: (_, { tags }) => (
      <>
        {tags.map((tag) => {
          let color = tag.length > 5 ? "geekblue" : "green";
          if (tag === "loser") {
            color = "volcano";
          }
          return (
            <Tag color={color} key={tag}>
              {tag.toUpperCase()}
            </Tag>
          );
        })}
      </>
    ),
  },
  {
    title: "Action",
    key: "action",
    render: (_, record) => (
      <Space size="middle">
        <a>Invite {record.name}</a>
        <a>Delete</a>
      </Space>
    ),
  },
];

const Home: React.FC = () => {
  const $div = useRef<HTMLDivElement>(null);
  const iframeRef = useRef<HTMLIFrameElement>(null);
  const chartRef = useRef<echarts.ECharts>(null);
  const resizeObserver = useRef<ResizeObserver>(null);

  useLayoutEffect(() => {
    if ($div.current) {
      initEcharts();
    }
  }, []);

  const initEcharts = (): void => {
    const myChart = echarts.init($div.current);
    myChart.setOption(echartsOption);
    chartRef.current = myChart;

    // 监听echarts容器的size变化,当echarts图形尺寸与容器尺寸不匹配时,执行resize,确保echarts渲染完全
    resizeObserver.current = new ResizeObserver(resizeCallback);
    if ($div.current) {
      resizeObserver.current.observe($div.current);
    }
  };

  const resizeCallback = () => {
    if (!chartRef.current) return;
    const $dom = chartRef.current.getDom();
    const { clientHeight, clientWidth } = $dom;
    const width = chartRef.current.getWidth();
    const height = chartRef.current.getHeight();
    if (!clientHeight || !clientWidth) return;
    if (width !== clientWidth || height !== clientHeight) {
      chartRef.current.resize();
    }
  };

  /**
   * 图片的第一种处理方式,将上传到图床,更换导出后的src,使其可以正常访问
   * 优点:处理方便,导出的html文件体积较小
   * 缺点:下载的html需要联网才能正常访问图片
   * @param doc
   */
  const processImages = async (doc: Document): Promise<void> => {
    const images = doc.getElementsByTagName("img");

    for (const img of Array.from(images)) {
      try {
        const originalSrc = img.src;
        if (originalSrc.startsWith("http")) {
          //
          const blob = await downloadImage(originalSrc);
          const newUrl = await uploadToOSS(blob);
          img.src = newUrl;
        }
      } catch (error) {
        console.error("处理图片失败:", error);
      }
    }
  };

  /**
   * 图片的第二种处理方式,将其转为 base64, 注入到html中
   * 优点:离线也能访问
   * 缺点:会造成导出的html文件体积很大
   * @param doc
   */
  const processImages2Base64 = async (doc: Document): Promise<void> => {
    const images = doc.getElementsByTagName("img");

    for (const img of Array.from(images)) {
      try {
        const originalSrc = img.src;
        if (originalSrc.startsWith("http") || originalSrc.startsWith("https")) {
          const blob = await downloadImage(originalSrc);
          const reader = new FileReader();
          const base64Url = await new Promise<string>((resolve) => {
            reader.onloadend = () => resolve(reader.result as string);
            reader.readAsDataURL(blob);
          });
          img.src = base64Url;
        }
      } catch (error) {
        console.error("处理图片失败:", error);
      }
    }
  };

  const processCanvas = async (doc: Document): Promise<void> => {
    const canvases = doc.getElementsByTagName("canvas");

    for (const canvas of Array.from(canvases)) {
      try {
        // 创建新的图片元素
        const img = doc.createElement("img");
        // 将 canvas 转换为 base64 图片
        const dataUrl = canvas.toDataURL("image/png");
        img.src = dataUrl;

        // 复制 canvas 的样式和属性
        const width = chartRef.current?.getWidth();
        const height = chartRef.current?.getHeight();
        img.width = width ?? canvas.width;
        img.height = height ?? canvas.height;

        img.style.cssText = canvas.style.cssText;
        if (canvas.id) img.id = canvas.id;
        if (canvas.className) img.className = canvas.className;

        // 替换 canvas
        canvas.parentNode?.replaceChild(img, canvas);
      } catch (error) {
        console.error("处理 canvas 失败:", error);
      }
    }
  };

  const onLoadPromise = (iframe: HTMLIFrameElement): Promise<string> => {
    // 将回调封装成Promise,方便使用async-await语法糖
    return new Promise((resolve, reject) => {
      iframe.onload = async () => {
        const $doc = iframe.contentWindow?.document;
        if (!$doc) {
          return reject("$doc为空");
        }

        // 制造延迟,等待1s
        // await sleep(1000);

        // 处理img
        // processImages($doc);
        await processImages2Base64($doc);

        // 处理canvas
        await processCanvas($doc);

        const serializedNodeId = snapshot($doc, {
          inlineStylesheet: true,
          inlineImages: true,
          recordCanvas: true,
          preserveWhiteSpace: true,
        });
        if (!serializedNodeId) {
          return reject("serializedNodeId为空");
        }

        rebuild(serializedNodeId, {
          doc: $doc,
          cache: undefined as any,
          mirror: undefined as any,
        });
        return resolve(
          iframe.contentWindow?.document.documentElement.outerHTML
        );
      };
    });
  };

  const getHtml = async (): Promise<string> => {
    const iframe = createIframe();
    await onLoadPromise(iframe);
    return iframe.contentWindow?.document.documentElement.outerHTML ?? "";
  };

  const createIframe = () => {
    // 创建并设置 iframe
    const iframe = document.createElement("iframe");
    iframe.style.position = "absolute";
    iframe.style.zIndex = "-1";
    iframe.width = `${document.body.clientWidth}px`;
    iframe.height = `${document.body.clientHeight}px`;
    iframe.src = location.href;
    document.body.appendChild(iframe);
    iframeRef.current = iframe;
    return iframe;
  };

  const removeIframe = () => {
    if (iframeRef.current) {
      // 清理 iframe 的内容
      const iframeDoc = iframeRef.current.contentWindow?.document;
      if (iframeDoc) {
        iframeDoc.close();
      }

      // 移除 iframe 元素
      iframeRef.current.remove();
      iframeRef.current = null;
    }
  };

  const downloadHtml = (html: string) => {
    const blob = new Blob([html], { type: "text/html" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "home.html";
    a.click();
    message.success("导出成功!");
  };

  const exportHtml = async () => {
    try {
      const html = await getHtml();
      downloadHtml(html);
      removeIframe();
    } catch (e: any) {
      message.error(`导出失败,${e?.message}`);
    }
  };

  return (
    <Space direction="vertical" size="large" style={{ display: "flex" }}>
      <Card>
        <h2>欢迎来到首页</h2>
        <div className={s["export-div"]}>
          <div>
            这是一个使用 React + Vite + TypeScript 构建的示例项目,集成了 Ant
            Design 组件库和 React Router 路由系统。
          </div>
          <Button type="primary" onClick={exportHtml}>
            导出HTML
          </Button>
        </div>
      </Card>

      <Card title="功能特点">
        <ul>
          <li>基于 Vite 的快速开发体验</li>
          <li>TypeScript 的类型安全</li>
          <li>Ant Design 的精美组件</li>
          <li>React Router 的路由管理</li>
        </ul>
      </Card>
      <img src={demoImg} alt="demo" width={200} />
      <div ref={$div} className={s["echart-div"]}></div>
      <Table<DataType> columns={columns} dataSource={tableData} />
    </Space>
  );
};

export default Home;

完整项目代码可以去github上查看export-html-demo。这里贴上导出效果图:

小结

  1. 导出报告页为HTML需要解决页面沙箱渲染、样式注入、图表和Canvas导出问题;
  2. 渲染沙箱可以用iframe实现;
  3. 图片的导出有上传到图片和base64转码两种方式,Canvas的处理最终可以映射为对图片的处理;
  4. 样式注入可以借助社区已有的库实现,rrweb-snapshot是个不错的选择;
  5. 上面的处理需要消耗大量时间,因此导出时机很重要,可使用async-await语法糖实现同步等待;
  6. 多媒体资源无法序列化,所以也就不在本文的讨论范围。
相关推荐
星空寻流年2 小时前
css3伸缩盒模型第二章(侧轴相关)
javascript·css·css3
GalenWu3 小时前
对象转换为 JSON 字符串(或反向解析)
前端·javascript·微信小程序·json
GUIQU.3 小时前
【Vue】微前端架构与Vue(qiankun、Micro-App)
前端·vue.js·架构
zwjapple3 小时前
“ES7+ React/Redux/React-Native snippets“常用快捷前缀
javascript·react native·react.js
数据潜水员3 小时前
插槽、生命周期
前端·javascript·vue.js
2401_837088504 小时前
CSS vertical-align
前端·html
优雅永不过时·4 小时前
实现一个漂亮的Three.js 扫光地面 圆形贴图扫光
前端·javascript·智慧城市·three.js·贴图·shader
CodeCraft Studio5 小时前
报表控件stimulsoft教程:使用 JoinType 关系参数创建仪表盘
前端·ui
春天姐姐6 小时前
vue知识点总结 依赖注入 动态组件 异步加载
前端·javascript·vue.js
互联网搬砖老肖6 小时前
Web 架构之数据读写分离
前端·架构·web