为什么前端初学者必须要明白发布订阅模式
By Hubert Zub | Oct 3, 2018
当你将关注点从样式,美学和网格系统转移到逻辑,框架和编写JavaScript代码时。一切都开始了,你会发现你处于你的web开发历程中最激动人心的那一刻。
<center>开始的时候像这样子…</center>
在这个非常时刻你会发现,当涉及到JS时,它不仅仅是几个简单的jQuery技巧和视觉效果。你的视野是一整个web应用,而不再仅仅是局限于页面。
当你把更多的精力投入到写js代码时,你会开始考虑交互、你的子模块和逻辑。事情开始奏效,你感觉到你的app有了生命。一个全新的、令人兴奋的世界出现在你眼前,同样,也出现了很多全新的、棘手的问题。
<center>这仅是开始</center>
你并不气馁,并想出来各种各样的办法,代码也写的越来越多。尝试某些博客文章中那各种各样的技术,不断地完善自己解决问题的方法。
然后,你开始觉得有些不对路。
你的脚本文件慢慢变大,一小时前才200行的,现在已经500行了。“嘿”——你想——“这没什么大不了的”。随后,你开始阅读关于代码维护的相关文章,并着手实现它。开始分离你的逻辑代码,并把它们分块、组件。事情开始又变好了点。代码像图书馆藏书那样分类存放。你感觉良好,因为各种各样的文件被以正确的命名放置在合适的目录里。代码变得模块化,更易于维护了。
然而,你又感觉不对路了,但是不知道哪里有问题。
<center> * * * </center>
web应用的行为很少是线性的。事实上,web应用的许多行为应该是瞬时发生(有时候应该是出乎意料或是自发地)。
应用需要正确并合适响应各种网络请求、用户操作、计时事件和各种延时动作。名为“异步”和“race condition”的怪物无时不刻在敲你的脑门。
你需要将你帅气的模块化结构与丑陋的新娘结合 - 异步代码。一个棘手的问题来了:我应该把这段代码放在哪里?
你会把你的app精心地划分成一个个构建块。导航和内容组件被整齐地放置在合适的目录中,较小的辅助脚本文件包含了执行普通任务的重复代码。一切都通过app.js这个文件来调度,一切都从这里开始。完美。
但是,你的目标是在app中的某个地方调用异步代码,运行后把它放在一旁。
异步代码应该放在ui组件么?或者放在主文件里?app的哪个构建块负责响应呢?哪一个构建块负责开始运行?错误处理呢?你在脑海里考虑着各种方法——但是你还是愁眉不解——你意识到如果想要拓展或维护这些代码,那难度是相当大的,问题还没解决。你需要理想的一劳永逸的方案。
放松一下,这对你来说没有问题。事实上,你的思维越有条理,这种烦恼就会越强烈。
你开始阅读有关处理此问题的信息并寻求即用型解决方案。一开始,你了解到promises优于回调的地方。随后,你开始试图了解什么是RxJS(并且为什么网上的一些人说这是解决网络异步请求的唯一解决方案)。经过一些阅读之后,你试着去理解,为什么一个博客写道没有redux-thunk的redux没有意义,但是另一个人认为redux-saga也是如此。
一天结束后,你疲惫的大脑充斥着各种词。阅读完大量可行的方法后,你的想法喷涌出来。为什么会有这么多呢?那么复杂?人们怎么喜欢在互联网上争论,不去开发一个好的模式?
因为这些都不重要
无论使用哪种框架,异步代码都不可能被正确地存放好。并没有一个单一、通用、既定的解决方案,要根据具体的开发环境、需求来采取不同的方案。
并且,这篇文章也不会提供解决所有问题的方案。但是它可以给你提供一个好的思路,让你处理好你的异步代码——因为它都基于一个非常基本的原则。
<center> * * * </center>
通用部分
从某些角度来看,编程语言的结构并不复杂。毕竟,它们只是类似于计算机的愚蠢东西,能够在各种盒子里储存值而已,并且通过if或函数调用改变程序执行流程。作为一种命令式和略微面向对象的语言,js在这里也是类似的。
这意味着究其本质,来自各路大神写的各种宇宙级异步库(无论是redux-saga、RxJS、观察者或者其他奇奇怪怪的库)都依赖相同的基本原理。它们并没有那么神奇——它必须让大家学习它的概念,这里并没有新发明。
为什么这个事实如此重要?让我们来考虑这样的一个例子。
<center> * * * </center>
Let’s do (and break) something
先来个简单的app,这个app可以让我们在地图上标记我们喜欢的地方。没有什么花哨的东西:只是右侧的地图视图和左侧的简单侧边栏。单击地图应在地图上保存新标记。
当然,我们需要一个与众不同的特性:我们需要它用local storage记住我们标记好的地方列表。
综上所述,我们可以画一个流程图出来
看,并不是很复杂
为简洁起见,下面的示例将在不使用任何框架或UI库的情况下编写 - 仅涉及vanilla js。此外,我们将使用谷歌地图API的一小部分 - 如果你想自己创建类似的应用程序,你应该注册你的API密钥https://cloud.google.com/maps-platform/#get-started.
快速分析一下
init方法用google地图api初始化地图组件,注册地图点击事件并且尝试从local storage加载数据。
addPlace方法处理地图点击事件——把新地点加在列表里并且更新ui
renderMarkers方法迭代地点列表,清除地图后,将标记放在其上。
忽略一些不完善的地方(没有错误处理之类的)—— 它将作为原型提供足够好的服务。完美。让我们写一些html:
假设我们写了一些样式(我们不会在这里介绍它,因为它不相关),不管你信不信 - 它实际上是这样做的:
尽管它很丑,但是管用。不过可拓展性不好。
首先,我们的代码责任分割不明确。如果你听说过SOLID原则,你应该清楚我们已经打破了第一条规则:单一责任原理。在我们的例子中——尽管很简单——一个js文件包含了所有,包括处理用户响应的代码和数据转换和异步代码。“为什么这样不好,运行起来不是棒棒的么?”——你可能会这么说。确实运行起来棒棒的,但是如果要加新特性那就不棒棒了——可维护性低。
我用一个例子让你彻底心服口服:
首先,我们想要侧边栏加标记列表。第二,我们想要用googleAPI实现在地图上看到城市名的功能——这就引入了异步代码。
好了,我们的新流程图画出来了:
<center>提示:城市名称查找不是很复杂,谷歌地图为此提供了非常简单的API。 你可以自己检查一下! </center>
既然你调用别人的接口,那肯定不是同步代码而是异步代码啦。它首先要调用google的js库,并且回复过来需要一定时间。虽然有点复杂,但是用于教学刚刚好。
让我们回到ui代码这里并且这里有个明显的事实。我们的页面分两大块,侧边栏和主要内容区。我们绝对不能把它们两的代码放在一起。原因很明显——我们将来有四个组件怎么办?六个呢?一百个呢?我们需要把我们的代码分开——我们需要有两个独立的js文件。一个是侧边栏,一个是主要内容区块。问题来了,哪一个应该存放地方标记列表的数组呢?
哪一个正确呢?哪个都不对。还记得单一责任原则么?为了降低代码冗余度,我们应该以某种方式分离关注点并将我们的数据逻辑保存在其他地方。看吧:
代码分离万金油:我们可以把进行数据操作的代码放到另一个文件里,这个文件集中处理数据。这个servce文件将负责那些与本地存储同步的问题和机制。相反,组件将仅仅提供接口。这符合SOLID原则。让我们介绍下这个模式:
Service code
Map component code
Sidebar component code:
好了,一个大问题已经解决。代码整齐摆放在它们该待的位置。但在我们感觉良好之前,运行下这个。
。。。oops。
在做任何动作之后,app没有交互了。
为什么? 好吧,我们没有实现任何同步手段。使用导入的方法添加地点后,我们不会在任何地方发出任何信号。在调用addPlace()之后,我们甚至无法在下一步调用getPlaces()方法,因为城市查找是异步的,需要时间来完成。
程序在后台进行,但是并没有反应到界面上——在地图上添加标记后,我们没有看到侧边栏的更新。怎么解决?
一个简单的方法就是,使用定时器轮询我们的服务,例如:
它有用么?emm。。有,但不是最佳方案。大多数情况下我们并不需要这个服务。
毕竟,你也不会定时去看你的包裹有没到达。同样地,如果你把汽车丢去维修,你也不会每半小时给修车师傅打电话询问工作是否完成(至少希望你不是这种人)。正常的情况应该是这样的,修车师傅修好了,自然会打电话给你。当然,我们事先留电话了。
现在,我们在js中尝试下这种“留电话”的方式。
<center> * * * </center>
js是一门非常神奇的语言——它的一个古怪的特征就是可以把函数视为其他值。形象点表示就是,“函数是一等公民”。这意味着任何函数都可以分配给变量或作为参数传递给另一个函数。事实上你已经接触过了:还记得setTimeout,setInterval和各种事件监听器回调吗? 它们通过将函数作为参数来使用。
这种特性在异步场景中是基础
我们可以定义一个更新我们的UI的函数 - 然后将它传递给另一部分的代码,在那里它将被调用。
使用这种机制,我们可以将renderCities方法以某种方式传递给dataService。在那里,它将在必要时被调用:毕竟,服务能准确地知道何时应该将数据传输到组件。
试一试,我们首先在服务端添加这个功能,然后在某个时刻调用它。
现在,在sidebar那里使用
你知道会发生什么么?当在加载我们的sidebar代码时,它在dataService注册了renderCities方法。
在这种情况下,当我们的数据发生更改时,dataService就会调用此函数(由于addPlace()的调用)。
确切地说,我们的代码的一部分是事件的SUBSCRIBER,另一部分是PUBLISHER(服务方法)。我们已经实现了发布 - 订阅模式的最基本形式,这是几乎所有高级异步概念的基本概念。
还有呢?
请注意,我们的代码,仅限于一个监听组件(即,一位订阅者)。如果其他方法也用了这个subscribe方法来传递的话,它会覆盖掉dataService的changeListener变量,为了解决这个问题,我们需要用数组来存储监听者。
现在,我们可以稍微整理一下代码并编写一个函数来为我们调用所有的监听者:
这样我们也可以连接map.js组件,以便它对服务中的所有操作做出正确的反应:
如果需要传递参数怎么办?我们可以使用监听者的参数直接获得。像这样:
然后,可以轻松地在组件中检索数据:
这里还有更多的可能性 - 我们可以为不同类型的行为创建不同的主题(或渠道)。此外,我们可以提取发布和订阅方法到一个文件并从那里使用它。但就目前而言,还OK啦 - 以下是使用我们刚刚创建的相同代码的应用的简短视频
(译者注,大家去原文那里看吧)
<center> * * * </center>
(译者注:接下来的内容是作者关于这个模式的想法,他说,那些组件的概念比如RxjS,虽然它们功能更强大、概念更加地复杂,但是基本概念都是上文讲过的。它们搞得太复杂了而已。并且这个模式也可以套在其他的地方。如DOM操作。另外,本文只是讲了最基本的,还有很多地方可以拓展。比如取消订阅、事件订阅等等。最后作者还建议我们多点搞优秀的源代码,down下来用debugger研究源码。挖掘出它们最基本的思想。多动手、多思考,不要害怕专有名词,觉得很高大上、很难理解。其实就是那么一回事。有些人搞得太复杂了。)
(译者为什么不翻译完呢?因为想读者们自己尝试去翻译,最重要的原因,是因为译者懒。。。)
Does this whole publish-subscribe thing resemble something you might already know? After giving it some thought, it’s the pretty same mechanism that you use in element.addEventListener(action, callback). You subscribe your function to a particular event, which ich being called when some action is published by element. Same story.
Going back to the title: why is this thing so bloody important? After all, in the long run, there is little sense in holding up to vanilla JavaScript and modifying the DOM manually — same goes with manual mechanisms for passing and receiving events. Various frameworks have their established solutions: Angular uses RxJS, React have state and props management with possibility of boosting it with redux, literally every usable framework or library have its own method of data synchronization.
Well, the truth is that all of them use some variation of publish-subscribe pattern.
As we already said — DOM event listeners are nothing more than subscribing to publishing UI actions. Going further: what is a Promise? From certain point of view, it’s just a mechanism that allows us to subscribe for completion of a certain deferred action, then publishes some data when ready.
React state and props change? Components’ updating mechanisms are subscribed to the changes. Websocket’s on()? Fetch API? They allow to subscribe to certain network action. Redux? It allows to subscribe to changes in the store. And RxJS? It’s a shameless one big subscribe pattern.
It’s the same principle. There are no magic unicorns under the hood. It’s just like the ending of the Scooby-Doo episode.
It’s not a great discovery. But it’s important to know:
No matter what method of solving
asynchronous problem will you use,
it will be always some variation of
the same principle: something
subscribes, something publishes.
That’s why it is so essential. You can always think of publish and subscribe. Take note and keep going. Keep building larger and more complex application with many asynchronous mechanisms — and no matter how difficult it may look like, try to synchronize everything with publishers and subscribers.
<center> * * * </center>
Still, there is a number of topics untouched in this story:
- Mechanisms of unsubscribing listeners when not needed anymore,
- Multi-topic subscribing (just like addEventListener allows you to subscribe to different events),
- Expanded ideas: event buses, etc.
To expand your knowledge, you can review a number of JavaScript libraries that implement publish-subscribe in its bare form:
- https://github.com/mroderick/PubSubJS
- https://github.com/Sahadar/pubsub.js
- https://github.com/shystruk/publish-subscribe-js
Go ahead and try to use them, break them and run the debugger in order to see what happens under the hood. Also, there is a number of great articles that describe this idea very well.
You can find the code from this story in the following GitHub repository:
https://github.com/hzub/pubsub-demo/
Keep experimenting and tinkering—and don’t be afraid of the buzz words, they’re usually just regular code in disguise. And keep thinking.
See you!