前言
需求背景:
- 目標:將頁面內容變成圖片分享出去
- 持久化:不需要服務端存儲
- 客户端能力:不依賴客户端端能力
使用pupeteer做截圖服務
優點:
- 前端使用簡單,只需調用接口即可
- 分享頁面可以和給用户展示的頁面內容分離,不用額外處理樣式等問題
缺點:
- 新開服務&維護服務
- 接口響應時間較長會影響體驗
結論
調研發現,可以使用html2canvas純前端做頁面轉化成圖片的功能。
正文
前端如何產出一張圖片,首先能想到的就是使用canvas繪製區域,然後轉成一張圖片。但是手動canvas繪製存在以下問題:
- 不能使用ctx.drawImage直接將分享區域進行整體截圖:
ctx.drawImage對 圖片元素Image、svg元素、視頻元素Video、Canvas元素、ImageBitmap數據等可以被繪製,但是對於一般的其他div或者列表li元素它是不可以被繪製的 - 調用
canvas繪製:需要進行佈局計算,繪製起來也很繁瑣,造成開發量大 - 需要解決一些樣式問題/設備兼容問題
介於以上原因,打算使用開源npm來實現dom轉圖片功能。
以下是使用比較多的庫:
dome-to-image(上次更新時間4年前): https://github.com/tsayen/dom... 7.2✨html-to-image(dome-to-image優化版,一直在維護): https://github.com/bubkoo/htm... 650✨html2canvas: https://github.com/niklasvh/h... 23.1K✨
庫優劣分析
html-to-image
/* ES6 */
import * as htmlToImage from 'html-to-image';
import { toPng, toJpeg, toBlob, toPixelData, toSvg } from 'html-to-image';
支持的配置:
filter / backgroundColor / width, height / style / cacheBust / inagePlaceholder / pixelRatio / fontEmbedCss
總結:
- 優點:庫小 183 kB;用起來比較簡單,一般不需要配置
- 缺點:歷史上兼容問題較多;截圖區域內容圖片src不識別base64; 清晰度問題(現在看來還可以)
html2canvas
支持的配置:https://html2canvas.hertzen.c...
總結:
- 優點:23K+ 的start量,功能強大,解決方案多;清晰度上不需要處理
- 缺點:還原度問題;有配置負擔;2.96 MB
關於 foreignObject:
html-to-image因為在實現上用了 foreignObject,所以必須要瀏覽器支持這個屬性。看了下支持程度,除了IE外, chrome safari 支持的都挺好。所以不用因為這個擔心不能使用html-to-image。
html2canvas無法解決的問題【開發時候規避】:
重要的有兩點問題需要關注:
- 圖片問題:支持img標籤圖片,背景圖, 不支持邊框圖
- 字體問題: 不支持用FontFace的定義的字體
html2canvas中的解釋:
總結
- 保守的方案:直接使用html2canvas,因為它功能強大,兼容性好
- 極致追求:可以優先使用html-to-image ,因為它非常小&可以傻瓜式調用而不用擔心配置&還原度高。兜底使用html2canvas。
場景一: 截圖內容不能直接複用頁面內容
在主頁面底部有個分分享按鈕,點擊之後可以分享一張圖片到朋友圈,圖片底部有一個攜帶小程序路徑參數的小程序二維碼,圖片的內容和展示給用户的頁面內容不一致。
方案設計
由於圖片內容是動態的,由數據組成,所以需要我們手動繪製。能想到的簡單的方法就是把海報內容創建到一個div裏,把div 進行隱藏。之後就可以對這個div進行整體操作。
幾種元素不可見方案
display: none ❎
visibility: hidden 【黑屏】
margin-left / nargin-top: -1000px 【黑屏】
position:absolute; left/top: -1000px 【黑屏】
z-index: -1000 ✅
除了元素不可見,為了實現隱藏效果而不影響頁面正常顯示,還需要處理讓分享元素內容不佔文檔流位置。
具體代碼
html:
<div id="app">
<div class="wrapper">
...
<div id="share" class="share-screen">
...
</div>
</div>
</div>
// style
.wrapper{
position:relative;
}
.share-screen{
width: 100%;
z-index: -100; // 用户不可見
position: absolute;
// 定位
top: 0;
left:0;
background-color: #fff;
}
js:
// 獲取分享區域內容圖片
private getShareImg() {
const node = document.getElementById('share');
return html2canvas(node, {
useCORS: true,
x: 0,
y: 0,
logging: false,
}).then(canvas => {
return this.getPosterUrl(canvas);
}).catch();
}
// 轉成一張圖片base64
public getPosterUrl(canvas): string {
return canvas.toDataURL('image/jpeg');
// return canvas.toDataURL('image/jpeg').replace(/data:image\/\w+;base64,/, '');
}
問題參考
圖片跨域問題
useCORS設置為true
圖片清晰度問題
經測試,html2canvas v1.0.01-rc7 之後 不存在清晰度的問題
v1.0.01-rc5之前參考:
/*
* 圖片跨域及截圖模糊處理
* https://www.cnblogs.com/padding1015/p/9225517.html
*/
let shareContent = domObj,//需要截圖的包裹的(原生的)DOM 對象
width = shareContent.clientWidth,//shareContent.offsetWidth; //獲取dom 寬度
height = shareContent.clientHeight,//shareContent.offsetHeight; //獲取dom 高度
canvas = document.createElement("canvas"), //創建一個canvas節點
scale = 2; //定義任意放大倍數 支持小數
canvas.width = width * scale; //定義canvas 寬度 * 縮放
canvas.height = height * scale; //定義canvas高度 *縮放
canvas.style.width = shareContent.clientWidth * scale + "px";
canvas.style.height = shareContent.clientHeight * scale + "px";
canvas.getContext("2d").scale(scale, scale); //獲取context,設置scale
let opts = {
scale: scale, // 添加的scale 參數
canvas: canvas, //自定義 canvas
logging: false, //日誌開關,便於查看html2canvas的內部執行流程
width: width, //dom 原始寬度
height: height,
useCORS: true // 【重要】開啓跨域配置
};
html2canvas(shareContent,opts).then()
截圖的內容區域在可視區外,html2canvas 截圖空白的問題
需要確定你的截圖區域。截圖區域樣式設置:
// html
<div id="app">
<div class="wrapper">
...
<div id="share" class="share-screen">
...
</div>
</div>
</div>
// style
.wrapper{
position:relative;
}
.share-screen{
width: 100%;
z-index: -100; // 用户不可見
position: absolute;
// 定位
top: 0;
left:0;
background-color: #fff;
}
html2canvas 配置:
html2canvas(node, {
useCORS: true,
x: 0, // x-offset:Crop canvas x-coordinate
y: 0, // y-offset:Crop canvas y-coordinate
logging: false,
}).then();
使用html-to-image 報錯
安卓上會報錯:
Error: Failed to execute 'toDataURL' on 'HTMLCanvasElement'......
請換成html2canvas。
html2canvas 圖片大小適配問題
這裏主要是dpr的問題,庫會自動匹配設備window.devicePixelRatio。經過上述配置就不會有問題。
html2canvas 遠程圖片【允許跨域】偶爾不展示問題
html2canvas截圖原理是節點克隆和樣式克隆,所以即使頁面上的圖片加載出來了也不能保證截圖裏的圖片是正常的。遠程圖片加載需要時間,這個時間是不確定的。如果在圖片還沒加載回來就轉成圖片,會有圖片不顯示的問題。
處理的辦法就是:
- 加載本地圖片不會有這個問題
- 遠程圖片不要返回http地址,可以返回base64編碼直接被瀏覽器渲染,這樣就相當於直接返回圖片內容。或者手動請求圖片地址,將二進制流轉成base64。
可能遇到的難點
頁面內容中有特別多的圖片,由於截圖的過程中要截圖圖片內容需要對圖片發起請求,大量的圖片請求不一定能保證截圖的完整性。即使圖片請求沒有問題,html2canvas在截圖處理上也可能會丟失部分圖片。(參考:https://zhuanlan.zhihu.com/p/...)
所以使用遠程圖片地址是有風險的。經過真機測試發現,出錯的概率確實很高(50% 是有的)。
如何解決?
- 轉成base64
- 轉成blob,在用URL.createBlobURL 存在內存中
轉成base64
這個就需要討論base64的優缺點了。
優點:
- 使用base64資源可以減少網絡請求數量(通常是遠程圖片)
圖片內容直出,不會出現圖片加載耗時或失敗導致截圖問題 - toDataURL()的兼容性強,除了IE<=8 safari<=3 之外基本其他瀏覽器都支持(https://caniuse.com/?search=c...)
缺點: - 大小比原始圖片大1/3
- 要注意轉換時候的格式,png → jpeg 透明區域會變黑色背景(解決方案:https://evestorm.github.io/po...)
轉換:
動態創建一個圖片標籤,將圖片繪製到畫布上,使用canvas.toDataURL 導出base64
function toDataURL(url) {
// 跳過base64圖片
const base64Pattern = /data:image\/(.)+;base64/i;
if (base64Pattern.test(url)) {
return Promise.resolve(url);
}
const fileType = getFileType(url) || 'png'; // 默認是png png支持透明
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = document.createElement('img');
img.crossOrigin = 'Anonymous'; // 解決跨域圖片問題,
img.src = url;
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const dataUrl = canvas.toDataURL(`image/${fileType}`);
resolve(dataUrl);
};
img.onerror = e => {
reject(e);
};
});
}
// 獲取圖片文件格式
function getFileType(url) {
const pattern = /.*\.(png|jpeg|gif)$/i; // Data URI scheme支持的image類型
return (url.match(pattern) || [undefined, undefined])[1];
}
轉成blob
優點:
- 支持轉換的格式豐富,除了image之外還可以支持其他類型,無需關心文件類型轉換問題
缺點:
- 兼容性還是有一定問題, IE6~9 safari<=10 都是不支持的(https://caniuse.com/?search=c...)
- 需要調用revokeObjectURL 釋放內存
轉換:
function toBlobURL(url) {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = document.createElement('img');
img.crossOrigin = 'Anonymous'; // 解決跨域圖片問題,
img.src = url;
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// 關鍵
canvas.toBlob(blob => {
const blobURL = URL.createObjectURL(blob);
resolve(blobURL);
// 釋放 blobURL
URL.revokeObjectURL(blobURL)
});;
};
img.onerror = e => {
reject(e);
};
});
}
實現頁面全部圖片src使用base64
const urlmap = {};
// 獲取圖片文件格式
function getFileType(url) {
const pattern = /.*\.(png|jpeg|gif)$/i; // Data URI scheme支持的image類型
return (url.match(pattern) || [undefined, undefined])[1];
}
// remoteURL轉base64
function toDataURL(url) {
// 過濾重複值
if (urlMap[url]) {
return Promise.resolve(urlMap[url]);
}
// 跳過base64圖片
const base64Pattern = /data:image\/(.)+;base64/i;
if (base64Pattern.test(url)) {
return Promise.resolve(url);
}
const fileType = getFileType(url) || 'png'; // 默認是png png支持透明
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = document.createElement('img');
img.crossOrigin = 'Anonymous'; // 解決跨域圖片問題,
img.src = url;
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const dataUrl = canvas.toDataURL(`image/${fileType}`);
resolve(dataUrl);
};
img.onerror = e => {
reject(e);
};
});
}
// 批量轉base64
function convertToBlobImage(targetNode) {
if (!targetNode) { return Promise.resolve(); }
let nodeList = targetNode;
if (targetNode instanceof Element) {
if (targetNode.tagName.toLowerCase() === 'img') {
nodeList = [targetNode];
} else {
nodeList = targetNode.getElementsByTagName('img');
}
} else if (!(nodeList instanceof Array) && !(nodeList instanceof NodeList)) {
throw new Error('[convertToBlobImage] 必須是Element或NodeList類型');
}
if (nodeList.length === 0) {
return Promise.resolve();
}
// 僅考慮<img>
return new Promise(resolve => {
let count = 0;
// 逐一替換<img>資源地址
for (let i = 0, len = nodeList.length; i < len; ++i) {
const v = nodeList[i];
let p = Promise.resolve();
if (v.tagName.toLowerCase() === 'img') {
p = toDataURL(v.src).then(blob => {
v.src = blob;
});
}
p.finally(() => {
if (++count === nodeList.length) {
return resolve(true);
}
});
}
});
}
注意事項:
- 遠程圖片是png的時候要轉成png格式(
toDataURL(image/png)),因為png圖片是支持透明的,在轉成jpeg文件的時候會關閉alpha通道,導致透明區域變成黑色背景。推薦自適應文件類型,默認設置成png - Data URI scheme支持的image類型: png | jpeg | gif
- 在canvas 上繪製圖片的時候,可能會報錯:
"Failed to execute 'toBlob' on 'HTMLCanvasElement': Tainted canvases may not be exported",原因是CORS圖片會污染畫布,導致無法讀取其數據。加上後面這段代碼就可以解決跨域圖片問題:img.crossOrigin = 'Anonymous'
擴展知識
<img /> src 下支持三種: url/base64/blob。
1. 聊一聊幾種常用web圖片格式:gif、jpg、png、webp
2. 處理arrayBuffer 文件
// buffer -> base64
function getData(){
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return 'data:image/png;base64,' + btoa(binary); // window.btoa() 方法用於創建一個 base-64 編碼的字符串。
}
3. base64轉blob:
// 原理:利用URL.createObjectURL為blob對象創建臨時的URL
function base64ToBlob ({b64data = '', contentType = '', sliceSize = 512} = {}) {
return new Promise((resolve, reject) => {
// 使用 atob() 方法將數據解碼
let byteCharacters = atob(b64data);
let byteArrays = [];
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
let slice = byteCharacters.slice(offset, offset + sliceSize);
let byteNumbers = [];
for (let i = 0; i < slice.length; i++) {
byteNumbers.push(slice.charCodeAt(i));
}
// 8 位無符號整數值的類型化數組。內容將初始化為 0。
// 如果無法分配請求數目的字節,則將引發異常。
byteArrays.push(newUint8Array(byteNumbers));
}
let result = new Blob(byteArrays, {
type: contentType
})
result = Object.assign(result,{
// jartto: 這裏一定要處理一下 URL.createObjectURL
preview: URL.createObjectURL(result),
name: `圖片示例.png`
});
resolve(result)
})
}
4. blob轉base64:
兼容性:IE>9 safari>5 https://caniuse.com/?search=r...
// 原理:利用fileReader的readAsDataURL,將blob轉為base64
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => {
resolve(e.target.result);
};
// readAsDataURL
fileReader.readAsDataURL(blob);
fileReader.onerror = () => {
reject(newError('文件流異常'));
};
});
}
場景二:截圖內容是頁面內容上的擴展
截圖的主要內容就是頁面展示的內容,底部拼接導流banner。
如何實現動態拼接
可以使用html2canvas的onclone屬性,實現修改複製出來的節點內容。在onclone裏面修改元素,不會影響頁面原始元素。
將動態顯示內容脱離文檔流並且置為不可見:
<!-- html -->
<div class="wrapper">
<!-- 主要內容 -->
<div class="content" id="share"></div>
<!-- 動態添加的內容 -->
<div class="dynamic-contetn" id="share-footer"></div>
</div>
// css
.wrapper{
position:relative;
}
.dynamic-content{
position: absolute;
top:0;
left:0;
right: 0;
z-index:-100;
}
截圖的時候,在onclone裏面修改讓動態內容出現在正常位置上並且置為可見:
const node = document.getElementById('share'); // 分享海報的主要內容區域
const
shareFooter = document.getElementById('share-footer'); // 海報底部內容
html2canvas(node,{
onclone: (doc: Document) => {
// 將海報底部內容區域顯示出來,用於和內容一起截屏
shareFooterNode.style.position = 'relative';
shareFooterNode.style.display = 'flex';
shareFooterNode.style.zIndex = '1';
}
}).then();
控制截圖大小:
因為截圖的高度默認是Node元素的offsetHeight,現在這個高度要變成 Node高度 + 動態區域高度:
const node = document.getElementById('share'); // 分享海報的主要內容區域
const shareFooter = document.getElementById('share-footer'); // 海報底部內容
const { height: footerHeight} = shareFooter.getBoundingClientRect();
html2canvas(node, {
useCORS: true,
logging: false,
height: node.offsetHeight + footerHeight,
}).then();
截取可視區域內容的另一種實踐
在body的子元素加一層殼子,讓網頁內容在殼子裏滾動。
html:
<html>
<body>
<div class="wrapper">
<div id="share" class="content"></div>
</div>
</body>
</html>
// css
.wrapper{
position:fixed;
left:0;
right:0;
top:0;
bottom:0;
}
javascript:
const wrapper = document.getElementsByClassName('wrapper')[0]; // wrapper 用於監聽scroll
const node = document.getElementById('share'); // 分享海報的主要內容區域
const { scrollTop } = wrapper;
const { height: footerHeight} = shareFooter.getBoundingClientRect();
return this.convertToBlobImage(node).then(res => {
return html2canvas(node, {
useCORS: true,
logging: false,
x: 0,
y: -scrollTop, // 重要
height: node.offsetHeight, // 重要
}).then();
這樣也能正常截圖。
所以x/y的設置和你的佈局有關係,可以幾種都試試,使用截圖正常的那種方案。
兩種場景下總結
截圖內容與頁面內容 完全不一樣:
- 準備一份截圖區域節點內容並隱藏
- 使用
x:0 y:0來解決截圖區域問題
截圖內容與頁面大內容部分一致:
- 複用頁面節點內容
- 使用body下的殼子和
y:-scrollTop解決截圖區域問題 - 如果有動態內容,使用
height: node.offsetHeight + dynamicHeight
圖片要轉換成base64內容,保障截圖的完整性
在特殊場景下出現的兩大問題
一 canvas 尺寸過大導致canvas.toDataURL()返回data:/的問題
解決辦法:
檢測是否合法,不合法則降低html2canvas scale(會影響清晰度)。 降到1還不合法那就沒辦法了。
每個瀏覽器限制不通,沒有直接用於檢測的API,推薦一個檢測庫canvas-size。從設備當前的dpr開始測試,向下兼容:
private async getAvailableScale(node) {
let scale = 1;
const {offsetWidth, offsetHeight} = node;
const nodeWidth = Math.ceil(offsetWidth);
const nodeHeight = Math.ceil(offsetHeight);
const dpr = window.devicePixelRatio;
const sizes = [];
for (let i = dpr; i >= 1; i--) {
sizes.push([nodeWidth * dpr, nodeHeight * dpr]);
}
try {
const { width, height, benchmark } = await canvasSize.test({
sizes,
usePromise: true,
useWorker : true,
});
console.log(`Success: ${width} x ${height} (${benchmark} ms)`);
scale = width / nodeWidth;
} catch ({ width, height, benchmark }) {
console.log(`Error: ${width} x ${height} (${benchmark} ms)`);
}
return scale;
}
二 微信SDK分享朋友圈大小512K限制問題
解決方案:
使用 canvas.toDataURL(type,quality)第二個參數,壓縮圖片,會減小圖片大小(但同時圖片質量也會下降)。quality: (0,1),默認是0.92
public getPosterUrl(canvas): string {
const img = canvas.toDataURL('image/jpeg');
const size = Math.ceil(getImgSize(img) / 1024); // 獲取base64圖片大小
console.log('海報圖片大小(kb):', size);
console.log('海報圖片質量:', 400 / size); // 超出取值範圍0-1,將會使用默認值 0.92
// HuaWei mate20 / android10 長圖下不壓縮會分享失敗
// 400kb是基於512kb經過測試算出來的一個比較好的參考值
return canvas.toDataURL('image/jpeg', 400 / size).replace(/data:image\/\w+;base64,/, '');
}
dom-to-image 原理
可能某天會存在(或許已經存在?)將HTML的一部分導出到圖像的簡單、標準的方式(然後,這段腳本就成了我為了實現這樣的目標而經歷的所有問題的證據),但到目前為止我還沒有找到。
該庫使用SVG的功能,該功能允許在<foreignObject>標記內包含任意HTML內容。因此,為了呈現該DOM節點,會執行以下步驟:
- 遞歸克隆原始DOM節點
-
計算節點和每個子節點的樣式,然後將其複製到相應的克隆中
- 並且不要忘記重新創建偽元素,因為它們當然不會以任何方式克隆
-
嵌入網絡字體
- 查找所有可能表示網絡字體的
@font-face聲明 - 解析文件URL,下載相應的文件
base64編碼並將內聯內容作為dataURLs- 連接所有已處理的CSS規則,並將它們放入一個<style>元素中,然後將其附加到克隆中
- 查找所有可能表示網絡字體的
-
嵌入圖片
- 將圖像URL嵌入到<img>元素中
- 背景CSS屬性中使用的嵌入式圖像,其方式類似於字體
- 將克隆的節點序列化為XML
- 將XML包裝到
<foreignObject>標記中,然後包裝到SVG中,然後使其成為數據URL - (可選)要以Uint8Array的形式獲取PNG內容或原始像素數據,請創建一個以
SVG為源的Image元素,並將其呈現在您還創建的屏幕外畫布上,然後從畫布中讀取內容 - 完畢
參考資料
- 原理實現: https://zhuanlan.zhihu.com/p/...
-
海報方案:前端海報生成的不同方案和優劣圖片
- dom-to-image踩坑指南: https://segmentfault.com/a/11...
- html2canvas 踩坑1: https://www.cnblogs.com/paddi...
- html2canvas 踩坑2: https://somelou.xyz/p/3871219...
- 前端快照方案: https://zhuanlan.zhihu.com/p/...
- canvas尺寸過大問題:https://stackoverflow.com/que...
- 微信分享圖片尺寸限制 https://developers.weixin.qq....
- https://developer.mozilla.org...
後記
後續有新的場景和問題積累會持續補充。