🍲 设计模式与 JS 魔法锅

设计模式最终要融入任何语言。

设计模式是什么

从《人月神话》到《大教堂与集市》,许多闻名业界的作品都“低代码、高文化[1]”;各种代码文化运动可以看作“造神运动”此类周日剧场。许多大佬在向人们传授代码经验之余,都喜欢尝试用文化手段(不论是哲学思想还是艺术内涵),来统一人与代码的关系。这种跨界思考的过程通常比较晦涩,诸如“面向对象”、“设计模式”这些放到现在来说我们耳熟能详的词,在过去几十年都是由行业顶尖大佬们牵头搞研究,再经过不断地实践,逐渐沉淀下来形成的标准。

设计模式是什么?

设计模式是一种标准,它描述了使用面向对象编程语言解决问题的方式。

设计模式一词发源于建筑学作品《建筑模式语言》,后被一个人们称为“四人帮”(GoF)的小组种出它在软件界开的新花。无论是建筑学还是软件学中的设计模式,相同的地方是,它们都想解决“人”和“物”的关系相关问题,并且形成了一套特定的方法论。好比人们有了灶就可以长久保存火种,用名词把晦涩难懂的抽象定义为标准,这样有利于思想传播。只要人们普遍接纳并熟知设计模式,便能畅通无阻地沟通代码的实现。当然,现实很丰满,畅快沟通有个大前提:程序设计时,大家需要使用面向对象编程语言来合作。

为什么非面向对象不可

构建大型软件不止面向对象这一条路,与面向对象相对应的还有结构化、形式化等程序设计方法。任意一种有自有优劣。面对日益复杂的软件,人们关心的重心逐步转移到软件的可重用性方面。面向对象凭借着分类、封装等天然符合人们认知规律的优势,得到快速发展,终打通需求分析、程序设计到编程语言等开发上下游工作,赢得了业界青睐。

面向对象把功能作为节点,组合为功能网格,节点间通过消息通讯以及做对应的响应。这样一来,开发在编程时不再需要直面输入输出这种数据流动的思考方式,只需要按照指令维护各个对象内部的状态即可,节点对应的代码片段也就能得到重用。

Message Passing In OO

相比子程序复用,函数复用已经是巨大的进步[2]。类复用相比函数复用要更加抽象,因为它保存了函数和状态的集合;而设计模式比类复用在抽象上更胜一筹:设计模式不单单直接复用某个类,而是复用整个“功能网格”,把特定问题的解决方案相关的所有节点和通讯机制全部拷贝,固化下来,命名并形成标准。设计模式其实是使用面向对象编程语言时代码复用的高级形式,是面向对象发展壮大之路的必然的产物。只要写代码,就会碰到复用性问题,四人帮偏好面向对象,所以“设计模式”只是他们用来传授的面向对象编程中的代码复用性经验的概括。换句话说,大部分情况下我们讨论的“设计模式”和“面向对象设计模式”完全等同。这也就解释了为什么非面向对象不可了。

我们讨论一个新的问题,如果过滤掉设计模式中的面向对象成分,剩下的是些啥呢?

console.log(DesignPatterns.filter(removeOO))

设计模式的本质

我们先直接删除上文关于设计模式的定义中的“面向对象”几个字,看看剩下些什么:设计模式是一种标准,它描述了使用面向对象编程语言解决问题的方式,是使用面向对象编程语言时代码复用性的高级形式,是面向对象发展壮大之路的必然的产物。

句子有些不通顺,修补之后变成了:设计模式是一种标准,它描述了编程时解决问题的特定方式,是代码复用的高级形式及发展代码复用必然的产物。

设计模式本质是什么?不言自明,它只是相关代码复用的一种抽象概念。

说到代码复用,主要关系到代码组织。从函数复用,到使用设计模式,再到使用库、框架,三种代码复用方式的主要区别在于代码组织的粒度不同。

函数复用比较简单,你可以写一个叫做 Add 的函数给两数相加,然后 Copy&Paste 到其他地方复用,或是把函数存放到 Utils.js 文件中,通过模块机制复用。为了了解如何使用某个函数,需要在使用前阅读注释。

库和框架最为复杂,它们是组织了大量函数及实体形成的巨型代码片段。开发新项目时很少会花精力自研框架,一般都引现成的轮子直接开造。使用某库或框架前,必须详细阅读文档。

设计模式则是单个或多个函数的组合(在 Java 中不是函数而是类),既可以在新项目里现写一个模式供使用,也可以引入社区现有的模块来复用。为了了解如何使用某个设计模式,需要提前了解该模式定义的实体以及实体是通讯机制。

函数、设计模式、框架间的升级关系

设计模式作为最简单的实体和函数的组合,可看作函数的升级版或是框架的缩水版;去掉设计模式中的面向对象相关内容,就只剩下了函数。

等等,“实体和实体间通讯机制”凭空消失了?

并没有。但我想让他消失。下文将从 JavaScript 入手,介绍如何把设计模式的概念映射到语言特征中。语言特征可是个好东西呀!如果你同意抽象也像复用一样有函数复用、库和框架等不同复杂度的层次的话,那我认为语言特征就是抽象的基础了。除了语言本身以外,所有抽象的建立都依赖于语言特征。把设计模式映射成语言特征后,我们就能跳出面向对象的禁锢,从新的、更高层的角度重新思考代码复用问题。

回到正题,我们从 JS 开始说起。

仿制设计模式

编程语言并不是万能的。特定语言适合用来解决特定问题[3]。提到 JavaScript 诸多语言特征,其优点可以用寥寥三个词概括:“原型”、“对象”、“函数”。熟练掌握了这些内容,就能在 JS 的世界中游刃有余。

《JavaScript 精粹》VS《JavaScript 权威指南》

传统面向对象语言以类封装状态,并向外暴露改变状态的方法。以下代码中的书本类为例:Book Class 保持有 name 状态,可以使用 getPrintName 方法返回包装后的 name 或使用 rename 修改 name 。

class Book {
  constructor () {
    this.name = '设计模式与JS魔法锅'
  }
  getPrintName () {
    return '《' + this.name + '》'
  }
  rename (newName) {
    this.name = newName
  }
}

放到早期版本 JS 中,想要封装变量,这不好办。早期版本的 JS 没有块级作用域,所以声明的变量会在整个代码或函数作用域中生效。为了仿制类的状态封装能力,知名布道者道格拉斯发明了一种“模块模式”[4],即通过闭包来控制变量的访问权,代码如下:

const aBook = (() => {
  var name = '设计模式与JS魔法锅'
  function getPrintName () {
    return '《' + name + '》'
  }
  function rename (newName) {
    name = newName
  }
  return {
    getPrintName,
    rename
  }
})()

看起来,模块模式就像是语言能力不足的补充。把目光从遥远的模块模式收回到 2015 年 ES6 发布之后,JavaScript 语言特征得到增强,曾风靡一时的潮词模块模式几乎从网络中销声匿迹了。现在我们可以自由地使用类封装状态,不必再写因缺乏语义显得蹩脚的 IIFE 。

我们再来看看面向对象中的常见的装饰器模式。

假设你在维护一个开源项目,项目中有些过时的 API,你想在别人调用该 API 时提示一句“你好,此 API 将在下个版本移除”。这段代码可以这样写:

// 目前不支持给对象属性使用装饰器,所以这是一段伪代码
const myAPI = {
  @deprecate('WARN: oldAPI is decrapted, please use oldAPI_v2')
  oldAPI() {}
}

若不是装饰器提案的出现,我赌五毛,社区里不会有太多讨论装饰器模式的文章。不过大家若有留意过平常的代码,肯定会注意到其实高阶函数就是装饰器的具体实现。函数在 JS 中是一等公民,通过函数组合可以极其方便地仿制某些设计模式。比如,下代码中,使用“局部应用”函数仿制了一个工厂模式:

const Partial = (fn, ...args) => (...rest) => fn(...args, ...rest)

const Adder = (a, b) => a + b

const add5 = Partial(Adder, 5)

add5(5) // >>> 10
add5(10) // >>> 15

我猜你想咆哮:“工厂模式根本就不是这样写的!”。哈哈,别那么固执,变通一些嘛。我们现在讨论的设计模式是去除了面向对象之后的余留物,几乎只保留了设计模式的本质,复用。换句话说,Partial 是“函数工厂”,是“生成器”,“产出”了仍能接受一个参数的函数。

我用到了类吗?

没有。

用到了原型吗?

没有。

那我在写什么?

函数。

函数,函数而已!“函数是一等公民[5],这句话得背熟了,因为诸如“闭包”、“回调”等概念都和函数有关,面试经常会问道你迟早会用上的。若谈论设计模式脱离不开语言特征的话,那扯上函数绝对不会有任何问题。

亲函数而远类的写法可以帮助我们消化许多种类的设计模式,我们再来看最后一种,代理模式。

ES6 原生支持代理模式。对,就是 Vue3 里的那个“Proxy”,直译为“代理器”。社区有很多讲 Vue3 原理分析的文章,肯定绕不开 Proxy,这里不再赘述了。以下展示一个使用 Proxy 拦截对象操作的小例子。

/* 使用 Proxy 代理对象,禁用对象中下划线开头的属性的访问 */
const proxy = new Proxy({}, {
  set(target, key, value) {
    invariant(key, 'set')
    target[key] = value
    return true
  }
})

function invariant(key, action) {
  if (key.startsWith('_')) {
    throw new Error(`Invalid Key`)
  }
}

proxy._prop = 'somevalue'
// Error: Invalid Key

若脱离 ES 6,代理模式会变成什么?

会变成一团带有 Proxy Patterns 注释的函数,会被某个 Github 上的库吸收下放到 Readme 中亦或是变成社区中新增的几篇“JS 中的代理模式”的博客... 害,无所谓了。

设计模式的消融

总的来说,设计模式无非有三种作用:创建对象、组合对象以及处理对象的依赖。面向对象设计模式所代表的复用性,只能在面向对象的上下文中使用。跳出面向对象,你会发现代码复用的层次可以更加开放,下至语言特征,上至框架。

作为抽象的最低层级,JS 语言特征,离不开日益见新的语言规范。上文总结的设计模式与语言特征的重叠之处,可以概括为以下两点:

  • 若语言本身孱弱时,可通过语言特征的再上层抽象,如函数,来提高复用的层次(即发明一种新的设计模式);
  • 当语言逐渐强大时,设计模式的概念也随之弱化;

听起来颇有些“设计模式是对语言能力不足的补充”的味道,难道说我们要开始编程语言的圣战?这让我回想起 Paul Graham 在《黑客与画家》中描写到他对“强大语言”的赤裸裸的崇拜:

如果你想解决一个困难的问题,
关键不是你使用的语言是否强大,而是好几个因素同时发挥作用:
(a)使用一种强大的语言;
(b)为这个难题写一个真实世界中的编译器;
(c)或者... 你把自己变成这个难题的人肉编译器。 Paul Graham

Paul Graham 就觉得设计模式就是一种对语言能力不足的妥协,“语言的编程能力越强大,写出来的程序就越短”。当然,就算有一种语言可以强大到和破除面向对象设计模式的神话,关于语言的表达能力强弱的争论也不会停止。不过好消息是,脱离场景谈语言一定是错误的,正如没有人会指望用汇编来编写 Web 程序。

我们没必要去追求极致的编程语言表达力,表达力和复杂度往往是正相关的,就像世界上所有的语言的法阵都会伴随着词语的演进、读音的兴替以及对错误语法的包容,但很少有人说(或敢说)“你看,白话文比起文言文来说真是不够格啊”。

脱离面向对象,我们也可以谈论设计模式,因为我们聊的就是代码复用。就算使用函数式、声明式的语言解决问题,也许有特定范式可遵循。请想象一下 CSS 中的设计模式。嗯?CSS 有设计模式么?私以为有。特异性、继承和层叠这三种语言特征就是 CSS 的设计模式;在这三种基础特性之上建立起来的各种代码组织方案/命名方案(如 OOCSS、Atomic CSS),也算。

唯一不变的,只有代码复用。我们做的一切工作,只是抽象出不同层次的代码复用。最重要的不是记住某个设计模式中的实体以及实体是如何通讯的,而是要理解该设计模式所代表的抽象的体量适合的场景。

语言的魔法锅

在威尔士神话中,有一口神奇的大锅,加入特定配方熬制上一年零一天后,便能萃取出三滴解决任何难题的灵药。 《塔列辛传奇》

JavaScript 的火热日益见长,前端社区越加活跃,规范也日益见新。ESNeeext 已经往魔法锅中加了许多新材料,这是好事(其实我满心期待它直接把锅塞满)。新的语言特征能带来了更多的可能性,就目前而言,新特征大概率能增加语义,增强表达力、降低思维负担[6]。尽管把材料往大锅中塞吧[7]!让我们一同祈祷萃取出灵药的一天早日到来!

以下就用魔法锅的故事作为文章结尾,这是一个颇有意思的传奇故事。

炼金术 | 维基百科

在爱尔兰一个隐秘的角落,住着一个精通智慧与魔法的巫师,凯丽杜恩(Ceridwen)。她能力超凡,偏偏却生了一个丑陋的儿子。所以这位母亲竭尽所能想将智慧传授给他,以弥补其外表的丑陋。她辗转于各种巫术与秘仪,最后,总算在一本隐秘的魔书中找到了灵感。

魔书将秘密告诉凯丽杜恩:她需要使用一口大的魔法锅,填满红山花、小麦、火盐、精灵耳、直立根和灵尘,用大火熬制上一年零一天。最终会生成一锅致命毒物,但溅出的前三滴汁液却是充满“预言之灵”的智慧灵药。

很快,熬制魔法锅的工作在凯丽杜恩的安排下有条不紊地进行,一个名字未知的盲人被派来搅拌大锅,年轻人巴赫则负责烧它下面的火。

星象在天空中咯咯响地平移着,太阳和月亮绕着地球转了一轮又一轮... 随着时间推移,锅中的草药效力变得越来越强。看着咕嘟嘟冒着气泡的大锅,凯丽杜恩觉得很是满意,她便躺在温暖的锅边睡着了。

就在此时,意外发生了。翻腾着热气的大锅,意外地溅出了几点液体在年轻人巴赫的手指上,而巴赫居然不假思索地将手指吮吸地干干净净。和魔书的预言一样,巴赫立马就获得了智慧。他瞬间通晓了火焰的舞蹈和水的诉说,山的尊严和风的呢喃... 他知晓了无数秘密,以及... 凯丽杜恩肯定会杀了他!

慌乱中,巴赫变成一只野兔夺路而逃,而凯丽杜恩惊醒后赶忙变成猎犬一路追踪过去。巴赫变成天空中的鸽子,她就变成一只鹰;他变成一头奔跑的鹿,她就变成一只草原上的狼;他化身为谷仓中的一粒麦粒,她就变成一只啄个不停老母鸡...

这两人也许一直较量着,直到今天。

阅读更多

希望本文能对你有所帮助,也欢迎各位批评文中观点或指出错误。

想看看这篇文章是如何被创造的?你能从我的博客项目中找到答案~ 欢迎 Star & Follow~ 也请大家多来我的线上博客逛逛,排版超 Nice 哦~


  1. 非贬义。 ↩︎

  2. 见文章:子程序(函数)考古学 ↩︎

  3. 试试使用 CSS 去写游戏?即便可行,但过程也会让人无比沮丧。 ↩︎

  4. 此模块模式并非传统面向对象中的模块模式,后者的定义见:https://www.jdon.com/52843↩︎

  5. 且叫且珍惜,说不定以后就听不到“一等公民”这种叫法啦。 ↩︎

  6. 用一个单词就能达意时就不需要用一个句子。 ↩︎

  7. 语言特征并不总是银弹,类似观点见道格拉斯的《How JS Works》 ↩︎

本文最后更新于: September 15 2021 21:52