前端截图 将 DOM 转换为图片 魔改 html-to-image 源码

需求

将这个 DOM 转换成 PNG

  • 需要将圆角转换为直角
  • 且需要去除容器内部文本元素的边框

DOM:

图片:

方案

使用 html-to-image js 库,地址:github.com/bubkoo/html...

选择这个库的原因是因为这个库比较新(相对于其他库来说),还有很多其他类似的库可供选择:

截图方法 screenshot 的代码:

js 复制代码
// 截图
async function screenshot(dom) {
    try {
        // 动态加载 html-to-image 库
        const {
            toPng
        } = await import('html-to-image');

        // 截图
        toPng(dom, {
            quality: 1,
            style: {
                borderRadius: 0
            }
        })
            .then(dataUrl => {
                console.log('dataUrl', dataUrl);
            });
    } catch (error) {
        console.error('oops, something went wrong!', error);
    }
}

通过 style 参数可以控制最外层的 DOM 的样式,传入 borderRadius: 0 即可解决圆角转换为直角的问题。

但是并不能控制里面的 DOM 元素的样式。

所以为了解决去除容器内部文本元素的边框这个问题,有两种方案:

  1. 文本元素的边框,不再使用父元素的 border 属性实现;而是单独使用一个绝对定位的子元素实现;这样可以通过 html-to-image 的 filter API 来过滤;即将边框单独作为一个子元素实现,和文本元素同层。

    • 至于为什么不直接过滤是因为过滤掉父元素子元素也会被一起过滤
    • 这个需要改现有的实现方式
  2. 修改 html-to-image 源代码,增加能控制子元素样式的方法;或将 style 应用于所有元素而不只是最外层 DOM

感觉第一个方案比较 trick;这次是边框能这么改,如果以后是其他呢?不是很通用也没啥挑战性

直接改 html-to-image 源码吧!

修改 html-to-image 源码,增加自定义样式的功能,而不仅限于根元素

首先 fork 一份代码到自己的 github 里

github.com/BadWaka/htm...

clone 下来,看下逻辑

首先在 package.json 里看入口文件

打开 index.js 文件

找到 toPng 方法

可以看到是先调用了 toCanvas 方法,获取到 Canvas 对象,再调用 canvas 的 toDataURL 转换成图片 base64 字符串

看一下 toCanvas 方法

  • 首先调用 getImageSize 方法获取图片宽高
  • 然后调用 toSvg 方法获取 svg 图片
  • 接着调用 createImage 创建图片元素
  • 创建 canvas 对象
  • 计算比例 ratio
  • 设置 canvas 宽高
  • 判断是否跳过自动缩放 if (!options.skipAutoScale)
  • 判断是否有背景色 if (options.backgroundColor)
  • 调用 drawImage 在 canvas 上绘制图片

整个流程还是很清晰的

着重看一下这个 svg 图片是怎么获取的,看下 toSvg 方法的实现

  • 首先也是计算宽高
  • 然后调用 cloneNode 方法复制 DOM 节点 node
  • 嵌入 web 字体
  • 嵌入图片
  • 应用样式
  • 调用 nodeToDataURL 方法把 node 转换成 datauri base64 字符串

所以 html-to-image 内部其实已经进行过一次 cloneNode 了

  • 源码中会判断元素类型是否是 Canvas 或者是 Video 或者是 IFrame 以进行不同的处理
  • 如果是普通 DOM 节点会调用 node.cloneNode 方法,且参数是 false 浅复制,不复制子节点

猜测传入的 style 参数会在 applyStyle 这个方法里调用

全局搜一下 options.style

果然是在这里

观察 applyStyle 方法的实现

  • 首先从 cloneNode 中取出 style 对象
  • 判断背景色和宽高并赋值
  • 遍历传入的 options.style 的每一个 key,并将其值赋值给 node.style,这个 node 是 cloneNode,也就是根元素

开始调试

调试方法为直接修改 node_modules/html-to-image/es/apply-style.js 文件

我是使用的 vite 作为构建工具,直接修改 node_modules 下面的文件不会直接生效,需要手动删除缓存 rm -rf node_modules/.vite

(当然还有其他方法,我是懒得搞了,直接删了缓存重启开发服务完事)

修改思路

在 toPng 的第二个参数 options 配置项里新增一个 preprocess 预处理方法,用于在把 clonedNode 添加到 canvas 之前修改它的样式

js 复制代码
        toPng(dom, {
            // 参数为 clonedNode
            preprocess: (clonedNode) => {
                // 修改以后记得把 clonedNode 返回给 html-to-image
                return clonedNode;
            }
        })
            .then(dataUrl => {
                console.log('dataUrl', dataUrl);
            });

这样我们在业务侧就可以拿到 clonedNode 并进行样式修改,随便怎么改样式,自由度很高

在设计 API 的时候需要考虑其扩展性和兼容性,以及 API 是否合理。有很多其他的方法也可以满足需求,但我个人觉得添加一个预处理方法比较通用

例如:还可以将 style 应用到每个子节点上;但这种方式就不太合理,每个元素的样式本来就是不同的,都覆盖是不符合用户通用认知的,所以就废弃了这种想法

在 html-to-image 的 index.ts 里

在 toSvg 方法里新增判断

  • 首先把 clonedNode 由 const 改为 let
  • 接着判断下用户是否传了 preprocess 方法
  • 如果用户传了,就调用这个方法,把 clonedNode 传进去并将返回值赋值给 clonedNode

这样 html-to-image 的源码就修改完成了!是不是很简单

而且通过新增 API 的方式,只要做好判断,就能大大降低对现有功能的影响,避免影响到已使用的用户,降低线上风险;只需要在文档中标明新增 API 的版本号就好

接下来修改业务侧代码

首先实现一个递归获取 dom 元素所有子元素的方法 getAllChildNodes

js 复制代码
function getAllChildNodes(node) {
    let allNodes = [];
    if (node.hasChildNodes()) {
        node.childNodes.forEach(child => {
            allNodes.push(child);
            allNodes = allNodes.concat(getAllChildNodes(child));
        });
    }
    return allNodes;
}

接着扩写 preprocess 方法

js 复制代码
        toPng(dom, {
            // 参数为 clonedNode
            preprocess: (clonedNode) => {
                // 拿到所有子元素,放入 children 数组中
                const children = getAllChildNodes(clonedNode);
                // 遍历 children 数组
                children.forEach(node => {
                    // 找到带边框的 dom 节点
                    if (
                        node
                        && node.classList
                        && node.classList.value === 'editable draggable textbox'
                    ) {
                        // 通过 js 设置 style 的方式把边框颜色透明度设为 0
                        node.style.borderColor = 'rgba(255, 255, 255, 0)';
                    }
                });
                // 修改以后记得把 clonedNode 返回给 html-to-image
                return clonedNode;
            }
        })
            .then(dataUrl => {
                console.log('dataUrl', dataUrl);
            });

测试下截图效果

完美!边框已经被去除了

接下来的事就是怎么使用修改后的 html-to-image 了

有三种方法:

  1. 发布 pr,等 html-to-image 的作者审核完,中途可能会经过很多沟通交流和修改,最终合并至 html-to-image 官方库的 master,成为一个大家都可以使用的正式功能

    • 这种当然是最好的,但是周期会很长,也需要付出很多额外的精力,例如英文和作者沟通什么的;推荐有额外精力的去试试,能很大提升对开源社区的参与度
  2. 发布自己修改后的 npm 包版本

    • 一般是这种做法,因为开源协议为 MIT,自由度很高,所以可以基于原始的版本发布任意自定义的版本,改个名字就行
    • 如果是公司项目的话,可以只把 npm 包发在内网,并且在内网建一个 fork 版本的代码库,与同事们一起维护
  3. 直接编译,把编译后的代码内嵌到业务代码中

    • 这种比较简单粗暴,优点是很简单,缺点是不方便维护

按需求自由选择即可

好啦这篇文章就到这,感谢观看!


附:一些被排除的方案问题记录

  1. 为什么不能先改变样式,再截图?

    • 这样会导致样式变更,用户会看到变化,不想让用户看到变化
  2. 先将要截图的 DOM 使用 cloneNode 复制,再将 clone 后的 Node 放入页面中不让用户看到,再截图可以吗?

    • 试过这种方法,但尝试了很多布局方式,截图都是灰色的色块,没有成功
    • 以下是一部分测试代码
js 复制代码
async function screenshot() {
    try {
        // 动态加载 html-to-image 库
        const {
            toPng
        } = await import('html-to-image');
        console.log('container.value', container.value);

        // 深复制 dom 节点,因为截图需要去除圆角,去除文案边框,去除更换图片按钮,所以需要改变 dom 样式
        // 为了避免影响当前展示效果,需要 cloneNode
        // const clonedDom = container.value.cloneNode(true);
        // console.log('clonedDom', clonedDom);

        // 获取目标元素的样式
        // const computedStyle = window.getComputedStyle(container.value);
        // Array.from(computedStyle).forEach((key) => {
        //     clonedDom.style[key] = computedStyle[key];
        // });

        // 将克隆的 DOM 添加到文档中(隐藏状态)
        // clonedDom.style.position = 'absolute';
        // clonedDom.style.left = '100vw';
        // clonedDom.style.top = '100vh';
        // editor.value.appendChild(clonedDom);

        // 保存原始样式
        // const originalborderRadius = container.value.style.borderRadius;

        // 修改样式
        // clonedDom.style.borderRadius = '0';
        // container.value.style.borderRadius = '0';

        // setTimeout(async () => {
        //     // 截图
        //     const dataUrl = await domtoimage.toPng(clonedDom);
        //     console.log('dataUrl', dataUrl);
        // }, 300);

        // setTimeout(() => {
        // 截图
        const startTime = new Date().getTime();
        toPng(container.value, {
            quality: 1,
            // height: props.imgHeight,
            // width: props.imgWidth,
            // canvasHeight: props.imgHeight,
            // canvasWidth: props.imgWidth,
            style: {
                borderRadius: 0,
                border: null
            },
            filter: (node) => {
                console.log('filter node', node.classList);
                // if (node && node.classList && node.classList.value === 'editable draggable textbox') {
                //     return false;
                // }
                // const clonedDom = node.cloneNode(true);
                // if (clonedDom && clonedDom.style) {
                //     clonedDom.style.fontSize = '10px';
                // }
                return true;
            }
        })
            .then(dataUrl => {
                console.log('dataUrl', dataUrl);

                // 恢复样式
                // container.value.style.borderRadius = originalborderRadius;

                // 计算耗时
                const endTime = new Date().getTime();
                console.log('diffTime', endTime - startTime);
            });
        // }, 5000);

    } catch (error) {
        console.error('oops, something went wrong!', error);
    }
}
相关推荐
爆更小小刘21 分钟前
Python基础语法(2)
开发语言·前端·python
前端小菜鸟也有人起41 分钟前
ElementUI大坑Notification修改样式
android·前端·elementui
终末圆1 小时前
JDBC与MyBatis:数据库访问技术的变迁【后端 15】
前端·数据结构·数据库·后端·算法·mybatis·jdbc
花下的晚风1 小时前
Vue实用操作-2-如何使用网页开发者工具
前端·javascript·vue.js
安冬的码畜日常2 小时前
【D3.js in Action 3 精译_022】3.2 使用 D3 完成数据准备工作
前端·javascript·信息可视化·数据挖掘·数据可视化·d3.js
挣钱花3882 小时前
Vue2时间轴组件(TimeLine/分页、自动顺序播放、暂停、换肤功能、时间选择,鼠标快速滑动)
开发语言·前端·javascript
louqle2 小时前
vue2实践:el-table实现由用户自己添加删除行数的动态表格
前端·javascript·vue.js
会思想的苇草i2 小时前
HTML深度探索 :img应用与实践
前端·html·图片·懒加载·预加载·img
慧都小妮子2 小时前
Spire.PDF for .NET【页面设置】演示:为 PDF 添加背景颜色或背景图像
前端·pdf·.net·spire.pdf
阿巴阿巴Ovo2 小时前
类似mac dock的tab切换组件
前端·vue.js