yinguangyao / blog

关于 JavaScript 前端开发、工作经验的一点点总结。
262 stars 12 forks source link

浅谈 React 组件设计 #40

Open yinguangyao opened 4 years ago

yinguangyao commented 4 years ago

前言

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

jQuery 插件

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

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

组件?插件?

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

封装 DOM 结构

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

// 轮播图插件
$("#slider").slider({
    config: {
        showDot: true, // 是否展示小圆点
        showArrow: true // 是否展示左右小箭头
    }, // 一些配置
    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({
            on: this.state.on,
            toggle: this.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>
       )
     }
    }

数据解耦

我们的组件不应该依赖于特定格式的数据,组件中避免出现 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 等等,去思考他们为什么会这样设计,有没有更好的设计?如果是自己来设计会怎么样?