React 进阶

高频版 · 来自面试实战和个人笔记 · 有代码有场景有追问

1 Hooks 深度

Hooks 产生原因与实现原理
React Hooks 产生的主要目的是让函数组件拥有管理状态和副作用的能力,同时简化类组件的复杂性。核心原因:减少类组件样板代码、提高内聚性、逻辑复用更自然(自定义 Hook 替代 HOC/render props)、让函数组件也能管理状态/副作用/引用 DOM。

实现原理:React 内部用链表(memorizedState)存储每个 Hook 的状态。每次组件渲染时按顺序遍历链表,通过调用顺序把 useState/useEffect 对应到同一个状态槽位。useState 存 state 值,useEffect 在 commit 阶段调度,useMemo/useCallback 存 [值/函数, deps]。

function useState(initialValue) {
  const currentHook = hooks[currentHookIndex] || { state: initialValue };
  hooks[currentHookIndex] = currentHook;
  currentHookIndex++;
  return [currentHook.state, setState];
}
Hooks 两条铁律
1. 只能在函数组件 / 自定义 Hook 的顶层调用;2. 不能在条件、循环、嵌套函数里调用。

原因:React 用调用顺序来对应内部状态槽位。若在条件里调用,下次渲染顺序变化会导致状态错乱——例如第一次渲染有 useState1、useState2,第二次条件为 false 时只有 useState2,React 会把第二个 useState 错误地当成 Hook 1。

React 闭包陷阱:setInterval 场景与解法
闭包陷阱 = 某次 render 里创建的函数/副作用捕获了当时的 state/props,之后 UI 更新了,函数里读到的仍是旧快照。React 每次 render 都是新闭包,effect、定时器、事件监听若引用 state 且依赖是 [],会一直用旧值。

最经典:setInterval 里 setCount(count + 1),count 永远是 0。

// ❌ 错误:effect 只跑一次,interval 闭包捕获 count=0
React.useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);  // 永远用 0
  }, 1000);
  return () => clearInterval(id);
}, []);

// ✅ 解法 A:函数式更新(推荐)
React.useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

// ✅ 解法 B:把 count 加进依赖(会频繁重建 interval)
}, [count]);

// ✅ 解法 C:用 ref 存最新值(需读值但不触发渲染时)
const countRef = React.useRef(count);
React.useEffect(() => { countRef.current = count }, [count]);
React.useEffect(() => {
  const id = setInterval(() => console.log(countRef.current), 1000);
  return () => clearInterval(id);
}, []);

口播模板:effect、事件监听、定时器、异步回调里若引用 state/props 会被闭包捕获成旧值。解法:补全依赖让 effect 重跑,或用函数式 setState 读最新值,必要时用 ref 保存最新值。

2 常用 Hooks 进阶

useState 底层与懒初始化
useState 返回 [state, setState]。setState 不是立即生效,可能被批处理。若初始值需计算,传函数给 useState,只在初次渲染时执行一次。
const [state, setState] = useState(() => computeExpensiveInitialState());
useEffect 完整生命周期与 cleanup
useEffect(fn, deps) 表示:渲染后执行 fn;deps 变化时重新执行;若 fn 返回 cleanup,会在下一次 effect 前或卸载时执行。相当于 componentDidMount + componentDidUpdate + componentWillUnmount 的组合。

执行时机:Render(计算)→ Commit(改 DOM)→ Effect(useEffect 异步执行,不阻塞绘制)。deps:[] 只挂载/卸载执行;[a,b] 变化时执行;不传则每次 render 都执行(不推荐)。

useEffect(() => {
  const timer = setInterval(() => {}, 1000);
  return () => clearInterval(timer);  // cleanup
}, []);
useRef 妙用
useRef 保存跨 render 持久存在的可变值,修改不触发 re-render。用途:1. 获取 DOM;2. 存最新值避免闭包;3. 存定时器 ID、防抖 timer;4. 存前一个 state(usePrevious)。
// 防抖:timerRef 存定时器
const timerRef = useRef(null);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => { /* ... */ }, 500);

// 前一个值
const prevCountRef = useRef();
useEffect(() => { prevCountRef.current = count }, [count]);
const prevCount = prevCountRef.current;
useLayoutEffect 与 useEffect 区别
useLayoutEffect 在 DOM 变更后、浏览器重绘前同步执行;useEffect 在绘制后异步执行。useLayoutEffect 会阻塞绘制,适合需在绘制前读 DOM 并同步调整布局、避免画面抖动的场景。
useLayoutEffect(() => {
  const divWidth = divRef.current.getBoundingClientRect().width;
  setWidth(divWidth);  // 绘制前完成,避免闪烁
}, []);
useCallback 防抖失效原因
防抖依赖同一函数实例管理 timer。不用 useCallback 时,每次 render 都创建新的 debFn,新实例无法清除旧实例的 timer,导致防抖失效或多次触发。

正确写法:fetchFn 和 debFn 都用 useCallback 保持引用稳定。防抖函数用 useMemo 记忆实例:const debFn = useMemo(() => debounce(sendVal, 500), [sendVal]);

useMemo 与 useCallback 关系
useCallback(fn, deps) === useMemo(() => fn, deps)。useMemo 缓存计算结果,useCallback 缓存函数引用。本质都是用 deps 控制引用稳定性。
useContext 拆分与避免嵌套地狱
Context 更新会导致所有消费组件 re-render。拆分策略:按领域拆(Theme/User/Locale);同领域切片(UserProfile vs UserPermission);state 与 actions 分离;用 useMemo 稳定 value;用 composeProviders 消灭嵌套。
function composeProviders(providers) {
  return ({ children }) =>
    providers.reduceRight((acc, Provider) => <Provider>{acc}</Provider>, children);
}
const AppProviders = composeProviders([ThemeProvider, UserProvider, LocaleProvider]);

3 性能三件套

React.memo + useMemo + useCallback 搭配策略
React.memo 对 props 浅比较,避免父更新导致子无意义渲染。若传对象/函数,每次 render 都是新引用,memo 失效。需用 useMemo 固定对象、useCallback 固定函数。
const Table = React.memo(function Table({ columns, onRowClick }) { /* ... */ });

function Page() {
  const columns = React.useMemo(() => [
    { key: "name", title: "Name" },
    { key: "age", title: "Age" },
  ], []);
  const onRowClick = React.useCallback((row) => { console.log(row); }, []);
  return <Table columns={columns} onRowClick={onRowClick} />;
}
依赖怎么写?引用稳定性
依赖写「用到的外部变量」。更新 state 时优先用函数式更新避免依赖膨胀。漏依赖会闭包拿旧值。
// ✅ 依赖写全
const fn = useCallback(() => { console.log(userId); }, [userId]);

// ✅ 函数式更新避免依赖
const inc = useCallback(() => setCount(c => c + 1), []);

// ❌ 漏依赖
const fn = useCallback(() => { console.log(count); }, []);
什么时候该用 / 不该用
只有当「引用稳定」能带来收益时才用。该用:传给 React.memo 子组件的 props、传给 useEffect 依赖、大计算量 useMemo、某些库依赖引用稳定。不该用:组件小、子组件没 memo、只为「看起来专业」乱加。

4 Fiber 与 Diff

Fiber 可中断更新
Fiber 解决同步更新阻塞主线程的问题。把耗时任务拆成小单元,每完成一个单元检查剩余时间,没时间就让出控制权给浏览器响应用户交互。Fiber 既是数据结构(链表:child/sibling/return)又是工作单位。

两阶段:Render/Reconciliation(可中断,做 diff、打 flags)→ Commit(不可中断,改 DOM、执行 layout effects)。

三阶段:Render / Commit / Effect
Render:调用组件、计算 Virtual DOM、执行 hooks(useState/useMemo 等),纯计算可中断。Commit:把变更应用到真实 DOM,执行 useLayoutEffect。Effect:commit 后异步执行 useEffect,不阻塞绘制。

Diff 只发生在 Render 阶段;Commit 只负责把 diff 结果应用到 DOM。

Diff 三假设与虚拟 DOM
React diff 基于三大策略:1. tree diff 同级比较,跨层级移动极少;2. component diff 同类型继续比较,不同类型直接替换;3. element diff 用 key 区分同层子节点。复杂度从 O(n³) 降到 O(n)。

虚拟 DOM:轻量 JS 对象树描述 UI 结构。Diff 比较新旧树找出差异,最小化 DOM 操作。列表必须用稳定 key。

5 状态管理选型

Redux 原理与中间件
Redux:单一状态树、不可变、纯函数 reducer。dispatch(action) → reducer 返回新 state。中间件增强 dispatch,在 action 到达 reducer 前插入逻辑。形式:store => next => action => { ... return next(action); }。

redux-thunk 允许 action 返回函数处理异步;redux-saga 用 generator。Redux 因 reducer 必须纯函数,副作用通过中间件处理。

Zustand / Jotai / MobX / React Query 对比
Zustand:轻量、直接改状态、订阅粒度细,适合快速业务共享。Jotai:原子化 atom,粒度极细,适合局部依赖复杂。MobX:响应式,隐式依赖调试难。React Query:server state 标准答案,缓存/重试/失效/乐观更新。
选型原则:UI / Client / Server 状态
UI 状态(modal、tab、input)→ useState/useReducer。Client 状态(登录态、购物车)→ Redux/Zustand/Jotai。Server 状态(接口数据)→ React Query/SWR,不要放 Redux。

Context 适合低频全局配置/依赖注入;Redux 适合高频复杂业务、需可预测和调试。

6 React 18+

批处理与 setState 同步/异步
React 18 前:合成事件和钩子中 setState 表现异步(批处理);Promise、setTimeout、原生事件中同步。React 18 后:自动批处理,所有场景都表现为异步批处理。

「异步」指合成事件/钩子调用顺序在更新之前,导致无法立即拿到更新后的值。可通过 setState 的 callback 或 useEffect 获取。

ErrorBoundary
类组件实现 getDerivedStateFromError + componentDidCatch,捕获子组件树中渲染、生命周期、构造函数中的错误,展示降级 UI。不能捕获:事件处理、异步代码、服务端渲染、自身错误。
class ErrorBoundary extends React.Component {
  state = { hasError: false };
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
  componentDidCatch(error, errorInfo) {
    console.log("Error:", error, errorInfo);
  }
  render() {
    if (this.state.hasError) return <h1>Something went wrong.</h1>;  // JSX
    return this.props.children;
  }
}
受控与非受控组件
受控:value 由 state 控制,onChange 更新 state。非受控:defaultValue,DOM 自己管理。不能混用:value 从 undefined 变为有值会报警告。受控时确保 value 始终有定义(如空字符串)。

7 实战问题

React Router 原理:hash 与 history
通过 history 对象监听 URL 变化,根据路径匹配渲染组件。Hash 模式:用 # 后部分,监听 hashchange,无需服务端配置。History 模式:用 pushState/replaceState,需服务端支持 fallback。

HashRouter 用 hashchange;BrowserRouter 用 History API。

内存泄漏:cleanup 模式与 AbortController
三方面:1. 未完成异步请求—用 isMounted 或 AbortController 取消;2. 定时器—useEffect cleanup 里 clearInterval/clearTimeout;3. 事件订阅—cleanup 里 removeEventListener。
// AbortController 取消 fetch
useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal })
    .then(res => res.json())
    .then(setData)
    .catch(e => { if (e.name !== 'AbortError') throw e; });
  return () => controller.abort();
}, [url]);
React 懒加载
React.lazy(() => import('./Component')) 基于动态 import 返回 Promise。模块在组件首次渲染时加载。配合 Suspense 的 fallback 显示加载中。用于路由按需加载、减少首屏体积。
const About = React.lazy(() => import('./About'));
<Suspense fallback={<div>Loading...</div>}>
  <About />
</Suspense>