html转图片

1,573 阅读1分钟

开发中经常遇到一些 html 代码转图片的需求,比如海报动态生成。这里我们介绍一些常用的库

  1. html-to-image:github.com/bubkoo/html…
  2. html2canvas:github.com/niklasvh/ht…

类似的库有很多,这里我们不讲如何使用这些库,因为到时候用的时候也可以看文档。这里主要讲解实现的原理。

这里以 html-to-image 为例来讲解底层实现原理。(需要掌握 svg 相关知识)

原理介绍(掌握了原理就掌握了一切)

重点就是 html 转化为 svg,步骤如下:

然后我们得到了 svg 的信息 dataurl 之后,我们可以通过 new Image 来创建图片在Canvas上绘制,转化为canvas对象,实现了 html-to-canvas。接下来就是 canvas 转化为 图片就简单多了,这些看我们这里的其他文章。

难点就是对 node 的处理,这块我们慢慢学习。

主要实现原理

  • 构建 node 树,这个不作解释,太复杂
  • 创建 SVG 的壳子
export const createForeignObjectSVG = (
    width: number,
    height: number,
    x: number,
    y: number,
    node: Node
): SVGForeignObjectElement => {
    const xmlns = 'http://www.w3.org/2000/svg';
    const svg = document.createElementNS(xmlns, 'svg');
    const foreignObject = document.createElementNS(xmlns, 'foreignObject');
    svg.setAttributeNS(null, 'width', width.toString());
    svg.setAttributeNS(null, 'height', height.toString());

    foreignObject.setAttributeNS(null, 'width', '100%');
    foreignObject.setAttributeNS(null, 'height', '100%');
    foreignObject.setAttributeNS(null, 'x', x.toString());
    foreignObject.setAttributeNS(null, 'y', y.toString());
    foreignObject.setAttributeNS(null, 'externalResourcesRequired', 'true');
    svg.appendChild(foreignObject);

    foreignObject.appendChild(node);

    return svg;
};
  • DOM 转 SVG
const s = new XMLSerializer();
this.svg = `data:image/svg+xml,${encodeURIComponent(s.serializeToString(img))}`;
  • SVG 转化为 Canvas
const img = new Image()
img.decode = () => resolve(img)
img.onload = () => resolve(img)
img.onerror = reject
img.crossOrigin = 'anonymous'
img.decoding = 'async'
img.src = svg_dataurl; // 这里将上边的this.svg传入就行

详细分解

如何获取DOM的尺寸

首先我们得获取我们的DOM元素的各种尺寸属性getImageSize

function getImageSize(targetNode) {
  const width = getNodeWidth(targetNode)
  const height = getNodeHeight(targetNode)
  return { width, height }
}

从上图看到,我们知道一个元素的宽度是:内容宽度 + 边的宽度

所以我们获取一个元素的宽度和高度分别是:getNodeWidthgetNodeHeight

function getNodeWidth(node) {
  const leftBorder = px(node, 'border-left-width')
  const rightBorder = px(node, 'border-right-width')
  return node.clientWidth + leftBorder + rightBorder
}

function getNodeHeight(node) {
  const topBorder = px(node, 'border-top-width')
  const bottomBorder = px(node, 'border-bottom-width')
  return node.clientHeight + topBorder + bottomBorder
}

函数px获取某个尺寸属性。

function px(node,styleProperty) {
		let win = node.ownerDocument.defaultView || window;
		let val = win.getComputedStyle(node).getPropertyValue(styleProperty);
		return val? parseFloat(val.replace('px','')):0;
}

核心实现

看了代码有几个对外方法分别是:toSvg toCanvas toPixelData toPng toJpeg toBlob

但是核心其实只有toSvg,其余方法都是基于这个实现的。

比如toCanvas

function toCanvas() {
	const { width,height } = getImageSize(node);
	const svg = await toSvg(node);
	const img = await createImage(svg);

	const canvas = document.createElement('canvas')
	const context = canvas.getContext('2d')!
	const ratio = window.devicePixelRatio || 1;
	
	const canvasWidth = width;
	const canvasHeight = height;

	canvas.width = canvasWidth * ratio;
	canvas.height = canvasHeight * ratio;
	
	canvas.style.width = `${canvasWidth}`;
	canvas.style.height = `${canvasHeight}`;
	
	context.drawImage(img, 0, 0, canvas.width, canvas.height)

	return canvas;
}

可见核心就是toSvg createImage

function createImage(url) {
	return new Promise((resolve, reject) => {
		const img = new Image()
		img.decode = () => resolve(img)
		img.onload = () => resolve(img)
		img.onerror = reject
		img.crossOrigin = 'anonymous'
		img.decoding = 'async'
		img.src = url
	})
}

可见,我们核心的实现其实就是 toSvg

接下来详细介绍 toSvg

toSvg

先看代码:

function toSvg(node) {
  const { width, height } = getImageSize(node)
  const clonedNode = await cloneNode(node, true);
  await embedWebFonts(clonedNode)
  await embedImages(clonedNode);
	
  const datauri = await nodeToDataURL(clonedNode, width, height)
  return datauri;
}

1. getImageSize

不讲解了,上面有介绍。

2. cloneNode

首先讲讲我们 Element 上的方法 cloneNode。MDN:developer.mozilla.org/zh-CN/docs/…

// deep: 是否采用深度克隆
var dupNode = node.cloneNode(deep);

上面是我们普及的一些基本知识,接下来开始讲。

我们先看下如何实现:

async function cloneNode(node,isRoot) {
  if (!isRoot) {
    return null
  }

  return Promise.resolve(node)
    .then((clonedNode) => cloneSingleNode(clonedNode))
    .then((clonedNode) => cloneChildren(node, clonedNode))
    .then((clonedNode) => decorate(node, clonedNode))
    .then((clonedNode) => ensureSVGSymbols(clonedNode))
}
  • cloneSingleNode:实现方式就是我们上边讲的方法 node.cloneNode(false),但是有三种情况得注意:
async function cloneSingleNode(node){
  if (isInstanceOfElement(node, HTMLCanvasElement)) {
    return cloneCanvasElement(node)
  }

  if (isInstanceOfElement(node, HTMLVideoElement)) {
    return cloneVideoElement(node, options)
  }

  if (isInstanceOfElement(node, HTMLIFrameElement)) {
    return cloneIFrameElement(node)
  }

  return node.cloneNode(false) as T
}

这里介绍个方法:isInstanceOfElement

export const isInstanceOfElement = (node,instance) => {
  if (node instanceof instance) return true

  const nodePrototype = Object.getPrototypeOf(node)

  if (nodePrototype === null) return false

  return (
    nodePrototype.constructor.name === instance.name ||
    isInstanceOfElement(nodePrototype, instance)
  )
}
    • canvas标签:使用了cloneCanvasElement实现
async function cloneCanvasElement(canvas) {
  const dataURL = canvas.toDataURL()
  if (dataURL === 'data:,') {
    return canvas.cloneNode(false);
  }
	// 使用到了上面用到的这个方法
  return createImage(dataURL)
}
    • video标签:使用了cloneVideoElement实现
async function cloneVideoElement(video) {
  if (video.currentSrc) {
    const canvas = document.createElement('canvas')
    const ctx = canvas.getContext('2d')
    canvas.width = video.clientWidth
    canvas.height = video.clientHeight
    ctx?.drawImage(video, 0, 0, canvas.width, canvas.height)
    const dataURL = canvas.toDataURL()
    return createImage(dataURL)
  }

  const poster = video.poster
  const contentType = getMimeType(poster)
  const dataURL = await resourceToDataURL(poster, contentType)
  return createImage(dataURL)
}
    • iframe标签:使用了cloneIFrameElement实现
async function cloneIFrameElement(iframe) {
  try {
    if (iframe?.contentDocument?.body) {
      return (await cloneNode(
        iframe.contentDocument.body,
        {},
        true,
      ));
    }
  } catch {
    // Failed to clone iframe
  }

  return iframe.cloneNode(false);
}
  • cloneChildren

查看assignedNodes: MDN

// nativeNode: 原node
// clonedNode:克隆的node
async function cloneChildren(nativeNode,clonedNode) {
  let children: T[] = []
	// 如果是 slot 标签
  if (isSlotElement(nativeNode) && nativeNode.assignedNodes) {
    children = toArray(nativeNode.assignedNodes())
  } else if (
    isInstanceOfElement(nativeNode, HTMLIFrameElement) &&
    nativeNode.contentDocument?.body
  ) {
    children = toArray(nativeNode.contentDocument.body.childNodes)
  } else {
    children = toArray((nativeNode.shadowRoot ?? nativeNode).childNodes)
  }
	// 如果是 video 标签
  if (
    children.length === 0 ||
    isInstanceOfElement(nativeNode, HTMLVideoElement)
  ) {
    return clonedNode
  }
	// 核心
  await children.reduce(
    (deferred, child) =>
      deferred
        .then(() => cloneNode(child))
        .then((clonedChild) => {
          if (clonedChild) {
            clonedNode.appendChild(clonedChild)
          }
        }),
    Promise.resolve(),
  )

  return clonedNode
}
  • decorate:处理 html 标签。
function decorate(nativeNode, clonedNode) {
  if (isInstanceOfElement(clonedNode, Element)) {
    cloneCSSStyle(nativeNode, clonedNode)
    clonePseudoElements(nativeNode, clonedNode)
    cloneInputValue(nativeNode, clonedNode)
    cloneSelectValue(nativeNode, clonedNode)
  }

  return clonedNode
}
    • cloneCSSStyle:克隆CSS样式

window.getComputedStyle:developer.mozilla.org/en-US/docs/…

function cloneCSSStyle(nativeNode, clonedNode) {
	// 获取克隆的node的style
	const targetStyle = clonedNode.style
	if (!targetStyle) {
		return
	}
	// 获取原node的样式
	const sourceStyle = window.getComputedStyle(nativeNode)
	// 如果原样式存在css我们直接赋值
	if (sourceStyle.cssText) {
		targetStyle.cssText = sourceStyle.cssText
		targetStyle.transformOrigin = sourceStyle.transformOrigin
	} else {
		toArray(sourceStyle).forEach((name) => {
			let value = sourceStyle.getPropertyValue(name)
			if (name === 'font-size' && value.endsWith('px')) {
				const reducedFont =
					Math.floor(parseFloat(value.substring(0, value.length - 2))) - 0.1;
				value = `${reducedFont}px`
			}

			if (
				isInstanceOfElement(nativeNode, HTMLIFrameElement) &&
				name === 'display' &&
				value === 'inline'
			) {
				value = 'block'
			}

			if (name === 'd' && clonedNode.getAttribute('d')) {
				value = `path(${clonedNode.getAttribute('d')})`
			}

			targetStyle.setProperty(
				name,
				value,
				sourceStyle.getPropertyPriority(name),
			)
		})
	}
}
    • clonePseudoElements
export function clonePseudoElements(nativeNode,clonedNode) {
  clonePseudoElement(nativeNode, clonedNode, ':before')
  clonePseudoElement(nativeNode, clonedNode, ':after')
}

function clonePseudoElement(nativeNode,clonedNode,pseudo) {
  const style = window.getComputedStyle(nativeNode, pseudo)
  const content = style.getPropertyValue('content')
  if (content === '' || content === 'none') {
    return
  }

  const className = uuid()
  try {
    clonedNode.className = `${clonedNode.className} ${className}`
  } catch (err) {
    return
  }

  const styleElement = document.createElement('style')
  styleElement.appendChild(getPseudoElementStyle(className, pseudo, style))
  clonedNode.appendChild(styleElement)
}
    • cloneInputValue
function cloneInputValue(nativeNode, clonedNode) {
  if (isInstanceOfElement(nativeNode, HTMLTextAreaElement)) {
    clonedNode.innerHTML = nativeNode.value
  }

  if (isInstanceOfElement(nativeNode, HTMLInputElement)) {
    clonedNode.setAttribute('value', nativeNode.value)
  }
}
    • cloneSelectValue
function cloneSelectValue(nativeNode, clonedNode) {
  if (isInstanceOfElement(nativeNode, HTMLSelectElement)) {
    const clonedSelect = clonedNode;
    const selectedOption = Array.from(clonedSelect.children).find(
      (child) => nativeNode.value === child.getAttribute('value'),
    )

    if (selectedOption) {
      selectedOption.setAttribute('selected', '')
    }
  }
}
  • ensureSVGSymbols:处理 svg 标签
async function ensureSVGSymbols(clone) {
  const uses = clone.querySelectorAll ? clone.querySelectorAll('use') : []
  if (uses.length === 0) {
    return clone
  }

  const processedDefs = {}
  for (let i = 0; i < uses.length; i++) {
    const use = uses[i]
    const id = use.getAttribute('xlink:href')
    if (id) {
      const exist = clone.querySelector(id)
      const definition = document.querySelector(id);
      if (!exist && definition && !processedDefs[id]) {
        // eslint-disable-next-line no-await-in-loop
        processedDefs[id] = (await cloneNode(definition, true))!
      }
    }
  }

  const nodes = Object.values(processedDefs)
  if (nodes.length) {
    const ns = 'http://www.w3.org/1999/xhtml'
    const svg = document.createElementNS(ns, 'svg')
    svg.setAttribute('xmlns', ns)
    svg.style.position = 'absolute'
    svg.style.width = '0'
    svg.style.height = '0'
    svg.style.overflow = 'hidden'
    svg.style.display = 'none'

    const defs = document.createElementNS(ns, 'defs')
    svg.appendChild(defs)

    for (let i = 0; i < nodes.length; i++) {
      defs.appendChild(nodes[i])
    }

    clone.appendChild(svg)
  }

  return clone
}

3. embedWebFonts

async function embedWebFonts<T extends HTMLElement>(
  clonedNode: T,
  options: Options,
) {
  const cssText =
    options.fontEmbedCSS != null
      ? options.fontEmbedCSS
      : options.skipFonts
      ? null
      : await getWebFontCSS(clonedNode, options)

  if (cssText) {
    const styleNode = document.createElement('style')
    const sytleContent = document.createTextNode(cssText)

    styleNode.appendChild(sytleContent)

    if (clonedNode.firstChild) {
      clonedNode.insertBefore(styleNode, clonedNode.firstChild)
    } else {
      clonedNode.appendChild(styleNode)
    }
  }
}

4. embedImages

这个主要就是处理图片,包含背景图片,图片标签,嵌套图片。

export async function embedImages(clonedNode) {
  if (isInstanceOfElement(clonedNode, Element)) {
    await embedBackground(clonedNode)
    await embedImageNode(clonedNode)
    await embedChildren(clonedNode)
  }
}

5. 🌻 nodeToDataURL(核心)

这个是核心方法,需要掌握一定的 svg 的原理。

async function nodeToDataURL(node,width,height) {
  const xmlns = 'http://www.w3.org/2000/svg'
  const svg = document.createElementNS(xmlns, 'svg')
  const foreignObject = document.createElementNS(xmlns, 'foreignObject')

  svg.setAttribute('width', `${width}`)
  svg.setAttribute('height', `${height}`)
  svg.setAttribute('viewBox', `0 0 ${width} ${height}`)

  foreignObject.setAttribute('width', '100%')
  foreignObject.setAttribute('height', '100%')
  foreignObject.setAttribute('x', '0')
  foreignObject.setAttribute('y', '0')
  foreignObject.setAttribute('externalResourcesRequired', 'true')

  svg.appendChild(foreignObject)
  foreignObject.appendChild(node)
  return svgToDataURL(svg)
}
 
// 通过 XMLSerializer 对象的  serializeToString 方法,将 node 转化为 xml
async function svgToDataURL(svg) {
  return Promise.resolve()
    .then(() => new XMLSerializer().serializeToString(svg))
    .then(encodeURIComponent)
    .then((html) => `data:image/svg+xml;charset=utf-8,${html}`)
}

XMLSerializer developer.mozilla.org/en-US/docs/…

这个对象有一个方法叫 serializeToString 主要作用就是可以将 DOM tree 转化为 XML