# React 基础

React 的生态万紫千红, 可谓是繁花似锦; 从这些框架及工具中衍生了各种的设计模式及理论知识, 本文就来盘点记录下相关的知识点

# 组件设计

如今各种前端框架均统一了组件化的思维, 面试中也会被问到, 如何设计一个组件(库), 笔者认为应该分为以下几个部分:

  • 贯彻单一职责的原则

  • 组件的通信和封装

  • 组件的可组合性

  • 组件的可测试性

  • 组件的归类分类

  • 组件的命名规范

# 单一职责原则

良好的设计中, 一个组件只干一件事情; 单一职责在组件中决定了组件如何拆分; 保持组件的单一职责将会非常重要,甚 至我们可以使用 HoC 强制组件的单一职责性。

# 组件的通信和封装

这块涉及到组件的参数设计、输入校验检查等, 也就是组件的健壮性;封装性决定了组件如何组织结构;

# 组件的可组合性

这块设计到整个组件如何拼接整个应用, 也就是组件的可复用性和扩展性;

# 组件的可测试性

良好的组件设计会让自动化测试变得异常简单, 这也涉及到良好的组件拆分

# 多组件的分类

如果需要设计一个组件库, 需要考虑的组件的拆分, 将组件分为纯组件和非纯(副作用)组件, 也就是对应React中的DumbSmartß组件

# 组件的命名规范

最后一步讲组件的命名, 主要是这块大多数人都觉得可有可无不重要, 其实良好的组件设计中, 组件名应该是能够见名知意的

# 状态管理

现在各种主流框架都会有各种的状态管理库, 而且框架中亦能自己管理状态; 所以问题就来了: 到底什么样的状态用框架自带的状态来管理(局部状态),什么情况使用状态管理类库呢?

其实在历经这么多年的发展, 早已有最佳实践, 现在笔者来总结下:

状态拆分原则

  1. 公共通用数据放状态类库 (redux/vuex), 否则私有数据放框架局部 (react/vue) 中管理

  2. 分散虚复用数据放状态类库, 频繁变动的原子级别数据放局部管理

  3. 刷新需要持久化的放在 localstorage 中

# React 类组件的生命周期

此处贴一张自己画的图

React 的生命周期

# 组件的逻辑复用

如今组件的逻辑复用, 主要有以下几种方式

  • Mixin

  • HOC

  • Render Props

  • Hooks

# Mixin 的优缺点

Mixin 作为早期数据复用的方式, 本质是将对象复制到另一个对象; 主要缺点是:

相关依赖:mixin 有可能去依赖其他的 mixin,当我们修改其中一个的时候,可能会影响到其他的 mixin

命名冲突:不同的人在写的时候很有可能会有命名冲突,比如像 handleChange 等类似常见的名字

增加复杂性:当我们一个组件引入过多的 mixin 时,代码逻辑将会非常复杂,因为在不停的引入状态,和我们最初想的每个组件只做单一的功能背道而驰。

# HOC

HOC 优点是不会影响组件内部的状态,但是缺点也有:

需要在原组件上进行包裹和嵌套,如果大量使用 HOC,将会产生非常多的嵌套,这让调试变得非常困难

HOC 可以劫持 props,在不遵守约定的情况下也可能造成冲突

# Render Props

Render Props灵活性非常高, 扩展性也很强;但是本身的缺点就是太容易造成“嵌套地狱”

# Hooks

Hooks 可以算是终级解决方案了, 解决了上面的一系列问题

# Redux

设计理念

使用简单数组和对象来表示状态, 使用对象来描述状态的改变, 状态的改变逻辑必须是纯函数, 规范了状态管理的思想, 其主要有三个特点:

  • 单一数据源
  • 所有数据都是只读的(只能通过 dispath('name',action)来触发)
  • 处理 action 只是新生成对象而不修改原状态

因此保证了 Redux 的数据可靠性, 可测试性, 无副作用, 数据的来源是清晰的, 数据的修改也能追溯

set/get 的状态管理混乱, 无数据可靠性(不清楚 set 是否会覆盖其他的对象), 无法追溯数据来源(直接 get 太混乱, 导致复用性差), 不便于测试

# CombineReducers

redux中的combineReducers函数用于将多个reducer合并为一个reducer

combineReducers函数的作用是将多个reducer合并为一个reducer,以便 Redux 可以轻松地管理多个状态

语法

combineReducers(reducers)

其中,reducers是一个对象,每个属性都对应一个reducer

例如,假设我们有以下两个reducer

const todosReducer = (state = [], action) => {
  switch (action.type) {
    case "ADD_TODO":
      return [...state, action.payload];
    case "REMOVE_TODO":
      return state.filter((todo) => todo.id !== action.payload);
    default:
      return state;
  }
};

const visibilityReducer = (state = "SHOW_ALL", action) => {
  switch (action.type) {
    case "SHOW_COMPLETED":
      return "SHOW_COMPLETED";
    case "SHOW_ACTIVE":
      return "SHOW_ACTIVE";
    default:
      return state;
  }
};

// 要将这两个reducer合并为一个reducer,可以使用以下代码:
const rootReducer = combineReducers({
  todos: todosReducer,
  visibility: visibilityReducer,
});

// 现在,我们可以将rootReducer传递给createStore函数,以创建一个新的store:
const store = createStore(rootReducer);

store将包含todos和visibility两个状态。

combineReducers函数还支持默认值参数。默认值参数用于指定如果reducer没有指定默认值时,应该使用的值

例如,以下代码将todosReducer的默认值设置为空数组

// 在这种情况下,如果todosReducer没有指定默认值,则todos的初始值将为空数组。
const rootReducer = combineReducers({
  todos: todosReducer,
  visibility: visibilityReducer,
}, {
  todos: [],
});

# Connect

Reduxconnect 函数用于将组件与 Redux 状态绑定。它接收两个参数:mapStateToPropsmapDispatchToProps

  • mapStateToProps 函数用于将 Redux 状态映射到组件的 props。它返回一个对象,该对象的属性对应组件的 props

  • mapDispatchToProps 函数用于将 Redux 动作映射到组件的 props。它返回一个对象,该对象的属性对应组件可以使用的动作

connect 函数的实现原理

  • 创建一个新的组件类
  • mapStateToPropsmapDispatchToProps 函数作为 props 传递给新的组件类
  • 在新的组件类的构造函数中,将 Redux 状态和动作注入到组件中
  • 在新的组件类的 render 函数中,使用 mapStateToProps 函数将 Redux 状态映射到组件的 props
  • 在新的组件类的 render 函数中,使用 mapDispatchToProps 函数将 Redux 动作映射到组件的 props

下面是自己实现的一个简单的connect函数

::: code-group

// 函数式写法
export const connect = (mapStateToProps: any, mapDispatchToProps: any) =>
  (WrappedComponent: any) => {
    // 1. 检查 WrappedComponent 是否为 React 组件
    if (typeof WrappedComponent !== 'function') {
      throw new Error('WrappedComponent 必须是一个 React 组件。')
    }

    // 2. 创建一个新的组件
    const ConnectedComponent = (props: any) => {
      // 4. 渲染 WrappedComponent
      return (
        <WrappedComponent
          // 3. 将 Redux 状态和动作映射到 props 上
          {...props}
          {...mapStateToProps(props)}
          {...mapDispatchToProps(props.dispatch)}
        />
      )
    }

    ConnectedComponent.displayName = 'Connected' + WrappedComponent.displayName

    // 5. 返回新的组件
    return ConnectedComponent
  }
import React from "react";
import { connect } from "./connect";

class TodoList extends React.Component {
  render() {
    return (
      <ul>
        {this.props.todos.map((todo) => (
          <li key={todo.id}>
            {todo.text}
            <button onClick={() => this.props.onRemoveTodo(todo.id)}>删除 </button>
          </li>
        ))}
      </ul>
    );
  }
}

const mapStateToProps = (state) => ({
  todos: state.todos,
});

const mapDispatchToProps = (dispatch) => ({
  onRemoveTodo: (id) => dispatch({ type: "REMOVE_TODO", payload: id }),
});

const ConnectedTodoList = connect(mapStateToProps, mapDispatchToProps)(TodoList);

export default ConnectedTodoList;

:::

# SetState

React经过多个版本的迭代, setState同步和异步的场景也发生了变化, 作者通过React-setState同步还是异步的测试Demo (opens new window)验证后, 最终得出如下结论

⭐️ 经过测试React@18以下版本:

  • 不受控的事件回调中, setState是同步更新的, 即界面值和js中的log值是一致的
  • 在受控的回调中, setState是异步批量更新的, js中的同步输出的日志log值是上一次的值
  • 不受控的事件回调包括setTimeout/setInterval/Promise.then/addEventListener

⭐️ 在React@18以上的版本:

  • setState均是异步批量更新的

⭐️ 问题: setState既然是异步, 如何获取最新的状态值

  • setState(oldv => { console.log(oldv); return newv }) 通过回调方式获取旧值,返回新值
  • useEffect中的回调也是最新值
  • useRef() 通过这个api来存储变化的值, 随时都是最新值

⭐️ useEffectuseLayoutEffect区别

  • useLayoutEffect等里面的代码执行完后才更新视图,会忽略掉setState()的那次更新, DOM操作一般可以放进这个回调
  • useEffect会先更新初始值再更新改变后的随机数。有种一闪而过的感觉

# 通信方式

  • props + callback
  • ref 方式
  • 状态管理库ReduxMobx
  • context上下文
  • 事件总线event-bus

# 合成事件

React@18中, 下面代码的事件顺序如下:

  • document原生捕获
  • 父元素React事件捕获
  • 子元素React事件捕获
  • 父元素原生捕获
  • 子元素原生捕获
  • 子元素原生冒泡
  • 父元素原生冒泡
  • 子元素React事件冒泡
  • 父元素React事件冒泡
  • document原生冒泡
const EventOrder = () => {
  const divRef = useRef();
  const pRef = useRef();

  const parentBubble = () => {
    console.log("父元素React事件冒泡");
  };
  const childBubble = () => {
    console.log("子元素React事件冒泡");
  };
  const parentCapture = () => {
    console.log("父元素React事件捕获");
  };
  const childCapture = () => {
    console.log("子元素React事件捕获");
  };

  useEffect(() => {
    divRef.current.addEventListener(
      "click",
      () => {
        console.log("父元素原生捕获");
      },
      true
    );
    divRef.current.addEventListener("click", () => {
      console.log("父元素原生冒泡");
    });

    pRef.current.addEventListener(
      "click",
      () => {
        console.log("子元素原生捕获");
      },
      true
    );
    pRef.current.addEventListener("click", () => {
      console.log("子元素原生冒泡");
    });

    document.addEventListener(
      "click",
      () => {
        console.log("document原生捕获");
      },
      true
    );
    document.addEventListener("click", () => {
      console.log("document原生冒泡");
    });
  }, []);

  return (
    <div ref={divRef} onClick={parentBubble} onClickCapture={parentCapture}>
      <p ref={pRef} onClick={childBubble} onClickCapture={childCapture}>
        事件执行顺序
      </p>
    </div>
  );
};

# ReactHooks

ReactHooksReact的一套新的特性, 通过Hooks可以让函数组件具有类组件的能力

ReactHooks解决了类组件的一些历史问题

  • 状态难以复用(HOC 导致嵌套层级过多)

  • 复杂组件难以维护(逻辑分散且生命周期太多)

  • this 指向问题

下面来浅析一些官方内置Hooks的使用方式

# useState

语法

const [state, setState] = useState(initData)
  • initData: 默认初始值,有两种情况:函数和非函数,如果是函数,则函数的返回值作为初始值
  • state: 数据源,用于渲染UI 层的数据源
  • setState: 改变数据源的函数,可以理解为类组件的 this.setState

如果 initData 需要通过复杂计算获得,则需要传入一个函数,在函数中计算并返回 initData,此函数只会在初始渲染时被调用一次

应该特别注意useState初始值为函数的这种情况

import { useState } from "react";

const Index: React.FC<any> = (props) => {
  // 这种方式跟直接传入一个对象是不一样的, 这种方式只会在初始化时执行一次
  const getInitState = () => ({ number: props.number })
  // 此时, getInitState是一个更新函数,所以React尝试调用它并存储结果作为初始值
  const [state, setState] = useState(getInitState)

  return (
    <>
      <div>number: {state.number}</div>
       <button onClick={() => setState({ number: counter.number + 1 })}>+</button>
      <button onClick={() => setState(counter)}>setState</button>
    </>
  );
};

export default Index;

# useImperativeHandle、forwardRef

React中, 父组件如果需要调用子组件的方法时, 可以将ref传递到子组件。 然后通过forwardRef进行包装后, 子组件就能获取到ref。 然后通过useImperativeHandle暴露出自己的API供父组件调用

在实际的开发中, 应用场景一般是表单的校验, 例如:

::: code-group

import React, { useRef } from "react";

const Parent = () => {
  const childRef = useRef();

  const getFocus = () => {
    childRef.current.focus();
  };

  const validate = () => {
    console.log(childRef.current.validate());
  };

  return (
    <div>
      <Child ref={childRef} />
      <button onClick={getFocus}>获取焦点</button>
      <button onClick={validate}>校验</button>
    </div>
  );
};
import React, { forwardRef, useImperativeHandle, useRef } from "react";

// react中限制了ref无法作为props来直接获取, 因此需要借助forwardRef来获取父ref属性
const Child = forwardRef((props, ref) => {
  const inputRef = useRef();

  useImperativeHandle(ref,
  () => ({
    focus: () => {
      inputRef.current.focus();
    },
    validate: () => {
      return inputRef.current.value.length > 0;
    },
  }),
  []);

  return <input ref={inputRef} />;
});

:::

# useContext、useReducer

useContext

上下文,类似于 Context,其本意就是设置全局共享数据,使所有组件可跨层级实现共享。

useContext 的参数一般是由 createContext 创建,或者是父级上下文 context传递的,通过 CountContext.Provider 包裹的组件,才能通过 useContext 获取对应的值。我们可以简单理解为 useContext 代替 context.Consumer 来获取 Provider 中保存的 value

在组件的顶层作用域调用 useReducer 以创建一个用于管理状态的 reducer, 可以利用Hooks实现小型状态管理。再搭配useContext可以实现跨组件的状态管理

本示例demo请移步stackblitz.com (opens new window)

注意

在 reducer 中,如果返回的 state 和之前的 state 值相同,那么组件将不会更新

::: code-group

// store仓库
// reducer.ts
export const initialState = { count: 0 };
export function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + 1 };
    case "decrement":
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
}
// 顶层容器组件
// provider.tsx
import React, { useReducer, createContext, useContext } from 'react';
import { initialState, reducer } from './reducer';

// 使用initialState初始化
export const StateContext = createContext(initialState);
export const useStateValue = () => useContext(StateContext);

function CounterProvider(props) {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <StateContext.Provider value={{ state, dispatch }}>
      {props.children}
    </StateContext.Provider>
  );
}
// 后代子组件, 消费context
import { useStateValue } from './provider'

const ChangeCount= () => {
  const { state, dispatch } = useStateValue()

  return (
    <div>
      <h1>Counter: {state.count}</h1>
      <button onClick={() => dispatch({ type: 'increment' })}>
        increment
      </button>
      <button onClick={() => dispatch({ type: 'decrement' })}>
        decrement
      </button>
    </div>
  )
}
// App.tsx
import { CounterProvider } from './provider'
import ChangeCount from './ChangeCount'

const App = ()=>{
  return (
    <CounterProvider>
      <ChangeCount />
    </CounterProvider>
  )
}

:::

# useMemo、React.memo、useCallback

这几个API都常用于性能优化, 作用也都是来缓存数据,避免子组件的无效重复渲染

  • 当父子组件之间不需要传值通信时,可以选择用React.memo来避免子组件的无效重复渲染

  • 当父子组件之间需要进行传值通信时,需要React.memouseMemo两者配合使用

  • 当引起变化的数据是函数的引用时, 可以使用useCallback来缓存函数

# React.memo

React.memo是一个高阶组件,常用于包裹整个子组件,当子组件的 props 没有改变的情况下会跳过重复的渲染

# useMemo

useMemo是一个 React Hook,使用它定义的变量,只会在useMemo的第二个依赖参数发生修改时才会发生修改。

使用场景关注于值的变化,常用于包裹变化的值或属性Props,把需要传递给子组件的参数用useMemo进行处理,从而实现了子组件的更新只发生在传递给子组件的参数发生变化的时候

# useCallback

useCallbackuseMemo 极其高度类似, 但两者的返回值是不同的。 useMemo 返回的是值,而 useCallback 返回的是函数

# useLayoutEffect

useLayoutEffectuseEffect 基本一致,不同点在于它是同步执行的

  • useLayoutEffect 是在 DOM 更新之后,浏览器绘制之前的操作

设计目的

这样可以更加方便地修改 DOM,获取 DOM 信息,这样浏览器只会绘制一次,所以useLayoutEffect 的执行顺序在 useEffect 之前

  • useLayoutEffect 相当于有一层防抖效果

  • useLayoutEffectcallback 中会阻塞浏览器绘制

# useDebugValue

可用于在 React 开发者工具中显示自定义 Hook 的标签。这个 Hooks 目的就是检查自定义 Hooks

# useSyncExternalStore

语法

const state = useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot
)
  • subscribe:订阅函数,用于注册一个回调函数,当存储值发生更改时被调用。 此外,useSyncExternalStore 会通过带有记忆性的 getSnapshot 来判断数据是否发生变化,如果发生变化,那么会强制更新数据

  • getSnapshot:返回当前存储值的函数。必须返回缓存的值。如果 getSnapshot 连续多次调用,则必须返回相同的确切值,除非中间有存储值更新

  • getServerSnapshot:返回服务端(hydration 模式下)渲染期间使用的存储值的函数

useSyncExternalStore 是一个 React Hooks,它允许你订阅一个外部存储。外部存储可以是一个 API数据库或任何其他可以提供数据的地方

useSyncExternalStore 将确保 React 组件始终与外部存储保持同步

应用场景包括

  • 从 API 获取数据
  • 从数据库获取数据
  • 从其他 React 组件获取数据

平时使用 useSyncExternalStore 时,需要注意以下几点:

  • 外部存储必须是同步的。如果外部存储是异步的,则 useSyncExternalStore 可能会导致组件重渲染
  • 外部存储必须是可靠的。如果外部存储不可靠,则 useSyncExternalStore 可能会导致组件出现错误

这个Hooks能够让 React 组件在 Concurrent(并发) 模式下安全、有效地读取外接数据源,在组件渲染过程中能够检测到变化,并且在数据源发生变化的时候,能够调度更新

::: code-group

import { combineReducers, createStore } from "redux";

const reducer = (state: number = 1, action: any) => {
  switch (action.type) {
    case "ADD":
      return state + 1;
    case "DEL":
      return state - 1;
    default:
      return state;
  }
};

/* 注册reducer,并创建store */
const rootReducer = combineReducers({ count: reducer });
export const store = createStore(rootReducer, { count: 1 });
import { store } from './store.ts'
import { useSyncExternalStore } from "react";

const Index: React.FC<any> = () => {
  //订阅
  const state = useSyncExternalStore(
    store.subscribe,
    () => store.getState().count
  );

  // 当我们点击按钮后,会触发 store.subscribe(订阅函数),执行 getSnapshot 后得到新的 count,此时 count 发生变化,就会触发更新
  return (
    <>
      <div>数据源: {state}</div>
      <button onClick={() => store.dispatch({ type: "ADD" })}>
        加1
      </button>
      <button
        style={{ marginLeft: 8 }}
        onClick={() => store.dispatch({ type: "DEL" })}
      >
        减1
      </button>
    </>
  );
};

export default Index;

:::

# useTransition

useTransition 是为了延迟任务而设计的, 例如处理大量数据的耗时场景或者loading加载的场景

它会返回一个状态值表示过渡(延迟)更新任务的等待状态,以及一个启动该过渡(延迟)更新任务的函数。 其本质是将紧急更新任务变为过渡(延迟)任务

语法

const [isPending, startTransition] = useTransition();
  • isPending 布尔值,过渡状态的标志,为 true 时表示等待状态;
  • startTransition 可以将里面的任务变成过渡更新任务。
import { useState, useTransition } from "react";
import { Input } from "antd";

const Index: React.FC<any> = () => {
  const [isPending, startTransition] = useTransition();
  const [input, setInput] = useState("");
  const [list, setList] = useState<string[]>([]);

  return (
    <>
      <div>useTransition</div>
      <Input
        value={input}
        onChange={(e) => {
          setInput(e.target.value);
          startTransition(() => {
            const res: string[] = [];
            for (let i = 0; i < 10000; i++) {
              res.push(e.target.value);
            }
            setList(res);
          });
        }}
      />
      {isPending ? (
        <div>加载中...</div>
      ) : (
        list.map((item, index) => <div key={index}>{item}</div>)
      )}
    </>
  );
};

export default Index;

# useDeferredValue

语法

const deferredValue = useDeferredValue(value);
  • value:接受一个可变的值,如useState所创建的值
  • deferredValue:返回一个延迟状态的值

useDeferredValue的作用也可让您推迟更新部分 UI, 从而防止页面卡顿

在一些场景中,渲染比较消耗性能,比如输入框。输入框的内容去调取后端服务,当用户连续输入的时候会不断地调取后端服务,其实很多的片段信息是无用的,这样会浪费服务资源, React 的响应式更新和 JS 单线程的特性也会导致其他渲染任务的卡顿。而 useDeferredValue 就是用来解决这个问题的

但是, 它与传统的节流和防抖却不同。 它不需要选择任何固定延迟。如果用户的设备速度很快(例如功能强大的笔记本电脑),则延迟的重新渲染几乎会立即发生并且不会被注意到。如果用户的设备速度很慢,则列表将“落后于”输入,与设备的速度成比例。

此外,与去抖或节流不同,延迟重新渲染useDeferredValue默认情况下是可中断的。这意味着如果 React 正在重新渲染一个大列表,但用户再次进行点击,React 将放弃该重新渲染,处理点击任务,然后再次开始在后台渲染。相比之下,去抖动和节流仍然会产生卡顿的体验,因为它们会阻塞-它们只是推迟渲染阻塞击键的时刻

:::info 与useTransition的区别 useDeferredValueuseTransition 从本质上都是标记成了过渡更新任务,不同点在于 useDeferredValue 是将原值通过过渡任务得到新的值(更关注值), 而 useTransition 是将紧急更新任务变为过渡任务(更关注函数)

useDeferredValue 用来处理数据本身,useTransition 用来处理更新函数 :::

# useInsertionEffect

useInsertionEffect允许在任何布局效果触发之前将元素插入到 DOM

使用限制(不通用)

应限于 CSS-In-JS 库作者使用, 在实际的项目中优先考虑使用 useEffectuseLayoutEffect

这个钩子是为了解决 CSS-In-JS 在渲染中注入样式的性能问题而出现的,所以在我们日常的开发中并不会用到这个钩子,但我们要知道如何去使用它

至此, 三个副作用钩子分别为useEffectuseLayoutEffectuseInsertionEffect, 那他们的调用顺序如何?

import { useEffect, useLayoutEffect, useInsertionEffect } from "react";

const Index: React.FC<any> = () => {
  useEffect(() => console.log("useEffect"), []);

  useLayoutEffect(() => console.log("useLayoutEffect"), []);

  useInsertionEffect(() => console.log("useInsertionEffect"), []);

  return <div>副作用钩子 useEffect 、useLayoutEffect、useInsertionEffect 执行顺序比较</div>;
};

export default Index;

从先到后执行顺序

  • useInsertionEffect
  • useLayoutEffect
  • useEffect

# useId

语法

const id = useId();
  • id: 生成一个服务端和客户端统一的id

为什么需要这个useId的钩子函数?

是因为React@18streaming renderer(流式渲染)中 id 的稳定性

React@18服务端渲染(SSR)的hydrate过程中, 遇到大模块或者大组件, 就需要局部渲染。局部渲染的过程中, 将模块进行拆分,让加载快的小模块先进行渲染,大的模块挂起,再逐步加载出大模块。 此时就需要id来标记这些组件或模块, 以便于hydrate过程中能够找到对应的组件