React面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

前端阅读 895月28日 03:16

什么是 Virtual DOM?React 为什么用它替代直接操作 DOM?

Virtual DOM 是一棵用 JavaScript 对象描述的 DOM 树。React 状态变化时,先在内存里生成新的虚拟 DOM 树,和旧树做 diff,算出最小变更集,再批量更新真实 DOM。这么做的核心原因是——直接操作真实 DOM 太贵了,一次 appendChild 可能触发重排+重绘+合成三层渲染管线。Virtual DOM 把"手动精确定位变更"这件事自动化了:你只管声明 UI 长什么样,React 负责高效地同步到真实 DOM。顺带一提,正因为 UI 描述和渲染层解耦,React Native 才能复用同一套组件模型渲染原生控件。追问Virtual DOM 一定比直接操作 DOM 快吗?不是。单改一个文本节点,el.textContent = 'xxx' 比 Virtual DOM diff 整棵子树更快。Virtual DOM 的价值在复杂 UI 场景:几十个组件同时更新时,它能自动算出最优更新路径,避免开发者手动 diff。所以它不是"最快",而是"在绝大多数场景下足够快,且不需要你操心"。React 的 diff 算法怎么做到 O(n) 的?朴素的树 diff 是 O(n³),React 用三个策略降到 O(n):| 策略 | 含义 ||------|------|| 类型不同直接重建 | <div> 变成 <span>,整个子树丢弃重建,不跨类型 diff || key 标识稳定性 | 通过 key 追踪同一组子元素中的身份,避免错位复用 || 只比较同级 | 不跨层级比较,父亲和儿子不会互相匹配 |代价是可能遗漏极少数跨层级移动的最优解,但实际场景中跨层级移动极少,这个权衡是值得的。Vue 的 Virtual DOM 和 React 有什么区别?Vue 的响应式系统精确追踪了"哪个组件依赖了哪个数据",数据变了可以直接跳过无关组件的 diff。React 默认从触发更新的组件开始整棵子树 diff,需要 React.memo、useMemo 手动优化。但 React 18 并发模式可以在调度层面拆分任务、让高优先级更新插队,这是 Vue 目前没有的。实际项目里 Virtual DOM 有什么常见的坑?index 做 key:列表增删元素时 diff 错位,导致非预期复用和状态错乱。用唯一 ID 做 key。大列表没有虚拟化:Virtual DOM 只解决 diff 效率,不解决渲染量。几千行的长列表必须上 react-window / react-virtuoso 做虚拟滚动。不必要的重渲染:父组件 state 变了,即使子组件 props 没变也会 diff。React.memo 和 useMemo 不是过早优化,是 React 开发的日常操作。
服务端阅读 05月28日 02:51

useCallback 和 useMemo 有什么区别?什么场景下使用?

面试官问:"useCallback 和 useMemo 有什么区别?什么场景下使用?"——这道题几乎出现在每一场 React 岗位的面试中。答案其实不复杂,但很多人答完区别就卡在使用场景上,要么说"都用上总没错",要么完全不知道什么时候该用。核心区别:一句话记住useCallback 缓存函数引用,useMemo 缓存计算结果。// 这两行等价const fn = useCallback(() => doSomething(a), [a]);const fn = useMemo(() => () => doSomething(a), [a]);useCallback(fn, deps) 本质上就是 useMemo(() => fn, deps) 的语法糖。记住这个等式,很多困惑会自动消散。| 特征 | useCallback | useMemo ||------|-------------|---------|| 返回值 | 函数本身 | 函数的执行结果 || 缓存对象 | 函数引用 | 任意值(对象、数组、基本类型) || 典型场景 | 传给子组件的回调 | 昂贵计算 / 保持引用稳定 || 一句话 | "别重新创建这个函数" | "别重新算这个值" |为什么需要它们:React 重渲染机制React 函数组件每次渲染都会重新执行整个函数体。你在组件里写的每一行代码——定义变量、创建函数、计算表达式——每次渲染都重新跑一遍:function MyComponent({ items }) { const handleClick = () => console.log("clicked"); // 每次渲染都创建新函数 const filtered = items.filter(item => item.active); // 每次渲染都重新过滤 return <Child onClick={handleClick} data={filtered} />;}如果 items 有 10000 条,而组件每秒渲染 60 次,你就在每秒过滤 60 万次。更麻烦的是,handleClick 每次都是新的引用——如果 Child 用了 React.memo,它期待的"不变引用"就白费了。useCallback 和 useMemo 的作用就是告诉 React:"如果依赖没变,把上次的结果还给我。"useCallback:保持函数引用稳定配合 React.memo 阻止子组件无效渲染这是 useCallback 最核心的使用场景:function Parent({ items }) { // ❌ 每次渲染都创建新函数,Child 的 React.memo 白费了 const handleClick = () => { console.log("clicked"); }; // ✅ 函数引用稳定,Child 不会因它而重渲染 const handleClick = useCallback(() => { console.log("clicked"); }, []); return <Child onClick={handleClick} items={items} />;}const Child = React.memo(({ onClick, items }) => { console.log("Child render"); return <button onClick={onClick}>Click</button>;});关键点:useCallback 单独用效果有限,必须配合 React.memo 才能阻止子组件重渲染。如果子组件没有 React.memo 包裹,父组件渲染它就会渲染——useCallback 改变不了这一点。作为 useEffect 的稳定依赖function UserProfile({ userId }) { // ❌ fetchUser 每次都是新引用,useEffect 每次都会执行 const fetchUser = () => { api.getUser(userId); }; // ✅ 只在 userId 变化时重新创建 const fetchUser = useCallback(() => { api.getUser(userId); }, [userId]); useEffect(() => { fetchUser(); }, [fetchUser]);}不过这里有个陷阱:用 useCallback 缓存函数再放进 useEffect 依赖,实际上你是间接依赖了 callback 的真实依赖。对于上面的例子,直接在 useEffect 里写请求逻辑、直接依赖 userId 更直接。这个模式主要用在自定义 Hook 中,函数需要暴露给外部使用:function useUserData(userId) { const fetchUser = useCallback(() => { return api.getUser(userId); }, [userId]); return { fetchUser }; // 调用方拿到的引用是稳定的}自定义 Hook 中的 useCallback这是面试中容易忽略的场景。当你写一个自定义 Hook 返回方法时,如果不加 useCallback,消费方每次拿到的都是新函数,它的 useEffect 会被反复触发:function useTable(pagination) { const refresh = useCallback(() => { fetchData(pagination); }, [pagination]); const reset = useCallback(() => { setFilters({}); setPagination({ page: 1 }); }, []); return { refresh, reset }; // 消费方可以安全地放入依赖数组}useMemo:缓存计算结果避免重复的数组操作function ProductList({ products, filter }) { // ❌ 每次渲染都重新过滤 const filteredProducts = products.filter(p => p.name.includes(filter)); // ✅ 只在 products 或 filter 变化时重新计算 const filteredProducts = useMemo(() => products.filter(p => p.name.includes(filter)), [products, filter] ); return <ul>{filteredProducts.map(p => <li key={p.id}>{p.name}</li>)}</ul>;}复杂计算(排序、聚合、数据转换)function DataTable({ data }) { const sortedData = useMemo(() => { return [...data].sort((a, b) => a.score - b.score); }, [data]); const chartData = useMemo(() => { return data.reduce((acc, item) => { const key = item.category; if (!acc[key]) acc[key] = { total: 0, count: 0 }; acc[key].total += item.value; acc[key].count += 1; return acc; }, {}); }, [data]); return <Chart data={chartData} />;}保持对象和数组的引用稳定这个用法很容易被忽略,但在性能优化中很重要:function Parent({ items }) { // ❌ 每次渲染创建新对象,子组件 React.memo 失效 const style = { color: "red" }; const config = { threshold: 0.5, rootMargin: "0px" }; // ✅ 对象引用稳定 const style = useMemo(() => ({ color: "red" }), []); const config = useMemo(() => ({ threshold: 0.5, rootMargin: "0px" }), []); return <Child style={style} observerConfig={config} />;}useCallback 缓存函数,useMemo 缓存值——这是一个重要的互补关系。当你需要传一个稳定引用的对象或数组给子组件时,用 useMemo。useMemo 和 useRef 的区别面试中经常追加这个问题。两者都能"记住"上一次的值,但机制完全不同:| 特征 | useMemo | useRef ||------|---------|--------|| 触发重渲染 | 依赖变化时返回新值,组件正常重渲染 | 修改 .current 不触发重渲染 || 用途 | 缓存计算结果 | 持久化可变值(DOM 引用、前一次渲染的值等) || 依赖追踪 | 有依赖数组,自动更新 | 无依赖,手动管理 |// useMemo:依赖变了才重算,结果参与渲染const sorted = useMemo(() => data.sort(), [data]);// useRef:记住上一次的值,但不触发重渲染const prevCount = useRef(count);useEffect(() => { prevCount.current = count;}, [count]);如果你只是想跨渲染记住一个值但不影响渲染输出,用 useRef;如果你需要基于依赖缓存计算结果参与渲染,用 useMemo。组合使用:性能优化三层架构在一个复杂的列表组件中,三个 Hook 经常协同工作:function SearchResults({ query, data }) { // 第一层:缓存数据计算结果 const results = useMemo(() => { return data.filter(item => item.name.toLowerCase().includes(query.toLowerCase()) ); }, [data, query]); // 第二层:缓存事件处理函数 const handleItemClick = useCallback((id) => { console.log("Selected:", id); }, []); // 第三层:缓存传递给子组件的 props 对象 const listProps = useMemo(() => ({ items: results, onItemClick: handleItemClick }), [results, handleItemClick]); return <ResultList {...listProps} />;}这种"数据 → 函数 → props 对象"的三层缓存,是 React 性能优化的标准范式。但记住,这是在已经发现性能问题之后的优化手段,不是写代码时的默认操作。用 DevTools 验证优化效果说了这么多"什么时候该用",那怎么判断你写的 useCallback / useMemo 真的有用?靠猜是不行的,用 React DevTools Profiler:打开 Chrome DevTools → Profiler 标签点击录制按钮,操作你的组件停止录制,查看火焰图找到不必要的重渲染(灰色条表示"没变但重渲染了")针对性地加 useCallback / useMemo,再录一次对比如果你加了缓存但 Profiler 没有变化,说明这个缓存是多余的——移除它。优化从来不是越多越好。三个最常见的坑坑一:过度使用——简单运算不需要缓存// ❌ 两个数相加也要 useMemo?缓存的成本比计算还大const total = useMemo(() => a + b, [a, b]);// ❌ 简单的字符串拼接也要 useCallback?const label = useCallback(() => `${firstName} ${lastName}`, [firstName, lastName]);// ✅ 直接写const total = a + b;const label = `${firstName} ${lastName}`;经验法则:计算操作耗时 < 1ms 的,不需要 useMemo。只有循环遍历大数组、递归、复杂对象转换才值得。useCallback 同理——如果子组件没被 React.memo 包裹,你加不加 useCallback 效果一样。坑二:闭包陷阱——遗漏依赖// ❌ multiplier 在闭包里但不在依赖数组const multiplier = 2;const result = useMemo(() => value * multiplier, [value]);// ✅ 所有引用的外部变量都要声明const result = useMemo(() => value * multiplier, [value, multiplier]);务必开启 eslint-plugin-react-hooks 的 exhaustive-deps 规则,让 ESLint 帮你检查。闭包陷阱不是"偶尔遇到"的问题,是"迟早遇到"的问题。坑三:useCallback 配合了没 memo 的子组件// ❌ 子组件没有 React.memo,useCallback 基本白用function Parent() { const handleClick = useCallback(() => {}, []); return <PlainChild onClick={handleClick} />;}没有 React.memo 的子组件,父组件渲染它就会渲染——useCallback 改变不了这一点。这是一个非常常见的"写了等于没写"的场景。React 19 Compiler 会取代它们吗?React 19 引入了 React Compiler(实验性),可以自动为代码插入等效的 useMemo 和 useCallback。如果你的项目已经启用了 Compiler,手动写这些 Hook 的需求会大幅减少。但目前绝大多数项目(React 16-18)仍然需要手动优化。而且即使有了 Compiler,理解 useCallback 和 useMemo 的原理,能帮你在遇到性能问题时快速定位根因——Compiler 不是万能的,它也会犯错,这时候你需要知道底层的运作方式来判断是 Compiler 的 bug 还是你代码的问题。面试速答模板面试中被问到这道题,建议这样组织答案:区别:useCallback 缓存函数引用,useMemo 缓存计算结果。useCallback(fn, deps) 等价于 useMemo(() => fn, deps),是它的语法糖。场景:useCallback 主要配合 React.memo 阻止子组件无效渲染,或作为自定义 Hook 的稳定返回值;useMemo 用于昂贵计算和保持对象/数组引用稳定。注意点:两者都不是"越多越好"。没有 React.memo 的子组件加 useCallback 无效;简单计算加 useMemo 反而更慢;务必开启 exhaustive-deps 规则避免闭包陷阱。追问准备:React 19 Compiler 可以自动处理大部分缓存需求,但理解原理对定位问题仍然必要。useRef 也能"记住"值但不触发重渲染,和 useMemo 的触发机制不同。总结| 你需要 | 用什么 | 关键搭档 ||--------|--------|----------|| 缓存函数给子组件 | useCallback | React.memo || 缓存计算结果 | useMemo | — || 缓存对象/数组引用 | useMemo | React.memo || 防止 useEffect 不必要触发 | useCallback / useMemo | 依赖数组 |记住三点:先写对,再优化。等 React DevTools Profiler 告诉你哪里慢了再动手,不要预先给所有东西加缓存useCallback 是 useMemo 的特例,缓存的是函数引用,不是计算结果没有 React.memo 的子组件,useCallback 基本是自我安慰
服务端阅读 05月28日 02:37

如何创建和使用 Zustand store?

核心答案Zustand 通过 create 函数创建 store,返回一个可直接在组件中使用的 Hook。与 Redux 不同,它不需要 Provider 包裹,store 本身就是 Hook:import { create } from 'zustand'const useStore = create((set, get) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), reset: () => set({ count: 0 }),}))组件中使用时,推荐通过选择器订阅,避免不必要的重渲染:const count = useStore((state) => state.count) // 只订阅 countconst increment = useStore((state) => state.increment) // 只订阅 incrementset 与 get 的用法set 用于更新状态,支持对象和函数两种形式。Zustand 自动浅合并第一层属性,所以不需要手动展开 ...state:const useStore = create((set) => ({ user: { name: 'Tom', age: 20 }, // 对象形式:直接替换第一层属性 setName: (name) => set({ user: { name, age: 20 } }), // 注意:第二层需手动处理 // 函数形式:基于旧状态计算 incrementAge: () => set((state) => ({ user: { ...state.user, age: state.user.age + 1 } })),}))get 用于在 action 中读取当前状态,不触发订阅:const useStore = create((set, get) => ({ items: [], addItem: (item) => set({ items: [...get().items, item] }), getCount: () => get().items.length, // 不触发重渲染}))选择性订阅与性能优化直接解构整个 store 会导致任何状态变化都触发重渲染,应避免:// 不推荐:任何状态变化都触发重渲染const { count, name } = useStore()// 推荐:按需订阅const count = useStore((s) => s.count)const name = useStore((s) => s.name)对于复杂对象,使用 shallow 比较避免引用变化导致的重渲染:import { shallow } from 'zustand/shallow'const { name, age } = useStore( (s) => ({ name: s.user.name, age: s.user.age }), shallow)Store 拆分(Slice 模式)大型应用中,将不同领域的状态拆成独立 slice,再合并到一个 store:// slices/cartSlice.jsexport const createCartSlice = (set) => ({ items: [], addItem: (item) => set((s) => ({ items: [...s.items, item] })), clearCart: () => set({ items: [] }),})// slices/userSlice.jsexport const createUserSlice = (set) => ({ user: null, setUser: (user) => set({ user }),})// store.jsimport { create } from 'zustand'import { createCartSlice } from './slices/cartSlice'import { createUserSlice } from './slices/userSlice'const useStore = create((...a) => ({ ...createCartSlice(...a), ...createUserSlice(...a),}))异步操作Zustand 的 action 可以直接是 async 函数,不需要额外的中间件:const useStore = create((set) => ({ data: null, loading: false, error: null, fetchData: async (id) => { set({ loading: true, error: null }) try { const res = await fetch(`/api/data/${id}`) const data = await res.json() set({ data, loading: false }) } catch (error) { set({ error: error.message, loading: false }) } },}))常用中间件persist — 持久化到 localStorageimport { create } from 'zustand'import { persist } from 'zustand/middleware'const useStore = create( persist( (set) => ({ theme: 'light', setTheme: (theme) => set({ theme }), }), { name: 'theme-storage' } // localStorage key ))immer — 不可变更新的简化写法import { create } from 'zustand'import { immer } from 'zustand/middleware/immer'const useStore = create( immer((set) => ({ user: { name: 'Tom', address: { city: 'Beijing' } }, setCity: (city) => set((state) => { state.user.address.city = city }), // 无需手动展开,直接修改 draft })))devtools — Redux DevTools 调试支持import { create } from 'zustand'import { devtools } from 'zustand/middleware'const useStore = create( devtools((set) => ({ count: 0, increment: () => set((s) => ({ count: s.count + 1 })), }), { name: 'CounterStore' }))中间件可以组合使用,顺序从外到内:devtools(persist(immer(...)))。create 与 createStore 的区别| | create | createStore ||---|---|---|| 返回值 | React Hook | Store 对象 || 使用场景 | React 组件内 | React 外(测试、服务端、非React环境) || 订阅方式 | useStore(s => s.xxx) | store.subscribe() / store.getState() |import { createStore } from 'zustand'const store = createStore((set) => ({ count: 0, increment: () => set((s) => ({ count: s.count + 1 })),}))// React 外部使用store.getState().count // 读取store.setState({ count: 10 }) // 更新store.subscribe((state) => { // 监听 console.log('state changed', state)})追问:Zustand 与 Redux 的核心区别是什么?无需 Provider:Zustand 不需要 <Provider> 包裹组件树,直接导入 Hook 使用订阅粒度:Zustand 通过选择器精确订阅,Redux 用 useSelector 实现类似效果但机制不同样板代码:Zustand 无 action type、reducer、dispatch,一个函数搞定Bundle 体积:Zustand ~1KB vs Redux Toolkit ~11KB中间件生态:Redux 有更成熟的中间件链,Zustand 的中间件更轻量但够用
服务端阅读 05月28日 02:10

什么是 React Query,它解决了前端开发中的哪些常见问题?

React Query(现名 TanStack Query)是一个专门管理服务器状态的异步状态管理库。它解决的核心问题是:把原本散落在 useEffect + useState + 全局状态库中的数据获取逻辑,收敛到声明式的 Hook 调用中。它解决的关键问题1. 手动管理请求状态的样板代码没有 React Query 时,每个请求都要写 loading、error、data 三件套:// 传统写法const [data, setData] = useState(null);const [loading, setLoading] = useState(true);const [error, setError] = useState(null);useEffect(() => { fetch("/api/todos") .then(res => res.json()) .then(setData) .catch(setError) .finally(() => setLoading(false));}, []);用 React Query 一行替代:const { data, isLoading, isError } = useQuery({ queryKey: ["todos"], queryFn: () => fetch("/api/todos").then(res => res.json()),});2. 缓存与重复请求React Query 基于 queryKey 自动缓存响应数据。相同 key 的多个组件只发一次请求,后续直接读缓存。通过 staleTime 和 gcTime 控制数据新鲜度和缓存回收。3. 数据同步与过期刷新内置 stale-while-revalidate 策略:先展示缓存数据,后台静默刷新。窗口重新聚焦、网络恢复时自动重新请求,无需手动监听事件。4. 竞态条件当快速切换页面或筛选条件时,早期请求的响应可能覆盖最新数据。React Query 自动取消过期请求,确保结果与当前 queryKey 匹配。5. 服务端状态与客户端状态混用Redux 管客户端 UI 状态没问题,但把服务端数据也塞进去会导致:手动写 loading 状态、手动处理缓存失效、手动同步更新。React Query 把服务端状态单独抽出来,让 Redux 只管 UI 状态。追问staleTime 和 gcTime 的区别是什么?staleTime 控制数据何时标记为过期(触发后台刷新),gcTime 控制缓存多久后被垃圾回收(彻底删除)。useMutation 如何配合 queryClient.invalidateQueries 实现乐观更新?先通过 onMutate 回调更新缓存,失败时在 onError 中回滚。React Query v5 相比 v4 有哪些破坏性变更?移除了 onError/onSuccess 全局回调,统一用 queryClient.getQueryData 替代。为什么说 React Query 是异步状态管理器而非数据请求库?它不关心请求怎么发(fetch/axios/GraphQL 都行),只管 Promise 的状态流转和缓存策略。
服务端阅读 05月28日 01:18

Zustand 与 Redux 相比有哪些优缺点?

Zustand 的核心优势极简 API,告别样板代码Zustand 创建 store 只需一个函数调用,无需定义 action types、reducers、action creators。与 Redux Toolkit 的 createSlice 相比,代码量减少 60% 以上:// Zustand — 一个函数搞定 storeimport { create } from 'zustand'const useStore = create((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })),}))// 组件中直接用 hookfunction Counter() { const { count, increment } = useStore() return <button onClick={increment}>{count}</button>}// Redux Toolkit — 需要更多概念import { createSlice, configureStore } from '@reduxjs/toolkit'import { Provider, useSelector, useDispatch } from 'react-redux'const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { increment: (state) => { state.value += 1 }, decrement: (state) => { state.value -= 1 }, },})const store = configureStore({ reducer: { counter: counterSlice.reducer } })// 还需要 Provider 包裹 + useSelector + useDispatchfunction Counter() { const count = useSelector((s) => s.counter.value) const dispatch = useDispatch() return <button onClick={() => dispatch(increment())}>{count}</button>}无需 Provider,更干净的组件树Redux 必须在应用顶层包裹 <Provider store={store}>,导致组件树多出一层嵌套,测试时也需要用 <Provider> 包裹。Zustand 直接在模块作用域创建 store,组件通过 hook 消费状态,无需任何包裹组件。这对存量项目集成尤其友好——不用改已有的组件树结构就能接入状态管理。更小的体积,更快的加载| 库 | Gzipped 大小 ||---|---|| Zustand | ~1.1 KB || Redux Toolkit | ~8.2 KB || Redux Toolkit + React-Redux | ~11.8 KB |在 3G 网络下,11 KB 的差距可带来 50-100ms 的交互时间优化。对包体积有严格限制的移动端 H5 场景,Zustand 优势明显。内置选择器,精准控制重渲染Zustand 支持细粒度订阅,只订阅需要的状态切片,自动跳过无关更新:// 只订阅 count,其他状态变化不会触发重渲染const count = useStore((state) => state.count)// 也支持 shallow 比较对象import { shallow } from 'zustand/shallow'const { count, name } = useStore( (state) => ({ count: state.count, name: state.name }), shallow)Redux 的 useSelector 同样支持选择器,但默认使用 === 严格比较,返回对象时需要手动使用 shallowEqual,容易遗忘导致不必要的重渲染。TypeScript 开箱即用Zustand 的 store 定义自带类型推导,无需额外声明类型:const useStore = create<{ count: number; increment: () => void }>((set) => ({ count: 0, increment: () => set((s) => ({ count: s.count + 1 })),}))// count 和 increment 类型自动推导,无需泛型Redux Toolkit 虽然也有良好的 TS 支持,但 createSlice 和 configureStore 需要更多类型标注和泛型配置。异步操作更直观Zustand 处理异步不需要额外中间件,直接写 async 函数:const useStore = create((set) => ({ data: null, loading: false, fetchData: async () => { set({ loading: true }) const res = await fetch('/api/data') const data = await res.json() set({ data, loading: false }) },}))Redux Toolkit 通过 createAsyncThunk 处理异步,需要定义 pending/fulfilled/rejected 三种状态,样板代码更多。Zustand 的不足生态和中间件相对薄弱Redux 拥有 Redux Saga、Redux Observable、Redux Persist 等成熟中间件生态,处理竞态、取消、重试等复杂副作用有成熟方案。Zustand 自带 persist、devtools、immer 等中间件,覆盖常见需求,但社区中间件数量和成熟度仍远不及 Redux。遇到复杂副作用场景时,往往需要自己实现或组合多个中间件。调试体验有差距Zustand 可通过 devtools 中间件接入 Redux DevTools,但时间旅行调试和 action 回放功能不如原生 Redux 完善。对于需要严格追踪每次状态变更、回溯 bug 的场景,Redux 的调试体验更可靠。大型项目实践尚在积累Zustand 已被 React Three Fiber、shadcn/ui、Next.js 示例等项目广泛采用,2026 年 npm 周下载量达 700 万次,但在超大型企业级应用中的最佳实践仍不如 Redux 成熟。缺少千人团队的治理模式参考和大规模重构案例。灵活性的双刃剑Zustand 不强制状态更新模式,不同开发者可能写出风格迥异的 store。在缺乏 Code Review 约束的团队中,灵活反而变成混乱。Redux 的严格单向数据流天然约束了风格统一性,新人接手代码时理解成本更低。什么时候选 Zustand中小型项目:状态逻辑不复杂,追求开发速度和简洁性存量项目集成:不想引入 Provider 改动组件树,零侵入接入性能敏感场景:对包体积和重渲染有严格要求,如移动端 H5快速原型开发:重视迭代速度而非架构完备性React Three Fiber / 可视化项目:Zustand 是 R3F 生态的默认选择什么时候选 Redux大型企业级应用:团队 5 人以上,需要标准化流程和严格约束复杂副作用:需要 Saga 级别的竞态处理、取消和重试能力重度调试需求:时间旅行调试对排查线上问题至关重要团队已熟悉 Redux:迁移成本高于收益,不必为了换而换金融 / 交易类系统:需要追踪每一次状态变更的审计场景怎么选:一句结论项目小、求快、求轻选 Zustand;项目大、求稳、求规范选 Redux。两者并非互斥——复杂项目中可以在全局状态用 Redux、局部状态用 Zustand,按需搭配。
服务端阅读 05月28日 01:18

Zustand 中的 set 函数有几种使用方式?

Zustand 的 set 函数有三种使用方式,面试中需要完整回答。1. 对象式更新——直接传入新状态const useStore = create((set) => ({ count: 0, reset: () => set({ count: 0 })}));适用于更新不依赖当前状态值的场景,写法简洁。set 会将传入的对象与当前状态浅合并,未提及的字段保持不变。2. 函数式更新——基于当前状态计算新值const useStore = create((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 }))}));函数接收当前 state,返回要合并的对象。异步操作中必须用这种写法,否则闭包会捕获过期的 state:// 错误:count 来自闭包,可能是旧值const badIncrement = async () => { await delay(1000); set({ count: count + 1 });};// 正确:state 始终是最新的const goodIncrement = async () => { await delay(1000); set((state) => ({ count: state.count + 1 }));};3. 替换模式——完全替换而非合并set 的第二个参数 replace 默认为 false(浅合并)。设为 true 时,传入的对象会完全替换当前状态,而非合并:const useStore = create((set) => ({ count: 0, name: 'demo', // 只剩 count,name 被清除 resetAll: () => set({ count: 0 }, true)}));实际开发中常用于登出时清空整个 store,或切换用户时重置所有字段。三种方式的对比| 方式 | 签名 | 状态处理 | 适用场景 ||------|------|----------|----------|| 对象式 | set(partial) | 浅合并 | 不依赖旧值的更新 || 函数式 | set((state) => partial) | 浅合并 | 依赖旧值、异步操作 || 替换式 | set(partial, true) | 完全替换 | 重置 store |浅合并的行为细节浅合并只做第一层合并,嵌套对象需要手动展开:const useStore = create((set) => ({ user: { name: 'Tom', age: 20 }, // 错误:age 丢失 badUpdate: () => set({ user: { name: 'Jerry' } }), // 正确:展开后再覆盖 goodUpdate: () => set((state) => ({ user: { ...state.user, name: 'Jerry' } }))}));如果嵌套层级深,可以配合 immer 中间件来简化写法。面试追问:set 为什么默认浅合并而不是深合并?性能。深合并需要递归遍历整个状态树,对大多数场景来说是不必要的开销。浅合并只需一次 Object.assign 级别的操作,配合 React 的引用比较就能高效判断是否需要重渲染。需要深合并时,开发者自行选择 immer 等工具即可。
服务端阅读 05月28日 00:38

React Query 中如何实现乐观更新?它有哪些优缺点?

乐观更新(Optimistic Update)是 React Query 的核心特性之一,它让应用在服务器响应返回之前就更新 UI,用户操作能获得即时反馈,体验更接近原生应用。乐观更新的工作原理乐观更新的核心思路是"先斩后奏":用户触发操作时,立刻把预期结果写入缓存更新 UI,同时发起真实请求;如果服务器确认成功,用真实数据替换乐观数据;如果失败,则回滚到操作前的状态。整个生命周期分为四步:onMutate — 取消进行中的查询,保存当前缓存快照,写入乐观数据请求发出 — mutation 函数执行,等待服务器响应onError(失败时)— 用快照回滚缓存,恢复 UIonSettled(无论成败)— 让相关查询失效,拉取服务器最新数据基础实现以更新待办事项为例,完整的乐观更新代码如下:const mutation = useMutation({ mutationFn: updateTodo, onMutate: async (updatedTodo) => { // 1. 取消进行中的查询,防止竞态覆盖 await queryClient.cancelQueries({ queryKey: ['todos'] }); // 2. 保存当前缓存,用于回滚 const previousTodos = queryClient.getQueryData(['todos']); // 3. 乐观写入缓存 queryClient.setQueryData(['todos'], (old: Todo[]) => old.map(todo => todo.id === updatedTodo.id ? { ...todo, ...updatedTodo } : todo ) ); // 4. 返回上下文,onError 中可拿到 return { previousTodos }; }, onError: (_err, _variables, context) => { // 失败时回滚 if (context?.previousTodos) { queryClient.setQueryData(['todos'], context.previousTodos); } }, onSettled: () => { // 最终和服务器同步 queryClient.invalidateQueries({ queryKey: ['todos'] }); },});// 触发mutation.mutate({ id: 1, title: '更新后的标题' });为什么需要 cancelQueries?这是面试中经常被追问的点。如果不取消正在进行的查询,可能出现这种情况:onMutate 刚把乐观数据写入缓存,但一个正在后台执行的 refetch 随后返回,把乐观数据覆盖掉。cancelQueries 会中止这些进行中的请求,确保乐观更新不会被意外冲掉。新增数据的乐观更新上面的例子是更新已有数据,比较简单。新增数据时有一个额外问题:新项没有服务端返回的真实 ID,需要生成临时 ID,成功后再替换。onMutate: async (newTodo) => { await queryClient.cancelQueries({ queryKey: ['todos'] }); const previousTodos = queryClient.getQueryData(['todos']); // 生成临时 ID const tempId = `temp-${Date.now()}`; queryClient.setQueryData(['todos'], (old: Todo[] = []) => [ ...old, { ...newTodo, id: tempId }, ]); return { previousTodos, tempId };},onSuccess: (data, _variables, context) => { // 用服务端真实 ID 替换临时 ID queryClient.setQueryData(['todos'], (old: Todo[] = []) => old.map(todo => todo.id === context.tempId ? { ...todo, id: data.id } : todo ) );},并发冲突怎么处理?当多个乐观更新同时发生时,可能出现后一个覆盖前一个的情况。React Query 的推荐做法是依赖 onSettled 中的 invalidateQueries——每次 mutation 结束后都重新拉取最新数据,让 UI 最终收敛到服务器状态。如果对实时性要求更高,可以使用 queryClient.invalidateQueries 的 refetchType: 'all' 选项,确保所有相关查询立即刷新。优缺点对比优点:用户体验显著提升,操作即时反馈,无需等待网络往返减少感知延迟,即使在慢网络下 UI 也能快速响应接近原生应用的交互体验不需要手动管理 loading 和临时 UI 状态缺点:实现复杂度增加,需要正确处理回滚和缓存同步可能出现短暂的 UI 闪烁——用户先看到更新,失败后又回滚并发场景需要额外考虑冲突处理调试难度更高,问题可能出现在乐观写入、回滚或服务器同步任一环节什么时候该用?乐观更新最适合简单、可预测的操作:切换开关、编辑文本、点赞收藏。对于涉及复杂校验、金融计算或不可逆操作的场景,应该等待服务器确认后再更新 UI,避免误导用户。关键在于权衡:用户对即时反馈的期待,和操作失败时回滚带来的困惑,哪个影响更大。
前端阅读 05月27日 17:30

Qwik 和 React 有什么区别?

Qwik 和 React 的核心架构差异是什么?Qwik 和 React 最大的区别在于架构理念:React 基于 虚拟 DOM + 水合(Hydration),Qwik 基于 可恢复性(Resumability)+ 按需加载。这个根本差异直接影响了加载策略、状态管理、性能表现等方方面面。加载策略:全量下载 vs 按需加载React 在页面渲染时,通常需要下载整个应用包(或多个 chunk)。即使使用了 Code Splitting 做懒加载,也需要开发者手动配置:// React 懒加载需要手动配置const LazyComponent = React.lazy(() => import('./HeavyComponent'));function App() { return ( <Suspense fallback={<Loading />}> <LazyComponent /> </Suspense> );}Qwik 的加载策略完全不同——所有 JavaScript 默认都是延迟加载的,只有用户与页面交互时才加载和执行相关代码:// Qwik 组件:事件处理器自动延迟加载import { component$, useSignal } from '@builder.io/qwik';export default component$(() => { const count = useSignal(0); return ( <button onClick$={() => count.value++}> 点击了 {count.value} 次 </button> );});注意 Qwik 中的 component$ 和 onClick$,$ 后缀表示这是一个延迟加载边界,编译器会自动将这段代码拆分为独立 chunk,仅在需要时加载。水合 vs 可恢复性这是 Qwik 和 React 最本质的区别。React 的水合过程:SSR 渲染出 HTML 后,客户端必须重新下载并执行 JavaScript,重建组件树、附加事件监听器,让页面变得可交互。这个过程称为 Hydration:SSR HTML → 下载 JS → 执行组件代码 → 附加事件 → 页面可交互 ↑ 这一步耗时且昂贵即使用 React 18 的 Selective Hydration 做了部分优化,仍然无法避免大量 JavaScript 的下载和执行。Qwik 的可恢复性:不需要水合。Qwik 在 SSR 时将组件状态序列化到 HTML 中,事件监听器通过 HTML 属性直接附加:<!-- Qwik 渲染出的 HTML --><button on:click="/build/bundle-abc.js#handler_xyz"> 点击了 0 次</button>浏览器拿到 HTML 后,当用户点击按钮时,才去加载对应的 JS 函数并执行。页面天然就是可交互的,不需要任何"唤醒"过程:SSR HTML → 页面立即可交互 ↑ 无需额外 JS 执行状态管理:细粒度更新 vs 重新渲染React 使用 useState、useReducer、Context API 管理状态,状态变化会触发组件重新渲染:// React:状态更新触发组件重渲染function Counter() { const [count, setCount] = useState(0); // count 变化 → 整个组件重新执行 return <button onClick={() => setCount(c => c + 1)}>{count}</button>;}复杂应用中,开发者需要借助 useMemo、useCallback、React.memo 手动优化渲染性能,或者引入 Redux、Zustand 等外部状态管理库。Qwik 使用 useSignal 和 useStore 管理状态,状态变化只更新绑定的 DOM 节点,不会触发组件重新渲染:// Qwik:状态更新只更新具体 DOM 节点export default component$(() => { const count = useSignal(0); // count 变化 → 只更新 {count.value} 对应的文本节点 return <button onClick$={() => count.value++}>{count.value}</button>;});此外,Qwik 的状态会被序列化到 HTML 中,刷新页面后状态依然存在,不需要额外的状态恢复逻辑。性能数据对比| 指标 | React + Next.js | Qwik + Qwik City ||------|----------------|-------------------|| 首屏 JS 体积 | 40-100KB+ | 约 1-2KB || TTI(可交互时间) | 需等待水合完成 | HTML 加载即交互 || 水合开销 | 重新执行全部组件 JS | 无水合过程 || 代码分割 | 手动配置(lazy/Suspense) | 编译器自动完成 |Qwik 在首屏加载上的优势尤为明显——初始 JS 包只有 1-2KB,而 React 应用即使做了代码分割,首屏仍需加载框架核心和组件代码。开发体验对比React 的优势:生态系统成熟,npm 上几乎任何需求都有现成库可用。社区支持强大,遇到问题容易找到解决方案。Next.js 提供了完善的 SSR/SSG 方案。Qwik 的学习成本:语法与 React 相似(JSX、Hooks 风格的 API),但有几个关键差异需要适应:component$ 替代普通函数组件$ 后缀标记延迟加载边界useSignal / useStore 替代 useStateuseTask$ 替代 useEffect编译器自动处理优化,不需要手动写 useMemo / useCallback各自适合什么场景?选择 Qwik 的场景:内容密集型网站(博客、新闻、电商列表页)对首屏加载速度和 SEO 排名有严格要求面向移动端用户或网络条件不稳定的场景大型应用希望减少 JS 体积对性能的影响选择 React 的场景:需要丰富的第三方库和工具支持团队已有 React 经验,迁移成本需要考虑项目复杂度高,需要成熟的架构方案(如 Next.js App Router)快速原型开发,优先开发效率而非极致性能迁移建议如果你正在考虑从 React 迁移到 Qwik,需要注意:Qwik 提供了 qwik-react 集成,可以在 Qwik 应用中逐步引入 React 组件,支持渐进式迁移并非所有 React 生态库都有 Qwik 对应方案,复杂项目建议先做技术评估对于已有 React 项目,迁移优先级应基于性能瓶颈:如果当前应用首屏加载不是痛点,迁移的收益有限Qwik 通过可恢复性架构在首屏性能上建立了明显优势,但 React 凭借成熟的生态和社区仍是更稳妥的选择。具体选型应基于项目对性能、生态和团队能力的综合考量。
前端阅读 245月27日 01:12

React 组件渲染过程是怎么样的?

React 的渲染分两个阶段:Render 阶段(协调 Reconciliation):状态变化 → 创建新的 Virtual DOM 树 → 和旧的 Virtual DOM 做 diff → 标记需要更新的节点。这个阶段是纯计算,没有副作用,React 18 可以中断和恢复(并发模式)。Commit 阶段:把 diff 结果应用到真实 DOM。这个阶段不可中断,React 保证 DOM 更新的原子性。commit 结束后触发 useLayoutEffect 同步执行,然后浏览器重绘,最后异步执行 useEffect。React 18 之前 render 不可中断。18 的并发渲染(Concurrent Features)可以在 render 阶段暂停低优先级更新,优先处理用户交互等高优先级任务。追问什么情况下 React 会跳过组件的渲染?shouldComponentUpdate 返回 false(class 组件)React.memo 包裹的组件 props 没变(浅比较)state 没变化时(setState 传入相同值,React 用 Object.is 判断)Context value 没变时(但 Provider 下的所有 Consumer 会在 Provider render 时重新渲染,和 value 是否变化无关——这是常见性能陷阱)useLayoutEffect 和 useEffect 执行的时机有什么不同?useLayoutEffect 在 DOM 变更后、浏览器绘制前同步执行(阻塞渲染)。useEffect 在浏览器绘制后异步执行。需要读取 DOM 布局、同步更新防止闪烁的场景用 useLayoutEffect。React 18 并发模式的 "可中断渲染" 是怎么实现的?基于时间切片(Time Slicing)——Render 阶段被切成 5ms 的小段,每段结束后检查是否有更高优先级任务。如果有就暂停当前渲染,先处理高优任务。实现依赖的是 MessageChannel(而非 requestIdleCallback,因为后者在后台标签页可能被暂停)。
服务端阅读 02月19日 17:58

如何在 React 中使用 MobX 的 observer?

在 MobX 中,observer 是一个高阶组件(HOC),用于将 React 组件转换为响应式组件。当组件使用的数据发生变化时,组件会自动重新渲染。observer 的基本用法1. 类组件中使用 observerimport React from 'react';import { observer } from 'mobx-react';import { observable } from 'mobx';class Store { @observable count = 0;}const store = new Store();@observerclass Counter extends React.Component { render() { return ( <div> <p>Count: {store.count}</p> <button onClick={() => store.count++}>Increment</button> </div> ); }}2. 函数组件中使用 observerimport React from 'react';import { observer } from 'mobx-react-lite';import { observable } from 'mobx';class Store { @observable count = 0;}const store = new Store();const Counter = observer(() => { return ( <div> <p>Count: {store.count}</p> <button onClick={() => store.count++}>Increment</button> </div> );});observer 的工作原理1. 组件挂载时MobX 创建一个 reaction 来追踪组件 render 函数中访问的所有 observable建立组件与 observable 之间的依赖关系2. 状态变化时当 observable 被修改时,MobX 检测到依赖变化将组件标记为需要重新渲染在下一个事件循环中,触发组件的重新渲染3. 组件卸载时自动清理 reaction 和依赖关系避免内存泄漏observer 的优化特性1. 细粒度更新observer 只会重新渲染真正需要更新的组件:@observerclass Parent extends React.Component { render() { return ( <div> <ChildA /> <ChildB /> </div> ); }}@observerclass ChildA extends React.Component { render() { // 只依赖 store.count return <div>Count: {store.count}</div>; }}@observerclass ChildB extends React.Component { render() { // 只依赖 store.name return <div>Name: {store.name}</div>; }}当 store.count 变化时,只有 ChildA 会重新渲染,ChildB 不会。2. shouldComponentUpdate 优化observer 会自动实现 shouldComponentUpdate,避免不必要的渲染:只有当组件依赖的 observable 真正变化时才重新渲染即使父组件重新渲染,子组件也可能不会重新渲染3. 批量更新多个状态变化会被批量处理,只触发一次重新渲染:runInAction(() => { store.count++; store.name = 'New Name';});observer 的最佳实践1. 只在需要的地方使用 observer不是所有组件都需要 observer,只在需要响应状态变化的组件上使用:// 不需要 observerconst Header = () => <h1>My App</h1>;// 需要 observerconst Counter = observer(() => { return <div>Count: {store.count}</div>;});2. 避免在 render 中创建新对象在 render 中创建新对象会导致不必要的重新渲染:// 不好的做法const BadComponent = observer(() => { const style = { color: 'red' }; // 每次渲染都创建新对象 return <div style={style}>{store.count}</div>;});// 好的做法const style = { color: 'red' }; // 在组件外部定义const GoodComponent = observer(() => { return <div style={style}>{store.count}</div>;});3. 使用 computed 优化计算在组件外部使用 computed 来优化计算逻辑:// 不好的做法const BadComponent = observer(() => { const fullName = `${store.firstName} ${store.lastName}`; return <div>{fullName}</div>;});// 好的做法class Store { @observable firstName = 'John'; @observable lastName = 'Doe'; @computed get fullName() { return `${this.firstName} ${this.lastName}`; }}const GoodComponent = observer(() => { return <div>{store.fullName}</div>;});4. 使用 React.memo 配合 observer对于纯展示组件,可以结合 React.memo 使用:const PureComponent = React.memo(observer(() => { return <div>{store.count}</div>;}));常见问题1. 组件不更新确保:组件被 observer 包装访问的是 observable 而不是普通对象状态修改在 action 中进行2. 过度渲染如果组件过度渲染,检查:是否在 render 中创建了新对象是否使用了 computed 来优化计算是否可以拆分组件以减少依赖3. 内存泄漏确保:组件卸载时 observer 会自动清理手动创建的 reaction 需要手动清理总结observer 是 MobX 与 React 集成的核心,它通过细粒度的依赖追踪实现了高效的响应式更新。正确使用 observer 和遵循最佳实践,可以构建高性能的 React 应用。