原文来源于:前端小馆 ,作者:nuIl
如有侵权,联系删除
前言
前端组件化一直是老生常谈的话题,在前面介绍 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
个人觉得,组件设计应该遵循以下几个原则:
-
适当的组件粒度:一个组件尽量只做一件事。
-
复用相同部分:尽量复用不同组件相同的部分。
-
松耦合:组件不应当依赖另一个组件。
-
数据解耦:组件不应该依赖特定结构的数据。
-
结构自由:组件不应该封闭固定的结构。
容器组件与展示组件
顾名思义,容器组件就是类似于“容器”的组件,它可以拥有状态,会做一些网络请求之类的副作用处理,一般是一个业务模块的入口,比如某个路由指向的组件。我们最常见的就是 Redux 中被 connect 包裹的组件。容器组件有这么几个特点:
-
容器组件常常是和业务相关的。
-
统一的数据管理,可以作为数据源给子组件提供数据。
-
统一的通信管理,实现子组件之间的通信。
展示组件就比较简单的多,在 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.on
和 this.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>
)
}
}
}
组件松耦合
松耦合一般是和紧耦合相对立的,两者的区别在于:
-
组件之间彼此依赖方法和数据,这种叫做紧耦合。
-
组件之间没有彼此依赖,一个组件的改动不会影响到其他组件,这种叫做松耦合。
很明显,我们在开发中应当使用松耦合的方式来设计组件,这样不仅提供了复用性,还方便了测试。
我们来看一下简单的紧耦合反面例子:
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 依赖了父组件的两个方法,一旦父组件的
increment
和decrement
改了名字呢?那 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 = {
count: 0
}
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({
count: this.ref.current.state + 1
});
}
}
对于组件 Counter 来说,并不知道外面会直接通过 ref 来调用 setState。如果以后发现 count 突然就变化了,也不知道是哪里出了问题。
对于这种情况我们可以在组件里面注册事件,在外面发送事件来通知。这样我们可以明确知道组件监听了外部的事件。
class Counter extends React.Component {
state = {
count: 0
}
componentDidMount() {
event.on('increment', this.increment);
}
componentWillUnmount() {
event.off('increment', this.increment);
}
increment = () => {
this.setState({
count: this.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 等等,去思考他们为什么会这样设计,有没有更好的设计?如果是自己来设计会怎么样?