首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >组件复用的 3 种高级方案:抽离逻辑 vs 封装组件 vs 自定义 hooks

组件复用的 3 种高级方案:抽离逻辑 vs 封装组件 vs 自定义 hooks

作者头像
fruge365
发布2025-12-15 13:38:21
发布2025-12-15 13:38:21
100
举报

组件复用的 3 种高级方案:抽离逻辑 vs 封装组件 vs 自定义 hooks

组件复用的关键不在“把代码复用起来”,而在于选择正确的抽象边界,让后续迭代的成本更低、协作更顺畅。本文从三条互补路径展开:抽离逻辑(跨 UI 复用)、封装组件(统一视觉与交互)与自定义 hooks(复用有状态逻辑),提供实战示例与选型指南。

快速对比

  • 抽离逻辑:把计算/规则/格式化变成纯函数或服务模块;跨页面、跨技术栈均可复用。
  • 封装组件:把稳定的 UI 模式与交互协议固化为组件,对外暴露简洁安全的 Props 接口。
  • 自定义 hooks:把状态管理与副作用抽象为“可组合逻辑”,在不同组件中灵活拼装。

方案一:抽离逻辑(跨 UI 的通用复用)

适用场景

  • 业务规则稳定但展示形式多样(表格、表单、图表都需要同一套计算/校验/权限判断)。
  • 希望被多端(Web/Node/React Native)或不同框架共同复用。

实践要点

  • 纯函数优先:输入→输出明确,避免隐藏副作用与环境耦合。
  • 契约清晰:单位、范围、边界、错误抛出策略要写清楚并在类型上体现。
  • 小而稳:构建可组合的原子工具函数,复杂能力通过组合获得。

示例:分页与排序的通用工具

代码语言:javascript
复制
// logic/paginate.ts
export function paginate<T>(list: T[], pageSize: number, page: number): T[] {
  if (pageSize <= 0 || page < 1) return [];
  const start = (page - 1) * pageSize;
  return list.slice(start, start + pageSize);
}

// logic/sortBy.ts
export function sortBy<T>(list: T[], key: keyof T, order: 'asc' | 'desc' = 'asc'): T[] {
  const factor = order === 'asc' ? 1 : -1;
  return [...list].sort((a, b) => {
    const av = a[key] as unknown as number | string;
    const bv = b[key] as unknown as number | string;
    if (av === bv) return 0;
    return (av > bv ? 1 : -1) * factor;
  });
}

在组件中调用

代码语言:javascript
复制
import { paginate } from './logic/paginate';
import { sortBy } from './logic/sortBy';

export function UserTable({ data }: { data: Array<{ name: string; age: number }> }) {
  const sorted = sortBy(data, 'age', 'asc');
  const page1 = paginate(sorted, 10, 1);
  return (
    <ul>
      {page1.map((u) => (
        <li key={u.name}>{u.name} - {u.age}</li>
      ))}
    </ul>
  );
}

方案二:封装组件(统一视觉与交互协议)

适用场景

  • UI 结构与交互较为稳定,需在多个页面一致呈现(如 Modal、Select、Table)。
  • 团队希望通过“基座组件”统一规范与可访问性(a11y)。

实践要点

  • 受控/非受控双模式:同时支持 open + onOpenChangedefaultOpen
  • 插槽设计:通过 children 或 render props 暴露可插入区域。
  • 可扩展:合理的 props 设计(classNamestyleas)与 forwardRef

示例:可受控的 Modal 组件

代码语言:javascript
复制
import { useState, useEffect, forwardRef } from 'react';

type ModalProps = {
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (next: boolean) => void;
  title?: string;
  children?: React.ReactNode;
  footer?: React.ReactNode;
  className?: string;
};

export const Modal = forwardRef<HTMLDivElement, ModalProps>(function Modal(
  { open, defaultOpen, onOpenChange, title, children, footer, className },
  ref
) {
  const [innerOpen, setInnerOpen] = useState(Boolean(defaultOpen));
  const isControlled = typeof open === 'boolean';
  const visible = isControlled ? open! : innerOpen;

  useEffect(() => {
    if (!isControlled && typeof open === 'boolean') setInnerOpen(open);
  }, [open, isControlled]);

  const setOpen = (next: boolean) => {
    if (!isControlled) setInnerOpen(next);
    onOpenChange?.(next);
  };

  if (!visible) return null;
  return (
    <div ref={ref} role="dialog" aria-modal="true" className={className}>
      {title && <div>{title}</div>}
      <div>{children}</div>
      <div>{footer ?? <button onClick={() => setOpen(false)}>关闭</button>}</div>
    </div>
  );
});

使用示例

代码语言:javascript
复制
// 受控模式
function Page() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>打开</button>
      <Modal open={open} onOpenChange={setOpen} title="标题">
        内容……
      </Modal>
    </>
  );
}

// 非受控模式
function PageUncontrolled() {
  return (
    <>
      <Modal defaultOpen title="欢迎">
        初次打开显示
      </Modal>
    </>
  );
}

方案三:自定义 hooks(复用有状态逻辑)

适用场景

  • 多个组件共享同样的状态管理与副作用(请求、滚动、节流/防抖、订阅)。
  • 逻辑以“组合”方式更灵活复用,而不强制 UI 结构。

实践要点

  • 输入输出明确:参数与返回(state、操作函数)清晰一致。
  • 引用稳定:返回的函数使用 useCallbackuseRef 保持稳定。
  • 清理副作用:订阅、计时器、网络请求在卸载时清理或取消。

示例 A:useDebouncedValue(防抖值)

代码语言:javascript
复制
import { useEffect, useState } from 'react';

export function useDebouncedValue<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const t = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(t);
  }, [value, delay]);
  return debounced;
}

示例 B:useRequest(带取消与重试)

代码语言:javascript
复制
import { useEffect, useRef, useState, useCallback } from 'react';

type RequestState<T> = { loading: boolean; data?: T; error?: unknown };

export function useRequest<T>(
  fn: (signal: AbortSignal) => Promise<T>,
  options?: { retries?: number; baseDelay?: number; immediate?: boolean }
) {
  const [state, setState] = useState<RequestState<T>>({ loading: false });
  const controllerRef = useRef<AbortController | null>(null);
  const retries = options?.retries ?? 2;
  const base = options?.baseDelay ?? 200;

  const run = useCallback(async () => {
    controllerRef.current?.abort();
    controllerRef.current = new AbortController();

    setState({ loading: true });
    for (let i = 0; i <= retries; i++) {
      try {
        const data = await fn(controllerRef.current.signal);
        setState({ loading: false, data });
        return;
      } catch (e) {
        if (controllerRef.current.signal.aborted) return;
        if (i === retries) {
          setState({ loading: false, error: e });
          return;
        }
        const jitter = Math.random() * 0.2 + 0.9;
        const delay = Math.round(base * Math.pow(2, i) * jitter);
        await new Promise((r) => setTimeout(r, delay));
      }
    }
  }, [fn, retries, base]);

  useEffect(() => {
    if (options?.immediate) run();
    return () => controllerRef.current?.abort();
  }, [options?.immediate, run]);

  return { ...state, run, cancel: () => controllerRef.current?.abort() };
}

使用示例

代码语言:javascript
复制
function SearchBox() {
  const [q, setQ] = useState('');
  const dq = useDebouncedValue(q, 300);
  const { data, loading, error } = useRequest<string[]>(
    async (signal) => {
      const res = await fetch(`/api/search?q=${encodeURIComponent(dq)}`, { signal });
      if (!res.ok) throw new Error('failed');
      return res.json();
    },
    { immediate: true }
  );

  return (
    <div>
      <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="搜索…" />
      {loading && <span>加载中…</span>}
      {error && <span>出错</span>}
      <ul>{data?.map((x) => <li key={x}>{x}</li>)}</ul>
    </div>
  );
}

选型指南:怎么选最合适的?

  • 只复用“计算/规则/格式化”,且与 UI 无关 → 选 抽离逻辑。
  • 需要统一 UI 与交互协议,页面直接复用整体 → 选 封装组件。
  • 需要复用“有状态逻辑 + 副作用”,但保留 UI 灵活性 → 选 自定义 hooks。

进一步判断

  • 复用面广、跨技术栈 → 抽离逻辑优先。
  • 设计系统/风格统一要求强 → 封装组件优先。
  • 多种组件组合同一数据流 → hooks 优先。

常见误区与规避

  • 过度封装:把不稳定需求封成“大组件”,改动牵一发而动全身。
  • hooks 泄漏业务:把接口路径、环境变量硬编码在 hooks 内,降低可移植性。
  • 边界不清:未定义受控/非受控、错误处理、取消策略,使用者难以正确集成。
  • 不可测试:逻辑混在 UI 中,单测无法覆盖关键路径。

工程实践建议

  • 分层结构:logic(纯函数) → hooks(状态逻辑) → components(UI)
  • 类型友好:通过 TypeScript 泛型与可选参数增强复用性。
  • 文档与示例:为封装组件与 hooks 保留最小示例(Story/MDX),降低接入成本。
  • 监控与可视化:对 hooks 的请求与重试打点,定位性能与稳定性问题。

小结

抽离逻辑、封装组件、自定义 hooks 是互补的三条路。正确的复用,是在合适层级建立稳固的抽象边界。先搞清“要复用的东西是什么”,再决定用哪种方案实现,才能让未来的你与同事受益。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-12-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 组件复用的 3 种高级方案:抽离逻辑 vs 封装组件 vs 自定义 hooks
    • 快速对比
    • 方案一:抽离逻辑(跨 UI 的通用复用)
    • 方案二:封装组件(统一视觉与交互协议)
    • 方案三:自定义 hooks(复用有状态逻辑)
    • 选型指南:怎么选最合适的?
    • 常见误区与规避
    • 工程实践建议
    • 小结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档