(原创)mat-table-with-d3的示例以及code历程

代码repo地址:https://github.com/kxc1573/mat-table-with-d3-sample

foreword

Recently, I got my first formal frontend task to implement a tree-table by mat-table and to draw chart of detail row by d3.
The technologies involved included Angular, Material table, animation and d3, they were all new to me.
I quickly picked up these skills with examples had been implemented by my colleagues.
But I still had doubts about some details, so I implemented a new demo from zero.
The code is mat-table-with-d3-sample, and I write this document to record the implementation process.

1. 需求设定

假设我们需要实现一个这样的表:

  • 一共有3层结构
  • 第1层和第2层每行都展示一些数值,点击第1层可以控制第2层数据的展开和伸缩
  • 第3层是一个图表,点击第2层来进行相应的展开和伸缩
    如下面图1所示,当然这也是这个demo的最终效果了。
    图1

2. 初始化(branch step0)

1)项目初始化

Angular的开发可以参考教程(Tutorial-ESTutorial-CN)。

  • 首先使用Angular-Cli创建项目, 并把materialanimations 的module安装、引用
  • 其次创建table componentpost service
  • 再就是定义好用来示例的数据,具体见/assets/data.json

2)读取数据

post.service中实现数据读取操作,代码如下:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'

@Injectable({
  providedIn: 'root'
})
export class PostService {

  constructor(private http: HttpClient) { }

  readData() {
    return this.http.get('assets/data.json')
  }
}

Key Point:

  • this.http.get(path) 是异步操作,不论path是本地的data file path还是data api url,其实它的内部是封装了Promise
  • 所以它的调用需要使用subscribe,如下为table.component.ts的调用代码
post.readData()
    .subscribe( res => {
        console.log(res);
    })

3)页面初始化

使用mat-cardmat-grid-list进行页面布局,具体参考mat-card文档mat-grid-list文档
mat-grid-list的使用如下:

<mat-grid-list cols="3" rowHeight="40px">
    <mat-grid-tile [colspan]="2" [rowspan]="1">
    </mat-grid-tile>
    <mat-grid-tile [colspan]="1" [rowspan]="1">
    </mat-grid-tile>
<mat-grid-list>

Key Point:

  • mat-grid-list通过属性cols来设置列数,通过属性rowHeight来设置行高,至于行数则是根据mat-grid-tile的列数和数量自适应调整。
  • mat-grid-tile通过[colspan][rowspan]两个特性来定义列数和行数。

此处效果如图2


图2

3. mat-table简单实现(branch step1)

1) 表格实现

上一步中已经完成了数据的提供和页面的布局,剩下的就是实现表格展示数据了。
一般的table是逐行实现的,下面是w3school的一个示例:

<table border="1">
  <tr>
    <th>Month</th>
    <th>Savings</th>
  </tr>
  <tr>
    <td>January</td>
    <td>$100</td>
  </tr>
</table>

mat-table的实现思路则不一样:它是逐列来定义的,包括表头和单元格数据;然后再按行提供数据来完成表格的渲染。
下面是demo的表格代码,具体参考mat-table文档

<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">

  <!-- Name Column -->
  <ng-container matColumnDef="name">
    <th mat-header-cell class="cell_name" *matHeaderCellDef>Name</th>
    <td mat-cell *matCellDef="let element">{{element.name}}</td>
  </ng-container>

  <!-- Score Columns -->
  <ng-container matColumnDef="{{column}}" *ngFor="let column of dataHeader">
    <th mat-header-cell class="cell_content" *matHeaderCellDef>{{column}}</th>
    <td mat-cell *matCellDef="let element"> {{element[column]}}</td>
  </ng-container>

  <tr mat-header-row *matHeaderRowDef="headers"></tr>
  <tr mat-row *matRowDef="let element; columns: headers;"
      class="example-element-row">
  </tr>
</table>

Key Point:

  • 开头<table mat-table>申明这不是常规的table
  • 通过dataSource来提供数据, 传入数组/列表是最简单的数据格式.
  • ng-container matColumnDef 定义列的模板, th mat-header-cell 定义了这一列中的表头, td mat-cell定义了这一列的单元格内容.
  • tr 定义了行的模板, tr mat-header-row提供表头这一行要展示的数据,tr mat-row提供数据行要展示的内容.

2)数据处理

为了能在表格中展示,当然需要将读取到的数据进行相应的修改,如何修改就不细说了。上文提到过数据读取是异步操作,这就意味着在数据返回之前页面就已经完成渲染了,拿到数据之后需要刷新页面才能将数据显示出来。而通过dataSource来提供数据是不会主动检查数据的更新的,原文是这么说的:

 If you are providing a data array directly to the table, don't forget to call renderRows() on the table, since it will not automatically check the array for changes.

但是具体如何调用,却没有说明,也是困扰了我许久,幸得同事ZhenYi指点迷津,代码如下:

import { ViewChild } from '@angular/core';

...

  @ViewChild(MatTable) table: MatTable<any>;

  ...

  this.table.renderRows();

...

此处的效果图如下


图3

4.嵌入动画事件显示子行数据(branch step2

经过上一步,基本的表格已经实现了,但只有一层数据,这一步要实现的效果就是点击后展开第二层数据。
由于之前的demo中有用到angular/animations来实现点击展开事件,所以我也依葫芦画瓢地用了一番,然后由于当时对mat-table的理解不够,第二层数据是通过原生的table嵌入在动画事件中来实现的。
angular/animations也是一个巨坑,animation文档我也没看,不过读了这篇angular-animations 动画 BrowserAnimationsModule 详解,这里就不详细说了。

这一部分的代码改动主要就是三部分:

1)在table.component.ts中定义触发器

import { animate, state, style, transition, trigger } from '@angular/animations';

...

  animations: [
    trigger('detailExpand', [
      state('collapsed', style({height: '0px', minHeight: '0', visibility: 'hidden'})),
      state('expanded', style({height: '*'})),
      transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')),
    ]),
  ]

2)在table.componenet.html添加子行数据的行并绑定事件

这里首先将两层数据定义为parentRowchildRow,并添加了一个扩展标志位属性expand
然后加了when: isParentRowwhen: isChildRow作为过滤条件
其次增加了是否expandedElement的判断
还添加了点击事件Click,其中包含了当前行expand取反和数据更新updateChildRow操作
最后就是添加了childRow专属的行,所提供的数据同parentRow是不一样的,因为内部的实现也是不一致的。

  <tr mat-row *matRowDef="let element; let i = dataIndex; columns: headers; when: isParentRow"
      class="example-element-row"
      [class.example-expanded-row]="expandedElement === element"
      (click)="element['expand'] = !element['expand']; updateChildRow(i, element)">
  </tr>
  <tr mat-row *matRowDef="let element; columns: ['expandedDetail'];  when: isChildRow"
      class="example-detail-row"></tr>

我认为isParentRowisChildRowupdateChildRow代码调用实现包含了函数式编程的思想。

3)用原生table在展开行中嵌入实现子行数据

这里关注点应该就是虽然同样为ng-container,但expandedDetail的格式是自成一格的,不只是定义一列,而是定义所有通过动画展开的行的全部格式,这与上面代码中columns: ['expandedDetail']是对应的。

  <!-- Expanded Content Column - The detail row is made up of this one column that spans across all columns -->
  <ng-container matColumnDef="expandedDetail">
    <td mat-cell *matCellDef="let element" [attr.colspan]="headers.length"
        class="zero2three">
      <div class="example-element-detail"
           [@detailExpand]="element['expand'] ? 'expanded' : 'collapsed'">
        <table>
          <tbody>
            <tr style="background-color: white; text-align: center">
              <td style="width:40px"></td>
              <td style="width:180px">{{element.name}}</td>
              <td style="width:58px" *ngFor="let column of dataHeader">{{element[column]}}</td>
            </tr>
          </tbody>
        </table>
      </div>
    </td>
  </ng-container> 

4)Bug:行间存有黑线

如图4,如果parentRow未展开,那么行间就存在黑线,这是因为虽然childRow通过animation定义的高度为0px,但在html也是占了一行的,且行高1px,因而隐藏了几条childRow就有相应高度的黑线。

图4

5.动态更新数据显示子行数据(branch step3

对于行间黑线的问题,多次尝试从CSS角度解决均失败,最后依然是在阅读同事的代码时开窍——应该从数据的动态更新入手,具体参考代码中的updateChildExpand方法实现即可。

这里列出代码中我感觉好用的两个js编程技巧:

  • splice...的妙用,怕误导就不细说,自行实践理解更好。

    this.dataSource.splice(i + 1, 0, ...rows)

  • 通过字符串处理实现深拷贝的一种方法

    JSON.parse(JSON.stringify(this.parentData))

6. 使用D3动态画图(branch step4

step2step3中不同的展开childRow方法结合起来,就得到了我们实现tree-table-with-chart的思路:
点击parentRow更新dataSource展示childRow,点击childRow通过animation渲染D3动态绘制的detail chart
为了保证每次渲染都是正确的,dataSource都会由updateChildExpand(i, element)updateDetailGraph(element)两个方法进行更新,内部逻辑不复杂也不简单,此处不细说。

1)D3画图

D3的全称是Data-Driven Documents,用来画矢量图的,是一个贼拉牛逼的前端神器,具体的看D3官网吧,这里大致说一下我理解的画图实现。
首先当然是安装d3库,简单的npm install d3即可。
基本实现过程为

  • 根据selectorId找到对应的selector,添加svg,然后在svg上一顿操作
  • 具体操作对于这个demo里绘制的图表而言,就是
    先定义x轴、定义y轴
    再格式化处理数据
    然后根据数据画线,再根据线和数据渲染区域背景

具体代码查看createChart方法的实现吧,看懂了就可以自行裁剪,看不懂去啃官方文档则更好了。

2)为每个childRow匹配一个detail chart

上面画图过程中说到,第一步就是要根据selectorId来新建一个svg,每一个childRow都要有自己的detail chart,那么就需要相应独立的selectorId。这里采用的方法是在数据处理时为每行数据加一个position属性作为行标,这样在html中通过如下代码即实现了自适应生成slectorId的功能了。

      <div class="example-element-detail"
           [@detailExpand]="element['expand'] ? 'expanded' : 'collapsed'">
        <div class='gia-chart-wrapper' style="width: 720px; height: 360px; float: left;">
          <div class="gia-chart-{{element['position']}}"></div>
        </div>
      </div>

Key Point:

  • 1)bug: 想象中的gia-chart-wrapper并没有出现
    在代码实现过程却并不如设想中的顺利,出现了图5中的问题,点击childRow时报错:TypeError: Cannot read property 'clientWidth' of null,对应的代码是var widther = d3.select(selectorId).node().clientWidth;
    按说拿到数据重新渲染页面后每一个childRow对应的gia-chart-wapper div都应该已经有了,这里报错有些头大,因为这是第二次了,第一次确实是页面渲染前画图导致的,修改画图操作在页面渲染后就解决了。
    图5

    最终经过一番代码比对后发现是需要对mat-table添加一个multiTemplateDataRows属性,代码如下,这个的具体作用我还没去查过,有待学习。
<table mat-table [dataSource]="dataSource" multiTemplateDataRows class="mat-elevation-z8">
...
</table>
  • 2)代码<tr mat-row *matRowDef="let element; columns: ['expandedDetail']; when: isChildRow" class="example-detail-row">...<tr>isChildRow的判断不可少,否则点击parentRow的时候也会画一个图表。
  • 3)对进行画图的数据要进行deep copy处理,否则第二次使用时会报错,这说明内部数据传递是基于引用或者内存地址的。

7.添加datepickerbranch step5

到了step4基本就实现预期目标了,不过当前设定的数据全部是2018年的,如果想显示更多年份数据的话,我们可以通过datepicker来选择时间,可以实现为只选择year的,同样Angular material有自己的matDatepicker,具体用法还是看matDatepicker文档
这里要强调的依然是一个bug,如图6所示,点击datepicker后的弹窗位置是不对的。

图6

最终在另一个同事HongYi的调研下找到了该issue的讨论和解答:angular-material-datepicker-popup-position

8.改进方向?

  • 利用现成的treetable组件进行扩展?
    pick up过程中,看到有一个名为ng-material-treetable的开源组件,github地址:https://www.npmjs.com/package/ng-material-treetable,只是阅后感觉对数据格式要求有些严格,就放弃了。
    不知道是不是自己理解不够,如果理解深一些是否可以轻松将数据处理成所要求的样子,这样就可以简单套用已有的组件了。

  • 在文档中有这么一句话

able's default role is grid, and it can be changed to treegrid through role attribute.
表格的默认角色是 grid,可以通过 role 属性来把它改为 treegrid

一个gridtreegrid让我有了些想法,不过怎么都没找到相应的解释,只能作罢。

9.最后

放上昨天在朋友圈看到的一张图“假如让写编程书的那群人来出数学书”,确实好多编程教程的基本操作就是先写Hello World,然后接着就是实战了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,290评论 6 491
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,107评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,872评论 0 347
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,415评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,453评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,784评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,927评论 3 406
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,691评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,137评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,472评论 2 326
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,622评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,289评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,887评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,741评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,977评论 1 265
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,316评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,490评论 2 348