如何用canvas拍出 jDer's工作照
背景
在京東,就職滿五年的老員工被稱作「大佬」,如果滿了十年,那就要被稱之為「超級大佬」了。
從 2016 年 5 月 19 日開始,每一年的這一天都被定為京東集團的「519 老員工日」。正所謂:五年礪銀,十年鍛金!在京東成長 10 年的員工,放在行業里的任何一家公司,都能夠像金子般發光!
在這 5 年或 10 年無數個奮鬥的日夜裡,大家是以怎樣的姿勢在工作呢?下面由我揭曉這些姿勢是怎樣修鍊而成的吧~
玩法
首先我們用一張 gif 圖來回顧一下效果

玩法基本的步驟如下

ok,拍完照就可以分享到朋友圈了。
技術選型
可以看到這裡用到了大量的圖片,通過對圖片的拖拽縮放等操作,擺放人物及配件,最終合成相應的圖片。那麼這一過程是怎麼實現的呢?
首先我們採用 NUTUI 來搭建整個項目,其腳手架可以很好地處理圖片優化打包等。底部操作菜單模組使用了 NUTUI 中的 Tab 組件,提升了開發效率。在主介面的部分選用了基於 canvas 的 creatjs 庫,以及一個輕量級的觸屏設備手勢庫 hammer.js 來開發。

NUTUI
NUTUI 是一套京東風格的移動端組件庫,開發和服務於移動 Web 介面的企業級前中後台產品。50+ 高品質組件,40+ 京東移動端項目正在使用,支援按需載入,支援服務端渲染(Vue SSR)...
快掃碼體驗起來吧

Hammer.js
Hammer 是一個開源程式碼庫,可以識別由觸摸,滑鼠和 pointerEvents 做出的手勢。它沒有任何依賴性,並且很小,壓縮後只有 7.34 kB。 它支援常見的單點和多點觸摸手勢,並且可以添加自定義手勢

Create.js
CreateJS 是基於 HTML5 開發的一套模組化的庫和工具。基於這些庫,可以非常快捷地開發出基於 HTML5 的遊戲、動畫和交互應用。
CreateJS 包含如下幾部分

在本項目中主要是運用了 EaseJs,並結合 Tween.js 做了一些小動畫。
了解完所用到的技術後,我們來看看具體的實現過程:
實現方案
這個項目主要包含了三大核心:載入圖片、繪製姿勢、手勢操作,下面我們分別來討論一下。
1. 載入圖片
由於這個項目 99%的模組是由圖片構成,因此預載入圖片這一功能必不可少。圖片那麼多,要一個個手動列出來去載入嗎?當然不用!現在是機械化時代了,能交給工具的就不動手。
const fs = require("fs");
const path = require("path");
let components = [];
const files = fs.readdirSync(path.resolve(__dirname, "../img/"));
files.forEach(function (item) {
components.push(`'@/asset/img/${item}'`);
});
let data = `let imgList = [${[...components]}]
module.exports = imgList;`;
fs.writeFile(path.resolve(__dirname, "./imgList.js"), data, (error) => {
console.log(error);
});
依託於 nodejs 對文件的讀寫來完成自動生成圖片列表文件,載入時對這個列表下的圖片依次 load 即可。
2. 繪製姿勢
EaselJS 在 Createjs 中承擔 『畫』 的能力,這裡用到了畫圖片和畫文字的 API。EaselJS 一般的繪製步驟是:創建舞台 → 創建對象 → 設置對象屬性 → 添加對象到舞台 → 更新舞台呈現下一幀
this.stage = new createjs.Stage(this.canvas); // 創建舞台
let bgImg = new createjs.Bitmap(imgSrc); // 創建對象
this.stage.addChild(bgImg); // 添加對象到舞台
CreateJs 提供了兩種渲染模式,一種是用 setTimeout,一種是用 requestAnimationFrame,默認是 setTimeout,幀數是 20,這裡我們選用 requestAnimationFrame 模式,因為要對頁面元素進行大量的操作,選此種方式會更加流暢。
createjs.Ticker.timingMode = createjs.Ticker.RAF; // RAF為requestAnimationFrame縮寫
createjs 其他基本設置
easeljs 事件默認是不支援 touch 設備的,需要手動開啟
createjs.Touch.enable(this.stage);
實時刷新舞台
createjs.Ticker.addEventListener("tick", this.stage.update(event));
hammer.js 配置
由於 hammer.js 默認是不開啟 rotate 事件的,因此需要在選項中使用 recognizers 來設置一個識別器
let bodyHandle = new Hammer.Manager(this.canvas, {
recognizers: [[Hammer.Rotate], [Hammer.Pan]],
});
let bodyRotate = new Hammer.Rotate();
bodyHandle.add(bodyRotate);
準備工作完成,下面正式開始
繪製場景
為了保持文明的形象,就不支援站在桌子上辦公了。因此場景分為背景和桌子兩部分,通過設置桌子的層級在人物的上層來進行約束。
首先繪製背景
let Bg = new Image();
Bg.src = require("../asset/img/scene" + n + ".png");
Bg.onload = () => {
let bgimg = new createjs.Bitmap(Bg);
this.stage.addChild(bgimg);
};
注意,如果不是首次繪製,需要將之前的內容清空
this.stage.removeAllChildren();
同理繪製桌子,需要注意的是,桌子繪製完以後,需要設置其層級
...
this.stage.addChild(deskImg);
this.stage.setChildIndex(deskImg, 1);
繪製角色
繪製角色與場景不同,這裡需要用到 Container。 Container 是一個容器,可以包含 Text、Bitmap、Shape、Sprite 等其他的 EaselJS 元素。例如,你可以將手臂、腿部、軀幹和頭部聚在一起,把它們轉換為一組,同時還可以將各個部分相對彼此移動。在這裡我們將角色及其表情放在一個 Container 中方便統一管理,統一移動縮放旋轉等。
繪製角色前,我們先確定繪製的位置:默認位置在畫布的最中間
let pos = {
x: this.canvasW / 2,
y: this.canvasH / 2,
};
如果已經選擇過角色,需要更換時,需要保持之前角色的位置
pos = {
x: joy.x,
y: joy.y,
};
下面是具體繪製步驟:
var joy = new Image();
joy.src = require("../asset/img/joy" + n + ".png");
// 載入角色圖片
joy.onload = () => {
var joyImg = new createjs.Bitmap(joy); // 創建影像
joyImg.name = "joy"; // 角色命名
joyImg.regX = joy.width / 2; // 移動x方向到中心點位置
joyImg.regY = joy.height / 2; // 移動y方向到中心點位置
joyImg.x = pos.x; // 設置初始位置
joyImg.y = pos.y; // 設置初始位置
let container = new createjs.Container(); // 創建容器
container.name = "joyContainer"; // 容器命名
container.addChild(joyImg); // 容器添加角色
this.stage.addChild(container); // 添加容器到舞台
};
繪製表情
在上面繪製角色時,創建了一個 name 為 joyContainer 的容器,我們將表情也繪製進去
var face = new createjs.Bitmap(imgBg);
...
joyContainer.addChild(face);
這樣當我們想移動這個角色時,通過移動容器,來保證整體性。否則會出現腦袋跟不上身體移動的情況。。。
刪除元素
從添加角色開始,就會記錄下當前的操作對象 activeItem,當觸發刪除按鈕時,只要找到 activeItem,並將其相關內容刪除即可。
const ele = this.stage.getChildByName(this.activeItem.name);
this.stage.removeChild(ele);
3. 手勢操作
hammer.js 是用於檢測觸摸手勢的 JavaScript 庫,支援最常見的單點和多點觸摸手勢,並且可以完全擴展以添加自定義手勢。NUTUI中將會集成此功能並在下個版本中正式發布。
bodyHandle.on("rotate", (e) => {
let ctrEle = this.activeItem;
ctrEle.scaleX = ctrEle.scaleY = e.scale * this.nowScale;
ctrEle.rotation = this.BorderBox.rotation = e.rotation + this.nowRotate;
});
通過監聽 rotate 事件,可以得到當次操作的縮放及旋轉的數據,我們再將其與之前的狀態相結合,就能達到各種手勢操作的效果了。
好了,一切準備就緒,開始你的表演吧~
首先,選擇一個辦公場景,然後來個角色扮演,站著有點累?沒關係,換個姿勢坐下來吧,當然你想站著凳子上也沒關係。。表情是不是有點古板?那就吐吐舌頭吧。電腦水杯安排上,最後再來個口號「在京東胖個 20 斤」。。

玩過癮了嗎?好了,收收心咱們繼續聊如何實現的吧。
生成圖片
當你點擊「完成時」,我們會進入分享頁,分享頁的底圖是三種顏色隨機選擇。這裡我們需要創建一個臨時的 canvas 來繪製分享圖片,將分享的背景,訂製好的姿勢場景圖(通過 canvas.toDataURL 方法轉成圖片),還有二維碼,以及昵稱,依次繪製到這個臨時的 canvas 中,最後導出圖片後賦值給分享圖片的 url。
let tmpStage = new createjs.Stage(tmpCanvas);
tmpStage.addChild(bg, share, code, text);
由於分享圖片與分享頁展示元素不完全一樣,因此展示給用戶看到的是分享頁,而分享圖片設置了透明度為 0,只能保存不能被看到。
然而,事情沒有這麼簡單,一大波 bug 正在馬不停蹄的狂奔襲來。。
遇到的問題
路由 底部導航去除
前面介紹過,這個項目是由載入頁和主介面兩個頁面組成,中間是通過路由跳轉(history 模式)。但是在一些手機中,通過路由跳轉到另一個頁面時,底部會自動出現導航模組,這是我們所不希望看到的,本就捉襟見肘的空間里,憑空多了這麼大一塊,這是不可容忍的存在。

因此在權衡之後,選擇了 replace 模式,但是這樣用戶在進入主介面以後,就不能回到載入頁了,魚與熊掌不可兼得。

ios 中輸入框不自動收回,有白塊
在載入完成後,有個昵稱的輸入框,在 ios 下輸入完成,鍵盤收起後頁面底部會有一大片空白,呈卡死狀。

但是當我們在頁面上隨意滑動一下,這個白塊就會消失。這是因為 ios 鍵盤彈出後,會把頁面整體頂上去,因此我們需要使用 scrollTo 函數,在 blur 鍵盤落下時滾動頁面,使頁面歸位。
blur() {
window.scrollTo(0, 0);
}
由於系統更新後,白塊變成了透明狀態,這使得人更加琢磨不透,明明看不到任何東西,但是輸入框就是無法選中。別以為脫了馬甲就不認識你了,上面的解決方案依舊是有效的。
圖片跨域
本地開發完成,上傳程式碼到伺服器後,原本的世界靜好全都消失不見,取而代之的是刺眼的紅:

一番查閱後找到了如下這段話:
儘管可以在畫布中使用未經CORS批准的影像,但這樣做會污染畫布。一旦畫布被污染,就不能再從畫布中提取數據。例如,不能再使用canvas toBlob()、toDataURL()或getImageData()方法;這樣做將引發安全錯誤。這可以防止用戶在未經允許的情況下使用影像從遠程網站獲取資訊,從而公開私有數據。
這就解釋了上面報錯的由來,那麼如何解決呢?
var bg = new Image();
bg.crossOrigin = "Anonymous";
這就開啟了圖片載入過程中的 CORS 功能,從而繞過了報錯。
點擊報錯
圖片可以載入了,可是當我想做拖拽等操作時,又又又報錯了。。。

createjs 提供了 hitArea 點擊區域。可以設置另一個對象 objB 作為顯示對象 objA 的 hitArea,當點擊到 objB 時就相當於點擊到了 objA。 這個 objB 不需要添加到顯示對象列表,也不需要可見,但它會在交互事件的觸發中替代 objA。
var hitArea = new createjs.Shape();
hitArea.graphics.beginFill("#000").drawRect(0, 0, imgBg.width, imgBg.height); //這裡的大小為圖片大小,請自己調整
img.hitArea = hitArea;
給對象綁定一個點擊區域,這樣拖拽是操作這個區域,而不是原本的影像,這樣就可以不報錯了
層級問題
在這個項目中的設定,角色在所有其他元素的底層,而元素切換選中時,也需要將當前選中元素置頂,這裡用到了 createjs 的 setChildIndex 方法
setChildIndex 方法允許你向上或向下移動顯示對象在顯示列表內的位置。顯示列表可以看作為一個數組,它的索引位置是從第 0 開始的。假如創建了 3 個元素,那麼他們的位置就是第 0,1,2 層。第二層的對象在外面,第 0 層的在最裡面。
如果想把某一元素移到所有元素的上面,這時就要用到 getNumChildren 屬性,它的含義就是該容器內顯示對象的數目。最外層的層深就是第 numChildren-1 層。其他原本層級高於置頂元素的元素,相應層級會減少一級。
if (ele.name === "joy") {
this.stage.setChildIndex(ele, 1);
} else {
this.stage.setChildIndex(ele, this.stage.getNumChildren() - 2);
}
在我們選中或者新增一個元素時,觸發層級設置,因為要保證當前操作的元素層級在上。由於有置頂的元素,因此在設置層級時,如果是角色元素,那麼設置在第 2 層,僅僅高於場景背景層;如果是其他元素,則設置為次頂層。
ios 低版本 base64 onload 有問題
在測試階段發現,ios10 以下的手機,不能拖拽,真是個晴天霹靂!
在排查過程中發現了蹊蹺,不能拖拽竟然是因為選中框上面的刪除按鈕沒有載入到,這個按鈕有什麼特別之處呢,哦,原來是 webpack 配置中的 url-loader 自動將小圖片轉成了 base64 格式,順著這個思路,將這個功能去掉以後,問題得以解決,但並沒有深究。
接下來的結果更糟,分享圖片不翼而飛了,只剩下個背景框!

上面「生成圖片」部分就講過,圖片都是將 canvas 通過 toDataURL 導出,導出格式正是上面有問題的 base64 格式。
我們發現 base64 在 ios10 以下版本中,無法觸發 onload 事件,而是走了 onerror。那麼 base64 圖片還能轉成什麼格式呢?答案就在這裡:
dataURLToBlob(dataurl) {
//dataurl: data:image/webp;base64,UklGRvAIAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSC4CAAABkAXbtmlH+xmxn...
var arr = dataurl.split(','); // ['data:image/webp;base64','UklGRvAIAABXRUJQVlA4WAoAAAAQAAAAXwAAXwAAQUxQSC4CAAABkAXbtmlH+xmxn...']
var mime = arr[0].match(/:(.*?);/)[1]; // 分離出mime類型 ——> image/webp
var bstr = atob(arr[1]); // atob() 方法用於解碼使用 base64 編碼的字元串,轉換為字元串中保存的原始二進位數據。
var n = bstr.length;
var u8arr = new Uint8Array(n); // Uint8Array表示一個8位無符號整型數組,創建時內容被初始化為0。創建完後,可以以對象的方式或使用數組下標索引的方式引用數組中的元素。
while (n--) {
u8arr[n] = bstr.charCodeAt(n); // 依次存儲Unicode 編碼
}
return new Blob([u8arr], {type: mime}); // type:代表了將會被放入到blob中的數組內容的MIME類型
}
我們先將 base64 圖片轉為 blob 格式
sharePhoto.src = window.URL.createObjectURL(this.dataURLToBlob(photo));
然後通過 URL.createObjectURL 方法生成 ObjectURL
window.URL.revokeObjectURL(sharePhoto);
由於 createObjectURL 返回的 url 一直存儲在記憶體中,直到 document 觸發了 unload 事件(例如:document close)。所以咱們養成好習慣,在使用完成以後要記得隨手釋放一下哦~
那麼 createObjectURL 到底是何方神聖呢?我們一起來學習下:
createObjectURL
定義:URL.createObjectURL()方法會根據傳入的參數創建一個指向該參數對象的 URL。這個 URL 的生命僅存在於它被創建的這個文檔里。新的對象 URL 指向執行的 File 對象或者是 Blob 對象。
createObjectURL 返回一段帶 hash 的 url,並且一直存儲在記憶體中,直到 document 觸發了 unload 事件(例如:document close)或者執行 revokeObjectURL 來釋放。
瀏覽器支援情況如下,移動端基本可以放心使用~

阻止長按事件
在即將上線時,由於內部 app 對長按保存圖片支援不太充分,因此臨時決定在其中屏蔽此功能,這裡嘗試了三種方法:
- 加透明 div 蓋在最頂層 由於長按保存時間是在 img 標籤上觸發,因此 div 能阻擋住
- touchstart 時阻止 contextmenu 究其本質,長按是觸發了 contextmenu 上下文菜單,那麼我們只要阻止這個事件即可
document.oncontextmenu = (e) => {
e.preventDefault();
};
在 web 瀏覽器中生效,但是在移動端無效
- 加樣式
* {
-webkit-touch-callout: none; /* 系統默認菜單被禁用*/
-webkit-user-select: none; /* webkit瀏覽器*/
-moz-user-select: none; /* 火狐*/
-ms-user-select: none; /* IE10*/
user-select: none; /* 用戶是否能夠選中文本*/
}
實踐證明這種方式不可行,我們依次來分析一下:
user-select 控制用戶能否選中文本,而我們這裡需要的是控制圖片。
-webkit-touch-callout:當你觸摸並按住觸摸目標時候,禁止或顯示系統默認菜單。適用於:鏈接元素比如新窗口打開,img 元素比如保存影像等等
乍一看,這不就是我們所需要的嗎?
但是,-webkit-touch-callout 是一個 不規範的屬性(unsupported WebKit property),它沒有出現在 CSS 規範草案中。
看一下支援情況就明白了:

最終選擇了第一種方式,簡單直接,不用考慮兼容性。
圖片優化
在解決了上面一系列的問題之後,要回到最初的分析:不管項目用了何種技術,最終呈現的本質都是圖片。所以圖片的大小不僅影響載入速度,同時也影響著渲染速度,為了提供更優的用戶體驗,選擇使用 NUTUI 中的圖片壓縮功能,它可以提供高壓縮比的圖片優化,並且可以自動轉化成 webp 格式。大家都知道,webp 格式的圖片比一般壓縮過的圖片還要小很多,依託於這麼強大的靠山,想不出色都難!
總結
不管你現在是大佬、超級大佬,還是剛剛加入京東的 fresh blood,519 老員工日就是屬於每一位 JDer 共同的節日!
在做項目的過程中,從零開始學習 createjs,項目中間不斷試錯,不斷去解決問題,學習新知識,收穫良多。在以後的工作中,還要注重基礎知識的廣度,不斷積累,也許學習的時候並不清楚應用場景,但是終有一天會發現,每個知識都有其存在的理由。

