2、Python3 博客前端开发

作者: Brinnatt 分类: python 道 发布时间: 2023-06-18 14:50

2.1、开发环境设置

解压 webpackjs.rar 脚手架,更名为 frontend。在 src 中新增 component、service、css 目录。

注:没有特别说明,js 开发都在 src 目录下。

frontend/
|-- babel.config.js
|-- config
|   `-- webpack.config.js
|-- package-lock.json
|-- package.json
|-- public
|   `-- index.html
`-- src
    |-- App.jsx
    |-- component
    |-- css
    |-- main.js
    `-- service

修改项目信息:

{
  "name": "blog",
  "version": "1.0.0",
  "description": "blog project",
  "author": "Brinnatt",
}

webpack.config.js

proxy: {
    '/api': {
        target: 'http://127.0.0.1:8080',
        changeOrigin: true
    }
}

proxy 指定访问 /api 开头路径都代理到 http://127.0.0.1:8080

安装依赖:

$ npm install

npm 会按照 package.json 的定义安装 devDependencies 和 dependencies 依赖。注意必须科学上网才能安装顺利,安装过程还会请求 github 之类的网站。

我这里的打包文件中已安装,解压后可以直接使用。

2.2、开发

2.2.1、前端路由

前端路由使用 react-router 组件完成。

官网文档 https://reactrouter.com/en/main/start/tutorial

基本例子 https://reactrouter.com/en/main/start/examples

使用 react-router,更改 src/main.js。

import * as React from "react";
import { createRoot } from "react-dom/client";
import {
  createBrowserRouter,
  RouterProvider,
  Link,
} from "react-router-dom";

const router = createBrowserRouter([
  {
    path: "/",
    element: (
      <div>
        <h1>This is Home</h1>
        <Link to="about">To About</Link>
      </div>
    ),
  },
  {
    path: "about",
    element: <div>This is About</div>,
  },
]);

createRoot(document.getElementById("app")).render(
  <RouterProvider router={router} />
);

在地址栏里面输入 http://localhost:3000/http://localhost:3000/about 试试看,能够看到页面的变化

以上是 react-router v6.x 的新语法,请参见官方文档。

2.2.2、登录组件

在 component 目录下构建 react 组件。

登录页模板:https://codepen.io/colorlib/pen/rxddKy?q=login&limit=all&type=type-pens

<div class="login-page">
  <div class="form">
    <form class="register-form">
      <input type="text" placeholder="name"/>
      <input type="password" placeholder="password"/>
      <input type="text" placeholder="email address"/>
      <button>create</button>
      <p class="message">Already registered? <a href="#">Sign In</a></p>
    </form>
    <form class="login-form">
      <input type="text" placeholder="username"/>
      <input type="password" placeholder="password"/>
      <button>login</button>
      <p class="message">Not registered? <a href="#">Create an account</a></p>
    </form>
  </div>
</div>

使用这个 HTML 模板来构建组件。

特别注意:搬到 React 组件中的时候,要将 class 属性改为 className。所有标签,需要闭合。

login.js

在 component 目录下建立 login.js 的登录组件。使用上面 HTML 模板中的登录部分,挪到 render 函数中。

  • 修改 class 为 className。
  • <a> 标签替换成 <Link to="?"> 组件。
  • 注意标签闭合问题。
import React from 'react';
import { Link } from 'react-router-dom';
export default class Login extends React.Component {
  render() {
    return (<div className="login-page">
      <div className="form">
        <form className="login-form">
          <input type="text" placeholder="邮箱" />
          <input type="password" placeholder="密码" />
          <button>登录</button>
          <p className="message">还未注册? <Link to="/reg">请注册</Link></p>
        </form>
      </div>
    </div>);
  }
}

index.js

在 component 目录下建立 index.js 的路由实例,增加登录组件。

import * as React from "react";
import { createBrowserRouter, Link } from "react-router-dom";
import Login from "./login";

const router = createBrowserRouter([
    {
        path: "/",
        element: (
            <div>
                <h1>This is Home</h1>
                <Link to="/about">To About</Link>
            </div>
        ),
    },
    {
        path: "/about",
        element: <div>This is About</div>,
    },

    {
        path: "/login",
        element: <Login />,
    },
]);

export default router;

main.js

import * as React from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import router from "./component/index"

createRoot(document.getElementById("app")).render(
  <RouterProvider router={router} />
);

访问 http://localhost:3000/login 就可以看到登录界面了。但是没有样式。

样式表,在 src/css 中,创建 login.css,放入以下内容,然后在 login.js 中导入样式。

@import url(https://fonts.googleapis.com/css?family=Roboto:300);

.login-page {
  width: 360px;
  padding: 8% 0 0;
  margin: auto;
}
.form {
  position: relative;
  z-index: 1;
  background: #FFFFFF;
  max-width: 360px;
  margin: 0 auto 100px;
  padding: 45px;
  text-align: center;
  box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2), 0 5px 5px 0 rgba(0, 0, 0, 0.24);
}
.form input {
  font-family: "Roboto", sans-serif;
  outline: 0;
  background: #f2f2f2;
  width: 100%;
  border: 0;
  margin: 0 0 15px;
  padding: 15px;
  box-sizing: border-box;
  font-size: 14px;
}
.form button {
  font-family: "Roboto", sans-serif;
  text-transform: uppercase;
  outline: 0;
  background: #4CAF50;
  width: 100%;
  border: 0;
  padding: 15px;
  color: #FFFFFF;
  font-size: 14px;
  -webkit-transition: all 0.3 ease;
  transition: all 0.3 ease;
  cursor: pointer;
}
.form button:hover,.form button:active,.form button:focus {
  background: #43A047;
}
.form .message {
  margin: 15px 0 0;
  color: #b3b3b3;
  font-size: 12px;
}
.form .message a {
  color: #4CAF50;
  text-decoration: none;
}
.container {
  position: relative;
  z-index: 1;
  max-width: 300px;
  margin: 0 auto;
}
.container:before, .container:after {
  content: "";
  display: block;
  clear: both;
}
.container .info {
  margin: 50px auto;
  text-align: center;
}
.container .info h1 {
  margin: 0 0 15px;
  padding: 0;
  font-size: 36px;
  font-weight: 300;
  color: #1a1a1a;
}
.container .info span {
  color: #4d4d4d;
  font-size: 12px;
}
.container .info span a {
  color: #000000;
  text-decoration: none;
}
.container .info span .fa {
  color: #EF3B3A;
}
body {
  background: #76b852; /* fallback for old browsers */
  background: rgb(141,194,111);
  background: linear-gradient(90deg, rgba(141,194,111,1) 0%, rgba(118,184,82,1) 50%);
  font-family: "Roboto", sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;      
}

在 login.js 中导入样式表 import '../css/login.css',再一次访问 http://localhost:3000/login 就可以看到如下界面:

python3_blog_login

2.2.3、注册组件

与登录组件编写方式差不多,创建 component/reg.js,使用 login.css。

import React from 'react';
import { Link } from 'react-router-dom';
import '../css/login.css'

const Reg = () => (
    <div className="login-page">
        <div className="form">
            <form className="register-form">
                <input type="text" placeholder="姓名" />
                <input type="text" placeholder="邮箱" />
                <input type="password" placeholder="密码" />
                <input type="password" placeholder="确认密码" />
                <button>注册</button>
                <p className="message">如果已经注册<Link to="/login">请登录</Link></p>
            </form>
        </div>
    </div>
);

export default Reg;

在 index.js 中增加一条静态路由。

import * as React from "react";
import { createBrowserRouter, Link } from "react-router-dom";
import Login from "./login";
import Reg from './reg'

const router = createBrowserRouter([
    {
        path: "/",
        element: (
            <div>
                <h1>This is Home</h1>
                <Link to="/about">To About</Link>
            </div>
        ),
    },
    {
        path: "/about",
        element: <div>This is About</div>,
    },
    {
        path: "/login",
        element: <Login />,
    },
    {
        path: "/reg",
        element: <Reg />,
    },
]);

export default router;

输入 http://localhost:3000/reg 可以看到如下界面:

python3_blog_reg

2.2.4、导航栏组件

在 index.js 中增加导航栏组件,方便页面切换。createBrowserRouter 是创建路由实例的函数,不能直接加导航标签,修改 index.js:

import * as React from "react";
import { Link, useRoutes } from "react-router-dom";
import Login from "./login";
import Reg from './reg'

const App = () => {
    const routes = useRoutes([
        {
            path: "/",
            element: (
                <div>
                    <h1>This is Home</h1>
                    <Link to="/about">To About</Link>
                </div>
            ),
        },
        {
            path: "/about",
            element: <div>This is About</div>,
        },
        {
            path: "/login",
            element: <Login />,
        },
        {
            path: "/reg",
            element: <Reg />,
        },
    ]);
    return (
        <div>
          <nav>
            <ul>
              <li>
                <Link to="/">首页</Link>
              </li>
              <li>
                <Link to="/login">登陆</Link>
              </li>
              <li>
                <Link to="/reg">注册</Link>
              </li>
            </ul>
          </nav>

          {routes}
        </div>
      );
}

export default App;

修改 main.js:

import * as React from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./component/index";

const root = createRoot(document.getElementById("app"));

root.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

接下来,我们实现分层前端开发

视图层,负责显示数据,每一个 React 组件一个 xxx.js。

服务层,负责业务数据处理逻辑,命名为 xxxService.js。

Model 层,负责数据处理,数据从后端取。

2.3、登录功能实现

view 层,登录组件和用户交互。当 button 点击触发 onClick,调用事件响应函数 handleClick,handleClick 中调用服务 service 层的 login 函数。

service 层,负责业务逻辑处理。调用 Model 层数据操作函数。

src/service/user.js

export default class UserService {
    login (email, password) {
        // TODO
    }
}

src/component/login.js

import React from 'react';
import { Link } from 'react-router-dom';
import '../css/login.css'

// const Login = () => (
//   <div className="login-page">
//     <div className="form">
//       <form className="login-form">
//         <input type="text" placeholder="邮箱" />
//         <input type="password" placeholder="密码" />
//         <button>登录</button>
//         <p className="message">还未注册? <Link to="/reg">请注册</Link></p>
//       </form>
//     </div>
//   </div>
// );
// export default Login;

export default class Login extends React.Component {
  handlerClick(event) {
    console.log(event.target)
  }

  render() {
    return (
      <div className="login-page">
        <div className="form">
          <form className="login-form">
            <input type="text" placeholder="邮箱" />
            <input type="password" placeholder="密码" />
            <button onClick={this.handlerClick.bind(this)}>登录</button>
            <p className="message">还未注册? <Link to="/reg">请注册</Link></p>
          </form>
        </div>
      </div>
    );
  }
}

这一次发现有一些问题,按钮点击会提交,导致页面刷新了。要阻止页面刷新,其实就是阻止提交。使用 event.preventDefault()。

如何拿到邮箱和密码?

  • event.target.form 返回按钮所在表单,可以看作一个数组。

  • fm[0].valuefm[0].value 就是文本框的值。

如何在 Login 组件中使用 UserService 实例呢?

  • 可以在 Login 的构造器中通过属性注入;

  • 也可以在外部使用 props 注入。使用这种方式。

import React from 'react';
import { Link } from 'react-router-dom';
import '../css/login.css'
import UserService from '../service/user';

const userService = new UserService();

export default class Login extends React.Component {
  render() {
    return <Login0 service={userService} />;
  }
}

class Login0 extends React.Component {
  handlerClick(event) {
    event.preventDefault();
    let fm = event.target.form;
    this.props.service.login(
      fm[0].value, fm[1].value
    );
  }

  render() {
    return (
      <div className="login-page">
        <div className="form">
          <form className="login-form">
            <input type="text" placeholder="邮箱" />
            <input type="password" placeholder="密码" />
            <button onClick={this.handlerClick.bind(this)}>登录</button>
            <p className="message">还未注册? <Link to="/reg">请注册</Link></p>
          </form>
        </div>
      </div>
    );
  }
}

2.3.1、UserService 的 login 方法实现

2.3.1.1、代理配置

修改 webpack.config.js 文件中 proxy 部分。注意,修改这个配置,需要重启 dev server。

  devServer: {
    open: true,
    host: "localhost",
    port: 3000,
    hot: true,
    compress: true,
    historyApiFallback: true,
    proxy: {
      '/api': {
        target: 'http://127.0.0.1:8000',
        changeOrigin: true
      }
    }
  },

2.3.1.2、axios 异步库

axios 是一个基于 Promise 的 HTTP 异步库,可以用在浏览器或 nodejs 中。

使用 axios 发起异步调用,完成 POST、GET 方法的数据提交。可参照官网的例子。

官方仓库:https://github.com/axios/axios

官方文档:https://axios-http.com/docs/intro

service/user.js 修改如下:

import axios from "axios";

export default class UserService {
    login (email, password) {
        console.log(email, password);

        axios.post('/api/user/login', {
            email:email,
            password:password
        })/* dev server会代理 */
        .then(
            function(response) {
                console.log(response);
                console.log(response.data);
                console.log(response.status);
                console.log(response.statusText);
                console.log(response.headers);
                console.log(response.config);
            }
        ).catch(
            function(error){
                console.log(error);
            }
        )
    }
}

填入邮箱、密码,点击登录按钮,返回 404,查看 Python 服务端,访问地址是 /api/user/login ,也就是多了 /api。如何解决?

  1. 修改 blog server 的代码的路由匹配规则?

    不建议这么做,影响有点大。

  2. rewrite?

    类似 httpd、nginx 等的 rewrite 功能。本次测试使用的是 dev server,去官方看看。

    可以查到 pathRewrite 可以完成路由重写。

     devServer: {
       open: true,
       host: "localhost",
       port: 3000,
       hot: true,
       compress: true,
       historyApiFallback: true,
       proxy: {
         '/api': {
           target: 'http://127.0.0.1:8000',
           pathRewrite: {"^/api": ""},
           changeOrigin: true
         }
       }
     },

    重启 dev server。使用正确的邮箱、密码登录,返回了 json 数据,response.data 中可以看到 token、user。

2.3.1.3、token 持久化(LocalStorage)

使用 LocalStorage 来存储 token。LocalStorage 是浏览器端持久化方案之一,HTML5 标准增加的技术。LocalStorage 是为了存储得到的数据,例如 Json。

数据存储就是键值对。数据会存储在不同的域名下面。不同浏览器对单个域名下存储数据的长度支持不同,有的最多支持 2MB。

Chrome 浏览器中查看,如下:

python3_blog_localstorage

SessionStorage 和 LocalStorage 差不多,它是会话级的,浏览器关闭,会话结束,数据清除。

IndexedDB:

  • 一个域一个 datatable。

  • key-value 的检索方式

  • 建立在关系型的数据模型之上,具有索引表、游标、事务等概念。

store.js:

  • store.js 是一个兼容所有浏览器的 LocalStorage 包装器,不需要借助 Cookie 或者 Flash。

  • store.js 会根据浏览器自动选择使用 localStorage、globalStorage 或者 userData 来实现本地存储功能。

安装:

$ npm i store

测试代码:

let store = require('store')

store.set('user', 'brinnatt');
console.log(1, '-->', store.get('user'));

store.remove('user');
console.log(2, '-->', store.get('user'));
console.log(2.1, '-->', store.get('user', 'a'));

store.set('user', { 'name': 'brinnatt', age: 30 });
console.log(3, '-->', store.get('user').age);

let i = 10
store.set('school', { 'name': 'www.brinnatt.com' })
store.each(function (value, key) {
    console.log(++i, key, '-->', value)
});

store.clearAll();
console.log(20, '-->', store.get('user'));

安装 store 的同时,也安装了 expire 过期插件,当 kv 对存储到 LS 中的时候,顺便加入过期时长。

store.addPlugin(require('store/plugins/expire')); // 过期插件
store.set('token', res.data.token, (new Date()).getTime() + (8 * 3600 * 1000));

2.3.2、Mobx 状态管理

TodoList 项目中,感觉基本功能都实现了,但是,state 的控制有点麻烦。社区提供的状态管理库,有 Redux 和 Mobx

  • Redux 代码优秀,使用严格的函数式编程思想,学习曲线陡峭,小项目使用的优势不明显。

  • Mobx,非常优秀稳定的库,简单方便,适合中小项目使用。使用面向对象的方式,容易学习和接受。现在在项目中使用也非常广泛。Mobx 和 React 是一对强力组合。

Mobx 官网 https://mobx.js.org/README.html

MobX 是由 Mendix、Coinbase、Facebook 开源,它实现了观察者模式。

观察者模式,也称为发布订阅模式,观察者观察某个目标,目标对象(Obserable)状态发生了变化,就会通知自己内部注册了的观察者 Observer。

状态管理需求

一个组件的 onClick 触发事件响应函数,此函数会调用后台服务。但是后台服务比较耗时,等处理完,需要引起组件的渲染操作。

要组件渲染,需要改变组件的 props 或 state。

2.3.2.1、同步调用

同步调用中,实际上就是等着耗时的函数返回。

class Service {
    handle(n) {
        // 同步
        console.log('pending~~~~~~~~')
        for (let s = new Date(); new Date() - s < n * 1000;);
        console.log('done');
        return Math.random();
    }
}
class Root extends React.Component {
    state = { ret: null }
    handleClick(event) {
        // 同步返回值
        let ret = this.props.service.handle(4);
        this.setState({ ret: ret });
    }
    render() {
        console.log('***********************');
        return (
            <div>
                <button onClick={this.handleClick.bind(this)}>触发handleClick函数</button><br />
                <span style={{ color: 'red' }}>{new Date().getTime()} Service的handle函数返回值是:
                    {this.state.ret}</span>
            </div>);
    }
}

这里使用一个死循环来模拟同步调用,来模拟耗时的等待返回的过程。

2.3.2.2、异步调用

思路一,使用 setTimeout,有 2 个问题。

  1. 无法向内部的待执行函数传入参数,比如传入 Root 实例。

  2. 延时执行函数的返回值无法取到,所以无法通知 Root。

思路二,Promise 异步执行。Promise 异步执行,如果成功执行,将调用回调。


class Service {
    handle(obj) {
        // Promise
        new Promise((resolve, reject) => {
            setTimeout(() => resolve('OK'), 5000);
        }).then(
            value => { // 使用obj
                obj.setState({ ret: (Math.random() * 100) });
            }
        )
    }
}
class Root extends React.Component {
    state = { ret: null }
    handleClick(event) {
        // 异步不能直接使用返回值
        this.props.service.handle(this);
    }
    render() {
        console.log('***********************');
        return (
            <div>
                <button onClick={this.handleClick.bind(this)}>触发handleClick函数</button><br />
                <span style={{ color: 'red' }}>{new Date().getTime()} Service中修改state的值是:
                    {this.state.ret}</span>
            </div>);
    }
}

不管 render 中是否显示 state 的值,只要 state 改变,都会触发 render 执行。

2.3.2.3、Mobx 实现

属性,完整的对象,数组,Maps 和 Sets 都可以被转化为可观察对象。 使得对象可观察的基本方法是使用 makeObservable 为每个属性指定一个注解。 最重要的注解如下:

  • observable 定义一个存储 state 的可追踪字段。
  • action 将一个方法标记为可以修改 state 的 action。
  • computed 标记一个可以由 state 派生出新的值并且缓存其输出的 getter。
import { makeObservable, observable, action } from "mobx"

class Todo {
    id = Math.random()
    title = ""
    finished = false

    constructor(title) {
        makeObservable(this, {
            title: observable,
            finished: observable,
            toggle: action
        })
        this.title = title
    }

    toggle() {
        this.finished = !this.finished
    }
}

observer 装饰器:设置观察者,将 React 组件转换为响应式组件。

import React from 'react';
import ReactDom from 'react-dom';
import { observable, makeObservable } from 'mobx';
import { observer } from 'mobx-react';

class Service {
  ret = -100;
  x = 1;
  constructor() {
    makeObservable(this, {
      ret: observable
    });
  }
  handle() {
    // Promise
    new Promise((resolve, reject) => {
      setTimeout(() => resolve('OK'), 2000);
    }).then(
      value => {
        this.ret = (Math.random() * 100);
        console.log(this.ret);
      }
    );
  }
}

@observer // 将react组件转换为响应式组件
class Root extends React.Component {
  //state = {ret:null} // 不使用state了
  handleClick(event) {
    // 异步不能直接使用返回值
    this.props.service.handle(this);
  }
  render() {
    console.log('***********************');
    return (
      <div>
        <button onClick={this.handleClick.bind(this)}>触发handleClick函数</button><br />
        <span style={{ color: 'red' }}>{new Date().getTime()} Service中修改state的值是:
          {/*this.props.service.ret*/} - {this.props.service.x++}</span>
      </div>);
  }
}
ReactDom.render(<Root service={new Service()} />, document.getElementById('app'));

MobX 还有很多用法,请参见官方文档 https://mobx.js.org/README.html

Service 中被观察者 ret 变化,导致了观察者调用了 render 函数。

被观察者变化不引起渲染的情况:将上例中的 this.props.service.ret 注释 /*this.props.service.ret*/。可以看到,如果在 render 中不使用这个被观察者,render 函数就不会调用。

在观察者的 render 函数中,一定要使用这个被观察对象

2.3.3、login 登录功能代码实现

./src/service/user.js

import axios from "axios";
import store from 'store';
import {observable, makeObservable} from 'mobx';

//过期插件
store.addPlugin(require('store/plugins/expire'));

export default class UserService {
    loggedin = false;

    constructor () {
        makeObservable(this, {
            loggedin: observable
        });
    };

    login (email, password) {
        console.log(email, password);

        axios.post('/api/user/login', {
            email:email,
            password:password
        })/* dev server会代理 */
        .then(
            response => { // 此函数要注意this的问题
                console.log(response);
                console.log(response.data);
                console.log(response.status);
                //+ 存储token,注意需要重开一次chrome的调试窗口才能看到
                store.set('token', 
                response.data.token,
                (new Date()).getTime() + (8*3600*1000));
                this.loggedin = true;  //+ 修改被观察者
            }
        ).catch(
            function(error){
                console.log(error);
            }
        );
    }
};

./src/component/login.js

import React from 'react';
import { Link, Navigate } from 'react-router-dom';
import '../css/login.css';
import UserService from '../service/user';
import { observer } from 'mobx-react';

const userService = new UserService();

export default class Login extends React.Component {
  render() {
    return <Login0 service={userService} />;
  }
}

@observer
class Login0 extends React.Component {
  handlerClick(event) {
    event.preventDefault();
    let fm = event.target.form;
    this.props.service.login(
      fm[0].value, fm[1].value
    );
  }

  render() {
    if (this.props.service.loggedin) {
      return (
        <div>
          <Navigate to="/" replace={true} />
        </div>
      );
    }
    else {
      return (
        <div className="login-page">
          <div className="form">
            <form className="login-form">
              <input type="text" placeholder="邮箱" />
              <input type="password" placeholder="密码" />
              <button onClick={this.handlerClick.bind(this)}>登录</button>
              <p className="message">还未注册? <Link to="/reg">请注册</Link></p>
            </form>
          </div>
        </div>
      );
    }
  }
}

注意,测试时,开启 Django 所写后台服务。

测试成功,成功登录,写入 Localstorage,也实现了跳转。

2.4、注册功能实现

./src/service/user.js 中增加 reg 注册函数。

import axios from "axios";
import store from 'store';
import { observable, makeObservable } from 'mobx';

//过期插件
store.addPlugin(require('store/plugins/expire'));

export default class UserService {
    loggedin = false;

    constructor() {
        makeObservable(this, {
            loggedin: observable
        });
    };

    login(email, password) {
        console.log(email, password);

        axios.post('/api/user/login', {
            email: email,
            password: password
        })/* dev server会代理 */
            .then(
                response => { // 此函数要注意this的问题
                    console.log(response);
                    console.log(response.data);
                    console.log(response.status);
                    //+ 存储token,注意需要重开一次chrome的调试窗口才能看到
                    store.set('token',
                        response.data.token,
                        (new Date()).getTime() + (8 * 3600 * 1000));
                    this.loggedin = true;  //+ 修改被观察者
                }
            ).catch(
                function (error) {
                    console.log(error);
                }
            );
    };

    reg(name, email, password) {
        console.log(email, password);

        axios.post("/api/user/reg", {
            email: email,
            password: password,
            name: name
        }) /* dev server会代理 */
            .then(
                response => { // 此函数要注意this的问题
                    console.log(response.data);
                    console.log(response.status);
                    //+ 存储token
                    store.set('token',
                        response.data.token,
                        (new Date()).getTime() + (8 * 3600 * 1000)
                    );
                    // 注册成功,返回token即成功登录
                    this.loggedin = true; //+ 修改被观察者
                }
            ).catch(
                function (error) {
                    console.log(error);
                }
            );
    }
};

./src/component/reg.js 组件类:

import React from 'react';
import { Link, Navigate } from 'react-router-dom';
import '../css/login.css';
import UserService from '../service/user';
import { observer } from 'mobx-react';

const userService = new UserService();

export default class Reg extends React.Component {
    render() {
        return <Reg0 service={userService} />;
    }
}

@observer
class Reg0 extends React.Component {
    handlerClick(event) {
        event.preventDefault();
        let fm = event.target.form;
        this.props.service.reg(fm[0].value, fm[1].value, fm[2].value);
    }

    render() {
        if (this.props.service.loggedin) {
            return (
                <div>
                    <Navigate to="/" replace={true} />
                </div>);
        }
        else {
            return (
                <div className="login-page">
                    <div className="form">
                        <form className="register-form">
                            <input type="text" placeholder="姓名" />
                            <input type="text" placeholder="邮箱" />
                            <input type="password" placeholder="密码" />
                            <input type="password" placeholder="确认密码" />
                            <button onClick={this.handlerClick.bind(this)}>注册</button>
                            <p className="message">如果已经注册<Link to="/login">请登录</Link></p>
                        </form>
                    </div>
                </div>
            );
        }
    }

}

2.5、Ant Design

Ant Design 蚂蚁金服开源的 React UI 库。

官网 https://ant.design/index-cn

官方文档 https://ant.design/docs/react/introduce-cn

安装:

$ npm install antd

使用:

import React from "react";
import { createRoot } from "react-dom/client";
import { Button, DatePicker, Space, version } from "antd";
import 'antd/dist/reset.css';
import "./css/login.css";

const App = () => {
  return (
    <div className="login-page">
      <h1>antd version: {version}</h1>
      <Space>
        <DatePicker />
        <Button type="primary">Primary Button</Button>
      </Space>
    </div>
  );
};

const root = createRoot(document.getElementById("app"));
root.render(<App />);

每一种组件的详细使用例子,官网都有,需要时查阅官方文档即可。

2.5.1、信息显示

网页开发中,不管操作成功与否,有很多提示信息,目前信息都是控制台输出,用户看不到。

使用 Antd 的 message 组件显示友好信息提示。在 service/user.js 中增加一个被观察对象。

import axios from "axios";
import store from 'store';
import { observable, makeObservable } from 'mobx';

//过期插件
store.addPlugin(require('store/plugins/expire'));

export default class UserService {
    loggedin = false;
    errMsg = '';

    constructor() {
        makeObservable(this, {
            loggedin: observable,
            errMsg: observable
        });
    };

    login(email, password) {
        console.log(email, password);

        axios.post('/api/user/login', {
            email: email,
            password: password
        })/* dev server会代理 */
            .then(
                response => { // 此函数要注意this的问题
                    console.log(response);
                    console.log(response.data);
                    console.log(response.status);
                    //+ 存储token,注意需要重开一次chrome的调试窗口才能看到
                    store.set('token',
                        response.data.token,
                        (new Date()).getTime() + (8 * 3600 * 1000));
                    this.loggedin = true;  //+ 修改被观察者
                }
            ).catch(
                function (error) {
                    console.log(error);
                    this.errMsg = '登陆失败'; //+ 信息显示
                }
            );
    };

    reg(name, email, password) {
        console.log(email, password);

        axios.post("/api/user/reg", {
            email: email,
            password: password,
            name: name
        }) /* dev server会代理 */
            .then(
                response => { // 此函数要注意this的问题
                    console.log(response.data);
                    console.log(response.status);
                    //+ 存储token
                    store.set('token',
                        response.data.token,
                        (new Date()).getTime() + (8 * 3600 * 1000)
                    );
                    // 注册成功,返回token即成功登录
                    this.loggedin = true; //+ 修改被观察者
                }
            ).catch(
                function (error) {
                    console.log(error);
                    this.errMsg = '注册失败'; //+ 信息显示
                }
            );
    }
};

./src/component/login.js

import React from 'react';
import { Link, Navigate } from 'react-router-dom';
import '../css/login.css';
import UserService from '../service/user';
import { observer } from 'mobx-react';
import { message } from 'antd';
import 'antd/lib/message/style';

const userService = new UserService();

export default class Login extends React.Component {
  render() {
    return <Login0 service={userService} />;
  }
}

@observer
class Login0 extends React.Component {
  handlerClick(event) {
    event.preventDefault();
    let fm = event.target.form;
    this.props.service.login(
      fm[0].value, fm[1].value
    );
  }

  render() {
    // 这里增加错误提示函数
    if (this.props.service.errMsg) {
      message.info(this.props.service.errMsg, 3,
        () => setTimeout(() => this.props.service.errMsg = ''), 1000);
    }
    if (this.props.service.loggedin) {
      return (
        <div>
          <Navigate to="/" replace={true} />
        </div>
      );
    }
    else {
      return (
        <div className="login-page">
          <div className="form">
            <form className="login-form">
              <input type="text" placeholder="邮箱" />
              <input type="password" placeholder="密码" />
              <button onClick={this.handlerClick.bind(this)}>登录</button>
              <p className="message">还未注册? <Link to="/reg">请注册</Link></p>
            </form>
          </div>
        </div>
      );
    }
  }
}

./src/component/reg.js

import React from 'react';
import { Link, Navigate } from 'react-router-dom';
import '../css/login.css';
import UserService from '../service/user';
import { observer } from 'mobx-react';
import { message } from 'antd';
import 'antd/lib/message/style';

const userService = new UserService();

export default class Reg extends React.Component {
    render() {
        return <Reg0 service={userService} />;
    }
}

@observer
class Reg0 extends React.Component {
    handlerClick(event) {
        event.preventDefault();
        let fm = event.target.form;
        this.props.service.reg(fm[0].value, fm[1].value, fm[2].value);
    }

    render() {
        // 这里增加错误提示函数
        if (this.props.service.errMsg) {
            message.info(this.props.service.errMsg, 3,
                () => setTimeout(() => this.props.service.errMsg = ''), 1000);
        }
        if (this.props.service.loggedin) {
            return (
                <div>
                    <Navigate to="/" replace={true} />
                </div>);
        }
        else {
            return (
                <div className="login-page">
                    <div className="form">
                        <form className="register-form">
                            <input type="text" placeholder="姓名" />
                            <input type="text" placeholder="邮箱" />
                            <input type="password" placeholder="密码" />
                            <input type="password" placeholder="确认密码" />
                            <button onClick={this.handlerClick.bind(this)}>注册</button>
                            <p className="message">如果已经注册<Link to="/login">请登录</Link></p>
                        </form>
                    </div>
                </div>
            );
        }
    }

}

以上代码,在逻辑上没有问题,但是会报错,如下:

TypeError
Cannot set properties of undefined (setting 'errMsg')
Call Stack
 undefined
  main.js:712:19
× Close

原因在这个函数上:

function (error) {
                    console.log(error);
                    this.errMsg = '登陆失败'; //+ 信息显示
                }

在这个回调函数中,我们尝试设置 this.errMsg 属性,但是 this 在这里并不指向 UserService 类的实例。这是因为在普通函数中,this 的上下文会发生变化。

为了解决这个问题,我们可以使用箭头函数来保持正确的 this 上下文。箭头函数会继承父级作用域的 this 值。修改代码如下:

.catch((error) => {
    console.log(error);
    this.errMsg = '登陆失败'; //+ 信息显示
});

同样的更改也需要应用于 reg 方法中的 .catch 回调函数。

2.6、高阶组件装饰器

装饰器函数的整个演变过程如下:

import React from 'react';

// 1 组件原型
class Reg extends React.Component {
    render() {
        return <Reg0 service={service} />;
    }
}

// 2 匿名组件
const Reg = class extends React.Component {
    render() {
        return <Reg0 service={service} />;
    }
};

// 3 提参数
function inject(Comp) {
    return class extends React.Component {
        render() {
            return <Comp service={service} />;
        }
    };
}

// 继续提参数
function inject(service, Comp) {
    return class extends React.Component {
        render() {
            return <Comp service={service} />;
        }
    };
}

// 4 props
function inject(obj, Comp) {
    return class extends React.Component {
        render() {
            return <Comp {...obj} />;
        }
    };
}

// 5 柯里化
function inject(obj) {
    function wrapper(Comp) {
        return class extends React.Component {
            render() {
                return <Comp {...obj} />;
            }
        };
    }
    return wrapper;
}
// 变形
function inject(obj) {
    return function wrapper(Comp) {
        return class extends React.Component {
            render() {
                return <Comp {...obj} />;
            }
        };
    };
}

// 6 箭头函数简化
const inject = obj => {
    return Comp => {
        return class extends React.Component {
            render() {
                return <Comp {...obj} />;
            }
        };
    };
}
// 继续简化
const inject = obj => Comp => {
    return class extends React.Component {
        render() {
            return <Comp {...obj} />;
        }
    };
};

// 7 函数式组件简化
const inject = obj => Comp => {
    return props => <Comp {...obj} />;
}
const inject = obj => Comp => props => <Comp {...obj} />;
const inject = obj => Comp => props => <Comp {...obj} {...props} />;

新建 src/utils.js,放入以下内容:

import React from 'react';

const inject = obj => Comp => props => <Comp {...obj} {...props} />;
export { inject };

将登陆、注册组件装饰一下:

// login.js修改如下:
import React from 'react';
import { Link, Navigate } from 'react-router-dom';
import '../css/login.css';
import UserService from '../service/user';
import { observer } from 'mobx-react';
import { message } from 'antd';
import 'antd/lib/message/style';
import { inject } from '../utils';

const service = new UserService();

// export default class Login extends React.Component {
//   render() {
//     return <Login0 service={userService} />;
//   }
// }

// 注意装饰器顺序
@inject({service}) // {service}会转化成{service:(new UserService())}
@observer
export default class Login extends React.Component {
  handlerClick(event) {
    event.preventDefault();
    let fm = event.target.form;
    this.props.service.login(
      fm[0].value, fm[1].value
    );
  }

  render() {
    if (this.props.service.errMsg) {
      message.info(this.props.service.errMsg, 3,
        () => setTimeout(() => this.props.service.errMsg = ''), 1000);
    }
    if (this.props.service.loggedin) {
      return (
        <div>
          <Navigate to="/" replace={true} />
        </div>
      );
    }
    else {
      return (
        <div className="login-page">
          <div className="form">
            <form className="login-form">
              <input type="text" placeholder="邮箱" />
              <input type="password" placeholder="密码" />
              <button onClick={this.handlerClick.bind(this)}>登录</button>
              <p className="message">还未注册? <Link to="/reg">请注册</Link></p>
            </form>
          </div>
        </div>
      );
    }
  }
}
// reg.js修改如下:
import React from 'react';
import { Link, Navigate } from 'react-router-dom';
import '../css/login.css';
import UserService from '../service/user';
import { observer } from 'mobx-react';
import { message } from 'antd';
import 'antd/lib/message/style';
import { inject } from '../utils';

const service = new UserService();

// export default class Reg extends React.Component {
//     render() {
//         return <Reg0 service={userService} />;
//     }
// }

// 注意装饰器顺序
@inject({service}) // {service}会转化成{service:(new UserService())}
@observer
export default class Reg extends React.Component {
    handlerClick(event) {
        event.preventDefault();
        let fm = event.target.form;
        this.props.service.reg(fm[0].value, fm[1].value, fm[2].value);
    }

    render() {
        if (this.props.service.errMsg) {
            message.info(this.props.service.errMsg, 3,
                () => setTimeout(() => this.props.service.errMsg = ''), 1000);
        }
        if (this.props.service.loggedin) {
            return (
                <div>
                    <Navigate to="/" replace={true} />
                </div>);
        }
        else {
            return (
                <div className="login-page">
                    <div className="form">
                        <form className="register-form">
                            <input type="text" placeholder="姓名" />
                            <input type="text" placeholder="邮箱" />
                            <input type="password" placeholder="密码" />
                            <input type="password" placeholder="确认密码" />
                            <button onClick={this.handlerClick.bind(this)}>注册</button>
                            <p className="message">如果已经注册<Link to="/login">请登录</Link></p>
                        </form>
                    </div>
                </div>
            );
        }
    }

}

Mobx 的 observer 装饰器有要求,所以装饰的顺序要注意一下。

2.7、博文功能实现

2.7.1、导航菜单

参见 Antd 官网

$ npm install @ant-design/icons

./src/component/index.js

import * as React from "react";
import { Link, useRoutes } from "react-router-dom";
import Login from "./login";
import Reg from './reg';
import Pub from "./pub";

import { LoginOutlined, RobotOutlined, InfoOutlined } from '@ant-design/icons';
import { Menu } from 'antd';

import { Layout, Space } from 'antd';
const { Header, Footer, Content } = Layout;
const headerStyle = {
  textAlign: 'center',
  color: '#fff',
  height: 64,
  paddingInline: 50,
  lineHeight: '64px',
  backgroundColor: '#00888f',
};
const contentStyle = {
  textAlign: 'center',
  minHeight: 120,
  lineHeight: '60px',
  color: '#fff',
  backgroundColor: '#00888f',
};
const footerStyle = {
  textAlign: 'center',
  color: '#fff',
  backgroundColor: '#000000',
};

const items = [
  {
    label: (
      <Link to="/">首页</Link>
    ),
    key: 'board',
    icon: <RobotOutlined />,
  },
  {
    label: (
      <Link to="/about">关于</Link>
    ),
    key: 'about',
    icon: <InfoOutlined />,
  },
  {
    label: (
      <Link to="/login">登陆</Link>
    ),
    key: 'login',
    icon: <LoginOutlined />,
  },
  {
    label: (
      <Link to="/reg">注册</Link>
    ),
    key: 'reg',
    icon: <LoginOutlined />,
  },
  {
    label: (
      <Link to="/pub">发布</Link>
    ),
    key: 'pub',
    icon: <LoginOutlined />,
  },
];

const App = () => {
  const routes = useRoutes([
    {
      path: "/",
      element: (
        <div>
          <h1>This is Home</h1>
          <Link to="/about">To About</Link>
        </div>
      ),
    },
    {
      path: "/about",
      element: <div>This is About</div>,
    },
    {
      path: "/login",
      element: <Login />,
    },
    {
      path: "/reg",
      element: <Reg />,
    },
    {
      path: "pub",
      element: <Pub />,
    },
  ]);
  return (
    <Space
      direction="vertical"
      style={{
        width: '100%',
      }}
      size={[0, 48]}
    >
      <Layout>
        <Header style={headerStyle}>
          <Menu mode="horizontal" items={items} />
        </Header>
        <Content style={contentStyle}>
          {routes}
        </Content>
        <Footer style={footerStyle}>
          Brinnatt Schoole www.brinnatt.com.
        </Footer>
      </Layout>
    </Space>
  );
};

export default App;

布局:采用上中下布局,参考 https://ant.design/components/layout-cn/

2.7.2、博文业务

/post/pub POST 提交博文的 title、content,成功返回 Json,post_id。

/post/id GET 返回博文详情,返回 Json,post_id、title、author、author_id、postdate(时间戳)、content。

/post/ GET 返回博文列表。

2.7.2.1、业务层

创建 service/post.js 文件,新建 PostService 类。

import axios from "axios";
import {observable,makeObservable} from 'mobx';

export default class PostService {
    msg = "";
    constructor() {
        makeObservable(this, {
            msg: observable
        });
    };

    pub(title, content) {
        console.log(title);
        axios.post('/api/post/pub', {
            title:title,
            content:content
        })
        .then(
            response => {
                console.log(response.data);
                console.log(response.status);
                this.msg = '博文提交成功';
            }
        ).catch (
            error => {
                console.log(error);
                this.msg = '登陆失败';
            }
        );
    }
}

2.7.2.2、发布组件

使用 Form 组件,参见 https://ant.design/components/form-cn/

import React from 'react';
import { observer } from 'mobx-react';
import { message } from 'antd';
import { inject } from '../utils';
import { Form, Input, Button } from 'antd';
import FormItem from 'antd/lib/form/FormItem'; // 不在antd中单独导
import PostService from '../service/post';
import 'antd/lib/message/style';
import 'antd/lib/form/style';
import 'antd/lib/input/style';
import 'antd/lib/button/style';

const { TextArea } = Input;
const service = new PostService();

@inject({ service })
@observer
export default class Pub extends React.Component {
    handleSubmit = (values) => {
        console.log(values);
        console.log(values.title);
        console.log(values.content);
        this.props.service.pub(values.title, values.content);
    };

    render() {
        if (this.props.service.msg) {
            message.info(this.props.service.msg, 3,
                () => setTimeout(() => this.props.service.msg = ''), 1000);
        }
        return (
            <div style={{ maxWidth: 1200, margin: '0 auto' }}>
                <Form layout="vertical" onFinish={this.handleSubmit}>
                    <FormItem label="标题" name="title" labelCol={{ span: 4 }} rules={[{ required: true, message: '请输入标题' }]}>
                        <Input placeholder="标题" />
                    </FormItem>
                    <FormItem label="内容" name="content" labelCol={{ span: 4 }} rules={[{ required: true, message: '请输入内容' }]}>
                        <TextArea rows={20} />
                    </FormItem>
                    <FormItem>
                        <Button type="primary" htmlType="submit">提交</Button>
                    </FormItem>
                </Form>
            </div>
        );
    }
}

2.7.2.3、业务层改进

header 中的 Jwt。

由于与后台 Django Server 通信,身份认证需要 Jwt,这个要放到 request header 中。使用 axios 的 API 发送。

import axios from "axios";
import {observable,makeObservable} from 'mobx';
import store from 'store';

export default class PostService {
    msg = "";
    constructor() {
        makeObservable(this, {
            msg: observable
        });
        this.axios = axios.create({
            baseURL:'/api/post/'
        });
    };

    getJwt(){
        return store.get('token', null);
    }

    pub(title, content) {
        console.log(title);
        this.axios.post('pub', {
            title:title,
            content:content
        }, {headers:{'Jwt':this.getJwt()}})
        .then(
            response => {
                console.log(response.data);
                console.log(response.status);
                this.msg = '博文提交成功';
            }
        ).catch (
            error => {
                console.log(error);
                this.msg = '登陆失败';
            }
        );
    }

}

2.7.2.4、文章列表页组件

创建 component/list.js,创建 List 组件。在 index.js 中添加菜单项和路由。

// 部分代码
const items = [
  ...
  {
    label: (
      <Link to="/pub">发布</Link>
    ),
    key: 'pub',
    icon: <LoginOutlined />,
  },
  {
    label: (
      <Link to="/list">文章列表</Link>
    ),
    key: 'list',
    icon: <LoginOutlined />,
  },
];

const App = () => {
  const routes = useRoutes([
    ...
    {
      path: "/list",
      element: <L />,
    },
  ]);
  return (
    <Space
      direction="vertical"
      style={{
        width: '100%',
      }}
      size={[0, 48]}
    >
      <Layout>
        <Header style={headerStyle}>
          <Menu mode="horizontal" items={items} />
        </Header>
        <Content style={contentStyle}>
          {routes}
        </Content>
        <Footer style={footerStyle}>
          Brinnatt Schoole www.brinnatt.com.
        </Footer>
      </Layout>
    </Space>
  );
};

export default App;
2.7.2.4.1、查询字符串处理

用户请求的 URL 是 http://localhost:3000/list?page=2,要被转换成 /api/post/?page=2,如何提取查询字符串?

现在前端路由有 react-router 管理,它匹配路径后,才会路由。react-router v5 版本将匹配的数据注入组件的 props 中,也可以使用解构提取 const { match, location } = this.props。react-router v6 版本只能使用 useLocation() 钩子函数获取。

location 也是一个对象,pathname 表示路径,search 表示查询字符串。pathName:"/list", search:"?page=2"。拿到查询字符串后,可以使用 URLSearchParams 解析它。本次将查询字符串直接拼接发往后端,有 Django 服务器端判断。

var params = new URLSearchParams(url.search);
console.log(params.get('page'), params.get('size'))
2.7.2.4.2、List 组件

component/list.js,前面我们用的都是类组件,主要是为了利旧以前的代码,但是我们使用的 React 18.x 并不向前兼容,类组件全部废弃了,变成函数式组件和钩子函数。

import React, { useEffect } from 'react';
import { observer } from 'mobx-react';
import { inject } from '../utils';
import { Link, useLocation } from 'react-router-dom';
import { List } from 'antd';
import PostService from '../service/post';
import 'antd/lib/message/style';
import 'antd/lib/list/style';

const service = new PostService();

const L = observer((props) => {
    const location = useLocation();

    useEffect(() => {
        console.log(location);
        // 将查询字符串向后传
        props.service.list(location.search);
    }, [location, props.service]);

    let data = props.service.posts;
    if (data.length) {
        return (
            <List bordered={true} dataSource={data} renderItem={
                item => (
                    <List.Item>
                        <Link to={'/post/'+item.post_id}>{item.title}</Link>
                    </List.Item>
                )
            } />
        );
    } else {
        return <div></div>;
    }
});

export default inject({ service })(L);

List 列表组件,bordered 有边线,dataSource 给定数据源,renderItem 渲染每一行,给定一个单参函数迭代每一行,List.Item 每一行的组件。

使用 Link 组件增加链接:

<List bordered={true} dataSource={data} renderItem={
    item => (<List.Item>
                <Link to={'/post/' + item.post_id}>{item.title}</Link>
            </List.Item>)
} />

如果需要根据复杂的效果可以这样:

<List bordered={true} dataSource={data} renderItem={
    item => (<List.Item>
                <List.Item.Meta title={<Link to={'/post/' + item.post_id}>{item.title}</Link>} />
            </List.Item>)
} />
<Link to={'/post/' + item.post_id}>{item.title}</Link> 这是详情页的链接

PostService 部分代码如下:

import axios from "axios";
import { observable, makeObservable } from 'mobx';
import store from 'store';

export default class PostService {
    msg = "";
    posts = []; // 博文列表
    pagination = { page: 1, size: 20, pages: 0, count: 0 }; // 分页信息
    constructor() {
        makeObservable(this, {
            msg: observable,
            posts: observable,
            pagination: observable
        });
        this.axios = axios.create({
            baseURL: '/api/post/'
        });
    };

    getJwt() {
        return store.get('token', null);
    }

    pub(title, content) {/*省略*/}

    list(search) {
        this.axios.get(search)
            .then(
                response => {
                    console.log(response.data);
                    console.log(response.status);
                    this.posts = response.data.posts;
                    this.pagination = response.data.pagination;
                }
            ).catch(
                error => {
                    console.log(error);
                    this.msg = '文章列表加载失败'; //+ 信息显示
                }
            );
    }
}

2.7.2.5、分页功能

分页还是需要解析查询字符串的,我们自己写一个解析函数,把这个函数放入 utils.js 中。

let url = '?id=5&page=1&size=20&id=&age-20&name=abc&name=汤姆=&测试=1'
function parse_qs(qs, re = /(\w+)=([^&]+)/) {
    let obj = {};
    if (qs.startsWith('?')){
        qs = qs.substr(1);
    }
    console.log(qs);
    qs.split('&').forEach(element => {
        let match = re.exec(element);
        //console.log(match)
        if (match) obj[match[1]] = match[2];
    });
    return obj;
}
console.log(parse_qs(url))

分页使用了 Pagination 组件,在 L 组件的 render 函数的 List 组件中使用 pagination 属性,这个属性内放入一个 pagination 对象,有如下属性:

  • current ,当前页。
  • pageSize, 页面内行数。
  • total,记录总数。
  • onChange,页码切换时调用,回调函数为 (pageNo, pageSize) => {},即切换是获得当前页码和页内行数。

component/list.js 代码修改如下:

import React, { useEffect } from 'react';
import { observer } from 'mobx-react';
import { inject} from '../utils';
import { Link, useLocation } from 'react-router-dom';
import { List } from 'antd';
import PostService from '../service/post';
import 'antd/lib/message/style';
import 'antd/lib/list/style';

const service = new PostService();

const L = observer((props) => {
    const location = useLocation();

    useEffect(() => {
        console.log(location);
        // 将查询字符串向后传
        props.service.list(location.search);
    }, [location, props.service]);

    const handleChange = (pageNo, pageSize) => {
        console.log(pageNo, pageSize);
        // 不管以前查询字符串是什么,重新拼接 查询字符串 向后传
        let search = '?page=' + pageNo + '&size=' + pageSize;
        props.service.list(search);
    };

    let data = props.service.posts;
    if (data.length) {
        const pagination = props.service.pagination;
        return (
            <List bordered={true} dataSource={data} renderItem={
                item => (
                    <List.Item>
                        <List.Item.Meta title={<Link to={'/post/' + item.post_id}>{item.title}</Link>} />
                    </List.Item>
                )
            }
                pagination={{
                    current: pagination.page,
                    pageSize: pagination.size,
                    total: pagination.count,
                    onChange: handleChange
                }}
            />
        );
    } else {
        return <div></div>;
    }
});

export default inject({ service })(L);

测试可以切换页面。但是鼠标放到左右两端发现上一页、下一页是英文,如何修改?国际化。

2.7.2.5.1、国际化

index.js 修改如下:

import * as React from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./component/index";
import { ConfigProvider } from "antd";
import zhCN from 'antd/locale/zh_CN';

const root = createRoot(document.getElementById("app"));

root.render(
  <ConfigProvider locale={zhCN}>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </ConfigProvider>
);

将 App 这个根组件包裹住就行了,再看分页组件就显示中文了。

2.7.2.5.2、浏览器地址变动

基本上没有什么问题了,但是,如果在地址栏里面输入 http://localhost:3000/list?size=2&page=2 后,再切换分页,地址栏 URL 不动,不能和当前页一致。

这个问题的解决有一定的难度。需要定义 itemRender 属性,定义一个函数,这个函数有 3 个参数。

  • current,当前 pageNo。
  • type,当前类型,上一页为 prev,下一页为 next,页码为 page。
  • originalElement,不要动这个参数,直接返回就行了。
import React, { useEffect } from 'react';
import { observer } from 'mobx-react';
import { inject, parse_qs } from '../utils';
import { Link, useLocation } from 'react-router-dom';
import { List } from 'antd';
import PostService from '../service/post';
import 'antd/lib/message/style';
import 'antd/lib/list/style';

const service = new PostService();

const L = observer((props) => {
    const location = useLocation();

    useEffect(() => {
        console.log(location);
        // 将查询字符串向后传
        props.service.list(location.search);
    }, [location, props.service]);

    const handleChange = (pageNo, pageSize) => {
        console.log(pageNo, pageSize);
        // 不管以前查询字符串是什么,重新拼接 查询字符串 向后传
        let search = '?page=' + pageNo + '&size=' + pageSize;
        props.service.list(search);
    };

    const getUrl = (cur) => {
        let obj = parse_qs(location.search);
        let { size = 20 } = obj;
        return '/list?page=' + cur + '&size=' + size;
    };

    const itemRender = (current, type, originalElement) => {
        if (type === 'page')
            return <Link to={getUrl(current)}>{current}</Link>;
        return originalElement;
    };

    let data = props.service.posts;
    if (data.length) {
        const pagination = props.service.pagination;
        return (
            <List bordered={true} dataSource={data} renderItem={
                item => (
                    <List.Item>
                        <List.Item.Meta title={<Link to={'/post/' + item.post_id}>{item.title}</Link>} />
                    </List.Item>
                )
            }
                pagination={{
                    current: pagination.page,
                    pageSize: pagination.size,
                    total: pagination.count,
                    onChange: handleChange,
                    itemRender: itemRender
                }}
            />
        );
    } else {
        return <div></div>;
    }
});

export default inject({ service })(L);

基本解决问题。但是,上一页、下一页点击不能改变浏览器地址栏。

import React, { useEffect } from 'react';
import { observer } from 'mobx-react';
import { inject, parse_qs } from '../utils';
import { Link, useLocation } from 'react-router-dom';
import { List } from 'antd';
import PostService from '../service/post';
import 'antd/lib/message/style';
import 'antd/lib/list/style';

const service = new PostService();

const L = observer((props) => {
    const location = useLocation();

    useEffect(() => {
        console.log(location);
        // 将查询字符串向后传
        props.service.list(location.search);
    }, [location, props.service]);

    const handleChange = (pageNo, pageSize) => {
        console.log(pageNo, pageSize);
        // 不管以前查询字符串是什么,重新拼接 查询字符串 向后传
        let search = '?page=' + pageNo + '&size=' + pageSize;
        props.service.list(search);
    };

    const getUrl = (cur) => {
        let obj = parse_qs(location.search);
        let { size = 20 } = obj;
        return '/list?page=' + cur + '&size=' + size;
    };

    const itemRender = (current, type, originalElement) => {
        if (current === 0) return originalElement; // 竟然返回0,只能屏蔽它
        if (type === 'page')
            return <Link to={getUrl(current)}>{current}</Link>;
        if (type === 'next')
            return <Link to={getUrl(current)} className='ant-pagination-item-link'></Link>;
        if (type === 'prev')
            return <Link to={getUrl(current)} className='ant-pagination-item-link'></Link>;
        return originalElement;
    };

    let data = props.service.posts;
    if (data.length) {
        const pagination = props.service.pagination;
        return (
            <List bordered={true} dataSource={data} renderItem={
                item => (
                    <List.Item>
                        <List.Item.Meta title={<Link to={'/post/' + item.post_id}>{item.title}</Link>} />
                    </List.Item>
                )
            }
                pagination={{
                    current: pagination.page,
                    pageSize: pagination.size,
                    total: pagination.count,
                    onChange: handleChange,
                    itemRender: itemRender
                }}
            />
        );
    } else {
        return <div></div>;
    }
});

export default inject({ service })(L);

至此,分页问题解决。

2.7.2.6、详情页组件

index.jsp

import Detail from "./post";

const App = () => {
  const routes = useRoutes([
    {
      path: "/",
      element: (
        <div>
          <h1>This is Home</h1>
          <Link to="/about">To About</Link>
        </div>
      ),
    },
    // 省略
    {
      path: "/post/:id",
      element: <Detail />,
    },
  ]);
  return (
    <Space
      direction="vertical"
      style={{
        width: '100%',
      }}
      size={[0, 48]}
    >
      <Layout>
        <Header style={headerStyle}>
          <Menu mode="horizontal" items={items} />
        </Header>
        <Content style={contentStyle}>
          {routes}
        </Content>
        <Footer style={footerStyle}>
          Brinnatt Schoole www.brinnatt.com.
        </Footer>
      </Layout>
    </Space>
  );
};

service/post.js 增加 getPost 服务代码:

import axios from "axios";
import { observable, makeObservable } from 'mobx';
import store from 'store';

export default class PostService {
    msg = "";
    posts = []; // 博文列表
    pagination = { page: 1, size: 20, pages: 0, count: 0 }; // 分页信息
    post = {}; // 添加博客详情监控属性
    constructor() {
        makeObservable(this, {
            msg: observable,
            posts: observable,
            pagination: observable,
            post: observable
        });
        this.axios = axios.create({
            baseURL: '/api/post/'
        });
    };

    getJwt() {
        return store.get('token', null);
    }

    pub(title, content) {
        console.log(title);
        this.axios.post('pub', {
            title: title,
            content: content
        }, { headers: { 'Jwt': this.getJwt() } })
            .then(
                response => {
                    console.log(response.data);
                    console.log(response.status);
                    this.msg = '博文提交成功';
                }
            ).catch(
                error => {
                    console.log(error);
                    this.msg = '登陆失败';
                }
            );
    }

    list(search) {
        this.axios.get(search)
            .then(
                response => {
                    console.log(response.data);
                    console.log(response.status);
                    this.posts = response.data.posts;
                    this.pagination = response.data.pagination;
                }
            ).catch(
                error => {
                    console.log(error);
                    this.msg = '文章列表加载失败'; //+ 信息显示
                }
            );
    }

    getPost(id) { // 获取博客详情
        this.axios.get(id).then(
            response => {
                console.log(response.data);
                console.log(response.status);
                this.post = response.data.post;
            }
        ).catch(
            error => {
                console.log(error);
                this.msg = "详情页加载失败";
            }
        );
    }
}

新建 component/post.js,创建 Detail 组件。使用 antd Card 布局。

import React, { useEffect } from 'react';
import { observer } from 'mobx-react';
import { inject } from '../utils';
import { useParams } from 'react-router-dom';
import { Card, message } from 'antd';
import PostService from '../service/post';
import 'antd/lib/message/style';
import 'antd/lib/list/style';

const service = new PostService();

const Detail = observer((props) => {
    const params = useParams();

    useEffect(() => {
        console.log(params);
        let { id = -1 } = params; // {id:'3'}
        props.service.getPost(id);
    }, [params, props.service]);

    if (props.service.msg) {
        message.info(
            props.service.msg,
            3,
            () => setTimeout(
                () => props.service.msg = '', 1000
            )
        );
    }
    let post = props.service.post;
    if (post.title) {
        return <Card title={post.title} bordered={true} style={{ width: 800, margin: 'auto'}}>
            <p>{post.author} {new Date(post.postdate * 1000).toLocaleDateString()}</p>
            <p>{post.content}</p>
        </Card>;
    } else {
        return (<div></div>);
    }

});

export default inject({ service })(Detail);

至此,前后端分离的博客系统基本框架搭好了,去看看页面的成果。

2.8、Django 部署

2.8.1、Django 打包

构建 setup.py 文件:

from distutils.core import setup
import glob

setup(name='blog',
      version='1.0',
      description='This is a demo for web dev',
      author='Brinnatt',
      author_email='brinnatt@gmail.com',
      url='https://www.brinnatt.com',
      packages=['blog', 'post', 'user'],
      py_modules=['manage'],  # 可以不打包manage.py
      data_files=glob.glob('templates/*.html') + ['requirements']
      )
# 应用程序的根目录下打包
$ pip freeze > requirements
$ python setup.py sdist --formats=gztar # gz

在 Linux 系统中创建一个 python 虚拟环境目录。

[python@brinnatt ~]$ pyenv virtualenv 3.9.10 blog3_9_10
[python@brinnatt ~]$ pyenv local blog3_9_10 
(blog3_9_10) [python@brinnatt ~]$
(blog3_9_10) [python@brinnatt ~]$ pip list
Package    Version
---------- -------
pip        21.2.4
setuptools 58.1.0
(blog3_9_10) [python@brinnatt ~]$ tar xf blog-1.0.tar.gz
(blog3_9_10) [python@brinnatt ~]$ cd blog-1.0/
# yum install python-devel mysql-devel # CentOS需要root权限安装,mysqlclient依赖
(blog3_9_10) [python@brinnatt blog-1.0]$ pip install -r requirements
(blog3_9_10) [python@brinnatt blog-1.0]$ pip list
Package      Version
------------ -------
asgiref      3.6.0
bcrypt       4.0.1
cffi         1.15.1
cryptography 40.0.2
Django       4.0
mysqlclient  2.1.1
pip          21.2.4
pycparser    2.21
PyJWT        2.7.0
setuptools   58.1.0
simplejson   3.19.1
sqlparse     0.4.4
tzdata       2023.3

(blog3_9_10) [python@brinnatt blog-1.0]$ sed -i -e 's/DEBUG.*/DEBUG = False/' -e 's/ALLOWED_HOSTS.*/ALLOWED_HOSTS = ["*"]/' blog/settings.py # 修改Django配置
(blog3_9_10) [python@brinnatt blog-1.0]$ python manage.py runserver 0.0.0.0:8000 # 启动服务

使用 http://192.168.136.128:8000/post/?page=2&size=2 成功返回数据,说明Django应用成功。

至此,Django 应用部署完成。Django 带了个开发服务器 Web Server,生产环境不要用,需要借助其它 Server。

注意:ALLOWED_HOSTS = ["*"] 这是所有客户都可以访问,生产环境应指定具体可以访问的 IP。

2.8.2、WSGI

Web Server Gateway Interface,是 Python 中定义的 Web Server 与应用程序的接口定义。

应用程序有 WSGI 的 Django 框架负责,WSGI Server 谁来做?

2.8.3、uWSGI 项目

uWSGI 是一个 C 语言的项目,提供一个 WEB 服务器,它支持 WSGI 协议,可以和 Python 的 WSGI 应用程序通信。

官方文档 https://uwsgi-docs.readthedocs.io/en/latest/

uWSGI 可以直接启动 HTTP 服务,接收 HTTP 请求,并调用 Django 应用。

安装:

(blog3_9_10) [python@brinnatt blog-1.0]$ pip install uwsgi
(blog3_9_10) [python@brinnatt blog-1.0]$ uwsgi --help

2.8.4、uWSGI + Django 部署

在 Django 项目根目录下,运行 $ uwsgi --http :8000 --wsgi-file blog/wsgi.py --stats :8001,使用下面的链接测试:

http://192.168.136.128:8000/

http://192.168.136.128:8000/post/?page=1&size=2

运行正常。

2.9、React 部署

2.9.1、React 项目打包

$ npm run build

$ npm run build 编译成功。查看项目目录中的 dist 目录。

2.9.2、nginx 部署

安装 nginx:

[root@brinnatt ~]# yum install nginx -y
[root@brinnatt ~]# rm -rf /usr/share/nginx/html/*
[root@brinnatt ~]# systemctl start nginx
[root@brinnatt ~]# systemctl enable nginx

将前端发布的 dist 目录下的所有文件都拷贝至 /usr/share/nginx/html 目录下。刷新首页,一切从此开始。

python3_blog_dist

2.9.3、uwsgi 部署

将 nginx 和 uWSGI 之间的通信配置成 uwsgi 协议通信。

本次 pyenv 的虚拟目录是 /home/python,其下放 uWSGI 的配置文件 blog.ini。

(blog3_9_10) [python@brinnatt ~]$ pwd
/home/python
(blog3_9_10) [python@brinnatt ~]$ vim blog.ini
(blog3_9_10) [python@brinnatt ~]$ cat blog.ini 
[uwsgi]
socket = 127.0.0.1:9000
chdir = /home/python/blog-1.0
wsgi-file = blog/wsgi.py
(blog3_9_10) [python@brinnatt ~]$ uwsgi blog.ini

socket = 127.0.0.1:9000 使用 uwsgi 协议通信。

chdir = /home/python/blog-1.0 Django 项目根目录。

wsgi-file = blog/wsgi.py 指定 App 文件,blog 下 wsgi.py。

在 nginx 中配置 uwsgi:

[root@brinnatt ~]# vim /etc/nginx/conf.d/WsgiDjango.conf
[root@brinnatt ~]# cat /etc/nginx/conf.d/WsgiDjango.conf
server {
    listen 80;
    server_name localhost;

    location ^~ /api/ {
        rewrite ^/api(/.*) $1 break;
        #proxy_pass http://127.0.0.1:8000
        include uwsgi_params;
        uwsgi_pass 127.0.0.1:9000;
    }
}
[root@brinnatt ~]# systemctl restart nginx.service

重新装载 nginx 配置文件,成功运行。至此,前后端分离的开发、动静分离部署的博客项目大功告成。

参看 https://uwsgi-docs.readthedocs.io/en/latest/WSGIquickstart.html

uwsgi 协议 https://uwsgi-docs.readthedocs.io/en/latest/Protocol.html

2.9.4、MVC 设计

MVC 设计模式:

Controller 控制器:负责接收用户请求,调用 Model 完成数据处理,调用 view 完成对用户的响应。

Model 模型:负责业务数据的处理。

View 视图:负责用户的交互界面。

python3_mvc

标签云