前言:一种名为"组件肥胖症"的绝症

恭喜你,经过前几篇的洗礼,你已经学会了用 useRef 逃生,用 Context 拆分状态,用 Composition 组合组件。
现在,你的代码跑得很快,组件层级也很扁平。但是,当你打开你的核心组件文件(比如一个复杂的 MultiSelect 下拉框),你还是会感到一阵眩晕。
症状如下: 文件一共 500 行。 前 400 行全是 useState、useEffect、useCallback,以及各种 handleChange, handleBlur, handleKeyDown。 最后 100 行才是可怜巴巴的 JSX 代码。
每次你想改个样式,得在 400 行逻辑代码里翻山越岭找 className。 每次你想复用这个逻辑但换个皮肤(比如 PC 端换成移动端 BottomSheet),你只能把那 400 行代码复制粘贴一份。
这就叫**"组件肥胖症"。你的组件承载了太多它不该承受的痛苦:既要负责 长得好看(UI),又要负责脑子好使(Logic)**。
今天,我要教你一招灵肉分离 术,也就是 Headless(无头)组件 和 Custom Hooks 的终极奥义。 灵肉分离 额,不是 骨肉相连 啊

第一阶段:手动抽脂(Extract Hooks)
我们要做的第一件事,就是把组件里的"脑子"挖出来。
场景:一个发送验证码的按钮。 逻辑:点击 -> 请求接口 -> 开始倒计时 60s -> 倒计时结束变回"重新发送"。
❌ 典型的"胖"组件:
tsx
// SendCodeBtn.tsx
const SendCodeBtn = ({ phone }) => {
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
let timer;
if (count > 0) {
timer = setInterval(() => setCount(c => c - 1), 1000);
}
return () => clearInterval(timer);
}, [count]);
const handleClick = async () => {
setLoading(true);
try {
await api.sendCode(phone);
setCount(60);
} finally {
setLoading(false);
}
};
return (
<button disabled={count > 0 || loading} onClick={handleClick}>
{loading ? '发送中...' : count > 0 ? `${count}s 后重试` : '发送验证码'}
</button>
);
};
这就很难受。如果你在另一个页面需要一个长得不一样的验证码按钮(比如是个纯文字链接),你还得把这堆定时器逻辑重写一遍。
✅ 抽脂后(Custom Hook):
我们新建一个 hooks 文件,把所有跟 UI 无关的脏活累活都扔进去。
//
export const useCountDown = (initialCount = 60) => {
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(false);
// 这里的 useEffect 逻辑跟上面一模一样,省略...
const start = async (task) => {
setLoading(true);
try {
await task();
setCount(initialCount);
} finally {
setLoading(false);
}
};
return { count, loading, start, isRunning: count > 0 };
};
✅ 瘦身后的组件:
//
const SendCodeBtn = ({ phone }) => {
// 核心逻辑只有这一行!
const { count, loading, start, isRunning } = useCountDown();
const handleClick = () => start(() => api.sendCode(phone));
return (
<button disabled={isRunning || loading} onClick={handleClick}>
{loading ? '...' : isRunning ? `${count}s` : '发送'}
</button>
);
};
发现了吗? SendCodeBtn 现在变成了一个弱智 (褒义)。它完全不知道什么是 setInterval,它只关心:给我状态,我画界面。
以后你在做移动端、小程序端,甚至 React Native,这个 useCountDown 都可以直接拷过去用。这就叫逻辑复用。
第二阶段:Headless 组件思想(无头骑士)
Hook 解决了逻辑复用,但有时候,我们需要复用的不仅仅是逻辑,还有交互行为(Accessibility/Keyboard Support) 。
比如一个 Modal(弹窗)或者 Switch(开关)。 你需要处理 aria-expanded,处理 ESC 关闭,处理 Focus Trap(焦点锁定)。
这时候,Headless UI 概念就登场了。 它的理念是:我给你提供功能齐全的组件,但是我不带任何样式(CSS)。 你想给它穿什么衣服,是你自己的事。
举个栗子:一个简单的 Toggle 开关
如果你用普通的封装方式,你可能会写死 CSS:
//
const Toggle = ({ on, onClick }) => (
<div className="toggle-bg-green" onClick={onClick}> {/* 样式写死了 */}
<div className={`circle ${on ? 'right' : 'left'}`} />
</div>
);
Headless 的做法是,我们写一个 Hook,把所有交互需要的属性吐出来:
//
const useToggle = (initialState = false) => {
const [on, setOn] = useState(initialState);
const toggle = () => setOn(!on);
// 重点:Headless 的精髓在于生成 Props
const getTogglerProps = () => ({
'aria-pressed': on,
onClick: toggle,
role: 'button',
tabIndex: 0,
// 甚至可以处理键盘回车事件
onKeyDown: (e) => { if(e.key === 'Enter') toggle() }
});
return { on, toggle, getTogglerProps };
};
怎么用?(穿衣服环节)
场景 A:我要一个像 iOS 那样的开关
const
const { on, getTogglerProps } = useToggle();
return (
// 把逻辑 Props 展开到 UI 上
<div {...getTogglerProps()} className={`ios-switch ${on ? 'on' : 'off'}`}>
<span className="circle" />
</div>
);
};
场景 B:我要一个复古的 Checkbox 样式
const
const { on, getTogglerProps } = useToggle();
return (
<button {...getTogglerProps()} style={{ border: '2px solid black', background: on ? 'black' : 'white' }}>
{on ? 'ON' : 'OFF'}
</button>
);
};
看到没有? 同一个 Hook,驱动了两个完全长得不一样的组件。 这就是像 TanStack Table (React Table) 或者 Radix UI 这种库的核心原理。它们不给你画表格,它们只告诉你"这一行该不该显示,这一格数据是多少",剩下的 HTML/CSS 你自己写。
什么时候该用这招?
虽然 Headless 很帅,但不要走火入魔。
- 简单的展示型组件 (比如
Avatar,Button,Badge):别折腾了,直接写组件就行,逻辑很少。 - 复杂的交互型组件 (比如
DatePicker,Autocomplete,Carousel):强烈建议先把逻辑抽成 Hook。
判断标准: 如果你的组件里 useEffect 超过了 2 个,或者当你试图测试这个组件时,发现还得去模拟 DOM 点击才能测逻辑,那就是时候把逻辑剥离出来了。
总结:让代码"各司其职"
所谓的高级工程师代码,其实就是极度的强迫症:
- UI 组件 :只管
className,只管布局,只管颜色。像个单纯的模特。 - Custom Hooks:只管数据变没变,只管接口通不通,只管逻辑对不对。像个幕后的导演。
当你把这种模式应用到项目中,你会发现:
- Bug 少了:因为逻辑集中在 Hook 里,不用在 UI 代码的迷宫里找 Bug。
- 改版快了:UI 设计师把页面改得面目全非,你只需要换一套 JSX,逻辑一行都不用动。
- 同事跪了:他们看着你清爽的代码结构,会流下感动的泪水。
好了,我要去把那个混杂了 800 行表单验证逻辑的 Form 组件给拆了。

下期预告 :你以为写好代码就万事大吉了吗?生产环境是残酷的,用户是不可预测的。 接口会挂,数据会空,JS 会报错。 下一篇,我们来聊聊 "防御性编程"与 React Error Boundaries。教你如何给你的应用穿上"防弹衣",让它在崩溃边缘也能优雅地着陆。