React-组件指南-二-

26 阅读34分钟

React 组件指南(二)

原文:zh.annas-archive.org/md5/947d3eee2e70faff6cfb9ae1731c27bc

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章。更改视图

在上一章中,我们简要介绍了材料设计,因此我们将登录和页面管理部分分成了不同的文件。我们还没有将登录重定向到页面管理部分。

在本章中,你将学习如何在不重新加载页面的情况下更改部分。我们将使用这些知识来创建我们 CMS 意图控制的网站的公共页面。

我们将了解如何与浏览器的地址栏和位置历史记录一起工作。我们还将学习如何使用流行的库来抽象这些功能,这样我们就可以节省编写样板代码的时间,专注于使我们的界面更加出色!

位置,位置,位置!

在我们了解页面重新加载的替代方案之前,让我们看看浏览器是如何管理重新加载的。

你可能已经遇到了 window 对象。它是浏览器功能状态的全球通用对象。它也是任何 HTML 页面的默认 this 作用域:

位置,位置,位置!

我们甚至之前已经访问过 window。当我们渲染到 document.body 或使用 document.querySelector 时,这些属性和方法是在 window 对象上被调用的。这和调用 window.document.querySelector 是一样的。

大多数情况下,document 是我们需要的唯一属性。但这并不意味着它是我们唯一有用的属性。在控制台中尝试以下操作:

console.log(window.location);

你应该会看到以下类似的内容:

Location {
    hash: ""
    host: "127.0.0.1:3000"
    hostname: "127.0.0.1"
    href: "http://127.0.0.1:3000/examples/login.html"
    origin: "http://127.0.0.1:3000"
    pathname: "/examples/login.html"
    port: "3000"
    ...
}

如果我们试图根据浏览器 URL 来确定要显示哪些组件,这将是一个绝佳的起点。我们不仅可以从这个对象中读取,还可以写入它:

<script>
    window.location.href = "http://material-ui.com";
</script>

将此内容放入 HTML 页面或输入控制台中的那一行 JavaScript 代码,将使浏览器重定向到 www.material-ui.com。这和点击该网站的链接是一样的。而且,如果它重定向到了不同的页面(而不是浏览器指向的页面),那么它将导致整个页面刷新。

一点历史

那么,这如何帮助我们呢?毕竟,我们试图避免整个页面的刷新。让我们通过这个对象进行实验。

让我们看看当我们在 URL 中添加类似 #page-admin 的内容时会发生什么。

一点历史

#page-admin 添加到 URL 中会导致 window.location.hash 属性被填充相同的值。更重要的是,更改哈希值不会刷新页面!这和点击具有 href 属性中哈希的链接是一样的。我们可以修改它而不会导致整个页面刷新,并且每次修改都会在浏览器历史记录中存储一个新的条目。

使用这个技巧,我们可以不重新加载页面地逐步通过多个不同的状态,并且我们将能够使用浏览器的后退按钮回退每个状态。

使用浏览器历史记录

让我们在我们的 CMS 中使用这个技巧。首先,让我们在我们的 Nav 组件中添加几个函数:

export default (props) => {
    // ...define class names

 var redirect = (event, section) => {
 window.location.hash = `#${section}`;
 event.preventDefault();
 }

    return <div className={drawerClassNames}>
        <header className="demo-drawer-header">
            <img src="img/user.jpg"
                 className="demo-avatar" />
        </header>
        <nav className={navClassNames}>
            <a className="mdl-navigation__link"
               href="/examples/login.html"
               onClick={(e) => redirect(e, "login")}>
                <i className={buttonIconClassNames}
                   role="presentation">
                    lock
                </i>
                Login
            </a>
            <a className="mdl-navigation__link"
               href="/examples/page-admin.html"
               onClick={(e) => redirect(e, "page-admin")}>
                <i className={buttonIconClassNames}
                   role="presentation">
                    pages
                </i>
                Pages
            </a>
        </nav>
    </div>;
};

我们给我们的导航链接添加了一个onClick属性。我们创建了一个特殊函数,该函数将改变window.location.hash并阻止链接可能引起的默认完整页面刷新行为。

注意

这是对箭头函数的一个巧妙应用,但我们在每次渲染调用中实际上创建了三个新函数。记住,这可能会很昂贵,所以最好将函数创建移出渲染。我们很快就会替换它。

看到模板字符串的实际应用也很有趣。我们不是使用"#" + section,而是可以使用'#${section}'来插入 section 名称。在短字符串中,这并不那么有用,但在长字符串中变得越来越有用。

点击导航链接现在会改变 URL hash。我们可以通过在点击导航链接时渲染不同的组件来添加这种行为:

import React from "react";
import ReactDOM from "react-dom";
import Component from "src/component";
import Login from "src/login";
import Backend from "src/backend";
import PageAdmin from "src/page-admin";

class Nav extends Component {
    render() {
        // ...define class names

        return <div className={drawerClassNames}>
            <header className="demo-drawer-header">
                <img src="img/user.jpg"
                     className="demo-avatar" />
            </header>
            <nav className={navClassNames}>
                <a className="mdl-navigation__link"
                   href="/examples/login.html"
                   onClick={(e) => this.redirect(e, "login")}>
                    <i className={buttonIconClassNames}
                       role="presentation">
                        lock
                    </i>
                    Login
                </a>
                <a className="mdl-navigation__link"
                   href="/examples/page-admin.html"
                   onClick={(e) => this.redirect(e, "page-admin")}>
                    <i className={buttonIconClassNames}
                       role="presentation">
                        pages
                    </i>
                    Pages
                </a>
            </nav>
        </div>;
    }

 redirect(event, section) {
 window.location.hash = '#${section}';

 var component = null;

 switch (section) {
 case "login":
 component = <Login />;
 break;
 case "page-admin":
 var backend = new Backend();
 component = <PageAdmin backend={backend} />;
 break;
 }

 var layoutClassNames = [
 "demo-layout",
 "mdl-layout",
 "mdl-js-layout",
 "mdl-layout--fixed-drawer"
 ].join(" ");

 ReactDOM.render(
 <div className={layoutClassNames}>
 <Nav />
 {component}
 </div>,
 document.querySelector(".react")
 );

 event.preventDefault();
 }
};

export default Nav;

我们不得不将Nav函数转换为Nav类。我们希望在渲染之外创建重定向方法(因为这样更高效),并且隔离渲染组件的选择。

使用类也给我们提供了一种命名和引用Nav组件的方法,因此我们可以在redirect方法中创建一个新的实例来覆盖它。将这种代码包装在组件中并不理想,所以我们会稍后清理它。

我们现在可以在不进行完整页面刷新的情况下在不同的部分之间切换。

还有一个问题需要解决。当我们使用浏览器的后退按钮时,组件不会改变以反映每个 hash 应该显示的组件。我们可以通过几种方式解决这个问题。我们可以尝试的第一种方法是频繁检查 hash:

componentDidMount() {
 var hash = window.location.hash;

 setInterval(() => {
 if (hash !== window.location.hash) {
 hash = window.location.hash;
 this.redirect(null, hash.slice(1), true);
 }
 }, 100);
}

redirect(event, section, respondingToHashChange = false) {
 if (!respondingToHashChange) {
 window.location.hash = `#${section}`;
 }

    var component = null;

    switch (section) {
        case "login":
            component = <Login />;
            break;
        case "page-admin":
            var backend = new Backend();
            component = <PageAdmin backend={backend} />;
            break;
    }

    var layoutClassNames = [
        "demo-layout",
        "mdl-layout",
        "mdl-js-layout",
        "mdl-layout--fixed-drawer"
    ].join(" ");

    ReactDOM.render(
        <div className={layoutClassNames}>
            <Nav />
            {component}
        </div>,
        document.querySelector(".react")
    );

 if (event) {
 event.preventDefault();
 }
}

我们的redirect方法有一个额外的参数,用于在我们不响应 hash 更改时应用新的 hash。我们还包装了对event.preventDefault的调用,以防我们没有点击事件可以处理。除了这些更改之外,redirect方法保持不变。

我们还在其中添加了一个componentDidMount方法,在其中我们调用setInterval。我们存储了初始的window.location.hash属性,并且每秒检查 10 次是否已更改。hash 值是#login#page-admin,所以我们切掉了第一个字符,并将剩余的部分传递给redirect方法。

尝试点击不同的导航链接,然后使用浏览器的后退按钮。

第二种选项是使用window.history对象上的较新的pushStatepopState方法。它们目前支持得不是很好,所以当你处理旧浏览器时需要小心,或者确保你不需要处理它们。

注意

你可以在developer.mozilla.org/en-US/docs/Web/API/History_API了解更多关于pushStatepopState的信息。

有一种更简单的方式来响应用户点击链接:hashchange 事件。我们不需要给每个链接添加 onClick 事件(并且每次都调用 redirect 函数),我们可以监听 hashchange 事件并切换到相应的视图。关于这个主题有一个很好的教程在 medium.com/@tarkus/react-js-routing-from-scratch-246f962ededf

使用路由器

我们的哈希代码是功能性的但侵入性的。我们不应该在组件内部(至少不是我们自己的组件)调用 render 方法。因此,我们将使用一个流行的路由器来帮我们管理这些。

使用以下命令下载路由器:

$ npm install react-router --save

然后,我们需要将 login.htmlpage-admin.html 放回到同一个文件中:

<!DOCTYPE html>
<html>
    <head>
        <script src="img/browser.js"></script>
        <script src="img/system.js"></script>
        <script src="img/material.min.js"></script>
        <link rel="stylesheet" href="https://storage.googleapis.com/code.getmdl.io/1.0.6/material.indigo-pink.min.css" />
        <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
        <link rel="stylesheet" href="admin.css" />
    </head>
    <body class="
        mdl-demo
        mdl-color--grey-100
        mdl-color-text--grey-700
        mdl-base">
        <div class="react"></div>
        <script>
            System.config({
                "transpiler": "babel",
                "map": {
                    "react": "/examples/react/react",
                    "react-dom": "/examples/react/react-dom",
 "router": "/node_modules/react-router/umd/ReactRouter"
                },
                "baseURL": "../",
                "defaultJSExtensions": true
            });

 System.import("examples/admin");
        </script>
    </body>
</html>

注意我们是如何将 ReactRouter 文件添加到导入映射中的?我们将在 admin.js 中使用它。首先,让我们定义我们的 layout 组件:

import React from "react";
import ReactDOM from "react-dom";
import Component from "src/component";
import Nav from "src/nav";
import Login from "src/login";
import Backend from "src/backend";
import PageAdmin from "src/page-admin";
import {Router, browserHistory, IndexRoute, Route} from "router";

var App = function(props) {
    var layoutClassNames = [
        "demo-layout",
        "mdl-layout",
        "mdl-js-layout",
        "mdl-layout--fixed-drawer"
    ].join(" ");

    return (
        <div className={layoutClassNames}>
            <Nav />
            {props.children}
        </div>
    );
};

这创建了我们所使用的页面布局,并允许一个动态的内容组件。每个 React 组件都有一个 this.props.children 属性(或者对于函数组件来说是 props.children),它是一个嵌套组件的数组。例如,考虑以下组件:

<App>
    <Login />
</App>

App 组件内部,this.props.children 将包含一个单独的项目:一个 Login 实例。接下来,我们将为想要路由的两个部分定义处理组件:

var LoginHandler = function() {
    return <Login />;
};

var PageAdminHandler = function() {
    var backend = new Backend();
    return <PageAdmin backend={backend} />;
};

我们实际上并不需要将 Login 包装在 LoginHandler 中,但我选择这样做是为了与 PageAdminHandler 保持一致。PageAdmin 期望一个 Backend 实例,因此我们必须像在这个示例中看到的那样进行包装。

现在,我们可以为我们的 CMS 定义路由:

ReactDOM.render(
 <Router history={browserHistory}>
 <Route path="/" component={App}>
 <IndexRoute component={LoginHandler} />
 <Route path="login" component={LoginHandler} />
 <Route path="page-admin" component={PageAdminHandler} />
 </Route>
 </Router>,
    document.querySelector(".react")
);

对于根路径 /,存在一个单一的根路由。它创建了一个 App 实例,因此我们总是得到相同的布局。然后,我们嵌套了一个 "login" 路由和一个 "page-admin" 路由。这些创建了它们各自组件的实例。我们还定义了 IndexRoute,以便登录页面将显示为着陆页。

我们需要从 Nav 中移除我们的自定义历史代码:

import React from "react";
import ReactDOM from "react-dom";
import { Link } from "router";

export default (props) => {
    // ...define class names

    return <div className={drawerClassNames}>
        <header className="demo-drawer-header">
            <img src="img/user.jpg"
                 className="demo-avatar" />
        </header>
        <nav className={navClassNames}>
 <Link className="mdl-navigation__link" to="login">
                <i className={buttonIconClassNames}
                   role="presentation">
                    lock
                </i>
                Login
 </Link>
 <Link className="mdl-navigation__link" to="page-admin">
                <i className={buttonIconClassNames}
                   role="presentation">
                    pages
                </i>
                Pages
 </Link>
        </nav>
    </div>;
};

由于我们不再需要一个单独的 redirect 方法,我们可以将类转换回一个声明式组件(function)。

注意我们用新的 Link 组件替换了锚点组件。这个组件与路由器交互,在我们点击导航链接时显示正确的部分。我们还可以更改路由路径,而无需更新此组件(除非我们同时更改路由名称)。

备注

在前一章中,我们将 index.html 分割成 login.htmlpage-admin.html,以便通过更改 URL 来查看这两个部分。在这一章中,我们将它们重新组合在一起,因为我们有一个路由器可以在它们之间切换。你需要做出同样的更改或使用本章的示例代码,以便使示例正常工作。

创建公共页面

现在我们能够轻松地在 CMS 各个部分之间切换,我们可以使用同样的技巧来显示我们网站的公共页面。让我们创建一个全新的 HTML 页面专门用于这些:

<!DOCTYPE html>
<html>
    <head>
 <script src="img/browser.js"></script>
 <script src="img/system.js"></script>
    </head>
    <body>
        <div class="react"></div>
        <script>
            System.config({
                "transpiler": "babel",
                "map": {
                    "react": "/examples/react/react",
                    "react-dom": "/examples/react/react-dom",
                    "router": "/node_modules/react-router/umd/ReactRouter"
                },
                "baseURL": "../",
                "defaultJSExtensions": true
            });

            System.import("examples/index");
        </script>
    </body>
</html>

这是一个没有材料设计资源的admin.html的简化形式。我认为在我们专注于导航的同时,我们可以暂时忽略这些页面的外观。

公共页面是无状态的,因此我们可以为它们使用函数组件。让我们从布局组件开始:

var App = function(props) {
    return (
        <div className="layout">
            <Nav pages={props.route.backend.all()} />
            {props.children}
        </div>
    );
};

这与App管理组件类似,但它还有一个对Backend的引用。然后我们在渲染组件时定义它:

var backend = new Backend();

ReactDOM.render(
    <Router history={browserHistory}>
        <Route path="/" component={App} backend={backend}>
 <IndexRoute component={StaticPage} backend={backend} />
 <Route path="pages/:page" component={StaticPage} backend={backend} />
        </Route>
    </Router>,
    document.querySelector(".react")
);

为了使这可行,我们还需要定义StaticPage

var StaticPage = function(props) {
    var id = props.params.page || 1;
    var backend = props.route.backend;

    var pages = backend.all().filter(
        (page) => {
            return page.id == id;
        }
    );

    if (pages.length < 1) {
        return <div>not found</div>;
    }

    return (
        <div className="page">
            <h1>{pages[0].title}</h1>
            {pages[0].content}
        </div>
    );
};

这个组件更有趣。我们访问params属性,这是一个包含所有为该路由定义的 URL 路径参数的映射。路径中有:pagepages/:page),所以当我们访问pages/1时,params对象是{"page":1}

我们还将Backend传递给Page,这样我们就可以获取所有页面并通过page.id进行过滤。如果没有提供page.id,则默认为1

过滤后,我们检查是否有任何页面。如果没有,我们返回一个简单的未找到信息。否则,我们渲染数组中第一个页面的内容(因为我们期望数组长度至少为1)。

现在我们有一个网站公共页面的页面:

创建公共页面

我们还可以为每个路由添加onEnteronLeave回调函数:

<Route path="pages/:page"
    component={StaticPage}
    backend={backend}
    onEnter={props => console.log("entering")}
    onLeave={() => console.log("leaving")} />

当当前路由发生变化时,前一个路由将触发onLeave,以及继承链上的每个父组件。一旦所有onLeave回调函数被触发,路由器将开始触发继承链下的onEnter回调函数。我们没有真正使用很多继承(由于我们的导航很简单),但仍然重要的是要记住onLeave是在onEnter之前触发的。

如果我们想将任何未保存的数据提交到我们的后端,记录用户通过界面的进度,或者任何可能依赖于用户在网站页面间导航的其他事情,这将很有用。

此外,我们希望在渲染不同页面时进行动画。我们可以将它们与React.addons.CSSTransitionGroup结合使用,我们在第四章中见过,样式和动画组件。当新的组件在App组件内部渲染时,我们将能够以完全相同的方式对它们进行动画。只需在React.addons.CSSTransitionGroup组件中包含div.layout,你应该就设置好了!

摘要

在本章中,你学习了浏览器如何存储 URL 历史记录,以及我们如何操作它以在不进行完整页面刷新的情况下加载不同的部分。它介绍了一些复杂性,但我们还看到了其他替代方案(例如,hashchange事件),这些方案在减少复杂性的同时,仍然减少了我们需要执行的完整页面刷新次数。

你还了解了一个流行的 React 路由,并使用它来抽象我们之前必须手动进行的定位跟踪或更改。

在下一章中,你将学习关于服务器端渲染和应用程序结构的内容。

第七章. 服务器端渲染

在上一章中,你学习了如何在页面不重新加载的情况下渲染我们 CMS 的不同部分。我们甚至创建了一种方法来查看我们网站的公共页面,使用相同的路由技术。

到目前为止,我们都在浏览器中做所有事情。我们在本地存储中存储页面。我们像在互联网上托管一样使用网站和 CMS,但我们是我们唯一能看到它的人。如果我们想与他人分享我们的创作,我们需要某种服务器端技术。

在本章中,我们将简要了解服务器端 JavaScript 和 React 编程的一些方面。我们将看到 React 如何在浏览器之外工作,以及我们如何能够实时持久化和与他人共享数据。

将组件渲染为字符串

React 的一个美妙之处在于它可以在许多地方工作。它的目标是高效渲染界面,但这些界面可以扩展到 DOM 和浏览器之外。

你可以使用 React 来渲染原生移动界面(facebook.github.io/react-native),或者甚至渲染纯 HTML 字符串。这在我们想要在不同地方重用组件代码时非常有用。

例如,我们可以为我们的 CMS 构建一个复杂的数据表组件。我们可以将这个组件发送到 iPad 应用程序,或者甚至从 Web 服务器渲染它,以减少页面加载时间。

我们将在本章尝试后者示例。首先,我们需要安装 React 和 React DOM 库的源版本:

$ npm install --save babel-cli babel-preset-react babel-preset-es2015 react react-dom

我们已经看到了 React 库的例子,但这些新的(来自 BabelJS)将给我们一种在服务器上使用 ES6 和 JSX 的方法。它们甚至提供了通过 Node.js 直接运行代码的替代方案。通常,我们会使用以下命令来运行服务器端 JavaScript 代码:

$ node server.js

但现在,我们可以使用 BabelJS 版本,如下所示:

$ node_modules/.bin/babel-node server.js

我们需要告诉 BabelJS 应用哪些代码预设到我们的代码中。默认情况下,它将应用一些 ES6 转换,但不是全部。它也不会处理 JSX,除非我们加载那个预设。我们通过创建一个名为.babelrc的文件来实现这一点:

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

我们习惯于看到 ES6 的import语句,但可能不习惯于 RequireJS 的require语句。它们在功能上相似,Node.js 使用它们作为从外部脚本导入代码的一种方式。

我们还需要一个名为hello-world.js的文件:

var React = require("react");
var ReactDOMServer = require("react-dom/server");

console.log(
    ReactDOMServer.renderToString(<div>hello world</div>)
);

又有新东西了!我们加载了一个新的 React 库,名为ReactDOMServer,并从div组件中渲染一个字符串。通常我们会在浏览器中使用类似React.render(component, element)这样的方法。但在这里,我们只对组件生成的 HTML 字符串感兴趣。考虑运行以下代码:

$ babel-node examples/server.js

当我们运行前面的命令时,我们会看到类似以下的内容:

<div data-reactid=".yt0g9w8kxs" data-react-checksum="-1395650246">hello world</div>

也许并不完全符合我们的预期,但它看起来像有效的 HTML。我们可以使用它!

创建一个简单的服务器

现在我们能够将组件渲染为 HTML 字符串,那么有一种方法可以响应 HTTP 请求并返回 HTML 响应将更有用。

幸运的是,Node.js 还包含了一个小巧的 HTTP 服务器库。我们可以在server.js文件中使用以下代码来响应 HTTP 请求:

var http = require("http");

var server = http.createServer(
    function (request, response) {
        response.writeHead(200, {
            "Content-Type": "text/html"
        });

        response.end(
            require("./hello-world")
        );
    }
);

server.listen(3000, "127.0.0.1");

要使用 HTTP 服务器库,我们需要引入/导入它。我们创建一个新的服务器,并在回调参数中响应单个 HTTP 请求。

对于每个请求,我们设置内容类型,并使用hello-world.js文件的 HTML 值进行响应。服务器监听端口3000,这意味着你需要打开http://127.0.0.1:3000来看到这个消息。

在我们这样做之前,我们还需要稍微调整hello-world.js

var React = require("react");
var ReactDOMServer = require("react-dom/server");

module.exports = ReactDOMServer.renderToString(
    <div>hello world</div>
);

module.exports = ...语句是 RequireJS 中我们习惯看到的export default ...语句的等效语句。结果是,当这个文件被其他模块引入时,它将返回组件的 HTML 字符串。

如果我们在浏览器中打开 URL(http://127.0.0.1:3000),我们应该看到一个hello world消息,检查它将显示类似 React HTML 的组件:

创建一个简单的服务器

注意

你可以在nodejs.org/api/http.html了解更多关于 Node.js HTTP 服务器的信息。

创建服务器后端

我们 CMS 仍然缺少的是公开的、持久的页面。到目前为止,我们已经在本地存储中存储了它们,在我们构建 CMS 组件时这是可以接受的。但总有一天,我们希望与世界分享我们的数据。

为了实现这一点,我们需要某种存储机制。即使这种存储只是服务器运行期间在内存中。当然,我们可以使用关系型数据库或对象存储来持久化我们的 CMS 页面。现在,让我们保持简单。一个内存存储(pages 变量)现在应该足够了。

那么,我们应该如何构建这个数据存储结构呢?无论我们选择哪种存储介质,接口都需要与服务器交互以存储和检索数据。我想探讨两种主流选项...

通过 Ajax 请求进行通信

Ajax 是一个常用的词。在本章中,我希望你只将其视为一种通过 HTTP 请求从服务器获取数据并将其发送到服务器的手段。

我们刚刚看到了如何响应 HTTP 请求,所以我们已经完成了一半!在这个阶段,我们可以检查请求以确定每个 HTTP 请求的 URL 和方法。浏览器可能会请求类似GET http://127.0.0.1:3000/pages的内容来获取所有页面。所以,如果方法匹配POST且路径匹配/pages,那么我们可以相应地返回适当的页面。

幸运的是,在我们之前已经有其他人走过这条路。例如 ExpressJS 这样的项目已经出现,为我们提供了一些脚手架。让我们来安装 ExpressJS:

$ npm install --save express

现在,我们可以将我们的简单 HTTP 服务器转换为基于 ExpressJS:

var app = require("express")();
var server = require("http").Server(app);

app.get("/", function (request, response) {
    response.send(
        require("./hello-world")
    );
});

server.listen(3000);

注意

记住,每次更改这些 JavaScript 文件后,你都需要重新启动 node server.js 命令。

这应该在浏览器中渲染得完全一样。然而,定义新事物的应用程序端点要容易得多:

app.get("/", function (request, response) {
    response.send(
        require("./hello-world")
    );
});

app.get("/pages", function (request, response) {
    response.send(
        JSON.stringify([ /* ... */ ])
    );
});

注意

JSON.stringify 语句将 JavaScript 变量转换为字符串表示形式,这对于通过网络进行通信非常有用。

我们还可以访问 app.post 这样的方法来处理 POST 请求。为我们的后端数据设计 HTTP 端点非常容易。

然后,在浏览器中,我们需要一种方式来发送这些请求。一个常见的解决方案是使用 jQuery 这样的库。有时这确实是个好主意,但通常只有在你需要比 jQuery 提供的 Ajax 功能更多的时候才这样做。

如果你正在寻找一个轻量级的解决方案,可以尝试 SuperAgent (github.com/visionmedia/superagent) 或甚至新的 Fetch API (developer.mozilla.org/en/docs/Web/API/Fetch_API):

var options = {
    "method": "GET"
};

fetch("http://127.0.0.1/pages", options).then(
    function(response) {
        console.log(response);
    }
);

使用这种方法,我们可以逐渐用对服务器的调用替换后端中的本地存储部分。在那里,我们可以在数组、关系型数据库或对象存储中存储页面数据。

Ajax 是一种经过时间考验的浏览器和服务器之间通信的方法。它是一个得到良好支持的技巧,对于旧浏览器有许多种垫片(从 iframe 到 flash)。

注意

你可以在 expressjs.com 上了解更多关于 ExpressJS 的信息。

通过 WebSocket 进行通信

有时,在浏览器和服务器之间进行快速的双向通信会更好。

在这样的时刻,你可以尝试使用 WebSocket。它们是 Ajax 中传统 HTTP 通信的升级。为了轻松地与之交互,我们需要 Socket.IO 的帮助:

npm install --save socket.io

现在,我们可以访问一个新的对象,我们将称之为 io

// ...enable JSX/ES6 compilation

var app = require("express")();
var server = require("http").Server(app);
var io = require("socket.io")(server);

app.get("/", function (request, response) {
    response.send(
        require("./hello-world")
    );
});

// ...define other endpoints

io.on("connection", function (socket) {
 console.log("connection");

 socket.on("message", function (message) {
 console.log("message: " + message);

 io.emit("message", message);
 });
});

server.listen(3000);

注意

"message" 可以是任何内容。你可以通过将其更改为其他内容来发送不同类型的消息。如果你发送一个包含 "chat message""page command" 的消息,那么你需要为相同类型的消息添加事件监听器。

我们使用一个指向 HTTP 服务器的引用来创建一个新的 io 实例。WebSocket 连接始于一个 HTTP 请求,因此这是一个监听它们的良好位置。

当建立新的 WebSocket 连接时,我们可以开始监听消息。目前,我们只需将消息发送回去。Socket.IO 提供了 WebSocket 客户端脚本,但我们仍然需要连接并发送消息。让我们更新 hello-world.js

var React = require("react");
var ReactDOMServer = require("react-dom/server");

var script = {
 "__html": `
 var socket = io();

 socket.on("message", function (message) {
 console.log(message);
 });

 socket.emit("message", "hello world");
 `
};

module.exports = ReactDOMServer.renderToString(
    <div>
        <script src="img/socket.io.js"></script>
        <script dangerouslySetInnerHTML={script}></script>
    </div>
);

在这段代码中,有两个重要的事项需要注意:

  • 我们可以使用多行字符串作为 ES6 语法的一部分。对于想要跨越多行的字符串,我们可以使用反引号而不是单引号或双引号。

  • 我们可以通过 dangerouslySetInnerHTML 属性设置 innerHTML(这是我们需要做的,以便通过这个 HTTP 响应让 JavaScript 在浏览器中渲染)。

    注意

    你可以在facebook.github.io/react/tips/dangerously-set-inner-html.html了解更多关于dangerouslySetInnerHTML的信息。

在我们的 WebSocket 示例中,数据流类似于以下内容:

  1. HTTP 和 WebSocket 服务器监听http://127.0.0.1:3000

  2. /的请求返回一些浏览器脚本。

  3. 这些脚本开始向服务器发起连接请求。

  4. 服务器接收到这些连接请求,并在连接成功打开后添加新消息的事件监听器。

  5. 浏览器脚本为新消息添加事件监听器,并立即向服务器发送消息。

  6. 服务器的事件监听器被触发,并将消息重新发送到所有打开的套接字。

  7. 浏览器的事件监听器被触发,并将消息写入控制台。

    注意

    在这个例子中,我们将消息(来自服务器)广播到所有打开的套接字。你可以使用类似socket.emit("message", message)的方式将消息限制在特定的套接字连接上。请查看 Socket.IO 文档中的示例。

你应该在控制台中看到hello world消息:

通过 WebSocket 进行通信

注意

你可以在socket.io了解更多关于 Socket.IO 的信息。

结构化服务器端应用程序

当涉及到 HTTP 和 WebSocket 服务器时,通常将端点代码与服务器初始化代码分开是一个好主意。有些人喜欢创建单独的路由文件,这些文件可以被server.js文件引入。还有一些人喜欢将每个端点作为一个单独的文件,并将路由定义为server.js和这些“处理程序”文件之间的粘合剂。

这可能已经足够用于你将要构建的应用程序类型,或者你可能喜欢一个更规定的应用程序结构,例如 AdonisJS (adonisjs.com),例如。

Adonis 是一个为 Node.js 应用程序设计的结构优美的 MVC 框架。它使用许多酷炫的技巧(如生成器)来提供一个干净的 API,用于定义模板、请求处理程序和数据库代码。

一个典型的请求可以按以下方式处理:

class HomeController {
    * indexAction (request, response) {
        response.send("hello world");
    }
}

module.exports = HomeController

你可以在名为app/Http/Controllers/HomeController.js的文件中定义这个类。为了在浏览器访问你的网站主页时渲染此文件,你可以在app/Http/routes.js中定义一个路由:

const Route = use("Route");

Route.get("/", "HomeController.indexAction");

你可以结合一些持久的关系型数据库存储:

const Database = use("Database");

const users = yield Database.table("users").select("*");

总的来说,AdonisJS 为原本开放和可解释的领域提供了很多结构。它让我想起了流行的 PHP 框架 Laravel,而 Laravel 本身又从流行的 Ruby on Rails 框架中汲取了灵感。

注意

你可以在adonisjs.com了解更多关于 AdonisJS 的信息。

摘要

在本章中,你学习了如何在服务器上渲染组件。我们创建了一个简单的 HTTP 服务器,并将其升级以允许多个端点和 WebSocket。最后,我们简要地探讨了如何结构化我们的服务器端代码,并快速了解了 AdonisJS MVC 框架。

在下一章中,你将学习一些流行的 React 设计模式,这些模式可以应用于你的组件和界面。

第八章。React 设计模式

在上一章中,我们探讨了服务器上的 React。我们创建了一个简单的 HTTP 服务器,随后是多个端点和 WebSocket。

在本章中,我们将回顾一下迄今为止构建的组件架构。我们将探讨几个流行的 React 设计模式以及我们如何对我们的架构进行细微的改进。

我们目前的位置

让我们看看迄今为止我们创建的内容以及它们是如何相互作用的。如果你一直密切关注,这些可能对你来说都很熟悉;但请继续关注。

我们将讨论这些交互是如何失败的,以及我们如何改进它们。从我们的界面开始渲染的那一刻起,我们就开始看到以下事情发生:

  1. 我们首先创建一个后端对象。我们使用它作为我们应用程序中页面的存储。它具有addeditdeleteall等方法。它还充当事件发射器,在页面更改时通知监听器。

  2. 我们创建一个PageAdmin React 组件,并将Backend对象传递给它。PageAdmin组件使用Backend对象作为其他页面组件的数据源,所有这些组件都是在PageAdmin渲染方法中创建的。PageAdmin组件在挂载后立即监听Backend的变化。在卸载后停止监听。

  3. PageAdmin组件有几个回调,它将这些回调传递给它创建的其他页面组件。这些提供了子组件触发Backend对象变化的方式。

  4. 通过用户交互,PageEditorPageView等组件触发它们从PageAdmin接收的回调函数。然后这些函数触发Backend对象的变化。

  5. Backend中的数据发生变化。同时,Backend通知事件监听器数据已更改,而PageAdmin就是其中之一。

  6. PageAdmin组件将其内部状态更新为Backend页面的最新版本,这导致其他页面组件重新渲染。

我们可以这样想象:

我们目前的位置

我们甚至可以将现有的代码缩减到这个架构的必要部分。让我们在不使用样式或构建链的情况下重新实现列出和添加页面。我们可以将此作为本章后面进行架构改进的起点。这也会是一个回顾我们迄今为止看到的一些新 ES6 特性以及了解一些新特性的好地方。

注意

我不想在这里重复整个构建链,但我们确实需要一些帮助来在我们的代码中使用 ES6 和 JSX:

$ npm install --save babel-cli babel-preset-react babel-preset-es2015 eventemitter3 react react-dom

我们在.babelrc中启用 ES6/JSX 转换器:

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

我们可以使用以下命令运行此代码:

$ node_modules/.bin/babel-node index.js

这将转换index.js中的 ES6/JSX 代码以及它导入的所有文件。

我们从src/backend.js文件开始:

import Emitter from "eventemitter3";

class Backend extends Emitter {
    constructor() {
        super();

        this.id = 1;
        this.pages = [];
    }

    add() {
        const id = this.id++;
        const title = `New Page ${id}`;

        const page = {
            id,
            title
        };

        this.pages.push(page);
        this.emit("onAdd", page);
    }

    getAll() {
        return this.pages;
    }
}

export default Backend;

Backend 是一个具有内部 idpages 属性的类。id 属性作为每个新页面对象的自动递增身份值。它有 addgetAll 方法,分别用于添加新页面和返回所有页面。

在 ES6 中,我们可以定义常量(在定义和分配后不能更改的变量)。当我们需要只定义一次变量时,它们非常有用,因为它们可以防止意外的更改。

我们分配下一个身份值并增加内部 id 属性,以便下一个身份值将不同。ES6 模板字符串允许我们插入变量(就像我们对身份值所做的那样)并定义多行字符串。

我们可以使用新的 ES6 对象字面量语法定义具有与定义的局部变量名称匹配的键的对象。换句话说,{ title }{ title: title } 的意思相同。

每次添加新页面时,Backend 都会向任何监听器发出 onAdd 事件。我们可以通过以下代码(在 index.js 中)看到所有这些操作:

import Backend from "./src/backend";

let backend = new Backend();

backend.on("onAdd", (page) => {
    console.log("new page: ", page);
});

console.log("all pages: ", backend.getAll());

backend.add();
console.log("all pages: ", backend.getAll());

在 ES6 中,let 关键字与 var 的工作方式类似。区别在于 var 的作用域是包含函数,而 let 的作用域是包含块:

function printPages(pages) {
    for (var i = 0; i < pages.length; i++) {
        console.log(pages[i]);
    }

    // i == pages.length - 1 

    for (let j = 0; j < pages.length; j++) {
        console.log(pages[j]);
    }

    // j == undefined
}

如果你运行这个 Backend 代码,你应该看到以下输出:

all pages:  []
new page:  { id: 1, title: 'New Page 1' }
all pages:  [ { id: 1, title: 'New Page 1' } ]

我们可以将此与 PageAdmin 组件(在 src/page-admin.js 中)结合使用:

import React from "react";

const PageAdmin = (props) => {
    return (
        <div>
            <a href="#"
                onClick={(e) => {
                    e.preventDefault();
                    props.backend.add();
                }}>
                add page
            </a>
            <ol>
                {props.backend.all().map((page) => {
                    return (
                        <li key={page.id}>
                            {page.title}
                        </li>
                    );
                })}
            </ol>
        </div>
    );
};

export default PageAdmin;

这是之前 PageAdmin 组件的无状态函数版本。我们可以使用以下代码(在 index.js 中):

import Backend from "./src/backend";
import PageAdmin from "./src/page-admin";
import React from "react";
import ReactDOMServer from "react-dom/server";

let backend = new Backend();

backend.add();
backend.add();
backend.add();

console.log(
    ReactDOMServer.renderToString(
        <PageAdmin backend={backend} />
    )
);

这将生成以下输出:

<div data-reactid=".51gm9pfn5s" data-react-checksum="865425333">
    <a href="#" data-reactid=".51gm9pfn5s.0">add page</a>
    <ol data-reactid=".51gm9pfn5s.1">
        <li data-reactid=".51gm9pfn5s.1.$1">New Page 1</li>
        <li data-reactid=".51gm9pfn5s.1.$2">New Page 2</li>
        <li data-reactid=".51gm9pfn5s.1.$3">New Page 3</li>
    </ol>
</div>

现在,如果我们将其渲染到 HTML 页面中,我们会点击 添加页面 链接,并在 Backend 内部的现有页面列表中添加一个新页面。我们还创建了一个 PageAdmin 类,以便我们可以在 componentWillMount 生命周期方法中添加事件监听器。然后,这个监听器将更新子 Page 组件的页面数组。

PageAdmin 组件用于渲染 Page 组件,这些组件反过来渲染 PageViewPageEditor 组件以显示和编辑页面。我们通过每一层传递回调函数,以便每个组件都可以在不知道它如何存储或操作数据的情况下触发 Backend 对象中的更改。

Flux

在这个阶段,我们遇到了第一个设计模式(以及我们可以做出的改进)。Flux 是 Facebook 提出的一种模式,它定义了界面中数据的流动。

注意

Flux 不是一个库,但 Facebook 已经发布了一些工具来帮助实现设计模式。你不必使用这些工具来实现 Flux。要安装它,除了之前的依赖项外,运行 npm install --save flux

我们实现了一个非常接近 Flux 的东西,但我们的实现存在一些劣势。我们的 Backend 类做得太多。我们直接调用它来添加和获取页面。当添加新页面时,它会发出事件。它与使用它的组件紧密耦合。

因此,我们很难用一个新的Backend类来替换它(除非方法、事件和返回值都完全相同格式)。我们很难使用多个数据后端。我们甚至没有真正实现单向数据流,因为我们从Backend发送和接收数据。

Flux 在这里有所不同;它为制作更改获取数据定义了单独的对象。我们的Backend类成为前者的分发器和后者的存储。更重要的是,更改应用程序状态的指令采用消息对象的形式(称为操作)。

我们可以这样想象:

Flux

注意

这些代码示例将需要另一个库,您可以使用npm install --save flux来安装。

我们可以通过创建一个新的PageDispatcher对象来实现这个设计变更(在src/page-dispatcher.js中):

import { Dispatcher } from "flux";

const pageDispatcher = new Dispatcher();

export default pageDispatcher;

Dispatcher类并不复杂。它有几个方法,我们很快就会用到。重要的是要注意,我们导出的是Dispatcher类的一个实例,而不是一个子类。我们只需要一个分发器来处理页面操作。因此,我们将其用作一种单例,尽管我们没有专门编写代码使其成为单例。

注意

如果你不太熟悉单例模式,你可以在en.wikipedia.org/wiki/Singleton_pattern上了解它。基本思想是我们为某物(或在这种情况下,使用现有类)创建一个类,但我们只创建和使用该类的一个实例。

这个变化的第二部分是一个名为PageStore的类,我们在src/page-store.js中创建它:

import Emitter from "eventemitter3";
import PageDispatcher from "./page-dispatcher";

class PageStore extends Emitter {
    constructor() {
        super();

        this.id = 1;
        this.pages = [];
    }

    add() {
        // ...add new page
    }

    getAll() {
        return this.pages;
    }
}

const pageStore = new PageStore();

PageDispatcher.register((payload) => {
    if (payload.action === "ADD_PAGE") {
        pageStore.add();
    }

    pageStore.emit("change");
});

export default pageStore;

这个类与Backend类非常相似。一个显著的变化是我们不再在添加新页面后发出onAdd事件。相反,我们在PageDispatcher上注册了一种事件监听器,这就是我们知道要将新页面添加到PageStore的原因。直接调用PageStore.add是可能的,但在这里,我们是在响应发送到PageDispatcher的操作时这样做。这些操作看起来是这样的(在src/index.js中):

import PageAdmin from "./src/page-admin";
import PageDispatcher from "./src/page-dispatcher";
import PageStore from "./src/page-store";

PageStore.on("change", () => {
    console.log("on change: ", PageStore.getAll());
});

console.log("all pages: ", PageStore.getAll());

PageDispatcher.dispatch({
    "action": "ADD_PAGE"
});

console.log("all pages: ", PageStore.getAll());

注意

分发器会在所有注册的存储中触发事件监听器。如果你通过分发器发出一个操作,无论有效负载如何,所有存储都将被通知。

现在,存储不仅管理对象集合(如我们的页面)。它们不是一个应用程序数据库。它们旨在存储所有应用程序状态。也许我们应该更改一些方法来使其更清晰,从src/page-store.js开始:

class PageStore extends Emitter {
    constructor() {
        super();

        this.id = 1;
        this.pages = [];
    }

    handle(payload) {
        if (payload.action == "ADD_PAGE") {
            // ...add new page
        }
    }
    getState() {
        return {
            "pages": this.pages
        };
    }
}

const pageStore = new PageStore();

PageDispatcher.register((payload) => {
    if (payload.action === "ADD_PAGE") {
        pageStore.handle(payload);
    }

    pageStore.emit("change");
});

我们仍然称这个存储为PageStore,但它可以存储除了页面数组之外的其他多种状态。例如,它可以存储过滤和排序状态。对于每个新的操作,我们只需要在handle方法中添加一些代码。

我们还需要调整index.js中的调用代码:

PageStore.on("change", () => {
    console.log("change: ", PageStore.getState());
});

console.log("all state: ", PageStore.getState());

PageDispatcher.dispatch({
    "action": "ADD_PAGE"
});

console.log("all state: ", PageStore.getState());

当我们运行这个程序时,我们应该看到以下输出:

all state:  { pages: [] }
change:  { pages: [ { id: 1, title: 'New Page 1' } ] }
all state:  { pages: [ { id: 1, title: 'New Page 1' } ] }

现在,我们需要在src/page-admin.js中实现这些变更:

import React from "react";
import PageDispatcher from "./page-dispatcher";
import PageStore from "./page-store";
class PageAdmin extends React.Component {
    constructor() {
        super();
        this.state = PageStore.getState();
       this.onChange = this.onChange.bind(this);
    }
    componentDidMount() {
        PageStore.on("change", this.onChange);
    }
    componentWillUnmount() {
        PageStore.removeListener("change", this.onChange);
    }
    onChange() {
        this.setState(PageStore.getState());
    }
    render() {
        return (
            <div>
                <a href="#"
                    onClick={(e) => {
                        e.preventDefault();

                        PageDispatcher.dispatch({
                            "action": "ADD_PAGE"
                        });
                    }}>
                    add page
                </a>
                <ol>
                    {this.state.pages.map((page) => {
                        return (
                            <li key={page.id}>
                                {page.title}
                            </li>
                        );
                    })}
                </ol>
            </div>
        );
    }
};

export default PageAdmin;

最后,我们可以更新index.js以反映这些新的变化:

import PageAdmin from "./src/page-admin";
import PageDispatcher from "./src/page-dispatcher";
import PageStore from "./src/page-store";
import React from "react";
import ReactDOMServer from "react-dom/server";

PageDispatcher.dispatch({
    "action": "ADD_PAGE"
});

// ...dispatch the same thing a few more times

console.log(
    ReactDOMServer.renderToString(
        <PageAdmin />
    )
);

如果我们运行这段代码,我们会看到与之前实现 Flux 之前相同的输出。

使用 Flux 的好处

在某种意义上,我们仍然紧密耦合了渲染界面元素的代码和存储及操作状态的代码。我们只是在它们之间建立了一点点障碍。那么,我们从这种方法中获得了什么?

首先,Flux 是 React 应用程序中流行的设计模式。我们可以谈论动作、调度器和存储,并且可以确信其他 React 开发者会确切地知道我们的意思。这降低了将新开发者引入 React 项目的学习曲线。

我们还分离了状态存储和用户及系统动作。我们有一个单一的、通用的对象,我们可以通过它发送动作。这些动作可能会导致多个存储的变化,进而触发我们界面多个部分的变化。在我们的简单示例中,我们不需要多个存储,但复杂界面可以从多个存储中受益。在这些情况下,一个调度器和多个存储可以很好地协同工作。

注意

值得注意的是,虽然我们命名了 Flux 调度器,以便我们可以有多个调度器,但应用程序通常只有一个。数据后端和调度器作为单例也很常见。我选择根据我们开始和结束应用程序的方式偏离这一点。

Redux

Flux 引导我们将Backend类分离成调度器和存储,作为从单个状态存储和实现中解耦的手段。这导致了很多样板代码,我们仍然有一些耦合(到全局调度器和存储对象)。有一些术语可以工作当然很好,但这并不感觉是最好的解决方案。

如果我们能够解耦动作和存储并移除全局对象会怎样?这正是 Redux 试图做到的,同时减少样板代码并带来更好的整体标准。

注意

您可以通过运行npm install --save redux react-redux来下载 Redux 工具,除了之前的依赖项。Redux 也是一个模式,但这些库中的工具将极大地帮助设置这些事情。

Redux 一开始可能难以理解,但有一些简单的底层事物将它们联系在一起。首先,所有状态都存储在不可变对象中的想法。这种状态应该只通过纯函数进行转换,这些纯函数接受当前状态并产生新的状态。这些纯函数有时也被称为幂等的,这意味着它们可以多次运行(使用相同的输入)并每次都产生完全相同的结果。让我们通过index.js中的代码来探讨这个想法:

const transform = (state, action) => {
    let id = 1;
    let pages = state.pages;

    if (action.type == "ADD_PAGE") {
        pages = [
            ...state.pages,
            {
                "title": "New Page " + id,
                "id": id++
            }
        ];
    }

    return {
        pages
    };
};

console.log(
    transform({ "pages": [] }, { "type": "ADD_PAGE" })
);

在这里,我们有一个函数,它接受一个初始状态值,并在存在与为 Flux 创建的相同类型的动作的情况下修改它。这是一个没有副作用(side-effects)的纯函数。返回一个新的状态对象,我们甚至使用 ES6 扩展运算符(spread operator)作为将页面连接到一个新数组的方式。这实际上与以下操作相同:

pages = pages.concat({
    "title": "New Page " + id,
    "id": id++
});

当我们在数组前缀上使用 ... 时,其值会像我们逐行写出它们一样展开。这个转换函数被称为 还原器,这个名字来源于 MapReduce 的 reduce 部分 (en.wikipedia.org/wiki/MapReduce)。也就是说,Redux 将还原器定义为通过一个或多个还原器将初始状态减少到新状态的一种方式。

我们将这个还原器(reducer)给一个类似于为 Flux 创建的存储:

import { createStore } from "redux";

const transform = (state = { "pages": [] }, action) => {
    // ...create a new state object, with a new page
};

const store = createStore(transform);

store.dispatch({ "type": "ADD_PAGE" });

console.log(
    store.getState()
);

存储也充当调度器,所以这更接近我们的原始代码。我们在存储上注册监听器,以便我们可以通知状态的变化。我们可以使用类似于为 Flux 创建的 PageAdmin 组件(在 src/page-admin.js 中):

import React from "react";

class PageAdmin extends React.Component {
    constructor(props) {
        super(props);
        this.state = this.props.store.getState();
 this.onChange = this.onChange.bind(this);
    }
    componentDidMount() {
        this.removeListener = 
            this.props.store.register(this.onChange);
    }
    componentWillUnmount() {
        this.removeListener();
    }
    onChange() {
        this.setState(this.props.store.getState());
    }
    render() {
        return (
            <div>
                <a href="#"
                    onClick={(e) => {
                        e.preventDefault();

                        this.props.store.dispatch({
                            "type": "ADD_PAGE"
                        });
                    }}>
                    add page
                </a>
                <ol>
                    {this.state.pages.map((page) => {
                        // ...render each page
                    })}
                </ol>
            </div>
        );
    }
};

export default PageAdmin;

此外,我们只需对 index.js 进行一些小的修改就可以渲染所有这些内容:

import { createStore } from "redux";
import PageAdmin from "./src/page-admin";
import React from "react";
import ReactDOMServer from "react-dom/server";

const transform = (state = { "pages": [] }, action) => {
    let id = 1;
    let pages = state.pages;

    if (action.type == "ADD_PAGE") {
        pages = [
            ...state.pages,
            {
                "title": "New Page " + id,
                "id": id++
            }
        ];
    }

    return {
        pages
    };
};

const store = createStore(transform);

store.dispatch({ "type": "ADD_PAGE" });

console.log(
    ReactDOMServer.renderToString(
        <PageAdmin store={store} />
    )
);

我们可以想象一个 Redux 应用程序是这样的:

Redux

因此,我们已经移除了全局依赖。我们几乎回到了起点——从我们的原始代码到 Flux,再到 Redux。

使用上下文

随着你构建越来越复杂的组件,你可能会发现所有这些做法的一个令人沮丧的副作用。在 Redux 中,存储(store)充当调度器(dispatcher)的角色。因此,如果你想从组件层次结构深处的组件中分发(dispatch)动作,你需要将存储通过多个可能甚至不需要它的组件传递。

暂时考虑构建我们的 CMS 接口组件,以便直接将动作分发到存储。我们可能会得到一个类似以下的层次结构:

React.render(
    <PageAdmin store={store}>
        {store.getState().pages.map((page) => {
            <Page key={page.id} store={store}>
                <PageView {...page} store={store} />
                <PageEditor {...page} store={store} />
            </Page>
        })}
    </PageAdmin>
    document.querySelector(".react")
);

注意

那些嵌套组件也可以是 render 方法的一部分。

将这些存储逐级传递到界面中的每个组件级别变得令人厌烦。幸运的是,有一个解决这个问题的方法。它被称为 上下文,它的工作方式如下。首先,我们创建一个新的组件,并修改 index.js 中的渲染方式:

class Provider extends React.Component {
    getChildContext() {
        return {
            "store": this.props.store
        };
    }
    render() {
        return this.props.children;
    }
}

Provider.childContextTypes = {
    "store": React.PropTypes.object
};

console.log(
    ReactDOMServer.renderToString(
        <Provider store={store}>
            <PageAdmin />
        </Provider>
    )
);

新组件被称为 Provider,它渲染所有嵌套组件而不做任何修改。然而,它确实定义了一个新的生命周期方法,称为 getChildContext。这个方法返回一个对象,其中包含我们希望嵌套组件获得的属性值。这些值类似于 props;然而,它们是隐式提供给嵌套组件的。

除了 getChildContext 之外,我们还需要定义 Provider.childContextTypes。这些 React.PropTypes 应该与我们从 getChildContext 返回的内容相匹配。同样,我们需要修改 PageAdmin

class PageAdmin extends React.Component {
    constructor(props, context) {
        super(props, context);
        this.state = context.store.getState();
        this.onChange = this.onChange.bind(this);
    }
    componentDidMount() {
        this.removeListener =
            this.context.store.register(this.onChange);
    }
    componentWillUnmount() {
        this.removeListener();
    }
    onChange() {
        this.setState(this.context.store.getState());
    }
    render() {
        return (
            <div>
                <a href="#"
                    onClick={(e) => {
                        e.preventDefault();

                        this.context.store.dispatch({
                            "type": "ADD_PAGE"
                        });
                    }}>
                    add page
                </a>
                <ol>
                    {this.state.pages.map((page) => {
                        // ...render each page
                    })}
                </ol>
            </div>
        );
    }
};

PageAdmin.contextTypes = {
    "store": React.PropTypes.object
};

当我们定义 PageAdmin.contextTypes 时,我们允许层次结构中更高层的组件向 PageAdmin 提供它们的上下文。在这种情况下,上下文将包含对存储的引用。为此,我们将 props.store 改为 context.store

这在 Redux 架构中是一个常见的现象。它如此普遍,以至于这样的 Provider 组件是 Redux 工具的标准组成部分。我们可以用从 ReactRedux 导入的 Provider 实现来替换我们的 Provider 实现:

import { Provider } from "react-redux";

console.log(
    ReactDOMServer.renderToString(
        <Provider store={store}>
            <PageAdmin />
        </Provider>
    )
);

我们甚至不需要定义 Provider.childContextTypes。然而,我们仍然需要定义 PageAdmin.contextTypes 以选择加入提供的环境。

Redux 的优势

Redux 正在变得越来越受欢迎,这并不令人惊讶。它拥有 Flux 的所有优势(例如真正的单向数据流和减少对单一后端实现的耦合)而没有所有样板代码。关于 Redux 还有更多东西要学习,但我们所涵盖的内容将为你开始构建更好的应用程序打下坚实的基础!

注意

你可以在 egghead.io/series/getting-started-with-redux 了解更多关于 Redux 的信息。这是一套由 Redux 创作者制作的精彩视频课程。

摘要

在本章中,你学习了我们可以用来构建更好的 React 应用程序的现代架构设计模式。我们从 Flux 模式开始,然后转向 Redux。

在下一章中,我们将探讨如何创建基于插件的组件,以便我们的界面可以被他人扩展。

第九章. 考虑插件

在上一章中,我们探讨了我们可以用来构建应用程序的一些设计模式。使用 Flux 和 Redux 有理由和反对的理由,但它们通常可以提高 React 应用程序的结构。

良好的结构对于任何大型应用程序都是必不可少的。对于小型实验,拼凑东西可能可行,但设计模式是维护任何更大规模事物的组成部分。尽管如此,它们并没有太多关于创建可扩展组件的内容。在本章中,我们将探讨一些我们可以用来使我们的组件通过替换、注入功能以及从组件动态列表中组合接口来扩展组件的方法。

我们将回顾一些相关的软件设计概念,并看看它们如何帮助我们(以及其他人)在想要用修改后的组件和替代实现替换应用程序的部分时提供帮助。

依赖注入和服务定位

依赖注入服务定位是两个有趣的概念,它们并不仅限于 React 开发。要真正理解它们,让我们暂时远离组件。想象一下,如果我们想创建一个网站地图。为此,我们可能可以使用类似于以下代码:

let backend = {
    getAll() {
        // ...return pages
    }
};
class SitemapFormatter {
    format(items) {
        // ...generate xml from items
    }
}
function createSitemap() {
    const pages = backend.getAll();
    const formatter = new SitemapFormatter();

    return formatter.format(
        pages.filter(page => page.isPublic)
    );
}

let sitemap = createSitemap();

在这个例子中,createSitemap有两个依赖。首先,我们从backend获取页面。这是一个全球存储对象。当我们查看 Flux 架构时,我们使用过类似的东西。

第二个依赖是SitemapFormatter实现。我们使用它来获取页面列表并返回某种形式的标记,总结列表中的这些页面。我们以某种方式硬编码了这两个依赖,这在小剂量下是可以接受的,但随着应用程序的扩展,它们会变得有问题。

例如,如果我们想使用这个网站地图生成器与多个后端?如果我们还想尝试格式化器的替代实现?目前,我们已经将网站地图生成器耦合到单个后端和单个格式化器实现。

依赖注入和服务定位是解决这个问题的两种可能方案。

依赖注入

我们已经以微妙的方式使用了依赖注入。这看起来如下所示:

function createSitemap(backend, formatter) {
    const pages = backend.getAll();

    return formatter.format(
        pages.filter(page => page.isPublic)
    );
}

let formatter = new SitemapFormatter();
let sitemap = createSitemap(backend, formatter);

依赖注入的全部内容是将依赖项从使用它们的函数和类中移出。这并不是要避免它们的使用;而是创建外部的新实例,并通过函数参数传递它们。

有两种依赖注入:构造函数注入和设置器注入。这可以说明它们之间的区别:

class SitemapGenerator {
    constructor(formatter) {
        this._formatter = formatter;
    }

    set formatter(formatter) {
        this._formatter = formatter;
    }
}

let generator = new SitemapGenerator(new SitemapFormatter());

generator.formatter = new AlternativeSitemapFormatter();

我们可以通过构造函数注入依赖项并将它们分配给属性,或者为它们创建设置器。我们已经多次看到了构造函数注入,但设置器注入是另一种有效的注入依赖项的方式。我们也可以使用普通函数来注入依赖项,但那样我们就无法通过对象属性来设置或获取它们。

类似地,当我们定义组件属性时,我们实际上是将这些属性值作为构造函数依赖项注入。

工厂和服务定位器

另一个解决方案是将创建新实例的逻辑封装起来,并在查找依赖项时使用对这种类似工厂对象的引用:

class Factory {
    createNewFormatter() {
        // ...create new formatter instance
    }

    getSharedBackend() {
        // ...get shared backend instance
    }
}

const factory = new Factory();

const formatter = factory.createNewFormatter();
const backend = factory.getSharedBackend();

然后,我们可以传递 Factory 类的实例,甚至将其作为依赖项注入。在其他动态语言中,如 PHP,这已成为一种常见做法。然后我们可以使用这些工厂来创建基于某些初始标准的新实例。我们可以有一个工厂来创建新的数据库连接,并连接到 MySQL 或 SQLite,这取决于我们指定的连接类型。

另一个选择是创建多个对象并将它们存储在一个公共服务定位器对象中:

let locator = new ServiceLocator();
locator.set("formatter", new Formatter());
locator.set("backend", new Backend());

同样,我们可以将定位器作为依赖项注入,并根据需要获取实际的依赖项:

class SitemapGenerator {
    constructor(locator) {
        this.formatter = locator.get("formatter");
        this.backend = locator.get("backend");
    }
}

let generator = new SitemapGenerator(locator);

Fold

幸运的是,我们不需要构建和维护工厂、依赖注入器和服务定位器。JavaScript 中已经有了很多可供选择,我们将特别讨论其中一个。记得我们讨论在服务器上渲染 React 组件的时候吗?我们查看了一个名为 AdonisJS 的 MVC 应用程序框架。

AdonisJS 的创建者也维护了一个依赖注入容器,称为 Fold。Fold 做的一些事情很有趣,我们希望与您分享。

我们可以使用以下命令安装 Fold:

$ npm install --save adonis-fold

注意

在前面的章节中,我们创建了一个工作流程来运行 ES6 代码通过 Node.js。我们建议您为本章的一些代码重新创建此设置。

然后,我们可以开始使用它来注册和解析对象:

import { Ioc } from "adonis-fold";

Ioc.bind("App/Authenticator", function() {
    // ...return a new authenticator object
});

let authenticator = Ioc.use("App/Authenticator");

注意

Fold 引入了一个全局的 use 函数,因此我们可以在不需要每次都导入 Ioc 的情况下使用它。

我们使用 bind 为类似工厂的函数分配别名。当这个别名被 使用 时,这个工厂函数将被调用,并返回结果。当我们拥有大型依赖图时,这变得更加强大:

Ioc.bind("App/Authenticator", function() {
    const repository = Ioc.use("App/UserRepository");
    const crypto = Ioc.use("App/Crypto");

    return new Authenticator(repository, crypto);
});

我们可以组合对 binduse 的调用。在这个例子中,创建一个新的 App/Authenticator 将会从容器中解析出 App/UserRepositoryApp/Crypto

更重要的是,我们可以使用 Fold 自动加载类文件。所以,假设我们有一个类似于以下内容的 Authenticator 类文件(在 src/Authenticator.js 中):

class Authenticator {
    // ...do some authentication!
}

module.exports = Authenticator;

注意

通常我们使用 export default ... 来导出类或函数;但在这个情况下,我们只是将类分配给 module.exports。这使得 Fold 能够对我们的代码做更多有趣的事情。

我们可以用以下代码来自动加载:

Ioc.autoload("App", __dirname + "/src");

const Authenticator = Ioc.use("App/Authenticator");

let authenticator = new Authenticator();

我们也可以使用常规类作为单例,只需稍微不同的绑定语法:

Ioc.singleton("App/Backend", function() {
    // ...this will only be run once!
 return new Backend();
});

注意

use无论您使用bind还是singleton,都按相同的方式工作。

当涉及到递归解析依赖关系时,折叠功能特别出色。让我们改变Authenticator

class Authenticator {
    static get inject() {
        return ["App/Repository", "App/Crypto"];
    }

    constructor(repository, crypto) {
        this.repository = repository;
        this.crypto = crypto;
    }
}

module.exports = Authenticator;

我们可以使用 getter(ES6 的一个特性)来重载静态inject属性的属性访问。这仅仅意味着每当我们在Authenticator.inject中写入时,都会运行一个函数。Fold 使用这个静态数组属性来确定要解析哪些依赖项。因此,我们可以创建Repository(在src/Repository.js中),如下所示:

class Repository {
    // ...probably fetches users from a data source
}

module.exports = Repository;

我们还可以创建Crypto(在src/Crypto.js中),如下所示:

class Crypto {
    // ...probably performs cryptographic comparisons
}

module.exports = Crypto;

这些类的作用并不重要。重要的是 Fold 如何将它们连接起来:

Ioc.autoload("App", __dirname + "/src");

let authenticator = Ioc.make("App/Authenticator");

第一行在App/类前缀和我们要加载的类文件之间创建了一个链接。因此,当创建App/Foo/Bar时,src/Foo/Bar.js中的类将被加载。同样,当使用Ioc.make时,在静态注入数组属性中定义的别名将与它们的相应构造函数参数连接起来。

为什么这很重要

如果我们注入依赖项,我们可以轻松地用另一个部分替换应用程序的一部分,因为依赖项没有在依赖它们的类中命名。这些类不负责创建新实例,只接收外部创建的实例。

如果我们使用服务定位器(特别是递归解析依赖项的那种),我们可以在引导阶段避免很多样板代码。

我们能够替换应用程序的部分,我们能从中得到什么?

我们允许其他开发者通过替换应用程序的核心部分来将行为注入我们的应用程序。想象一下我们有以下Authenticator方法:

class Authenticator {
    static get inject() {
        return ["App/Repository", "App/Crypto"];
    }

    constructor(repository, crypto) {
        this.repository = repository;
        this.crypto = crypto;
    }

    authenticate(email, password) {
        // ...authenticate the user details
    }
}

现在想象一下,我们想要为所有认证添加日志记录。我们可以直接更改Authenticator类。如果我们拥有代码,这很容易,但我们经常使用第三方库。我们可以在src/AuthenticatorLogger.js中创建一个装饰器:

class AuthenticatorLogger {
    static get inject() {
        return ["App/Authenticator"];
    }

    constructor(authenticator) {
        this.authenticator = authenticator;
    }

    authenticate(email, password) {
        this.log("authentication attempted");
        return this.authenticator.authenticate(email, password);
    }

    log(message) {
        // ...store the log message
    }
}

module.exports = AuthenticatorLogger;

注意

装饰器是增强其他类功能的类,通常是通过组合它们增强的类的实例。您可以在en.wikipedia.org/wiki/Decorator_pattern了解更多关于这种模式的信息。

这个新类期望一个Authenticator依赖项,并为authenticate方法添加了透明的日志记录。我们可以通过重新绑定App/Authenticator来覆盖默认(自动加载)行为:

const Authenticator = Ioc.use("App/Authenticator");
const AuthenticatorLogger = Ioc.use("App/AuthenticatorLogger");

Ioc.bind("App/Authenticator", function() {
    return new AuthenticatorLogger(Ioc.make(Authenticator));
});

let authenticator = Ioc.make("App/Authenticator");

让我们从这个组件的角度来思考这个问题。想象一下,我们有一个页面列表,这些页面是由PagesComponent组件(在src/Page.js中)展示给我们的:

import React from "react";

class PagesComponent extends React.Component {
    constructor(props, context) {
        super(props, context);
        // ...get context.store state
    }
    componentDidMount() {
        // ...add context.store change listener
    }
    componentWillUnmount() {
        // ...remove context.store change listener
    }
    render() {
        // ...return a list of pages
        return <div>pages</div>;
    }
}

module.exports = PagesComponent;

我们可以使用折叠来自动加载,如下所示:

import React from "react";
import ReactDOMServer from "react-dom/server";

const PagesComponent = Ioc.use("App/PagesComponent");

let rendered = ReactDOMServer.renderToString(
    <PagesComponent />
);

现在,想象一下,另一位开发者出现了,并想围绕页面列表添加一些额外的装饰。他们可以深入到 node_modules 文件夹并直接编辑组件,但这会很混乱。相反(并且因为我们使用依赖注入容器),他们可以覆盖到 App/PagesComponent 的别名:

const PagesComponent = Ioc.use("App/PagesComponent");

Ioc.bind("App/PagesComponent{}", function() {
    return (
        <PagesComponent />
    );
});

// ...then, when we want to decorate the component

class PagesComponentChrome extends React.Component {
    render() {
        return (
            <div className="chrome">
                {this.props.children}
            </div>
        )
    }
}

Ioc.bind("App/PagesComponent{}", function() {
    return (
        <PagesComponentChrome>
            <PagesComponent />
        </PagesComponentChrome>
    );
});

// ...some time later

let rendered = ReactDOMServer.renderToString(
    Ioc.use("App/PagesComponent{}")
);

注意

当涉及到 React.Component 子类与这些子类的实例时,事情会变得有点棘手。ReactDOM.renderReactDOMServer.renderToString 期望实例是在我们使用 <SomeComponent/> 在 JSX 中创建时。这可能有助于在容器中注册这两种形式:类引用的绑定和创建这些类实例的工厂函数的绑定。我们给后者添加了 {} 后缀,我们可以在 render 方法中直接使用它。

通过进行以下小的更改,可能更容易理解最后一部分:

// ...some time later

const NewPagesComponent = Ioc.use("App/PagesComponent{}");

let rendered = ReactDOMServer.renderToString(
    <div>{NewPagesComponent}</div>
);

以这种方式,我们允许其他开发者用自定义类或组件装饰器替换应用程序的部分。在创建团队标准方面肯定有一些工作要做,但基本想法是稳固的。

注意

你可以在 adonisjs.com/docs/2.0/ioc-container 上了解更多关于 Fold 的信息。

通过回调扩展

创建更多可插拔组件的另一种方法是公开(并操作)事件回调。我们已经看到了类似的东西,但让我们再看一看。假设我们有一个 PageEditorComponent 类,如下所示:

import React from "react";

class PageEditorComponent extends React.Component {
    onSave(e, refs) {
        this.props.onSave(e, refs);
    }
    onCancel(e, refs) {
        this.props.onCancel(e, refs);
    }
    render() {
        let refs = {};

        return (
            <div>
                <input type="text"
                    ref={ref => refs.title = ref} />
                <input type="text"
                    ref={ref => refs.body = ref} />
                <button onClick={e => this.onSave(e, refs)}>
                    save
                </button>
                <button onClick={e => this.onCancel(e, refs)}>
                    cancel
                </button>
            </div>
        );
    }
}

PageEditorComponent.propTypes = {
    "onSave": React.PropTypes.func.isRequired,
    "onCancel": React.PropTypes.func.isRequired
};

module.exports = PageEditorComponent;

注意

这是一段代码,最好通过我们之前创建的工作流程之一(允许在浏览器中渲染组件)或 jsbin.com 来运行。我们感兴趣的是查看一些动态行为,因此我们能够点击东西非常重要!

如我们之前所见,我们可以通过属性从某些更高组件传递 onSaveonCancel 回调。每个 React 组件都可以有一个 ref 回调。将 DOM 节点的引用传递给此回调,因此我们可以使用 focus 等方法以及 value 等属性。这对于与公共后端或存储同步状态非常有用。然而,我们该如何添加一些自定义验证呢?

我们可以添加可选的回调属性(和 propTypes),并将这些属性纳入我们的 onSaveonCancel 方法中:

class PageEditorComponent extends React.Component {
    onSave(e, refs) {
        if (this.props.onBeforeSave) {
            if (!this.props.onBeforeSave(e, refs)) {
                return;
            }
        }

        this.props.onSave(e, refs);

        if (this.props.onAfterSave) {
            this.props.onAfterSave(e, refs);
        }
    }
    onCancel(e, refs) {
        this.props.onCancel(e, refs);
    }
    render() {
        let refs = {};

        return (
            <div>
                <input type="text"
                    ref={ref => refs.title = ref} />
                <input type="text"
                    ref={ref => refs.body = ref} />
                <button onClick={e => this.onSave(e, refs)}>
                    save
                </button>
                <button onClick={e => this.onCancel(e, refs)}>
                    cancel
                </button>
            </div>
        );
    }
}

PageEditorComponent.propTypes = {
    "onSave": React.PropTypes.func.isRequired,
    "onCancel": React.PropTypes.func.isRequired,
    "onBeforeSave": React.PropTypes.func,
    "onAfterSave": React.PropTypes.func,
};

我们可以在组件行为的要点处定义额外的步骤:

const onSave = (e, refs) => {
    // ...save the data
    console.log("saved");
};

const onCancel = (e, refs) => {
    // ...cancel the edit
    console.log("cancelled");
};

const onBeforeSave = (e, refs) => {
    if (refs.title.value == "a bad title") {
        console.log("validation failed");
        return false;
    }

    return true;
};

ReactDOM.render(
    <PageEditorComponent
        onBeforeSave={onBeforeSave}
        onSave={onSave}
        onCancel={onCancel} />,
    document.querySelector(".react")
);

onSave 方法检查是否定义了可选的 onBeforeSave 属性。如果是这样,则运行此回调。如果回调返回 false,我们可以将其用作防止默认组件保存行为的一种方式。我们仍然需要默认的保存或取消行为正常工作,因此这些属性是必需的。其他属性是可选的但很有用。

存储、reducer 和组件

在这些概念的基础上,我们想要你查看的最后一件事是所有这些如何在 Redux 架构内部结合在一起。

注意

如果你跳到了这一章,请确保你已经通过阅读上一章对 Redux 有了牢固的理解。

让我们从PageComponent类开始(用于列表中的单个页面):

import React from "react";

class PageComponent extends React.Component {
    constructor(props) {
        super(props);
        this.state = props.store.getState();
    }
    componentDidMount() {
        this.remove = this.props.store.register(
            this.onChange
        );
    }
    componentWillUnmount() {
        this.remove();
    }
    onChange() {
        this.setState(this.props.store.getState());
    }
    render() {
        const DummyPageViewComponent = use(
            "App/DummyPageViewComponent"
        );

        const DummyPageEditorComponent = use(
            "App/DummyPageEditorComponent"
        );

        const DummyPageActionsComponent = use(
            "App/DummyPageActionsComponent"
        );

        return (
            <div>
                <DummyPageViewComponent />
                <DummyPageEditorComponent />
                <DummyPageActionsComponent />
            </div>
        );
    }
}

module.exports = PageComponent;

注意

Dummy*Component类可以是任何东西。我们在与本章相关的源代码中创建了一些“空”组件。主要的事情是PageComponent组合了几个其他组件。

这里没有太多花哨的东西:我们组合了一些组件,并将其连接到常规的 Redux 功能。这由一些新的服务位置功能补充:

import { combineReducers, createStore } from "redux";

Ioc.bind("App/Reducers", function() {
    return [
        (state = {}, action) => {
            let pages = state.pages || [];

            if (action.type == "UPDATE_TITLE") {
                pages = pages.map(page => {
                    if (page.id = payload.id) {
                        page.title = payload.title;
                    }

                    return page;
                });
            }

            return {
                pages
            };
        }
    ];
});

Ioc.bind("App/Store", function() {
    const reducers = combineReducers(
        Ioc.use("App/Reducers")
    );

    return createStore(reducers);
});

const Store = Ioc.use("App/Store");
const PageComponent = Ioc.use("App/PageComponent");

let rendered = ReactDOMServer.renderToString(
    <PageComponent store={Store} />
)

我们正在使用一个新的combineReducers方法,它接受一个 reducer 数组并生成一个新的超级 reducer。让我们使子组件的顺序和包含变得可配置:

render() {
    const components = [
        use("App/DummyPageViewComponent"),
        use("App/DummyPageEditorComponent"),
        use("App/DummyPageActionsComponent"),
    ];

    return (
        <div>
            {components.map((Component, i) => {
                return <Component key={i} />;
            })}
        </div>
    );
}

这里正在发生两个有趣的事情:

  • 在 ES6 中,Array.prototype.map方法将第二个参数传递给回调。这是正在映射的数组的当前迭代的数字索引。我们可以将其用作创建子组件列表时的key参数。

  • 我们可以使用动态组件名称。请注意Component的字母大小写。如果组件名称变量以小写字母开头,React 将假设它是一个字面量值。

现在我们正在构建一个动态的组件列表,我们可以将默认列表从这个组件中移除:

Ioc.bind("App/PageComponentChildren", function() {
    return [
        use("App/DummyPageViewComponent"),
        use("App/DummyPageEditorComponent"),
        use("App/DummyPageActionsComponent"),
    ];
});

然后,我们可以在PageComponent.render中替换这个列表:

render() {
    const components = use("App/PageComponentChildren");

    return (
        <div>
            {components.map((Component, i) => {
                return <Component key={i} />;
            })}
        </div>
    );
}

建立在之前关于 Fold 学习的知识之上,当我们想要添加插件时,我们可以覆盖这个列表!我们可以包含一个插件,将页面的快照通过电子邮件发送给某人:

const PageComponentChildren = use("App/PageComponentChildren");

Ioc.bind("App/PageComponentChildren", function() {
    return [
        ...PageComponentChildren,
        use("App/DummyPageEmailPluginComponent"),
    ];
});

let extended = ReactDOMServer.renderToString(
    <PageComponent store={Store} />
);

这个新的插件配置可能远离PageComponent的核心定义,我们不需要更改任何核心代码来使它工作。我们可以以完全相同的方式添加新的 reducer(从而改变我们的存储或分发器行为):

const Reducers = use("App/Reducers");

Ioc.bind("App/Reducers", function() {
    return [
        ...Reducers,
        (state, action) => {
            if (action.type == "EMAIL_PAGE") {
                // ...email the page
            }

            return state;
        }
    ]
});

以这种方式,其他开发者可以创建全新的组件和 reducer,并且可以轻松地将它们应用到我们已构建的系统上。

摘要

在本章中,我们探讨了我们可以使用的一些方法,使我们的组件(和通用架构)易于扩展,而无需修改核心代码。这里有很多东西需要吸收,而且社区标准化远远不够,这不能成为插件组件的最终结论。希望这里的内容足以让你为你的应用程序设计正确的插件架构。

在下一章中,我们将探讨测试我们迄今为止构建的组件和类的方法。我们将继续看到诸如依赖注入和服务位置等事物的优势,同时也会了解一些新工具。

第十章。测试组件

在最后一章,我们探讨了使我们的组件对插件开发者友好的方法。我们看到了依赖注入的一些好处以及 AdonisJS Fold 如何帮助我们以最小的努力实现它。

在本章中,我们将学习关于测试——自动化测试、有效测试、在编写代码之前进行测试。我们将学习测试的好处和不同类型的测试。

吃你的蔬菜

你真的不喜欢吃某样东西吗?可能是一种蔬菜或水果。当我还是个孩子的时候,有很多我不喜欢吃的食物。我甚至记不起它们是什么味道的,它们也没有伤害到我。我只是下定决心认为它们不好,我不喜欢它们。

一些开发者有类似的习惯。作为开发者,你不喜欢做什么?不是因为它们困难或不好,只是因为...

对我来说,测试就是这样一件事。我在开始编程多年后才了解到测试,而且这仍然是我需要积极努力的事情。我知道为什么它好,为什么反对测试的常见论点是错误的。尽管如此,我仍需要说服自己不断对我的工作进行良好的测试。

我必须学会,仅仅点击界面是不够的,测试除非可以自动运行,否则不是真正的测试,测试除非是持续进行的,否则不是真正有用的,而且测试通常在实施之前作为设计阶段的一部分非常有用。

这里有一些我认为的理由。也许你在学习测试或试图说服人们为什么他们应该为测试制定计划和预算时,会发现它们有用。

注意

我无法强调测试的重要性。我们在这里探讨的概念只是冰山一角。如果你真的想了解测试和编写可测试的代码,我强烈推荐你阅读*《代码整洁之道》*(罗伯特·C·马丁著)。

通过测试进行设计

测试可以是代码的强大设计工具,就像线框可以是交互式界面的设计工具一样。有时,快速制作你认为可以为你工作的代码原型是好的。但一旦你知道你希望你的代码如何表现,为这种行为编写一些断言是有用的。

把原型放在一边,开始创建一个清单,列出你现在知道你的代码应该具有的行为。也许这是一个好时机让产品负责人参与,因为你实际上创建了一个尚未实现的功能合同。

这种先测试后开发的方法通常被称为测试驱动开发TDD)。测试的有用性不在于你是否先编写它们。但如果你先编写它们,它们可以帮助你在项目的关键阶段塑造代码的行为。

通过测试进行文档编写

除非你有示例文件夹或广泛的文档,否则测试可能是你展示代码应该做什么以及如何做的唯一方式。

你(或与你代码一起工作的开发者)可能对你的代码应该做什么知之甚少,但如果你编写了好的测试,他们可以从中了解到一些有趣的事情。测试可以揭示函数的参数、函数崩溃的方式,甚至是不再使用的代码。

通过测试来睡眠

几乎没有什么事情能像将关键代码更改部署到大型生产系统那样让我感到紧张。你们团队是否遵循“周五永不部署”的规则?如果你有一套好的测试,你就可以毫无畏惧地部署。

测试是发现代码中回归的绝佳方式。想知道你做的更改是否会影响到应用程序的其他部分吗?如果应用程序经过良好的测试,你会在它发生的那一刻就知道。

总结来说,一个好的测试套件将帮助你保持代码按照预期运行,并在你破坏它时通知你。测试是极好的。

注意

无论你是在编写应用程序代码之前还是之后编写测试,有测试通常比没有测试要好。你不必遵循 TDD 原则,但它们已被证明可以改善代码的设计。成年人知道在拒绝西兰花之前先尝试一下。

测试类型

许多书籍可以(并且已经被)充满测试的复杂性。有很多术语,我们可以讨论很长时间。相反,我想专注于一些我认为对我们最有用的术语。我们可以编写两种常见的测试。

单元测试

单元测试是专注于一次一个小型、实际工作单元的测试。给定一个非平凡的类或组件,单元测试将专注于一个方法,甚至只是这个方法的一部分(如果这个方法做了很多事情)。

为了说明这一点,考虑以下示例代码:

class Page extends React.Component {
    render() {
        return (
            <div className="page">
                <h1>{this.props.title}</h1>
                {this.props.content}
            </div>
        );
    }
}

class Pages extends React.Component {
    render() {
        return (
            <div className="pages">
                {this.getPageComponents()}
            </div>
        );
    }

    getPageComponents() {
        return this.props.pages.map((page, key) => {
            return this.getPageComponent(page, key);
        });
    }

    getPageComponent(page, key) {
        return (
            <li key={key}>
                <Page {...page} />
            </li>
        );
    }
}

let pages = [
    {"title": "Home", "content": "A welcome message"},
    {"title": "Products", "content": "Some products"},
];

let component = <Pages pages={pages} />;

注意

在前面的章节中,我们创建了一个工作流程,可以通过 Node.js 运行 ES6 代码。我建议你为本章的一些代码重新创建这个设置,或者使用像jsbin.com/这样的网站。

对于Page组件的单元测试可能如下:给定这个组件的一个实例,一个包含标题"Home"和内容"欢迎信息"的对象,当我调用类似ReactDOMServer.render的操作时,我可以看到包含具有相同标题的h1元素和一些data-reactid属性的标记。

我们测试一个小型、实际的工作单元。在这种情况下,Page有一个单一的方法,具有小的关注点。我们可以一次性测试整个组件,确保我们测试的是小而专注的东西。

另一方面,Pages的单元测试可能如下:给定一个包含一个包含良好格式化页面对象的pages属性的组件实例,当我调用getPageComponents时,getPageComponent方法对每个页面对象调用一次,每次都带有正确的属性。

我们会为每个方法编写单独的测试,因为它们有不同的焦点和产生不同的结果。我们不会在单元测试中将所有页面一起测试。

功能测试

与单元测试相比,功能测试不太关注如此狭窄的焦点。功能测试仍然测试更多区域,但它们不需要像单元测试那样多的单元隔离。我们可以在单个功能测试中测试整个Pages组件,例如:给定一个包含一个包含良好格式化页面对象的pages属性的组件实例,当我调用类似于ReactDOMServer.render的操作时,我看到包含所有页面及其正确属性的标记。

使用功能测试,我们可以在更短的时间内测试更多内容。缺点是错误的原因更难定位。单元测试立即指向较小错误的根源,而功能测试通常只显示功能组没有按预期工作。

注意

所有这些都是为了说明——你测试代码越准确、越细致,定位错误原因就越容易。你可以为同一代码编写一个功能测试或 20 个单元测试。因此,你需要平衡可用时间和详细测试的重要性。

使用断言进行测试

断言是代码中的口语/书面语言结构。它们看起来和功能与我之前描述的相似。事实上,大多数测试都是按照我们描述测试的方式构建的:

  • 给定一些前置条件

  • 当发生某些事情时

  • 我们可以看到一些后置条件

前两点发生在我们创建对象和组件并调用它们的各种方法时。断言发生在第三点。Node.js 自带一些基本的断言方法,我们可以使用它们来编写我们的第一个测试:

import assert from "assert";

assert(
    rendered.match(/<h1 data-reactid=".*">Home<\/h1>/g)
);

我们可以使用相当多的断言方法:

  • assert(condition), assert.ok(condition)

  • assert.equal(actual, expected)

  • assert.notEqual(actual, expected)

  • assert.strictEqual(actual, expected)

  • assert.notStrictEqual(actual, expected)

  • assert.deepEqual(actual, expected)

  • assert.notDeepStrictEqual(actual, expected)

  • assert.throws(function, type)

你可以为这些方法的参数添加一个可选的自定义消息字符串。自定义消息将替换每个方法的默认错误消息。

我们可以非常简单地编写这些测试——创建一个tests.js文件,导入类和组件,并对它们的方法和标记进行断言。

如果你更喜欢更丰富的语法,考虑安装assertthat库:

$ npm install --save-dev assertthat

然后,您可以编写类似的测试:

import assert from "assertthat";

assert.that(actual).is.equals.to(expected);
assert.that(actual).is.between(expectedLow, expectedHigh);
assert.that(actual).is.not.undefined();

本章的示例代码包括您可以检查和运行的测试。我还创建了一种使用 BabelJS 的方式,可以在测试中使用 ES6 和 JSX。您可以使用以下命令运行测试:

$ npm test

这将运行以下定义的 NPM 脚本:

"scripts": {
  "test": "node_modules/.bin/babel-node index.js"
}

如果运行后您什么也没看到,请不要惊慌。测试被设置为这样的方式,只有当测试失败时您才会看到错误。如果您没有看到错误,那么一切正常!

测试不可变性和幂等性

当我们查看 Flux 和 Redux 时,我们看到的一个有趣的事情是他们推荐使用不可变类型和幂等功能(如在 reducers 中)。如果我们想要测试这些特性,我们可以!让我们安装一个辅助库:

$ npm install --save-dev deep-freeze

然后,让我们考虑以下示例:

import { createStore } from "redux";

const defaultState = {
    "pages": [],
};

const reducer = (state = defaultState, action) => {
    if (action.type === "ADD_PAGE") {
        state.pages.push(action.payload);
    }

    return state;
};

let store = createStore(reducer);

store.dispatch({
    "type": "ADD_PAGE",
    "payload": {
        "title": "Home",
        "content": "A welcome message",
    },
});

let state = store.getState();

assert(
    state.pages.filter(page => page.title == "Home").length > 0
);

注意

如果这对你来说不熟悉,请参阅关于设计模式的章节(第八章,React 设计模式)。

在这里,我们有一个示例 reducer、store 和断言。该 reducer 处理单个动作——添加新页面。当我们向 store 发送ADD_PAGE动作时,reducer 会将新页面添加到pages状态数组中。这个 reducer 不是幂等的——它不能使用相同的输入运行并总是产生相同的输出。

我们可以通过冻结默认状态来查看这一点:

import freeze from "deep-freeze";

const defaultState = freeze({
    "pages": [],
});

当我们运行这个时,我们应该会看到一个错误,例如无法添加属性 0,对象不可扩展。请记住,我们可以通过从我们的 reducer 返回一个新的、修改后的状态对象来解决这个问题:

const reducer = (state = defaultState, action) => {
    if (action.type === "ADD_PAGE") {
        let pages = state.pages;

        pages = [
            ...pages,
            action.payload,
        ];
    }

    return {
        "pages": pages,
    };
};

现在,我们可以发送相同的动作并总是得到相同的结果。我们不再就地修改状态,而是返回一个新的、修改后的状态。幂等性和不可变性的具体细节在其他地方有更好的解释;但重要的是要注意我们如何测试幂等性。

我们可以冻结我们想要保持幂等的对象/数组,并确信我们没有修改我们不希望修改的东西。

连接到 Travis

有测试是迈向更好代码的伟大第一步,但经常运行它们也很重要。有许多方法可以做到这一点(例如 Git 钩子或构建步骤),但我更喜欢将我的项目连接到Travis

Travis 是一个持续集成服务,这意味着 Travis 会监视 GitHub 仓库(github.com)中的更改,并为这些更改触发测试。

连接到 Travis

注意

我们将探讨如何将 Travis 连接到 GitHub 仓库,这意味着我们需要一个已经设置好的 GitHub 仓库。我不会详细介绍如何使用 GitHub,但您可以在guides.github.com/activities/hello-world找到一个优秀的教程。

您可以通过登录 GitHub 账户并点击一个使用 GitHub 登录按钮来登录 Travis。将鼠标悬停在您的个人资料上,然后点击账户

连接到 Travis

启用你希望 Travis 检查的仓库。此外,你还需要创建一个名为.travis.yml的配置文件:

language: node_js

node_js:
  - "5.5"

这告诉 Travis 将此项目作为 Node.js 项目进行测试,并针对版本 5.5 进行测试。默认情况下,Travis 将在任何测试之前运行npm install。它还会运行npm test来执行实际的测试。我们可以通过在package.json中添加以下内容来启用此命令:

"scripts": {
  "test": "node run.js"
}

注意

如果你将测试放在了另一个文件中,你需要调整该命令以反映你运行测试时输入的内容。这不过是一个常见的别名。

在你将代码提交到你的仓库后,Travis 应该会为你测试这些代码。

连接到 Travis

注意

你可以在docs.travis-ci.com/user/for-beginners了解更多关于 Travis 的信息。

端到端测试

你可能还希望尝试以普通用户的方式测试你的应用程序。当你点击你正在开发的应用程序以检查你刚刚输入的内容是否按预期工作的时候,你已经在做这件事了。为什么不自动化这个过程呢?

有很多这样的工具。我最喜欢使用的是名为Protractor的工具。设置起来可能有点棘手,但关于这个主题有一个非常优秀的教程,可以在www.joelotter.com/2015/04/18/protractor-reactjs.html找到。

摘要

在本章中,你了解了编写测试和经常运行测试的好处。我们为我们的类和组件创建了一些测试,并对它们的行为做出了断言。

我们现在已经涵盖了我想与你分享的所有主题。希望它们为你提供了开始自信地创建界面的所有工具。我们一起学到了很多;涵盖了单组件设计和管理状态、组件如何相互通信(通过如上下文这样的方式)、如何构建和装饰整个系统,甚至如何对其进行测试。

React 社区才刚刚开始,你可以加入其中并影响它。你所需要做的只是花一点时间用 React 构建一些东西,并与他人分享你的经验。