likes
comments
collection
share

《重构:改善既有代码的设计》3. 坏代码的味道

作者站长头像
站长
· 阅读数 4

3. 坏代码的味道

3.1 神秘命名(Mysterious Name)

使用难以理解的变量名、函数名等,会导致代码难以阅读和维护。可以考虑更改变量名、函数名,使其更加清晰易懂。

// 坏代码示例
function fn(a, b, c) {
  for (let i = 0; i < a.length; i++) {
    const x = a[i];
    if (x[b] === c) {
      return x;
    }
  }
}

// 改进后的代码示例
function findObjectByKey(array, key, value) {
  for (let i = 0; i < array.length; i++) {
    const object = array[i];
    if(object[key] === value) {
      return object;
    }
  }
}

在上面的示例中,我们将 fn 函数的名称改成了更加具体的 findObjectByKey,并且给函数参数添加了描述性的名称,使得代码更加易懂。

神秘命名不仅存在于函数和变量名称中,也可能出现在类名、属性名等其他方面。对于这些情况,同样需要注意名称的清晰和易懂性。

3.2 重复代码(Duplicated Code)

重复代码往往会导致维护成本的增加,同时也难以保证代码的正确性。可以考虑将重复代码封装成函数或者类,从而避免代码重复。

// 坏代码示例
function renderHeader() {
  const header = document.createElement('header');
  const logo = document.createElement('img');
  logo.src = '/images/logo.png';
  const nav = document.createElement('nav');
  const homeLink = document.createElement('a');
  homeLink.href = '/';
  homeLink.textContent = 'Home';
  const aboutLink = document.createElement('a');
  aboutLink.href = '/about';
  aboutLink.textContent = 'About';

  nav.appendChild(homeLink);
  nav.appendChild(aboutLink);
  header.appendChild(logo);
  header.appendChild(nav);

  document.body.appendChild(header);
}

function renderFooter() {
  const footer = document.createElement('footer');
  const copyright = document.createElement('p');
  copyright.textContent = 'Copyright © 2023';

  footer.appendChild(copyright);

  document.body.appendChild(footer);
}

// 改进后的代码示例
function createElement(type, attributes, children) {
  const element = document.createElement(type);

  for (const [key, value] of Object.entries(attributes)) {
    element.setAttribute(key, value);
  }

  if (children) {
    for (const child of children) {
      if (typeof child === 'string') {
        element.appendChild(document.createTextNode(child));
      } else {
        element.appendChild(child);
      }
    }
  }

  return element;
}

function renderHeader() {
  const header = createElement('header', null, [
    createElement('img', { src: '/images/logo.png' }),
    createElement('nav', null, [
      createElement('a', { href: '/' }, 'Home'),
      createElement('a', { href: '/about' }, 'About')
    ])
  ]);

  document.body.appendChild(header);
}

function renderFooter() {
  const footer = createElement('footer', null, [
    createElement('p', null, 'Copyright © 2023')
  ]);

  document.body.appendChild(footer);
}

在上面的示例中,我们将 renderHeaderrenderFooter 函数中的重复代码封装成了名为 createElement 的函数。这样做既避免了代码重复,也使得代码更加清晰易懂。

3.3 过长函数(Long Function)

过长的函数往往表示这个函数承担了太多的职责,难以理清函数的逻辑。可以考虑将其拆分成多个小函数,每个函数只承担一个职责。

// 坏代码示例
function calculateScore() {
  // 大块逻辑
  // ...
}

// 改进后的代码示例
function calculateScore() {
  const baseScore = getBaseScore();
  const bonusScore = getBonusScore();
  return baseScore + bonusScore;
}

function getBaseScore() {
  // ...
}

function getBonusScore() {
  // ...
}

在上面的示例中,我们将 calculateScore 函数拆分成了 getBaseScoregetBonusScore 函数。这样做既使得代码更加易懂,也方便了单元测试。

除了将函数拆分成多个小函数之外,还可以考虑使用命名良好的辅助函数等方式来改善过长函数的问题。

3.4 过长参数列表(Long Parameter List)

当函数的参数列表过长时,会导致函数难以理解和维护。可以考虑使用对象字面量或者重新设计函数参数,使参数更加简洁明了。

// 坏代码示例
function createUser(name, age, gender, email, address) {
  // ...
}

// 改进后的代码示例
function createUser(user) {
  // ...
}

const user = {
  name: '张三',
  age: 18,
  gender: '男',
  email: 'zhangsan@example.com',
  address: '上海市'
};

createUser(user);

在上面的示例中,我们将 createUser 函数的五个参数合并成了一个对象字面量 user。这样做既减少了参数数量,也使得代码更加清晰易懂。

除了使用对象字面量之外,还可以考虑重新设计函数参数,使其更加简洁明了。比如可以将多个参数拆分成多个函数,每个函数只处理其中的一部分数据。

3.5 全局数据(Global Data)

全局变量可能会被其他部分意外修改,导致出现难以追踪的错误。可以考虑将全局变量转化为局部变量,或者封装成模块等。

// 坏代码示例
let count = 0;

function incrementCount() {
  count++;
}

// 改进后的代码示例
function createCounter() {
  let count = 0;

  function incrementCount() {
    count++;
  }

  return {
    getCount: () => count,
    incrementCount
  };
}

const counter = createCounter();
counter.incrementCount();
console.log(counter.getCount()); // 1

在上面的示例中,我们将全局变量 count 转化为了局部变量,并且用一个闭包函数 createCounter 封装了 incrementCount 函数和 getCount 函数。这样做既避免了全局变量带来的风险,也使得代码更加可维护和清晰。

除了使用闭包函数之外,还可以考虑将全局变量封装成模块、类等形式,从而避免全局变量的使用。

3.6 可变数据(Mutable Data)

可变数据往往会导致代码难以理解和维护。可以考虑使用不可变数据结构,如不可变对象或函数式编程等方式来避免可变性。

// 坏代码示例
let items = ['item1', 'item2', 'item3'];

function addItem(item) {
  items.push(item);
}

// 改进后的代码示例
const items = ['item1', 'item2', 'item3'];

function addItem(items, item) {
  return [...items, item];
}

const newItems = addItem(items, 'item4');
console.log(items); // ['item1', 'item2', 'item3']
console.log(newItems); // ['item1', 'item2', 'item3', 'item4']

在上面的示例中,我们使用了不可变数组 items 和纯函数 addItem 来代替了可变数组和修改函数,从而避免了可变性带来的问题。这样做还有一个好处就是,不可变数据更易于进行单元测试,因为它们的行为更加可预测。

除了使用不可变数据结构之外,还可以考虑使用函数式编程的思想,将操作封装成函数,并且避免副作用(比如修改全局变量等)。这样做可以提高代码的可组合性和可重用性。

3.7 发散式变化(Divergent Change)

当需求变更时,多个不相关的地方都需要修改同样的代码,这种情况称为发散式变化。可以考虑使用面向对象编程的思想,将变化封装成类,并且遵循单一职责原则等设计原则。

// 坏代码示例
function showProductImage(product) {
  // 显示产品图片的代码...
}

function logProductClicked(product) {
  // 记录用户点击产品的日志的代码...
}

function updateCart(product) {
  // 更新购物车的代码...
}

// 改进后的代码示例
class Product {
  constructor(name, image) {
    this.name = name;
    this.image = image;
  }

  showImage() {
    // 显示产品图片的代码...
  }

  logClicked() {
    // 记录用户点击产品的日志的代码...
  }
}

class Cart {
  constructor() {
    this.items = [];
  }

  addProduct(product) {
    // 添加商品到购物车的代码...
  }
}

const product = new Product('iPhone', 'iphone.png');
product.showImage();
product.logClicked();

const cart = new Cart();
cart.addProduct(product);

在上面的示例中,我们使用了面向对象编程的思想,将显示产品图片、记录用户点击产品、更新购物车等逻辑分别封装到了 ProductCart 两个类中。这样做既避免了代码的发散式变化,也使得代码更加可维护和清晰。

除了使用面向对象编程之外,还可以考虑使用函数式编程的思想,将变化封装成纯函数,并且遵循单一职责原则等设计原则。

3.8 霰弹式修改(Shotgun Surgery)

霰弹式修改(Shotgun Surgery)指的是对于一个改变需求的请求,需要在多个不同的地方进行修改,而这些修改的位置分散在代码的多个地方。这样的代码通常难以维护和测试,因为修改会牵一发而动全身,使得代码更加脆弱和容易出错。

以下是一个JavaScript代码示例:

// 定义了一个获取用户信息的函数 getUserInfo
function getUserInfo(userId) {
  // 根据 userId 获取用户信息并返回
  // ...
}

// 定义了一个更新用户信息的函数 updateUserInfo
function updateUserInfo(userId, name, email) {
  // 根据 userId 更新用户的姓名和电子邮件
  // ...
}

// 定义了一个删除用户信息的函数 deleteUserInfo
function deleteUserInfo(userId) {
  // 根据 userId 删除用户信息
  // ...
}

// 在应用程序中的某处调用了 getUserInfo 函数
const user = getUserInfo(123);

// 在另一个地方调用了 updateUserInfo 函数,更新用户信息
updateUserInfo(123, 'Alice', 'alice@example.com');

// 然后在另一个地方调用了 deleteUserInfo 函数,删除用户信息
deleteUserInfo(123);

在上面这个例子中,如果我们需要更改 userId 的数据类型或者名称,那么就必须修改 getUserInfo, updateUserInfodeleteUserInfo 这三个函数。这种情况下,我们说代码中存在“霰弹式修改”,因为修改的地方分散在多个不同的函数中,这样会带来很大的维护成本和错误风险。

为了解决这个问题,我们可以考虑将这些操作封装成一个用户管理类,这样就可以将所有的修改都集中到一个地方。例如:

class UserManager {
  constructor() {
    this.users = new Map();
  }

  getUser(userId) {
    return this.users.get(userId);
  }

  updateUser(userId, name, email) {
    const user = this.getUser(userId);
    if (user) {
      user.name = name;
      user.email = email;
    }
  }

  deleteUser(userId) {
    this.users.delete(userId);
  }
}

// 在应用程序中的某处实例化 UserManager 类
const userManager = new UserManager();

// 在应用程序中调用 UserManager 的方法来获取、更新或删除用户信息
const user = userManager.getUser(123);
userManager.updateUser(123, 'Alice', 'alice@example.com');
userManager.deleteUser(123);

如此一来,如果我们需要更改 userId 的数据类型或者名称,只需要修改 UserManager 类中的代码即可,而不用在代码的多个地方进行修改。这样可以让代码更加清晰、简洁和易于维护。

3.9 依恋情结(Feature Envy)

到某个函数为了计算某个值,从另一个对象那儿调用几乎半打 的取值函数。疗法显而易见:这个函数想跟这些数据待在一起,那就使用搬移函数(198)把它移过去。

class Order {
  constructor(items, customer) {
    this.items = items
    this.customer = customer
  }

  totalPrice() {
    let result = 0
    this.items.forEach(item => {
      /** 重构前 */
      // let basePrice = item.getPrice()
      // let discount = item.getDiscount(this.customer)
      // result += basePrice - discount
      /** 重构后 */
      result += item.getFinalPrice(this.customer)
    });
    return result
  }
}

3.10 数据泥团(Data Clumps)

数据泥团是一种代码坏味道,指的是代码中多个地方使用相同的组合参数。这种情况下,可以将这些参数封装到一个对象中来消除数据泥团。

以下是一个JavaScript示例代码,演示了数据泥团的问题:

function createOrder(customerName, customerEmail, itemName, itemPrice) {
  // ... create an order ...
}

let customerName = 'John Doe';
let customerEmail = 'john.doe@example.com';
let itemName = 'Widget';
let itemPrice = 10.0;

createOrder(customerName, customerEmail, itemName, itemPrice);

在上面的代码中,createOrder 函数接收四个参数: customerNamecustomerEmailitemNameitemPrice 。然而,这些参数在多个地方被使用,因此可能会导致数据泥团的问题。

解决方法之一是将这些参数封装成一个对象,如下所示:

class Customer {
  constructor(name, email) {
    this.name = name;
    this.email = email;
  }
}

class Item {
  constructor(name, price) {
    this.name = name;
    this.price = price;
  }
}

class Order {
  constructor(customer, items) {
    this.customer = customer;
    this.items = items;
  }
  
  getTotalPrice() {
    let totalPrice = 0;
    
    for (let i = 0; i < this.items.length; i++) {
      totalPrice += this.items[i].price;
    }
    
    return totalPrice;
  }
}

let customer = new Customer('John Doe', 'john.doe@example.com');
let items = [ new Item('Widget', 10.0) ];
let order = new Order(customer, items);

在这个重构后的代码中,CustomerItem 类被用来封装了 customerNamecustomerEmailitemNameitemPrice 参数。Order 类现在接收一个 Customer 对象和一个 Item 数组作为参数。

这种方法提供了以下好处:

  • 可以消除数据泥团,因为所有相关信息都被封装到一个对象中。
  • 可以轻松地添加或删除与 CustomerItem 相关的属性,而无需更改 createOrder 函数的签名。
  • 可以使用 CustomerItem 类来执行其他操作,例如对 CustomerItem 的验证和存储。

3.11 基本类型偏执(Primitive Obsession)

基本类型偏执是一种代码坏味道,指的是过度使用基本类型代替对象。这种情况下,可以将基本类型封装成对象,以便添加更多的行为和状态。

以下是一个JavaScript示例代码,演示了基本类型偏执的问题:

function calculateCircleArea(radius) {
  return Math.PI * radius * radius;
}

let radius = 5;
let area = calculateCircleArea(radius);

在上面的代码中,calculateCircleArea 函数接收一个数字半径,并返回圆的面积。然而, radius 这个变量是一个基本类型,它没有任何状态或行为。如果需要向方法中传递更多参数,就需要增加其他参数,或者重载该方法。

解决方法之一是创建一个 Circle 对象,如下所示:

class Circle {
  constructor(radius) {
    this.radius = radius;
  }
  
  getArea() {
    return Math.PI * this.radius * this.radius;
  }
}

let circle = new Circle(5);
let area = circle.getArea();

在这个重构后的代码中,Circle 类被用来封装圆的半径,并添加了一个 getArea 方法。现在,可以创建一个 Circle 对象,调用 getArea 方法获取其面积。

这种方法提供了以下好处:

  • 可以避免使用基本类型,从而消除基本类型偏执的问题。
  • 可以轻松地添加更多的行为和状态,例如圆的直径、周长等。
  • 可以将 Circle 对象作为参数传递给其他方法,从而避免添加更多的参数或重载方法。

3.12 重复的 switch (Repeated Switches)

重复的 switch 是指在代码中存在多个 switch 语句,而且它们的结构和逻辑非常相似,甚至有些 case 分支的处理方式也是一模一样的。这种情况下,代码中的重复性会增加维护成本,降低可读性和可维护性。

以下是一个 JavaScript 代码示例,展示了如何通过函数、对象映射等技巧来消除重复的 switch:

function processAnimal(animal) {
  const animalActions = {
    'dog': function() {
      console.log('This is a dog.');
    },
    'cat': function() {
      console.log('This is a cat.');
    },
    'bird': function() {
      console.log('This is a bird.');
    }
  };

  if (animal in animalActions) {
    animalActions[animal]();
  } else {
    console.log('Unknown animal type');
  }
}

在这个示例中,我们定义了一个表示动物类型的字符串变量 animal,并使用对象 animalActions 将每种动物类型与对应的处理函数关联起来。在 processAnimal 函数中,我们首先检查传入的动物类型是否包含在 animalActions 对象中,如果存在,则调用相应的处理函数;否则输出错误信息。

通过使用对象映射和函数调用,我们避免了在代码中出现重复的 switch 语句。如果需要添加新的动物类型,只需在 animalActions 对象中添加相应的处理函数即可,不需要修改 processAnimal 函数本身。这种做法不仅减少了代码中的重复性,还提高了代码的可维护性和扩展性。

3.13 循环语句(Loops)

循环语句是一种常见的编程结构,用于在代码中重复执行某段逻辑。虽然循环语句可以帮助我们实现复杂的算法和控制流程,但过多的循环可能会导致代码难以维护、出现性能问题等坏味道。

以下是一个 JavaScript 代码示例,展示了如何通过函数式编程技巧来替代循环语句:

const numbers = [1, 2, 3, 4, 5];

const sum = numbers.reduce((acc, val) => acc + val, 0);

console.log(sum); // 输出 15

在这个示例中,我们定义了一个包含数字的数组 numbers,并使用 reduce 函数将数组元素累加起来。reduce 函数接受两个参数:一个累加器函数和一个初始值。在每次迭代中,累加器函数会将上一次的结果(或初始值)和当前元素作为参数进行运算,并返回新的累加结果,最终返回整个数组的累加结果。

通过使用函数式编程的技巧,我们避免了在代码中出现显式的循环语句。这种做法不仅减少了代码中的重复性,还提高了代码的可读性和可维护性。当需要对数组进行其他操作时,我们只需使用其他高阶函数(例如 mapfilter 等)即可,无需编写显式的循环语句。

3.14 冗赘的元素(Lazy Element)

程序元素(如类和函数)能给代码增加结构,从而支持变化、促进复用或者哪怕只是提供更好的名字也好,但有时我们真的不需要这层额外的结构。

// before
function isNumBiggerThen5 (num) {
    return num > 5
}
consol.log(isNumBiggerThen5(num) ? 'a' : 'b')

// after
consol.log(num > 5 ? 'a' : 'b)

3.15 夸夸其谈通用性(Speculative Generality)

夸夸其谈通用性(Speculative Generality)是指在代码中添加了许多不必要的、未来可能需要的功能,但这些功能往往并没有被使用到。这会使得代码变得复杂、难以维护,并且增加了开发和测试的工作量。

以下是一个可能存在夸夸其谈通用性问题的JavaScript代码示例:

function calculateArea(shapeType, dimensions) {
  if (shapeType === 'rectangle') {
    const [length, width] = dimensions;
    return length * width;
  } else if (shapeType === 'circle') {
    const [radius] = dimensions;
    return Math.PI * radius * radius;
  } else if (shapeType === 'triangle') {
    const [base, height] = dimensions;
    return 0.5 * base * height;
  } else {
    // handle unsupported shape type
    throw new Error('Unsupported shape type');
  }
}

上面的代码实现了计算不同形状的面积和体积的功能。虽然这是一个通用的实现,但它包含了许多未来可能需要的但目前并不需要的形状类型,而且每次添加新的形状类型都需要修改函数。如果只需要计算少数几种形状的面积和体积时,这种实现会显得过于复杂。

为了解决这个问题,可以将这两个函数拆分成更小的、专注于单个形状类型的函数。例如,可以编写一个名为 calculateRectangleArea 的函数,它只计算矩形的面积。这样做可以使代码更清晰、易于维护,并且减少了未来需求变更的工作量。

3.16 临时字段(Temporary Field)

临时字段(Temporary Field)是指一个对象或类中存在一些只在特定情况下使用的字段,这些字段对于对象或类的状态来说是无关紧要的,但是它们会占用额外的内存空间,并且可能导致代码难以理解和维护。

以下是一个可能存在临时字段问题的JavaScript代码示例:

class Order {
  constructor(items) {
    this.items = items;
  }

  calculateTotalPrice() {
    let total = 0;
    for (let item of this.items) {
      total += item.price * item.quantity;
    }
    if (total > 100) {
      this.discountApplied = true; // 设置临时字段
      return total * 0.9; // 打九折优惠
    } else {
      this.discountApplied = false; // 设置临时字段
      return total;
    }
  }
}

上面的代码实现了一个 Order 类,其中每个订单都有一些商品项(items),每个商品项包含价格和数量。calculateTotalPrice 方法计算订单的总价,并且如果总价大于 100100100,则将打九折的优惠应用到总价上,并设置临时字段 discountApplied 来表示是否应用了优惠。

这种实现方式存在两个问题:

  1. discountApplied 是一个只在特定情况下使用的临时字段,它不属于 Order 对象的状态,因此会增加代码的复杂性。
  2. calculateTotalPrice 方法实现了计算总价和应用优惠两个不同的功能,这违背了单一职责原则。

为了解决这个问题,可以将 calculateTotalPrice 方法拆分成两个方法,一个负责计算总价,另一个负责应用优惠。这样做可以使代码更加清晰、易于理解和维护,并且减少了临时字段的使用。

class Order {
  constructor(items) {
    this.items = items;
  }

  calculateTotalPrice() {
    let total = 0;
    for (let item of this.items) {
      total += item.price * item.quantity;
    }
    return total;
  }

  applyDiscount(total) {
    if (total > 100) {
      return total * 0.9;
    } else {
      return total;
    }
  }
}

上面的重构后的代码中,calculateTotalPrice 方法只负责计算订单的总价,applyDiscount 方法负责判断是否应该应用优惠并返回优惠后的价格。这样做可以避免使用临时字段,而且符合单一职责原则。

3.17 过长的消息链(Message Chains)

过长的消息链(Message Chains)是指在访问一个对象的属性时,通过多个点号连接多个对象而形成的一条链。这样做会使代码变得难以理解和维护,并且增加了耦合度。

例如,在一个在线购物网站中,有一个订单类(Order),存储着顾客(Customer)的信息,如下所示:

class Order {
  constructor(customer) {
    this.customer = customer;
  }

  getCustomerEmail() {
    return this.customer.contactInfo.email;
  }
}

class Customer {
  constructor(contactInfo) {
    this.contactInfo = contactInfo;
  }
}

class ContactInfo {
  constructor(email) {
    this.email = email;
  }
}

在上面的例子中,Order 类需要获取顾客的邮箱地址,但是却需要使用 this.customer.contactInfo.email 这条消息链来获取。这条消息链不仅不直观,而且容易出错。

重构这段代码,我们可以利用“隐藏委托关系”(Hide Delegate)重构技巧,将消息链放到 Customer 类中,在 Order 类中只需要调用 customer.getEmail() 方法即可,如下所示:

class Order {
  constructor(customer) {
    this.customer = customer;
  }

  getCustomerEmail() {
    return this.customer.getEmail();
  }
}

class Customer {
  constructor(contactInfo) {
    this.contactInfo = contactInfo;
  }

  getEmail() {
    return this.contactInfo.email;
  }
}

class ContactInfo {
  constructor(email) {
    this.email = email;
  }
}

通过这样的重构,我们将消息链隐藏在了 Customer 类中,使得代码更加直观和易于维护。同时也降低了耦合度,当我们需要修改顾客信息时,只需要在 Customer 类中修改即可,不会影响到 Order 类。

3.18 中间人(Middle Man)

中间人(Middle Man)是指一个类只是转发请求给其他类,并且没有任何自己的逻辑。这样做会增加代码的复杂性和维护成本,因为我们需要在多个类之间跳转才能理解代码。

下面是一个例子,在一个在线图书销售网站中,有一个 CustomerService 类用于处理客户信息,但是该类只是将请求转发给了另外一个类 CustomerRepository,并没有自己的业务逻辑:

class CustomerService {
  constructor(customerRepository) {
    this.customerRepository = customerRepository;
  }

  getCustomerById(id) {
    return this.customerRepository.getCustomerById(id);
  }
}

class CustomerRepository {
  constructor(customers) {
    this.customers = customers;
  }

  getCustomerById(id) {
    return this.customers.find((customer) => customer.id === id);
  }
}

通过观察上面的代码,我们可以看到 CustomerService 类只是直接调用 CustomerRepository 类中的方法来获取数据,没有实现任何自己的业务逻辑。这种情况就被称为中间人(Middle Man)。

针对这样的情况,我们可以使用“移除中间人”(Remove Middle Man)重构技巧,将 CustomerService 类中的逻辑直接合并到 CustomerRepository 类中,如下所示:

class CustomerRepository {
  constructor(customers) {
    this.customers = customers;
  }

  getCustomerById(id) {
    return this.customers.find((customer) => customer.id === id);
  }
  
  getAllCustomers() {
    // 处理获取所有客户信息的逻辑
  }

  updateCustomer(customer) {
    // 处理更新客户信息的逻辑
  }

  deleteCustomer(id) {
    // 处理删除客户信息的逻辑
  }
}

通过这样的重构,我们将 CustomerService 类中的逻辑直接合并到了 CustomerRepository 类中,使得代码更加简洁和易于维护。同时也避免了多个类之间的跳转,降低了复杂性。

3.19 内幕交易(Insider Trading)

假设有两个模块A和B,它们都对一些数据感兴趣。我们可以尝试使用JavaScript来实现这个场景。

首先,我们可以新建一个模块C,专门用于管理这些共用的数据。在模块C中,我们可以定义一个对象来存储这些数据:

// 模块C
const sharedData = {
  data1: 'value1',
  data2: 'value2'
};

然后,模块A和模块B都可以引入模块C,并访问其中的共享数据:

// 模块A
import { sharedData } from './moduleC';

console.log(sharedData.data1); // 输出 "value1"


// 模块B
import { sharedData } from './moduleC';

console.log(sharedData.data2); // 输出 "value2"

另一种方法是使用委托关系,将模块B变成模块A和模块C之间的中介。具体来说,模块A可以暴露出一个委托接口,让模块B可以通过它来访问共享数据:

// 模块A
const sharedData = {
  data1: 'value1',
  data2: 'value2'
};

export const delegate = {
  getData(key) {
    return sharedData[key];
  }
};


// 模块B
import { delegate } from './moduleA';

console.log(delegate.getData('data1')); // 输出 "value1"
console.log(delegate.getData('data2')); // 输出 "value2"

这样,模块B就可以通过委托接口来访问模块A中的共享数据,避免了直接访问共享数据可能带来的潜在问题。

3.20 过大的类(Large Class)

过大的类(Large Class)是指一个类承担了太多的职责,代码量非常庞大,难以维护和理解。这种情况下,我们可以采取以下措施来改善代码设计:

  1. 分解类:将一个过大的类拆分成多个小类,每个小类专注于处理特定的职责。

  2. 使用组合关系:将一个类中的某些功能提取出来形成独立的类,并通过组合关系将它们集成到原来的类中。

  3. 提炼接口:对于过大的类,我们可以将一些通用的、可复用的方法提炼成接口,并让该类实现这些接口,从而降低类的复杂度。

下面是一个针对过大的类的示例,假设我们有一个名为Order的类,它包含了订单相关的很多操作,比如添加商品、计算价格、保存订单等。这个类的代码量非常庞大,难以维护和理解。我们可以将它分解成多个小类,每个小类专注于处理特定的职责。

// 过大的类
class Order {
  constructor() {
    this.items = [];
    // ...
  }

  addItem(item) {
    // ...
  }

  calculatePrice() {
    // ...
  }

  saveOrder() {
    // ...
  }
}

// 分解后的类
class Order {
  constructor() {
    this.items = [];
  }

  addItem(item) {
    // ...
  }
}

class PriceCalculator {
  constructor(order) {
    this.order = order;
  }

  calculatePrice() {
    // ...
  }
}

class OrderSaver {
  constructor(order) {
    this.order = order;
  }

  saveOrder() {
    // ...
  }
}

在这个示例中,我们将原来的Order类分解成了三个小类:OrderPriceCalculatorOrderSaverOrder类只负责管理订单中的商品,PriceCalculator类负责计算订单的价格,OrderSaver类负责保存订单到数据库中。通过分解类,我们将复杂的逻辑拆分成了多个小的职责,进行了更好的代码组织。

另一种方法是使用组合关系,将原来的类中某些功能提取出来形成独立的类,并通过组合关系将它们集成到原来的类中:

// 过大的类
class Order {
  constructor() {
    this.items = [];
    this.priceCalculator = new PriceCalculator(this);
    this.orderSaver = new OrderSaver(this);
  }

  addItem(item) {
    // ...
  }

  calculatePrice() {
    return this.priceCalculator.calculatePrice();
  }

  saveOrder() {
    this.orderSaver.saveOrder();
  }
}

class PriceCalculator {
  constructor(order) {
    this.order = order;
  }

  calculatePrice() {
    // ...
  }
}

class OrderSaver {
  constructor(order) {
    this.order = order;
  }

  saveOrder() {
    // ...
  }
}

在这个示例中,我们将PriceCalculatorOrderSaver提取出来形成了独立的类,并通过组合关系将它们集成到原来的Order类中。这种方法比较适用于某些功能特别复杂、可重用性较高的场景。

最后,我们可以使用接口提炼,将一些通用的、可复用的方法提炼成接口,并让类实现这些接口,从而降低类的复杂度。

3.21 异曲同工的类(Alternative Classes with Different Interfaces)

异曲同工的类是指具有相似功能和实现细节但接口不同的两个或多个类。这通常会导致代码重复和不必要的冗余。

下面是一个JavaScript示例,其中有两个类SquareRectangle,它们都有计算面积和周长的方法。虽然它们实现了相同的功能,但是由于它们的接口不同,调用它们的代码需要针对每个类编写不同的代码:

class Square {
  constructor(length) {
    this.length = length;
  }

  area() {
    return Math.pow(this.length, 2);
  }

  perimeter() {
    return 4 * this.length;
  }
}

class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  area() {
    return this.width * this.height;
  }

  perimeter() {
    return 2 * (this.width + this.height);
  }
}

// 调用Square类的代码
const square = new Square(5);
const squareArea = square.area();
const squarePerimeter = square.perimeter();

// 调用Rectangle类的代码
const rectangle = new Rectangle(5, 7);
const rectangleArea = rectangle.area();
const rectanglePerimeter = rectangle.perimeter();

为了消除这种重复,可以使用接口统一两个类的方法名称和参数列表。例如,在此示例中,我们可以创建一个名为Shape的接口,并将SquareRectangle类分别实现该接口。此后,我们只需要编写一次调用代码即可使用这两个类。

以下是重构后的示例:

// 定义通用接口Shape
class Shape {
  area() {}
  perimeter() {}
}

// Square类实现Shape接口
class Square extends Shape {
  constructor(length) {
    super();
    this.length = length;
  }

  area() {
    return Math.pow(this.length, 2);
  }

  perimeter() {
    return 4 * this.length;
  }
}

// Rectangle类实现Shape接口
class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  area() {
    return this.width * this.height;
  }

  perimeter() {
    return 2 * (this.width + this.height);
  }
}

// 调用代码不再需要针对每个类分别编写
const shapes = [new Square(5), new Rectangle(5, 7)];
shapes.forEach((shape) => {
  console.log(shape.area());
  console.log(shape.perimeter());
});

通过这种方式,消除了代码重复和冗余,并使得代码更易于维护和扩展。

3.22 纯数据类(Data Class)

这样的类只是一种不会说话的数据容器,它们几乎一 定被其他类过分细琐地操控着。 纯数据类常常意味着行为被放在了错误的地方。也就是说,只要把处理数据 的行为从客户端搬移到纯数据类里来,就能使情况大为改观。

纯数据类是指只包含数据而不包含任何业务逻辑的类。在这种情况下,如果我们发现该类中存在大量的处理数据的行为,那么很可能是因为这些行为被放置在了错误的位置上。

以下是一个纯数据类的 JavaScript 代码示例:

class User {
  constructor(name, age, gender) {
    this.name = name;
    this.age = age;
    this.gender = gender;
  }

  getName() {
    return this.name;
  }

  getAge() {
    return this.age;
  }

  getGender() {
    return this.gender;
  }

  // 处理数据的行为被放在了客户端
  toObject() {
    return {
      name: this.name,
      age: this.age,
      gender: this.gender
    };
  }
}

在上述代码中,User 类是一个纯数据类,它只包含用户的姓名、年龄和性别等基本信息。但是,toObject() 方法却将处理数据的行为放置在了客户端中,这可能会导致类的职责不明确,使得代码难以维护。

为了改善这个问题,我们应该将处理数据的行为放到 User 类内部,例如修改代码如下:

class User {
  constructor(name, age, gender) {
    this.name = name;
    this.age = age;
    this.gender = gender;
  }

  getName() {
    return this.name;
  }

  getAge() {
    return this.age;
  }

  getGender() {
    return this.gender;
  }

  // 处理数据的行为被放在了纯数据类中
  toObject() {
    return {
      name: this.getName(),
      age: this.getAge(),
      gender: this.getGender()
    };
  }
}

通过将 toObject() 方法移到 User 类内部,我们可以使得数据处理行为与 User 类的基本信息聚合在一起,从而提高代码的可读性和可维护性。

代码烂味2:过于依赖外部类的属性

如果一个纯数据类依赖于其他类的属性,那么这个类就不再是一个真正的纯数据类。在这种情况下,我们可以考虑将这些依赖性转移到其他类中,或者是将这些依赖性通过构造函数注入。

例如,以下是一个依赖于其他类的属性的数据类:

class Order {
  constructor(orderId, customerId) {
    this.orderId = orderId;
    this.customer = new Customer(customerId);
  }

  getOrderDetails() {
    const customerName = this.customer.getName();
    const orderDate = this.getOrderDate();
    return `Order details:\nOrder ID: ${this.orderId}\nCustomer name: ${customerName}\nOrder date: ${orderDate}`;
  }

  getOrderDate() {
    // get order date from API
    return '2023-06-17';
  }
}

class Customer {
  constructor(customerId) {
    this.customerId = customerId;
  }

  getName() {
    // get name from API
    return 'John';
  }
}

可以通过将依赖性注入构造函数中改写:

class Order {
  constructor(orderId, customer) {
    this.orderId = orderId;
    this.customer = customer;
  }

  getOrderDetails() {
    const customerName = this.customer.getName();
    const orderDate = this.getOrderDate();
    return `Order details:\nOrder ID: ${this.orderId}\nCustomer name: ${customerName}\nOrder date: ${orderDate}`;
  }

  getOrderDate() {
    // get order date from API
    return '2023-06-17';
  }
}

class Customer {
  constructor(customerId) {
    this.customerId = customerId;
  }

  getName() {
    // get name from API
    return 'John';
  }
}

// usage
const customer = new Customer(1);
const order = new Order(1001, customer);
console.log(order.getOrderDetails());

这样,我们就把纯数据类和业务逻辑分离开来,使得代码更易维护和可扩展。另外,这种方式还使得代码更加灵活,我们可以通过注入不同的依赖来实现不同的业务需求。

代码烂味3:过于暴露内部状态

如果一个纯数据类暴露它的内部状态给客户端,那么它会破坏封装性,并且很难保证这些状态的一致性。在这种情况下,我们可以考虑使用访问控制修饰符(如private、protected等)来限制对内部状态的访问。

3.23 被拒绝的遗赠(Refused Bequest)

被拒绝的遗赠(Refused Bequest)是坏味道之一,通常指的是子类从父类继承了一些属性或方法,但并不需要或者不想要这些属性或方法,因此在子类中对它们进行了重写或删除操作。这可能会导致代码冗余、不必要的复杂性和难以维护。

以下是一个使用JavaScript语言的示例代码:

class Animal {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  eat() {
    console.log('The animal is eating.');
  }
}

class Cat extends Animal {
  constructor(name, age, color) {
    super(name, age);
    this.color = color;
  }

  // 子类不需要继承的方法
  sleep() {
    console.log('The cat is sleeping.');
  }

  // 子类重写父类的方法
  eat() {
    console.log('The cat is eating fish.');
  }
}

const myCat = new Cat('Tom', 2, 'gray');
myCat.sleep(); // 调用子类新增的方法
myCat.eat(); // 调用子类重写的方法

在上面的代码中,Animal类有一个eat方法,而Cat类继承了Animal类,并在其中重写了eat方法和新增了一个sleep方法。然而,在实际应用中,可能并不需要Animal类的eat方法,或者Cat类只需要部分继承Animal类的属性和方法。因此,为了避免被拒绝的遗赠这种坏味道,我们可以使用组合和接口隔离原则等技术来改善代码设计。

3.24 注释(Comments)

当你感觉需要撰写注释时,请先尝试重构,试着让所有注释都变得多余。 如果你不知道该做什么,这才是注释的良好运用时机。除了用来记述将来的打算之外,注释还可以用来标记你并无十足把握的区域。你可以在注释里写下自己“为什么做某某事”。