В материале, перевод которого мы сегодня публикуем, речь пойдёт о том, как создать типографскую сетку для дизайн-системы с использованием render-функций Vue. Вот демонстрационная версия проекта, который мы будем здесь рассматривать. Здесь можно найти его код. Автор этого материала говорит, что использовал render-функции из-за того, что они позволяют гораздо точнее контролировать процесс создания HTML-кода, чем обычные шаблоны Vue. Однако, к своему удивлению, он не смог найти практических примеров их применения. Ему попадались лишь учебные руководства. Он надеется на то, что этот материал изменит ситуацию в лучшую сторону благодаря тому, что здесь приводится практический пример использования render-функций Vue.
Render-функции Vue
Render-функции всегда казались мне чем-то таким, что немного несвойственно Vue. Всё в этом фреймворке подчёркивает стремление к простоте и к разделению обязанностей различных сущностей. А вот render-функции представляют собой странную смесь из HTML и JavaScript, которую часто сложно бывает читать.
Например, вот HTML-разметка:
<div class="container">
<p class="my-awesome-class">Some cool text</p>
</div>
Для её формирования нужна следующая функция:
render(createElement) {
return createElement("div", { class: "container" }, [
createElement("p", { class: "my-awesome-class" }, "Some cool text")
])
}
Подозреваю, что такие конструкции заставят многих сразу же отвернуться от render-функций. Ведь простота использования — это именно то, что привлекает разработчиков в Vue. Жаль, если за неприглядной внешностью render-функций многие не увидят их истинных достоинств. Всё дело в том, что render-функции и функциональные компоненты — это интересные и мощные инструменты. Я, для того, чтобы продемонстрировать их возможности и их истинную ценность, расскажу о том, как они помогли мне решить реальную задачу.
Обратите внимание на то, что очень полезно будет открыть демо-версию рассматриваемого здесь проекта в соседней вкладке браузера и обращаться к ней в процессе чтения статьи.
Определение критериев для дизайн-системы
У нас имеется дизайн-система, основанная на VuePress. Нам понадобилось включить в неё новую страницу, демонстрирующую различные типографские возможности оформления текстов. Вот как выглядел макет, который дал мне дизайнер.
Макет страницы
А вот пример соответствующего этой странице CSS-кода:
h1, h2, h3, h4, h5, h6 {
font-family: "balboa", sans-serif;
font-weight: 300;
margin: 0;
}
h4 {
font-size: calc(1rem - 2px);
}
.body-text {
font-family: "proxima-nova", sans-serif;
}
.body-text--lg {
font-size: calc(1rem + 4px);
}
.body-text--md {
font-size: 1rem;
}
.body-text--bold {
font-weight: 700;
}
.body-text--semibold {
font-weight: 600;
}
Заголовки форматируются на основе имён тегов. Для форматирования других элементов используются имена классов. Кроме того, тут предусмотрены отдельные классы для насыщенности и размеров шрифтов.
Я, прежде чем приступать к написанию кода, сформулировал некоторые правила:
- Так как основная цель этой страницы — визуализация данных — данные должны храниться в отдельном файле.
- Для форматирования заголовков должны использоваться семантические теги заголовков (то есть —
<h1>
,<h2>
и так далее), их форматирование не должно быть основано на классе. - В теле страницы должны использоваться теги абзацев (
<p>
) с именами классов (например —<p class="body-text--lg">
). - Материалы, состоящие из различных элементов, должны быть сгруппированы путём оборачивая их в корневой тег
<p>
, или в другой подходящий корневой элемент, которому не назначен класс стилизации. Дочерние элементы должны быть обёрнуты в тег<span>
, в котором задаётся имя класса. Вот как может выглядеть применение этого правила:<p> <span class="body-text--lg">Thing 1</span> <span class="body-text--lg">Thing 2</span> </p>
- Материалы, при выводе которых особых требований не предъявляется, должны быть обёрнуты в тег
<p>
, которому назначено нужное имя класса. Дочерние элементы должны быть заключены в тег<span>
:<p class="body-text--semibold"> <span>Thing 1</span> <span>Thing 2</span> </p>
- Для оформления каждой стилизуемой ячейки имена классов должны записываться лишь один раз.
Варианты решения задачи
Я, прежде чем приступить к работе, рассмотрел несколько вариантов решения поставленной передо мной задачи. Вот их обзор.
▍Ручное написание HTML-кода
Мне нравится писать HTML-код вручную, но только тогда, когда это позволяет адекватным образом решить имеющуюся задачу. Однако в моём случае ручное написание кода означало бы ввод различных повторяющихся фрагментов кода, в которых присутствуют некоторые вариации. Мне это не понравилось. Кроме того, это означало бы, что данные нельзя будет хранить в отдельном файле. В итоге от такого подхода я отказался.
Если бы я создавал страницу, о которой идёт речь, именно так, то получилось бы у меня примерно следующее:
<div class="row">
<h1>Heading 1</h1>
<p class="body-text body-text--md body-text--semibold">h1</p>
<p class="body-text body-text--md body-text--semibold">Balboa Light, 30px</p>
<p class="group body-text body-text--md body-text--semibold">
<span>Product title (once on a page)</span>
<span>Illustration headline</span>
</p>
</div>
▍Использование традиционных шаблонов Vue
В обычных условиях такой подход используется чаще всего. Однако взгляните на этот пример.
Пример использования шаблонов Vue
В первой колонке тут имеется следующее:
- Тег
<h1>
, который представлен в том виде, в каком его выводит браузер. - Тег
<p>
, группирующий несколько дочерних элементов<span>
с текстом. Каждому из этих элементов назначен класс (но самому тегу<p>
особого класса не назначено). - Тег
<p>
, не имеющий вложенных элементов<span>
, которому назначен класс.
Для реализации всего этого понадобилось бы много экземпляров директив v-if
и v-if-else
. А это, я знаю, привело бы к тому, что код очень скоро стал бы весьма запутанным. Кроме того, мне не нравится использование в разметке всей этой условной логики.
▍Render-функции
В результате, проанализировав возможные альтернативы, я выбрал render-функции. В них, средствами JavaScript, с использованием условных конструкций, создаются дочерние узлы других узлов. При создании этих дочерних узлов учитываются все необходимые критерии. В данной ситуации такое решение показалось мне идеальным.
Модель данных
Как я уже говорил, мне хотелось хранить типографские данные в отдельном JSON-файле. Это позволило бы, при необходимости, вносить в них изменения, не прикасаясь к разметке. Вот эти данные.
Каждый JSON-объект в файле представляет собой описание отдельной строки:
{
"text": "Heading 1",
"element": "h1", // Корневой элемент.
"properties": "Balboa Light, 30px", // Третий столбец текста.
"usage": ["Product title (once on a page)", "Illustration headline"] // Четвёртый столбец текста. Каждый элемент - это дочерний узел.
}
Вот HTML-код, который получается после обработки этого объекта:
<div class="row">
<h1>Heading 1</h1>
<p class="body-text body-text--md body-text--semibold">h1</p>
<p class="body-text body-text--md body-text--semibold">Balboa Light, 30px</p>
<p class="group body-text body-text--md body-text--semibold">
<span>Product title (once on a page)</span>
<span>Illustration headline</span>
</p>
</div>
Теперь рассмотрим более сложный пример. Массивы представляют группы дочерних элементов. Свойства объектов classes
, которые сами являются объектами, могут хранить описания классов. Свойство base
объекта classes
содержит описание классов, общих для всех узлов в ячейке. Каждый класс, присутствующий в свойстве variants
, применяется к отдельному элементу в группе.
{
"text": "Body Text - Large",
"element": "p",
"classes": {
"base": "body-text body-text--lg", // Применяется к каждому дочернему узлу.
"variants": ["body-text--bold", "body-text--regular"] //Этот массив обходят в цикле, один класс применяется к одному из примеров. Каждый элемент в массиве представляет собой отдельный узел.
},
"properties": "Proxima Nova Bold and Regular, 20px",
"usage": ["Large button title", "Form label", "Large modal text"]
}
Этот объект превращается в следующий HTML-код:
<div class="row">
<!-- Столбец 1 -->
<p class="group">
<span class="body-text body-text--lg body-text--bold">Body Text - Large</span>
<span class="body-text body-text--lg body-text--regular">Body Text - Large</span>
</p>
<!-- Столбец 2 -->
<p class="group body-text body-text--md body-text--semibold">
<span>body-text body-text--lg body-text--bold</span>
<span>body-text body-text--lg body-text--regular</span>
</p>
<!-- Столбец 3 -->
<p class="body-text body-text--md body-text--semibold">Proxima Nova Bold and Regular, 20px</p>
<!-- Столбец 4 -->
<p class="group body-text body-text--md body-text--semibold">
<span>Large button title</span>
<span>Form label</span>
<span>Large modal text</span>
</p>
</div>
Базовая структура проекта
У нас имеется родительский компонент TypographyTable.vue
, который содержит разметку для формирования таблицы. Также у нас есть дочерний компонент, TypographyRow.vue
, который ответственен за создание строки таблицы и содержит нашу render-функцию.
При формировании строк таблицы выполняется обход массива с данными. Объекты, описывающие строки таблицы, передаются компоненту TypographyRow
в качестве свойств.
<template>
<section>
<!-- Заголовок таблицы жёстко задан в коде ради простоты -->
<div class="row">
<p class="body-text body-text--lg-bold heading">Hierarchy</p>
<p class="body-text body-text--lg-bold heading">Element/Class</p>
<p class="body-text body-text--lg-bold heading">Properties</p>
<p class="body-text body-text--lg-bold heading">Usage</p>
</div>
<!-- Обходим массив с данными и передаём данные каждой строке в качестве свойств -->
<typography-row
v-for="(rowData, index) in $options.typographyData"
:key="index"
:row-data="rowData"
/>
</section>
</template>
<script>
import TypographyData from "@/data/typography.json";
import TypographyRow from "./TypographyRow";
export default {
// Мы работаем со статическими данными, поэтому нет нужды делать таблицу реактивной
typographyData: TypographyData,
name: "TypographyTable",
components: {
TypographyRow
};
</script>
Тут хотелось бы отметить одну приятную мелочь: типографские данные в экземпляре Vue могут быть представлены в виде свойства. Обращаться к ним можно с помощью конструкции $options.typographyData
так как они не меняются и не должны быть реактивными (благодарю Антона Косых).
Создание функционального компонента
Компонент TypographyRow
, который обрабатывает данные, представляет собой функциональный компонент. Функциональные компоненты — это сущности, не имеющие состояний и экземпляров. Это означает, что у них нет this
, и то, что у них нет доступа к методам жизненного цикла компонентов Vue.
Вот «скелет» подобного компонента, с которого мы начнём работу над нашим компонентом:
// Нет <template>
<script>
export default {
name: "TypographyRow",
functional: true, // Это свойство делает компонент функциональным
props: {
rowData: { // Свойство с данными строки
type: Object
},
render(createElement, { props }) {
// Здесь выводится разметка
}
</script>
Метод компонента render
принимает аргумент context
, у которого есть свойство props
. Это свойство подвергается деструктурированию и используется как второй аргумент.
Первым аргументом является createElement
. Это — функция, которая сообщает Vue о том, какой узел нужно создать. Ради краткости и стандартизации кода я использую для createElement
сокращение h
. О том, почему я так поступил, можете почитать здесь.
Итак, h
принимает три аргумента:
- HTML-тег (например —
div
). - Объект с данными, содержащий атрибуты шаблона (например —
{ class: 'something'}
). - Текстовые строки (если мы просто добавляем текст) или дочерние узлы, созданные с использованием
h
.
Вот как это выглядит:
render(h, { props }) {
return h("div", { class: "example-class" }, "Here's my example text")
}
Подведём краткие итоги по тому, что мы уже создали. А именно, сейчас у нас есть следующее:
- Файл с данными, которые планируется использовать при формировании страницы.
- Обычный компонент Vue, в котором выполняется импорт файла данных.
- Каркас функционального компонента, который ответственен за вывод строк таблицы.
Для создания строк таблицы данные из формата JSON должны быть переданы в виде аргумента для h
. Можно передать все подобные данные за один заход, но при таком подходе понадобится большой объём условной логики, что ухудшит понятность кода. Вместо этого я решил поступить так:
- Трансформировать данные в стандартизированный формат.
- Вывести трансформированные данные.
Трансформация данных
Мне хотелось, чтобы мои данные были бы представлены в формате, который соответствовал бы аргументам, принимаемым h
. Но, прежде чем их преобразовывать, я спланировал то, какую структуру они должны иметь в JSON-файле:
// Одна ячейка
{
tag: "", // HTML-тег текущего уровня
cellClass: "", // Класс текущего уровня. Если класса на данном уровне нет - здесь будет null
text: "", // Текст, который нужно вывести
children: [] // Описание дочерних узлов, которое следует той же модели. Пустой массив в том случае, если дочерних узлов нет.
}
Каждый объект представляет собой одну ячейку таблицы. Каждую строку таблицы формируют четыре ячейки (они собраны в массив):
// Одна строка
[ { cell1 }, { cell2 }, { cell3 }, { cell4 } ]
Входной точкой может быть функция наподобие следующей:
function createRow(data) { // Сюда поступают данные для одной строки, в ходе работы функции создаются ячейки таблицы
let { text, element, classes = null, properties, usage } = data;
let row = [];
row[0] = createCellData(data) // Трансформируем данные с использованием некоей общедоступной функции
row[1] = createCellData(data)
row[2] = createCellData(data)
row[3] = createCellData(data)
return row;
}
Посмотрим ещё раз на макет.
Макет страницы
Можно видеть, что в первом столбце элементы стилизованы по-разному. А в остальных столбцах используется одно и то же форматирование. Поэтому давайте начнём с этого.
Напомню, что в качестве модели описания каждой ячейки мне хотелось бы использовать следующую JSON-структуру:
{
tag: "",
cellClass: "",
text: "",
children: []
}
При таком подходе для описания каждой ячейки будет применяться структура, напоминающая дерево. Это именно так из-за того, что некоторые ячейки содержат группы дочерних элементов. Воспользуемся для создания ячеек следующими двумя функциями:
- Функция
createNode
принимает каждое из интересующих нас свойств в виде аргумента. - Функция
createCell
играет роль обёртки вокругcreateNode
, с её помощью мы проверяем, является ли аргументtext
массивом. Если это так — мы создаём массив дочерних элементов.
// Модель для ячеек
function createCellData(tag, text) {
let children;
// Базовые классы, которые применяются к каждому корневому тегу ячейки
const nodeClass = "body-text body-text--md body-text--semibold";
// Если аргумент text является массивом - создадим дочерние элементы, обёрнутые в теги span.
if (Array.isArray(text)) {
children = text.map(child => createNode("span", null, child, children));
return createNode(tag, nodeClass, text, children);
}
// Модель для узлов
function createNode(tag, nodeClass, text, children = []) {
return {
tag: tag,
cellClass: nodeClass,
text: children.length ? null : text,
children: children
};
}
Теперь мы можем поступить примерно так:
function createRow(data) {
let { text, element, classes = null, properties, usage } = data;
let row = [];
row[0] = ""
row[1] = createCellData("p", ?????) // Нужно передать имена классов в виде текста
row[2] = createCellData("p", properties) // Третий столбец
row[3] = createCellData("p", usage) // Четвёртый столбец
return row;
}
При формировании третьего и четвёртого столбцов мы передаём properties
и usage
в виде текстовых аргументов. Однако второй столбец от третьего и четвёртого отличается. Тут мы выводим имена классов, которые, в исходных данных, хранятся в таком виде:
"classes": {
"base": "body-text body-text--lg",
"variants": ["body-text--bold", "body-text--regular"]
},
Кроме того, не будем забывать о том, что при работе с заголовками классы не используются. Поэтому нам нужно сформировать имена тегов заголовков для соответствующих строк (то есть — h1
, h2
, и так далее).
Создадим вспомогательные функции, которые позволяют преобразовать эти данные в формат, который облегчает их использование в виде аргумента text
.
// Передаём базовый тег и имена классов в виде аргументов
function displayClasses(element, classes) {
// Если классов нет, тогда возвращаем базовый тег (это подходит для заголовков)
return getClasses(classes) ? getClasses(classes) : element;
}
// Возвращаем класс узла в виде строки (если имеется лишь один класс) или в виде массива (если есть несколько классов), либо возвращаем null (в том случае, если классов нет)
// Например: "body-text body-text--sm" or ["body-text body-text--sm body-text--bold", "body-text body-text--sm body-text--italic"]
function getClasses(classes) {
if (classes) {
const { base, variants = null } = classes;
if (variants) {
// Конкатенируем каждый из вариантов с базовыми классами
return variants.map(variant => base.concat(`${variant}`));
return base;
return classes;
}
Теперь мы можем сделать следующее:
function createRow(data) {
let { text, element, classes = null, properties, usage } = data;
let row = [];
row[0] = ""
row[1] = createCellData("p", displayClasses(element, classes)) // Второй столбец
row[2] = createCellData("p", properties) // Третий столбец
row[3] = createCellData("p", usage) // Четвёртый столбец
return row;
}
Трансформация данных, используемых для демонстрации стилей
Нам нужно решить — что делать с первым столбцом таблицы, демонстрирующем примеры применения стилей. Этот столбец отличается от других. Здесь мы применяем новые теги и классы к каждой ячейке вместо того, чтобы использовать комбинацию классов, используемую остальными столбцами:
<p class="body-text body-text--md body-text--semibold">
Вместо того чтобы попытаться реализовать этот функционал в createCellData
или в createNodeData
, предлагаю создать новую функцию, которая будет пользоваться возможностями этих базовых функций, выполняющих трансформацию данных. В ней будет реализован новый механизм обработки данных:
function createDemoCellData(data) {
let children;
const classes = getClasses(data.classes);
// В тех случаях, когда нам приходится работать с несколькими классами, нам нужно создать дочерние элементы и применить каждый класс к каждому дочернему элементу
if (Array.isArray(classes)) {
children = classes.map(child =>
// Мы можем использовать "data.text" так как каждый узел в группе, имеющейся в ячейке, содержит один и тот же текст
createNode("span", child, data.text, children)
);
// Обрабатываем тот случай, когда имеется лишь один класс
if (typeof classes === "string") {
return createNode("p", classes, data.text, children);
// Обрабатываем случаи, когда классов нет (для заголовков)
return createNode(data.element, null, data.text, children);
}
Теперь данные строк приведены к нормализованному формату и их можно передать render-функции:
function createRow(data) {
let { text, element, classes = null, properties, usage } = data
let row = []
row[0] = createDemoCellData(data)
row[1] = createCellData("p", displayClasses(element, classes))
row[2] = createCellData("p", properties)
row[3] = createCellData("p", usage)
return row
}
Рендеринг данных
Вот как выполняется рендеринг данных, которые выводятся на страницу:
// Обращаемся к данным, находящимся в объекте "props"
const rowData = props.rowData;
// Передаём их функции, используемой для трансформации данных
const row = createRow(rowData);
// Создаём корневой узел "div" и обрабатываем каждую ячейку
return h("div", { class: "row" }, row.map(cell => renderCells(cell)));
// Обходим значения ячейки
function renderCells(data) {
// Обработка ячеек, имеющих несколько дочерних узлов
if (data.children.length) {
return renderCell(
data.tag, // Используем базовый тег ячейки
{ // Тут работаем с атрибутами
class: {
group: true, // Добавляем класс для "группы" так как здесь несколько узлов
[data.cellClass]: data.cellClass // Если класс ячейки не представлен значением, применяем его к данному узлу
},
// Содержимое узла
data.children.map(child => {
return renderCell(
child.tag,
{ class: child.cellClass },
child.text
);
})
);
// Если дочерних элементов нет - выводим базовую ячейку
return renderCell(data.tag, { class: data.cellClass }, data.text);
}
// Функция-обёртка вокруг "h" для улучшения читабельности кода
function renderCell(tag, classArgs, text) {
return h(tag, classArgs, text);
}
Теперь всё готово! Вот, снова, исходный код.
Итоги
Стоит сказать, что рассмотренный здесь подход представляет собой экспериментальный способ решения довольно-таки тривиальной задачи. Я уверен, что многие будут говорить о том, что это решение неоправданно усложнено и перегружено инженерными излишествами. Возможно, я с этим соглашусь.
Несмотря на то, что разработка этого проекта заняла немало времени, данные теперь полностью отделены от представления. Теперь, если наши дизайнеры заходят добавить какие-то строки в таблицу, или удалить из неё какие-нибудь из существующих строк, мне не придётся разгребать запутанный HTML-код. Для того чтобы это сделать, мне достаточно будет поменять несколько свойств в JSON-файле.
Стоит ли полученный результат затраченных усилий? Я так думаю, что тут надо смотреть по обстоятельствам. Это, впрочем, весьма характерно для программирования. Хочу сказать, что у меня в голове, в процессе работы над этим проектом, постоянно появлялась следующая картинка.
Возможно, это и есть ответ на мой вопрос о том, стоит ли этот проект усилий, затраченных на его разработку.
Уважаемые читатели! Какие идеи и предложения вы можете высказать по поводу рассмотренного здесь проекта? Какими способами вы решали подобные задачи?
Автор: ru_vds