Unetway

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.

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

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

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

Когда браузер читает этот код, он создаёт дерево «узлов 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 в области видимости, приложение выбросит ошибку.

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

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

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

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

Если вы используете однофайловые компоненты, вы можете объявить функциональные компоненты основанные на шаблоне таким образом:

<template functional>
</template>

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

  • props: Объект, содержащий переданные входные параметры
  • children: Массив дочерних VNode’ов
  • slots: Функция, возвращающая объект slots
  • data: Объект данных, переданный объекту, целиком
  • parent: Ссылка на родительский компонент
  • listeners: Объект, содержащий все зарегистрированные в родителе прослушиватели событий. Это просто псевдоним для data.on
  • injections: Если используется опция inject, будет содержать все разрешённые инъекции.

После указания 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 в реальном времени компилирующая строки шаблонов:

<div>
  <header>
    <h1>I'm a template!</h1>
  </header>
  <p v-if="message">
    {{ message }}
  </p>
  <p v-else>
    No message.
  </p>
</div>    

render:

function anonymous() {
  with(this){return _c('div',[_m(0),(message)?_c('p',[_v(_s(message))]):_c('p',[_v("No message.")])])}
}

staticRenderFns:

_m(0): function anonymous() {
  with(this){return _c('header',[_c('h1',[_v("I'm a template!")])])}
}