React19入门
前言
- 这篇是 React 19 的上手 + 日常使用笔记:JSX、
useState与不可变更新、useEffect家族、useMemo/useCallback、ref、列表key、Context、19 的新东西(use()、Actions、useOptimistic、ref 当 prop)、错误边界,最后是与 Vue / Svelte 的对照。 - 环境是 Node v26.13.0 + npm 12.2.0 + react 19.3.1 + react-dom 19.3.1 + vite 8.3.5 + @vitejs/plugin-react 6.2.1(
npm ls --depth=0读出来的)。所有「实测」都是在npm create vite --template react建的真项目里跑起来、在浏览器里读 DOM 得到的,开发模式和生产构建各测了一遍(StrictMode 下差别很大,见下面)。 - React 的核心难点不是 API 数量(Hook 就十几个),而是渲染模型:什么时候重渲染、为什么我改了数据界面没变、为什么这个 effect 跑了两次。所以这篇的每条结论都尽量配上「我数了几次」的证据。
React 的位置与选型
2026-11-03 用 https://api.npmjs.org/downloads/point/last-week/<包名> 实测的近一周下载量(接口返回的统计窗口是 2026-10-11 到 10-17):react 115,815,254、preact 23,145,815、vue 10,221,956、solid-js 4,623,443、svelte 4,178,017、@angular/core 3,423,108。React 的量级是 Vue 的十倍多,主要因为它背后是 Next.js、大量 UI 库(MUI、shadcn/ui、Ant Design)和全球岗位盘子。
学 React 前要知道的三件事:
- 它只管渲染,不管路由和状态管理:路由要么用 React Router 7,要么直接上 Next.js;状态要么用 Context + reducer,要么 Zustand / Redux Toolkit / Jotai。
- JSX 是 JS 的扩展语法,不是模板语言:
{}里能写任意表达式,list.map(x => <Li />)就是循环。 - 不可变数据是纪律:改了对象内部但引用没变,React 认为「什么都没变」。这条本文有专门实测。
与 Vue 的分歧点只有一个:Vue 帮你追踪依赖,React 要求你换新值并自己声明依赖。学会了这个,两边就都通了。
环境准备与脚手架
1 | npm create vite@latest react-demo -- --template react |
实测装上的东西:
1 | react-demo@0.0.0 |
入口文件(main.jsx)与 Vue 最大的差别是 createRoot + 自己控制 StrictMode:
1 | import { StrictMode, version } from 'react' |
ReactDOM.render(...)那套老 API 在 React 18 就废弃了,19 里请用createRoot。<StrictMode>在开发模式会故意把组件渲染两次、把 effect 挂载-卸载-再挂载一次,用来暴露「不幂等」的副作用。生产构建不会这样。这不是 bug,但很多新手在这里以为自己的代码坏了。
JSX 编译成什么(实测)
把压缩关掉再构建(npx vite build --minify false --outDir dist-nomin),在未压缩产物里数:
| 符号 | 出现次数 | 说明 |
|---|---|---|
_jsx |
58 | JSX 编译后的调用(自动运行时,函数名被打包器加了前缀) |
jsx-runtime |
3 | 来自 react/jsx-runtime |
React.createElement |
0 | 老式写法,这里一次都没有 |
Fragment |
102 | <>...</> 编译成 Fragment |
所以新版 JSX 不需要 import React:编译走自动运行时(automatic runtime),只有你显式写 React.xxx 时才需要导入。
构建体积(实测)
1 | npm run build |
官方模板的 HelloWorld 是 222.44 kB / gzip 69.28 kB,我这个「实测台」加到 226.45 kB / gzip 71.16 kB——几乎没变,因为大头是 react-dom 本身,不是业务代码。对比:同样复杂度的 Vue 实测台是 120.45 kB / gzip 47.39 kB(含 vue-router + pinia),Svelte 的是 48.60 kB / gzip 19.72 kB。
渲染模型:什么时候会重渲染
这是 React 最该先建立的心智:组件是函数,state 变了就重新调用这个函数,返回新的 UI 描述。
实测:开发模式与生产模式的渲染次数
我在一个空组件里加了 renderCount++,然后读它的值:
| 场景 | 首屏渲染次数 | useEffect 执行次数 |
useLayoutEffect 执行次数 |
effect 清理是否出现 |
|---|---|---|---|---|
| 开发模式(StrictMode) | 4 | 2 | 2 | 是 |
| 生产构建 | 2 | 1 | 1 | 否 |
钩子顺序也直接读出来了:
1 | 开发模式:layoutEffect → effect → layoutCleanup → effectCleanup → layoutEffect → effect |
读法:开发模式下 React 会「挂载 → 立刻卸载 → 再挂载」,所以清理函数会跑一次。如果你的 effect 在开发模式跑两遍而生产只跑一遍,那是 StrictMode 在提醒你:这个副作用不幂等(比如重复发请求、重复绑定事件),要在清理函数里撤销。
什么会触发重渲染
- 自己的
state变了(且新值与旧值Object.is不等)。 - 父组件重渲染(默认会一路往下渲染,React 不会因为 props 没变就跳过子组件)。
- 消费的 Context 值变了。
跳过重渲染的手段只有两个:memo(组件) 包住子组件,或者把子组件作为 children 传下去。实测:
1 | 父组件 setState 后 memo 子组件渲染增量 :: 0 |
(父组件重渲染了一次,被 memo 包的子组件一次都没多渲染。)
useState 与不可变更新
1 | const [value, setValue] = useState(0) // 参数:初始值(只在首次渲染用) |
useState 的更新是异步批处理的:连续调用两次 setValue(x+1) 只会加 1(读到的 x 是同一个旧值),所以连续更新必须用函数式写法。
不可变:这条最容易踩(实测)
1 | function Profile() { |
我点了两个按钮并读界面文本,实测:
1 | 初始 :: 苏寅 |
这就是 React 与 Vue 的分水岭:Vue 用 Proxy 追踪到「user.name 被改了」,React 只比较引用。规则很简单:
- 对象:
setUser({ ...user, name: 'x' }) - 数组:
setList([...list, item])、setList(list.filter(...)),别用list.push() - 嵌套深:用 immer(
useImmer)或structuredClone
补充一个诚实的细节:我同时数了组件的渲染次数,发现 setUser(同一个引用) 后组件仍可能被渲染一次(React 会先渲染再决定是否继续往下传播),但界面内容不变。所以别把「引用没变」当成「绝对不会有渲染」,它是「不会有可见更新」。
多个相关状态怎么组织
1 | const [form, setForm] = useState({ name: '', age: 0 }) |
新手常见事故就是把 setForm({ name: 'x' }) 直接写了——age 没了。
useEffect 与副作用
1 | useEffect(() => { |
| 依赖数组写法 | 什么时候跑 | 典型用途 |
|---|---|---|
| 不写第二个参数 | 每次渲染后都跑 | 极少用,容易死循环 |
[] |
只在挂载后跑一次(StrictMode 下两次) | 初始化、首屏拉数据 |
[a, b] |
a 或 b 变了之后跑 | 跟着某个值走的同步 |
useLayoutEffect 签名一样,但在 DOM 更新后、浏览器绘制前同步执行——需要读/改布局(尺寸、滚动位置)且不希望看到闪烁时用它。实测顺序见前面:layoutEffect 先于 effect。
三个必须知道的坑
- 依赖数组漏写:闭包会捕获旧值(「stale closure」),界面看着对但请求发的是旧参数。ESLint 的
react-hooks/exhaustive-deps规则要听。 - 清理函数没写:
addEventListener、setInterval、WebSocket 会泄漏;StrictMode 会立刻暴露这类问题(第一次挂载的监听器被清理后,如果代码不幂等就再也收不到事件,或者重复收到)。 - 在 effect 里 setState 又依赖那个 state:死循环。要么改依赖,要么加条件。
数据请求的最小正确写法
1 | function useUser(id) { |
生产项目更推荐用 TanStack Query / SWR(缓存、重试、失效都替你做了),但先理解上面这段。
useMemo、useCallback 与 ref
实测它们的真实行为
1 | let runs = 0 |
在浏览器里比较函数标识、数工厂执行次数(生产构建下的数字):
1 | 两次渲染后 useMemo 工厂执行次数 :: 3 |
开发模式下 useMemo 工厂执行次数是 4(因为每次状态变更渲染两遍),结论一致:依赖没变就不重算、就返回同一个函数。
该不该用 memo 类 Hook
- 默认不要用。React 的 diff 很快,过早
useMemo/useCallback只会让代码更啰嗦、缓存本身也有开销。 - 三种情况才值得加:① 计算确实昂贵(大列表排序、图数据);② 值要作为另一个
useMemo/useEffect的依赖;③ 传给被memo包的子组件、且它是引用类型。 memo只比较浅层 props,所以配useCallback才有意义(否则每次都是新函数,memo白包)。
useRef:两个用途,都不触发渲染
1 | const box = useRef(null) // ① 存 DOM 引用:<div ref={box}> |
实测:
1 | 只改 ref.current 不 setState,DOM 变了吗 :: 否 |
别在渲染过程中读写 ref(除了初始化),那是「渲染不纯」,StrictMode 下容易出问题。
React 19:ref 可以直接当 prop
1 | // 19 之前:必须 forwardRef 包一层 |
实测两种写法都可用:
1 | 函数组件直接收 ref prop(19) :: true |
结论:新代码不用再写 forwardRef(只有要同时传 children/其它参数时才需要),老代码看到 forwardRef 也不用急着改。
列表与 key
1 | {items.map((it) => ( |
key只在 React 元素上(<label key=...>),不会传到 DOM。- 不写 key 会怎样:React 控制台会警告
Each child in a list should have a unique "key" prop,并且按位置复用 DOM 节点。
我做了和《Vue3入门》完全一样的实验(两个列表,一个带 key 一个不带,先填输入框再 reverse()),三个框架结果一致:
1 | 带 key 重排后第一个输入框 :: "输入了B",同行文字 "B" |
不带 key 时输入框留在原位、文字更新了 → 状态串了。所以:列表项里有输入框/勾选/动画/组件实例时,key 必须是数据的稳定标识,别用数组下标。
Context:跨层传值
1 | import { createContext, useContext, use } from 'react' |
实测:
1 | <Context.Provider> + useContext :: useContext=Provider 给的值 | use(Ctx)=Provider 给的值 |
三点提醒:
value必须是新引用才会通知下游:<Ctx.Provider value={{ user }}>每次渲染都新建对象 → 所有消费者重渲染。要useMemo包一下。- Context 更新会重渲染所有消费者。用它做全局状态时,把「高频变化的值」拆出去(或改用 Zustand 这类选择器式方案)。
- 它解决的是「主题、当前用户、表单实例」这种低频、跨层的传递,不是「替代 props 的万能工具」。
React 19 的新东西
use():读 Promise 与 Context
1 | const p = Promise.resolve('promise 里的值') |
实测渲染结果:
1 | use(promise) 渲染出的文本 :: promise 里的值 |
use() 与 useContext 的区别是它可以被条件调用(不是 Hook),并且能在渲染中「暂停」直到 Promise 完成。
Actions:useActionState + useOptimistic
React 19 把「表单提交」变成一等公民:<form action={fn}>,fn 可以是 async 函数。
1 | function Form() { |
实测(点了提交按钮,分别在「请求中」和「请求完成后」读 DOM):
1 | useActionState 初始状态 :: 还没提交 |
看懂这四条:pending 在 action 期间为 true(可以拿来禁用按钮);乐观数据只在请求期间显示,action 返回后自动回落到真实状态——所以真实数据要由 action 的返回值/服务端提供,别指望乐观项自己留下。
useTransition
1 | const [isPending, startTransition] = useTransition() |
诚实的实测结果:
1 | useTransition 初始 isPending :: false |
本地瞬时更新看不到 pending 变 true(React 直接完成了)。要在过渡里做耗时工作(大列表渲染、等待数据)才观察得到——别为了「性能」盲目包 startTransition。
其它 19 的变化
<Context>可直接当 Provider 用(<Ctx value={...}>)。document metadata:可以在组件里渲染<title>、<meta>、<link>,React 会提升到<head>。ref作为 prop(见上)。defaultProps对函数组件不再推荐(用参数默认值),propTypes交给 TypeScript。- 服务端组件(RSC)与
use server/use client:只在 Next.js 这类支持服务端渲染的框架里可用,纯 Vite 客户端项目里用不上,本文没实测。
错误边界
React 里捕获子组件渲染错误的唯一手段是 class 组件(19 仍未提供 Hook 版):
1 | import { Component } from 'react' |
实测:
1 | 错误边界 fallback :: 错误边界接住了:子组件抛的错 |
三个边界条件要记:
- 它捕获不了事件处理器里的错误、异步代码(
setTimeout)里的错误、以及错误边界自身的错误——那些用try/catch或全局window.onerror。 - 开发模式下 React 会先弹错误浮层再显示 fallback,别以为没生效。
- 生产项目建议直接用
react-error-boundary,它把这套 class 组件包装好了,还带resetKeys自动重试。
受控与非受控表单
| 写法 | 数据在哪 | 什么时候用 |
|---|---|---|
受控:value={x} + onChange={e => setX(e.target.value)} |
React state | 要联动校验、禁用按钮、格式化 |
非受控:defaultValue="原始值" + ref 或 FormData |
DOM 自己 | 只关心提交时的最终值(配 Actions 更省事) |
实测非受控:
1 | 非受控组件(defaultValue + 直写 DOM) :: 非受控可以直接写 DOM |
同一个输入框别在受控/非受控之间来回切:value={undefined} 会让 React 把它当非受控并警告。要用 value={x ?? ''}。
与 Vue、Svelte 的对照
| 维度 | React 19 | Vue 3 | Svelte 5 |
|---|---|---|---|
| 写界面 | JSX | HTML 模板 + 指令 | HTML 模板 + 少量语法 |
| 状态更新 | 换新值(不可变) | 直接改(Proxy 追踪) | 直接赋值(编译期插桩) |
| 派生值 | 直接算 / useMemo |
computed |
$derived |
| 副作用 | useEffect + 依赖数组 |
watch / watchEffect |
$effect |
| 列表 | map + key |
v-for + :key |
{#each} + (it.id) |
| 跨层传值 | Context | provide/inject | context(setContext/getContext) |
| 官方路由/状态 | 都无(第三方) | Vue Router + Pinia | SvelteKit |
| 实测首屏 JS(gzip) | 71.16 kB(模板 69.28 kB) | 47.39 kB(含 router+pinia) | 19.72 kB |
| 上手曲线 | 陡(渲染模型要先想清楚) | 平缓 | 最平缓 |
| 岗位与生态 | 最多 | 中文世界最多 | 少但增长快 |
同一个「列表重排」实验我在三个框架里都跑了一遍,结果完全一致(都是「不带 key 就串状态」),说明这条不是某个框架的坑,而是 vDOM 协调算法的通用要求。
常见坑速查
- 改了对象内部但界面不更新 → 引用没变。用
setUser({ ...user, name })(本文有实测)。 useEffect在开发模式跑两遍 → StrictMode 故意的,生产只跑一遍;说明你的副作用不幂等,补清理函数。- effect 里读到旧值 → 依赖数组漏了,或者闭包捕获旧 state。
- 连续
setCount(count + 1)只加一次 → 改函数式setCount(c => c + 1)。 - 列表报
unique "key"警告 → 补key={item.id};别用 index。 - 给
memo包的组件传新对象/新函数 →memo白包,用useCallback/useMemo稳定引用。 - 到处
useMemo→ 默认不加;只在昂贵计算、依赖引用、memo 子组件三种场景加。 - 在渲染里读写
ref.current→ 破坏纯渲染;ref 只在 effect 和事件里用。 ReactDOM.render报错 → 18 起用createRoot(...).render(...)。- 想用 Hook 做错误边界 → 目前只能 class 组件(或用
react-error-boundary)。 value={x}但没写onChange→ React 警告「受控到非受控」;要么补onChange,要么改defaultValue。import { version } from 'svelte'那种直觉:React 是导出version的(实测 19.3.1),Vue 也导出,Svelte 不导出——别把三个框架当一样。
练习
- 不可变复现:把本文的
Profile组件抄下来,先点「改同一个对象」,再点「换新对象」,确认界面文本的变化与实测一致;然后加一层嵌套(user.info.city),用展开写法改它,观察漏写展开时丢了什么。 - 数渲染次数:在组件里加
renderCount++,分别在npm run dev与npm run build && npm run preview下读取,解释为什么差一倍。 - effect 幂等:写一个
useEffect给document.title加监听并在清理里移除,在 StrictMode 下验证「不加清理」会重复绑定。 - stale closure:写一个每 1 秒
setInterval累加的组件,故意把依赖写成[]并在回调里用count,观察它卡在 1;再改函数式更新修好。 memo+useCallback:一个父组件带两个子组件,一个用memo包、一个不包,父里放输入框;输入时数子组件渲染次数,验证「传内联函数会让 memo 失效」。- Actions 小表单:用
useActionState+useOptimistic做「留言列表」,提交时先显示乐观项,pending为 true 时禁用按钮;请求失败时让乐观项消失并提示。 - 错误边界:做一个会在 3 秒后抛错的子组件,用 class 边界捕获并显示「重试」按钮(改 state 触发重新渲染)。
- key 实验:抄本文的带 key / 不带 key 双列表,重排后观察输入框内容,再改成用
index当 key,看它什么时候仍然正确、什么时候出错。