通过造组件来学习React-1
前言
本文将通过用 React 18 造一个菜单框,来了解 React 中一些常用的功能。菜单框效果如下:
当我们点击红色的按钮,会出现一个菜单栏,再次点击按钮、或菜单栏中的某一项、或菜单框以外的其他区域,就会收起菜单栏。
如果您已经知道如何实现这个组件,那么已经可以关掉本文了。
在本文中,我会用最简单的方式来实现这个组件,完全用不到任何第三方库。你将学到:
- useReducer hook
- useContext hook
- 利用上面两个 hook 在父子组件之间交互
里面涉及到的知识点,我会尽可能详细地讲解,所以本文会非常啰嗦。
组件划分
从这个组件的表现来看,总的来说分为两部分:按钮和菜单栏,所以至少会有一个 button
和一个 ul
标签。为了封装他们的行为,用如下的组件名表示他们:
// 按钮
<Menu.Button></Menu.Button>
// 菜单列表
<Menu.Items></Menu.Items>
但是我们不可能在使用的时候写两个组件,所以需要一个外包装来代表整个菜单组件,我会写成以下的形式:
<Menu>
{/* 按钮 */}
<Menu.Button></Menu.Button>
{/* 菜单列表 */}
<Menu.Items></Menu.Items>
</Menu>
这样,我们控制 <Menu></Menu>
,就等于控制了整个组件。<Menu.Items></Menu.Items>
代表菜单栏的列表,我希望统一其中每一项的样式,所以弄了一个 <Menu.Item></Menu.Item>
来展示单独的一项。最终组件的结构如下:
<Menu>
{/* 按钮 */}
<Menu.Button></Menu.Button>
{/* 菜单列表 */}
<Menu.Items>
<Menu.Item>ITEM 1</Menu.Item>
<Menu.Item>ITEM 2</Menu.Item>
<Menu.Item>ITEM 3</Menu.Item>
</Menu.Items>
</Menu>
如果你能够了解上面每一项的用处,那么接下来就是实现他们了。
要实现的功能
- 我希望点击按钮
<Menu.Button></Menu.Button>
能够切换菜单栏<Menu.Items></Menu.Items>
的显示隐藏 - 点击了列表中的某一项后隐藏列表
- 点击了
<Menu></Menu>
以外的其他区域隐藏列表
如果是点击按钮后显示或隐藏列表,我们会想到使用 useState
来维护一个变量,按钮的点击事件改变这个变量,变量的值决定了列表是显示还是隐藏。而因为按钮和列表在同一级,所以这个变量要提升到他们共同的父级来维护(参考:状态提升)。那么在 <Menu></Menu>
中就会有一个:
const [visiable, setVisiable] = useState(false);
visiable
变量将决定列表是否显示。这里有一个问题,如何让visiable
变量控制到列表?在结构中我们能够看到,<Menu.Items></Menu.Items>
是作为外部传入的子组件在 <Menu></Menu>
中存在,不是在实现 <Menu></Menu>
的时候在拥有的,所以我们不能直接在 <Menu></Menu>
中控制 <Menu.Items></Menu.Items>
,我们甚至不会知道在使用的时候 <Menu.Items></Menu.Items>
会嵌套在多深的地方:
<Menu>
{/* 按钮 */}
<Menu.Button></Menu.Button>
{/* 嵌套的菜单列表 */}
<div>
<div>
<Menu.Items>
<Menu.Item>ITEM 1</Menu.Item>
<Menu.Item>ITEM 2</Menu.Item>
<Menu.Item>ITEM 3</Menu.Item>
</Menu.Items>
</div>
</div>
</Menu>
为了能够在子级中方便获取到父级的状态,React 提供了 useContext
这个 hook。关于这个 hook,更详细的说明参考这里:Hook API 索引 useContext。
那么思路就很明显了,在 <Menu></Menu>
中提供 Context
,在 <Menu.Items></Menu.Items>
中获取这个 Context
。
使用 useContext
Context
在 React 中使用 createContext(...)
创建,该方法接收一个参数作为要传递的数据的默认值:假设我们要传递的数据是 Boolean 类型,如果你使用的是 ts,还可以使用泛型来定义类型,写法如下:
const MenuContext = createContext<Boolean>(false);
如上,我们就创建了一个传递的数据为 Boolean 类型的 Context
。子组件要如何使用这个 Context
呢?
在子组件使用之前,必须在父组件中显式地传递 Context,其传递的方式也不过是如下的标签式的写法:
Menu 组件
const MenuContext = createContext<Boolean>(false);
function Menu(props) {
const [visiable, setVisiable] = useState(false);
return (
<MenuContext.Provider value={visiable}>
{/* MenuContext 会向下传递给所有子组件 */}
{props.children}
</MenuContext.Provider>
);
}
如上,我们在创建了一个 Context
后,通过标签的方式使用 Context.Provider 包裹住子组件,并以该标签的属性 value
的值的形式传递要向下传递的数据,这样一来,被包裹住的所有子组件都能够获取到传递的数据。
在子组件中,使用 useContext(<Context>)
可以获取到该 Context
传递的数据。
function Items(props) {
const visiable = useContext(MenuContext);
return visiable ? <div>{props.children}</div> : null;
}
如上,在子组件中使用 useContext()
方法,参数传入 createContext()
返回的 Context
,就会返回在父组件中传递的数据。这里要确保使用 useContext()
组件被包裹在 <MenuContext.Provider></MenuContext.Provider>
中。在上面的示例代码中,我们就使用 useContext(MenuContext)
获取父组件传递的数据 visiable
,然后根据 visiable
来决定显示还是隐藏列表。
列表的显示和隐藏的问题是解决了,那么我们要怎么触发显示和隐藏呢?
触发事件
状态的修改需要事件来触发。状态定义在父级的 <Menu></Menu>
中,而事件应该在按钮 <Menu.Button></Menu.Button>
中触发,那么其中一种方式就是按钮暴露一个 onClick
属性,由父级传入事件,点击了按钮后,直接调用传入的事件。
Menu.Button 组件
function Button(props) {
return <button onClick={props.onClick}>按钮</button>;
}
但是这种方式还有有和列表组件一样的问题,就是按钮组件并不是直接在 <Menu></Menu>
中实现的,而是由外部作为子组件传递进来的,所以没法直接给按钮传递事件。
就像上面的列表一样,往子组件传递事件可使用 useContext()
。
将在 <Menu></Menu>
中的 createContext(false)
修改下,让它可以传递事件。
const MenuContext = createContext<[Boolean, ((e: any) => void) | null]>([
false,
null,
]);
function Menu(props) {
const [visiable, setVisiable] = useState(false);
return (
<MenuContext.Provider value={[visiable, setVisiable]}>
{/* MenuContext 会向下传递给所有子组件 */}
{props.children}
</MenuContext.Provider>
);
}
如上,我们将整个 useState()
所返回的都向下传递了,这样一来,列表中可以获取到状态(visiable),按钮中可以获取到事件(setVisiable)。
好像这样也可以吼,但是代码也慢慢的变得难看了起来。而且有个隐患,如果父组件控制且要向下传递的状态复杂的话,Context
中要维护的数据也会变得难以维护。当然,如果要传递的数据在上面的规模的话,其实也可以,但是因为有下面这个需求,我们会传递有更复杂的状态:菜单框以外的其他区域,就会收起菜单栏。
复杂的 State
在这里先分析这个需求,我们就会知道状态会变成什么样子了。
- 菜单框包括按钮和列表,点击按钮和列表以外的页面区域隐藏菜单栏
<Menu></Menu>
自己不知道按钮和列表是哪几个元素,因为按钮和列表是作为子级传递进来的- 所以只能让按钮和列表子级告诉
<Menu></Menu>
子级是哪个元素
这样一来,<Menu></Menu>
要维护的状态就不是单纯的 Boolean 了,还加上了按钮的引用和列表的引用。如下定义状态的类型:
type StateDefine = {
visiable: Boolean;
buttonRef: React.MutableRefObject<HTMLButtonElement | null>;
itemsRef: React.MutableRefObject<HTMLSpanElement | null>;
};
你可能不知道上面的 MutableRefObject
类型。MutableRefObject
是 useRef()
返回类型之一,useRef()
会返回 RefObject
类型或者 MutableRefObject
类型,不同之处在于它们的 .current
属性,RefObject
是只读的,MutableRefObject
可以修改。因为我们一开始不知道按钮和列表的元素,直到它们实例化后才会直到,所以我们会随时修改 buttonRef
和 itemsRef
,所以要使用 MutableRefObject
类型。在后面跟着的泛型中,我给按钮的类型是 HTMLButtonElement
,给列表的类型是 HTMLSpanElement
,这是因为我的实现是这样实现的。如果你的实现不是,就要修改这两个类型。
如果对于 MutableRefObject
还不懂的话,建议参考这篇文章:带你了解 React 中的 Ref,值得了解的知识点分享。
看到这里,你有没有发现 State 慢慢变得复杂了。修改 State 也不只是单纯传入 Boolean,而是传入对象。复杂的对象就会有复杂的逻辑,我们可能要判断传入给 buttonRef 的引用类型是否合法,就免不了要有代码来处理这部分逻辑。
同样,状态复杂后,我们的 useState()
就显得捉襟见肘了,维护复杂的状态、处理复杂的逻辑靠 useState()
已经不够看了。这个时候就要 useReducer()
出马了。
使用 useReducer()
你可以认为 useReducer()
是 useState()
的扩展版,它的第二个参数仍然是默认状态值,但 useReducer()
接收的第一个参数是一个函数,是告诉它如何维护这个状态。
type StateDefine = {
visiable: Boolean;
buttonRef: React.MutableRefObject<HTMLButtonElement | null>;
itemsRef: React.MutableRefObject<HTMLSpanElement | null>;
};
type Action = { type: "open" | "close" | "trigger" };
function reducer(state: StateDefine, action: Action): StateDefine {
switch (action.type) {
case "trigger":
return { ...state, visiable: (state.visiable = !state.visiable) };
case "open":
return { ...state, visiable: true };
case "close":
return { ...state, visiable: false };
default:
throw new Error(`unknow action type: ${action.type}`);
}
}
const [state, dispatch] = useReducer(reducer, {
visiable: false,
buttonRef: React.useRef<HTMLButtonElement | null>(null),
itemsRef: React.useRef<HTMLUListElement | null>(null),
});
如上面代码,先看 reducer 函数,这个函数是要传递给 useReducer()
的第一个参数的,作用是告诉 useReducer()
要怎么生成新的状态。reducer 函数接收两个参数,第一个参数是旧的状态,第二个参数是行为(action),reducer 函数会根据传入的行为来生成新的状态。当然这个逻辑是要自己实现的,并且这个函数不需要我们去调用它,而是传入 useReducer()
,让 useReducer()
去调用。
我定义了 reducer 的实现为,如果 action 的 type 是 "trigger",就切换列表的显示和隐藏;如果是 "open",就显示列表;如果是 "close",就隐藏列表。
useReducer()
接收参数后的返回值:[state, dispatch]
,看起来和 useState()
差不多,state 仍然是那个 state,但是 dispatch
就不一样了,它接收的是一个 Action
,而不是一个新的 state
。useReducer 会调用你传递给它的 reducer 函数来处理这个 action 的,并且生成新的 state。
这种方式的好处是显而易见的,我们可以把生成新的 state 的复杂的逻辑封装到 reducer 函数中,使用的时候只需要调用 dispatch
传递简单的 aciton 即可。
所以我们可以把原来的 useState()
逻辑改一下了,在 <Menu>
中向下传递的数据可以修改为:
function Menu(props) {
const [state, dispatch] = useReducer(reducer, {
visiable: false,
buttonRef: (React.useRef < HTMLButtonElement) | (null > null),
itemsRef: (React.useRef < HTMLUListElement) | (null > null),
});
return (
<MenuContext.Provider value={[state, dispatch]}>
{/* MenuContext 会向下传递给所有子组件 */}
{props.children}
</MenuContext.Provider>
);
}
如此一来,修改状态代码所引起的破坏性改动可以可控制在最小范围内,我们只会修改到 state 的数据类型和 reducer 函数的逻辑,使用 dispatch 的时候不需要去想我会修改了什么 state,全部交给 reducer 去做,你只需要传递 “行为”。如果子组件要修改到 state 的话,也只需要调用 dispatch。
我们其中一个需求就是点击了列表中的某一项后,隐藏列表。在使用了 useContext()
和 useReducer()
后就很简单了:
function Item(props) {
const [dispatch] = useContext(MenuContext);
return (
<li
onClick={() => {
dispatch({ type: "close" });
}}
role="listitem"
>
{props.children}
</li>
);
}
临时总结
本文就先到这里,我们还有一个需求没做:点击菜单框以外的其他区域,就会收起列表。我会在一下篇中讲解。
在本文中,你学到了:
- 使用 useContext 向子组件传递数据
- 使用 useReducer 维护复杂的状态
在下一篇,会讲到 useEffect 和事件的绑定。
参考
状态提升 by React
Hook API 索引 useContext by React
Hook API 索引 useReducer by React
带你了解 React 中的 Ref,值得了解的知识点分享 by 青灯夜游
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通