Django + React、Redux 和 JWT
前言
先决条件
创建 Django 项目
创建 React 应用程序
API 服务
重制版
成分
跑步
资源
前言
过去四年我一直在用 Python 编程,做 Web 开发时,我总是选择 Django + Bootstrap 和 jQuery。我也知道如何用 CSS 来设计页面样式,但仅限于此。我一直对现代单页应用(SPA)及其框架/库(例如 React、Angular 或 Vue)敬而远之。我曾经尝试过一次,体验非常糟糕。我对 Babel、Webpack、Yarn 以及它们如何协同工作一无所知。更别提 JavaScript 中的箭头函数和解构赋值了。但最终我决定再试一次,于是花了无数个小时观看 React 教程。现在,我尝试让 Django 后端与 React 前端协同工作。
本文的目标是构建一个最小化的后端,支持 JSON Web Token 身份验证,以及一个简单的前端,具备登录/注销功能,还有一个仅限登录用户访问的受保护页面。这主要是为了我自己尝试实现所有功能。这样,如果将来需要重现这些步骤,我只需回顾一下我的操作并重复即可。因此,我决定:
- 保留默认的 SQLite 数据库,以便根据需要进行替换。
- 不要使用任何 UI 框架或样式,因为那样会显得过于主观,而且并不适用于每个项目。
还有一点需要说明。我不会详细讲解这里列出的代码。如果你想真正理解,网上有很多有用的资料。我会列出所有帮助过我的资源。这只是一个操作指南。完整的代码可以在我的GitHub和GitLab上找到。
好了,废话不多说,准备好开始阅读吧!希望对你有所帮助😊
先决条件
你的系统需要安装以下软件包:Python(版本 3,没有旧版本代码 😎)、pip、Node、npm 和 Yarn。我使用的是 Arch Linux,所以列出的命令应该与其他类 Unix 系统相同或类似。
我们先创建一个项目目录,mkdir djact然后cd进入该目录。接着创建一个虚拟环境python -m venv venv并激活它source venv/bin/activate。
创建 Django 项目
安装 Django、REST Framework 和 JWT 处理程序pip install django djangorestframework djangorestframework-simplejwt django-cors-headers。最后一个包是必需的,它允许我们的开发 React 服务器与 Django 应用交互。安装完成后,让我们保存依赖项:pip freeze > requirements.txt。现在创建一个新项目django-admin startproject djact .。注意.末尾的 `--project-name`,它告诉 Django 在当前目录中创建项目。
应用
我喜欢把所有应用和设置都放在一个单独的目录里。所以我们来创建这样一个目录:mkdir djact/{apps, settings}。然后把setting.py新创建的设置目录移动到该目录下。创建settings一个包touch djact/settings/__init__.py,并在其中插入以下代码:
# djact/settings/__init__.py
from .settings import *
这里以及每个文件列表中的第一行都会包含一条注释,其中包含文件的相对路径。特此说明。
这样我们就无需覆盖该DJANGO_SETTINGS_MODULE变量了。
核
现在创建一个目录,用于存放核心应用程序mkdir djact/apps/core和应用程序本身python manage.py startapp core djact/apps/core。在这个新创建的目录中,mkdir {templates,templatetags}创建
一个空的__init__.pyReact 加载器模板标签:load_react.pytemplatetags
# djact/apps/core/templatetags/load_react.py
from django import template
from django.conf import settings
from django.utils.safestring import mark_safe
register = template.Library()
@register.simple_tag
def load_react():
css = load_css()
js = load_js()
return mark_safe(''.join(css + js))
def load_css():
return [
f'<link rel="stylesheet" href="/static/{asset}"/>'
for asset in load_files('.css')
]
def load_js():
return [
f'<script type="text/javascript" src="/static/{asset}"></script>'
for asset in load_files('.js')
]
def load_files(extension: str):
files = []
for path in settings.STATICFILES_DIRS:
for file_name in path.iterdir():
if file_name.name.endswith(extension):
files.append(file_name.name)
return files
我知道有django-webpack-loader,但我更喜欢像上面这样更简单的方法。
接下来,在目录中创建index.html以下内容templates:
{# djact/apps/core/templates/index.html #}
{% load static %}
{% load load_react %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>Djact</title>
<link rel="icon" href="{% static 'favicon.ico' %}">
</head>
<body>
<div id="app"></div>
{% load_react %}
</body>
</html>
验证
接下来我们需要一个用于身份验证的应用程序,因此mkdir djact/apps/authentication需要python manage.py startapp authentication djact/apps/authentication编辑此目录中的models.py文件:
# djact/apps/authentication/models.py
from django.contrib.auth.models import AbstractUser
class User(AbstractUser):
class Meta:
verbose_name = 'User'
verbose_name_plural = 'Users'
def __str__(self):
return f'<{self.id}> {self.username}'
接下来我们需要一个序列化器,供用户注册djact/apps/authentication/serializers.py:
# djact/apps/authentication/serializers.py
from rest_framework import serializers
from .models import User
class UserSerializer(serializers.ModelSerializer):
email = serializers.EmailField(required=True)
username = serializers.CharField()
password = serializers.CharField(min_length=8, write_only=True)
class Meta:
model = User
fields = ('email', 'username', 'password')
extra_kwargs = {'password': {'write_only': True}}
def create(self, validated_data):
password = validated_data.pop('password', None)
instance = self.Meta.model(**validated_data)
if password is not None:
instance.set_password(password)
instance.save()
return instance
然后是这样的视图djact/apps/authentication/views.py:
# djact/apps/authentication/views.py
from rest_framework import permissions
from rest_framework.generics import CreateAPIView
from rest_framework.response import Response
from rest_framework.views import APIView
from .serializers import UserSerializer
class UserCreate(CreateAPIView):
permission_classes = (permissions.AllowAny,)
authentication_classes = ()
serializer_class = UserSerializer
user_create = UserCreate.as_view()
class Protected(APIView):
def get(self, request):
return Response(data={'type': 'protected'})
protected = Protected.as_view()
这样Protected做是为了检查我们是否只能在登录后才能访问该页面。
至于 URL,我们将提供指向两个视图的路径,以及获取和刷新 JWT 的路径:
# djact/apps/authentication/urls.py
from django.urls import path
from rest_framework_simplejwt import views as jwt_views
from . import views
app_name = 'authentication'
urlpatterns = [
path(
'users/create/',
views.user_create,
name='sign-up'
),
path(
'token/obtain/',
jwt_views.TokenObtainPairView.as_view(),
name='token-create'
),
path(
'token/refresh/',
jwt_views.TokenRefreshView.as_view(),
name='token-refresh'
),
path(
'protected/',
views.protected,
name='protected'
)
]
主界面更新urls.py于djact:
# djact/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('djact.apps.authentication.urls')),
]
设置
我喜欢这个新Pathlib模块,所以我们用它重写所有代码,而不是用原来的模块os。我用它django-environ来处理环境变量,所以我们先安装它pip install django-environ && pip freeze > requirements.txt。DJANGO_SECRET_KEY从现有的配置中复制,这样你就不需要生成新的配置了(虽然生成也很容易)。我们会把这些配置放到一个.env文件中。
# djact/settings/settings.py
import pathlib
from datetime import timedelta
import environ
BASE_DIR = pathlib.Path(__file__).parent.parent
PROJECT_ROOT = BASE_DIR.parent
env = environ.Env()
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env.str('DJANGO_SECRET_KEY')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env.bool('DJANGO_DEBUG', False)
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=list())
# Application definition
INSTALLED_APPS = [
'djact.apps.authentication',
'djact.apps.core',
'rest_framework',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'djact.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'djact.wsgi.application'
# Database
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': str(BASE_DIR.joinpath('db.sqlite3')),
}
}
# Password validation
# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
AUTH_USER_MODEL = 'authentication.User'
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
), #
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
'REFRESH_TOKEN_LIFETIME': timedelta(days=30),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': False,
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'VERIFYING_KEY': None,
'AUTH_HEADER_TYPES': ('JWT',),
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
'TOKEN_TYPE_CLAIM': 'token_type',
}
LOGIN_URL = '/login'
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/login'
# Internationalization
# https://docs.djangoproject.com/en/3.0/topics/i18n/
LANGUAGE_CODE = 'ru'
TIME_ZONE = 'Europe/Moscow'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.0/howto/static-files/
STATIC_URL = '/static/'
STATICFILES_DIRS = [
PROJECT_ROOT.joinpath('static'),
]
STATIC_ROOT = PROJECT_ROOT / 'public' / 'static'
pathlib.Path(STATIC_ROOT).mkdir(exist_ok=True, parents=True)
MEDIA_URL = '/media/'
MEDIA_ROOT = PROJECT_ROOT / 'public' / 'media'
pathlib.Path(MEDIA_ROOT).mkdir(exist_ok=True, parents=True)
# Logging
LOG_DIR = PROJECT_ROOT / 'log'
LOG_DIR.mkdir(exist_ok=True)
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'console': {
'format': '%(levelname)-8s %(name)-12s %(module)s:%(lineno)s\n'
'%(message)s'
},
'file': {
'format': '%(asctime)s %(levelname)-8s %(name)-12s '
'%(module)s:%(lineno)s\n%(message)s'
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'console',
},
'file': {
'class': 'logging.handlers.RotatingFileHandler',
'formatter': 'file',
'filename': LOG_DIR / 'django.log',
'backupCount': 10, # keep at most 10 files
'maxBytes': 5 * 1024 * 1024 # 5MB
},
},
'loggers': {
'django.request': {
'handlers': ['console', 'file'],
'level': 'DEBUG',
'propagate': True,
},
},
}
LOGGING['loggers'].update(
{app: {
'handlers': ['console', 'file'],
'level': 'DEBUG',
'propagate': True,
} for app in INSTALLED_APPS}
)
# Load dev config
if DEBUG:
try:
from .dev import *
except ModuleNotFoundError:
print('Dev config not found')
我们可以覆盖一些设置,或者添加一些仅与开发环境相关的内容,djact/settings/dev.py这就是为什么我们需要最后 5 行代码。我的dev.py代码看起来像这样:
# djact/settings/dev.py
from .settings import LOGGING, INSTALLED_APPS, MIDDLEWARE
LOGGING['handlers']['file']['backupCount'] = 1
INSTALLED_APPS += ['corsheaders']
CORS_ORIGIN_ALLOW_ALL = True
MIDDLEWARE.insert(2, 'corsheaders.middleware.CorsMiddleware')
在这里,我们告诉 Django 允许与我们的 React 开发服务器进行交互,该服务器将在不同的端口上运行,因此被视为跨域。
我们的 .env.example 文件内容如下:
<!-- .env.example -->
PYTHONDONTWRITEBYTECODE=1
DJANGO_SECRET_KEY=random long string
DJANGO_DEBUG=True for dev environment|False or omit completely for production
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1:8000,0.0.0.0:8000
所以,创建一个.env包含这些变量的文件。
现在urls.py在内部djact/apps/core/目录中创建以下内容:
# djact/apps/core/urls.py
from django.urls import re_path
from django.views.generic import TemplateView
app_name = 'core'
urlpatterns = [
re_path(r'^.*$', TemplateView.as_view(template_name='index.html'), name='index'),
]
并更新主 URL 文件:
# djact/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('djact.apps.authentication.urls')),
path('', include('djact.apps.core.urls')),
]
然后运行python manage.py makemigrations并python manage.py migrate。
我们的目录结构应该如下所示:
.
├── djact
│ ├── apps
│ │ ├── authentication
│ │ │ ├── admin.py
│ │ │ ├── apps.py
│ │ │ ├── __init__.py
│ │ │ ├── migrations
│ │ │ │ ├── 0001_initial.py
│ │ │ │ └── __init__.py
│ │ │ ├── models.py
│ │ │ ├── serializers.py
│ │ │ ├── urls.py
│ │ │ └── views.py
│ │ └── core
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── __init__.py
│ │ ├── migrations
│ │ │ └── __init__.py
│ │ ├── templates
│ │ │ └── index.html
│ │ ├── templatetags
│ │ │ ├── __init__.py
│ │ │ └── load_react.py
│ │ └── urls.py
│ ├── asgi.py
│ ├── __init__.py
│ ├── settings
│ │ ├── dev.py
│ │ ├── __init__.py
│ │ └── settings.py
│ ├── urls.py
│ └── wsgi.py
├── .env
├── .env.example
├── manage.py
└── requirements.txt
创建 React 应用程序
让mkdir我们开始 React 前端开发吧—— mkdir frontend && cd frontend。
首先初始化前端项目yarn init并回答问题。以下是我的示例:
$ yarn init
yarn init v1.22.4
question name (frontend): djact
question version (1.0.0):
question description: Django + React
question entry point (index.js):
question repository url:
question author: Constantine
question license (MIT):
question private:
success Saved package.json
Done in 34.53s.
现在我们可以使用 `.` 添加依赖项yarn add react react-dom axios react-redux redux redux-thunk reselect,并使用 `.` 添加开发依赖项yarn add -D eslint babel-eslint babel-polyfill eslint-plugin-import eslint-plugin-react eslint-plugin-react-hooks eslint-loader style-loader css-loader postcss-loader webpack-dev-server mini-css-extract-plugin cssnano html-webpack-plugin npm-run-all rimraf redux-immutable-state-invariant webpack webpack-cli babel-loader @babel/core @babel/node @babel/preset-env @babel/preset-react。
正在配置
在当前目录中创建.eslintrc.js以下内容:
// frontend/.eslintrc.js
module.exports = {
parser: "babel-eslint",
env: {
browser: true,
commonjs: true,
es6: true,
node: true,
jest: true,
},
parserOptions: {
ecmaVersion: 2020,
ecmaFeatures: {
impliedStrict: true,
jsx: true,
},
sourceType: "module",
},
plugins: ["react", "react-hooks"],
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
],
settings: {
react: {
version: "detect",
},
},
rules: {
"no-debugger": "off",
"no-console": "off",
"no-unused-vars": "warn",
"react/prop-types": "warn",
},
};
Babel 配置存储在babel.config.js:
// frontend/babel.config.js
module.exports = {
presets: ["@babel/preset-env", "@babel/preset-react"],
};
开发环境的 Webpack 配置存储在webpack.config.dev.js:
// frontend/webpack.config.dev.js
const webpack = require("webpack");
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
process.env.NODE_ENV = "development";
module.exports = {
mode: "development",
target: "web",
devtool: "cheap-module-source-map",
entry: ["babel-polyfill", "./src/index"],
output: {
path: path.resolve(__dirname),
publicPath: "/",
filename: "bundle.js",
},
devServer: {
historyApiFallback: true,
headers: { "Access-Control-Allow-Origin": "*" },
https: false,
},
plugins: [
new webpack.DefinePlugin({
"process.env.API_URL": JSON.stringify("http://localhost:8000/api/"),
}),
new HtmlWebpackPlugin({
template: "./src/index.html",
favicon: "./src/favicon.ico",
}),
],
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: [
{
loader: "babel-loader",
},
"eslint-loader",
],
},
{
test: /(\.css)$/,
use: ["style-loader", "css-loader"],
},
],
},
};
编辑此package.json scripts部分,使其看起来像这样:
// frontend/package.json
{
"name": "djact",
"version": "1.0.0",
"description": "Django + React",
"scripts": {
"start:dev": "webpack-dev-server --config webpack.config.dev.js --port 3000",
"clean:build": "rimraf ../static && mkdir ../static",
"prebuild": "run-p clean:build",
"build": "webpack --config webpack.config.prod.js",
"postbuild": "rimraf ../static/index.html"
},
"main": "index.js",
"author": "Constantine",
"license": "MIT",
"dependencies": {
...
},
"devDependencies": {
...
}
}
现在我们来添加一个前端源代码目录:mkdir -p src/components。同时,创建一个 React 入口点touch src/index.js,内容如下:
// frontend/src/index.js
import React from "react";
import { render } from "react-dom";
import { BrowserRouter as Router } from "react-router-dom";
import App from "./components/App";
render(
<Router>
<App />
</Router>,
document.getElementById("app")
);
创建html模板 - touch src/index.html:
<!-- frontend/src/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Djact</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
src如果你喜欢的话,可以在目录内添加网站图标。
然后创建App组件——touch src/components/App.js使其返回简单的值:
// frontend/src/components/App.js
import React from "react";
function App() {
return <h1>Hello from React!</h1>;
}
export default App;
现在我们可以测试一下我们的应用程序是否正常工作了yarn start:dev。访问http://localhost:3000后,我们应该会看到“Hello from React!”的问候语!
以下是一个制作示例webpack.config.prod.js:
// frontend/webpack.config.prod.js
const webpack = require("webpack");
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
process.env.NODE_ENV = "production";
module.exports = {
mode: "production",
target: "web",
devtool: "source-map",
entry: {
vendor: ["react", "react-dom", "prop-types"],
bundle: ["babel-polyfill", "./src/index"],
},
output: {
path: path.resolve(__dirname, "../static"),
publicPath: "/",
filename: "[name].[contenthash].js",
},
plugins: [
new MiniCssExtractPlugin({
filename: "[name].[contenthash].css",
}),
new webpack.DefinePlugin({
// This global makes sure React is built in prod mode.
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
"process.env.API_URL": JSON.stringify("http://localhost:8000/api/"),
}),
new HtmlWebpackPlugin({
template: "src/index.html",
favicon: "./src/favicon.ico",
minify: {
// see https://github.com/kangax/html-minifier#options-quick-reference
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}),
],
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: [
{
loader: "babel-loader",
},
"eslint-loader",
],
},
{
test: /(\.css)$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: "css-loader",
options: {
sourceMap: true,
},
},
{
loader: "postcss-loader",
options: {
plugins: () => [require("cssnano")],
sourceMap: true,
},
},
],
},
],
},
};
现在我们可以yarn build在目录中看到打包后的文件static。如果我们通过启动 Django 应用,python manage.py runserver 0.0.0.0:8000会看到完全相同的内容,但运行模式是生产模式。
我们的项目目录应该如下所示:
.
├── djact
│ ├── apps
│ │ ├── authentication
│ │ │ ├── admin.py
│ │ │ ├── apps.py
│ │ │ ├── __init__.py
│ │ │ ├── migrations
│ │ │ │ ├── 0001_initial.py
│ │ │ │ └── __init__.py
│ │ │ ├── models.py
│ │ │ ├── serializers.py
│ │ │ ├── urls.py
│ │ │ └── views.py
│ │ └── core
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── __init__.py
│ │ ├── migrations
│ │ │ └── __init__.py
│ │ ├── templates
│ │ │ └── index.html
│ │ ├── templatetags
│ │ │ ├── __init__.py
│ │ │ └── load_react.py
│ │ └── urls.py
│ ├── asgi.py
│ ├── db.sqlite3
│ ├── __init__.py
│ ├── settings
│ │ ├── dev.py
│ │ ├── __init__.py
│ │ └── settings.py
│ ├── urls.py
│ └── wsgi.py
├── .env
├── .env.example
├── frontend
│ ├── babel.config.js
│ ├── package.json
│ ├── src
│ │ ├── components
│ │ │ └── App.js
│ │ ├── favicon.ico
│ │ ├── index.html
│ │ └── index.js
│ ├── webpack.config.dev.js
│ ├── webpack.config.prod.js
│ └── yarn.lock
├── log
│ └── django.log
├── manage.py
├── public
│ ├── media
│ └── static
├── requirements.txt
└── static
├── bundle.76ba356d74f1017eda2f.js
├── bundle.76ba356d74f1017eda2f.js.map
├── favicon.ico
├── vendor.9245c714f84f4bbf6bdc.js
└── vendor.9245c714f84f4bbf6bdc.js.map
API 服务
在目录内部components创建axiosApi.js:
// frontend/src/components/api/axiosApi.js
import axios from "axios";
const baseURL = process.env.API_URL;
const accessToken = localStorage.getItem("access_token");
const axiosAPI = axios.create({
baseURL: baseURL,
timeout: 5000,
headers: {
Authorization: accessToken ? "JWT " + accessToken : null,
"Content-Type": "application/json",
accept: "application/json",
},
});
axiosAPI.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Prevent infinite loops
if (
error.response.status === 401 &&
originalRequest.url === baseURL + "token/refresh/"
) {
window.location.href = "/login/";
return Promise.reject(error);
}
if (
error.response.status === 401 &&
error.response.statusText === "Unauthorized"
) {
const refresh = localStorage.getItem("refresh_token");
if (refresh) {
const tokenParts = JSON.parse(atob(refresh.split(".")[1]));
// exp date in token is expressed in seconds, while now() returns milliseconds:
const now = Math.ceil(Date.now() / 1000);
if (tokenParts.exp > now) {
try {
const response = await axiosAPI.post("/token/refresh/", {
refresh,
});
setNewHeaders(response);
originalRequest.headers["Authorization"] =
"JWT " + response.data.access;
return axiosAPI(originalRequest);
} catch (error) {
console.log(error);
}
} else {
console.log("Refresh token is expired", tokenParts.exp, now);
window.location.href = "/login/";
}
} else {
console.log("Refresh token not available.");
window.location.href = "/login/";
}
}
// specific error handling done elsewhere
return Promise.reject(error);
}
);
export function setNewHeaders(response) {
axiosAPI.defaults.headers["Authorization"] = "JWT " + response.data.access;
localStorage.setItem("access_token", response.data.access);
localStorage.setItem("refresh_token", response.data.refresh);
}
export default axiosAPI;
和authenticationApi.js:
// frontend/src/components/api/authenticationApi.js
import axiosAPI, { setNewHeaders } from "./axiosApi";
export async function signUp(email, username, password) {
const response = await axiosAPI.post("users/create/", {
email,
username,
password,
});
localStorage.setItem("user", response.data);
return response;
}
export async function obtainToken(username, password) {
const response = await axiosAPI.post("token/obtain/", {
username,
password,
});
setNewHeaders(response);
return response;
}
export async function refreshToken(refresh) {
const response = await axiosAPI.post("token/refresh/", {
refresh,
});
setNewHeaders(response);
return response;
}
// eslint-disable-next-line
export async function logout(accessToken) {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
// TODO: invalidate token on backend
}
export const isAuthenticated = () => {
const token = localStorage.getItem("access_token");
return !!token;
};
重制版
首先在目录redux下创建文件夹djact/frontend/src/,并将以下文件放入其中:
// frontend/src/redux/configureStore.dev.js
import { createStore, applyMiddleware, compose } from "redux";
import rootReducer from "./reducers";
import reduxImmutableStateInvariant from "redux-immutable-state-invariant";
import thunk from "redux-thunk";
export default function configureStore(initialState) {
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; // add support for Redux dev tools
return createStore(
rootReducer,
initialState,
composeEnhancers(applyMiddleware(thunk, reduxImmutableStateInvariant()))
);
}
// frontend/src/redux/configureStore.prod.js
import { createStore, applyMiddleware } from "redux";
import rootReducer from "./reducers";
import thunk from "redux-thunk";
export default function configureStore(initialState) {
return createStore(rootReducer, initialState, applyMiddleware(thunk));
}
// frontend/src/redux/configureStore.js
// Use CommonJS require below so we can dynamically import during build-time.
if (process.env.NODE_ENV === "production") {
module.exports = require("./configureStore.prod");
} else {
module.exports = require("./configureStore.dev");
}
商店已配置完毕,现在可以开始操作了!请actions在其中创建目录redux并放入以下文件:
// frontend/src/redux/actions/types.js
export const LOGIN_USER_SUCCESS = "LOGIN_USER_SUCCESS";
export const LOGOUT_USER = "LOGOUT_USER";
// frontend/src/redux/actions/auth.js
import { LOGIN_USER_SUCCESS, LOGOUT_USER } from "./types";
import { obtainToken, logout } from "../../components/api/authenticationApi";
export function loginUserSuccess(token) {
return { type: LOGIN_USER_SUCCESS, token };
}
export function loginUser(username, password) {
return async function (dispatch) {
try {
const response = await obtainToken(username, password);
dispatch(loginUserSuccess(response.data.access));
} catch (error) {
console.log("Error obtaining token. " + error);
}
};
}
export function logoutUserSuccess() {
return { type: LOGOUT_USER };
}
export function logoutUser() {
return async function (dispatch) {
await logout();
dispatch(logoutUserSuccess());
};
}
最后一步是编写 reducer 本身,它位于frontend/src/redux/reducers目录内。
// frontend/src/redux/reducers/initialState.js
export default {
accessToken: localStorage.getItem("access_token"),
};
// frontend/src/redux/reducers/auth.js
import * as types from "../actions/types";
import initialState from "./initialState";
export default function authReducer(state = initialState.accessToken, action) {
switch (action.type) {
case types.LOGIN_USER_SUCCESS:
return action.token;
case types.LOGOUT_USER:
return "";
default:
return state;
}
}
// frontend/src/redux/reducers/index.js
import { combineReducers } from "redux";
import auth from "./auth";
const rootReducer = combineReducers({
auth,
});
export default rootReducer;
现在我们需要将所有内容登记在册index.js:
// frontend/src/index.js
import React from "react";
import {render} from "react-dom";
import {BrowserRouter as Router} from "react-router-dom";
import {Provider as ReduxProvider} from "react-redux";
import App from "./components/App";
import configureStore from "./redux/configureStore";
const store = configureStore();
render(
<ReduxProvider store={store}>
<Router>
<App/>
</Router>
</ReduxProvider>,
document.getElementById("app")
);
成分
验证
我们的 reducer 已经准备就绪,现在需要使用它们。所以让我们authentication在里面创建一个目录frontend/src/components,并将接下来的三个文件放进去。
这将是我们用于私有路由的封装:
// frontend/src/components/authentication/PrivateRoute.js
import React from "react";
import { Redirect, Route } from "react-router-dom";
import PropTypes from "prop-types";
import { isAuthenticated } from "../api/authenticationApi";
const PrivateRoute = ({ component: Component, ...rest }) => (
<Route
{...rest}
render={(props) =>
isAuthenticated() ? (
<Component {...props} />
) : (
<Redirect
to={{ pathname: "/login", state: { from: props.location } }}
/>
)
}
/>
);
PrivateRoute.propTypes = {
component: PropTypes.func.isRequired,
location: PropTypes.object,
};
export default PrivateRoute;
// frontend/src/components/authentication/LoginPage.js
import React, { useState } from "react";
import { connect } from "react-redux";
import PropTypes from "prop-types";
import { loginUser } from "../../redux/actions/auth";
const LoginPage = ({ loginUser, history }) => {
const [state, setState] = useState({
username: "",
password: "",
});
const handleChange = (event) => {
const { name, value } = event.target;
setState({ ...state, [name]: value });
};
const login = async (event) => {
event.preventDefault();
const { username, password } = state;
await loginUser(username, password);
history.push("/");
};
return (
<div>
<h1>Login page</h1>
<form onSubmit={login}>
<label>
Username:
<input
name="username"
type="text"
value={state.username}
onChange={handleChange}
/>
</label>
<label>
Password:
<input
name="password"
type="password"
value={state.password}
onChange={handleChange}
/>
</label>
<input type="submit" value="Submit" />
</form>
</div>
);
};
LoginPage.propTypes = {
loginUser: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
};
const mapDispatchToProps = {
loginUser,
};
export default connect(null, mapDispatchToProps)(LoginPage);
注册组件会比较简单,因为我懒得实现,但应该很容易上手:
// frontend/src/components/authentication/SignUpPage.js
import React from "react";
import { useHistory } from "react-router-dom";
const SignUpPage = () => {
const history = useHistory();
const handleClick = () => {
history.push("/");
};
return (
<div>
<h1>Sign Up page</h1>
<button onClick={handleClick}>sign up</button>
</div>
);
};
export default SignUpPage;
常见的
公共组件只会包含头部信息。但理论上,所有……你知道的……通用的东西都可以包含在内。
// frontend/src/components/common/Header.js
import React from "react";
import { connect } from "react-redux";
import PropTypes from "prop-types";
import { NavLink, useHistory } from "react-router-dom";
import { logoutUser } from "../../redux/actions/auth";
const Header = ({ accessToken, logoutUser }) => {
const history = useHistory();
const handleLogout = async () => {
await logoutUser();
history.push("login/");
};
return (
<nav>
{accessToken ? (
<>
<NavLink to="/">Profile</NavLink>
{" | "}
<NavLink to="/logout" onClick={handleLogout}>
Logout
</NavLink>
</>
) : (
<>
<NavLink to="/login">Login</NavLink>
{" | "}
<NavLink to="/sign-up">SignUp</NavLink>
</>
)}
</nav>
);
};
Header.propTypes = {
accessToken: PropTypes.string,
logoutUser: PropTypes.func.isRequired,
};
function mapStateToProps(state) {
return {
accessToken: state.auth,
};
}
const mapDispatchToProps = {
logoutUser,
};
export default connect(mapStateToProps, mapDispatchToProps)(Header);
核
最后一部分是包含应用程序逻辑的核心组件。这里我们将放置受保护的页面:
// frontend/src/components/core/ProfilePage.js
import React from "react";
import axiosAPI from "../api/axiosApi";
const ProfilePage = () => {
const handleClick = async () => {
const response = await axiosAPI.get("protected/");
alert(JSON.stringify(response.data));
};
return (
<div>
<h1>Profile page</h1>
<p>Only logged in users should see this</p>
<button onClick={handleClick}>GET protected</button>
</div>
);
};
export default ProfilePage;
最后要做的是更新我们的App.js:
// frontend/src/components/App.js
import React from "react";
import {Route, Switch} from "react-router-dom";
import PageNotFound from "./PageNotFound";
import Header from "./common/Header";
import ProfilePage from "./core/ProfilePage";
import PrivateRoute from "./authentication/PrivateRoute";
import LoginPage from "./authentication/LoginPage";
import SignUpPage from "./authentication/SignUpPage";
function App() {
return (
<>
<Header/>
<Switch>
<PrivateRoute exact path="/" component={ProfilePage}/>
<Route path="/login" component={LoginPage}/>
<Route path="/sign-up" component={SignUpPage}/>
<Route component={PageNotFound}/>
</Switch>
</>
);
}
export default App;
我们最终的项目结构应该如下所示:
.
├── blogpost.md
├── djact
│ ├── apps
│ │ ├── authentication
│ │ │ ├── admin.py
│ │ │ ├── apps.py
│ │ │ ├── __init__.py
│ │ │ ├── migrations
│ │ │ │ ├── 0001_initial.py
│ │ │ │ └── __init__.py
│ │ │ ├── models.py
│ │ │ ├── serializers.py
│ │ │ ├── urls.py
│ │ │ └── views.py
│ │ └── core
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── __init__.py
│ │ ├── migrations
│ │ │ └── __init__.py
│ │ ├── templates
│ │ │ └── index.html
│ │ ├── templatetags
│ │ │ ├── __init__.py
│ │ │ └── load_react.py
│ │ └── urls.py
│ ├── asgi.py
│ ├── db.sqlite3
│ ├── __init__.py
│ ├── settings
│ │ ├── dev.py
│ │ ├── __init__.py
│ │ └── settings.py
│ ├── urls.py
│ └── wsgi.py
├── .env
├── .env.example
├── frontend
│ ├── babel.config.js
│ ├── package.json
│ ├── src
│ │ ├── components
│ │ │ ├── api
│ │ │ │ ├── authenticationApi.js
│ │ │ │ └── axiosApi.js
│ │ │ ├── App.js
│ │ │ ├── authentication
│ │ │ │ ├── LoginPage.js
│ │ │ │ ├── PrivateRoute.js
│ │ │ │ └── SignUpPage.js
│ │ │ ├── common
│ │ │ │ └── Header.js
│ │ │ ├── core
│ │ │ │ └── ProfilePage.js
│ │ │ └── PageNotFound.js
│ │ ├── favicon.ico
│ │ ├── index.html
│ │ ├── index.js
│ │ └── redux
│ │ ├── actions
│ │ │ ├── auth.js
│ │ │ └── types.js
│ │ ├── configureStore.dev.js
│ │ ├── configureStore.js
│ │ ├── configureStore.prod.js
│ │ └── reducers
│ │ ├── auth.js
│ │ ├── index.js
│ │ └── initialState.js
│ ├── webpack.config.dev.js
│ ├── webpack.config.prod.js
│ ├── yarn-error.log
│ └── yarn.lock
├── log
│ └── django.log
├── manage.py
├── public
│ ├── media
│ └── static
├── requirements.txt
└── static
├── bundle.c86ace9a42dd5bd70a59.js
├── bundle.c86ace9a42dd5bd70a59.js.map
├── favicon.ico
├── vendor.0d40e04c29796a70dc89.js
└── vendor.0d40e04c29796a70dc89.js.map
跑步
现在,设置环境变量export $(cat .env | xargs)。构建前端部分cd frontend && yarn:build。创建一个用于测试的超级用户cd ../ && python manage.py createsuperuser,并按照说明操作。运行 Django 应用python manage.py runserver并访问http://localhost:8000。我们应该会看到登录页面。输入创建超级用户时提供的凭据,即可进入受保护的个人资料页面。点击按钮后,GET protected我们会看到包含服务器响应的提示框。
就是这样!如果你一路看到这里……哇!如果你真的把这些都实践了……哇!!太棒了,我的朋友!希望你学到了新知识或者解决了你的某个问题🚀
谢谢,祝您编程愉快!
资源
正如我在文章开头承诺的那样,以下是帮助我完成整个项目的资源列表:
PluralSight课程:
文章:
- 使用 Django 和 React 实现 110% 完整的 JWT 身份验证 - 2020 年,作者:Stuart Leitch
- React + Redux - JWT 身份验证教程及示例,作者:Jason Watmore
- Leizl Samano著:《在 React+Redux 应用中使用 JWT 进行身份验证》。