我們的業務涉及電商、教育行業,出於營銷以及功能需要,會有很多卡片展示(長按保存)的需求,或者分享長圖的需求。以及我們有面向商家的PC端,商家端又能編輯、實時預覽卡片的樣式。
同樣的卡片內容我們需要在兩端以兩種框架(vue react)分別維護。
考慮到依賴太大(ungzipped 160kb+)、穩定性、可維護性、可拓展性等因素,我們沒有採用 html2canvas 這個第三方轉換庫。而是採用抽離一系列 canvas-utils的方式進行 canvas 畫圖。
canvas-utils
因為 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 再進行畫圖操作。
採用這種方式畫海報能實現基本需求,但也有一定局限性。
draw***
那麼,如何改善這些問題,在前端更優雅地畫海報呢?
不使用 html2canvas 還有個原因是該庫基於 htmlElement,公司現狀下 jsx 和 vue 模板語法不兼容,無法復用代碼片段,還有個更重要的原因是小程序沒法用,那麼採用什麼類型的 schema 去收斂 api,以及最大化在不同平台兼容?
這裡採用了 json 的形式去配置化參數生成圖片。
基礎 schema:
{ type: , css: {}, custom: null, // 自定義回調 }
之前的核心 drawImage drawParagraph drawRoundedRectangle 方法目的就是繪製 圖片、文字、容器,對於這三個類型分別有不同的額外配置,需要不同的更具語義化的 schema。
drawImage drawParagraph drawRoundedRectangle
圖片:
{ 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,共同構建一顆完整的節點樹。
div
children
用 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 的類庫都是這樣設計,但這樣並不能解決我們提到的幾個局限性。
比如說下圖的樣式,橫向布局,有不同的文字大小以及樣式,而且文字的個數還是自定義的:
這三個節點我們都要實時計算 width height x y,再傳入 css 欄位,工作量還是巨大的。
width height x y
既然我們的 schema 在描述圖片結構上(嵌套)的向 html 靠齊,那麼我們 css 欄位 的 schema 為什麼不向真實的 css 靠齊?
藉助 margin 塊狀流式布局,藉助 inline-block 橫向布局,將之前的絕對定位改成 css 默認的 相對定位,模擬 css 的能力。
margin
inline-block
更重要的是模擬實現 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 的能力,那 css schema 的定義也就要參照 css2.1 規範進行,我們定義的 css schema 是 css2.1 規範的子集。
那我們去尋找規範中有哪幾個集合是適用我們的 case。
https://www.w3.org/TR/CSS2/box.html#box-model
涉及到盒模型相關的 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; }
https://www.w3.org/TR/CSS2/visuren.html#q9.0
可視格式化模型也是 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; }
https://www.w3.org/TR/CSS2/colors.html#q14.0
用來描述顏色和背景
export interface IColorAndBg { color: string; backgroundColor: string; }
https://www.w3.org/TR/CSS2/fonts.html#q15.0
用來描述單個文字的具體樣式,大小、字體等。
export interface IFonts { lineHeight: string | undefined; // line-height 應該屬於 visual formatting model,但與傳統的 css 不太一樣,我們規定在無法在 div 中寫文字 fontStyle: string; fontFamily: string; fontWeight: number; fontSize: string; }
https://www.w3.org/TR/CSS2/text.html#q16.0
與 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);
因為 schema 允許部分欄位不傳,所以第一步遞歸遍歷傳入的數據源,將默認值賦值給入參。
如圖所示,setInlineBlock 方法會將連續排列的 inline-block 節點聚合,新建一個空白的 div 插入原先的位置,然後將這些 inline-block節點作為 children 插入其中,這樣做的目的在於方便後面的 width height 計算。
遍歷所有節點,如果發現是有 children 的 div,則繼續遞歸遍歷。
模擬原生 css 特性,如果當前節點設置了 width,則取當前寬,否則取父節點計算完的寬。
當然還有許多 css 屬性會影響到 width 最終的計算,比如 minWidth maxWidth,又比如子節點元素是否都是 inline-block。
再比如當前的 type 為 text,而且又沒有設置 width,這裡就得調用 canvas 提供的 ctx.measureText(content).width; 去獲取 width。
ctx.measureText(content).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 了。
與計算寬度大同小異,這裡不再贅述。
既然已經計算得出所有節點的尺寸信息,同樣遞歸遍歷所有的節點,以父節點為基準就能計算得到所有子節點的位置信息。
const images = canvasWrap.getImages(originConfig);
images.then(imgMap => { resolve(canvasWrap.drawCanvas(originConfig, imgMap)); })
得到所有節點的位置、尺寸信息,再結合統一 load 的圖片信息,最後就可以使用 canvas-utils 中的繪製方法,進行圖片繪製了。
最後再提一下定義 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 設定 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 設置成原寬高。
window.devicePixelRatio
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]; };
使用 ctx.fillText(content, x, y); 繪製段落時,y 的定位並不在文字的下方。
ctx.fillText(content, x, y);
比如我們繪製兩條 y 分別為 10 24 的直線,再繪製 y 為 24 的文字:
原因是 canvas 繪製文字有自己的基準規則
默認文字的基準線就是偏下,這裡做過實驗,在不同系統設備上各個基準都不太一樣,包括 bottom ideographic,唯獨 middel 的樣式在各個平台上表現是一致的。
bottom ideographic
middel
所以這裡有個取巧的方法,可以使文字是上下居中的。
ctx.textBaseline = middle; // 適配安卓 ios 下的文字居中問題
ctx.save(); ctx.translate(0, -(fontSize / 2)); // 適配安卓 ios 下的文字居中問題 ctx.fillText(content, x, y); ctx.restore();
先將文字基準線居中,再在繪製文字的時刻改變坐標系,畫完後改變成原來的坐標系。
這套畫圖庫的效果其實很類似 html2canvas 這個類庫了,但是 json2canvas 的形式其實還有其他可以想像的空間。
html2canvas
json2canvas
比如
<Div stylex={}> <Text stylex={}> <Image stylex={}>
推薦閱讀: