React-组件指南-全-

React 组件指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

React 是对传统前端开发的一种令人着迷的新尝试。它席卷了 JavaScript 社区,并激发了许多现有 JavaScript 应用程序框架和架构的全面变革。

不幸的是,关于优秀架构的例子仍然不多。大多数教程和书籍都专注于小型组件和示例,而没有回答关于大型应用程序和组件层次结构的问题。这正是本书试图改变的地方。

本书涵盖的内容

第一章, 组件化思维,探讨了将整个界面视为小型组件的集合的需要,以及如何使用现代 ES6 JavaScript 构建它们。

第二章, 使用属性和状态,全面分析了属性和状态管理的多个方面,并在过程中分享了一些 ES6 技巧。

第三章, 保存和通信数据,探讨了使用事件发射器和单向数据流进行响应式编程。

第四章, 样式化和动画化组件,探讨了如何对组件进行内联和通过样式表进行样式化和动画化。

第五章, 走向 Material!,探讨了材料设计,并将所学应用到我们的组件集中。

第六章, 更改视图,探讨了使用路由和动画在不同视图之间过渡的方法。

第七章, 服务器端渲染,探讨了通过节点渲染组件的过程以及一些结构服务器端应用程序代码的方法。

第八章, React 设计模式,探讨了不同的架构,如 Flux 和 Redux。

第九章, 考虑插件,探讨了如何使用依赖注入和扩展点构建组件。

第十章, 测试组件,探讨了确保组件无错误以及应用程序部分更改不会对其他部分产生级联影响的各种方法。

你需要为此书准备什么

以下硬件推荐以获得最佳体验:

  • 任何配备 Linux、Mac OS 或 Windows 的现代计算机。

本书提到的所有软件都是免费的,可以从互联网上下载。

本书面向的对象

本书非常适合熟悉 React 基础知识并寻求构建各种组件以及开发驱动 UI 指南的开发者。

惯例

在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“我们需要注册一组默认的任务,我们将它们设置为browserifyuglify。”

代码块如下设置:

"scripts": {
    "bundle": "browserify -t babelify main.js -o main.dist.js",
    "minify": "..."
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

render() {
    if (this.state.isEditing) {
        return <PageEditor {...this.props} />;
    }

    return <PageView {...this.props} />;
}

任何命令行输入或输出都如下所示:

$ npm install --save-dev grunt
$ npm install --save-dev grunt-browserify
$ npm install --save-dev grunt-contrib-uglify
$ npm install --save-dev grunt-contrib-watch

新术语重要词汇以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,在文本中如下所示:“为了充分利用 JSBin,请确保将JavaScript下拉菜单设置为ES6/Babel,并包含来自CDNJS的 ReactJS 脚本。”

注意

警告或重要提示将以这样的框显示。

小贴士

小贴士和技巧如下所示。

读者反馈

我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。

要向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书的标题。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为本书做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大价值。

下载示例代码

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击代码下载与勘误

  4. 搜索框中输入书的名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击代码下载

文件下载完成后,请确保使用最新版本的以下软件解压缩或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

勘误

尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的非法复制我们的作品,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过发送链接到疑似盗版材料至<copyright@packtpub.com>与我们联系。

我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。

问题

如果您对本书的任何方面有问题,您可以通过发送电子邮件至<questions@packtpub.com>与我们联系,我们将尽力解决问题。

第一章. 组件化思维

React 是第一个让我开始思考组件化设计的界面库。React 推崇许多良好的模式和习惯,但对我来说,这一点最为突出。要理解为什么,我们需要思考 React 在底层是如何工作的。React 主要是一个渲染引擎。它是为了生成用户界面而被创建(并使用)的。

接口过去是如何工作的(实际上除了 React 之外仍然如此)是有人会提出一个设计。然后,这个图像文件会被分割成界面每个交互部分的资产。例如,jQuery 这样的库会管理用户交互并连接不同的界面组件,通常还会附带一系列插件。

单个界面组件可以非常干净和完整。然而,当它们组合在一起时,组件之间的交互和共享的可变组件状态往往会使代码库变得混乱。React 被创建的主要原因之一就是为了简化组件之间的交互,使它们保持干净且易于理解。

为什么是组件?

基于组件的设计非常强大,尤其是当我们使用不可变数据和单向数据流时。它迫使我停止思考不同技术或工具之间的交互方式。它让我开始思考每个界面元素最重要的功能。

当我们开始构建一个应用程序时,很容易认为每个部分都是整体的一部分。所有界面元素都融合成同一个大画面,直到它变得如此之大,以至于分离出其部分似乎是不可能的。

想象一下,如果你必须建造一艘宇宙飞船。这是一项多么庞大的任务!你需要一些火箭助推器、几对翅膀、生命维持系统等等。现在考虑一下,如果其中一个约束是宇宙飞船的每个活动部件都需要单独测试,你会如何处理?

测试是整体设计系统与将系统设计为大量小部件集合之间的巨大差异。基于组件的设计非常出色,因为它确保每个部分都是可测试的。

使用现代 JavaScript

React 组件被巧妙地封装。每个组件都是任何时刻一个专注的标记片段应该呈现的蓝图。它们是可重用的,并且可以根据提供的上下文改变其行为。这让你想起了另一种编程范式吗?

让我们谈谈 JavaScript。JavaScript 有一个原型继承模型。这意味着不同的对象可以有一个共同的架构。一个对象的架构可以源自另一个对象的架构。

这还意味着对原始对象的更改会继承到所有派生对象中。让我用一些代码来解释这一点:

var Page = function(content) {
    this.content = content;
};

Page.prototype.render = function() {
    return "<div>" + this.content + "</div>";
}

var Post = function(tags, content) {
    this.tags = tags;

    Page.call(this, content);
};

Post.prototype = new Page();

Post.prototype.render = function() {
    var page = Page.prototype.render.call(this);

    return "<ul>" + this.renderTags() + "</ul>" + page;
};

Post.prototype.renderTags = function() {
    return "<li>" + this.tags.join("</li></li>") + "</li>";
};

var page = new Page("Welcome to my site!");
var post = new Post(["news"], "A new product!");

Page.prototype.render = function() {
    return "<section>" + this.content + "</section>";
};

我首先创建了一个名为 Page 的函数,它需要一个 content 参数。一个简单的 render 方法返回该内容,并用 div 标签包裹。这似乎是一个构建网站的不错起点。

接下来,我决定创建第二种类型,称为Post。这种类型的对象有标签,所以我创建了一个新的初始化函数来存储它们。我希望Post的行为几乎像Page类型,所以我调用了Page的初始化函数。

要在Post中继承Page的方法,我需要链接它们的原型。然后我可以选择覆盖render方法并为派生类型添加新方法。我还可以更改Page类型,这些更改将继承到Post类型的对象中。这种连接发生是因为原型是一个引用而不是一个副本。

根据你成长过程中所使用的编程语言,原型继承一开始可能有些棘手。许多新开发者错误地认为面向对象代码意味着面向类代码。动态概念如原型对他们来说很陌生。在过去,这导致了一些库实现了“假装”类。它们创建了使代码看起来像面向类模式的模式。

然后,ES6 添加了class关键字。它是刚才展示模式的正式化。它是原型继承的语法捷径。

我们可以将之前的代码简化为:

class Page {
    constructor(content) {
        this.content = content;
    }

    render() {
        return "<div>" + this.content + "</div>";
    }
}

class Post extends Page {
    constructor(tags, content) {
        super(content);
        this.tags = tags;
    }

    render() {
        var page = super.render();

        return "<ul>" + this.renderTags() + "</ul>" + page;
    }

    renderTags() {
        return "<li>" + this.tags.join("</li></li>") + "</li>";
    }
}

var page = new Page("Welcome to my site!");
var post = new Post(["news"], "A new product!");

注意

如果你尝试使用 Node(最好是版本大于 4.1)运行此代码,你可能在文件顶部需要添加use strict

注意一下事情变得有多清晰?如果你想使用类,那么这种语法捷径是非常棒的!

让我们看看一个典型的 ES5 兼容的 React 组件:

var Page = React.createClass({
    render: function() {
        return <div>{this.props.content}</div>;
    }
});

var Post = React.createClass({
    render: function() {
        var page = <Page content={this.props.content} />
        var tags = this.renderTags();

        return <div><ul>{tags}</ul>{page}</div>;
    },
    renderTags: function() {
        return this.props.tags.map(function(tag, i) {
            return <li key={i}>{tag}</li>;
        });
    }
});

ReactDOM.render(
    <Post tags={["news"]} content="A new product!" />,
    document.querySelector(".react")
);

你可能之前见过这种代码。它被称为 JSX,是一种 JavaScript 超集语言。其理念是标记和支撑逻辑被创建并存储在一起。

注意

React 组件必须返回一个单一的 React 节点,这就是为什么我们将标签和页面元素包裹在一个div元素中。如果你在浏览器中使用 React,你还需要将你的组件渲染到一个现有的 DOM 节点(就像我刚刚将帖子渲染到.react中)。

我们将在后面的章节中详细介绍一些具体内容,但这是与之前几乎相同的事情。我们创建了一个名为Page的基本组件。它渲染一个属性而不是构造函数参数。

Post组件组合了Page组件。这种风格的 React 代码不支持组件继承。为此,我们需要 ES6 代码:

class Page extends React.Component {
    render() {
        return <div>{this.props.content}</div>;
    }
}

class Post extends Page {
    render() {
        var page = super.render();
        var tags = this.renderTags();

        return <div><ul>{tags}</ul>{page}</div>;
    }

    renderTags() {
        return this.props.tags.map(function(tag, i) {
            return <li key={i}>{tag}</li>;
        });
    }
}

我们仍然可以在Post中组合Page,但这不是 ES6 的唯一选项。这段代码与之前看到的非 React 版本相似。

在接下来的章节中,我们将学习 ES6 的许多有用特性,这将使我们能够创建现代、表达式的 React 组件。

注意

如果你想要提前了解一下,请查看babeljs.io/docs/learn-es2015。这是一个学习 ES6 主要特性的绝佳地方!

Babel 是我们将使用的跨编译工具,将 ES6 代码转换为 ES5 代码:

使用现代 JavaScript

编译现代 JavaScript

是时候看看如何将 ES6 和 JSX 代码编译成大多数浏览器都能读取的格式了。为你的 React 组件创建一个文件夹,并在其中运行以下命令:

$ npm init
$ npm install --save browserify babelify
$ npm install --save react react-dom

第一个命令将启动一系列问题,其中大部分应该有合理的默认值。第二个命令将下载用于你的 ES6 代码的构建器和跨编译器。将以下组件放在一个名为 page.js 的文件中:

import React from "react";

export default class Page extends React.Component {
    render() {
        return <div>{this.props.content}</div>;
    }
}

与之前的 Page 组件相比,这里有几个重要的区别。我们从 node_modules 文件夹中导入主要的 React 对象。我们还导出类定义,以便导入此文件立即引用此类。将每个文件限制为单个类是一个好主意。同样,让每个文件定义类型或使用它们也是一个好主意。我们在 main.js 中使用这个类:

import React from "react";
import ReactDOM from "react-dom";
import Page from "./page";

ReactDOM.render(
    <Page content="Welcome to my site!" />,
    document.querySelector(".react")
);

这段代码从 node_modules 文件夹中导入 ReactReactDOM,因此我们可以渲染 Page 类。这里我们再次引用 DOM 中的元素。我们可以在 HTML 页面中使用这段 JavaScript:

<!doctype html>
<html lang="en">
    <body>
        <div class="react"></div>
    </body>
    <script src="img/main.dist.js"></script>
</html>

最后一步是将 main.js 中的 ES6/JSX 代码编译成 main.dist.js 中的 ES5 兼容代码:

$ alias browserify=node_modules/.bin/browserify
$ browserify -t babelify main.js -o main.dist.js

第一个命令在 node_modules/.bin 文件夹中创建 browserify 命令的快捷方式。这对于重复调用 browserify 非常有用。

注意

如果你想要保留那个别名,请确保将其添加到你的 ~/.bashrc~/.zshrc~/.profile 文件中。

第二个命令开始构建。Browserify 将所有导入的文件合并成一个文件,以便在浏览器中使用。

我们使用 babelify 转换器,因此 ES6 代码变成了 ES5 兼容的代码。Babel 支持 JSX,所以我们不需要额外的步骤。我们指定 main.js 作为要转换的文件,并将 main.dist.js 作为输出文件。

注意

如果你想要将 React 和 ReactDOM 编译成单独的文件,你可以使用 -x 开关来排除它们。你的命令应该是这样的:

browserify main.js -t babelify -x react -x react-dom --outfile main.dist.js

浏览器调试

我们也可以直接在浏览器中使用我们的代码。有时我们可能想在构建步骤之前看到更改的效果。在这种情况下,我们可以尝试以下操作:

$ npm install --save babel-core
$ npm install --save systemjs

这些将为我们提供一个基于浏览器的依赖管理器和跨编译器;也就是说,我们可以在一个示例 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",
                    "page": "/src/page"
                },
                "defaultJSExtensions": true
            });

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

这使用的是之前相同的未处理 main.js 文件,但我们在每次更改源代码后不再需要重新构建它。System 是对通过 NPM 安装的 SystemJS 库的引用。它负责处理导入语句,通过 Ajax 请求加载这些依赖项。

你可能会注意到对 reactreact-dom 的引用。我们在 main.js 中导入这些,但它们是从哪里来的?Browserify 从 node_modules 文件夹中获取它们。当我们跳过 Browserify 步骤时,我们需要让 SystemJS 知道它们的位置。

这些文件最易找到的地方是facebook.github.io/react。点击下载按钮,解压存档,并将build文件夹中的JS文件复制到 HTML 页面中引用的位置。

ReactJS 网站是一个下载 ReactJS 和查找如何使用它的文档的好地方:

浏览器中的调试

管理常见任务

随着我们 React 组件集合的增长,我们需要将它们全部捆绑在一起的方法。同时,将生成的 JavaScript 代码压缩以减少在浏览器中加载它们所需的时间也是一个好主意。

我们可以使用package.json中的脚本执行这类任务:

"scripts": {
    "bundle": "browserify -t babelify main.js -o main.dist.js",
    "minify": "..."
}

NPM 脚本对于小型、简单的任务来说很好。当任务变得更加复杂时,我们开始看到使用 NPM 脚本进行此操作的缺点。在这些脚本中使用变量没有简单的方法,因此参数经常被重复。这些脚本也有些不够灵活,坦白说,看起来也不太美观。

有一些工具可以解决这些问题。我们将使用其中之一,名为Grunt,来创建灵活、可重复的任务。

Grunt 网站提供了使用 Grunt 的说明以及你可以用来定制工作流程的流行插件列表:

管理常见任务

Grunt 是一个 JavaScript 任务运行器。使用它的三个步骤如下:

  1. 首先,我们需要安装 CLI 工具。我们将使用它来运行不同的任务。

  2. 然后,我们需要安装我们的任务将使用的库,通过 NPM。

  3. 最后,我们需要创建一个gruntfile.js文件,我们将在这里放置我们的任务。

我们可以使用以下命令安装 CLI 工具:

$ npm install -g grunt-cli

注意

前面的命令全局安装了 Grunt CLI 工具。如果你不希望这样做,请省略-g标志。但是,从现在开始,你需要直接使用node_modules/.bin/grunt来别名/运行它。

我们将需要以下任务库:

$ npm install --save-dev grunt
$ npm install --save-dev grunt-browserify
$ npm install --save-dev grunt-contrib-uglify
$ npm install --save-dev grunt-contrib-watch

全局 CLI 工具需要一个本地的grunt副本。此外,我们还想在 Grunt 中运行 Browserify、Uglify 和文件监视器的粘合库。我们用类似以下的方式配置它们:

module.exports = function(grunt) {
    grunt.initConfig({
        "browserify": {
            "main.js": ["main.es5.js"],
            "options": {
                "transform": [
                    "babelify"
                ],
                "external": [
                    "react", "react-dom"
                ]
            }
        },
        "uglify": {
            "main.es5.js": ["main.dist.js"]
        },
        "watch": {
            "files": ["main.js"],
            "tasks": ["browserify", "uglify"]
        }
    });

    grunt.loadNpmTasks("grunt-browserify");
    grunt.loadNpmTasks("grunt-contrib-uglify");
    grunt.loadNpmTasks("grunt-contrib-watch");

    grunt.registerTask("default", ["browserify", "uglify"]);
};

我们可以在gruntfile.js中配置每个任务。在这里,我们创建一个browserify任务,定义源文件和目标文件。我们包括babelify转换,将我们的 ES6 类转换为 ES5 兼容的代码。

注意

我已经添加了external选项,这样你就可以看到如何使用它。如果你不需要它,只需删除它,然后你的包文件应该包含完整的 React 源代码。

在 ES6 代码转换后,我们可以运行Uglify来删除不必要的空白。这减少了文件的大小,因此浏览器可以更快地下载它。我们可以针对 Browserify 创建的文件,并从中创建一个新的压缩文件。

最后,我们创建一个watch任务。这个任务会监视main.js的变化,并触发 Browserify 和 Uglify 任务。我们需要注册一组默认任务,我们将它们设置为browserifyuglify。此配置启用以下命令:

$ grunt
$ grunt browserify
$ grunt uglify
$ grunt watch

还有其他一些优秀的工具,如 Grunt:

他们使用类似的配置文件,但配置是通过功能组合来完成的。从这个例子中我们可以得到的重要信息是,有一些工具我们可以用来自动化我们本需要手动执行的任务。这使得这些重复性任务变得简单易行!

在 JSBin 中进行测试

如果您像我一样,您通常会想要一个快速的地方来测试一些小的组件或 ES6 代码。设置这些构建链或实时浏览器环境需要时间。有一种更快的方法。它被称为 JSBin,您可以在jsbin.com找到它:

在 JSBin 中进行测试

要充分利用 JSBin,请确保将JavaScript下拉菜单设置为ES6/Babel,并包含来自CDNJS的 ReactJS 脚本。这些是预构建的 ReactJS 版本,因此您可以直接从浏览器中创建 React 组件(使用 ES6 功能)。

小贴士

下载示例代码

您可以从www.packtpub.com上的账户下载此书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  • 使用您的电子邮件地址和密码登录或注册我们的网站。

  • 将鼠标指针悬停在顶部的“支持”标签上。

  • 点击“代码下载与勘误”。

  • 在搜索框中输入书籍名称。

  • 选择您想要下载代码文件的书籍。

  • 从下拉菜单中选择您购买此书籍的地方。

  • 点击“代码下载”。

您还可以通过点击 Packt Publishing 网站上的书籍网页上的“代码文件”按钮来下载代码文件。您可以通过在搜索框中输入书籍名称来访问此页面。请注意,您需要登录到您的 Packt 账户。

文件下载完成后,请确保使用最新版本的以下软件解压缩或提取文件夹:

  • Windows 上的 WinRAR / 7-Zip

  • Mac 上的 Zipeg / iZip / UnRarX

  • Linux 上的 7-Zip / PeaZip

摘要

在本章中,我们看到了基于组件的设计为什么是好的。我们看到了简单的 React 组件的样子。我们看到了 ES5 和 ES6 之间的一些有趣的不同之处,我们还看到了这些差异如何影响 React 组件。

我们还看到了一些方法可以使 ES6 代码以 ES5 兼容的方式运行。我们可以编写适用于常见浏览器的尖端代码。我们甚至可以将这些代码打包成单个高效文件,或者在浏览器中实时调试它。

在下一章中,我们将探讨状态和属性的某些复杂性。我们将从创建可重用 React 组件以在我们的示例应用程序中使用开始。

第二章:使用属性和状态

在上一章中,我们设置了我们的工作流程。我们解决了如何通过构建步骤编译 ReactJS 和 ES6 代码,直接在我们的浏览器中解释它,甚至使用 JSBin 等服务运行它的问题。现在,我们可以开始为我们的内容管理系统创建组件。

在本章中,我们将开始构建我们的界面。我们将看到连接组件的有趣和有效的方法。本章的重要之处在于学习如何安排复杂层次结构中的组件。我们将嵌套几个组件,并通过自定义数据后端与它们以及我们的数据源进行通信。

组件嵌套

让我们思考一下我们想要如何构建我们界面的组件。许多内容管理系统都包含项目列表——这些项目是我们存储在数据库中并从中检索出来的。例如,让我们想象一个可以管理网站页面的系统。

对于这样的系统,我们需要一个入口点——类似于PageAdmin的东西,它将我们的持久层连接到我们的界面:

import React from "react";

class PageAdmin extends React.Component {
    render() {
        return <ol>...page objects</ol>;
    }
}

export default PageAdmin;

我们也可以将持久层表示为后端类:

class Backend {
    getAll() {
        // ...returns an array of pages
    }

    update(id, property, value) {
        // ...updates a page
    }

    delete(id) {
        // ...deletes a page
    }
}

注意

之后,我们将探讨持久化这些数据的方法。现在,在这个类中使用静态数据是可以的。

我们可以通过提供一个Backend实例作为属性来将PageAdmin连接到这个类:

var backend = new Backend();

ReactDOM.render(
    <PageAdmin backend={backend} />,
    document.querySelector(".react")
);

现在,我们可以在PageAdmin组件中使用Backend数据:

class PageAdmin extends React.Component {
    constructor(props) {
        super(props);

        this.state = {
            "pages": []
        };
    }

    componentWillMount() {
        this.setState({
 "pages": this.props.backend.getAll()
 });
    }

    render() {
        return <ol>
            {this.state.pages.map(function(page) {
 return <li key={page.id}>a new page</li>
 })}
        </ol>;
    }
}

注意

事实上,我们并不真的需要定义一个默认状态,或者将页面对象存储到状态中。我这样做是为了演示使用 ES6 风格组件定义初始组件状态和覆盖状态时的惯用方法。

这里有很多事情在进行中,所以让我们一点一点地分解它:

  • 我们创建了一个构造函数。在构造函数中,我们定义了组件的初始状态。我们将状态定义为具有空pages数组的对象。

  • React 将在组件的生命周期中调用几个魔法方法。我们使用componentWillMount来获取页面数组,以便我们有东西可以渲染。我们还把这个页面数组传递给setState方法。这是为了存储状态数据和同时更新组件的标记。现在,this.state.pages方法将包含来自后端的页面数组。

  • 当我们在标记中使用花括号时,它就像一个动态值(就像属性一样)。我们可以使用Array.prototype.map方法为页面数组中的每个页面返回一个新的元素。这将返回一个新的li组件列表。React 还期望列表中的组件具有特殊的key属性,它使用这个属性来识别它们。React 使用这个属性来高效地跟踪它可以删除、添加或更改的组件。

    注意

    代码引用page.id。后端返回的页面应该有idtitlebody属性,以便这些示例可以工作。

让我们集中讨论如何通过内容管理系统展示每一页。PageAdmin会将每一页渲染为一个列表项,因此让我们思考在每个列表项内部我们想要做什么。我认为为每一页提供一个非交互式的摘要是有意义的。想象一下,一个网站中所有页面的表格视图:

  • 首页

  • 产品

  • 服务条款

  • 联系我们

所以,页面的一个方面是静态的:页面标题的视图。也许我们还可以包括编辑或删除每个页面的链接。

我们还希望能够更新每一页。我们可能需要某种形式的表单,为可能更新的每个字段提供文本输入。

我们可以在单个组件中表示这两个场景:

import React from "react";

class Page extends React.Component {
    constructor(props) {
        super(props);

        this.state = {
 "isEditing": false
 };
    }

    render() {
 if (this.state.isEditing) {
 return <PageEditor />;
 }

 return <PageView />;
    }
}

export default Page;

现在,我们可以根据是否在编辑之间切换不同的组件。当然,我们还需要定义这些新组件:

import React from "react";

class PageEditor extends React.Component {
    render() {
        return <form>
            <input type="text" name="title" />
            <textarea name="body"></textarea>
            <button>back</button>
        </form>;
    }
}

export default PageEditor;

注意,如果我们之前使用过 HTML 标记,我们可以以我们可能期望的方式定义输入元素。我们稍后会重新审视这个组件,所以现在不用担心细节。

对于这个组件,预览模式有一点相似:

import React from "react";

class PageView extends React.Component {
    render() {
        return <div>
            {this.props.title}
            <button>edit</button>
            <button>delete</button>
        </div>;
    }
}

export default PageView;

这引发了一个有趣的问题。我们如何高效地将属性从一个组件传输到另一个组件?ES6 提供了一个名为扩展运算符的语言特性,这是一个很好的工具。首先,我们需要在PageAdmin中将页面提供给页面组件:

render() {
    return <ol>
        {this.state.pages.map(function(page) {
            return <li key={page.id}>
                <Page {...page} />
            </li>;
        })}
    </ol>;
}

我们正在用我们之前创建的Page组件替换a new page。我们使用扩展运算符将每个对象键分配为组件属性。我们可以在Page中重复这个概念:

render() {
    if (this.state.isEditing) {
        return <PageEditor {...this.props} />;
    }

    return <PageView {...this.props} />;
}

{...this.props}扩展了页面对象的键。在PageEditorPageView组件内部,page.id变成了this.props.id。这个方法对于传输许多属性非常棒;我们不需要逐个写出每一个。

共享组件操作

那么,我们如何从PageView类转换为PageEditor类?为此,我们需要挂钩到浏览器事件并调整状态:

class Page extends React.Component {
    constructor(props) {
        super(props);

        this.state = {
 "isEditing": false
 };
    }

    render() {
        if (this.state.isEditing) {
            return <PageEditor
                {...this.props}
                onCancel={this.onCancel.bind(this)}
                />;
        }

        return <PageView
            {...this.props}
            onPageEdit={this.onEdit.bind(this)}
            />;
    }

    onEdit() {
 this.setState({
 "isEditing": true
 });
 }

 onCancel() {
 this.setState({
 "isEditing": false
 });
 }
}

我们提供了一个方法,允许组件通过传递方法来调用组件中的方法。当一个PageView类想要将Page放入编辑模式时,它可以调用this.props.onEditPage将知道如何处理。我们将在后续内容中经常看到这个模式,所以在这里移动之前理解它在做什么是很好的!

同样地,我们为PageEditor类提供了一个取消编辑模式的方法。在这两种情况下,我们使用setState在编辑和查看状态之间切换。

备注

我们绑定处理方法,因为否则当方法被调用时this将意味着不同的东西。这种绑定方式不是很高效,所以稍后我们会用一种替代方案来重新审视这个问题!

我们可以将这些处理程序连接到每个组件的点击事件:

class PageEditor extends React.Component {
    render() {
        return <form>
            <input type="text" name="title" />
            <textarea name="body"></textarea>
            <button>save</button>
            <button
                onClick={this.onCancel.bind(this)}
                >back</button>
        </form>;
    }

    onCancel(event) {
        event.preventDefault();
        this.props.onCancel();
    }
}

在调用通过 props 传递下来的onCancel之前,我们需要防止默认表单提交。代码如下:

class PageView extends React.Component {
    render() {
        return <div>
            {this.props.title}
            <button
                onClick={this.props.onEdit}
                >edit</button>
            <button>delete</button>
        </div>;
    }
}

现在,你应该能够在浏览器中运行此代码,并在每个页面的编辑和查看方面之间切换。这是一个停下来总结我们所取得的成果的好时机:

  1. 我们创建了一个名为 PageAdmin 的页面管理的入口点组件。该组件处理获取和持久化页面数据。它使用 Backend 类来完成这些操作。它还渲染 Page 组件,每个 Backend 返回一个页面。

  2. 我们创建了一个 Page 组件来封装页面数据以及每个页面的编辑和查看方面。Page 组件通过回调处理这两个子组件之间的切换。

  3. 我们创建了一个 PageEditor 组件作为编辑页面数据的接口。它包含一些字段,我们将在下面简要讨论。

  4. 最后,我们创建了一个 PageView 组件作为查看页面数据和进入编辑模式的接口。我们即将使 删除 按钮也能工作。

如果你一直在跟随,你的界面可能看起来像这样:

共享组件操作

我们在本章中创建了许多新的函数引用。每次我们使用 fn.bind(this),我们都会创建一个新的函数。如果我们这样做是在渲染方法内部,这就不太高效。我们可以通过创建一个基础组件来解决这个问题:

import React from "react";

class Component extends React.Component {
    bind(...methods) {
        methods.map(
            method => this[method] = this[method].bind(this)
        )
    }
}

export default Component;

如果我们扩展这个基础组件(而不是通常的 React.Component),那么我们将能够访问 bind 方法。它接受一个或多个函数名称,并将它们替换为绑定版本。

现在,我们需要添加更新和删除页面的事件处理器。让我们从 PageViewPageEditor 开始:

import Component from "component";

class PageView extends Component {
    constructor(props) {
        super(props);

        this.bind(
 "onDelete"
 );
    }

    render() {
        return <div>
            {this.props.title}
            <button
                onClick={this.props.onEdit}
                >edit</button>
            <button
                onClick={this.onDelete}
                >delete</button>
        </div>;
    }

    onDelete() {
 this.props.onDelete(
 this.props.id
 );
 }
}

我们在删除按钮上添加了一个 onClick 处理程序。这将触发一个绑定的 onDelete 版本,我们传递正确的:

import Component from "component";

class PageEditor extends Component {
    constructor(props) {
        super(props);

        this.bind(
"onCancel",
 "onUpdate"
 );
    }

    render() {
        return <form>
            <input
                type="text"
                name="title"
                value={this.props.title}
 onChange={this.onUpdate}
               />
           <textarea
               name="body"
               value={this.props.body}
 onChange={this.onUpdate}>
 </textarea>
            <button
                onClick={this.onCancel}>
                cancel
            </button>
        </div>;
    }

 onUpdate() {
 this.props.onUpdate(
 this.props.id,
 event.target.name,
 event.target.value
 );
 }

 onCancel(event) {
 event.preventDefault();
 this.props.onCancel();
 }
}

在这里,我们添加了 onUpdate,这样我们就可以确定哪个输入发生了变化。它调用带有正确属性名称和值的 props onUpdate` 方法。

我们还添加了 namevalue 属性给输入,将值设置为相应的属性。这些更新会在输入改变时触发,调用 onUpdate 方法。这意味着属性更新将反映在字段中。

这些新的处理程序属性从何而来?我们需要将它们添加到 PageAdmin 中:

import Component from "component";

class PageAdmin extends Component {
    constructor(props) {
        super(props);

        this.state = {
            "pages": []
        };

 this.bind(
 "onUpdate",
 "onDelete"
 );
    }

    componentWillMount() {
        this.setState({
            "pages": this.props.backend.getAll()
        });
    }

    render() {
        return <ol>
            {this.state.pages.map(function(page) {
                return <li key={page.id}>
                    <Page
                        {...page}
                        onUpdate={this.onUpdate}
                        onDelete={this.onDelete}
                        />
                </li>;
            })}
        </ol>;
    }

    onUpdate(...params) {
 this.props.backend.update(...params);
 }

 onDelete(...params) {
 this.props.backend.delete(...params);
 }
}

最后,我们创建了一些处理更新和删除的方法。这些方法与我们之前在其他类中做的方法一样被绑定。它们还使用剩余/展开操作符作为一点快捷方式!

我们可以使用一组页面和一些数组修改方法来伪造后端数据和操作:

class Backend {
    constructor() {
        this.deleted = [];
 this.updates = [];

 this.pages = [
 {
 "id": 1,
 "title": "Home",
 "body": "..."
 },
 {
 "id": 2,
 "title": "About Us",
 "body": "..."
 },
 {
 "id": 3,
 "title": "Contact Us",
 "body": "..."
 },
 {
 "id": 4,
 "title": "Products",
 "body": "..."
 }
 ];
    }

    getAll() {
        return this.pages
 .filter(page => {
 return this.deleted.indexOf(page.id) == -1
 })
 .map(page => {
 var modified = page;

 this.updates.forEach((update) => {
 if (update[0] == page.id) {
 modified[update[1]] = update[2];
 }
 });
 return modified;
 });
    }

    update(id, property, value) {
        this.updates.push([id, property, value]);
    }

    delete(id) {
        this.deleted.push(id);
    }
}

小贴士

这绝对不是一个高效的实现。请勿在生产环境中使用此代码。它只是一个示例接口,我们可以用它来测试我们的代码!

all 方法返回一个过滤并映射的初始页面数组。() => {} 语法是 (function(){}).bind(this) 的快捷方式。如果函数只有一个属性,括号是可选的。过滤器检查每个页面的 id 是否不在 deleted 数组中。在这个模拟的后端中,我们实际上并没有删除页面。我们只是排除了我们不想看到的页面。

我们不是直接更新页面,而是在 all 返回数组之前应用更新。这并不高效,但它确实允许我们看到我们的界面在行动。

注意

你可以在 developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array 上了解更多关于这些数组技巧的信息。这是一个学习 JavaScript 语言特性的好地方。

组件生命周期方法

在结束之前,我想展示几个技巧。第一个是我们可以用来自定义组件属性变化时机的一个 生命周期方法。我们可以使用这个方法来改变组件的外观,或者刷新内部状态。

例如,我们可以将此方法添加到 PageEditor 中:

class PageEditor extends Component {
    constructor(props) {
        super(props);

        this.state = {
            "changed": false
        };

        this.bind(
            "onCancel",
            "onUpdate"
        );
    }

    isChanged(next, previous) {
 return JSON.stringify(next) !== JSON.stringify(previous)
 }

 componentWillReceiveProps(props) {
 this.setState({
 "changed": this.isChanged(props, this.props)
 });
 }

    render() {
        return <div>
            <input
                type="text"
                name="title"
                value={this.props.title}
                onChange={this.onUpdate}
                />
            <textarea
                name="body"
                value={this.props.body}
                onChange={this.onUpdate}>
            </textarea>
            <button
                onClick={this.onCancel}>
                cancel
            </button>
        </div>;
    }

    onUpdate() {
        this.props.onUpdate(
            this.props.id,
            event.target.name,
            event.target.value
        );
    }

 onCancel(event) {
 event.preventDefault();
 this.props.onCancel();
 }
}

现在我们可以知道页面何时发生变化,即使变化是立即传播的。

另一个我们可以使用的神奇方法将有助于减少 React 需要执行的比较。它被称为 shouldComponentUpdate,我们可以将其添加到 PageView

class PageView extends Component {
    constructor(props) {
        super(props);

        this.bind(
            "onDelete"
        );
    }

    isChanged(next, previous) {
 return JSON.stringify(next) !== JSON.stringify(previous)
 }

 shouldComponentUpdate(props, state) {
 return this.isChanged(props, this.props);
 }

    render() {
        return <div>
            {this.props.title}
            <button
                onClick={this.props.onEdit}
                >edit</button>
            <button
                onClick={this.onDelete}
                >delete</button>
        </div>;
    }

    onDelete() {
        this.props.onDelete(
            this.props.id
        );
    }
}

shouldComponentUpdate 方法为我们提供了一种告诉 React 不要在此组件中查找变化的方式。在这个规模上,我们不太可能看到巨大的性能提升。但是当我们将此方法添加到更复杂的布局中时,它将极大地减少确定文档应该如何更改所需的工作量。

我们将在构建更复杂的内容管理功能时使用这些技巧。

摘要

在本章中,你学习了更多关于 ES6 类以及它们如何在结构和功能上补充 React 组件的知识。我们还探讨了状态和属性的一些有趣用法。

总而言之,我们看到了避免内部组件状态的可能性和好处。属性是组件设计的一个强大工具。我们知道如何对变化的属性做出反应,以及如何减少 React 渲染我们界面所需的工作量。

在下一章中,我们将讨论如何持久化这些数据(到不同类型的本地存储)。我们将看到如何通过事件连接到这些数据存储。

第三章:保存和通信数据

在上一章中,我们创建了复杂的组件层次结构。我们创建了一个页面列表以及编辑这些页面的方法。然而,我们并没有停止在某种存储中保存和读取这些数据。

例如,我们可以通过 Ajax 请求发送一个编辑操作以保存到数据库服务器。实际上,这是我们今天使用的应用程序中经常发生的事情。它们总是保存我们的交互,无论我们是否期望它们这样做。

在本章中,您将了解本地数据存储以及如何与它们通信。您还将了解基于事件的架构以及它是如何促进数据单向流动的。

存储数据有许多方法。这是一个丰富且有趣的话题,可以填满几十本书。我甚至可以说,它是商业和应用程序工作的核心。

此外,在可维护的应用程序和不可维护的应用程序中,数据通信的方式可能经常不同。取决于我们如何找到优雅的方式来持久化数据,以使我们的应用程序保持可维护性。

我们在本章中只将探索本地存储。您将能够在页面重新加载后看到您存储的数据,但其他人则看不到。仅凭本章内容,您无法构建一个实用的网站。您将不得不等待我们探索服务器上的 React。

验证属性

在我们查看存储数据之前,我还有一个习惯想与您分享。我们在上一章中创建的组件可以很好地协同工作,但我们的目标是使每个组件都是自包含的。我们希望其他人能够重用我们的组件,但如果他们不知道我们的组件期望哪些属性,他们将会遇到问题。

考虑如果我们这样使用PageAdmin会发生什么:

ReactDOM.render(
    <PageAdmin backend="ajax" />,
    document.querySelector(".react")
);

面对这样的组件,如果没有文档,可能会诱使我们用一些其他配置数据替换Backend对象。这对不熟悉该组件的人来说似乎是合理的。而且,如果我们没有仔细研究我们所有的组件,我们也不能期望其他人知道这些属性应该是什么。

我们可以通过添加属性验证来防止这种情况。让我们给PageEd itor添加一些验证:

PageEditor.propTypes = {
    "id": React.PropTypes.number.isRequired,
    "title": React.PropTypes.string.isRequired,
    "body": React.PropTypes.string.isRequired,
    "onUpdate": React.PropTypes.func.isRequired,
    "onCancel": React.PropTypes.func.isRequired
};

我们已经导入了React对象,它暴露了一个PropTypes对象。这个对象包含一些验证器。当我们对PageEditor.propTypes指定一些验证器时,React 会在组件渲染时检查属性的类型。如果我们提供了错误的属性类型或省略了必需的属性,React 将发出警告。

警告看起来是这样的:

验证属性

有许多类型可供选择,其中简单的一些如下:

  • React.PropTypes.array

  • React.PropTypes.bool

  • React.PropTypes.func

  • React.PropTypes.number

  • React.PropTypes.object

  • React.PropTypes.string

如果你需要一个属性是必需的(这在大多数情况下可能是这样),你可以在末尾添加.isRequired。让我们用PageView的验证器来继续:

PageView.propTypes = {
    "title": React.PropTypes.string.isRequired,
    "onEdit": React.PropTypes.func.isRequired,
    "onDelete": React.PropTypes.func.isRequired
};

这甚至更简单,因为PageView使用的属性比PageEditor少。此外,Page相对简单:

Page.propTypes = {
    "id": React.PropTypes.number.isRequired,
    "onDelete": React.PropTypes.func.isRequired
};

我们不需要验证直接通过组件传递的属性。例如,PageEditor使用onUpdate。它通过Page传递,但Page不使用它,PageEditor使用,所以我们就在那里使用验证器。

然而,如果我们想要验证嵌套结构或更复杂的数据类型呢?我们可以尝试以下方法:

PageAdmin.propTypes = {
    "backend": function(props, propName, componentName) {
 if (props.backend instanceof Backend) {
 return;
 }

 return new Error(
 "Required prop `backend` is not a `Backend`."
 );
 }
};

我们期望backend属性是Backend类的一个实例。如果它不是,我们返回一个Error来描述为什么属性无效。我们还可以使用shape来验证嵌套属性:

Component.propTypes = {
    "page": React.PropTypes.shape({
 "id": React.PropTypes.number.isRequired,
 "title": React.PropTypes.string.isRequired,
 "body": React.PropTypes.string.isRequired
 })
};

我们对属性越具体,坏属性破坏接口的机会就越小。因此,养成总是定义它们的习惯是好的。

存储 cookies

你一定听说过 cookies。它们是一种与互联网一样古老的基于浏览器的存储机制,在电影中经常被滑稽地描述。以下是我们的使用方法:

document.cookie = "pages=all_the_pages";
document.cookie = "current=current_page_id";

document.cookie参数作为一个临时的字符串存储。你可以继续添加新的字符串,其中键和值通过=分隔,并且它们将在页面重新加载后存储,也就是说,直到你达到浏览器每个域名可以存储的 cookies 数量限制。如果你多次设置document.cookie,将会设置多个 cookies。

你可以用这样的函数再次读取 cookies:

var cookies = {};

function readCookie(name) {
    var chunks = document.cookie.split("; ");

    for (var i = chunks.length - 1; i >= 0; i--) {
        var parts = chunks[i].split("=");
        cookies[parts[0]] = parts[1];
    }

    return cookies[name];
}

export default readCookie;

整个 cookie 字符串通过分号读取并分割。然后,每个 cookie 被分割成等号,留下键和值。这些被存储在本地cookies对象中。未来的请求只需从本地对象中读取键。可以在任何时刻检查cookies对象以查看已设置的 cookies。

尝试browsercookielimits.squawky.net来测试你的浏览器可以处理什么。我正在运行一个现代版本的 Chrome,我可能每个域名可以存储 180 个 cookies,总共 4096 字节。4096 字节听起来并不多...

Cookies 通常不用于我们想要存储的数据类型。我们不得不在其他地方寻找。

注意

如果你想要了解更多关于如何使用 cookies 的信息,请访问developer.mozilla.org/en-US/docs/Web/API/Document/cookie

使用本地存储

我们将要查看的下一类存储是浏览器工具集的一个相对较新的添加。它被称为本地存储,它已经存在了一段时间。你可以按照以下方式向其中添加项:

localStorage.setItem("pages", "all_the_pages");

从中读取项比 cookies 简单:

localStorage.getItem("pages");

这将在页面重新加载或浏览器关闭后持久化数据。你可以存储比 cookies 多得多的数据(默认情况下,从 3MB 到 10MB),并且接口更容易使用。

那么,我们如何使用它来存储我们的页面?让我们对本地存储进行一些抽象:

export default {
    "get": function(key, defaultValue) {
        var value = window.localStorage.getItem(key);

        var decoded = JSON.parse(value);

        if (decoded) {
            return decoded;
        }

        return defaultValue;
    },

    "set": function(key, value) {
        window.localStorage.setItem(
            key, JSON.stringify(value)
        );
    }
};

一次,我们导出的是一个对象而不是一个类。这个对象有几个方法,它们都访问window.localStorage。直接引用它并不理想,但如果我们在其他地方都使用这种抽象,那么我认为这是可以接受的。

get方法从本地存储中拉出一个字符串值,并将其解析为 JSON 字符串。如果该值解析为任何非假值,我们就返回它,否则返回默认值。

set方法将值编码为 JSON,并存储它。

然后,我们可以在Backend类中使用以下抽象:

import LocalStore from "local-store";

class Backend {
    constructor() {
        this.pages = LocalStore.get("pages", []);
    }

    getAll() {
        return this.pages;
    }

    update(id, property, value) {
        this.pages = this.pages.map((page) => {
 if (page.id == id) {
 page[property] = value;
 }

 return page;
 });

 LocalStore.set("pages", this.pages);
    }

    delete(id) {
        this.pages = this.pages.filter(
 (page) => page.id !== id
 );

 LocalStore.set("pages", this.pages);
    }
}

export default Backend;

我们从一个构造函数开始,它从localStorage获取任何存储的页面。我们提供了一个默认的空数组,以防localStorage中缺少pages键。我们将其存储在this.pages中,以便我们稍后可以获取和修改它。

getAll方法这次要简单得多。它只是返回this.pages。而updatedelete方法则更有趣。update方法使用Array.map方法来应用对受影响页面对象的更新。我们必须将更新后的pages数组存储回本地存储,以便持久化更改。

类似地,delete修改了pages数组(这次使用简短的功能语法)并将修改后的数组存储回本地存储。我们必须用一些初始数据查看本地存储。你可以在开发者控制台中这样做:

localStorage.setItem("pages", JSON.stringify([
    {
       "id": 1,
       "title": "Home",
       "body": "..."
    },
    {
       "id": 2,
       "title": "About Us",
       "body": "..."
    },
    {
       "id": 3,
       "title": "Contact Us",
       "body": "..."
    },
    {
       "id": 4,
       "title": "Products",
       "body": "..."
    }
]));

如果你已经做了这些更改,并且刷新了页面,你应该能看到新的后端代码在起作用!

使用事件发射器

到目前为止,我们的组件通过方法调用与后端进行通信。这对小型应用来说是可以的,但当事情开始扩展时,我们可能会忘记做一些方法调用。

看看onUpdate,例如:

onUpdate(id, field, value) {
    this.props.backend.update(id, field, value);

    this.setState({
        "pages": this.props.backend.getAll()
    });
}

每次我们更改页面的状态时,我们都必须从后端获取更新后的页面列表。如果有多个组件向后端发送更新,我们的PageAdmin组件将如何知道何时获取新的页面列表?

我们可以转向基于事件的架构来解决这个问题。我们已经遇到过并使用过事件!回想一下我们创建页面编辑表单时做了什么。在那里,我们连接到输入事件,以便在输入值更改时更新页面。

这种架构使我们更接近单向数据流。我们可以想象我们的整个应用就像一个组件树,从单个根组件开始。当一个组件需要更新应用的状态时,我们不需要在组件的位置上编码状态更改。在过去,我们可能不得不引用特定的 CSS 选择器,或者在更新状态时依赖于兄弟元素的定位。

当我们开始使用事件时,任何组件都可以触发应用中的更改。多个组件也可以触发同一种类型的更改。我们将在后面的章节中更详细地探讨这个想法。

我们可以用同样的想法来通知组件数据发生变化。首先,我们需要下载一个事件发射器类:

$ npm install --save eventemitter3

现在,Backend可以扩展这个功能,提供我们想要的的事件功能:

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

        this.pages = LocalStore.get("pages", []);
    }

    getAll() {
        return this.pages;
    }

    update(id, property, value) {
        // ...update a page

        this.emit("update", this.pages);
    }

    delete(id) {
        // ...delete a page

        this.emit("update", this.pages);
    }
}

随着每个页面的更新或删除,后端将在自身上发出事件。在我们没有在PageAdmin中监听这些事件之前,这什么也不做:

constructor(props) {
    super(props);

    this.bind(
        "onUpdate",
        "onDelete"
    );

    this.state = {
        "pages": this.props.backend.getAll()
    };

    this.props.backend.on("update",
 (pages) => this.setState({pages})
 );
}

onUpdate(id, field, value) {
    this.props.backend.update(id, field, value);
}

onDelete(id) {
    this.props.backend.delete(id);
}

现在,我们可以移除对this.setState的多次调用,并在constructor中使用单个事件监听器来替换它们。我们还在setState调用上做了一些有趣的事情。这被称为对象解构,它允许{pages}变成{"pages":pages}

现在,我们可以开始使用这个后端来处理界面的许多不同部分,并且它们都将拥有准确、实时的数据。在几个不同的窗口中打开页面,并观察它们同时更新!

摘要

在本章中,我们探讨了如何保护我们的组件免受错误属性的影响。我们还看到使用 cookie 是多么容易,尽管它们在我们需要的方面有限。幸运的是,我们可以使用本地存储并将其整合到现有的后端和组件中。

最后,我们探索了使用事件将状态变化推送到所有感兴趣的组件。

在下一章中,我们将开始美化我们的组件。我们将探讨如何对它们进行样式化和动画处理,使我们的界面生动起来!

第四章 组件的样式和动画

在上一章中,你学习了如何在重新加载页面或重启浏览器后持久化页面。现在,这已经开始成为对我们有用的系统了。不幸的是,它仍然看起来很粗糙,没有样式。

这是因为,到目前为止,我们几乎完全忽略了组件中的样式。在本章中,我们将改变所有这些!

你将学习如何为组件元素添加自定义样式和类名。我们将为新旧组件添加动画。我们甚至将学习如何将两者结合起来创建高度可重用的样式和动画。

添加新页面

到目前为止,我们能够更改和从我们的内容管理系统移除页面。我们在上一章结束时通过在本地存储中播种序列化数组来结束,这样我们就可以看到它的实际效果。让我们退一步,通过界面创建新页面的方法。

首先,我们将添加一个insert方法并更新Backendconstructor方法:

constructor() {
    super();

    var pages = LocalStore.get("pages", []);

    this.id = 1;

    this.pages = pages.map((page) => {
        page.id = this.id++;
        return page;
    });
}

insert() {
    this.pages.push({
        "id": this.id,
        "title": "New page " + this.id,
        "body": ""
    });

    this.id++;

    LocalStore.set("pages", this.pages);

    this.emit("update", this.pages);
}

页面id值对我们来说并不重要,除了在 React 组件的上下文中。因此,当页面从本地存储加载时,重新生成它们是完全可以的。我们跟踪内部id值,以便在创建新页面时为新页面分配一个新的id值。

insert方法将一个新的页面对象推送到页面列表中。然后我们更新本地存储中的pages数据,以便在下次需要页面时可用。并且,与updatedelete方法一样,我们发出一个update事件,以便所有相关的组件都会更新其状态。

我们可以在PageAdmin中使用这个insert方法:

onInsert() {
    this.props.backend.insert();
}

render方法中,添加以下代码:

render() {
    return <div>
        <div>
 <button onClick={this.onInsert}>
 create new page
 </button>
 </div>
        <ol>
            ...
        </ol>
    </div>;
}

除了我们编写的其他代码外,界面看起来是这样的:

添加新页面

为组件添加样式

我们有几种方法可以改善我们组件的外观。以PageView组件为例。什么可以使它变得更好?也许如果我们增加字体大小并使用无衬线字体,标题将更容易阅读。也许我们可以增加每页周围的边距。

我们有几种不同的方式来样式化我们的组件。第一种是在PageViewrender方法中内联添加样式:

render() {
    var rowStyle = this.props.rowStyle || {
 "fontSize": "18px",
 "fontFamily": "Helvetica"
 };

 var labelStyle = this.props.labelStyle || {
 "whiteSpace": "nowrap"
 };

 var buttonStyle = this.props.buttonStyle || {
 "margin": "0 0 0 10px",
 "verticalAlign": "middle",
 };

    return <div style={rowStyle}>
        <label style={labelStyle}>
            {this.props.title}
        </label>
        <button
            style={buttonStyle}
            onClick={this.props.onEdit}>
            edit
        </button>
        <button
            style={buttonStyle}
            onClick={this.props.onDelete}>
            delete
        </button>
    </div>;
}

我们可以为想要我们的组件渲染的每个元素定义一组样式。对于外部的div元素,我们定义字体大小和家族。对于label标题,我们告诉浏览器不要换行。对于每个按钮,我们添加边距。每个样式对象都可以通过属性覆盖,归因于var value = value1 || value2的表示法。这是说如果value1是未定义的,则使用value2的简写。

我们还应该将这些样式应用到列表项上,以便数字以与标题相同的方式显示:

render() {
    var itemStyle = this.props.itemStyle || {
        "minHeight": "40px",
        "lineHeight": "40px",
 "fontSize": "18px",
 "fontFamily": "Helvetica"

    };

    return <div>
        <div>
            <button
                onClick={this.onInsert}>
                create new page
            </button>
        </div>
        <ol>
            {this.state.pages.map((page, i) => {
                return <li key={i} style={itemStyle}>
                    <Page
                        {...page}
                        onUpdate={this.onUpdate}
                        onDelete={this.onDelete}
                        />);
                </li>;
            })}
        </ol>
    </div>;
}

注意style对象有两对花括号?这就是我们定义对象作为属性的方式。在这种情况下,它是一个我们想要应用于每个列表项的样式对象。

更改和撤销

现在,我们可以为我们的编辑表单设置样式。让我们将修改指示符(星号)替换为一个模拟保存操作的按钮:

constructor(props) {
    super(props);

    this.state = {
        "changed": false
    };

    this.bind(
        "onCancel",
        "onSave",
        "onUpdate",
    );
}

render() {
    var cancelButtonStyle = null;
 var saveButton = null;

 if (this.state.changed) {
 cancelButtonStyle = this.props.cancelButtonStyle || {
 "margin": "0 0 0 10px"
 };

 saveButton = <button
 onClick={this.onCancel}>
 save
 </button>
 }

    return <form>
        <div>
            <input
                type="text"
                onChange={this.onUpdate}
                name="title"
                value={this.props.title}
                />
        </div>
        <div>
            <input
                type="text"
                onChange={this.onUpdate}
                name="body"
                value={this.props.body}
                />
        </div>
        {saveButton}
 <button
 onClick={this.onCancel}
 style={cancelButtonStyle}>
 cancel
 </button>
    </form>;
}

onSave(event) {
    event.perventDefault();
    this.props.onSave();
}

这给人一种点击保存按钮会保存一些内容的错觉,而保存实际上已经发生。这提出了一个有趣的问题——我们应该让取消按钮取消编辑吗?因为现在它只是一个伪装成取消按钮的后退按钮。我们还应该定义一个onSave函数并将其传递给这个组件。

要做到这一点,我们需要跟踪其初始状态。但我们在哪里可以获得这个初始状态呢?PageEditor组件通过属性接收页面详情,所以当前状态在PageEditor中与在Backend中相同。

也许我们应该在PageView隐藏和PageEditor显示时存储状态:

onEdit() {
    this.setState({
        "isEditing": true,
        "title": this.props.title
    });
}

当页面进入编辑模式时,我们存储未编辑的标题。我们应该将onCancel方法更改为实际取消更改:

onCancel() {
    this.props.onUpdate(
 this.props.id,
 "title",
 this.state.title
 );

    this.setState({
        "isEditing": false
    });
}

onSave() {
    this.setState({
        "isEditing": false
    });
}

当调用onCancel属性时,我们将页面标题设置为之前存储的未编辑标题。我们需要在构造函数中绑定这个新的onSave方法:

constructor(props) {
    super(props);

    this.state = {
        "isEditing": false
    };

    this.bind(
        "onEdit",
        "onDelete",
        "onCancel",
        "onSave"
    );
}

这确保了当稍后调用onSave属性时,this指的是页面组件。我们需要将这个新方法以属性的形式传递给PageEditor组件:

render() {
    if (this.state.isEditing) {
        return <PageEditor
            {...this.props}
            onCancel={this.onCancel}
            onSave={this.onSave}
            />;
    }

    return <PageView
        {...this.props}
        onEdit={this.onEdit}
        onDelete={this.onDelete}
        />;
}

现在,PageEditor的两个按钮不再调用this.props.onCancel,而是可以调用它们适用的方法:

if (this.state.changed) {
    cancelButtonStyle = this.props.cancelButtonStyle || {
       "margin": "0 0 0 10px"
    };

    saveButton = <button
        onClick={this. onSave}>
        save
    </button>
}

动画新组件

目前,新页面只是出现。没有微妙的动画来缓解它们的进入。让我们改变这一点!

我们将使用一个新的 React 组件来完成这个任务,我们可以在 React 的附加组件构建中找到它。回到你在第一章下载的 React 脚本,将所有对react.js的引用替换为react-with-addons.js

这使我们能够访问一个名为CSSTransitionGroup的新组件:

render() {
    var itemStyle = this.props.itemStyle || {
        "minHeight": "40px",
        "lineHeight": "40px",
 "fontSize": "18px",
 "fontFamily": "Helvetica"
    };

    return <div>
        <div>
            <button
                onClick={this.onInsert}>
                create new page
            </button>
        </div>
        <ol>
            <React.addons.CSSTransitionGroup
                transitionName="page"
                transitionEnterTimeout={150}
                transitionLeaveTimeout={150}>
                {this.state.pages.map((page, i) => {
                    return <li key={i} style={itemStyle}>
                        <Page
                            {...page}
                            onUpdate={this.onUpdate}
                            onDelete={this.onDelete}
                            />
                    </li>;
                })}
            </React.addons.CSSTransitionGroup>
        </ol>
    </div>;
}

这个新的容器组件会监视其子组件的变化。当添加新的子组件时,它们会被赋予几个 CSS 类名,这些类名可以应用 CSS 动画。我们需要将这个动画添加到相应的 CSS 样式:

.page-enter {
    opacity: 0.01;
    margin-left: -50%;
}

.page-enter.page-enter-active {
    opacity: 1;
    margin-left: 0;
    transition: all 150ms linear;
}

.page-leave {
    opacity: 1;
    margin-left: 0;
}

.page-leave.page-leave-active {
    opacity: 0.01;
    margin-left: 50%;
    transition: all 150ms linear;
}

由于我们指定了transitionName="page",React 在Page组件进入和离开PageAdmin时添加了page-enterpage-leave,注意我们样式中的150mstransitionEnterTimeout={150}匹配?它们需要相同。React 为这150ms添加了类名,如page-enter-active,然后移除它们。这确保了过渡只发生一次。

使用 CSS 过渡效果

现在是讨论 CSS 过渡的好时机。我们已使用它们从左侧淡入和滑动新的Page组件。如果你不熟悉它们通常的工作方式,代码可能会让人困惑且难以更改。

有几件事情你应该知道。第一点是你可以过渡单个 CSS 属性或所有属性:

.background-transition {
    background-color: red;
    font-size: 16px;
    transition-property: background-color;
}

.background-transition:hover {
    background: blue;
    font-size: 18px;
}

在这个例子中,我们只想过渡背景颜色。字体大小将立即从 16px 跳跃到 18px。或者,我们也可以过渡所有 CSS 属性:

.all-transition {
    transition-property: all;
}

我们已经看到了过渡持续时间,尽管只是简短地提到了。我们可以使用 mss 作为这些的单元:

.background-transition {
    transition-duration: 1s;
}

然后是时间函数。这些控制动画如何从 0% 到 100%。它们有时被称为曲线,因为它们通常是这样展示的:

与 CSS 过渡一起工作

线性是这些时间函数中最基本的,它均匀地从 0% 移动到 100%。它也是默认的 timing 函数。

备注

你可以在 easings.net 看到这些示例。

你也可以定义自己的曲线,形式为 cubic-bezier(x1, y1, x2, y2)。这目前有点高级,但无论如何都是好的。

过渡也可以延迟,因此它们只在经过一定时间后才会发生:

.background-transition {
    transition-delay: 1s;
}

总体来说,这些样式看起来是这样的:

.background-transition {
    transition-property: background;
    transition-duration: 1s;
    transition-timing-function: linear;
    transition-delay: 0.5s;
}

如前所述,你可以将这些属性捆绑成一个更小的集合:

.background-transition {
 transition: background 1s linear 0.5s;
}

并非所有属性都可以进行过渡。属性需要有一定的粒度。以下是一些常见的属性:

  • 背景(应用于颜色和位置)

  • 边框(应用于颜色、宽度和间距)

  • 底部

  • 裁剪

  • 颜色

  • 裁剪

  • 字体(应用于大小和粗细)

  • 高度

  • 左边距

  • 字母间距

  • 行高

  • 外边距

  • 最大高度

  • 最大宽度

  • 最小高度

  • 最小宽度

  • 不透明度

  • 轮廓(应用于颜色、偏移和宽度)

  • 内边距

  • 右边距

  • 文本缩进

  • 文本阴影

  • 顶部

  • 垂直对齐

  • 可见性

  • 宽度

  • 单词间距

  • z-index

使用 Sass 组织样式

样式表是内联组件样式的优秀替代品。CSS 作为一种寻找并应用于元素视觉特性的语言,非常具有表现力。

不幸的是,它也有缺点。CSS 的最大缺点是所有样式都在全局范围内。一些样式是继承的,并且应用于元素的样式经常冲突(并相互抵消)。

在小剂量下,碰撞是可以避免或管理的。在大剂量下,这些碰撞可能会削弱生产力。作为一种权宜之计,CSS 支持使用 !important 关键字。这通常会导致丑陋的解决方案,因为每个人都希望他们的样式是最重要的。

此外,还需要重复常见的值。直到最近,CSS 甚至不支持计算值。如果我们想使元素相对于其他元素具有绝对宽度(例如),我们必须使用 JavaScript。

这些是 Sass 试图解决的问题之一。它是一种 CSS 扩展语言(CSS + 其他特性),因此易于学习,也就是说,一旦你了解了 CSS。

Sass 样式表在使用浏览器之前需要编译成 CSS 样式表。安装 Sass 编译器很容易;执行以下命令来安装它:

$ npm install --save node-sass

完成这些后,我们就可以使用以下命令编译 Sass 样式表(以 .scss 结尾的文件):

$ node_modules/.bin/node-sass index.scss > index.css

考虑以下代码:

$duration: 150ms;
$timing-function: linear;

.page-enter {
    opacity: 0.01;
    margin-left: -50%;

    &.page-enter-active {
        opacity: 1;
        margin-left: 0;
        transition: all $duration $timing-function;
    }
}

.page-leave {
    opacity: 1;
    margin-left: 0;

    &.page-leave-active {
        opacity: 0.01;
        margin-left: 50%;
        transition: all $duration $timing-function;
    }
}

以下代码将被转换为 CSS,如下所示:

.page-enter {
    opacity: 0.01;
    margin-left: -50%;
}

.page-enter.page-enter-active {
    opacity: 1;
    margin-left: 0;
    transition: all 150ms linear;
}

.page-leave {
    opacity: 1;
    margin-left: 0;
}

.page-leave.page-leave-active {
    opacity: 0.01;
    margin-left: 50%;
    transition: all 150ms linear;
}

如果你更喜欢在浏览器中编译 React 组件,并且想用 Sass 做同样的事情,那么你可以安装以下内容:

$ npm install --save sass.js

然后,你需要将以下元素添加到页面的头部:

<script src="img/sass.sync.js"></script>
<style type="text/sass">
    $duration: 150ms;
    $timing-function: linear;

    .page-enter {
        opacity: 0.01;
        margin-left: -50%;

        &.page-enter-active {
            opacity: 1;
            margin-left: 0;
            transition: all $duration $timing-function;
        }
    }

    .page-leave {
        opacity: 1;
        margin-left: 0;

        &.page-leave-active {
            opacity: 0.01;
            margin-left: 50%;
            transition: all $duration $timing-function;
        }
    }
</style>
<script>
    var stylesheets = Array.prototype.slice.call(
 document.querySelectorAll("[type='text/sass']")
 );

 stylesheets.forEach(function(stylesheet) {
 Sass.compile(stylesheet.innerHTML, function(result) {
 stylesheet.type = "text/css";
 stylesheet.innerHTML = result.text;
 });
 });
</script>

这是一段 JavaScript 代码,用于查找具有type="text/sass"style元素。每个这些样式元素的内容都通过sass.js传递,并保存回样式元素。它们的类型被改回text/css,这样浏览器就会识别这些样式为 CSS。

注意

你应该只在开发中使用这种方法。它会给浏览器带来很多工作,这可以通过在生产环境中预先编译 Sass(使用如 Grunt、Gulp 和 webpack 等工具)来避免。

替代方案

我们还可以用几种其他方式来样式化和动画化 React 组件,并且它们都以微妙不同的方式处理这个问题。

CSS 模块

CSS 模块允许你定义只适用于局部上下文中单个元素的样式。它们看起来像常规 CSS 样式,但当它们应用于组件时,会被修改,使得分配给组件的类名是唯一的。你可以在glenmaddern.com/articles/css-modules上了解更多关于 CSS 模块的信息。

React 样式

React 样式是一种创建内联样式的方式,作为略微增强的对象。它不支持一些常见的 CSS 选择器,但在其他方面做得很好。你可以在github.com/js-next/react-style上了解更多关于它的信息。

摘要

在本章中,你学习了如何对 React 组件进行大小样式化。我们使用了内联样式、CSS 样式表,甚至 Sass 样式表。你还学习了如何使用 CSS 过渡来动画化子组件的进入和退出视图。

最后,我们简要地查看了一些替代技术,这些技术以略有不同的方式完成了我们在本章中做的事情。你可能更喜欢这些方法中的任何一个,但重要的是要认识到我们可以使用许多方法来样式化和动画化组件。

在下一章中,我们将把这些技能应用到深入研究材料设计中。接下来会有很多样式化和动画化的内容!

第五章。迈向材料设计!

在上一章中,我们探讨了如何样式化和动画化 React 组件的基础。我们可以让组件看起来像我们想要的样子,但我们希望它们看起来如何?

在本章中,我们将探讨所谓的材料设计。你将学习如何用一致的设计语言表达我们的界面,而不仅仅是组件。

你将看到组件式设计和视觉设计模式之间的主要交叉。材料设计非常详细,正如我们在本章中将要看到的。它详细描述了每种类型的组件(或表面)应该如何看起来、感觉和移动。这就是我们通过 React 理解的最小化设计方法的核心。现在,我们将从视觉角度应用这些经验教训。

理解材料设计

对我来说,“材料设计”这个名字唤起了时尚或工程的图像,其中不同的质感和图案具有独特的视觉或技术特征。我触摸的一切都是某种材料。我所看到的一切都是某种材料。所有这些材料都遵循物理定律并以熟悉的方式表现。

这些感觉,触摸和视觉,是我们与世界互动的基础。它们是广泛且不言而喻的常数。材料设计是一种旨在弥合物理世界和数字世界之间差距的语言,使用材料作为隐喻。

在这种语言中,材料的表面和边缘为我们提供了基于现实的视觉界面提示。在这种语言中,组件会立即对触摸做出反应,暗示它们可以做什么。

材料设计深受印刷设计的启发。然而,所有的字体、色彩和图像不仅仅是为了美观。它们创造了焦点、层次和意义。它强化了用户作为运动主要驱动的角色,使用有意义的运动来聚焦于界面的重要区域,始终提供适当的反馈。

表面

每个对象都由一个材料表面表示。这些对象的大小以设备无关像素(或dp,简称)来衡量。这是一个衡量用户输入的绝佳单位,因为它允许设计师设计独立于屏幕大小的界面。

它也可以根据设备的屏幕大小转换为绝对单位(如英寸或毫米)。

注意

你可以在en.wikipedia.org/wiki/Device_independent_pixel了解更多关于 dp 的信息。

表面被视为 3D 对象,具有宽度、高度和深度。所有表面都有 1 dp 的深度,但它们可以有任意宽度和高度。表面还可以重叠,因此它们之间有垂直偏移。

这种垂直偏移(或高度)允许分层,创造出类似于现实世界的深度感。表面上的内容,如排版和图像,平铺在表面上。把它想象成纸上的墨水,纸张的深度比打印在上面的墨水更容易注意到。

这种分层效果通过投射到下表面的阴影得到强调。屏幕作为光源,因此表面离它越近(在界面中位置越高),它们投射的阴影就越大。

注意

您可以在www.google.com/design/spec/what-is-material/elevation-shadows.html了解更多关于高度和分层的信息。

交互

表面提供了深度和层次结构,但应用程序的真实价值来自于通过界面呈现的内容。内容驱动交互。

材料表面应该对交互做出响应。播放视频、缩放照片或填写表单应该感觉自然。交互应该感觉自然,并且尽可能模仿现实世界中的感觉。

注意

您可以在www.google.com/design/spec/animation/responsive-interaction.html了解更多关于交互的信息。

运动

接口对用户交互做出响应的一种方式是通过有效利用运动。动画对移动界面(材料设计诞生的地方)来说并不陌生,但我们谈论的是对动画执行方式的深思熟虑的方法。

就像在现实世界中一样,当涉及到摩擦和动量等概念时,一些物体在交互时移动得更快或更慢。一些元素在按下时会压缩得更深,而其他元素几乎不会移动。

注意

您可以在www.google.com/design/spec/animation/meaningful-transitions.html了解更多关于运动的信息。

排版和图标

存在有关如何在材料界面中结构和着色文本内容的指南。大多数示例展示了 Roboto(这是标准的 Android 字体),但规则同样适用于其他清晰的字体。

类似地,还有关于如何通过界面创建和使用图标的指南。存在一套标准的图标(称为系统图标),这将帮助您开始。

注意

您可以从www.google.com/fonts/specimen/Roboto下载 Roboto 字体,以及从www.google.com/design/spec/style/icons.html#icons-system-icons获取系统图标。

保持清醒

第一次阅读材料设计规范(可在www.google.com/design/spec/material-design/introduction.html找到),你可能会感到有些不知所措。其中有很多细节,知道从哪里开始并不容易。

事实上,你不必记住所有的内容。我第一次阅读时感到非常紧张,但后来我意识到,重点不是要记住所有内容。当然,这有助于你在界面上的每一个选择时做出决定,但它并不是一个工具包或组件库。

材料设计是一种语言——一份活生生的文档。谷歌已经并且将继续对其进行调整,当你学习一门语言时,了解一些单词是有帮助的。然而,你不可能在一天之内学会一门语言,也不可能仅仅通过记忆固定短语就能学好它。

不,要学习一门语言,你需要每天都说它。你需要用心去学习语法,并努力克服你的错误,直到有一天你能够舒适地使用它,而不必有意识地记住每一条规则。在那一天,材料设计对你来说将变得像第二本能一样自然。

注意

我鼓励你在继续之前至少阅读一遍材料设计规范。记住,你可以在www.google.com/design/spec/material-design/introduction.html找到它。即使你忘记了其中的一些或大部分内容,也没有关系。阅读它的目的是,当你记住规范的部分内容时,一些决策将对你来说显得更加自然,因为各个部分更容易结合起来。

材料设计 lite

说了这么多,还有一些工具可以帮助你开始。我们将首先查看的工具被称为材料设计 lite。超过 120 位贡献者联合起来,在材料设计语言中创建了一套可重用的组件。

名称中的“lite”部分主要来自两个方面:

  • 它是框架无关的,这意味着你可以使用它而无需包含像 jQuery 或 Angular 这样的东西

  • 当它被 gzip 压缩时,它的大小不到 30 KB

让我们通过自定义默认模板的颜色来试一试。前往www.getmdl.io/customize/index.html并选择几种颜色。材料设计中的调色板由主色和强调色组成。你还可以在你的设计中使用主色的某些色调,但我们现在不必担心这些。

当你从色轮中选择颜色时,你可能注意到一些其他颜色消失了。色轮隐藏了与已选颜色对比度不足的颜色。

你应该选择你想要在设计中使用的颜色。我在下面的截图中选择了两色,标记为12

材料设计 lite

在轮子下方,你会看到一个链接元素。这个元素指向一个托管 CSS 文件。如果你将它放置在你的 HTML 页面头部,这些颜色就会应用到你的材料设计元素上。我选择了靛蓝和粉色,这反映在 URL 中:

<link rel="stylesheet" href="https://storage.googleapis.com/code.getmdl.io/1.0.6/material.indigo-pink.min.css" />

你可以选择保存此文件并本地提供服务。目前,我将直接将其包含在我的 HTML 页面头部。

我们还需要包含一个 JavaScript 文件,以及一个定义了一些自定义字体的 CSS 文件:

<script src="img/material.min.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">

在我们开始应用一些材料设计样式到我们的界面时,让我们给我们的按钮元素添加一些类名。我们将从PageAdmin开始:

render() {
    var addButton ClassNames = [
 "mdl-button",
 "mdl-js-button",
 "mdl-button--fab",
 "mdl-js-ripple-effect",
 "mdl-button--colored"
 ].join(" ");

    return (
        <div>
            <div>
                <button
                    onClick={this.onInsert}
                    className={addButtonClassNames}>
                    <i className="material-icons">add</i>
                </button>
            </div>
            <ol>
                {this.state.pages.map((page) => {
                    return <li key={page.id} style={{
                            "fontSize": "18px",
                            "fontFamily": "Helvetica",
                            "minHeight": "40px",
                            "lineHeight": "40px"
                        }}>
                        <Page
                            {...page}
                            onUpdate={this.onUpdate}
                            onDelete={this.onDelete} />
                    </li>;
                })}
            </ol>
        </div>
    );
}

所有材料设计轻量级类名都以mdl-开头。我们添加了一些定义浮动操作按钮(或简称FAB)一些视觉样式的类名。

这些样式让我们的添加按钮看起来好多了:

材料设计轻量级

然而,这不是一个很好的布局。我们应该更好地定位按钮,并为Page组件添加一些边界。实际上,我们应该开始从我们计划拥有的不同部分/页面来思考这个 CMS 界面。

在开始时,这个管理界面不会对所有人开放,所以也许我们应该有一个登录页面。那么,我们如何在这个界面的不同部分之间导航呢?也许我们应该添加一些导航。

当我们访问www.getmdl.io/templates/index.html时,我们可以看到一些不同的起始布局,我们可以使用。我真的很喜欢仪表板布局的外观,所以我认为我们可以基于这个布局来构建导航。

然后,还有文本密集型网页布局,它包含一系列卡片组件。这些看起来非常适合我们的Page组件。让我们开始工作吧!

创建登录页面

我们将首先创建一个简单的登录页面,不包含任何服务器端验证(目前是这样)。我们将将其拆分为单独的login.html页面和login.js文件:

<!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="example.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"
                },
                "baseURL": "../",
                "defaultJSExtensions": true
            });

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

我对 SystemJS 的配置做了一些修改。我们不再列出每个单独的文件,现在我们将根目录设置为baseURL。这意味着我们需要改变除了reactreact-dom之外的所有内容的导入方式:

import React from "react";
import ReactDOM from "react-dom";
import Nav from "src/nav";
import Login from "src/login";

var layoutClassNames = [
    "demo-layout",
    "mdl-layout",
    "mdl-js-layout",
    "mdl-layout--fixed-drawer"
].join(" ");
ReactDOM.render(
    <div className={layoutClassNames}>
        <Nav />
        <Login />
    </div>,
    document.querySelector(".react")
);

再次,我们将使用一些材料设计轻量级仪表板类名,这些我们在example.css中定义过:

html, body {
    font-family: "Roboto", "Helvetica", sans-serif;
}

.demo-avatar {
    width: 48px;
    height: 48px;
    border-radius: 24px;
}

.demo-layout .mdl-layout__header .mdl-layout__drawer-button {
    color: rgba(0, 0, 0, 0.54);
}

.mdl-layout__drawer .avatar {
    margin-bottom: 16px;
}

.demo-drawer {
    border: none;
    position: fixed;
}

.demo-drawer .mdl-menu__container {
    z-index: -1;
}

.demo-drawer .demo-navigation {
    z-index: -2;
}

.demo-drawer .mdl-menu .mdl-menu__item {
    display: -webkit-box;
    display: -webkit-flex;
    display: -ms-flexbox;
    display: flex;
    -webkit-box-align: center;
    -webkit-align-items: center;
    -ms-flex-align: center;
    align-items: center;
}

.demo-drawer-header {
    box-sizing: border-box;
    display: -webkit-box;
    display: -webkit-flex;
    display: -ms-flexbox;
    display: flex;
    -webkit-box-orient: vertical;
    -webkit-box-direction: normal;
    -webkit-flex-direction: column;
    -ms-flex-direction: column;
    flex-direction: column;
    -webkit-box-pack: end;
    -webkit-justify-content: flex-end;
    -ms-flex-pack: end;
    justify-content: flex-end;
    padding: 16px;
}

.demo-navigation {
    -webkit-box-flex: 1;
    -webkit-flex-grow: 1;
    -ms-flex-positive: 1;
    flex-grow: 1;
}

.demo-layout .demo-navigation .mdl-navigation__link {
    display: -webkit-box !important;
    display: -webkit-flex !important;
    display: -ms-flexbox !important;
    display: flex !important;
    -webkit-box-orient: horizontal;
    -webkit-box-direction: normal;
    -webkit-flex-direction: row;
    -ms-flex-direction: row;
    flex-direction: row;
    -webkit-box-align: center;
    -webkit-align-items: center;
    -ms-flex-align: center;
    align-items: center;
    font-weight: 500;
}

.demo-navigation .mdl-navigation__link .material-icons {
    font-size: 24px;
    color: rgba(255, 255, 255, 0.56);
    margin-right: 32px;
}

.demo-content {
    max-width: 1080px;
}

.demo-card-wide.mdl-card {
    width: 512px;
    min-height: auto !important;
    margin: 16px;
}

.demo-card-wide > .mdl-card__title {
    color: #fff;
    background: #263238;
}

.demo-card-wide > .mdl-card__menu {
    color: #fff;
}

.react {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
}

.mdl-layout__content {
    overflow: visible !important;
    position: relative;
    z-index: 10 !important;
}

.mdl-button--fab {
    position: fixed;
    bottom: 16px;
    right: 16px;
    z-index: 10 !important;
}

这些样式主要来自仪表板布局文件,尽管我已经删除了我们不需要的样式。注意.react容器元素是如何拉伸到窗口边界的。

注意

理想情况下,我们希望将每个组件内部的样式隔离出来。这可以通过多种方式实现(我们之前在上一章中简要讨论过)。尝试将每个这些组件的样式拉入组件本身。如果大部分样式来自打包的 MDL 源文件,这可能有点困难。

让我们来看看Nav组件:

import React from "react";
import ReactDOM from "react-dom";

export default (props) => {
    var drawerClassNames = [
        "demo-drawer",
        "mdl-layout__drawer",
        "mdl-color--blue-grey-900",
        "mdl-color-text--blue-grey-50"
    ].join(" ");

    var navClassNames = [
        "demo-navigation",
        "mdl-navigation",
        "mdl-color--blue-grey-800"
    ].join(" ");

    var iconClassNames = [
        "mdl-color-text--blue-grey-400",
        "material-icons"
    ].join(" ");

    var buttonIconClassNames = [
        "mdl-color-text--blue-grey-400",
        "material-icons"
    ].join(" ");

    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">
                    <i className={buttonIconClassNames}
                        role="presentation">
                        lock
                    </i>
                    Login
                </a>
                <a className="mdl-navigation__link"
                    href="/examples/page-admin.html">
                    <i className={buttonIconClassNames}
                        role="presentation">
                        pages
                    </i>
                    Pages
                </a>
            </nav>
        </div>
    );
};

这个新的Nav组件与我们迄今为止创建的其他 React 组件大不相同。首先,它没有内部状态,只渲染静态内容。它不是一个类,而是一个普通的箭头函数。自 React 0.14 以来,将函数传递给ReactDOM.render(无论是直接还是间接)已经成为可能。

注意

你可能还想将类数组移出使用它们的函数之外,因为它们不太可能经常改变。这样可以将它们与每个组件的动态部分隔离开来,并减少每次组件渲染时的工作量。

为了简化,我将它们留在了render函数内部,但你可以根据需要将它们移动到扩展或自定义组件时。

当涉及到无状态组件时,这种风格要简单得多。这是一个随着时间的推移我们将重复的模式,所以请留意它!

然后,我们需要创建Login组件:

import React from "react";
import ReactDOM from "react-dom";

export default (props) => {
    var contentClassNames = [
 "mdl-layout__content",
 "mdl-color--grey-100"
 ].join(" ");

 var gridClassNames = [
 "mdl-grid",
 "demo-content"
 ].join(" ");

 var fieldClassNames = [
 "mdl-textfield",
 "mdl-js-textfield"
 ].join(" ");

    return <div className={contentClassNames}>
        <form className={gridClassNames}>
            <div className={fieldClassNames}>
                <input className="mdl-textfield__input"
                       type="text" />
                <label className="mdl-textfield__label"
                       htmlFor="sample1">
                    Email...
                </label>
            </div>
            <div className={fieldClassNames}>
                <input className="mdl-textfield__input"
                       type="text" />
                <label className="mdl-textfield__label"
                       htmlFor="sample1">
                    Password...
                </label>
            </div>
        </form>
    </div>;
};

如你所见,输入元素的工作方式与往常一样,增加了几个特殊的 MDL 类。

这为我们提供了一个令人愉悦的登录页面:

创建登录页面

更新页面管理员

我们需要将类似的变化应用到现在分开的page-admin.htmlpage-admin.js文件上。让我们从 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="example.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"
 },
 "baseURL": "../",
                "defaultJSExtensions": true
            });

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

这几乎与login.html完全相同,只是在这个页面上我们加载了不同的引导文件。然而,在page-admin.js中存在显著差异:

import React from "react";
import ReactDOM from "react-dom";
import Nav from "src/nav";
import Backend from "src/backend";
import PageAdmin from "src/page-admin";

var backend = new Backend();

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

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

我们仍然加载mdl-类和 HTML 结构,但我们还包含了之前包含的相同的Backend引导。现在PageAdmin组件的render方法看起来非常不同:

render() {
    var contentClassNames = [
 "mdl-layout__content",
 "mdl-color--grey-100"
 ].join(" ");

 var addButtonClassNames = [
 "mdl-button",
 "mdl-js-button",
 "mdl-button--fab",
 "mdl-js-ripple-effect",
 "mdl-button--colored"
 ].join(" ");

    return (
        <div className={contentClassNames}>
            <button onClick={this.onInsert}
                className={addButtonClassNames}>
                <i className="material-icons">add</i>
            </button>
            {this.state.pages.map((page) => {
    return (
    <Page {...page}
   key={page.id}
   onUpdate={this.onUpdate}
   onDelete={this.onDelete} />
    );
    })}
        </div>
    );
}

最后,我们需要更新PageView组件:

render() {
    var cardClassNames = [
 "demo-card-wide",
 "mdl-card",
 "mdl-shadow--2dp"
 ].join(" ");

 var buttonClassNames = [
 "mdl-button",
 "mdl-button--icon",
 "mdl-js-button",
 "mdl-js-ripple-effect"
 ].join(" ");

    return (
        <div className={cardClassNames}>
            <div className="mdl-card__title">
                <h2 className="mdl-card__title-text">
                    {this.props.title}
                </h2>
            </div>
            <div className="mdl-card__supporting-text">
                {this.props.body}
            </div>
            <div className="mdl-card__menu">
                <button className={buttonClassNames}
                    onClick={this.props.onEdit}>
                    <i className="material-icons">edit</i>
                </button>
                <button className={buttonClassNames}
                    onClick={this.props.onDelete}>
                <i className="material-icons">delete</i>
            </button>
        </div>
    </div>;
}

这些更改对我们的功能没有产生重大影响,除了 CSS 和 HTML 的变化。我们仍然以完全相同的方式创建页面。此外,我们仍然以完全相同的方式编辑和删除它们。我们只是将新的视觉元素应用到已经功能齐全的界面中。

页面管理员部分现在应该看起来像这样:

更新页面管理员

它比以前好得多!需要注意的是,我们还没有为PageEdit组件添加样式,所以请将其视为下一次练习的内容。

注意

请仔细注意示例代码文件。自从上一章以来,有很多变化,包括分割示例文件以及使用 SystemJS 以不同的方式加载所有内容。如果不进行这些更改,就无法从上一章的代码继续,并期望本章的示例能够直接工作。

替代资源

在我们结束之前,我想分享一些可能对你有帮助的资源。

Font Squirrel

在本章中,我们使用了 Roboto。更准确地说,MDL 指令告诉我们如何在 Google Webfonts 上嵌入 Roboto 的链接。如果你希望使用自己的自定义字体,那么你可能需要将它们转换。

你可以在 Font Squirrel 转换字体文件:

Font Squirrel

访问www.fontsquirrel.com/tools/webfont-generator并上传你的字体文件。你将开始下载转换后的文件,附带一些有用的 CSS 文件以帮助你开始。我们没有时间涵盖所有自定义字体的复杂性,但下载的示例文件应该能让你走上正确的道路。

材料 UI

我们在 MDL CSS/JavaScript 和 React 之间创建了一些混合。这不一定总是按你的意愿工作或那么优雅。在这种情况下,请查看www.material-ui.com

材料 UI 提供了大量的组件供选择,如下面的截图所示:

Material UI

这是一个构建在 React 之上的材料设计组件目录。我们可能会在我们的 CMS 中使用其中的一些,但我将确保在这样做时进行解释。查看它提供的组件并决定你是否更喜欢比我们迄今为止的代码更纯粹的材料设计方法,这是值得的。

摘要

在本章中,我们快速浏览了材料设计的一些概念。你学会了将其视为一种随着时间的推移我们必须学习的不断变化的语言。我们使用一种新的 React 组件形式(一个函数)实现了全局导航组件。我们还实现了一个登录页面,它需要服务器端验证,并且让我们的PageAdmin组件看起来更好!

在下一章中,我们将探讨如何在不重新加载页面的情况下更改视图,以及我们可以做一些使体验变得美丽的事情。

第六章。更改视图

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

在本章中,你将学习如何在不重新加载页面的情况下更改部分。我们将使用这些知识来创建我们 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 构建一些东西,并与他人分享你的经验。

posted @ 2025-09-08 13:03  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报