建造者设计模式轻松解决 JavaScript 中的 4 个危险问题
在Medium上找到我
当你用 JavaScript 开发应用程序时,你有时会发现构造复杂的对象很困难。一旦代码达到这个程度,它就变得更加重要,因为随着应用程序的规模越来越大,它也会变得越来越复杂。
“复杂”可以有多种表现形式。一种可能是,当你尝试创建某些对象的不同变体时,代码会变得重复。另一种可能是,尝试创建这些对象的变体可能会变得非常冗长,因为你必须在某个地方的一个巨大的代码块中执行逻辑,例如在类的构造函数代码块中。
本文将讨论这些问题,并展示 JavaScript 中的 Builder 设计模式如何使这些问题变得不再那么严重。
那么Builder模式可以轻松解决哪些问题呢?
让我们首先看一个没有构建器模式的示例,然后再看一个带有构建器模式的示例,这样在我们进行的过程中,我不是唯一一个想到可视化代码示例的人:
在下面的代码示例中,我们将定义一个Frog
类。我们假设,为了让这个Frog
类能够完全在野外生存和冒险,它们需要两只眼睛、四条腿、一种气味、一条舌头和一颗心脏。显然,在现实世界中,情况要复杂得多,而且需要气味才能生存听起来很荒谬,但我们只会尽量保持简单有趣,而不是事事都完全符合事实。我们可以在另一篇文章中再次验证我们100%正确的事实 :)
没有建造者模式
class Frog {
constructor(name, gender, eyes, legs, scent, tongue, heart, weight, height) {
this.name = name
this.gender = gender
this.eyes = eyes
this.legs = legs
this.scent = scent
this.tongue = tongue
this.heart = heart
if (weight) {
this.weight = weight
}
if (height) {
this.height = height
}
}
}
使用建造者模式
class FrogBuilder {
constructor(name, gender) {
this.name = name
this.gender = gender
}
setEyes(eyes) {
this.eyes = eyes
return this
}
setLegs(legs) {
this.legs = legs
return this
}
setScent(scent) {
this.scent = scent
return this
}
setTongue(tongue) {
this.tongue = tongue
return this
}
setHeart(heart) {
this.heart = heart
return this
}
setWeight(weight) {
this.weight = weight
return this
}
setHeight(height) {
this.height = height
return this
}
}
现在看来,这似乎有点矫枉过正,因为建造者模式的示例代码量更大。但如果你深入研究在开发一个潜在的青蛙应用程序过程中可能出现的所有情况,你会发现,通过观察这两个示例,应用建造者模式的代码示例在提升简洁性、可维护性方面会逐渐提升,并为实现健壮功能提供更多机会。
以下是 Builder 设计模式可以轻松解决 JavaScript 中的 4 个问题:
1. 代码混乱
在开发大量功能块时,如果疏忽大意,很容易导致错误和事故。此外,如果单个功能块中包含的内容过多,也很容易造成混乱。
那么,当函数块(如构造函数)中“发生太多事情”时,您会遇到什么样的情况?
回到我们第一个没有使用构建器模式实现的代码示例,假设我们必须添加一些额外的逻辑以便在将传入的参数应用到实例之前接受它们:
class Frog {
constructor(name, gender, eyes, legs, scent, tongue, heart, weight, height) {
if (!Array.isArray(legs)) {
throw new Error('Parameter "legs" is not an array')
}
// Ensure that the first character is always capitalized
this.name = name.charAt(0).toUpperCase() + name.slice(1)
this.gender = gender
// We are allowing the caller to pass in an array where the first index is the left eye and the 2nd is the right
// This is for convenience to make it easier for them.
// Or they can just pass in the eyes using the correct format if they want to
// We must transform it into the object format if they chose the array approach
// because some internal API uses this format
this.eyes = Array.isArray(eyes) ? { left: eye[0], right: eye[1] } : eyes
this.legs = legs
this.scent = scent
// Pretending some internal API changed the field name of the frog's tongue from "tongueWidth" to "width"
// Check for old implementation and migrate them to the new field name
const isOld = 'tongueWidth' in tongue
if (isOld) {
const newTongue = { ...tongue }
delete newTongue['tongueWidth']
newTongue.width = tongue.width
this.tongue = newTongue
} else {
this.tongue = newTongue
}
this.heart = heart
if (typeof weight !== 'undefined') {
this.weight = weight
}
if (typeof height !== 'undefined') {
this.height = height
}
}
}
const larry = new Frog(
'larry',
'male',
[{ volume: 1.1 }, { volume: 1.12 }],
[{ size: 'small' }, { size: 'small' }, { size: 'small' }, { size: 'small' }],
'sweaty socks',
{ tongueWidth: 18, color: 'dark red', type: 'round' },
{ rate: 22 },
6,
3.5,
)
我们的构造函数有点长,有些情况下甚至很多逻辑看起来都没有必要。它被处理不同参数的逻辑弄得乱七八糟。这可能会让人感到困惑,尤其是如果我们很久没看过它的源代码的话。
当我们开发一个青蛙应用程序并想要实例化一个 的实例时Frog
,缺点是我们必须确保每个参数都几乎 100% 完美地遵循函数签名,否则在构造阶段就会抛出错误。如果我们需要eyes
在某个时刻再次检查 的类型,就必须扫描杂乱的代码才能找到我们要找的代码。如果你终于找到了要找的那几行代码,却发现就在前 50 行,还有另一行代码引用并影响着同一个参数,你会不会感到困惑?现在你必须回过头去仔细检查那些代码,才能明白接下来会发生什么。
如果我们重新审视FrogBuilder
之前示例中的构造函数,我们可以简化它,使其感觉更“自然”,同时消除混淆。我们仍然会进行额外的验证,只是它们会被隔离到各自的小方法中,而这正是建造者模式的核心所在。
2.可读性
如果我们看一下最近的代码示例,就会发现它已经有点难以阅读了,因为我们必须一次性处理这些不同的处理方式。如果我们想创建一个实例,除了一次性理解所有Frog
代码之外,别无他法。
另外,我们必须提供一些文档,否则我们真的搞不清楚为什么要tongueWidth
改名为width
。这太荒唐了!
如果我们将示例转换为使用构建器模式,我们可以使事情更易于阅读:
class FrogBuilder {
constructor(name, gender) {
// Ensure that the first character is always capitalized
this.name = name.charAt(0).toUpperCase() + name.slice(1)
this.gender = gender
}
formatEyesCorrectly(eyes) {
return Array.isArray(eyes) ? { left: eye[0], right: eye[1] } : eyes
}
setEyes(eyes) {
this.eyes = this.formatEyes(eyes)
return this
}
setLegs(legs) {
if (!Array.isArray(legs)) {
throw new Error('"legs" is not an array')
}
this.legs = legs
return this
}
setScent(scent) {
this.scent = scent
return this
}
updateTongueWidthFieldName(tongue) {
const newTongue = { ...tongue }
delete newTongue['tongueWidth']
newTongue.width = tongue.width
return newTongue
}
setTongue(tongue) {
const isOld = 'tongueWidth' in tongue
this.tongue = isOld
? this.updateTongueWidthFieldName(tongue, tongue.tongueWidth)
: tongue
return this
}
setHeart(heart) {
this.heart = heart
return this
}
setWeight(weight) {
if (typeof weight !== 'undefined') {
this.weight = weight
}
return this
}
setHeight(height) {
if (typeof height !== 'undefined') {
this.height = height
}
return this
}
build() {
return new Frog(
this.name,
this.gender,
this.eyes,
this.legs,
this.scent,
this.tongue,
this.heart,
this.weight,
this.height,
)
}
}
const larry = new FrogBuilder('larry', 'male')
.setEyes([{ volume: 1.1 }, { volume: 1.12 }])
.setScent('sweaty socks')
.setHeart({ rate: 22 })
.setWeight(6)
.setHeight(3.5)
.setLegs([
{ size: 'small' },
{ size: 'small' },
{ size: 'small' },
{ size: 'small' },
])
.setTongue({ tongueWidth: 18, color: 'dark red', type: 'round' })
.build()
我们可以通过以下几种方式提高代码的可读性:
- 方法的名称足够自文档化
updateTongueWidthFieldName
它很容易地向我们解释它的作用以及它这样做的原因。我们知道它正在更新字段名称。我们也知道原因,因为“更新”这个词本身就意味着更新!这段自文档化的代码帮助我们假设某些字段名称已经过时,需要更改为使用新的字段名称。
- 构造函数简短而简化。
- 稍后再设置其他属性就完全没问题了!
- 启动新程序时可以清楚地了解每个参数
Frog
- 就像读英文一样。你清楚地设置了眼睛、腿等,最后调用了build方法来创建一个
Frog
。
- 现在,每个逻辑都被隔离在单独的块中,我们可以轻松地跟进
- 当你进行某些更改时,你只需要关注一件事,即功能块中隔离的内容。
3.缺乏控制
此列表中最重要的一点是受益于对实现的更多控制。在构建器示例之前,可以在构造函数中编写更多代码,但代码越多,可读性就越差,从而导致混乱和困惑。
由于我们能够将实现细节隔离到每个功能块中,因此我们现在可以在很多方面进行更精细的控制。
一种方法是,我们可以添加验证,甚至无需添加更多问题,这使得构建阶段更加稳健:
setHeart(heart) {
if (typeof heart !== 'object') {
throw new Error('heart is not an object')
}
if (!('rate' in heart)) {
throw new Error('rate in heart is undefined')
}
// Assume the caller wants to pass in a callback to receive the current frog's weight and height that he or she has set
// previously so they can calculate the heart object on the fly. Useful for loops of collections
if (typeof heart === 'function') {
this.heart = heart({
weight: this.weight,
height: this.height
})
} else {
this.heart = heart
}
return this
}
validate() {
const requiredFields = ['name', 'gender', 'eyes', 'legs', 'scent', 'tongue', 'heart']
for (let index = 0; index < requiredFields.length; index++) {
const field = requiredFields[index]
// Immediately return false since we are missing a parameter
if (!(field in this)) {
return false
}
}
return true
}
build() {
const isValid = this.validate(this)
if (isValid) {
return new Frog(
this.name,
this.gender,
this.eyes,
this.legs,
this.scent,
this.tongue,
this.heart,
this.weight,
this.height,
)
} else {
// just going to log to console
console.error('Parameters are invalid')
}
}
我们利用构造函数的每个部分都是隔离的这一事实,通过添加验证以及一种validate
方法来确保在最终构建之前已设置所有必需的字段Frog
。
我们还可以利用这些开放的机会添加进一步的自定义输入数据类型来构建参数的原始返回值。
例如,我们可以添加更多调用者可以传入的自定义方式eyes
,为他们提供比我们以前提供的更多的便利:
formatEyesCorrectly(eyes) {
// Assume the caller wants to pass in an array where the first index is the left
// eye, and the 2nd is the right
if (Array.isArray(eyes)) {
return {
left: eye[0],
right: eye[1]
}
}
// Assume that the caller wants to use a number to indicate that both eyes have the exact same volume
if (typeof eyes === 'number') {
return {
left: { volume: eyes },
right: { volume: eyes },
}
}
// Assume that the caller might be unsure of what to set the eyes at this current moment, so he expects
// the current instance as arguments to their callback handler so they can calculate the eyes by themselves
if (typeof eyes === 'function') {
return eyes(this)
}
// Assume the caller is passing in the directly formatted object if the code gets here
return eyes
}
setEyes(eyes) {
this.eyes = this.formatEyes(eyes)
return this
}
这样,调用者就可以更轻松地选择他们想要的任何输入类型的变体:
// variation 1 (left eye = index 1, right eye = index 2)
larry.setEyes([{ volume: 1 }, { volume: 1.2 }])
// variation 2 (left eye + right eye = same values)
larry.setEyes(1.1)
// variation 3 (the caller calls the shots on calculating the left and right eyes)
larry.setEyes(function(instance) {
let leftEye, rightEye
let weight, height
if ('weight' in instance) {
weight = instance.weight
}
if ('height' in instance) {
height = instance.height
}
if (weight > 10) {
// It's a fat frog. Their eyes are probably humongous!
leftEye = { volume: 5 }
rightEye = { volume: 5 }
} else {
const volume = someApi.getVolume(weight, height)
leftEye = { volume }
// Assuming that female frogs have shorter right eyes for some odd reason
rightEye = { volume: instance.gender === 'female' ? 0.8 : 1 }
}
return {
left: leftEye,
right: rightEye,
}
})
// variation 4 (caller decides to use the formatted object directly)
larry.setEyes({
left: { volume: 1.5 },
right: { volume: 1.51 },
})
4. 样板(解决方案:模板)
我们将来可能会遇到的一个问题是,我们最终会得到一些重复的代码。
例如,回顾我们的Frog
课堂,你是否认为当我们想要创造某些类型的青蛙时,其中一些可能具有完全相同的特征?
在现实世界中,青蛙有各种各样的变种。例如,蟾蜍是青蛙的一种,但并非所有青蛙都是蟾蜍。这说明蟾蜍有一些独特的属性,这些属性不应该属于普通青蛙。
蟾蜍和青蛙的一个区别是,蟾蜍大部分时间生活在陆地上,而普通青蛙大部分时间生活在水中。此外,蟾蜍的皮肤干燥粗糙,而普通青蛙的皮肤略带黏糊糊的。
这意味着我们必须以某种方式确保每次实例化青蛙时,只有某些值可以通过,以及某些值必须通过。
让我们回到Frog
构造函数并添加两个新参数:habitat
和skin
:
class Frog {
constructor(
name,
gender,
eyes,
legs,
scent,
tongue,
heart,
habitat,
skin,
weight,
height,
) {
this.name = name
this.gender = gender
this.eyes = eyes
this.legs = legs
this.scent = scent
this.tongue = tongue
this.heart = heart
this.habitat = habitat
this.skin = skin
if (weight) {
this.weight = weight
}
if (height) {
this.height = height
}
}
}
对这个构造函数进行两个简单的修改就已经有点让人困惑了!这就是为什么推荐使用构建器模式。如果我们把habitat
和skin
参数放在最后,可能会导致错误,因为weight
和height
可能是 undefined,因为它们都是可选的!而且由于它们是可选的,如果调用者没有传入它们,那么habitat
和skin
就会被错误地用作它们。哎呀!
让我们编辑FrogBuilder
以支持habitat
和skin
:
setHabitat(habitat) {
this.habitat = habitat
}
setSkin(skin) {
this.skin = skin
}
现在假设我们需要创建 2 只独立的蟾蜍和 1 只普通的青蛙:
// frog
const sally = new FrogBuilder('sally', 'female')
.setEyes([{ volume: 1.1 }, { volume: 1.12 }])
.setScent('blueberry')
.setHeart({ rate: 12 })
.setWeight(5)
.setHeight(3.1)
.setLegs([
{ size: 'small' },
{ size: 'small' },
{ size: 'small' },
{ size: 'small' },
])
.setTongue({ width: 12, color: 'navy blue', type: 'round' })
.setHabitat('water')
.setSkin('oily')
.build()
// toad
const kelly = new FrogBuilder('kelly', 'female')
.setEyes([{ volume: 1.1 }, { volume: 1.12 }])
.setScent('black ice')
.setHeart({ rate: 11 })
.setWeight(5)
.setHeight(3.1)
.setLegs([
{ size: 'small' },
{ size: 'small' },
{ size: 'small' },
{ size: 'small' },
])
.setTongue({ width: 12.5, color: 'olive', type: 'round' })
.setHabitat('land')
.setSkin('dry')
.build()
// toad
const mike = new FrogBuilder('mike', 'male')
.setEyes([{ volume: 1.1 }, { volume: 1.12 }])
.setScent('smelly socks')
.setHeart({ rate: 15 })
.setWeight(12)
.setHeight(5.2)
.setLegs([
{ size: 'medium' },
{ size: 'medium' },
{ size: 'medium' },
{ size: 'medium' },
])
.setTongue({ width: 12.5, color: 'olive', type: 'round' })
.setHabitat('land')
.setSkin('dry')
.build()
那么这其中的重复代码在哪里呢?
仔细观察,我们会发现蟾蜍habitat
和skin
setter 必须重复。如果再增加 5 个只用于蟾蜍的 setter 会怎么样?每次创建蟾蜍时,我们都必须手动应用此模板——普通青蛙也是如此。
我们可以做的是创建一个模板器,通常按照惯例称为Director。
导演负责执行创建对象的步骤——通常有一些通用结构可以在构建最终对象时预先定义,例如本例中的蟾蜍。
因此,我们不必手动设置蟾蜍之间的独特属性,而是可以让主管为我们生成该模板:
class ToadBuilder {
constructor(frogBuilder) {
this.builder = frogBuilder
}
createToad() {
return this.builder.setHabitat('land').setSkin('dry')
}
}
let mike = new FrogBuilder('mike', 'male')
mike = new ToadBuilder(mike)
.setEyes([{ volume: 1.1 }, { volume: 1.12 }])
.setScent('smelly socks')
.setHeart({ rate: 15 })
.setWeight(12)
.setHeight(5.2)
.setLegs([
{ size: 'medium' },
{ size: 'medium' },
{ size: 'medium' },
{ size: 'medium' },
])
.setTongue({ width: 12.5, color: 'olive', type: 'round' })
.build()
这样,你就避免了实现所有蟾蜍共有的样板代码,而可以只关注你需要的属性。当蟾蜍有更多独有的属性时,这种方法就更有用了。
结论
这篇文章到此结束!希望你觉得这篇文章很有价值,并期待未来有更多精彩内容!
在Medium上找到我
文章来源:https://dev.to/jsmanifest/4-dangerous-problems-in-javascript-easily-solved-by-the-builder-design-pattern-1738