原文摘自:https://dmitripavlutin.com/7-architectural-attributes-of-a-reliable-react-component/#6testableandtested
当只有唯一的原因能改变一个组件时,该组件就是“单一职责”的
单一职责原则(SRP - single responsibility principle)是编写 React 组件时的基础原则。
所谓职责可能指的是渲染一个列表、显示一个时间选择器、发起一次 HTTP 请求、描绘一幅图表,或是懒加载一个图片等等。组件应该只选择一个职责去实现。当修改组件所实现的唯一职责时(如对所渲染列表中的项目数量做出限制时),组件就会因此改变。
为何“只有一个改变的原因”如此重要呢?因为这样组件的修改就被隔离开来,变得可控了。
单一职责限制了组件的体积,也使其聚焦于一件事。这有利于编码,也方便了之后的修改、重用和测试。
举几个例子看看。
例子1:一个请求远端数据并做出处理的组件,其唯一的改变原因就是请求逻辑发送变化了,包括:
例子2:一个映射了由若干行组件形成的数组的表格组件,引起其改变的唯一原因是映射逻辑的改变:
你的组件是否有多个职责呢?如果答案是肯定的话,就应将其分割成若干单一职责的组件。
在项目发布之前,早期阶段编写的代码单元会频繁的修改。这些组件要能够被轻易的隔离并修改 -- 这正是 SRP 的题中之意。
一个组件有多个职责的情况经常被忽视,乍看起来,这并无不妥且容易理解:
这种天真烂漫的结构在编码之处非常简单。当应用不断增长并变得越来越复杂,需要对组件修改的时候,麻烦就会出现。
有很多理由去改变一个同时担负了多个职责的组件;那么主要的问题就会浮现:因为一个原因去改变组件,很可能会误伤其他的职责。
这样的设计是脆弱的。无意间带来的副作用极难预知和控制。
举个例子,<ChartAndForm>
负责绘制图表,同时还负责处理为图表提供数据的表单。那么 <ChartAndForm>
就有了两个改变的原因:绘图和表单。
当改变表单域的时候(如将 <input>
改为 <select>
),就有可能无意间破坏了图表的渲染。此外图表的实现也无法复用,因为它耦合了表单的细节。
要解决掉多职责的问题,需要将<ChartAndForm>
分割成 <Chart>
和 <Form>
两个组件。分别负责单一的职责:绘制图表或相应的处理表单。两个组件之间的通信通过 props 完成。
多职责问题的极端情况被称为“反模式的上帝组件”。一个上帝组件恨不得要知道应用中的所有事情,通常你会见到这种组件被命名为<Application>
、<Manager>
、<BigContainer>
或是<Page>
,并有超过 500 行的代码。
对于上帝组件,应通过拆分和组合使其符合 SRP。
想象有这样一个组件,其向指定的服务器发送一个 HTTP 请求以查询当前天气。当请求成功后,同样由该组件使用响应中的数据显示出天气状况。
import axios from 'axios';
// 问题:一个组件具有多个职责
class Weather extends Component {
constructor(props) {
super(props);
this.state = { temperature: 'N/A', windSpeed: 'N/A' };
}
render() {
const { temperature, windSpeed } = this.state;
return (
<div className="weather">
<div>Temperature: {temperature}°C</div>
<div>Wind: {windSpeed}km/h</div>
</div>
);
}
componentDidMount() {
axios.get('http://weather.com/api').then(function(response) {
const { current } = response.data;
this.setState({
temperature: current.temperature,
windSpeed: current.windSpeed
})
});
}
}
每当处理此类问题时,问一下自己:我是不是得把组件分割成更小的块呢?决定组件如何根据其职责发生改变,就能为以上问题提供最好的答案。
这个天气组件有两个原因去改变:
解决之道是将 <Weather>
分割成两个组件,其中每个都有自己的唯一职责。将其分别命名为 <WeatherFetch>
和 <WeatherInfo>
。
第一个组件 <WeatherFetch>
负责获取天气、提取响应数据并将之存入 state。只有 fetch 逻辑会导致其改变:
import axios from 'axios';
// 解决方案:组件只负责远程请求
class WeatherFetch extends Component {
constructor(props) {
super(props);
this.state = { temperature: 'N/A', windSpeed: 'N/A' };
}
render() {
const { temperature, windSpeed } = this.state;
return (
<WeatherInfo temperature={temperature} windSpeed={windSpeed} />
);
}
componentDidMount() {
axios.get('http://weather.com/api').then(function(response) {
const { current } = response.data;
this.setState({
temperature: current.temperature,
windSpeed: current.windSpeed
});
});
}
}
这种结果带来了什么好处呢?
举例来说,你可能会喜欢用 async/await 语法取代 promise 来处理服务器响应。这就是一种造成 fetch 逻辑改变的原因:
// 改变的原因:用 async/await 语法
class WeatherFetch extends Component {
// ..... //
async componentDidMount() {
const response = await axios.get('http://weather.com/api');
const { current } = response.data;
this.setState({
temperature: current.temperature,
windSpeed: current.windSpeed
});
}
}
因为 <WeatherFetch>
只会因为 fetch 逻辑而改变,所以对其的任何修改都不会影响其他的事情。用 async/await 就不会直接影响天气显示的方式。
而 <WeatherFetch>
渲染了 <WeatherInfo>
,后者只负责显示天气,只有视觉方面的理由会造成改变:
// 解决方案:组件职责只是显示天气
function WeatherInfo({ temperature, windSpeed }) {
return (
<div className="weather">
<div>Temperature: {temperature}°C</div>
<div>Wind: {windSpeed} km/h</div>
</div>
);
}
将 <WeatherInfo>
中的 "Wind: 0 km/h" 改为显示 "Wind: calm":
// Reason to change: handle calm wind
function WeatherInfo({ temperature, windSpeed }) {
const windInfo = windSpeed === 0 ? 'calm' : `${windSpeed} km/h`;
return (
<div className="weather">
<div>Temperature: {temperature}°C</div>
<div>Wind: {windInfo}</div>
</div>
);
}
同样,对 <WeatherInfo>
的这项改变是独立的,不会影响到 <WeatherFetch>
。
<WeatherFetch>
和 <WeatherInfo>
各司其职。每个组件的改变对其他的组件微乎其微。这就是单一职责原则的强大之处:修改被隔离开,从而对系统中其他组件的影响是微小而可预期的。
将分割后的组件按照职责组合在一起并不总是能符合单一职责原则。另一种被称作高阶组件(HOC - Higher order component)的有效方式可能会更适合:
HOC 就是一个以某组件作为参数并返回一个新组件的函数
HOC 的一个常见用途是为被包裹的组件添加额外的 props 或修改既有的 props。这项技术被称为属性代理(props proxy):
function withNewFunctionality(WrappedComponent) {
return class NewFunctionality extends Component {
render() {
const newProp = 'Value';
const propsProxy = {
...this.props,
// Alter existing prop:
ownProp: this.props.ownProp + ' was modified',
// Add new prop:
newProp
};
return <WrappedComponent {...propsProxy} />;
}
}
}
const MyNewComponent = withNewFunctionality(MyComponent);
甚至可以通过替换被包裹组件渲染的元素来形成新的 render 机制。这种 HOC 技术被称为渲染劫持(render highjacking):
function withModifiedChildren(WrappedComponent) {
return class ModifiedChildren extends WrappedComponent {
render() {
const rootElement = super.render();
const newChildren = [
...rootElement.props.children,
<div>New child</div> //插入新 child
];
return cloneElement(
rootElement,
rootElement.props,
newChildren
);
}
}
}
const MyNewComponent = withModifiedChildren(MyComponent);
如果想深入学习 HOC,可以阅读文末推荐的文章。
下面跟随一个实例来看看 HOC 的属性代理技术如何帮助我们实现单一职责。
<PersistentForm>
组件由一个输入框 input 和一个负责保存到存储的 button 组成。输入框的值被读取并存储到本地。
<div id="root"></div>
class PersistentForm extends React.Component {
constructor(props) {
super(props);
this.state = { inputValue: localStorage.getItem('inputValue') };
this.handleChange = this.handleChange.bind(this);
this.handleClick = this.handleClick.bind(this);
}
render() {
const { inputValue } = this.state;
return (
<div>
<input type="text" value={inputValue}
onChange={this.handleChange}/>
<button onClick={this.handleClick}>Save to storage</button>
</div>
)
}
handleChange(event) {
this.setState({
inputValue: event.target.value
});
}
handleClick() {
localStorage.setItem('inputValue', this.state.inputValue);
}
}
ReactDOM.render(<PersistentForm />, document.getElementById('root'));
当 input 变化时,在 handleChange(event) 中更新了组件的 state;当 button 点击时,在 handleClick() 中将上述值存入本地存储。
糟糕的是 <PersistentForm>
同时有两个职责:管理表单数据并将 input 值存入本地。
<PersistentForm>
似乎不应该具有第二个职责,即不应关心如何直接操作本地存储。那么按此思路先将组件优化成单一职责:渲染表单域,并附带事件处理函数。
class PersistentForm extends Component {
constructor(props) {
super(props);
this.state = { inputValue: props.initialValue };
this.handleChange = this.handleChange.bind(this);
this.handleClick = this.handleClick.bind(this);
}
render() {
const { inputValue } = this.state;
return (
<div className="persistent-form">
<input type="text" value={inputValue}
onChange={this.handleChange}/>
<button onClick={this.handleClick}>Save to storage</button>
</div>
);
}
handleChange(event) {
this.setState({
inputValue: event.target.value
});
}
handleClick() {
this.props.saveValue(this.state.inputValue);
}
}
组件从属性中接受 input 初始值 initialValue,并通过同样从属性中传入的 saveValue(newValue) 函数存储 input 的值;而这两个属性,是由叫做 withPersistence() 的属性代理 HOC 提供的。
现在 <PersistentForm>
符合 SRP 了。表单的更改称为了唯一导致其变化的原因。
查询和存入本地存储的职责被转移到了 withPersistence() HOC 中:
function withPersistence(storageKey, storage) {
return function(WrappedComponent) {
return class PersistentComponent extends Component {
constructor(props) {
super(props);
this.state = { initialValue: storage.getItem(storageKey) };
}
render() {
return (
<WrappedComponent
initialValue={this.state.initialValue}
saveValue={this.saveValue}
{...this.props}
/>
);
}
saveValue(value) {
storage.setItem(storageKey, value);
}
}
}
}
withPersistence() 是一个负责持久化的 HOC;它并不知道表单的任何细节,而是只聚焦于一项工作:为被包裹的组件提供 initialValue 字符串和 saveValue() 函数。
将 <PersistentForm>
和 withPersistence() 连接到一起就创建了一个新组件 <LocalStoragePersistentForm>
:
const LocalStoragePersistentForm
= withPersistence('key', localStorage)(PersistentForm);
const instance = <LocalStoragePersistentForm />;
只要 <PersistentForm>
正确使用 initialValue 和 saveValue() 两个属性,则对自身的任何修改都无法破坏被 withPersistence() 持有的本地存储相关逻辑,反之亦然。
这再次印证了 SRP 的功效:使修改彼此隔离,对系统中其余部分造成的影响很小。
此外,代码的可重用性也增强了。换成其他 <MyOtherForm>
组件,也能实现持久化逻辑了:
const LocalStorageMyOtherForm
= withPersistence('key', localStorage)(MyOtherForm);
const instance = <LocalStorageMyOtherForm />;
也可以轻易将存储方式改为 sessionStorage:
const SessionStoragePersistentForm
= withPersistence('key', sessionStorage)(PersistentForm);
const instance = <SessionStoragePersistentForm />;
对修改的隔离以及可重用性遍历,在初始版本的多职责 <PersistentForm>
组件中都是不存在的。
在组合无法生效的情景下,HOC 属性代理和渲染劫持技术往往能帮助组件实现单一职责。