头像

yume

$\href{https://codeforces.com/profile/YumeMinami}{Codeforces}$




在线 


最近来访(230)
用户头像
Ascension
用户头像
CLYANALI
用户头像
幻想乡的十六夜咲夜
用户头像
tornadoH2O
用户头像
Egbert-Lannister.
用户头像
FarewellTears
用户头像
nQmyiz
用户头像
今天要干亿碗饭
用户头像
sea的sky
用户头像
Dos.
用户头像
Ioq
用户头像
Ref
用户头像
sep
用户头像
laser
用户头像
绊缘
用户头像
yxc的小迷妹
用户头像
NikoNairre
用户头像
时间的痕迹_5
用户头像
随便玩玩
用户头像
倚窗听雨_2

活动打卡代码 工程课 Web-6.2. 课上代码

yume
22小时前

Vue3 项目

配置环境

Vue官网

安装Nodejs

Nodejs

安装LTS版本即可

安装@vue/cli脚手架

打开终端 执行:npm i -g @vue/cli

如果执行后面的操作有bug,可能是最新版有问题,可以尝试安装早期版本,比如:npm i -g @vue/cli@4

启动vue自带的图形化项目管理界面

powershellCMD上运行:vue ui

常见问题1:Windows上运行vue,提示无法加载文件,表示用户权限不足。
解决方案:用管理员身份打开终端,输入set-ExecutionPolicy RemoteSigned,然后输入y

安装问题杂谈

一些参考blog:

安装vue与启动项目

#安装nodejs  安装完毕后,用管理员启动cmd
#1.检查
node -v
npm -v
#2.创建两个目录:
D:\nodejs\nodejs\node_cache
D:\nodejs\nodejs\node_global
#3.设置global cache路径
npm config set prefix "D:\nodejs\nodejs\node_global"
npm config set cache "D:\nodejs\nodejs\node_cache"
#4.修改用户的环境变量
Path中->D:\nodejs\nodejs\node_global
#5.修改系统变量
添加:NODE_PATH  D:\nodejs\nodejs\node_global\node_modules
添加:Path  D:\nodejs\nodejs\node_global
           D:\nodejs\nodejs
#6.测试安装
npm install express -g
#7.修改镜像
npm config get registry
npm config set registry https://registry.npm.taobao.org/
npm config get registry
npm install -g cnpm --registry=https://registry.npm.taobao.org
#8.安装vue
cnpm install -g @vue/cli
#9.启动
vue ui

# nodejs 安装目录是 
D:\nodejs\nodejs
# nodejs版本是最新的 
node-v18.12.0-x64.msi
# 详细解释 参考地址
https://blog.csdn.net/qq_48485223/article/details/122709354

卸载vue:

npm uninstall -g vue
npm uninstall -g vue-cli
npm uninstall -g @vue-cli

基本概念

基本结构

views文件夹一般用来写各种页面的

router文件夹一般用来存放网页路由

默认的路由使用createWebHashHistory,这会使我们网页的url存在#,若将#删除,需把其改成createWebHistory

vue const router = createRouter({ history: createWebHistory(), routes })

components文件夹一般是存各种组件的,和views文件夹差不多,可以根据个人习惯来存放。

整个vue项目的入口在main.js文件里

createApp(App).use(store).use(router).mount('#app')

这句话的意思是新创建一个App,其根组件为App.vue,使用依赖store(之前安装的vuex)、router(之前安装的router),并挂载渲染到#app上,这个#app表示在/public/index.htmlid="app"div标签,即该项目存在一个网页文件index.html,后面写的组件都是打包挂载到这个文件上面对应的<div id="app"></div>里的。

前端渲染

vue框架和react一样也是通过前端渲染的。

前端渲染与后端渲染的区别:

后端渲染:在客户端每打开一个页面、链接,都会向服务器发送请求,服务器将所用到的页面返回给客户端。
前端渲染;只有在客户端第一次打开页面(无论是什么页面),客户端才会向服务器端发送请求,服务器端将页面所需要的元素全部返回给客户端,同时打包在js文件中,当打开第二个或第三个等页面后,就不需要向服务器发送请求了,直接用返回的js文件将新页面渲染出来。

熟悉vue

.vue里同时包含了htmlcssjs分别对应templatestylescript

<style scoped><style>表示当前页面的css样式只会影响自己,而不会影响其他页面。

我们写的网页一般都是由不同的模块组装而成的,因此我们可以将某些模块单独写成组件,这样就可以实现组件的复用。可以通过以下写法将当前文件的组件模块导出

<script>
    export default {
        name: '组件名',如'HelloWorld'
        props: { //该组件需要传递的信息
            键 : 值
            msg: String
        }
    }
<script>

通过一下写法,将组件挂载到对应的页面:

<HelloWorld msg="Welcome to your vue!">

项目实现

页面设计

我们用vue实现网站时,一般是从上往下设计的,将页面的各个部分拆分成不同的组件,若组件中的元素过多,也可以继续拆分成不同的组件。

设计图如下:

vue页面.png

App.vue

App.vue是我们项目的根组件。我们需要用到的依赖(如bootstrap)、需要挂载的组件都是需要引用到这里。

我们先来看看项目初建的App.vue长什么样:

<router-view/> 表示会根据我们的网页网址不同展示不同的网页。

<template>
 <nav>
     <router-link to="/">Home</router-link>
     <router-link to="/about">About</router-link>
 </nav>
 <router-view/>
</template>

<script>
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

nav {
  padding: 30px;
}

nav a {
  font-weight: bold;
  color: #2c3e50;
}

nav a.router-link-exact-active {
  color: #42b983;
}
</style>

这个原装的导航栏有点丑,我们可以把它删掉,自己实现一个新的。

导航栏

我们利用bootstrap来制作我们的导航栏,首先需要在App.vue中引入bootstrap文件。

<template>
  <nav-bar></nav-bar>
  <router-view :key="$route.fullPath"></router-view>
</template>

<script>
import 'bootstrap/dist/css/bootstrap.css';
import 'bootstrap/dist/js/bootstrap';
import navBar from './components/navBar.vue';

export default {
  name: "App",
  components: {
    navBar,
  }
}

</script>

<style>

然后编写导航栏组件:

/components/navbar.vue

<template>
  <nav class="navbar navbar-expand-lg navbar-light bg-light">
    <div class="container">
      <router-link class="navbar-brand"
        :to="{ name: 'home' }">Myspace</router-link>
      <button class="navbar-toggler" type="button" data-bs-toggle="collapse"
        data-bs-target="#navbarText" aria-controls="navbarText"
        aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>
      <div class="collapse navbar-collapse" id="navbarText">
        <ul class="navbar-nav me-auto mb-2 mb-lg-0">
          <li class="nav-item">
            <router-link class="nav-link active"
              :to="{ name: 'home' }">Home</router-link>
          </li>
          <li class="nav-item">
            <router-link class="nav-link"
              :to="{ name: 'userlist' }">Friends</router-link>
          </li>
          <li class="nav-item">
            <router-link class="nav-link"
              :to="{ name: 'userprofile', params: { ID: 2 } }">Activity</router-link>
          </li>

        </ul>

        <ul class="navbar-nav" v-if="!$store.state.user.is_login">
          <li class="nav-item">
            <router-link class="nav-link" :to="{ name: 'login' }">Sign
              in</router-link>
          </li>
          <li class="nav-item">
            <router-link class="nav-link" :to="{ name: 'register' }">Sign
              up</router-link>
          </li>
        </ul>

        <ul class="navbar-nav" v-else>
          <li class="nav-item">
            <router-link class="nav-link"
              :to="{ name: 'userprofile', params: { ID: $store.state.user.id } }">
              {{ $store.state.user.username }}</router-link>
          </li>
          <li class="nav-item">
            <a class="nav-link" @click="logout" style="cursor: pointer">Sign
              out</a>
          </li>
        </ul>

      </div>
    </div>
  </nav>
</template>

<script>
import { useStore } from 'vuex';

export default {
  name: "navBar",
  setup() {
    const store = useStore();
    const logout = () => {
      store.dispatch("logout");
    };

    return {
      logout,
    }
  }
}
</script>

<style scoped></style>

导航栏的前端渲染

 <router-link class="navbar-brand"
        :to="{ name: 'home' }">Myspace</router-link>

Vue里如果想给某个标签绑定属性要用: + 属性名称:v-bind:的缩写。

举个例子:

vue <UserProfileInfo :user="user"/>

这里加了:后双引号里面的字符串就不是一个普通的字符串,而是一个表达式,可以传一个表达式,表示对双引号里面的字符串内容取值。

由于每个页面都需要导航栏,因此要把导航栏组件放到根组件App.vue里面。

当我们在当前组件里需要用到其他的组件时,要把这个组件放在当前组件的export default{}中的components里,components属性是一个对象,要定义keyvalue,其中value是引入的组件名,是它export default中定义的namekey是在当前组件中自己定义的外部组件的别名。当keyvalue同名时,可以统一简写成一个名字即key=value;若不同名时,要规范地写成key: value,这样在当前组件中就可以用key来引用外部的组件了。

如下:

<template>
    <navBar/>
</template>
<script>
import 'bootstrap/dist/css/bootstrap.css';
import 'bootstrap/dist/js/bootstrap';
import navBar from './components/navBar.vue';

export default {
  name: "App",
  components: {
    navBar,
  }
}

</script>

ContentBase组件

利用bootstrap中的card组件将页面中的内容框起来

<div class="card">
    <div class="card-body">

    </div>
</div>

利用bootstrap中的container组件,响应式地调整中间内容区域的大小。

<div class="container">
    <div class="card">
        <div class="card-body">

        </div>
    </div>
</div>

由于不同组件的布局方式类似,即存在公共部分,因此,我们可以将上面的写的cardcontainer布局写成一个ContentBase组件,引入到各个组件页面中去。这样做的好处是,当我们想修改布局或者添加一些公共元素的时候,可以直接修改这个ContentBase基类组件:

components/ContentBase.vue

<template>
  <div class="home">
    <div class="container">
      <div class="card">
        <div class="card-body">
          <slot></slot> 渲染子元素
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: "ContentBase",
}
</script>

<style scoped>
.container { 
  margin-top: 20px;
}
</style>

在其他页面中引入此组件,以HomeView为例:

<template>

  <ContentBase>

    <img src="../assets/2.jpg" class="card-img-top" alt="..." >
    <div class="card-body">
      <h5 class="card-title">Lucky day</h5>
      <p class="card-text">have a fun</p>
      <a href="#" class="btn btn-primary">Go somewhere</a>
    </div>

  </ContentBase>

</template>

<script>
import ContentBase from '../components/ContentBase';
export default {
  name: 'HomeView',
  components: {
    ContentBase
  }
}
</script>

<style scoped>
</style>

这里有一个知识点:如何在ContentBase中获取到HomeView中以<ContentBase><ContentBase>包裹着的内容。

HomeView是父组件,ContentBase是其子组件;

ContentBase负责渲染外部样式,定义布局效果,而其内部的内容由父组件HomeView提供。

这里需要用到插槽<slot></slot>在子组件ContentBase中渲染父组件HomeView的内容。

关于插槽内容插槽出口,用例子来展示:

HomeView.vue

<ContentBase>
    这里的是插槽内容
<ContentBase>

ContentBase.vue

<template>
  <div class="home">
    <div class="container">
      <div class="card">
        <div class="card-body">
          <slot></slot> 插槽出口
        </div>
      </div>
    </div>
  </div>
</template>

<slot> 元素是一个插槽出口 (slot outlet),标示了父元素提供的插槽内容 (slot content) 将在哪里被渲染。

通过使用插槽,<ContentBase> 组件更加灵活和具有可复用性。现在组件可以用在不同的地方渲染各异的内容,但同时还保证都具有相同的样式。

React中是用this.props.children[]在子组件中获取父组件中的子节点内容的,与Vue的插槽<slot>效果类似。

更多用法可以参考官方文档插槽slot

其他的页面也类似滴引入ContentBase组件。

创建路由

/router/index.js

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import UserList from '../views/UserList.vue'
import UserProfile from '../views/UserProfile.vue'
import LoginView from '../views/LoginView.vue'
import RegisterView from '../views/RegisterView.vue'
import NotFoundView from '../views/NotFoundView.vue'


const routes = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/userlist/',
    name: 'userlist',
    component: UserList
  },
  {
    path: '/userprofile/:ID/',
    name: 'userprofile',
    component: UserProfile
  },
  {
    path: '/login/',
    name: 'login',
    component: LoginView
  },
  {
    path: '/register/',
    name: 'register',
    component: RegisterView
  },
  {
    path: '/404/',
    name: '404',
    component: NotFoundView
  },
  { 
    path: '/:catchAll(.*)',
    redirect: "/404/" //重定向
  }

]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

实现用户动态页面

该页面的主要功能为展示当前用户信息、展示当前用户发过的帖子、动态发帖。

因此该页面可以由三个组件实现:

  1. UserProfileinfo:展示个人信息
  2. UserProfilePost:展示帖子
  3. UserProfileWrite:发帖

页面布局

可以用bootstrapGrid System

vue_userprofile布局.png

先展示一下大致的样子:

userprofile.png

/views/UserProfile.vue

<template>
    <div class="card">
        <div class="card-body">
            <div class="row">
                <div class="col-5">
                    <img :src="user.photo" class="img-fluid" alt="...">
                </div>
                <div class="col-9">
                    <div class="Username">{{ user.Username }}</div>
                    <div class="fans">followers:{{ user.Followers }}</div>
                    <button @click="follow" v-if="!user.is_followed" type="button"
                        class="btn btn-primary btn-sm">Follow</button>
                    <button @click="unfollow" v-if="user.is_followed" type="button"
                        class="btn btn-primary btn-sm">Unfollow</button>

                </div>

            </div>

        </div>
    </div>

</template>

组件中定义数据变量

我们需要在url上添加参数来区别当前的动态页面是哪个用户的动态页面,可以用userid来充当url的参数。

因此这三个组件都需要进行数据的交互,我们可以把用户数据存放在这三个组件的根组件UserProfile.vue中。

我们在组件的setup函数里定义变量存储数据:

setup函数可以代替之前的data,methods,computed,watch,Mounted等对象,但是props声明还是在外面

  • setup(props, context):初始化变量、函数
  • ref定义变量,可以用.value属性重新赋值
  • reactive定义对象,不可重新赋值
  • props存储父组件传递过来的数据
  • context.emit():触发父组件绑定的函数

关于refreactive:

Vue3中实现响应式数据的方法是ref和reactive

reactive:

  • reactive的参数一般是对象或者数组,他能够将复杂数据类型变为响应式数据。
  • reactive的响应式是深层次的,底层本质是将传入的数据转换为Proxy对象
  • reactive() 不可重新赋值(会丢失响应性),只能通过.属性名=xx修改对应的属性。

ref:

  • ref的参数一般是基本数据类型,也可以是对象类型;ref的本质还是react,如ref(1) ===> reactive({value:1})

  • 如果参数是对象类型,其实底层的本质还是reactive,系统会自动将ref转换为reactive

  • 在模板(template)中访问ref中的数据,系统会自动帮我们添加.value,在JS中访问ref中的数据,需要手动添加.value

  • ref的底层原理同reactive一样,都是Proxy

template中引用ref

```vue
[HTML_REMOVED]
import { ref } from ‘vue’

const ref1 = ref(0)

[HTML_REMOVED]

[HTML_REMOVED]
[HTML_REMOVED]{{ ref1 }}[HTML_REMOVED]
[HTML_REMOVED]
```

reactive vs ref:

  • reactive参数一般接受对象或数组(一般情况下都是对象),是深层次的响应式。ref参数一般接收简单数据类型,若直接定义a = reactive([]),则只能通过a.push(...[1,2,3])的形式重新赋值,不能直接通过=赋值,或者直接改成ref;若ref接收对象为参数,本质上会转变为reactive方法
  • 在JS中访问ref的值需要手动添加.value,访问reactive不需要
  • 响应式的底层原理都是Proxy
  • ref开销reactive大

总结:

  1. 如果你需要一个响应式原始值,那么使用ref()是正确的选择,要注意是原始值
  2. 如果你需要一个响应式对象,层级不深,那么使用ref也可以
  3. 如果您需要一个响应式可变对象,并且对象层级较深,需要深度跟踪,那么使用reactive

你可以把 reactive 看成ref的子集,ref可以解决一切烦恼,

钻牛角尖必用

vue3实战-完全掌握ref、reactive

在不同组件里传递信息

父组件向子组件传递信息是通过props属性

子组件向父组件传递信息是通过绑定事件传递的

子组件里接受父组件传过来的参数,要在export default{}中添加props属性。

如子组件UserProfileinfo.vue中接收其父组件UserProfile.vue传递过来的参数:

UserProfile.vue传递参数user

 <ContentBase>
        <div class="row">
            <div class="col-3">
                <UserProfileInfo  :user="user" />

            </div>
            <div class="col-9">
                <UserProfilePost >
                </UserProfilePosts>
            </div>
        </div>
    </ContentBase>

定义uservue_userprifile_use.png

UerProfileinfo.vue:子组件中接收参数

vue子组件接收参数.png

然后就可以在UserProfileInfo中愉快地用父组件传来的user

利用computed()函数动态计算值

引用:import {computed} from 'vue'

computed.png

setup()中是没有this的,所以必须传入props调用父组件传来的数据。

实现关注按钮

需要用到v-if,关注与不关注显示的按钮不同。

template 属性
  • <slot></slot>:存放父组件传过来的children
  • v-on:click`@click属性:绑定点击事件
  • v-on:函数名@函数名: 绑定对应的函数事件
  • v-if、v-else、v-else-if属性:判断
  • v-for属性:循环,`:key循环的每个元素需要有唯一的key
  • v-bind:绑定属性
子组件触发父组件的函数

父组件中定义的数据是不能直接在子组件里进行修改的,只能通过绑定函数事件的方式,在子组件触发父组件中的函数,在父组件相应的函数中进行数据的修改。

利用context.emit(函数名,参数)可以触发父组件的函数。

这里的函数名指的是父组件中@后面跟着的函数名,在父组件中这个@函数名的函数名并不一定是跟父组件中实现函数的函数名相同,只不过在子组件中通过emit触发时,要对应这个@函数名。举个例子:

在父组件UserProfile中绑定函数:

emit1.png

@follow123不一定要与follow同名,follow为该组件中在setup()中实现的一个函数。

而在子组件触发时一定与@后的函数对应:

emit2.png

基本逻辑如下:当在子组件中触发context.emit('follow123')时,会在父组件中寻找@follow123,并且执行父组件中@follow123对应的函数follow,达到更新数据的目的。

实现UserProfilePost

这里都是先写死数据,后面再调成从后端获取数据:

首先在父组件中定义posts帖子列表:

vue_posts.png

传入参数给UserProfilePosts组件:

<UserProfilePosts :posts="posts" />

UserProfileInfo一样,在UserProfilePosts中要定义props将父组件传来的参数获取;

export default {
    ...
    props: {
        posts: {
            type: Object,
            required: true, //表示必须传
        }
    }
}

template中循环渲染列表:

v-if.png

注意:在v-if循环中要加上一个key,这个key需要保证是唯一的

也可以改写成取下标的形式:

<div v-for="(post,index) in posts.posts" :key="index" >
    ...
</div>

如果数组不变的话可以用下标,变的话不建议用下标。

实现UserProfileWrite

这里当懒狗了,没有用到新的知识,直接贴代码吧

<template>
    <div class="card edit-field">
        <div class="card-body">
            <div class="mb-3">
                <label for="EditPost" class="form-label">Write Your Post</label>
                <textarea v-model="content" class="form-control" id="EditPost" rows="3"></textarea>
                <button @click="postApost" type="button" class="btn btn-success">Send</button>
            </div>

        </div>
    </div>

</template>

<script>

export default {
    name: "UserProfileWrite",
    setup() {
        let content = ref('');
        const postApost = () => {
            console.log(content.value);
            content.value = "";
        }
        return {
            content // 这里其实是content:content的缩写
        }
    }
}

</script>

<style scoped>
.edit-field {
    margin-top: 20px;
}

button {
    margin-top: 10px;
}
</style>

vue里获取textarea的信息: v-model双向绑定

<textarea v-model="content" class="form-control" id="EditPost" rows="3"></textarea>
//将textarea里的信息双向绑定到content里

将帖子发到页面右边,需要用到子组件向父组件传递信息,即要触发父组件的函数。

在父组件中定义:

...
<template>
    ...
      <UserProfileWrite @postApost="postApost" />
    </div>
</template>

...
<script>
    ...
    const postApost =(content) => {
    posts.count ++;
    posts.posts.unshift({
        id: posts.count,
        userId: 1,
        content: content
    }) //在数组最前面加元素
}
...
return {
    postApost,
}
</script>

UserProfileWrite.vue

...
<script>

export default {
    name: "UserProfileWrite",
    setup(props,context) {
        let content = ref('');
        const postApost = () => {
            context.emit("postApost",content.value);
            content.value = "";
        }
        return {
            content // 这里其实是content:content的缩写
        }
    }
}

</script>
...

实现登录页面

登录页面就是我自己的祖传模板了懒得重新设计,所以在这里只是记录一下登录的逻辑。

首先要用两个双向绑定(v-model)的响应式变量记录用户名与密码,还要有一个变量记录报错信息:

  let username = ref('');
  let password = ref('');
  let error_message = ref('');

编写login函数

    const login = () => {
      error_message.value = "";
      store.dispatch("login", {
        username: username.value,
        password: password.value,
        success() {
          router.push({ name: 'userlist' });
        },
        error() {
          error_message.value = "Password or Username error!";
        }
      });
    };

阻止表单的默认提交事件:.prevent

<form @submit.prevent="login"> </form>

由于访问很多页面都需要用到用户的信息,因此用户的信息需要放到一个全局变量里,使得所有页面都能访问得到。一般来说子组件给父组件传递信息是用emit触发函数的形式,父组件给子组件传递信息是通过props来传递,子组件可以利用props来调用父组件的数据。这种传递方式是仅限于父子的,若是想给兄弟结点传递信息,则需要先上传到它们的LCA再下传到对应的兄弟结点,这样做太麻烦了,我们这里引入全新的定义:vuex.

Vuex

vuex负责维护全局的变量,类似于react中的redux

vuex会在全局维护一个对象,这个对象是一个状态树(state),我们所有的信息都会存储在这个状态树里(只会存一份)。这样的话,我们所有页面结点都只需要和这个状态树进行数据交互即可,就不需要先传给LCA再往下传递了。

由于当前的用户信息在所有页面都用得到,所以我们将用户信息存储到vuex里。

基本概念
  • vuex:存储全局状态,全局唯一
  • state: 存储所有数据,可以用modules属性划分成若干模块。
  • getters:根据state中的值计算新的值,只能通过编写函数的方式返回计算后的新的state中的值,不能直接修改state的值。
  • mutations:所有对state的修改操作都需要定义在这里,不支持异步,可以通过$store.commit()触发。举个例子,当我们需要从云端获取数据信息,获取后再进行更新数据信息即修改state时,是不能在mutations中修改的,因为从云端获取数据信息的过程是异步的。
  • actions:定义对state的复杂修改操作,支持异步,可以通过$store.dispatch()触发。注意不能直接修改state,只能通过mutations修改state。这里可以简单理解为:若只需要简单的修改state,如state.username = "xxx"这种直接赋值的话就可以放在mutations里修改,若需要进行复杂操作,包括从云端获取数据信息等异步操作,就需要放在actions里。
  • modules:定义state的子模块,对state进行分割。每个modules里的对象实际上都是维护state里的一个对象,同时具有getters、mutations、actions属性。
vuex实践

在vue的可视化界面装完vuex插件后,我们会在项目文件下得到一个sotre文件夹,里面的index.js存放的就是vuex创建的全局唯一的对象。

vuex.png

上面这些模块本质上是一个字典,只不过当字典里面套字典(对象),且值为函数体时,字典里的键可以省略简写,直接写函数体就好了:

```js
actions: {
login: () => {

}
}
可简写成
actions: {
login() => {

}
}
```

actions里的函数有两个传入参数:1. contextcontext里存在着一些API方法,如context.commit("函数名",需要传入的参数)可以调用mutations中对应的函数;2. 自己传的data参数,自定义传入一些需要用到的数据。

modules中定义一个user模块,并将其放到/store/index.jsmodules

这个user模块和原始的state模型本质上是一样的,只不过将有关“用户信息”的这些数据抽象出一个新的模块,然后再引入进/store/index.js主文件中。

/store/user.js

import $ from "jquery";
import jwt_decode from "jwt-decode";
const ModuleUser = {
  state: {
    id: "",
    username: "",
    photo: "",
    followerCount: 0,
    access: "",
    refresh: "",
    is_login: false,
  },
  getters: {},
  mutations: {
  },
  actions: {
  modules: {},
};

export default ModuleUser;

/store/index.js

import { createStore } from 'vuex'
import ModuleUser from './user'

export default createStore({
  state: {
  },
  getters: {
  },
  mutations: {
  }, 
  actions: {
  },
  modules: {
    user: ModuleUser,
  }
})

这样就使得我们的代码更加简洁可观了,不用全都缩在一个文件里。

以后访问user模块中的数据只需要通过store.state.user.属性名访问。

特别注意:在template中访问store是用$store

JWT验证登录

JWT(json web token)是一种比较新的登录验证方式,与传统登录方式不同,这种方式支持跨域访问,具体的话可以参考我之前写的文章,这里就不再阐述了。 简单来说就是客户端输入用户名和密码,点击登录就会向服务端发送请求,服务端验证身份后会返回一个jwt token (可以理解为由用户信息与私钥进行加密后得到的字符串 + 用户信息拼接成的新字符串)给客户端,客户端可以将其存在浏览器的local Storage里,以后每次访问需要验证身份的页面,客户端都需要携带token向服务端发送请求,服务端是不会存储token的(这点与传统的传递seesion_id登陆方式不同,传统的方式是将用户信息与session存到服务端的数据库里,jwt方式是不用将token存到数据库里的,会将用户信息封装在这个token里,只会将私钥存储到数据库里,方便对用户传回来的信息进行加密验证),但是会对这个token用加密算法进行验证,若验证成功则返回信息,若不成功则不返回。

简单逻辑就是:用户在客户端输入用户账号与密码,向服务端申请jwt token,服务端会向客户端返回一个accessrefresh字符串。access就是我们上面所说的用来验证用户身份信息的token,也叫做令牌,一般有效期比较短;而refresh是用来刷新access的有效期的,其本身的有效期比较长。当access过期后需要利用refresh调用新的刷新令牌API来获取新的access令牌。为什么要这样设计呢?因为有些API调用用的是GET方法,GET方法是会直接将用户信息作为参数放到url上的,这种方法会将数据全部暴露出来,不太安全,可能会被中间的一些恶意用户直接从url上截取了你的令牌access,所以access的有效期一般要设计得比较短。

refresh获取新的令牌的API使用POST方法,POST方法里的参数是存在HTTP body里面的,不会暴露在url里,一般来说更加安全,有效期更长。用refresh刷新access令牌的方式更能保证用户信息的安全。

access过期后就代表登出状态了。

获取token

/store/user.js

import $ from "jquery";
import jwt_decode from "jwt-decode";
const ModuleUser = {
  state: {
    id: "",
    username: "",
    photo: "",
    followerCount: 0,
    access: "",
    refresh: "",
    is_login: false,
  },
  getters: {},
  mutations: {
  },
  actions: {
    login(context, data) {
      $.ajax({
        url: "https://app165.acapp.acwing.com.cn/api/token/",
        type: "POST",
        data: {
          username: data.username,
          password: data.password,
        },
        success(resp) {
          const { access, refresh } = resp;
          const access_obj = jwt_decode(access);
        },
        error() {
          data.error();
        },
      });
    },
  },
  modules: {},
};

export default ModuleUser;

views/LoginView.vue中调用上面的login函数:

使用store.dispatch("函数名",需要传入的参数)来调用actions里定义的函数

...
import { useStore } from 'vuex';
import router from '@/router';

export default {
  name: 'LoginView',
  components: {

  },

  setup() {
    const store = useStore();
    let username = ref('');
    let password = ref('');
    let error_message = ref('');

    const login = () => {
      error_message.value = "";
      store.dispatch("login", {
        username: username.value,
        password: password.value,
        success() {
          router.push({ name: 'userlist' }); //路由跳转
        },
        error() {
          error_message.value = "Password or Username error!";
        }
      });
    };

    const GotoRegister = () => {
      router.push({
        name: "register",
      });
    }


    return {
      username,
      password,
      error_message,
      login,
      GotoRegister,
    }
  }


}

获取某个用户的信息

想要获取某个用户的信息,则需要获取他的user_id,这个user_id是编码在上面获取的token里的,也就是access,我们需要将它解码出来。(注意,这里user_id是后端命名传给前端的)

安装解码包:在项目终端上输入:npm i jwt-decode

引入: import jwt_decode from "jwt-decode";

继续完善上面的login函数:

当获取token成功后,将其解码,获得user_id

然后就可以根据user_id去获取用户信息了

从后端获取用户信息后,要以此更新state中存储的用户信息,在mutations里更新(mutations可以直接用state.属性名 = xx 赋值 )

通过context.commit("函数名",需要传入的参数)可以调用mutations中定义的对应函数。

...
mutations: {
    updateUser(state, user) {
      state.id = user.id;
      state.username = user.username;
      state.photo = user.photo;
      state.followerCount = user.followerCount;
      state.access = user.access;
      state.refresh = user.refresh;
      state.is_login = user.is_login;
    },
    updateAccess(state, access) {
      state.access = access;
    },
    updateLogout(state, user) {
      state.id = user.id;
      state.username = user.username;
      state.photo = user.photo;
      state.followerCount = user.followerCount;
      state.access = user.access;
      state.refresh = user.refresh;
      state.is_login = user.is_login;
    },
  },

actions: {
    login(context, data) {
          // 获取token
          $.ajax({
            url: "https://app165.acapp.acwing.com.cn/api/token/",
            type: "POST",
            data: {
              username: data.username,
              password: data.password,
            },
            success(resp) {
              const { access, refresh } = resp;
              const access_obj = jwt_decode(access);

              setInterval(() => {
                $.ajax({
                  url: "https://app165.acapp.acwing.com.cn/api/token/refresh/",
                  type: "POST",
                  data: {
                    refresh,
                  },
                  success(resp) {
                    context.commit("updateAccess", resp.access);
                  },
                });
              }, 4.5 * 60 * 1000); // 利用refresh刷新access
               // 获取用户信息
              $.ajax({
                url: "https://app165.acapp.acwing.com.cn/myspace/getinfo/",
                type: "GET",
                data: {
                  user_id: access_obj.user_id,
                },
                headers: { //jwt 验证
                  Authorization: "Bearer " + access, //这里是后端人为定义的Authorization
                },
                success(resp) {
                  context.commit("updateUser", {
                    ...resp, //resp是后端传回来的用户信息,这里是将其解构
                    access: access,
                    refresh: refresh,
                    is_login: true,
                  });
                  data.success();
                },
              });
            },
            error() {
              data.error();
            },
          });
        },
      });
    },
}
利用refresh定期刷新access

有一种傻瓜方法是,写一个定期函数,每隔一段时间就用refresh刷新aaccess,这个间隔时间一般取略小于access的有效期,这里是5分钟有效期,因此设定每隔4.5分钟就刷新一次。上面的代码也已经实现了,这里将它单独抽出来

          setInterval(() => {
            $.ajax({
              url: "https://app165.acapp.acwing.com.cn/api/token/refresh/",
              type: "POST",
              data: {
                refresh, //key与value一样可以简写
              },
              success(resp) {
                context.commit("updateAccess", resp.access);
              },
            });
          }, 4.5 * 60 * 1000);

实现登出功能

登出功能十分简单,直接将token和用户信息一起清除掉就好了,相当于把通行证给撕了。。

由于可以直接修改state的值,因此将其写在mutations

/store/user.js

...
mutations {
    logout(state) {
        state.id = "";
        state.username = "";
        state.photo = "";
        state.followerCount = 0;
        state.access = "";
        state.refresh = "";
        state.is_login = false;

    }

}
...

直接在组件里调用mutations里的函数:

如果是调用mutations里的函数用commit()

调用actions里的函数用dispatch()

点击导航栏的Sign out按钮实现退出功能

/components/NavBar.vue

...
export default {
    name: "NavBar",
    setup() {
        const store = useStore();
        const logout = () => {
            store.commit("logout"); //在组件中直接调用mutations里的函数
        }
    }
}

实现用户列表页面

这里考虑从后端获取数据,就不写死数据了。

在后端获取数据需要用到jQuery中的ajax或者是axios,这里选择jQuery中的ajax.

首先安装jquery:

VScode的终端中输入:npm i jquery

然后在项目中引入import $ from 'jquery'

ajax中的同一个url可以对应不同的type方法

UserList.vue

<template>
  <ContentBase>
    <div class="card" v-for="user in users" :key="user.id"
      @click="OpenUserProfile(user.id)">
      <div class="card-body">
        <div class="row">
          <div class="col-1">
            <img :src="user.photo" class="img-fluid" alt="">
          </div>
          <div class="col-10">
            <div class="cc">
              <div class="username">{{ user.username }}</div>
              <div class="followers">
                <span class="text"> {{ user.followerCount }} </span> followers
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </ContentBase>
</template>

<script>
import ContentBase from '@/components/ContentBase'
import router from '@/router';
import $ from 'jquery';
import { ref } from 'vue';
import { useStore } from 'vuex';

export default {
  name: 'UserList',
  components: {
    ContentBase,
  },
  setup() {
    let users = ref([]);
    const store = useStore();

    $.ajax({
      //url: 'https://api.bilibili.com/x/relation/followers?vmid=2884629&pn=1&ps=20&order=desc&jsonp=jsonp',
      url: 'https://app165.acapp.acwing.com.cn/myspace/userlist/',
      type: "get",
      success(resp) {
        users.value = resp;
      }
    });

    const OpenUserProfile = userID => {
      if (store.state.user.is_login) {
        router.push({
          name: "userprofile",
          params: {
            ID: userID,
          }
        });
      }
      else {
        router.push({
          name: "login"
        });
      }
    }


    return {
      users,
      OpenUserProfile,
    }

  }
}
</script>

<style scoped>
img {
  border-radius: 50%;
}

.cc {
  float: left;
  height: 50%;
}

.username {
  font-weight: bold;

}

.text {
  font-weight: bold;
  color: black;
}

.followers {
  font-size: 15px;
  color: rgb(180, 162, 170);

}

.card {
  margin-bottom: 20px;
  cursor: pointer;
}

.card:hover {
  box-shadow: 2px 2px 10px lightgrey;
  transition: 500ms;
}
</style>

页面效果大致如下:

userlist.png

url添加参数

我们在用户列表中点开不同的用户理应展示不同用户对应的userprofile个人信息。因此要在url上添加id参数以引导去不同的对应id的用户个人信息页面。

如:http://localhost:8080/userprofile/18/

表示访问id=18的用户个人信息页面。

在路由上的userprofile添加参数:

表示通过ID来访问对应ID的用户的userprofile页面。

 {
    path: '/userprofile/:ID/',
    name: 'userprofile',
    component: UserProfile
  },

然后在UserProfile.vuie里引入userRoute

import {useRoute} from 'vue-router'
setup() {
    const route = useRoute();
    // route.params.参数 params后面可以获取我们添加到路由url后面的参数
    console.log(route.params.ID);
}

注意,要在NavBar.vue里对应的链接也加上参数params

 <router-link class="nav-link"
              :to="{ name: 'userprofile', params: { ID: 19 } }">Activity</router-link>

修改用户动态页面

添加只有登录后才能访问用户动态页面

template调用带参数的函数直接像正常函数调用即可,如下面的@click='OpenUserProfile(user.id)',相当于React中的通过匿名函数调用相应的带参数函数,即前面等效于

@click='() => {OpenUserProfile(user.id)}',vue在底层已经帮我们做好了很多轮子了,因此不用写这么麻烦。React: 勿Q

/views/UserList.vue

<template>
  <ContentBase>
    <div class="card" v-for="user in users" :key="user.id"
      @click="OpenUserProfile(user.id)"> 
      <div class="card-body">
        <div class="row">
          <div class="col-1">
            <img :src="user.photo" class="img-fluid" alt="">
          </div>
          <div class="col-10">
            <div class="cc">
              <div class="username">{{ user.username }}</div>
              <div class="followers">
                <span class="text"> {{ user.followerCount }} </span> followers
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </ContentBase>
</template>

<script>
import ContentBase from '@/components/ContentBase'
import router from '@/router';
import $ from 'jquery';
import { ref } from 'vue';
import { useStore } from 'vuex';

export default {
  name: 'UserList',
  components: {
    ContentBase,
  },
  setup() {
    let users = ref([]);
    const store = useStore();

    $.ajax({
      //url: 'https://api.bilibili.com/x/relation/followers?vmid=2884629&pn=1&ps=20&order=desc&jsonp=jsonp',
      url: 'https://app165.acapp.acwing.com.cn/myspace/userlist/',
      type: "get",
      success(resp) {
        users.value = resp;
      }
    });

    const OpenUserProfile = userID => { //打开指定用户的userprofile
      if (store.state.user.is_login) { //若已经登录了
        router.push({
          name: "userprofile",
          params: {
            ID: userID,
          }
        });
      }
      else {
        router.push({ //未登录就跳到登录页面
          name: "login"
        });
      }
    }


    return {
      users,
      OpenUserProfile,
    }

  }
}
</script>

<style scoped>
img {
  border-radius: 50%;
}

.cc {
  float: left;
  height: 50%;
}

.username {
  font-weight: bold;

}

.text {
  font-weight: bold;
  color: black;
}

.followers {
  font-size: 15px;
  color: rgb(180, 162, 170);

}

.card {
  margin-bottom: 20px;
  cursor: pointer;
}

.card:hover {
  box-shadow: 2px 2px 10px lightgrey;
  transition: 500ms;
}
</style>

修改个人信息页面

之前我们写个人信息页面的时候是写死数据在里面的,这里我们修改一下,将其改成从云端获取数据。

需要修改的地方:1. 用户个人信息、2. 用户发的帖子。

需要用到的API: 1. 获取用户信息、2. 获取用户的帖子。

/views/UserProfile.vue

<template>
    <ContentBase>
        <div class="row">
            <div class="col-3">
                <UserProfileInfo @checkfollow="follow" @unfollow="unfollow" :user="info" />
                <UserProfileWrite v-if="isMe" @postApost="postApost" />

            </div>
            <div class="col-9">
                <UserProfilePosts :user="info" :posts="posts" @deletepost="DeletePost" >
                </UserProfilePosts>
            </div>
        </div>
    </ContentBase>
</template>

<script>
import ContentBase from '@/components/ContentBase.vue'
import UserProfileInfo from '@/components/UserProfileinfo.vue'
import UserProfilePosts from '@/components/UserProfilePosts.vue'
import { reactive } from 'vue'
import { useRoute } from 'vue-router'
import { useStore } from 'vuex'
import UserProfileWrite from '../components/UserProfileWrite.vue'
import $ from 'jquery'
import { computed } from '@vue/reactivity'


export default {
    name: 'UserProfile',
    components: {
        ContentBase,
        UserProfileInfo,
        UserProfilePosts,
        UserProfileWrite
    },
    setup() {
        const route = useRoute();
        const userID = route.params.ID;
        const store = useStore();
        const user = reactive({});
        const posts = reactive({});
        //获取用户信息
        $.ajax({
            url: "https://app165.acapp.acwing.com.cn/myspace/getinfo/",
            type: "GET",
            data: {
                user_id: userID,
            },
            headers: {
                "Authorization": "Bearer " + store.state.user.access,
            },
            success(resp) {
                //console.log(resp);
                user.id = resp.id;
                user.Username = resp.username;
                user.photo = resp.photo;
                user.Followers = resp.followerCount;
                user.is_followed = resp.is_followed;
            }
        });
        //获取用户帖子
        $.ajax({
            url: "https://app165.acapp.acwing.com.cn/myspace/post/",
            type: "GET",
            data: {
                user_id: userID,
            },
            headers: {
                "Authorization": "Bearer " + store.state.user.access,
            },
            success(resp) {
                posts.count = resp.length;
                //console.log(resp);
                posts.posts = resp;
            }
        });


        const follow = () => {
            if (user.is_followed) return;
            user.is_followed = true;
            user.Followers++;

        }

        const unfollow = () => {
            if (!user.is_followed) return;
            user.is_followed = false;
            user.Followers--;
        }

        const postApost = content => {
            posts.count++;
            posts.posts.unshift({
                id: posts.count,
                content: content,
            })
        }

        const DeletePost = postID => {
            posts.posts = posts.posts.filter(post => post.id !== postID);
            posts.count = posts.posts.length;


        }

        const isMe = computed(() => userID == store.state.user.id)

        return {
            info: user,
            follow,
            unfollow,
            posts,
            postApost,
            isMe,
            DeletePost,
        }


    }
}
</script>

<style scoped>
</style>

修改发帖功能的展示形式

只有当前页面是自己的时候,个人信息页面才会有发帖框。

因此要记录一下当前的个人信息页面是否是为自己。

可以定义一个辅助函数用computed()函数来动态计算当前页面是否为自己:

/views/UserProfile.vue

const userID = route.params.ID; //获取url路径上的参数,这里默认是字符串类型,若要在下面与store.state.user.id用===比较需转化为整型,或者使用== 非严谨判定
...
const isMe = computed(() => userID == store.state.user.id)

//////
const userID = parseInt(route.params.ID);
const isMe = computed(() => userID === store.state.user.id)

修改<router-view>

我们写到这里时,还有一单小bug,就是当我们在访问他人的个人信息页面时,无法通过点击自己右上角的用户名跳回自己的个人信息页面。原因是按照我们之前设置路由的方法,他判断页面跳转的时候是不考虑判断url的参数的,因此会卡在同一个userprofile个人信息页面里,我们要在<router-view>中添加key值来绑定参数。

App.vue

<template>
    <NavBar />
    <router-view :key="$router.fullPath" /> 表示按照完整路径判断
</template>

增加发帖功能

将通过发帖框发送的帖子存到后端的数据库中,并展示在我们页面右边的发帖区

/components/UserProfileWirte.vue

<template>
    <div class="card edit-field">
        <div class="card-body">
            <div class="mb-3">
                <label for="EditPost" class="form-label">Write Your Post</label>
                <textarea v-model="content" class="form-control" id="EditPost"
                    rows="3"></textarea>
                <button @click="postApost" type="button"
                    class="btn btn-success">Send</button>
            </div>
        </div>
    </div>
</template>

<script>
import { ref } from 'vue';
import $ from 'jquery'
import { useStore } from 'vuex';

export default {
    name: "UserProfileWrite",

    setup(props, context) {
        let content = ref('');
        const store = useStore();

        const postApost = () => {
            $.ajax({
                url: "https://app165.acapp.acwing.com.cn/myspace/post/",
                type: "POST",
                data: {
                    content: content.value,
                },
                headers: {
                    "Authorization": "Bearer " + store.state.user.access,
                },
                success(resp) {
                    if (resp.result == "success") {
                        context.emit("postApost", content.value)
                        content.value = "";
                    }
                }
            });

            /*  context.emit("postApost", content.value)
              content.value = "";*/
        }
        return {
            content,
            postApost,
        }
    }
}
</script>

<style scoped>
.edit-field {
    margin-top: 20px;
}

button {
    margin-top: 10px;
}
</style>

增加删帖功能

添加删除按钮,并只有在自己的页面才能显示这个按钮。

UserProfile页面中会获取不同用户对应的个人信息,

为了判断当前页面是否为自己的,需要从父组件UserProfile.vue中传一个对方用户的userId给子组件UserProfilePosts,这样当渲染到userProfilePost模块时,可以通过store.state.user.id当前用户的idprops.user.id父组件传来的用户id是否相同,判断是否该展示删除按钮。

编写删帖逻辑,实现前端与后端同步删除帖子。

/views/UserProfile.vue

...
        const DeletePost = postID => {
            posts.posts = posts.posts.filter(post => post.id !== postID); //过滤
            posts.count = posts.posts.length;
        }
...

/components/UserProfilePosts.vue

<template>
    <div class="card">
        <div class="card-body">
            <div v-for="post in posts.posts" :key="post.id">
                <div class="card OnePost">
                    <div class="card-body">
                        {{ post.content }}
                        <button @click="delete_a_post(post.id)" v-if="isMe"
                            type="button"
                            class="btn btn-danger btn-sm">Delete</button>
                    </div>
                </div>
            </div>

        </div>


    </div>
</template>

<script>
import { computed } from '@vue/reactivity'
import { useStore } from 'vuex'
import $ from 'jquery'



export default {
    name: "UserProfilePosts",
    props: {
        posts: {
            type: Object,
            required: true,
        },

        user: {
            type: Object,
            required: true,
        }
    },

    setup(props, context) {
        const store = useStore();

        const isMe = computed(() => store.state.user.id === props.user.id);

        const delete_a_post = postID => {

            $.ajax({
                url: "https://app165.acapp.acwing.com.cn/myspace/post/",
                type: "DELETE",
                data: {
                    post_id: postID,
                },
                headers: {
                    "Authorization": "Bearer " + store.state.user.access,
                },
                success(resp) {
                    if (resp.result == "success") {
                        context.emit("deletepost", postID);
                    }
                }
            });

        }


        return {
            isMe,
            delete_a_post,
        }
    }

}

</script>

<style scoped>
.OnePost {
    margin-bottom: 10px;
}

button {
    float: right;
}
</style>

实现注册功能

和登陆功能类似,这里就直接贴代码了QAQ

/views/RegisterView.vue

<template>
  <div class="homeBox">
    <div class="container">
      <div class="sign-box">
        <div class="apple-btn sign-apple">
          <li class="red-btn"></li>
          <li class="yellow-btn"></li>
          <li class="green-btn"></li>
        </div>
        <div class="title">Sign</div>
        <form @submit.prevent="sign">
          <div class="input">
            <input v-model="username" type="text" id="sign-user"
              placeholder="Have A Good Name?">
          </div>
          <div class="input">
            <input v-model="password" type="password" id="sign-password"
              placeholder="Keep Secret">
          </div>
          <div class="input">
            <input v-model="password_confirm" type="password"
              id="password_confirm" placeholder="Confirm Your Password">
            <div class="error-message">{{ error_message }}</div>
          </div>
          <button type="submit" class="btn sign-btn">Sign up</button>
        </form>
        <div class="change-box sign-change">
          <div class="change-btn toLogin" @click="GotoLogin">
            <span>Login</span>
          </div>
        </div>
      </div>
    </div>

  </div>
</template>

<script>
import { ref } from 'vue';
import { useStore } from 'vuex';
import router from '@/router';
import $ from "jquery"
export default {
  name: 'RegisterView',
  components: {
  },
  setup() {
    const store = useStore();
    let username = ref('');
    let password = ref('');
    let error_message = ref('');
    let password_confirm = ref('');
    const sign = () => {
      error_message.value = "";
      $.ajax({
        url: "https://app165.acapp.acwing.com.cn/myspace/user/",
        type: "POST",
        data: {
          username: username.value,
          password: password.value,
          password_confirm: password_confirm.value,
        },
        success(resp) {
          console.log(resp);
          if (resp.result === "success") {
            store.dispatch("login", {
              username: username.value,
              password: password.value,
              success() {
                router.push({ name: 'userlist' });
              },
              error() {
                error_message.value = "System Error";
              }
            });
          } else error_message.value = resp.result;
          //console.log(resp);
        }
      });
      // console.log(store);
      //console.log(username.value,password.value,password_confirm.value);
    };
    const GotoLogin = () => {
      router.push({
        name: "login",
      });
    }
    return {
      username,
      password,
      password_confirm,
      error_message,
      sign,
      GotoLogin,
    }
  }
}
</script>



<style scoped>
* {
  padding: 0px;
  margin: 0px;
}

.error-message {
  display: flex;
  font-weight: 800;
  color: red;
  font-size: 1em;
  justify-content: flex-start;
}

.homeBox {
  position: fixed;
  width: 100%;
  height: 100%;
  top: 60px;
  background: -webkit-linear-gradient(130deg, #ffc72c, #14248b);
}

.container {
  position: absolute;
  height: 430px;
  width: 600px;
  background-color: rgba(255, 255, 255, .9);
  left: 50%;
  top: 30%;
  transform: translate(-50%, -30%);
  border-radius: 10px;
  box-shadow: 0px 0px 10px rgba(17, 39, 59, 0.8);
  border: 1px solid rgba(17, 39, 59, 1);
  box-sizing: border-box;
}

.container:hover .title {
  font-size: 20px;
  /* transform: translate(0,-30%); */
}

.container:hover input {
  transform: translate(0, -30%);
}

.container:hover .btn {
  height: 30px;
  width: 90px;
  transform: translate(0, -30%);
  font-size: 12px;
}

.container:hover .change-box {
  height: 200px;
}

.container:hover .change-btn {
  display: block;
}

.title {
  margin-top: 10px;
  position: relative;
  height: 40px;
  width: 100%;
  font-size: 30px;
  display: flex;
  justify-content: center;
  align-items: center;
  text-transform: uppercase;
  font-weight: 500;
  letter-spacing: 3px;
  transition: .4s;
}

.input {
  width: 400px;
  height: 45px;
  position: relative;
  margin: 40px auto;
  /* border-radius: 45px;
    overflow: hidden; */
}

input {
  position: relative;
  width: 100%;
  height: 100%;
  border: none;
  outline: none;
  /* box-sizing: border-box; */
  padding-left: 15px;
  font-size: 16px;
  background-color: rgba(255, 255, 255, 0.4);
  border-radius: 45px;
  transition: .4s;
}

.btn {
  height: 50px;
  width: 160px;
  position: relative;
  margin: -10px auto;
  background-color: rgba(0, 0, 0, 0.1);
  border-radius: 20px;
  color: rgba(255, 255, 255, .4);
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  transition: .4s;
}

.change-box {
  position: absolute;
  height: 0px;
  width: 100%;
  clip-path: polygon(100% 50%, 50% 100%, 100% 100%);
  bottom: 0px;
  transition: .4s;
}

.change-btn {
  position: absolute;
  bottom: 30px;
  right: 40px;
  font-size: 20px;
  display: none;
  font-weight: 500;
}

.change-btn:hover {
  text-shadow: 0px 0px 3px rgba(200, 200, 200, .8);
  cursor: pointer;
}

.login-box input {
  caret-color: white;
  color: rgba(255, 255, 255, 0.8);
}

.sign-change {
  background-color: rgba(17, 39, 59, 0.8);
}

.toLogin {
  color: white;
}

.sign-box input {
  box-shadow: 0 0 3px black;
}

.sign-box .btn {
  color: #1e90ff;
  background-color: rgba(200, 200, 200, .1);
  transition: .5s;
}

.sign-box .btn:hover {
  color: white;
  background-color: #1e90ff;
}

/* Mac 按钮样式及特效 */
.apple-btn {
  position: absolute;
  height: 15px;
  width: 65px;
  top: 3px;
}

.apple-btn li {
  list-style: none;
  position: relative;
  height: 15px;
  width: 15px;
  border-radius: 15px;
  opacity: 0;
}

.sign-apple li {
  right: 5px;
  float: right;
}

.sign-apple {
  right: 5px;
}

li:nth-child(2) {
  margin: 0px 2px
}

.red-btn {
  background-color: red;
  transition: .3s;
  transform: translate(0, -50%);
}

.yellow-btn {
  background-color: orange;
  /* transition-delay: .2s; */
  transition: .6s;
  transform: translate(0, -50%);
}

.green-btn {
  background-color: rgb(15, 136, 15);
  /* transition-delay: .3s; */
  transition: .9s;
  transform: translate(0, -50%);
}

.container:hover .red-btn {
  opacity: 1;
  transform: translate(0, 0);
}

.container:hover .yellow-btn {
  opacity: 1;
  transform: translate(0, 0);
}

.container:hover .green-btn {
  opacity: 1;
  transform: translate(0, 0);
}
</style>

实现关注功能

完善之前在/components/UserProfileInfo.vue中写的两个followunfollow函数

与服务端数据库的交互,实现功能的持久化。

前端与后端的交互是通过API交互的,每次从前端修改之前,先通过API调用后端,根据后端的结果来修改前端的内容。

/copmponents/UserProfileInfo.vue

<template>
    <div class="card">
        <div class="card-body">
            <div class="row">
                <div class="col-5">
                    <img :src="user.photo" class="img-fluid" alt="...">
                </div>
                <div class="col-9">
                    <div class="Username">{{ user.Username }}</div>
                    <div class="fans">followers:{{ user.Followers }}</div>
                    <button @click="follow" v-if="!user.is_followed" type="button"
                        class="btn btn-primary btn-sm">Follow</button>
                    <button @click="unfollow" v-if="user.is_followed" type="button"
                        class="btn btn-primary btn-sm">Unfollow</button>

                </div>

            </div>

        </div>
    </div>
</template>

<script>
//import { computed } from 'vue'
import $ from 'jquery'
import { useStore } from 'vuex';


export default {
    name: "UserProfileInfo",
    props: {
        user: {
            type: Object,
            required: true,
        },
    },
    setup(props, context) {
        //let FullName = computed(() => props.user.FirstName + ' ' + props.user.LastName);
        const store = useStore();
        const follow = () => {
            $.ajax({
                url: "https://app165.acapp.acwing.com.cn/myspace/follow/",
                type: "POST",
                data: {
                    target_id: props.user.id,
                },
                headers: {
                    "Authorization": "Bearer " + store.state.user.access,
                },
                success(resp) {
                    if (resp.result === "success") {
                        context.emit("checkfollow"); //触发父组件函数以更新父组件里的值
                    }
                }
            });
        };

        const unfollow = () => {
            $.ajax({
                url: "https://app165.acapp.acwing.com.cn/myspace/follow/",
                type: "POST",
                data: {
                    target_id: props.user.id,
                },
                headers: {
                    "Authorization": "Bearer " + store.state.user.access,
                },
                success(resp) {
                    if (resp.result === "success") {
                        context.emit("unfollow");
                    }
                }
            });

        };

        return {
            // FullName,
            follow,
            unfollow,
        }

    }
}
</script>

<style scoped>
img {
    border-radius: 50%;
}

.Username {
    font-weight: bold;
}

.fans {
    font-size: 12;
    color: rgb(180, 162, 170);
}

button {
    padding: 2px 5px;
    font-size: 12px;
}
</style>

项目部署

可以参考下面的博客:博客



活动打卡代码 工程课 Web-6.1. 课上代码

yume
1天前

Vue3 项目

配置环境

Vue官网

安装Nodejs

Nodejs

安装LTS版本即可

安装@vue/cli脚手架

打开终端 执行:npm i -g @vue/cli

如果执行后面的操作有bug,可能是最新版有问题,可以尝试安装早期版本,比如:npm i -g @vue/cli@4

启动vue自带的图形化项目管理界面

powershellCMD上运行:vue ui

常见问题1:Windows上运行vue,提示无法加载文件,表示用户权限不足。
解决方案:用管理员身份打开终端,输入set-ExecutionPolicy RemoteSigned,然后输入y

安装问题杂谈

一些参考blog:

安装vue与启动项目

#安装nodejs  安装完毕后,用管理员启动cmd
#1.检查
node -v
npm -v
#2.创建两个目录:
D:\nodejs\nodejs\node_cache
D:\nodejs\nodejs\node_global
#3.设置global cache路径
npm config set prefix "D:\nodejs\nodejs\node_global"
npm config set cache "D:\nodejs\nodejs\node_cache"
#4.修改用户的环境变量
Path中->D:\nodejs\nodejs\node_global
#5.修改系统变量
添加:NODE_PATH  D:\nodejs\nodejs\node_global\node_modules
添加:Path  D:\nodejs\nodejs\node_global
           D:\nodejs\nodejs
#6.测试安装
npm install express -g
#7.修改镜像
npm config get registry
npm config set registry https://registry.npm.taobao.org/
npm config get registry
npm install -g cnpm --registry=https://registry.npm.taobao.org
#8.安装vue
cnpm install -g @vue/cli
#9.启动
vue ui

# nodejs 安装目录是 
D:\nodejs\nodejs
# nodejs版本是最新的 
node-v18.12.0-x64.msi
# 详细解释 参考地址
https://blog.csdn.net/qq_48485223/article/details/122709354

卸载vue:

npm uninstall -g vue
npm uninstall -g vue-cli
npm uninstall -g @vue-cli

基本概念

基本结构

views文件夹一般用来写各种页面的

router文件夹一般用来存放网页路由

默认的路由使用createWebHashHistory,这会使我们网页的url存在#,若将#删除,需把其改成createWebHistory

vue const router = createRouter({ history: createWebHistory(), routes })

components文件夹一般是存各种组件的,和views文件夹差不多,可以根据个人习惯来存放。

整个vue项目的入口在main.js文件里

createApp(App).use(store).use(router).mount('#app')

这句话的意思是新创建一个App,其根组件为App.vue,使用依赖store(之前安装的vuex)、router(之前安装的router),并挂载渲染到#app上,这个#app表示在/public/index.htmlid="app"div标签,即该项目存在一个网页文件index.html,后面写的组件都是打包挂载到这个文件上面对应的<div id="app"></div>里的。

前端渲染

vue框架和react一样也是通过前端渲染的。

前端渲染与后端渲染的区别:

后端渲染:在客户端每打开一个页面、链接,都会向服务器发送请求,服务器将所用到的页面返回给客户端。
前端渲染;只有在客户端第一次打开页面(无论是什么页面),客户端才会向服务器端发送请求,服务器端将页面所需要的元素全部返回给客户端,同时打包在js文件中,当打开第二个或第三个等页面后,就不需要向服务器发送请求了,直接用返回的js文件将新页面渲染出来。

熟悉vue

.vue里同时包含了htmlcssjs分别对应templatestylescript

<style scoped><style>表示当前页面的css样式只会影响自己,而不会影响其他页面。

我们写的网页一般都是由不同的模块组装而成的,因此我们可以将某些模块单独写成组件,这样就可以实现组件的复用。可以通过以下写法将当前文件的组件模块导出

<script>
    export default {
        name: '组件名',如'HelloWorld'
        props: { //该组件需要传递的信息
            键 : 值
            msg: String
        }
    }
<script>

通过一下写法,将组件挂载到对应的页面:

<HelloWorld msg="Welcome to your vue!">

项目实现

页面设计

我们用vue实现网站时,一般是从上往下设计的,将页面的各个部分拆分成不同的组件,若组件中的元素过多,也可以继续拆分成不同的组件。

设计图如下:

vue页面.png

App.vue

App.vue是我们项目的根组件。我们需要用到的依赖(如bootstrap)、需要挂载的组件都是需要引用到这里。

我们先来看看项目初建的App.vue长什么样:

<router-view/> 表示会根据我们的网页网址不同展示不同的网页。

<template>
 <nav>
     <router-link to="/">Home</router-link>
     <router-link to="/about">About</router-link>
 </nav>
 <router-view/>
</template>

<script>
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

nav {
  padding: 30px;
}

nav a {
  font-weight: bold;
  color: #2c3e50;
}

nav a.router-link-exact-active {
  color: #42b983;
}
</style>

这个原装的导航栏有点丑,我们可以把它删掉,自己实现一个新的。

导航栏

我们利用bootstrap来制作我们的导航栏,首先需要在App.vue中引入bootstrap文件。

<template>
  <nav-bar></nav-bar>
  <router-view :key="$route.fullPath"></router-view>
</template>

<script>
import 'bootstrap/dist/css/bootstrap.css';
import 'bootstrap/dist/js/bootstrap';
import navBar from './components/navBar.vue';

export default {
  name: "App",
  components: {
    navBar,
  }
}

</script>

<style>

然后编写导航栏组件:

/components/navbar.vue

<template>
  <nav class="navbar navbar-expand-lg navbar-light bg-light">
    <div class="container">
      <router-link class="navbar-brand"
        :to="{ name: 'home' }">Myspace</router-link>
      <button class="navbar-toggler" type="button" data-bs-toggle="collapse"
        data-bs-target="#navbarText" aria-controls="navbarText"
        aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>
      <div class="collapse navbar-collapse" id="navbarText">
        <ul class="navbar-nav me-auto mb-2 mb-lg-0">
          <li class="nav-item">
            <router-link class="nav-link active"
              :to="{ name: 'home' }">Home</router-link>
          </li>
          <li class="nav-item">
            <router-link class="nav-link"
              :to="{ name: 'userlist' }">Activity</router-link>
          </li>

        </ul>

        <ul class="navbar-nav" v-if="!$store.state.user.is_login">
          <li class="nav-item">
            <router-link class="nav-link" :to="{ name: 'login' }">Sign
              in</router-link>
          </li>
          <li class="nav-item">
            <router-link class="nav-link" :to="{ name: 'register' }">Sign
              up</router-link>
          </li>
        </ul>

        <ul class="navbar-nav" v-else>
          <li class="nav-item">
            <router-link class="nav-link"
              :to="{ name: 'userprofile', params: { ID: $store.state.user.id } }">
              {{ $store.state.user.username }}</router-link>
          </li>
          <li class="nav-item">
            <a class="nav-link" @click="logout" style="cursor: pointer">Sign
              out</a>
          </li>
        </ul>

      </div>
    </div>
  </nav>
</template>

<script>
export default {
  name: "navBar",
}
</script>

<style scoped></style>

导航栏的前端渲染

 <router-link class="navbar-brand"
        :to="{ name: 'home' }">Myspace</router-link>

Vue里如果想给某个标签绑定属性要用: + 属性名称:v-bind:的缩写。

举个例子:

vue <UserProfileInfo :user="user"/>

这里加了:后双引号里面的字符串就不是一个普通的字符串,而是一个表达式,可以传一个表达式

由于每个页面都需要导航栏,因此要把导航栏组件放到根组件App.vue里面。

当我们在当前组件里需要用到其他的组件时,要把这个组件放在当前组件的export default{}中的components里,components属性是一个对象,要定义keyvalue,其中value是引入的组件名,是它export default中定义的namekey是在当前组件中自己定义的外部组件的别名。当keyvalue同名时,可以统一简写成一个名字即key=value;若不同名时,要规范地写成key: value,这样在当前组件中就可以用key来引用外部的组件了。

如下:

<template>
    <navBar/>
</template>
<script>
import 'bootstrap/dist/css/bootstrap.css';
import 'bootstrap/dist/js/bootstrap';
import navBar from './components/navBar.vue';

export default {
  name: "App",
  components: {
    navBar,
  }
}

</script>

ContentBase组件

利用bootstrap中的card组件将页面中的内容框起来

<div class="card">
    <div class="card-body">

    </div>
</div>

利用bootstrap中的container组件,响应式地调整中间内容区域的大小。

<div class="container">
    <div class="card">
        <div class="card-body">

        </div>
    </div>
</div>

由于不同组件的布局方式类似,即存在公共部分,因此,我们可以将上面的写的cardcontainer布局写成一个ContentBase组件,引入到各个组件页面中去。这样做的好处是,当我们想修改布局或者添加一些公共元素的时候,可以直接修改这个ContentBase基类组件:

components/ContentBase.vue

<template>
  <div class="home">
    <div class="container">
      <div class="card">
        <div class="card-body">
          <slot></slot> 渲染子元素
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: "ContentBase",
}
</script>

<style scoped>
.container { 
  margin-top: 20px;
}
</style>

在其他页面中引入此组件,以HomeView为例:

<template>

  <ContentBase>

    <img src="../assets/2.jpg" class="card-img-top" alt="..." >
    <div class="card-body">
      <h5 class="card-title">Lucky day</h5>
      <p class="card-text">have a fun</p>
      <a href="#" class="btn btn-primary">Go somewhere</a>
    </div>

  </ContentBase>

</template>

<script>
import ContentBase from '../components/ContentBase';
export default {
  name: 'HomeView',
  components: {
    ContentBase
  }
}
</script>

<style scoped>
</style>

这里有一个知识点:如何在ContentBase中获取到HomeView中以<ContentBase><ContentBase>包裹着的内容。

HomeView是父组件,ContentBase是其子组件;

ContentBase负责渲染外部样式,定义布局效果,而其内部的内容由父组件HomeView提供。

这里需要用到插槽<slot></slot>在子组件ContentBase中渲染父组件HomeView的内容。

关于插槽内容插槽出口,用例子来展示:

HomeView.vue

<ContentBase>
    这里的是插槽内容
<ContentBase>

ContentBase.vue

<template>
  <div class="home">
    <div class="container">
      <div class="card">
        <div class="card-body">
          <slot></slot> 插槽出口
        </div>
      </div>
    </div>
  </div>
</template>

<slot> 元素是一个插槽出口 (slot outlet),标示了父元素提供的插槽内容 (slot content) 将在哪里被渲染。

通过使用插槽,<ContentBase> 组件更加灵活和具有可复用性。现在组件可以用在不同的地方渲染各异的内容,但同时还保证都具有相同的样式。

React中是用this.props.children[]在子组件中获取父组件中的子节点内容的,与Vue的插槽<slot>效果类似。

更多用法可以参考官方文档插槽slot

其他的页面也类似滴引入ContentBase组件。

创建路由

/router/index.js

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import UserList from '../views/UserList.vue'
import UserProfile from '../views/UserProfile.vue'
import LoginView from '../views/LoginView.vue'
import RegisterView from '../views/RegisterView.vue'
import NotFoundView from '../views/NotFoundView.vue'


const routes = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/userlist/',
    name: 'userlist',
    component: UserList
  },
  {
    path: '/userprofile/:ID/',
    name: 'userprofile',
    component: UserProfile
  },
  {
    path: '/login/',
    name: 'login',
    component: LoginView
  },
  {
    path: '/register/',
    name: 'register',
    component: RegisterView
  },
  {
    path: '/404/',
    name: '404',
    component: NotFoundView
  },
  { 
    path: '/:catchAll(.*)',
    redirect: "/404/" //重定向
  }

]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

实现用户动态页面

该页面的主要功能为展示当前用户信息、展示当前用户发过的帖子、动态发帖。

因此该页面可以由三个组件实现:

  1. UserProfileinfo:展示个人信息
  2. UserProfilePost:展示帖子
  3. UserProfileWrite:发帖

页面布局

可以用bootstrapGrid System

vue_userprofile布局.png

先展示一下大致的样子:

userprofile.png

/views/UserProfile.vue

<template>
    <div class="card">
        <div class="card-body">
            <div class="row">
                <div class="col-5">
                    <img :src="user.photo" class="img-fluid" alt="...">
                </div>
                <div class="col-9">
                    <div class="Username">{{ user.Username }}</div>
                    <div class="fans">followers:{{ user.Followers }}</div>
                    <button @click="follow" v-if="!user.is_followed" type="button"
                        class="btn btn-primary btn-sm">Follow</button>
                    <button @click="unfollow" v-if="user.is_followed" type="button"
                        class="btn btn-primary btn-sm">Unfollow</button>

                </div>

            </div>

        </div>
    </div>

</template>

在url添加参数

我们需要在url上添加参数来区别当前的动态页面是哪个用户的动态页面,可以用userid来充当url的参数。

因此这三个组件都需要进行数据的交互,我们可以把用户数据存放在这三个组件的根组件UserProfile.vue中。

我们在组件的setup函数里定义变量存储数据:

setup函数可以代替之前的data,methods,computed,watch,Mounted等对象,但是props声明还是在外面

  • setup(props, context):初始化变量、函数
  • ref定义变量,可以用.value属性重新赋值
  • reactive定义对象,不可重新赋值
  • props存储父组件传递过来的数据
  • context.emit():触发父组件绑定的函数

关于refreactive:

Vue3中实现响应式数据的方法是ref和reactive

reactive:

  • reactive的参数一般是对象或者数组,他能够将复杂数据类型变为响应式数据。
  • reactive的响应式是深层次的,底层本质是将传入的数据转换为Proxy对象
  • reactive() 不可重新赋值(会丢失响应性),只能通过.属性名=xx修改对应的属性。

ref:

  • ref的参数一般是基本数据类型,也可以是对象类型;ref的本质还是react,如ref(1) ===> reactive({value:1})

  • 如果参数是对象类型,其实底层的本质还是reactive,系统会自动将ref转换为reactive

  • 在模板(template)中访问ref中的数据,系统会自动帮我们添加.value,在JS中访问ref中的数据,需要手动添加.value

  • ref的底层原理同reactive一样,都是Proxy

reactive vs ref:

  • reactive参数一般接受对象或数组(一般情况下都是对象),是深层次的响应式。ref参数一般接收简单数据类型,若直接定义a = reactive([]),则只能通过a.push(...[1,2,3])的形式重新赋值,不能直接通过=赋值,或者直接改成ref;若ref接收对象为参数,本质上会转变为reactive方法
  • 在JS中访问ref的值需要手动添加.value,访问reactive不需要
  • 响应式的底层原理都是Proxy
  • ref开销reactive大

总结:

  1. 如果你需要一个响应式原始值,那么使用ref()是正确的选择,要注意是原始值
  2. 如果你需要一个响应式对象,层级不深,那么使用ref也可以
  3. 如果您需要一个响应式可变对象,并且对象层级较深,需要深度跟踪,那么使用reactive

你可以把 reactive 看成ref的子集,ref可以解决一切烦恼,

钻牛角尖必用

vue3实战-完全掌握ref、reactive

在不同组件里传递信息

父组件向子组件传递信息是通过props属性

子组件向父组件传递信息是通过绑定事件传递的

子组件里接受父组件传过来的参数,要在export default{}中添加props属性。

如子组件UserProfileinfo.vue中接收其父组件UserProfile.vue传递过来的参数:

UserProfile.vue传递参数user

 <ContentBase>
        <div class="row">
            <div class="col-3">
                <UserProfileInfo  :user="user" />

            </div>
            <div class="col-9">
                <UserProfilePost >
                </UserProfilePosts>
            </div>
        </div>
    </ContentBase>

定义uservue_userprifile_use.png

UerProfileinfo.vue:子组件中接收参数

vue子组件接收参数.png

然后就可以在UserProfileInfo中愉快地用父组件传来的user

利用computed()函数动态计算值

引用:import {computed} from 'vue'

computed.png

setup()中是没有this的,所以必须传入props调用父组件传来的数据。

实现关注按钮

需要用到v-if,关注与不关注显示的按钮不同。

template 属性
  • <slot></slot>:存放父组件传过来的children
  • v-on:click`@click属性:绑定点击事件
  • v-on:函数名@函数名: 绑定对应的函数事件
  • v-if、v-else、v-else-if属性:判断
  • v-for属性:循环,`:key循环的每个元素需要有唯一的key
  • v-bind:绑定属性
子组件触发父组件的函数

父组件中定义的数据是不能直接在子组件里进行修改的,只能通过绑定函数事件的方式,在子组件触发父组件中的函数,在父组件相应的函数中进行数据的修改。

利用context.emit(函数名,参数)可以触发父组件的函数。

这里的函数名指的是父组件中@后面跟着的函数名,在父组件中这个@函数名的函数名并不一定是跟父组件中实现函数的函数名相同,只不过在子组件中通过emit触发时,要对应这个@函数名。举个例子:

在父组件UserProfile中绑定函数:

emit1.png

@follow123不一定要与follow同名,follow为该组件中在setup()中实现的一个函数。

而在子组件触发时一定与@后的函数对应:

emit2.png

基本逻辑如下:当在子组件中触发context.emit('follow123')时,会在父组件中寻找@follow123,并且执行父组件中@follow123对应的函数follow,达到更新数据的目的。

实现UserProfilePost

这里都是先写死数据,后面再调成从后端获取数据:

首先在父组件中定义posts帖子列表:

vue_posts.png

传入参数给UserProfilePosts组件:

<UserProfilePosts :posts="posts" />

UserProfileInfo一样,在UserProfilePosts中要定义props将父组件传来的参数获取;

export default {
    ...
    props: {
        posts: {
            type: Object,
            required: true, //表示必须传
        }
    }
}

template中循环渲染列表:

v-if.png

注意:在v-if循环中要加上一个key,这个key需要保证是唯一的

也可以改写成取下标的形式:

<div v-for="(post,index) in posts.posts" :key="index" >
    ...
</div>

如果数组不变的话可以用下标,变的话不建议用下标。

实现UserProfileWrite

这里当懒狗了,没有用到新的知识,直接贴代码吧

<template>
    <div class="card edit-field">
        <div class="card-body">
            <div class="mb-3">
                <label for="EditPost" class="form-label">Write Your Post</label>
                <textarea v-model="content" class="form-control" id="EditPost" rows="3"></textarea>
                <button @click="postApost" type="button" class="btn btn-success">Send</button>
            </div>

        </div>
    </div>

</template>

<script>

export default {
    name: "UserProfileWrite",
    setup() {
        let content = ref('');
        const postApost = () => {
            console.log(content.value);
            content.value = "";
        }
        return {
            content // 这里其实是content:content的缩写
        }
    }
}

</script>

<style scoped>
.edit-field {
    margin-top: 20px;
}

button {
    margin-top: 10px;
}
</style>

vue里获取textarea的信息: v-model双向绑定

<textarea v-model="content" class="form-control" id="EditPost" rows="3"></textarea>
//将textarea里的信息双向绑定到content里

将帖子发到页面右边,需要用到子组件向父组件传递信息,即要触发父组件的函数。

在父组件中定义:

...
<template>
    ...
      <UserProfileWrite @postApost="postApost" />
    </div>
</template>

...
<script>
    ...
    const postApost =(content) => {
    posts.count ++;
    posts.posts.unshift({
        id: posts.count,
        userId: 1,
        content: content
    }) //在数组最前面加元素
}
...
return {
    postApost,
}
</script>

UserProfileWrite.vue

...
<script>

export default {
    name: "UserProfileWrite",
    setup(props,context) {
        let content = ref('');
        const postApost = () => {
            context.emit("postApost",content.value);
            content.value = "";
        }
        return {
            content // 这里其实是content:content的缩写
        }
    }
}

</script>
...


新鲜事 原文

yume
4天前
深夜树洞 () 反正应该也没人看到 (笑 感觉最近越学越不会,学完睡醒就忘,像恶性循环般 cs的技术内容和方向真的多的让人眼花缭乱,也不知道自己适合哪个方向,学的越多,问题越多,遗忘速度也成正比例增长,有时一钻牛角尖就会连带一连串问题,越查越崩溃,还浪费时间。。。 真羡慕其他人都有明确的目的,我还不知道自己会什么,擅长什么,样样都了解一点,样样都不精通,相当于什么都不会。。。 或许以我的智商真的挺难学懂cs吧,挺羡慕那些聪明的大脑的,他们总能以相当短的时间理解我难以理解的问题,甚至觉得他们根本没有遗忘这个属性。。。感觉选专业的时候又做出了个错误的决定呢(怎么次次都错误呀QAQ) 最近学了一会工程真的心疲力竭,一如工程深似海,感觉永远都看不到头,不知道往哪个方向使劲QAQ 我还是回去偶尔看看算法了。。被自己菜到了。。 cs好难,生活好难,不知道出路是什么,以后可能要披上黄蓝战袍去自力更生了,我们都有光明的未来(希望
图片 图片


活动打卡代码 工程课 Web-4.1. 拳皇项目

yume
5天前

拳皇

准备工作

新建项目结构如下:

kof_1.png

项目逻辑如下:

项目结构图:

首先定义一个/js/base.js根据id索引HTML里的div,进行相应的js代码操作:

import GameMap from './game_map/base.js';

class KOF {
    constructor(id) {
        this.$kof = $('#' + id);
        this.game_map = new GameMap(this);

    }
}

export default KOF;

定义AcGame Object

游戏里面有三个元素:地图、玩家1、玩家2,都需要实现每秒钟刷新60次(即每一帧都刷新一次)以实现动画效果,则我们可以让这三个元素继承一个AcGame Object,这个AcGame Object就是用来实现动画效果的,每一帧都刷新一次。

若要实现每一帧都让AcGameObject里的元素都刷新一遍,需要用一个列表AC_GAME_OBJECTS,将对象里的元素存储起来。同时AcGameObject里需要存储几个值:

this.timedelta: 用来存储当前帧距离上一帧的时间间隔

我们每一个元素在页面上运动的速度取决于时间,而不是取决于帧数,因此有必要将时间间隔存储起来。

this.has_call_start: 是否已经执行过start()函数了

定义方法:

start(): 初始执行一次

update(): 每一帧执行一次(除了第一帧以外)

destroy():删除当前对象

调用动画方法:

ac_game_object.js:

let AC_GAME_OBJECTS = []; //存储创建的游戏对象
export default class AcGameObject {
    constructor() {
        AC_GAME_OBJECTS.push(this);

        this.timeDelta = 0; // 存储时间间隔
        this.has_call_start = false; // 是否已经执行过start()函数

    }

    start() { //初始执行一次
        //console.log(AC_GAME_OBJECTS);
        //console.log(22);
        // console.log(obj);

    }

    update() { //每一帧执行一次(除了第一帧)
    }

    destroy() { // 删除当前对象
        for (let i in AC_GAME_OBJECTS) {
            AC_GAME_OBJECTS.splice(i, 1);
        }
    }
}


let last_timestamp;

let AC_GAME_OBJECTS_FRAME = timestamp => {
    for (let obj of AC_GAME_OBJECTS) {
        if (!obj.has_call_start) {
            obj.start(); // 调用游戏对象对应的start()
            obj.has_call_start = true;
        } else {
            obj.timeDelta = timestamp - last_timestamp;
            obj.update(); // 调用游戏对象对应的update()
        }
    }
    last_timestamp = timestamp;
    requestAnimationFrame(AC_GAME_OBJECTS_FRAME);
}

requestAnimationFrame(AC_GAME_OBJECTS_FRAME);
console.log("again!");

所有的游戏对象都需要像上面一样定义start() update()函数,以实现每一帧的更新。

游戏地图

游戏地图的实现继承自AcGameObject.

做游戏的时候一般初始化一个canvas

想让canvas读取键盘输入的话需要将canvas聚焦,在<canvas>里添加tabindex=0属性。

游戏地图渲染时每一帧都要先清空一遍,如果不清空的话,我们视觉上看到的就不是一个物体在移动了,而是一个物体在不停地画线,会把轨迹存下来。清空地图可以放在render()函数里写。

比如:我们简单地实现一个物体下落效果,正常清空的话,效果是这样的:

kof2.png

若不清空,即注释掉 this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);,效果如下所示:

kob3.png

变成了一个不停渲染的长条状,原因是没有在每一帧开始前清空掉之前的图像,从而保留了每一帧的图像,连起来就成了一条长条。

这里记录一下,当我们通过import引入了一个JS类后,会自动执行这个JS文件里的内容,比如这里importac_game_object.js,虽然只是在构造函数里super()后,并没有做其他的操作,但是还是会输出“again!” (对应上面的console.log(again!))

/game_map/zbase.js初始化:

import AcGameObject from '../ac_game_object.js'


export default class GameMap extends AcGameObject {
    constructor(root) {
        super();
        this.root = root; //方便索引地图上的整个元素
        this.$canvas = $(`<canvas id="tutorial" width="150" height="150" tabindex=0></canvas>`);
        this.ctx = this.$canvas[0].getContext('2d'); //获取canvas
        //canvas的所有操作都是在this.ctx上
        this.root.$kof.append(this.$canvas); //将canvas加入到$kof中
        this.$canvas.focus(); //为了能让canvas获取输入,要让canvas聚焦
    }

    start() {


    }

    update() {
        this.render();
    }

    render() {
        this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); //要清空canvas里的矩形,不然的话角色就不会运动了
        //this.ctx.fillRect(0, 0, this.$canvas.width(), this.$canvas.height());
    }
}

游戏角色

该类也是继承自AcGameObject

这里先用两个矩形来代替两个角色,矩形的属性就是角色的属性,通过矩形的坐标来进行角色移动的判断,后期再用图像替代掉矩形展示在页面上就行了。

实现游戏角色的移动有三大要素:速度、位置、方向。只要设定好这三个参数,就可以操作角色进行移动了。

拳皇是一个贴图游戏,角色不同的状态对应着不同的动作,我们需要人工判断角色处于一个什么样的状态下,从而展示什么样的图片,因此我们可以使用状态机对角色的状态加以区分。

this.status:角色状态
0: idle准备状态, 
1: 向前,
2: 向后,
3: 跳跃,
4: 攻击,
5:受击,
6:死亡

角色初始的时候是在空中,有一种下落的动画效果,因此初始状态设为3
未来想要扩展的话,就在状态机里补充动作状态

基本状态机模型:(1,2逻辑上是一样的因此可以统一归为1)

kof状态机.png

控制按键

为角色的动作添加操作按键,读取键盘输入。新建/controller/base.js创建控制类Controller,并将其引入到GameMap中,this.Controller = new Controller(this.$canvas);

因为控制角色移动以及做动作的时候,需要确切地知道我们摁住了那些键(包括组合键),所以js中自带的keydown,keypresss并不适合我们取键值。理由很简单,我们处理一些动作状态,如“跳跃”的时候,一般来说是只能按一次跳跃一次的,是不循环的 ,若一直按着一个键不放手keydownkeypress会一直获取键值,我们就不能恰当地处理这个动作状态,一些组合键也是一样的道理。

因此,我们在Controller类中用一个Set去维护我们当前读取了哪些键值,然后在Plyaer类里引入这个Set:this.pressed_keys = this.root.game_map.controller.pressed_keys;

通过this.pressed_keys.has.(key)判断当前帧是否摁下了某个键。

这里是我们实现人物动作的关键,可以在后期根据不同的动作创造不同的组合按键。\

/controller/base.js

export default class Controller {
    constructor($canvas) {
        this.$canvas = $canvas;

        this.pressed_keys = new Set(); //手动实现当前摁住了哪个键
        this.start();
    }

    start() {
        this.$canvas.keydown(e => {
            this.pressed_keys.add(e.key);
            // console.log(e.key);
            //console.log(event.code);
            // console.log(e.code, e.altKey);
        });

        this.$canvas.keyup(e => {
            this.pressed_keys.delete(e.key);
            //console.log(e.key);
        });

    }
}

/player/base.js

import AcGameObject from "../ac_game_object.js";

export default class Player extends AcGameObject {
    constructor(root, info) {
        super();
        this.root = root;
        this.id = info.id; //区分两名角色的id
        this.x = info.x; //坐标x
        this.y = info.y; //坐标y
        this.width = info.width; // 宽
        this.height = info.height; // 高
        this.color = info.color; //颜色

        this.vx = 0; //水平方向的速度
        this.vy = 0; // 竖直方向的速度

        this.direction = 1; // 1 为向右,-1位向左

        this.speedX = 400; //水平移动速度
        this.speedY = -1200; //跳起的初始速度

        this.gravity = 50;  //重力

        this.ctx = this.root.game_map.ctx;
        this.status = 3; //角色状态:0:idle, 1: 向前,2: 向后,3:跳跃,4:攻击,5:受击,6:死亡

        this.pressed_keys = this.root.game_map.controller.pressed_keys;

    }

    start() {


    }

    update_move() {
        this.vy += this.gravity;

        this.x += this.vx * this.timeDelta / 1000; //路程=速度*时间,timeDelta单位是毫秒,所以要 / 1000
        this.y += this.vy * this.timeDelta / 1000;

        if (this.y > 450) {  //到达地面后转换为状态0
            this.vy = 0;
            this.y = 450;
            this.status = 0;
        }

        //防越界
        // console.log(this.x, this.root.game_map.$canvas.width(), this.width);
        if (this.x < 0) this.x = 0;
        else if (this.x + this.width >= this.root.game_map.$canvas.width()) {
            console.log(this.root.game_map.$canvas.width(), this.width);
            this.x = this.root.game_map.$canvas.width() - this.width;
        }
    }

    update_control() { //每一帧都要去判断一下摁的是什么键
        let w, a, space, d;
        if (this.id === 0) {
            w = this.pressed_keys.has('w');
            a = this.pressed_keys.has('a');
            d = this.pressed_keys.has('d');
            space = this.pressed_keys.has(' ');
        } else {
            w = this.pressed_keys.has('ArrowUp');
            a = this.pressed_keys.has('ArrowLeft');
            d = this.pressed_keys.has('ArrowRight');
            space = this.pressed_keys.has('ArrowDown');
        }
        //console.log(w, a, space, d);
        //这里是跳起来在空中不能做动作的版本,因此只能从0,1,2转变到3
        if (this.status === 0 || this.status === 1 || this.status === 2) {
            if (w) {
                if (d) { //向前45°跳
                    this.vx = this.speedX;
                } else if (a) { //向后45°跳
                    this.vx = -this.speedX;
                } else { //垂直向上跳
                    this.vx = 0;
                }
                this.vy = this.speedY;
                this.status = 3;
            } else if (d) { // 向前
                this.vx = this.speedX;
                this.status = 1;
            } else if (a) { //向后
                this.vx = -this.speedX;
                this.status = 2;
            } else { //没有移动
                this.vx = 0;
                this.status = 0;
            }
        }


    }


    update() {
        this.update_move();
        this.update_control();

        this.render();
    }


    render() {
        this.ctx.fillStyle = this.color;
        //console.log(`player${this.id}: ${this.ctx.fillStyle}`);
        // console.log(this.x, this.y);
        this.ctx.fillRect(this.x, this.y, this.width, this.height); //创建矩形
    }
}

渲染GIF贴图

我们上面是使用矩形来代表人物的,现在考虑用GIF贴图代替矩形。

我们渲染图片的时候并不是直接渲染GIF,而是将GIF中的每一帧拎出来,做成一个个贴图,自己控制每一帧去渲染贴图,因为直接渲染GIF的话我们不容易控制角色的速度,而使用每一帧去渲染贴图的方式更容易让我们控制角色的速度。

贴图如下所示:

贴图展示.png

贴图的文件格式和GIF是等价的。

具体操作如下:

每次计算不同状态贴图的位置,贴图的位置大概是等分的,这个可以用偏移量表示出来,然后在每一帧渲染不同的动作,这个基本原理是和GIF类似的。

当然,我们也可以直接用canvas直接操作GIF,可以利用先人写好的第三方函数 How to put a gif with Canvas 调包侠

/js/utils/gif.js

const GIF = function () {
    // **NOT** for commercial use.
    var timerID;                          // timer handle for set time out usage
    var st;                               // holds the stream object when loading.
    var interlaceOffsets = [0, 4, 2, 1]; // used in de-interlacing.
    var interlaceSteps = [8, 8, 4, 2];
    var interlacedBufSize;  // this holds a buffer to de interlace. Created on the first frame and when size changed
    var deinterlaceBuf;
    var pixelBufSize;    // this holds a buffer for pixels. Created on the first frame and when size changed
    var pixelBuf;
    const GIF_FILE = { // gif file data headers
        GCExt: 0xF9,
        COMMENT: 0xFE,
        APPExt: 0xFF,
        UNKNOWN: 0x01, // not sure what this is but need to skip it in parser
        IMAGE: 0x2C,
        EOF: 59,   // This is entered as decimal
        EXT: 0x21,
    };
    // simple buffered stream used to read from the file 
    var Stream = function (data) {
        this.data = new Uint8ClampedArray(data);
        this.pos = 0;
        var len = this.data.length;
        this.getString = function (count) { // returns a string from current pos of len count
            var s = "";
            while (count--) { s += String.fromCharCode(this.data[this.pos++]) }
            return s;
        };
        this.readSubBlocks = function () { // reads a set of blocks as a string
            var size, count, data = "";
            do {
                count = size = this.data[this.pos++];
                while (count--) { data += String.fromCharCode(this.data[this.pos++]) }
            } while (size !== 0 && this.pos < len);
            return data;
        }
        this.readSubBlocksB = function () { // reads a set of blocks as binary
            var size, count, data = [];
            do {
                count = size = this.data[this.pos++];
                while (count--) { data.push(this.data[this.pos++]); }
            } while (size !== 0 && this.pos < len);
            return data;
        }
    };
    // LZW decoder uncompressed each frames pixels
    // this needs to be optimised.
    // minSize is the min dictionary as powers of two
    // size and data is the compressed pixels
    function lzwDecode(minSize, data) {
        var i, pixelPos, pos, clear, eod, size, done, dic, code, last, d, len;
        pos = pixelPos = 0;
        dic = [];
        clear = 1 << minSize;
        eod = clear + 1;
        size = minSize + 1;
        done = false;
        while (!done) { // JavaScript optimisers like a clear exit though I never use 'done' apart from fooling the optimiser
            last = code;
            code = 0;
            for (i = 0; i < size; i++) {
                if (data[pos >> 3] & (1 << (pos & 7))) { code |= 1 << i }
                pos++;
            }
            if (code === clear) { // clear and reset the dictionary
                dic = [];
                size = minSize + 1;
                for (i = 0; i < clear; i++) { dic[i] = [i] }
                dic[clear] = [];
                dic[eod] = null;
            } else {
                if (code === eod) { done = true; return }
                if (code >= dic.length) { dic.push(dic[last].concat(dic[last][0])) }
                else if (last !== clear) { dic.push(dic[last].concat(dic[code][0])) }
                d = dic[code];
                len = d.length;
                for (i = 0; i < len; i++) { pixelBuf[pixelPos++] = d[i] }
                if (dic.length === (1 << size) && size < 12) { size++ }
            }
        }
    };
    function parseColourTable(count) { // get a colour table of length count  Each entry is 3 bytes, for RGB.
        var colours = [];
        for (var i = 0; i < count; i++) { colours.push([st.data[st.pos++], st.data[st.pos++], st.data[st.pos++]]) }
        return colours;
    }
    function parse() {        // read the header. This is the starting point of the decode and async calls parseBlock
        var bitField;
        st.pos += 6;
        gif.width = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
        gif.height = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
        bitField = st.data[st.pos++];
        gif.colorRes = (bitField & 0b1110000) >> 4;
        gif.globalColourCount = 1 << ((bitField & 0b111) + 1);
        gif.bgColourIndex = st.data[st.pos++];
        st.pos++;                    // ignoring pixel aspect ratio. if not 0, aspectRatio = (pixelAspectRatio + 15) / 64
        if (bitField & 0b10000000) { gif.globalColourTable = parseColourTable(gif.globalColourCount) } // global colour flag
        setTimeout(parseBlock, 0);
    }
    function parseAppExt() { // get application specific data. Netscape added iterations and terminator. Ignoring that
        st.pos += 1;
        if ('NETSCAPE' === st.getString(8)) { st.pos += 8 }  // ignoring this data. iterations (word) and terminator (byte)
        else {
            st.pos += 3;            // 3 bytes of string usually "2.0" when identifier is NETSCAPE
            st.readSubBlocks();     // unknown app extension
        }
    };
    function parseGCExt() { // get GC data
        var bitField;
        st.pos++;
        bitField = st.data[st.pos++];
        gif.disposalMethod = (bitField & 0b11100) >> 2;
        gif.transparencyGiven = bitField & 0b1 ? true : false; // ignoring bit two that is marked as  userInput???
        gif.delayTime = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
        gif.transparencyIndex = st.data[st.pos++];
        st.pos++;
    };
    function parseImg() {                           // decodes image data to create the indexed pixel image
        var deinterlace, frame, bitField;
        deinterlace = function (width) {                   // de interlace pixel data if needed
            var lines, fromLine, pass, toline;
            lines = pixelBufSize / width;
            fromLine = 0;
            if (interlacedBufSize !== pixelBufSize) {      // create the buffer if size changed or undefined.
                deinterlaceBuf = new Uint8Array(pixelBufSize);
                interlacedBufSize = pixelBufSize;
            }
            for (pass = 0; pass < 4; pass++) {
                for (toLine = interlaceOffsets[pass]; toLine < lines; toLine += interlaceSteps[pass]) {
                    deinterlaceBuf.set(pixelBuf.subarray(fromLine, fromLine + width), toLine * width);
                    fromLine += width;
                }
            }
        };
        frame = {}
        gif.frames.push(frame);
        frame.disposalMethod = gif.disposalMethod;
        frame.time = gif.length;
        frame.delay = gif.delayTime * 10;
        gif.length += frame.delay;
        if (gif.transparencyGiven) { frame.transparencyIndex = gif.transparencyIndex }
        else { frame.transparencyIndex = undefined }
        frame.leftPos = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
        frame.topPos = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
        frame.width = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
        frame.height = (st.data[st.pos++]) + ((st.data[st.pos++]) << 8);
        bitField = st.data[st.pos++];
        frame.localColourTableFlag = bitField & 0b10000000 ? true : false;
        if (frame.localColourTableFlag) { frame.localColourTable = parseColourTable(1 << ((bitField & 0b111) + 1)) }
        if (pixelBufSize !== frame.width * frame.height) { // create a pixel buffer if not yet created or if current frame size is different from previous
            pixelBuf = new Uint8Array(frame.width * frame.height);
            pixelBufSize = frame.width * frame.height;
        }
        lzwDecode(st.data[st.pos++], st.readSubBlocksB()); // decode the pixels
        if (bitField & 0b1000000) {                        // de interlace if needed
            frame.interlaced = true;
            deinterlace(frame.width);
        } else { frame.interlaced = false }
        processFrame(frame);                               // convert to canvas image
    };
    function processFrame(frame) { // creates a RGBA canvas image from the indexed pixel data.
        var ct, cData, dat, pixCount, ind, useT, i, pixel, pDat, col, frame, ti;
        frame.image = document.createElement('canvas');
        frame.image.width = gif.width;
        frame.image.height = gif.height;
        frame.image.ctx = frame.image.getContext("2d");
        ct = frame.localColourTableFlag ? frame.localColourTable : gif.globalColourTable;
        if (gif.lastFrame === null) { gif.lastFrame = frame }
        useT = (gif.lastFrame.disposalMethod === 2 || gif.lastFrame.disposalMethod === 3) ? true : false;
        if (!useT) { frame.image.ctx.drawImage(gif.lastFrame.image, 0, 0, gif.width, gif.height) }
        cData = frame.image.ctx.getImageData(frame.leftPos, frame.topPos, frame.width, frame.height);
        ti = frame.transparencyIndex;
        dat = cData.data;
        if (frame.interlaced) { pDat = deinterlaceBuf }
        else { pDat = pixelBuf }
        pixCount = pDat.length;
        ind = 0;
        for (i = 0; i < pixCount; i++) {
            pixel = pDat[i];
            col = ct[pixel];
            if (ti !== pixel) {
                dat[ind++] = col[0];
                dat[ind++] = col[1];
                dat[ind++] = col[2];
                dat[ind++] = 255;      // Opaque.
            } else
                if (useT) {
                    dat[ind + 3] = 0; // Transparent.
                    ind += 4;
                } else { ind += 4 }
        }
        frame.image.ctx.putImageData(cData, frame.leftPos, frame.topPos);
        gif.lastFrame = frame;
        if (!gif.waitTillDone && typeof gif.onload === "function") { doOnloadEvent() }// if !waitTillDone the call onload now after first frame is loaded
    };
    // **NOT** for commercial use.
    function finnished() { // called when the load has completed
        gif.loading = false;
        gif.frameCount = gif.frames.length;
        gif.lastFrame = null;
        st = undefined;
        gif.complete = true;
        gif.disposalMethod = undefined;
        gif.transparencyGiven = undefined;
        gif.delayTime = undefined;
        gif.transparencyIndex = undefined;
        gif.waitTillDone = undefined;
        pixelBuf = undefined; // dereference pixel buffer
        deinterlaceBuf = undefined; // dereference interlace buff (may or may not be used);
        pixelBufSize = undefined;
        deinterlaceBuf = undefined;
        gif.currentFrame = 0;
        if (gif.frames.length > 0) { gif.image = gif.frames[0].image }
        doOnloadEvent();
        if (typeof gif.onloadall === "function") {
            (gif.onloadall.bind(gif))({ type: 'loadall', path: [gif] });
        }
        if (gif.playOnLoad) { gif.play() }
    }
    function canceled() { // called if the load has been cancelled
        finnished();
        if (typeof gif.cancelCallback === "function") { (gif.cancelCallback.bind(gif))({ type: 'canceled', path: [gif] }) }
    }
    function parseExt() {              // parse extended blocks
        const blockID = st.data[st.pos++];
        if (blockID === GIF_FILE.GCExt) { parseGCExt() }
        else if (blockID === GIF_FILE.COMMENT) { gif.comment += st.readSubBlocks() }
        else if (blockID === GIF_FILE.APPExt) { parseAppExt() }
        else {
            if (blockID === GIF_FILE.UNKNOWN) { st.pos += 13; } // skip unknow block
            st.readSubBlocks();
        }

    }
    function parseBlock() { // parsing the blocks
        if (gif.cancel !== undefined && gif.cancel === true) { canceled(); return }

        const blockId = st.data[st.pos++];
        if (blockId === GIF_FILE.IMAGE) { // image block
            parseImg();
            if (gif.firstFrameOnly) { finnished(); return }
        } else if (blockId === GIF_FILE.EOF) { finnished(); return }
        else { parseExt() }
        if (typeof gif.onprogress === "function") {
            gif.onprogress({ bytesRead: st.pos, totalBytes: st.data.length, frame: gif.frames.length });
        }
        setTimeout(parseBlock, 0); // parsing frame async so processes can get some time in.
    };
    function cancelLoad(callback) { // cancels the loading. This will cancel the load before the next frame is decoded
        if (gif.complete) { return false }
        gif.cancelCallback = callback;
        gif.cancel = true;
        return true;
    }
    function error(type) {
        if (typeof gif.onerror === "function") { (gif.onerror.bind(this))({ type: type, path: [this] }) }
        gif.onload = gif.onerror = undefined;
        gif.loading = false;
    }
    function doOnloadEvent() { // fire onload event if set
        gif.currentFrame = 0;
        gif.nextFrameAt = gif.lastFrameAt = new Date().valueOf(); // just sets the time now
        if (typeof gif.onload === "function") { (gif.onload.bind(gif))({ type: 'load', path: [gif] }) }
        gif.onerror = gif.onload = undefined;
    }
    function dataLoaded(data) { // Data loaded create stream and parse
        st = new Stream(data);
        parse();
    }
    function loadGif(filename) { // starts the load
        var ajax = new XMLHttpRequest();
        ajax.responseType = "arraybuffer";
        ajax.onload = function (e) {
            if (e.target.status === 404) { error("File not found") }
            else if (e.target.status >= 200 && e.target.status < 300) { dataLoaded(ajax.response) }
            else { error("Loading error : " + e.target.status) }
        };
        ajax.open('GET', filename, true);
        ajax.send();
        ajax.onerror = function (e) { error("File error") };
        this.src = filename;
        this.loading = true;
    }
    function play() { // starts play if paused
        if (!gif.playing) {
            gif.paused = false;
            gif.playing = true;
            playing();
        }
    }
    function pause() { // stops play
        gif.paused = true;
        gif.playing = false;
        clearTimeout(timerID);
    }
    function togglePlay() {
        if (gif.paused || !gif.playing) { gif.play() }
        else { gif.pause() }
    }
    function seekFrame(frame) { // seeks to frame number.
        clearTimeout(timerID);
        gif.currentFrame = frame % gif.frames.length;
        if (gif.playing) { playing() }
        else { gif.image = gif.frames[gif.currentFrame].image }
    }
    function seek(time) { // time in Seconds  // seek to frame that would be displayed at time
        clearTimeout(timerID);
        if (time < 0) { time = 0 }
        time *= 1000; // in ms
        time %= gif.length;
        var frame = 0;
        while (time > gif.frames[frame].time + gif.frames[frame].delay && frame < gif.frames.length) { frame += 1 }
        gif.currentFrame = frame;
        if (gif.playing) { playing() }
        else { gif.image = gif.frames[gif.currentFrame].image }
    }
    function playing() {
        var delay;
        var frame;
        if (gif.playSpeed === 0) {
            gif.pause();
            return;
        } else {
            if (gif.playSpeed < 0) {
                gif.currentFrame -= 1;
                if (gif.currentFrame < 0) { gif.currentFrame = gif.frames.length - 1 }
                frame = gif.currentFrame;
                frame -= 1;
                if (frame < 0) { frame = gif.frames.length - 1 }
                delay = -gif.frames[frame].delay * 1 / gif.playSpeed;
            } else {
                gif.currentFrame += 1;
                gif.currentFrame %= gif.frames.length;
                delay = gif.frames[gif.currentFrame].delay * 1 / gif.playSpeed;
            }
            gif.image = gif.frames[gif.currentFrame].image;
            timerID = setTimeout(playing, delay);
        }
    }
    var gif = {                      // the gif image object
        onload: null,       // fire on load. Use waitTillDone = true to have load fire at end or false to fire on first frame
        onerror: null,       // fires on error
        onprogress: null,       // fires a load progress event
        onloadall: null,       // event fires when all frames have loaded and gif is ready
        paused: false,      // true if paused
        playing: false,      // true if playing
        waitTillDone: true,       // If true onload will fire when all frames loaded, if false, onload will fire when first frame has loaded
        loading: false,      // true if still loading
        firstFrameOnly: false,      // if true only load the first frame
        width: null,       // width in pixels
        height: null,       // height in pixels
        frames: [],         // array of frames
        comment: "",         // comments if found in file. Note I remember that some gifs have comments per frame if so this will be all comment concatenated
        length: 0,          // gif length in ms (1/1000 second)
        currentFrame: 0,          // current frame. 
        frameCount: 0,          // number of frames
        playSpeed: 1,          // play speed 1 normal, 2 twice 0.5 half, -1 reverse etc...
        lastFrame: null,       // temp hold last frame loaded so you can display the gif as it loads
        image: null,       // the current image at the currentFrame
        playOnLoad: true,       // if true starts playback when loaded
        // functions
        load: loadGif,    // call this to load a file
        cancel: cancelLoad, // call to stop loading
        play: play,       // call to start play
        pause: pause,      // call to pause
        seek: seek,       // call to seek to time
        seekFrame: seekFrame,  // call to seek to frame
        togglePlay: togglePlay, // call to toggle play and pause state
    };
    return gif;
}

export {
    GIF
}

创建角色GIF

这里每个角色创建一个对应的类,extendsPlayer类,然后在此类里import进上面写好的GIF轮子。

注意Player类里需要添加两个新的属性:

this.animations来存储每个角色不同的动作状态

this.frame_current_cnt记录当前是第几帧,从开始到现在的总的帧数,方便后续通过取模将GIF中的每一帧图片渲染出来

我们通过上面实现的GIF类将角色的GIF动态图片中的每一帧静态图片提取出来(类似于贴图),并根据提取顺存储进一个数组gif.frames[]里,gif.frames[index].image即为该gif动态图每一帧对应的静态图片,之后便可以通过总帧数 % 数组长度的方法取出当前帧对应的静态图片是什么(this.frame_current_cnt初值为0,因此可以对应数组里的每一个元素编号),然后在每一帧里渲染出对应的图片,就可以实现角色的GIF动态效果了。记得每一帧渲染完后要将this.frame_current_cnt ++

/js/player/base.js

export default class Player extends AcGameObject {
    constructor(root, info) {
        super();
        ...
        this.animations = new Map(); //用Map存储角色每个状态的动作,staus: gif
        this.frame_current_cnt = 0; // 记录当前是第几帧(总帧数)

    }

以创建一个新的角色Kyo类为例:

/js/player/kyo.js

import Player from "./base.js";
import { GIF } from "../utils/gif.js";

export default class Kyo extends Player {
    constructor(root, info) {
        super(root, info);

        this.init_animations();

    }

    init_animations() { //初始化动画
        let offsets = [0, -22, -22, -140, 0, 0, 0]; //纵向偏移量
        for (let i = 0; i < 7; i++) //一种有7个动作
        {
            let gif = GIF(); //这里参考别人写好的轮子写法
            gif.load(`/static/images/player/kyo/${i}.gif`);
            this.animations.set(i, {
                gif: gif,
                frame_cnt: 0, //有多少帧,总图片数,初始都为0,需要加载完后重新定义
                frame_rate: 4, //渲染速率,每5帧渲染一次
                offset_y: offsets[i], //竖直方向的偏移量
                loaded: false,//有没有被加载进页面
                scale: 2.2, //缩放倍率
            });

            //这些都是GIF类的API
            gif.onload = () => {
                let obj = this.animations.get(i);
                obj.frame_cnt = gif.frames.length; // 将gif里的所有帧数提取出来,即将gif中每帧的图片都提取出来
                obj.loaded = true; //已加载进页面
            }

        }
    }
}

在总类里面引入进这个角色

js/base.js

import GameMap from './game_map/base.js';
import Kyo from './player/kyo.js';

class KOF {
    constructor(id) {
        this.$kof = $('#' + id);
        this.game_map = new GameMap(this);

        this.players = [
            new Kyo(this, {
                id: 0,
                x: 290,
                y: 0,
                width: 150,
                height: 220,
            }),
            new Kyo(this, {
                id: 1,
                x: 1010,
                y: 0,
                width: 150,
                height: 220,
            }),
        ];

    }
}

export default KOF;

在渲染GIF图片的时候我们需要控制它的渲染速率,不然的话每一帧都渲染一次图片,等同于每秒钟显示60帧,渲染60次图片,角色就会像抽搐一样。。。

所以我们调整一下速率,改成每5帧渲染一次图片,即每秒钟显示12帧,渲染12次。

只需要在渲染的时候将总帧数先除以一个渲染速率再取模即可。

注意:因为GIF动态图的关系,人物在水平移动和跳跃时,画面显示人物的脚会在地面水平线以下,我们还需要添加一个纵向偏移量来调整一下参数。

/js/player/base.js

...
 render() {
        // this.ctx.fillStyle = this.color;
        // this.ctx.fillRect(this.x, this.y, this.width, this.height); //创建矩形
        let status = this.status;
        let obj = this.animations.get(status);
        //当gif被加载完毕才渲染
        if (obj && obj.loaded) {
            let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;
            let image = obj.gif.frames[k].image; //通过这个接口将GIF图片里的每一帧提取出来
            this.ctx.drawImage(image, this.x, this.y, image.width, image.height);
        }
        this.frame_current_cnt++;
    }
...

实现攻击动作

每次渲染完攻击动作后,都需要将状态置为待机状态,即状态0,因此在渲染函数里要加个特判:当攻击行为的GIF动态效果渲染到倒数第二帧图片时,要将当前的状态设为0,不然的话角色就会一直处于攻击状态,画面就会一直加载攻击动画效果停不下来来。

如果是在渲染到最后一张图片时才将状态设为0的话,角色的动作会出现一些细小的空白卡顿,原因是我们并不是每一帧都渲染图片,而是每5帧才渲染一次图片,在这种判断条件的情况下,当前的帧恰好等于GIF图片的最后一帧时,在正常情况下会跳到攻击状态GIF的第一帧图片, 角色依然会进行攻击的动作,这时我们强制把他变为状态0的话,就会出现一些空白卡顿。因此我们在倒数第二帧图片那里设置判断条件的话,我们就可以在角色即将进行最后一帧的攻击动画效果时,将他变为状态0,这样就会流畅许多了。

这里补充一点:obj.frame_cnt - 1表示的是最后一帧的图片,并不是倒数第二帧的图片,因为obj.frame_cnt 是数组obj.frames的长度,而数组下标是从0开始的,因此数组的最后一个元素下标应该为obj.frame_cnt - 1。而当我们当前帧渲染到该动作的最后一帧图片时,我们就要将其状态转变为待机状态,即不渲染这最后一帧,与我们上面说的渲染到倒数第二张图片并不冲突。

需要注意的是,当我们按下攻击键时,还需要将this.frame_current_cnt设为0,以保证我们的特判能生效。

/js/player/base.js

...
update_controll()
{
    ...
     if (this.status === 0 || this.status === 1 || this.status === 2) {
     ...
            if (space) {
                this.status = 4;
                this.vx = 0;
                this.frame_current_cnt = 0; //从第0帧开始渲染
            } else ...
     }
}

...
render() {
    ...
      if (status === 4) //当攻击动作播放完后恢复为状态0
        {
            if (this.frame_current_cnt === obj.frame_rate * (obj.frame_cnt - 1)) //当前帧为该动作效果的最后一帧时
            {
                this.status = 0;

            }
            //console.log(this.status);
        }
     this.frame_current_cnt++;
}

调整跳跃

我们发现当我们这样写完的时候,跳跃效果还是有点问题的,会出现卡帧的情况,而且会出现在空中就改变动作的情况。因此我们要在跳跃时(按下跳跃键时),要设置为从头播放跳跃动画,即令this.frame_current_cnt = 0,将当前帧调整为0。

实验证明,当this.frame_rate = 4即渲染速率为4,每4帧才渲染一次图片时,跳跃效果时最好的。。。

一个小技巧,我们可以在如下地方判断跳跃的帧数,注释的语句那里:

/js/player/base.js:

...
 if (obj && obj.loaded) {
            let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;
            // if (status === 3) console.log(k, obj.frame_cnt); 用来调试跳跃的帧数
            let image = obj.gif.frames[k].image; //通过这个接口将GIF图片里的每一帧提取出来
            this.ctx.drawImage(image, this.x, this.y + obj.offset_y, image.width * obj.scale, image.height * obj.scale);
        }

...

当在一次跳跃过程中,k与跳跃状态的GIF的总帧数越接近,说明跳跃的动画效果越完美,越流畅,因为这表明在一次跳跃的动画时间内能更为完整地将跳跃动画效果展现出来。

如下所示:

调试.png

实现角色对称

这里需要注意的是,当两个玩家交换方位时,他们的对称效果也需要交换,因此这里需要每一帧都要判断两个玩家的对称情况。

实现更新对称情况函数:

/js/player/base.js

...

    update_direction() {
        let players = this.root.players;
        if (players[0] && players[1]) {
            let me = this, you = players[1 - this.id];
            if (me.x < you.x) me.direction = 1;
            else me.direction = -1;
        }

    }

...

在渲染GIF图片的时候也需要根据方向将图片做对应的对称加工,这里运用到了canvas的水平翻转,

原理其实就是将坐标系按y轴翻转,即x轴的正方向向左边延伸,y轴的正方向向下延伸的情况。

此时物体的坐标不变,是在x轴的负方向上,我们要将坐标系向x轴的负方向上平移,把物体框到坐标系的正方向内。

渲染对称的图像时,因为换了坐标轴,所以也需要按照新的坐标轴渲染图像,将对应的点进行轴对称渲染。

this.ctx.drawImage(image, this.root.game_map.$canvas.width() - this.x - this.width, this.y + obj.offset_y, image.width * obj.scale, image.height * obj.scale);

js/player/base.js

...
 render() {
        // this.ctx.fillStyle = this.color;
        // this.ctx.fillRect(this.x, this.y, this.width, this.height); //创建矩形
        let status = this.status;

        if (status === 1 && this.direction * this.vx < 0) { //如果当前方向和我们移动方向是不同方向的,就代表后退
            status = 2;
        }

        let obj = this.animations.get(status);
        //当gif被加载完毕才渲染
        if (obj && obj.loaded) {
            if (this.direction > 0) { //正方向
                let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;
                //if (status === 3) console.log(k, obj.frame_cnt); //用来调试跳跃的帧数
                let image = obj.gif.frames[k].image; //通过这个接口将GIF图片里的每一帧提取出来
                //将image画出来
                this.ctx.drawImage(image, this.x, this.y + obj.offset_y, image.width * obj.scale, image.height * obj.scale);
            } else { //反方向
                //canvas水平翻转
                this.ctx.save();
                this.ctx.scale(-1, 1); //让x * -1 ,y * 1 ,即将整个坐标系按y轴翻转
                this.ctx.translate(-this.root.game_map.$canvas.width(), 0);//将坐标系向负方向平移 

                let k = parseInt(this.frame_current_cnt / obj.frame_rate) % obj.frame_cnt;
                //if (status === 3) console.log(k, obj.frame_cnt); //用来调试跳跃的帧数
                let image = obj.gif.frames[k].image; //通过这个接口将GIF图片里的每一帧提取出来
                //将image画出来
                // console.log(this.root.game_map.$canvas.width())
                this.ctx.drawImage(image, this.root.game_map.$canvas.width() - this.x - this.width, this.y + obj.offset_y, image.width * obj.scale, image.height * obj.scale);
                this.ctx.restore();
            }
        }

        if (status === 4) //当攻击动作播放完后恢复为状态0
        {
            if (this.frame_current_cnt === obj.frame_rate * (obj.frame_cnt - 1)) //当前帧为该动作效果的最后一帧时
            {
                this.status = 0;

            }
            //console.log(this.status);
        }

        this.frame_current_cnt++;
    }

...

初步效果

初步效果.png

碰撞检测

如何判断拳头击打到对方?在游戏里如果是一个2d角色的话,我们一般用一个二维图形如矩形去代替角色;如果是一个3d角色的话,一般用一个三维图形如圆柱、球体去替代角色。这样的话,我们可以利用两个图形是否有交集去判断角色是否受到攻击。举个例子,当我们角色挥拳时,可以用一个小矩形代替角色的拳头,当这个小矩形与代表另一个角色的大矩形产生交集时,就表示当前我们的角色击中了对方,否则没有击中对方。这个过程也被叫做碰撞检测

我们先将代表角色的矩形方框渲染出来

碰撞检测1.png

角色攻击动作如下:

我们估算一下出拳的距离,将拳头渲染成矩形

碰撞检测2.png

对称的角色也需要将拳头要以角色为轴做轴对称渲染

 this.ctx.fillStyle = 'red';
 if (this.direction > 0) this.ctx.fillRect(this.x + this.width, this.y + 55, 130, 20);
 else this.ctx.fillRect(this.x - 130, this.y + 55, 130, 20);

如果这样设计的话,我们会诡异地发现当两个个角色靠太近的时候挥拳,拳头的红色方框会在对手人物框外面,这样就造成不了攻击了,不太符合人性化,毕竟贴脸输出居然还输出不了伤害。。因此,我们将出拳动作的红色拳头框区域覆盖到出拳动作的整个手臂,这样当对面角色碰到我们的手臂时,也判定为他会受到攻击。稍改一下:

kof手臂.png

更新攻击效果

我们在出拳动作中选取一个合适的帧数来判断当前攻击范围与对手所在的矩形框有无交集,这里选了第15帧。

因为两名玩家是对称的,所以他的攻击范围,也就是图上的两个红色小矩形的坐标需要分别判断。用红色小矩形的左上角坐标(x1,y1)与右下角坐标(x2,y2)定位整个矩形。

当存在交集时,将被攻击的那方的状态转换为状态5。

/js/player/base.js

 is_attack() {
        this.status = 5;
        this.frame_current_cnt = 0; //从第0帧开始渲染
 }   

update_attack() {
        if (this.status === 4 && this.frame_current_cnt === 15) { //在攻击动作的第15帧判断攻击的矩形与对手角色的矩形有无交集
            let me = this, you = this.root.players[1 - this.id];
            let r1;
            if (this.direction > 0) { //攻击范围的矩形坐标(红色)
                r1 = {
                    x1: me.x + me.width, //左上角坐标
                    y1: me.y + 55,
                    x2: me.x + me.width + 130, //右下角坐标
                    y2: me.y + 55 + 20,
                }
            }
            else {
                r1 = {
                    x1: me.x - 130,
                    y1: me.y + 55,
                    x2: me.x,
                    y2: me.y + 55 + 20,
                }
            }

            let r2 = { //对手角色的矩形坐标
                x1: you.x,
                y1: you.y,
                x2: you.x + you.width,
                y2: you.y + you.height,
            };
            if (this.is_collision(r1, r2)) { //有交集
                you.is_attack(); //转换为被击状态
            }
        }
    }

碰撞检测函数

如何判断两个矩形有没有交集?

只要水平方向与竖直方向都有交集,就代表两个矩形存在交集

  • 判断水平方向是否有交集

问题转化为求两个线性区域[a,b],[c,d]是否存在交集,即两个线段是否有一段是重合的.

判断条件:max(a,c) <= min(b,d)时,两个线性区域存在交集。

  • 判断竖直方向是否有交集

与上面同理

js is_collision(r1, r2) { if (Math.max(r1.x1, r2.x1) > Math.min(r1.x2, r2.x2)) return false; if (Math.max(r1.y1, r2.y1) > Math.min(r1.y2, r2.y2)) return false; return true; }

渲染受击效果

受击的动画展示效果和攻击的动画展示效果一样,都需要在被击中后转化为待机状态,即状态0。

render(){
    ...
         if (status === 4 || status === 5) //当攻击动作播放完,或被攻击动作播放完后恢复为状态0
        {
            if (this.frame_current_cnt === obj.frame_rate * (obj.frame_cnt - 1)) //当前帧为该动作效果的最后一帧时
            {
                this.status = 0;
            }
            //console.log(this.frame_current_cnt);
            //console.log(this.status);
        }
}

实现受击函数

添加角色血条

定义角色新属性:this.hp = 100,初始定义角色满血为100。

/js/player/base.js

is_attack() {
        if (this.status === 6) return;
        this.status = 5;
        this.frame_current_cnt = 0; //从第0帧开始渲染
        this.hp = Math.max(this.hp - 50, 0);

        if (this.hp <= 0) { //若血量为0
            this.status = 6; //更新为死亡状态
            this.frame_current_cnt = 0;
        }
    }

渲染死亡效果

注意,为了维持我们玩家的基本体面,避免死亡动画一直重复(死亡回放 悲),记得当角色死亡后,在render()里面只渲染死亡效果的最后一帧图片。实现方法很简单,当渲染到最后一张图片时将this.frame_current_cnt --,这样在render()函数最后再累加当前总帧数时,会抵消到这个减帧操作,就会一直将当前帧固定在死亡效果的最后一帧了。

再者,当角色死亡时,只需要静止不动,逝者安息即可,也不需要频频切换方向,因此在update_direction()里不需要再做对称方向判断,直接return;为了防止被鞭尸,还需要在is_attack()函数里直接return,表示死后不会再被攻击鞭尸了。

render() {
        if (status === 4 || status === 5 || status === 6) //当攻击动作播放完,或被攻击动作播放完后恢复为状态0
        {
            if (this.frame_current_cnt === obj.frame_rate * (obj.frame_cnt - 1)) //当前帧为该动作效果的最后一帧时
            {
                if (status === 6) this.frame_current_cnt--; //维持最后一帧死亡效果
                else this.status = 0;
            }
            //console.log(this.frame_current_cnt);
            //console.log(this.status);
        }
}

修改update_move

这里我们要修复前面一些小bug,我们更改设置为对所有状态都施加重力效果,但是只有在跳跃状态时,在落地的时候才会强制转换成待机状态,这样在空中被击中时就不会有诡异的卡顿现象了。

 update_move() {
        // if (this.status === 3) this.vy += this.gravity;
        this.vy += this.gravity;

        this.x += this.vx * this.timeDelta / 1000; //路程=速度*时间,timeDelta单位是毫秒,所以要 / 1000
        this.y += this.vy * this.timeDelta / 1000;


        if (this.y > 410) {  //到达地面后转换为状态0
            this.vy = 0;
            this.y = 410;
            if (this.status === 3) this.status = 0;
        }

        //防越界
        // console.log(this.x, this.root.game_map.$canvas.width(), this.width);

        if (this.x < 0) this.x = 0;
        else if (this.x + this.width >= this.root.game_map.$canvas.width()) {
            this.x = this.root.game_map.$canvas.width() - this.width;
        }
    }

添加拓展*

这里角色的移动逻辑,能不能重叠,或者能不能推着人走,可以按照自己的逻辑与喜好调整。

添加页面血条与计时器

一些不需要每秒渲染60次的东西,可以直接写在html里,防止浪费系统资源。

为了统一规范,我选择在js文件中添加html元素

/js/base.js中我们可以创建html元素并把他appendid=kof的类里面

class KOF {
    constructor(id) {
        this.$kof = $('#' + id);
        this.game_map = new GameMap(this);
        this.$head = $(`<div class="kof-head">
                       <div class="kof-head-hp-0"></div>
                       <div class="kof-head-timer">60</div>
                       <div class="kof-head-hp-1"></div>
                    </div>`);
        this.$kof.append(this.$head);

        this.players = [
            new Kyo(this, {
                id: 0,
                x: 290,
                y: 0,
                width: 150,
                height: 250,
                color: 'blue',
            }),
            new Kyo(this, {
                id: 1,
                x: 1010,
                y: 0,
                width: 150,
                height: 250,
                color: 'orange',
            }),
        ];

        // this.test = new Player(this, {});

    }
}

接着就是一些css样式设计了,可以根据自己喜欢的样式进行个性化设计,这里就不展示代码了。

样式如下:

hp.png

设计血条按照百分比缩减

我们要在血条的div下再创建一个div以控制血条的缩减,实现攻击扣血效果,这个效果可以用width的百分比占比实现。

并且按照一般格斗游戏的经验,血条扣血方向都是从两边往中间扣的,因此要特别设计左边血条中的浮动方向float: right这样当改变它的百分比时,就是从右往左占比的了。

#kof > .kof-head > .kof-head-hp-0 > div {
    width: 80%;
    height: 100%;
    background-color: lightgreen;
    float: right;
}

#kof > .kof-head > .kof-head-hp-1 > div {
    width: 70%;
    height: 100%;
    background-color: lightgreen;
}

控制血条变化

Player类中新增一个属性将控制血条变化的div找到:

this.$hp = this.root.$kof.find(`.kof-head-hp-${this.id} > div`);

当我们被攻击掉血后就改变血条长度.

/js/player/base.js

 is_attack() {
        if (this.status === 6) return;
        this.status = 5;
        this.frame_current_cnt = 0; //从第0帧开始渲染
        this.hp = Math.max(this.hp - 10, 0);

        //根据父元素来改变百分比宽度
        this.$hp.width(this.$hp.parent().width() * this.hp / 100);

        if (this.hp <= 0) {
            this.status = 6; //更新为死亡状态
            this.frame_current_cnt = 0;
            this.vx = 0;
        }
    }

实现血条的渐变效果

实现扣血效果的渐变动画,直接改成

 this.$hp.animate({
            width: this.$hp.parent().width() * this.hp / 100
        },300);

这里其实也可以设计很多特效,比如说血条拖影的效果,我们可以在div里再套一个div,通过两个div动画变化的时间不一样,颜色不一样,可以产生一种拖影的感觉。

这里属于是发挥自己的想象力了(笑

我们设置最里面那层div的背景为浅绿色,靠外面那层div设置为红色

动画设置的时候先变里面的div,后变外面的div

里面的div变得快一点,后面的变得慢一点。

    this.$hp = this.root.$kof.find(`.kof-head-hp-${this.id} > div`); //外面的div
    this.$hp_div = this.$hp.find('div'); //里面的div
    ...
    is_attack() {
        if (this.status === 6) return;
        this.status = 5;
        this.frame_current_cnt = 0; //从第0帧开始渲染
        this.hp = Math.max(this.hp - 10, 0);

        this.$hp_div.animate({
            width: this.$hp.parent().width() * this.hp / 100
        }, 300);

        this.$hp.animate({
            width: this.$hp.parent().width() * this.hp / 100
        }, 800);
        //根据父元素来改变百分比宽度
        //this.$hp.width(this.$hp.parent().width() * this.hp / 100);

        if (this.hp <= 0) {
            this.status = 6; //更新为死亡状态
            this.frame_current_cnt = 0;
            this.vx = 0;
        }
    }

设置时间

先将渲染时间的html标签提取出来

 this.$timer = this.$kof.find(`.kof-head-timer`);

这里我是把它放在KOF类(/js/base.js)里提取的,其实放在哪里提取都是一样的,自己记得在哪提的就行。

然后我们在GameMap类里将时间渲染进这个html标签里

具体逻辑如下:

定义新属性: this.time_left = 6000; // 单位为毫秒
...    
update() {
        this.time_left -= this.timeDelta;
        if (this.time_left < 0) this.time_left = 0;

        this.root.$timer.text(parseInt(this.time_left / 1000)); //转化为秒

        this.render();
    }



yume
7天前

django中channel模块之websocket
Django使用Channels实现WebSocket消息通知功能
asgi.png
ASGI 中,将一个网络请求划分成三个处理层面,最前面的一层,interface server(协议处理服务器),负责对请求协议进行解析,并将不同的协议分发到不同的 Channel(频道);频道属于第二层,通常可以是一个队列系统。频道绑定了第三层的 Consumer(消费者)。

django channel 的基本使用和概念解析




yume
11天前

实战项目——计算器

项目结构

calculator结构.png

设置路由

app.jsx:

import React, { Component } from 'react';
import NavBar from './navbar';
import {Routes, Route, Navigate} from 'react-router-dom'
import Calculator from './content/calculator';
import Home from './content/home';
import Login from './content/login';
import Register from './content/register';
import NotFound from './content/notFound';


class App extends Component {
    state = {  } 
    render() { 
        return (
            <React.Fragment>
                <NavBar />
                <div className="container">
                    <Routes>
                        <Route path='/calculator' element={<Calculator />} />
                        <Route path='/' element={<Home />} />
                        <Route path='/login' element={<Login />} />
                        <Route path='/register' element={<Register />} />
                        <Route path='/404' element={<NotFound />} />
                        <Route path="*" element={ <Navigate replace to="/404" /> } />
                    </Routes>
                </div>
            </React.Fragment>
        );
    }
}

export default App;

设计导航栏

navbar.jsx>: 记得页面跳转的时候,用Link改成前端渲染

import React, { Component } from 'react';
import {Link} from 'react-router-dom'

class NavBar extends Component {
    state = {  } 
    render() { 
        return (
        <nav className="navbar navbar-dark bg-dark">
            <div className="container">
                <Link className="navbar-brand" to="/">Web App</Link>
                <button className="navbar-toggler" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasDarkNavbar" aria-controls="offcanvasDarkNavbar">
                <span className="navbar-toggler-icon"></span>
                </button>
                <div className="offcanvas offcanvas-end text-bg-dark" tabIndex="-1" id="offcanvasDarkNavbar" aria-labelledby="offcanvasDarkNavbarLabel">
                    <div className="offcanvas-header">
                        <h5 className="offcanvas-title" id="offcanvasDarkNavbarLabel">Web App</h5>
                        <button type="button" className="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
                    </div>
                    <div className="offcanvas-body">
                        <ul className="navbar-nav justify-content-end flex-grow-1 pe-3">
                        <li className="nav-item">
                            <Link className="nav-link active" aria-current="page" to="/home">Home</Link>
                            <Link className="nav-link active" aria-current="page" to="/login">login</Link>
                            <Link className="nav-link active" aria-current="page" to="/register">register</Link>
                        </li>
                        <li className="nav-item dropdown">
                            <a className="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
                            APP
                            </a>
                            <ul className="dropdown-menu dropdown-menu-dark">
                                <li><Link className="dropdown-item" to="/calculator">Calculator</Link></li>
                            </ul>
                        </li>
                        </ul>
                        <form className="d-flex mt-3" role="search">
                        <input className="form-control me-2" type="search" placeholder="Search" aria-label="Search" />
                        <button className="btn btn-success" type="submit">Search</button>
                        </form>
                    </div>
                </div>
            </div>
        </nav>
        );
    }
}

export default NavBar;

card

每个页面用一个card来把页面框柱,可以使得页面更加好看。

新建一个base.jsx存储card格式,之后在每个页面引用<Base> 页面内容<Base>即可实现统一化card布局,在base.jsx里通过this.props.children<Base>标签里的子元素全部渲染出来

base.jsx:

import React, { Component } from 'react';

class Base extends Component {
    state = {  } 
    render() { 
        console.log(this.props);
        return (
            <div className="card" style={{marginTop: "20px", backgroundColor: 'lightgray'}}>
                <div className="card-body">
                    {this.props.children}
                </div>
            </div>
        );
    }
}

export default Base;

计算器布局

Grid布局

Gird布局详解

grid1.png

output是现示输出数据的,应该独占一行,后面的都是计算器的按键。实际上项目计算器加上output一共有六行,并要给第一行的output设定一个最小与最大高度minmax(6rem, auto):表示第一行的最小高度为6rem,最大高度为自适应。给每个按键设置一个gap: 1px的间隔,让外表好看点。

calculator.jsx

import React, { Component } from 'react';
import Base from './base';

class Calculator extends Component {
    state = {  } 
    render() { 
        return (
            <Base>
                <div className="calculator">
                    <div className="output">
                        <div className="last-output">123 </div>
                        <div className="current-output">99999999999</div>
                    </div>

                    <button>AC</button>
                    <button>Del</button>
                    <button>x^2</button>
                    <button>/</button>
                    <button>7</button>
                    <button>8</button>
                    <button>9</button>
                    <button>*</button>
                    <button>4</button>
                    <button>5</button>
                    <button>6</button>
                    <button>-</button>
                    <button>1</button>
                    <button>2</button>
                    <button>3</button>
                    <button>+</button>
                    <button>+/-</button>
                    <button>0</button>
                    <button>.</button>
                    <button>=</button>

                </div>
            </Base>
        );
    }
}

export default Calculator;

计算器的CSS样式设计:

body {
    margin: 0;
}

* {
    box-sizing: border-box;
}

.calculator {
    display: grid; /*grid布局*/
    grid-template-columns: repeat(4, 6rem); /*一行有多少列,每列长度是多少,repeat(n,p)把p重复n遍*/
    grid-template-rows: minmax(6rem, auto) repeat(5, 4rem); /*第一行定个最小高度*/
    gap: 1px;
    background-color: rgba(237, 237, 237, 0.75);
    width: calc(24rem + 3px);
    margin: auto;
}

.output {
    grid-column: 1 / span 4; /*从第一个格子开始独占4个格子*/
    border: 2px solid black;
    display: flex;
    flex-direction: column; /*竖轴为主轴*/
    align-items: flex-end; /*向右对齐*/
    justify-content: space-around; /*在每行上均匀分配弹性元素*/
    padding: 10px;
    /*让输入/输出的数如果超过长度就打断放到下一行:*/
    word-wrap: break-word;
    word-break: break-all;
}

.last-output {
}

.current-output {
    font-size: 3rem;
}

.calculator > button {
    background-color: rgba(246, 246, 246, 0.75);
}

.calculator > button:hover {
    background-color: #b5b5b5;
}

设计计算器逻辑

计算器需要维护的状态:

  1. 当前的输入数据current-operand
  2. 前一步的输入数据last-operand
  3. 运算符operation

计算器的功能操作:

  1. add digit 增加一位数字
  2. delete digit 删除一位数字
  3. choose operation 选择运算规则
  4. clear 清除屏幕数字
  5. evaluate计算结果

我们可以使用redux存储三个状态以便维护。

定义redux

redux结构

redux1.png

action.js

const ACTIONS = {
    Add_digit: "add-digit",
    Delete_digit: "delete-digit",
    Choose_operation: "choose-operation",
    Clear: "clear",
    Evaluate: "evaluate",
}

export default ACTIONS;

reducer.js

import ACTIONS from "./action";

const reducer = (state = {
    currentOperand: "",
    lastOperand: "",
    operation: "",
}, action) => {
    switch (action.type) {
        default:
            return state;

    }

}

export default reducer;

store.js

import { configureStore } from "@reduxjs/toolkit";
import reducer from "./reducer";

const store = configureStore({
    reducer, //实际上是reducer: reducer,因为Key和value一致时可以只写一个就好了
})

export default store;
index.js中引入store

index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import 'bootstrap/dist/css/bootstrap.css';
import 'bootstrap/dist/js/bootstrap.js'
import { BrowserRouter } from 'react-router-dom';
import App from './components/app';
import { Provider } from 'react-redux';
import store from './redux/store';


const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <Provider store={store}>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </Provider>
);

创建计算器功能相关的组件

实现add digit功能

/src/component/目录下新建calculator文件夹,存放功能组件digitButton.jsx,此组件将替换掉之前静态页面中的<button>数字<button>按钮,因此,该组件作为calculator.jsx中的子组件,在父组件中向子组件传递对应的digit数据,子组件digitButton.jsx中可以通过this.props.digit调用该digit数据,实现点击计算器上的数字Button,可以在输出屏幕上添加数据。

这里用this.props.digit传送每个按钮对应的数据,还需要绑定一个dispatch进行状态的更新。

digitButton.jsx

import React, { Component } from 'react';
import ACTIONS from './../../../redux/action';
import {connect} from 'react-redux';

class DigitButton extends Component {
    state = {  } 



    render() { 
        return (<button onClick={() => this.props.add_digit(this.props.digit)}>{this.props.digit}</button>);
    }
}

const mapDispatchToProps = {
    add_digit: digit => {
        return {
            type: ACTIONS.Add_digit,
            digit: digit,
        }
    }
}

export default connect(null,mapDispatchToProps)(DigitButton); //第一个参数是把state绑定到当前组件的props中,这里不需要

calculator.jsx

这里需要访问redux中维护的全局数据 ,因此要绑定一个mapStateToProps,以实时访问storestate的数据

 import React, { Component } from 'react';
import Base from './base';
import {connect} from 'react-redux'
import DigitButton from './calculator/digitButton';

class Calculator extends Component {
    state = {  } 
    render() { 
        return (
            <Base>
                <div className="calculator">
                    <div className="output">
                        <div className="last-output">
                            {this.props.lastOperand} {this.props.operation}
                        </div>
                        <div className="current-output">
                            {this.props.currentOperand}
                        </div>
                    </div>
                    <button>CE</button>
                    <button>Del</button>
                    <button>x^2</button>
                    <button>÷</button>
                    <DigitButton digit={"7"}></DigitButton>
                    <DigitButton digit={"8"}></DigitButton>
                    <DigitButton digit={"9"}></DigitButton>
                    <button>×</button>
                    <DigitButton digit={"4"}></DigitButton>
                    <DigitButton digit={"5"}></DigitButton>
                    <DigitButton digit={"6"}></DigitButton>
                    <button>-</button>
                    <DigitButton digit={"1"}></DigitButton>
                    <DigitButton digit={"2"}></DigitButton>
                    <DigitButton digit={"3"}></DigitButton>
                    <button>+</button>
                    <button>+/-</button>
                    <DigitButton digit={"0"}></DigitButton>
                    <DigitButton digit={'.'}></DigitButton>
                    <button>=</button> 
                </div>
            </Base>
        );
    }
}

const mapStateToProps = (state,props) => {
    return {
        currentOperand: state.currentOperand,
        lastOperand: state.lastOperand,
        operation: state.operation,
    }

}

export default connect(mapStateToProps)(Calculator);

reducer.jsx中完善相应的reducer函数,实现功能,注意特判:

import ACTIONS from "./action";

const reducer = (state = {
    currentOperand: "",
    lastOperand: "",
    operation: "",
}, action) => {
    switch (action.type) {
        case ACTIONS.Add_digit:
            if (state.currentOperand === "0" && action.digit === '0') return state; /*当前为0再按0的话就不显示多余的0*/
            if (state.currentOperand === "0" && action.digit !== '.') return { /*点击按钮前是0,再输入不是.的其他数据,则将前置0去掉*/
                ...state,
                currentOperand: action.digit
            }
            //str.includes('.'):判断str是否包含'.' 
            if (action.digit === '.' && state.currentOperand.includes('.')) { /*当前是.且前面已经有'.'的话则不能再输入'.' */
                return state;
            }
            //点击'.'时,若前面为空,则在前面添加一个0
            if (action.digit === '.' && state.currentOperand === "")
                return {
                    ...state,
                    currentOperand: "0" + action.digit,
                }
            return {
                ...state,
                currentOperand: state.currentOperand + action.digit,
            }
        default:
            return state;

    }

}

export default reducer;
实现delete digit功能

calculator.jsx中绑定一个mapDispatchToProps以更新dispatch。在DEL按钮中绑定这个函数。

...
  <button onClick={this.props.delete_digit}>Del</button>
...

const mapDispatchToProps = {
    delete_digit: () => {
        return {
            type: ACTIONS.Delete_digit,
        }
    }
}

export default connect(mapStateToProps,mapDispatchToProps)(Calculator);

reducer.jsx中完善删除功能:

slicesplice的区别

...  
case ACTIONS.Delete_digit:
            if (state.currentOperand === "") return state; //若已经是空的话就不需要再删了
            return {
                ...state,
                currentOperand: state.currentOperand.slice(0, -1), //截取从索引0开始到索引的最后一项(不包括最后一项)的字符串,slice不改变原字符串
            }
...
实现choose operation功能

add digit一样,我们也在calculator.jsx中将所有的功能键<button>替换成一个功能组件<OperationButton>,定义在operationButton.jsx中,作为calculator.jsxCalculator组件的子组件,通过父组件向其传递operation={'操作符'}属性,operationButton.jsx中可以用this.props.operation调用传递过来的属性值。

同样的,operationButton.jsx中也要定义一个组件的行为mapDispatchToProps,绑定到dispatch()中。

operationButton.jsx

import React, { Component } from 'react';
import {connect} from 'react-redux'
import ACTIONS from '../../../redux/action';

class OperationButton extends Component {
    state = {  } 
    render() { 
        return (
            <button onClick={() => {this.props.choose_operation(this.props.operation)}}>{this.props.operation}</button>
        );
    }
}

const mapDispatchToProps = {
    choose_operation: operation => {
        return {
            type: ACTIONS.Choose_operation,
            operation: operation,
        }
    }

}

export default connect(null,mapDispatchToProps)(OperationButton);

reducer.jsx中完成相应的操作(注意一堆特判)

const evaluate = state => {
    let { lastOperand: last, currentOperand: current, operation } = state;
    last = parseFloat(last);
    current = parseFloat(current);
    let ans = "";
    switch (operation) {
        case '+':
            ans = last + current;
            break;
        case '-':
            ans = last - current;
            break;
        case '×':
            ans = last * current;
            break;
        case '÷':
            if (current === 0) ans = "除数不能为0"
            else ans = last / current;
            break;
    }
    return ans.toString();
}
...
case ACTIONS.Choose_operation:
            if (action.operation === 'CE' || state.currentOperand === "除数不能为0")
                return {
                    lastOperand: "",
                    currentOperand: "",
                    operation: "",

                }
            if (state.lastOperand === "" && state.currentOperand === "") { //当都没有数据时,点击操作符都不会有翻译
                return state;
            }
            if (state.lastOperand === "") { //当只有上面为空,下面不为空时
                if (action.operation === 'sqr') {
                    return {
                        ...state,
                        lastOperand: (parseFloat(state.currentOperand) * parseFloat(state.currentOperand)).toString(),
                        currentOperand: "",
                    }
                }
                return {
                    ...state,
                    lastOperand: state.currentOperand,
                    operation: action.operation,
                    currentOperand: "",
                }

            }
            if (state.currentOperand === "") { //当下面是空的时候,直接将运算符替换掉
                return {
                    ...state,
                    operation: action.operation,
                }
            }
            let ans = evaluate(state);

            if (action.operation === "sqr") {
                return {
                    ...state,
                    lastOperand: ans,

                }
            }
            if (ans === "除数不能为0")
                return {
                    ...state,
                    currentOperand: "除数不能为0",
                }
            else
                return {
                    ...state,
                    lastOperand: ans,
                    operation: action.operation,
                    currentOperand: "",
                }

        default:
            return state;
...
实现evaluate 功能

实现按=可以显示计算结果。

过程和前面类似,只是逻辑不同。

calculator.jsx

 <button onClick={this.props.evaluate}>=</button>
...
const mapDispatchToProps = {
    delete_digit: () => {
        return {
            type: ACTIONS.Delete_digit,
        }
    },
    evaluate: () => {
        return {
            type: ACTIONS.Evaluate,
        }
    }
}

...

reducer.jsx中完成对应的操作,

添加一个state属性overwrite表示按下=后再次按下数字键是否需要将结果覆盖掉,在之前的ACTION.type里面分别修改。还有一些细枝末节的东西,看看代码就好了

...
const reducer = (state = {
    currentOperand: "",
    lastOperand: "",
    operation: "",
    overwrite: false, //是否要将结果覆盖掉
}, action) => {
    switch (action.type) {
        case ACTIONS.Add_digit:
            if (state.overwrite) {
                if (state.lastOperand === '')
                    return {
                        ...state,
                        overwrite: false,
                        currentOperand: "",
                    }
                else {
                    return {
                        ...state,
                        overwrite: false,
                        currentOperand: action.digit,
                    }
                }
            }
            if (state.currentOperand === "除数不能为0")
                return {
                    ...state,
                    currentOperand: action.digit,
                }
            if (state.currentOperand === "0" && action.digit === '0') return state; /*当前为0再按0的话就不显示多余的0*/
            if (state.currentOperand === "0" && action.digit !== '.') return { /*点击按钮前是0,再输入不是.的其他数据,则将前置0去掉*/
                ...state,
                currentOperand: action.digit
            }
            //str.includes('.'):判断str是否包含'.' 
            if (action.digit === '.' && state.currentOperand.includes('.')) { /*当前是.且前面已经有'.'的话则不能再输入'.' */
                return state;
            }
            //点击'.'时,若前面为空,则在前面添加一个0
            if (action.digit === '.' && state.currentOperand === "")
                return {
                    ...state,
                    currentOperand: "0" + action.digit,
                }
            return {
                ...state,
                currentOperand: state.currentOperand + action.digit,
            }
        case ACTIONS.Delete_digit:
            if (state.overwrite)
                return {
                    ...state,
                    currentOperand: "",
                    overwrite: false,
                }
            if (state.currentOperand === "除数不能为0")
                return {
                    lastOperand: "",
                    currentOperand: "",
                    operation: "",
                }
            if (state.currentOperand === "") return state; //若已经是空的话就不需要再删了
            return {
                ...state,
                currentOperand: state.currentOperand.slice(0, -1), //截取从索引0开始到索引的最后一项(不包括最后一项)的字符串,slice不改变原字符串
            }
        case ACTIONS.Choose_operation:
            if (action.operation === 'CE' || state.currentOperand === "除数不能为0")
                return {
                    lastOperand: "",
                    currentOperand: "",
                    operation: "",

                }
            if (state.lastOperand === "" && state.currentOperand === "") { //当都没有数据时,点击操作符都不会有翻译
                return state;
            }
            if (state.lastOperand === "") { //当只有上面为空,下面不为空时
                if (action.operation === 'sqr') {
                    return {
                        ...state,
                        lastOperand: (parseFloat(state.currentOperand) * parseFloat(state.currentOperand)).toString(),
                        currentOperand: "",
                    }
                }
                return {
                    ...state,
                    lastOperand: state.currentOperand,
                    operation: action.operation,
                    currentOperand: "",
                }

            }
            if (state.currentOperand === "") { //当下面是空的时候,直接将运算符替换掉
                return {
                    ...state,
                    operation: action.operation,
                }
            }
            let ans = evaluate(state);

            if (action.operation === "sqr") {
                return {
                    ...state,
                    lastOperand: ans,

                }
            }
            if (ans === "除数不能为0")
                return {
                    ...state,
                    currentOperand: "除数不能为0",
                }
            else
                return {
                    ...state,
                    lastOperand: ans,
                    operation: action.operation,
                    currentOperand: "",
                }
        case ACTIONS.Evaluate:
            if (state.currentOperand === '' || state.lastOperand === '' || state.operation === '') return state;
            return {
                ...state,
                currentOperand: evaluate(state),
                lastOperand: "",
                operation: "",
                overwrite: true,
            }

        default:
            return state;

    }

}


...
将数据格式化

calculator.jsxstate中添加格式化属性formater: Intl.NumberFormat('en-us')

其中的formater.format可以将我们的数字变得格式化,即每三位数用,隔开,

同时,为了让我们浮点数仍然保留相对应的小数而不会被格式化掉,我们自己手写一个格式化函数,来调整我们的格式。

calculator.jsx

...
 state = { 
        formater: Intl.NumberFormat('en-us')
     } 

      format(number) {
        if (number === "除数不能为0") return number;
        const [integer,decimal] = number.split('.');
        if (decimal === undefined) return this.state.formater.format(integer);
        return `${this.state.formater.format(integer)}.${decimal}`;
     }

  render() {
    ....
                    <div className="output">
                        <div className="last-output">
                            {this.format(this.props.lastOperand)} {this.props.operation}
                        </div>
                        <div className="current-output">
                            {this.format(this.props.currentOperand)}
                        </div>
                    </div>
  }

计算器项目结果

calc.png

实现登录与注册功能(后端)

我们这里的后端用Django实现,实现的过程中遇到好多坑,要解决跨域的问题,具体参考下面的文章:

Django解决跨域问题

更新:完善了JWT认证,使得跨域也可以记录登录状态了!!

并且通过JWT不再需要手写loginlogout了,只需要将token获取与删除即可。具体参考我的另一篇题解

为了偷懒,下面的登录后端,依旧是传统的Django自带的session登录方式,并不能实现跨域记录登录状态,因此,凑合着看就好了QAQ

一些用到的API

login

登录接口:https://app165.acapp.acwing.com.cn/calculator/login/

输入参数: usernamepassword

输出参数: result

  • result = "success": 表示登录成功
  • result = "用户名或密码不正确": 表示错误信息
logout

退出接口:https://app165.acapp.acwing.com.cn/calculator/logout/

输入参数:无

输出参数:result

  • result = "success": 表示退出成功
register

注册接口:https://app165.acapp.acwing.com.cn/calculator/register/

输入参数:usernamepasswordpassword_confirm

输出参数:result

  • result = "success":表示注册成功
  • result = "用户名和密码不能为空":表示错误信息
  • result = "两个密码不一致":表示错误信息
  • result = "用户名已存在":表示错误信息
get_status

查询登录状态,如果已登录,则返回用户名:

https://app165.acapp.acwing.com.cn/calculator/get_status/

输入参数:无

输出参数:resultusername

  • result = "login",此时有返回值useranme,表示已登录的用户名
  • result = "logout",此时username不存在,表示未登录

后端结构

calc_backend.png

设置后端路由

urls.py

from django.contrib import admin
from django.urls import path,include

urlpatterns = [
    path('admin/', admin.site.urls),
    path("/",include('backend.calculator.index'))
]

login API

calculator/login.py

from django.contrib.auth import authenticate, login
from django.http import JsonResponse


def signin(request):
    data = request.GET
    username = data.get('username')
    password = data.get('password')
    user = authenticate(username=username, password=password)
    #print(user)
    if not user:
        return JsonResponse({
            'result': "Password or username incorrect"
        })
    login(request, user)

    return JsonResponse({
        'result': "success",
        "is_login": "true",
    })


logout API

calculator/logout.py

from django.http import JsonResponse
from django.contrib.auth import logout


def signout(request):
    user = request.user
    if not user.is_authenticated:
        return JsonResponse({
            'result': "success",
        })
    logout(request)  # 从request中把cookie删掉
    return JsonResponse({
        'result': "success",
    })

register API

calculator/register.py

from django.http import JsonResponse
from django.contrib.auth import login
from django.contrib.auth.models import User
#from backend.models.player import Player


def signup(request):
    data = request.GET
    username = data.get('username', "").strip()  # 获取用户名,如果没有的话返回空,并把前后空格去掉
    password = data.get('password', "").strip()
    password_confirm = data.get('password_confirm', "").strip()

    if not username or not password:
        return JsonResponse({
            'result': '用户名或密码不能为空',
        })
    if password != password_confirm:
        return JsonResponse({
            'result': '两次密码不一致',
        })
    if User.objects.filter(username=username).exists():
        return JsonResponse({
            'result': '用户名已存在',
        })

    user = User(username=username)
    user.set_password(password)
    user.save()
    #Player.objects.create(user=user, photo="https://cdn-userpic.codeforces.com/486565/avatar/7004b8994370c323.jpg")
    login(request, user)
    return JsonResponse({
        'result': "success",
    })

get_status API

from django.http import JsonResponse

#判断是否登录成功
def get_status(request):
    user = request.user
    if user.is_authenticated:
        return JsonResponse({
            'result': "login",
            'username': user.username
        })
    return JsonResponse({
        'result': "logout"
    })

集成路由

calculator/index.py

from django.urls import path
from backend.calculator.login import signin
from backend.calculator.logout import signout
from backend.calculator.register import signup

urlpatterns = [
    path("login/", signin, name="calculator_login"),
    path("logout/", signout, name="calculator_logout"),
    path("register/", signup, name="calculator_register"),
    path("get_status/", get_status,name="calculator_get_status"), #name是在后端渲染有用,前端渲染用不到
]

实现登录注册功能(前端)

安装jQuery

我们需要ajax去获取后端的数据,因此要先安装jQuery:

npm i jquery

装完之后重新启动一次项目:npm start

完善登录页面

state里面维护三个值:

error_mesage: 登录失败的提示信息;

username:用户名,绑定在用户输入的表单username

password:密码,绑定在用户输入的表单password;

当用户在登录窗口上输入用户名与密码时,要相应地与this.state.usernamethis.state.password进行绑定更新。

这里用到JS中的表单的事件change,捕捉我们的表单是否发生变化,若发生变化则触发相应的函数,用this.setState{{username: e.target.value}}更新维护相对应的用户名。密码的维护同上。

react中使用onChange{}来实现change事件

target 事件属性可返回事件的目标节点(触发该事件的节点),如生成事件的元素、文档或窗口。

tar = event.target则有

  • 获取文本内容:tar.textContent

  • 获取父级节点:tar.parentNode

  • 获取节点名称:tar.nodeName
    需要注意,使用target获取到的节点名称全部为大写

一些相关网站:

traget1

target2

当点击sign in注册按钮时,用onClick{}绑定一个提交函数。为了方便我们后面用ajax提交,所以要在提交函数里面,把默认提交行为给组织掉:e.preventDefault();

引入ajax: import $ from 'jquery'

components/content/login.jsx:

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

class Login extends Component {
    state = { 
        error_message: "",
        username: "",
        password: "",
     };

     handleClick = e => {
        e.preventDefault();
        if (this.state.username === "") {
            this.setState({error_message: "用户名不能为空"});
        } else if (this.state.password === "") {
            this.setState({error_message: "密码不能为空"});
        } else {
            $.ajax({
                url: "http://127.0.0.1:8000/login/",
                type: "get",
                data: {
                    username: this.state.username,
                    password: this.state.password,
                },
                dataType: 'json',
                success: (resp) => {
                    console.log(resp);
                    if (resp.result === "success") {
                        //js重定向,登陆成功后直接跳转到计算器页面
                        window.location.href="/calculator";
                    } else {
                        this.setState({error_message: resp.result});
                    }
                },
                error() {
                    this.setState({error_message: "系统获取数据失败QAQ"});
                }
            })

        }
        console.log(this.state);
     }

    render() { 
        return (
            <div className="homeBox">
                <div className="box-container">
                    <div className="login-box">
                        <div className="apple-btn login-apple">
                            <li className="red-btn"></li>
                            <li className="yellow-btn"></li>
                            <li className="green-btn"></li>
                        </div>
                        <div className="title">Login</div>
                        <form >
                            <div className="input">
                                <input onChange={(e) => {this.setState({username: e.target.value})}} type="text" id="login-user" placeholder="Input your username" />
                            </div>
                            <div className="input">
                                <input onChange={e => {this.setState({password: e.target.value})}} type="password" id="login-password" placeholder="Input your password" />
                                <div className='error-message' style={{height: "2rem"}} >
                                    {this.state.error_message}
                                 </div>
                            </div>
                            <button onClick={e => {this.handleClick(e)}} type="submit" className="btn login-btn">Sign in</button>
                        </form>
                        <div className="change-box login-change">
                            <div className="change-btn toSign">
                                <Link to={'/register'} style={{textDecoration: "none",color: "black"}}>
                                    <span>Register</span>
                                </Link>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        );
    }
}

export default Login;

登录页面效果

calc-login.png

完善注册页面

register.jsx:

import React, { Component } from 'react';
import $ from 'jquery'
import '../../register.css'
import { Link } from 'react-router-dom';


class Register extends Component {
    state = { 
        username: "",
        password: "",
        confirmed_password: "",
        error_message: "",
     };

     handleClick = e => {
        e.preventDefault();
        $.ajax({
            url: "http://127.0.0.1:8000/register/",
            type: "get",
            data: {
                username: this.state.username,
                password: this.state.password,
                password_confirm: this.state.confirmed_password,
            },
            dataType: 'json',
            success: resp => {
                if (resp.result === "success") {
                    window.location.href="/calculator";
                } else {
                       this.setState({error_message: resp.result});
                 }
            }
        });
        console.log(this.state);

     }

    render() { 
        return (
            <div className="homeBox">
                <div className="box-container">
                    <div className="sign-box">
                        <div className="apple-btn sign-apple">
                            <li className="red-btn"></li>
                            <li className="yellow-btn"></li>
                            <li className="green-btn"></li>
                        </div>
                        <div className="title">Sign</div>
                        <form>
                            <div className="input">
                                <input onChange={(e) => {this.setState({username: e.target.value})}} type="text" id="sign-user" placeholder="Have A Good Name?" />
                            </div>
                            <div className="input">
                                <input onChange={(e) => {this.setState({password: e.target.value})}}  type="password" id="sign-password" placeholder="Keep Secret" />
                            </div>
                            <div className="input">
                                <input onChange={(e) => {this.setState({confirmed_password: e.target.value})}} type="password" id="password_confirm" placeholder="Confirm Your Password" />
                                <div className='error-message' style={{height: "2rem"}} >
                                    {this.state.error_message}
                                 </div>
                            </div>
                            <button onClick={e => {this.handleClick(e)}} type="submit" className="btn sign-btn">Sign up</button>
                        </form>
                        <div className="change-box sign-change">
                            <div className="change-btn toLogin" onClick={this.gotoLogin}>
                                <Link to={'/login'} style={{textDecoration: "none",color: "white"}}>
                                    <span>Login</span>
                                </Link>
                            </div>
                        </div>
                    </div>
                </div>
          </div>
        );
    }
}

export default Register;

注册页面效果:

react-register.png

实现退出功能

前端需要特判下,如果我们没有登录的话就不显示计算器页面,登录成功后才显示计算器页面,同时显示一个用户名与退出按钮。

因此,我们可以在app.jsx里的state中加入一个属性is_login表示有没有登录成功,true表示已登录,false表示未登录;一个属性username,存放登录的用户名。

app.jsx中的子组件NavBar里传入is_loginusername参数,以实现上述特判功能。

app.jsx:

import React, { Component } from 'react';
import NavBar from './navbar';
import {Routes, Route, Navigate} from 'react-router-dom'
import Calculator from './content/calculator';
import Home from './content/home';
import Login from './content/login';
import Register from './content/register';
import NotFound from './content/notFound';
import $ from 'jquery'


class App extends Component {
    state = { 
        is_login: true,
        username: "",
     } 

     //动态获取当前登录状态
     //ajax一般在componentDidMount()里写,这个函数是在组件挂载完后执行
     componentDidMount() {
        $.ajax({
            url: "http://127.0.0.1:8000/get_status/",
            type: "get",
            success : resp => {
                console.log(resp);
                if (resp.result === "login") {
                    this.setState({
                        is_login: true,
                        username: resp.username,
                    })
                } else {
                    this.setState({
                        is_login: false,
                    })
                }
            }
        })
     }

    render() { 
        return (
            <React.Fragment>
                <NavBar is_login={this.state.is_login} username={this.state.username} />
                <div className="container">
                    <Routes>
                        <Route path='/calculator' element={ <Calculator />  } />
                        <Route path='/' element={  <Home />  } />
                        <Route path='/login' element={<Login />} />
                        <Route path='/register' element={ <Register />} />
                        {/* <Route path='/calculator' element={this.state.is_login ? <Calculator /> : <Navigate replace to="/login" /> } />
                        <Route path='/' element={ this.state.is_login ? <Home /> : <Navigate replace to="/login" /> } />
                        <Route path='/login' element={this.state.is_login ? <Navigate replace to="/" /> : <Login />} />
                        <Route path='/register' element={this.state.is_login ? <Navigate replace to="/" /> : <Register />} /> */}
                        <Route path='/404' element={<NotFound />} />
                        <Route path="*" element={ <Navigate replace to="/404" /> } />
                    </Routes>
                </div>
            </React.Fragment>
        );
    }
}

export default App;

navbar.jsx:

import React, { Component } from 'react';
import {Link} from 'react-router-dom'
import $ from 'jquery'

class NavBar extends Component {
    state = {  };

    handleClick = () => {
        $.ajax({
            url: "http://127.0.0.1:8000/logout/",
            type: "get",

            success: (resp) => {
                console.log(resp);
                if (resp.result === "success") {
                    window.location.href="/login";
                }
            }
        })
    }

    render_user = () => {
        if (this.props.is_login) {
            return (
                <li className="nav-item">
                    <Link className="nav-link active" aria-current="page" to="/">Home</Link>
                    <Link className="nav-link active" aria-current="page" to="#">{this.props.username}</Link>
                    <Link onClick={this.handleClick} className="nav-link active" aria-current="page" to="#">LOGOUT</Link>
                </li>
            )
        } else return (
            <li className="nav-item">
                <Link className="nav-link active" aria-current="page" to="/login">login</Link>
                <Link className="nav-link active" aria-current="page" to="/register">register</Link>
            </li>
        )
    }

    render_APP = () => { //是否需要渲染App栏目
        if (this.props.is_login) {
            return (
            <li className="nav-item dropdown">
                <a className="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
                APP
                </a>
                <ul className="dropdown-menu dropdown-menu-dark">
                    <li><Link className="dropdown-item" to="/calculator">Calculator</Link></li>
                </ul>
            </li>
            )
        }
        else return "";
    }

    render_photo() {
        if (this.props.is_login) {
            return (
                <img style={{margin: "10px auto", width: "300px", height:"300px"}} src="https://cdn.acwing.com/media/user/profile/photo/118375_lg_e2515ed3ad.jpg" alt="" />

            )
        }
        else return (
            <img style={{margin: "10px auto", width: "300px", height:"300px"}} src="https://avatars.githubusercontent.com/u/83831450?v=4" alt="" />
        )
    }

    render() { 
        return (
        <nav className="navbar navbar-dark bg-dark">
            <div className="container">
                <Link className="navbar-brand" to="/">Web App</Link>
                <button className="navbar-toggler" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasDarkNavbar" aria-controls="offcanvasDarkNavbar">
                <span className="navbar-toggler-icon"></span>
                </button>
                <div className="offcanvas offcanvas-end text-bg-dark" tabIndex="-1" id="offcanvasDarkNavbar" aria-labelledby="offcanvasDarkNavbarLabel">
                    <div className="offcanvas-header">
                        <h5 className="offcanvas-title" id="offcanvasDarkNavbarLabel">Web App</h5>
                        <button type="button" className="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
                    </div>
                    <div className="offcanvas-body">
                        <ul className="navbar-nav justify-content-end flex-grow-1 pe-3">
                            {this.render_user()}
                            {this.render_APP()}
                            {this.render_photo()}
                        </ul>
                    </div>
                </div>
            </div>
        </nav>
        );
    }
}

export default NavBar;



yume
11天前

Django Rest FrameworkJWT身份验证

理论部分

这个技术是用来解决跨域情况下的用户登录与维持登录状态的问题。即处理跨域之后的身份验证问题。

Django自带的auth登录验证系统只能应用与于前端页面与后端页面在同一个网址(域)的情况下,也就是说当前后端分离,后端只提供API给前端,前端通过API提供的数据对页面进行渲染或增加修改的情况下,这种默认的登录验证系统就不起效果了,因为HTTP是一种无状态的协议,也就是说后端服务并不知道是谁发来的请求,我们也就无法验证请求的合法性。我们一般写前后端分离的项目时,都是用ajax向服务器端发起请求以获取后端API的数据,这种方式是无法获取从服务器端返回回来的sessionid的,从而就难以做身份验证。简单来说,就是传统的登录鉴权在前后端分离的情况下,无法将后端的sessionid存到前端对应域名的cookie里,因此无法做前后端分离的身份验证,cookie无法做跨域。

传统的登录鉴权与基于Token的鉴权区别

先来看看传统的登录鉴权跟基于Token的鉴权有什么区别:

Django的账号密码登录为例来说明传统的验证鉴权方式是怎么工作的,当我们登录页面输入账号密码提交表单后,会发送请求给服务器,服务器对发送过来的账号密码进行验证鉴权,验证鉴权通过后,把用户信息记录在服务器端(django_session表中),同时返回给浏览器一个sessionid用来唯一标识这个用户,浏览器将sessionid保存在cookie中(它是属于http-only的,是不能用js读取到的),之后浏览器的每次请求都一并将sessionid发送给服务器,服务器根据sessionid与记录的信息做对比以验证身份。

Token的鉴权方式就清晰很多了,客户端用自己的账号密码进行登录,服务端验证鉴权,验证鉴权通过生成Token返回给客户端,之后客户端每次请求都将Token放在header里一并发送,服务端收到请求时校验Token以确定访问者身份。

session的主要目的是给无状态的HTTP协议添加状态保持,通常在浏览器作为客户端的情况下比较通用。而Token的主要目的是为了鉴权,同时又不需要考虑CSRF防护以及跨域的问题,所以更多的用在专门给第三方提供API的情况下,客户端请求无论是浏览器发起还是其他的程序发起都能很好的支持。所以目前基于Token的鉴权机制几乎已经成了前后端分离架构或者对外提供API访问的鉴权标准,得到广泛使用。

传统的用户登录认证中,因为http是无状态的,所以都是采用session的认证方式。用户登录成功,服务端就会保存一个session,也会给客户端一个sessionID,以后客户端每次请求,都会携带这个sessionID,服务端会会根据这个sessionID来区分不同的用户。

这种基于cookie+session的认证方式,随着服务从单服务到多服务,缺点就出来了,因为session是存储在服务端,这样服务器的开销就会大起来了。并且session标示丢失,就可能出现安全问题CSRF(跨站请求伪造)。

扩展性: 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。

Token字面意思是令牌,功能跟Session类似,也是用于验证用户信息的,Token是服务端生成的一串字符串,当客户端发送登录请求时,服务器便会生成一个Token并将此Token返回给客户端,作为客户端进行请求的一个标识,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。session的不同之处在于session是将用户信息存储在服务器中保持用户的请求状态,而Token在服务器端不需要存储用户的登录记录,客户端每次向服务端发送请求的时候都会带上服务端发给的Token,服务端收到请求后去验证客户端请求里面带着Token,如果验证成功,就向客户端返回请求的数据。

一些参考文章:

Django-rest-frameworkJWT登录和认证

cookiesession的区别

Django解决跨域问题

JSON Web Token 入门教程

基于Token 的身份验证流程

  • 客户端使用用户名跟密码请求登录
  • 服务端收到请求开始验证用户名与密码
  • 验证成功后,服务端生成一个 Token(可以理解为一种加密算法,一般为有效信息 + 一个加密字符串)并把这个 Token发送给客户端
  • 客户端收到 Token 以后可以把它存储起来,可以存放在Cookie里或者Local Storage
  • 客户端再次向服务端请求资形式源的时候携带服务端生成的 Token发送给服务器
  • 服务端收到请求,然后去验证客户端请求里面携带的 Token,如果验证成功(如读取里面的user_id信息从而知道是哪一个用户传过来的),就向客户端返回请求的数据,否则拒绝请求

JWT用户认证.png

JWT的构成

JSON Web Token(JWT)由三部分组成,这些部分由点(.)分隔,分别是header(头部),payload(有效负载)和signature(签名)。

如下示例为一个JWT:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsInR5cGUiOiJqd3QifQ.eyJ1c2VybmFtZSI6InhqayIsImV4cCI6MTU4MjU0MjAxN30.oHdfcsUftJJob66e5mL1jLRpJwiG0i9MOD5gzM476eY

  • header(头部):

JWT的头部承载两部分:声明类型,声明加密算法

python headers = { "type":"jwt", "alg":"HS256" }

然后将头部进行base64加密。(该加密是可以对称解密的),构成了第一部分

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsInR5cGUiOiJqd3QifQ

  • payload(有效负载)

载荷就是存放有效信息的地方,这个名字像是特指飞机上承载的货品,这些有效信息包含三部分:

  • 标准中注册声明(建议但不强制使用):
    • iss:jwt签发者。
    • sub:jwt所面向的用户
    • aud:接收jwt的一方
    • exp:jwt过期时间,这个过期时间必须大于签发时间
    • nbf:定义在什么时间之前,该jwt都是可用的
    • lat:jwt的签发时间
    • jti:jwt的唯一身份表示,主要用来作为一次性token,从而回避重放攻击。
  • 公共的声明:
    • 可以添加任何信息,一般添加用户相关信息。但不建议添加敏感信息,因为该部分在客户端可解密
  • 私有的声明:
    • 私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。

python { "username": "xjk", }

构成了第二部分:eyJ1c2VybmFtZSI6InhqayIsImV4cCI6MTU4MjU0MjAxN30

  • signature(签名)

  • jwt的第三部分是一个签证信息,这个签证信息由三部分组成:

    • header(base64后的)
    • payload(base64后的)
    • secret(私钥)
  • headerpayload使用Base64编码生成一下再加入签名字符secret(密码加盐)用(header中声明的加密算法加密一遍,得到唯一的签名,用来防止其他人来篡改Token中的信息。
  • signature = 加密算法(header + "." + payload, 密钥)

构成了第三部分:oHdfcsUftJJob66e5mL1jLRpJwiG0i9MOD5gzM476eY

jwt实现原理.png

签名的目的:最后一步签名的过程,实际上是对头部以及负载内容进行签名,防止内容被窜改。如果有人对头部以及负载的内容解码之后进行修改,再进行编码,最后加上之前的签名组合形成新的JWT的话,那么服务器端会判断出新的头部和负载形成的签名和JWT附带上的签名是不一样的。如果要对新的头部和负载进行签名,在不知道服务器加密时用的密钥的话,得出来的签名也是不一样的。

Token基本实现原理

Token 也称作令牌,是由服务器端返回的字符串,此字符串是通过base64编码后得到的,编码前信息为json,包括加密方法、需要公开的用户信息、签名也就是密文。服务器端只有开发者随机设置的密钥。而服务器端是将用户信息和密钥一起通过加密方法加密,将得到的密文与需要公开的用户信息与加密方法通过base64编码为字符串。当客户端请求带上 Token,服务器端用同样方法构造Token,相同则允许请求,不同则报401 错误。这个加密采用的加密算法,一般有此特点:从源串计算出加密串很好算,但从加密串算出源串一般不可能。

IMG_1151(20230318-211953).PNG.PNG)

之后当客户端向服务端发起请求时,只需要携带Token给服务端,服务端根据Token中的用户有效信息加上私钥再做一遍加密算法,看看结果是否与Token中的“加密之后的结果”一致,若一致说明验证成功。

通过DRF simple jwt构造的jwt会返回两个值,分别是 accessrefreshaccess就是上文我们所说的 Tokenrefresh 是用来是刷新access 串, refresh请求每次都是用post方法,post方法的参数会在请求的body里面,相对来说更加安全。 accessrefresh 的过期时间不同,access 一般是 5 分钟,· 一般是14天,因为有些 get 方法也需要登录之后才能访问,意味着 Token会显式的显示在url链接里,这样不太安全,因此 Token 的有效期一般比较短。

配置DJango Rest FrameworkJWT

集成Django Rest Framework

mark

简书

安装

pip install djangorestframework
pip install pyjwt
pip install

#######
如果出现could not find a version....报错信息的话,可能是python解释器的路径与pip的安装路径不同,引用不到权限导致的,所以要修改一下指令:
python3 -m pip install djangorestframework
python3 -m pip install pyjwt

然后在settings.pyINSTALLED_APPS中添加rest_framework

#INSTALLED_APPS中添加注册信息 
INSTALLED_APPS = [
    'rest_framework',
     ...
    'corsheaders',
]

Class-Based Views

from rest_framework.views import APIView
from rest_framework.response import Response

class SnippetDetail(APIView):
    def get(self, request):  # 查找
        ...
        return Response(...)

    def post(self, request):  # 创建
        ...
        return Response(...)

    def put(self, request, pk):  # 修改
        ...
        return Response(...)

    def delete(self, request, pk):  # 删除
        ...
        return Response(...)


集成jwt验证

安装

pip install djangorestframework-simplejwt

然后我们需要告诉DRF我们使用jwt认证作为后台认证方案

settings.py中添加:

INSTALLED_APPS = [
    ...
    'rest_framework_simplejwt',
    ...
]

# REST_FRAMEWORK中全局配置认证方式、权限方式。
# 如settings.py文件中没有REST_FRAMEWORK,请自主写入 

REST_FRAMEWORK = {
    ...
    # DEFAULT_AUTHENTICATION_CLASSES设置默认的认证类,这里用token,也可以设置session或自定义的认证     # 用户登陆认证方式
    'DEFAULT_AUTHENTICATION_CLASSES': (
        ...
        'rest_framework_simplejwt.authentication.JWTAuthentication',# 进行token认证 
    )
    ...
}

注意:INSTALLED_APPS后都需要执行一个指令:python3 manage.py collectstatic

配置

settings.py中添加:

from datetime import timedelta # 导入datetime库生成时间参数 
...
# SIMPLE_JWT是token配置项,参数很多,可查看官网https://django-rest-framework-simplejwt.readthedocs.io/en/latest/settings.html
SIMPLE_JWT = {
    # ACCESS_TOKEN_LIFETIME设置token令牌有效时间 
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5), 
    # REFRESH_TOKEN_LIFETIME设置token刷新令牌有效时间 
    'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
    'ROTATE_REFRESH_TOKENS': False,
    'BLACKLIST_AFTER_ROTATION': False,
    'UPDATE_LAST_LOGIN': False,

    'ALGORITHM': 'HS256',
    'SIGNING_KEY': SECRET_KEY, # 注意这里的SECRET_KEY需要改成自己的字符串密钥(一个随机字符串)
    'VERIFYING_KEY': None,
    'AUDIENCE': None,
    'ISSUER': None,
    'JWK_URL': None,
    'LEEWAY': 0,

    'AUTH_HEADER_TYPES': ('Bearer',),
    'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
    'USER_ID_FIELD': 'id',
    'USER_ID_CLAIM': 'user_id',
    'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule',

    'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
    'TOKEN_TYPE_CLAIM': 'token_type',
    'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser',

    'JTI_CLAIM': 'jti',

    'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
    'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
    'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
}

添加获取jwt和刷新jwt的路由

jwt已经帮我们实现了登录功能,不需要再自己手写了,直接引入就好了。

登录有两个API:

  1. api/token/:获取Token令牌
  2. api/token/refresh/:刷新Token令牌

在总项目下的urls.py中加入自己定义的url,给自己的新建的app一个总的路由,inclue中写入自己appnameurls.py的路径

from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)

urlpatterns = [
    ...
    # 类.as_view()将类转换为函数写法
    path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    ...
]

访问对应的URL可以得到rest framework work给我们写好的调试页面:

restframwork_api.png

我们需要用POST的方法获取密钥

登出功能的实现只需要在客户端将用户的JWT删掉即可

手动获取jwt

from rest_framework_simplejwt.tokens import RefreshToken

def get_tokens_for_user(user):
    refresh = RefreshToken.for_user(user)

    return {
        'refresh': str(refresh),
        'access': str(refresh.access_token),
    }


jwt验证集成到Django Channels

创建文件channelsmiddleware.py

"""General web socket middlewares
"""

from channels.db import database_sync_to_async
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework_simplejwt.tokens import UntypedToken
from rest_framework_simplejwt.authentication import JWTTokenUserAuthentication
from channels.middleware import BaseMiddleware
from channels.auth import AuthMiddlewareStack
from django.db import close_old_connections
from urllib.parse import parse_qs
from jwt import decode as jwt_decode
from django.conf import settings
@database_sync_to_async
def get_user(validated_token):
    try:
        user = get_user_model().objects.get(id=validated_token["user_id"])
        # return get_user_model().objects.get(id=toke_id)
        return user

    except:
        return AnonymousUser()



class JwtAuthMiddleware(BaseMiddleware):
    def __init__(self, inner):
        self.inner = inner

    async def __call__(self, scope, receive, send):
       # Close old database connections to prevent usage of timed out connections
        close_old_connections()

        # Try to authenticate the user
        try:
            # Get the token
            token = parse_qs(scope["query_string"].decode("utf8"))["token"][0]

            # This will automatically validate the token and raise an error if token is invalid
            UntypedToken(token)
        except:
            # Token is invalid

            scope["user"] = AnonymousUser()
        else:
            #  Then token is valid, decode it
            decoded_data = jwt_decode(token, settings.SIMPLE_JWT["SIGNING_KEY"], algorithms=["HS256"])

            # Get the user using ID
            scope["user"] = await get_user(validated_token=decoded_data)
        return await super().__call__(scope, receive, send)


def JwtAuthMiddlewareStack(inner):
    return JwtAuthMiddleware(AuthMiddlewareStack(inner))

项目实现

后端

rest frame work配合JWT实现获取用户登录状态与登录信息。

appname/get_status.py

from rest_framework.views import   APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated

class InfoView(APIView):
    permission_classes = ([IsAuthenticated])

    def get(self,request):
        # 获取当前用户信息
        user = request.user
        return Response({
            'username': user.username,
            'result': "success",
        })

url路由里面引进该模块,可以把之前手写的loginlogout模块去掉了,使用jwt可以替代掉这两个功能。

appname/index.py

from django.urls import path
from backend.calculator.register import signup
from backend.calculator.get_status import InfoView
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)

urlpatterns = [
    path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path("register/", signup, name="calculator_register"),
    path("get_status/", InfoView.as_view(),name="calculator_get_status"), #name是在后端渲染有用,前端渲染用不到
]

前端

前端是之前写的一个Web App项目网站,里面目前只实现了个计算器(菜),不是本文的重点内容,因此就不强调了,只是展示如何在前端调用后端的API,以实现登录功能与获取登录状态。

基本逻辑

每一次客户端向服务端发送请求,都需要携带access(5分钟)与refresh(14天),如果这个请求需要用到身份验证的话,都需要在后端做一个判断:如果refresh过期了,需要用户重新登录;如果refresh没有过期,access过期了,那么就需要用refreshAPI重新获取一个access;如果refresh没有过期,且access也没有过期,则可以直接返回数据。

基本实现(完整版)

前端的客户端可以直接把accessrefresh字段直接放在浏览器的local Storage里面,每次可以从local Storage里面读取这两个字段。

判断是否过期的方法:可以同时存一个创建时间,每次判断当前时间与创建时间的时间间隔有没有超过他的保质期,注意要预留时间间隔,防止传到服务器的时候有时间误差。

乞丐版展示

这里只是展示一下功能,因此逻辑可以不那么复杂,先搞个乞丐版的:用户每次刷新页面都需要重新登录,登录后将accessrefresh字段直接都存到内存里,存完后写个周期函数,每隔4.5分钟向后端发送请求,重新获取一次access

这里前端用的是react

登录模块:login.jsx

 handleClick = e => {
        e.preventDefault();
        if (this.state.username === "") {
            this.setState({error_message: "用户名不能为空"});
        } else if (this.state.password === "") {
            this.setState({error_message: "密码不能为空"});
        } else {
                $.ajax({
                    url: "http://127.0.0.1:8000/token/",
                    type: "post",
                    data: {
                        username: this.state.username,
                        password: this.state.password,
                    },
                    dataType: 'json',
                    success: (resp) => {
                        console.log(resp);
                        this.props.set_token(resp.access,resp.refresh,this.state.username);
                        this.props.set_login(true);
                     },
                    error() {
                        this.setState({error_message: "用户名或密码错误"});
                     }
                })

        }
        //console.log(this.state);
     }

取消登录,直接将accessrefresh_token去掉就好了

    handleClick = () => {
        this.props.set_token("","","");
        this.props.set_login(false);
    }

注册功能:register.jsx

 handleClick = e => {
        e.preventDefault();
        $.ajax({
            url: "http://127.0.0.1:8000/register/",
            type: "get",
            data: {
                username: this.state.username,
                password: this.state.password,
                password_confirm: this.state.confirmed_password,
            },
            dataType: 'json',
            success: resp => {
                if (resp.result === "success") {
                    window.location.href="/calculator";
                } else {
                       this.setState({error_message: resp.result});
                 }
            }
        });
        console.log(this.state);

     }



yume
11天前

Django Rest FrameworkJWT身份验证

理论部分

这个技术是用来解决跨域情况下的用户登录与维持登录状态的问题。即处理跨域之后的身份验证问题。

Django自带的auth登录验证系统只能应用与于前端页面与后端页面在同一个网址(域)的情况下,也就是说当前后端分离,后端只提供API给前端,前端通过API提供的数据对页面进行渲染或增加修改的情况下,这种默认的登录验证系统就不起效果了,因为HTTP是一种无状态的协议,也就是说后端服务并不知道是谁发来的请求,我们也就无法验证请求的合法性。我们一般写前后端分离的项目时,都是用ajax向服务器端发起请求以获取后端API的数据,这种方式是无法获取从服务器端返回回来的sessionid的,从而就难以做身份验证。简单来说,就是传统的登录鉴权在前后端分离的情况下,无法将后端的sessionid存到前端对应域名的cookie里,因此无法做前后端分离的身份验证,cookie无法做跨域。

传统的登录鉴权与基于Token的鉴权区别

先来看看传统的登录鉴权跟基于Token的鉴权有什么区别:

Django的账号密码登录为例来说明传统的验证鉴权方式是怎么工作的,当我们登录页面输入账号密码提交表单后,会发送请求给服务器,服务器对发送过来的账号密码进行验证鉴权,验证鉴权通过后,把用户信息记录在服务器端(django_session表中),同时返回给浏览器一个sessionid用来唯一标识这个用户,浏览器将sessionid保存在cookie中(它是属于http-only的,是不能用js读取到的),之后浏览器的每次请求都一并将sessionid发送给服务器,服务器根据sessionid与记录的信息做对比以验证身份。

Token的鉴权方式就清晰很多了,客户端用自己的账号密码进行登录,服务端验证鉴权,验证鉴权通过生成Token返回给客户端,之后客户端每次请求都将Token放在header里一并发送,服务端收到请求时校验Token以确定访问者身份。

session的主要目的是给无状态的HTTP协议添加状态保持,通常在浏览器作为客户端的情况下比较通用。而Token的主要目的是为了鉴权,同时又不需要考虑CSRF防护以及跨域的问题,所以更多的用在专门给第三方提供API的情况下,客户端请求无论是浏览器发起还是其他的程序发起都能很好的支持。所以目前基于Token的鉴权机制几乎已经成了前后端分离架构或者对外提供API访问的鉴权标准,得到广泛使用。

传统的用户登录认证中,因为http是无状态的,所以都是采用session的认证方式。用户登录成功,服务端就会保存一个session,也会给客户端一个sessionID,以后客户端每次请求,都会携带这个sessionID,服务端会会根据这个sessionID来区分不同的用户。

这种基于cookie+session的认证方式,随着服务从单服务到多服务,缺点就出来了,因为session是存储在服务端,这样服务器的开销就会大起来了。并且session标示丢失,就可能出现安全问题CSRF(跨站请求伪造)。

扩展性: 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。

Token字面意思是令牌,功能跟Session类似,也是用于验证用户信息的,Token是服务端生成的一串字符串,当客户端发送登录请求时,服务器便会生成一个Token并将此Token返回给客户端,作为客户端进行请求的一个标识,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。session的不同之处在于session是将用户信息存储在服务器中保持用户的请求状态,而Token在服务器端不需要存储用户的登录记录,客户端每次向服务端发送请求的时候都会带上服务端发给的Token,服务端收到请求后去验证客户端请求里面带着Token,如果验证成功,就向客户端返回请求的数据。

一些参考文章:

Django-rest-frameworkJWT登录和认证

cookiesession的区别

Django解决跨域问题

基于Token 的身份验证流程

  • 客户端使用用户名跟密码请求登录
  • 服务端收到请求开始验证用户名与密码
  • 验证成功后,服务端生成一个 Token(可以理解为一种加密算法,一般为有效信息 + 一个加密字符串)并把这个 Token发送给客户端
  • 客户端收到 Token 以后可以把它存储起来,可以存放在Cookie里或者Local Storage
  • 客户端再次向服务端请求资形式源的时候携带服务端生成的 Token发送给服务器
  • 服务端收到请求,然后去验证客户端请求里面携带的 Token,如果验证成功(如读取里面的user_id信息从而知道是哪一个用户传过来的),就向客户端返回请求的数据,否则拒绝请求

JWT用户认证.png

JWT的构成

JSON Web Token(JWT)由三部分组成,这些部分由点(.)分隔,分别是header(头部),payload(有效负载)和signature(签名)。

如下示例为一个JWT:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsInR5cGUiOiJqd3QifQ.eyJ1c2VybmFtZSI6InhqayIsImV4cCI6MTU4MjU0MjAxN30.oHdfcsUftJJob66e5mL1jLRpJwiG0i9MOD5gzM476eY

  • header(头部):

JWT的头部承载两部分:声明类型,声明加密算法

python headers = { "type":"jwt", "alg":"HS256" }

然后将头部进行base64加密。(该加密是可以对称解密的),构成了第一部分

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsInR5cGUiOiJqd3QifQ

  • payload(有效负载)

载荷就是存放有效信息的地方,这个名字像是特指飞机上承载的货品,这些有效信息包含三部分:

  • 标准中注册声明(建议但不强制使用):
    • iss:jwt签发者。
    • sub:jwt所面向的用户
    • aud:接收jwt的一方
    • exp:jwt过期时间,这个过期时间必须大于签发时间
    • nbf:定义在什么时间之前,该jwt都是可用的
    • lat:jwt的签发时间
    • jti:jwt的唯一身份表示,主要用来作为一次性token,从而回避重放攻击。
  • 公共的声明:
    • 可以添加任何信息,一般添加用户相关信息。但不建议添加敏感信息,因为该部分在客户端可解密
  • 私有的声明:
    • 私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。

python { "username": "xjk", }

构成了第二部分:eyJ1c2VybmFtZSI6InhqayIsImV4cCI6MTU4MjU0MjAxN30

  • signature(签名)

  • jwt的第三部分是一个签证信息,这个签证信息由三部分组成:

    • header(base64后的)
    • payload(base64后的)
    • secret(私钥)
  • headerpayload使用Base64编码生成一下再加入签名字符secret(密码加盐)用(header中声明的加密算法加密一遍,得到唯一的签名,用来防止其他人来篡改Token中的信息。
  • signature = 加密算法(header + "." + payload, 密钥)

构成了第三部分:oHdfcsUftJJob66e5mL1jLRpJwiG0i9MOD5gzM476eY

jwt实现原理.png

签名的目的:最后一步签名的过程,实际上是对头部以及负载内容进行签名,防止内容被窜改。如果有人对头部以及负载的内容解码之后进行修改,再进行编码,最后加上之前的签名组合形成新的JWT的话,那么服务器端会判断出新的头部和负载形成的签名和JWT附带上的签名是不一样的。如果要对新的头部和负载进行签名,在不知道服务器加密时用的密钥的话,得出来的签名也是不一样的。

Token基本实现原理

Token 也称作令牌,是由服务器端返回的字符串,此字符串是通过base64编码后得到的,编码前信息为json,包括加密方法、需要公开的用户信息、签名也就是密文。服务器端只有开发者随机设置的密钥。而服务器端是将用户信息和密钥一起通过加密方法加密,将得到的密文与需要公开的用户信息与加密方法通过base64编码为字符串。当客户端请求带上 Token,服务器端用同样方法构造Token,相同则允许请求,不同则报401 错误。这个加密采用的加密算法,一般有此特点:从源串计算出加密串很好算,但从加密串算出源串一般不可能。

IMG_1151(20230318-211953).PNG.PNG)

之后当客户端向服务端发起请求时,只需要携带Token给服务端,服务端根据Token中的用户有效信息加上私钥再做一遍加密算法,看看结果是否与Token中的“加密之后的结果”一致,若一致说明验证成功。

通过DRF simple jwt构造的jwt会返回两个值,分别是 accessrefreshaccess就是上文我们所说的 Tokenrefresh 是用来是刷新access 串, refresh请求每次都是用post方法,post方法的参数会在请求的body里面,相对来说更加安全。 accessrefresh 的过期时间不同,access 一般是 5 分钟,· 一般是14天,因为有些 get 方法也需要登录之后才能访问,意味着 Token会显式的显示在url链接里,这样不太安全,因此 Token 的有效期一般比较短。

配置DJango Rest FrameworkJWT

集成Django Rest Framework

mark

简书

安装

pip install djangorestframework
pip install pyjwt
pip install

#######
如果出现could not find a version....报错信息的话,可能是python解释器的路径与pip的安装路径不同,引用不到权限导致的,所以要修改一下指令:
python3 -m pip install djangorestframework
python3 -m pip install pyjwt

然后在settings.pyINSTALLED_APPS中添加rest_framework

#INSTALLED_APPS中添加注册信息 
INSTALLED_APPS = [
    'rest_framework',
     ...
    'corsheaders',
]

Class-Based Views

from rest_framework.views import APIView
from rest_framework.response import Response

class SnippetDetail(APIView):
    def get(self, request):  # 查找
        ...
        return Response(...)

    def post(self, request):  # 创建
        ...
        return Response(...)

    def put(self, request, pk):  # 修改
        ...
        return Response(...)

    def delete(self, request, pk):  # 删除
        ...
        return Response(...)


集成jwt验证

安装

pip install djangorestframework-simplejwt

然后我们需要告诉DRF我们使用jwt认证作为后台认证方案

settings.py中添加:

INSTALLED_APPS = [
    ...
    'rest_framework_simplejwt',
    ...
]

# REST_FRAMEWORK中全局配置认证方式、权限方式。
# 如settings.py文件中没有REST_FRAMEWORK,请自主写入 

REST_FRAMEWORK = {
    ...
    # DEFAULT_AUTHENTICATION_CLASSES设置默认的认证类,这里用token,也可以设置session或自定义的认证     # 用户登陆认证方式
    'DEFAULT_AUTHENTICATION_CLASSES': (
        ...
        'rest_framework_simplejwt.authentication.JWTAuthentication',# 进行token认证 
    )
    ...
}

注意:INSTALLED_APPS后都需要执行一个指令:python3 manage.py collectstatic

配置

settings.py中添加:

from datetime import timedelta # 导入datetime库生成时间参数 
...
# SIMPLE_JWT是token配置项,参数很多,可查看官网https://django-rest-framework-simplejwt.readthedocs.io/en/latest/settings.html
SIMPLE_JWT = {
    # ACCESS_TOKEN_LIFETIME设置token令牌有效时间 
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5), 
    # REFRESH_TOKEN_LIFETIME设置token刷新令牌有效时间 
    'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
    'ROTATE_REFRESH_TOKENS': False,
    'BLACKLIST_AFTER_ROTATION': False,
    'UPDATE_LAST_LOGIN': False,

    'ALGORITHM': 'HS256',
    'SIGNING_KEY': SECRET_KEY, # 注意这里的SECRET_KEY需要改成自己的字符串密钥(一个随机字符串)
    'VERIFYING_KEY': None,
    'AUDIENCE': None,
    'ISSUER': None,
    'JWK_URL': None,
    'LEEWAY': 0,

    'AUTH_HEADER_TYPES': ('Bearer',),
    'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
    'USER_ID_FIELD': 'id',
    'USER_ID_CLAIM': 'user_id',
    'USER_AUTHENTICATION_RULE': 'rest_framework_simplejwt.authentication.default_user_authentication_rule',

    'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
    'TOKEN_TYPE_CLAIM': 'token_type',
    'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser',

    'JTI_CLAIM': 'jti',

    'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
    'SLIDING_TOKEN_LIFETIME': timedelta(minutes=5),
    'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1),
}

添加获取jwt和刷新jwt的路由

jwt已经帮我们实现了登录功能,不需要再自己手写了,直接引入就好了。

登录有两个API:

  1. api/token/:获取Token令牌
  2. api/token/refresh/:刷新Token令牌

在总项目下的urls.py中加入自己定义的url,给自己的新建的app一个总的路由,inclue中写入自己appnameurls.py的路径

from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)

urlpatterns = [
    ...
    # 类.as_view()将类转换为函数写法
    path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    ...
]

访问对应的URL可以得到rest framework work给我们写好的调试页面:

restframwork_api.png

我们需要用POST的方法获取密钥

登出功能的实现只需要在客户端将用户的JWT删掉即可

手动获取jwt

from rest_framework_simplejwt.tokens import RefreshToken

def get_tokens_for_user(user):
    refresh = RefreshToken.for_user(user)

    return {
        'refresh': str(refresh),
        'access': str(refresh.access_token),
    }


jwt验证集成到Django Channels

创建文件channelsmiddleware.py

"""General web socket middlewares
"""

from channels.db import database_sync_to_async
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from rest_framework_simplejwt.tokens import UntypedToken
from rest_framework_simplejwt.authentication import JWTTokenUserAuthentication
from channels.middleware import BaseMiddleware
from channels.auth import AuthMiddlewareStack
from django.db import close_old_connections
from urllib.parse import parse_qs
from jwt import decode as jwt_decode
from django.conf import settings
@database_sync_to_async
def get_user(validated_token):
    try:
        user = get_user_model().objects.get(id=validated_token["user_id"])
        # return get_user_model().objects.get(id=toke_id)
        return user

    except:
        return AnonymousUser()



class JwtAuthMiddleware(BaseMiddleware):
    def __init__(self, inner):
        self.inner = inner

    async def __call__(self, scope, receive, send):
       # Close old database connections to prevent usage of timed out connections
        close_old_connections()

        # Try to authenticate the user
        try:
            # Get the token
            token = parse_qs(scope["query_string"].decode("utf8"))["token"][0]

            # This will automatically validate the token and raise an error if token is invalid
            UntypedToken(token)
        except:
            # Token is invalid

            scope["user"] = AnonymousUser()
        else:
            #  Then token is valid, decode it
            decoded_data = jwt_decode(token, settings.SIMPLE_JWT["SIGNING_KEY"], algorithms=["HS256"])

            # Get the user using ID
            scope["user"] = await get_user(validated_token=decoded_data)
        return await super().__call__(scope, receive, send)


def JwtAuthMiddlewareStack(inner):
    return JwtAuthMiddleware(AuthMiddlewareStack(inner))

项目实现

后端

rest frame work配合JWT实现获取用户登录状态与登录信息。

appname/get_status.py

from rest_framework.views import   APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated

class InfoView(APIView):
    permission_classes = ([IsAuthenticated])

    def get(self,request):
        # 获取当前用户信息
        user = request.user
        return Response({
            'username': user.username,
            'result': "success",
        })

url路由里面引进该模块,可以把之前手写的loginlogout模块去掉了,使用jwt可以替代掉这两个功能。

appname/index.py

from django.urls import path
from backend.calculator.register import signup
from backend.calculator.get_status import InfoView
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
)

urlpatterns = [
    path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path("register/", signup, name="calculator_register"),
    path("get_status/", InfoView.as_view(),name="calculator_get_status"), #name是在后端渲染有用,前端渲染用不到
]

前端

前端是之前写的一个Web App项目网站,里面目前只实现了个计算器(菜),不是本文的重点内容,因此就不强调了,只是展示如何在前端调用后端的API,以实现登录功能与获取登录状态。

基本逻辑

每一次客户端向服务端发送请求,都需要携带access(5分钟)与refresh(14天),如果这个请求需要用到身份验证的话,都需要在后端做一个判断:如果refresh过期了,需要用户重新登录;如果refresh没有过期,access过期了,那么就需要用refreshAPI重新获取一个access;如果refresh没有过期,且access也没有过期,则可以直接返回数据。

基本实现(完整版)

前端的客户端可以直接把accessrefresh字段直接放在浏览器的local Storage里面,每次可以从local Storage里面读取这两个字段。

判断是否过期的方法:可以同时存一个创建时间,每次判断当前时间与创建时间的时间间隔有没有超过他的保质期,注意要预留时间间隔,防止传到服务器的时候有时间误差。

乞丐版展示

这里只是展示一下功能,因此逻辑可以不那么复杂,先搞个乞丐版的:用户每次刷新页面都需要重新登录,登录后将accessrefresh字段直接都存到内存里,存完后写个周期函数,每隔4.5分钟向后端发送请求,重新获取一次access

这里前端用的是react

登录模块:login.jsx

 handleClick = e => {
        e.preventDefault();
        if (this.state.username === "") {
            this.setState({error_message: "用户名不能为空"});
        } else if (this.state.password === "") {
            this.setState({error_message: "密码不能为空"});
        } else {
                $.ajax({
                    url: "http://127.0.0.1:8000/token/",
                    type: "post",
                    data: {
                        username: this.state.username,
                        password: this.state.password,
                    },
                    dataType: 'json',
                    success: (resp) => {
                        console.log(resp);
                        this.props.set_token(resp.access,resp.refresh,this.state.username);
                        this.props.set_login(true);
                     },
                    error() {
                        this.setState({error_message: "用户名或密码错误"});
                     }
                })

        }
        //console.log(this.state);
     }

取消登录,直接将accessrefresh_token去掉就好了

    handleClick = () => {
        this.props.set_token("","","");
        this.props.set_login(false);
    }

注册功能:register.jsx

 handleClick = e => {
        e.preventDefault();
        $.ajax({
            url: "http://127.0.0.1:8000/register/",
            type: "get",
            data: {
                username: this.state.username,
                password: this.state.password,
                password_confirm: this.state.confirmed_password,
            },
            dataType: 'json',
            success: resp => {
                if (resp.result === "success") {
                    window.location.href="/calculator";
                } else {
                       this.setState({error_message: resp.result});
                 }
            }
        });
        console.log(this.state);

     }



yume
13天前

实战项目——计算器

项目结构

calculator结构.png

设置路由

app.jsx:

import React, { Component } from 'react';
import NavBar from './navbar';
import {Routes, Route, Navigate} from 'react-router-dom'
import Calculator from './content/calculator';
import Home from './content/home';
import Login from './content/login';
import Register from './content/register';
import NotFound from './content/notFound';


class App extends Component {
    state = {  } 
    render() { 
        return (
            <React.Fragment>
                <NavBar />
                <div className="container">
                    <Routes>
                        <Route path='/calculator' element={<Calculator />} />
                        <Route path='/' element={<Home />} />
                        <Route path='/login' element={<Login />} />
                        <Route path='/register' element={<Register />} />
                        <Route path='/404' element={<NotFound />} />
                        <Route path="*" element={ <Navigate replace to="/404" /> } />
                    </Routes>
                </div>
            </React.Fragment>
        );
    }
}

export default App;

设计导航栏

navbar.jsx>: 记得页面跳转的时候,用Link改成前端渲染

import React, { Component } from 'react';
import {Link} from 'react-router-dom'

class NavBar extends Component {
    state = {  } 
    render() { 
        return (
        <nav className="navbar navbar-dark bg-dark">
            <div className="container">
                <Link className="navbar-brand" to="/">Web App</Link>
                <button className="navbar-toggler" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasDarkNavbar" aria-controls="offcanvasDarkNavbar">
                <span className="navbar-toggler-icon"></span>
                </button>
                <div className="offcanvas offcanvas-end text-bg-dark" tabIndex="-1" id="offcanvasDarkNavbar" aria-labelledby="offcanvasDarkNavbarLabel">
                    <div className="offcanvas-header">
                        <h5 className="offcanvas-title" id="offcanvasDarkNavbarLabel">Web App</h5>
                        <button type="button" className="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
                    </div>
                    <div className="offcanvas-body">
                        <ul className="navbar-nav justify-content-end flex-grow-1 pe-3">
                        <li className="nav-item">
                            <Link className="nav-link active" aria-current="page" to="/home">Home</Link>
                            <Link className="nav-link active" aria-current="page" to="/login">login</Link>
                            <Link className="nav-link active" aria-current="page" to="/register">register</Link>
                        </li>
                        <li className="nav-item dropdown">
                            <a className="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
                            APP
                            </a>
                            <ul className="dropdown-menu dropdown-menu-dark">
                                <li><Link className="dropdown-item" to="/calculator">Calculator</Link></li>
                            </ul>
                        </li>
                        </ul>
                        <form className="d-flex mt-3" role="search">
                        <input className="form-control me-2" type="search" placeholder="Search" aria-label="Search" />
                        <button className="btn btn-success" type="submit">Search</button>
                        </form>
                    </div>
                </div>
            </div>
        </nav>
        );
    }
}

export default NavBar;

card

每个页面用一个card来把页面框柱,可以使得页面更加好看。

新建一个base.jsx存储card格式,之后在每个页面引用<Base> 页面内容<Base>即可实现统一化card布局,在base.jsx里通过this.props.children<Base>标签里的子元素全部渲染出来

base.jsx:

import React, { Component } from 'react';

class Base extends Component {
    state = {  } 
    render() { 
        console.log(this.props);
        return (
            <div className="card" style={{marginTop: "20px", backgroundColor: 'lightgray'}}>
                <div className="card-body">
                    {this.props.children}
                </div>
            </div>
        );
    }
}

export default Base;

计算器布局

Grid布局

Gird布局详解

grid1.png

output是现示输出数据的,应该独占一行,后面的都是计算器的按键。实际上项目计算器加上output一共有六行,并要给第一行的output设定一个最小与最大高度minmax(6rem, auto):表示第一行的最小高度为6rem,最大高度为自适应。给每个按键设置一个gap: 1px的间隔,让外表好看点。

calculator.jsx

import React, { Component } from 'react';
import Base from './base';

class Calculator extends Component {
    state = {  } 
    render() { 
        return (
            <Base>
                <div className="calculator">
                    <div className="output">
                        <div className="last-output">123 </div>
                        <div className="current-output">99999999999</div>
                    </div>

                    <button>AC</button>
                    <button>Del</button>
                    <button>x^2</button>
                    <button>/</button>
                    <button>7</button>
                    <button>8</button>
                    <button>9</button>
                    <button>*</button>
                    <button>4</button>
                    <button>5</button>
                    <button>6</button>
                    <button>-</button>
                    <button>1</button>
                    <button>2</button>
                    <button>3</button>
                    <button>+</button>
                    <button>+/-</button>
                    <button>0</button>
                    <button>.</button>
                    <button>=</button>

                </div>
            </Base>
        );
    }
}

export default Calculator;

计算器的CSS样式设计:

body {
    margin: 0;
}

* {
    box-sizing: border-box;
}

.calculator {
    display: grid; /*grid布局*/
    grid-template-columns: repeat(4, 6rem); /*一行有多少列,每列长度是多少,repeat(n,p)把p重复n遍*/
    grid-template-rows: minmax(6rem, auto) repeat(5, 4rem); /*第一行定个最小高度*/
    gap: 1px;
    background-color: rgba(237, 237, 237, 0.75);
    width: calc(24rem + 3px);
    margin: auto;
}

.output {
    grid-column: 1 / span 4; /*从第一个格子开始独占4个格子*/
    border: 2px solid black;
    display: flex;
    flex-direction: column; /*竖轴为主轴*/
    align-items: flex-end; /*向右对齐*/
    justify-content: space-around; /*在每行上均匀分配弹性元素*/
    padding: 10px;
    /*让输入/输出的数如果超过长度就打断放到下一行:*/
    word-wrap: break-word;
    word-break: break-all;
}

.last-output {
}

.current-output {
    font-size: 3rem;
}

.calculator > button {
    background-color: rgba(246, 246, 246, 0.75);
}

.calculator > button:hover {
    background-color: #b5b5b5;
}

设计计算器逻辑

计算器需要维护的状态:

  1. 当前的输入数据current-operand
  2. 前一步的输入数据last-operand
  3. 运算符operation

计算器的功能操作:

  1. add digit 增加一位数字
  2. delete digit 删除一位数字
  3. choose operation 选择运算规则
  4. clear 清除屏幕数字
  5. evaluate计算结果

我们可以使用redux存储三个状态以便维护。

定义redux

redux结构

redux1.png

action.js

const ACTIONS = {
    Add_digit: "add-digit",
    Delete_digit: "delete-digit",
    Choose_operation: "choose-operation",
    Clear: "clear",
    Evaluate: "evaluate",
}

export default ACTIONS;

reducer.js

import ACTIONS from "./action";

const reducer = (state = {
    currentOperand: "",
    lastOperand: "",
    operation: "",
}, action) => {
    switch (action.type) {
        default:
            return state;

    }

}

export default reducer;

store.js

import { configureStore } from "@reduxjs/toolkit";
import reducer from "./reducer";

const store = configureStore({
    reducer, //实际上是reducer: reducer,因为Key和value一致时可以只写一个就好了
})

export default store;
index.js中引入store

index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import 'bootstrap/dist/css/bootstrap.css';
import 'bootstrap/dist/js/bootstrap.js'
import { BrowserRouter } from 'react-router-dom';
import App from './components/app';
import { Provider } from 'react-redux';
import store from './redux/store';


const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <Provider store={store}>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </Provider>
);

创建计算器功能相关的组件

实现add digit功能

/src/component/目录下新建calculator文件夹,存放功能组件digitButton.jsx,此组件将替换掉之前静态页面中的<button>数字<button>按钮,因此,该组件作为calculator.jsx中的子组件,在父组件中向子组件传递对应的digit数据,子组件digitButton.jsx中可以通过this.props.digit调用该digit数据,实现点击计算器上的数字Button,可以在输出屏幕上添加数据。

这里用this.props.digit传送每个按钮对应的数据,还需要绑定一个dispatch进行状态的更新。

digitButton.jsx

import React, { Component } from 'react';
import ACTIONS from './../../../redux/action';
import {connect} from 'react-redux';

class DigitButton extends Component {
    state = {  } 



    render() { 
        return (<button onClick={() => this.props.add_digit(this.props.digit)}>{this.props.digit}</button>);
    }
}

const mapDispatchToProps = {
    add_digit: digit => {
        return {
            type: ACTIONS.Add_digit,
            digit: digit,
        }
    }
}

export default connect(null,mapDispatchToProps)(DigitButton); //第一个参数是把state绑定到当前组件的props中,这里不需要

calculator.jsx

这里需要访问redux中维护的全局数据 ,因此要绑定一个mapStateToProps,以实时访问storestate的数据

 import React, { Component } from 'react';
import Base from './base';
import {connect} from 'react-redux'
import DigitButton from './calculator/digitButton';

class Calculator extends Component {
    state = {  } 
    render() { 
        return (
            <Base>
                <div className="calculator">
                    <div className="output">
                        <div className="last-output">
                            {this.props.lastOperand} {this.props.operation}
                        </div>
                        <div className="current-output">
                            {this.props.currentOperand}
                        </div>
                    </div>
                    <button>CE</button>
                    <button>Del</button>
                    <button>x^2</button>
                    <button>÷</button>
                    <DigitButton digit={"7"}></DigitButton>
                    <DigitButton digit={"8"}></DigitButton>
                    <DigitButton digit={"9"}></DigitButton>
                    <button>×</button>
                    <DigitButton digit={"4"}></DigitButton>
                    <DigitButton digit={"5"}></DigitButton>
                    <DigitButton digit={"6"}></DigitButton>
                    <button>-</button>
                    <DigitButton digit={"1"}></DigitButton>
                    <DigitButton digit={"2"}></DigitButton>
                    <DigitButton digit={"3"}></DigitButton>
                    <button>+</button>
                    <button>+/-</button>
                    <DigitButton digit={"0"}></DigitButton>
                    <DigitButton digit={'.'}></DigitButton>
                    <button>=</button> 
                </div>
            </Base>
        );
    }
}

const mapStateToProps = (state,props) => {
    return {
        currentOperand: state.currentOperand,
        lastOperand: state.lastOperand,
        operation: state.operation,
    }

}

export default connect(mapStateToProps)(Calculator);

reducer.jsx中完善相应的reducer函数,实现功能,注意特判:

import ACTIONS from "./action";

const reducer = (state = {
    currentOperand: "",
    lastOperand: "",
    operation: "",
}, action) => {
    switch (action.type) {
        case ACTIONS.Add_digit:
            if (state.currentOperand === "0" && action.digit === '0') return state; /*当前为0再按0的话就不显示多余的0*/
            if (state.currentOperand === "0" && action.digit !== '.') return { /*点击按钮前是0,再输入不是.的其他数据,则将前置0去掉*/
                ...state,
                currentOperand: action.digit
            }
            //str.includes('.'):判断str是否包含'.' 
            if (action.digit === '.' && state.currentOperand.includes('.')) { /*当前是.且前面已经有'.'的话则不能再输入'.' */
                return state;
            }
            //点击'.'时,若前面为空,则在前面添加一个0
            if (action.digit === '.' && state.currentOperand === "")
                return {
                    ...state,
                    currentOperand: "0" + action.digit,
                }
            return {
                ...state,
                currentOperand: state.currentOperand + action.digit,
            }
        default:
            return state;

    }

}

export default reducer;
实现delete digit功能

calculator.jsx中绑定一个mapDispatchToProps以更新dispatch。在DEL按钮中绑定这个函数。

...
  <button onClick={this.props.delete_digit}>Del</button>
...

const mapDispatchToProps = {
    delete_digit: () => {
        return {
            type: ACTIONS.Delete_digit,
        }
    }
}

export default connect(mapStateToProps,mapDispatchToProps)(Calculator);

reducer.jsx中完善删除功能:

slicesplice的区别

...  
case ACTIONS.Delete_digit:
            if (state.currentOperand === "") return state; //若已经是空的话就不需要再删了
            return {
                ...state,
                currentOperand: state.currentOperand.slice(0, -1), //截取从索引0开始到索引的最后一项(不包括最后一项)的字符串,slice不改变原字符串
            }
...
实现choose operation功能

add digit一样,我们也在calculator.jsx中将所有的功能键<button>替换成一个功能组件<OperationButton>,定义在operationButton.jsx中,作为calculator.jsxCalculator组件的子组件,通过父组件向其传递operation={'操作符'}属性,operationButton.jsx中可以用this.props.operation调用传递过来的属性值。

同样的,operationButton.jsx中也要定义一个组件的行为mapDispatchToProps,绑定到dispatch()中。

operationButton.jsx

import React, { Component } from 'react';
import {connect} from 'react-redux'
import ACTIONS from '../../../redux/action';

class OperationButton extends Component {
    state = {  } 
    render() { 
        return (
            <button onClick={() => {this.props.choose_operation(this.props.operation)}}>{this.props.operation}</button>
        );
    }
}

const mapDispatchToProps = {
    choose_operation: operation => {
        return {
            type: ACTIONS.Choose_operation,
            operation: operation,
        }
    }

}

export default connect(null,mapDispatchToProps)(OperationButton);

reducer.jsx中完成相应的操作(注意一堆特判)

const evaluate = state => {
    let { lastOperand: last, currentOperand: current, operation } = state;
    last = parseFloat(last);
    current = parseFloat(current);
    let ans = "";
    switch (operation) {
        case '+':
            ans = last + current;
            break;
        case '-':
            ans = last - current;
            break;
        case '×':
            ans = last * current;
            break;
        case '÷':
            if (current === 0) ans = "除数不能为0"
            else ans = last / current;
            break;
    }
    return ans.toString();
}
...
case ACTIONS.Choose_operation:
            if (action.operation === 'CE' || state.currentOperand === "除数不能为0")
                return {
                    lastOperand: "",
                    currentOperand: "",
                    operation: "",

                }
            if (state.lastOperand === "" && state.currentOperand === "") { //当都没有数据时,点击操作符都不会有翻译
                return state;
            }
            if (state.lastOperand === "") { //当只有上面为空,下面不为空时
                if (action.operation === 'sqr') {
                    return {
                        ...state,
                        lastOperand: (parseFloat(state.currentOperand) * parseFloat(state.currentOperand)).toString(),
                        currentOperand: "",
                    }
                }
                return {
                    ...state,
                    lastOperand: state.currentOperand,
                    operation: action.operation,
                    currentOperand: "",
                }

            }
            if (state.currentOperand === "") { //当下面是空的时候,直接将运算符替换掉
                return {
                    ...state,
                    operation: action.operation,
                }
            }
            let ans = evaluate(state);

            if (action.operation === "sqr") {
                return {
                    ...state,
                    lastOperand: ans,

                }
            }
            if (ans === "除数不能为0")
                return {
                    ...state,
                    currentOperand: "除数不能为0",
                }
            else
                return {
                    ...state,
                    lastOperand: ans,
                    operation: action.operation,
                    currentOperand: "",
                }

        default:
            return state;
...
实现evaluate 功能

实现按=可以显示计算结果。

过程和前面类似,只是逻辑不同。

calculator.jsx

 <button onClick={this.props.evaluate}>=</button>
...
const mapDispatchToProps = {
    delete_digit: () => {
        return {
            type: ACTIONS.Delete_digit,
        }
    },
    evaluate: () => {
        return {
            type: ACTIONS.Evaluate,
        }
    }
}

...

reducer.jsx中完成对应的操作,

添加一个state属性overwrite表示按下=后再次按下数字键是否需要将结果覆盖掉,在之前的ACTION.type里面分别修改。还有一些细枝末节的东西,看看代码就好了

...
const reducer = (state = {
    currentOperand: "",
    lastOperand: "",
    operation: "",
    overwrite: false, //是否要将结果覆盖掉
}, action) => {
    switch (action.type) {
        case ACTIONS.Add_digit:
            if (state.overwrite) {
                if (state.lastOperand === '')
                    return {
                        ...state,
                        overwrite: false,
                        currentOperand: "",
                    }
                else {
                    return {
                        ...state,
                        overwrite: false,
                        currentOperand: action.digit,
                    }
                }
            }
            if (state.currentOperand === "除数不能为0")
                return {
                    ...state,
                    currentOperand: action.digit,
                }
            if (state.currentOperand === "0" && action.digit === '0') return state; /*当前为0再按0的话就不显示多余的0*/
            if (state.currentOperand === "0" && action.digit !== '.') return { /*点击按钮前是0,再输入不是.的其他数据,则将前置0去掉*/
                ...state,
                currentOperand: action.digit
            }
            //str.includes('.'):判断str是否包含'.' 
            if (action.digit === '.' && state.currentOperand.includes('.')) { /*当前是.且前面已经有'.'的话则不能再输入'.' */
                return state;
            }
            //点击'.'时,若前面为空,则在前面添加一个0
            if (action.digit === '.' && state.currentOperand === "")
                return {
                    ...state,
                    currentOperand: "0" + action.digit,
                }
            return {
                ...state,
                currentOperand: state.currentOperand + action.digit,
            }
        case ACTIONS.Delete_digit:
            if (state.overwrite)
                return {
                    ...state,
                    currentOperand: "",
                    overwrite: false,
                }
            if (state.currentOperand === "除数不能为0")
                return {
                    lastOperand: "",
                    currentOperand: "",
                    operation: "",
                }
            if (state.currentOperand === "") return state; //若已经是空的话就不需要再删了
            return {
                ...state,
                currentOperand: state.currentOperand.slice(0, -1), //截取从索引0开始到索引的最后一项(不包括最后一项)的字符串,slice不改变原字符串
            }
        case ACTIONS.Choose_operation:
            if (action.operation === 'CE' || state.currentOperand === "除数不能为0")
                return {
                    lastOperand: "",
                    currentOperand: "",
                    operation: "",

                }
            if (state.lastOperand === "" && state.currentOperand === "") { //当都没有数据时,点击操作符都不会有翻译
                return state;
            }
            if (state.lastOperand === "") { //当只有上面为空,下面不为空时
                if (action.operation === 'sqr') {
                    return {
                        ...state,
                        lastOperand: (parseFloat(state.currentOperand) * parseFloat(state.currentOperand)).toString(),
                        currentOperand: "",
                    }
                }
                return {
                    ...state,
                    lastOperand: state.currentOperand,
                    operation: action.operation,
                    currentOperand: "",
                }

            }
            if (state.currentOperand === "") { //当下面是空的时候,直接将运算符替换掉
                return {
                    ...state,
                    operation: action.operation,
                }
            }
            let ans = evaluate(state);

            if (action.operation === "sqr") {
                return {
                    ...state,
                    lastOperand: ans,

                }
            }
            if (ans === "除数不能为0")
                return {
                    ...state,
                    currentOperand: "除数不能为0",
                }
            else
                return {
                    ...state,
                    lastOperand: ans,
                    operation: action.operation,
                    currentOperand: "",
                }
        case ACTIONS.Evaluate:
            if (state.currentOperand === '' || state.lastOperand === '' || state.operation === '') return state;
            return {
                ...state,
                currentOperand: evaluate(state),
                lastOperand: "",
                operation: "",
                overwrite: true,
            }

        default:
            return state;

    }

}


...
将数据格式化

calculator.jsxstate中添加格式化属性formater: Intl.NumberFormat('en-us')

其中的formater.format可以将我们的数字变得格式化,即每三位数用,隔开,

同时,为了让我们浮点数仍然保留相对应的小数而不会被格式化掉,我们自己手写一个格式化函数,来调整我们的格式。

calculator.jsx

...
 state = { 
        formater: Intl.NumberFormat('en-us')
     } 

      format(number) {
        if (number === "除数不能为0") return number;
        const [integer,decimal] = number.split('.');
        if (decimal === undefined) return this.state.formater.format(integer);
        return `${this.state.formater.format(integer)}.${decimal}`;
     }

  render() {
    ....
                    <div className="output">
                        <div className="last-output">
                            {this.format(this.props.lastOperand)} {this.props.operation}
                        </div>
                        <div className="current-output">
                            {this.format(this.props.currentOperand)}
                        </div>
                    </div>
  }

计算器项目结果

calc.png

实现登录与注册功能(后端)

我们这里的后端用Django实现,实现的过程中遇到好多坑,要解决跨域的问题,具体参考下面的文章:

Django解决跨域问题

更新:完善了JWT认证,使得跨域也可以记录登录状态了!!

并且通过JWT不再需要手写loginlogout了,只需要将token获取与删除即可。具体参考我的另一篇题解

为了偷懒,下面的登录后端,依旧是传统的Django自带的session登录方式,并不能实现跨域记录登录状态,因此,凑合着看就好了QAQ

一些用到的API

login

登录接口:https://app165.acapp.acwing.com.cn/calculator/login/

输入参数: usernamepassword

输出参数: result

  • result = "success": 表示登录成功
  • result = "用户名或密码不正确": 表示错误信息
logout

退出接口:https://app165.acapp.acwing.com.cn/calculator/logout/

输入参数:无

输出参数:result

  • result = "success": 表示退出成功
register

注册接口:https://app165.acapp.acwing.com.cn/calculator/register/

输入参数:usernamepasswordpassword_confirm

输出参数:result

  • result = "success":表示注册成功
  • result = "用户名和密码不能为空":表示错误信息
  • result = "两个密码不一致":表示错误信息
  • result = "用户名已存在":表示错误信息
get_status

查询登录状态,如果已登录,则返回用户名:

https://app165.acapp.acwing.com.cn/calculator/get_status/

输入参数:无

输出参数:resultusername

  • result = "login",此时有返回值useranme,表示已登录的用户名
  • result = "logout",此时username不存在,表示未登录

后端结构

calc_backend.png

设置后端路由

urls.py

from django.contrib import admin
from django.urls import path,include

urlpatterns = [
    path('admin/', admin.site.urls),
    path("/",include('backend.calculator.index'))
]

login API

calculator/login.py

from django.contrib.auth import authenticate, login
from django.http import JsonResponse


def signin(request):
    data = request.GET
    username = data.get('username')
    password = data.get('password')
    user = authenticate(username=username, password=password)
    #print(user)
    if not user:
        return JsonResponse({
            'result': "Password or username incorrect"
        })
    login(request, user)

    return JsonResponse({
        'result': "success",
        "is_login": "true",
    })


logout API

calculator/logout.py

from django.http import JsonResponse
from django.contrib.auth import logout


def signout(request):
    user = request.user
    if not user.is_authenticated:
        return JsonResponse({
            'result': "success",
        })
    logout(request)  # 从request中把cookie删掉
    return JsonResponse({
        'result': "success",
    })

register API

calculator/register.py

from django.http import JsonResponse
from django.contrib.auth import login
from django.contrib.auth.models import User
#from backend.models.player import Player


def signup(request):
    data = request.GET
    username = data.get('username', "").strip()  # 获取用户名,如果没有的话返回空,并把前后空格去掉
    password = data.get('password', "").strip()
    password_confirm = data.get('password_confirm', "").strip()

    if not username or not password:
        return JsonResponse({
            'result': '用户名或密码不能为空',
        })
    if password != password_confirm:
        return JsonResponse({
            'result': '两次密码不一致',
        })
    if User.objects.filter(username=username).exists():
        return JsonResponse({
            'result': '用户名已存在',
        })

    user = User(username=username)
    user.set_password(password)
    user.save()
    #Player.objects.create(user=user, photo="https://cdn-userpic.codeforces.com/486565/avatar/7004b8994370c323.jpg")
    login(request, user)
    return JsonResponse({
        'result': "success",
    })

get_status API

from django.http import JsonResponse

#判断是否登录成功
def get_status(request):
    user = request.user
    if user.is_authenticated:
        return JsonResponse({
            'result': "login",
            'username': user.username
        })
    return JsonResponse({
        'result': "logout"
    })

集成路由

calculator/index.py

from django.urls import path
from backend.calculator.login import signin
from backend.calculator.logout import signout
from backend.calculator.register import signup

urlpatterns = [
    path("login/", signin, name="calculator_login"),
    path("logout/", signout, name="calculator_logout"),
    path("register/", signup, name="calculator_register"),
    path("get_status/", get_status,name="calculator_get_status"), #name是在后端渲染有用,前端渲染用不到
]

实现登录注册功能(前端)

安装jQuery

我们需要ajax去获取后端的数据,因此要先安装jQuery:

npm i jquery

装完之后重新启动一次项目:npm start

完善登录页面

state里面维护三个值:

error_mesage: 登录失败的提示信息;

username:用户名,绑定在用户输入的表单username

password:密码,绑定在用户输入的表单password;

当用户在登录窗口上输入用户名与密码时,要相应地与this.state.usernamethis.state.password进行绑定更新。

这里用到JS中的表单的事件change,捕捉我们的表单是否发生变化,若发生变化则触发相应的函数,用this.setState{{username: e.target.value}}更新维护相对应的用户名。密码的维护同上。

react中使用onChange{}来实现change事件

target 事件属性可返回事件的目标节点(触发该事件的节点),如生成事件的元素、文档或窗口。

tar = event.target则有

  • 获取文本内容:tar.textContent

  • 获取父级节点:tar.parentNode

  • 获取节点名称:tar.nodeName
    需要注意,使用target获取到的节点名称全部为大写

一些相关网站:

traget1

target2

当点击sign in注册按钮时,用onClick{}绑定一个提交函数。为了方便我们后面用ajax提交,所以要在提交函数里面,把默认提交行为给组织掉:e.preventDefault();

引入ajax: import $ from 'jquery'

components/content/login.jsx:

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

class Login extends Component {
    state = { 
        error_message: "",
        username: "",
        password: "",
     };

     handleClick = e => {
        e.preventDefault();
        if (this.state.username === "") {
            this.setState({error_message: "用户名不能为空"});
        } else if (this.state.password === "") {
            this.setState({error_message: "密码不能为空"});
        } else {
            $.ajax({
                url: "http://127.0.0.1:8000/login/",
                type: "get",
                data: {
                    username: this.state.username,
                    password: this.state.password,
                },
                dataType: 'json',
                success: (resp) => {
                    console.log(resp);
                    if (resp.result === "success") {
                        //js重定向,登陆成功后直接跳转到计算器页面
                        window.location.href="/calculator";
                    } else {
                        this.setState({error_message: resp.result});
                    }
                },
                error() {
                    this.setState({error_message: "系统获取数据失败QAQ"});
                }
            })

        }
        console.log(this.state);
     }

    render() { 
        return (
            <div className="homeBox">
                <div className="box-container">
                    <div className="login-box">
                        <div className="apple-btn login-apple">
                            <li className="red-btn"></li>
                            <li className="yellow-btn"></li>
                            <li className="green-btn"></li>
                        </div>
                        <div className="title">Login</div>
                        <form >
                            <div className="input">
                                <input onChange={(e) => {this.setState({username: e.target.value})}} type="text" id="login-user" placeholder="Input your username" />
                            </div>
                            <div className="input">
                                <input onChange={e => {this.setState({password: e.target.value})}} type="password" id="login-password" placeholder="Input your password" />
                                <div className='error-message' style={{height: "2rem"}} >
                                    {this.state.error_message}
                                 </div>
                            </div>
                            <button onClick={e => {this.handleClick(e)}} type="submit" className="btn login-btn">Sign in</button>
                        </form>
                        <div className="change-box login-change">
                            <div className="change-btn toSign">
                                <Link to={'/register'} style={{textDecoration: "none",color: "black"}}>
                                    <span>Register</span>
                                </Link>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        );
    }
}

export default Login;

登录页面效果

calc-login.png

完善注册页面

register.jsx:

import React, { Component } from 'react';
import $ from 'jquery'
import '../../register.css'
import { Link } from 'react-router-dom';


class Register extends Component {
    state = { 
        username: "",
        password: "",
        confirmed_password: "",
        error_message: "",
     };

     handleClick = e => {
        e.preventDefault();
        $.ajax({
            url: "http://127.0.0.1:8000/register/",
            type: "get",
            data: {
                username: this.state.username,
                password: this.state.password,
                password_confirm: this.state.confirmed_password,
            },
            dataType: 'json',
            success: resp => {
                if (resp.result === "success") {
                    window.location.href="/calculator";
                } else {
                       this.setState({error_message: resp.result});
                 }
            }
        });
        console.log(this.state);

     }

    render() { 
        return (
            <div className="homeBox">
                <div className="box-container">
                    <div className="sign-box">
                        <div className="apple-btn sign-apple">
                            <li className="red-btn"></li>
                            <li className="yellow-btn"></li>
                            <li className="green-btn"></li>
                        </div>
                        <div className="title">Sign</div>
                        <form>
                            <div className="input">
                                <input onChange={(e) => {this.setState({username: e.target.value})}} type="text" id="sign-user" placeholder="Have A Good Name?" />
                            </div>
                            <div className="input">
                                <input onChange={(e) => {this.setState({password: e.target.value})}}  type="password" id="sign-password" placeholder="Keep Secret" />
                            </div>
                            <div className="input">
                                <input onChange={(e) => {this.setState({confirmed_password: e.target.value})}} type="password" id="password_confirm" placeholder="Confirm Your Password" />
                                <div className='error-message' style={{height: "2rem"}} >
                                    {this.state.error_message}
                                 </div>
                            </div>
                            <button onClick={e => {this.handleClick(e)}} type="submit" className="btn sign-btn">Sign up</button>
                        </form>
                        <div className="change-box sign-change">
                            <div className="change-btn toLogin" onClick={this.gotoLogin}>
                                <Link to={'/login'} style={{textDecoration: "none",color: "white"}}>
                                    <span>Login</span>
                                </Link>
                            </div>
                        </div>
                    </div>
                </div>
          </div>
        );
    }
}

export default Register;

注册页面效果:

react-register.png

实现退出功能

前端需要特判下,如果我们没有登录的话就不显示计算器页面,登录成功后才显示计算器页面,同时显示一个用户名与退出按钮。

因此,我们可以在app.jsx里的state中加入一个属性is_login表示有没有登录成功,true表示已登录,false表示未登录;一个属性username,存放登录的用户名。

app.jsx中的子组件NavBar里传入is_loginusername参数,以实现上述特判功能。

app.jsx:

import React, { Component } from 'react';
import NavBar from './navbar';
import {Routes, Route, Navigate} from 'react-router-dom'
import Calculator from './content/calculator';
import Home from './content/home';
import Login from './content/login';
import Register from './content/register';
import NotFound from './content/notFound';
import $ from 'jquery'


class App extends Component {
    state = { 
        is_login: true,
        username: "",
     } 

     //动态获取当前登录状态
     //ajax一般在componentDidMount()里写,这个函数是在组件挂载完后执行
     componentDidMount() {
        $.ajax({
            url: "http://127.0.0.1:8000/get_status/",
            type: "get",
            success : resp => {
                console.log(resp);
                if (resp.result === "login") {
                    this.setState({
                        is_login: true,
                        username: resp.username,
                    })
                } else {
                    this.setState({
                        is_login: false,
                    })
                }
            }
        })
     }

    render() { 
        return (
            <React.Fragment>
                <NavBar is_login={this.state.is_login} username={this.state.username} />
                <div className="container">
                    <Routes>
                        <Route path='/calculator' element={ <Calculator />  } />
                        <Route path='/' element={  <Home />  } />
                        <Route path='/login' element={<Login />} />
                        <Route path='/register' element={ <Register />} />
                        {/* <Route path='/calculator' element={this.state.is_login ? <Calculator /> : <Navigate replace to="/login" /> } />
                        <Route path='/' element={ this.state.is_login ? <Home /> : <Navigate replace to="/login" /> } />
                        <Route path='/login' element={this.state.is_login ? <Navigate replace to="/" /> : <Login />} />
                        <Route path='/register' element={this.state.is_login ? <Navigate replace to="/" /> : <Register />} /> */}
                        <Route path='/404' element={<NotFound />} />
                        <Route path="*" element={ <Navigate replace to="/404" /> } />
                    </Routes>
                </div>
            </React.Fragment>
        );
    }
}

export default App;

navbar.jsx:

import React, { Component } from 'react';
import {Link} from 'react-router-dom'
import $ from 'jquery'

class NavBar extends Component {
    state = {  };

    handleClick = () => {
        $.ajax({
            url: "http://127.0.0.1:8000/logout/",
            type: "get",

            success: (resp) => {
                console.log(resp);
                if (resp.result === "success") {
                    window.location.href="/login";
                }
            }
        })
    }

    render_user = () => {
        if (this.props.is_login) {
            return (
                <li className="nav-item">
                    <Link className="nav-link active" aria-current="page" to="/">Home</Link>
                    <Link className="nav-link active" aria-current="page" to="#">{this.props.username}</Link>
                    <Link onClick={this.handleClick} className="nav-link active" aria-current="page" to="#">LOGOUT</Link>
                </li>
            )
        } else return (
            <li className="nav-item">
                <Link className="nav-link active" aria-current="page" to="/login">login</Link>
                <Link className="nav-link active" aria-current="page" to="/register">register</Link>
            </li>
        )
    }

    render_APP = () => { //是否需要渲染App栏目
        if (this.props.is_login) {
            return (
            <li className="nav-item dropdown">
                <a className="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
                APP
                </a>
                <ul className="dropdown-menu dropdown-menu-dark">
                    <li><Link className="dropdown-item" to="/calculator">Calculator</Link></li>
                </ul>
            </li>
            )
        }
        else return "";
    }

    render_photo() {
        if (this.props.is_login) {
            return (
                <img style={{margin: "10px auto", width: "300px", height:"300px"}} src="https://cdn.acwing.com/media/user/profile/photo/118375_lg_e2515ed3ad.jpg" alt="" />

            )
        }
        else return (
            <img style={{margin: "10px auto", width: "300px", height:"300px"}} src="https://avatars.githubusercontent.com/u/83831450?v=4" alt="" />
        )
    }

    render() { 
        return (
        <nav className="navbar navbar-dark bg-dark">
            <div className="container">
                <Link className="navbar-brand" to="/">Web App</Link>
                <button className="navbar-toggler" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasDarkNavbar" aria-controls="offcanvasDarkNavbar">
                <span className="navbar-toggler-icon"></span>
                </button>
                <div className="offcanvas offcanvas-end text-bg-dark" tabIndex="-1" id="offcanvasDarkNavbar" aria-labelledby="offcanvasDarkNavbarLabel">
                    <div className="offcanvas-header">
                        <h5 className="offcanvas-title" id="offcanvasDarkNavbarLabel">Web App</h5>
                        <button type="button" className="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
                    </div>
                    <div className="offcanvas-body">
                        <ul className="navbar-nav justify-content-end flex-grow-1 pe-3">
                            {this.render_user()}
                            {this.render_APP()}
                            {this.render_photo()}
                        </ul>
                    </div>
                </div>
            </div>
        </nav>
        );
    }
}

export default NavBar;


活动打卡代码 工程课 Web-5.4. Redux

yume
18天前

Redux

代码地址

redux将所有数据存储到树中,且树是唯一的。

基本作用:存储全局数据

Redux基本概念

  • store:存储树的结构。以字典的形式存储每个节点对应的数据(state),可以嵌套字典。
  • state: 维护的数据,一般维护成树的结构,树的每个节点都是一个state,都有一个reducer函数。
  • reducer:对state进行更新的函数,每个state绑定一个reducer。传入两个参数:当前stateaction,返回(return)新state。更新数据的时候,不管修改的是哪一个子节点的值,Redux都会将整棵树都修改一遍(dispatch)。
  • action:一个普通对象(字典),存储reducer的传入参数,一般描述对state的更新类型(type)。每个节点都有唯一对应的type(type是可以自己定义的,名字可以随便取)。`
  • dispatch:传入一个参数action,对整棵state树操作一遍,将参数action传递给整棵树的reducer函数。从根节点的reducer开始,以深度优先遍历的顺序递归调用每个子节点的reducer,如果是对应的action里面的相同的type就进行修改操作,不是的话就不修改。
简单案例

构建一个简单的树,f3为根节点,f2,f1f3的两个子节点

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import { configureStore } from '@reduxjs/toolkit';
import { combineReducers } from 'redux';

const f1 = (state = 10, action) => { //reducer函数
  switch (action.type) {
    case 'add':
      return state + action.value;
    case 'sub':
      return state - action.value;
    default:
      return state;
  }
};

const f2 = (state = "", action) => {
  switch (action.type) {
    case "concat":
      return state + action.character;
    default:
      return state;
  }
}

// const f3 = (state = {}, action) => { //将f1,f2组合起来,f3为父节点
//   return {
//     f1: f1(state.f1, action),
//     f2: f2(state.f2, action),
//   }
// }

const f3 = combineReducers({//与上面等价
  f1: f1,
  f2: f2,
})

const store = configureStore({ //构建store状态树
  reducer: f3
});

store.subscribe(() => { console.log(store.getState()) }); //在每次dispatch执行完后执行一个函数

store.dispatch({ type: "add", value: 1 }); // 将action参数传递给整棵树的所有reducer函数
store.dispatch({ type: "add", value: 2 }); // 将action参数传递给整棵树的所有reducer函数
store.dispatch({ type: "sub", value: 3 }); // 将action参数传递给整棵树的所有reducer函数
store.dispatch({ type: "add", value: 4 }); // 将action参数传递给整棵树的所有reducer函数
store.dispatch({ type: "sub", value: 3 }); // 将action参数传递给整棵树的所有reducer函数
store.dispatch({ type: "concat", character: "hhh" });



//console.log(store.getState()); //返回整棵树的值



const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>

  </React.StrictMode>
);

React-Redux基本概念

  • Provider组件:用来包裹整个项目,其store属性用来存储reduxstore对象。Provider一定要传一个属性store

  • connect(mapStateToProps, mapDispatchToProps)(组件)函数:用来将store与组件关联起来。

  • mapStateToProps:是个函数,每次store中的状态更新后调用一次,用来更新组件中的值。将store里的state的值绑定到组件的Props属性上。(每次store.dispatch()后都会调用一次)

  • mapDispatchToProps:是个对象,组件创建时调用一次,用来将storedispatch函数传入组件。connect函数不仅会将misDispatchToProps中定义的函数映射到组件的props属性上,还会将这个返回值作用到整个状态树的dispatch()函数中,从而达到更新状态树的目的,将状态树的每个节点重新渲染。(只会在创建的时候调用一次)

    mapStateToPropsmapDispatchToProps的名字是可以随便取的,只不过一般用这个做名字。

简单案例

案例结构:

redux.png

实现numberstring之间的信息传递。

一、 访问在number.jsxstring.jsx里访问全局变量

index.js

  import React from 'react';
  import ReactDOM from 'react-dom/client';
  import './index.css';
  import { configureStore } from '@reduxjs/toolkit';
  import { combineReducers } from 'redux';
  import { Provider } from 'react-redux';
  import App from './components/app';


  const f1 = (state = 10, action) => { //reducer函数
    switch (action.type) {
      case 'add':
        return state + action.value;
      case 'sub':
        return state - action.value;
      default:
        return state;
    }
  };

  const f2 = (state = "", action) => {
    switch (action.type) {
      case "concat":
        return state + action.character;
      default:
        return state;
    }
  }

  // const f3 = (state = {}, action) => { //将f1,f2组合起来,f3为父节点
  //   return {
  //     f1: f1(state.f1, action),
  //     f2: f2(state.f2, action),
  //   }
  // }

  const app = combineReducers({//与上面等价
    number: f1,
    string: f2,
  })

  const store = configureStore({ //构建store状态树
    reducer: app
  });

  //store.subscribe(() => { console.log(store.getState()) }); //在每次dispatch执行完后执行一个函数

  store.dispatch({ type: "add", value: 1 }); // 将action参数传递给整棵树的所有reducer函数
  store.dispatch({ type: "add", value: 2 }); // 将action参数传递给整棵树的所有reducer函数
  store.dispatch({ type: "sub", value: 3 }); // 将action参数传递给整棵树的所有reducer函数
  store.dispatch({ type: "add", value: 4 }); // 将action参数传递给整棵树的所有reducer函数
  store.dispatch({ type: "sub", value: 3 }); // 将action参数传递给整棵树的所有reducer函数
  store.dispatch({ type: "concat", character: "hhh" });



  //console.log(store.getState()); //返回整棵树的值



  const root = ReactDOM.createRoot(document.getElementById('root'));
  root.render(
    <Provider store={store}>
      <App />
    </Provider>
  );

number.jsx为例访问全局变量

  import React, { Component } from 'react';
  import {connect} from 'react-redux'

  class Number extends Component {
      state = {  } 
      render() { 
          console.log(this.props);
          return (
              <React.Fragment>
                  <h1>Number</h1>
                  <div>{this.props.number}</div>
              </React.Fragment>
          );
      }
  }

  //state表示store.getState()之后的结果,即整个状态树的树结构(不包含reducer函数,只包含state的值)
  //每次调用完store.dispatch()后都会执行一遍mapStateToProps函数
  const mapStateToProps = (state, props) => {
      return {
          number: state.number, //在index.js中combineReducers定义的number
      }
  }



  export default connect(mapStateToProps)(Number);

connect(mapStateToProps)会返回一个新的函数,新函数的输入参数为Number组件,返回一个新的组件,将state.number绑定到当前组件的props里的number属性上。
这个写法与之前的类组件调用useParams的写法类似,都是通过外套一个函数,将属性值传递给里面的组件,从而使得里面的组件可以用props.属性名调用该属性。

每次调用完store.dispatch()之后(修改了某个节点的state值)connect函数就会执行,里面会调用mapStateToProps将整个Number组件里的state值重新渲染一遍。

二、修改全局变量

以在string.jsx中修改number中的值为例

string.jsx

  import React, { Component } from 'react';
  import { connect } from 'react-redux';

  class String extends Component {
      state = {  } 

      handleClickAdd = () => {
          this.props.add(10);

      }

      handleClickSub = () => {
          this.props.sub(1);

      }

      render() { 
          return (
              <React.Fragment>
                  <h1>string:</h1>
                  <div>{this.props.string}</div>
                  <button onClick={this.handleClickAdd} >Add</button>
                  <button onClick={this.handleClickSub} >Sub</button>
              </React.Fragment>
          );
      }
  }

  //state表示store.getState()之后的结果,即整个状态树的树结构(不包含reducer函数,只包含state的值)
  const mapStateToProps = (state, props) => {
      return {
          string: state.string, //在index.js中combineReducers定义的string
      }
  }

  const mapDispatchToProps = {
      add: x => {
          return {
              type: "add",
              value: x,
          }
      },
      sub: x => {
          return {
              type: "sub",
              value: x,
          }
      }
  }


  export default connect(mapStateToProps,mapDispatchToProps)(String);

  /*

  `connect`函数不仅会将`misDispatchToProps`中定义的函数映射到组件的`props`属性上,
  还会将这个返回值作用到整个状态树的`dispatch()`函数中,从而达到更新状态树的目的,将状态树的每个节点重新渲染。
  */

安装

npm i redux react-redux @reduxjs/toolkit