1 开篇

烹饪有菜谱,游戏有攻略,编程的“套路”就是设计设计模式.png模式

image.png
通过学习这部分设计模式,我们至少可以达到三个目的:

  1. 充分理解前端设计模式的核心思想和基本理念,在具体的场景中掌握抽象的设计原则
  2. 会写代码,会写好代码;
  3. 会面试,能言之有物。

把自己放到一个正确的场景里,去体会这个模式的好
原理->实践->总结
跟着做

2.设计模式的道与术

(1)设计模式之道

在学习设计模式时,如果各位可以回忆起这种“从映射到默写”的思维方式

SOLID设计原则

“SOLID” :面向对象编程和面向对象设计的五个基本原则。
设计原则是设计模式的指导理论,它可以帮助我们规避不良的软件设计。

SOLID 指代的五个基本原则分别是:

  • 单一功能原则(Single Responsibility Principle)
  • 开放封闭原则(Opened Closed Principle)
  • 里式替换原则(Liskov Substitution Principle)
  • 接口隔离原则(Interface Segregation Principle)
  • 依赖反转原则(Dependency Inversion Principle)

    JavaScript 设计模式中,主要用到的设计模式基本都围绕“单一功能”和“开放封闭”这两个原则来展开。

    设计模式的核心思想——封装变化

    软件设计越来越复杂的“罪魁祸首”,就是变化
    在实际开发中,不发生变化的代码可以说是不存在的,将这个变化造成的影响最小化 —— 将变与不变分离,确保变化的部分灵活、不变的部分稳定
    这个过程,就叫“封装变化”;这样的代码,就是我们所谓的“健壮”的代码,它可以经得起变化的考验。而设计模式出现的意义,就是帮我们写出这样的代码。

(2)设计模式的“术”

23种设计模式,《设计模式:可复用面向对象软件的基础》这本书将23种设计模式按照“创建型”、“行为型”和“结构型”进行划分:
image.png
设计模式的核心思想,就是“封装变化”
无论是创建型、结构型还是行为型,这些具体的设计模式都是在用自己的方式去封装不同类型的变化 ——
创建型模式封装了创建对象过程中的变化,比如下节的工厂模式,它做的事情就是将创建对象的过程抽离;结构型模式封装的是对象之间组合方式的变化,目的在于灵活地表达对象间的配合与依赖关系;而行为型模式则将是对象千变万化的行为进行抽离,确保我们能够更安全、更方便地对行为进行更改。

封装的正是软件中那些不稳定的要素

从 Java/C++ 到 JavaScript 的迁移

场景是基础,代码是辅助,逻辑是主角

3.创建型:工厂模式/简单工厂-区别“变与不变”

(1)构造器

Class我们采用了 ES5 构造函数的写法,因为 ES6 中的 class 其实本质上还是函数,class 语法只是语法糖,构造函数,才是它的真面目。

  1. function User(name , age, career) {
  2. this.name = name
  3. this.age = age
  4. this.career = career
  5. }
  6. const user = new User(name, age, career)

像 User 这样当新建对象的内存被分配后,用来初始化该对象的特殊函数,就叫做构造器。在 JavaScript 中,我们使用构造函数去初始化对象,就是应用了构造器模式

使用构造器模式的时候,我们本质上是去抽象了每个对象实例的变与不变。那么使用工厂模式时,我们要做的就是去抽象不同构造函数(类)之间的变与不变。

(2)简单工厂模式

工厂模式其实就是将创建对象的过程单独封装

  1. function User(name , age, career, work) {
  2. this.name = name
  3. this.age = age
  4. this.career = career
  5. this.work = work
  6. }
  7. function Factory(name, age, career) {
  8. let work
  9. switch(career) {
  10. case 'coder':
  11. work = ['写代码','写系分', '修Bug']
  12. break
  13. case 'product manager':
  14. work = ['订会议室', '写PRD', '催更']
  15. break
  16. case 'boss':
  17. work = ['喝茶', '看报', '见客户']
  18. case 'xxx':
  19. // 其它工种的职责分配
  20. ...
  21. return new User(name, age, career, work)
  22. }

实现无脑传参
将创建对象的过程单独封装,这样的操作就是工厂模式。同时它的应用场景也非常容易识别:有构造函数的地方,我们就应该想到简单工厂;在写了大量构造函数、调用了大量的 new、自觉非常不爽的情况下,我们就应该思考是不是可以掏出工厂模式重构我们的代码了。

4.创建型:工厂模式:抽象工厂—理解“开放封闭”

目前的 JavaScript 语法里,也确实不支持抽象类的直接实现,我们只能凭借模拟去还原抽象类。因此有一种言论认为,对于前端来说,抽象工厂就是鸡肋

(1)一个不简单的简单工厂引发的命案

  1. function Factory(name, age, career) {
  2. let work
  3. switch(career) {
  4. case 'coder':
  5. work = ['写代码','写系分', '修Bug']
  6. break
  7. case 'product manager':
  8. work = ['订会议室', '写PRD', '催更']
  9. break
  10. case 'boss':
  11. work = ['喝茶', '看报', '见客户']
  12. case 'xxx':
  13. // 其它工种的职责分配
  14. ...
  15. return new User(name, age, career, work)
  16. }

乍一看没什么问题,Boss 和基层员工在职能上差别还是挺大的,具体在员工系统里怎么表现呢?他的权限就跟咱们不一样,除此之外还有许多操作,是只有管理层可以执行的,因此我们需要对这个群体的对象进行单独的逻辑处理。这么做其实是在挖坑——因为公司不仅仅只有这两类人,除此之外还有外包同学、还有保安,他们的权限、职能都存在着质的差别。如果延续这个思路,每考虑到一个新的员工群体,就回去修改一次 Factory 的函数体,这样做糟糕透了—Factory会变得异常庞大;你坑死了你的队友Factory 的逻辑过于繁杂和混乱,没人敢维护它;最后,你还连带坑了隔壁的测试同学:你每次新加一个工种,他都不得不对整个Factory 的逻辑进行回归这一切悲剧的根源只有一个——没有遵守开放封闭原则

开放封闭原则的内容:对拓展开放,对修改封闭。说得更准确点,软件实体(类、模块、函数)可以扩展,但是不可修改

(2)抽象工厂模式

我不是让你拿去new一个实例的,我就是个定规矩的
抽象工厂不干活,具体工厂(ConcreteFactory)来干活!
因此我们可以用一个抽象产品(AbstractProduct)类来声明这一类产品应该具有的基本功能

对原有的内容不会造成任何潜在影响 所谓的“对拓展开放,对修改封闭”就这么实现。前面我们之所以要实现抽象产品类,也是同样的道理。

假如有一天,FakeStar过气了,我们需要产出一款新机投入市场,这时候怎么办?我们是不是不需要对抽象工厂MobilePhoneFactory做任何修改,只需要拓展它的种类:

  1. class newStarFactory extends MobilePhoneFactory {
  2. createOS() {
  3. // 操作系统实现代码
  4. }
  5. createHardWare() {
  6. // 硬件实现代码
  7. }
  8. }

抽象工厂和简单工厂的思路,思考一下:它们之间有哪些异同?
它们的共同点,在于都尝试去分离一个系统中变与不变的部分。它们的不同在于场景的复杂度
在简单工厂的使用场景里,处理的对象是类,并且是一些非常好对付的类——它们的共性容易抽离,同时因为逻辑本身比较简单,故而不苛求代码可扩展性。
抽象工厂本质上处理的其实也是类,但是是一帮非常棘手、繁杂的类,这些类中不仅能划分出门派,还能划分出等级,同时存在着千变万化的扩展可能性——这使得我们必须对共性作更特别的处理、使用抽象类去降低扩展的成本,同时需要对类的性质作划分,于是有了这样的四个关键角色:

  • 抽象工厂(抽象类,它不能被用于生成具体实例): 用于声明最终目标产品的共性。在一个系统里,抽象工厂可以有多个(大家可以想象我们的手机厂后来被一个更大的厂收购了,这个厂里除了手机抽象类,还有平板、游戏机抽象类等等),每一个抽象工厂对应的这一类的产品,被称为“产品族”。
  • 具体工厂(用于生成产品族里的一个具体的产品): 继承自抽象工厂、实现了抽象工厂里声明的那些方法,用于创建具体的产品的类。
  • 抽象产品(抽象类,它不能被用于生成具体实例): 上面我们看到,具体工厂里实现的接口,会依赖一些类,这些类对应到各种各样的具体的细粒度产品(比如操作系统、硬件等),这些具体产品类的共性各自抽离,便对应到了各自的抽象产品类。
  • 具体产品(用于生成产品族里的一个具体的产品所依赖的更细粒度的产品): 比如我们上文中具体的一种操作系统、或具体的一种硬件等。

抽象工厂模式的定义,是围绕一个超级工厂创建其他工厂。留意以下三点:

  1. 学会用 ES6 模拟 JAVA 中的抽象类;
  2. 了解抽象工厂模式中四个角色的定位与作用;
  3. 对“开放封闭原则”形成自己的理解,知道它好在哪,知道执行它的必要性。

如果能对这三点有所掌握,那么这一节的目的就达到了,最难搞、最难受的抽象工厂也就告一段落了。

(3)最后,再跟大家谈谈学习

抽象工厂对于各位而言的价值是什么?这么一个看似鸡肋、其实也确实不怎么常用的一个设计模式,凭什么值得我们花这么大力气去理解它?原因有三:
其一: 开篇我们说过,前端工程师首先是软件工程师。只会写 JavaScript、只理解 JavaScript、只通过 JavaScript 去理解软件世界,是一件可怕的事情,它会窄化你的技术视野——因为 JavaScript 只是编程语言中的一个分支,准确地说,它是一个后辈。虽说它确实很流行,但它还不够强大(正是因为不够强大,所以在演化发展的过程中必然需要借鉴其它优秀语言的优秀特性,也会渐渐遇到其它语言的应用场景,不信大家看看 ES6789 都做了什么,再看看遍地开花的 TypeScript)。
但写这本小册并不是为了把大家指去学 Java/C++,而是为了以最小的时间成本帮大家去理解设计模式的套路和原则。比起要求大家为了这个设计模式去理解强类型语言、去理解强类型语言里的应用场景,我更希望能在这儿用 JavaScript 把这个东西给说清楚,把那些关键的设计模式概念在这儿给大家引出来——哪怕你当下用到它的场景还不是那么多(相信以当下前端语言和前端应用的发展速度和发展趋势来看,它会有用的:))。
其二: 在大家今后的职业生涯里,可能会不止一次地遇到服务端/客户端出身、或者单纯对受试者知识广度有疯狂执念的各种不同背景不同脑回路的面试官。在他们的世界里,不知道抽象工厂就像不知道 this 一样恐怖:)。所以,要学
其三: 也是最重要的一点。前面我们说过,设计模式的“术”说到底是在佐证它的“道”。充分理解了设计原则后,设计模式纵有 1w 种也难不倒大家。抽象工厂是佐证“开放封闭原则”的良好素材,通过本节的学习,相信大家会对这个抽象的概念有更加具体和感性的认知。在后面的章节中,“开放封闭”作为各位的老朋友,会被反复提及。有了本节的平稳过渡,相信大家在后续的学习中可以真正做到心中有数、游刃有余。

说了这么多,无非是想传达给大家一个学习态度:不要小看那些看似“无用”的知识
技术,尤其是前端技术,它的更新迭代速度是非常快的。仅仅因为“这个技术点我现在用不到”而推开摆在眼前的知识,是一种非常糟糕的学习方法——它会极大地限制你的能力和你职业生涯的可能性。
设计模式之外的东西,我们点到即止,剩下的就看大家的悟性和造化了。 接下来,我们一起看点更好玩的东西~

5.创建型: 单例模式-vuex的数据管理哲学

保证一个类仅有一个实例,并提供一个访问它的全局访问点,这样的模式就叫做单例模式。

(1)单例模式的实现思路

如何才能保证一个类仅有一个实例?
单例模式想要做到的是,不管我们尝试去创建多少次,它都只给你返回第一次所创建的那唯一的一个实例
需要构造函数具备判断自己是否已经创建过一个实例的能力。我们现在把这段判断逻辑写成一个静态方法(其实也可以直接写入构造函数的函数体里):

  1. class SingleDog {
  2. show() {
  3. console.log('我是一个单例对象')
  4. }
  5. static getInstance() {
  6. // 判断是否已经new1个实例
  7. if (!SingleDog.instance) {
  8. // 若这个唯一的实例不存在,那么先创建它
  9. SingleDog.instance = new SingleDog()
  10. }
  11. // 如果这个唯一的实例已经存在,则直接返回
  12. return SingleDog.instance
  13. }
  14. }
  15. const s1 = SingleDog.getInstance()
  16. const s2 = SingleDog.getInstance()
  17. // true
  18. s1 === s2

除了楼上这种实现方式之外,getInstance的逻辑还可以用闭包来实现:

  1. SingleDog.getInstance = (function() {
  2. // 定义自由变量instance,模拟私有变量
  3. let instance = null
  4. return function() {
  5. // 判断自由变量是否为null
  6. if(!instance) {
  7. // 如果为nullnew出唯一实例
  8. instance = new SingleDog()
  9. }
  10. return instance
  11. }
  12. })()

(2)生产实践:Vuex中的单例模式

无论是 Redux 和 Vuex,它们都实现了一个全局的 Store 用于存储应用的所有状态。这个 Store 的实现,正是单例模式的典型应用。这里我们以 Vuex 为例,研究一下单例模式是怎么发光发热的:

a. 理解 Vuex 中的 Store

Vuex 使用单一状态树,用一个对象就包含了全部的应用层级状态。至此它便作为一个“唯一数据源 (SSOT)”而存在。这也意味着,每个应用将仅仅包含一个 store 实例。单一状态树让我们能够直接地定位任一特定的状态片段,在调试的过程中也能轻易地取得整个当前应用状态的快照。 ——Vuex官方文档

在Vue中,组件之间是独立的,组件间通信最常用的办法是 props(限于父组件和子组件之间的通信),稍微复杂一点的(比如兄弟组件间的通信)我们通过自己实现简单的事件监听函数也能解决掉。

但当组件非常多、组件间关系复杂、且嵌套层级很深的时候,这种原始的通信方式会使我们的逻辑变得复杂难以维护。这时最好的做法是将共享的数据抽出来、放在全局,供组件们按照一定的的规则去存取数据,保证状态以一种可预测的方式发生变化。于是便有了 Vuex,这个用来存放共享数据的唯一数据源,就是 Store。

b. Vuex如何确保Store的唯一性

我们先来看看如何在项目中引入 Vuex:

  1. // 安装vuex插件
  2. Vue.use(Vuex)
  3. // store注入到Vue实例中
  4. new Vue({
  5. el: '#app',
  6. store
  7. })

通过调用Vue.use()方法,我们安装了 Vuex 插件。Vuex 插件是一个对象,它在内部实现了一个 install 方法,这个方法会在插件安装时被调用,从而把 Store 注入到Vue实例里去。也就是说每 install 一次,都会尝试给 Vue 实例注入一个 Store。
在 install 方法里,有一段逻辑和我们楼上的 getInstance 非常相似的逻辑:

  1. let Vue // 这个Vue的作用和楼上的instance作用一样
  2. ...
  3. export function install (_Vue) {
  4. // 判断传入的Vue实例对象是否已经被installVuex插件(是否有了唯一的state
  5. // 如果一样则代表已经创建过了
  6. if (Vue && _Vue === Vue) {
  7. if (process.env.NODE_ENV !== 'production') {
  8. console.error(
  9. '[vuex] already installed. Vue.use(Vuex) should be called only once.'
  10. )
  11. }
  12. return
  13. }
  14. // 若没有,则为这个Vue实例对象install一个唯一的Vuex
  15. Vue = _Vue
  16. // Vuex的初始化逻辑写进Vue的钩子函数里
  17. applyMixin(Vue)
  18. }

楼上便是 Vuex 源码中单例模式的实现办法了,套路可以说和我们的getInstance如出一辙。通过这种方式,可以保证一个 Vue 实例(即一个 Vue 应用)只会被 install 一次 Vuex 插件,所以每个 Vue 实例只会拥有一个全局的 Store。

小结

这里大家不妨开个脑洞,思考一下:如果我在 install 里没有实现单例模式,会带来什么样的麻烦?
我们通过上面的源码解析可以看出,每次 install 都会为Vue实例初始化一个 Store。假如 install 里没有单例模式的逻辑,那我们如果在一个应用里不小心多次安装了插件:

  1. // 在主文件里安装Vuex
  2. Vue.use(Vuex)
  3. ...(中间添加/修改了一些store的数据)
  4. // 在后续的逻辑里不小心又安装了一次
  5. Vue.use(Vuex)

失去了单例判断能力的 install 方法,会为当前的Vue实例重新注入一个新的 Store,也就是说你中间的那些数据操作全都没了,一切归 0。因此,单例模式在此处是非常必要的。
除了说在 Vuex 中大展身手,我们在 Redux、jQuery 等许多优秀的前端库里也都能看到单例模式的身影。

6.创建型: 单例模式-题

(1)实现一个 Storage

描述

实现Storage,使得该对象为单例,基于 localStorage 进行封装。实现方法 setItem(key,value) 和 getItem(key)。

思路

拿到单例模式相关的面试题,大家首先要做的是回忆我们上个小节的“基本思路”部分——至少要记起来getInstance方法和instance这个变量是干啥的。

具体实现上,把判断逻辑写入静态方法或者构造函数里都没关系,最好能把闭包的版本也写出来,多多益善。

实现:静态方法版

  1. // 定义Storage
  2. class Storage {
  3. static getInstance() {
  4. // 判断是否已经new1个实例
  5. if (!Storage.instance) {
  6. // 若这个唯一的实例不存在,那么先创建它
  7. Storage.instance = new Storage()
  8. }
  9. // 如果这个唯一的实例已经存在,则直接返回
  10. return Storage.instance
  11. }
  12. getItem (key) {
  13. return localStorage.getItem(key)
  14. }
  15. setItem (key, value) {
  16. return localStorage.setItem(key, value)
  17. }
  18. }
  19. const storage1 = Storage.getInstance()
  20. const storage2 = Storage.getInstance()
  21. storage1.setItem('name', '李雷')
  22. // 李雷
  23. storage1.getItem('name')
  24. // 也是李雷
  25. storage2.getItem('name')
  26. // 返回true
  27. storage1 === storage2

实现: 闭包版

  1. // 先实现一个基础的StorageBase类,把getItemsetItem方法放在它的原型链上
  2. function StorageBase () {}
  3. StorageBase.prototype.getItem = function (key){
  4. return localStorage.getItem(key)
  5. }
  6. StorageBase.prototype.setItem = function (key, value) {
  7. return localStorage.setItem(key, value)
  8. }
  9. // 以闭包的形式创建一个引用自由变量的构造函数
  10. const Storage = (function(){
  11. let instance = null
  12. return function(){
  13. // 判断自由变量是否为null
  14. if(!instance) {
  15. // 如果为nullnew出唯一实例
  16. instance = new StorageBase()
  17. }
  18. return instance
  19. }
  20. })()
  21. // 这里其实不用 new Storage 的形式调用,直接 Storage() 也会有一样的效果
  22. const storage1 = new Storage()
  23. const storage2 = new Storage()
  24. storage1.setItem('name', '李雷')
  25. // 李雷
  26. storage1.getItem('name')
  27. // 也是李雷
  28. storage2.getItem('name')
  29. // 返回true
  30. storage1 === storage2

(2)实现一个全局的模态框

描述

实现一个全局唯一的Modal弹框

思路

这道题比较经典,基本上所有讲单例模式的文章都会以此为例,同时它也是早期单例模式在前端领域的最集中体现。
万变不离其踪,记住getInstance方法、记住instance变量、记住闭包和静态方法,这个题除了要多写点 HTML 和 CSS 之外,对大家来说完全不成问题。

实现

完整代码如下:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>单例模式弹框</title>
  6. </head>
  7. <style>
  8. #modal {
  9. height: 200px;
  10. width: 200px;
  11. line-height: 200px;
  12. position: fixed;
  13. left: 50%;
  14. top: 50%;
  15. transform: translate(-50%, -50%);
  16. border: 1px solid black;
  17. text-align: center;
  18. }
  19. </style>
  20. <body>
  21. <button id='open'>打开弹框</button>
  22. <button id='close'>关闭弹框</button>
  23. </body>
  24. <script>
  25. // 核心逻辑,这里采用了闭包思路来实现单例模式
  26. const Modal = (function() {
  27. let modal = null
  28. return function() {
  29. if(!modal) {
  30. modal = document.createElement('div')
  31. modal.innerHTML = '我是一个全局唯一的Modal'
  32. modal.id = 'modal'
  33. modal.style.display = 'none'
  34. document.body.appendChild(modal)
  35. }
  36. return modal
  37. }
  38. })()
  39. // 点击打开按钮展示模态框
  40. document.getElementById('open').addEventListener('click', function() {
  41. // 未点击则不创建modal实例,避免不必要的内存占用;此处不用 new Modal 的形式调用也可以,和 Storage 同理
  42. const modal = new Modal()
  43. modal.style.display = 'block'
  44. })
  45. // 点击关闭按钮隐藏模态框
  46. document.getElementById('close').addEventListener('click', function() {
  47. const modal = new Modal()
  48. if(modal) {
  49. modal.style.display = 'none'
  50. }
  51. })
  52. </script>
  53. </html>

是不是发现又是熟悉的套路?又可以默写了?(ES6 版本的实现大家自己尝试默写一下,相信对现在的你来说已经非常简单了)。
这就是单例模式面试题的特点,准确地说,是所有设计模式相关面试题的特点——牢记核心思路,就能举一反三。所以说设计模式的学习是典型的一分耕耘一分收获,性价比极高。

7.创建型:原型模式—prototype

原型模式不仅是一种设计模式,它还是一种编程范式(programming paradigm),是 JavaScript 面向对象系统实现的根基。
只要我们还在借助Prototype来实现对象的创建和原型的继承,那么我们就是在应用原型模式。
例如Object.create

我们使用原型模式,并不是为了得到一个副本,而是为了得到与构造函数(类)相对应的类型的实例、实现数据/方法的共享。克隆是实现这个目的的方法,但克隆本身并不是我们的目的。

(1)以类为中心的语言和以原型为中心的语言

js创建实例只能用Prototype,(es6的class也是Prototype的语法糖)
其它语言,比如 JAVA 中不仅可以用原型,还能用类class

使用 JavaScript 以来,我们确实离不开Prototype——难道我还有除了Prototype以外的选择? 没有

Java 中的类

作为 JavaScript 开发者,我们确实没有别的选择 —— 毕竟开头我们说过,原型模式是 JavaScript 这门语言面向对象系统的根本。但在其它语言,比如 JAVA 中,类才是它面向对象系统的根本。所以说在 JAVA 中,我们可以选择不使用原型模式 —— 这样一来,所有的实例都必须要从类中来,当我们希望创建两个一模一样的实例时,就只能这样做(假设实例从 Dog 类中来,必传参数为姓名、性别、年龄和品种):
Dog dog = new Dog(‘旺财’, ‘male’, 3, ‘柴犬’) Dog dog_copy = new Dog(‘旺财’, ‘male’, 3, ‘柴犬’)
没错,我们不得不把一模一样的参数传两遍,非常麻烦。而原型模式允许我们通过调用克隆方法的方式达到同样的目的,比较方便,所以 Java 专门针对原型模式设计了一套接口和方法,在必要的场景下会通过原型方法来应用原型模式。

JavaScript 中的“类”

因为 ES6 的类其实是原型继承的语法糖:

当我们尝试用 class 去定义一个 Dog 类时:

  1. class Dog {
  2. constructor(name ,age) {
  3. this.name = name
  4. this.age = age
  5. }
  6. eat() {
  7. console.log('肉骨头真好吃')
  8. }
  9. }
  10. //其实完全等价于写了这么一个构造函数:
  11. function Dog(name, age) {
  12. this.name = name
  13. this.age = age
  14. }
  15. Dog.prototype.eat = function() {
  16. console.log('肉骨头真好吃')
  17. }

所以说 JavaScript 这门语言的根本就是原型模式。
因此我们此处不必强行把原型模式当作一种设计模式去理解,把它作为一种编程范式来讨论会更合适。

(2)谈原型模式,其实是谈原型范式

原型编程范式的核心思想就是利用实例来描述对象,用实例作为定义对象和继承的基础。在 JavaScript 中,原型编程范式的体现就是基于原型链的继承。这其中,对原型、原型链的理解是关键。

原型

在 JavaScript 中,每个构造函数都拥有一个prototype属性,它指向构造函数的原型对象,这个原型对象中有一个 constructor 属性指回构造函数;每个实例都有一个proto属性,当我们使用构造函数去创建实例时,实例的proto属性就会指向构造函数的原型对象。 具体来说,当我们这样使用构造函数创建一个对象时:

  1. // 创建一个Dog构造函数
  2. function Dog(name, age) {
  3. this.name = name
  4. this.age = age
  5. }
  6. Dog.prototype.eat = function() {
  7. console.log('肉骨头真好吃')
  8. }
  9. // 使用Dog构造函数创建dog实例
  10. const dog = new Dog('旺财', 3)

这段代码里的几个实体之间就存在着这样的关系:
七 【设计模式】 - 图4

原型链

现在我在上面那段代码的基础上,进行两个方法调用:

  1. // 输出"肉骨头真好吃"
  2. dog.eat()
  3. // 输出"[object Object]"
  4. dog.toString()

以我们的 eat 方法和 toString 方法的调用过程为例,它的搜索过程就是这样子的:
七 【设计模式】 - 图5
楼上这些彼此相连的prototype,就组成了一个原型链。 注: 几乎所有 JavaScript 中的对象都是位于原型链顶端的 Object 的实例,除了Object.prototype(当然,如果我们手动用Object.create(null)创建一个没有任何原型的对象,那它也不是 Object 的实例)。

对象的深拷贝

这类题目的发问方式又很多,除了“模拟 JAVA 中的克隆接口”、“JavaScript 实现原型模式”以外,它更常见、更友好的发问形式是“请实现JS中的深拷贝”。
实现 JavaScript 中的深拷贝,有一种非常取巧的方式 —— JSON.stringify:

  1. const liLei = {
  2. name: 'lilei',
  3. age: 28,
  4. habits: ['coding', 'hiking', 'running']
  5. }
  6. const liLeiStr = JSON.stringify(liLei)
  7. const liLeiCopy = JSON.parse(liLeiStr)
  8. liLeiCopy.habits.splice(0, 1)
  9. console.log('李雷副本的habits数组是', liLeiCopy.habits)
  10. console.log('李雷的habits数组是', liLei.habits)

但是注意,这个方法存在一些局限性,比如无法处理 function、无法处理正则等等——只有当你的对象是一个严格的 JSON 对象时,可以顺利使用这个方法。在面试过程中,大家答出这个答案没有任何问题,但不要仅仅答这一种做法。

深拷贝没有完美方案,每一种方案都有它的边界 case。而面试官向你发问也并非是要求你破解人类未解之谜,多数情况下,他只是希望考查你对递归的熟练程度。所以递归实现深拷贝的核心思路,大家需要重点掌握(解析在注释里):

  1. function deepClone(obj) {
  2. // 如果是 值类型 null,则直接return
  3. if(typeof obj !== 'object' || obj === null) {
  4. return obj
  5. }
  6. // 定义结果对象
  7. let copy = {}
  8. // 如果对象是数组,则定义结果数组
  9. if(obj.constructor === Array) {
  10. copy = []
  11. }
  12. // 遍历对象的key
  13. for(let key in obj) {
  14. // 如果key是对象的自有属性
  15. if(obj.hasOwnProperty(key)) {
  16. // 递归调用深拷贝方法
  17. copy[key] = deepClone(obj[key])
  18. }
  19. }
  20. return copy
  21. }

调用深拷贝方法,若属性为值类型,则直接返回;若属性为引用类型,则递归遍历。这就是我们在解这一类题时的核心的方法。

拓展阅读

深拷贝在命题时,可发挥的空间主要在于针对不同数据结构的处理,比如除了考虑 Array、Object,还需要考虑一些其它的数据结构(Map、Set 等);此外还有一些极端 case(循环引用等)的处理等等。

  1. function isObject(val) {
  2. return typeof val === "object" && val !== null;
  3. }
  4. function deepClone(obj, hash = new WeakMap()) {
  5. if (!isObject(obj)) return obj; // 不是对象直接返回
  6. if (hash.has(obj)) {
  7. return hash.get(obj);// 如果hash中有值的话,从hash中获取值,防止循环引用
  8. }
  9. let target = Array.isArray(obj) ? [] : {};// 根据传入的数据类型设置目标初始值
  10. hash.set(obj, target);// hash中设置值,防止循环引用
  11. // 映射 Reflect.ownKeys() 返回一个由目标对象自身的属性键组成的数组
  12. Reflect.ownKeys(obj).forEach((item) => {
  13. if (isObject(obj[item])) { // 如果对应键的值是对象的话递归
  14. target[item] = deepClone(obj[item], hash);
  15. } else {// 是简单数据类型的话 直接赋值
  16. target[item] = obj[item];
  17. }
  18. });
  19. return target;
  20. }
  21. // var obj1 = {
  22. // a:1,
  23. // b:{a:2}
  24. // };
  25. // var obj2 = deepClone(obj1);
  26. // console.log(obj1);

8.结构型:装饰器模式—对象装上它就像开了挂

装饰器模式,又名装饰者模式。它的定义是“在不改变原对象的基础上,通过对其进行包装拓展,使原有对象可以满足用户的更复杂需求”。

(1)生活中的装饰器

手机壳 手机膜 等

(2)装饰器的应用场景

按钮是我们平时写业务时常见的页面元素。假设我们的初始需求是:每个业务中的按钮在点击后都弹出「您还未登录哦」的弹框。
代码:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>按钮点击需求1.0</title>
  6. </head>
  7. <style>
  8. #modal {
  9. height: 200px;
  10. width: 200px;
  11. line-height: 200px;
  12. position: fixed;
  13. left: 50%;
  14. top: 50%;
  15. transform: translate(-50%, -50%);
  16. border: 1px solid black;
  17. text-align: center;
  18. }
  19. </style>
  20. <body>
  21. <button id='open'>点击打开</button>
  22. <button id='close'>关闭弹框</button>
  23. </body>
  24. <script>
  25. // 弹框创建逻辑,这里我们复用了单例模式面试题的例子
  26. const Modal = (function() {
  27. let modal = null
  28. return function() {
  29. if(!modal) {
  30. modal = document.createElement('div')
  31. modal.innerHTML = '您还未登录哦~'
  32. modal.id = 'modal'
  33. modal.style.display = 'none'
  34. document.body.appendChild(modal)
  35. }
  36. return modal
  37. }
  38. })()
  39. // 点击打开按钮展示模态框
  40. document.getElementById('open').addEventListener('click', function() {
  41. // 未点击则不创建modal实例,避免不必要的内存占用
  42. const modal = new Modal()
  43. modal.style.display = 'block'
  44. })
  45. // 点击关闭按钮隐藏模态框
  46. document.getElementById('close').addEventListener('click', function() {
  47. const modal = document.getElementById('modal')
  48. if(modal) {
  49. modal.style.display = 'none'
  50. }
  51. })
  52. </script>
  53. </html>

之后要在弹框被关闭后把按钮的文案改为“快去登录”,同时把按钮置灰。
听到这个消息,你立刻马不停蹄地翻出之前的代码,找到了按钮的 click 监听函数,手动往里面添加了文案修改&按钮置灰逻辑。但这还没完,因为你司的几乎每个业务里都用到了这类按钮:除了“点击打开”按钮,还有“点我开始”、“点击购买”按钮等各种五花八门的按钮,这意味着你不得不深入到每一个业务的深处去给不同的按钮添加这部分逻辑。
有的业务不在你这儿,但作为这个新功能迭代的 owner,你还需要把需求细节再通知到每一个相关同事(要么你就自己上,去改别人的代码,更恐怖),怎么想怎么麻烦。一个文案修改&按钮置灰尚且如此麻烦,更不要说我们日常开发中遇到的更复杂的需求变更了。
不仅麻烦,直接去修改已有的函数体,这种做法违背了我们的“开放封闭原则”;往一个函数体里塞这么多逻辑,违背了我们的“单一职责原则”。所以说这个事儿,越想越不能这么干。

  1. 我想一定会有同学质疑说为啥不把按钮抽成公共组件 Button,这样只需要在 Button 组件里修改一次逻辑就可以了。这种想法非常好。但注意,我们楼上的例子没有写组件直接写了 Button 标签是为了简化示例。事实上真要写组件的话,不同业务里必定有针对业务定制的不同 Button 组件,比如 MoreButton BeginButton等等,也是五花八门的,所以说我们仍会遇到同样的困境。

讲真,我想任何人去做这个需求的时候,其实都压根不想去关心它现有的业务逻辑是啥样的——你说这按钮的旧逻辑是我自己写的还好,理解成本不高;万一碰上是个离职同事写的,那阅读难度谁能预料呢?我不想接锅,我只是想对它已有的功能做个拓展,只关心拓展出来的那部分新功能如何实现,对不对?
程序员说:“我不想努力了,我想开挂”,于是便有了装饰器模式。

(3)装饰器模式初相见

为了不被已有的业务逻辑干扰,当务之急就是将旧逻辑与新逻辑分离,把旧逻辑抽出去

  1. // 将展示Modal的逻辑单独封装
  2. function openModal() {
  3. const modal = new Modal()
  4. modal.style.display = 'block'
  5. }

编写新逻辑:

  1. // 按钮文案修改逻辑
  2. function changeButtonText() {
  3. const btn = document.getElementById('open')
  4. btn.innerText = '快去登录'
  5. }
  6. // 按钮置灰逻辑
  7. function disableButton() {
  8. const btn = document.getElementById('open')
  9. btn.setAttribute("disabled", true)
  10. }
  11. // 新版本功能逻辑整合
  12. function changeButtonStatus() {
  13. changeButtonText()
  14. disableButton()
  15. }

然后把三个操作逐个添加open按钮的监听函数里:

  1. document.getElementById('open').addEventListener('click', function() {
  2. openModal()
  3. changeButtonStatus()
  4. })

如此一来,我们就实现了“只添加,不修改”的装饰器模式,使用changeButtonStatus的逻辑装饰了旧的按钮点击逻辑。以上是ES5中的实现,ES6中,我们可以以一种更加面向对象化的方式去写:

  1. // 定义打开按钮
  2. class OpenButton {
  3. // 点击后展示弹框(旧逻辑)
  4. onClick() {
  5. const modal = new Modal()
  6. modal.style.display = 'block'
  7. }
  8. }
  9. // 定义按钮对应的装饰器
  10. class Decorator {
  11. // 将按钮实例传入
  12. constructor(open_button) {
  13. this.open_button = open_button
  14. }
  15. onClick() {
  16. this.open_button.onClick()
  17. // “包装”了一层新逻辑
  18. this.changeButtonStatus()
  19. }
  20. changeButtonStatus() {
  21. this.changeButtonText()
  22. this.disableButton()
  23. }
  24. disableButton() {
  25. const btn = document.getElementById('open')
  26. btn.setAttribute("disabled", true)
  27. }
  28. changeButtonText() {
  29. const btn = document.getElementById('open')
  30. btn.innerText = '快去登录'
  31. }
  32. }
  33. const openButton = new OpenButton()
  34. const decorator = new Decorator(openButton)
  35. document.getElementById('open').addEventListener('click', function() {
  36. // openButton.onClick()
  37. // 此处可以分别尝试两个实例的onClick方法,验证装饰器是否生效
  38. decorator.onClick()
  39. })

大家这里需要特别关注一下 ES6 这个版本的实现,这里我们把按钮实例传给了 Decorator,以便于后续 Decorator 可以对它为所欲为进行逻辑的拓展。在 ES7 中,Decorator 作为一种语法被直接支持了,它的书写会变得更加简单,但背后的原理其实与此大同小异。

(4)值得关注的细节

单一职责原则

大家可能刚刚没来得及注意,按钮新逻辑中,文本修改&按钮置灰这两个变化,被我封装在了两个不同的方法里,并以组合的形式出现在了最终的目标方法changeButtonStatus里。这样做的目的是为了强化大家脑中的“单一职责”意识。将不同的职责分离,可以做到每个职责都能被灵活地复用;同时,不同职责之间无法相互干扰,不会出现因为修改了 A 逻辑而影响了 B 逻辑的狗血剧情。

9.结构型:装饰器模式—原理与案例

在 ES7 中,我们可以像写 python 一样通过一个@语法糖轻松地给一个类装上装饰器:

  1. // 装饰器函数,它的第一个参数是目标类
  2. function classDecorator(target) {
  3. target.hasDecorator = true
  4. return target
  5. }
  6. // 将装饰器“安装”到Button类上
  7. @classDecorator
  8. class Button {
  9. // Button类的相关逻辑
  10. }
  11. // 验证装饰器是否生效
  12. console.log('Button 是否被装饰了:', Button.hasDecorator)

也可以用同样的语法糖去装饰类里面的方法:

  1. // 具体的参数意义,在下个小节,这里大家先感知一下操作
  2. function funcDecorator(target, name, descriptor) {
  3. let originalMethod = descriptor.value
  4. descriptor.value = function() {
  5. console.log('我是Func的装饰器逻辑')
  6. return originalMethod.apply(this, arguments)
  7. }
  8. return descriptor
  9. }
  10. class Button {
  11. @funcDecorator
  12. onClick() {
  13. console.log('我是Func的原有逻辑')
  14. }
  15. }
  16. // 验证装饰器是否生效
  17. const button = new Button()
  18. button.onClick()

注:以上代码直接放进浏览器/Node 中运行会报错,因为浏览器和 Node 目前都不支持装饰器语法,需要大家安装 Babel 进行转码:

  1. npm install babel-preset-env babel-plugin-transform-decorators-legacy --save-dev
  2. // 编写配置文件.babelrc
  3. {
  4. "presets": ["env"],
  5. "plugins": ["transform-decorators-legacy"]
  6. }
  7. // 下载全局的 Babel 命令行工具用于转码:
  8. npm install babel-cli -g

执行完这波操作,我们首先是对目标文件进行转码,比如说你的目标文件叫做 test.js,想要把它转码后的结果输出到 babel_test.js,就可以这么写:
babel test.js —out-file babel_test.js
运行babel_test.js
babel_test.js
就可以看到你的装饰器是否生效啦~

(1)装饰器语法糖背后的故事

@decorator都帮我们做了些什么:

Part1:函数传参&调用

上一节使用 ES6 实现装饰器模式时曾经将按钮实例传给了 Decorator,以便于后续 Decorator 可以对它进行逻辑的拓展。这也正是装饰器的最最基本操作——定义装饰器函数,将被装饰者“交给”装饰器 —— 函数传参&调用。

类装饰器的参数

当我们给一个类添加装饰器时:

  1. function classDecorator(target) {
  2. // Button传给Decorator
  3. // target 就是被装饰的类本身
  4. target.hasDecorator = true
  5. return target
  6. }
  7. // 将装饰器“安装”到Button类上
  8. @classDecorator
  9. class Button {
  10. // Button类的相关逻辑
  11. }

方法装饰器的参数

而当我们给一个方法添加装饰器时:

  1. function funcDecorator(target, name, descriptor) {
  2. // 此处的 target 变成了Button.prototype,即类的原型对象
  3. let originalMethod = descriptor.value
  4. descriptor.value = function() {
  5. console.log('我是Func的装饰器逻辑')
  6. return originalMethod.apply(this, arguments)
  7. }
  8. return descriptor
  9. }
  10. class Button {
  11. // 修饰 onClik 其实是修饰它的实例,然后onClick会挂载在Button的原型上
  12. @funcDecorator
  13. onClick() {
  14. console.log('我是Func的原有逻辑')
  15. }
  16. }

此处的 target 变成了Button.prototype,即类的原型对象。这是因为 onClick 方法总是要依附其实例存在的,修饰 onClik 其实是修饰它的实例。

但我们的装饰器函数执行的时候,Button 实例还并不存在,装饰器只能去修饰 Button 类的原型对象。

装饰器函数调用的时机

装饰器函数执行的时候,Button 实例还并不存在。这是因为实例是在我们的代码运行时动态生成的,而装饰器函数则是在编译阶段就执行了。

所以说装饰器函数真正能触及到的,就只有类这个层面上的对象。

Part2:将“属性描述对象”交到你手里

在编写类装饰器时,我们一般获取一个target参数就足够了。但在编写方法装饰器时,我们往往需要至少三个参数:

  1. function funcDecorator(target, name, descriptor) {
  2. // name 修饰的目标属性属性名
  3. // descriptor 属性描述对象
  4. let originalMethod = descriptor.value
  5. descriptor.value = function() {
  6. console.log('我是Func的装饰器逻辑')
  7. return originalMethod.apply(this, arguments)
  8. }
  9. return descriptor
  10. }

参数 descriptor 是我们使用频率最高的一个参数,它的真面目就是“属性描述对象”(attributes object)。

Object.defineProperty(obj, prop, descriptor)
此处的descriptor和装饰器函数里的 descriptor 是一个东西,它是 JavaScript 提供的一个内部数据结构、一个对象,专门用来描述对象的属性。它由各种各样的属性描述符组成,这些描述符又分为数据描述符和存取描述符:

  • 数据描述符:包括 value(存放属性值,默认为默认为 undefined)、writable(表示属性值是否可改变,默认为true)、enumerable(表示属性是否可枚举,默认为 true)、configurable(属性是否可配置,默认为true)。
  • 存取描述符:包括 get 方法(访问属性时调用的方法,默认为 undefined),set(设置属性时调用的方法,默认为 undefined )

很明显,拿到了 descriptor,就相当于拿到了目标方法的控制权。
通过修改 descriptor,我们就可以对目标方法的逻辑进行拓展了~
在上文的示例中,我们通过 descriptor 获取到了原函数的函数体(originalMethod),把原函数推迟到了新逻辑(console)的后面去执行。装饰器就是这么回事儿,换汤不换药~

生产实践

装饰器在前端世界的应用十分广泛

React中的装饰器:HOC

高阶组件就是一个函数,且该函数接受一个组件作为参数,并返回一个新的组件。
HOC (Higher Order Component) 即高阶组件。它是装饰器模式在 React 中的实践,同时也是 React 应用中非常重要的一部分。通过编写高阶组件,我们可以充分复用现有逻辑,提高编码效率和代码的健壮性。

现在编写一个高阶组件,它的作用是把传入的组件丢进一个有红色边框的容器里(拓展其样式)。

  1. import React, { Component } from 'react'
  2. // 把传入的WrappedComponent组件结果处理后 再返回出去
  3. const BorderHoc = WrappedComponent => class extends Component {
  4. render() {
  5. return <div style={{ border: 'solid 1px red' }}>
  6. <WrappedComponent />
  7. </div>
  8. }
  9. }
  10. export default borderHoc

用它来装饰目标组件

  1. import React, { Component } from 'react'
  2. import BorderHoc from './BorderHoc'
  3. // BorderHoc装饰目标组件
  4. @BorderHoc
  5. class TargetComponent extends React.Component {
  6. render() {
  7. // 目标组件具体的业务逻辑
  8. }
  9. }
  10. // export出去的其实是一个被包裹后的组件
  11. export default TargetComponent

高阶组件从实现层面来看其实就是上文我们提到的类装饰器。在高阶组件的辅助下,我们不必因为一个小小的拓展而大费周折地编写新组件或者把一个新逻辑重写 N 多次,只需要轻轻 @ 一下装饰器即可。

使用装饰器改写 Redux connect

Redux 是热门的状态管理工具。在 React 中,当我们想要引入 Redux 时,通常需要调用 connect 方法来把状态和组件绑在一起:

  1. import React, { Component } from 'react'
  2. import { connect } from 'react-redux'
  3. import { bindActionCreators } from 'redux'
  4. import action from './action.js'
  5. class App extends Component {
  6. render() {
  7. // App的业务逻辑
  8. }
  9. }
  10. function mapStateToProps(state) {
  11. // 假设App的状态对应状态树上的app节点
  12. return state.app
  13. }
  14. function mapDispatchToProps(dispatch) {
  15. // 这段看不懂也没关系,下面会有解释。重点理解connect的调用即可
  16. return bindActionCreators(action, dispatch)
  17. }
  18. // App组件与Redux绑在一起
  19. export default connect(mapStateToProps, mapDispatchToProps)(App)

这里给没用过 redux 的同学解释一下 connect 的两个入参:
mapStateToProps 是一个函数,它可以建立组件和状态之间的映射关系;
mapDispatchToProps也是一个函数,它用于建立组件和store.dispatch的关系,使组件具备通过 dispatch 来派发状态的能力。

总而言之,我们调用 connect 可以返回一个具有装饰作用的函数,这个函数可以接收一 个React 组件作为参数,使这个目标组件和 Redux 结合、具备 Redux 提供的数据和能力。既然有装饰作用,既然是能力的拓展,那么就一定能用装饰器来改写:
把 connect 抽出来:

  1. import { connect } from 'react-redux'
  2. import { bindActionCreators } from 'redux'
  3. import action from './action.js'
  4. function mapStateToProps(state) {
  5. return state.app
  6. }
  7. function mapDispatchToProps(dispatch) {
  8. return bindActionCreators(action, dispatch)
  9. }
  10. // connect调用后的结果作为一个装饰器导出
  11. export default connect(mapStateToProps, mapDispatchToProps)

在组件文件里引入connect:

  1. import React, { Component } from 'react'
  2. import connect from './connect.js'
  3. @connect
  4. export default class App extends Component {
  5. render() {
  6. // App的业务逻辑
  7. }
  8. }

这样一来,我们的代码结构是不是清晰了很多?可维护性、可读性都上升了一个level,令人赏心悦目~

Tips: 回忆一下上面一个小节的讲解,对号入座看一看,connect装饰器从实现和调用方式上来看,是不是同时也是一个高阶组件呢? 是的~传入函数 返回函数~

优质的源码阅读材料——core-decorators

前面都在教大家怎么写装饰器模式,这里来聊聊怎么用好装饰器模式。
装饰器模式的优势在于其极强的灵活性和可复用性——它本质上是一个函数,而且往往不依赖于任何逻辑而存在。这一点提醒了我们,当我们需要用到某个反复出现的拓展逻辑时,比起自己闷头搞,不如去看一看团队(社区)里有没有现成的实现,如果有,那么贯彻“拿来主义”,直接@就可以了。所以说装饰器模式是个好同志,它可以帮我们省掉大量复制粘贴的时间。
这里就要给大家推荐一个非常赞的装饰模式库 —— core-decorators。core-decorators 帮我们实现好了一些使用频率较高的装饰器,比如@readonly(使目标属性只读)、@deprecate(在控制台输出警告,提示用户某个指定的方法已被废除)等等等等。这里强烈建议大家把 core-decorators 作为自己的源码阅读材料,你能收获的或许比你想象中更多~

10.结构型:适配器模式—兼容

适配器模式通过把一个类的接口变换成客户端所期待的另一种接口,可以帮我们解决不兼容的问题。
设计模式告诉我这种实际接口与目标接口不匹配的尴尬可以用一个叫适配器的东西来化解。

(1)兼容接口就是一把梭——适配器的业务场景

大家知道我们现在有一个非常好用异步方案叫fetch,它的写法比ajax优雅很多。为了能更好地使用fetch,他封装了一个基于fetch的http方法库:

  1. export default class HttpUtils {
  2. // get方法
  3. static get(url) {
  4. return new Promise((resolve, reject) => {
  5. // 调用fetch
  6. fetch(url)
  7. .then(response => response.json())
  8. .then(result => {
  9. resolve(result)
  10. })
  11. .catch(error => {
  12. reject(error)
  13. })
  14. })
  15. }
  16. // post方法,dataobject形式传入
  17. static post(url, data) {
  18. return new Promise((resolve, reject) => {
  19. // 调用fetch
  20. fetch(url, {
  21. method: 'POST',
  22. headers: {
  23. Accept: 'application/json',
  24. 'Content-Type': 'application/x-www-form-urlencoded'
  25. },
  26. // object类型的数据格式化为合法的body参数
  27. body: this.changeData(data)
  28. })
  29. .then(response => response.json())
  30. .then(result => {
  31. resolve(result)
  32. })
  33. .catch(error => {
  34. reject(error)
  35. })
  36. })
  37. }
  38. // body请求体的格式化方法
  39. static changeData(obj) {
  40. var prop,
  41. str = ''
  42. var i = 0
  43. for (prop in obj) {
  44. if (!prop) {
  45. return
  46. }
  47. if (i == 0) {
  48. str += prop + '=' + obj[prop]
  49. } else {
  50. str += '&' + prop + '=' + obj[prop]
  51. }
  52. i++
  53. }
  54. return str
  55. }
  56. }

使用:

  1. // 定义目标url地址
  2. const URL = "xxxxx"
  3. // 定义post入参
  4. const params = {
  5. ...
  6. }
  7. // 发起post请求
  8. const postResponse = await HttpUtils.post(URL,params) || {}
  9. // 发起get请求
  10. const getResponse = await HttpUtils.get(URL) || {}

现在要把以下古老项目的代码兼容上面的fetch方式去使用

  1. function Ajax(type, url, data, success, failed){
  2. // 创建ajax对象
  3. var xhr = null;
  4. if(window.XMLHttpRequest){
  5. xhr = new XMLHttpRequest();
  6. } else {
  7. xhr = new ActiveXObject('Microsoft.XMLHTTP')
  8. }
  9. ...(此处省略一系列的业务逻辑细节)
  10. var type = type.toUpperCase();
  11. // 识别请求类型
  12. if(type == 'GET'){
  13. if(data){
  14. xhr.open('GET', url + '?' + data, true); //如果有数据就拼接
  15. }
  16. // 发送get请求
  17. xhr.send();
  18. } else if(type == 'POST'){
  19. xhr.open('POST', url, true);
  20. // 如果需要像 html 表单那样 POST 数据,使用 setRequestHeader() 来添加 http 头。
  21. xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
  22. // 发送post请求
  23. xhr.send(data);
  24. }
  25. // 处理返回数据
  26. xhr.onreadystatechange = function(){
  27. if(xhr.readyState == 4){
  28. if(xhr.status == 200){
  29. success(xhr.responseText);
  30. } else {
  31. if(failed){
  32. failed(xhr.status);
  33. }
  34. }
  35. }
  36. }
  37. }

这远古项目的调用方法:

  1. // 发送get请求
  2. Ajax('get', url地址, post入参, function(data){
  3. // 成功的回调逻辑
  4. }, function(error){
  5. // 失败的回调逻辑
  6. })

不仅接口名不同,入参方式也不一样,这手动改要改到何年何日呢?
可以使用抹平差异的适配器模式我们只需要在引入接口时进行一次适配,便可轻松地 cover 掉业务里可能会有的多次调用(具体的解析在注释里):

  1. // Ajax适配器函数,入参与旧接口保持一致
  2. async function AjaxAdapter(type, url, data, success, failed) {
  3. const type = type.toUpperCase()
  4. let result
  5. try {
  6. // 实际的请求全部由新接口发起
  7. if(type === 'GET') {
  8. result = await HttpUtils.get(url) || {}
  9. } else if(type === 'POST') {
  10. result = await HttpUtils.post(url, data) || {}
  11. }
  12. // 假设请求成功对应的状态码是1
  13. result.statusCode === 1 && success ? success(result) : failed(result.statusCode)
  14. } catch(error) {
  15. // 捕捉网络错误
  16. if(failed){
  17. failed(error.statusCode);
  18. }
  19. }
  20. }
  21. // 用适配器适配旧的Ajax方法
  22. async function Ajax(type, url, data, success, failed) {
  23. await AjaxAdapter(type, url, data, success, failed)
  24. }

如此一来,我们只需要编写一个适配器函数AjaxAdapter,并用适配器去承接旧接口的参数,就可以实现新旧接口的无缝衔接了~

(2)生产实践:axios中的适配器

axios用到了我们的适配器模式,它的兼容方案值得我们学习和借鉴。
在使用axios时,作为用户我们只需要掌握以下面三个最常用的接口为代表的一套api:

  1. // Make a request for a user with a given ID
  2. axios.get('/user?ID=12345')
  3. .then(function (response) {
  4. // handle success
  5. console.log(response);
  6. })
  7. .catch(function (error) {
  8. // handle error
  9. console.log(error);
  10. })
  11. .then(function () {
  12. // always executed
  13. })
  14. axios.post('/user', {
  15. firstName: 'Fred',
  16. lastName: 'Flintstone'
  17. })
  18. .then(function (response) {
  19. console.log(response);
  20. })
  21. .catch(function (error) {
  22. console.log(error);
  23. });
  24. axios({
  25. method: 'post',
  26. url: '/user/12345',
  27. data: {
  28. firstName: 'Fred',
  29. lastName: 'Flintstone'
  30. }
  31. })

便可轻松地发起各种姿势的网络请求,而不用去关心底层的实现细节。
除了简明优雅的api之外,axios 强大的地方还在于,它不仅仅是一个局限于浏览器端的库。在Node环境下,我们尝试调用上面的 api,会发现它照样好使 —— axios 完美地抹平了两种环境下api的调用差异,靠的正是对适配器模式的灵活运用。
axios 的核心逻辑中,我们可以注意到实际上派发请求的是 dispatchRequest 方法。该方法内部其实主要做了两件事:

  1. 数据转换,转换请求体/响应体,可以理解为数据层面的适配;
  2. 调用适配器。

调用适配器的逻辑如下:

  1. // 若用户未手动配置适配器,则使用默认的适配器
  2. var adapter = config.adapter || defaults.adapter;
  3. // dispatchRequest方法的末尾调用的是适配器方法
  4. return adapter(config).then(function onAdapterResolution(response) {
  5. // 请求成功的回调
  6. throwIfCancellationRequested(config);
  7. // 转换响应体
  8. response.data = transformData(
  9. response.data,
  10. response.headers,
  11. config.transformResponse
  12. );
  13. return response;
  14. },
  15. function onAdapterRejection(reason) {
  16. // 请求失败的回调
  17. if (!isCancel(reason)) {
  18. throwIfCancellationRequested(config);
  19. // 转换响应体
  20. if (reason && reason.response) {
  21. reason.response.data = transformData(
  22. reason.response.data,
  23. reason.response.headers,
  24. config.transformResponse
  25. );
  26. }
  27. }
  28. return Promise.reject(reason);
  29. });

手动配置适配器允许我们自定义处理请求,主要目的是为了使测试更轻松。
实际开发中,我们使用默认适配器的频率更高。默认适配器在axios/lib/default.js里是通过getDefaultAdapter方法来获取的:

  1. function getDefaultAdapter() {
  2. var adapter;
  3. // 判断当前是否是node环境
  4. if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
  5. // 如果是node环境,调用node专属的http适配器
  6. adapter = require('./adapters/http');
  7. } else if (typeof XMLHttpRequest !== 'undefined') {
  8. // 如果是浏览器环境,调用基于xhr的适配器
  9. adapter = require('./adapters/xhr');
  10. }
  11. return adapter;
  12. }

我们再来看看 Node 的 http 适配器和 xhr 适配器大概长啥样:

  1. //http 适配器:
  2. module.exports = function httpAdapter(config) {
  3. return new Promise(function dispatchHttpRequest(resolvePromise, rejectPromise) {
  4. // 具体逻辑
  5. }
  6. }
  1. //xhr 适配器:
  2. module.exports = function xhrAdapter(config) {
  3. return new Promise(function dispatchXhrRequest(resolve, reject) {
  4. // 具体逻辑
  5. }
  6. }

注意两个事儿:

  • 两个适配器的入参都是 config;
  • 两个适配器的出参都是一个 Promise。

Tips:要是仔细读了源码,会发现两个适配器中的 Promise 的内部结构也是如出一辙。
这么一来,通过 axios 发起跨平台的网络请求,不仅调用的接口名是同一个,连入参、出参的格式都只需要掌握同一套。这导致它的学习成本非常低,开发者看了文档就能上手;同时因为足够简单,在使用的过程中也不容易出错,带来了极佳的用户体验,axios 也因此越来越流行。
这正是一个好的适配器的自我修养——把变化留给自己,把统一留给用户。
在此处,所有关于 http 模块、关于 xhr 的实现细节,全部被 Adapter 封装进了自己复杂的底层逻辑里,暴露给用户的都是十分简单的统一的东西——统一的接口,统一的入参,统一的出参,统一的规则。用起来就是一个字 —— 爽!

(3)小结

本节我们除了针对适配器的原理、实践及应用场景进行讨论之外,还花了不少力气来讲 axios。希望大家都能去读“纸的背面”。这个“纸的背面”不仅仅是说代码之外的东西,它也可以是一些超越这本书的东西

开卷有益,源码是非常好的学习材料,它能教会你的东西,比你想象中多得多。
适配器模式的思想可以说是遍地开花,稍微多看几个库,你会发现不仅 axios 在用适配器,其它库也在用。

11.结构型:代理模式

(一)定义

代理模式,式如其名——在某些情况下,出于种种考虑/限制,一个对象不能直接访问另一个对象,需要一个第三者(代理)牵线搭桥从而间接达到访问目的,这样的模式就是代理模式。

比如大家耳熟能详的科学上网,就是代理模式的典型案例。

(1)VPN(虚拟专用网络)

科学上网,就是咱们常说的 VPN(虚拟专用网络)。
正常情况下,我们尝试去访问 Google.com,Chrome会给你一个这样的提示:
七 【设计模式】 - 图6
这是为啥呢?这就要从网络请求的整个流程说起了。一般情况下,当我们访问一个 url 的时候,会发生下图的过程:
七 【设计模式】 - 图7
为了屏蔽某些网站,一股神秘的东方力量会作用于你的 DNS 解析过程,告诉它:“你不能解析出xxx.xxx.xxx.xxx(某个特殊ip)的地址”。而我们的 Google.com,不幸地出现在了这串被诅咒的 ip 地址里,于是你的 DNS 会告诉你:“对不起,我查不到”。
但有时候,一部分人为了搞学习,通过访问VPN,是可以间接访问到 Google.com 的。这背后,就是代理模式在给力。在使用VPN时,我们的访问过程是这样的:
七 【设计模式】 - 图8
没错,比起常规的访问过程,多出了一个第三方 —— 代理服务器。这个第三方的 ip 地址,不在被禁用的那批 ip 地址之列,我们可以顺利访问到这台服务器。而这台服务器的 DNS 解析过程,没有被施加咒语,所以它是可以顺利访问 Google.com 的。代理服务器在请求到 Google.com 后,将响应体转发给你,使你得以间接地访问到目标网址 —— 像这种第三方代替我们访问目标对象的模式,就是代理模式。

(2)婚姻介绍所的故事

通过第三方(婚介所)间接获取对方的一些信息,他能够获取到的信息和权限,取决于第三方愿意给他什么——这不就是典型的代理模式吗?

(3)前置知识: ES6中的Proxy

在 ES6 中,提供了专门以代理角色出现的代理器 —— Proxy。它的基本用法如下:

  1. const proxy = new Proxy(obj, handler)

第一个参数是我们的目标对象,也就是上文中的“未知妹子”。
第二个参数handler 也是一个对象,用来定义代理的行为,相当于上文中的“婚介所”。
当我们通过 proxy 去访问目标对象的时候,handler会对我们的行为作一层拦截,我们的每次访问都需要经过 handler 这个第三方。

(4)“婚介所”的实现

未知妹子的个人信息,刚问了下我们已经注册了 VIP 的同事哥,大致如下:

  1. // 未知妹子
  2. const girl = {
  3. // 姓名
  4. name: '小美',
  5. // 自我介绍
  6. aboutMe: '...'(大家自行脑补吧)
  7. // 年龄
  8. age: 24,
  9. // 职业
  10. career: 'teacher',
  11. // 假头像
  12. fakeAvatar: 'xxxx'(新垣结衣的图片地址)
  13. // 真实头像
  14. avatar: 'xxxx'(自己的照片地址),
  15. // 手机号
  16. phone: 123456,
  17. }

要想 get 这些信息,平台要考验一下你的诚意了 —— 首先,你是不是已经通过了实名审核?如果通过实名审核,那么你可以查看一些相对私密的信息(年龄、职业)。然后,你是不是 VIP ?只有 VIP 可以查看真实照片和联系方式。满足了这两个判定条件,你才可以顺利访问到别人的全部私人信息,不然,就劝退你提醒你去完成认证和VIP购买再来。

  1. // 普通私密信息
  2. const baseInfo = ['age', 'career']
  3. // 最私密信息
  4. const privateInfo = ['avatar', 'phone']
  5. // 用户(同事A)对象实例
  6. const user = {
  7. ...(一些必要的个人信息)
  8. isValidated: true,
  9. isVIP: false,
  10. }
  11. // 掘金婚介所登场了
  12. const JuejinLovers = new Proxy(girl, {
  13. get: function(girl, key) {
  14. if(baseInfo.indexOf(key)!==-1 && !user.isValidated) {
  15. alert('您还没有完成验证哦')
  16. return
  17. }
  18. //...(此处省略其它有的没的各种校验逻辑)
  19. // 此处我们认为只有验证过的用户才可以购买VIP
  20. if(user.isValidated && privateInfo.indexOf(key) && !user.isVIP) {
  21. alert('只有VIP才可以查看该信息哦')
  22. return
  23. }
  24. }
  25. })

以上主要是 getter 层面的拦截。假设我们还允许会员间互送礼物,每个会员可以告知婚介所自己愿意接受的礼物的价格下限,我们还可以作 setter 层面的拦截。:

  1. // 规定礼物的数据结构由typevalue组成
  2. const present = {
  3. type: '巧克力',
  4. value: 60,
  5. }
  6. // 为用户增开presents字段存储礼物
  7. const girl = {
  8. // 姓名
  9. name: '小美',
  10. // 自我介绍
  11. aboutMe: '...'(大家自行脑补吧)
  12. // 年龄
  13. age: 24,
  14. // 职业
  15. career: 'teacher',
  16. // 假头像
  17. fakeAvatar: 'xxxx'(新垣结衣的图片地址)
  18. // 真实头像
  19. avatar: 'xxxx'(自己的照片地址),
  20. // 手机号
  21. phone: 123456,
  22. // 礼物数组
  23. presents: [],
  24. // 拒收50块以下的礼物
  25. bottomValue: 50,
  26. // 记录最近一次收到的礼物
  27. lastPresent: present,
  28. }
  29. // 掘金婚介所推出了小礼物功能
  30. const JuejinLovers = new Proxy(girl, {
  31. get: function(girl, key) {
  32. if(baseInfo.indexOf(key)!==-1 && !user.isValidated) {
  33. alert('您还没有完成验证哦')
  34. return
  35. }
  36. //...(此处省略其它有的没的各种校验逻辑)
  37. // 此处我们认为只有验证过的用户才可以购买VIP
  38. if(user.isValidated && privateInfo.indexOf(key)!==-1 && !user.isVIP) {
  39. alert('只有VIP才可以查看该信息哦')
  40. return
  41. }
  42. }
  43. set: function(girl, key, val) {
  44. // 最近一次送来的礼物会尝试赋值给lastPresent字段
  45. if(key === 'lastPresent') {
  46. if(val.value < girl.bottomValue) {
  47. alert('sorry,您的礼物被拒收了')
  48. return
  49. }
  50. // 如果没有拒收,则赋值成功,同时并入presents数组
  51. girl.lastPresent = val
  52. girl.presents = [...girl.presents, val]
  53. }
  54. }
  55. })

看来婚介所这条路,真是不太好走。
不过如果认为代理模式的本领仅仅是开个婚介所这么简单,那就太小瞧它了。代理模式在前端领域一直是一种应用十分广泛的设计模式

(二)应用解析

本节我们选取业务开发中最常见的四种代理类型:事件代理、虚拟代理、缓存代理和保护代理来进行讲解。
在实际开发中,代理模式的核心操作都是死的,套路也是死的——正是这种极强的规律性带来了极高的性价比。

(1)事件代理

事件代理,可能是代理模式最常见的一种应用方式,也是一道实打实的高频面试题。它的场景是一个父元素下有多个子元素,像这样:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <meta http-equiv="X-UA-Compatible" content="ie=edge">
  7. <title>事件代理</title>
  8. </head>
  9. <body>
  10. <div id="father">
  11. <a href="#">链接1号</a>
  12. <a href="#">链接2号</a>
  13. <a href="#">链接3号</a>
  14. <a href="#">链接4号</a>
  15. <a href="#">链接5号</a>
  16. <a href="#">链接6号</a>
  17. </div>
  18. </body>
  19. </html>

我们现在的需求是,希望鼠标点击每个 a 标签,都可以弹出“我是xxx”这样的提示。如果给每个a标签都注册一个事件的话性能较差,如果我们的 a 标签进一步增多,那么性能的开销会更大。

  1. // 假如不用代理模式,我们将循环安装监听函数
  2. const aNodes = document.getElementById('father').getElementsByTagName('a')
  3. const aLength = aNodes.length
  4. for(let i=0;i<aLength;i++) {
  5. aNodes[i].addEventListener('click', function(e) {
  6. e.preventDefault()
  7. alert(`我是${aNodes[i].innerText}`)
  8. })
  9. }

考虑到事件本身具有“冒泡”的特性,当我们点击 a 元素时,点击事件会“冒泡”到父元素 div 上,从而被监听到。如此一来,点击事件的监听函数只需要在 div 元素上被绑定一次即可,而不需要在子元素上被绑定 N 次——这种做法就是事件代理,它可以很大程度上提高我们代码的性能。

用代理模式实现多个子元素的事件监听,代码会简单很多:

  1. // 获取父元素
  2. const father = document.getElementById('father')
  3. // 给父元素安装一次监听函数
  4. father.addEventListener('click', function(e) {
  5. // 识别是否是目标子元素
  6. if(e.target.tagName === 'A') {
  7. // 以下是监听函数的函数体
  8. e.preventDefault()
  9. alert(`我是${e.target.innerText}`)
  10. }
  11. } )

在这种做法下,我们的点击操作并不会直接触及目标子元素,而是由父元素对事件进行处理和分发、间接地将其作用于子元素,因此这种操作从模式上划分属于代理模式。

(2)虚拟代理-懒加载,预加载

在《性能小册的Lazy-Load小节》,我们介绍了懒加载这种技术,此处强烈建议大家,尤其是近期有校招或跳槽需求的同学,转过头去复习一下这个小节,说不定下一次的面试题里就有原题,这点在该小节的评论区已经有同学佐证了。
我们此处简单地给大家描述一下懒加载是个什么东西:它是针对图片加载时机的优化:在一些图片量比较大的网站,比如电商网站首页,或者团购网站、小游戏首页等。如果我们尝试在用户打开页面的时候,就把所有的图片资源加载完毕,那么很可能会造成白屏、卡顿等现象。
此时我们会采取“先占位、后加载”的方式来展示图片 —— 在元素露出之前,我们给它一个 div 作占位,当它滚动到可视区域内时,再即时地去加载真实的图片资源,这样做既减轻了性能压力、又保住了用户体验。

除了图片懒加载,还有一种操作叫图片预加载。预加载主要是为了避免网络不好、或者图片太大时,页面长时间给用户留白的尴尬。常见的操作是先让这个 img 标签展示一个占位图,然后创建一个 Image 实例,让这个 Image 实例的 src 指向真实的目标图片地址、观察该 Image 实例的加载情况 —— 当其对应的真实图片加载完毕后,即已经有了该图片的缓存内容,再将 DOM 上的 img 元素的 src 指向真实的目标图片地址。此时我们直接去取了目标图片的缓存,所以展示速度会非常快,从占位图到目标图片的时间差会非常小、小到用户注意不到,这样体验就会非常好了。
上面的思路,我们可以不假思索地实现如下

  1. class PreLoadImage {
  2. // 占位图的url地址
  3. static LOADING_URL = 'xxxxxx'
  4. constructor(imgNode) {
  5. // 获取该实例对应的DOM节点
  6. this.imgNode = imgNode
  7. }
  8. // 该方法用于设置真实的图片地址
  9. setSrc(targetUrl) {
  10. // img节点初始化时展示的是一个占位图
  11. this.imgNode.src = PreLoadImage.LOADING_URL
  12. // 创建一个帮我们加载图片的Image实例
  13. const image = new Image()
  14. // 监听目标图片加载的情况,完成时再将DOM上的img节点的src属性设置为目标图片的url
  15. image.onload = () => {
  16. this.imgNode.src = targetUrl
  17. }
  18. // 设置src属性,Image实例开始加载图片
  19. image.src = targetUrl
  20. }
  21. }

这个 PreLoadImage 违反了我们设计原则中的单一职责原则
PreLoadImage 不仅要负责图片的加载,还要负责 DOM 层面的操作(img 节点的初始化和后续的改变)。这样一来,就出现了两个可能导致这个类发生变化的原因
好的做法是将两个逻辑分离,让 PreLoadImage 专心去做 DOM 层面的事情(真实 DOM 节点的获取、img 节点的链接设置),再找一个对象来专门来帮我们搞加载——这两个对象之间缺个媒婆,这媒婆非代理器不可:

  1. // DOM 层面的事情
  2. class PreLoadImage {
  3. constructor(imgNode) {
  4. // 获取真实的DOM节点
  5. this.imgNode = imgNode
  6. }
  7. // 操作img节点的src属性
  8. setSrc(imgUrl) {
  9. this.imgNode.src = imgUrl
  10. }
  11. }
  12. // 负责加载
  13. class ProxyImage {
  14. // 占位图的url地址
  15. static LOADING_URL = 'xxxxxx'
  16. constructor(targetImage) {
  17. // 目标Image,即PreLoadImage实例
  18. this.targetImage = targetImage
  19. }
  20. // 该方法主要操作虚拟Image,完成加载
  21. setSrc(targetUrl) {
  22. // 真实img节点初始化时展示的是一个占位图
  23. this.targetImage.setSrc(ProxyImage.LOADING_URL)
  24. // 创建一个帮我们加载图片的虚拟Image实例
  25. const virtualImage = new Image()
  26. // 监听目标图片加载的情况,完成时再将DOM上的真实img节点的src属性设置为目标图片的url
  27. virtualImage.onload = () => {
  28. this.targetImage.setSrc(targetUrl)
  29. }
  30. // 设置src属性,虚拟Image实例开始加载图片
  31. virtualImage.src = targetUrl
  32. }
  33. }

ProxyImage 帮我们调度了预加载相关的工作,我们可以通过 ProxyImage 这个代理,实现对真实 img 节点的间接访问,并得到我们想要的效果。

在这个实例中,virtualImage 这个对象是一个“幕后英雄”,它始终存在于 JavaScript 世界中、代替真实 DOM 发起了图片加载请求、完成了图片加载工作,却从未在渲染层面抛头露面。因此这种模式被称为“虚拟代理”模式。

(3)缓存代理

缓存代理比较好理解,它应用于一些计算量较大的场景里。在这种场景下,我们需要“用空间换时间”——当我们需要用到某个已经计算过的值的时候,不想再耗时进行二次计算,而是希望能从内存里去取出现成的计算结果。这种场景下,就需要一个代理来帮我们在进行计算的同时,进行计算结果的缓存了。
一个比较典型的例子,是对传入的参数进行求和:

  1. // addAll方法会对你传入的所有参数做求和操作
  2. const addAll = function() {
  3. console.log('进行了一次新计算')
  4. let result = 0
  5. const len = arguments.length
  6. for(let i = 0; i < len; i++) {
  7. result += arguments[i]
  8. }
  9. return result
  10. }
  11. // 为求和方法创建代理
  12. const proxyAddAll = (function(){
  13. // 求和结果的缓存池
  14. const resultCache = {}
  15. return function() {
  16. // 将入参转化为一个唯一的入参字符串
  17. const args = Array.prototype.join.call(arguments, ',')
  18. // 检查本次入参是否有对应的计算结果
  19. if(args in resultCache) {
  20. // 如果有,则返回缓存池里现成的结果
  21. return resultCache[args]
  22. }
  23. return resultCache[args] = addAll(...arguments)
  24. }
  25. })()

我们把这个方法丢进控制台,尝试同一套入参两次,结果喜人:
七 【设计模式】 - 图9
我们发现 proxyAddAll 针对重复的入参只会计算一次,这将大大节省计算过程中的时间开销。现在我们有 6 个入参,可能还看不出来,当我们针对大量入参、做反复计算时,缓存代理的优势将得到更充分的凸显。

(4)保护代理

保护代理,其实在我们上个小节大家就见识过了。

开婚介所的时候,为了保护用户的私人信息,我们会在同事哥访问小美的年龄的时候,去校验同事哥是否已经通过了我们的实名认证;所谓“保护代理”,就是在访问层面做文章,在 getter 和 setter 函数里去进行校验和拦截,确保一部分变量是安全的。

值得一提的是,上节中我们提到的 Proxy,它本身就是为拦截而生的,所以我们目前实现保护代理时,考虑的首要方案就是 ES6 中的 Proxy。

(5)小结

代理模式行文至此,相信大家都已经做到了心中有数。在本节,我们看到代理模式的目的是十分多样化的,既可以是为了加强控制、拓展功能、提高性能,也可以仅仅是为了优化我们的代码结构、实现功能的解耦。无论是出于什么目的,这种模式的套路就只有一个—— A 不能直接访问 B,A 需要借助一个帮手来访问 B,这个帮手就是代理器。需要代理器出面解决的问题,就是代理模式发光发热的应用场景。

12.行为型:策略模式—重构小能手,拆分胖逻辑

(1)前言

策略模式和状态模式属于本书”彩蛋“性质的附加小节。这两种模式理解难度都不大,在面试中也几乎没有什么权重,但是却对大家培养良好的编码习惯和重构意识却大有裨益。针对这两种模式,大家了解、会用即可,不建议大家死磕。

(2)先来看一个真实场景

现在要做差异化询价。啥是差异化询价?就是说同一个商品,我通过在后台给它设置不同的价格类型,可以让它展示不同的价格。具体的逻辑如下:

  • 当价格类型为“预售价”时,满 100 - 20,不满 100 打 9 折
  • 当价格类型为“大促价”时,满 100 - 30,不满 100 打 8 折
  • 当价格类型为“返场价”时,满 200 - 50,不叠加
  • 当价格类型为“尝鲜价”时,直接打 5 折

李雷扫了一眼 prd,立刻来了主意。他首先将四种价格做了标签化:

  1. 预售价 - pre
  2. 大促价 - onSale
  3. 返场价 - back
  4. 尝鲜价 - fresh

接下来李雷仔细研读了 prd 的内容,作为资深 if-else 侠,他三下五除二就写出一套功能完备的代码:

  1. // 询价方法,接受价格标签和原价为入参
  2. function askPrice(tag, originPrice) {
  3. // 处理预热价
  4. if(tag === 'pre') {
  5. if(originPrice >= 100) {
  6. return originPrice - 20
  7. }
  8. return originPrice * 0.9
  9. }
  10. // 处理大促价
  11. if(tag === 'onSale') {
  12. if(originPrice >= 100) {
  13. return originPrice - 30
  14. }
  15. return originPrice * 0.8
  16. }
  17. // 处理返场价
  18. if(tag === 'back') {
  19. if(originPrice >= 200) {
  20. return originPrice - 50
  21. }
  22. return originPrice
  23. }
  24. // 处理尝鲜价
  25. if(tag === 'fresh') {
  26. return originPrice * 0.5
  27. }
  28. }

(3)if-else 侠,人人喊打

if-else这么写代码会带来什么后果:

  • 首先,它违背了“单一功能”原则。一个 function 里面,它竟然处理了四坨逻辑——这个函数的逻辑太胖了!这样会带来什么样的糟糕后果,笔者在前面的小节中已经 BB 过很多次了:比如说万一其中一行代码出了 Bug,那么整个询价逻辑都会崩坏;与此同时出了 Bug 你很难定位到底是哪个代码块坏了事;再比如说单个能力很难被抽离复用等等等等。相信跟着我一路学下来的各位,也已经在重重实战中对胖逻辑的恶劣影响有了切身的体会。总之,见到胖逻辑,我们的第一反应,就是一个字——拆!
  • 不仅如此,它还违背了“开放封闭”原则。假如有一天韩梅梅再次找到李雷,要他加一个满 100 - 50 的“新人价”怎么办?他只能继续 if-else:

    1. function askPrice(tag, originPrice) {
    2. // 处理预热价
    3. if(tag === 'pre') {
    4. if(originPrice >= 100) {
    5. return originPrice - 20
    6. }
    7. return originPrice * 0.9
    8. }
    9. // 处理大促价
    10. if(tag === 'onSale') {
    11. if(originPrice >= 100) {
    12. return originPrice - 30
    13. }
    14. return originPrice * 0.8
    15. }
    16. // 处理返场价
    17. if(tag === 'back') {
    18. if(originPrice >= 200) {
    19. return originPrice - 50
    20. }
    21. return originPrice
    22. }
    23. // 处理尝鲜价
    24. if(tag === 'fresh') {
    25. return originPrice * 0.5
    26. }
    27. // 处理新人价
    28. if(tag === 'newUser') {
    29. if(originPrice >= 100) {
    30. return originPrice - 50
    31. }
    32. return originPrice
    33. }
    34. }

    没错,李雷灰溜溜地跑去改了 askPrice 函数!随后他恬不知耻地徐徐转头,对背后的测试同学说:哥,我改了询价函数,麻烦你帮我把整个询价逻辑回归一下。测试同学莞尔一笑, 心中早已有无数头羊驼在狂奔。他强忍着周末加班的悲痛,做完了这漫长而不必要的回归测试,随后默默点击了同事系统里的举报按钮对李雷说:哥,求你学学设计模式吧!!

    (4)重构询价逻辑

    现在我们基于我们已经学过的设计模式思想,一点一点改造掉这个臃肿的 askPrice。

    (1)单一功能改造

    首先,我们赶紧把四种询价逻辑提出来,让它们各自为政: ```json // 处理预热价 function prePrice(originPrice) { if(originPrice >= 100) { return originPrice - 20 } return originPrice * 0.9 }

// 处理大促价 function onSalePrice(originPrice) { if(originPrice >= 100) { return originPrice - 30 } return originPrice * 0.8 }

// 处理返场价 function backPrice(originPrice) { if(originPrice >= 200) { return originPrice - 50 } return originPrice }

// 处理尝鲜价 function freshPrice(originPrice) { return originPrice * 0.5 }

// 根据不同价格标签 返回不同价格 function askPrice(tag, originPrice) { // 处理预热价 if(tag === ‘pre’) { return prePrice(originPrice) } // 处理大促价 if(tag === ‘onSale’) { return onSalePrice(originPrice) }

// 处理返场价 if(tag === ‘back’) { return backPrice(originPrice) }

// 处理尝鲜价 if(tag === ‘fresh’) { return freshPrice(originPrice) } }

  1. OK,我们现在至少做到了一个函数只做一件事。现在每个函数都有了自己明确的、单一的分工:
  2. ```json
  3. prePrice - 处理预热价
  4. onSalePrice - 处理大促价
  5. backPrice - 处理返场价
  6. freshPrice - 处理尝鲜价
  7. askPrice - 分发询价逻辑

如此一来,我们在遇到 Bug 时,就可以做到“头痛医头,脚痛医脚”

同时,如果我在另一个函数里也想使用某个询价能力,比如说我想询预热价,那我直接把 prePrice 这个函数拿去调用就是了,而不必在 askPrice 肥胖的身躯里苦苦寻觅、然后掏出这块逻辑、最后再复制粘贴到另一个函数去——更何况万一哪天 askPrice 里的预热价逻辑改了,你还得再复制粘贴一次,扎心啊老铁!
到这里,在单一功能原则的指引下,我们已经解决了一半的问题。

我们现在来捋一下,其实这个询价逻辑整体上来看只有两个关键动作:
询价逻辑的分发 ——> 询价逻辑的执行

在改造的第一步,我们已经把“询价逻辑的执行”给摘了出去,并且实现了不同询价逻辑之间的解耦。接下来,我们就要拿“分发”这个动作开刀。

(2)开放封闭改造

这会儿我要想给 askPrice 增加新人询价逻辑,我该咋整?我只能这么来:

  1. // 处理预热价
  2. function prePrice(originPrice) {
  3. if(originPrice >= 100) {
  4. return originPrice - 20
  5. }
  6. return originPrice * 0.9
  7. }
  8. // 处理大促价
  9. function onSalePrice(originPrice) {
  10. if(originPrice >= 100) {
  11. return originPrice - 30
  12. }
  13. return originPrice * 0.8
  14. }
  15. // 处理返场价
  16. function backPrice(originPrice) {
  17. if(originPrice >= 200) {
  18. return originPrice - 50
  19. }
  20. return originPrice
  21. }
  22. // 处理尝鲜价
  23. function freshPrice(originPrice) {
  24. return originPrice * 0.5
  25. }
  26. // 处理新人价
  27. function newUserPrice(originPrice) {
  28. if(originPrice >= 100) {
  29. return originPrice - 50
  30. }
  31. return originPrice
  32. }
  33. function askPrice(tag, originPrice) {
  34. // 处理预热价
  35. if(tag === 'pre') {
  36. return prePrice(originPrice)
  37. }
  38. // 处理大促价
  39. if(tag === 'onSale') {
  40. return onSalePrice(originPrice)
  41. }
  42. // 处理返场价
  43. if(tag === 'back') {
  44. return backPrice(originPrice)
  45. }
  46. // 处理尝鲜价
  47. if(tag === 'fresh') {
  48. return freshPrice(originPrice)
  49. }
  50. // 处理新人价
  51. if(tag === 'newUser') {
  52. return newUserPrice(originPrice)
  53. }
  54. }

在外层,我们编写一个 newUser 函数用于处理新人价逻辑;在 askPrice 里面,我们新增了一个 if-else 判断。可以看出,这样其实还是在修改 askPrice 的函数体,没有实现“对扩展开放,对修改封闭”的效果。
那么我们应该怎么做?我们仔细想想,楼上用了这么多 if-else,我们的目的到底是什么?是不是就是为了把 询价标签-询价函数 这个映射关系给明确下来?那么在 JS 中,有没有什么既能够既帮我们明确映射关系,同时不破坏代码的灵活性的方法呢?答案就是对象映射
咱们完全可以把询价算法全都收敛到一个对象里去嘛:

  1. // 定义一个询价处理器对象
  2. const priceProcessor = {
  3. pre(originPrice) {
  4. if (originPrice >= 100) {
  5. return originPrice - 20;
  6. }
  7. return originPrice * 0.9;
  8. },
  9. onSale(originPrice) {
  10. if (originPrice >= 100) {
  11. return originPrice - 30;
  12. }
  13. return originPrice * 0.8;
  14. },
  15. back(originPrice) {
  16. if (originPrice >= 200) {
  17. return originPrice - 50;
  18. }
  19. return originPrice;
  20. },
  21. fresh(originPrice) {
  22. return originPrice * 0.5;
  23. },
  24. };

当我们想使用其中某个询价算法的时候:通过标签名去定位就好了:

  1. // 询价函数
  2. function askPrice(tag, originPrice) {
  3. return priceProcessor[tag](originPrice)
  4. }

如此一来,askPrice 函数里的 if-else 大军彻底被咱们消灭了。这时候如果你需要一个新人价,只需要给 priceProcessor 新增一个映射关系:

  1. priceProcessor.newUser = function (originPrice) {
  2. if (originPrice >= 100) {
  3. return originPrice - 50;
  4. }
  5. return originPrice;
  6. }

变得易读、易维护。

(5)这,就是策略模式!

说起来你可能不相信,咱们上面的整个重构的过程,就是对策略模式的应用。

现在大家来品品策略模式的定义:
定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换。

算法,就是我们这个场景中的询价逻辑,它也可以是你任何一个功能函数的逻辑;
“封装”就是把某一功能点对应的逻辑给提出来;
“可替换”建立在封装的基础上,只是说这个“替换”的判断过程,咱们不能直接怼 if-else,而要考虑更优的映射方案。


13.行为型:状态模式—自助咖啡机背后的力量

(1)状态模式

状态模式和策略模式宛如一对孪生兄弟——它们长得很像、解决的问题也可以说没啥本质上的差别。

(2)一杯咖啡带来的思考

“咖啡机其实也是一个产品。它在不同的选择下有着不同的任务:

当我们选择香草拿铁时,它进入香草拿铁的制作工序;当我们选择美式时,它进入美式的制作工序。眼前一台小小的机器,可以根据用户的口味产出四种咖啡,想想也好厉害啊!李雷,听说你们程序员都是万能的,你能把这个过程用程序实现一下吗?”

(3)一台咖啡机的诞生

作为一个具备强大抽象思维能力的程序员,李雷没有辜负自己这么多年来学过的现代前端框架。他敏锐地感知到,韩梅梅所说的这些不同的”选择“间的切换,本质就是状态的切换。在这个能做四种咖啡的咖啡机体内,蕴含着四种状态:

  1. - 美式咖啡态(american):只吐黑咖啡
  2. - 普通拿铁态(latte):黑咖啡加点奶
  3. - 香草拿铁态(vanillaLatte):黑咖啡加点奶再加香草糖浆
  4. - 摩卡咖啡态(mocha):黑咖啡加点奶再加点巧克力

嘿嘿,这么一梳理,李雷的思路一下子清晰了起来。作为死性不改的 if-else 侠,他再次三下五除二写出了一套功能完备的代码:

  1. class CoffeeMaker {
  2. constructor() {
  3. /**
  4. 这里略去咖啡机中与咖啡状态切换无关的一些初始化逻辑
  5. **/
  6. // 初始化状态,没有切换任何咖啡模式
  7. this.state = 'init';
  8. }
  9. // 关注咖啡机状态切换函数
  10. changeState(state) {
  11. // 记录当前状态
  12. this.state = state;
  13. if(state === 'american') {
  14. // 这里用 console 代指咖啡制作流程的业务逻辑
  15. console.log('我只吐黑咖啡');
  16. } else if(state === 'latte') {
  17. console.log(`给黑咖啡加点奶`);
  18. } else if(state === 'vanillaLatte') {
  19. console.log('黑咖啡加点奶再加香草糖浆');
  20. } else if(state === 'mocha') {
  21. console.log('黑咖啡加点奶再加点巧克力');
  22. }
  23. }
  24. }

测试一下,完美无缺:

  1. const mk = new CoffeeMaker();
  2. mk.changeState('latte'); // 输出 '给黑咖啡加点奶'

(4)不再做 if-else 侠

鉴于 if-else 使不得,李雷赶紧翻出了他在策略模式中学到的“单一职责”和“开放封闭”原则。

(5)改造咖啡机的状态切换机制

(1)职责分离

首先,映入李雷眼帘最大的问题,就是咖啡制作过程不可复用:

  1. changeState(state) {
  2. // 记录当前状态
  3. this.state = state;
  4. if(state === 'american') {
  5. // 这里用 console 代指咖啡制作流程的业务逻辑
  6. console.log('我只吐黑咖啡');
  7. } else if(state === 'latte') {
  8. console.log(`给黑咖啡加点奶`);
  9. } else if(state === 'vanillaLatte') {
  10. console.log('黑咖啡加点奶再加香草糖浆');
  11. } else if(state === 'mocha') {
  12. console.log('黑咖啡加点奶再加点巧克力');
  13. }
  14. }

李雷发现,这个 changeState 函数,它好好管好自己的事(状态切换)不行吗?怎么连做咖啡的过程也写在这里面?这不合理。
别的不说,就说咱李雷和韩梅梅都欲罢不能的香草拿铁吧:它是啥高深莫测的新品种么?它不是,它就是拿铁加点糖浆。那我至于把做拿铁的逻辑再在香草拿铁里写一遍么——完全不需要!直接调用拿铁制作工序对应的函数,然后末尾补个加糖浆的动作就行了——可惜,我们现在所有的制作工序都没有提出来函数化,而是以一种极不优雅的姿势挤在了 changeState 里面,谁也别想复用谁。太费劲了,咱们赶紧给它搞一搞职责分离:

  1. class CoffeeMaker {
  2. constructor() {
  3. /**
  4. 这里略去咖啡机中与咖啡状态切换无关的一些初始化逻辑
  5. **/
  6. // 初始化状态,没有切换任何咖啡模式
  7. this.state = 'init';
  8. }
  9. changeState(state) {
  10. // 记录当前状态
  11. this.state = state;
  12. if(state === 'american') {
  13. // 这里用 console 代指咖啡制作流程的业务逻辑
  14. this.americanProcess();
  15. } else if(state === 'latte') {
  16. this.latteProcress();
  17. } else if(state === 'vanillaLatte') {
  18. this.vanillaLatteProcress();
  19. } else if(state === 'mocha') {
  20. this.mochaProcress();
  21. }
  22. }
  23. americanProcess() {
  24. console.log('我只吐黑咖啡');
  25. }
  26. latteProcress() {
  27. this.americanProcess();
  28. console.log('加点奶');
  29. }
  30. vanillaLatteProcress() {
  31. this.latteProcress();
  32. console.log('再加香草糖浆');
  33. }
  34. mochaProcress() {
  35. this.latteProcress();
  36. console.log('再加巧克力');
  37. }
  38. }
  39. const mk = new CoffeeMaker();
  40. mk.changeState('latte');

输出结果符合预期:
我只吐黑咖啡 加点奶

(2)开放封闭

复用的问题解决了,if-else 却仍然活得好好的。
现在咱们假如要增加”气泡美式“这个咖啡品种,就不得不去修改 changeState 的函数逻辑,这违反了开放封闭的原则。
要像策略模式一样,想办法把咖啡机状态和咖啡制作工序之间的映射关系(也就是咱们上节谈到的分发过程)用一个更优雅地方式做掉。如果你策略模式掌握得足够好,你会第一时间反映出对象映射的方案:

  1. const stateToProcessor = {
  2. american() {
  3. console.log('我只吐黑咖啡');
  4. },
  5. latte() {
  6. this.american();
  7. console.log('加点奶');
  8. },
  9. vanillaLatte() {
  10. this.latte();
  11. console.log('再加香草糖浆');
  12. },
  13. mocha() {
  14. this.latte();
  15. console.log('再加巧克力');
  16. }
  17. }
  18. class CoffeeMaker {
  19. constructor() {
  20. /**
  21. 这里略去咖啡机中与咖啡状态切换无关的一些初始化逻辑
  22. **/
  23. // 初始化状态,没有切换任何咖啡模式
  24. this.state = 'init';
  25. }
  26. // 关注咖啡机状态切换函数
  27. changeState(state) {
  28. // 记录当前状态
  29. this.state = state;
  30. // 若状态不存在,则返回
  31. if(!stateToProcessor[state]) {
  32. return ;
  33. }
  34. stateToProcessor[state]();
  35. }
  36. }
  37. const mk = new CoffeeMaker();
  38. mk.changeState('latte');

输出结果符合预期:
我只吐黑咖啡 加点奶

当我们这么做时,其实已经实现了一个 js 版本的状态模式。
但这里有一点大家需要引起注意:这种方法仅仅是看上去完美无缺,其中却暗含一个非常重要的隐患——stateToProcessor 里的工序函数,感知不到咖啡机的内部状况。

(3)策略与状态的辨析

策略模式是对算法的封装。算法和状态对应的行为函数虽然本质上都是行为,但是算法的独立性可高多了。

策略模式的询价算法,我只需要读取一个数字,我就能啪啪三下五除二给你吐出另一个数字作为返回结果——它和计算主体之间可以是分离的,我们只要关注计算逻辑本身就可以了。

但状态模式可不一样了。拿咱们咖啡机来说,为了好懂,咱写代码的时候把真正咖啡的制作工序用 console 来表示了。但大家都知道,做咖啡要考虑的东西可太多了。 比如咱们做拿铁,拿铁里的牛奶从哪来,是不是从咖啡机的某个储物空间里去取?再比如我们行为函数是不是应该时刻感知咖啡机每种原材料的用量、进而判断自己的工序还能不能如期执行下去?这就决定了行为函数必须能很方便地拿到咖啡机这个主体的各种信息——它必须得对主体有感知才行。

策略模式和状态模式确实是相似的,它们都封装行为、都通过委托来实现行为分发。
但策略模式中的行为函数是”潇洒“的行为函数,它们不依赖调用主体、互相平行、各自为政,井水不犯河水。
而状态模式中的行为函数,首先是和状态主体之间存在着关联,由状态主体把它们串在一起;另一方面,正因为关联着同样的一个(或一类)主体,所以不同状态对应的行为函数可能并不会特别割裂。

(4)进一步改造

按照我们这一通描述,当务之急是要把咖啡主体机和它的状态处理函数建立关联。
如果你读过一些早期的设计模式教学资料,有一种思路是将每一个状态所对应的的一些行为抽象成类,然后通过传递 this 的方式来关联状态和状态主体。

这种思路也可以,不过它一般还需要你实现抽象工厂,比较麻烦。实际业务中这种做法极为少见。我这里要给大家介绍的是一种更方便也更常用的解决方案——非常简单,把状态-行为映射对象作为主体类对应实例的一个属性添加进去就行了:

  1. class CoffeeMaker {
  2. constructor() {
  3. /**
  4. 这里略去咖啡机中与咖啡状态切换无关的一些初始化逻辑
  5. **/
  6. // 初始化状态,没有切换任何咖啡模式
  7. this.state = 'init';
  8. // 初始化牛奶的存储量
  9. this.leftMilk = '500ml';
  10. }
  11. stateToProcessor = {
  12. that: this,
  13. american() {
  14. // 尝试在行为函数里拿到咖啡机实例的信息并输出
  15. console.log('咖啡机现在的牛奶存储量是:', this.that.leftMilk)
  16. console.log('我只吐黑咖啡');
  17. },
  18. latte() {
  19. this.american()
  20. console.log('加点奶');
  21. },
  22. vanillaLatte() {
  23. this.latte();
  24. console.log('再加香草糖浆');
  25. },
  26. mocha() {
  27. this.latte();
  28. console.log('再加巧克力');
  29. }
  30. }
  31. // 关注咖啡机状态切换函数
  32. changeState(state) {
  33. this.state = state;
  34. if (!this.stateToProcessor[state]) {
  35. return;
  36. }
  37. this.stateToProcessor[state]();
  38. }
  39. }
  40. const mk = new CoffeeMaker();
  41. mk.changeState('latte');

输出结果为:
咖啡机现在的牛奶存储量是: 500ml 我只吐黑咖啡 加点奶
如此一来,我们就可以在 stateToProcessor 轻松拿到咖啡机的实例对象,进而感知咖啡机这个主体了。感知:拿到主体状态

(6)状态模式复盘

和策略模式一样,咱们仍然是敲完代码之后,一起来复盘一下状态模式的定义:

状态模式(State Pattern) :允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。

这个定义比较粗糙,可能你读完仍然 get 不到它想让你干啥。这时候,我们就应该把目光转移到它解决的问题上来:
状态模式主要解决的是当控制一个对象状态的条件表达式过于复杂时的情况。把状态的判断逻辑转移到表示不同状态的一系列类中,可以把复杂的判断逻辑简化。

仔细回忆一下我们这节做的事情,也确实就是这么回事儿。
唯一的区别在于,定义里强调了”类“的概念。但我们的示例中,包括大家今后的实践中,一个对象的状态如果复杂到了你不得不给它的每 N 种状态划分为一类、一口气划分很多类这种程度,我更倾向于你去反思一个这个对象是不是做太多事情了。

在大多数场景下,我们的行为划分,都是可以像本节一样,控制在”函数“这个粒度的。

14.行为型:观察者模式

(一)定义

观察者模式,是所有 JavaScript 设计模式中使用频率最高,面试频率也最高的设计模式,所以说它十分重要
重点不一定是难点。观察者模式十分重要,但它并不抽象,理解难度不大。

(1)重点角色对号入座

观察者模式有一个“别名”,叫发布 - 订阅模式(之所以别名加了引号,是因为两者之间存在着细微的差异)。这个别名非常形象地诠释了观察者模式里两个核心的角色要素——“发布者”与“订阅者”

在上述的过程中,需求文档(目标对象)的发布者只有一个——产品经理韩梅梅。而需求信息的接受者却有多个——前端、后端、测试同学,这些同学的共性就是他们需要根据需求信息开展自己后续的工作、因此都非常关心这个需求信息,于是不得不时刻关注着这个群的群消息提醒,他们是实打实的订阅者,即观察者对象。

略显抽象的定义:
观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个目标对象,当这个目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。

在我们上文这个钉钉群里,一个需求信息对象对应了多个观察者(技术同学),当需求信息对象的状态发生变化(从无到有)时,产品经理通知了群里的所有同学,以便这些同学接收信息进而开展工作:
角色划分 —> 状态变化 —> 发布者通知到订阅者,这就是观察者模式的“套路”。

(2)在实践中理解定义

结合我们上面的分析,现在大家知道,在观察者模式里,至少应该有两个关键角色是一定要出现的——发布者和订阅者。用面向对象的方式表达的话,那就是要有两个类
首先我们来看这个代表发布者的类,我们给它起名叫Publisher。这个类应该具备哪些“基本技能”呢?大家回忆一下上文中的韩梅梅,韩梅梅的基本操作是什么?
首先是拉群(增加订阅者),
然后是@所有人(通知订阅者),这俩是最明显的了。此外作为群主&产品经理,
韩梅梅还具有踢走项目组成员(移除订阅者)的能力。

  1. // 定义发布者类
  2. class Publisher {
  3. constructor() {
  4. this.observers = []
  5. console.log('Publisher created')
  6. }
  7. // 增加订阅者
  8. add(observer) {
  9. console.log('Publisher.add invoked')
  10. this.observers.push(observer)
  11. }
  12. // 移除订阅者
  13. remove(observer) {
  14. console.log('Publisher.remove invoked')
  15. this.observers.forEach((item, i) => {
  16. if (item === observer) {
  17. this.observers.splice(i, 1)
  18. }
  19. })
  20. }
  21. // 通知所有订阅者
  22. notify() {
  23. console.log('Publisher.notify invoked')
  24. this.observers.forEach((observer) => {
  25. observer.update(this)
  26. })
  27. }
  28. }

ok,搞定了发布者,我们一起来想想订阅者能干啥——其实订阅者的能力非常简单,作为被动的一方,它的行为只有两个——被通知、去执行(本质上是接受发布者的调用,这步我们在Publisher中已经做掉了)。既然我们在Publisher中做的是方法调用,那么我们在订阅者类里要做的就是方法的定义

  1. // 定义订阅者类
  2. class Observer {
  3. constructor() {
  4. console.log('Observer created')
  5. }
  6. update() {
  7. console.log('Observer.update invoked')
  8. }
  9. }

以上,我们就完成了最基本的发布者和订阅者类的设计和编写。在实际的业务开发中,我们所有的定制化的发布者/订阅者逻辑都可以基于这两个基本类来改写。比如我们可以通过拓展(extends)发布者类,来使所有的订阅者来监听某个特定状态的变化。仍然以开篇的例子为例,我们让开发者们来监听需求文档(prd)的变化:

  1. // 定义一个具体的需求文档(prd)发布类
  2. class PrdPublisher extends Publisher {
  3. constructor() {
  4. super()
  5. // 初始化需求文档
  6. this.prdState = null
  7. // 韩梅梅还没有拉群,开发群目前为空
  8. this.observers = []
  9. console.log('PrdPublisher created')
  10. }
  11. // 该方法用于获取当前的prdState
  12. getState() {
  13. console.log('PrdPublisher.getState invoked')
  14. return this.prdState
  15. }
  16. // 该方法用于改变prdState的值
  17. setState(state) {
  18. console.log('PrdPublisher.setState invoked')
  19. // prd的值发生改变
  20. this.prdState = state
  21. // 需求文档变更,立刻通知所有开发者
  22. this.notify()
  23. }
  24. }

作为订阅方,开发者的任务也变得具体起来:接收需求文档、并开始干活:

  1. class DeveloperObserver extends Observer {
  2. constructor() {
  3. super()
  4. // 需求文档一开始还不存在,prd初始为空对象
  5. this.prdState = {}
  6. console.log('DeveloperObserver created')
  7. }
  8. // 重写一个具体的update方法
  9. update(publisher) {
  10. console.log('DeveloperObserver.update invoked')
  11. // 更新需求文档
  12. this.prdState = publisher.getState()
  13. // 调用工作函数
  14. this.work()
  15. }
  16. // work方法,一个专门搬砖的方法
  17. work() {
  18. // 获取需求文档
  19. const prd = this.prdState
  20. // 开始基于需求文档提供的信息搬砖。。。
  21. ...
  22. console.log('996 begins...')
  23. }
  24. }

下面,我们可以 new 一个 PrdPublisher 对象(产品经理),她可以通过调用 setState 方法来更新需求文档。需求文档每次更新,都会紧接着调用 notify 方法来通知所有开发者,这就实现了定义里所谓的:
目标对象的状态发生变化时,会通知所有观察者对象,使它们能够自动更新。
OK,下面我们来看看韩梅梅和她的小伙伴们是如何搞事情的吧:

  1. // 创建订阅者:前端开发李雷
  2. const liLei = new DeveloperObserver()
  3. // 创建订阅者:服务端开发小Asorry。。。起名字真的太难了)
  4. const A = new DeveloperObserver()
  5. // 创建订阅者:测试同学小B
  6. const B = new DeveloperObserver()
  7. // 韩梅梅出现了
  8. const hanMeiMei = new PrdPublisher()
  9. // 需求文档出现了
  10. const prd = {
  11. // 具体的需求内容
  12. ...
  13. }
  14. // 韩梅梅开始拉群
  15. hanMeiMei.add(liLei)
  16. hanMeiMei.add(A)
  17. hanMeiMei.add(B)
  18. // 韩梅梅发送了需求文档,并@了所有人
  19. hanMeiMei.setState(prd)

以上,就是观察者模式在代码世界里的完整实现流程了。
相信走到这一步,大家对观察者模式的核心思想、基本实现模式都有了不错的掌握。下面我们趁热打铁,一起来看看如何凭借观察者模式,在面试中表演真正的技术~

(二)应用

观察者模式作为一个超高频考点,在设计模式中具有举足轻重的地位。单从面试的维度来说,说它是最重要的设计模式也不为过。
不过面试题这东西,和数学题一样,看似变化多端,实则大同小异。大家如果经历的面试足够多,会发现观察者模式考来考去也就是那么几种考法,所谓的“变化多端”,也无非是改个条件改个变量的事情。

观察者模式的四个出题方向

(1)Vue数据双向绑定(响应式系统)的实现原理

1.解析

Vue 框架是热门的渐进式 JavaScript框架。在 Vue 中,当我们修改状态时,视图会随之更新,这就是Vue的数据双向绑定(又称响应式原理)。
七 【设计模式】 - 图10
在 Vue 中,每个组件实例都有相应的 watcher 实例对象,它会在组件渲染的过程中收集依赖,之后当依赖项的 setter 被调用时,会通知 watcher 重新计算,从而致使它关联的组件得以更新——这是一个典型的观察者模式。这道面试题考察了受试者对Vue底层原理的理解、对观察者模式的实现能力以及一系列重要的JS知识点,具有较强的综合性和代表性。

在面试过程中,是要求你“说说自己的理解”。

在Vue数据双向绑定的实现逻辑里,有这样三个关键角色:

  • observer(监听器):注意,此 observer 非彼 observer。在我们上节的解析中,observer 作为设计模式中的一个角色,代表“订阅者”。但在Vue数据双向绑定的角色结构里,所谓的 observer 不仅是一个数据监听器,它还需要对监听到的数据进行转发——也就是说它同时还是一个发布者—getter
  • watcher(订阅者):observer 把数据转发给了真正的订阅者——watcher对象。watcher 接收到新的数据后,会去更新视图。—订阅者Dep
  • compile(编译器):MVVM 框架特有的角色,负责对每个节点元素指令进行扫描和解析,指令的数据初始化、订阅者的创建这些“杂活”也归它管~

这三者的配合过程如图所示:
七 【设计模式】 - 图11
OK,实现方案搞清楚了,下面我们给整个流程中涉及到发布-订阅这一模式的代码来个特写:

2.核心代码—实现observer

首先我们需要实现一个方法,这个方法会对需要监听的数据对象进行遍历、给它的属性加上定制的 getter 和 setter 函数。
这样但凡这个对象的某个属性发生了改变,就会触发 setter 函数,进而通知到订阅者。这个 setter 函数,就是我们的监听器:

  1. // observe方法遍历并包装对象属性
  2. function observe(target) {
  3. // target是一个对象,则遍历它
  4. if(target && typeof target === 'object') {
  5. Object.keys(target).forEach((key)=> {
  6. // defineReactive方法会给目标属性装上“监听器”
  7. defineReactive(target, key, target[key])
  8. })
  9. }
  10. }
  11. // 定义defineReactive方法
  12. function defineReactive(target, key, val) {
  13. // 属性值也可能是object类型,这种情况下需要调用observe进行递归遍历
  14. observe(val)
  15. // 为当前属性安装监听器
  16. Object.defineProperty(target, key, {
  17. // 可枚举
  18. enumerable: true,
  19. // 不可配置
  20. configurable: false,
  21. get: function () {
  22. return val;
  23. },
  24. // 监听器函数
  25. set: function (value) {
  26. console.log(`${target}属性的${key}属性从${val}值变成了了${value}`)
  27. val = value
  28. }
  29. });
  30. }

下面实现订阅者 Dep:

  1. // 定义订阅者类Dep
  2. class Dep {
  3. constructor() {
  4. // 初始化订阅队列
  5. this.subs = []
  6. }
  7. // 增加订阅者
  8. addSub(sub) {
  9. this.subs.push(sub)
  10. }
  11. // 通知订阅者(是不是所有的代码都似曾相识?)
  12. notify() {
  13. this.subs.forEach((sub)=>{
  14. sub.update()
  15. })
  16. }
  17. }

现在我们可以改写 defineReactive 中的 setter 方法,在监听器里去通知订阅者了:

  1. function defineReactive(target, key, val) {
  2. const dep = new Dep()
  3. // 监听当前属性
  4. observe(val)
  5. Object.defineProperty(target, key, {
  6. set: (value) => {
  7. // 通知所有订阅者
  8. dep.notify()
  9. }
  10. })
  11. }

(2)实现一个Event Bus/ Event Emitter

Event Bus(Vue、Flutter 等前端框架中有出镜)和 Event Emitter(Node中有出镜)出场的“剧组”不同,但是它们都对应一个共同的角色——全局事件总线

全局事件总线,严格来说不能说是观察者模式,而是发布-订阅模式(具体的概念甄别我们会在下个小节着重讲)。
如果只能考一个设计模式的面试题,我一定会出观察者模式。
如果只能选一道题,那这道题一定是 Event Bus/Event Emitter 的代码实现——我都说这么清楚了,这个知识点到底要不要掌握、需要掌握到什么程度,就看各位自己的了。

(3)在Vue中使用Event Bus来实现组件间的通讯

Event Bus/Event Emitter 作为全局事件总线,它起到的是一个沟通桥梁的作用。
我们可以把它理解为一个事件中心,我们所有事件的订阅/发布都不能由订阅方和发布方“私下沟通”,必须要委托这个事件中心帮我们实现。

在Vue中,有时候 A 组件和 B 组件中间隔了很远,看似没什么关系,但我们希望它们之间能够通信。这种情况下除了求助于 Vuex 之外,我们还可以通过 Event Bus 来实现我们的需求。

创建一个 Event Bus(本质上也是 Vue 实例)并导出:

  1. const EventBus = new Vue()
  2. export default EventBus

在主文件里引入EventBus,并挂载到全局:

  1. import bus from 'EventBus的文件路径'
  2. Vue.prototype.bus = bus

订阅事件:

  1. // 这里funcsomeEvent这个事件的监听函数
  2. this.bus.$on('someEvent', func)

发布(触发)事件:

  1. // 这里paramssomeEvent这个事件被触发时回调函数接收的入参
  2. this.bus.$emit('someEvent', params)

大家会发现,整个调用过程中,没有出现具体的发布者和订阅者(比如上节的PrdPublisher和DeveloperObserver),全程只有bus这个东西一个人在疯狂刷存在感。这就是全局事件总线的特点——所有事件的发布/订阅操作,必须经由事件中心,禁止一切“私下交易”!

下面,我们就一起来实现一个Event Bus(注意看注释里的解析):

  1. class EventEmitter {
  2. constructor() {
  3. // handlers是一个map,用于存储事件与回调之间的对应关系
  4. this.handlers = {}
  5. }
  6. // on方法用于安装事件监听器,它接受目标事件名和回调函数作为参数
  7. on(eventName, cb) {
  8. // 先检查一下目标事件名有没有对应的监听函数队列
  9. if (!this.handlers[eventName]) {
  10. // 如果没有,那么首先初始化一个监听函数队列
  11. this.handlers[eventName] = []
  12. }
  13. // 把回调函数推入目标事件的监听函数队列里去
  14. this.handlers[eventName].push(cb)
  15. }
  16. // emit方法用于触发目标事件,它接受事件名和监听函数入参作为参数
  17. emit(eventName, ...args) {
  18. // 检查目标事件是否有监听函数队列
  19. if (this.handlers[eventName]) {
  20. // 这里需要对 this.handlers[eventName] 做一次浅拷贝,主要目的是为了避免通过 once 安装的监听器在移除的过程中出现顺序问题
  21. const handlers = this.handlers[eventName].slice()
  22. // 如果有,则逐个调用队列里的回调函数
  23. handlers.forEach((callback) => {
  24. callback(...args)
  25. })
  26. }
  27. }
  28. // 移除某个事件回调队列里的指定回调函数
  29. off(eventName, cb) {
  30. const callbacks = this.handlers[eventName]
  31. const index = callbacks.indexOf(cb)
  32. if (index !== -1) {
  33. callbacks.splice(index, 1)
  34. }
  35. }
  36. // 为事件注册单次监听器
  37. once(eventName, cb) {
  38. // 对回调函数进行包装,使其执行完毕自动被移除
  39. const wrapper = (...args) => {
  40. cb(...args)
  41. this.off(eventName, wrapper)
  42. }
  43. this.on(eventName, wrapper)
  44. }
  45. }

在日常的开发中,大家用到EventBus/EventEmitter往往提供比这五个方法多的多的多的方法。但在面试过程中,如果大家能够完整地实现出这五个方法,已经非常可以说明问题了,因此楼上这个EventBus希望大家可以熟练掌握。学有余力的同学,推荐阅读FaceBook推出的通用EventEmiiter库的源码,相信你会有更多收获。

(4)观察者模式与发布-订阅模式的区别是什么?

在大量参考资料以及已出版的纸质书籍中,都会告诉大家“发布-订阅模式和观察者模式是同一个东西的两个名字”。其实这两个模式,要较起真来,确实不能给它们划严格的等号。

为什么大家都喜欢给它们强行划等号呢?这是因为就算划了等号,也不影响我们正常使用,毕竟两者在核心思想、运作机制上没有本质的差别。

发布者直接触及到订阅者的操作,叫观察者模式。
发布者不直接触及到订阅者、而是由统一的第三方来完成实际的通信的操作,叫做发布-订阅模式

观察者模式和发布-订阅模式之间的区别,在于是否存在第三方、发布者能否直接感知订阅者
七 【设计模式】 - 图12
七 【设计模式】 - 图13
既然有了观察者模式,为什么还需要发布-订阅模式呢?
大家思考一下:
为什么要有观察者模式?观察者模式,解决的其实是模块间的耦合问题,有它在,即便是两个分离的、毫不相关的模块,也可以实现数据通信。但观察者模式仅仅是减少了耦合,并没有完全地解决耦合问题——被观察者必须去维护一套观察者的集合,这些观察者必须实现统一的方法供被观察者调用,两者之间还是有着说不清、道不明的关系。

而发布-订阅模式,则是快刀斩乱麻了——发布者完全不用感知订阅者,不用关心它怎么实现回调方法,事件的注册和触发都发生在独立于双方的第三方平台(事件总线)上。发布-订阅模式下,实现了完全地解耦。这并不意味着,发布-订阅模式就比观察者模式“高级”。在实际开发中,我们的模块解耦诉求并非总是需要它们完全解耦

如果两个模块之间本身存在关联,且这种关联是稳定的、必要的,那么我们使用观察者模式就足够了。
而在模块与模块之间独立性较强、且没有必要单纯为了数据通信而强行为两者制造依赖的情况下,我们往往会倾向于使用发布-订阅模式。

15.行为型:迭代器模式—遍历专家

迭代器模式提供一种方法顺序访问一个聚合对象中的各个元素,而又不暴露该对象的内部表示。 ——《设计模式:可复用面向对象软件的基础》

迭代器模式是设计模式中少有的目的性极强的模式。所谓“目的性极强”就是说它不操心别的,它就解决这一个问题——遍历。

(1)“公元前”的迭代器模式

JS中,本身也内置了一个比较简陋的数组迭代器的实现——Array.prototype.forEach。
通过调用forEach方法,我们可以轻松地遍历一个数组:

  1. const arr = [1, 2, 3]
  2. arr.forEach((item, index)=>{
  3. console.log(`索引为${index}的元素是${item}`)
  4. })

但forEach方法并不是万能的,比如下面这种场景:

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <meta http-equiv="X-UA-Compatible" content="ie=edge">
  7. <title>事件代理</title>
  8. </head>
  9. <body>
  10. <a href="#">链接1号</a>
  11. <a href="#">链接2号</a>
  12. <a href="#">链接3号</a>
  13. <a href="#">链接4号</a>
  14. <a href="#">链接5号</a>
  15. <a href="#">链接6号</a>
  16. </body>
  17. </html>

我想拿到所有的a标签,我可以这样做:

  1. const aNodes = document.getElementsByTagName('a')
  2. console.log('aNodes are', aNodes)

我想取其中一个a标签,可以这样做:

  1. const aNode = aNodes[i]

在这个操作的映衬下,aNodes看上去多么像一个数组啊!但当你尝试用数组的原型方法去遍历它时:

  1. aNodes.forEach((aNode, index){
  2. console.log(aNode, index)
  3. })

你发现报错了:
七 【设计模式】 - 图14
这个aNodes准确地说,它是一个类数组对象,并没有为你实现好用的forEach方法。

迭代器的定义是什么——遍历集合的同时,我们不需要关心集合的内部结构

想用一个真·迭代器又不想自己搞的时候,也是请jQuery实现的迭代器来帮忙:
首先我们要在页面里引入jQuery:

  1. <script src="https://cdn.bootcss.com/jquery/3.3.0/jquery.min.js" type="text/javascript"></script>

借助jQuery的each方法,我们可以用同一套遍历规则遍历不同的集合对象:

  1. const arr = [1, 2, 3]
  2. const aNodes = document.getElementsByTagName('a')
  3. $.each(arr, function (index, item) {
  4. console.log(`数组的第${index}个元素是${item}`)
  5. })
  6. $.each(aNodes, function (index, aNode) {
  7. console.log(`DOM类数组的第${index}个元素是${aNode.innerText}`)
  8. })

输出结果完全没问题:
七 【设计模式】 - 图15
当然啦,遍历jQuery自己的集合对象也不在话下:

  1. const jQNodes = $('a')
  2. $.each(jQNodes, function (index, aNode) {
  3. console.log(`jQuery集合的第${index}个元素是${aNode.innerText}`)
  4. })

输出结果仍然没问题:
七 【设计模式】 - 图16
可以看出,jQuery的迭代器为我们统一了不同类型集合的遍历方式,使我们在访问集合内每一个成员时不用去关心集合本身的内部结构以及集合与集合间的差异,这就是迭代器存在的价值~

(2)ES6对迭代器的实现

ES6中,又新增了Map和Set。
四种数据结构(对象 数组 Map和Set)各自有着自己特别的内部实现,但我们仍期待以同样的一套规则去遍历它们,所以ES6在推出新数据结构的同时也推出了一套统一的接口机制——迭代器(Iterator)。

ES6约定,任何数据结构只要具备Symbol.iterator属性(这个属性就是Iterator的具体实现,它本质上是当前数据结构默认的迭代器生成函数),就可以被遍历——准确地说,是被for…of…循环和迭代器的next方法遍历。 事实上,for…of…的背后正是对next方法的反复调用。

在ES6中,针对Array、Map、Set、String、TypedArray、函数的 arguments 对象、NodeList 对象这些原生的数据结构都可以通过for…of…进行遍历。原理都是一样的,此处我们拿最简单的数组进行举例,当我们用for…of…遍历数组时:

  1. const arr = [1, 2, 3]
  2. const len = arr.length
  3. for(item of arr) {
  4. console.log(`当前元素是${item}`)
  5. }

之所以能够按顺序一次一次地拿到数组里的每一个成员,是因为我们借助数组的Symbol.iterator生成了它对应的迭代器对象,通过反复调用迭代器对象的next方法访问了数组成员,像这样:

  1. const arr = [1, 2, 3]
  2. // 通过调用iterator,拿到迭代器对象
  3. const iterator = arr[Symbol.iterator]()
  4. // 对迭代器对象执行next,就能逐个访问集合的成员
  5. iterator.next()
  6. iterator.next()
  7. iterator.next()

丢进控制台,我们可以看到next每次会按顺序帮我们访问一个集合成员:
七 【设计模式】 - 图17 而for…of…做的事情,基本等价于下面这通操作:

  1. // 通过调用iterator,拿到迭代器对象
  2. const iterator = arr[Symbol.iterator]()
  3. // 初始化一个迭代结果
  4. let now = { done: false }
  5. // 循环往外迭代成员
  6. while(!now.done) {
  7. now = iterator.next()
  8. if(!now.done) {
  9. console.log(`现在遍历到了${now.value}`)
  10. }
  11. }

可以看出,for…of…其实就是iterator循环调用换了种写法。在ES6中我们之所以能够开心地用for…of…遍历各种各种的集合,全靠迭代器模式在背后给力。
ps:此处推荐阅读迭代协议,相信大家读过后会对迭代器在ES6中的实现有更深的理解。

(3)一起实现一个迭代器生成函数吧!

ok,看过了迭代器从古至今的操作,我们一起来实现一个自定义的迭代器。
楼上我们说迭代器对象全凭迭代器生成函数帮我们生成。在ES6中,实现一个迭代器生成函数并不是什么难事儿,因为ES6早帮我们考虑好了全套的解决方案,内置了贴心的生成器(Generator)供我们使用:

  1. // 编写一个迭代器生成函数
  2. function *iteratorGenerator() {
  3. yield '1号选手'
  4. yield '2号选手'
  5. yield '3号选手'
  6. }
  7. const iterator = iteratorGenerator()
  8. iterator.next()
  9. iterator.next()
  10. iterator.next()

丢进控制台,不负众望:
七 【设计模式】 - 图18
接下来:用ES5去写一个能够生成迭代器对象的迭代器生成函数(解析在注释里):

  1. // 定义生成器函数,入参是任意集合
  2. function iteratorGenerator(list) {
  3. // idx记录当前访问的索引
  4. var idx = 0
  5. // len记录传入集合的长度
  6. var len = list.length
  7. return {
  8. // 自定义next方法
  9. next: function() {
  10. // 如果索引还没有超出集合长度,donefalse
  11. var done = idx >= len
  12. // 如果donefalse,则可以继续取值
  13. var value = !done ? list[idx++] : undefined
  14. // 将当前值与遍历是否完毕(done)返回
  15. return {
  16. done: done,
  17. value: value
  18. }
  19. }
  20. }
  21. }
  22. var iterator = iteratorGenerator(['1号选手', '2号选手', '3号选手'])
  23. iterator.next()
  24. iterator.next()
  25. iterator.next()

此处为了记录每次遍历的位置,我们实现了一个闭包,借助自由变量来做我们的迭代过程中的“游标”。
运行一下我们自定义的迭代器,结果符合预期:
七 【设计模式】 - 图19

(4)小结

迭代器模式比较特别,它非常重要,重要到语言和框架都争着抢着帮我们实现。但也正因为如此,大家业务开发中需要手动写迭代器的场景几乎没有,所以很少有同学会去刻意留意迭代器模式、思考它背后的实现机制。通过阅读本节,希望大家可以领略迭代器模式的妙处(为什么会有,为什么要用)和迭代器模式的实现思路(方便面试)。至此,我们的设计模式之旅就告一段落了~

实现:生成器返回一个next函数,返回的done为:如果传入数组索引还没有超出数组长度,done为false,返回的value值为如果done为false,则可以继续取值list[idx++]