React基础-组件进阶-类组件

01. 组件属性校验

目标:掌握组件属性校验的基本使用方式

对于组件来说,组件属性 (props) 是组件的调用者传递的,如果组件调用者传递的组件属性不符合要求,将会导致组件内部的代码执行出错,所以为了组件运行的稳定性,我们需要在组件内部对接收的组件属性进行校验。

https://zh-hans.reactjs.org/docs/typechecking-with-proptypes.html

class App extends React.Component {
  render() {
    return <Colors colors={100} />;
  }
}
class Colors extends React.Component {
  render() {
    return (
      <ul>
        {this.props.colors.map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    );
  }
}

对组件属性进行校验需要使用 prop-types 包,所以在校验前要先下载它。

npm install prop-types@15.8.1
import PropTypes from "prop-types";

class Colors extends React.Component {
  static propTypes = {
    colors: PropTypes.array,
    gender: PropTypes.oneOf(["male", "female"]).isRequired,
  };
}

02. 组件属性默认值

目标:掌握组件属性默认值的使用方式

组件属性可以有默认值,如果开发者传递了该属性就是开发者传递的,否则就使用组件属性的默认值。

比如 Colors 组件接收可选的 colors 属性,如果开发者没有传递 colors 属性,为了防止程序执行出错,colors 属性的必须有默认值,即空数组。

class Colors extends React.Component {
  static defaultProps = {
    colors: [],
  };
  static propTypes = {
    colors: PropTypes.arrayOf(PropTypes.string),
  };
  render() {
    // this.props.colors
    // 如果开发者没有传递 colors 属性, 它的值为空数组
    // 如果开发者传递了 color 属性, 它的值就是开发者传递的
    return (
      <ul>
        {this.props.colors.map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    );
  }
}

03. 组件生命周期

目标:理解什么是组件生命周期以及为什么会存在生命周期的概念


  • 理解什么是生命周期、什么是组件生命周期、什么是组件生命周期函数
  • 理解组件生命周期函数的执行时机和执行顺序
  • 掌握在不同的生命周期函数中能够做什么事情

1. 理解生命周期

生命周期是指事物从创建到消亡经历的整个过程。

组件生命周期是指组件从创建到消亡经历的整个过程,这个过程包括挂载组件、更新组件、卸载组件。

理解组件的生命周期可以帮助开发者掌握组件的运行过程、从而能够在正确的时间做正确的事情。

在组件挂载完成后、更新完成后可以向服务端发送请求、可以操作 DOM 对象、开启定时器等。

在组件卸载前要做一些清理工作,比如清除定时器、解绑事件等。

开发者如何才能知道什么时候组件挂载完成了、什么时候组件更新完成了、什么时候组件将要被卸载呢?

生命周期函数是指在组件的不同生命周期阶段被 React 自动调用的函数,开发者可以利用这些生命周期函数将自定义的逻辑插入到组件的生命周期中。

生命周期函数的名字都是固定的,开发者只需要在组件类中定义这些函数即可。

https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/

2. 生命周期函数

1. 挂载阶段

① 组件挂载阶段生命周期函数的执行时机和执行顺序

生命周期函数执行时机
constructor创建组件时执行
render组件初始渲染和组件状态更新都会执行
componentDidMount组件挂载完成后执行且在当前生命周期中只执行一次
// 单个组件中生命周期函数的执行顺序
class App extends React.Component {
  // ①
  constructor(props) {
    super(props);
    // React 组件采用类的形式, 而 constructor 是类的构造函数
    // React 在内部会实例化我们创建的类, 在这个过程中类的构造函数必然最先执行, 这是 JavaScript 语法的要求
    // 创建类的实例就是在创建组件
    console.log("App constructor");
  }
  
  // ② 
  render() {
    // 类的构造函数执行完成以后 React 内部会调用组件类实例对象下的 render 方法进行视图渲染
    console.log("App render");
    return <>App works</>;
  }
  // ③
  componentDidMount() {
    // render 方法调用完成后 React 内部会调用组件类实例对象下的 componentDidMount 方法
    // 该方法表调用后就表示组件视图渲染完毕
    console.log("App componentDidMount");
  }
}
// 父子组件生命周期函数的执行顺序
class App extends React.Component {
  // ①
  constructor(props) {
    super(props);
    console.log("App constructor");
  }
  // ② 
  render() {
    console.log("App render");
    return (
      <>
        App works <Child />
      </>
    );
  }
  // ⑥
  componentDidMount() {
    console.log("App componentDidMount");
  }
}

class Child extends React.Component {
  // ③
  constructor(props) {
    super(props);
    console.log("Child constructor");
  }
  // ④
  render() {
    console.log("Child render");
    return <>Child works</>;
  }
  // ⑤
  componentDidMount() {
    console.log("Child componentDidMount");
  }
}

② 组件挂载阶段的生命周期函数中可以做的事情

生命周期函数可以做的事情
constructor初始化组件状态、创建DOM引用对象、更改组件方法 this 指向等
import React, { createRef } from "react";

class App extends React.Component {
  // 组件类构造函数,创建组件时第一个执行
  constructor(props) {
    // 调用 super 为 JavaScript 继承语法的要求
    // 传递 props 参数是类组件的要求
    super(props);
    // 初始化状态
    this.state = {
      name: "张三",
    };
    // 更改组件函数 this 指向
    this.clickHandler = this.clickHandler.bind(this);
    // 创建 DOM 对象的引用对象
    this.buttonRef = createRef();
  }
  // button 元素点击事件的事件处理函数
  clickHandler() {
    console.log(this);
    console.log(this.buttonRef);
  }
  // 渲染用户界面的入口方法
  render() {
    return (
      <button ref={this.buttonRef} onClick={this.clickHandler}>
        {this.state.name}
      </button>
    );
  }
}

export default App;
生命周期函数可以做的事情
render渲染组件用户界面(注意:不要在此处做和渲染用户界面无关的事情比如调用 setState 方法)
class App extends React.Component {
  render() {
    // 注意不要在 render 方法中更改组件状态,此行为会产生无限渲染导致页面卡死
    // this.setState({ name: "李四" });
    return <button>{this.state.name}</button>;
  }
}
生命周期函数可以做的事情
componentDidMountDOM操作、发送网络请求、更改组件状态
class App extends React.Component {
  componentDidMount() {
    // 更改组件状态
    // 谨慎使用, 会导致性能问题: render 函数会被调用两次
    // 第一次是组件初始渲染、第二次是更改了组件状态触发了组件更新
    // 应该在 constructor 中初始化状态
    this.setState({ name: "李四" });
    // 操作 DOM 对象 (可以但不建议)
    this.buttonRef.current.style.color = "red";
  }
}
import React from "react";
import axios from "axios";

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      list: [],
    };
  }
  async componentDidMount() {
    let response = await axios.get(
      "https://jsonplaceholder.typicode.com/todos"
    );
    this.setState({ list: response.data });
  }

  render() {
    return (
      <ul>
        {this.state.list.map((item) => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>
    );
  }
}

2. 更新阶段

① 在什么情况下会触发组件更新

// ① 当前组件的 state 发生变化会触发组件更新
class App extends React.Component {
  state = {
    count: 0,
  };
  render() {
    console.log("render");
    return (
      <>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          {this.state.count}
        </button>
      </>
    );
  }
}
// ② 父组件更新会触发子组件更新
class App extends React.Component {
  state = {
    count: 0,
  };
  render() {
    console.log("App render");
    return (
      <>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          {this.state.count}
        </button>
        <Child />
      </>
    );
  }
}

class Child extends React.Component {
  render() {
    console.log("Child render");
    return <>Child works</>;
  }
}
// ③ 调用组件的 forceUpdate 方法进行强制更新 (一般不会使用)
class App extends React.Component {
  render() {
    console.log("render");
    return (
      <>
        <button onClick={() => this.forceUpdate()}>button</button>
      </>
    );
  }
}

② 组件更新阶段生命周期函数的执行时机与执行顺序

生命周期函数执行时机
render组件初始渲染和组件状态更新都会执行
componentDidUpdate组件界面更新完成后执行(初始渲染时不执行)
// 单个组件中生命周期函数的执行顺序
class App extends React.Component {
  state = {
    count: 0,
  };
  // ①
  render() {
    console.log("App render");
    return (
      <button onClick={() => this.setState({ count: this.state.count + 1 })}>
        {this.state.count}
      </button>
    );
  }
  // ②
  componentDidUpdate(prevProps, prevState, snapshot) {
    console.log("App componentDidUpdate");
  }
}
// 父子组件中生命周期函数的执行顺序
class App extends React.Component {
  state = {
    count: 0,
  };
  // ①
  render() {
    console.log("App render");
    return (
      <>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          {this.state.count}
        </button>
        <Child />
      </>
    );
  }
  // ④
  componentDidUpdate(prevProps, prevState, snapshot) {
    console.log("App componentDidUpdate");
  }
}

class Child extends React.Component {
  // ② 
  render() {
    console.log("Child render");
    return <>Child works</>;
  }
  // ③
  componentDidUpdate(prevProps, prevState, snapshot) {
    console.log("Child componentDidUpdate");
  }
}

③ 组件更新阶段生命周期函数中可以做的事情

生命周期函数可以做的事情
render渲染用户界面
componentDidUpdateDOM 操作、有条件的发送请求、有条件的更新组件状态
// componentDidUpdate 生命周期函数参数的解释
class App extends React.Component {
  componentDidUpdate(prevProps, prevState, snapshot) {
    // prevProps 组件更新之前的 props 对象
    // prevState 组件更新之前的 state 对象, 若当前组件没有定义 state 则为 null
    // this.props 组件更新时的 props 对象
    // this.state 组件更新时的 state 对象
    // snapshot 暂时忽略
    console.log(prevProps);
    console.log(prevState);
  }
}
// DOM 操作: 由于 componentDidUpdate 生命周期函数执行时 DOM 树已经更新完成,在此处已经可以获取到最新的 DOM 树
// 所以此处是可以执行 DOM 操作的 (可以但不建议)
class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
    };
    this.buttonRef = createRef();
  }

  render() {
    return (
      <>
        <button
          ref={this.buttonRef}
          onClick={() => this.setState({ count: this.state.count + 1 })}
        >
          {this.state.count}
        </button>
      </>
    );
  }
  componentDidUpdate(prevProps, prevState, snapshot) {
    console.log(this.buttonRef.current);
  }
}

千万注意,只要组件发生更新 componentDidUpdate 生命周期函数就会执行,无论更新是由父组件引起的还是当前组件的某一个状态引起的。而我们要执行的操作一般都是在某个特定操作产生的更新之后,为了避免产生性能问题比如额外的网络请求,在 componentDidUpdate 生命周期函数中一定要先判断当前这次更新是否是那个特定操作产生的更新。

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
      msg: "Hello",
    };
  }

  render() {
    return (
      <>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          {this.state.count}
        </button>
        <button onClick={() => this.setState({ msg: "hi" })}>
          {this.state.msg}
        </button>
      </>
    );
  }

  // 因为很可能会导致额外的请求被发送或组件的无限次渲染
  componentDidUpdate(prevProps, prevState, snapshot) {
    // 无论是 count 发生变化还是 msg 发生变化还是父组件更新都会执行当前函数
    console.log("componentDidUpdate");
  }
}

就算是特定操作产生的更新对应的代码段也不一定需要执行,因为该操作虽然触发了组件更新但是组件状态不一定发生了实质性的变化。

所以在 componentDidUpdate 生命周期函数中我们还要判断那个特定的状态是否真正的发生了变化。

class App extends React.Component {
  componentDidUpdate(prevProps, prevState, snapshot) {
    console.log("componentDidUpdate");
    if (this.state.msg !== prevState.msg) {
      console.log("发送请求");
    }
  }
}

在 componentDidUpdate 生命周期函数中也可以调用 setState 方法更新状态,但是必须将 setState 方法置于条件判断语句中,防止组件发生无限次渲染。

class App extends React.Component {
  // 将导致组件的无限次渲染
  componentDidUpdate(prevProps, prevState, snapshot) {
    this.setState({ msg: "Hello" });
  }
}
class App extends React.Component {
  // 不会导致组件的无限次渲染
  componentDidUpdate(prevProps, prevState, snapshot) {
    if (this.state.msg === prevState.msg) {
      this.setState({ msg: Math.random() });
    }
  }
}

3. 卸载阶段

① 组件卸载阶段生命周期函数的执行时机与执行顺序

生命周期函数执行时机
componentWillUnmount卸载(销毁)组件前执行
// 当 App 组件被卸载时
import React from "react";
import { root } from "./index";

class App extends React.Component {
  // ①
  componentWillUnmount() {
    console.log("App componentWillUnmount");
  }
  render() {
    return (
      <>
        <Child />
        <button onClick={() => root.unmount()}>卸载组件</button>
      </>
    );
  }
}

class Child extends React.Component {
  // ②
  componentWillUnmount() {
    console.log("Child componentWillUnmount");
  }
  render() {
    return <>Child works</>;
  }
}
export const root = ReactDOM.createRoot(document.getElementById("root"));

② 组件卸载阶段生命周期函数中可以做的事情

生命周期函数可以做的事情
componentWillUnmount清理操作:定时器、事件、订阅
import React from "react";

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
    };
    this.clickHandler = this.clickHandler.bind(this);
  }

  clickHandler() {
    this.setState({
      count: this.state.count + 1,
    });
  }

  render() {
    return (
      <>
        <button onClick={this.clickHandler}>{this.state.count}</button>
        {this.state.count <= 3 ? <Child /> : <p>Child组件被卸载了</p>}
      </>
    );
  }
}
// 注意:在 React 中通过原生 JavaScript 添加的事件或者开启的定时器需要手动清除
class Child extends React.Component {
  timer = -1;
  // 组件挂载完成后
  componentDidMount() {
    // 组件挂载完成后开启定时器
    this.timer = setInterval(() => {
      console.log("Hello");
    }, 1000);
    // 组件挂载完成后为 window 对象绑定事件
    window.addEventListener("resize", this.resizeHandler);
  }
   // 组件卸载前
  componentWillUnmount() {
    // 组件卸载前清除定时器
    clearInterval(this.timer);
    // 组件卸载前解绑事件
    window.removeEventListener("resize", this.resizeHandler);
  }
  // resize 事件处理函数
  resizeHandler() {
    console.log("resize");
  }
}

注意:组件生命周期函数中的 this 指向组件实例对象,这和事件处理函数是不一样的。

3. 持久化评论数据

componentDidUpdate() {
  localStorage.setItem('comments', JSON.stringify(this.state.comments))
}

componentDidMount() {
  const comments = JSON.parse(localStorage.getItem('comments') || '[]')
  this.setState({ comments })
}

04. setState 进阶

目标:理解 setState 延迟更新数据的特性


  • 理解状态更新特性
  • 状态延迟更新策略之状态对象
  • 状态延迟更新策略之状态函数

1. 状态更新特性

观察以下代码并尝试说出运行结果。

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
    };
    this.clickHandler = this.clickHandler.bind(this);
  }

  clickHandler() {
    this.setState({ count: this.state.count + 1 });
    // 打印在控制台中的 count 值是?
    console.log(this.state.count);
  }

  render() {
    // 渲染在页面中的 count 值是?
    return <button onClick={this.clickHandler}>{this.state.count}</button>;
  }
}

在 setState 方法调用后 React 并没有立即更新状态,也没有立即触发视图更新,所以在 setState 方法调用的后面输出 count 值,拿到的总是上一次的值。

React 是等到当前调用栈中的所有代码执行完成后才更新的状态值,才触发的视图更新,所以在页面中渲染的是最新的 count 值。

在上述代码,当前调用栈指的是 clickHandler 函数中的代码。

那么 React 为什么要采用延迟更新状态的策略呢?

由于状态更新会触发视图更新,而视图更新是一项比较耗性能的操作,如果每次在调用 setState 方法后都立即更新状态值立即驱动视图更新性能将会变的很差。

2. 状态对象

观察以下代码并尝试说出运行结果。

class App extends React.Component {
  clickHandler() {
    this.setState({ count: this.state.count + 1 });
    this.setState({ count: this.state.count + 2 });
  }

  render() {
    // 验证 render 方法的调用次数
    console.log("render");
    // 在页面中渲染的 count 值是?
    return <button onClick={this.clickHandler}>{this.state.count}</button>;
  }
}

当多次调用 setState 方法时 React 会先按照方法的调用顺序将方法接收的参数存储在一个队列中。

当调用栈中的所有代码执行完成后,React 会进行状态对象的合并操作。

合并状态对象的过程中如果状态属性相同后设置的状态属性值会覆盖先设置的状态属性值。

当状态合并完成后再触发视图更新,这样就可以保证多次调用 setState 方法后只更新一次视图,提升应用的运行性能。

// initial
{ count: 0, msg: 'Hello' }

// ①
{ count: 1 }
// ②
{ count: 2 }
// ③
{ greet: 'hi' }
// 最终合并的结果 
{ count: 2, msg: 'Hello', greet: "hi" }

3. 状态函数

setState 方法也可以接收函数作为参数,函数接收当前状态作为参数,要求基于当前参数状态返回最新状态。

class App extends React.Component {
  clickHandler() {
    this.setState((prevState) => ({ count: prevState.count + 1 }));
  }
  render() {
    return <button onClick={this.clickHandler}>{this.state.count}</button>;
  }
}

当多次调用 setState 方法时,每个参数函数接收到的都是基于上一次函数返回的最新状态,所以当状态属性相同时,状态不会发生覆盖现象。

class App extends React.Component {
  clickHandler() {
    this.setState((prevState) => ({ count: prevState.count + 1 }));
    this.setState((prevState) => ({ count: prevState.count + 2 }));
  }

  render() {
    // 在页面中渲染的 count 值是?
    return <button onClick={this.clickHandler}>{this.state.count}</button>;
  }
}

4. 状态更新回调

setState 方法的第二个参数是一个回调函数,该回调函数会在状态更新完成后执行(用户界面更新完成后)。

class App extends React.Component {
  clickHandler() {
    this.setState({ count: this.state.count + 1 }, function () {
      console.log(this.state.count);
    });
  }
}

setState 方法本身是同步方法,但是由于 React 内部的性能优化机制,即状态合并更新机制,导致了它表现出了异步特征,但 setState 确定是同步更新,只是在更新前对多个状态进行了合并操作而已。

05. PureComponent 类

目标:掌握 PureComponent 类的使用方式

PureComponent 类是 React 提供的用于优化组件运行性能的类。

会对组件输入数据(props)进行浅层比较,如果当前输入数据和上次输入数据相同阻止组件重新渲染。

浅层比较是指比较引用数据类型在内存中的引用地址是否相同,比较基本数据类型的值是否相同。

① 使用 PureComponent 类优化基本数据类型的输入数据

import React, { Component, PureComponent } from "react";

export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      name: "张三",
      age: 20,
    };
  }

  // 组件挂载完成之后执行
  componentDidMount() {
    // 开启定时器
    setInterval(() => {
      this.setState({
        age: this.state.age + 1,
      });
    }, 1000);
  }

  render() {
    return (
      <>
        <div>{this.state.age}</div>
        <RegularComponent name={this.state.name} />
        <PureChildComponent name={this.state.name} />
      </>
    );
  }
}

// 未做优化的组件
class RegularComponent extends Component {
  render() {
    console.log("RegularComponent");
    return <div>RegularComponent {this.props.name}</div>;
  }
}
// 做了优化的组件
class PureChildComponent extends PureComponent {
  render() {
    console.log("PureChildComponent");
    return <div>PureChildComponent {this.props.name}</div>;
  }
}

② 使用 PureComponent 类优化引用数据类型的输入数据

import React, { Component, PureComponent } from "react";

export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      hobby: ["编程", "足球"],
      age: 20,
    };
  }

  // 组件挂载完成之后执行
  componentDidMount() {
    // 开启定时器
    setInterval(() => {
      this.setState({
        age: this.state.age + 1,
      });
    }, 1000);
  }

  render() {
    return (
      <>
        <div>{this.state.age}</div>
        <RegularComponent hobby={this.state.hobby} />
        <PureChildComponent hobby={this.state.hobby} />
      </>
    );
  }
}

// 未做优化的组件
class RegularComponent extends Component {
  render() {
    console.log("RegularComponent");
    return <div>RegularComponent {this.props.hobby}</div>;
  }
}
// 做了优化的组件
class PureChildComponent extends PureComponent {
  render() {
    console.log("PureChildComponent");
    return <div>PureChildComponent {this.props.hobby}</div>;
  }
}

06. shouldComponentUpdate

目标:掌握 shouldComponentUpdate 生命周期函数的使用方式

注意:继承了 PureComponent 的类组件不能使用 shouldComponentUpdate 类。

问题:对于引用类型数据 PureComponent 只能进行浅层比较,一旦内存中的引用地址发生变化优化失效。

shouldComponentUpdate 函数是类组件的生命周期函数,它会在组件每次更新时执行。

该函数用于决定是否允许组件更新,如果函数返回 true 表示允许组件更新、如果函数返回 false 表示拒绝组件更新。

class RegularComponent extends Component {
  shouldComponentUpdate(nextProps, nextState, nextContext) {
    // nextProps 表示即新的 props 状态
    // nextState 表示新的 state 状态
    // nextContext 表示新的 context 状态
     return true;
  }
}

需求:子组件接收 person 对象作为 props 状态并在组件中渲染 person.name 属性值,父组件不断更新 person.age 属性值,优化子组件的运行性能。

import React, { Component } from "react";

export default class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      person: {
        name: "张三",
        age: 20,
      },
    };
  }

  // 组件挂载完成之后执行
  componentDidMount() {
    // 开启定时器
    setInterval(() => {
      this.setState({
        person: {
          ...this.state.person,
          age: this.state.person.age + 1,
        },
      });
    }, 1000);
  }

  render() {
    return (
      <>
        <div>{this.state.person.age}</div>
        <RegularComponent person={this.state.person} />
      </>
    );
  }
}

class RegularComponent extends Component {
  shouldComponentUpdate(nextProps, nextState, nextContext) {
    return this.props.person.name !== nextProps.person.name;
  }
  render() {
    console.log("RegularComponent");
    return <div>RegularComponent {this.props.person.name}</div>;
  }
}

07. Portal 组件

目标:使用 createPortal 方法将组件渲染到应用程序之外的某个元素中

createPortal 方法允许开发者将组件渲染到应用程序之外,比如将 Modal 组件渲染到 id 为 portal-root 的 div 中。

<!-- public/index.html -->
<div id="portal-root"></div>
// src/App.js
import React from "react";
import ReactDOM from "react-dom";

class Modal extends React.Component {
  render() {
    return ReactDOM.createPortal(
      <div>Hello Portal</div>,
      document.getElementById("portal-root")
    );
  }
}

export default class App extends React.Component {
  render() {
    return <Modal />;
  }
}

Portal 最经典的应用场景就是渲染弹框组件,弹框组件是应用中的公共组件,在任何组件中都有可能调用弹框组件,但是弹框不属于任何一个组件,所以弹框组件不应该在调用处渲染,它应用被渲染到应用程序之外的某处地方。

08. 综合案例 TodoList

目标:通过所学知识完成 TodoList 案例

1. 静态结构和样式

// src/App.js
import React from "react";

class App extends React.Component {
  render() {
    return (
      <section className="todoapp">
        <header className="header">
          <h1>todos</h1>
          <input
            className="new-todo"
            placeholder="What needs to be done?"
            autoFocus
          />
        </header>
        <section className="main">
          <ul className="todo-list">
            <li className="completed">
              <div className="view">
                <input className="toggle" type="checkbox" defaultChecked />
                <label>Taste JavaScript</label>
                <button className="destroy"></button>
              </div>
              <input
                className="edit"
                defaultValue="Create a TodoMVC template"
              />
            </li>
            <li>
              <div className="view">
                <input className="toggle" type="checkbox" />
                <label>Buy a unicorn</label>
                <button className="destroy"></button>
              </div>
              <input className="edit" defaultValue="Rule the web" />
            </li>
          </ul>
        </section>
        <footer className="footer">
          <span className="todo-count">
            <strong>0</strong> item left
          </span>
          <ul className="filters">
            <li>
              <a className="selected" href="#/">
                All
              </a>
            </li>
            <li>
              <a href="#/active">Active</a>
            </li>
            <li>
              <a href="#/completed">Completed</a>
            </li>
          </ul>
        </footer>
      </section>
    );
  }
}

export default App;
html,
body {
  margin: 0;
  padding: 0;
}

button {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font-size: 100%;
  vertical-align: baseline;
  font-family: inherit;
  font-weight: inherit;
  color: inherit;
  -webkit-appearance: none;
  appearance: none;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

body {
  font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
  line-height: 1.4em;
  background: #f5f5f5;
  color: #111111;
  min-width: 230px;
  max-width: 550px;
  margin: 0 auto;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  font-weight: 300;
}

.hidden {
  display: none;
}

.todoapp {
  background: #fff;
  margin: 130px 0 40px 0;
  position: relative;
  box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
  0 25px 50px 0 rgba(0, 0, 0, 0.1);
}

.todoapp input::-webkit-input-placeholder {
  font-style: italic;
  font-weight: 400;
  color: rgba(0, 0, 0, 0.4);
}

.todoapp input::-moz-placeholder {
  font-style: italic;
  font-weight: 400;
  color: rgba(0, 0, 0, 0.4);
}

.todoapp input::input-placeholder {
  font-style: italic;
  font-weight: 400;
  color: rgba(0, 0, 0, 0.4);
}

.todoapp h1 {
  position: absolute;
  top: -140px;
  width: 100%;
  font-size: 80px;
  font-weight: 200;
  text-align: center;
  color: #b83f45;
  -webkit-text-rendering: optimizeLegibility;
  -moz-text-rendering: optimizeLegibility;
  text-rendering: optimizeLegibility;
}

.new-todo,
.edit {
  position: relative;
  margin: 0;
  width: 100%;
  font-size: 24px;
  font-family: inherit;
  font-weight: inherit;
  line-height: 1.4em;
  color: inherit;
  padding: 6px;
  border: 1px solid #999;
  box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
  box-sizing: border-box;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

.new-todo {
  padding: 16px 16px 16px 60px;
  height: 65px;
  border: none;
  background: rgba(0, 0, 0, 0.003);
  box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
}

.main {
  position: relative;
  z-index: 2;
  border-top: 1px solid #e6e6e6;
}

.todo-list {
  margin: 0;
  padding: 0;
  list-style: none;
}

.todo-list li {
  position: relative;
  font-size: 24px;
  border-bottom: 1px solid #ededed;
}

.todo-list li:last-child {
  border-bottom: none;
}

.todo-list li.editing {
  border-bottom: none;
  padding: 0;
}

.todo-list li.editing .edit {
  display: block;
  width: calc(100% - 43px);
  padding: 12px 16px;
  margin: 0 0 0 43px;
}

.todo-list li.editing .view {
  display: none;
}

.todo-list li .toggle {
  text-align: center;
  width: 40px;
  /* auto, since non-WebKit browsers doesn't support input styling */
  height: auto;
  position: absolute;
  top: 0;
  bottom: 0;
  margin: auto 0;
  border: none; /* Mobile Safari */
  -webkit-appearance: none;
  appearance: none;
}

.todo-list li .toggle {
  opacity: 0;
}

.todo-list li .toggle + label {
  /*
    Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
    IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
  */
  background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
  background-repeat: no-repeat;
  background-position: center left;
}

.todo-list li .toggle:checked + label {
  background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E');
}

.todo-list li label {
  word-break: break-all;
  padding: 15px 15px 15px 60px;
  display: block;
  line-height: 1.2;
  transition: color 0.4s;
  font-weight: 400;
  color: #484848;
}

.todo-list li.completed label {
  color: #949494;
  text-decoration: line-through;
}

.todo-list li .destroy {
  display: none;
  position: absolute;
  top: 0;
  right: 10px;
  bottom: 0;
  width: 40px;
  height: 40px;
  margin: auto 0;
  font-size: 30px;
  color: #949494;
  transition: color 0.2s ease-out;
}

.todo-list li .destroy:hover,
.todo-list li .destroy:focus {
  color: #C18585;
}

.todo-list li .destroy:after {
  content: '×';
  display: block;
  height: 100%;
  line-height: 1.1;
}

.todo-list li:hover .destroy {
  display: block;
}

.todo-list li .edit {
  display: none;
}

.todo-list li.editing:last-child {
  margin-bottom: -1px;
}

.footer {
  padding: 10px 15px;
  height: 20px;
  text-align: center;
  font-size: 15px;
  border-top: 1px solid #e6e6e6;
}

.footer:before {
  content: '';
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  height: 50px;
  overflow: hidden;
  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
  0 8px 0 -3px #f6f6f6,
  0 9px 1px -3px rgba(0, 0, 0, 0.2),
  0 16px 0 -6px #f6f6f6,
  0 17px 2px -6px rgba(0, 0, 0, 0.2);
}

.todo-count {
  float: left;
  text-align: left;
}

.todo-count strong {
  font-weight: 300;
}

.filters {
  margin: 0;
  padding: 0;
  list-style: none;
  position: absolute;
  right: 0;
  left: 0;
}

.filters li {
  display: inline;
  color: inherit;
  margin: 3px;
  padding: 3px 7px;
  text-decoration: none;
  border: 1px solid transparent;
  border-radius: 3px;
  cursor: pointer;
}

.filters li:hover {
  border-color: #DB7676;
}

.filters li.selected {
  border-color: #CE4646;
}

.info {
  margin: 65px auto 0;
  color: #4d4d4d;
  font-size: 11px;
  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
  text-align: center;
}

.info p {
  line-height: 1;
}

.info a {
  color: inherit;
  text-decoration: none;
  font-weight: 400;
}

.info a:hover {
  text-decoration: underline;
}

/*
  Hack to remove background from Mobile Safari.
  Can't use it globally since it destroys checkboxes in Firefox
*/
@media screen and (-webkit-min-device-pixel-ratio:0) {
  .toggle-all,
  .todo-list li .toggle {
    background: none;
  }

  .todo-list li .toggle {
    height: 40px;
  }
}

@media (max-width: 430px) {
  .footer {
    height: 50px;
  }

  .filters {
    bottom: 10px;
  }
}

:focus,
.toggle:focus + label,
.toggle-all:focus + label {
  box-shadow: 0 0 2px 2px #CF7D7D;
  outline: 0;
}

2. 模拟案例接口

① 全局安装 json-server

npm install json-server@0.17.0 -D
npm install axios@0.27.2

② 在项目的根目录下创建 db.json 文件

{
  "todos": [
    {
      "id": "Joxo3Rf",
      "title": "吃饭",
      "isCompleted": false
    },
    {
      "id": "x_qE2Ri",
      "title": "睡觉",
      "isCompleted": false
    },
    {
      "id": "p38JkEY",
      "title": "打豆豆",
      "isCompleted": false
    }
  ]
}

③ 启动服务器提供接口服务

// package.json
{
  "scripts": {
    "json-server": "json-server ./db.json --port 3005 --watch"
  }
}
npm run json-server
获取任务列表
GET http://localhost:3005/todos

获取 id 值为 1 的任务
GET http://localhost:3005/todos/1

添加任务
POST http://localhost:3005/todos

删除 id 值为 1 的任务
DELETE http://localhost:3005/todos/1

更新 id 值为 1 的任务
PATCH http://localhost:3005/todos/1

3. 拆分组件

// src/components/TodoHeader.js
import React from "react";

export default class TodoHeader extends React.Component {
  render() {
    return (
      <header className="header">
        <h1>todos</h1>
        <input
          className="new-todo"
          placeholder="What needs to be done?"
          autoFocus
        />
      </header>
    );
  }
}
// src/components/TodoBody.js
import React from "react";

export default class TodoBody extends React.Component {
  render() {
    return (
      <section className="main">
        <ul className="todo-list">
          <TodoItem />
        </ul>
      </section>
    );
  }
}
// src/components/TodoItem.js
import React from "react";

export default class TodoItem extends React.Component {
  render() {
    return (
      <li>
        <div className="view">
          <input className="toggle" type="checkbox" />
          <label>Buy a unicorn</label>
          <button className="destroy"></button>
        </div>
        <input className="edit" defaultValue="Rule the web" />
      </li>
    );
  }
}
// src/components/TodoFooter.js
import React from "react";

export default class TodoFooter extends React.Component {
  render() {
    return (
      <footer className="footer">
        <span className="todo-count">
          <strong>0</strong> item left
        </span>
        <ul className="filters">
          <li>All</li>
          <li>Active</li>
          <li>Completed</li>
        </ul>
      </footer>
    );
  }
}
// src/components/TodoContainer.js
import React from "react";
import TodoHeader from "./TodoHeader";
import TodoBody from "./TodoBody";
import TodoFooter from "./TodoFooter";

export default class TodoContainer extends React.Component {
  render() {
    return (
      <section className="todoapp">
        <TodoHeader />
        <TodoBody />
        <TodoFooter />
      </section>
    );
  }
}
// src/App.js
import React from "react";
import TodoContainer from "./components/TodoContainer";

export default class App extends React.Component {
  render() {
    return <TodoContainer />;
  }
}

4. 渲染 Todo 列表

目标:发送请求获取 todos 列表并渲染列表

因为 TodoBody 组件和 TodoFooter 组件都会用到 todos 列表状态,所以利用状态提升思想我们要将 todos 列表状态存储在 TodoContainer 组件中。

组件挂载完成后向服务端发送请求获取 todos 列表状态,获取完成后将 todos 列表存储为组件状态。

将 todos 状态从 TodoContainer 传递到 TodoBody,在 TodoBody 组件中遍历 todos 状态,将遍历项传递到 TodoItem 组件中,在该组件中渲染 todo 状态。

约束 TodoBody 组件要接收的 todos 列表状态,约束 TodoItem 组件要接收的 todo 状态。

npm install prop-types@15.8.1
// src/components/TodoContainer.js
import axios from "axios";

export default class TodoContainer extends React.Component {
  constructor(props) {
    super(props);
    // 初始化 todos 列表状态
    this.state = {
      todos: [],
    };
    this.getTodos = this.getTodos.bind(this);
  }
  // 组件挂载完成后
  componentDidMount() {
    // 获取并存储任务列表
    this.getTodos();
  }
  // 获取并存储任务列表
  getTodos() {
    // 发送请求获取 todos 列表状态
    axios
      .get("http://localhost:3005/todos")
      // 存储 todos 列表状态
      .then((response) => this.setState({ todos: response.data }));
  }
  render() {
    return <TodoBody todos={this.state.todos} />;
  }
}
// src/components/TodoBody.js
import PropTypes from "prop-types";

export default class TodoBody extends React.Component {
  // 当前组件接收必选参数 todos 任务列表状态
  static propTypes = {
    todos: PropTypes.array.isRequired,
  };
  render() {
    return (
      <section className="main">
        <ul className="todo-list">
          {this.props.todos.map((todo) => (
            <TodoItem key={todo.id} todo={todo} />
          ))}
        </ul>
      </section>
    );
  }
}
// src/components/TodoItem.js
import PropTypes from "prop-types";

export default class TodoItem extends React.Component {
  // 当前组件接收必选参数 todo 任务状态
  static propTypes = {
    todo: PropTypes.shape({
      id: PropTypes.string.isRequired,
      title: PropTypes.string.isRequired,
      isCompleted: PropTypes.bool.isRequired,
      isEditing: PropTypes.bool.isRequired,
    }).isRequired,
  };
  render() {
    return <label>{this.props.todo.title}</label>;
  }
}

5. 添加任务

目标:实现添加任务功能

// src/components/TodoContainer.js
export default class TodoContainer extends React.Component {
  constructor(props) {
    this.addTodo = this.addTodo.bind(this);
  }
  // 添加任务
  addTodo(todo) {
    axios.post("http://localhost:3005/todos", todo).then(this.getTodos);
  }
  render() {
    return <TodoHeader addTodo={this.addTodo} />;
  }
}
// src/components/TodoHeader.js
import PropTypes from "prop-types";

export default class TodoHeader extends React.Component {
  constructor() {
    this.state = {
      // 用户在文本框中输入的任务名称
      title: "",
    };
    // 将方法 this 指向组件实例对象
    this.updateTitle = this.updateTitle.bind(this);
    this.addTodo = this.addTodo.bind(this);
  }
  // 当前组件要接收的 props
  static propTypes = {
    // 接收添加任务方法
    addTodo: PropTypes.func.isRequired,
  };
  // 更新用户在文本框中输入的任务名称
  updateTitle(event) {
    this.setState({ title: event.target.value });
  }
  render() {
    return <input value={this.state.title} onChange={this.updateTitle} onKeyUp={this.addTodo} />;
  }
}

6. 删除任务

目标:实现删除任务功能

// src/components/TodoContainer.js
export default class TodoContainer extends React.Component {
  constructor() {
    this.removeTodo = this.removeTodo.bind(this);
  }
  // 删除任务
  removeTodo(id) {
    axios.delete(`http://localhost:3005/todos/${id}`).then(this.getTodos);
  }
  render() {
    return <TodoBody removeTodo={this.removeTodo} />;
  }
}
// src/components/TodoBody.js
export default class TodoBody extends React.Component {
  // 当前组件接收必选参数 todos 任务列表状态
  static propTypes = {
    removeTodo: PropTypes.func.isRequired,
  };
  render() {
    return <TodoItem removeTodo={this.props.removeTodo} />;
  }
}
// src/components/TodoItem.js
export default class TodoItem extends React.Component {
  static propTypes = {
    removeTodo: PropTypes.func.isRequired,
  };
  // 删除任务
  removeTodo(id) {
    if (window.confirm("确定要删除该任务吗")) {
      this.props.removeTodo(id);
    }
  }
  render() {
    return <button className="destroy" onClick={() => this.removeTodo(this.props.todo.id)}></button>;
  }
}

7. 修改任务状态

目标: 更改任务的是否已完成状态

npm install classnames@2.3.1
// src/components/TodoContainer.js
export default class TodoContainer extends React.Component {
  constructor() {
    this.updateTodo = this.updateTodo.bind(this);
  }
  // 更改任务的是否已完成状态
  updateTodo(id, args) {
    axios.patch(`http://localhost:3005/todos/${id}`, args).then(this.getTodos);
  }
  render() {
    return <TodoBody updateTodo={this.updateTodo} />;
  }
}
// src/components/TodoBody.js
import classNames from "classnames";

export default class TodoBody extends React.Component {
  // 当前组件接收必选参数 todos 任务列表状态
  static propTypes = {
    updateTodo: PropTypes.func.isRequired,
  };
  render() {
    return <TodoItem updateTodo={this.props.updateTodo}/>;
  }
}
// src/components/TodoItem.js
import classNames from "classnames";

export default class TodoItem extends React.Component {
  static propTypes = {
    updateTodo: PropTypes.func.isRequired,
  };
  render() {
    return (
      <li className={classNames({ completed: this.props.todo.isCompleted })}>
        <input
            onChange={(event) =>
              this.props.updateTodo(this.props.todo.id, {
                isCompleted: event.target.checked,
              })
            }
            checked={this.props.todo.isCompleted}
          />
      </li>
    );
  }
}

8. 未完成任务数量

目标:计算未完成的任务数量

// src/components/TodoContainer.js
export default class TodoContainer extends React.Component {
  render() {
    return <TodoFooter unCompletedCount={this.state.todos.filter((todo) => !todo.isCompleted).length}/>;
  }
}
// src/components/TodoFooter.js
import PropTypes from "prop-types";

export default class TodoFooter extends React.Component {
  static propTypes = {
    unCompletedCount: PropTypes.number.isRequired,
  };
  render() {
    return <strong>{this.props.unCompletedCount}</strong> item left;
  }
}

9. 任务列表筛选

目标:根据筛选按钮对任务进行筛选

// src/components/TodoContainer.js
export default class TodoContainer extends React.Component {
  constructor(props) {
    // 初始化 todos 列表状态
    this.state = {
      // 对应不同的状态的任务列表
      screenTodos: [],
      // 任务状态
      status: "all",
    };
    this.screenTodos = this.screenTodos.bind(this);
  }
  // 获取并存储任务列表
  getTodos() {
    // 发送请求获取 todos 列表状态
    axios
      .get("http://localhost:3005/todos")
      // 存储 todos 列表状态
      .then((response) =>
        this.setState({ todos: response.data }, () => {
          // 任务列表获取完成后对根据 status 状态值对任务进行筛选
          this.screenTodos(this.state.status);
        })
      );
  }
  // 任务筛选
  screenTodos(status) {
    let target = [];
    if (status === "all") {
      target = [...this.state.todos];
    } else if (status === "active") {
      target = this.state.todos.filter((todo) => !todo.isCompleted);
    } else if (status === "completed") {
      target = this.state.todos.filter((todo) => todo.isCompleted);
    }
    this.setState({ screenTodos: target, status });
  }

  render() {
    return (
      <TodoBody todos={this.state.screenTodos} />
      <TodoFooter screenTodos={this.screenTodos} status={this.state.status} />
    );
  }
}
// src/components/TodoFooter.js
import classNames from "classnames";

export default class TodoFooter extends React.Component {
  static propTypes = {
    screenTodos: PropTypes.func.isRequired,
    status: PropTypes.string.isRequired,
  };
  render() {
    return (
      <ul className="filters">
        <li
          className={classNames({ selected: this.props.status === "all" })}
          onClick={() => this.props.screenTodos("all")}
        >
          All
        </li>
        <li
          className={classNames({
            selected: this.props.status === "active",
          })}
          onClick={() => this.props.screenTodos("active")}
        >
          Active
        </li>
        <li
          className={classNames({
            selected: this.props.status === "completed",
          })}
          onClick={() => this.props.screenTodos("completed")}
        >
          Completed
        </li>
      </ul>
    );
  }
}

10. 修改任务名称

目标:实现修改任务名称功能

// src/components/TodoItem.js
import { createRef } from "react";

export default class TodoItem extends React.Component {
  constructor(props) {
    super(props);
    this.inputRef = createRef();
  }
  // 如果组件更新后
  componentDidUpdate(prevProps, prevState, snapshot) {
    // 当前任务的编辑状态被更改了
    if (this.props.todo.isEditing !== prevProps.todo.isEditing) {
      // 判断任务的编辑状态是否为真
      if (this.props.todo.isEditing) {
        // 使文本框获取焦点
        this.inputRef.current.focus();
      }
    }
  }

  render() {
    return (
      <li className={classNames({ editing: this.props.todo.isEditing })}>
        <label onDoubleClick={() => this.props.updateTodo(this.props.todo.id, { isEditing: true })}>
          {this.props.todo.title}
        </label>
        <input
          ref={this.inputRef}
          className="edit"
          defaultValue={this.props.todo.title}
          onBlur={(event) => {
            this.props.updateTodo(this.props.todo.id, {
              title: event.target.value,
              isEditing: false,
            });
          }}
        />
      </li>
    );
  }
}