您现在的位置是:亿华云 > 应用开发

踩坑实战:如何走出“万劫不复”的代码重构深渊?

亿华云2025-10-02 18:41:56【应用开发】9人已围观

简介【.com原创稿件】 Martin Fowler 在其经典著作《重构:改善既有代码的设计》中给出了下述定义。“重构是一个改变软件系统的过程,它旨在不改变代码外部行为的前提下,改善代码内部的结构。它用一

【.com原创稿件】 Martin Fowler 在其经典著作《重构:改善既有代码的万劫不复设计》中给出了下述定义。

“重构是踩坑出一个改变软件系统的过程,它旨在不改变代码外部行为的实战深渊前提下,改善代码内部的何走结构。它用一种规范的代码方式来清理代码,从而***限度地减少错误出现的重构几率。从本质上说,万劫不复重构是踩坑出在代码写完之后对其设计的改善。”

我最近有幸参与了一个复杂的实战深渊代码重构项目,它是何走由 React 和 Redux 来构建的一个销售预算管理与分析的应用系统。

该系统大约由 300 个文件,代码共计 13000 行代码所组成。重构由于能够定期地接触到代码库,万劫不复因此我对于在遵从代码标准的踩坑出基础上如何进行代码改进比较熟悉。

以下是实战深渊我的最终目标列表:

更新文件夹结构,以集中 Redux 的各种文件(actions、reducers、亿华云计算selectors)。 将 Foundation 框架转换为 Semantic UI React。 实现 CSS 模块。 升级到 webpack3(一种模块打包器)。 将 Fetch 替换为 jQuery,以处理各种 HTTP 请求(使用 polyfill)。 删除不必要的抽象类和“死代码”。 安装并配置 Jest 测试框架,编写各种单元测试。

经过一番周折之后,我最终完成了上述目标。在此,我将自己从该项目以及其他过往项目中所获取的宝贵经验与技术分享给大家。

提出正确的问题

在决定开始进行代码重构项目之前,让我们先理清一些重要的问题。

首先,扪心自问:我需要重构吗?

所有程序员都希望能写出干净优雅的代码,但是事实并非如此。各种截止日期的临近和需求的变更,往往只是大量问题的开始而已。

面对庞大的代码库,您可能早已丧失了重构的云服务器提供商动力。那么请您在阅读了下列问题并能够回答“是”之后,再考虑如何开展重构工作吧:

是否存在重大的技术缺陷而造成了系统的巨大问题? 添加各种新功能是否困难? 对于某部分代码库的细微更改,是否会破坏应用中另一部分的某个不相关的功能? 您是否还使用了那些存在着安全与性能问题的过时依赖关系? 在 JavaScript 环境中,ES5 的语法是否能够被箭头函数(arrow functions)和解构(destructuring)等新的语言功能所增强?

我建议您多与同事,特别是该领域的高级工程师讨论,而不是盲目开始。他们可能会详细地说明为什么会以此种方式来编写代码,或者给您提供一些能够影响决断的有价值的见解。

某位经验丰富的工程师甚至还会提醒您他们曾在代码重构中失败过,因此在某种程度上说这次也不适合再次进行重构。

接下来,问问自己:我能够重构吗?

现在您面临着下一个障碍是云服务器:确定重构是否可行。可行的条件包括如下限制因素:

根据我目前的技能组合,我能胜任重构吗? 根据我的时间安排,我能胜任重构吗? 重构时,我可以添加新的功能吗? 根据我当前的预算,我能胜任重构吗?

如果您不能对上述所有的问题回答“Yes”,那么重构可能对您来说就是自寻烦恼了。

我的经验是:您需要得到软件产品所有者的批准与支持,因为他们会经常与客户直接合作并能管理预算,切忌擅自行事!

***,自问自答:我愿意重构吗?

代码重构向来是一项巨大的工程。那些严苛的预算和时间表往往会给人们带来难以想象的压力。

因此,您可能会时常询问自己如下的问题:

为什么代码不是在一开始就以正确的方式被编写呢?

如果应用能够正常工作的话,何必要重构它们呢?

如果它没那么糟糕的话,就不能只是添加点新的功能吗?

这样做能够增加企业的价值吗?

面对上述问题,代码重构常被人们误解为一项吃力不讨好的任务。而如果一些现有的功能因为代码重构而产生中断的话,那么重构工作就会变得更加“万劫不复”了。

尽管困难重重、弊端多多,但是在完成了上述“尽职调查”之后,您会发现代码重构还是非常值得我们投入宝贵的时间去开展的。

在清理代码库的同时,添加良好的单元测试会有益于新功能的轻松添加,以及回归类错误的(regression bug)大幅降低。

制定计划

“如果没有计划,您就是在计划失败。”

--本杰明富兰克林如是说。

一旦开始决定重构某个应用程序,您的***本能应该是:深挖代码并清理不适合的代码。然而事实证明:如果没有一个事先的计划,您将难逃“劫难”。

在按照时间和预算的范围制定出行动计划之前,您先别急着修改代码。代码重构的目的是为了让整个团队受益于代码价值的***化。

如果代码中的某些部分虽然显得特别混乱,但是能够提供正常服务的话,那么就没有必要迅速对它们进行重构。

我们稍后会讨论区分优先级的问题,而当前您至少应该先对代码的潜在价值有所预判。

以下列出了能够保证您的重构项目正确开始的若干步骤。即使您的重构项目相对较小,我仍然建议您参考并使用下列技术。

选择一种项目管理工具

无论代码重构的范围是大还是小,您都需要用一种工具来跟踪项目进度。我是 Trello 的忠实粉丝,并认为其“看板(Kanban)”风格界面非常适合于项目管理。

当然,您完全可以挑选自己喜欢的工具,而且***具有对任务进行排序、分组、添加注释、以及说明的功能。同时,如果它能够添加附件或创建标签的话,那就更是锦上添花了。

以我的项目为例,我创建了一个 Trello 看板,并通过如下列表名称来跟踪各项任务:

积压工作下一步进行中拉取请求(PullRequest)关闭

我还创建了一个标签专门用来表示某个任务是否涉及到 React 组件、Redux元素(如 actions、reducers、selectors)、以及应用配置等方面。

如果您和我一样属于“视觉系”的,则可以通过给标签添加颜色,来迅速了解其表征的特性,而不需仔细阅读其标题或相关说明。

例如,我创建了一个将 webpack 从 1.0 升级到 3.0 的任务,并为 Configuration 标签设定了特殊颜色,以便我能在面板上快速地识别出来。

查找逻辑上下文

如果代码量非常大的话,您可以试着将应用程序解构为多个模块或上下文的关系。

此处“上下文”表示为:隶属于某个特定业务实体或是应用程序配置的代码段。如果您所面对的代码库颇为混乱的话,该过程将具有挑战性。

不过,即使只是粗略地研究与划分,也会有助于您更进一步熟悉代码库,甚至让您受益匪浅。在多数情况下,您可以根据应用的服务流程来推断出上下文关系。

例如,一个为牙科诊所安排日程表的应用,就可以被分为如下的上下文关系:

病人 约诊 用户导航 牙科记录 用户管理

在实践中,我们经常会碰到的棘手部分是:如何正确把控粒度。对于我所开发过的应用而言,我一般会根据 API 的调用和现有 Redux 的 reducer 来确定上下文关系。

我通常会定义出:用户类上下文、超级用户类上下文(用于各种管理操作)、应用类上下文(用于 UI 的状态)、以及其他类型。

注意:不要过于苛求***的上下文关系,这一步的目的只是为了简化任务创建的过程。

创建任务

您必须创建出具有明确范围的任务。在此,您可以根据“拉取请求”的方式来考虑范围。

虽然谁都不想一次性提交具有 5000 行代码之多的变更,但是在 5000 行代码中只提交 2 处修改显然也是远远不够的。

以我曾经参与过的一个重构项目为例,其目标是从 Foundation 框架转换到语义 UI 的 React,并且实现 CSS 模块。

请参考:https://foundation.zurb.com/

我最初创建了单一的任务来表示这一转化,但是我立即意识到了它所牵扯到的巨大工作量。

在该应用中,有着近 100 个 React 组件需要被更新,而我并不想在 Trello 中创建 100 个任务来代表每一个单独的组件。

在此情况下,我定义了一些简单的上下文关系:

首先,我在每个上下文中创建了不同的任务,来重构与 Redux 相连接的各个容器组件。 接下来,我查看了共享的 /components 目录,并按照表单控件、图表等类别对它们进行分组。 ***,我创建了单独的任务来重构每一组共享的组件。

下图是我的看板界面截图,上面包含了一些示例:

当然,您也需要考虑到自己的变更对于应用的影响。如果可能需要恢复到旧的版本,您一定不想在大段的变更代码中费力地深挖出造成某个 Bug 的原因。

曾经有一次,我为了某个 Bug 而不得不撤销了自己的绝大部分的更改。在此之后我试着用小块的程序代码去进行重构。

为任务排序

如果您愿意的话,可以考虑为任务制定排序或优先级的一些标准。当然,如果您已经为每一项任务创建了明确的范围,那么完成它们的顺序也就一目了然了。

我倾向于将具有相似特定功能的任务进行分组处理,例如根据 Redux 的元素或 API 管理来区分。

当然,如果您是新手的话,我会建议您先去“摘取那些低垂的果实”,即一些仅付出少量的努力就会效果明显的任务。

持续更新您的计划

随着对于代码的“深耕”,您可能会发现在前期重构计划里不准确的地方,那么请不要犹豫,尽快修正以免覆水难收。

重构代码库的好处往往是无形且难以衡量的。哪怕最终不得不终止重构计划,您的这份详细的计划也能为下一次重构的“重启”提供宝贵的资源和“追踪”的线索。

测试的重要作用

如果连您都不喜欢测试自己的产品,那么很可能您的客户也不会乐意试用它。

下面我将引导您来建立一个有效的测试架构。考虑到方便搭建与运行,我使用了Jest 和 Enzyme。

参考:https://facebook.github.io/jest/

http://airbnb.io/enzyme/

您可以根据如下链接的指引,将这两个库配置到 React/Redux 的应用之中:

使用 Jest 和 Enzyme 实施基本组件测试

https://hackernoon.com/implementing-basic-component-tests-using-jest-and-enzyme-d1d8788d627a

如何用 Jest 测试 React 的各种组件

https://www.sitepoint.com/test-react-components-jest/

设置模拟数据

如果测试的是现有应用,那么您应该了解现有数据的形态和使用方式。在大多数情况下,随着新功能的添加,API 会被微调与改进。

对于我的重构应用项目,我创建了两个带有数据的文件:一个带有API的各种响应,而另一个则带有 Redux 的状态。

您可能需要通过稍许的修改和大量的复制/粘贴来创建这两个文件,不过这些工作都是一次性的。

产生这两个数据文件的目的有两个:

首先(也是最明显的),您需要测试的许多元素都会以某种方式来显示或操纵数据。 其次,通过快速地参考数据的形态,以便有效地确定如何撰写测试程序,并分析代码可能出现的问题。

如果所测数据过于敏感的话,有时候存储 API 的响应与状态并不太可行。在此情况下,您可以使用带有 fakcer 的 json-schema-faker 库,或 chance 库来生成随机数据。

参考:https://www.npmjs.com/package/json-schema-faker

https://www.npmjs.com/package/chance

我建议您一次性生成数据并存储到库中,而不是每次在运行测试时都使用其“种子”来生成新的数据。

我将自己的文件存放在__fixtures__文件夹中,目录结构如下所示:

/src   /components   /constants   /containers   /redux     /__fixtures__       /state.js       /responses.js     /app       /appActions.js       /appReducer.js       /appSelectors.js   /... 

获取 Redux 整体状态的最简便方法是使用 Redux DevTools 的扩展。

参考:https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=en

您可以在状态视图中选择 Raw 选项卡,复制所有内容,并将其粘贴到一个带有 module.exports 声明的 JavaScript 文件中。

我建议您只从状态和 API 的响应中获取小部分的记录,以减少 Jest 整体快照的大小。而具体保留多少条记录则完全取决于您自己的判断。

例如:某个 API 的响应返回了一个包含着 400 条记录的数组,那么您肯定需要去除其中的绝大部分,以提高测试效率。

值得注意的是:使用有效的数据对于防止回归错误的产生是至关重要的。如果你用到的数据并不能代表应用中真实环境所用到的内容,那么测试的效果是无法保证重构质量的。

标准化您的测试

您在重构项目中往往需要编写大量的测试。在刚开始写测试的时候,我经常会出现命名不统一的情况。

例如:在测试两个非常相似的组件时,我所用到的 describe 时常不尽相同。同时,标准化您的测试将有利于减少团队花费在理解测试程序上的时间。

确定测试文件的位置

大部分人喜欢复制整个 src/ 目录,并将测试文件放在那里。无论您喜欢用 .spec.js 还是用 .test.js 作为测试文件的扩展名,都要注意一致性。

Jest 的默认配置会指定将测试文件放置在 __tests__ 目录,并以 .test.js 作为扩展名。

一旦您将此作为了标准,请务必添加到自述文件(README)之中,以便将来使用该应用的程序员能够籍此遵守下去。

建立格式

您应当为每个正在测试的上下文(如:React 组件、Redux 的 selectors 等)建立起“格式/结构”(format/structure)的关系。

例如,我创建的每个 React 组件和容器的测试文件都会在其顶部有一个如下所示的 setup 函数:

const setup = (propOverrides, renderFn = shallow) => {    const props = {      propA: Some Value,     propB: false,     onClick: jest.fn(),     ...propOverrides,   };   const wrapper = renderFn(<AppComponent { ...props} />);   return {  props, wrapper }; }; 

这会使得我在测试组件时非常轻松,而无需编写大量额外的样板引用。同时,我还为 React 组件建立了一个特定的 describe 块结构。

describe(Component A, () => {    describe(Snapshot validation, () => {      it(matches its snapshot with valid props, () => {        const {  wrapper } = setup();       expect(wrapper).toMatchSnapshot();     });   });   describe(Event validation, () => {      it(fires props.onClick when button is clicked, () => {        const {  wrapper, props } = setup();       wrapper.find(button).simulate(click);       expect(props.onClick).toHaveBeenCalled();     });   });   // Note: This is only for connected components.   describe(Redux validation, () => {      const store = {        getState: () => state,       dispatch: jest.fn(),       subscribe: () => { },     };     it(renders when connected to Redux state, () => {        const wrapper = shallow(<ComponentA store={ store} />);       expect(wrapper).toHaveLength(1);     });   }); }); 

对于 Redux 的 actions、reducers 和 selectors,我进行了同样的操作。根据具体的测试环境,我还会使用 WebStorm 的文件模板功能,以快速地创建各种测试文件。

参考:https://www.jetbrains.com/help/webstorm/creating-and-editing-file-templates.html

如果您使用的程序编辑器能够支持代码片段或文件模板的话,我建议您事先创建好相应的模板,以保持格式上的规范。同样,请记得在自述文件中留下简要的概述说明。

编写测试

假设您正在着手开始重构 Redux 的 actions、reducer 和 selectors 的 UI 状态。由于您已经掌握了相应的状态数据,因此编写测试相对来说会比较简单。

您只需要使用类似 redux-mock-store 的库来模拟出用于测试 actions 的状态即可。

参考:https://github.com/arnaudbenard/redux-mock-store

请务必在更改任何代码之前编写好了所有的测试。通过对重构之后的代码进行测试,我们能够发现一些人为的或无意犯下的错误。

而事先保存好快照,则有助于我们发现那些字段或对象关键字上的拼写错误。

您应当对完成了重构的代码部分,和任何直接受到变更影响的代码编写测试程序。

虽说深挖程序间的依赖关系、并编写出涉及到应用各个方面的测试,会是一项比较繁琐的工程,但是这会给您提供对于整个代码库的深入解析,并为代码的重构提供更多的整改机会。

应该测试什么?确定测试内容的最简单和可靠的方式是:代码覆盖率。

Jest 具有内置的代码覆盖率检查功能,您可以用来生成带有覆盖率百分比的 HTML 报告,以显示代码中的哪些部分目前未被测试所覆盖到。

虽然行覆盖(Line coverage,查看是否每一行都执行了?)会让您颇有成就感,但是分支覆盖(branch coverage,查看是否每个 if 代码块都执行了?)才是您需要去关注的地方。

更多有关不同覆盖类型的概念,请参考:http://jasonrudolph.com/blog/2008/06/10/a-brief-discussion-of-code-coverage-types/

如何知道自己已经完成了?如前面所述,覆盖率是评估代码的某个部分是否通过了测试的***工具。

如果您的某个函数中包含一个 if 的声明,而它的 else 条件却没被测试所覆盖到,那么就会被覆盖率报告所指出。

我经常会习惯性地多看几次函数代码,并理解其逻辑关系,然后编写出能够故意破坏它的测试用例,包括:如果 API 的响应中缺少某个字段会怎么样?如果响应为空又会发生什么?

例如,假设有个 selector 能够加总某个特定区域里每个销售员所分配到的预算。而销售经理则拥有该地区所有可用的预算总和。

显然,所有可用预算的总和应当始终大于或等于分配出去的总预算。那么,如果小于的话,会发生什么?代码中是否有 if 的声明来涉及这方面呢?

通过阅读代码和编写测试,您可考虑到更多的极端情况。由此可见,代码覆盖率(function coverage)可以反映出每个函数是否被测试所调用到的情况。

重写代码

通过上述部分,您应该已经制定出了计划、选取了相应任务、编写出了测试,那么是否现在就可以开始打开某个文件、修正变量名称并清理代码了呢?

为了确保重构的顺利进行,我建议您先熟悉一些基本的概念。下面,我将向您介绍一些在代码重构过程中的常见错误和化繁为简的技巧。

识别自动化的可能性

您很可能会碰到需要移动并梳理到正确的位置的大量文件。

例如,我曾经在重构一个应用时发现其 Redux 的 actions、reducers 和 selectors 都分属于自己单独的文件夹,而我需要将它们按照模块(例如 appActions.js、appReducer.js 和 appSelectors.js)进行分类。

因此,我需要运行一条 git mv 的命令,将 /actions/app.js 移动到 /redux/app/appActions.js,并且对于 /reducers/app.js 和 /selectors/app.js 要执行相同的操作。

由于该应用项目中有 11 个模块,因此我必须输入 33 次 git mv 命令。另外,我还需要再运行 150 次 git mv,以将 React 的容器和组件放置到正确的文件夹位置。

因此,面对如此“崩溃”的任务,我并没有手动地逐条输入命令,而是使用 JavaScript 和 Node.js 编写了一个脚本来实现:

const fs = require(fs); const path = require(path); const chalk = require(chalk); const sh = require(shelljs); const _ = require(lodash); const sourcePath = path.resolve(process.cwd(), src); // This is the new /src/redux folder that gets created: const reduxPath = path.resolve(sourcePath, redux); // I used "entities" instead of "modules", but they represent the same thing: const entities = [   app,   projects,   schedules,   users, ]; const createReduxFolders = () => {    if (!fs.existsSync(reduxPath)) fs.mkdirSync(reduxPath);   // Code to create entities folders in /src/redux... }; // Executes a `git mv` command (I omitted some additional code that validates // if the file already exists for brevity). const gitMoveFile = (sourcePath, targetPath) => {    console.log(chalk.cyan(`Moving ${ sourcePath} to ${ targetPath}`));   const command = `git mv ${ sourcePath} ${ targetPath}`;   sh.exec(command);   console.log(chalk.green(Move successful.)); }; const moveReduxFiles = () => {    entities.forEach(entity => {      [actions, reducers, selectors].forEach(reduxType => {        // Get the file associated with the specified entity for the specified reduxType,       // so the first file might be /src/actions/app.js:       const sourceFile = path.resolve(sourcePath, reduxType, `${ entity}.js`);       if (fs.existsSync(sourceFile)) {          // Capitalize the reduxType to append to the file name (e.g. appActions.js):         const fileSuffix = _.capitalize(reduxType);         // Build the path to the target file, so this would be /src/redux/app/appActions.js:         const targetPath = `${ reduxPath}/${ entity}`;         const targetFile = `${ targetPath}/${ entity}${ fileSuffix}.js`;         // Execute a `git mv` command for the file:         gitMoveFile(sourceFile, targetFile);       }     });   }); }; moveReduxFiles(); 

您既可以通过脚本来自动迁移文件的路径,也可以通过脚本来直接修改路径的名称。

当然,您需要注意投入产出比,不要花费了 20 小时去编写一个脚本,却只是节省了 1 个小时手动工作量。

由于大多数代码库、及其结构都相对独特,因此一般您编写脚本的复用性都不高。

持续提交

您所重构的应用程序越多、时间越长,就越难以记住和追踪那些在不同文件里的细微修改。

而对于各种文件的累计且大量更改,势必给您的应用测试带来失败的风险。因此,无论代码的修改量大或小、多或少,请记得予以持续提交。

以某次应用重构为例,我就进行了 1747 次提交,涉及到 659 个文件中的 76080 行代码,总体占用的存储空间为 10MB。

另外,在多次且持续的提交过程中,您可以通过限制每一次更改的内容和文件的数量,以便您能够随时按需“跳回”到某一个可靠的“保存点”。

抵御“分心”

请暂时避免清理那些手头任务范围之外的代码,这也是代码重构过程中最困难的方面之一。

假设您遇到了一个使用 Object.assign() 的 selector,而您的后续任务之一是更新代码,使用类似 spread syntax 新的 ESNext 功能。

参考:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax

那么请不要偏离当前的任务走向,哪怕只是对该代码进行微不足道的改变,都不要放在现在进行。

我的经验是:在相应的代码处添加了一条 //REFACTOR: Fix this later 的注释,然后继续自己的当前任务。

有关拉取请求的方法论

有时候,您花费了大量的时间去清理一部分代码库,而难以后退到过去的某个时间点,或无法对修改后的代码质量进行评估。

那么拉取请求往往就能够帮助您和同事来评审这些修改,以确定是否有益于代码的优化:

当您在提交拉取请求时,请在摘要处给予尽可能详细的描述。如果您使用的是 GitHub,那么新的拉取请求会伴随着一个模板。您可以在其中填写相应的标题、总体描述、重要章节的变更列表等各种审评人员所关注的信息。 您需要注意的一个指标是:与拉取请求相关的代码更改数量。请尽量限制更改的代码行数在 500 行左右。由于 Jest 快照文件的添加会使得拉取请求动辄添加数千行,因此请务必在摘要中包含与之相关的注释。 如果您无法控制改变的数量,那么就请尽量降低其复杂性。 如果您只是对重要的声明语句进行重新排序的话,只要此类更改并不太复杂,超过 2000 行的代码量还是可以接受的。 请将同类变更尽量限定在同一次拉取请求之中。 您可以大胆地在注释中写上“本次并未做逻辑上的修改”,以节约审阅者的理解时间。

写在***

我们从代码重构项目的实施角度向您提供了:尽可能自动化、持续提交、抵御“分心”,以及善用拉取请求等方面的建议。

【原创稿件,合作站点转载请注明原文作者和出处为.com】

很赞哦!(3)