Render-функции

Основы

В большинстве случаев для формирования HTML с помощью Vue рекомендуется использовать шаблоны. Впрочем, иногда возникает необходимость в использовании всех алгоритмических возможностей JavaScript. В таких случаях можно применить render-функции — более низкоуровневую альтернативу шаблонам.

Давайте разберём простой пример, в котором использование render-функции будет целесообразным. Предположим, вы хотите сгенерировать заголовки с “якорями”:

<h1>
<a name="hello-world" href="#hello-world">
Hello world!
</a>
</h1>

Для генерации представленного выше HTML вы решаете использовать такой интерфейс компонента:

<anchored-heading :level="1">Hello world!</anchored-heading>

При использовании шаблонов для реализации такого интерфейса придётся написать что-то вроде кода ниже:

<script type="text/x-template" id="anchored-heading-template">
<div>
<h1 v-if="level === 1">
<slot></slot>
</h1>
<h2 v-if="level === 2">
<slot></slot>
</h2>
<h3 v-if="level === 3">
<slot></slot>
</h3>
<h4 v-if="level === 4">
<slot></slot>
</h4>
<h5 v-if="level === 5">
<slot></slot>
</h5>
<h6 v-if="level === 6">
<slot></slot>
</h6>
</div>
</script>
Vue.component('anchored-heading', {
template: '#anchored-heading-template',
props: {
level: {
type: Number,
required: true
}
}
})

Смотрится не очень. Мало того, что шаблон получился очень многословным — приходится ещё и <slot></slot> повторять для каждого возможного уровня заголовка. Бесполезный корневой div, вызванный формальным требованием единственности корневого элемента шаблона, тоже красоты не добавляет.

Шаблоны хорошо подходят для большинства компонентов, но рассматриваемый сейчас — явно не один из них. Давайте попробуем переписать компонент, используя render-функцию:

Vue.component('anchored-heading', {
render: function (createElement) {
return createElement(
'h' + this.level, // имя тега
this.$slots.default // массив потомков
)
},
props: {
level: {
type: Number,
required: true
}
}
})

Так-то лучше, наверное? Код короче, но требует более подробного знакомства со свойствами экземпляра Vue. В данном случае, необходимо знать, что когда дочерние элементы передаются без указания атрибута slot, как например Hello world! внутри anchored-heading, они сохраняются в экземпляре компонента как $slots.default. Если вы этого ещё не сделали, советуем вам пробежать глазами API свойств экземпляра перед тем как углубляться в рассмотрение render-функций.

Аргументы createElement

Второй момент, с которым необходимо познакомиться, это синтаксис использования возможностей шаблонизации функцией createElement. Вот аргументы, которые принимает createElement:

// @returns {VNode}
createElement(
// {String | Object | Function}
// Название тега HTML, опции компонента, или функция,
// их возвращающая. Обязательный параметр.
'div',
// {Object}
// Объект данных, содержащий атрибуты,
// который вы бы указали в шаблоне. Опциональный параметр.
{
// (см. детали в секции ниже)
},
// {String | Array}
// Дочерние VNode'ы. Опциональный параметр.
[
createElement('h1', 'hello world'),
createElement(MyComponent, {
props: {
someProp: 'foo'
}
}),
'bar'
]
)

Подробно об объекте данных

Заметьте: особым образом рассматриваемые в шаблонах атрибуты v-bind:class и v-bind:style и в объектах данных VNode’ов имеют собственные поля на верхнем уровне объектов данных.

{
// То же API что и у `v-bind:class`
'class': {
foo: true,
bar: false
},
// То же API что и у `v-bind:style`
style: {
color: 'red',
fontSize: '14px'
},
// Обычные атрибуты HTML
attrs: {
id: 'foo'
},
// Входные параметры компонентов
props: {
myProp: 'bar'
},
// Свойства DOM
domProps: {
innerHTML: 'baz'
},
// Обработчики событий располагаются под ключом "on",
// однако модификаторы вроде как v-on:keyup.enter не
// поддерживаются. Проверять keyCode придётся вручную.
on: {
click: this.clickHandler
},
// Только для компонентов. Позволяет слушать нативные события,
// а не генерируемые в компоненте через vm.$emit.
nativeOn: {
click: this.nativeClickHandler
},
// Пользовательские директивы. Обратите внимание, что 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'
// Прочие специальные свойства верхнего уровня
key: 'myKey',
ref: 'myRef'
}

Полный пример

Узнав всё это, мы теперь можем завершить начатый ранее компонент:

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) {
// создать id в kebabCase
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-функция ниже — не валидна:

render: function (createElement) {
var myParagraphVNode = createElement('p', 'hi')
return createElement('div', [
// Упс — дублирующиеся VNode'ы!
myParagraphVNode, myParagraphVNode
])
}

Если вы действительно хотите многократно использовать один и тот же элемент/компонент, примените функцию-фабрику. Например, следующая render-функция полностью валидный способ для отображения 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>Ничего не найдено.</p>

… при использовании render-функции легко заменить на if/else и map:

render: function (createElement) {
if (this.items.length) {
return createElement('ul', this.items.map(function (item) {
return createElement('li', item.name)
}))
} else {
return createElement('p', 'Ничего не найдено.')
}
}

v-model

В render-функции нет прямого аналога v-model — вы должны реализовать эту логику самостоятельно:

render: function (createElement) {
var self = this
return createElement('input', {
domProps: {
value: self.value
},
on: {
input: function (event) {
self.value = event.target.value
self.$emit('input', event.target.value)
}
}
})
}

Это цена использования низкоуровневой реализации, которая в тоже время предоставляет вам больше контроля над взаимодействием чем v-model.

События и модификаторы клавиш

Для модификаторов событий .capture и .once, Vue предоставляет префиксы, которые могут быть использованы вместе с on:

Модификаторы Префикс
.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 (13)
// и клавиша SHIFT не была нажата в тоже время
if (!event.shiftKey || event.keyCode !== 13) return
// Останавливаем всплытие события
event.stopPropagation()
// Останавливаем стандартный обработчик keyup для этого элемента
event.preventDefault()
// ...
}
}

Слоты

Вы можете получить доступ к статическому содержимому слотов в виде массивов VNode используя this.$slots:

render: function (createElement) {
// <div><slot></slot></div>
return createElement('div', this.$slots.default)
}

И получить доступ к слотам со своей областью видимости как к функциям, возвращающим VNode, используя this.$scopedSlots:

render: function (createElement) {
// <div><slot :text="msg"></slot></div>
return createElement('div', [
this.$scopedSlots.default({
text: this.msg
})
])
}

Чтобы передать слоты со своей областью видимости в дочерний компонент используя render-функцию, применяйте свойство scopedSlots в данных VNode:

render (createElement) {
return createElement('div', [
createElement('child', {
// передаём scopedSlots в объект data
// в виде { 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, позволяющий использовать JSX во Vue, и применять синтаксис похожий на шаблоны:

import AnchoredHeading from './AnchoredHeading.vue'
new Vue({
el: '#demo',
render (h) {
return (
<AnchoredHeading level={1}>
<span>Hello</span> world!
</AnchoredHeading>
)
}
})

Сокращение createElement до h — распространённое соглашение в экосистеме Vue, и обязательное для использования JSX. В случае отсутствия h в области видимости, приложение выбросит ошибку.

Подробную информацию о преобразовании JSX в JavaScript можно найти в документации плагина.

Функциональные компоненты

Компонент для заголовков с “якорями”, который мы создали выше, довольно прост. У него нет какого-либо состояния, хуков или требующих наблюдения данных. По сути это всего лишь функция с параметром.

В подобных случаях мы можем пометить компоненты как функциональные (опция functional), что означает отсутствие у них состояния (нет опции data) и экземпляра (нет контекстной переменной this). Функциональный компонент выглядит так:

Vue.component('my-component', {
functional: true,
// чтобы компенсировать отсутствие экземпляра
// мы передаём контекст вторым аргументом
render: function (createElement, context) {
// ...
},
// входные параметры опциональны
props: {
// ...
}
})

Всё необходимое компоненту передаётся через context — объект, содержащий следующие поля:

После указания functional: true, обновление render-функции нашего компонента для заголовков потребует только добавления параметра context, обновления this.$slots.default на context.children и замены this.level на context.props.level.

Поскольку функциональные компоненты — это просто функции, их рендеринг обходится значительно дешевле. Однако, это также означает, что функциональные компоненты не отображаются в дереве компонентов в инструментах разработчика Chrome на вкладке Vue.

Кроме того, они очень удобны в качестве обёрток. Например, если вам нужно:

Вот пример компонента smart-list, делегирующего рендеринг к более специализированным компонентам, в зависимости от переданных в него данных:

var EmptyList = { /* ... */ }
var TableList = { /* ... */ }
var OrderedList = { /* ... */ }
var UnorderedList = { /* ... */ }
Vue.component('smart-list', {
functional: true,
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
)
},
props: {
items: {
type: Array,
required: true
},
isOrdered: Boolean
}
})

slots() vs children

Вы можете задаться вопросом зачем нужны slots() и children одновременно. Разве не будет slots().default возвращать тот же результат, что и children? В некоторых случаях — да, но что если у нашего функционального компонента будут следующие дочерние элементы?

<my-functional-component>
<p slot="foo">
первый
</p>
<p>второй</p>
</my-functional-component>

Для этого компонента, children даст вам оба абзаца, slots().default — только второй, а slots().foo — только первый. Таким образом, наличие и children, и slots() позволяет выбрать, знать ли компоненту о системе слотов, или просто делегировать это знание потомку через children.

Компиляция шаблонов

Возможно, вас заинтересует тот факт, что шаблоны Vue в действительности компилируются в render-функцию. Обычно нет необходимости знать подобные детали реализации, но может быть любопытным посмотреть на то, как компилируются те или иные возможности шаблонов. Ниже приведена небольшая демонстрация, с помощью Vue.compile в реальном времени компилирующая строки шаблонов:

{{ result.render }}
_m({{ index }}): {{ fn }}
{{ result.staticRenderFns }}
{{ result }}