阅读视图

发现新文章,点击刷新页面。

React Hooks 核心原理

Hooks 是 React 16.8 推出的里程碑特性,核心目的是 让函数组件拥有类组件的状态管理和生命周期能力,彻底解决了函数组件无法维护状态、代码复用繁琐的痛点。其底层原理围绕「Hook 调用顺序」和「Hook 存储结构」展开,逻辑简洁但约束严格,是面试高频考点。

一、核心前提:为什么 Hooks 必须依赖固定调用顺序?

通俗理解:函数组件每次渲染(首次渲染/重渲染)都会从头到尾重新执行一遍,Hooks 要想“记住”每次渲染的状态,就必须保证每次执行时,调用的顺序完全一致——就像排队领号,每次排队的顺序不能乱,才能对应到自己的号码(状态)。

专业拆解:这是 Hooks 原理的基石,核心是「状态与 Hook 调用的一一对应」,依赖两个关键底层设计:

1. 底层存储结构:Hook 链表(核心!)

React 内部为每个组件的 Fiber 节点(组件的底层抽象表示,可理解为“组件的骨架”),维护了一个 Hook 单向链表,用于存储该组件所有 Hooks 的相关信息。

每个 Hook 本身是一个对象,官方简化结构:

// 单个 Hook 节点的极简结构
const hook = {
  memoizedState: null, // 存储当前 Hook 的状态(如 useState 的值、useEffect 的回调)
  next: null, // 指向下一个 Hook 节点,串联成链表
  queue: null, // 存储该 Hook 的更新队列(如 setState 触发的新值)
};

补充说明:组件 Fiber 节点中,通过 fiber.memoizedState 指向 Hook 链表的头节点,后续每个 Hook 节点通过 next 依次连接,形成完整的链表结构(对应题干中 fiber.memoizedState = { memoizedState, next } 的极简模型)。

除了 Hook 链表,每个 Hook 节点还包含一个「更新队列」(queue),用于存储该 Hook 的待更新状态(比如 useState 调用 setXxx 时,新值会先存入 queue,等待组件重渲染时更新)。

2. 调用顺序的核心作用

React 无法通过“变量名”识别 Hooks,只能通过「调用顺序」匹配 Hook 链表中的节点,具体流程分两步:

  1. 首次渲染:函数组件执行时,会依次调用 useState、useEffect 等 Hooks,每调用一个 Hook,就创建一个对应的 Hook 节点,挂载到 Hook 链表的末尾,同时初始化 memoizedState(状态)和 queue(更新队列)。

  2. 重渲染:组件因 setState、props 变化等触发重渲染时,函数组件会再次执行,此时 React 会从 Hook 链表的头节点开始,按「与首次渲染完全相同的顺序」遍历链表,读取每个 Hook 节点的 memoizedState,从而保证“Hook 调用”与“状态”一一对应。

举个通俗例子:

function Counter() {
  // 第1个 Hook:对应链表头节点,存储 count 状态
  const [count, setCount] = useState(0);
  // 第2个 Hook:对应链表第二个节点,存储 name 状态
  const [name, setName] = useState('React');
  return <button onClick={() => setCount(count+1)}>{count}-{name}</button>;
}

首次渲染时,count 对应链表第1个节点,name 对应第2个节点;重渲染时,依然按“先 count 后 name”的顺序读取,状态不会错乱。但如果破坏顺序(比如写在条件里),React 就无法匹配到正确的节点,直接报错。

二、核心 Hooks 原理拆解

重点拆解最常考的两个 Hooks:useState(基础)和 useEffect(高频),原理简化为“步骤化”,方便背诵。

1. useState 原理(最基础,必背)

通俗理解:useState 就像一个“带记忆的盒子”,第一次调用时放入初始值,之后每次调用,要么取出盒子里的当前值,要么通过 setXxx 替换盒子里的值,并且会通知组件重新渲染。

专业拆解:本质是「读取/更新 Hook 链表中对应节点的状态」,步骤分为3个阶段:

(1)首次渲染时

  1. 创建一个新的 Hook 节点,将传入的初始值(如 0)赋值给该节点的 memoizedState。

  2. 将这个 Hook 节点挂载到组件 Fiber 的 Hook 链表末尾(通过 next 指针连接)。

  3. 返回一个数组[memoizedState, setXxx]:第一个元素是当前状态,第二个元素是触发状态更新的函数(setXxx)。

(2)调用 setXxx 时(触发更新)

  1. 将 setXxx 传入的新值,存入对应 Hook 节点的 queue(更新队列)中。

  2. 触发组件重渲染(React 会标记该组件为“待更新”,进入调度流程)。

(3)重渲染时

  1. 按首次渲染的顺序,找到该 Hook 节点,读取其 queue 中的新值,更新 memoizedState(将旧状态替换为新状态)。

  2. 再次返回最新的 [memoizedState, setXxx],保证组件渲染的是最新状态。

补充:setXxx 是异步的(React 会批量处理更新),这也是为什么有时候 setState 后,立即打印状态还是旧值——因为此时更新还未执行,需在 useEffect 中读取最新状态。

2. useEffect 原理(高频考点,重点记依赖对比)

通俗理解:useEffect 是“副作用处理器”,用于处理组件渲染之外的操作(如请求接口、操作 DOM、监听事件),它会在组件“渲染完成后”执行,并且可以控制“什么时候重新执行”。

专业拆解:核心是「依赖对比 + 异步执行」,避免副作用干扰组件渲染,步骤同样分3个阶段:

(1)首次渲染时

  1. 创建一个 useEffect 对应的 Hook 节点,存储「副作用回调函数」和「依赖数组」(第二个参数)。

  2. 组件渲染完成后(异步执行,不会阻塞 DOM 渲染),执行副作用回调函数。

(2)重渲染时

  1. 读取该 Hook 节点中存储的「旧依赖数组」,与本次重渲染的「新依赖数组」进行浅对比(对比每一项的值,基本类型比值,引用类型比地址)。

  2. 若依赖有变化:先执行上一次副作用的「清理函数」(useEffect 返回的函数),再执行本次的副作用回调,最后更新 Hook 节点中的“旧依赖数组”为新依赖。

  3. 若依赖无变化:直接跳过副作用的执行(性能优化,避免不必要的重复操作)。

(3)组件卸载时

执行该 useEffect Hook 节点的清理函数,用于清除副作用(如取消接口请求、移除事件监听),避免内存泄漏。

补充:若 useEffect 没有第二个参数(依赖数组),则每次重渲染都会执行副作用和清理函数;若依赖数组为空([]),则只在首次渲染和组件卸载时各执行一次。

三、关键约束的原理支撑(为什么 Hooks 不能写在条件里?)

核心结论(必背):Hooks 不能写在 if、for、while 等条件判断、循环中,也不能写在 return 之后,本质是为了保证「Hook 调用顺序固定不变」,避免 Hook 链表节点匹配错位。

通俗解读:假设把 Hook 放在 if 条件里,当条件从 true 变为 false 时,重渲染时该 Hook 就不会被调用,导致后续的 Hook 调用顺序整体前移一位,React 按原顺序遍历链表时,就会匹配到错误的节点,进而导致状态错乱、报错。

专业拆解:

  1. React 源码中,是通过「遍历索引」来定位 Hook 节点的(首次渲染时记录索引,重渲染时按索引匹配),一旦调用顺序被破坏,索引对应关系就会失效。

  2. 举个反例:

function WrongComponent() {
  const [count, setCount] = useState(0);
  // 错误:Hook 写在条件里
  if (count > 0) {
    const [name, setName] = useState('React'); // 条件为 false 时,该 Hook 不执行
  }
  return <button onClick={() => setCount(count+1)}>{count}</button>;
}

当 count 从 0 变为 1 时,首次执行 if 里的 useState,Hook 链表有2个节点;当 count 再变为 0 时,if 条件不成立,该 Hook 不执行,重渲染时只调用1个 useState,React 按顺序遍历链表时,会试图读取第二个节点(不存在),直接抛出错误:“Hooks must be called in the exact same order in every render”。

四、核心总结

  1. 核心结构:每个组件 Fiber 节点维护一个 Hook 单向链表,每个 Hook 节点存储 memoizedState(状态)、next(下一个节点)、queue(更新队列),靠「fiber.memoizedState」指向链表头节点。

  2. 核心逻辑:首次渲染创建 Hook 节点并初始化,重渲染按固定顺序读取/更新节点状态;useState 负责状态的读取与更新,useEffect 基于依赖对比控制副作用的执行时机。

  3. 核心约束:Hooks 必须在函数组件顶层调用,本质是保证调用顺序固定,避免 Hook 链表节点匹配错位,导致状态错乱。

五、面试常考问题及标准回答

1. 请说说 React Hooks 的核心原理?

标准回答:Hooks 的核心是「固定调用顺序 + Hook 链表存储」。React 为每个组件的 Fiber 节点维护一个 Hook 单向链表,每个 Hook 节点存储状态(memoizedState)、下一个节点(next)和更新队列(queue);首次渲染时,按顺序创建 Hook 节点并挂载到链表,初始化状态;重渲染时,按相同顺序遍历链表,读取/更新对应节点的状态,保证 Hook 调用与状态一一对应。其核心目的是让函数组件拥有状态管理和生命周期能力。

2. 为什么 Hooks 不能写在条件判断、循环里?

标准回答:因为 Hooks 依赖「固定的调用顺序」来匹配 Hook 链表中的节点。React 无法通过变量名识别 Hooks,只能按调用顺序遍历链表、匹配状态;若写在条件/循环里,会导致组件重渲染时,Hook 调用顺序发生变化,React 无法匹配到正确的链表节点,进而导致状态错乱、抛出错误。

3. useState 的原理是什么?setXxx 是同步还是异步?

标准回答:useState 本质是操作组件 Fiber 节点上的 Hook 链表——首次渲染创建 Hook 节点,初始化状态并返回 [状态, setXxx];调用 setXxx 时,将新值存入该 Hook 的更新队列,触发组件重渲染;重渲染时,按顺序读取更新队列中的新值,更新状态并返回最新结果。setXxx 是异步的,React 会批量处理更新,避免频繁渲染,因此直接在 setXxx 后打印状态,可能得到旧值。

4. useEffect 的依赖数组作用是什么?依赖为空([])和不写依赖有什么区别?

标准回答:依赖数组的作用是「控制 useEffect 副作用的执行时机」,React 会通过浅对比新旧依赖数组,判断是否执行副作用。区别:① 不写依赖数组:每次组件重渲染,都会执行副作用和清理函数;② 依赖数组为空([]):只在组件首次渲染时执行一次副作用,组件卸载时执行一次清理函数,相当于类组件的 componentDidMount 和 componentWillUnmount。

5. Hook 链表的结构是什么?fiber.memoizedState 作用是什么?

标准回答:单个 Hook 节点的结构是 { memoizedState: 状态值, next: 下一个 Hook 节点, queue: 更新队列 },多个 Hook 节点通过 next 指针串联成单向链表。fiber.memoizedState 的作用是「指向该组件 Hook 链表的头节点」,React 通过它遍历整个 Hook 链表,读取和更新各个 Hook 的状态。

6. 函数组件重渲染时,Hooks 是如何“记住”上一次的状态的?

标准回答:因为状态存储在组件 Fiber 节点的 Hook 链表中,而非函数组件的局部变量(局部变量每次渲染都会重新初始化)。重渲染时,函数组件重新执行,React 会从 Hook 链表的头节点开始,按与首次渲染相同的顺序,读取每个 Hook 节点的 memoizedState,从而“记住”上一次的状态。

LeetCode 918. 环形子数组的最大和:两种解法详解

刷题路上遇到环形数组的问题,总容易被“环形”这个条件绕晕——子数组不仅能是常规的连续片段,还能跨数组首尾连接。今天就来拆解 LeetCode 918 题「环形子数组的最大和」,分享两种高效解法,从原理到代码一步步讲透,帮你彻底搞懂这类环形数组问题。

先看题目核心:给定一个长度为 n 的环形整数数组 nums,返回非空子数组的最大可能和。这里要注意两个关键约束:一是环形意味着数组首尾相连,二是子数组不能重复使用元素(也就是说,跨首尾的子数组比如 nums[n-1], nums[0], nums[1] 是允许的,但不能包含 nums[0] 两次)。

题目核心难点

常规的子数组最大和(比如 LeetCode 53 题),用 Kadane 算法就能轻松解决,但环形数组多了“跨首尾”的情况,这就需要我们跳出常规思维:

  • 常规子数组:从 i 到 j(i ≤ j),连续且不跨首尾;

  • 环形子数组:从 j 到 n-1,再从 0 到 i(j > i),本质是“数组总和 - 中间一段最小子数组的和”。

基于这个思路,衍生出两种经典解法,下面分别详细讲解。

解法一:全局最大值 = max(常规最大和, 总和 - 常规最小和)

核心原理

这是最直观、最易理解的解法,核心逻辑分两种情况:

  1. 最大子数组不跨首尾:就是常规的子数组最大和,用 Kadane 算法直接求解;

  2. 最大子数组跨首尾:此时最大和 = 数组总和 - 最小子数组的和(因为总和减去中间一段最小的子数组,剩下的就是跨首尾的最大子数组)。

还有一个特殊情况:如果数组中所有元素都是负数,那么“总和 - 最小子数组和”会得到 0(因为总和 = 最小子数组和),但题目要求子数组非空,所以此时直接返回常规最大和(即数组中最大的那个负数)。

代码解析(TypeScript)

function maxSubarraySumCircular_1(nums: number[]): number {
  if (nums.length === 0) return 0;
  let curMax = nums[0], maxSum = nums[0]; // 常规最大和相关
  let curMin = nums[0], minSum = nums[0]; // 常规最小和相关
  let totalSum = nums[0]; // 数组总和

  for (let i = 1; i < nums.length; i++) {
    // 常规Kadane算法求最大子数组和
    curMax = Math.max(nums[i], curMax + nums[i]);
    maxSum = Math.max(maxSum, curMax);

    // 同理,求最小子数组和(Kadane算法变种)
    curMin = Math.min(nums[i], curMin + nums[i]);
    minSum = Math.min(minSum, curMin);

    // 累加计算数组总和
    totalSum += nums[i];
  }

  // 特殊情况:所有元素都是负数,直接返回最大和(非空)
  if (maxSum < 0) {
    return maxSum;
  }

  // 两种情况取最大值:常规最大和 vs 总和 - 最小子数组和
  return Math.max(maxSum, totalSum - minSum);
};

关键细节

  • curMax 和 curMin 分别记录“以当前元素结尾的最大子数组和”和“以当前元素结尾的最小子数组和”,每次迭代更新;

  • totalSum 必须在迭代中累加,避免二次遍历数组,保证时间复杂度 O(n);

  • 判断 maxSum < 0 是核心容错,避免所有元素为负时返回 0(不符合非空子数组要求)。

解法二:前缀和 + 后缀枚举(避免总和为负的判断)

核心原理

这种解法的思路是“拆分环形子数组”:跨首尾的子数组可以拆分为「前缀子数组」(从 0 开始)和「后缀子数组」(到 n-1 结束)。我们可以:

  1. 先计算常规的最大子数组和(不跨首尾);

  2. 再计算“后缀子数组 + 前缀子数组”的最大和:用 leftMax 数组记录「从 0 到 i 的最大前缀和」,再从右到左枚举后缀子数组,每次将后缀和与 leftMax[i-1](前 i-1 个元素的最大前缀和)相加,取最大值。

这种方法不需要判断数组是否全为负,因为枚举的后缀和 + 前缀和都是非空的,且常规最大和已经覆盖了全负的情况。

代码解析(TypeScript)

function maxSubarraySumCircular_2(nums: number[]): number {
  let n: number = nums.length;
  // leftMax[i]:从0开始,到i为止的最大前缀和(必须包含0,保证前缀非空)
  const leftMax = new Array(n).fill(0);
  leftMax[0] = nums[0]; // 初始值:只有第一个元素的前缀和
  let leftSum: number = nums[0]; // 累加前缀和
  let pre: number = nums[0]; // 常规最大子数组和的中间变量(Kadane)
  let res: number = nums[0]; // 最终结果,初始化为第一个元素

  // 第一次遍历:计算常规最大和 + leftMax数组
  for (let i = 1; i < n; i++) {
    // 常规Kadane算法求最大子数组和
    pre = Math.max(pre + nums[i], nums[i]);
    res = Math.max(res, pre);

    // 累加前缀和,更新leftMax(保证leftMax[i]是0到i的最大前缀和)
    leftSum += nums[i];
    leftMax[i] = Math.max(leftMax[i - 1], leftSum);
  }

  // 第二次遍历:从右到左枚举后缀子数组,计算后缀和 + 对应最大前缀和
  let rightSum = 0;
  for (let i = n - 1; i > 0; i--) {
    rightSum += nums[i]; // 后缀和:从i到n-1的和
    // 后缀和(i到n-1) + 前缀和(0到i-1的最大),更新结果
    res = Math.max(res, rightSum + leftMax[i - 1]);
  }

  return res;
};

关键细节

  • leftMax 数组的核心作用:记录“以 0 为起点,到 i 为止”的最大前缀和,确保后续枚举后缀时,能快速找到对应的最大前缀;

  • 第二次遍历从 n-1 到 1(不包含 0),因为当 i=0 时,leftMax[i-1] 越界,且此时后缀和就是整个数组,已经被常规最大和覆盖;

  • 时间复杂度依然是 O(n),空间复杂度 O(n)(leftMax 数组),相比解法一多了一点空间,但避免了总和为负的判断,逻辑更简洁。

两种解法对比

解法 时间复杂度 空间复杂度 核心优势 适用场景
解法一(总和 - 最小和) O(n) O(1) 空间最优,逻辑直观 追求空间效率,能记住“全负判断”的场景
解法二(前缀+后缀) O(n) O(n) 无需特殊判断,逻辑更简洁 不想处理边界条件,追求代码简洁

刷题总结

环形子数组的最大和,本质是“常规子数组”和“跨首尾子数组”的最大值求解。两种解法都基于 Kadane 算法的延伸,核心是找到“跨首尾子数组”的等价转换方式——要么用总和减去最小子数组和,要么拆分为前缀+后缀。

刷题时可以根据自己的习惯选择:如果喜欢空间最优,优先解法一;如果怕遗漏边界条件,解法二更友好。另外,建议多动手模拟几个测试用例(比如全负数组、全正数组、混合数组),就能彻底掌握两种解法的逻辑。

LeetCode 53. 最大子数组和:两种高效解法(动态规划+分治)

LeetCode经典题目「53. 最大子数组和」,这道题是动态规划和分治思想的典型应用,也是面试中高频考察的基础题。题目难度不算高,但两种解法各有侧重,吃透能帮我们更好地理解两类算法的核心逻辑,话不多说,直接进入正题。

一、题目回顾

题目要求:给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。注意,子数组是数组中的一个连续部分,和子序列(不要求连续)是完全不同的概念哦。

举个简单例子:输入 nums = [-2,1,-3,4,-1,2,1,-5,4],输出应该是 6。因为连续子数组 [4,-1,2,1] 的和最大,等于6。

这道题的核心难点在于「连续」和「最大和」,如果暴力枚举所有连续子数组,时间复杂度会达到 O(n²),对于大规模数组会超时,所以我们需要更高效的解法——今天重点讲两种 O(n) 和 O(nlogn) 复杂度的解法。

二、解法一:动态规划(DP)—— 最优时间复杂度 O(n)

1. 核心思路

动态规划的核心是「状态定义」和「状态转移方程」,这道题我们可以这样拆解:

定义状态 pre:表示以当前元素结尾的连续子数组的最大和。

对于每个元素 nums[i],我们有两个选择:

  • 将当前元素加入到之前的连续子数组中(即 pre + nums[i]);

  • 放弃之前的子数组,以当前元素为起点重新开始一个子数组(即 nums[i])。

所以状态转移方程就是:pre = Math.max(pre + x, x)(x 是当前遍历到的元素)。

同时,我们需要一个变量 maxAns 来记录遍历过程中出现的最大 pre 值,这个值就是最终的最大子数组和。

2. 代码解读

给出的代码非常简洁,我们逐行拆解:

function maxSubArray_1(nums: number[]): number {
  // pre:以当前元素结尾的连续子数组最大和;maxAns:全局最大和
  let pre: number = 0, maxAns: number = nums[0];
  // 遍历数组中的每个元素x
  nums.forEach((x) => {
    // 状态转移:选择加入前序子数组,或重新开始
    pre = Math.max(pre + x, x);
    // 更新全局最大和
    maxAns = Math.max(maxAns, pre);
  });
  return maxAns;
};

举个例子辅助理解(以 nums = [-2,1,-3,4,-1,2,1,-5,4] 为例):

  • 初始:pre=0,maxAns=-2(nums[0]);

  • 遍历x=-2:pre = max(0+(-2), -2) = -2,maxAns = max(-2, -2) = -2;

  • 遍历x=1:pre = max(-2+1, 1) = 1,maxAns = max(-2, 1) = 1;

  • 遍历x=-3:pre = max(1+(-3), -3) = -2,maxAns 仍为1;

  • 遍历x=4:pre = max(-2+4, 4) = 4,maxAns = 4;

  • 后续遍历依次更新,最终 maxAns 为6,和预期一致。

这种解法的优势的是:一次遍历完成,时间复杂度 O(n),空间复杂度 O(1)(只用到两个变量),是这道题的最优解法,面试中优先推荐写这种。

三、解法二:分治思想 —— 时间复杂度 O(nlogn)

分治思想的核心是「分而治之」:将数组分成左右两部分,最大子数组和要么在左半部分,要么在右半部分,要么横跨左右两部分。我们需要分别计算这三种情况的最大值,取三者中的最大者。

1. 核心思路

为了高效计算「横跨左右两部分」的最大和,我们需要定义一个 Status 类,存储每个区间的四个关键信息:

  • lSum:该区间的最大前缀和(从区间左端点开始,连续子数组的最大和);

  • rSum:该区间的最大后缀和(从区间右端点开始,连续子数组的最大和);

  • mSum:该区间的最大子数组和(就是我们需要的核心值);

  • iSum:该区间的所有元素和(用于计算横跨左右的最大和)。

然后通过「递归拆分」和「合并区间」(pushUp 函数),逐步计算出整个数组的 mSum,即为答案。

2. 代码解读

class Status {
  lSum: number; // 区间最大前缀和
  rSum: number; // 区间最大后缀和
  mSum: number; // 区间最大子数组和
  iSum: number; // 区间总元素和
  constructor(l: number, r: number, m: number, i: number) {
    this.lSum = l;
    this.rSum = r;
    this.mSum = m;
    this.iSum = i;
  }
}

function maxSubArray_2(nums: number[]): number {
  // 合并两个区间的Status,计算出父区间的四个关键值
  const pushUp = (l: Status, r: Status): Status => {
    const iSum = l.iSum + r.iSum; // 父区间总和 = 左区间总和 + 右区间总和
    // 父区间最大前缀和:要么是左区间的最大前缀和,要么是左区间总和+右区间最大前缀和
    const lSum = Math.max(l.lSum, l.iSum + r.lSum);
    // 父区间最大后缀和:要么是右区间的最大后缀和,要么是右区间总和+左区间最大后缀和
    const rSum = Math.max(r.rSum, r.iSum + l.rSum);
    // 父区间最大子数组和:三者取最大(左区间最大、右区间最大、横跨左右的最大)
    const mSum = Math.max(Math.max(l.mSum, r.mSum), l.rSum + r.lSum);
    return new Status(lSum, rSum, mSum, iSum);
  }

  // 递归获取区间 [l, r] 的Status
  const getInfo = (a: number[], l: number, r: number): Status => {
    if (l === r) { // 递归终止:区间只有一个元素时,四个值都等于该元素
      return new Status(a[l], a[l], a[l], a[l]);
    }
    const m = Math.floor((l + r) / 2); // 拆分区间为左右两部分
    const lSub = getInfo(a, l, m); // 左区间Status
    const rSub = getInfo(a, m + 1, r); // 右区间Status
    return pushUp(lSub, rSub); // 合并左右区间,返回父区间Status
  }

  // 整个数组的区间是 [0, nums.length-1],其mSum就是答案
  return getInfo(nums, 0, nums.length - 1).mSum;
};

3. 补充说明

分治解法的时间复杂度是 O(nlogn),空间复杂度是 O(logn)(递归调用栈的深度)。虽然效率不如动态规划,但这种思想很重要——在解决更复杂的区间问题(如最大子矩阵和)时,分治+区间信息合并的思路会非常有用。

四、两种解法对比总结

解法 时间复杂度 空间复杂度 核心优势 适用场景
动态规划 O(n) O(1) 高效、简洁,空间开销小 单独求解最大子数组和,面试首选
分治思想 O(nlogn) O(logn) 思路通用,可扩展到复杂区间问题 区间相关延伸题,理解分治思想

五、刷题思考

这道题虽然简单,但能帮我们理清两个重要算法思想的应用:

  1. 动态规划的核心是「抓住当前状态的最优选择」,不需要回溯,通过状态转移逐步推导全局最优;

  2. 分治思想的核心是「拆分+合并」,将大问题拆成小问题解决,再通过合并小问题的结果得到大问题的答案。

React Scheduler & Lane 详解

一、核心前置概念

在讲解Scheduler和Lane之前,先明确3个面试常考的基础概念,帮你快速理解二者的作用场景:

1. Lane:优先级的抽象(通俗版+专业版)

通俗解释:把React中的每一种更新优先级,想象成一条“车道”——高优先级更新走“快车道”,能插队;低优先级更新走“慢车道”,会被快车道的车辆(高优先级更新)打断,这样就能精准区分不同更新的执行顺序,避免混乱。

专业定义:Lane是React用于精细化管理更新优先级的核心机制,替代了早期的过期时间模型,本质是用31位整数的每一位(位掩码)代表一条“车道”,每一条车道对应一种更新优先级,通过高效位运算实现更新的合并、筛选与优先级判断,是React并发更新的基石。

2. 同步更新 vs 并发更新(面试高频区分)

同步更新:更新任务一旦开始,就会从头到尾执行完毕,期间会阻塞主线程(比如长时间渲染列表时,用户点击按钮没反应),React18之前默认是同步更新。

并发更新:React18引入的核心特性,允许高优先级更新打断低优先级更新,任务可以“暂停、恢复、中断”,不会阻塞主线程,能同时处理多个不同优先级的更新(比如一边渲染列表,一边响应用户输入),而Scheduler和Lane正是支撑并发更新的核心。

3. 时间切片(Time Slicing,Scheduler的核心能力)

通俗解释:浏览器每16.6ms会刷新一帧(保证页面不卡顿),时间切片就是把长任务“切”成一个个≤16.6ms的小任务,每执行一个小任务,就检查一下浏览器是否需要渲染,若需要就暂停任务、让出主线程,避免卡顿。

专业定义:React Scheduler实现的一种任务调度机制,默认切片时长约16.6ms(对应60fps),通过shouldYieldToHost方法判断是否中断当前任务,确保任务执行不阻塞浏览器的渲染和用户交互。

二、Scheduler(调度器)详解(面试核心)

1. 核心定义(必背)

React Scheduler 是 React 并发模式的核心模块,本质是“任务管理者”,负责协调 React 中所有更新任务的优先级、执行时机,调控任务队列,避免重型任务阻塞主线程(如用户交互、渲染),确保应用流畅响应,核心目标是“推进任务,且不阻塞浏览器”。

2. 核心功能(详细且简洁,面试重点背诵)

记住:Scheduler的核心就是“管优先级、管队列、管执行”,4个核心功能如下,结合通俗解释更好记:

  • 优先级调度(核心中的核心):定义5种核心优先级(从高到低),精准匹配不同场景,优先执行高优先级任务,同时避免低优先级任务“饥饿”(长期不执行)。

    通俗补充:就像医院挂号,急诊(高优先级)优先看病,普通门诊(低优先级)排队,但不会让普通门诊病人一直等不到(过期机制)。

    专业细节:5种优先级对应场景+超时时间(面试可简要提及):

    • ImmediatePriority(立即执行):对应SyncLane,超时时间0ms(比如用户点击按钮的同步更新);
    • UserBlockingPriority(用户阻塞):对应输入/滚动,超时时间250ms(比如打字、拖拽,必须快速响应);
    • NormalPriority(普通优先级):普通setState更新默认,超时时间5000ms;
    • LowPriority(低优先级):对应过渡更新,超时时间10000ms(比如useTransition包裹的非紧急更新);
    • IdlePriority(空闲优先级):空闲时执行,超时时间无限(比如后台日志、预加载)。
  • 任务队列管理:维护两个最小堆结构的队列,高效管理任务(堆结构能快速找到最高优先级任务):

    • taskQueue(立即可执行任务):按任务过期时间排序,堆顶是最先过期、优先级最高的任务;
    • timerQueue(延迟执行任务):按任务开始时间排序,比如延迟执行的后台任务,到期后移至taskQueue。
  • 时间切片与中断:实现时间切片(默认≤16.6ms),通过shouldYieldToHost方法判断是否中断任务——若任务执行超时,或浏览器需要渲染、处理用户交互,立即让出主线程,待浏览器空闲后再恢复执行,避免卡顿。 通俗补充:就像写作业,每写20分钟(对应16.6ms),就检查一下有没有人叫你(浏览器是否需要工作),有就暂停,没人叫就继续写。

  • 任务调度循环:通过workLoop(工作循环)推进任务,核心流程:① 先将timerQueue中到期的任务移至taskQueue;② 从taskQueue中取出堆顶的最高优先级任务执行;③ 若任务未执行完毕(返回回调函数),则放回taskQueue,等待下一轮调度;④ 重复以上步骤,直到队列清空。

三、Lane(车道模型)详解(面试核心)

1. 核心定义(必背)

Lane 是 React 用于精细化管理更新优先级的机制,替代了早期的过期时间模型,核心是用31位整数的每一位(位掩码)代表一条“车道”,每一条车道对应一种更新优先级,通过高效位运算(按位与、按位或)实现更新的合并、筛选与优先级判断,适配并发模式下的中断与恢复需求,是 React 并发更新的基石。

通俗补充:31位整数就像31条并行的车道,每条车道跑一种优先级的更新,位运算就是“快速判断哪条车道有车、能不能合并车道、哪条车道的车优先级最高”,比传统的队列遍历高效得多(O(1)复杂度)。

2. 核心功能(详细且简洁,面试重点背诵)

  • 优先级精细化划分:将更新划分为5类核心车道,对应Scheduler的5种优先级,分工明确(面试常考对应关系):

    • SyncLane(最高优先级):对应Scheduler的ImmediatePriority,比如用户点击、输入等同步更新,不能被中断;
    • InputContinuousLane(很高优先级):对应UserBlockingPriority,比如滚动、拖拽等连续交互,需快速响应;
    • DefaultLane(中等优先级):对应NormalPriority,普通setState更新默认走这条车道;
    • TransitionLanes(可中断优先级):对应LowPriority,有16条车道(可并行处理多个过渡更新),比如useTransition、useDeferredValue包裹的更新,可被高优先级更新打断;
    • IdleLane(最低优先级):对应IdlePriority,比如后台预加载、日志上报,只有浏览器空闲时才执行。
  • 高效位运算管理:通过位运算实现O(1)复杂度的更新操作(面试常考位运算场景):

    • 按位或(|):合并多条车道的更新,比如同时有普通更新和过渡更新,就用位或把两条车道合并,统一处理;
    • 按位与(&):检查特定车道是否有未处理的更新,比如判断是否有同步更新,就用SyncLane和pendingLanes(待处理车道)做按位与运算,结果非0则有同步更新。 通俗补充:位运算就像“快速检票”,不用一个个查,一句话就能判断所有车道的状态,效率极高。
  • 并发中断与恢复:低优先级车道(如TransitionLanes)的更新执行过程中,若有高优先级车道(如SyncLane)的更新插入,可立即中断低优先级任务,优先处理高优先级任务;待高优先级任务完成后,再恢复或重新执行低优先级任务,保障用户交互流畅性。 通俗补充:就像慢车道的车正在行驶,快车道的车来了,慢车道的车就让道,等快车道的车过去,再继续走。

  • 过期与优先级提升:为避免低优先级任务长期被高优先级任务阻塞(饥饿问题),React会为每条车道计时,若某条车道的更新待处理过久(超过对应超时时间),会将其优先级提升至SyncLane(立即执行),确保UI最终一致性,不会出现“更新一直不生效”的情况。

四、Scheduler 与 Lane 的关联(面试高频,必背)

核心总结:Lane 负责“定义更新优先级”(告诉React“这个更新该用什么优先级”),Scheduler 负责“执行优先级调度”(告诉React“这个优先级的任务什么时候执行、怎么执行”),二者协同工作,支撑React并发模式,核心关联3点:

  1. 优先级映射:Lane的每类车道,都对应Scheduler的一种优先级(一一对应,面试必背):
  • SyncLane ↔ ImmediatePriority(立即执行)
  • InputContinuousLane ↔ UserBlockingPriority(用户阻塞)
  • DefaultLane ↔ NormalPriority(普通优先级)
  • TransitionLanes ↔ LowPriority(低优先级)
  • IdleLane ↔ IdlePriority(空闲优先级)
  1. 协同工作流程(面试可完整背诵,体现逻辑):
  • 触发更新(如setState、useState更新);
  • React为该更新分配对应Lane,并将Lane加入根节点的pendingLanes(待处理车道);
  • Scheduler读取pendingLanes,将其映射为自身的优先级;
  • Scheduler从taskQueue中挑选最高优先级任务执行;
  • 执行过程中,通过Lane检查是否有更高优先级更新插入,若有则中断当前任务;
  • 高优先级任务执行完毕后,恢复或重新执行低优先级任务,形成调度闭环。
  1. 核心目标一致:二者的最终目的都是解决“主线程阻塞”问题——Lane实现优先级的精细化区分,让不同更新有明确的执行顺序;Scheduler实现任务的高效调度与中断,让任务执行不影响浏览器渲染和用户交互,共同保障React应用在复杂场景下(如大量数据渲染、高频交互)的流畅性。

五、面试常考问题(标准答案,可直接背诵)

说明:以下问题均为React面试高频题,答案简洁精准,贴合面试场景,避开复杂细节,重点突出核心考点。

1. 请说说React Scheduler的作用?

标准答案:Scheduler是React并发模式的核心模块,本质是“任务管理者”,核心作用是协调所有更新任务的优先级和执行时机,通过维护任务队列、实现时间切片和任务中断,避免重型任务阻塞主线程,确保应用的流畅响应(核心目标:推进任务,且不阻塞浏览器)。

2. Lane是什么?它的核心作用是什么?

标准答案:Lane是React用于精细化管理更新优先级的机制,核心是用31位整数的每一位(位掩码)代表一条“车道”,每一条车道对应一种更新优先级。核心作用是通过高效位运算,实现更新的合并、筛选与优先级判断,支撑React并发模式下的任务中断与恢复,解决优先级区分不精细的问题。

3. Scheduler和Lane的关系是什么?

标准答案:二者协同支撑React并发特性,核心关系是“Lane定义优先级,Scheduler执行调度”。Lane负责给更新分配优先级(对应不同车道),Scheduler负责将车道优先级映射为自身优先级,管理任务队列、执行任务,并通过Lane判断是否需要中断任务,共同解决主线程阻塞问题,保障应用流畅。

4. 什么是时间切片?它由哪个模块实现?作用是什么?

标准答案:时间切片是将长任务切分为≤16.6ms的小任务,每执行一个小任务就检查浏览器是否需要渲染,避免阻塞主线程的机制。由Scheduler实现,核心作用是防止重型任务(如大量列表渲染)阻塞主线程,保障用户交互和页面渲染的流畅性。

5. React中高优先级更新为什么能打断低优先级更新?依赖什么机制?

标准答案:依赖Lane和Scheduler的协同机制。Lane将更新划分为不同优先级的车道,高优先级更新对应高优先级车道;Scheduler在执行任务时,会通过Lane检查是否有更高优先级更新插入,若有则立即中断当前低优先级任务,优先执行高优先级任务,执行完毕后再恢复低优先级任务,从而实现高优先级打断低优先级。

6. 如何避免低优先级更新“饥饿”(长期不执行)?

标准答案:React通过Lane的“过期机制”避免低优先级更新饥饿。为每条车道设置对应超时时间,若某条低优先级车道的更新待处理过久(超过超时时间),会将其优先级提升至SyncLane(最高优先级),由Scheduler立即执行,确保UI最终一致性。

7. Lane为什么用位掩码实现?优势是什么?

标准答案:因为位运算的时间复杂度是O(1),效率极高。优势是能快速实现更新的合并(按位或)、筛选(按位与)和优先级判断,无需遍历任务队列,适配React高频更新场景(如滚动、输入),提升调度性能。

React 更新触发原理详解

核心结论(面试开篇必说):React 的更新触发本质上是 状态(State)/属性(Props)/上下文(Context)发生变化 后,React 调度组件重新渲染的过程。简单说,就是“依赖的数据变了,React 就会重新渲染组件,更新页面”。下面从「触发源」「执行流程」「关键细节(避坑点)」「核心底层逻辑」「面试常考问题」五个维度,讲透更新触发的全流程,兼顾通俗理解和专业表述,适合面试直接背诵。

一、更新的核心触发源(4类,重中之重)

React 组件不会“无缘无故”更新,核心原因是「它依赖的数据变了」。这4类触发场景,面试必问,务必记牢,结合示例理解更易背诵。

1. 状态(State)变化(最核心、最常见)

通俗说:组件自己“内部的数据”变了,就会触发更新。比如计数器的数字、表单的输入值,都是 State,修改它们就会让组件重新渲染。

专业表述:通过 React 提供的「状态更新函数」修改组件内部状态,是触发更新的首要方式,分类组件和函数组件两种写法。

  • 类组件:调用 this.setState()(推荐)或 this.forceUpdate()(强制更新,不推荐)。

    • 关键细节:setState异步的(合成事件、生命周期钩子中),React 会批量处理多次 setState,避免频繁渲染(比如连续调用2次 setState,只会渲染1次)。

    • 面试可用示例(简洁好记):

    class Counter extends React.Component {
      state = { count: 0 };
      handleClick = () => {
        // 触发更新:修改state后,组件重新执行render
        this.setState({ count: this.state.count + 1 });
      };
      render() {
        return <button onClick={this.handleClick}>{this.state.count}</button>;
      }
    }
    
  • 函数组件:调用useState 返回的更新函数,或 useReducerdispatch 方法(复杂状态管理用)。

    • 关键细节:和类组件的 setState 类似,更新函数也是异步批量处理,避免无效渲染。

    • 面试可用示例(简洁好记):

    function Counter() {
      const [count, setCount] = React.useState(0);
      const handleClick = () => {
        // 触发更新:调用setCount后,组件重新执行
        setCount(count + 1);
      };
      return <button onClick={handleClick}>{count}</button>;
    }
    

2. 属性(Props)变化(父子组件通信相关)

通俗说:父组件给子组件“传的数据”变了,子组件就会跟着更新(除非手动阻止)。比如父组件传一个“用户名”给子组件,用户名变了,子组件就会重新渲染显示新的用户名。

专业表述:父组件传递给子组件的 Props 发生变化时,子组件会触发更新;父组件自身更新时,会重新计算子组件的 Props,即使 Props 看起来没变化(比如传递新的对象/函数引用),子组件也会默认更新。

面试可用示例(简洁好记):

// 父组件更新 → 子组件Props变化 → 子组件更新
function Parent() {
  const [name, setName] = React.useState("React");
  return (
    <div>
      <button onClick={() => setName("Vue")}>修改名称</button>
      <Child name={name} /> // 父组件name变了,子组件Props变化
    </div>
  );
}
function Child({ name }) {
  // 父组件修改name后,这里会重新渲染
  return <div>名称:{name}</div>;
}

3. 上下文(Context)变化(跨组件通信相关)

通俗说:多个组件共享的“全局数据”变了,所有用到这个数据的组件都会更新。比如全局主题(浅色/深色),切换主题后,所有使用主题的组件都会重新渲染。

专业表述:组件通过 useContext(函数组件)或 Context.Consumer(类组件)订阅了上下文,当上下文的 Providervalue 发生变化时,所有订阅该上下文的组件都会触发更新。

面试可用示例(简洁好记):

const ThemeContext = React.createContext();
function Parent() {
  const [theme, setTheme] = React.useState("light");
  return (
    <ThemeContext.Provider value={theme}> // 提供上下文数据
      <button onClick={() => setTheme("dark")}>切换主题</button>
      <Child /> // 子组件订阅上下文
    </ThemeContext.Provider>
  );
}
function Child() {
  // 上下文变化 → 组件更新
  const theme = React.useContext(ThemeContext);
  return <div>当前主题:{theme}</div>;
}

4. 其他特殊触发方式(面试易考补充)

这类场景不常用,但面试常问“还有哪些方式能触发更新”,记3个核心即可:

  • useState/useReducer 的更新函数接收「函数参数」时,即使最终值未变化,也会触发更新(但 React 会跳过无变化的渲染,不会更新真实 DOM);

  • 类组件 this.forceUpdate():强制触发更新,跳过 shouldComponentUpdate 检查(不推荐,会导致不必要的渲染);

  • React 18+ 新增 useSyncExternalStore:用于订阅外部数据源(如 Redux、localStorage),当外部数据源变化时,触发组件更新。

二、更新的执行流程(简化版,面试直接背)

核心口诀:调度 → 渲染 → 提交(3步走,通俗+专业结合,好记不绕)

触发更新后(比如调用 setState),React 不会立刻更新页面,而是按以下步骤有序执行,核心是“高效更新,只更变化的部分”:

1. 调度(Schedule):排优先级,入队列

通俗说:React 收到更新请求后,先判断“这个更新有多紧急”,比如用户点击按钮(高优先级)要立刻响应,定时器回调(低优先级)可以缓一缓,然后把更新请求加入调度队列,按优先级排序。

专业表述:React 接收到更新请求后,根据更新的优先级(由 Lane 机制标记),将更新加入调度队列,优先处理高优先级更新,避免卡顿(比如用户交互不会被低优先级更新阻塞)。

2. 渲染(Render):生成虚拟DOM,做Diff对比

通俗说:React 从触发更新的组件开始,像“查家谱”一样,递归遍历整个组件树,生成一份新的“虚拟DOM”(可以理解为页面的“虚拟蓝图”),然后和旧的虚拟DOM对比,找出“不一样的地方”(也就是需要更新的部分)。

专业表述:从触发更新的组件出发,递归遍历组件树,执行组件的 render 方法(函数组件直接执行组件本身),生成新的虚拟 DOM(VNode);通过 React 的 Diff 算法(协调算法,Reconciliation)对比新旧虚拟 DOM,找出最小更新集(只更新变化的节点,不更新整个页面)。

3. 提交(Commit):更新真实DOM,执行副作用

通俗说:React 把 Diff 对比找到的“变化部分”,应用到真实的页面上(也就是更新浏览器的 DOM),完成页面更新;同时执行一些“副作用”,比如类组件的生命周期、函数组件的 useEffect。

专业表述:将 Diff 算法的结果应用到真实 DOM 上,完成页面更新;此时类组件会执行 componentDidUpdate 生命周期钩子,函数组件会执行 useEffect(只有依赖项发生变化时才会执行)。

三、关键细节(避坑点,面试高频提问)

这部分是面试“拉开差距”的地方,不仅要记,还要能说清“为什么”和“怎么解决”,结合场景记忆。

1. setState 的异步特性(必考)

核心问题:为什么调用 setState 后,立刻打印 this.state,拿到的还是旧值?

通俗解释:React 为了提高性能,会把多个 setState 合并成一次更新,所以在合成事件(比如 onClick、onChange)、生命周期钩子(比如 componentDidMount)中,setState 是异步的,不会立刻更新 state。

特殊情况:在原生事件(比如 addEventListener 绑定的事件)、定时器(setTimeout、setInterval)中,setState 是同步的,能立刻拿到最新 state。

解决方法(面试必说):用 setState 的「函数形式」,接收 prevState(上一次的状态)作为参数,就能拿到最新的 state:

// 正确写法,能拿到最新state
this.setState(prevState => ({ count: prevState.count + 1 }));
// 错误写法,可能拿到旧值(异步场景下)
this.setState({ count: this.state.count + 1 });

2. 避免不必要的更新(性能优化,必考)

核心问题:如何减少 React 组件的无效渲染?(比如父组件更新,子组件没变化也跟着更新)

分组件类型给出解决方案(通俗+专业,好记):

  • 函数组件:用 React.memo 包裹组件,它会浅比较 Props,Props 没变化就不会重新渲染;

  • 类组件:重写 shouldComponentUpdate 钩子,手动判断 Props/State 是否变化,返回 true 才更新,返回 false 阻止更新;

  • 通用优化:传递 Props 时,避免创建新的引用(比如不要在 Props 中直接写箭头函数、新建对象),用 useCallback 缓存函数、useMemo 缓存对象/计算结果。

3. React 18+ 批量更新(新增考点)

核心变化:React 18 之前,只有合成事件、生命周期中会批量更新;React 18 之后,默认对所有更新(包括定时器、原生事件中)进行批量处理,进一步减少渲染次数。

特殊需求:如果需要同步更新(比如更新后立刻获取 DOM 信息),用ReactDOM.flushSync() 包裹更新操作:

import ReactDOM from 'react-dom';

// 同步更新,执行完setState后,能立刻拿到最新DOM
ReactDOM.flushSync(() => {
  setCount(count + 1);
});

四、核心底层逻辑(面试拔高,不用看源码,直接背)

面试常问:setState / dispatch 到底做了什么?(不用讲源码,说清逻辑顺序即可,记下面这段,直接背诵)

核心逻辑(分4步,清晰好记):

  1. 调用 setState(或 dispatch)后,React 会创建一个「update 对象」(记录更新的内容、优先级等信息);

  2. 将这个 update 对象放入「更新队列(updateQueue)」中;

  3. 通过「Lane 机制」给这个更新标记优先级(高优先级优先执行);

  4. React 调度器(Scheduler)触发渲染流程,开始执行“调度 → 渲染 → 提交”的步骤。

总结一句(面试必说):setState 本身不会立刻更新 state,它只是创建一个更新请求,React 会根据优先级统一调度,批量处理更新,最终完成组件渲染和 DOM 更新

五、面试常考问题(直接背诵答案,覆盖90%考点)

以下问题,直接记答案,面试时直接回答,不用临场组织语言,高效得分。

1. 问:React 组件更新的触发条件有哪些?

答:核心是依赖的数据发生变化,主要有4类:① State 变化(调用 setState、useState 更新函数、useReducer 的 dispatch);② Props 变化(父组件传递的 Props 改变,或父组件更新导致 Props 重新计算);③ Context 变化(订阅的 Context.Provider 的 value 变化);④ 特殊方式(forceUpdate、useSyncExternalStore、useState/useReducer 函数参数触发的更新)。

2. 问:setState 是同步还是异步的?为什么?

答:分场景:① 合成事件(onClick 等)、生命周期钩子中,setState 是异步的;② 原生事件、定时器中,setState 是同步的。原因:React 为了优化性能,会批量处理多个 setState 请求,避免频繁渲染,所以在异步场景下会延迟更新 state,合并多次更新。

3. 问:如何解决 setState 异步导致的“拿不到最新 state”问题?

答:使用 setState 的函数形式,接收 prevState 作为参数,prevState 是上一次的最新状态,通过它计算新状态,就能确保拿到最新值,示例:this.setState(prevState => ({ count: prevState.count + 1 }))。

4. 问:父组件更新,子组件一定会更新吗?如何避免不必要的更新?

答:不一定。父组件更新时,会重新计算子组件的 Props,即使 Props 没变化(比如传递新的函数/对象引用),子组件也会默认更新。避免方法:① 函数组件用 React.memo 包裹,浅比较 Props;② 类组件重写 shouldComponentUpdate 钩子,手动判断是否更新;③ 用 useCallback 缓存函数、useMemo 缓存对象,避免传递新引用。

5. 问:React 更新的执行流程是什么?

答:核心3步:① 调度(Schedule):接收更新请求,标记优先级,加入调度队列;② 渲染(Render):递归遍历组件树,生成新虚拟 DOM,通过 Diff 算法对比新旧虚拟 DOM,找出变化部分;③ 提交(Commit):将变化应用到真实 DOM,执行副作用(componentDidUpdate、useEffect)。

6. 问:React 18 中批量更新有什么变化?

答:React 18 之前,只有合成事件、生命周期中支持批量更新;React 18 之后,默认对所有场景(包括定时器、原生事件)进行批量更新,减少渲染次数。如需同步更新,可用 ReactDOM.flushSync() 包裹更新操作。

7. 问:useState 和 setState 的区别?(延伸考点)

答:① 用法不同:useState 用于函数组件,返回 [state, 更新函数];setState 用于类组件,是 this 的方法;② 状态更新方式不同:useState 的更新函数是直接替换状态(不会合并),setState 会自动合并同名状态;③ 异步特性一致:两者在合成事件、生命周期中都是异步的,原生事件、定时器中是同步的。

总结

React 更新的核心是“依赖数据变化触发调度渲染”,记住3个核心:① 触发源(State/Props/Context 为主);② 执行流程(调度→渲染→提交);③ 优化点(避免无效更新、理解 setState 异步)。

❌