開發適用於微信小程式的跨平臺圖表庫:part1

xch89820發表於2019-03-01

寫在前面

微信小程式出來已經有一段時間了,github上也有很多人開源了很多專案。但是由於微信平臺的限制(底層Canvas能力呼叫為一系列JSBridge封裝),圖表的製作一直是個比較頭疼的問題。當前的成熟圖表庫無法正常執行,即使經過改造,效能也有很大問題。

當前已經有做的比較好的圖表庫,如xiaolin3303的 wx-charts,但是隻是為小程式量身定做的,在普通的H5頁面就無法執行。因此,在開發中萌生了寫一個多環境下相容的圖形庫的念頭。

這個圖形庫已經完成基本的圖形建設,已經支援PC瀏覽器,WAP,React以及微信小程式等環境。並上傳至github,大家可以自由使用:wx-chart,並歡迎在Issues中提出BUG以及修改建議。

實現效果

先做個推廣,貼一個當前的實現效果

工程結構

wx-chart 圖形庫參考了眾多圖形庫的構建,分為三層,如下圖所示:

程式結構
程式結構

最下方一層為相容層,其中最主要的是 WxCanvas 和 WxCanvasRenderingContext2D
用於相容瀏覽器以及微信小程式的環境。在本章節中,一會兒會展開講解實現原理。
中間層為基礎元件以及動畫庫,最上層為實現的各種圖形。這兩部分會在後續的章節中再另行闡述。

WxCanvas & WxCanvasRenderingContext2D

首先,我們實現這個中間層的目的很明確,就是完成一套與W3C Canvas一致的介面層,忽略小程式的底層介面,並使得上層呼叫時與普通Canvas使用方式一致。

小程式的架構是類似於React Native的JSBridge混合開發模式,因此其Canvas繪圖實現也很有侷限性,與普通的瀏覽器對比,主要有四個不同點:

  • 節點和Canvas物件建立:小程式的Canvas節點(嚴格意義上小程式沒有節點的概念)id的標識屬性為canvas-id,其建立JS物件使用wx.createCanvasContext方法
  • 屬性賦值不同:在W3C的規範中,繪製的屬性都是賦值形式的,例如:
      ctx.fillStyle = '#ffffff';
      ctx.font = '40px Arial';複製程式碼
    而小程式全部是呼叫形式的:
      ctx.setFillStyle('#ffffff');
      ctx.setFontSize(20);複製程式碼
  • 每次繪圖後需要呼叫 draw 方法完成渲染
  • 有些Canvas的能力與原生的引數有區別,如shadowBlur,shadowOffsetX均要呼叫wx.setShadow;有些能力並沒有實現,例如lineDash屬性,clip剪下能力等。

其中,以上這些基本也都是由於其JSBridge的實現機理導致的,有興趣的同學可以Google一下具體瞭解。

那麼,問題來了,我們的中間層能夠解決上面那些問題?實現思路是什麼?
第一個問題很簡單,我們宣告節點同時書寫idcanvas-id,而其他三個問題我的解決思路是:

  • 能夠檢測小程式環境
  • 與W3C標準一樣,實現Canvas & CanvasRenderingContext2D 兩個類:我們稱其為WxCanvas & WxCanvasRenderingContext2D
  • 使用Object.defineProperty將呼叫轉換為賦值
  • 當呼叫有區別和沒有實現的函式時,自動處理。

檢測小程式環境

export function checkWX() {
    return __GLOBAL__DEBUG__WX__ === true || (typeof wx != 'undefined' && typeof wx === 'object');
}複製程式碼

忽略__GLOBAL__DEBUG__WX__,此變數用於除錯。我們簡單檢測 wx 全域性是否存在。程式碼

實現相容類

WxCanvas主要邏輯如下:

class WxCanvas {
    constructor(id, config, contextOptions) {
        let me = this;

        // 檢查小程式環境
        me.isWeiXinAPP = checkWX();
        me._config = extend({}, WX_CANVAS_DEFAULT_PROPERTY, me.initConfig(config));

        // 提前獲取Canvas與context(這裡忽略了webgl的情況)
        let {canvas, context} = this.acquireContext(id, config);

        me._canvas = canvas;
        me._ctx = context;
        // 例項化WxCanvasRenderingContext2D
        me.wxCanvasRenderingContext2D = new WxCanvasRenderingContext2D(canvas, context, contextOptions);

        return me;
    }
    /**
     * Create Canvas context
     * @param {String} id
     * @param {Object} config
     * @returns {*}
     */
    acquireContext(id, config) {
        let me = this,
            canvas,
            context;
        // Outer canvas config
        let handlerCanvas = config.canvas;

        if (me.isWeiXinAPP) {
            if (is.String(id)) {
                canvas = context = wx.createCanvasContext(id);
            } else { //... }
        } else {
            if (handlerCanvas) canvas = handlerCanvas;
            else
                canvas = is.String(id) ? document.getElementById(id) :
                    (typeof HTMLCanvasElement != 'undefined' && id instanceof HTMLCanvasElement) ?
                        id:
                        null;
            if (typeof canvas != 'undefined') {
                context = canvas.getContext && canvas.getContext('2d');
            }
        }

        // ...
        return {canvas, context};
    }
    getContext(str){
    }
    set height() {...}
    get height() {...}
    set width() {...}
    get width() {...}
}複製程式碼

WxCanvas的實現還是比較簡單的,主要注意的就是acquireContext方法,在這裡我們判斷了小程式與非小程式環境,並獲得Canvas上下文。

下面我們看下WxCanvasRenderingContext2D的實現。

class WxCanvasRenderingContext2D {
    constructor(canvas, context, options) {
        let me = this;

        me.canvas = canvas;
        me._ctx = context;
        me.isWeiXinAPP = checkWX();

        // 宣告canvas屬性快取棧,用於優化效能
        me._ctxOptions = options;
        me._propertyCache = [extend({}, WX_CANVAS_CTX_DEFAULT_PROPERTY, options)];
        me.cp = me._propertyCache[0];

        // 屬性賦值轉化
        me.createStyleProperty();
        me.createShadowsProperty();

        // 後續省略,我們就拿前兩個舉例足以...

        return me;
    }
    createStyleProperty() {
        let me = this;

        let styleProperty = ['fillStyle', 'strokeStyle'];
        styleProperty.forEach(p => {
            Object.defineProperty(me, p, {
                get: () => {
                    return me.cp[p];
                },
                set: (value) => {
                    if (value) {
                        return me._wxSetPropertyCallable(value.toLowerCase(), p)
                    }
                }
            })
        });
    }

    _wxSetPropertyCallable(value, propertyName, wxSetName = 'set' + propertyName.replace(/(\w)/, v => v.toUpperCase())) {
        let me = this;

        if (is.Null(value) || is.Undefined(value)) {
            return value;
        }

        // performance
        if (me.cp[propertyName] === value) {
            return value;
        }

        if (me.isWeiXinAPP) {
            me._ctx[wxSetName](value);
            me.cp[propertyName] = value;
        } else {
            me._ctx[propertyName] = value;
            me.cp[propertyName] = me._ctx[propertyName];
        }
        return value;
    }
    ...
    draw(ctu = true) {
        if (this.isWeiXinAPP) {
            this._ctx.draw(ctu);
        }
    }
}複製程式碼

上面的例子,我們只舉例 fillStyle, strokeStyle 兩個賦值屬性的相容轉換。可以看到,我們定義了通用方法_wxSetPropertyCallable,在小程式環境下,它會呼叫set[wxSetName](也就是setFillStyle和setStrokeStyle),而在普通環境下會正常的屬性賦值。

再來看我們的draw方法,同樣只在微信小程式中有效,但是遺憾的是每次繪製以後還是要呼叫draw方法一次,這是唯一的與W3C方法有區別的事情。不過,這主要是為了效能考慮,因為JSBridge每一次的呼叫都是有成本的,如果每次設定我們都自動的呼叫draw會大幅增加這個成本。

以上完成以後,我們就可以做到類似於W3C標準,在小程式中繪圖了:

正方形
正方形

import WxCanvas, { WxCanvasRenderingContext2D } from 'wx-chart/util/wxCanvas'

let wxCanvas = new WxCanvas('myCanvas', {width: '800px', height: '500px'});
let wxCanvasContext = wxCanvas.getContext('2d');
wxCanvasContext.fillStyle='#FF0000';
wxCanvasContext.fillRect(0,0,80,100);
wxCanvasContext.draw();複製程式碼

一些遇到的問題

在實現相容庫時,有幾個比較棘手的問題。
首先是小程式並沒有實現 measureText 方法,因此我們需要模擬一個:

measureText(text, fontSize = this.fontSize) {
        let me = this;
        if (me.isWeiXinAPP) {
            if (!text) { return 0; }
            let textLen = text.length;
            let hanzi = text.match(new RegExp(REG_HANZI, 'g'));
            let hanziNum = !!hanzi ? hanzi.length: 0;
            let otherNum = textLen - hanziNum;

            return {'width': fontSize * (otherNum + hanziNum * 2) / 2 + fontSize / 4};
        } else {//...}
    }複製程式碼

我們對漢字和非漢字進行了處理。不過,需要承認的是,即便是這樣,也與實際有一些不準。

另外,textBaseline,textAlign 也需要特殊處理(當前1.1版本已經支援)

結束

由於時間和和篇幅有限,第一段文章就先寫到這裡。有錯誤或者不完善的情況請多多諒解和支援。也換用大家使用wx-chart 圖形庫並提出意見和建議。

相關文章