在 React 组件中抽离出相同的业务逻辑

Extract the same logic in React components

Posted by Reckless on November 6, 2023

React 中,代码复用的最基本单位就是组件,但是如果组件中也出现了重复的代码,该怎么做呢?

那么我们需要通过某种方式将代码中公共的部分抽取出来,这些公共的部分就被称之为横切关注点(Cross-Cutting Concerns

React 中,常见有两种方式来进行横切关注点的抽离:

  • 高阶组件(HOC
  • Render Props

Render Props 实际上本身并非什么新语法,而是指一种在 React 组件之间使用一个值为函数的 prop 共享代码的简单技术。

Render Props

Render Props 用于抽离出完全相同的逻辑,并用于不同的组件展示。它传入的 prop 值为一个函数,并且这段函数返回一段 JSX 用于视图渲染。

先看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ./App.jsx
import Move1 from "./components/Move1.jsx"
import Move2 from "./components/Move2.jsx"

function App() {
    const appStyle = {
        display: 'flex',
        justifyContent: 'space-between',
        width: "850px"
    }
    return (
        <div style={appStyle}>
          <Move1 />
          <Move2 />
        </div>
    );
}

export default App
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ./components/Move1.jsx
import { useState } from "react";

const Move1 = () => {

    const [points, setPoints] = useState({x: 0, y: 0})

    const handleMouseMove = e => setPoints({x: e.clientX, y: e.clientY})

    const commonStyle = {
        width: 400,
        height: 400,
        backgroundColor: "deeppink"
    }
    
    return(
        <div onMouseMove={handleMouseMove}
             style={commonStyle}
        >
            <h1>移动鼠标!</h1>
            <p>当前的鼠标位置是 ({points.x}, {points.y})</p>
        </div>
    )
}

export default Move1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// ./components/Move2.jsx
import { useState } from "react";

const Move2 = () => {
    
    const [points, setPoints] = useState({x: 0, y: 0})

    const handleMouseMove = e => setPoints({x: e.clientX, y: e.clientY})
    
    const commonStyle = {
        width: 400,
        height: 400,
        backgroundColor: "lightgray",
        position: "relative"
    }

    const ballStyle = {
        width: '15px',
        height: '15px',
        borderRadius: "50%",
        backgroundColor: 'white',
        position: 'absolute',

        // 这里减去 460 是因为要减去左边 div 的宽度 + 两个大 div 之间 50 的间距
        left: props.points.x - 5 - 460,
        top: props.points.y - 5 - 10,
    }
    
    return(
        <div onMouseMove={handleMouseMove}
             style={commonStyle}
        >
            <div style={ballStyle}></div>
        </div>
    )
}

export default Move2

在上面的代码中,App 根组件下渲染了两个子组件,这两个子组件一个是显示鼠标的位置,另外一个是根据鼠标位置显示一个追随鼠标移动的小球。

仔细观察代码,你会发现这两个子组件内部的逻辑基本上是一模一样的,只是最终渲染的内容不一样,此时我们就可以使用 Render Props 对横切关注点进行一个抽离。

方式也很简单,就是在一个组件中使用一个值为函数的 prop,函数的返回值为要渲染的视图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ./components/MouseMove.jsx
import { useState } from 'react';

const MouseMove = props => {

    const [points, setPoints] = useState({
        x: 0,
        y: 0
    })

    const handleMouseMove = e => {
        setPoints({
            x: e.clientX,
            y: e.clientY
        })
    }
    
    return (
        props.render && props.render({points, handleMouseMove}) 
    )
}

export default MouseMove

在上面的代码中,我们新创建了一个 MouseMove 组件,该组件就封装了之前 Move1 和 Move2 组件的公共逻辑。该组件的 props 接收一个名为 render 的参数,只不过该参数对应的值为一个函数,我们调用时将对应的状态和处理函数传递过去,该函数的调用结果为返回一段视图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ./App.jsx
import Move1 from "./components/move1"
import Move2 from "./components/move2.jsx";
import MouseMove from "./components/MouseMove.jsx";

function App() {
    
    const appStyle = {
        width: 850,
        display: "flex",
        justifyContent: "space-between"
    }

    return (
        <div style={appStyle}>
            {/* 这里不同通过 props,通过 children 可实现相同的效果 只需要在 MouseMove 组件中修改渲染的逻辑  */}
            <MouseMove render={props => <Move1 {...props} />} />
            <MouseMove render={props => <Move2 {...props} />} />
        </div>
    )
}

export default App

接下来在 App 根组件中,我们使用 MouseMove 组件,该组件上有一个 render 属性,对应的值就是函数,函数返回要渲染的组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ./components/Move1.jsx
const Move1 = (props) => {

    const commonStyle = {
        width: 400,
        height: 400,
        backgroundColor: "deeppink"
    }
    
    return(
        <div onMouseMove={props.handleMouseMove}
             style={commonStyle}
        >
            <h1>移动鼠标!</h1>
            <p>当前的鼠标位置是 ({props.points.x}, {props.points.y})</p>
        </div>
    )
}

export default Move1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// ./components/Move2.jsx
const Move2 = (props) => {
    
    const commonStyle = {
        width: 400,
        height: 400,
        backgroundColor: "lightgray",
        position: "relative"
    }
    
    const ballStyle = {
        width: '15px',
        height: '15px',
        borderRadius: "50%",
        backgroundColor: 'white',
        position: 'absolute',
        
        // 这里减去 460 是因为要减去左边 div 的宽度 + 两个大 div 之间 50 的间距
        left: props.points.x - 5 - 460,
        top: props.points.y - 5 - 10,
    }

    return(
        <div onMouseMove={props.handleMouseMove}
             style={commonStyle}
        >
            <div style={ballStyle}></div>
        </div>
    )
}

export default Move2

最后就是子组件 Move1 和 Move2 的改写,可以看到这两个子组件就只需要书写要渲染的视图了。公共的逻辑已经被 MouseMove 抽取出去了。

另外需要说明的是,虽然这个技巧的名字叫做 Render Props,但不是说必须要提供一个名为 render 的属性,事实上,封装公共逻辑的组件(例如上面的 MouseMove)只要能够得到要渲染的视图即可,所以传递的方式可以有多种。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ./App.jsx
import ChildCom1 from "./components/Move1.jsx";
import ChildCom2 from "./components/Move2.jsx";
import MouseMove from "./components/MouseMove.jsx";

function App() {
    const appStyle = {
        display: 'flex',
        justifyContent: 'space-between',
        width: "850px"
    }
    return (
        <div style={appStyle}>
            <MouseMove>
                {(props) => <Move1 {...props} />}
            </MouseMove>
            <MouseMove>
                {props => <Move2 {...props} />}
            </MouseMove>
        </div>
    )
}

export default App

上面使用 MouseMove 组件时,并没有传递什么 render 属性,而是通过 props.children 的形式将要渲染的视图传递到了组件内部。

在 MouseMove 组件内部,就不再是执行 render 方法了,而是应该执行 props.children,如下:

props.children && props.children({ points, handleMouseMove })

高阶组件 HOC(Higher-Order Components)

何时使用 Render Props

同样是作为抽离横切关注点,前面所讲的 HOC 也能做到相同的效果。例如我们可以将上面的示例修改为 HOC 的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// HOC/withMouseMove.js

import { useState } from "react";
function withMouseMove(Com) {
  // 返回一个新组件
  return function NewCom() {
    const [points, setPoints] = useState({
      x: 0,
      y: 0,
    });
    function handleMouseMove(e) {
      setPoints({
        x: e.clientX,
        y: e.clientY,
      });
    }

    const mouseHandle = { points, handleMouseMove };
    return <Com {...mouseHandle} />;
  };
}

export default withMouseMove
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// App.jsx

import ChildCom1 from "./components/Move1.jsx";
import ChildCom2 from "./components/Move2.jsx";
import withMouseMove from "./HOC/withMouseMove"

const NewChildCom1 = withMouseMove(ChildCom1);
const NewChildCom2 = withMouseMove(ChildCom2);

function App() {
    const style = {
        display: 'flex',
        justifyContent: 'space-between',
        width: "850px"
    }
    return (
        <div style={style}>
          <NewChildCom1 />
          <NewChildCom2 />
        </div>
    );
}

export default App

那么自然而然疑问就来了,什么时候使用 Render Props ?什么时候使用 HOC ?

一般来讲,Render Props 应用于组件之间功能逻辑完全相同,仅仅是渲染的视图不同。这个时候我们可以通过 Render Props 来指定要渲染的视图是什么。

而 HOC 一般是抽离部分公共逻辑,也就是说组件之间有一部分逻辑相同,但是各自也有自己独有的逻辑,那么这个时候使用 HOC 比较合适,可以在原有的组件的基础上做一个增强处理。

未完待续…后续补充如何用自定义 hook 替代 Render Props



App ready for offline use.