React全家桶笔记(四):React脚手架与TodoList实战

2 阅读7分钟

React全家桶笔记(四):React脚手架与TodoList实战

本篇进入 React 工程化开发阶段,学习 Create React App 脚手架的使用,并通过 TodoList 案例掌握组件化编码的完整流程。 📺 对应张天禹react全家桶视频:P49 - P64


一、初始化 React 脚手架(P49)

1.1 什么是脚手架?

  • 脚手架(Scaffold)是用来帮助程序员快速创建一个基于某个库的模板项目的工具
  • React 提供了一个用于创建 React 项目的脚手架库:create-react-app
  • 项目的整体技术架构为:React + Webpack + ES6 + ESLint

1.2 创建项目

# 全局安装 create-react-app(只需一次)
npm i -g create-react-app

# 创建项目
create-react-app my-app

# 进入项目目录
cd my-app

# 启动项目
npm start

💡 现在更推荐用 npx 方式,不需要全局安装:npx create-react-app my-app


二、脚手架文件结构(P50-P51)

2.1 public 目录(P50)

public/
├── favicon.ico          → 网站页签图标
├── index.html           → 主页面 ⭐(整个应用只有这一个 HTML 文件)
├── logo192.png          → logo 图
├── logo512.png          → logo 图
├── manifest.json        → 应用加壳的配置文件(PWA 相关)
└── robots.txt           → 爬虫协议文件

index.html 关键内容

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <!-- %PUBLIC_URL% 代表 public 文件夹的路径 -->
  <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
  <!-- 开启理想视口,用于做移动端网页适配 -->
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <!-- 用于配置浏览器页签+地址栏的颜色(仅安卓手机浏览器) -->
  <meta name="theme-color" content="#000000" />
  <!-- 网站描述 -->
  <meta name="description" content="Web site created using create-react-app" />
  <!-- 用于指定网页添加到手机主屏幕后的图标(仅苹果手机) -->
  <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
  <!-- 应用加壳时的配置文件(PWA) -->
  <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
  <title>React App</title>
</head>
<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <!-- 根容器 -->
  <div id="root"></div>
</body>
</html>

2.2 src 目录(P51)

src/
├── App.css              → App 组件的样式
├── App.js               → App 组件 ⭐
├── App.test.js          → App 组件的测试文件
├── index.css            → 全局样式
├── index.js             → 入口文件 ⭐⭐
├── logo.svg             → logo 图
├── reportWebVitals.js   → 页面性能分析文件(需要 web-vitals 库)
└── setupTests.js        → 组件单元测试文件(需要 jest-dom 库)

index.js 入口文件

import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)

🔗 概念扩展React.StrictMode 是什么? 它是 React 提供的一个用于检测潜在问题的工具组件,只在开发模式下有效,不会影响生产构建。它会帮你检查:过时的 API 使用、意外的副作用等。


三、一个简单的 Hello 组件(P52)

3.1 项目结构规范

src/
├── components/              → 存放所有组件
│   ├── Hello/               → 每个组件一个文件夹
│   │   ├── index.jsx        → 组件代码
│   │   └── index.css        → 组件样式
│   └── Welcome/
│       ├── index.jsx
│       └── index.css
├── App.jsx                  → 根组件
└── index.js                 → 入口文件

3.2 Hello 组件示例

// src/components/Hello/index.jsx
import React, { Component } from 'react'
import './index.css'

export default class Hello extends Component {
  render() {
    return <h2 className="title">Hello, React!</h2>
  }
}
/* src/components/Hello/index.css */
.title {
  background-color: orange;
  padding: 10px;
}
// src/App.jsx
import React, { Component } from 'react'
import Hello from './components/Hello'
import Welcome from './components/Welcome'

export default class App extends Component {
  render() {
    return (
      <div>
        <Hello />
        <Welcome />
      </div>
    )
  }
}

文件命名约定

  • 组件文件用 .jsx 后缀(非强制,但推荐,便于区分)
  • 组件文件夹内用 index.jsx 命名,这样导入时可以省略文件名:import Hello from './components/Hello'

四、样式的模块化(P53)

当多个组件的 CSS 中有相同的类名时,会产生样式冲突。解决方案:

4.1 CSS Modules

/* 文件名改为 index.module.css */
.title {
  background-color: orange;
}
import React, { Component } from 'react'
import hello from './index.module.css'

export default class Hello extends Component {
  render() {
    // 通过 hello.title 引用样式,生成的类名是唯一的
    return <h2 className={hello.title}>Hello, React!</h2>
  }
}

4.2 其他方案(了解)

  • Less/Sass 嵌套:通过组件外层类名嵌套避免冲突
  • CSS-in-JS:styled-components 等库
  • Tailwind CSS:原子化 CSS,不存在类名冲突

五、组件化编码流程(P55)

拿到一个功能需求后的标准开发流程:

1. 拆分组件:拆分界面,抽取组件
2. 实现静态组件:使用组件实现静态页面效果(只有结构和样式,没有交互)
3. 实现动态组件:
   a. 动态显示初始化数据
      - 数据类型?
      - 数据名称?
      - 保存在哪个组件的 state 中?
   b. 交互(从绑定事件开始)

🔗 核心原则:数据放在哪?

  • 如果数据只有某个组件用 → 放在该组件自身的 state 中
  • 如果数据是多个组件共用的 → 放在它们共同的父组件的 state 中(状态提升)

六、TodoList 案例完整实战(P56-P64)

6.1 组件拆分

App(根组件,管理 todos 数据)
├── Header(输入框,添加 todo)
├── List(列表容器)
│   └── Item(单个 todo 项)× N
└── Footer(底部统计和操作)

6.2 静态组件(P56)

先把 HTML + CSS 写好,不考虑任何交互逻辑。

6.3 动态初始化列表(P57)

// App.jsx — 数据放在 App 中,因为 Header 和 List 都需要用
export default class App extends Component {
  state = {
    todos: [
      { id: '001', name: '吃饭', done: true },
      { id: '002', name: '睡觉', done: true },
      { id: '003', name: '写代码', done: false },
    ]
  }

  render() {
    const { todos } = this.state
    return (
      <div className="todo-container">
        <div className="todo-wrap">
          <Header />
          <List todos={todos} />
          <Footer />
        </div>
      </div>
    )
  }
}
// List/index.jsx — 接收 todos,遍历渲染 Item
export default class List extends Component {
  render() {
    const { todos } = this.props
    return (
      <ul className="todo-main">
        {todos.map(todo => (
          <Item key={todo.id} {...todo} />
        ))}
      </ul>
    )
  }
}

6.4 添加 Todo(P58)

关键问题:Header 组件需要把数据传给 App 组件(子 → 父通信)

解决方案:App 通过 props 传一个函数给 Header,Header 调用这个函数把数据传回来

// App.jsx
addTodo = (todoObj) => {
  const { todos } = this.state
  const newTodos = [todoObj, ...todos]
  this.setState({ todos: newTodos })
}

render() {
  return (
    <Header addTodo={this.addTodo} />
    // ...
  )
}
// Header/index.jsx
import { nanoid } from 'nanoid' // 生成唯一 id

export default class Header extends Component {
  handleKeyUp = (event) => {
    // 判断是否按下回车
    if (event.keyCode !== 13) return
    // 输入不能为空
    if (event.target.value.trim() === '') {
      alert('输入不能为空')
      return
    }
    // 准备好一个 todo 对象
    const todoObj = {
      id: nanoid(),
      name: event.target.value,
      done: false
    }
    // 调用父组件传来的函数,把数据传回去
    this.props.addTodo(todoObj)
    // 清空输入
    event.target.value = ''
  }

  render() {
    return (
      <div className="todo-header">
        <input onKeyUp={this.handleKeyUp} type="text" placeholder="请输入你的任务名称,按回车键确认" />
      </div>
    )
  }
}

🎯 核心模式:子组件 → 父组件通信 父组件通过 props 传递一个回调函数给子组件,子组件在需要时调用该函数并传入数据。这是 React 中最基本的子传父通信方式。

6.5 鼠标移入效果(P59)

// Item/index.jsx
export default class Item extends Component {
  state = { mouse: false }

  handleMouse = (flag) => {
    return () => {
      this.setState({ mouse: flag })
    }
  }

  render() {
    const { name, done } = this.props
    const { mouse } = this.state
    return (
      <li
        style={{ backgroundColor: mouse ? '#ddd' : 'white' }}
        onMouseEnter={this.handleMouse(true)}
        onMouseLeave={this.handleMouse(false)}
      >
        <label>
          <input type="checkbox" checked={done} onChange={this.handleCheck(this.props.id)} />
          <span>{name}</span>
        </label>
        <button
          className="btn btn-danger"
          style={{ display: mouse ? 'block' : 'none' }}
        >
          删除
        </button>
      </li>
    )
  }
}

6.6 勾选与取消勾选(P60)

// App.jsx — 更新 todo 的 done 状态
updateTodo = (id, done) => {
  const { todos } = this.state
  const newTodos = todos.map(todoObj => {
    if (todoObj.id === id) return { ...todoObj, done }
    else return todoObj
  })
  this.setState({ todos: newTodos })
}

⚠️ 注意checkedonChange 必须配合使用。如果只写 checked 不写 onChange,复选框会变成只读的。或者用 defaultChecked(只在初始化时生效)。

6.7 对 props 进行限制(P61)

import PropTypes from 'prop-types'

// 在 List 组件中
static propTypes = {
  todos: PropTypes.array.isRequired,
}

6.8 删除 Todo(P62)

// App.jsx
deleteTodo = (id) => {
  const { todos } = this.state
  const newTodos = todos.filter(todoObj => todoObj.id !== id)
  this.setState({ todos: newTodos })
}
// Item/index.jsx
handleDelete = (id) => {
  if (window.confirm('确定删除吗?')) {
    this.props.deleteTodo(id)
  }
}

6.9 底部功能(P63)

// Footer/index.jsx
export default class Footer extends Component {
  handleCheckAll = (event) => {
    this.props.checkAllTodo(event.target.checked)
  }

  handleClearAllDone = () => {
    this.props.clearAllDone()
  }

  render() {
    const { todos } = this.props
    // 已完成的数量
    const doneCount = todos.reduce((pre, todo) => pre + (todo.done ? 1 : 0), 0)
    // 总数
    const total = todos.length

    return (
      <div className="todo-footer">
        <label>
          <input
            type="checkbox"
            onChange={this.handleCheckAll}
            checked={doneCount === total && total !== 0}
          />
        </label>
        <span>
          已完成{doneCount} / 全部{total}
        </span>
        <button onClick={this.handleClearAllDone} className="btn btn-danger">
          清除已完成任务
        </button>
      </div>
    )
  }
}

🔗 概念扩展reduce 条件计数 todos.reduce((pre, todo) => pre + (todo.done ? 1 : 0), 0) 是一个经典的用 reduce 做条件计数的写法。初始值为 0,每遇到一个 done 为 true 的就 +1。

6.10 TodoList 案例总结(P64)

TodoList 案例核心收获:
├── 组件拆分思路:按功能区域拆分(Header/List/Item/Footer)
├── 数据放置原则:
│   ├── 某个组件独用 → 放自身 state
│   └── 多个组件共用 → 放共同父组件 state(状态提升)
├── 父子通信:
│   ├── 父 → 子:通过 props 传递数据
│   └── 子 → 父:父组件传递回调函数,子组件调用
├── 状态在哪里,操作状态的方法就在哪里
├── checked 和 onChange 必须配合使用
├── defaultChecked 只在初始化时生效(不受控)
└── 数组方法的灵活运用:map、filter、reduce

⚠️ 注意事项

  • 不要直接修改 state 中的数据,要产生新数据再 setState
  • setState 是合并更新,不是替换
  • 状态在哪里,操作状态的方法就定义在哪里(这是一个重要的设计原则)

本章知识图谱

React 脚手架与实战
├── Create React App
│   ├── 创建:npx create-react-app my-app
│   ├── public/index.html → 唯一的 HTML 页面(SPA)
│   ├── src/index.js → 入口文件
│   └── src/App.jsx → 根组件
├── 项目规范
│   ├── 组件文件夹化:components/Hello/index.jsx
│   ├── 样式模块化:index.module.css
│   └── .jsx 后缀区分组件文件
├── 组件化编码流程
│   ├── 1. 拆分组件
│   ├── 2. 实现静态组件
│   └── 3. 实现动态组件(数据 + 交互)
└── TodoList 实战
    ├── 状态提升:共享数据放父组件
    ├── 子传父:回调函数模式
    ├── 状态在哪,方法在哪
    └── 不可变数据:产生新数据再 setState

📌 下一篇:[React全家桶笔记(五):React网络请求 — 代理、Axios与Fetch] 将学习如何在 React 中发送网络请求,配置代理解决跨域问题。