項目概述
一個基於Vue的virtual dom插件庫,按照Vue render 函數的寫法,直接將Vue生成的Vnode渲染到canvas中。支持常規的滾動操作和一些基礎的元素事件綁定。
github 地址: github
demo實例:demo
背景
從一個小的需求說起:某一天,產品提了一個這樣的需求,需要制作一個微信活動頁,活動頁可以分享包含用戶相關信息的圖片。這些信息是需要從接口取的,而且每個人都不一樣。第一次碰到這種需求的時候,基本上都會去手擼canvasAPI去做渲染功能,這種情況的步驟大致如下:
- 寫一大串 dom template 標簽
- 渲染template成dom標簽
- 開始捕捉dom元素,繪制canvas
- canvas 渲染圖片
面臨的主要問題是復用性太差,其次是性能上也有問題,用戶看到的界面不一定和正式渲染出的界面一致,可能存在渲染差異。作為一個有追求的前端,當然得想想看有沒有更好的法子。於是乎了解到了一個html2canvas 這樣一個庫。但是總是感覺還是要轉成dom再去繪制,而且感覺性能和穩定性也不是很好。
我們知道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繪制頁面的研究,很久之前就已經有人付出過研究了。而且性能還是很不錯的。那我們更要試試看,到底我們的想法能不能實現了!越來越期待....
開始
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我們需要做額外的一些約束,也就是說我們需要怎么樣的渲染標簽,來渲染對應的canvas元素(舉個🌰):
- view/scrollView/scrollItem --> fillRect
- text --> fillText
- image --> drawImage
其中這些元素類分別都繼承於一個Super類,並且由於它們各有不同的展示方式,因此它們分別實現自己的draw方法,做定制化的展示。
繪制對象的布局機制實現
繪制 canvas 布局最基礎的寫法是為canvas 元素傳入一系列坐標點和相關的基礎寬高,這樣寫到實際項目中可能是這樣的:
renderCanvas(h) {
return h('view', {
style: {
left: 10,
top: 10,
width: 100,
height: 100
}
})
}
這樣寫確實有點不方便維護,目前有好幾種解決方案,一種是使用css-layout
去做管理。css-layout
支持的轉換屬性如下:
這樣也只是做了一層轉換,幫我們更好的用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的渲染的組合。