React
可谓如日中天,webpack也风声水起。React刚出来不久就浏览了一遍官网的文档,当时想这个新玩意挺“颠覆”,暂时保持观望好了。直到React Angular Vue
三分天下的时候,还处于观望就不太妥了。再次看完官网的document,尝试实现一个todo应用来实践react。
如果说实现一个Blog是后端工程师入门的第一个应用,那么Todo可谓是前端开发者练手处女项目了。
下面就使用React实现一个简单的todo,实现基本的增删改的功能,其效果请访问react-todo。
创建项目
初始化项目
前端发展太快,从打包工具
到框架/库
都层出不穷,往往一个坑还没爬出来,就掉进了另外的坑。创建的todo
主要采用node
包的方式,使用webpack
打包,具体的js代码使用ES6
的语法。
初始化项目并创建一些基础文件,项目结构大概如下:
~ mkdir todos
~ cd todos && npm init
todos mkdir app app/components
todos touch index.html webpack.config.js
todos touch app/index.js app/components/app.js
todos tree
.
├── app
│ ├── components
│ │ └── app.js
│ └── index.js
├── index.html
├── package.json
└── webpack.config.js
2 directories, 5 files
安装依赖包
初始化项目之后,就需要安装所需要的库及其依赖。npm安装方式可以为开发环境或生产选择所安装的依赖。首先需要安装webpack
和webpack-dev-server
。这两个包需要全局安装,通过 npm install -g webpack webpack-dev-server
,如果已经安装了,可以忽略。
随后将会安装编译ES6和JSX的编译工具babel
。运行下面命令安装。react,react-dom是react的基础库,lodash
则是一个函数库,用于使用ES6的一些新特性。
todos npm install --save react react-dom lodash
上述包是生产发布环境也需要的依赖,下面安装开发环境中使用的打包编译的loader包:
todos npm install --save-dev babel-core babel-loader babel-preset-es2015 babel-preset-react react-hot-loader style-loader css-loader webpack webpack-dev-server
如果一切顺利,npm将会在package.json中显示已经安装好的包。迁移项目的时候,只需要npm install即可。
配置webpack
安装完所需要的依赖之后,配置webpack。webpack的配置比较简单。具体配置如下:
var webpack = require("webpack")
var path = require("path")
module.exports = {
devtool: "inline-source-map",
entry: [
"webpack-dev-server/client?http://127.0.0.1:8080/",
"webpack/hot/only-dev-server",
"./app"
],
output: {
path: path.join(__dirname, "public"),
filename: "bundle.js"
},
resolve: {
modulesDirectories: ["node_modules", "app"],
extensions: ["", ".js"]
},
module: {
loaders: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
loaders: ["react-hot", "babel?presets[]=react,presets[]=es2015"]
},
{
test: /\.css?$/,
exclude: /node_modules/,
loaders: ["style", "css"]
}
]
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin()
]
}
关于webpack的配置,并不是本篇的主题,想要了解更多的详细内容,可以查阅官网的文档。
Hello world
配置了webpack之后,编写html入口和js的入口文件,展示一下hello world啦。
编辑 index.html 文件
index.html
<!DOCTYPE html>
<html>
<head>
<title>React Todos App</title>
</head>
<body>
<div id="app" />
<script src="bundle.js"></script>
</body>
</html>
入口的js文件
index.js
import React from 'react'
import {render} from 'react-dom'
render(<div>hello world</div> document.getElementById('app'))
运行 webpack-dev-server
启动webpack服务器,使用浏览器打开 http://127.0.0.1:8080
就能看见helloworld啦。这里webpack-dev-server是为了监测前端文件的变化,以便实时编译打包前端文件。
React的render方法,将自定义的的component挂载到html中的dom中(div#app)。
React 组件
前面通过react的render方法,创建了一个组件,下面创建更多的组件。编辑 components/app.js
import React from "react"
class App extends React.Component {
render() {
return (
<div>
<h1>React Todo App</h1>
</div>
)
}
}
export default App
然后修改之前的入口文件index.js,将随后创建的App组件渲染到html中。
index.js
import React from "react"
import {render} from "react-dom"
import App from "components/app"
render(<App />, document.getElementById("app"))
刷新浏览器,就能看见新创建的App组件。
组件是React中的重要概念。对于软件界面,按钮,导航,表单这些可视化的界面都可以称之为组件,组件实现了逻辑和功能的封装。就像完积木一样,每个组件都是一个积木,多个积木可以合成一个大的积木,最终实现组件构成的用户界面。
React的组件都是用********大写********的拉丁字母开头,继承自
React.Components
类。render方法用于返回该组件的JSX代码。JSX是Facebook为了配合react定义的一套xml规范。与html及其相似,用于构建组件界面。需要注意,JSX的所有标签必须闭合。return之后必须返回一个组件元素,不能同时返回多个,如果有多个的,需要用div重新包装一次。
编写Todo
todo 列表
配置好基本环境之后,接下来将要完成一个完整的todo应用。这个小应用主要有两个大的组件,一个是用于创建todo条目,另外一个用于展示todo列表。下面先完成todo列表的组件。随着功能的增加,会经常编辑某个文件,文件内容也会变多,在此只会贴出变更的代码部分,不再贴出完整的文件内容,完整的文件内容可以参考源码。
接下来在app.js 文件中定义数据,数据的获取方式很多,假设现在从本地获取数据。通过App这个组件逐渐把数据传递下去。定义了数据,需要借助React的state和props两个属性来实现数据传递。编辑app.js文件如下:
...
import TodoList from "components/todo-list"
const todos = [
{
task: 'Learning React',
isCompleted: true
},
{
task: 'Learning Jsx',
isCompleted: false
},
{
task: 'React in action',
isCompleted: false
}
]
class App extends React.Component {
constructor(props){
super(props)
this.state = {
todos: todos
}
}
render() {
return (
<div>
<h1>React Todo App</h1>
<TodoList todos={this.state.todos}/>
</div>
)
}
}
export default App;
props
和state
是react组件中重要的两个属性。它们本质都是js对象。props常用于存储一些不可变的组件属性,例如函数和方法,state则用于保留一些可变的数据结构,例如实际的数据和状态tag。
上述的代码定义了一些todos数据,然后把这些数据初始化给App组件。再通过TodoList组件的todo props传递给后者。也就是在TodoList内部,它的this.props.todos则为 App组件的this.state.todos。
TodoList是用于展示todo列表的组件。再创建一个文件。
app touch components/todo-list.js
编辑todo-list.js 如下:
todo-list.js
...
import _ from 'lodash'
class TodoList extends React.Component {
renderItem(){
return _.map(this.props.todos, (todo, index) => {
return (
<tr key={index}>
<td>{todo.task}</td><td>{todo.isCompleted ? 'done' : 'undo'}</td>
</tr>
)
})
}
render() {
return (
<table>
<thead>
<tr>
<th>Task</th><th>Action</th>
</tr>
</thead>
<tbody>
{this.renderItem()}
</tbody>
</table>
)
}
}
export default TodoList;
TodoList组件由两部分组成,table的head和body部分。body通过一个表格的行来展示todo的列表内容。通过lodash的map方法,可以迭代一个数组(this.props.todos)对象,然后把todo的列表拼装成表格返回。最后在tbody中调用函数renderItem。至此,大致的一个todo应用轮廓已经成形。接下来将要把TodoList这个组件更细化的拆分。主要拆分为head和item两个组件。
TodoHeader 组件
创建一个组件文件,用于表示todo应用的表头。
app touch components/todo-header.js
修改编辑的todo-list.js 文件
import TodoListHeader from "components/todo-list-header"
class TodoList extends React.Component {
...
render() {
return (
<table>
<TodoListHeader />
<tbody>
{this.renderItem()}
</tbody>
</table>
)
}
}
然后再编辑 todo-list-header.js
import React from 'react'
class TodoListHeader extends React.Component {
render() {
return (
<thead>
<tr>
<th>Task</th>
<th>Action</th>
</tr>
</thead>
)
}
}
export default TodoListHeader
TodoListHeader 组件相当简单,只需要把thead的内容copy即可。
TodoListItem 组件
需要拆分列表组件稍微复杂一点点。因为针对todo的每一个列表,都有修改、删除的操作。因此这些事件可以封装成为一个单独的组件,即item是组件。编辑todo-list.js文件,修改render函数如下:
todo-list.js
...
import TodoListItem from "components/todo-list-item"
class TodoList extends React.Component {
renderItem(){
return _.map(this.props.todos, (todo, index) => {
return (
<TodoListItem todo={todo} key={index}/>
)
})
}
...
}
然后编辑todo-list-item.js文件,增加TodoListItem组件。和TodoListHeader组件类似,将之前renderItem中的jsx拷贝一份,通过this.props读取单条todo的数据即可。
由于 react使用了virtrul-dom来实现操作dom的性能。那么针对一些列表元素的dom,都需要给他们一个id,这个id可以使用 key={index} 来指定。
todo-list-item.js
import React from 'react'
class TodoListItem extends React.Component {
render() {
return (
<tr key={this.props.index}>
<td>{this.props.todo.task}</td><td>{this.props.todo.isCompleted ? 'done' : 'undo'}</td>
</tr>
)
}
}
export default TodoListItem
Todo 创建
完成了todo列表的基本功能,下一步需要实现todo的创建功能。需要引入一个新的组建,TodoCreate。创建一个文件todo-create.js
。编写如下内容:
todo-create.js
import React from 'react';
class TodoCreate extends React.Component {
render() {
return (
<form>
<input type="text" placeholder="What need I do?" ref="createInput" />
<button>Create</button>
</form>
)
}
}
export default TodoCreate
然后编辑app.js 文件,引入TodoCreate 组件。
app.js
import TodoCreate from "components/todo-create"
class App extends React.Component {
...
render() {
return (
<div>
<h1>React Todo App</h1>
<TodoCreate />
<TodoList todos={this.state.todos}/>
</div>
)
}
}
React事件
TodoCreate组件实质是一个表单,一个表单域和提交按钮。button的点击事件会触发form的onsubmit事件。因此需要定义form的事件,同时给表单域提供了一个ref属性,用于react引用表单域对象。
todo-craete.js
class TodoCreate extends React.Component {
render() {
return (
<form onSubmit={this.handleCreate.bind(this)}>
<input type="text" placeholder="What need I do?" ref="createInput" />
<button>Create</button>
</form>
)
}
handleCreate(event){
event.preventDefault()
const task = this.refs.createInput.value
this.refs.createInput.value = ''
}
}
给from增加了onSubmit事件函数handleCreate。handleCreate函数中先把form的默认事件除去,然后通过ref属性获取了表单的值。如果按照之前的编程习惯,此时这里可以处理增加todo的实际操作。可是如果这里增加了todo,那么如何渲染到todo列表的组件中呢?
实际上,TodoCreate和TodoList是同级的组件,他们通信的共同点是通过App组件,并且之前的数据源都是通过App组件往子组件传递。因此可以在App组件中定义函数用于操作todo的数据,子组件只需要在自己的事件函数中调用父组建函数实现数据通信。
React 的事件和原生的js事件很像,只是写法上使用驼峰式,并且还保证了浏览器的兼容性。这样的处理react随处可见,例如后面将会遇到的样式写法。ref是表单中常用的属性,用于引用一个dom元素。
编辑App.js文件
app.js
...
class App extends React.Component {
...
render() {
return (
<div>
<h1>React Todo App</h1>
// 绑定createTask函数给子组件TodoCreate
<TodoCreate createTask={this.createTask.bind(this)}/>
<TodoList todos={this.state.todos}/>
</div>
)
}
// 增加createTask函数用于接受处理TodoCreate组件创建的task数据
createTask(task){
this.state.todos.push({
task: task,
isCompleted: false
})
this.setState({todos: this.state.todos})
}
}
App组件中实现了createTask函数,该函数绑定到TodoCreate组件中,通过后者的handleCreate事件调用,并传递创建的task内容。createTask再把数据重新设置state,以便渲染整个数据变化的组件。之前的handleCreate将改下如下:
todo-create.js
class TodoCreate extends React.Component {
...
handleCreate(event){
event.preventDefault()
const task = this.refs.createInput.value
// 调用App组件的createTask函数用于操作todo数据
this.props.createTask(task)
this.refs.createInput.value = ''
}
}
Todo 修改
完成了Todo的创建,应用的功能算是完成了一半,CURD操作,仅仅是完成了两步,还有最重要的修改和删除两个功能。
修改主要针对的是单条todo内容的数据进行操作,因此大部分逻辑都和TodoListItem组件有关,而基于前面的学习中,TodoCreate中的数据是需要借助App这个组件进行通信,同样TodoListItem中遇到数据的操作,也需要借助App的组件进行操作,比TodoCreate更复杂的情况是,TodoListItem的父组件确实TodoList,因此这个数据流的传递将会被TodoCreate多了一层组件。
action 操作
todo的action中的功能,对于todo列表,action将会提供编辑
和删除
的功能,一旦点击了编辑,将会出现一个表单,同时action将会变成保存
和取消
两个功能。一旦点取消
,action将变成之前的样子。下面先实现这两组action的交互变化。
...
class TodoListItem extends React.Component {
constructor(props){
super(props)
// 借助 isEditing state用于存储修改todo的状态
this.state = {
isEditing: false
}
}
renderActionSection(){
if(this.state.isEditing){
return (
<td>
<button>Save</button>
<button onClick={this.onCancel.bind(this)}>Cancel</button>
</td>
)
}
return (
<td>
<button onClick={this.onEditing.bind(this)}>Edit</button>
<button>Delete</button>
</td>
)
}
render() {
return (
<tr key={this.props.index}>
<td>{this.props.todo.task}</td>
{this.renderActionSection()}
</tr>
)
}
onEditing(){
this.setState({
isEditing: true
})
}
onCancel(){
this.setState({
isEditing: false
})
}
}
把动态变化的action内容抽出之后,点击编辑之后,除了action的按钮变化之外,还需要将task展示的地方变成一个form表单,以便实际修改task内容。因此在展示task内容的时候,需要根据当前的状态(是否是编辑)是否展示表单。
todo-list-item.js
class TodoListItem extends React.Component {
...
renderTaskSection(){
if (this.state.isEditing){
return (
<td>
<form>
<input type="text" defaultValue={this.props.todo.task} ref="editInput"/>
</form>
</td>
)
}
return <td>{this.props.todo.task}</td>
}
render() {
return (
<tr key={this.props.index}>
{this.renderTaskSection()}
{this.renderActionSection()}
</tr>
)
}
...
todo 编辑
点击编辑之后,会出现一个可编辑的表单,其中defaulValue属性比较重要,如果设置value,还需要针对表单的onchange事件进行监听,否则不会修改表单域的内容。
实现todo的编辑功能,通过表单提交来修改内容,我们之前也遇到了创建todo的时候需要提交表单,两者的思路类似,都是通过表单的事件,调用父组件的函数,然后更新todo的数据状态,最后重新render数据变化的组件。只不过这一次的函数还需要通过TodoList这个组件做一次数据流向的中继。
todo-list-item.js
class TodoListItem extends React.Component {
renderActionSection(){
if(this.state.isEditing){
return (
<td>
// 绑定save方法
<button onClick={this.onSave.bind(this)}>Save</button>
<button onClick={this.onCancel.bind(this)}>Cancel</button>
</td>
)
}
...
}
renderTaskSection(){
if (this.state.isEditing){
return (
<td>
// 绑定save方法
<form onSubmit={this.onSave.bind(this)}>
<input type="text" defaultValue={this.props.todo.task} ref="editInput"/>
</form>
</td>
)
}
return <td>{this.props.todo.task}</td>
}
onSave(event){
event.preventDefault()
const oldTask = this.props.todo.task
const newTask = this.refs.editInput.value
// 调用父组件的方法
this.props.saveTask(oldTask, newTask)
this.setState({
isEditing: false
})
}
下面实现saveTask方法,编辑 app.js文件
app.js
class App extends React.Component {
...
render() {
return (
<div>
<h1>React Todo App</h1>
<TodoCreate createTask={this.createTask.bind(this)}/>
<TodoList todos={this.state.todos}
// 将saveTask函数传递给子组件
saveTask={this.saveTask.bind(this)}/>
</div>
)
}
...
saveTask(oldTask, newTask){
const foundTask = _.find(this.state.todos, todo => todo.task === oldTask)
foundTask.task = newTask
this.setState({todos: this.state.todos})
}
}
完成了App组件中的saveTask函数定义,并传递给子组件,此时需要修改TodoList组件,并将这个函数方法继续传递给TodoListItem组件。
todo-list.js
class TodoList extends React.Component {
renderItem(){
return _.map(this.props.todos, (todo, index) => {
return (
// 传递saveTask函数方法
<TodoListItem todo={todo} key={index} saveTask={this.props.saveTask}/>
)
})
}
...
}
通过TodoList组件的传递,编辑功能就可以实现了。下一步,将会实现将todo的状态进行改变,即完成与否的操作功能,点击todo条目,将变成删除线,表示已经完成;重新点击,将除去删除线,表示未完成。这是常见的前端toggle操作。修改TodoListItem组件
todo-list-item.js
class TodoListItem extends React.Component {
...
renderTaskSection(){
...
// 增加 taskStyle 和 完成状态的删除线
if (!this.props.todo.isCompleted){
return <td onClick={this.onToggle.bind(this)} style={taskStyle}>{this.props.todo.task}</td>
}
return <td onClick={this.onToggle.bind(this)} style={taskStyle}><strike>{this.props.todo.task}</strike></td>
}
...
onToggle(){
const currentTask = this.props.todo.task
this.props.toggleTask(currentTask)
}
}
在 renderTaskSection中,如果不是处于编辑状态,将对todo条目进行绑定一个onToggle的操作,以及将此时todo的状态用style颜色标注。style是Jsx中的组件的属性,本质上是一个js对象,js的对象就是把CSS的编写改写一下,和JSX组件属性一样,遇到连字符连接的属性,则改未驼峰式书写。
taskStyle = {
color: this.props.todo.isCompleted ? 'green' : 'red',
cursor: 'pointer'
}
下面来看onToggle方法,与onSave类似,调用的都是父级组件传递过来的方法操作todo数据state然后重新render组件。
React props特性
增加了编辑功能之后,还差一个删除,todo功能算是完成了。当然,现在还有两个小bug,稍后我们再fix。在此之前,针对TodoListItem组件的数据及其状态的修改,都是调用父级组件App定义的函数方法,其中通过TodoList传递,而每一次传递,都需要修改TodoList的代码,这一点实在太繁琐。为了解决这个问题,可以借助React和ES6的一些特性。下面修改TodoList组建。
todo-list.js
...
class TodoList extends React.Component {
renderItem(){
// return _.map(this.props.todos, (todo, index) => {
// return (
// <TodoListItem todo={todo} key={index} saveTask={this.props.saveTask} toggleTask={this.props.toggleTask}/>
// )
// })
// 将 todo 对象直接传递
return _.map(this.props.todos, (todo, index) => {
return <TodoListItem key={index} {...todo} />
})
}
...
}
{...todo}写法可以把todo({task: task value, isCompleted: isCompleted value})对象传递给子组建,相当于给todo对象进行解包,等价于task=task value
和 isCompleted: isCompleted value
。经过了这样处理,原TodoListItem组件中的task获取就不再是 this.props.todos.task
,而是变成了 this.props.task
, 相应的isCompleted
属性同理。即把 this.props.todos
替换成 this.props
即可。
使用 ... 封包和解包的功能是为了减少 props 属性的传递,之前繁琐的属性是各种事件,这些事件包含在 TodoList组件的 this.props 中,因此todo-list.js还需要再修改以便传递各种事件。
todo-list.js
class TodoList extends React.Component {
renderItem(){
// 除去 this.props 中的 todos属性,减少传递
const props = _.omit(this.props, 'todos');
return _.map(this.props.todos, (todo, index) => {
return <TodoListItem key={index} {...todo} {...props} />
})
}
...
}
上述代码使用了lodash的omit方法,将todos属性除去,因为map的时候,会针对当前的item传递todo,因此,不需要把props中的todos传递了。通过是用...
功能,省去了一大堆props的书写。{...props}
取代了saveTask={this.props.saveTask} toggleTask={this.props.toggleTask}
删除&bugfix
删除功能
todo即将成形,完成删除功能和bugfix,再披上css样式,就大功告成了。实现删除功能很简单,基于前面的实践可知,再TodoListItem中绑定删除事件,然后调用App的删除方法即可,同时因为借助了...
的解包方式,不需要再从TodoListItem中显式的传递这个函数方法啦。
todo-list-item.js
class TodoListItem extends React.Component {
...
renderActionSection(){
...
return (
<td>
<button onClick={this.onEditing.bind(this)}>Edit</button>
<button onClick={this.onDelete.bind(this)}>Delete</button>
</td>
)
}
onDelete(){
const currentTask = this.props.task
this.props.deleteTask(currentTask)
}
app.js
class App extends React.Component {
render() {
return (
<div>
...
<TodoList todos={this.state.todos}
deleteTask={this.deleteTask.bind(this)}
toggleTask={this.toggleTask.bind(this)}
saveTask={this.saveTask.bind(this)}/>
</div>
)
}
deleteTask(currentTask){
_.remove(this.state.todos, todo => todo.task === currentTask)
this.setState({todos: this.state.todos})
}
...
}
验证
完成删除之后,可以尝试使用啦。在使用的时候,会发现,即使什么都不输入,也会增加一条空内容的task,同时,相同的task内容,在编辑修改的时候,总是修改成为第一条内容。产生bug的原因是在查找todo的时候,采用了task内容来匹配,而不是使用一个id之类的唯一标识。
解决的方法也很简单,在创建和编辑的时候,禁止发布空内容和相同的内容。这修要修改create和save两个函数方法,在其中增加一个验证的函数即可。
class TodoCreate extends React.Component {
constructor(props){
super(props)
this.state = {
error: null
}
}
render() {
return (
<form onSubmit={this.handleCreate.bind(this)}>
<input type="text" placeholder="What need I do?" ref="createInput" />
<button>Create</button>
{this.renderError()}
</form>
)
}
handleCreate(event){
event.preventDefault()
const task = this.refs.createInput.value
const error = this.validateInput(task)
if (error){
this.setState({error: error})
return
}
this.props.createTask(task)
this.refs.createInput.value = ''
}
validateInput(task){
console.log(task)
if (!task){
return 'Please enter a task~'
}else if (_.find(this.props.todos, todo => todo.task === task)){
return 'Task already exsits!'
}else{
return ''
}
}
renderError(){
if (this.state.error){
return <p>{this.state.error}</p>
}
return null
}
}
因为用到了与现有的todo对比task内容,因此需要从App组件传给TodoCreate组件。
app.js
class App extends React.Component {
...
render() {
return (
<div>
<h1>React Todo App</h1>
// 传递todos
<TodoCreate todos={this.state.todos}
createTask={this.createTask.bind(this)}/>
<TodoList todos={this.state.todos}
deleteTask={this.deleteTask.bind(this)}
toggleTask={this.toggleTask.bind(this)}
saveTask={this.saveTask.bind(this)}/>
</div>
)
}
...
}
增加样式
与create类似,save的时候,也需要对task的内容做验证。这里就不再记录。具体实现看源码即可。源码的实现,把 validateTask方法抽出为公共的方法给TodoCreate 和 TodoListItem使用。
完成了基本功能之后,还需要给app披上一外衣,在这个看脸的时代,一副好皮囊至关重要。借助与webpack的模块打包功能,在react中使用css很简单,只需要把css文件当成模块import即可。例子中使用了siimple的css样式库。
index.js
...
import "siimple.css"
...
总结
前端发展迅猛,之前jQuery一招鲜。随后backbone,angular等携带mvc等理想从后端杀入前端。一时前端战场硝烟弥漫,各种框架库层出不穷。最让人受不了的是一个工具还没掌握,就已经过时了。与其说前端发展快,私下觉得是因为前端缺少了太多东西,才需要工程师把别的端的理念在前端重新实现一遍。
不管怎么样,近年来逐渐偏向与react,angular,vue几个项目。在此不想比较它们孰优孰劣。就个人的感受而言,也许angular让你在写angular,vue也让你写vue,React却让你真正的在写js,而不是react。通过todo这个应用,大致可以明白React的基本用法和其核心概念。正如React创作的组件一样,这些函数库react,redux,webpack等同样也是一个个组件,如何搭配合理,发挥他们的生态功力。