2018年3月20日星期二

React 导读(六)

阅读完之前的第四和第五章,分享了 DialogTable 组件的一点设计,还有一些小组件的代码都已经上传到 Github-smarty 上去了,能够自己翻阅看一下,接着我们开始整合这些组件来合成一个业务模块。

一、理解容器组件、展示组件

在现在流行的方案中,Redux 的出现迎来了容器组件、展示组件等概念的流行,其实在这之前 Flux 的方案已经有了这类划分,Redux 应该是让它更知名了。

容器组件(containers/employeeManage/index.jsx):又称充血组件,主要用于一个顶层组件的数据获取,以及一些副作用数据、业务逻辑的处理,我这里的状态订阅代码都放在此处。

展示组件(components/employee/**/*.jsx):又可以称贫血组件,跟容器组件不同的是它几乎没有业务逻辑,只是通过传递的 props 进行渲染,更像一个纯函数,(props) => component

这里多联想一下,如果把这里的组件类型,脱离 View 层的限制去连接后端的分层设计,其实他们也是有充血模型和贫血模型的,但是唯一不同的是,后端的分层只是对数据的操作,可以简单分为:
充血模型:(Domain + Bussiness Logic) => End
贫血模型:(Bussiness Logic + Service) => (Domain Model) => End

上面主要是领域模型(Domain)以及业务逻辑(Bussiness Logic)的关系,区别的话简单可以理解成业务逻辑存在的位置,这里就跟前端的组件划分是一样的,如何更好的划分[业务逻辑]就是设计和开发组件的重点。

二、容器组件代码

容器就有一点打包的趣味,容器+组件,就是打包一些组件在一起,那么我们需要打包哪些东西呢?
我们需要打包业务模块的所有功能的入口组件,比如这里我的功能有:

  • 模块名称
  • 搜索、按钮等操作栏
  • 人员表格
  • 删除、添加、编辑弹框

入口代码就这样子:

<div className="mod">
    <ModTitle>员工管理</ModTitle>
    <EmployeeHeader onAdd={this.handleAdd} />
    <EmployeeTable loading={this.state.isLoadingData}
        data={this.state.list}
        onEdit={this.handleEdit}
        onDelete={this.handleDelete} />

    {/* 各种弹框 */}
    <EmployeeDeleteDialog visible={this.state.visibleDeleteDialog}
        data={this.state.currentSelected}
        onClose={this.handleCloseDeleteDialog}/>
    <EmployeeAddDialog visible={this.state.visibleAddDialog}
        onClose={this.handleCloseAddDialog} />
    <EmployeeEditDialog visible={this.state.visibleEditDialog}
        data={this.state.currentSelected}
        onClose={this.handleCloseEditDialog} />
</div>

从上面的代码可以看出,我这里的容器组件 render 的内容都是一些包装了的自定义组件,有很多 props 的传递。那么这些 props 的值在容器组件中是如何获取的呢?那就要用到之前我们解释数据流程的内容了,这里温习一下主要代码:

// 1. 先看一下我们容器组件依赖的状态 state
const state = {
    list: [],
    currentSelected: null,
    visibleDeleteDialog: false,
    visibleAddDialog: false,
    visibleEditDialog: false,
    isLoadingData: false,
};

// 2. 这里的 state 我是都放在 Store 中进行托管的,所以我们在容器组件构造器里面需要获取初始值
constructor() {
    super();
    // Store 有一个获取状态的 getter
    this.state = EmployeeStore.getState();
}

这样我们的数据就初始化好了,接下来就是监听来更改状态,然后 props 里面的值就有了更新。

componentDidMount() {
    EmployeeStore.on('loadingData', this.handleLoadingData);
    EmployeeStore.on('updateList', this.handleUpdateList);
    
    // 第一次先请求一次员工列表
    EmployeeStore.getList();
}

// 只要 emit('updateList', newData) 就会触发 React 的更新方法 setState 这样整个组件数据就重新更新了
handleUpdateList(list) {
    this.setState(prevState => {
        return {
            list: list,
            isLoadingData: false,
        };
    });
}

三、弹框

其实容器组件要做的工作我这里就差不多了,因为功能很简单。那么我这里只是处理了列表更新,对于列表数据的删除和添加等功能都没有做,这些副作用其实我是放在了弹框里面去做,无论你放在哪里都是可以的,看你项目的情况。我们这里只看一下添加弹框的内容,添加弹框是一个业务类型的组件,依赖的是 Dialog 基础组件的功能。

// addDialog/index.jsx
render() {
    if(!this.props.visible) {
        return null;
    }

    const footer = (
        <DialogFooter
            onSubmit={this.handleSubmit}
            onClose={this.handleClose}
            submitText="添加"
            closeText="关闭" />
    );

    return (
        <Dialog className="employeeAddDialog"
            renderHeader={() => <DialogHeader title="添加成员" />}
            renderFooter={() => footer}>
            <div className="addDialog">
                <Input onChange={this.handleChangeName} placeholder="请输入姓名" />
                <Input onChange={this.handleChangeDays} placeholder="请输入天数" />
                <Input onChange={this.handleChangeAge} placeholder="请输入年龄" />
            </div>
        </Dialog>
    );
}

上面的代码可以看出来,其实主要就是多了三个输入框来进行交互,最后这个添加弹框是有自己的状态的,不是一个纯展示组件,他的 state 如下:

state = {
    formData: {
        name: '',
        age: 0,
        sex: null,
        days: 0,
    }
};

这里我喜欢包裹一层 formData,用来单独就值我要跟后端对接的数据,可能需求还有变化,需要添加的数据并不想影响我的表单提交数据,而且更新也相对方便一点。

那么弹框的提交和关闭是怎么弄的呢?

handleSubmit(e) {
    const {formData} = this.state;
    // addEmployee 方法会 emit('updateList', newData) 来更新容器里面的状态
    EmployeeStore.addEmployee(formData);
    // 乐观交互
    this.handleClose();
}

handleClose(e) {
    // 把关闭状态给外面控制
    this.props.onClose();
}

这里可以看到关闭是通过 props 传递的一个容器方法来做的,因为弹框总体上来说,都是不具备重业务逻辑的一个组件,所以渲染的控制对外开放会更好,因为有时候万一你弹框是默认就要显示的就很别扭:this.state.visible = this.props.visible 这种代码就很蛋疼。所以干脆控制都让外面来做,要改变的给一个 onChange 的接口就行。

这里需要注意的就是添加弹框继承的是 React.PureComponent 来减少不必要的渲染,会进行一个浅的数据 Diff 控制更新。

细心的朋友可能会发现,我的 Input 组件写得比较恶心,为什么呢?我绑定了三个不同的 handleChangeXXX 这种方法,那么如何优化呢?可以思考一下哦~

四、展示表格组件

在这里我将表格做成了纯粹靠外界数据进行渲染的展示组件,将业务逻辑和行为几乎都给了容器组件去做,先来看一下目录结构:

├── dataTable
│   ├── config.js // 表格的配置,主要是 columns、格式化方法
│   ├── dataTable.css
│   └── index.jsx // 表格组件

根据之前的需求,表格这里具有:表头内容 两部分,这里表头能够根据我们的配置进行初始化:

// 具体的配置内容能够在 Github 上看一下
import config from './config';

const headers = config.table.map((row, index) => {
    let width = row.width;
    return <th onClick={row.onSort} style={row.style} className={row.className} width={width} key={`header-${index}`}>{row.title}</th>;
});
headers.push(<th width="160" key="opts">操作</th>);

表头初始化好了,开始初始化内容,表格的内容这里按行进行初始化,依赖了一个 SimpleRow 的业务组件来减少业务表格的代码量:

const rows = this.props.data.map((item, i) => {
    return (
        <SimpleRow key={item.id} tableConfig={config.table} row={item} index={i}>
            <td key={`opt-${i}`}>
                <Button style={{marginRight: 5}} color="blue"
                    onClick={() => this.props.onEdit(item)}>编辑</Button>
                <Button color="red" onClick={() => this.props.onDelete(item)}>删除</Button>
            </td>
        </SimpleRow>
    );
});

这样我们的一个表格就搞定了~加入 Table 组件就能够渲染一个简单职工列表了:

<div className="mod-table employeeTable">
    <TableLoading loading={loading} />
    <Table>
        <Table.Header>
            {headers}
        </Table.Header>
        <Table.Body>
            {rows}
        </Table.Body>
    </Table>
</div>

这里可以看到还有一个 TableLoading 组件,这也是一个偏业务的组件,主要就是用于表格加载数据时候的一个 Loading 行为。最后这个表格是搞定了,但是代码还是复杂了一点,那么怎么才能更简单的让别人用呢?让代码更少,O__O "…这个可以再思考一下哦~可以再进行一次封装。

PS: 有什么东西是分层搞不定的...如果搞不定,再分一层。哈哈,当然这是一个段子。

今天就写到这里吧,其实导读系列就先结束了,这里涉及的东西不多,但是对最初用 React 来编写代码还算比较有帮助的,后面会继续深入一点的话题,比如

  • 库类:热火朝天的数据状态管理、纯函数、流式、单向数据流、表单、验证器等等吧~
  • 脚手架:如何搭建一个自己心仪的玩具~
  • 框架:如何更工程的去开发 React 等内容~
  • 好玩的一些开发组件的理念~
  • 当 TypeScript 遇上 React 呀这种。

想到的时候有时间就写写。

PS: 系列写了六集 =.= 还蛮神奇的,我姓协音就是六。

2018年3月19日星期一

React 导读(四)

一、前言

React 导读(三) 中介绍了项目的背景、功能需求、项目结构以及组件的划分层次,接下来我们就来看下实际的代码,这一篇文章会主要分享用到的基础组件的封装。

二、基础组件设计

我们在设计组件之前本来是有一个流程和过程的,这里我写的组件并不会像社区内的组件库一样完善或者说一定考虑很完整,但是这样也会有一个好处,可以按照自己项目的需求进行定制、扩展以及冗余的代码会更少,当然很多时候节约的这点代码可以忽略不计(特别是项目业务代码和库的代码比例上升到一定比例过后,所以一切不说场景就说某某库太大的观点都是不正确的),因为大家都有按需加载的配置可选。这不是绝对的,不一定说你自己花时间和精力去开发一个这样的库就更好,因为随着项目规模的扩大,组件的种类和需求会越来越多,即使是一个不错的工程师利用技巧保障项目持续迭代,但是人的时间和精力是有限的,更合理的利用现有资源去提高效率才是最优先考虑的事情。

我这里的基础组件实现了这么几个:
Button, Dialog, Input, Loading, Table

然后分别来介绍一下如何基础开始封装和拆合组件。其实基础组件的设计是很杀脑细胞的,如果要考虑很周全的话,因为要兼顾别人用的爽,也尽可能要保留可扩展性,基础组件如果扩展性太弱,基本等于废了。其实如果有学习设计模式是可以相互连接的,因为设计模式是成熟的经验,不是说非要在写某种逻辑代码或者做架构设计的时候才能使用,它是能够贯穿在整个软件周期内的。

(1) 思考想要如何去组织组件样式

首先这里的组件是 css 和 js 最好能够分开使用的(这里的分开使用不是指传统意义的分离,而是保持独立,可进可退),拿以前 UI 需求来看,就是在同一个结构的 HTML,加上 class 都是能够正常展现的,我这里的 css 结构是以前用过的,也没做什么改动直接拿过来使用的。这种设计思路其实和现在的组件化开发是不冲突的,组件化后还能够使得这种模式更简单的被实现,因为你只需要考虑组件这个作用域内的样式。

(2) Dialog 组件
这里先拿 Dialog 组件先举例,这里我将弹框组件分成了三部分:DialogHeader, DialogFooter, Dialog 拆分上也没什么理由,这是一种简单直接的拆分,因为很多弹框都具有这么几部分:标题、内容、按钮区域。

而且不只是这样的才叫弹框,弹框如其名:弹出的框,所以都是可以的,比如下面这种像个 Alert 一样的弹框:

我理解的好扩展的组件就像小时候玩的玩具一样,各部分都是可拆解可组合的,所以弹框的这三部分都需要有一定灵活的地方。来看看代码,其实蛮简单的。

class Dialog extends React.Component {
    render() {
        const {
            renderHeader,
            renderFooter,
            className,
        } = this.props;

        const header = renderHeader ? renderHeader() : null;
        const footer = renderFooter ? renderFooter() : null;

        const wrapClassName = cx('st-dialog', className);

        return (
            <div className={wrapClassName}>
                <div className="st-dialogContent">
                    {header}
                    {this.props.children}
                    {footer}
                </div>
                <div className="st-dialogMask"></div>
            </div>
        );
    }
}

上面就是一个我这里 Dialog 的结构,headerfooter采用的是render-props的方式来实现具体的插入,为什么能够采用这种方式其实不难理解,因为在 React 中,一个函数就自然就是一个组件声明,返回值就能是一个组件实例。我这里更直接,你要组件返回值,我就给你一个组件...使用上就是这样:

// 这里结构稍微代码有点多,就不要揉在一起了,给一个变量存一下更清晰,在 React 中组件的使用是自由的。
const footer = (
    <DialogFooter
        onSubmit={this.handleSubmit}
        onClose={this.handleClose}
        submitText="添加"
        closeText="关闭" />
);

<Dialog className="employeeAddDialog"
    renderHeader={() => <DialogHeader title="添加成员" />}
    renderFooter={() => footer}>
    <div className="addDialog">...</div>
</Dialog>

为什么要这样设计呢?主要还是因为有时候需求不定,万一哪天 DialogFooter 组件不是这样子,我就在外面实现好组件给这个renderFooter就行了,其他部分就不需要改动,还有就是实现的时候不要吝啬div这种容器标签的使用,多一层就多一个权重,少一层就多了一份自由。

现在还有一个问题,就是我的基础弹框有了,业务弹框各种各样,这么简单的一个封装根本不靠谱啊...那么这里你就将弹框作为一个流行的渲染组件来使用,但是是否挂载到业务模块中就使用封装的一层业务弹框来控制,比如我的业务弹框叫 EmployeeAddDialog,在 render 方法中:

if(!this.props.visible) {
    return null;
}

return (<Dialog />);

通过一个 visibleprops 值来控制是否挂载 Dialog,那么这样做就有一个好处,在处理异步弹框的时候,想什么时候关闭弹框可以由业务的流程来控制。在业务组件声明业务弹框的地方就这样:

<EmployeeAddDialog visible={this.state.visibleAddDialog}
onClose={this.handleCloseAddDialog} />

然后这样就实现了一个弹框了,灵活性和扩展性都还好,最后还有一个细节就是这个EmployeeAddDialog始终都挂载在业务组件中,业务组件渲染一次这个弹框更新周期也会走一次,所以能够继承一下PureComponent来简单避免多次执行不必要代码:

class EmployeeAddDialog extends React.PureComponent { }

看上去这个弹框组件还算干净,不可能啊,业务太复杂也不会太干净,那么脏的东西去哪儿了呢?我这里有 2 个比较脏的地方:

(1) Footer,因为有按钮,每个需求的按钮是不一样的;
(2) 弹框的内容,这里就更是千奇百怪,每个产品经理的脑洞都不一样。

我这里应对上 Footer 有一定的定制又有简单的开关,比如我就支持2个按钮:提交类、关闭类。

弹框内容我控制不了,那么我就把代码是否更脏的职责交出去,使用了 this.props.children来做这个事情,使用者的代码干净度来决定最后的业务弹框干净度。

具体的全部代码能够在这里看到:

基础 Dialog

业务 Dialog

今天先到这里吧,睡觉了😪

React 导读(五)

React 导读(四)中分享了组件设计最开始考虑的一些事情,不能介绍太矛盾了,其实对于设计来讲是有正反两面的分析的,就跟评论历史事件来看都是要分两面进行分析的。今天我们接着分享剩下的内容,我觉得不一定要求多,但是一定要带着思考来写,有点意识流。

首先弹框和表格是最常用的组件,下面就介绍表格吧。

表格其实是很复杂的一个组件,我们先来看下成熟的表格,然后再来看下我实现的最简单的表格组件是什么样子的。这里就看下比较流行的 antd 吧,我不会分析他的实现和代码,因为这并不利于第一次学习一个组件的封装,一个成熟的组件一般是迭代出来的,越是迭代时间长的组件就更复杂,学习前应该是知道有一个东西,然后将这个东西简化,知道它是什么,然后进行扩展会比较好一点。

这里我将表格组件的实现分为 2 类:

  1. 配置式
  2. 组合式

2种没有哪一种好,各有优缺点:

配置式优点

  • 易于使用
  • 功能声明清晰,具有一定的整体性,代码也不分散

配置式缺点

  • 扩展、定制性其实是相对组合式较差
  • 递归性低于组合式
  • 组件结构非标记语言表达,在第一层阅读代码的时候主观上认为不够清晰

组合式优点

  • 组件结构标记语言实现,更清晰
  • 扩展性较强,不容易在代码里面形成较多的 if-else 式编程

组合式缺点

  • 使用上相比配置式复杂
  • 代码量较多

这里分的比较开,但是可以是配置+组合的方式来实现组件,做一个制衡。比如:

  • 表头无论是动态、静态构造,最后都是一个固定的配置式数据,也许你还能够自定义列展示;
  • 数据的结构 ID 与 Columns key 进行统一的配置,其实从某种意义上数据与列也是一种约定的模式;
  • 分页组件比较固定,就需要固定配置等等。

对于 antd-table 是依赖于 rc-table 组件进行封装的,props 主要有 2 个:columns、dataSource。

配置上的基础结构,我这里用自己的描述代码:

// 一个 JSON 的结构
ColumnItem {
    // String 表示 title 的数据类型是字符串
    title: String;
    key: String;
    dataIndex: String;
    // 表示 render 是一个非必填的配置,是一个函数,有 2 个值,可以返回一个 JSX 结构内容
    render?: (text, record) => JSX.Element;
}

Columns = ColumnItem[]

然后使用的时候通过把配置传递进去就返回了一个你配置的表格:

<Table columns={columns} dataSource={data} />

当然 antd 的体验是不错的,还支持将 Column 组件当作子组件的方式声明,也就是我说的配置+组合的一种结合,会将可读性增强、扩展外置的优点放大。具体可以参考官方文档,这里不能介绍太多:antd-table-cn-document

随着需求的扩展,<Table /> 组件需求逐渐增多,就会出现更多的依赖 props,也许会做更多的判断。虽然从需求上我们不能控制这种 if-else 的逻辑,但是我们能够通过平行组合的方式来减少单个组件的复杂度。这个是双刃剑,因为可能会有冗余。

平行组合的方式就好比有很多 if 的条件,根据组件的种类进行拆分,分成不同的组件,然后让使用者去决定用什么组件,这样一层一层的铺展使用,重的表格可能会有聚合的组件模式,这个又是另外一个问题了。

那么我这里例子里面用到的表格组件怎么弄的呢?我模仿的是 bootstrap-table 的配置方式,然后再配合 React 的一些组件特性拼起来实现的一个 Table 组件。

我这边实现的表格组件只有以下四个组件,其中 SimpleRow 组件是一个 Row 最基础的用法,满足基本的一个表格行。Table.HeaderTable.Body 分别是对表头和表内容的装饰,主要是样式和布局上的统一。

Table
    Table.Header
    Table.Body
    SimpleRow

具体的 Table.jsx 组件代码如下,基本等于就是一个壳:

export default class Table extends React.Component {
    render() {
        const {children, className} = this.props;
        const classNames = cx('st-table', className);

        return (
            <table className={classNames}>
                {/* 这里就意味着这个组件是层级 Wrap 类型的组件 */}
                {children}
            </table>
        );
    }
};

我们先看一下会如何使用表格吧。

<Table>
    <Table.Header>
        <th>...</th>
    </Table.Header>
    <Table.Body>
        <tr>...</tr>
    </Table.Body>
</Table>

React 组件是能够有命名空间的写法的,比如这里的 Table.<Someone> 的写法。那么我们要统一样式和布局,基本是等于就统一一下 class 就行,我们不能说让使用者自己每次去添加一些共有的代码,所以这里我们要用一个 API React.cloneElementReact.Children.map,后面 map 方法可能比较好理解,类似 Array.prototype.map,那么 React.cloneElement 的意思就跟它的表面意思一样:克隆一个元素(ReactElement),API 可以先看下官网 cloneElementApimapApi

先看看一下 Table.Body 的代码吧:

Table.Body = ({ children }) => {
    // 1. 先遍历孩子元素
    const cloneChild = React.Children.map(children, (child, i) => {
        if(!child) {
            return null;
        }
        
        // 2. 要更新的孩子组件的属性
        const wrapProps = {
            key: `st-table-row_${i}`,
            className: cx('st-table-row', child.props.className)
        };

        // 3. 返回一个新的 clone 的 React 元素,这里可以思考一下为什么要 clone
        return React.cloneElement(child, wrapProps);
    });
    
    // 4. 最后返回一个 tbody 原始的容器
    return (<tbody className="st-table-tbody">{cloneChild}</tbody>);
};

这里的代码可以看 github 上完整的源码:TableComponent

那么我们组件的结构通过组合的写法构造好了,那么我们的 Columns 在哪儿去声明呢?我们这里也是通过 js 数组对象的方式来声明,结构如下:

const table: [
    {
        field: 'id',
        title: 'ID',
        width: 50
    },
    {
        field: 'sex',
        title: '性别',
        format(value, row, index) {
            if(!value) {
                return '保密';
            }
            
            if(value === Gender.Girl) {
                return '女';
            } else if(value === Gender.Boy) {
                return '男';
            }
        }
    },
    ...
];

这里的配置怎么和表格的列连接起来呢?我这里将业务表格组件的目录划分成了这样:

├── dataTable
│   ├── config.js // 表格的配置
│   ├── dataTable.css // 表格的样式
│   └── index.jsx // 表格组件
// 初始化 header
const headers = table.map((row, index) => {
    let width = row.width;
    return <th key={`header-${index}`} width={width}>{row.title}</th>;
});
// 多一个操作列,这个操作列我这里是没有 antd 那么智能,识别到差 1 列的时候就成为了操作什么的
headers.push(<th width="160" key="opts">操作</th>);

// 初始化每行的内容
const rows = (this.props.data || []).map((item, i) => {
    return (
        <SimpleRow key={item.id} tableConfig={table} row={item} index={i}>
            {/* SimpleRow 通过传入的 TableConfig 和 Row 数据,初始化好了基本的行元素 */}
            {/* SimpleRow 也是一个 this.props.children 的结构 */}
            <td key={`opt-${i}`}>
                <Button color="blue" onClick={() => this.props.onEdit(item)}>编辑</Button>
                <Button color="red" onClick={() => this.props.onDelete(item)}>删除</Button>
            </td>
        </SimpleRow>
    );
});

这样回到最开始那个使用表格的例子,将 header, rows 都添加进入即可,最后整个表格 Row 的效果就是这样子,SimpleRow 的实现代码依然能在业务表格 Github 文件中找到:

这种写组件的方法特别费代码,所以是否可以考虑封装成一个业务通用的表格组件呢?这肯定是可以的,将变化的部分通过 props 传入即可,这个工作可以自己动手试试,在我的源码基础上进行修改。还有就是 tableConfig 里边的对象属性,你是能按自己需求进行添加的,可以在看看源码中的配置。

最后表格实现的效果,虽然没有排序、固定表头,但是用来展示信息是已经完善了,比如点击表头进行排序这个可以动手试试,在我组件的基础上应该比较好添加:


这里表格组件就实现了,你肯定会想,为什么没有 antd 那么方便实用和强大呢?

(1) antd-table 和这里介绍的 Table 组件层级是不同的,更接近的应该是 rc-table;
(2) 这只是针对最简单的表格使用场景进行设计的,按的是自由编写修改,并不是一个复杂场景的表格设计。

今天先写到这里吧~11点半了,休息一会儿。