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点半了,休息一会儿。

2018年2月25日星期日

React 导读(三)

前言

React 导读(一)
React 导读(二)

在之前 2 篇文章中中学习到了写第一个 Web 组件以及常用的生命周期函数的使用,这篇文章将继续之前的目录,开始新的知识点补充:

  1. React 如何编写 Hello World!
  2. React 中三个最基础、最重要的东西
  3. React 中的 JSX
  4. 你的第一个 Web 组件
  5. React 中最开始需要关注的生命周期
  6. React 一个组件集合的简单交互
  7. React 开始一个项目的一点建议
  8. React 简单的项目结构组织

这篇文章主要会介绍第6、7的知识点。

六 & 七、React 一个组件集合的简单交互以及开始一个项目的一点建议

为什么要将6、7合在一起写呢?不是因为想偷懒...其实是脱离一个场景和合适的开始去规划组件等设计都是不合理的,多多少少都有点交集,所以将这 2 点融合在一起是更利于学习和理解的,到这里就已经不是太基础的内容了,基本上代码量会有所提高,但是分析依然会很细致。

这里用一个简单的表格的添加删除编辑搜索四个功能来作为实例吧。
因为这应该是日常开发过程中遇到过程最多的,我将参考 bootstrap-table 的方式来开发一个简单的表格组件和约定配置来做,感觉比较自由,如果你动手能力好且业务稍大和复杂可以参考 antd 设计规范来实现,目前市面上应该蚂蚁这套用的比较多,但是这并不意味着我们就一定是按照他来做,实际项目不复杂的情况是可以使用更简单的方式。

做这个开始之前,首先要假设一点场景和基本需求,这样才能带着去思考如何实现以及更接近需求目标。

(1) 场景

为了更清晰的安排年前年后的工作和值班,现在要对过年期间人员请假的情况进行统计,并且进行一个简单的管理。

(2) 功能性需求

  • 添加员工的请假信息
  • 展示添加的员工请假的列表
  • 能够对信息进行修改
  • 能够删除添加的信息,由于不可恢复,所以需要一个提示
  • 能够根据员工的名字进行搜索

简单描述了一下,其实就之前说的几个功能。

最后做出来的效果如下(=.=没有设计,对齐就行哈):

看之前可以下载源代码对照着看,不过代码可能会不断修改 BUG,哈哈~有 BUG 不要虚,没有 BUG 我们可能就失业了。
源码-GitHub

(3) 准备工作

  1. 整理需要用到的技术
  2. 开发要用的基础 UI 组件
  3. 看下 bootstrap-table 的基本设计
  4. 搭建项目目录

1. 需要用到的技术
需要用到的技术:React/ES6, CSS 即可

2. 基础 UI 组件
根据我们这里的功能来看,我们只需要下面这几个基础组件即可:
Button, Dialog, Input, Table, Radio

在这个例子项目里面,组件的划分结构如下:

为什么要这样划分呢?

  • 基础组件:其实这个是每一个项目都需要的,如果说太小的项目不需要其实大多数是考虑掉了项目的迭代周期的考量以及以后代码的可复用性,顾名思义,基础组件就是你要在以后的组件编写过程中需要依赖的最基础的组件,基本是只负责 UI 层面的职责,当然你还能够再剥离,这里就不太展开了,知道这一层是为了以后写组件能够有自己的基础组件即可。

  • 业务组件、模块组件:在我们开发好基础组件过后,其实这些基础组件是不具备任何业务价值的,比如有了业务设计稿后,我们需要针对业务然后编写业务中公用的组件或者封装使用操作2次的组件代码,形成一个可复用的业务组件或者业务模块类型的组件。比如我这里会将每个模块用到的模块标题封装成一个 ModTitle 组件,这样以后修改这里样式的时候全部就在一个地方修改,或者在业务系统上会有 Layout 相关的布局组件需求,再比如系统中表格整个一块的需求,包含搜索、头部操作按钮、数据展示表格,这三者能够进行一个通用性的封装来形成业务模块上的表格使用组件,增加编写模块的效率,当然这里我并没有封装,因为封装和重构并不是软件初始开发更应该注重的,而是遇到第二次的时候再反过来思考如何避免重复或者让组件内部封装。

  • 展示组件、容器组件:这一层就是网上流行的展示型组件、容器组件的一层,我这里划分主要是跟具体业务功能有关系的一层。由于我这里没有 react-router,所以复杂度会低一些,后面有时间也可以介绍。

3. bootstrap-table 生成表格的方式

可以查看 github-bootstrap-table 的使用例子来看下使用方式,这里我用它做例子并不是说此库完全好或者不好,而是以前项目用了 bootstrap-table 然后就模仿了 columns 配置的方式,对于它 API 设计的其他部分暂时没采用。
表格组件其实是管理类系统很核心的部分,一是用的多,二是本身也比较复杂,封装太死缺少灵活性,封装太简单缺少效率,种类也比较多。大体上我会采用字段进行配置的方式,具体看后面的代码和分析。

4. 项目的目录规划
上面介绍了一些概念性的东西,那么项目主要的目录单独提一下,这里的项目目录不适合大型项目,但是需要一个这个过程,来理解每一项的意思以及为什么我们还需要其他技术来解决你遇到的问题,堆技术的做法是不可取的,至少在不疯狂 KPI 模式的情况下。

├── public
│   ├── favicon.ico
│   ├── index.html
│   └── manifest.json
├── server // 网站后端的目录,这里我们不需要关系
├── src // 前端的源代码目录
│   ├── App.js // App 的入口组件
│   ├── apis // API 请求层的相关文件,Ajax 的方法也是需要适配的,比如常见的拦截器做法
│   ├── app.css
│   ├── assets // 一些静态资源
│   ├── components // 包含了业务组件、模块组件、展示组件,这里项目较小的时候不需要划分太细,但是要有这样的分层来组织代码
│   ├── containers // 容器组件,主要的副作用等逻辑组件,基本上是数据初始化、维护一个较顶层的数据入口
│   ├── index.css
│   ├── index.js // 网站的入口 JS 文件,主要是负责组件挂载到 DOM,或者你也可以做一些全局注入的一些操作
│   ├── normalize.css
│   ├── registerServiceWorker.js
│   ├── smarty // 基础组件的目录,这里我叫它 smarty,命名空间使用 st-,这个随你高兴
│   ├── stores // 数据操作的主要聚焦地方,每一个 Store 都能是一个事件订阅者,用于连接 React View 组件
│   └── utils // 一些工具辅助函数,目前我这里没有使用,真实项目肯定会用上的

(4) 开始思考要如何开始写代码

1. 需要一个 React 的容器组件来渲染我想要的一个功能模块;
2. 功能模块的数据需要一个地方进行管理。

要解决第一个问题,假设我们的容器组件叫 EmployeeManage,那么在最外层的 App 组件中应该声明要渲染它,代码就会是这样:

class App extends Component {
    render() {
        return (
            <div className="App">
                <EmployeeManage />
            </div>
        );
    }
}

好了,假设这样会出现最初的那个效果图的样子,那么数据并不想写的太过于零散,所以我定义了一个 Store 类进行管理,为什么是类呢?现在不是流行 Redux 之类函数式的么?一是在最开始学习的时候增加太多技术栈心累,二是不一定要用 Redux 我们才能写好 React,三是感觉也不太必要就我们目前的需求来看,四是我就想最初简简单单的。
但是现在我们是数据驱动方式的编程,数据变了来通知 React 的 state 变了然后 React 去帮我们做视图的更新,所以,我们的 Store 得是一个基于事件的类,要有事件应该有的特征:监听。所以最后我需要一个 EmployeeStore

// 用下自带的,你也可以自己实现一个简单的
import EventEmitter from 'events';
import assign from 'object-assign';

const state = {};

const EmployeeStore = assign({}, EventEmitter.prototype, {
    // 把容器组件的 this.state 在这里管理
    getState() {
        return state;
    }
});

原始是原始了一点,但是应该很好理解,那就是我的 EmployeeStore 拥有了 EventEmitter.prototype 的东西,比如常用的 on(), off(), emit() 等方法来实现事件特性。
然后我们需要把 EmployeeManageEmployeeStore 连接起来,最简单的连接像这样子:

class EmployeeManage extends React.Component {
    constructor() {
        super();
        // 看这里
        this.state = EmployeeStore.getState();
    }
}

连接了这个基础的东西,我们的 EmployeeStore 不是还可以订阅事件么?然后数据修改了我们就触发一下订阅的事件去告诉 EmployeeManage 然后通过 this.setState 去更新视图即可,整个关系如下:

看图可能就更直观的知道数据和组件之间的关系了,用过 Flux 可能可以发现还比较像,但是这是两个不同的理念,我这里只是一个最基础的事件系统,所以会特别简单。

我们现在来订阅一个名为 updateList 的事件,用来表示表格中需要展示每条数据。我们需要在 EmployeeManage 中加入下面的代码:

componentDidMount() {
    EmployeeStore.on('updateList', this.handleUpdateList);
}

componentWillUnMount() {
    EmployeeStore.off('updateList', this.handleUpdateList);
}
    
handleUpdateList(list) {
    this.setState(prevState => {
        return {
            list: list,
        };
    });
}

这三个方法能跟上面的图对应一下,就对应上了 EmployeeManageComponent,那么我们的 Store 需要怎么做呢?

getList() {
    // API 请求列表数据的方法,返回一个 Promise
    EmployeeApi.get().then(result => {
        if(result.status === 200) {
            // 刚好,就通知了 EmployeeManage 说我数据获取成功了,可以更新视图了
            this.emit('updateList', result.data);
        }
    });
},

以上就完成了连接工作了,基本上剩下的就是码代码,往上累积功能。
先写到这里吧,太长看着也累,分一下章节吧~其实架子已经差不多了,剩下的就是写功能点了。如果觉得看文章太慢可以直接看源码可能会更快更直接一点,没有数据层,其实并不是太好,先理解视图和关系吧。

2018年2月3日星期六

React 导读(二)

前言

在上篇文章React 导读(一)中学习到了写第一个 Web 组件,这篇文章将继续之前的目录,开始新的知识点补充:

  1. React 如何编写 Hello World!
  2. React 中三个最基础、最重要的东西
  3. React 中的 JSX
  4. 你的第一个 Web 组件
  5. React 中最开始需要关注的生命周期
  6. React 一个组件集合的简单交互
  7. React 开始一个项目的一点建议
  8. React 简单的项目结构组织

五、React 中最开始需要关注的生命周期

其实在学习 React 之前,就应该了解目前前端推荐的是组件化开发的方式,React 是让组件化更加简单的库。那么组件开发必不可少的就是生命周期,说直白一点就是运行组件的过程是 React 来做,运行过程中需要有一些代码钩子来让我们去调用,在组件执行的某一个地方去执行我们自己写的代码。这里先介绍拥有的生命周期钩子,下面的方法 constructorrender 不属于生命周期,我按功能分类了一下,也就是学习的时候不一定要按部就班,应该以学习之后能真正写一些东西为目标:

(1) 与组件挂载相关的方法,包括构造函数

  • constructor
  • componentWillMount
  • componentDidMount
  • componentWillUnmount

最常用的生命周期应该是最后 2 个,constructorcomponentWillMount 目前先理解成能满足的功能大体相同,如果这里解释太复杂不太好。

对于最开始关注的是:this.state 的初始化以及 ajax 在哪里请求。

this.state 在 constructor 进行初始化,ajax 推荐在 componentDidMount 中进行请求。

  • componentDidMount 就是在组件已经挂载到 DOM 中后的钩子,可以理解为 jQuery 中提供的 ready 方法。
  • componentWillUnmount 是在组件即将被卸载前一刻的钩子,一般用于取消 componentDidMount 中订阅的事件等作用,清理一些不要的变量等,避免内存泄漏。

下面通过一个简单的例子说明一下:

先有一个 foods.json 文件来模拟请求的后台数据。

[
    {
        "id": 1,
        "name": "香蕉"
    },
    {
        "id": 2,
        "name": "苹果"
    },
    {
        "id": 3,
        "name": "猕猴桃"
    }
]
// 1. 挂载相关的生命周期
class CycleMount extends React.Component {
    constructor() {
        super();
        this.state = {
            foods: []
        };

        console.log('1. constructor 执行了...');
    }
    componentDidMount() {
        // 这里使用原生的 fetch API 进行 ajax 请求,你也可以使用 $.ajax 进行请求,原理是一样的,重点是关注 setState 的地方
        fetch('/mock/foods.json',
            {
                method: 'GET',
                headers: new Headers({
                    'Accept': 'application/json'
                })
            }
        ).then(dataResult => {
            if(dataResult.status === 200) {
                return dataResult.json();
            } else {
                return [];
            }
        }).then(data => {
            // 这里的 data 就是 foods.json 里面的数据
            // 调用 setState 来更新 render 里面调用的 this.state 的值
            this.setState({
                foods: data
            });
        });

        console.log('2. componentDidMount 执行了...');
    }
    render() {
        // foods 是一个数组,map 方法是数组自带的方法,可以查询相关 api
        const foodItems = 
            this.state.foods.map(food => {
                return (<li key={food.id}>{food.name}</li>);
            });

        // 这里是返回的最终组件结构
        return (
            <ul>
                {foodItems}
            </ul>
        );
    }
}

上面有了完整的注释,也能看到基本上项目中可能会将代码写到何处,我也打了两个日志,来识别到底是谁先执行,结果可以自己运行一下,执行顺序就是我标记的1,2。

好了,基本的学习了,可以自己动手试试订阅一个事件,然后在卸载的时候取消这个事件。

(2) 优化相关

  • shouldComponentUpdate

这个方法比较重要,但是我这里不会介绍得太过于复杂,太复杂只会让重要的部分不那么突出。这个方法的返回值是一个 boolean 类型,分别代码的意义:

  • true 组件应该更新,执行 render 方法以及相关生命周期;
  • false 组件状态没有更新,不执行 render 等方法,意味着网页界面不会改变。

那么它直观上的作用是能够通过返回值来决定界面是否改变,实际的意义就是当我们知道当前 oldState = this.state 的值和新的 newState = this.state 值完全相等的时候(或者是新传入的 props)就不用再浪费性能去重新渲染组件了。

API 上的定义是这样的:

/* nextProps: 新的 props, nextState: 新的 state */
shouldComponentUpdate(nextProps, nextState): boolean

举个例子来直观说明一下:


class ComponentOptimize extends React.Component {
    state = {
        count: 0
    };
    shouldComponentUpdate(nextProps, nextState) {
        // 当 count > 10 的时候就不能再重新渲染组件了
        if(nextState.count > 10) {
            return false;
        }

        return true;
    }
    handleUpdateCount() {
        console.log('我点了一下哦!');
        this.setState(prevState => {
            return {
                count: prevState.count + 1
            };
        });
    }
    render() {
        return (
            <div onClick={this.handleUpdateCount.bind(this)} style={{cursor: 'pointer'}}>
                <h1>{this.state.count}</h1>
            </div>
        );
    }
}

你会发现 10 过后界面就没有再更新过了,这样应该很直观了。

(3) Props 相关的生命周期

  • componentWillReceiveProps

这个主要是在组件的 props 传入新的值后被调用,不管是不是传的一样的值或者 shouldComponentUpdate 返回了 false,看下例子吧:

class Cat extends React.Component {
    componentWillReceiveProps(nextProps) {
        console.log('改一次我执行一次!');
    }
    shouldComponentUpdate(nextProps, nextState) {
        // 改的名字一样的时候
        return this.props.name !== nextProps.name;
    }
    render() {
        console.log('猫改了一次名字!');
        return (
            <h1>我有新名字了!{this.props.name}</h1>
        );
    }
}

class App extends React.Component {
    state = {
        catName: '噜噜'
    };
    handleChangeCatName() {
        const catNames = ['噜噜', '小白', '小黄', '小黑', '皮卡丘'];
        const catIndex = this.getSomeOneIndex();

        this.setState({
            catName: catNames[catIndex]
        });
    }
    getSomeOneIndex() {
        return Math.floor(Math.random() * 5 + 0);
    }
    render() {
        return (
            <div>
                {/* 给 Cat 传新的名字 */}
                <Cat name={this.state.catName} />
                <button onClick={this.handleChangeCatName.bind(this)}>点我给猫咪取新名字!</button>
            </div>
        );
    }
}

最后肯定每次点击按钮都会输出这句的结果 console.log('改一次我执行一次!');

(4) 更新组件相关

  • componentWillUpdate
  • componentDidUpdate

因为都是讲解 API,所以国际惯例的先看下 API 的定义吧:

// 组件更新前执行
componentWillUpdate(nextProps, nextState)
// 组件更新后执行
componentDidUpdate(prevProps, prevState)

可以从定义中看出,它们都接受了两个参数:props && state,不过看变量前缀能够联想点什么。

暂时想不到什么实际项目的例子,随便假设点内容吧。不过这里需要注意的地方是:

  • 1. 这两个方法里面都不要调用 setState!
  • 2. 第一次初始化组件 render 的时候不会执行
  • 3. shouldComponentUpdate 返回 false 不会执行

第一条的原因:容易形成一个递归的调用,不作就不会死...所以尽量不要在这里调~目前还没有碰到需要在这里调的需求。
第二条的原因:额,说好的更新才调,初始化不调用是符合逻辑的。
第三条的原因:额,这 2 个钩子是与组件更新相关的,所以也符合逻辑的,组件是否更新就是靠 shouldComponentUpdate 返回值。

在上面 Cat 的例子中加入下面的代码可以看下结果:

componentWillUpdate() {
    console.log('componentWillUpdate 执行了!')
}
componentDidUpdate() {
    console.log('componentDidUpdate 执行了!')
}

(5)组件错误

  • componentDidCatch

就是在组件发生异常的时候可能会被调用的钩子,需要注意的有下面的地方:

  • 只能在父级组件捕获子组件的异常;
  • 如果异常被 try...catch 包裹父级组件的钩子就不会执行了。

看个例子吧:

class Cat extends React.Component {
    componentWillReceiveProps(nextProps) {
        // 这里手动抛一个异常,触发我们的钩子 componentDidCatch
        throw new Error('miao miao~');
    }
    render() {
        let miao = this.props.name;

        return (
            <div>
                {miao}
            </div>
        );
    }
}

class App extends React.Component {
    state = {
        catName: '噜噜',
        isError: false,
    };
    handleChangeCatName() {
        const catNames = ['噜噜', '小白', '小黄', '小黑', '皮卡丘'];
        const catIndex = this.getSomeOneIndex();

        this.setState({
            catName: catNames[catIndex]
        });
    }
    getSomeOneIndex() {
        return Math.floor(Math.random() * 5 + 0);
    }
    componentDidCatch(error, info) {
        console.log(error, info);
        if(error) {
            // 如果有错误信息,就重新渲染一下组件,可能是更好的交互
            this.setState({
                isError: true
            });
        }
    }
    render() {
        return (
            <div>
                <Cat name={this.state.catName} />
                {!this.state.isError ?
                    <button onClick={this.handleChangeCatName.bind(this)}>点我给猫咪取新名字!</button> :
                    <p>不要奴才给我取名字了!</p>
                }
            </div>
        );
    }
}

(6) 渲染相关

  • render

额...这个不想写了,先睡觉吧~应该写了这么多个小例子也差不多了~可以动手试试哦!还不清楚的可以 Google 一下,你就知道。

生命周期很重要,其实学到这里也差不多可以上手写点项目熟练一下了,其他的更多是思维和编程方面的东西,周期的篇幅单独来一篇吧~其他主题之后再继续吧!

2018年2月1日星期四

React 导读(一)

前言

写这篇文章的主要目标是让初学者更快的上手 React 的项目开发,能有一个循循渐进的理解过程。需要有一定的 JavaScript 基础和 NPM 的使用经验。不多说了,下面会按这个顺序进行介绍:

  1. React 如何编写 Hello World!
  2. React 中三个最基础、最重要的东西
  3. React 中的 JSX
  4. 你的第一个 Web 组件
  5. React 中最开始需要关注的生命周期
  6. React 一个组件集合的简单交互
  7. React 开始一个项目的一点建议
  8. React 简单的项目结构组织

开始前需要安装的环境:node.js、yarn

一、React 如何编写 Hello World!

1.使用脚手架直接避开环境搭建的问题

// 安装脚手架
➜ npm install -g create-react-app

2.使用脚手架创建项目

// react-study 是项目的根文件夹
➜ create-react-app react-study

// 执行后的第一行提示语,会提示创建的完整路径
Creating a new React app in /Users/lulin/Desktop/react-study.

// 安装成功后会提示下面的内容
Success! Created react-study at /Users/lulin/Desktop/react-study
Inside that directory, you can run several commands:

// 使用 yarn 启动项目
yarn start
    Starts the development server.
// 要发布项目的时候运行
yarn build
    Bundles the app into static files for production.
// 做测试的时候执行,目前没用
yarn test
    Starts the test runner.
// 可以自定义配置,目前也不用
yarn eject
    Removes this tool and copies build dependencies, configuration files
    and scripts into the app directory. If you do this, you can’t go back!

We suggest that you begin by typing:
  cd react-study
  yarn start

Happy hacking!

3.使用 Visual Studio Code 打开 react-study

先只需要关注 src 目录中的 index.js,如下:

├── src
│   ├── App.css
│   ├── App.js
│   ├── App.test.js
│   ├── index.css
│   ├── index.js // 代码的入口文件
│   ├── logo.svg
│   └── registerServiceWorker.js

4.修改 index.js

删除 index.js 中所有的内容,贴以下代码运行:

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(
    <h1>Hello World!</h1>,
    document.getElementById('root')
);

ReactDOM.render 方法2个参数,第一个参数就是要渲染的界面结构或者一个 React 组件,第二个参数是要把这个结构渲染到真实网页 DOM 的什么位置。所以这份代码的结果就是在 id=root 的DOM节点下渲染出来 Hello World!

二、React 中三个最基础、最重要的东西

一中介绍了 React 能够渲染一个 HTML 到指定的 DOM 中,但是 React 发明出来主要不是做这个事情的,因为这个事情可以直接通过 原生 JavaScript 的 innerHTML 也能实现。

React 主要作用可以认为是以下几点:
(1) 使用数据来驱动界面更新;
(2) 使用单向变化的数据来让 BUG 更好调试一点;
(3) 更方便、更声明式的编写 Web 组件;

那么这节主要介绍三个东西:
(1) state
(2) props
(3) setState 方法

如果要实现一个简单的加法器,这三个东西已经可以很好实现:

根据上面标的数字:
(1) this.state 里面有一个属性叫 count,这个属性能够通过 this.setState 方法重新传入一个对象来重新赋值。赋值的同时 render() 方法中 this.state.count 也会跟随自动变化,最后体现到网页上。这就是 state 属性的作用。
(2) this.setState() 方法接收一个新对象来重新赋值 this.state
(3) this.setState() 也接收一个函数,这个回调函数这里我默认有一个 prevState 参数,表示之前的 this.state 的值,这个函数的返回值就是最新的 this.state

大家还应该注意 button 上绑定的 onClick 事件,这就是跟 DOM 上直接绑定事件的写法一样(目前先这样理解),不过需要都写成驼峰标识。

那么 statesetState 都介绍了,props 又是什么呢?你可以暂时理解成一个组件外面传递的属性。
还是以计数器的代码为例子,简单修改一下:

class Counter extends React.Component {
    state = {
        count: 0
    };
    // 加 1
    onAdd() {
        this.setState({
            count: this.state.count + 1
        });
    }
    // 减 1
    onSub() {
        this.setState(prevState => {
            return {
                count: prevState.count - 1
            };
        });
    }
    render() {
        return (
            <div>
                {/* 这里的 this.props 属性 */}
                <h1>{this.props.name}</h1>
                <button onClick={this.onSub.bind(this)}>-</button>
                <span>{this.state.count}</span>
                <button onClick={this.onAdd.bind(this)}>+</button>
            </div>
        );
    }
}

// Counter 组件传了一个 name 属性
ReactDOM.render(
    <Counter name="计数器" />,
    document.getElementById('root')
);

这里注意代码中的注释,应该很直观了,我们在组件上添加的属性,都能在组件里面通过 this.props 属性获取到,拿一个其他方式来比喻,就相当于函数的参数,参数传递进函数,函数内部可以使用。但是不同的是 this.props 在组件内部是只读的。

看到这里,其实你已经能够使用 React 来构造一个网页了,是不是很简单,就三个东西,加上一个 ReactDOM.render 方法。当然,如果 ES6 不熟悉的话可能还是麻烦,但是这是必须要去学习和克服的,因为目前已经是主流而且是进入了规范的东西。

三、React 中的 JSX

接触 React 你肯定会问为什么要用 JSX,JSX 到底是什么。其实非官方的我只想这么解释,就是一个编写视图的模版而已,语法也不复杂,列下:
(1) 基本上是使用原始的 HTML;
(2) 事件绑定方法使用驼峰方式;
(3) 要插入 JavaScript 代码需要 {} 包裹,里面的代码就是原生的 JavaScript 代码;
(4) 避开一些 JavaScript 关键字,比如:class 要写成 className。

上面第二节的计数器中,render() 方法就是编写 JSX 的主要位置,其实 JSX 可以编写在 React 代码中的任意位置,但是推荐不要写得太过于零散。

其实理解这几点就已经足够了,具体可以看一下官方文档 JSX-简介

JSX 最终编译后也就是原生的 JavaScript 代码

四、你的第一个 Web 组件

学习了上面这些知识后,其实我们就已经能够封装一个简单的组件的,第一个我这里先以 CheckBox 为例子,比较简单也很常用,应该比较适合。先来分析下这个小组件最基础的功能:就是点击选中和取消选中,模拟的话可以通过换图来实现,就是切换 DOM class。那么我们开始动手吧!

class CheckBox extends React.Component {
    state = {
        checked: false // 默认没有选中
    };
    // 交替(选中/没有选中)的状态
    onClickCheckbox() {
        this.setState(prevState => {
            return {
                checked: !prevState.checked
            };
        });
    }
    render() {
        const checkboxClassArr = ['ui-checkbox'];
        // 如果选中,就添加一个 checked class 来给 css 做样式
        if(this.state.checked) {
            checkboxClassArr.push('checked');
        }

        // 组合最后的 class 结果
        const checkboxClass = checkboxClassArr.join(' ');

        return (
            <div onClick={this.onClickCheckbox.bind(this)}>
                {/* 这里模拟图标 */}
                <span className={checkboxClass}>
                    <i className="icon-checkbox"></i>
                </span>
                {/* 这里模拟图标内容 */}
                <span>{this.state.checked ? '选中' : '没有选中'}</span>
            </div>
        );
    }
}

这样一个简单的组件就已经完成了!效果如下:

今天就写到这里睡觉吧~可以动手试试,来点感觉哦~这基本是最常用的一些概念和意义了,有兴趣可以提前阅读中文官方文档,已经改版体验好多了,慢慢读下来应该很好理解。

2017年10月23日星期一

组件化通用模式

一、前言

模式是一种规律或者说有效的方法,所以掌握某一种实践总结出来的模式是快速学习和积累的较好方法,模式的对错需要自己去把握,但是只有量的积累才会发生质的改变,多思考总是好的。(下面的代码实例更多是 React 类似的伪代码,不一定能够执行,函数类似的玩意更容易简单描述问题)

二、前端的关注点迁移

这篇文章主要介绍现在组件化的一些模式,以及设计组件的一些思考,那么为什么是思考组件呢?因为现在前端开发过程是以组件为基本单位来开发。在组件化被普及(因为提及的时间是很早的或者说有些厂实现了自己的一套但是在整个前端还未是一种流行编写页面的单元)前,我们的大多数聚焦点是资源的分离,也就是 HTML、CSS、JavaScript,分别负责页面信息、页面样式、页面行为,现在我们编程上的聚焦点更多的是聚焦在数据组件

但是有时候会发现只关心到这一个层级的事情在某些业务情况下搞不定,比如组件之间的关系、通信、可扩展性、复用的粒度、接口的友好度等问题,所以需要在组件上进行进一步的延伸,扩展一下组件所参考的视角,延伸到组件模块组件系统的概念来指导我们编写代码。

概念可能会比较生硬,但是你如果有趣的理解成搭积木的方式可能会更好扩展思路一点。

三、数据之于组件

在说组件之前,先来说下数据的事情,因为现在数据对于前端是很重要的,其实这是一个前、后端技术和工作方式演变形成的,以前的数据行为和操作都是后端处理完成之后,前端基本拿到的就是直接可用的 View 展示数据,但是随着后端服务化,需要提供给多个端的数据以及前后端分离工作模式的形成,前端就变得越来越复杂了,其实 SPA 的形成也跟这些有一定关系,一是体验可能对于用户好,二是演变决定了这种方式。此时,前端的数据层就需要设计以及复用一些后端在这一层级的成熟模式,在这里就产生了一种思想的交集。

比如现在有一个 RadioGroup 组件,然后有下面 2 种数据结构可以选择:

items = [{
    id: 1,
    name: 'A',
    selected: true
}, {
    id: 2,
    name: 'B',
    selected: false
}];
data = {
    selected: 1
    items: [{
        id: 1,
        name: 'A'
    }, {
        id: 2,
        name: 'B'
    }]
};

那么我们的组件描述(JSX)会怎么写呢?
第一种:

items.map(item =>
    return <CheckBox key={`checkbox-${item.id}`}
                label={item.name}
                selected={item.selected}
                onClick={this.handleClick} />
);

第二种:

data.items.map(item => 
    const isSelected = item.id === data.selected;
    
    return <Checkbox key={`checkbox-${item.id}`
                label={item.name}
                selected={isSelected}
                onClick={this.handleClick}/>
);

当然,数据结构的选择上是根据需求,因为不同的数据结构有不同的优势,比如这里第二种类似 Dict 的查询很方便,数据也很干净,第一种渲染是比较直接的,但是要理解组件的编写方式其实很大程度上会跟数据产生一种关系,有时候编写发现问题可以返过来思考是否换种结构就变简单了。

数据就谈这些吧,不然都能单独开话题了,接下来看下组件,如果要学习模式就需要采集样本然后去学习与总结,这里我们来看下 Android && iOS 中的组件长什么样子,然后看是否能给我们日常编写 Web 组件提供点灵感,篇幅有限,本来是应该先看下 GUI 的方式。

四、iOS 端的组件概览

假设,先摒弃到 Web 组件的形态比其他端丰富,如果不假设那么这套估计不是那么适用。

4.1 iOS

iOS 的 View 声明能够通过一个故事板的方式,特别爽,比如这里给按钮的状态设定高亮、选中、失效这种,方便得很。

看完界面,直接的感觉下,然后我们来看下这个故事板的源码,上面是 XML 的描述,描述了组件的 View 有哪些部件以及 ViewController 里面映射的属性,用来将 View 和 ViewController 进行解耦。

<!-- 结构描述 -->
<scenes>
  <scene sceneID="tne-QT-ifu">
    <objects>
      <viewController title=“Login" customClass="ViewController">
        ...
        <view key="view" contentMode="scaleToFill"></view>
        ...
        <!-- 这里就是描述 vm 关联对象的地方,ios 里面可能称之为 outlet -->
        <connections>
          <outlet property="passwordTextField"/>
          <outlet property="tipValidLabel"/>
        </connections>
      </viewController>
    </objects>
  </scene>
</scenes>

<!-- 状态 & 样式描述 -->
<!-- 单独一个 button 组件描述 -->
<button>
  <state key="normal" title="Login">
      <color key="titleColor" red="1" green="1" blue="1" alpha="1"/>
  </state>
</button>

我这里定义的按钮状态、颜色都在这里,分别给他们命名:结构描述样式描述

那么具体怎么给用户交互,比较编程化的东西在 ViewController,来看下代码:

// 数据行为描述
// connection 中关联的钩子
@IBOutlet private weak var passwordTextField: UITextField!
@IBOutlet private weak var tipValidLabel: UILabel!

// 一个密码输入框的验证逻辑,最后绑定给 tipValidLabel、loginButton 组件状态上
let passwordValid: Observable<Bool> = passwordTextField.rx.text.orEmpty
    .map { newPassword in newPassword.characters.count > 5 }

passwordValid
    .bind(to: tipValidLabel.rx.isHidden)
    .disposed(by: disposeBag)

passwordValid
    .bind(to: loginButton.rx.isEnabled)
    .disposed(by: disposeBag)

上面代码整体可以看做是响应式的对象,绑定3个组件之间的交互,密码不为空以及大于5个字符就执行 bind 地方,主要是同步另外2个组件的状态。其实也不需要看懂代码,这只是为了体会客户端组件的方式的例子,ViewController 我这里就叫:数据行为描述。这样就有组件最基本的三个描述了:结构、样式、数据行为,虽然样本不多,但是这里直接描述它们就是一个组件的基本要素,整个故事板和 swift 代码很好的描述。

五、什么是组件?

5.1 组件描述
  1. 结构描述
  2. 样式描述
  3. 数据描述

对于组件来说,也是一份代码的集合,基本组成要素还是需要的,但是这三种要素存在和以前的 HTML, CSS, JS 三种资源的分离是不一样的,到了组件开发,更多的是关注如何将这些要素连接起来,形成我们需要的组件。

比如 React 中对这三要素的描述用一个 .js 文件全部描述或者将结构、数据包裹在一起,样式描述分离成 .<style> 文件,这里就可能会形成下面 2 种形式的组件编写。

=> 3 -> (JSX + styled-components)

// 组件样式
const Title = styled.h1`
    font-size: 1.5em;
    text-align: center;
`;

// 组件内容
<Title>Hello World!</Title>

=> 2 + 1 -> (JSX + CSS Module)

export default function Button(props) {
    // 分离的样式,通过结构化 className 来实现连接
    const buttonClass = getClassName(['lv-button', 'primary']);

    return (
        <button onClick={props.onClick} className={buttonClass}>
            {props.children}
        </button>
    );
}

可能最开始很多不习惯这样写,或者说不接受这类理念,那么再看下 Angular 的实现方式,也有 2 种:

(1) 采用元数据来装饰一个组件行为,然后样式和结构能够通过导入的方式连接具体实现文件。

@Component({
    selector: 'app-root',
    // 结构模板
    templateUrl: './app.component.html',
    // 样式模板
    styleUrls: ['./app.component.css']
})
// 等同于上面描述的 iOS 组件的 ViewController
export class AppComponent { }

(2) 与第一种方式不同的地方是能够直接将结构和样式写到元数据中。

@Component({
    selector: 'app-root',
    template: `
        <style>
            h1 { font-weight: normal; }
        </style>
        
        <h1>{{title}}</h1>
        <ul>
            <li *ngFor="let item of items">{{ item }}</li>
        </ul>
    `,
    // styles: ['h1 { font-weight: normal; }']
})
export class AppComponent {
    title = 'Hello Angular';
    items: number[] = [1, 2, 3];
}

无论实现的形式如何,其实基本不会影响太多写代码的逻辑,样式是目前前端工程化的难点和麻烦点,所以适合自己思维习惯即可。这里需要理解的是学习一门以组件为核心的技术,都能够先找到要素进行理解和学习,构造最简单的部分。

5.2 组件特性

虽然有了描述一个组件的基本要素,但是还远不足以让我们开发一个中大型应用,需要关注其他更多的点。这里提取组件基本都有的特性:

1. 注册组件

将组件拖到故事板

2. 组件接口(略)

别人家的代码能够修改组件的部分

3. 组件自属性

组件创建之初,就有的一些固定属性

4. 组件生命周期

组件存在到消失如何控制以及资源的整合

5. 组件 Zone

组件存在于什么空间下,或者说是上下文,很可能会影响到设计的接口和作用范围,比如 React.js 可用于写浏览器中的应用,React Native 可以用来写类似原生的 App,在设计上大多数能雷同,但是平台的特殊地方也许就会出现对应的代码措施)

这些主要就是拿来帮助去看一门不懂的技术的时候,只要是组件的范围,就先看看有没有这些东西的概念能不能联想帮助理解。

具体来看下代码是如何来落地这些模式的。

1.组件注册,其实注册就是让代码识别你写的组件

(1) 声明即定义,导入即注册

export SomeOneComponent {};
import {SomeOneComponent} from 'SomeOneComponent';

(2) 直接了当的体现注册的模式

AppRegistry.registerComponent('ReactNativeApp', () => SomeComponent);

(3) 拥有模块来划分组件,以模块为单位启动组件

@NgModule({
    // 声明要用的组件
    declarations: [
        AppComponent,
        TabComponent,
        TableComponent
    ],
    // 导入需要的组件模块
    imports: [
        BrowserModule,
        HttpModule
    ],
    providers: [],
    // 启动组件, 每种平台的启动方式可能不一样
    bootstrap: [AppComponent]
})
export class AppModule { }

2.组件的自属性

比如 Button 组件,在平时场景下使用基本需要绑定一些自身标记的属性,这些属性能够认为是一个 Component Model 所应该拥有,下面用伪代码进行描述。

// 将用户的 touch, click 等行为都抽象成 pointer 的操作
~PointerOperateModel {
    selected: boolean;
    disabled: boolean;
    highlighted: boolean;
    active: boolean;
}

ButtonModel extends PointerOperateModel { }
LinkModel extends PointerOperateModel { }
TabModel extends PointerOperateModel { }
...

// 或者是具有对立的操作模型
~ToggleModel {
    on: boolean;
}

OnOffModel extends ToggleModel { }
SwitchModel extends ToggleModel { }
MenuModel extends ToggleModel { }

...

// 组件的使用
this.ref.attribute = value;
this.ref.attribute = !value;

这些操作如果需要更少的代码,也许能够这样:

~ObserverState<T> {
    set: (value: T) => void;
    get: (value: T) => T;
    changed: () => void;
    cacheQueue: Map<string, T>;
    private ___observe: Observe;
}

Model extends ObserverState { }

基本上组件的这些属性是遍布在我们整个代码开发过程中,所以是很重要的点。这里还有一个比较重要的思考,那就是表单的模型,这里不扩展开来,可以单独立一篇文章分析。

3.组件的声明周期

与其说是生命周期,更多的是落地时候的代码钩子,因为我们要让组件与数据进行连接,也许需要在特定的时候去操作一份数据。在浏览器(宿主)中,要知道具体是否已经可用是一个关键的点,所以任何在这个平台的组件都会有这类周期,如果没有的话用的时候就会很蛋疼。

最简单的路线是:

mounted => update => destory

但是往往实际项目会至少加一个东西,那就是异常,所以就能够开分支了,但是更清晰的应该是平行的周期方式。

mounted => is error => update => destory

4.组件 Zone

组件在不同的 Zone 下可能会呈现不同的状态,这基本上是受外界影响的,然后自己做出反应。这里可以针对最基本的组件使用场景举例,但是这个 Zone 是一种泛化概念。

比如我们要开发一个弹框组件:Modal,先只考虑一个最基本需求:弹框的位置,这个弹框到底挂载到哪儿?

  1. 挂载到组件内部;
  2. 挂载到最近的容器节点下;
  3. 挂载到更上层的容器,以至于 DOM 基础节点。

每一种场景下的弹框,对于每种组件的方案影响是不同的:

  1. 组件内部,如果组件产生了 render,很可能受到影响;
  2. 挂载到最近的容器组件,看似问题不大,但是业务组件的拆、合是不定的,对于不定的需求很可能代码会改变,但是这种方案是不错的,不用写太远,当然在 React 16 有了新的方案;
  3. 挂载到更高的层级,这种方案适合项目对弹框需求依赖比较强的情况吧,因为受到的影响更小,弹框其实对于前端更强调的是一种渲染或者说是一种交互。

5.组件的递归特性

组件能够拥有递归是一个很重要的纵向扩展的特性,每一种库或者框架都会支持,就要看支持对于开发的自然度,比如:

// React
this.props.children

// Angular
<ng-content></ng-content>

基本上可以认为现在面向组件的开发是更加贴近追求的设计即实现的理想,因为这是面向对象方法论不容易具备的,组件是一种更高抽象的方法,一个组件也许会有对象分析的插入,但是对外的表现是组件,一切皆组件后经过积累,这将大大提升开发的效率。

六、如何设计组件?

经过前面的描述,知道了组件的概念和简单组件的编写方法,但是掌握了这些东西在实际项目中还是容易陷入蛋痛的地步,因为组件只是组成一个组件模块的基础单元,慢慢的开发代码的过程中,我们需要良好的去组织这些组件让我们的模块即实现效果的同时也拥有一定的鲁棒性和可扩展性。这里将组件的设计方法分为 2 个打点:

  1. 横向分类
  2. 纵向分层

其实这种思路是一直以来都有的,这里套用到平时自己的组件设计过程中,让它帮助我们更容易去设计组件。

这种设计的方法论是一个比较容易掌握和把握的,因为它的模型是一个二维的(x, y)两个方向去拆、合自己的组件。注意,这里基本上的代码操作单元是组件,因为这里我们要组装的目标是模块^0^感觉很好玩的样子,举例来描述一下。

比如我们现在来设计比较常用的下拉列表组件(DropDownList),最简单的有如下做法:

class DropDownList {
    render() {
        return (
            <div>
                <div>
                    <Button onClick={this.handleClick}>请选择</Button>
                </div>
                <DropDownItems>
                {this.props.dataSource.map((itemData, index) => <DropDownItem></DropDownItem>)}
                </DropDownItems>
            </div>
        );
    }
}

现在自己玩的往上加点需求,现在我需要加一个列表前面都加一个统一的 icon, 首先我们要做的肯定是要有一个 Icon 的组件,这个设计也比较依赖场景,目前我们先设计下拉。现在就有2种方案:

  1. 在 DropDownList 组件里面加一个判断,动态加一个组件就行;
  2. 重新写一个组件叫 DropDownIconList。

第一种方案比较省事,但是其实写个 if...else... 算是一个逻辑分支的代码,以后万一要加一个 CheckBox 或者 Radio 组件在前面...

第二种方案看上去美好,但是容易出现代码变多的情况,这时候就需要再重新分析需求变化以及变化的趋势。

这时候按垂直和水平功能上,这里拆分 DropDownIconList 组件可以看成一个水平的划分,从垂直的情况来看,将下拉这一个行为做成一个组件叫 DropDown,最后就变成了下面的样子:

class DropDown  {
    render() {
        <div>
            <div>
                <p onClick={this.handleClick}>请选择</p>
            </div>
            <div>{this.props.children}</div>
        </div>
    }
}

class DropDownList {
    render() {
        return (
            <DropDown onClick={this.handleClick} selected={selectedItems}>
                <DropDownItems>
                    {this.props.dataSource.map((itemData, index) => <DropDownItem></DropDownItem>)}
                </DropDownItems>
            </DropDown>
        );
    }
}

class DropDownIconList {
    render() {
        return (
            <DropDown onClick={this.handleClick} selected={selectedItems}>
                <DropDownItems>
                    {this.props.dataSource.map((itemData, index) => <DropDownIconItem></DropDownIconItem>)}
                </DropDownItems>
            </DropDown>
        );
    }
}

这样的缺点就是存在多个组件,也许会有冗余代码,优点就是以后增加类似组件,不会将代码的复杂度都加到一份代码中,比如我要再加一个下拉里面分页、加入选中的项、下拉内容分页、下拉的无限滚动等等,都是不用影响之前那份代码的扩展。

七、让组件连接起来

组件化的开发在结构上是一种分形架构的体现,是一个应用引向有序组件构成的过程。组件系统的复杂度可以理解成 f(x) = g(x) + u(x), g(x) 表示特有功能,u(x)表示功能的交集或者说有一定重合度的集合。组件弹性体现在 u(x) -> 0(趋近)的过程中,这个论点可参考:面向积木(BO)的方法论与分形架构

上面的过程中,有了组件组件模块,既然有了基础的实体,那么他们或多或少会有沟通的需求(活的模块)。基本上现在主流的方案可以用下面的图来表示。

我们提取一下主要的元素:

  1. Component 实体
  2. Component 实体的集合:Container
  3. Action
  4. Action 的操作流
  5. Service Or Store
  6. 状态同步中心
  7. Observable Effect

如果要说单向数据流和双向绑定的体现基本可以理解成体现在虚线框选的位置,如果组件或者Store是一个观察的模型,那么方案实现后就很可能往双向绑定靠近。如果是手动党连接 ViewValue 和 ModelValue,按照一条流下来可以理解成单向流。虽然没有按定义完全约束,但是代码的落地上会形成这种模式,这块细讲也会是一个单独的话题,等之后文章再介绍各种模式。

组件的关系能够体现在包含、组合、继承、依赖等方面,如果要更好的松耦合,一般就体现在配置上,配置就是一种自然的声明式,这是声明式的优势同时也是缺点。

八、组件系统体系

模块的管理中心是看组件系统,这块涉及到更高一层的概念或者说微服务化前端的理念,最后可连接到 FAAS,这里不深入讲解,因为还没有具体案例,暂时还未发现一个更好的落地方法,但是这应该是一个趋势。简单的图形方式表示一个实现关系:

代码可以借鉴这里体会一下:micro-frontends

但是这个方案只能认为它是一个 DEMO,并没有实际的项目价值,要运用的话还需要再结合场景重新思考。

以上是一些对组件的思考,不一定很深入,但是希望能够帮助到刚踏入组件化前端开发的小伙伴~