渲染函數 & JSX
基礎
Vue 推薦在絕大多數情況下使用模板來創建你的 HTML。然而在一些場景中,你真的需要 JavaScript 的完全編程的能力。這時你可以用渲染函數,它比模板更接近編譯器。
讓我們深入一個簡單的例子,這個例子里 render
函數很實用。假設我們要生成一些帶錨點的標題:
<h1>
<a name="hello-world" href="#hello-world">
Hello world!
</a>
</h1>
對于上面的 HTML,你決定這樣定義組件接口:
<anchored-heading :level="1">Hello world!</anchored-heading>
當開始寫一個只能通過 level
prop 動態生成標題 (heading) 的組件時,你可能很快想到這樣實現:
<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>
Vue.component('anchored-heading', {
template: '#anchored-heading-template',
props: {
level: {
type: Number,
required: true
}
}
})
這里用模板并不是最好的選擇:不但代碼冗長,而且在每一個級別的標題中重復書寫了 <slot></slot>
,在要插入錨點元素時還要再次重復。
雖然模板在大多數組件中都非常好用,但是顯然在這里它就不合適了。那么,我們來嘗試使用 render
函數重寫上面的例子:
Vue.component('anchored-heading', {
render: function (createElement) {
return createElement(
'h' + this.level, // 標簽名稱
this.$slots.default // 子節點數組
)
},
props: {
level: {
type: Number,
required: true
}
}
})
看起來簡單多了!這樣代碼精簡很多,但是需要非常熟悉 Vue 的實例 property。在這個例子中,你需要知道,向組件中傳遞不帶 v-slot
指令的子節點時,比如 anchored-heading
中的 Hello world!
,這些子節點被存儲在組件實例中的 $slots.default
中。如果你還不了解,在深入渲染函數之前推薦閱讀實例 property API。
節點、樹以及虛擬 DOM
在深入渲染函數之前,了解一些瀏覽器的工作原理是很重要的。以下面這段 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 通過建立一個虛擬 DOM 來追蹤自己要如何改變真實 DOM。請仔細看這行代碼:
return createElement('h1', this.blogTitle)
createElement
到底會返回什么呢?其實不是一個實際的 DOM 元素。它更準確的名字可能是 createNodeDescription
,因為它所包含的信息會告訴 Vue 頁面上需要渲染什么樣的節點,包括及其子節點的描述信息。我們把這樣的節點描述為“虛擬節點 (virtual node)”,也常簡寫它為“VNode”。“虛擬 DOM”是我們對由 Vue 組件樹建立起來的整個 VNode 樹的稱呼。
createElement
參數
接下來你需要熟悉的是如何在 createElement
函數中使用模板中的那些功能。這里是 createElement
接受的參數:
// @returns {VNode}
createElement(
// {String | Object | Function}
// 一個 HTML 標簽名、組件選項對象,或者
// resolve 了上述任何一種的一個 async 函數。必填項。
'div',
// {Object}
// 一個與模板中 attribute 對應的數據對象。可選。
{
// (詳情見下一節)
},
// {String | Array}
// 子級虛擬節點 (VNodes),由 `createElement()` 構建而成,
// 也可以使用字符串來生成“文本虛擬節點”。可選。
[
'先寫一些文字',
createElement('h1', '一則頭條'),
createElement(MyComponent, {
props: {
someProp: 'foobar'
}
})
]
)
深入數據對象
有一點要注意:正如 v-bind:class
和 v-bind:style
在模板語法中會被特別對待一樣,它們在 VNode 數據對象中也有對應的頂層字段。該對象也允許你綁定普通的 HTML attribute,也允許綁定如 innerHTML
這樣的 DOM property (這會覆蓋 v-html
指令)。
{
// 與 `v-bind:class` 的 API 相同,
// 接受一個字符串、對象或字符串和對象組成的數組
'class': {
foo: true,
bar: false
},
// 與 `v-bind:style` 的 API 相同,
// 接受一個字符串、對象,或對象組成的數組
style: {
color: 'red',
fontSize: '14px'
},
// 普通的 HTML attribute
attrs: {
id: 'foo'
},
// 組件 prop
props: {
myProp: 'bar'
},
// DOM property
domProps: {
innerHTML: 'baz'
},
// 事件監聽器在 `on` 內,
// 但不再支持如 `v-on:keyup.enter` 這樣的修飾器。
// 需要在處理函數中手動檢查 keyCode。
on: {
click: this.clickHandler
},
// 僅用于組件,用于監聽原生事件,而不是組件內部使用
// `vm.$emit` 觸發的事件。
nativeOn: {
click: this.nativeClickHandler
},
// 自定義指令。注意,你無法對 `binding` 中的 `oldValue`
// 賦值,因為 Vue 已經自動為你進行了同步。
directives: [
{
name: 'my-custom-directive',
value: '2',
expression: '1 + 1',
arg: 'foo',
modifiers: {
bar: true
}
}
],
// 作用域插槽的格式為
// { name: props => VNode | Array<VNode> }
scopedSlots: {
default: props => createElement('span', props.text)
},
// 如果組件是其它組件的子組件,需為插槽指定名稱
slot: 'name-of-slot',
// 其它特殊頂層 property
key: 'myKey',
ref: 'myRef',
// 如果你在渲染函數中給多個元素都應用了相同的 ref 名,
// 那么 `$refs.myRef` 會變成一個數組。
refInFor: true
}
完整示例
有了這些知識,我們現在可以完成我們最開始想實現的組件:
var getChildrenTextContent = function (children) {
return children.map(function (node) {
return node.children
? getChildrenTextContent(node.children)
: node.text
}).join('')
}
Vue.component('anchored-heading', {
render: function (createElement) {
// 創建 kebab-case 風格的 ID
var headingId = getChildrenTextContent(this.$slots.default)
.toLowerCase()
.replace(/\W+/g, '-')
.replace(/(^-|-$)/g, '')
return createElement(
'h' + this.level,
[
createElement('a', {
attrs: {
name: headingId,
href: '#' + headingId
}
}, this.$slots.default)
]
)
},
props: {
level: {
type: Number,
required: true
}
}
})
約束
VNode 必須唯一
組件樹中的所有 VNode 必須是唯一的。這意味著,下面的渲染函數是不合法的:
render: function (createElement) {
var myParagraphVNode = createElement('p', 'hi')
return createElement('div', [
// 錯誤 - 重復的 VNode
myParagraphVNode, myParagraphVNode
])
}
如果你真的需要重復很多次的元素/組件,你可以使用工廠函數來實現。例如,下面這渲染函數用完全合法的方式渲染了 20 個相同的段落:
render: function (createElement) {
return createElement('div',
Array.apply(null, { length: 20 }).map(function () {
return createElement('p', 'hi')
})
)
}
使用 JavaScript 代替模板功能
v-if
和 v-for
只要在原生的 JavaScript 中可以輕松完成的操作,Vue 的渲染函數就不會提供專有的替代方法。比如,在模板中使用的 v-if
和 v-for
:
<ul v-if="items.length">
<li v-for="item in items">{{ item.name }}</li>
</ul>
<p v-else>No items found.</p>
這些都可以在渲染函數中用 JavaScript 的 if
/else
和 map
來重寫:
props: ['items'],
render: function (createElement) {
if (this.items.length) {
return createElement('ul', this.items.map(function (item) {
return createElement('li', item.name)
}))
} else {
return createElement('p', 'No items found.')
}
}
v-model
渲染函數中沒有與 v-model
的直接對應——你必須自己實現相應的邏輯:
props: ['value'],
render: function (createElement) {
var self = this
return createElement('input', {
domProps: {
value: self.value
},
on: {
input: function (event) {
self.$emit('input', event.target.value)
}
}
})
}
這就是深入底層的代價,但與 v-model
相比,這可以讓你更好地控制交互細節。
事件 & 按鍵修飾符
對于 .passive
、.capture
和 .once
這些事件修飾符,Vue 提供了相應的前綴可以用于 on
:
修飾符 | 前綴 |
---|---|
.passive |
& |
.capture |
! |
.once |
~ |
.capture.once 或 | |
.once.capture |
~! |
例如:
on: {
'!click': this.doThisInCapturingMode,
'~keyup': this.doThisOnce,
'~!mouseover': this.doThisOnceInCapturingMode
}
對于所有其它的修飾符,私有前綴都不是必須的,因為你可以在事件處理函數中使用事件方法:
修飾符 | 處理函數中的等價操作 |
---|---|
.stop |
event.stopPropagation() |
.prevent |
event.preventDefault() |
.self |
if (event.target !== event.currentTarget) return |
按鍵: | |
.enter , .13
|
if (event.keyCode !== 13) return (對于別的按鍵修飾符來說,可將 13 改為另一個按鍵碼) |
修飾鍵: | |
.ctrl , .alt , .shift , .meta
|
if (!event.ctrlKey) return (將 ctrlKey 分別修改為 altKey 、shiftKey 或者 metaKey ) |
這里是一個使用所有修飾符的例子:
on: {
keyup: function (event) {
// 如果觸發事件的元素不是事件綁定的元素
// 則返回
if (event.target !== event.currentTarget) return
// 如果按下去的不是 enter 鍵或者
// 沒有同時按下 shift 鍵
// 則返回
if (!event.shiftKey || event.keyCode !== 13) return
// 阻止 事件冒泡
event.stopPropagation()
// 阻止該元素默認的 keyup 事件
event.preventDefault()
// ...
}
}
插槽
你可以通過 this.$slots
訪問靜態插槽的內容,每個插槽都是一個 VNode 數組:
render: function (createElement) {
// `<div><slot></slot></div>`
return createElement('div', this.$slots.default)
}
也可以通過 this.$scopedSlots
訪問作用域插槽,每個作用域插槽都是一個返回若干 VNode 的函數:
props: ['message'],
render: function (createElement) {
// `<div><slot :text="message"></slot></div>`
return createElement('div', [
this.$scopedSlots.default({
text: this.message
})
])
}
如果要用渲染函數向子組件中傳遞作用域插槽,可以利用 VNode 數據對象中的 scopedSlots
字段:
render: function (createElement) {
// `<div><child v-slot="props"><span>{{ props.text }}</span></child></div>`
return createElement('div', [
createElement('child', {
// 在數據對象中傳遞 `scopedSlots`
// 格式為 { name: props => VNode | Array<VNode> }
scopedSlots: {
default: function (props) {
return createElement('span', props.text)
}
}
})
])
}
JSX
如果你寫了很多 render
函數,可能會覺得下面這樣的代碼寫起來很痛苦:
createElement(
'anchored-heading', {
props: {
level: 1
}
}, [
createElement('span', 'Hello'),
' world!'
]
)
特別是對應的模板如此簡單的情況下:
<anchored-heading :level="1">
<span>Hello</span> world!
</anchored-heading>
這就是為什么會有一個 Babel 插件,用于在 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 所要求的。從 Vue 的 Babel 插件的 3.4.0 版本開始,我們會在以 ES2015 語法聲明的含有 JSX 的任何方法和 getter 中 (不是函數或箭頭函數中) 自動注入 const h = this.$createElement
,這樣你就可以去掉 (h)
參數了。對于更早版本的插件,如果 h
在當前作用域中不可用,應用會拋錯。
要了解更多關于 JSX 如何映射到 JavaScript,請閱讀使用文檔。
函數式組件
之前創建的錨點標題組件是比較簡單,沒有管理任何狀態,也沒有監聽任何傳遞給它的狀態,也沒有生命周期方法。實際上,它只是一個接受一些 prop 的函數。在這樣的場景下,我們可以將組件標記為 functional
,這意味它無狀態 (沒有響應式數據),也沒有實例 (沒有 this
上下文)。一個函數式組件就像這樣:
Vue.component('my-component', {
functional: true,
// Props 是可選的
props: {
// ...
},
// 為了彌補缺少的實例
// 提供第二個參數作為上下文
render: function (createElement, context) {
// ...
}
})
注意:在 2.3.0 之前的版本中,如果一個函數式組件想要接收 prop,則
props
選項是必須的。在 2.3.0 或以上的版本中,你可以省略props
選項,所有組件上的 attribute 都會被自動隱式解析為 prop。當使用函數式組件時,該引用將會是 HTMLElement,因為他們是無狀態的也是無實例的。
在 2.5.0 及以上版本中,如果你使用了單文件組件,那么基于模板的函數式組件可以這樣聲明:
<template functional>
</template>
組件需要的一切都是通過 context
參數傳遞,它是一個包括如下字段的對象:
-
props
:提供所有 prop 的對象 -
children
:VNode 子節點的數組 -
slots
:一個函數,返回了包含所有插槽的對象 -
scopedSlots
:(2.6.0+) 一個暴露傳入的作用域插槽的對象。也以函數形式暴露普通插槽。 -
data
:傳遞給組件的整個數據對象,作為createElement
的第二個參數傳入組件 -
parent
:對父組件的引用 -
listeners
:(2.3.0+) 一個包含了所有父組件為當前組件注冊的事件監聽器的對象。這是data.on
的一個別名。 -
injections
:(2.3.0+) 如果使用了inject
選項,則該對象包含了應當被注入的 property。
在添加 functional: true
之后,需要更新我們的錨點標題組件的渲染函數,為其增加 context
參數,并將 this.$slots.default
更新為 context.children
,然后將 this.level
更新為 context.props.level
。
因為函數式組件只是函數,所以渲染開銷也低很多。
在作為包裝組件時它們也同樣非常有用。比如,當你需要做這些時:
- 程序化地在多個組件中選擇一個來代為渲染;
- 在將
children
、props
、data
傳遞給子組件之前操作它們。
下面是一個 smart-list
組件的例子,它能根據傳入 prop 的值來代為渲染更具體的組件:
var EmptyList = { /* ... */ }
var TableList = { /* ... */ }
var OrderedList = { /* ... */ }
var UnorderedList = { /* ... */ }
Vue.component('smart-list', {
functional: true,
props: {
items: {
type: Array,
required: true
},
isOrdered: Boolean
},
render: function (createElement, context) {
function appropriateListComponent () {
var items = context.props.items
if (items.length === 0) return EmptyList
if (typeof items[0] === 'object') return TableList
if (context.props.isOrdered) return OrderedList
return UnorderedList
}
return createElement(
appropriateListComponent(),
context.data,
context.children
)
}
})
向子元素或子組件傳遞 attribute 和事件
在普通組件中,沒有被定義為 prop 的 attribute 會自動添加到組件的根元素上,將已有的同名 attribute 進行替換或與其進行智能合并。
然而函數式組件要求你顯式定義該行為:
Vue.component('my-functional-button', {
functional: true,
render: function (createElement, context) {
// 完全透傳任何 attribute、事件監聽器、子節點等。
return createElement('button', context.data, context.children)
}
})
通過向 createElement
傳入 context.data
作為第二個參數,我們就把 my-functional-button
上面所有的 attribute 和事件監聽器都傳遞下去了。事實上這是非常透明的,以至于那些事件甚至并不要求 .native
修飾符。
如果你使用基于模板的函數式組件,那么你還需要手動添加 attribute 和監聽器。因為我們可以訪問到其獨立的上下文內容,所以我們可以使用 data.attrs
傳遞任何 HTML attribute,也可以使用 listeners
(即 data.on
的別名) 傳遞任何事件監聽器。
<template functional>
<button
class="btn btn-primary"
v-bind="data.attrs"
v-on="listeners"
>
<slot/>
</button>
</template>
slots()
和 children
對比
你可能想知道為什么同時需要 slots()
和 children
。slots().default
不是和 children
類似的嗎?在一些場景中,是這樣——但如果是如下的帶有子節點的函數式組件呢?
<my-functional-component>
<p v-slot:foo>
first
</p>
<p>second</p>
</my-functional-component>
對于這個組件,children
會給你兩個段落標簽,而 slots().default
只會傳遞第二個匿名段落標簽,slots().foo
會傳遞第一個具名段落標簽。同時擁有 children
和 slots()
,因此你可以選擇讓組件感知某個插槽機制,還是簡單地通過傳遞 children
,移交給其它組件去處理。