Un caso de uso práctico para las funciones de Vue Render: creación de una cuadrícula de tipografía de sistema de diseño | Programar Plus

Esta publicación cubre cómo construí una cuadrícula de tipografía para un sistema de diseño usando las funciones de renderizado de Vue. Aquí está la demostración y el código. Utilicé funciones de renderizado porque te permiten crear HTML con un mayor nivel de control que las plantillas de Vue normales, pero sorprendentemente no pude encontrar mucho cuando busqué en la web aplicaciones de la vida real que no fueran tutoriales. Espero que esta publicación llene ese vacío y proporcione un caso de uso útil y práctico sobre el uso de las funciones de renderizado de Vue.

Siempre he encontrado que las funciones de renderización están un poco fuera de lugar para Vue. Si bien el resto del marco enfatiza la simplicidad y la separación de preocupaciones, las funciones de renderización son una combinación extraña y, a menudo, difícil de leer de HTML y JavaScript.

Por ejemplo, para mostrar:

<div class="container">
  <p class="my-awesome-class">Some cool text</p>
</div>

…necesitas:

render(createElement) {
  return createElement("div", { class: "container" }, [
    createElement("p", { class: "my-awesome-class" }, "Some cool text")
  ])
}

Sospecho que esta sintaxis apaga a algunas personas, ya que la facilidad de uso es una razón clave para buscar Vue en primer lugar. Es una lástima porque las funciones de renderizado y los componentes funcionales son capaces de hacer cosas muy interesantes y poderosas. Con el ánimo de demostrar su valor, así es como me resolvieron un problema empresarial real.

Descargo de responsabilidad rápido: Será muy útil tener la demostración abierta en otra pestaña para hacer referencia a lo largo de esta publicación.

Definición de criterios para un sistema de diseño

Mi equipo quería incluir una página en nuestro sistema de diseño impulsado por VuePress que mostrara diferentes opciones de tipografía. Esto es parte de una maqueta que obtuve de nuestro diseñador.

Una captura de pantalla del sistema de diseño tipográfico.  Hay cuatro columnas donde la primera muestra el nombre del estilo con el estilo renderizado, la segunda es el elemento o la clase, la tercera muestra las propiedades que hacen los estilos y la cuarta es el uso definido.

Y aquí hay una muestra de algunos de los CSS correspondientes:

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

Los encabezados se orientan con nombres de etiquetas. Otros elementos usan nombres de clases y hay clases separadas para peso y tamaño.

Antes de escribir cualquier código, creé algunas reglas básicas:

  • Dado que se trata realmente de una visualización de datos, los datos deben almacenarse en un archivo separado.
  • Los encabezados deben usar etiquetas de encabezado semántico (p. Ej. <h1>, <h2>, etc.) en lugar de tener que depender de una clase.
  • El contenido del cuerpo debe usar el párrafo (<p>) etiquetas con el nombre de la clase (p. ej. <p class="body-text--lg">).
  • Los tipos de contenido que tienen variaciones deben agruparse envolviéndolos en la etiqueta del párrafo raíz, o el elemento raíz correspondiente, sin una clase de estilo. Los niños deben estar envueltos con <span> y el nombre de la clase.
<p>
  <span class="body-text--lg">Thing 1</span>
  <span class="body-text--lg">Thing 2</span>
</p>
  • Cualquier contenido que no demuestre un estilo especial debe usar una etiqueta de párrafo con el nombre de clase correcto y <span> para cualquier nodo hijo.
<p class="body-text--semibold">
  <span>Thing 1</span>
  <span>Thing 2</span>
</p>
  • Los nombres de las clases solo deben escribirse una vez para cada celda que demuestre el estilo.

Por que las funciones de renderización tienen sentido

Consideré algunas opciones antes de comenzar:

Código difícil

Me gusta codificar cuando es apropiado, pero escribir mi HTML a mano habría significado escribir diferentes combinaciones del marcado, lo que parecía desagradable y repetitivo. También significaba que los datos no se podían guardar en un archivo separado, por lo que descarté este enfoque.

Esto es lo que quiero decir:

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

Usando una plantilla de Vue tradicional

Esta sería normalmente la opción preferida. Sin embargo, considere lo siguiente:

Ver la pluma
Ejemplo de diferentes estilos por Salomone Baquis (@soluhmin)
en CodePen.

En la primera columna tenemos:

– Un <h1>> etiqueta representada como está.
– A <p> etiqueta que agrupa a algunos <span> niños con texto, cada uno con una clase (pero sin una clase especial en el <p> etiqueta).
– A <p> etiqueta con una clase y sin niños.

El resultado habría significado muchos casos de v-if y v-if-else, que sabía que se volvería confuso rápidamente. Tampoco me gustó toda esa lógica condicional dentro del marcado.

Por estas razones, elegí las funciones de render. Las funciones de renderizado usan JavaScript para crear condicionalmente nodos secundarios basados ​​en todos los criterios que se han definido, que parecían perfectos para esta situación.

Modelo de datos

Como mencioné anteriormente, me gustaría mantener los datos de tipografía en un archivo JSON separado para poder realizar cambios fácilmente más adelante sin tocar el marcado. Aquí están los datos sin procesar.

Cada objeto del archivo representa una fila diferente.

{
  "text": "Heading 1",
  "element": "h1", // Root wrapping element.
  "properties": "Balboa Light, 30px", // Third column text.
  "usage": ["Product title (once on a page)", "Illustration headline"] // Fourth column text. Each item is a child node. 
}

El objeto anterior muestra el siguiente 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>

Veamos un ejemplo más complicado. Las matrices representan grupos de niños. A classes El objeto puede almacenar clases. El base La propiedad contiene clases que son comunes a todos los nodos de la agrupación de celdas. Cada clase en variants se aplica a un elemento diferente en la agrupación.

{
  "text": "Body Text - Large",
  "element": "p",
  "classes": {
    "base": "body-text body-text--lg", // Applied to every child node
    "variants": ["body-text--bold", "body-text--regular"] // Looped through, one class applied to each example. Each item in the array is its own node. 
  },
  "properties": "Proxima Nova Bold and Regular, 20px",
  "usage": ["Large button title", "Form label", "Large modal text"]
}

Así es como se traduce:

<div class="row">
  <!-- Column 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>
  <!-- Column 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>
  <!-- Column 3 -->
  <p class="body-text body-text--md body-text--semibold">Proxima Nova Bold and Regular, 20px</p>
  <!-- Column 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>

La configuración básica

Tenemos un componente padre, TypographyTable.vue, que contiene el marcado para el elemento de la tabla contenedora y un componente secundario, TypographyRow.vue, que crea una fila y contiene nuestra función de renderizado.

Recorro el componente de la fila, pasando los datos de la fila como accesorios.

<template>
  <section>
    <!-- Headers hardcoded for simplicity -->
    <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>  
    <!-- Loop and pass our data as props to each row -->
    <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 {
  // Our data is static so we don't need to make it reactive
  typographyData: TypographyData,
  name: "TypographyTable",
  components: {
    TypographyRow
  }
};
</script>

Una cosa interesante para señalar: los datos de tipografía pueden ser una propiedad en la instancia de Vue y se puede acceder a ellos usando $options.typographyData ya que no cambia y no necesita ser reactivo. (Punta de sombrero para Anton Kosykh.)

Hacer un componente funcional

El TypographyRow El componente que transmite datos es un componente funcional. Los componentes funcionales son apátridas y sin instancia, lo que significa que no tienen this y no tiene acceso a ningún método de ciclo de vida de Vue.

El componente inicial vacío se ve así:

// No <template>
<script>
export default {
  name: "TypographyRow",
  functional: true, // This property makes the component functional
  props: {
    rowData: { // A prop with row data
      type: Object
    }
  },
  render(createElement, { props }) {
    // Markup gets rendered here
  }
}
</script>

El render el método toma un context argumento, que tiene un props propiedad que está desestructurada y se utiliza como segundo argumento.

El primer argumento es createElement, que es una función que le dice a Vue qué nodos crear. Por brevedad y convención, abreviaré createElement como h. Puedes leer sobre por qué hago eso en la publicación de Sarah.

h toma tres argumentos:

  1. Una etiqueta HTML (p. Ej. div)
  2. Un objeto de datos con atributos de plantilla (p. Ej. { class: 'something'})
  3. Cadenas de texto (si solo estamos agregando texto) o nodos secundarios construidos usando h
render(h, { props }) {
  return h("div", { class: "example-class" }, "Here's my example text")
}

Bien, para recapitular dónde estamos en este punto, hemos cubierto la creación:

  • un archivo con los datos que se utilizarán en mi visualización;
  • un componente normal de Vue donde estoy importando el archivo de datos completo; y
  • el comienzo de un componente funcional que mostrará cada fila.

Para crear cada fila, los datos del archivo JSON deben pasarse a argumentos para h. Esto se puede hacer todo a la vez, pero implica mucha lógica condicional y es confuso.

En cambio, decidí hacerlo en dos partes:

  1. Transforme los datos en un formato predecible.
  2. Renderiza los datos transformados.

Transformando los datos comunes

Quería mis datos en un formato que coincidiera con los argumentos de h, pero antes de hacer esto, escribí cómo quería estructurar las cosas:

// One cell
{
  tag: "", // HTML tag of current level
  cellClass: "", // Class of current level, null if no class exists for that level
  text: "", // Text to be displayed 
  children: [] // Children each follow this data model, empty array if no child nodes
}

Cada objeto representa una celda, con cuatro celdas que forman cada fila (una matriz).

// One row
[ { cell1 }, { cell2 }, { cell3 }, { cell4 } ]

El punto de entrada sería una función como:

function createRow(data) { // Pass in the full row data and construct each cell
  let { text, element, classes = null, properties, usage } = data;
  let row = [];
  row[0] = createCellData(data) // Transform our data using some shared function
  row[1] = createCellData(data)
  row[2] = createCellData(data)
  row[3] = createCellData(data)

  return row;
}

Echemos otro vistazo a nuestra maqueta.

La primera columna tiene variaciones de estilo, pero el resto parece seguir el mismo patrón, así que comencemos con esas.

Nuevamente, el modelo deseado para cada celda es:

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

Esto nos da una estructura en forma de árbol para cada celda, ya que algunas celdas tienen grupos de hijos. Usemos dos funciones para crear las celdas.

  • createNode toma cada una de nuestras propiedades deseadas como argumentos.
  • createCell envuelve alrededor createNode para que podamos comprobar si el texto que estamos pasando es una matriz. Si es así, construimos una matriz de nodos secundarios.
// Model for each cell
function createCellData(tag, text) {
  let children;
  // Base classes that get applied to every root cell tag
  const nodeClass = "body-text body-text--md body-text--semibold";
  // If the text that we're passing in as an array, create child elements that are wrapped in spans. 
  if (Array.isArray(text)) {
    children = text.map(child => createNode("span", null, child, children));
  }
  return createNode(tag, nodeClass, text, children);
}
// Model for each node
function createNode(tag, nodeClass, text, children = []) {
  return {
    tag: tag,
    cellClass: nodeClass,
    text: children.length ? null : text,
    children: children
  };
}

Ahora podemos hacer algo como:

function createRow(data) {
  let { text, element, classes = null, properties, usage } = data;
  let row = [];
  row[0] = ""
  row[1] = createCellData("p", ?????) // Need to pass in class names as text 
  row[2] = createCellData("p", properties) // Third column
  row[3] = createCellData("p", usage) // Fourth column

  return row;
}

Pasamos properties y usage a la tercera y cuarta columnas como argumentos de texto. Sin embargo, la segunda columna es un poco diferente; allí, mostramos los nombres de las clases, que se almacenan en el archivo de datos como:

"classes": {
  "base": "body-text body-text--lg",
  "variants": ["body-text--bold", "body-text--regular"]
},

Además, recuerde que los encabezados no tienen clases, por lo que queremos mostrar los nombres de las etiquetas de encabezado para esas filas (p. Ej. h1, h2, etc.).

Creemos algunas funciones auxiliares para analizar estos datos en un formato que podamos usar para nuestro argumento de texto.

// Pass in the base tag and class names as arguments
function displayClasses(element, classes) {
  // If there are no classes, return the base tag (appropriate for headings)
  return getClasses(classes) ? getClasses(classes) : element;
}

// Return the node class as a string (if there's one class), an array (if there are multiple classes), or null (if there are none.) 
// Ex. "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) {
      // Concatenate each variant with the base classes
      return variants.map(variant => base.concat(`${variant}`));
    }
    return base;
  }
  return classes;
}

Ahora podemos hacer esto:

function createRow(data) {
  let { text, element, classes = null, properties, usage } = data;
  let row = [];
  row[0] = ""
  row[1] = createCellData("p", displayClasses(element, classes)) // Second column
  row[2] = createCellData("p", properties) // Third column
  row[3] = createCellData("p", usage) // Fourth column

  return row;
}

Transformando los datos de demostración

Esto deja la primera columna que demuestra los estilos. Esta columna es diferente porque estamos aplicando nuevas etiquetas y clases a cada celda en lugar de usar la combinación de clases utilizada por el resto de las columnas:

<p class="body-text body-text--md body-text--semibold">

En lugar de intentar hacer esto en createCellData o createNodeData, hagamos otra función que se ubique encima de estas funciones de transformación base y maneje parte de la nueva lógica.

function createDemoCellData(data) {
  let children;
  const classes = getClasses(data.classes);
  // In cases where we're showing off multiple classes, we need to create children and apply each class to each child.
  if (Array.isArray(classes)) {
    children = classes.map(child =>
      // We can use "data.text" since each node in a cell grouping has the same text
      createNode("span", child, data.text, children)
    );
  }
  // Handle cases where we only have one class
  if (typeof classes === "string") {
    return createNode("p", classes, data.text, children);
  }
  // Handle cases where we have no classes (ie. headings)
  return createNode(data.element, null, data.text, children);
}

Ahora tenemos los datos de la fila en un formato normalizado que podemos pasar a nuestra función de renderizado:

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
}

Renderizando los datos

Así es como realmente representamos los datos para mostrar:

// Access our data in the "props" object
const rowData = props.rowData;

// Pass it into our entry transformation function
const row = createRow(rowData);

// Create a root "div" node and handle each cell
return h("div", { class: "row" }, row.map(cell => renderCells(cell)));

// Traverse cell values
function renderCells(data) {

  // Handle cells with multiple child nodes
  if (data.children.length) {
    return renderCell(
      data.tag, // Use the base cell tag
      { // Attributes in here
        class: {
          group: true, // Add a class of "group" since there are multiple nodes
          [data.cellClass]: data.cellClass // If the cell class isn't null, apply it to the node
        }
      },
      // The node content
      data.children.map(child => {
        return renderCell(
          child.tag,
          { class: child.cellClass },
          child.text
        );
      })
    );
  }

  // If there are no children, render the base cell
  return renderCell(data.tag, { class: data.cellClass }, data.text);
}

// A wrapper function around "h" to improve readability
function renderCell(tag, classArgs, text) {
  return h(tag, classArgs, text);
}

¡Y obtenemos nuestro producto final! Aquí está el código fuente nuevamente.

Terminando

Vale la pena señalar que este enfoque representa una forma experimental de abordar un problema relativamente trivial. Estoy seguro de que mucha gente argumentará que esta solución es innecesariamente complicada y está sobre-diseñada. Probablemente estaría de acuerdo.

Sin embargo, a pesar del costo inicial, los datos ahora están completamente separados de la presentación. Ahora, si mi equipo de diseño agrega o elimina filas, no tengo que profundizar en HTML desordenado, solo actualizo un par de propiedades en el archivo JSON.

¿Vale la pena? Como todo lo demás en programación, supongo que depende. Diré que esta tira cómica estaba en el fondo de mi mente mientras trabajaba en esto:

Una tira cómica de tres paneles.  El primer panel es una figura de palo en una mesa pidiendo pasar la sal.  El segundo panel es la misma figura sin diálogo.  El tercer panel es otra figura que dice que está construyendo un sistema para pasar los condimentos y que ahorrará tiempo a largo plazo.  La primera cifra dice que ya han pasado 20 minutos.Fuente: https://xkcd.com/974

Quizás esa sea una respuesta. Me encantaría escuchar todos sus pensamientos y sugerencias (constructivos), o si ha probado otras formas de realizar una tarea similar.