基於virtual dom 的canvas渲染

muwoo發表於2018-07-16

專案詳情

github 地址: github

demo例項:demo

基於virtual dom 的canvas渲染

背景

起初,在公司做一些活動頁的時候,經常需要用到截圖分享的千人千面的功能,而且這種需求並不止一兩次,而是經常會出現在各種各樣的截圖場景。第一次碰到這種需求的時候,基本上都會去手擼canvasAPI去做渲染功能,這種情況的步驟大致如下:

  1. 寫一大串 dom template 標籤
  2. 渲染templatedom標籤
  3. 開始捕捉dom元素,繪製canvas
  4. canvas 渲染圖片

面臨的主要問題是複用性太差,其次是效能上也有問題,使用者看到的介面不一定和正式渲染出的介面一致,可能存在渲染差異。 因為我工作中主要使用的是vue,對vue核心思想也有一定研究,vue通過vnode實現了對不同端的渲染工作,那有沒有可能通過vnode實現對canvas的渲染呢?也就是說,沒有vnode -> html -> canvas 而是直接vnode -> canvas。 同時利用vue的資料驅動,來達到繪製的資料驅動。想法有了,下面開始實施。

調研

這篇文章對此有詳細的介紹:60 FPS on the mobile web 這裡簡單的概括一下:

canvas是一種立即模式的渲染方式,不會儲存額外的渲染資訊。Canvas 受益於立即模式,允許直接傳送繪圖命令到 GPU。但若用它來構建使用者介面,需要進行一個更高層次的抽象。例如一些簡單的處理,比如當繪製一個非同步載入的資源到一個元素上時會出現問題,如在圖片上繪製文字。在HTML中,由於元素存在順序,以及 CSS 中存在 z-index,因此是很容易實現的。 dom渲染是一種保留模式,保留模式是一種宣告性API,用於維護繪製到其中的物件的層次結構。保留模式 API 的優點是,對於你的應用程式,他們通常更容易構建複雜的場景,例如 DOM。通常這都會帶來效能成本,需要額外的記憶體來儲存場景和更新場景,這可能會很慢。

開始!

canvas 的渲染其實也是一種嘗試,既然前人以及做了充分的實踐,那麼我們便站在巨人的肩膀上去基於vue來實現一個資料驅動的canvas渲染。說做就做!

處理vnode

熟悉Vue原始碼的應該都知道,Vue通過render函式,傳入createElement方法來構造出一個vnode,通過釋出--訂閱模式來實現對資料的監聽,重新生成vnode。我們要做的就是在vnode這一層開始。所以,我們基於Vue原始碼的方式,實現一個監聽函式,並混入Vue例項中:

Vue.mixin({
    // ...
    created() {
      if (this.$options.renderCanvas) {
        // ...
        // 監聽vnode中引用的變化,重新渲染
        this.$watch(this.updateCanvas, this.noop)
        // ...
      }
    },
    methods: {
      updateCanvas() {
        // 模擬Vue render 函式
        // 尋找例項中定義的 renderCanvas 方法,並傳入createElement方法
        let vnode = this.$options.renderCanvas.call(this._renderProxy, this.$createElement)
      }
})
複製程式碼

這樣我們就可以愉快的在元件內部使用:

renderCanvas (h) {
  return h(...)
}
複製程式碼

canvas 元素處理

render的vnode我們需要做額外的一些約束,比如domdiv 應該對應canvas裡面的什麼,dom裡面的文字,對應canvas裡面的什麼... 也就是說我們可以這樣做一些約束:

自定義標籤 繪製形式 類比dom
view/scrollView/scrollItem rect div
text text span
image img img

其中這些元素類分別都繼承於一個Super類,並且由於它們各有不同的展示方式,因此它們分別實現自己的draw方法,做定製化的展示。

繪製物件的佈局機制實現

繪製 canvas 佈局最基礎的寫法是為canvas 元素傳入一系列座標點和相關的基礎寬高,這樣寫到實際專案中可能是這樣的:

renderCanvas(h) {
  return h('view', {
     style: {
       left: 10,
       top: 10,
       width: 100,
       height: 100
     }
  })
}
複製程式碼

這樣寫確實有點不方便維護,目前有好幾種解決方案,一種是使用css-layout去做管理。css-layout支援的轉換屬性如下:

image

這樣也只是做了一層轉換,幫我們更好的用css思維去寫canvas,但是如果我們很不爽css in js的寫法,其實我們還可以寫一個webpack loader 來載入外部css:

const css = require('css')
module.exports = function (source, other) {
  let cssAST = css.parse(source)
  let parseCss = new ParseCss(cssAST)
  parseCss.parse()
  this.cacheable();
  this.callback(null, parseCss.declareStyle(), other);
};

class ParseCss {
  constructor(cssAST) {
    this.rules = cssAST.stylesheet.rules
    this.targetStyle = {}
  }

  parse () {
    this.rules.forEach((rule) => {
      let selector = rule.selectors[0]
      this.targetStyle[selector] = {}
      rule.declarations.forEach((dec) => {
        this.targetStyle[selector][dec.property] = this.formatValue(dec.value)
      })
    })
  }

  formatValue (string) {
    string = string.replace(/"/g, '').replace(/'/g, '')
    return string.indexOf('px') !== -1 ? parseInt(string) : string
  }

  declareStyle (property) {
    return `window.${property || 'vStyle'} = ${JSON.stringify(this.targetStyle)}`
  }
}
複製程式碼

主要也就是將 css 檔案轉成AST語法樹,之後再對語法樹做轉換,轉成canvas需要的定義形式。並以變數的形式注入到元件中。

實現列表滾動

如果我們的元素很多,需要滾動時,我們必須解決canvas內部元素滾動的問題。這裡我選擇了使用Zynga Scroller 來模擬使用者滾動方法,通過他返回的滾動座標點,來對canvas進行重繪。

詳細的參考這裡

事件模擬

對於click,touch等dom事件的模擬,我們採用的方案是根據點選區域進行檢測,並找出最底層的元素,遞迴尋找父元素並觸發對應事件處理程式,從而模擬事件冒泡。

詳細的實現可以參考這裡

最後

canvas繪製頁面也是一種創新的嘗試,希望這裡的研究對你有啟發,也歡迎您的PR。這裡也做了很多效能優化,限於篇幅不在贅述了,有興趣也可以一起探討。

最後:它並不意味著完全取代基於DOM的渲染,這仍然需要文字輸入,複製/貼上,可訪問性和SEO。 出於這些原因,我們可以使用canvas和基於DOM的渲染的組合。

相關文章