(译)使用渲染函数构建一个设计系统的排版布局

谈谈你对Vue函数式组件的理解?javascript

本身先想一分钟css

译者注:英语和文笔有限,不对之处欢迎留言斧正!原文地址:t.cn/AipuKWqvhtml

这篇文章介绍了我是如何使用Vue渲染函数为设计系统构建网格布局的。这里是相关的demo代码。我之因此选择使用渲染函数是由于它比常规的Vue模板语法在可控性方面表现的更增强大。然而关于渲染函数的介绍,我在网上并未找到太多的文章,这让我很惊讶。我但愿这篇文章能弥补这方面的不足,而且提供一个有用和实用的使用Vue渲染函数的用例。vue

过去我老是以为渲染函数太过于抽象,甚至有点不合时宜。虽然框架的其他部分强调简单性和关注点分离,但渲染函数是一种奇怪的,一般难以阅读的HTML和JavaScript混合。java

例如,下面的代码:node

<div class="container">
  <p class="my-awesome-class">Some cool text</p>
</div>
复制代码

使用渲染函数你须要:git

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

我怀疑这种语法会让一些人头大,由于简单易上手是咱们一开始学习Vue的关键因素。这很遗憾,由于渲染函数和函数式组件可以提供一些很是酷,且功能强大的东西。好吧,让咱们看下它是怎样帮助我解决实际问题的。github

Tips: 在新标签页中打开示例代码对照本文阅读效果更佳哦~ 编程

1. 定义一个设计标准

个人团队但愿在咱们的VuePress驱动设计系统中包含一个页面,展现不一样的排版选项。下面的截图是我从设计师那里得到的效果图的一部分。json

这里是一些相应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;
}
复制代码

标题主要用的是标题(h1~h6)元素,其余项目用的是类名,而且还有设置字体加粗和大小的单独类。

在写代码以前,咱们先作一些约定:

  • 因为这其实是一个数据可视化展现,所以数据应该存储在单独的文件中

  • 标题应使用语义化标题标签(例如<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>
    复制代码
  • 对于每一个要展现样式的单元格,只须要编写一次类名

2. 为什么渲染函数有存在的意义

开始作以前我考虑了几点:

2.1 硬编码

我喜欢在适当的时候使用硬编码,由于手工编写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>
复制代码

2.2 使用传统的Vue模板

点击查看Codeopen代码。这一般是首选方案,但请注意:

第一列,咱们有:

  • 一个按原样渲染的<h1> 标签
  • 一个<p>标签,它将一些带有文本的子标签<span>组合在一块儿,每一个 span 标签上都有一个类(可是在 p 标签上没有特殊的类)
  • 一个带有类但没有子节点的 <p> 标签

这也就意味着咱们须要加不少的 v-ifv-if-else 去判断,并且加着加着逻辑就会变得很混乱,我也不太喜欢在HTML中加这些逻辑判断,这让代码很难阅读。

基于这个缘由,我选择了渲染函数。渲染函数是使用Javascript基于现有的逻辑规则有条件的添加子节点,对于我这种业务处理彷佛是完美的解决方案。

3. 数据模型

正如我以前提到的,我但愿将数据存放在一个单独的JSON文件中,这样我之后只需改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"]
}
复制代码

渲染成HTML是这样子:

<div class="row">
  <!-- 第一列 -->
  <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>
  <!-- 第二列 -->
  <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>
  <!-- 第三列 -->
  <p class="body-text body-text--md body-text--semibold">Proxima Nova Bold and Regular, 20px</p>
  <!-- 第四列 -->
  <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>
复制代码

4. 基础设置

假设咱们有一个用来包装表格容器的父组件 TypographyTable.vue,以及一个用来建立行和包含咱们渲染函数的子组件 TypographyRow.vue。而后我循环遍历行,每行的数据经过 props 传递:

<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>  
    <!-- 循环遍历将咱们的数据做为props传给每一行 -->
    <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访问。这样它既不会改变也不会成为响应式数据。

5. 使用函数式组件

我将 TypographyRow.vue 组件改形成一个函数式组件。这意味函数式组件无状态 (没有响应式数据),也没有实例 (没有 this 上下文),而且没法访问任何Vue生命周期方法。

一个空的函数式组件大致上是这个样子:

// No <template>
<script> export default { name: "TypographyRow", functional: true, // 这个属性表示它是一个函数式组件 props: { rowData: { // 行数据 type: Object } }, render(createElement, { props }) { // 渲染行的逻辑代码在这里 } } </script>
复制代码

其中 render 方法接受一个 context 上下文参数,咱们可经过解构获取单独的 props 属性。

第一个参数 createElement,咱们都知道是一个Vue提供的用来建立虚拟DOM的函数。按照国际惯例,我把 createElement 缩写成 h,关于为何这么作,你能够读一下Sarsh's post的文章。

h 有三个参数:

  1. 一个html标签(例如:div
  2. 一个具备模板属性的数据对象(例如:{class: 'something'}
  3. 文本字符串(若是咱们只是添加文本)或者是使用 h 建立的子节点
render(h, { props }) {
  return h("div", { class: "example-class" }, "Here's my example text")
}
复制代码

好了,简单归纳下咱们目前为止作了哪些事情吧:

  • 一个用于呈现数据可视化的JSON文件
  • 一个我正在导入完整数据的常规Vue组件和
  • 一个用于展现每一行数据的函数式组件雏形

要建立每一行,须要将JSON文件中的数据对象做为参数传给h。这能够一次完成但这样作会涉及到不少的条件逻辑判断,而且使人困惑。因此接下来,我决定分两部分来作:

  1. 将数据格式化,即使于观察的格式
  2. 而后再渲染转换后的数据

6. 转换普通数据

我但愿个人数据结构跟 h 要求的参数匹配,因此转换以前我说下我想要的结构:

// 一个单元格
{
  tag: "", // 当前级别的HTML标签
  cellClass: "", // 当前级别的类名,若是类名不存在则为空
  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";
  // 若是 text 做为数组传入的,则建立一个以 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;
}
复制代码

咱们将 propertiesusage 做为参数传递给第三和第四列。可是第二列有点不一样;画?????那里,咱们将展现存储在数据文件中的类名,如:

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

另外,请记住标题没有类,所以咱们要显示这些行的标题标记名称(例如h1h2等)。

让咱们建立一个辅助函数来将这些数据解析成咱们能够用于文本参数的格式:

// 参数分别是标签名和类名
function displayClasses(element, classes) {
  // 若是没有类,就返回基本标签(适用于标题)
  return getClasses(classes) ? getClasses(classes) : 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;
}
复制代码

7. 转换演示数据

还剩下第一列的示例样式没处理了。这一列须要展现效果,因此咱们须要为其赋予新的标签和类名,不能用上面的方法了。

<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`是由于元数据中每一个对象都有一个 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
}
复制代码

8. 渲染数据

下面这才是咱们渲染数据方式的最终逻辑代码:

// 访问`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 // 若是单元格类不为空,则将其应用于该节点
        }
      },
      // 节点内容
      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);
}
复制代码

咱们获得了咱们最终想要的产品! 源码看这里

9. 总结

值得指出的是,这种方法表明了解决相对简单问题的一种实验性方法。我相信不少人会争辩说这个解决方案是不必的复杂的以及过分设计的。我可能会认可这一点。

然而,尽管前期花费了不少时间,但数据如今已彻底跟页面解耦。如今,若是个人设计团队再想添加或删除行,我没必要深刻研究那一坨又一坨凌乱的HTML — 我只要更新JSON文件中的几个属性就好了。

这值得吗?就像编程中的其余事情同样,它取决于最佳实践,一图胜千言吧:

  • 顾客:你能给我加点盐吗?
  • (20分钟后)
  • 顾客:都过二十分钟了!
  • 厨师:我说过了——我知道!我正在开发一个能够给你加任意调味品的系统。从长远来看这会节省更多时间!

图片来源: xkcd.com/974

也许这是一个答案,我很乐意听到你全部的(建设性)的想法和建议。或者你是否尝试过其余方式完成相似的任务。

最后,下面是我维护的一个Q群,欢迎扫码进群哦,让咱们一块儿交流学习吧。也能够加我我的微信:G911214255 ,备注 掘金 便可。

Q1769617251
相关文章
相关标签/搜索