发布于 2026-01-05 12 阅读
0

Django + React、Redux 和 JWT 前言 前提条件 创建 Django 项目 创建 React 应用 API 服务 Redux 组件 运行资源

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 框架或样式,因为那样会显得过于主观,而且并不适用于每个项目。

还有一点需要说明。我不会详细讲解这里列出的代码。如果你想真正理解,网上有很多有用的资料。我会列出所有帮助过我的资源。这只是一个操作指南。完整的代码可以在我的GitHubGitLab上找到。

好了,废话不多说,准备好开始阅读吧!希望对你有所帮助😊

先决条件

你的系统需要安装以下软件包: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 *
Enter fullscreen mode Exit fullscreen mode

这里以及每个文件列表中的第一行都会包含一条注释,其中包含文件的相对路径。特此说明。

这样我们就无需覆盖该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
Enter fullscreen mode Exit fullscreen mode

我知道有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>
Enter fullscreen mode Exit fullscreen mode

验证

接下来我们需要一个用于身份验证的应用程序,因此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}'
Enter fullscreen mode Exit fullscreen mode

接下来我们需要一个序列化器,供用户注册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
Enter fullscreen mode Exit fullscreen mode

然后是这样的视图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()
Enter fullscreen mode Exit fullscreen mode

这样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'
    )
]
Enter fullscreen mode Exit fullscreen mode

主界面更新urls.pydjact

# 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')),
]
Enter fullscreen mode Exit fullscreen mode

设置

我喜欢这个新Pathlib模块,所以我们用它重写所有代码,而不是用原来的模块os。我用它django-environ来处理环境变量,所以我们先安装它pip install django-environ && pip freeze > requirements.txtDJANGO_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')
Enter fullscreen mode Exit fullscreen mode

我们可以覆盖一些设置,或者添加一些仅与开发环境相关的内容,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')

Enter fullscreen mode Exit fullscreen mode

在这里,我们告诉 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
Enter fullscreen mode Exit fullscreen mode

所以,创建一个.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'),
]
Enter fullscreen mode Exit fullscreen mode

并更新主 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')),
]
Enter fullscreen mode Exit fullscreen mode

然后运行python manage.py makemigrationspython 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
Enter fullscreen mode Exit fullscreen mode

创建 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.
Enter fullscreen mode Exit fullscreen mode

现在我们可以使用 `.` 添加依赖项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",
  },
};
Enter fullscreen mode Exit fullscreen mode

Babel 配置存储在babel.config.js

// frontend/babel.config.js
module.exports = {
  presets: ["@babel/preset-env", "@babel/preset-react"],
};
Enter fullscreen mode Exit fullscreen mode

开发环境的 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"],
      },
    ],
  },
};
Enter fullscreen mode Exit fullscreen mode

编辑此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": {
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

现在我们来添加一个前端源代码目录: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")
);
Enter fullscreen mode Exit fullscreen mode

创建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>
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

现在我们可以测试一下我们的应用程序是否正常工作了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,
            },
          },
        ],
      },
    ],
  },
};
Enter fullscreen mode Exit fullscreen mode

现在我们可以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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

重制版

首先在目录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()))
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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));
}
Enter fullscreen mode Exit fullscreen mode
// 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");
}
Enter fullscreen mode Exit fullscreen mode

商店已配置完毕,现在可以开始操作了!请actions在其中创建目录redux并放入以下文件:

// frontend/src/redux/actions/types.js
export const LOGIN_USER_SUCCESS = "LOGIN_USER_SUCCESS";
export const LOGOUT_USER = "LOGOUT_USER";
Enter fullscreen mode Exit fullscreen mode
// 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());
  };
}
Enter fullscreen mode Exit fullscreen mode

最后一步是编写 reducer 本身,它位于frontend/src/redux/reducers目录内。

// frontend/src/redux/reducers/initialState.js
export default {
  accessToken: localStorage.getItem("access_token"),
};
Enter fullscreen mode Exit fullscreen mode
// 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;
  }
}
Enter fullscreen mode Exit fullscreen mode
// frontend/src/redux/reducers/index.js
import { combineReducers } from "redux";
import auth from "./auth";

const rootReducer = combineReducers({
  auth,
});

export default rootReducer;
Enter fullscreen mode Exit fullscreen mode

现在我们需要将所有内容登记在册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")
);
Enter fullscreen mode Exit fullscreen mode

成分

验证

我们的 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;
Enter fullscreen mode Exit fullscreen mode
// 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);
Enter fullscreen mode Exit fullscreen mode

注册组件会比较简单,因为我懒得实现,但应该很容易上手:

// 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;
Enter fullscreen mode Exit fullscreen mode

常见的

公共组件只会包含头部信息。但理论上,所有……你知道的……通用的东西都可以包含在内。

// 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);
Enter fullscreen mode Exit fullscreen mode

最后一部分是包含应用程序逻辑的核心组件。这里我们将放置受保护的页面:

// 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;
Enter fullscreen mode Exit fullscreen mode

最后要做的是更新我们的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;
Enter fullscreen mode Exit fullscreen mode

我们最终的项目结构应该如下所示:

.
├── 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
Enter fullscreen mode Exit fullscreen mode

跑步

现在,设置环境变量export $(cat .env | xargs)。构建前端部分cd frontend && yarn:build。创建一个用于测试的超级用户cd ../ && python manage.py createsuperuser,并按照说明操作。运行 Django 应用python manage.py runserver并访问http://localhost:8000。我们应该会看到登录页面。输入创建超级用户时提供的凭据,即可进入受保护的个人资料页面。点击按钮后,GET protected我们会看到包含服务器响应的提示框。

就是这样!如果你一路看到这里……哇!如果你真的把这些都实践了……哇!!太棒了,我的朋友!希望你学到了新知识或者解决了你的某个问题🚀

谢谢,祝您编程愉快!

资源

正如我在文章开头承诺的那样,以下是帮助我完成整个项目的资源列表:

PluralSight课程:

文章:

文章来源:https://dev.to/c_v_ya/django-react-redux-and-jwt-3jn4