React面试题手册

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

服务端阅读 05月31日 15:55

React 里 MobX observer 为什么能自动更新组件?

MobX 的 observer 不是简单地给组件加一个订阅开关,它会在组件渲染时记录“这次 render 到底读了哪些 observable”。之后只有这些被读过的状态变化,组件才会重新渲染。也就是说,observer 的关键不是“组件用了 store”,而是“组件在渲染期间访问了 store 的哪个字段”。在 React 项目里,函数组件通常使用 mobx-react-lite,类组件才会用到 mobx-react。MobX 6 以后更推荐 makeAutoObservable,少写装饰器,也更适合 TypeScript 和现代构建环境。import { makeAutoObservable } from "mobx";import { observer } from "mobx-react-lite";class CounterStore { count = 0; name = "MobX"; constructor() { makeAutoObservable(this); } inc() { this.count += 1; }}const store = new CounterStore();export const Counter = observer(() => { return <button onClick={() => store.inc()}>{store.count}</button>;});上面组件只读取了 store.count,所以 store.name 改变不会让它重渲染。这个粒度比“整个 store 变化就刷新”要细很多,也是 MobX 在复杂表单、局部状态很多的页面里比较省心的原因。实际接入时,还要想清楚 store 从哪里来。小 demo 里直接 const store = new CounterStore() 没问题,但真实应用通常会用 React Context 注入 store,避免测试、SSR 或多实例页面互相污染。尤其是 Next.js 这类服务端渲染场景,全局单例可能把 A 用户的状态带到 B 用户请求里,这是很隐蔽的边界问题。另一个容易忽略的点是 React 18 的 StrictMode。开发环境下某些渲染和 effect 会被重复调用,用来暴露副作用问题,很多人会误判为 observer 重复更新。判断时要区分“React 开发模式故意重复执行”和“MobX 依赖真的变化”。如果 action 里混入请求、埋点或一次性初始化逻辑,最好把这些副作用放到明确的生命周期或事件里,不要依赖 render 触发。还有一个实用经验:不要过早把 store 解构成一堆局部变量再传来传去。const { count } = store 在某些位置只是拿到了当前值,后续组件读取的就不是 observable getter。保留 store.count 的读取路径,或者在 observer 子组件内再读取,通常更符合 MobX 的追踪模型。如果使用 Context 注入 store,也不要在 Provider 的 render 里反复 new Store()。每次创建新实例都会让依赖关系重建,旧组件里的 reaction 也可能来不及按预期清理。通常可以用 useState(() => new Store()) 或模块级工厂保证实例生命周期稳定。这个细节不显眼,但在多标签页、弹窗复用和测试隔离时很容易变成偶发 bug。追问observer 应该包在父组件还是子组件上?更推荐把 observer 放在真正读取 observable 的叶子组件上,而不是一股脑包住最外层页面。这样依赖会更小,某个字段变化时只刷新用到它的那块 UI。取舍是组件数量会多一些,但性能边界更清晰,排查“为什么这里更新了”也更容易。踩坑点是父组件先把 observable 解构成普通值再传下去,子组件即使包了 observer,也可能失去追踪效果。为什么有时数据变了,observer 组件却不更新?最常见原因是渲染期间没有直接读取 observable,例如在组件外提前把值存成普通变量。MobX 只能追踪 reaction 执行时发生的读取,追踪不到已经脱离 observable 的快照。另一个边界是对象本身没有被 makeAutoObservable、observable 或对应 annotation 处理,普通对象当然不会触发更新。实际项目里还要检查状态修改是否发生在 action 中,开启 enforceActions 后,违规修改会直接暴露问题。observer 和 React.memo 需要一起用吗?多数情况下不需要,observer 本身已经对 observable 依赖做了细粒度判断,也会处理一部分 props 变化带来的重复渲染。React.memo 更适合纯展示组件,用普通 props 驱动且没有读取 MobX 状态。两者强行叠加不一定出错,但容易让团队误以为“性能优化越多越好”。真正需要权衡的是组件边界:把读取状态的组件拆小,通常比到处加 memo 更稳定。在 render 里创建新对象会影响 observer 吗?会影响,但影响点通常不是 MobX 追踪,而是 React 子组件的 props 比较。比如每次 render 都创建 { color: 'red' } 或新的回调函数,传给普通子组件时可能导致子组件跟着刷新。这个坑在 MobX 页面里更隐蔽,因为你会以为是 observable 更新太频繁,其实是 React 引用变化。固定样式对象、用 computed 产出派生数据,或者把子组件也拆成 observer,都是可选方案。异步请求里修改状态,observer 会怎样更新?异步本身不会破坏 observer,关键是每次修改 observable 时是否在 action 边界内。async 函数里 await 之后已经离开原来的同步 action,因此后续赋值最好放进 runInAction 或拆成单独 action。这样做的取舍是代码多几行,但状态变化会更可追踪,也能避免严格模式报错。多个字段一起更新时,action 还能合并通知,减少组件中间态闪烁。async load() { this.loading = true; const data = await api.getList(); runInAction(() => { this.items = data; this.loading = false; });}observer 用得好,核心不是“所有组件都包一下”,而是让组件在正确的位置读取正确的 observable。状态读取越靠近展示位置,MobX 的自动追踪越准确,页面也越不容易出现莫名其妙的刷新。
服务端阅读 05月30日 01:39

如何优化 Zustand 状态更新性能?

Zustand 性能优化先看订阅粒度:组件只订阅自己需要的字段,不要 useStore() 拿整个 store。多个字段一起取时用 shallow 或拆成多个 selector;状态太大时按领域拆 store;异步更新用函数式 set 或 get() 避免旧值。真正的瓶颈通常不是 Zustand,而是选择器返回新对象、组件订阅过宽、列表渲染太重。追问为什么 useStore() 容易造成重渲染?它订阅整个 store,任何字段变化都会让组件重新渲染。字段越多,误伤越明显。shallow 能解决什么问题?selector 返回对象或数组时,每次都是新引用。shallow 会比较第一层字段,字段没变就不触发更新。拆 store 一定更好吗?不一定。强相关状态放一起更好维护;变化频率差异很大、业务边界清楚时再拆,否则会增加同步成本。批量更新要手动处理吗?React 18 下大多数场景会自动批处理。更重要的是把相关字段放在一次 set 里,避免中间状态被订阅者看到。写段代码import { shallow } from 'zustand/shallow';const count = useStore((s) => s.count);const inc = useStore((s) => s.inc);const userView = useStore( (s) => ({ name: s.user.name, role: s.user.role }), shallow);const useStore = create((set) => ({ count: 0, inc: () => set((s) => ({ count: s.count + 1 }))}));
服务端阅读 05月30日 01:39

如何在 Zustand 中处理异步操作?

Zustand 处理异步不需要额外机制,直接在 action 里写 async/await,用 set 更新 loading/data/error,需要最新状态时用 get()。如果是服务端缓存、重试、失效刷新这类问题,优先交给 React Query 或 SWR,Zustand 只保存跨页面共享的 UI 或业务状态。追问async action 里为什么要用 get()?异步代码执行时,闭包里的旧值可能已经过期。get() 读取的是当前 store 状态,适合在 await 后继续基于最新状态更新。loading 和 error 应该怎么设计?简单请求可以放 loading: boolean;多个并发请求最好按 key 存状态,避免 A 请求结束把 B 请求的 loading 误关掉。Promise 链和 async/await 有区别吗?能力上差不多。面试回答用 async/await 更清楚,但要说明 action 可以返回 Promise,组件或测试里可以继续 await。什么时候不该把请求全塞进 Zustand?需要缓存、分页、去重、后台刷新、请求取消时,不要自己造一套数据请求框架,直接用 React Query/SWR 更稳。写段代码const useStore = create((set, get) => ({ user: null, loading: false, error: null, fetchUser: async (id) => { set({ loading: true, error: null }); try { const user = await fetch(`/api/users/${id}`).then(r => r.json()); set({ user, loading: false }); } catch (e) { set({ error: e.message, loading: false }); } }}));
服务端阅读 05月30日 01:39

如何对 Zustand store 进行单元测试?

Zustand store 单测重点是把状态恢复到干净初始值,再验证 action、异步状态和 selector 行为。同步 action 可以直接用 getState() 调;React hook 场景用 renderHook 和 act;异步 action 要 mock 请求并等待 Promise 结束。面试里别只说“很好测”,要提到全局 store 会污染用例,必须在 beforeEach 重置。追问为什么每个测试前要重置 store?Zustand store 默认是模块级单例,上一个测试改过的状态会留到下一个测试,导致用例顺序一变就失败。测 action 一定要 renderHook 吗?不一定。纯 store 逻辑用 useStore.getState().action() 更快;只有要验证 hook 订阅和组件重渲染时,才需要 renderHook。异步 action 怎么测?mock fetch 或请求层,调用 action 后断言 loading、data、error 的最终状态。需要中间态时,可以分阶段 await。selector 性能怎么测?订阅一个具体 selector,更新无关字段,断言渲染次数不变;再更新目标字段,断言它才重新触发。写段代码beforeEach(() => { useStore.setState({ count: 0, user: null }, true);});test('increments count', () => { useStore.getState().increment(); expect(useStore.getState().count).toBe(1);});test('fetch user', async () => { vi.spyOn(global, 'fetch').mockResolvedValue({ json: async () => ({ id: 1 }) }); await useStore.getState().fetchUser(1); expect(useStore.getState().user).toEqual({ id: 1 });});
服务端阅读 05月30日 01:39

如何在 Zustand 中自定义中间件?

Zustand 自定义中间件本质是包一层 config:拦截 set/get/api,再把增强后的能力交还给 store。常见用途是日志、校验、性能埋点、撤销重做。面试里先说函数签名,再强调两点:不要破坏原始 set 语义;组合多个 middleware 时外层先执行,顺序会影响结果。追问自定义 middleware 和普通 action 封装有什么区别?普通 action 只管某个业务动作;middleware 能统一拦截所有状态更新,适合横切能力,比如日志、持久化、校验。set 包装时最容易踩什么坑?别忘了转发 replace 参数,也不要在 middleware 里无条件再次调用增强后的 set,否则可能递归或改变 replace 行为。多个 middleware 的执行顺序怎么看?create(a(b(config))) 中,a 先拿到 config 并包装,实际更新时通常外层逻辑先触发。日志、persist、immer 混用时要明确谁先处理原始对象。实际项目会怎么用?我更倾向把日志、权限校验、状态快照放 middleware,业务状态变化仍留在 action 里,避免 middleware 变成黑盒业务层。写段代码const logger = (config) => (set, get, api) => config((partial, replace) => { const prev = get(); const ret = set(partial, replace); console.log('zustand change', { prev, next: get() }); return ret; }, get, api);const useStore = create(logger((set) => ({ count: 0, inc: () => set((s) => ({ count: s.count + 1 }))})));
服务端阅读 05月30日 00:37

useQuery 和 useMutation 有什么区别?分别适合什么场景?

useQuery 用来读数据,useMutation 用来改数据。读数据通常自动执行、会按 queryKey 缓存,并处理 loading、error、重试和后台刷新;改数据不会因为组件渲染就自动执行,需要调用 mutate,成功后通常 invalidateQueries 让相关列表重新获取,或者做乐观更新。追问为什么 useMutation 不像 useQuery 那样自动缓存?因为写操作的重点不是展示“这次提交的返回值”,而是让服务器状态发生变化。变化后要么让相关 query 失效重新拉取,要么手动更新缓存。提交表单应该用哪个?用 useMutation。表单提交是创建或更新资源,应该由用户动作触发,而不是组件一挂载就执行。删除一条数据后列表怎么刷新?常见做法是在 onSuccess 里调用 queryClient.invalidateQueries,让列表 query 重新获取。体验要求高时可以先做乐观删除,失败再回滚。queryKey 在 useQuery 里为什么重要?它决定缓存身份。列表、详情、筛选条件都应该进 queryKey,否则容易拿到旧数据或串数据。写段代码const todos = useQuery({ queryKey: ['todos'], queryFn: fetchTodos });const addTodo = useMutation({ mutationFn: createTodo, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }); },});addTodo.mutate({ title: 'learn query' });
服务端阅读 05月30日 00:37

React Query 和 Redux 有什么区别?什么时候该用 React Query?

React Query 管服务器状态,Redux 管客户端状态。服务器状态来自后端,会过期、要缓存、要重试、要和接口同步;客户端状态只存在前端,比如弹窗开关、主题、复杂表单流程。项目里常见做法不是二选一,而是用 React Query 处理接口数据,用 Redux、Zustand 或 Context 处理真正的全局 UI 状态。追问React Query 能替代 Redux 吗?只能替代一大部分“把接口数据塞进 Redux”的写法。购物车草稿、权限后的菜单展开状态、跨页面编辑流程这类客户端状态,React Query 不负责。为什么接口数据不适合直接放 Redux?你要自己写 loading、error、缓存过期、重复请求合并、重试和失效逻辑。React Query 把这些变成默认能力,代码量少很多。什么时候还应该用 Redux?当状态更新规则复杂、多个模块共享同一份前端状态、需要严格可追踪的状态变更时,Redux 仍然合适。已经重度使用 Redux Toolkit 的项目,也可以考虑 RTK Query。两者一起用会不会混乱?不会,边界划清就行:接口返回的数据归 React Query,用户在页面上的交互状态归客户端状态库。最怕的是同一份数据两边各存一份。写段代码const { data: user } = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id),});const theme = useSelector(state => state.ui.theme);
服务端阅读 05月30日 00:37

React Query 有哪些高级特性?依赖查询和并行查询怎么用?

React Query 的高级特性主要解决三个问题:按条件发请求、同时发多个请求、让缓存数据在合适时机变新。依赖查询用 enabled 控制执行时机;并行查询用多个 useQuery 或 useQueries;窗口聚焦重新获取用 refetchOnWindowFocus 保证页面回来时数据不过期。回答时要补一句:这些能力都围绕服务器状态,不是替代本地 UI 状态。追问依赖查询为什么不用 if 包住 useQuery?Hook 不能放进条件语句里,否则调用顺序会变。正确做法是始终调用 useQuery,用 enabled: !!id 控制是否真正请求。多个 useQuery 和 useQueries 怎么选?数量固定时直接写多个 useQuery,可读性更好。数量来自数组时用 useQueries,例如按一批 id 拉详情。refetchOnWindowFocus 会不会导致请求太多?会,所以后台管理页、实时看板适合开启;编辑表单、低频静态数据可以关闭,或配合 staleTime 降低重复请求。无限滚动靠什么实现?用 useInfiniteQuery,关键是 getNextPageParam 返回下一页参数,返回 undefined 表示没有更多。写段代码const user = useQuery({ queryKey: ['user', id], queryFn: getUser });const posts = useQuery({ queryKey: ['posts', user.data?.id], queryFn: () => getPosts(user.data.id), enabled: !!user.data?.id, staleTime: 60_000, refetchOnWindowFocus: true,});const results = useQueries({ queries: ids.map(id => ({ queryKey: ['todo', id], queryFn: () => getTodo(id) }))});
服务端阅读 05月30日 00:37

React Query 如何处理错误和重试?哪些错误不该重试?

React Query 的错误处理要分两层:组件里展示用户能看懂的错误,全局里做日志和兜底通知。重试不要一刀切,网络抖动和 5xx 可以重试,401、403、404、表单校验这类 4xx 通常不该重试;否则只是把错误请求重复打到服务端。追问retry 默认是几次?浏览器端查询默认会重试 3 次,并使用退避延迟;服务端渲染场景通常不建议重试太多,否则会拖慢响应。retry 和 refetch 有什么区别?retry 是一次请求失败后的自动补救,refetch 是用户或程序主动重新拉取。错误页里的“再试一次”通常调用 refetch 或 reset error boundary。Mutation 失败要怎么处理?如果做了乐观更新,onMutate 里先保存旧数据,onError 里回滚,再提示用户。不要只弹 toast,却让缓存停在错误状态。全局 onError 适合做什么?适合上报 Sentry、统一 toast、处理登录过期。具体页面文案仍应在业务组件里判断。写段代码useQuery({ queryKey: ['todos'], queryFn: getTodos, retry: (count, error: any) => { if ([400, 401, 403, 404].includes(error.status)) return false; return count < 3; }, retryDelay: i => Math.min(1000 * 2 ** i, 30_000),});
服务端阅读 05月30日 00:37

React Query 如何与 Suspense 集成?错误边界怎么处理?

React Query 接入 Suspense 后,加载状态交给 Suspense fallback,错误交给 Error Boundary。实际项目里优先用 useSuspenseQuery,而不是在每个组件里判断 isLoading。注意:开启 Suspense 不代表不用管错误,网络失败会抛给最近的错误边界;如果要让“重试”按钮生效,还要配 QueryErrorResetBoundary。追问useSuspenseQuery 和 useQuery 有什么区别?useSuspenseQuery 成功返回时 data 一定有值,加载中会挂起组件。你少写了 loading 分支,但必须准备好 Suspense 和错误边界。Error Boundary 应该放在哪里?放在页面块级别通常最合适。太外层会导致整页白掉,太内层又会让错误处理重复。有缓存时还会进入 fallback 吗?已有可用缓存时通常直接渲染;queryKey 改变触发新请求时,可能再次 fallback。交互更新可用 startTransition 降低界面闪烁。SSR 里能直接用吗?要谨慎。Next.js 等场景通常配合预取、dehydrate/hydrate,或使用框架推荐的 Suspense 数据方案。写段代码<QueryErrorResetBoundary> {({ reset }) => ( <ErrorBoundary onReset={reset} fallbackRender={({ resetErrorBoundary }) => ( <button onClick={resetErrorBoundary}>重试</button> )}> <Suspense fallback={<Spinner />}> <UserPanel /> </Suspense> </ErrorBoundary> )}</QueryErrorResetBoundary>
服务端阅读 05月30日 00:37

React Query 项目中如何组织查询和命名 query key?

项目里不要把 useQuery 散落在组件里。更稳的做法是按业务域组织:API 函数只负责请求,自定义 Hook 负责暴露查询,query key 工厂负责命名和失效。query key 用数组,从宽到窄写,比如 ['users', 'detail', id];凡是 queryFn 依赖的参数都必须进 key,否则缓存可能串数据。追问为什么不建议直接写字符串 key?字符串很难表达层级,也不方便批量失效。数组 key 可以让你失效整个用户域、某个详情页,或某个筛选列表。API 函数和 Hook 为什么要分开?API 函数可独立测试,也能被预取、SSR、mutation 复用。Hook 里再放 staleTime、enabled、select 等 React Query 配置。项目结构按 api、hooks 分,还是按 feature 分?小项目都可以;中大型项目更推荐 feature 分组,例如 features/user/api.ts、queries.ts、keys.ts,修改用户模块时不用跨目录找文件。命名上最容易踩什么坑?列表和详情共用一个 key、筛选条件没放进 key、mutation 成功后失效范围太大。这些都会造成脏数据或无意义重请求。写段代码export const userKeys = { all: ['users'] as const, detail: (id: string) => [...userKeys.all, 'detail', id] as const,};export function useUser(id: string) { return useQuery({ queryKey: userKeys.detail(id), queryFn: () => getUser(id), staleTime: 60_000, });}
服务端阅读 05月29日 22:54

React Native 调试有哪些方法?各调试工具分别适用什么场景?

React Native 调试分 JS 层调试、原生层调试、性能分析 三个层面。JS 层调试: Chrome DevTools 是最基础的方式,通过 Ctrl+M 打开开发者菜单选择 Debug 打开 Chrome 调试。但 Chrome 调试运行在 Chrome V8 而非设备 JSC/Hermes 上,存在兼容差异。推荐使用 Flipper,它内置 React DevTools 和网络检查器,支持断点、Console、组件树检查,是官方推荐的调试平台。原生层调试: Android 用 Android Studio 的 Logcat 过滤 ReactNativeJNI 日志,可对原生模块设断点调试;iOS 用 Xcode 的 LLDB,在 RCTBridge 方法处打断点。性能分析: Perf Monitor(开发者菜单内)查看 FPS 和内存;Systrace 记录帧渲染时间线定位掉帧原因;Hermes 引擎自带 CPU Profiler 生成 Chrome Trace 格式文件。React DevTools Profiler 分析组件渲染耗时和频率。常用工具总结: Flipper(全能调试)、React DevTools(组件树)、Chrome DevTools(JS 断点)、Xcode/Android Studio(原生调试)、Systrace(性能分析)。 追问: Chrome 调试模式和 Hermes 引擎调试有什么区别? Flipper 在新架构下是否还适用?有什么替代方案? 如何对 React Native 的启动耗时进行量化分析? 生产环境如何收集 JS 异常和崩溃日志? Hermes 的 CPU Profiler 生成的 trace 文件如何分析?
服务端阅读 05月29日 22:54

React Native 性能优化怎么做?常见性能瓶颈如何排查和解决?

React Native 性能优化的核心是 减少 JS Bridge 通信、控制重渲染、优化列表滚动 三个方向。减少 Bridge 通信: Bridge 是异步串行的,高频调用会积压队列。将批量操作合并为一次原生调用,使用 Native Modules 批量传递数据而非逐条传递。新架构 Fabric 取代了异步 Bridge,实现同步通信。控制重渲染: 使用 React.memo 包裹子组件避免不必要的 re-render;用 useMemo 缓存计算结果、useCallback 缓存回调函数;列表项拆分为独立组件并 memo 化。避免在 render 中创建内联对象和函数。优化列表: FlatList 替代 ScrollView 渲染长列表,设置 keyExtractor、getItemLayout 提升性能;windowSize 控制渲染窗口大小;removeClippedSubviews 回收不可见视图。VirtualizedList 是 FlatList 的底层,可做更细粒度控制。其他关键点: 图片使用缓存库(如 react-native-fast-image);动画用 useNativeDriver 驱动跑在原生线程;避免在主线程做 JSON 解析等耗时操作;Profiling 工具用 Flipper 或 Systrace 定位瓶颈。 追问: React.memo 的浅比较在什么场景下会失效?如何自定义比较逻辑? FlatList 的 windowSize 参数设为多少合适?过小会有什么副作用? useNativeDriver 为什么不能驱动布局属性(如 height)的动画? 新架构 Fabric 和 TurboModules 相比旧 Bridge 的性能提升体现在哪里? 如何用 Systrace 定位 JS 线程和原生线程的耗时瓶颈?
服务端阅读 05月29日 01:38

Zustand 中间件怎么使用?有哪些内置中间件?

Zustand 中间件以函数组合方式包裹 create 的回调,从内到外依次嵌套。内置三个核心中间件:persist(状态持久化到 localStorage/sessionStorage)、devtools(接入 Redux DevTools 调试)、immer(简化不可变更新,可直接写 state.user.name = 'new')。组合顺序:devtools 在最外层,persist 在内层,中间件顺序影响 set/get 的拦截链。追问persist 的 partialize 怎么过滤不需要持久化的字段?partialize: (state) => ({ user: state.user }) 只持久化 user,token 等敏感或临时字段不会写入存储。反序列化时缺失字段会使用 create 中的初始值填充。immer 中间件解决了什么问题?React 要求状态不可变更新,深层嵌套需逐层展开 {…s, user: {…s.user, name: 'new'}},代码冗长易出错。immer 让你直接赋值 state.user.name = 'new',内部通过 Proxy 生成新对象。中间件组合顺序有影响吗?有。devtools(persist(fn)) 中 persist 在内层,持久化后的状态变化才会被 devtools 捕获;反过来的话 devtools 记录的是持久化前。一般推荐 devtools 在外、persist 在内。如何自定义中间件?中间件本质是高阶函数:(config) => (set, get, api) => config(fnSet, fnGet, api),在 fnSet/fnGet 中插入自定义逻辑(如日志、节流、权限校验),然后调用原 set/get。persist 持久化的状态怎么版本迁移?persist 配置中提供 migrate 函数:migrate: (persisted, version) => { if (version === 0) return { …persisted, newField: 'default' }; return persisted; },配合 version 字段标识当前版本,自动执行迁移。写段代码import { create } from 'zustand'import { persist, devtools } from 'zustand/middleware'const useStore = create( devtools( persist( (set) => ({ token: '', setToken: (t: string) => set({ token: t }), }), { name: 'auth', partialize: (s) => ({ token: s.token }) } ), { name: 'AuthStore' } ))
服务端阅读 05月29日 01:38

Zustand 中如何用 TypeScript 定义 Store 类型?

定义一个包含状态和方法的 interface,作为 create 的泛型参数传入即可:create((set, get) => ({…}))。TypeScript 会自动推断 set 回调中 state 的类型,方法参数也能正确约束。关键点:状态和方法写在同一个 interface 中,方法参数可引用自身类型(如 setUser: (user: StoreState['user']) => void)。追问中间件会改变 store 类型,怎么处理类型组合?persist/devtools 等中间件会向 store 注入额外属性(如 persist.clearStorage),需定义扩展类型 type Store = StoreState & StorePersist,再用 create() 包裹。zustand v4+ 的中间件泛型签名已支持自动推断,大部分场景无需手动拼接。StateCreator 泛型怎么用?StateCreator 用于拆分 store 逻辑时约束每个切片的类型,Mutators 描述中间件链对 set/get 的改写,U 是未包装的原始类型。手动组合切片时需要用到。selector 的返回类型怎么保证?useStore(s => s.count) 自动推断为 number。自定义 selector 返回派生值时,TypeScript 能推断返回类型;如果需要显式标注,写 useStore((s) => s.items.length)。泛型 store 怎么定义?function createSlice() 返回 StateCreator>,通过泛型参数让切片适配不同实体(如 createSlice()、createSlice()),避免为每个实体写重复逻辑。set 的 partial 参数类型为什么不是 Partial?set 接受 Partial 或 (state) => Partial,但嵌套对象是浅合并,深层更新需展开:set(s => ({ user: { …s.user, name: 'new' } })),否则丢失同级字段。写段代码interface BearState { bears: number inc: (n: number) => void}const useBearStore = create<BearState>((set) => ({ bears: 0, inc: (n) => set((s) => ({ bears: s.bears + n })),}))// 组件中const bears = useBearStore((s) => s.bears) // number
服务端阅读 05月29日 01:38

Zustand 是什么?相比 Redux 有什么优势?

Zustand 是一个极简 React 状态管理库,核心 API 只有 create():传入一个返回状态对象的函数,返回一个 hook,组件通过 const count = useStore(s => s.count) 按需订阅,未变化的部分不会触发重渲染。不需要 Provider 包裹,不需要 reducer/action 分发,setState 直接更新。体积约 1KB,零依赖。追问Zustand 的 selector 如何避免不必要的重渲染?useStore(selector) 只订阅 selector 返回的切片,内部用 Object.is 浅比较判断是否变化。引用类型可传第二个参数 shallow 比较函数,或用 useShallow。和 Jotai/Recoil 的原子化方案有什么区别?Zustand 是单一 store(可拆分),状态集中管理;Jotai/Recoil 是原子化,每个状态独立。Zustand 更适合关联性强的状态,原子化适合独立派生状态。没有 Provider 怎么实现组件间共享状态?Zustand store 本质是一个模块级闭包,所有引用同一 useStore 的组件共享同一个状态引用,不依赖 React Context 传递。create() 返回的 hook 能在组件外使用吗?可以。useStore.getState() 获取快照、useStore.setState() 更新状态,均可在非组件代码(如 axios 拦截器、WebSocket 回调)中使用,这是相比 useContext 的显著优势。多 store 还是单 store?Zustand 不限制,实践中按领域拆分多个 store 更常见,避免单个 store 臃肿,也便于按需加载和测试。写段代码import { create } from 'zustand'const useStore = create((set) => ({ count: 0, inc: () => set((s) => ({ count: s.count + 1 })),}))function Counter() { const count = useStore((s) => s.count) return <button onClick={useStore.getState().inc}>{count}</button>}
服务端阅读 05月29日 01:22

React Query 性能优化的常见瓶颈和解决方案有哪些?

React Query 最大性能陷阱是组件过度渲染:query 数据变化时所有订阅组件都重渲染。解法是用 select 提取组件关心的子字段,select 返回值用浅比较去重,相等则跳过渲染。第二陷阱是缓存策略不当:staleTime 控制数据何时标记为过期触发重新请求(默认0即立即过期),gcTime(v5 前叫 cacheTime)控制未使用的缓存何时被垃圾回收(默认5分钟)。不常变的数据应设较长 staleTime 避免无谓请求。第三是 queryKey 设计:key 变化就触发新请求,key 中包含频繁变化的值(如时间戳)会导致缓存失效。queryKey 应稳定且分层:['users', 'list', { status: 'active' }]。追问staleTime 和 gcTime 有什么区别?staleTime 决定数据是否需要重新获取(过期前用缓存,过期后下次挂载或窗口聚焦时 refetch);gcTime 决定缓存数据在内存中保留多久,观察者归零后开始倒计时,到期彻底删除。一个管新鲜度,一个管生命周期。select 怎么避免不必要的渲染?select 每次查询都会执行,但只有返回值与上次浅比较不同时才触发渲染。返回新对象/数组每次都是新引用会失效,需确保返回原始值或用结构化分享的子集。useInfiniteQuery 如何优化?每页数据独立缓存,默认所有页面变化都触发渲染。可用 select 只取当前视口需要的数据;配合虚拟滚动(如 react-virtual)避免渲染长列表;getNextPageParam 返回 undefined 自动停止加载。如何实现乐观更新?用 useMutation 的 onMutate 在请求发出前乐观修改缓存(queryClient.setQueryData),onError 时用 onMutate 保存的快照回滚,onSettled 时 invalidate 相关 query 保证最终一致。写段代码// select + staleTime 减少渲染和请求const { data: name } = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id), staleTime: 5 * 60 * 1000, select: (data) => data.name,});
服务端阅读 05月29日 01:20

React Query 的缓存机制是如何工作的,如何配置和管理缓存?

React Query 缓存的核心是两个时间参数:staleTime 决定数据何时被标记为"过期"(默认 0,即立即过期),gcTime(原 cacheTime)决定数据何时被垃圾回收(默认 5 分钟)。数据在 staleTime 内不会重新请求,但窗口聚焦时会触发 refetch;超过 gcTime 且无观察者的查询会被从缓存清除。管理缓存的三个关键 API:queryClient.invalidateQueries() 标记失效并触发重新获取、queryClient.setQueryData() 直接更新缓存数据、queryClient.removeQueries() 彻底移除缓存条目。对于需要持久化的场景,可借助 persistQueryClient 将缓存写入 localStorage。追问staleTime 设为 Infinity 意味着什么?什么场景下合理?invalidateQueries 和 resetQueries 的区别是什么?如何利用 queryKey 层级结构实现局部失效(如只刷新某个用户下的 todo 列表)?窗口聚焦 refetch 在移动端或 Electron 中表现如何?如何关闭?持久化缓存到 localStorage 时如何处理敏感数据和版本不一致问题?写段代码const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60_000, gcTime: 30 * 60_000 } }})// mutation 成功后局部失效const mu = useMutation(updateTodo, { onSuccess: () => queryClient.invalidateQueries(['todos'])})
前端阅读 495月28日 03:32

React 组件抽离公共逻辑代码有哪些方式?

React 逻辑复用经历了三代方案的演进:Mixin → HOC / Render Props → Hooks。Mixin 已随 Class 组件淘汰,当前面试重点在后面三种。HOC(高阶组件)函数接受一个组件,返回增强后的新组件:function withAuth(WrappedComponent) { return function AuthComponent(props) { const isAuthenticated = checkAuth(); return isAuthenticated ? <WrappedComponent {...props} /> : <Navigate to="/login" />; };}// 使用const ProtectedPage = withAuth(Dashboard);核心问题:Wrapper Hell:多层 HOC 嵌套后,DevTools 里组件树极深,调试困难Props 来源不透明:<WrappedComponent {...props} /> 透传的 props 来自哪里不直观,容易命名冲突静态方法丢失:HOC 返回新组件,原组件的静态方法不会自动复制,需要 hoist-non-react-statics 手动提升Ref 丢失:ref 不属于 props,会被绑定到外层 HOC 组件而非原组件,需配合 React.forwardRef 转发Render Props组件接受一个返回 React 元素的函数 prop,由该函数决定渲染内容:function Mouse({ render }) { const [pos, setPos] = useState({ x: 0, y: 0 }); useEffect(() => { const handler = (e) => setPos({ x: e.clientX, y: e.clientY }); window.addEventListener(mousemove, handler); return () => window.removeEventListener(mousemove, handler); }, []); return render(pos);}// 使用<Mouse render={pos => <Cursor pos={pos} />} />核心问题:嵌套地狱:多个 Render Props 嵌套时,回调层级极深,可读性急剧下降性能隐患:每次父组件渲染,render 函数都会重新创建,导致子组件不必要的重渲染,需要额外做 useCallback 优化Hooks(推荐)在函数组件内调用自定义 Hook,逻辑与 UI 完全分离,无组件层级嵌套:function useAuth() { const [user, setUser] = useState(null); useEffect(() => { const unsub = onAuthStateChanged(setUser); return unsub; }, []); return user;}// 使用function Dashboard() { const user = useAuth(); if (!user) return <Navigate to="/login" />; return <main>...</main>;}Hooks 的注意事项:不能在条件语句、循环或嵌套函数中调用——React 依靠调用顺序匹配 Fiber 链表上的 Hook 节点闭包陷阱:useEffect 内部如果引用了 state 但未加入依赖数组,回调中捕获的始终是旧值,需用 useRef 或函数式更新 setState(prev => prev + 1) 解决三种方案对比| 维度 | HOC | Render Props | Hooks ||------|-----|-------------|-------|| 组件嵌套 | 多层包裹 | 回调嵌套 | 无嵌套 || Props 透明度 | 来源不透明 | 显式传递 | 显式调用 || 类型推导 | 困难(泛型丢失) | 较好 | 好 || 适用场景 | 旧代码维护、Class 组件 | 旧代码维护 | 新代码首选 |三种方式的核心思想一致——把可复用逻辑从 UI 中分离。Hooks 胜在零组件嵌套、逻辑内聚、类型友好,是当前最佳实践。追问为什么 Hooks 不能放在条件语句里?React 用 Fiber 节点上的链表结构存储 Hook 状态。每次渲染时,Hook 按调用顺序依次匹配链表上的节点。如果某个 Hook 在某次渲染被跳过,后续 Hook 就会错位匹配到前一个 Hook 的状态节点,导致状态混乱。这是 React 内部实现机制决定的,而非 API 设计限制。HOC 还用在哪些场景?React.memo(性能优化,浅比较 props)connect(mapStateToProps, mapDispatchToProps)(Redux v5 以前)withRouter(React Router v5)权限控制:withAuth(ProtectedComponent)日志/埋点:withTracker(InteractiveComponent)如何把 Class 组件中的 HOC 迁移到 Hooks?| HOC 模式 | Hooks 替代 ||----------|-----------|| withRouter | useNavigate() + useLocation() + useParams() || connect() | useSelector() + useDispatch() || withAuth | 自定义 useAuth() || withTracker | 自定义 useTracker() + useEffect || 通用 HOC | 自定义 Hook + 组件内直接调用 |Hooks 有哪些常见陷阱?闭包陷阱:useEffect 中引用了 state 但依赖数组遗漏,回调拿到旧值。用 useRef 存最新值或函数式更新解决无限渲染:useEffect 依赖项传入每次新建的对象/数组引用,用 useMemo 稳定引用依赖缺失:遗漏依赖导致 effect 不按预期执行,启用 eslint-plugin-react-hooks 的 exhaustive-deps 规则自动检查
前端阅读 335月28日 03:31

React setState 是同步还是异步?原理是什么?

setState 并非真正“异步”——它是批量延迟执行。调用 setState 时,React 把更新对象推入当前 Fiber 节点的 updateQueue(环形链表),然后调度一次重新渲染,而不是立即修改 state 和触发 DOM 更新。等调度机制在下一个工作单元执行时,才遍历 updateQueue 计算新 state 并渲染。批量更新的核心逻辑:同一个事件循环内的多次 setState,只会产生一次渲染调度。合并发生在遍历 updateQueue 阶段——React 依次执行每个 update 的计算函数,得到最终 state,再进入 render。// 直接值更新:三次调用,updateQueue 里三个 update// 但都基于同一个闭包中的 count,最终 count = 0 + 1 = 1setCount(count + 1);setCount(count + 1);setCount(count + 1);// 函数式更新:每个 update 拿到前一个 update 的结果// count = ((0+1)+1)+1 = 3setCount(c => c + 1);setCount(c => c + 1);setCount(c => c + 1);追问setState 是同步还是异步?都不是。它本质是同步入队 + 延迟渲染。React 17 中,事件处理器外(setTimeout、原生事件)没有批量机制,setState 后能立即在同步代码中读到新值,看起来像“同步”——但这不是真正的同步,只是批量边界不同。React 18 用 createRoot 统一了所有场景的批处理。React 18 自动批处理的原理是什么?React 18 引入新调度入口 scheduleUpdateOnFiber,替代了原来的 enqueueSetState。无论更新来自事件处理器、setTimeout 还是 Promise,都走同一条调度路径。内部用 lane 模型(替代 expiration time)管理优先级,Scheduler 模块按优先级安排回调执行时机,实现所有场景统一批处理。什么情况下 setState 后组件不会重新渲染?bailout 机制:React 在渲染前会比较新旧 state(浅比较)。如果值没变,直接跳过该组件的渲染。常见陷阱——setState(obj) 传入同一个引用,浅比较相等,不会触发更新。必须创建新对象:setState({...obj, key: newVal})。函数式更新什么场景必须用?依赖前一个 state 时必须用。典型场景:计数器、队列操作(往数组追加元素)。函数式更新能保证每次拿到的都是链表中上一个 update 计算后的最新值,而不是闭包捕获的旧值。