目录
一、前言
二、组件设计
三、常见的组件设计模式
1.容器组件和展示组件
场景
问题
解决方案
小结
2.模版化组件
场景
问题
解决方案
小结
3.高阶组件
场景
问题
解决办法
小结
四、总结
提到React,大家最容易联想到的就是组件,这也是React能够帮助我们简单容易地开发复杂用户界面的有效利器。可能大家初次尝试React进行组件化开发时,都只是把一些使用多次的组件提取成单独的组件以达到复用的目的,减少冗余。这么做没错,这一点确实是组件化带来的最直接的好处,但React组件化并不只是简单地把公共提取复用,还有很多组件设计模式能对代码的复用性、扩展性带来质的提升,今天我们就来看下有哪些组件设计模式。
首先我们先想一个问题:怎样进行组件设计?我刚学React时,也对这个问题踌躇了很久,看了很多资料,但看完后还是感觉一知半解的,直到一次无意间在知乎上看到下面这么一篇问答。
问题:
新手的对于前端组件化开发的一些疑问?(大致就是问如何合理地划分、设计组件)
回复:
我们说到组件化,一般会用components这个词,多个components组合形成一个page,不同的page用router调度。
components应该是和业务无关的,它只负责渲染给入的数据。比如按钮是一个组件,可能有一个参数决定了它的尺寸,一个参数决定了它是否可以点击,但是点击这个按钮之后会发生什么,就不是按钮这个组件需要知道的事情了。
所以我们的组件都是业务无关,然后把所有的数据放在page中,去调度组件的使用么?这显然又有哪里不太对。问题出在我们在这里面少了一层结构,components要组成module,然后module和一些简单components一起形成page。components和modules,组件和模块,或者叫做木偶组件和智能组件。
比如常见的TODO list demo中的addNewTodo这件事,可以由input决定,可以有TodoList决定,甚至可以由整个根组件(page)决定。input应该是一个木偶组件,就像是公司最底层的员工,只能听命于领导埋头做事,并没有决策的权利。所以把方法安排在input上是不合适的。如果给到根组件呢?这就像让CEO去负责一个员工的入职,可能在小公司(简单页面)里也是可以的,但如果公司特别大,入职这种事情肯定授权给HR来负责了。所以TodoList就是这个HR,它可以全权负责新增和删除,既不越权也不屈尊。
组件开发设计和人员的组织架构设计非常像,要分为多少个层级,每个人负责哪些事务,如何把权利和责任落实到合适的层级,这是两者的共同点。
在这位答主的评论中,他提到了components、module等关键词,以及按照功能职责、模块组合的方式去设计组件,并用CEO和HR的工作拟人化地举了这么一个形象的例子,看完有没有对组件设计有种醍醐灌顶的感觉?其实这种设计方式就和社区中流行的Container&Component模式特别相似,而且设计模式还不止这一种,具体的请继续往下看。
假设有这么一个业务场景,需要实时显示当前的时间,常规的写法可能是下面这样的:
class Clock extends React.Component {
constructor(props) {
super(props);
this.state = {
time: props.time
};
}
render() {
const time = this._formatTime(this.state.time);
return (
{ time.hours } : { time.minutes } : { time.seconds }
);
}
componentDidMount() {
this._interval = setInterval(this._update, 1000);
}
componentWillUnmount() {
clearInterval(this._interval);
}
_formatTime = (time) => {
var [ hours, minutes, seconds ] = [
time.getHours(),
time.getMinutes(),
time.getSeconds()
].map(num => num < 10 ? '0' + num : num);
return { hours, minutes, seconds };
}
_updateTime = () => {
this.setState({
time: new Date(this.state.time.getTime() + 1000)
});
}
};
在组件的构造函数中,初始化了组件的状态,这里只保存了当前时间。通过使用 setInterval ,每秒更新一次状态,然后组件会重新渲染。为了看起来像个真正的时钟,还使用了两个辅助函数: _formatTime 和 _updateTime 。_formatTime 用来提取时分秒并确保它们是两位数的形式。_updateTime 用来将 time 对象设置为当前时间加一秒。
这个组件中做了好几件事,它似乎承担了太多的职责。
它通过自身来修改状态。在组件中更改时间可能不是一个好主意,因为只有 Clock 组件知道当前时间。如果系统中的其他部分也需要此数据,那么将很难进行共享。
_formatTime 实际上做了两件事,它从时间对象中提取出所需信息,并确保这些值永远以两位数字的形式进行展示。这没什么问题,但如果提取操作不是函数的一部分那就更好了,因为函数绑定了 time对象的类型。即此函数既要知道数据结构,同时又要对数据进行可视化处理。
对于容器型组件和展示型组件的概念,大家或多或少都听过,下面我们详细地介绍下容器型组件和展示型组件。
在 React.js Conf 2015 ,有一个 Making your app fast with high-performance components 的主题介绍了容器组件,它的基本原则是:
一个container组件仅仅做数据提取,然后渲染它对应的corresponding子组件
“Corresponding”意味着分享同一个名称的组件,例如:
StockWidgetContainer => StockWidget
TagCloudContainer => TagCloud
MerchandiseListContainer => MerchandiseList
容器组件专门负责和 store 通信,把数据通过 props 传递给普通的展示组件,展示组件如果想发起数据的更新,也是通过容器组件从 props 传递来的回调函数来告诉 store。
由于展示组件不再直接和 store 耦合,而是通过 props 接口来定义自己所需的数据和方法,使得展示组件的可复用性会更高。
两者的区别
|
展示组件 |
容器组件 |
---|---|---|
作用 |
描述如何展现(骨架、样式) |
描述如何运行(数据获取、状态更新) |
直接使用 store |
否 |
是 |
数据来源 |
props |
监听 store state |
数据修改 |
从 props 调用回调函数 |
向 store 派发 actions |
来自 Redux 文档 https://user-gold-cdn.xitu.io/2018/5/2/1631f590aa5512b7
划分出容器组件和展示组件的优点:
展示和容器更好的分离,更好的理解应用程序和UI
重用性高,展示组件可以用于多个不同的state数据源
展示组件就是你的调色板,可以把他们放到单独的页面,在不影响应用程序的情况下,配合设计师很方便地调整UI
迫使你分离出更细,职责更单一的组件,达到更高的可用性
下面将应用容器组件和展示组件的模式来对上面的例子进行组件提取:
容器型组件 ClockContainer 的代码:
import Clock from './Clock.jsx'; // <-- 展示型组件
export default class ClockContainer extends React.Component {
constructor(props) {
super(props);
this.state = { time: props.time };
this._update = this._updateTime.bind(this);
}
render() {
return ;
}
componentDidMount() {
this._interval = setInterval(this._update, 1000);
}
componentWillUnmount() {
clearInterval(this._interval);
}
_extract(time) {
return {
hours: time.getHours(),
minutes: time.getMinutes(),
seconds: time.getSeconds()
};
}
_updateTime() {
this.setState({
time: new Date(this.state.time.getTime() + 1000)
});
}
};
它接收 time (date 对象) 属性,使用 setInterval 循环并了解数据 (getHours、getMinutes 和 getSeconds) 的详情。最后渲染展示型组件并传入时分秒三个数字。这里没有任何展示相关的内容。只有业务逻辑。
展示型组件 Clock 的代码:
export default function Clock(props) {
var [ hours, minutes, seconds ] = [
props.hours,
props.minutes,
props.seconds
].map(num => num < 10 ? '0' + num : num);
return { hours } : { minutes } : { seconds } ;
};
这么做的好处
提高组件的可复用性
不改变时间或不使用 JavaScript Date 对象的应用中,都可以使用 Clock 函数/组件。原因是它相当纯粹,不需要对所需数据的详情有任何了解。
逻辑和UI隔离
容器型组件封装了逻辑,它们可以搭配不同的展示型组件使用,因为它们不参与任何展示相关的工作。我们上面所采用的方法是一个很好的示例,我们可以很容易地从数字时钟切换到模拟时钟,唯一的变化就是替换 render 方法中的
更加容易测试
测试也将变得更容易,因为组件承担的职责更少,容器型组件不关心 UI ,展示型组件只是纯粹地负责展示。
复用容器组件
需要提一点的是,容器型组件也能复用。比如下面这个小说列表的示例,两种分类的小说,数据(接口)差异不大,主要是展现形式(列表和网格)的不同,这种情况下可以复用容器组件:
export interface NovelGridProps {
loading: boolean
novels: Array
}
export const NovelGrid = ({ loading, novels }: NovelGridProps) => (
(
{item.name}
)}
/>
)
export interface NovelListProps {
loading: boolean
novels: Array
}
export const NovelList = ({ novels, loading }: NovelListProps) => (
(
{item.name}
)}
/>
)
export interface Props {
type: string,
render: (state: State, props: Props) => ReactNode
}
export interface State {
loading: boolean
novels: Array
}
export class NovelListContainer extends PureComponent {
state = {
loading: false,
novels: []
}
componentDidMount() {
this.fetchNovels()
}
fetchNovels = () => {
this.setState({
loading: true
})
API.fetchNovelsByType(this.props.type)
.then(novels => {
this.setState({
novels,
loading: false
})
})
.catch(error => {
this.setState({
loading: false
})
})
}
render() {
return this.props.render(this.state, this.props)
}
}
export class Test extends PureComponent {
render() {
return (
(
)}
/>
(
)}
/>
)
}
}
没有什么规则是一成不变的,是否适用还是需要看业务场景是否需要,没必要给每个场景都强行套上这套模式,它只是为你在代码设计时提供了一种选择。
在一些非常小的组件里混用容器和展示是可以的,当业务变复杂后,如何进行容器组件和展示组件的拆分就很明显了,就像你知道什么时候该提取一个函数一样!
假设有这么一种业务场景,需要有一个 Comment(评论) 组件,这个组件存在多种行为或事件,同时组件所展现的信息根据用户的身份不同而有所变化:
用户是否是此 comment 的作者;
此 comment 是否被正确保存;
各种权限不同
等等......
都会引起这个组件的不同展示行为。
想象上面这种场景,是不是就感觉到了繁杂的逻辑,各种 if else逻辑判断,也就是多种configurations的情况。就算使用上面提到的容器组件和展示组件的划分模式,也解决不了这种根本问题。
我们先想想,为什么一个组件会变的臃肿而复杂呢?
渲染元素较多且嵌套
组件内部变化较多,或者存在多种 configurations 的情况。
这种情况下,我们便可以将组件改造为模版:父组件类似一个模版,只专注于各种 configurations。
比如上面提到的这种场景,与其把所有的逻辑混淆在一起,也许更好的做法是利用 React 可以传递 React component 的特性,我们将 React component 进行组件间传递,这样就更加像一个强大的模版:
class CommentTemplate extends React.Component {
static propTypes = {
// Declare slots as type node
metadata: PropTypes.node,
actions: PropTypes.node,
};
render() {
return (
// Slot for metadata
{this.props.metadata}
// Slot for actions
{this.props.actions}
...
此时,我们真正的 Comment 组件组织为:
class Comment extends React.Component {
render() {
const metadata = this.props.publishTime ? : Saving...;
const actions = [];
if (this.props.isSignedIn) {
actions.push( );
actions.push( );
}
if (this.props.isAuthor) {
actions.push( );
}
return ;
}
metadata 和 actions 其实就是在特定情况下需要渲染的 React element。
比如:
如果 this.props.publishTime 存在,metadata 就是
反之则为
如果用户已经登陆,则需要渲染(即actions值为)
如果是评论者本人,需要渲染的内容就要加入
模板化组件对于一些UI样式高度相似、渲染元素较多但存在多种“configurations“的业务场景是特别适用的,灵活运用模板化组件,能给你带来逻辑简化,易扩展的好处。
在实际开发当中,组件经常会被其他需求所污染。
想象这样一个场景:我们想统计页面中所有链接的点击信息。在链接点击时,发送统计请求,同时这条请求需要包含此页面 document 的 id 值。
常见的做法是在 Document 组件的生命周期函数 componentDidMount 和 componentWillUnmount 增加代码逻辑:
class Document extends React.Component {
componentDidMount() {
ReactDOM.findDOMNode(this).addEventListener('click', this.onClick);
}
componentWillUnmount() {
ReactDOM.findDOMNode(this).removeEventListener('click', this.onClick);
}
onClick = (e) => {
if (e.target.tagName === 'A') { // Naive check for elements
sendAnalytics('link clicked', {
documentId: this.props.documentId // Specific information to be sent
});
}
};
render() {
// ...
这么做的几个问题在于:
相关组件 Document 除了自身的主要逻辑:显示主页面之外,多了其他统计逻辑;
如果 Document 组件的生命周期函数中,还存在其他逻辑,那么这个组件就会变的更加含糊不合理;
统计逻辑代码无法复用;
组件重构、维护都会变的更加困难。
为了解决这个问题,我们提出了高阶组件这个概念: higher-order components (HOCs)。不去晦涩地解释这个名词,我们来直接看看使用高阶组件如何来重构上面的代码:
function withLinkAnalytics(mapPropsToData, WrappedComponent) {
return class LinkAnalyticsWrapper extends React.Component {
componentDidMount() {
ReactDOM.findDOMNode(this).addEventListener('click', this.onClick);
}
componentWillUnmount() {
ReactDOM.findDOMNode(this).removeEventListener('click', this.onClick);
}
onClick = (e) => {
if (e.target.tagName === 'A') { // Naive check for elements
const data = mapPropsToData ? mapPropsToData(this.props) : {};
sendAnalytics('link clicked', data);
}
}
render() {
// Simply render the WrappedComponent with all props
return ;
}
}
}
需要注意的是,withLinkAnalytics 函数并不会去改变 WrappedComponent 组件本身,更不会去改变 WrappedComponent 组件的行为。而是返回了一个被包裹的新组件。实际用法为:
class Document extends React.Component {
render() {
// ...
}
}
export default withLinkAnalytics((props) => ({
documentId: props.documentId
}), Document);
这样一来,Document 组件仍然只需关心自己该关心的部分,而 withLinkAnalytics 赋予了复用统计逻辑的能力。
高阶组件的存在,完美展示了 React 天生的复合(compositional)能力,在 React 社区当中,react-redux,styled-components,react-intl 等都普遍采用了这个方式。值得一提的是,recompose 类库又利用高阶组件,并发扬光大,做到了“脑洞大开”的事情。
高阶组件是对React代码进行更高层次重构的好方法,如果你想精简你的state和生命周期方法,那么高阶组件可以帮助你提取出可重用的函数。一般来说高阶组件能完成的用组件嵌套+继承也可以,用嵌套+继承的方式理解起来其实更容易一点,特别是去重构一个复杂的组件时,通过这种方式往往更快,拆分起来更容易。至于到底用哪个最佳还要具体看业务场景。
最后再说下几种组件设计模式的特点:
容器组件和展示组件:将逻辑和UI进行隔离,降低耦合,便于复用,但如果强加这种模式,可能会增加额外的工作量
模板化组件:更加适合渲染元素较多但存在多种“configurations“的业务场景,简化逻辑判断,使逻辑清晰
高阶组件:给所有子组件注入逻辑,达到功能增强的目的,通常来讲,能使用父组件达到的效果,尽量不要用高阶组件,因为高阶组件是一种更 hack 的方法,但同时也有更高的灵活性。
相关资料:
Techniques for decomposing React components
React 组件设计和分解思考
Presentational and Container Components
Redux 文档
Higher Order Composition with Typescript for React