A Practical Use Case for Vue Render Functions: Building a Design System Typography Grid
Publikováno: 30.5.2019
This post covers how I built a typography grid for a design system using Vue render functions. Here’s the demo and the code. I used render functions because they allow you to create HTML with a greater level of control than regular Vue templates, yet surprisingly I couldn’t find very much when I web searched around for real-life, non-tutorial applications of them. I’m hoping this post will fill that void and provide a helpful and practical use case … Read article
The post A Practical Use Case for Vue Render Functions: Building a Design System Typography Grid appeared first on CSS-Tricks.
This post covers how I built a typography grid for a design system using Vue render functions. Here’s the demo and the code. I used render functions because they allow you to create HTML with a greater level of control than regular Vue templates, yet surprisingly I couldn’t find very much when I web searched around for real-life, non-tutorial applications of them. I’m hoping this post will fill that void and provide a helpful and practical use case on using Vue render functions.
I’ve always found render functions to be a little out-of-character for Vue. While the rest of the framework emphasizes simplicity and separation of concerns, render functions are a strange and often difficult-to-read mix of HTML and JavaScript.
For example, to display:
<div class="container">
<p class="my-awesome-class">Some cool text</p>
</div>
...you need:
render(createElement) {
return createElement("div", { class: "container" }, [
createElement("p", { class: "my-awesome-class" }, "Some cool text")
])
}
I suspect that this syntax turns some people off, since ease-of-use is a key reason to reach for Vue in the first place. This is a shame because render functions and functional components are capable of some pretty cool, powerful stuff. In the spirit of demonstrating their value, here’s how they solved an actual business problem for me.
Quick disclaimer: It will be super helpful to have the demo open in another tab to reference throughout this post.
Defining criteria for a design system
My team wanted to include a page in our VuePress-powered design system showcasing different typography options. This is part of a mockup that I got from our designer.
And here’s a sample of some of the corresponding 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;
}
Headings are targeted with tag names. Other items use class names, and there are separate classes for weight and size.
Before writing any code, I created some ground rules:
- Since this is really a data visualization, the data should be stored in a separate file.
- Headings should use semantic heading tags (e.g.
<h1>
,<h2>
, etc.) instead of having to rely on a class. - Body content should use paragraph (
<p>
) tags with the class name (e.g.<p class="body-text--lg">
). - Content types that have variations should be grouped together by wrapping them in the root paragraph tag, or corresponding root element, without a styling class. Children should be wrapped with
<span>
and the class name.
<p>
<span class="body-text--lg">Thing 1</span>
<span class="body-text--lg">Thing 2</span>
</p>
- Any content that’s not demonstrating special styling should use a paragraph tag with the correct class name and
<span>
for any child nodes.
<p class="body-text--semibold">
<span>Thing 1</span>
<span>Thing 2</span>
</p>
- Class names should only need to be written once for each cell that's demonstrating styling.
Why render functions make sense
I considered a few options before starting:
Hardcoding
I like hardcoding when appropriate, but writing my HTML by hand would have meant typing out different combinations of the markup, which seemed unpleasant and repetitive. It also meant that data couldn’t be kept in a separate file, so I ruled out this approach.
Here’s what I mean:
<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>
Using a traditional Vue template
This would normally be the go-to option. However, consider the following:
See the Pen
Different Styles Example by Salomone Baquis (@soluhmin)
on CodePen.
In the first column, we have:
- An <h1>
> tag rendered as-is.
- A <p>
tag that groups some <span>
children with text, each with a class (but no special class on the <p>
tag).
- A <p>
tag with a class and no children.
The result would have meant many instances of v-if
and v-if-else
, which I knew would get confusing fast. I also disliked all of that conditional logic inside the markup.
Because of these reasons, I chose render functions. Render functions use JavaScript to conditionally create child nodes based on all of the criteria that’s been defined, which seemed perfect for this situation.
Data model
As I mentioned earlier, I’d like to keep typography data in a separate JSON file so I can easily make changes later without touching markup. Here’s the raw data.
Each object in the file represents a different row.
{
"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.
}
The object above renders the following 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>
Let’s look at a more involved example. Arrays represent groups of children. A classes
object can store classes. The base
property contains classes that are common to every node in the cell grouping. Each class in variants
is applied to a different item in the grouping.
{
"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"]
}
Here’s how that renders:
<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>
The basic setup
We have a parent component, TypographyTable.vue
, which contains the markup for the wrapper table element, and a child component, TypographyRow.vue
, which creates a row and contains our render function.
I loop through the row component, passing the row data as 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>
One neat thing to point out: the typography data can be a property on the Vue instance and be accessed using $options.typographyData
since it doesn’t change and doesn’t need to be reactive. (Hat tip to Anton Kosykh.)
Making a functional component
The TypographyRow
component that passes data is a functional component. Functional components are stateless and instanceless, which means that they have no this
and don’t have access to any Vue lifecycle methods.
The empty starting component looks like this:
// 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>
The render
method takes a context
argument, which has a props
property that’s de-structured and used as the second argument.
The first argument is createElement
, which is a function that tells Vue what nodes to create. For brevity and convention, I’ll be abbreviating createElement
as h
. You can read about why I do that in Sarah’s post.
h
takes three arguments:
- An HTML tag (e.g.
div
) - A data object with template attributes (e.g.
{ class: 'something'}
) - Text strings (if we’re just adding text) or child nodes built using
h
render(h, { props }) {
return h("div", { class: "example-class }, "Here's my example text")
}
OK, so to recap where we are at this point, we’ve covered creating:
- a file with the data that’s going to be used in my visualization;
- a regular Vue component where I’m importing the full data file; and
- the beginning of a functional component that will display each row.
To create each row, the data from the JSON file needs to be passed into arguments for h
. This could be done all at once, but that involves a lot of conditional logic and is confusing.
Instead, I decided to do it in two parts:
- Transform the data into a predictable format.
- Render the transformed data.
Transforming the common data
I wanted my data in a format that would match the arguments for h
, but before doing this, I wrote out how I wanted things structured:
// 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
}
Each object represents one cell, with four cells making up each row (an array).
// One row
[ { cell1 }, { cell2 }, { cell3 }, { cell4 } ]
The entry point would be a function like:
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;
}
Let’s take another look at our mockup.
The first column has styling variations, but the rest seem to follow the same pattern, so let’s start with those.
Again, the desired model for each cell is:
{
tag: "",
cellClass: "",
text: "",
children: []
}
This gives us a tree-like structure for each cell since some cells have groups of children. Let’s use two functions to create the cells.
createNode
takes each of our desired properties as arguments.createCell
wraps aroundcreateNode
so that we can check if the text that we’re passing in is an array. If it is, we build up an array of child nodes.
// 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
};
}
Now, we can do something like:
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;
}
We pass properties
and usage
to the third and fourth columns as text arguments. However, the second column is a little different; there, we’re displaying the class names, which are stored in the data file like:
"classes": {
"base": "body-text body-text--lg",
"variants": ["body-text--bold", "body-text--regular"]
},
Additionally, remember that headings don’t have classes, so we want to show the heading tag names for those rows (e.g. h1
, h2
, etc.).
Let’s create some helper functions to parse this data into a format that we can use for our text argument.
// 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;
}
Now we can do this:
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;
}
Transforming the demo data
This leaves the first column that demonstrates the styles. This column is different because we’re applying new tags and classes to each cell instead of using the class combination used by the rest of the columns:
<p class="body-text body-text--md body-text--semibold">
Rather than try to do this in createCellData
or createNodeData
, let’s make another function to sit on top of these base transformation functions and handle some of the new logic.
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);
}
Now we have the row data in a normalized format that we can pass to our render function:
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
}
Rendering the data
Here’s how we actually render the data to display:
// 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);
}
And we get our final product! Here’s the source code again.
Wrapping up
It’s worth pointing out that this approach represents an experimental way of addressing a relatively trivial problem. I’m sure many people will argue that this solution is needlessly complicated and over-engineered. I’d probably agree.
Despite the up-front cost, however, the data is now fully separated from the presentation. Now, if my design team adds or removes rows, I don’t have to dig into messy HTML — I just update a couple of properties in the JSON file.
Is it worth it? Like everything else in programming, I guess it depends. I will say that this comic strip was in the back of my mind as I worked on this:
Maybe that’s an answer. I’d love to hear all of your (constructive) thoughts and suggestions, or if you’ve tried other ways of going about a similar task.
The post A Practical Use Case for Vue Render Functions: Building a Design System Typography Grid appeared first on CSS-Tricks.