前段时间刚好有截取DOM为图片的需求,一开始用的html2canvas,但因为html2canvas太过重量级且有许多缺陷,于是我弃用了它。
我花了一些时间自己写了一个npm包:
https://www.npmjs.com/package/dom-painterwww.npmjs.com下面我来说说怎么自己用SVG foreignObject实现dom截图功能。
假如我需要为一个dom节点截图:
const ele = document.getElementById('test');
获取到节点之后开始一步步地获取样式并截图。
选择可渲染样式
由于要使用getComputedStyle获取节点style,所以在遍历dom结点获取样式前,我们需要决定获取哪些样式,可以用一个数组renderStyles来放置需要渲染的样式,如果ele只定义了height
、width
、border
和font-size
四个属性,那么renderStyles就是:
const renderStyles=['height','width','border','font-size'];
但实际上ele和其子元素所定义的样式比这样复杂许多,于是我们要进行一些筛选。
第二步就是筛选可继承样式,对于可继承样式,比如font-size
,如果父元素已经定义了字体大小,那么子元素的字体大小默认继承父元素的字体大小,这个时候便不需要在子元素内联样式内添加font-size
。
因此要声明一个inheritedStyles存储可继承属性:
const inheritedStyles= [
'fontFamily',
'fontWeight',
'fontSize',
'fontStyle',
'lineHeight',
'letterSpacing',
'textAlign',
'textShadow',
'textTransform',
// ...
]
第三步是flex
和grid
,我们知道如果元素的display
不为flex
或grid
等值,属性的flex-direction
、 flex-wrap
、 grid-template-columns
属性就是无效的,因此当元素的display
不为flex
或grid
等值时,要筛选掉这些属性。
第四步是筛选默认属性,比如position
的默认属性就是static
,如果属性值是默认值,这些属性可以筛选掉。
遍历DOM结点获取样式
我们需要遍历包含ele和其子元素在内的所有节点,获取他们的style并拼接成字符串。
但在获取style之前,需要判断节点的类型。
- 如果是
#text
,也就是文本节点,不需要做任何操作,因为文本节点没有style,更没有子元素。 - 如果是
img
,判断其src
属性是否为base64格式,因为绘制图片需要将图片格式转为base64,网络图片如果存在跨域问题便无法转换为base64格式。 - 如果是
canvas
,将canvas
转换成一个base64位的图片(并不是真的将canvas
结点变成img
结点)。 - 如果是
svg
,同canvas
一样转换为base64图片。 - 如果是
br
,hr
,img
这些没有子元素的元素,就不需要遍历子元素。
经过上述操作后,我们就可以递归遍历结点和其子节点,获取其style,最后生成一个包含有若干内联样式的html字符串。
该部分代码及注释如下:
async function htmlToText(node: any) {
let txt: string = ""
if (node.nodeName !== "#text") { // 不为文本节点
let nodeName: string = node.nodeName.toLowerCase() // nodeName转换为小写
let otherAttr: string = ''
if (nodeName === 'img') { // 如果是img
const src = node.src
if (isBase64(src)) { // src属性是否为base64
otherAttr += ` src='${src}'` // 如果是就添加一个src属性
}
}
if (nodeName === 'canvas') { // 如果是canvas
nodeName = 'img' // 当前节点名换成img
otherAttr += ` src='${node.toDataURL(node)}'` // 将canvas转换为base64并添加src属性
}
if (node.nodeName === 'svg') { // 如果是svg
nodeName = 'img' // 当前节点名换成img
await svgToImage(node).then(res => { // 将svg转换为base64并添加src属性
otherAttr += ` src=${res}`
})
}
const style: string = styleToString(node, styleInfo.renderStyles) // 将结点和可渲染样式列表传入生成内联style字符串
txt += `<${nodeName}${otherAttr} style="${style}">` // 拼接html标签和style样式
if (!tags.includes(nodeName)) { // 如果是可以包含子元素的标签
const childNodes: any = node.childNodes // 获取子节点集合
for (let i = 0, j = childNodes.length;i < j;i++) { // 利用递归遍历子节点获取样式
txt += await htmlToText(childNodes[i])
}
txt += `</${nodeName}>` // 闭合标签
}
} else {
txt += node.data
}
return txt
}
function styleToString(node: any, styleNames: String[]) {
const css: any = window.getComputedStyle(node, null) // 获取元素样式对象
const style: string[] = []
const parent: any = node.parentNode // 获取父元素
let parentStyle: any = null
if (parent.nodeName !== 'HTML') { // 如果父元素不是HTML元素
parentStyle = window.getComputedStyle(parent, null) // 获取父元素样式列表,HTML元素书没有样式列表的
}
const display: String = css['display'] // 获取元素display属性值
const gridDisplay: String[] = ['grid', 'inlineGrid'] // 是否为grid或行内grid
const flexDisplay: String[] = ['flex', 'inlineFlex'] // 是否为flex或行内flex
const { gridStyles, flexStyles, inheritedStyles, defaultStyles, specialStyles } = styleInfo // 获取先前筛选出来的各类样式列表
for (const name of styleNames) { // 遍历可渲染属性
const fName: string = separatorToCamelNaming(name) // 将属性名格式改为驼峰命名
let value = css[fName] // 获取样式属性值
if (!gridDisplay.includes(display)) { // 如果不是grid布局
if (gridStyles.includes(fName)) { // 跳过与grid相关联的属性
continue
}
}
if (!flexDisplay.includes(display)) { // 如果不是flex布局
if (flexStyles.includes(fName)) { // 跳过与flex相关联的属性
continue
}
}
if (fName === 'backgroundImage') {
let url: string = ''
if (/^url(/.test(value)) { // 背景图片的url是否为base64
url = value.split('(')[1].split(')')[0]
if (url && !isBase64(url)) {
continue
}
}
}
if (parentStyle) { // 存在父元素样式
let parV: string = parentStyle[fName] // 获取父元素对应属性值
if (value === parV) { // 同属性上,子元素属性值和父元素属性值相同
if (value === 'none' || value === 'normal' || value === '0px' || value === 'auto') {
continue
}
if (inheritedStyles.includes(fName)) { // 跳过继承属性
continue
} else if (defaultStyles[fName] === value) { // 跳过默认属性值属性
continue
}
} else {
if (defaultStyles[fName] === value) { // 跳过默认属性值属性
continue
}
}
if (parentStyle.display !== 'flex' || parentStyle.display !== 'inline-flex') { // 如果父元素不是flex布局,跳过子元素的flex属性
if (fName === 'flex') continue
}
}
if (fName === 'fontFamily') {
if(value){
value = value.replace(/"/g, ''); // 去掉font-family属性值中的双引号
}
}
style.push(`${name}: ${value};`);
}
return style.join(' ') // 拼接样式字符串
}
function separatorToCamelNaming(name: String) { // 将属性名转换为驼峰命名
const nameArr: string[] = name.split(/-/g)
let newName: string = ''
for (let i = 0, j = nameArr.length;i < j;i++) {
const item: string = nameArr[i]
if (i === 0) {
newName += item
} else {
newName += `${item[0].toLocaleUpperCase()}${item.substr(1)}`
}
}
return newName
}
function svgToImage(svg: any) { // 将svg转换为base64
const svgXmlStr: string = new XMLSerializer().serializeToString(svg)
const imgBase64: string = `data:image/svg+xml;base64,${window.btoa(svgXmlStr)}`
let url: string = ''
const canvas: any = document.createElement('canvas')
const ctx: any = canvas.getContext('2d')
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'Anonymous'
img.src = imgBase64
img.onload = () => {
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
url = canvas.toDataURL()
resolve(url)
}
})
}
节点的样式我们都获取完了,但是如果有外部样式和网络字体介入怎么办呢?
interface FontUrl {
url: RequestInfo;
fontFamily: String;
}
async function loadFonts(fontUrlList: any) { // 接收外部字体列表
const promises: Promise<String>[] = []
let fontStyleStr = ''
fontUrlList.forEach((item: FontUrl) => {
// 遍历并fetch字体链接,获取内容
const promise: Promise<String> = fetch(item.url)
.then((res: any) => res.blob())
.then((data: Blob) => {
return new Promise(resolve => {
const fr: FileReader = new FileReader()
fr.onload = (e: any) => {
resolve(e.target.result)
}
fr.readAsDataURL(data)
})
}).then((data: any) => {
const fontFamily: String = item.fontFamily
let fontStr: String =
`n@font-face {
font-family: ${fontFamily};
font-style: normal;
font-weight: regular;
src: url('${data}');
}n`
return fontStr
})
promises.push(promise)
})
await Promise.all(promises).then(list => {
for (let i = list.length - 1;i >= 0;i--) {
fontStyleStr += list[i]
}
})
return fontStyleStr
}
async function loadLinks(linkList: any) { // 接收外部链接
const promises: Promise<String>[] = []
let linkStyleStr = ''
linkList.forEach((item: string) => {
// 转换url为blob对象
const promise: Promise<String> = fetch(item)
.then((res: any) => res.text())
.then((data: string) => data)
promises.push(promise)
})
await Promise.all(promises).then(list => {
for (let i = list.length - 1;i >= 0;i--) {
linkStyleStr += list[i]
}
})
return linkStyleStr
}
这样就可以获取到外部字体和样式的内容了,不过要注意一点:这里的字体链接一定是直接链接到字体文件的,很多网络字体的链接只是链接到另外一个包含许多字体链接的样式表而已,这样的话是获取不到字体内容的。
重要的事情都完成了,接下来就要利用foreignObject来实现截图功能了!
由于svg标签内包含的html格式必须符合xhtml标准,所以要先转换一下格式:
function htmlToXml(str: string) {
const div = document.createElement('div')
div.innerHTML = str
const xml = new XMLSerializer().serializeToString(div.childNodes[0])
return xml
}
然后我们就可以拼接svg字符串并转换成base64编码了:
let styleStr: string = ''
await loadFonts(fonts).then((fontStyleStr: string) => { // 获取外部字体样式
styleStr += fontStyleStr
})
await loadLinks(links).then((linkStyleStr: string) => { // 获取外部链接样式
styleStr += linkStyleStr
})
let xmlStr: string = ''
await htmlToText(element).then(res => { // 将element结点转换为html字符串
xmlStr = res
})
xmlStr = htmlToXml(xmlStr) // 将html变为xml格式
const svg: Blob = buildSVG(width, height, xmlStr, styleStr) // 构建svg,传入绘制图片的宽高,html和css
let dataURL: string = ''
await drawImage(width, height, svg, quality, format).then(res => { // 传入图片宽高,svg,图片质量和图片格式
dataURL = res // 获取dataURL
})
// buildSVG函数:
function buildSVG(width: number, height: number, xmlStr: string, styleStr: string) { // 构建svg
// 拼接svg字符串
const htmlStr = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">n
<style>n
${styleStr}
</style>n
<foreignObject width="100%" height="100%">n
${xmlStr}
</foreignObject>n
</svg>`
// 将svg转化为blob
const svg = new Blob(htmlStr.split(''), {
type: 'image/svg+xml;charset=utf-8'
})
return svg
}
async function drawImage(width: number, height: number, svg: any, quality?: number, format?: string) {
let url: string = ''
await blobToDataURL(svg).then(async (res: any) => {
// 用canvas绘制
const canvas: any = document.createElement('canvas')
const ctx: any = canvas.getContext('2d')
const canvasW: any = document.createAttribute("width")
const canvasH: any = document.createAttribute("height")
canvasW.nodeValue = width
canvasH.nodeValue = height
canvas.setAttributeNode(canvasW)
canvas.setAttributeNode(canvasH)
ctx.fillStyle = "#fff"
ctx.fillRect(0, 0, width, height)
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'Anonymous'
img.src = res
img.onload = () => {
ctx.drawImage(img, 0, 0, width, height)
url = canvas.toDataURL(`image/${format}`, quality)
resolve(url)
}
})
})
return url
}
async function blobToDataURL(blob: Blob) { // 将blob转化为dataUrl
return new Promise((resolve, reject) => {
let a: FileReader = new FileReader()
a.onload = async (e: any) => {
resolve(e.target.result)
}
a.readAsDataURL(blob)
})
}
这样我们就可以截取页面的dom对象了。
实际来讲,如果你明确地知道你要绘制什么元素,大可不必写这么多步骤,这样写只是为了在更复杂的条件下也能进行截图。