前面若干篇文章(链接见文末)已经介绍了重构组件的各种姿势,让我们从“写出一个组件”到“写好组件”有了理论基础。
本文尝试将相关的概念做一个总结,列出一张可用、实用的方法论清单,让我们每次新建组件、修改组件时有章可循,真诚是让一切变好的基础,但实用的套路也是必不可少的。
主要概念
-
重构:在不改变外部行为的前提下,有条不紊地改善代码
-
依赖:A 组件的变化会影响 B 组件,就是 B 依赖于 A
-
耦合:耦合度就是组件之间的依赖性,要尽可能追求松耦合
-
副作用:除了返回值,还会修改全局变量或参数
-
纯函数:没有副作用,并针对相同的输入有相同的输出
Q: 为什么要优化、重构?A: 时过、境迁、物是、人非,代码必然变得难以理解
Q: 什么时候需要重构?A: 不光是事后修改自己或他人的代码,从代码新鲜出炉后就应开始
checklist
-
是否符合单一职责原则
-
只保留一项最主要的职责
-
让该职责的输入,依靠 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: 初步清理
-
重新缩进,手动格式化
-
删除没有被调用的方法
handleVal(e)
和整段注释掉的过时逻辑 -
修正不统一的 import 格式等
-
一处枚举值不为 0 的判断由
_d.type && ...
改为_d.type != 0 && ...
-
多处硬编码的中文 “
库存:{_d.standard[idx].onhand}
”,提取到语言包中
//组件中:
{i18n('spike.onhand', _d.standard[idx].onhand)}
//语言包
spike: {
onhand: '库存:{0}',
...
}
step2: 理清逻辑
-
阅读源码->结合文档->通读代码->在浏览器中跑通->询问原作者,理出大致逻辑
-
在关键的地方先补充上必要的注释
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)
原文作者:掘金
本文来源: 掘金 如需转载请联系原作者