Web截图 - dom转图片源码解析

612 阅读6分钟

背景

最近接触到js截图的功能,看了一些分享,也做了一些调研,看了下源码,了解了下实现原理

前端截图大概逻辑都为

  1. 拿到需要复制的node对象
  2. 解析node对象,克隆一个带样式的node
  3. 用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等

image.png image.png

生成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样式

这里的代码可以细读一下

  1. 拿到node的computed style
  2. iframe生成一个同样的node tag的元素
  3. diff 上面2个node节点的computed style,拿到差异的diff
  4. 设置到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: &quot;Microsoft YaHei&quot;; 
    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: &quot;en&quot;; 
    -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',
]

伪类的逻辑主要为

  1. 将伪类的style拿出来,生成svg style
  2. 再给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: &quot;Microsoft YaHei&quot;; 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: &quot;en&quot;; -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: &quot;Microsoft YaHei&quot;; 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: &quot;en&quot;; -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等节点的更多细节,在这里就不具体都说了, 大家可以看源码

阅读拓展