首页 > web前端 > js教程 > 清洗代码:分而治之,或合并并放松

清洗代码:分而治之,或合并并放松

Linda Hamilton
发布: 2024-11-11 19:30:03
原创
996 人浏览过

Washing your code: divide and conquer, or merge and relax

您正在阅读我关于干净代码的书“清洗代码”的摘录。提供 PDF、EPUB、平装本和 Kindle 版本。立即获取副本。


了解如何将代码组织成模块或函数,以及何时引入抽象而不是重复代码,是一项重要技能。编写其他人可以有效使用的通用代码是另一项技能。拆分代码的原因与将代码保持在一起的原因一样多。在本章中,我们将讨论其中一些原因。

让抽象增长

我们,开发者,讨厌同样的工作做两次。 DRY 是许多人的口头禅。然而,当我们有两到三段代码做同样的事情时,引入抽象可能还为时过早,无论它感觉多么诱人。

信息: 不要重复自己(DRY)原则要求“每条知识都必须在系统内有一个单一的、明确的、权威的表示”,这通常被解释为 严格禁止任何代码重复

暂时忍受代码重复的痛苦;也许最终并没有那么糟糕,而且代码实际上并不完全相同。一定程度的代码重复是健康的,可以让我们更快地迭代和改进代码,而不必担心破坏某些东西。

当我们只考虑几个用例时,也很难想出一个好的 API。

管理具有许多开发人员和团队的大型项目中的共享代码很困难。一个团队的新要求可能不适用于另一个团队并破坏他们的代码,或者我们最终会得到一个具有数十个条件的无法维护的意大利面怪物。

假设团队 A 正在向他们的页面添加一个评论表单:名称、消息和提交按钮。然后,团队 B 需要反馈表,因此他们找到团队 A 的组件并尝试重用它。然后,团队 A 也想要一个电子邮件字段,但他们不知道团队 B 使用他们的组件,因此他们添加了一个必需的电子邮件字段并破坏了团队 B 用户的功能。然后,团队 B 需要电话号码字段,但他们知道团队 A 使用的组件没有该字段,因此他们添加了一个选项来显示电话号码字段。一年后,两个团队因破坏对方代码而互相憎恨,而且组件充满了条件,无法维护。如果两个团队都维护由较低级别的共享组件(例如输入字段或按钮)组成的单独组件,那么他们将节省大量时间并拥有更健康的关系。

提示:禁止其他团队使用我们的代码可能是个好主意,除非它被设计并标记为共享。 Dependency Cruiser 是一个可以帮助建立此类规则的工具。

有时,我们必须回滚抽象。当我们开始添加条件和选项时,我们应该问自己:它仍然是同一事物的变体还是应该分离的新事物?向模块添加太多条件和参数可能会导致 API 难以使用,代码也难以维护和测试。

重复比错误的抽象更便宜、更健康。

信息:请参阅 Sandi Metz 的文章《错误的抽象》以获得很好的解释。

代码的级别越高,我们在抽象它之前需要等待的时间就越长。低级实用抽象比业务逻辑更加明显和稳定。

尺寸并不总是重要的

代码重用并不是将一段代码提取到单独的函数或模块中的唯一原因,甚至不是最重要的原因。

代码长度通常被用作我们何时应该拆分模块或函数的指标,但大小本身并不会使代码难以阅读或维护。

将线性算法(即使是很长的算法)拆分为多个函数,然后依次调用它们,很少会使代码更具可读性。在函数(甚至文件)之间跳转比滚动更困难,如果我们必须研究每个函数的实现来理解代码,那么抽象就不正确。

信息: Egon Elbre 写了一篇关于代码可读性心理学的好文章。

这是一个示例,改编自 Google 测试博客:

function createPizza(order) {
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });

  if (order.kind === 'Veg') {
    pizza.toppings = vegToppings;
  } else if (order.kind === 'Meat') {
    pizza.toppings = meatToppings;
  }

  const oven = new Oven();

  if (oven.temp !== cookingTemp) {
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }

  if (!pizza.baked) {
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }

  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(order.size);
  pizza.ready = box.close();

  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

我对 Pizza 类的 API 有很多疑问,但让我们看看作者建议的改进:

function prepare(order) {
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });
  addToppings(pizza, order.kind);
  return pizza;
}

function addToppings(pizza, kind) {
  if (kind === 'Veg') {
    pizza.toppings = vegToppings;
  } else if (kind === 'Meat') {
    pizza.toppings = meatToppings;
  }
}

function bake(pizza) {
  const oven = new Oven();
  heatOven(oven);
  bakePizza(pizza, oven);
}

function heatOven(oven) {
  if (oven.temp !== cookingTemp) {
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }
}

function bakePizza(pizza, oven) {
  if (!pizza.baked) {
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }
}

function pack(pizza) {
  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(pizza.size);
  pizza.ready = box.close();
}

function createPizza(order) {
  const pizza = prepare(order);
  bake(pizza);
  pack(pizza);
  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

原本就复杂的事情现在变得更加复杂,一半的代码只是函数调用。这并不会使代码更容易理解,但确实使其几乎无法使用。文章没有展示重构版本的完整代码,或许是为了让观点更有说服力。

Pierre “catwell” Chapuis 在他的博文中建议添加评论而不是新功能:

function createPizza(order) {
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });

  if (order.kind === 'Veg') {
    pizza.toppings = vegToppings;
  } else if (order.kind === 'Meat') {
    pizza.toppings = meatToppings;
  }

  const oven = new Oven();

  if (oven.temp !== cookingTemp) {
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }

  if (!pizza.baked) {
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }

  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(order.size);
  pizza.ready = box.close();

  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

这已经比拆分版本好得多了。更好的解决方案是改进 API 并使代码更加清晰。皮埃尔建议,预热烤箱不应该成为 createPizza() 函数的一部分(我自己也烤了很多披萨,我完全同意!),因为在现实生活中,烤箱已经在那里了,而且可能已经因为之前的披萨而热了。皮埃尔还建议该函数应该返回盒子,而不是披萨,因为在原始代码中,盒子在所有切片和包装魔法之后消失了,我们最终手里拿着切片披萨。

烹饪披萨的方法有很多种,就像解决问题的方法有很多种一样。结果可能看起来相同,但某些解决方案比其他解决方案更容易理解、修改、重用和删除。

当所有提取的函数都是同一算法的一部分时,命名也可能是一个问题。我们需要发明比代码更清晰、比注释更短的名称——这不是一件容易的事。

信息:我们在避免注释章节中讨论注释代码,并在命名很难章节中讨论命名。

你可能在我的代码中找不到很多小函数。根据我的经验,拆分代码最有用的原因是更改频率更改原因

单独的经常更改的代码

让我们从更改频率开始。业务逻辑的变化比效用函数更频繁。将经常更改的代码与非常稳定的代码分开是有意义的。

我们在本章前面讨论的评论表单就是前者的一个例子;将camelCase 字符串转换为kebab-case 的函数就是后者的一个示例。当出现新的业务需求时,评论表单可能会随着时间的推移而发生变化和分歧;大小写转换函数根本不可能改变,并且可以在很多地方安全地重用。

想象一下我们正在制作一个漂亮的表格来显示一些数据。我们可能认为我们永远不会再需要这个表设计,因此我们决定将表的所有代码保留在单个模块中。

下一个冲刺,我们的任务是向表中添加另一列,因此我们复制现有列的代码并更改其中的几行。下一个冲刺,我们需要添加另一个具有相同设计的表格。下一个冲刺,我们需要改变表格的设计......

我们的表格模块至少有三个更改原因,或职责

  • 新的业务需求,比如新的表列;
  • UI 或行为更改,例如添加排序或调整列大小;
  • 设计更改,例如用条纹行背景替换边框。

这使得模块更难理解并且更难更改。展示性代码增加了很多冗长的内容,使得业务逻辑更难理解。要更改任何职责,我们需要阅读和修改更多代码。这使得迭代变得更加困难和缓慢。

将通用表作为单独的模块可以解决这个问题。现在,要向表中添加另一列,我们只需要了解并修改两个模块之一。除了其公共 API 之外,我们不需要了解有关通用表模块的任何信息。要更改所有表的设计,我们只需要更改通用表模块的代码,并且可能根本不需要触及各个表。

但是,根据问题的复杂性,从整体方法开始并稍后提取抽象是可以的,而且通常更好。

甚至代码重用也可以成为分离代码的有效理由:如果我们在一个页面上使用某些组件,我们可能很快就会在另一个页面上需要它。

将同时更改的代码放在一起

将每个函数提取到自己的模块中可能很诱人。然而,它也有缺点:

  • 其他开发人员可能认为他们可以在其他地方重用该函数,但实际上,该函数可能不够通用或经过足够的测试而无法重用。
  • 当该功能仅在一个地方使用时,创建、导入和在多个文件之间切换会产生不必要的开销。
  • 此类函数通常在使用它们的代码消失后很长时间内仍保留在代码库中。

我更喜欢将仅在一个模块中使用的小函数保留在模块的开头。这样,我们不需要导入它们以在同一个模块中使用,但在其他地方重用它们会很尴尬。

function createPizza(order) {
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });

  if (order.kind === 'Veg') {
    pizza.toppings = vegToppings;
  } else if (order.kind === 'Meat') {
    pizza.toppings = meatToppings;
  }

  const oven = new Oven();

  if (oven.temp !== cookingTemp) {
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }

  if (!pizza.baked) {
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }

  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(order.size);
  pizza.ready = box.close();

  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

在上面的代码中,我们有一个组件(FormattedAddress)和一个函数(getMapLink()),它们仅在该模块中使用,因此它们定义在文件的顶部。

如果我们需要测试这些函数(我们应该!),我们可以将它们从模块中导出并与模块的主函数一起测试它们。

这同样适用于仅与特定函数或组件一起使用的函数。将它们放在同一个模块中可以更清楚地看出所有函数属于同一组,并使这些函数更容易被发现。

另一个好处是,当我们删除模块时,我们会自动删除其依赖项。共享模块中的代码通常会永远保留在代码库中,因为很难知道它是否仍在使用(尽管 TypeScript 使这变得更容易)。

信息:此类模块有时称为深层模块:封装复杂问题但具有简单API的相对较大的模块。深层模块的反面是浅层模块:许多需要彼此交互的小模块。

如果我们经常需要同时更改多个模块或函数,那么将它们合并到单个模块或函数中可能会更好。这种方法有时称为共置

以下是几个托管示例:

  • React 组件:将组件所需的所有内容保留在同一个文件中,包括标记 (JSX)、样式(JS 中的 CSS)和逻辑,而不是将每个组件分离到自己的文件中,可能在单独的文件夹中。
  • 测试:将测试放在模块文件旁边,而不是放在单独的文件夹中。
  • Redux 的 Ducks 约定:将相关操作、操作创建者和减速器保留在同一个文件中,而不是将它们放在单独文件夹中的三个文件中。

以下是文件树如何随着共置而变化:

<桌子> <标题> 分开 同地 <正文>
Separated Colocated
React components
src/components/Button.tsx src/components/Button.tsx
styles/Button.css
Tests
src/util/formatDate.ts src/util/formatDate.ts
tests/formatDate.ts src/util/formatDate.test.ts
Ducks
src/actions/feature.js src/ducks/feature.js
src/actionCreators/feature.js
src/reducers/feature.js
反应组件<🎜> src/components/Button.tsx src/components/Button.tsx 样式/Button.css <🎜>测试<🎜> src/util/formatDate.ts src/util/formatDate.ts 测试/formatDate.ts src/util/formatDate.test.ts <🎜>鸭子<🎜> src/actions/feature.js src/ducks/feature.js src/actionCreators/feature.js src/reducers/feature.js

信息:要了解有关托管的更多信息,请阅读 Kent C. Dodds 的文章。

关于托管的一个常见抱怨是它使组件太大。在这种情况下,最好将某些部分连同标记、样式和逻辑一起提取到它们自己的组件中。

共置的想法也与关注点分离相冲突——这是一个过时的想法,导致 Web 开发人员将 HTML、CSS 和 JavaScript 保存在单独的文件中(并且通常保存在文件树的不同部分中)太长了,迫使我们同时编辑三个文件,甚至对网页进行最基本的更改。

信息:更改原因也称为单一职责原则,该原则规定“每个模块、类或函数都应该对功能的单个部分负责”由软件提供,并且该责任应该完全由类封装。”

把那些丑陋的代码隐藏起来

有时,我们必须使用特别难以使用或容易出错的 API。例如,它需要按特定顺序执行多个步骤,或者调用具有多个始终相同的参数的函数。这是创建效用函数以确保我们始终做对的一个很好的理由。作为奖励,我们现在可以为这段代码编写测试。

字符串操作——例如 URL、文件名、大小写转换或格式——是很好的抽象候选者。最有可能的是,已经有一个库可以满足我们正在尝试做的事情。

考虑这个例子:

function createPizza(order) {
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });

  if (order.kind === 'Veg') {
    pizza.toppings = vegToppings;
  } else if (order.kind === 'Meat') {
    pizza.toppings = meatToppings;
  }

  const oven = new Oven();

  if (oven.temp !== cookingTemp) {
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }

  if (!pizza.baked) {
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }

  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(order.size);
  pizza.ready = box.close();

  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

需要一些时间才能意识到此代码删除了文件扩展名并返回基本名称。它不仅没有必要且难以阅读,而且还假设扩展名始终为三个字符,但事实可能并非如此。

让我们使用库(内置 Node.js 的路径模块)重写它:

function prepare(order) {
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });
  addToppings(pizza, order.kind);
  return pizza;
}

function addToppings(pizza, kind) {
  if (kind === 'Veg') {
    pizza.toppings = vegToppings;
  } else if (kind === 'Meat') {
    pizza.toppings = meatToppings;
  }
}

function bake(pizza) {
  const oven = new Oven();
  heatOven(oven);
  bakePizza(pizza, oven);
}

function heatOven(oven) {
  if (oven.temp !== cookingTemp) {
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }
}

function bakePizza(pizza, oven) {
  if (!pizza.baked) {
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }
}

function pack(pizza) {
  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(pizza.size);
  pizza.ready = box.close();
}

function createPizza(order) {
  const pizza = prepare(order);
  bake(pizza);
  pack(pizza);
  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

现在,很清楚发生了什么,没有神奇的数字,并且它适用于任何长度的文件扩展名。

其他抽象候选包括日期、设备功能、表单、数据验证、国际化等等。我建议在编写新的实用函数之前查找现有的库。我们经常低估看似简单功能的复杂性。

以下是此类库的一些示例:

  • Lodash:各种实用函数。
  • Date-fns:处理日期的函数,例如解析、操作和格式化。
  • Zod:TypeScript 的架构验证。

祝福内联重构!

有时,我们会得意忘形并创建既不能简化代码也不能缩短代码的抽象:

function createPizza(order) {
  // Prepare pizza
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });

  // Add toppings
  if (order.kind == 'Veg') {
    pizza.toppings = vegToppings;
  } else if (order.kind == 'Meat') {
    pizza.toppings = meatToppings;
  }

  const oven = new Oven();

  if (oven.temp !== cookingTemp) {
    // Heat oven
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }

  if (!pizza.baked) {
    // Bake pizza
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }

  // Box and slice
  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(order.size);
  pizza.ready = box.close();

  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

另一个例子:

function createPizza(order) {
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });

  if (order.kind === 'Veg') {
    pizza.toppings = vegToppings;
  } else if (order.kind === 'Meat') {
    pizza.toppings = meatToppings;
  }

  const oven = new Oven();

  if (oven.temp !== cookingTemp) {
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }

  if (!pizza.baked) {
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }

  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(order.size);
  pizza.ready = box.close();

  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

在这种情况下我们能做的最好的事情就是应用全能的内联重构:用它的主体替换每个函数调用。没有抽象,没问题。

第一个示例变为:

function prepare(order) {
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });
  addToppings(pizza, order.kind);
  return pizza;
}

function addToppings(pizza, kind) {
  if (kind === 'Veg') {
    pizza.toppings = vegToppings;
  } else if (kind === 'Meat') {
    pizza.toppings = meatToppings;
  }
}

function bake(pizza) {
  const oven = new Oven();
  heatOven(oven);
  bakePizza(pizza, oven);
}

function heatOven(oven) {
  if (oven.temp !== cookingTemp) {
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }
}

function bakePizza(pizza, oven) {
  if (!pizza.baked) {
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }
}

function pack(pizza) {
  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(pizza.size);
  pizza.ready = box.close();
}

function createPizza(order) {
  const pizza = prepare(order);
  bake(pizza);
  pack(pizza);
  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

第二个例子变成:

function createPizza(order) {
  // Prepare pizza
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });

  // Add toppings
  if (order.kind == 'Veg') {
    pizza.toppings = vegToppings;
  } else if (order.kind == 'Meat') {
    pizza.toppings = meatToppings;
  }

  const oven = new Oven();

  if (oven.temp !== cookingTemp) {
    // Heat oven
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }

  if (!pizza.baked) {
    // Bake pizza
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }

  // Box and slice
  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(order.size);
  pizza.ready = box.close();

  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

结果不仅更短且更具可读性;现在读者不需要猜测这些函数的作用,因为我们现在使用 JavaScript 原生函数和特性,而不需要自制的抽象。

在很多情况下,重复一点是有好处的。考虑这个例子:

function FormattedAddress({ address, city, country, district, zip }) {
  return [address, zip, district, city, country]
    .filter(Boolean)
    .join(', ');
}

function getMapLink({ name, address, city, country, zip }) {
  return `https://www.google.com/maps/?q=${encodeURIComponent(
    [name, address, zip, city, country].filter(Boolean).join(', ')
  )}`;
}

function ShopsPage({ url, title, shops }) {
  return (
    <PageWithTitle url={url} title={title}>
      <Stack as="ul" gap="l">
        {shops.map(shop => (
          <Stack key={shop.name} as="li" gap="m">
            <Heading level={2}>
              <Link href={shop.url}>{shop.name}</Link>
            </Heading>
            {shop.address && (
              <Text variant="small">
                <Link href={getMapLink(shop)}>
                  <FormattedAddress {...shop} />
                </Link>
              </Text>
            )}
          </Stack>
        ))}
      </Stack>
    </PageWithTitle>
  );
}
登录后复制
登录后复制
登录后复制

它看起来非常好,并且在代码审查期间不会提出任何问题。但是,当我们尝试使用这些值时,自动补全仅显示数字而不是实际值(参见插图)。这使得选择正确的值变得更加困难。

Washing your code: divide and conquer, or merge and relax

我们可以内联 baseSpacing 常量:

const file = 'pizza.jpg';
const prefix = file.slice(0, -4);
// → 'pizza'
登录后复制
登录后复制

现在,我们的代码更少了,也更容易理解,并且自动补全显示了实际值(参见插图)。而且我认为这段代码不会经常更改——可能永远不会。

Washing your code: divide and conquer, or merge and relax

区分“什么”和“如何”

考虑表单验证函数的摘录:

const file = 'pizza.jpg';
const prefix = path.parse(file).name;
// → 'pizza'
登录后复制
登录后复制

很难理解这里发生了什么:验证逻辑与错误消息混合在一起,许多检查都是重复的......

我们可以把这个函数分成几个部分,每个部分只负责一件事:

  • 特定表单的验证列表;
  • 验证函数的集合,例如 isEmail();
  • 使用验证列表验证所有表单值的函数。

我们可以将验证以声明方式描述为数组:

// my_feature_util.js
const noop = () => {};

export const Utility = {
  noop
  // Many more functions…
};

// MyComponent.js
function MyComponent({ onClick }) {
  return <button onClick={onClick}>Hola!</button>;
}

MyComponent.defaultProps = {
  onClick: Utility.noop
};
登录后复制
登录后复制

每个验证函数和运行验证的函数都非常通用,因此我们可以抽象它们或使用第三方库。

现在,我们可以通过描述哪些字段需要哪些验证以及当某个检查失败时显示哪些错误来为任何表单添加验证。

信息: 有关完整代码和此示例的更详细说明,请参阅避免条件章节。

我称这个过程“什么”和“如何”的分离

  • “什么” 是数据 — 特定表单的验证列表;
  • “如何” 是算法 - 验证函数和验证运行器函数。

好处是:

  • 可读性:通常,我们可以使用数组和对象等基本数据结构以声明方式定义“内容”。
  • 可维护性:我们更频繁地更改“内容”而不是“如何”,现在它们是分开的。我们可以从文件(例如 JSON)导入“内容”,或从数据库加载它,从而无需更改代码即可进行更新,或者允许非开发人员执行这些操作。
  • 可重用性:通常,“如何”是通用的,我们可以重用它,甚至从第三方库导入它。
  • 可测试性:每个验证和验证运行器函数都是隔离的,我们可以单独测试它们。

避免怪物实用程序文件

许多项目都有一个名为 utils.js、helpers.js 或 Misc.js 的文件,开发人员在找不到更好的位置时会在其中添加实用程序函数。通常,这些函数永远不会在其他地方重用,并永远保留在实用程序文件中,因此它不断增长。这就是怪物实用程序文件诞生的方式。

怪物实用程序文件有几个问题:

  • 可发现性差:由于所有函数都在同一个文件中,因此我们无法使用代码编辑器中的模糊文件打开器来查找它们。
  • 它们可能比调用者的寿命更长:通常这些函数永远不会被再次重用并保留在代码库中,即使在使用它们的代码被删除之后也是如此。
  • 不够通用:此类函数通常是针对单个用例而设计的,不会涵盖其他用例。

这些是我的经验法则:

  • 如果函数很小并且只使用一次,请将其保留在使用它的同一个模块中。
  • 如果函数很长或多次使用,请将其放在 util、shared 或 helpers 文件夹内的单独文件中。
  • 如果我们想要更多的组织,我们可以将相关函数(每个函数都在自己的文件中)分组到一个文件夹中,而不是创建像 utils/validators.js 这样的文件。

避免默认导出

JavaScript 模块有两种类型的导出。第一个是命名导出

function createPizza(order) {
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });

  if (order.kind === 'Veg') {
    pizza.toppings = vegToppings;
  } else if (order.kind === 'Meat') {
    pizza.toppings = meatToppings;
  }

  const oven = new Oven();

  if (oven.temp !== cookingTemp) {
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }

  if (!pizza.baked) {
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }

  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(order.size);
  pizza.ready = box.close();

  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

可以这样导入:

function prepare(order) {
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });
  addToppings(pizza, order.kind);
  return pizza;
}

function addToppings(pizza, kind) {
  if (kind === 'Veg') {
    pizza.toppings = vegToppings;
  } else if (kind === 'Meat') {
    pizza.toppings = meatToppings;
  }
}

function bake(pizza) {
  const oven = new Oven();
  heatOven(oven);
  bakePizza(pizza, oven);
}

function heatOven(oven) {
  if (oven.temp !== cookingTemp) {
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }
}

function bakePizza(pizza, oven) {
  if (!pizza.baked) {
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }
}

function pack(pizza) {
  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(pizza.size);
  pizza.ready = box.close();
}

function createPizza(order) {
  const pizza = prepare(order);
  bake(pizza);
  pack(pizza);
  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

第二个是默认导出:

function createPizza(order) {
  // Prepare pizza
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });

  // Add toppings
  if (order.kind == 'Veg') {
    pizza.toppings = vegToppings;
  } else if (order.kind == 'Meat') {
    pizza.toppings = meatToppings;
  }

  const oven = new Oven();

  if (oven.temp !== cookingTemp) {
    // Heat oven
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }

  if (!pizza.baked) {
    // Bake pizza
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }

  // Box and slice
  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(order.size);
  pizza.ready = box.close();

  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

可以这样导入:

function FormattedAddress({ address, city, country, district, zip }) {
  return [address, zip, district, city, country]
    .filter(Boolean)
    .join(', ');
}

function getMapLink({ name, address, city, country, zip }) {
  return `https://www.google.com/maps/?q=${encodeURIComponent(
    [name, address, zip, city, country].filter(Boolean).join(', ')
  )}`;
}

function ShopsPage({ url, title, shops }) {
  return (
    <PageWithTitle url={url} title={title}>
      <Stack as="ul" gap="l">
        {shops.map(shop => (
          <Stack key={shop.name} as="li" gap="m">
            <Heading level={2}>
              <Link href={shop.url}>{shop.name}</Link>
            </Heading>
            {shop.address && (
              <Text variant="small">
                <Link href={getMapLink(shop)}>
                  <FormattedAddress {...shop} />
                </Link>
              </Text>
            )}
          </Stack>
        ))}
      </Stack>
    </PageWithTitle>
  );
}
登录后复制
登录后复制
登录后复制

我确实没有看到默认导出有任何优势,但它们有几个问题:

  • 糟糕的重构:使用默认导出重命名模块通常会使现有导入保持不变。命名导出不会发生这种情况,所有导入都会在重命名函数后更新。
  • 不一致:默认导出的模块可以使用任何名称导入,这会降低代码库的一致性和可重复性。命名导出也可以使用 as 关键字使用不同的名称导入,以避免命名冲突,但它更明确,并且很少是偶然完成的。

信息:我们在其他技术章节的编写greppable代码部分详细讨论了greppability。

不幸的是,一些第三方 API,例如 React.lazy() 需要默认导出,但对于所有其他情况,我坚持使用命名导出。

避免桶锉

桶文件是一个模块(通常命名为index.js 或index.ts),它重新导出一堆其他模块:

function createPizza(order) {
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });

  if (order.kind === 'Veg') {
    pizza.toppings = vegToppings;
  } else if (order.kind === 'Meat') {
    pizza.toppings = meatToppings;
  }

  const oven = new Oven();

  if (oven.temp !== cookingTemp) {
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }

  if (!pizza.baked) {
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }

  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(order.size);
  pizza.ready = box.close();

  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

主要优势是更清洁的进口。而不是单独导入每个模块:

function prepare(order) {
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });
  addToppings(pizza, order.kind);
  return pizza;
}

function addToppings(pizza, kind) {
  if (kind === 'Veg') {
    pizza.toppings = vegToppings;
  } else if (kind === 'Meat') {
    pizza.toppings = meatToppings;
  }
}

function bake(pizza) {
  const oven = new Oven();
  heatOven(oven);
  bakePizza(pizza, oven);
}

function heatOven(oven) {
  if (oven.temp !== cookingTemp) {
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }
}

function bakePizza(pizza, oven) {
  if (!pizza.baked) {
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }
}

function pack(pizza) {
  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(pizza.size);
  pizza.ready = box.close();
}

function createPizza(order) {
  const pizza = prepare(order);
  bake(pizza);
  pack(pizza);
  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

我们可以从桶文件导入所有组件:

function createPizza(order) {
  // Prepare pizza
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });

  // Add toppings
  if (order.kind == 'Veg') {
    pizza.toppings = vegToppings;
  } else if (order.kind == 'Meat') {
    pizza.toppings = meatToppings;
  }

  const oven = new Oven();

  if (oven.temp !== cookingTemp) {
    // Heat oven
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }

  if (!pizza.baked) {
    // Bake pizza
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }

  // Box and slice
  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(order.size);
  pizza.ready = box.close();

  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

但是,桶文件有几个问题:

  • 维护成本:我们需要在桶文件中添加每个新组件的导出,以及诸如实用函数类型之类的附加项目。
  • 性能成本:设置tree shake很复杂,并且桶文件通常会导致包大小或运行时成本增加。这也会减慢热重载、单元测试和 linter。
  • 循环导入:当两个模块都从同一个桶文件导入时(例如,Button 组件导入 Box 组件),从桶文件导入可能会导致循环导入。
  • 开发者体验:导航到函数定义导航到桶文件而不是函数的源代码;和 autoimport 可能会混淆是否从桶文件而不是源文件导入。

信息: TkDodo 详细解释了桶文件的缺点。

桶状锉刀的好处太小,不足以证明其使用的合理性,因此我建议避免使用它们。

我特别不喜欢的一种类型的桶文件是那些导出单个组件只是为了允许将其导入为 ./components/button 而不是 ./components/button/button。

保持水分

为了攻击 DRYers(从不重复代码的开发人员),有人创造了另一个术语:WET,将所有内容写两次,或者 我们喜欢打字,建议我们应该在以下位置复制代码至少两次,直到我们用抽象替换它。这是一个笑话,我并不完全同意这个想法(有时重复一些代码两次以上是可以的),但它很好地提醒我们,所有美好的事物都最好适度。

考虑这个例子:

function createPizza(order) {
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });

  if (order.kind === 'Veg') {
    pizza.toppings = vegToppings;
  } else if (order.kind === 'Meat') {
    pizza.toppings = meatToppings;
  }

  const oven = new Oven();

  if (oven.temp !== cookingTemp) {
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }

  if (!pizza.baked) {
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }

  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(order.size);
  pizza.ready = box.close();

  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

这是代码干燥的一个极端例子,它不会使代码更具可读性或可维护性,特别是当大多数常量仅使用一次时。在这里看到变量名称而不是实际字符串是没有帮助的。

让我们内联所有这些额外的变量。 (不幸的是,Visual Studio Code 中的内联重构不支持内联对象属性,因此我们必须手动执行此操作。)

function prepare(order) {
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });
  addToppings(pizza, order.kind);
  return pizza;
}

function addToppings(pizza, kind) {
  if (kind === 'Veg') {
    pizza.toppings = vegToppings;
  } else if (kind === 'Meat') {
    pizza.toppings = meatToppings;
  }
}

function bake(pizza) {
  const oven = new Oven();
  heatOven(oven);
  bakePizza(pizza, oven);
}

function heatOven(oven) {
  if (oven.temp !== cookingTemp) {
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }
}

function bakePizza(pizza, oven) {
  if (!pizza.baked) {
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }
}

function pack(pizza) {
  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(pizza.size);
  pizza.ready = box.close();
}

function createPizza(order) {
  const pizza = prepare(order);
  bake(pizza);
  pack(pizza);
  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

现在,我们的代码显着减少,并且更容易理解正在发生的事情,也更容易更新或删除测试。

我在测试中遇到了很多无望的抽象。例如,这种模式很常见:

function createPizza(order) {
  // Prepare pizza
  const pizza = new Pizza({
    base: order.size,
    sauce: order.sauce,
    cheese: 'Mozzarella'
  });

  // Add toppings
  if (order.kind == 'Veg') {
    pizza.toppings = vegToppings;
  } else if (order.kind == 'Meat') {
    pizza.toppings = meatToppings;
  }

  const oven = new Oven();

  if (oven.temp !== cookingTemp) {
    // Heat oven
    while (oven.temp < cookingTemp) {
      time.sleep(checkOvenInterval);
      oven.temp = getOvenTemp(oven);
    }
  }

  if (!pizza.baked) {
    // Bake pizza
    oven.insert(pizza);
    time.sleep(cookTime);
    oven.remove(pizza);
    pizza.baked = true;
  }

  // Box and slice
  const box = new Box();
  pizza.boxed = box.putIn(pizza);
  pizza.sliced = box.slicePizza(order.size);
  pizza.ready = box.close();

  return pizza;
}
登录后复制
登录后复制
登录后复制
登录后复制
登录后复制

此模式试图避免在每个测试用例中重复 mount(...) 调用,但它使测试变得比实际需要的更加混乱。让我们内联 mount() 调用:

function FormattedAddress({ address, city, country, district, zip }) {
  return [address, zip, district, city, country]
    .filter(Boolean)
    .join(', ');
}

function getMapLink({ name, address, city, country, zip }) {
  return `https://www.google.com/maps/?q=${encodeURIComponent(
    [name, address, zip, city, country].filter(Boolean).join(', ')
  )}`;
}

function ShopsPage({ url, title, shops }) {
  return (
    <PageWithTitle url={url} title={title}>
      <Stack as="ul" gap="l">
        {shops.map(shop => (
          <Stack key={shop.name} as="li" gap="m">
            <Heading level={2}>
              <Link href={shop.url}>{shop.name}</Link>
            </Heading>
            {shop.address && (
              <Text variant="small">
                <Link href={getMapLink(shop)}>
                  <FormattedAddress {...shop} />
                </Link>
              </Text>
            )}
          </Stack>
        ))}
      </Stack>
    </PageWithTitle>
  );
}
登录后复制
登录后复制
登录后复制

此外,beforeEach 模式仅在我们想要使用相同的值初始化每个测试用例时才起作用,但这种情况很少发生:

const file = 'pizza.jpg';
const prefix = file.slice(0, -4);
// → 'pizza'
登录后复制
登录后复制

为了避免在测试 React 组件时一些重复,我经常添加一个 defaultProps 对象并将其传播到每个测试用例中:

const file = 'pizza.jpg';
const prefix = path.parse(file).name;
// → 'pizza'
登录后复制
登录后复制

这样,我们就不会出现太多的重复,但同时每个测试用例都是隔离且可读的。测试用例之间的差异现在更加清晰,因为更容易看到每个测试用例的独特属性。

这是同一问题的更极端的变体:

// my_feature_util.js
const noop = () => {};

export const Utility = {
  noop
  // Many more functions…
};

// MyComponent.js
function MyComponent({ onClick }) {
  return <button onClick={onClick}>Hola!</button>;
}

MyComponent.defaultProps = {
  onClick: Utility.noop
};
登录后复制
登录后复制

我们可以像上一个示例中一样内联 beforeEach() 函数:

const findByReference = (wrapper, reference) =>
  wrapper.find(reference);

const favoriteTaco = findByReference(
  ['Al pastor', 'Cochinita pibil', 'Barbacoa'],
  x => x === 'Cochinita pibil'
);

// → 'Cochinita pibil'
登录后复制

我会更进一步,使用 test.each() 方法,因为我们使用一堆不同的输入运行相同的测试:

function MyComponent({ onClick }) {
  return <button onClick={onClick}>Hola!</button>;
}

MyComponent.defaultProps = {
  onClick: () => {}
};
登录后复制

现在,我们已将所有测试输入及其预期结果收集到一个地方,从而可以更轻松地添加新的测试用例。

信息:查看我的 Jest 和 Vitest 备忘单。


抽象的最大挑战是在过于僵化和过于灵活之间找到平衡,并知道何时开始抽象事物以及何时停止。通常值得等待,看看我们是否真的需要抽象某些东西——很多时候,最好不要这样做。

有一个全局按钮组件很好,但如果它太灵活并且有十几个布尔属性在不同变体之间切换,那么它将很难使用。但是,如果过于严格,开发人员将创建自己的按钮组件,而不是使用共享的按钮组件。

我们应该警惕让其他人重用我们的代码。通常,这会在应该独立的代码库部分之间造成紧密耦合,从而减慢开发速度并导致错误。

开始思考:

  • 将相关代码放在同一文件或文件夹中,以便更轻松地更改、移动或删除。
  • 在向抽象添加另一个选项之前,请考虑这个新用例是否真正属于该抽象。
  • 在合并几段看起来相似的代码之前,想想它们是否实际上解决了相同的问题,或者只是碰巧看起来相同。
  • 在进行 DRY 测试之前,请考虑这是否会使它们更具可读性和可维护性,或者一些代码重复不是问题。

如果您有任何反馈,请发送给我、发推文、在 GitHub 上打开问题或给我发送电子邮件至 artem@sapegin.ru。获取您的副本。

以上是清洗代码:分而治之,或合并并放松的详细内容。更多信息请关注PHP中文网其他相关文章!

来源:dev.to
本站声明
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn
作者最新文章
热门教程
更多>
最新下载
更多>
网站特效
网站源码
网站素材
前端模板