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

В статье рассказывается, как создать сетку для системы проектирования с использованием функций рендеринга Vue (демо и код примера). Я использовал функции рендеринга, потому что они позволяют создавать HTML с более высоким уровнем контроля.

В то время как основная часть Vue характеризуется простотой, функции рендеринга представляют собой трудную для чтения смесь HTML и JavaScript.

Например, чтобы отобразить:

<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")
  ])
}

Но, несмотря на это, функции рендеринга и функциональные компоненты способны на многое.

Определение критериев будущей дизайн-системы

Моя команда хотела добавить в дизайн-систему веб-страницу на основе VuePress, которая демонстрирует различные варианты типографики. Это часть макета, который я получил от дизайнера.

Определение критериев будущей дизайн-системы

Несколько примеров типографики:

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;
}

Заголовки ориентированы на название тегов. Другие элементы используют имена классов. Заданы отдельные классы для толщины и размера.

Вот несколько правил, которых я придерживаюсь при написании HTML-разметки:

  • Данные должны храниться в отдельном файле.
  • Заголовки должны использовать теги заголовков (например, <h1><h2> и т. д.) вместо классов.
  • В контенте должны использоваться теги абзаца (<p>) с именем класса (например, <p class="body-text--lg">).
  • Дочерние элементы должны быть обернуты в <span> с соответствующим именем класса.
<p>
  <span class="body-text--lg">Thing 1</span>
  <span class="body-text--lg">Thing 2</span>
</p>
  • Контент без индивидуальных стилей должен помещаться в тег абзаца с соответствующим именем класса <span> для любых дочерних узлов.
<p class="body-text--semibold">
  <span>Thing 1</span>
  <span>Thing 2</span>
</p>
  • Имена классов должны использоваться только один раз для каждой ячейки.

Почему стоит использовать функции рендеринга

Прежде чем начать, я рассмотрел несколько вариантов реализации.

Написание кода вручную

Написание 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

Это наиболее предпочтительный вариант. Но учтите следующее:

В первом столбце мы имеем:

  • Тег <h1> , отображаемый как есть.
  • Тег <p>, который группирует несколько дочерних <span> с текстом, у каждого из которых задан класс (но у тега <p> специального класса нет).
  • Тег <p> с классом и без потомков.

Это означало бы частое использование v-if и v-if-else, которые сделают код запутанным. Мне также не понравилась вся эта условная логика внутри разметки.

По этим причинам я выбрал функции рендеринга. Они используют JavaScript для условного создания дочерних узлов на основе определенных критериев.

Модель данных

Я хотел бы хранить данные типографики в отдельном файле JSON, чтобы можно было легко вносить изменения без редактирования HTML-разметки. Вот необработанные данные.

Каждый объект в файле представляет собой отдельную строку.

{
  "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 содержит классы, которые общие для всех узлов ячейки. Каждый класс в 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"]
}

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

<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. Он создает строку и содержит функцию рендеринга.

Я перебираю компонент строки, передавая данные строки в качестве свойства.

<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")
}

Чтобы создать каждую строку, данные из файла JSON необходимо передать в аргументы h. Для этого:

  1. Преобразуем данные в предсказуемый формат.
  2. Визуализируем преобразованные данные.

Преобразование общих данных

Я хотел, чтобы мои данные были представлены в формате, который соответствовал бы аргументам h. Предполагаемая структура:

// Одна ячейка
{
  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;
}

Давайте еще раз посмотрим на шаблон.

Преобразование общих данных

В первом столбце используется несколько вариантов стилей. Но остальные, следуют той же схеме. Поэтому давайте начнем с них.

Предполагаемая структура каждой ячейки:

{
  tag: "",
  cellClass: "", 
  text: "", 
  children: []
}

Это создает древовидную структуру каждой ячейки. Для создания ячеек используем две функции:

  • createNode принимает каждое из свойств в качестве аргументов.
  • createCell оборачивает createNode так, что можно проверить, является ли текст, который мы передаем, массивом. Если это так, то создаем массив дочерних узлов.
// Модель для каждой ячейки
function createCellData(tag, text) {
  let children;
  // Базовый класс, который применяется к каждому тегу корневой ячейки
  const nodeClass = "body-text body-text--md body-text--semibold";
  // Если текст, который мы передаем, является массивом, создаем дочерний элемент, который оборачиваем в 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"]
},

Также помните, что у заголовков нет классов. Поэтому нужно отобразить имена тегов заголовков для этих строк (например, h1h2 и т. д.).

Создадим несколько вспомогательных функций для анализа данных в формате, который можно использовать для текстового аргумента.

// Передаем базовый тег и имена классов, в качестве аргументов
function displayClasses(element, classes) {
  // Если нет классов, возвращаем базовый тег (соответствующий заголовкам)
}

// Возвращаем класс узла, как строку (если есть один класс), массив (если есть несколько классов) или null (если нет ни одного класса) .
// Например, "body-text body-text--sm" или ["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">

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

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);
}

Теперь у нас есть данные строки в нормализованном формате. Их можно передать функции рендеринга.

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, // Добавляем класс "group", так как у нас есть несколько узлов

          [data.cellClass]: data.cellClass // Если класс ячейки отличен от null, применяем его к узлу
        }
      },
      // Содержимое узла
      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.

Пожалуйста, оставьте ваши комментарии по текущей теме статьи. За комментарии, отклики, дизлайки, лайки, подписки низкий вам поклон!

Вадим Дворниковавтор-переводчик статьи «A Practical Use Case for Vue Render Functions: Building a Design System Typography Grid»