最近在写一个需求的时候,要实现一个吸顶功能折叠面板组件,看起来并不是很难,但是如何去设计一个组件,我觉得是比较有意思的一件事情,当真正完成的时候,来到代码评审阶段,组长却表示这是没有意义的创新,让我改用最简单的做法去写就好,原来创新并不是在哪都能发芽。
我的设计
一般最好的一个代码写法,去参考组件库,肯定没有错。于是打开 antd、arco 看看他们的实现,antd 写得太复杂,所以主要参考 arco 的折叠组件,我发现他的用法是使用 headless 的写法,之前我也看了 chakra-ui 的组件库,我觉得这种写法挺好的,并没有耦合在一起。
tsx
import { Collapse, Divider } from "@arco-design/web-react";
const CollapseItem = Collapse.Item;
const App = () => {
return (
<Collapse defaultActiveKey={["1", "2"]} style={{ maxWidth: 1180 }}>
<CollapseItem header="Beijing Toutiao Technology Co., Ltd." name="1">
Beijing Toutiao Technology Co., Ltd.
<Divider style={{ margin: "8px 0" }} />
Beijing Toutiao Technology Co., Ltd.
<Divider style={{ margin: "8px 0" }} />
Beijing Toutiao Technology Co., Ltd.
</CollapseItem>
<CollapseItem header="Introduce" name="2" disabled>
ByteDance's core product, Toutiao ('Headlines'), is a content platform
in China and around the world. Toutiao started out as a news
recommendation engine and gradually evolved into a platform delivering
content in various formats, such as texts, images, question-and-answer
posts, microblogs, and videos.
</CollapseItem>
</Collapse>
);
};
于是我也想着把我的折叠组件的用法也设计成这样,Collapse 组件可以统一管理 item 的折叠状态,这样就不用交给用户去书写更多的逻辑,但是 item 组件又可以高度自由化,无论是 ui 的样式,还是状态的传输。
滚动吸顶
之前就记得有个 css 属性能够实现滚动到该元素位置时,就给予吸顶,sticky 粘性定位,这个很好做到,目前这个属性的兼容性也还可以,所以就一旦点击后,就把这个 sticky 加上就可以。不过怎么做到滚动到超出这个元素的时候,他又不吸顶,这到底怎么做呢。其实,很简单,只要我们把 html 的布局结构改下就好
html
<ul>
<li class="sticky">sticky 1</li>
<li>2</li>
<li>3</li>
<li>4</li>
</ul>
<ul>
<li class="sticky">sticky 5</li>
<li>6</li>
<li>7</li>
<li>8</li>
</ul>
<ul>
<li class="sticky">sticky 9</li>
<li>10</li>
<li>11</li>
<li>12</li>
</ul>
<ul>
<li class="sticky">sticky 13</li>
<li>14</li>
<li>15</li>
<li>16</li>
</ul>
当他滚动到对应 ul 下的 li 标签 sticky,超过这个 ul 时就会到第二个元素 sticky。
collapse-sticky
准备工作都已经完成,接下来设计上层父组件 collapse-sticky,发现这里有用到 context 的东西,这是一个很妙的点,因为目前的设计用法,并不能通过 props 下发折叠态的数据到 item 组件,item 组件是在业务层面进行引入的,他并不能拿到 collapse-sticky 的状态,并下发到 item 组件,所以这里是必须得用 context 了,而 arco 的 context 写的并不优雅,这里,我借用 chakra-ui 的 context 封装,他写的挺好
ts
import {
createContext as createReactContext,
useContext as useReactContext,
} from "react";
export interface CreateContextOptions<T> {
strict?: boolean;
hookName?: string;
providerName?: string;
errorMessage?: string;
name?: string;
defaultValue?: T;
}
export type CreateContextReturn<T> = [
React.Provider<T>,
() => T,
React.Context<T>
];
function getErrorMessage(hook: string, provider: string) {
return `${hook} returned \`undefined\`. Seems you forgot to wrap component within ${provider}`;
}
export function createContext<T>(options: CreateContextOptions<T> = {}) {
const {
name,
strict = true,
hookName = "useContext",
providerName = "Provider",
errorMessage,
defaultValue,
} = options;
const Context = createReactContext<T | undefined>(defaultValue);
Context.displayName = name;
function useContext() {
const context = useReactContext(Context);
if (!context && strict) {
const error = new Error(
errorMessage ?? getErrorMessage(hookName, providerName)
);
error.name = "ContextError";
Error.captureStackTrace?.(error, useContext);
throw error;
}
return context;
}
return [Context.Provider, useContext, Context] as CreateContextReturn<T>;
}
使用
ts
import { createContext } from "@/hooks/react-context";
interface UseCollapseStickyType {
activeKeys: string | string[];
onToggle: (key: string, e) => Promise<any>;
}
export const [CollapseStickyProvider, useCollapseStickyContext] =
createContext<UseCollapseStickyType>({
name: "CollapseStickyContext",
hookName: "useCollapseStickyContext",
providerName: "<CollapseSticky />",
});
CollapseSticky 组件,统一管理 item 组件的状态,及提供一个点击的事件的回调传输
tsx
import {
createElement as h,
PropsWithChildren,
FC,
useState,
useLayoutEffect,
} from "react";
import { CollapseStickyProvider } from "./collapse-context";
import CollapseStickyItem from "./item";
interface Props {
/**
* 当前面板选中值
*/
activeKey?: string | string[];
/**
* 折叠面板点击改变事件
*/
onChange?: (
key: string,
newActiveKeys: string[],
e: React.MouseEvent<Element, MouseEvent>
) => Promise<any>;
}
const CollapseSticky: FC<PropsWithChildren<Props>> = ({
children,
activeKey,
onChange,
}) => {
const [activeKeys, setActiveKeys] = useState(activeKey || []);
const onItemClick = async (
key: string,
e: React.MouseEvent<Element, MouseEvent>
) => {
// 折叠子项点击的事件钩子
};
return (
<CollapseStickyProvider value={{ activeKeys, onToggle: onItemClick }}>
{children || null}
</CollapseStickyProvider>
);
};
const CollapseComponent = CollapseSticky as typeof CollapseSticky & {
Item: typeof CollapseStickyItem;
};
CollapseComponent.Item = CollapseStickyItem;
export default CollapseComponent;
collapse-sticky-item
collapse-sticky-item 组件,能够具备展开折叠的功能,并能 sticky,而且他的状态是由 collapse-sticky 的 context 下发管理的
tsx
import {
createElement as h,
PropsWithChildren,
FC,
useState,
useMemo,
ReactNode,
} from "react";
import cs from "classnames";
import { isArray } from "@/utils/is";
import { useCollapseStickyContext } from "./collapse-context";
import css from "./style.less";
interface Props {
/**
* 折叠面板头部内容,允许自定义
*/
header?: ReactNode | string;
/**
* 对应 activeKey,当前面板组件的的唯一标识
*/
name: string;
}
const Item: FC<PropsWithChildren<Props>> = ({ children, header, name }) => {
const { activeKeys, onToggle } = useCollapseStickyContext();
const [loading, setLoading] = useState(false);
const isExpanded = useMemo(
() =>
isArray(activeKeys)
? activeKeys?.indexOf(name) > -1
: activeKeys === name,
[activeKeys, name]
);
const handleClick = (e: React.MouseEvent<Element, MouseEvent>) => {
onToggle(name, e).finally(() => {});
};
return (
<ul className={cs(!isExpanded ? css.hidden : "")}>
<li
className={cs(isExpanded ? css.sticky : "")}
onClick={handleClick}
data-expand-name={`${Number(isExpanded)}_${name}`}
>
<i className={cs(css.icon, isExpanded ? css.active : "")} />
{header}
</li>
{children || null}
</ul>
);
};
export default Item;
用法
在业务层面,我们可以引入这 collapse-sticky 的组件,由于 item 组件本身就在 collapse-sticky 暴露,所以不需要额外引入 item 组件,只需要示例化出来就好。
tsx
<CollapseSticky activeKey={activeKey} onChange={handleChange}>
{collapseList.map((item, index) => (
<CollapseStickyItem key={item.id} header={item.title} name={`${index}`}>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
</CollapseStickyItem>
))}
</CollapseSticky>
思考
最终我的版本已经大致输出完成,而在面对组长的评审时,他的原话是,为什么要新增一个 context 的概念,项目中已经有 store 去管理这种全局状态,又新增一个 context,是否会带来一个新的偏重的东西,到时候如果别人看到这个,也随便拿去用,怎么办,而且这种写法上,带来一定的复杂度,我们这个项目比较简单,尽量不要用这么复杂去做这种事情,会带来学习成本,还会带来新的心智负担,到时候不好管理,到时候项目里混用 store 跟 context 也不好溯源。 而我的回答是:
- store 跟 context 的概念要区分清楚,我的理解上,store 是管理项目的全局状态,组件的局部全局状态是并非得用 store,而且公共组件并不会使用 store 这种第三方依赖库去管理全局状态,用 context 也不会有问题,而且当前这种组件写法的场景也只能用 context
- headless 组件优点本来就是高度拓展,ui 样式可以自由定制,虽然目前项目并没有接入原子 css,并不足以打开 headless 的全功能,缺点就是可能需要二次封装一个组件,使其成为一个业务组件去使用,使用上肯定是会复杂些
这个项目并不是我的项目,所以,还是会听从上面的安排,把他写成他期望的样子,不再分开 CollapseStickyItem,都把他融合了在 CollapseSticky 里面,他大概长这样:
tsx
<CollapseSticky
activeKey={activeKey}
onChange={handleChange}
collapseList={data}
></CollapseSticky>
起初这么做的原因还有一个是为了推广一下这种写法,毕竟目前很多组件库都是以这种形式写的,headless 带来的好处是巨大的,未来的事情谁说的准呢,需求永远在改变,谁又能知道未来项目形成的模样。还有像项目中滥用 useCallback,useEffect 的依赖项全部都是用 eslint 的 fix update 一键更新,导致依赖混乱,重渲染的次数几何倍数增长,等等这种问题。也不知道大家如何看待这 2 种写法,到底哪个好,哪个坏,欢迎大胆讨论 😄