
React 脚手架和 Hooks 钩子这两样东西几乎是现在前端面试和日常开发里出现频率最高的组合。这篇是这个系列的第三篇前两篇聊过基础搭建和状态管理这一篇把重心放到真正能提升项目质量的关键点上——脚手架为什么要这样搭、Hooks 为什么要这样写、以及那些只有踩过坑之后才会明白的边界条件。文章会结合工程化实战、SSR 数据预取、图表封装、长连接轮询等场景适合正在做 React 工程化改造、或者想把函数组件写得更有章法的同学参考。1. 脚手架选型先搞清楚你要解决的是“创建”还是“工程化”1.1 create-react-app 淡出视野的真正原因CRA 当年是绝对的起步标准一条命令能跑起 React 项目解决了“零配置”问题也让很多后端转前端的朋友感受到了现代前端工具链的便捷。但现在我基本不推荐新项目再用它不是因为它跑不起来而是它把大量配置包在一个 Webpack 黑盒里项目小还好项目一大就会出现三个问题。第一启动和构建速度会越来越慢。Webpack 在大型项目里做全量编译冷启动时间可以轻松到几十秒甚至几分钟开发体验非常被动。第二内层配置难以观测。CRA 虽然提供了 craco、react-app-rewired 这样的二次配置方案但本质上是在黑盒上打补丁依赖版本升级时这些补丁经常失效。第三官方自己也在向社区推荐替代品。create-react-app 的维护状态早已不如从前Vite 这类基于原生 ESM 的工具链变成了新项目的主流。这里不是说 CRA 一无是处。对于纯学习、快速验证 demo、或者没有复杂定制需求的内部工具它仍然能用。但如果你要做一个会持续迭代的生产项目我更建议直接选更透明、更快的方案。注意脚手架不等于工程化。脚手架只解决“项目怎么创建”工程化要解决“团队怎么协作、构建怎么优化、代码怎么约束、线上怎么排障”。选型时不要只看启动速度还要看插件生态、升级路径和团队熟悉成本。1.2 一条比较稳的脚手架路线Vite TypeScript 轻量封装以我最近搭建的项目为例基础命令是npm create vitelatest my-app -- --template react-tsVite 的核心优势是开发环境按需编译利用浏览器原生 ESM 能力只有请求到的模块才会被编译所以项目冷启动能稳定控制在几百毫秒到一两秒。它底层用 Rollup 处理生产构建配置也很直观vite.config.ts几乎就承担了过去 Webpack 一堆配置文件的工作。一个最小可用的配置示例import { defineConfig } from vite; import react from vitejs/plugin-react; import path from node:path; export default defineConfig({ plugins: [react()], resolve: { alias: { : path.resolve(__dirname, src), }, }, server: { port: 5173, host: true, }, build: { sourcemap: false, chunkSizeWarningLimit: 800, }, });注意__dirname在 ESM 模式下不可直接用需要fileURLToPath(new URL(./src, import.meta.url))来取路径。这个坑我遇到不少次很多从 CJS 转过来的同学会在这里莫名报错。再往企业层走还可以直接用 create-vite 定制一个团队模板把package.json里的启动脚本、单元测试、ESLint、Prettier、目录结构一次定好。模板里固定了团队规范之后新同学入职不用再纠结“项目配置怎么搭”可以直接进入业务开发。如果项目需要 SSR、路由约定和较完整的框架能力可以考虑 Next.js 或 Umi如果团队有微前端诉求Umi qiankun 的链路相对成熟。我的经验是不要为了某个框架的“热度”去选型而是先列出业务必须解决的问题再反推哪个脚手架解决得最直接。下表是几个方案的适用场景对照方案适合场景不适合场景Vite中后台应用、SPA、组件库开发需要大量服务端渲染的复杂站点Next.jsSSR/SSG、内容站点、全栈应用纯前端小工具、团队无 Node 运维能力Umi中后台、微前端、依赖完整生态的公司项目个人项目、追求极简配置的场景CRA学习、小规模 demo大型长期项目配置黑盒、速度慢2. Hooks 执行机制比 API 名字重要一百倍2.1 为什么组件逻辑必须是一个“函数”从类组件转到函数组件很多人以为只是语法变化把this.state.xxx改成xxx把生命周期函数改成useEffect就完了。这种理解在简单组件里没问题但一旦涉及复杂状态就会遇到各种奇怪问题。类组件里this指向的是组件实例所以你在方法里读取this.state时读到的往往是“最新状态”。函数组件则不一样每一次渲染都会执行一次组件函数函数内部通过闭包捕获当次渲染的状态和 props。这也是为什么会有“闭包陷阱”这个概念——你在 effect 里读到的不一定是当前新的状态而是它创建时那一刻的状态。把组件逻辑理解为“每次渲染都是独立快照”以后很多问题就清晰了。比如function Counter() { const [count, setCount] useState(0); useEffect(() { const timer setTimeout(() { console.log(count); // 这里打印的是 effect 创建时刻的 count }, 1000); return () clearTimeout(timer); }, []); }2.2 useState 和 useEffect 在底层到底做了什么Hooks 之所以能在函数组件里保存状态是因为 React 在运行时维护了一条与组件节点关联的“记忆链”。简单说每个 Hook 在 Fiber 节点上按调用顺序排列组件每次重新渲染React 都会按相同顺序去读取对应位置的状态。如果你在条件语句里调用 Hook顺序就会错位状态混乱也就是必然的。useState可以简化理解成React 用数组把每次 set 函数和状态值存起来。模拟一个极简版本let index 0; const stateStore []; const componentInstance { render() { index 0; // render 组件 }, }; function useState(initialValue) { const currentIndex index; if (!(currentIndex in stateStore)) { stateStore[currentIndex] initialValue; } const setState (newValue) { stateStore[currentIndex] newValue; componentInstance.render(); }; index 1; return [stateStore[currentIndex], setState]; }这个例子略掉了批量更新和调度但能说明状态存储的核心逻辑位置而不是变量名。所以 React 官方才反复强调 Hooks 只能在组件顶层调用不能放进if、循环或嵌套函数里。useEffect的底层机制也是类似的它会在 commit 之后延迟执行并且每次依赖变化都会先清理上一个 effect。如果依赖数组为空effect 只在组件挂载时执行如果依赖数组里有值每次那个值变化后都会重新执行。这里最容易踩的坑是依赖数组里漏掉了某个变量导致 effect 一直用的是旧值。此时不要硬记“要加依赖”而应理解“effect 内部引用的所有外部值只要可能变化都应该进依赖数组”。3. 基于 Hooks 的请求、实时推送与 SSR 数据预取3.1 自己封装一个带轮询和取消竞态的 useRequest业务项目里请求相关逻辑最容易写乱。最容易出现的就是组件卸载后 setState 还在执行或者轮询接口返回顺序错乱最后把旧数据覆盖了新数据。这些问题都可以通过一个自定义请求 Hook 统一解决。我自己在项目里常用的思路是function useRequest(requestFn, { manual false, polling false } {}) { const [data, setData] useState(null); const [loading, setLoading] useState(!manual); const abortRef useRef(null); const run useCallback(async (...args) { abortRef.current?.abort(); const controller new AbortController(); abortRef.current controller; setLoading(true); try { const result await requestFn(...args, { signal: controller.signal }); setData(result); return result; } finally { setLoading(false); } }, [requestFn]); useEffect(() { if (manual) return; run(); }, [run, manual]); useEffect(() { if (!polling) return; const timer setInterval(() run(), polling); return () clearInterval(timer); }, [polling, run]); return { data, loading, run }; }注意几个要点一是 AbortController 要存到useRef里因为闭包和渲染周期之间需要共享同一个引用。二是清理函数里清楚定时器避免组件卸载后继续轮询。三是轮询不要用setInterval做“固定间隔”调用因为接口延迟可能会导致请求堆积更稳的做法是用setTimeout在接口返回结果后再安排下一次调用。使用setTimeout实现更稳妥的轮询useEffect(() { if (!polling) return; let timer null; const schedule async () { await run(); timer setTimeout(schedule, polling); }; schedule(); return () clearTimeout(timer); }, [polling, run]);另外在实时场景里文件变化监听、CI 日志增量输出这类需求更适合用 SSE 或 WebSocket。SSE 适合服务端单向推送实现简单自动重连WebSocket 适合双向交互。我自己封装过一个淡化细节的 SSE Hookfunction useSSE(url, { onEvent } {}) { useEffect(() { const source new EventSource(url); source.onmessage (event) onEvent?.(JSON.parse(event.data)); source.onerror () { source.close(); }; return () source.close(); }, [url]); }这类 Hook 的关键不是“建立连接”而是“断开连接”。忘记关闭 EventSource、忘记取消 WebSocket 订阅是很多长列表页面内存泄漏的元凶。3.2 SSR 场景下 Hooks 要避开哪些坑SSR 是服务端渲染函数组件会在 Node 环境中执行一次然后浏览器端再执行一次。这意味着你用的useEffect在服务端不会执行因为服务端没有 commit 之后的“生命周期”window、document也不存在直接引用就会报错。所以 SSR 下的数据预获取不能写在useEffect里常见做法有这几种在框架约定文件里做数据预取。以 Next.js 的 App Router 为例可以在服务端组件里直接await fetch然后传给页面组件。使用 React 18 的use()搭配 Suspense。在服务端先加载资源渲染完成后再输出 HTML。把预取数据和序列化后的状态作为全局数据注入页面客户端再基于这份数据做 hydration。比如在服务端组件里async function Page() { const data await fetchData(/api/user); return UserProfile data{data} /; }这里的核心点是Hooks 不是万能的它只管客户端交互带来的状态更新服务端渲染需要的是“数据就绪后再拼接模板”的思路。水合阶段如果发现初次渲染结果不一致React 会警告并重新渲染一次这会让首屏体验变差。解决办法通常是统一使用useId或用条件判断处理只在客户端渲染的内容。提示只要涉及 SSR请尽早把“环境判断”写成一个工具函数isBrowser()并在任何可能访问window的地方使用它。这个习惯能避免很多部署后才发现的白屏问题。4. Hooks 与渲染性能useMemo、useCallback、图表封装4.1 记忆化不是免费的什么时候才该用 useMemo/useCallback很多人一听说性能优化就到处加useMemo和useCallback实际上这是最容易反噬的用法。因为记忆化本身也有成本React 要维护依赖数组、要做浅比较、要在内存里保存之前的值。如果计算本身不复杂或者变量引用不需要在子组件依赖中保持稳定加上它反而是浪费。我给自己定的三条经验法则useMemo只用于真正昂贵的计算比如大列表过滤、复杂对象序列化、图表数据处理。useCallback只在把函数传给依赖memo的组件、或者作为其他 Hooks 依赖时使用。不要因为“别人都用了”就用先用 React DevTools Profiler 量一下实际渲染消耗再决定是否优化。一个很多人忽略的点是useMemo返回的引用稳定并不代表组件渲染就一定减少。只有当这个值被作为memo子组件的依赖或者出现在其他useEffect依赖里时引用稳定才有意义。否则 useMemo 纯粹是增加代码复杂度。另外一个高频坑是useEffect的依赖数组里包含对象和函数。如果它们是渲染时新建的会导致 effect 每次渲染都执行。这种场景下useCallback就变得有意义。但更好的做法是把依赖关系抽出来让组件层级更清晰而不是盲目记忆化。4.2 用自定义 Hooks 封装 uPlot K 线图图表场景是 React 里比较典型的“Hooks 边界”问题。以我使用过的 uPlot 为例它非常轻量基于 canvas支持高性能时序数据和大量数据点很适合做 K 线图。但它的实例是命令式的直接把它塞进 JSX 里很容易写出反复创建实例、数据更新混乱的代码。我更推荐用自定义 Hooks 持有 uPlot 实例function useKLineChart(containerRef, options) { const [chart, setChart] useState(null); useEffect(() { if (!containerRef.current) return; const instance new uPlot(options, [], containerRef.current); setChart(instance); return () instance.destroy(); }, [containerRef]); const updateData useCallback( (data) { chart?.setData(data); }, [chart], ); return { chart, updateData }; }注意到useEffect的依赖里有options如果 options 是每次渲染都新建的对象这段代码会反复销毁重建实例。所以调用方要把 options 用useMemo包起来或者把稳定不变的 options 定义在组件外部。建议在 K 线图场景里还要处理容器尺寸变化。uPlot 自己没有响应式封装所以通常要用ResizeObserver监听容器宽度然后调用chart.setSize({ width, height })。示例useEffect(() { if (!containerRef.current) return; const observer new ResizeObserver((entries) { const { width, height } entries[0].contentRect; chart?.setSize({ width, height }); }); observer.observe(containerRef.current); return () observer.disconnect(); }, [containerRef, chart]);在 React StrictMode 下useEffect会额外执行一次 dev-only 的挂载和清理流程。如果 effect 里没有正确调用destroy()很容易看到图表实例越堆越多。平时一定要养成“所有外部资源都要清理”的习惯。5. 高频问题排查与面试自检清单5.1 白屏问题的三个排查方向白屏在 React 项目里太常见了而且多数时候不是框架问题。第一个方向是检查入口渲染是否成功。比如createRoot的容器是否有高度、根节点是否被错误覆盖以及是否被某个 CSS 设置为display: none。第二个方向是看 JS 是否报错。如果代码在渲染阶段抛错且没有错误边界React 会直接把根节点清空页面就会一片白。生产环境可以用window.onerror或 Sentry 捕获开发环境直接看控制台。第三个方向是检查资源和路径问题。尤其在使用 Vite 的base配置后打包出来的 index.html 里静态资源路径可能不对导致 JS 加载失败页面是空 HTML。如果是 React Native 场景的启动白屏优先确认Metro Bundle是否成功打包、原生依赖是否匹配、入口页面是否被原生容器正确加载。5.2 面试里“手写 Hooks”到底在考什么近两年的 React 前端面试题里“手写 useState”“手写 useEffect”变得很常见。看起来是考代码能力其实面试官真正想看你是否理解 React 内部的调度机制、闭包、以及状态存储的核心思想。你不需要写出和 React 源码一致的实现但至少要能表达清楚三件事Hooks 的状态保存在哪Fiber 节点上的 Hooks 链表而不是全局变量。调用顺序为什么重要因为后续读取依赖数组和状态都靠顺序。effect 为什么需要清理函数因为下一次 effect 执行前要先撤销上一次的影响。一个经典的追问是“如果 useEffect 依赖数组为空为什么还能用于组件卸载清理”答案在于 React 在组件卸载时执行最后一次清理。所以清理函数是 effect 不可分割的一部分而不是顺手写的代码。面试时如果被问到“Hooks 和类组件怎么选”不要只背“函数式更现代”而要说清楚函数组件让状态和副作用更显式降低了理解成本类组件适合已有历史代码迁移、或极少数需要实例字段的场景。这样回答会更有说服力。技巧平时可以自己写一个极简版useState模拟再对照源码看调度机制很多模棱两可的理解会一下子清晰。最后再分享一点我的实际感受脚手架和 Hooks 本质上都是在做“让开发过程更可控”的事。我搭过不少项目最深的体会是工具链不要一上来就追求最炫Hooks 也不要为了抽象而抽象。先把团队规范、请求封装、渲染性能边界定好后面的迭代才会轻松。如果今天要我给一个可落地的方案我会这样做新项目用 Vite TypeScript 做基础模板模板里固定 ESLint、Prettier、目录分层业务代码统一走自定义请求 Hook支持取消、轮询、SWR 缓存所有外部资源统一在useEffect清理图表、SSR、实时推送这些场景单独用自定义 Hooks 隔离。最后推荐一个很实用的排障流程页面卡了先打开 React DevTools Profiler 看哪一轮 render 耗时异常页面白了先看控制台报错再看 DOM 根节点是不是被卸载。不要上来就重构代码先把问题定位到“渲染、副作用、还是构建产物”这三个层面能省下大半天。