ThinkJS 和 Sprite.js 服務端渲染實踐
介紹
ThinkJS 是一個基於 [email protected] 的企業級服務端開發框架,本專案中除基本的 HTTP 服務外,還使用了定時任務和 websocket 功能。
Sprite.js 是一個跨平臺的 2D 繪圖物件模型庫,它支援 Web、Node、桌面應用和微信小程式的等多端圖形繪製及動畫實現。Sprite.js 使用 node-canvas 進行服務端渲染,這意味著我們可以在 node 環境中使用 Sprite.js,並將繪製好的圖形儲存成 png,或將動畫儲存成 gif。在本文中的專案中主要使用了以下特性:
- Scene(場景):sprite.js 通過建立場景 scene 實現 layer管理;
- Layer(圖層):每層 layer 是一個封裝過的 canvas 2D 物件;
- Sprite(精靈):一個擁有盒模型的可渲染物件。sprite.js 預設支援的精靈型別有四種,分別是Sprite、Label、Path和Group,其中Sprite是最基礎的精靈;
️ 疑問
為什麼進行 canvas 服務端渲染呢?
本專案的需求是實現峰值每小時百萬級的實時資料的大屏展示,為了能達到最好的展示效果,並且能回溯歷史態勢,我們決定使用前端、服務端程式碼同構,前端進行實時資料的動畫展示, 服務端同時渲染資料攻擊路徑,具體策略如下:
- 服務端作為 websocket 客戶端,接收 websocket 上游的資料,使用 sprite.js 繪製圖像,通過 ThinkJS 定時任務拍快照,並將圖片上傳到 CDN 後儲存 URL;
- 同時,服務端也作為 websocket 服務端,把上游的資料過濾後傳送給前端,前端將接收到的資料通過sprite.js 實時繪製到 canvas 上。
- 前端回溯歷史態勢時,需請求服務端取得歷史快照。服務端將請求時間內的快照合併為一張,上傳到 CDN後將URL返回給前端,並由前端繪製到 canvas 上。
:eyes: 開發前的爬坑之旅
在實現這套方案的過程中爬了不少坑,其中最大的坑是 node-canvas 挖的:joy:,爬坑的路上,我一度弄掛了伺服器(幸虧只是個docker容器)。
安裝 node-canvas
node-canvas 是一個使用 Cairo 支援的 Node.js 環境的 canvas 實現,開啟它的開發者列表頁面,你會看到一個熟悉的名字 TJ Holowaychuk。目前遇到的這幾個問題也是在多次更換伺服器的過程中發現的,希望大家留意,免得以後被坑哭。
缺少預編譯的二進位制檔案
node-canvas 只有在 node 服務端才會用到,所以 sprite.js 的依賴中沒有新增它,需要我們手動執行 npm i canvas@next
安裝到專案中,預設會安裝最新版本,安裝時它會根據系統架構決定在預編譯專案中下載相應的二進位制檔案,如果你遇到了圖1所示錯誤,有兩種解決方法:
- 編譯安裝 node-canvas ,官方文件上寫清楚了不同作業系統編譯需要的依賴;
- 安裝最近有預編譯二進位制檔案的版本,目前是 [email protected];

缺少 GLIBC_2.14
在解決完上個問題後你可能還會遇到這個問題
Error: /lib64/libc.so.6: version `GLIBC_2.14` not found

這表示伺服器作業系統上沒有 GLIBC_2.14
的庫,先了解下 GLIBC 是什麼:
GLIBC是GNU釋出的libc庫,即C執行庫。GLIBC是Linux系統中最底層的API,幾乎其它任何執行庫都會依賴於GLIBC。GLIBC除了封裝Linux作業系統所提供的系統服務外,它本身也提供了許多其它一些必要功能服務的實現。
看完這段介紹,你就應該明白你即將面對的是什麼級別的依賴缺失,去搜一下相關詞條,多少人因為它重灌了系統。
檢視系統核心是否支援 GLIBC_2.14 可以用這條命令
strings /lib64/libc.so.6 | grep GLIBC
如果結果中確實沒有 GLIBC_2.14 關鍵字,可以嘗試以下兩種方式解決這個問題:
- 在你使用的作業系統上新增 GLIBC 的源,然後安裝對應版本的 GLIBC;
- 選擇一個 GLIBC 版本 >= 2.14 的作業系統,如 CentOS 7。
如果沒有找到伺服器系統核心對應的源,也不要嘗試編譯安裝這個庫,運維的同事說有些老版本核心就不支援 GLIBC_2.14。然後請讀一下下面這句話:
由於 GLIBC 囊括了幾乎所有的 UNIX 通行的標準,可以想見其內容包羅永珍。而就像其他的 UNIX 系統一樣,其內含的檔案群分散於系統的樹狀目錄結構中,像一個支架一般撐起整個作業系統。
所以最好的方式還是直接用支援的作業系統。
缺少字型檔案
在安裝好 node-canvas 後,可以使用下面這段程式碼進行測試。如果輸出圖片上文字顯示為下圖所示的長方形,這表示你使用的系統缺少字型檔案。碰巧你又有渲染文字的需求,就需要解決這個問題。

一般 PC 上會有很多字型檔案,但沒有介面的伺服器環境可能會缺少字型檔案,因此需要至少安裝一種字型,操作方法可以參考這篇文章。操作完後執行下面的程式碼,會生成一張圖片,如果能正確顯示文字說明成功安裝了字型檔案。
// label.js const {Scene,Label} = require('spritejs'); const fs = require('fs'); (function () { // 建立scene和layer const scene = new Scene('#paper', {resolution: [1200, 1200]}); const fglayer = scene.layer('fglayer'); // 建立label並設定屬性 const text1 = new Label('Hello World !'); text1.attr({ anchor: [0.5, 0.5], pos: [600, 600], font: 'bold 48px Arial', color: '#ffdc45', }); // 將label新增到layer上,並將將canvas存為圖片 fglayer.append(text1); await fglayer.prepareRender(); fs.writeFileSync('spritejs.png', fglayer.canvas.toBuffer()); }());
:cactus: 服務端渲染
服務端渲染 canvas 的關鍵操作是圖片的輸出和合並,理解並靈活運用這兩個過程,能夠滿足大部分 canvas 服務端渲染的場景。
圖片輸出
本文專案的方案,服務端得到新資料後建立 sprite 並新增到 layer 上,構造出的 sprite 只應該完成一個任務,就是在 layer 留下影象,然後就刪除掉(如果不刪掉記憶體會吃不消的)。要完成這個過程需要重寫 layer 上的 clearContext 方法,這樣才能保留 sprite 繪製的圖案。
// 重寫 clearContext 方法確保 sprite,label,path,等元素移除後保留影象 layer.clearContext = () => {} // 通過資料生成新的 sprite 元素 const sprite = drawSomething(data); // 繪製到 layer 上 layer.append(sprite); // 確保 sprite 繪製到 layer 上後 await layer.prepareRender(); // 將 sprite 元素移除,因為重寫了 clearContext 方法,移除後圖像仍在 layer 上 sprite.remove(); // 如果要清空 layer const {width, height} = layer.context.canvas; layer.context.clearRect(0, 0, width, height);
sprite.js 的 scene 物件上是有快照方法的,它對當前的 scene 截圖並返回 canvas 物件,我們可以在這個 canvas 物件上呼叫 toBuffer 方法獲得影象二進位制資料,然後使用 node.js 中 fs 模組提供的同步寫方法生成一張 png 圖。
const canvas = await scene.snapshot() fs.writeFileSync('snap.png', canvas.toBuffer())
如果不想對整個 scene 拍快照,只想對特定的某個 layer 拍照快,可以通過 layer 獲得 canvas 物件,然後使用同樣的方式對layer進行拍攝快照。
async function snapshot(layer) { await layer.prepareRender(); return layer.canvas.toBuffer(); };
快照圖片量很大的時候,需要定時將快照上傳到 cdn 或者單獨的檔案伺服器,然後在資料庫中儲存圖片的 url。這個過程用到了 ThinkJS 的定時任務,可以在 src/config/crontab.js
中新增如下配置,然後編寫對應的處理方法。如果想確保在某個時間進行定時任務,例如在 5 分鐘整數倍時執行任務,可以設一個更細粒度的定時器,然後在處理方法中判斷,如果不是 5 分鐘的倍數則不執行。
// src/config/crontab.js module.exports = [ { enable: true, interval: '1m', // 每1分鐘執行一次 handle: 'crontab/snapshot' } ]; // src/controller/crontab.js module.exports = class extends think.Controller { async snapshotAction() { // 拒絕非定時任務啟動 if (!this.isCli) return; const now = new Date(); // 如果不是 5 分鐘的整數倍,則不執行任務 if (now.Minutes() % 5) { return; } // 下面實現拍快照 -> 上傳 cdn -> 存資料庫的邏輯 // ... } }
圖片處理
使用 sprite.js 可以在服務端組合,合併圖片,新增濾鏡等,這個方案中簡單地將多張相同型別的圖片合為一張。sprite.js 實現了前後端通用的預載入功能,可以預載入圖片,然後在 sprite.js 中使用,下面的程式碼就實現了這個過程,具體可以參考 sprite.js 文件圖片非同步載入。
const spritejs = require('spritejs'); const fs = require('fs'); (async function() { const {Scene, Sprite} = spritejs; const scene = new Scene('#paper', {resolution: [1200, 1200]}); // 預載入圖片 await scene.preload( 'https://p3.ssl.qhimg.com/t01ccaee34d3f92a10c.png', 'https://p2.ssl.qhimg.com/t01eb096408038e7496.png' ); // 是否代理DOM 事件,如果這個引數設定為false,那麼這個 Layer 將不處理DOM事件 // 可以提升效能 const layer = scene.layer('fglayer', { handleEvent: false }); const sprite = new Sprite(); // 在 sprite 元素上新增多個 texture // http://spritejs.org/#/zh-cn/doc/attribute?id=textures sprite.attr({ textures: [ { src: 'https://p3.ssl.qhimg.com/t01ccaee34d3f92a10c.png' }, { src: 'https://p2.ssl.qhimg.com/t01eb096408038e7496.png' } ] }); // 新增到 layer 上 layer.append(sprite); const buffer = await snapshot(layer); fs.writeFileSync('test.png', buffer); layer.remove(sprite); })();
websocket
ThinkJS 使用 master/workers 多程序模型,如果專案沒有用到 websocket,master 接收到請求後是以 Round Robin 演算法交給 workers 處理,這樣基本保證負載均衡。如過專案前後端需要 websocket 通訊,在 ThinkJS 中需要配置 stickyCluster: true
,新增這個配置後,master 會做 IP Hash,這樣確保來自同一個客戶端的請求會被相同的 worker 處理,從而成功建立起 websocket 通訊,這樣會犧牲一部分效能,詳細瞭解多程序模型,請看 ofollow,noindex" target="_blank">《細談ThinkJS多程序模型》 。
由於我們的專案是資料視覺化大屏專案,一般沒有什麼訪問量,因此在這個專案中只啟動了一個 worker,將 stickyCluster 設定為 false,也能成功建立 websocket 通訊。因為只有一個 worker 幹活,所有的請求必然都交給了它。
雖然前端可以直接跟 websocket 上游服務建立通訊,但是為什麼沒有這麼做(目前是跟 ThinkJS 服務建立 websocket 通訊),主要是考慮在 ThinkJS 服務中可以通過制定一套策略處理資料,決定服務端渲染以及前端實時資料展示,這樣前端大屏頁面就可以只關注繪圖工作。
:notebook: 總結
這是第一次做 ThinkJS 和 Sprite.js 結合的服務端渲染大屏專案,對我們來說是一次新的嘗試,但是技術解決的是怎樣實現的問題,實現什麼樣的展示?以及為什麼這麼展示?仍是視覺化展現過程中需要先行思考的問題。