开发中经常遇到一些 html 代码转图片的需求,比如海报动态生成。这里我们介绍一些常用的库
html-to-image:github.com/bubkoo/html…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 }
}
从上图看到,我们知道一个元素的宽度是:内容宽度 + 边的宽度。
所以我们获取一个元素的宽度和高度分别是:getNodeWidth、getNodeHeight。
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。