React 入门指南(二)
四、构建 React Web 应用
Electronic supplementary material The online version of this chapter (doi:10.1007/978-1-4842-1245-5_4) contains supplementary material, which is available to authorized users.
在前三章中,您已经获得了关于 React 的大量信息。从 React 是什么以及它与其他 JavaScript 和用户界面框架有何不同开始,您就为理解 React 的工作方式打下了坚实的基础。从那里,您了解了 React 的核心概念和它的特性。引入了组件创建和渲染生命周期之类的东西。在最后一章中,你被介绍给了 React 世界中一个强大的租户,JSX。通过 JSX,您看到了与普通的 JavaScript 实现相比,如何以一种更易接近、更易维护的方式简洁地创建 React 组件。
本章将展示如何通过考虑一个非 React 应用并将其分解成你需要的组件来构建一个 React 应用。然后,你就可以将它拆分到 React 应用中,你会看到 React 甚至可以为一个规模不及脸书或 Instagram 的应用带来的价值。
概述应用的基本功能
有几种方法可以概述应用的基本功能,这些功能将被转移到 React 应用中。一种方法是用线框设计。如果您没有一个活动的 web 应用,而是考虑用 React 从头开始创建应用结构,这将非常有用。这个线框化过程对于任何应用来说显然都是重要的,但是对于确定应该在哪里将应用拆分成不同的组件有很大的帮助。
在开始绘制线框之前,您需要一个应用的概念。我创建了一个锻炼日记/日志,在那里我可以存储各种锻炼并查看我的努力历史。这类项目是不同框架如何一起工作并集成到工作流中的一个很好的例子。您的示例应用可能有所不同,但出于本书的目的,您将遵循锻炼应用主题。接下来是头脑风暴和构建应用的思考过程。
现在,您对自己的应用有了一个想法。您需要确定代表该应用全貌的主要功能领域。对于这个锻炼应用,您需要一种方法让用户通过应用的身份验证,因为每个用户都希望记录自己的锻炼数据。一旦用户通过身份验证,还应该有一个页面或表单,用户可以定义和分类他们将记录的锻炼。这将是一些允许定义的名称和类型,如“时间”、“最大重量”、“重复次数”。这些不同的类型将在下一部分发挥作用,允许用户存储他们的锻炼。当他们存储健身程序且类型与时间相关时,您可以选择使用特定的表格字段来记录完成工作所需的时间。最大重量和重复次数的类似特定字段也是可用的,但仅针对特定工作类型显示。这种类型的特殊性允许用户在应用的历史记录部分对他们的锻炼进行不同的分类。也许他们甚至可以随着时间的推移为每次锻炼规划不同的努力。
现在你所拥有的是一个基本的、散文式的应用功能概述。现在,您可能在 React 思维中看到了这一点,但是您需要进入下一步,将该应用视为一个线框。
从组件的角度思考
基于上一节中创建的大纲,您现在将遇到如何构建应用的两个场景。如前所述,一种方法是创建遵循应用轮廓的线框。这给了您一个新的开始,以确定在哪里可以创建适合新 React 应用的组件。另一种方法是将结构建立在现有应用及其源代码的基础上,以便将功能分解成组件。您将首先看到应用的一组线框,然后您将看到一个现有应用的示例,该应用需要重写为 React 应用。
线框
创建线框时,您可以选择使用餐巾背面、MS Paint 或任何数量的工具来帮助您以描述体验的图像表达您的想法。下面是我决定分成 React 组件的应用部分。所有组件的根是应用,它将是以下所有嵌套组件的父组件。如果您选择不使用线框,而是更喜欢使用现有的代码来剖析您的应用,那么您可以浏览这一小节,并从“重写现有的应用”开始,来发现从组件的角度来思考的见解。
图 4-1 所示的登录界面是一个简单的认证组件。这实际上是一个完整的组件。实际上,您可以选择将该组件作为两部分身份验证组件之一。
图 4-1。
Sign In component wireframe
该登录组件可能不需要任何子组件,因为您可能会将该表单发送到您的身份验证服务器进行验证。可能构成该身份验证部分的另一个 React 组件是创建帐户屏幕。
在创建帐户组件中,如图 4-2 所示,您可以看到还有一个简单的表单,就像登录表单一样。这里的区别在于您需要一个密码验证组件。这将确保您拥有的任何密码规则都得到实施,并且还会检查第二个密码字段以确保值匹配。在您的应用中,您还可以选择包含一个 reCAPTCHA 或其他组件,以确保创建帐户的人不是机器人。
图 4-2。
Account creation component wireframe
与帐户创建组件中的密码验证子组件一起,您需要确保输入的用户名是惟一的,并且在您的系统中可用。因此,即使这个简单的形式也可以使用 React 分解成更多的原子组件。这将允许你维护一个特定的功能,并保持每个动作与应用的其他部分相分离(图 4-2 )。
应用线框的下一部分是定义健身程序部分(图 4-3 )。这可以分为至少两个决定性的部分。一旦通过身份验证,应用的每个视图都将包含一个导航菜单。这个菜单本身就是一个组件,它将控制应用的哪个部分被呈现。除了导航菜单组件,还有健身程序定义组件。这将保存允许您存储新健身程序定义的表单,当您决定记录您已完成的健身程序时,您将能够返回该表单。这个表单也是您想要为 React 应用创建的一个组件。
图 4-3。
Define a Workout component wireframe
在定义健身程序部分之后,下一部分(称为记录健身程序)将保留您在上一部分看到的相同导航组件(图 4-4 )。除了导航组件之外,还有一个表格,用于控制您要记录的锻炼以及您要记录的运动量。这可能是一个单一的组成部分,但你可能会发现创建一个下拉菜单的可用锻炼更好。
图 4-4。
Store workout component wireframe
应用的最后一部分是“健身程序历史记录”部分(图 4-5 )。此部分保留了导航组件,并显示了一个表格,或一个列表视图,如果你选择,所有你的锻炼。这个表本身就是一个组件,所以请记住,在将来的版本中,您可能希望用一个子组件来扩展这个组件。这个子组件可以对历史进行搜索或排序,因此它应该具有处理该功能的可用属性。
图 4-5。
Workout History component wireframe
重写现有的应用
在本节中,您将看到一个可以使用 React 重写的现有应用。同样,第一步是确定在应用中何处可以创建组件或子组件,就像您在线框示例中看到的那样。
第一部分是身份验证组件,由登录子组件和创建帐户组件组成。如果您研究清单 4-1 中显示基本 HTML 和 jQuery 应用的例子,您应该能够确定在哪里可以创建组件。
Listing 4-1. Basic Markup for Authentication in Your Existing Application
<div id="signInForm" class="notSignedIn">
<label for="username">Username:</label>
<input type="text" id="username">
<label for="password">Password:</label>
<input type="text" id="password">
<button id="signIn">Sign In</button>
</div>
<div id="createAccount" class="notSignedIn">
<label for="username">Username:</label>
<input type="text" id="username">
<label for="password">Password:</label>
<input type="text" id="password">
<label for="password">Confirm Password:</label>
<input type="text" id="confpassword">
<button id="signIn">Create Account</button>
</div>
使用 jQuery 的认证机制
$("#signIn").on("click", function() {
// do authentication
$(".notSignedIn").hide();
$(".signedIn").show();
});
你可以看到,这显然有两个部分。也许您可以想象创建一个如下所示的组件。
<Authentication>
<SignIn />
<CreateAccount />
</Authentication>
这正是将在下一节中创建的组件。当然,还有实际执行身份验证的功能,这是您必须考虑的,但从基本意义上来说,这就是组件的样子。
下一部分是导航菜单,一旦认证完成,导航菜单将在整个应用中共享。
<ul id="navMenu">
<li><a href="#defineWorkouts">Define Workouts</a></li>
<li><a href="#logWorkout">Log Workout</a></li>
<li><a href="#viewHistory">View History</a></li>
<li><a href="#logout" id="logout">Logout</a></li>
</ul>
这个导航菜单将在 JSX 中被重写,这样它就可以在每个需要它的组件中被重用。应用的 jQuery/HTML 版本的下一部分是基本的可提交区域,它们从特定字段中获取值,并在单击时提交这些值。例如,“定义健身程序”部分类似于清单 4-2 。
Listing 4-2. Save a Workout Definition in HTML/jQuery
<div id="defineWorkouts" class="tabview">
<label for="defineName">Define Name</label>
<input type="text" id="defineName">
<label for="defineType">Define Type</label>
<input id="defineType" type="text">
<label for="defineDesc">Description</label>
<textarea id="defineDesc" ></textarea>
<button id="saveDefinition">Save Definition</button>
</div>
其他两个部分,记录锻炼和锻炼历史,遵循相同的形式,除了有一部分组件来自存储的锻炼(列表 4-3 和 4-4 )。
Listing 4-3. The Record a Workout Section—Different Workouts Are Available from Defined Workouts in #chooseWorkout and Pulled from a Data Store
<div id="logWorkout" class="tabview">
<label for="chooseWorkout">Workout:</label>
<select name="" id="chooseWorkout">
<!-- populated via script -->
</select>
<label for="workoutResult">Result:</label>
<!-- input based on the type of the workout chosen -->
<input id="workoutResult" type="text" />
<input id="workoutDate" type="date" />
<label for="notes">Notes:</label>
<textarea id="notes"></textarea>
</div>
Listing 4-4. Workout History Based on All the Work Recorded and Pulled from a Data Store
<div id="viewHistory" class="tabview">
<!-- dynamically populated -->
<ul id="history">
</ul>
</div>
现在你可以看到,这些原子或亚原子的代码片段中的每一个都代表了生成用户界面组件的单一代码路径。这正是您想要的,以便将这些功能部分分割成它们自己的组件。这个例子是一个简单的锻炼日志应用。尝试检查您自己的源代码,并通过编目您需要创建哪些组件来为重写做准备。
为您的应用创建必要的组件
在前面的部分中,您检查了一个线框和一个现有的应用,以确定您希望将应用的哪些功能拆分为 React 组件,或者您至少可以直观地看到这样做有什么意义。在本节中,您将采取下一步,开始使用 React 代码隔离这些组件,以便开始构建您的应用。
首先,您将创建授权组件。从线框或代码示例中可以看出,这个组件由两个子组件组成— SignIn和CreateAccount。如果您愿意,整个应用可以放在一个文件中,但是出于可维护性的考虑,谨慎的做法是将组件分离到它们自己的文件中,并利用 browserify 或 webpack 之类的工具将这些文件模块化。首先是signin.jsx档,其次是createaccount.jsx(列表 4-5 和 4-6 )。
Listing 4-5. The signin.jsx File
var React = require("react");
var SignIn = React.createClass({
render: function() {
return (
<div>
<label htmlFor="username">Username
<input type="text" id="username" />
</label>
<label htmlFor="password">Password
<input type="text" id="password" />
</label>
<button id="signIn" onClick={this.props.onAuthComplete.bind(null, this._doAuth)}>Sign In</button>
</div>
);
},
_doAuth: function() {
return true;
}
});
module.exports = SignIn;
Listing 4-6. The createaccount.jsx File
var React = require("react");
var CreateAccount = React.createClass({
render: function() {
return (
<div>
<label htmlFor="username">Username:
<input type="text" id="username" />
</label>
<label htmlFor="password">Password:
<input type="text" id="password" />
</label>
<label htmlFor="password">Confirm Password:
<input type="text" id="confpassword" />
</label>
<button id="signIn" onClick={this.props.onAuthComplete.bind( null, this._createAccount)}>Create Account</button>
</div>
);
},
_createAccount: function() {
// do creation logic here
return true;
}
});
module.exports = CreateAccount;
这两个组件都很简单,JSX 标记看起来类似于上一节中在 jQuery 和 HTML 应用中创建的标记。不同的是,您不再看到使用 jQuery 绑定到按钮。在它的位置,有一个onClick绑定,然后调用对this.props.onAuthComplete的引用。这可能看起来很奇怪,但是一旦您看到父应用组件,它将指示如何通过每个子组件处理授权状态。清单 4-7 提供了一个简单的组件——Authentication——它包含两个子认证组件。这些子组件是可用的,因为在它们被定义的文件中,我们通过利用module.exports. module.exports导出了组件对象,这是一种 CommonJS 机制,允许您导出您定义的对象。一旦在后续模块中使用require()加载了该对象,您就可以访问它了。
Listing 4-7. The auth.jsx File
var React = require("react");
var SignIn = require("./signin.jsx");
var CreateAccount = require("./createaccount.jsx");
var Authentication = React.createClass({
render: function() {
return (
<div>
<SignIn onAuthComplete={this.props.onAuthComplete}/>
<CreateAccount onAuthComplete={this.props.onAuthComplete}/>
</div>
);
}
})
module.exports = Authentication;
现在您有了认证,它由两个子组件组成— SignIn和CreateAccount。从这里开始,您需要应用的下一个主要部分,这是在您对应用进行身份验证之后发生的所有事情。同样,这个过程将被分成适当的组件,每个组件都包含在自己的模块中(列表 4-8 )。
Listing 4-8. The navigation.jsx File
var React = require("react");
var Navigation = React.createClass({
render: function() {
return (
<ul>
<li><a href="#" onClick={this.props.onNav.bind(null, this._nav("define"))}>Define A Workout</a></li>
<li><a href="#"onClick={this.props.onNav.bind(null, this._nav("store"))}>Record A Workout</a></li>
<li><a href="#"onClick={this.props.onNav.bind(null, this._nav("history"))}>View History</a></li>
<li><a href="#" onClick={this.props.onLogout}>Logout</a></li>
</ul>
);
},
_nav: function( view ) {
return view;
}
});
module.exports = Navigation;
清单 4-8 显示了Navigation组件。您会注意到每个导航元素都有一个到onClick事件的绑定。对于Logout,这是一个对注销机制的简单调用,它作为属性传递给这个Navigation组件。对于其他导航部分,这个示例展示了如何在本地设置一个值并将其传递给父组件。这是通过在_nav功能中设置一个值来实现的。一旦我们编写了它,您将会看到它在父组件中被引用。现在,您需要创建用于定义、存储和查看锻炼历史的模块和组件。这些如清单 4-9 至 4-11 所示。
Listing 4-9. The define.jsx File
var React = require("react");
var DefineWorkout = React.createClass({
render: function() {
return (
<div id="defineWorkouts" >
<h2>Define Workout</h2>
<label htmlFor="defineName">Define Name
<input type="text" id="defineName" />
</label>
<label htmlFor="defineType">Define Type
<input id="defineType" type="text" />
</label>
<label htmlFor="defineDesc">Description</label>
<textarea id="defineDesc" ></textarea>
<button id="saveDefinition">Save Definition</button>
</div>
);
}
});
module.exports = DefineWorkout;
DefineWorkout组件只是简单的输入和一个保存定义按钮。如果您通过 API 将这个应用连接到一个数据存储中,那么您会希望向 Save Definition 按钮添加一个onClick函数,以便将数据存储在适当的位置。
Listing 4-10. The store.jsx File
var React = require("react");
var Option = React.createClass({
render: function() {
return <option>{this.props.value}</option>;
}
});
var StoreWorkout = React.createClass({
_mockWorkouts: [
{
"name": "Murph",
"type": "fortime",
"description": "Run 1 Mile \n 100 pull-ups \n 200 push-ups \n 300 squats \n Run 1 Mile"
},
{
"name": "Tabata Something Else",
"type": "reps",
"description": "4 x 20 seconds on 10 seconds off for 4 minutes \n pull-ups, push-ups, sit-ups, squats"
}
],
render: function() {
var opts = [];
for (var i = 0; i < this._mockWorkouts.length; i++ ) {
opts.push(<Option value={this._mockWorkouts[i].name} />);
}
return (
<div id="logWorkout" class="tabview">
<h2>Record Workout</h2>
<label htmlFor="chooseWorkout">Workout:</label>
<select name="" id="chooseWorkout">
{opts}
</select>
<label htmlFor="workoutResult">Result:</label>
<input id="workoutResult" type="text" />
<input id="workoutDate" type="date" />
<label htmlFor="notes">Notes:</label>
<textarea id="notes"></textarea>
<button>Store</button>
</div>
);
}
});
module.exports = StoreWorkout;
StoreWorkout是一个组件,同样包含简单的表单输入,帮助您记录锻炼情况。有趣的是,现有锻炼的模拟数据会动态填充<select/>标签。该标签包含您在DefineWorkout组件中定义的锻炼。
Listing 4-11. The history.jsx File
var React = require("react");
var ListItem = React.createClass({
render: function() {
return <li>{this.props.name} - {this.props.result}</li>;
}
});
var History = React.createClass({
_mockHistory: [
{
"name": "Murph",
"result": "32:18",
"notes": "painful, but fun"
},
{
"name": "Tabata Something Else",
"type": "reps",
"result": "421",
"notes": ""
}
],
render: function() {
var hist = this._mockHistory;
var formatedLi = [];
for (var i = 0; i < hist.length; i++) {
var histObj = { name: hist[i].name, result: hist[i].result };
formatedLi.push(<ListItem {...histObj} />);
}
return (
<div>
<h2>History</h2>
<ul>
{formatedLi}
</ul>
</div>
);
}
});
module.exports = History;
History还获取模拟数据,并以<ListItem />组件的formattedLi数组的形式将其添加到应用的表示层。在将所有这些组件放在一起并运行它们之前,让我们停下来思考一下测试 React 应用需要什么。
测试应用
React 使得将测试框架集成到应用中变得容易。这是因为 React 附加组件在React.addons.testUtils被称为testUtils。本节概述了此附加组件中可用的测试实用程序。要使用附加组件,您必须通过拨打require("react/addons")等电话或在<script src=" https://fb.me/react-with-addons-0.13.3.js"></script >从脸书 CDN 获取 React with add-ons 源来请求 React 附加组件。
模仿
Simulate 是一种利用模拟事件的方法,这样您就能够模拟 React 应用中的交互。利用 Simulate 的方法签名如下:
React.addons.TestUtils.Simulate.{eventName}(DOMElement, eventData)
DOMElement是元素,eventData是对象。一个例子是这样的:
var node = React.findDOMNode(this.refs.input);
React.addons.TestUtils.Simulate.click(node);
渲染成文档
renderIntoDocument获取一个组件,并将其呈现在文档中一个分离的 DOM 节点中。由于该方法呈现为一个 DOM,因此该方法需要一个 DOM。因此,如果您在 DOM 之外进行测试,您将无法利用这种方法。
模拟组件
这个方法允许您创建一个假的 React 组件。这将成为应用中的一个简单的<div>,除非您对该对象使用可选的mockTagName参数。当您想要在测试场景中创建一个组件并向其添加有用的方法时,这尤其有用。
解决
这个函数只是返回一个布尔值,表明作为目标的 React 元素是否确实是一个元素:
isElement (ReactElement element)
iselemontoftype
该方法接受一个 React 元素和一个 component 类函数,如果您提供的元素属于componentClass的类型,它将返回True。
isElementOfType ( element, componentClass)
isDOMComponent
该方法返回布尔值,该值确定 React 组件的实例是否是 DOM 元素,如<div>或<h1>。
isCompositeComponent
这是另一个布尔检查,如果提供的 React 组件是一个复合组件,将返回True,这意味着它是使用React.createClass或在 ES6 扩展ReactComponent中创建的。
isCompositeComponentWithType
类似于isCompositeComponent,该方法将检查ReactComponent实例,并将其与提供给该方法的componentClass进行比较。如果实例和提供的类类型匹配,这将返回True。
findAllInRenderedTree
该方法返回存在于树或基础组件中的组件数组,前提是提供给该方法的函数测试为True。
findAllInRenderedTree ( tree, test )
scryrrendereddomcomponentswithsclass
这个方法在呈现的树中寻找 DOM 组件,比如带有匹配的className的<span>。
scryRenderedDOMComponentsWithClass ( tree, className)
findrendeddomcomponentswithsclass
这个方法和scryRenderedDOMComponentsWithClass是一样的,唯一的区别是期望的结果是一个单一的组件而不是一个数组。这意味着如果返回多个组件,将会出现错误。
scrrendereddomcomponentswithtag
返回一个从树组件开始的数组,匹配所有共享相同tagName的实例。
scryRenderedDOMComponentsWithTag( tree, tagName)
findRenderedDOMComponentsWithTag
这与前面的方法相同,除了它预期只有一个结果而不是一个数组。如果返回多个结果,此方法将产生错误。
scryRenderedComponentsWithType
类似于前面的例子,但是基于componentClass进行比较,这是提供给该方法的一个函数。
scryRenderedComponentsWithType( tree, componentClass )
findRenderedComponentsWithType
与前一个方法相同,再次预测一个单一的结果,如果找到多个结果,则抛出一个错误。
您可以采用所有这些方法,并利用它们来扩充您选择的测试工具。对脸书来说,这个工具就是笑话。为了在您的机器上设置 Jest,只需如下使用npm:
npm install jest-cli –save-dev
一旦安装完毕,您就可以更新您的应用的package.json并命名测试框架。
{
...
"scripts": {
"test": "jest"
}
...
}
现在每次运行npm test时,位于__tests__文件夹中的测试都会被执行。测试可以以一种需要一个模块的方式来构建,然后你可以在这个模块上运行测试。对SignIn组件的测试可能如下所示:
jest.dontMock("../src/signin.jsx");
describe("SignIn", function() {
it("will contain a Sign In button to submit", function() {
var React = require("react/addons");
var SignIn = require("../src/signin.jsx");
var TestUtils = React.addons.TestUtils;
var signin = TestUtils.renderIntoDocument(
<SignIn />;
);
var username = TestUtils.findRenderedDOMComponentWithTag( signin, "button" );
expect( username.getDOMNode().textContent).equalTo("Sign In");
});
});
您可以看到,您可以利用 React 附加组件中包含的TestUtils来构建测试,这将允许您在构建应用的测试套件时断言测试。
运行您的应用
在本节中,您将把构建的组件拼凑成一个工作应用。现在,您将获得每个组件,并将其组装起来。在这种情况下,您将使用 browserify 来组合您的脚本,这些脚本是使用 CommonJS 模块模块化的。当然,您可以将它们合并成一个文件,或者您可以将它们编写在类似于清单 4-12 的 ES6 模块中。
Listing 4-12. signin.jsx as an ES6 Module
var React = require("react");
class SignIn extends React.Component {
constructor(props) {
super(props);
}
render() {
return (
<div>
<label htmlFor="username">Username
<input type="text" id="username" />
</label>
<label htmlFor="password">Password
<input type="text" id="password" />
</label>
<button id="signIn" onClick={this.props.onAuthComplete.bind( null, this._doAuth)}>Sign In</button>
</div>
);
}
_doAuth() {
return true;
}
}
module.exports = SignIn;
因此,您也可以在 ES6 中创作您的应用,但是对于本例,应用将使用使用React.createClass();编写的现有源代码进行组装。
首先需要做的是,需要有一个包含代码的核心app.jsx文件,并成为应用的主要入口点。这个文件应该包括构建应用所必需的组件。在这种情况下,您需要主应用(您将在一秒钟内构建)和身份验证模块。
var React = require("react");
var Authentication = require("./auth.jsx");
var WorkoutLog = require("./workoutlog.jsx");
var App = React.createClass({
getInitialState: function() {
return { signedIn: false }
},
render: function() {
return (
<div>{ this.state.signedIn ? <WorkoutLog onLogout={this._onLogout} /> : <Authentication onAuthComplete={this._onAuthComplete}/> }</div>
);
},
_onAuthComplete: function( result ) {
// let the child auth components control behavior here
if (result()) {
this.setState( { signedIn: true } );
}
},
_onLogout: function() {
this.setState( { signedIn: false } )
}
})
React.render(<App/>, document.getElementById("container"));
这是实现Authentication和WorkoutLog组件的单个组件。有一个单一状态参数,指示用户是否登录。正如您之前看到的,这是通过传递属性从子组件传递的。SignIn组件绑定到按钮的点击,然后它将与 _ onAuthComplete函数共享点击的结果。这与_onLogout相同,在WorkoutLog组件的导航菜单中处理。
说到WorkoutLog组件——现在是时候看看它了,因为它是由所有剩余的组件组成的(清单 4-13 )。
Listing 4-13. The workoutlog.jsx File
var React = require("react");
var Nav = require("./navigation.jsx");
var DefineWorkout = require("./define.jsx");
var StoreWorkout = require("./store.jsx");
var History = require("./history.jsx");
var WorkoutLog = React.createClass({
getInitialState: function() {
return { view: "define" };
},
render: function() {
return (
<div>
<h1>Workout Log</h1>
<Nav onLogout={this.props.onLogout} onNav={this._onNav}/>
{this.state.view === "define" ? <DefineWorkout /> : "" }
{this.state.view === "store" ? <StoreWorkout /> : "" }
{this.state.view === "history" ? <History /> : "" }
</div>
);
},
_onNav: function( theView ) {
this.setState( { view: theView });
}
});
module.exports = WorkoutLog;
WorkoutLog是一个包含Nav的组件,然后通过属性onLogout来控制<App>组件的状态。<DefineWorkout />, <StoreWorkout />和<History />组件都可用,但是渲染机制中的可见性由state.view控制,这是在WorkoutLog组件级别维护的唯一状态参数。当点击<Nav/>组件中的链接时,设置该状态。只要您的所有路径都是正确的,并且您正在使用这样的命令:
$ watchify -t babelify ./src/app.jsx -o ./dist/bundle.js –v
结果将被捆绑到bundle.js中。您将能够导航到您的index.html(或者您命名的 HTML 文档)并查看您的工作 React 应用。恭喜你!
摘要
在本章中,您研究了 React web 应用从概念化到最终表示的过程。这包括利用线框化的思想来可视化应用的组件将被拆分的位置,或者剖析现有的应用以便为 React 重写做准备。
然后,您看到了如何利用 CommonJS 模块实际创建这些组件,以便保持组件的隔离和可维护性。最后,您将所有这些放在一个工作应用中。
在接下来的章节中,您将会遇到一些辅助工具,它们将会帮助您在 React 开发中走得更远。现在,您已经成功地构建了一个 React 应用,并且可能正在享受 React 所展示的 web 开发世界的新视图。
五、Flux 简介:React 的应用架构
Electronic supplementary material The online version of this chapter (doi:10.1007/978-1-4842-1245-5_5) contains supplementary material, which is available to authorized users.
本书的前四章介绍了 React,这是用于创建用户界面的 JavaScript 框架,是脸书工程团队的产品。到目前为止,您所看到的已经足以使用 React 创建健壮的用户界面,并将 React 实现到新的或现有的应用框架中。然而,React 生态系统不仅仅是 React。其中之一是 Flux,这是一个由脸书创建的应用框架,以取代标准的模型-视图-控制器(MVC)框架的方式来补充 React。这并不是因为 MVC 本身有什么问题,而是因为当您开始用 React 构建应用并将应用逻辑分解成组件时,您会发现一个类似于典型 MVC 的框架不如 Flux 那样高效或可维护,Flux 在设计时就考虑到了 React,并且还具有在不增加维护成本的情况下扩展应用的能力。
本章将概述什么是 Flux 以及如何开始使用 Flux,并探讨 Flux 和 React 如何配合使用。在下一章用 Flux 构建应用之前,你将熟悉 Flux 的概念。
什么是 Flux,为什么它不同于典型的 MVC 框架
Flux 是专门为 React 设计的。这是一个应用架构,旨在避免典型 MVC 框架中常见的多向数据流和绑定的概念。相反,它提供单向数据流,React 是中间的用户界面层。为了得到一个更好的例子,让我们研究一下典型的 MVC 框架,看看当试图将应用扩展到超出其设计容量时会出现什么问题。
在图 5-1 中,你可以看到方向从一个动作开始,通过控制器到达模型。
图 5-1。
Typical Model-View-Controller data flow model
模型和视图可以来回交换数据。这是相对直接的,但是如果您添加一些额外的模型和视图会发生什么呢?然后事情变得稍微复杂一点,但仍然是你可以处理的事情,如图 5-2 所示。
图 5-2。
Additional models and views added to the MVC data model
这显然更加复杂,因为有多个视图和模型,其中一些甚至在彼此之间共享数据。然而,这种结构不会变得完全笨拙,直到有如此多的模型和视图,以至于您甚至不能在一个简单的模型图中跟踪依赖关系,更不用说弄清楚模型和视图如何在代码本身中相互交互。
当它开始变得难以处理时,你看到的是最初导致我们做出 React 的相同场景。这些依赖关系的嵌套和耦合导致您有足够的机会失去对特定变量或关系的跟踪。这意味着更新单个模型或视图可能会对未知的相关视图产生不利影响。这既不有趣也不可维护。它会增加您的开发时间,或者以糟糕的用户体验甚至无限更新循环的形式导致严重的错误。这就是 Flux 的好处,尤其是当您有多个模型和视图时。
Flux 在最基本的层面上看起来如图 5-3 所示,有一个动作、调度、存储和视图层。
图 5-3。
Basic Flux data flow
这是数据流通过 Flux 应用的基本结构。数据流的初始状态来自一个动作。这个动作然后被转移到调度器。
Flux 应用中的调度员就像一名交通官员。这个调度程序将确保流经应用的数据不会导致任何级联效应,这种效应可能会在多模型和视图 MVC 设置中看到。调度程序还必须确保动作按照它们到达的顺序执行,以防止出现竞争情况。
商店接管每项活动的调度员。一旦一个动作进入存储区,在存储区完成当前动作的处理之前,不允许该动作进入存储区。一旦存储表明数据中的某些内容发生了变化,视图就会对存储做出响应。
视图本身可以通过实例化另一个动作来为这个数据流做出贡献,然后这个动作通过 dispatcher 传递到商店并返回到视图,如图 5-4 所示。
图 5-4。
Flux with a view creating its own action and passing that to the dispatcher
您可能想知道这个数据流的视图组件是否是 React 适合 Flux 的地方。这正是 React 符合 Flux 模型的地方。您可以将应用中的 React 组件视为基于从数据模型的存储部分传输的数据呈现的项目。
从视图本身创建的操作呢?React 如何创建一个发送给调度程序的动作?这可能只是用户交互的结果。例如,如果我有一个聊天应用,想要过滤朋友列表或类似的东西,React 将在我与组件的该部分交互时创建新的动作,这些动作将传递给 dispatcher 以启动另一个 Flux 流程,如图 5-5 所示。
图 5-5。
Full Flux architecture, including calls from data stores
图 5-5 显示了 Flux 架构的完整生命周期。这从某种数据 API 开始,然后将信息或数据发送给动作创建者。顾名思义,动作创建者创建传递给调度程序的动作。然后,调度程序控制这些操作,并将它们过滤到商店。存储处理动作并将它们推送到视图层,在本例中,视图层是 React 组件的集合。然后,这些 React 组件可以进行用户交互,将它们的事件或活动传递给动作创建者,以便继续流程。接下来,您将看到这些 Flux 组件的更详细的分解。
焊剂的基本成分
Flux 由四个主要部分组成,或者至少可以被认为是核心概念。这些是 dispatcher、stores、actions 和 views,正如您在上一节中所学的。在接下来的章节中会对它们进行更详细的描述。
分配器
调度程序是 Flux 应用中数据流的中心。这意味着它控制流入 Flux 应用的存储的内容。它这样做是因为存储创建了链接到调度程序的回调,所以调度程序充当这些回调的存放位置。应用中的每个存储都创建一个回调,并向调度程序注册它。当一个动作创建者向调度程序发送一个新的动作时,调度程序将确保所有注册的存储都获得该动作,因为提供了回调。
对于较大规模的应用来说,调度程序通过回调将操作实际调度到商店的能力是必不可少的,因为回调可以被管理到以特定顺序执行的程度。此外,存储可以在更新自己之前显式等待其他存储完成更新。
商店
存储包含 Flux 应用的逻辑和状态。您可能认为这些基本上是传统 MVC 应用的模型部分。区别在于,与传统模型表示单一数据结构不同,Flux 中的存储实际上可以表示许多对象的状态管理。这些对象代表了 Flux 应用中的一个特定的域子集。
如前一节所述,存储将向调度程序注册自己,并为其提供回调。传入的回调将有一个参数,该参数是通过 dispatcher 传递给它的动作。回调还将包含一个基于动作类型的switch语句,并允许适当地委托给存储内部包含的函数或方法。这允许存储通过调度程序提供的操作来更新状态。然后,商店必须广播一个指示状态已经改变的事件,以便视图可以获取新的状态并更新应用的呈现。
行动
动作实际上是已经发送到商店的任何形式的数据。在本章的后面,你会看到一个使用 Flux 架构的简单TODO应用的动作和动作创建者的基本例子。
视图
视图层是 React 适合这个架构的地方。React 具有呈现虚拟 DOM 和最小化复杂 DOM 更新的能力,在创建 Flux 应用时特别有用。React 不仅仅是视图本身。事实上,在视图层次结构的最高级别上的 React 可以成为一种控制器视图,它可以控制用户界面并呈现应用的任何特定子集。
当视图或控制器视图从存储层接收到一个事件时,它将首先通过访问存储的 getter 方法来确保它保存最新的数据。然后,它将使用setState()或forceUpdate()将render()正确地放入 DOM。一旦发生这种情况,控制器视图的渲染将传播到它所控制的所有子视图。
将应用的状态传递给控制器视图并随后传递给其子视图的常见范例是将整个状态作为单个对象传递。这为您提供了两个好处。首先,您可以看到将到达视图层次结构所有部分的状态,从而允许您作为一个整体来管理它;其次,它将减少您需要传递和维护的属性的数量,本质上使您的应用更容易维护。
React 和 Flux 看起来如何
现在你已经对 Flux 和 React 如何协同工作以及如何使用它们有了基本的了解,本章的剩余部分将集中在一个简单的TODO应用上。正如前几章对 React 的介绍集中在TodoMVC.com上一样,本章将研究利用 Flux 的基本TodoMVC应用,然后在下一章讨论更复杂的聊天应用。
HTML 与您之前看到的类似,您将把所有的 JavaScript 资源构建到一个单独的bundle.js文件中。接下来,您可以在 https://github.com/facebook/flux.git 克隆 Flux repository 并导航到examples/flux-todomvc目录。然后,您可以使用npm install和npm start命令,并在浏览器中导航到index.html文件来查看示例。这些命令的作用是利用npm来安装 Flux 示例的依赖项。这包括实际的 Flux npm包,它虽然不是一个框架,但包含了调度程序和其他允许 Flux 架构正常工作的模块。
Note
清单 5-1 到 5-10 中显示的代码是由脸书根据 BSD 许可证授权的。
Listing 5-1. Index.html for TodoMVC with Flux
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Flux • TodoMVC</title>
<link rel="stylesheet" href="todomvc-common/base.css">
<link rel="stylesheet" href="css/app.css">
</head>
<body>
<section id="todoapp"></section>
<footer id="info">
<p>Double-click to edit a todo</p>
<p>Created by <a href="``http://facebook.com/bill.fisher.771">Bill
<p>Part of <a href="``http://todomvc.com">TodoMVC</a></p
</footer>
<script src="js/bundle.js"></script>
</body>
</html>
bundle.js文件所基于的主引导文件是清单 5-2 中所示的app.js文件。该文件需要 React 并包含对TodoApp.react模块的引用,该模块是TODO应用的主要组件。
Listing 5-2. Main Entry app.js for the TodoMVC Flux Application
var React = require('react');
var TodoApp = require('./components/TodoApp.react');
React.render(
<TodoApp />,
document.getElementById('todoapp')
);
清单 5-3 中所示的TodoApp.react.js模块需要该 Flux 模块的Footer、Header和MainSection组件。另外,你看stores / TodoStore模块的介绍。
Listing 5-3. Todoapp.js: A Controller-View for the TodoMVC Flux Application
var Footer = require('./Footer.react');
var Header = require('./Header.react');
var MainSection = require('./MainSection.react');
var React = require('react');
var TodoStore = require('../stores/TodoStore');
/**
* Retrieve the current TODO data from the TodoStore
*/
function getTodoState() {
return {
allTodos: TodoStore.getAll(),
areAllComplete: TodoStore.areAllComplete()
};
}
var TodoApp = React.createClass({
getInitialState: function() {
return getTodoState();
},
componentDidMount: function() {
TodoStore.addChangeListener(this._onChange);
},
componentWillUnmount: function() {
TodoStore.removeChangeListener(this._onChange);
},
/**
* @return {object}
*/
render: function() {
return (
<div>
<Header />
<MainSection
allTodos={this.state.allTodos}
areAllComplete={this.state.areAllComplete}
/>
<Footer allTodos={this.state.allTodos} />
</div>
);:
},
/**
* Event handler for 'change' events coming from the TodoStore
*/
_onChange: function() {
this.setState(getTodoState());
}
});:
module.exports = TodoApp;
清单 5-4 中的MainSection组件,正如标题所示——控制TODO应用主要部分的组件。注意,它还包含了对TodoActions模块的第一次引用,您将在本例的后面看到。除此之外,这是一个您期望看到的 React 组件;它渲染主要部分,处理一些 React 属性,并插入TodoItems,就像您在前面章节中看到的基于非 Flux 的 React TodoMVC应用一样。
Listing 5-4. The MainSection.js Module
var React = require('react');
var ReactPropTypes = React.PropTypes;
var TodoActions = require('../actions/TodoActions');
var TodoItem = require('./TodoItem.react');
var MainSection = React.createClass({
propTypes: {
allTodos: ReactPropTypes.object.isRequired,
areAllComplete: ReactPropTypes.bool.isRequired
},
/**
* @return {object}
*/
render: function() {
// This section should be hidden by default
// and shown when there are TODOs.
if (Object.keys(this.props.allTodos).length < 1) {
return null;
}
var allTodos = this.props.allTodos;
var todos = [];
for (var key in allTodos) {
todos.push(<TodoItem key={key} todo={allTodos[key]} />);
}
return (
<section id="main">
<input
id="toggle-all"
type="checkbox"
onChange={``this._onToggleCompleteAll
checked={this.props.areAllComplete ? 'checked' : ''}
/>
<label htmlFor="toggle-all">Mark all as complete</label>
<ul id="todo-list">{todos}</ul>
</section>
);
},
/**
* Event handler to mark all TODOs as complete
*/
_onToggleCompleteAll: function() {
TodoActions.toggleCompleteAll();
}
});
module.exports = MainSection;
TodoItems组件(清单 5-5 )与该应用的非 Flux 版本非常相似。注意,绑定到 DOM 的事件,就像在MainSection中一样,现在链接到一个TodoActions函数(在示例中用粗体文本显示)。这允许将动作绑定到 Flux 数据流,并适当地从调度程序传播到存储,然后最终传播到视图。在Header(清单 5-7 )和Footer(清单 5-6 )组件中也可以找到与TodoActions类似的绑定。
Listing 5-5. TodoItem.react.js
var React = require('react');
var ReactPropTypes = React.PropTypes;
var TodoActions = require('../actions/TodoActions');
var TodoTextInput = require('./TodoTextInput.react');
var cx = require('react/lib/cx');
var TodoItem = React.createClass({
propTypes: {
todo: ReactPropTypes.object.isRequired
},
getInitialState: function() {
return {
isEditing: false
};
},
/**
* @return {object}
*/
render: function() {
var todo = this.props.todo;
var input;
if (this.state.isEditing) {
input =
<TodoTextInput
className="edit"
onSave={``this._onSave
value={todo.text}
/>;
}
// List items should get the class 'editing' when editing
// and 'completed' when marked as completed.
// Note that 'completed' is a classification while 'complete' is a state.
// This differentiation between classification and state becomes important
// in the naming of view actions toggleComplete() vs. destroyCompleted().
return (
<li
className={cx({
'completed': todo.complete,
'editing': this.state.isEditing
})}
key={todo.id}>
<div className="view">
<input
className="toggle"
type="checkbox"
checked={todo.complete}
onChange={``this._onToggleComplete
/>
<label onDoubleClick={this._onDoubleClick}>
{todo.text}
</label>
<button className="destroy" onClick={``this._onDestroyClick
</div>
{input}
</li>
);
},
_onToggleComplete: function() {
TodoActions.toggleComplete(this.props.todo);
},
_onDoubleClick: function() {
this.setState({isEditing: true});
},
/**
* Event handler called within TodoTextInput.
* Defining this here allows TodoTextInput to be used in multiple places
* in different ways.
* @param {string} text
*/
_onSave: function(text) {
TodoActions.updateText(this.props.todo.id, text);
this.setState({isEditing: false});
},
_onDestroyClick: function() {
TodoActions.destroy(this.props.todo.id);
}
});
module.exports = TodoItem;
Listing 5-6. footer.react.js
var React = require('react');
var ReactPropTypes = React.PropTypes;
var TodoActions = require('../actions/TodoActions');
var Footer = React.createClass({
propTypes: {
allTodos: ReactPropTypes.object.isRequired
},
/**
* @return {object}
*/
render: function() {
var allTodos = this.props.allTodos;
var total = Object.keys(allTodos).length;
if (total === 0) {
return null;
}
var completed = 0;
for (var key in allTodos) {
if (allTodos[key].complete) {
completed++;
}
}
var itemsLeft = total - completed;
var itemsLeftPhrase = itemsLeft === 1 ? ' item ' : ' items ';
itemsLeftPhrase += 'left';
// Undefined and thus not rendered if no completed items are left.
var clearCompletedButton;
if (completed) {
clearCompletedButton =
<button
id="clear-completed"
onClick={``this._onClearCompletedClick
Clear completed ({completed})
</button>;
}
return (
<footer id="footer">
<span id="todo-count">
<strong>
{itemsLeft}
</strong>
{itemsLeftPhrase}
</span>
{clearCompletedButton}
</footer>
);
},
/**
* Event handler to delete all completed TODOs
*/
_onClearCompletedClick: function() {
TodoActions.destroyCompleted();
}
});
module.exports = Footer;
Listing 5-7. header.react.js
var React = require('react');
var TodoActions = require('../actions/TodoActions');
var TodoTextInput = require('./TodoTextInput.react');
var Header = React.createClass({
/**
* @return {object}
*/
render: function() {
return (
<header id="header">
<h1>todos</h1>
<TodoTextInput
id="new-todo"
placeholder="What needs to be done?"
onSave={``this._onSave
/>
</header>
);
},
/**
* Event handler called within TodoTextInput.
* Defining this here allows TodoTextInput to be used in multiple places
* in different ways.
* @param {string} text
*/
_onSave: function(text) {
if (text.trim()){
TodoActions.create(text);
}
}
});
module.exports = Header;
既然您已经看到了 React 组件如何向TodoActions模块发送事件或动作,那么您可以在这个示例中检查一下TodoActions模块是什么样子的。它只是一个带有与AppDispatcher(清单 5-8 )相关的方法的对象。
Listing 5-8. appdispatcher.js
var Dispatcher = require('flux').Dispatcher;
module.exports = new Dispatcher();
正如您在前面的例子中看到的,AppDispatcher是基本 Flux 分配器的一个简单实例。您会看到清单 5-9 中所示的TodoActions函数,每一个都与AppDispatcher有关。他们调用了dispatch函数,该函数保存了一个对象,该对象描述了从调度器AppDispatcher.dispatch( /* object describing dispatch */ );发送的内容。您可以看到,根据所调用的动作,所发送的对象会有所不同。这意味着create函数将生成一个 dispatch,其中包含传递了TodoItem文本的TodoConstants.TODO_CREATE actionType。
Listing 5-9. Todoactions.js
var AppDispatcher = require('../dispatcher/AppDispatcher');
var TodoConstants = require('../constants/TodoConstants');
var TodoActions = {
/**
* @param {string} text
*/
create: function(text) {
AppDispatcher.dispatch({
actionType: TodoConstants.TODO_CREATE,
text: text
});
},
/**
* @param {string} id The ID of the TODO item
* @param {string} text
*/
updateText: function(id, text) {
AppDispatcher.dispatch({
actionType: TodoConstants.TODO_UPDATE_TEXT,
id: id,
text: text
});
},
/**
* Toggle whether a single TODO is complete
* @param {object} todo
*/
toggleComplete: function(todo) {
var id = todo.id;
var actionType = todo.complete ?
TodoConstants.TODO_UNDO_COMPLETE :
TodoConstants.TODO_COMPLETE;
AppDispatcher.dispatch({
actionType: actionType,
id: id
});
},
/**
* Mark all TODOs as complete
*/
toggleCompleteAll: function() {
AppDispatcher.dispatch({
actionType: TodoConstants.TODO_TOGGLE_COMPLETE_ALL
});
},
/**
* @param {string} id
*/
destroy: function(id) {
AppDispatcher.dispatch({
actionType: TodoConstants.TODO_DESTROY,
id: id
});
},
/**
* Delete all the completed TODOs
*/
destroyCompleted: function() {
AppDispatcher.dispatch({
actionType: TodoConstants.TODO_DESTROY_COMPLETED
});
}
};
module.exports = TodoActions;
最后,在清单 5-10 中,您会遇到TodoStore.js file,它是动作、调度程序和视图之间的中介。您看到的是,在这个模块的函数中处理的每个事件也是从回调注册表中调用的。这个注册表在下面的例子中被加粗,它为调度程序和视图之间的所有委托提供了动力。每个函数都将完成更新TODOs的值所需的工作,之后调用方法TodoStore.emitChange()。这个方法将告诉 React 视图,是时候协调视图并相应地更新 DOM 了。
Listing 5-10. TodoStore.js
var AppDispatcher = require('../dispatcher/AppDispatcher');
var EventEmitter = require('events').EventEmitter;
var TodoConstants = require('../constants/TodoConstants');
var assign = require('object-assign');
var CHANGE_EVENT = 'change';
var _todos = {};
/**
* Create a TODO item.
* @param {string} text The content of the TODO
*/
function create(text) {
// Hand waving here -- not showing how this interacts with XHR or persistent
// server-side storage.
// Using the current timestamp + random number in place of a real id.
var id = (+new Date() + Math.floor(Math.random() * 999999)).toString(36);
_todos[id] = {
id: id,
complete: false,
text: text
};
}
/**
* Update a TODO item.
* @param {string} id
* @param {object} updates An object literal containing only the data to be
* updated.
*/
function update(id, updates) {
_todos[id] = assign({}, _todos[id], updates);
}
/**
* Update all of the TODO items with the same object.
* the data to be updated. Used to mark all TODOs as completed.
* @param {object} updates An object literal containing only the data to be
* updated.
*/
function updateAll(updates) {
for (var id in _todos) {
update(id, updates);
}
}
/**
* Delete a TODO item.
* @param {string} id
*/
function destroy(id) {
delete _todos[id];
}
/**
* Delete all the completed TODO items.
*/
function destroyCompleted() {
for (var id in _todos) {
if (_todos[id].complete) {
destroy(id);
}
}
}
var TodoStore = assign({}, EventEmitter.prototype, {
/**
* Tests whether all the remaining TODO items are marked as completed.
* @return {boolean}
*/
areAllComplete: function() {
for (var id in _todos) {
if (!_todos[id].complete) {
return false;
}
}
return true;
},
/**
* Get the entire collection of TODOs.
* @return {object}
*/
getAll: function() {
return _todos;
},
emitChange: function() {
this.emit(CHANGE_EVENT);
},
/**
* @param {function} callback
*/
addChangeListener: function(callback) {
this.on(CHANGE_EVENT, callback);
},
/**
* @param {function} callback
*/
removeChangeListener: function(callback) {
this.removeListener(CHANGE_EVENT, callback);
}
});
// Register callback to handle all updates
AppDispatcher.register(function(action) {
var text;
switch(action.actionType) {
case TodoConstants.TODO_CREATE:
text = action.text.trim();
if (text !== '') {
create(text);
TodoStore.emitChange();
}
break;
case TodoConstants.TODO_TOGGLE_COMPLETE_ALL:
if (TodoStore.areAllComplete()) {
updateAll({complete: false});
} else {
updateAll({complete: true});
}
TodoStore.emitChange();
break;
case TodoConstants.TODO_UNDO_COMPLETE:
update(action.id, {complete: false});
TodoStore.emitChange();
break;
case TodoConstants.TODO_COMPLETE:
update(action.id, {complete: true});
TodoStore.emitChange();
break;
case TodoConstants.TODO_UPDATE_TEXT:
text = action.text.trim();
if (text !== '') {
update(action.id, {text: text});
TodoStore.emitChange();
}
break;
case TodoConstants.TODO_DESTROY:
destroy(action.id);
TodoStore.emitChange();
break;
case TodoConstants.TODO_DESTROY_COMPLETED:
destroyCompleted();
TodoStore.emitChange();
break;
default:
// no op
}
});
module.exports = TodoStore;
摘要
这一章不同于纯粹的 React,它开始向你展示 React 生态系统作为一个整体是如何工作的。从描述 Flux 体系结构如何提供一种有意义和有用的机制来构建 React 应用,使其不仅可维护,而且可有效扩展开始,您看到了如何单向路由数据流,从而为 React 应用提供最佳的开发实践。然后,您快速浏览了一个简单 Flux TodoMVC应用的脸书版本,该版本展示了如何开始以 Flux 架构的方式构建 React 应用。
在下一章,React 入门书的最后一章,您将剖析一个用 React 和 Flux 构建的全功能聊天应用,这样您就可以全面理解如何以可维护和可伸缩的方式创建一个复杂的应用。