React 入门 —— 给 Vue / Angular 老兵的速成文档

默认读者:JS/TS 熟练,写过多年 Vue(包括 Composition API)和 Angular。
目标:用最少的概念跑通 React 的心智模型 + 直接能写业务页。
风格:对照 Vue / Angular 来记,避免重新学一遍 UI 套路。


0. React 是什么(一句话版)

  • 不是框架,只是个「把 state 变成 UI」的视图库。
  • 路由、请求、状态管理、表单、表格……全靠生态拼。
  • 核心公式:
UI = f(state)

state 一变 → 组件函数整体重跑 → React 拿新旧 JSX diff → 更新真实 DOM。

记住这条,后面所有「为什么这样写」都能解释。


1. 三大框架心智模型对照

维度 Vue 3 Angular React
组件载体 .vue 单文件 .ts + .html + .css 一个函数(.tsx
模板 template + 指令 template + 指令 JSX(JS 表达式)
响应式 ref / reactive 自动追踪 RxJS / Signals / Zone 不自动,必须 setState
父传子 props @Input() props(函数参数)
子传父 emit @Output() EventEmitter 传函数 proponXxx
双向绑定 v-model [(ngModel)] 没有,自己写 value + onChange
条件渲染 v-if *ngIf {cond && <X/>} 或三元
列表渲染 v-for *ngFor array.map(...) + key
计算属性 computed getter / computed() signal useMemo(多数时候用变量就行)
监听 watch RxJS / effect() useEffect(fn, [deps])
生命周期 onMounted ngOnInit useEffect(统一)
DI provide/inject Injector(强 DI) Context / 直接 import
路由 vue-router @angular/router react-router-dom(第三方)
状态管理 Pinia NgRx / Signals Zustand / Redux Toolkit / Context

最大的心智差异:Vue/Angular 的响应式是「数据变 → 框架知道」;
React 是「你告诉 React 数据变了」,必须调 setXxx,否则不渲染。


2. 项目起步(10 分钟)

npm create vite@latest my-app -- --template react-ts
cd my-app
npm i
npm run dev

目录:

src/
  main.tsx        # 入口,挂载 <App/>
  App.tsx         # 根组件
  components/     # 自己拆

main.tsx 长这样:

import { createRoot } from 'react-dom/client'
import App from './App'

createRoot(document.getElementById('root')!).render(<App />)

3. JSX —— 在 JS 里写 HTML 的规则

function Demo() {
  const name = '小明'
  const list = [1, 2, 3]
  const visible = true

  return (
    <div className="box" style={{ color: 'red' }}>
      <h1>你好 {name}</h1>

      {visible && <p>会显示</p>}
      {visible ? <p>A</p> : <p>B</p>}

      <ul>
        {list.map((n) => (
          <li key={n}>{n}</li>
        ))}
      </ul>
    </div>
  )
}

规则要点

Vue 写法 JSX 写法
class="x" className="x"
:class="{a: ok}" className={ok ? 'a' : ''}
style="color: red" style={{ color: 'red' }}对象
@click="fn" onClick={fn}
v-if {cond && <X/>}
v-for arr.map(x => <X key={x.id}/>)
{{ x }} {x}
多根节点 <>...</>(Fragment)

key 必须、稳定、唯一。别用 index(数据顺序会变就 GG)。


4. 组件 = 函数

type Props = {
  title: string
  onClose: () => void          // 子→父:传函数
  children?: React.ReactNode   // 默认插槽
}

export default function Modal({ title, onClose, children }: Props) {
  return (
    <div className="modal">
      <header>{title} <button onClick={onClose}>×</button></header>
      <main>{children}</main>
    </div>
  )
}

用:

<Modal title="提示" onClose={() => setOpen(false)}>
  内容
</Modal>

Vue → React 对照

Vue React
defineProps<{...}>() 函数参数 + TS 类型
defineEmits onXxx 函数 prop
默认 slot children
具名 slot 传 ReactNode 类型的 prop,比如 header?: React.ReactNode

5. 状态:useState

import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  )
}

关键规则(新手 90% 的坑

// ❌ 直接改对象/数组,React 看不到变化
state.list.push(item)
setState(state)

// ✅ 新引用
setState({ ...state, list: [...state.list, item] })
// ❌ 连续调用基于旧值
setCount(count + 1)
setCount(count + 1)   // 还是 +1,不是 +2

// ✅ 用函数形式
setCount(c => c + 1)
setCount(c => c + 1)
// ❌ 在渲染里直接 set,会死循环
setCount(count + 1)

// ✅ 放事件、effect 里

对比

Vue React
const count = ref(0); count.value++ const [count, setCount] = useState(0); setCount(c=>c+1)
reactive({a:1}) useState({a:1}) + 整体替换
自动 deep 响应 没有 deep,对象/数组要新引用

6. 副作用:useEffect(Vue watch + 生命周期 二合一)

import { useEffect, useState } from 'react'

function User({ id }: { id: number }) {
  const [data, setData] = useState<any>(null)

  useEffect(() => {
    let cancelled = false
    fetch(`/api/user/${id}`)
      .then(r => r.json())
      .then(d => { if (!cancelled) setData(d) })

    return () => { cancelled = true }   // 清理(相当于 onUnmounted / watch stop)
  }, [id])  // 依赖变了重跑

  return <div>{data?.name}</div>
}

三种依赖写法

依赖 含义 类比
[] 只在挂载时跑一次 onMounted
[a, b] a 或 b 变了再跑 watch([a,b], ...)
不传 每次渲染都跑 几乎不要这么写

关键坑

  1. 依赖必须诚实。effect 里用到的外部变量,都要进数组(用 ESLint 的 react-hooks/exhaustive-deps)。
  2. 请求要带取消标志(上面 cancelled),否则旧请求迟到会覆盖新数据。
  3. 别拿 effect 当 watch 用所有场景:能在事件里直接做的事,就别放 effect。
  4. Strict Mode 下开发期 effect 会跑两次,是故意的,逼你写清理函数。

7. 派生数据:useMemo / useCallback

const filtered = useMemo(
  () => list.filter(x => x.name.includes(keyword)),
  [list, keyword]
)

const handleClick = useCallback((id: number) => {
  doSomething(id)
}, [])
  • useMemo ≈ Vue 的 computed,但不强制用
    90% 的派生值直接 const x = a + b 就够了,性能瓶颈再优化。
  • useCallback 用于「把函数传给子组件」时保持引用稳定,避免子组件白白重渲。
    没传给子组件、没进依赖数组的函数,别套,纯增加复杂度。

一句话:先别优化。React 重渲很便宜,profile 之后再上 memo 三件套(memo / useMemo / useCallback)。


8. 引用与 DOM:useRef

const inputRef = useRef<HTMLInputElement>(null)

useEffect(() => {
  inputRef.current?.focus()
}, [])

return <input ref={inputRef} />
  • 拿 DOM:和 Vue 的 ref 一模一样。
  • 存「不触发渲染的可变值」:定时器 id、上一次的值等。
    ref.current 不会触发重渲,这点和 state 相反。

9. 表单:没有 v-model

function Form() {
  const [name, setName] = useState('')
  const [age, setAge] = useState<number | ''>('')

  const submit = (e: React.FormEvent) => {
    e.preventDefault()
    console.log({ name, age })
  }

  return (
    <form onSubmit={submit}>
      <input value={name} onChange={e => setName(e.target.value)} />
      <input
        type="number"
        value={age}
        onChange={e => setAge(e.target.value === '' ? '' : Number(e.target.value))}
      />
      <button type="submit">提交</button>
    </form>
  )
}
  • 这叫受控组件value + onChange 全自己接管。
  • 复杂表单别手写,用 UI 库自己的 Form(Ant Design / Arco / TDesign)。
    Ant Design React 版的 Form.useForm() 用法和 Vue 的 Form 校验体验差不多。

10. Context(解决 props 一层层传)

type Theme = 'light' | 'dark'
const ThemeCtx = React.createContext<Theme>('light')

function App() {
  return (
    <ThemeCtx.Provider value="dark">
      <Page />
    </ThemeCtx.Provider>
  )
}

function DeepChild() {
  const theme = useContext(ThemeCtx)
  return <div>{theme}</div>
}
  • 类比:Vue 的 provide/inject、Angular 的 DI(弱化版)。
  • 不要用 Context 当全局状态管理,会让所有消费者重渲。状态多就上 Zustand / Redux Toolkit。

11. 自定义 Hook(最爽的复用方式)

逻辑复用不靠 mixin / 高阶组件,靠自定义 hook(一个以 use 开头的普通函数)。

function useLoading(initial = false) {
  const [loading, setLoading] = useState(initial)
  return { loading, setLoading }
}

function useRequest<T>(fn: () => Promise<T>, deps: any[] = []) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<unknown>(null)

  useEffect(() => {
    let cancelled = false
    setLoading(true)
    fn()
      .then(d => { if (!cancelled) setData(d) })
      .catch(e => { if (!cancelled) setError(e) })
      .finally(() => { if (!cancelled) setLoading(false) })
    return () => { cancelled = true }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps)

  return { data, loading, error }
}

用:

const { data, loading } = useRequest(() => fetch('/api/user').then(r => r.json()))

Vue 的 useXxx 组合式函数思想几乎完全一致,迁移无痛。


12. Hooks 的硬规则(违反就报错)

  1. 只在函数组件 / 自定义 hook 顶层调用
    不能写在 if / for / 回调 里。
  2. 顺序必须稳定。React 靠调用顺序认 hook,乱了就全错。
  3. 不能在普通函数里调用 hook

ESLint 装 eslint-plugin-react-hooks 自动帮你检查。


13. 路由:react-router-dom(v6)

npm i react-router-dom
import { createBrowserRouter, RouterProvider, Link, useParams, useNavigate } from 'react-router-dom'

const router = createBrowserRouter([
  { path: '/', element: <Home /> },
  { path: '/user/:id', element: <User /> },
])

function Root() {
  return <RouterProvider router={router} />
}

function User() {
  const { id } = useParams()
  const nav = useNavigate()
  return (
    <>
      <div>user {id}</div>
      <button onClick={() => nav('/')}>回首页</button>
    </>
  )
}
Vue Router React Router
<router-link> <Link>
<router-view> <Outlet/>(嵌套路由)
useRoute() useParams() / useLocation()
useRouter() useNavigate()
守卫 没有内置,自己写 wrapper 组件 / loader

14. 请求 + 服务端状态:用 TanStack Query(强烈推荐)

npm i @tanstack/react-query
import { useQuery } from '@tanstack/react-query'

function UserList() {
  const { data, isLoading, error, refetch } = useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(r => r.json()),
  })

  if (isLoading) return <div>loading...</div>
  if (error) return <div>err</div>
  return <ul>{data.map((u: any) => <li key={u.id}>{u.name}</li>)}</ul>
}
  • 自动缓存、去重、重试、轮询、依赖刷新。
  • 业务页 90% 的「loading / data / error」三件套都不用自己写了。
  • Vue 圈对标的是 @tanstack/vue-query,思路一样。

15. 全局状态:先 Zustand,再考虑 Redux Toolkit

npm i zustand
import { create } from 'zustand'

type Store = {
  count: number
  inc: () => void
}

const useCounter = create<Store>((set) => ({
  count: 0,
  inc: () => set(s => ({ count: s.count + 1 })),
}))

function A() {
  const count = useCounter(s => s.count)
  return <div>{count}</div>
}

function B() {
  const inc = useCounter(s => s.inc)
  return <button onClick={inc}>+</button>
}
  • API 简洁、无 boilerplate,Pinia 用户秒上手。
  • 团队大 / 需要严格规范 → Redux Toolkit。

16. UI 库选择(业务向)

备注
Ant Design 后台首选,组件全,文档好
Arco Design 字节出品,和你 Vue 项目里用的 Arco 体验一致
TDesign 腾讯出品
MUI Material 风,国外团队多
shadcn/ui + Tailwind 现代风潮,复制源码到项目里、完全可控

你已经在用 Arco Vue 版,React 版迁移成本最低。


17. 一个完整可对照的例子(你 Vue 项目里那种「弹窗 + 表单」)

Vue(你已有的写法)

<template>
  <a-modal visible @cancel="onCancel" @ok="onOk">
    <a-form ref="formRef" :model="form" :rules="rules">
      <a-form-item label="名称" field="name">
        <a-input v-model="form.name" />
      </a-form-item>
    </a-form>
  </a-modal>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue'
const emit = defineEmits(['submit', 'cancel'])
const formRef = ref()
const form = ref({ name: '' })
const rules = reactive({ name: [{ required: true, message: '请输入' }] })

const onCancel = () => emit('cancel')
const onOk = async () => {
  const err = await formRef.value.validate()
  if (err) return
  emit('submit', form.value)
}
</script>

React 对应

import { useState } from 'react'
import { Modal, Form, Input } from 'antd' // 或 @arco-design/web-react

type Props = {
  onSubmit: (data: { name: string }) => void
  onCancel: () => void
}

export default function AddStage({ onSubmit, onCancel }: Props) {
  const [form] = Form.useForm()
  const [loading, setLoading] = useState(false)

  const handleOk = async () => {
    try {
      const values = await form.validateFields()
      setLoading(true)
      await onSubmit(values)
    } finally {
      setLoading(false)
    }
  }

  return (
    <Modal open title="添加" onCancel={onCancel} onOk={handleOk} confirmLoading={loading}>
      <Form form={form} layout="vertical">
        <Form.Item
          label="名称"
          name="name"
          rules={[{ required: true, message: '请输入' }]}
        >
          <Input />
        </Form.Item>
      </Form>
    </Modal>
  )
}

父用法:

{open && (
  <AddStage
    onCancel={() => setOpen(false)}
    onSubmit={async (data) => {
      await api.add(data)
      setOpen(false)
    }}
  />
)}

对照重点

  • 没有 visible 这种 props,通过条件渲染控制存在/销毁(也可以用 open prop)。
  • 子组件不 emit,父传 onSubmit / onCancel 函数下来。
  • 表单值由 antd Form 托管,几乎和 Vue 的 Arco Form 一样。

18. 工程化常用搭配(业务推荐栈)

Vite + React + TS
react-router-dom         路由
@tanstack/react-query    请求 / 服务端状态
zustand                  客户端状态
antd / arco-react        UI
react-hook-form + zod    (表单不用 UI 库时)
eslint + prettier        规范

19. 给你这位 Vue/Angular 老兵的「避坑清单」

  1. 响应式不会自动。所有更新走 setState
  2. 状态不可变:对象/数组永远换新引用。
  3. 函数组件每次渲染都重跑:不要在函数体里写副作用、定时器、订阅。
  4. 依赖数组要诚实:装 react-hooks/exhaustive-deps,别和它对着干。
  5. Strict Mode 下 effect 跑两次是正常的,别去关。
  6. useEffect 不是 watch 的万能替代:能在事件里直接做的,别推到 effect。
  7. 不要用 index 当 key
  8. 优化先 profileReact.memo / useMemo / useCallback 是兜底,不是日常。
  9. 没有全局 this,没有 DI:依赖靠 import + Context。
  10. 请求别裸写 useEffect:直接上 react-query,省心 80%。

20. 推荐学习路径(3–5 天可上手业务)

Day 1

  • 跑通 Vite + React + TS 模板
  • 写:计数器、todo 列表、表单受控输入
  • 理解 useState 不可变更新

Day 2

  • useEffect、清理函数、依赖数组
  • fetch + useEffect 写一个用户列表
  • 自定义 hook:useRequestuseLoading

Day 3

  • React Router:列表页 + 详情页
  • Antd / Arco:Form、Table、Modal
  • 抄一遍你 Vue 项目里 add-stage.vuedetail-info.vue 这种页

Day 4

  • TanStack Query 替换裸 useEffect 请求
  • Zustand 写一个全局 user / 权限 store

Day 5

  • 复刻一个真实业务页(比如你 worker 仓库里某个列表+弹窗)
  • 写完之后回头看本文「避坑清单」,逐条对照

21. 官方 & 高质量文档


22. 一句话总结

Vue 是「自动响应式 + 模板指令」,React 是「不可变 state + 纯函数 UI」
你已经会的所有前端工程能力(构建、路由、组件拆分、表单校验、请求封装)几乎平移过去,
真正要换的,只是「数据变更怎么告诉视图」这一层。

把这条记牢,剩下的就是查 API + 写业务。


进阶补充

下面四章是写完业务一阵子后会用到的内容。第一次看可以跳过,等踩到问题再回头看。


23. SSR / SSG / RSC(服务端渲染体系)

23.1 几个名词先分清

缩写 全称 在哪里跑 类比
CSR Client Side Rendering 浏览器跑 React Vue SPA 默认模式
SSR Server Side Rendering Node 端把组件渲成 HTML 字符串再返回,浏览器再 hydrate Nuxt SSR / Angular Universal
SSG Static Site Generation 构建期就把 HTML 生成好 Nuxt generate / VitePress
ISR Incremental Static Regeneration SSG + 后台定时再生 Next 特有
RSC React Server Components 组件本身分服务端 / 客户端,服务端组件不打到前端包 Vue 没有等价物
Streaming 流式 SSR 服务端边渲边吐 HTML

23.2 SSR 的核心好处与代价

好处

  • 首屏 HTML 直接可见 → SEO / 首屏更快
  • 服务端能直接读数据库 / 内部接口(RSC 尤其友好)

代价

  • 部署要有 Node 运行时(CSR 一个 nginx 就行)
  • 状态、window / document、定时器、第三方库的 SSR 兼容要注意
  • 心智复杂度↑:要分清「这段代码哪边跑」

23.3 现在的主流选择

框架 模式 一句话
Next.js (App Router) SSR / SSG / RSC / ISR / Edge 事实标准,几乎所有 React SSR 项目都用它
Remix / React Router v7 SSR / loader-action 数据流靠 loader/action,体验像写后端
Astro 默认静态,岛屿架构 内容站点首选,可混用多框架
Vite + 自定义 SSR DIY 不推荐生产使用

23.4 Next.js App Router 极简上手

npx create-next-app@latest my-app
app/
  page.tsx                // 路由 /
  about/page.tsx          // 路由 /about
  layout.tsx              // 公共布局
  users/[id]/page.tsx     // /users/123
  api/hello/route.ts      // 接口路由

默认是服务端组件(RSC)

// app/users/page.tsx —— 直接 await,没有 useEffect
export default async function UsersPage() {
  const res = await fetch('https://api.example.com/users', { cache: 'no-store' })
  const users = await res.json()
  return (
    <ul>
      {users.map((u: any) => <li key={u.id}>{u.name}</li>)}
    </ul>
  )
}

需要状态 / 事件 / 浏览器 API 的组件加 "use client"

'use client'
import { useState } from 'react'
export default function Counter() {
  const [n, setN] = useState(0)
  return <button onClick={() => setN(n + 1)}>{n}</button>
}

23.5 RSC 心智速记

服务端组件(默认) 客户端组件("use client"
await fetch / 直连 DB 能用 useState / useEffect / 事件
不能用 hook、事件、window 不能直接 await 顶层
不打进 client bundle 会下发到浏览器
可嵌套客户端组件 不能反过来嵌服务端组件(只能通过 children 传)

23.6 SSR 常见坑

  1. window is not defined:访问 DOM 的代码放进 useEffect,或动态 import + ssr: false
  2. Hydration mismatch:服务端和客户端首次渲染结果不一致(常见于「时间」「随机数」「依赖 localStorage」),用 suppressHydrationWarning 或挪到 effect。
  3. 环境变量:Next 里 NEXT_PUBLIC_ 前缀才会下发到浏览器。
  4. 第三方库 SSR 不兼容:用 next/dynamic 动态加载。

23.7 你该不该用 SSR?

项目类型 建议
你 worker 这种内部后台管理系统 不需要 SSR,老老实实 Vite + CSR 就行
营销官网 / 内容站 Astro / Next SSG
C 端应用、SEO 重要 Next.js
重交互、登录后才用 普通 SPA

24. 性能优化深入

24.1 第一性原理

React 慢,永远是其中之一:

  1. 重渲染太多(组件没必要的 re-render)
  2. 重渲染太重(一次渲染做了大量 JS 计算)
  3. DOM 太大 / 太深(虚拟 DOM diff 也救不了几万 DOM)
  4. 请求 / 资源没优化(首屏白屏其实和 React 无关)

先 profile,再优化。打开 React DevTools → Profiler,录一段交互看「flame chart」,谁红优化谁。别凭直觉到处套 useMemo。

24.2 重渲染来源

一个组件什么时候会重渲?

  1. 它自己的 state / 它订阅的 context 变了
  2. 它的父组件重渲了(即使 props 没变也会跟着重渲)

注意:props 即使值相同,引用变了也算变。

24.3 React.memo —— 阻断父级带来的重渲

const Child = React.memo(function Child({ user }: { user: User }) {
  return <div>{user.name}</div>
})
  • 默认浅比较 props;引用没变就跳过。
  • 配合 useCallback / useMemo 才有意义,否则父每次给你的 props 都是新引用,memo 形同虚设。
function Parent() {
  const [count, setCount] = useState(0)
  const user = useMemo(() => ({ name: 'A' }), [])           // 稳定引用
  const onClick = useCallback(() => console.log('x'), [])   // 稳定引用
  return <Child user={user} onClick={onClick} />
}

24.4 useMemo / useCallback 的真实场景

只在这三种情况下用:

  1. 计算真的贵(几 ms 以上,或大数据 filter/sort)
  2. 传给 memo 化的子组件(保稳定引用)
  3. 函数传给 memo 化的子组件 / 进 effect deps

其它都是过早优化,反而徒增 hook 数量。

24.5 状态拆分 / 状态下移

降低重渲染范围最有效的手段。

// ❌ 整个表单一个大 state,输入一个字段,全部子组件都重渲
const [form, setForm] = useState({ name: '', age: 0, address: '' })

// ✅ 拆开 / 或者把 input 抽成自己管 state 的子组件
function NameField() {
  const [name, setName] = useState('')
  return <input value={name} onChange={e => setName(e.target.value)} />
}

口诀:state 离用它的组件越近越好

24.6 Context 性能陷阱

Context 一变,所有消费者都重渲,与是否真正用到那部分值无关。

解决:

  1. 拆 Context:常变的 / 不常变的分开
  2. selector 模式:用 zustand / jotai / use-context-selector
  3. 别拿 Context 当全局 store

24.7 列表性能

问题 方案
长列表(>500 项) 虚拟滚动react-virtuoso / @tanstack/react-virtual
表格大数据 AG Grid / TanStack Table + 虚拟化
key 不稳定 永远用业务 id,不用 index
每行重渲 行组件 React.memo,并传稳定的 props

24.8 大组件 / 路由级代码分割

import { lazy, Suspense } from 'react'
const Heavy = lazy(() => import('./Heavy'))

<Suspense fallback={<div>loading...</div>}>
  <Heavy />
</Suspense>
  • 路由用 lazy 拆 chunk,按需加载。
  • Vite / Next 默认会做 route-based splitting,但页内重组件你要自己拆。

24.9 useTransition(标记非紧急更新)

const [keyword, setKeyword] = useState('')
const [list, setList] = useState<Item[]>([])
const [isPending, startTransition] = useTransition()

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setKeyword(e.target.value)              // 紧急:输入框立即更新
  startTransition(() => {                  // 非紧急:大列表过滤
    setList(filter(allData, e.target.value))
  })
}

输入框打字流畅,列表更新可被中断 / 推迟。

24.10 React 19 的 React Compiler

  • 官方自动优化方案,编译期自动加 memo
  • 一旦稳定,多数 useMemo / useCallback / React.memo 不用手写。
  • 现阶段保持关注,业务可以小范围试用。

24.11 性能检查清单

  • React DevTools Profiler 录过一遍
  • state 是否能下移到更小的子组件
  • 大列表是否虚拟化
  • 路由级 / 重组件级有没有 code split
  • Context 是否拆开 / 用 selector
  • 网络层是否做了缓存(react-query)
  • 首屏图片有没有懒加载、字体有没有 preload
  • 包体积有没有 vite-plugin-visualizer / webpack-bundle-analyzer 看过

25. 测试

25.1 选型

层级 工具 类比
单测 / 组件测试 Vitest + @testing-library/react Vue Test Utils + Vitest
浏览器 E2E Playwright(推荐)/ Cypress 同 Vue
视觉回归 Chromatic / Loki
组件预览 Storybook 同 Vue

Vite 项目用 Vitest 几乎零配置。Jest 不是不能用,但 ESM / TS / Vite 体系下 Vitest 更顺。

25.2 安装

npm i -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

vite.config.ts

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    globals: true,
  },
})

src/test/setup.ts

import '@testing-library/jest-dom/vitest'

25.3 组件测试(核心思想:从用户视角)

// Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Counter from './Counter'

test('点击按钮 count 加 1', async () => {
  render(<Counter />)
  const btn = screen.getByRole('button', { name: /\+/ })
  expect(btn).toHaveTextContent('0')
  await userEvent.click(btn)
  expect(btn).toHaveTextContent('1')
})

要点:

  • 按角色 / 文本找元素getByRole / getByText / getByLabelText),不要按 class / id。
  • userEvent 模拟真实交互,不要直接 fireEvent
  • 别测实现细节(state 名字、内部方法),测用户能感知的结果

25.4 mock 请求

// 用 msw(Mock Service Worker)
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'

const server = setupServer(
  http.get('/api/users', () => HttpResponse.json([{ id: 1, name: 'A' }]))
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

业务里所有 fetch / axios 都自动被拦截。

25.5 自定义 hook 测试

import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

test('useCounter', () => {
  const { result } = renderHook(() => useCounter())
  expect(result.current.count).toBe(0)
  act(() => result.current.inc())
  expect(result.current.count).toBe(1)
})

25.6 E2E(Playwright)

npm init playwright@latest
import { test, expect } from '@playwright/test'

test('登录流程', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('用户名').fill('admin')
  await page.getByLabel('密码').fill('123456')
  await page.getByRole('button', { name: '登录' }).click()
  await expect(page).toHaveURL('/dashboard')
})

25.7 测什么、不测什么

该测 不该测
业务流程(登录、下单) 第三方库自身
自定义 hook 的逻辑 UI 库的内部行为
关键工具函数 样式细节
容易回归的边界条件 实现细节(私有方法、内部 state 名)

经验值:业务后台先把 E2E 写好 5–10 条主流程,性价比远高于组件单测铺满。


26. TypeScript 高阶用法(React 场景)

26.1 组件 Props 的几种写法

// 1. 最常用:函数参数解构 + type
type ButtonProps = {
  type?: 'primary' | 'default'
  loading?: boolean
  onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
  children: React.ReactNode
}

function Button({ type = 'default', loading, onClick, children }: ButtonProps) {
  return <button onClick={onClick}>{children}</button>
}

// 2. 不推荐:React.FC(默认隐式有 children、默认值类型不友好)
const Bad: React.FC<ButtonProps> = (props) => <button />

26.2 常见类型速查

场景 类型
可渲染内容(任意 JSX / 字符串 / null) React.ReactNode
单个 React 元素 React.ReactElement
事件 React.MouseEvent<HTMLButtonElement> / ChangeEvent<HTMLInputElement>
ref 指向 input React.RefObject<HTMLInputElement>
转发 ref 的组件 React.ForwardedRef<T>
样式对象 React.CSSProperties
子组件作为函数 (args: T) => React.ReactNode

26.3 useState 的类型推导

const [n, setN] = useState(0)                          // number
const [user, setUser] = useState<User | null>(null)    // 显式联合
const [list, setList] = useState<Item[]>([])           // 空数组要显式

26.4 useRef 三种用法

const inputRef = useRef<HTMLInputElement>(null)       // 给 DOM
const timerRef = useRef<number | null>(null)          // 存可变值
const initRef = useRef(false)                          // 标志位

26.5 useReducer 强类型

type State = { count: number }
type Action = { type: 'inc' } | { type: 'set'; payload: number }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'inc': return { count: state.count + 1 }
    case 'set': return { count: action.payload }
  }
}

const [state, dispatch] = useReducer(reducer, { count: 0 })
dispatch({ type: 'set', payload: 10 })   // ✅ 类型严格

26.6 forwardRef + 泛型组件

import { forwardRef } from 'react'

type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
  label?: string
}

const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
  { label, ...rest },
  ref,
) {
  return (
    <label>
      {label}
      <input ref={ref} {...rest} />
    </label>
  )
})

26.7 泛型组件(比如通用 List)

type ListProps<T> = {
  data: T[]
  renderItem: (item: T) => React.ReactNode
  keyOf: (item: T) => React.Key
}

function List<T>({ data, renderItem, keyOf }: ListProps<T>) {
  return <ul>{data.map(item => <li key={keyOf(item)}>{renderItem(item)}</li>)}</ul>
}

<List
  data={users}
  keyOf={u => u.id}
  renderItem={u => <span>{u.name}</span>}
/>

26.8 Discriminated Union 收窄 props

type Props =
  | { mode: 'view'; data: User }
  | { mode: 'edit'; data: User; onChange: (u: User) => void }

function UserPanel(props: Props) {
  if (props.mode === 'edit') {
    props.onChange     // ✅ 这里 TS 知道有 onChange
  }
  return <div>{props.data.name}</div>
}

业务上区分「只读 vs 编辑」「单选 vs 多选」非常好用。

26.9 as const + satisfies(避免类型变宽 / 又不丢类型校验)

const statusMap = {
  1: '待派工',
  2: '已派工',
  3: '已完成',
} as const satisfies Record<number, string>

type Status = keyof typeof statusMap   // 1 | 2 | 3

适合后台项目里的状态枚举、map。

26.10 工具类型常用

type Partial<T>            // 全部可选
type Required<T>           // 全部必填
type Pick<T, K>            // 选字段
type Omit<T, K>            // 去字段
type Record<K, V>          // 字典
type ReturnType<typeof fn> // 取函数返回值
type Awaited<T>            // 解包 Promise
type NonNullable<T>        // 去 null/undefined

业务里最常用的是 Pick / Omit / Partial,比如:

type UserVO = { id: number; name: string; age: number; password: string }
type UserCreateDTO = Omit<UserVO, 'id'>
type UserUpdateDTO = Partial<Omit<UserVO, 'id'>> & { id: number }
type UserListItem  = Pick<UserVO, 'id' | 'name'>

26.11 严格 tsconfig 推荐

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "exactOptionalPropertyTypes": true,
    "verbatimModuleSyntax": true
  }
}

noUncheckedIndexedAccess 会让 arr[0] 推成 T | undefined,避免运行期 undefined.xxx 崩溃,强推

26.12 业务里实用 Pattern

API 返回 + Hook 强类型

type ApiRes<T> = { code: number; data: T; message: string }

async function request<T>(url: string): Promise<T> {
  const res = await fetch(url)
  const json: ApiRes<T> = await res.json()
  if (json.code !== 0) throw new Error(json.message)
  return json.data
}

// 使用
const users = await request<User[]>('/api/users')

全局 Store 类型推导(Zustand)

import { create } from 'zustand'

interface UserStore {
  user: User | null
  setUser: (u: User | null) => void
}

export const useUserStore = create<UserStore>((set) => ({
  user: null,
  setUser: (u) => set({ user: u }),
}))

27. 进阶部分的「下一步」建议

读完进阶部分后,建议你按需深入:

想做的事 看什么
写 C 端 / SEO 页面 Next.js 官方 tutorial + RSC 文档
优化业务后台卡顿 React DevTools Profiler + react-virtual
团队上 TS 规范 typescript-eslint strict 规则
提升交付质量 Playwright + msw 起一套 E2E
跟进新特性 关注 React Compiler、useOptimisticuseFormStatususeActionState

更多推荐