重构我写过的最糟糕的代码

2025-05-25

重构我写过的最糟糕的代码

在最近的#DevDiscuss主题为“开发者自白”的聊天中,我坦白说,三年前我开始第一份开发工作时,我其实并不清楚自己在做什么。为了说明我当时缺乏经验,我分享了一个当时我正在编写的代码示例。

我收到的反馈绝大多数都是积极的。我们大多数人都写过一些不引以为豪的“糟糕”*代码,但当你回顾那些旧代码,认识到它可以如何改进,甚至可能为自己做出的选择而自嘲时,这本身就是一种成长的标志。本着不断学习的精神,我想分享一些我今天可能解决这个问题的方法。

*尽管这段代码很愚蠢,并且可以写得更有效率,但硬编码可以很好地完成它需要完成的任务。

背景与目标

在重构任何遗留代码之前,回顾并评估代码编写的上下文至关重要。可能有一个重要的原因 疯狂开发人员做出的选择可能受到上下文的影响,而您可能没有意识到(或者如果是您的代码,您可能记不住)。就我而言,我只是缺乏经验,所以这段代码可以安全地重构。

代码是为两个数据可视化项目编写的:“全球外国直接投资存量”(流入/流出)和“中国双边投资流出”(中国)。它们的数据和功能相似,主要目标是允许用户通过按类型、年份或地区筛选来探索数据集。我将重点关注全球数据,但中国数据集可以用类似的方式重构。

显示全球直接投资的交互式气泡图。

假设更改其中一个过滤器将导致返回以下值:

    let currentType = 'in' // or 'out'
    let currentYear = 2017
    let currentRegions = ['Africa', 'Americas', 'Asia', 'Europe', 'Oceania']
Enter fullscreen mode Exit fullscreen mode

注意:区域复选框目前无法以这种方式工作,因此代码片段中出现了“全部”和“部分”,但应该这样做。

最后,这是从 CSV 加载数据本身后的简化示例:

    const data = [
      { country: "Name", type: "in", value: 100, region: "Asia", year: 2000 },
      { country: "Name", type: "out", value: 200, region: "Asia", year: 2000 },
      ...
    ]
    // Total Items in Array: ~2,400
Enter fullscreen mode Exit fullscreen mode

选项 1:初始化空对象

除了硬编码之外,我原来的代码片段完全违反了“不要重复自己”(DRY)的代码编写原则。在某些情况下,重复自己确实有意义,但在这种情况下,当相同的属性被一遍又一遍地重复时,动态创建对象是更明智的选择。这样做还可以减少在数据集中添加新年份时所需的手动工作量,并降低输入错误的可能性。

有几种不同的方法可以使其更符合 DRY 原则:for.forEach.reduce等等。我将使用.reduceArray 方法,因为它会处理数组并将其转换为其他内容(在我们的例子中是对象)。我们将使用.reduce三次,每个分类一次。

首先,我们将类别声明为常量。以后,我们只需要在years数组中添加新的年份即可。接下来的代码会处理剩下的事情。

    const types = ['in', 'out']
    const years = [2000, 2005, 2010, 2015, 2016, 2017]
    const regions = ['Africa', 'Americas', 'Asia', 'Europe', 'Oceania']
Enter fullscreen mode Exit fullscreen mode

我们不想将其视为类型 → 年份 → 区域,而是想将其反转,从区域开始。一旦regions转换为对象,该对象将成为赋予年份属性的值。类型中的年份也是如此。请注意,可以用更少的代码行来编写此代码,但我更注重清晰度而不是巧妙性。

    const types = ['in', 'out']
    const years = [2000, 2005, 2010, 2015, 2016, 2017]
    const regions = ['Africa', 'Americas', 'Asia', 'Europe', 'Oceania']

    /*
      Convert regions to an object with each region as a property and 
      the region's value as an empty array.
    */
    const regionsObj = regions.reduce((acc, region) => {
      acc[region] = []
      return acc
    }, {}) // The initial value of the accumulator (`acc`) is set to `{}`. 

    console.log(regionsObj)
    // {Africa: [], Americas: [], Asia: [], Europe: [], Oceania: []}
Enter fullscreen mode Exit fullscreen mode

现在我们有了区域对象,我们可以对年份和类型执行类似的操作。但是,我们不会像对区域那样将它们的值设置为空数组,而是将它们的值设置为上一个类别的对象。

编辑:我注意到,原始代码片段在尝试加载数据时实际上不起作用,因为我只是引用了一个现有对象,而不是实例化一个新对象。下面的代码片段已更新,通过创建现有对象的深拷贝来解决这个问题。Lukas Gisder-Dubé 的《如何区分 JavaScript 中的深拷贝和浅拷贝》一文对此进行了解释。

    function copyObj(obj) {
      return JSON.parse(JSON.stringify(obj))
    }

    /* 
      Do the same thing with the years, but set the value 
      for each year to the regions object.
    */
    const yearsObj = years.reduce((acc, year) => {
        acc[year] = copyObj(regionsObj)
      return acc
    }, {})

    // One more time for the type. This will return our final object.
    const dataset = types.reduce((acc, type) => {
      acc[type] = copyObj(yearsObj)
      return acc
    }, {})

    console.log(dataset)
    // {
    //  in: {2000: {Africa: [], Americas: [],...}, ...},
    //  out: {2000: {Africa: [], Americas: [], ...}, ...}
    // }
Enter fullscreen mode Exit fullscreen mode

现在,我们得到了与原始代码片段相同的结果,但成功重构了现有代码片段,使其更具可读性和可维护性!在向数据集添加新的年份时,无需再进行复制粘贴!

但问题是:这个方法仍然需要手动更新年份列表。而且,如果我们无论如何都要将数据加载到对象中,就没有必要单独初始化一个空对象。接下来的两个重构选项将完全删除我原来的代码片段,并演示如何直接使用数据。

补充:说实话,如果我三年前尝试编写这个代码,我可能会写三个嵌套for循环,并且对结果很满意。但是嵌套循环可能会对性能产生显著的负面影响。这种方法分别关注每一层分类,消除了多余的循环并提高了性能。修改:查看此评论,了解此方法的示例以及关于性能的讨论。

选项 2:直接过滤

你们中有些人可能想知道为什么我们甚至要按类别对数据进行分组。基于我们的数据结构,我们可以.filter根据currentTypecurrentYear和来返回我们需要的数据currentRegion,如下所示:

    /*
      `.filter` will create a new array with all elements that return true
      if they are of the `currentType` and `currentYear`

      `.includes` returns true or false based on if `currentRegions`
      includes the entry's region
    */
    let currentData = data.filter(d => d.type === currentType && 
    d.year === currentYear && currentRegion.includes(d.region))
Enter fullscreen mode Exit fullscreen mode

虽然这个单行代码效果很好,但我不建议在我们的例子中使用它,原因有二:

  1. 每次用户进行选择时,此方法都会运行。根据数据集的大小(请记住,它每年都在增长),这可能会对性能产生负面影响。现代浏览器效率很高,性能损失可能微乎其微,但如果我们已经知道用户一次只能选择一种类型和一年,那么我们可以从一开始就对数据进行分组,从而主动提升性能。
  2. 此选项不提供可用类型、年份或地区的列表。如果我们有这些列表,就可以使用它们动态生成选择界面,而无需手动创建(和更新)。

带有硬编码选项的年份下拉菜单。

是的,我也硬编码了选择器。每次添加新的年份,我都得记得更新 JS 和 HTML。

选项 3:数据驱动对象

我们可以结合第一和​​第二种方案,以第三种方式重构代码。目标是在更新数据集时完全不必更改代码,而是根据数据本身确定类别。

再次强调,有多种技术方法可以实现这一点,但我会坚持下去,.reduce因为我们要将数据数组转换为对象。

    const dataset = data.reduce((acc, curr) => {
        /*
          If the current type exists as a property of our accumulator,
          set it equal to itself. Otherwise, set it equal to an empty object.
        */
        acc[curr.type] = acc[curr.type] || {}
        // Treat the year layer the same way
        acc[curr.type][curr.year] = acc[curr.type][curr.year] || []
        acc[curr.type][curr.year].push(curr)
        return acc
    }, {})
Enter fullscreen mode Exit fullscreen mode

请注意,我已经从数据集对象中删除了区域分类层。因为与类型和年份不同,区域可以以任意组合一次性选择多个。这使得预先分组到区域中几乎毫无用处,因为我们无论如何都必须将它们合并在一起。

考虑到这一点,下面是更新后的一行代码,currentData用于根据所选类型、年份和地区获取 。由于我们将查找范围限制在当前类型和年份的数据,因此我们知道数组中的最大项目数等于国家/地区的数量(少于 200 个),这使得它比选项 2 的实现效率高得多.filter

    let currentData = dataset[currentType][currentYear].filter(d => currentRegions.includes(d.region))
Enter fullscreen mode Exit fullscreen mode

最后一步是获取包含不同类型、年份和地区的数组。为此,我喜欢使用集合。下面是一个示例,说明如何获取包含数据中所有唯一.map地区的数组。

    /*
      `.map` will extract the specified object property 
      value (eg. regions) into a new array
    */
    let regions = data.map(d => d.region)

    /*
        By definition, a value in a Set must be unique.
        Duplicate values are excluded. 
    */
    regions = new Set(regions)

    // Array.from creates a new array from the Set
    regions = Array.from(regions)

    // One-line version
    regions = Array.from(new Set(data.map(d => d.region)))

    // or using the spread operator
    regions = [...new Set(data.map(d => d.region))]
Enter fullscreen mode Exit fullscreen mode

重复上述步骤,创建类型和年份数组。然后,您可以根据数组值动态创建筛选 UI。

最终重构代码

综上所述,我们最终得到了能够适应未来数据集变化的代码。无需手动更新!

    // Unique Types, Years, and Regions
    const types = Array.from(new Set(data.map(d => d.type)))
    const years = Array.from(new Set(data.map(d => d.year)))
    const regions = Array.from(new Set(data.map(d => d.region)))

    // Group data according to type and year
    const dataset = data.reduce((acc, curr) => {
        acc[curr.type] = acc[curr.type] || {}
        acc[curr.type][curr.year] = acc[curr.type][curr.year] || []
        acc[curr.type][curr.year].push(curr)
        return acc
    }, {})

    // Update current dataset based on selection
    let currentData = dataset[currentType][currentYear].filter(d => currentRegions.includes(d.region))
Enter fullscreen mode Exit fullscreen mode

最后的想法

清理语法只是重构的一小部分,但“重构代码”通常意味着重新构思不同部分之间的实现或关系。重构之所以困难,是因为解决问题的方法多种多样。一旦你找到了一个可行的解决方案,就很难再想出其他方案了。确定哪种解决方案更好并不总是显而易见的,而且会因代码上下文以及个人偏好而异。

我对提升重构能力的建议很简单:多读代码。如果你在团队中,积极参与代码评审。如果你被要求重构某个代码,问问为什么,并尝试了解其他人是如何解决问题的。如果你独自工作(就像我刚开始工作时一样),留意针对同一问题的不同解决方案,并寻找最佳代码实践指南。我强烈推荐阅读Jason McCreary《BaseCode》。它是一本优秀的编写更简单、更易读代码的指南,涵盖了大量实际案例。

最重要的是,接受你有时会写出糟糕的代码,而经历重构的过程——使其变得更好——是成长的标志,应该庆祝。

文章来源:https://dev.to/jnschrag/refactoring-the-worst-code-i-ve-ever-writing-42c7
PREV
应用这 7 条规则来清理你的代码⚡️
NEXT
使用 CSS 创建像素艺术