Skip to content

图片处理与类组件的生命周期(Day5 2/3) ​

本页对应 React + TypeScript + 医通项目,共 5 段视频,约 48 分钟。

学习目标 ​

  • 能够将 todoList 静态页面接入项目并运行,完成组件拆分且页面无报错。
  • 能够在父组件中定义 todo 状态数据,使用 nanoId 生成唯一 ID,并通过 props 与 map 完成首屏列表渲染及 footer 统计。
  • 能够将函数组件改造为类组件,正确通过 props 接收数据,并为列表项设置唯一 key。
  • 能够按照“描述结果—拆分步骤—匹配技术点”的流程,分析添加、删除等交互功能的实现思路。
  • 能够实现添加 todo:获取输入内容、绑定回车事件、子传父调用方法、setState 追加数据并清空输入框。
  • 能够实现删除 todo:点击按钮传递 ID、跨层传递回调、使用 filter 更新状态,并通过 confirm 完成删除确认。

知识点 ​

网络图片:先判断资源在哪里 ​

为什么需要:图片和组件一样,最终都要交给浏览器去请求。网络图片的地址本身就是一个可访问的 URL,所以不需要经过构建工具处理,直接把地址写进 src 即可。
怎么想:先问“这张图在项目里,还是在互联网上?”如果图片已经有稳定的线上地址,就按网络资源处理;如果图片在本地目录里,才需要后面讲的 import 或 require。
怎么做:把网络地址直接填入 src,例如:

jsx
<img src="https://example.com/1.jpeg" alt="网络图片" />

易错点:地址必须是浏览器能访问到的完整 URL;不要写成项目里的相对路径却误以为它是网络图片。另外 alt 要写有意义的中文描述,而不是空着或写“图片”。

本地图片的前提:文件必须放在 src 目录中 ​

为什么需要:React 项目在开发时由构建工具处理模块,src 下的资源才能被模块系统正确解析和打包。笔记里特别用红字强调“存放位置必须在 src 目录中”,这是本地图片能否被导入的前提。
怎么想:先确认图片到底放在哪里。如果图片在 src/assets/images 下,就可以通过相对路径导入;如果放在 public 下,处理方式不同,不能直接套用本节的 import/require 写法。
怎么做:把图片放到 src 目录内,例如 src/assets/images/1.jpeg,再从组件文件里写相对路径导入。
易错点:路径大小写、扩展名、层级都要对;src 外或 public 下的图片不要硬套本节代码。转写稿里反复出现“先打印、看控制台、刷新验证”的调试习惯,这里同样适用:路径不确定时,先确认文件真实位置,再看构建报错。

用 import 导入静态图片 ​

为什么需要:import 是 ES Module 的静态导入方式,构建工具会在编译阶段分析它,把图片当成模块资源处理。适合图片路径固定、不会随状态变化的场景。
怎么想:如果这张图在代码里写死,比如头像、默认插图、固定背景图,就用 import。它的路径在编译时就确定了。
怎么做:

jsx
import imgSrc from './assets/images/1.jpeg'

<img src={imgSrc} alt="本地图片" />

易错点:import 的路径是静态字符串,不能写成变量拼接;导入后的 imgSrc 是一个模块值,要放到 src={imgSrc} 里,而不是写成字符串 src="imgSrc"。转写稿中讲 to do 数据时强调“数据在哪,方法就定义在哪”,这里可以类比:图片路径写死在模块顶部,就别指望它随状态变化。

用 require 导入可变的图片路径 ​

为什么需要:当图片文件名要根据状态变化时,比如 1.jpeg、2.jpeg、3.jpeg,静态 import 无法在运行时拼路径。require 可以在表达式里参与路径拼接,适合这种动态目录场景。
怎么想:先判断“图片路径是否固定”。固定就用 import;如果路径里有变量,比如 index,就考虑 require。笔记里的例子是:

jsx
state = {
    index: 2
}

<img src={require(`./assets/images/${index}.jpeg`)} alt="动态本地图片" />

怎么做:在类组件里定义 state.index,再用模板字符串拼出相对路径,交给 require。注意示例中 index 要从状态里取,实际写法通常是 this.state.index 或先解构。
易错点:require 里的路径不能是完全随意的运行时字符串,构建工具仍需要能分析出大致目录;文件名、扩展名、反引号都要写对。转写稿里多次出现“先 console.log 验证,再继续往下写”的过程,这里也应先确认 index 的值和拼出的路径是否符合预期,再刷新看图片是否显示。

import 与 require 的差别:路径能否被状态控制 ​

为什么需要:知道差别,才能在“固定图片”和“动态图片”之间选对写法。笔记明确说:require 导入可以看到图片的路径,可以通过状态数据控制导入的图片目录;import 导入的图片路径是写死的,不能控制。
怎么想:把“路径是否变化”作为判断标准。路径固定,import 更清晰;路径要跟着 state 变,require 更合适。
怎么做:固定图用 import imgSrc from './assets/images/1.jpeg';动态图用 require(&#96;./assets/images/${index}.jpeg&#96;)。
易错点:不要把 import 当成能运行时拼路径的工具;也不要在 require 里漏掉模板字符串的反引号。转写稿里讲 to do 时反复纠正“父组件/子组件、子传父”的口误,本质是提醒我们:概念先分清,代码才不会写反。图片处理也一样,先分清“静态导入”和“动态导入”,再动手写。

挂载阶段为什么需要单独理解 ​

类组件不是“写一个 render 就结束”的函数。它从被创建、渲染到真正进入页面,中间有固定顺序。笔记给出的挂载阶段顺序是:

text
constructor --> render --> componentDidMount

类组件生命周期挂载阶段示意图

为什么需要:因为不同代码要放在不同位置。初始化 state、绑定事件适合放在 constructor;写 UI 结构放在 render;需要操作真实 DOM、发请求、开定时器则要等组件挂载完成。怎么想:先问“这段代码依赖不依赖页面已经出现”。不依赖,放 constructor;只负责返回 JSX,放 render;依赖挂载完成,放 componentDidMount。怎么做:按顺序定义类组件结构,但注意类方法书写顺序不等于执行顺序,真正执行顺序由 React 控制。易错点:不要把发请求、开定时器写进 render,因为 render 可能多次执行;也不要在 constructor 里操作尚未挂载的 DOM。

constructor:初始化与 this 绑定 ​

为什么需要:类组件需要初始化 state,也常需要把事件回调的 this 绑定到当前组件实例。转写稿里把函数组件改造成类组件时,使用了 class ... extends Component,并在 render 中 return JSX;接收父组件数据则通过 this.props。这说明类组件里 this 很关键。

怎么想:constructor 是最早执行的钩子,此时还没有 render,更没有真实 DOM。它适合做“不依赖页面”的初始化。怎么做:先调用 super(props),再初始化 state 或绑定方法。易错点:转写稿中事件回调直接使用 this.props.addTodo 时出现 this 指向问题,因为回调最终由 window 调用,this 不是组件实例;需要绑定 this 或用箭头函数。这个调试过程说明:constructor 里做事件绑定不是形式,而是为了保证后续回调里的 this 正确。

render:只负责根据数据返回 UI ​

为什么需要:render 是类组件描述界面的地方。转写稿中 Footer、Main、Item 改成类组件后,都是在 render 里 return JSX;数据通过 this.props 接收,列表通过 map 渲染,并用唯一 id 作为 key。

怎么想:render 应该是“纯”的:给我 state 和 props,我返回 UI。怎么做:在 render 中解构 this.props,用条件、map、表达式生成 JSX。易错点:不要在 render 里发 ajax、开定时器、订阅消息,也不要直接修改 state。转写稿里列表渲染出现“each child in a list should have a unique key”的报错,最后用数据里的 id 解决,这类问题属于 render 阶段的典型调试。

componentDidMount:挂载完成后执行 ​

笔记明确:componentDidMount 的执行时机是“组件挂载完成后执行”。为什么需要:有些事必须等组件真正进入页面后才能做,例如获取 DOM 节点、发请求拿首屏数据、启动定时器、订阅消息、添加自定义事件。怎么想:只要代码依赖“页面已经挂载完成”,就优先考虑它。怎么做:在类组件中定义 componentDidMount(),把上述逻辑放进去。易错点:它只会在挂载完成后执行一次,不要把它当成每次更新都执行的钩子;如果在这里开了定时器、订阅或自定义事件,后续卸载时要清理,否则容易造成泄漏。笔记列出的四个作用可以原样记住:

  1. 开启定时器
  2. 发送 ajax 请求
  3. 订阅消息
  4. 添加自定义事件

结合 todoList 理解“挂载后”与“更新后” ​

转写稿的 todoList 里,数据定义在共同父组件 ToDoList 身上,再通过 props 传给 Footer、Main、Item;添加任务时用 setState 修改数组,删除时用 filter 保留不等于当前 id 的项,统计已完成数量时用 reduce。这些操作会触发重新 render,但不会重新走一遍挂载阶段。为什么需要区分:如果把“只做一次”的请求或订阅误放到 render 或更新逻辑里,就会重复执行。怎么想:挂载阶段负责“第一次进入页面要准备什么”,更新阶段负责“数据变了界面怎么变”。怎么做:首屏请求、定时器、订阅放 componentDidMount;用户点击、输入、删除等交互通过事件回调和 setState 驱动更新。易错点:转写稿里添加 todo 时还出现“添加成功后文本框要清空”的细节,这是交互后的状态同步问题,不是挂载阶段问题;不要把所有逻辑都塞进 componentDidMount。

本节调试检查清单 ​

先确认类组件是否继承 Component、render 是否返回 JSX;再确认 props 是否通过 this.props 接收;事件回调里 this 是否正确;最后检查“只执行一次”的逻辑是否放在 componentDidMount。记住顺序:constructor 先初始化,render 出 UI,componentDidMount 在挂载完成后做副作用。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

1. 图片处理:import 导入 与 require 导入(JSX) ​

jsx
// src/components/ImageDemo.jsx
import React, { Component } from 'react'

// 本地图片:文件必须放在 src 目录里,否则打包不进去
// 2-1. import 导入:路径写死在编译期,无法用变量控制
import imgSrc from './assets/images/1.jpeg'

export default class ImageDemo extends Component {
  state = {
    index: 2
  }

  render() {
    const { index } = this.state
    return (
      <div>
        {/* 1. 网络图片:直接填网络地址 */}
        <img src="https://picsum.photos/200/100" alt="" />

        {/* 2-1. import 导入的本地图片 */}
        <img src={imgSrc} alt="" />

        {/* 2-2. require 导入:路径是模板字符串,可以用状态数据控制 */}
        <img src={require(`./assets/images/${index}.jpeg`)} alt="" />
      </div>
    )
  }
}

要点:

  • 本地图片的前提是存放在 src 目录中,放在 public 外面打包不进去。
  • import 是静态语法,路径只能是字面量、写死在编译期,所以不能用 state 切换图片。
  • require 是运行时调用,模板字符串可以拼变量,所以能通过 index 控制加载哪张图;但 ./assets/images/ 这段静态前缀必须保留,否则打包工具找不到目录。
  • src 要用 {} 包表达式,写成 src="imgSrc" 就是纯字符串,图片会 404。

2. 首屏数据渲染:父传子 + map + reduce(完整 HTML,可直接运行) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>todoList 首屏数据渲染</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    .todo-wrap { width: 500px; margin: 40px auto; border: 1px solid #ddd; padding: 10px; }
    .todo-header input { width: 100%; height: 34px; padding: 0 8px; }
    .todo-main { margin: 10px 0; }
    .todo-main li { list-style: none; height: 34px; line-height: 34px; display: flex; justify-content: space-between; }
    .todo-footer { display: flex; justify-content: space-between; height: 34px; line-height: 34px; }
  </style>
</head>
<body>
<div id="root"></div>

<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>

<script type="text/babel">
const { Component } = React;

// 生成不重复 id:方式二——自己模拟
// 36 = 0~9(10 个) + 26 个英文字母,toString(36) 把随机数转成字母数字组合
// 真实项目用 nanoid:npm i nanoid  →  import { nanoid } from 'nanoid'
const nanoId = () => Math.random().toString(36).slice(2);

class Item extends Component {
  render() {
    const { todo } = this.props;
    return (
      <li>
        <label>
          <input type="checkbox" checked={todo.isDone} />
          <span>{todo.title}</span>
        </label>
        <button>删除</button>
      </li>
    );
  }
}

class Main extends Component {
  render() {
    const { todos } = this.props;
    return (
      <ul className="todo-main">
        {todos.map(todo => <Item key={todo.id} todo={todo} />)}
      </ul>
    );
  }
}

class Footer extends Component {
  render() {
    const { todos } = this.props;
    const total = todos.length;
    // 已完成数量:reduce 累加 isDone 为 true 的项,初始值 0
    const doneNumber = todos.reduce((pre, cur) => pre + (cur.isDone ? 1 : 0), 0);
    return (
      <div className="todo-footer">
        <label>
          <input type="checkbox" />
          <span>已完成 {doneNumber} / 全部 {total}</span>
        </label>
      </div>
    );
  }
}

class ToDoList extends Component {
  // 数据定义在共同的爹身上,子组件靠父传子拿数据
  state = {
    todos: [
      { id: nanoId(), title: '吃饭', isDone: true },
      { id: nanoId(), title: '睡觉', isDone: true },
      { id: nanoId(), title: '写代码', isDone: false }
    ]
  };

  render() {
    const { todos } = this.state;
    return (
      <div className="todo-wrap">
        <header className="todo-header">
          <input type="text" placeholder="请输入任务名,按回车添加" />
        </header>
        <Main todos={todos} />
        <Footer todos={todos} />
      </div>
    );
  }
}

ReactDOM.createRoot(document.getElementById('root')).render(<ToDoList />);
</script>
</body>
</html>

要点:

  • 数据被 Main 和 Footer 同时使用,所以定义在共同父级 ToDoList 的 state 上——父传子是最省事的路子。
  • 类组件里 state 用 = 赋值(state = {...}),不是对象字面量里的 :,这是从函数组件改类组件最容易写错的一行。
  • map 渲染列表必须给 key,用不重复的 todo.id,否则控制台报 each child in a list should have a unique key。
  • checked={todo.isDone} 是受控写法,此时没有 onChange 会有一条红色警告,属于预期(后面做交互再补)。
  • reduce 第二个参数 0 是初始值,cur.isDone ? 1 : 0 把布尔值转成数字累加。
  • 类组件不能直接 return,必须写在 render() 里;函数组件改类组件时要 const { Component } = React 并继承。

3. 添加 + 删除:子传父、事件传参、confirm(完整 HTML) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>todoList 添加 / 删除</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    .todo-wrap { width: 500px; margin: 40px auto; border: 1px solid #ddd; padding: 10px; }
    .todo-header input { width: 100%; height: 34px; padding: 0 8px; }
    .todo-main { margin: 10px 0; }
    .todo-main li { list-style: none; height: 34px; line-height: 34px; display: flex; justify-content: space-between; }
    .todo-footer { display: flex; justify-content: space-between; height: 34px; line-height: 34px; }
  </style>
</head>
<body>
<div id="root"></div>

<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>

<script type="text/babel">
const { Component } = React;
const nanoId = () => Math.random().toString(36).slice(2);

class Header extends Component {
  // 箭头函数写法,this 才指向当前组件实例
  handleKeyUp = (e) => {
    if (e.keyCode !== 13) return;          // 只处理回车
    const title = e.target.value.trim();   // 通过事件对象拿输入内容
    if (!title) return;                    // 空内容不添加
    this.props.addTodo(title);             // 子传父:调用父组件传下来的方法
    e.target.value = '';                   // 添加成功后清空文本框
  };

  render() {
    return (
      <header className="todo-header">
        <input
          type="text"
          placeholder="请输入任务名,按回车添加"
          onKeyUp={this.handleKeyUp}
        />
      </header>
    );
  }
}

class Item extends Component {
  render() {
    const { todo, deleteById } = this.props;
    return (
      <li>
        <label>
          <input type="checkbox" checked={todo.isDone} readOnly />
          <span>{todo.title}</span>
        </label>
        {/* 必须用箭头函数包裹,否则点击前就被执行了 */}
        <button onClick={() => deleteById(todo.id)}>删除</button>
      </li>
    );
  }
}

class Main extends Component {
  render() {
    const { todos, deleteById } = this.props;
    return (
      <ul className="todo-main">
        {todos.map(todo => (
          <Item key={todo.id} todo={todo} deleteById={deleteById} />
        ))}
      </ul>
    );
  }
}

class Footer extends Component {
  render() {
    const { todos } = this.props;
    const total = todos.length;
    const doneNumber = todos.reduce((pre, cur) => pre + (cur.isDone ? 1 : 0), 0);
    return (
      <div className="todo-footer">
        <label>
          <input type="checkbox" />
          <span>已完成 {doneNumber} / 全部 {total}</span>
        </label>
      </div>
    );
  }
}

class ToDoList extends Component {
  state = {
    todos: [
      { id: nanoId(), title: '吃饭', isDone: true },
      { id: nanoId(), title: '睡觉', isDone: true },
      { id: nanoId(), title: '写代码', isDone: false }
    ]
  };

  // 添加:数据在谁身上,操作数据的方法就定义在谁身上
  addTodo = (title) => {
    const todo = { id: nanoId(), title, isDone: false };  // 新增的默认未完成
    this.setState({ todos: [todo, ...this.state.todos] }); // 往前追加
  };

  // 删除:Item → Main → ToDoList,一层层传下来
  deleteById = (id) => {
    if (!window.confirm('确定删除吗?')) return;  // 点取消返回 false,取反后直接退出
    this.setState({
      todos: this.state.todos.filter(todo => todo.id !== id) // 长度变化用 filter
    });
  };

  render() {
    const { todos } = this.state;
    return (
      <div className="todo-wrap">
        <Header addTodo={this.addTodo} />
        <Main todos={todos} deleteById={this.deleteById} />
        <Footer todos={todos} />
      </div>
    );
  }
}

ReactDOM.createRoot(document.getElementById('root')).render(<ToDoList />);
</script>
</body>
</html>

要点:

  • 事件名要驼峰:onKeyUp、onClick;回车判断用 e.keyCode !== 13。
  • handleKeyUp 用箭头函数定义(类字段),否则 this 是 undefined,会报 Cannot read properties of undefined (reading 'props')。
  • 方法定义在数据所在的组件(ToDoList)上,再通过 props 传给 Main 传给 Item,这就是“孙子给爷爷传数据”的走法。
  • onClick={() => deleteById(todo.id)} 必须包一层箭头函数;写成 onClick={deleteById(todo.id)} 是立即调用,渲染时就执行了。
  • setState 要给新数组:添加用 [todo, ...this.state.todos],删除用 filter(返回新数组且长度变短),不能直接 push。
  • window.confirm 点确定返回 true,取反为 false 不 return,继续删除;点取消返回 false,取反为 true 直接 return,什么都不发生。
  • 添加成功后别忘了 e.target.value = '' 清空输入框。

常见错误 ​

  1. 本地图片 404 / 找不到模块:现象是 import img from './assets/1.jpeg' 或 require('./assets/1.jpeg') 报错、页面图片不显示。原因是图片没放在 src 目录内,或 require 路径无法被静态解析。修法是把图片移到 src/assets,静态图片用 import,需要按状态拼接路径时用可解析的 require(&#96;./assets/images/${index}.jpeg&#96;)。

  2. 事件回调里 this 为 undefined:现象是调用 this.props.addTodo 或 this.setState 时报 Cannot read properties of undefined。原因是普通函数作为事件回调时 this 丢失。修法是用箭头函数定义回调,或传方法时 this.addTodo.bind(this),或在调用处用箭头函数包裹。

  3. 删除按钮一渲染就触发删除:现象是页面加载后直接执行删除,或传入的 id 为 undefined。原因是写成 onClick={deleteById(item.id)},这里执行的是函数调用而不是传函数。修法是改成 onClick={() => deleteById(item.id)}。

  4. 父组件传方法给子组件后,子组件调用报错:现象是子组件能拿到方法名,但执行时 this 不是父组件实例。原因是父组件传方法时未处理 this 指向。修法是父组件定义箭头函数方法,或传 this.deleteById.bind(this)。

  5. 列表渲染出现 key 警告:现象是控制台提示 Each child in a list should have a unique "key" prop。原因是 map 渲染时没有给每一项稳定唯一的 key。修法是使用数据中的唯一值,如 key={item.id},不要用随机数或数组下标兜底。

  6. 受控 input 只给 checked 不给 onChange:现象是 React 警告 You provided a "checked" prop to a form field without an "onChange" handler。原因是把 input 变成了受控组件却没有变更处理。修法是补 onChange;若只是展示初始状态,可改 defaultChecked 或加 readOnly。

  7. 子组件从 state 取父组件传来的数据:现象是 this.state.todos 为 undefined,统计或渲染报错。原因是数据是通过 props 传入的,不在子组件自己的 state 上。修法是 this.props.todos 解构后使用。

练习 ​

  1. 图片处理练习:在 src/assets/images 放 3 张图片,分别用 import 和 require 渲染;再用一个 state.index 切换 require 的图片路径,验证动态导入效果。

  2. 生命周期练习:写一个类组件,在 constructor、render、componentDidMount 中分别打印标记,观察执行顺序;在 componentDidMount 中开启定时器,并在卸载阶段清理。

  3. 首屏渲染练习:用 nanoid 生成 3 条待办数据,放在共同父组件 TodoList 的 state 中;把数据传给 Footer 统计总数和已完成数,传给 Main 用 map 渲染列表,key 使用 id。

  4. 添加待办练习:在 Header 中获取输入内容,按回车后通过子传父把 title 交给父组件;父组件拼接新待办并追加到数组,添加成功后清空输入框。

  5. 删除待办练习:在 Item 中点击删除按钮,把 id 经 Main 传到 TodoList;父组件用 confirm 确认后,用 filter 删除对应项,并检查取消时不应删除。

原视频对照 ​

#视频时长
1todoList静态布局运行01:20
2首屏数据渲染实现17:51
3用户交互功能实现思路09:20
4添加todo-0206:55
5删除todo13:05

小结 ​

  1. 本地图片必须进入 src 体系;静态路径优先 import,需要状态控制路径时用 require,但要保证路径可解析。
  2. 类组件挂载顺序固定为 constructor → render → componentDidMount;定时器、请求、订阅等副作用放在挂载后,并考虑清理。
  3. 多个组件共享的数据应提升到共同父级,再通过 props 向下传递,避免兄弟组件互相绕路取数。
  4. 子传父的核心是“数据在谁身上,方法就定义在谁身上”;子组件只负责调用并传参,不直接改父级数据。
  5. 事件绑定要区分“传函数”和“调用函数”;需要参数时用箭头函数包裹,同时注意回调中的 this 指向。
  6. 列表渲染必须给稳定唯一 key;数组删除优先使用返回新数组的方法,再通过 setState 触发更新。
  7. 交互功能先描述“做了什么、产生什么结果”,再拆步骤、匹配技术点,最后逐步打印验证。
  8. 受控表单属性要成对出现:有 value/checked 就要有对应的 onChange,否则先补处理逻辑或改为非受控写法。