通过造组件来学习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 青灯夜游

浙公网安备 33010602011771号