# React 基础

React 的生态万紫千红, 可谓是繁花似锦; 从这些框架及工具中衍生了各种的设计模式及理论知识, 本文就来盘点记录下相关的知识点
# 组件设计
如今各种前端框架均统一了组件化的思维, 面试中也会被问到, 如何设计一个组件(库), 笔者认为应该分为以下几个部分:
贯彻单一职责的原则
组件的通信和封装
组件的可组合性
组件的可测试性
组件的归类分类
组件的命名规范
# 单一职责原则
良好的设计中, 一个组件只干一件事情; 单一职责在组件中决定了组件如何拆分; 保持组件的单一职责将会非常重要,甚 至我们可以使用 HoC 强制组件的单一职责性。
# 组件的通信和封装
这块涉及到组件的参数设计、输入校验检查等, 也就是组件的健壮性;封装性决定了组件如何组织结构;
# 组件的可组合性
这块设计到整个组件如何拼接整个应用, 也就是组件的可复用性和扩展性;
# 组件的可测试性
良好的组件设计会让自动化测试变得异常简单, 这也涉及到良好的组件拆分
# 多组件的分类
如果需要设计一个组件库, 需要考虑的组件的拆分, 将组件分为纯组件和非纯(副作用)组件, 也就是对应React
中的Dumb
和Smartß
组件
# 组件的命名规范
最后一步讲组件的命名, 主要是这块大多数人都觉得可有可无不重要, 其实良好的组件设计中, 组件名应该是能够见名知意的
# 状态管理
现在各种主流框架都会有各种的状态管理库, 而且框架中亦能自己管理状态; 所以问题就来了: 到底什么样的状态用框架自带的状态来管理(局部状态),什么情况使用状态管理类库呢?
其实在历经这么多年的发展, 早已有最佳实践, 现在笔者来总结下:
状态拆分原则
公共通用数据放状态类库 (redux/vuex), 否则私有数据放框架局部 (react/vue) 中管理
分散虚复用数据放状态类库, 频繁变动的原子级别数据放局部管理
刷新需要持久化的放在 localstorage 中
# 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
Redux
的 connect
函数用于将组件与 Redux
状态绑定。它接收两个参数:mapStateToProps
和 mapDispatchToProps
mapStateToProps
函数用于将Redux
状态映射到组件的props
。它返回一个对象,该对象的属性对应组件的props
mapDispatchToProps
函数用于将Redux
动作映射到组件的props
。它返回一个对象,该对象的属性对应组件可以使用的动作
connect 函数的实现原理
- 创建一个新的组件类
- 将
mapStateToProps
和mapDispatchToProps
函数作为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来存储变化的值, 随时都是最新值
⭐️ useEffect
与 useLayoutEffect
区别
useLayoutEffect
等里面的代码执行完后才更新视图,会忽略掉setState
()的那次更新, DOM操作一般可以放进这个回调useEffect
会先更新初始值再更新改变后的随机数。有种一闪而过的感觉
# 通信方式
props
+callback
ref
方式- 状态管理库
Redux
、Mobx
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
ReactHooks
是React
的一套新的特性, 通过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.memo
和useMemo
两者配合使用当引起变化的数据是函数的引用时, 可以使用
useCallback
来缓存函数
# React.memo
React.memo
是一个高阶组件,常用于包裹整个子组件,当子组件的 props
没有改变的情况下会跳过重复的渲染
# useMemo
useMemo
是一个 React Hook
,使用它定义的变量,只会在useMemo
的第二个依赖参数发生修改时才会发生修改。
使用场景关注于值的变化,常用于包裹变化的值或属性Props
,把需要传递给子组件的参数用useMemo
进行处理,从而实现了子组件的更新只发生在传递给子组件的参数发生变化的时候
# useCallback
useCallback
与 useMemo
极其高度类似, 但两者的返回值是不同的。 useMemo
返回的是值,而 useCallback
返回的是函数
# useLayoutEffect
useLayoutEffect
与 useEffect
基本一致,不同点在于它是同步执行的
useLayoutEffect
是在DOM
更新之后,浏览器绘制之前的操作
设计目的
这样可以更加方便地修改 DOM
,获取 DOM
信息,这样浏览器只会绘制一次,所以useLayoutEffect
的执行顺序在 useEffect
之前
useLayoutEffect
相当于有一层防抖效果useLayoutEffect
的callback
中会阻塞浏览器绘制
# 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的区别
useDeferredValue
和 useTransition
从本质上都是标记成了过渡更新任务,不同点在于 useDeferredValue
是将原值通过过渡任务得到新的值(更关注值), 而 useTransition
是将紧急更新任务变为过渡任务(更关注函数)
useDeferredValue
用来处理数据本身,useTransition
用来处理更新函数
:::
# useInsertionEffect
useInsertionEffec
t允许在任何布局效果触发之前将元素插入到 DOM
中
使用限制(不通用)
应限于 CSS-In-JS
库作者使用, 在实际的项目中优先考虑使用 useEffect
或 useLayoutEffect
这个钩子是为了解决 CSS-In-JS
在渲染中注入样式的性能问题而出现的,所以在我们日常的开发中并不会用到这个钩子,但我们要知道如何去使用它
至此, 三个副作用钩子分别为useEffect
、useLayoutEffect
、useInsertionEffect
, 那他们的调用顺序如何?
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@18
的streaming renderer
(流式渲染)中 id
的稳定性
在React@18
服务端渲染(SSR)的hydrate
过程中, 遇到大模块或者大组件, 就需要局部渲染。局部渲染的过程中, 将模块进行拆分,让加载快的小模块先进行渲染,大的模块挂起,再逐步加载出大模块。 此时就需要id
来标记这些组件或模块, 以便于hydrate
过程中能够找到对应的组件
← Promise 深度解析 预编译器SASS →