我整理 React 知识点笔记这件事,其实源于一次团队内部的技术分享。当时为了让新来的同事能快速上手项目,我把日常开发中踩过的坑、用过的套路、琢磨过的原理,零零散散记了好几个月,最后沉淀成了一份比较系统的笔记。这份笔记不是什么官方文档的翻译,更不是面面俱到的教程,它更像是一个在一线写了多年 React 的开发者,把"真正用得上的东西"挑出来讲清楚。今天把它整理成文章分享出来,适合刚学完 React 基础、准备写实战项目的人,也适合写了几年 React 但总觉得自己"会而不懂"的同行。我会把组件化思维、Hooks 运行机制、状态管理方案选型、性能优化手段这些核心知识点串起来讲,每个环节都配上实际项目里验证过的代码片段和选型理由。1. 组件化思维与 JSX 背后的运行逻辑1.1 组件到底在抽象什么很多初学者写 React,第一反应是"把页面切成一块块的组件",这个理解没错,但太浅了。我在实际项目里摸索出来的心得是:组件抽象的核心不是"视觉区块",而是**"可变区域的边界"**。换句话说,你要找的是"页面上哪些地方会因数据变化而重新渲染",这些地方才值得拆成组件。举个例子,一个电商商品列表页,有搜索框、筛选栏、商品卡片、分页器。如果整个页面是一个巨型组件,那么搜索框每次输入一个字符,全页所有商品卡片、筛选栏全部重新渲染。拆成组件之后,搜索框的状态变化只影响它自己,商品列表只在筛选条件真正变化时才重新计算。这里的判断标准不是"看起来是不是一块",而是"它是不是一个独立的数据变化单元"。组件拆分的粒度也有讲究,我见过有人把一个按钮都拆成独立组件,结果 props 传递链比业务逻辑还长。我的经验是:同一层级、同一数据源、一起变化的内容,优先放在同一个组件里;跨层级、不同数据源、独立变化的内容,才拆出去。比如商品卡片里的价格和库存,它们都来自同一个商品对象,大概率一起变化,拆成两个组件反而制造不必要的通信成本。1.2 JSX 不是模板引擎,是语法糖JSX 长得像 HTML,这是它易上手的原因,但也是很多人误解它的根源。它本质上是一个 JavaScript 表达式,编译之后就是React.createElement的嵌套调用。这意味着你可以在 JSX 里写完整的 JavaScript 表达式,比如三元运算、数组 map、逻辑与短路,但不能写 if 语句和 for 循环,因为那些是语句不是表达式。// JSX 写法 const list = ( <ul> {items.map((item) => ( <li key={item.id}>{item.name}</li> ))} </ul> ); // 编译后等价于 const list = React.createElement( 'ul', null, items.map((item) => React.createElement('li', { key: item.id }, item.name)) );理解这一点对排查 bug 特别重要。比如你写{condition && <Component />},当condition为 0 或空字符串时,页面上会渲染出一个"0"或空白字符,而不是什么都不渲染。因为0 && anything的结果是0,JSX 会把这个数字原样渲染出来。正确的写法是{condition ? <Component /> : null},或者用!!condition && <Component />。另一个容易踩的坑是 JSX 的注释写法。在 JSX 内部不能直接用//或/* */,必须写成{/* 注释内容 */}。这个语法很反直觉,但记住"JSX 里的{}是 JavaScript 的地盘"这个原则,就都能理解了。1.3 为什么需要虚拟 DOM虚拟 DOM 可能是 React 被讨论最多、也最常被误解的概念。很多人以为"虚拟 DOM 比真实 DOM 快",这个说法其实不准确。精确的说法是:虚拟 DOM 让 React 能够在内存中计算出最小的 DOM 变更集合,然后一次性批量更新,从而减少昂贵的真实 DOM 操作次数。真实 DOM 操作慢在哪?慢在它不仅仅是改一个值,还可能触发样式重算、布局回流、绘制合成,这一整套浏览器的渲染流水线。而 JavaScript 对象之间的比较,成本要低得多。React 先在虚拟 DOM 上做 diff 算法,找出哪些节点需要增删改,再把这些变更一次性应用到真实 DOM。注意:虚拟 DOM 的意义不是"比直接操作 DOM 快",而是"在你不手动优化的情况下,依然维持足够好的性能"。如果你的团队对性能有极致要求,直接写命令式 DOM 操作或者用别的方案可能更快,但代码的可维护性会断崖式下降。React 的选择是用一点性能换开发效率。2. Hooks 运行机制与状态管理实践2.1 useState 的更新是异步且批处理的useState大概是所有人入门的第一个 Hook,但它的更新机制暗藏玄机。setState之后,状态并不会立刻改变,而是在当前事件循环结束后统一批量处理。这就导致了一个经典问题:连续调用两次setCount(count + 1),最终count只加了 1 而不是 2。const [count, setCount] = useState(0); // 错误示范:两次调用只加 1 setCount(count + 1); setCount(count + 1); // 正确示范:使用函数式更新 setCount((prev) => prev + 1); setCount((prev) => prev + 1);函数式更新的参数是上一次的状态值,React 会把多次更新排队,依次把前一次的结果传给后一次。在需要基于最新状态计算新状态时,永远使用函数式更新。这个习惯能在很多隐蔽 bug 中救你一命,尤其是涉及到定时器、异步回调、连续交互的场景。还有一点是关于批处理的范围。React 18 之前,只有在 React 事件处理函数中,状态更新才会被批处理;在setTimeout、Promise回调、原生事件监听器里,每次setState都会触发一次独立的渲染。React 18 之后,自动批处理覆盖了这些场景,几乎所有地方的更新都会被批处理。如果你在用老版本,又遇到"为什么这里没批量更新"的困惑,先检查一下更新发生的上下文。2.2 useEffect 的依赖数组是契约,不是优化手段useEffect是所有 Hook 里最容易被滥用的。我见过大量代码把useEffect当作"在某个时机做某件事"的万能工具,结果就是依赖数组乱写,要么漏了依赖导致闭包陷阱,要么多写了依赖导致无限循环。核心认知是:effect 的依赖数组是你对 React 的承诺——"只有这些值变化时,我这份副作用才需要重新执行"。如果你在 effect 内部使用了某个外部变量,却没写进依赖数组,那这个 effect 读取到的就是上一次渲染时的旧值,也就是所谓的"闭包陷阱"。const [userId, setUserId] = useState(''); const [userInfo, setUserInfo] = useState(null); useEffect(() => { // 这里使用了 userId,它必须出现在依赖数组里 fetchUser(userId).then(setUserInfo); }, [userId]);依赖数组的另一个坑是引用类型依赖。如果依赖项是一个对象或数组,而每次渲染都会生成新的引用,effect 就会每次都执行,可能引发无限循环。这时候你要么把依赖拆成基本类型,要么用useMemo缓存引用。我个人的习惯是,每个useEffect只负责一件事,宁可多写几个 effect,也不要把不相关的内容堆在一起。这既是为了依赖数组清晰,也是为了后面排查问题时能直接定位。还有一个小技巧:如果一段副作用依赖的是某个"由 props 计算而来的值",应该把计算过程放在组件里,而不是塞进 effect 内。这样 effect 的依赖数组会更短,逻辑也更符合数据流。2.3 useRef 不只是"避开依赖"的逃生通道useRef最常见的用法是获取 DOM 节点,但它的本质是一个跨渲染周期保持不变的容器。修改ref.current不会触发重新渲染,这个特性让它成为了存储"可变值"的好工具。我在实际开发中经常需要"读取最新的状态,但不希望因为读取它而触发 effect"。比如一个点击埋点逻辑,需要在事件回调里读取当前用户状态,但又不想因为这个状态变化而重新绑定事件。这时候ref就是正解:const [user, setUser] = useState(null); const userRef = useRef(user); useEffect(() => { userRef.current = user; }, [user]); // 在某个不会被重新注册的回调里 const handleClick = useCallback(() => { trackEvent('button_click', { userId: userRef.current?.id }); }, []);有一类经典问题是"在setInterval里读取最新状态"。直接写会拿到初始值,因为闭包捕获的是第一次渲染时的状态。解法就是把状态同步到 ref 里,让定时器每次执行都读取ref.current。这个模式我叫它"ref 镜像",是处理异步回调里状态读取问题的基础手段。注意:useRef不是useState的替代品。可不要因为"ref 改值不触发渲染"就把所有状态都放 ref 里。如果这个值影响到界面展示,它必须是 state;只有在"需要读写但不关心渲染"时才用 ref。2.4 useMemo 与 useCallback 的正确打开方式useMemo和useCallback是性能优化的双刃剑,用好了显著减少无效渲染,用滥了反而让代码更难读、更难调试。先说结论:useCallback(fn, deps)返回一个 memoized 的函数引用,用于保持回调函数身份的稳定,方便子组件做浅比较。useMemo(() => value, deps)返回一个 memoized 的值,用于跳过昂贵的计算,以及保持引用类型值的稳定。// 昂贵计算的缓存 const filteredList = useMemo(() => { return list.filter((item) => item.price < maxPrice); }, [list, maxPrice]); // 稳定回调引用 const handleSelect = useCallback((item) => { setSelected(item); }, []);但这里有个反直觉的点:useMemo并不保证一定不重新计算。React 在内存紧张或为了释放内存时,可能会丢弃 memoized 的结果,下次渲染重新计算。所以useMemo只适合做"性能优化",不能依赖它保证语义上的"必然跳过"。如果你的业务逻辑依赖"这段代码只执行一次",应该用useRef加标志位,或者把逻辑放到useState的初始化函数里。实际项目里我见过的最常见误用是:给所有函数都套上useCallback,给所有计算都套上useMemo,理由是"万一性能有问题呢"。结果就是依赖数组满天飞,每次该更新的不更新,不该更新的被跳过,bug 反而更多。我的建议是:凡是没有明显性能问题的场景,先不用 memo。等你通过 React DevTools 的 Profiler 确认了某处存在性能瓶颈,再精准地加上useMemo或useCallback。性能优化应该基于测量,而不是基于猜测。3. 组件通信与数据流的几种方案3.1 props 逐层传递什么时候该停药React 的数据流是单向的,自上而下。子组件需要什么数据,父组件就通过 props 传什么。组件层级浅的时候很清爽,但项目一大,就会出现"中间组件只是路过数据"的情况——它自己不用这个 props,纯粹是为了传给更深层的子孙组件。这就是所谓的 props drilling 问题。我判断该不该引入其他通信方式的依据很简单:如果数据需要穿过三层以上的中间组件,而且中间组件完全不对数据做任何加工,那就要考虑 Context 或状态管理库了。三层是我的经验值,两层以内用 props 传递最清晰,超过三层维护成本就显著升高。3.2 Context 的适用边界与常见误区Context 解决了"跨层级传递"的问题,但它不是万能的。最关键的一点是:Context 的 value 一旦变化,所有消费这个 Context 的组件都会重新渲染,不管它们实际使用的是 value 中的哪一部分。const ThemeContext = createContext({ theme: 'light', user: null, config: {} }); // 这种写法很危险:每次渲染都生成新对象 <ThemeContext.Provider value={{ theme, user, config }}> {children} </ThemeContext.Provider>上面的写法里,value是个对象字面量,父组件每次渲染都会生成一个全新的对象引用,导致所有useContext(ThemeContext)的组件全部重新渲染,哪怕theme根本没变。解法是把 value 用useMemo缓存起来:const contextValue = useMemo(() => ({ theme, user, config }), [theme, user, config]); <ThemeContext.Provider value={contextValue}>{children}</ThemeContext.Provider>Context 适合放"低频变化、全局需要"的数据,比如主题、登录态、语言配置。不适合放"高频变化、局部使用"的数据,比如表单输入值、列表筛选条件。后者高频更新会导致大范围的组件重渲染,性能很差。如果你发现 Context 刷新太频繁,说明选型错了。3.3 状态管理库的选型思考我在几个项目里分别用过不同的状态管理方案,简单说说选型思路。中小型项目里,Context 加useReducer基本够用。useReducer的好处是把状态的更新逻辑集中到一个 reducer 函数里,便于测试和追踪,适合那种"多个操作会修改同一块状态"的场景。function reducer(state, action) { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; default: return state; } } const [state, dispatch] = useReducer(reducer, { count: 0 });再往上走,当项目里有大量跨页面、跨模块的共享状态,并且有服务端数据缓存、请求取消、状态持久化、时间旅行调试等需求时,就有必要引入专门的状态管理库。我现在的默认选择是轻量级的方案,不引入过度的概念和样板代码。我见过很多团队把服务端的数据请求结果也放进全局状态,这是一个值得商榷的做法。服务端数据和客户端状态是两回事:服务端数据有缓存、失效、重试的语义,客户端状态有本地交互、临时 UI 状态的语义。混在一起会让状态管理变得臃肿,而且很难理清数据的来源和更新时机。如果项目以服务端数据为主,我会优先考虑数据请求缓存方案,而不是把请求结果手动塞进全局 store。全局 store 只保留"真正跨组件共享的客户端状态"。4. 性能优化:从测量到实践的完整路径4.1 先找出"不值得渲染"的组件React 组件每次父组件渲染,默认都会跟着重新渲染。判断一个组件是否值得加memo,要看它的渲染开销大不大、渲染频率高不高、props 引用是否稳定。三个条件都满足,再加memo才有意义。const ExpensiveChart = memo(function ExpensiveChart({ data, onSelect }) { // 这里做了昂贵的图表绘制计算 return <Chart data={data} onSelect={onSelect} />; });memo的作用是让组件在 props 不变时跳过重新渲染。但它做的是浅比较,如果父组件给ExpensiveChart传了一个内联函数或内联对象,那么每次父组件渲染,props 的引用都变了,memo就形同虚设。这就是为什么useCallback通常和memo成对出现——一个负责稳定函数引用,一个负责跳过渲染。4.2 key 的正确使用是diff算法的地基列表渲染的key可能是 React 中最容易被敷衍的属性。我看到很多代码用数组下标做 key,一旦列表顺序变化、中间插入或删除元素,React 的复用逻辑就会出错,可能出现状态错位、输入框内容串行等问题。// 错误示范:用下标做 key {items.map((item, index) => <Item key={index} data={item} />)} // 正确示范:用稳定唯一的标识 {items.map((item) => <Item key={item.id} data={item} />)}key的本质是告诉 React:"这个组件实例对应的是哪个数据项"。只有用稳定唯一的标识,React 才能准确追踪每个组件的增删移动。如果数据没有天然的 id,可以自己生成一个唯一标识。同时要注意,key只需要在列表上下文中唯一,不需要全局唯一。还有一个小细节:不要用随机数或Date.now()做 key。那样会导致每次渲染 key 都不一样,React 会认为所有列表项都是新的,全部卸载重建,不仅状态丢失,性能也会崩。这在做拖拽排序或动画时特别容易踩到。4.3 渲染优化之外的大头:减少不必要的工作很多人一谈性能优化就只盯着"减少重新渲染",但在实际项目中,很多时候性能瓶颈根本不在渲染次数,而在于每次渲染时做了太多不必要的计算。比如在组件函数体里直接执行复杂的过滤、排序、格式化逻辑,这些逻辑在每次组件渲染时都会重算。而且因为闭包的原因,即使相关数据没变,只要组件重新渲染,这些纯函数也会被重新执行。解法是useMemo:const visibleOrders = useMemo(() => { return orders .filter((o) => o.status === 'active') .sort((a, b) => b.createTime - a.createTime); }, [orders]);另一个容易忽视的性能杀手是在渲染过程中创建新的对象和数组。如果这个对象被传给了一个memo包着的子组件,父组件每次渲染都会让子组件的 memo 失效,因为引用变了。这种问题用 React DevTools 的 Profiler 一眼就能看出来:明明数据没变,大量组件却红了一片。优化的顺序也很有讲究。我建议:先优化数据结构和计算逻辑,再优化渲染次数,最后才是各种 memo。很多时候,修改一个不合理的状态层级,比给十几个组件加 memo 有效得多。比如一个列表的筛选条件如果被定义在列表组件的父组件里,每次父组件状态变化都会导致列表重新过滤,这时把筛选逻辑下沉到列表内部,性能立刻改善。4.4 治理 useEffect 引起的重复请求项目上线后最常见的性能问题之一,是 useEffect 的依赖数组导致重复请求。比如:useEffect(() => { fetchData(keyword).then(setData); }, [keyword]);如果keyword从某个对象属性中解构而来,而那个对象每次渲染都是新引用,那这个 effect 就会请求风暴。排查的思路是:先在 effect 里打印依赖项的引用或值,看它是否每次都在变。如果是引用类型变化,用useMemo或把值提取成基本类型。另一个实用技巧是给请求加取消机制。React 18 之后,StrictMode 会在开发模式下故意执行两次 effect 来暴露副作用问题,如果请求没有做好防御,就会出现重复请求。用 AbortController 或者简单的局部标志位,可以在组件卸载或依赖变化时取消上一次请求:useEffect(() => { let ignore = false; fetchData(keyword).then((data) => { if (!ignore) setData(data); }); return () => { ignore = true; }; }, [keyword]);这段代码虽然简单,但能避免大量的竞态问题。忽略它的话,用户快速切换筛选条件时,后返回的旧请求可能覆盖新数据,出现数据错乱。这也是"React 知识笔记"里我标注过好几次的重点,建议每个写请求的人都记下来。5. 常见问题与排查技巧实录5.1 状态更新了但页面不刷新这是新手阶段最常遇到的问题。我排查时最先怀疑的永远是:状态到底是没更新,还是更新了但没触发渲染。先区分两种情况。第一种是把可变对象直接改掉:const [arr, setArr] = useState([1, 2, 3]); arr.push(4); // 直接修改原数组 setArr(arr); // 引用的地址没变,React 认为状态没变这里的问题在于 React 的useState是通过Object.is比较旧值和新值的。旧数组和新数组是同一个引用,所以 React 判定状态没变,跳过渲染。解法是永远返回一个新引用:setArr([...arr, 4]);第二种是试图"修改 props"。props 是只读的,子组件修改 props 不会影响父组件,应当在子组件里触发父组件的更新事件,由父组件改自己的状态。这条规则有时候要靠踩坑才记得住。5.2 闭包陷阱:为什么我的回调里拿到的总是旧值闭包陷阱的经典场景是这样的:你写了一个倒计时,setInterval 每秒让 count 减 1。然后你在某个事件回调里读取 count,发现一直是初始值。问题出在事件回调函数是在第一次渲染时创建的,它捕获的是那一轮渲染的 count 值,之后 count 变化,这个回调里的 count 还是旧值。解决思路有两种:如果回调需要"最新值",用ref镜像;如果回调本身需要在依赖变化时重新创建,把依赖写进useCallback的依赖数组里。const [count, setCount] = useState(0); const countRef = useRef(count); useEffect(() => { countRef.current = count; }, [count]); // 任何地方都能拿到最新值 const handleClick = () => { console.log(countRef.current); };动手排查时,先在怀疑的回调里打断点或者console.log相关变量,看它是哪次渲染的值。出现"总是第一版的值"基本就是闭包陷阱,不要再去分析业务逻辑了,方向错了会很浪费时间。5.3 依赖数组写不对导致的三个典型症状依赖数组是 useEffect 最容易写错的地方,我整理几类高频问题:症状原因解决方向effect 执行次数远超预期,甚至无限循环依赖了每次渲染都会变化的对象、数组或函数用 useMemo/useCallback 稳定引用,或拆解成基本类型依赖effect 不执行,数据是旧值漏写了某个内部使用的变量凡是在 effect 里用到的 props、state,都要列入依赖组件卸载后还在 setState清理函数没写或没写对在 effect 中返回清理函数,取消订阅、清除定时器、标记忽略有一个比较实用的小技巧来检查漏写的依赖:在项目里接入 lint 规则,当依赖数组缺失时会给出警告。这个 lint 提示在绝大多数情况下都是对的,值得养成不跳过它的习惯。当然 lint 会误报的场景也有,比如你确实只想在挂载时执行一次,又用到了某个只会变一次的值,这时候可以显式地把依赖写进数组,或者加注释说明原因,而不是硬把警告关掉。5.4 列表数据更新后组件状态串位这个问题的根源就是前面说的 key 用错。列表项组件是带本地状态的,当列表数据重新排序或过滤后,React 需要知道哪个组件实例对应哪个数据项。如果没有合适的 key,React 会复用旧的组件实例,导致原来第一项的输入框内容跑到了第二项身上。解决方法是检查列表渲染的 key 是否为稳定唯一标识。如果数据里没有 id,可以建立一个key映射关系,或者在数据入库时就生成 id。另一个相关的坑是,key 连带着组件的 DOM 结构一起决定 React 是复用还是重建。如果 key 相同但结构大变,React 会尽量复用 DOM 节点,可能导致样式错乱,这时可能需要调整 key 的粒度。写在最后的一点个人体会这些知识点单独拎出来,每一个都不复杂,但它们之间是相互关联的。props 传递影响组件边界,组件边界决定状态放哪,状态放哪影响重渲染范围,重渲染范围产生性能问题,性能问题又逼着你回头调整数据结构。我最大的感触是:React 的很多"坑"其实是它的设计特性,理解了特性背后的原理,坑自然就不是坑了。如果在这些笔记里只能留下一句话,我会选:所有 React 问题,本质上都是数据流和组件生命周期的问题。排查的时候先画清楚数据从哪来、到哪去、在哪变化,再去看具体代码,往往能少走很多弯路。希望这份笔记能给你带来一些实际的帮助,至少让你在写下一个组件时,心里少一点不确定,多一点底气。
我整理 React 知识点笔记这件事,其实源于一次团队内部的技术分享。当时为了让新来的同事能快速上手项目,我把日常开发中踩过的坑、用过的套路、琢磨过的原理,零零散散记了好几个月,最后沉淀成了一份比较系统的笔记。这份笔记不是什么官方文档的翻译,更不是面面俱到的教程,它更像是一个在一线写了多年 React 的开发者,把"真正用得上的东西"挑出来讲清楚。今天把它整理成文章分享出来,适合刚学完 React 基础、准备写实战项目的人,也适合写了几年 React 但总觉得自己"会而不懂"的同行。我会把组件化思维、Hooks 运行机制、状态管理方案选型、性能优化手段这些核心知识点串起来讲,每个环节都配上实际项目里验证过的代码片段和选型理由。1. 组件化思维与 JSX 背后的运行逻辑1.1 组件到底在抽象什么很多初学者写 React,第一反应是"把页面切成一块块的组件",这个理解没错,但太浅了。我在实际项目里摸索出来的心得是:组件抽象的核心不是"视觉区块",而是**"可变区域的边界"**。换句话说,你要找的是"页面上哪些地方会因数据变化而重新渲染",这些地方才值得拆成组件。举个例子,一个电商商品列表页,有搜索框、筛选栏、商品卡片、分页器。如果整个页面是一个巨型组件,那么搜索框每次输入一个字符,全页所有商品卡片、筛选栏全部重新渲染。拆成组件之后,搜索框的状态变化只影响它自己,商品列表只在筛选条件真正变化时才重新计算。这里的判断标准不是"看起来是不是一块",而是"它是不是一个独立的数据变化单元"。组件拆分的粒度也有讲究,我见过有人把一个按钮都拆成独立组件,结果 props 传递链比业务逻辑还长。我的经验是:同一层级、同一数据源、一起变化的内容,优先放在同一个组件里;跨层级、不同数据源、独立变化的内容,才拆出去。比如商品卡片里的价格和库存,它们都来自同一个商品对象,大概率一起变化,拆成两个组件反而制造不必要的通信成本。1.2 JSX 不是模板引擎,是语法糖JSX 长得像 HTML,这是它易上手的原因,但也是很多人误解它的根源。它本质上是一个 JavaScript 表达式,编译之后就是React.createElement的嵌套调用。这意味着你可以在 JSX 里写完整的 JavaScript 表达式,比如三元运算、数组 map、逻辑与短路,但不能写 if 语句和 for 循环,因为那些是语句不是表达式。// JSX 写法 const list = ( <ul> {items.map((item) => ( <li key={item.id}>{item.name}</li> ))} </ul> ); // 编译后等价于 const list = React.createElement( 'ul', null, items.map((item) => React.createElement('li', { key: item.id }, item.name)) );理解这一点对排查 bug 特别重要。比如你写{condition && <Component />},当condition为 0 或空字符串时,页面上会渲染出一个"0"或空白字符,而不是什么都不渲染。因为0 && anything的结果是0,JSX 会把这个数字原样渲染出来。正确的写法是{condition ? <Component /> : null},或者用!!condition && <Component />。另一个容易踩的坑是 JSX 的注释写法。在 JSX 内部不能直接用//或/* */,必须写成{/* 注释内容 */}。这个语法很反直觉,但记住"JSX 里的{}是 JavaScript 的地盘"这个原则,就都能理解了。1.3 为什么需要虚拟 DOM虚拟 DOM 可能是 React 被讨论最多、也最常被误解的概念。很多人以为"虚拟 DOM 比真实 DOM 快",这个说法其实不准确。精确的说法是:虚拟 DOM 让 React 能够在内存中计算出最小的 DOM 变更集合,然后一次性批量更新,从而减少昂贵的真实 DOM 操作次数。真实 DOM 操作慢在哪?慢在它不仅仅是改一个值,还可能触发样式重算、布局回流、绘制合成,这一整套浏览器的渲染流水线。而 JavaScript 对象之间的比较,成本要低得多。React 先在虚拟 DOM 上做 diff 算法,找出哪些节点需要增删改,再把这些变更一次性应用到真实 DOM。注意:虚拟 DOM 的意义不是"比直接操作 DOM 快",而是"在你不手动优化的情况下,依然维持足够好的性能"。如果你的团队对性能有极致要求,直接写命令式 DOM 操作或者用别的方案可能更快,但代码的可维护性会断崖式下降。React 的选择是用一点性能换开发效率。2. Hooks 运行机制与状态管理实践2.1 useState 的更新是异步且批处理的useState大概是所有人入门的第一个 Hook,但它的更新机制暗藏玄机。setState之后,状态并不会立刻改变,而是在当前事件循环结束后统一批量处理。这就导致了一个经典问题:连续调用两次setCount(count + 1),最终count只加了 1 而不是 2。const [count, setCount] = useState(0); // 错误示范:两次调用只加 1 setCount(count + 1); setCount(count + 1); // 正确示范:使用函数式更新 setCount((prev) => prev + 1); setCount((prev) => prev + 1);函数式更新的参数是上一次的状态值,React 会把多次更新排队,依次把前一次的结果传给后一次。在需要基于最新状态计算新状态时,永远使用函数式更新。这个习惯能在很多隐蔽 bug 中救你一命,尤其是涉及到定时器、异步回调、连续交互的场景。还有一点是关于批处理的范围。React 18 之前,只有在 React 事件处理函数中,状态更新才会被批处理;在setTimeout、Promise回调、原生事件监听器里,每次setState都会触发一次独立的渲染。React 18 之后,自动批处理覆盖了这些场景,几乎所有地方的更新都会被批处理。如果你在用老版本,又遇到"为什么这里没批量更新"的困惑,先检查一下更新发生的上下文。2.2 useEffect 的依赖数组是契约,不是优化手段useEffect是所有 Hook 里最容易被滥用的。我见过大量代码把useEffect当作"在某个时机做某件事"的万能工具,结果就是依赖数组乱写,要么漏了依赖导致闭包陷阱,要么多写了依赖导致无限循环。核心认知是:effect 的依赖数组是你对 React 的承诺——"只有这些值变化时,我这份副作用才需要重新执行"。如果你在 effect 内部使用了某个外部变量,却没写进依赖数组,那这个 effect 读取到的就是上一次渲染时的旧值,也就是所谓的"闭包陷阱"。const [userId, setUserId] = useState(''); const [userInfo, setUserInfo] = useState(null); useEffect(() => { // 这里使用了 userId,它必须出现在依赖数组里 fetchUser(userId).then(setUserInfo); }, [userId]);依赖数组的另一个坑是引用类型依赖。如果依赖项是一个对象或数组,而每次渲染都会生成新的引用,effect 就会每次都执行,可能引发无限循环。这时候你要么把依赖拆成基本类型,要么用useMemo缓存引用。我个人的习惯是,每个useEffect只负责一件事,宁可多写几个 effect,也不要把不相关的内容堆在一起。这既是为了依赖数组清晰,也是为了后面排查问题时能直接定位。还有一个小技巧:如果一段副作用依赖的是某个"由 props 计算而来的值",应该把计算过程放在组件里,而不是塞进 effect 内。这样 effect 的依赖数组会更短,逻辑也更符合数据流。2.3 useRef 不只是"避开依赖"的逃生通道useRef最常见的用法是获取 DOM 节点,但它的本质是一个跨渲染周期保持不变的容器。修改ref.current不会触发重新渲染,这个特性让它成为了存储"可变值"的好工具。我在实际开发中经常需要"读取最新的状态,但不希望因为读取它而触发 effect"。比如一个点击埋点逻辑,需要在事件回调里读取当前用户状态,但又不想因为这个状态变化而重新绑定事件。这时候ref就是正解:const [user, setUser] = useState(null); const userRef = useRef(user); useEffect(() => { userRef.current = user; }, [user]); // 在某个不会被重新注册的回调里 const handleClick = useCallback(() => { trackEvent('button_click', { userId: userRef.current?.id }); }, []);有一类经典问题是"在setInterval里读取最新状态"。直接写会拿到初始值,因为闭包捕获的是第一次渲染时的状态。解法就是把状态同步到 ref 里,让定时器每次执行都读取ref.current。这个模式我叫它"ref 镜像",是处理异步回调里状态读取问题的基础手段。注意:useRef不是useState的替代品。可不要因为"ref 改值不触发渲染"就把所有状态都放 ref 里。如果这个值影响到界面展示,它必须是 state;只有在"需要读写但不关心渲染"时才用 ref。2.4 useMemo 与 useCallback 的正确打开方式useMemo和useCallback是性能优化的双刃剑,用好了显著减少无效渲染,用滥了反而让代码更难读、更难调试。先说结论:useCallback(fn, deps)返回一个 memoized 的函数引用,用于保持回调函数身份的稳定,方便子组件做浅比较。useMemo(() => value, deps)返回一个 memoized 的值,用于跳过昂贵的计算,以及保持引用类型值的稳定。// 昂贵计算的缓存 const filteredList = useMemo(() => { return list.filter((item) => item.price < maxPrice); }, [list, maxPrice]); // 稳定回调引用 const handleSelect = useCallback((item) => { setSelected(item); }, []);但这里有个反直觉的点:useMemo并不保证一定不重新计算。React 在内存紧张或为了释放内存时,可能会丢弃 memoized 的结果,下次渲染重新计算。所以useMemo只适合做"性能优化",不能依赖它保证语义上的"必然跳过"。如果你的业务逻辑依赖"这段代码只执行一次",应该用useRef加标志位,或者把逻辑放到useState的初始化函数里。实际项目里我见过的最常见误用是:给所有函数都套上useCallback,给所有计算都套上useMemo,理由是"万一性能有问题呢"。结果就是依赖数组满天飞,每次该更新的不更新,不该更新的被跳过,bug 反而更多。我的建议是:凡是没有明显性能问题的场景,先不用 memo。等你通过 React DevTools 的 Profiler 确认了某处存在性能瓶颈,再精准地加上useMemo或useCallback。性能优化应该基于测量,而不是基于猜测。3. 组件通信与数据流的几种方案3.1 props 逐层传递什么时候该停药React 的数据流是单向的,自上而下。子组件需要什么数据,父组件就通过 props 传什么。组件层级浅的时候很清爽,但项目一大,就会出现"中间组件只是路过数据"的情况——它自己不用这个 props,纯粹是为了传给更深层的子孙组件。这就是所谓的 props drilling 问题。我判断该不该引入其他通信方式的依据很简单:如果数据需要穿过三层以上的中间组件,而且中间组件完全不对数据做任何加工,那就要考虑 Context 或状态管理库了。三层是我的经验值,两层以内用 props 传递最清晰,超过三层维护成本就显著升高。3.2 Context 的适用边界与常见误区Context 解决了"跨层级传递"的问题,但它不是万能的。最关键的一点是:Context 的 value 一旦变化,所有消费这个 Context 的组件都会重新渲染,不管它们实际使用的是 value 中的哪一部分。const ThemeContext = createContext({ theme: 'light', user: null, config: {} }); // 这种写法很危险:每次渲染都生成新对象 <ThemeContext.Provider value={{ theme, user, config }}> {children} </ThemeContext.Provider>上面的写法里,value是个对象字面量,父组件每次渲染都会生成一个全新的对象引用,导致所有useContext(ThemeContext)的组件全部重新渲染,哪怕theme根本没变。解法是把 value 用useMemo缓存起来:const contextValue = useMemo(() => ({ theme, user, config }), [theme, user, config]); <ThemeContext.Provider value={contextValue}>{children}</ThemeContext.Provider>Context 适合放"低频变化、全局需要"的数据,比如主题、登录态、语言配置。不适合放"高频变化、局部使用"的数据,比如表单输入值、列表筛选条件。后者高频更新会导致大范围的组件重渲染,性能很差。如果你发现 Context 刷新太频繁,说明选型错了。3.3 状态管理库的选型思考我在几个项目里分别用过不同的状态管理方案,简单说说选型思路。中小型项目里,Context 加useReducer基本够用。useReducer的好处是把状态的更新逻辑集中到一个 reducer 函数里,便于测试和追踪,适合那种"多个操作会修改同一块状态"的场景。function reducer(state, action) { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; default: return state; } } const [state, dispatch] = useReducer(reducer, { count: 0 });再往上走,当项目里有大量跨页面、跨模块的共享状态,并且有服务端数据缓存、请求取消、状态持久化、时间旅行调试等需求时,就有必要引入专门的状态管理库。我现在的默认选择是轻量级的方案,不引入过度的概念和样板代码。我见过很多团队把服务端的数据请求结果也放进全局状态,这是一个值得商榷的做法。服务端数据和客户端状态是两回事:服务端数据有缓存、失效、重试的语义,客户端状态有本地交互、临时 UI 状态的语义。混在一起会让状态管理变得臃肿,而且很难理清数据的来源和更新时机。如果项目以服务端数据为主,我会优先考虑数据请求缓存方案,而不是把请求结果手动塞进全局 store。全局 store 只保留"真正跨组件共享的客户端状态"。4. 性能优化:从测量到实践的完整路径4.1 先找出"不值得渲染"的组件React 组件每次父组件渲染,默认都会跟着重新渲染。判断一个组件是否值得加memo,要看它的渲染开销大不大、渲染频率高不高、props 引用是否稳定。三个条件都满足,再加memo才有意义。const ExpensiveChart = memo(function ExpensiveChart({ data, onSelect }) { // 这里做了昂贵的图表绘制计算 return <Chart data={data} onSelect={onSelect} />; });memo的作用是让组件在 props 不变时跳过重新渲染。但它做的是浅比较,如果父组件给ExpensiveChart传了一个内联函数或内联对象,那么每次父组件渲染,props 的引用都变了,memo就形同虚设。这就是为什么useCallback通常和memo成对出现——一个负责稳定函数引用,一个负责跳过渲染。4.2 key 的正确使用是diff算法的地基列表渲染的key可能是 React 中最容易被敷衍的属性。我看到很多代码用数组下标做 key,一旦列表顺序变化、中间插入或删除元素,React 的复用逻辑就会出错,可能出现状态错位、输入框内容串行等问题。// 错误示范:用下标做 key {items.map((item, index) => <Item key={index} data={item} />)} // 正确示范:用稳定唯一的标识 {items.map((item) => <Item key={item.id} data={item} />)}key的本质是告诉 React:"这个组件实例对应的是哪个数据项"。只有用稳定唯一的标识,React 才能准确追踪每个组件的增删移动。如果数据没有天然的 id,可以自己生成一个唯一标识。同时要注意,key只需要在列表上下文中唯一,不需要全局唯一。还有一个小细节:不要用随机数或Date.now()做 key。那样会导致每次渲染 key 都不一样,React 会认为所有列表项都是新的,全部卸载重建,不仅状态丢失,性能也会崩。这在做拖拽排序或动画时特别容易踩到。4.3 渲染优化之外的大头:减少不必要的工作很多人一谈性能优化就只盯着"减少重新渲染",但在实际项目中,很多时候性能瓶颈根本不在渲染次数,而在于每次渲染时做了太多不必要的计算。比如在组件函数体里直接执行复杂的过滤、排序、格式化逻辑,这些逻辑在每次组件渲染时都会重算。而且因为闭包的原因,即使相关数据没变,只要组件重新渲染,这些纯函数也会被重新执行。解法是useMemo:const visibleOrders = useMemo(() => { return orders .filter((o) => o.status === 'active') .sort((a, b) => b.createTime - a.createTime); }, [orders]);另一个容易忽视的性能杀手是在渲染过程中创建新的对象和数组。如果这个对象被传给了一个memo包着的子组件,父组件每次渲染都会让子组件的 memo 失效,因为引用变了。这种问题用 React DevTools 的 Profiler 一眼就能看出来:明明数据没变,大量组件却红了一片。优化的顺序也很有讲究。我建议:先优化数据结构和计算逻辑,再优化渲染次数,最后才是各种 memo。很多时候,修改一个不合理的状态层级,比给十几个组件加 memo 有效得多。比如一个列表的筛选条件如果被定义在列表组件的父组件里,每次父组件状态变化都会导致列表重新过滤,这时把筛选逻辑下沉到列表内部,性能立刻改善。4.4 治理 useEffect 引起的重复请求项目上线后最常见的性能问题之一,是 useEffect 的依赖数组导致重复请求。比如:useEffect(() => { fetchData(keyword).then(setData); }, [keyword]);如果keyword从某个对象属性中解构而来,而那个对象每次渲染都是新引用,那这个 effect 就会请求风暴。排查的思路是:先在 effect 里打印依赖项的引用或值,看它是否每次都在变。如果是引用类型变化,用useMemo或把值提取成基本类型。另一个实用技巧是给请求加取消机制。React 18 之后,StrictMode 会在开发模式下故意执行两次 effect 来暴露副作用问题,如果请求没有做好防御,就会出现重复请求。用 AbortController 或者简单的局部标志位,可以在组件卸载或依赖变化时取消上一次请求:useEffect(() => { let ignore = false; fetchData(keyword).then((data) => { if (!ignore) setData(data); }); return () => { ignore = true; }; }, [keyword]);这段代码虽然简单,但能避免大量的竞态问题。忽略它的话,用户快速切换筛选条件时,后返回的旧请求可能覆盖新数据,出现数据错乱。这也是"React 知识笔记"里我标注过好几次的重点,建议每个写请求的人都记下来。5. 常见问题与排查技巧实录5.1 状态更新了但页面不刷新这是新手阶段最常遇到的问题。我排查时最先怀疑的永远是:状态到底是没更新,还是更新了但没触发渲染。先区分两种情况。第一种是把可变对象直接改掉:const [arr, setArr] = useState([1, 2, 3]); arr.push(4); // 直接修改原数组 setArr(arr); // 引用的地址没变,React 认为状态没变这里的问题在于 React 的useState是通过Object.is比较旧值和新值的。旧数组和新数组是同一个引用,所以 React 判定状态没变,跳过渲染。解法是永远返回一个新引用:setArr([...arr, 4]);第二种是试图"修改 props"。props 是只读的,子组件修改 props 不会影响父组件,应当在子组件里触发父组件的更新事件,由父组件改自己的状态。这条规则有时候要靠踩坑才记得住。5.2 闭包陷阱:为什么我的回调里拿到的总是旧值闭包陷阱的经典场景是这样的:你写了一个倒计时,setInterval 每秒让 count 减 1。然后你在某个事件回调里读取 count,发现一直是初始值。问题出在事件回调函数是在第一次渲染时创建的,它捕获的是那一轮渲染的 count 值,之后 count 变化,这个回调里的 count 还是旧值。解决思路有两种:如果回调需要"最新值",用ref镜像;如果回调本身需要在依赖变化时重新创建,把依赖写进useCallback的依赖数组里。const [count, setCount] = useState(0); const countRef = useRef(count); useEffect(() => { countRef.current = count; }, [count]); // 任何地方都能拿到最新值 const handleClick = () => { console.log(countRef.current); };动手排查时,先在怀疑的回调里打断点或者console.log相关变量,看它是哪次渲染的值。出现"总是第一版的值"基本就是闭包陷阱,不要再去分析业务逻辑了,方向错了会很浪费时间。5.3 依赖数组写不对导致的三个典型症状依赖数组是 useEffect 最容易写错的地方,我整理几类高频问题:症状原因解决方向effect 执行次数远超预期,甚至无限循环依赖了每次渲染都会变化的对象、数组或函数用 useMemo/useCallback 稳定引用,或拆解成基本类型依赖effect 不执行,数据是旧值漏写了某个内部使用的变量凡是在 effect 里用到的 props、state,都要列入依赖组件卸载后还在 setState清理函数没写或没写对在 effect 中返回清理函数,取消订阅、清除定时器、标记忽略有一个比较实用的小技巧来检查漏写的依赖:在项目里接入 lint 规则,当依赖数组缺失时会给出警告。这个 lint 提示在绝大多数情况下都是对的,值得养成不跳过它的习惯。当然 lint 会误报的场景也有,比如你确实只想在挂载时执行一次,又用到了某个只会变一次的值,这时候可以显式地把依赖写进数组,或者加注释说明原因,而不是硬把警告关掉。5.4 列表数据更新后组件状态串位这个问题的根源就是前面说的 key 用错。列表项组件是带本地状态的,当列表数据重新排序或过滤后,React 需要知道哪个组件实例对应哪个数据项。如果没有合适的 key,React 会复用旧的组件实例,导致原来第一项的输入框内容跑到了第二项身上。解决方法是检查列表渲染的 key 是否为稳定唯一标识。如果数据里没有 id,可以建立一个key映射关系,或者在数据入库时就生成 id。另一个相关的坑是,key 连带着组件的 DOM 结构一起决定 React 是复用还是重建。如果 key 相同但结构大变,React 会尽量复用 DOM 节点,可能导致样式错乱,这时可能需要调整 key 的粒度。写在最后的一点个人体会这些知识点单独拎出来,每一个都不复杂,但它们之间是相互关联的。props 传递影响组件边界,组件边界决定状态放哪,状态放哪影响重渲染范围,重渲染范围产生性能问题,性能问题又逼着你回头调整数据结构。我最大的感触是:React 的很多"坑"其实是它的设计特性,理解了特性背后的原理,坑自然就不是坑了。如果在这些笔记里只能留下一句话,我会选:所有 React 问题,本质上都是数据流和组件生命周期的问题。排查的时候先画清楚数据从哪来、到哪去、在哪变化,再去看具体代码,往往能少走很多弯路。希望这份笔记能给你带来一些实际的帮助,至少让你在写下一个组件时,心里少一点不确定,多一点底气。