我们用React官方推荐的Thinking in React的方式来开发一个Deskmark记事本程序。这个程序左边是文章列表,右边是预览和编辑。
- 将原型图分割成不同组件
在原型图上画方块,命名。组件原则,一个组件理想情况下应该只做一件事。如果发现它有过多的功能,就可以分割成更多的子组件。然后就很容易地建立起项目结构。
将所有的组件放到components文件夹下,每个组件对应一个子文件夹,组件命名统一采用index.jsx,样式文件命名统一采用style.css。
components/
Deskmark(整个程序的框架)/
index.jsx
style.css
CreateBar(新建按钮)/
List(左侧文章列表)/
ListItem(左侧列表中的每个条目)/
ItemEditor(右侧文章编辑器,包含保存和取消按钮)/
ItemShowLayer(右侧文章展示,包含编辑和删除两个按钮)/
- 创造无状态函数式组件
ListItem就是典型的例子,它什么都不关心,只接收一个属性、展示一条文章列表。
/*
* @file component Item
*/
// 当声明一个组件的时候,采用下面的顺序规则
// 加载依赖
import React, { PropTypes } from 'react';
// 属性验证
const propTypes = {
item: PropTypes.object.isRequired,
onClick: PropTypes.func.isRequired,
};
// 组件主体,这里是stateless function,所以直接就是一个函数
function ListItem({ item }) {
// 返回JSX结构
return (
<a href="#" className="list-group-item item-component">
<span className="label label-default label-pill pull-xs-right">{item.time}</span>
{item.title}
</a>
);
}
// 添加验证
ListItem.propTypes = propTypes;
// 导出组件
export default ListItem;
同样,List组件也是无状态组件,它只是根据传入的数组展示列表而已,就像是组件组件,将ListItem组件循环输出:
import ListItem from '../ListItem';
...
function List({ items }) {
// 循环插入子组件
items = items.map(
item => (
<ListItem item={item} key={item.id} />
)
);
return (
<div className="list-component col-md-4 list-group">
{items}
</div>
);
}
在循环展示子组件时,必需为每个自组件指定key值,可以保证重新渲染的效率,提高内部Diff算法的效率。
左边的组件已经完成,再来创建右边的组件。右边有ItemShowLayer.jsx和ItemEditor.jsx两个组件。
ItemShowLayer也没什么特殊,只是展示文章标题和内容。只不过要显示的是Markdown转换后的内容,所以需要装一个库来将Markdown格式转化为HTML文档格式:
npm install marked --save
// ItemShowLayer.jsx
import marked from 'marked';
...
function ItemShowLayer({ item }) {
// 如果没有传入Item,直接返回一些静态的提示
if(!item || !item.id) {
return (
<div className="col-md-8 item-show-layer-component">
<div className="no-select">请选择左侧列表里面的文章</div>
</div>
);
}
// 将Markdown转换成HTML
// 注意在渲染HTML代码时使用了描述过的JSX转义写法dangerouslySetInnerHTML
let content = marked(item.content);
return (
<div className="col-md-8 item-show-layer-component">
<div className="control-area">
<button className="btn btn-primary">编辑</button>
<button className="btn btn-danger">删除</button>
</div>
<h2>{item.title}</h2>
<div className="item-text">
<div dangerouslySetInnerHTML={{__html: content}} />
</div>
</div>
);
}
现在,完成的组件还没有添加任何交互,所以上面的编辑和删除两个按钮只是先放在那里,没有触发任何事件。
剩下的无状态组件就不一一写了,可以自己写一下ItemEditor的实现,只不过是一个input框和一个textarea而已。
- 组合无状态组件
新建一个Deskmark组件,作为整个程序的框架,利用一些数据把组件都展示出来,暂时不做任何交互。先不添加组件内部的state,因为交互可以改变组件的state,导致UI的重新渲染。
// Deskmark.jsx
render() {
const items = [
{
"id": "6c84fb90-12c4-11e1-840d-7b25c5ee775a",
"title": "Hello",
"content": "# testing markdown",
"time": 1458030208359
}, {
"id": "6c84fb90-12c4-11e1-840d-7b25c5ee775b",
"title": "Hello2",
"content": "# Hello world",
"time": 1458030208359
}
];
return (
<section className="deskmark-component">
<div className="container">
<div className="row">
<CreateBar />
<List items={items} />
</div>
</div>
</section>
);
}
右边是文章展示区,也可以切换成一个编辑器。暂且把这两个组件都添加到右边。
...
return (
const currentItem = items[0];
<section className="deskmark-component">
<div className="container">
<div className="row">
<CreateBar />
<List items={items} />
<ItemEditor item={currentItem} />
<ItemShowLayer item={currentItem} />
</div>
</div>
</section>
);
- 添加state的结构
Deskmark组件是整个程序的框架,它控制了整个程序的状态。根据程序的静态版本思考一下,都需要什么状态来存储数据呢?state的设计原则是:尽量最简化,遵循DRY(Don't Repeat Yourself)的原则。 - 需要一个数组来存储所有的文章。这一点没有异议,上面静态版本的组件其实已经采用这个结构来渲染组件。
- 需要一个数据来展示已被选中的文章,并且展示在右边。最直观的方法是有一个对象保存展示的内容,就像这样{"id": "...", "title": "...", ...}。这样当然非常直观。那么再想想有没有更优解?选中的内容只是所有文章中的一项,其实不需要把这些数据全部复制下来,只需要保存一个索引,随时从文章列表中取出来就可以。这个索引就是每篇文章的ID,如此用一个selectId就可以表示当前选中的文章。
- 还需要一个数据来表示编辑器状态,表示在编辑状态还是在浏览文章状态。那么狠容易想出用一个布尔值来表达:editing。
经过这样的思考,不难得出整个程序的最后状态如下:
this.state = {
items: [],
selected: null,
editing: false
}
- 组件交互设计
现在,静态组件和程序的state都已经确定,是时候添加交互了。根据原型图和组件传入的回调总结出的交互如下。
- 文章的CRUD操作。1.创建文章(createItem),2.删除文章(deleteItem),3.更新文章(updateItem),4.选择文章(selectItem)。
- 右侧状态栏切换。1.切换到编辑器状态(editItem),2.切换到文章展示状态(cancelEdit)
现在把这些组件的交互操作都添加到Deskmark里
// 安装一个用来生成uuid库
npm install uuid --save
import uuid from 'uuid';
export default class Deskmark extends React.Component {
...
constructor(props) {
super(props);
this.state = {
items: [],
selectId: null,
editing: false
};
}
saveItem(item) {
// item是编辑器返回的对象,里面应该包括标题和内容
// 当前的items state
let items = this.state.items;
item.id = uuid.v4();
item.time = new Date().getTime();
// 新的state
items = [..items, item];
// 更新新的state
this.setState({
items: items
});
}
}
需要注意的一点是,在构造函数中需要bind新建的方法,否则这个方法无法在render中使用。
constructor(props) {
...
this.saveItem = this.saveItem.bind(this);
}
这样就完成了第一个新增文章的方法,其实就是在state的items这个数组中添加一项。举一反三,其他的方法也就不难写了,这些方法只不过是各种各样对状态的操作:
...
// 从左侧列表选择一篇文章
// 将selectId置为选择文章的ID,并且将editing状态置为false
selectItem(id) {
if(id === this.state.selectedId) {
return;
}
this.setState({
selectedId: id,
editing: false
});
}
// 新建一篇文章
createItem() {
// 将editing状态置为true,并且selectedId为null,表示要创建一篇新的文章
this.setState({
selectedId: null,
editing: true
});
}
- 组合为最终版本
现在已经有了静态组件,有了state,还添加了一系列交互操作。下面只要把交互操作和静态组件组合在一起就可以了。现在要做的就是将这些回调一一传入各个组件中,将JSX中的事件交互和这些回调联系起来。
// Deskmark/index.jsx
export default class Deskmark extends React.Component {
...
render() {
let { items, selectId, editing } = this.state;
// 选出当前被选中的文章
let selected = selectedId && items.find(item => item.id === selectedId);
// 根据editing状态来决定是要显示ItemEditing组件还是ItemShowLayer组件,并且将回调方法都传入组件中
let mainPart = editing
? <ItemEditor
item={selected}
onSave={this.saveItem}
onCancel={this.cancelEdit}
/>
: <ItemShowLayer
item={selected}
onEdit={this.editItem}
onDelete={this.deleteItem}
/>;
// 将交互回调添加到组建中
return (
<section className="deskmark-component">
<div className="container">
<CreateBar onClick={this.createIem} />
<List items={this.state.items} onSelect={this.selectItem} />
{mainPart}
</div>
</section>
);
}
...
}
回调已经传入组件,那么再来一一改造静态组件,让它们变得交互起来:
// ItemShowLayer
...
// 不要忘记把传入的回调加入到属性验证中
const propTypes = {
item: PropTypes.object,
onEdit: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
};
function ItemShowLayer({ item, onEdit, onDelete }) {
...
const content = marked(item.content);
return (
<div className="col-md-8 item-show-layer-component">
<div className="control-area">
<button onClick="{() => onEdit(item.id)}" className="btn btn-primary">编辑</button>
<button onClick="{() => onDelete(item.id)}" className="btn btn-danger">删除</button>
</div>
<h2>{item.title}</h2>
<div className="item-text">
<div dangerouslySetInnerHTML={{ __html: content }} />
</div>
</div>
);
...
}
再来改造一个稍微复杂一点的ItemEditor
import React, { PropTypes } from 'react';
const propTypes = {
item: PropTypes.object,
onSave: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
};
class ItemEditor extends React.Component {
render() {
const { onSave, onCancel } = this.props;
const item = this.props.item || {
title: '',
content: '',
};
// 判断是否已经选择了selectId,渲染按钮不同的文本
// let save = () => {
onSave({
...item,
// this.refs可以获取真实的DOM节点,从而取得value
title: this.refs.title.value,
content: this.refs.content.value,
});
};
return (
<div className="col-md-8 item-editor-component">
<div className="control-area">
<button onClick={save} className="btn btn-success">{saveText}</button>
<button onClick={onCancel} className="btn secondary">取消</button>
</div>
<div className="edit-area">
<input ref="title" placeholder="请填写标题" defaultValue={item.title} />
<textarea ref="content" placeholder="请填写内容" defaultValue={item.content} />
</div>
</div>
);
}
}
ItemEditor.propTypes = propTypes;
export default ItemEditor;
到此完成了React的第一个项目,回顾下步骤:
- 先画出程序的Mockup图
- 将Mockup图划分成不同的组件
- 实现静态版本的程序和组件
- 将静态版本组合起来
- 考虑state的组成和实现
- 添加交互方法
- 将这些组合在一起,完成最终的版本