От автора: сегодня мы поговорим про Vue js компоненты. Вы когда-нибудь брали сторонний UI компонент только, чтобы обнаружить, что из-за одной маленькой настройки вам придется выбросить весь пакет?
Очень сложно создавать пользовательские элементы типа выпадающих списков, виджетов дат и полей с автокомплитером с множеством неожиданных крайних случаев.
Существует много библиотек, которые справляются с этой сложностью, но зачастую у них есть недостаток: очень сложно или невозможно кастомизировать внешний вид.
Разберем в качестве примера этот элемент с тегами:
У компонента есть несколько интересных сценариев:
Он не позволяет добавлять дубликаты
Он не позволяет добавлять пустые теги
Он обрезает проблемы у тегов
Теги добавляются по нажатию клавиши enter
Теги удаляются по нажатию на иконку x
Если вам нужен такой компонент в проекте, то получение его пакета и логики определенно сэкономит вам время и силы.
А если вам нужно слегка переделать внешний вид?
Компонент ниже имеет полностью то же поведение, что и предыдущий, но его макет значительно отличается:
Можно попробовать поддерживать оба макета в одном компоненте через костыли CSS и опции настроек компонента, но есть способ лучше.
Scoped Slots
В Vue.js slots – это алейсхолдер элементы в компоненте, которые заменяются контентом, который передается через родителя/потребителя:
<!-- Card.vue --> <template> <div class="card"> <div class="card-header"> <slot name="header"></slot> </div> <div class="card-body"> <slot name="body"></slot> </div> </div> </template> <!-- Parent/Consumer --> <card> <h1 slot="header">Special Features</h1> <div slot="body"> <h5>Fish and Chips</h5> <p>Super delicious tbh.</p> </div> </card> <!-- Renders: --> <div class="card"> <div class="card-header"> <h1>Special Features</h1> </div> <div class="card-body"> <div> <h5>Fish and Chips</h5> <p>Super delicious tbh.</p> </div> </div> </div>
Scoped slots похожи на обычные слоты, но с возможностью передавать параметры из дочерних компонентов наверх к родителю/потребителю.
Обычные слоты – это передача HTML в компонент. Scoped slots – это передача колбека, который принимает данные и возвращает HTML.
Параметры передаются к родителю путем добавления свойств в элемент слота в дочернем компоненте, после чего родитель получает доступ к этим параметрам путем деструктуризации их из специального атрибута slot-scope.
Пример компонента LinksList со scoped slot для каждого элемента списка, который передает данные для каждого элемента обратно к родителю через свойство :link:
<!-- LinksList.vue --> <template> <!-- ... --> <li v-for="link in links"> <slot name="link" + :link="link" ></slot> </li> <!-- ... --> </template> <!-- Parent/Consumer --> <links-list> <a slot="link" + slot-scope="{ link }" :href="link.href" >{{ link.title }}</a> </links-list>
Родитель получает доступ через slot-scope и использует его в слот шаблоне путем вставки свойства :link в слот элемент в компонент LinksList.
Типы слот свойств
В слот можно передавать что угодно, но мне удобно разделять все слот свойства на 3 категории.
Данные
Данные – простейший тип слот свойства: строки, числа, Булевы значения, массивы, объекты и т.д. В нашем примере со ссылками link – это пример свойства данных. Это просто объект со свойствами:
<!-- LinksList.vue --> <template> <!-- ... --> <li v-for="link in links"> <slot name="link" :link="link" ></slot> </li> <!-- ... --> </template> <script> export default { data() { return { links: [ { href: 'http://...', title: 'First Link', bookmarked: true }, { href: 'http://...', title: 'Second Link', bookmarked: false }, // ... ] } } } </script>
Родитель может рендерить эти данные или использовать их, чтобы решить, что рендерить:
<!-- Parent/Consumer --> <links-list> <div slot="link" slot-scope="{ link }"> <star-icon v-show="link.bookmarked"></star-icon> <a :href="link.href"> {{ link.title }} </a> </div> </links-list>
Действия
Свойства действий – это функции дочернего компонента, которые родитель может вызывать для запуска некого поведения в дочернем компоненте.
Например, можно передать действие bookmark в родителя, и оно добавит в закладки ссылку:
<!-- LinksList.vue --> <template> <!-- ... --> <li v-for="link in links"> <slot name="link" :link="link" + :bookmark="bookmark" ></slot> </li> <!-- ... --> </template> <script> export default { data() { // ... }, + methods: { + bookmark(link) { + link.bookmarked = true + } + } } </script>
Родитель может выполнить это действие, когда пользователь кликнет на кнопку рядом со ссылкой, которой нет в закладках:
<!-- Parent/Consumer --> <links-list> - <div slot="link" slot-scope="{ link }"> + <div slot="link" slot-scope="{ link, bookmark }"> <star-icon v-show="link.bookmarked"></star-icon> <a :href="link.href">{{ link.title }}</a> + <button v-show="!link.bookmarked" @click="bookmark(link)">Bookmark</button> </div> </links-list>
Привязки
Привязки – это коллекции атрибутов или обработчиков событий, которые ограничиваются определенным элементом с помощью v-bind или v-on.
Они полезны, когда нужно инкапсулировать детали реализации о том, как работать с предоставленным элементом.
Например, вместо того, чтобы потребитель обрабатывал v-show и @click для кнопок добавления в закладки можно предоставить привязки bookmarkButtonAttrs и bookmarkButtonEvents, которые передадут эти детали в сам компонент:
<!-- LinksList.vue --> <template> <!-- ... --> <li v-for="link in links"> <slot name="link" :link="link" :bookmark="bookmark" + :bookmarkButtonAttrs="{ + style: [ link.bookmarked ? { display: none } : {} ] + }" + :bookmarkButtonEvents="{ + click: () => bookmark(link) + }" ></slot> </li> <!-- ... --> </template>
Если потребитель хочет, то теперь можно применить эти привязки к кнопкам bookmark вслепую, не зная, что они делают:
<!-- Parent/Consumer --> <links-list> - <div slot="link" slot-scope="{ link, bookmark }"> + <div slot="link" slot-scope="{ link, bookmarkButtonAttrs, bookmarkButtonEvents }"> <star-icon v-show="link.bookmarked"></star-icon> <a :href="link.href">{{ link.title }}</a> - <button v-show="!link.bookmarked" @click="bookmark(link)">Bookmark</button> + <button + v-bind="bookmarkButtonAttrs" + v-on="bookmarkButtonEvents" + >Bookmark</button> </div> </links-list>
Renderless Components
Renderless Components – это компоненты, которые не рендерят свой HTML. Они просто управляют состоянием и поведением, предоставляя один scoped slot, который позволяет родителю/потребителю контролировать рендеринг. renderless component рендерят ровно то, что вы в них передадите без дополнительных элементов:
<!-- Parent/Consumer --> <renderless-component-example> <h1 slot-scope="{}"> Hello world! </h1> </renderless-component-example> <!-- Renders: --> <h1>Hello world!</h1>
Так в чем их польза?
Разделение представления и поведения
Renderless components работают только с состоянием и поведением, поэтому они не участвуют в дизайне и макете.
То есть если вы сможете найти способ вытащить все интересное поведение из компонента UI, как наш пример с тегами, в renderless component, вы сможете повторно использовать renderless component для реализации любого макета с тегами.
Ниже представлено два макета с тегами, но на этот раз под управлением одного renderless component:
Как это работает?
Структура Renderless Component
Renderless component обладает одним scoped slot, где потребитель может предоставить целый шаблон для рендера. Базовый скелет renderless component:
Vue.component('renderless-component-example', { // Props, data, methods, etc. render() { return this.$scopedSlots.default({ exampleProp: 'universe', }) }, })
В нем нет шаблона, он не рендерит свой HTML. Он использует функцию render, которая выполняет scoped slot по умолчанию, передавая в него любые слот свойства, и возвращает результат.
Любой родитель/потребитель этого компонента может деструктурировать exampleProp из slot-scope и использовать его в своем шаблоне:
<!-- Parent/Consumer --> <renderless-component-example> <h1 slot-scope="{ exampleProp }"> Hello {{ exampleProp }}! </h1> </renderless-component-example> <!-- Renders: --> <h1>Hello universe!</h1>
Рабочий пример
Давайте создадим renderless версию тегов с нуля. Начнем с пустого renderless component, который не передает слот свойства:
/* Renderless Tags Input Component */ export default { render() { return this.$scopedSlots.default({}) }, }
… и родительского компонента со статичным неинтерактивным UI, который мы передаем в слот дочернего компонента:
<!-- Parent component --> <template> <renderless-tags-input> <div slot-scope="{}" class="tags-input"> <span class="tags-input-tag"> <span>Testing</span> <span>Design</span> <button type="button" class="tags-input-remove">×</button> </span> <input class="tags-input-text" placeholder="Add tag..."> </div> </renderless-tags-input> </template> <script> export default {} </script>
По частям мы приведем в работу этот компонент, добавляя состояние и поведение в renderless component и открывая его нашему макету через slot-scope.
Листинг теги
Во-первых, давайте заменим статичный список тегов на динамичный.
Компонент ввода тегов – это пользовательский элемент формы. Поэтому как в оригинальном примере, теги должны быть в родителе и должны быть ограничены компонентом через v-model.
Начнем с добавления свойства value в компонент и его передачи вверх в виде слот свойства tags:
/* Renderless Tags Input Component */ export default { + props: ['value'], render() { - return this.$scopedSlots.default({}) + return this.$scopedSlots.default({ + tags: this.value, + }) }, }
Затем добавим привязку v-model в родителя, получим теги из slot-scope и пробежимся по ним в цикле через v-for:
<!-- Parent component --> <template> - <renderless-tags-input> + <renderless-tags-input v-model="tags"> - <div slot-scope="{}" class="tags-input"> + <div slot-scope="{ tags }" class="tags-input"> <span class="tags-input-tag"> - <span>Testing</span> - <span>Design</span> + <span v-for="tag in tags">{{ tag }}</span> <button type="button" class="tags-input-remove">×</button> </span> <input class="tags-input-text" placeholder="Add tag..."> </div> </renderless-tags-input> </template> <script> export default { + data() { + return { + tags: ['Testing', 'Design'] + } + } } </script>
Это слот свойство – отличный пример простого свойства данных.
Удаление тегов
Теперь давайте удалим тег по клику на кнопку x.
Добавим новый метод removeTag в наш компонент и передадим ссылку на этот метод вверх в родителя в виде слот свойства:
/* Renderless Tags Input Component */ export default { props: ['value'], + methods: { + removeTag(tag) { + this.$emit('input', this.value.filter(t => t !== tag)) + } + }, render() { return this.$scopedSlots.default({ tags: this.value, + removeTag: this.removeTag, }) }, })
Теперь добавим обработчик @click для кнопки в родителе, которая вызывает removeTag с текущим тегом:
<!-- Parent component --> <template> <renderless-tags-input> - <div slot-scope="{ tags }" class="tags-input"> + <div slot-scope="{ tags, removeTag }" class="tags-input"> <span class="tags-input-tag"> <span v-for="tag in tags">{{ tag }}</span> - <button type="button" class="tags-input-remove">×</button> + <button type="button" class="tags-input-remove" + @click="removeTag(tag)" + >×</button> </span> <input class="tags-input-text" placeholder="Add tag..."> </div> </renderless-tags-input> </template> <script> export default { data() { return { tags: ['Testing', 'Design'] } } } </script>
Это слот свойство – пример свойства действия.
Добавление новых тегов по enter
Добавление новых тегов немного сложнее, чем два последних примера.
Чтобы понять почему, давайте разберем, как бы это было реализовано в обычном компоненте:
<template> <div class="tags-input"> <!-- ... --> <input class="tags-input-text" placeholder="Add tag..." @keydown.enter.prevent="addTag" v-model="newTag" > </div> </template> <script> export default { props: ['value'], data() { return { newTag: '', } }, methods: { addTag() { if (this.newTag.trim().length === 0 || this.value.includes(this.newTag.trim())) { return } this.$emit('input', [...this.value, this.newTag.trim()]) this.newTag = '' }, // ... }, // ... } </script>
Мы отслеживаем новый тег (до его добавления) в свойстве newTag и привязываем это свойство к полю через v-model. После нажатия на enter мы проверяем тег на валидность, добавляем его в список и очищаем поле ввода.
Возникает вопрос – как передать v-model привязку через scoped slot? Если вы хорошо знаете Vue, вы можете знать, что v-model представляет собой лишь синтаксический сахар для привязки атрибута :value и привязки события @input:
<input class="tags-input-text" placeholder="Add tag..." @keydown.enter.prevent="addTag" - v-model="newTag" + :value="newTag" + @input="(e) => newTag = e.target.value" >
То есть мы можем обработать это поведение в нашем renderless component, внеся пару изменений:
Добавим локальное свойство данных newTag в компонент
Вернем свойство привязки атрибута, которое привязывает :value к newTag
Вернем свойство привязки события, которое привязывает @keydown.enter к addTag и @input к обновлению newTag
/* Renderless Tags Input Component */ export default { props: ['value'], + data() { + return { + newTag: '', + } + }, methods: { + addTag() { + if (this.newTag.trim().length === 0 || this.value.includes(this.newTag.trim())) { + return + } + this.$emit('input', [...this.value, this.newTag.trim()]) + this.newTag = '' + }, removeTag(tag) { this.$emit('input', this.value.filter(t => t !== tag)) } }, render() { return this.$scopedSlots.default({ tags: this.value, removeTag: this.removeTag, + inputAttrs: { + value: this.newTag, + }, + inputEvents: { + input: (e) => { this.newTag = e.target.value }, + keydown: (e) => { + if (e.keyCode === 13) { + e.preventDefault() + this.addTag() + } + } + } }) }, }
Осталось лишь привязать эти свойства к полю ввода в родителе:
<template> <renderless-tags-input> - <div slot-scope="{ tags, removeTag }" class="tags-input"> + <div slot-scope="{ tags, removeTag, inputAttrs, inputEvents }" class="tags-input"> <span class="tags-input-tag"> <span v-for="tag in tags">{{ tag }}</span> <button type="button" class="tags-input-remove" @click="removeTag(tag)" >×</button> </span> - <input class="tags-input-text" placeholder="Add tag..."> + <input class="tags-input-text" placeholder="Add tag..." + v-bind="inputAttrs" + v-on="inputEvents" + > </div> </renderless-tags-input> </template> <script> export default { data() { return { tags: ['Testing', 'Design'] } } } </script>
Явное добавление новых тегов
В нашем примере пользователь добавляет новые теги путем ввода и нажатия enter. Легко представить ситуацию, когда кто-то захочет иметь кнопку, по клику на которую будут добавляться новые теги.
Сделать это легко. Нам лишь нужно передать ссылку на наш метод addTag в slot scope:
/* Renderless Tags Input Component */ export default { // ... methods: { addTag() { if (this.newTag.trim().length === 0 || this.value.includes(this.newTag.trim())) { return } this.$emit('input', [...this.value, this.newTag.trim()]) this.newTag = '' }, // ... }, render() { return this.$scopedSlots.default({ tags: this.value, + addTag: this.addTag, removeTag: this.removeTag, inputAttrs: { // ... }, inputEvents: { // ... } }) }, }
При проектировании renderless components как этот лучше перебрать с количеством слот свойств, чем не добрать. Потребителю нужно лишь деструктурировать необходимые свойства. Поэтому передача лишних свойств никак на них не повлияет.
Рабочее демо
Ниже представлено рабочее демо renderless компонента ввода тегов, которое мы создали:
Настоящий компонент не содержит HTML, а в родителе, где мы задали шаблон, нет поведения. Аккуратно, правда?
Альтернативный макет
Мы создали renderless версию поля ввода тегов. Теперь легко можно реализовать альтернативные макеты, написав любой HTML и применив слот свойства в нужных местах.
Ниже представлен пример с реализацией для вертикального макета из начала статьи с использованием нового renderless component:
Создание упрямых компонентов-оберток
Вы можете посмотреть на эти примеры и подумать «ого, нужно писать так много HTML, когда нужно добавить еще один объект этого компонента тегов!». И вы будете правы.
Нужно действительно много писать каждый раз, когда нужен элемент ввода тегов:
<renderless-tags-input v-model="tags"> <div class="tags-input" slot-scope="{ tags, removeTag, inputAttrs, inputEvents }"> <span class="tags-input-tag" v-for="tag in tags"> <span>{{ tag }}</span> <button type="button" class="tags-input-remove" @click="removeTag(tag)">×</button> </span> <input class="tags-input-text" placeholder="Add tag..." v-on="inputEvents" v-bind="inputAttrs"> </div> </renderless-tags-input>
… в отличие от того, что было у нас в начале:
<tags-input v-model="tags"></tags-input>
Но есть легкий фикс: создайте упрямый компонент-обертку!
Вот как выглядит наш оригинальный компонент
<!-- InlineTagsInput.vue --> <template> <renderless-tags-input :value="value" @input="(tags) => { $emit('input', tags) }"> <div class="tags-input" slot-scope="{ tag, removeTag, inputAttrs, inputEvents }"> <span class="tags-input-tag" v-for="tag in tags"> <span>{{ tag }}</span> <button type="button" class="tags-input-remove" @click="removeTag(tag)">×</button> </span> <input class="tags-input-text" placeholder="Add tag..." v-bind="inputAttrs" v-on="inputEvents" > </div> </renderless-tags-input> </template> <script> export default { props: ['value'], } </script>
Теперь можно использовать этот компонент одной строкой кода в любом месте в макете:
<inline-tags-input v-model="tags"></inline-tags-input>
Сходим с ума
Как только вы понимаете, что компонент не должен рендерить что-либо и может предоставлять только данные, типы поведений, которые можно смоделировать с компонентом, становятся безлимитными.
Например, ниже представлен компонент fetch-data, который принимает URL как свойство, получает JSON из URL и передает ответ обратно к родителю:
Правильно ли так делать все AJAX запросы? Возможно, нет. Но это точно интересно!
Заключение
Разбиение компонента на компонент представления и renderless component — невероятно полезный шаблон, который стоит изучить. Он может облегчить повторное использование кода, но не всегда.
Используйте этот подход, если:
Вы пишите библиотеку и хотите упростить пользователям кастомизацию внешнего вида компонентов
У вас много компонентов в проекте с похожим поведением, но разными макетами
Не идите этим путем, если вы работаете над компонентом, который будет выглядеть одинаково везде, где используется. Намного проще хранить все в одном компоненте, если только это и нужно.
Автор: Adam Wathan
Источник: https://adamwathan.me/
Редакция: Команда webformyself.