前端解题的 6 个思维模型:比记答案更有用的东西

0 阅读7分钟

这篇文章整理了我在备考过程中归纳出的 6 个前端思维模型。每个模型都有一个"触发信号"——当你在题目里看到这个信号,就知道该用哪个模型了。


模型一:数据分组 → 先想 Map,不要想嵌套循环

触发信号:题目出现"按 X 分组"、"统计每个 X 的 Y"、"多维度聚合"。

为什么 Map 比嵌套循环好

遇到"按部门和月份统计用车金额"这类需求,第一反应可能是写嵌套 reduce——外层按部门分,内层再按月份分。这样写能跑,但有两个问题:代码难读,而且查询时还要嵌套访问 result[dept][month]

更清晰的思路是:把多维 key 拍平成复合字符串,建一张 Map,查的时候 O(1) 直接拿。

// 环境:浏览器 / Node.js
// 场景:按部门 × 月份聚合用车金额

const records = [
  { dept: 'engineering', month: '2024-01', amount: 1200 },
  { dept: 'engineering', month: '2024-01', amount: 800 },
  { dept: 'sales', month: '2024-01', amount: 500 },
  { dept: 'engineering', month: '2024-02', amount: 600 },
];

// ✅ 复合 key + Map:结构清晰,查询 O(1)
function groupByDeptAndMonth(records) {
  const map = new Map();

  for (const record of records) {
    // 复合 key:把两个维度拼成一个字符串
    const key = `${record.dept}|${record.month}`;
    const current = map.get(key) ?? 0;
    map.set(key, current + record.amount);
  }

  return map;
}

const result = groupByDeptAndMonth(records);
// 查询:O(1) 直接拿
console.log(result.get('engineering|2024-01')); // 2000
console.log(result.get('sales|2024-01'));        // 500

这个模型的延伸

复合 key 不只适用于两个维度,三个维度同样有效:${dept}|${month}|${project}。分隔符选一个不会出现在值里的字符就行,| 是常见选择。

当维度更复杂、需要频繁更新或删除时,可以考虑嵌套 Map(Map<string, Map<string, number>>)——但要警惕过度设计,大多数聚合场景用复合 key 的扁平 Map 就够了。


模型二:状态机 → 用数据表替代 if/else

触发信号:题目出现"不同状态下,不同角色有不同权限/行为"、"状态流转"。

条件爆炸的根源

审批流的权限判断经常写成这样:

// 环境:浏览器
// 场景:❌ if/else 地狱,每新增一个状态或角色都要改多处

function getActions(status, role) {
  if (status === 'pending') {
    if (role === 'manager') return ['approve', 'reject'];
    if (role === 'employee') return ['cancel'];
    return [];
  }
  if (status === 'approved') {
    if (role === 'finance') return ['pay'];
    return [];
  }
  // 每新增一个 status 或 role,这里就要再加分支...
}

三个状态 × 三个角色 = 9 种组合,代码已经开始难以维护。加到五个状态 × 五个角色,就是 25 种组合散落在代码里。

把矩阵写成数据表

// 环境:浏览器
// 场景:✅ 状态机表格,新增状态或角色只需修改数据,不改逻辑

const ACTION_TABLE = {
  pending: {
    manager:  ['approve', 'reject'],
    employee: ['cancel'],
    finance:  [],
  },
  approved: {
    manager:  ['revoke'],
    employee: [],
    finance:  ['pay'],
  },
  rejected: {
    manager:  [],
    employee: ['resubmit'],
    finance:  [],
  },
};

// 查询逻辑只有一行,永远不需要改
function getActions(status, role) {
  return ACTION_TABLE[status]?.[role] ?? [];
}

// 判断某个操作是否允许
function canPerform(status, role, action) {
  return getActions(status, role).includes(action);
}

console.log(getActions('pending', 'manager'));       // ['approve', 'reject']
console.log(canPerform('approved', 'finance', 'pay')); // true

新增一个状态? 在表格里加一行。新增一个角色? 在每个状态里加一列。逻辑代码完全不用动。

这个思路来自"数据与逻辑分离"的设计原则:让数据承载变化,让逻辑保持稳定。 条件越多、变化越频繁的场景,这个收益越明显。


模型三:跨 render 的可变值 → useRef,不是 useState

触发信号:题目出现"取消上一个请求"、"停止轮询"、"计时器 id"、"上一次的值"。

一个判断标准

React 里保存数据有两种方式,判断用哪个只需要问一个问题:

这个值变化时,需要触发 UI 重新渲染吗?

需要 → useState。不需要 → useRef

// 环境:浏览器(React)
// 场景:轮询实现,用 useRef 保存 timer id

function usePolling(fetchFn, interval = 5000) {
  const timerRef = useRef(null);  // timer id 变化不需要重渲染 → useRef
  const [data, setData] = useState(null); // data 变化需要更新 UI → useState

  const start = useCallback(() => {
    // 先停止已有的轮询,防止重复启动
    if (timerRef.current) clearInterval(timerRef.current);

    timerRef.current = setInterval(async () => {
      const result = await fetchFn();
      setData(result); // 更新数据 → 触发重渲染
    }, interval);
  }, [fetchFn, interval]);

  const stop = useCallback(() => {
    clearInterval(timerRef.current);
    timerRef.current = null;
  }, []);

  useEffect(() => {
    start();
    return stop; // 组件卸载时停止轮询
  }, [start, stop]);

  return { data, stop };
}

useRef 的另一个常见用途:保存上一次的值

// 环境:浏览器(React)
// 场景:用 useRef 保存上一次请求的 AbortController,实现取消上一个请求

function useSearchWithCancel(query) {
  const abortRef = useRef(null); // 保存 AbortController,不需要触发渲染
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query) return;

    // 取消上一个还未完成的请求
    abortRef.current?.abort();

    // 创建新的 AbortController
    const controller = new AbortController();
    abortRef.current = controller;

    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then((res) => res.json())
      .then(setResults)
      .catch((err) => {
        // AbortError 是主动取消,不是错误,静默处理
        if (err.name !== 'AbortError') console.error(err);
      });

    return () => controller.abort(); // 组件卸载或 query 变化时取消
  }, [query]);

  return results;
}

模型四:异步副作用 → cleanup 函数是你的安全网

触发信号:题目出现轮询、防抖、WebSocket 订阅、事件监听。

对称原则

useEffect 的 cleanup 函数有一个我觉得很好用的记忆法:

你在 useEffect 里启动了什么,就在 cleanup 里停止什么。

// 环境:浏览器(React)
// 场景:cleanup 的对称性示意

useEffect(() => {
  // 启动了定时器
  const timer = setInterval(tick, 1000);
  // cleanup:停止定时器
  return () => clearInterval(timer);
}, []);

useEffect(() => {
  // 启动了事件监听
  window.addEventListener('resize', handleResize);
  // cleanup:移除监听
  return () => window.removeEventListener('resize', handleResize);
}, []);

useEffect(() => {
  // 启动了 WebSocket 连接
  const ws = new WebSocket(url);
  ws.onmessage = handleMessage;
  // cleanup:关闭连接
  return () => ws.close();
}, [url]);

防抖的 cleanup 是一个常见考点

// 环境:浏览器(React)
// 场景:搜索框防抖,cleanup 清除未触发的 timer

function SearchBox({ onSearch }) {
  const [query, setQuery] = useState('');

  useEffect(() => {
    if (!query) return;

    // 用户停止输入 500ms 后才发请求
    const timer = setTimeout(() => {
      onSearch(query);
    }, 500);

    // 关键:query 变化时(用户继续输入),清除上一个 timer
    // 组件卸载时,也清除未触发的 timer
    return () => clearTimeout(timer);
  }, [query, onSearch]);

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="搜索..."
    />
  );
}

没有 cleanup 的防抖实现,在组件快速卸载后会触发"在已卸载组件上调用 setState"的警告,严重时会造成内存泄漏。


模型五:批量异步 → Promise.all vs Promise.allSettled

触发信号:题目出现"批量操作"、"即使部分失败也继续"、"需要所有都成功"。

一个问题区分两者

允不允许部分失败?

不允许(一个失败就停止)→ Promise.all。 允许(每条都有结果,失败的也要收集)→ Promise.allSettled

// 环境:浏览器 / Node.js
// 场景:批量审批订单,允许部分失败,收集每条的结果

async function batchApprove(orderIds) {
  const tasks = orderIds.map((id) =>
    approveOrder(id)
      .then(() => ({ id, status: 'success' }))
      .catch((err) => ({ id, status: 'failed', reason: err.message }))
  );

  // allSettled:等所有任务都完成(无论成功或失败)
  const results = await Promise.allSettled(tasks);

  // 每个结果的 status 是 'fulfilled' 或 'rejected'
  const succeeded = results
    .filter((r) => r.status === 'fulfilled')
    .map((r) => r.value);

  const failed = results
    .filter((r) => r.status === 'rejected')
    .map((r) => r.reason);

  return { succeeded, failed };
}
// 环境:浏览器 / Node.js
// 场景:页面初始化,需要同时拿到用户信息、权限列表、配置项
// 任何一个失败都无法正常渲染,用 Promise.all

async function initPage() {
  try {
    // 三个请求并发,任意一个失败就抛错,跳到 catch
    const [user, permissions, config] = await Promise.all([
      fetchUser(),
      fetchPermissions(),
      fetchConfig(),
    ]);
    return { user, permissions, config };
  } catch (error) {
    // 任意一个失败,整体初始化失败
    showErrorPage(error);
  }
}

一个容易忽视的细节

Promise.allSettled 的每个结果对象里,成功的是 { status: 'fulfilled', value: ... },失败的是 { status: 'rejected', reason: ... }。注意是 valuereason,不是统一的 result——这个细节在写代码时容易出错。


模型六:递归组件 → 关注点分离

触发信号:题目出现树形结构、嵌套列表、部门组织架构。

递归组件的两个关键决策

写递归组件之前,需要先想清楚两件事:

  1. 展开状态存在哪里? 存在父组件(集中管理),还是每个节点自己管?
  2. 叶子节点和非叶子节点的渲染逻辑分不分开?

一般来说,展开状态提升到父组件,用 Set<id> 管理哪些节点是展开的,子组件只负责渲染,不持有状态——这样更容易测试和扩展(比如"全部展开/折叠"功能只需要操作父组件的 Set)。

// 环境:浏览器(React)
// 场景:部门组织架构树,展开状态集中在父组件管理

// 数据结构
const deptTree = [
  {
    id: 'dept_01',
    name: 'Engineering',
    children: [
      { id: 'dept_01_01', name: 'Frontend', children: [] },
      { id: 'dept_01_02', name: 'Backend', children: [] },
    ],
  },
  {
    id: 'dept_02',
    name: 'Sales',
    children: [
      { id: 'dept_02_01', name: 'Domestic', children: [] },
    ],
  },
];

// 父组件:持有展开状态
function DeptTree({ data }) {
  // 用 Set 存储展开的节点 id,O(1) 查询
  const [expandedIds, setExpandedIds] = useState(new Set(['dept_01']));

  const toggle = useCallback((id) => {
    setExpandedIds((prev) => {
      const next = new Set(prev);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });
  }, []);

  // 全部展开/折叠
  const expandAll = () => {
    const allIds = getAllIds(data); // 递归收集所有 id
    setExpandedIds(new Set(allIds));
  };

  return (
    <div>
      <button onClick={expandAll}>全部展开</button>
      <button onClick={() => setExpandedIds(new Set())}>全部折叠</button>
      {data.map((node) => (
        <DeptNode
          key={node.id}
          node={node}
          expandedIds={expandedIds}
          onToggle={toggle}
        />
      ))}
    </div>
  );
}

// 递归节点:只负责渲染,不持有状态
function DeptNode({ node, expandedIds, onToggle }) {
  const isExpanded = expandedIds.has(node.id);
  const hasChildren = node.children.length > 0;

  return (
    <div style={{ marginLeft: 16 }}>
      <div onClick={() => hasChildren && onToggle(node.id)}>
        {hasChildren && <span>{isExpanded ? '▼' : '▶'}</span>}
        {node.name}
      </div>

      {/* 递归渲染子节点,展开时才渲染 */}
      {isExpanded && hasChildren && (
        <div>
          {node.children.map((child) => (
            <DeptNode
              key={child.id}
              node={child}
              expandedIds={expandedIds}
              onToggle={onToggle}
            />
          ))}
        </div>
      )}
    </div>
  );
}

递归组件的性能注意点

onToggleuseCallback 包裹,配合 React.memo 可以避免树形结构里每次展开/折叠都重渲染所有节点。树的节点数越多,这个优化越重要。


六个模型的触发信号汇总

题目出现 "按 X 分组" / "统计每个 X 的 Y"
  → 模型一:复合 key + Map

题目出现 "不同状态 × 不同角色的权限/行为"
  → 模型二:状态机数据表

题目出现 "取消" / "停止" / "计时器 id" / "上一个请求"
  → 模型三:useRef 保存可变值

题目出现 "轮询" / "防抖" / "WebSocket" / "事件监听"
  → 模型四:cleanup 对称原则

题目出现 "批量操作" / "允许部分失败"
  → 模型五:Promise.allSettled vs Promise.all

题目出现 "树形结构" / "嵌套列表" / "组织架构"
  → 模型六:状态提升 + 递归组件

延伸与发散

整理这 6 个模型之后,我开始思考一个更大的问题:为什么我之前没有主动归纳模型?

一个可能的原因是:学技术的时候容易聚焦在"这道题怎么做",而不是"这类题有什么共同结构"。前者是解题,后者是建模。解题能过当前这道题,建模能过同类型的所有题。

这让我想到一个类比:建筑设计里有"设计模式"的概念——不是告诉你某栋楼怎么画,而是告诉你"这类空间问题有这几种解法"。前端的思维模型大概也是同样的东西。

还有几个模型我觉得同样重要,但这篇文章没有覆盖到:

乐观更新:先更新 UI,再发请求,失败时回滚。适合"点赞"、"收藏"这类用户需要即时反馈的操作。

虚拟化渲染:列表超过几百条时,只渲染可视区域的节点。触发信号是"大列表"、"无限滚动"。

请求去重:同一个接口被多个组件同时调用,怎么只发一次请求,把结果共享给所有调用方。SWR 和 React Query 的核心价值之一就是解决这个问题。

这些模型值得单独成文,留作下一篇的素材。


小结

6 个模型不是前端开发的全部,也不是面试的万能钥匙。但它们是我在备考过程中找到的、信噪比最高的一批知识——理解了这 6 个,遇到新题时至少知道从哪里开始想。

这篇文章更多是一个个人学习笔记,整理的过程本身就让我对这些模型理解更深了一层。如果你有其他觉得重要的前端思维模型,欢迎交流。


参考资料