配置化页面渲染系统设计和实践

3,265 阅读16分钟

前言

一般来说我们在开发前端页面的时候,都会根据设计稿的设计思路编写 HTML 然后通过 CSS 修饰页面样式并使用 JavaScript 编写控制逻辑,从而形成了非常经典的“DIV + CSS + JS”组合。再后来因为来自产品经理和设计师的需求越发的重(tou)复(lan),聪明的前端工程师发现可以通过将这些重复的内容进行抽象化变成组件从而可以进行多次复用,减少重复劳动所带来的心智负担,前端开发组件化的时代从此开始。

html_css_js.jpg

随着越来越多的前端工程师选择了组件化的开发模式以后,拥有更多时间、需求、经验和 KPI 的大厂就决定将自己的技术成果向外输出。从 jQuery UI、Sencha、YUI(Yahoo)、Bootstrap(Twitter)、Pure(Yahoo) 这些曾经红极一时的 UI 框架,再到后来的 Antd(蚂蚁金服)、ElementUI(饿了么) 这些结合大量控件逻辑的组件库,都在致力于利用特定的样式风格输出自家的一些技术成果和“价值观”。

不管怎样,这些第三方组件库和 UI 框架都大大地提高了前端工程师在技术选型和实际开发的工作效率。这也让我们可以从繁杂的重复工作中脱离,并带着“摆脱重复”的理念向下一个问题进行探索。

结构化系统

随着大数据、监控系统、分析系统等概念逐渐在各大小公司中普及后,开发一个前端系统以展示和控制的需求则变得十分常见,所以我们也不例外。在开始着手开发之前,事实上我们已经考察甚至长时间使用过一些不同类型的类似系统,如监控报表系统 Grafana、数据分析系统 Zeppline、Metabase、Superset 等等。

我们不难发现这些的设计思路都有一个共同的特点:卡片式页面结构。Desktop HD.jpg

卡片式页面结构的好处是可以将页面内容更好地进行梳理和排版,而且内容的分布和语义也更加直观,从开发的角度看也会更便捷。每一个卡片我们都可以看做是一个组件,可以通过使用 UI 框架或者组件库中栅格(Gird)系统将其非常简单地堆砌在页面上。

新时代的重复工作

有了优秀的开发模式、方便的组件库和灵活的页面结构设计后,前端工程师终于可以将工作的重心从零碎的技术细节开发变成了详细页面内容逻辑的开发,有了质的飞跃。

但是前端工程师慢慢地又发现,在工作中依然会有很多的重复性工作存在,只是从以前的不断写重复代码,变成了不断地写重复逻辑。就以我们这里的卡片式图表系统作为例子,我们可以看到其实其中的很多卡片都是同样的类型,只是其中的数据、文字不一样。

但是哪怕我们已经将这些这些相同类型的卡片通过组件的方式(如 React Component 和 Vue Component)进行了一定的抽象化,使得这些内容可以套用在不同的数据上重复使用。但是一般来说这种系统的页面数一定是非常多的,不同的数据类型、权限分级便会有不同的页面内容。所以前端工程师的重复工作就又变成了:根据需求开发新的卡片和根据需求堆砌新的页面。

数据平台的问题

在我们开始开发我们的数据平台前,我们首先需要明确以下几个问题(针对前端开发):

  1. 数据参数传递的主动权归谁?
  2. 页面的内容由谁开发?

可能你看到这几个问题会有些奇怪,这两个问题看上去都不太像是前端需要关心的啊?别着急,我们一个一个问题来解决。

Q1: 数据参数传递的主动权归谁?

首先什么是数据参数,比如页面中有两个同样为饼状图的图表,其中的一个图表的内容是字段 A 的分类情况,而另外一个则是字段 B 的分类情况。1.png

那么数据参数便是卡片 1 的“字段 A 分布”和卡片 2 的“字段 B 分布”,抛开原生不支持 SQL 语言的数据库(MongoDB 等)或分析系统(Elasticsearch 等)不说,我们从同一个数据库获取数据的参数最终都会是 SQL 语句。而在系统设计的过程中,有的架构师会选择给每一个 SQL 语句(对应一个图表或多个图表)设定一个 ID 或别名(Alias)以作为从客户端向服务端获取指定数据的参数。

Alias SQL
Query 1 SELECT A, count(id) FROM table GROUP BY A
Query 2 SELECT B, count(id) FROM table GROUP BY B

这种就是典型的服务端掌握参数主动权,详细的查询由服务端掌握,而客户端只能通过“别名+参数”的方式进行查询。而如果说主动权归客户端掌握的话,便是将 SQL 或者类似于 MongoDB 中的查询结构由客户端直接提供,而服务端则充当一个链接客户端和数据库的代理。two-ways.png

有的同学会担心有客户端直接发送 SQL 或等效内容的方法容易造成数据安全的问题,事实上无论是向服务端发送 ID 还是发送 SQL,在这中间的通讯中都需要进行权限鉴定,而相比于接受 ID 和参数需要检查参数是否有 SQL 注入或其他安全隐患外,接受 SQL 或等效对象则需要检查这个东西是否有安全隐患或者权限是否足够。虽然听着有点困难,实际上跟检查是否有注入威胁的原理是类似的。

Q2: 页面的内容由谁开发?

这个问题听上去这个问题非常可笑,大部分人认为页面的内容应该就是要由负责开发页面的前端工程师负责的啊?其实上我们并不会这样认为,因为在数据分析平台这种场景中,页面的内容应该由需求方负责,因为数据的来源、统计口径、可选参数、语义表达等等都只能由需求方来确定,而不是负责平台本身的工程师。

假如你所负责的项目需要服务非常多人,而且其中内容的需求方多而复杂。那么作为项目工程师的你也同样不愿意同时处理那么多的需求,也就是你们不可能为在短时间内亲自开发所有的页面。

在这种情况下我们要如何应对呢?答案便是页面和内容由需求方直接负责,包括“开发”。但我们可爱的需求方可不一定会写 JavaScript 和 HTML,有的甚至连用于获取数据的 SQL 语句都不会写,那要怎么办呢?我们的做法是开发“傻瓜式”的编辑工具。(下图为 Metabase)Xnip2019-03-26_15-39-46.jpg

这就回应了我们的第一个问题,需求方可以通过选择数据源、制定统计逻辑以及可选参数的方式构建查询参数,由前端系统将需求方“编写”好的查询结构主动向服务端请求数据,也就是客户端掌握数据参数传递的主动权。

这样的好处是可以在编辑的时候可以马上看到数据和可视化效果,因为无论是编辑预览还是实际的展示,对于服务端来说都一样的。当然,如果担心普通用户浏览时的数据安全性,也可以通过编辑时将查询逻辑保存到数据库中并返回相应的 ID 或别名以供前端系统使用。

但是光有数据内容可不够啊,一个数据只能代表一个图表和一个卡片,而页面的开发还包括了如标题、排版布局还有数据的可选项等等,那么我们便可以正式进入本文的正题了。

配置化页面渲染系统

既然我们已经确定了整个系统除了一些基本样式和基本组件(比如顶部栏、侧边菜单栏等)由我们来开发以外,基本上大部分的页面都由需求方直接完成。那么我们的核心工作重点便是开发一个满足这个需求的渲染系统以及用于给需求方进行“开发”的可视化编辑器

事实上除了页面内容以外,我们还为整个系统的导航和菜单实现了配置化渲染,但本文的重点并不在于这里,所以我们只讨论每一个页面中的渲染系统。

动态栅格排版

首先我们确定页面的渲染基础架构使用栅格排版系统进行排版,此处我们使用 Ant Design(后简称 antd)作为我们开发的基本工具。antd 的栅格系统提供每一个 Row 中总宽度为 24 个栅格的空间,假设我们需要制作前面设计图中的内容,其中的栅格情况用简化图表达为如下。未命名.001.jpeg

因为一般的栅格系统都是通过 Col 的从左往右堆砌得到完整的排版结果的,所以将上面的格式用从左往右的顺序打平就得到这样的列表:

Order Span Offset
0 8 0
1 8 0
2 8 0
3 24 0
4 16 0
5 8 0

这样的存储方式也便于在 RMDBS 中进行存储和管理,但问题在于如何将这个序列再次转换成排版结果。因为在 antd 中如果在同一个 Row 中的 Col 宽度栅格总和超过 24 的话,便会发生排版错误的问题。所以转换过程中需要计算好每一行的栅格宽度不超过 24,其中每一个卡片的栅格宽度等于 span+offset。我们可以用下面的这样一个算法可以将一维序列转换为二维序列。

// 卡片对象
interface ICard {
  order: number
  span: number
  offset: number
}

// 排版算法
function convertCardList2Grid(cardList: ICard[]): ICard[][] {
  const rows: ICard[][] = []
  const pendingCards = cardList
    .sort((leftCard, rightCard) => leftCard.order - rightCard.order)
    .slice()
  
  let currentRow: ICard[] = []
  
  while (pendingCards.length > 0) {
    const card = pendingCards.shift()
    const sumWidth = computeRowWidth(currentRow.concat([ card ]))
    
    if (sumWidth <= 24) {
      currentRow.push(card)
    }
    
    if (sumWidth === 24) {
      rows.push(currentRow)
      currentRow = []
    }
    
    if (sumWidth > 24) {
      rows.push(currentRow)
      currentRow = []
      pendingCards.unshift(card)
    }
  }
  
  if (currentRow.length > 0 && computeRowWidth(currentRow) <= 24) {
    rows.push(currentRow)
  }
  
  return rows
}

// 计算栅格宽度
function computeRowWidth(row: ICard[]) {
  return row
    .map(card => card.span + card.offset)
    .reduce((a, b) => a + b)
}

// 从服务器得到的卡片列表
const pageCards: ICard[] = [
  { order: 0, span: 8,  offset: 0 },
  { order: 1, span: 8,  offset: 0 },
  { order: 2, span: 8,  offset: 0 },
  { order: 3, span: 24, offset: 0 },
  { order: 4, span: 16, offset: 0 },
  { order: 5, span: 8,  offset: 0 }
]
const rows = convertCardList2Grid(pageCards)
//=>
// [
//   [ { order: 0, span: 8,  offset: 0 }, { order: 1, span: 8,  offset: 0 }, { order: 2, span: 8,  offset: 0 } ],
//   [ { order: 3, span: 24, offset: 0 } ],
//   [ { order: 4, span: 16, offset: 0 }, { order: 5, span: 8,  offset: 0 } ]
// ]

完成了排版的转换以后,就可以将得到的结果进行实际的栅格渲染了。

import * as React from 'react'
import { Row, Col } from 'antd'

function renderGrid(rows: ICard[][]): React.ReactNode {
  const rowComps = rows.map((cards, i) => {
    const colComps = cards.map((card, j) => {
      return (
        <Col span={card.span} offset={card.offset} key={`${i}-${j}`}>
          {`${i}-${j}`}
        </Col>
      )
    })
    return (
      <Row key={i}>
        {colComps}
      </Row>
    )
  })
  return rowComps
}

class App extends React.Component<any> {
  public render() {
    return (
      <div>
        {renderGrid(rows)}
      </div>
    )
  }
}


配置机制

除了排版的渲染以外,如果卡片没有内容就没有意义了。而在我们的数据平台的场景中,每一个卡片的内容主要由三个部分组成:

  1. 模板卡片自身所带内容(HTML 内容)
  2. 卡片配置参数
  3. 图表数据

但事实上我们不应该把“卡片”的概念限制得太死,事实上我们还把页面上的其他元素以“卡片”的形式实现了出来,比如小标题、时间范围选择控件等等。而其中像时间范围选择控件这类卡片是需要在页面中充当控制器的一种存在,也就是说页面中的其他卡片需要从这类卡片中得到一些现场可变的参数来进行渲染,所以除了上面三个元素以外,用于渲染卡片内容的元素还包括了卡片内部参数和页面卡片参数。

另外我们再来详细说一下图表数据的部分,前面我们说到了一个卡片如果是一个图表卡片,它便需要在进行渲染之前通过查询机制从服务端获取到数据然后再渲染到图表上。而前面的部门我们就讨论过如何向服务端索取数据,那么无论是哪一种方式都应该支持动态查询参数,那么这些查询参数就来源于卡片的配置参数、卡片内部参数和页面卡片参数。未命名.002.jpeg

卡片配置参数

我们在数据库中存储了类似于这样的卡片配置,其中用于查询数据的查询参数也以特殊字段的形式存储在使用 JSON 表达的卡片配置参数中。

{
  // Part 1
  "page_id": 1,
  "card_key": "pie",          // 对应应用的卡片模板
  "order": 0,
  "span": 24,
  "offset": 0,
  
  // Part 2
  "args": {
    "title": "字段 A 分布情况",
    "group": "@{A}",          // 引用聚合字段
    "value": "@{count(id)}",  // 绑定统计字段
    
  // Part 3
    "$query": {               // 查询参数
      "select": [ "A", "count(id)" ],
      "table": "table",
      "group": [ "A" ]
    }
  }
}

首先第一部分是系统用于整体配置的参数,包括所在的页面、所使用的卡片模板还有排版渲染所用的参数,渲染系统在读取到这些参数以后首先先检查是否存在对应的卡片模板,如果没有则放弃该配置的渲染。然后就是进行前面所设计的排版流程,得到完整的页面架构。

然后第二部分则是目标卡片模板所指定的模板参数,一般来说除了 title 以外的字段都应该由每一个卡片模板自行设定。

字段绑定

import * as React from 'react'
import BaseCard from '@cards/base-card'

class Pie extends BaseCard<any> {
  
  get chartOption() {
    const groups = this.getProp<string[]>('group') // aggregation values
    const values = this.getProp<number[]>('value')
    return {
      // ...
    }
  }
  
  public renderContent() {
    return (
      <div>
        {/* render chart with this.chartOption */}
      </div>
    )
  }
  
}

BaseCard.register('pie', Pie)

Pie.registerProp('group', {
  description: '绑定聚合',
  required: true
})
Pie.registerProp('value', {
  description: '绑定数值',
  required: true
})

我们还是以前面的饼状图 Pie 作为例子,上面的卡片参数中设定了两个参数 group 和 value,分别对应了饼状图中的分类和其对应数值。假设我们从服务端获取到的数据是以标准的行式表形式表达的,那么在 BaseCard 中为了方便进行字段绑定会将其转换为列式表,参考 基于 JavaScript 开发灵活的数据应用

// 从服务端取得的原始行式数据表
[
  [ "A", "count(id)" ],
  [ "A-1", 100 ],
  [ "A-2", 123 ],
  [ "A-3", 233 ]
]
// 转换得到的列式数据表
{
  "A": [ "A-1", "A-2", "A-3" ],
  "count(id)": [ 100, 123, 233 ]
}

在 this.getProp 方法中有一套比较复杂的逻辑,这个方法会自动从卡片配置获取对应字段的配置。如果该字段的配置是一个完全绑定字段,比如 "group": "@{A}",那么这个方法便会返回一个元素类型为字符串的数组,这个 @{A} 则是绑定的在第三部分中 select 的一个查询字段。对应的,"value": "@{count(id)}" 则是绑定的计数字段,所以内容是一个元素类型为数值的数组。


卡片内部参数

这里是卡片配置中引用了图表数据中的字段,而事实上这两者是可以互相引用的。假设这个图表上我们除了图表本身以外,还包含有一个下拉选择控件,用于选择查看字段 A 或字段 B。未命名.003.jpeg

我们把下拉选择框中的内容也在配置中设置。

{
  "args": {
    "options": [
      { "label": "字段 A", "value": "A" },
      { "label": "字段 B", "value": "B" }
    ]
  }
}
interface IOption {
  label: string
  value: string
}

// ...

public renderContent() {
  const options = this.getProp<IOption[]>('options')
}

// ...

Pie.registerProp('options', {
  description: '下拉框选项',
  required: true,
  type: BaseCard.cardPropType.json
})

配置好卡片上的可选项以后,这个选项应该需要应用到数据查询的参数上,以在浏览用户在变动选项以后图表的数据可以得到更新。那么我们首先需要在卡片上注册该卡片的内部参数,也就是我们前面提到的 Param。

Param 的注册方法跟 Prop 类似,但需要在卡片类中实现一个方法以向外提供参数内容。

class Pie extends BaseCard<any> {
  private state = {
    selectedOptionValue: ''
  }
  public cardParams() {
    return {
      'selected-option': this.state.selectedOptionValue
    }
  }
}

Pie.registerParam('selected-option', {
  description: '已选选项'
})

这样我们通过调用 this.setState('selectedOptionValue', value) 便可以向配置提供由终端用户控制的动态参数,然后我们在卡片配置中就可以进行使用了。

{
  "args": {
    "group": "@{selected-option}",  // 引用聚合字段
    "$query": {                     // 查询参数
      "select": [ "@{selected-option}", "count(id)" ],
      "table": "table",
      "group": [ "@{selected-option}" ]
    }
  }
}


页面级参数

除了卡片内部的控件会产生参数以外,页面中我们还会使用一些可以控制全局或局部卡片的控制器,比如当前页面的数据取数范围受限于页面中的一个时间范围选择控件。而就如前面我们曾经提到的,为了实现更加方便我们同样的把这些控件作为一个“卡片”来进行实现,不过相比于普通的内容卡片通过重载 renderContent 方法以只控制卡片中的内容部分,而对于这种我们并不需要卡片样式的“卡片”我们则选择直接重载 render 方法。

相比于卡片内部的参数,如果一个参数需要向页面中的其他卡片提供内容,则只需要在调用 Card.registerParam 方法的时候加上 scope 配置即可(默认为 card)。

import * as React from 'react'
import * as moment from 'moment'
import BaseCard from '@cards/base-card'

const TIME_LAYOUT = 'YYYY-MM-DD'

class TimeRangePicker extends BaseCard<any> {
  
  private state = {
    timeRange = [moment().subtract(1, 'day'), moment()]
  }
  
  public cardParams() {
    return {
      'start-time': this.state.timeRange[0].format(TIME_LAYOUT),
      'end-time': this.state.timeRange[1].format(TIME_LAYOUT)
    }
  }

}

BaseCard.register('time-range-picker', TimeRangePicker)

TimeRangePicker.registerParam('start-time', {
  description: '起始时间',
  scope: 'page'
})
TimeRangePicker.registerParam('end-time', {
  description: '结束时间',
  scope: 'page'
})

通过给参数的 scope 选项配置为 page 就可以将该参数注册到页面作用域内,然后在其他的卡片中就可以通过使用 'page:start-time' 进行绑定或使用 this.getParam<string>('page:start-time') 直接取得。

参数流水线

设计了这么多的配置项,最终的目的都是为了能够将以往需要编写逻辑代码来完成的任务能够通过配置的方式方便地完成。那么这些配置项有的可以相互引用,有的则存在依赖关系,我们可以总结成这样得一个流水线。

  1. 服务端从数据库中取得卡片配置参数 Props,也就是数据记录中的 args
  2. 渲染系统根据卡片参数完成卡片的渲染,其中包括卡片内部参数 Params 的生成;
  3. 卡片内部机制根据 args.$query 配合卡片配置参数和卡片内部参数组合成真正的数据查询参数,并取得数据集;
  4. 得到数据后再将数据中的数据根据需要绑定到卡片配置参数上。未命名.004.jpeg

事实上 Props、Params 和 Dataset 都可以在卡片模板代码中分别通过 this.getProp<T>(key)this.getParam<T>(key) 和 this.dataset 取得,而不一定非得使用字段绑定的方法完成。

卡片注册机制

在上面的探索中我们使用到了 BaseCard.registerCard.registerProp 等方法,这些方法都是为了能让系统知道卡片的存在,以及每个卡片中都有什么可使用的内容我们这样就可以通过注册机制中取得所有可用卡片的可选配置,从而实现在可视化编辑器中取得当前前端系统中的可用卡片,这些信息并不需要服务端来进行维护。

import BaseCard, { cards } from '@cards/base-card'

// 列出卡片
console.log(Object.keys(cards))
// => [ 'pie', 'time-range-picker' ]

const PieCard = cards['pie']

// 列出饼状图卡片的可选配置参数和卡片内部参数
console.log(BaseCard.carPropsMap.get(PieCard))
//=> [ { key: 'group', ... }, ... ]
console.log(BaseCard.carParamsMap.get(PieCard))
//=> [ { key: 'selected-option', ... }, ... ]

EOF

有了用于渲染页面排版的动态栅格排版机制和用于渲染卡片内容以及协调卡片逻辑的配置机制以后,便可以很方便地将开发工作压缩到了开发卡片和维护稳定性上。当然为了能让需求方能够更好地进行使用,我们还是要为他们预先开发一些页面作为样例。

除了这些问题以外,我们在开发卡片的时候也需要不断地控制着一个平衡性:适用范围与开发成本的矛盾。一个卡片的适用范围越广,那么它的开发成本也会随着增加,假设一个卡片利用一个通用的图表可视化工具(比如 ECharts)可以适用于所有的可视化图表类型,那么想必它的配置选项一定需要做得非常复杂,因为为了安全性考虑我们并不允许卡片配置本身(JSON)包含逻辑。

而另一个极端便是完全没有可选配置,比如我们也使用了这套机制完成了一些系统控制页面之类的内容,这些就跟普通的页面开发没有区别。

事实上这套系统是在一个数据分析平台的场景中诞生的,所以其中包括了数据的查询、字段绑定等等功能。但它同样可以将这个部分剥离,只保留动态排版和卡片配置,这样便可以适用于更多的业务场景上。