主题
React 基础与 JSX(二)
本页对应 React 基础与 JSX,共 8 段视频,约 1 小时 30 分。
学习目标
- 能够使用
useState定义组件状态,并将状态渲染到 JSX 中,通过调用状态修改函数触发组件重新渲染。 - 能够解释并遵循“状态不可变”规则,使用展开语法、
filter、map等方式正确更新数组或对象类型状态。 - 能够使用组件拆分 B 站评论案例结构,并正确处理 SCSS 样式依赖与本地图片导入。
- 能够基于状态完成评论列表的
map渲染、条件渲染、删除操作,以及喜欢/不喜欢的高亮和数量联动。 - 能够实现导航 Tab 的动态渲染、高亮切换与评论列表排序,并使用
classnames优化动态类名处理。
知识点
先弄清楚状态解决什么问题
React 是数据驱动视图的框架,但真正让页面内容发生变化的“开关”是状态。想让计数器从 10 变成 11,不是去操作 DOM,而是修改状态;状态一变,组件重新渲染,页面自然更新。
怎么想:useState 调用后返回一个数组,第一项是状态值,第二项是修改状态的函数;参数是初始值。怎么做:
jsx
import { useState } from 'react'
function App() {
const [count, setCount] = useState(10)
return <button onClick={() => setCount(count + 1)}>{count}</button>
}易错点:count 是用 const 声明的,为什么还能变?因为每次重新渲染都会重新执行组件函数、重新调用 useState,拿到的是一个全新的变量,第一次是 10,第二次是 11,它们并不冲突。
修改状态的规则:状态不可变
为什么需要这条规则:React 判断“要不要重新渲染”时,会比较新旧状态是不是同一个引用。直接改当前状态,引用没变,React 就拒绝渲染。
怎么想:不要直接修改当前状态,而要创建新值。怎么做:
jsx
// 错误:直接改当前状态
count += 1
// 正确:创建新值
setCount(count + 1)
// 数组:添加、删除、修改
setList([...list, '橘子'])
setList(list.filter(item => item !== '苹果'))
setList(list.map(item => item === '苹果' ? 'apple' : item))
// 对象:修改属性
setUser({ ...user, name: '李四' })易错点:list.push('橘子') 之后再 setList(list),页面不会变,因为数组还是同一个;删除用 filter,修改用 map,添加用展开运算符,核心都是“造一个新数组/新对象”。
案例结构:组件拆分与两个工程细节
B站评论案例分成三块:顶部导航 Tab、中间发表评论区、底部评论列表。先用 App 组件把结构搭起来,再用两个细节把环境跑通。
第一,使用 Sass 要手动装包,否则会报“找不到模块 sass”:
bash
npm i sassjsx
import './App.scss'第二,本地图片不能直接把相对路径写进 src,必须先导入:
jsx
import avatar from './images/avatar.png'
<img src={avatar} alt="用户头像" />易错点:在线图片可以直接写 URL,本地图片必须 import,否则图片会加载失败。
评论列表渲染:用 map 把状态变成 UI
为什么用状态存列表:点赞、删除都会改变列表内容,只有状态变化才能驱动页面更新。
jsx
const [list, setList] = useState(defaultList)
{list.map(item => (
<div className="comment-item" key={item.rpid}>
<img src={item.user.avatar} alt="用户头像" />
<span>{item.user.uname}</span>
<p>{item.content}</p>
<span>{item.ctime}</span>
<span>{item.like}</span>
</div>
))}
{list.length === 0 && <div className="empty">暂无评论</div>}易错点:key 要用唯一值,这里用 rpid;条件渲染用逻辑与 &&,不要写成 if 语句塞在 JSX 里;评论数量直接用 list.length。
删除评论:条件渲染 + filter
为什么不能每项都显示删除按钮:只有自己发表的评论才能删,判断依据是当前登录用户的 uid 和评论作者的 uid 是否相同。
jsx
{item.user.uid === currentUser.uid && (
<button onClick={() => onDelete(item.rpid)}>删除</button>
)}
function onDelete(rpid) {
setList(list.filter(item => item.rpid !== rpid))
}易错点:不要用 splice 直接改原数组;过滤后一定要调用 setList,状态才会更新。
喜欢和不喜欢:用 map 修改数组元素
高亮由 action 控制:0 未表态、1 喜欢、2 不喜欢。点击喜欢时,action 取反,同时数量加减:
jsx
function onLike(rpid) {
setList(list.map(item => {
if (item.rpid !== rpid) return item
return {
...item,
action: item.action === 1 ? 0 : 1,
like: item.action === 1 ? item.like - 1 : item.like + 1
}
}))
}不喜欢的业务逻辑稍有不同:如果当前已经喜欢,数量要减一;如果本来没喜欢,数量不变:
jsx
like: item.action === 1 ? item.like - 1 : item.like易错点:修改数组元素必须用 map,不是要改的那一项要原封不动返回;改完一定要 setList。
导航 Tab:渲染、高亮与排序
Tab 的文字内容不会变,所以不必做成状态,直接拿数组渲染即可;但“谁高亮”会变,需要单独一个状态记录:
jsx
const tabs = [
{ type: 'hot', text: '最热' },
{ type: 'time', text: '最新' }
]
const [activeType, setActiveType] = useState('hot')
{tabs.map(item => (
<span
key={item.type}
className={activeType === item.type ? 'active' : ''}
onClick={() => onTabChange(item.type)}
>{item.text}</span>
))}排序推荐用 lodash 的 orderBy,三个参数分别是“对谁排、按谁排、升序还是降序”:
bash
npm i lodashjsx
import orderBy from 'lodash/orderBy'
function onTabChange(type) {
setActiveType(type)
if (type === 'time') {
setList(orderBy(list, 'ctime', 'desc'))
} else {
setList(orderBy(list, 'like', 'desc'))
}
}易错点:orderBy 返回新数组,必须交给 setList;升序是 'asc',降序是 'desc'。
用 classnames 优化动态类名
为什么需要:当一个元素有多个动态类名时,三元表达式会层层嵌套,很难读。classnames 可以把“固定类名”和“条件类名”分开写:
bash
npm i classnamesjsx
import classNames from 'classnames'
<button
className={classNames('btn', {
'btn-disable': disabled,
'btn-small': size === 'small',
'btn-large': size === 'large'
})}
/>单个动态类名也可以用逻辑与:classNames('btn', disabled && 'btn-disable')。易错点:对象写法中键是类名,值是布尔值,只有为 true 才会添加该类名。
先明确:这一节只做“结构”
为什么需要:B站评论案例后面要接状态、列表渲染、删除、点赞等逻辑,如果一开始没有稳定的 DOM 结构,后面就没有地方挂数据和事件。
怎么想:先按页面视觉把案例拆成“评论导航区、发表评论区、评论列表区”,用固定类名把骨架搭出来;数据先写死,等结构稳定后再替换成状态。
怎么做:在 App 组件中写 JSX,保留 reply-navigation、box-normal、reply-list、reply-none 等类名,样式文件单独导入。
易错点:JSX 里写 className,不要写 class;注释用 {/* ... */};图片的 alt 要写中文,便于可读性和无障碍。
三块区域怎么拆
为什么需要:结构不清楚,后面做“最新/最热排序、发表评论、删除、喜欢不喜欢”时容易把事件绑错位置。
怎么想:上部是导航 Tab,中部是当前用户发表评论,下部是评论列表;列表项内部再分头像、用户信息、评论内容、操作区。
怎么做:导航区用 ul.nav-bar,发表区用 textarea 和“发布”按钮,列表区用 reply-list 包多个 reply-item;无数据时预留 reply-none。
易错点:不要把“暂无评论”和评论列表同时写死展示;后续应根据列表长度条件渲染。列表项后续要加唯一 key。
组件入口与样式
为什么需要:脚手架需要一个根组件来承载案例结构,样式也要有统一入口。
怎么想:转写稿中先建 App 组件,再在入口文件里渲染它;样式单独建 app.scss 并导入。
怎么做:创建函数组件并导出,在入口中渲染 <App />;在组件顶部 import './app.scss'。
易错点:组件名要大写;样式路径要相对当前文件;不要把样式直接散落在入口文件里。
Sass 的安装与报错调试
为什么需要:案例样式较多,用 Sass 方便嵌套和维护。
怎么想:React 脚手架默认不一定能解析 .scss,需要让构建工具认识 Sass 语法。
怎么做:先停项目,执行 npm i sass,再重新启动;新建 app.scss 并在组件中导入。
易错点:转写稿里“saras/萨斯”是口误,实际是 Sass;如果没装包会报“找不到模块 sass”,装完要重启项目。
本地头像的导入与破图调试
为什么需要:本地图片要经过构建工具处理,不能像写普通字符串路径那样直接使用。
怎么想:把图片当成模块先导入,再把导入结果交给 src。
怎么做:按转写稿演示,把本地头像导入为 avatar,在 <img src={avatar} alt="用户头像" /> 中使用。
易错点:直接写相对路径作为 src 可能加载失败、图片破损;在线图片才可以直接写 URL。
结构代码(保留)
jsx
<div className="app">
{/* 导航 Tab */}
<div className="reply-navigation">
<ul className="nav-bar">
<li className="nav-title">
<span className="nav-title-text">评论</span>
{/* 评论数量 */}
<span className="total-reply">9</span>
</li>
<li className="nav-sort">
{/* 高亮类名: active */}
<div className="nav-item active">最热</div>
<div className="nav-item">最新</div>
</li>
</ul>
</div>
<div className="reply-wrap">
{/* 发表评论 */}
<div className="box-normal">
{/* 当前用户头像 */}
<div className="reply-box-avatar">
<div className="bili-avatar">
<img className="bili-avatar-img" src={avatar} alt="用户头像" />
</div>
</div>
<div className="reply-box-wrap">
{/* 评论框 */}
<textarea
className="reply-box-textarea"
placeholder="发一条友善的评论"
/>
{/* 发布按钮 */}
<div className="reply-box-send">
<div className="send-text">发布</div>
</div>
</div>
</div>
{/* 评论列表 */}
<div className="reply-list">
{/* 评论项 */}
<div className="reply-item">
{/* 头像 */}
<div className="root-reply-avatar">
<div className="bili-avatar">
<img
className="bili-avatar-img"
src="https://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/reactbase/comment/zhoujielun.jpeg"
alt="周杰伦头像"
/>
</div>
</div>
<div className="content-wrap">
{/* 用户名 */}
<div className="user-info">
<div className="user-name">周杰伦</div>
</div>
{/* 评论内容 */}
<div className="root-reply">
<span className="reply-content">哎呦,不错哦~</span>
<div className="reply-info">
{/* 评论时间 */}
<span className="reply-time">11-13 11:29</span>
{/* 喜欢 */}
<span className="reply-like">
{/* 选中类名: liked */}
<i className="icon like-icon" />
<span>66</span>
</span>
{/* 不喜欢 */}
<span className="reply-dislike">
{/* 选中类名: disliked */}
<i className="icon dislike-icon" />
</span>
<span className="delete-btn">删除</span>
</div>
</div>
</div>
</div>
</div>
<div className="reply-none">暂无评论</div>
</div>
</div>结构完成后的衔接
为什么需要:当前 9、周杰伦评论、66、删除按钮都是写死的,后面必须换成状态数据。
怎么想:导航数量接列表长度;评论项用 map 渲染;删除按钮按“是否当前用户”条件渲染;喜欢/不喜欢按 action 加高亮类名。
怎么做:先把这份结构作为模板,后续逐步把写死内容替换为 list、activeType 等状态。
易错点:不要在结构阶段提前写复杂逻辑;先保证类名、层级、图片和样式正确,再进入数据驱动视图。
样式总览:先分区,再写选择器
为什么需要:B 站评论案例的结构分成评论导航、发表评论框、评论列表和暂无评论四块,样式必须先按区块组织,否则选择器会互相污染。
怎么想:外层 .app 控制整体宽度和居中,内部按 .reply-navigation、.reply-wrap、.reply-list、.reply-none 分块。
怎么做:.app { width: 80%; margin: 50px auto; };列表区用 flex 排版,评论项用 padding-left: 80px 给头像留位。
易错点:百分比宽度要和 margin: auto 配合;ul 要清掉默认 margin/padding/list-style。
评论导航:flex 一行排列,伪元素做分隔线
为什么需要:标题、回复总数、最热/最新要在一行内对齐,并且排序项之间要有竖线分隔。
怎么想:.nav-bar 用 flex 垂直居中;分隔线不额外加标签,用 .nav-item::after 画 1px 竖线。
怎么做:.nav-item::after 设置 height:10px; width:1px; margin:-1px 12px; background:#9499a0;,最后一项 &:last-child::after { display:none; };hover 和 .active 改文字颜色。
易错点:忘记隐藏最后一项的 ::after 会多一条线;.active 的优先级要能覆盖默认色。
回复框:聚焦时扩展,发送按钮用伪元素叠色
为什么需要:输入框默认灰底,聚焦后变白并变高;发送按钮的文字要盖在蓝色背景上。
怎么想:textarea 用 transition: .2s 做过渡,:focus 改高度、背景和边框;发送按钮用 ::after 画背景,.send-text 用 z-index:1 提到上层。
怎么做:.reply-box-textarea:focus { height:60px; background:#fff; border-color:#c9ccd0; };.reply-box-send::after { opacity:.5; },:hover::after { opacity:1; }。
易错点:textarea 要 box-sizing:border-box、resize:none;伪元素必须绝对定位,否则会撑开布局。
头像与评论列表:绝对定位对齐,底部分隔线用伪元素
为什么需要:头像固定在左侧,评论内容右缩进;每条评论底部要有一条浅灰分隔线。
怎么想:.reply-item 设 position:relative 和 padding-left:80px,头像 .root-reply-avatar 绝对定位到左侧;分隔线用 .content-wrap::after。
怎么做:.bili-avatar 设 48px 圆形;.bili-avatar-img 用 top:50%; left:50%; transform:translate(-50%,-50%) 居中,并 object-fit:cover。
易错点:父级没有 position:relative 时绝对定位会跑出容器;图片不设 object-fit 可能变形。
互动区与空状态:雪碧图定位,空列表单独样式
为什么需要:喜欢/不喜欢图标用背景图位置切换,删除按钮 hover 变蓝;没有评论时要显示居中提示。
怎么想:.like-icon、.dislike-icon 固定宽高,通过 background-position 切换普通/高亮;.liked、.disliked 覆盖位置;.reply-none 单独做 64px 行高居中。
怎么做:.reply-info 用 flex 对齐;.delete-btn:hover { color:#00aeec; };.reply-none { height:64px; line-height:64px; text-align:center; }。
易错点:liked 与 :hover 的 background-position 可能冲突,要注意书写顺序;空状态应由数据长度条件渲染控制显示。
工程调试:Sass、本地图片、动态类名
转写稿里的“萨斯”应写作 Sass,“reite”是 React,“GSS”是 JSX,“us state”是 useState,“future”是 filter,“out buy”是 orderBy,“load dash”是 lodash,“class names”是 classnames。
为什么需要:在 React 脚手架里直接用 .scss 会报“找不到模块 sass”;本地图片直接写相对路径会加载失败;多个动态类名手写三元表达式很乱。
怎么想:SCSS 需要 sass 解析包;本地图片要像 JS 模块一样 import 后再作为 src;动态类名用 classnames 统一处理。
怎么做:npm i sass 后重启项目;import avatar from './images/avatar.png';classnames('btn', { 'btn-disable': disabled })。
易错点:装包后要重启;本地图片不能直接写相对路径;classnames 对象里键是类名、值是布尔值。
下面代码中的两张 data URI 图片原样保留;头像图片在模板中应使用中文 alt="用户头像"。
scss
.app {
width: 80%;
margin: 50px auto;
}
.icon {
background: url(data:image/png;base64,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
### 课件图示







代码示例
常见错误
1. 改完数组,页面不动
- 现象:
list.push(新项)后调用setList(list),控制台没有报错,但列表就是不增加;用count++时直接报TypeError: Assignment to constant variable。 - 原因:
const声明的基本类型不能重新赋值;数组和对象虽然能修改内容,但引用没有变化,React 用Object.is对比新旧状态时判定为同一个值,于是跳过渲染。 - 修法:一律创建新值再交给 setter,例如
setList([...list, item])、setCount(count + 1);删除用setList(list.filter(...)),排序用orderBy的返回值,不要用list.sort()原地排序。
2. Sass 样式一引入就白屏报错
- 现象:新建
app.scss并import后,终端提示找不到sass模块,页面编译失败。 - 原因:脚手架只负责把
.scss交给解析器,解析器本身需要单独安装。 - 修法:先
npm i sass,然后停掉 dev server 重新启动;只改样式不重启时,该错误不会自行消失。
3. 本地图片裂图
- 现象:
<img src="../../assets/avatar.png" />渲染出来是破图,控制台报 404。 - 原因:打包工具不会把字符串形式的相对路径当作模块处理,产物目录里没有这个文件。
- 修法:
import avatar from './assets/avatar.png'后再src={avatar};网络图片则可以直接写 URL。
4. 删除按钮每一条都出现
- 现象:明明只有自己发的评论能删,结果三条全带删除按钮。
- 原因:条件渲染漏写,或者比较的字段取错层级(拿
item.uid去比user.uid,实际数据在item.user.uid)。 - 修法:
{item.user.uid === user.uid && <span className="delete-btn">删除</span>},先用console.log打印两边的值确认路径。
5. 点了喜欢,高亮变了但数字没变(或反之)
- 现象:
action更新了,like数量还是原值。 - 原因:
map里return { ...item }只复制了旧对象,新字段在 return 之后才补,或者压根没写进新对象。 - 修法:把要改的字段一次性写在展开之后,
return { ...item, action: 1, like: item.like + 1 };改完必须setList(新数组),忘记调用 setter 一样不渲染。
6. key 用数组下标
- 现象:删掉中间一条后,剩下项的高亮、数量错位,控制台出现 key 警告。
- 原因:
index会随删除重排,React 复用了错误的 DOM 节点。 - 修法:用数据自带的唯一值
key={item.rpid}。
7. 直接把对象塞进 JSX
- 现象:
<div>{user}</div>报Objects are not valid as a React child。 - 原因:对象不是合法的 React 子节点。
- 修法:渲染具体属性
{user.name} {user.age},调试时才用JSON.stringify。
练习
用
classnames的对象语法重写评论项的类名:静态icon like-icon+ 动态liked,以及icon dislike-icon+ 动态disliked,要求两个按钮共四处类名都不出现三元嵌套。给评论列表加一个「只看自己的评论」开关:勾选后只渲染
user.uid === 当前用户 uid的项,取消勾选恢复全部。思考这个过滤结果该不该放进useState。给排序加第三个 Tab「最早」:按
ctime升序。要求不改动原list状态,并说明为什么不能直接list.sort()。实现发表评论:受控
textarea+ 点击发布后把新评论插到列表头部,新评论自带删除按钮。要求处理空内容的情况。把删除改成「点击后二次确认」,确认后删除;并验证删到 0 条时「暂无评论」自动出现。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 组件的状态 | 09:39 |
| 2 | 修改状态的规则 | 12:53 |
| 3 | B站评论案例-1使用组件搭建案例结构 | 09:51 |
| 4 | B站评论案例-2评论列表的渲染 | 07:56 |
| 5 | B站评论案例-2评论列表的删除 | 06:41 |
| 6 | B站评论案例-3评论列表的喜欢和不喜欢 | 16:42 |
| 7 | B站评论案例-3导航Tab的渲染和操作 | 18:28 |
| 8 | 使用classnames优化类名处理 | 08:43 |
小结
useState每次渲染都会重新执行,第一次拿到默认值,之后拿到最新值——所谓「常量能变」的错觉,本质是函数体重跑产生了新的变量。- 判断要不要重渲染看的是引用是否变化,不是内容是否变化;因此「状态不可变」是一条执行层面的硬约束,不是编码风格建议。
map渲染、filter删除、map改元素、orderBy/slice+sort排序,四件事的共同点是都返回新数组,记住这条主线,就不会写出原地修改的代码。- 能算出来的数据不要存状态:评论总数、是否显示「暂无评论」都由
list.length派生,多存一份只会多一个同步 bug。 - 只有会变的东西才进 state:Tab 的文字数组是常量,只有「当前选中的 type」需要状态;评论数据会变,所以整个列表进 state。
- 条件渲染用
&&,类名条件超过一个就换classnames对象语法,可读性差距随条件数量线性放大。 - 静态资源(scss、本地图片)必须按模块导入,路径字符串在打包后不存在——这是工程约定,不是 React 的限制。
- 业务逻辑优先于 API:不喜欢按钮的「数量减一 / 不变」取决于此前是否点过喜欢,先写清分支再写代码,比边写边调更快。