React | 青训营

158 阅读37分钟

React 官方文档学习

描述 UI

  1. 更新数组或对象时,应该先拷贝一份,进行修改,然后再进行更新

  2. <></> 与

后者可以绑定 key

  1. Memo 允许你的组件再 props 没有改变的情况下跳过渲染
const MemoizedComponent = meomo(SomeComponent, arePropsEqual?)
  1. JSX 中单标签也应该闭合 <img ``/``>

  2. 使用扩展运算符将属性展开到一个组件上

const props = {
    className: "app",
    id: "only",
    key: 233
}

(
    <MyApp {...props}>
)
  1. JSX 中的注释需要用 {}/**/ 包裹起来
const element = (
    <div>
        {/* 这是一行注释 */}
        <span>React</span>
    </div>
);

添加交互

响应事件

  • 浏览器自带 html 标签的事件名是预设的(比如 onClick),但是自定义组件的名字可以是自定义的(比如 onSmash,通常以 on 开头)

React 中所有事件都会传播,除了 onScroll

  • e 会作为事件对象的唯一参数

  • e.stopPropagation() 阻止事件冒泡

  • e.preventDefault() 阻止事件的浏览器默认行为

  • 触发事件捕获 在一个事件名后加 Capture, 比如 onClickCapture, 则可以在事件执行/冒泡之前触发这个捕获事件

  • 另一种实现事件捕获触发逻辑的方式是, 在传给子组件的事件对象中(传入 props 事件之前)添加需要的逻辑

state

  1. useState
import { useState } from 'react'

// ...
    // index 是一个 state 变量, setIndex 是对应的 setter 函数
    const [index, setIndex] = useState(initData);
    
    
    // 可以正常使用(只读) index
    {index}
    
    // 更改值, 并重新渲染组件
    setIndex(index + 1);
    
// ...

何时用 state ?

  1. 这个变量是否是通过Props从父组件中获取?如果是,那么它不是一个状态。

  2. 这个变量是否在组件的整个生命周期中都保持不变?如果是,那么它不是一个状态。

  3. 这个变量是否可以通过其他状态(State)或者属性(Props)计算得到?如果是,那么它不是一个状态。

  4. 这个变量是否在组件的render方法中使用?如果不是,那么它不是一个状态。这种情况下,这个变量更适合定义为组件的一个普通属性,例如组件中用到的定时器,就应该直接定义为this.timer,而不是this.state.timer。

在 React 中, use 开头的函数都被称为 Hook

Hook 是特殊的函数, 只在 React 渲染时有效。

Hook 只能在组件或自定义 Hook 的最顶层使用, 你不能在条件语句、循环语句或其他嵌套函数中调用 Hook。

如果你遵循这个原则,那么 Hooks 最终将始终以相同的顺序被调用。

React 内部为每一个组件保存了一个数组,其中的元素都是 state 对,维护当前 state 对的索引值,每次调用 useState 时,React 都会为你提供一个 state 对并增加索引值。

渲染和提交

在一个 React 应用中一次屏幕更新都会发生以下三个步骤:

  1. 触发

  2. 渲染

  3. 提交

两种原因导致组件的渲染:

  1. 组件的 初次渲染

  2. 组件(或其祖先之一)的 状态发生了改变

当 React 重新渲染一个组件时:

  1. React 会再次调用你的函数

  2. 你的函数会返回新的 JSX 快照

  3. React 会更新界面来匹配你返回的快照

React 会等到事件处理函数中的 所有 代码都运行完毕再处理你的 state 更新(批处理)

在下次渲染加入队列之前对 state 的值进行多次操作

传入 更新函数

// 根据队列中的前一个 state 计算下一个 state 的函数
setNumber(n => n + 1);
setNumber(n => n + 1);

// 而不是
setNumber(number + 1);
setNumber(number + 1);  // 如果 number 是 0,则最终设置的都是 0 + 1 = 1
  1. React 会将此函数加入队列,以便在事件处理函数中的所有其他代码运行后进行处理

  2. 在下一次渲染期间,React 会遍历队列并给你更新之后的最终 state

更新函数必须:

  • 纯函数

  • 返回 结果(不要尝试在内部设置 state 或者执行其他副作用)

变更 state 中对象的属性

  • 局部的 mutation(突变) 是可以接受的,因为刚刚创建的对象没有任何代码引用它,改变它并不会意外地影响到依赖它的东西。

    • const newShape = shape;
      newShape.color = newValue;
      setShape(newShape);
      
  • 用扩展运算符展开旧值,然后将新的修改覆盖上去,最后再触发 set 函数。

    • setShape({
          ...shape,
          color: newValue
      })
      
  • 通过 Immer 简便地对 state 中一个对象的某些属性进行修改。

// 需要提前安装 Immer 库

import { useImmer } from 'use-immer'

// ...
    const [shape, updateShape] = useImmer({/* ... */})
    setShape(draft => {
        draft.color = newValue;
    });
// ...

变更 state 中的数组

扩展运算符,然后新加元素即可

用 filter 过滤出去

  • 某些或全部元素:用 map 进行 判断元素并修改

  • 替换某个元素:同样用 map,但是可以传入第二个参数 idx 来判断

  1. 插入

可以用 slice 进行切片,然后在两个切片之间(用扩展运算符展开)加入

更简单的方法是拷贝,然后修改副本,再进行整体变更。

但是注意,一般实现的拷贝是浅拷贝,如果直接更改元素内部里面的属性/元素,仍然会作用到原数据上。

例如 nextList[0].seen = false, 实际上 nextList[0]list[0] 指向同一个对象。

因此,你可能需要多次拷贝(配合 map 进行筛选)。

// ...
    setMyList(myList.map(artwork => {
        if (artwork.id === artworkId) {
            return {...artwork, seen: nextSeen};
        } else {
            return artwork; // 没有变更
        }
    }))
// ...

用 Immer 简便编码

// ...
    updateMylist(draft => {
        const artwork = draft.find(a => a.id === id);
        artwork.seen = nextSeen;
    })
// ...

状态管理

用 State 响应输入

如果一个组件有多个视图状态,你可以很方便地将它们展示再一个页面中。

利用 map 方法进行列表渲染即可。

类似这样的页面通常被称作"living styleguide" 或 "storybook"。

开发组件的流程:

  1. 写出你的组件中所有的视图状态

  2. 确定是什么出发了这些 state 的改变

  3. 通过 useState 模块化内存中的 state

  4. 删除任何不必要的 state 变量

  5. 连接事件处理函数去设置 state

选择 State 结构

构建 state 的原则

  1. 合并关联

  2. 避免相互矛盾

  3. 避免冗余(如果你能在渲染期间从组件的 props 或其现有的 state 变量中计算出一些信息,则不应该将这些信息放入组件的 state 中)

  4. 避免重复(同一个数据在多个 state 变量或多个嵌套对象中重复)

  5. 避免深度嵌套的 state

不要在 state 中镜像 props(不要用 props 来初始化)

function Message({ messageColor }) {
    const [color, setColor] = useState(messageColor);
    // 如果父组件稍后传递不同的 messageColor,那么 color 的 state 将不会更新!
    // state 仅在第一次渲染期间初始化
}

只有当你想要忽略特定 props 属性的所有更新时,将 props 镜像到 state 才有意义。

按照管理, props 的名称以 initialdefault 开头,以阐明该 props 的新值将被忽略。

function Message({ initialColor }) {
    const [color, setColor] = useState(initialColor);

在组件间共享状态

状态提升:将子组件的 state 放到它们的公共父级,再由父级通过 props 将 state 传递给两个组件。

可信单一数据源:对于每个独特的状态,都应该存在且只存在于一个指定的组件中作为 state。

对 state 进行保留和重置

只要一个组件还被渲染在 UI 树 (而不是 JSX)的相同位置,React 就会保留它的 state。

如果它被移除,或者一个不同的组件被渲染在该位置,那么 React 就会丢掉它(包括其子节点)的 state。

相同位置的同个组件,将会保留 state

<div>
    { isPlayerA ? (
        <Counter person="Taylor" />
    ) : (
        <Counter person="Sarah" />
    )}
</div>

如果想要在相同位置重置 state:

  1. 将组件渲染在不同的位置 (只适用于项目数量较少时)
<div>
    {isPlayerA && <Counter person="Taylor" />}
    {!isPlayerA && <Counter person="Sarah" />}
</div>
  1. 使用 key 来重置 state
<div>
    { isPlayerA ? (
        <Counter key="Taylor" person="Taylor" />
    ) : (
        <Counter key="Sarah" person="Sarah" />
    )}
</div>

迁移状态逻辑至 Reducer 中

对于拥有许多状态更新逻辑的组件来说,过于分散的事件处理程序可能会令人不知所措。

对于这种情况,你可以将组件的所有状态更新逻辑整合到一个外部函数中,这个函数叫做 reducer。

例如,对于增删改三个操作,可以统一到一个 reducer 中:

通过 dispatch 一个 action 对象来表明操作的类型和参数,然后在 reducer 中统一处理,返回下一个状态。

import { useReducer } from 'react';

export default function TaskApp() {
    // const [tasks, setTasks] = useState(initialTasks);
    const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
    // ...
    
    function handleAddTask(text) {
        dispatch({  // action 对象
            type: 'added',
            id: nextId++,
            text: text
        })
    }
}

function tasksReducer(tasks, action) {
    switch (action.type) { // switch 是习惯用法
        case 'added': {
            return [
                ...tasks,
                {
                    id: action.id,
                    text: action.text,
                    done: false
                }
            ]
        }
        case 'changed': {
        // ...
    }
}

reducer 的原则:

  1. reducers 必须是纯函数
  • reducers 是在渲染时运行,actions 会排队知道下一次渲染

  • reducers 不应该包含异步请求、定时器或者任何副作用(对组件外部有影响的操作)

  1. 每个 action 都描述了一个单一的用户交互,即使它会引发数据的多个变化

可以使用 Immer 来简化 reducers

function tasksReducer(draft, action) {
    switch (action.type) {
        case 'added': {
            draft.push({
                id: action.id,
                text: action.text,
                done: false
            });
            break;
        }
        case 'changed': {
        // ...
    }
    
}

Immer 为你提供了一种特殊的 draft 对象,你可以通过它安全地修改 state。

使用 Context 深层传递参数

Context 允许父组件向其下层无论多深的任何组件提供信息,而无需通过 props 显式传递。

  1. 创建 context
// in LevelContext.js
import { createContext } from 'react';
export const LevelContext = createContext(1); // 初始值
  1. 在需要使用 context 的组件中引入 context
// in Heading.js
import { useContext } from 'react';
import { LevelContext } from './LevelContext.js';
export default function Heading({ children }) {
    const level = useContext(LevelContext);
    // ...
}
  1. 提供 context
// in Section.js
import { LevelContext } from './LevelContext.js';

export default function Section({ level, children }) {
    return (
        <section>
            <LevelContext.Provider value={level}>  
                <!-- 将 props 接收到的 value 通过 context 传递给后续的子组件(如果子组件有引用的话) --> 
                { children }
            </LevelContext.Provider>
        </section>
    );
}

你也可以让每层的组件都对上层的信息进行一定的加工

// in Section.js
import { useContext } from 'react';
import { LevelContext } from './LevelContext.js';

export default function Section({ children }){ // 不需要再从 props 获取数据
    // 从这里获取 level,如果本组件是根组件,则会获取到 context 中设置的默认值
    const level = useContext(LevelContext); 
    return (
        <section>
            <!-- 
                子组件中每次引用context,则会获取到上一层的值再加1
                无论你嵌套了多少层,祖先链中每有一个父辈组件有引用,则本组件使用的 contextValue 会对应加1.
            -->
            <LevelContext.Provider value={level + 1}>  
                {children}
            </LevelContext.Provider>
        </section>
    );
}

在使用 context 前应该考虑:

  1. 如果能用 props 则用 props,因为它让组件的数据流更加清晰。

  2. 如果传递路上的中间组件并不需要使用到 context,则应该抽象组件,并将 JSX 作为 children 传递。

结合 Reducer 和 Context

不需要再通过 props 将事件处理函数逐层传递

  1. 创建 context
// in TasksContext.js
import { createContext } from 'react';
export const TasksContext = createContext(null);
export const TasksDispatchContext = createContext(null);
  1. 将 state 和 dispatch 放入 context(在需要使用的根组件)
// in TaskApp.js
import { useReducer } from 'react';
import { TasksContext, TasksDispatchContext } from './TasksContext.js';
export default function TaskApp() {
    const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
    // ...
    return (
        // 通过 Provider 将 value 传入
        <TasksContext.Provider value={tasks}>
            <TasksDispatchContext.Provider value={dispatch}>
                ...
            </TasksDispatchContext.Provider>
        </TasksContext.Provider>
    );
}


function tasksReducer(tasks, action) {
    switch (action.type) {
        case 'added': {
            return [
                ...tasks,
                {
                    id: action.id,
                    text: action.text,
                    done: false
                }
            ];
        }
        // ...
    }
}
  1. 在组件树任何需要使用的地方使用 context
// 引入...
export default function TaskList() {
    const tasks = useContext(TasksContext);
    const dispatch = useContext(TasksDispatchContext);
    // ...
}

还可以将相关逻辑迁移到一个文件当中(reducer 和 context 集合到一个组件中)

// in TasksContext.js
export function TasksProvider({ children }) {
    const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
    return (
        <TasksContext.Provider value={tasks}>
            <TasksDispatchContext.Provider value={dispatch}>
                {children}
            </TasksDispatchContext.Provider>
        </TasksContext.Provider>
    );
} 

function tasksReducer(tasks, action) {
    // ...
}

这样,在组件树的根节点使用会更加方便。

// in TaskApp.js
export default function TaskApp() {
    return (
        <TasksProvider>
            ...
        </TasksProvider>
    );
}

还导出使用 context 的函数(自定义 Hook):

// in TasksContext.js
export function useTasks() {
    return useContext(TasksContext);
}
export function useTaskDispatch() {
    return useContext(TasksDispatchContext);
}

// 使用更加方便
// in other.js
const tasks = useTasks();
const dispatch = useTasksDispatch();

应急方案

控制和同步 React 之外的系统(比如浏览器 API、连接并监听远程服务器的消息)

应该避免更改 React 管理的 DOM 节点

使用 ref 引用值

希望组件记录某些信息,但是不想让这些信息 触发新的渲染,使用 ref

但是如果可以,不要经常使用它

import { useRef } from 'react';

const ref = useRef(0);
// useRef 返回的对象
{
    current: 0
}
// 可以直接修改,而不会触发重新渲染
ref.current += 1;

应用场景:

  • 存储 timeoutID
  • 存储和操作 DOM 元素
  • 存储不需要被用来计算 JSX 的其他对象

注意:不要在渲染过程中读取或写入 ref.current

唯一的例外是初始化,因为它只在第一次渲染期间设置一次 ref:

if (!ref.current) ref.current = new Thing();

出乎意料的挑战: 4. 读取最新的 state

设置一个 state(用于再次渲染到文本框),一个 ref(用于让提示框展示最新的文本),两者都是保存最新的文本! 这样点击发送时,提示框上显示的就是实时的最新文本,而不是点击按钮时的 state。(因为提示框弹出有延迟,如果在这段延迟之间修改,则提示框显示的是旧的文本,不符合题目要求。)

import { useState, useRef } from 'react';
export default function Chat() {
    const [text, setText] = useState('');
    const textRef = useRef(text);
    
    function handleChange(e) {
        setText(e.target.value);
        textRef.current = e.target.value; // 同时更新两个值
    }
    
    function handleSend() {
        setTimeout(() => {
            // alert(`正在发送${text}`);
            alert(`正在发送:${textRef.current}`);   
        }, 3000);
    }
} 

使用 ref 操作 DOM

如果给一个 DOM 节点赋值 ref 属性,则会在对应的 ref 获取到该节点。

<div ref={myRef}/>

const myRef = useRef(null); // 自动获取到上面的节点(前提是名字相同,这里都是 `myRef`)

这样就可以使用浏览器 API 操作 DOM 元素了。

文档中举例:

  1. 输入框的编程聚焦(focus()

  2. 轮播到某一节点(scrollIntoView()

useRef(不是元素的 ref 属性) 不可以在循环语句、条件语句或 map() 函数中调用。

当你不确定自己要使用多少个 ref 来指定 DOM 时,解决方法:

  1. ref 引用父元素,然后用 querySelectorAll() 来寻找它的子节点。(脆弱,不建议
  2. 将函数传递给 ref 属性(ref 回调)
const itemsRef = useRef(null); // 一个Map,用来存 `节点id: 节点`

// 定义获取 Map 的方法
function getMap() {
    if (!itemsRef.current) {
        // 首次运行时初始化 Map
        itemsRef.current
    }
    return itemsRef.current; 
}

// 给元素的 ref 属性传入一个函数,用于更新 Map
(<ul>
    { someList.map(item => (
     <li
         key={item.id}
         ref={ (node) => {   // 这里的 node 就是本节点
             const map = getMap();
             if (node) {
                 map.set(item.id, node);
             } else {
                 map.delete(item.id);
             }
         }}
     >      
     </li>
    ))}
</ul>)

React 不允许组件访问其他组件的 DOM 节点,甚至自己的子组件也不行。这是出于安全考虑。

想要暴露 DOM 节点的组件应该使用 forwardRef 声明:

// 传入 forwardRef 的应该是一个函数(组件)
const MyInput = forwardRef((props, ref) => {
    return <input {...props} ref={ref} />;
});

// 此后 JSX 中可以指定 ref 来获取这个组件
(
    <MyInput ref={inputRef} />
)

const inputRef = useRef(null); // <myInput />

在设计系统中,将低级组件(如按钮、输入框等)的 ref 转发到它们的 DOM 节点是一种常见模式。另一方面,像表单、列表或页面段落这样的高级组件通常不会暴露它们的 DOM 节点,以避免对 DOM 结构的意外依赖。

使用 useImperativeHandle 只暴露部分 API

import {
    forwardRef,
    useRef,
    useImperativeHandle
} from 'react';

const MyInput = forwardRef((props, ref) => {
    const realInput = useRef(null); // 在组件内部获取到的完整 ref
    useImperativeHandle(ref, () => {
        // 只暴露部分 API
        myFocus() {
            realInputRef.current.focus();
        },
    }));
    return <input {...props} ref={realInputRef} />
});

// 其他组件引用处
export default function Form() {
    const inputRef = useRef(null);  // 获取到的是只有一部分 API 暴露的 ref
    function handleClick() {
        inputRef.current.myFocus(); // 调用暴露的 API
    }
    
    return (
        <>
            <MyInput ref={inputRef} />
            <button onClick={handleClick}>聚焦输入框</button>
        </>
    );
}

React 中,每次更新都分为两个阶段:

  • 渲染:React 调用你的组件来确定屏幕上应该显示什么

  • 提交:React 把变更应用于 DOM

同样地,不应该在渲染期间访问保存 DOM 节点的 refs

  • 在第一次渲染期间,DOM 节点尚未创建,因此 ref.current 将为 null;

  • 在渲染更新的过程中,DOM 节点还没有更新。

一般从事件处理器访问 refs,如果你想使用 ref 执行某些操作,但没有特定的事件课可以执行此操作,则你可以使用 effect

使用 Effect 同步

不要随意在组件中使用 Effect

根据 React state 控制非 React 组件、设置服务器连接或在组件出现在屏幕上时发送分析日志。

Effects 会在渲染后运行一些代码,以便可以将组件与 React 之外的某些系统同步。

React 组件中的两种逻辑类型:

  • 渲染逻辑代码

    • 位于组件的顶层
    • 接收 props 和 state,并对它们进行转换,最终返回预期的 JSX
    • 渲染的代码必须是纯粹的
  • 事件处理程序

    • 嵌套在组件内部的函数
    • 可能引起 "副作用"

Effect 允许你指定由渲染本身,而不是特定事件引起的副作用。

  1. 声明 Effect

useEffect 会把逻辑放到每次屏幕更新渲染之后执行。

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? '暂停' : '播放'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

流程:点击按钮 - 改变 isPlaying 这个 state - 触发重新渲染 - 触发 useEffect 中的逻辑

陷阱:死循环

const [count, setCount] = useState(0);
useEffect(() => {
    setCount(count + 1);
}); // 每次更改 state 都会触发渲染,每次渲染后会触发 useEffect 中的逻辑,然后又更改了 state...
  1. 指定 Effect 依赖

有时并不需要每次渲染都执行 Effect,可以给 Effect 传入第二个参数(依赖数组)

当依赖数组中的所有值都与上一次渲染期间完全相同时,将跳过本次的 Effect。

React 会验证是否将每个响应式值都指定为了依赖项。

function VideoPlayer({ src, isPlaying }) {
    const ref = useRef(null);
    useEffect(() => {
        if (isPlaying) {
            ref.current.play();
        } else {
            ref.current.pause();
        }
    }, [isPlaying])
}

如果你打算传入依赖数组,则你必须保证 Effect 中可能改变的依赖都加进了这个依赖数组。

依赖数组中可以省略 ref(实际上是 ref.current):

这是因为 ref 具有稳定的标识:React 保证每轮渲染中调用 useRef 所产生的引用对象时,获取到的对象总是相同的。即获取到的对象引用永远不会改变,所以它不会导致重新运行 Effect。

(state 返回的 set 函数也有稳定的标识)

如果在忽略某个依赖项时 linter 不报错,那么这么做就是安全的。

但是,仅在 linter 可以“看到”对象稳定时,忽略稳定依赖项的规则才会起作用。例如,如果 ref 是从父组件传递的,则必须在依赖项数组中指定它。这样做是合适的,因为无法确定父组件是否始终是传递相同的 ref,或者可能是有条件地传递几个 ref 之一。因此,你的 Effect 将取决于传递的是哪个 ref。

如果你不想进行重新同步,可以将变量移动到:

  • 组件的外部
  • 或者 Effect 内部

这样子他们都不是响应式的,也就不会因为响应式数据没有加入到依赖数组而报错。

  1. 按需添加 清理函数(cleanup)

如果你传入了一个空的依赖数组,则表明你只希望在”挂在“时运行 Effect(首次出现在屏幕上这一阶段)。

但是,有时候切换导航到另一个页面,再重新切换回来时,会再触发一次挂载。

然而,上一次的 Effect 的服务(例如建立服务器连接)并没有被停止,长此以往,就会形成很多“堆积”。

为了帮助你快速发现这种错误,在开发环境,React 会在初始挂载组件之后,立即再挂载一次。

因此,在 Effect 中返回一个 清理函数(cleanup) 是非常重要的。

每次重新执行 Effect 之前 / 组件被卸载时,React 都会调用清理函数;

useEffect(() => {
    const connection = createConnection();
    connection.connect();
    
    // 返回一个清理函数
    return () => {
        connection.disconnect();
    }
}, []);

控制非 React 组件

比如控制一个地图组件的 zoom level 与 React 中的 zoomLevel state 变量保持同步。

useEffect(() => {
    const map = mapRef.current;
    map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

某些 API 不允许连续调用两次,则应该实现对应的清理函数,保证在检查(二次渲染)时不出错。

订阅事件

应该在清理函数中退订

useEffect(() => {
    function handleScroll(e) {
        console.log(window.scrollX, window.scrollY);
    }
    window.addEventListener('scroll', handleScroll); // 订阅事件
    return () => window.removeEventListener('scroll', handleScroll); // 退订事件
}, []);

触发动画

应该在清理函数将中重置动画

useEffect(() => {
    const node = ref.current;
    node.style.opacity = 1; // 触发动画
    return () => {
        node.style.opacity = 0; // 重置为初始值
    };
}, [])

在开发环境中,透明度由 1 变为 0,再变为 1。这与在生产环境中,直接将其设置为 1 具有相同的感知效果,如果你使用支持过渡的第三方动画库,你的清理函数应将时间轴重置为其初始状态。

获取数据

如果 Effect 将会获取数据,清理函数应该:

  • 中止该数据获取操作
  • 或者 忽略其结果(比如无法撤销已经发生的网络请求)
useEffect(() => {
    let ignore = false;
    async function startFetching() {
        const json = await fetchTodos(userId);
        if (!ignore) {
            setTodos(json);
        }
    }
    startFetching();
    return () => {
        ignore = true;
    }
}, [userId]);

初始化应用

有些逻辑只需要在程序启动时运行一次,比如:

  • 验证登陆状态
  • 加载本地程序数据

可以把这部分逻辑放在组件之外。

有的操作不应该由渲染触发

比如 购买,应该将逻辑移动到购买按钮的事件处理程序中。

每一轮渲染都有自己的 Effect

他们之中的数据是相互隔离的,类似于 闭包。

在开发模式下,React 会在每次卸载组件后重新挂载组件。

每当你在开发环境中保存更新代码文件时,React 也会重新挂载 Effect。

出乎意料的挑战: 4. 修复在 Effect 中获取数据的问题

该题的正确解法是在 Effect 中设置一个是否忽略数据的flag ignore(初始值为 false),然后在清理函数中将其设置为 true,这样,在重新渲染而触发 Effect 时,会丢弃掉旧的数据。

那么为什么不能用 ref 呢?

因为如果用了 ref,这个 ignore 就不再是在各个 Effect 中相互独立了。也就是说,新一轮的 Effect 会将 ref 改变为 true,这样在 后续到来的 旧数据的回调中 会判断为不丢弃数据,导致旧数据覆盖了新数据。

普通变量,state, ref 总结

  • 普通变量

    • 每次渲染组件都会重新声明、赋值
    • 如果是在 Effect 中使用,则会被当作闭包变量,而在每一次的 Effect 中独立
  • State

    • 仅初始化一次,需要通过 set 来修改
    • 每次修改都会触发新一轮的渲染
    • 有快照的性质,每次读取都会是当前渲染状态的值,而不一定是最新的值
  • Ref

    • 仅初始化一次,直接通过修改 current 字段来修改值

    • 每次修改不会触发新一轮的渲染

    • 不应该在渲染时被读取,在其余时候读取一定会访问到最新值

可能不需要 Effect

如果没有涉及到外部系统同步,就不应该使用 Effect。移除不必要的 Effect 可以让代码更容易理解,运行得更快,并且更少出错。

  1. 根据 props 或 state 来更新 state
function Form() {
    const [firstName, setFirstName] = useState('Taylor');
    const [lastName, setLastName] = useState('Swift');
    
    // fullName 不应该用 Effect 来在每次渲染重新计算
    // 直接字符串相加即可,因为 fullName 依赖了某些 state,
    // 当依赖的 state 改变后,在渲染时就会自动计算并更新 UI
    const fullName = firstName + ' ' + lastName;  
}
  1. 避免缓存昂贵的计算
function TodoList({ todos, filter }) {
    const [newTodo, setNewTodo] = useState('');
    // 直接通过函数来获取数据,不需要放在 Effect 中获取
    const visibleTodos = getFilteredTodos(todos, filter);
}

如果 getFilteredTodos() 耗时过大(比如数据量很大),可以使用 useMemo 来缓存数据,减少不必要的计算时间。

function TodoList({ todos, filter }) {
    const [newTodo, setNewTodo] = useState('');
    
    const visibleTodos = useMemo(() => {
        return getFilteredTodos(todos, filter);
    }, [todos, filter]); // 除非 todos 或 filter 发生变化,否则不重新执行 
}

由于传入 useMemo 的函数会在渲染期间执行,所以仅使用于 纯函数 的场景。

  1. 当 props 变化时重置所有 state

不应该设置一个 Effect 来重置一些与 state 相关的数据,因为:

  • 低效:这些数据一开始会以旧状态呈现,然后再通过 Effect 改变
  • 复杂:需要在嵌套的 UI 中都写这样的逻辑

正确的做法是将这些与 state 相关的数据单独拆分出一个组件,然后父组件传入的 key(也就是相关的 state)改变时,就会自动重新渲染新的数据(因为 React 将不同的 key 视为不同的组件,因此会重新渲染)。

export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId}
      key={userId}
    />
  );
}

function Profile({ userId }) {
  // ✅ 当 key 变化时,该组件内的 comment 或其他 state 会自动被重置
  const [comment, setComment] = useState('');
  // ...
}
  1. 当 props 变化时调整部分 state

同样不应该在 Effect 中改变 state / 部分数据。

比 Effect 好一些的做法:可以存储历史数据作为 state,然后和最新的 state 比对,如果不同,则触发渲染。

function List({ items }) {
    const [isReverse, setIsReverse] = useState(false);
    const [selection, setSelection] = useState(null);
    
    // 在渲染期间调整 state (比使用 Effect 好一些)
    const [prevItems, setPrevItems] = useState(items);
    if (items !== prevItems) {
        setPrevItems(items);
        setSelection(null); 
    }
    
    // 执行完毕后(一直到 return 语句之前),将立即重新渲染
}

为了避免非常缓慢的级联重试,React 只允许在渲染期间更新 同一 组件的状态。

如果你在渲染期间更新另一个组件的状态,你会看到一条报错信息。

更好的做法:

  • 总是检查是否可以通过添加 key 来重置所有的 state、

  • 在渲染期间计算所需的内容

    • function List({ items }) {
          const [isReverse, setIsReverse] = useState(false);
          const [selectedId, setSelectedId] = useState(null);
          // 在渲染期间就计算所需的内容
          const selection = items.find(item => item.id === selectedId) ?? null;
      }
      
  1. 在事件处理函数中共享逻辑

分清某段逻辑的触发时机:

  • 页面显示触发?(用 Effect)

  • 用户交互触发?(用 事件处理程序)

  1. 发送 POST 请求

同上,应该分清触发时机。

正确的:每次刷新页面获取信息

// ✅ 每次刷新页面时获取信息 √
useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
}, []);

错误的:在 Effect 判断状态并触发事件逻辑

// 事件逻辑不应该写在 Effect 中 X
const [jsonToSubmit, setJsonToSubmit] = useState(null);
function handleSubmit(e) {
    e.preventDefault();
    setJsonToSubmit(/*...*/);
}
useEffect(() => {
    if (jsonToSubmit !== null) {
        post('/api/register', jsonToSubmit);
    }
}, [jsonToSubmit]);

正确的:直接在事件处理程序中触发

// ✅ 应该直接由事件处理程序来触发 √
function handleSubmit(e) {
    e.preventDefault();
    post('/api/register', jsonToSubmit);
}
  1. 避免链式计算

链式计算:链接多个 Effect,只是为了基于某些 state 来调整其他的 state。

尽可能地在渲染期间进行计算,以及在事件处理函数中调整 state。

function Game() {
    const [card, setCard] = useState(null);
    const [goldCardCount, setGoldCardCount] = useState(0);
    const [round, setRound] = useState(1);

    // ✅ 尽可能在渲染期间进行计算
    const isGameOver = round > 5;
    
    function handlePlaceCard(nextCard) {
        if (isGameOver) {
            throw Error('游戏已经结束!');
        }
        
        // ✅ 在事件处理函数中计算剩下的所有 state
        setCard(nextCard);
        if (nextCard.gold) {
            if (goldCardCount <= 3) {
                setGoldCardCount(goldCardCount + 1);
            } else {
                setGoldCardCount(0);
                setRound(round + 1);
                if (round === 5) {
                    alert('游戏结束!');
                }
            } 
        }
    }
    // ...
}
  1. 初始化应用

添加一个顶层变量来记录一段逻辑是否已经执行过。

let didInit = false;

function App() {
    useEffect(() => {
        if (!didInit) {
            didInit = true;
            // ...Some Init
        }
    }, []);
    // ...
}

顶层代码会在组件被导入时执行一次,即使它最终并没有被渲染。

  1. 通知父组件有关 state 变化的信息

不应该用 effect,应该:

  1. 事件处理程序触发(同步更新父子组件的 state,这样只有一次渲染)。

  2. 或者 状态提升 至父组件

  3. 将数据传递给父组件

不要在 Effect 中进行,因为这样数据流很难追踪

既然父子组件都需要相同的数据,那么可以让父组件获取那些数据,并将其 向下传递 给子组件。

  1. 订阅外部 store

使用 useSyncExternalStore() Hook

function subscribe(callbac) {
    // ...
}

// 自定义 Hook
function useOnlineStatus() {
    // 用内置的 Hook 订阅外部 store
    return useSyncExternalStore(
        subscribe, // 只要传递的是同一个函数, React 不会重新订阅
        () => navigator.onLine, // 如何在客户端获取值
        () => true  // 如何在服务端获取值
    );
}
  1. 处理 竞态条件

添加一个 清理函数 来忽略较早的返回结果

响应式 Effect 的生命周期

React 组件的生命周期:

  • 挂载:组件被添加到屏幕上

  • 更新:组件接收到新的 props 或 state,通常是对交互的响应

  • 卸载:组件从屏幕上被移除

Effect 的生命周期和上述并不完全相同

  • 开始同步:Effect 的主体部分的逻辑

  • 停止同步:Effect 返回的清理函数

使用自定义 Hook 复用逻辑

  • 自定义 Hook 一般以 use 开头

  • 如果自定义 Hook 没有调用其他 Hook(React 自带的),则不应该以 Hook 去编写它

  • 自定义 Hook 共享的是状态逻辑,而不是状态(各个逻辑之中的状态是相互独立的,只不过大多数情况下他们是相同的)

  • 每当组件重新渲染,自定义 Hook 中的代码就会重新运行,因此组件和自定义 Hook 都必须是 纯函数

什么时候使用自定义 Hook?

  • 把重复的逻辑提取出来

  • 让调用 Hook 处的输入输出更加简洁清晰(让组件专注于目标,而不是如何实现具体的 Effect)

React 基础与实践

React 哲学

React 是用 JavaScript 构建 快速响应 的 大型 Web 应用的首选方式之一。

等待资源加载时间 和 大部分情况下浏览器单线程执行 是影响 web 性能的两大主要原因。

等待资源加载

  • 一次请求太多资源
  • 网络请求慢
  • 资源加载失败

解决:

  • React.Lazy (资源懒加载)

  • React.Suspense (显示兜底组件,直到子组件完成加载)

  • ErrorBoundary (错误时渲染降级 UI,避免白屏)

浏览器 线程 执行

  • JS 执行
  • 浏览器计算样式布局
  • UI 绘制

解决:

  • 异步更新

  • 时间切片

  • React Fiber

事件处理

  • React 中的事件是合成事件,并不是原生 DOM 事件。

    • 如果想要调用原生 DOM 事件,应该使用 e.nativeEvent
  • React 事件中,必须显式地调用事件对象的 preventDefault 方法来阻止事件的默认行为

this 处理

使用箭头函数

  • this 在定义时就确定

  • 多次 render,会多次创建不同的处理函数

函数绑定

  • 在类的 constructor 中使用 bind 绑定 this 为实例

  • 多次 render,不会创建不同的处理函数,但是 bind 比较繁琐

调和过程

React diff 算法的两个假设:

  • 不同类型的元素会生成不同的子树

  • 开发者可以根据 key 和 props 来暗示某些子元素在不同的渲染场景下不会发生变化

更新流程

Scheduler(调度器)

  • 维护时间切片(类似 requestIdleCallback,在浏览器空闲时间执行操作)

  • 与浏览器任务调度

  • 优先级调度

Reconciler(协调器)

  • 将 JSX 转化为 Fiber(虚拟 DOM)

  • Fiber 树对比(双缓存)

  • 确定本次更新的 Fiber(不是每个小任务完成都更新)

Renderer(渲染器)

渲染器用于管理一棵 React 树,

使其根据底层平台进行不同的调用。

双缓存

内存中有两棵 Fiber 树,用于逐层比较(递归),同一层级的节点有三种操作:

  • 插入

  • 移动(根据 key)

  • 删除

React 基础

Class 组件

生命周期

结构:

  • 继承 + 构造函数

  • this

  • 生命周期

  • render 方法

定时器 Demo

class Demo1 extends React.Component {
    constructor(props) {
        super(props);
        this.state = { date: new Date() };
    }
    timerID = null;
    tick() {
        this.setState({
            date: new Date(),
        });
    }
    componentDidMount() { // 组件挂载完毕后
       this.timerID = setInterval(() => this.tick(), 1000); 
    }
    componentWillUnmount() { // 组件卸载之前
        clearInterval(this.timerID);
    }
    render() { // 作为组件,返回 JSX
        return (
            <div>
                <h1>Hello, world!</h1>
                <h2>Ti is {this.state.date.toLocaleTimeString()}.</h2>
            </div>
        );
    }
}

函数式组件

  • 没有生命周期
  • 借助 Hook
  • return JSX
function Demo1WithHook() {
    const [date, setDate] = useState<Date>(new Date());
    const timerID = useRef<any>(null);

    const tickk = () => setDate(new Date());
    
    useEffect(() => {
        timerID.current = setInterval(tick, 1000);
        return () => {
            clearInterval(timerID.current);
        }
    });
    
    return (
        <div>
            <h1>Hello, world!</h1>
            <h2>Ti is {this.state.date.toLocaleTimeString()}.</h2>
        </div>
    );
}

函数式 相较于 Class 的优点

  • 代码量骤减,组件干净清爽

  • 没有复杂的生命周期

  • 支持自定义 hook,逻辑复用方便

组件 和 Hook 的关系

  • 将 UI 拆成多个独立单元,这些单元组合可以构成多种视图展示(独立单元 <=> 组件 <=> 原子)

  • Hook 贴近组件内部运行的各种概念逻辑 (Hook <=> 电子)

    • Effect

    • State

    • Context

    • ...

React 常见 API

API作用简介
React.Component类组件的基类,用于 extends
React.PureComponent未实现 shouldComponentUpdate(),内置了 porps 和 state 浅层对比
React.memo高阶组件,仅比较 props 变更
React.createElement创建并返回 React 元素,不使用 JSX 场景
React.cloneElement克隆并返回新 React 元素,并且可以为新元素添加额外 props
React.Children.[Fn]map 遍历并返回;forEach 仅遍历;count 子组件数量;only 是否只有一个子节点
React.createRef创建一个 ref,并附加到具体元素上,class 组件中获取 DOM 结构常用
React.forwardRef转发 ref 或者 与 useImperativeHandler 联合使用暴露方法
React.lazy实现组件动态加载
React.Suspense组件加载过程优雅降级

React 实践

如何划分组件

常见的布局 Layout:

  • Navbar
  • Main Content
  • Footer
  • Floating Button

常见的布局 Page:

  • Banner
  • CardGroup
  • Slider Group
  • Help Docs
  • Footer Banner

常见的布局 Component:

  • BlockHeader 块头部

    • Tag
    • Title
  • BlockWrapper 块包裹

    • backkgroundType
    • animate?: boolean
    • theme?: "dark" | "light"
  • AnimationWrapper 块包裹甚至可以单独拆出一个动画包裹,用来专门实现动画

    • animationName?: string
    • animationDuration?: number
  • SlideButton

    • onClick(direction, index)
    • icon?: ReactNode
    • animate?: boolean
  • SizeText

    • 通过 className 控制
    • 封装成组件
    • 安装主题包

父子组件通信

当我们知道子组件的表现时,父组件可以直接传递 props 给子组件

当我们不知道子组件的变现时,可以定义一个公用组件,专门给子组件传递 props

传入公共 props

传入公共的 props 很繁琐

(
<div>
    <Input value="input" size={props.size} />
    <InputTag value={['input-tag']} size={props.size} />
    <InputNumber value={100} size={props.size} />
    <Button type="primary" size={props.size}>
        Primary
    </Button>
</div>
)

定义一个 Wrapper 来给 children 传入公共 props(这个组件没有UI,只是处理逻辑)

// SizeWrapper
(
<div>
    {React.Children.map(children, (child) => {
        if (child && child.props) {
            return React.cloneElement(child, commonProps); (/* 可以传入额外的 props */)
        }
        return child;
    })}
</div>
)

直接传给 wrapper,由其将公共 props 传入子组件

(
<SizeWrapper size={props.size}>
    <Input value="input" />
    <InputTag value={['input-tag']} />
    <InputNumber value={100} />
    <Button type="primary">Primary</Button>
</SizeWrapper>
)

调用子组件暴露的方法

传递信息:通过 callback

传递方法:父组件调用子组件的方法(外层传介质对象给内层,内层给介质附加方法,这样外层就能调用附加的方法了)

// 子组件
const FormComponent = (props, ref) => {
    const [formInstance] = Form.useForm();
    // 给 ref 添加 change, clear 方法
    useImperativeHandle(ref, () => ({
        change: (newValue) => formInstance.setFieldsValue(newValue),
        clear: () => formInstance.clearFields(['username', 'email']),
    }));
    
    return (
        <Form form={formInstance}>
            <FormItem label="Username" field="username">
                <Input placeholder="please enter your username..." />
            </FormItem>
            { /* ... */ }
        </Form>
    );
}
// 转发 ref
export const FormConponentWithRef = forwardRef(FormComponent);
// 父组件
const FormWrapper = () => {
    const formRef = useRef(null); // 获取表单的 ref,其中暴露了介质
    
    return (
        <div>
            <div>
                <b>Wrapper Oprations: </b>
                {/* 调用介质中的方法 */}
                <Button onClick={() => formRef.current?.change()}>
                    Change
                </Button>
                <Button onClick={() => formRef.current?.clear()}>
                    Clear
                </Button>
            </div>
            
            <FormComponentWithRef ref={formRef} />
        </div>
    );
}

组件间共享信息

context & reducer

详见上文:React 官方文档学习 - 状态管理 - 结合 Reducer 和 Context

React-redux

场景:navBar 处进行用户信息的操作,然后需要将信息共享

  1. 定义 Reducer
// store.js
export const userReducer = (state, action) => {
    switch (action.type) {
        case "SET":
            return action.user;
        case "UPDATE":
            return {...state, ...action.user };
        case "REMOVE":
            return undefined;
        default:
            return state;
    }
};

// 创建公共状态容器 Store
export default createStore(userReducer);
  1. 传递 Store
// index.jsx
import store from './store.js'
import { Provider } from 'react-redux';
export default () => (
    // 将 Store 透传下去
    <Provider store={store}>
        <Navbar />
        <UserInfo />
    </Provider>
);
  1. 组件使用
// Navbar.jsx
function Navbar(props) {
    const { login, user, logout} = props;
    return ...
}
export default connect(
    // mapStateToProps:将 store 中的 state 注入到 props.user 中
    (state: IUserInfo) => ({ user: state }),
    (dispatch) => ({
        // mapDispatchToProps:操作 store 的方法注入到 props 中
        login: (user: IUserInfo) => dispatch({type: "SET", user}),
        logout: () => dispatch({ type: "DELETE" }),
    })
)(Navbar);

组件性能优化

useCallback & useMemo

见下文 Hook 的使用

组件挂载位置

createPortal (react-dom)

将真实 DOM 的渲染位置改变

冒泡

Portal 内部触发的事件会一直冒泡到 React 树的祖先

逻辑复用

手动实现一个 useRequest( 轮询 请求)

function BasicRun() {
    const [interval, setInterval] = useState(1000);
    const requestFn = () => 
        mocRequest({
            info: '这里是轮询请求',
        });
    const { run, loading, ...extra } = useRequest(requestFn, { interval });
    
    return (
        <Space size={30} direction="vertical">
            <Button loading={loading} onClick={run} type="primary">
                轮询请求
            </Button>
            {JSON.stringify(extra, null, 4)}
        </Space>
    );
}

分析

实现

React hooks

Hook 是 React 16.8 的新增特性。它可以在不编写 class 的情况下使用 state 以及其他的 React 特性。

注意:只能在组件的最顶层使用 Hook,不可以在条件语句、循环语句或其他嵌套函数中调用 Hook。

前置

Hook 规则 & 原理

调用顺序

React 根据 Hook 的调用顺序来确定 哪个 state 对应哪个 useStateHook 应该返回什么

右上角的代码中,将 useEffect 在条件语句中使用,导致第二次渲染组件时,由于此处的 Hook 被跳过,导致后续的 Hook 提前调用了(顺序错误,导致 BUG)。

正确的做法是将条件语句放在 Hook 内部,如右下角的代码所示。

规则

  • 只能在 React 函数组件 或 自定义 Hook 中调用

  • 自定义 Hook 必须以 use 开头

  • Hook 中的 state 是完全隔离的

自定义 Hook:只在组件更新时调用

function useUpdatedEffect(effect, deps) {
    const firstRender = useRef(true);
    useEffect(() => {
        if (firstRender.current) {
            firstRender.current = false;
        } else {
            return effect();
        }
    }, deps);
}

组件中使用

function Form() {
    // ...
    useUpdatedEffect(function persistForm() {
        localStorage.setItem('formData', name);
    }, [name]);
    // ...
}

Hook 过期闭包问题

将 state 传入 hook 的依赖数组,这样 state 改变时,hook 内部的数据也会更新

function WatchCount() {
    const [count, setCount] = useState(0);

    useEffect(() => {
        const id = setInterval(() => {
            console.log(`Current Count is ${count}`);
        }, 1000);
    }, [count]); // 此处应该传入 count,否则上面的 log 不会更新数值
    const increment = () => setCount(count + 1);
    return (
        <div>
            {count}
            <button onClick={increment}>Increment</button>
        </div>
    );
}

useState

让函数组件使用状态,返回一个 state 和 更新 state 的函数

const [v, setV] = React.useState(initValue);

详情见上面的 React 基础学习 的 state

useState 可以接受函数作为初始值(传入的函数不能有入参),则会执行函数返回函数的结果用于初始化。

开销比较大的话,推荐传入函数。

Typescript 签名

function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];

更新函数(set 函数)也可以接收函数:

这样可以避开 state 的快照特性,详见前面的笔记。

useReducer

useState 的更丰富替代方案,返回 [state, dispatch],这里的 state 可以是复杂对象,dispatch 可以更新这个复杂对象

详见上方

useEffect

让组件在 挂载时、依赖变化时、卸载前 执行某些逻辑

详见上方

useLayoutEffect

和 useEffect 几乎差不多,但是执行的时机不同。

在页面刷新之前执行,也就是执行完 useLayoutEffect 再重新渲染页面,和 componentDidMount 等价

但是 useLayoutEffect 会阻塞渲染,用户体验不太好。

useMemo

参数为计算函数和依赖项,只有在依赖项发生变化时才调用计算函数。返回函数的计算值

Memo

React 中,父组件更新时,会递归更新子组件。

给子组件包装一下,后续更新时,如果发现传入的 props 没有改变,则不会更新子组件。

const Child2 = React.memo(Child)

但是如果父组件传入子组件的 props 是一个对象,由于父组件重新渲染后,对象的地址改变了(即使对象内部数据没有改变),则仍然会触发子组件的重新渲染。这时候应该使用 useMemo。

useMemo

记住 props 的 obj,使它内存地址保持不变,这样子子组件就不会重新渲染了。

const data_static = useMemo(() => {
    return {a: 1};
}, []);

// 组件重新渲染时,data_static 的内存地址不会改变。

Typescript 签名

function useMemo<T>(factory: () => T, deps: DependencyList | undefined): T;
  • 第一个参数是一个工厂函数,return 要记忆的 value

  • 传入的第二个参数类似于 useEffect 的第二个参数,即依赖数组。

    • 只有依赖改变时,才会执行工厂函数,返回新的 value

    • 类似于 vue 的计算属性的 computed

useCallback

作用和 useMemo 一样,用来记忆函数。

当需要记忆的对象是一个函数的时候:

// useMemo 写法
const fn = useMemo(() => {
    return () => { console.log('fn'); };
}, []);

// useCallback 写法
const fn2 = useCallback(() => {
    console.log('fn2');
}, []);

也就是直接记忆传入的函数。

useRef

返回一个可变的 ref 对象(直接通过 ref.current 改变),在组件生命周期内持续存在

详见上方

useRef 与 useSate

  • 与视图相关,则用 useState 保存,因为它会驱动视图的更新

  • 保存的对象需要在生命周期中唯一(单例),使用 useRef

  • 想要在改变某个值后,同步获取更新后的结果,使用 useRef

forwardRef

暴露本组件的一些节点,配合 useRef 以让父组件获取子组件(不是父组件中的子元素)的节点

详见上方

useImperativeHandle

限制子组件暴露给父组件的属性(父组件只能访问暴露的对象中的属性),一般与 forwardRef 一起使用

详见上方

useContext

接收最近的上层 context 对象,并返回其值,一般与 createContext 一起使用

详见上方

自定义 hook

作用:

  • 提取组件逻辑

  • 复用

  • 优化代码

React 状态管理

为什么不把状态都放在 window 对象上?

  1. 全局污染
  2. 直接取/赋值,数据变更不清晰
  3. 渲染粒度不可控
  4. 无法进行时间旅行

什么是状态管理

单向数据流,如果组件嵌套太深,传递十分困难。

状态管理工具的本质:管理共享内存中的状态。

  1. 共享内存

  2. 管理状态

  3. 页面通信

  4. 组件通信

  5. 刷新失效?

React 状态管理简介

React 状态管理工具可以分为以下几类:

  • React 自带:Local State(props) 和 Context

  • 单项数据流:Flux、Redux(Redux-toolkit)

  • 双向数据绑定:Mobx

  • 原子型状态管理:Recoil、Jotai

  • 异步操作密集型:Rxjs

Local State(props)

组件级别的局部状态,就是最基础的 props。

如果组件需要获取兄弟组件的信息呢?

状态提升,再通过父组件将 props 传递给子组件。

问题:如果层级嵌套太深,传递信息很麻烦。

Context

React 自带的 API,定义一个公共的状态库,需要使用的组件通过该 Context 的 Provider 注入。

解决跨层级组件的通信

问题:

  1. 相当于全局变量,难以追溯数据的变更情况

  2. 使用 Context 的组件内部耦合度太高,不利于组件的复用和单元测试

  3. 会产生不必要的更新(比如穿透 memo 和 dependicies 等)

    1. Provider 里面有些组件没有用到 Context 的 value,但是 Context 的值一旦变化,这个组件也会重新渲染。
  4. 只能存储单一值,无法存储多个各自拥有消费者的值的集合

  5. 粒度不可控:不能细粒度地区分组件依赖了哪一个 Context

  6. 多个 Context 也会存在层层嵌套的问题

如果数据(比如主题配置,用户主题信息)不会经常变动,可以选择 Context

Redux

从 Flux 演变而来,但是现在已经被淘汰了。

不过其设计思想还是额可以参考和借鉴的。

Flux

使用举例:

  1. 在 UI 页面中发出 action

  2. 在 Flux 的 Action 中使用 dispatcher.dispath 将 Action 发送给 Flux 的dispatcher

  3. Dispatcher 通过 register 注册事件,然后根据传递过来的 action,来改变 store 中的 state

  4. 在 store 中进行数据更新

  5. 在 UI 中建通 store 并触发更新

Flux 的缺点:

  1. UI 组件和容器组件的拆分过于复杂

  2. Action 和 Dispatcher 绑定在一起

  3. 不支持多个 store

  4. Store 被频繁的引用和调用

Redux

Reducers 是纯函数,利用纯函数来修改 store

Redux 的三大原则:

  1. 单一数据源
  2. Store 中的 State 是只读的
  3. 使用纯函数来执行修改

基于以上原则,Redux 可以做“时间旅行”:让应用程序切换到任意时间的状态

缺点:

  1. 实现纯函数 Reducer,必须引入一系列副作用的中间件,增加心智负担
  2. Action,Dispatch,Reducer 的模式需要写过多的样板代码(虽然可以通过 React hooks 和 Redux toolkit 减少,但是复杂度还是很高)

因此,中小型项目不推荐使用 Redux。

Mobx

和 Vue 的设计比较相似。

使用了可观察对象,可以直接修改状态,不需要写 Action 和 Reducer,也不需要引入各种复杂的中间件。

缺点:

  1. 不能实现时间旅行和回溯

  2. 比较灵活,但代码风格很难统一

Recoil

原子级的状态,一定程度上解决了 Local State 和 Context 的局限性,而且能够兼容 React 的新特性(比如 Concurrent 模式等)。

解决的问题:

  1. 组件的状态共享只能通过 state 提升,但是会导致重新渲染一棵巨大的组件树

  2. Context 只能存单一值,无法存储多个各自拥有消费者的值的集合

Recoil 的状态是原子级的,可以任意组合,实现完美的局部更新。

Zustand

轻量化(仅2KB),特别适合移动端

使用十分简单,初始化过程中,不仅能保存状态,也能制定方法和函数。