原文摘自: https://dmitripavlutin.com/7-architectural-attributes-of-a-reliable-react-component/#6testableandtested
当只有唯一的原因能改变一个组件时, 该组件就是 "单一职责" 的
单一职责原则 (SRP - single responsibility principle) 是编写 React 组件时的基础原则.
所谓职责可能指的是渲染一个列表, 显示一个时间选择器, 发起一次 HTTP 请求, 描绘一幅图表, 或是懒加载一个图片等等. 组件应该只选择一个职责去实现. 当修改组件所实现的唯一职责时(如对所渲染列表中的项目数量做出限制时), 组件就会因此改变.
为何 "只有一个改变的原因" 如此重要呢? 因为这样组件的修改就被隔离开来, 变得可控了.
单一职责限制了组件的体积, 也使其聚焦于一件事. 这有利于编码, 也方便了之后的修改, 重用和测试.
举几个例子看看.
例子 1: 一个请求远端数据并做出处理的组件, 其唯一的改变原因就是请求逻辑发送变化了, 包括:
服务器 URL 被修改了
响应数据的格式被修改了
换了一种 HTTP 请求库
其他只关系到请求逻辑的改动
例子 2: 一个映射了由若干行组件形成的数组的表格组件, 引起其改变的唯一原因是映射逻辑的改变:
有一个限制最多渲染行数的需求, 比如 25 行
没有行可渲染的时候, 需要给出文字提示
其他只关系到数组和组件之间映射的改变
你的组件是否有多个职责呢? 如果答案是肯定的话, 就应将其分割成若干单一职责的组件.
在项目发布之前, 早期阶段编写的代码单元会频繁的修改. 这些组件要能够被轻易的隔离并修改 -- 这正是 SRP 的题中之意.
1. 多个职责的陷阱
一个组件有多个职责的情况经常被忽视, 乍看起来, 这并无不妥且容易理解:
撸个袖子就写起了代码: 不用区分去各种职责, 也不用规划相应的结构
形成了一个大杂烩的组件
不用为相互分隔的组件间的通信创建 props 和回调函数
这种天真烂漫的结构在编码之处非常简单. 当应用不断增长并变得越来越复杂, 需要对组件修改的时候, 麻烦就会出现.
有很多理由去改变一个同时担负了多个职责的组件; 那么主要的问题就会浮现: 因为一个原因去改变组件, 很可能会误伤其他的职责.
这样的设计是脆弱的. 无意间带来的副作用极难预知和控制.
举个例子,<ChartAndForm> 负责绘制图表, 同时还负责处理为图表提供数据的表单. 那么 <ChartAndForm> 就有了两个改变的原因: 绘图和表单.
当改变表单域的时候(如将 <input> 改为 <select>), 就有可能无意间破坏了图表的渲染. 此外图表的实现也无法复用, 因为它耦合了表单的细节.
要解决掉多职责的问题, 需要将 < ChartAndForm> 分割成 <Chart> 和 <Form> 两个组件. 分别负责单一的职责: 绘制图表或相应的处理表单. 两个组件之间的通信通过 props 完成.
多职责问题的极端情况被称为 "反模式的上帝组件". 一个上帝组件恨不得要知道应用中的所有事情, 通常你会见到这种组件被命名为 < Application>,<Manager>,<BigContainer > 或是 < Page>, 并有超过 500 行的代码.
对于上帝组件, 应通过拆分和组合使其符合 SRP.
2. 案例学习: 让组件具有单一职责
想象有这样一个组件, 其向指定的服务器发送一个 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
- })
- });
- }
- }
每当处理此类问题时, 问一下自己: 我是不是得把组件分割成更小的块呢? 决定组件如何根据其职责发生改变, 就能为以上问题提供最好的答案.
这个天气组件有两个原因去改变:
componentDidMount() 中的请求逻辑: 服务端 URL 或响应格式可能会被修改
render() 中的天气可视化形式: 组件显示天气的方式可能会改变很多次
解决之道是将 <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> 各司其职. 每个组件的改变对其他的组件微乎其微. 这就是单一职责原则的强大之处: 修改被隔离开, 从而对系统中其他组件的影响是微小而可预期的.
3. 案例学习: HOC 风格的单一职责原则
将分割后的组件按照职责组合在一起并不总是能符合单一职责原则. 另一种被称作高阶组件 (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 属性代理和渲染劫持技术往往能帮助组件实现单一职责.
- (end)
- ----------------------------------------
来源: https://juejin.im/post/5b207f3c5188257d47354a19