本文尝试将相关的概念做一个总结,列出一张可用、实用的方法论清单,让我们每次新建组件、修改组件时有章可循,真诚是让一切变好的基础,但实用的套路也是必不可少的。
1.是否符合单一职责原则
2. 是否和其他组件松耦合
3.是否可以重用 相同/相似 的逻辑
4.组件能否提纯
5.组件命名是否清晰规范
6.代码含义是否清晰
7. 编写测试
用一个小的例子来实践这份清单,虽然不可能每次重构都把上面的 checkbox 画满 √,但基本的流程是相同的。
这是一个既有的组件,在秒杀活动的商品列表中点击某一项时,会在原页面弹出这个组件:
//<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) {
...
}
}
handleVal(e)
和整段注释掉的过时逻辑_d.type && ...
改为 _d.type != 0 && ...
库存:{_d.standard[idx].onhand}
”,提取到语言包中//组件中:
{i18n('spike.onhand', _d.standard[idx].onhand)}
//语言包
spike: {
onhand: '库存:{0}',
...
}
代码现状分析:
componentDidUpdate()
和 this.state.show
控制是否显示整个弹窗组件onClose()
只用来供外部调用关闭整个弹窗组件spikeSubmit(e)
只和 render() 中被 2 次渲染的 CountDown
组件关联CountDown
所在区域为 <header>
中一块较繁杂的代码,根据条件有两种不同的渲染gradeRules
和 desc
渲染出了 2 个结构一样的代码段根据“单一职责”和“重用”的原则,规划新的组件结构如下:
<PopupItem>
)应该只负责组合渲染大致框架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.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;
//<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 等还有进一步拆分细化的空间,此处按下不表,按此思路照猫画虎即可。
浏览拆分后的代码,虽然结构清晰了许多,但仔细观察会发现,诸如 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);
基本的结构梳理清楚些了,再看代码好像还是一下子读不懂;仍然以上面的 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);
现在,这段代码已经改观了很多,虽然过程和结果还称不上是优雅完美的,但无论是可重用性还是可阅读性都得到了改善;在此基础上无论是扩展功能或是复用逻辑都更加有把握了。
心里觉得没问题,浏览器也看过了;可一来手动验证难免百密一疏,对 mock 数据的要求也较高,二来之后再做哪怕一点小改动,理论上也要把之前这些成果再检查一遍。此时要做的就是对新划分好的关键组件,比如 PopupItemHeader
、PopupItemRuleList
,做出单元测试;并将之纳入打包发布工作流中,比如每次 build 或 commit 之前自动检查一遍,就能避免上述的担心。
对于 UI 组件,无论是作为一种特殊的 OOP 实现,或是采纳函数式的组合提纯,都需要尽量减少对外部的依赖、排除改变参数或全局变量的副作用,并尽可能拥有唯一的职责。
总之,重构并非锦上添花,而是软件开发过程中必不可少的工作。