持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第8天,点击查看活动详情
前沿
最近遇到很多需求里有分享海报功能,一开始采用 html2Canvas
实现,后来发现 IOS各种兼容问题,就改用纯 Canvas
绘制的,简单的还好,但稍微复杂一些的页面就比较费劲了。
后来想,如果后面遇到非常复杂的页面需要生成图片海报,用 Canvas
绘制岂不是很苦逼么。后来真的现有遇到的问题做了些梳理并查阅了一些资料,开始有了新的思路,ts-dom-to-image就这么诞生。
遇到的常见问题
- 跨越图片的问题
看 html2Canvas
生成出来的和原页面差距很大。
文档里说跨域图片需要加给图片加上 crossorigin="anonymous"
或者调用时传入{allowTaint:true,useCORS:true}
这里的useCORS
与crossorigin
一样,源码 cache-storage.ts 文件中的loadImage
方法里可以看出,感兴趣可以去看看,这里我截了个图:
但是
crossorigin
IOS 兼容只支持15.1以上版本。
你以为这样就解决了么,虽然针对跨域图片做了处理, 但是还是有些机型有问题,比如14.x,13.x等系统还会出现跨域图片空白问题。
解决方案 :
-
模糊失真等问题
- 原因: 手机端屏幕、分辨率和电脑端不同。
- 因为手机端的屏幕是Retina屏幕。假设图标icon.png是16x16, 设备是2x的Retina屏,那么你得准备一个icon@2x.png,分辨率是32x32。iPhone 6 Plus和iPhone 6S Plus是3x的Retina屏,如果要兼容,就要更多的图。
- 所谓“Retina”是一种显示标准,是把更多的像素点压缩至一块屏幕里,从而达到更高的分辨率并提高屏幕显示的细腻程度。
- 解决方案:使用
SVG
或者调整图片大小。目前我只想到这两个方法。我后面方案里就是采用SVG
来实现的。
-
CSS属性支持问题
html2Canvas
官方也有所列举,比如: box-shadow 、filter、zoom ......
ts-dom-to-image
方案的实现
先重点介绍下 SVG
,因为方案里它可是关键的一步。最终所有不同类型的图片输出都是经过SVG
来绘制的。
SVG
SVG
是一种矢量格式,除作为照片之外,适用于任何类型的图像。作为可缩放矢量图形来说非常实用。这就是设计师更频繁地使用它的原因。
SVG
是一种无损格式 – 意味着它在压缩时不会丢失任何数据,可以呈现无限数量的颜色,最常用于网络上的图形、徽标可供其他高分辨率屏幕上查看。
优点
- 矢量格式可以随意调整大小
- 能够在代码或文本编辑器中创建简单的 SVG 渲染
- 从 Adobe Illustrator 或 Sketch 设计和导出复杂图形
- 可以访问 SVG 文本
- SVG 很容易设计风格和脚本
- 现代浏览器支持 SVG 格式,并且面向未来
- 格式具有高度可压缩性和轻量级
- 由于基于文本的格式,因此适合搜索
- 支持透明度
- 允许静止或动画图像
缺点
- 设计 SVG 可能会变得复杂
- 不建议在某些降级的浏览器上呈现
- 电子邮件客户端支持有限
原理
首先,克隆目标元素已经所有的子类元素,然后对克隆元素做不同资源的处理,比如:字体、图片、样式等。
等克隆元素的内容和样式等都处理完成之后,将其转换成SVG的Base64
数据URL,再生成SVG
。最后通过Canvas
来绘制生成的SVG
,最终输出你需要的不同格式的图。可以看看实现的流程图:
实现
先看看需要实现哪些功能,如:SVG、JPG、PNG、Blob、PixelData 等类型图的输出。这里实现一个 DomToImage
类,类里实现这些方法即可。
export default class DomToImage {
public options
/**
* constructor
* @param props 渲染参数
*/
constructor(options: RenderOptions) {
const defaultValue = {
quality: 1, // 透明度
cacheBust: false,
useCredentials: false,
httpTimeout: 30000,
scale: window.devicePixelRatio, // 图片放大倍数
}
this.options = { ...defaultValue, ...options }
}
toSvg() {
// 克隆元素
// 处理克隆元素字体
// 处理图片和样式内以及背景图
// 生成SVG
}
toPng() {
// 具体实现
}
toJpg() {
// 具体实现
}
toCanvas() {
// 具体实现
}
toBlob() {
// 具体实现
}
toPixelData() {
// 具体实现
}
}
这里主要的是 toSVG
里的实现,从流程图上可以看出,针对克隆元素有process-font
、process-style
、process-image
三大处理。这里就只列举下关键的实现,感兴趣的同学可以去看看源码。
如何解决图片跨域
这里我采用XMLHttpRequest
+ Blob
文件流的方式来处理图片,包括图片元素以及样式内的图片都会经过这处理。看看具体实现:
export const xhr = (props: {
url: string
httpTimeout?: number
cacheBust?: boolean // 是否绕过缓存
useCredentials?: boolean // 是否跨域
successHandle?: Function | undefined
failHandle?: Function | undefined
}) => {
let { url } = props
const {
httpTimeout = 30000,
cacheBust = false,
useCredentials = false,
successHandle,
failHandle,
} = props
if (cacheBust) {
// Cache bypass so we dont have CORS issues with cached images
// Source: https://developer.mozilla.org/en/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest#Bypassing_the_cache
url += (/\?/.test(url) ? '&' : '?') + new Date().getTime()
}
return new Promise(function (resolve, reject) {
const request = new XMLHttpRequest()
request.onreadystatechange = handle
request.ontimeout = () => reject(`timeout of ${httpTimeout}ms occured while fetching resource: ${url}`)
request.responseType = 'blob'
request.timeout = httpTimeout
if (useCredentials) request.withCredentials = true
request.open('GET', url, true)
request.send()
request.onerror = reject;
function handle() {
if (request.readyState !== 4) return
if (request.status === 200) {
if (successHandle instanceof Function) {
successHandle(request, resolve)
} else {
resolve(request)
}
} else {
if (failHandle instanceof Function) {
failHandle(request, reject)
} else {
reject(`cannot fetch resource: ${url}, status: ${request.status}`)
}
}
}
})
}
通过这个方法获取到图片的blob
数据流,再通过fileReader
来读取并转换为 Base64 的 Image。
export const readUrlFileToBase64 = (props: {
url: string
httpTimeout?: number
cacheBust?: boolean
useCredentials?: boolean
imagePlaceholder?: string // base64
}): Promise<unknown> =>
xhr({
...props,
successHandle: (
request: { response: Blob },
resolve: (arg0: string | ArrayBuffer | null) => void,
) => {
const reader = new FileReader()
reader.onloadend = function () {
const content = reader.result
resolve(content)
}
reader.onerror = (err) => console.error('img url reader fail', err)
reader.readAsDataURL(request.response)
},
})
图片处理的功能实现,接下来就是将图片元素和样式背景图片的图片都通过这个转换成 Base64 的形式,最终也内联形式呈现这克隆元素上。
图片最终在 SVG上呈现的结果是这样的:
处理字体
这里说的主要是自定义的字体的处理,其原理是把当前页面的document.styleSheets
中筛选自定义字体进行处理处理。比如这样的字体:
@font-face {
font-family: LiuJianMaoCao-Regular;
src: url('LiuJianMaoCao-Regular.ttf');
}
@font-face {
font-family: "Al-Black";
font-weight: 1000;
src: url("//at.alicdn.com/wf/webfont/QBa4l4xvmwzg/-xLe239h9x-gXbqAqlxsa.woff2") format("woff2"),
url("//at.alicdn.com/wf/webfont/QBa4l4xvmwzg/pYI44mTrOmZ5yzTP60GEq.woff") format("woff");
font-display: swap;
}
这些自定义字体都别转换成 Base64 的形式存在,比如这样:
看看DEMO测试 效果:
Style 处理
把所有元素的样式转化成行内样式一一对应到克隆元素上,样式处理这块包括针对background
图片的处理,上面跨域图片这块已经讲过,另外针对伪类做了处理,目前只处理了before
、after
。
有个要注意地方,在 safari 上针对 background-size:100% auto;
写法,正常情况省略auto
并没有问题,但是当有-webkit-background-size
时,以它优先垂直方向就会被拉升,因为在 safari 上通过 getComputedStyle
获取到的样式会有 -webkit-background-size
并没带 auto
,所以这里需要特殊处理。具体实现看看下面源码:
/**
* 设置克隆元素样式
* @param {CSSStyleDeclaration} sourceNodeCssStyle
* @param {CSSStyleDeclaration} cloneNodeCssStyle
*/
export const setCloneNodeStyleProperty = (
sourceNodeCssStyle: CSSStyleDeclaration,
cloneNodeCssStyle: CSSStyleDeclaration,
) => {
if (sourceNodeCssStyle.cssText) {
cloneNodeCssStyle.cssText = sourceNodeCssStyle.cssText
// TODO safari 解析Style兼容问题,100% auto 形式会自动省略 auto,这样会导致生成的背景图图高度会被默认为 100%,结果就是被拉伸了
if (
sourceNodeCssStyle.getPropertyValue('-webkit-background-size') ===
'100%' &&
util.checkBrowse().isSafari
) {
cloneNodeCssStyle.removeProperty('-webkit-background-size')
}
} else {
for (const key of sourceNodeCssStyle) {
if (sourceNodeCssStyle.getPropertyValue(key)) {
if (key !== '-webkit-background-size')
cloneNodeCssStyle.setProperty(
key,
sourceNodeCssStyle.getPropertyValue(key),
sourceNodeCssStyle.getPropertyPriority(key),
)
}
}
}
}
来看看原图和生成被拉升的对比结果:
被拉升了肯定不是我们想要的结果。另外针对 html2Canvas
不支持的,常用的样式做了测试
下面示例中的都包含这些属性:
最后就回到一开始的实现上方法,主要就是生成 Svg 过程实现:
toSvg() {
return Promise.resolve()
.then((): any =>
cloneNode(this.options.targetNode, this.options.filter, true),
)
.then(processFonts)
.then(checkElementImgToInline) // 图片和背景图转内联形式
.then(this.applyOptions.bind(this))
.then((clone) => {
clone.setAttribute('style', '')
return createSvgEncodeUrl(
clone,
this.options.width || util.width(this.options.targetNode),
this.options.height || util.height(this.options.targetNode),
)
})
}
其他的类型都是基于 SVG的基础上再通过 Canvas
来绘制的,这一步就相对简单了,我这里就不细说了。 另外关于使用以及参数配置可以看下 ts-dom-to-image 说明文档。
最后
欢迎有兴趣的朋友去体验使用,如有问题欢迎提 issues,也可以在评论去说明。如觉得还不错的,也不妨点个👍或 start
就万分感谢了!