GGbond : 通过实例速通JavaScript设计原则与模式

186 阅读14分钟

什么是设计模式?

💡 软件开发中被广泛使用的最佳实践和经验总结。

曾经有一位小编程序员,他正在为一个电商网站开发一个购物车模块。开始时,他的代码看起来非常简单和直观,但随着项目的不断发展,代码变得越来越复杂、难以维护,并且出现了很多重复的代码。

于是,这位小编开始学习 JavaScript 设计模式,并尝试将其应用于购物车模块中。他使用了工厂模式来创建不同类型的商品对象,使用单例模式确保购物车只被实例化一次,使用观察者模式来监听各个组件之间的变化并更新购物车列表等。

通过设计模式,这位小编成功地优化了购物车模块的代码,并使其更加易于维护和扩展。他的代码不再出现大量的重复代码和难以理解的逻辑,同时还变得更加灵活和易于修改。最终,这个购物车模块成功地被集成到电商网站中,让用户可以更加愉快地购物。

所以说,JavaScript 设计模式是一种可重用的解决方案,可以帮助开发者提高代码的可维护性、可读性和可扩展性,减少重复代码的出现,提高代码的复用率。它们是在开发中被广泛使用的最佳实践和经验总结。

那么现在我们知道什么是模式了,那么模式还有一个更重要的概念,也就是 模式的适用性。

模式的适用性

去看篮球比赛的话,我们知道最重要的目的就是进球。所有可以帮助我们进球的方案,都是好的方案。但是如果我们把一些 正确的战术用在了错误的场景下,比如教练可以对奥尼尔使用犯规战术 , 但是如果对方是罚球很准的后卫 , 那么这种战术就是白送二分给对手。

💡 任何设计模式都不是万能的,每个模式都有其独特的优缺点和适用范围。注意选择适合当前场景的模式,而不是简单地套用某个模式。

基础知识

学习设计模式前先了解一下基础知识

  • js的面向对象编程,如原型、继承、构造函数、this 等
  • js的函数式编程,如高阶函数、闭包、作用域等
💡 这里涵盖的知识面太多 , 我准备单独开文章讲解 ,这篇文章还是主要讲解设计模式

设计模式

设计模式有几十种, 而且并不是完全适用于前端的,所以我先讲解以下 3 种常用设计模式:

  1. 单例模式
  2. 代理模式 ( vue3 )
  3. 观察者模式 ( 如useEffect ,vue2 )
  • 单例模式是一种保证一个类只有一个实例的方法,通常用于创建全局对象或管理共享资源。例如:
// 创建一个单例对象
var Singleton = (function () {
  // 保存唯一的实例
  var instance;

  // 创建一个新的实例
  function createInstance() {
    var object = new Object("I am the instance");
    return object;
  }

  // 返回一个函数,用于获取或创建实例
  return {
    getInstance: function () {
      if (!instance) {
        instance = createInstance();
      }
      return instance;
    }
  };
})();

// 测试单例模式
var instance1 = Singleton.getInstance();
var instance2 = Singleton.getInstance();
console.log(instance1 === instance2); // true
  • 代理模式是一种在访问一个对象之前,通过另一个对象(代理)来控制或修改这个访问的方法。我们可以用es6的Proxy类来实现代理模式,例如:
// 创建一个目标对象
var target = {
  name: "Alice",
  age: 25
};

// 创建一个代理对象,用于拦截目标对象的访问
var proxy = new Proxy(target, {
  // 拦截获取属性的操作
  get: function (obj, prop) {
    // 如果属性是name,就返回大写形式
    if (prop === "name") {
      return obj[prop].toUpperCase();
    }
    // 否则,就返回原始值
    return obj[prop];
  },
  // 拦截设置属性的操作
  set: function (obj, prop, value) {
    // 如果属性是age,就检查是否是合法的数字
    if (prop === "age") {
      if (typeof value !== "number" || value < 0 || value > 150) {
        throw new TypeError("Invalid age");
      }
    }
    // 否则,就设置属性值
    obj[prop] = value;
  }
});

// 测试代理模式
console.log(proxy.name); // ALICE
console.log(proxy.age); // 25
proxy.age = 30; // ok
proxy.age = "hello"; // error: Invalid age
  • 观察者模式用于在对象间定义一种一对多的依赖关系,当一个对象状态发生改变时,它的所有依赖者都会收到通知并自动更新。
// 定义一个主题对象
const subject = {
  observers: [],// 存放所有观察者
  subscribe: function(observer) {
    this.observers.push(observer);// 添加一个新的观察者
  },
  unsubscribe: function(observerToRemove) {
    this.observers = this.observers.filter(observer => {
      return observer !== observerToRemove;// 删除指定的观察者
    })
  },
  notify: function() {
    this.observers.forEach(observer => {
      observer();// 调用每个观察者的函数
    })
  }
}

// 使用示例const observer1 = function() {
  console.log('观察者 1 收到了更新通知。');
}

const observer2 = function() {
  console.log('观察者 2 收到了更新通知。');
}

const observer3 = function() {
  console.log('观察者 3 收到了更新通知。');
}

subject.subscribe(observer1);
subject.subscribe(observer2);
subject.subscribe(observer3);

subject.notify();  // 123 收到通知

subject.unsubscribe(observer2);

subject.notify(); // 13 收到通知

在这个示例中,我们定义了一个 subject 主题对象,它具有添加、删除和通知观察者的方法。我们使用三个函数作为观察者,当调用 notify 方法时,这些观察者函数将被调用以接收更新通知。在使用示例中,我们订阅了三个观察者函数,并通知了所有观察者。然后我们取消了一个观察者的订阅,并再次通知剩余的观察者。

设计原则

💡 所有的设计模式都是从设计原则中衍生出来的。

设计模式数量众多,一篇文章难以涵盖。最初并不存在设计模式。但是在写了很多代码之后,设计师们发现了一些重复出现的场景,将它们抽象成了设计模式。因此,有许多不同的设计模式,而且它们都必须遵守某些规则,这些规则被称为设计原则。

模式只有在具体的环境下,才有意义,请不要滥用它并且过度设计!

其中对我们现在有意义的,主要是 3个

  1. 单一职责原则
  2. 最少知识原则
  3. 开放封闭原则

单一职责原则

指的是一个类(组件,函数等)只应该负责单一的功能或职责

const calculatePrice = (quantity, itemPrice) => {
  const basePrice = quantity * itemPrice;
  return applyDiscount(basePrice);
};

const calculateTax = (quantity, itemPrice, shipping) => {
  const basePrice = quantity * itemPrice;
  const taxableAmount = basePrice + shipping;
  return applyDiscount(taxableAmount);
};

const applyDiscount = (basePrice) => {
  if (basePrice > 1000) {
    return basePrice * 0.95;
  } else {
    return basePrice * 0.98;
  }
};

我们将每个函数的功能拆分成更小的函数,实现单一职责原则。

最少知识原则

一个对象应当尽可能少地与其他对象发生相互作用。在面向对象的设计语言中,存在高内聚、低耦合的概念,这个概念指的其实就是最少知识原则。最少知识原则可以很好地通过中介者模式呈现出来。

以下是一个简单的 JavaScript 函数示例,它演示了如何使用最少知识原则思想的中介者模式

// 中介者对象
const mediator = {
  // 计算两个数字之和并返回结果
  calculateSum: function (num1, num2) {
    return num1 + num2;
  }
};

// 使用中介者对象来计算两个数字之和
function sumCalculator(num1, num2, mediator) {
  return mediator.calculateSum(num1, num2);
}

// 示例用法
const result = sumCalculator(5, 10, mediator);
console.log(result); // 输出:15

在这个例子中,sumCalculator 函数使用了最少知识原则,因为它仅仅与它需要知道的中介者对象进行交互。函数本身并不需要知道关于数字的任何信息,比如数字如何被加起来,或者数字的来源等等。

开放封闭原则

开放封闭原则: (类、组件、函数)等应该对扩展开放,对修改封闭。

这个描述其实是挺好理解的,因为原有的代码一旦发生修改,那么就必然代表着风险,所以我们尽量不要修改原有代码。这意味着我们应该在设计软件时,预留好扩展点,让软件可以在不修改原有代码的情况下,通过添加新的代码来实现新的功能。这样可以提高软件的可维护性、可复用性和可测试性 , 也就是所谓的 ” 打补丁

下面是一个示例代码:

function calculateArea(shape) {
  switch (shape.type) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rectangle':
      return shape.width * shape.height;
    default:
      throw new Error('Unsupported shape type');
  }
}

const circle = { type: 'circle', radius: 5 };
const rectangle = { type: 'rectangle', width: 10, height: 20 };

console.log(calculateArea(circle)); // Output: 78.53981633974483
console.log(calculateArea(rectangle)); // Output: 20

上述代码中的 calculateArea 函数并没有直接针对某一种特定形状进行计算,而是通过参数的形式传入不同的形状对象。这样,当需要增加一种新的形状时,只需要新增一个对应的形状对象,而不需要修改原有的 calculateArea 函数。这就体现了开放封闭原则。

这样当需要新增行为时,就不再需要修改原有代码,也就是更加符合 开放封闭原则

如何写出适合重构的代码 (也叫重构原则)

重构原则是指在不改变代码功能的前提下,对代码进行优化和改进,以提高代码的可读性、可维护性和可扩展性。

  1. 提炼函数:将一个大型函数拆分成若干个小函数,使得函数实现更加简单,同时也更加具有可读性和可维护性。
  2. 合并重复的条件片段:消除重复代码,提高代码的复用性。
  3. 把条件分支语句提炼成函数:将复杂的条件语句提取出来,封装成函数,使得代码更加简洁易懂。
  4. 合理使用循环:在循环中进行操作时,尽量保证每次循环操作的内容相同,减少循环体内部的复杂度,提高代码的可读性和可维护性。
  5. 提前让函数退出代替嵌套条件分支:尽量避免嵌套条件分支,提高代码的可读性和可维护性。
  6. 传递对象参数代替过长的参数列表:将多个参数封装成对象,可以让代码更加简洁易懂,提高代码的可读性和可维护性。
  7. 尽量减少参数数量:过多的参数会增加代码的复杂度,降低代码的可读性和可维护性。

下面我们来看 7 个提升代码可维护性的具体场景与代码:

1.提炼函数

函数提炼可以让我们更加遵循 单一职责原则,每个函数只在做一件事情就可以。下面我们来看一个例子:

在下面的代码中,我们可以看到函数 calculatePrice 中存在大量的代码,且代码量过于庞大,如果这样的代码维护起来,就会变得非常困难:

function calculatePrice (quantity, itemPrice) {
  // 价格计算逻辑
  let basePrice = quantity * itemPrice
  if (basePrice > 1000) {
    return basePrice * 0.95
  } else {
    return basePrice * 0.98
  }
}

那么我们可以通过函数提炼的方式来优化上面的代码:

function calculatePrice (quantity, itemPrice) {
  // 价格计算逻辑
  const basePrice = calculateBasePrice(quantity, itemPrice)
  return applyDiscount(basePrice)
}
function calculateBasePrice (quantity, itemPrice) {
  return quantity * itemPrice
}
function applyDiscount (basePrice) {
  if (basePrice > 1000) {
    return basePrice * 0.95
  } else {
    return basePrice * 0.98
  }
}

通过函数提炼,我们将 calculatePrice 函数中的代码分别拆分到了 calculateBasePriceapplyDiscount 函数中,每个函数只做一件事情,代码更加清晰,易于维护。

2.合并重复的代码片段

如果你的代码中存在重复的代码片段,那么应该把这些重复的代码片段进行合并处理。

合并重复的代码片段可以通过将重复的代码片段抽离成一个函数,然后在需要的地方调用该函数的方式来进行优化。下面是一个 JavaScript 的例子。

合并前:

function calculatePrice(quantity, itemPrice) {
  // 价格计算逻辑
  let basePrice = quantity * itemPrice;
  if (basePrice > 1000) {
    return basePrice * 0.95;
  } else {
    return basePrice * 0.98;
  }
}

function calculateTax(quantity, itemPrice, shipping) {
  // 税费计算逻辑
  let basePrice = quantity * itemPrice;
  let taxableAmount = basePrice + shipping;
  if (taxableAmount > 1000) {
    return taxableAmount * 0.95;
  } else {
    return taxableAmount * 0.98;
  }
}

合并后:

function calculatePrice(quantity, itemPrice) {
  return applyDiscount(quantity * itemPrice);
}

function calculateTax(quantity, itemPrice, shipping) {
  return applyDiscount(quantity * itemPrice + shipping);
}

function applyDiscount(basePrice) {
  if (basePrice > 1000) {
    return basePrice * 0.95;
  } else {
    return basePrice * 0.98;
  }
}

在上面的例子中,我们发现 calculatePricecalculateTax 中都存在类似的计算逻辑,我们可以把这部分代码抽离成一个独立的函数 applyDiscount,然后在需要的地方进行调用。这样可以避免代码冗余,提高代码的可读性和可维护性。

3.把条件分支语句提炼成函数

如果你的一段逻辑中,包含难以理解的条件分支语句,那么可以把这个条件分支语句封装成一个单独的函数进行处理:

处理前:

function calculatePrice(quantity, itemPrice) {
  let basePrice = quantity * itemPrice;
  if (basePrice > 1000 && quantity > 500) {
    return basePrice * 0.95;
  } else {
    return basePrice * 0.98;
  }
}

处理后:

function calculatePrice(quantity, itemPrice) {
  let basePrice = quantity * itemPrice;
  if (isQualifiedForDiscount(basePrice, quantity)) {
    return basePrice * 0.95;
  } else {
    return basePrice * 0.98;
  }
}

function isQualifiedForDiscount(basePrice, quantity) {
  return basePrice > 1000 && quantity > 500;
}

4.合理使用循环

在函数体内,如果有些代码实际上负责的是一些重复性的工作,那么合理利用循环不仅可以完成同样的功能,还可以使代码量更少。

在超市购物时,我们需要计算所有商品的价格总和。如果我们不使用循环,代码可能如下所示:

let item1 = 10;
let item2 = 20;
let item3 = 30;
let total = item1 + item2 + item3;

但是,如果我们有许多商品,这将变得非常麻烦。相反,我们可以使用循环来计算总价:

let items = [10, 20, 30];
let total = 0;
for (let i = 0; i < items.length; i++) {
  total += items[i];
}

这个循环将遍历我们的商品数组,并将每个项目的价格添加到 total 变量中。这种方法不仅更简洁,而且可以轻松处理许多项目。

5. 提前让函数退出代替嵌套条件分支

如果一个函数中存在多个条件分支,那么可以通过尽早让函数退出来减少嵌套的条件分支。这样可以简化代码逻辑,提高代码的可读性和可维护性。

下面是一个简单的例子:

function calculatePrice(quantity, itemPrice) {
  if (quantity > 100) {
    return itemPrice * quantity * 0.95;
  } else {
    if (itemPrice > 50) {
      return itemPrice * quantity * 0.9;
    } else {
      return itemPrice * quantity;
    }
  }
}

在上面的代码中,我们可以看到有两个条件分支语句嵌套在一起,这会让代码变得难以理解和维护。我们可以通过提前让函数退出来改善代码:

function calculatePrice(quantity, itemPrice) {
  if (quantity > 100) {
    return itemPrice * quantity * 0.95;
  }
  if (itemPrice > 50) {
    return itemPrice * quantity * 0.9;
  }
  return itemPrice * quantity;
}

在上面的代码中,我们通过让函数尽早退出来简化了代码逻辑。这样代码更易于理解和维护。

6. 传递对象参数代替过长的参数列表

如果一个函数需要传递多个参数,那么我们可以考虑将这些参数封装成一个对象,然后将对象作为参数传递。这样可以减少传递参数的数量,让代码更加简洁。下面是一个简单的例子:

function printOrder(name, age, address, phone, email) {
  console.log(name, age, address, phone, email);
}

printOrder('张三', 18, '北京市朝阳区', '13888888888', 'zhangsan@example.com');

在上面的代码中,我们需要传递五个参数来打印订单信息。如果我们使用对象来代替这些参数,代码会更加简洁,如下所示:

function printOrder(order) {
  console.log(order.name, order.age, order.address, order.phone, order.email);
}

const order = {
  name: '张三',
  age: 18,
  address: '北京市朝阳区',
  phone: '13888888888',
  email: 'zhangsan@example.com',
};

printOrder(order);

在上面的代码中,我们使用一个对象来封装订单信息,然后将对象作为参数传递给 printOrder 函数。这样可以使代码更加简洁,易于维护。

7. 尽量减少参数数量

函数参数过多会增加代码的复杂性,降低代码的可读性和可维护性。如果一个参数可以在函数内部计算得到,那么就不要传递这个参数。下面是一个简单的例子:

function calculatePrice(quantity, itemPrice, discount) {
  // 价格计算逻辑
  let basePrice = quantity * itemPrice
  if (basePrice > 1000) {
    return basePrice * 0.95 * discount
  } else {
    return basePrice * 0.98 * discount
  }
}

在上面的代码中,我们需要传递三个参数来计算价格。如果我们可以在函数内部计算出折扣,那么我们可以将 discount 参数去掉,如下所示:

function calculatePrice(quantity, itemPrice) {
  // 价格计算逻辑
  let basePrice = quantity * itemPrice
  let discount = 1
  if (basePrice > 1000) {
    discount = 0.95
  } else {
    discount = 0.98
  }
  return basePrice * discount
}

在上面的代码中,我们可以看到,我们可以在函数内部计算出折扣,从而将 discount 参数去掉。这样可以使代码更加简洁,易于维护。

周末有空的话再更其他设计模式 , 下班咯 冲鸭

jntm.webp