【翻译计划4】Why every beginner front-end developer should know publish-subscribe pattern?

Created: Mar 23, 2021 1:57 PM
Year: 2018
link: https://itnext.io/why-every-beginner-front-end-developer-should-know-publish-subscribe-pattern-72a12cd68d44
标签: 前端, 技术
About writer: Hubert Zub: JavaScript guy. Occasional speaker and writer, community animator. Advisor@graphqleditor.com.

【About Article】小朱:发布—订阅模式在许多框架中有使用,比如在react中的state和props。也在之前的开发过程中遇到过state和props的坑,了解了这一基础原理能尽量避免一些关于数据传递或是渲染方面的坑。同时,此文还有一些关于代码结构方面的知识,可以让你意识到应该如何去管理你的代码文件,让你的程序变得整洁,可扩展。

以下为本文正文内容


为什么每一个初学的前端开发都需要知道发布-订阅模式?

AKA: 如何以一种不那么痛苦的方式去理解异步的JS代码。

在某一瞬间, 你将你的注意力从界面的样式设计、风格和排版转移到了逻辑、框架和编写JavaScirpt代码上,你会发现你正处于你的网页开发旅程中最激动人心的时刻之一。一切的问题都将从这一刻开始。

开始过程类似于此

此时此刻,你开始涉及到了JS,它不仅仅是一些简单的JQuery技巧和视觉效果。你可以总览自己做的网页程序——不仅仅只是简单的网页而已。

当你花费越来越多的精力写JS代码时,你开始思考页面交互、子系统以及系统逻辑。一切开始运转起来—最后,你能从你的app中感受到类似生命的演变。一个全新的、令人激动的世界正出现在你的面前。随之而来的,还有大量的新的挑战。

最后会变成这样,但这并不是结局。

但你并不会感到气馁—新的想法会不断涌现,而你写的代码也会越来越多。不断地测试从某篇博客中查到的方法,不断地修改可以解决问题的多种方法。

然后你开始挠头。

开始挠头

你的 script.js 文件内容开始变多。一个小时以前它只有200行,而现在它超过了500行。你会想:“这并不是什么大事。",你阅读了关于整洁和可维护的代码的知识—并且打算将之实现,你开始将你的代码根据逻辑拆分至不同的文件、不同的模块或是不同的组件。一切好像看起来再次变得美观。一切都被编排地非常整洁,就像是一个组织的一丝不苟的图书馆。你感到非常快乐,因为大量的文件都有合适的命名,并且被放置在确定的文件夹下,代码变得模块化并且更加易维护。

不知从哪儿冒出来,你又开始挠头——但原因还不清楚。


Web应用程序的行为很少是线性的。事实上,任何web程序的许多操作都应该是突然发生的。(有些时刻甚至是意外地或是自发地)

应用程序需要恰当的对网络事件、用户交互操作、定时机制和各种延迟操作作出反应。不知从哪儿冒出来的,被命名为”条件竞争"(race condition)“异步性”(asynchronicity)的丑陋怪物正在敲你的门。

expectation&reality

你需要将你的帅气的模型框架结构与丑陋的异步代码相结合。这时候,痒就变得明显了(开始挠头)。一个困难的问题被抛出:我到底该把这段代码放在哪里?

你也许已经将你的程序优美地分为了若干个块。导航和内容控件能够被整洁的放置在合适的文件夹内,更小的辅助性的script文件包含着重复性的代码,这些代码可以用于解决单调而又平凡的任务。所有代码都可以被一个简单的入口文件app.js所管理,所有的一切也将从这个文件开

但是你的目标是要在这个程序的某一部分调用异步代码,处理它,并将结果发送到另一个需要它的部分。

异步代码应该被放置在UI组件中吗?或是放置在主文件之中?你的应用程序中的哪一部分应该负责处理回应?哪一个要处理?关于错误处理呢?你在脑海中尝试了大量的方法,但你的不安感并不会消失,你会意识到如果你要扩大代码的规模,会变得越来越困难,痒的感觉并不会消失(挠头不止)。你需要找到一些完美的、多功能的解决方案。

别紧张,这并不是你的问题,事实上,你的构思越有条例,痒的感觉就会越发强烈。

你开始阅读如何处理这个问题的信息,并且寻找已经可以使用的解决方案。刚开始,你阅读到了Promise相较于回调的优势,接下去的一小时,你发现自己正在尝试理解什么是RxJS(以及为什么网上有些人说对于网站开发人员来说这是唯一合法的解决方案)。在更深入的阅读后,你试图去理解,为什么有的博主认为没有redux-thunkredux毫无意义,其他博主对于redux-saga的redux也持有同样看法。

在这一天结束的时候,你的脑子被大量的词语占据。你的大脑想象了大量的可能行得通的方法。所以,为什么会有这么多的方法?难道这个问题不应该是很简单的吗?难道人们是真的希望在网上对这个问题进行争论而不是去开发一种好的方法吗?

这是因为这个问题不简单。

无论使用哪种框架,对异步的代码正确安排不是,也永远不会简单明了。没有一种唯一的,已确立的并且能满足所有目的的解决方案。而解决方案很大程度上是由需求、环境、希望的结果以及许多其他的因素决定。

并且,这篇文章不会提供能解决全部问题的解决方案,但是它将会帮助你更轻松地去理解异步代码-因为它完全基于很基本的准则。

通用部分

从某些角度来看,编程语言在结构上来说并不复杂。毕竟,它们只是一种能够在存储硬件上存储数据并通过一些条件语句或是函数调用改变数据的流动的、笨拙的类似计算器的工具。JavaScript作为一种命令式的并且稍微有点面向对象的语言,在这里并没有什么不同。

这意味着,在幕后,所有宇宙中的异步机制(无论是redux-saga, RxJS, observables 或者 成千上万的其他衍生机制)必然是依赖于同一个基本准则的。它们的魔法并没有那么神奇—它必然是基于广为人知的基础,在更底层并没有发明创造出新的东西。

为什么这一事实如此重要,让我们来考虑一个例子。

...

让我们做点什么吧

思考一个简单的程序,一个非常简单的程序。举个例子,一个小的app用于在地图上标记你最喜欢的地方。这里并没有奇特的地方:仅仅是在右边显示一个地图视图,然后在左边添加一个简单的侧边栏。点击地图就会在地图上存储一个新的标记。

to do a map app

当然,我们也是有野心的,我们打算添加一些额外的功能:我们想要用本地存储来记住我们所标记的位置的列表。

现在,基于以上描述,我们可以为我们的app绘制一个基础的流程图。

workflow1.0

如你所见,这并不复杂。

为了简便起见,下面的例子将不使用任何的框架以及UI库,仅仅涉及普通的JavaScript,同时,我们将使用小部分的谷歌地图API-如果你想自己实现一个小app你应该在https://cloud.google.com/maps-platform/#get-started上注册一个API Key。

让我们开始编写代码并且创建一个简单的模型。

let googleMaps;
let myPlaces = [];

function init() {
    googleMaps = new googleMaps.maps.Map(document.getElementById('map'), {
        center: { lat: 0, lng: 0 },
        zoom: 3
    });

    googleMaps.markerList = [];
    googleMaps.addListener('click', appPlace);

    const placesFromLocalstorage = JSON.parse(localStorage.getItem('myPlaces'));
    // 如果localstorage中存在则设置为当前的地点数组
    if (Array.isArray(placesFromLocalstorage)) {
        myPlaces = placesFromLocalstorage;
        renderMarkers();
    }
}

function addPlace(event) {
    myPlaces.push({
        position: event.latLng
    });
    // 在得到新的地点时,加入到localstorage中去
    localStorage.setItem('myPlaces', JSON.stringify(myPlaces));
    renderMarkers();
}

function renderMarkers() {
    // 移除所有的标签
    googleMap.markerList.forEach((m) => m.setMap(null));
    googleMap.markerList = [];

    // 遍历标签数组
    myPlaces.forEach((place) => {
        const marker = new google.maps.Marker({
            position: place.position,
            map: googleMap
        });

        googleMap.markerList.push(marker);
    });
}

init();

我们快速分析一下这个代码:

  • init()函数会使用Google Maps API来初始化地图元素,设置click的操作,然后从local Storage中加载标记
  • addPlace()处理地图click事件,然后增加新的地点到list,并调用marker 渲染函数。
  • renderMarkers()遍历数组中的位置,清楚地图元素后,将标记push到list中。

在此处暂时先不理会一些不完美的地方,比如错误控制等,作为一个原型,这段代码已经足够了。让我们来做一些标记。

<!DOCTYPE html>
<html>
<head>
    <title>My Favorite Places</title>
    <link rel="stylesheet" href="/styles.css"/>
</head>
<body>
    <div class="sidebar">
        <h1>My fav places on earth v1.0</h1>
        <div class="main-content-area">
            <div id="map"></div>
        </div>
    </div>
    <script src="https://maps.googleapis.com/maps/api/js?key=API_KEY"></script>
    <script src="map.js"></script>
</body>
</html>

假设我们添加了一些样式,但是因为不相关,所以不讨论它。相信我,这段代码已经可以实现我们所需的功能了。

demo-v1.png

尽管有些丑陋,但是它已经实现了我们所需要的功能。但是它并不可扩展

首先,在这段代码中我们混淆了代码的职责。

如果你曾听说过SOLID原则(国内博客),你应该知道我们早已打破了第一条原则:单一职责原则。在我们的例子中—尽管它很简单—一个js代码文件就包含了包括用户行为,数据处理以及异步行为,然而并不应该这么做。也许你会说:为什么?代码不是能跑起来吗?功能不是执行正常吗?当然,它是实现了功能,但是在后续功能开发的过程中会变得很难去维护它。

让我用另一种方式来说服你。想象一下我们将要扩展我们的app并且加一些新的特性。

add-feature

首先,我们想要在侧边栏中添加一个已标记的位置列表,其次我们想要通过Google API来查到城市的名字—这里将会出现我们要提到的异步机制。

下面就是我们的新流程图:

workflow2.0

NOTE:查询城市名字并不是很复杂的方法,Google Maps提供了非常简单的API,你也可以自己尝试一下

在通过Google API获取城市名称的过程中有一个非常明确的特性:城市名并不是瞬间被获取的。这需要调用谷歌JavaScript库中的服务,并且需要等一段时间才能收到返回的结果。这个过程有点麻烦—但是确是一个很值得学习的例子。

让我们回到UI并且注意一下很容易发现的事情。页面内有两个独立接口的区域:侧边栏以及中间用于显示地图的区域。我们绝不应该写一大片的代码用于同时处理这两个页面。原因很显然—如果未来我们将有4个组件呢?或者6个?或者100个?我们需要将我们的代码分块—这样我们就将有用两个分离的JavaScript文件,一个负责侧边栏,一个负责地图,那么问题来了哪一个文件需要用于存储位置信息的列表呢?

两种存储数组信息的方式

哪一种方式是正确的呢?第一种或是第二种?当然答案是两者皆不。记住单一职责原则,为了保持整洁和模块化,我们应该用某种方式分离关注点,并将数据逻辑放在其他地方。可以看到如下的结构:

可行的方案

代码分离非常有效的办法:我们可以将存储和逻辑移动到其他的文件中,这一类文件将只处理数据。这些文件可被称为service文件,将负责诸如与本地存储同步等问题和机制。相反的,组件将仅提供接口部分,它应该是SOLID模式的。接下去尝试介绍这种模式。

首先是Service 代码:

let myPlaces = [];
const geocoder = new google.maps.Geocoder;

export function addPlace(latLng) {
    // 插入能查询城市名的Google API
    // 第二个参数是当结果返回时用于回调
    geocoder.geocode({'location': latLng}, function (results) {
        try {
          // 在此回调函数中 提取城市名称
            const cityName = results
                .find(result => result.types.includes('locality')
                .address_components[0]
                .long_name;

            // 加入位置列表中
            myPlaces.push({ postion: latLng, name: cityName});
            
            // 同步存储进入本地存储
            localStorage.setItem('myPlaces', JSON.stringify(myPlaces));
        }
        catch (e) {
            // 当城市未被查询到时,显示一条信息如下。
            console.log('位置上不存在城市信息。')
        }
    }
}

// 用于返回现有的位置列表
export function getPlaces() {
    return myPlaces;
}

// 初始化从本地存储中读取出来的数据
function initLocalStorage() {
    const placesFromLocalstorage = JSON.parse(localStorage.getItem('myPlaces'));
    if (Array.isArray(placesFromLocalstorage)) {
        myPlaces = placesFromLocalstorage;
        publish();
    }
}

initLocalStorage();

地图内容的代码:

let googleMap;

import { addPlace, getPlaces } from './dataService.js';

function init() {
  googleMap = new googleMap.maps.Map(document.getElementById('map'), {
    center: { lat: 0, lng: 0 },
    zoom: 3,
  });

  googleMap.markerList = [];
  googleMap.addListener('click', addMarker);
}

function addMarker(event) {
  addPlace(event.latLng);
  renderMarkers();
}

function renderMarkers() {
  // 移除所有的标记
  googleMap.markerList.array.forEach(m => {
    m.setMap(null);
  });
  googleMap.markerList = [];

  getPlaces().forEach(place => {
    const marker = new google.maps.Marker({
      postion: place.position,
      map: googleMap,
    });
    googleMap.markerList.push(marker);
  });
}

init();

侧边栏组件代码:

import { getPlaces } from './dataService.js';

function rederCities() {
  // 获取用于显示城市列表的元素
  const cityListElement = document.getElementById('citiesList');

  // 清空元素内容
  cityListElement.innerHTML = '';

  // 在元素内逐一添加内容
  getPlaces().forEach(place => {
    const cityElement = document.createElement('div');
    cityElement.innerText = place.name;
    cityListElement.appendChild(cityElement);
  });
}

rederCities();

最让人头皮发痒的部分已经被解决了,代码再一次被整洁地放在合适的模块中。在我们开始感到舒服之前,让我们来运行一下这段代码。

问题...

在操作了一番之后,界面并没有发生什么变化。

这是为什么呢?我们并没有实施任何同步的操作,在使用导入的方法添加一个位置后,我们并没有在任何位置接收关于它的信号,我们甚至不能将getPlace()方法放置在 addPlace() 的下一行,因为查询城市是一个异步操作,完成它需要一定的时间。即使在addPlace()运行后,结果也不会立刻返回。

事情发生在后台,但是我们的界面并不会感知到结果的变化—在添加了一个标记在地图上后,我们并不能见到侧边栏中对于城市列表的变化。

那么如何去解决这个问题呢?

一个相当简单的解决方案就是去每隔一段时间去查询一下我们的服务—-举个例子,每个组件都可以经由一个间隔函数从service中查询结果。

比如

setInterval(() => {
  rederCities();
}, 1000);

这有效吗?虽然写的很凌乱,但是它的确是可以见效。但是这是最佳方案吗?并不是!

这种方法会产生大量的没有意义的事件循环,因为这部分代码仅在某一次数据获取时才算真正有效,大多时间都是无效的,但它又给我们的app带来了大量的事件循环。

毕竟,你并不会每个小时就去附近的快递驿站去看看你的包裹是否寄到了。同样的,你如果将你的车放在修车店修理,你也不会每一个小时就打电话给修车师傅问他你的车是不是修好了(至少希望你不是这样的人)。相反,我们则是应该等待一个车修好了的电话。然后,当车修好了时,修车师傅如何知道应该给谁打电话?这真是个愚蠢的问题—我们当然会给他留个联系方式。

既然如此,就让我们用JavaScript来模仿“留下联系方式”这个过程。

...

JavaScript是一个非常有魔力的语言—其中一个比较奇怪的特性就是,他将函数作为和其他变量一样的存在。使用正式化的语言来描述,即使“函数是第一类公民",这意味着任一函数都可以被作为变量,或者作为参数的形式传递给另一个函数。你应该早已了解这个机制:比如setTimeoutsetInterval 以及许多的事件监听回调函数。他们的工作原理就是将函数以参数的形式进行传递。

这个特性在异步情景中是最基本的。

我们可以定义一个函数用于更新我们的UI—并且将他传递到将被调用的位置。

sendfunc

利用这个机制,我们可以把 renderCities 传递到dataService中去。在这里,它将会在需要的时候被调用:毕竟,service明确地知道什么时候数据应该被传递到组件中去。

让我们尝试一下,我们将在service处增加一个用于存储函数的容器,并在需要的时候调用它。

let googleMaps;
let myPlaces = [];

let changeListener = null;
export function subscribe(callbackFunction) {
    changeListener = callbackFunction;
}

export function addPlace(latLng) {
    // 插入能查询城市名的Google API
    // 第二个参数是当结果返回时用于回调
    geocoder.geocode({'location': latLng}, function (results) {
        try {
          // 在此回调函数中 提取城市名称
            const cityName = results
                .find(result => result.types.includes('locality')
                .address_components[0]
                .long_name;

            // 加入位置列表中
            myPlaces.push({ postion: latLng, name: cityName});

            if (changeListener) { changeListener(); }
            
            // 同步存储进入本地存储
            localStorage.setItem('myPlaces', JSON.stringify(myPlaces));
        }
        catch (e) {
            // 当城市未被查询到时,显示一条信息如下。
            console.log('位置上不存在城市信息。')
        }
    }
}

现在将它和侧边栏联系起来

import { getPlaces, subscribe } from './dataService.js';

function rederCities() {
  // 获取用于显示城市列表的元素
  const cityListElement = document.getElementById('citiesList');

  // 清空元素内容
  cityListElement.innerHTML = '';

  // 在元素内逐一添加内容
  getPlaces().forEach(place => {
    const cityElement = document.createElement('div');
    cityElement.innerText = place.name;
    cityListElement.appendChild(cityElement);
  });
}

rederCities();

subscribe(renderCities);

你看出来这里发生了什么了吗?当侧边栏的代码被加载时,它将**renderCities这个函数注册进了dataService文件中。**

之后dataService将会在需要的时候调用renderCities—在这个例子中是当数据发生改变的时候。

准确地说:我们代码中的一部分(sidebar component)是这个事件的订阅者(SUBSCRIBER),另一段代码(service method)则是发布者(PUBLISHER)。我们实现了最基础的发布-订阅模式,这是几乎所有高级异步概念的基本概念。

还有什么?

注意使用此代码,我们只是将自己限制在了一个监听组件中(换句话说,只有一个订阅者),如果有任意其他的函数通过subscribe()注册,它将会覆盖掉之前传入的函数。要解决这个问题,我们可以使用一整个监听者(listerner)数组。

let changeListener = [];
export function subscribe(callbackFunction) {
    changeListener.push(callbackFunction);
}

现在我们可以整理一下我们的代码并且写一个可以调用所有的监听者(listerner)的函数。

function publish() {
    changeListeners.forEach((changeListener) => { changeListener(); });
}

export function addPlace(latLng) {
    // 插入能查询城市名的Google API
    // 第二个参数是当结果返回时用于回调
    geocoder.geocode({'location': latLng}, function (results) {
        try {
          // 在此回调函数中 提取城市名称
            const cityName = results
                .find(result => result.types.includes('locality')
                .address_components[0]
                .long_name;

            // 加入位置列表中
            myPlaces.push({ postion: latLng, name: cityName});

            publish();
            
            // 同步存储进入本地存储
            localStorage.setItem('myPlaces', JSON.stringify(myPlaces));
        }
        catch (e) {
            // 当城市未被查询到时,显示一条信息如下。
            console.log('位置上不存在城市信息。')
        }
    }
}

我们也可以将此代码写在map.js组件中,那么它也会在service有动作时做出合适的响应。

let googleMap;

import { addPlace, getPlaces, subscribe } from './dataService.js';

/* ... */

init();
renderMarkers();

subscribe(renderMarkers);

function renderMarkers() {

/* ... */

}

那么当将订阅者用作一个传递数据的方法呢?此时我们只需要直接使用一个带有参数的侦听器。就像下面这样

function publish(data) {
    changeListeners.forEach((changeListener) => { changeListener(data); });
}

export function addPlace(latLng) {
    // 插入能查询城市名的Google API
    // 第二个参数是当结果返回时用于回调
    geocoder.geocode({'location': latLng}, function (results) {
        try {
          // 在此回调函数中 提取城市名称
            const cityName = results
                .find(result => result.types.includes('locality')
                .address_components[0]
                .long_name;

            // 加入位置列表中
            myPlaces.push({ postion: latLng, name: cityName});

            publish(myPlaces);
            
            // 同步存储进入本地存储
            localStorage.setItem('myPlaces', JSON.stringify(myPlaces));
        }
        catch (e) {
            // 当城市未被查询到时,显示一条信息如下。
            console.log('位置上不存在城市信息。')
        }
    }
}

这样我们可以轻松的检索组件内的数据:

import { getPlaces, subscribe } from './dataService.js';

function rederCities(placesArray) {
  // 获取用于显示城市列表的元素
  const cityListElement = document.getElementById('citiesList');

  // 清空元素内容
  cityListElement.innerHTML = '';

  // 在元素内逐一添加内容
  placesArray.forEach(place => {
    const cityElement = document.createElement('div');
    cityElement.innerText = place.name;
    cityListElement.appendChild(cityElement);
  });
}

rederCities(getPlaces());

subscribe(renderCities);

这里拥有更多的可能性。我们可以为不同的actions开展不同的话题。并且我们可以提取publish 和subscribe方法来完全分离代码文件,并能在其中使用它。

...

整个发布-订阅模式与你可能已知的某些事情相似吗?在思考后,你会发现这与你使用的element.addEventListener(action, callback)是非常相似的。你在特定的事件中订阅了你的函数,它将会在某些元素发布action时被调用。这是同样的道理。

回过头来看标题:为什么这件事情如此重要?毕竟,在长远来看,支持原版的JavaScript并且手动修改DOM元素并没有什么意义—手动的传递和接收事件也同样没有什么意义。各种框架都有其既定的解决方案:Angular使用RxJS,React使用state和props来管理,并且可能通过Redux来增强它,实际上每个可用的框架和库都拥有自己的数据同步的方法。

事实上,他们使用的都是发布-订阅模式的变形

正如我们所说—DOM事件监听者不外乎订阅和发布UI事件。更深层次的:什么是Promise?从一个确定的点来说,它订阅(subscribe)了一个延迟事件的完成,然后当完成时会发布(publishes)获得的数据。

React的state和props发生变化,组件更新机制被变化所订阅。网络通信的on()事件?获取API?他们都允许订阅特定的网络事件。Redux呢?它允许订阅(subscribe)在store中发生的改变。(RxJS呢?这是一个无耻的大的订阅模式。)

同样的原则。兜帽下面没有神奇的独角兽。就像史酷比的结局一样。

the ugly truth

这并不是一个伟大的发现。但是却十分值得去了解:

无论你将使用什么方法来解决异步问题,它将总是符合这个原则的变体:某一部分订阅,某一部分发布。

这就是为什么它如此重要。你总是能想到发布和订阅。做下笔记并持续前进。使用许多的异步机制来持续构建更大更复杂的程序—无论他看起来会是多么困难,尝试着使用发布者和订阅者来让他们变同步。

...

当然仍然有许多话题没有在此文中提及:

  • 当不需要监听者时如何对监听者进行移除的机制
  • 多主题订阅(就像addEventListener啊允许你订阅不同的事件
  • 扩展思路:事件巴士等。

为了扩展你的知识,你可以浏览一系列的使用了发布-订阅模式的JS代码库。

直接使用它们,并通过调试他们来观察在面具下发生了什么。同时,这里有一系列的非常好的文章描述了发布—订阅模式。

你可以在一下的GitHub仓库中查看本文使用的代码

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

继续尝试和理解—不要害怕不懂的代码,他们也不过是经过伪装的常规代码。

最后修改:2022 年 01 月 23 日
如果觉得我的文章对你有用,请随意赞赏