代码重构之路

1. 开始

持续记录项目重构过程中的方法、思路和感想。

首先,要有一个意识,不要指望需求一成不变,不要指望项目结构、代码一成不变,程序员20%的时间花在写代码上,80%的时间花在后续维护迭代上。

如何让代码更健壮,更能适应将来的变化呢?个人实践中觉得有用的包括:

理论层面:

  • 抽象工具、组件,提高复用性
  • 单一职责原则,保持简单,并减少耦合
  • 保持单向依赖

实现层面:

  • ts语法,编译阶段发现错误
  • 函数式调用替换普通写法,减少中间变量
  • 项目分层,不同功能模块放在不同的文件夹

抽象能力是一种容易忽略的能力,每次遇到需求,容易无脑拷贝,其实可以看看和以前的组件、逻辑有什么异同,找找规则,封装、抽象出公共的、稳定的部分。

应该将复杂的事情简单做,化繁为简,而不是所谓的简单的事情复杂做。"化繁为简"这个词在招聘要求中看到过一次。

2. 关于重构

2.1. 目的

为什么进行重构呢?原因各不相同:

  • 生产力低下
    • 框架原因,比如jquery这种操作dom的方式,比不上数据驱动开发效率高
    • 代码自身问题,耦合严重,理解成本高、开发效率低
  • 性能差
  • 其他

2.2. 分级

从技术层面上,重构可以分为:

  • 技术栈迁移,比如用electron跨平台框架重构多端代码,本质是重写,也可视为广义的重构
  • 核心框架迁移,可复用一小部分逻辑,但核心代码仍需重写,比如jqueryvuevuereact
  • 核心框架升级,或架构模式变动,比如vue2vue3、客户端渲染到ssr
  • 其他框架迁移
  • 代码细节改造

从项目层面上,重构可以分为:

  • 项目级别
  • 多页面级别
  • 单页面级别
  • 组件级别

除了项目级别的改动,也就是推倒重来、另起炉灶,其他的重构都建议在原地改。

比如要沉淀组件库,或ui和逻辑的分离,先在原来的地方改好、再迁移效率会更高,因为如果在组件库上改,需要mock很多环境,效率低。

3. 原则

3.1. 单一职责

好代码一定是符合单一职责原则的,如果一个组件、函数、类不符合这个原则,短期内看似做的很快,长远来看,一定会后续迭代时频繁出bug、难维护,还会让其他开发者无法复用,导致频繁拷贝代码然后修改。

不符合单一职责原则的代码,模块化一定不会太好,可复用性低。

符合单一职责的代码,在需求变更、项目重构时可很容易的复用、迁移、扩展等,哪怕是技术栈的迁移,比如jqueryvuevuereact这种,开发者都能轻易的理清之前的逻辑。

3.2. 单向依赖

单一职责可以降低模块内部的复杂度,单向依赖可以降低模块之间的复杂度,增强可维护性。

不要用someMethod.call(this),这种本质上是违反了单向依赖,本来是组件依赖工具方法,现在工具方法又依赖了组件内的内容。

也许一开始用到了一两个属性,但随着不断迭代,后面这个this会越用越多,后面就积重难返了。所以一开始就不要用 .call(this)这种语法。

另外,公共基础库中对业务项目的config的依赖,也是违反了单向依赖原则,这种情况其实一个依赖注入就解决了,就是在startApp的时候把config注入进入即可。很多人理不清这里,其实是没想明白单向依赖。

3.3. 组件库

将核心组件、核心逻辑抽离到press-ui,好处如下:

  • 增强可维护性,提升开发效率
    • 通过整理代码,合并属性,分离业务逻辑等,让组件变纯粹,增强可维护性,进而提升效率
  • 减少业务和组件的耦合,降低各自复杂度,并减少bug
  • 封装核心逻辑,控制变化
    • 不用担心外部合作人员改乱代码,以及解决冲突时的覆盖问题
  • UI问题定位简单
    • 三端代码同时发布,以及多种类型的示例,覆盖面全,容易发现ui问题,以及三端表现不一致问题
  • 可提升性能
    • 通过自定义队伍数等变量,定位性能瓶颈,并解决性能问题
  • 提高可复用性,可应用到其他项目
  • 技术沉淀,技术积累,不断打磨组件细节

并非所有组件、逻辑都可以沉淀到组件库中,有以下准入条件:

  • press-ui内的组件、逻辑需要有一定的通用性或复杂性,比如buttoninputareamessage-detail等组件通用型强,schedule-tree组件复杂度高。
  • 对于press-ui中沉淀的逻辑也是如此。

press-ui的组件、逻辑都应该减少与业务的耦合,基础组件很容易做到,也很容易判断是否耦合,对于业务中沉淀下来的,如何做到呢?

  • 不能存在业务状态码,多重判断逻辑应该前置完成
  • 关注点分离,关注组件自身,而非业务
  • 最少知识原则,只传入必要的参数

3.4. 循序渐进

重构应该每次只做一件事,避免涉及多个功能点。好的重构可以随时停下来。

尽量重构而不是重写,要克服重写一遍的冲动。应该发现问题就及时重构,而不是等到积重难返时,再重写,那样成本更高。

4. 项目

4.1. 轮次设置

对于某项目的轮次设置页面,进行分层如下:

  • config主要是常量定义
  • helper包含一些辅助工具,比如对时间做格式化
  • popup是一些函数式调用组件
  • page引用了component组件和popup
  • logic包含请求接口逻辑
  • operation包含对logic的封装,比如错误提示、检查已经开赛的比赛个数等

函数式调用可以减少中间变量,比如上面的页面,有很多弹出层,且是嵌套的逻辑。点击A弹出B,点击B弹出C,点击C中的某个选项又要回到B,这其中涉及很多中间状态,需要写很多的datacomputedmethods,用函数调用的方式可以大大减少这种中间变量,使逻辑更内聚,不容易出bug。

重构后的效果:

  • 页面之前1600行,重构后降到500多行
  • 减少了中间变量,逻辑内聚、清晰
  • 减少了其他文件对页面this的引用,减少耦合

4.2. 赛程

首先,赛程相关部分的命名有些混乱, 对他们进行了统一。

将赛程树核心组件抽离,并遵循最小知识原则,roundListscheList计算生成,不再由外部传入。

对中间层做了公共props的抽离。这个中间层链接了多种赛制,是propsevents传递的桥梁。

4.3. 消息中心

对消息中心数据的处理逻辑进行了梳理:

  1. App.vuedispatch一些actions,如getTimUnReadCountgetTimRobotMessage,调用后台CGI,给到store,全局消息弹窗获取此数据。
  2. 需要IM的页面,引入IM Mixin,进行IM的登录、获取会话列表、设置事件回调等,同样给到storePageGlobal Notify可以使用此数据。

为什么搞这么复杂,省掉第一步不行吗?不行,因为IM SDK包太大,不能放在主包,所以需要Page层自己引入,而首页又需要消息提示,所以调用后台CGI,后台接口会调用IM的API获取消息。

另外,对消息列表、消息详情等组件进行了抽离,放到了press-ui中,并且对格式化消息的核心逻辑进行了分离和沉淀。

4.4. 分享模块

分享模块存在的问题包括:

  • 通过文件顶层变量进行属性共享,难追踪,难维护
  • 与项目耦合,比如依赖getWxSignaturegetMPOpenLink
  • 与框架耦合,比如依赖van-dialogvue
  • 扩展性差,比如游戏内不支持指定分享类型,需业务库自行处理
  • 无类型提示,黑盒子,易出错

如何优化?

对于全局变量,改为class,之前:

js 复制代码
// share.js
let shareObject = {};
const shareUiObj = {};



function foo(obj) {
  shareObject = obj;
  shareUiObj.initCommShareUI = function() {
    // ....
  }
}

现在:

ts 复制代码
// config.ts
export class ShareConfig {
  static shareObject: IShareObject;
  static shareUiObj: IShareUiObj;

  static setShareObject(shareObject: IShareObject) {
    this.shareObject = {
      ...this.shareObject,
      ...shareObject,
    };
  }

  static setShareUI(shareUiObj:  IShareUiObj) {
    this.shareUiObj = {
      ...this.shareUiObj,
      ...shareUiObj,
    };
  }
}



// share.ts
function foo(obj) {
  ShareConfig.setShareObject(obj);
  ShareConfig.setShareUI({
    initCommShareUI() {
      // ...
    }
  })
}

对于与业务项目耦合的部分,比如getWxSignaturegetMPOpenLink等方法,每个业务不一样,可以通过参数传递的方式,即参数为函数。

core不需要关心getMPOpenLink的所有参数,比如appidmpPathquery等,外界自己处理就行。

对于与框架耦合的部分,直接改成原生实现,不依赖任何框架。以后扩展到vue3react,或者更新组件库,比如不再引入Vant,该模块都无需做任何改动。

另外,可变部分都放到参数里,并补全类型提示。

5. 耦合

下面列举几种工作中常见的耦合。

5.1. 在window上挂载并修改变量

ts 复制代码
// common/vue/mixin/option/settingMixins.ts
function readRecommendStatus() {
  // ...
  window.app.isOpenRecommend = isOpen === '1';
}

这种使用方式不可控,你不知道这个值被什么人修改,在哪里修改,难以调试,难以定位问题。

除非这个变量在整个项目中是唯一的,独特的,比如window.vConsolewindow.aegis,这种在所有开发者中间有共识的,可以挂载,但是永远不可以修改。

5.2. 在文件中设置全局变量

ts 复制代码
// common/tools/share/share-web/index.js
let shareObject = {};

const initShare = function (params = {}) {
  const obj = params;
  obj.title = obj.title || document.getElementsByTagName('title')?.[0]?.innerText;
  // ...
  shareObject = obj;
}

当文件很小,内容很少时,文件顶部的全局变量不会出现问题。但是,当这个全局变量被很多方法使用,在很多地方都被更改时,就变得不可控了。比如share模块比较复杂,需适配许多场景,就不适合用这种方法。

所以要使用这种方式也可以,需保证文件不要太大,更新它的地方不要太多。

5.3. 引入外部模块

引入外部模块并使用,是天经地义的事情,似乎并没有什么问题。但是当你维护的是一个底层库、工具库、组件库的时候,就要考虑引入的这个东西是不是稳定的?是不是业务的?有没有违背单向依赖原则?

ts 复制代码
import miniJumpLogic from '../../minijump';

function openWeixinOpenLink(shareObject, failedCallback) {
  const data = shareObject.path.split('?');
  miniJumpLogic.postGetMiniProgramOpenLink({
    adcfg: {},
    appid: getJumpMiniProgramAppid(shareObject.gid),
    path: data.length > 0 ? data[0] : '',
    param_data: data.length > 1 ? data[1] : '',
    jump_type: JUMP_TYPES.CUSTOM_NO_ENCODE,
  })
    .then((response) => {
      if (response?.open_link) {
        window.location.href = response.open_link;
      } else if (failedCallback && typeof failedCallback === 'function') {
        failedCallback();
      }
    })
}


function initInGameShare() {
  window.slugSDKShareDelegate = function (type) {
    openWeixinOpenLink(shareObject, () => {
      window.customBrowserInterface.sendToWeixinWithUrl(
        2,
        shareObject.title,
        shareObject.desc,
        shareObject.link,
        shareObject.icon,
      );
    });
  };
}

上面代码是分享模块的一段逻辑,意思是游戏内分享的时候,优先获取openLink,然后跳转小程序。这段代码的问题是不应该引用logicpostGetMiniProgramOpenLink方法,这个是上层的业务的,不稳定,且违背了单向依赖原则。

可以用"依赖注入"的思想,把此方法作为参数注入。

ts 复制代码
function openWeixinOpenLink({
  failedCallback,
  getMiniProgramOpenLink,
}: {
  failedCallback: Function;
  getMiniProgramOpenLink?: IGetMiniProgramOpenLink;
}) {
  if (typeof getMiniProgramOpenLink === 'undefined') {
    failedCallback?.();
    return;
  }
  getMiniProgramOpenLink()
    .then((response) => {
      if (response?.open_link) {
        window.location.href = response.open_link;
      } else {
        failedCallback?.();
      }
    })
    .catch(() => {
      failedCallback?.();
    });
}

5.4. refs

$refs.oneRef.someMethod操作子组件方法,适合用于稳定组件,如果是频繁变动的业务库,就存在被人改动、然后异常的风险。

相关推荐
笃励1 小时前
Angular面试题二
前端·javascript·angular.js
速盾cdn2 小时前
速盾:高防 CDN 怎么屏蔽恶意访问?
前端·网络·web安全
一生为追梦7 小时前
Linux 内存管理机制概述
前端·chrome
喝旺仔la8 小时前
使用vue创建项目
前端·javascript·vue.js
心.c8 小时前
植物大战僵尸【源代码分享+核心思路讲解】
前端·javascript·css·数据结构·游戏·html
喝旺仔la8 小时前
Element Plus中button按钮相关大全
前端·javascript·vue.js
柒@宝儿姐8 小时前
Git的下载与安装
前端·javascript·vue.js·git·elementui·visual studio
Hiweir ·8 小时前
机器翻译之数据处理
前端·人工智能·python·rnn·自然语言处理·nlp·机器翻译
曈欣9 小时前
vue 中属性值上变量和字符串怎么拼接
前端·javascript·vue.js
QGC二次开发10 小时前
Vue3:v-model实现组件通信
前端·javascript·vue.js·前端框架·vue·html