1. 开始
持续记录项目重构过程中的方法、思路和感想。
首先,要有一个意识,不要指望需求一成不变,不要指望项目结构、代码一成不变,程序员20%的时间花在写代码上,80%的时间花在后续维护迭代上。
如何让代码更健壮,更能适应将来的变化呢?个人实践中觉得有用的包括:
理论层面:
- 抽象工具、组件,提高复用性
- 单一职责原则,保持简单,并减少耦合
- 保持单向依赖
实现层面:
- ts语法,编译阶段发现错误
- 函数式调用替换普通写法,减少中间变量
- 项目分层,不同功能模块放在不同的文件夹
抽象能力是一种容易忽略的能力,每次遇到需求,容易无脑拷贝,其实可以看看和以前的组件、逻辑有什么异同,找找规则,封装、抽象出公共的、稳定的部分。
应该将复杂的事情简单做,化繁为简,而不是所谓的简单的事情复杂做。"化繁为简"这个词在招聘要求中看到过一次。
2. 关于重构
2.1. 目的
为什么进行重构呢?原因各不相同:
- 生产力低下
- 框架原因,比如
jquery
这种操作dom
的方式,比不上数据驱动开发效率高 - 代码自身问题,耦合严重,理解成本高、开发效率低
- 框架原因,比如
- 性能差
- 其他
2.2. 分级
从技术层面上,重构可以分为:
- 技术栈迁移,比如用
electron
跨平台框架重构多端代码,本质是重写,也可视为广义的重构 - 核心框架迁移,可复用一小部分逻辑,但核心代码仍需重写,比如
jquery
到vue
、vue
到react
- 核心框架升级,或架构模式变动,比如
vue2
到vue3
、客户端渲染到ssr
- 其他框架迁移
- 代码细节改造
从项目层面上,重构可以分为:
- 项目级别
- 多页面级别
- 单页面级别
- 组件级别
除了项目级别的改动,也就是推倒重来、另起炉灶,其他的重构都建议在原地改。
比如要沉淀组件库,或ui和逻辑的分离,先在原来的地方改好、再迁移效率会更高,因为如果在组件库上改,需要mock很多环境,效率低。
3. 原则
3.1. 单一职责
好代码一定是符合单一职责原则的,如果一个组件、函数、类不符合这个原则,短期内看似做的很快,长远来看,一定会后续迭代时频繁出bug、难维护,还会让其他开发者无法复用,导致频繁拷贝代码然后修改。
不符合单一职责原则的代码,模块化一定不会太好,可复用性低。
符合单一职责的代码,在需求变更、项目重构时可很容易的复用、迁移、扩展等,哪怕是技术栈的迁移,比如jquery
到vue
、vue
到react
这种,开发者都能轻易的理清之前的逻辑。
3.2. 单向依赖
单一职责可以降低模块内部的复杂度,单向依赖可以降低模块之间的复杂度,增强可维护性。
不要用someMethod.call(this)
,这种本质上是违反了单向依赖,本来是组件依赖工具方法,现在工具方法又依赖了组件内的内容。
也许一开始用到了一两个属性,但随着不断迭代,后面这个this
会越用越多,后面就积重难返了。所以一开始就不要用 .call(this)
这种语法。
另外,公共基础库中对业务项目的config
的依赖,也是违反了单向依赖原则,这种情况其实一个依赖注入就解决了,就是在startApp
的时候把config
注入进入即可。很多人理不清这里,其实是没想明白单向依赖。
3.3. 组件库
将核心组件、核心逻辑抽离到press-ui
,好处如下:
- 增强可维护性,提升开发效率
- 通过整理代码,合并属性,分离业务逻辑等,让组件变纯粹,增强可维护性,进而提升效率
- 减少业务和组件的耦合,降低各自复杂度,并减少bug
- 封装核心逻辑,控制变化
- 不用担心外部合作人员改乱代码,以及解决冲突时的覆盖问题
- UI问题定位简单
- 三端代码同时发布,以及多种类型的示例,覆盖面全,容易发现ui问题,以及三端表现不一致问题
- 可提升性能
- 通过自定义队伍数等变量,定位性能瓶颈,并解决性能问题
- 提高可复用性,可应用到其他项目
- 技术沉淀,技术积累,不断打磨组件细节
并非所有组件、逻辑都可以沉淀到组件库中,有以下准入条件:
press-ui
内的组件、逻辑需要有一定的通用性或复杂性,比如button
、input
、area
、message-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,这其中涉及很多中间状态,需要写很多的data
、computed
、methods
,用函数调用的方式可以大大减少这种中间变量,使逻辑更内聚,不容易出bug。
重构后的效果:
- 页面之前1600行,重构后降到500多行
- 减少了中间变量,逻辑内聚、清晰
- 减少了其他文件对页面
this
的引用,减少耦合
4.2. 赛程
首先,赛程相关部分的命名有些混乱, 对他们进行了统一。
将赛程树核心组件抽离,并遵循最小知识原则,roundList
由scheList
计算生成,不再由外部传入。
对中间层做了公共props
的抽离。这个中间层链接了多种赛制,是props
、events
传递的桥梁。
4.3. 消息中心
对消息中心数据的处理逻辑进行了梳理:
App.vue
中dispatch
一些actions
,如getTimUnReadCount
、getTimRobotMessage
,调用后台CGI,给到store
,全局消息弹窗获取此数据。- 需要IM的页面,引入
IM Mixin
,进行IM的登录、获取会话列表、设置事件回调等,同样给到store
,Page
、Global Notify
可以使用此数据。
为什么搞这么复杂,省掉第一步不行吗?不行,因为IM SDK包太大,不能放在主包,所以需要Page层自己引入,而首页又需要消息提示,所以调用后台CGI,后台接口会调用IM的API获取消息。
另外,对消息列表、消息详情等组件进行了抽离,放到了press-ui
中,并且对格式化消息的核心逻辑进行了分离和沉淀。
4.4. 分享模块
分享模块存在的问题包括:
- 通过文件顶层变量进行属性共享,难追踪,难维护
- 与项目耦合,比如依赖
getWxSignature
、getMPOpenLink
- 与框架耦合,比如依赖
van-dialog
、vue
- 扩展性差,比如游戏内不支持指定分享类型,需业务库自行处理
- 无类型提示,黑盒子,易出错
如何优化?
对于全局变量,改为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() {
// ...
}
})
}
对于与业务项目耦合的部分,比如getWxSignature
、getMPOpenLink
等方法,每个业务不一样,可以通过参数传递的方式,即参数为函数。
core
不需要关心getMPOpenLink
的所有参数,比如appid
、mpPath
、query
等,外界自己处理就行。
对于与框架耦合的部分,直接改成原生实现,不依赖任何框架。以后扩展到vue3
、react
,或者更新组件库,比如不再引入Vant
,该模块都无需做任何改动。
另外,可变部分都放到参数里,并补全类型提示。
5. 耦合
下面列举几种工作中常见的耦合。
5.1. 在window上挂载并修改变量
ts
// common/vue/mixin/option/settingMixins.ts
function readRecommendStatus() {
// ...
window.app.isOpenRecommend = isOpen === '1';
}
这种使用方式不可控,你不知道这个值被什么人修改,在哪里修改,难以调试,难以定位问题。
除非这个变量在整个项目中是唯一的,独特的,比如window.vConsole
、window.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
,然后跳转小程序。这段代码的问题是不应该引用logic
的postGetMiniProgramOpenLink
方法,这个是上层的业务的,不稳定,且违背了单向依赖原则。
可以用"依赖注入"的思想,把此方法作为参数注入。
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
操作子组件方法,适合用于稳定组件,如果是频繁变动的业务库,就存在被人改动、然后异常的风险。