主题
图片处理与类组件的生命周期(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(`./assets/images/${index}.jpeg`)。
易错点:不要把 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(),把上述逻辑放进去。易错点:它只会在挂载完成后执行一次,不要把它当成每次更新都执行的钩子;如果在这里开了定时器、订阅或自定义事件,后续卸载时要清理,否则容易造成泄漏。笔记列出的四个作用可以原样记住:
- 开启定时器
- 发送 ajax 请求
- 订阅消息
- 添加自定义事件
结合 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. 图片处理: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 = ''清空输入框。
常见错误
本地图片 404 / 找不到模块:现象是
import img from './assets/1.jpeg'或require('./assets/1.jpeg')报错、页面图片不显示。原因是图片没放在src目录内,或require路径无法被静态解析。修法是把图片移到src/assets,静态图片用import,需要按状态拼接路径时用可解析的require(`./assets/images/${index}.jpeg`)。事件回调里
this为undefined:现象是调用this.props.addTodo或this.setState时报Cannot read properties of undefined。原因是普通函数作为事件回调时this丢失。修法是用箭头函数定义回调,或传方法时this.addTodo.bind(this),或在调用处用箭头函数包裹。删除按钮一渲染就触发删除:现象是页面加载后直接执行删除,或传入的
id为undefined。原因是写成onClick={deleteById(item.id)},这里执行的是函数调用而不是传函数。修法是改成onClick={() => deleteById(item.id)}。父组件传方法给子组件后,子组件调用报错:现象是子组件能拿到方法名,但执行时
this不是父组件实例。原因是父组件传方法时未处理this指向。修法是父组件定义箭头函数方法,或传this.deleteById.bind(this)。列表渲染出现 key 警告:现象是控制台提示
Each child in a list should have a unique "key" prop。原因是map渲染时没有给每一项稳定唯一的key。修法是使用数据中的唯一值,如key={item.id},不要用随机数或数组下标兜底。受控
input只给checked不给onChange:现象是 React 警告You provided a "checked" prop to a form field without an "onChange" handler。原因是把input变成了受控组件却没有变更处理。修法是补onChange;若只是展示初始状态,可改defaultChecked或加readOnly。子组件从
state取父组件传来的数据:现象是this.state.todos为undefined,统计或渲染报错。原因是数据是通过props传入的,不在子组件自己的state上。修法是this.props.todos解构后使用。
练习
图片处理练习:在
src/assets/images放 3 张图片,分别用import和require渲染;再用一个state.index切换require的图片路径,验证动态导入效果。生命周期练习:写一个类组件,在
constructor、render、componentDidMount中分别打印标记,观察执行顺序;在componentDidMount中开启定时器,并在卸载阶段清理。首屏渲染练习:用
nanoid生成 3 条待办数据,放在共同父组件TodoList的state中;把数据传给Footer统计总数和已完成数,传给Main用map渲染列表,key使用id。添加待办练习:在
Header中获取输入内容,按回车后通过子传父把title交给父组件;父组件拼接新待办并追加到数组,添加成功后清空输入框。删除待办练习:在
Item中点击删除按钮,把id经Main传到TodoList;父组件用confirm确认后,用filter删除对应项,并检查取消时不应删除。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | todoList静态布局运行 | 01:20 |
| 2 | 首屏数据渲染实现 | 17:51 |
| 3 | 用户交互功能实现思路 | 09:20 |
| 4 | 添加todo-02 | 06:55 |
| 5 | 删除todo | 13:05 |
小结
- 本地图片必须进入
src体系;静态路径优先import,需要状态控制路径时用require,但要保证路径可解析。 - 类组件挂载顺序固定为
constructor → render → componentDidMount;定时器、请求、订阅等副作用放在挂载后,并考虑清理。 - 多个组件共享的数据应提升到共同父级,再通过
props向下传递,避免兄弟组件互相绕路取数。 - 子传父的核心是“数据在谁身上,方法就定义在谁身上”;子组件只负责调用并传参,不直接改父级数据。
- 事件绑定要区分“传函数”和“调用函数”;需要参数时用箭头函数包裹,同时注意回调中的
this指向。 - 列表渲染必须给稳定唯一
key;数组删除优先使用返回新数组的方法,再通过setState触发更新。 - 交互功能先描述“做了什么、产生什么结果”,再拆步骤、匹配技术点,最后逐步打印验证。
- 受控表单属性要成对出现:有
value/checked就要有对应的onChange,否则先补处理逻辑或改为非受控写法。