快到年底了,总想把今年的一些收获记录一下。其实我早有这个想法,但是一直都没有打起精神去写。此外,我不得不避开公司的业务和代码细节。

Background

任何人看到超过 3000 行的单个文件都会感到窒息。一般来说,这是因为某个小文件业务不断迭代,最后膨胀成这样。我可以说,如果一个文件从头到尾都是一位开发在维护,然后这名开发放任这个文件膨胀到 3000 行,如果这没有特殊的理由,那么我认为这名开发是不负责的。但是,更常见的情况是,一名开发接手了一个模块,里面充斥了大量的这种文件。

如果这是一个 Vue2 文件又如何呢?Vue3 有 composition Api, 设计上就鼓励代码复用。Vue2 则很容易逻辑挤作一团。在缺乏整体设计的情况下,我常常发现,页面行为和业务逻辑放在一起。普通的项目当然没有问题,但是只要业务稍微复杂一点,就会遇到我说的这种情况。

在今年年初的时候,我就在维护这样一个文件。稍只要谢过代码就能看出来这个文件必须重构,但是这样的文件也是烫手山芋。于是,另一个维护该文件的同事将 Vue2 文件改成了 Vue3,我知道我可以有所动作了。

重构陷阱

在写这篇 blog 之前,我觉得这会是一篇讲我怎么重构的文章。可是,当我写到这里,我发现这会是一篇如何保证重构步不出问题的文章。

我们公司的研测比极低,开发必须保证自己的代码质量,一旦出现 regression 问题开发会被追责。其中,大部分 regression 都是因为重构产生的。

重构代码是一份苦活。一个基本逻辑是:所有的输入应该有和之前相同的输出。然而,这一点很难保证。后端代码的输入和输出相对容易预测,前端却很难预测用户行为。如果在不同的状态下,相同的行为会走向不同的逻辑,那么就更难覆盖完全。

由于重构不会带来即使收益,所以我经历过的公司大部分都只是口头鼓励重构。但我并不认为这是一种虚伪。作为开发,技术 leader 天然知晓技术债应该被弥补,但是作为公司意志的传递者,他们无法抵抗。

一旦 regression 出现,所有的辩解都是无力的。由于在重构没有实现任何新需求,leader 往往不会宽容。

然而,优秀的开发反而容易因为重构导致 BUG. 如果开发摆烂,他只需要进行复制粘贴就可以完成任务。等到项目无法维护了,自然有其它人解决。但是优秀的开发会注意到项目中的坏味道,持之以恒地改进。然而,就像之前说的,重构是一件危险的事情。再优秀的开发也难免背锅。

如何保护自己

一般的公司会由测试兜底。然而如同自己之前说的,我们公司文化特殊,所以必须另想办法。

TA 陷阱

同时,我认为开发有职责交付高质量的代码,不应该完全交给测试。个人很不喜欢高压环境。老实说,现公司的政策让所有的开发都压力很大。但是也正是因为这样,才会逼迫开发考虑如何在尽可能安全的情况下进行重构。

对于开发而言,很容易想到使用TA保证测试质量。但是,对于足够复杂的项目而言,积攒足够全面的TA需要相当长的时间。如果是以速度为导向的公司,则基本不能积累足够的TA. 同时,积累TA这个说法也有一些问题。对于前端而言,往往要出过一次 BUG 才能想到对应的复杂场景。然而,已经出过一次BUG, 这已经是不可以接受的了。TA 更多是一个后续的改进手段。

同时,前端的迭代——无论是的需求还是重构——都非常容易导致TA失效,以至于出现使用自然语言描述 Case 的项目。即使如此,只要需求变化,依然会失效。

换句话说,TA是需要不停维护的。现在国内互联网公司很难允许花这么多资源在TA上,更多时候选择在BUG出现后立刻维护。

重构的规模

首先需要说的是,在需求中进行重构是一件危险的事情。在需求中进行重构,端要看公司能承认多少风险。如果是ToC项目,自然应该尽可能保守;如果是ToB项目,则可以稍微激进一点。

重构可以分为:

  • 函数级别的重构
  • 文件级别的重构
  • 模块级别的重构

其中,一个令人惊讶的事情是,文件级别的重构更容易带来问题。模块级别的重构虽然更危险,但是开发一般不容易忽视其中的危险性,并准备对应的 PlanB;相反,文件级别的重构反而容易因为轻视而造成问题。

函数级别的重构

对于函数级别的重构,只要遵循一定的规则,风险是可控的:

  1. 需要在项目里进行全局搜索,寻找使用该函数的地方
  2. 需要确认所有的输入和原函数的输出
  3. 需求确认所有函数的副作用
  4. 需要确保新函数在对应的输入上,返回相同的输出
  5. 需要确保新函数在对应的输入上,出发相同的副作用

这里,对于优秀的开发,1 是常识。这里容易出问题的地方是副作用。副作用无法预料,所以开发对副作用有本能的厌恶。从这里我们看出,函数的重构风险也是浮动了。对于有副作用的函数的重构是需要小心的。

这其中有很多细节。比如说,如果出现这样的代码,那么可能还是会出现问题:

const fn = condition() ? funcA : funcB window.__fn__ = fn fn() <componentsA callback={fn} />

当函数被赋值给另一个变量,就需要检索该文件所有的变量;如果被传入另一个文件,那么需要持续追踪。如果这个组建是一个多实例组建,可能有多个callback,这项工作会变得非常恶心。

文件和模块级别的重构

相对函数级别的开发,文件级别的重构就危险很多了。这里提出一个准则:

对于希望尽可能压低风险的开发,文件和模块级别的重构都需要对应的PlanB. 这里的 PlanB是指,即使重构出现问题,可以立刻切回老的代码的能力。

文件及以上级别的重构的安全性几乎是无法保证的。这里提一个个人遇到的比较典型的问题:竞态和全局变量问题。

竞态和全局变量

假设组件A内有一个表格,组件B是一个仪表盘。点击表格行会和仪表盘联动。这里是一个非常简单的结构。

// componentsA const dataA = ref({}) queryData() { fetch().then(res => dataA.value = res)} <componentB data={dataA} /> // componentsB <componentB data={porps.data} />

假设这个组件B原本只会在组件A内渲染;后续 componentsA 变得越来越复杂,于是进行拆分,组件层次变多。

// componentsA const dataA = ref({}) queryData() { fetch().then(res => dataA.value = res)} <componentB data={dataA} /> // componentsB, 随着迭代从A拆分而来 <componentC data={porps.data} /> // componentsC <div>{ props.data }</div>

在这种情况下props 难以维护,同时其他页面也出现可以改变仪表盘的入口。于是改为存入 store, 从 store 读数据渲染。

// componentsA const { setA } = useActionA() queryData() { fetch().then(setA) } // componentsB const { dataA } = useGetterA() <div>{ dataA }</div>

到目前为止看起来没有任何问题。但是,这里面有一个潜在风险:如果用户快速点击,那么可能会出现问题。如果第一次请求的结果比第二次结果返回更慢,就会出现数据不匹配的问题。而且,由于大部分情况下先发出先返回,这会变成一个偶现问题。

这只是一个最简单的示例。我实际遭遇过的问题比这个复杂很多,几乎无法提前察觉。指望开发通过 Review 发现是不现实的。在这种情况下,我们需要保证出现问题可以快速回滚。

快速回滚

如果在发现问题的时候,需要准备代码才能回滚,那反应是慢的。对于基建较好的公司,回滚是一瞬间的事情。问题是,开发很难保证 commit 的干净。一个有趣的现象是,基建越是灵活,开发在 commit 上越是随意。这可能是因为在基建上越是优秀的公司,通过发版解决 Bug 的代价就越低。

这里的问题是,定位问题本身就需要时间,解决 Bug 可能会带来另一个 Bug. 对于比较大的改动,我们需要可以用最低的代价回滚代码。

这种情况下,我所知道的方法是:

  1. 在重构的时候,保留原文件。
  2. 使用配置平台,比如 nacos 配置开关
  3. 一旦出现问题,切换开关换回旧组件
  4. 当线上运行一段时间后,删除相关代码

这也意味着,文件及模块以上的重构是一件严肃的事情,应该作为一个单独的需求来对待。这还带来另一个好处,测试一定知道你做了相关改动,并进行相关测试。相反,如果在做需求的时候,“顺便”进行重构,容易忘记告诉测试造成漏测。

重构实例:Vue2 改 Vue3

这篇文章到这里就应该结束了。但是,我还是要记录一下自己本年一次较为成功的成功。可以说,这篇文章就是这一次重构的,以及另一些失误的总结。

如之前所说,一个 Vue2 大文件被改成了 Vue3. 改动的同事没有改动任何逻辑,只是改了写法,所以是比较安全的。但这是一个苦差事,所以我还是要为这位同事脱帽致敬。

我是一个比起讨论更喜欢行动的人。就是说,我讨厌不停讨论但是没有结果,更喜欢一点一点重构。不过我现在对讨论有点改观。我发现,在开发足够优秀的情况下,通过详细规划进行的重构是可以实现的。

那么,在我的风格下,我进行的第一件事情是对文件进行拆分。这个文件本不应该膨胀到这个大小。里面的逻辑可以简单地进行分类:

// useData -> 核心数据 // useAppStatus -> App状态 // usePageStatus -> 视图状态 // useModuleA -> 业务模块A // useModuleB -> 业务模块B // useResize -> 独立逻辑 // ...

最后我分割出了大约 10 个 Hook.

通常,我们希望这些 Hook 相互不感知。但是实际情况是这里的 Hook 耦合严重。这是 Vue2 文件转 Vue3 会遇到的问题。Vue2文件没有合理分割组件,就会导致逻辑都放在一起,最后就会相互感知。

需要提前说一下,我这里只是说我的处理方式,并不一定是最优解。如果谁有更好的解法,欢迎留言。

一开始,我希望把 ref 作为全局变量。这导致了上面说的竞态问题。最终我的解决方案是,把hook的结果作为参数传下去。这样虽然很蠢,但是确实解决了问题。我顺便整理了一下组件依赖的情况,结果令人绝望。

意想不到的好处

在这次重构后不久,项目出现了多平台的需求。由于已经把逻辑都抽象到 Js 文件当中,视图和逻辑已经分离,这导致多平台变得非常顺利。顺便提一,对于多平台项目,每一个平台都会导致测试用例重走一边。TA在这种场景非常有利。

后续的规划

  1. 消除依赖:该放到 store 里的东西放到 store 里。有些逻辑可能调整位置
  2. 单文件的优化:文件内部有优化的空间。分割文件可以减轻心智负担
  3. 模块优化:和业务强相关,不在这里赘述了

结语

我认为,开发应该重构,但是开发也需要掌握保护自己的方法。自己的项目怎么玩都可以,但是在职业生涯中会遇到各种压力。这篇文章就是我在当前的职业环境中所获得的经验。