速读《JavaScript设计模式与开发实践》

2,055 阅读25分钟

不想看文字?没有关系,博客提供视频版了!点击直接进入

这是一本可以帮助我们 提升编程内功,教给我们如何写出 高可维护代码 的书籍。

前言

hello,大家好,我是 Sunday

23种设计模式 其实是程序设计中非常重要的概念。由此,就出现了很多专门去讲解设计模式的书籍,比如:《设计模式:可复用面向对象软件的基础》。

设计模式中文版封面.jpg

但是,在前端领域中设计模式大多数时候都是呈 隐性 的,可能对于很多的项目开发者而言,都开发过十几个项目了,但是也没有用到过设计模式相关的东西。

弹幕:你用过设计模式吗?

其实对于前端而言,我们在很多时候确实没有必要专门去遵循某一种设计模式的写法。但是掌握一些良好的,被广泛认同的编程方式,确是非常有价值的。

《JavaScript设计模式与开发实践》是一个专门教给我们如何写出 高可维护代码的书籍。书中以设计模式作为一个切入点,通过这些比较虚的设计模式,最终带给我们的确是非常实用的一些编程方案。

image-20230322165742499.png

那么这些编程方案具体都有什么呢?又能给我们的日常开发带来什么启示呢?下面就让我们来看一下。

正文

整本书包含前言在内,一共分为四个部分。

首先是 前言:作者在前言中提到了一些非常有用的理念。这些理念可以贯彻到我们整个的开发生涯,或者生活中去。同时也就是因为这些理念,所以我才把前言,作为一个单独的部分进行了介绍。

第二部分是 基础知识:主要包含三个章节。基础部分的内容本意是为了后面章节的 “核心内容” 进行铺垫的。但是在现在的前端开发中,基础部分的一些内容,在面试和开发的场景下,反而可以给我们提供了更大的价值。

第三部分是 设计模式:这一块是本书的 “核心部分”。但是因为本书是 2015 年 发布的书籍,所以 ES6 的内容并没有被写入到本书中去,再加上这么多年前端发展的一些变化。所以这部分的核心内容,以现在的角度去看的话,很多的内容并不能给我们带来直接价值。那么基于这样的一个原因,我们在去讲解这本书的时候,会把这部分所谓的 “核心内容” 的价值后置,依赖于最后一部分进行说明。

那么,所谓的最后一部分就是 设计原则与编程技巧:这部分更像是一个内功的提升。设计模式的最终目的其实是为了可以让我们写出高可维护性的代码,而高可维护性代码的体现其实就是 设计原则与编程技巧。所以说这一部分,将会是咱们这次视频重点要讲解的内容。从具体的设计原则触发,延伸出对应的,有价值的设计模式,争取让大家可以学以致用。

前言

那么明确好了整本书中的内容之后,那么接下来就让我们来看一下书中的前言部分。

让我们从两个问题开始:

  1. 什么是模式
  2. 设计模式的适用性是什么?

咱们先记住这两个问题,然后咱们来看下作者对这两个问题的解答。

什么是模式?

如果是踢球的同学,大家应该知道,在足球中有一个 下底传中 的名词,它表示:在进攻中,通过球员个人带球突破或者球员之间的整体配合把球推进到对方底线附近,然后传至对方球门前的一种战术

下底传中.gif

正因为有了这种战术,所以教练才可以很方便的和球员进行交流。而这种战术,如果用一个名词来描述的话,那么就是 模式

所以如果给模式一个定义的话,那么模式就是:通过一个特有的名词,来描述一类问题对应的解决方案。

那么现在我们知道什么是模式了,那么接下来咱们来给模式定义一个边界,也就是 模式的适用性。

如果我们作为观众再去看球赛的话,那么我们知道,球赛最重要的目的就是进球。所有可以帮助我们进球的方案,都是好的方案。而如果我们把一些 正确的战术用在了错误的场景下,那么肯定会被观众骂娘的。

所以基于这个理念,当我们掌握了一些模式的时候,不要滥用它。“我们手里有一个锤子,就看什么都是钉子”,这是一个很愚蠢的行为。大家要切记的是 模式只有在具体的环境下,才有意义。

那么这个具体的环境指的是什么呢?在第三部分,我们会为大家进行说明。

前言总结

作者在前言中,为模式进行了基本的定调,这个定调不局限于设计模式,而是在我们工作、生活的方方面面中都会有不同的体现。

切记两点:

  1. 通过一个特有的名词,来描述一类问题对应的解决方案,被称为模式。
  2. 模式只有在具体的环境下,才有意义,请不要滥用它。

第一部分:基础知识

整个基础知识,主要包含了三个部分的内容:

  1. 面向对象的 JavaScript
  2. this 指向相关
  3. 闭包和高阶函数

其中 this 指向、闭包、高阶函数 我们在很多的 JavaScript 书籍中都专门讲解过,所以咱们这里不作为重点讲解内容。

整个第一部分,我们以 面向对象的 JavaScript 为主。

① 面向对象的 JavaScript

现在的编程语言,根据设计风格可以大致分为 5 类:

  1. 命令式语言(过程化语言)
  2. 结构化语言
  3. 面向对象语言
  4. 函数式语言
  5. 脚本语言

其中对于我们大多数的开发者而言,面向对象的编程语言 应该是大家最为常见的。

我们所熟知的 java 就是典型的面向对象编程语言。它具备以下三个特点:

  1. 封装
  2. 继承
  3. 多态

而对于 js 而言,虽然它内部也包含对象的概念,但是它并不具备完善的面向对象的特点,而是 通过原型来实现面向对象的开发,所以 js 并不能被称为 标准的面向对象的编程语言。

同时,我们知道 js 中的变量类型是根据变量的值动态发生变化的,那么基于这样的一个特性,JS 被称为是一个 动态类型语言

let t = 'str' // string 类型
t = 007 // number 类型

在动态类型语言的面向对象设计中,存在一个非常重要的概念,那就是 鸭子类型

让我们通过一个故事,来看下什么是鸭子类型:

“从前在JavaScript王国里,有一个国王,他觉得世界上最美妙的声音就是鸭子的叫声,于是国王召集大臣,要组建一个1000只鸭子组成的合唱团。大臣们找遍了全国,终于找到999只鸭子,但是始终还差一只,最后大臣发现有一只非常特别的鸡,它的叫声跟鸭子一模一样,于是这只鸡就成为了合唱团的最后一员。”

image-20230325172204844.png

这个故事告诉我们:如果有一个动物,走起路来像鸭子,叫起来也像鸭子,那么它就是鸭子。

我们可以通过代码,来描述什么是鸭子类型:

image-20230325174207742.png

在这段代码中,我们拥有两个对象 鸭子 duck鸡 chicken 。同时拥有一个合唱团的数组和加入合唱团的方法,在 joinChoir 方法中,只要传入的对象时一个 animal 并且 animal 具备 duckSinging 的方法,那么这个动物就可以加入合唱团。

而针对于动态类型语言而言,它天生就具备一定的 多态性。所谓多态指的就是 同一操作作用于不同的对象上面,可以产生不同的解释和不同的执行结果。

什么意思呢?我们来看下面这段代码:

image-20230325172336523.png

多态背后的思想是将“做什么”和“谁去做以及怎样去做”分离开来,也就是将“不变的事物”与 “可能改变的事物”分离开来。

鸡(Chicken)和 鸭子(Duck) 是两个不同的 “对象”,这两个不同的对象都可以发出叫声,也就是 makeSound 。所以 makeSound 可以分别应用在两个不同的对象 “鸭子和鸡” 身上,从而得到了不同结果。

那么这样的一种特性,就是我们刚才所说的 多态性 的具体体现。

② this、call 和 apply

this、call 和 apply 主要指的是 this 指向 的相关问题。

在现代的 JS 中,影响 this 指向的场景主要分为 4 个方面:

  1. 构造函数:this 指向生成的实例对象。
  2. 普通函数:this 指向函数调用方。
  3. 箭头函数:不修改 this 指向。
  4. call、apply、bindthis 指向第一个实参

以上 4 个场景,我们在很多的书籍中都见到过,我不是很想在每个视频中都把重复的重点进行讲解,所以如果大家想要了解 this 指向的详细讲解示例,那么可以查看下最近的 你不知道的JavaScript(上卷),咱们这里就不再详细说明该问题了。

image-20230326183125383.png

除了基本的 this 指向之外,在这里作者还为我们提供了另外一个关于 this 的常见重点,那就是 this “丢失” 的问题。大家注意,这里的 “丢失” 是被加引号的,也就是说 并不是 this 消失了,而是因为 this 指向的改变,导致了原有的属性无法被找到。

那么什么场景下,会出现这样的问题呢?我们来看这样一段代码:

image-20230326184819767.png

在这段代码中,我们进行了两次打印:

  1. 第一次打印是直接通过 obj.getName() ,根据我们前面的 this 指向所说,此时的 this 会指向 obj 对象,所以会打印出 sven
  2. 而第二次打印的时候,因为我们通过 getName2 这个全局变量接收了 getName 的方法。所以,当 getName2 被执行时,它的代码会变成 window.getName2(),也就是 this 指向 window,因为 window 下不存在 myName 属性,所以会打印出 undefined

那么此时的第二次打印的场景,就是 this “丢失”

③ 闭包和高阶函数

闭包也是咱们老生常谈的话题了,所谓闭包说白了就是 能够访问其他函数作用域中变量的函数

如果我们使用 var 来声明一个变量的话,那么在非 函数作用域 下,var 声明的变量会被置为 全局变量。最明显的体现就是在 for 循环下,以下代码会永远输出 5

image-20230326211129233.png

如果想要解决这个问题,那么可以使用 闭包 来进行解决:

image-20230326211151339.png

关于闭包的问题,咱们在这里不再多说,如果大家想要更多的了解闭包,那么可以查看下咱们之前的 一小时读完《深入理解现代 JavaScript》,彻底掌握 ES6 之后 JavaScript 新特性!

image-20230326211237239.png

第二个是 高阶函数。高阶函数并不是一个很高级的东西,在我们的日常开发中,高阶函数是非常常见的。只要满足以下两个条件,那么该函数就可以被称为是高阶函数:

  1. 函数可以作为参数被传递
  2. 函数可以作为返回值输出

第一部分总结

对于整个第一部分的内容而言,核心的重点应该是 《面向对象的 JavaScript》这一小节的部分,无论是 鸭子类型 还是 多态性 ,他们都可以为我们日常开发和交谈,带来一些帮助。

第二部分:设计模式

在程序设计中,设计模式一共分为 23 种,被称为 23 种设计模式

但是这 23 种设计模式 在前端开发领域中,并不是完全适用的,所以作者为我们列举出来适用于前端的 14 种 设计模式。

14 种 设计模式依然挺多的,如果我们直接按照顺序来为大家一个一个讲解这 14 种设计模式 的话,那么我们相信没有人有耐心能够看下去,并且做到学以致用,包括我在内。

不知道大家还记不记得,我们在《前言》的时候说过 模式只有在具体的环境下,才有意义。 那么基于这样的一个前提条件,所以我们可以从《第三部分:设计原则和编程技巧》中入手。

第三部分:设计原则和编程技巧

《第三部分:设计原则和编程技巧》为我们带来了一些相对具体的业务场景。基于这些业务场景,我们来看适用于该场景下的,具体的《设计模式》,以此来保证 设计模式在具体环境下的意义

那么在第三部分中,作者一共列举出了 5 个原则。其中对我们现在有意义的,主要是 4 个

  1. 单一职责原则
  2. 最少知识原则
  3. 开放封闭原则
  4. 代码重构原则

所以,下面咱们就从这四个原则入手,来看看 设计模式如何帮助我们提升 “编程内功”

单一职责原则

单一职责原则 规定:每个对象(方法)应该只做一件事情。 什么意思呢?咱们来看一段代码:

小明买酱油

function shoping() {
  console.log('小明用钥匙打开门')
  console.log('小明去超市')
  console.log('小明买酱油')
  console.log('小明回家')
}

在这段代码中,我们通过 shoping 方法完成了小明买酱油的整个过程,在这整个过程中,小明一共做了 4 件事情。

那么假如有一天,小明家的门换成了密码门,不需要钥匙了。那么我们就需要修改 shoping 这个现有的函数。而我们知道,当我们修改一个现有函数的实现时,风险是巨大的,这意味着我们需要重新测试整个流程。

而同样的内容,如果我们基于单一职责原则,那么我们得到的代码应该是这个样子的:

function shoping() {
  open()
  goSupermarket()
  buy()
  goHome()
}
​
function open() {
  console.log('小明用钥匙打开门')
}
​
function goSupermarket() {
  console.log('小明去超市')
}
​
function buy() {
  console.log('小明买酱油')
}
​
function goHome() {
  console.log('小明回家')
}

那么基于这样的一个代码,当我们在遇到相同的需求时,只需要新增一个函数,替换掉旧函数的实现即可

function shoping() {
+ openPasswordDoor()
  ...
}
​
function open() {
  console.log('小明用钥匙打开门')
}
+function openPasswordDoor() {
+ console.log('小明用钥匙打开门')
+}

那么此时我们只需要对新函数进行测试,而无需关注旧的实现。

那么基于这样的基础原则,我们可以通过以下 4 种设计模式来进行表现:

  1. 单例模式
  2. 代理模式
  3. 迭代器模式
  4. 装饰者模式

这四种设计模式,我们主要来说前三种。

单例模式

单例模式在前端领域中是使用率非常高的一个设计模式,比如 vuex 中的 store 就是一个标准的单例模式。

所谓单例模式,指的是: 保证一个类仅有一个实例。

那么接下来咱们基于一个场景,来看下对应的单例模式:

假设,我们现在要实现一个登录框的功能: 点击一个按钮,展示登录框,完成登录功能。

那么基于这样的一个需求,如果我们想要实现对应的功能的话,那么代码应该是这个样子:

image-20230327225023314.png

可是这样的代码会存在一个问题。那就是:如果我们点击了多次登录按钮,那么就会生成多个登录框。

而我们知道,在日常的项目中开发中,无论有多少个地方可以打开登录框,登录框本身应该 仅有一个。所以针对于这样的场景,我们就期望可以通过单例模式,来进行对应的实现,那么怎么做呢?咱们来看下面这段代码:

image-20230327225418195.png

在这样的代码中,我们新增了一个 getSingle 的方法,该方法被作为单例生成器。这个单例生成器的实现其实非常简单,它通过一个 闭包 内部封装了 result 变量。这个变量就是我们的单例对象。

基于以上代码,我们无论点击了多少次登录按钮,都可以得到唯一的登录视图。

getSingle 的实现,可以在大多数的项目中,正常运行。

同时它也遵循单一职责原则 生成单例对象。

代理模式

第二个是 代理模式。所谓代理模式,指的是:为其他对象提供一种代理以控制对这个对象的访问

vue3 中的响应性就是基于 proxy 代理对象 进行实现的。

同样,我们也基于一个场景,来看下代理模式:

图片预加载时日常开发中,非常常见的一个功能。在图片尚未加载完成时,我们通过会给 img 标签添加一个占位图。

如果我们想要去实现类似的图片预加载功能,那么我们可能会得到如下代码:

image-20230327230140379.png

在这样的一个代码中,我们首先为 imgNode 节点,设置了一个本地的 src。然后为 img 实例设置了网络图片地址,当 img 实例图片加载完成时,在为 imgNode 节点 设置 src 属性,以完成懒加载逻辑。

但是,如果我们基于 单一职责原则 ,那么可以发现,以上代码其实存在一些问题,比如说:MyImage 函数内部其实完成了两件事情:

  1. 创建 imgNode 节点
  2. 完成 img 实例的图片预加载

那么假如将来需求发生了变化,我们就不得不修改这个复杂的函数。

那么如果想要完成功能的分离,使其遵循单一职责原则,那么可以借助 代理模式 来进行实现:

image-20230327230807473.png

在这样的一段代码中,我们创建了一个代理 proxyImage。如果我们想要为 img 实现图片懒加载,那么可以直接通过 proxyImage 代理完成。

基于代理模式,我们完成了 img 标签和 image实例 的功能分离,使其可以遵循单一职责原则。

迭代器模式

所谓迭代器模式,指的是:提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示

迭代器模式在 JS 中其实已经具备了原生的实现,那么下面咱们先来了解一下手动实现方案,然后再来看 JS 原生实现。同样,我们还是基于一个需求:

这个需求非常简单:根据数据创建 div,并添加到 body 中。

基于这个需求,我们可能会实现如下代码:

image-20230327231318309.png

这个代码非常清晰易懂,但是同样的道理,appendDiv 函数中不光完成了数据的循环处理,同样还生成了 div 节点,并把该节点放入到了 body 中。同样不符合单一职责原则。

那么我们来看这段代码:

image-20230327231514476.png

这段代码比较负责,主要的功能是把 循环和添加 进行了分离,each 循环的逻辑被封装到了方法中,不对外进行表示。那么这样的一种方式就是迭代器模式。

同时,基于迭代器模式我们完成了功能的分离,使其负责单一职责原则。

JS 中,迭代器模式其实已经具备的原生的实现,我们在 一小时读完《深入理解现代 JavaScript》,彻底掌握 ES6 之后 JavaScript 新特性! 中进行过详细的讲解,这里就不重复赘述了。

image-20230326211237239.png

单一职责原则总结

那么到这里,我们基本上明确了单一职责原则,以及对应的设计模式实现方案。但是大家要注意,当我们手里拿着锤子时,不要看什么都是钉子。

我们以迭代器模式的代码为例,虽然第二套代码更负责单一职责原则,但是明显的代码量增加其实并不利于我们的维护,实际开发中第一套代码的方式,明显更加有利于我们的日常维护。

所以大家要注意,作者为我们灌输的一个特别重要的概念,那就是:违反原则并不奇怪。千万不要 手 里拿着锤子,看什么都是钉子。

webp-20230327232056550.jpg 就像前言所说的一样:模式只有在具体的环境下,才有意义,请不要滥用它!

最少知识原则

顾名思义,所谓最少知识原则指的就是: 一个对象应当尽可能少地与其他对象发生相互作用

我们知道在面向对象的设计语言中,存在 高内聚、低耦合 的概念,这个概念指的其实就是 最少知识原则

最少知识原则,可以非常好的通过 中介者模式 呈现出来,所以下面我们来看一下在中介者模式下的最少知识原则:

中介者模式指的是:定义一个对象(中介者),该对象封装了系统中对象间的交互方式。

“假设我们正在编写一个手机购买的页面,在购买流程中,可以选择手机的颜色以及输入购买数量,同时页面中有两个展示区域,分别向用户展示刚刚选择好的颜色和数量。还有一个按钮动态显示下一步的操作,我们需要查询该颜色手机对应的库存,如果库存数量少于这次的购买数量,按钮将被禁用并且显示库存不足,反之按钮可以点击并且显示放入购物车。”

image-20230329094132552.png

image-20230329094141074.png

image-20230329094151398.png

基于以上需求,我们需要监听 颜色切换数量改变 时的对应逻辑处理,所以我们可以得到如下代码:

image-20230329094531730.png

image-20230329094537633.png

基于以上代码,虽然我们可以完成对应的功能处理,但是假设如果我们的需求发生变动,比如现在要去掉颜色选择器和时间选择器,改为样式选择器和城市选择器,那么我们的代码就需要发生大规模的改动。

那么如何可以让我们的代码能够更好的应对变化呢?我们来看基于中介者模式的编码写法:

image-20230329095031102.png

image-20230329095035535.png

在以上代码中,我们封装了一个闭包函数 changed 表示 修改行为。无论是颜色还是其他发生变化时,我们只需要触发对应的 changed 函数即可。

此时的 changed 就承担了中介者的行为,无论是谁只要发生了变化,那么只需要通知中介者即可。

在中介者模式下,changed 内部对整个修改的行为进行了内聚 处理,同时对外界暴露了唯一的修改接口,也就是 低耦合,这样的一种 高内聚,低耦合 的编码方式,也就是我们一直再说的 最少知识原则

开放封闭原则

所谓开放封闭原则指的是:软件实体(类、模块、函数)等应该是可以扩展的,但是不可修改。

这个描述其实是挺好理解的,因为原有的代码一旦发生修改,那么就必然代表着风险,所以我们尽量不要修改原有代码,如果要增加新功能的话,那么可以通过 “打补丁” 的方式进行。

大家还记不记得,我们之前在前言部分了解过 鸭子类型,针对鸭子类型,我们得出了这样的代码:

image-20230329100642085.png

那么现在,假如我们要插入一个新的 dog 对象,那么就不得不修改 makeSound 方法,增加一个新的 if

那么针对于这种场景,我们就可以利用 JS 原生多态性 来对以上代码进行一个修改:

image-20230329101822998.png

我们创建 makeSound 方法,接收任何的 animalanimal 必然存在 sound 方法,以 “多态” 的形式触发对应的叫声。

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

代码重构原则

代码重构原则这里,我觉得是整本书中对我们技术能力提升最明显的地方,可以说是整本书中的精华部分。在代码重构原则中,作者为我们列举出了 8 种场景,以及对应的 最佳实践。通过对这些场景的了解,可以显著提升我们的编程能力。

首先我们先来看一下作者对于重构的描述:无论你进行了再多的努力,重构都是一个会在未来发生的事情,只不过是一个早晚的问题。

不过,如果我们的代码足够 “优秀”,那么可以减少我们维护的成本,以及尽量拖慢重构的时间。

下面我们来看 8 个提升代码可维护性的场景:

① 提炼函数

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

image-20230329104758322.png

提炼后,每个函数只做一件事情:

image-20230329104810682.png

② 合并重复的代码片段

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

image-20230329105237032.png

合并后

image-20230329105240025.png

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

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

image-20230329105341569.png

封装后:

image-20230329105350902.png

④ 合理使用循环

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

image-20230329105555987.png

把多个 XMLHttp 合并为一个数组,然后对数组进行处理:

image-20230329105620221.png

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

一个变量存在不同条件的值,那么可以在获取到值之后直接退出:

image-20230329105758353.png

赋值后,直接退出:

image-20230329105817267.png

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

当一个函数需要接收多个参数的时候,一长串的形参会让调用者难以接收:

image-20230329105914454.png

我们可以把多个形参封装成一个 对象形参:

image-20230329105947834.png

⑦ 尽量减少参数数量

不要让函数的参数过多,一些可以在函数内部计算得到的值,不要必要的参数进行传递:

image-20230329110136270.png

square 可以通过计算得到:

image-20230329110155071.png

⑧ 合理使用链式调用

链式调用可以让我们的代码逻辑处理起来更加方便:

image-20230329110402059.png

可以通过 renturn this 的方式,来完成链式调用:

image-20230329110425930.png

总结

《JavaScript设计模式与开发实践》虽然是 2015年的一本书籍了,但是看完之后我们可以发现,里面的一些 理念技巧 对我们现在而言,依然是非常有价值的。

所以这也告诉了我们一件事情,那就是 编程理念并不会随着时间的流逝而失去价值。

虽然现在网络中流传着很多 前端已死 的言论,但是我认为前端目前依然处于一个快速发展的时期。只不过是由原先的 野蛮生长,变成了现在 趋于正常 的发展状态。

再说了,就算真的 “前端已死” ,只要我们积累下了足够的内功,对于我们而言,也不过是把原先的 “扳手” 换成了 “锤子” 而已。不要忘了 编程理念并不会随着时间的流逝而失去价值。

我是 Sunday,陪大家一起读书,一起分享技术知识,咱们下次再见,88~~~