Rxjs 响应式编程-第三章: 构建并发程序

构建并发程序

并发是正确有效地同时做几件事的艺术。为了实现这一目标,我们构建我们的程序来利用时间,以最有效的方式一起运行任务。 应用程序中的日常并发示例包括在其他活动发生时保持用户界面响应,有效地处理数百个客户的订单。

在本章中,我们将通过为浏览器制作一个用于射击的太空飞船游戏来探索RxJS中的并发性和纯函数。我们将首先介绍Observable管道,这是一种链接Observable运算符并在它们之间传递状态的技术。然后,我将向您展示如何使用管道来构建程序,而不依赖于外部状态或副作用,将所有逻辑和状态封装在Observables本身中。

视频游戏是需要保持很多状态的计算机程序,但是我们将使用Observable管道和一些优秀的RxJS运算符的功能编写我们的游戏,没有任何外部状态。

简洁和可观察的管道

Observable管道是一组链接在一起的运算符,其中每个运算符都将Observable作为输入并返回Observable作为输出。 我们一直在使用本书中的管道; 在使用RxJS进行编程时,它们无处不在。 下面是一个简单的事例:

spaceship_reactive/pipeline.js

Rx.Observable
    .from(1, 2, 3, 4, 5, 6, 7, 8)
    .filter(function(val) { return val % 2; })
    .map(function(val) { return val * 10; });

管道是独立的。 所有状态从一个运算符流向下一个运算符,而不需要任何外部变量。但是当我们构建我们的响应式程序时,我们可能会想要将状态存储在Observable管道之外(我们在Side Effects和External State中讨论了外部状态)。这迫使我们跟踪我们在管道外设置的变量,所有这些bean计数都很容易导致错误。为避免这种情况,管道中的运算符应始终使用纯函数。

在相同输入的情况下,纯函数始终返回相同的输出。当我们可以保证程序中的函数不能修改其他函数依赖的状态时,设计具有高并发性的程序更容易。这就是纯粹的功能给我们带来的东西。

避免外部状态

在下面的例子中,我们计算到目前为止每隔一秒产生的偶数。我们通过从interval创建一个Observable并在我们收到的值是偶数时增加evenTicks:

spaceship_reactive/state.js

var evenTicks = 0;

function updateDistance(i) {
    if (i % 2 === 0) {
        evenTicks += 1;
    }
    return evenTicks;
}

var ticksObservable = Rx.Observable
    .interval(1000)
    .map(updateDistance)
    
ticksObservable.subscribe(function() {
    console.log('Subscriber 1 - evenTicks: ' + evenTicks + ' so far');
});

这是程序运行四秒后得到的输出:

Subscriber 1 - evenTicks: 1 so far
Subscriber 1 - evenTicks: 1 so far
Subscriber 1 - evenTicks: 2 so far
Subscriber 1 - evenTicks: 2 so far

现在,测试一下,让我们为ticksObservable添加另一个订阅者:

spaceship_reactive/state.js

var evenTicks = 0;
function updateDistance(i) {
    if (i % 2 === 0) {
        evenTicks += 1;
    }
    return evenTicks;
}

var ticksObservable = Rx.Observable
    .interval(1000)
    .map(updateDistance)
    
ticksObservable.subscribe(function() {
    console.log('Subscriber 1 - evenTicks: ' + evenTicks + ' so far');
});

ticksObservable.subscribe(function() {
    console.log('Subscriber 2 - evenTicks: ' + evenTicks + ' so far');
});

输出现在如下:

Subscriber 1 - evenTicks: 1 so far
Subscriber 2 - evenTicks: 2 so far
Subscriber 1 - evenTicks: 2 so far
Subscriber 2 - evenTicks: 2 so far
Subscriber 1 - evenTicks: 3 so far
Subscriber 2 - evenTicks: 4 so far
Subscriber 1 - evenTicks: 4 so far
Subscriber 2 - evenTicks: 4 so far

等等--第二个订阅者的偶数计数不应该起作用的!他应该跟第一个订阅者的计数完全一致。正如您可能已经猜到的那样,Observable管道将为每个订户运行一次,增evenTicks两次。

共享外部状态引起的问题通常比这个例子更微妙。在复杂的应用程序中,打开通向管道外部状态的大门会导致代码变得复杂,并且很快就会出现错误。解决方案是尽可能多地封装管道内的信息。 这是我们可以重构前面的代码以避免外部状态的方法:

spaceship_reactive/state.js

function updateDistance(acc, i) {
    if (i % 2 === 0) {
        acc += 1;
    }
    return acc;
}

var ticksObservable = Rx.Observable
    .interval(1000)
    .scan(updateDistance, 0);
    
ticksObservable.subscribe(function(evenTicks) {
    console.log('Subscriber 1 - evenTicks: ' + evenTicks + ' so far');
});

ticksObservable.subscribe(function(evenTicks) {
    console.log('Subscriber 2 - evenTicks: ' + evenTicks + ' so far');
});

预期输出:

Subscriber 1 - evenTicks: 1 so far
Subscriber 2 - evenTicks: 1 so far
Subscriber 1 - evenTicks: 1 so far
Subscriber 2 - evenTicks: 1 so far
Subscriber 1 - evenTicks: 2 so far
Subscriber 2 - evenTicks: 2 so far
Subscriber 1 - evenTicks: 2 so far
Subscriber 2 - evenTicks: 2 so far

使用scan,我们完全避免外部状态。我们将累计的偶数传递给updateDistance而不是依赖外部变量来保持累积值。 这样我们就不会增加每个新订户的计数。

大多数时候我们可以避免依赖外部状态。使用它的常见方案是缓存值或跟踪程序中更改的值。 但是,正如您将在前面的Spaceship Reactive!中看到的那样,可以通过其他几种方式处理这些场景。例如,当我们需要缓存值时,RxJS的Subject Class(后面会讲到)可以提供很多帮助,当我们需要跟踪游戏的先前状态时,我们可以使用像Rx.Observable.scan这样的方法。

管道是高效的

我第一次将一堆操作符链接到管道中来转换序列,我的直觉是它不可能有效。我知道通过链接运算符在JavaScript中转换数组是很昂贵的。然而在本书中,我们通过将序列转换为新序列来设计程序。 这会不会很低效呢?

链接在Observables和数组中看起来类似; 也都有filtermap等方法。但是有一个至关重要的区别:数组方法由于每个操作而创建一个新数组,并且完全由下一个操作符转换。另一方面,可观察的管道不会创建中间的Observable,并且可以一次性将所有操作应用于每个元素。因此,Observable仅被遍历一次,这使得Observable链接变得高效。 看看以下示例:

spaceship_reactive/array_chain.js

stringArray // represents an array of 1,000 strings
    .map(function(str) {
        return str.toUpperCase();
    })
    .filter(function(str) {
        return /^[A-Z]+$/.test(str);
    })
    .forEach(function(str) {
        console.log(str);
    });

假设stringArray是一个包含1,000个字符串的数组,我们要将其转换为大写,然后过滤掉包含字母字符以外的任何字符串(或根本没有字母)。然后我们要将结果数组的每个字符串打印到控制台。

这是背后发生的事情:

  1. 遍历数组并创建一个包含所有项大写的新数组。
  2. 遍历大写数组,创建另一个包含1,000个元素的数组。
  3. 遍历筛选的数组并将每个结果记录到控制台。

在转换数组的过程中,我们迭代了三次数组并创建了两个全新的大数组。 这非常低效! 如果您关注性能或者处理大量项目,则不应该以这种方式编程。

spaceship_reactive/array_chain.js

stringObservable // represents an observable emitting 1,000 strings
    .map(function(str) {
        return str.toUpperCase();
    })
    .filter(function(str) {
        return /^[A-Z]+$/.test(str);
    })
    .subscribe(function(str) {
        console.log(str);
    });

Observable的管道看起来与数组链非常相似,但是又不同。 在一个Observable中,在我们订阅它之前,没有任何事情发生过,无论我们应用了多少查询和转换。 当我们调用像map这样的变换时,我们其实只运行了一个函数,它将对数组的每个项目进行一次操作。 因此,在前面的代码中,这将是会发生的事情:

  1. 创建一个大写函数,该函数将应用于Observable的每个项目,并在Observer订阅它时返回将发出这些新项目的Observable。
  2. 使用先前的大写函数组合过滤器函数,并返回一个Observable,它将发出新项目,大写和过滤,但仅在Observable订阅时候,才会运行。
  3. 通过订阅Observable来发布,通过我们所有操作器的数据将会被发布出来。

使用Observables,我们只会查看一次列表,只有在绝对需要时才会应用转换。 例如,假设我们在上一个示例中添加了一个take运算符:

spaceship_reactive/array_chain.js

stringObservable
    .map(function(str) {
        return str.toUpperCase();
    })
    .filter(function(str) {
        return /^[A-Z]+$/.test(str);
    })
    .take(5)
    .subscribe(function(str) {
        console.log(str);
    });

take使得Observable只发出我们指定的前n个项目。在我们的例子中,n是五,所以在数千个数据中,我们只会收到前五个。 很酷的部分是我们的代码永远不会遍历所有项目; 只会遍历前5个。

这使开发人员的生活更加轻松。 你可以放心,在操作序列时,RxJS只会做必要的工作。 这种操作方式称为惰性评估,在Haskell和Miranda等函数式语言中非常常见。

RxJS的主体类

Subject是一种实现Observer和Observable类型的类型。 作为Observer,它可以订阅Observable,并且作为Observable,它可以生成值并让Observers订阅它。

在某些情况下,单个Subject可以执行Observers和Observables组合的工作。例如,为了在数据源和Subject的侦听器之间创建代理对象,我们可以使用:

spaceship_reactive/subjects.js

var subject = new Rx.Subject();
var source = Rx.Observable.interval(300)
    .map(function(v) { return 'Interval message #' + v; })
    .take(5);
    
source.subscribe(subject);

var subscription = subject.subscribe(
    function onNext(x) { console.log('onNext: ' + x); },
    function onError(e) { console.log('onError: ' + e.message); },
    function onCompleted() { console.log('onCompleted'); }
);

subject.onNext('Our message #1');
subject.onNext('Our message #2');

setTimeout(function() {
    subject.onCompleted();
}, 1000);

输出:

onNext: Our message #1
onNext: Our message #2
onNext: Interval message #0
onNext: Interval message #1
onNext: Interval message #2
onCompleted

在前面的示例中,我们创建了一个新的Subject和一个每秒发送一个整数的源Observable。 然后我们给Subject订阅Observable。之后,我们订阅了一个Observer到Subject本身。 Subject本身现在表现为Observable。

接下来,我们使Subject发出自己的值(message1和message2)。在最终结果中,我们获取Subject自己的消息,然后从源Observable获取代理值。来自Observable的值后来因为它们是异步的,而我们立即使Subject的自己的值。请注意,即使我们告诉源Observable采用前五个值,输出也只显示前三个。那是因为在一秒之后我们在主题上调用onCompleted。 这将完成对所有订阅的通知,并在这种情况下覆盖take操作符。

Subject类为创建更专业的Subject提供了基础。事实上,RxJS带有一些有趣的:AsyncSubjectReplaySubjectBehaviorSubject

AsyncSubject

仅当序列完成时,AsyncSubject才会仅发出序列的最后一个值。然后永远缓存此值,并且在发出值之后订阅的任何Observer将立即接收它。AsyncSubject便于返回单个值的异步操作,例如Ajax请求。

让我们看一个订阅range的AsyncSubject的简单示例:

spaceship_reactive/subjects.js

var delayedRange = Rx.Observable.range(0, 5).delay(1000);
var subject = new Rx.AsyncSubject();
delayedRange.subscribe(subject);
subject.subscribe(
    function onNext(item) { console.log('Value:', item); },
    function onError(err) { console.log('Error:', err); },
    function onCompleted() { console.log('Completed.'); }
);

在该示例中,delayedRange在延迟一秒之后发出值0到4。然后我们创建一个新的AsyncSubject主题并将其订阅到delayedRange。 输出如下:

Value: 4
Completed.

按照预期,我们只得到Observer发出的最后一个值。现在让我们使用AsyncSubject来实现更真实的场景。 我们将获取一些远程内容:

spaceship_reactive/subjects.js

function getProducts(url) {
    var subject;
    return Rx.Observable.create(function(observer) {
        if (!subject) {
            subject = new Rx.AsyncSubject();
            Rx.DOM.get(url).subscribe(subject);
        }
        return subject.subscribe(observer);
    });
}

var products = getProducts('/products');

// Will trigger request and receive the response when read
products.subscribe(
    function onNext(result) { console.log('Result 1:', result.response); },
    function onError(error) { console.log('ERROR', error); }
);

// Will receive the result immediately because it's cached
setTimeout(function() {
    products.subscribe(
        function onNext(result) { console.log('Result 2:', result.response); },
        function onError(error) { console.log('ERROR', error); }
    );
}, 5000)

在此代码中,当使用URL调用getProducts时,它将返回一个Observer,该Observer将发出HTTP GET请求的结果。 以下是它如何分解:

  1. getProducts返回一个Observable序列。 我们在这里创建它。
  2. 如果我们还没有创建AsyncSubject,我们创建它并将订阅Rx.DOM.Request.get(url)返回的Observable。
  3. 我们将Observer订阅到AsyncSubject。每次Observer订阅Observable时,它实际上都会订阅AsyncSubject,它作为Observable检索URL和Observers之间的代理。
  4. 我们创建Observable来检索URL“products”并将其存储在products变量中。
  5. 这是第一个订阅,将启动URL检索并在检索URL时记录结果。
  6. 这是第二个订阅,在第一个订阅后运行五秒钟。由于此时已经检索到URL,因此不需要其他网络请求。 它将立即收到请求的结果,因为它已存储在AsyncSubject中了。

有趣的是,我们正在使用一个订阅Rx.DOM.Request.get这个Observable的AsyncSubject。 由于AsyncSubject缓存最后的结果,因此对产品的任何后续订阅都将立即收到结果,而不会导致其他网络请求。每当我们期望单个结果并希望保留它时,我们就可以使用AsyncSubject。

这是否意味着AsyncSubject像Promise一样?确实。 AsyncSubject表示异步操作的结果,您可以将其用作promise的替代。内部的区别在于promise只会处理单个值,而AsyncSubject处理序列中的所有值,只会发送(和缓存)最后一个值。 能够如此轻松地模拟Promise显示了RxJS模型的灵活性。(即使没有AsyncSubject,使用Observables模拟一个承诺也很容易。)

BehaviorSubject

当Observer订阅BehaviorSubject时,它接收最后发出的值,然后接收所有后续值。BehaviorSubject要求我们提供一个起始值,以便所有Observers在订阅BehaviorSubject时始终会收到一个值。

想象一下,我们想要检索一个远程文件并在HTML页面上输出它的内容,但我们在等待内容时需要占位符文本。 我们可以使用BehaviorSubject

spaceship_reactive/behavior_subject.js

var subject = new Rx.BehaviorSubject('Waiting for content');
subject.subscribe(
    function(result) {
        document.body.textContent = result.response || result;
    },
    function(err) {
        document.body.textContent = 'There was an error retrieving content';
    }
);
Rx.DOM.get('/remote/content').subscribe(subject);

现在,HTML正文包含我们的占位符文本,它将保持这种状态,直到Subject发出新值。最后,我们请求我们想要的资源,并将我们的Subject订阅到生成的Observer。

BehaviorSubject保证始终至少发出一个值,因为我们在其构造函数中提供了一个默认值。一旦BehaviorSubject完成,它将不再发出任何值,释放缓存值使用的内存。

ReplaySubject

ReplaySubject缓存其值并将其重新发送到任何较晚的Observer。 与AsyncSubject不同,不需要为此完成序列。

Subject

var subject = new Rx.Subject();

subject.onNext(1);
subject.subscribe(function(n) {         
    console.log('Received value:', n);
});
subject.onNext(2);
subject.onNext(3);

输出如下:

Received value: 2 
Received value: 3

ReplaySubject

var subject = new Rx.ReplaySubject();

subject.onNext(1);

subject.subscribe(function(n) {     
    console.log('Received value:', n);
});

subject.onNext(2);
subject.onNext(3);

输出如下:

Received value: 1
Received value: 2
Received value: 3

ReplaySubject有助于确保Observers从一开始就获取Observable发出的所有值。它使我们免于编写凌乱的代码来缓存以前的值,从而帮助我们减少了很多错误。

当然,要实现该行为,ReplaySubject会将所有值缓存在内存中。 为了防止它占用太多内存,我们可以通过缓冲区大小限制它存储的数据量,或者通过将特定参数传递给构造函数来限制它。

ReplaySubject构造函数的第一个参数接受一个数字,表示我们要缓冲的值的数量:

var subject = new Rx.ReplaySubject(2); // Buffer size of 2

subject.onNext(1);
subject.onNext(2);
subject.onNext(3);

subject.subscribe(function(n) { 
    console.log('Received value:', n);
});

输出如下:

Received value: 2 
Received value: 3

第二个参数采用一个数字来表示我们想要缓冲值的时间(以毫秒为单位):

var subject = new Rx.ReplaySubject(null, 200); // Buffer size of 200ms

setTimeout(function() { 
    subject.onNext(1); 
}, 100);

setTimeout(function() { 
    subject.onNext(2); 
}, 200); 

setTimeout(function() { 
    subject.onNext(3); 
}, 300); 

setTimeout(function() {
    subject.subscribe(function(n) { 
        console.log('Received value:', n);
    });
     subject.onNext(4);
}, 350);

在这个例子中,我们根据时间而不是值的数量设置缓冲区。 我们的ReplaySubject将缓存最多200毫秒前发出的值。 我们发出三个值,每个值相隔100毫秒,350毫秒后我们订阅一个Observer,然后我们发出另一个值。 在订阅时,缓存的项目是2和3,因为1发生在很久以前(大约250毫秒前),所以它不再被缓存。

Subject是一个强大的工具,可以为您节省大量时间。 它们为缓存和重复等常见场景提供了很好的解决方案。 因为他们的核心只是观察者和观察者,所以你不需要学习任何新东西。

响应式的飞船

为了展示我们如何保持一个应用程序的纯粹,我们将构建一个视频游戏,其中我们的英雄将和无尽的敌人宇宙飞船战斗。 我们将大量使用Observable管道,并且我会指出在可能很容易将状态存储在管道外的情况以及如何避免它。

众所周知,视频游戏会保留很多外部状态分数,字符,定时器等的屏幕坐标。我们的计划是在不依赖于保持状态的单个外部变量的情况下构建整个游戏。

在我们的游戏中,玩家将使用鼠标水平移动飞船,并通过单击鼠标或点击空格键进行射击。我们的游戏将有四个主要角色:背景中的移动星球场,玩家的宇宙飞船,敌人,以及来自玩家和敌人的子弹。

它看起来像这样:

在屏幕截图中,红色三角形是我们的宇宙飞船,绿色三角形是敌人。 较小的三角形是子弹。

让我们从设置阶段开始; 这将是我们的HTML文件:

spaceship_reactive/spaceship.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Spaceship Reactive!</title>
        <script src="../rx.all-4.0.0.js"></script> <style>
            html, body {
                margin: 0;
                padding: 0;
            }
        </style>
    </head>
    <body>
        <script src="spaceship.js"></script>
    </body>
</html>

它只是一个简单的HTML文件,可以加载我们将在本章其余部分使用的JavaScript文件。在那个JavaScript文件中,我们首先设置一个canvas元素,我们将在其中渲染我们的游戏:

spaceship_reactive/starfield_1.js

var canvas = document.createElement('canvas');
var ctx = canvas.getContext("2d");
document.body.appendChild(canvas); 
canvas.width = window.innerWidth; 
canvas.height = window.innerHeight;

有了这个,我们就可以开始编写我们游戏的组件了。 首先让我们画出我们的星空背景。

创建星空背景

我们在太空中设置游戏所需的第一件事就是星空。我们将创建一个向下滚动的星空,以提供穿越太空的感觉。 为此,我们首先使用range运算符生成星标:

spaceship_reactive/starfield_1.js

var SPEED = 40;
var STAR_NUMBER = 250;
var StarStream = Rx.Observable.range(1, STAR_NUMBER)
.map(function() { 
    return {
          x: parseInt(Math.random() * canvas.width),
          y: parseInt(Math.random() * canvas.height),
          size: Math.random() * 3 + 1
    }; 
})

每个星形将由一个包含随机坐标和1到4之间大小的对象表示。这段代码将为我们提供一个生成250个这些“星星”的流。

我们希望这些星星保持前进。一种方法是每隔几毫秒增加所有星星的y坐标。我们将使用toArray将StarStream Observable转换为数组,然后将发出一个包含range生成的所有对象的数组。然后我们可以使用flatMap运算符来获取该数组,该运算符将Observable转换为每隔几毫秒产生一个值的数据。使用map我们可以增加原始数组的每个项目中的y坐标:

spaceship_reactive/starfield_1.js

var SPEED = 40;
var STAR_NUMBER = 250;
var StarStream = Rx.Observable.range(1, STAR_NUMBER)
.map(function() { 
    return {
          x: parseInt(Math.random() * canvas.width),
          y: parseInt(Math.random() * canvas.height),
          size: Math.random() * 3 + 1
    }; 
})
.toArray() 
.flatMap(function(starArray) {
    return Rx.Observable.interval(SPEED)
    .map(function() { 
        starArray.forEach(function(star) {
            if (star.y >= canvas.height) {
                star.y = 0; // Reset star to top of the screen
            }
            star.y += 3; // Move star 
        });
        return starArray; 
    });
})

在地图中,我们检查星醒y坐标是否已经在屏幕之外,如果是的话,我们将其重置为0.通过改变每个星星中的坐标,我们可以始终使用相同的星星阵列。

现在我们需要一个小的辅助函数,在画布上“绘制”一系列星星:

spaceship_reactive/starfield_1.js

function paintStars(stars) {
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#ffffff';
stars.forEach(function(star) {
    ctx.fillRect(star.x, star.y, star.size, star.size);
  });
}

paintStars绘制黑色背景并在画布上绘制星星。 实现移动星星的唯一方法是订阅Observable并使用生成的数组调用paintStars。 这是最终的代码:

spaceship_reactive/starfield_1.js

function paintStars(stars) {
    ctx.fillStyle = '#000000';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = '#ffffff';
    stars.forEach(function(star) {
        ctx.fillRect(star.x, star.y, star.size, star.size);
    });
}

var SPEED = 40;
var STAR_NUMBER = 250;
var StarStream = Rx.Observable.range(1, STAR_NUMBER)
.map(function() { 
    return {
          x: parseInt(Math.random() * canvas.width),
          y: parseInt(Math.random() * canvas.height),
          size: Math.random() * 3 + 1
    }; 
})
.toArray() 
.flatMap(function(starArray) {
    return Rx.Observable.interval(SPEED).map(function() { 
        starArray.forEach(function(star) {
            if (star.y >= canvas.height) {
                star.y = 0; // Reset star to top of the screen
            }
            star.y += 3; // Move star 
        });
        return starArray; });
}).subscribe(function(starArray) {
    paintStars(starArray);
});

现在我们已经绘制好了舞台,现在是我们的英雄出场的时候了。

添加玩家的太空飞船

现在我们拥有美丽的星空背景,我们已准备好对英雄的宇宙飞船编程。虽然我们的宇宙飞船看似简单,但它是游戏中最重要的对象。它是鼠标移动的观察者,它发出当前的鼠标x坐标和恒定的y坐标(玩家只能水平移动,所以我们永远不会改变y坐标):

spaceship_reactive/hero_1.js

var HERO_Y = canvas.height - 30;
var mouseMove = Rx.Observable.fromEvent(canvas, 'mousemove'); 
var SpaceShip = mouseMove
.map(function(event) { 
    return {
        x: event.clientX,
        y: HERO_Y
    };
})
.startWith({
    x: canvas.width / 2,
    y: HERO_Y 
});

请注意,我使用了startWith。这将设置Observable中的第一个值,并将其设置为屏幕中间的位置。没有startWith我们的Observable只有在玩家移动鼠标时才开始发射。

让我们在屏幕上渲染我们的英雄。在这个游戏中,所有角色都是三角形(我的图形设计技巧不是很令人印象深刻),所以我们将定义一个辅助函数来在画布上渲染三角形,给定坐标,大小和颜色,以及它们的朝向:

spaceship_reactive/hero_1.js

function drawTriangle(x, y, width, color, direction) { 
    ctx.fillStyle = color;
    ctx.beginPath();
    ctx.moveTo(x - width, y);
    ctx.lineTo(x, direction === 'up' ? y - width : y + width); 
    ctx.lineTo(x + width, y);
    ctx.lineTo(x - width,y);
    ctx.fill();
}

我们还将定义paintSpaceShip,它使用辅助函数:

spaceship_reactive/hero_1.js

function paintSpaceShip(x, y) { 
    drawTriangle(x, y, 20, '#ff0000', 'up');
}

但我们现在面临一个问题。 如果我们订阅了SpaceShip Observable并在订阅中调用了drawTriangle,我们的太空船只有在我们移动鼠标时才能看到,而且只是瞬间。 这是因为starStream每秒更新画布很多次,如果我们不移动鼠标就擦除我们的太空船。由于starStream无法直接访问太空船,因此我们无法在starStream订阅中渲染太空船。 我们可以将最新的太空船坐标保存到starStream可以访问的变量中,但是我们将修改外部状态的规则。 该怎么办?

通常情况下,RxJS有一个非常方便的operator,我们可以用它来解决我们的问题。

Rx.Observable.combineLatest是一个方便的operator。 它需要两个或更多Observable并在每个Observable发出新值时发出每个Observable的最后结果。知道starStream如此频繁地发出一个新项目(星星数组),我们可以删除starStream的订阅并使用combineLatest结合starStream和SpaceShip Observables,并在它们发出任何新项目时立即更新它们:

spaceship_reactive/hero_1.js

function renderScene(actors) {
    paintStars(actors.stars);
    paintSpaceShip(actors.spaceship.x, actors.spaceship.y);
}
var Game = Rx.Observable.combineLatest(StarStream, SpaceShip, function(stars, spaceship) {
    return { stars: stars, spaceship: spaceship }; 
});

Game.subscribe(renderScene);

我们现在使用renderScene函数绘制屏幕上的所有内容,因此您可以删除StarStream的以下订阅代码:

.subscribe(function(starArray) {
    paintStars(starArray);
});

有了这个,每当Observable发出新项目时,我们都会画出星空背景和宇宙飞船。我们现在有一艘宇宙飞船在太空中飞行,我们可以使用我们的鼠标随意移动它。这么简短的代码还不错吧!但是我们的英雄宇宙飞船在浩瀚的太空中太孤独了。 给它一些同伴怎么样?

生成敌人

如果我们没有任何敌人,这将是一个非常无聊的游戏。 所以让我们创造一个无限的流!我们想要每两秒半创造一个新的敌人。让我们看一下Enemies Observable的代码,然后仔细阅读:

spaceship_reactive/enemy_1.js

var ENEMY_FREQ = 1500;
var Enemies = Rx.Observable.interval(ENEMY_FREQ)
.scan(function(enemyArray) { 
    var enemy = {
        x: parseInt(Math.random() * canvas.width),
        y: -30
    };
    enemyArray.push(enemy);
    return enemyArray; 
}, []);

var Game = Rx.Observable.combineLatest(
StarStream, SpaceShip, Enemies, function(stars, spaceship, enemies) {
    return {
        stars: stars, 
        spaceship: spaceship, 
        enemies: enemies
    }; 
});
Game.subscribe(renderScene);

为了创造敌人,我们使用interval运算符每1,500毫秒运行一次,然后我们使用scan运算符创建一个敌人阵列。

每次Observable发出一个值时,scan聚合结果,并发出每个中间结果。 在Enemies Observable中,我们从一个空数组开始,作为scan的第一个参数,我们在每次迭代中将一个新对象推送到它。 该对象包含随机x坐标和可见屏幕外的固定y坐标。 有了这个,敌人将每1500毫秒发出一个包含所有当前敌人的阵列。

剩下的唯一的事情事渲染enemies的辅助函数。此函数也将更新enemies数组中每个项目的坐标:

spaceship_reactive/enemy_1.js

// Helper function to get a random integer
function getRandomInt(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
}

function paintEnemies(enemies) { 
    enemies.forEach(function(enemy) {
        enemy.y += 5;
        enemy.x += getRandomInt(-15, 15);
        drawTriangle(enemy.x, enemy.y, 20, '#00ff00', 'down'); 
    });
}

你可以在paintEnemies中看到我们也在随机改变x坐标,这样敌人就会无法预测地移动到两侧。现在我们需要更新函数renderScene以包含对paintEnemies的调用。

你可能已经注意到了我们到目前为止玩游戏时的一个奇怪的效果:如果你移动鼠标,敌人会更快地向你走来!这可能是游戏中的一个很好的功能,但我们绝对不打算这样做。你能猜出导致这个bug的原因吗?

如果你猜到它与paintEnemies功能有关,你就是对的。只要任何Observable产生一个值,combineLatest就会渲染我们的场景。如果我们不移动鼠标,最快的发射器将始终是starStream,因为它的间隔为40毫秒(Enemies Observable仅每1,500毫秒发出一次)。但是,当我们移动鼠标时,SpaceShip将比starStream发射得更快(你的鼠标每秒发射多次坐标),然后paintEnemies将执行多次,更快地增加敌人的坐标。

为了避免这种情况以及未来的类似问题,我们需要规范游戏的速度,以便Observable不会比我们的鼠标速度更快地发出值。

是的,正如您可能已经猜到的那样,RxJS有一个operator。

Avoid Drinking from the Firehose

我们是不是接收数据的速度太快了。大多数情况下,我们希望获得所有速度,但是根据Observable流值的频率,我们可能希望删除一些我们收到的值。我们现在处于其中一种情况中。我们在屏幕上渲染事物的速度与我们拥有的最快Observable的速度成正比。事实证明,我们最快的Observable对我们来说太快了,我们需要在游戏中建立一个恒定的更新速度。

sample是Observable实例中的一个方法,给定一个以毫秒为单位的时间参数,返回一个Observable,它发出每个时间间隔内父Observable发出的最后一个值。

请注意sample如何在间隔时刻丢弃最后一个值之前的任何值。 认清您是否需要此行为非常重要。在我们的例子中,我们不关心删除值,因为我们只想每40毫秒渲染一个元素的当前状态。如果所有值对您都很重要,您可能需要考虑缓冲区运算符:

spaceship_reactive/enemy_2.js

Rx.Observable.combineLatest( StarStream, SpaceShip, Enemies, function(stars, spaceship, enemies) {
    return {
        stars: stars, 
        spaceship: spaceship, 
        enemies: enemies
    }; 
    
})
.sample(SPEED)
.subscribe(renderScene);

通过在combineLatest之后调用sample,我们确保combineLatest永远不会在前一个之后的40毫秒内产生任何值(我们的常量SPEED设置为40)。

射击

看到成群的敌人来到我们身边有点可怕;我们能做的就是走开,希望他们不要看到我们。如果让让我们的英雄有能力射击邪恶的外星人宇宙飞船会怎么样?

我们希望我们的太空船在我们点击鼠标或按空格键时进行射击,因此我们将为每个事件创建一个Observable并将它们合并到一个名为playerShots的Observable中。 请注意,我们通过空格键,空格键的键代码事32:

spaceship_reactive/hero_shots.js

var playerFiring = Rx.Observable .merge(
Rx.Observable.fromEvent(canvas, 'click'),
Rx.Observable.fromEvent(canvas, 'keydown')
.filter(function(evt) { 
    return evt.keycode === 32; 
}) )

现在我们已经了解了sample,我们可以用它来增加游戏的趣味并限制我们太空船的射击频率。否则,玩家可以高速射击并轻易摧毁所有敌人。我们这样做是为了让玩家最多只能每200毫秒射击一次:

spaceship_reactive/hero_shots.js

var playerFiring = Rx.Observable.merge(
    Rx.Observable.fromEvent(canvas, 'click'),
    Rx.Observable.fromEvent(canvas, 'keydown')
    .filter(function(evt) { 
        return evt.keycode === 32; 
    })
)
.sample(200)
.timestamp();

我们还添加了一个时间戳操作符,它在我们的Observable发出的每个值中设置一个属性时间戳,以及它发出的确切时间。 我们稍后会用它。

最后,为了从我们的宇宙飞船发射射击,我们需要知道射击时刻宇宙飞船的x坐标。这样我们就可以将设计子弹渲染到正确的x坐标。 从SpaceShip Observable设置一个外部变量看起来比较简单,它会始终包含最后发出的x坐标,但这会破坏我们不成文的协议,永远不会改变外部状态!

相反,我们将通过再次使用我们的好朋友combineLatest来实现这一目标:

spaceship_reactive/hero_shots.js

var HeroShots = Rx.Observable
.combineLatest(
playerFiring,
SpaceShip,
function(shotEvents, spaceShip) {
    return { x: spaceShip.x };
})
.scan(function(shotArray, shot) {
    shotArray.push({x: shot.x, y: HERO_Y});
    return shotArray;
}, []);

我们现在从SpaceShip和playerFiring获取更新的值,这样我们就可以得到我们想要的x坐标。 我们使用扫描的方式与我们用于Enemy Observable的方式相同,为每个子弹创建一个当前坐标数组。有了这个,我们应该准备好在屏幕上绘制我们的镜头。 我们使用辅助函数绘制子弹数组中的每个子弹:

spaceship_reactive/hero_shots.js

var SHOOTING_SPEED = 15;
function paintHeroShots(heroShots) {
    heroShots.forEach(function(shot) {
        shot.y -= SHOOTING_SPEED;
        drawTriangle(shot.x, shot.y, 5, '#ffff00', 'up');
    });
}

然后我们从我们的主要combineLatest操作中调用paintHeroShots

Rx.Observable.combineLatest(
StarStream, SpaceShip, Enemies, HeroShots,
function(stars, spaceship, enemies, heroShots) {
    return {
        stars: stars,
        spaceship: spaceship,
        enemies: enemies,
        eroShots: heroShots
    };
})
.sample(SPEED)
.subscribe(renderScene);

我们在renderScene中添加对paintHeroShots的调用:

function renderScene(actors) {
    paintStars(actors.stars);
    paintSpaceShip(actors.spaceship.x, actors.spaceship.y);
    paintEnemies(actors.enemies);
    aintHeroShots(actors.heroShots);
}

现在,当你运行游戏时,你会注意到每次移动鼠标时,我们的宇宙飞船都会疯狂的射击。 效果虽然不错,但这不是我们想要的! 让我们再看看HeroShots Observable。 在其中,我们使用combineLatest,以便我们拥有来自playerFiring和SpaceShip的值。 这与我们之前遇到的问题类似。每次鼠标移动时,HeroShots中的combineLatest都会发出值,这就转化为被射击的子弹。在这种情况下,节流无济于事,因为我们希望用户随时随地进行射击,并且节流将限制射击次数并使其中的许多次数下降。

每当Observable发出新值时,combineLatest会发出每个Observable发出的最后一个值。 我们可以利用这个优势。每当鼠标移动时,combineLatest会发出新的SpaceShip位置和playerFiring的最后一个发射值,除非我们发射新子弹,否则它将保持不变。 然后,只有当发射的子弹与前一子弹不同时,我们才能发出一个值。 distinctUntilChanged操作符为我们执行脏工作。

运算符distinctdistinctUntilChanged允许我们过滤掉Observable已经发出的结果。 distinct过滤掉先前发出的任何结果,而distinctUntilChanged过滤掉相同的结果,除非在它们之间发出不同的结果。我们只需要确保新子弹与前一子弹不同,所以distinctUntilChanged对我们来说已经足够了。(它还使我们免于更高内存使用的不同;不同的需要将所有先前的结果保留在内存中。)

我们修改了heroShots,因此它只根据时间戳发出新子弹:

spaceship_reactive/hero_shots2.js

var HeroShots = Rx.Observable
.combineLatest(
playerFiring,
SpaceShip,
function(shotEvents, spaceShip) {
    return {
        timestamp: shotEvents.timestamp,
        x: spaceShip.x
    };
})
.distinctUntilChanged(function(shot) { return shot.timestamp; })
.scan(function(shotArray, shot) {
    shotArray.push({ x:shot.x, y: HERO_Y });
    return shotArray;
}, []);

如果一切顺利,我们现在能够从我们的宇宙飞船射击敌人!

敌人射击

我们应该允许敌人射击; 否则这是一个非常不公平的无聊游戏。 而且很无聊! 对于敌人射击,我们将执行以下操作:

  • 每个敌人都会保留更新的子弹阵列。
  • 每个敌人都会以给定的频率射击。

为此,我们将使用区间运算符来存储敌人值的新子弹。我们还将介绍一个新的辅助函数isVisible,它有助于滤除坐标在可见屏幕之外的元素。 这就是Enemy Observable现在的样子:

spaceship_reactive/enemy_shots.js

function isVisible(obj) {
    return obj.x > -40 && obj.x < canvas.width + 40 &&
obj.y > -40 && obj.y < canvas.height + 40;
}
var ENEMY_FREQ = 1500;
var ENEMY_SHOOTING_FREQ = 750;
var Enemies = Rx.Observable.interval(ENEMY_FREQ)
.scan(function(enemyArray) {
    var enemy = {
        x: parseInt(Math.random() * canvas.width),
        y: -30,
        shots: []
    };
    
    Rx.Observable.interval(ENEMY_SHOOTING_FREQ).subscribe(function() {
        enemy.shots.push({ x: enemy.x, y: enemy.y });
        enemy.shots = enemy.shots.filter(isVisible);
    });
    
    enemyArray.push(enemy);
    return enemyArray.filter(isVisible);
}, []);

在该代码中,我们每次创建新敌人时都会创建一个区间。此间隔将继续向敌方子弹阵列添加子弹,然后它将过滤掉屏幕外的子弹。我们也可以使用isVisible来过滤屏幕外的敌人,就像我们在return语句中所做的那样。

我们需要更新paintEnemies,以便渲染敌人的镜头并更新他们的y坐标。然后我们使用我们方便的drawTriangle函数来绘制镜头:

spaceship_reactive/enemy_shots.js

function paintEnemies(enemies) {
    enemies.forEach(function(enemy) {
        enemy.y += 5;
        enemy.x += getRandomInt(-15, 15);
        drawTriangle(enemy.x, enemy.y, 20, '#00ff00', 'down');
        enemy.shots.forEach(function(shot) {
            shot.y += SHOOTING_SPEED;
            drawTriangle(shot.x, shot.y, 5, '#00ffff', 'down');
        });
    });
}

有了这个,现在每个人都在射击其他人,但没有人被摧毁!他们只是滑过敌人和我们的宇宙飞船,因为我们还没有定义当射击与太空飞船碰撞时会发生什么。

碰撞检测

当射击击中敌人时,我们希望子弹和敌人都能消失?让我们定义一个辅助函数来检测两个目标是否发生了碰撞:

spaceship_reactive/enemy_shots2.js

function collision(target1, target2) {
    return (target1.x > target2.x - 20 && target1.x < target2.x + 20) &&
(target1.y > target2.y - 20 && target1.y < target2.y + 20);
}

现在让我们修改辅助函数paintHeroShots来检查每个子弹是否击中敌人。对于发生命中的情况,我们将在已击中的敌人上将属性isDead设置为true,并且我们将子弹的坐标设置为屏幕外。 子弹最终会被滤除,因为它在屏幕外。

spaceship_reactive/enemy_shots2.js

function paintEnemies(enemies) {
    enemies.forEach(function(enemy) {
        enemy.y += 5;
        enemy.x += getRandomInt(-15, 15);
        if (!enemy.isDead) {
        drawTriangle(enemy.x, enemy.y, 20, '#00ff00', 'down');
        }
        enemy.shots.forEach(function(shot) {
            shot.y += SHOOTING_SPEED;
            drawTriangle(shot.x, shot.y, 5, '#00ffff', 'down');
        });
    });
}

var SHOOTING_SPEED = 15;
function paintHeroShots(heroShots, enemies) {
    heroShots.forEach(function(shot, i) {
        for (var l=0; l<enemies.length; l++) {
            var enemy = enemies[l];
            if (!enemy.isDead && collision(shot, enemy)) {
                enemy.isDead = true;
                shot.x = shot.y = -100;
                break;
            }
        }
        shot.y -= SHOOTING_SPEED;
        drawTriangle(shot.x, shot.y, 5, '#ffff00', 'up');
    });
}

接下来让我们摆脱任何将属性isDead设置为true的敌人。唯一需要注意的是,我们需要等待那个特定敌人的所有射击消失;否则,当我们击中一个敌人时,它的所有射击都会随之消失,这很奇怪。 因此,我们检查其射击的长度,并仅在没有射击时过滤掉敌人物体:

spaceship_reactive/enemy_shots2.js

var Enemies = Rx.Observable.interval(ENEMY_FREQ)
.scan(function(enemyArray) {
    var enemy = {
        x: parseInt(Math.random() * canvas.width),
        y: -30,
        shots: []
    };
    Rx.Observable.interval(ENEMY_SHOOTING_FREQ).subscribe(function() {
        if (!enemy.isDead) {
            enemy.shots.push({ x: enemy.x, y: enemy.y });
        }
        enemy.shots = enemy.shots.filter(isVisible);
    });
    enemyArray.push(enemy);
    return enemyArray
    .filter(isVisible)
    .filter(function(enemy) {
        return !(enemy.isDead && enemy.shots.length === 0);
    });
}, []);

为了检查玩家的船是否被击中,我们创建了一个函数gameOver:

spaceship_reactive/enemy_shots2.js

function gameOver(ship, enemies) {
    return enemies.some(function(enemy) {
        if (collision(ship, enemy)) {
            return true;
        }
        return enemy.shots.some(function(shot) {
            return collision(ship, shot);
        });
    });
}

如果敌人或敌人射击击中玩家的宇宙飞船,则此函数返回true。

在继续之前,让我们了解一个有用的运算符:takeWhile。当我们在现有的Observable上调用takeWhile时,Observable将继续发出值,直到函数作为参数传递给takeWhile返回false。

我们可以使用takeWhile告诉我们的主要combineLatest Observable继续获取值,直到gameOver返回true:

spaceship_reactive/enemy_shots2.js

Rx.Observable.combineLatest(
    StarStream, SpaceShip, Enemies, HeroShots,
    function(stars, spaceship, enemies, heroShots) {
        return {
            stars: stars,
            spaceship: spaceship,
            enemies: enemies,
            heroShots: heroShots
        };
    })
.sample(SPEED)
.takeWhile(function(actors) {
    return gameOver(actors.spaceship, actors.enemies) === false;
})
.subscribe(renderScene);

当gameOver返回true时,combineLatest将停止发射值,从而立刻停止游戏。

最后一件事:保持分数

如果我们不能向朋友吹嘘我们的结果,会是什么样的游戏?我们显然需要一种方法来跟踪我们的表现。 我们需要得分。

让我们创建一个简单的辅助函数来将分数绘制到屏幕的左上角:

spaceship_reactive/score.js

function paintScore(score) {
    ctx.fillStyle = '#ffffff';
    ctx.font = 'bold 26px sans-serif';
    ctx.fillText('Score: ' + score, 40, 43);
}

为了保持分数,我们将使用Subject。我们可以在基于combineLatest的主游戏循环中轻松使用它,就像它只是另一个Observable一样,我们可以随时将值推送到它。

spaceship_reactive/score.js

var ScoreSubject = new Rx.Subject();
var score = ScoreSubject.scan(function (prev, cur) {
    return prev + cur;
}, 0).concat(Rx.Observable.return(0));

在该代码中,我们使用scan运算符将每个新值与总聚合结果相加。由于我们在游戏开始时不会有任何分数,我们会连接一个返回0的Observable,因此我们有一个起点。

现在,只要我们击中敌人,我们就必须将分数推向我们的Subject;这是在paintHeroShots中发生的事情:

spaceship_reactive/score.js

var SCORE_INCREASE = 10;
function paintHeroShots(heroShots, enemies) {
    heroShots.forEach(function(shot, i) {
        for (var l=0; l<enemies.length; l++) {
            var enemy = enemies[l];
            if (!enemy.isDead && collision(shot, enemy)) {
                ScoreSubject.onNext(SCORE_INCREASE);
                enemy.isDead = true;
                shot.x = shot.y = -100;
                break;
            }
        }
        shot.y -= SHOOTING_SPEED;
        drawTriangle(shot.x, shot.y, 5, '#ffff00', 'up');
    });
}

当然,我们将paintScore添加到renderScene,以便分数显示在屏幕上:

spaceship_reactive/score.js

function renderScene(actors) {
    paintStars(actors.stars);
    paintSpaceShip(actors.spaceship.x, actors.spaceship.y);
    paintEnemies(actors.enemies);
    paintHeroShots(actors.heroShots, actors.enemies);
    paintScore(actors.score);
}

这完成了我们的Spaceship Reactive游戏。我们已经设法在浏览器中对整个游戏进行编码,避免通过Observable管道的功能改变任何外部状态。

改进的想法

我相信你已经有了一些使游戏更令人兴奋的想法,我也有一些改进建议,让游戏更好,同时提高你的RxJS技能:

  • 添加以不同速度移动的第二个(或第三个!)星形场以创建视差效果。 这可以通过几种不同的方式完成。 尝试重用现有代码并尽可能以声明方式执行。
  • 通过使它们以随机间隔发射而不是ENEMY_SHOOTING_FREQ中指定的固定敌人来制造更多不可预测的敌人。 如果玩家的分数越高,你可以让他们更快地开火,这是额外的积分!
  • 允许玩家在短时间内击中几个敌人获得更多积分。

总结

我们只使用Observables为浏览器构建了一个完整的游戏,并且沿途我们已经看到了几种非常方便的方法来处理并发以及组合和转换Observable。这是RxJS的优势之一:总有一种方法可以帮助解决您正在尝试解决的问题。请随意在RxJS文档中探索它们

反应式编程可以轻松编写并发程序。Observable抽象和强大的RxJS方法使程序的不同部分能够有效地进行交互。不依赖外部状态进行编程可能需要一些时间来适应,但它有很大的好处。我们可以将整个行为封装在一个Observable管道中,使我们的程序更加可靠和可靠。

在下一章中,我们将选择我们离开它的地震可视化应用程序并添加一个显示与地震有关的推文的Node.js服务器部分。我们还将改进其用户界面,使其看起来像一个真正的地震仪表板。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券