Ein praktischer Anwendungsfall für Vue-Renderfunktionen: Erstellen eines Designsystem-Typografie-Gitters

Avatar of Salomone Baquis
Salomone Baquis am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Dieser Beitrag beschreibt, wie ich mit Vue-Renderfunktionen ein Typografie-Gitter für ein Designsystem erstellt habe. Hier ist die Demo und der Code. Ich habe Renderfunktionen verwendet, da sie es ermöglichen, HTML mit einem höheren Kontrollgrad als normale Vue-Vorlagen zu erstellen. Überraschenderweise konnte ich bei meiner Websuche nur sehr wenige reale, nicht-tutorial-basierte Anwendungen dafür finden. Ich hoffe, dieser Beitrag füllt diese Lücke und bietet einen hilfreichen und praktischen Anwendungsfall für Vue-Renderfunktionen.

Ich fand Renderfunktionen schon immer etwas untypisch für Vue. Während der Rest des Frameworks Einfachheit und Trennung von Belangen betont, sind Renderfunktionen eine seltsame und oft schwer lesbare Mischung aus HTML und JavaScript.

Um zum Beispiel anzuzeigen

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

…benötigt man

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

Ich vermute, dass diese Syntax einige Leute abschreckt, da die Benutzerfreundlichkeit einer der Hauptgründe ist, warum man sich überhaupt für Vue entscheidet. Das ist schade, denn Renderfunktionen und funktionale Komponenten sind zu einigen ziemlich coolen, leistungsstarken Dingen fähig. Um ihren Wert zu demonstrieren, zeige ich, wie sie ein echtes Geschäftsproblem für mich gelöst haben.

Kurzer Haftungsausschluss: Es wird sehr hilfreich sein, die Demo in einem anderen Tab geöffnet zu haben, um sie während des gesamten Beitrags nachschlagen zu können.

Festlegen von Kriterien für ein Designsystem

Mein Team wollte eine Seite in unserem VuePress-basierten Designsystem aufnehmen, die verschiedene Typografie-Optionen zeigt. Dies ist Teil eines Mockups, das ich von unserem Designer erhalten habe.

A screenshot of the typographic design system. There are four columns where the first shows the style name with the rendered style, second is the element or class, third shows the properties that make the styles, and fourth is the defined usage.

Und hier ist ein Beispiel für das entsprechende 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;
}

Überschriften werden mit Tag-Namen angesprochen. Andere Elemente verwenden Klassennamen, und es gibt separate Klassen für Gewicht und Größe.

Bevor ich Code schrieb, habe ich einige Grundregeln aufgestellt

  • Da dies eigentlich eine Datenvisualisierung ist, sollten die Daten in einer separaten Datei gespeichert werden.
  • Überschriften sollten semantische Überschriften-Tags (z. B. <h1>, <h2> usw.) verwenden, anstatt sich auf eine Klasse verlassen zu müssen.
  • Textinhalte sollten Absatz-Tags (<p>) mit dem Klassennamen verwenden (z. B. <p class="body-text--lg">).
  • Inhaltstypen, die Variationen aufweisen, sollten gruppiert werden, indem sie in das Wurzelelement des Absatzes oder das entsprechende Wurzelelement ohne Styling-Klasse eingehüllt werden. Kinder sollten mit <span> und dem Klassennamen eingehüllt werden.
<p>
  <span class="body-text--lg">Thing 1</span>
  <span class="body-text--lg">Thing 2</span>
</p>
  • Alle Inhalte, die kein spezielles Styling demonstrieren, sollten ein Absatz-Tag mit dem richtigen Klassennamen und <span> für alle Kindknoten verwenden.
<p class="body-text--semibold">
  <span>Thing 1</span>
  <span>Thing 2</span>
</p>
  • Klassennamen müssen nur einmal für jede Zelle geschrieben werden, die Styling demonstriert.

Warum Renderfunktionen sinnvoll sind

Ich habe vor Beginn ein paar Optionen in Betracht gezogen

Hardcoding

Ich mag Hardcoding, wenn es angebracht ist, aber mein HTML von Hand zu schreiben hätte bedeutet, verschiedene Kombinationen des Markups zu tippen, was unangenehm und repetitiv erschien. Es bedeutete auch, dass die Daten nicht in einer separaten Datei gespeichert werden konnten, daher schied dieser Ansatz aus.

Hier ist, was ich meine

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

Verwendung einer traditionellen Vue-Vorlage

Dies wäre normalerweise die Standardoption. Betrachten Sie jedoch Folgendes

Siehe den Pen
Unterschiedliche Stilbeispiele
von Salomone Baquis (@soluhmin)
auf CodePen.

In der ersten Spalte haben wir

– Ein <h1>-Tag, das unverändert gerendert wird.
– Ein <p>-Tag, das einige <span>-Kinder mit Text gruppiert, jedes mit einer Klasse (aber ohne spezielle Klasse auf dem <p>-Tag).
– Ein <p>-Tag mit einer Klasse und ohne Kinder.

Das Ergebnis hätte viele Instanzen von v-if und v-if-else bedeutet, von denen ich wusste, dass sie schnell verwirrend werden würden. Mir gefiel auch die ganze bedingte Logik innerhalb des Markups nicht.

Aus diesen Gründen habe ich Renderfunktionen gewählt. Renderfunktionen verwenden JavaScript, um bedingt Kindknoten basierend auf allen definierten Kriterien zu erstellen, was für diese Situation perfekt erschien.

Datenmodell

Wie ich bereits erwähnte, möchte ich die Typografie-Daten in einer separaten JSON-Datei aufbewahren, damit ich Änderungen später leicht vornehmen kann, ohne das Markup zu berühren. Hier sind die Rohdaten.

Jedes Objekt in der Datei repräsentiert eine andere Zeile.

{
  "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. 
}

Das obige Objekt rendert das folgende 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>

Betrachten wir ein komplexeres Beispiel. Arrays repräsentieren Gruppen von Kindern. Ein classes-Objekt kann Klassen speichern. Die base-Eigenschaft enthält Klassen, die für jeden Knoten in der Zellengruppierung gemeinsam sind. Jede Klasse in variants wird auf ein anderes Element in der Gruppierung angewendet.

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

Hier ist, wie das gerendert wird

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

Die Grundeinrichtung

Wir haben eine Elternkomponente, TypographyTable.vue, die das Markup für das Wrapper-Tabellenelement enthält, und eine Kindkomponente, TypographyRow.vue, die eine Zeile erstellt und unsere Renderfunktion enthält.

Ich iteriere durch die Zeilenkomponente und übergebe die Zeilendaten als Props.

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

Eine nette Sache, auf die man hinweisen kann: Die Typografie-Daten können eine Eigenschaft auf der Vue-Instanz sein und mit $options.typographyData aufgerufen werden, da sie sich nicht ändert und nicht reaktiv sein muss. (Hutspitze an Anton Kosykh.)

Erstellen einer funktionalen Komponente

Die TypographyRow-Komponente, die Daten übergibt, ist eine funktionale Komponente. Funktionale Komponenten sind zustandslos und instanzlos, was bedeutet, dass sie kein this haben und keinen Zugriff auf Vue-Lebenszyklusmethoden haben.

Die leere Startkomponente sieht so aus

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

Die render-Methode nimmt ein context-Argument entgegen, das eine props-Eigenschaft hat, die de-strukturiert und als zweites Argument verwendet wird.

Das erste Argument ist createElement, eine Funktion, die Vue mitteilt, welche Knoten erstellt werden sollen. Der Kürze und Konvention halber werde ich createElement als h abkürzen. Warum ich das tue, können Sie in Sarahs Beitrag nachlesen.

h nimmt drei Argumente entgegen

  1. Ein HTML-Tag (z. B. div)
  2. Ein Datenobjekt mit Template-Attributen (z. B. { class: 'something'})
  3. Textzeichenfolgen (wenn wir nur Text hinzufügen) oder Kindknoten, die mit h erstellt wurden
render(h, { props }) {
  return h("div", { class: "example-class" }, "Here's my example text")
}

OK, um zusammenzufassen, wo wir bisher stehen: Wir haben Folgendes behandelt:

  • eine Datei mit den Daten, die in meiner Visualisierung verwendet werden sollen;
  • eine reguläre Vue-Komponente, in der ich die vollständige Datendatei importiere; und
  • den Anfang einer funktionalen Komponente, die jede Zeile anzeigen wird.

Um jede Zeile zu erstellen, müssen die Daten aus der JSON-Datei als Argumente für h übergeben werden. Dies könnte alles auf einmal geschehen, aber das beinhaltet viel bedingte Logik und ist verwirrend.

Stattdessen habe ich beschlossen, es in zwei Teilen zu machen

  1. Die Daten in ein vorhersagbares Format transformieren.
  2. Die transformierten Daten rendern.

Transformation der gemeinsamen Daten

Ich wollte meine Daten in einem Format haben, das den Argumenten für h entspricht, aber bevor ich das tat, schrieb ich auf, wie ich mir die Struktur vorstellte

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

Jedes Objekt repräsentiert eine Zelle, wobei vier Zellen eine Zeile bilden (ein Array).

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

Der Einstiegspunkt wäre eine Funktion wie

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

Werfen wir noch einmal einen Blick auf unser Mockup.

Die erste Spalte hat Stilvariationen, aber die anderen scheinen demselben Muster zu folgen, also beginnen wir mit diesen.

Wiederum ist das gewünschte Modell für jede Zelle

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

Dies gibt uns eine baumartige Struktur für jede Zelle, da einige Zellen Gruppen von Kindern haben. Verwenden wir zwei Funktionen, um die Zellen zu erstellen.

  • createNode nimmt jede unserer gewünschten Eigenschaften als Argumente entgegen.
  • createCell wickelt createNode ein, damit wir prüfen können, ob der Text, den wir übergeben, ein Array ist. Wenn ja, bauen wir ein Array von Kindknoten auf.
// 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
  };
}

Jetzt können wir so etwas tun

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

Wir übergeben properties und usage an die dritte und vierte Spalte als Textargumente. Die zweite Spalte ist jedoch etwas anders; dort zeigen wir die Klassennamen an, die in der Datendatei wie folgt gespeichert sind

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

Außerdem, denken Sie daran, dass Überschriften *keine* Klassen haben, also wollen wir für diese Zeilen die Überschriften-Tag-Namen anzeigen (z. B. h1, h2 usw.).

Lassen Sie uns einige Hilfsfunktionen erstellen, um diese Daten in ein Format zu parsen, das wir für unser Textargument verwenden können.

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

Jetzt können wir das tun

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

Transformation der Demo-Daten

Damit bleibt die erste Spalte, die die Stile demonstriert. Diese Spalte ist anders, weil wir jeder Zelle neue Tags und Klassen zuweisen, anstatt die Klassen-Kombination zu verwenden, die vom Rest der Spalten verwendet wird.

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

Anstatt zu versuchen, dies in createCellData oder createNodeData zu tun, erstellen wir eine weitere Funktion, die über diesen grundlegenden Transformationsfunktionen liegt und einige der neuen Logik verarbeitet.

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

Nun haben wir die Zeilendaten in einem normalisierten Format, das wir unserer Renderfunktion übergeben können

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
}

Rendern der Daten

Hier ist, wie wir die Daten tatsächlich rendern, um sie anzuzeigen

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

Und wir erhalten unser Endprodukt! Hier ist erneut der Quellcode.

Zusammenfassung

Es ist erwähnenswert, dass dieser Ansatz eine experimentelle Methode zur Lösung eines relativ trivialen Problems darstellt. Ich bin sicher, viele Leute werden argumentieren, dass diese Lösung unnötig kompliziert und überkonstruiert ist. Ich würde wahrscheinlich zustimmen.

Trotz der anfänglichen Kosten sind die Daten nun vollständig von der Präsentation getrennt. Wenn mein Designteam nun Zeilen hinzufügt oder entfernt, muss ich nicht mehr in unübersichtlichem HTML wühlen – ich aktualisiere nur ein paar Eigenschaften in der JSON-Datei.

Lohnt es sich? Wie bei allem in der Programmierung, denke ich, es hängt davon ab. Ich sage nur, dieser Comic-Strip war mir im Hinterkopf, als ich daran arbeitete

A three-panel comic strip. First panel is a stick figure at a dinner table asking to pass the salt. Second panel is the same figure with no dialogue. Third panel is another figure saying he's building a system to pass the condiments and that it will save time in the long run. First figure says it's already been 20 minutes.
Quelle: https://xkcd.com/974

Vielleicht ist das eine Antwort. Ich würde mich sehr über all eure (konstruktiven) Gedanken und Vorschläge freuen, oder ob ihr andere Wege ausprobiert habt, um eine ähnliche Aufgabe zu bewältigen.