构建具有用户身份认证的 React + Flux 应用程序
React 的生态系统很大,为了解决 React 中比较困难的问题,你可以选择多种模块。大多数实际的 React 应用程序都有一些共同的需求,这些需求主要包括状态管理及路由。而解决这些需求最常用的是 Flux 及 React Router。 在 Scotch 上, Ken 有一些关于React 和 Flux 的 awesome series,当然,网上也有很多关于这些话题的教程。但是,在构建一个真实的 React 应用程序时,我们还需要考虑其它一些不经常讨论的事情:如何调用远程 API 以及如何验证用户身份。 在这篇教程中,我们将通过 API 获取数据的方式制作一个简单的通讯录应用。我们会使用 Express (NodeJS)服务器发送数据,需要说明的是并不一定非要使用 Node。只要能输出 JSON 数据,我们可以使用任何服务器。 单页应用中进行用户身份验证的最好方式就是 JSON Web Tokens (JWT) 。从头开始设置 JWT 身份验证非常繁琐,所以我们将使用 Auth0 。 使用 Auth0,我们只需要放置一个 script 标签就可以立即得到一个 登录框 ,它具有 社交登录 ,多重身份认证 等等。 当我们 注册 Auth0 之后,我们会得到一个免费账户,它提供 7,000 个免费用户以及两个社交认证供应商。最好的一点是这个账户是针对产品就绪的,所以我们可以开发真正的应用程序。 开始吧! 创建一个新的 React 项目在这篇教程中,我们将使用 React 以及 ES2015,这意味着我们需要一个编译器才能使用所有特性并兼容所有浏览器。我们会使用 webpack 编译,而使用 React + Webpack 构建一个新项目最简单的方式就是使用 Yeoman 的生成器。 npm install -g yo npm install -g generator-react-webpack mkdir react-auth && cd react-auth yo react-webpack 根据 Yeoman 的提示一步步安装,最后会得到一个搭配 webpack 的 React 新项目。 还需要安装一些 Yeoman 中没有的依赖包,快开始吧。 npm install flux react-router bootstrap react-bootstrap keymirror superagent
为了使用 React Bootstrap,我们需要对 webpack 配置文件中的 // cfg/defaults.js ... { test: /.(png|woff|woff2|eot|ttf|svg)$/,loader: 'url-loader?limit=8192' },... 另外,我们要改一下 webpack 用于保存项目的路径,否则使用 React Router 会出问题。打开 open('http://localhost:' + config.port + '/webpack-dev-server/'); 改成 open('http://localhost:' + config.port); 创建一个 Express 服务器项目开始之前我们先创建 Express 服务器,保证 React 应用程序可以获取数据。这个服务器非常简单,我们只需要几个依赖模块。 mkdir react-auth-server && cd react-auth-server npm init npm install express express-jwt cors touch server.js 安装 express-jwt 包是为了创建用户身份验证的中间件来保护 API 端口。 // server.js const express = require('express'); const app = express(); const jwt = require('express-jwt'); const cors = require('cors'); app.use(cors()); // Authentication middleware provided by express-jwt. // This middleware will check incoming requests for a valid // JWT on any routes that it is applied to. const authCheck = jwt({ secret: new Buffer('YOUR_AUTH0_SECRET','base64'),audience: 'YOUR_AUTH0_CLIENT_ID' }); var contacts = [ { id: 1,name: 'Chris Sevilleja',email: 'chris@scotch.io',image: '//gravatar.com/avatar/8a8bf3a2c952984defbd6bb48304b38e?s=200' },{ id: 2,name: 'Nick Cerminara',email: 'nick@scotch.io',image: '//gravatar.com/avatar/5d0008252214234c609144ff3adf62cf?s=200' },{ id: 3,name: 'Ado Kukic',email: 'ado@scotch.io',image: '//gravatar.com/avatar/99c4080f412ccf46b9b564db7f482907?s=200' },{ id: 4,name: 'Holly Lloyd',email: 'holly@scotch.io',image: '//gravatar.com/avatar/5e074956ee8ba1fea26e30d28c190495?s=200' },{ id: 5,name: 'Ryan Chenkie',email: 'ryan@scotch.io',image: '//gravatar.com/avatar/7f4ec37467f2f7db6fffc7b4d2cc8dc2?s=200' } ]; app.get('/api/contacts',(req,res) => { const allContacts = contacts.map(contact => { return { id: contact.id,name: contact.name} }); res.json(allContacts); }); app.get('/api/contacts/:id',authCheck,res) => { res.json(contacts.filter(contact => contact.id === parseInt(req.params.id))); }); app.listen(3001); console.log('Listening on http://localhost:3001'); 我们得到了从两个端口返回的联系人数据数组。在 注册 Auth0你可能注意到我们在 Express 服务器中定义的 如果你还没有 注册 Auth0,那现在就去注册一个。在你注册之后,你会在 management area 中找到用户密码及用户 ID。拿到这些关键信息之后,你要把它们放到中间件的合适位置,这样就大功告成了。 你要在 “Allowed Origins” 输入框中输入 localhost 域名及端口,这样 Auth0 才允许从测试域名获取请求。 创建 Index 文件和路由先设置 // src/index.js import 'core-js/fn/object/assign'; import React from 'react'; import ReactDOM from 'react-dom'; import { browserHistory } from 'react-router'; import Root from './Root'; // Render the main component into the dom ReactDOM.render(<Root history={browserHistory} />,document.getElementById('app')); 我们渲染了一个名为 为了完成路由设置,我们需要创建一个设置路由的 // Root.js import React,{ Component } from 'react'; import { Router,Route,IndexRoute } from 'react-router'; import Index from './components/Index'; import ContactDetail from './components/ContactDetail'; import App from './components/App'; class Root extends Component { // We need to provide a list of routes // for our app,and in this case we are // doing so from a Root component render() { return ( <Router history={this.props.history}> <Route path='/' component={App}> <IndexRoute component={Index}/> <Route path='/contact/:id' component={ContactDetail} /> </Route> </Router> ); } } export default Root; 通过 React Router ,我们可以使用 现在我们还应该添加 Lock 组件。可以使用 npm 安装,然后通过 webpack 构建的方式添加,或者作为 script 标签插入。为了简单一点,我们直接使用一个 script 标签插入。 <!-- src/index.html -->
...
<!-- Auth0Lock script -->
<script src="//cdn.auth0.com/js/lock-9.1.min.js"></script>
<!-- Setting the right viewport -->
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no" />
...
创建 App 组件我们设置的第一个组件是 // src/components/App.js import 'normalize.css/normalize.css'; import 'bootstrap/dist/css/bootstrap.min.css'; import React,{ Component } from 'react'; import Header from './Header'; import Sidebar from './Sidebar'; import { Grid,Row,Col } from 'react-bootstrap'; class AppComponent extends Component { componentWillMount() { this.lock = new Auth0Lock('YOUR_AUTH0_CLIENT_ID','YOUR_AUTH0_DOMAIN); } render() { return ( <div> <Header lock={this.lock}></Header> <Grid> <Row> <Col xs={12} md={3}> <Sidebar /> </Col> <Col xs={12} md={9}> {this.props.children} </Col> </Row> </Grid> </div> ); } } export default AppComponent; 我们调用了名为 需要注意的一点是我们在第二个 我们已经将 创建 Header 组件导航条可以放置用户用来登录及注销应用程序的按钮。 // src/components/Header.js import React,{ Component } from 'react'; import { Nav,Navbar,NavItem,Header,Brand } from 'react-bootstrap'; // import AuthActions from '../actions/AuthActions'; // import AuthStore from '../stores/AuthStore'; class HeaderComponent extends Component { constructor() { super(); this.state = { authenticated: false } this.login = this.login.bind(this); this.logout = this.logout.bind(this); } login() { // We can call the show method from Auth0Lock, // which is passed down as a prop,to allow // the user to log in this.props.lock.show((err,profile,token) => { if (err) { alert(err); return; } this.setState({authenticated: true}); }); } logout() { // AuthActions.logUserOut(); this.setState({authenticated: false}); } render() { return ( <Navbar> <Navbar.Header> <Navbar.Brand> <a href="#">React Contacts</a> </Navbar.Brand> </Navbar.Header> <Nav> <NavItem onClick={this.login}>Login</NavItem> <NavItem onClick={this.logout}>Logout</NavItem> </Nav> </Navbar> ); } } export default HeaderComponent; 不可否认,我们省略了用户验证的一些细节,因为我们还没有创建 actions 和 stores。但是,现在已经可以看到程序的工作流程。 在我们看到屏幕上的东西之前,我们需要先创建 创建 Sidebar 和 Index 组件// src/components/Sidebar.js import React,{ Component } from 'react'; class SidebarComponent extends Component { render() { return ( <h1>Hello from the sidebar</h1> ); } } export default SidebarComponent; 最终这个组件会渲染从服务器返回的联系人列表,但是现在只展示一条简单的信息。 我们需要一个 // src/components/Index.js import React,{ Component } from 'react'; class IndexComponent extends Component { constructor() { super(); } render() { return ( <h2>Click on a contact to view their profile</h2> ); } } export default IndexComponent; 现在准备查看我们的应用程序。但是首先我们要删除或者注释掉 如果一切顺利,我们应该能看到渲染出的应用程序。 当我们点击 Login,应该可以看到 Lock 组件。 使用 FluxFlux 非常适合状态管理,但是它的缺点就是需要大量代码,这意味着这一部分有些啰嗦。为了尽可能简洁,我们不会详细讨论 Flux 是什么以及如何工作,如果你想深入了解,你可以阅读 Ken 的文章 。 简单介绍一下 Flux,它是一种帮助我们处理应用程序中单向数据流的结构。当应用程序变得庞大时,拥有一个单向流动的数据结构非常重要,因为相比混乱的双向数据流更容易理解。 为了做到这一点,Flux 需要 actions,dispatcher 以及 stores 。 创建 Dispatcher先创建一个 dispatcher 。 // src/dispatcher/AppDispatcher.js import { Dispatcher } from 'flux'; const AppDispatcher = new Dispatcher(); export default AppDispatcher; 在 React + Flux 应用中只有一个 dispatcher,可以通过调用 创建 Actions接下来,我们创建 actions 检索从 API 获取的联系人数据。 // src/actions/ContactActions.js import AppDispatcher from '../dispatcher/AppDispatcher'; import ContactConstants from '../constants/ContactConstants'; import ContactsAPI from '../utils/ContactsAPI'; export default { recieveContacts: () => { ContactsAPI .getContacts('http://localhost:3001/api/contacts') .then(contacts => { AppDispatcher.dispatch({ actionType: ContactConstants.RECIEVE_CONTACTS,contacts: contacts }); }) .catch(message => { AppDispatcher.dispatch({ actionType: ContactConstants.RECIEVE_CONTACTS_ERROR,message: message }); }); },getContact: (id) => { ContactsAPI .getContact('http://localhost:3001/api/contacts/' + id) .then(contact => { AppDispatcher.dispatch({ actionType: ContactConstants.RECIEVE_CONTACT,contact: contact }); }) .catch(message => { AppDispatcher.dispatch({ actionType: ContactConstants.RECIEVE_CONTACT_ERROR,message: message }); }); } } 在一个 Flux 架构中,actions 需要 人们对于是否在应该在 actions 中调用 API 等操作有不同的看法,有些人认为应该保存在 stores 中。最终,你选择的方式取决于它是否适合你的应用程序,在 actions 中调用 API 是处理远程数据比较好的方式。 该组件依赖还没有创建的 创建 Contact Constants// src/constants/ContactConstants.js import keyMirror from 'keymirror'; export default keyMirror({ RECIEVE_CONTACT: null,RECIEVE_CONTACTS: null,RECIEVE_CONTACT_ERROR: null,RECIEVE_CONTACTS_ERROR: null }); Constants 可以识别 action 的类型,可以同步 actions 及 stores,之后会看到。我们使用 创建 Contacts API我们已经从 // src/utils/ContactsAPI.js import request from 'superagent/lib/client'; export default { // We want to get a list of all the contacts // from the API. This list contains reduced info // and will be be used in the sidebar getContacts: (url) => { return new Promise((resolve,reject) => { request .get(url) .end((err,response) => { if (err) reject(err); resolve(JSON.parse(response.text)); }) }); },getContact: (url) => { return new Promise((resolve,response) => { if (err) reject(err); resolve(JSON.parse(response.text)); }) }); } } 通过 superagent,我们可以调用 如果我们在请求中遇到任何错误, 我们可以 创建 Contact Store在我们将通讯录数据渲染到屏幕上之前,我们需要创建 store 。 // src/stores/ContactStore.js import AppDispatcher from '../dispatcher/AppDispatcher'; import ContactConstants from '../constants/ContactConstants'; import { EventEmitter } from 'events'; const CHANGE_EVENT = 'change'; let _contacts = []; let _contact = {}; function setContacts(contacts) { _contacts = contacts; } function setContact(contact) { _contact = contact; } class ContactStoreClass extends EventEmitter { emitChange() { this.emit(CHANGE_EVENT); } addChangeListener(callback) { this.on(CHANGE_EVENT,callback) } removeChangeListener(callback) { this.removeListener(CHANGE_EVENT,callback) } getContacts() { return _contacts; } getContact() { return _contact; } } const ContactStore = new ContactStoreClass(); // Here we register a callback for the dispatcher // and look for our various action types so we can // respond appropriately ContactStore.dispatchToken = AppDispatcher.register(action => { switch(action.actionType) { case ContactConstants.RECIEVE_CONTACTS: setContacts(action.contacts); // We need to call emitChange so the event listener // knows that a change has been made ContactStore.emitChange(); break case ContactConstants.RECIEVE_CONTACT: setContact(action.contact); ContactStore.emitChange(); break case ContactConstants.RECIEVE_CONTACT_ERROR: alert(action.message); ContactStore.emitChange(); break case ContactConstants.RECIEVE_CONTACTS_ERROR: alert(action.message); ContactStore.emitChange(); break default: } }); export default ContactStore; 和大多数 stores 的功能一样,我们在 我们已经有了获取单个联系人或者整个列表的逻辑,这些方法会用在组件中。 在看到通讯录之前,我们需要创建几个组件来专门处理我们的列表。 创建 Contacts 组件
// src/components/Contacts.js import React,{ Component } from 'react'; import { ListGroup } from 'react-bootstrap'; // import { Link } from 'react-router'; import ContactActions from '../actions/ContactActions'; import ContactStore from '../stores/ContactStore'; import ContactListItem from './ContactListItem'; // We'll use this function to get a contact // list item for each of the contacts in our list function getContactListItem(contact) { return ( <ContactListItem key={contact.id} contact={contact} /> ); } class ContactsComponent extends Component { constructor() { super(); // For our initial state,we just want // an empty array of contacts this.state = { contacts: [] } // We need to bind this to onChange so we can have // the proper this reference inside the method this.onChange = this.onChange.bind(this); } componentWillMount() { ContactStore.addChangeListener(this.onChange); } componentDidMount() { ContactActions.recieveContacts(); } componentWillUnmount() { ContactStore.removeChangeListener(this.onChange); } onChange() { this.setState({ contacts: ContactStore.getContacts() }); } render() { let contactListItems; if (this.state.contacts) { // Map over the contacts and get an element for each of them contactListItems = this.state.contacts.map(contact => getContactListItem(contact)); } return ( <div> <ListGroup> {contactListItems} </ListGroup> </div> ); } } export default ContactsComponent; 我们需要有一个初始状态, 如果使用 ES2015,可以在constructor 中设置 当组件加载后,我们通过直接调用 我们使用 map 方法循环设置了状态的 创建 Contact List Item 组件
// src/components/ContactListItem.js import React,{ Component } from 'react'; import { ListGroupItem } from 'react-bootstrap'; import { Link } from 'react-router'; class ContactListItem extends Component { render() { const { contact } = this.props; return ( <ListGroupItem> <Link to={`/contact/${contact.id}`}> <h4>{contact.name}</h4> </Link> </ListGroupItem> ); } } export default ContactListItem; 我们通过 修改 Sidebar在预览应用之前做最后一次调整,就是修改 // src/components/Sidebar.js import React,{ Component } from 'react'; import Contacts from './Contacts'; class SidebarComponent extends Component { render() { return ( <Contacts /> ); } } export default SidebarComponent; 完成这一步,我们就可以查看联系人列表了。 创建 Contact Detail 组件应用程序的最后一部分是联系人详情区域,它占据页面的主要部分。当点击联系人姓名时,会向服务器端发送请求,然后接收联系人信息并显示出来。 你已经注意到,在我们设置 Express 应用时,一开始我们就向 我们已经有了处理单个联系人的 action 和 store,所以让我们开始编写组件。 // src/components/ContactDetail.js import React,{ Component } from 'react'; import ContactActions from '../actions/ContactActions'; import ContactStore from '../stores/ContactStore'; class ContactDetailComponent extends Component { constructor() { super(); this.state = { contact: {} } this.onChange = this.onChange.bind(this); } componentWillMount() { ContactStore.addChangeListener(this.onChange); } componentDidMount() { ContactActions.getContact(this.props.params.id); } componentWillUnmount() { ContactStore.removeChangeListener(this.onChange); } componentWillReceiveProps(nextProps) { this.setState({ contact: ContactActions.getContact(nextProps.params.id) }); } onChange() { this.setState({ contact: ContactStore.getContact(this.props.params.id) }); } render() { let contact; if (this.state.contact) { contact = this.state.contact; } return ( <div> { this.state.contact && <div> <img src={contact.image} width="150" /> <h1>{contact.name}</h1> <h3>{contact.email}</h3> </div> } </div> ); } } export default ContactDetailComponent; 这个组件看上去和 回顾 Contact Detail 路由在预览这个组件之前,我们回顾 / src/Root.js ... render() { return ( <Router history={this.props.history}> <Route path='/' component={App}> <IndexRoute component={Index}/> <Route path='/contact/:id' component={ContactDetail} /> </Route> </Router> ); } ... 现在我们可以点击联系人查看详情,但是无权访问。 这个无权访问的错误是因为服务器端的中间件在保护联系人的详情资源。服务器需要一个有效的 JWT 才允许请求。为了做到这一点,我们首先需要对用户进行身份验证。让我们完成验证部分。 完成用户身份认证当用户使用 Auth0 登录后会发生什么? 回调函数会返回很多内容,其中最重要的是 好消息是, 由于大部分的工作在 Auth0 的沙盒中完成,所以我们已经完成了身份认证。我们需要做的认证部分就是提供处理用户信息数据的逻辑以及成功登陆后返回的 JWT。 我们将遵循 Flux 的架构,为认证创建一系列的 actions,constants 以及 store 。 创建 AuthActions// src/actions/AuthActions.js import AppDispatcher from '../dispatcher/AppDispatcher'; import AuthConstants from '../constants/AuthConstants'; export default { logUserIn: (profile,token) => { AppDispatcher.dispatch({ actionType: AuthConstants.LOGIN_USER,profile: profile,token: token }); },logUserOut: () => { AppDispatcher.dispatch({ actionType: AuthConstants.LOGOUT_USER }); } } 以上设置和 创建 Auth Constants我们的用户身份认证需要一些新的 constants (静态变量) // src/constants/AuthConstants.js import keyMirror from 'keymirror'; export default keyMirror({ LOGIN_USER: null,LOGOUT_USER: null }); 创建 Auth Store
// src/stores/AuthStore.js import AppDispatcher from '../dispatcher/AppDispatcher'; import AuthConstants from '../constants/AuthConstants'; import { EventEmitter } from 'events'; const CHANGE_EVENT = 'change'; function setUser(profile,token) { if (!localStorage.getItem('id_token')) { localStorage.setItem('profile',JSON.stringify(profile)); localStorage.setItem('id_token',token); } } function removeUser() { localStorage.removeItem('profile'); localStorage.removeItem('id_token'); } class AuthStoreClass extends EventEmitter { emitChange() { this.emit(CHANGE_EVENT); } addChangeListener(callback) { this.on(CHANGE_EVENT,callback) } isAuthenticated() { if (localStorage.getItem('id_token')) { return true; } return false; } getUser() { return localStorage.getItem('profile'); } getJwt() { return localStorage.getItem('id_token'); } } const AuthStore = new AuthStoreClass(); // Here we register a callback for the dispatcher // and look for our various action types so we can // respond appropriately AuthStore.dispatchToken = AppDispatcher.register(action => { switch(action.actionType) { case AuthConstants.LOGIN_USER: setUser(action.profile,action.token); AuthStore.emitChange(); break case AuthConstants.LOGOUT_USER: removeUser(); AuthStore.emitChange(); break default: } }); export default AuthStore;
但是让我们再考虑一下。在传统的身份认证设置中,当用户成功登录时,服务器会生成一个 session ,这个 session 稍后用于检查用户是否经过身份认证。然而,JWT 认证是无状态的,它的工作原理是通过服务器去检查请求中的 token 令牌是否与密钥匹配。没有会话或也没有必要的状态。 出于很多原因 ,这是一种很好的方式,但是在我们的前端应用中应该如何验证用户的身份。 好消息是,我们真正需要做的是检查令牌是否保存在本地存储中。如果令牌无效,则请求将被拒绝,用户将需要重新登录。我们可以进一步检查令牌是否已经过期,但是现在只需要检查 JWT 是否存在。 修改 Header 组件让我们赶快修改 header 组件,这样它就可以使用 // src/components/Header.js ... import AuthActions from '../actions/AuthActions'; import AuthStore from '../stores/AuthStore'; class HeaderComponent extends Component { ... login() { this.props.lock.show((err,token) => { if (err) { alert(err); return; } AuthActions.logUserIn(profile,token); this.setState({authenticated: true}); }); } logout() { AuthActions.logUserOut(); this.setState({authenticated: false}); } ... 正确修改文件之后,如果用户已经登录,用户信息及 JWT 会被保存。 发送身份认证请求联系人详情资源受 JWT 身份认证的保护,现在我们为用户添加了有效的 JWT 。我们还需要在发送请求时将令牌添加到 // src/utils/ContactsAPI.js import AuthStore from '../stores/AuthStore'; ... getContact: (url) => { return new Promise((resolve,reject) => { request .get(url) .set('Authorization','Bearer ' + AuthStore.getJwt()) .end((err,response) => { if (err) reject(err); resolve(JSON.parse(response.text)); }) }); } } 我们在 最后:根据条件显示和隐藏元素我们的应用程序已经做的差不多了!最后,让我们根据条件展示和隐藏一些元素。我们将在用户未验证时显示“Login”导航项,而验证之后将其隐藏起来。 “Logout”导航项正好相反。 // src/components/Header.js ... constructor() { super(); this.state = { authenticated: AuthStore.isAuthenticated() } ... } ... render() { return ( <Navbar> <Navbar.Header> <Navbar.Brand> <a href="#">React Contacts</a> </Navbar.Brand> </Navbar.Header> <Nav> { !this.state.authenticated ? ( <NavItem onClick={this.login}>Login</NavItem> ) : ( <NavItem onClick={this.logout}>Logout</NavItem> )} </Nav> </Navbar> ); } ... 当组件加载后,我们从 store 中获得用户的身份验证状态。根据 我们可以用同样的方法设置 // src/components/Index.js ... constructor() { super(); this.state = { authenticated: AuthStore.isAuthenticated() } } render() { return ( <div> { !this.state.authenticated ? ( <h2>Log in to view contact details</h2> ) : ( <h2>Click on a contact to view their profile</h2> )} </div> ); } ... 总结如果你跟着本教程做完,现在你已经有了一个 React + Flux 的应用,它调用 API 获取数据以及使用 Auth0 完成用户身份认证。非常棒! 毫无疑问: 创建一个 React + Flux 应用程序需要写大量代码,而构建小项目很难看到它的优势。但是,随着应用程序体量的增长,单向数据流以及 Flux 遵循的应用结构变得非常重要。当应用程序变得越来越大时,有必要消除双向绑定带来的困惑。 幸运的是,令人棘手的身份验证部分使用 Auth0 来做非常简单。如果你的应用程序没有使用 Node 作为后端,务必选择适合你的 Auth0 SDK 。几乎所有流行的语言和框架都有集成,包括:
(编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |