舊狀

我們的業務涉及電商、教育行業,出於營銷以及功能需要,會有很多卡片展示(長按保存)的需求,或者分享長圖的需求。以及我們有面向商家的PC端,商家端又能編輯、實時預覽卡片的樣式。

同樣的卡片內容我們需要在兩端以兩種框架(vue react)分別維護。

考慮到依賴太大(ungzipped 160kb+)、穩定性、可維護性、可拓展性等因素,我們沒有採用 html2canvas 這個第三方轉換庫。而是採用抽離一系列 canvas-utils的方式進行 canvas 畫圖。

因為 canvas 原生的繪圖 api 都是以絕對定位的像素點,再輔以尺寸信息進行繪製。

比如:

ctx.rect(x, y, width, height); // 畫矩形
ctx.drawImage(img, destx, desty, destWidth, destHeight); // 畫圖片

所以我們定義的 canvas-utils 入參也必須包含這些位置、尺寸信息。

/**
* 繪製圓角矩形
*
* @param {*} ctx 畫布
* @param {Number} radius 半徑
* @param {Number} x 左上角
* @param {Number} y 左上角
* @param {Number} width 寬度
* @param {Number} height 高度
* @param {String} color 顏色
* @param {String} mode 填充模式
* @param {Function} fn 回調函數
*/
export function drawRoundedRectangle() {}

/**
* 繪製圖片(方、圓角、圓)
*
* @param {*} ctx 畫布
* @param {*} img load好的img對象
* @param {Number} x 左上角定點 x 軸坐標
* @param {Number} y 左上角定點 y 軸坐標
* @param {Number} w 寬
* @param {Number} h 高
* @param {Number} radius 圓角半徑
*/
export function drawImage() {}

/**
* 繪製多行片段
*
* @param {*} ctx 畫布
* @param {*} content 內容
* @param {*} x 繪製左下角原點 x 坐標
* @param {*} y 繪製左下角原點 y 坐標
* @param {*} maxWidth 最大寬度
* @param {*} fontSize 字體大小
* @param {*} fontFamily 字體家族
* @param {*} color 字體顏色
* @param {*} textAlign 字體排布
* @param {*} lineHeight 設置行高
* @param {*} maxLine 最大行數
*/
export function drawParagraph() {}

/**
* 創建一個畫布
*
* @param {*} width 寬
* @param {*} height 高
* @return {*} canvasAndCtx 畫布相關信息
*/
export function initCanvasContext(width, height) {
return [canvas, ctx];
}

這四個核心方法涵蓋了幾乎所有海報畫圖類需求,圖片、段落文字、背景容器、畫布創建。並且已經把 canvas 相關的 api 收攏了,開發者無需關注惱人的 canvas api,只需要在設計稿上量好尺寸以及位置,就能將對應的元素絕對定位到畫布上。

大概業務中的實現(偽代碼):

Promise.all([
canvasUtils.loadUrlImage(mainCoverImg),
canvasUtils.loadBase64Image(cardInfo.qrCode),
])
.then(([cover, qrCode, shopnameIcon, titleIcon]) => {
const [canvas, ctx] = canvasUtils.initCanvasContext(325, 564);

// 繪製底框
canvasUtils.drawRoundedRectangle(ctx, ...sizeMapValue.base);

// 繪製封面圖
canvasUtils.drawImage(ctx, ...sizeMapValue.cover);

// 繪製標題
canvasUtils.drawParagraph(ctx, ...sizeMapValue.title);

// 繪製題數
canvasUtils.drawImage(ctx, ...sizeMapValue.titleIcon);

// ...

return canvas.toDataURL(image/png);
})

因為圖片的入參是個 img 對象,需要先 load 圖片鏈接,這裡就有個非同步的過程,所以設計之初就規定先 Promise.all 所有圖片拿到 img 再進行畫圖操作。

採用這種方式畫海報能實現基本需求,但也有一定局限性。

比如:

  • 畫圖前需要先 load 圖片地址,涉及非同步,這是比較冗餘的操作
  • 一直調 draw*** 方法,傳相似的參數,這也是冗餘操作,採用 json 配置參數會不會更好?
  • 如果生成圖片的高度需要自適應多個子元素的高度?這需要寫很多額外邏輯。
  • 如果兩種不同樣式的文字橫向居中顯示?又要瘋狂的計算再傳入 x y 定位,總之涉及到自適應樣式的需求我們就得在邏輯中頻繁的計算。

那麼,如何改善這些問題,在前端更優雅地畫海報呢?

如何定義 schema

不使用 html2canvas 還有個原因是該庫基於 htmlElement,公司現狀下 jsx 和 vue 模板語法不兼容,無法復用代碼片段,還有個更重要的原因是小程序沒法用,那麼採用什麼類型的 schema 去收斂 api,以及最大化在不同平台兼容?

這裡採用了 json 的形式去配置化參數生成圖片。

基礎 schema:

{
type: ,
css: {},
custom: null, // 自定義回調
}

之前的核心 drawImage drawParagraph drawRoundedRectangle 方法目的就是繪製 圖片、文字、容器,對於這三個類型分別有不同的額外配置,需要不同的更具語義化的 schema。

圖片:

{
type: image,
css: {},
url: ,
mode: fill | contain,
custom: null,
};

文字:

{
type: text,
css: {},
text: ,
custom: null,
};

容器:

{
type: div,
css: {},
mode: div | line,
children: [],
custom: null,
}

type 為 div 類型的 schema 相當於是個容器,具有 children 欄位,與 html 中的 div 概念也類似,div 可以嵌套承載更多的 div、text、image,共同構建一顆完整的節點樹。

用 json schema 去描述一張卡片的偽代碼:

{
type: div,
css: {},
children: [
{
type: div,
css: {},
children: [
{
type: text,
css: {},
text: 文字一
},
{
type: image,
css: {},
url: cdn.image.com/test1,
mode: contain
}
]
},
{
type: text,
css: {},
text: 好多文字 好多文字 好多文字
},
]
}

使用 json schema 去描述視圖,已經解決了之前 canvas-utils 方案的幾個局限性。

畫圖前需要先 load 圖片地址,涉及非同步,這是比較冗餘的操作

傳入給 image 的是 url 地址或者是 base64字元串,load 圖片的操作會在內部實現,外部無需關心。

一直調 draw*** 方法,傳相似的參數,這也是冗餘操作,採用 json 配置參數會不會更好?

所有的方法調用被 type 替代,原先必傳的 尺寸、位置信息

canvasUtils.drawParagraph(ctx, cardInfo.title, 14, 380, 285, 14, undefined, undefined, undefined, 20, 2);

被 css 欄位代替:

{
type: text,
css: {
width: 285px,
height: 14px,
x: 14px,
y: 380px,
...
},
text: cardInfo.title,
custom: null,
};

絕對定位的布局系統的缺陷

現在的 schema 定義在實現的功能上跟之前的 canvas-utils 本質上沒什麼區別,只是簡化了使用姿勢,所有的節點都是按照絕對定位,我們需要手動傳入所有節點的尺寸信息(width height)以及位置信息(x y),現在市面是幾乎所有類似 jsonToCanvas 的類庫都是這樣設計,但這樣並不能解決我們提到的幾個局限性。

  • 如果生成圖片的高度需要自適應多個子元素的高度?這需要寫很多額外邏輯。
  • 如果兩種不同樣式的文字橫向居中顯示?又要瘋狂的計算再傳入 x y 定位,總之涉及到自適應樣式的需求我們就得在邏輯中頻繁的計算。

比如說下圖的樣式,橫向布局,有不同的文字大小以及樣式,而且文字的個數還是自定義的:

這三個節點我們都要實時計算 width height x y,再傳入 css 欄位,工作量還是巨大的。

既然我們的 schema 在描述圖片結構上(嵌套)的向 html 靠齊,那麼我們 css 欄位 的 schema 為什麼不向真實的 css 靠齊?

藉助 margin 塊狀流式布局,藉助 inline-block 橫向布局,將之前的絕對定位改成 css 默認的 相對定位,模擬 css 的能力。

更重要的是模擬實現 css屬性 的強大繼承能力,這樣我們在定義某個節點的 css 屬性時,就不用把各種屬性再寫一遍,直接依賴父節點css屬性的繼承。

暴露給用戶使用的 schema 需要足夠智能,把需求計算的需求在組件內部吃掉。

原本的定義:

{
"type": "div",
"css": {
"width": "200px",
"height": "200px",
"x": "0px",
"y": "0px",
},
"children": [
{
"type": "text",
"css": {
"width": "動態計算",
"height": "動態計算",
"x": "動態計算",
"y": "動態計算",
"fontSize": "12px"
},
"text": "自定義文案:"
},
{
"type": "text",
"css": {
"width": "動態計算",
"height": "動態計算",
"x": "動態計算",
"y": "動態計算",
"fontSize": "16px",
"color": "red"
},
"text": "我後面跟這張圖片"
},
{
"type": "image",
"css": {
"width": "15px",
"height": "15px",
},
"url": "https://su.yzcdn.cn/public_files/2018/12/14/61d0dad50c5b2789a0232c120ae5f7fa.jpg",
"mode": "contain"
}
]
}

更智能的定義:

{
"type": "div",
"css": {
"width": "200px",
"height": "200px",
},
"children": [
{
"type": "text",
"css": {
"display": "inline-block",
"marginTop": "3px",
},
"text": "自定義文案:"
},
{
"type": "text",
"css": {
"display": "inline-block",
"fontSize": "16px",
"color": "red"
},
"text": "我後面跟這張圖片"
},
{
"type": "image",
"css": {
"width": "15px",
"height": "15px",
"display": "inline-block"
},
"url": "https://su.yzcdn.cn/public_files/2018/12/14/61d0dad50c5b2789a0232c120ae5f7fa.jpg",
"mode": "contain"
}
]
}

我們可以看到優化後的版本並不需要指定文字的寬度高度,也不用指定圖片的位置信息,就跟寫原生 css html 一致。

優化 css schema 來處理動態尺寸的需求

既然要靠齊 css 的能力,那 css schema 的定義也就要參照 css2.1 規範進行,我們定義的 css schema 是 css2.1 規範的子集。

那我們去尋找規範中有哪幾個集合是適用我們的 case。

box model

w3.org/TR/CSS2/box.html#

涉及到盒模型相關的 css 屬性

export interface IBoxModel {
marginLeft: string;
marginRight: string;
marginTop: string;
marginBottom: string;
borderWidth: string;
borderColor: string;
borderStyle: solid | dashed;
borderRadius: string | undefined;
boxShadow: string | undefined;
customVerticalAlign: down | top | center;
customAlign: left | right | center;
}

visual formatting model

w3.org/TR/CSS2/visuren.

可視格式化模型也是 css 規範中除了 盒模型(box model)外最為重要的模型,他描述了基於盒模型的元素是如何排列在可視化窗口中的,比如 position 來描述是絕對定位還是相對定位。display: block | inline-block 用來描述縱向排列還是橫向排列。

摘取部分需要的屬性:

export interface IVisFormatModel {
width: string;
height: string;
maxWidth: string | undefined;
maxHeight: string | undefined;
minWidth: string;
minHeight: string;
position: absolute | relative;
top: string | undefined;
left: string | undefined;
right: string | undefined;
bottom: string | undefined;
display: block | inline-block;
}

Colors and Backgrounds

w3.org/TR/CSS2/colors.h

用來描述顏色和背景

export interface IColorAndBg {
color: string;
backgroundColor: string;
}

Fonts

w3.org/TR/CSS2/fonts.ht

用來描述單個文字的具體樣式,大小、字體等。

export interface IFonts {
lineHeight: string | undefined; // line-height 應該屬於 visual formatting model,但與傳統的 css 不太一樣,我們規定在無法在 div 中寫文字
fontStyle: string;
fontFamily: string;
fontWeight: number;
fontSize: string;
}

Text

w3.org/TR/CSS2/text.htm

與 Fonts 不同,這個規範是為了描述文字之前的排列行為,比如對其方式,是否有中劃線等。

export interface IText {
textAlign: left | right | center;
lineClamp: number | undefined; // 不在 css2.1 規範內,方便描述幾行文字攔截展示 【...】
textDecoration: line-through | undefined;
}

畫圖庫的實現過程,計算盒模型

不管我們的 css schema 定義的如何對用戶友好,在組件內部最終調用 canvas api 的時候我們還是需要傳入絕對定位的尺寸以及位置。

定義好了元素類型的 schema 以及 css 的 schema,需要實現的就是在組件內部根據節點的 css屬性 計算各個節點的盒模型尺寸,再由最終的盒模型數據,繪製出最終的 canvas。

整體流程:

根據 css 計算得到盒模型數據,是畫圖庫代碼量最大的步驟。以下就是計算盒模型的計算流程。

const defaultConfig = canvasWrap.setDefault(copyConfig);

const inlineBlockConfig = canvasWrap.setInlineBlock(defaultConfig);

const widthConfig = canvasWrap.addWidth(inlineBlockConfig);

const heightConfig = canvasWrap.addHeight(widthConfig);

const originConfig = canvasWrap.addOrigin(heightConfig);

setDefault 設置默認值

因為 schema 允許部分欄位不傳,所以第一步遞歸遍歷傳入的數據源,將默認值賦值給入參。

setInlineBlock 將 inline-block 的元素修改結構

如圖所示,setInlineBlock 方法會將連續排列的 inline-block 節點聚合,新建一個空白的 div 插入原先的位置,然後將這些 inline-block節點作為 children 插入其中,這樣做的目的在於方便後面的 width height 計算。

addWidth 計算所有節點的寬

遍歷所有節點,如果發現是有 children 的 div,則繼續遞歸遍歷。

模擬原生 css 特性,如果當前節點設置了 width,則取當前寬,否則取父節點計算完的寬。

當然還有許多 css 屬性會影響到 width 最終的計算,比如 minWidth maxWidth,又比如子節點元素是否都是 inline-block。

再比如當前的 type 為 text,而且又沒有設置 width,這裡就得調用 canvas 提供的 ctx.measureText(content).width; 去獲取 width。

計算完的 width 會結合 margin,border 等 css 屬性再次計算各種盒模型寬。

const sumWidth = calRealdemension(sumWidth, [css.minWidth, css.maxWidth]);
const layerWidth = sumPixels(sumWidth, marginWidth);
const contentWidth = minusPixels(sumWidth, addedBorderWidth);

addBoxWidth(element, sumWidth);
addLayerWidth(element, layerWidth);
addContentWidth(element, contentWidth);

這裡會將計算完的數據直接賦值給當前 config 對象,這樣在遞歸到下一層 children 時就可以直接使用父節點 width 了。

addHeight 計算所有節點的高

與計算寬度大同小異,這裡不再贅述。

addOrigin 計算所有節點的位置

既然已經計算得出所有節點的尺寸信息,同樣遞歸遍歷所有的節點,以父節點為基準就能計算得到所有子節點的位置信息。

繪製 canvas 圖片

const images = canvasWrap.getImages(originConfig);

images.then(imgMap => {
resolve(canvasWrap.drawCanvas(originConfig, imgMap));
})

得到所有節點的位置、尺寸信息,再結合統一 load 的圖片信息,最後就可以使用 canvas-utils 中的繪製方法,進行圖片繪製了。

自定義插槽 custom

最後再提一下定義 schema 時預留的 custom 欄位,可以傳回調函數進去,暴露出來的參數為 ctx,用來調用 canvas 繪製 api,以及該節點的盒模型數據,這樣用戶就能知道當前節點的範圍。

custom(canvas, ctx, config) {
ctx.beginPath();
ctx.moveTo(config.origin.x, config.origin.y);
ctx.lineTo(50, 40);
ctx.stroke();
},

canvas 繪圖的注意點

生成圖片模糊問題

當我們直接給 canvas 設定 width,height 時,比如

<canvas width_="200" height="200"></canvas>

這實際告訴瀏覽器的是以點陣圖(bitmap)的形式生成一張 200x200 物理像素點的畫布,我們可以直接看成是一張圖片。

如果沒有人為的用 css 指定這張畫布的邏輯寬高,那麼瀏覽器默認會設置成 200px x 200px。

我們可以直接想像成將一張 200x200 的點陣圖,以 css 200x200 設置。這就相當於前端工程師熟知的高解析度下 2 倍圖優化問題。

解決方式也就類似解決 2 倍圖問題,將 canvas 的寬高放大 n 倍(n 取決於 window.devicePixelRatio),css 設置成原寬高。

function initCanvasContext(width: number, height: number): [HTMLCanvasElement, CanvasRenderingContext2D] {
canvas.width = width * window.devicePixelRatio;
canvas.height = height * window.devicePixelRatio;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
return [canvas, ctx];
};

如何用 canvas 繪製文欄位落

使用 ctx.fillText(content, x, y); 繪製段落時,y 的定位並不在文字的下方。

比如我們繪製兩條 y 分別為 10 24 的直線,再繪製 y 為 24 的文字:

原因是 canvas 繪製文字有自己的基準規則

默認文字的基準線就是偏下,這裡做過實驗,在不同系統設備上各個基準都不太一樣,包括 bottom ideographic,唯獨 middel 的樣式在各個平台上表現是一致的。

所以這裡有個取巧的方法,可以使文字是上下居中的。

ctx.textBaseline = middle; // 適配安卓 ios 下的文字居中問題

ctx.save();
ctx.translate(0, -(fontSize / 2)); // 適配安卓 ios 下的文字居中問題
ctx.fillText(content, x, y);
ctx.restore();

先將文字基準線居中,再在繪製文字的時刻改變坐標系,畫完後改變成原來的坐標系。

Further

這套畫圖庫的效果其實很類似 html2canvas 這個類庫了,但是 json2canvas 的形式其實還有其他可以想像的空間。

比如

  • 可以直接通過 sketch 根據圖層直接生成匹配的 json 數據,而 json 數據是適配不同前端框架的。
  • 這個類庫的大部分實現是如何計算各個節點的盒模型尺寸位置,而這也是跟平台無關的,可以很快速的遷移至小程序中。小程序中僅僅兼容下畫圖 api 就可以了。
  • 如果在各個前端框架層覺得配置 json 不太直觀,可以在組件層創建幾個關鍵組件 <Div stylex={}> <Text stylex={}> <Image stylex={}>,然後就可以像寫 html 一樣去寫 canvas。這也類似 html2canvas 的寫法。

推薦閱讀:

相关文章