实战TodoList

开发前准备

  1. 环境配置
  1. 初始化项目
cd Desktop 
create-react-app todo
  1. 删除todo项目src目录上的所有文件
cd todo/src  && rm -rf *
  1. 在 src 中创建 Components 及 Containers 文件夹
  • Containers 用于存放容器组件
  • Components 用于存放组件组件
mkdir Components
mkdir Containers
  1. 在 src/Containers 上创建 TodoApp.js
cd src/Containers && touch TodoApp.js

TodoApp.js

import React, { Component } from 'react';

class TodoApp extends Component {
  render() {
    return (
      <div>Hello, React!</div>
    );
  }
}

export default TodoApp;
  1. 在 src 目录下创建 index.js 并引用 TodoApp.js
cd src && touch index.js

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import TodoApp from './Container/TodoApp'

ReactDOM.render(<TodoApp />, document.getElementById('root'));

这时候,页面就可以跑起来了,我们可以看到页面显示 Hello, React! 接下来我们创建组件。

组件开发

  1. AddTodo.js 在 Component 中创建 AddTodo.js,主要包含:
  • 标题
  • 输入框
cd src/Component && touch AddTodo.js

AddTodo.js

import React from 'react';

const AddTodo = () => (
  <header>
    <h1>Todos</h1>
    <input placeholder="接下来做什么?" autoFocus />
  </header>
);

export default AddTodo;
  1. TodoList.js 在 Component 中创建 TodoList.js ,列表组件。

涉及到 React 当中的列表渲染,TodoList 接收 props,使用 map 方法渲染出每一项 Todo。

cd src/Component && touch TodoList.js

TodoList.js

import React from 'react';
import Todo from './Todo';

const TodoList = ({ todos }) => (
  <ul>
    {todos.map((todo) => (
      <Todo key={todo} content={todo.content} />
    ))}
  </ul>
);

export default TodoList;
cd src/Component && touch Todo.js
  1. Todo.js 我们把列表中的每一个元素设为 Todo.js 组件

Todo.js

import React from 'react';

const Todo = ({ content }) => (
  <li>
    <div>
      <input type="checkbox" />
      <label href="/#">{content}</label>
    </div>
  </li> 
);

export default Todo;
  1. Footer.js 在 Component 中创建 Footer.js,导航组件。

包含全部、代办、已完成,并且把当中的每一项封装为Link组件。由于这三项的内容都是固定的,所以我们直接重复引用三次Link组件即可。

cd src/Component && touch Footer.js

Footer.js

import React from 'react';
import Link from './Link';

const Footer = () => (
  <footer>
    <ul>
      <Link filter="all">全部</Link>
      <Link filter="active">待办</Link>
      <Link filter="completed">已完成</Link>
    </ul>
  </footer>
);

export default Footer;
  1. Link.js 创建在 Footer 组件中使用的 Link 组件
cd src/Component && touch Link.js

Link.js

import React from 'react';

const Link = ({ children }) => (
  <li>
    <a href="/#">{children}</a>
  </li>
);

export default Link;
  1. TodoApp.js 修改 Container 中应用主文件 TodoApp.js,引入编写的三个模块,以及 state 数据。把 state 数据传入 TodoList 中。

TodoApp.js

import React    from 'react';
import AddTodo  from '../Component/AddTodo';
import TodoList from '../Component/TodoList';
import Footer   from '../Component/Footer';

class TodoApp extends Component {
  constructor(props) {
    super(props);
    this.state = {
      todos: [
        { 
          content: "学习 React",
          completed: false
        },
        { 
          content: "学习 Redux",
          completed: false
        },
        { 
          content: "学习 react-router",
          completed: false
        },
      ]
    };
  }

  render() {
    return (
      <section className="todoapp">
        <AddTodo/>
        <TodoList todos={this.state.todos}/>
        <Footer/>
      </section>
    )
  }
}

export default TodoApp;

这样再查看页面,就可以看到我们的组件都被引用到视图中了。

样式

借用 todomvc-app-css 来进行样式填充,然后对应的添加className。

此处我们仅仅是为了页面的美观,如果对样式没有要求,只关注功能,可以跳过。

通过npm安装

yarn todomvc-app-css
  1. TodoApp.js 在TodoApp.js中引入todomvc-app-css文件
import 'todomvc-app-css/index.css';

然后更具 todomvc-html里为我们的Todo应用对应添加上className。

<section className="todoapp">
  <AddTodo/>
  <TodoList todos={this.state.todos}/>
  <Footer/>
</section>
  1. AddTodo.js
const AddTodo = () => (
  <header className="header">
    <h1>Todos</h1>
    <input className="new-todo" placeholder="接下来做什么?" autoFocus />
  </header>
);
  1. TodoList.js
const TodoList = ({ todos }) => (
  <ul className="todo-list">
    {todos.map((todo) => (
      <Todo key={todo.id} content={todo.content} />
    ))}
  </ul>
);
  1. Todo.js
const Todo = ({ content }) => (
  <li>
    <div className="view">
      <input className="toggle" type="checkbox" />
      <label href="/#">{content}</label>
    </div>
  </li> 
);
  1. Foot.js
const Footer = () => (
  <footer className="footer">
    <ul className="filters">
      <Link filter="all">全部</Link>
      <Link filter="active">待办</Link>
      <Link filter="completed">已完成</Link>
      </ul>
  </footer>
);

事件

  • 再输入框中输入,按下回车时候,新建数据
  • 点击数据,完成与未完成切换,切换状态
  • 点击底部数据状态筛选,筛选数据

新建数据

  1. 在 TodoApp 新建 addTodo 方法。addTodo 方法获取节点的数据,然后判断是否为空。当数据不为空,且当前是按下回车键触发新增事件。state 的 todos 数据新增一项,并把 input 元素的 value 设置为空。

  2. 把事件传达给 AddTodo 组件 在 render 函数中 AddTodo 组件传入 addTodo 方法,并使用 bind 绑定当前作用域。

  3. AddTodo 组件把事件绑定在 input 元素上 在 AddTodo 组件中接受 addTodo ,并在其中 input 元素中为其绑定 onKeyDown 事件触发 addTodo

src/Container/TodoApp.js

import React    from 'react';
import AddTodo  from '../Component/AddTodo';
import TodoList from '../Component/TodoList';
import Footer   from '../Component/Footer';
import 'todomvc-app-css/index.css';

class TodoApp extends Component {
  constructor(props) {
    super(props);
    this.state = {
      todos: [
        { 
          content: "学习 React",
          completed: false
        },
        { 
          content: "学习 Redux",
          completed: false
        },
        { 
          content: "学习 react-router",
          completed: false
        },
      ],
    };
  }

  addTodo(e) {
    let value = e.target.value.trim();
    if(value && e.keyCode === 13){
      this.setState({
        todos:[...this.state.todos,{
          content: value,
          completed: false
        }]
      })
      e.target.value = '';
    }
  }

  render() {
    return (
      <section className="todoapp">
        <AddTodo addTodo={this.addTodo.bind(this)}/>
        <TodoList todos={this.state.todos}/>
        <Footer/>
      </section>
    )
  }
}

export default TodoApp;

src/Component/AddTodo.js

import React from 'react';

const AddTodo = ({addTodo}) => (
  <header className="header">
    <h1>Todos</h1>
    <input className="new-todo" 
      placeholder="接下来做什么?" 
      autoFocus 
      onKeyDown={addTodo}/>
  </header>
);

export default AddTodo;

切换状态

切换状态为点击每一项的选择框的时候,切换是否完成的状态

  1. 在 TodoApp 新建 toggleTodo 方法 toggleTodo 接受一个索引,把数组中索引下的项目中的 completed 属性值取反。

  2. 把 toggleTodo 传递到 TodoList 组件上

  3. TodoList 组件把接收到的 toggleTodo 方法传递给 Todo 组件

  4. Todo 组件 把接收到的 toggleTodo 方法绑定到 input 元素中的 onChange 事件上

  5. Todo 组件的其他属性根据接受的 todo数据中的completed属性值的不同,呈现不同的是否选中状态

这样当 Todo 组件中的 input 发生变动时候,就会把索引值逐级传递到 TodoApp 组件的 toggleTodo 事件中。

src/Container/TodoApp.js

import React    from 'react';
import AddTodo  from '../Component/AddTodo';
import TodoList from '../Component/TodoList';
import Footer   from '../Component/Footer';
import 'todomvc-app-css/index.css';

class TodoApp extends Component {
  constructor(props) {
    super(props);
    this.state = {
      todos: [
        { 
          content: "学习 React",
          completed: false
        },
        { 
          content: "学习 Redux",
          completed: false
        },
        { 
          content: "学习 react-router",
          completed: false
        },
      ],
    };
  }

  addTodo(e) {
    let value = e.target.value.trim();
    if(value && e.keyCode === 13){
      this.setState({
        todos:[...this.state.todos,{
          content: value,
          completed: false
        }]
      })
      e.target.value = '';
    }
  }

  toggleTodo(index){
    let todos = this.state.todos;
    todos[index].completed = !todos[index].completed;
    this.setState({ todos })
  }

  render() {
    return (
      <section className="todoapp">
        <AddTodo addTodo={this.addTodo.bind(this)}/>
        <TodoList todos={this.state.todos} toggleTodo={this.toggleTodo.bind(this)}/>
        <Footer/>
      </section>
    )
  }
}

export default TodoApp;

src/Component/TodoList.js

import React from 'react';
import Todo from './Todo';

const TodoList = ({todos,toggleTodo}) => (
  <ul className="todo-list">
    {todos.map((todo,index) => (
      <Todo key={todo} todo={todo} index={index} toggleTodo={toggleTodo} />
    ))}
  </ul>
);

export default TodoList;

src/Component/Todo.js

import React from 'react';

const Todo = ({ todo,index,toggleTodo }) => {
  return  (
    <li style={{
      textDecoration:
        todo.completed ?
        'line-through' :
        'none',
      color:
        todo.completed ?
        '#d9d9d9' :
        '#4d4d4d'
      }
    }>
      <div className="view">
        <input type="checkbox" 
          className="toggle" 
          checked={todo.completed ? 'checked' : ''} 
          onChange={(e)=>toggleTodo(index)}/>
        <label href="/#">{todo.content}</label>
      </div>
    </li> 
  );
}
export default Todo;

筛选数据

筛选数据为当点击底部,全部、完成、未完成的状态,筛选展示出对应的状态。

  1. 在 TodoApp 中 state 新建 filter 属性来定义当前状态

  2. 在 TodoApp render 的时候 更具 filter 的规则,筛选出相关的数据

  3. 把当前 filter 数据以及切换 filter 的 checkoutTodo 方法传递给 Footer 组件

  4. Footer 组件把接收到的 filter 及 checkoutTodo 传递给 Link 组件

  5. Link 组件更具 filter 的值展示是否选中的状态,同时点击时候触发 checkoutTodo 事件,并把点击元素的 filter 值回传更新

src/Container/TodoApp.js

import React,{ Component } from 'react';
import AddTodo  from '../Component/AddTodo';
import TodoList from '../Component/TodoList';
import Footer   from '../Component/Footer';
import 'todomvc-app-css/index.css';

class TodoApp extends Component {
  constructor(props) {
    super(props);
    this.state = {
      todos: [
        { 
          content: "学习 React",
          completed: false
        },
        { 
          content: "学习 Redux",
          completed: true
        },
        { 
          content: "学习 react-router",
          completed: false
        },
      ],
      filter: 'all'
    };
  }

  addTodo(e) {
    let value = e.target.value.trim();
    if(value && e.keyCode === 13){
      this.setState({
        todos:[...this.state.todos,{
          content: value,
          completed: false
        }]
      })
      e.target.value = '';
    }
  }

  toggleTodo(index){
    console.log(index)
    let todos = this.state.todos;
    todos[index].completed = !todos[index].completed;
    this.setState({ todos })
  }

  checkoutTodo(filter){
    this.setState({ filter })
  }

  getVisibleTodos (todos, filter){
    switch (filter) {
      case 'all':
        return todos
      case 'completed':
        return todos.filter(t => t.completed)
      case 'active':
        return todos.filter(t => !t.completed)
      default:
        return todos
    }
  }

  render() {
    let todos = this.getVisibleTodos(this.props.todos,this.props.filter)

    return (
      <section className="todoapp">
        <AddTodo addTodo={this.addTodo.bind(this)}/>
        <TodoList todos={todos} toggleTodo={this.toggleTodo.bind(this)}/>
        <Footer filter={this.state.filter} checkoutTodo={this.checkoutTodo.bind(this)}/>
      </section>
    )
  }

}

export default TodoApp;

src/Component/Footer.js

import React from 'react';
import Link from './Link';

const Footer = ({ filter,checkoutTodo }) => (
  <footer className="footer">
    <ul className="filters">
      <Link selected={ filter === 'all'} filter="all" checkoutTodo={checkoutTodo}>全部</Link>
      <Link selected={ filter === 'active'} filter="active" checkoutTodo={checkoutTodo}>待办</Link>
      <Link selected={ filter === 'completed'} filter="completed" checkoutTodo={checkoutTodo}>已完成</Link>
    </ul>
  </footer>
);

export default Footer;

src/Component/Link.js

import React from 'react';

const Link = ({ children,selected,filter,checkoutTodo }) => (
  <li>
    <a className={ selected ? 'selected' : ''} 
      href="/#" 
      onClick={()=>checkoutTodo(filter)}>
      {children}</a>
  </li>
);

export default Link;

一个流程走下来我们的 TodoApp 中的各项功能搞定了,但是发现我们的数据控制都在 Container 中,如果组件嵌套节点比较深的话,将很难传递数据,要一个个的不断往下传递,非常麻烦。这个时候,数据如果可以集中统一管理起来多好,这个时候,就要用到 redux 。下一个章节中,使用 redux 改造当前 TodoApp。

数据管理

React技术栈的主要适用情景是Web应用,Web应用涉及非常多的用户交互和状态数据改变。我们应该尽量控制应用中有状态组件的个数,应用数据应该尽量集中统一管理,否则后期维护成本较高。之前介绍有状态组件与无状态组件,React也推荐我们控制有状态组件的个数,在我们开发过程中编写的90%都应该是无状态组件。React本身提供给我们控制引用数据的方式只有props、state 及带有实验性质的context。React本身并没有提供应用状态管理的解决方案,在小的应用中可能感受不到状态管理是一个问题,但随着应用复杂度的增加,这个问题就会暴露得越来越明显,这个时候就可以考虑使用Redux。

既然React推荐我们尽量少的编写有状态组件,为何不干脆把一个应用所有的状态数据集中到一个地方管理呢?这便是Redux的理念。Redux把应用的数据统一储存在一个对象当中,把应用的所有的数据交互及引用状态的改变统一用固定形式的action动作对象来描述,并由reducer的方法来判断不同的动作如何改变引用状态,最后通过store对象来执行action动作获取state应用状态,并触发改变应用状态的事件。

实际上Redux提供的是一种应用状态的解决思路,我们可以用基本的原生JS方法来实现Redux的全部功能。并且我们就算引入Redux编写的大部分的是原生的JS函数和对象。

以下我们将把 redux 应用在 react 中:

  1. 下载 redux、react-redux 相关依赖
yarn add redux react-redux
  1. 在 src 下创建 store 目录用于存放数据相关文件
cd src && mkdir store
  1. 在 store 中创建 index.js 、action.js 、actionType.js 、reducer.js
  • index.js 实例化创建redux数据
  • action.js 操作行为
  • actionType.js 操作命令
  • reducer.js 数据操作

actionType.js 定义命令常量,防止直接使用字符无法定位错误地址。

export const ADDTODO = 'add_todo';
export const TOGGLETODO = 'toggle_todo';
export const CHECKOUTTODO = 'checkout_todo';

action.js 定义操作行为,封装操作类型及参数

import * as actionType from './actionType';

export const handleAddTodo = (value) => ({
  type: actionType.ADDTODO,
  value,
})

export const handleToggleTodo = (index)=>({
  type: actionType.TOGGLETODO,
  index,
})

export const handleCheckoutTodo = (filter) => ({
  type: actionType.CHECKOUTTODO,
  filter
})

reducer.js 操作行为,接收 action 操作行为,根据操作类型不同,执行不同的事件。

注意:reducer 函数为纯函数,不可对 state 数据进行修改。

import * as actionType from './actionType';

const defaultState = {
  todos: [{ 
    content: "学习 React",
    completed: false
  }],
  filter: 'all'
};

export default ( state = defaultState, action) => {
  let newState = JSON.parse(JSON.stringify(state));
  switch(action.type) {
    case actionType.ADDTODO:
      newState.todos.push({
        content: action.value,
        completed: false
      })
      return newState
    case actionType.TOGGLETODO:
      newState.todos[action.index].completed = !state.todos[action.index].completed
      return newState
    case actionType.CHECKOUTTODO:
      newState.filter = action.filter
      return newState
    default:
      return state;
  }
}

index.js

import { createStore }  from  'redux';
import reducer from './reducer';

const store = createStore(reducer);

export default store;
  1. 在 src 入口文件 index.js 中引用 store 数据,并使用 Provider 组件把整个 TodoApp 包含,这样 TodoApp 就可以获取到所有 store 的数据。

src/index.js

import React, { Component } from 'react';
import { Provider } from 'react-redux';
import ReactDOM from 'react-dom';
import TodoApp from './Container/TodoApp'
import store from './store';

class App extends Component {
  render() {
    return (
      <Provider store={store}>
        <TodoApp />
      </Provider>
    );
  }
}

ReactDOM.render(<App />, document.getElementById('root'));
  1. 修改 TodoApp.js

src/Container/TodoApp.js

import React,{ Component } from 'react';
import AddTodo  from '../Component/AddTodo';
import TodoList from '../Component/TodoList';
import Footer   from '../Component/Footer';
import { connect } from 'react-redux';
import 'todomvc-app-css/index.css';

class TodoApp extends Component {
  getVisibleTodos (todos, filter){
    switch (filter) {
      case 'all':
        return todos
      case 'completed':
        return todos.filter(t => t.completed)
      case 'active':
        return todos.filter(t => !t.completed)
      default:
        return todos
    }
  }

  render() {
    let todos = this.getVisibleTodos(this.props.todos,this.props.filter)
    return (
      <section className="todoapp">
        <AddTodo/>
        <TodoList todos={todos}/>
        <Footer filter={this.props.filter}/>
      </section>
    )
  }
}

const mapStateToProps = (state) => {
  return {
    todos: state.todos,
    filter: state.filter
  }
}

export default connect(mapStateToProps,null)(TodoApp);
  1. 修改 AddTodo.js

src/Components/AddTodo.js

import React from 'react';
import { connect } from 'react-redux';
import * as action from '../store/action';

const AddTodo = ({addTodo}) => (
  <header className="header">
    <h1>Todos</h1>
    <input className="new-todo" placeholder="接下来做什么?" autoFocus onKeyDown={addTodo}/>
  </header>
);

const mapDispathToProps = (dispatch) => {
  return {
    addTodo(e){
      let value = e.target.value.trim();
      if(value && e.keyCode === 13){
        dispatch(action.handleAddTodo(value));
        e.target.value = '';
      }
    }
  }
}

export default connect(null,mapDispathToProps)(AddTodo);
  1. 修改 TodoList.js

src/Components/TodoList.js

import React from 'react';
import Todo from './Todo';

const TodoList = ({todos,toggleTodo}) => (
  <ul className="todo-list">
    {todos.map((todo,index) => (
      <Todo key={index} todo={todo} index={index}/>
    ))}
  </ul>
);

export default TodoList;
  1. 修改 Todo.js

src/Components/Todo.js

import React from 'react';
import { connect } from 'react-redux';
import * as action from '../store/action';

const Todo = ({ todo,index, toggleTodo }) => {
  return  (
    <li style={{
      textDecoration:
        todo.completed ?
        'line-through' :
        'none',
      color:
        todo.completed ?
        '#d9d9d9' :
        '#4d4d4d'
      }
    }>
      <div className="view">
        <input type="checkbox" 
          className="toggle" 
          checked={todo.completed ? 'checked' : ''} 
          onChange={(e)=>toggleTodo(index)}/>
        <label href="/#">{todo.content}</label>
      </div>
    </li> 
  );
}
const mapDispathToProps = (dispatch) => {
  return {
    toggleTodo(index){
      dispatch(action.handleToggleTodo(index));
    }
  }
}

export default connect(null,mapDispathToProps)(Todo);
  1. 修改 Footer.js

src/Component/Footer.js

import React from 'react';
import Link from './Link';

const Footer = ({ filter,checkoutTodo }) => (
  <footer className="footer">
    <ul className="filters">
      <Link selected={ filter === 'all'} filter="all" checkoutTodo={checkoutTodo}>全部</Link>
      <Link selected={ filter === 'active'} filter="active" checkoutTodo={checkoutTodo}>待办</Link>
      <Link selected={ filter === 'completed'} filter="completed" checkoutTodo={checkoutTodo}>已完成</Link>
    </ul>
  </footer>
);

export default Footer;
  1. 修改 Link.js

src/Component/Link.js

import React from 'react';
import { connect } from 'react-redux';
import * as action from '../store/action';

const Link = ({ children,selected,filter,checkoutTodo }) => (
  <li>
    <a className={ selected ? 'selected' : ''} 
      href="/#" 
      onClick={()=>checkoutTodo(filter)}>
    {children}</a>
  </li>
);

const mapDispathToProps = (dispatch) => {
  return {
    checkoutTodo(filter){
      dispatch(action.handleCheckoutTodo(filter));
    }
  }
}

export default connect(null,mapDispathToProps)(Link);