React 设计模式

117 阅读6分钟

React 设计模式

这是在 React 中使用的最重要设计模式的集合。由 Cosden Solutions 制作。

test.png

1. 单一职责原则

您的组件应该只有一个责任。它们应该只做“一件事”,并将其他所有事情委托给其他组件。以下是一个责任过多的组件示例:

// ❌ 责任过多!
function BigComponent() {
  // 负责多个无关的状态
  const [data, setData] = useState();
  const [isModalOpen, setIsModalOpen] = useState(false);

  // 负责获取数据
  useEffect(() => {
    fetch('/api/data')
      .then(response => response.json())
      .then(data => setData(data));
  }, []);

  // 负责发送分析事件
  useEffect(() => {
    sendAnalyticsEvent('page_view', { page: 'big_component' });
  }, []);

  // 负责切换模态框
  function toggleModal() {
    setIsModalOpen(prev => !prev);
  }

  // ... 其他代码
}

相反,创建多个具有单一职责的组件/钩子。

首先,创建 useFetchData.ts。这个钩子将持有 data 状态并管理获取和更新它。

// ✅ 单一职责:管理数据
export function useFetchData() {
  const [data, setData] = useState();

  useEffect(() => {
    fetch('/api/data')
      .then(response => response.json())
      .then(data => setData(data));
  }, []);

  return data;
}

或者更好的是,使用 react-query

// ✅ 单一职责:通过 react-query 管理数据
export function useFetchData() {
  return useQuery({
    queryKey: ['data'],
    queryFn: () => fetch('/api/data'),
  });
}

然后创建 usePageAnalytics.ts。这个钩子将通过 props 接收 event 并发送它。

type Event = {
  page: string;
};

// ✅ 单一职责:管理分析
export function usePageAnalytics(event: Event) {
  useEffect(() => {
    sendAnalyticsEvent('page_view', event);
  }, []);
}

最后创建 Modal.tsx。这个组件将接收 children 作为 props,并管理自己的 isModalOpen 状态。

type ModalProps = {
  children: React.ReactNode;
};

// ✅ 单一职责:管理模态框
export function Modal({ children }: ModalProps) {
  const [isModalOpen, setIsModalOpen] = useState(false);

  function toggleModal() {
    setIsModalOpen(prev => !prev);
  }

  return (
    <>
      <button onClick={toggleModal}>打开</button>
      {isModalOpen && children}
    </>
  );
}

这样,BigComponent 只需要导入并组合所有内容。现在它小巧、易于管理且高度可扩展。

import { useFetchData } from './useFetchData';
import { useAnalytics } from './useAnalytics';
import { Modal } from './Modal';

// ✅ 单一职责:将所有内容组合在一起
function BigComponent() {
  const data = useFetchData();

  useAnalytics();

  return <Modal>{/* ... 其他代码 */}</Modal>;
}

2. 容器和展示组件

为了保持代码的组织性,您可以将组件分为容器组件和展示组件。容器组件持有所有逻辑,而展示组件渲染 UI。

// 容器组件负责逻辑
function ContainerComponent() {
  const [items, setItems] = useState([]);
  const [filters, setFilters] = useState({});

  useEffect(() => {
    const filteredItems = filterItems(items, filters);
  }, [filters]);

  function handleFilters(newFilters) {
    setFilters(newFilters);
  }

  // ... 其他业务逻辑代码

  return <PresentationComponent items={items} />;
}

// 展示组件负责 UI
function PresentationComponent({ items }) {
  return (
    <>
      {/* ... 其他 UI 代码 */}
      {items.map(item => (
        <ItemCard key={item.id} item={item} />
      ))}
      {/* ... 其他 UI 代码 */}
    </>
  );
}

3. 复合组件模式

将一起使用的组件组合成一个复合组件,使用 React 上下文 API。

import { createContext, useState } from 'react';

const ToggleContext = createContext();

// 主组件导出以供项目使用
export default function Toggle({ children }) {
  const [on, setOn] = useState(false);

  function toggle() {
    setOn(!on);
  }

  return (
    <ToggleContext.Provider value={{ on, toggle }}>
      {children}
    </ToggleContext.Provider>
  );
}

// 附加到主组件的复合组件
Toggle.On = function ToggleOn({ children }) {
  const { on } = useContext(ToggleContext);
  return on ? children : null;
};

// 附加到主组件的复合组件
Toggle.Off = function ToggleOff({ children }) {
  const { on } = useContext(ToggleContext);
  return on ? null : children;
};

// 附加到主组件的复合组件
Toggle.Button = function ToggleButton(props) {
  const { on, toggle } = useContext(ToggleContext);
  return <button onClick={toggle} {...props} />;
};

现在这个组件可以在任何地方灵活使用。子组件可以任意顺序放置,或者只使用它们的一个子集:

import Toggle from '@/components/Toggle';

// 示例用法,包含所有组件
function App() {
  return (
    <Toggle>
      <Toggle.On>按钮开启</Toggle.On>
      <Toggle.Off>按钮关闭</Toggle.Off>
      <Toggle.Button>切换</Toggle.Button>
    </Toggle>
  );
}

// 示例用法,顺序不同
function App() {
  return (
    <Toggle>
      <Toggle.Button>切换</Toggle.Button>
      <Toggle.Off>按钮关闭</Toggle.Off>
      <Toggle.On>按钮开启</Toggle.On>
    </Toggle>
  );
}

// 示例用法,仅使用部分组件
function App() {
  return (
    <Toggle>
      <Toggle.Button>切换</Toggle.Button>
    </Toggle>
  );
}

4. 嵌套属性转发

当一个灵活组件使用另一个组件时,允许将属性转发到嵌套组件。

// 接收属性作为 `...rest`
function Text({ children, ...rest }) {
  return (
    <span className="text-primary" {...rest}>
      {children}
    </span>
  );
}

// 按钮组件使用 `Text` 组件作为其文本
function Button({ children, textProps, ...rest }) {
  return (
    <button {...rest}>
      {/* ✅ `textProps` 被转发 */}
      <Text {...textProps}>{children}</Text>
    </button>
  );
}

示例用法:

function App() {
  return (
    <Button textProps={{ className: 'text-red-500' }}>
      红色文本按钮
    </Button>
  );
}

5. 子组件模式

为了提高性能并防止不必要的重新渲染,提升组件并将其作为子元素传递。

function Component() {
  const [count, setCount] = useState(0);

  return (
    <div>
      {count}
      {/* ❌ 昂贵的组件会在每次 count 更改时不必要地重新渲染 */}
      <ExpensiveComponent />
    </div>
  );
}

ExpensiveComponent 移动到上面并作为子元素传递将防止其重新渲染。

// 组件
function Component({ children }) {
  const [count, setCount] = useState(0);

  // ✅ 子组件在状态变化时不会重新渲染
  return <Component>{children}</Component>;
}

// 应用
function App() {
  return (
    <Component>
      {/* ✅ 昂贵的组件在组件更新时不会重新渲染 */}
      <ExpensiveComponent />
    </Component>
  );
}

6. 自定义钩子

为了保持代码整洁和可重用,将相关功能提取到可以共享的自定义钩子中。

// ❌ 与 `items` 相关的所有代码直接在组件中。
function Component() {
  const [items, setItems] = useState([]);
  const [filters, setFilters] = useState({});

  useEffect(() => {
    const filteredItems = filterItems(items, filters);
  }, [filters]);

  function handleFilters(newFilters) {
    setFilters(newFilters);
  }

  // ... 其他代码
}

您可以创建 useFilteredItems.ts 并将所有功能放在那里。

// ✅ 与 `items` 相关的所有代码在自定义可重用钩子中
export function useFilteredItems() {
  const [items, setItems] = useState([]);
  const [filters, setFilters] = useState({});

  useEffect(() => {
    const filteredItems = filterItems(items, filters);
  }, [filters]);

  function handleFilters(newFilters) {
    setFilters(newFilters);
  }

  return {
    items,
    filters,
    handleFilters,
  };
}

然后在 Component 中可以使用该钩子。

// ✅ 组件更简洁,并可以共享过滤项的功能
function Component() {
  const { items, filters, handleFilters } = useFilteredItems();

  // ... 其他代码
}

7. 高阶组件 (HOC)

有时,创建一个高阶组件 (HOC) 来共享可重用功能是更好的选择。

function Button(props) {
  // ❌ 样式对象重复
  const style = { padding: 8, margin: 12 };
  return <button style={style} {...props} />;
}

function TextInput(props) {
  // ❌ 样式对象重复
  const style = { padding: 8, margin: 12 };
  return <input type="text" style={style} {...props} />;
}

通过 HOC,您可以创建一个包装组件,该组件接受一个组件及其 props,并增强它。

// ✅ 高阶组件实现样式
function withStyles(Component) {
  return props => {
    const style = { padding: 8, margin: 12 };

    // 将组件 props 与自定义样式对象合并
    return <Component style={style} {...props} />;
  };
}

// 内部组件通过 props 接收样式
function Button({ style, ...props }) {
  return <button style={style} {...props} />;
}
function TextInput({ style, ...props }) {
  return <input type="text" style={style} {...props} />;
}

// ✅ 使用 HOC 包装导出
export default withStyles(Button);
export default withStyles(TextInput);

8. 变体属性

如果您有在整个应用程序中共享的组件,请创建变体属性,以便使用预设值轻松自定义它们。

type ButtonProps = ComponentProps<'button'> & {
  variant?: 'primary' | 'secondary';
  size?: 'sm' | 'md' | 'lg';
};

function Button({ variant = 'primary', size = 'md', ...rest }: ButtonProps) {
  // ✅ 基于变体和大小派生样式
  const style = {
    ...styles.variant[variant],
    ...styles.size[size],
  };

  return <button style={style} {...rest} />;
}

// ✅ 明确定义每个变体/大小的样式的自定义对象
const styles = {
  variant: {
    primary: {
      backgroundColor: 'blue',
    },
    secondary: {
      backgroundColor: 'gray',
    },
  },
  size: {
    sm: {
      minHeight: 10,
    },
    md: {
      minHeight: 12,
    },
    lg: {
      minHeight: 16,
    },
  },
};

示例用法:

function App() {
  return (
    <div>
      <Button>主要按钮</Button>
      <Button variant="secondary" size="sm">
        次要按钮
      </Button>
    </div>
  );
}

9. 通过 ref 暴露功能

有时,通过 ref 从一个子组件导出功能到父组件是有用的。这可以通过 useImperativeHandle 钩子来实现。

type Props = {
  componentRef: React.RefObject<{ reset: () => void }>;
};

function Component({ componentRef }: Props) {
  const [count, setCount] = useState(0);

  // ✅ 通过 ref 向父组件暴露自定义重置函数以更改状态
  useImperativeHandle(componentRef, () => ({
    reset: () => {
      setCount(0);
    },
  }));

  return (
    <div>
      {count}
      <button onClick={() => setCount(count + 1)}>增加</button>
    </div>
  );
}

要使用它,只需在渲染它的同一组件中创建一个 ref。

function App() {
  const componentRef = useRef(null);

  return (
    <>
      <Component componentRef={componentRef} />

      {/* ✅ 使用 ref 我们可以重置 Component 的内部状态 */}
      <button onClick={() => componentRef.current?.reset()}>重置</button>
    </>
  );
}

10. 使用提供者共享常用数据

如果您有在多个组件之间共享的数据,考虑使用上下文 API 将其放入提供者中。

function Component1() {
  // ❌ 用户在多个组件中被获取
  const { data: user } = useFetchUser();

  // ❌ 不必要的重复检查未定义的用户
  if (!user) {
    return <div>加载中...</div>;
  }

  // ... 返回 JSX
}

function Component2() {
  // ❌ 用户在多个组件中被获取
  const { data: user } = useFetchUser();

  // ❌ 不必要的重复检查未定义的用户
  if (!user) {
    return <div>加载中...</div>;
  }

  // ... 返回 JSX
}

使用提供者,我们可以将所有这些功能放在一个组件内。

const UserContext = createContext(undefined);

function UserProvider({ children }) {
  // ✅ 用户获取在提供者中完成
  const { data: user } = useFetchUser();

  // ✅ 用户检查在提供者中完成
  if (!user) {
    return <div>加载中...</div>;
  }

  return (
    <UserContext.Provider value={{ user }}>{children}</UserContext.Provider>
  );
}

// 自定义钩子以便轻松访问上下文
export function useUser() {
  const context = useContext(UserContext);

  if (!context) {
    throw new Error('useUser 必须在 UserProvider 内部使用。');
  }

  return context;
}

在用它包装整个应用程序后,您可以在任何需要的地方使用共享功能...