这篇文章整理了我在备考过程中归纳出的 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: ... }。注意是 value 和 reason,不是统一的 result——这个细节在写代码时容易出错。
模型六:递归组件 → 关注点分离
触发信号:题目出现树形结构、嵌套列表、部门组织架构。
递归组件的两个关键决策
写递归组件之前,需要先想清楚两件事:
- 展开状态存在哪里? 存在父组件(集中管理),还是每个节点自己管?
- 叶子节点和非叶子节点的渲染逻辑分不分开?
一般来说,展开状态提升到父组件,用 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>
);
}
递归组件的性能注意点
onToggle 用 useCallback 包裹,配合 React.memo 可以避免树形结构里每次展开/折叠都重渲染所有节点。树的节点数越多,这个优化越重要。
六个模型的触发信号汇总
题目出现 "按 X 分组" / "统计每个 X 的 Y"
→ 模型一:复合 key + Map
题目出现 "不同状态 × 不同角色的权限/行为"
→ 模型二:状态机数据表
题目出现 "取消" / "停止" / "计时器 id" / "上一个请求"
→ 模型三:useRef 保存可变值
题目出现 "轮询" / "防抖" / "WebSocket" / "事件监听"
→ 模型四:cleanup 对称原则
题目出现 "批量操作" / "允许部分失败"
→ 模型五:Promise.allSettled vs Promise.all
题目出现 "树形结构" / "嵌套列表" / "组织架构"
→ 模型六:状态提升 + 递归组件
延伸与发散
整理这 6 个模型之后,我开始思考一个更大的问题:为什么我之前没有主动归纳模型?
一个可能的原因是:学技术的时候容易聚焦在"这道题怎么做",而不是"这类题有什么共同结构"。前者是解题,后者是建模。解题能过当前这道题,建模能过同类型的所有题。
这让我想到一个类比:建筑设计里有"设计模式"的概念——不是告诉你某栋楼怎么画,而是告诉你"这类空间问题有这几种解法"。前端的思维模型大概也是同样的东西。
还有几个模型我觉得同样重要,但这篇文章没有覆盖到:
乐观更新:先更新 UI,再发请求,失败时回滚。适合"点赞"、"收藏"这类用户需要即时反馈的操作。
虚拟化渲染:列表超过几百条时,只渲染可视区域的节点。触发信号是"大列表"、"无限滚动"。
请求去重:同一个接口被多个组件同时调用,怎么只发一次请求,把结果共享给所有调用方。SWR 和 React Query 的核心价值之一就是解决这个问题。
这些模型值得单独成文,留作下一篇的素材。
小结
6 个模型不是前端开发的全部,也不是面试的万能钥匙。但它们是我在备考过程中找到的、信噪比最高的一批知识——理解了这 6 个,遇到新题时至少知道从哪里开始想。
这篇文章更多是一个个人学习笔记,整理的过程本身就让我对这些模型理解更深了一层。如果你有其他觉得重要的前端思维模型,欢迎交流。
参考资料
- MDN - Promise.allSettled() - allSettled 的返回值结构说明
- React 官方文档 - useRef - useRef 的使用场景与注意事项
- React 官方文档 - useEffect cleanup - cleanup 函数的作用与时机
- You Don't Know JS - Async & Performance - Promise 并发模型的深入理解