要点
这一篇在上一篇建好的 Vite 工程里,先用三十行跑通第一屏,再用一百行把它改成一个能增删、能勾选、刷新不丢数据的待办应用。第一课只有两句话:组件就是函数,JSX 是 JavaScript 的语法糖而不是模板语言。理解了这两句,React 的语法面比想象中小得多——剩下的都是 JavaScript。
从一屏开始
把 src/App.jsx 整个替换成:
// src/App.jsx
function App() {
const name = 'React'
return (
<div>
<h1>你好,{name}</h1>
<p>现在是 {new Date().toLocaleDateString()}</p>
</div>
)
}
export default App
保存后页面自动热更新。逐条看 JSX 的规则,一共五条:
| 规则 | 例子 | 违反时的现象 |
|---|---|---|
| 组件名大写开头 | function App() 而非 function app() |
小写标签被当成原生 HTML 元素,渲染成空标签 |
| 最外层单根 | 两个兄弟元素要包一层 div 或空标签 <>…</> |
编译直接报错,页面白屏,终端有明确行号 |
| 花括号里放表达式 | {name}、{items.length}、{ok ? '是' : '否'} |
放语句(if、for)会报错——表达式世界没有语句的位置 |
| class 写成 className | <p className="title"> |
class 能渲染但控制台告警,部分场景行为不一致 |
| 布尔与 null 不渲染 | {false}、{null} 页面上什么都不出 |
数字 0 会渲染出来——条件渲染的经典坑,语法篇展开 |
JSX 会被 Vite 编译成普通的函数调用,所以你不需要 import React——这条从 React 17 起由新的 JSX 转换自动完成。”JSX 是糖”不是修辞:花括号里能放的东西,和你在一个 .js 文件里能写的东西完全一样。
状态:useState
静态页面不需要 React。让页面动起来的最小单位是 state:
import { useState } from 'react'
function App() {
const [count, setCount] = useState(0)
// 数组解构出"当前值"与"设置器";0 是初始值,只在首次渲染用到
return (
<button onClick={() => setCount(count + 1)}>
点了 {count} 次
</button>
)
}
关键机制一句话:调用 setCount 后,React 重新执行整个 App 函数,新一次渲染里的 count 才是新值。函数组件不是”执行一次的函数”,而是”React 反复调用的描述”——这句话是所有 React 心智的入口,本系列语法篇会反复用到它,完整的执行模型在站内《React Hooks 心智模型》深潜文里。
待办应用:完整代码
四步拼出来:列表、输入、增删改、拆组件。先看成品(可直接整文件粘贴):
// src/App.jsx
import { useState } from 'react'
import TodoItem from './TodoItem'
let nextId = 1 // 演示用自增 id;真实项目用 crypto.randomUUID()
function App() {
const [todos, setTodos] = useState([])
const [text, setText] = useState('')
function addTodo(e) {
e.preventDefault() // 阻止表单默认提交导致的整页刷新
const t = text.trim()
if (!t) return // 空输入不入列——校验放在事件里,不放渲染里
setTodos([...todos, { id: nextId++, text: t, done: false }])
// 展开旧数组创建新数组:React 靠引用变化感知更新,直接 push 是反模式
setText('')
}
function toggle(id) {
setTodos(todos.map(t =>
t.id === id ? { ...t, done: !t.done } : t // 同理:换新对象,不改旧对象
))
}
function remove(id) {
setTodos(todos.filter(t => t.id !== id))
}
return (
<main>
<h1>待办({todos.filter(t => !t.done).length} 件未完成)</h1>
<form onSubmit={addTodo}>
<input value={text} onChange={e => setText(e.target.value)} />
<button>添加</button>
</form>
{todos.length === 0
? <p>还没有待办</p>
: <ul>
{todos.map(t => (
<TodoItem key={t.id} todo={t}
onToggle={toggle} onRemove={remove} />
))}
</ul>}
</main>
)
}
export default App
// src/TodoItem.jsx —— 子组件只管"怎么画一条",数据与操作都从 props 来
function TodoItem({ todo, onToggle, onRemove }) {
return (
<li>
<label>
<input type="checkbox" checked={todo.done}
onChange={() => onToggle(todo.id)} />
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.text}
</span>
</label>
<button onClick={() => onRemove(todo.id)}>删除</button>
</li>
)
}
export default TodoItem
四个知识点在代码里各就各位:
| 知识点 | 代码里的位置 | 一句话规则 |
|---|---|---|
| 受控输入 | <input value={text} onChange={…}> |
value 与 onChange 成对出现;只给 value 不给 onChange,输入框打不了字 |
| 条件渲染 | 三元表达式切换空提示与列表 | 用 && 或三元,不用 if——JSX 里只能放表达式 |
| 列表与 key | todos.map(…) 且 key={t.id} |
key 必须稳定唯一,用业务 id 不用数组下标——增删时下标会串位 |
| 子组件与 props | TodoItem 接收 todo/onToggle/onRemove | 父组件持有数据与操作,子组件只负责展示与回抛事件 |
刷新不丢:useEffect 第一口
渲染是纯计算,写 localStorage 这类副作用不能放在渲染过程里,归 useEffect 管。给 App 加两段:
import { useEffect } from 'react'
// 初始值改造:惰性初始化——useState 传入函数,首次渲染才执行一次读取
const [todos, setTodos] = useState(() => {
try {
return JSON.parse(localStorage.getItem('todos') ?? '[]')
} catch {
return [] // localStorage 被写坏时宁可从空开始,也别让整个应用崩掉
}
})
// todos 变化后同步到 localStorage;依赖数组声明"盯谁"
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos))
}, [todos])
这是 useEffect 的最小用法:依赖数组 [todos] 表示 todos 变了就重跑。它为什么不是”生命周期钩子”、清理函数什么时候执行、漏依赖会踩什么雷——这些深入话题整篇都写在《React Hooks 心智模型》,第一周只需要照上面的形状用。
与 Vue 起步对照
同一个待办应用,Vue 3 的写法长什么样?站内《第一个 Vue 3 应用》是镜像教程,对照表放这儿:
| 维度 | React | Vue 3 |
|---|---|---|
| 声明状态 | const [n, setN] = useState(0) |
const n = ref(0),改值即触发 |
| 写界面 | JSX,花括号里是 JavaScript | 模板 + 指令(v-if / v-for / v-model) |
| 监听输入 | 受控:value + onChange 成对 | v-model 一个指令双向绑定 |
| 列表渲染 | todos.map(t => <li key={t.id}>) |
<li v-for="t in todos" :key="t.id"> |
| 副作用 | useEffect + 手写依赖数组 | watch 自动收集依赖 |
底层差别一句话:React 靠”状态变了就重新执行整个组件函数”,Vue 靠”依赖追踪、精确更新”。两边都学过一遍,你对”状态驱动视图”的理解会完整得多。
常见卡点
Q1: 页面白屏
九成是 JSX 结构问题:标签没闭合、最外层两个根。看终端与浏览器控制台,报错带行号,直接指向问题。
Q2: 输入框打不了字
value 绑了状态却没写 onChange——受控组件只有一半。React 不允许你在不提供更新路径的情况下锁定 value。
Q3: 控制台警告 Each child in a list should have a unique key
map 渲染的列表没给 key。用稳定 id;用随机数也不行——每次渲染都是新值,React 无法对上节点。下标当 key 在增删场景会串位,语法篇有专门展开。
Q4: 勾选了没反应
八成是直接改了对象:todo.done = true。要创建新对象 { ...t, done: !t.done }——React 比较的是引用,旧对象原地改它感知不到。
反模式
反模式一:直接修改状态。todos.push(…) 之后界面不动,因为数组引用没变。所有更新都是”创建新的替换旧的”:展开、map、filter 三个高阶操作覆盖九成场景。
反模式二:把逻辑堆回 App.jsx 不拆组件。TodoItem 拆出来不是为了目录好看——列表项的渲染细节收进子组件后,App 里只剩数据流,这是后面所有复杂度的收纳方式。
检查清单
- 第一屏渲染出来,能对 JSX 五条规则各举一个例子
- 待办应用:添加、勾选、删除、空列表提示全部可用
- 输入框是受控的:value 与 onChange 成对
- 列表 key 用的是 id,控制台无 key 警告
- 刷新页面数据还在;手动把 localStorage 改成非法 JSON 再刷新,应用不崩
- 能口头回答:setTodos 之后 React 做了什么
下一步
这一篇走完了”组件—状态—子组件—副作用”的最小闭环。下一篇《React 语法要点:组件、JSX 与数据流》把 props 单向流动、children、组合与继承的取舍这些”为什么”讲透;useEffect 想深入的现在就可以插读上节链接的《React Hooks 心智模型》,与本篇的”第一口”正好衔接。
版本核实(撰写时检索,2025-07):React 当前稳定版为 19.1.x;自动 JSX 转换(无需 import React)自 React 17 起可用;crypto.randomUUID 为浏览器原生 API;本篇全部 API(useState/useEffect)为 React 稳定特性。具体以官方文档与你的锁文件为准。