照方抓药 - 重构 React 组件的实用清单

前面若干篇文章(链接见文末)已经介绍了重构组件的各种姿势,让我们从“写出一个组件”到“写好组件”有了理论基础。

本文尝试将相关的概念做一个总结,列出一张可用、实用的方法论清单,让我们每次新建组件、修改组件时有章可循,真诚是让一切变好的基础,但实用的套路也是必不可少的

主要概念

  • 重构:在不改变外部行为的前提下,有条不紊地改善代码

  • 依赖:A 组件的变化会影响 B 组件,就是 B 依赖于 A

  • 耦合:耦合度就是组件之间的依赖性,要尽可能追求松耦合

  • 副作用:除了返回值,还会修改全局变量或参数

  • 纯函数:没有副作用,并针对相同的输入有相同的输出

    Q: 为什么要优化、重构?A: 时过、境迁、物是、人非,代码必然变得难以理解

    Q: 什么时候需要重构?A: 不光是事后修改自己或他人的代码,从代码新鲜出炉后就应开始

checklist

  1.  是否符合单一职责原则

  •  只保留一项最主要的职责

  •  让该职责的输入,依靠 props 获得

  •  把该职责的输出,用 props 中的回调处理

  •  在 propTypes 中写清所有 props 的 类型/结构 及是否必选

  •  用 defaultProps 列出默认值

  •  把另一项相关的职责,用 HOC 提取成组件,并满足上一项职责的输入输出

  •  重复以上步骤,直至完成所有职责

 是否和其他组件松耦合

  •  不能将实例引用或 refs 等传给外部,改为提供 props 回调

  •  外部不能调用本组件生命周期或 setState() 等方法,改为提供 props 回调

  •  是否有内部数组、对象等在运行中可能被扩展,改为 props 回调

  •  参考以上几步,反向检查是否直接 依赖/调用 了其他类的实例、方法等

  •  是否直接调用了其他 组件/类 的静态方法,改为 props 注入

  •  在 propTypes 中写清所有 props 的 类型/结构 及是否必选

  •  用 defaultProps 列出默认值

 是否可以重用 相同/相似 的逻辑

  •  重复的纯 逻辑/计算 可提取成工具方法,并用可选参数实现通用

  •  涉及界面的重复可封装成通用组件,并用可选 props 实现通用

  •  相似的其他组件,可将差异部分提取为 prop 传入的子组件,实现通用

  •  在 propTypes 中写清所有 props 的 类型/结构 及是否必选

  •  用 defaultProps 列出默认值

 组件能否提纯

  •  将全局变量、随机数、new Date / Date.now() 等提取为 props

  •  检查对相同输入是否保证相同输出,重复以上步骤

  •  将网络请求等异步操作提取为 props 回调

  •  检查组件是否有其他副作用,提取为 props

  •  包含回调的生命周期方法是否可以用 HOC 分离出去

  •  在 propTypes 中写清所有 props 的 类型/结构 及是否必选

  •  用 defaultProps 列出默认值

 组件命名是否清晰规范

  •  用驼峰拼写法,首字母也大写

  •  用尽可能通俗规范的英文,不用自定义的缩写

  •  写清楚含义,不单纯追求短命名

  •  应用同样的意义不用多种命名

 代码含义是否清晰

  •  不使用含糊无意义的变量名等

  •  直接写在代码中的数字要提取成命名清晰的常量

  •  重复以上两步,尽可能少甚至不用注释

  •  确实无法用代码本身解释的业务需求等,用注释解释

  •  修正无意义的或语焉不详的注释

  •  全局性的约定、共识、注释也无法说清的功能,总结到文档中

 编写测试

  •  针对重构后的组件,可以轻易编写单元测试了

  •  若编写测试仍遇到问题,重复检查以上所有步骤

重构案例:秒杀商品详情弹窗

用一个小的例子来实践这份清单,虽然不可能每次重构都把上面的 checkbox 画满 √,但基本的流程是相同的。

这是一个既有的组件,在秒杀活动的商品列表中点击某一项时,会在原页面弹出这个组件:

//<PROJECT_PATH>/components/spike/PopupItem.jsx

import Weui from 'weui';
import * as _ from 'underscore';
import Immutable from 'seamless-immutable';
import React,{Component} from 'react';
import {List, BasePopup} from '../AppLib';
import CountDown from '../CountDown';
import SpikeInfo from './SpikeInfo';
import {i18n, updateSpiked, updateGradeCard} from 'utils/product/util';

export default class PopupItem extends BasePopup {
    constructor(props) {
        ...
    }
    onClose() {
        ...
    }
    handleVal(e){
        console.log(e)
    }
    spikeSubmit(e) {
        ...
    }
    render() {
        ...
    }
    componentDidUpdate(prevProps, prevState) {
        ...
    }
    
    // componentDidMount(){
    //  let heights=this.refs.innbox,
    //      mEle = heights.firstElementChild;
    //  console.log(heights,mEle)
    //  _.delay(()=>{
    //      try {
    //          console.log(heights.querySelector('section').offsetHeight)
    //          // console.log(window.getComputedStyle('section').style.height)
    //          // mEle.style.height = 'auto';
    //          // mEle.style.position = 'relative';
    //          // rEle.style.overflowY = 'auto';
    //          // rEle.style.overflowX = 'hidden';
    //          // mEle.style.height = Math.max(mEle.clientHeight, wh) + 'px';
    //          let style={
    //              "height": heights.querySelector('section').offsetHeight+8+'px'
    //          };
    //          console.log(style)
    //          this.setState({
    //              style: style
    //          });
    //      } catch (ex) {
    //          console.log(ex.stack);
    //      }
    //  }, 0);
    // }
    
    componentWillReceiveProps(nextProps) {
        ...
    }
}

step1: 初步清理

  1. 重新缩进,手动格式化

  2. 删除没有被调用的方法 handleVal(e)  和整段注释掉的过时逻辑

  3. 修正不统一的 import 格式等

  4. 一处枚举值不为 0 的判断由 _d.type && ...  改为 _d.type != 0 && ...

  5. 多处硬编码的中文 “库存:{_d.standard[idx].onhand}”,提取到语言包中

//组件中:
{i18n('spike.onhand', _d.standard[idx].onhand)}

//语言包
spike: {
    onhand: '库存:{0}',
    ...
}

step2: 理清逻辑

  1. 阅读源码->结合文档->通读代码->在浏览器中跑通->询问原作者,理出大致逻辑

  2. 在关键的地方先补充上必要的注释

step3: 厘清职责

代码现状分析:

  • componentDidUpdate()  和 this.state.show  控制是否显示整个弹窗组件

  • onClose()  只用来供外部调用关闭整个弹窗组件

  • spikeSubmit(e)  只和 render() 中被 2 次渲染的 CountDown  组件关联

  • 除了以上问题,一些弹窗要求的特有样式也混杂在具体组件中

  • CountDown  所在区域为 <header>  中一块较繁杂的代码,根据条件有两种不同的渲染

  • 根据 gradeRules  和 desc  渲染出了 2 个结构一样的代码段

根据“单一职责”和“重用”的原则,规划新的组件结构如下:

  • 本组件( <PopupItem>  )应该只负责组合渲染大致框架

  • “是否显示” 和 “外部关闭” 等逻辑和特殊样式等“Popup通用组件”相关的逻辑用 HOC 提取,业务组件不用关心

  • CountDown  所在的头部两种样式的渲染部分及相关逻辑收敛成 <PopupItemHeader>  组件

  • 将原来底部两处重复的渲染提取成 <PopupItemRuleList>  组件

根据以上步骤,分别动手;修改后的代码是这样的:


//<PROJECT_PATH>/components/spike/PopupItem.jsx

import Weui from 'weui';
import Immutable,{asMutable} from 'seamless-immutable';
import React,{Component} from 'react';
import PropTypes from 'prop-types';
import {assign} from 'underscore';
import {List, BasePopup, makePopup} from '../AppLib';
import PopupItemHeader from './PopupItemHeader';
import PopupItemRuleList from './PopupItemRuleList';
import {i18n} from 'utils/product/util';

export class PopupItemCore extends BasePopup {

    constructor(props) {
        super(props);

        this.state = {
            data: Immutable(this.props.data)
        };
    }

    render() {
        const _d = this.state.data;
        return <div className="spikeDetail" id="product_detail">
            <div className="spikeInner">
                {this.getCloseButton()}
                <PopupItemHeader itemData={_d} />
                {_d.isVirtualCard &&
                    <span className="vir_msg">
                        {i18n('spike.virtualCardWarn')}</span>
                }
                <PopupItemRuleList styleName="gradeRules" 
                    listData={ _d.gradeRules ? asMutable(_d.gradeRules) : null } />
                <PopupItemRuleList styleName="desc" 
                    listData={ _d.describes ? asMutable(_d.describes) : null } />
            </div>
        </div>;
    }

    componentWillReceiveProps(nextProps) {
        if (this.state.data === nextProps.data) return;
        this.setState({
            data: nextProps.data,
            idx: nextProps.idx
        });
    }
}
PopupItemCore.propTypes = {
    data: PropTypes.instanceOf(Immutable).isRequired
};

export default makePopup(PopupItemCore);

//<PROJECT_PATH>/components/AppLib.jsx

...

/**
 * 让普通组件具有 onClose 方法
 * @private
 * @description 搭配 PopupOpener 使用
 */
function closeHandlerHOC(WrappedComponent) {
    return class CloseHandlerRefsHOC extends WrappedComponent {
        constructor(props) {
            super(props);
        }
        onClose() {
            let _d = this.state.data;
            this.props.onClose(_d);
        }
    };
}

/**
 * 让普通组件具有 打开关闭 逻辑
 * @private
 * @description 搭配 PopupOpener 使用
 */
function showLogicHOC(WrappedComponent) {
    return class ShowLogicRefsHOC extends WrappedComponent {
        constructor(props) {
            super(props);
            this.state = {
                data: Immutable(this.props.data),
                show: false
            };
        }
        componentDidUpdate(prevProps, prevState) {
            if (this.state.show === prevState.show) {
                return;
            }
            if (this.state.show) {
                $(this.refs.p_root).popup();
            }
        }
    };
}

/**
 * 让普通组件符合 PopupOpener 要求的样式
 * @private
 * @description 搭配 PopupOpener 使用
 */
function PopupItemHOC(WrappedComponent) {
    return class PopupItemHOC extends WrappedComponent {
        render() {
            return <div className="weui-popup-container" ref="p_root">
                <div className="weui-popup-modal">
                    { super.render() }
                </div>
            </div>;
        }
    };
}

/**
 * 让普通组件符合 PopupOpener 要求
 * @private
 * @description 搭配 PopupOpener 使用
 */
export function makePopup(WrappedComponent) {
    return showLogicHOC(closeHandlerHOC(PopupItemHOC(WrappedComponent)));
}

//<PROJECT_PATH>/components/spike/PopupItemRuleList.jsx

import React, {Component} from 'react';
import PropTypes from 'prop-types';

const PopupItemRuleList = ({listData, styleName})=>{
    return listData
        ? <div>{listData.map( (item,idx)=>(
            <div key={idx} className={styleName}>
                <h4>{item.key}</h4>
                <div
                    className="cont"
                    dangerouslySetInnerHTML={{__html: item.value}}>
                </div>
            </div>
        ))}</div>
        : null;
};
PopupItemRuleList.propTypes = {
    listData: PropTypes.arrayOf(PropTypes.shape({
        key: PropTypes.string.isRequired,
        value: PropTypes.string.isRequired
    })).isRequired,
    styleName: PropTypes.string.isRequired
};

export default PopupItemRuleList;

//<PROJECT_PATH>/components/spike/PopupItemHeader.jsx

import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {noop} from 'underscore';
import Immutable from 'seamless-immutable';
import CountDown from '../CountDown';
import SpikeInfo from './SpikeInfo';
import {i18n, updateSpiked, updateGradeCard} from 'utils/product/util';

export const PopupItemHeaderCore = ({itemData, countDownCallback})=>{
    const _d = itemData;
    const idx = 0;

    return <header 
            className={
                (_d.isVirtual ? ('coupon_'+ _d.couponType) : '')
                    + ' '
                    + (_d.pic ? 'nobefore' : '')
            }>
        {
            // <CountDown> 所在的逻辑代码块
        }
        <SpikeInfo ... />
    </header>
};
PopupItemHeaderCore.propTypes = {
    itemData: PropTypes.instanceOf(Immutable).isRequired,
    countDownCallback: PropTypes.func
};
PopupItemHeaderCore.defaultProps = {
    countDownCallback: noop
};

const HOC = (WrappedComponent)=>{
    class Header extends Component {
        constructor(props) {
            super(props);
        }
        spikeSubmit(e) {
            e.stopPropagation();
            
            const newK = this.props.itemData.setIn(['standard', 0, 'count'], 1);
            const arr = [newK];
            
            if(newK.isGradeCard) {
                if(newK.isCanBuy != 1) {
                    let warnMsg = i18n('spike.buyTip', newK.productGradeName);
                    if(newK.isCanBuy == 3) {
                        warnMsg = i18n('spike.buyTipNo');
                    }
                    $.modal({
                        title: '',
                        text: '<ul id="stockout_dlg_list">'+ warnMsg +'</ul>',
                        buttons: [{ text: i18n('spike.tipBtn'), onClick: noop }]
                    });
                    return;
                }
                updateGradeCard(arr, false);
                _appFacade.go('product_submit');
            }

            updateSpiked(arr, true);
            _appFacade.go('product_submit');
        };
        render() {
            return <WrappedComponent 
                itemData={this.props.itemData}
                countDownCallback={this.spikeSubmit.bind(this)} />;
        }
    };
    Header.propTypes = {
        itemData: PropTypes.instanceOf(Immutable).isRequired
    };
    return Header;
};

export default HOC(PopupItemHeaderCore);

至此,原本的一个文件被按职责隔离拆分开来,也用 PropTypes 等明确了所需的属性和回调等;虽然 PopupItemHeader.jsx 等还有进一步拆分细化的空间,此处按下不表,按此思路照猫画虎即可。

step4: 排除干扰因素

浏览拆分后的代码,虽然结构清晰了许多,但仔细观察会发现,诸如 i18n()、 updateSpiked()_appFacade.go()  、$.modal()  等外部或全局的方法,不时地混杂其中,分别用以格式化语言字符串、升级本地存储、全局路由跳转或调用自定义弹窗等。

正如在“提纯”的相关文章中所介绍的,这些外部依赖一方面会在测试时造成多余的负担,甚至难以模仿;另一方面也使得组件对于相同输入产生的输出变得不确定。

_appFacade  或 $  等全局对象从外部注入相对简单,而 updateSpiked、updateGradeCard 这样在模块上下文中引入的部分最难将息;在 React 组件中,可以选择的方法之一是用 props 注入可选值。

此处就以这两个操作本地存储的外部方法为例,完善 PopupItemHeader 中的 HOC 部分:


//<PROJECT_PATH>/components/spike/PopupItemHeader.jsx

import {noop} from 'underscore';
import {i18n, updateSpiked, updateGradeCard} from 'utils/product/util';

...

const HOC = (WrappedComponent)=>{
    class Header extends Component {
        constructor(props) {
            super(props);
        }
        spikeSubmit(e) {
            e.stopPropagation();
            
            const newK = this.props.itemData.setIn(['standard', 0, 'count'], 1);
            const arr = [newK];
            
            if(newK.isGradeCard) {
                if(newK.isCanBuy != 1) {
                    let warnMsg = this.props.word('spike.buyTip', newK.productGradeName);
                    if(newK.isCanBuy == 3) {
                        warnMsg = this.props.word('spike.buyTipNo');
                    }
                    $.modal({
                        title: '',
                        text: '<ul id="stockout_dlg_list">'+ warnMsg +'</ul>',
                        buttons: [{ text: this.props.word('spike.tipBtn'), onClick: noop }]
                    });
                    return;
                }
                this.props.localUpdateGradeCard(arr, false);
                _appFacade.go('product_submit');
            }

            this.props.localUpdateSpiked(arr, true);
            _appFacade.go('product_submit');
        };
        render() {
            return <WrappedComponent 
                itemData={this.props.itemData}
                countDownCallback={this.spikeSubmit.bind(this)} />;
        }
    };
    Header.propTypes = {
        itemData: PropTypes.instanceOf(Immutable).isRequired,
        word: PropTypes.func,
        localUpdateSpiked: PropTypes.func,
        localUpdateGradeCard: PropTypes.func
    };
    Header.defaultProps = {
        word: i18n,
        localUpdateGradeCard: updateGradeCard,
        localUpdateSpiked: updateSpiked
    };
    return Header;
};

export default HOC(PopupItemHeaderCore);

step5: 让代码自己说话

基本的结构梳理清楚些了,再看代码好像还是一下子读不懂;仍然以上面的 HOC 为例,首先组件本身在调试工具中的名称也让人摸不清头脑;其次,newK  是什么意思?if(newK.isCanBuy != 1)  在判断个啥?这些如果不去搜索相关的前后端代码,根本无从可知。

根据清单中的命名和注释规则,对其进一步优化:


//<PROJECT_PATH>/utils/product/constants.js

...

export const BUY_STATUS = {
    AVAILABLE: 1, //可以购买
    HIGH_LEVEL: 2, //等级高于购买的等级
    NOT_IN_QUEUE: 3 //没有在升降级规则队列里
};


//<PROJECT_PATH>/components/spike/PopupItemHeader.jsx

import {noop} from 'underscore';
import {i18n, updateSpiked, updateGradeCard} from 'utils/product/util';
import {BUY_STATUS} from 'utils/product/constants';

...

const PopupItemHeaderHOC = (WrappedComponent)=>{
    class PopupItemHeader extends Component {
        constructor(props) {
            super(props);
        }
        spikeSubmit(e) {
            e.stopPropagation();
            
            const firstZeroCountItemData = this.props.itemData.setIn(['standard', 0, 'count'], 1);
            const itemDataAsList = [firstZeroCountItemData];
            
            if(firstZeroCountItemData.isGradeCard) {
                if(firstZeroCountItemData.isCanBuy != BUY_STATUS.AVAILABLE) {
                    const WARN = firstZeroCountItemData.isCanBuy == BUY_STATUS.NOT_IN_QUEUE
                        ? this.props.word('spike.buyTipNo')
                        : this.props.word('spike.buyTip', firstZeroCountItemData.productGradeName);
                    
                    $.modal({
                        title: '',
                        text: '<ul id="stockout_dlg_list">'+ WARN +'</ul>',
                        buttons: [{ text: this.props.word('spike.tipBtn'), onClick: noop }]
                    });
                    
                    return;
                }
                this.props.localUpdateGradeCard(itemDataAsList, false);
                _appFacade.go('product_submit');
            }

            this.props.localUpdateSpiked(itemDataAsList, true);
            _appFacade.go('product_submit');
        };
        render() {
            return <WrappedComponent 
                itemData={this.props.itemData}
                countDownCallback={this.spikeSubmit.bind(this)} />;
        }
    };
    PopupItemHeader.propTypes = {
        itemData: PropTypes.instanceOf(Immutable).isRequired,
        word: PropTypes.func,
        localUpdateSpiked: PropTypes.func,
        localUpdateGradeCard: PropTypes.func
    };
    PopupItemHeader.defaultProps = {
        word: i18n,
        localUpdateGradeCard: updateGradeCard,
        localUpdateSpiked: updateSpiked
    };
    return PopupItemHeader;
};

export default PopupItemHeaderHOC(PopupItemHeaderCore);

step6: 编写测试验证更改

现在,这段代码已经改观了很多,虽然过程和结果还称不上是优雅完美的,但无论是可重用性还是可阅读性都得到了改善;在此基础上无论是扩展功能或是复用逻辑都更加有把握了。

心里觉得没问题,浏览器也看过了;可一来手动验证难免百密一疏,对 mock 数据的要求也较高,二来之后再做哪怕一点小改动,理论上也要把之前这些成果再检查一遍。此时要做的就是对新划分好的关键组件,比如 PopupItemHeader、 PopupItemRuleList ,做出单元测试;并将之纳入打包发布工作流中,比如每次 build 或 commit 之前自动检查一遍,就能避免上述的担心。

总结

对于 UI 组件,无论是作为一种特殊的 OOP 实现,或是采纳函数式的组合提纯,都需要尽量减少对外部的依赖、排除改变参数或全局变量的副作用,并尽可能拥有唯一的职责。

总之,重构并非锦上添花,而是软件开发过程中必不可少的工作。

相关文章

(end)



原文发布时间为:2018年06月28日
原文作者:掘金

本文来源: 掘金 如需转载请联系原作者


上一篇:牛逼的OSQL----大数据导入


下一篇:【物联网初探】- 04 - ESP32 结合 LVGL 库开发环境搭建 (Arduino IDE)