欢迎你们阅读「从零开始的 React 组件开发之路」系列第一篇,表格篇。本系列的特点是从 需求分析、API 设计和代码设计 三个递进的过程当中,由简到繁地开发一个 React 组件,并在讲解过程当中穿插一些 React 组件开发的技巧和心得。 前端
为何从表格开始呢?在企业系统中,表格是最多见但功能需求最丰富的组件之一,同时也是基于 React 数据驱动的思想受益最多的组件之一,十分具备表明性。这篇文章也是近期南京谷歌开发者大会前端专场的分享总结。UXCore table 组件 Demo 也能够和本文行文思路相契合,可作参考。git
有表头,每行的展现方式相同,只是数据上有所不一样github
每一列可能有不一样的对齐方式,可能有不一样的展现类型,好比金额,好比手机号码等ajax
由于每一列的展现类型不一样,所以列配置应该做为一个 Prop,因为有多列应该是一个数组json
数据源应该做为基础配置之一,应该做为一个 prop,因为有多行也应该是一个数组segmentfault
如今的样子:<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 做为参数 }
推荐渲染函数的缘由:
函数在作属性比较时,更简单
约定更少,渲染组件的方式须要配合 Table
预留好比 rowData
一类的接口,不够灵活。
图:Table 的分层设计
图:最初的 Table 结构,详细的分层为后续的功能扩展作好准备。
目前的表格能够知足咱们的最简单经常使用的场景,但仍然有不少常常须要使用的功能没有支持,如列排序,分页,搜索过滤、经常使用动做条、行选择和行筛选等。
列排序:升序/降序/默认顺序 Head/Cell 相关
分页:当表格须要展现的条数不少时,分页展现固定的条数 Table/Pagination 相关,这里假设已有 Pagination 组件
搜索过滤:Table 相关
经常使用操做:Table 相关
行选择:选中某些行,Row/Cell 相关
行筛选:手动展现或者隐藏一些行,不属于任何一列,所以是 Table 级
根据上面对于功能的需求分析,咱们很容易定位 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, // 总条数 }
图:扩展后的 Table 结构
目前组件的数据流向还比较简单,咱们彷佛能够所有经过 props 来控制状态,制做一个 stateless 的组件。
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.data
和 this.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 作“汇报”,进行跨级通讯。
图:父子级间的通讯
做为一个尽量为用户提升效率的组件,除了手动传入 data 外,咱们也应该有自行获取数据的能力,用户只须要配置 url 和相应的参数就能够完成表格的配置,为此咱们可能须要如下参数:
数据源,返回的数据格式应和咱们以前定义的 data 数据结构一致。 (易用)
随请求一块儿发出去的参数。(通用)
在发请求前的回调,能够在这里调整发送的参数。(通用)
请求回来后的回调,能够在这里调整数据结构以知足对 data 的要求。(通用)
同时要考虑到内置功能的适配。(易用)
// 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 进行展现的数据。 }, }
基于前面良好的通讯模式,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); } } }
经过双击或者点击编辑按钮,实现行内可编辑状态的切换。若是只是变成普通的文本框那就太 low 了,有追求的咱们但愿每一个列根据数据类型能够有不一样的编辑形式。既然是可编辑的,那么关于表单的一套东西都适用,他要能够验证,能够重置,也能够联动。
// 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, // 总条数 }
图:行内编辑模式下的表格架构
全部的 CellField 继承一个基类 Field,这个基类提供通用的与 Table 通讯,校验的方式,具体的 Field 只负责交互部分的实现。
下面是这部分设计的具体代码实现,碍于篇幅,不在文章中直接贴出。
这篇文章以复杂表格组件的开发为切入点,讨论了如下内容:
组件设计的通用流程
组件分层架构与 API 的对应设计
组件设计中易用性与通用性的权衡
State 和 Props 的正确使用
生命周期的实战应用
父子级间组件通讯
碍于总体篇幅,有一些和这个组件相关的点未详细讨论,咱们会在本系列的后续文章中详细说明。
数据的 不可变性(immutability)
shouldComponentUpdate 和 pure render
树形表格 和 数据的递归处理
在目前架构上进行折叠面板的扩展
惯例地来宣传一下团队开源的 React PC 组件库 UXCore ,上面提到的点,在咱们的组件开发工具中都有体现,欢迎你们一块儿讨论,也欢迎在咱们的 SegmentFault 专题下进行提问讨论。