Render-функции и JSX

Основы

В большинстве случаев для формирования 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">
<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>
</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. В данном случае, необходимо знать, что когда дочерние элементы передаются без указания атрибута slot, как например Hello world! внутри anchored-heading, они сохраняются в экземпляре компонента как $slots.default. Если вы этого ещё не сделали, советуем вам пробежать глазами API свойств экземпляра перед тем как углубляться в рассмотрение render-функций.

Узлы, деревья, и виртуальный DOM

Прежде чем погрузиться в render-функции, важно знать как работают браузеры. Возьмите этот HTML-код например:

<div>
<h1>My title</h1>
Some text content
<!-- TODO: Add tagline -->
</div>

Когда браузер читает этот код, он создаёт дерево «узлов DOM», чтобы облегчить ему отслеживание всего, как например вы могли бы построить генеалогическое дерево для отслеживания вашей увеличивающейся семьи.

Дерево узлов DOM для HTML из примера выше выглядит следующим образом:

Визуализация дерева DOM

Каждый элемент является узлом. Каждый фрагмент текста является узлом. Даже комментарии это узлы! Узел — это всего лишь часть страницы. И так же, как и в генеалогическом дереве, каждый узел может иметь своих потомков (т.е. каждая часть может содержать в себе другие части).

Обновление всех этих узлов может быть затруднительно, но, к счастью, вам не придётся делать это вручную. Вы просто указываете в шаблоне какой HTML-код вы хотите получить на странице:

<h1>{{ blogTitle }}</h1>

Или render-функцию:

render: function (createElement) {
return createElement('h1', this.blogTitle)
}

В обоих случаях Vue автоматически будет обновлять страницу при изменениях blogTitle.

Виртуальный DOM

Vue реализует это созданием виртуального DOM, чтобы отслеживать изменения, которые ему требуется внести в реальный DOM. Рассмотрим подробнее следующую строку:

return createElement('h1', this.blogTitle)

Что в действительности возвращает createElement? Это не в точности реальный DOM-элемент. Можно было бы назвать createNodeDescription для точности, так как результат содержит информацию, описывающую Vue какой именно узел должен быть отображён на странице, включая описания любых дочерних узлов. Мы называем это описание узла «виртуальной нодой», обычно сокращая до аббревиатуры VNode. «Виртуальный DOM» — это то, что мы называем всем деревом VNodes, созданных из дерева компонентов Vue.

Аргументы createElement

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

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

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

Заметьте: особым образом рассматриваемые в шаблонах атрибуты v-bind:class и v-bind:style и в объектах данных VNode’ов имеют собственные поля на верхнем уровне объектов данных. Этот объект также позволяет вам связывать обычные атрибуты HTML, а также свойства DOM, такие как innerHTML (это заменит директиву v-html):

{
// То же 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.

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

Для модификаторов событий .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 (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: {
// ...
}
})

Примечание: в версиях до 2.3.0 опция props необходима, если вы хотите использовать входные параметры в функциональном компоненте. С версии 2.3.0+ вы можете опустить опцию props и все атрибуты найденные на ноде компонента будут неявно извлечены в качестве входных данных.

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

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

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

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

Вот пример компонента 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 }}