本文做者:ggvswildcss
將網頁保存爲圖片(如下簡稱爲快照),是用戶記錄和分享頁面信息的有效手段,在各類興趣測試和營銷推廣等形式的活動頁面中尤其常見。html
快照環節一般處於頁面交互流程的末端,彙總了用戶最終的參與結果,直接影響到用戶對於活動的完總體驗。所以,生成高質量的頁面快照,對於活動的傳播和品牌的轉化具備十分重要的意義。前端
本文基於雲音樂往期優質活動的相關實踐(例如「關於你的畫」、「權力的遊戲」和「你的使用說明書」等),從快照的內容完整性、清晰度和轉換效率等多個方面,討論將網頁轉換爲高質量圖片的實踐探索。node
依據圖片是否由設備本地生成,快照可分爲前端處理和後端處理兩種方式。webpack
因爲後端生成的方案依賴於網絡通訊,不可避免地存在通訊開銷和等待時延,同時對於模板和數據結構變動也有必定的維護成本。git
所以,出於實時性和靈活性等綜合考慮,咱們優先選用前端處理的方式。github
前端側對於快照的處理過程,實質上是將 DOM 節點包含的視圖信息轉換爲圖片信息的過程。這個過程能夠藉助 canvas 的原生 API 實現,這也是方案可行性的基礎。web
具體來講,轉換過程是將目標 DOM 節點繪製到 canvas 畫布,而後 canvas 畫布以圖片形式導出。可簡單標記爲繪製階段和導出階段兩個步驟:canvas
nodeType
調用 canvas 對象的對應 API,將目標 DOM 節點繪製到 canvas 畫布(例如對於<img>
的繪製使用 drawImage 方法)。具體地,對於單個<img>
元素可按以下方式生成自身的快照:segmentfault
HTML:
<img id="target" src="./music-icon.png" />
複製代碼
JavaScript:
// 獲取目標元素
const target = document.getElementById('target');
// 新建canvas畫布
const canvas = document.createElement('canvas');
canvas.width = 100;
canvas.height = 100;
const ctx = canvas.getContext("2d");
// 導出階段:從canvas導出新的圖片
const exportNewImage = (canvas) => {
const exportImage = document.createElement('img');
exportImage.src = canvas.toDataURL();
document.body.appendChild(exportImage);
}
// 繪製階段:待圖片內容加載完畢後繪製畫布
target.onload = () => {
// 將圖片內容繪入畫布
ctx.drawImage(target, 0, 0, 100, 100);
// 將畫布內容導出爲新的圖片
exportNewImage(canvas);
}
複製代碼
其中,drawImage
是 canvas 上下文對象的實例方法,提供多種方式將 CanvasImageSource 源繪製到 canvas 畫布上。exportNewImage
用於將 canvas 中的視圖信息導出爲包含圖片展現的 data URI。
在上一部分中,咱們能夠看到基於 canvas 提供的相關基礎 API,爲前端側的頁面快照處理提供了可能。
然而,具體的業務應用每每更加複雜,上面的「低配版」實例顯然未能覆蓋多數的實際場景,例如:
drawImage
方法只接受 CanvasImageSource,而CanvasImageSource
並不包括文本節點、普通的div
等,將非<img>
的元素繪製到 canvas 須要特定處理。float
、z-index
、position
等佈局定位的處理。所以,基於對綜合業務場景的考慮,咱們採用社區中承認度較高的方案:html2canvas
和canvas2image
做爲實現快照功能的基礎庫。
提供將 DOM 繪製到 canvas 的能力
這款來自社區的神器,爲開發者簡化了將逐個 DOM 繪製到 canvas 的過程。簡單來講,其基本原理爲:
在使用方面,html2canvas
對外暴露了一個可執行函數,它的第一個參數用於接收待繪製的目標節點(必選);第二個參數是可選的配置項,用於設置涉及 canvas 導出的各個參數:
// element 爲目標繪製節點,options爲可選參數
html2canvas(element[,options]);
複製代碼
簡易調用示例以下:
import html2canvas from 'html2canvas';
const options = {};
// 輸入body節點,返回包含body視圖內容的canvas對象
html2canvas(document.body, options).then(function(canvas) {
document.body.appendChild(canvas);
});
複製代碼
提供由 canvas 導出圖片信息的多種方法
相比於html2canvas
承擔的複雜繪製流程,canvas2image 所要作的事情簡單的多。
canvas2image
僅用於將輸入的 canvas 對象按特定格式轉換和存儲操做,其中這兩類操做均支持 PNG,JPEG,GIF,BMP 四種圖片類型:
// 格式轉換
Canvas2Image.convertToPNG(canvasObj, width, height);
Canvas2Image.convertToJPEG(canvasObj, width, height);
Canvas2Image.convertToGIF(canvasObj, width, height);
Canvas2Image.convertToBMP(canvasObj, width, height);
// 另存爲指定格式圖片
Canvas2Image.saveAsPNG(canvasObj, width, height);
Canvas2Image.saveAsJPEG(canvasObj, width, height);
Canvas2Image.saveAsGIF(canvasObj, width, height);
Canvas2Image.saveAsBMP(canvasObj, width, height);
複製代碼
實質上,canvas2image
只是提供了針對 canvas 基礎 API 的二次封裝(例如 getImageData、toDataURL),而自己並不依賴html2canvas
。
在使用方面,因爲目前做者並未提供 ES6 版本的canvas2image
(v1.0.5),暫時不能直接以 import 方式引入該模塊。
對於支持現代化構建的工程中(例如 webpack),開發者能夠自助 clone 源碼並手動添加 export 得到 ESM 支持:
支持 ESM 導出:
// canvas2Image.js
const Canvas2Image = function () {
...
}();
// 如下爲定製添加的內容
export default Canvas2Image;
複製代碼
調用示例:
import Canvas2Image from './canvas2Image.js';
// 其中,canvas表明傳入的canvas對象,width, height分別爲導出圖片的寬高數值
Canvas2Image.convertToPNG(canvas, width, height)
複製代碼
接下來,咱們基於以上兩個工具庫,實現一個基礎版的快照生成方案。一樣是分爲兩個階段,對應 3.2 節的基本原理:
html2canvas
實現 DOM 節點繪製到 canvas 對象中;canvas2image
,進而按需導出快照圖片信息。具體地,咱們封裝一個convertToImage
的函數,用於輸入目標節點以及配置項參數,輸出快照圖片信息。
JavaScript
:
// convertToImage.js
import html2canvas from 'html2canvas';
import Canvas2Image from './canvas2Image.js';
/** * 基礎版快照方案 * @param {HTMLElement} container * @param {object} options html2canvas相關配置 */
function convertToImage(container, options = {}) {
return html2canvas(container, options).then(canvas => {
const imageEl = Canvas2Image.convertToPNG(canvas, canvas.width, canvas.height);
return imageEl;
});
}
複製代碼
經過上一節的實例,咱們基於html2canvas
和canvas2image
,實現了相比原生方案通用性更佳的基礎頁面快照方案。然而面對實際複雜的應用場景,以上基礎方案生成的快照效果每每不盡如人意。
快照效果的差別性,一方面是因爲html2canvas
導出的視圖信息是經過各類 DOM 和 canvas 的 API 複合計算二次繪製的結果(並不是一鍵柵格化)。所以不一樣宿主環境的相關 API 實現差別,可能致使生成的圖片效果存在多端不一致性或者顯示異常的狀況。
另外一方面,業務層面的因素,例如對於開發者html2canvas
的配置有誤或者是頁面佈局不當等緣由,也會對生成快照的結果帶來誤差。
社區中也能夠常見到一些對於生成快照質量的討論,例如:
下面咱們從內容完整性、清晰度優化和轉換效率,進一步探究高質量的快照解決方案。
首要問題:保證目標節點視圖信息完整導出
因爲真機環境的兼容性和業務實現方式的不一樣,在一些使用html2canvas
過程當中常會出現快照內容與原視圖不一致的狀況。內容不完整的常見自檢checklist
以下:
常見於引入的圖片素材相對於部署工程跨域的場景。例如部署在https://st.music.163.com/
上面的頁面中引入了來源爲https://p1.music.126.net
的圖片,這類圖片便是屬於跨域的圖片資源。
因爲 canvas 對於圖片資源的同源限制,若是畫布中包含跨域的圖片資源則會污染畫布( Tainted canvases ),形成生成圖片內容混亂或者html2canvas
方法不執行等異常問題。
對於跨域圖片資源處理,能夠從如下幾方面着手:
(1)useCORS 配置
開啓html2canvas
的useCORS
配置項,示例以下:
// doc: http://html2canvas.hertzen.com/configuration/
const opts = {
useCORS : true, // 容許使用跨域圖片
allowTaint: false // 不容許跨域圖片污染畫布
};
html2canvas(element, opts);
複製代碼
在html2canvas
的源碼中對於useCORS
配置項置爲true
的處理,實質上是將目標節點中的<img>
標籤注入 crossOrigin 爲anonymous
的屬性,從而容許載入符合 CORS 規範的圖片資源。
其中,allowTaint
默認爲false
,也能夠不做顯式設置。即便該項置爲true
,也不能繞過 canvas 對於跨域圖片的限制,由於在調用 canvas 的toDataURL
時依然會被瀏覽器禁止。
(2)CORS 配置
上一步的useCORS
的配置,只是容許<img>
接收跨域的圖片資源,而對於解鎖跨域圖片在 canvas 上的繪製並導出,須要圖片資源自己須要提供 CORS 支持。
這裏介紹下跨域圖片使用 CDN 資源時的注意事項:
驗證圖片資源是否支持 CORS 跨域,經過 Chrome 開發者工具能夠看到圖片請求響應頭中應含有Access-Control-Allow-Origin
的字段,即坊間常提到的跨域頭。
例如,某個來自 CDN 圖片資源的響應頭示例:
// Response Headers
access-control-allow-credentials: true
access-control-allow-headers: DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type
access-control-allow-methods: GET,POST,OPTIONS
access-control-allow-origin: *
複製代碼
不一樣的 CDN 服務商配置資源跨域頭的方式不一樣,具體應諮詢 CDN 服務商。
特殊狀況下,部分 CDN 提供方可能會存在圖片緩存不含 CORS 跨域頭的狀況。爲保證快照顯示正常,建議優先聯繫 CDN 尋求技術支持,不推薦經過圖片連接後綴時間戳等方式強制回源,避免影響源站性能和 CDN 計費。
(3)服務端轉發
在微信等第三方 APP 中,平臺的用戶頭像等圖片資源是不直接提供 CORS 支持的。此時須要藉助服務端做代理轉發,從而繞過跨域限制。
即經過服務端代爲請求平臺用戶的頭像地址並轉發給客戶端(瀏覽器),固然這個服務端接口自己要與頁面同源或者支持 CORS。
爲簡潔表述,假設前端與後端針對跨域圖片轉發做以下約定,且該接口與前端工程部署在相同域名下:
請求地址 | 請求方式 | 傳入參數 | 返回信息 |
---|---|---|---|
/api/redirect/image | GET |
redirect,表示原圖地址 | Content-Type 爲image/png 的圖片資源 |
頁面中的<img>
經過拼接/api/redirect/image
與表明原圖地址的查詢參數redirect
,發出一個 GET 請求圖片資源。因爲接口與頁面同源,所以不會觸發跨域限制:
<img src="/api/redirect/image?redirect=thirdwx.qlogo.cn/somebody/avatar" alt="user-pic" class="avatar" crossorigin="anonymous">
複製代碼
對於服務端接口的實現,這裏基於 koa 提供了一則簡易示例:
const Koa = require('koa');
const router = require('koa-router')();
const querystring = require('querystring');
const app = new Koa();
/** * 圖片轉發接口 * - 接收 redirect 入參,即須要代爲請求的圖片URL * - 返回圖片資源 */
router.get('/api/redirect/image', async function(ctx) {
const querys = ctx.querystring;
if (!querys) return;
const { redirect } = querystring.parse(querys);
const res = await proxyFetchImage(redirect);
ctx.set('Content-Type', 'image/png');
ctx.set('Cache-Control', 'max-age=2592000');
ctx.response.body = res;
})
/** * 請求並返回圖片資源 * @param {String} url 圖片地址 */
async function proxyFetchImage(url) {
const res = await fetch(url);
return res.body;
}
const res = await proxyFetchImage(redirect);
app.use(router.routes());
複製代碼
在瀏覽器看來,頁面請求的圖片資源還是相同域名下的資源,轉發過程對前端透明。建議在需求開發前瞭解圖片資源的來源狀況,明確是否須要服務端支持。
在雲音樂早期的活動「權力的遊戲」中,使用了同類方案,實現了微信平臺中用戶頭像的完整繪製和快照導出。
資源加載不全,是形成快照不完整的一個常見因素。在生成快照時,若是部分資源沒有加載完畢,那麼生成的內容天然也談不上完整。
除了設置必定的延遲外,若是要確保資源加載完畢,能夠基於 Promise.all 實現。
加載圖片:
const preloadImg = (src) => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
resolve();
}
img.src = src;
});
}
複製代碼
確保在所有加載後生成快照:
const preloadList = [
'./pic-1.png',
'./pic-2.png',
'./pic-3.png',
];
Promise.all(preloadList.map(src => preloadImg(src))).then(async () => {
convertToImage(container).then(canvas => {
// ...
})
});
複製代碼
實際上,以上方法只是解決頁面圖片的顯示問題。在真實場景中,即便頁面上的圖片顯示完整,保存快照後依然可能出現內容空白的狀況。 緣由是 html2canvas 庫內部處理時,對圖片資源仍會作一次加載請求;若是此時加載失敗,那麼該部分保存快照後便是空白的。
下面介紹圖片資源轉 Blob 的方案,保證圖片的地址來自本地,避免在快照轉化時加載失敗的狀況。 這裏提到的 Blob 對象表示一個不可變、表明二進制原始數據的類文件對象,在特定的使用場景會使用到。
圖片資源轉 Blob:
// 返回圖片Blob地址
const toBlobURL = (function () {
const urlMap = {};
// @param {string} url 傳入圖片資源地址
return function (url) {
// 過濾重複值
if (urlMap[url]) return Promise.resolve(urlMap[url]);
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = document.createElement('img');
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);
});
};
img.onerror = (e) => {
reject(e);
};
});
};
}());
複製代碼
以上toBlobURL
方法實現將加載<img>
的資源連接轉爲 blobURL。
進一步地,經過convertToBlobImage
方法,實現對於傳入的目標節點中的<img>
批量處理爲Blob
格式。
// 批量處理
function convertToBlobImage(targetNode, timeout) {
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 resolved = false;
// 超時處理
if (timeout) {
setTimeout(() => {
if (!resolved) resolve();
resolved = true;
}, timeout);
}
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 = toBlobURL(v.src).then((blob) => {
v.src = blob;
});
}
p.finally(() => {
if (++count === nodeList.length && !resolved) resolve();
});
}
});
}
export default convertToBlobImage;
複製代碼
使用方面,convertToBlobImage
應在調用生成快照convertToImage
方法前執行。
convertToImage
以前,先記錄此時的scrollTop
,而後調用window.scroll(0, 0)
將頁面移動至頂部。待快照生成後,再調用window.scroll(0, scrollTop)
恢復原有縱向偏移量。示例:
// 待保存的目標節點(按實際修改👇)
const container = document.body;
// 實際的滾動元素(按實際修改👇)
const scrollElement = document.documentElement;
// 記錄滾動元素縱向偏移量
const scrollTop = scrollElement.scrollTop;
// 針對滾動元素是 body 先做置頂
window.scroll(0, 0);
convertToImage(container)
.then(() => {
// ...
}).catch(() => {
// ...
}).finally(() => {
// 恢復偏移量
window.scroll(0, scrollTop);
});
複製代碼
特別地,對於存在局部滾動佈局的狀況,也能夠操做對應滾動元素置頂避免容器頂部空白的狀況。
清晰度是快照質量的分水嶺
下圖取自「權力的遊戲」中兩張優化先後的結果頁快照對比。能夠看到優化前的左圖,不管是在文字邊緣仍是圖像細節上,相較優化後的清晰度存在明顯可辨的差距。
最終生成快照的清晰度,源頭上取決於第一步中 DOM 轉換成的 canvas 的清晰度。
如下介紹 5 種行之有效的清晰度優化方法。
爲了給到html2canvas
明確的整數計算值,避免因小數舍入而致使的拉伸模糊,建議將佈局中使用中使用%
、vw
、vh
或rem
等單位的元素樣式,統一改成使用px
。
good:
<div style="width: 100px;"></div>
複製代碼
bad:
<div style="width: 30%;"></div>
複製代碼
不少狀況下,導出圖片模糊是由原視圖中的圖片是以 css 中 background 的方式顯示的。 由於 background-size 並不會反饋一個具體的寬高數值,而是經過枚舉值如 contain、cover 等表明圖片縮放的類型;相對於<img>
標籤, background 方式最終生成的圖片會較爲模糊。 將 background 改成<img>
方式呈現,對於圖片清晰度會有必定的改觀。對於必需要使用 background 的場景,參見 5.25 節的解決方案。
good:
<img class="u-image" src="./music.png" alt="icon">
複製代碼
bad:
<div class="u-image" style="background: url(./music.png);"></div>
複製代碼
對於高分辨率的屏幕,canvas 可經過將 css 像素與高分屏的物理像素對齊,實現必定程度的清晰度提高(這裏對兩類像素有詳細描述和討論)。
在具體操做中,建立由 devicePixelRatio 放大的圖像,而後使用 css 將其縮小相同的倍數,有效地提升繪製到 canvas 中的圖像清晰度表現。
在使用html2canvas
時,咱們能夠配置一個放縮後的 canvas 畫布用於導入節點的繪製。
// convertToImage.js
import html2canvas from 'html2canvas';
// 建立用於繪製的基礎canvas畫布
function createBaseCanvas(scale) {
const canvas = document.createElement("canvas");
canvas.width = width * scale;
canvas.height = height * scale;
canvas.getContext("2d").scale(scale, scale);
return canvas;
}
// 生成快照
function convertToImage(container, options = {}) {
// 設置放大倍數
const scale = window.devicePixelRatio;
// 建立用於繪製的基礎canvas畫布
const canvas = createBaseCanvas(scale);
// 傳入節點原始寬高
const width = container.offsetWidth;
const height = container.offsetHeight;
// html2canvas配置項
const ops = {
scale,
width,
height,
canvas,
useCORS: true,
allowTaint: false,
...options
};
return html2canvas(container, ops).then(canvas => {
const imageEl = Canvas2Image.convertToPNG(canvas, canvas.width, canvas.height);
return imageEl;
});
}
複製代碼
imageSmoothingEnabled 是 Canvas 2D API
用來設置圖片是否平滑的屬性,true
表示圖片平滑(默認值),false
表示關閉 canvas 抗鋸齒。
默認狀況下,canvas 的抗鋸齒是開啓的,能夠經過關閉抗鋸齒來實現必定程度上的圖像銳化,提升線條邊緣的清晰度。
據此,咱們將以上createBaseCanvas
方法升級爲:
// 建立用於繪製的基礎canvas畫布
function createBaseCanvas(scale) {
const canvas = document.createElement("canvas");
canvas.width = width * scale;
canvas.height = height * scale;
const context = canvas.getContext("2d");
// 關閉抗鋸齒
context.mozImageSmoothingEnabled = false;
context.webkitImageSmoothingEnabled = false;
context.msImageSmoothingEnabled = false;
context.imageSmoothingEnabled = false;
context.scale(scale, scale);
return canvas;
}
複製代碼
受到 canvas 畫布放縮的啓發,咱們對特定的 DOM 元素也能夠採用相似的優化操做,即設置待優化元素寬高設置爲 2 倍或devicePixelRatio
倍,而後經過 css 縮放的方式控制其展現大小不變。
例如,對於必須用背景圖background
的元素,採用如下方式可明顯提升快照的清晰度:
.box {
background: url(/path/to/image) no-repeat;
width: 100px;
height: 100px;
transform: scale(0.5);
transform-origin: 0 0;
}
複製代碼
其中,width
和height
爲實際顯示寬高的 2 倍值,經過transform: scale(0.5)
實現了元素大小的縮放,transform-origin
根據實際狀況設置。
快照的轉換效率直接關係到用戶的等待時長。咱們能夠在目標節點傳入階段和快照導出兩個階段對其進行必定優化。
傳入節點的視圖信息越精簡,生成快照處理的計算量就越小
如下方式適用於傳入視圖信息「瘦身」:
html2canvas
遞歸遍歷的計算量。scale
值以縮放 canvas 畫布(5.2.3節)。一般狀況下 2~3 倍就已經知足通常的場景,沒必要要傳入過大的放大倍數。canvas2image
提供了多個 API 用於導出圖片信息,上文已有介紹。包括:
不一樣的導出格式,對於生成快照的文件體積存在較大的影響。 一般對於沒有透明度展現要求的圖片素材,可使用jpeg
格式的導出。在咱們的相關實踐中,jpeg
相比於png
甚至可以節約 80% 以上的文件體積。
實際場景中的的圖片導出格式,按業務需求選用便可。
本文基於html2canvas
和canvas2image
,從快照的內容完整性、清晰度和轉換效率等多個方面,介紹了前端頁面生成高質量快照的解決方案。
因爲實際應用的複雜性,以上方案可能沒法覆蓋到每一處具體場景,歡迎你們交流和探討。
本文發佈自 網易雲音樂前端團隊,可自由轉載,轉載請在標題標明轉載並在顯著位置保留出處。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們!