React面试宝典【Part8-1:经典面试题】

2026-08-03 React,面试

项目 React 渲染卡顿:标准排查 + 逐阶段优化方案

一、第一步:先定位“哪里卡”

1. 打开浏览器 Performance 面板录制

  • 看是否有 Long Task(长任务 > 50ms)
  • Scripting 耗时是否异常高
  • 看是否有 频繁 Layout Shift / 大量重排重绘

2. 打开 React DevTools → Profiler

  • 开启 Record
  • 操作卡顿场景
    看三件事:
    1. 哪些组件渲染了
    2. 渲染耗时多少
    3. 为什么渲染(Why did this render?)

这一步直接定位:

  • 某个组件疯狂重渲染
  • 列表渲染过多
  • 大计算阻塞主线程

3. 看控制台是否有大量重复 log
快速判断是否无限渲染 / 死循环渲染

二、第二步:按优先级排查最常见 4 大原因

1)无效重渲染(最常见)
父组件一渲染,所有子组件全跟着跑
表现:Profiler 一片红,组件疯狂 render

排查点:

  • 组件是否包 memo
  • 传递的 props 是否是对象/函数字面量
    • onClick={() => {}}
    • style={{}}
    • data={[...]}
  • 状态是否放在过高层级(不该放 App 级的放了 App)
  • Context 滥用,一个 value 变全组件刷新

2)长列表 / 大量 DOM
表现:滚动卡、切换卡

排查:

  • 列表是否超过 50 条还直接全渲染
  • 是否有图片、复杂组件在列表里

3)同步 heavy 计算
表现:点击后卡死几秒,JS 阻塞主线程

排查:

  • 渲染时做了大量计算:filter、sort、deepClone、递归
  • useEffect 里 heavy 计算
  • 大量 JSON 解析、大列表处理

4)Effect 死循环 / 无限重渲染
表现:CPU 持续 100%,控制台 log 刷屏

原因:

  • useEffect 依赖项写得不对
  • setState → effect → setState 循环

三、第三步:对应逐一优化

1. 解决无效重渲染(最有效)

  1. 子组件包 React.memo
  2. 函数用 useCallback 包裹
  3. 对象/数组用 useMemo
  4. 不传递字面量对象/函数给 memo 组件
  5. 状态下放:谁用谁定义,不要全放顶层
  6. Context 拆分:避免一个状态变全应用刷新
  7. 状态管理改用细粒度方案:Zustand / Jotai

2. 长列表卡顿

  1. 虚拟列表(react-window / react-virtualized)
    只渲染可视区域 10~20 条
  2. 列表项包 memo
  3. 图片懒加载
  4. 减少列表项内复杂计算与 DOM

3. Heavy 计算阻塞

  1. 计算逻辑用 useMemo 缓存
  2. 大计算放到 Web Worker
  3. 使用 React 并发特性
    • startTransition
    • useDeferredValue
      让渲染可中断,不阻塞输入/点击

4. 消除 Effect 循环

  1. 修复依赖项
  2. 不要在 useEffect 里无脑 setState
  3. 用 ref 跳过不必要更新

5. 其他通用优化

  • 移除 dev 环境、严格模式,生产环境验证
  • 大组件拆小
  • 避免内联样式、频繁修改 style
  • 避免频繁操作 DOM

四、第四步:验证优化效果

  • 重新跑 Performance
  • 重新跑 React Profiler
  • Long Task 消失
  • 组件渲染次数大幅下降
  • 滚动/交互流畅

五、总结

我会先通过 Performance 定位长任务,再用 React Profiler 定位过度渲染组件,然后按优先级处理:

  1. 减少无效重渲染:memo、useCallback、useMemo、状态下放、拆分 Context
  2. 长列表使用虚拟滚动
  3. 耗时计算用 useMemo 或 Web Worker
  4. 用 startTransition 做并发调度
  5. 修复 effect 循环依赖
    最后通过工具验证卡顿是否消失。

如何设计大型表单 + 性能优化

一、大型表单怎么设计组件?

1. 分层拆分(核心原则:单一职责)

  • 表单容器组件:负责整体结构、提交、重置、校验、数据流转
  • 区块组件:按业务拆分组(基础信息 / 联系人 / 账户 / 附件…)
  • 表单项组件:Input、Select、Radio、Date 等封装成统一受控组件
  • 公共组件:上传、富文本、级联选择、动态行(增删行)

结构示例:

FormPage(容器)
├── BasicBlock(基础区块)
│   ├── FormItem(姓名)
│   └── FormItem(性别)
├── ContactBlock(联系信息区块)
└── SubmitBar(提交/重置)

2. 统一数据结构

  • 整个表单使用 单层/分层对象 管理
  • 统一 name 映射字段:user.nameuser.phone
  • 方便回填、提交、重置

3. 统一受控逻辑

封装一个 useForm 核心 Hook,提供:

  • formData
  • setFormItem 修改单个字段
  • setFields 批量修改
  • validate 校验
  • reset 重置

4. 校验设计

  • 字段校验:必填、格式、长度、异步校验(用户名重复)
  • 区块校验:按区块分步提交/分步校验
  • 提交前全量校验
  • 错误信息统一展示

5. 交互体验

  • 分步表单(Step Form),避免一屏超长
  • 自动聚焦、tab 切换
  • 失焦校验 / 输入防抖校验
  • 草稿自动保存(localStorage / 接口)
  • 错误滚动定位

二、大型表单性能优化

1. 阻止全表单重渲染(重点)

问题:改一个输入框 → 整个表单所有组件重新渲染

优化方案

  1. 表单区块组件用 React.memo 包裹
  2. 修改字段的方法用 useCallback 缓存
  3. 不直接传递整个 formData 给子组件
    只传对应区块数据:basicDatacontactData
// 父组件
const basicData = useMemo(() => ({
  name: formData.name,
  age: formData.age
}), [formData.name, formData.age])

<BasicBlock
  data={basicData}
  onChange={onFieldChange}
/>

2. 防抖输入(避免高频 setState)

搜索/输入联想/实时格式化:

  • 使用 useDebounce 延迟更新状态
  • 只在停止输入 300ms 后触发 setState & 校验

3. 按需校验,不要全量校验

  • 输入时:只校验当前字段
  • 离开焦点时:校验当前字段
  • 提交时:才全量校验
    避免每次输入都跑一遍所有规则。

4. 动态表单项(增删行)优化

  • 每行用 memo
  • 行内事件用 useCallback
  • 不使用索引作为 key
  • 大量行使用虚拟列表

5. 避免内联对象/函数/样式

下面这些都会导致 memo 失效:

// 坏
<Input style={{ margin: 10 }} />
<Button onClick={() => submit()} />

// 好
const style = useMemo(() => ({ margin:10 }), [])
const handleSubmit = useCallback(() => submit(), [])

6. 分步加载 / 懒渲染

  • Step 表单:只渲染当前步骤
  • 折叠面板:收起的区块不渲染
  • 复杂组件(富文本、上传)按需加载 React.lazy

7. 避免在 render 中做 heavy 计算

  • 数据格式化、选项过滤用 useMemo
  • 级联选择预处理数据
  • 不循环生成大量 DOM

8. 关闭不必要的重新渲染

  • 使用 不可变更新,不修改原对象
  • 使用 Jotai/Valtio/Zustand 做细粒度表单状态(改谁谁渲染)
  • 自己实现原子化表单,每个字段独立更新,不扩散

9. 草稿自动保存优化

  • 防抖保存(5s 一次)
  • 只保存变化部分
  • 卸载/关闭页面前一次保存

10. 生产环境检查

  • 开发环境严格模式会导致重复渲染
  • 打包后再评估真实性能

三、总结

大型表单先按业务区块做组件拆分,统一用 useForm 管理数据与校验;
性能上核心是避免全表单重渲染,通过 memo 拆分区块、useCallback 稳定方法、useMemo 隔离数据,配合输入防抖、按需校验、分步渲染,即可保证超流畅体验。


如何实现组件缓存(类似 Vue keep-alive)?

React 没有内置 keep-alive,但可以通过三种方式实现组件缓存:

  1. CSS display: none / 隐藏(最简单)
  2. 状态控制挂载/卸载 + 组件实例保留
  3. 第三方库 react-keep-alive / react-activation(生产标准)

一、最简单实现:隐藏式缓存(display:none)

原理:组件不卸载,只隐藏,状态 & DOM 全部保留。

import { useState } from 'react';

function KeepAliveDemo() {
  const [tab, setTab] = useState('A');

  return (
    <div>
      <button onClick={() => setTab('A')}>A</button>
      <button onClick={() => setTab('B')}>B</button>

      <div style={{ display: tab === 'A' ? 'block' : 'none' }}>
        <ComponentA /> {/* 切换走也不卸载 */}
      </div>
      <div style={{ display: tab === 'B' ? 'block' : 'none' }}>
        <ComponentB />
      </div>
    </div>
  );
}

function ComponentA() {
  const [count, setCount] = useState(0);
  console.log('A 渲染/挂载');
  return <div>组件A count: {count} <button onClick={() => setCount(count+1)}>+</button></div>;
}

function ComponentB() {
  console.log('B 渲染/挂载');
  return <div>组件B</div>;
}

优点:超简单、状态完全保留
缺点:DOM 一直存在,多页面会越来越卡

二、标准路由级缓存(纯 React Router 标签页缓存)

思路:

  • 组件不卸载,只根据 path 显示/隐藏
  • 适合后台管理系统 Tab 页缓存
import { useState, useEffect } from 'react';
import {
  HashRouter,
  Routes,
  Route,
  Link,
  useLocation,
  Navigate
} from 'react-router-dom';

// 页面组件
function PageA() {
  const [count, setCount] = useState(0);
  console.log('✅ PageA 渲染');
  return (
    <div style={{ padding: 20 }}>
      <h2>Page A</h2>
      <p>计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

function PageB() {
  const [text, setText] = useState('');
  console.log('✅ PageB 渲染');
  return (
    <div style={{ padding: 20 }}>
      <h2>Page B</h2>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="输入测试缓存"
      />
    </div>
  );
}

// 缓存路由容器
function KeepAliveRouter() {
  const location = useLocation();
  const [activePath, setActivePath] = useState(location.pathname);
  const [cachedPaths, setCachedPaths] = useState([location.pathname]);

  // 监听路由变化,自动加入缓存
  useEffect(() => {
    const currentPath = location.pathname;
    setActivePath(currentPath);

    if (!cachedPaths.includes(currentPath)) {
      setCachedPaths(prev => [...prev, currentPath]);
    }
  }, [location.pathname]);

  return (
    <div>
      {/* 标签栏 */}
      <div style={{ padding: '0 20px', background: '#f5f5f5' }}>
        {cachedPaths.map((path) => (
          <button
            key={path}
            onClick={() => setActivePath(path)}
            style={{
              padding: '8px 16px',
              margin: '0 8px 0 0',
              background: activePath === path ? '#1677ff' : '#fff',
              color: activePath === path ? '#fff' : '#000',
              border: '1px solid #ddd',
              cursor: 'pointer'
            }}
          >
            {path}
          </button>
        ))}
      </div>

      {/* 缓存内容区域 */}
      <div style={{ padding: 20 }}>
        {cachedPaths.map((path) => (
          <div
            key={path}
            style={{ display: activePath === path ? 'block' : 'none' }}
          >
            {path === '/' && <Navigate to="/a" />}
            {path === '/a' && <PageA />}
            {path === '/b' && <PageB />}
          </div>
        ))}
      </div>
    </div>
  );
}

// 入口
export default function App() {
  return (
    <HashRouter>
      <div style={{ padding: 10 }}>
        <Link to="/a" style={{ marginRight: 15 }}>Page A</Link>
        <Link to="/b">Page B</Link>
      </div>

      <Routes>
        <Route path="*" element={<KeepAliveRouter />} />
      </Routes>
    </HashRouter>
  );
}

三、生产级方案:react-activation(最接近 Vue keep-alive)

最成熟、社区公认 React keep-alive 方案

1. 安装

这个依赖有严重的版本匹配问题,请参考以下配置进行

依赖版本(package.json 关键字段)

{
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.20.0",
    "react-activation": "0.12.4",
    "react-scripts": "5.0.1"
  }
}

确保安装以上精确版本或兼容版本。react-activation 必须为 0.12.4,因为其导出方式为 export default KeepAlive

src/main.jsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

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

src/App.jsx — 根组件(路由与缓存配置)

import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import KeepAlive from 'react-activation';          // 默认导出
import { AliveScope } from 'react-activation';    // 具名导出

import Home from './pages/Home';
import PageA from './pages/PageA';
import PageB from './pages/PageB';

export default function App() {
  return (
    <AliveScope>
      <BrowserRouter>
        <nav style={{ marginBottom: 20 }}>
          <Link to="/">Home</Link> |{' '}
          <Link to="/a">PageA (keep-alive)</Link> |{' '}
          <Link to="/b">PageB (keep-alive)</Link>
        </nav>

        <Routes>
          <Route path="/" element={<Home />} />
          <Route
            path="/a"
            element={
              <KeepAlive name="PageA" key="PageA">
                <PageA />
              </KeepAlive>
            }
          />
          <Route
            path="/b"
            element={
              <KeepAlive name="PageB" key="PageB">
                <PageB />
              </KeepAlive>
            }
          />
        </Routes>
      </BrowserRouter>
    </AliveScope>
  );
}

src/pages/Home.jsx — 普通页面(不缓存)

export default function Home() {
  return <h2>首页 - 每次进入都会重新渲染</h2>;
}

src/pages/PageA.jsx — 被缓存的页面 A

import { useState } from 'react';

export default function PageA() {
  const [count, setCount] = useState(0);

  return (
    <div style={{ border: '2px solid green', padding: 10 }}>
      <h2>页面 A - 状态已缓存</h2>
      <p>计数器:{count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

src/pages/PageB.jsx — 被缓存的页面 B

import { useState } from 'react';

export default function PageB() {
  const [count, setCount] = useState(0);

  return (
    <div style={{ border: '2px solid orange', padding: 10 }}>
      <h2>页面 B - 状态已缓存</h2>
      <p>计数器:{count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}
  • Home 页面每次进入都会重新渲染。
  • PageAPageB 之间任意切换,各自的计数器状态都会被保留(类似 Vue 的 <keep-alive>)。
  • 从 Home 切换到 PageA/PageB 时,若先前已有缓存,则直接恢复。

四、底层原理

keep-alive 本质是什么?

组件不执行 unmount,只隐藏,保留 Fiber 节点、state、DOM。

React 为什么没有内置?

  • React 推崇单向数据流、显式控制
  • 保留组件会导致:
    • 内存占用升高
    • 副作用堆积
    • 数据不同步风险
  • 官方认为缓存应该显式控制,而不是黑盒

实现核心

  1. 组件不卸载
  2. 控制显示/隐藏
  3. 保留组件实例 & 状态
  4. 提供生命周期钩子(activated / deactivated)

五、总结

React 没有内置 keep-alive,实现组件缓存主要有三种方式:

  1. 简单场景用 CSS display: none
    组件不卸载,只隐藏,实现最简单,但 DOM 累积会影响性能。
  2. 路由场景手动维护缓存列表
    切换路由时不卸载组件,只控制显示隐藏,适合后台多标签页。
  3. 生产环境使用 react-activation
    功能最完整,和 Vue keep-alive 行为一致,支持缓存、激活、失活、手动销毁。
    底层原理都是保留组件实例不卸载,只控制显示隐藏,从而保留状态与 DOM。