带验证功能的Redux应用
我们将完成一个使用JWT结合Redux的应用。这个应用可以认用户登录并且得到一个token,并且在一个JWT的中间件中访问一些私有的链接。本文不是React和Redux的入门文章,相关知识可以看其它章节。
开始
我们从零开始建一个项目文件夹redux_jwt
package.json
{
"name": "redux_jwt",
"version": "1.0.0",
"description": "redux JWT",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"react": "^0.14.x",
"react-dom": "^0.14.x",
"react-redux": "^4.0.4",
"redux": "^3.0.5",
"redux-thunk": "^0.1.0"
},
"devDependencies": {
"babel-core": "^5.6.18",
"babel-loader": "^5.1.4",
"babel-plugin-react-transform": "^1.1.0",
"express": "^4.13.3",
"webpack": "^1.9.11",
"webpack-dev-middleware": "^1.2.0",
"webpack-hot-middleware": "^2.2.0"
}
}
还需要搭一个简单的服务器:
server.js
var webpack = require('webpack')
var webpackDevMiddleware = require('webpack-dev-middleware')
var webpackHotMiddleware = require('webpack-hot-middleware')
var config = require('./webpack.config')
var app = new (require('express'))()
var port = 3000
var compiler = webpack(config)
app.use(webpackDevMiddleware(compiler, { noInfo: true, publicPath: config.output.publicPath }))
app.use(webpackHotMiddleware(compiler))
app.get("/", function(req, res) {
res.sendFile(__dirname + '/index.html')
})
app.listen(port, function(error) {
if (error) {
console.error(error)
} else {
console.info("==> �� Listening on port %s. Open up http://localhost:%s/ in your browser.", port, port)
}
})
下一步写我们的webpack.config.js
webpack.config.js
var path = require('path')
var webpack = require('webpack')
module.exports = {
devtool: 'cheap-module-eval-source-map',
entry: [
'webpack-hot-middleware/client',
'./index'
],
output: {
path: path.join(__dirname, 'dist'),
filename: 'bundle.js',
publicPath: '/static/'
},
plugins: [
new webpack.optimize.OccurenceOrderPlugin(),
new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin()
],
module: {
loaders: [{
test: /\.js$/,
loaders: [ 'babel' ],
exclude: /node_modules/,
include: __dirname
}, {
test: /\.css?$/,
loaders: [ 'style', 'raw' ],
include: __dirname
}]
}
}
最后还需要一个后端服务器为我们生成JWT规范的token,这个我们直接用一个现成的包,把它放它server子文件夹中。
创建Store
为个简单我们直接把我们的store放到最顶级的index.js中
index.js
import React from 'react'
import { render } from 'react-dom'
import { createStore, applyMiddleware } from 'redux'
import { Provider } from 'react-redux'
import App from './containers/App'
import quotesApp from './reducers'
import thunkMiddleware from 'redux-thunk'
import api from './middleware/api'
let createStoreWithMiddleware = applyMiddleware(thunkMiddleware, api)(createStore)
let store = createStoreWithMiddleware(quotesApp)
let rootElement = document.getElementById('root')
render(
<Provider store={store}>
<App />
</Provider>,
rootElement
)
创建App容器组件放到containers目录中:
app.js
// containers/App.js
import React, { Component, PropTypes } from 'react'
import { connect } from 'react-redux'
import { loginUser, fetchQuote, fetchSecretQuote } from '../actions'
import Login from '../components/Login'
import Navbar from '../components/Navbar'
import Quotes from '../components/Quotes'
class App extends Component {
render() {
const { dispatch, quote, isAuthenticated, errorMessage, isSecretQuote } = this.props
return (
<div>
<Navbar
isAuthenticated={isAuthenticated}
errorMessage={errorMessage}
dispatch={dispatch}
/>
<div className='container'>
<Quotes
onQuoteClick={() => dispatch(fetchQuote())}
onSecretQuoteClick={() => dispatch(fetchSecretQuote())}
isAuthenticated={isAuthenticated}
quote={quote}
isSecretQuote={isSecretQuote}
/>
</div>
</div>
)
}
}
App.propTypes = {
dispatch: PropTypes.func.isRequired,
quote: PropTypes.string,
isAuthenticated: PropTypes.bool.isRequired,
errorMessage: PropTypes.string,
isSecretQuote: PropTypes.bool.isRequired
}
// These props come from the application's
// state when it is started
function mapStateToProps(state) {
const { quotes, auth } = state
const { quote, authenticated } = quotes
const { isAuthenticated, errorMessage } = auth
return {
quote,
isSecretQuote: authenticated,
isAuthenticated,
errorMessage
}
}
export default connect(mapStateToProps)(App)
加入Actions
因为所有请求都通过调用api,所以我们这里用的是异步的Actions,每个Action都会对应三种状态:
- 请求开始(请求中);
- 请求成功;
- 请求失败;
故我们的action看起来是这样的:
actions.js
// actions.js
// There are three possible states for our login
// process and we need actions for each of them
export const LOGIN_REQUEST = 'LOGIN_REQUEST'
export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'
export const LOGIN_FAILURE = 'LOGIN_FAILURE'
function requestLogin(creds) {
return {
type: LOGIN_REQUEST,
isFetching: true,
isAuthenticated: false,
creds
}
}
function receiveLogin(user) {
return {
type: LOGIN_SUCCESS,
isFetching: false,
isAuthenticated: true,
id_token: user.id_token
}
}
function loginError(message) {
return {
type: LOGIN_FAILURE,
isFetching: false,
isAuthenticated: false,
message
}
}
我们的action中包含类型即对应上面那三种请求状态,isFetching
对应是否发在请求数据,isAuthenticated
保存我们用户的登录状态,id_token
则为我们后台传回的用户token。
我们还需要写一些调用这些函数的方法,因为我们一般通过点击按钮来实现登录功能,所以我们要写一些方法来dispatch这些action。
actions.js
// actions.js
...
//调用action取得token,并dispatch三种状态。
export function loginUser(creds) {
let config = {
method: 'POST',
headers: { 'Content-Type':'application/x-www-form-urlencoded' },
body: `username=${creds.username}&password=${creds.password}`
}
return dispatch => {
// dispatch 请求开始状态
dispatch(requestLogin(creds))
return fetch('http://localhost:3001/sessions/create', config)
.then(response =>
response.json().then(user => ({ user, response }))
).then(({ user, response }) => {
if (!response.ok) {
// dispatch 错误状态
dispatch(loginError(user.message))
return Promise.reject(user)
} else {
// 登录成功后,将token存到local storage中,当然也可以放至session storage中
localStorage.setItem('id_token', user.id_token)
// dispatch 成功状态
dispatch(receiveLogin(user))
}
}).catch(err => console.log("Error: ", err))
}
}
...
在一个方法中dispatch了三种状态,对应请求中,请求成功与失败的情况,并在最终成功后把反回的认证信息(token)存到本地的localStorage中。
登出过程不需要调用api我们只是把用户isAuthenticated
设为false并把token从localStorage中移除。
actions.js
// actions.js
...
// 对应退出过程的三种状态,我们可以调用api到后台通知用户退出,这里我们只是把用户`isAuthenticated` 设为false并把token从localStorage中移除。
export const LOGOUT_REQUEST = 'LOGOUT_REQUEST'
export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS'
export const LOGOUT_FAILURE = 'LOGOUT_FAILURE'
function requestLogout() {
return {
type: LOGOUT_REQUEST,
isFetching: true,
isAuthenticated: true
}
}
function receiveLogout() {
return {
type: LOGOUT_SUCCESS,
isFetching: false,
isAuthenticated: false
}
}
...
// 退出方法
export function logoutUser() {
return dispatch => {
dispatch(requestLogout())
localStorage.removeItem('id_token')
dispatch(receiveLogout())
}
}
...
我们没有提供用户注册方法,因为这是与用户登录相似的过程。
用户认证的Reducer
我们需要在reducer中处理用户反回的状态:
reducers.js
// reducers.js
import { combineReducers } from 'redux'
import {
LOGIN_REQUEST, LOGIN_SUCCESS, LOGIN_FAILURE, LOGOUT_SUCCESS
} from './actions'
// 初始值在localStorage中取得,实际应用中我们也需要检查用户是否失效。
function auth(state = {
isFetching: false,
isAuthenticated: localStorage.getItem('id_token') ? true : false
}, action) {
switch (action.type) {
case LOGIN_REQUEST:
return Object.assign({}, state, {
isFetching: true,
isAuthenticated: false,
user: action.creds
})
case LOGIN_SUCCESS:
return Object.assign({}, state, {
isFetching: false,
isAuthenticated: true,
errorMessage: ''
})
case LOGIN_FAILURE:
return Object.assign({}, state, {
isFetching: false,
isAuthenticated: false,
errorMessage: action.message
})
case LOGOUT_SUCCESS:
return Object.assign({}, state, {
isFetching: true,
isAuthenticated: false
})
default:
return state
}
}
function quotes(state = {}, action) {
switch (action.type) {
default:
return state
}
}
// 拼装器
const quotesApp = combineReducers({
auth,
quotes
})
export default quotesApp
NavBar和Login组件
Navbar.js
// components/Navbar.js
import React, { Component, PropTypes } from 'react'
import Login from './Login'
import Logout from './Logout'
import { loginUser, logoutUser } from '../actions'
export default class Navbar extends Component {
render() {
const { dispatch, isAuthenticated, errorMessage } = this.props
return (
<nav className='navbar navbar-default'>
<div className='container-fluid'>
<a className="navbar-brand" href="#">Quotes App</a>
<div className='navbar-form'>
{!isAuthenticated &&
<Login
errorMessage={errorMessage}
onLoginClick={ creds => dispatch(loginUser(creds)) }
/>
}
{isAuthenticated &&
<Logout onLogoutClick={() => dispatch(logoutUser())} />
}
</div>
</div>
</nav>
)
}
}
Navbar.propTypes = {
dispatch: PropTypes.func.isRequired,
isAuthenticated: PropTypes.bool.isRequired,
errorMessage: PropTypes.string
}
下面是一个登陆组件:
Login.js
// components/Login.js
import React, { Component, PropTypes } from 'react'
export default class Login extends Component {
render() {
const { errorMessage } = this.props
return (
<div>
<input type='text' ref='username' className="form-control" style= placeholder='Username'/>
<input type='password' ref='password' className="form-control" style= placeholder='Password'/>
<button onClick={(event) => this.handleClick(event)} className="btn btn-primary">
Login
</button>
{errorMessage &&
<p style=>{errorMessage}</p>
}
</div>
)
}
handleClick(event) {
const username = this.refs.username
const password = this.refs.password
const creds = { username: username.value.trim(), password: password.value.trim() }
this.props.onLoginClick(creds)
}
}
Login.propTypes = {
onLoginClick: PropTypes.func.isRequired,
errorMessage: PropTypes.string
}
退出组件:
Logout.js
// components/Logout.js
import React, { Component, PropTypes } from 'react'
export default class Logout extends Component {
render() {
const { onLogoutClick } = this.props
return (
<button onClick={() => onLogoutClick()} className="btn btn-primary">
Logout
</button>
)
}
}
Logout.propTypes = {
onLogoutClick: PropTypes.func.isRequired
}
使用中间件API获取数据
api.js
// middleware/api.js
const BASE_URL = 'http://localhost:3001/api/'
function callApi(endpoint, authenticated) {
let token = localStorage.getItem('id_token') || null
let config = {}
if(authenticated) {
if(token) {
config = {
headers: { 'Authorization': `Bearer ${token}` }
}
}
else {
throw "No token saved!"
}
}
return fetch(BASE_URL + endpoint, config)
.then(response =>
response.text().then(text => ({ text, response }))
).then(({ text, response }) => {
if (!response.ok) {
return Promise.reject(text)
}
return text
}).catch(err => console.log(err))
}
export const CALL_API = Symbol('Call API')
export default store => next => action => {
const callAPI = action[CALL_API]
// 使中间件不会应用到没有CALL_API的action
if (typeof callAPI === 'undefined') {
return next(action)
}
let { endpoint, types, authenticated } = callAPI
const [ requestType, successType, errorType ] = types
// Passing the authenticated boolean back in our data will let us distinguish between normal and secret quotes
return callApi(endpoint, authenticated).then(
response =>
next({
response,
authenticated,
type: successType
}),
error => next({
error: error.message || 'There was an error.',
type: errorType
})
)
}
在我们的actions里增加这个调用:
actions.js
// actions.js
// The middleware to call the API for quotes
import { CALL_API } from './middleware/api'
...
export const QUOTE_REQUEST = 'QUOTE_REQUEST'
export const QUOTE_SUCCESS = 'QUOTE_SUCCESS'
export const QUOTE_FAILURE = 'QUOTE_FAILURE'
// dispatch这个action时将调用到中间件
export function fetchQuote() {
return {
[CALL_API]: {
endpoint: 'random-quote',
types: [QUOTE_REQUEST, QUOTE_SUCCESS, QUOTE_FAILURE]
}
}
}
// 获取私密数时带上认证结果。将在fetch时带着token头
export function fetchSecretQuote() {
return {
[CALL_API]: {
endpoint: 'protected/random-quote',
authenticated: true,
types: [QUOTE_REQUEST, QUOTE_SUCCESS, QUOTE_FAILURE]
}
}
}
...
接着补充我的的reducer
reducers.js
// reducers.js
...
import {
LOGIN_REQUEST, LOGIN_SUCCESS, LOGIN_FAILURE, LOGOUT_SUCCESS,
QUOTE_REQUEST, QUOTE_SUCCESS, QUOTE_FAILURE
} from './actions'
...
// The quotes reducer
function quotes(state = {
isFetching: false,
quote: '',
authenticated: false
}, action) {
switch (action.type) {
case QUOTE_REQUEST:
return Object.assign({}, state, {
isFetching: true
})
case QUOTE_SUCCESS:
return Object.assign({}, state, {
isFetching: false,
quote: action.response,
authenticated: action.authenticated || false
})
case QUOTE_FAILURE:
return Object.assign({}, state, {
isFetching: false
})
default:
return state
}
}
...
我们这里只是要展示一下token的用法,这个Quotes组件不是必要的:
Quotes.js
// components/Quotes.js
import React, { Component, PropTypes } from 'react'
export default class Quotes extends Component {
render() {
const { onQuoteClick, onSecretQuoteClick, isAuthenticated, quote, isSecretQuote } = this.props
return (
<div>
<div className='col-sm-3'>
<button onClick={onQuoteClick} className="btn btn-primary">
Get Quote
</button>
</div>
{ isAuthenticated &&
<div className='col-sm-3'>
<button onClick={onSecretQuoteClick} className="btn btn-warning">
Get Secret Quote
</button>
</div>
}
<div className='col-sm-6'>
{ quote && !isSecretQuote &&
<div>
<blockquote>{quote}</blockquote>
</div>
}
{ quote && isAuthenticated && isSecretQuote &&
<div>
<span className="label label-danger">Secret Quote</span>
<hr/>
<blockquote>
{quote}
</blockquote>
</div>
}
</div>
</div>
)
}
}
Quotes.propTypes = {
onQuoteClick: PropTypes.func.isRequired,
onSecretQuoteClick: PropTypes.func.isRequired,
isAuthenticated: PropTypes.bool.isRequired,
quote: PropTypes.string,
isSecretQuote: PropTypes.bool.isRequired
}
其实JWT主要是依赖于后台生成并管理token,前台只是持有并转完令牌而已。
附:关于JWT,及前后端分离的一些文章: