React 入门:从「亲手炒菜」到「填一张菜单」

这篇讲 React 最核心的那个转变——你不再动手改页面,而是描述页面该长什么样。从声明式渲染讲到 JSX、组件、Props、State、事件和列表 key。零基础可以顺着读;写过原生 DOM 的同学建议重点看第一节和第五节。

React 入门文章封面插画

这篇讲 React 最核心的那个转变——你不再动手改页面,而是描述页面该长什么样。从声明式渲染讲到 JSX、组件、Props、State、事件和列表 key。零基础可以顺着读;写过原生 DOM 的同学建议重点看第一节和第五节,那里是最容易别扭的地方。

一、你写的不是「怎么改页面」,是「页面该长什么样」

学 React 之前,改页面是这样的:拿到元素,然后动手改它。

const 数字框 = document.getElementById("count");
let 计数 = 0;

按钮.addEventListener("click", () => {
  计数 = 计数 + 1;
  数字框.textContent = 计数;   // 亲手把新数字塞进去
});

这段代码没什么问题,能跑。但注意最后那一行在做什么:你在下命令。「把这个数字,塞进那个元素里。」

页面上只有一个数字时,这样很轻松。麻烦在于页面会长大。假设现在计数超过 10 要变红、旁边标题要跟着显示「已达上限」、还有个按钮要禁用——于是那个点击回调变成:

按钮.addEventListener("click", () => {
  计数 = 计数 + 1;
  数字框.textContent = 计数;
  数字框.style.color = 计数 > 10 ? "red" : "black";
  标题.textContent = 计数 > 10 ? "已达上限" : "计数器";
  加一按钮.disabled = 计数 >= 20;
});

五行指令,每一行都必须记得写。而且不止这一处——如果还有个「重置」按钮,那五行得再抄一遍。漏抄一行,就出现「数字回到 0 了但标题还写着已达上限」这种页面。

这类 bug 有个共同点:不是算错了,是忘了同步

React 换了个思路。你不写「计数变了要去改哪五个地方」,你写「当计数是某个值时,页面长这样」:

function 计数器() {
  const [计数, 设置计数] = useState(0);

  return (
    <div>
      <h2>{计数 > 10 ? "已达上限" : "计数器"}</h2>
      <span style={{ color: 计数 > 10 ? "red" : "black" }}>{计数}</span>
      <button disabled={计数 >= 20} onClick={() => 设置计数(计数 + 1)}>
        加一
      </button>
    </div>
  );
}

读一遍这段代码:它从头到尾在描述,没有一句「去改某个元素」。标题是什么、颜色是什么、按钮禁不禁用,全都直接写成「由计数算出来的结果」。

计数变了会发生什么?React 重新执行这个函数,拿到新的描述,然后自己去比对哪里和之前不一样,只改那些地方。

这就是声明式命令式的差别。用点菜打个比方:

  • 命令式是你自己冲进厨房:开火、下油、放菜、翻炒、装盘。每一步都得你记得做,漏一步这盘菜就毁了。
  • 声明式是你填一张菜单交出去:「宫保鸡丁,不要辣」。你不关心厨师怎么颠勺,你只负责说清要什么。

React 就是那个厨师。你的工作从「炒菜」变成「写菜单」。

这个转变对写过原生 DOM 的人来说是别扭的,因为放弃了控制权——你不再知道页面具体是怎么变的。但换来的东西很实在:「忘了同步」这类 bug 从此不存在,因为没有任何需要你记得执行的更新步骤。

命令式写「怎么改」,声明式写「该是什么」。前者要你记得每一步,后者不给你漏掉的机会。

二、JSX 不是模板,它就是 JavaScript

上面那段代码里混着 HTML 标签,这个东西叫 JSX。很多人第一眼以为它是模板语言——像 Vue 的 template 或者 Django 的模板那样,有自己一套语法规则。

不是。JSX 只是 JavaScript 的一层语法糖,写完会被编译成普通的函数调用:

// 你写的
<h2 className="title">你好</h2>

// 编译成
React.createElement("h2", { className: "title" }, "你好")

理解这一点能解释掉 JSX 大部分「奇怪」的地方。

为什么大括号里能写任何表达式。 因为 {} 里的东西就是普通 JS,会被求值然后作为参数传进去:

<span>{用户名.toUpperCase()}</span>
<span>{价格 * 数量}</span>
<span>{列表.length > 0 ? "有数据" : "空的"}</span>

为什么不能写 if 语句。 因为参数的位置只能放表达式(有值的东西),不能放语句(一段动作)。if 是语句,它没有值:

// ❌ 语法错误,if 是语句
<div>{if (登录了) { <p>欢迎</p> }}</div>

// ✅ 三元运算符是表达式,有值
<div>{登录了 ? <p>欢迎</p> : <p>请登录</p>}</div>

// ✅ 只想在某个条件下显示,用 &&
<div>{有新消息 && <p>你有新消息</p>}</div>

这不是 React 故意限制你,是 JavaScript 本身的规则。

为什么用 className 不用 class 因为这是 JS 对象的属性名,而 class 在 JS 里是保留字(用来定义类的)。同理 for 要写成 htmlFor

为什么必须有一个根标签。 因为一个函数只能返回一个值。想返回并列的几个标签,用 <>...</> 包起来——这叫 Fragment,它只是个语法上的容器,不会在页面里留下多余的 div。

这里有个新手常踩的坑,跟 && 有关:

// ❌ 消息数是 0 时,页面上会凭空出现一个「0」
{消息数 && <p>你有 {消息数} 条消息</p>}

// ✅ 明确写成布尔判断
{消息数 > 0 && <p>你有 {消息数} 条消息</p>}

原因是 && 在左边为假时返回的是左边那个值本身0 && 任何东西 得到 0,而 React 会把数字 0 渲染成字面的「0」。空字符串和 false 不会显示,偏偏 0 会。写条件渲染时把左边写成明确的布尔判断,这个坑就不存在。

JSX 的规则不是 JSX 定的,是 JavaScript 定的。想不通某条限制时,把它编译成函数调用看一眼。

三、组件就是一个返回界面的函数

React 里的组件,本质就是一个返回 JSX 的普通函数:

function 欢迎语() {
  return <h1>你好,世界</h1>;
}

没有继承、没有特殊的基类、不需要注册。它就是函数,你可以像调用函数一样理解它,也可以像组织函数一样组织它——需要复用就抽出来,太长了就拆开。

用起来是当标签写:

function 页面() {
  return (
    <div>
      <欢迎语 />
      <欢迎语 />
    </div>
  );
}

有一条硬规则:组件名首字母必须大写(中文名不受这条限制,但真实项目里请用英文名,这里用中文只是为了讲概念时少一层翻译)。原因回到第二节——JSX 编译成函数调用时,靠首字母大小写来区分你写的是「HTML 标签」还是「你自己的组件」:

<div />      // 编译成 createElement("div")    传字符串,当 HTML 标签处理
<Welcome />  // 编译成 createElement(Welcome)  传函数,当组件处理

所以把组件写成 function welcome() 然后用 <welcome />,React 会当成一个它不认识的 HTML 标签,页面上什么都不显示,也不报错。这是个很难查的错误,而规则本身很简单:自己写的组件,首字母大写

拆组件的判断标准是「职责」,不是行数。一个组件应该能用一句话说清它负责什么。说不清、或者需要用「以及」来连接,就是该拆的信号。

四、Props:往下传,不能往上改

组件要有用,得能接收外部数据。这个数据叫 props,写起来就像 HTML 属性:

function 欢迎语({ 名字, 次数 }) {
  return <h1>你好,{名字}!这是你第 {次数} 次来</h1>;
}

// 使用
<欢迎语 名字="小明" 次数={3} />

注意两种写法的差别:字符串直接用引号,其他类型(数字、布尔、数组、对象、函数)要用大括号。次数="3" 传的是字符串 "3"次数={3} 传的是数字 3

关于 props 有一条不能破的规则:props 是只读的。子组件不能改它收到的 props。

function 欢迎语({ 名字 }) {
  名字 = 名字.toUpperCase();  // ❌ 不要这样
  return <h1>你好,{名字}</h1>;
}

为什么?因为数据的所有权在父组件那里。子组件偷偷改掉,父组件并不知道,于是同一份数据在两个地方不一致——而且你排查时会去看父组件,因为那才是它「应该」被定义的地方。

想基于 props 算点东西,用一个新变量,别动原来那个:

function 欢迎语({ 名字 }) {
  const 大写名字 = 名字.toUpperCase();   // ✅
  return <h1>你好,{大写名字}</h1>;
}

这条规则加上「数据只能从父往子传」,构成了 React 的单向数据流。它带来一个很实际的好处:某个值显示错了,你只需要往上找它的来源,不用担心页面上任意一处代码把它改掉了。数据的流向是一条线,不是一张网。

那子组件想让父组件改数据怎么办?父组件传一个函数下去:

function 父组件() {
  const [名字, 设置名字] = useState("小明");
  return <改名输入框 当前名字={名字} 当改名时={设置名字} />;
}

子组件调用 当改名时("小红"),实际执行的是父组件的函数。改动仍然发生在数据的所有者那里——子组件是在请求,不是在动手

数据往下流,事件往上报。子组件从不直接改自己没有的东西。

五、State:界面会变的那部分

Props 是外面给的、组件不能改的。那组件自己要记住的东西呢——输入框里打了什么、面板是展开还是收起——放哪?

放 state 里。

import { useState } from "react";

function 计数器() {
  const [计数, 设置计数] = useState(0);

  return (
    <button onClick={() => 设置计数(计数 + 1)}>
      点了 {计数} 次
    </button>
  );
}

useState(0) 返回两个东西:当前值,和一个用来改它的函数。参数 0 是初始值,只在第一次渲染时用。

这里要理解的关键不是 API,而是:为什么必须通过那个函数改,不能直接赋值。

计数 = 计数 + 1;        // ❌ 什么都不会发生
设置计数(计数 + 1);     // ✅

第一行不是「不规范」,是真的没有任何效果。回到第一节的道理:React 是靠重新执行你的函数来更新页面的。它需要知道「有东西变了,该重新执行一遍了」——而直接给变量赋值,React 完全不知情。设置计数 除了改值,还负责通知 React「该重新渲染了」。

回到点菜的比方:改菜单不能在旧的那张纸上划两笔,要交一张新的进去。厨房是按照收到的菜单做菜的,你在自己手里那张上改,厨房看不见。

同样的道理延伸到对象和数组,这是新手最容易踩的地方:

const [用户, 设置用户] = useState({ 名字: "小明", 年龄: 18 });

// ❌ 改了原对象,React 认为「还是同一个对象」,不重新渲染
用户.年龄 = 19;
设置用户(用户);

// ✅ 交一个新对象
设置用户({ ...用户, 年龄: 19 });
const [列表, 设置列表] = useState([1, 2, 3]);

// ❌ push 是在原数组上改
列表.push(4);
设置列表(列表);

// ✅ 造一个新数组
设置列表([...列表, 4]);

原因是 React 判断「变了没有」用的是比较引用,不是逐个字段对比内容——后者在大对象上太慢。你改了对象内部而没换对象,引用没变,React 看到的就是「没变」。

还有一个更隐蔽的问题。看这段:

function 加二() {
  设置计数(计数 + 1);
  设置计数(计数 + 1);
}

点一下,计数加了几?加 1,不是 2。

因为 计数 是本次渲染时的那个值,比如 0。两行都在算 0 + 1,都是设成 1。状态更新不是立即生效的,React 会把同一批更新攒起来一起处理。

要连续基于最新值改,传一个函数进去:

function 加二() {
  设置计数((上一次) => 上一次 + 1);
  设置计数((上一次) => 上一次 + 1);   // 这里的「上一次」是 1,不是 0
}

这个写法在「基于当前值计算新值」时都该用,尤其是在异步回调里——那时候闭包里的旧值可能已经过期很久了。

直接改变量 React 不知道;改对象内部 React 看不出变化。要么交新的,要么用函数式更新。

六、事件:你给的是函数,不是调用

绑事件写起来很像原生 HTML,但有个必踩一次的坑:

// ❌ 页面一加载就执行了,而且会无限循环
<button onClick={处理点击()}>点我</button>

// ✅ 把函数本身交出去
<button onClick={处理点击}>点我</button>

// ✅ 需要传参数时,包一层箭头函数
<button onClick={() => 处理点击(商品.id)}>点我</button>

第一种写法错在哪:处理点击() 带括号是立即调用,React 渲染时就把它执行了,然后把返回值(通常是 undefined)当成事件处理器。如果这个函数里还改了 state,就会触发重新渲染,重新渲染又执行一次它——无限循环。

区别就是有没有那对括号。onClick 要的是「点击时该执行什么」,所以你交的应该是一个还没执行的函数

另外两件事:

事件名是驼峰的。 onClick 不是 onclickonChange 不是 onchange

表单提交一定要 preventDefault

function 处理提交(事件) {
  事件.preventDefault();   // 不写这行,浏览器会整页刷新
  // ……
}

<form onSubmit={处理提交}>

表单的默认行为是提交后刷新页面。在 React 应用里,刷新意味着所有 state 全部清零——用户填的、选的、展开的,全没了。这是必须记得写的一行。

七、列表和 key:为什么用数组下标会出事

渲染列表用 map,每一项要给 key

<ul>
  {待办列表.map((待办) => (
    <li key={待办.id}>{待办.标题}</li>
  ))}
</ul>

不给 key,控制台会警告。很多人的应对是随手用下标把警告消掉:

{待办列表.map((待办, 下标) => (
  <li key={下标}>{待办.标题}</li>
))}

警告消失,页面看起来完全正常。这就是它危险的地方——在列表只会整体替换时,用下标真的没问题,所以你会以为这样可以。等到列表能删除单项时,bug 才出现,而且看起来跟 key 毫无关系。

具体是这样。假设有三个待办,每项前面有个勾选框:

[ ] 0. 买菜
[✓] 1. 写代码      ← 你勾上了这一项
[ ] 2. 遛狗

现在你删掉「买菜」。剩下两项,「写代码」变成下标 0,「遛狗」变成下标 1。

React 看到的是:下标 0 的那一项,内容从「买菜」变成了「写代码」。它认为这还是同一项,只是文字改了——所以它复用了原来那个 DOM 节点,以及节点内部的状态。而原来下标 0 那项(买菜)的勾选框是没勾的。

结果:

[ ] 0. 写代码      ← 勾选丢了
[✓] 1. 遛狗        ← 勾选跑到别人身上了

你删了「买菜」,「遛狗」却莫名被勾上。这个 bug 出现的时候,你大概会去查勾选逻辑、查删除逻辑,很难想到问题在 key。

根因是:key 是你对 React 的承诺,说「这个标识对应的就是这一项」。用下标做 key,等于承诺「第 0 个位置永远是同一项」——而这句话在能增删的列表里是假的。

用数据自己的 id 就对了。id 跟着数据走,数据换位置它也跟着换,React 就能正确认出「这一项移动了」而不是「这个位置的内容改了」。

没有 id 怎么办?找一个业务上唯一且不变的字段(邮箱、文件名、订单号)。实在没有,在数据进入列表时生成一个(crypto.randomUUID()),存进数据里——注意是存进去,不是渲染时现生成。渲染时生成的话每次渲染 key 都变,React 会认为整个列表全换了,把所有节点删掉重建,state 全丢。

key 要稳定、唯一、跟着数据走。下标满足前两条,唯独不跟着数据走。

八、小结

回头看这一路,会发现所有这些规则指向的是同一件事:

你想做的命令式的做法React 的做法
更新页面找到元素,改它描述新的样子,React 去比对
记住数据变量,或者存在 DOM 里state,改动必须通过 setter
传递数据谁都能访问、谁都能改props 往下传,只读
响应交互回调里手动改一堆元素改 state,界面自己跟上
渲染列表拼字符串或循环 appendmap + 稳定的 key

它们共同要求你交出一样东西:对「页面具体怎么变」的控制权。你只说该是什么样,具体怎么变由 React 决定。

这也是 React 真正的门槛所在——不在 JSX 语法,也不在 Hooks API,那些查文档就会了。难的是放手。写过原生 DOM 的人尤其别扭,因为原来那种「我亲手把值塞进这个元素」的确定感没有了。

但换来的是:那类「忘了同步某处」的 bug 从此不存在。不是你变小心了,是代码里已经没有需要你记得执行的更新步骤了。

至于 state 该放在哪个组件、已有的状态管理代码怎么接进 React、以及怎么验证「换了渲染方式但功能没变」——那是下一篇的事。

学 React 不是学一套 API,是换一种描述界面的方式:你写页面该是什么样,而不是页面该怎么变

没有回应

    发表回复

    您的邮箱地址不会被公开。 必填项已用 * 标注