前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >照方抓药 - 重构 React 组件的实用清单

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

作者头像
江米小枣
发布2020-06-15 22:11:02
1.5K0
发布2020-06-15 22:11:02
举报
文章被收录于专栏:云前端

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

主要概念

  • 重构:在不改变外部行为的前提下,有条不紊地改善代码
  • 依赖:A 组件的变化会影响 B 组件,就是 B 依赖于 A
  • 耦合:耦合度就是组件之间的依赖性,要尽可能追求松耦合
  • 副作用:除了返回值,还会修改全局变量或参数
  • 纯函数:没有副作用,并针对相同的输入有相同的输出 Q: 为什么要优化、重构? A: 时过、境迁、物是、人非,代码必然变得难以理解 Q: 什么时候需要重构? A: 不光是事后修改自己或他人的代码,从代码新鲜出炉后就应开始

checklist

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

  • 只保留一项最主要的职责
  • 让该职责的输入,依靠 props 获得
  • 把该职责的输出,用 props 中的回调处理
  • 在 propTypes 中写清所有 props 的 类型/结构 及是否必选
  • 用 defaultProps 列出默认值
  • 把另一项相关的职责,用 HOC 提取成组件,并满足上一项职责的输入输出
  • 重复以上步骤,直至完成所有职责

2. 是否和其他组件松耦合

  • 不能将实例引用或 refs 等传给外部,改为提供 props 回调
  • 外部不能调用本组件生命周期setState() 等方法,改为提供 props 回调
  • 是否有内部数组、对象等在运行中可能被扩展,改为 props 回调
  • 参考以上几步,反向检查是否直接 依赖/调用 了其他类的实例、方法等
  • 是否直接调用了其他 组件/类 的静态方法,改为 props 注入
  • 在 propTypes 中写清所有 props 的 类型/结构 及是否必选
  • 用 defaultProps 列出默认值

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

  • 重复的纯 逻辑/计算 可提取成工具方法,并用可选参数实现通用
  • 涉及界面的重复可封装成通用组件,并用可选 props 实现通用
  • 相似的其他组件,可将差异部分提取为 prop 传入的子组件,实现通用
  • 在 propTypes 中写清所有 props 的 类型/结构 及是否必选
  • 用 defaultProps 列出默认值

4.组件能否提纯

  • 将全局变量、随机数、new Date / Date.now() 等提取为 props
  • 检查对相同输入是否保证相同输出,重复以上步骤
  • 将网络请求等异步操作提取为 props 回调
  • 检查组件是否有其他副作用,提取为 props
  • 包含回调的生命周期方法是否可以用 HOC 分离出去
  • 在 propTypes 中写清所有 props 的 类型/结构 及是否必选
  • 用 defaultProps 列出默认值

5.组件命名是否清晰规范

  • 用驼峰拼写法,首字母也大写
  • 用尽可能通俗规范的英文,不用自定义的缩写
  • 写清楚含义,不单纯追求短命名
  • 应用同样的意义不用多种命名

6.代码含义是否清晰

  • 不使用含糊无意义的变量名等
  • 直接写在代码中的数字要提取成命名清晰的常量
  • 重复以上两步,尽可能少甚至不用注释
  • 确实无法用代码本身解释的业务需求等,用注释解释
  • 修正无意义的或语焉不详的注释
  • 全局性的约定、共识、注释也无法说清的功能,总结到文档中

7. 编写测试

  • 针对重构后的组件,可以轻易编写单元测试了
  • 若编写测试仍遇到问题,重复检查以上所有步骤

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

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

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

代码语言:javascript
复制
//<PROJECT_PATH>/components/spike/PopupItem.jsximport 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}”,提取到语言包中
代码语言:javascript
复制
//组件中:
{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> 中一块较繁杂的代码,根据条件有两种不同的渲染
  • 根据 gradeRulesdesc 渲染出了 2 个结构一样的代码段

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

  • 本组件( <PopupItem> )应该只负责组合渲染大致框架
  • “是否显示” 和 “外部关闭” 等逻辑和特殊样式等“Popup通用组件”相关的逻辑用 HOC 提取,业务组件不用关心
  • CountDown 所在的头部两种样式的渲染部分及相关逻辑收敛成 <PopupItemHeader> 组件
  • 将原来底部两处重复的渲染提取成 <PopupItemRuleList> 组件

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

代码语言:javascript
复制
//<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);
代码语言:javascript
复制
//<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)));
}
代码语言:javascript
复制
//<PROJECT_PATH>/components/spike/PopupItemRuleList.jsximport 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;
代码语言:javascript
复制
//<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 部分:

代码语言:javascript
复制
//<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) 在判断个啥?这些如果不去搜索相关的前后端代码,根本无从可知。

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

代码语言:javascript
复制
//<PROJECT_PATH>/utils/product/constants.js

...

export const BUY_STATUS = {
    AVAILABLE: 1, //可以购买
    HIGH_LEVEL: 2, //等级高于购买的等级
    NOT_IN_QUEUE: 3 //没有在升降级规则队列里
};
代码语言:javascript
复制
//<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 数据的要求也较高,二来之后再做哪怕一点小改动,理论上也要把之前这些成果再检查一遍。此时要做的就是对新划分好的关键组件,比如 PopupItemHeaderPopupItemRuleList ,做出单元测试;并将之纳入打包发布工作流中,比如每次 build 或 commit 之前自动检查一遍,就能避免上述的担心。

总结

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

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

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2018-06-26,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 云前端 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 主要概念
  • checklist
  • 重构案例:秒杀商品详情弹窗
    • step1: 初步清理
      • step2: 理清逻辑
        • step3: 厘清职责
          • step4: 排除干扰因素
            • step5: 让代码自己说话
              • step6: 编写测试验证更改
              • 总结
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档