12

React 下的表格狂想曲

0. 前言

欢迎大家阅读「从零开始的 React 组件开发之路」系列第一篇,表格篇。本系列的特色是从 需求分析、API 设计和代码设计 三个递进的过程中,由简到繁地开发一个 React 组件,并在讲解过程中穿插一些 React 组件开发的技巧和心得。

为什么从表格开始呢?在企业系统中,表格是最常见但功能需求最丰富的组件之一,同时也是基于 React 数据驱动的思想受益最多的组件之一,十分具有代表性。这篇文章也是近期南京谷歌开发者大会前端专场的分享总结。UXCore table 组件 Demo 也可以和本文行文思路相契合,可做参考。

1. 一个简单 React 表格的构造

1.1 需求分析

  • 有表头,每行的展示方式相同,只是数据上有所不同

  • 每一列可能有不同的对齐方式,可能有不同的展示类型,比如金额,比如手机号码等

1.2 API 设计

  • 因为每一列的展示类型不同,因此列配置应该作为一个 Prop,由于有多列应该是一个数组

  • 数据源应该作为基础配置之一,应该作为一个 prop,由于有多行也应该是一个数组

  • 现在的样子:<Table columns={[]} data={[]} />

  • 基本思路是通过遍历列配置来生成每一行

  • data 中的每一个元素应该是一行的数据,是一个 hash 对象。

    {
        city: '北京',
        name: '小李'
    }
  • columns 中的每一个元素是一列的配置,也是一个 hash 对象,至少应该包括如下几部分:

    {
        title: '表头',
        dataKey: 'city', // 该列使用行中的哪个 key 进行显示
    }
  • 易用性与通用性的平衡

    • 易用性与通用性互相制衡,但并不是绝对矛盾。

    • 何为易用?使用尽量少的配置来完成最典型的场景。

    • 何为通用?提供尽量多的定制接口已适应各种不同场景。

    • 在 API 设计上尽量开放保证通用性

    • 在默认值上提炼最典型的场景提高易用性。

    • 从易用性角度出发

    {
        align: 'left', // 默认左对齐
        type: 'money/action', // 提供 'money', 'card', 'cnmobile' 等常用格式化形式
        delimiter: ',', // 格式化时的分隔符,默认是空格
        actions: { // 表格中常见的操作列,不以数据进行渲染,只包含动作,hash 对象使配置最简化
          "编辑": function() {doEdit();}
        }, 
    }
    • 从通用性角度出发

    {
        actions: [ // 相对繁琐,但定制能力更强
          {
              title: '编辑',
              callback: function() {doEdit();},
              render: function(rowData) {
                  // 根据当前行数据,决定是否渲染,及渲染成定制的样子
              }
          }
        ],
        render: function(cellData, rowData) {
            // 根据当前行数据,完全由用户决定如何渲染
            return <span>{`${rowData.city} - ${rowData.name}`}</span>
        }
    }
    • 提供定制化渲染的两种方式:

      • 渲染函数 (更推荐)

      {
          render: function(rowData) {
              return <CustomComp url={rowData.url} />
          },
      }
      • 渲染组件

      {
          renderComp: <CustomComp />, // 内部接收 rowData 作为参数
      }
      • 推荐渲染函数的原因:

        1. 函数在做属性比较时,更简单

        2. 约定更少,渲染组件的方式需要配合 Table 预留比如 rowData 一类的接口,不够灵活。

1.3 代码设计

Table 分层

图:Table 的分层设计

table 简单架构

图:最初的 Table 结构,详细的分层为后续的功能扩展做好准备。

2. 加入更多的内置功能

目前的表格可以满足我们的最简单常用的场景,但仍然有很多经常需要使用的功能没有支持,如列排序,分页,搜索过滤、常用动作条、行选择和行筛选等。

2.1 需求分析

  • 列排序:升序/降序/默认顺序 Head/Cell 相关

  • 分页:当表格需要展示的条数很多时,分页展示固定的条数 Table/Pagination 相关,这里假设已有 Pagination 组件

  • 搜索过滤:Table 相关

  • 常用操作:Table 相关

  • 行选择:选中某些行,Row/Cell 相关

  • 行筛选:手动展示或者隐藏一些行,不属于任何一列,因此是 Table 级

2.2 API 设计

根据上面对于功能的需求分析,我们很容易定位 API 的位置,完成相应的扩展。

// table 配置,需求对应的模块对应了他的配置在整个配置中的位置
{
    columns: [ // HEAD/ROW 相关
        {
            order: true, // 是否展示排序按钮
            hidden: false, // 是否隐藏,行筛选需要
        }
    ],
    onOrder: function (activeColumn, order) { // 排序时的回调
        doOrder(activeColumn, order)
    }, 
    actionBar: { // 常用操作条
        "打印": function() {doPrint()}, 
    },
    showSeach: true, // 是否显示搜索过滤,为什么不直接用下面的,这里也是设计上的一个优化点
    onSearch: function(keyword) { doSearch(keyword) }, // 搜索时的回调
    showPager: true, // 是否显示分页
    onPagerChange: function(current, pageSize) {}, // 分页改变时的回调
    rowSelection: { // 行选择相关
        onSelect: function(isSelected, currentRow, selectedRows) { 
            doSelect() 
        }
    }
}
// data 结构
{
    data: [{
        city: 'xxx',
        name: 'xxx',
        __selected__: true, // 行选择相关,用以标记该行是否被选中,用前后的 __ 来做特殊标记,另一方面也尽可能避免与用户的字段重复
    }],
    currentPage: 1, // 当前页数
    totalCount: 50, // 总条数
}

2.3 代码设计

结构图

图:扩展后的 Table 结构

内部数据的处理

目前组件的数据流向还比较简单,我们似乎可以全部通过 props 来控制状态,制作一个 stateless 的组件。

何时该用 state?何时该用 props?

UI=fn(state, props), 人们常说 React 组件是一个状态机,但我们应该清楚的是他是由 state 和 props 构成的双状态机;

props 和 state 的改变都会触发组件的重新渲染,那么我们使用它们的时机分别是什么呢?由于 state 是组件自身维护的,并不与他的父级组件进行沟通,进而也无法与他的兄弟组件进行沟通,因此我们应该尽量只在页面的根节点组件或者复杂组件的根节点组件使用 state,而在其他情况下尽量只使用 props,这可以增强整个 React 项目的可预知性和可控性。

但凡事不是绝对的,全都使用 Props 固然可以使组件可维护性变强,但全部交给用户来操作会使用户的使用成本大大提高,利用 state,我们可以让组件自己维护一些状态,从而减轻用户使用的负担。

我们举个简单的例子

{/* 受控模式 */}
<input value="a" onChange={ function() {doChange()} } />
{/* 非受控模式 */}
<input onChange={ function() {doChange()} } />

value 配置时,input 的值由 value 控制,value 没有配置时,input 的值由自己控制,如果把 <input /> 看做一个组件,那么此时可以认为 input 此时有一个 state 是 value。显然,无 value 状态下的配置更少,降低了使用的成本,我们在做组件时也可以参考这种模式。

例如在我们希望为用户提供 行选择 的功能时,用户通常是不希望自己去控制行的变化的,而只是关心行的变化时应该拿取的数据,此时我们就可以将 data 这个 prop 变成 state。有一点需要注意的是,用户的 prop

class Table extends React.Component {
    constructor(props) {
        super(props);
        this.data = deepcopy(props.data);
        this.state = {
            data: this.data,
        };
    }
    
    /**
     * 在 data 发生改变时,更改对应的 state 值。
     */
    componentWillReceiveProps(nextProps, nextState) {
        if (!deepEqual(nextProps.data, this.data) {
            this.data = deepcopy(nextProps.data);
            this.setState({
                data: this.data,
            });
        }
    }
}

这里涉及的一个很重要的点,就是如何处理一个复杂类型数据的 prop 作为 state。因为 JS 对象传地址的特性,如果我们直接对比 nextProps.datathis.props.data 有些情况下会永远相等(当用户直接修改 data 的情况下),所以我们需要对这个 prop 做一个备份。

生命周期的使用时机

图:React 的生命周期

  • constructor: 尽量简洁,只做最基本的 state 初始化

  • willMount: 一些内部使用变量的初始化

  • render: 触发非常频繁,尽量只做渲染相关的事情。

  • didMount: 一些不影响初始化的操作应该在这里完成,比如根据浏览器不同进行操作,获取数据,监听 document 事件等(server render)。

  • willUnmount: 销毁操作,销毁计时器,销毁自己的事件监听等。

  • willReceiveProps: 当有 prop 做 state 时,监听 prop 的变化去改变 state,在这个生命周期里 setState 不会触发两次渲染。

  • shouldComponentUpdate: 手动判断组件是否应该更新,避免因为页面更新造成的无谓更新,组件的重要优化点之一。

  • willUpdate: 在 state 变化后如果需要修改一些变量,可以在这里执行。

  • didUpdate: 与 didMount 类似,进行一些不影响到 render 的操作,update 相关的生命周期里最好不要做 setState 操作,否则容易造成死循环。

父子级组件间的通信

父级向子级通信不用多说,使用 prop 进行传递,那么子级向父级通信呢?有人会说,靠回调啊~ onChange等等,本质上是没有错误的,但当组件比较复杂,存在多级结构时,如果每一级都去处理他的子级的回调的话,不仅写起来非常麻烦,而且很多时候是没有意义的。

我们采取的办法是,只在顶级组件也就是 Table 这一层控制所有的 state,其他的各个子层都是完全由 prop 来控制,这样一来,我们只需要 Table 去操作数据,那么我们逐级向下传递一个属于 Table 的回调函数,完成所有子级都只向 Table 做“汇报”,进行跨级通信。

图:父子级间的通信

3. 自行获取数据

3.1 需求分析

作为一个尽可能为用户提高效率的组件,除了手动传入 data 外,我们也应该有自行获取数据的能力,用户只需要配置 url 和相应的参数就可以完成表格的配置,为此我们可能需要以下参数:

  • 数据源,返回的数据格式应和我们之前定义的 data 数据结构一致。 (易用)

  • 随请求一起发出去的参数。(通用)

  • 在发请求前的回调,可以在这里调整发送的参数。(通用)

  • 请求回来后的回调,可以在这里调整数据结构以满足对 data 的要求。(通用)

  • 同时要考虑到内置功能的适配。(易用)

3.2 API 设计

// table 配置,需求对应的模块对应了他的配置在整个配置中的位置
{
    url: "//fetchurl.com/data", // 数据源,只支持 json 和 jsonp
    fetchParams: { // 额外的一些参数
        token: "xxxabxc_sa"
    },
    beforeFetch: function(data, from) { // data 为要发送的参数,from 参数用来区分发起 fetch 的来源(分页,排序,搜索还是其他位置)
        return data; // 返回值为真正发送的参数
    },
    afterFetch: function(result) { // result 为请求回来的数据
        return process(result); // 返回值为真正交给 table 进行展示的数据。
    },
}

3.3 代码设计

基于前面良好的通信模式,url 的扩展变得非常简单,只需要在所有的回调中加入是否配置 url 的判断即可。

class Table extends React.Component {
    constructor(props) {
        super(props);
        this.data = deepcopy(props.data);
        this.fetchParams = deepcopy(props.fetchParams);
        this.state = {
            data: this.data,
        };
    }
    
    /**
     * 获取数据的方法
     */
    fetchData(props, from) {
        props = props || this.props;
        const otherParams = process(this.state);
        ajax(props.url, this.fetchParams, otherParams, from);
    }
    
    /**
     * 搜索时的回调
     */
    handleSearch(key) {
        if (this.props.url) {
            this.setState({
                searchKey: key,
            }, () => {
                this.fetchData();
            });
        } else {
            this.props.onSearch(key);
        }
        
    }
    
    componentDidMount() {
        if (this.props.url) {
            this.fetchData();
        }
    }

    componentWillReceiveProps(nextProps, nextState) {
        let newState = {};
        if (!deepEqual(nextProps.data, this.data) {
            this.data = deepcopy(nextProps.data);
            newState['data'] = this.data; 
        }
        if (!deepEqual(nextProps.fetchParams, this.fetchParams)) {
            this.fetchParams = deepcopy(nextProps.fetchParams);
            this.fetchData();
        }
        if (nextProps.url !== this.props.url) {
            this.fetchData(nextProps);
        }
        if (Object.keys(newState) !== 0) {
            this.setState(newState);
        }
    }
}

4. 行内编辑

4.1 需求分析

通过双击或者点击编辑按钮,实现行内可编辑状态的切换。如果只是变成普通的文本框那就太 low 了,有追求的我们希望每个列根据数据类型可以有不同的编辑形式。既然是可编辑的,那么关于表单的一套东西都适用,他要可以验证,可以重置,也可以联动。

4.2 API 设计

// table 配置,需求对应的模块对应了他的配置在整个配置中的位置,显然行内编辑是和列相关的
{
    columns: [ // HEAD/ROW 相关
        {   
            dataKey: 'cityName', // 展示时操作的变量
            editKey: 'cityValue', // 编辑时操作的变量
            customField: SelectField, // 编辑状态的类型
            config: {}, // 编辑状态的一些配置
            renderChildren: function() {
                return [
                {id: 'bj', name: '北京'},
                {id: 'hz', name: '杭州'}].map((item) => {
                    return <Option key={item.id}>{item.name}</Option>
                });
            },
            rules: function(value) { // 校验相关
                return true;
            }
        }
    ],
    onChange: function(result) {
        doSth(result); // result 包括 {data: 表格的所有数据, changedData: 变动行的数据, dataKey: xxx, editKey: xxx, pass: 正在编辑的域是否通过校验}
    }
}
// data 结构
{
    data: [{
        cityName: 'xxx',
        cityValue: 'yyy',
        name: 'xxx',
        __selected__: true, 
        __mode__: "edit", // 用来区分当前行的状态
    }],
    currentPage: 1, // 当前页数
    totalCount: 50, // 总条数
}

4.3 代码设计

table edit mode

图:行内编辑模式下的表格架构

  • 所有的 CellField 继承一个基类 Field,这个基类提供通用的与 Table 通信,校验的方式,具体的 Field 只负责交互部分的实现。

  • 下面是这部分设计的具体代码实现,碍于篇幅,不在文章中直接贴出。

  • https://github.com/uxcore/uxc...

  • https://github.com/uxcore/uxc...

5. 总结

这篇文章以复杂表格组件的开发为切入点,讨论了以下内容:

  • 组件设计的通用流程

  • 组件分层架构与 API 的对应设计

  • 组件设计中易用性与通用性的权衡

  • State 和 Props 的正确使用

  • 生命周期的实战应用

  • 父子级间组件通信

碍于整体篇幅,有一些和这个组件相关的点未详细讨论,我们会在本系列的后续文章中详细说明。

  • 数据的 不可变性(immutability)

  • shouldComponentUpdate 和 pure render

  • 树形表格 和 数据的递归处理

  • 在目前架构上进行折叠面板的扩展

最后

惯例地来宣传一下团队开源的 React PC 组件库 UXCore ,上面提到的点,在我们的组件开发工具中都有体现,欢迎大家一起讨论,也欢迎在我们的 SegmentFault 专题下进行提问讨论。

uxcore


紅白
1.9k 声望195 粉丝