Vue 2.0學習筆記:Vue的render函數
前幾天想學學Vue中怎么編寫可復用的組件,提到要對Vue的 render 函數有所了解。可仔細一想,對于Vue的 render 函數自己只是看了官方的一些介紹,并未深入一點去了解這方面的知識。為了更好的學習后續的知識,又折回來了解Vue中的 render 函數,這一切主要都是為了后續能更好的學習Vue的知識。
回憶Vue的一些基本概念
今天我們學習的目的是了解和學習Vue的 render 函數。如果想要更好的學習Vue的 render 函數相關的知識,我們有必要重溫一下Vue中的一些基本概念。那么先上一張圖,這張圖從宏觀上展現了Vue整體流程:
從上圖中,不難發現一個Vue的應用程序是如何運行起來的,模板通過編譯生成AST,再由AST生成Vue的 render 函數(渲染函數),渲染函數結合數據生成Virtual DOM樹,Diff和Patch后生成新的UI。從這張圖中,可以接觸到Vue的一些主要概念:
- 模板 :Vue的模板基于純HTML,基于Vue的模板語法,我們可以比較方便地聲明數據和UI的關系。
- AST :AST是 Abstract Syntax Tree 的簡稱,Vue使用HTML的Parser將HTML模板解析為AST,并且對AST進行一些優化的標記處理,提取最大的靜態樹,方便Virtual DOM時直接跳過Diff。
- 渲染函數 :渲染函數是用來生成Virtual DOM的。Vue推薦使用模板來構建我們的應用界面,在底層實現中Vue會將模板編譯成渲染函數,當然我們也可以不寫模板,直接寫渲染函數,以獲得更好的控制 (這部分是我們今天主要要了解和學習的部分)。
- Virtual DOM :虛擬DOM樹,Vue的Virtual DOM Patching算法是基于 Snabbdom 的實現,并在些基礎上作了很多的調整和改進。
- Watcher :每個Vue組件都有一個對應的 watcher ,這個 watcher 將會在組件 render 的時候收集組件所依賴的數據,并在依賴有更新的時候,觸發組件重新渲染。你根本不需要寫 shouldComponentUpdate ,Vue會自動優化并更新要更新的UI。
上圖中, render 函數可以作為一道分割線, render 函數的左邊可以稱之為 編譯期 ,將Vue的模板轉換為 渲染函數 。 render 函數的右邊是Vue的運行時,主要是基于渲染函數生成Virtual DOM樹,Diff和Patch。
渲染函數的基礎
Vue推薦在絕大多數情況下使用 template 來創建你的HTML。然而在一些場景中,需要使用JavaScript的編程能力和創建HTML,這就是 render 函數 ,它比 template 更接近編譯器。
<h1>
<a name="hello-world" href="#hello-world">
Hello world!
</a>
</h1>
在HTML層,我們決定這樣定義組件接口:
<anchored-heading :level="1">Hello world!</anchored-heading>
當我們開始寫一個通過 level 的 prop 動態生成 heading 標簽的組件,你可能很快想到這樣實現:
<!-- HTML -->
<script type="text/x-template" id="anchored-heading-template">
<h1 v-if="level === 1">
<slot></slot>
</h1>
<h2 v-else-if="level === 2">
<slot></slot>
</h2>
<h3 v-else-if="level === 3">
<slot></slot>
</h3>
<h4 v-else-if="level === 4">
<slot></slot>
</h4>
<h5 v-else-if="level === 5">
<slot></slot>
</h5>
<h6 v-else-if="level === 6">
<slot></slot>
</h6>
</script>
<!-- Javascript -->
Vue.component('anchored-heading', {
template: '#anchored-heading-template',
props: {
level: {
type: Number,
required: true
}
}
})
在這種場景中使用 template 并不是最好的選擇:首先代碼冗長,為了在不同級別的標題中插入錨點元素,我們需要重復地使用 <slot></slot> 。
雖然模板在大多數組件中都非常好用,但是在這里它就不是很簡潔的了。那么,我們來嘗試使用 render 函數重寫上面的例子:
Vue.component('anchored-heading', {
render: function (createElement) {
return createElement(
'h' + this.level, // tag name 標簽名稱
this.$slots.default // 子組件中的陣列
)
},
props: {
level: {
type: Number,
required: true
}
}
})
簡單清晰很多!簡單來說,這樣代碼精簡很多,但是需要非常熟悉 Vue 的實例屬性。在這個例子中,你需要知道當你不使用 slot 屬性向組件中傳遞內容時,比如 anchored-heading 中的 Hello world! ,這些子元素被存儲在組件實例中的 $slots.default 中。
節點、樹以及虛擬DOM
對Vue的一些概念和渲染函數的基礎有一定的了解之后,我們需要對一些瀏覽器的工作原理有一些了解,這樣對我們學習 render 函數是很重要的。比如下面的這段HTML代碼:
<div>
<h1>My title</h1>
Some text content
<!-- TODO: Add tagline -->
</div>
當瀏覽器讀到這些代碼時,它會建立一個 DOM節點樹 來保持追蹤,如果你會畫一張家譜樹來追蹤家庭成員的發展一樣。
HTML的DOM節點樹如下圖所示:
每個元素都是一個節點。每片文字也是一個節點。甚至注釋也都是節點。一個節點就是頁面的一個部分。就像家譜樹一樣,每個節點都可以有孩子節點 (也就是說每個部分可以包含其它的一些部分)。
高效的更新所有這些節點會是比較困難的,不過所幸你不必再手動完成這個工作了。你只需要告訴 Vue 你希望頁面上的 HTML 是什么,這可以是在一個模板里:
<h1>{{ blogTitle }}</h1>
或者一個渲染函數里:
render: function (createElement) {
return createElement('h1', this.blogTitle)
}
在這兩種情況下,Vue 都會自動保持頁面的更新,即便 blogTitle 發生了改變。
虛擬DOM
在Vue 2.0中,渲染層的實現做了根本性改動,那就是引入了虛擬DOM。
Vue的編譯器在編譯模板之后,會把這些模板編譯成一個渲染函數。而函數被調用的時候就會渲染并且返回一個 虛擬DOM的樹 。
當我們有了這個虛擬的樹之后,再交給一個 Patch函數 ,負責把這些虛擬DOM真正施加到真實的DOM上。在這個過程中,Vue有自身的響應式系統來偵測在渲染過程中所依賴到的數據來源。在渲染過程中,偵測到數據來源之后就可以精確感知數據源的變動。到時候就可以根據需要重新進行渲染。當重新進行渲染之后,會生成一個新的樹,將新的樹與舊的樹進行對比,就可以最終得出應施加到真實DOM上的改動。最后再通過Patch函數施加改動。
簡單點講,在Vue的底層實現上,Vue將模板編譯成虛擬DOM渲染函數。結合Vue自帶的響應系統,在應該狀態改變時,Vue能夠智能地計算出重新渲染組件的最小代價并應到DOM操作上。
Vue支持我們通過 data 參數傳遞一個JavaScript對象做為組件數據,然后Vue將遍歷此對象屬性,使用 Object.defineProperty 方法 設置描述對象,通過存取器函數可以追蹤該屬性的變更,Vue創建了一層 Watcher 層,在組件渲染的過程中把屬性記錄為依賴,之后當依賴項的 setter 被調用時,會通知 Watcher 重新計算,從而使它關聯的組件得以更新,如下圖:
有關于Vue的響應式相關的內容,可以閱讀下列文章:
- 深入理解Vue.js響應式原理
- Vue雙向綁定的實現原理 Object.defineproperty
- Vue的雙向綁定原理及實現
- Vue中的響應式
- 從JavaScript屬性描述器剖析Vue.js響應式視圖
對于Vue自帶的響應式系統,并不是咱們今天要聊的東西。我們還是回到Vue的虛擬DOM中來。對于虛擬DOM,咱們來看一個簡單的實例,就是下圖所示的這個,詳細的闡述了 模板 → 渲染函數 → 虛擬DOM樹 → 真實DOM 的一個過程
其實Vue中的虛擬DOM還是很復雜的,我也是一知半解,如果你想深入的了解,可以閱讀@JoeRay61的《 Vue原理解析之Virtual DOM 》一文。
通過前面的學習,我們初步了解到Vue通過建立一個 虛擬DOM 對真實DOM發生的變化保持追蹤。比如下面這行代碼:
return createElement('h1', this.blogTitle)
createElement 到底會返回什么呢?其實不是一個實際的 DOM 元素。它更準確的名字可能是 createNodeDescription ,因為它所包含的信息會告訴 Vue 頁面上需要渲染什么樣的節點,及其子節點。我們把這樣的節點描述為“虛擬節點 (Virtual Node)”,也常簡寫它為“VNode”。“虛擬 DOM”是我們對由 Vue 組件樹建立起來的整個 VNode 樹的稱呼。
Vue組件樹建立起來的整個VNode樹是唯一的。這意味著,下面的 render 函數是無效 的:
render: function (createElement) {
var myParagraphVNode = createElement('p', 'hi')
return createElement('div', [
// 錯誤-重復的 VNodes
myParagraphVNode, myParagraphVNode
])
}
如果你真的需要重復很多次的元素/組件,你可以使用工廠函數來實現。例如,下面這個例子 render 函數完美有效地渲染了 20 個重復的段落:
render: function (createElement) {
return createElement('div',
Array.apply(null, { length: 20 }).map(function () {
return createElement('p', 'hi')
})
)
}
Vue的渲染機制
上圖展示的是獨立構建時的一個渲染流程圖。
繼續使用上面用到的模板到真實DOM過程的一個圖:
這里會涉及到Vue的另外兩個概念:
- 獨立構建 :包含模板編譯器,渲染過程 HTML字符串 → render函數 → VNode → 真實DOM節點
- 運行時構建 :不包含模板編譯器,渲染過程 render函數 → VNode → 真實DOM節點
運行時構建的包,會比獨立構建少一個模板編譯器。在 $mount 函數上也不同。而 $mount 方法又是整個渲染過程的起始點。用一張流程圖來說明:
由此圖可以看到,在渲染過程中,提供了三種渲染模式,自定義 render 函數、 template 、 el 均可以渲染頁面,也就是對應我們使用Vue時,三種寫法:
自定義 render函數
Vue.component('anchored-heading', {
render: function (createElement) {
return createElement (
'h' + this.level, // tag name標簽名稱
this.$slots.default // 子組件中的陣列
)
},
props: {
level: {
type: Number,
required: true
}
}
})
template 寫法
let app = new Vue({
template: `<div>{{ msg }}</div>`,
data () {
return {
msg: ''
}
}
})
el 寫法
let app = new Vue({
el: '#app',
data () {
return {
msg: 'Hello Vue!'
}
}
})
這三種渲染模式最終都是要得到 render 函數。只不過用戶自定義的 render 函數省去了程序分析的過程,等同于處理過的 render 函數,而普通的 template 或者 el 只是字符串,需要解析成AST,再將AST轉化為 render 函數。
記住一點,無論哪種方法,都要得到 render 函數。
我們在使用過程中具體要使用哪種調用方式,要根據具體的需求來。
如果是比較簡單的邏輯,使用 template 和 el 比較好,因為這兩種都屬于聲明式渲染,對用戶理解比較容易,但靈活性比較差,因為最終生成的 render 函數是由程序通過AST解析優化得到的;而使用自定義 render 函數相當于人已經將邏輯翻譯給程序,能夠勝任復雜的邏輯,靈活性高,但對于用戶的理解相對差點。
理解 createElement
在使用 render 函數,其中還有另一個需要掌握的部分,那就是 createElement 。接下來我們需要熟悉的是如何在 createElement 函數中生成模板。那么我們分兩個部分來對 createElement 進行理解。
createElement 參數
createElement 可以是接受多個參數:
第一個參數: {String | Object | Function}
第一個參數對于 createElement 而言是一個必須的參數,這個參數可以是字符串 string 、是一個對象 object ,也可以是一個函數 function 。
<div id="app">
<custom-element></custom-element>
</div>
Vue.component('custom-element', {
render: function (createElement) {
return createElement('div')
}
})
let app = new Vue({
el: '#app'
})
上面的示例,給 createElement 傳了一個 String 參數 'div' ,即傳了一個HTML標簽字符。最后會有一個 div 元素渲染出來:
接著把上例中的 String 換成一個 Object ,比如:
Vue.component('custom-element', {
render: function (createElement) {
return createElement({
template: `<div>Hello Vue!</div>`
})
}
})
上例傳了一個 {template: '<div>Hello Vue!</div>'} 對象。此時 custom-element 組件渲染出來的結果如下:
除此之外,還可以傳一個 Function ,比如:
Vue.component('custom-element', {
render: function (createElement) {
var eleFun = function () {
return {
template: `<div>Hello Vue!</div>`
}
}
return createElement(eleFun())
}
})
最終得到的結果和上圖是一樣的。這里傳了一個 eleFun() 函數給 createElement ,而這個函數返回的是一個對象。
第二個參數: {Object}
createElement 是一個可選參數,這個參數是一個 Object 。來看一個小示例:
<div id="app">
<custom-element></custom-element>
</div>
Vue.component('custom-element', {
render: function (createElement) {
var self = this
// 第一個參數是一個簡單的HTML標簽字符 “必選”
// 第二個參數是一個包含模板相關屬性的數據對象 “可選”
return createElement('div', {
'class': {
foo: true,
bar: false
},
style: {
color: 'red',
fontSize: '14px'
},
attrs: {
id: 'boo'
},
domProps: {
innerHTML: 'Hello Vue!'
}
})
}
})
let app = new Vue({
el: '#app'
})
最終生成的DOM,將會帶一些屬性和內容的 div 元素,如下圖所示:
第三個參數:{String | Array}
createElement 還有第三個參數,這個參數是可選的,可以給其傳一個 String 或 Array 。比如下面這個小示例:
<div id="app">
<custom-element></custom-element>
</div>
Vue.component('custom-element', {
render: function (createElement) {
var self = this
return createElement(
'div', // 第一個參數是一個簡單的HTML標簽字符 “必選”
{
class: {
title: true
},
style: {
border: '1px solid',
padding: '10px'
}
}, // 第二個參數是一個包含模板相關屬性的數據對象 “可選”
[
createElement('h1', 'Hello Vue!'),
createElement('p', '開始學習Vue!')
] // 第三個參數是傳了多個子元素的一個數組 “可選”
)
}
})
let app = new Vue({
el: '#app'
})
最終的效果如下:
其實從上面這幾個小例來看,不難發現,以往我們使用 Vue.component() 創建組件的方式,都可以用 render 函數配合 createElement 來完成。你也會發現,使用 Vue.component() 和 render 各有所長,正如文章開頭的一個示例代碼,就不適合 Vue.component() 的 template ,而使用 render 更方便。
接下來看一個小示例,看看 template 和 render 方式怎么創建相同效果的一個組件:
<div id="app">
<custom-element></custom-element>
</div>
Vue.component('custom-element', {
template: `<div id="box" :class="{show: show}" @click="handleClick">Hello Vue!</div>`,
data () {
return {
show: true
}
},
methods: {
handleClick: function () {
console.log('Clicked!')
}
}
})
上面 Vue.component() 中的代碼換成 render 函數之后,可以這樣寫:
Vue.component('custom-element', {
render: function (createElement) {
return createElement('div', {
class: {
show: this.show
},
attrs: {
id: 'box'
},
on: {
click: this.handleClick
}
}, 'Hello Vue!')
},
data () {
return {
show: true
}
},
methods: {
handleClick: function () {
console.log('Clicked!')
}
}
})
最后聲明一個Vue實例,并掛載到 id 為 #app 的一個元素上:
let app = new Vue({
el: '#app'
})
createElement 解析過程
簡單的來看一下 createElement 解析的過程,這部分需要對JS有一些功底。不然看起來有點蛋疼:
const SIMPLE_NORMALIZE = 1
const ALWAYS_NORMALIZE = 2
function createElement (context, tag, data, children, normalizationType, alwaysNormalize) {
// 兼容不傳data的情況
if (Array.isArray(data) || isPrimitive(data)) {
normalizationType = children
children = data
data = undefined
}
// 如果alwaysNormalize是true
// 那么normalizationType應該設置為常量ALWAYS_NORMALIZE的值
if (alwaysNormalize) normalizationType = ALWAYS_NORMALIZE
// 調用_createElement創建虛擬節點
return _createElement(context, tag, data, children, normalizationType)
}
function _createElement (context, tag, data, children, normalizationType) {
/**
* 如果存在data.__ob__,說明data是被Observer觀察的數據
* 不能用作虛擬節點的data
* 需要拋出警告,并返回一個空節點
*
* 被監控的data不能被用作vnode渲染的數據的原因是:
* data在vnode渲染過程中可能會被改變,這樣會觸發監控,導致不符合預期的操作
*/
if (data && data.__ob__) {
process.env.NODE_ENV !== 'production' && warn(
`Avoid using observed data object as vnode data: ${JSON.stringify(data)}\n` +
'Always create fresh vnode data objects in each render!',
context
)
return createEmptyVNode()
}
// 當組件的is屬性被設置為一個falsy的值
// Vue將不會知道要把這個組件渲染成什么
// 所以渲染一個空節點
if (!tag) {
return createEmptyVNode()
}
// 作用域插槽
if (Array.isArray(children) && typeof children[0] === 'function') {
data = data || {}
data.scopedSlots = { default: children[0] }
children.length = 0
}
// 根據normalizationType的值,選擇不同的處理方法
if (normalizationType === ALWAYS_NORMALIZE) {
children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) {
children = simpleNormalizeChildren(children)
}
let vnode, ns
// 如果標簽名是字符串類型
if (typeof tag === 'string') {
let Ctor
// 獲取標簽名的命名空間
ns = config.getTagNamespace(tag)
// 判斷是否為保留標簽
if (config.isReservedTag(tag)) {
// 如果是保留標簽,就創建一個這樣的vnode
vnode = new VNode(
config.parsePlatformTagName(tag), data, children,
undefined, undefined, context
)
// 如果不是保留標簽,那么我們將嘗試從vm的components上查找是否有這個標簽的定義
} else if ((Ctor = resolveAsset(context.$options, 'components', tag))) {
// 如果找到了這個標簽的定義,就以此創建虛擬組件節點
vnode = createComponent(Ctor, data, context, children, tag)
} else {
// 兜底方案,正常創建一個vnode
vnode = new VNode(
tag, data, children,
undefined, undefined, context
)
}
// 當tag不是字符串的時候,我們認為tag是組件的構造類
// 所以直接創建
} else {
vnode = createComponent(tag, data, context, children)
}
// 如果有vnode
if (vnode) {
// 如果有namespace,就應用下namespace,然后返回vnode
if (ns) applyNS(vnode, ns)
return vnode
// 否則,返回一個空節點
} else {
return createEmptyVNode()
}
}
}
簡單的梳理了一個流程圖,可以參考下
這部分代碼和流程圖來自于@JoeRay61的《 Vue原理解析之Virtual DOM 》一文。
使用JavaScript代替模板功能
在使用Vue模板的時候,我們可以在模板中靈活的使用 v-if 、 v-for 、 v-model 和 <slot> 之類的。但在 render 函數中是沒有提供專用的API。如果在 render 使用這些,需要使用原生的JavaScript來實現。
v-if 和 v-for
在 render 函數中可以使用 if/else 和 map 來實現 template 中的 v-if 和 v-for 。
<ul v-if="items.length">
<li v-for="item in items">{{ item }}</li>
</ul>
<p v-else>No items found.</p>
換成 render 函數,可以這樣寫:
Vue.component('item-list',{
props: ['items'],
render: function (createElement) {
if (this.items.length) {
return createElement('ul', this.items.map((item) => {
return createElement('item')
}))
} else {
return createElement('p', 'No items found.')
}
}
})
<div id="app">
<item-list :items="items"></item-list>
</div>
let app = new Vue({
el: '#app',
data () {
return {
items: ['大漠', 'W3cplus', 'blog']
}
}
})
得到的效果如下:
v-model
render 函數中也沒有與 v-model 相應的API,如果要實現 v-model 類似的功能,同樣需要使用原生JavaScript來實現。
<div id="app">
<el-input :name="name" @input="val => name = val"></el-input>
</div>
Vue.component('el-input', {
render: function (createElement) {
var self = this
return createElement('input', {
domProps: {
value: self.name
},
on: {
input: function (event) {
self.$emit('input', event.target.value)
}
}
})
},
props: {
name: String
}
})
let app = new Vue({
el: '#app',
data () {
return {
name: '大漠'
}
}
})
刷新你的瀏覽器,可以看到效果如下:
這就是深入底層要付出的,盡管麻煩了一些,但相對于 v-model 來說,你可以更靈活地控制。
插槽
你可以從 this.$slots 獲取VNodes列表中的靜態內容:
render: function (createElement) {
// 相當于 `<div><slot></slot></div>`
return createElement('div', this.$slots.default)
}
還可以從 this.$scopedSlots 中獲得能用作函數的作用域插槽,這個函數返回VNodes:
props: ['message'],
render: function (createElement) {
// `<div><slot :text="message"></slot></div>`
return createElement('div', [
this.$scopedSlots.default({
text: this.message
})
])
}
如果要用渲染函數向子組件中傳遞作用域插槽,可以利用VNode數據中的 scopedSlots 域:
<div id="app">
<custom-ele></custom-ele>
</div>
Vue.component('custom-ele', {
render: function (createElement) {
return createElement('div', [
createElement('child', {
scopedSlots: {
default: function (props) {
return [
createElement('span', 'From Parent Component'),
createElement('span', props.text)
]
}
}
})
])
}
})
Vue.component('child', {
render: function (createElement) {
return createElement('strong', this.$scopedSlots.default({
text: 'This is Child Component'
}))
}
})
let app = new Vue({
el: '#app'
})
JSX
如果寫習慣了 template ,然后要用 render 函數來寫,一定會感覺好痛苦,特別是面對復雜的組件的時候。不過我們在Vue中使用JSX可以讓我們回到更接近于模板的語法上。
import AnchoredHeading from './AnchoredHeading.vue'
new Vue({
el: '#demo',
render: function (h) {
return (
<AnchoredHeading level={1}>
<span>Hello</span> world!
</AnchoredHeading>
)
}
})
將 h 作為 createElement 的別名是 Vue 生態系統中的一個通用慣例,實際上也是 JSX 所要求的,如果在作用域中 h 失去作用,在應用中會觸發報錯。
總結
回過頭來看,Vue中的渲染核心關鍵的幾步流程還是非常清晰的:
- new Vue ,執行初始化
- 掛載 $mount 方法,通過自定義 render 方法、 template 、 el 等生成 render 函數
- 通過 Watcher 監聽數據的變化
- 當數據發生變化時, render 函數執行生成VNode對象
- 通過 patch 方法,對比新舊VNode對象,通過DOM Diff算法,添加、修改、刪除真正的DOM元素
至此,整個 new Vue 的渲染過程完畢。
而這篇文章,主要把精力集中在 render 函數這一部分。學習了怎么用 render 函數來創建組件,以及了解了其中 createElement 。
最后要說的是,上文雖然以學習 render 函數,但文中涉及了Vue不少的知識點,也有點零亂。初學者自己根據自己獲取所要的知識點。由于本人也是初涉Vue相關的知識點,如果文章中有不對之處,煩請路過的大神拍正。
來自:https://www.w3cplus.com/vue/vue-render-function.html