咱們的業務涉及電商、教育行業,出於營銷以及功能須要,會有不少卡片展現(長按保存)的需求,或者分享長圖的需求。以及咱們有面向商家的PC端,商家端又能編輯、實時預覽卡片的樣式。javascript
一樣的卡片內容咱們須要在兩端以兩種框架(vue react)分別維護。css
考慮到依賴太大(ungzipped 160kb+)、穩定性、可維護性、可拓展性等因素,咱們沒有采用 html2canvas 這個第三方轉換庫。而是採用抽離一系列 canvas-utils
的方式進行 canvas 畫圖。html
由於 canvas 原生的繪圖 api 都是以絕對定位的像素點,再輔以尺寸信息進行繪製。前端
好比:vue
ctx.rect(x, y, width, height); // 畫矩形
ctx.drawImage(img, destx, desty, destWidth, destHeight); // 畫圖片
複製代碼
因此咱們定義的 canvas-utils 入參也必須包含這些位置、尺寸信息。java
/** * 繪製圓角矩形 * * @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,只須要在設計稿上量好尺寸以及位置,就能將對應的元素絕對定位到畫布上。react
大概業務中的實現(僞代碼):ios
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 再進行畫圖操做。typescript
採用這種方式畫海報能實現基本需求,但也有必定侷限性。json
好比:
draw***
方法,傳類似的參數,這也是冗餘操做,採用 json 配置參數會不會更好?那麼,如何改善這些問題,在前端更優雅地畫海報呢?
不使用 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 的能力,那 css schema 的定義也就要參照 css2.1 規範進行,咱們定義的 css schema 是 css2.1 規範的子集。
那咱們去尋找規範中有哪幾個集合是適用咱們的 case。
涉及到盒模型相關的 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';
}
複製代碼
可視格式化模型也是 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';
}
複製代碼
用來描述顏色和背景
export interface IColorAndBg {
color: string;
backgroundColor: string;
}
複製代碼
用來描述單個文字的具體樣式,大小、字體等。
export interface IFonts {
lineHeight: string | undefined; // line-height 應該屬於 visual formatting model,但與傳統的 css 不太同樣,咱們規定在沒法在 div 中寫文字
fontStyle: string;
fontFamily: string;
fontWeight: number;
fontSize: string;
}
複製代碼
與 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。
計算完的 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 設置成原寬高。
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 的定位並不在文字的下方。
好比咱們繪製兩條 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();
複製代碼
先將文字基準線居中,再在繪製文字的時刻改變座標系,畫完後改變成原來的座標系。
這套畫圖庫的效果其實很相似 html2canvas
這個類庫了,可是 json2canvas
的形式其實還有其餘能夠想象的空間。
好比
<Div style={}> <Text style={}> <Image style={}>
,而後就能夠像寫 html 同樣去寫 canvas。這也相似 html2canvas 的寫法。