重构不是对以前代码的全盘否定, 而是利用更好的方式, 写出更好, 更有维护性代码. 不断的追求与学习, 才有更多的进步.
1. 前言
做前端开发有一段时间了, 在这段时间里面, 对于自己的要求, 不仅仅是项目能完成, 功能正常使用这一层面上. 还尽力的研究怎么写出优雅的代码, 性能更好, 维护性更强的代码, 通俗一点就是重构. 这篇文章算是我一个小记录, 在此分享一下. 该文章主要针对介绍, 例子也简单, 深入复杂的例子等以后有适合的实例再进行写作分享. 如果大家对怎么写出优雅的代码, 可维护的代码, 有自己的见解, 或者有什么重构的实力, 欢迎指点评论.
关于重构, 准备写一个系列的文章, 不定时更新, 主要针对以下方案: 逻辑混乱重构, 分离职责重构, 添加扩展性重构, 简化使用重构, 代码复用重构. 其中会穿插以下原则: 单一职责原则, 最少知识原则, 开放 - 封闭原则. 如果大家对重构有什么好的想法, 或者有什么好的实例, 欢迎留言评论, 留下宝贵的建议.
2. 什么是重构
首先, 重构不是重写. 重构大概的意思是在不影响项目的功能使用前提下, 使用一系列的重构方式, 改变项目的内部结构. 提高项目内部的可读性, 可维护性.
无论是什么项目, 都有一个从简单到复杂的一个迭代过程. 在这个过程里面, 在不影响项目的使用情况下, 需要不断的对代码进行优化, 保持或者增加代码的可读性, 可维护性. 这样一来, 就可以避免在团队协作开发上需要大量的沟通, 交流. 才能加入项目的开发中.
3. 为什么重构
衣服脏了就洗, 破了就补, 不合穿就扔.
随着业务需求的不断增加, 变更, 舍弃, 项目的代码也难免会出现瑕疵, 这就会影响代码的可读性, 可维护性, 甚至影响项目的性能. 而重构的目的, 就是为了解决这些瑕疵, 保证代码质量和性能. 但是前提是不能影响项目的使用.
至于重构的原因, 自己总结了一下, 大概有以下几点
函数逻辑结构混乱, 或因为没注释原因, 连原代码写作者都很难理清当中的逻辑.
函数无扩展性可言, 遇到新的变化, 不能灵活的处理.
因为对象强耦合或者业务逻辑的原因, 导致业务逻辑的代码巨大, 维护的时候排查困难.
重复代码太多, 没有复用性.
随着技术的发展, 代码可能也需要使用新特性进行修改.
随着学习的深入, 对于以前的代码, 是否有着更好的一个解决方案.
因为代码的写法, 虽然功能正常使用, 但是性能消耗较多, 需要换方案进行优化
4. 何时重构
在合适的时间, 在合适的事情
在我的理解中, 重构可以说是贯穿整一个项目的开发和维护周期, 可以当作重构就是开发的一部分. 通俗讲, 在开发的任何时候, 只要看到代码有别扭, 激发了强迫症, 就可以考虑重构了. 只是, 重构之前先参考下面几点.
首先, 重构是需要花时间去做的一件事. 花的时间可能比之前的开发时间还要多.
其次, 重构是为了把代码优化, 前提是不能影响项目的使用.
最后, 重构的难度大小不一, 可能只是稍微改动, 可能难度比之前开发还要难.
基于上面的几点, 需要大家去评估是否要进行重构. 评估的指标, 可以参考下面几点
数量: 需要重构的代码是否过多.
质量: 可读性, 可维护性, 代码逻辑复杂度, 等问题, 对代码的质量影响是否到了一个难以忍受的地步.
时间: 是否有充裕的时间进行重构和测试.
效果: 如果重构了代码, 得到哪些改善, 比如代码质量提高了, 性能提升了, 更好的支持后续功能等.
选定目标, 针对性出击
怎么重构, 这个就是具体情况, 具体分析了. 如同 "为什么重构一样". 发现代码有什么问题就针对什么情况进行改进.
重构也是写代码, 但是不止于写, 更在于整理和优化. 如果说写代码需要一个'学习 -- 了解 - 熟练'的过程, 那么重构就需要一个'学习 - 感悟 - 突破 - 熟练'的过程.
针对重构的情况, 下面简单的用几个例子进行说明
5-1. 函数无扩展性
如下面一个例子, 在我一个库的其中一个 API
- // 检测字符串
- //checkType('165226226326','mobile')
- //result:false
- let checkType=function(str, type) {
- switch (type) {
- case 'email':
- return /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(str);
- case 'mobile':
- return /^1[3|4|5|7|8][0-9]{9}$/.test(str);
- case 'tel':
- return /^(0\d{2,3}-\d{7,8})(-\d{1,4})?$/.test(str);
- case 'number':
- return /^[0-9]$/.test(str);
- case 'english':
- return /^[a-zA-Z]+$/.test(str);
- case 'text':
- return /^\w+$/.test(str);
- case 'chinese':
- return /^[\u4E00-\u9FA5]+$/.test(str);
- case 'lower':
- return /^[a-z]+$/.test(str);
- case 'upper':
- return /^[A-Z]+$/.test(str);
- default:
- return true;
- }
- }
这个 API 看着没什么毛病, 能检测常用的一些数据. 但是有以下两个问题.
1. 但是如果想到添加其他规则的呢? 就得在函数里面增加 case . 添加一个规则就修改一次! 这样违反了开放 - 封闭原则(对扩展开放, 对修改关闭). 而且这样也会导致整个 API 变得臃肿, 难维护.
2. 还有一个问题就是, 比如 A 页面需要添加一个金额的校验, B 页面需要一个日期的校验, 但是金额的校验只在 A 页面需要, 日期的校验只在 B 页面需要. 如果一直添加 case . 就是导致 A 页面把只在 B 页面需要的校验规则也添加进去, 造成不必要的开销. B 页面也同理.
建议的方式是给这个 API 增加一个扩展的接口
- let checkType=(function(){
- let rules={
- email(str){
- return /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(str);
- },
- mobile(str){
- return /^1[3|4|5|7|8][0-9]{9}$/.test(str);
- },
- tel(str){
- return /^(0\d{2,3}-\d{7,8})(-\d{1,4})?$/.test(str);
- },
- number(str){
- return /^[0-9]$/.test(str);
- },
- english(str){
- return /^[a-zA-Z]+$/.test(str);
- },
- text(str){
- return /^\w+$/.test(str);
- },
- chinese(str){
- return /^[\u4E00-\u9FA5]+$/.test(str);
- },
- lower(str){
- return /^[a-z]+$/.test(str);
- },
- upper(str){
- return /^[A-Z]+$/.test(str);
- }
- };
- // 暴露接口
- return {
- // 校验
- check(str, type){
- return rules[type]?rules[type](str):false;
- },
- // 添加规则
- addRule(type,fn){
- rules[type]=fn;
- }
- }
- })();
- // 调用方式
- // 使用 mobile 校验规则
- console.log(checkType.check('188170239','mobile'));
- // 添加金额校验规则
- checkType.addRule('money',function (str) {
- return /^[0-9]+(.[0-9]{2})?$/.test(str)
- });
- // 使用金额校验规则
- console.log(checkType.check('18.36','money'));
上面的代码, 是多了一些, 但是理解起来也没怎么费劲, 而且拓展性也有了.
上面这个改进其实是使用了策略模式 (把一系列的算法进行封装, 使算法代码和逻辑代码可以相互独立, 并且不会影响算法的使用) 进行改进的. 策略模式的概念理解起来有点绕, 但是大家看着代码, 应该不绕.
这里展开讲一点, 在功能上来说, 通过重构, 给函数增加扩展性, 这里实现了. 但是如果上面的 checkType 是一个开源项目的 API, 重构之前调用方式是:
checkType('165226226326','phone')
. 重构之后调用方式是:
checkType.check('188170239','phone')
; 或者
checkType.addRule()
;. 如果开源项目的作者按照上面的方式重构, 那么之前使用了开源项目的 checkType 这个 API 的开发者, 就可能悲剧了, 因为只要开发者一更新这个项目版本, 就有问题. 因为上面的重构没有做向下兼容.
如果要向下兼容, 其实也不难. 加一个判断而已.
- let checkType=(function(){
- let rules={
- email(str){
- return /^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/.test(str);
- },
- mobile(str){
- return /^1[3|4|5|7|8][0-9]{9}$/.test(str);
- },
- tel(str){
- return /^(0\d{2,3}-\d{7,8})(-\d{1,4})?$/.test(str);
- },
- number(str){
- return /^[0-9]$/.test(str);
- },
- english(str){
- return /^[a-zA-Z]+$/.test(str);
- },
- text(str){
- return /^\w+$/.test(str);
- },
- chinese(str){
- return /^[\u4E00-\u9FA5]+$/.test(str);
- },
- lower(str){
- return /^[a-z]+$/.test(str);
- },
- upper(str){
- return /^[A-Z]+$/.test(str);
- }
- };
- // 暴露接口
- return function (str,type){
- // 如果 type 是函数, 就扩展 rules, 否则就是验证数据
- if(type.constructor===Function){
- rules[str]=type;
- }
- else{
- return rules[type]?rules[type](str):false;
- }
- }
- })();
- console.log(checkType('188170239','mobile'));
- checkType('money',function (str) {
- return /^[0-9]+(.[0-9]{2})?$/.test(str)
- });
- // 使用金额校验规则
- console.log(checkType('18.36','money'));
这样运行能正常, 也有扩展性性, 但是对于代码洁癖的来说, 这样写法不优雅. 因为 checkType 违反了函数单一原则. 一个函数负责过多的职责可能会导致以后不可估量的问题, 使用方面也很让人疑惑.
面对这样的情况, 就个人而言, 了解的做法是: 保留 checkType , 不做任何修改, 在项目里面增加一个新的 API , 比如 checkTypOfString , 把重构的代码写到 checkTypOfString 里面. 通过各种方式引导开发者少旧 checkType , 多用 checkTypOfString . 之后的项目迭代里面, 合适的时候废弃 checkType.
5-2. 函数违反单一原则
函数违反单一原则最大一个后果就是会导致逻辑混乱. 如果一个函数承担了太多的职责, 不妨试下: 函数单一原则 -- 一个函数只做一件事.
如下例子
- // 现有一批的录入学生信息, 但是数据有重复, 需要把数据进行去重. 然后把为空的信息, 改成保密.
- let students=[
- {
- id:1,
- name:'守候',
- sex:'男',
- age:'',
- },
- {
- id:2,
- name:'浪迹天涯',
- sex:'男',
- age:''
- },
- {
- id:1,
- name:'守候',
- sex:'',
- age:''
- },
- {
- id:3,
- name:'鸿雁',
- sex:'',
- age:'20'
- }
- ];
- function handle(arr) {
- // 数组去重
- let _arr=[],_arrIds=[];
- for(let i=0;i<arr.length;i++){
- if(_arrIds.indexOf(arr[i].id)===-1){
- _arrIds.push(arr[i].id);
- _arr.push(arr[i]);
- }
- }
- // 遍历替换
- _arr.map(item=>{
- for(let key in item){
- if(item[key]===''){
- item[key]='保密';
- }
- }
- });
- return _arr;
- }
- console.log(handle(students))
运行结果没有问题, 但是大家想一下, 如果以后, 如果改了需求, 比如, 学生信息不会再有重复的记录, 要求把去重的函数去掉. 这样一来, 就是整个函数都要改了. 还影响到下面的操作流程. 相当于了改了需求, 整个方法全跪. 城门失火殃及池鱼.
下面使用单一原则构造一下
- let handle={
- removeRepeat(arr){
- // 数组去重
- let _arr=[],_arrIds=[];
- for(let i=0;i<arr.length;i++){
- if(_arrIds.indexOf(arr[i].id)===-1){
- _arrIds.push(arr[i].id);
- _arr.push(arr[i]);
- }
- }
- return _arr;
- },
- setInfo(arr){
- arr.map(item=>{
- for(let key in item){
- if(item[key]===''){
- item[key]='保密';
- }
- }
- });
- return arr;
- }
- };
- students=handle.removeRepeat(students);
- students=handle.setInfo(students);
- console.log(students);
结果一样, 但是需求改下, 比如不需要去重, 把代码注释或者直接删除就好. 这样相当于把函数的职责分离了, 而且职责之前互不影响. 中间去除那个步骤不会影响下一步.
- //students=handle.removeRepeat(students);
- students=handle.setInfo(students);
- console.log(students);
5-3. 函数写法优化
这种情况就是, 对于以前的函数, 在不影响使用的情况下, 现在有着更好的实现方式. 就使用更好的解决方案, 替换以前的解决方案.
比如下面的需求, 需求是群里一个朋友发出来的, 后来引发的一些讨论. 给出一个
20180408000000
字符串, formatDate 函数要处理并返回
- 2018-04-08 00:00:00
- .
以前的解法
- let _dete='20180408000000'
- function formatStr(str){
- return str.replace(/(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/, "$1-$2-$3 $4:$5:$6")
- }
- formatStr(_dete);
- //"2018-04-08 00:00:00"
后来研究了这样的解法. 这个方式就是根据 x 的位置进行替换填充数据, 不难理解
- let _dete='20180408000000'
- function formatStr(str,type){
- let _type=type||"xxxx-xx-xx xx:xx:xx";
- for(let i = 0; i <str.length; i++){
- _type = _type.replace('x', str[i]);
- }
- return _type;
- }
- formatStr(_dete);
- result:"2018-04-08 00:00:00"
在之后的几天, 在掘金一篇文章 (那些优雅灵性的 JS 代码片段, 感谢提供的宝贵方式) 的评论里面发现更好的实现方式, 下面根据上面的需求自己进行改造.
- let _dete='20180408000000'
- function formatStr(str,type){
- let i = 0,_type = type||"xxxx-xx-xx xx:xx:xx";
- return _type .replace(/x/g, () => str[i++])
- }
- formatStr(_dete);
- result:"2018-04-08 00:00:00"
5-4. 代码复用
上面几个例子都是 js 的, 说下与 html 沾边一点的两个例子 --vue 数据渲染.
下面代码中, payChannelEn2Cn addZero formatDateTime 函数都是在 vue 的 methods 里面. 大家注意.
以前写法
- <span v-if="cashType==='cash'">现金</span>
- <span v-else-if="cashType==='check'">支票</span>
- <span v-else-if="cashType==='draft'">汇票</span>
- <span v-else-if="cashType==='zfb'">支付宝</span>
- <span v-else-if="cashType==='wx_pay'">微信支付</span>
- <span v-else-if="cashType==='bank_trans'">银行转账</span>
- <span v-else-if="cashType==='pre_pay'">预付款</span>
这样写的问题在于, 首先是代码多, 第二是如果项目有 10 个地方这样渲染数据, 如果渲染的需求变了. 比如银行转账的值从 bank_trans 改成 bank , 那么就得在项目里面修改 10 次. 时间成本太大.
后来就使用了下面的写法, 算是一个小重构吧
<span>{{payChannelEn2Cn(cashType)}}</span>
payChannelEn2Cn 函数, 输出结果
- payChannelEn2Cn(tag){
- let _obj = {
- 'cash': '现金',
- 'check': '支票',
- 'draft': '汇票',
- 'zfb': '支付宝',
- 'wx_pay': '微信支付',
- 'bank_trans': '银行转账',
- 'pre_pay': '预付款'
- };
- return _obj[tag];
- }
还有一个例子就是时间戳转时间的写法. 原理一样, 只是代码不同. 下面是原来的代码.
- <span>{{new Date(payTime).toLocaleDateString().replace(/\//g, '-')}}
- {{addZero(new Date(payTime).getHours())}}:
- {{addZero(new Date(payTime).getMinutes())}}:
- {{addZero(new Date(payTime).getSeconds())}}</span>
addZero 时间补零函数
- Example:3->03
- addZero(i){
- if (i <10) {
- i = "0" + i;
- }
- return i;
- }
问题也和上面的一样, 这里就不多说了, 就写重构后的代码
<span>{{formatDateTime(payTime)}} </span>
formatDateTime 函数, 格式化字符串
- formatDateTime(dateTime){
- return `${new Date(payTime).toLocaleDateString().replace(/\//g, '-')} ${this.addZero(new Date(payTime).getHours())}:${this.addZero(new Date(payTime).getMinutes())}:${this.addZero(new Date(payTime).getSeconds())}`;
- }
可能很多人看到这里, 觉得重构很简单, 这样想是对的, 重构就是这么简单. 但是重构也难, 因为重构一步登天, 需要一个逐步的过程, 甚至可以说重构就是一次次的小改动, 逐步形成一个质变的过程. 如何保证每一次的改动都是有意义的改善代码; 如何保证每一次的改动都不会影响到项目的正常使用; 如果发现某次改动没有意义, 或者改动了反而让代码更糟糕的时候, 可以随时停止或回滚代码, 这些才是重构的难点.
6. 小结
关于重构就说到这里了, 该文章主要是介绍重构, 例子方面都是很简单的一些例子. 目的是为了好些理解重构的一些概念. 关于重构, 可能很复杂, 可能很简单. 怎么重构也是具体情况, 具体分析, 重构也没有标准的答案. 以后, 如果有好的例子, 我会第一时间分享, 给大家具体情况, 具体分析的讲述: 为什么重构, 怎么重构.
最后, 如果大家对文章有什么建议, 看法, 欢迎交流, 相互学习, 共同进步.
来源: https://juejin.im/post/5adc8e18518825672b0352a8