浅谈 React 组件设计
大厂技术 高级前端 Node进阶
点击上方 程序员成长指北,关注公众号
回复1,加入高级Node交流群
前言
前端组件化一直是老生常谈的话题,在前面介绍 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,组件设计思想都是一样的。
个人觉得,组件设计应该遵循以下几个原则:
适当的组件粒度:一个组件尽量只做一件事。 复用相同部分:尽量复用不同组件相同的部分。 松耦合:组件不应当依赖另一个组件。 数据解耦:组件不应该依赖特定结构的数据。 结构自由:组件不应该封闭固定的结构。
容器组件与展示组件
顾名思义,容器组件就是类似于“容器”的组件,它可以拥有状态,会做一些网络请求之类的副作用处理,一般是一个业务模块的入口,比如某个路由指向的组件。我们最常见的就是 Redux 中被 connect 包裹的组件。容器组件有这么几个特点:
容器组件常常是和业务相关的。 统一的数据管理,可以作为数据源给子组件提供数据。 统一的通信管理,实现子组件之间的通信。
展示组件就比较简单的多,在 React 中组件的设计理念是 view = f(data)
,展示组件只接收外部传来的 props,一般内部没有状态,只有一个渲染的作用。
适当的组件粒度
在项目开发中,可能你会看到懒同事一个几千行的文件,却只有一个组件,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
,那该怎么办呢?
就像上一节讲的一样,我们可以利用 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 组件。
你可能会想到我们可以把图片的地址当做 props 传给组件,这样不就行了吗?但万一前面不是 Icon 呢?而是一个文字、一个符号呢?
那我们是不是可以把元素当做 props 传给组件呢?组件来负责渲染,但渲染后长什么样还是使用者来控制的。这就是 Ant Design 的实现思路。
在前面数据解耦中我们就讲过了类似的思路,实际上数据解耦和结构自由是相辅相成的。在设计一个组件的时候,很多人往往会陷入一种怪圈,那就是我该怎么才能封装更多功能?怎么才能兼容不同的渲染?
这时候我们就不妨换一种思路,如果将渲染交给使用者来控制呢?渲染成什么样都由用户来决定,这样的组件结构是非常灵活自由的。
当然,如果你把什么都交给用户来渲染,这个组件的使用复杂度就大大提高了,所以我们也应当提供一些默认的渲染,即使用户什么都不传也可以渲染默认的结构。
总结
组件设计是一项重要的工作,好的组件我们直接拿来复用可以大大提高效率,不好的组件只会增加我们的复杂度。
在组件设计的学习中,你需要多探索、实践,多去参考社区知名的组件库,比如 Ant Design、Element UI、iview 等等,去思考他们为什么会这样设计,有没有更好的设计?如果是自己来设计会怎么样?
Node 社群
我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。
如果你觉得这篇内容对你有帮助,我想请你帮我2个小忙:
1. 点个「在看」,让更多人也能看到这篇文章 2. 订阅官方博客 www.inode.club 让我们一起成长 点赞和在看就是最大的支持❤️