使用Django-channels/Vue3&websocket实现一个即时聊天室—Part 2 前后端交互

384 阅读8分钟

前言

欢迎来到使用Django&Channels、Vue3&WebSocket实现一个即时聊天室教程的第二部分。本教程将花一些时间介绍聊天室的基础设计和具体实现,接着通过ajax技术将前后端接通,实现基本的前后端交互。

我们将使用现阶段较为流行的css框架--Tailwindcss来编写页面,它的出现使我们可以大量减少基础css代码的编写,十分方便。

本次教程的待办事项如下:

  • 初始化vue项目
  • 编写静态页面
  • 配置路由
  • 前后端交互
  • pinia的配置与使用

初始化vue项目

使用命令创建vue3项目

npm init vue@latest

在提示下填下项目名和相关配置:

此时vue项目已经创建完成,接下来我们可以启动项目查看

cd client
yarn install

Tips

为了避免与其他项目在同时运行时出现冲突,我们可以在vite.config.js中设置指定端口:

export default defineConfig({
	plugins: [vue()],
  // 设置端口号
	server: {
		port: 3000,
	},
	resolve: {
		alias: {
			'@': fileURLToPath(new URL('./src', import.meta.url)),
		},
	},
})

修改完成之后,使用命令启动项目:

yarn dev

启动完成后,在http://localhost:3000下会看到如下页面:

看到这个页面,说明vue项目已经成功创建。

编写页面

在进行下一步之前,简单描述一下我们接下来要编写的页面。

  • HomeView:主页
  • LoginView:登录页
  • SignupView:注册页
  • NavBar:导航栏

本部分所需的页面就是这些,后面随着课程的推进会有变动。

开始编写页面之前,我们先安装一下Tailwind CSS

安装Tailwind CSS

安装TailwindCSS以及其依赖项

yarn add tailwindcss postcss autoprefixer

生成配置文件

npx tailwindcss init -p

修改配置文件tailwind.config.js

设置content内的内容,您可以根据自己的开发需求添加:

/** @type {import('tailwindcss').Config} */
module.exports = {
	content: ['./index.html', './src/**/*.{vue,js}'],
	theme: {
		extend: {},
	},
	plugins: [],
}

修改src/assets中的main.css

@tailwind base;
@tailwind components;
@tailwind utilities;

这个文件后面会用到,现在先保持初始配置。

上述配置完成后,tailwindcss就可以使用了。接下来就是编写页面了。

准备工作

编写页面之前,先把项目原有的页面和组件清除,换上我们即将要开发的内容。

清理完成之后,文件结构应该是这样:

📦 client
 ┣ 📂.vscode
 ┣ 📂node_modules
 ┣ 📂public
 ┣ 📂src
 ┃ ┣ 📂assets
 ┃ ┃ ┣ 📜logo.svg
 ┃ ┃ ┗ 📜main.css
 ┃ ┣ 📂components
 ┃ ┃ ┗ 📜Navbar.vue
 ┃ ┣ 📂router
 ┃ ┃ ┗ 📜index.js
 ┃ ┣ 📂views
 ┃ ┃ ┣ 📜HomeView.vue
 ┃ ┃ ┣ 📜LoginView.vue
 ┃ ┃ ┗ 📜SignUpView.vue
 ┃ ┣ 📜App.vue
 ┃ ┗ 📜main.js
 ┃ 📜.gitignore
 ┃ 📜index.html
 ┃ 📜package.json
 ┃ 📜postcss.config.js
 ┃ 📜README.md
 ┃ 📜tailwind.config.js
 ┃ 📜vite.config.js
 ┗ 📜yarn.lock

然后在main.css编写全局样式,为每个页面添加背景颜色:

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  body {
    @apply bg-teal-600;
  }
}

恭喜!准备工作已经完成,让我们开始编写页面吧!

HomeView

主页其实就只写了一个标题,稍微设置了一下移动端样式:

<template>
	<div class="p-10 text-center lg:p-20">
		<h1 class="text-3xl text-white lg:text-6xl">Django-chat</h1>
	</div>
</template>

SignUpView

在这里编写了一个简单的表单,由标题、输入框和按钮组成。设置了一下移动端样式:

<template>
	<div class="text-center p-10 lg:p-20">
		<h1 class="text-3xl text-white lg:text-6xl">注册</h1>
	</div>
	<div class="px-4 mx-auto lg:w-1/4">
		<div class="mb-5">
			<input
				type="text"
				class="w-full mt-2 px-4 py-2 rounded-xl focus:outline-none"
				placeholder="Email"
			/>
		</div>
		<div class="mb-5">
			<input
				type="text"
				class="w-full mt-2 px-4 py-2 rounded-xl focus:outline-none"
				placeholder="用户名"
			/>
		</div>
		<div class="mb-5">
			<input
				type="password"
				class="w-full mt-2 px-4 py-2 rounded-xl focus:outline-none"
				placeholder="密码"
			/>
		</div>
		<div class="mb-5">
			<input
				type="password"
				class="w-full mt-2 px-4 py-2 rounded-xl focus:outline-none"
				placeholder="重复密码"
			/>
		</div>
		<div id="err-msg" class="py-2 text-sm text-white"></div>
		<div class="flex gap-5">
			<button
				class="px-5 py-3 rounded-xl text-white bg-teal-800 hover:bg-teal-700"
			>
				注册
			</button>

			<button
				class="px-5 py-3 rounded-xl text-white bg-teal-800 hover:bg-teal-700"
			>
				已有账号?去登录
			</button>
		</div>
	</div>
</template>

LoginView

登录页面与注册页面组成大致相同,这里不再赘述:

<template>
	<div class="text-center p-10 lg:p-20">
		<h1 class="text-3xl text-white lg:text-6xl">登录</h1>
	</div>
	<div class="px-4 mx-auto lg:w-1/4">
		<div class="mb-5">
			<input
				type="text"
				class="w-full mt-2 px-4 py-2 rounded-xl focus:outline-none"
				placeholder="Email"
			/>
		</div>
		<div class="mb-5">
			<input
				type="password"
				class="w-full mt-2 px-4 py-2 rounded-xl focus:outline-none"
				placeholder="密码"
			/>
		</div>
		<div class="flex gap-5">
			<button
				class="px-5 py-3 rounded-xl text-white bg-teal-800 hover:bg-teal-700"
			>
				登录
			</button>
			<button
				class="px-5 py-3 rounded-xl text-white bg-teal-800 hover:bg-teal-700"
			>
				没有账号?去注册
			</button>
		</div>
	</div>
</template>

NavBar

我们还需要制作一个导航栏,可以在其中添加登录、注册等页面链接。由于页面构成不难,在这里直接给出代码:

<template>
	<nav class="flex items-center justify-between px-4 py-6 bg-teal-800">
		<div>
			<router-link to="/" class="text-xl text-white">Django-Chat</router-link>
		</div>

		<div class="flex items-center space-x-4">
			<router-link to="/login" class="text-white hover:text-gray-200"
				>登录</router-link
			>
			<router-link
				to="/signup"
				class="px-5 py-3 rounded-xl text-white bg-teal-600 hover:bg-teal-700"
				>注册</router-link
			>
		</div>

		<div class="flex items-center space-x-4">
			<router-link to="/rooms" class="text-white hover:text-gray-200"
				>房间</router-link
			>
			<button
				class="px-5 py-3 rounded-xl text-white bg-teal-600 hover:bg-teal-700"
			>
				登出
			</button>
		</div>
	</nav>
</template>

将其添加至App.vue

<script setup>
import Navbar from './components/Navbar.vue'
</script>

<template>
	<Navbar />
	<RouterView />
</template>

配置路由

vue-router的配置比较简单,我们直接根据我们已经编写好的页面配置即可,配置如下:

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import LoginView from '../views/LoginView.vue'
import SignUpView from '../views/SignUpView.vue'

const router = createRouter({
	history: createWebHistory(import.meta.env.BASE_URL),
	routes: [
		{
			path: '/',
			name: 'home',
			component: HomeView,
		},
		{
			path: '/login',
			name: 'login',
			component: LoginView,
		},
		{
			path: '/signup',
			name: 'signup',
			component: SignUpView,
		},
	],
})

export default router

前后端交互

提起前后端交互,不得不提到Axios

Axios 是一个基于 promise 网络请求库,作用于node.js 和浏览器中。 它是 isomorphic 的(即同一套代码可以运行在浏览器和node.js中)。在服务端它使用原生 node.js http 模块, 而在客户端 (浏览端) 则使用 XMLHttpRequests。

我们将使用Axios连通前后端。

安装与封装axios

首先让我们安装axios

yarn add axios

安装完成后,我们需要封装一下axios。封装axios的好处有很多,比如提高代码的可维护性、可扩展性和复用性等。我们在src中创建文件夹composables,创建文件useAxios.js文件用于各组件可以全局调用axios,封装的代码如下:

import axios from 'axios'

function useAxios() {
	const instance = axios.create({
		baseURL: 'http://127.0.0.1:8000/',
		timeout: 5000,
	})

	instance.interceptors.request.use(
		(config) => {
			const token = localStorage.getItem('user.token')
			if (token) {
				config.headers['token'] = 'Token ' + token
			}
			return config
		},
		(error) => {
			return Promise.reject(error)
		}
	)

	instance.interceptors.response.use(
		(response) => {
			return response.data
		},
		(error) => {
			if (error.response.status == 400) {
				return error.response.data
			}
			return Promise.reject(error)
		}
	)
	return instance
}

export default useAxios

封装主要是为了给每个请求统一请求地址和请求超时时间,简单设置了两个拦截器:

  • 请求拦截器:用于在每个请求被发送之前添加额外的配置或标头。在这里,你检查了localStorage中是否存在用户令牌(token),如果有,就将它添加到请求头中。
  • 响应拦截器:用于在接收到响应后对其进行处理。在成功的情况下,请求只返回响应的数据部分(response.data),以使调用者只获取数据而不是整个响应对象。在错误的情况下,检查了错误的响应状态,如果是400错误,返回了错误的响应数据(error.response.data)。

配置后端跨域

在发起请求之前,我们需要返回后端代码配置跨域,否则将无法成功发起请求。

让我们回到后端,安装并配置django-cors-headers

在后端输入以下命令:

# server/
env\Scripts\activate
pip install django-cors-headers

接着在server/chat_api/settings.py里配置跨域

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "accounts",
    "rest_framework",
    "rest_framework.authtoken",
    # 跨域
    "corsheaders",
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    # 放的位置要注意
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]


CORS_ALLOWED_ORIGINS = ["http://localhost:3000"]  # 前端地址
CORS_ALLOW_METHODS = [
    "DELETE",
    "GET",
    "OPTIONS",
    "PATCH",
    "POST",
    "PUT",
]
CORS_ALLOW_HEADERS = [
    "accept",
    "accept-encoding",
    "authorization",
    "content-type",
    "dnt",
    "origin",
    "user-agent",
    "x-csrftoken",
    "x-requested-with",
]

这样就配置完成了。

实现注册功能

SignUpView里添加JS代码,实现前后端交互,修改后的代码如下:

<template>
  <div class="text-center p-10 lg:p-20">
    <h1 class="text-3xl text-white lg:text-6xl">注册</h1>
  </div>

  <div class="px-4 mx-auto lg:w-1/4">
    <div class="mb-5">
      <input
        type="text"
        class="w-full mt-2 px-4 py-2 rounded-xl focus:outline-none"
        placeholder="Email"
        v-model="user.email"
        />
    </div>

    <div class="mb-5">
      <input
        type="text"
        class="w-full mt-2 px-4 py-2 rounded-xl focus:outline-none"
        placeholder="用户名"
        v-model="user.username"
        />
    </div>

    <div class="mb-5">
      <input
        type="password"
        class="w-full mt-2 px-4 py-2 rounded-xl focus:outline-none"
        placeholder="密码"
        v-model="user.password"
        />
    </div>

    <div class="mb-5">
      <input
        type="password"
        class="w-full mt-2 px-4 py-2 rounded-xl focus:outline-none"
        placeholder="重复密码"
        v-model="user.repassword"
        />
    </div>

    <div ref="errMsg" class="py-2 text-sm text-white"></div>

    <div class="flex gap-5">
      <button
        @click="signup"
        class="px-5 py-3 rounded-xl text-white bg-teal-800 hover:bg-teal-700"
        >
        注册
      </button>

      <button
        @click="toLogin"
        class="px-5 py-3 rounded-xl text-white bg-teal-800 hover:bg-teal-700"
        >
        已有账号?去登录
      </button>
    </div>
  </div>
</template>

<script setup>
  import { reactive, ref } from 'vue'
  import { useRouter } from 'vue-router'
  import useAxios from '../composables/useAxios'

  const router = useRouter()
  const axios = useAxios()

  const user = reactive({
    email: '',
    username: '',
    password: '',
    repassword: '',
  })

  const errMsg = ref(null)

  async function signup() {
    if (user.repassword !== user.password) {
      errMsg.value.innerHTML = '两次密码不一致,请重新输入!'
      errMsg.value.classList.add(
        'mb-5',
        'text-center',
        'bg-red-500',
        'rounded-lg'
      )
      return
    } else if (
      user.email.length == 0 ||
      user.username.length == 0 ||
      user.password.length == 0 ||
      user.repassword.length == 0
    ) {
      errMsg.value.innerHTML = '表单未填写完整!'
      errMsg.value.classList.add(
        'mb-5',
        'text-center',
        'bg-red-500',
        'rounded-lg'
      )
      return
    } else {
      errMsg.value.innerHTML = ''
      errMsg.value.classList.add('hidden')
    }

    const userForm = new FormData()
    userForm.append('email', user.email)
    userForm.append('username', user.username)
    userForm.append('password', user.password)

    await axios.post('auth/signup/', userForm).then((res) => {
      const error = res.errors && res.errors.length > 0 ? res.errors[0] : null
      if (error) {
        errMsg.value.innerHTML = '该邮箱已被使用!'
        errMsg.value.classList.remove('hidden')
        errMsg.value.classList.add(
          'mb-5',
          'text-center',
          'bg-red-500',
          'rounded-lg'
        )
        return
      } else {
        router.push({ name: 'login' })
      }
    })
  }

  function toLogin() {
    router.push({ name: 'login' })
  }
</script>

这里主要说说JS部分。这里通过调用errMsg元素做了一个简单的表单验证,给该元素在不同情况下显示出不同的状态。然后通过使用封装好的axios发起请求,根据读取请求的返回值判断并执行请求成功或失败后的相关逻辑。成功就跳转登录,失败就返回错误并显示在页面上。

实现登录功能

登录功能与注册功能相似,在LoginView.vue中编写以下代码:

<template>
	<div class="text-center p-10 lg:p-20">
		<h1 class="text-3xl text-white lg:text-6xl">登录</h1>
	</div>

	<div class="px-4 mx-auto lg:w-1/4">
		<div class="mb-5">
			<input
				type="text"
				class="w-full mt-2 px-4 py-2 rounded-xl focus:outline-none"
				placeholder="Email"
				v-model="user.email"
			/>
		</div>

		<div class="mb-5">
			<input
				type="password"
				class="w-full mt-2 px-4 py-2 rounded-xl focus:outline-none"
				placeholder="密码"
				v-model="user.password"
			/>
		</div>

		<div class="flex gap-5">
			<button
				@click="login"
				class="px-5 py-3 rounded-xl text-white bg-teal-800 hover:bg-teal-700"
			>
				登录
			</button>

			<button
				@click="toRegister"
				class="px-5 py-3 rounded-xl text-white bg-teal-800 hover:bg-teal-700"
			>
				没有账号?去注册
			</button>
		</div>
	</div>
</template>

<script setup>
import { reactive } from 'vue'
import { useRouter } from 'vue-router'
import useAxios from '../composables/useAxios'

const router = useRouter()
const axios = useAxios()

const user = reactive({
	email: '',
	password: '',
})

function toRegister() {
	router.push({ name: 'signup' })
}

async function login() {
	const userForm = new FormData()
	userForm.append('email', user.email)
	userForm.append('password', user.password)
	await axios.post('auth/login/', userForm).then((res) => {
		console.log('res :>> ', res)
		localStorage.setItem('user.token', res.user.token)
		router.push({ name: 'home' })
	})
}
</script>

这里主要是通过设置浏览器存储的方式记录来确定登录状态,这种方式并不是很好,后面我们会使用pinia来对用户状态进行缓存。

实现登出功能

实现登出功能也非常简单,由于后面要通过pinia来控制缓存,这里先把使用本地存储进行登出的代码先贴出来供大家参考:

function logout() {
	localStorage.setItem('user.token', '')
	router.push({ name: 'home' })
}

pinia的使用

设置全局状态

由于在创建vue项目的时候已经安装好pinia了,现在我们直接开始设置用户的状态:

import { defineStore } from 'pinia'

export const useUserStore = defineStore({
	id: 'user',
	state: () => ({
		user: {
			isAuthenticated: false,
			id: null,
			name: null,
			email: null,
			token: null,
		},
	}),
	actions: {
		initStore() {
			console.log('initStore')
			const token = localStorage.getItem('user.token')
			if (token) {
				this.user.token = localStorage.getItem('user.token')
				this.user.email = localStorage.getItem('user.email')
				this.user.id = localStorage.getItem('user.id')
				this.user.name = localStorage.getItem('user.name')
				this.user.isAuthenticated = true
				console.log('用户' + this.user.name + '登录成功')
			}
		},

		setToken(data) {
			console.log('setToken', data)
			this.user.token = data.token
			this.user.email = data.email
			this.user.name = data.name
			this.user.id = data.user_id
			this.user.isAuthenticated = true
      
			localStorage.setItem('user.token', data.token)
			localStorage.setItem('user.email', data.email)
			localStorage.setItem('user.name', data.name)
			localStorage.setItem('user.id', data.user_id)
			console.log('用户的token已设置:' + localStorage.getItem('user.token'))
		},

		removeToken(user) {
			console.log('removeToken')
			this.user.token = null
			this.user.email = null
			this.user.name = null
			this.user.id = null
			this.user.isAuthenticated = null
      
			localStorage.setItem('user.token', '')
			localStorage.setItem('user.email', '')
			localStorage.setItem('user.name', '')
			localStorage.setItem('user.id', '')
		},
	},
})

代码比较清晰,理解起来不难。我直接简述一下这个文件的内容干了些啥:

states:存放当前用户状态,有以下字段:

  • user.id,用户的ID
  • user.email,用户的账号(邮箱)
  • user.username,用户的昵称
  • user.token,用户的验证令牌
  • user.isAuthenticated,判断用户是否处于登录状态

actions:定义了一些方法用于操作用户的状态,有以下方法:

  • initStore:初始化状态,确认用户是否登录
  • setToken:用户登录的时候执行,添加用户相关信息的同时设置用户为在线状态
  • removeToken:用户登出的时候执行,移除用户相关信息的同时设置用户为离线状态

在组件内使用

LoginView中的登录函数

async function login() {
	const userForm = new FormData()
	userForm.append('email', user.email)
	userForm.append('password', user.password)

	await axios.post('auth/login/', userForm).then((res) => {
		console.log('res :>> ', res)
             // 使用pinia设置全局状态
		userStore.setToken(res.user)
		router.push({ name: 'home' })
	})
}

Navbar中的导航切换

通过user.isAuthenticated的值完成导航栏的切换。

<template>
	<nav class="flex items-center justify-between px-4 py-6 bg-teal-800">
		<div>
			<router-link to="/" class="text-xl text-white">Django-Chat</router-link>
		</div>

		<div
			v-if="!userStore.user.isAuthenticated"
			class="flex items-center space-x-4"
		>
			<router-link to="/login" class="text-white hover:text-gray-200"
				>登录</router-link
			>
			<router-link
				to="/signup"
				class="px-5 py-3 rounded-xl text-white bg-teal-600 hover:bg-teal-700"
				>注册</router-link
			>
		</div>

		<div v-else class="flex items-center space-x-4">
			<router-link to="/rooms" class="text-white hover:text-gray-200"
				>房间</router-link
			>
			<button
				@click="logout"
				class="px-5 py-3 rounded-xl text-white bg-teal-600 hover:bg-teal-700"
			>
				登出
			</button>
		</div>
	</nav>
</template>

<script setup>
import { useRouter } from 'vue-router'
import { useUserStore } from '../stores/user'

const router = useRouter()
const userStore = useUserStore()

function logout() {
	userStore.removeToken()
	router.push({ name: 'home' })
}
</script>

App.vue的全局状态

使用initStore管理Vue应用的全局状态,它可以发挥以下作用:

  • 初始化用户数据
  • 保持用户登录状态
  • 避免频繁登录
  • ...

下面看看pinia在App.vue的应用

<script setup>
import Navbar from './components/Navbar.vue'
import { useUserStore } from './stores/user'

const userStore = useUserStore()

userStore.initStore()
</script>

<template>
	<Navbar />
	<RouterView />
</template>

以上配置完成之后,我们现在的应用程序已经可以实现前后端交互了!

写在最后

下一部分将是本教程的重点内容:channels的使用,它是如何处理并实现即时通信的?它在与websocket的碰撞中又会擦出什么样的火花?各位看官别走开,后面的教程为您揭晓!

本章节的代码:github