Skip to content

React 基础与 JSX(一) ​

本页对应 React 基础与 JSX,共 8 段视频,约 1 小时 38 分。

学习目标 ​

  1. 能使用 CRA 创建并启动 React 项目,完成 SRC 目录精简,仅保留并清空入口文件,验证页面无报错。
  2. 能按“导入 createRoot、创建 root 对象、调用 render”三步将 JSX 内容渲染到指定 DOM 节点,并说明 React 与 ReactDOM 的导入差异。
  3. 能编写符合 JSX 三条规则的页面结构(单一根节点、标签闭合、属性驼峰/className),并借助 Babel 在线工具说明 JSX 转换为 React.createElement 及 JS 对象的过程。
  4. 能配置 VSCode 的 Emmet、Prettier 保存格式化、Auto Rename Tag,并实际演示 JSX 快速生成、自动格式化和成对标签同步修改。
  5. 能使用 {} 完成列表渲染(map + 唯一 key)、条件渲染(逻辑与/三元)、动态 className 与行内样式,独立实现菜单案例的结构和样式。
  6. 能使用行内函数和抽离函数两种方式绑定 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,iVBORw0KGgoAAAANSUhEUgAAA+gAAAPoCAMAAAB6fSTWAAAA51BMVEUAAACYoKhwd3yboqni5emDjJL7+/yZoqoAodbnix8AodYAodaZoqoAodYAodaln5jnix8Aodbnix8AodaZoqoAodbnix8Aodbnix/yXY6ZoqoAodYAodYAodaZoqoAodaZoqryXY7yXY4AodbyXY6ZoqryXY6ZoqoAodaZoqoAodaZoqryXY7nix8AodYAodbnix+ZoqqZoqrnix8AodYAodbnix+Zoqr////19vfM0NcAoda/v7/l6e9MyP//u1PlL+z/s3yS0eWV3bL/bAAVFRX/AACEHPnnix+M2fn/1pbyXY4iIiIkv4BgAAAAOHRSTlMA9fUreZKu4eI+EfDtgtwP7AkexYcv2WfIsP3refnX0mcmGUPyxsScjXkXF++zoZpMMyn+Ppl8Q6/LsKoAAA3QSURBVHja7NvdbtowGIfxP7UsaEqbfkGj0bWVpqofiK0f2nZALyD3f0V7E4KsbULCjpRA9fykQDjw4SOb2BEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG2cF4X64vzAeJc+/sDYeGDH3Q0e1MrV1x9q4eW0LNUTP2j4xPEHDS9gp70O50O1MRk9j5Tu13tZhX4+LdS5ejJvpnUlqCfzZloXsMPym99qFfrZ7Telh54vyop1Xk7VNevbqeas+KT5fD2eOR3b+FhR1/L84dJaz42SZNnPR2UnWZadKV7+Mi1rss7P1THXdB7u47iq83DP/3RsijtQpevQ78bjL/fS29CMHxTrana0vDjT5MTMviuSVb6movvO5Qe+Wr2vLvsRP6H7avW+ujxTOjaErrrw+mq+1K1hrqHWxoo3yjTS2kyRTssQeh9sEg+hO/uIZJN4CN3xLx07G7pC6G/3KaErhD65UKQyUGEfhbplaYfQlRK6Quja29CPj4W/febQn55ahn59vY+hO9VcWuhh/P6GfrxcUvq/PnHo965l6BcTRZruwNLdexnv05buYfzeLt2tc0qPkBi6qb77D31+o3ahP58o1mERQl8U/TyMc3bZjUt9GOfsshvHwzhsDt00jdf3fYZ+d9ky9KtHxcsPe99ec746NJO+veZ8dWiG7TVs9PGfzkOfr0PPb16TQn9eh57dTtoemCm0NQ7MAHH76OOVJylxH/2oNrtufQR2oa1xBBbYN/ZSy7ui8VILsF94TRUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAH3buoMVNIAzA8BxESA5ldyHkUui1p/Y6YrJ71v//g/rFmFoKaaMBdZPngTWzh+/4MqKTAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwIMqyirnqizungfWqihzryzum5c6rFVkWrUfoa0i1Unzx+Y9NMfTPKzZvv6ZnlJ02n702ih1wnzz3muUzrrt6rpOS3kbFrMrzp0PpRdj57vOh9LdvbNer/WCob+9bFJn8zJ/6eWl87Y9l16OnW/6xpvuakvnvw5naW7bbX2y3W5f0xI2UXr/MbciV33nffBVLsbNH/vO++CPtnSuxT3o/k/z2td/+JGWEIkv0vmwobf596KcsqE3ORa2dK46nNLuLsNiXpF3/F2kRUTkC3QeqnzpPBadXI2bv3Qei07Mg9CvlR6dLyDnc+ehqqou9Dxu/tJ5zB+70HOCtYf+Nd3sgUKvcqedGno/3widTxL6Lt3skW7do+/ofPKtezh17tadf4YeTp8rCP1Lup2HcR7GMSL00BfeNb5o6N/TzR7r9Vobnd/zeq2Jzr1e47rD35YM/dsujfMwB2bauE4/MNMdl7Ghs2r7+o5HcY7AOgILn4AvtcAz8DVVeAZ+eAKegp+SAgAAAAAAAAAAAAAAAAAAAH6xczctbQRxAIf/RmHDGgyiQWisCkV8gxaF0nZDTjkF+v0/T4dNrIFe6g5JnOR5srksDHP6wTCzDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlKhZdXRY3HjgPzS/Vkybd5fW/FyRxmfOr3RorS/0ZHqUEXqSxufODyRrDD1pckJPmuz5gQihQxc3g8GnwcJDdHAxPp4ct8aXUR6hsx+qp6iiNbx6jvfrP0Y/WvX1KIojdDZtthCbVbVP6+a8S+jt07q4j+IsQjvIDH2eGfpU6Dtutioi2WLoT1d5oT+eRHEWof0+yAt9Ms8LvZkKfbfNoi28/be2GXrcHmaFHmflrd2XoafSs0KfzPNCb6ZC32kfK/SHh7zQL8vbjluGnkrPC30yzwu9mQp9l62Evv2le7zc5oU+OovS/A29J3Q66BT6Vjbjhm+hx6BD6PVb6DGO0ryG3rN0Z41e406/jNBzz9FvI16qZHDX7Rz97DRGJ8n4a5RmGXrPZhzr1Gb92vjyzaYNh3fnMbwaJtFFXX+/j/qkruvTKM4itJ7jNdZq9q/YuFT5j6iiu9PrL9GPIvlghj3yXD1VkWHUfxS60Pnwbg7uIsfF529RJKHDHhA67AEXT8AecJUU7IHG5ZAAAAAAAAAAAAAAAMAfdu6etUEgDuDwNcnkUMgQshS6dmrXeOKSLdDv/3kqlxeELCVXk9T/84Aogtz0w+OUAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAmVqu8ti/ex74RWe5b8dueH43Vj0+8PdWfVsV2mrofOyG8YUOU8ttXWh5Vxd6boUOV4QOt9h2F28pHqETwxD4cBTvmxSO0Lm3/VGqUBd695HCuYT2Uhn6oTL0Xuhzth8rdx4Z+msKJ587/64L/dDVhd5noc/ZPpXCy1E8LPQi3tw9nzuvC/3Q1YXeZ6HP2pOFHm85Lp86rwv90NWF3mehz9so9CeYug+X0Rz7WgidKzN+o0cN3dSdaZ36LufHhL7tRj5TNLk9WliMY0Il69J3xap7paYpkTdNs07h5PZk4fMa09lfS/e3Djlr98MM0WyELnQC2HZfKSShQwBChwBsPAEB2EoKIljaHBIAAAAAAAAAAPhhzw5WGwSiMIzekCGbkF1Wgb5HhzIL3/+lClaCEixCCMl4zwER3H/8OgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADtX2gYlgJ617w1aAD0TOiQgdEhA6JCA0

### 课件图示

![课件第2页: React 是一个用来渲染用户界面(UI)的 JavaScript 库 ](/14-react/slides/day01/slide-02.png)

![课件第4页:1. JSX(编写页面结构) 2. 组件、组件通讯 3. React Hooks](/14-react/slides/day01/slide-04.png)

![课件第6页:总结 总结 1. React 是渲染用户界面(UI)的 JavaScript 库](/14-react/slides/day01/slide-06.png)

![课件第8页:官方推荐,使用 React 脚手架 CRA(Create React App)搭](/14-react/slides/day01/slide-08.png)

![课件第13页:总结 总结 1. React 渲染内容(比如,h1 标签)到页面中,分哪三步? ](/14-react/slides/day01/slide-13.png)

![课件第15页:JSX 的使用和原理 JSX 允许你在 JS 文件中写类似 HTML 的标记,是](/14-react/slides/day01/slide-15.png)

![课件第17页:JSX 的使用和原理 1. JSX 的使用 2. JSX 的原理 作用:写 Re](/14-react/slides/day01/slide-17.png)

![课件第19页:写 JSX 推荐的 VSCode 配置 /** 1. JS 文件中启用 Emme](/14-react/slides/day01/slide-19.png)

代码示例 ​

示例一: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=&#123;&#123; width: 200, backgroundColor: 'skyblue' &#125;&#125; 是「双花括号 = 外层 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 接收单条数据。拆完后页面效果与拆分前完全一致,控制台无任何警告。

原视频对照 ​

#视频时长
1React介绍和快速体验16:54
2开发环境搭建10:33
3React初体验10:38
4JSX的使用和原理13:30
5写JSX推荐的VSCode配置04:12
6JSX中{}的应用17:11
7React事件绑定14:26
8React组件11:22

小结 ​

  1. key 是列表的身份标识,必须来自业务数据的唯一 id;用 index 当 key,在增删场景下必然出 bug,且这类 bug 表现为"状态错位",比报错更难排查。
  2. React 里所有状态更新都要返回新引用——数组用扩展/map/filter,对象用展开运算符,这是判断"页面不更新"问题的第一反射。
  3. 事件绑定只有两种合法形态:onClick={fn} 和 onClick={() => fn(arg)},凡是出现 onClick={fn()} 就是错的,先查这一条。
  4. 事件对象从处理函数的第一个参数拿;stopPropagation 管冒泡、preventDefault 管默认行为,两者解决的是不同问题,嵌套可点击元素时必须显式处理。
  5. 表单元素记住口诀"有 value 必有 onChange";受控组件的值永远是 state 的唯一映射,不要试图从 DOM 上读值。
  6. 条件渲染按语义选运算符:单个内容的有/无用 &&(并注意 0 的陷阱),两个内容二选一用三元。
  7. 组件拆分的判据是"是否有独立的状态与复用需求",而不是行数;根组件放 src,其余组件一组件一文件夹,样式跟着组件走、在组件内导入。
  8. 这个案例的三种页面处理方式(列表渲染 / 条件渲染 / 样式处理)覆盖了绝大多数后台与社区类页面的结构,先把这三件事做扎实,再谈 hooks 与状态管理。