[Translation] A practical example of using Vue render functions: creating a typographic grid for a design system

[Translation] A practical example of using Vue render functions: creating a typographic grid for a design system


The article, the translation of which we are publishing today, will deal with how to create a typographic grid for the design system using Vue render functions . Here is a demo version of the project, which we will consider here. Here you can find its code. The author of this material says that he used render functions because they allow much more precise control over the process of creating HTML code than the usual Vue templates. However, to his surprise, he could not find practical examples of their application. He came across only training manuals. He hopes that this material will change the situation for the better due to the fact that here is a practical example of using Vue’s render functions.


Vue Render Functions


Render functions have always seemed to me to be something that is a little unusual for Vue. Everything in this framework emphasizes the desire for simplicity and separation of the responsibilities of different entities. But render functions are a strange mixture of HTML and JavaScript, which is often difficult to read.

For example, here is the HTML markup:

  & lt; div class = "container" & gt;
 & lt; p class = "my-awesome-class" & gt; Some cool text & lt;/p & gt;
 & lt;/div & gt;  

For its formation, the following function is needed:

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

I suspect that such constructions will force many to immediately turn away from the render functions. After all, ease of use is exactly what attracts developers to Vue. It’s a pity if many people don’t see their true merits behind the unsightly appearance of the render functions. The thing is, render functions and functional components are interesting and powerful tools. I, in order to demonstrate their capabilities and their true value, will tell you how they helped me solve the real problem.

Please note that it will be very useful to open the demo version of the project considered here in the next browser tab and access it when reading the article.

Defining criteria for a design system


We have a design system based on VuePress . We needed to include in it a new page, demonstrating various typographical possibilities of text design. Here is the layout that the designer gave me.


Page Layout

Here is an example of the corresponding CSS code page:

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

Titles are formatted based on tag names. For formatting other elements, class names are used. In addition, there are separate classes for richness and font sizes.

I, before starting to write code, formulated some rules:

  • Since the main purpose of this page is data visualization, the data should be stored in a separate file.
  • Semantic header tags should be used for formatting headings (i.e., & lt; h1 & gt; , & lt; h2 & gt; , and so on), their formatting should not be based on classroom.
  • The body of the page should use paragraph tags ( & lt; p & gt; ) with class names (for example, & lt; p class = "body-text - lg" & gt; ).
  • Materials consisting of different elements should be grouped by wrapping them in the root & lt; p & gt; tag, or in another suitable root element that is not assigned a stylization class. Child elements must be wrapped in a & lt; span & gt; tag, which specifies the class name. This is how the application of this rule may look like:

      & lt; p & gt;
     & lt; span class = "body-text - lg" & gt; Thing 1 & lt;/span & gt;
     & lt; span class = "body-text - lg" & gt; Thing 2 & lt;/span & gt;
     & lt;/p & gt;  
  • Materials that do not have special requirements for output should be wrapped in a & lt; p & gt; tag that is assigned the correct class name. Child elements must be enclosed in a & lt; span & gt; tag:

      & lt; p class = "body-text - semibold" & gt;
     & lt; span & gt; Thing 1 & lt;/span & gt;
     & lt; span & gt; Thing 2 & lt;/span & gt;
     & lt;/p & gt;  
  • For each styled cell, class names must be written only once.

Problem Solution Options


I, before starting work, considered several options for solving the task set before me. Here is their review.

▍Manually write HTML code


I like to write HTML-code manually, but only when it allows to adequately solve the existing problem. However, in my case, manually writing the code would mean entering various repetitive code fragments in which there are some variations. I did not like it. In addition, it would mean that the data can not be stored in a separate file. As a result, I refused this approach.

If I created the page in question exactly like this, I would have done something like this:

  & lt; div class = "row" & gt;
 & lt; h1 & gt; Heading 1 & lt;/h1 & gt;
 & lt; p class = "body-text body-text - md body-text - semibold" & gt; h1 & lt;/p & gt;
 & lt; p class = "body-text body-text - md body-text - semibold" & gt; Balboa Light, 30px & lt;/p & gt;
 & lt; p class = "group body-text body-text - md body-text - semibold" & gt;
 & lt; span & gt; Product title (once on a page) & lt;/span & gt;
 & lt; span & gt; Illustration headline & lt;/span & gt;
 & lt;/p & gt;
 & lt;/div & gt;  

▍ Using Traditional Vue Templates


Under normal conditions, this approach is used most often. However, take a look at this example.


Example of using Vue templates

In the first column there is the following:

  • The & lt; h1 & gt; tag, presented as it is displayed by the browser.
  • A & lt; p & gt; tag that groups several & lt; span & gt; child elements with text. Each of these elements is assigned a class (but the & lt; p & gt; tag itself is not assigned a special class).
  • A & lt; p & gt; tag that does not have nested & lt; span & gt; elements to which a class is assigned.

To implement all this, you would need many instances of the v-if and v-if-else directives.And this, I know, would lead to the fact that the code would very soon become very confusing. Also, I don’t like the use of all this conditional logic in the markup.

▍Render-functions


As a result, after analyzing possible alternatives, I chose render functions. In them, by means of JavaScript, using conditional constructions, child nodes of other nodes are created. When creating these child nodes, all necessary criteria are taken into account. In this situation, this solution seemed ideal.

Data Model


As I said, I wanted to store typographical data in a separate JSON file. This would allow, if necessary, to make changes to them without touching the markup. Here this data.

Each JSON object in the file is a description of a separate line:

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

Here is the HTML code that is generated after processing this object:

  & lt; div class = "row" & gt;
 & lt; h1 & gt; Heading 1 & lt;/h1 & gt;
 & lt; p class = "body-text body-text - md body-text - semibold" & gt; h1 & lt;/p & gt;
 & lt; p class = "body-text body-text - md body-text - semibold" & gt; Balboa Light, 30px & lt;/p & gt;
 & lt; p class = "group body-text body-text - md body-text - semibold" & gt;
 & lt; span & gt; Product title (once on a page) & lt;/span & gt;
 & lt; span & gt; Illustration headline & lt;/span & gt;
 & lt;/p & gt;
 & lt;/div & gt;  

Now consider a more complex example. Arrays represent groups of children. Properties of classes objects, which are themselves objects, can store class descriptions. The base property of the classes object contains a description of the classes common to all nodes in the cell. Each class that is present in the options property is applied to a separate item in the group.

  {
 "text": "Body Text - Large",
 "element": "p",
 "classes": {
 "base": "body-text body-text - lg",//Applies to each child node.
 "variants": ["body-text - bold", "body-text - regular"]//This array is bypassed in a loop, one class is applied to one of the examples.  Each element in the array is a separate node.
 },
 "properties": "Proxima Nova Bold and Regular, 20px",
 "usage": ["Large button title", "Form label", "Large modal text"]
 }  

This object turns into the following HTML:

  & lt; div class = "row" & gt;
 & lt;! - Column 1 - & gt;
 & lt; p class = "group" & gt;
 & lt; span class = "body-text body-text - lg body-text - bold" & gt; Body Text - Large & lt;/span & gt;
 & lt; span class = "body-text body-text - lg body-text - regular" & gt; Body Text - Large & lt;/span & gt;
 & lt;/p & gt;
 & lt;! - Column 2 - & gt;
 & lt; p class = "group body-text body-text - md body-text - semibold" & gt;
 & lt; span & gt; body-text body-text - lg body-text - bold & lt;/span & gt;
 & lt; span & gt; body-text body-text - lg body-text - regular & lt;/span & gt;
 & lt;/p & gt;
 & lt;! - Column 3 - & gt;
 & lt; p class = "body-text body-text - md body-text - semibold" & gt; Proxima Nova Bold and Regular, 20px & lt;/p & gt;
 & lt;! - Column 4 - & gt;
 & lt; p class = "group body-text body-text - md body-text - semibold" & gt;
 & lt; span & gt; Large button title & lt;/span & gt;
 & lt; span & gt; Form label & lt;/span & gt;
 & lt; span & gt; Large modal text & lt;/span & gt;
 & lt;/p & gt;
 & lt;/div & gt;  

Basic project structure


We have a parent component TypographyTable.vue , which contains markup to form the table. We also have a child component, TypographyRow.vue , which is responsible for creating the row of the table and contains our render function.

When forming the rows of the table, the array is crawled with data. Objects that describe the rows of a table are passed to TypographyRow as properties.

  & lt; template & gt;
 & lt; section & gt;
 & lt;! - The table header is hard coded in the code for the sake of simplicity - & gt;
 & lt; div class = "row" & gt;
 & lt; p class = "body-text body-text - lg-bold heading" & gt; Hierarchy & lt;/p & gt;
 & lt; p class = "body-text body-text - lg-bold heading" & gt; Element/Class & lt;/p & gt;
 & lt; p class = "body-text body-text - lg-bold heading" & gt; Properties & lt;/p & gt;
 & lt; p class = "body-text body-text - lg-bold heading" & gt; Usage & lt;/p & gt;
 & lt;/div & gt;
 & lt;! - Go around the array with the data and pass the data to each line as properties - & gt;
 & lt; typography-row
 v-for = "(rowData, index) in $ options.typographyData": key = "index": row-data = "rowData"/& gt;
 & lt;/section & gt;
 & lt;/template & gt;
 & lt; script & gt;
 import TypographyData from "@/data/typography.json";
 import TypographyRow from "./TypographyRow";
 export default {//We work with static data, so there is no need to make the table reactive
 typographyData: TypographyData,
 name: "TypographyTable",
 components: {
 TypographyRow

 };
 & lt;/script & gt;  

Here I would like to note one pleasant trifle: the typographical data in the Vue instance can be represented as a property. You can access them using the $ options.typographyData construction since they do not change and should not be reactive (thanks to Anton Kosyy ).

Creating a functional component


The TypographyRow component that processes the data is a functional component. Functional components are entities that do not have states or instances. This means that they do not have this , and that they do not have access to the lifecycle methods of the Vue components.

Here is the “skeleton” of a similar component, from which we will begin work on our component:

 //None & lt; template & gt;
 & lt; script & gt;
 export default {
 name: "TypographyRow",
 functional: true,//This property makes the component functional.
 props: {
 rowData: {//Property with row data
 type: Object

 },
 render (createElement, {props}) {//The markup is displayed here.

 }
 & lt;/script & gt;  

The render component method takes a context argument that has a props property. This property undergoes destructuring and is used as the second argument.

The first argument is createElement . This is the function that tells Vue which node to create. For the sake of brevity and standardization of code, I use h for createElement . About why I did that, you can read here .

So h takes three arguments:

  1. HTML tag (for example, div ).
  2. A data object containing template attributes (for example, {class: 'something'} ).
  3. Text strings (if we just add text) or child nodes created using h .

Here's what it looks like:

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

Let's summarize what we have already created. Namely, we now have the following:

  1. The file with the data that you plan to use when forming the page.
  2. A regular Vue component that imports the data file.
  3. The framework of the functional component that is responsible for the output of the rows of the table.

To create table rows, data from the JSON format must be passed as an argument to h . It is possible to transmit all such data in one go, but with such an approach a large amount of conditional logic will be needed, which will worsen the clarity of the code. Instead, I decided to do this:

  1. Transform data into a standardized format.
  2. Print the transformed data.

Data Transformation


I wanted my data to be in a format that matches the arguments taken by h . But, before converting them, I planned what structure they should have in the JSON file:

 //Single Cell
 {
 tag: "",//HTML tag of the current level
 cellClass: "",//Class of the current level.  If there is no class at this level, there will be null
 text: "",//Text to be output
 children: []//Description of the child nodes, which follows the same model.  An empty array if there are no child nodes.
 }  

Each object is a single table cell. Each row of the table is formed by four cells (they are assembled into an array):

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

The entry point can be a function like the following:

  function createRow (data) {//Data for one row comes here, table functions are created during the work of the function
 let {text, element, classes = null, properties, usage} = data;
 let row = [];
 row [0] = createCellData (data)//Transform data using some kind of public function
 row [1] = createCellData (data)
 row [2] = createCellData (data)
 row [3] = createCellData (data)

 return row;
 }  

Look again at the layout.


Page Layout

You can see that in the first column the elements are styled differently. And the remaining columns use the same formatting. So let's start with this.
Let me remind you that I would like to use the following JSON structure as a model for describing each cell:

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

With this approach, a tree-like structure will be used to describe each cell. This is precisely because some cells contain groups of child elements. Use the following two functions to create cells:

  • The createNode function takes each of the properties of interest to us as an argument.
  • The createCell function plays the role of a wrapper around createNode , with its help we check whether the argument text is an array. If so, we create an array of child elements.

 //Model for cells
 function createCellData (tag, text) {
 let children;//Base classes that apply to each root cell tag
 const nodeClass = "body-text body-text - md body-text - semibold";//If the text argument is an array, create child elements wrapped in span tags.
 if (Array.isArray (text)) {
 children = text.map (child = & gt; createNode ("span", null, child, children));

 return createNode (tag, nodeClass, text, children);
 }//Model for nodes
 function createNode (tag, nodeClass, text, children = []) {
 return {
 tag: tag,
 cellClass: nodeClass,
 text: children.length?  null: text,
 children: children
 };
 }  

Now we can do something like this:

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

 return row;
 }  

When forming the third and fourth columns, we pass properties and usage as text arguments. However, the second column is different from the third and fourth. Here we display the names of the classes, which, in the original data, are stored in this form:

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

In addition, let's not forget that when working with headings classes are not used. Therefore, we need to generate header tag names for the corresponding lines (i.e., h1 , h2 , and so on).

We will create helper functions that allow you to convert this data into a format that facilitates their use as a text argument.

 //Pass the base tag and class names as arguments
 function displayClasses (element, classes) {//If there are no classes, then return the base tag (this is suitable for headers)
 return getClasses (classes)?  getClasses (classes): element;
 }
//Return the node class as a string (if there is only one class) or as an array (if there are several classes), or return null (if there are no classes)//For example: "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 of the variants with base classes
 return variants.map (variant = & gt; base.concat (`$ {variant}`));

 return base;

 return classes;
 }  

Now we can do the following:

  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 of data used to demonstrate styles


We need to decide what to do with the first column of the table, which shows examples of applying styles. This column is different. Here we apply new tags and classes to each cell instead of using the combination of classes used by the other columns:

  & lt; p class = "body-text body-text - md body-text - semibold" & gt;  

Instead of trying to implement this functionality in createCellData or createNodeData , I suggest creating a new function that will use the capabilities of these basic functions that perform data transformation. It will implement a new data processing mechanism:

  function createDemoCellData (data) {
 let children;
 const classes = getClasses (data.classes);//In those cases when we have to work with several classes, we need to create child elements and apply each class to each child element.
 if (Array.isArray (classes)) {
 children = classes.map (child = & gt;//We can use "data.text" as each node in the group present in the cell contains the same text
 createNode ("span", child, data.text, children)
 );
//Process the case when there is only one class
 if (typeof classes === "string") {
 return createNode ("p", classes, data.text, children);
//Handle cases when there are no classes (for headings)
 return createNode (data.element, null, data.text, children);
 }  

Now the string data is in the normalized format and can be passed to the render functions:

  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
 }  

Data Rendering


Here's how to render the data that is displayed on the page:

 //Access the data in the "props" object
 const rowData = props.rowData;
//Pass them to the function used to transform the data.
 const row = createRow (rowData);
//Create the root node "div" and process each cell
 return h ("div", {class: "row"}, row.map (cell = & gt; renderCells (cell)));
//Go around the cell values
 function renderCells (data) {
//Handling cells that have several child nodes
 if (data.children.length) {
 return renderCell (
 data.tag,//Using Cell Base Tag
 {//Here we work with attributes
 class: {
 group: true,//Add a class for the "group" since there are several nodes here
 [data.cellClass]: data.cellClass//If the cell class is not represented by a value, apply it to this node

 },//Site Content
 data.children.map (child = & gt; {
 return renderCell (
 child.tag,
 {class: child.cellClass},
 child.text
 );
 })
 );

//If there are no child elements, display the base cell
 return renderCell (data.tag, {class: data.cellClass}, data.text);
 }
//Wrap function around "h" to improve code readability
 function renderCell (tag, classArgs, text) {
 return h (tag, classArgs, text);
 }  

Now, everything is ready ! Here , again, the source code.

Totals


It is worth saying that the approach considered here is an experimental way to solve a rather trivial task. I am sure that many will say that this decision is unnecessarily complicated and overloaded with engineering excesses. Perhaps I agree with that.

Despite the fact that the development of this project took a lot of time, the data are now completely separated from the presentation. Now, if our designers go to add some rows to the table, or delete any existing rows from it, I don’t have to rake tangled html code. In order to do this, it will be enough for me to change several properties in the JSON file.

Is the result worth the effort? I think that here it is necessary to look at the circumstances. This, however, is very typical for programming. I want to say that in my head, in the process of working on this project, the following picture constantly appeared.


Perhaps this is the answer to my question about whether this project is worth the effort spent on its development.

Dear readers! What ideas and suggestions can you express about the project reviewed here? What ways did you solve such problems?

Source text: [Translation] A practical example of using Vue render functions: creating a typographic grid for a design system