3天學寫mvvm框架[三]:瀏覽器端渲染
通過之前的實踐,我們已經實現了資料變動的監聽與模板的解析,今天我們就將把兩者結合起來,完成瀏覽器端的渲染工作。
Vue類
首先我們來編寫類:Vue
。
Vue
的建構函式將接受多個引數,包括:
- el:例項的渲染將以此作為父節點。
- data:一個函式,執行後將返回一個物件/陣列,作為例項的資料。
- tpl: 例項的模板字串。
- methods:例項的方法。
在建構函式中,我們將先設定根元素為$el
,然後呼叫我們之前寫的parseHtml
和generateRender
並最終生成Function
例項作為我們的渲染函式render
,同時使用proxy
來建立可觀察的資料:
class Vue { constructor({ el, data, tpl, methods }) { // set render if (el instanceof Element) { this.$el = el; } else { this.$el = document.querySelector(el); } const ast = parseHtml(tpl); const renderCode = generateRender(ast); this.render = new Function(renderCode); // set data this.data = proxy(data.call(this)); ... } ... } 複製程式碼
這裡,我們將再次使用proxy
來建立一個代理。在Vue
中,例如data
方法建立了{ a: 1 }
這樣的資料,我們可以通過this.a
而非類似this.data.a
來訪問。為了支援這樣更簡潔地訪問資料,我們希望提供一個物件,同時提供對資料的訪問以及其他內容例如方法的訪問,同時又保持proxy
對於新鍵值對的設定的靈活性,因此我這裡採取的方式是建立一個新的proxy
,它會優先訪問例項的資料,如果資料不存在,再來訪問方法等:
const proxyObj = new Proxy(this, { get(target, key) { if (key in target.data) return target.data[key]; return target[key]; }, set(target, key, value) { if (!(key in target.data) && key in target) { target[key] = value; } else { target.data[key] = value; } return true; }, has(target, key) { return (key in target) || (key in target.data); }, }); this._proxyObj = proxyObj; 複製程式碼
接下去,我們將methods
中的方法繫結到例項上:
Object.keys(methods).forEach((key) => { this[key] = methods[key].bind(proxyObj); }); 複製程式碼
最後我們將呼叫watch
方法,傳入的求值函式updateComponent
將完成渲染工作,同時收集依賴,以便在資料變動時重新渲染:
const updateComponent = () => { this._update(this._render()); }; watch(updateComponent, () => {/* noop */}); 複製程式碼
渲染與v-dom
_render
方法將呼叫render
來建立一棵由VNode
節點組成的樹,或稱之為v-dom
:
class VNode { constructor(tag, text, attrs, children) { this.tag = tag; this.text = text; this.attrs = attrs; this.children = children; } } class Vue { ... _render() { return this.render.call(this._proxyObj); } _c(tag, attrs, children) { return new VNode(tag, null, attrs, children); } _v(text) { return new VNode(null, text, null, null); } } 複製程式碼
_update
方法將根據是否已經建立過舊的v-dom
來判斷是進行建立過程還是比較更新過程(patch),隨後我們需要儲存本次建立的v-dom
,以便進行後續的比較更新:
_update(vNode) { const preVode = this.preVode; if (preVode) { patch(preVode, vNode); } else { this.preVode = vNode; this.$el.appendChild(build(vNode)); } } 複製程式碼
建立過程將遍歷整個v-dom
,使用document.createTextNode
和document.createElement
來建立dom元素,並將其儲存在VNode
節點上,用以之後進行更新:
const build = function (vNode) { if (vNode.text) return vNode.$el = document.createTextNode(vNode.text); if (vNode.tag) { const $el = document.createElement(vNode.tag); handleAttrs(vNode, $el); vNode.children.forEach((child) => { $el.appendChild(build(child)); }); return vNode.$el = $el; } }; const handleAttrs = function ({ attrs }, $el, preAttrs = {}) { if (preAttrs.class !== attrs.class || preAttrs['v-class'] !== attrs['v-class']) { let clsStr = ''; if (attrs.class) clsStr += attrs.class; if (attrs['v-class']) clsStr += ' ' + attrs['v-class']; $el.className = clsStr; } if (attrs['v-on-click'] !== preAttrs['v-on-click']) { // 這裡匿名函式總是會不等的 if (attrs['v-on-click']) $el.onclick = attrs['v-on-click']; } }; 複製程式碼
由於我們還不支援v-if
、v-for
或component
元件等等,因此我們可以認為更新後的v-dom
在結構上是一致的,這樣就大大簡化了比較更新的過程。我們只需要遍歷新老兩顆v-dom
,在patch
方法中傳入對應的新老VNode
節點,如果存在不同的屬性,便進行跟新就可以了:
const patch = function (preVode, vNode) { if (preVode.tag === vNode.tag) { vNode.$el = preVode.$el; if (vNode.text) { if (vNode.text !== preVode.text) vNode.$el.textContent = vNode.text; } else { vNode.$el = preVode.$el; preVode.children.forEach((preChild, i) => { // TODO: patch(preChild, vNode.children[i]); }); handleAttrs(vNode, vNode.$el, preVode.attrs); } } else { // 因為結構是一樣的,因此暫時不必考慮 } }; 複製程式碼
最後,我們暴露一個方法來返回新建的Vue
例項所繫結的_proxyObj
物件,我們就可以通過這個物件來改變例項資料或是呼叫例項的方法等了:
Vue.new = function (opts) { return new Vue(opts)._proxyObj; }; 複製程式碼
總結
我們通過3次實踐,完成了資料監聽、模板解析以及最後的渲染。當然這只是一個非常簡陋的demo,容錯性有限、支援的功能也非常有限。
也許之後我還會更新這一系列的文章,加入計算屬性的支援、元件的支援、v-if
、v-for
和v-model
等directive的支援、template
、keep-alive
與component
等元件,等等。
最後謝謝您閱讀本文,希望有幫助到您理解Vue
的一部分原理。
參考:
- ofollow,noindex">Vue