react课程20-Advanced React Patterns

Last updated on October 18, 2024 am

本节课要点:1、render props patern 2、HOC 3、Compound Component Pattern

4、React Portal 5、javascript事件传播顺序 6、检测在窗体外的点击行为

“这节课看起来很高级,是因为它确实很高级🤫“

Jonas每次教新的知识都激动的很,搞得我也很激动,笑鼠。。。我倒要看看这节课讲的内容到底多么高级😋😋😋。

image-20240924153709168

(什么玩意进我脑子里了??)

(怎么现在vite 和 creatreact app都不会自动配置prettier了)

一、render props patern

二、HOC

三、Compound Component Pattern

Compound Component Pattern(复合组件模式) 是一种 React 设计模式,它允许你创建一组组件,这些组件可以一起工作并共享状态。这种模式通过将状态管理和 UI 逻辑分离,使得组件的组合更加灵活和可重用。

(1)主要特征

  1. 状态共享:父组件管理状态,并通过上下文(Context)提供给子组件,使它们可以访问和操作该状态。
  2. 易于组合:子组件可以灵活地组合在一起,形成复杂的 UI,而不需要关心状态的管理。
  3. 清晰的 API:使用者可以清楚地知道如何使用这些组件,组件之间的关系一目了然。

(2)使用示例

1.App.js
1
2
3
4
5
6
<Counter>
<Counter.Decrease icon="-" />
<Counter.Count />
<Counter.Increase icon="+" />
<Counter.Label>My super flexible counter</Counter.Label>
</Counter>
2.Couter.js

居然使用到了尘封的contextAPI,已经被我忘得一干二净了hhh

根据我的理解,步骤如下:

  1. 创建一个context
  2. 创建一个父组件,用以提供状态或更多东西
  3. 创造子组件,子组件接收想要的状态
  4. 把子组件连接到父组件上

然后子组件就可以灵活地组合,还可以灵活地修改😋。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import { createContext, useContext, useState } from 'react';

//1.create a context
const CounterContext = createContext();

//2.create parent component

function Counter({ children }) {
const [count, setCount] = useState(0);
const increase = () => setCount((c) => c + 1);
const decrease = () => setCount((c) => c - 1);

return (
<CounterContext.Provider value={{ count, increase, decrease }}>
<span>{children}</span>
</CounterContext.Provider>
);
}

//3.create child component to help implementing the common task
function Count() {
const { count } = useContext(CounterContext);
return <span>{count}</span>;
}

function Label({ children }) {
return <span>{children}</span>;
}

function Increase({ icon }) {
const { increase } = useContext(CounterContext);
return <button onClick={increase}>{icon}</button>;
}

function Decrease({ icon }) {
const { decrease } = useContext(CounterContext);
return <button onClick={decrease}>{icon}</button>;
}

//4.add child components as proeprties to parent compontent
Counter.Count = Count;
Counter.Label = Label;
Counter.Increase = Increase;
Counter.Decrease = Decrease;

export default Counter;

(”恭喜你成为少数几个知道这种模式的React开发人员之一“ ,hhh,Jonas你是懂得捧杀的)

三、使用React Portal创建一个Modal Window

(1)自然创建

image-20240924193002270

这样的表单是不是好看多了?

1、Modal组件
1
2
3
4
5
6
7
8
9
10
11
12
function Modal({ children, onClose }) {
return (
<Overlay>
<StyledModal>
<Button onClick={onClose}>
<HiXMark />
</Button> {/*这里是一个删除按钮,接受的函数就是隐藏表单的函数 */}
<div>{children}</div> {/*这里接收children,显示相应表单*/}
</StyledModal>
</Overlay>
);
}
2、AddCabin组件

这里我们把之前在cabin.jsx(page页)的状态、按钮和表单组件都拿过来了;

onCloseModal传递给表单组件是为了点击cancel和创建完cabin的时候都能关闭表单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function AddCabin() {
const [isOpenModal, setIsOpenModal] = useState(false);

return (
<div>
<Button onClick={() => setIsOpenModal((show) => !show)}>
Add new cabin
</Button>

{isOpenModal && (
<Modal onClose={() => setIsOpenModal(false)}>
<CreateCabinForm onCloseModal={() => setIsOpenModal(false)} />
</Modal>
)}
</div>
);
}
3、createCabinForm中所做的修改

在创建cabin的onSubmit的函数中的onSuccess回调函数中加入onCloseModal?.(),使得创建cabin成功后关闭表单。同时cacel按钮的onClick函数中也加入。(?.很重要)

Form组件接收type道具,因为这个表单也可能在其他地方被重用。所以如果含有onCloseModal就说明这是modal组件,type设置为modal,否则为regular。以显示不同的style:type={onCloseModal ? 'modal' : 'regular'

(2)使用React Portal

React Portal 是 React 中的一种机制,允许你将子组件渲染到父组件以外的 DOM 节点中。使用 Portal,你可以在应用的某个部分以外的地方呈现子组件,而不需要更改组件的层次结构。

1、主要特性
  1. DOM 节点分离:Portal 允许你在 React 组件树之外渲染子组件,这对于模态框、弹出菜单、工具提示等 UI 组件非常有用。
  2. 保持父组件的上下文:即使子组件被渲染到不同的 DOM 节点中,它仍然可以访问父组件的上下文。
  3. 不改变 React 组件层级:Portal 的使用不会影响组件的层次结构,这使得组织和维护代码变得更加清晰。
2、重要特点

通过使用 Portal 渲染的组件可以避免在其父容器或其他包含它的元素有 overflow: hiddenoverflow: scroll 等样式时被裁剪或遮挡。

当一个组件(比如模态框、下拉菜单、工具提示等)被渲染在它的父组件内部,如果父组件有以下 CSS 属性:overflow: hiddenoverflow: scroll,那么在父组件的边界之外的部分内容会被裁剪掉或滚动隐藏掉。这意味着如果你的模态框或菜单超出父组件的边界,它就会被遮挡,用户无法看到全部内容。

通过 React Portal,你可以将组件渲染到根节点或其他独立的 DOM 节点中,而不是当前的父组件内部。这样,它的渲染位置就不再受到父组件的 overflow 样式的限制,而是可以自由地显示在页面的任何地方。

3、如何使用

可以看到相较上面的版本,一共只做了两层修改:

return 后面添加creatPortal;返回值结尾加上document.{想要放置的位置}

1
2
3
4
5
6
7
8
9
10
11
12
13
function Modal({ children, onClose }) {
return createPortal (
<Overlay>
<StyledModal>
<Button onClick={onClose}>
<HiXMark />
</Button> {/*这里是一个删除按钮,接受的函数就是隐藏表单的函数 */}
<div>{children}</div> {/*这里接收children,显示相应表单*/}
</StyledModal>
</Overlay>,
document.body
);
}

(3)使用复合组件模式来创建Modal

我咋听不懂呢:这个modal是根据isOpenModal状态变量来控制是否打开的,但是这个modal本身应该知道它当前是否打开。

1、为什么要使用复合组件模式

😶当前版本的问题(简单状态控制)

  1. 状态和逻辑紧耦合isOpenModal 状态存在于 AddCabin 组件中,Modal 组件不知道自己是否打开,所有状态都必须通过父组件控制。这种方式对于简单场景是可以的,但如果有更多的交互需求,代码会变得复杂。
  2. 难以扩展:如果你需要为 Modal 添加更多功能,比如额外的子组件或控制多个 modal,这种结构不够灵活。

😲为什么使用复合组件模式?

复合组件模式允许你把不同部分的逻辑和 UI 分离开,使得状态控制和 UI 展示更加解耦。可以将 Modal 组件内部的状态交给 Modal 自己管理,而不是完全依赖父组件。这样可以带来以下好处:

  1. 增强灵活性:通过复合组件模式,Modal 可以有更多子组件(比如标题、内容、关闭按钮等),这些子组件可以根据需要自由组合,避免将所有逻辑和样式硬编码在 AddCabin 中。
  2. 状态管理解耦:Modal 内部可以自己知道它的状态(是否打开),从而让父组件不用过度管理 Modal 的内部逻辑。你只需要管理是否调用 Modal,不必关心 Modal 内部是如何关闭或显示的。
2、使用复合组件模式来创建Modal

首先看一下AddCabin.js现在变成了什么样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function AddCabin() {
return (
<Modal>
<Modal.Open opens="cabin-form">
<Button>Add new cabin</Button>
</Modal.Open>
<Modal.Window name="cabin-form">
<CreateCabinForm />
</Modal.Window>

<Modal.Open opens="table">
<Button>Show table</Button>
</Modal.Open>
<Modal.Window name="table">
<CabinTable />
</Modal.Window>
</Modal>
);
}

可以看到它非常的简洁,只有子组件和传递给子组件的children组件,以及传递给子组件的必要prop。经过分析可以得出一共有两个子组件:Open和Window,Open传递要打开的window名字,并且会传递一个按钮,至于这个按钮如何实现onClick函数,需要看Mortal组件;Window传递本窗体的名字,并包裹需要显示的表单。两个window被条件渲染。

下面是Mortal.jsx的主要内容,实现复合组件的步骤与上面相同。(记得父组件要返回Provider来提供给子组件必要信息呀,Jonas美美忘记,当然我也没记住) 按钮的按动导致openName被设置为各个传递的名字,并在window函数中与接收到的窗体名字验证,如果相同就渲染,否则不渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const ModalContext = createContext();

function Modal({ children }) {
const [openName, setOpenName] = useState('');

const close = () => setOpenName('');
const open = setOpenName;

return (
<ModalContext.Provider value={{ openName, close, open }}>
{children}
</ModalContext.Provider>
);
}

function Open({ children, opens: opensWindowName }) {
const { open } = useContext(ModalContext);
return cloneElement(children, { onClick: () => open(opensWindowName) });
}

function Window({ children, name }) {
const { openName, close } = useContext(ModalContext);
if (name !== openName) return null;

return createPortal(
<Overlay>
<StyledModal>
<Button onClick={close}>
<HiXMark />
</Button>
<div>{cloneElement(children, { onCloseModal: close })}</div>
</StyledModal>
</Overlay>,

document.body
);
}

Modal.Open = Open;
Modal.Window = Window;

💡接下来是要点!!!!既然AddCabin组件中没法传递关闭还是打开窗体的信息了,那按钮按动的函数如何绑定,又如何把onClseModal传递给createCabinForm组件呢?答案在下面⬇️

(4)cloneElement

在 React 中,cloneElement 是一个非常有用的工具,特别是在复合组件模式中,它允许你克隆一个 React 元素并向其添加新的属性或修改现有的属性,而不改变原始元素的外观和行为。它可以帮助你在不直接修改子组件的情况下,灵活地传递额外的 props 或绑定新的事件。

1、要点
  • 克隆元素:它会基于一个现有的 React 元素(子组件)创建一个新的元素。这个克隆后的元素可以接收新的 props 或覆盖现有的 props
  • 保持原始结构:克隆的元素仍然保持与原始元素相同的子组件层次结构和渲染方式,不会影响现有的 JSX 结构。
  • **传递额外的 props**:你可以在克隆的过程中向元素传递新的 props,比如传递 onClick 事件处理函数或状态信息。这是增强组件的一个强大工具,特别是在复合组件模式中。
2、语法
1
2
3
4
5
React.cloneElement(
element, // 需要克隆的 React 元素
[props], // 可选:要添加或覆盖的 props
[...children] // 可选:可以指定新的子组件
)
3、优点
  • 避免直接修改子组件:你不需要在子组件的定义中明确处理某些 props,而是可以动态地通过 cloneElement 传递额外的属性。这种方式可以让组件更加灵活、可复用。
  • 保持组件封装性:父组件可以在不改变子组件内部实现的情况下,注入新的行为,比如添加 onClick 或其他事件处理器。
  • 动态扩展组件:使用 cloneElement,你可以根据上下文或者状态动态修改组件的行为,而不需要重新定义或重新渲染整个组件。

(5)检测在窗体外的点击行为

我们想要实现一个功能:点击form外的区域会关闭窗体,再把功能抽象成了一个custom hook。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useEffect, useRef } from 'react';

export function useHandleClickOutside(handler, listenCapturing = true) {
const ref = useRef();
useEffect(
function () {
function handleClick(e) {
if (ref.current && !ref.current.contains(e.target)) {
handler();
}
}
document.addEventListener('click', handleClick, listenCapturing);
return () =>
document.removeEventListener('click', handleClick, listenCapturing);
},
[handler, listenCapturing]
);

return ref;
}

然后在Modal.js中的window函数中使用该hook,并把ref绑定到组件树上:<StyledModal ref={ref}>

1
const ref = useHandleClickOutside(close);

要点解析:

1、listenCapturing的必要性

转移至本节最后一个模块讲解。

2、if (ref.current && !ref.current.contains(e.target))
  • ref.current 是通过 React 的 useRef hook 获取到的 DOM 元素的引用。通常我们会使用 useRef 来保存一个对 DOM 元素的引用,从而能够在 React 的函数组件中访问它。ref.current 指向你想检测是否被点击的元素(比如模态框、弹窗等)。在这个例子中,ref 会保存模态框的引用。

如果 ref.current 存在,说明元素已经渲染到页面上,并且 ref 正常指向该 DOM 元素。

  • e.target 是触发点击事件的元素,即用户点击的具体 DOM 元素。
  • ref.current.contains(e.target) 是用来检查这个 e.target 是否在 ref.current 所引用的元素内部。

四、使用复合组件模式创建可重用的table

(1)创建可复合使用的Table组件

重点:1、传递columns参数 2、Body接受的prop是data和render

子组件接受columns道具是为了提供给它们的条件渲染风格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const TableContext = createContext();

function Table({ columns, children }) {
return (
<TableContext.Provider value={{ columns }}>
<StyledTable role="table">{children}</StyledTable>
</TableContext.Provider>
);
}

function Header({ children }) {
const { columns } = useContext(TableContext);
return (
<StyledHeader as="header" role="row" columns={columns}>
{children}
</StyledHeader>
);
}
function Row({ children }) {
const { columns } = useContext(TableContext);
return (
<StyledRow role="row" columns={columns}>
{children}
</StyledRow>
);
}

function Body({ data, render }) {
if (!data.length) return <Empty> No data to show at the moment</Empty>;

return <StyledBody>{data.map(render)}</StyledBody>;
}

Table.Header = Header;
Table.Row = Row;
Table.Body = Body;
Table.Footer = Footer;

(2)修改Table组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<Table columns="0.6fr 1.8fr 2.2fr 1fr 1fr 1fr">
<Table.Header>
<div></div>
<div>Cabin</div>
<div>Capacity</div>
<div>Price</div>
<div>Discount</div>
<div></div>
</Table.Header>

<Table.Body
data={cabins}
render={(cabin) => <CabinRow cabin={cabin} key={cabin.id} />}
/>
</Table>

利用到了render属性。

五、使用复合组件模式创建可重用的文字菜单

真的有那么一丢丢复杂。。。不知道从何下手来记录了我都

首先让我们看看这个Menu长什么样:

image-20240925105934378

(1)Menu.jsx

1、父组件Menus

两个状态变量:openId:控制打开菜单的cabin,position:控制菜单的位置,List的style的控制变量,因此需要通过计算得出,并传递给StyledList

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Menus({ children }) {
const [openId, setOpenId] = useState('');
const [position, setPosition] = useState(null);

const close = () => setOpenId('');
const open = setOpenId;

return (
<MenuContext.Provider
value={{ openId, close, open, position, setPosition }}
>
{children}
</MenuContext.Provider>
);
}
2、子组件Toggle.jsx(开关/切换)

rect常量是得到了Toggle的位置,然后再根据它的值来设置position

这里的逻辑运算的意思是:如果点击了这个开关,此时如果没有openId(也就是没有List被打开)或者当前的openId与此cabin的id不同(另外一个List现在正打开),就把openId设置为此cabin的id;否则的话,也就是说openId===id(当前打开的List就是这个cabin的),就把List关上(将openId设置为空)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Toggle({ id }) {
const { openId, close, open, setPosition } = useContext(MenuContext);

function handleClick(e) {
const rect = e.target.closest('button').getBoundingClientRect();
setPosition({
x: window.innerWidth - rect.width - rect.x,
y: rect.y + rect.height + 8,
});

openId === '' || openId !== id ? open(id) : close();
}

return (
<StyledToggle onClick={handleClick}>
<HiEllipsisVertical />
</StyledToggle>
);
}
3、子组件List

接收position并传递给styledList组件,并且使用useHandleClickOutside hook,来使得它在检测到List外的点击后关闭。并且使用Portal,将组件呈现在body层次。

接收cabin的id,如果和openId不相同则不进行渲染。

1
2
3
4
5
6
7
8
9
10
11
12
function List({ id, children }) {
const { openId, position, close } = useContext(MenuContext);
const ref = useHandleClickOutside(close);

if (openId !== id) return null;
return createPortal(
<StyledList position={position} ref={ref}>
{children}
</StyledList>,
document.body
);
}
4、子组件Button

接收CabinRow传递的icon和onClick函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Button({ children, icon, onClick }) {
const { close } = useContext(MenuContext);

function handleClick() {
onClick?.();
close();
}

return (
<li>
<StyledButton onClick={handleClick}>
{icon}
<span>{children}</span>
</StyledButton>
</li>
);
}
5、子组件Menu

直接等于styled component,不返回实际内容。

(2)很不一样的CabinRow.jsx

因为我们的按钮打开的窗口也需要有Modal的形式,因此我们将两个复合组件模式组合在了一起。

仔细看会发现,Modal是在最外层,使得整个Menu都是其子组件,然后再用Menus.Menu包裹,首先要渲染的是Toggle(固定在屏幕上),然后是Menus.List包裹住相应的按钮,其中编辑和删除的按钮被Modal.Open包裹住,使得按钮按动后打开的窗户是Mortal形式的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
 <Modal>
<Menus.Menu>
<Menus.Toggle id={cabinId} />

<Menus.List id={cabinId}>
<Menus.Button icon={<HiSquare2Stack />} onClick={handleDuplicate}>
Duplicate
</Menus.Button>

<Modal.Open opens="edit">
<Menus.Button icon={<HiPencil />}>Edit</Menus.Button>
</Modal.Open>

<Modal.Open opens="delete">
<Menus.Button icon={<HiTrash />}>Delete</Menus.Button>
</Modal.Open>
</Menus.List>

<Modal.Window name="edit">
<CreateCabinForm cabinToEdit={cabin} />
</Modal.Window>

<Modal.Window name="delete">
<ConfirmDelete
resourceName="cabins"
disabled={isDeleting}
onConfirm={() => deleteCabin({ id: cabinId })}
/>
</Modal.Window>
</Menus.Menu>
</Modal>

?、事件触发阶段

useHandleClickOutside Hook 中,listenCapturing 参数控制事件监听的方式(捕获阶段或冒泡阶段)。默认情况下,将 listenCapturing 设置为 true,表示事件是在捕获阶段被处理的。如果不传递 true,则会使用冒泡阶段监听事件。

(1)详细原因

1. 事件传播机制:
  • 捕获阶段(Capturing Phase):事件从顶层元素(如 document)开始,一路向下传播到事件目标元素(你点击的元素)。
  • 目标阶段(Target Phase):事件在目标元素上触发。
  • 冒泡阶段(Bubbling Phase):事件从目标元素开始,向上传播回顶层元素。
2. 代码中的事件监听器:

useHandleClickOutside 中,当 listenCapturingfalse(默认值)时,document.addEventListener('click', handleClick) 会在冒泡阶段添加事件监听器。

3. 事件触发的顺序:

当你点击打开按钮时:

  1. 目标元素的事件处理器:首先触发按钮的 onClick 事件处理器,调用 open 函数,打开模态框。
  2. **事件冒泡到 document**:接下来,事件冒泡到 document,触发在 document 上注册的冒泡阶段的事件监听器,即你的 handleClick 函数。

handleClick 中发生了什么:

  • 此时,模态框已经被打开,ref.current 已经被赋值为模态框的 DOM 元素。
  • handleClick检查 if (ref.current && !ref.current.contains(e.target))
    • e.target 是点击的打开按钮,它不在模态框内部。
    • 因此条件为 truehandleClick 调用 close 函数,关闭模态框。
4. 结果:
  • 模态框被打开后,立即又被关闭。
  • 用户体验上表现为模态框无法被打开。

(2)为什么设置 listenCapturingtrue 可以解决问题:

当将 listenCapturing 设置为 true,事件监听器会在捕获阶段触发。事件传播顺序变为:

  1. 事件捕获阶段
    • 事件从 document 开始,向下传播。
    • document 上的捕获阶段,触发 handleClick
    • 此时模态框还未被打开(因为按钮的 onClick 还未执行)。
    • ref.current 尚未定义,ref.current && !ref.current.contains(e.target)falsehandleClick 不执行任何操作。
  2. 目标元素的事件处理器
    • 事件到达目标元素(打开按钮)。
    • 触发按钮的 onClick,调用 open,模态框被打开。
  3. 事件冒泡阶段
    • 事件从目标元素向上冒泡,但因为你的事件监听器只在捕获阶段,不会再次触发 handleClick

结果:

  • 模态框成功打开。
  • handleClick 不会在模态框打开后立即调用 close

react课程20-Advanced React Patterns
http://example.com/2024/09/24/react课程20-Advanced-React-Patterns/
Author
Yaodeer
Posted on
September 24, 2024
Licensed under