代码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
的最终效果了。
2. 初始化(branch step0
)
1)项目初始化
Angular
的开发可以参考教程(Tutorial-ES、Tutorial-CN)。
- 首先使用
Angular-Cli
创建项目, 并把material
和animations
的module安装、引用 - 其次创建
table component
和post 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-card
和mat-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
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();
...
此处的效果图如下
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
添加子行数据的行并绑定事件
这里首先将两层数据定义为parentRow
和childRow
,并添加了一个扩展标志位属性expand
然后加了when: isParentRow
和when: 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>
我认为isParentRow
、isChildRow
、updateChildRow
代码调用实现包含了函数式编程
的思想。
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
就有相应高度的黑线。
5.动态更新数据显示子行数据(branch step3
)
对于行间黑线的问题,多次尝试从CSS
角度解决均失败,最后依然是在阅读同事的代码时开窍——应该从数据的动态更新入手,具体参考代码中的updateChildExpand
方法实现即可。
这里列出代码中我感觉好用的两个js
编程技巧:
-
splice
和...
的妙用,怕误导就不细说,自行实践理解更好。this.dataSource.splice(i + 1, 0, ...rows)
-
通过字符串处理实现深拷贝的一种方法
JSON.parse(JSON.stringify(this.parentData))
6. 使用D3动态画图(branch step4
)
将step2
和step3
中不同的展开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
都应该已经有了,这里报错有些头大,因为这是第二次了,第一次确实是页面渲染前画图导致的,修改画图操作在页面渲染后就解决了。
最终经过一番代码比对后发现是需要对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.添加datepicker
(branch step5
)
到了step4
基本就实现预期目标了,不过当前设定的数据全部是2018年的,如果想显示更多年份数据的话,我们可以通过datepicker
来选择时间,可以实现为只选择year
的,同样Angular material
有自己的matDatepicker
,具体用法还是看matDatepicker文档。
这里要强调的依然是一个bug,如图6所示,点击datepicker
后的弹窗位置是不对的。
最终在另一个同事
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
一个grid
和treegrid
让我有了些想法,不过怎么都没找到相应的解释,只能作罢。
9.最后
放上昨天在朋友圈看到的一张图“假如让写编程书的那群人来出数学书”,确实好多编程教程的基本操作就是先写Hello World
,然后接着就是实战
了。