React组件设计模式

0 阅读18分钟

本文所阐述的设计模式并不是编程通用的设计模式,如大家熟悉的单例模式、工厂模式等等。而是在设计 React 组件时的一些解决方案与技巧。

React 的设计模式不仅有助于简化开发过程,在单独的组件上工作并在组件之间共享逻辑,帮助减轻 React 开发团队的巨大压力还可以确保 React 开发人员遵循 React 开发最佳实践,提供了标准术语和常见问题的解决方案,避免代码冲突或错误,并优化整个流程。

下面我们将介绍常见的几种react组件设计模式。

Render Props模式

render prop是指使用函数类型的 prop 在 React 组件之间共享代码的技术。在这种模式中,一个组件接收一个函数作为属性(称为 render prop),并在组件内部调用这个函数以呈现子组件。通过将 render prop 作为参数传递给这个函数,可以在组件之间灵活地共享逻辑和状态。这使得组件更具可重用性和可扩展性。使用渲染属性的库包括React Router等。

基本实现

const RenderPropsComponent = ({ render }) => {
  const data = "Render Props Example Data";
  // 调用传递进来的 render 函数,并将数据作为参数传递给它
  return render(data);
};
 <RenderPropsComponent render={(data) => <p>{data}</p>} />

使用场景

基于render鼠标位置监听

假设我们现在要获取鼠标当前的位置,使用render实现

import  { useEffect, useState } from 'react';
// 使用render
const MoveCom  = ({render}) => {
  const [state,setState] = useState({x:0,y:0})
  const moveHandler = e => { 
    setState({
        x: e.clientX,
        y:e.clientY
    })
  }
  useEffect(()=>{
    window.addEventListener('mousemove',moveHandler)
  })
  return render(state) 
}
export default MoveCom

{ /* 接受抛出来的数据*/}
  <MoveCom render={data => {
    return (
      <p>当前鼠标的坐标横坐标: {data.x }  纵坐标: {data.y }</p>
    )
  }}></MoveCom>

基于children鼠标位置监听

render prop 是因为模式才被称为 render prop ,但不一定要用名为 render 的 prop 来使用这种模式。事实上, 任何被用于告知组件需要渲染什么内容的函数 prop 在技术上都可以被称为render prop。所以我们也可以基于children实现使用children实现

import  {  useEffect, useState } from 'react';
// 使用render
const MoveCom  = (props) => {
  const [state,setState] = useState({x:0,y:0})
  const moveHandler = e => { 
    setState({
        x: e.clientX,
        y:e.clientY
    })
  }
  useEffect(()=>{
    window.addEventListener('mousemove',moveHandler)
  })
  console.log("props",props)
  const allProps = {state, ...props};
  return props.children(allProps);
   
}
export default MoveCom


 <MoveComChildren name={"hello"}>
    {
      ({state,name}) => {
        return (
          <>
          <p style={{ position:'absolute', left:state.x, top:state.y }}>
            横坐标: {state.x }  纵坐标: {state.y }
          </p>
          <p>props:{name}</p>
          </>              
        )
      }
    }
 </MoveComChildren>

image.png

鉴权

和高阶组件一样,render props 可以做很多的定制功能,我们还是以根据是否登录状态来显示一些界面元素为例,来实现一个 render props。

  • 实现 render props 的 Login 组件,可以看到,render props 和高阶组件的第一个区别,就是 render props 是真正的 React 组件,而不是一个返回 React 组件的函数
  • 当用户处于登录状态,当前token,否则返回空,然后我们根据这个结果决定是否渲染 props.children 返回的结果。当然,render props 完全可以决定哪些 props 可以传递给 props.children,在 Login 中,我们把 token作为增加的 props 传递给下去,这样就是 Login 的增强功能。这个时候我们也可以对扩展 Login,不光在用户登录时显示一些东西,也可以定制用户没有登录时显示的东西
const Login = (props) => {
  const token = localStorage.getItem("token") ;
  if (token) {
    const allProps = {token, ...props};
    return (
      <React.Fragment>
        {/* {props.children(allProps)} */}
        {props.login(allProps)}
      </React.Fragment>
    );
  } else {
    // return null;
    return (
      <React.Fragment>
      {props.nologin(props)}
    </React.Fragment>
    )
  }

};
export default Login
// 使用
<Login
    login={({token}) => <h1>Hello {token}</h1>}
    nologin={() => <h1>Please login</h1>}
  />

render props 相对于高阶组件还有一个显著优势,就是对于新增的 props 更加灵活。还是以登录状态为例,假如我们使用高阶组件扩展 withLogin 的功能,让它给被包裹的组件传递用户名这个 props,这就要求被 withLogin 包住的组件要接受 userName 这个props。可是,假如有一个现成的 React 组件不接受 userName,却接受名为 name 的 props 作为用户名,这就麻烦了。我们就不能直接用 withLogin 包住这个 React 组件,还要再造一个组件来做 userNamename 的映射,十分费事。代码如下:

const withLogin = (Component) => {
  const NewComponent = (props) => {
    const userName= getUserName();
    if (userName) {
      return <Component {...props} userName={userName}/>;
    } else {
      return null;
    }
  }
  return NewComponent;
};

对于应用 render props 的 Login,就不存在这个问题,接受 name 不接受 userName 就直接在return中修改props的名字就好。

<Login>
  {
    (props) => {
      const {userName} = props;
      return <TheComponent {...props} name={userName} />
    }
  }

</Login>

所以,当需要重用 React 组件的逻辑时,建议首先看这个功能是否可以抽象为一个简单的组件;如果行不通的话,考虑是否可以应用 render props 模式;再不行的话,才考虑应用高阶组件模式。

常规的使用场景有以下几种

1. 复用组件逻辑: 通过将共享的代码逻辑封装为一个函数,并将该函数作为 prop 传递给组件,可以实现在不同的组件中复用相同的逻辑。这样可以避免代码重复和维护多个相似的组件。

2. 动态组件渲染: 使用 Render Props 可以根据条件或状态动态地决定要渲染的组件。通过将渲染逻辑封装为一个函数,并将该函数作为 prop 传递给包含条件或状态的父组件,可以实现根据不同条件渲染不同的组件。

3. 数据获取和处理:Render Props 可以用于数据获取和处理的场景。通过将数据获取和处理的逻辑封装为一个函数,并将该函数作为 prop 传递给组件,可以实现将数据获取和处理的责任委托给父组件或外部组件,从而实现更灵活的数据获取和处理方式。

高阶组件模式

在React组件开发过程中,很容易出现重复实现的逻辑,例如使用相同的设计元素增强各种卡片的视图、需要登录用户数据的应用程序组件等,这个时候我们常用的处理方法就是将公共逻辑提取成一个React组件。但是有时候这些公共的逻辑无法称为一个独立的组件。例如退出登录按钮只有在用户登录之后显示,对应这些模块的 React 组件如果连“只有在登录时才显示”的功能都重复实现,就显得有点多余。这是我们可以使用高阶组件(HOC)。

高阶组件比起集成它更偏向于组合,在React框架中很多地方都使用了HOC,例如Redux的连接。当我们使用Redux的时候,通过connect函数传递组件,则传递的组件将注入来自 Redux 存储的一些数据,并且这些值将作为 Props 传递。可以使用相同的函数“connect”来传递任何组件,以获得访问 Redux 存储的相同能力。

基本实现

高阶组件实际上是一个函数,这个函数接受至少一个React组件作为参数,并且可以返回全新的一个React组件作为结果。一般情况下高阶组件命名带with前缀。

const withDoSomething= (Component) => {
  const NewComponent = (props) => {
    return <Component {...props} />;
  };
  return NewComponent;
};

应用场景

props增强

我们可以通过高阶组件对传入的组件进行props增强, 也就是为传入的组件添加一些参数, 需要注意的是, 如果传入的组件本身也有传递参数的话, 我们在为组件注入要增强的参数的同时, 还需要将本身传递的参数也注入进来。

原始组件

function OriginalComponent(props) {
  return (
    <div>
      <p>原始组件</p>
      <p>增强的props属性: {props.additionalProp}</p>
      <p>原本传递的参数: {props.parameter}</p>
    </div>
  );
}

高阶组件

// 高阶组件
function withEnhancedProps(WrappedComponent) {
  return class extends React.Component {
    render() {
      // 增强传入的 props
      const enhancedProps = {
        ...this.props,
        additionalProp: "This is an additional prop",
      };
      // 合并增强的props和传递的参数
      const mergeProps = {
        ...enhancedProps,
        ...this.props, // 传递的参数
      };

      // 将增强的 props 传递给被包裹的组件
      return <WrappedComponent {...mergeProps} />;
    }
  };
}

这种方式,可以使用高阶组件来实现在不修改原始组件代码的情况下,增强组件的功能和属性

基于HOC的鼠标位置监听

import React, { useState, useEffect } from "react";

const withMousePosition = (WrappedComponent) => {
  return (props) => {
    const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });

    const handleMouseMove = (event) => {
      setMousePosition({ x: event.clientX, y: event.clientY });
    };

    useEffect(() => {
      window.addEventListener("mousemove", handleMouseMove);

      return () => {
        window.removeEventListener("mousemove", handleMouseMove);
      };
    }, []);

    return (
      <WrappedComponent
        {...props}
        mouseX={mousePosition.x}
        mouseY={mousePosition.y}
      />
    );
  };
};

export default withMousePosition;
// 参数组件
const MousePositionDisplay = ({ mouseX, mouseY }) => {
  return (
    <div>
      <h2>Mouse Position:</h2>
      <p>
        X: {mouseX}, Y: {mouseY}
      </p>
    </div>
  );
};

export default withMousePosition(MousePositionDisplay);

鉴权

某些页面是必须用户登录成功才能进行进入;如果用户没有登录成功,那么直接跳转到登录页面;

function WithloginAuth(Cpn1, Cpn2) {
  return (props) => {
    // 从本地存储中获取token, token有值表示已登录, 没有值表示未登录
    const token = localStorage.getItem("token");
    if (token) {
      return <Cpn1 {...props} />;
    } else {
      return <Cpn2 />;
    }
  };
}
const Login = () => {
  return (
    <>
      <h2>请先登录, 再跳转到对应的页面中</h2>
      <button onClick={() => localStorage.setItem("token", "123")}>登录</button>
    </>
  );
};
const Cart = (props) => {
  return (
    <>
      <h2>购物车页面,欢迎用户{props.name}</h2>
      <button onClick={() => localStorage.removeItem("token")}>退出</button>
    </>
  );
};

const AuthComponent = WithloginAuth(Cart, Login);

HOC需要在原组件上进行包裹或者嵌套,如果大量使用HOC,将会产生非常多的嵌套,这让调试变得非常困难,HOC可以劫持props,在不遵守约定的情况下也可能造成冲突;

注意:

refs不会被传递

虽然高阶组件的约定是将所有 props 传递给被包装组件,但这对于 refs 并不适用。那是因为 ref 实际上并不是一个 prop ,它是由 React 专门处理的。如果将 ref 添加到 HOC 的返回组件中,则 ref 引用指向容器组件,而不是被包装组件。这个问题的解决方案是通过使用 React.forwardRef API

不要在render的地方使用

React 的 diff 算法使用组件标识来确定它是应该更新现有子树还是将其丢弃并挂载新子树。 如果从 render 返回的组件与前一个渲染中的组件相同,则递归更新子树。 如果它们不相等,则完全卸载前一个子树。如果在组件之外创建 HOC,这样一来组件只会创建一次,如果在render中使用HOC,每次都会生成一个新的组件,导致性能问题。同时,重新挂载组件会导致该组件及其所有子组件的状态丢失。如果需要动态调用 HOC.你可以在组件的生命周期方法或其构造函数中进行调用。

Provider模式

Provider模式的目的在于让React组件树的各个组件之间共享全局数据。Provider模式中有一个Provider组件,保存了全局数据,可以通过Consumer组件和自定义Hook将它传递到应用程序的组件树中。要理解Provider设计模式首先我们要对React的Context 有所了解。

Context 主要是为了解决Prop Drilling问题。它帮助开发人员将数据存储在React Context对象的集中位置,该位置使用React Context API 和 Redux 存储。Context Provider 或 Redux Store 可以直接将此数据传递给任何需要它的组件,而无需进行 props Drilling。

Prop Drilling是一种在React组件树中逐级传递属性(props)的方法。在这种模式下,数据或状态从组件树的顶部向下传递,直到它到达需要这些数据的组件。在传递过程中,中间组件并不需要这些属性,但仍然需要将它们向下传递。Prop Drilling 在简单的应用程序中可能没有问题,但在更大、更复杂的应用程序中,它可能导致冗余代码、难以维护的结构和难以跟踪的数据流

基本实现

  • 首先要创建一个Context对象,用于定义要共享的数据和默认值;
  • 使用Context对象的Provider包裹组件树;
  • 在后代组件中使用数据,使用 useMyContext 钩子来访问共享的数据;

例如很多不同层级的组件需要访问同样一些的数据。如组件a、组件g、组件f需要共享数据,则只需要在最外层套上Provider,需要共享的组件使用Consumer即可。

 // 数据提供
function Provider() {
  const sharedData = "我是共享数据";
  return (
    <div>
      <h2>Provider组件设计模式</h2>
      <MyContext.Provider value={sharedData}>
        <div
          style={{
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
          }}
        >
          <ChildrenA />
          <ChildrenB />
        </div>
      </MyContext.Provider>
    </div>
  );
}
      
// 数据获取
const ChildComponent = () => {
  return (
    <>
      <div
        style={{ border: "1px pink solid", width: "200px", height: "100px" }}
      >
        <span>这里是孩子A</span>
        <MyContext.Consumer>
          {(value) => <p>信息:{value}</p>}
        </MyContext.Consumer>
        <GrandSonAComponent />
      </div>
    </>
  );
};

使用场景

  1. 主题和样式设置: 如果希望在整个应用共享主题设置、样式变量或用户偏好设置,可以使用 Provider 组件模式来提供这些设置。
  2. 用户登录状态: 在用户登录和认证方面,Provider 组件模式可用于管理用户的登录状态、用户信息以及登录/登出操作。
  3. 国际化: 如果你需要在整个应用中实现国际化和多语言支持,可以使用 Provider 组件来提供当前语言和翻译函数。

Provider 组件简化了通讯,实现了整个组件树中共享状态,避免了通过 props 一层层传递数据的复杂性。但是使用 Provider 模式可能会导致组件之间的一些耦合,因为它们共享了相同的状态,这可能会影响组件的独立性。过于频繁地更新 Provider 中的状态可能导致不必要的重新渲染,影响性能。当 Provider 中的数据被多个组件共享和更新时,需要注意数据的一致性和更新时机,避免数据不同步。

展示和容器组件模式

展示和容器组件(有状态组件和无状态组件)的主要目的是找到一种更简单的方法来重用React应用程序组件,其工作原理是将组件分成展示组件和容器组件。

展示组件:**主要关心如何展示UI,**只根据自身 state 及接收自父组件的 props 做渲染,并不直接与外部数据源进行沟通。展示组件一般是纯函数组件,只负责展示数据和触发事件,例如按钮、表格等

容器组件:只关注应用的数据逻辑和状态管理。容器组件通常是类组件或者React Hooks函数组件,负责将数据和回调函数作为props传递给展示组件,以便展示组件根据这些数据进行渲染,并在时间触发时通知容器组件。常见的容器组件,例如:数据获取、表单处理等

基本实现

展示组件

const DisplayComponent = ({ data }) => {
  return (
    <div>
      <h1>{data.title}</h1>
      <p>{data.content}</p>
    </div>
  );
};

容器组件

const ContainerComponent = () => {
  const [data, setData] = useState({
    title: "Hello",
    content: "This is some content.",
  });

  const updateContent = () => {
    setData({
      title: "Updated Title",
      content: "Updated content here.",
    });
  };

  return (
    <div>
      <h2>Container Component</h2>
      <button onClick={updateContent}>Update Content</button>
      <DisplayComponent data={data} />
    </div>
  );
};

容器展示组件这个模式所解决的问题在于,当我们切换数据获取方式时,只需在容器组件修改相应逻辑即可,展示组件无需做改动。比如现在我们获取数据源是通过普通的 fetch 请求,那么将来改成 redux 或者 mobx 作为数据源,我们只需聚焦到容器组件去修改相应逻辑即可,展示组件可完全不变,展示组件有了更高的可复用性。但该模式的缺点也在于将一个组件分成了两部分,增加了代码跳转的成本。

应用场景

展示组件和容器组件在 React 应用中有许多常见的应用场景,例如:

待办事项应用:展示组件展示单个待办事项,显示任务名称和完成状态。容器组件管理待办事项的列表,处理添加、删除、等操作。

博客列表页面:展示组件渲染单个博客文章的摘要、标题和作者信息。容器组件从 API 获取博客文章列表,传递数据给展示组件。

用户配置页面:展示组件显示用户的个人信息、偏好设置等。容器组件处理用户信息的获取和更新,管理数据状态。

总的来说,展示组件和容器组件模式有助于分离关注点、提高代码的可维护性,并使代码更易于测试和重用。展示组件与具体的业务逻辑解耦,可以在不同的地方被重用,同时值依赖于传入的属性,不需要关心数据源。而容器组件可以专心处理数据和复杂的业务逻辑,方便单独测试,更加灵活。

组合组件模式

组合模式的本质上是为了解决组件之间传值的问题,希望减少上下级组件之间props的传递,组合组件模式通过API在父子组件之间建立了无缝通信,父组件可以隐地与子组件交互并共享状态。主要做法是将组件划分为更小、更具功能性的部分,并将这些部分组合起来,进一步提高代码的可读性、可维护性和可重用性。

基本实现

可以将一个Card分为Title和Content两个小组件,再进行组合.

拆分

const Card = ({ children }) => {
  return <div>{children}</div>;
};
const Title = ({ children }) => {
  return <h2>{children}</h2>;
};
const Content = ({ children }) => {
  return <div>{children}</div>;
};

组合

 <Card>
    <Title>Card Title</Title>
    <Content>Card Content</Content>
 </Card>

应用场景

当我们希望减少上下级组件之间props的传递,简单来说就是不用传做显式地传值,来达到组件之间相互通信的目的举例来说,某些界面中应该有Tabs这样的组件,由Tab和TabItem组成,点击每个TabItem,该TabItem会高亮, 那么Tab和TabItem自然要进行沟通。很自然的写法是像下面这样

 <TabItem active={true} onClick={onClick}>
    One
  </TabItem>
  <TabItem active={false} onClick={onClick}>
    Two
  </TabItem>
  <TabItem active={false} onClick={onClick}>
    Three
  </TabItem>

这样的缺点很明显:

  • 每次使用 TabItem 都要传递一堆 props
  • 每增加一个新的 TabItem,都要增加对应的 props
  • 如果要增加 TabItem,就要去修改 Tabs 的 JSX 代码

但是,组件之间的交互我们又不希望通过props或者context来实现。希望用法如下面一样简洁。

<Tabs>
  <TabItem>1</TabItem>
  <TabItem>2</TabItem>
  <TabItem>3</TabItem>
</Tabs>

这时我们可以使用组和模式,TabItem组件有两个关键的props: active(表明当前是否应高亮),onTabClick(自己被点击时调用的回调函数), TabItem由于是每个Tab页面的容器,它只负责把props.children渲染出来,所以用函数式组件即可。

export const TabItem = (props) => {
  const { active, onTabClick, children } = props;
  console.log("aaa", active, onTabClick);
  const style = {
    color: active ? "red" : "green",
    cursor: "pointer",
  };
  return (
    <>
      <h1 style={style} onClick={onTabClick}>
        {children}
      </h1>
    </>
  );
};
export const NewTabs = (props) => {
  const [activeIndex, setActivatIndex] = useState(0);
  const newChildren = React.Children.map(props.children, (child, index) => {
    if (child.type) {
      // 复制并修改children
      return React.cloneElement(child, {
        active: activeIndex === index,
        onTabClick: () => setActivatIndex(index),
      });
    } else {
      return child;
    }
  });
  return <div>{newChildren}</div>;
}

  1. 容器组件:当需要在应用中多次使用相同的容器样式时,可以通过组合创建一个通用的容器组件。例如,可以创建一个带有相同边框、背景颜色和内边距的卡片容器来包裹不同的内容。
  2. 布局组件:组合可以用于创建应用的布局组件,如网格、侧边栏或导航。通过组合,您可以轻松地在应用中重用和自定义布局组件。
  3. 可配置组件:组合可以让创建可配置的组件,即根据你传递给它的子组件来改变其行为的组件。例如,一个模态对话框组件可以根据传递给它的内容来呈现不同的信息。
  4. 表单列表等组件:例如,可以创建一个通用的列表组件,它知道如何呈现特定类型的子项。这样,即使列表内容发生变化,也无需更改列表组件的代码。

条件渲染模式

在很多情况下,开发人员要根据条件渲染元素来创建React屏幕。例如当我们提供一个身份验证选项,我们创建一个“注销”按钮,该按钮在用户登录程序时保持可见状态,创建一个“登录”按钮,在用户注销或访问应用程序的时候保持可见状态。这种,根据特定的条件渲染UI元素或者执行逻辑的过程在React中称为条件渲染设计模式。

基本实现

常用的条件渲染设计模式有:三元操作符、逻辑与和短路操作符、立即执行函数表达式、if条件渲染等方法

三元运算符

function threeComponent(props: any) {
  return <div>{props.user ? `hello, ${props.user}` : "please sign in"}</div>;
}

逻辑操作符

function OptComponent(props: any) {
  return (
    <>
      <div>{props.user && `hello, ${props.user}存在`}</div>
      <div>{props.user || "用户不存在"}</div>
    </>
  );
}

立即执行函数(IIFE)

function IIFEComponent(props: any) {
  const { user } = props;
  return (
    <div>
      {(() => {
        if (user) {
          return `Hello, ${user}`;
        } else if (user === null) {
          return "Please sign in";
        } else {
          return "Loading...";
        }
      })()}
    </div>
  );
}

If条件渲染

function IFComponent(props: any) {
  const { user } = props;
  function fun(user) {
    if (user) {
      return `Hello, ${user.name}`;
    } else {
      return "Please sign in";
    }
  }
  return <div>{fun(user)}</div>;
}

应用场景

  1. 认证与授权: 在应用程序中,根据用户的认证状态和权限,你可能会根据条件来渲染不同的界面元素。例如,登录后显示用户个人资料,未登录时显示登录按钮。
  2. 用户权限管理: 如果你的应用有不同的用户角色和权限级别,你可以根据用户的角色条件渲染不同的功能区块。例如,管理员和普通用户可能会看到不同的管理面板。
  3. 表单校验和错误提示: 在表单中,你可以根据表单元素的状态(如校验错误)来渲染错误提示信息,以帮助用户更好地理解问题并提供修复。
  4. 国际化(多语言支持): 根据用户的语言设置,你可能需要根据条件在界面上渲染不同的文本和标签。
  5. 移动设备适配: 在移动设备和桌面设备上可能需要根据屏幕大小和分辨率来渲染不同的布局和组件。
  6. 交互状态: 根据用户的操作和选择,你可以在界面上渲染不同的状态,比如加载中、成功、失败等。