微信号:frontshow

介绍:InfoQ大前端技术社群:囊括前端、移动、Node全栈一线技术,紧跟业界发展步伐。

每个前端开发者都要了解的发布和订阅模式

2018-10-13 19:39 前端之巅
作者|Hubert Zub
译者|无明

当你发现自己处于 Web 开发旅程中最激动人心的那一刻,一切才刚开始:你的注意力从样式、美学和网格系统转向了逻辑、框架和 JavaScript 代码。

就像这样,一切才刚开始……

在这个时刻,你开始意识到,JS 不仅仅是几个简单的 jQuery 技巧和视觉效果。你看到自己正在构建的是整个 Web 应用,而不仅仅是几个网页而已。

当你在 JS 代码方面付出越来越多的努力之后,你开始思考交互性、子系统和逻辑。你终于开始感受到 Web 应用程序的生命力。一个令人兴奋的新世界出现在你面前。但与此同时,很多大问题也接踵而至。

结果是这样的,但这还不是终点!

你没有气馁——新的想法不断涌现出来,你写了越来越多的代码。你试验了博客上提到的各种技术,完善了各种解决问题的方法。

然后,你开始感到有点小痒痒。

你的 script.js 文件在增长。一小时前它只有 200 行代码,现在却超过了 500 行。你觉得这不是什么大不了的事。你知道什么样的代码是整洁和可维护的,于是你开始将逻辑拆分到单独的文件、块和组件中。一切又开始变得美好了。所有东西都被整齐地分门别类,就像身处一个经过精心编排的图书馆里。你感觉良好,因为各种文件都有恰如其分的名字,并且被放在恰当的目录中。代码走向了模块化,更易于维护。

突然,你又开始感觉到痒痒,只是这次不知道是什么原因引起的。

Web 应用程序的行为很少是线性的。实际上,Web 应用程序的很多行为都应该是突然发生的(有时候甚至是意外或自发的)。

应用程序需要正确地响应网络事件、用户交互、计时机制和各种延迟动作。突然,“异步”和“竟态条件”这两个丑陋的怪物开始敲你的门。

你要让你那俊朗的模块化结构与丑陋的新娘——异步代码结为一对。然后你的小痒痒开始加剧。一个棘手的问题开始冒出来:我应该把这些代码放在哪里?

你可能已经将你的应用程序完美地拆分为构建块。导航和内容组件可以整齐地放在适当的目录中,较小的辅助脚本文件包含了执行普通任务的重复代码。所有东西都可以通过一个入口 app.js 代码文件进行管理。一切才刚开始。

但你的目标是在应用程序的一个地方调用异步代码,处理它,并将它发送到应用程序的另一个地方。

异步代码应该被放在 UI 组件中吗?还是放在主文件中?应该由应用程序的哪个构建负责处理响应?要处理哪一个?错误处理怎么做?你开始在脑子里开始浮现出各种方法——但你不安的心情并没有平复下来——你知道,如果你想扩展这些代码,它将变得越来越难以维护。痒痒还没有消失。你需要找到一些理想的解决方案。

不过不要紧张,这不是你的问题。事实上,你的思维越是有条理,这种痒痒的感觉就会越强烈。

你开始查找资料,看看如何解决这个问题,希望能够找到现成的解决方案。在一开始,你了解到 promise 比回调更好。然后,你开始了解 RxJS。经过一番折腾之后,你试着去理解为什么有人会说没有 redux-thunk 的 redux 是没有意义的,而另一个人却说没有 redux-saga 的 redux 才是没有意义的。

最后,你的脑子里充斥着各种流行术语,让你头痛欲裂。为什么会有这么多解决方案?就不能简单一些吗?人们真的喜欢在互联网上争论而不是去开发出一种好的模式吗?

这是因为这个问题不是那么好解决的。

无论使用哪一种框架,进行异步编码从来而且永远都不会那么轻而易举就能搞定。根本就不存在一个通用的既定解决方案。它在很大程度上取决于具体的要求、环境、预期结果和其他因素。

另外,我并不打算提供可以解决所有问题的解决方案。但它有助于你更容易理解异步代码——因为它是基于一个非常基本的原则。

基础原则

从某些角度来看,编程语言的结构并不复杂。毕竟,它们只是类似计算器一样的机器,将信息保存在各种盒子中,然后根据某些 if 语句或函数调用来改变流程。JavaScript 作为一门命令式且略微面向对象的语言,也没能避免这样的俗套。

所以,从本质上说,来自银河系各个星球的异步装置(无论是 redux-saga、RxJS、Observable 还是其他变体)都依赖相同的基本原则。它们的魔力并不像看上去的那么神奇——它们都是建立在众所周知的基础之上,在底层没有什么新东西。

为什么这个原则如此重要?让我们看一个例子。

让我们做(并打破)一些东西

假设有一个简单的应用程序,它真的非常简单,用于在地图上标记我们最喜欢的地方。没有什么花哨的东西:右侧是地图,左边是简单侧边栏。单击地图的某个点就可以在地图上保存新的标记。

当然,我们还是有点野心的。我们将提供额外的功能:我们希望它能够使用本地存储来保存地图标记。

基于上面的描述,我们现在可以为我们的应用程序绘制一些基本的流程图:

看,它并不复杂。

为简洁起见,下面的示例将不使用任何框架或 UI 库——只有纯 JavaScript 代码。此外,我们将使用一小部分 Google Maps API——如果你想自己创建类似的应用,应该在 https://cloud.google.com/maps-platform/#get-started 上注册你的 API 密钥。

好的,让我们开始编写代码并快速创建一个原型:

让我们快速分析一下:

  • init() 函数使用 Google Maps API 初始化地图元素,设置地图点击操作动作,然后尝试从本地存储加载地图标记。

  • addPlace() 函数处理地图点击动作,然后将新标记点添加到列表中,并进行标记渲染。

  • renderMarkers() 函数遍历数组中的标记位置,在清理完地图后,将标记放在地图上。

有些不完善之处我们暂且忽略,如猴子补丁(monkey patching)、没有错误处理等——它作为原型来说已经能够提供足够好的服务。现在让我们编写一些 HTML 标记:

假设我们应用了一些样式:

虽然很丑,但它确实管用,可惜不具备可扩展性。

首先,我们将各个组件的职责混在了一起。如果你听说过 SOLID 原则,那么你应该知道我们已经打破了第一个原则:单一职责原则。在我们的示例中——尽管它很简单——一个代码文件负责处理用户操作和数据同步。我们不应该这样做。为什么?你可能会说,这样不是已经可以奏效了吗?确实如此,但在添加下一个功能时,代码变得难以维护。

假设我们要扩展我们的应用程序,并添加一些新功能:

首先,我们希望在侧边栏显示被标记过的位置。其次,我们希望通过 Google API 查找城市名——这就是引入异步机制的地方。

我们的新流程图如下所示:

通过 Google API 获取城市名不是实时的。它需要用 Google 的 JavaScript 库调用一些服务,并要过一段时间才能收到响应。这将引入一些复杂性。

让我们回到 UI,这里有一个地方值得我们注意。我们有两个独立的界面:侧边栏和内容区域。我们绝对不应该编写一大堆代码来处理这两个区域。原因很明显——如果将来我们有四个组件,那该怎么办?六个呢?100 个呢?我们需要将代码拆分成块,这样我们就会有两个单独的 JavaScript 文件,一个用于侧边栏,一个用于地图。问题是保存标记位置的数组应该放在哪一边?

是放在第一个还是第二个?答案是:两个都不放。还记住单一职责原则吗?为了保持整洁和模块化,我们应该分离关注点,并将数据逻辑保存到其他地方。

我们可以将数据存储和逻辑移动到另一个代码文件中。这个文件——也就是服务——将负责处理与数据有关的关注点以及与本地存储的同步。其他组件只作为接口。这样就遵循了 SOLID 原则了。

服务代码:

地图组件代码:

侧边栏组件代码:

现在大部分痒痒的感觉消失了,代码又被整齐地排列在抽屉当中。但在我们感觉良好之前,让我们试试点击一下地图。

糟糕,点了地图没有任何反应。

为什么会这样?原来我们还没有实现任何同步。在使用导入的方法添加地点后,我们没有发出信号。就算我们在 addPlace() 之后加上 getPlaces() 方法也不行,因为城市查找是异步的,需要一点时间才能完成。

数据获取在后台发生,但界面并不知道结果——在地图上添加标记后,我们没有看到侧边栏有任何变化。怎么办?

一个简单的想法是定时轮询我们的服务——例如,每个组件都以一秒为间隔从服务获取数据。

这样做有用吗?虽然有点矬,但确实有用。但它是最佳的方式吗?完全不是——我们的应用程序充斥着在大多数情况下不会产生任何作用的事件循环。

毕竟,你不可能每隔一个小时跑去邮局看看包裹到了没有。同样地,你的车子坏了,拿去送修,你也不可能每隔半小时给修车师傅打电话询问是否已经修好(至少希望你不是这种人)。相反,我们等他们打电话来。那么在车子修好后,修车师傅怎么知道应该打给谁?很简单,我们已经给他们留了联系方式了。

现在,让我们来模拟 JavaScript 版本的“留下我们的联系方式”。

JavaScript 是一门奇特的语言——它的一个特点是将函数视为某种值。换成正式一点的描述就是“函数是一等公民”。也就是说,函数可以被赋值给变量或作为参数传给另一个函数。还记得 setTimeout、setInterval 和各种事件监听器回调吗?它们都将函数作为参数来使用。

这种特性是实现异步的基础。

我们可以定义一个用于更新 UI 的函数——然后将它传给另一部分代码,它将在那里被调用。

我们以某种方式将 renderCities 传给 dataService。在必要时它会被调用:毕竟只有 dataService 才确切地知道数据何时传给组件。

让我们试一下。我们先在服务这边记住函数,然后在某个时刻调用它。

现在,将它附加到侧边栏:

你看到这里发生了什么吗?在加载侧边栏代码时,它在 dataService 里注册了 renderCities 函数。

然后,dataService 会在必要时调用这个函数——也就是当数据发生变更时(因为调用了 addPlace())。

确切地说,一部分代码是事件的订阅者(侧边栏组件),另一部分是发布者(服务)。我们已经实现了发布和订阅模式的最基本形式,这是几乎所有高级异步概念的基础。

除此之外还有哪些东西?

这个代码只使用了一个监听组件(也就是只有一个订阅者)。如果使用 subscribe() 传进来其他函数,当前的 changeListener 就会被覆盖。为了解决这个问题,我们可以设置一个监听器数组:

现在,我们可以稍微整理一下代码,并编写一个可以调用所有监听器的函数:

我们也可以连接 map.js 组件,它将对服务中所有的动作做出恰当的反应:

如果将订阅者作为传输数据的方式会怎样?这样我们就可以通过监听器直接获取位置标记。

然后,我们可以轻松地在组件中获取数据:

这里有更多的可能性——我们可以为不同类型的动作创建不同的主题。此外,我们可以将 publish 和 subscribe 方法提取到单独的代码文件中,但现在先不这么做。这是使用刚刚创建的代码得到的地图应用的视频链接:https://youtu.be/unSv4BkIbQs。

这种发布和订阅模式是否与你已经知道的某些东西相类似?它与 element.addEventListener(action,callback) 使用的机制是一样的。你让你的函数订阅特定的事件,当元素发布某个动作时就调用这个函数。道理是一样的。

问题是为什么这个模式如此重要?毕竟,从长远来看,一直使用纯 JavaScript 代码和手动修改 DOM 几乎是没有意义的,通过手动机制传递和接收事件也是一样。各种框架都已经提供了各自的解决方案:Angular 使用 RxJS,React 有 state 和 prop 管理,还可以使用 Redux。几乎每个可用的框架或库都有自己的数据同步方法。

但说到底,所有这些框架都使用了发布和订阅模式的变体。

DOM 事件监听器只不过是对 UI 动作的订阅。更进一步:什么是 promise?从某些角度来看,它只是一种允许我们订阅某个延迟动作的机制,然后在准备就绪时发布一些数据。

React 的 state 和 prop 变更呢?组件的更新机制订阅了它们的变更。Websocket 的 on() 呢?Fetch API 呢?它们允许订阅某些网络动作。Redux 呢?它允许订阅发生在存储上的变更。RxJS 呢?它就是一个彻头彻尾的订阅模式。

它们的原则都是一样的,并没有什么神奇的东西。

这并不是什么伟大的发现,关键是你要知道:

无论你使用什么方法来解决异步问题,总是会出现一些变体,但它们都出自同一原则:有订阅者和发布者。

当然,还有很多主题我们没有覆盖到:

  • 在不需要时取消订阅;

  • 多主题订阅(就像 addEventListener 允许你订阅不同的事件);

  • 扩展:事件总线等。

为了扩展你的知识,可以参考其他实现了发布和订阅模式的 JavaScript 库:

  • https://github.com/mroderick/PubSubJS

  • https://github.com/Sahadar/pubsub.js

  • https://github.com/shystruk/publish-subscribe-js

你可以在下面的 GitHub 存储库中找到文中提到的代码:

https://github.com/hzub/pubsub-demo/

 英文原文

https://itnext.io/why-every-beginner-front-end-developer-should-know-publish-subscribe-pattern-72a12cd68d44

 课程推荐

技术是没有边界的,保持技术敏感性和快速学习能力程序员进阶的重要因素,《程序员进阶攻略》相信会给予正在成长阶段的你很多启发与指引。

福利:该专栏正在做限时拼团活动,原价¥99,限时优惠¥68,仅此 1 天,扫码下图二维码阅读原文立即订阅

 
前端之巅 更多文章 2018年,最常见的26个JavaScript面试题和答案 JavaScript私有属性要来了,但实现方式惹争议 这三个新特性可能改变JavaScript未来 正则表达式真的很6,可惜你不会写 为什么说ReasonReact是编写React的最佳方式?
猜您喜欢 光棍节 | 嫁给创业者,比单身还苦?创业的都是些什么人! 由 Python2 和 Python3 中 socket.inet_aton() 实现不同引发的血案 集三域生物之力,合成一条高效的固碳通路 数据分析师的一生 [精校版]Using Swift with Cocoa and Objective-C--将ObjC代码迁至Swift