@rdkmaster/jigsaw-labs
Version:
Jigsaw, the next generation component set for RDK
1,201 lines (1,071 loc) • 46.9 kB
text/typescript
import {HttpClient} from "@angular/common/http";
import {Subject} from "rxjs/Subject";
import "rxjs/add/operator/map";
import 'rxjs/add/operator/debounceTime';
import {AbstractGeneralCollection} from "./general-collection";
import {
DataFilterInfo,
DataSortInfo,
HttpClientOptions,
IFilterable,
IPageable,
IServerSidePageable,
ISlicedData,
ISortable,
PagingInfo,
PreparedHttpClientOptions,
SortAs,
SortOrder,
serializeFilterFunction,
ViewportData
} from "./component-data";
import {CommonUtils} from "../utils/common-utils";
/**
* 代表表格数据矩阵`TableDataMatrix`里的一行
*/
export type TableMatrixRow = any[];
/**
* 代表表格的列头描述,其个数需要与表格数据`TableDataMatrix`的列数相等并一一对应
* 这里的数据将会显示在界面上,需要确保对他们进行国际化处理
*/
export type TableDataHeader = string[];
/**
* 代表表格的列头字段,其个数需要与表格数据`TableDataMatrix`的列数相等并一一对应,
* 并且不能重复,建议以数据库表字段对应起来。
* 这些数据对表格识别列至关重要,无效的、重复的值将会被忽略
*/
export type TableDataField = string[];
/**
* 代表表格的数据区,是一个二维矩阵。矩阵的列数需要和`TableDataField`以及`TableDataHeader`的个数一致且一一对应。
*/
export type TableDataMatrix = TableMatrixRow[];
/**
* 原始表格数据结构,Jigsaw的表格组件接收的唯一数据结构。
*/
export class RawTableData {
/**
* 表格数据的字段序列,这个序列决定了`JigsawTable`实际渲染出来哪些列。无效、重复的字段将被抛弃。
*/
field: TableDataField;
/**
* 表格的列头,这里的文本将会直接显示在界面上,请确保他们已经被正确国际化过。
*/
header: TableDataHeader;
/**
* 表格的数据,是一个二维数组。
*/
data: TableDataMatrix;
[property: string]: any;
}
/**
* 表格数据的基类,应用一般无需直接使用这个类。
*
* 关于Jigsaw数据体系详细介绍,请参考`IComponentData`的说明
*/
export class TableDataBase extends AbstractGeneralCollection<any> {
/**
* 给出`data`的数据结构是否和`RawTableData`一致,即`data`是否是一个合法的表格数据。
* 注意此方法并非使用类的血缘关系来判断,而是通过数据结构的特征来判断。
*
* @param data
* @return {boolean}
*/
public static isTableData(data: any): boolean {
return data && data.hasOwnProperty('data') && data.data instanceof Array &&
data.hasOwnProperty('header') && data.header instanceof Array &&
data.hasOwnProperty('field') && data.field instanceof Array;
}
constructor(/**
* 表格的数据,是一个二维数组。
* @type {TableDataMatrix}
*/
public data: TableDataMatrix = [],
/**
* 表格数据的字段序列,这个序列决定了`JigsawTable`实际渲染出来哪些列。无效、重复的字段将被抛弃。
* @type {TableDataField}
*/
public field: TableDataField = [],
/**
* 表格的列头,这里的文本将会直接显示在界面上,请确保他们已经被正确国际化过。
* @type {TableDataHeader}
*/
public header: TableDataHeader = []) {
super();
}
/**
* 参考 `TableData.isTableData`
*
* @param data
* @return {boolean}
*/
protected isDataValid(data): boolean {
return TableDataBase.isTableData(data);
}
protected ajaxSuccessHandler(data): void {
if (this.isDataValid(data)) {
this.fromObject(data);
} else {
console.log('invalid raw TableData received from server...');
this.clear();
this.refresh();
}
this._busy = false;
this.componentDataHelper.invokeAjaxSuccessCallback(data);
}
public fromObject(data: any): TableDataBase {
if (!this.isDataValid(data)) {
throw new Error('invalid raw TableData object!');
}
this.clear();
TableDataBase.arrayAppend(this.data, data.data);
TableDataBase.arrayAppend(this.field, data.field);
TableDataBase.arrayAppend(this.header, data.header);
this.refresh();
return this;
}
protected static arrayAppend(dest: any[], source: any): void {
if (!source) {
return;
}
if (source instanceof Array) {
source.forEach(item => dest.push(item));
} else {
dest.push(source);
}
}
/**
* 参考 `TableData.toArray`
* @returns {any[]}
*/
public toArray(): any[] {
const result: any[] = [];
if (!this.data || !this.field) {
return result;
}
this.data.forEach(row => {
let item = {};
this.field.forEach((field, index) => item[field] = row[index]);
result.push(item);
});
return result;
}
/**
* 清空此对象上的所有数据,避免潜在的内存泄露风险
*/
public clear(): void {
this.data.splice(0, this.data.length);
this.header.splice(0, this.header.length);
this.field.splice(0, this.field.length);
}
public destroy(): void {
super.destroy();
this.clear();
console.log('destroying TableDataBase....');
}
/**
* 在当前表格数据的`column`位置处插入一个新列。常常用于表格数据的`dataReviser`函数内,对服务端返回的数据做调整时用到。
*
* @param {number} column 新列所在的位置
* @param cellData 新列的单元格的值,新插入列的每个单元格的值都相同。
* @param {string} field 新插入列的字段,不允许与已有字段相同。
* @param {string} header 新插入列的列头信息。
*/
public insertColumn(column: number, cellData: any, field: string, header: string): void;
/**
* @param {number} column
* @param {any[]} cellDatas 新列的单元格的值,每个单元格的值与此数组的元素一一对应
* @param {string} field
* @param {string} header
*/
public insertColumn(column: number, cellDatas: any[], field: string, header: string): void;
/**
* @internal
*/
public insertColumn(column: number, data: any | any[], field: string, header: string): void {
column = isNaN(column) ? this.data.length : column;
this.data.forEach((row, index) => row.splice(column, 0, data instanceof Array ? data[index] : data));
this.field.splice(column, 0, field);
this.header.splice(column, 0, header);
}
/**
* 删除当前表格数据`column`位置处的列。常常用于表格数据的`dataReviser`函数内,对服务端返回的数据做调整时用到。
*
* @param {number} column 待删除的列索引
* @returns {TableData} 返回删除后的`TableData`对象,当前对象不变
*/
public removeColumn(column: number): TableData {
if (isNaN(column) || column < 0 || column >= this.field.length) {
return new TableData();
}
const matrix = [];
this.data.forEach(row => matrix.push(row.splice(column, 1)));
const field = this.field.splice(column, 1);
const header = this.header.splice(column, 1);
return new TableData(matrix, field, header);
}
}
/**
* 这是最基础的表格数据对象,只具备最基本的表格数据展示能力,无法分页,
* 一般用于以下类型的表格数据无法满足需求时,实现自定义表格数据的基础数据。
*
* - {@link PageableTableData} 适用于需要在服务端进行分页、过滤、排序的场景,这是最常用的一个数据对象;
* - {@link LocalPageableTableData} 适用于需要在浏览器本地进行分页、过滤、排序的场景,受限于数据量,不是很常用;
* - {@link BigTableData} 适用于海量数据的展示场景,它可以做到在常数时间内展示**任意量级**的数据。
*
* 建议尽可能的挑选以上类型的表格数据,以减少定制化的开发工作量。
*
* 表格数据是Jigsaw数据体系中的一个重要分支,关于Jigsaw数据体系详细介绍,请参考`IComponentData`的说明
*/
export class TableData extends TableDataBase implements ISortable, IFilterable {
/**
* 将`RawTableData`对象转为`TableData`对象。
*
* 注意:源对象的 `data` / `field` / `header` 属性会被**浅拷贝**到目标`TableData`对象中。
*
* @param rawData 结构为 `RawTableData` 的json对象
* @returns {TableData} 返回持有输入数据的`TableData`实例
*/
public static of(rawData: any): TableData {
return TableData.isTableData(rawData) ? new TableData(rawData.data, rawData.field, rawData.header) : new TableData();
}
/**
* 将一个`RawTableData`对象转为一个json对象数组。
*
* 虽然`RawTableData`这样的数据结构有很多好处,比如利于网络传输,利于表格展示,在占用更小的内存等,
* 但是由于这不是一个典型的json对象结构,因此如果需要将它使用到表格以外的场合,则会产生一些麻烦。
* 可以通过这个方法将它转换为一个典型的json对象。
*
* 原始原始数据:
*
* ```
* {
* data: [
* [11, 12, 13],
* [21, 22, 23],
* [31, 32, 33]
* ],
* field: ['field1', 'field2', 'field3'],
* header: ['header1', 'header2', 'header3']
* }
* ```
*
* 将被转换为:
*
* ```
* [
* {field1: 11, field2: 12, field3: 13},
* {field1: 21, field2: 22, field3: 23},
* {field1: 31, field2: 32, field3: 33},
* ]
* ```
*
* @param rawData
* @returns {any[]}
*/
public static toArray(rawData: any): any[] {
return TableData.of(rawData).toArray();
}
public sortInfo: DataSortInfo;
public sort(compareFn?: (a: any[], b: any[]) => number): void;
public sort(as: SortAs, order: SortOrder, field: string | number): void;
public sort(sort: DataSortInfo): void;
/**
* @internal
*/
public sort(as: SortAs | DataSortInfo | Function, order?: SortOrder, field?: string | number): void {
this.sortData(this.data, as, order, field);
}
/**
* 对输入的数据进行排序
*
* @param {TableDataMatrix} data 输入的数据
* @param {SortAs | DataSortInfo | Function} as 排序参数
* @param {SortOrder} order 排序顺序
* @param {string | number} field 排序字段
*/
protected sortData(data: TableDataMatrix, as: SortAs | DataSortInfo | Function, order?: SortOrder, field?: string | number) {
field = typeof field === 'string' ? this.field.indexOf(field) : field;
if (as instanceof Function) {
// cast to any to peace the compiler.
data.sort(<any>as);
} else {
this.sortInfo = as instanceof DataSortInfo ? as : new DataSortInfo(as, order, field);
const orderFlag = this.sortInfo.order == SortOrder.asc ? 1 : -1;
if (this.sortInfo.as == SortAs.number) {
data.sort((a, b) => orderFlag * (Number(a[field]) - Number(b[field])));
} else {
data.sort((a, b) => orderFlag * String(a[field]).localeCompare(String(b[field])));
}
}
this.refresh();
}
public filterInfo: DataFilterInfo;
public filter(compareFn: (value: any, index: number, array: any[]) => any, thisArg?: any): any;
public filter(term: string, fields?: (string | number)[]): void;
public filter(term: DataFilterInfo): void;
/**
* @internal
*/
public filter(term, fields?: (string | number)[]): void {
throw new Error("Method not implemented.");
}
public destroy() {
super.destroy();
this.sortInfo = null;
this.filterInfo = null;
}
}
/**
* 这是实际使用时做常用的表格数据对象,它具备服务端分页、服务端排序、服务端过滤能力。
* 详细用法请参考[这个demo]($demo=table/pageable)。
*
* 注意:需要有一个统一的具备服务端分页、服务端排序、服务端过滤能力的REST服务配合使用,
* 更多信息请参考`PagingInfo.pagingServerUrl`
*
* 相关的表格数据对象:
* - {@link PageableTableData} 适用于需要在服务端进行分页、过滤、排序的场景,这是最常用的一个数据对象;
* - {@link LocalPageableTableData} 适用于需要在浏览器本地进行分页、过滤、排序的场景,受限于数据量,不是很常用;
* - {@link BigTableData} 适用于海量数据的展示场景,它可以做到在常数时间内展示**任意量级**的数据。
*
* 表格数据是Jigsaw数据体系中的一个重要分支,关于Jigsaw数据体系详细介绍,请参考`IComponentData`的说明
*/
export class PageableTableData extends TableData implements IServerSidePageable, IFilterable, ISortable {
/**
* 当前数据对象的查询参数,注意这个参数在进行分页、排序、过滤的时候,都会带给服务端。
* 提示:可将这个对象对应属性通过双向绑定的方式提供给查询条件的视图,这样在视图上数据更新了后,这里的值就可立即得到更新,例如:
*
* ```
* <j-input [(value)]="tableData?.sourceRequestOptions?.params?.value">
* </j-input>
* ```
*
* 或者在需要获取数据之前,一次性通过`updateDataSource`来更新这个对象。
*/
public sourceRequestOptions: HttpClientOptions;
public pagingInfo: PagingInfo = new PagingInfo();
private _filterSubject = new Subject<DataFilterInfo>();
private _sortSubject = new Subject<DataSortInfo>();
constructor(public http: HttpClient, requestOptionsOrUrl: HttpClientOptions | string) {
super();
if (!http) {
throw new Error('invalid http!');
}
this.sourceRequestOptions = typeof requestOptionsOrUrl === 'string' ? {url: requestOptionsOrUrl} : requestOptionsOrUrl;
this._initSubjects();
}
private _initSubjects(): void {
this._filterSubject.debounceTime(300).subscribe(filter => {
this.filterInfo = filter;
this._ajax();
});
this._sortSubject.debounceTime(300).subscribe(sort => {
this.sortInfo = sort;
this._ajax();
});
this.pagingInfo.subscribe(() => {
this._ajax();
})
}
/**
* 在使用此方法之前,请先阅读一下`sourceRequestOptions`的说明,你需要先了解它的作用之后,
* 才能够知道如何恰当的使用这个方法来更新`sourceRequestOptions`。
*
* 这个方法除了更新`sourceRequestOptions`以外,还会自动重置`pagingInfo`的各个参数,
* 以及清空`filterInfo`和`sortInfo`。
*
* @param {HttpClientOptions} options 数据源的结构化信息
*/
public updateDataSource(options: HttpClientOptions): void;
/**
* @param {string} url 包含查询参数的url,只能通过GET访问它。
*/
public updateDataSource(url: string): void;
/**
* @internal
*/
public updateDataSource(optionsOrUrl: HttpClientOptions | string): void {
if (CommonUtils.isUndefined(optionsOrUrl)) {
optionsOrUrl = this.sourceRequestOptions;
}
this.sourceRequestOptions = typeof optionsOrUrl === 'string' ? {url: optionsOrUrl} : optionsOrUrl;
this.pagingInfo.currentPage = 1;
this.pagingInfo.totalRecord = 0;
this.filterInfo = null;
this.sortInfo = null;
}
public fromAjax(url?: string): void;
public fromAjax(options?: HttpClientOptions): void;
/**
* @internal
*/
public fromAjax(optionsOrUrl?: HttpClientOptions | string): void {
if (optionsOrUrl instanceof HttpClientOptions) {
this.updateDataSource(<HttpClientOptions>optionsOrUrl);
} else if (!!optionsOrUrl) {
this.updateDataSource(<string>optionsOrUrl);
}
this._ajax();
}
private _ajax(): void {
if (this._busy) {
this.ajaxErrorHandler(null);
return;
}
const options = HttpClientOptions.prepare(this.sourceRequestOptions);
if (!options) {
console.error('invalid source request options, use updateDataSource() to reset the option.');
return;
}
this._busy = true;
this.ajaxStartHandler();
const method = this.sourceRequestOptions.method ? this.sourceRequestOptions.method.toLowerCase() : 'get';
const paramProperty = method == 'get' ? 'params' : 'body';
let originParams = this.sourceRequestOptions[paramProperty];
delete options.params;
delete options.body;
options[paramProperty] = {
service: options.url, paging: this.pagingInfo.valueOf()
};
if (CommonUtils.isDefined(originParams)) {
options[paramProperty].peerParam = originParams;
}
if (CommonUtils.isDefined(this.filterInfo)) {
options[paramProperty].filter = this.filterInfo;
}
if (CommonUtils.isDefined(this.sortInfo)) {
options[paramProperty].sortInfo = this.sortInfo;
}
if (CommonUtils.isDefined(this.sortInfo)) {
options[paramProperty].sort = this.sortInfo;
}
if (paramProperty == 'params') {
options.params = PreparedHttpClientOptions.prepareParams(options.params)
}
this.http.request(options.method, PagingInfo.pagingServerUrl, options)
.map(res => this.reviseData(res))
.map(data => {
this._updatePagingInfo(data);
const tableData: TableData = new TableData();
if (TableData.isTableData(data)) {
tableData.fromObject(data);
} else {
console.error('invalid data format, need a TableData object.');
}
return tableData;
})
.subscribe(
tableData => this.ajaxSuccessHandler(tableData),
error => this.ajaxErrorHandler(error),
() => this.ajaxCompleteHandler()
);
}
private _updatePagingInfo(data: any): void {
if (!data.hasOwnProperty('paging')) {
return;
}
const paging = data.paging;
this.pagingInfo.totalRecord = paging.hasOwnProperty('totalRecord') ? paging.totalRecord : this.pagingInfo.totalRecord;
}
public filter(compareFn: (value: any, index: number, array: any[]) => any, thisArg?: any): any;
public filter(term: string, fields?: string[] | number[]): void;
public filter(term: DataFilterInfo): void;
/**
* @internal
*/
public filter(term, fields?: string[] | number[]): void {
let pfi: DataFilterInfo;
if (term instanceof DataFilterInfo) {
pfi = term;
} else if (term instanceof Function) {
// 这里的fields相当于thisArg,即函数执行的上下文对象
pfi = new DataFilterInfo(undefined, undefined, serializeFilterFunction(term), fields);
} else {
pfi = new DataFilterInfo(term, fields);
}
this._filterSubject.next(pfi);
}
public sort(compareFn?: (a: any[], b: any[]) => number): void;
public sort(as: SortAs, order: SortOrder, field: string | number): void;
public sort(sort: DataSortInfo): void;
/**
* @internal
*/
public sort(as, order?: SortOrder, field?: string | number): void {
if (as instanceof Function) {
throw new Error('compare function is not supported by PageableTableData which sorts data in the server side');
}
const psi = as instanceof DataSortInfo ? as : new DataSortInfo(as, order, field);
psi.order = SortOrder[psi.order];
this._sortSubject.next(psi);
}
public changePage(currentPage: number, pageSize?: number): void;
public changePage(info: PagingInfo): void;
/**
* @internal
*/
public changePage(currentPage, pageSize?: number): void {
if (!isNaN(pageSize) && +pageSize > 0) {
this.pagingInfo.pageSize = pageSize;
}
let cp: number = 0;
if (currentPage instanceof PagingInfo) {
this.pagingInfo.pageSize = currentPage.pageSize;
cp = currentPage.currentPage;
} else if (!isNaN(+currentPage)) {
cp = +currentPage;
}
if (cp >= 1 && cp <= this.pagingInfo.totalPage) {
this.pagingInfo.currentPage = cp;
} else {
console.error(`invalid currentPage[${cp}], it should be between in [1, ${this.pagingInfo.totalPage}]`);
}
}
public firstPage(): void {
this.changePage(1);
}
public previousPage(): void {
this.changePage(this.pagingInfo.currentPage - 1);
}
public nextPage(): void {
this.changePage(this.pagingInfo.currentPage + 1);
}
public lastPage(): void {
this.changePage(this.pagingInfo.totalPage);
}
public destroy(): void {
super.destroy();
this.http = null;
this.sourceRequestOptions = null;
this.pagingInfo.unsubscribe();
this.pagingInfo = null;
this._filterSubject.unsubscribe();
this._filterSubject = null;
this._sortSubject.unsubscribe();
this._sortSubject = null;
}
}
export class TableViewportData extends ViewportData {
public maxWidth: number = 0;
public maxHeight: number = 0;
constructor(private _bigTableData: BigTableData) {
super();
}
private _rows = 25;
public set rows(value: number) {
if (value <= 0 || this._rows == value) {
return;
}
this._rows = value;
if (this.maxHeight > 0 && this._verticalTo + value > this.maxHeight) {
this._verticalTo = this.maxHeight - value;
this._verticalTo = this._verticalTo >= 0 ? this._verticalTo : 0;
}
this._sliceData();
}
public get rows(): number {
return this._rows;
}
private _columns = 15;
public set columns(value: number) {
if (value <= 0 || this._columns == value) {
return;
}
this._columns = value;
if (this.maxWidth > 0 && this._horizontalTo + value > this.maxWidth) {
this._horizontalTo = this.maxWidth - value;
this._horizontalTo = this._horizontalTo >= 0 ? this._horizontalTo : 0;
}
this._sliceData();
}
public get columns(): number {
return this._columns;
}
private _verticalTo = 0;
public set verticalTo(value: number) {
value = value < 0 ? 0 : value;
if (this._verticalTo == value) {
return;
}
// `vScroll` will update `_fromRow` to a proper value
this._bigTableData.vScroll(value);
}
public get verticalTo(): number {
return this._verticalTo;
}
public setVerticalPositionSilently(value: number) {
this._verticalTo = value < 0 ? this._verticalTo : value;
}
private _horizontalTo = 0;
public set horizontalTo(value: number) {
value = value < 0 ? 0 : value;
if (this._horizontalTo == value) {
return;
}
// `hScroll` will update `_horizontalTo` to a proper value
this._bigTableData.hScroll(value);
}
public get horizontalTo(): number {
return this._horizontalTo;
}
public setHorizontalPositionSilently(value: number) {
this._horizontalTo = value < 0 ? this._horizontalTo : value;
}
private _sliceData() {
// ts 没有 friend 关键字,只好出此下策了
this._bigTableData['sliceData']();
}
public set width(value: number) {
this.columns = value;
}
public get width(): number {
return this.columns;
}
public set height(value: number) {
this.rows = value;
}
public get height(): number {
return this.rows;
}
}
/**
* `BigTableData`是Jigsaw的表格呈现海量数据时的一个解决方案,**它能够以常数时间处理任何量级的数据**。
*
* #### 适用的场景
*
* 这个方法目前适用于海量对静态数据做展示的场景,暂时不支持对海量数据展示的同时提供**有状态**的交互能力,
* 即`BigTableData`暂不支持与可编辑的渲染器(如`JigsawInput`/`JigsawCheckbox`/`JigsawSwitch`等)一起使用,
* 如果你有这样的需求,那请给我们[提Issue](https://github.com/rdkmaster/jigsaw/issues/new),我会考虑支持。
*
* 此外,这个解决方案也充分考虑到了用户在IE11等低性能浏览器上浏览海量数据的体验,针对性的做了优化,
* 你可以使用IE11打开这个demo看看它在低性能浏览器上的表现。
*
* #### 原理
*
* 原理非常简单,我们使用`BigTableData`这个数据对象将数据做切片处理后传递给表格呈现出来,
* 表格控件无需处理所有数据,它始终只需要处理当前用户可视部分的数据,用户不可视部分的数据被忽略,
* 这也就是`BigTableData`可以在常数时间处理任意量级的数据的原因了。
* `BigTableData`充分体现了表格彻底由数据驱动的优势。
*
* #### 无分页浏览数据
*
* 甚至,`BigTableData`还能够消除数据分页给浏览器数据带来的不便之处,进一步提升浏览数据的体验。
*
* 我们都知道,海量的数据是不可能一下子全部从服务端读取到客户端里的,传统的解决方案是对数据做分页处理,
* 页面上分批下载数据,用户分批查看数据,用户不得不等待两页数据切换带来的时延,这打断了用户浏览数据的过程,体验很差。
*
* `BigTableData`在第一页数据下载完毕之后,在两三百ms之内就能够将数据呈现出来,用户开始浏览数据,
* 随着用户将滚动条下移到接近本页数据尾部的时候,`BigTableData`自动在后台发起加载下一页数据的请求,
* 当用户浏览完毕当前页数据的时候,`BigTableData`早就将下一页数据准备好了。
* 这样,用户浏览数据的过程没有因为加载数据而中断。
*
* 考虑到内存的消耗,`BigTableData`默认只缓存3页数据:
*
* - 前一页;
* - 当前页;
* - 下一页;
*
* 超过部分将会从内存中清理掉,从而避免浏览器占用过高的内存导致用户电脑卡顿。缓存的页数越多体验越好,
* 你可以根据实际情况调整`BigTableData`的`numCachedPages`属性来调整缓存的页数,设置为`0`则缓存所有。
* `BigTableData`至少缓存3页数据。
*
* #### 不适用的场景
*
* 正如前文所说,`BigTableData`目前暂时不适用于展示有状态的交互需求的场景,例如使用有编辑功能的渲染器就是典型的有状态的交互场景。
* 如果你有这样的需求,那请给我们[提Issue](https://github.com/rdkmaster/jigsaw/issues/new),
* 将你碰到的场景和需求详细描述给我们。
*
* #### 注意
*
* `BigTableData`需要有一个统一的具备服务端分页、服务端排序、服务端过滤能力的REST服务配合使用,
* 更多信息请参考`PagingInfo.pagingServerUrl`。
*
* 如果你的服务端无法给提供一个统一的分页服务,
* 则可以通过[Angular的拦截器](https://angular.cn/guide/http#intercepting-requests-and-responses)来模拟。
* `BigTableData`在需要获取下一页数据时,会将请求做一层包装后发给统一分页服务,实际的数据请求是在统一分页服务里完成的。
* 你需要做的事情是实现一个拦截器,将`BigTableData`发给统一分页服务的请求拦截下来,解析被拦截的请求里的实际请求参数,
* 并将这些请求转发给实际提供数据的服务。
*
* 相关的表格数据对象:
* - {@link PageableTableData} 适用于需要在服务端进行分页、过滤、排序的场景,这是最常用的一个数据对象;
* - {@link LocalPageableTableData} 适用于需要在浏览器本地进行分页、过滤、排序的场景,受限于数据量,不是很常用;
* - {@link BigTableData} 适用于海量数据的展示场景,它可以做到在常数时间内展示**任意量级**的数据。
*
* 表格数据是Jigsaw数据体系中的一个重要分支,关于Jigsaw数据体系详细介绍,请参考`IComponentData`的说明
*/
export class BigTableData extends PageableTableData implements ISlicedData {
public readonly viewport: TableViewportData = new TableViewportData(this);
/**
* 缓存数据页数,这里的页指的是服务端单次返回的数据集,和传统服务端分页数据的概念是相同的。
*
* 这个数值越大,表格数据浏览的体验更好,更流畅,但是需要占用的浏览器内存越多;
* 相反的,给的数值越小,表格找服务端请求数据的机会越多,数据浏览体验下降,但是浏览器所需内存越小。
* 需要根据服务端性能以及单页数据量而定。最小值为3页。
*
* @type {number}
*/
public numCachedPages = 3;
/**
* 当预加载的一页数据剩下未展示出来的记录数小于这个比例时,{@link BigTableData}会在后台悄悄发起下一页数据的加载,
* 以确保用户将这一页数据浏览完时,可以在不被打断的前提下继续浏览下一页数据。这个参数的有效取值范围是0.01 ~ 0.99。
*
* @type {number}
*/
public fetchDataThreshold = 0.5;
/**
* 和`busy`具有相同含义
*
* @type {boolean}
*/
protected reallyBusy = false;
constructor(public http: HttpClient, requestOptionsOrUrl: HttpClientOptions | string) {
super(http, requestOptionsOrUrl);
this.pagingInfo.pageSize = 1000;
}
private _cache: RawTableData = {field: [], header: [], data: [], startPage: 1, endPage: 1};
/**
* 当前缓存的数据
*
* @returns {RawTableData}
*/
get cache(): RawTableData {
return this._cache;
}
private _isCacheAvailable(): boolean {
return this._cache && !!this._cache.field.length && !!this._cache.header.length && !!this._cache.data.length;
}
/**
* 根据界面上滚动条滑动对缓冲的数据进行切片
*/
protected sliceData(): void {
if (!this._isCacheAvailable()) {
return;
}
const toColumn = this.viewport.columns + this.viewport.horizontalTo;
this.field = this._cache.field.slice(this.viewport.horizontalTo, toColumn);
this.header = this._cache.header.slice(this.viewport.horizontalTo, toColumn);
const toRow = this.viewport.rows + this.viewport.verticalTo;
const data = this._cache.data.slice(this.viewport.verticalTo, toRow);
this.data = data.map(item => item.slice(this.viewport.horizontalTo, toColumn));
this.refresh();
}
public scroll(verticalTo: number, horizontalTo: number = NaN): void {
if (!this._isCacheAvailable()) {
return;
}
//从html模板中传过来的,仍然有可能是一个字符串,这也算是ts的一个坑了
verticalTo = parseInt(verticalTo + "");
verticalTo = isNaN(verticalTo) ? this.viewport.verticalTo : verticalTo;
this.checkCache(verticalTo);
verticalTo = verticalTo + this.viewport.rows > this._cache.data.length ?
this._cache.data.length - this.viewport.rows : verticalTo;
horizontalTo = parseInt(horizontalTo + "");
horizontalTo = isNaN(horizontalTo) ? this.viewport.horizontalTo : horizontalTo;
horizontalTo = horizontalTo + this.viewport.columns > this._cache.field.length ?
this._cache.field.length - this.viewport.columns : horizontalTo;
if (verticalTo != this.viewport.verticalTo || horizontalTo != this.viewport.horizontalTo) {
this.viewport.setVerticalPositionSilently(verticalTo);
this.viewport.setHorizontalPositionSilently(horizontalTo);
this.sliceData();
}
}
public vScroll(scrollTo: number): void {
this.scroll(scrollTo);
}
public hScroll(scrollTo: number): void {
this.scroll(this.viewport.verticalTo, scrollTo);
}
/**
* 检查缓冲区里的数据是否足够用,如果够用了,则会触发获取数据流程
*
* @param {number} verticalTo
*/
protected checkCache(verticalTo: number): void {
const pages = this._cache.endPage - this._cache.startPage + 1;
const threshold = this.fetchDataThreshold > 0 && this.fetchDataThreshold < 1 ? this.fetchDataThreshold : .5;
if (verticalTo < this.pagingInfo.pageSize * threshold) {
// fetch last page...
this.fetchData(this._cache.startPage - 1, verticalTo);
} else if (verticalTo > pages * this.pagingInfo.pageSize - this.pagingInfo.pageSize * threshold) {
// fetch next page...
this.fetchData(this._cache.endPage + 1, verticalTo);
} else {
// do not need to fetch any data.
}
}
/**
* `changePage`改用debounce之后,由于有debounce,`_busy`的值就不准了,只能自己维护这个状态
*
* @type {boolean}
* @private
*/
private _fetchingData: boolean = false;
/**
* 向服务端发起获取数据的请求
*
* @param targetPage
* @param verticalTo
*/
protected fetchData(targetPage, verticalTo): void {
if (targetPage < 1 || targetPage > this.pagingInfo.totalPage) {
return;
}
if (!this._fetchingData) {
this._fetchingData = true;
super.changePage(targetPage);
return;
}
console.log('BigTableData has already being fetching data, waiting for response...');
if (this.reallyBusy) {
return;
}
// it is really busy if the request page is out of the cached page range.
const startIndex = (this._cache.startPage - 1) * this.pagingInfo.pageSize;
const endIndex = this._cache.endPage * this.pagingInfo.pageSize;
this.reallyBusy = verticalTo <= 0 || verticalTo + startIndex > endIndex;
if (this.reallyBusy) {
console.error('it is really busy now, please wait for a moment...');
}
}
/**
* 更新缓冲区
*/
protected updateCache(): void {
this._cache.field = this.field;
this._cache.header = this.header;
if (this.pagingInfo.currentPage >= this._cache.endPage) {
this._cache.data = this._cache.data.concat(this.data);
this._cache.endPage = this.pagingInfo.currentPage;
} else if (this.pagingInfo.currentPage <= this._cache.startPage) {
this._cache.data = this.data.concat(this._cache.data);
this._cache.startPage = this.pagingInfo.currentPage;
} else {
this._printPageError();
return;
}
let numCachedPages;
if (this.numCachedPages <= 0 || isNaN(this.numCachedPages)) {
numCachedPages = Infinity;
} else {
numCachedPages = this.numCachedPages >= 3 ? this.numCachedPages : 3;
}
const pages = this._cache.endPage - this._cache.startPage + 1;
if (pages > numCachedPages) {
// the cached data exceeded the configured reserved data, need to clear.
// because we don't know the scroll direction, we need to calculate the `verticalTo` value
// to find out which one is closer to the `startPage` or the `endPage`,
// and truncate from the further point.
const deltaStart = this.viewport.verticalTo;
const deltaEnd = pages * this.pagingInfo.pageSize - this.viewport.verticalTo;
if (deltaStart > deltaEnd) {
this._cache.startPage++;
console.log('truncating data from top');
this.viewport.setVerticalPositionSilently(this.viewport.verticalTo - this.pagingInfo.pageSize);
this._cache.data.splice(0, this.pagingInfo.pageSize);
} else {
this._cache.endPage--;
this.viewport.setVerticalPositionSilently(this.viewport.verticalTo + this.pagingInfo.pageSize);
console.log('truncating data from bottom');
this._cache.data.splice(this.pagingInfo.pageSize * numCachedPages, this.pagingInfo.pageSize);
}
}
this.viewport.setVerticalPositionSilently(this.viewport.verticalTo);
this._updateViewPortSize();
this.sliceData();
}
private _printPageError(): void {
console.error(`unknown error, currentPage=${this.pagingInfo.currentPage}, startPage=${this._cache.startPage}, endPage=${this._cache.endPage}`);
throw new Error('_printPageError')
}
protected ajaxSuccessHandler(rawTableData): void {
super.ajaxSuccessHandler(rawTableData);
this.reallyBusy = false;
this._fetchingData = false;
this.updateCache();
console.log(`data fetched, startPage=${this._cache.startPage}, endPage=${this._cache.endPage}`);
}
protected ajaxErrorHandler(error): void {
super.ajaxErrorHandler(error);
this.reallyBusy = false;
this._fetchingData = false;
this._cache = {field: [], header: [], data: [], startPage: 1, endPage: 1};
this._updateViewPortSize();
}
private _updateViewPortSize(): void {
this.viewport.maxWidth = this._cache.field.length;
this.viewport.maxHeight = this._cache.data.length;
}
/**
* 这个属性为true时,表示{@link BigTableData}的预加载数据已经被浏览完,并且下一页数据还未取到。否则此值为false。
* 即只有在{@link BigTableData}真的很忙的时候,此属性才是true。
*
* @returns {boolean}
*/
public get busy(): boolean {
return this.reallyBusy;
}
/**
* @internal
*/
public changePage(currentPage: number, pageSize?: number): void;
/**
* @internal
*/
public changePage(info: PagingInfo): void;
/**
* @internal
*/
public changePage(currentPage, pageSize?: number): void {
throw new Error('BigTableData do not support changePage action.');
}
}
/**
* `LocalPageableTableData`具备浏览器本地内存中进行分页、排序、过滤能力,
* 受限于浏览器内存的限制,无法操作大量的数据,建议尽量采用`PageableTableData`以服务端分页的形式展示数据。
* 详细用法请参考[这个demo]($demo=table/local-paging-data)。
*
* 相关的表格数据对象:
* - {@link PageableTableData} 适用于需要在服务端进行分页、过滤、排序的场景,这是最常用的一个数据对象;
* - {@link LocalPageableTableData} 适用于需要在浏览器本地进行分页、过滤、排序的场景,受限于数据量,不是很常用;
* - {@link BigTableData} 适用于海量数据的展示场景,它可以做到在常数时间内展示**任意量级**的数据。
*
* 表格数据是Jigsaw数据体系中的一个重要分支,关于Jigsaw数据体系详细介绍,请参考`IComponentData`的说明
*/
export class LocalPageableTableData extends TableData implements IPageable, IFilterable, ISortable {
public pagingInfo: PagingInfo;
/**
* 原始数据经过过滤后的数据,请勿直接操作这些数据,而是采用本类定义的各个api来操作他们。
*/
public filteredData: TableDataMatrix;
/**
* 原始数据,请勿直接操作这些数据,而是采用本类定义的各个api来操作他们。
*/
public originalData: TableDataMatrix;
constructor() {
super();
this.pagingInfo = new PagingInfo();
this.pagingInfo.subscribe(() => {
if (!this.filteredData) {
return;
}
this._setDataByPageInfo();
this.refresh();
})
}
public fromObject(data: any): LocalPageableTableData {
super.fromObject(data);
this.originalData = this.data.concat();
this.filteredData = this.originalData;
this.data.length = 0; // 初始化时清空data,防止过大的data加载或屏闪
this.firstPage();
return this;
}
public filter(callbackfn: (value: any, index: number, array: any[]) => any, thisArg?: any): any;
public filter(term: string, fields?: string[] | number[]): void;
public filter(term: DataFilterInfo): void;
/**
* @internal
*/
public filter(term, fields?: (string | number)[]): void {
if (term instanceof Function) {
this.filteredData = this.originalData.filter(term);
} else {
let key: string;
if (term instanceof DataFilterInfo) {
key = term.key;
fields = term.field
} else {
key = term;
}
if (fields && fields.length != 0) {
let numberFields: number[];
if (typeof fields[0] === 'string') {
numberFields = [];
(<string[]>fields).forEach(field => {
numberFields.push(this.field.findIndex(item => item == field))
}
)
} else {
numberFields = <number[]>fields;
}
this.filteredData = this.originalData.filter(
row => row.filter(
(item, index) => numberFields.find(num => num == index)
).filter(
item => (item + '').indexOf(key) != -1
).length != 0
);
} else {
this.filteredData = this.originalData.filter(
row => row.filter(
item => (<string>item).indexOf(key) != -1
).length != 0
);
}
}
this.firstPage();
}
public sort(compareFn?: (a: any, b: any) => number): any;
public sort(as: SortAs, order: SortOrder, field: string | number): void;
public sort(sort: DataSortInfo): void;
/**
* @internal
*/
public sort(as, order?: SortOrder, field?: string | number): void {
super.sortData(this.filteredData, as, order, field);
this.changePage(this.pagingInfo.currentPage, undefined);
}
private _updatePagingInfo() {
this.pagingInfo.totalRecord = this.filteredData.length;
}
public changePage(currentPage: number, pageSize?: number): void;
public changePage(info: PagingInfo): void;
/**
* @internal
*/
public changePage(currentPage, pageSize?: number): void {
if (!this.filteredData) {
return;
}
this._updatePagingInfo();
if (!isNaN(pageSize) && +pageSize > 0) {
this.pagingInfo.pageSize = pageSize;
}
let cp: number = 0;
if (currentPage instanceof PagingInfo) {
this.pagingInfo.pageSize = currentPage.pageSize;
cp = currentPage.currentPage;
} else if (!isNaN(+currentPage)) {
cp = +currentPage;
}
if (cp >= 1 && cp <= this.pagingInfo.totalPage) {
this.pagingInfo.currentPage = cp;
} else {
console.error(`invalid currentPage[${cp}], it should be between in [1, ${this.pagingInfo.totalPage}]`);
}
}
private _setDataByPageInfo() {
const begin = (this.pagingInfo.currentPage - 1) * this.pagingInfo.pageSize;
const end = this.pagingInfo.currentPage * this.pagingInfo.pageSize < this.pagingInfo.totalRecord ? this.pagingInfo.currentPage * this.pagingInfo.pageSize : this.pagingInfo.totalRecord;
this.data = this.filteredData.slice(begin, end);
}
public firstPage(): void {
this.changePage(1);
}
public previousPage(): void {
this.changePage(this.pagingInfo.currentPage - 1);
}
public nextPage(): void {
this.changePage(this.pagingInfo.currentPage + 1);
}
public lastPage(): void {
this.changePage(this.pagingInfo.totalPage);
}
public destroy(): void {
super.destroy();
this.pagingInfo.unsubscribe();
this.pagingInfo = null;
this.sortInfo = null;
this.filteredData = null;
this.originalData = null;
}
}