一次 H5 「儲存頁面為圖片」 的踩坑之旅

舞動乾坤發表於2017-12-04

1. 需求

最近丁香醫生的產品大佬又雙叒叕搞事情,想要在 H5 中做一個醫生邀請提問的功能,可以將醫生的二維碼名片分享出去,支援移動、PC 和微信。之前的圖片是由後端生成的,並且會快取三天,導致資訊更新不及時。由前端來做就能避免這些問題。

我一聽,這好說,不就是個儲存圖片的功能麼,簡單看看需求:

  • 完善卡片資訊,分享出去時候資訊更加立體
  • 編輯個人資料入口
  • 儲存圖片入口
  • 可解決醫生名片快取時間問題
  • 長下面這樣 ⬇

image
image

分析下來就兩點

  • html展示實時使用者資訊
  • 點選儲存將當前頁面儲存成圖片至本地,並且不包含功能按鈕

2. 方案

因為之前已經聽說過有個庫能將 HTML 轉為 canvas,然後又聽說 canvas 能轉為圖片,然後又聽說圖片能下載....(開發基本靠聽說(搜尋),這是廢話)

那我的基本方案就是:
html -> canvas -> image -> a[download]

  1. html2canvas.js:可將 htmldom 轉為 canvas 元素。傳送門
  2. canvasAPI:toDataUrl() 可將 canvas 轉為 base64 格式
  3. 建立 a[download] 標籤觸發 click 事件實現下載

3. 採坑表演

既然方案定下來了,下面就開始踩坑表演了,?

3.1. 原理

官方是這樣介紹的:

js將遍歷載入頁面的 DOM 節點,收集所有元素的資訊,然後用這些資訊來呈現頁面。換句話說,實際上這個庫並不是真的對頁面進行截圖,而是基於從 DOM 讀取的元素及屬性來一點點的繪製 canvas。 因此,它只能正確地呈現它理解的元素和屬性,這意味著有許多 CSS 屬性不起作用。

// v0.4.1
html2canvas(element, {
    onrendered: function(canvas) {
        // 現在你已經拿到了canvas DOM元素    
    }
});

// v0.5.0
html2canvas(element, options).then(canvas => {
    // 現在你已經拿到了canvas DOM元素    
});複製程式碼

所以基本可以猜到整個工作流程應該是:

  1. 遞迴處理每個節點,記錄這個節點應該怎麼畫。(比如div就畫邊框和背景,文字就畫文字等等)
  2. 考慮節點的層級問題。比如很多佈局相關樣式屬性: z-index、float、position 等的影響。
  3. 從低層級開始畫到 canvas 上,逐漸向上畫。層級高的覆蓋層級低的(和瀏覽器本身的渲染流程很像)。

3.2 坑?

目前官方提供的版本有很多,正式版本是v0.4.1 - 7.9.2013,最新版本是v0.5.0-beta4,那對於我們開發來說如果不是玩新特性什麼的一般還是會選擇正式版,結果第一個坑就掉進去爬了半天。

3.2.1 圖片模糊

因為開發的時候是用 chrome 模擬器生成 canvas 後沒有發現有模糊的地方,但是用 PC 代理手機請求開發資源時,發現畫面的模糊感非常明顯。

如圖:

image
image

容易想到,可能是移動端畫素密度計算的問題。

裝置畫素比 (簡稱 dpr) 定義了物理畫素和裝置獨立畫素的對應關係,它的值可以按如下的公式的得到:

裝置畫素比 = 物理畫素 / 裝置獨立畫素 // 在某一方向上,x方向或者y方向

知道了這個也沒用,因為文件中根本沒有給出能夠配置畫素比的地方。。

然而通過研究發現,官方文件其實還是 0.4.1 的,從 0.5.0 版本開始,其實已經偷偷摸摸支援自定義 canvas 作為配置項傳入了,它會根據我們傳入的 canvas 為基礎開始繪製。所以我們在呼叫 html2canvas 的時候,可以先建立好一個尺寸合適的 canvas,再傳進去。

話不多說,首先將庫升級到 0.5.0,然後:

/**
 * 根據window.devicePixelRatio獲取畫素比
 */
function DPR() {
    if (window.devicePixelRatio && window.devicePixelRatio > 1) {
        return window.devicePixelRatio;
    }
    return 1;
}
/**
 *  將傳入值轉為整數
 */
function parseValue(value) {
    return parseInt(value, 10);
};
/**
 * 繪製canvas
 */
async function drawCanvas(selector) {
    // 獲取想要轉換的 DOM 節點
    const dom = document.querySelector(selector);
    const box = window.getComputedStyle(dom);
    // DOM 節點計算後寬高
    const width = parseValue(box.width);
    const height = parseValue(box.height);
    // 獲取畫素比
    const scaleBy = DPR();
    // 建立自定義 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 作為配置項傳入,開始繪製
    return await html2canvas(dom, {canvas});
}複製程式碼

以上程式碼先獲取裝置畫素比,並根據比例建立尺寸更大的 canvas。如二倍屏就是二倍,三倍屏就是三倍,八倍鏡就是八倍···
手機端截圖,和html展示效果一致,基本看不出來差別。

image
image

3.2.2 一次 H5 「儲存頁面為圖片」 的踩坑之旅圖片畫出來怎麼不見了

PC端截圖:

image
image

可能有多種原因,排查後發現是因為 canvas 內的圖片跨域了 這裡有解釋
總而言之,就是:可以在 canvas 中繪製跨域的圖片,但此時的 canvas 處於被 「汙染」 的狀態,而汙染狀態的 canvas 使用 toDataUrl() 等 API 是會出現問題的。

所以,現在我們需要做兩件事:

  1. 給 img 元素設定 crossOrigin 屬性,值為 anonymous
  2. 圖片服務端設定允許跨域(返回 CORS 頭)

第一件事好辦,因為 html2canvas 本身支援配置useCORS: true

但是第二件事就要分情況。當圖片放在自己伺服器時,僅僅是讓後端小哥改個配置的事兒。但是當圖片放在 CDN 上時······嗯, 為了更快的響應,很多 CDN 會快取圖片的返回值,而快取的值是不帶 CORS 頭的。因為沒有 CORS 頭,所以 js 請求會被攔截。這個時候,我們可以使用伺服器轉發,在轉發時帶上 CORS 頭。(前端擼一個 node 中間層來進行伺服器轉發是個很好的方案,這個下回再單獨說)

OK。使用以上方案,我們測試一下。

PC 端開啟,完美。

微信端,咦,還是不行。
後期發現,使用 html2canvas 0.5.0 版本是沒有問題的,但是開發時使用 0.4.1 繪製 canvas 還是會導致圖片丟失。猜測是因為 html2canvas 在預載圖片和繪製圖片時多了什麼不可描述的東西。為了解決這個問題,我們使用了一個非常暴力的解決方案:用 js 去獲取圖片,獲得其 base64,放回 img 的 src 中再進行繪製。

/**
 * 圖片轉base64格式
 */
img2base64(url, crossOrigin) {
    return new Promise(resolve => {
        const img = new Image();

        img.onload = () => {
            const c = document.createElement('canvas');

            c.width = img.naturalWidth;
            c.height = img.naturalHeight;

            const cxt = c.getContext('2d');

            cxt.drawImage(img, 0, 0);
            // 得到圖片的base64編碼資料
            resolve(c.toDataURL('image/png'));
        };

        crossOrigin && img.setAttribute('crossOrigin', crossOrigin);
        img.src = url;
    });
}複製程式碼

這個坑總算是磕磕碰碰趟過去了。

3.2.3 倒角

border-radius 必須 ≤ 短邊長度的一半,並且是具體數值,否則可能會出現奇妙的效果。

另外使用偽元素實現 0.5px 邊框也可能會出現奇妙效果,建議直接使用 border 屬性

0.4.1 版本中需要做圓形圖片只能置為背景圖,img 不支援繪製 border-radius,0.5.0 中則無此限制

3.2.4 虛線

前面說的, html2canvas 並不支援所有 css 屬性。使用 border-style: dashed/dotted 無效,還是大實線。切圖在 PC 端有效,但是在微信中,嘗試使用切圖渲染虛線時有可能還會報 SecurityError, The operation is insecure. 錯誤,導致轉 base64 失敗

3.3 儲存

理想:

/**
 * 在本地進行檔案儲存
 * @param  {String} data     要儲存到本地的圖片資料
 * @param  {String} filename 檔名
 */
saveFile(data, filename) {
    const save_link = document.createElementNS('http://www.w3.org/1999/xhtml', 'a');
    save_link.href = data;
    save_link.download = filename;

    const event = document.createEvent('MouseEvents');
    event.initMouseEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
    save_link.dispatchEvent(event);
}複製程式碼

現實:

PC端: 完美。微信大佬:不好意思,你說什麼?我聽不見?!

好嘛,微信中根本沒有任何反應。檢視 微信sdk 後發現:

  • downloadImage 僅支援 uploadImage 介面上傳的圖片。
  • uploadImage 介面僅支援 chooseImage 介面相簿選擇的圖片。
  • chooseImage 介面是從本地相簿選擇圖片。
  • 那麼問題來了,圖片都在相簿了還需要我們幹啥?
  • ....

4. (在痛苦和妥協中) 交付

最終實現的方案是:

  • 使用者進入該頁面
  • 獲取當前使用者所有資訊,頭像,二維碼等
  • 將所有圖片轉為 base64
  • 渲染 html
  • 繪製 canvas
  • 將 canvas 儲存為 base64
  • 替換 htmlimgsrc為 base64
  • 完成頁面到圖片的轉換,微信中使用者可長按頁面調起 actionSheet 識別或儲存圖片

也就是說,使用者剛進入頁面時,顯示的是 html。js 執行完後,將原有 html 刪掉,替換為圖片。

再回頭看我們的需求:

  • html 展示實時使用者資訊
  • 點選儲存將當前頁面儲存成圖片至本地

其實最終只實現了第一點,而第二點其實是實現了一半,圖片雖然生成了,但儲存功能還是需要使用者長按圖片,調起微信內建選單來完成。在進行 H5 開發時,一旦考慮到微信,就有可能出現一些之前考慮不到的問題和限制,對此,產品經理和程式設計師都要儘可能地多多瞭解。知道在微信中,能幹什麼,不能幹什麼,降低開發和反覆溝通的成本。

希望以上內容能夠對大家以後的開發有所幫助。

作者: 丁香園 f2e - 顧重喜

相關文章