需求
将这个 DOM 转换成 PNG
- 需要将圆角转换为直角
- 且需要去除容器内部文本元素的边框
DOM:
图片:
方案
使用 html-to-image js 库,地址:github.com/bubkoo/html...
选择这个库的原因是因为这个库比较新(相对于其他库来说),还有很多其他类似的库可供选择:
- html2canvas github.com/niklasvh/ht...
- star 最多
- dom-to-image github.com/tsayen/dom-...
- 太老了,最近更新是 7 年前
截图方法 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 元素的样式。
所以为了解决去除容器内部文本元素的边框这个问题,有两种方案:
-
文本元素的边框,不再使用父元素的 border 属性实现;而是单独使用一个绝对定位的子元素实现;这样可以通过 html-to-image 的 filter API 来过滤;即将边框单独作为一个子元素实现,和文本元素同层。
- 至于为什么不直接过滤是因为过滤掉父元素子元素也会被一起过滤
- 这个需要改现有的实现方式
-
修改 html-to-image 源代码,增加能控制子元素样式的方法;或将 style 应用于所有元素而不只是最外层 DOM
感觉第一个方案比较 trick;这次是边框能这么改,如果以后是其他呢?不是很通用也没啥挑战性
直接改 html-to-image 源码吧!
修改 html-to-image 源码,增加自定义样式的功能,而不仅限于根元素
首先 fork 一份代码到自己的 github 里
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 了
有三种方法:
-
发布 pr,等 html-to-image 的作者审核完,中途可能会经过很多沟通交流和修改,最终合并至 html-to-image 官方库的 master,成为一个大家都可以使用的正式功能
- 这种当然是最好的,但是周期会很长,也需要付出很多额外的精力,例如英文和作者沟通什么的;推荐有额外精力的去试试,能很大提升对开源社区的参与度
-
发布自己修改后的 npm 包版本
- 一般是这种做法,因为开源协议为 MIT,自由度很高,所以可以基于原始的版本发布任意自定义的版本,改个名字就行
- 如果是公司项目的话,可以只把 npm 包发在内网,并且在内网建一个 fork 版本的代码库,与同事们一起维护
-
直接编译,把编译后的代码内嵌到业务代码中
- 这种比较简单粗暴,优点是很简单,缺点是不方便维护
按需求自由选择即可
好啦这篇文章就到这,感谢观看!
附:一些被排除的方案问题记录
-
为什么不能先改变样式,再截图?
- 这样会导致样式变更,用户会看到变化,不想让用户看到变化
-
先将要截图的 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);
}
}