(一)前言
这里提到的设计模式并不是编程通用的设计模式,如常说的单例模式、抽象工厂模式、建造者模式、工厂模式、原型模式等。而是在设计 React 组件时的一些解决方案与技巧,包括以下几种:
(1) 容器与展示组件
(2) 高阶组件
(3) render props
(4) context 模式
(5) 组合组件
(6) 继承
当然概念部分,大家能根据名字猜出,但是我还是要为每种,单独给出demo详细说下。
设计模式本身是因为产品需求,解决特定的物业场景,并非完全需要按照这样,所以个人开发中建议形成自己的开发模式。
(二)容器与展示组件
这可能是目前应用最广泛,也是最简单的,大家最推崇的设计方案。其实大概意思就是,将组件分为两种。
一个为容器组件,负责与外部数据进行交互,比如处理业务逻辑(连接redux),
一个为展示组件,只通过props传递业务逻辑,state控制内部交互,不包含任何业务逻辑,也不与外部数据源(连接redux)进行沟通,现在我来举个例子。
需求: 当我们点击按钮后,会去接口拉去数据,并将返回的数据渲染到视图,当然这种需求,简单可以将全部视图写在一起,但是我们需要使用容器和展示组件的方式来组织代码,那么代码如下
// 容器组件 textContainer.js
import React from 'react';
import Text from './text';
class TextContainer extends React.Component {
state = {
text: '',
};
getData = () => {
// 模拟异步请求
setTimeout(() => {
this.setState({ text: '测试数据' });
}, 1000);
};
render() {
const {
state: {
text,
},
} = this;
return (
<div>
<Text text={text} onClick={this.getData} />
</div>
);
}
}
export default TextContainer;
// 展示组件 text.js
import React from 'react';
import PropTypes from 'prop-types';
class Text extends React.PureComponent {
render() {
const {
props: {
text,
onClick,
},
} = this;
return (
<div>
<div>
接口返回的数据:
{text}
</div>
<button onClick={onClick}>点我啊</button>
</div>
);
}
}
// 这里我建议,不是必须传入的参数,尽量不使用isRequired验证
// 比如input value 肯定是必须的 但是如果不做受控组件,那么回调onChange就不是必须
Text.defaultProps = {
text: '',
onClick: () => null,
};
Text.propTypes = {
text: PropTypes.string,
onClick: PropTypes.func,
};
export default Text;
从上面代码我们看出,我们采用软件设计原则中的“责任分离”, 即让一个模块只负责责任尽量单一
容器展示组件这个模式所解决的问题在于,当我们切换数据获取方式时,只需在容器组件修改相应逻辑即可,展示组件无需做改动。展示组件可完全不变,展示组件有了更高的可复用性。
但该模式的缺点也在于将一个组件分成了两部分,增加了代码跳转的成本。并不是说组件包含从外部获取数据,就要将其拆成容器组件与展示组件。
(三)高阶组件
1.概念:
高阶组件不是组件,是 增强函数,可以输入一个元组件,返回出一个新的增强组件;
高阶组件的主要作用是 代码复用,操作状态和参数;
2. 分类(主要两个。一个属性代理,一个反向继承)
- 属性代理 : 返回出一个组件,它基于被包裹组件进行功能增强;
这里有个获取display name的函数,下面新增的高阶组件都会调用这个方法,就不再次书写了
// 获取display name
function getDisplayName(WrappedComponent) {
return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}
1.1 默认参数: 可以为组件包裹一层默认参数;
export function ProxyHoc(Component) {
const NewComponent = (props) => {
const newProps = {
name: '高阶组件增加的属性',
age: 1,
};
return <Component {...props} {...newProps} />;
};
return NewComponent;
}
1.2 提取状态: 可以通过 props 将被包裹组件中的 state 依赖外层,例如用于转换受控组件:
现在我们来实现一个简单表单高阶组件,不包含表单验证。
// withForm.js
import React from 'react';
import hoistNonReactStatic from 'hoist-non-react-statics';
import { getDisplayName } from './utils';
export default function WithForm(Component) {
class Enhance extends React.Component {
static displayName = `WithForm(${getDisplayName(Component)})`;
state = {
form: {},
};
onChange = key => (e) => {
const { form } = this.state;
form[key] = e.target.value;
this.setState({
form,
});
};
handleSubmit = () => this.state.form;
getField = fieldName => ({
onChange: this.onChange(fieldName),
});
render() {
const newProps = {
...this.props,
getForm: this.handleSubmit,
setFormItem: this.getField,
};
return (<Component {...newProps} />);
}
}
// 自动拷贝所有非 React 的静态方法
hoistNonReactStatic(Enhance, Component);
return Enhance;
}
我们需要使用这个表单控件时候,那么代码如下
import React from 'react';
// components
import HocForm from './components/hoc/hocForm';
// style
import './app.css';
class App extends React.Component {
render() {
const {
props: { setFormItem, getForm },
} = this;
return (
<div className="App">
<header className="App-header">
<input
type="text"
{...setFormItem('username')}
placeholder="用户名"
/>
<input
type="password"
placeholder="密码"
{...setFormItem('password')}
/>
<div>
表单数据:
{JSON.stringify(getForm())}
</div>
</header>
</div>
);
}
}
export default HocForm(App);
我们可以通过props,设置表单需要的key,最后调用getForm得到内部表单数据。
1.3 包裹组件: 可以为被包裹元素进行一层包装,
import React from 'react';
import hoistNonReactStatic from 'hoist-non-react-statics';
import { getDisplayName } from './utils';
export default function WithMask(Component) {
class Enhance extends React.Component {
static displayName = `WithMask(${getDisplayName(Component)})`;
render() {
return (
<div>
<Component {...this.props} />
<div
style={{
position: 'absolute',
left: 0,
top: 0,
zIndex: -1,
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
}}
/>
</div>
);
}
}
// 自动拷贝所有非 React 的静态方法
hoistNonReactStatic(Enhance, Component);
return Enhance;
}
这里给mask封装来一层背景包裹组件。
- 反向继承 : 返回出一个组件,继承于被包裹组件,常用于以下操作:
export default function WithHocSimple(Component) {
return class extends Component {
render() {
return super.render();
}
};
}
反向继承其实就是 一个函数接受一个 Component 组件作为参数传入,并返回一个继承了该传入 Component 组件的类,且在该类的 render() 方法中返回 super.render() 方法。
会发现其属性代理和反向继承的实现有些类似的地方,都是返回一个继承了某个父类的子类,只不过属性代理中继承的是 React.Component,反向继承中继承的是传入的组件 Component。
反向继承可以用来做什么:
操作 state
渲染劫持(Render Highjacking)
2.1 操作state
高阶组件中可以读取、编辑和删除 WrappedComponent 组件实例中的 state。甚至可以增加更多的 state 项,但是 非常不建议这么做 因为这可能会导致 state 难以维护及管理。
import React from 'react';
export default function WithHocLoading(Component) {
return class extends Component {
render() {
return (
<div>
<h2>Debugger Component Logging...</h2>
<p>state:</p>
<pre>{JSON.stringify(this.state, null, 4)}</pre>
<p>props:</p>
<pre>{JSON.stringify(this.props, null, 4)}</pre>
{super.render()}
</div>
);
}
};
}
在这个例子中利用高阶函数中可以读取 state 和 props 的特性,对 Component 组件做了额外元素的嵌套,把 Component 组件的 state 和 props 都打印了出来,
2.2 渲染劫持
我们来实现一个通过修改由 render() 输出的 React 元素树
import React from 'react';
export default function WithTree(Component) {
return class extends Component {
render() {
const tree = super.render();
const newProps = {};
if (tree && tree.type === 'div') {
newProps.className = 'App add-view';
}
const props = {
...tree.props,
...newProps,
};
const newTree = React.cloneElement(tree, props, tree.props.children);
return newTree;
}
};
}
模式所解决的问题
同样的逻辑我们总不能重复写多次。高阶组件起到了抽离共通逻辑的作用。同时高阶组件的嵌套使用使得代码复用更加灵活了。
注意以下几点
- 包装显示名称以便轻松调试(定义displayName)
- 不要在 render 方法中使用 HOC(会导致每次都重新渲染整个HOC,而且之前的状态会丢失)
- 务必复制静态方法(hoist-non-react-statics库解决)
- Refs 不会被传递(使用React.forwardRef解决)
(四)render props
概念:
指一种在 React 组件之间使用一个值为函数的prop来共享代码的简单技术。同高阶组件一样,render props的引入也是为了解决复用业务逻辑。
需求:
我们实现一个判断性别的组件,提供给购物车和商品列表组件使用,将共用性别判断逻辑抽离出来。
// providerGender.js
import React from 'react';
import PropTypes from 'prop-types';
const ProviderGender = (props) => {
// 判断是否是女性用户
const isWoman = Math.random() > 0.1;
if (isWoman) {
const allProps = { add: '高阶组件增加的属性', ...props };
return props.children(allProps);
}
return <div>女士专用,男士无权浏览</div>;
};
ProviderGender.defaultProps = {
children: () => null,
};
ProviderGender.propTypes = {
children: PropTypes.func,
};
export default ProviderGender;
然后我们分别定义商品列表组件
// list.js
import React from 'react';
import PropTypes from 'prop-types';
const List = ({ add, name }) => (
<div>
<span>{`${name}:`}</span>
<span>{add}</span>
</div>
);
List.defaultProps = {
name: '',
add: '',
};
List.propTypes = {
name: PropTypes.string,
add: PropTypes.string,
};
export default List;
现在我们来该如何使用两者组合
import React from 'react';
// components
// render props
import ProviderGender from './components/renderProps/providerGender';
import List from './demo/shoppingPropsDemo';
// style
import './app.css';
class App extends React.Component {
state = {
name: '商品名称列表',
};
onToggleList = () => {
this.setState(state => ({
...state,
name: state.name === '商品名称列表' ? '购物车列表' : '商品名称列表',
}));
};
render() {
const {
state: { name },
} = this;
return (
<div className="App">
<header className="App-header">
<ProviderGender>
{props => <List add={props.add} name={name} />}
</ProviderGender>
<button
type="button"
onClick={this.onToggleList}
>
点我
</button>
</header>
</div>
);
}
}
export default App;
上面代码中可以看出,属性代理的方案高阶组件也能实现,但是 render props 却有更加强大的一个点就是,因为可以在render中,所以能自定义给list组件,添加新的props,而不需要修改ProviderGender组件,这是高阶不能在render中调用,所不具备的最大优势。
当然,render props 的使用可以不局限在利用 children,组件任意的 prop 属性都可以达到相同效果,比如我们用 renderChildren 这个 prop 实现上面相同的效果。
比如我们将children改为renderChildren函数。
// providerGender.js
import React from 'react';
import PropTypes from 'prop-types';
const ProviderGender = (props) => {
// 判断是否是女性用户
const isWoman = Math.random() > 0.1;
if (isWoman) {
const allProps = { add: '高阶组件增加的属性', ...props };
return props.renderChildren(allProps);
}
return <div>女士专用,男士无权浏览</div>;
};
ProviderGender.defaultProps = {
renderChildren: () => null,
};
ProviderGender.propTypes = {
renderChildren: PropTypes.func,
};
export default ProviderGender;
// app.js
class App extends React.Component {
render() {
const {
state: { name },
} = this;
renturn (
<div>
<ProviderGender
renderChildren={props => <List add={props.add} name={name} />}
/>
</div>
)
}
}
注意事项:
将 Render Props 与 React.PureComponent 一起使用时要小心!如果你在 Provider 属性中创建函数,那么使用 render props 会抵消使用React.PureComponent 带来的优势。因为浅比较 props 的时候总会得到 false,并且在这种情况下每一个 render 对于 render props 将会生成一个新的值。
比如上面例子就是在每次render,重新生成一个新的函数作为 的 prop。那么解决方案就是,将匿名函数,提到外层组件的方法
class App extends React.Component {
state = {
name: '商品名称列表',
};
onToggleList = () => {
this.setState(state => ({
...state,
name: state.name === '商品名称列表' ? '购物车列表' : '商品名称列表',
}));
};
renderList = (props) => {
const {
state: { name },
} = this;
return (
<List add={props.add} name={name} />
);
};
render() {
return (
<div className="App">
<header className="App-header">
<ProviderGender>
{this.renderList}
</ProviderGender>
<button
type="button"
onClick={this.onToggleList}
>
点我
</button>
</header>
</div>
);
}
}
(五)Context模式
概念:
React 的 Context 接口提供了一个无需为每层组件手动添加 props ,就能在组件树间进行数据传递的方法。
// themeProvider.js
import React from 'react';
import PropTypes from 'prop-types';
const ThemeContext = React.createContext();
const ThemeProvider = ThemeContext.Provider;
export const ThemeConsumer = ThemeContext.Consumer;
const Context = ({ value, children }) => (
<div>
<ThemeProvider value={value}>
{children}
</ThemeProvider>
</div>
);
Context.defaultProps = {
value: {},
};
Context.propTypes = {
value: PropTypes.objectOf(PropTypes.any),
children: PropTypes.node.isRequired,
};
export default Context;
// consumer.js
import React from 'react';
// import PropTypes from 'prop-types';
import { ThemeConsumer } from '../components/context/themeProvider';
const Consumer = () => (
<ThemeConsumer>
{
theme => (
<p
style={{
color: theme.textColor,
backgroundColor: theme.mainColor,
}}
>
内容区域
</p>
)
}
</ThemeConsumer>
);
Consumer.defaultProps = {};
Consumer.propTypes = {};
export default Consumer;
在app.js中调用为
import React from 'react';
// context
import ThemeProvider from './components/context/themeProvider';
import Consumer from './demo/consumer';
// style
import './app.css';
class App extends React.Component {
render() {
return (
<ThemeProvider className="App" value={{ mainColor: 'blue', textColor: 'red' }}>
<header className="App-header">
<Consumer />
</header>
</ThemeProvider>
);
}
}
export default App;
我们看到,consumer.js中并直接传递props,通过ThemeContext.Consumer直接读取到context中的theme。
优势:
Context 主要应用场景在于很多不同层级的组件需要访问同样一些的数据,可以跨组件访问一样的数据。
(五)组合组件模式
借用组合组件,使用者只需要传递子组件,子组件所需要的props在父组件会封装好,引用子组件的时候就没必要传递所有props了。
组合组件核心的两个方法是React.Children.map和React.cloneElement。React.Children.map 用来遍历获得组件的子元素。React.cloneElement 则用来复制元素,这个函数第一个参数就是被复制的元素,第二个参数可以增加新产生元素的 props ,我们就是利用这个函数,把想要的 props 传入子元素。
现在我们来实现一个antd的tabs组件,我们先看他如何使用的
import { Tabs } from 'antd';
const { TabPane } = Tabs;
function callback(key) {
console.log(key);
}
ReactDOM.render(
<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>,
mountNode,
);
首先我们实现tabs
import React from 'react';
import PropTypes from 'prop-types';
class Tabs extends React.Component {
state = {
activeIndex: 0,
};
onClickItem = (index) => {
this.setState({ activeIndex: index });
this.props.onChange(index);
};
renderHeaderItem = () => {
const {
state: { activeIndex },
props: { children },
} = this;
return React.Children.map(children, (childElem, index) => {
if (!childElem.type) return null;
const active = activeIndex === index;
return (
<div
style={{
maxWidth: '150px',
color: active ? 'red' : 'green',
border: active ? '1px red solid' : '0px',
}}
onClick={() => this.onClickItem(index)}
>
{childElem.props.tab}
</div>
);
});
};
render() {
const {
state: { activeIndex },
props: { children },
} = this;
return (
<div>
<div style={{ display: 'flex' }}>
{this.renderHeaderItem()}
</div>
{
React.Children.map(children, (childElem, index) => {
if (!(childElem.type && activeIndex === index)) return null;
return React.cloneElement(childElem, {
activeIndex,
index,
});
})
}
</div>
);
}
}
Tabs.defaultProps = {
onChange: () => null,
};
Tabs.propTypes = {
children: PropTypes.node.isRequired,
onChange: PropTypes.func,
};
export default Tabs;
然后实现tabPane.js
import React from 'react';
import PropTypes from 'prop-types';
const TabPane = ({ children }) => (
<div
style={{
width: '100%',
minHeight: 400,
border: '1px #fff solid',
}}
>
{children}
</div>
);
TabPane.defaultProps = {
// activeIndex: 0,
// index: 0,
children: '',
};
TabPane.propTypes = {
// activeIndex: PropTypes.number,
// index: PropTypes.number,
children: PropTypes.node,
};
export default TabPane;
在app.js中调用为
import React from 'react';
// tabs
import Tabs from './components/tabs/tabs';
import TabPane from './components/tabs/tabPane';
// style
import './app.css';
class App extends React.Component {
render() {
return (
<div>
<header className="App-header">
<Tabs>
<TabPane tab="One">
<div>内容1</div>
</TabPane>
<TabPane tab="Two">
<div>内容2</div>
</TabPane>
<TabPane tab="Three">
<div>内容3</div>
</TabPane>
</Tabs>
</header>
</div>
);
}
}
export default App;
模式所解决的问题
组合组件设计模式一般应用在一些共享组件上。如 select 和 option , Tab 和TabItem 等,通过组合组件,使用者只需要传递子组件,子组件所需要的 props 在父组件会封装好,引用子组件的时候就没必要传递所有 props 了。我们可以在共享的组件中运用这种模式,简化组件使用者的调用方式,antd 当中你就能看到许多组合组件的使用。
(六)继承模式
我们最后来谈谈OOP的继承模式。
如果组件定义为class组件,那么我们当然可以使用继承的模式来实现组件的复用。我们通过一个基类来实现一些通用的逻辑,然后再通过继承分别实现两个子类。
// base.js
import React from 'react';
class Base extends React.PureComponent {
getHeader = () => null;
render() {
return (
<div style={{ border: '1px solid red', margin: 5, width: 300 }}>
{this.getHeader()}
<div>这里是通用逻辑</div>
</div>
);
}
}
export default Base;
// demo.js
import React from 'react';
// import PropTypes from 'prop-types';
// components
import Base from '../components/oop/base';
class Demo1 extends Base {
getHeader = () => <span>demo1</span>;
}
class Demo2 extends Base {
getHeader = () => <span>Demo2</span>;
}
export { Demo1, Demo2 };
从上面代码可以看出,可以将通用逻辑封装在基本类中,通过继承,实现方法,达到复用。
缺点:
- 父类的属性和方法,子类是无条件继承的。也就是说,不管子类愿意不愿意,都必须继承父类所有的属性和方法,这样就不够灵活了。
- js中class并不直接支持多继承。
这两个缺点使得继承相对于组合组件缺少了灵活性以及可扩展性。
(七)结语
通过上面例子我们总结常见的模式,但是请记住,组合优于继承!组件的复用请第一时间想到使用组合而非继承。