“我们已经扩展了功能列表。”
你忍住呻吟然后等待。
“我们想为用户提供一切。他们需要的一切,他们想要的一切,他们可能想要的一切。”
“好吧,”你说。“但这是一个原型……”
“分析页面、个人资料页面、朋友分析页面、笔记页面、天气页面。”
你静静地展示自己,屏住呼吸重复,“这是一个原型。”
现在,我们的应用在技术上可以正常工作(允许用户登录),但缺少真正有用的内容。是时候改变了。
然而,要做到这一点,我们需要在应用中添加额外的页面。你们中的一些人可能听说过术语单页应用(SPA),它用于指 React 应用,因此可能会被更多页面的说法所迷惑。我们将在进一步讨论时讨论这一区别,然后使用 React Router 进入实际的路由设置。
以下是我们将学到的:
- 如何安装和使用 React 路由 v4
- 如何将其他管线添加到其他构件
- 如何在路线之间移动
幸运的是,理智的头脑占了上风,首席产品设计师(该公司目前雇用的五位设计师中排名最高的)表示,他们只需要三个视图即可创建原型:登录视图(完成!)、主聊天视图和用户配置文件视图。
不过,我们显然需要一种健壮且可扩展的方式在应用的不同屏幕之间移动。我们需要一个可靠的布线解决方案。
传统上,路由一直是提供哪些 HTML/CSS/JavaScript 文件的问题。您点击static-site.com上的 URL 获取主index.html
,然后进入static-site.com/resources获取resources.html
。
在这个模型中,服务器获取特定 URL 的请求并返回相应的文件。
然而,路由越来越多地转移到客户端。在一个反应的世界里,我们只上过我们的index.html
和bundle.js
。我们的 JavaScript 从浏览器接收 URL,然后决定呈现什么 JSX。
因此出现了单页应用这个术语——从技术上讲,我们的用户只坐在一个页面上(如果我们从传统模式来看)。但是,它们能够在其他视图之间导航,并且以更加精简的方式进行导航,而无需从服务器请求更多文件。
我们的顶级容器组件(App.js
)将始终被渲染,但更改的是它内部的渲染内容。
对于某些 React 路由解决方案,模型将如下所示。
我们将呈现初始屏幕,如图所示:
<App>
<LoginContainer />
</App>
这将与chatastrophe.com/login
的 URL 相匹配。当用户完成登录后,我们会将其发送到chatastrophe.com/chat
。在这一点上,我们将调用ReactDOM.render
并提供以下信息:
<App>
<ChatContainer />
</App>
React 的对账引擎会将旧应用与新应用进行比较,并调出发生更改的组件;在这种情况下,它会将LoginContainer
交换为ChatContainer
,而不会重新提交App
。
下面是一个非常简单的示例,使用一个名为page.js
的基本路由解决方案,它可能看起来像什么:
page(‘/’, () => {
ReactDOM.render(
<App>
<ChatContainer />
</App>.
document.getElementById('root')
);
});
page(‘/login’, () => {
ReactDOM.render(
<App>
<LoginContainer />
</App>.
document.getElementById('root')
);
});
这个解决方案很好用。我们能够在多个视图之间导航,React 的协调确保不会对未更改的组件进行浪费性的重新招标。
然而,这种解决方案不是很理想。每次更改页面时,我们都会将整个应用传递给ReactDOM.render
,这会导致router.js
文件中出现大量重复代码。我们正在定义应用的多个版本,而不是精确地选择应该在哪个时间呈现哪些组件。
换句话说,该解决方案采用了一种整体的路由方法,而不是按组件划分的方法。
输入React Router v4
,这是对库的完全重写,过去是一种更传统的路由解决方案。不同之处在于,路由现在是基于 URL 呈现的组件。
让我们通过重写前面的示例来讨论这到底意味着什么:
ReactDOM.render(
<Router>
<App>
<Route path="/" component={ChatContainer} />
<Route path="/login" component={LoginContainer} />
</App>
</Router>,
document.getElementById('root')
);
现在,我们只打一次电话。我们呈现我们的应用,并在其中呈现包裹两个容器的两个Route
组件。
每个Route
都有一个path
道具。如果浏览器中的 URL 与该path
匹配,Route
将呈现其子组件(容器);否则,它将不提供任何结果。
我们从不试图重新播放我们的App
。它应该是静止的。此外,我们的路由解决方案不再与router.js
文件中的组件分离。现在,它存在于我们的组件中。
我们还可以在组件中进一步嵌套路由。在LoginContainer
内部,我们可以添加两个路由——一个用于/login
,另一个用于/login/new
——如果我们想拥有单独的登录和注册视图。
在这个模型中,每个组件都可以根据当前 URL 决定要呈现什么。
老实说,这种方法习惯起来有点奇怪,我开始使用时一点也不喜欢它。对于有经验的开发人员来说,这需要以不同的方式考虑路由,而不是自上而下、整页地决定要呈现什么,现在鼓励您在组件级别做出决策,这可能很困难。
然而,在使用它一段时间之后,我认为这个范例正是 React 路由方法所需要的,它将给开发人员带来更大的灵活性。
好了,说够了。让我们创建第二个视图——聊天屏幕——在这里,用户可以同时查看并向世界上的每个人发送消息(“全球互联”,你知道)。首先,我们将创建一个基本组件,然后开始使用路由解决方案。
创建组件现在应该是老生常谈了。我们的ChatContainer
将是一个基于类的组件,因为我们需要深入研究一些生命周期方法(稍后将详细介绍)。
在我们的components
文件夹中,创建一个名为ChatContainer.js
的文件。然后,设置我们的骨架:
import React, { Component } from 'react';
export default class ChatContainer extends Component {
render() {
return (
);
}
}
让我们继续我们的模式,将组件包装成一个div
和一个id
的组件名称:
import React, { Component } from 'react';
export default class ChatContainer extends Component {
render() {
return (
<div id="ChatContainer">
</div>
);
}
}
正如在我们的LoginContainer
顶部一样,我们希望为用户呈现我们美丽的徽标和标题。如果我们有某种可重用的组件,这样我们就不必重写代码了:
import React, { Component } from 'react';
import Header from './Header';
export default class ChatContainer extends Component {
render() {
return (
<div id="ChatContainer">
<Header />
</div>
);
}
}
这真漂亮。好的,让我们在Header
之后添加<h1>Hello from ChatContainer</h1>
并继续进行路由,这样我们就可以在工作时看到我们在做什么。现在,我们的ChatContainer
不可见。要改变这一点,我们需要设置 React 路由。
让我们从基础开始。从项目根目录在终端中运行以下命令。
yarn add react-router-dom@4.2.2
react-router-dom
包含我们希望通过应用路由用户的所有 React 组件。您可以在查看完整的文档 https://reacttraining.com/react-router 。然而,我们唯一感兴趣的组件是Route
和BrowserRouter
。
It's important to ensure that you install react-router-dom
and not react-router
. Since version 4 was released, the package has been split into various branches. React-router-dom
is specifically geared towards providing routing components, which is what we’re interested in. Note that it installed react-router
as a peer dependency, though.
Route
组件比较简单;它需要一个名为path
的道具,它是一个字符串,如/
或/login
。当浏览器中的 URL 与该字符串匹配时(http://chatastrophe.com/login ,Route
组件渲染组件,通过component
道具传入;否则,它将不呈现任何内容。
与 web 开发中的任何内容一样,如何使用Route
组件还有很多额外的复杂性。稍后我们将进一步深入探讨。但是,目前我们只希望根据路径是/
还是/login
有条件地呈现ChatContainer
或LoginContainer
。
BrowserRouter
更为复杂,但出于我们的目的,它将更易于使用。本质上,它确保我们的Route
组件与 URL 保持同步(呈现或不呈现)。它使用 HTML5 历史 API 来实现这一点。
我们需要做的第一件事是将整个应用包装在BrowserRouter
组件中,然后我们可以添加Route
组件。
因为我们想让路由围绕整个应用,所以最容易添加路由的地方是我们的src/index.js
。在顶部,我们需要以下组件:
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import App from './components/App';
然后,我们将我们的App
呈现为BrowserRouter
的孩子:
ReactDOM.render(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('root')
);
您还应该在我们的热重新加载配置中执行相同的操作:
if (module.hot) {
module.hot.accept('./components/App', () => {
const NextApp = require('./components/App').default;
ReactDOM.render(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('root')
);
});
}
完成!现在我们可以开始添加路线了。
在我们的App
组件中,我们目前呈现LoginContainer
,无论:
render() {
return (
<div id="container">
<LoginContainer />
</div>
);
}
我们想改变这个逻辑,这样我们要么只渲染LoginContainer
要么渲染ChatContainer
。要做到这一点,让我们在我们的ChatContainer
中要求它。
我们还需要react-router-dom
提供的Route
组件:
import React, { Component } from 'react';
import { Route } from 'react-router-dom';
import LoginContainer from './LoginContainer';
import ChatContainer from './ChatContainer';
import './app.css';
I put the Route
import above the two Container
imports. Best practices say you should put absolute imports (imports from node_modules
) before relative imports (files imported from within src
). This keeps things clean.
现在,我们可以用带有component
道具的Route
组件替换容器:
render() {
return (
<div id="container">
<Route component={LoginContainer} />
<Route component={ChatContainer} />
</div>
);
}
We pass in our component prop as LoginContainer
, and not as <LoginContainer />
.
我们的应用重新加载,我们看到…一团混乱:
我们当前正在同时渲染两个容器!哎呀。问题是我们没有给Route
一个path
道具,告诉他们何时渲染(何时不渲染)。我们现在就开始吧。
我们的第一个Route``LoginContainer
将在/login
路线上进行渲染,因此我们添加了如下路径:
<Route path="/login" component={LoginContainer} />
我们的另一个容器ChatContainer
将在用户位于根目录时显示,/
(当前位于localhost:8080/
或https://chatastrophe-77bac.firebaseapp.com/ 用于我们部署的应用),因此我们添加了一个路径,如图所示:
<Route path="/" component={ChatContainer} />
保存并检查应用,您将获得以下信息:
美好的我们的LoginContainer
不再呈现。让我们前往/login
并确保在那里只看到我们的LoginContainer
:
啊!
我们正在/login
处渲染两个容器。怎么搞的?
长话短说,React 路由使用RegEx模式匹配路由并确定渲染内容。我们的当前路径(/login
与传递给我们的登录名Route
的道具匹配,但技术上也与/
匹配。事实上,所有内容都与/
匹配,如果您希望在每个页面上呈现组件,这非常好,但我们希望我们的ChatContainer
仅在路径为/
时呈现(没有其他内容)。
换句话说,当路径与/
完全匹配时,我们希望呈现ChatContainer
路由。
好消息是 React 路由已经为这个问题做好了准备;只需在我们的Route
中添加一个道具exact
:
<Route exact path="/" component={ChatContainer} />
The preceding is the same as writing:
<Route exact={true} path="/" component={ChatContainer} />
当我们检查/login
时,我们应该只看到我们的LoginContainer
。伟大的我们有前两条路线。
接下来我们要做的是添加一点强制路由;当用户登录时,我们希望将他们重定向到主聊天屏幕。让我们做吧!
在这里,事情会变得有点棘手。首先,我们要做一些准备。
在我们的LoginContainer
内部,当涉及到我们的signup
和login
方法时,我们目前只是console.log
在then
声明中给出结果。换句话说,一旦用户登录,我们实际上什么也不做:
signup() {
firebase.auth().createUserWithEmailAndPassword(this.state.email, this.state.password)
.then(res => {
console.log(res);
}).catch(error => {
console.log(error);
this.setState({ error: 'Error signing up.' });
})
}
让我们更改此位(在signup
和login
中)以调用另一个方法onLogin
:
login() {
firebase.auth().signInWithEmailAndPassword(this.state.email, this.state.password)
.then(res => {
this.onLogin();
}).catch((error) => {
if (error.code === 'auth/user-not-found') {
this.signup();
} else {
this.setState({ error: 'Error logging in.' });
}
});
}
然后,我们可以定义我们的onLogin
方法:
onLogin() {
// redirect to '/'
}
那么,我们如何重定向到根路径?
我们知道我们的Route
组件将基于浏览器中的 URL 呈现。我们可以确信,如果我们正确修改 URL,我们的应用将重新加载以显示适当的组件。诀窍是从LoginContainer
中修改该 URL。
正如我们前面提到的,React 路由使用 HTML5 历史 API 在 URL 之间移动。在这个模型中,有一个名为history
的对象,它具有某些方法,允许您将新 URL 推送到应用的当前状态。
所以,如果我们在/login
,想去/
:
history.pushState(null, null, ‘/’)
React Router 允许我们以一种更加简化的方式与 HTML5 历史对象交互(例如,避免空参数)。这样做的方式很简单:传递给Route
(通过component
道具)的每个组件都会收到另一个名为history
的道具,该道具有一个名为push
的方法。
如果这听起来让人困惑,别担心,一会儿就会明白的。我们所要做的就是:
onLogin() {
this.props.history.push(‘/’);
}
通过进入/login
并登录来尝试。您将被重定向到ChatContainer
。魔术
当调用push
时,history
道具正在更新浏览器的 URL,这会导致我们的Route
组件呈现它们的组件(或不呈现任何内容):
History.push -> URL change -> Re-render
请注意,这是一种革命性的网站导航方式。以前,情况大不相同:
Click link/submit form -> URL change -> Download new page
欢迎来到路由单页应用的世界。感觉很好,不是吗?
好的,我们已经处理了用户的登录,但是他们什么时候想注销呢?
让我们在ChatContainer
顶部为他们构建一个按钮,让他们可以注销。它最适合Header
组件,所以我们为什么不在那里构建它呢?
等等。我们目前在/login
路径的LoginContainer
中使用Header
。如果我们添加一个Logout
按钮,它也会出现在登录屏幕上,这让人很困惑。我们需要一种只渲染ChatContainer
上的Logout
按钮的方法。
我们可以利用Route history
属性并使用它根据 URL 对注销按钮进行有条件的呈现(如果路径为“/
”,则呈现按钮,否则不呈现!)。然而,随着我们添加更多的路由,这对未来的开发人员来说可能是混乱和难以理解的。当我们想要注销按钮出现时,让我们把它变得非常明确。
换句话说,我们希望在Header
内呈现注销按钮,但仅当Header
在ChatContainer
内时。这有意义吗?
这样做的方法是和孩子们一起做。如果从 HTML 的角度来看,儿童实际上非常容易理解:
<div>
<h1>I am the child of div</h1>
</div>
h1
是div
的孩子。如果是 React 组件,Parent
组件将收到一个名为children
的道具,它等于h1
标签:
<Parent>
<h1>I am the child of Parent</h1>
</Parent>
为了在Parent
中呈现,我们只需这样做:
<div id=”Parent”>
{this.props.children}
</div>
让我们看看它的实际效果,希望它会更有意义(并让您了解它的威力)。
在ChatContainer
内,让我们将<Header />
标签替换为一个开始和结束标签:
<Header>
</Header>
在其中,我们将定义我们的按钮:
<Header>
<button className="red">Logout</button>
</Header>
查看我们的页面,我们发现没有任何变化。这是因为我们没有告诉Header
实际渲染其children
。让我们跳到Header.js
并改变它。
在我们的h1
下方,添加以下内容:
import React from 'react';
const Header = (props) => {
return (
<div id="Header">
<img src="/img/icon.png" alt="logo" />
<h1>Chatastrophe</h1>
{props.children}
</div>
);
};
export default Header;
我们在这里干什么?首先,我们将props
定义为功能组件的参数:
const Header = (props) => {
所有功能组件都将props
对象作为其第一个参数接收。
然后,在该对象中,我们访问children
属性,该属性等于我们的按钮。现在,我们的Logout
按钮应该出现:
令人惊叹的如果您检查/login
路径,您会注意到我们的按钮没有出现。那是因为在LoginContainer
中Header
没有children
,所以没有任何渲染。
Children make React components super composable and extra reuseable.
好吧,让我们的按钮真正起作用。我们想调用一个名为firebase.auth().signOut
的方法。让我们为调用此函数的按钮创建一个单击处理程序:
export default class ChatContainer extends Component {
handleLogout = () => {
firebase.auth().signOut();
};
render() {
return (
<div id="ChatContainer">
<Header>
<button className="red" onClick={this.handleLogout}>
Logout
</button>
</Header>
<h1>Hello from ChatContainer</h1>
</div>
);
}
}
现在,当我们按下按钮时,什么也没有发生,但我们正在注销。我们缺少登录拼图的最后一块。
当用户注销时,我们希望将其重定向到登录屏幕。如果我们能知道 Firebase 授权的状态就好了:
这太完美了。单击注销按钮后,当我们的用户注销时,Firebase 将使用用户的空参数调用firebase.auth().onAuthStateChanged
。
换句话说,我们已经拥有了我们所需要的一切;我们只需要在if
语句中添加一个else
,以涵盖找不到用户的情况。
流程将如下所示:
- 当用户单击注销按钮时,Firebase 会将其注销。
- 然后,它将使用空参数调用
onAuthStateChanged
方法。 - 如果使用空用户调用
onAuthStateChanged
,我们将使用history
属性将用户重定向到登录页面。
让我们跳到App.js
来实现这一点。
我们的App
不是Route
的孩子,因此它无法访问LoginContainer
中使用的history
道具,但我们可以使用一些变通方法。
在App.js
顶部,将以下内容添加到我们的react-router-dom
导入中:
import { Route, withRouter } from 'react-router-dom';
然后,在底部,将我们的export default
语句替换为:
export default withRouter(App);
这里发生了什么事?本质上,withRouter
是一个函数,它将一个组件作为一个参数,并按原样返回该组件,只是现在它可以访问history
属性。我们将继续讨论更多内容,但让我们先完成注销流程。
最后,我们可以填写componentDidMount
:
componentDidMount() {
firebase.auth().onAuthStateChanged((user) => {
if (user) {
this.setState({ user });
} else {
this.props.history.push('/login')
}
});
}
尝试再次登录并点击注销按钮。您应该直接进入登录屏幕。魔术
在前面的代码中,我们使用了withRouter
函数(从react-router-dom
导入)为App
组件提供对history
道具的访问权限。让我们花一点时间讨论这是如何运作的,因为它是你能学到的最有力的反应模式之一。
withRouter
是高阶分量(HOC的一个示例。这个略显浮夸的名字比我最喜欢的解释要好:函数构建函数(感谢Tom Coleman的这个解释)。让我们看一个例子。
假设您有一个Button
组件,如下所示:
const Button = (props) => {
return (
<button style={props.style}>{props.text}</button>
);
};
另外,假设有一种情况,我们希望它有白色文本和红色背景:
<Button style={{ backgroundColor: 'red', color: 'white' }} text="I am red!" />
随着应用的发展,你会发现这个按钮经常使用这种特殊的样式。你需要很多红色按钮,上面有不同的文字,每次都要输入backgroundColor
让人厌烦。
不仅如此;您还有另一个组件,即警报框,具有相同的样式:
<AlertBox style={{ backgroundColor: 'red', color: 'white' }} warning="ALERT!" />
在这里,你有两个选择。您需要两个新组件(RedAlertBox
和RedButton
,可以在任何地方使用。您可以如图所示定义它们:
const RedButton = (props) => {
return (
<Button style={{ backgroundColor: 'red', color: 'white' }} text={props.text} />
);
};
以及:
const RedAlertBox = (props) => {
return (
<AlertBox style={{ backgroundColor: 'red', color: 'white' }} warning={props.text} />
);
};
然而,有一种更简单、更可组合的方法,那就是生成一个更高阶的组件。
我们想要实现的是一种方法,采取一个组成部分,并给它的红色对白色的造型。就这样。我们希望将这些道具注入到任何给定的组件中。
让我们看看最终结果,然后看看我们的 HOC 将是什么样子。如果我们成功创建了一个名为makeRed
的 HOC,我们可以使用它来创建我们的RedButton
和RedAlertBox
:
// RedButton.js
import Button from './Button'
import makeRed from './makeRed'
export default makeRed(Button)
// RedAlertBox.js
import AlertBox from './AlertBox'
import makeRed from './makeRed'
export default makeRed(AlertBox)
这更容易,也更易于重用。我们现在可以重用makeRed
将任何组件转换成漂亮的红色背景和白色文本。这就是力量。
好的,那么我们如何创建一个makeRed
函数呢?我们希望将组件作为参数,并返回该组件及其所有指定的道具和正确的样式道具:
import React from 'react';
const makeRed = (Component) => {
const wrappedComponent = (props) => {
return (
<Component style={{ backgroundColor: 'red', color: 'white' }} {...props} />
);
};
return wrappedComponent;
}
export default makeRed;
以下是相同的代码,带有注释:
import React from 'react';
// We receive a component constructor as an argument
const makeRed = (Component) => {
// We make a new component constructor that takes props, just as any component
const wrappedComponent = (props) => {
// This new component returns the original component, but with the style applied
return (
// But we also use the ES6 spread operator to apply the regular props passed in.
// The spread operator applies props like the text in <RedButton text="hello" />
to our new component
// It will "spread" any and all props across our component
<Component style={{ backgroundColor: 'red', color: 'white' }} {...props} />
);
};
// We return the new constructor, so it can be called as <RedButton /> or <RedAlertBox />
return wrappedComponent;
}
export default makeRed;
最令人困惑的可能是{...props}
的扩展运算符。spread 操作符是一个有用但容易混淆的 ES6 工具。它允许您获取一个对象(此处为props
对象),并将其所有键和值应用于一个新对象(组件):
const obj1 = { 1: 'one', 2: 'two' };
const obj2 = { 3: 'three', ...obj1 };
console.log(obj2);
// { 1: 'one', 2: 'two', 3: 'three' }
高阶组件是下一个级别的工具,可使您的 React 组件更易于重用。我们只触及了他们的表面。欲了解更多信息,请查阅Tom Coleman撰写的理解高阶组件,网址为https://medium.freecodecamp.org/understanding-higher-order-components-6ce359d761b 。
正如本章开头所讨论的,Chatastrophe 团队已着手创建用户概要视图。让我们为它做骨架和基本布线。
在src/components
中,创建一个名为UserContainer.js
的新文件。在内部,执行基本组件骨架:
import React, { Component } from 'react';
import Header from './Header';
export default class UserContainer extends Component {
render() {
return (
<div id="UserContainer">
<Header />
<h1>Hello from UserContainer</h1>
</div>
);
}
}
回到App.js
,让我们导入我们的新容器并添加Route
组件:
import UserContainer from './UserContainer';
// Inside render, underneath ChatContainer Route
<Route path="/users" component={UserContainer} />
等等前面的代码在/users
为我们的UserContainer
创建了一条路由,但我们并没有一个用户视图。我们的应用的每个用户都有一个用户视图。我们需要在chatastrophe.com/users/1
为用户 1 和chatastrophe.com/users/2
为用户 2 设置一条路由,以此类推。
我们需要某种方法将变量值传递给我们的path
道具,等于用户id
。幸运的是,这样做很容易:
<Route path="/users/:id" component={UserContainer} />
最好的部分是什么?现在,在我们的UserContainer
中,我们将接收一个props.params.match
对象,等于{ id: 1 }
或id
是什么,然后我们可以使用它来获取该用户的消息。
让我们通过更改UserContainer.js
中的h1
来测试这一点:
<h1>Hello from UserContainer for User {this.props.match.params.id}</h1>
然后,前往localhost:8080/users/1
:
如果在嵌套路由中查找bundle.js
时遇到问题,请确保webpack.config.js
中的输出如下所示:
output: {
path: __dirname + "/public",
filename: "bundle.js",
publicPath: "/"
},
美丽的现在,还有最后一步。让我们为用户添加一种从UserContainer
返回主聊天屏幕的方式。
通过再次利用Header
儿童,我们可以以非常简单的方式做到这一点;只是,在这种情况下,我们可以添加另一个 React 路由组件,使我们的生活超级轻松。它被称为Link
,它就像 HTML 中的一个标记,但针对 React 路由进行了优化。
在UserContainer.js
中:
import { Link } from 'react-router-dom';
<Header>
<Link to="/">
<button className="red">
Back To Chat
</button>
</Link>
</Header>
当您点击该按钮时,您应该被带到/
的根路径。
就这样!为了让应用的路由解决方案启动并运行,我们在本章中介绍了很多内容。如果有什么让人困惑的地方,我邀请您查看上的 React Router 文档 https://reacttraining.com/react-router/ 。接下来,当我们完成基本应用,然后开始将其转换为一个渐进的 Web 应用时,我们将更加深入地讨论 React。