Learn React
若已熟悉 Vue 3,学习 React 会很快。Vue 3 的 Composition API(组合式 API) 在很多设计思想上都受到了 React Hooks 的启发。不过,虽然「长得像」,但两者的底层逻辑有着本质的区别。本文会分阶段系统性地切入 React 的世界。
一、核心差异:从「自动驾驶」切换到「手动挡」
Vue 3 最大的特点是响应式(Reactivity)。当修改一个 ref 时,Vue 会自动追踪依赖,只更新需要变动的部分。
而 React 遵循的是单向数据流和不可变性(Immutability)。在 React 中,当状态改变时,整个函数组件会重新执行一遍。
| 特性 | Vue 3 (Composition API) | React (Hooks) |
| 逻辑组织 | setup 函数(仅执行一次) | 函数组件本身(渲染一次执行一次) |
| 响应式原理 | 基于 Proxy 的数据劫持 | 基于快照(Snapshot)的状态更新 |
| UI 表达 | 模板语法 (template) | JSX(在 JS 中写 HTML) |
二、JSX 与组件
在 Vue 里,HTML、JS、CSS 泾渭分明。在 React 里,一切皆 JS。
- 没有指令:没有
v-if,用 JS 的&&或三元运算符;没有v-for,用数组的.map()。 - Props 是只读的:Vue 里可用
v-model双向绑定,React 里必须手动传递value和onChange回调。
对比示例
在 Vue 3 中可能会这样写一个列表:
<template> <div v-if="showList"> <li v-for="item in items" :key="item.id">{{ item.name }}</li> </div></template>在 React 中,等价写法如下:
function MyComponent({ items, showList }) { return ( <div> {showList && items.map(item => ( <li key={item.id}>{item.name}</li> ))} </div> );}三、状态与渲染:快照与批处理
3.1 从 useState 开始
在 Vue 3 中常用 ref(0) 创建计数器;在 React 中则使用 useState。
思考: 若在 React 中直接写 let count = 0; 并在点击事件里执行 count++,页面会发生变化吗?为什么?(提示:函数组件每次更新都会重新执行。)
在 Vue 3 中,组件的 setup 里的逻辑通常只在挂载时运行一次,剩下的更新交给响应式系统。但在 React 中,渲染(Render)本质上就是调用一次函数。
3.2 核心概念:快照(Snapshot)
可以把 React 的每一次渲染想象成一张照片:
- 触发更新:当状态(State)改变时。
- 重新执行函数:React 会再次运行该组件函数。
- 生成新照片:函数根据当前的状态,返回一套新的 JSX。
- 对比与提交:React 把「新照片」和「旧照片」对比(Diffing),只把变化的部分更新到真实 DOM。
3.3 关键区别:闭包与「旧值」
function Counter() { const [count, setCount] = useState(0);
const handleClick = () => { setCount(count + 1); console.log(count); // 打印的是点击之前的值 };
return <button onClick={handleClick}>{count}</button>;}在 React 中,handleClick 里的 console.log(count) 会打印点击之前的值。因为当前的 count 被「锁定」在这次渲染的快照里。setCount 并不是直接修改当前变量,而是告诉 React:用 count + 1 作为新状态,再运行一遍这个函数。
3.4 批处理(Batching)
在 Vue 中,修改 ref.value 时 Proxy 会立即拦截并准备触发更新。而在 React 中,setCount 是向 React 提交一个「更新申请」。React 会等当前事件处理函数中的所有代码执行完后,再统一进行一次重新渲染。
3.5 连续调用的陷阱
const [count, setCount] = useState(0);
const handleMultipleAdd = () => { setCount(count + 1); setCount(count + 1); setCount(count + 1);};点击后 count 会变成 1,而不是 3。因为三次调用时函数还未重新执行,count 始终是 0,React 最终只应用最后一次「改成 1」的请求。
解决:函数式更新
const handleMultipleAdd = () => { setCount(prev => prev + 1); setCount(prev => prev + 1); setCount(prev => prev + 1);};这样点击后 count 会正确变为 3。
3.6 Commit 阶段
当 React 算出所有状态变更后,进入 Commit(提交)阶段:对比新旧虚拟 DOM,只修改真实 DOM 中变化的部分(例如只改 textContent)。
因此虽然「函数重新执行」发生在 JS 层(Virtual DOM),真实 DOM 操作被控制在最小范围内,性能依然可以很好。
四、虚拟 DOM 与 Diffing
在 Vue 中,响应式系统能精确知道哪个属性变了;React 则生成完整的虚拟树,通过对比找出差异。
4.1 什么是虚拟 DOM?
虚拟 DOM 就是一个普通的 JavaScript 对象,用来描述真实 DOM 的结构。例如:
<div className="active">Hello</div>会转换成类似:
{ type: 'div', props: { className: 'active', children: 'Hello' }}操作 JS 对象比操作真实 DOM 快几个数量级。
4.2 Diffing 的两个假设
- 同层比较:只比较同一层级的节点。若
<div>变成<section>,React 会直接丢弃旧分支并重新创建。 - Key 的重要性:在列表中用
key标识稳定元素。
4.3 为什么 key 不能用 index?
列表 [A, B, C] 在头部插入 D 时:
- 用 index (0,1,2,3):React 会认为 index 0 从 A 变成了 D,导致不必要的更新甚至输入框错位。
- 用唯一 ID:React 能识别 A、B、C 只是移动,只有 D 是新增,只做一次插入和移动。
4.4 性能优化「手动挡」
- React.memo:Props 没变则不重新渲染子组件。
- useMemo / useCallback:缓存计算结果或函数引用,避免因父组件重绘导致不必要的子组件更新。
小结:Vue 依赖追踪、精确更新;React 快照对比、虚拟 DOM Diff,需在关键处手动优化。
五、useEffect:副作用与生命周期
在 Vue 3 中,生命周期(onMounted)和数据监听(watch)是分开的。在 React 中,它们统一由 useEffect 处理。
5.1 语法
useEffect(() => { // 业务逻辑}, [dep1, dep2]);- 第一个参数:要执行的逻辑。
- 第二个参数:依赖数组,决定何时重新执行。
5.2 对应 Vue 的思维
| Vue | React |
| `onMounted(() => { ... })` | 依赖数组为 `[]` |
| `watch(count, () => { ... })` | 依赖数组为 `[count]` |
| 清理逻辑 | 在 effect 中 `return () => { ... }` |
模拟 onMounted:
useEffect(() => { console.log('只在挂载时运行一次');}, []);模拟 watch:
useEffect(() => { console.log('当 count 变化时运行');}, [count]);模拟 onUnmounted(清理):
useEffect(() => { const timer = setInterval(() => {}, 1000); return () => { clearInterval(timer); };}, []);5.3 闭包陷阱
useEffect 的回调会「捕捉」某次渲染时的变量。例如:
const [count, setCount] = useState(0);
useEffect(() => { const timer = setInterval(() => { console.log(count); // 永远是 0 }, 1000); return () => clearInterval(timer);}, []);若要在定时器里用最新 count:要么把 count 放进依赖 [count](会频繁重置定时器),要么在定时器里用函数式更新 setCount(c => c + 1)(推荐)。
5.4 数据请求与竞态(AbortController)
当 userId 变化时请求用户数据,且要在 userId 再次变化时取消旧请求:
useEffect(() => { const controller = new AbortController(); const signal = controller.signal;
fetchUserData(userId, { signal }) .then(data => console.log('获取成功:', data)) .catch(err => { if (err.name === 'AbortError') { console.log('请求被取消'); } else { // 处理真正错误 } });
return () => controller.abort();}, [userId]);理解「订阅与清理」的对称性,是掌握 React 副作用的关键。
六、自定义 Hooks(逻辑复用)
在 Vue 3 中会写 Composables;在 React 中对应 Custom Hooks,目标一致:逻辑抽离与复用。
自定义 Hook 就是以 use 开头的函数,内部可以调用其他 React Hooks。
6.1 useWindowSize 示例
import { useState, useEffect } from 'react';
function useWindowSize() { const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight, });
useEffect(() => { const handleResize = () => { setSize({ width: window.innerWidth, height: window.innerHeight, }); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []);
return size;}
// 使用function MyComponent() { const { width } = useWindowSize(); return <div>当前窗口宽度: {width}px</div>;}6.2 Hooks 两条规则
- 只在最顶层调用 Hooks:不要在循环、条件或嵌套函数里调用,否则调用顺序错乱,状态会错位。
- 只在 React 函数中调用:在函数组件或自定义 Hook 里用。
6.3 useFetch 封装
异步请求必须放在 useEffect 里,否则每次渲染都会发请求,可能死循环。正确形态示例:
export function useFetch(apiConfig) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null);
useEffect(() => { const fetchData = async () => { setLoading(true); try { const res = await axios({ url: apiConfig.url, method: apiConfig.method || 'get', ...apiConfig.options, }); setData(res.data); } catch (e) { setError(e); } finally { setLoading(false); } }; fetchData(); }, [apiConfig.url]);
return { data, loading, error };}注意:若在组件里写 useFetch({ url: '/api/user' }),每次渲染都会生成新对象引用,导致依赖变化、重复请求。应把配置提到组件外或用 useMemo 稳定引用。
七、Context:跨组件通信
在 Vue 3 中有 provide / inject;在 React 中对应 Context API,用于避免 Props Drilling。
7.1 三个角色
- Context 对象:
React.createContext() - Provider:在父组件外层提供数据
- useContext:子组件消费数据
7.2 主题切换示例
// 创建 Contextexport const ThemeContext = createContext();
export function ThemeProvider({ children }) { const [theme, setTheme] = useState('light'); const toggleTheme = () => setTheme(prev => (prev === 'light' ? 'dark' : 'light')); return ( <ThemeContext.Provider value={{ theme, toggleTheme }}> {children} </ThemeContext.Provider> );}
// 顶层包裹function App() { return ( <ThemeProvider> <Navbar /> <MainContent /> </ThemeProvider> );}
// 任意子组件消费function Button() { const { theme, toggleTheme } = useContext(ThemeContext); return ( <button onClick={toggleTheme} style={{ background: theme === 'light' ? '#fff' : '#333' }}> 切换模式 </button> );}7.3 注意「穿透更新」
React 中,一旦 Provider 的 value 变化,所有使用 useContext(ThemeContext) 的组件都会重新渲染。建议按业务拆成多个小 Context(如 UserContext、CartContext),而不是一个巨型 Context。
八、性能优化:Memoization
父组件一变,子组件默认全重绘,需要「手动挡」优化。
- React.memo:仅当子组件 Props 变化时才重渲染。
- useCallback:缓存函数引用,避免传给 memo 子组件的回调每次都是新的。
- useMemo:缓存计算结果(类似 Vue 的
computed),依赖不变就不重算。
原则:不要滥用。只有组件确实昂贵、或需要稳定引用(配合 memo)时才用。
8.1 React.memo
const UserItem = React.memo(({ user }) => { console.log('子组件渲染:', user.name); return <li>{user.name}</li>;});8.2 useCallback
// 每次渲染 handleClick 都是新引用,会导致 memo 失效const handleClick = useCallback(() => { console.log('点击了');}, []);8.3 useMemo
const expensiveValue = useMemo(() => { return performanceHeavyTask(data);}, [data]);8.4 实战:列表 + 删除
const UserList = React.memo(({ users, onDelete }) => { return ( <ul> {users.map(u => ( <li key={u.id}> {u.login} <button onClick={() => onDelete(u.id)}>删除</button> </li> ))} </ul> );});
export default function App() { const [count, setCount] = useState(0); const [users, setUsers] = useState([...]);
const handleDelete = useCallback((id) => { setUsers(prev => prev.filter(user => user.id !== id)); }, []);
return ( <div> <button onClick={() => setCount(c => c + 1)}>增加计数</button> <UserList users={users} onDelete={handleDelete} /> </div> );}何时不用:子组件是简单 HTML 或未用 memo 时,不必强行 useCallback/useMemo,否则反而增加心智负担和 Hooks 开销。
九、状态管理进阶:Zustand
当应用变大,useContext 容易变成嵌套地狱,且一改全量更新。Zustand 类似 Pinia:无模板代码、不依赖 Context、支持按状态切片订阅。
9.1 定义 Store
import { create } from 'zustand';
const useUserStore = create((set) => ({ users: [], loading: false, setUsers: (newUsers) => set({ users: newUsers }), fetchUsers: async (query) => { set({ loading: true }); try { const res = await fetch(`https://api.github.com/search/users?q=${query}`); const data = await res.json(); set({ users: data.items || [], loading: false }); } catch (e) { set({ loading: false }); } },}));9.2 在组件中按需订阅
function UserList() { const users = useUserStore(state => state.users); const loading = useUserStore(state => state.loading); if (loading) return <div>加载中...</div>; return ( <ul> {users.map(user => <li key={user.id}>{user.login}</li>)} </ul> );}只有 users 或 loading 真正变化时,对应使用到它们的组件才会重绘。
9.3 选择器(Selector)与不可变性
useUserStore(state => state.users)表示「只关心users」。Zustand 用引用相等判断是否变化。- React 约定不可变更新:改状态要替换对象/数组(如
set({ users: [...state.users, newUser] })),不能state.users.push(newUser)再set({ users: state.users }),否则引用不变,React 不会更新。
错误写法:
set(state => { state.users.push(newUser); return { users: state.users }; // 引用未变,可能不触发渲染});正确写法:
set(state => ({ users: [...state.users, newUser],}));9.4 持久化(persist)
import { persist } from 'zustand/middleware';
const useAuthStore = create( persist( (set) => ({ isLoggedIn: false, login: () => set({ isLoggedIn: true }), }), { name: 'auth-storage' } ));9.5 多字段订阅(shallow)
import { shallow } from 'zustand/shallow';
const { users, loading } = useUserStore( (state) => ({ users: state.users, loading: state.loading }), shallow);十、服务端状态:TanStack Query
React 不负责异步数据,只负责 UI。数据可以这样区分:
- Client State:主题、表单等 → Zustand
- Server State:接口数据 → TanStack Query
TanStack Query 通过 queryKey 做缓存:key 不变则用缓存;key 变则重新请求。
10.1 配置 Provider
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
function App() { return ( <QueryClientProvider client={queryClient}> <SearchPage /> </QueryClientProvider> );}10.2 useQuery 替代手写 useFetch
const { data, isLoading, error } = useQuery({ queryKey: ['githubUsers', searchTerm], queryFn: async () => { if (!searchTerm) return []; const res = await axios.get(`https://api.github.com/search/users?q=${searchTerm}`); return res.data.items; }, enabled: !!searchTerm, staleTime: 1000 * 60 * 5, // 5 分钟内视为新鲜});优势:Stale-While-Revalidate(先展示缓存再后台更新)、自动重试、预取等。建议配合 DevTools 观察每个 queryKey 的状态。
十一、路由与样式:React Router 与 Tailwind
11.1 React Router 6.x:Loader 数据预加载
在进入路由前就拉取数据,避免「先进页面再请求」的白屏:
const router = createBrowserRouter([ { path: '/user/:id', element: <UserPage />, loader: async ({ params }) => fetch(`/api/user/${params.id}`), },]);
function UserPage() { const userData = useLoaderData(); return <div>{userData.name}</div>;}11.2 样式:Tailwind CSS
React 没有 <style scoped>,样式易冲突。Tailwind 通过原子类在 JSX 中写样式,无需起类名、体积可控:
function Card({ active }) { return ( <div className={`p-4 rounded-lg ${active ? 'bg-blue-500' : 'bg-gray-100'}`}> <h2 className="text-xl font-bold text-white">进阶之路</h2> </div> );}十二、企业级架构与实战建议
12.1 推荐目录结构
src/├── api/ # TanStack Query 相关├── components/ # 复用 UI 组件(可配合 Tailwind)├── hooks/ # 自定义 Hooks├── store/ # Zustand├── routes/ # React Router 配置└── views/ # 页面级组件12.2 实战目标建议
做一个带权限的后台管理系统,覆盖:
- 鉴权:登录状态用 Zustand + persist 持久化。
- 列表:TanStack Query 处理分页、搜索与缓存。
- 样式:Tailwind 搭响应式界面。
- 性能:列表页用 React.memo、useCallback 优化删除/编辑等回调。
12.3 进阶可学
- useReducer:多状态、强关联时统一更新逻辑。
- HOC / Render Props:权限封装、弹窗等场景仍常见。
- useTransition / useDeferredValue:标记非紧急更新,减轻卡顿。
总结:React 技能树总览
| 层次 | 内容 |
| 核心原理 | 虚拟 DOM、Diffing、快照与批处理 |
| Hooks | useState、useEffect、useContext、自定义 Hooks |
| 性能 | memo、useCallback、useMemo、引用与不可变性 |
| 状态 | Zustand(本地)+ TanStack Query(服务端) |
| 工程化 | React Router(含 Loader)+ Tailwind CSS |
这套 Zustand + TanStack Query 组合在国内大厂 React 项目中非常常见。按本文顺序把「快照思维 → Hooks → 性能 → 状态与数据请求 → 路由与样式」过一遍,即可建立起完整的 React 实战视野。