0

react(一)从编码要点到原理

 7 months ago
source link: https://www.zoucz.com/blog/2024/01/14/4ce8eac0-b2bd-11ee-95cb-3556e1632a5e/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

1. 编码要点

react 中有一些不符合直觉,容易忽略或者误解的点,想要编写出高质量的 react 代码,通读文档还是很有必要的。这部分主要内容来自官方文档( https://react.dev/ ),官方文档中给出了非常多的示例代码,以及最佳实践和常见错误的示例。 我要说话

结合我以往编码经验和官方文档描述,从 JSX 、状态管理、副作用 等方面,记录一些 react 编码要点。我要说话

1.1 JSX

1.1.1 组件命名限制

这算是个入门级的潜规则,组件的名称必须以大写字母开头。如果组件名不是以大写字母开头的,react dom 不会把它识别为一个组件。 我要说话

function myButton() {
return (
<button>
I'm a button
</button>
);
}

let App = function MyApp() {
return (
<div>
<h1>Welcome to my app</h1>
<myButton />
</div>
);
}

image.png我要说话

1.1.2 禁止嵌套定义组件

组件可以嵌套渲染其他组件,但是禁止嵌套定义组件。这一点单独拿出来说,是因为 react 编译时运行时均不认为这是一个错误,然而这样做会导致运行时性能问题和体验BUG。 我要说话

① 性能问题 —— 重复渲染 我要说话

function ParentComponent(){
function ChildComponent(){...}
return <>...<ChildComponent />...</>
}

上面的写法中,父组件每次重新渲染时,都会重新定义子组件,进而导致每次返回的 JSX 组件中,子组件部分都是一个全新组件。渲染、提交DOM时,会将其当做一个全新的渲染节点,生成新的 DOM 节点。 我要说话

② 体验 BUG —— 子组件状态丢失 我要说话

有时候如果子组件本身没有状态,我们并不容易发现重复渲染的性能问题。 我要说话

let App = function MyApp() {
const [counter, setCounter] = useState(0);
function ChildComponent() {
const id = useId()
const [content, setContent] = useState('');
return (<>
<p>component id is: {id}, content is: {content}</p>
<input value={content} onChange={(e)=>{ setContent(e.target.value) }} />
</>)
}
return (
<div>
<h1>Welcome to my app</h1>
<button onClick={() => {setCounter(counter + 1)}}>counter: {counter}</button>
<ChildComponent />
</div>
);
}

如果子组件中有状态,则在父组件重新渲染时,会触发子组件的重新定义重新渲染生成,其状态会丢失,运行上面的代码还可以看到子组件的 id 在不断发生变化。 我要说话

③ 体验 BUG —— 焦点丢失 我要说话

当子组件嵌套定义时,它可以访问到父组件的状态,如果直接在子组件中使用和修改父组件的状态,还可能引入因重新渲染而丢失焦点的体验问题。 我要说话

let App = function MyApp() {
const [counter, setCounter] = useState(0);
const [content, setContent] = useState('');
function ChildComponent() {
const id = useId()
return (<>
<p>component id is: {id}, content is: {content}</p>
<input value={content} onChange={(e)=>{ setContent(e.target.value) }} />
</>)
}
return (
<div>
<h1>Welcome to my app</h1>
<button onClick={() => {setCounter(counter + 1)}}>counter: {counter}</button>
<ChildComponent />
</div>
);
}

例如上面的写法,组件貌似可以正常工作,但是每输入一个字符,输入框的焦点就会丢失。原因就是嵌套定义带来的子组件重新渲染问题。 我要说话

解决方案非常简单,如上面②中的示例,将子组件挪到外面定义就行了;③中的示例,子组件本身没有状态,可以把它挪到外面定义,或者当做函数调用而不是子组件使用 {ChildComponent()}。 我要说话

1.1.3 html 不是 JSX

看起来我们可以直接在 JSX 中使用 html 的标签, 但是要注意的是 html 并不能直接当 JSX 使用。 我要说话

标签属性和内联style属性需要以驼峰命名法编写。 我要说话

例如,html
我要说话

<ul style="background-color: black">

我要说话

在 JSX 组件里应该写成 我要说话

<ul style={{ backgroundColor: 'black' }}>

需要避免 JSX 属性和 html 属性冲突。
由于 class 是一个保留字,所以在 React 中需要用 className 来代替。 我要说话

更多细节可以使用 html-to-jsx 转换器探索。 https://transform.tools/html-to-jsx。 我要说话

1.1.4 条件渲染时的 && 写法

条件渲染时,切勿将数字放在 && 左侧,0将会被作为文本渲染 我要说话

1.1.5 列表渲染时的 key

  • 不要把数组项的索引当作 key 值来用
  • 不要使用随机数当做 key 使用
    这两种方式都会导致渲染性能下降。

1.1.6 严格模式与纯函数

纯函数的特征:我要说话

  • 只负责自己的任务。它不会更改在该函数调用前就已存在的对象或变量。
  • 输入相同,则输出相同。给定相同的输入,纯函数应总是返回相同的结果。

简单来说就是纯函数不会修改外部的变量或对象,也不会引用外部会发生变化的变量/对象。 我要说话

下面是一个非纯函数组件 我要说话

let guest = 0;
function Cup() {
// Bad: changing a preexisting variable!
guest = guest + 1;
return <h2>Tea cup for guest #{guest}</h2>;
}
export default function TeaSet() {
return (
<>
<Cup />
<Cup />
<Cup />
</>
);
}

这个组件在每次渲染时都会让外部变量加1,有很多不可控因素会影响这个组件的渲染,例如父组件重新渲染带来的子组件重新渲染等,导致最终的结果不可控。 我要说话

基于非纯函数的特性,react 在开发环境下引入的 StrictMode 来帮助检测这些问题,Strict 模式下,每个组件会被渲染两次,这样上面的组件显示的结果就是 2、4、6 而不是 1、2、3。 我要说话

1.2 状态管理

1.2.1 状态的本质

状态在 react 内部的实现,类似一个组件实例绑定的数组,下面是一段示例代码帮助我们理解 我要说话

let componentHooks = [];
let currentHookIndex = 0;

// useState 在 React 中是如何工作的(简化版)
function useState(initialState) {
let pair = componentHooks[currentHookIndex];
if (pair) {
// 这不是第一次渲染
// 所以 state pair 已经存在
// 将其返回并为下一次 hook 的调用做准备
currentHookIndex++;
return pair;
}

// 这是我们第一次进行渲染
// 所以新建一个 state pair 然后存储它
pair = [initialState, setState];

function setState(nextState) {
// 当用户发起 state 的变更,
// 把新的值放入 pair 中
pair[0] = nextState;
updateDOM();
}

// 存储这个 pair 用于将来的渲染
// 并且为下一次 hook 的调用做准备
componentHooks[currentHookIndex] = pair;
currentHookIndex++;
return pair;
}

详见文档:react.dev/learn/state-a-components-memory#giving-a-component-multiple-state-variables 我要说话

这段代码演示了组件是如何记忆状态变量,并在每次重新渲染时返回变量的。 我要说话

值得注意的是,组件的状态由 react 保存,且对于每个组件实例(即渲染节点),状态都是隔离且私有的。 我要说话

1.2.2 状态更新(不可变对象)

组件的状态应该是一个不可变对象,即不能在组件内部直接给状态变量赋值、修改状态变量的属性/元素等。 我要说话

① 不能直接在组件中修改状态变量或者给状态变量赋值 我要说话

function Component(){
let [counter, setCounter] = useState(0);
// 这样赋值修改的是当前作用域的值,不会对存储在组件对象内部的状态值造成任何影响
counter = 1;
// ...
let [obj, setObj] = useState({});
// 这样赋值修改会影响状态,但是无法正确触发组件重新渲染
obj.a = 1;
// ...
let [arr, setArr] = useState([]);
// 这样赋值修改会影响状态,但是无法正确触发组件重新渲染
arr[3] = 1;
}

② 如果 state 变量是一个对象时,不能只更新它的属性。 我要说话

function Component(){
let [obj, setObj] = useState({a:1, b:2});
// 这样会导致 obj 整体变为 {a: 3},b属性丢失
setObj({a: 3});
// 正确做法
setObj({...obj, a: 3});
}

③ 不能使用状态对象或数组本身修改后更新状态 我要说话

function Component(){
let [obj, setObj] = useState({a:1, b:2});
// 这样的状态修改不能正常触发重新渲染
obj.a = 3;
setObj(obj);
// 正确做法,创建一个新对象
setObj({...obj, a: 3});
// ...
let [arr, setArr] = useState([1, 2, 3]);
// 这样的状态修改不能正常触发重新渲染
arr.push(4);
setArr(arr);
// 正确做法,创建一个新数组
setArr([...arr, 4]);
}

react 使用 Object.is 来比较状态是否发生变化,因而直接修改状态本身,react 是感知不到状态变化的,在更新渲染树时也就不会更新渲染节点。 我要说话

使用不可变对象库可以简化写法,参考:我要说话

1.2.3 重新渲染时的状态保留和重置

image.png

我要说话

react 从组件中创建渲染树,并基于渲染树创建真正的 DOM 树。中间的部分就是根据组件渲染得到的渲染树。 我要说话

当向一个组件添加状态时,看起来状态是保存在组件内。但实际上,状态是由 React 保存的。React 通过组件在渲染树中的位置将它保存的每个状态与正确的组件关联起来。 我要说话

也就是说,只有当在树中相同的位置渲染相同的组件时,React 才会一直保留着组件的 state。 我要说话

值得注意的是,react 在生成渲染树时,每个花括号内的渲染结果都会被当做一个渲染节点,下面的两个 JSX 示例中 我要说话

<div>
{isFancy ? (
<Counter isFancy={true} />
) : (
<Counter isFancy={false} />
)}
</div>
...
<div>
{isFancy ? <Counter isFancy={true} /> : null }
{isFancy ? null : <Counter isFancy={false} /> }

</div>

前面的写法会被 react 解析成一个渲染节点,isFancy 发生变化时,这个渲染节点返回的总是 Counter 组件,Counter 组件对应的状态变量保留;后面的写法则会被解析为两个渲染节点,分别根据情况返回 Counter 组件或者 null,此时 isFancy 发生变化时,表达式返回的组件不同,状态不会保留。 我要说话

这两种写法渲染效果从表面上看是一致的,但是内部的状态逻辑完全不同,值得注意。 我要说话

1.2.4 使用 reducer 整合状态更新

由 useState 切换到 useReducer 可以将复杂的状态更新逻辑整合到 reducer 函数中。 我要说话

reducer 函数的作用用一句话来描述就是:接收当前的 state 和一个 action,返回经过 action 处理后的 state。 我要说话

reducer 函数一般可以支持多种 action,即把不同的更新逻辑都整合到各种 action 中。
我要说话

import { useReducer } from 'react';
import AddTask from './AddTask.js';
import TaskList from './TaskList.js';

export default function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++,
text: text,
});
}

function handleChangeTask(task) {
dispatch({
type: 'changed',
task: task,
});
}

function handleDeleteTask(taskId) {
dispatch({
type: 'deleted',
id: taskId,
});
}

return (
<>
<h1>布拉格的行程安排</h1>
<AddTask onAddTask={handleAddTask} />
<TaskList
tasks={tasks}
onChangeTask={handleChangeTask}
onDeleteTask={handleDeleteTask}
/>
</>
);
}

function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [
...tasks,
{
id: action.id,
text: action.text,
done: false,
},
];
}
case 'changed': {
return tasks.map((t) => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter((t) => t.id !== action.id);
}
default: {
throw Error('未知 action: ' + action.type);
}
}
}

let nextId = 3;
const initialTasks = [
{id: 0, text: '参观卡夫卡博物馆', done: true},
{id: 1, text: '看木偶戏', done: false},
{id: 2, text: '打卡列侬墙', done: false}
];

我要说话

参考 http://react.dev/learn/extracting-state-logic-into-a-reducer#step-3-use-the-reducer-from-your-component 我要说话

1.2.5 使用 props 传递状态

下面是一个官网提供的典型示例,若两个同级子组件想要共享一个状态,可以将状态提升到它们的父组件中,然后通过 props 向下传递状态。 我要说话

image.png

我要说话

变量提升,并通过 props 向子组件传递状态 我要说话

image.png

我要说话

1.2.6 使用 context 传递状态

使用 props 时,状态需要逐级透传,当组件数量多结构复杂时,这样效率会非常低,而且让组件的结构变得复杂难以维护。 我要说话

image.png

我要说话

而使用 context 可以跨层级传递状态 我要说话

image.png

我要说话

一般用于传递这类数据:我要说话

  • 登录用户信息
  • 全局状态管理

    1.2.7 影响渲染的因素总结

    总结一下,当一个组件的渲染函数被调用时,除了它自身内部的局部变量和引用的外部变量(如浏览器API返回的外部状态数据等),还有这些可变因素会影响渲染结果
  • state
  • props
  • context

1.3 副作用

1.3.1 什么时候使用

react 中的逻辑分为这几类 我要说话

  • 渲染逻辑代码:组件的主体代码部分,一个返回 JSX 组件的纯函数
  • 用户事件处理程序:特征组件内部,用于处理用户操作(如点击或者输入)引起的引起的“副作用”(它们改变了程序的状态)。
  • 用渲染本身引起的副作用:如组件渲染后需要连接到服务器,或者需要加载数据等。
    值得注意的是,useEffect 的执行时机是 渲染完成、屏幕更新后的 commit 阶段运行。

1.3.2 触发类型

useEffect 有无依赖参数、空数组依赖、非空数组依赖 三种情况 我要说话

// 每次渲染完成后都执行
useEffect(() => {})
// 仅第一次渲染完成后执行
useEffect(() => {}, [])
// 依赖的变量 a / b 中任意一个发生变化后执行
useEffect(() => {}, [a, b])

其中第三种方式要注意,在 useEffect 中依赖的任何可变对象,都需要在后面的依赖数组中传入,否则 react 会报错。 我要说话

1.3.3 useEffect 引起的死循环

useEffect 会在每次渲染后执行,或者每次渲染后依赖发生变化后执行,下面的写法会导致每次 useEffect 执行后状态被改变,又触发重新渲染,导致死循环。 我要说话

const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
//...
useEffect(() => {
setCount(count + 1);
}, [count]);

1.3.4 多种不应该使用 useEffect 的情况

参考文档 https://react.dev/learn/you-might-not-need-an-effect
官方给出的场景非常多,就不一一列举了,感觉也记不住。 我要说话

我觉得要不最佳实践就别记黑名单了,记白名单吧: 我要说话

  • 组件初始化时的服务器连接操作
  • 组件初始化时的数据加载操作等
  • 一次渲染完毕后的其它操作如修改 DOM 等

1.3.5 useEffect 的生命周期

Effect 与组件有不同的生命周期。组件可以挂载、更新或卸载。Effect 只能做两件事:开始同步某些东西,然后停止同步它。如果 Effect 依赖于随时间变化的 props 和 state,这个循环可能会发生多次。 我要说话

例如下面的 useEffect 我要说话

const serverUrl = 'https://localhost:1234';

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

在 组件挂载 → roomId 发生变化 → 组件卸载,的过程中,useEffect 可能经历下面的生命周期 我要说话

ChatRoom 组件的变化过程:

ChatRoom 组件挂载,roomId 设置为 "general"
ChatRoom 组件更新,roomId 设置为 "travel"
ChatRoom 组件更新,roomId 设置为 "music"
ChatRoom 组件卸载

Effect 执行了不同的操作:
Effect 连接到了 "general" 聊天室
Effect 断开了与 "general" 聊天室的连接,并连接到了 "travel" 聊天室
Effect 断开了与 "travel" 聊天室的连接,并连接到了 "music" 聊天室
Effect 断开了与 "music" 聊天室的连接

1.4 hooks

1.4.1 现有 hooks

参考文档:https://react.dev/reference/react/hooks
目前 react 提供了内置的 useState、useContext、useRef、useEffect 等常用 hook,以及我要说话

  • 性能优化相关hook: useMemo、useCallback、useTransition、useDeferredValue
  • 资源相关 hook:use
  • 其它 hook:useDebugValue、useId、useSyncExternalStore

    1.4.2 自定义 hooks

    像组件有一个命名规范一样,自定义 hooks 也有要遵守的命名规范。
  • React 组件名称必须以大写字母开头,比如 StatusBar 和 SaveButton。React 组件还需要返回一些 React 能够显示的内容,比如一段 JSX。
  • Hook 的名称必须以 use 开头,然后紧跟一个大写字母,如内置的 useState 或者自定义的 useOnlineStatus 。Hook 可以返回任意值。
    需要注意的是,自定义 Hook 共享的是状态逻辑,而不是状态本身。
    参考官方文档 https://react.dev/learn/reusing-logic-with-custom-hooks#custom-hooks-let-you-share-stateful-logic-not-state-itself 中的示例。

使用自定义 hook 的方式:
我要说话

function StatusBar() {
const isOnline = useOnlineStatus();
// ...
}

function SaveButton() {
const isOnline = useOnlineStatus();
// ...
}

我要说话

可以看做是和在组件中各自写重复逻辑代码一样,两个组件中的状态是私有独立,互不干扰的。
我要说话

function StatusBar() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// ...
}, []);
// ...
}

function SaveButton() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// ...
}, []);
// ...
}

我要说话

1.5 其它能力

1.5.1 记忆化

参考:http://react.dev/reference/react/useMemo、http://react.dev/reference/react/useCallback
可以将数据、组件、函数缓存到 react 中,当依赖项没有发生变化时,整个生命周期中数据或组件或函数不再重新生成。 我要说话

① 使用 useMemo 缓存数据 我要说话

import { useMemo } from 'react';

function TodoList({ todos, tab }) {
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
// ...
}

当依赖的 todos,tab 未发生变化时,TodoList 的多次重新渲染不会导致 filterTodos 的重新计算。
适用场景:数据计算量比较大时,缓存数据 我要说话

② 使用 useCallback / useMemo 缓存函数 我要说话

使用 useMemo 的写法 我要说话

export default function Page({ productId, referrer }) {
const handleSubmit = useMemo(() => {
return (orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
};
}, [productId, referrer]);

return <Form onSubmit={handleSubmit} />;
}

使用 useCallback 的写法,和上面的完全一样,只是为了简化写法 我要说话

export default function Page({ productId, referrer }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
}, [productId, referrer]);

return <Form onSubmit={handleSubmit} />;
}

③ 使用 memo 缓存组件 我要说话

默认情况下,当一个组件重新渲染时,React 会递归地重新渲染它的所有子组件。可以通过将它包装在 memo 中,这样当它的 props 跟上一次渲染相同的时候它就会跳过本次渲染。 我要说话

不使用 memo 的情况 我要说话

export default function TodoList({ todos, tab, theme }) {
// 每当主题发生变化时,这将是一个不同的数组……
const visibleTodos = filterTodos(todos, tab);
return (
<div className={theme}>
{/* ... 所以List的props永远不会一样,每次都会重新渲染 */}
<List items={visibleTodos} />
</div>
);
}

使用 memo 的情况 我要说话

export default function TodoList({ todos, tab, theme }) {
// 告诉 React 在重新渲染之间缓存你的计算结果...
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab] // ...所以只要这些依赖项不变...
);
return (
<div className={theme}>
{/* ... List 也就会接受到相同的 props 并且会跳过重新渲染 */}
<List items={visibleTodos} />
</div>
);
}

总结:记忆化只要适用于下面的场景 我要说话

  • 跳过代价昂贵的数据重新计算:useMemo(数据, [依赖])
  • 跳过组件的重新渲染:memo(组件, [依赖])
  • 记忆另一个 hook 的依赖,防止重复触发:useXXX(xxx, [ useMemo(yyy) ])
  • 记忆一个函数:useMemo( () => fn, [依赖] ) / useCallback(fn, [依赖])

1.5.2 ref

当你希望组件“记住”某些信息,但又不想让这些信息 触发新的渲染 时,你可以使用 ref 。 我要说话

① 什么是 ref 我要说话

ref 可以看做是一个不支持 setState 的状态对象:
我要说话

// React 内部
function useRef(initialValue) {
const [ref, unused] = useState({ current: initialValue });
return ref;
}

我要说话

从这里可以看出,ref 对象是一个不可变对象,通过 Object.js 判断两次渲染间的 ref 变量,结果永远是 true。我要说话

② 使用 ref 来缓存 DOM 我要说话

当你将 ref 放在像 <input /> 这样输出浏览器元素的内置组件上时,React 会将该 ref 的 current 属性设置为相应的 DOM 节点(例如浏览器中实际的 <input /> )。
我要说话

import { useRef } from 'react';
const myRef = useRef(null);
<div ref={myRef}>

我要说话

③ 允许 JSX 组件接收 ref 我要说话

如前面所说,只有浏览器元素的内置组件才支持通过 ref 属性设置 dom,如果想要 JSX 组件也能接收 ref,可以使用 forwardRef 将组件进行包装。
我要说话

const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});

我要说话

④ ref 只暴露 dom 的部分能力 我要说话

可以使用 useImperativeHandle 只暴露 DOM 的一部分能力,而不是全部 我要说话

const MyInput = forwardRef((props, ref) => {
const realInputRef = useRef(null);
useImperativeHandle(ref, () => ({
// 只暴露 focus,没有别的
focus() {
realInputRef.current.focus();
},
}));
return <input {...props} ref={realInputRef} />;
});

1.5.3 懒加载 lazy

参考:http://react.dev/reference/react/lazy 我要说话

react 的懒加载机制: 我要说话

import { lazy } from 'react';

const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));
<Suspense fallback={<Loading />}>
<h2>Preview</h2>
<MarkdownPreview />
</Suspense>

实现了懒加载模块,以及加载中时的替代组件展示。 我要说话

1.5.4 useSyncExternalStore 订阅外部数据

当一个 react 组件想订阅外部来源的数据时,可以用 useEffect 的方式 我要说话

function useOnlineStatus() {
// 不理想:在 Effect 中手动订阅 store
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}

updateState();

window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}

function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}

可以使用 useSyncExternalStore 来实现上面的逻辑 我要说话

function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}

function useOnlineStatus() {
// ✅ 非常好:用内置的 Hook 订阅外部 store
return useSyncExternalStore(
subscribe, // 只要传递的是同一个函数,React 不会重新订阅
() => navigator.onLine, // 如何在客户端获取值
() => true // 如何在服务端获取值
);
}

function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}

1.5.5 并发任务

在 v18 版本中, react 提供了 useDeferredValue、 startTransition 来执行并发渲染的操作,以提高复杂渲染过程的用户体验,其原理是利用渲染的空闲时间来执行复杂的渲染操作,后面会详细描述。 我要说话

1.6 外部状态管理库

前面描述了 react 内置的状态管理能力,而在复杂的实际项目中,有一些问题需要解决,因而业界诞生了非常多的状态管理库,来优化 react 项目中的状态管理。他们主要解决下面的问题: 我要说话

  • 组件间状态共享:在大型应用中,有时候需要在多个组件之间共享状态,使用外部状态管理库可以方便地在组件间共享状态,而不需要通过层层传递 props。
  • 状态管理复杂度:随着应用规模的增长,状态管理变得越来越复杂。使用外部状态管理库可以将状态管理与组件逻辑分离,使得代码更加清晰、易于维护。
  • 时间旅行调试:一些外部状态管理库提供了时间旅行功能,可以方便地回溯状态变化过程,帮助开发者更快地定位问题。
  • 中间件支持:外部状态管理库可以支持中间件,使得开发者可以方便地扩展状态管理功能,例如异步操作、日志记录等。

一些常见的外部状态管理库: 我要说话

类型 描述 缺点
单向数据流 Redux 一个基于 Flux 架构的状态管理库,通过单一数据源和纯函数(reducer)来管理状态。它解决了状态共享、状态管理复杂度、时间旅行调试等问题。常用的中间件有 redux-thunk、redux-saga、redux-logger 等 学习曲线较高,需要理解一定的概念(如 action、reducer、store 等)。代码冗余,需要编写大量样板代码。对于一些简单的状态管理场景,使用 Redux 可能会显得过于繁琐。
单向数据流 Zustand 一个轻量级的状态管理库,通过 hooks API 来管理状态。它解决了状态共享、状态管理复杂度等问题,同时具有较低的学习成本。 相对较小,功能可能不如 Redux 和 MobX 丰富。社区和生态相对较小,可能在一些特定场景下缺乏支持。
响应式 MobX 一个基于观察者模式的状态管理库,通过可观察对象(observable)和自动追踪(autorun)来管理状态。它解决了状态共享、状态管理复杂度等问题,相较于 Redux,它的学习曲线更低,代码更简洁。 隐式依赖,由于使用观察者模式,可能导致难以追踪的数据流和副作用。不支持时间旅行调试,与 Redux 相比,调试功能较弱。
原子状态 Recoil Facebook 开源的一款状态管理库,它使用原子(atoms)和选择器(selectors)来管理状态。Recoil解决了组件间状态共享、状态管理复杂度等问题,同时与 React 更紧密地集成。 相对较新,社区和生态尚未完全成熟。
Hooks hox 一个基于 React Hooks 的轻量级状态管理库,它将状态和操作封装在自定义 Hook 中,实现了状态共享和逻辑复用。 相对较新,社区和生态相对较小;对于大型应用的状态管理需求,可能不如 Redux 和 MobX。

2. 框架原理分析

日常编码,我们参考 react 官方文档,已经可以写出足够好的代码了。如果想要更深入的了解,则需要对 react 的实现原理进行分析。 我要说话

这部分主内容主要来源于 https://github.com/7kms/react-illustration-series 图解 react 核心逻辑,按应用启动、渲染流程、任务管理、用户接口(状态和副作用)、hook 的顺序进行描述。 我要说话

2.1 启动流程

react 有三种启动模式:我要说话

  • legacy 模式,是 react17 版本中的默认启动模式,只能进入同步工作循环,无法使用可中断渲染特性。启动方式:ReactDOM.render(, rootNode)
  • Blocking 模式,是一个过渡版本,实现了部分 concurrent 模式的特性。启动方式:ReactDOM.createBlockingRoot(rootNode).render()
  • Concurrent 模式,支持时间分片、可中断渲染的模式。启动方式:ReactDOM.createRoot(rootNode).render()
image.png

我要说话

上面的三种启动方式都是从 react-dom 包发起,调用 react-reconciler 包。整个启动的过程概述: 我要说话

  • 【react-dom】入口 render/createRoot/createBlockingRoot
  • 【react-dom】创建 ReactDOMRoot / ReactDOMBlockingRoot 对象,提供 render / umount 方法
  • 【react-reconciler】创建 fiberRoot 对象,保存 fiber 构建过程中依赖的全局状态,保存为 ReactDOMRoot 对象的 _internalRoot 属性
  • 创建 HostRootFiber 对象, react 的首个 fiber 对象,作为 fiberRoot 的 current 属性

三种模式的初始化后对象引用关系: 我要说话

image.png

我要说话

2.2 渲染流程

应用启动完成后,react 即进入 react-reconciler 包调用 updateContainer 函数,执行 fiber 树的构造工作,并最终将 JSX 转换为真实 DOM 节点,渲染出用户界面。 我要说话

函数调用栈大致是我要说话

  • updateContainer → scheduleUpdateOnFiber → performSyncWorkOnRoot(legacy模式 && 首次创建)
  • updateContainer → scheduleUpdateOnFiber → ensureRootIsScheduled(concurrent 或非首次启动)→ 回调 → performSyncWorkOnRoot / performConcurrentWorkOnRoot

fiber 树构造

  • 步骤一:所有采用jsx语法书写的节点, 都会被编译器转换, 最终会以React.createElement(…)的方式, 创建出来一个与之对应的ReactElement对象
  • 步骤二:通过ReactElement对象创建对应的fiber对象, 多个fiber对象构成了一棵fiber树
  • 步骤三:以fiber树 为数据模型构造最终的 DOM 树,触发最终的 UI 渲染
image.png

我要说话

ReactElement 树和 fiber 树的构造过程都是一个深度优先遍历的过程,这里只引用 ReactElement 树的构造构成,如下 我要说话

image.png

我要说话

fiber 树更新

有 3 种常见方式可以主动触发 fiber 树的重新构造: 我要说话

  • Class组件中调用setState
  • Function组件中调用hook对象暴露出的dispatchAction(使用 useState )
  • 在container节点上重复调用render

fiber树的构造过程, 就是把ReactElement转换成fiber树的过程. 在这个过程中, 内存里会同时存在 2 棵fiber树: 我要说话

  • 其一: 代表当前界面的fiber树(已经被展示出来, 挂载到fiberRoot.current上). 如果是初次构造(初始化渲染), 页面还没有渲染, 此时界面对应的 fiber 树为空(fiberRoot.current = null).
  • 其二: 正在构造的fiber树(即将展示出来, 挂载到HostRootFiber.alternate上, 正在构造的节点称为workInProgress). 当构造完成之后, 重新渲染页面, 最后切换fiberRoot.current = workInProgress, 使得fiberRoot.current重新指向代表当前界面的fiber树
    构造过程中,当前界面的 HostRootFiber 的 alternate 属性指向更新中的 Fiber 树实例
image.png

我要说话

构造完成后,切换 FiberRoot 的 current,指向更新完成的 HostRootFiber 我要说话

image.png

我要说话

diff 算法:更新 fiber 树的过程中,将旧的 fiber 对象与新的 ReactElement 对象相比较,给需要新增,移动,和删除的节点设置相应的 flag。 我要说话

单节点比较:我要说话

  • 如果是新增节点, 直接新建 fiber, 没有多余的逻辑
  • 对比节点,如果key和type都相同(即: ReactElement.key === Fiber.key 且 Fiber.elementType === ReactElement.type), 则复用,否则新建

可迭代节点比较(如数组类型等): 我要说话

  • 第一次循环: 遍历最长公共序列(key 相同), 公共序列的节点都视为可复用
    • 如果newChildren序列被遍历完, 那么oldFiber序列中剩余节点都视为删除(打上Deletion标记)
    • 如果oldFiber序列被遍历完, 那么newChildren序列中剩余节点都视为新增(打上Placement标记)
  • 第二次循环: 遍历剩余非公共序列, 优先复用 oldFiber 序列中的节点
    • 在对比更新阶段(非初次创建fiber, 此时shouldTrackSideEffects被设置为 true). 第二次循环遍历完成之后, oldFiber序列中没有匹配上的节点都视为删除(打上Deletion标记)
image.png

我要说话

image.png

我要说话

fiber 树渲染

fiber 树的整个渲染逻辑都在commitRoot 函数中,渲染上屏过程大致做了下面的一些步骤。 我要说话

  • commitBeforeMutationEffects dom 变更之前, 处理副作用队列中带有Snapshot,Passive标记的fiber节点
    • 处理Snapshot标记
    • 处理Passive标记
  • commitMutationEffects dom 变更, 界面得到更新. 处理副作用队列中带有Placement, Update, Deletion, Hydrating标记的fiber节点,最终调用appendChild, commitUpdate, removeChild这些react-dom包中的函数. 它们是HostConfig协议(源码在 ReactDOMHostConfig.js 中)中规定的标准函数, 调用后界面会得到更新。
    • 新增DOM: 函数调用栈 commitPlacement -> insertOrAppendPlacementNode -> appendChild
    • 更新DOM: 函数调用栈 commitWork -> commitUpdate
    • 删除DOM: 函数调用栈 commitDeletion -> removeChild
  • commitLayoutEffectsdom 变更后, 处理副作用队列中带有Update | Callback标记的fiber节点
    • 对于ClassComponent节点, 调用生命周期函数componentDidMount或componentDidUpdate, 调用update.callback回调函数.
    • 对于HostComponent节点, 如有Update标记, 需要设置一些原生状态(如: focus等)
      渲染完成后,还要做一些清理工作
  • 清除副作用队列
  • 检测更新
    • 在整个渲染过程中, 有可能产生新的update(比如在componentDidMount函数中, 再次调用setState()).
    • 如果是常规(异步)任务, 不用特殊处理, 调用ensureRootIsScheduled确保任务已经注册到调度中心即可.
    • 如果是同步任务, 则主动调用flushSyncCallbackQueue(无需再次等待 scheduler 调度), 再次进入 fiber 树构造循环

2.3 任务管理

前面描述的渲染流程,fiber 树更新的整个过程中,涉及到大量的 fiber 树构造、dom生成等工作。react 实现了一套任务执行流程和任务调度机制来高效完成这些工作,具体代码在 react-reconciler、scheduler 包中实现。 我要说话

reconciler 运作流程

react-reconciler包的主要作用, 将主要功能分为 4 个方面: 我要说话

  • 输入 scheduleUpdateOnFiber: 暴露api函数(如: scheduleUpdateOnFiber), 供给其他包(如react包)调用
    • 不经过调度, 直接进行fiber构造.
    • 注册调度任务, 经过Scheduler包的调度, 间接进行fiber构造
  • 注册调度任务 ensureRootIsScheduled: 与调度中心(scheduler包)交互, 注册调度任务task, 等待任务回调
    • 判断是否需要注册新的调度(如果无需新的调度, 会退出函数)
    • 注册调度任务,等待调度中心执行回调 performSyncWorkOnRoot或performConcurrentWorkOnRoot
  • 执行任务回调 performSyncWorkOnRoot / performConcurrentWorkOnRoot: 在内存中构造出fiber树, 同时与与渲染器(react-dom)交互, 在内存中创建出与fiber对应的DOM节点
    • fiber 树构造
    • 异常处理: 有可能 fiber 构造过程中出现异常
  • 输出 commitRoot: 与渲染器(react-dom)交互, 渲染DOM节点,即前面描述的“fiber 树渲染” 中的工作
    • commitBeforeMutationEffects
    • commitMutationEffects
    • commitLayoutEffects
image.png

我要说话

scheduler 调度原理

reconciler 运作的流程中,很多地方都涉及任务调度工作,任务调度决定了整个 react 应用的运行效率。 我要说话

scheduler 模块提供了两类能力。 我要说话

  • 调度相关: 请求或取消调度
    • requestHostCallback 请求调度
    • cancelHostCallback 取消调度
    • requestHostTimeout 请求延时调度
    • cancelHostTimeout 取消延时调度
  • 时间切片(time slicing)相关: 执行时间分割, 让出主线程(把控制权归还浏览器, 浏览器可以处理用户输入, UI 绘制等紧急任务)
    • getCurrentTime: 获取当前时间
    • shouldYieldToHost: 是否让出主线程
    • requestPaint: 请求绘制
    • forceFrameRate: 强制设置 yieldInterval(从源码中的引用来看, 算一个保留函数, 其他地方没有用到)

requestHostCallback 中,利用 MessageChannel / setTimeout 实现了类似 window.requestIdleCallback 的能力,以支持中断当前任务队列让出CPU的功能。 其中,shouldYieldToHost 让出 CPU 的判定条件是: 我要说话

  • currentTime >= deadline: 只有时间超过deadline之后才会让出主线程(其中deadline = currentTime + yieldInterval).
    • yieldInterval默认是5ms, 只能通过forceFrameRate函数来修改
    • 如果一个task运行时间超过5ms, 下一个task执行之前, 会把控制权归还浏览器.
  • navigator.scheduling.isInputPending(): 这 facebook 官方贡献给 Chromium 的 api, 现在已经列入 W3C 标准, 用于判断是否有输入事件(包括: input 框输入事件, 点击事件等)

整个调度过程的核心流程: 我要说话

image.png

我要说话

scheduler 优先级和任务队列

scheduler 内部实现了两种优先级机制。 我要说话

  • 与fiber构造过程相关的优先级(如fiber.updateQueue,fiber.lanes)都使用LanePriority
  • 与scheduler调度中心相关的优先级使用SchedulerPriority

为了能协同调度中心(scheduler包)和 fiber 树构造(react-reconciler包)中对优先级的使用, 则需要转换SchedulerPriority和LanePriority, 通过ReactPriorityLevel进行转换。 我要说话

其中 LanePriority 是基于位运算实现的优先级保存和比对: 我要说话

  • 可以使用的比特位一共有 31 位(js的位运算最高支持到int32,减去一个符号位)
  • 共定义了18 种车道(Lane/Lanes)变量, 每一个变量占有 1 个或多个比特位, 分别定义为Lane和Lanes类型.
  • 每一种车道(Lane/Lanes)都有对应的优先级, 所以源码中定义了 18 种优先级(LanePriority).
  • 占有低位比特位的Lane变量对应的优先级越高
    • 最高优先级为SyncLanePriority对应的车道为SyncLane = 0b0000000000000000000000000000001.
    • 最低优先级为OffscreenLanePriority对应的车道为OffscreenLane = 0b1000000000000000000000000000000

具体执行任务时, scheduler 内部定义了 2 个数组taskQueue和timerQueue, 它们都是按优先级以最小堆的形式进行存储, 这样就能保证以O(1)的时间复杂度, 取到数组顶端的对象(优先级最高的 task) 我要说话

image.png

我要说话

时间切片和可中断渲染

时间切片原理: 我要说话

消费任务队列的过程中, 可以消费1~n个 task, 甚至清空整个 queue. 但是在每一次具体执行task.callback之前都要进行超时检测, 如果超时可以立即退出循环并等待下一次调用。 我要说话

可中断渲染原理: 我要说话

在时间切片的基础之上, 如果单个task.callback执行时间就很长(假设 200ms). 就需要task.callback自己能够检测是否超时, 所以在 fiber 树构造过程中, 每构造完成一个单元, 都会检测一次超时(源码链接), 如遇超时就退出fiber树构造循环, 并返回一个新的回调函数(就是此处的continuationCallback)并等待下一次回调继续未完成的fiber树构造。 我要说话

react18.2中,提供了 useDeferredValue、useTransition 来进行并发渲染操作,其内部就应用了可中断渲染的功能。参考示例: 我要说话

尝试多段连续输入触发渲染,观察是否使用可中断渲染的表现: 我要说话

普通渲染 我要说话

image.png

我要说话

可中断渲染 我要说话

image.png

我要说话

2.4 状态和副作用

一个 fiber 节点中,影响最终渲染结果的属性可以分为状态类属性和副作用类属性。 我要说话

  • 状态类: 在renderRootSync[Concurrent]阶段, 为子节点提供确定的输入数据, 直接影响子节点的生成
  • 副作用类: 在commitRoot阶段, 如果fiber被标记有副作用, 则副作用相关函数会被(同步/异步)调用
export type Fiber = {|
// 1. fiber节点自身状态相关
pendingProps: any,
memoizedProps: any,
updateQueue: mixed,
memoizedState: any,

// 2. fiber节点副作用(Effect)相关
flags: Flags,
subtreeFlags: Flags,
deletions: Array<Fiber> | null,
nextEffect: Fiber | null,
firstEffect: Fiber | null,
lastEffect: Fiber | null,
|};

状态相关属性

  • fiber.pendingProps: 输入属性, 从ReactElement对象传入的 props. 它和fiber.memoizedProps比较可以得出属性是否变动.
  • fiber.memoizedProps: 上一次生成子节点时用到的属性, 生成子节点之后保持在内存中. 向下生成子节点之前叫做pendingProps, 生成子节点之后会把pendingProps赋值给memoizedProps用于下一次比较.pendingProps和memoizedProps比较可以得出属性是否变动.
  • fiber.updateQueue: 存储update更新对象的队列, 每一次发起更新, 都需要在该队列上创建一个update对象.
  • fiber.memoizedState: 上一次生成子节点之后保持在内存中的局部状态.

它们在fiber树构造阶段, 直接影响子节点的生成。 我要说话

副作用相关属性

  • fiber.flags: 标志位, 表明该fiber节点有副作用(在 react-reconciler/src/ReactFiberFlags.js 中定义了28种副作用).
  • fiber.nextEffect: 单向链表, 指向下一个副作用 fiber节点.
  • fiber.firstEffect: 单向链表, 指向第一个副作用 fiber 节点.
  • fiber.lastEffect: 单向链表, 指向最后一个副作用 fiber 节点.

副作用是一个动态功能, 由于它的调用时机是在fiber树渲染阶段, 故它拥有更多的能力, 能轻松获取突变前快照, 突变后的DOM节点等. 甚至通过调用api发起新的一轮fiber树构造, 进而改变更多的状态, 引发更多的副作用。 我要说话

使用副作用 hook useEffect(function(){}, [])时,其中的函数是异步执行的, 因为它经过了调度中心。 我要说话

2.5 hook

hook 是一串挂载在 fiber 上的链表 我要说话

export type Hook = {|
memoizedState: any, // 当前状态
baseState: any, // 基状态
baseQueue: Update<any, any> | null, // 基队列
queue: UpdateQueue<any, any> | null, // 更新队列
next: Hook | null, // next指针
|};

通过调用 mountWorkInProgressHook 函数,可以创建一个 Hook 对象,将其挂载到 fiber.memoizedState 链表结构上,并返回。 我要说话

并且在多次渲染之间,基于双缓冲技术对 hook 链表进行克隆,但是 hook 对象内部的状态和队列仍然共享,以保持状态不丢失。 我要说话

image.png

我要说话

状态 hook

使用状态类 hook,如 useState 或者 useReducer 时,实际上就是调用 mountWorkInProgressHook 创建挂载 hook 到 fiber 上,并返回其内部状态和更新函数。 我要说话

创建 hook: 我要说话

function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
// 1. 创建hook
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
initialState = initialState();
}
// 2. 初始化hook的属性
// 2.1 设置 hook.memoizedState/hook.baseState
// 2.2 设置 hook.queue
hook.memoizedState = hook.baseState = initialState;
const queue = (hook.queue = {
pending: null,
dispatch: null,
// queue.lastRenderedReducer是内置函数
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
});
// 2.3 设置 hook.dispatch
const dispatch: Dispatch<BasicStateAction<S>> = (queue.dispatch =
(dispatchAction.bind(null, currentlyRenderingFiber, queue): any));

// 3. 返回[当前状态, dispatch函数]
return [hook.memoizedState, dispatch];
}
image.png

我要说话

更新状态时,执行的操作是 我要说话

  • 创建update对象
  • 将update对象添加到hook.queue.pending环形链表 → 调度更新
  • 调用scheduleUpdateOnFiber, 进入reconciler 运作流程中的输入阶段
image.png

我要说话

最终更新结果 我要说话

image.png

我要说话

副作用 hook

创建副作用hook: 我要说话

和状态 hook 类似,创建 effect hook 的步骤是下面几步 我要说话

  • 创建hook
  • 设置workInProgress的副作用标记: flags |= fiberFlags
  • 创建effect(在pushEffect中), 挂载到hook.memoizedState上, 即 hook.memoizedState = effect
    创建完毕后,fiber、hook、effect 三者的引用关系如下:
image.png

我要说话

处理副作用 hook 回调: 我要说话

在commitRootImpl函数中,处理不同 flag 的副作用 hook 回调 我要说话

  • commitBeforeMutationEffects:dom 变更之前,处理副作用队列中带有 Passive 标记的 effect
  • commitMutationEffects:dom 变更, 界面得到更新
  • commitLayoutEffects:dom 变更后,处理副作用队列中带有 Layout 标记的 effect

本文链接:https://www.zoucz.com/blog/2024/01/14/4ce8eac0-b2bd-11ee-95cb-3556e1632a5e/我要说话

☞ 参与评论我要说话


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK