更優雅地基於 canvas 在前端畫海報

舊狀

咱們的業務涉及電商、教育行業,出於營銷以及功能須要,會有不少卡片展現(長按保存)的需求,或者分享長圖的需求。以及咱們有面向商家的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

好比:

  • 畫圖前須要先 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

www.w3.org/TR/CSS2/box…

涉及到盒模型相關的 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

www.w3.org/TR/CSS2/vis…

可視格式化模型也是 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

www.w3.org/TR/CSS2/col…

用來描述顏色和背景

export interface IColorAndBg {
  color: string;
  backgroundColor: string;
}
複製代碼

Fonts

www.w3.org/TR/CSS2/fon…

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

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

Text

www.w3.org/TR/CSS2/tex…

與 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 style={}> <Text style={}> <Image style={}>,而後就能夠像寫 html 同樣去寫 canvas。這也相似 html2canvas 的寫法。
相關文章
相關標籤/搜索