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
2
3
4
5
6
npm create vite@latest react-demo -- --template react
cd react-demo
npm install
npm run dev # 默认 http://localhost:5173
npm run build # 生产构建
npm run lint # 模板自带 oxlint(比 eslint 快很多的新工具)

实测装上的东西:

1
2
3
4
5
6
7
8
react-demo@0.0.0
├── @types/react-dom@19.3.0
├── @types/react@19.3.0
├── @vitejs/plugin-react@6.2.1
├── oxlint@1.90.0
├── react-dom@19.3.1
├── react@19.3.1
└── vite@8.3.5

入口文件(main.jsx)与 Vue 最大的差别是 createRoot + 自己控制 StrictMode:

1
2
3
4
5
6
7
8
9
10
11
12
import { StrictMode, version } from 'react'
import { createRoot } from 'react-dom/client' // 注意:从 react-dom/client 导入,不是 react-dom
import App from './App.jsx'
import './index.css'

window.__reactVersion = version // React 导出 version(实测读到 19.3.1)

createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
  • 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
2
3
4
5
$ npm run build
dist/index.html 0.46 kB │ gzip: 0.29 kB
dist/assets/index-nqMpL4T3.css 1.78 kB │ gzip: 0.81 kB
dist/assets/index-QSxVLdPk.js 226.45 kB │ gzip: 71.16 kB
✓ built in 97 ms

官方模板的 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
2
开发模式:layoutEffect → effect → layoutCleanup → effectCleanup → layoutEffect → effect
生产构建:layoutEffect → effect

读法:开发模式下 React 会「挂载 → 立刻卸载 → 再挂载」,所以清理函数会跑一次。如果你的 effect 在开发模式跑两遍而生产只跑一遍,那是 StrictMode 在提醒你:这个副作用不幂等(比如重复发请求、重复绑定事件),要在清理函数里撤销。

什么会触发重渲染

  • 自己的 state 变了(且新值与旧值 Object.is 不等)。
  • 父组件重渲染(默认会一路往下渲染,React 不会因为 props 没变就跳过子组件)。
  • 消费的 Context 值变了。

跳过重渲染的手段只有两个:memo(组件) 包住子组件,或者把子组件作为 children 传下去。实测:

1
父组件 setState 后 memo 子组件渲染增量 :: 0

(父组件重渲染了一次,被 memo 包的子组件一次都没多渲染。)

useState 与不可变更新

1
2
3
4
const [value, setValue] = useState(0)      // 参数:初始值(只在首次渲染用)
// 返回 [当前值, 更新函数]
setValue(5) // 直接给新值
setValue((prev) => prev + 1) // 函数式更新:拿最新值,异步/连续调用时更安全

useState 的更新是异步批处理的:连续调用两次 setValue(x+1) 只会加 1(读到的 x 是同一个旧值),所以连续更新必须用函数式写法。

不可变:这条最容易踩(实测)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Profile() {
const [user, setUser] = useState({ name: '苏寅' })

function mutateSame() {
user.name = '改过了'
setUser(user) // 引用没变 —— React 用 Object.is 比较,认为「没变化」
}
function newObject() {
setUser((u) => ({ ...u, name: '新对象' })) // 换新引用
}

return (
<>
<p>{user.name}</p>
<button onClick={mutateSame}>改同一个对象再 setUser</button>
<button onClick={newObject}>换新对象 setUser</button>
</>
)
}

我点了两个按钮并读界面文本,实测:

1
2
3
初始 :: 苏寅
mutate 同一对象后 setUser,界面文本 :: 苏寅 ← 没变!
换新对象后 setUser,界面文本 :: 新对象 ← 变了

这就是 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
2
3
const [form, setForm] = useState({ name: '', age: 0 })
// 一次改一个字段:必须展开旧对象,否则其它字段丢
const patch = (k, v) => setForm((f) => ({ ...f, [k]: v }))

新手常见事故就是把 setForm({ name: 'x' }) 直接写了——age 没了。

useEffect 与副作用

1
2
3
4
5
6
useEffect(() => {
// 副作用:订阅、请求、绑定原生事件、初始化第三方库
return () => {
// 清理:在下次执行前、以及组件卸载时运行
}
}, [dep1, dep2]) // 依赖数组
依赖数组写法 什么时候跑 典型用途
不写第二个参数 每次渲染后都跑 极少用,容易死循环
[] 只在挂载后跑一次(StrictMode 下两次) 初始化、首屏拉数据
[a, b] a 或 b 变了之后跑 跟着某个值走的同步

useLayoutEffect 签名一样,但在 DOM 更新后、浏览器绘制前同步执行——需要读/改布局(尺寸、滚动位置)且不希望看到闪烁时用它。实测顺序见前面:layoutEffect 先于 effect。

三个必须知道的坑

  • 依赖数组漏写:闭包会捕获旧值(「stale closure」),界面看着对但请求发的是旧参数。ESLint 的 react-hooks/exhaustive-deps 规则要听。
  • 清理函数没写:addEventListener、setInterval、WebSocket 会泄漏;StrictMode 会立刻暴露这类问题(第一次挂载的监听器被清理后,如果代码不幂等就再也收不到事件,或者重复收到)。
  • 在 effect 里 setState 又依赖那个 state:死循环。要么改依赖,要么加条件。

数据请求的最小正确写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function useUser(id) {
const [user, setUser] = useState(null)
const [err, setErr] = useState(null)

useEffect(() => {
const ctrl = new AbortController() // 清理时取消请求,避免竞态
setUser(null)
fetch(`/api/user/${id}`, { signal: ctrl.signal })
.then((r) => r.json())
.then(setUser)
.catch((e) => { if (e.name !== 'AbortError') setErr(e) })
return () => ctrl.abort()
}, [id])

return { user, err }
}

生产项目更推荐用 TanStack Query / SWR(缓存、重试、失效都替你做了),但先理解上面这段。

useMemo、useCallback 与 ref

实测它们的真实行为

1
2
3
4
let runs = 0
const value = useMemo(() => { runs++; return n * 2 }, [n]) // 依赖没变就不重算
const fn1 = useCallback(() => n, [n]) // 依赖变了 → 新函数
const fn2 = useCallback(() => n, []) // 依赖没变 → 同一函数

在浏览器里比较函数标识、数工厂执行次数(生产构建下的数字):

1
2
3
两次渲染后 useMemo 工厂执行次数 :: 3
依赖没变(空依赖)时 useCallback 是否同一函数 :: true
依赖变了时 useCallback 是否同一函数 :: false

开发模式下 useMemo 工厂执行次数是 4(因为每次状态变更渲染两遍),结论一致:依赖没变就不重算、就返回同一个函数。

该不该用 memo 类 Hook

  • 默认不要用。React 的 diff 很快,过早 useMemo/useCallback 只会让代码更啰嗦、缓存本身也有开销。
  • 三种情况才值得加:① 计算确实昂贵(大列表排序、图数据);② 值要作为另一个 useMemo/useEffect 的依赖;③ 传给被 memo 包的子组件、且它是引用类型。
  • memo 只比较浅层 props,所以配 useCallback 才有意义(否则每次都是新函数,memo 白包)。

useRef:两个用途,都不触发渲染

1
2
3
4
5
const box = useRef(null)                 // ① 存 DOM 引用:<div ref={box}>
const history = useRef([]) // ② 存「变了但不需要重渲染」的值

history.current.push(x) // 不会触发渲染
box.current.getBoundingClientRect() // 只能在挂载后(effect / 事件里)读

实测:

1
2
只改 ref.current 不 setState,DOM 变了吗 :: 否
重渲染后读到 ref 值 :: 42

别在渲染过程中读写 ref(除了初始化),那是「渲染不纯」,StrictMode 下容易出问题。

React 19:ref 可以直接当 prop

1
2
3
4
5
// 19 之前:必须 forwardRef 包一层
const InputOld = forwardRef((props, ref) => <input ref={ref} />)

// 19 起:ref 就是普通 prop
const InputNew = function InputNew({ ref }) { return <input ref={ref} /> }

实测两种写法都可用:

1
2
函数组件直接收 ref prop(19) :: true
forwardRef 仍然可用 :: true

结论:新代码不用再写 forwardRef(只有要同时传 children/其它参数时才需要),老代码看到 forwardRef 也不用急着改。

列表与 key

1
2
3
{items.map((it) => (
<label key={it.id}>{it.t} <input /></label>
))}
  • key 只在 React 元素上(<label key=...>),不会传到 DOM。
  • 不写 key 会怎样:React 控制台会警告 Each child in a list should have a unique "key" prop,并且按位置复用 DOM 节点。

我做了和《Vue3入门》完全一样的实验(两个列表,一个带 key 一个不带,先填输入框再 reverse()),三个框架结果一致:

1
2
带 key 重排后第一个输入框 :: "输入了B",同行文字 "B"
不带 key 重排后第一个输入框 :: "输入了A",同行文字 "B"

不带 key 时输入框留在原位、文字更新了 → 状态串了。所以:列表项里有输入框/勾选/动画/组件实例时,key 必须是数据的稳定标识,别用数组下标。

Context:跨层传值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { createContext, useContext, use } from 'react'

const Ctx = createContext('(默认值)') // 参数:没有 Provider 时的默认值

function Child() {
const a = useContext(Ctx) // Hook 写法
const b = use(Ctx) // 19 的 use(),效果一样
return <p>{a} | {b}</p>
}

function App() {
return (
<Ctx.Provider value="Provider 给的值"> {/* 19 里 <Ctx> 直接当 Provider 也行 */}
<Child />
</Ctx.Provider>
)
}

实测:

1
<Context.Provider> + useContext :: useContext=Provider 给的值 | use(Ctx)=Provider 给的值

三点提醒:

  • value 必须是新引用才会通知下游:<Ctx.Provider value={{ user }}> 每次渲染都新建对象 → 所有消费者重渲染。要 useMemo 包一下。
  • Context 更新会重渲染所有消费者。用它做全局状态时,把「高频变化的值」拆出去(或改用 Zustand 这类选择器式方案)。
  • 它解决的是「主题、当前用户、表单实例」这种低频、跨层的传递,不是「替代 props 的万能工具」。

React 19 的新东西

use():读 Promise 与 Context

1
2
3
4
5
6
7
8
9
10
const p = Promise.resolve('promise 里的值')

function AwaitChild() {
const v = use(p) // 配合 <Suspense> 使用;不是 Hook,可以在条件语句里调用
return <p>{v}</p>
}

<Suspense fallback={<p>等待中</p>}>
<AwaitChild />
</Suspense>

实测渲染结果:

1
use(promise) 渲染出的文本 :: promise 里的值

use() 与 useContext 的区别是它可以被条件调用(不是 Hook),并且能在渲染中「暂停」直到 Promise 完成。

Actions:useActionState + useOptimistic

React 19 把「表单提交」变成一等公民:<form action={fn}>,fn 可以是 async 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function Form() {
const [optimistic, addOptimistic] = useOptimistic(
['初始一条'],
(cur, text) => [...cur, '乐观:' + text], // 提交中先显示假数据
)
const [state, formAction, pending] = useActionState(
async (prev, formData) => {
addOptimistic(formData.get('kw'))
await new Promise((r) => setTimeout(r, 60)) // 模拟网络
return 'action 收到:' + formData.get('kw')
},
'还没提交',
)

return (
<form action={formAction}>
<input name="kw" defaultValue="abc" />
<button type="submit">提交</button>
<p>{state}</p>
<p>{String(pending)}</p>
<ul>{optimistic.map((x, i) => <li key={i}>{x}</li>)}</ul>
</form>
)
}

实测(点了提交按钮,分别在「请求中」和「请求完成后」读 DOM):

1
2
3
4
5
6
useActionState 初始状态 :: 还没提交
useActionState 初始 pending :: false
请求中:pending :: true
请求中:列表 :: ["初始一条", "乐观:abc"]
完成后:state :: action 收到:abc
完成后:列表 :: ["初始一条"]

看懂这四条:pending 在 action 期间为 true(可以拿来禁用按钮);乐观数据只在请求期间显示,action 返回后自动回落到真实状态——所以真实数据要由 action 的返回值/服务端提供,别指望乐观项自己留下。

useTransition

1
2
const [isPending, startTransition] = useTransition()
startTransition(() => setN((x) => x + 1)) // 把这次更新标记为「可打断」

诚实的实测结果:

1
2
useTransition 初始 isPending :: false
startTransition 期间 isPending 变了吗 :: false → 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
2
3
4
5
6
7
8
9
10
11
12
13
import { Component } from 'react'

class Boundary extends Component {
constructor(props) { super(props); this.state = { err: null } }
static getDerivedStateFromError(e) { return { err: e.message } }
render() {
return this.state.err
? <p>错误边界接住了:{this.state.err}</p>
: this.props.children
}
}

<Boundary><Boom /></Boundary>

实测:

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 不导出——别把三个框架当一样。

练习

  1. 不可变复现:把本文的 Profile 组件抄下来,先点「改同一个对象」,再点「换新对象」,确认界面文本的变化与实测一致;然后加一层嵌套(user.info.city),用展开写法改它,观察漏写展开时丢了什么。
  2. 数渲染次数:在组件里加 renderCount++,分别在 npm run dev 与 npm run build && npm run preview 下读取,解释为什么差一倍。
  3. effect 幂等:写一个 useEffect 给 document.title 加监听并在清理里移除,在 StrictMode 下验证「不加清理」会重复绑定。
  4. stale closure:写一个每 1 秒 setInterval 累加的组件,故意把依赖写成 [] 并在回调里用 count,观察它卡在 1;再改函数式更新修好。
  5. memo + useCallback:一个父组件带两个子组件,一个用 memo 包、一个不包,父里放输入框;输入时数子组件渲染次数,验证「传内联函数会让 memo 失效」。
  6. Actions 小表单:用 useActionState + useOptimistic 做「留言列表」,提交时先显示乐观项,pending 为 true 时禁用按钮;请求失败时让乐观项消失并提示。
  7. 错误边界:做一个会在 3 秒后抛错的子组件,用 class 边界捕获并显示「重试」按钮(改 state 触发重新渲染)。
  8. key 实验:抄本文的带 key / 不带 key 双列表,重排后观察输入框内容,再改成用 index 当 key,看它什么时候仍然正确、什么时候出错。

参考

本文结束 感谢您的阅读
正在加载今日诗词....