使用 Cro 创建单页应用程序

Building a Single Page Application with Cro

使用 Cro 创建单页应用

本教程将介绍如何使用 Cro 作为后端构建简单的单页应用程序。对于前端,我们将使用 webpack,ES6,React 和 Redux。你不需要事先了解这些信息,但如果您想在自己的应用程序中很好地使用它们,则需要进一步阅读。

代码可以在这里获取。在教程的各个阶段,提交了当前的状态。仓库历史记录与教程完全匹配,因此你可以使用它来获取各步骤变更的概述,或者如果你试图通过从头开始构建这些东西,可以知道你错过了什么。

你需要什么

  • 要安装 Cro (请参阅本指南获取相关帮助)
  • 要安装 npm (即 Node.js 的包管理器, 我们要用它来获取需要的包来构建前端); 在基于 Debian 的 Linux 发行版上,只需 sudo apt install npm

我们要构建什么

所以我们正在举办美食节或者啤酒节。或者任何带有一堆我们可以尝试的东西的活动。但是…尝试什么?如果有这样一些应用程序就好了,人们可以留下他们关于什么火和什么不火的提示,我们可以实时看到他们。如果在去过的上一届啤酒节上有过这样的东西,我可能会幸免于那杯绿茶啤酒……

所以, 我们会制作一个单页应用程序以支持:

  • 提交新的提示 (POST 到后端)
  • 现场发布最新提示 (通过网络套接字提供)
  • 能够同意或不同意某提示 (也是 POST)
  • 能够看到按照最愉快到最不愉快 (通过 GE T获取) 排序的提示列表

Stubbing 后端

给应用程序想一个有创意的名称。我叫它 “tipsy”。然后使用 cro stub 来存根 HTTP 应用程序。为了简单起见,我们将跳过 HTTPS(也就是HTTP/2.0),但会包含 Web 套接字支持。

$ cro stub http tipsy tipsy
Stubbing a HTTP Service 'tipsy' in 'tipsy'...

First, please provide a little more information.

Secure (HTTPS) (yes/no) [no]: n
Support HTTP/1.1 (yes/no) [yes]: y
Support HTTP/2.0 (yes/no) [no]: n
Support Web Sockets (yes/no) [no]: y

这创建了一个叫 tipsy 的目录。现在让我们进到这个目录中并检查存根后端运行, 使用 cro run:

$ cro run
▶ Starting tipsy (tipsy)
🔌 Endpoint HTTP will be at http://localhost:20000/
📓 tipsy Listening at http://localhost:20000

我们可以使用 curl 或通过在浏览器中访问它来检查它:

$ curl http://localhost:20000
<h1> tipsy </h1>

我喜欢在工作时定期提交。因此,我将创建一个 git 仓库,添加一个 .gitignore 文件 (忽略 Raku 预编译输出) 并提交 stub。

$ git init .
Initialized empty Git repository in /home/jnthn/dev/cro/tipsy/.git/
jnthn@lviv:~/dev/cro/tipsy$ echo '.precomp/' > .gitignore
jnthn@lviv:~/dev/cro/tipsy$ git add .
jnthn@lviv:~/dev/cro/tipsy$ git commit -m "Stub tipsy backend"
[master (root-commit) ff1043a] Stub tipsy backend
 5 files changed, 99 insertions(+)
 create mode 100644 .cro.yml
 create mode 100644 .gitignore
 create mode 100644 META6.json
 create mode 100644 lib/Routes.pm6
 create mode 100644 service.p6

服务静态页面

我们现在将调整我们的 Cro stub 应用程序以服务 HTML 页面。我们将创建一个 static 目录,其中是要服务的静态内容。

mkdir static

在那里,我们将把 index.html 放在下面的上下文中:

<html>
  <head>
    <title>Tipsy</title>
  </head>
  <body>
    <h1>Tipsy</h1>
  </body>
</html>

然后,我们编辑 lib/Routes.pm6 以使 / 路由服务此文件:

get -> {
    static 'static/index.html'
}

我们之前运行的 cro run 应该会自动重新启动服务。在浏览器中打开该文件,检查它是否被正被服务。

设置前端构建工具链

接下来,我们将设置前端。首先,我们将存储一个 package.json 文件,该文件将包含我们的开发和前端 JavaScript 依赖关系。我们使用 npm init 并提供一些答案:

$ npm init .
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.

See `npm help json` for definitive documentation on these fields
and exactly what they do.

Use `npm install <pkg> --save` afterwards to install a package and
save it as a dependency in the package.json file.

Press ^C at any time to quit.
name: (tipsy) 
version: (1.0.0) 
description: Tipsy gives you tips at festivals
entry point: (index.js) 
test command: 
git repository: 
keywords: 
author: 
license: (ISC) 
About to write to /home/jnthn/dev/cro/tipsy/package.json:

{
  "name": "tipsy",
  "version": "1.0.0",
  "description": "Tipsy gives you tips at festivals",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}


Is this ok? (yes)

接下来,让我们安装 webpack 工具作为开发依赖:

npm install --save-dev webpack webpack-cli

在运行的时候:我们来说说 webpack 是干什么的?它以各种方式提供帮助:

  • 它为我们从现代 JavaScript(具有诸如模块导入,lambda 表达式和 let 变量声明之类的便利功能)编译成适用于 Web 浏览器的 JavaScript 版本
  • 它允许我们使用 JavaScript 模块,使用 npm 包管理器进行管理,并将它们连接成一个 JavaScript 文件
  • 它也可以帮助 CSS 和图像资产

接下来,我们将在仓库的根目录中创建一个 webpack.config.js,其中包含以下内容:

const path = require('path');

module.exports = {
    entry: './frontend/index.js',
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'static/js')
    }
};

这意味着它将采用 frontend/index.js 作为我们应用程序的 JavaScript 前端部分的根,并遵循它的所有依赖关系,并将它们构建到一个将写入 static/js/bundle.js 的包中。接下来,我们来创建这些位置。首先,让我们对输出位置进行存根(stub); .gitignore 既会忽略生成的输出又会确保目录存在(因为 git 不会跟踪空目录)。

$ mkdir static/js
$ echo '*' > static/js/.gitignore

下面, 给 frontend/index.js 占个坑:

$ mkdir frontend
$ echo 'document.write("Hello from JS")' > frontend/index.js

现在,我们希望能够方便地从我们的本地安装中运行 webpack。一种方法是编辑 package.json 并向 scripts 部分添加一项:

"scripts": {
    "build": "webpack",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

现在可以使用 npm run build 运行:

$ npm run build

> tipsy@1.0.0 build /home/jnthn/dev/cro/tipsy
> webpack

Hash: 142d15221600d8f7960f
Version: webpack 3.6.0
Time: 125ms
    Asset    Size  Chunks             Chunk Names
bundle.js  2.5 kB       0  [emitted]  main
   [0] ./frontend/index.js 32 bytes {0} [built]

服务和使用 bundle

接下来,编辑 static/index.html,并在 </body> 闭合标记之前添加:

<script src="js/bundle.js"></script>

最后,我们需要为 JavaScript 提供服务。编辑 lib/Routes.pm6 并添加一个像这样的新路由(这将服务 static/js 下的任何东西,如果我们需要,可以为将来的多个包做好准备):

get -> 'js', *@path {
    static 'static/js', @path
}

在浏览器中刷新,那么 Hello from JS 应该出现在页面上。

最后但并非最不重要的是,我们需要忽略 node_modules,然后可以提交前端存根:

$ echo 'node_modules/' >> .gitignore
$ git add .
$ git commit -m "Stub JavaScript application"
[master 199866c] Stub JavaScript application
 6 files changed, 40 insertions(+), 1 deletion(-)
 create mode 100644 frontend/index.js
 create mode 100644 package.json
 create mode 100644 static/index.html
 create mode 100644 webpack.config.js

开始构建后端

为了简单起见,我们将构建一个内存模型。有各种各样的方法可以将它考虑在内,但要记住的关键是 Web 应用程序是一个并发系统,而 Cro 请求在线程池中处理。这意味着可以同时处理两个请求!

为了处理这个问题,我们将使用 OO::Monitors 模块。监视器就像一个类,但它强制它的方法互相排斥 - 也就是说,一次只有一个线程可能在特定实例的方法内。因此,如果我们不泄露我们的内部状态(如果我们必须返回其中的一部分,则制作防御副本),监视对象内部的状态将受到保护。

首先,让我们将 OO::Monitors 添加到我们的 META6.json depends 部分,以至于文件的一部分如下所示:

"depends": [
    "Cro::HTTP",
    "Cro::WebSocket",
    "OO::Monitors"
  ],

为了确保你真的有这个模块可用,如果缺失, 请使用 zef 安装它:

$ zef install --deps-only .

我们将把业务逻辑放在一个单独的模块 lib/Tipsy.pm6 中,并在 t/tipsy.t 中为它写一些测试。首先,让我们为我们的业务/域逻辑存储 API:

use OO::Monitors;

class Tip {
    has Int $.id;
    has Str $.tip;
    has Int $.agreed;
    has Int $.disagreed;
}

monitor Tipsy {
    method add-tip(Str $tip --> Nil) { ... }
    method agree(Int $tip-id --> Nil) { ... }
    method disagree(Int $tip-id --> Nil) { ... }
    method latest-tips(--> Supply) { ... }
    method top-tips(--> Supply) { ... }
}

在这里,Tip 是一个不变的对象,代表一个提示以及同意和不同意的数量。 Tipsy 类有很多方法来实现各种操作。前三个是可变操作。接下来的 atest-tips 将是 Tip 对象的 Supply,每次添加新提示时都会发出提示对象。第一次点击时,我们将始终发出最新的 50 个提示。 top-tips 方法返回一个 Supply,每当排名发生变化时,它将发出前 50 个提示的排序列表。不要试图记住所有这些,我们会一次一个地回到他们身上。

接下来是让我们的路由可用。我们可以在 Routes.pm6 中创建实例,但这会让我们很难在独立于业务逻辑的情况下测试我们的路由。相反,我们将使 Routes.pm6 中的 stub 作为参数来使用业务逻辑对象的实例:

use Cro::HTTP::Router;
use Cro::HTTP::Router::WebSocket;
use Tipsy;

sub routes(Tipsy $tipsy) is export {
    route {
        get -> {
            static 'static/index.html'
        }
    ...
}

然后通过以下方式将其设置在 service.p6 入口点:

1.使用模块 2.制作一个实例

service.p6 文件最终会看起来像这样:

use Cro::HTTP::Log::File;
use Cro::HTTP::Server;
use Routes;
use Tipsy;

my $tipsy = Tipsy.new;
my $application = routes($tipsy);

my Cro::Service $http = Cro::HTTP::Server.new(
    http => <1.1>,
    host => %*ENV<TIPSY_HOST> ||
        die("Missing TIPSY_HOST in environment"),
    port => %*ENV<TIPSY_PORT> ||
        die("Missing TIPSY_PORT in environment"),
    :$application,
    after => [
        Cro::HTTP::Log::File.new(logs => $*OUT, errors => $*ERR)
    ]
);
$http.start;
say "Listening at http://%*ENV<TIPSY_HOST>:%*ENV<TIPSY_PORT>";
react {
    whenever signal(SIGINT) {
        say "Shutting down...";
        $http.stop;
        done;
    }
}

添加提示和最近的提示

接下来,让我们编写测试以添加提示,并在最新提示中看到它。这里有 t/tipsy.t

use Tipsy;
use Test;

my $tipsy = Tipsy.new;
lives-ok { $tipsy.add-tip('The lamb kebabs are good!') },
    'Can add a tip';
lives-ok { $tipsy.add-tip('Not so keen on the fish burrito!') },
    'Can add another tip';
given $tipsy.latest-tips.head(2).list -> @tips {
    is @tips[0].tip, 'Not so keen on the fish burrito!',
        'Correct first tip retrieved on initial tap of latest-tips';
    is @tips[1].tip, 'The lamb kebabs are good!',
        'Correct second tip retrieved on initial tap of latest-tips';
}

react {
    whenever $tipsy.latest-tips.skip(2).head(1) {
        is .tip, 'Try the vanilla stout for sure',
            'Get new tips emitted live';
    }
    $tipsy.add-tip('Try the vanilla stout for sure');
}

done-testing;

第一部分测试我们可以添加两个提示,如果我们点击提供最新的提示,那么我们会立即给出这两个提示。第二部分涉及更多:它检查如果我们点击最新的提示Supply,然后添加一个新的提示,那么我们也会被告知这个新提示。

现在让他们通过!这是 monitor 中的实现:

monitor Tipsy {
    has Int $!next-id = 1;
    has Tip %!tips-by-id{Int};
    has Supplier $!latest-tips = Supplier.new;

    method add-tip(Str $tip --> Nil) {
        my $id = $!next-id++;
        my $new-tip = Tip.new(:$id, :$tip);
        %!tips-by-id{$id} = $new-tip;
        start $!latest-tips.emit($new-tip);
    }

    method latest-tips(--> Supply) {
        my @latest-existing = %!tips-by-id.values.sort(-*.id).head(50);
        supply {
            whenever $!latest-tips {
                .emit;
            }
            .emit for @latest-existing;
        }
    }
    
    # The other unimplemented methods go here
}

我们保留一个ID的计数器,为每个提示分配自己的唯一ID。我们有一个哈希映射到Tip对象的ID。然后我们有一个供应商,当有新的提示时,我们将用它来通知任何感兴趣的团体。

添加提示方法的前3行很简单,最后一行有点好奇:为什么要开始?答案是,使用耗材时,发件人支付分发消息的费用,但我们不希望将显示器 - 这会互相排斥 - 与所有这些工作挂钩。所以,我们开始异步分派通知。

最新技巧的方法也需要小心。请记住,我们从监视器返回的内容不受互斥排除的保护。因此,我们应该在供应模块之外复制最新的现有技巧,而显示器正在保护%!tips-by-id。然后,当返回的供应块被挖掘时,我们订阅最新的技巧,然后发出每个最新的现有技巧。

随着这些,我们的测试通过。进展!

$ git add .
$ git commit -m "Implement first part of business logic"
[master 6e352c6] Implement first part of business logic
 6 files changed, 70 insertions(+), 4 deletions(-)
 create mode 100644 lib/Tipsy.pm6
 create mode 100644 t/tipsy.t

设置 Reacts

现在是时候回到前端了。我们将使用 React。 React 为我们提供了一个虚拟的DOM,我们可以在每次改变时重建它。然后将其与当前真实的 DOM 进行比较,并应用更改。这让我们以更实用的风格工作。 React 组件是使用 JSX 编写的,一种嵌入到 JavaScript 中的类 XML 语法。首先,我们需要设置编译。以下是新的开发依赖关系:

$ npm install --save-dev babel-loader babel-core babel-preset-es2015 babel-preset-react

接下来, 我们需要创建一个 .babelrc 文件, 假设要使用这个 react 预先装置 (Babel 是用于将现代 JavaScript 转换成浏览器兼容的 Javascript 的东西)。它应该只包含:

{
  "presets" : ["es2015","react"]
}

最后, webpack.config.js 需要更新才能使用它。更改后, 它看起来应该像下面这样:

const path = require('path');

module.exports = {
    entry: './frontend/index.js',
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'static/js')
    },
    module : {
        rules : [
            {
                test: /\.js/,
                include: path.resolve(__dirname, 'frontend'),
                loader: 'babel-loader'
            }
        ]
    }
};

尝试 npm run build。它应该存活下来,尽管我们还没有使用任何新的 React 支持。

接下来构建工具链,是时候安装我们将用于前端的 React 模块。开始:

$ npm install --save react react-dom

通过这些,我们可以编辑我们的 index.js 文件,如下所示:

import React from 'react';
import {render} from 'react-dom';

var App = () => <p>Hello React!</p>;
render(<App />, document.getElementById('app'));
And add a div tag with the ID app to our index.html:

<html>
  <head>
    <title>Tipsy</title>
  </head>
  <body>
    <h1>Tipsy</h1>
    <div id="app"></div>
    <script src="js/bundle.js"></script>
  </body>
</html>

再次运行 npm run build, 刷新, 它应该会打印出 Hello React!.

$ git add .
$ git commit -m "Setup react"
[master 0ffa260] Setup react
 5 files changed, 27 insertions(+), 1 deletion(-)
 create mode 100644 .babelrc

添加一些组件

接下来,让我们再简单介绍一下 UI。用以下内容替换现有的 App 组件:

var SubmitTip = () => (
    <div>
        <h2>Got a tip?</h2>
        <div>
            <textarea rows="2" cols="100" maxlength="200" />
        </div>
        <input type="button" value="Add Tip" />
    </div>
);

var LatestTips = () => (
    <div>
        <h2>Latest Tips</h2>
        TODO
    </div>
);

var App = () => (
    <div>
        <SubmitTip />
        <LatestTips />
    </div>
);

再运行一次 npm run build, refresh,它应该显示一些看起来很像人们期望的 UI。但是我们如何获取数据来填充 UI?当点击按钮时我们要做些什么?为此,我们将引入客户端难题的最后一部分:Redux。

Redux

Redux 在其网站上有一个很好的教程,我不会在这里尝试重复,但我会尝试总结一下 Redux 的作用。 React 为我们提供了一种在每次更改时渲染虚拟 DOM 的方法。为了使它有用,我们需要某种对象,其中包含应该呈现在 UI 上的当前状态。 Redux 是该状态的容器,它让我们使用 reducer 组织对它的更改。也就是说,我们永远不会真正更新状态,我们只是生成一个从当前状态派生的新状态,再加上一个动作。

首先,让我们添加 redux 和相关的依赖项。

$ npm install --save redux redux-thunk react-redux

接下来,我们需要定义一些 action。action 对应于页面上的状态更改。我们现在只创建两个:

  • CHANGE_TIP_TEXT - 当用户输入的提示文本发生变化时
  • ADD_TIP - 当添加 Tip 的按钮被按下时

在 frontend/actions.js 文件中, 我们这样做:

export const CHANGE_TIP_TEXT = 'CHANGE_TIP_TEXT';
export const ADD_TIP = 'ADD_TIP';

export function changeTipText(text) {
    return { type: CHANGE_TIP_TEXT, text };
}
export function addTip() {
    return { type: ADD_TIP };
}

常量是 actions 的名称,这些函数称为“action 创建者”:它们创建一个具有类型属性的对象,并且可选地创建一些数据。

接下来,我们需要一个 reducer。 Reducers 采用当前状态,计算并返回一个新状态。他们永远不会改变状态,也不会做任何副作用(例如网络 I/O)。他们是纯粹的计算。到目前为止,我们的状态将非常简单:只是文本框的内容。

当我们在 JavaScript 中使用即将推出的扩展运算符时,写 reducers 会更方便; 它是一个前缀 ...,和 Raku 的前缀 | 操作符一样压平操作数。由于我们已经有了构建工具链,我们可以通过安装另一个语法转换来添加它:

npm install --save-dev babel-plugin-transform-object-rest-spread

并把它添加到我们的 .babelrc 文件中:

{
    "presets" : ["es2015","react"],
    "plugins" : ["transform-object-rest-spread"]
}

完成后,这是我们的 reducer,放在 frontend/reducer.js 文件中:

import * as ActionTypes from './actions';

const initialState = {
    tipText: ''
};
export function tipsyReducer(state = initialState, action) {
    switch (action.type) {
        case ActionTypes.CHANGE_TIP_TEXT:
            return { ...state, tipText: action.text };
        case ActionTypes.ADD_TIP:
            return { ...state, tipText: '' };
        default:
            return state;
    }
}

现在是时候把它连接到 React 了。整体流程将是:

  1. UI 上发生了一些事情(文本已更改,按下了添加提示按钮)2。创建了一个 action 对象 3.它被传递到 reducer,它生成一个新状态 4.新状态转换为属性,然后用于构建 React 虚拟 DOM 5. React 负责将任何更新应用于真实DOM,从而反映我们的更改

回到 frontend/index.js,我们将导入我们的 actions 和 reducer,以及 redux 和 react-redux 库中的一些东西:

import { createStore } from 'redux';
import { Provider, connect } from 'react-redux';
import * as Actions from './actions';
import { tipsyReducer } from './reducer';

接下来,我们将创建一个 Redux 存储,它包含 reducer 生成的最新版本的状态。我们通过将 reducer 传递给 createStore 来创建它:

let store = createStore(tipsyReducer);

接下来,我们需要将存储中的状态映射到 React 组件中可用属性,并生成一些也可以在这些属性中使用的函数,并调度 actions。

function mapProps(state) {
    return state;
}
function mapDispatch(dispatch) {
    return {
        onChangeTipText: text => dispatch(Actions.changeTipText(text)),
        onAddTip: text => dispatch(Actions.addTip())
    };
}

有了这些,我们可以完成 Redux 与 React 的集成,如下所示:

let ConnectedApp = connect(mapProps, mapDispatch)(App);
render(
    <Provider store={store}>
        <ConnectedApp />
    </Provider>,
    document.getElementById('app'));

connect 函数使来自存储中状态的属性可用于 App React 组件,并将其包装在 Provider 中使得存储中的状态可用以便实现。

最后但同样重要的是,我们可以更新我们的组件:

var SubmitTip = props => (
    <div>
        <h2>Got a tip?</h2>
        <div>
            <textarea rows="2" cols="100" maxLength="200"
                value={props.tipText}
                onChange={e => props.onChangeTipText(e.target.value)} />
        </div>
        <input type="button" value="Add Tip" onClick={props.onAddTip} />
    </div>
);

var App = props => (
    <div>
        <SubmitTip tipText={props.tipText}
            onChangeTipText={props.onChangeTipText}
            onAddTip={props.onAddTip} />
        <LatestTips />
    </div>
);

在 npm run build 之后, 刷新浏览器,我们应该注意到在键入后点击 Add Tip 按钮将清除文本框。我们的 action 和 reducer 正在运行。唷!

为了完整性,这里是这个阶段整个 frontend/index.js:

import React from 'react';
import { render } from 'react-dom';
import { createStore } from 'redux';
import { Provider, connect } from 'react-redux';
import * as Actions from './actions';
import { tipsyReducer } from './reducer';

var SubmitTip = props => (
    <div>
        <h2>Got a tip?</h2>
        <div>
            <textarea rows="2" cols="100" maxLength="200"
                value={props.tipText}
                onChange={e => props.onChangeTipText(e.target.value)} />
        </div>
        <input type="button" value="Add Tip" onClick={props.onAddTip} />
    </div>
);

var LatestTips = () => (
    <div>
        <h2>Latest Tips</h2>
        TODO
    </div>
);

var App = props => (
    <div>
        <SubmitTip tipText={props.tipText}
            onChangeTipText={props.onChangeTipText}
            onAddTip={props.onAddTip} />
        <LatestTips />
    </div>
);

function mapProps(state) {
    return state;
}
function mapDispatch(dispatch) {
    return {
        onChangeTipText: text => dispatch(Actions.changeTipText(text)),
        onAddTip: text => dispatch(Actions.addTip())
    };
}

let store = createStore(tipsyReducer);
let ConnectedApp = connect(mapProps, mapDispatch)(App);
render(
    <Provider store={store}>
        <ConnectedApp />
    </Provider>,
    document.getElementById('app'));

又到了提交的时间了。

$ git add .
$ git commit -m "Wire up Redux"
[master 3478433] Wire up Redux
 5 files changed, 83 insertions(+), 7 deletions(-)
 create mode 100644 frontend/actions.js
 rewrite frontend/index.js (81%)
 create mode 100644 frontend/reducer.js

POSTing 到后端

最后,是时候在前端添加一个提示调用后端了!我们需要做两件事:

1.在 Cro 后端编写一个 POST 处理程序 2.找到一种方法让我们的 Redux action 结果在 POST 中

首先是 Cro 部分。在 lib/Routes.pm6 中,我们添加以下路由实现:

post -> 'tips' {
    request-body -> (:$text) {
        $tipsy.add-tip($text);
        response.status = 204;
    }
}

接下来,JavaScript 部分。问题是网络部分在哪里?我们的 reducer 应该是纯净的。答案是我们之前安装的 redux-thunk 模块,但尚未使用。这允许我们编写返回函数的 action 创建者。该函数将通过调度程序,并可以调用它来调度 action。通常,我们将在异步操作开始时调度一个 action,我们可以使用它在 UI 上指示,并在完成后再指示一个操作,因此我们可以再次指示操作在 UI 中完成。

首先,让我们设置 redux-thunk,这是一个中间件。回到 frontend/index.js,添加:

import thunkMiddleware from 'redux-thunk';

然后改变:

import { createStore } from 'redux';

还要再导入 applyMiddleware:

import { createStore, applyMiddleware } from 'redux';

最后,更改:

let store = createStore(tipsyReducer);

还要应用 the middleware:

let store = createStore(tipsyReducer, applyMiddleware(thunkMiddleware));

下一步,在 frontend/actions.js 中, 我们将重构 add tip action 创建器函数作为 thunk action:

export function addTip() {
    return dispatch => {
        dispatch({ type: ADD_TIP });
    };
}

请注意,这还没有改变任何东西; build 它,行为应该是一样的。但是,现在我们已经完成了这项工作,我们可以看到在网络操作之后可以在回调中执行此调度。

当然有很多好方法可以处理 JavaScript 中的异步操作,如果你正在认真构建单页面应用程序,我强烈建议你考虑使用 promise 库。还有 Redux 中间件将与之集成。现在,我们将做最简单的事情:使用 jQuery 和回调。首先,让我们添加 jQuery 作为依赖:

$ npm install --save jquery

然后我们会在 frontend/actions.js 中导入它:

import $ from 'jquery';

并将 POST 发送到后端:

export function addTip() {
    return (dispatch, getState) => {
        $.ajax({
            url: '/tips',
            type: 'POST',
            contentType: 'application/json',
            data: JSON.stringify({ text: getState().tipText }),
            success: () => dispatch({ type: ADD_TIP })
        });
    };
}

重新加载它,可能打开浏览器的开发工具(通常是F12),翻到“网络”选项卡,然后单击“添加提示”。一切顺利,您将观察到请求已经完成并且它产生了 204 响应。或者,你可能想停止运行,而是做一些 cro trace; 键入消息,再次单击“添加提示”,您应该看到请求主体在跟踪输出中转储。

...
65 63 74 69 6f 6e 3a 20 6b 65 65 70 2d 61 6c 69  ection: keep-ali
76 65 0d 0a 0d 0a 7b 22 74 65 78 74 22 3a 22 74  ve....{"text":"t
72 79 20 74 68 65 20 62 65 65 66 22 7d           ry the beef"}

The latest tips websocket

我们可以通过各种方式编写 websocket,但我们将使用 redux-websocket-action。这允许我们通过 websocket 将 Redux 动作发送到客户端,这很简洁(尽管不幸的是,这意味着我们将后端与前端中此库的使用相结合,这值得一些质疑)。

首先,让我们在前端安装库:

$ npm install --save redux-websocket-action

在 index.js 中,我们导入它:

import WSAction from 'redux-websocket-action';

并设置它,像这样:

let host = window.location.host;
let wsAction = new WSAction(store, 'ws://' + host + '/latest-tips', {
    retryCount:3,
    reconnectInterval: 3
});
wsAction.start();

在 frontend/actions.js 中,我们将添加一个动作类型(但没有动作创建功能,因为它将来自服务器):

export const LATEST_TIP = 'LATEST_TIP';

然后我们将更新我们的 reducer,将每个传入的提示添加到最新提示列表中,因此 frontend/reducer.js 最终为:

import * as ActionTypes from './actions';

const initialState = {
    tipText: '',
    latestTips: []
};
export function tipsyReducer(state = initialState, action) {
    switch (action.type) {
        case ActionTypes.CHANGE_TIP_TEXT:
            return { ...state, tipText: action.text };
        case ActionTypes.ADD_TIP:
            return { ...state, tipText: '' };
        case ActionTypes.LATEST_TIP: {
            let tip = { id: action.id, text: action.text };
            return {
                ...state,
                latestTips: [tip, ...state.latestTips]
            };
        }
        default:
            return state;
    }
}

然后更新 frontend/index.js 中的 React 组件以渲染最新提示:

var LatestTips = props => (
    <div>
        <h2>Latest Tips</h2>
        <ul>
        {props.tips.map(t => <li key={t.id}>{t.text}</li>)}
        </ul>
    </div>
);

var App = props => (
    <div>
        <SubmitTip tipText={props.tipText}
            onChangeTipText={props.onChangeTipText}
            onAddTip={props.onAddTip} />
        <LatestTips tips={props.latestTips} />
    </div>
);

应用程序更新只是为了将提示传递给 LatestTips 组件。然后,客户端添加完成。

现在是后端,这只是 Routes.pm6 中的一个补充。我们清除了为我们生成的 websocket 存根,并将其替换为代码以从 Tipsy 业务逻辑对象获取最新提示,将事件转换为适当的 JSON,然后发出它们:

get -> 'latest-tips' {
    web-socket -> $incoming {
        supply whenever $tipsy.latest-tips -> $tip {
            emit to-json {
                WS_ACTION => True,
                action => {
                    type => 'LATEST_TIP',
                    id => $tip.id,
                    text => $tip.tip
                }
            }
        }
    }
}

The outer hash is the “envelope” for redux-websocket-action, which tells it to pay attention to the message and dispatch its action property as a Redux action.

Reload it in the browser. Add a tip. See it show up. Open a second tab with the application. You’ll see it shows the first tip. Add a second tip. Flip back to the first tab, and you’ll see that tip magically showed up there too. We’re now successfully sharing out the tips over the web socket. Hurrah! That calls for a commit.

外部哈希是 redux-websocket-action 的“信封”,它告诉它注意消息并将其 action 属性作为 Redux 动作发送。

在浏览器中重新加载它。添加提示。看到它出现。使用该应用程序打开第二个选项卡。你会看到它显示第一个提示。添加第二个提示。翻回第一个标签,你会看到那个小费神奇地出现在那里。我们现在已成功通过 Web 套接字分享提示。欢呼!这需要提交一次。

Agree and disagree

我们花了很多时间来设置客户端(client-side)的基础架构。现在它已经到位,但是,添加更多功能是一个更快的过程。让我们通过实现同意/不同意功能(链接让用户表示同意/不同意提示)来完善教程,并显示最头部的提示列表。

让我们从后端开始,在业务逻辑对象中编写这些新功能的测试。我们将这些测试添加到 t/tipsy.t

given $tipsy.latest-tips.head(3).list -> @tips {
    $tipsy.agree(@tips[0].id) for ^3;
    $tipsy.agree(@tips[1].id) for ^4;
    $tipsy.disagree(@tips[1].id) for ^10;
    $tipsy.agree(@tips[2].id) for ^2;
}
given $tipsy.top-tips.head(1).list[0] {
    is .[0].tip, 'Try the vanilla stout for sure',
        'Most agreeable tip first';
    is .[1].tip, 'The lamb kebabs are good!',
        'Next most agreeable tip second';
    is .[2].tip, 'Not so keen on the fish burrito!',
        'Least agreeable tip third';
}
throws-like { $tipsy.agree(99999) }, X::Tipsy::NoSuchId,
    'Correct exception on no such tip';

第一部分只是在提示上添加了一些 agreement 和 disagreement(最新的会拍在第一的位置)。接下来,我们使用 top-tips 供应并抓住它发出的第一个东西(或至少应该发出),这是我们点击 Supply 时的当前排序。它检查顺序是否正确。最终测试检查对于无法识别的 ID,我们抛出异常。

这是新的异常类型:

class X::Tipsy::NoSuchId is Exception {
    has $.id;
    method message() { "No tip with ID '$!id'" }
}

接下来,我们将给 Tip 类提供一些方法,这些方法生成 Tip 对象的新版本,同意或不同意更高一些(我们将这些实例保持不变,因为我们在监视器之外共享它们):

class Tip {
    has Int $.id is required;
    has Str $.tip is required;
    has Int $.agreed = 0;
    has Int $.disagreed = 0;

    method agree() {
        self.clone(agreed => $!agreed + 1)
    }

    method disagree() {
        self.clone(disagreed => $!disagreed + 1)
    }
}

同意和不同意的方法很容易:

method agree(Int $tip-id --> Nil) {
    with %!tips-by-id{$tip-id} -> $tip-ref is rw {
        $tip-ref .= agree;
    }
    else {
        X::Tipsy::NoSuchId.new(id => $tip-id).throw;
    }
}

method disagree(Int $tip-id --> Nil) {
    with %!tips-by-id{$tip-id} -> $tip-ref is rw {
        $tip-ref .= disagree;
    }
    else {
        X::Tipsy::NoSuchId.new(id => $tip-id).throw;
    }
}

嗯,实际上这有点相似。让我们使用私有方法来解决它:

method agree(Int $tip-id --> Nil) {
    self!with-tip: $tip-id, -> $tip-ref is rw {
        $tip-ref .= agree;
    }
}

method disagree(Int $tip-id --> Nil) {
    self!with-tip: $tip-id, -> $tip-ref is rw {
        $tip-ref .= disagree;
    }
}   
        
method !with-tip(Int $tip-id, &operation --> Nil) {
    with %!tips-by-id{$tip-id} -> $tip-ref is rw {
        operation($tip-ref)
    }
    else {
        X::Tipsy::NoSuchId.new(id => $tip-id).throw;
    }
}

最后,这是我们对 top-tips 方法的第一次尝试:

method top-tips(--> Supply) { 
    my @top-tips = %!tips-by-id.values
        .sort({ .disagreed - .agreed })
        .head(50);
    supply {
        emit @top-tips;
    }
}

它通过了测试,然而……缺少了一些东西。这意味着每当有新的提示或提示被同意或不同意时,会发出更新的排序提示列表。让我们为它添加一些测试:

my $new-tip-id;
react {
    whenever $tipsy.top-tips.skip(1).head(1) {
        is .[0].tip, 'Try the vanilla stout for sure',
            'After adding a tip, correct order (1)';
        is .[1].tip, 'The lamb kebabs are good!',
            'After adding a tip, correct order (2)';
        is .[2].tip, 'The pau bahji is super spicy',
            'After adding a tip, correct order (3)';
        is .[3].tip, 'Not so keen on the fish burrito!',
            'After adding a tip, correct order (4)';
        $new-tip-id = .[2].id;
    }
    $tipsy.add-tip('The pau bahji is super spicy');
}
ok $new-tip-id, 'New tip ID seen in top sorted tips';

react {
    whenever $tipsy.top-tips.skip(5).head(1) {
        is .[0].tip, 'The pau bahji is super spicy',
            'After agrees, order updated';
    }
    $tipsy.agree($new-tip-id) for ^5;
}

要进行此更新,我们需要一个我们可以发出的 Supplier,以表明对同意/不同意计数的更改。

has Supplier $!tip-change = Supplier.new;

并在其上发射(在之前的因子分解后很容易):

method !with-tip(Int $tip-id, &operation --> Nil) {
    with %!tips-by-id{$tip-id} -> $tip-ref is rw {
        operation($tip-ref);
        start $!tip-change.emit($tip-ref<>);
    }
    else {
        X::Tipsy::NoSuchId.new(id => $tip-id).throw;
    }
}

最后,更新 top-tips 方法。我们有几种方法可以做到这一点,但确保我们安全的最简单方法是确保 Supply 保持其自身的本地状态,它可以保护(再次,请记住,因为 supply 块从中返回,并且超越,方法,它不会受到监视器的保护)。

method top-tips(--> Supply) {
    my %initial-tips = %!tips-by-id;
    supply {
        my %current-tips = %initial-tips;
        sub emit-latest-sorted() {
            emit [%current-tips.values.sort({ .disagreed - .agreed }).head(50)]
        }
        whenever Supply.merge($!latest-tips.Supply, $!tip-change.Supply) {
            %current-tips{.id} = $_;
            emit-latest-sorted;
        }
        emit-latest-sorted;
    }
}

完成后,是时候通过更新 lib/Routes.pm6 将此功能公开给外界。它的工作是将业务逻辑映射到 HTTP。我们唯一需要注意的是将 “no such ID” 的异常转换为适当的 HTTP 响应,即 404 Not Found。否则,我们会发回 500 内部服务器错误,这是错误的,因为客户端发送了一些伪造的 ID 不是服务器的错。开始:

post -> 'tips', Int $id, 'agree' {
    $tipsy.agree($id);
    response.status = 204;
    CATCH {
        when X::Tipsy::NoSuchId {
            not-found;
        }
    }
}

post -> 'tips', Int $id, 'disagree' {
    $tipsy.disagree($id);
    response.status = 204;
    CATCH {
        when X::Tipsy::NoSuchId {
            not-found;
        }
    }
}

后端的最后一步是将顶部提示映射到另一个 Web 套接字:

get -> 'top-tips' {
    web-socket -> $incoming {
        supply whenever $tipsy.top-tips -> @tips {
            emit to-json {
                WS_ACTION => True,
                action => {
                    type => 'UPDATE_TOP_TIPS',
                    tips => [@tips.map: -> $tip {
                        {
                            id => $tip.id,
                            text => $tip.tip,
                            agreed => $tip.agreed,
                            disagreed => $tip.disagreed
                        }
                    }]
                }
            }
        }
    }
}

现在到前端了。在 frontend/actions.js 中,我们将添加三个新的 action 类型常量:

export const UPDATE_TOP_TIPS = 'UPDATE_TOP_TIPS';
export const AGREE = 'AGREE';
export const DISAGREE = 'DISAGREE';
And then two new action creators (UPDATE_TOP_TIPS doesn't need one, as it comes from the server):

export function agree(id) {
    return dispatch => {
        $.ajax({
            url: '/tips/' + id + '/agree',
            type: 'POST',
            success: () => dispatch({ type: AGREE, id })
        });
    };
}
export function disagree(id) {
    return dispatch => {
        $.ajax({
            url: '/tips/' + id + '/disagree',
            type: 'POST',
            success: () => dispatch({ type: DISAGREE, id })
        });
    };
}

在 reducer 中,我们将调整初始状态以获得一组空的 top 提示:

const initialState = {
    tipText: '',
    latestTips: [],
    topTips: []
};

然后添加一个额外的 case 语句来处理新的更新操作(我们不需要在同意/不同意的情况下做任何事情,尽管在真实的应用程序中我们想要给一些用户反馈他们的投票被计算在内):

case ActionTypes.UPDATE_TOP_TIPS:
    return {
        ...state,
        topTips: action.tips
    };

接下来我们需要连接第二个 Web 套接字。这只是 frontend/index.js 中的一点重构:

['latest-tips', 'top-tips'].forEach(endpoint => {
    let host = window.location.host;
    let wsAction = new WSAction(store, 'ws://' + host + '/' + endpoint, {
        retryCount:3,
        reconnectInterval: 3
    });
    wsAction.start();
});

接下来,我们将把调度更新到 props map,包括我们新的同意和不同意的 action:

function mapDispatch(dispatch) {
    return {
        onChangeTipText: text => dispatch(Actions.changeTipText(text)),
        onAddTip: text => dispatch(Actions.addTip()),
        onAgree: id => dispatch(Actions.agree(id)),
        onDisagree: id => dispatch(Actions.disagree(id)),
    };
}

接下来,我们将向您展示一个组件的提示,我们可以在最新的提示和重要提示中重复使用,其中包括同意或不同意的链接:

var Tip = props => (
    <li>
        {props.text} [<a href="#" onClick={() => props.onAgree(props.id)}>Agree</a>]
        [<a href="#" onClick={() => props.onDisagree(props.id)}>Disagree</a>]
    </li>
);

然后显示一个提示列表,标题为:

var TipList = props => (
    <div>
        <h2>{props.heading}</h2>
        <ul>
        {props.tips.map(t => <Tip key={t.id} {...props} {...t} />)}
        </ul>
    </div>
);

最后,App 组件变为:

var App = props => (
    <div>
        <SubmitTip tipText={props.tipText}
            onChangeTipText={props.onChangeTipText}
            onAddTip={props.onAddTip} />
        <TipList heading="Latest Tips" tips={props.latestTips}
            onAgree={props.onAgree} onDisagree={props.onDisagree} />
        <TipList heading="Top Tips" tips={props.topTips}
            onAgree={props.onAgree} onDisagree={props.onDisagree} />
    </div>
);

我们终于得到它了。 npm run build,刷新,并给它一个旋转。在任一列表中单击同意或不同意最终会更改顶部提示列表中的排序顺序以反映投票。

最后,我们完成了。

$ git commit -m "Add agree/disagree feature" .
[master 5d792bc] Add agree/disagree feature
 6 files changed, 188 insertions(+), 18 deletions(-)

Summing up

在本教程中,我们从零到应用单页面应用程序。前端是使用 React 和 Redux 在现代 ES6 中编写的。使用 Cro,后端在 Raku 中。它们使用 HTTP 和 Web 套接字进行通信。并且两者都声明了它们的依赖关系,因此新开发人员的入门只是以下情况:

zef install --deps-only .
npm install .
npm run build
cro run

再一次,代码可用于本教程中描述的提交历史记录。

cro 
comments powered by Disqus