浅谈 React 组件设计

共 22669字,需浏览 46分钟

 ·

2022-11-22 14:12

大厂技术  高级前端  Node进阶

点击上方 程序员成长指北,关注公众号

回复1,加入高级Node交流群

前言

前端组件化一直是老生常谈的话题,在前面介绍 React 的时候我们已经提到过 React 的一些优势,今天则是带大家了解一下组件设计原则。

jQuery 插件

在开始讲 React 组件之前,我们还是要先来聊聊 jQuery。在我看来,jQuery 插件就已经具备了组件化的雏形。

在 jQuery 还大行其道的时代,我们在网上可以看到一些 jQuery 插件网站,里面有各种丰富的插件,比如轮播图、表单、选项卡等等。

组件?插件?

组件和插件的区别是什么呢?插件是集成到某个平台上的,比如 Jenkins 插件、Chrome 插件等等,jQuery 插件也类似。平台只提供基础能力,插件则提供一些定制化的能力。而组件则是偏向于 ui 层面的,将 ui 和业务逻辑封装起来,供其他人使用。

封装 DOM 结构

在一些最简单无脑的 jQuery 插件中,它们一般会将 DOM 结构直接写死到插件中,这样的插件拿来即用,但限制也比较大,我们无法修改插件的 DOM 结构。

// 轮播图插件
$("#slider").slider({
    config: {
        showDottrue// 是否展示小圆点
        showArrowtrue // 是否展示左右小箭头
    }, // 一些配置
    data: [] // 数据
})

还有另一种极端的插件,它们完全不把 DOM 放到插件中,但会要求使用者按照某种固定格式的结构来组织代码。一旦结构不准确,就可能会造成插件内部获取 DOM 出错。但这种插件的好处在于可以由使用者自定义具体的 DOM 结构和样式。

<div id="slider">
    <ul class="list">
        <li data-index="0"><img src="" /></li>
        <li data-index="1"><img src="" /></li>
        <li data-index="2"><img src="" /></li>
    </ul>

    <a href="javascript:;" class="left-arrow"><</a>
    <a href="javascript:;" class="left-arrow">></a>
    <div class="dot">
        <span data-index="0"></span>
        <span data-index="1"></span>
        <span data-index="2"></span>
    </div>

</div>

$("#slider").slider({
    config: {} /
/ 配置
})

当然,你也可以选择将 DOM 通过配置传给插件,插件内部去做这些渲染的工作,这样的插件比较灵活。有没有发现?这和 render props 模式非常相似。

$("#slider").slider({
    config: {}, // 配置
    components: {
        dot(item, index) => `<span data-index=${index}></span>`,
        item(item, index) => `<li data-index=${index}><img src=${item.src} /></li>`
    }
})

React 组件设计

前面讲了几种 jQuery 插件的设计模式,其实万变不离其宗,不管是 jQuery 还是 React,组件设计思想都是一样的。

image_1e5jp360218jbi4amj918oin89m.png-39.4kB

个人觉得,组件设计应该遵循以下几个原则:

  1. 适当的组件粒度:一个组件尽量只做一件事。
  2. 复用相同部分:尽量复用不同组件相同的部分。
  3. 松耦合:组件不应当依赖另一个组件。
  4. 数据解耦:组件不应该依赖特定结构的数据。
  5. 结构自由:组件不应该封闭固定的结构。

容器组件与展示组件

顾名思义,容器组件就是类似于“容器”的组件,它可以拥有状态,会做一些网络请求之类的副作用处理,一般是一个业务模块的入口,比如某个路由指向的组件。我们最常见的就是 Redux 中被 connect 包裹的组件。容器组件有这么几个特点:

  1. 容器组件常常是和业务相关的。
  2. 统一的数据管理,可以作为数据源给子组件提供数据。
  3. 统一的通信管理,实现子组件之间的通信。

展示组件就比较简单的多,在 React 中组件的设计理念是 view = f(data),展示组件只接收外部传来的 props,一般内部没有状态,只有一个渲染的作用。

image_1e5813mbgbmvc623215qo6pf9.png-29.5kB

适当的组件粒度

在项目开发中,可能你会看到懒同事一个几千行的文件,却只有一个组件,render 函数里面又臭又长,让人实在没有读下去的欲望。在写 React 组件中,我见过最恐怖的代码是这样的:

function App() {
    let renderHeader,
        renderBody,
        renderHTML
    if (xxxxx) {
        renderHeader = <h1>xxxxx</h1>
    } else {
        renderHeader = <header>xxxxx</header>
    }
    if (yyyyy) {
        renderBody = (
            <div className="main">
                yyyyy
            </div>
        )
    } else {
        ...
    }
    if (...) {
        renderHTML = ...
    } else {
        ...
    }
    return renderHTML
}

当我看到这个组件的时候,我想要搞清楚他最终都渲染了什么。看到 return 的时候发现只返回了 renderHTML,而这个 renderHTML 却是经过一系列的判断得来的,相信没人愿意去读这样的代码。

拆分 render

我们可以将 render 方法进行一系列的拆分,创建一系列的子 render 方法,将原来大的 render 进行分割。

class App extends Component {
 renderHeader() {}
 renderBody() {}
 render() {
  return (
   <>
    {this.renderHeader()}
    {this.renderBody()}
   </>
  )
 }
}

当然最好的方式还是拆分为更细粒度的组件,这样不仅方便测试,也可以配合 memo/PureComponent/shouldComponentUpdate 做进一步性能优化。

const Header = () => {}
const Body = () => {}
const App = () => (
 <>
  <Header />
  <Body />
 </>
)

复用相同部分

对于可复用的组件部分,我们要尽量做到复用。这部分可以是状态逻辑,也可以是 HTML 结构。以下面这个组件为例,这样写看上去的确没有大问题。

class App extends Component {
    state = {
        on: props.initial
    }
    toggle = () => {
        this.setState({
            on: !this.state.on
        })
    }
    render() {
        <>
            <Button type="primary" onClick={this.toggle}> {this.on ? "Close" : "Open"} Modal </Button>
            <Modal visible={this.state.on} onOk={this.toggle} onCancel={this.toggle}/>
        </>
    }
}

但如果我们有个 checkbox 的按钮,它也会有开关两种状态,完全可以复用上面的 this.state.onthis.toggle,那该怎么办呢?

timg.gif-85.7kB

就像上一节讲的一样,我们可以利用 render props 来实现状态逻辑复用。

// 状态提取到 Toggle 组件里面
class Toggle extends Component {
    constructor(props) {
        this.state = {
            on: props.initial
        }
    }
    toggle = () => {
        this.setState({
            on: !this.state.on
        })
    }
    render() {
        return this.props.children({
            onthis.state.on,
            togglethis.toggle
        })
    }
}
// Toggle 结合 Modal
function App({
    return (
        <Toggle initial={false}>
            {({ on, toggle }) => (
                <>
                    <Button type="primary" onClick={toggle}> Open Modal </Button>
                    <Modal visible={on} onOk={toggle} onCancel={toggle}/>
                </>

            )}
        </Toggle>
    )
}
/
/ Toggle 结合 CheckBox
function App() {
    return (
        <Toggle initial={false}>
            {({ on, toggle }) => (
                <CheckBox visible={on} toggle={toggle} /
>
            )}
        </Toggle>
    )
}

或者我们可以用上节讲过的 React Hooks 来抽离这个通用状态和方法。

const useToggle = (initialState) => {
    const [state, setState] = useState(initialState);
    const toggle = () => setState(!state);
    return [state, toggle]
}

除了这种状态逻辑复用外,还有一种 HTML 结构复用。比如有两个页面,他们都有头部、轮播图、底部按钮,大体上的样式和布局也一致。如果我们对每个页面都写一遍,难免会有一些重复,像这种情况我们就可以利用高阶组件来复用相同部分的 HTML 结构。

const PageLayoutHoC = (WrappedComponent) => {
    return class extends Component {
        render() {
            const {
                title,
                sliderData,
                onSubmit,
                submitText
                ...props
            } = this.props
            return (
                <div className="main">
                    <Header title={title} />
                    <Slider dataList={sliderData} />
                    <WrappedComponent {...props} />
                    <Button onClick={onSubmit}>{submitText}</Button>
                </div>

            )
        }
    }
}

组件松耦合

松耦合一般是和紧耦合相对立的,两者的区别在于:

  1. 组件之间彼此依赖方法和数据,这种叫做紧耦合。

  2. 组件之间没有彼此依赖,一个组件的改动不会影响到其他组件,这种叫做松耦合。

    很明显,我们在开发中应当使用松耦合的方式来设计组件,这样不仅提供了复用性,还方便了测试。

    我们来看一下简单的紧耦合反面例子:

    class App extends Component {  
      state = { count: 0 }
     increment = () => {
      this.setState({
       count: this.state.count + 1
      })
     }
     decrement = () => {
      this.setState({
       count: this.state.count - 1
      })
     }
      render() {
        return <Counter count={this.state.count} parent={this} />
      }
    }

    class Counter extends Component {
      render() {
        return (
          <div className="counter">
            <button onClick={this.props.parent.increment}>
              Increase
            </button> 
            <div className="count">{this.props.count}</div>
            <button onClick={this.props.parent.decrement}>
              Decrease
            </button>
          </div>
        )
      }
    }

    可以看到上面的 Counter 依赖了父组件的两个方法,一旦父组件的 incrementdecrement 改了名字呢?那 Counter 组件只能跟着来修改,破坏了 Counter 的独立性,也不好拿去复用。

    所以正确的方式就是,组件之间的耦合数据我们应该通过 props 来传递,而非传递一个父组件的引用过来。

    class App extends Component {  
      state = { count: 0 }
     increment = () => {
      this.setState({
       count: this.state.count + 1
      })
     }
     decrement = () => {
      this.setState({
       count: this.state.count - 1
      })
     }
      render() {
        return <Counter count={this.state.count} increment={this.increment} decrement={this.decrement}/>
      }
    }

    class Counter extends Component {
      render() {
        return (
          <div className="counter">
            <button onClick={this.props.increment}>
              Increase
            </button> 
            <div className="count">{this.props.count}</div>
            <button onClick={this.props.decrement}>
              Decrease
            </button>
          </div>
        )
      }
    }

避免通过 ref 来 setState

对于需要在组件外面通知组件更新的操作,尽量不要在外面通过 ref 来调用组件的 setState,比如下面这种:

class Counter extends React.Component {
    state = {
        count0
    }
    render() {
        return (
            <div>{this.state.count}</div>
        );
    }
}
class App {
    ref = React.createRef();
    mount() {
        ReactDOM.render(<Counter ref={this.ref} />document.querySelector('#app'));
    }
    increment() {
        this.ref.current.setState({
            countthis.ref.current.state + 1
        });
    }
}

对于组件 Counter 来说,并不知道外面会直接通过 ref 来调用 setState。如果以后发现 count 突然就变化了,也不知道是哪里出了问题。

对于这种情况我们可以在组件里面注册事件,在外面发送事件来通知。这样我们可以明确知道组件监听了外部的事件。

  class Counter extends React.Component {
       state = {
           count0
       }
       componentDidMount() {
           event.on('increment'this.increment);
       }
       componentWillUnmount() {
           event.off('increment'this.increment);
       }
       increment = () => {
           this.setState({
                   countthis.state.count + 1
           });
       }
       render() {
           return (
               <div>{this.state.count}</div>
           );
       }
   }
   class App {
       ref = React.createRef();
       mount() {
           ReactDOM.render(<Counter ref={this.ref} />document.querySelector('#app'));
       }
       increment() {
           event.trigger('increment');
       }
   }

如果在函数组件里面,React 提供了 useImperativeHandle 这个 Hook,配合 forwardRef 可以支持传递函数组件内部的方法给外部使用。

import React, { useState, useImperativeHandle, forwardRef } from 'react';

const Counter = forwardRef((props, ref) => {
    const [count, setCount] = useState(0);
    useImperativeHandle(
        ref,
        () => ({
            increment() => {
                setCount(count + 1);
            }
        })
    );
});

class App {
       ref = React.createRef();
       mount() {
           ReactDOM.render(<Counter ref={this.ref} />document.querySelector('#app'));
       }
       increment() {
           this.ref.current.increment();
       }
   }

数据解耦

我们的组件不应该依赖于特定格式的数据,组件中避免出现 data.xxx 这种数据。你可以通过 render props 的模式将要处理的对象传到外面,让使用者自行操作。举个栗子:我设计了一个 Tabs 组件,我需要别人给我传入这样的结构:

[
    {
        key'Tab1',
        content'这是 Tab 1',
        title'Tab1'
    },
    {},
    {}
]

这个 key 是我们用来关联所有 Tab 和当前选中的 Tab 关系的。比如我选中了 Tab1,当前的 Tab1 会有高亮显示,就通过 key 来关联。而我们的组件可能会这样设计:

<Tabs data={data} currentTab={'Tab1'} />

这样的设计不够灵活,一个是耦合了数据的结构,大多数时候,接口不会返回上图中的 key 这种字段,title 也很可能没有,这就需要我们自己做一下数据格式化。另一个是封装了 DOM 结构,如果我们想定制化传入的 Tab 结构就会变得非常困难。我们不妨转换一下思路,当设计一个通用组件的时候,一定要只有一个组件吗?一定要把数据传给组件吗?那么来一起看看业界知名的组件库 Ant Design 是如何设计 Tabs 组件的。

<Tabs defaultActiveKey="1" onChange={callback}>
    <TabPane tab="Tab 1" key="1">
        Content of Tab Pane 1
    </TabPane>

    <TabPane tab="Tab 2" key="2">
        Content of Tab Pane 2
    </TabPane>

    <TabPane tab="Tab 3" key="3">
        Content of Tab Pane 3
    </TabPane>

</Tabs>

Ant Design 将数据和结构进行了解耦,我们不再传列表数据给 Tabs 组件,而是自行在外部渲染了所有的 TabPane,再将其作为 Children 传给 Tabs,这样的好处就是组件的结构更加灵活,TabPane 里面随便传什么结构都可以。

结构自由

一个好的组件,结构应当是灵活自由的,不应该对其内部结构做过度封装。我们上面讲的 Tabs 组件其实就是结构自由的一种代表。

考虑到这样一种业务场景,我们页面上有多个输入框,但这些输入框前面的 Icon 都是不一样的,代表着不同的含义。我相信肯定不会有人会对每个 Icon 都实现一个 Input 组件。

image_1e5jq2o13qj0qmele81aahh6l13.png-10.7kB

你可能会想到我们可以把图片的地址当做 props 传给组件,这样不就行了吗?但万一前面不是 Icon 呢?而是一个文字、一个符号呢?

那我们是不是可以把元素当做 props 传给组件呢?组件来负责渲染,但渲染后长什么样还是使用者来控制的。这就是 Ant Design 的实现思路。

code.png-111.5kB

在前面数据解耦中我们就讲过了类似的思路,实际上数据解耦和结构自由是相辅相成的。在设计一个组件的时候,很多人往往会陷入一种怪圈,那就是我该怎么才能封装更多功能?怎么才能兼容不同的渲染?

这时候我们就不妨换一种思路,如果将渲染交给使用者来控制呢?渲染成什么样都由用户来决定,这样的组件结构是非常灵活自由的。

当然,如果你把什么都交给用户来渲染,这个组件的使用复杂度就大大提高了,所以我们也应当提供一些默认的渲染,即使用户什么都不传也可以渲染默认的结构。

总结

组件设计是一项重要的工作,好的组件我们直接拿来复用可以大大提高效率,不好的组件只会增加我们的复杂度。

在组件设计的学习中,你需要多探索、实践,多去参考社区知名的组件库,比如 Ant Design、Element UI、iview 等等,去思考他们为什么会这样设计,有没有更好的设计?如果是自己来设计会怎么样?

Node 社群



我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。



如果你觉得这篇内容对你有帮助,我想请你帮我2个小忙:

1. 点个「在看」,让更多人也能看到这篇文章
2. 订阅官方博客 www.inode.club 让我们一起成长

点赞和在看就是最大的支持❤️

浏览 21
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报