用 TypeScript 写一个轻量级的 UI 框架之十二:Grid 表格组件(上)

Grid 简介

Grid 其实就是表格 Table,不过窃以为英文中 Grid 比 Table 来得高大上,所以大家都喜欢用 Grid。最开始认识 Grid 控件是 ExtJS UI 库,它有很成熟的设计模型支持着,缺点就是性能不太好,使用比较繁琐。如今笔者也想学着来设计一个 Grid,发现也没想象中的困难,当然了,主要还是依靠发挥来自 Vue.js 的威力。

一图胜千言,不妨先看看 Grid 界面图。

在这里插入图片描述我们期望这个 Grid 是可以直接在表格上面进行编辑的,——那样的好处不言而喻:开发者可以减少创建“编辑/新建”的界面,直接在这个界面上修改完事了,节省很多功夫。当然一些字段比较多的场合就不适合 Inline-edit 了。

完整代碼在:https://gitee.com/sp42_admin/ajaxjs/tree/master/aj-ts/src/list/grid
在线例子:https://framework.ajaxjs.com/demo/list/grid.html

设计思路

Grid 组件总体仍然是一个普通的 HTML Table,程序是通过读取 JSON 数据(实际 JS 数组)里面的字段,然后通过动态添加表格的方法逐行添加 <tr><td>,在添加行的过程中根据需要设置样式,添加方法等。每一行数据(即 <tr> 的 DOM 对象)都有 id 属性,其值就等于数据中的 idColumn 对应的列的值。不过这一切都不需要手写 DOM,而是通过 Vue 完成。

Grid 是一个复合组件,可以包含以下的子组件。

  • Grid 上分是工具条(Toolbar),可放置按钮、下拉菜单、日历控件和搜索输入框等的子组件;
  • Grid 最左一方的列(Column)通常是勾选框输入框(Checkbox input) ,可单选、多选不同的行记录,对应的组件是 SectionModel 选区模型;
  • Grid 正文就是 HTML Table 元素,每一行是 <tr><td> 是单元格(Cell)。正文这里就是一个二维表格,是组件的重点渲染部分,也是实现的难点。下面再展开详述;
  • Grid 下方有个状态栏,可以显示选区信息或者其他信息。下方居右的是分页控制 UI。

既然为 Table,那么就有行 Row 与列 Column 的关系。渲染过程是以行为单位逐行向下进行的,对应代码中的 aj-grid-inline-edit-row 组件。顾名思义,这些行是允许编辑的(当然也可以只读,禁止修改的)。

有一些固定的列是特别注意的,说明如下。

  • 选区列(Section Column)对应选区模型(Section Model),即最左方的那一列。可以设置 showCheckboxCol 关闭显示。
  • 专门显示 id 的列。可以设置 showIdCol 关闭显示。
  • 各种常规字段的渲染,读取 columns 数组中的单元格渲染器(Cell Renderer)。这是渲染列时候的重点,下面再展开详述。
  • 控制按钮的专门列,用于放置各种功能按钮的列,例如“确定”、“删除”等的,允许自定义不同用途的按钮。可以设置 showControl 关闭显示。

渲染这些列有的非常简单,只是 Vue 的数据绑定即可,例如显示 id 列的:

 <td v-if="showIdCol">{{id}}</td>

有的异常复杂,如单元格的渲染——我们下一小节专门探讨。

实现

选区列 Section Column

首先是选区模型列的渲染。所谓选区 Section,其实是软件里面很常见的概念,不论是 Photoshop 还是我们这里的 Grid,其目的就是让用户可以对一个特定目标进行选取,在一个大的范围或者大的集合中,选择我需要的那一部分,针对性处理。既然有了目标范围,用户就能更精确和清晰地知道在哪个地方干什么的事情,就算是试错了,也是发生在那个范畴之内,不会影响选区以外的地方。比如说 PS 的魔术棒也是一个选区的概念,点击一下黑色的那个点,周围凡是黑色的地方都选上,形成“蚂蚁高亮”的边框,用户只能在那个范围内编辑。选区的好处不言而喻,许多场合虽然名称不同但大体概念也是相通。

Grid 的选区模型则相对 PS 简单多,只是纪录哪一行被选中了,全选 or 反选的简单操作。尽管比较简单,我们设计 SectionModel 的时候还是把它独立成为一个对象,而不是编码到 Grid 组件中去。使用起来,就通过 Vue 的 mixins “混入”到 Grid 实例中。

/**
 * 选区模型
 */
interface GridSectionModel extends Vue {
    /**
     * 顶部是否全选的状态
     */
    isSelectAll: boolean;

    /**
     * 选择的行,key 是 id,value 为 true 表示选中
     * 没选中,则删除这个 key,所以也不存在 value 为 false 的情况
     */
    selected: { [key: number]: boolean };

    /**
     * 已选择总数
     */
    selectedTotal: number;

    /**
     * 最多的行数,用于判断是否全选
     */
    maxRows: number;

    /**
     * 批量删除
     */
    batchDelete(): void;
}

用户选择了的记录,其 id 被保存在一个 map 之中,是为 GridSectionModel.selected。key 是 id,value 为 true 表示选中;没选中,则删除这个 key,所以也不存在 value 为 false 的情况。

用户的交互操作如何与 Section Model 绑定呢?首先我们要弄清楚当前的组件结构:Grid 是最外围的“大”组件,里面包含多个 GridEditRow 子组件,GridEditRow 里面又包含 Checkbox 标签(<input type="checkbox" />),详见 GridEditRow 的 HTML 标签代码。

<td v-if="showCheckboxCol" class="selectCheckbox">
    <input type="checkbox" @change="selectCheckboxChange" :data-id="id" />
</td>

选区模型则 mixins 到了 Grid 组件中。若 Checkbox 状态改变,发送消息需要经过 GridEditRow 才能到达 Grid。详见 Checkbox 的 onchange 事件触发 selectCheckboxChange() 的方法:

/**
 * 选区模型的写入,记录哪一行被选中了
 * 
 * @param ev 事件对象 
 */
selectCheckboxChange(ev: Event): void {
    let checkbox: HTMLInputElement = <HTMLInputElement>ev.target,
        parent = this.$parent;

    if (checkbox.checked)
        parent.$set(parent.selected, this.id, true);
    else
        parent.$set(parent.selected, this.id, false);
}

GridEditRow 的实例属性 $parent 就是指向 Grid,那么访问 Grid.SectionModel 毫无问题,不过 Grid 与 Section Model 早已合二为一,访问 Grid.selected 即等于访问 SectionModel.selected。值得注意的是,Vue 不能直接修改数据驱动管理的 selected 状态,而是通过 $set() 设置。如下例是错误的,

this.$parent.selected[this.id] = true;

应该改为上面方法的 $set()

其他实现的地方参见 grid.ts 的源码,这里不详述。总的来说我的选区算法还是比较简练的,避开了机械的 if…else 判断。

单元格渲染

与其说 Grid 展现数据不如说是单元格展示数据,故涉及到单元格渲染的逻辑在 GridEditRow 类中(inline-edit-row.ts)。单元格渲染是重点也是难点,这一节我们深入看看。

单元格不仅展示数据,还要编辑数据,于是就有两种状态:编辑状态和非编辑状态,我们用 isEditMode 表示。当 isEditMode = false 表示正在展示数据,反之则正在编辑数据。Vue 提供的 v-if/v-show 可以轻松地显示或隐藏 HTML 元素,只要表达式或者方法返回一个 boolean 值即可。按照这思路,我们制定不同的判断函数来决定显示或隐藏哪一种状态下的组件。

编辑单元格的数据一般为 <input type="text" /> 的输入框控件,除此之外,还应允许有其他丰富类型的输入控件,例如日期类型的便显示日历控件,或者下拉菜单等等。于是在此细分之下单元格可以有下面四种形态,对应不同的渲染器。

  • 普通显示单元格数据,当 isEditMode = false 的时候显示。至于如何绑定数据参见 renderCell() 方法,下面详述;
  • 编辑状态下 isEditMode = ture 且不存在 cellRenderer.editMode 的,使用默认的编辑器,即 <input type="text" /> 的输入框控件;
  • 编辑状态下 isEditMode = ture 且存在 cellRenderer.editMode 的,使用自定义的编辑器;
  • 若这是固定的字段,则固定的字段不能被编辑(通过 isFixedField() 判断)。

下面逐一分析渲染原理。

单元格展示数据

如下是 Vue 绑定数据的常规操作:

 <td v-for="cellRenderer in columns" :style="styleModifly" class="cell" @dblclick="dbEdit">
    <span v-if="!isEditMode" v-html="renderCell(rowData, cellRenderer)"></span>
	……
 </td>

columns 是单元格渲染器的类型,这是一个有序的数组。

columns: Array<CellRenderer> = [];

CellRenderer 又是什么呢?就是说明单元格如何渲染显示的对象。为了支持灵活的配置,它可以是最简单的字符串、一个函数或是一个复杂的配置项对象。透过良好的 TypeScript 类型语义描述,我觉得不用太多文字即可让看官去明白设计者之意图。

/**
 * 单元格索引类型,指定 json 的 key(索引) 直接返回 value
 */
type CellRendererKey = string;

/**
 * 单元格渲染器函数
 */
type CellRendererFn = (j: JsonParam) => string;

/**
 * 复杂的单元格渲染器
 */
interface CellRendererConfig {
    /**
     * 可否被编辑编辑
     */
    editMode: boolean;

    key: CellRendererKey;

    renderer: CellRendererFn;

    editRenderer: CellRendererFn;

    /**
     * 数据类型
     */
    type: String | Number | Boolean;
}

/**
 * 单元格渲染器,可以存在以下几种类型之一
 */
type CellRenderer = CellRendererKey | CellRendererFn | CellRendererConfig;

CellRendererKey 显然就是 string,但我们不用 stirng 表示,只有一个好处就是更加符合语义,带来更佳的表达能力。

也许用户更关心的是如何使用的问题。用户所需要的是在标签上指定 Grid 的 columns 属性。标签上不好指定数组,于是绑定到 JavaScript 代码上。

<tr is="aj-grid-inline-edit-row" v-for="value in grid.list" v-bind:key="value.id"
                                :init-row-data="value" :show-id-col="false" :columns="gridCols()"
                                :enable-inline-edit="true" :filter-field="['id', 'createDate']" dele-api=".">
</tr>

请注意 :columns="gridCols()",对应函数如下。

gridCols() {
   return ['id', 'name', 'rate', data => new Date(data.createDate).format("yyyy-MM-dd")];
}

声明了哪些字段参与展示,——这就是所谓的 CellRenderer,有的是普通 string,用于行数据中 map 作为 key 返回 value 的显示内容,这是最简单的方式(即 CellRendererKey);其次是 CellRendererFn,如函数 data => new Date(data.createDate).format("yyyy-MM-dd");最后是复杂的 CellRendererConfig,这里的例子没有展示。它可以设置 renderereditRenderer 两种状态的渲染器,和指定的数据类型 type

下面就是内部的渲染函数,用户如果仅仅想知道用法,那么下面的内容是可以跳过的。

/**
 * 渲染单元格
 * 
 * @param data 
 * @param cellRenderer 
 */
renderCell(data: JsonParam, cellRenderer: CellRenderer): string {
    let v: string = "";
    if (cellRenderer === '')
        return v;

    if (typeof cellRenderer == 'string')
        v = data[<CellRendererKey>cellRenderer] + "";

    if (typeof cellRenderer == 'function')
        v = (<CellRendererFn>cellRenderer)(data);

    if (typeof cellRenderer == 'object') {
        let cfg: CellRendererConfig = <CellRendererConfig>cellRenderer;

        if (!!cfg.renderer)
            v = cfg.renderer(data);
    }

    return v;
}

其实也就那么回事,一点也不复杂。

编辑模式下的单元格

编辑状态下的单元格,除了在 <input type="text" /> 显示数据外,还需要修改数据返回实体,那么这情况下利用 Vue 的 v-model 最好不过了。

<input v-if="canEdit(cellRenderer)" v-model="rowData[cellRenderer]" type="text" size="0" />
/**
 * 没有指定编辑器的情况下,使用 input 作为编辑器
 * 
 * @param cellRenderer 
 */
canEdit(cellRenderer: CellRenderer): boolean {
    return this.isEditMode && !isFixedField.call(this, cellRenderer) && !((<CellRendererConfig>cellRenderer).editMode);
}

只有这三个条件都符合才渲染 input 编辑器。属性 filter-field 指定了哪些字段不参与编辑,例如 id 我们禁止用户进行编辑。

/**
 * 是否固定的字段,固定的字段不能被编辑
 * 
 * @param this 
 * @param cellRenderer 
 */
function isFixedField(this: GridEditRow, cellRenderer: CellRenderer): boolean {
    if (this.filterField && this.filterField.length) {
        for (let i = 0, j = this.filterField.length; i < j; i++) {
            if (this.filterField[i] == cellRenderer)
                return true;
        }
    }

    return false;
}

因篇幅所限,先介绍到这里,下一期再为大家介绍!

相关推荐
©️2020 CSDN 皮肤主题: 数字20 设计师:CSDN官方博客 返回首页