9

看板组件的高度业务封装

 3 years ago
source link: https://zhuanlan.zhihu.com/p/106702607
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

看板组件的高度业务封装

神经网络调过参,现做前端来搬砖

在中后台业务中,通常会有图表类的需求, 除了给领导看的各种大盘走势,还常有用于指导一线运营业务拓展的,而且展示、聚合情况多样化、精细化,为了适应数据的多种比较方式,我们结合底层数据的存储方式,封装了前后端一体化的图表查询方式。

前端展示上,基于echarts进行二次封装,同时提供多种能力的支持:

    • 基于统一的data query接口,传入相关请求参数,自动进行网络请求(也支持直接传入数据)
    • 支持多种维度的数据聚合、列计算能力
    • 将通用图表类型封装,使用时只需要指定图表type即可
    • 数据缓存与请求复用:对请求数据提供基于window的缓存,数据有效期内新发请求会复用之前结果
    • 懒更新:不可见时,不重新渲染图表,对初次加载和后续更新均有效
    • 预加载:在浏览器空闲时,顺序加载该页面中所有图表的网络请求
    • Render props: 可以使用Chart提供的基础能力,如请求、缓存、懒更新,进行自定义内容的渲染
    • 支持下钻: 鼠标在图表中hover时的选中区域,右键,支持自定义文案与onClick回调
    • 数据下载:可支持选择下载当前图表显示数据或者源数据
    • 请求刷新(for development):方便开发时调试单个图表请求
    • 支持echarts配置:会对内部生成的config进行merge后作为最终echarts配置参数
    • 自动数据上报

API层使用统一的数据请求接口

之前的看板开发模式:每个图表对应着api层的一个接口,接口请求多、开发工作量大、前后端复用性都较低,难以高效应对越来越复杂的看板统计需求:

  1. 展示图表与底层数据表对应关系复杂

有很多页面都是展示型看板(+筛选条件),并且一个页面中需要众多不同维度的统计,如根据性别、机型、年龄等维度或者组合条件如性别+年龄交叉等维度查看VV,DAU,留存等指标。其中有一些Chart对应的底层数据来自同一张表,而有些图表的数据却需要底层多张表的数据连接来提供。

2. 筛选条件中操作类型多样

对数据类的筛选,常常会有“大于”、“大于等于”、“is”,或者“in”之类的操作类型,如果还使用之前基于k:v的格式作为筛选项,就必须将每个v改为object类型:存储实际值和操作类型。但这无疑会大大增加前端提交和api层解析参数的成本。

综上,为了降低开发成本,需要针对业务现状规范好数据格式、固化组件样式,实现配置化开发,形成快速堆砌页面的能力。

设计文档&接口规范

前后端约定好config_name,通常每个config_name对应一个底层数据表。

  • 前端按照约定传入必要参数
struct QueryDataRequest {
1: required string config_name,
2: optional list<Filter> filters,
3: optional list<OrderBy> order_bys,
4: optional list<string> return_fields,
5: optional i32 offset,
6: optional i32 count, 
// 省略部分参数
} 
  • api层公用接口透传请求,rpc方式调用后端
  • 后端根据config_name约定,去数据仓库读取相应表,并返回数据,以及初步处理
  • 然后前端根据需求对相应维度进行数据统计聚合并展示

按照我们的约定,dataQuery接口返回的数据格式为:

struct Table {
1: list<ColumnInfo> column_infos,   // 表头
2: list<list<string>> datas,        // 一行行的数据,按照表头的顺序
}

接口返回的fake数据形如:

{
  column_infos: [{field_name: 'date', field_type: 2, field_title: '日期'}, 
    {field_name: 'age', field_type: 2, field_title: '年龄'}, 
    {field_name: 'gender', field_type: 2, field_title: '性别'}, 
    {field_name: 'mode', field_type: 2, field_title: '机型'}, 
    {field_name: 'dau', field_type: 1, field_title: '日活'}]
  datas: [['7月1日', '中年', '男性', '高端机', '671'], 
    ['7月1日', '老年', '男性', '高端机', '121'],
    ['7月1日', '青年', '男性', '高端机',  '892']]
}

其中column_infos中指明表头有哪些字段(field_name),其数据类型(field_type),其对应解释(field_title)。 datas中每一列都对应column_infos同顺位的字段解析。

前端将其处理Array<Object>类型后,格式如下:

也只有让数据这样“原子化”,才能支持前端对其进行各种(组合)维度的统计,但这也对前端的统计能力提出了挑战:我们需要提供根据指定的行、列维度进行统计,比如传参​{x: 'date', y: 'dau', dims: ['age', 'gender']}​则能够生成以日期为横轴,DAU值为y轴,以性别+年龄组合出的6条不通过维度的折线图。(性别分类为男/女2种,年龄分类为中/青/老3种,则有维度:男-中年,男-青年,男-老年,女-中年,女-青年,女-老年)

前端看板组件的数据处理&封装

组件需求分析

  1. 因为team已经根据看板需求确定了符合自身业务特色的数据查询接口,所以也希望将数据的获取工作统一到组件中来,进一步降低开发看板的工作量。

得到Todo1: 封装图表数据请求相关参数,并对其进行处理(也支持自定义的处理方法),得到Array<Object>类型的数据。

2. 为了应对多种多样条件的聚合展示,前端得到是“原子化”的数据,因此需要我们更够对各种需求都通过简单的参数配置就得到相应的数据统计结果。

得到Todo2: 封装图表格式化参数,根据指定横、纵坐标,聚合维度,输出能被echarts消费的数据格式。

3. 既然是基于echarts封装的看板组件,其必须实现的功能就是:对常见的图表类型line, bar, pie,lineBar(双坐标图表)等进行统一封装,以实现在尽可能统一的条件配置下,快速搭建风格一致的图表。

得到todo3: 基于上一步的数据,打造数据/配置 适配层,实现通过配置图表类型就能生成对应echarts配置的格式转换。开发者仅需要指定type是line还是bar即可,无需传入复杂的echarts配置。

综合以上3个关键的Todo来看,组件需要提供两类参数配置:

  • Request params(reqParams): 向dataQuery接口发起网络请求时的参数

根据前面提到的dataQuery查询接口规范,reqParams的操作空间不大,与rpc接口的请求参数基本相同:config_name, filters, order_bys, return_fields等。

  • Format params (oneOfType([formatParamsType, PropTypes.arrayOf(formatParamsType)])
    • type: 指定echarts图表类型,如line, bar, etc.
    • xKey/rowKey: 以哪个维度作为横坐标,如'date'
    • yKey/target: 以哪个指标作为纵坐标,比如x: 'date', y: 'dau',即以日期为横轴,dau值为纵轴
    • dims(dimension): 以什么维度进行聚合统计,如['age', 'gender'], 则是以年龄和性别交叉维度展示。
    • assistTarget: 以一个新的计算后的属性/指标作为y轴
    • assistIndexes: 传入需要的参数列
    • assistFunc: 以assistIndexes中指定的参数为入参的方法,返回新的值
    • mode: PropTypes.oneOf(['value', 'percent']),是展示数值还是比例,如果是比例,内部会自动转换为百分比
    • ...一些格式化的方法注入

而执行流程也就大抵如下:

​const data = fetchData(reqParams)​
​const processedData = getPerspectiveData(data, formatParams)​
​const echartConfig = getEchartConfig(processedData, formatParams)​
​renderChart(echartConfig)​

分步骤详解

下面来详细介绍下前三步的数据处理与封装逻辑。

封装请求参数

reqParams基本是符合dataQuery规范的入参,但该接口还有一个短板:rpc接口只支持一个config_name, 不支持同时获取多个表。所以前端在请求参数中新增了两个参数作为与API层的约定,在API层通用接口中实现对rpc调用的并发请求与多表连接。前端查询参数新增​extras: Array<DataQueryRequest>​和​leftJoin: number ​字段,API层会对extras中指定的多个DataQueryRequest数据并发请求,并且按照DataQueryRequest中指定的return_fields,以leftJoin个字段组合作为唯一key进行表的连接,然后生成新的数据。比如

extras: [{
    config_name: 'xxx1',
    return_fields: ['date', 'age', 'dau']
}, {
    config_name: 'xxx2',
    return_fields: ['date', 'age', 'vv']
}],
leftJoin: 2

则API层在并发2个rpc请求之后,会按照​leftJoin​指定的2,对​return_fields​中的前2个字段,即'date'和'age'两列,组合键作为唯一key,拼成新的含有​'date', 'age', 'dau', 'vv'​4列的数据。同时为了满足前端个性化的需求,也支持通过参数配置​passThroughData: true​将rpc并发获取的多个结果直接透传回前端(极少场景)。

前端部分的工作就是发起网络请求,并对返回数据格式进行初步转化。这儿会有个问题:多个前端图表可能对应同一张底表的数据,也就是多个前端看板可以共用同一份数据,此时倘若每个图表还都请求一次,无疑造成了资源的浪费。从UI上来讲,比较直观的解决思路有:实现​Panel.Group​,其children消费该Group提供的数据,以此实现共用。但这些对应同一张底表数据的看板在UI上也不总是放在一组的,利用样式去魔改的话,成本高,也不利于维护。所以我们实现了基于window的数据缓存方式,并将其通用化,用到了看板以外的业务中,以实现具有相同参数的请求公用同一份数据。将url, method, request params/body的组合作为请求的唯一key,存储该请求的data到window上。

  • 若该key有值,且在有效期,直接返回数据
  • 有请求正在pending中:return new Promise, 基于发布订阅机制,订阅该key的事件,被通知到数据后resolve(data)
  • 没有pending的请求,发起网络请求去接口获取, 获取后触发name为key的事件,并return data.
  • 内置请求失败refetch 1次逻辑

封装格式化参数

这儿比较关键的是数据的聚合处理函数,我们称之为getPerspectiveData方法。其主要工作是基于“原子化”的数据,可以通过指定xKey, yKey, target指标,就实现数据的聚合。如以年龄+性别交叉维度,查看dau随时间的变化趋势。则对应到formatParams是

xKey: 'date',
yKey: 'dau',
dims: ['age', 'gender']

getPerspectiveData方法需要处理成能容易被echarts接受的数据类型,结合echarts options,其预期返回结果格式为:

{
    xData: ['2019-01-01', '2019-01-02'],
    yData: [{
        name: '男性-青年', data: [123, 131]
    }, {
        name: '男性-中年', data: [213, 231]
    }] // ...省略其余情况
}

原始数据格式为前面提到的Array<Object>类型:

[{
    date: '2019-01-01', age: '中年', gender: '男性', mode: '高端机', dau: 123
}, {
    date: '2019-01-01', age: '中年', gender: '男性', mode: '低端机', dau: 138
}]

处理流程, 主要是两次遍历:

  • 通过一次遍历,得到中间状态 ​{'中年-男性': {'2019-01-01': 321}}​
  • 然后再获取每个维度下的data列表。(此处要注意,以日期为x轴时,需要根据前端查询的日期范围生成日期范围的数组,否则无法保证日期的连续的连续性,同时对接口返回的缺失数据补0)

除了该主流程之外,还需要在遍历中支持注入一些其他工具方法,比如

  • 计算每一列的总数(即图表中展示一条名为“全部”的折线)
  • 支持展示结果为百分比(比如查看男、女占比的DAU折线图)
  • 支持yKey为计算指标(比如 人均vv = vv/dau)
  • 还有支持按照指定的维度输出结果(dims中元素为object类型,指明了该维度的取值,如[{gender: '男性', age: '中年'}, {gender: '女性', age: '青年'})

封装各类型图表适配层

该步骤的目标是:根据上一步得到的数据处理结果,得到echarts对应类型的配置。在formatParams中介绍了可以通过指定type为 'line', 'bar', 'pie', 'stackBar', 'stackLine'或'multiY'等类型来实现echarts的配置,所以在适配层,我们针对每一种类型的图表都有一个对应的处理文件。然后会根据type来调用相应的适配方法,得到最终的echarts配置。这一步文件虽多,逻辑却并不复杂,故不再详细展开。另外,为了支持对图表展示数据的干预能力,在formatParams中都提供了相应的注入方法,如formatX, formatY, formatTooltip, formatAsPercent(比如原本是小数,需要展示成百分比)等。

看板组件的性能优化

看板页面常常会有数十个图表组件,如果在每次筛选条件变更的时候,就将页面中全部图表同时发起网络请求、进行数据处理、渲染图表,不仅仅会给数据库带来压力,也会给前端页面性能带来较大的影响。所以有必要针对图表做“懒更新”:只有可视区内的图表才进行实时的变更。

这儿的“懒更新”跟通常我们提到的“懒加载”略有不同,懒加载只是当元素初次出现在可视区时,才去加载,并且加载之后元素通常不会再改变。而图表在页面的筛选条件修改之后是要跟随变化的(触发请求、数据处理、重新渲染)。所以懒更新要保证只要处于不可见区域,就不触发组件的这一系列流程。

实现思路:因为看板组件的网络请求或者数据只跟reqParams有关,所以内部可以在图表每次展示时记录当前的reqParams,然后在shouldComponentUpdate中只要判断​isVisible && !isEqual(lastShowParams, nextProps.reqParams)​时,才触发更新流程即可。

在查看数据看板时,看完当前这一行,滚动页面,下一行才去发起请求,如果接口再慢点,还需要用户等待几秒才能查看,体验上是不够流畅的。并且在PC上,并不需要着重考虑节省流量,此时流畅的用户体验更重要。所以希望图表组件能够自动收集该页面中的所有数据请求,在页面空闲时,以一定的最高并发量,去发起数据请求,既不影响当前的交互使用,又提前加载了其余看板的数据,一举两得。

让浏览器在空闲时预加载,主要是依赖​requestIdleCallback​这个API。组件内引用一个单例TaskManager, 然后在​getDerivedStateFromProps​中判断:如果需要加载请求,则将请求加入到task队列​taskManager.addTask(yourFetch)​。addTask时会给requestIdleCallback添加了一个回调,当这个回调结束时,又会从task队列中取出任务,放到requestIdleCallback。因为getDerivedStateFromProps在mount或者update时都会触发,所以任务肯定能保证都加到队列中,也就是所谓的收集页面中的网络请求。得益于前面提到的我们在组件内的网络请求实现了基于window的数据缓存方式,任务队列预加载请求时无需什么特殊操作或保护,并且与正常发起的请求或者懒更新也不矛盾,并不会有重复的请求发出。当该组件可见时,如果已经加载完该请求,会直接从本地(window)上读取相应数据,如果正在加载,就会等着之前的请求完成然后resolve,如果还没加载,就会发起新的请求,一切都是自然而然实现的。

除了基本的能力与性能优化,对前端而言看板的核心诉求还是又快又好的支持各种展示。所以除了常见的、通用的图表类型封装,还需要实现足够灵活、便捷的配置。将基础能力夯实之后,实现线上的配置化是进一步解放人效的方式。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK