背景
最近接触到js截图的功能,看了一些分享,也做了一些调研,看了下源码,了解了下实现原理
前端截图大概逻辑都为
- 拿到需要复制的node对象
- 解析node对象,克隆一个带样式的node
- 用canvas或svg foreignObject转换得到图片
这个里面,为什么要克隆,以及克隆需要处理哪些细节,是蛮有意思的
市面上已经有不少的库,例如
- html2canvas
- dom-to-image
- html-to-image
- modern-screenshot
- 等
本文以modern-screenshot源码为例,分析下node转换成svg的原理
工程
我直接下载了modern-screenshot的源码放到工程内github.com/qq15725/mod… 项目结构也尽量简单化
html结构
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<title>screen shot</title>
<style>
html {
font-size: 20px;
}
#root {
background-color: red;
}
#child {
position: relative;
}
#child::before {
position: absolute;
top: 0px;
left: 0px;
width: 10px;
height: 10px;
background-color: yellow;
content: '66';
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">screen shot
<div id="child">before</div>
</div>
</body>
api调用入口
const el = document.querySelector('#root');
domToPng(el).then(dataUrl => {
const link = document.createElement('a')
link.download = 'screenshot.png'
link.href = dataUrl
link.click();
})
源码
我们利用svg的foreignObject来生成svg图片,但是如果要以其它图片格式下载到本地,就需要利用canvas的能力,所以我们重点看dom-to-canvas的逻辑
核心关键代码
const context = await orCreateContext(node, options)
const svg = await domToForeignObjectSvg(context)
const dataUrl = svgToDataUrl(svg, context.isEnable('removeControlCharacter'))
- orCreateContext 生成一个context对象,存放上下文需要的数据,
- domToForeignObjectSvg 生成svg foreignObject
- svgToDataUrl 转换成其它格式
context 上下文
context就是贯穿整个逻辑的对象,它存放了整个上下文的数据,有点像全局对象一样
源码定义的属性比较多,但里面主要分为2类
- options:外部传入。如 quality/type/scale/backgroundColor等
- 内部对象:如fontFamilies/fontCssTexts/ownerDocument/ownerWindow/node等
生成SVG foreignObejct
这里面有几个很关键的步骤,我简化了代码如下
// 克隆node节点
const clone = await cloneNode(context.node, context, true)
// 创建svg并插入clone节点
const svg = createForeignObjectSvg(clone, context)
svgDefsElement && svg.insertBefore(svgDefsElement, svg.children[0])
// 插入style
svgStyleElement && svg.insertBefore(svgStyleElement, svg.children[0])
克隆节点cloneNode
大致的逻辑为
node.cloneNode(false)
克隆当前节点(不要子节点),得到一个clone的节点。 这里只是简单克隆了node的节点,距离真实的node还差很远
copyCssStyles,复制node的css style样式
这里的代码可以细读一下
- 拿到node的computed style
- iframe生成一个同样的node tag的元素
- diff 上面2个node节点的computed style,拿到差异的diff
- 设置到clone的style上
等于将原node的所有差异样式都设置到了clone style上(注意:这里还没开始处理伪类)
// context存着上下文需要的数据
const { ownerWindow, includeStyleProperties, currentParentNodeStyle } = context
const clonedStyle = cloned.style
// 拿出原node computedStyle对象
const computedStyle = ownerWindow!.getComputedStyle(node);
// 这里是创建了一个iframe去添加node 对应的 新的tag节点,然后再返回其computedStyle
const defaultStyle = getDefaultStyle(node, null, context)
currentParentNodeStyle?.forEach((_, key) => {
defaultStyle.delete(key)
})
// 对比原node的computedStyle和defaultStyle, 拿出差异的值
const style = getDiffStyle(computedStyle, defaultStyle, includeStyleProperties)
// 针对svg/root节点/浏览器做一些hack
style.delete('transition-property')
...
// 给clone的节点设置样式
style.forEach(([value, priority], name) => {
clonedStyle.setProperty(name, value, priority)
})
处理到这里后,会得到一个有内联样式的clone节点
<div style="
background: none 0% 0% / auto repeat scroll padding-box border-box rgb(255, 0, 0);
block-size: 52.8px;
font-family: "Microsoft YaHei";
font-kerning: auto;
font-palette: normal;
font-size: 20px;
font-stretch: 100%;
font-style: normal;
font-variant: normal;
font-weight: 400;
inline-size: 1276px;
perspective-origin: 638px 26.4px;
transform-origin: 638px 26.4px;
transform-style: flat;
-webkit-locale: "en";
-webkit-text-fill-color: rgb(0, 0, 0);
box-sizing: border-box;
width: 1276px !important;
height: 52.8px !important;">
</div>
copyPseudoClass处理伪类
目前只定义了2大类
const pseudoClasses = [
':before',
':after',
// ':placeholder', TODO
]
const scrollbarPseudoClasses = [
':-webkit-scrollbar',
':-webkit-scrollbar-button',
// ':-webkit-scrollbar:horizontal', TODO
':-webkit-scrollbar-thumb',
':-webkit-scrollbar-track',
':-webkit-scrollbar-track-piece',
// ':-webkit-scrollbar:vertical', TODO
':-webkit-scrollbar-corner',
':-webkit-resizer',
]
伪类的逻辑主要为
- 将伪类的style拿出来,生成svg style
- 再给clone添加一个唯一的className来对应上svg style
<svg>
<style>
.uqb7q2::before{
content:'xx',
background: ''
}
</style>
<foreignObject>
<div class="uqb7q2" style="xxxxx"></div>
</foreignObject>
</svg>
关键代码
function copyBy(pseudoClass: string) {
// 原节点的伪类样式
const computedStyle = ownerWindow!.getComputedStyle(node, pseudoClass)
// 获取到content内容
let content = computedStyle.getPropertyValue('content')
if (!content || content === 'none') return
content = content
// TODO support css.counter
.replace(/(')|(")|(counter\(.+\))/g, '')
// 生成一个uuid作为类名
const klasses = [uuid()]
// 原节点伪类的默认样式
const defaultStyle = getDefaultStyle(node, pseudoClass, context)
currentNodeStyle?.forEach((_, key) => {
defaultStyle.delete(key)
})
// diff 样式
const style = getDiffStyle(computedStyle, defaultStyle, context.includeStyleProperties)
// 样式hack
style.delete('content')
style.delete('-webkit-locale')
// fix background-clip: text
if (style.get('background-clip')?.[0] === 'text') {
cloned.classList.add('______background-clip--text')
}
// 将content进去
const cloneStyle = [
`content: '${ content }';`,
]
// 将diff样式放进去
style.forEach(([value, priority], name) => {
cloneStyle.push(`${ name }: ${ value }${ priority ? ' !important' : '' };`)
})
if (cloneStyle.length === 1) return
// 将uuid的样式名放到cloned节点className上
try {
(cloned as any).className = [(cloned as any).className, ...klasses].join(' ')
} catch (err) {
return
}
// 设置svg样式,类似 .uuid::before{ xxxx } 关联上cloned节点的className
const cssText = cloneStyle.join('\n ')
let allClasses = svgStyles.get(cssText)
if (!allClasses) {
allClasses = []
svgStyles.set(cssText, allClasses)
}
allClasses.push(`.${ klasses[0] }:${ pseudoClass }`)
}
copyInputValue(node, cloned) 复制输入框的值
// 复制input节点的值
// textarea设置innerText, textarea/input/select设置attr value
copyInputValue(node, cloned)
copy font family
收集fontFamily, 并且设置到context fontFamilies上
// 分割字体,并且设置到context fontFamilies上
splitFontFamily(style.get('font-family')?.[0])
?.forEach(val => fontFamilies.add(val))
cloneNode的源码和标注
export async function cloneNode<T extends Node>(
node: T,
context: Context,
isRoot = false,
): Promise<Node> {
const { ownerDocument, ownerWindow, fontFamilies } = context
// 如果是 text节点,直接返回文字节点
if (ownerDocument && isTextNode(node)) {
return ownerDocument.createTextNode(node.data)
}
// html element节点 或 svg节点
if (
ownerDocument
&& ownerWindow
&& isElementNode(node)
&& (isHTMLElementNode(node) || isSVGElementNode(node))
) {
// 复制当前节点:canvas/iframe/image/video/element
const cloned = await cloneElement(node, context)
if (context.isEnable('removeAbnormalAttributes')) {
const names = cloned.getAttributeNames()
for (let len = names.length, i = 0; i < len; i++) {
const name = names[i]
if (!NORMAL_ATTRIBUTE_RE.test(name)) {
cloned.removeAttribute(name)
}
}
}
// 复制css样式: 将原node的computedStyle设置到clone节点style上
const style
= context.currentNodeStyle
= copyCssStyles(node, cloned, isRoot, context)
// context上如果有配置属性,给这些属性加important权重
// backgroundColor, width, height, style: styles
if (isRoot) applyCssStyleWithOptions(cloned, context)
// 判断是否需要复制滚动条
let copyScrollbar = false
if (context.isEnable('copyScrollbar')) {
const overflow = [ style.get('overflow-x')?.[0],
style.get('overflow-y')?.[1],
]
copyScrollbar = (overflow.includes('scroll'))
|| (
(overflow.includes('auto') || overflow.includes('overlay'))
&& (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth)
)
}
// 处理伪类 :before :after :-webkit-scrollbar-xx
// context内会有一个svgStyles, 处理伪类的时候,会生成svg style,类似 .xxx::before { content:'xxxx',yyy}, 然后给cloned节点加上className
copyPseudoClass(node, cloned, copyScrollbar, context)
// 复制input节点的值: textarea设置innerText, textarea/input/select设置attr value
copyInputValue(node, cloned)
// 分割字体,并且设置到context fontFamilies上
splitFontFamily(style.get('font-family')?.[0])
?.forEach(val => fontFamilies.add(val))
// 循环处理child 节点
if (!isVideoElement(node)) {
await cloneChildNodes(node, cloned, context)
}
return cloned
}
const cloned = node.cloneNode(false)
await cloneChildNodes(node, cloned, context)
return cloned
}
生成SVG
跑到这里我们拿到的clone对象和context上的svgStyleElement
就开始生成svg了
// 创建svg并插入clone节点
const svg = createForeignObjectSvg(clone, context)
svgDefsElement && svg.insertBefore(svgDefsElement, svg.children[0])
// 插入style
svgStyleElement && svg.insertBefore(svgStyleElement, svg.children[0])
function createForeignObjectSvg(clone: Node, context: Context): SVGSVGElement {
const { width, height } = context
const svg = createSvg(width, height, clone.ownerDocument)
const foreignObject = svg.ownerDocument.createElementNS(svg.namespaceURI, 'foreignObject')
foreignObject.setAttributeNS(null, 'x', '0%')
foreignObject.setAttributeNS(null, 'y', '0%')
foreignObject.setAttributeNS(null, 'width', '100%')
foreignObject.setAttributeNS(null, 'height', '100%')
foreignObject.append(clone)
svg.appendChild(foreignObject)
return svg
}
最终生成的svg对象如下
<svg width="1276" height="52.79999923706055" viewBox="0 0 1276 52.79999923706055">
<style>
.______background-clip--text {
background-clip: text;
-webkit-background-clip: text;
}
.uqb7q2::before {
content: '66';
bottom: 16.4px;
display: block;
height: 10px;
left: 0px;
position: absolute;
right: 1266px;
top: 0px;
width: 10px;
background-attachment: scroll;
...
}
</style>
<defs></defs>
<foreignObject x="0%" y="0%" width="100%" height="100%">
<div id="root" style="background: none 0% 0% / auto repeat scroll padding-box border-box rgb(255, 0, 0); block-size: 52.8px; font-family: "Microsoft YaHei"; font-kerning: auto; font-palette: normal; font-size: 20px; font-stretch: 100%; font-style: normal; font-variant: normal; font-weight: 400; inline-size: 1276px; perspective-origin: 638px 26.4px; transform-origin: 638px 26.4px; transform-style: flat; -webkit-locale: "en"; -webkit-text-fill-color: rgb(0, 0, 0); box-sizing: border-box; width: 1276px !important; height: 52.8px !important;">screen shot
<div id="child" class=" uqb7q2" style="inset: 0px; height: 26.4px; position: relative; width: 1276px; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0); block-size: 26.4px; box-shadow: none; box-sizing: content-box; font-family: "Microsoft YaHei"; font-kerning: auto; font-palette: normal; font-size: 20px; font-stretch: 100%; font-style: normal; font-variant: normal; font-weight: 400; inline-size: 1276px; inset-block: 0px; inset-inline: 0px; perspective-origin: 638px 13.2px; transform-origin: 638px 13.2px; transform-style: flat; -webkit-locale: "en"; -webkit-text-fill-color: rgb(0, 0, 0);">before</div>
</div>
</foreignObject>
</svg>
总结
读完并理解后,核心点在于将源node上的dom结构和style都取出来,构造一个clone node。computed style放到clone node的style上。如果有伪类(before after scrollbar),则使用svg style来处理。
当然我这里重点讲解的是html element部分,源码部分还包含了处理iframe/canvas/video/svg等节点的更多细节,在这里就不具体都说了, 大家可以看源码