為什么要寫
最近在做公司的項目中,遇到了需要對網頁上特定區域導出圖片的需求。在實現的過程中,遇到了一些坑並在填坑的過程中學習到了一些經驗,為了方便以后回顧,因此決定記錄下來。
初識 html2canvas
首先我們知道,瀏覽器沒有提供原生的截屏api供js使用,所以必須以“曲線救國”的方式進行實現。目前社區上關於截屏這塊最成熟的輪子是html2canvas,目前已經有2.3w 的star,它提供了開箱即用的簡潔api,能幫助我們很輕易地實現截屏功能,下面貼出官網給出的示例代碼,從中可以看到上手十分簡單
<div id="capture" style="padding: 10px; background: #f5da55"> <h4 style="color: #000; ">Hello world!</h4> </div> /* .... */ html2canvas(document.querySelector("#capture")).then(canvas => { document.body.appendChild(canvas) }) 復制代碼
這里還可以傳遞第二個參數,形如html2canvas(element, options)
,用於自定義控制渲染結果,其中可選屬性列舉如下
名稱 | 默認值 | 描述 |
---|---|---|
allowTaint | false |
是否允許跨域圖片被渲染到canvas上 |
backgroundColor | #ffffff |
canvas的背景顏色,如果背景需要被設置為透明,請設為null |
canvas | null |
指定使用頁面中已經存在的canvas實例 |
foreignObjectRendering | false |
在瀏覽器支持foreignObject 情況下,是否渲染foreignObject 中的內容 |
ignoreElements | (element) => false |
指定需要被忽略渲染的元素 |
onclone | null |
當頁面區域的dom被克隆完成時觸發的鈎子函數,在這個鈎子里可以對克隆出的dom進行修改,從而改變渲染結果,同時不會影響原本的頁面 |
proxy | null |
用於代理跨域圖片資源加載的地址 |
scale | window.devicePixelRatio |
渲染的縮放比例 |
width | Element width |
canvas 的寬度 |
height | Element height |
canvas 的高度 |
x | Element x-offset |
調整canvas畫布原點的x坐標 |
y | Element y-offset |
調整canvas畫布原點的y坐標 |
可以通過給元素增加屬性
data-html2canvas-ignore
從而讓html2canvas在渲染時忽略該元素,它是ignoreElements
選項的快捷使用方式,強烈推薦哦~
這里簡要敘述下它的工作原理,html2canvas將頁面區域的dom克隆出一個備份,然后在這個備份中搜集dom信息,將其解析成特定類型的數據,然后通過這些數據將頁面上的內容繪制到canvas上,並最終返回這個canvas實例,下面貼出流程圖方便理解
我們在使用這個庫時,會存在一些限制,列舉如下
- 當頁面區域中存在圖片時,如果圖片屬於非跨域資源,那么圖片是可以被渲染到canvas上並且canvas中的內容可以正常導出,如果屬於跨域資源,需要分兩種情況:
- 當allowTaint為false時,是不會渲染圖片到canvas上的,同時canvas中的內容是可以被正常導出的
- 當allowTaint為true時,圖片會被渲染到canvas上,但是canvas會被標記為Tainted狀態,從而無法將其中的內容進行導出
對於跨域圖片的處理,官方給出的解決方案是添加proxy
- 由於不是真正的截屏,而是通過dom轉化而來,所以最終的效果不會百分百還原頁面,究其原因,其實是html2canvas不支持部分css樣式,從而導致差異的產生,下面列舉目前最新版本(1.0.0)不支持的樣式
- background-blend-mode
- border-image
- box-decoration-break
- box-shadow
- filter
- font-variant-ligatures
- mix-blend-mode
- object-fit
- repeating-linear-gradient()
- writing-mode
- zoom
需要注意的是border-radius: 50%是沒有效果的,必須要寫成固定的數值才可以,正確寫法:border-radius: 50px
- 輸出的圖片清晰度會比較低,下面貼出解決方案的代碼片段
/* 獲取想要轉換的 DOM 節點 */ const dom = document.querySelector('.target'); const box = window.getComputedStyle(dom); /* DOM 節點計算后寬高 */ const width = parseInt(box.width, 10); const height = parseInt(box.height, 10); /* 獲取像素比 */ const scaleBy = window.devicePixelRatio /* 創建自定義 canvas 元素 */ const canvas = document.createElement('canvas'); /* 設定 canvas 元素屬性寬高為 DOM 節點寬高 * 像素比 */ canvas.width = width * scaleBy; canvas.height = height * scaleBy; /* 設定 canvas css寬高為 DOM 節點寬高 */ canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; /* 獲取畫筆 */ const context = canvas.getContext('2d'); /* 將所有繪制內容放大像素比倍 */ context.scale(scaleBy, scaleBy); /* 將自定義 canvas 作為配置項傳入,開始繪制 */ html2canvas(dom, {canvas, background: '#ffffff'}).then((canvas) => { let url= canvas.toDataURL(); /* 此時的url是圖片的base64格式,可直接賦值到img的src上 */ console.log(url) }) 復制代碼
使用html2canvas遇到的問題
其實絕大多數場景下,html2canvas都可以很好地勝任,但是少數場景下也會出現或多或少的問題,特別是在轉化svg時容易出現問題
當時的使用場景是需要導出頁面中svg里的某一個子元素的內容。最開始使用的版本是1.0.0-rc.7,當我進行導出操作時,會報錯unable to find element in cloned iframe
,當時百思不得其解,在經過一番搜尋之后,終於是在其倉庫的issue中發現了端倪,從那個人的提問中我推測應該是版本的問題,於是我將版本降為1.0.0-rc.6,再進行重新導出,沒報錯啦!開心!但是我只猜中了開頭,卻沒猜中結尾,我看了下輸出的圖片,竟然只有文本內容,其他什么都沒有!
於是我開始了摸索,我發現只要將目標元素的根節點選為<svg>
標簽,也就是全量導出,那么輸出的內容就沒有問題,如果將target設置為svg中某一個子節點時,那么輸出的內容就會出現內容不完整的問題,所以想要實現需求,html2canvas這條路是走不通了,於是在經過一番苦苦尋找之后,我找到了可以替代html2canvas的方案,那就是canvg
這個庫是專門用來將svg繪制到canvas上的。相比於html2canvas,這個庫對於svg的處理是更加專業以及成熟的,我也是使用了這個庫才最終完成了需求,但是在這個過程中也遇到了一些問題,接下來將圍繞這些問題展開敘述
初識canvg
首先引用下官方對於這個庫的描述
JavaScript SVG parser and renderer on Canvas. It takes the URL to the SVG file or the text of the SVG file, parses it in JavaScript and renders the result on Canvas.
其實簡單來說就是一個專門用於解析svg並將其渲染到canvas上的引擎,並提供了簡潔的api供我們使用,下面援引官網給出的示例
import Canvg from 'canvg'; let v = null; window.onload = async () => { const canvas = document.querySelector('canvas'); const ctx = canvas.getContext('2d'); /* 通過from來啟動canvg引擎 */ v = await Canvg.from(ctx, './svgs/1.svg'); /* Start SVG rendering with animations and mouse handling. */ v.start(); }; window.onbeforeunload = () => { v.stop(); }; 復制代碼
import Canvg, { presets } from 'canvg'; self.onmessage = async (event) => { const { width, height, svg } = event.data; const canvas = new OffscreenCanvas(width, height); const ctx = canvas.getContext('2d'); const v = await Canvg.from(ctx, svg, presets.offscreen()); /* Render only first frame, ignoring animations and mouse. */ await v.render(); const blob = await canvas.convertToBlob(); const pngUrl = URL.createObjectURL(blob); self.postMessage({ pngUrl }); }; 復制代碼
上述的示例中,我們有看到OffscreenCanvas
這樣一個陌生的構造函數名,它是用來提供一個可以脫離屏幕渲染的canvas對象,通過它我們就可以不必通過createElement('canvas')
來實際生成一個canvas,從而避免了對document的污染,但是目前這個特性的兼容性不太好,IE和safari完全不支持,所以目前只能持觀望態度
還有一個需要注意的地方是啟動渲染的方法有兩種:render
、start
。這兩個方法的區別是當需要繪制的svg是動態圖時,render
只會繪制第一幀的內容,也就是說繪制出來的圖像是靜態的,而start
是會將svg的內容以及動效全部都繪制出來,也就是說圖片是動態的形式,我們可以根據自己的需求進行選擇
啟動canvg引擎有三種方式,分別是
- new Canvg(...)
- Canvg.from(...)
- Canvg.fromString(...)
from和fromString的區別是from需要傳入的是svg本身,而fromString需要傳入的是svg的字符串形式。這三種方式都可以傳入三個參數,第一個參數是canvas畫布的繪制上下文,第二個是需要繪制的svg,第三個是自定義配置選項,可以用於控制畫布的渲染結果,具體可選配置如下
interface IOptions {
/** * WHATWG-compatible `fetch` function. */ fetch?: typeof fetch; /** * XML/HTML parser from string into DOM Document. */ DOMParser?: typeof DOMParser; /** * Window object. */ window?: Window; /** * Whether enable the redraw. */ enableRedraw?: boolean; /** * Ignore mouse events. */ ignoreMouse?: boolean; /** * Ignore animations. */ ignoreAnimation?: boolean; /** * Does not try to resize canvas. */ ignoreDimensions?: boolean; /** * Does not clear canvas. */ ignoreClear?: boolean; /** * Scales horizontally to width. */ scaleWidth?: number; /** * Scales vertically to height. */ scaleHeight?: number; /** * Draws at a x offset. */ offsetX?: number; /** * Draws at a y offset. */ offsetY?: number; forceRedraw?(): boolean; /*Will call the function on every frame, if it returns true, will redraw.*/ rootEmSize?: number; /*Default `rem` size.*/ emSize?: number; /* Default `em` size.*/ createCanvas?: (width: number, height: number) => HTMLCanvasElement | OffscreenCanvas; /*Function to create new canvas.*/ createImage?: (src: string, anonymousCrossOrigin?: boolean) => Promise<CanvasImageSource>; /* Function to create new image.*/ anonymousCrossOrigin?: boolean; /* Load images anonymously.*/ } 復制代碼
使用canvg遇到的問題
首先說明下我使用的版本是3.0.7,我遇到的問題列舉如下:
- canvg跟html2canvas一樣,對於部分css樣式也是不支持的,我遇到的情況就是由於canvg不支持filter屬性,並且如果元素上存在該屬性,會導致所有圖形的描邊或者填充都失效,所以我在轉換之前先將所有的元素的 filter去掉,使用stroke代替,從而解決了這個問題
- 全量導出svg時不會報錯,但是當想導出svg中某個子元素的內容時,會報錯
Uncaught (in promise) Error: This page contains the following errors:error on line 1 at column 2796: Namespace prefix xlink for href on image is not defined,Below is a rendering of the page up to the first error.
從報錯信息來看大致是缺少命名空間導致的,所以我很自然地將xmlns:xlink="http://www.w3.org/1999/xlink"
和xmlns="http://www.w3.org/2000/svg"
添加到了目標元素的根節點上,這里的根元素是<g>
,當我滿心歡喜地再次進行導出時,卻依然報同樣的錯誤,經過網上的搜尋之后,發現命名空間的屬性只能定義在<svg>
標簽上,所以問題的解決方案就很明朗了,下面貼出解決方案的代碼示例
let canvas = document.createElement('canvas') let ctx = canvas.getContext('2d') canvas.setAttribute('width', '8000px') canvas.setAttribute('height', '1500px') canvas.setAttribute('position', 'fixed') canvas.setAttribute('top', '99999999px') document.body.appendChild(canvas) /* 由於canvg庫不支持filter屬性,如果加上,矩形和圓形的描邊或者填充都失效,所以在轉換之前先將filter去掉,使用stroke代替 */ let rectArr = Array.from(container.getElementsByTagName('rect')) rectArr.forEach(item=>{ item.setAttribute('stroke', '#ccc') item.removeAttribute('filter') }) let rootCircle = container.getElementsByTagName('circle')[0] rootCircle.setAttribute('stroke', '#ccc') rootCircle.removeAttribute('filter') /* 這里手動添加svg標簽,增加命名空間 */ let v = await Canvg.from(ctx, `<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg">${container.innerHTML.trim()}</svg>`, { offsetX: 3000 }) await v.render() 復制代碼
這里說明下svg命名空間的含義與作用:命名空間聲明
xmlns
只需要在根標記上提供一次,聲明定義了默認命名空間,因此用戶代理知道所有<svg>
標簽的后代標簽也屬於同一命名空間,xmlns
是作為標簽的命名空間,而xmlns:xlink
是作為屬性的命名空間,這里定義的就是xlink
的命名空間,xlink
一般是跟href
屬性搭配,形如xlink:href
結語
經過一番折騰,總算是達成目的了,雖然過程比較曲折,但在解決問題的過程中也收獲了很多知識。其實前端截屏這塊有很多學問,據我所知還可以在服務端進行截屏,但是自己還沒有嘗試過,我想那也應該是一個很有趣並且充滿挑戰的方案,以后有機會一定也會去嘗試下的,就寫這么多吧,完結,撒花~