skip to content
usubeni fantasy logo Usubeni Fantasy

React 的设计哲学

/ 21 min read

React 的渲染机制

从前,React 渲染就有一个致命缺陷,在 setState 时,当前组件的整个树都会运行渲染函数。即使使用了虚拟 DOM,这里面也浪费了相当多的时间。因为有的组件,它依赖的 props 没变,甚至它根本不依赖任何 props,(默认情况下)依然被重新运行渲染再进行 diff。对于大型 Web 应用,这个缺陷常常造成明显卡顿。

与之相比,Vue 是本来就知道一个组件依赖什么值,那自然就能精准地以组件维度重新渲染。

卡顿形成的原因

大家应该都知道,浏览器网页渲染和 JavaScript 运行是争抢同一个线程的。

刷新时需要的画面未准备好(最常见原因就是被 JavaScript 长任务阻塞),这帧就会被丢弃,造成卡顿的感觉。要保持基本流畅,阻塞渲染的任务时长不能超过 30ms,能压到 16ms 更佳。使用 requestAnimationFrame 更可以有效对准刷新时间,让 JavaScript 的运行时间更充裕。

在 React 开发的场景下,如果开发者没有刻意优化,越是顶层的组件修改,就会引起越长时间的渲染。因为旧 diff 算法(stack reconciler)是无法中断的,渲染一旦跑起来,新旧虚拟 DOM 树就开始递归比对并进行 DOM 操作。这样的递归操作缺乏退出措施,大型组件很容易就超过 16ms,造成卡顿。

那么 React 到底怎么让大组件的渲染时间压缩到 16ms 呢?

Fiber 来救场

从 React 16 开始,React 引入 React Fiber,同时更新 Fiber Reconciler。它的核心算法就是把组件树的每个组件当成一个单元,逐个运行这些单元,当 React 检测到运行时间不够了,就会把线程让回给渲染,渲染完毕后,继续从中断的位置运行。

所以 Fiber 就是为 React 渲染提供暂停、中止、调整优先级的能力。整个渲染流程分为 Render 和 Commit 两步。

我们先看看到底 Fiber 是个啥。其实它就是一个 JavaScript 对象,在这个场景下我们只需要关心三个属性:

export type Fiber = {
// ...
return: Fiber | null;
child: Fiber | null;
sibling: Fiber | null;
// ...
};

Render 就是一个单线的遍历 Fiber 的过程,这个过程是可中断的,这是传统递归机制给不到的。基本算法如下,做到的效果是优先找子节点,没有就找兄弟节点,兄弟节点也处理完后 return 回上层,继续处理上层兄弟节点,直至回到根节点视为结束。

let root = fiber;
let node = fiber;
while (true) {
// Do something with node
if (node.child) {
node = node.child;
continue;
}
if (node === root) {
return;
}
while (!node.sibling) {
if (!node.return || node.return === root) {
return;
}
node = node.return;
}
node = node.sibling;
}
节点遍历

React 通过 Work Loop 调度 Fiber Reconciler,当时间不够的时候,React 需要让 JavaScript 让出线程响应用户操作和页面渲染。这个调度算法最初打算依赖 requestIdleCallback 接口,但是因为浏览器兼容问题迟迟未解决,React 就自己实现了一套。

React Workloop

Work Loop 这样的时间切片思维在 Vue 的长列表渲染中也能用到。

Commit 是经过完整 Render 之后,收集好一系列对真实 DOM 的操作,一并执行,这一步是同步执行的。分开两个阶段,就可以防止 Render 执行中断时,UI 只修改了半产生的不一致。也是因为这两个步骤被分开,而且渲染步骤可打断,在以前会造成 componentWillUpdate 等一些方法会被调用两次,后来,这些方法就被弃用了。

这个 Demo 里可以看到新旧两种渲染机制的性能对比,差距非常大。

Stack 的性能分析:

stack 的性能分析

Fiber 的性能分析:

fiber 的性能分析

Fiber 加 Reconciliation 算法极大提高系统响应速度,附带一点整个渲染时常的延长,看起来似乎是十分划算的。

函数式组件的哲学

Class 组件:

class Example extends React.Component {
state = { count: 0 };
componentDidMount() {
// 生命周期方法
}
render() {
return <div>{this.state.count}</div>;
}
}

函数式组件:

function Example() {
const [count, setCount] = useState(0);
useEffect(() => {
// 替代生命周期
}, []);
return <div>{count}</div>;
}

对比起来函数式组件的主要优势是更简洁的写法,省掉了 this,并且给你更方便抽取公共逻辑的基础。

渲染函数的特点就是它必须是个纯函数,只要输入是确定的,输出就是确定不变的,运行一万次也是相同的结果,这就是所谓的没有副作用。我们可以用公式表示这个行为 UI = f(state)

这个特性带来一个稍稍有点反直觉的问题,alert 的结果是什么:

export default function Counter({ init }) {
const [number, setNumber] = useState(init);
return (
<>
<h1>{number}</h1>
<button
onClick={() => {
setNumber(number + 5);
alert(number);
}}
>
+5
</button>
</>
);
}

当你真正明白 React 的底层机制和 JavaScript 的特性,结果很明显就是旧值。在当次渲染中,因为 JavaScript 静态作用域的特性,number 就是固定的一个值,在 setNumber 之后不会立即重新渲染(而只是进入队列等待 Work Loop 安排),所以即使 alertsetNumber 之后也不会立即得到新结果。

引入 Hook

hook

这个图片是一个很好的比喻,Hook 就是一个函数式组件里的钩子。跟古早版本相比,在彻底拥抱函数式组件后,之前类组件的各种属性都得再想办法储存。Hook 的引入就是让这些渲染函数依然拥有 React 的额外功能。

既然纯函数无法记录状态,就意味着 Hook 的本质就是把状态转移到某个地方,通过钩子钩到函数外面的值就好了。

为什么必须在外层

React 把状态转移时,调用顺序是关键,如果写在 if函数里,调用顺序是完全不能保证的。

我们来看看 DidactuseState 实现:

let wipFiber = null;
let hookIndex = null; // hook 顺序存储
function updateFunctionComponent(fiber) {
wipFiber = fiber;
hookIndex = 0;
wipFiber.hooks = [];
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
function useState(initial) {
const oldHook =
wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]; // 根据 index 取 hook
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
};
const actions = oldHook ? oldHook.queue : [];
actions.forEach((action) => {
hook.state = action(hook.state);
});
const setState = (action) => {
hook.queue.push(action);
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
nextUnitOfWork = wipRoot;
deletions = [];
};
wipFiber.hooks.push(hook);
hookIndex++; // index + 1 待下一个 hook 取用
return [hook.state, setState];
}

基于新范式的优化

缓存

相较于 Class 组件更新时运行 render 函数,更换到函数式组件后,每次渲染都会运行整个函数。如果这个组件包含大型函数和缓存 CPU 密集型操作,就非常容易引起性能问题。

之前提到 Hook 就是把状态转移到其他地方,除了状态,有时候还有必要储存大型函数、被依赖的函数和缓存 CPU 密集型操作。

这时候就必须提到 useMemouseCallback 了。

import React, { useState, useMemo } from "react";
// 一个计算第 n 个斐波那契数的函数(CPU 密集型操作)
const fibonacci = (n) => {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
};
const App = () => {
const [num, setNum] = useState(10); // 用户输入的数字
const [count, setCount] = useState(0); // 其他不相关的状态
// 使用 useMemo 缓存计算结果
const fibValueMemo = useMemo(() => {
console.log("Calculating Fibonacci...");
return fibonacci(num);
}, [num]);
// 无缓存
// const fibValue = fibonacci(num)
return (
<div style={{ padding: "20px" }}>
<h1>useMemo 缓存 CPU 密集型操作示例</h1>
<div>
<label>
计算第{" "}
<input
type="number"
value={num}
onChange={(e) => setNum(parseInt(e.target.value, 10) || 0)}
style={{ width: "50px" }}
/>{" "}
个斐波那契数:
</label>
</div>
<p>结果: {fibValueMemo}</p>
<button onClick={() => setCount(count + 1)}>点击次数: {count}</button>
</div>
);
};
export default App;

useCallback 用于对函数缓存,唯一功能就是保持它引用的不变。

// ...
const createOptions = useCallback(() => {
return {
serverUrl: "https://localhost:1234",
roomId: roomId,
};
}, [roomId]); // ✅ Only changes when roomId changes
useEffect(() => {
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // ✅ Only changes when createOptions changes
// ...

当函数成为依赖时,必须先被缓存。因为如果不缓存,根据函数式组件的特性,每次运行都是一个全新的函数,在匹配依赖是否改变时就永远为真了。

**不要以为函数声明也耗时,缓存后可以省掉这一部分耗时。**事实上,上面的例子相当于:

const fn = () => {
return {
serverUrl: "https://localhost:1234",
roomId: roomId,
};
};
const createOptions = useCallback(fn, [roomId]);

fn 的声明是绝对剩不掉的,useCallback 只是在依赖不变的情况下返回上一次的函数声明而已,别被骗了以为这次就不用声明了 😂。

当然,如果你的函数没有用到任何 stateprop 的话它最好直接定义在组件外,那就真的省下了。

既然我们可以缓存函数和运行结果,那我们何不从更高维度思考缓存的可能性,直接把整个组件都缓存了呢?

基于渲染函数是纯函数 UI = f(state) 的特性,只要 state 不变,这个组件的运行结果就能安全地重复使用。React 提供了 React.memo 函数把组件存起来,就把一个组件的相同参数时的重新渲染给省掉了。

const ExpensiveItem = React.memo(({ count }) => {
blockMainThread(500);
const arr = Array(count).fill(0);
return (
<>
{arr.map((i) => (
<div>i</div>
))}
</>
);
});

调度

下面两个优化都跟新渲染机制密切相关:

startTransition 一言以蔽之,一次过渡用的渲染。在 startTransition 中触发的渲染可以被立即打断,执行更高优先级的需求。

这个 Hook 本质上就是利用 Fiber 可打断的机制,直接打断当前不重要的渲染,直接给用户后面需要的内容。

Demo

useDeferredValue 一言以蔽之,React 特化的高级防抖。useDeferredValue 由 React 调度,线程不繁忙时提上日程,繁忙时一直延后。

当 React 检测到系统繁忙时,不会给你触发 useDeferredValue,所以这个值只会在繁忙期过后才会更新,配合 React.memo 使用有奇效。

Demo

import React, { useState, useDeferredValue, startTransition } from "react";
function blockMainThread(duration) {
const start = performance.now();
while (performance.now() - start < duration) {}
}
const ExpensiveItem = React.memo(({ count }) => {
blockMainThread(500);
const arr = Array(count).fill(0);
return (
<>
{arr.map((i) => (
<div>i</div>
))}
</>
);
});
const App = () => {
const [count, setCount] = useState(0);
const deferredCouont = useDeferredValue(count);
console.log(count, deferredCouont);
return (
<>
<button onClick={() => setCount(count + 1)}>
<span role="img" aria-label="react-emoji">
⚛️
</span>{" "}
{count}
</button>
<ExpensiveItem count={deferredCouont} />
</>
);
};
export default App;

打破纯函数的特例

身为纯函数渲染组件有时候也无可避免地需要产生副作用,例如在渲染后可能要自动请求 ajax 接口为页面提供数据。React 引入 useEffect Hook,你的副作用可以安心放在这里。

常见的副作用操作有这些:

  • 数据获取(如调用 API)
  • 订阅事件(如 WebSocket 或 DOM 事件监听)
  • 手动更改 DOM(如直接操作 document)
  • 定时器设置(如 setTimeout 或 setInterval)
  • 修改全局变量或外部状态

事件与副作用

除了 useEffect,事件也可以是非纯函数,因为事件不在渲染时运行。事件与 useEffect 的区别在于:事件是用户操作造成的,而 useEffect 引起的副作用是跟 React 渲染强关联的。

useEffect 就是每当组件 Render 时都有机会触发的函数,是否触发取决于它的依赖值是否改变。

对副作用负责

如果副作用是组件挂载时带来的,那我们就可能有责任在组件销毁时清除掉这些副作用

useEffect(() => {
const connection = createConnection(serverUrl, roomId);
return () => connection.disconnect();
}, [roomId]);

你或许听说过 useEffect 运行两次的问题,这是一个网红问题,起因就是 React 推荐在开发环境使用的严格模式。严格模式故意运行两次是为了暴露 useEffect 副作用是否清除干净的问题。正常来说纯函数运行结果必定一样,所以重复运行也不会有任何问题,假如出现问题,就是开发者没有遵守 React 渲染必须是纯函数的设定,又或者 useEffect 带来的副作用没有清除导致程序不如预期。

如果打开立刻就感受到异常,很可能就是严格模式重复运行造成的。这个时候我们要做的不是关闭严格模式,而是考虑组件是否引入了意外的副作用。

事件优先

官方文档花了非常大的篇幅讲解初学者可能会用 useEffect,但实际上并不需要的情况。这里举一个简单的例子:

useEffect(() => {
setSearch("");
}, [currentTeam]);

search 并不依赖 currentTeam 的情况下,这么写会被提示 This hook specifies more dependencies than necessary: currentTeam(使用 Biome)。实际上这么做确实是在凭空捏造依赖关系,更好的处理方法其实是在触发 currentTeam 的事件本身上重设 search。

这么做除了后期代码维护时难以理解这些捏造的关系,更加会影响应用性能。在 currentTeam 改变时本来就会重新渲染,再在重新渲染后调用 setSearch又得再跑一遍 render。比较好的解决方案如下:

<button
onClick={() => {
setCurrentTeam(item.teamId);
setSearch("");
refetch();
}}
>
{item.name}
</button>

这个情境下 useEffect 跟 Vue 的 watch 有点相像,同理可得,Vue 其实也不应该滥用 watch

总结一句话,活用事件驱动。能用事件解决的问题,不要用 useEffect。(Vue 也是)

即使你不得不用 useEffect,React 还有附加要求,不要乱写依赖

再来看看这个例子,假如一个组件在 roomId 变化时需要重连 websocket,但是连接 websocket 的函数里又需要用到 theme,如果把 theme 写到依赖,那在更改主题的时候就会白白重连一次。

React 针对 useEffect 依赖问题又引入了新的 Hook useEffectEvent。通过这个 Hook 把函数抽象成一个“副作用事件”,它不是一个“响应式的值”,特点是不允许有依赖,也不能成为依赖,但能保持引用的值是最新的。

function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification("Connected!", theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on("connected", () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <h1>Welcome to the {roomId} room!</h1>;
}

然而实际上 useEffectEvent 都还属于实验阶段,所以这个问题现在还是需要先通过无视 linter 提示来处理。

useEffect is your last resort

要记住,useEffect 是最后杀招,可以用事件,优先用事件!实在没有事件触发时才会考虑使用它。

未来

无论是搞清楚函数式组件的一堆概念、处理性能问题、useEffect 的依赖问题,这些设计全都会给初学者带来心智负担

以函数组件为基础的 Jsx 确实是越来越自由了,但是代价就是学习曲线比较陡峭。

不过好在 React 团队已经意识到这个问题了,在未来的版本中,useRef 会被移除,组件也会被 React compiler 自动缓存,也就是上面说的不少优化内容也将不需要开发者额外控制。

React 从一开始渲染性能优化的 Fiber,到把组件范式改为更适合 Fiber 的函数式组件,再到后来推出让 Fiber 压榨性能的 Hook,这一路演变下来确实日子越过越好了。

后面应该会有更多 Hook 完善 React 的设计理念,函数式这个路子应该会一路走到黑,早用早享受,晚用享轻松啊。

参考

评论组件加载中……