Практический пример использования render-функций Vue: создание типографской сетки для дизайн-системы

в 9:30, , рубрики: javascript, vue.js, vuejs, Блог компании RUVDS.com, дизайн, разработка, Разработка веб-сайтов

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

Практический пример использования render-функций Vue: создание типографской сетки для дизайн-системы - 1

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

Практический пример использования render-функций Vue: создание типографской сетки для дизайн-системы - 2

Макет страницы

А вот пример соответствующего этой странице 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

В обычных условиях такой подход используется чаще всего. Однако взгляните на этот пример.

Практический пример использования render-функций Vue: создание типографской сетки для дизайн-системы - 3

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

  1. HTML-тег (например — div).
  2. Объект с данными, содержащий атрибуты шаблона (например — { class: 'something'}).
  3. Текстовые строки (если мы просто добавляем текст) или дочерние узлы, созданные с использованием h.

Вот как это выглядит:

render(h, { props }) {
  return h("div", { class: "example-class" }, "Here's my example text")
}

Подведём краткие итоги по тому, что мы уже создали. А именно, сейчас у нас есть следующее:

  1. Файл с данными, которые планируется использовать при формировании страницы.
  2. Обычный компонент Vue, в котором выполняется импорт файла данных.
  3. Каркас функционального компонента, который ответственен за вывод строк таблицы.

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

  1. Трансформировать данные в стандартизированный формат.
  2. Вывести трансформированные данные.

Трансформация данных

Мне хотелось, чтобы мои данные были бы представлены в формате, который соответствовал бы аргументам, принимаемым 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;
}

Посмотрим ещё раз на макет.

Практический пример использования render-функций Vue: создание типографской сетки для дизайн-системы - 4

Макет страницы

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

Стоит ли полученный результат затраченных усилий? Я так думаю, что тут надо смотреть по обстоятельствам. Это, впрочем, весьма характерно для программирования. Хочу сказать, что у меня в голове, в процессе работы над этим проектом, постоянно появлялась следующая картинка.

Практический пример использования render-функций Vue: создание типографской сетки для дизайн-системы - 5

Возможно, это и есть ответ на мой вопрос о том, стоит ли этот проект усилий, затраченных на его разработку.

Уважаемые читатели! Какие идеи и предложения вы можете высказать по поводу рассмотренного здесь проекта? Какими способами вы решали подобные задачи?

Практический пример использования render-функций Vue: создание типографской сетки для дизайн-системы - 6

Автор: ru_vds

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js