圖片來源:https://unsplash.com
本文作者:飛揚
背景
作為前端開發尤其是偏 c 端的前端開發者(如微信小程式),相信大家都碰到過分享活動圖片、分享海報圖類似的功能
一般這種需求的解決方案大體上可以分為以下幾種:
- 依賴服務端,比如寫一個
node
服務,用puppeteer
訪問提前寫好的網頁來截圖。 - 直接使用
CanvasRenderingContext2D
的 api 或者使用輔助繪圖的工具如react-canvas
等來繪製。 - 使用前端頁面截圖框架,比如
html2canvas
、dom2image
,用 html 將頁面結構寫好,再在需要的時候呼叫框架 api 截圖
方案分析:
- 依賴服務端這種方案會消耗一定的服務端資源,尤其截圖這種服務,對 cpu 以及頻寬的消耗都是很大的,因此在一些可能高併發或者圖片比較大的場景用這種方案體驗會比較差,等待時間很長,這種方案的優點是還原度非常高,由於服務端無頭瀏覽器版本是確定的,所以可以確保所見即所得,並且從開發上來說,無其他學習成本,如果業務還不是很大訪問量不高用這種方案是最可靠的。
- 這種方案比較硬核,比較費時費力,大量的程式碼來計算佈局的位置,文字是否換行等等,並且當開發完成後,如果 ui 後續有一些調整,又要在茫茫程式碼中尋找你要修改的那個它。 這個方案的優點是細節很可控,理論上各種功能都可以完成,如果頭髮夠用的話。
- 這應該也是目前 web 端使用最廣的一種方案了,截止目前
html2canvas
star 數量已經 25k。html2canvas
的原理簡單來說就是遍歷 dom 結構中的屬性然後轉化到 canvas 上來渲染出來,所以它必然是依賴宿主環境的,那麼在一些老舊的瀏覽器上可能會遇到相容性問題,當然如果是開發中就遇到了還好,畢竟我們是萬能的前端開發(狗頭),可以通過一些 hack 手段來規避,但是 c 端產品會執行在各種各樣的裝置上,很難避免釋出後在其他使用者裝置上相容問題,並且出了問題除非使用者上報,一般難以監控到,並且在國內小程式使用者量基數很大,這個方案也不能在小程式中使用。所以這個方案看似一片祥和,但是會有一些相容的問題。
在這幾年不同的工作中,基本都遇到了需要分享圖片的需求,雖然需求一般都不大頻次不高,但是印象中每次做都不是很順暢,上面幾種方案也都試過了,多多少少都有一些問題。
萌生想法:
在一次需求評審中瞭解到在後續迭代有 ui 統一調整的規劃,並且會涉及到幾個分享圖片的功能,當時的業務是涉及到小程式以及 h5 的。會後開啟程式碼,看到了像山一樣的分享圖片程式碼,並且穿插著各種相容膠水程式碼,如此龐大的程式碼只是為了生成一個小卡片的佈局,如果是 html 佈局,應該 100 行就能寫完,當時就想著怎麼來進行重構。
鑑於開發時間還很充裕,我在想有沒有其他更便捷、可靠、通用一點的解決方案,並且自己對這塊也一直很感興趣,秉持著學習的態度,於是萌生了自己寫一個庫的想法,經過考慮後我選擇了 react-canvas
的實現思路,但是react-canvas
依賴於React
框架,為了保持通用性,我們本次開發的引擎不依賴特定web框架、不依賴 dom 的 api,能根據類似 css 的樣式表來生成佈局渲染,並且支援進階功能可以進行互動。
在梳理了要做的功能後,一個簡易的 canvas 排版引擎浮現腦海。
什麼是排版引擎
排版引擎(layout engine),也稱為瀏覽器引擎(browser engine)、頁面渲染引擎(rendering engine)或樣版引擎,它是一種軟體元件,負責獲取標記式內容(如 HTML、XML 及影像檔案等等)、整理資訊(如 CSS 及 XSL 等),並將排版後的內容輸出至顯示器或印表機。所有網頁瀏覽器、電子郵件客戶端、電子閱讀器以及其它需要根據表示性的標記語言(Presentational markup)來顯示內容的應用程式都需要排版引擎。
摘自 Wikipedia 對瀏覽器排版引擎的描述,對於前端同學來說這些概念應該是比較熟悉的,常見的排版引擎比如 webkit、Gecko 等。
設計
目標
本次需求承載了以下幾個目標:
- 框架支援“文件流佈局”,這也是我們的核心需求,不需要開發者指定元素的位置,以及自動寬高。
- 過程式呼叫轉為宣告式呼叫,即不需要呼叫繁瑣的 api 來繪製圖形,只需要編寫 template 就可以生成圖形。
- 跨平臺,這裡主要是指可以在 web 以及各種小程式上執行,不依賴特定框架。
- 支援互動,即可以增加事件,並且可以對 UI 進行修改。
總結下來就是在可以在 canvas 裡寫“網頁”。
api 設計
在最初的設想裡,打算使用類似 vue template 語法
來作為結構樣式資料,但是這麼做會增加編譯成本,對於我想要實現的核心功能來說它的起點有點太遠了。在權衡過後,最終打算使用類似 React createElement 的語法 + Javascript style object
的形式的 api,優先實現核心功能。
另外需要注意的是,我們的目標不是在 canvas 裡實現瀏覽器標準,而是儘可能貼近 css 的 api,以提供一套方案能實現文件流佈局。
目標 api 長這樣
// 建立圖層
const layer = lib.createLayer(options);
// 建立節點樹
// c(tag,options,children)
const node = lib.createElement((c) => {
return c(
"view", // 節點名
{
styles: {
backgroundColor: "#000",
fontSize: 14,
padding: [10, 20],
}, // 樣式
attrs: {}, // 屬性 比如src
on: {
click(e) {
console.log(e.target);
},
}, // 事件 如click load
},
[c("text", {}, "Hello World")] // 子節點
);
});
// 掛載節點
node.mount(layer);
如上所示,api 的核心在於建立節點的三個引數:
tagName
節點名,這裡我們支援基本的元素,像view
,image
,text
,scroll-view
等,另外還支援自定義標籤,通過全域性component
api 來註冊一個新的元件,利於擴充套件。
function button(c, text) {
return c(
"view",
{
styles: {
// ...
},
},
text
);
}
// 註冊一個自定義標籤
lib.component("button", (opt, children, c) => button(c, children));
// 使用
const node = lib.createElement((c) => {
return c("view", {}, [c("button", {}, "這是全域性元件")]);
});
options
,即標籤的引數,支援styles
,attrs
,on
,分別為樣式、_屬性_、_事件_children
,即子節點,同時也可以是文字。
我們期望執行以上 api 後可以在 canvas 中渲染出文字,並且點選後可以響應相應事件。
流程架構
框架的首次渲染將按以下流程執行,後面也會按照這個順序進行講解:
下面會將流程圖中的關鍵細節進行講述,程式碼中涉及到一些演算法以及資料結構需要注意。
模組細節
預處理
在拿到檢視模型(即開發者通過createElement
api 編寫的模型)後,需要首先對其進行預處理,這一步是為了過濾使用者輸入,使用者輸入的模型只是告訴框架意圖的目標,並不能直接拿來使用:
節點預處理
- 支援簡寫字串,這一步需要將字串轉為
Text
物件 - 由於我們後面需要頻繁訪問兄弟節點以及父節點,所以這一步將兄弟節點以及父節點都儲存在當前節點,並且標記出所在父容器中的位置,這一點很重要,這個概念類似於 React 中的
Fiber
結構,在後續計算中頻繁使用到,並且為我們實現可中斷渲染
打下了基礎。
- 支援簡寫字串,這一步需要將字串轉為
樣式預處理
- 一些樣式是支援多種簡寫方式的,需要將其轉換為目標值。如
padding:[10,20]
,在前處理器中需要轉換成paddingLeft
、paddingRight
、paddingTop
、paddingBottom
4 個值。 - 設定節點預設值,如
view
節點預設display
屬性為block
- 繼承值處理,如
fontSize
屬性預設繼承父級
- 一些樣式是支援多種簡寫方式的,需要將其轉換為目標值。如
- 異常值處理,使用者填寫了不符合預期的值在這一步進行提醒。
- 初始化事件掛載、資源請求等。
- 其他為後續計算以及渲染的準備工作(後面會講到)。
initStyles() {
this._extendStyles()
this._completeStyles()
this._initRenderStyles()
}
佈局處理
在上一步預處理過後,我們就得到了一個帶有完整樣式的節點樹,接下來需要計算佈局,計算佈局分為尺寸和位置的計算,這裡需要注意的是,流程裡為什麼要先計算尺寸呢?仔細思考一下,如果我們先計算位置,像文字,圖片這種之後的節點,是需要在上一個尺寸位置計算完畢再去參考計算。所以這一步是所有節點原地計算尺寸完畢後,再計算所有節點的位置。
整個過程如下動畫。
計算尺寸
更專業一點的說法應該是計算盒模型,說到盒模型大家應該是耳熟能詳了,基礎面試幾乎必問的。
圖片來源:https://mdn.mozillademos.org/...
在 css 中,可以通過box-sizing
屬性來使用不同的盒模型,但是我們本次不支援調整,預設為border-box
。
對於一個節點,他的尺寸可以簡化為幾種情況:
- 參考父節點,如
width:50%
。 - 設定了具體值,如
width:100px
。 - 參考子節點,如
width:fit-content
,另外像image
text
節點也是由內容決定尺寸。
梳理好這幾種模式之後就可以開始遍歷計算了,對於一個樹我們有多種遍歷模式。
_廣度優先遍歷_:
_深度優先遍歷_:
這裡我們對上面幾種情況分別做考慮:
- 因為是參考父節點所以需要從父到子遍歷。
- 沒有遍歷順序要求。
- 父節點需要等所有子節點計算完成後再進行計算,因此需要廣度優先遍歷,並且是從子到父。
這裡出現了一個問題,第 1 種和第 3 種所需遍歷方式出現了衝突,但是回過頭來看預處理部分正是從父到子的遍歷,因此 1、2 部分計算尺寸的任務可以提前在預處理部分計算好,這樣到達這一步的時候只需要計算第3部分,即根據子節點計算。
class Element extends TreeNode {
// ...
// 父節點計算高度
_initWidthHeight() {
const { width, height, display } = this.styles;
if (isAuto(width) || isAuto(height)) {
// 這一步需要遍歷,判斷一下
this.layout = this._measureLayout();
}
if (this._InFlexBox()) {
this.line.refreshWidthHeight(this);
} else if (display === STYLES.DISPLAY.INLINE_BLOCK) {
// 如果是inline-block 這裡僅計算高度
this._bindLine();
}
}
// 計算自身的高度
_measureLayout() {
let width = 0; // 需要考慮原本的寬度
let height = 0;
this._getChildrenInFlow().forEach((child) => {
// calc width and height
});
return { width, height };
}
// ...
}
程式碼部分就是遍歷在文件流中的直接子節點來累加高度以及寬度,另外處理上比較麻煩的是對於一行會有多個節點的情況,比如inline-block
和flex
,這裡增加了Line
物件來輔助管理,在Line
例項中會對當前行內的物件進行管理,子節點會繫結到一個行例項,直到這個Line
例項達到最大限制無法加入,父節點計算尺寸時如果讀取到Line
則直接讀取所在行的例項。
這裡Text
Image
等自身有內容的節點就需要繼承後重寫_measureLayout
方法,Text
在內部計算換行後的寬度與高度,Image
則計算縮放後的尺寸。
class Text extends Element {
// 根據設定的文字大小等來計算換行後的尺寸
_measureLayout() {
this._calcLine();
return this._layout;
}
}
計算位置
計算完尺寸後就可以計算位置了,這裡遍歷方式需要從父到子進行廣度優先遍歷,對於一個元素來說,只要確定了父元素以及上一個元素的位置,就可以確定自身的位置。
這一步只需要考慮根據父節點已經上一個節點的位置來確認自身的位置,如果不在文件流中則根據最近的參考節點進行定位。
相對複雜的是如果是繫結了Line
例項的節點,則在Line
例項內部進行計算,在Line
內部的計算則是類似的,不過需要另外處理對齊方式以及自動換行等邏輯。
// 程式碼僅保留核心邏輯
_initPosition() {
// 初始化ctx位置
if (!this._isInFlow()) {
// 不在文件流中處理
} else if (this._isFlex() || this._isInlineBlock()) {
this.line.refreshElementPosition(this)
} else {
this.x = this._getContainerLayout().contentX
this.y = this._getPreLayout().y + this._getPreLayout().height
}
}
class Line {
// 計算對齊
refreshXAlign() {
if (!this.end.parent) return;
let offsetX = this.outerWidth - this.width;
if (this.parent.renderStyles.textAlign === "center") {
offsetX = offsetX / 2;
} else if (this.parent.renderStyles.textAlign === "left") {
offsetX = 0;
}
this.offsetX = offsetX;
}
}
好了這一步完成後佈局處理器的工作就完成了,接下來框架會將節點輸入渲染器進行渲染。
渲染器
對於繪製單個節點來說分為以下幾個步驟:
- 繪製陰影,因為陰影是在外面的,所以需要在裁剪之前繪製
- 繪製裁剪以及邊框
- 繪製背景
- 繪製子節點以及內容,如
Text
和Image
對於渲染單個節點來說,功能比較常規,渲染器基本功能是根據輸入來繪製不同的圖形、文字、圖片,因此我們只需要實現這些 api 就可以了,然後將節點的樣式通過這些 api 按順序來渲染出來,這裡又說到順序了,那麼渲染這一步我們應該按照什麼順序呢。這裡給出答案深度優先遍歷。
canvas 預設合成模式下,在同一位置繪製,後渲染的會覆蓋在上面,也就是說後渲染的節點的z-index
更大。(由於複雜度原因,目前沒有實現像瀏覽器合成層的處理,暫時是不支援手動設定z-index
的。)
另外我們還需要考慮一種情況,如何去實現overflow:hidden
效果呢,比如圓角,在 canvas 中超出的內容我們需要進行裁剪顯示,但是僅僅對父節點裁剪是不符合需求的,在瀏覽器中父節點的裁剪效果是可以對子節點生效的。
在 canvas 中一個完整的裁剪過程呼叫是這樣的.
// save ctx status
ctx.save();
// do clip
ctx.clip();
// do something like paint...
// restore ctx status
ctx.restore();
//
需要了解的是,CanvasRenderingContext2D
中的狀態以棧的資料結構儲存,當我們多次執行save
後,每執行一次restore
就會恢復到最近的一次狀態
也就是說只有在clip
到restore
這個過程內繪製的內容才會被裁減,因此如果要實現父節點裁剪對子節點也生效,我們不能在渲染一個節點後馬上restore
,需要等到內部子節點都渲染完後再呼叫。
下面通過圖片講解
如圖,數字是渲染順序
- 繪製節點 1,由於還有子節點,所以不能馬上 restore
- 繪製節點 2,還有子節點,繪製節點 3,節點 3 沒有子節點,因此執行 restore
- 繪製節點 4,沒有子節點,執行 restore,注意啦,此時節點 2 內的節點都已經繪製完畢,因此需要再次執行 restore,恢復到節點 1 的繪製上下文
- 繪製節點 5,沒有子節點,執行 restore,此時節點 1 內都繪製完畢,再次執行 restore
由於我們在預處理中已經實現了Fiber
結構,並且知道節點所在父節點的位置,只需要在每個節點渲染完成後進行判斷,需要呼叫多少次restore
。
至此,經過漫長的 debug 以及重構,已經能正常將輸入的節點渲染出來了,另外需要做的是增加對其他 css 屬性的支援,此時內心已經是激動萬分,但是看著控制檯裡輸出的渲染節點,總覺得還能做點什麼。
對了!每個圖形的模型都儲存了,那是不是可以對這些模型進行修改以及互動呢,首先定一個小目標,實現事件系統。
事件處理器
canvas 中的圖形並不能像 dom 元素那樣響應事件,因此需要對 dom 事件進行代理,判斷在 canvas 上發生事件的位置,再分發到對應的 canvas 圖形節點。
如果按照常規的事件匯流排設計思路,我們只需要將不同的事件儲存在不同的List
結構中,在觸發的時候遍歷判斷點是否在節點區域,但是這種方案肯定不行,究其原因還是效能問題。
在瀏覽器中,事件的觸發分為捕獲與冒泡,也就是說要按照節點的層級從頂至下先執行捕獲,觸及到最深的節點後,再以相反的順序執行冒泡過程,List
結構無法滿足,遍歷這個資料結構的時間複雜度會很高,體現到使用者體驗上就是操作有延遲。
經過一陣的頭腦風暴後想到事件其實也可以儲存在樹結構中,將有事件監聽的節點抽離出來組成一個新的樹,可以稱之為“事件樹”,而不是儲存在原節點樹上。
如圖,在 1、2、3 節點掛載 click 事件,會在事件處理器內生成另一個回撥樹結構,在回撥時只需要對這個樹進行遍歷,並且可以進行剪枝優化,如果父節點沒有觸發,則這個父節點下的子元素都不需要遍歷,提高效能表現。
另外一個重點就是判定事件點是否在元素內,對於這個問題,已經有了許多成熟的演算法,如射線法:
時間複雜度:O(n) 適用範圍:任意多邊形
演算法思想:
以被測點 Q 為端點,向任意方向作射線(一般水平向右作射線),統計該射線與多邊形的交點數。如果為奇數,Q 在多邊形內;如果為偶數,Q 在多邊形外。
但是對於我們這個場景,除了圓角外都是矩形,而圓角處理起來會比較麻煩,因此初版都是使用矩形來進行判斷,後續再作為優化點改進。
按照這個思路就可以實現我們簡易的事件處理器。
class EventManager {
// ...
// 新增事件監聽
addEventListener(type, callback, element, isCapture) {
// ...
// 構造回撥樹
this.addCallback(callback, element, tree, list, isCapture);
}
// 事件觸發
_emit(e) {
const tree = this[`${e.type}Tree`];
if (!tree) return;
/**
* 遍歷樹,檢查是否回撥
* 如果父級沒有被觸發,則子級也不需要檢查,跳到下個同級節點
* 執行capture回撥,將on回撥新增到stack
*/
const callbackList = [];
let curArr = tree._getChildren();
while (curArr.length) {
walkArray(curArr, (node, callBreak, isEnd) => {
if (
node.element.isVisible() &&
this.isPointInElement(e.relativeX, e.relativeY, node.element)
) {
node.runCapture(e);
callbackList.unshift(node);
// 同級後面節點不需要執行了
callBreak();
curArr = node._getChildren();
} else if (isEnd) {
// 到最後一個還是沒監測到,結束
curArr = [];
}
});
}
/**
* 執行on回撥,從子到父
*/
for (let i = 0; i < callbackList.length; i++) {
if (!e.currentTarget) e.currentTarget = callbackList[i].element;
callbackList[i].runCallback(e);
// 處理阻止冒泡邏輯
if (e.cancelBubble) break;
}
}
// ...
}
事件處理器完成後,可以來實現一個scroll-view
了,內部實現原理是用兩個 view,外部固定寬高,內部可以撐開,外部通過事件處理器註冊事件來控制渲染的transform
值,需要注意的是,transform
渲染後,子元素的位置就不在原來的位置了,所以如果在子元素掛載了事件會偏移,這裡在scroll-view
內部註冊了相應的捕獲事件,當事件傳入scroll-view
內部後,修改事件例項的相對位置,來糾正偏移。
class ScrollView extends View {
// ...
constructor(options, children) {
// ...
// 內部再初始化一個scroll-view,高度自適應,外層寬高固定
this._scrollView = new View(options, [this]);
// ...
}
// 為自己註冊事件
addEventListener() {
// 註冊捕獲事件,修改事件的相對位置
this.eventManager.EVENTS.forEach((eventName) => {
this.eventManager.addEventListener(
eventName,
(e) => {
if (direction.match("y")) {
e.relativeY -= this.currentScrollY;
}
if (direction.match("x")) {
e.relativeX -= this.currentScrollX;
}
},
this._scrollView,
true
);
});
// 處理滾動
this.eventManager.addEventListener("mousewheel", (e) => {
// do scroll...
});
// ...
}
}
重排重繪
除了生成靜態佈局功能外,框架也有重繪重排的過程,當修改了節點的屬性後會觸發,內部提供了setStyle
,appendChild
等 api 來修改樣式或者結構,會根據屬性值來確認是否需要重排,如修改width
會觸發重排後重繪,修改backgroundColor
則只會觸發重繪,比如 scroll-view 滾動時,只是改變了 transform 值,只會進行重繪。
相容性
雖然框架本身不依賴 dom,直接基於CanvasRenderingContext2D
進行繪製,但是一些場景下仍需要作相容性處理,下面舉幾個例子。
- 微信小程式平臺繪製圖片 api 與標準不同,因此在 image 元件判斷了平臺,如果是微信則呼叫微信特定 api 進行獲取
- 微信小程式平臺設定字型粗細在 iOS 真機上不生效,內部判斷平臺後,會將文字繪製兩次,第二次在第一次基礎上進行偏移,形成加粗效果。
自定義渲染
雖然框架本身已經支援大部分場景的佈局,但是業務需求場景複雜多變,所以提供了自定義繪製的能力,即只進行佈局,繪製方法交給開發者自行呼叫,提供更高的靈活性。
engine.createElement((c) => {
return c("view", {
render(ctx, canvas, target) {
// 這裡可以獲取到ctx以及佈局資訊,開發者繪製自定義內容
},
});
});
web 框架中使用
雖然 api 本身相對簡單,但是仍然需要寫一些重複的程式碼,結構複雜的時候不便於閱讀。
當在現代 web 框架中使用時,可以採用相應的框架版本,比如 vue 版本,內部會將 vue 節點轉換為 api 呼叫,使用起來會更易於閱讀,但是需要注意,由於內部會有節點轉換過程,相比直接使用會有效能損耗,在結構複雜時差異會較明顯。
<i-canvas :width="300" :height="600">
<i-scroll-view :styles="{height:600}">
<i-view>
<i-image
:src="imageSrc"
:styles="styles.image"
mode="aspectFill"
></i-image>
<i-view :styles="styles.title">
<i-text>Hello World</i-text>
</i-view>
</i-view>
</i-scroll-view>
</i-canvas>
除錯
鑑於業務場景比較簡單,框架目前提供的除錯工具還比較基礎,通過設定debug
引數可以開啟節點佈局的除錯,框架會將所有節點的佈局繪製出來,如果需要檢視單個節點的佈局,需要通過掛載事件後列印到控制檯進行除錯。後續核心功能完善後會提供更全面的視覺化除錯工具。
成果
經過親身體驗,在一般頁面的開發效率上,已經與寫 html 不相上下,這裡為了展示成果,我寫了一個簡單的元件庫 demo 頁。
效能
框架在經過幾次重構後已經取得了不錯的表現,效能表現如下
已經做了的優化:
- 遍歷演算法優化
- 資料結構優化
scroll-view 重繪優化
- scroll-view 重繪只渲染範圍內的元素
- scroll-view 可視範圍外的元素不會渲染
- 圖片例項快取,雖然有 http 快取,但是對於同樣的圖片會產生多個例項,內部做了例項快取
待優化:
- 可中斷渲染,由於我們已經實現了類似
Fiber
結構,所以後續有需要加上這個特性也比較方便 - 前處理器還需要增強,增強對於使用者輸入的樣式與結構的相容,增強健壯性
總結
從最初想實現一個簡單的圖片渲染功能,最後實現了一個簡易的 canvas 排版引擎,雖然實現的 feature 有限並且還有不少細節與 bug 需要修復,但是已經具有基本的佈局以及互動能力,其中還是踩了不少坑,重構了很多次,同時也不禁感嘆瀏覽器排版引擎的強大。並且從中也體會到了演算法與資料結構的魅力,良好的設計是效能高、維護性佳的基石,也獲得不少樂趣。
另外這種模式經過完善後個人覺得還是有不少想象力,除了簡單的圖片生成,還可以用於 h5 遊戲的列表佈局、海量資料的表格渲染等場景,另外後期還有一個想法,目前社群渲染這塊已經有很多做的不錯的庫,所以想將佈局以及計算換行、圖片縮放等功能獨立出來一個單獨的工具庫,通過整合其他庫來進行渲染。
本人表達能力有限,可能還是有很多細節沒有得到澄清,也歡迎大家評論交流。
感謝閱讀
本文釋出自 網易雲音樂大前端團隊,文章未經授權禁止任何形式的轉載。我們常年招收前端、iOS、Android,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!