“求求你别在 JSX 里写逻辑了” —— Headless 思想与自定义 Hook 的“灵肉分离”术

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

恭喜你,经过前几篇的洗礼,你已经学会了用 useRef 逃生,用 Context 拆分状态,用 Composition 组合组件。

现在,你的代码跑得很快,组件层级也很扁平。但是,当你打开你的核心组件文件(比如一个复杂的 MultiSelect 下拉框),你还是会感到一阵眩晕。

症状如下: 文件一共 500 行。 前 400 行全是 useStateuseEffectuseCallback,以及各种 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 很帅,但不要走火入魔。

  1. 简单的展示型组件 (比如 Avatar, Button, Badge):别折腾了,直接写组件就行,逻辑很少。
  2. 复杂的交互型组件 (比如 DatePicker, Autocomplete, Carousel):强烈建议先把逻辑抽成 Hook。

判断标准: 如果你的组件里 useEffect 超过了 2 个,或者当你试图测试这个组件时,发现还得去模拟 DOM 点击才能测逻辑,那就是时候把逻辑剥离出来了。


总结:让代码"各司其职"

所谓的高级工程师代码,其实就是极度的强迫症

  • UI 组件 :只管 className,只管布局,只管颜色。像个单纯的模特。
  • Custom Hooks:只管数据变没变,只管接口通不通,只管逻辑对不对。像个幕后的导演。

当你把这种模式应用到项目中,你会发现:

  1. Bug 少了:因为逻辑集中在 Hook 里,不用在 UI 代码的迷宫里找 Bug。
  2. 改版快了:UI 设计师把页面改得面目全非,你只需要换一套 JSX,逻辑一行都不用动。
  3. 同事跪了:他们看着你清爽的代码结构,会流下感动的泪水。

好了,我要去把那个混杂了 800 行表单验证逻辑的 Form 组件给拆了。


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

相关推荐
Cache技术分享1 小时前
260. Java 集合 - 深入了解 HashSet 的内部结构
前端·后端
前端老宋Running1 小时前
你的代码在裸奔?给 React 应用穿上“防弹衣”的保姆级教程
前端·javascript·程序员
汤姆Tom1 小时前
前端转战后端:JavaScript 与 Java 对照学习指南(第四篇 —— List)
前端·编程语言·全栈
FinClip1 小时前
当豆包手机刷屏时,另一场“静悄悄”的变革已经在你手机里发生
前端
阿珊和她的猫1 小时前
深入理解 HTML 中 `<meta>` 标签的 `charset` 和 `http-equiv` 属性
前端·http·html
alamhubb1 小时前
前端终于不用再写html,可以js一把梭了,我的ovs(不写html,兼容vue)的语法插件终于上线了
javascript·vue.js·前端框架
汉堡大王95271 小时前
告别"回调地狱"!Promise让异步代码"一线生机"
前端·javascript
syt_10131 小时前
gird布局之九宫格布局
前端·javascript·css
BD_Marathon1 小时前
【JavaWeb】HTML_常见标签_布局相关标签
前端·html