React核心机制
虚拟 DOM 和 Diff 算法
什么是虚拟DOM
虚拟DOM可以理解为模拟了DOM树的JS对象树
比如
var element = {
element: 'ul',
props: {
id:"ulist"
},
children: [
{ element: 'li', props: { id:"first" }, children: ['这是第一个List元素'] },
{ element: 'li', props: { id:"second" }, children: ['这是第二个List元素'] }
]
}
为什么需要虚拟DOM
传统DOM更新方式的问题:
- 在原生JS中,更新DOM的方式往往是粗颗粒度的更新,直接替换整颗子树,容易造成性能的浪费
- 如果要做到细颗粒度更新,则需要自己决定修改哪一部分,但这种手动diff很麻烦
虚拟DOM更新的优势:
- 框架自动对新旧虚拟DOM树进行diff算法
- 然后精准更新DOM树中变化的部分,大幅度提升性能
举例:
比如有一个列表,我对其进行了修改
//旧UI
<ul id="list">
<li>苹果</li>
<li>香蕉</li>
</ul>
//新UI
<ul id="list">
<li>苹果</li>
<li>橘子</li> <!-- 改动 -->
</ul>
diff算法
传统diff算法的时间复杂度是O(n³)
为什么时间复杂度是O(n³)?
-
遍历旧树的每个节点(n次)
-
遍历新树的每个节点(n次)
-
比较两个节点的子结构是否完全相同
于是复杂度变成:
O(n)(旧树) × O(n)(新树) × O(n)(子树递归) = O(n³)
第 3 层递归比较子树的复杂度,是因为每一对匹配节点都还要递归地比较它们的子树结构。
前两层只是找出“候选节点对”,第三层才是深入检查“它们真的一样吗”。
所以整体复杂度是: 旧树节点数 × 新树节点数 × 子树递归 = O(n³) 。
React的diff算法的时间复杂度是O(n)
React 把问题简化成了三条“经验规则”,正是这三条规则让复杂度从 O(n³) → O(n)。
-
同层比较,不跨层
-
不同类型节点,直接替换整棵子树
- 也就是说,不同类型的节点永远不去比较子树。 这避免了对子树的递归匹配,进一步从 O(n²) → O(n) 。
-
通过 key 标识子节点的稳定性
- 对于同一层的子节点列表,React 通过 key 来判断哪些节点是“同一个节点”
//旧
<ul>
<li key="A">A</li>
<li key="B">B</li>
<li key="C">C</li>
</ul>
//新
<ul>
<li key="B">B</li>
<li key="A">A</li>
<li key="C">C</li>
</ul>
React 会通过 key 识别出:
- A、B、C 都还在;
- 只是顺序变了;
- 所以只需调整位置,不需要删除重建。
这就让 同层的节点比较只需一次线性扫描。
👉 因此,同层 diff 的复杂度变为 O(n) 。
key 的作用是什么?
-
是React对于列表元素的唯一标识
- 如果key相同,那么认为是同一节点,可以复用DOM元素
- 如果key不同,则会销毁旧的,创建新的节点
为什么不能用 index 作为 key?
因为会导致错误的复用和性能问题
- 因为列表内容如果从中间新增或者删除一项,那么index对应的元素将会错误的被复用
React 中 reconciliation 的过程是怎样的?
- 当组件的
state 或 props 变化时,React 会比较新旧虚拟 DOM(Fiber 树) ,找出需要更新的部分并同步到真实 DOM。这个比较与更新过程叫 Reconciliation
React 更新是同步还是异步的?
同步更新
同步模式下,React一旦开始渲染,就会一口气渲染完所有组件,期间不会中断
-
页面上的表现
- 当你触发一个大型渲染(比如 setState 导致 1000 个组件更新)时,页面会卡顿一下
- 浏览器在 React 渲染完成前,无法响应用户操作(比如滚动、点击)
function App() {
const [count, setCount] = useState(0)
const handleClick = () => {
for (let i = 0; i < 10000; i++) {
setCount(i) // 模拟大规模更新
}
}
return <button onClick={handleClick}>count: {count}</button>
}
在同步更新下,点击按钮后:
- UI 会“卡死”几百毫秒;
- 最后一次性更新成最终结果。
异步(Concurrent)更新(React 18 createRoot)
在并发模式下,React会把渲染拆分为小任务,在空闲时间片执行,可以随时暂停、恢复或丢弃
-
页面上的表现
- 大型渲染不再卡顿;
- 页面仍能响应滚动、输入、动画;
- React 会优先处理用户交互(高优先级),低优先级任务(如列表渲染)可延后执行。
import { useState, startTransition } from 'react'
function App() {
const [value, setValue] = useState('')
const [list, setList] = useState([])
const handleChange = (e) => {
const val = e.target.value
setValue(val)
startTransition(() => {
// 模拟高开销任务
const items = Array.from({ length: 5000 }, (_, i) => `${val}-${i}`)
setList(items)
})
}
return (
<>
<input value={value} onChange={handleChange} placeholder="输入点东西" />
<ul>{list.map((item) => <li key={item}>{item}</li>)}</ul>
</>
)
}
在异步(Concurrent)模式下:
- 输入框 不会卡顿;
- React 会优先更新输入框的值;
- 再利用空闲时间慢慢渲染列表;
- 如果你输入更快,React 会丢弃旧的渲染任务,直接开始最新的。
同步场景
React17及以前的全部更新,默认同步
// React 17 写法(使用 ReactDOM.render)
import ReactDOM from 'react-dom'
function App() {
const [count, setCount] = React.useState(0)
console.log('render:', count)
return (
<button onClick={() => setCount(count + 1)}>
Click: {count}
</button>
)
}
ReactDOM.render(<App />, document.getElementById('root'))
💬 说明:
- 在 React 17(及更早版本)中,React 没有并发模式(Concurrent Mode) 。
- 所有更新(无论大或小)都是同步执行的。
- 点击按钮时,会立刻执行所有渲染逻辑。
📍页面效果:
即使组件很复杂、渲染耗时,React 也会“卡着”把它一次性渲染完。
React 18 中的旧 Root(非 Concurrent Root)
// React 18,但仍使用 ReactDOM.render(旧 Root)
import ReactDOM from 'react-dom'
import App from './App'
ReactDOM.render(<App />, document.getElementById('root'))
- 即使你使用 React 18,只要还用旧的
ReactDOM.render, React 就不会启用并发模式(仍是同步更新)。
- 所以这种 root 下的渲染依然会一次性执行完,期间不能被打断。
📍页面效果:
和 React 17 完全一样,仍然是同步阻塞渲染。
在 React 事件回调中调用的更新
import { useState } from 'react'
function App() {
const [count, setCount] = useState(0)
const handleClick = () => {
console.log('Before:', count)
setCount(count + 1)
console.log('After:', count)
}
return <button onClick={handleClick}>Click: {count}</button>
}
即使你在 React 18 并发模式下(使用 createRoot), 在 React 事件回调中触发的更新仍是同步批量更新。
React 会立即计算新的 Fiber 树,保证交互即时。
异步场景
使用 createRoot()
使用 createRoot()(并发 root)—— 开启异步渲染能力
// React 18 推荐写法(Concurrent Root)
import ReactDOM from 'react-dom/client'
import App from './App'
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(<App />)
-
createRoot() 会启用 Concurrent Mode(并发模式) ;
- 在这种 root 下,React 的更新具备可中断、可延迟的能力;
- 不代表所有更新都异步,但具备异步调度的基础条件。
📍页面效果:
如果组件渲染量大,React 可以暂停、分段渲染,不会卡死主线程。 用户输入或动画依然流畅。
使用 startTransition()(标记低优先级更新)
import { useState, startTransition } from 'react'
function App() {
const [value, setValue] = useState('')
const [list, setList] = useState([])
const handleChange = (e) => {
const val = e.target.value
setValue(val)
// 👇 告诉 React:这是低优先级任务,可延迟执行
startTransition(() => {
const items = Array.from({ length: 5000 }, (_, i) => `${val}-${i}`)
setList(items)
})
}
return (
<>
<input value={value} onChange={handleChange} placeholder="输入点东西" />
<ul>{list.map((item) => <li key={item}>{item}</li>)}</ul>
</>
)
}
说明:
-
startTransition() 将内部更新标记为可中断任务;
- 高优先级任务(输入框更新)会先执行;
- 低优先级任务(列表渲染)会在空闲时执行;
- 若用户继续输入,React 会丢弃旧任务、渲染最新的。
📍页面效果:
输入非常流畅,列表延迟更新但不卡顿。
使用 useDeferredValue()(延迟渲染依赖值)
import { useState, useDeferredValue } from 'react'
function App() {
const [text, setText] = useState('')
const deferredText = useDeferredValue(text) // 延迟使用 text 的值
const list = Array.from({ length: 5000 }, (_, i) => (
<li key={i}>{deferredText}</li>
))
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ul>{list}</ul>
</>
)
说明:
-
useDeferredValue() 会在高优先级更新(输入)后,延迟执行耗时渲染;
- 输入流畅;
- 列表内容更新稍后完成。
📍页面效果:
输入框立即响应,列表延迟一点点更新。 类似于“防抖 + 并发更新”的效果
一句话总结:
在 React 18 中,只要使用 createRoot() 启动应用, 你就进入了并发世界。 再搭配 startTransition() / useDeferredValue(), 就能让 React 的更新更智能、更流畅。
Fiber 是为了解决什么问题?
- Fiber 是 React 为了解决「同步更新导致的卡顿问题」而引入的「可中断、可恢复的虚拟 DOM 架构」
- Fiber 是 React 的底层重构,用于让虚拟 DOM 更新“可中断、可恢复、可调度”,从而提升大规模渲染的流畅度。
背景问题
React15的缺陷,在React15之前,更新流程是这样的:
- 状态更新后,React 会从根节点开始,递归遍历整棵虚拟 DOM 树;
- 每次更新都同步执行到底(不能中断)
- 如果组件层级很深、计算复杂,就会长时间占用主线程;
- 主线程被占用时,浏览器无法响应用户操作(如输入、滚动) → 卡顿、掉帧
fiber的核心目标
React团队为了解决“更新太重,无法中断”的问题,引入了fiber架构
- 可中断更新:react更新可以被中断,让浏览器先去响应用户操作
- 可分片执行:大任务被拆分为小任务,(每一帧执行一点)
- 可恢复与重用:被中断后可以从上次中断的地方继续执行
Fiber的设计思路
React 把每个虚拟 DOM 节点(VNode)包装成一个 Fiber 对象, 这个对象包含:
- 节点类型、props、state
- 指向父节点、子节点、兄弟节点的指针(形成链表结构)
- 更新优先级信息(lane)
- 副作用标记(如需插入/删除/更新)
➡️ 这样 React 就可以:
- 用「遍历链表」代替「递归函数调用」(可随时暂停)
- 在空闲时间片中继续工作(用调度器协调)
- 动态决定哪部分更新优先(配合 Concurrent Mode)
Fiber 是如何实现可中断渲染的?
- Fiber 通过把递归改为循环、把组件树改为链表结构、并利用时间片调度机制,使得 React 的渲染可以“暂停—恢复—继续”,从而实现了可中断渲染。
在React15中,更新虚拟DOM时使用的是递归遍历整棵树的方式
function updateComponent(component) {
component.render()
component.children.forEach(updateComponent)
}
问题是:
- JS 是单线程的;
- 一旦进入这段递归逻辑,就无法中途暂停;
- 如果组件树很大,浏览器主线程会被长期占用;
- 用户交互、动画、输入都会卡顿。
React 16 重写架构为 Fiber Reconciler。 目标:让“渲染过程”像执行协程一样 —— 可中断、可恢复、可调度。
Fiber 的关键设计思想是:
“把每个虚拟 DOM 节点(VNode)变成一个 Fiber 对象,并把组件树改成链表结构。”
这样 React 就可以:
- 用
while 循环遍历链表(而非递归);
- 每处理一个 Fiber 节点,都检查当前帧是否超时;
- 如果时间用完,就暂停渲染,把控制权交还浏览器;
- 下一帧(或空闲时)再从上次中断的 Fiber 继续工作。
React Hooks
useState
基础概念
useState 是 React 提供的用于在函数组件中声明状态(state)的 Hook。
const [state, setState] = useState(initialValue)
-
state:当前状态值。
-
setState:更新状态的函数,会触发组件重新渲染。
-
initialValue:初始状态值,只在组件首次渲染时使用。
运行机制
当组件执行时(函数重新运行),useState 并不会重新创建新的状态,而是通过 React 内部的 Hook 链表(Fiber)机制 取回上一次保存的状态值。
也就是说:
- 虽然函数重新执行了,
- 但
useState 通过闭包 + 内部索引保存并取回之前的状态。
👉 因此即使多次调用 useState(0),state 也不会回到 0。
React 约束: Hook 调用顺序必须一致,否则状态会错位。
let x = []
let index = 0
const myUseState = initial => {
let currentIndex = index
x[currenIndex] = x[currentIndex] === undefined ? initial : x[currentIndex]
const setInitial = value => {
x[currentIndex] = value
render()
}
}
//模拟render函数
const render = () => {
index = 0
ReactDOM.render(<App/>, document.querySelector("#root"))
}
const App = () => {
const [n, setN] = myUseState(0)
const [m, setM] = myUseState(0)
return (
<div>
<p>n:{n}</p>
<button onClick={()=>setN(n+1)}>+1</button>
<p>m:{m}</p>
<button onClick={()=>setM(m+1)}>+1</button>
</div>
)
}
异步批处理(Batch Update)
React 会将多个状态更新合并执行(在事件回调中)。
setCount(count + 1)
setCount(count + 1)
// 实际只增加一次
在 React 18 中,异步任务(如 setTimeout、Promise)中的 setState 不再强制合并。
惰性初始化
初始值可以是一个函数:
const [data, setData] = useState(() => heavyCalculation())
✅ heavyCalculation() 只在首次渲染时执行,避免每次渲染重复计算。
更新函数形式
当新状态依赖旧状态时,用函数式更新:
setCount(prev => prev + 1)
useEffect
一、作用
useEffect 用于处理 副作用(side effects) ,比如:
- 网络请求
- 订阅 / 事件监听
- 操作 DOM
- 定时器
这些逻辑不能直接放在渲染阶段,否则会阻塞或污染渲染。
-
函数组件需要是纯函数:
- 相同输入 → 永远相同输出
- 不修改外部变量
- 不产生额外行为
-
React 设定函数组件必须满足:
- 相同的 props & state → 必须产生完全相同的 UI
换句话说:
组件函数必须像数学函数一样:输入 → 输出 UI
二、执行时机
-
初次渲染后 执行(不会阻塞渲染)。
-
依赖项变化 时重新执行。
-
组件卸载前 执行清理函数。
三、依赖数组 [deps]
| 写法 |
执行时机 |
useEffect(fn) |
每次渲染都执行 |
useEffect(fn, []) |
仅挂载和卸载时执行一次 |
useEffect(fn, [a, b]) |
当依赖项 a 或 b 改变时执行 |
⚠️ React 比较依赖项是浅比较,如果依赖对象或数组的引用变了,即使内容没变也会触发。
四、清理函数
返回一个函数,用于卸载或重新执行前清理副作用:
useEffect(() => {
const id = setInterval(() => console.log('tick'), 1000)
return () => clearInterval(id)
}, [])
执行时机:
- 组件卸载时;
- 副作用重新执行前。
五、面试常问点
-
useEffect 为什么在渲染后执行? 为了让渲染过程纯净,不被副作用打断。
-
为什么要写依赖数组? 告诉 React 什么时候重新运行副作用,否则可能死循环。
-
依赖项写错或少写会怎样? 可能导致状态不同步或逻辑失效(React 会在严格模式下警告)。
-
useLayoutEffect 和 useEffect 的区别?
-
useEffect:渲染完成后异步执行,不阻塞绘制。
-
useLayoutEffect:DOM 更新后、浏览器绘制前同步执行,可用于测量 DOM。
useRef
核心定义
-
useRef 是一个能在组件整个生命周期内 保持引用不变 的 Hook。
- 它返回一个可变对象
{ current: ... },这个对象在组件的重新渲染中不会被重置。
const ref = useRef(initialValue)
console.log(ref.current) // ref.current 保存的数据在组件多次渲染之间是持久的
应用场景
获取DOM节点
- 在React中使用useRef获取DOM比原生方式获取DOM更可靠
-
inputRef.current 会指向对应的 DOM 元素。
- 通常用于:聚焦、滚动、测量宽高、绑定第三方库。
function App() {
const inputRef = useRef(null)
useEffect(() => {
inputRef.current.focus()
}, [])
return <input ref={inputRef} />
}
保存 任意可变值(不触发重新渲染)
count.current 的值在组件重渲染时仍然保持;
修改 ref.current 不会引起重新渲染;
所以它非常适合存储:
- 前一次的值(用于比较)
- 定时器 id
- 防抖节流计数器
- 某个状态的缓存值
function Timer() {
const count = useRef(0)
const handleClick = () => {
count.current += 1
console.log(count.current)
}
return <button onClick={handleClick}>Click</button>
}
useMemo
- 开发中,我们只要修改了父组件的数据,所有的子组件都会重新渲染,这是十分消耗性能的
- 如果我们希望子组件不要进行这种没有必要的重新渲染,我们可以将子组件继承PureComponent或者使用memo函数包裹
import React, { memo, useState, useEffect } from 'react'
const A = (props) => {
console.log('A1')
useEffect(() => {
console.log('A2')
})
return <div>A</div>
}
const B = memo((props) => {
console.log('B1')
useEffect(() => {
console.log('B2')
})
return <div>B</div>
})
const Home = (props) => {
const [a, setA] = useState(0)
useEffect(() => {
console.log('start')
setA(1)
}, [])
return <div><A n={a} /><B /></div>
}
- 将子组件B使用memo包裹之后,Home组件中的状态a的变化就不会导致B组件的重新渲染
- 但是在子组件B使用了父组件的某个引用类型的变量或者函数时,那么当父组件状态更新之后,这些变量和函数就会重新赋值,导致子组件B还是会重新渲染
- 想解决这个问题,就需要使用useMemo和useCallback了
useCallback
- 当函数组件重新渲染时,其中的函数也会被重复定义多次
- 如果使用useCallBack对函数进行包裹,那么在依赖(第二个参数)不变的情况下,会返回同一个函数 这样子组件就不会因为函数的重新定义而导致重新渲染了
- useMemo和useCallBack相似,缓存的是函数的返回值,一般用来优化变量,但是如果将useMemo的返回值定义为返回一个函数就可以实现useCallBack一样的功能
//用useMemo实现同useCallback一样的效果
const increment = useCallback(fn,[])
const increment2 = useMemo(()=>fn,[])
import React, { memo, useState, useEffect, useMemo } from 'react'
const Home = (props) => {
const [a, setA] = useState(0)
const [b, setB] = useState(0)
useEffect(() => {
setA(1)
}, [])
const add = useCallback(() => {
console.log('b', b)
}, [b])
const name = useMemo(() => {
return b + 'xuxi'
}, [b])
return <div><A n={a} /><B add={add} name={name} /></div>
}
useContext
useContext 是什么?
useContext 是 React 的一个 Hook,用于:
- 在函数组件中直接读取由上层组件
Context.Provider 提供的值。
简单理解:
- 不用一层层 props 传递,也能让深层组件拿到共享数据。
语法
const value = useContext(MyContext)
- MyContext 是通过 React.createContext() 创建的上下文对象。
- useContext() 返回最近的 <MyContext.Provider> 提供的 value。
- 当 Provider 的 value 变化时,所有使用该 context 的组件都会重新渲染。
使用步骤
// context.js
import { createContext } from "react"
export const ThemeContext = createContext("light")
// App.jsx
import { ThemeContext } from "./context"
import Child from "./Child"
function App() {
return (
<ThemeContext.Provider value="dark">
<Child />
</ThemeContext.Provider>
)
}
// Child.jsx
import { useContext } from "react"
import { ThemeContext } from "./context"
function Child() {
const theme = useContext(ThemeContext)
return <div>当前主题:{theme}</div>
}
特性
| 特性 |
说明 |
| 最近优先 |
如果组件外层有多个相同类型的 Provider,会取最近一层的 value |
| 响应式更新 |
Provider 的 value 改变,会触发所有消费该 Context 的组件重新渲染 |
| 只能在函数组件或自定义 Hook 中使用 |
不能在类组件或普通函数中调用 |
| 不能脱离 Provider 使用 |
如果没有 Provider 包裹,会使用 createContext() 时设置的默认值 |
应用场景
| 场景 |
示例 |
| ✅ 主题切换 |
dark / light 模式 |
| ✅ 登录状态 |
用户信息、Token |
| ✅ 多语言切换 |
中英文语言包 |
| ✅ 全局配置 |
比如分页大小、API地址等 |
常见问题
useContext 和 props 传递的区别?
| 对比项 |
props |
useContext |
| 数据传递 |
一层层手动传递 |
任何层级都可直接拿到 |
| 灵活性 |
高(精确控制) |
全局性(可能过度渲染) |
| 适用场景 |
局部数据传递 |
全局共享状态 |
useContext 的缺点是什么?
- 当
Provider 的 value 改变时,所有消费它的组件都会重新渲染;
- 这可能导致性能问题(无论组件是否使用了 value 的具体字段);
- 因此大型项目中往往结合
useReducer 或 Redux / Zustand 等状态管理库 一起使用
forwardRef
在React开发中,有些时候我们需要获取DOM或者组件来进行某些操作
import React, { PureComponent ,createRef} from 'react'
export class App extends PureComponent {
//创建ref
this.titleRef = createRef()
}
getNativeDOM(){
console.log(this.titleRef.current)
}
render() {
return (
<div>
<h2 ref={this.titleRef}>hello world</h2>
<button onClick={e=>this.getNativeDOM()}>获取DOM</button>
</div>
)
}
}
export default App
ref 的值根据节点的类型有所不同:
- 当ref属性作用于HTML属性时,接收底层DOM元素作为其current属性
- 当ref属性作用于class组件时,接收组件实例作为其current属性
-
不能在函数组件上使用ref属性,因为他们没有实例
想将ref挂载到函数组件内部的某个class组件或者HTML元素上时,我们需要使用React.forwardRef将函数组件包裹,从而将ref传递到组件内部
//获取函数组件的某个DOM
//使用forwardRef之后,可以传入两个参数,第二个为ref,我们可以实现ref转发
const Fun = forwardRef(function (props,ref) {
return (
<h1 ref={ref}>hello react</h1>
)
})
使用ref作用于类组件,并调用类组件实例的方法
import React, { PureComponent, createRef, forwardRef } from 'react'
//类子组件
class HelloWorld extends PureComponent {
test() {
console.log("test---")
}
render() {
return (
<h1>hello world</h1>
)
}
}
export class App extends PureComponent {
constructor() {
super()
this.state = {}
this.hwRef = createRef()
}
getComponent() {
//调用类组件实例的方法
console.log(this.hwRef.current)
this.hwRef.current.test()
}
render() {
return (
<div>
<HelloWorld ref={this.hwRef} />
<button onClick={e => this.getComponent()}>获取组件实例</button>
</div>
)
}
}
export default App
useImperativeHandle
import React, {
useRef,
forwardRef, useImperativeHandle
} from 'react'
const HYInput = forwardRef((props,ref)=> {
const inputRef = useRef()
useImperativeHandle(ref,()=> {
return {
focus: () => {
console.log(345);
inputRef.current.focus()
}
}
})
return <input ref={inputRef} type="text"/>
})
export default function UseImperativeHandleHookDemo() {
const inputRef = useRef()
return (
<div>
<HYInput ref={inputRef}/>
<button onClick={e=>inputRef.current.focus()}>聚焦</button>
</div>
)
}
原理题
React Hooks 为什么不能放在条件判断里?
每个组件渲染时,React 会维护一个“Hook 调用链表”或“数组”, 类似这样(简化理解):
// 第一次渲染
useState('A') // Hook 1
useEffect(...) // Hook 2
useState('B') // Hook 3
React 会按顺序记下每一个 Hook 对应的状态(存在 Fiber 节点上)。
下一次渲染时,React 会再次按相同顺序调用 Hook 来匹配之前的状态。
如果放在条件语句中会发生什么?
if (flag) {
useState(1)
}
useEffect(() => {})
-
第一次渲染:
- flag = true → 执行
useState(Hook 1)
- 执行
useEffect(Hook 2)
-
第二次渲染:
- flag = false → 跳过
useState
-
useEffect 变成了 Hook 1!
🚨 React 内部匹配错位! 本该给 useEffect 的 Hook 状态,被错误地分配成了之前的 useState 状态。
结果可能报错:
Rendered fewer hooks than expected
Invalid hook call
Hooks的执行顺序是如何保证的?
- React 通过在每个 Fiber 节点上维护一个 Hook 链表
- 并在每次渲染时按顺序遍历执行
- 从而确保每个 Hook 的状态和顺序一致。
自定义 Hook 怎么避免闭包陷阱?
- 使用函数式更新(最常用)
const increment = () => setCount(prev => prev + 1)
-
prev 永远是最新 state
- 无需依赖闭包捕获的旧值
- 适用于事件回调、定时器、异步请求等
- 用
useRef 保存最新值
如果你需要在闭包里访问最新状态而不触发重新渲染:
const countRef = useRef(count)
useEffect(() => {
countRef.current = count
}, [count])
const logCount = () => {
console.log(countRef.current) // 永远是最新值
}
- 异步函数或事件可以使用
countRef.current
- 不影响 React 渲染流程
组件渲染与性能优化
React 组件何时重新渲染?
-
state发生变化:只要你调用 setState 产生了新的值(引用变化),组件就会重新渲染。
-
props变化:只要父组件重新渲染,子组件也会跟着渲染(除非使用 React.memo)。
即使 props 内容没变 —— 只要父组件 render,子组件也会 render。
-
context变化:当某个 Context Provider 的 value 改变,所有消费该 context 的子组件都会重渲染。
-
父组件重新渲染导致子组件渲染(哪怕 props 不变)
浅比较会对比:
-
基本类型值(number / string / boolean / null / undefined) → 直接比较值是否相等。
-
引用类型(object / array / function) → 只比较引用地址是否相同,不会比较内部的内容。
React 在性能优化时会用浅比较,比如:
- React.memo
- PureComponent
- shouldComponentUpdate
- useMemo / useCallback 的依赖项比较
- useEffect / useCallback 的依赖数组
因为浅比较非常快,不需要深度遍历对象。
-
浅比较带来的典型问题
- 使用 inline function 导致子组件重新渲染
<Child onClick={() => setCount(count + 1)} />
每次父组件 render 时都会创建新的函数引用 → 浅比较结果:不同 → 子组件重新渲染
-
解决方法
- 用 useCallback 固定函数引用
- 用 useMemo 固定对象 / 数组引用
- 子组件用 React.memo
如何优化一个大表格或长列表的渲染性能?(虚拟列表)
-
为什么需要虚拟列表?
-
当列表有 成百上千甚至上万条数据时:
- 浏览器会创建大量 DOM(慢)
- 布局、重排、重绘消耗巨大(卡)
- 滚动时频繁触发渲染(卡顿)
👉 核心思路: 只渲染可视区域内的那几十个节点,其余内容用占位高度撑开。
-
什么时候用虚拟列表
-
满足任一即可使用虚拟列表:
- 单页表格数据量 > 200 行
- 单页列表 > 300 行
- 存在大量复杂 DOM(图片、按钮、操作列)
- 有频繁更新、滚动操作
-
核心原理(一句话版本)
- 只有可视区域 + 缓冲区的元素真实渲染
- 其他区域只用一个大容器撑开高度
- 视觉上像完整列表,但实际 DOM 数量永远保持几十个
-
虚拟列表关键技术
-
容器高度:列表容器要固定高度或可计算高度,否则无法计算可视范围。
-
每行高度:
- 固定高度:最好实现,可用rowheight直接算
- 不定高度:需要实时记录高度(难度更高)
-
计算可视区域的起止 index
startIndex = Math.floor(scrollTop / rowHeight)
endIndex = startIndex + 可视区域行数 + buffer
-
渲染可视区域数据
-
使用 translateY 把渲染的内容“挪”到正确位置
style="transform: translateY(startIndex * rowHeight px)"
-
前端常用虚拟列表方案
-
React:react-window
- 轻量、简单、性能极佳。
-
<FixedSizeList> 固定行高列表
-
<VariableSizeList> 不定高度列表
-
<FixedSizeGrid> 表格(大表格强烈推荐)
import { FixedSizeList as List } from "react-window";
<List
height={600}
width={800}
itemSize={40}
itemCount={list.length}
>
{({ index, style }) => (
<div style={style}>{list[index].name}</div>
)}
</List>
<Table
scroll={{ y: 600 }}
virtual
columns={columns}
dataSource={data}
/>
import React, { useRef, useState, useEffect } from "react";
export default function VirtualList({ itemHeight, height, data, renderItem }) {
const containerRef = useRef(null);
const [startIndex, setStartIndex] = useState(0);
const visibleCount = Math.ceil(height / itemHeight); // 可视区域展示多少条
// 滚动事件
const onScroll = () => {
const scrollTop = containerRef.current.scrollTop;
const newStartIndex = Math.floor(scrollTop / itemHeight);
setStartIndex(newStartIndex);
};
// 当前需要渲染的数据
const endIndex = startIndex + visibleCount;
const visibleData = data.slice(startIndex, endIndex);
// 用两个 padding 占位本来应该存在的高度
const paddingTop = startIndex * itemHeight;
const paddingBottom = (data.length - endIndex) * itemHeight;
return (
<div
ref={containerRef}
style={{
height,
overflowY: "auto",
border: "1px solid #ccc",
}}
onScroll={onScroll}
>
<div style={{ paddingTop, paddingBottom }}>
{visibleData.map((item, i) =>
renderItem(item, startIndex + i)
)}
</div>
</div>
);
}
React.lazy + Suspense 是如何实现懒加载的?
React.Lazy是什么?
-
React.lazy 是 React 内置的函数,用于 动态导入组件(代码分割),语法:
const MyComponent = React.lazy(() => import('./MyComponent'));
特点:
- 接受一个函数,函数返回一个 Promise,resolve 后是一个 默认导出的 React 组件
-
组件本身不会立即加载,只有在真正渲染到 UI 时才触发加载
Suspense是什么?
<Suspense> 是一个 边界组件,用于处理 异步加载的组件:
<Suspense fallback={<div>Loading...</div>}>
<MyComponent />
</Suspense>
- 当
MyComponent 还没加载完时,显示 fallback
- 加载完后,React 会自动替换为真实组件
懒加载原理(核心)
核心思路:
-
React.lazy 返回一个“特殊对象”
const LazyComp = React.lazy(() => import('./Comp'));
- 内部包装成一个 可 Suspense 的组件
- 本质上是一个 占位组件,在加载完成前不会渲染真实内容
-
渲染阶段触发加载
import React, { Suspense } from 'react';
const LazyComponent = React.lazy(() => import('./HeavyComponent'));
export default function App() {
return (
<div>
<h1>Hello</h1>
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
</div>
);
}
React18 的自动批处理
- 批处理指的是
- 将多个 state 更新合并为一次渲染,避免重复渲染,提升性能。
传统 React 批处理(React 17 及以前)
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// React 17:在原生事件中,这两个 setState 会 **自动批处理**
// 只触发一次渲染
}
<button onClick={handleClick}>Click</button>
setTimeout(() => {
setCount(c => c + 1); // 单独触发一次渲染
setFlag(f => !f); // 再触发一次渲染
}, 0);
React 18 的自动批处理(Automatic Batching)
React 18 扩展了批处理范围:
- 不再局限于 React 事件
- 异步更新也会自动批处理
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React 18:只触发一次渲染
}, 0);
Promise.resolve().then(() => {
setCount(c => c + 1);
setFlag(f => !f);
// 也只触发一次渲染
});
⚡ 核心:React 18 会自动在 所有更新上下文中收集 state 更新,然后一次性执行渲染。
自动批处理原理
-
React 内部维护一个 更新队列
-
当多个 state 更新发生时(不管是同步还是异步):
-
所有更新完成后,React 只执行一次 commit,更新 Fiber 树
setCount
setFlag
------------------
React 批量更新渲染 UI
什么时候生效
| 场景 |
React 17 |
React 18 |
| React 事件 |
✅ 自动批处理 |
✅ 自动批处理 |
| setTimeout / setInterval |
❌ 单独渲染 |
✅ 自动批处理 |
| Promise.then / async await |
❌ 单独渲染 |
✅ 自动批处理 |
| 原生 DOM 事件(addEventListener) |
❌ 单独渲染 |
✅ 自动批处理 |
| React 18 startTransition
|
✅ 用于低优先级更新 |
✅ 可批处理并标记低优先级 |
并发渲染
1. 什么是 “并发渲染(Concurrent Rendering)”?
并发渲染是 React 18 引入的新渲染模式,让 React 能做到:
渲染可以被中断、继续、丢弃,不再是同步阻塞式的。
简单说:
- React 不再一次性渲染整个组件树
- React 现在可以 像操作系统一样调度任务
🟩 高优先级任务(用户输入、点击)
🟨 中优先级任务(网络返回)
🟦 低优先级任务(大量 DOM 更新、重渲染)
从而实现:
✔ 更流畅的页面交互
✔ 不阻塞用户输入
✔ 懒加载 / 复杂计算渲染不会卡顿
2. 为什么叫 “并发(Concurrent)”?
不是多线程,也不是浏览器真正的“并行”。
React 的并发渲染本质是:
利用 Fiber 架构,将渲染过程分片(切成小块),使用浏览器空闲时间执行。
即:
所以看起来像“并发”,实际上是 可中断的异步调度。
3. 并发渲染是不是自动提升性能?
是的,但不是“万能优化”。
- 并发渲染让用户交互更流畅
- 但不会减少你的组件渲染量
- 也不会自动让你的算法更快
- 它只是“调度更聪明”
如果组件本身太重 → 还是要优化(memo、虚拟列表等)
避免 Re-render(避免不必要重新渲染) 的全部技巧
为什么会重新渲染?
React 组件 Re-render 的触发条件:
- 自身 state 改变
- 父组件重新渲染(props 引用变了)
- context value 变了
- store(redux/mobx/zustand)订阅改变
- key 变了(会卸载重建)
👉 所有优化的目标都是:
避免组件接收到新的 props 引用、避免不必要的父组件渲染。
1. 使用 React.memo(对 props 做浅比较)
适合:组件经常被父组件重新渲染,但 props 未变化。
const Child = React.memo(function Child({ data }) {
console.log("child render");
return <div>{data.count}</div>;
});
👉 只要 props 浅比较不变 → 不重新渲染。
2. 使用 useCallback 让函数引用保持稳定
父组件中每次 render 都会重新创建函数:
<Child onChange={() => {}} />
会导致 Child 每次渲染。
解决:
const handleChange = useCallback(() => {
...
}, []);
3. 使用 useMemo 缓存计算结果 / 对象引用
对象字面量每次 render 都是新引用:
<Child config={{ size: 20 }} />
导致 Child 每次 render。
改成:
const config = useMemo(() => ({ size: 20 }), []);
<Child config={config} />
4. 缓存 Context 值:用 useMemo 包裹 Provider 的 value
错误用法:
<ThemeContext.Provider value={{ dark }}>
value 每次都是新对象 → 全体订阅者 re-render。
正确:
const value = useMemo(() => ({ dark }), [dark]);
<ThemeContext.Provider value={value}>
Context 很容易导致全局刷新,必须优化。
5. 拆分组件(最现实有效)
父组件频繁渲染,子组件不需要跟着渲染。
拆分:
function Parent() {
return (
<>
<HeavyPart /> // 不常变
<DynamicPart /> // 经常变
</>
);
}
再给 HeavyPart 加 memo:
export default React.memo(HeavyPart);
👉 React 会基于 Fiber 对不同子树分别调度。
6. key 稳定不要乱变
错误:
items.map((x, i) => <Row key={Math.random()} />)
React 认为每次都是新组件 → 全部重渲染/卸载重建。
正确:
items.map((x) => <Row key={x.id} />)
7. 避免在 render 中发请求/操作 DOM(会导致额外重渲染)
必须放到:
- useEffect
- useLayoutEffect
否则渲染 -> 副作用 -> setState -> 重新渲染 → 死循环风险。
综合理解的口诀
Re-render 本质上就是:props 引用变 / state变 / context变 / 父组件变
所有优化技术都是:
避免创建新引用
+
避免父组件不必要渲染
+
让组件拆分更细粒度