主题
React 基础与 JSX(一)
本页对应 React 基础与 JSX,共 8 段视频,约 1 小时 38 分。
学习目标
- 能使用 CRA 创建并启动 React 项目,完成 SRC 目录精简,仅保留并清空入口文件,验证页面无报错。
- 能按“导入 createRoot、创建 root 对象、调用 render”三步将 JSX 内容渲染到指定 DOM 节点,并说明 React 与 ReactDOM 的导入差异。
- 能编写符合 JSX 三条规则的页面结构(单一根节点、标签闭合、属性驼峰/className),并借助 Babel 在线工具说明 JSX 转换为 React.createElement 及 JS 对象的过程。
- 能配置 VSCode 的 Emmet、Prettier 保存格式化、Auto Rename Tag,并实际演示 JSX 快速生成、自动格式化和成对标签同步修改。
- 能使用
{}完成列表渲染(map + 唯一 key)、条件渲染(逻辑与/三元)、动态 className 与行内样式,独立实现菜单案例的结构和样式。 - 能使用行内函数和抽离函数两种方式绑定 React 事件,通过事件对象阻止默认行为,并创建首字母大写的函数组件,按根组件与其他组件约定完成导入、导出和渲染。
知识点
说明:本节笔记仅提供“B站评论案例模板”标题,未附图片与代码;转写稿实际讲的是 React 入门,不含完整的 B 站评论案例实现。因此下面只依据转写稿中可确认的内容整理,不虚构评论模板代码,也不补不存在的图片。
先明确:React 是库,不是框架
为什么需要:先分清“库”和“框架”,才知道写 React 时为什么没有 v-if、v-for 这类指令。转写稿明确说 React 是渲染用户界面的 JavaScript 库,不是框架。
怎么想:Vue 这类框架会规定写法,React 作为库更灵活,页面结构、样式、逻辑都要用 JS 组织起来。
怎么做:把 React 理解成“用 JS 渲染 UI 的库”,后续评论案例中的列表、条件、事件都用 JS 表达式完成。
易错点:不要把 React 说成框架;不要把转写稿中的“reite、right”写成原词,正确是 React;GS 是 JS,ATML 是 HTML。
搭建可运行环境:CRA 两步
为什么需要:没有开发环境就无法体验 JSX、事件和组件。
怎么想:官方推荐 create-react-app,简称 CRA;先创建项目,再启动项目。
怎么做:在终端执行:
bash
npx create-react-app react-basic
cd react-basic
npm start默认地址是 localhost:3000。项目重点看 public/index.html 和 src,src 是写代码的地方。
易错点:第一次创建较慢;启动前要先 cd 进项目目录;不要随意改 public/index.html。
三步把内容渲染到页面
为什么需要:要知道 React 把内容渲染到哪个 DOM 节点。
怎么想:先导包,再创建 root,最后 render。
怎么做:
jsx
import { createRoot } from 'react-dom/client'
const root = createRoot(document.querySelector('#root'))
root.render(<h1>hello react</h1>)#root 来自 public/index.html 中 id 为 root 的 div。
易错点:createRoot 参数必须是 DOM 对象;渲染相关从 react-dom/client 导入;不要写成 andy find,正确是 undefined。
JSX 三条硬规则
为什么需要:JSX 像 HTML,但不是 HTML,不守规则会报错。
怎么想:JSX 是写在 JS 中的类 HTML 结构,最终会转成 JS 对象。
怎么做:必须有一个根节点;所有标签必须闭合;属性用驼峰命名,如 className、alt。例如:
jsx
<div className="box">
<h1>hello JSX</h1>
<img src="..." alt="头像" />
</div>易错点:类名不能写 class,要写 className;img 必须闭合且建议写 alt;多个并列标签外面要包根节点。
用 {} 完成列表、条件与样式
为什么需要:真实页面不是静态的,评论列表、空状态、选中样式都需要动态处理。
怎么想:列表用数组 map;条件用逻辑与或三元;样式用 className 加样式文件。
怎么做:
jsx
<ul>
{list.map(item => (
<li key={item.id} className={item.id === selectedId ? 'selected' : ''}>
{item.name}
</li>
))}
</ul>条件渲染可写 {index === 0 && <img src="..." alt="推荐" />};样式文件要 import './index.css'。
易错点:列表项必须加唯一 key;false 不会渲染;动态类名仍用 className;样式文件不导入不生效。
事件绑定与事件对象
为什么需要:评论案例中的删除、点击高亮都依赖事件。
怎么想:事件名以 on 开头,采用驼峰;处理程序是函数。
怎么做:
jsx
<button onClick={() => alert('hello')}>按钮</button>
<button onClick={handleClick}>删除</button>需要阻止默认行为时:
jsx
function handleClick(e) {
e.preventDefault()
}易错点:onClick 不要写成 onclick;抽离函数时传函数名,不要写 handleClick(),否则页面加载就执行;事件对象参数常写 e。
组件:函数即组件,首字母大写
为什么需要:评论案例会被拆成评论列表、评论项、删除按钮等组件。
怎么想:React 组件就是一个返回 JSX 的 JS 函数。
怎么做:
jsx
function App() {
return <div>component</div>
}
export default App根组件一般放 src 下;其他组件可用文件夹管理,组件 JS、样式 CSS 放同一文件夹。
易错点:组件名首字母必须大写;组件要导出后在入口或其他组件中导入使用;逻辑写在函数体内,样式继续用 className 方式。
收束到“B站评论案例模板”
转写稿只讲到评论删除按钮这类交互场景,没有给出完整 B 站评论模板。因此本节可确认的模板骨架是:评论列表用 map + key,删除按钮用 onClick,空列表用条件渲染,选中/普通样式用动态 className,再把列表、评论项拆成组件。具体模板代码和图片需以原笔记为准,不能凭空补写。
先搭骨架:评论区的静态结构
为什么需要:页面开发先有结构,再有样式和交互;评论区区域多、层级深,先把JSX骨架搭稳,后面加高亮、发布、删除才不容易乱。怎么想:按视觉区域拆成“导航Tab—发表评论—评论列表—暂无评论”四块,每块用语义清晰的类名包住。怎么做:用外层app包住,内部依次写reply-navigation、reply-wrap;评论项先写一条静态数据,方便后续用map列表渲染。易错点:JSX必须有一个根节点;所有标签必须闭合;图片要写alt,否则会有警告。转写稿里调试时去掉根div会报“必须具有一个父元素”,img不闭合会报缺少结束标记,这些都要在结构阶段避免。
JSX不是HTML:类名、注释与图片
为什么需要:JSX看起来像HTML,但浏览器不能直接执行,它要经过转换;写法上比HTML更严格。怎么想:把“类名、注释、图片属性”当成JSX规则处理,而不是照抄HTML。怎么做:类名统一写className,例如className="app";注释用{/* 导航 Tab */};图片写<img className="bili-avatar-img" src={avatar} alt="用户头像" />。易错点:写class会报无效DOM属性,应改className;HTML注释<!-- -->不能直接放JSX里;img缺少alt会警告。转写稿中从class改成className后样式才生效,这个调试过程要记住。
导航与选中态:给后续状态留钩子
为什么需要:导航有“最热/最新”,评论有“喜欢/不喜欢”,后续要靠状态切换高亮类名。怎么想:结构阶段先写死一个示例选中态,注释标明“高亮类名:active”“选中类名:liked/disliked”,方便后面用三元表达式控制。怎么做:<div className="nav-item active">最热</div>,喜欢处写<i className="icon like-icon" />并在注释中说明liked。易错点:不要现在把active写成class;一个导航组里通常只应有一个active;类名拼写要和样式文件一致。
发表评论区与评论项:输入、按钮、头像和元信息
为什么需要:发表评论区承载输入和发布入口,评论项承载头像、用户名、内容、时间、点赞和删除。怎么想:输入框用textarea,发布按钮先用div.send-text占位;评论项按“头像—内容区—用户信息—评论内容—操作信息”嵌套。怎么做:保留textarea的placeholder="发一条友善的评论";评论项图片alt改为中文“周杰伦头像”。易错点:textarea在JSX中可自闭合;后续给按钮绑事件要用onClick驼峰,处理函数名不要加小括号,否则页面加载时就会执行。转写稿中onClick={handleClick()}导致立即调用并报undefined,正确写法是onClick={handleClick}。
空态与列表渲染的预备
为什么需要:评论可能为空,需要有“暂无评论”占位;评论列表未来是动态数组。怎么想:先写一条.reply-item作为模板,后续用数组map生成,并给每项加唯一key。怎么做:保留<div className="reply-none">暂无评论</div>;列表项结构完整写出,后续把静态“周杰伦”替换为item数据。易错点:列表渲染必须加key;空态和列表不要同时展示;图片src和alt都要保留。
完整结构代码
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>先看整体:评论模块为什么要这样分块
为什么需要:页面结构有了之后,如果没有样式,导航、输入框、评论列表会挤在一起,无法阅读。转写稿里反复强调“样式文件原来怎么写,现在还是怎么写,但必须 import 导入才生效”。
怎么想:先把评论模块拆成容器、导航、输入区、评论列表、空状态五块,再分别写样式。
怎么做:外层 .app 用 width: 80%; margin: 50px auto; 定宽居中;内部按 .reply-navigation、.box-normal、.reply-list 组织。
易错点:margin: 50px auto 的水平居中依赖块级元素有明确宽度;React 中给 JSX 加类名要写 className,不是 class,否则控制台会提示 Invalid DOM property class. Did you mean className?。转写稿里的“class name”应写作 className。
图标与头像:data URI 背景图怎么用
为什么需要:小图标和头像占位图用 data URI 可以减少额外请求,样式文件自带图片数据。
怎么想:小图标用 background: url(...) no-repeat,头像用 background-size: cover 加 border-radius: 50%;真实头像图再用绝对定位居中。
怎么做:.icon 放评论图标背景并设 cursor: pointer;.bili-avatar 设 48×48、圆角、背景图;.bili-avatar-img 用 top/left 50% + translate(-50%,-50%) 居中,并加 object-fit: cover。中文 alt 说明:评论图标、用户头像。
易错点:data URI 不要随意换行截断;头像图要 object-fit: cover 防止变形;背景图尺寸要配 background-size。转写稿里的“GSX”应写作 JSX,“图封命名法”应写作驼峰命名法。
导航栏:flex 排列与伪元素分隔线
为什么需要:标题、回复总数、排序项要横向对齐,排序项之间要有分隔线。
怎么想:.nav-bar 用 display:flex; align-items:center,去掉列表默认样式;分隔线不额外加标签,用 ::after。
怎么做:.nav-title 定宽 114px;.nav-sort 用 flex;.nav-item::after 画 1px 竖线,last-child::after { display:none; };.active 改深色。
易错点:list-style:none、margin/padding:0 要清掉;last-child 必须隐藏最后一条分隔线;伪元素要写 content:' '。转写稿说动态类名可用三元表达式,active 可由状态比较后拼接。
输入区:聚焦展开与发送按钮遮罩
为什么需要:输入框聚焦时要变高、变白,发送按钮要有半透明遮罩和 hover 效果。
怎么想:.box-normal 用 flex;textarea 用 transition:0.2s;聚焦时改高度、背景、边框;发送按钮用 ::after 做遮罩层。
怎么做:.reply-box-textarea 设 box-sizing:border-box、resize:none、outline:none;:focus 改 height:60px;.reply-box-send::after 绝对定位铺满,opacity:0.5,:hover::after 改 1;.send-text 用 z-index:1 压在遮罩上。
易错点:textarea 不设 box-sizing 容易溢出;伪元素遮罩要绝对定位且父级相对定位;文字层级别被遮罩盖住。转写稿提到行内 style 的值是对象、数值可省略 px,但这里仍推荐类名方式。
评论列表:头像定位、分割线与空状态
为什么需要:每条评论头像在左、内容在右,底部要有分割线;没有评论时要有空状态提示。
怎么想:.reply-item 用 padding-left:80px 给头像留位;头像容器绝对定位到左侧;分割线用 .content-wrap::after 画 1px 线;.reply-info 用 flex 放时间、点赞、点踩、删除。
怎么做:.reply-item 设 position:relative;.root-reply-avatar 设 position:absolute; left:0; width:80px;.content-wrap::after 设高度 1px、背景 #e3e5e7;点赞/点踩图标用 background-position 切换 hover 和已选状态;.reply-none 设高度 64px、行高 64px、居中。
易错点:绝对定位头像的父级要相对定位;分割线用伪元素可少写空标签;.liked、.disliked 背景位置别写错;.reply-none 的 line-height 要和高度一致。转写稿强调样式不生效先查 import;组件样式一般和组件 JSX 放同一文件夹,根组件放 src,其他组件用文件夹管理,样式文件在组件内单独导入。
完整 SCSS 代码
图片以 data URI 保留;中文 alt 说明:评论图标、用户头像。
scss
.app {
width: 80%;
margin: 50px auto;
}
.icon {
background: url(data:image/png;base64,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
### 课件图示







代码示例
示例一:JSX 中 {} 的应用(列表渲染 / 条件渲染 / 样式处理)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>JSX 中 {} 的应用:菜单案例</title>
<style>
body { margin: 40px; font-family: -apple-system, "Microsoft YaHei", sans-serif; }
/* 相当于 React 项目里 import 进来的 index.css */
.list {
width: 100px;
padding: 0;
margin: 0;
list-style: none;
background-color: #f8f8f8;
}
.list-item {
padding: 10px 12px;
font-size: 14px;
cursor: pointer;
}
.list-item .icon {
width: 12px;
height: 12px;
margin-right: 4px;
vertical-align: middle;
}
.selected {
color: #00aeec;
font-weight: 600;
}
</style>
</head>
<body>
<div id="root"></div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { createRoot } = ReactDOM;
// 菜单数据:每一项的 id 唯一
const menus = [
{ id: 1, name: '推荐' },
{ id: 2, name: '一人套餐' },
{ id: 3, name: '西贝凉菜' },
{ id: 4, name: '米饭' },
{ id: 5, name: '饮料' },
];
function App() {
// 记录当前高亮的菜单 id
const selectedId = 3;
return (
<ul className="list">
{menus.map((item, index) => (
<li
key={item.id}
className={`list-item ${item.id === selectedId ? 'selected' : ''}`}
>
{/* 条件渲染:只有第一项(index === 0)才渲染小图标 */}
{index === 0 && <img className="icon" src="推荐.png" alt="推荐" />}
{item.name}
</li>
))}
</ul>
);
}
const root = createRoot(document.querySelector('#root'));
root.render(<App />);
</script>
</body>
</html>关键行与易错点
- 列表渲染用数组的
map:Vue 里是v-for指令,React 没有指令,只能自己在{}里调用map手动映射出结构;return出来的 JSX 就是每一项的结构。 key必须加且必须唯一:key={item.id}用 id 而不是写死字符串,否则控制台会报「列表中的每一个子节点都应该有一个唯一的 key 属性」。- 加类名用
className而不是class:写成class会报「无效的 DOM 属性 class,你是不是想写 className」。 index === 0 && <img />:逻辑与适合「要么渲染、要么不渲染」的单内容场景;条件不成立时返回false,而布尔值在 JSX 里不渲染,所以页面上什么都不会出现。- 三元
? :更适合两个内容二选一:本行类名用三元拼出selected,item.id === selectedId成立才高亮,不成立就给空字符串。 <img>必须闭合且带alt:JSX 比 HTML 严格,单标签要写成自闭合,缺少alt会有警告;src请换成你自己的图标地址。- 行内样式
style的值是对象:style={{ width: 200, backgroundColor: 'skyblue' }}是「双花括号 = 外层 JSX 表达式 + 内层 JS 对象」,数值型样式可省略px。
示例二:React 事件绑定 + 组件
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>React 事件绑定 + 组件</title>
<style>
.app { margin: 40px; }
.box {
width: 200px;
height: 100px;
margin-bottom: 20px;
line-height: 100px;
color: #fff;
text-align: center;
background-color: skyblue;
cursor: pointer;
}
.header { width: 200px; height: 200px; background-color: skyblue; }
</style>
</head>
<body>
<div id="root"></div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { createRoot } = ReactDOM;
// 一个 React 组件就是一个 JS 函数,返回 JSX
// 与父组件关联度高、结构又很少的子组件,可以直接写在同一个文件里
function Child() {
return <h1>Header 的子组件</h1>;
}
function Header() {
return (
<div className="header">
<Child />
</div>
);
}
function App() {
// 写法二:事件处理程序单独抽离成函数
const handleClick = (e) => {
e.preventDefault(); // 阻止 a 标签的默认跳转
alert('欢迎来到黑马前端');
};
return (
<div className="app">
{/* 写法一:逻辑简单,事件处理程序直接写在标签上 */}
<div className="box" onClick={() => console.log('你好')}>
点我打印日志
</div>
<a href="https://www.itheima.com" onClick={handleClick}>
黑马程序员(点击不跳转)
</a>
<Header />
</div>
);
}
const root = createRoot(document.querySelector('#root'));
root.render(<App />);
</script>
</body>
</html>关键行与易错点
- 事件名是驼峰且以
on开头:onClick而不是onclick;VS Code 里敲小写onclick会自动提示补成标准写法。 - 事件处理程序的值是函数,不是字符串:
onClick={() => console.log('你好')},括号里写函数体;只有一行代码时还可以省掉{}。 - 抽离写法千万不要加小括号:
onClick={handleClick}正确;onClick={handleClick()}会在页面一进来就立即执行,且因为没传参e是undefined,e.preventDefault()直接报错,同时onClick变成undefined,等于没绑定。 - 事件对象从参数拿:
const handleClick = (e) => { e.preventDefault() },与原生一致,用于阻止 a 标签默认跳转。 - 组件名首字母必须大写:
<Header />能渲染,<header />会被当成原生标签,页面空白并报错。 - 组件 = 函数:逻辑直接写在函数体里,结构用
return返回 JSX,样式依旧走className+ 样式文件。 - 多组件同文件的场景:
Child与Header关联度高、结构少,写在同一个.jsx里更省事;正式项目中独立组件通常各建一个文件夹(Header/Header.jsx+Header/index.css)。
示例三:B 站评论案例(组件结构 + SCSS)
jsx
// App.jsx
import './App.scss';
// 当前用户头像,替换为你自己的图片
import avatar from './avatar.png';
function App() {
return (
<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>
);
}
export default App;scss
// App.scss
.app {
width: 80%;
margin: 50px auto;
}
.icon {
// 雪碧图 base64 见课件 .icon 的 background 声明,此处省略
background-repeat: no-repeat;
cursor: pointer;
}
.reply-navigation {
margin-bottom: 22px;
.nav-bar {
display: flex;
align-items: center;
margin: 0;
padding: 0;
list-style: none;
.nav-title {
display: flex;
align-items: center;
width: 114px;
font-size: 20px;
.nav-title-text {
color: #18191c;
font-weight: 500;
}
.total-reply {
margin: 0 36px 0 6px;
color: #9499a0;
font-weight: normal;
font-size: 13px;
}
}
.nav-sort {
display: flex;
align-items: center;
color: #9499a0;
font-size: 13px;
.nav-item {
cursor: pointer;
&:hover {
color: #00aeec;
}
&:last-child::after {
display: none;
}
&::after {
content: ' ';
display: inline-block;
height: 10px;
width: 1px;
margin: -1px 12px;
background-color: #9499a0;
}
}
.nav-item.active {
color: #18191c;
}
}
}
}
.reply-wrap {
position: relative;
}
.box-normal {
display: flex;
transition: 0.2s;
.reply-box-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 50px;
}
.reply-box-wrap {
display: flex;
position: relative;
flex: 1;
.reply-box-textarea {
width: 100%;
height: 50px;
padding: 5px 10px;
box-sizing: border-box;
color: #181931;
font-family: inherit;
line-height: 38px;
background-color: #f1f2f3;
border: 1px solid #f1f2f3;
border-radius: 6px;
outline: none;
resize: none;
transition: 0.2s;
&::placeholder {
color: #9499a0;
font-size: 12px;
}
&:focus {
height: 60px;
background-color: #fff;
border-color: #c9ccd0;
}
}
}
.reply-box-send {
position: relative;
display: flex;
flex-basis: 86px;
align-items: center;
justify-content: center;
margin-left: 10px;
border-radius: 4px;
cursor: pointer;
transition: 0.2s;
& .send-text {
position: absolute;
z-index: 1;
color: #fff;
font-size: 16px;
}
&::after {
position: absolute;
width: 100%;
height: 100%;
background-color: #00aeec;
border-radius: 4px;
opacity: 0.5;
content: '';
}
&:hover::after {
opacity: 1;
}
}
}
.bili-avatar {
position: relative;
display: block;
width: 48px;
height: 48px;
margin: 0;
padding: 0;
// 占位图 base64 见课件 .bili-avatar 的 background-image 声明,此处省略
background-size: cover;
border-radius: 50%;
}
.bili-avatar-img {
position: absolute;
top: 50%;
left: 50%;
display: block;
width: 48px;
height: 48px;
object-fit: cover;
border: none;
border-radius: 50%;
image-rendering: -webkit-optimize-contrast;
transform: translate(-50%, -50%);
}
// 评论列表
.reply-list {
margin-top: 14px;
}
.reply-item {
padding: 22px 0 0 80px;
.root-reply-avatar {
position: absolute;
left: 0;
display: flex;
justify-content: center;
width: 80px;
cursor: pointer;
}
.content-wrap {
position: relative;
flex: 1;
&::after {
content: ' ';
display: block;
height: 1px;
width: 100%;
margin-top: 14px;
background-color: #e3e5e7;
}
.user-info {
display: flex;
align-items: center;
margin-bottom: 4px;
.user-name {
height: 30px;
margin-right: 5px;
color: #61666d;
font-size: 13px;
line-height: 30px;
cursor: pointer;
}
}
.root-reply {
position: relative;
padding: 2px 0;
color: #181931;
font-size: 15px;
line-height: 24px;
.reply-info {
position: relative;
display: flex;
align-items: center;
margin-top: 2px;
color: #9499a0;
font-size: 13px;
.reply-time {
width: 76px;
margin-right: 20px;
}
.reply-like {
display: flex;
align-items: center;
margin-right: 19px;
.like-icon {
width: 14px;
height: 14px;
margin-right: 5px;
color: #9499a0;
background-position: -153px -25px;
&:hover {
background-position: -218px -25px;
}
}
.liked {
background-position: -154px -89px;
}
}
.reply-dislike {
display: flex;
align-items: center;
margin-right: 19px;
.dislike-icon {
width: 16px;
height: 16px;
background-position: -153px -153px;
&:hover {
background-position: -217px -153px;
}
}
.disliked {
background-position: -154px -217px;
}
}
.delete-btn {
cursor: pointer;
&:hover {
color: #00aeec;
}
}
}
}
}
}
.reply-none {
height: 64px;
margin-bottom: 80px;
color: #99a2aa;
font-size: 13px;
line-height: 64px;
text-align: center;
}关键行与易错点
- 样式文件必须
import才生效:import './App.scss'漏掉的话,类名加对了也没有任何样式;import语句写在文件顶部,与导入 JS 模块完全一致。 <img />、<textarea />等单标签必须自闭合:这是 JSX 三条规则之一(唯一根节点、标签必闭合、属性驼峰)。- JSX 里写注释要用
{/* ... */}:直接写//或<!-- -->会被当成文本或直接报错。 className而非class:所有类名统一驼峰属性名,这是「属性采用驼峰命名法」规则的具体体现。- 高亮/选中状态靠「类名切换」实现:
active、liked、disliked在 SCSS 中已经写好样式,后续用className={xxx ? 'liked' : ''}动态切换即可,不要去操作 DOM。 bili-avatar与bili-avatar-img配合:外层负责圆形占位背景,内层用绝对定位 +translate(-50%, -50%)居中,替换头像时两个都要改。- 雪碧图定位用
background-position:like-icon/dislike-icon各自一套位置,hover 与选中态各对应一组坐标,改图标时三处坐标要同步调整。 reply-none(暂无评论)与reply-list互斥:后续用条件渲染{list.length === 0 && <div className="reply-none">暂无评论</div>}控制显示即可。
常见错误
1. 列表渲染漏写 key,或拿 index 当 key
- 现象:控制台报
Each child in a list should have a unique "key" prop;或者删除中间一条评论后,后面评论的点赞高亮"串位"。 - 原因:
map返回的 JSX 根节点没有key,或用index作 key——删掉一条后索引整体前移,React 复用了错误的 DOM 节点和状态。 - 修法:用评论自身的唯一 id:
{list.map(item => <CommentItem key={item.rpid} data={item} />)};只有纯静态、永不增删的列表才允许用 index。
2. 直接修改 state 数组/对象,页面不更新
- 现象:
list.push(newComment)或comment.liked = true后,数据变了但视图纹丝不动。 - 原因:React 靠引用比较判断是否重渲染,原地修改引用没变。
- 修法:每次返回新引用。插入用
setList([newComment, ...list]);改单条用setList(list.map(it => it.rpid === id ? { ...it, liked: !it.liked } : it));删除用setList(list.filter(it => it.rpid !== id))。
3. 事件绑定写成函数调用 onClick={handleDelete(item.id)}
- 现象:页面一刷新就把所有评论删了,或报
Cannot read properties of undefined;有时干脆点击没反应。 - 原因:
{}里是表达式,带括号就是立即执行,把返回值(通常是undefined)赋给了onClick,等于没绑事件。 - 修法:二选一——只传函数名
onClick={handleDelete}(在函数内部拿 id,需借助闭包或data-id),或包一层箭头函数onClick={() => handleDelete(item.rpid)}。
4. 点赞/删除按钮点击后,连带触发了外层的展开或跳转
- 现象:点"喜欢"图标,评论详情面板也跟着展开;点"删除",
<a>标签把页面带走了。 - 原因:事件冒泡到父级绑定的
onClick;<a>/<button>还有默认行为。 - 修法:在子元素处理函数里先
e.stopPropagation();对链接再加e.preventDefault()。事件对象从处理函数第一个参数拿:const handleLike = (e) => { e.stopPropagation(); ... }。
5. 条件渲染用 && 时把 0 渲染到了页面上
- 现象:某条评论点赞数为 0 时,页面上莫名出现一个"0"。
- 原因:
{likeCount && <span>{likeCount}</span>}中0是 falsy,&&直接返回0,而 React 会渲染数字 0。 - 修法:改判布尔值
{likeCount > 0 && ...},或用三元{likeCount ? <span>{likeCount}</span> : null};"有 A 无 B"用&&,"A 或 B"用三元。
6. textarea 只写了 value 没写 onChange,输入框打不出字
- 现象:光标能进去,敲键盘没反应,控制台警告
You provided a value prop to a form field without an onChange handler。 - 原因:
value把输入框变成了受控组件,值完全由 state 决定,不写onChange就没有回写 state 的路径。 - 修法:
value与onChange成对出现:value={content} onChange={e => setContent(e.target.value)};点"发布"后校验content.trim()非空,再setContent('')清空。
7. 样式不生效 / 组件名小写被当成 HTML 标签
- 现象:写了
.reply-item的样式但页面没变化;或<commentItem />直接不渲染且无报错。 - 原因:忘了
import './index.scss';组件首字母小写被 React 当作原生标签处理。 - 修法:样式文件在组件自己的 js 文件顶部导入;组件名一律大驼峰
<CommentItem />,文件同名。
练习
练习 1|Tab 切换与排序
实现"最热/最新"切换:点击时给当前项加 active 类名(另一项去掉),并让列表按 like 降序 / time 降序重新排列。要求:类名用三元或模板字符串动态拼接,不要写两个 <div> 做条件渲染;排序不得直接 sort 原数组。
练习 2|发表评论
把 textarea 改成受控组件,实现:输入为空或纯空格时"发布"按钮置灰(可用 disabled 或类名控制);发布后新评论插入列表最前面,带上当前时间、初始点赞数 0,并清空输入框、让 total-reply 数量 +1。
练习 3|点赞与点踩互斥
点击"喜欢":图标切换 liked 类名,数字 +1,再点一次取消并 -1。点击"不喜欢":切换 disliked 类名。要求同一条评论的"赞"和"踩"互斥——点亮一个必须熄灭另一个;两个按钮的点击都不能触发父级的事件。
练习 4|删除与空态
点击"删除"移除对应评论,数量同步减少;当列表为空时显示 .reply-none 的"暂无评论",非空时隐藏。要求删除时不能误删其它项(用 rpid 而非索引定位)。
练习 5|组件拆分
把评论列表拆成 CommentList 与 CommentItem 两个组件,按笔记中的目录约定组织文件(每个组件一个文件夹,内含 js + scss,样式在各自 js 中导入),CommentItem 通过 props 接收单条数据。拆完后页面效果与拆分前完全一致,控制台无任何警告。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | React介绍和快速体验 | 16:54 |
| 2 | 开发环境搭建 | 10:33 |
| 3 | React初体验 | 10:38 |
| 4 | JSX的使用和原理 | 13:30 |
| 5 | 写JSX推荐的VSCode配置 | 04:12 |
| 6 | JSX中{}的应用 | 17:11 |
| 7 | React事件绑定 | 14:26 |
| 8 | React组件 | 11:22 |
小结
key是列表的身份标识,必须来自业务数据的唯一 id;用 index 当 key,在增删场景下必然出 bug,且这类 bug 表现为"状态错位",比报错更难排查。- React 里所有状态更新都要返回新引用——数组用扩展/
map/filter,对象用展开运算符,这是判断"页面不更新"问题的第一反射。 - 事件绑定只有两种合法形态:
onClick={fn}和onClick={() => fn(arg)},凡是出现onClick={fn()}就是错的,先查这一条。 - 事件对象从处理函数的第一个参数拿;
stopPropagation管冒泡、preventDefault管默认行为,两者解决的是不同问题,嵌套可点击元素时必须显式处理。 - 表单元素记住口诀"有 value 必有 onChange";受控组件的值永远是 state 的唯一映射,不要试图从 DOM 上读值。
- 条件渲染按语义选运算符:单个内容的有/无用
&&(并注意0的陷阱),两个内容二选一用三元。 - 组件拆分的判据是"是否有独立的状态与复用需求",而不是行数;根组件放
src,其余组件一组件一文件夹,样式跟着组件走、在组件内导入。 - 这个案例的三种页面处理方式(列表渲染 / 条件渲染 / 样式处理)覆盖了绝大多数后台与社区类页面的结构,先把这三件事做扎实,再谈 hooks 与状态管理。