TypeScript
动态类型的问题
js属于动态类型语言,例如
obj可能只是个字符串
obj也有可能是个函数
1
| test(()=>console.log('hello, world'))
|
obj 类型不确定,就给后期使用者带来了麻烦,一旦传入的参数不对,代码就崩溃了
动态类型意味着
- 运行代码时才知道发生什么 (running the code to see what happens)
静态类型意味着
- 在代码运行前,就对它的行为做出预测 (make predications about what code is expected before it runs)
TypeScript的引入
TypeScript是微软开发的一个开源的编程语言,通过在JavaScript的基础上添加静态类型定义构建而成。
TypeScript通过TypeScript编译器或Babel转译为JavaScript代码,可运行在任何浏览器,任何操作系统。
下面的typescript代码,就在代码运行前对参数加入了约束限制
1 2
| function test(msg : string) { }
|
1 2 3
| function test(msg : Function) { msg() }
|
TypeScript的入门
安装typescript编译器
1
| npm install -g typescript
|
编写 ts 代码
1 2 3 4 5
| function hello(msg: string) { console.log(msg) }
hello('hello,world')
|
执行 tsc 编译命令
编译生成js代码,编译后进行了类型擦除
1 2 3 4
| function hello(msg) { console.log(msg); } hello('hello,world');
|
例子,用interface定义用户类型
1 2 3 4 5 6 7 8 9 10 11
| interface User { name: string, age: number }
function test(u: User): void { console.log(u.name) console.log(u.age) }
test({ name: 'zhangs', age: 18 })
|
编译后
1 2 3 4 5
| function test(u) { console.log(u.name); console.log(u.age); } test({ name: 'zhangs', age: 18 });
|
可见,typescript属于编译时实施类型检查(静态类型)的技术
typescript类型
类型 |
例 |
备注 |
字符串类型 |
string |
|
数字类型 |
number |
|
布尔类型 |
boolean |
|
数组类型 |
number[],string[], boolean[] 依此类推 |
|
任意类型 |
any |
相当于又回到了没有类型的时代 |
复杂类型 |
type 与 interface |
|
函数类型 |
() => void |
对函数的参数和返回值进行说明 |
字面量类型 |
“a”|”b”|”c” |
限制变量或参数的取值 |
nullish类型 |
null与 undefined |
|
泛型 |
<T> ,<T extends 父类型> |
|
标注位置
标注变量
1
| let message: string = 'hello,world'
|
- 一般可以省略,因为可以根据后面的字面量推断出前面变量类型
1
| let message = 'hello,world'
|
标注参数
1 2 3
| function greet(name: string) { }
|
很多时候,都能够推断出参数类型
1 2
| const names = ['Alice', 'Bob', 'Eve'] const lowercaseNames = names.map((e: string) => e.toLowerCase())
|
- 可以用类型推断,推断出 e 是 string 类型
标注返回值
1 2 3
| function add(a: number, b: number) : number { return a + b }
|
复杂类型
type
1 2 3 4 5 6 7 8
| type Cat = { name: string, age: number }
const c1: Cat = { name: '小白', age: 1 } const c2: Cat = { name: '小花' } const c3: Cat = { name: '小黑', age: 1, sex: '公' }
|
interface
1 2 3 4 5 6 7 8
| interface Cat { name: string, age: number }
const c1: Cat = { name: '小白', age: 1 } const c2: Cat = { name: '小花' } const c3: Cat = { name: '小黑', age: 1, sex: '公' }
|
可选属性
如果需要某个属性可选,可以用下面的语法
1 2 3 4 5 6 7
| interface Cat { name: string, age?: number }
const c1: Cat = { name: '小白', age: 1 } const c2: Cat = { name: '小花' }
|
鸭子类型
1 2 3 4 5 6 7 8 9 10
| interface Cat { name: string }
function test(cat: Cat) { console.log(cat.name) }
const c1 = { name: '小白', age: 1 } test(c1)
|
- const c1 并没有声明类型为 Cat,但它与 Cat 类型有一样的属性,也可以被当作是 Cat 类型
方法类型
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| interface Api { foo(): void, bar(str: string): string }
function test(api: Api) { api.foo() console.log(api.bar('hello')) }
test({ foo() { console.log('ok') }, bar(str: string) { return str.toUpperCase() } })
|
字面量类型
1 2 3 4 5 6
| function printText(s: string, alignment: "left" | "right" | "center") { console.log(s, alignment) }
printText('hello', 'left') printText('hello', 'aaa')
|
nullish 类型
1 2 3 4 5 6 7
| function test(x?: string | null) { console.log(x?.toUpperCase()) }
test('aaa') test(null) test()
|
x?: string | null
表示可能是undefined或者是string或者是null
泛型
下面的几个类型声明显然有一定的相似性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| interface RefString { value: string }
interface RefNumber { value: number }
interface RefBoolean { value: boolean }
const r1: RefString = { value: 'hello' } const r2: RefNumber = { value: 123 } const r3: RefBoolean = { value: true }
|
可以改进为
1 2 3 4 5 6 7
| interface Ref<T> { value: T }
const r1: Ref<string> = { value: 'hello' } const r2: Ref<number> = { value: 123 } const r3: Ref<boolean> = { value: true }
|
- 泛型的要点就是
<类型参数>
,把【类型】也当作一个变化的要素,像参数一样传递过来,这样就可以派生出结构相似的新类型
函数定义也支持泛型
1 2 3 4 5 6 7 8 9
| function ref<T>(n: T): Ref<T> { return { value: n } }
const v1 = ref("hello"); const v2 = ref(123.3333);
v1.value.toLocaleLowerCase() v2.value.toFixed(2)
|
意义
更好理解框架
现在越来越多的前端框架采用 typescript,如果懂 typescript 语法,可以更好地阅读框架代码
以 Map 为例
1 2 3 4 5 6 7 8
| const map = new Map<string, string>() map .set("a", "b") .set("c", "d")
map.forEach((value,key,m)=>{ console.log(value, key) })
|
- 注意编译需要
tsc --target es6 .\xxx.ts
更好的提示
例如,从服务器返回的一段 json,如果不用 typescript,则编辑器也不能给出准确的提示
1 2 3 4 5 6
| interface User { name: string, age: number }
const user: User = JSON.parse(`{ "name":"张三", "age":18 }`)
|
类
关于TypeScript与JavaScript中的类语法不是重点,class相关语法只是起到辅助作用,更重要的是interface
基本语法
1 2 3 4 5 6 7 8 9
| class User { name: string; constructor(name: string) { this.name = name } }
const u = new User('张三')
|
其实会被编译成这个样子(默认 –target=es3)
1 2 3 4 5 6 7
| var User = (function () { function User(name) { this.name = name; } return User; }()); var u = new User('张三');
|
所以js中的class,并不等价于java中的class,它还是基于原型实现的
只读属性
1 2 3 4 5 6 7 8 9 10
| class User { readonly name: string; constructor(name: string) { this.name = name } }
const u = new User('张三') u.name = '李四'
|
- readonly是typescript特有的,表示该属性只读
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class User { readonly name: string; constructor(name: string) { this.name = name }
study() { console.log(`[${this.name}]正在学习`) } }
const u = new User('张三') u.study()
|
get,set
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| class User { _name: string;
constructor(name: string) { this._name = name }
get name() { return this._name }
set name(name: string) { this._name = name } }
const u = new User('张三') console.log(u.name) u.name = '李四' console.log(u.name)
|
- 注意,需要在编译时加上
tsc --target es6 .\xxx.ts
选项
- es6等价于es2015,再此之上还有es2016 … es2022
类与接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| interface User { name: string study(course: string): void }
class UserImpl implements User { name: string; constructor(name: string) { this.name = name } study(course: string) { console.log(`[${this.name}]正在学习[${course}]`) } foo() { } }
const user: User = new UserImpl('张三') user.study('Typescript') user.foo()
|
继承与接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| interface Flyable { fly(): void }
class Animal { name: string; constructor(name: string) { this.name = name } }
class Bird extends Animal implements Flyable { fly() { console.log(`${this.name}在飞翔`) } }
const b: Flyable & Animal = new Bird("小花") b.fly()
|
- Flyable&Animal表示变量是flyable类型,同时也是Animal类型
方法重写
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class Father { study(): void { console.log(`father study`) } }
class Son extends Father { study(): void { super.study() console.log(`son study`) } }
const f: Father = new Son() f.study()
|
Vue3基础
技术选型
- Vue
- 选项式 API 还是 组合式 API✔️
- HTML 还是 单文件组件✔️
- 语法
- javascript 还是 typescript✔️
- 构建工具
- 路由
- 共享存储
- 视图组件
环境准备
创建项目
采用vite作为前端项目的打包,构建工具
按提示操作
1 2 3
| cd 项目目录 npm install npm run dev
|
编码 IDE
Vue官方推荐采用微软的VSCode作为开发工具,到VSCode的官网 Visual Studio Code 下载安装即可
要对*.vue
做语法支持,还需VSCode安装一个Volar
插件
配置修改
修改端口
打开项目根目录下vite.config.ts
1 2 3 4 5 6 7 8 9 10
| import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue'
export default defineConfig({ plugins: [vue()], server: { port: 7070 } })
|
配置代理
为了避免前后端服务器联调时, fetch、xhr请求产生跨域问题,需要配置代理,同样是修改项目根目录下vite.config.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue'
export default defineConfig({ plugins: [vue()], server: { port: 7070, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })
|
项目结构
1 2 3 4 5 6 7 8 9 10 11 12
| index.html package.json tsconfig.json vite.config.ts ├─public └─src ├─assets ├─components ├─model ├─router ├─store └─views
|
- index.html 为主页面
- package.json npm 配置文件
- tsconfig.json typescript 配置文件
- vite.config.ts vite 配置文件
- public 静态资源
- src/components 可重用组件
- src/model 模型定义
- src/router 路由
- src/store 共享存储
- src/views 视图组件
Vue 组件
Vue 的组件文件以.vue
结尾,每个组件由三部分组成
1 2 3 4 5
| <script setup lang="ts"></script>
<template></template>
<style scoped></style>
|
- script代码部分,控制模板的数据来源和行为
- template模板部分,由它生成html代码
- style样式部分,一般不关心
根组件是src/App.vue
,演示Helloworld例子
1 2 3 4 5 6 7 8 9 10 11 12 13
| <script setup lang="ts"> import { ref } from "vue"; let msg = ref("hello"); // 把数据变成响应式的
function change() { msg.value = "world"; console.log(msg); } </script> <template> <h1>{{ msg }}</h1> <input type="button" value="修改msg" @click="change" /> </template>
|
{{msg}}
用来将一个变量绑定到页面上某个位置
- 绑定的变量必须用
ref
函数来封装
ref
返回的是【响应式】数据,即数据一旦变化,页面展示也跟着变化
main.ts
1 2 3 4 5 6
| import { createApp } from 'vue' import './style.css' import App from './App.vue'
createApp(App) .mount('#app')
|
- createApp是创建一个Vue应用程序,它接收的参数App即之前的根组件
- mount就是把根组件生成的html代码片段【挂载】到index.html中id为app的html元素上
可以修改自己的组件文件,挂载到主页面
新建 src/views/E0.vue,内容如下
1 2 3 4 5 6 7
| <script setup lang="ts"> import { ref } from 'vue' const msg = ref('Hello, World!!') </script> <template> <h1>{{ msg }}</h1> </template>
|
修改 main.ts 将自己的组件文件挂载
1 2 3 4 5 6
| import { createApp } from 'vue' import './style.css'
import E0 from './views/E0.vue'
createApp(E0).mount('#app')
|
ref 与 reactive
vue提供了两个函数,都可以将数据变为【响应式】数据
1 2 3 4 5 6 7 8 9 10
| <script setup lang="ts"> import { ref, reactive } from 'vue' const msg = ref('Hello, World') const user = reactive({ name: '张三' }) </script>
<template> <h2>{{msg}}</h2> <h2>{{user.name}}</h2> </template>
|
- ref能将任意类型的数据变为【响应式】数据
- reactive只能将对象类型变为【响应式】,对基本类型无效(例如string,number,boolean)
还有一点不同
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <script setup lang="ts"> import { ref, reactive } from 'vue' const u1 = ref({ name: '张三' }) const u2 = reactive({ name: '张三' })
function test() { console.log(u1.value) console.log(u2) }
test() </script> <template> <h2>{{u1}}</h2> <!-- { "name": "张三" } --> <h2>{{u2}}</h2> <!-- { "name": "张三" } --> <h2>{{u1.name}}</h2> <!-- 张三 --> <h2>{{u2.name}}</h2> <!-- 张三 --> </template>
|
- 在 template模板中使用ref包装的数据,直接写【变量名】就可以了
- 但在代码中要使用ref包装的数据,必须用
变量名.value
才能访问到
- reactive包装的数据,在模板中和代码中都是一致的
属性绑定
1 2 3 4 5 6 7 8 9
| <script setup lang="ts"> import { ref } from 'vue' const path = ref('/src/assets/vue.svg')
</script>
<template> <img :src="path" alt=""> </template>
|
事件绑定
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <script setup lang="ts"> import { ref } from 'vue' const count = ref(0) function dec() { count.value-- } function inc() { count.value++ } </script>
<template> <input type="button" value="-" @click="dec"> <h2>{{count}}</h2> <input type="button" value="+" @click="inc"> </template>
|
@事件名
用来将标签属性与函数绑定,事件发生后执行函数内代码
表单绑定
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| <script setup lang="ts"> import { ref } from "vue"; const user = ref({ name:'张三', age:18, sex:'男', fav:['游泳','打球'] })
function saveUser() { console.log(user.value) } </script>
<template> <div class="outer"> <div> <label for="">请输入姓名</label> <input type="text" v-model="user.name"/> </div> <div> <label for="">请输入年龄</label> <input type="text" v-model="user.age"/> </div> <div> <label for="">请选择性别</label> 男 <input type="radio" value="男" v-model="user.sex"/> 女 <input type="radio" value="女" v-model="user.sex"/> </div> <div> <label for="">请选择爱好</label> 游泳 <input type="checkbox" value="游泳" v-model="user.fav"/> 打球 <input type="checkbox" value="打球" v-model="user.fav"/> 健身 <input type="checkbox" value="健身" v-model="user.fav"/> </div> <div> <input type="button" value="保存" @click="saveUser"> </div> </div> </template>
<style scoped> div { margin-bottom: 8px; } .outer { width: 100%; position: relative; padding-left: 80px; } label { text-align: left; width: 100px; display: inline-block; position: absolute; left :0; } </style>
|
- 用
v-model
实现双向绑定,即
- javascript数据可以同步到表单标签
- 反过来用户在表单标签输入的新值也会同步到javascript这边
- 双向绑定只适用于表单这种带【输入】功能的标签,其它标签的数据绑定,单向就足够了
- 复选框这种标签,双向绑定的javascript数据类型一般用数组
计算属性
有时需要在数据展示时做简单的计算
1 2 3 4 5 6 7 8 9 10 11 12
| <script setup lang="ts"> import { ref } from 'vue' const firstName = ref('三') const lastName = ref('张')
</script>
<template> <h2>{{lastName + firstName}}</h2> <h3>{{lastName + firstName}}</h3> <h4>{{lastName + firstName}}</h4> </template>
|
看起来较为繁琐,可以用计算属性改进
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <script setup lang="ts"> import { ref, computed } from 'vue' const firstName = ref('三') const lastName = ref('张') const fullName = computed(() => { console.log('enter') return lastName.value + firstName.value }) </script>
<template> <h2>{{fullName}}</h2> <h3>{{fullName}}</h3> <h4>{{fullName}}</h4> </template>
|
- fullName即为计算属性,它具备缓存功能,即firstName 和 lastName的值发生了变化,才会重新计算
- 如果用函数实现相同功能,则没有缓存功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <script setup lang="ts"> import { ref } from 'vue' const firstName = ref('三') const lastName = ref('张') function fullName() { console.log('enter') return lastName.value + firstName.value } </script> <template> <h2>{{fullName()}}</h2> <h3>{{fullName()}}</h3> <h4>{{fullName()}}</h4> </template>
|
xhr
浏览器中有两套API可以和后端交互,发送请求、接收响应,fetch api已说明过,另一套api是xhr,基本用法如下
1 2 3 4 5 6 7
| const xhr = new XMLHttpRequest() xhr.onload = function() { console.log(xhr.response) } xhr.open('GET', 'http://localhost:8080/api/students') xhr.responseType = "json" xhr.send()
|
但这套api虽然功能强大,但比较老,不直接支持Promise,因此有必要对其进行改造
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| function get(url: string) { return new Promise((resolve, reject)=>{ const xhr = new XMLHttpRequest() xhr.onload = function() { if(xhr.status === 200){ resolve(xhr.response) } else if(xhr.status === 404) { reject(xhr.response) } } xhr.open('GET', url) xhr.responseType = 'json' xhr.send() }) }
|
- Promise对象适合用来封装异步操作,并可以配合await使用
- Promise在构造时,需要一个箭头函数,箭头函数有两个参数resolve和reject
- resolve是异步操作成功时被调用,把成功的结果传递给它,最后会作为await的结果返回
- reject在异步操作失败时被调用,把失败的结果传递给它,最后在catch块被捉住
- await会一直等到Promise内调用了resolve或reject才会继续向下运行
调用示例1:同步接收结果,不走代理
1 2 3 4 5 6
| try { const resp = await get("http://localhost:8080/api/students") console.log(resp) } catch (e) { console.error(e) }
|
调用示例2:走代理
1 2 3 4 5 6
| try { const resp = await get('/api/students') console.log(resp) } catch(e) { console.log(e) }
|
axios
基本用法
axios 就是对xhr api的封装,手法与前面例子类似
安装
一个简单的例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <script setup lang="ts"> import { ref, onMounted } from "vue"; import axios from "axios";
let count = ref(0);
async function getStudents() { try { const resp = await axios.get("/api/students"); count.value = resp.data.data.length; } catch (e) { console.log(e); } }
onMounted(() => { getStudents() }) </script>
<template> <h2>学生人数为:{{ count }}</h2> </template>
|
- onMounted指vue组件生成的html代码片段,挂载完毕后被执行
再来一个post 例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| <script setup lang="ts"> import { ref } from "vue"; import axios from "axios";
const student = ref({ name: '', sex: '男', age: 18 })
async function addStudent() { console.log(student.value) const resp = await axios.post('/api/students', student.value) console.log(resp.data.data) } </script>
<template> <div> <div> <input type="text" placeholder="请输入姓名" v-model="student.name"/> </div> <div> <label for="">请选择性别</label> 男 <input type="radio" value="男" v-model="student.sex"/> 女 <input type="radio" value="女" v-model="student.sex"/> </div> <div> <input type="number" placeholder="请输入年龄" v-model="student.age"/> </div> <div> <input type="button" value="添加" @click="addStudent"/> </div> </div> </template> <style scoped> div { font-size: 14px; } </style>
|
环境变量
- 开发环境下,联调的后端服务器地址是
http://localhost:8080
,
- 上线改为生产环境后,后端服务器地址为
http://xxx.com
这就要求区分开发环境和生产环境,这件事可交给构建工具vite来做
默认情况下,vite支持上面两种环境,分别对应根目录下两个配置文件
- .env.development - 开发环境
- .env.production - 生产环境
针对以上需求,分别在两个文件中加入
1
| VITE_BACKEND_API_BASE_URL = 'http://localhost:8080'
|
和
1
| VITE_BACKEND_API_BASE_URL = 'http://xxx.com'
|
然后在代码中使用vite给提供的特殊对象 import.meta.env
,就可以获取到 VITE_BACKEND_API_BASE_URL
在不同环境下的值
1
| import.meta.env.VITE_BACKEND_API_BASE_URL
|
默认情况下,不能智能提示自定义的环境变量,做如下配置:新增文件 src/env.d.ts
并添加如下内容
1 2 3 4 5 6 7 8 9 10
|
interface ImportMetaEnv { readonly VITE_BACKEND_API_BASE_URL: string }
interface ImportMeta { readonly env: ImportMetaEnv }
|
baseURL
可以自己创建一个axios对象,方便添加默认设置,新建文件 /src/api/request.ts
1 2 3 4 5 6 7
| import axios from 'axios' const _axios = axios.create({ baseURL: import.meta.env.VITE_BACKEND_API_BASE_URL })
export default _axios
|
然后在其它组件中引用这个 ts 文件,例如 /src/views/E8.vue,就不用自己拼接路径前缀了
1 2 3 4 5
| <script setup lang="ts"> import axios from '../api/request' // ... await axios.post('/api/students', ...) </script>
|
拦截器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| import axios from 'axios' const _axios = axios.create({ baseURL: import.meta.env.VITE_BACKEND_API_BASE_URL })
_axios.interceptors.request.use( (config)=>{ config.headers = { Authorization: 'aaa.bbb.ccc' } return config }, (error)=>{ return Promise.reject(error) } )
_axios.interceptors.response.use( (response)=>{ if(response.data.code === 200) { return response } else if(response.data.code === 401) { return Promise.resolve({}) } }, (error)=>{ console.error(error) if(error.response.status === 400) { } else if(error.response.status === 401) { } return Promise.resolve({}) } )
export default _axios
|
处理响应时,又分成两种情况
- 后端返回的是标准响应状态码,这时会走响应拦截器第二个箭头函数,用 error.response.status 做分支判断
- 后端返回的响应状态码总是200,用自定义错误码表示出错,这时会走响应拦截器第一个箭头函数,用 response.data.code 做分支判断
另外
Promise.reject(error)
类似于将异常继续向上抛出,异常由调用者(Vue组件)来配合try ... catch
来处理
Promise.resolve({})
表示错误已解决,返回一个空对象,调用者中接到这个空对象时,需要配合?.
来避免访问不存在的属性
条件与列表
首先,新增模型数据 src/model/Model8080.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| export interface Student { id: number; name: string; sex: string; age: number; }
export interface SpringError { timestamp: string, status: number, error: string, message: string, path: string }
export interface SpringList<T> { data: T[], message?: string, code: number }
export interface SpringPage<T> { data: { list: T[], total: number }, message?: string, code: number }
export interface SpringString { data: string, message?: string, code: number }
import { AxiosResponse } from 'axios' export interface AxiosRespError extends AxiosResponse<SpringError> { } export interface AxiosRespList<T> extends AxiosResponse<SpringList<T>> { } export interface AxiosRespPage<T> extends AxiosResponse<SpringPage<T>> { } export interface AxiosRespString extends AxiosResponse<SpringString> { }
|
其中
- AxiosRespPage代表分页时的响应类型
- AxiosRespList代表返回集合时的响应类型
- AxiosRespString代表返回字符串时的响应类型
- AxiosRespError代表Spring出错时时的响应类型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
| <script lang="ts" setup> import { ref, onMounted } from "vue"; import axios from "../api/request"; import { Student, SpringList } from "../model/Model8080";
// 说明 students 数组类型为 Student[] const students = ref<Student[]>([]);
async function getStudents() { // 说明 resp.data 类型是 SpringList<Student> const resp = await axios.get<SpringList<Student>>("/api/students"); console.log(resp.data.data); students.value = resp.data.data; }
onMounted(() => getStudents()); </script> <template> <div class="outer"> <div class="title">学生列表</div> <div class="thead"> <div class="row bold"> <div class="col">编号</div> <div class="col">姓名</div> <div class="col">性别</div> <div class="col">年龄</div> </div> </div> <div class="tbody"> <div v-if="students.length === 0">暂无数据</div> <template v-else> <div class="row" v-for="s of students" :key="s.id"> <div class="col">{{ s.id }}</div> <div class="col">{{ s.name }}</div> <div class="col">{{ s.sex }}</div> <div class="col">{{ s.age }}</div> </div> </template> </div> </div> </template> <style scoped> .outer { font-family: 华文行楷; font-size: 20px; width: 500px; }
.title { margin-bottom: 10px; font-size: 30px; color: #333; text-align: center; }
.row { background-color: #fff; display: flex; justify-content: center; }
.col { border: 1px solid #f0f0f0; width: 15%; height: 35px; text-align: center; line-height: 35px; }
.bold .col { background-color: #f1f1f1; } </style>
|
- 加入泛型是为了更好的提示
- v-if 与 v-else 不能和 v-for 处于同一标签
- template 标签还有一个用途,就是用它少生成一层真正 html 代码
- 可以看到将结果封装为响应式数据还是比较繁琐的,后面会使用 useRequest 改进
监听器
利用监听器,可以在【响应式】的基础上添加一些副作用,把更多的东西变成【响应式的】
原本只是数据变化 => 页面更新
watch可以在数据变化时 => 其它更新
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <template> <input type="text" v-model="name" /> </template>
<script setup lang="ts"> import { ref, watch } from "vue"; function useStorage(name: string) { const data = ref(sessionStorage.getItem(name) ?? ""); watch(data, (newValue) => { sessionStorage.setItem(name, newValue); }); return data; } const name = useStorage("name"); </script>
|
- 名称为 useXXXX 的函数,作用是返回带扩展功能的【响应式】数据
- localStorage 即使浏览器关闭,数据还在
- sessionStorage 数据工作在浏览器活动期间
vueuse
安装
1
| npm install @vueuse/core
|
一些函数的用法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <template> <h3>X: {{x}}</h3> <h3>Y: {{y}}</h3>
<h3>{{count}}</h3> <input type="button" @click="inc()" value="+"> <input type="button" @click="dec()" value="-">
<input type="text" v-model="name"> </template> <script setup lang="ts"> import { useMouse, useCounter, useStorage } from '@vueuse/core'
const {x, y} = useMouse()
const {count, inc, dec} = useCounter()
const name = useStorage("name", "") </script>
|
useRequest
响应式的 axios 封装,官网地址 一个 Vue 请求库 | VueRequest (attojs.org)
首先安装 vue-request
1
| npm install vue-request@next
|
组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| <template> <h3 v-if="students.length === 0">暂无数据</h3> <ul v-else> <li v-for="s of students" :key="s.id"> <span>{{s.name}}</span> <span>{{s.sex}}</span> <span>{{s.age}}</span> </li> </ul> </template> <script setup lang="ts"> import axios from "../api/request" import { useRequest } from 'vue-request' import { computed } from 'vue' import { AxiosRespList, Student } from '../model/Model8080'
// data 代表就是 axios 的响应对象 const { data } = useRequest<AxiosRespList<Student>>(() => axios.get('/api/students'))
const students = computed(()=>{ return data?.value?.data.data || [] }) </script> <style scoped> ul li { list-style: none; font-family: "华文行楷"; }
li span:nth-child(1) { font-size: 24px; } li span:nth-child(2) { font-size: 12px; color: crimson; vertical-align: bottom; } li span:nth-child(3) { font-size: 12px; color: darkblue; vertical-align: top; } </style>
|
- data.value 的取值一开始是 undefined,随着响应返回变成 axios 的响应对象
- 用 computed 进行适配
在 src/model/Model8080.ts 中补充类型说明
1 2 3 4 5 6 7
| export interface StudentQueryDto { name?: string, sex?: string, age?: string, page: number, size: number }
|
- js中类似于18,20这样以逗号分隔字符串,会在get传参时转换为java中的整数数组
编写组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
| <template> <input type="text" placeholder="请输入姓名" v-model="dto.name"> <select v-model="dto.sex"> <option value="" selected>请选择性别</option> <option value="男">男</option> <option value="女">女</option> </select> <input type="text" placeholder="请输入年龄范围" v-model="dto.age"> <br> <input type="text" placeholder="请输入页码" v-model="dto.page"> <input type="text" placeholder="请输入页大小" v-model="dto.size"> <input type="button" value="搜索" @click="search"> <hr> <h3 v-if="students.length === 0">暂无数据</h3> <ul v-else> <li v-for="s of students" :key="s.id"> <span>{{s.name}}</span> <span>{{s.sex}}</span> <span>{{s.age}}</span> </li> </ul> <hr> 总记录数{{total}} 总页数{{totalPage}} </template> <script setup lang="ts"> import axios from "../api/request" import { usePagination } from 'vue-request' import { computed, ref } from 'vue' import { AxiosRespPage, Student, StudentQueryDto } from '../model/Model8080'
const dto = ref<StudentQueryDto>({name:'', sex:'', age:'', page:1, size:5})
// data 代表就是 axios 的响应对象 // 泛型参数1: 响应类型 // 泛型参数2: 请求类型 const { data, total, totalPage, run } = usePagination<AxiosRespPage<Student>, StudentQueryDto[]>( (d) => axios.get('/api/students/q', {params: d}), // 箭头函数 { defaultParams: [ dto.value ], // 默认参数, 会作为参数传递给上面的箭头函数 pagination: { currentKey: 'page', // 指明当前页属性 pageSizeKey: 'size', // 指明页大小属性 totalKey: 'data.data.total' // 指明总记录数属性 } } // 选项 )
const students = computed(()=>{ return data?.value?.data.data.list || [] })
function search() { run(dto.value) // 会作为参数传递给usePagination的箭头函数 } </script> <style scoped> ul li { list-style: none; font-family: "华文行楷"; }
li span:nth-child(1) { font-size: 24px; } li span:nth-child(2) { font-size: 12px; color: crimson; vertical-align: bottom; } li span:nth-child(3) { font-size: 12px; color: darkblue; vertical-align: top; } input,select { width: 100px; } </style>
|
- usePagination 只需要定义一次,后续还想用它内部的 axios 发请求,只需调用 run 函数
子组件
例1
定义子组件 Child1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| <template> <div class="container"> <div class="card"> <div> <p class="name">{{name}}</p> <p class="location">{{country}}</p> </div> <img :src="avatar || '/src/assets/vue.svg'"/> </div> </div> </template> <script setup lang="ts"> // 定义属性, 编译宏 defineProps<{name:string,country:string,avatar?:string}>() </script> <style scoped> .container { width: 100%; display: flex; flex-wrap: wrap; justify-content: space-evenly; flex-direction: row-reverse; } .name { font-weight: bold; } .location { font-size: 0.8em; color: #6d597a; } .card { display: flex; justify-content: space-evenly; padding: 1em; margin: 1rem; border-radius: 5px; background: #fff; width: 200px; box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); }
.card:hover { transform: rotate(-5deg); }
.card img { margin-left: 1em; border-radius: 50%; max-width: 55px; max-height: 55px; } </style>
|
父组件引用
1 2 3 4 5 6 7 8
| <template> <Child1 name="张三" country="中国" avatar="/src/assets/vue.svg"></Child1> <Child1 name="李四" country="印度" avatar="/vite.svg"></Child1> <Child1 name="王五" country="韩国" ></Child1> </template> <script lang="ts" setup> import Child1 from '../components/Child1.vue'; </script>
|
例2
首先添加类型说明 model/ModelRandomUser.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| import { AxiosResponse } from "axios"; export interface AxiosRespResults extends AxiosResponse<Results>{}
export interface Results { info: { page: number, results: number }, results: Result[] }
export interface Result { gender: 'male' | 'female', name: { first: string, last: string }, location: { country: string }, picture: { medium: string }, login: { username: string } }
|
子组件不变,父组件使用子组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <!-- 父组件 --> <template> <Child1 v-for="u of users" :name="u.name.first" :country="u.location.country" :avatar="u.picture.medium" :key="u.login.username"></Child1> </template> <script setup lang="ts"> import axios from "axios"; import { useRequest } from "vue-request"; import { computed } from "vue"; import { AxiosRespResults } from '../model/ModelRandomUser' import Child1 from "../components/Child1.vue";
const { data } = useRequest<AxiosRespResults>( ()=>axios.get('https://randomuser.me/api/?results=3') )
const users = computed(()=>{ return data.value?.data.results || [] }) </script>
|
如果觉得 Result 数据结构嵌套太复杂,还可以做一个类型映射
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| <!-- 父组件 --> <template> <Child1 v-for="u of users" :name="u.name" :country="u.country" :avatar="u.avatar" :key="u.username"></Child1> </template> <script setup lang="ts"> import axios from "axios"; import { useRequest } from "vue-request"; import { computed } from "vue"; import { AxiosRespResults, Result } from '../model/ModelRandomUser' import Child1 from "../components/Child1.vue";
const { data } = useRequest<AxiosRespResults>( ()=>axios.get('https://randomuser.me/api/?results=3') )
const users = computed(()=>{ return data.value?.data.results.map(resultToUser) || [] })
interface User { name: string, country: string, avatar: string, username: string } function resultToUser(r:Result):User { return { name: r.name.first, country: r.location.country, avatar: r.picture.medium, username: r.login.username } } </script>
|
- resultToUser 将 Result 类型映射为 User 类型
Vue进阶
Antdv
添加必要插件
1
| npm install ant-design-vue
|
引入antdv功能,修改main.ts
1 2 3 4 5 6 7
| import { createApp } from 'vue' import './style.css' import App from './App.vue' import antd from 'ant-design-vue' import 'ant-design-vue/dist/antd.css'
createApp(App).use(antd).mount('#app')
|
表格
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| <template> <!-- <a-table :columns="columns" :dataSource="students" rowKey="id"></a-table> --> <a-table :columns="columns" :dataSource="students" :rowKey="rowKey"></a-table> </template> <script setup lang="ts"> import axios from "../api/request"; import { ref, computed } from "vue"; import { useRequest } from "vue-request"; import { AxiosRespList, Student } from "../model/Model8080";
const {data} = useRequest<AxiosRespList<Student>>( ()=>axios.get('/api/students') )
const students = computed(()=>{ return data.value?.data.data || [] })
function rowKey(r:Student) { return r.id }
const columns = ref([ { title:'编号', dataIndex:'id' }, { title:'姓名', dataIndex:'name' }, { title:'性别', dataIndex:'sex' }, { title:'年龄', dataIndex:'age' } ]) </script>
|
分页
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
| <template> <a-table :columns="columns" :data-source="students" row-key="id" :pagination="pagination" @change="tableChange"></a-table> </template> <script setup lang="ts"> import axios from "../api/request"; import { ref, computed } from "vue"; import { usePagination } from "vue-request"; import { AxiosRespPage, Student, StudentQueryDto } from "../model/Model8080"; import { PaginationProps } from "ant-design-vue"; import DateBody from "ant-design-vue/lib/vc-picker/panels/DatePanel/DateBody";
const dto = ref({page: 1, size: 5})
const {data, total, run} = usePagination<AxiosRespPage<Student>, StudentQueryDto[]>( (d)=> axios.get('/api/students/q', {params:d}), { defaultParams: [dto.value], pagination: { currentKey: "page", pageSizeKey: 'size', totalKey: 'data.data.total' } } )
// 在页号或页大小改变时调用 function tableChange(pagination: PaginationProps) { console.log(pagination) dto.value.page = pagination.current ?? 1 dto.value.size = pagination.pageSize ?? 5 run(dto.value) }
const pagination = computed<PaginationProps>(()=>{ return { current: dto.value.page, // 当前页 pageSize: dto.value.size, // 页大小 total: total.value, // 总记录数 showSizeChanger: true, // 显示页大小的下拉列表 pageSizeOptions: ["1","2","3","4","5"] // 自定义下拉列表内容 } })
const students = computed(()=>{ return data.value?.data.data.list || [] })
const columns = ref([ { title: "编号", dataIndex: "id", }, { title: "姓名", dataIndex: "name", }, { title: "性别", dataIndex: "sex", }, { title: "年龄", dataIndex: "age", }, ]); </script>
|
搜索、删除
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176
| <template> <a-row> <a-col :span="2"> <a-button type="primary" size="small">新增</a-button> </a-col> <a-col :span="4"> <a-popconfirm title="确认要删除选中学生吗?" ok-text="确定" cancel-text="取消" @confirm="onDeleteIds" @visibleChange="onVisibleChange" :visible="visible"> <a-button type="primary" size="small">删除选中</a-button> </a-popconfirm> </a-col> <a-col :span="4"> </a-col> <a-col :span="4"> <a-input v-model:value="dto.name" placeholder="输姓名" size="small"></a-input> </a-col> <a-col :span="4"> <a-select v-model:value="dto.sex" placeholder="选性别" :allowClear="true" size="small"> <a-select-option value="男">男</a-select-option> <a-select-option value="女">女</a-select-option> </a-select> </a-col> <a-col :span="4"> <a-select v-model:value="dto.age" placeholder="选年龄" :allowClear="true" size="small"> <a-select-option value="0,20">20以下</a-select-option> <a-select-option value="21,30">21~30</a-select-option> <a-select-option value="31,40">31~40</a-select-option> <a-select-option value="40,120">40以上</a-select-option> </a-select> </a-col> <a-col :span="2"> <a-button @click="tableChange" type="primary" size="small">搜索</a-button> </a-col> </a-row> <hr> <a-table :columns="columns" :data-source="students" row-key="id" :pagination="pagination" @change="tableChange" :row-selection="{selectedRowKeys:ids,onChange:onSelectChange}"> <template #bodyCell="{column, record}"> <template v-if="column.dataIndex==='name'"> {{record.name + (record.sex==='男'?'(大侠)':'(女侠)')}} </template>
<template v-else-if="column.dataIndex==='operation'"> <a>修改</a> <a-divider type="vertical"></a-divider> <a-popconfirm title="确认要删除该学生吗?" ok-text="确定" cancel-text="取消" @confirm="onDelete(record.id)"> <a>删除</a> </a-popconfirm> </template> </template> </a-table> </template> <script setup lang="ts"> import axios from "../api/request"; import { ref, computed } from "vue"; import { usePagination, useRequest } from "vue-request"; import { AxiosRespPage, AxiosRespString, Student, StudentQueryDto } from "../model/Model8080"; import { PaginationProps } from "ant-design-vue";
// >>>>>>>>>>>>>> 搜索功能开始 const dto = ref({page: 1, size: 5, name: '', sex: null, age: null})
const {data, total, run: search} = usePagination<AxiosRespPage<Student>, StudentQueryDto[]>( (d) => axios.get('/api/students/q', {params:d}), { defaultParams: [dto.value], pagination: { currentKey: "page", pageSizeKey: 'size', totalKey: 'data.data.total' } } )
function tableChange(pagination: PaginationProps) { // console.log(pagination) dto.value.page = pagination.current ?? 1 dto.value.size = pagination.pageSize ?? 5 search(dto.value) }
const pagination = computed<PaginationProps>(()=>{ return { current: dto.value.page, // 当前页 pageSize: dto.value.size, // 页大小 total: total.value, // 总记录数 showSizeChanger: true, // 显示页大小的下拉列表 pageSizeOptions: ["1","2","3","4","5"] // 自定义下拉列表内容 } })
const students = computed(()=>{ return data.value?.data.data.list || [] }) // <<<<<<<<<<<<<< 搜索功能结束
// >>>>>>>>>>>>>> 删除功能开始 async function onDelete(id:number) { // console.log("学生id是:"+id) await deleteById(id) // 删除请求 删除响应 search(dto.value) // 查询请求 查询响应 }
const { runAsync: deleteById } = useRequest<AxiosRespString, number[]>( (id) => axios.delete(`/api/students/${id}`), { manual: true } ) // <<<<<<<<<<<<<< 删除功能结束
// >>>>>>>>>>>>>> 删除选中开始 const ids = ref<number[]>([])
function onSelectChange(keys:number[]) { // console.log(keys) ids.value = keys }
async function onDeleteIds() { await deleteByIds(ids.value) ids.value = [] search(dto.value) }
const { runAsync: deleteByIds } = useRequest<AxiosRespString, number[][]>( (ids)=>axios.delete('/api/students', {data: ids}), { manual: true } )
const visible = ref(false)
function onVisibleChange(v:boolean) { if(!v) { // 希望隐藏 visible.value = false } else { // 希望显示 visible.value = ids.value.length > 0 } } // <<<<<<<<<<<<<< 删除选中结束
const columns = ref([ { title: "编号", dataIndex: "id", }, { title: "姓名", dataIndex: "name", }, { title: "性别", dataIndex: "sex", }, { title: "年龄", dataIndex: "age", }, { title: '操作', dataIndex: 'operation' } ]); </script> <style scoped> .ant-input, .ant-select { width: 80px; } </style>
|
新增、修改
子组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
| <template> <a-modal :visible="visible" :title="title" @ok="onOk" @cancel="onCancel"> <a-form> <a-form-item label="编号" v-if="id"> <a-input readonly v-model:value="id"></a-input> </a-form-item> <a-form-item label="姓名"> <a-input v-model:value="dto.name"></a-input> </a-form-item> <a-form-item label="性别"> <a-radio-group v-model:value="dto.sex"> <a-radio-button value="男">男</a-radio-button> <a-radio-button value="女">女</a-radio-button> </a-radio-group> </a-form-item> <a-form-item label="年龄"> <a-input-number v-model:value="dto.age"></a-input-number> </a-form-item> </a-form> </a-modal> </template> <script setup lang="ts"> import axios from "../api/request"; import { ref, computed } from "vue"; import { useRequest } from "vue-request"; import { StudentSaveDto, AxiosRespString } from "../model/Model8080"; import { Form } from 'ant-design-vue'
// 定义属性 const props = defineProps<{id:number, dto:StudentSaveDto, visible:boolean}>()
const title = computed(()=> props.id===0?'新增学生':'修改学生')
// 定义事件 const emit = defineEmits(['update:visible', 'saved'])
async function onOk() { if(props.id === 0) { await insert(props.dto) } else { await update(props.dto) } emit('saved') // 发送事件给父组件, 希望把 visible 改为 false emit('update:visible', false) }
function onCancel() { // 发送事件给父组件, 希望把 visible 改为 false emit('update:visible', false) }
const {runAsync:insert} = useRequest<AxiosRespString,StudentSaveDto[]>( (dto)=>axios.post('/api/students', dto), { manual: true } )
const {runAsync:update} = useRequest<AxiosRespString,StudentSaveDto[]>( (dto)=>axios.put(`/api/students/${props.id}`, dto), { manual: true } ) </script>
|
父组件使用子组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| <A4Save :id="id" :dto="saveDto" v-model:visible="saveVisible"></A4Save>
<script setup lang="ts"> // ... // >>>>>>>>>>>>>> 新增、修改开始 const saveVisible = ref(false) const id = ref(0) const saveDto = reactive({name:'', sex:'男', age:18})
function onInsert() { saveVisible.value = true id.value = 0 Object.assign(saveDto, {name:'', sex:'男', age:18}) }
function onUpdate(record: Student) { saveVisible.value = true id.value = record.id Object.assign(saveDto, record) }
function onSaved() { search(dto.value) } // <<<<<<<<<<<<<< 新增、修改结束 </script>
|
- saveDto 使用 reactive 包装,是为了解决后续表单校验失效问题
- Object.assign 是将源对象(参数2)的属性值赋值给目标对象(参数1)的同名属性,效果等价于
1 2 3
| saveDto.name = record.name saveDto.sex = record.sex saveDto.age = record.age
|
全局消息
在 request.ts 中对响应消息统一处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import { message } from 'ant-design-vue'
_axios.interceptors.response.use( (response)=>{ if(response.data.message) { message.success(response.data.message, 3) } }, (error)=>{ } )
|
表单校验
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92
| <template> <a-modal :visible="visible" :title="title" @ok="onOk" @cancel="onCancel"> <a-form> <a-form-item label="编号" v-if="id"> <a-input readonly v-model:value="id"></a-input> </a-form-item> <a-form-item label="姓名" v-bind="validateInfos.name"> <a-input v-model:value="dto.name"></a-input> </a-form-item> <a-form-item label="性别" v-bind="validateInfos.sex"> <a-radio-group v-model:value="dto.sex"> <a-radio-button value="男">男</a-radio-button> <a-radio-button value="女">女</a-radio-button> </a-radio-group> </a-form-item> <a-form-item label="年龄" v-bind="validateInfos.age"> <a-input-number v-model:value="dto.age"></a-input-number> </a-form-item> </a-form> </a-modal> </template> <script setup lang="ts"> import axios from "../api/request"; import { ref, computed } from "vue"; import { useRequest } from "vue-request"; import { StudentSaveDto, AxiosRespString } from "../model/Model8080"; import { Form } from 'ant-design-vue'
// 定义属性 const props = defineProps<{id:number, dto:StudentSaveDto, visible:boolean}>()
const title = computed(()=> props.id===0?'新增学生':'修改学生')
// 定义事件 const emit = defineEmits(['update:visible', 'saved'])
async function onOk() { try { // 提交前校验 await validate() if(props.id === 0) { await insert(props.dto) } else { await update(props.dto) } emit('saved') // 发送事件给父组件, 希望把 visible 改为 false emit('update:visible', false) } catch (e) { console.error(e) } }
function onCancel() { // 发送事件给父组件, 希望把 visible 改为 false emit('update:visible', false) }
const {runAsync:insert} = useRequest<AxiosRespString,StudentSaveDto[]>( (dto)=>axios.post('/api/students', dto), { manual: true } )
const {runAsync:update} = useRequest<AxiosRespString,StudentSaveDto[]>( (dto)=>axios.put(`/api/students/${props.id}`, dto), { manual: true } )
const rules = ref({ name: [ {required: true, message:'姓名必须'}, {min:2, message:'字符数至少为2'} ], sex: [ {required: true, message:'性别必须'} ], age: [ {required: true, message:'年龄必须'}, {min:10, message:'年龄最小为10岁', type:'number'}, {max:120, message:'年龄最大为120岁', type:'number'} ] })
// 参数1: 待校验的数据 // 参数2: 校验规则 const { validateInfos, validate } = Form.useForm(props.dto, rules) </script>
|
vue-router
安装
1
| npm install vue-router@4
|
创建 router
首先创建一个 /src/router/a5router.ts 文件,在其中定义路由
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import {createRouter, createWebHashHistory} from 'vue-router' import A51 from '../views/A51.vue' import A52 from '../views/A52.vue'
const routes = [ { path: '/a1', component: A51 }, { path: '/a2', component: A52 } ]
const router = createRouter({ history: createWebHashHistory(), routes: routes })
export default router
|
需要在 main.ts 中导入 router 对象:
1 2 3 4
| import A5 from './views/A5.vue' import router from './router/a5router' createApp(A5).use(antdv).use(router).mount('#app')
|
A5 是根组件,不必在 router 中定义,但需要在其中定义 router-view,用来控制路由跳转后,A51、A52 这些组件的显示位置,内容如下
1 2 3 4 5
| <template> <div class="a5"> <router-view></router-view> </div> </template>
|
动态导入
1 2 3 4 5 6 7 8 9 10
| import {createRouter, createWebHashHistory} from 'vue-router' import A51 from '../views/A51.vue' import A52 from '../views/A52.vue' const routes = [ { path: '/a3', component: () => import('../views/A53.vue') } ]
|
- 用 import 关键字导入,效果是打包时会将组件的 js 代码都打包成一个大的 js 文件,如果组件非常多,会影响页面加载速度
- 而 import 函数导入(动态导入),则是按需加载,即
- 当路由跳转到 /a3 路径时,才会去加载 A53 组件对应的 js 代码
- vue-router 官方推荐采用动态导入
嵌套路由
如果希望再嵌套更深层次的路由跳转,例如:希望在 A53 组件内再进行路由跳转
首先,修改 A53.vue
1 2 3 4 5
| <template> <div class="a53"> <router-view></router-view> </div> </template>
|
其次,再修改 /src/router/a5router.ts 文件 内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import {createRouter, createWebHashHistory} from 'vue-router' import A51 from '../views/A51.vue' import A52 from '../views/A52.vue' const routes = [ { path: '/a3', component: () => import('../views/A53.vue'), children: [ { path: 'student', component: () => import('../views/A531.vue') }, { path: 'teacher', component: () => import('../views/A532.vue') } ] } ]
|
重定向
用法1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import {createRouter, createWebHashHistory} from 'vue-router' import A51 from '../views/A51.vue' import A52 from '../views/A52.vue' const routes = [ { path: '/a3', component: () => import('../views/A53.vue'), redirect: '/a3/student', children: [ { path: 'student', component: () => import('../views/A531.vue') }, { path: 'teacher', component: () => import('../views/A532.vue') } ] } ]
|
效果是,页面输入 /a3,紧接着会重定向跳转到 /a3/student
用法2
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import {createRouter, createWebHashHistory} from 'vue-router' import A51 from '../views/A51.vue' import A52 from '../views/A52.vue' const routes = [ { path: '/a1', component: A51 }, { path: '/a2', component: A52 }, { path: '/:pathMatcher(.*)*', redirect: '/a2' } ]
|
效果是,当页面输入一个不存在路径 /aaa 时,会被 path: '/:pathMatcher(.*)*'
匹配到,然后重定向跳转到 A52 组件去
主页布局
借助antdv的layout组件,可以实现主页【上】【左】【右】布局
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| <template> <div class="a53"> <a-layout> <a-layout-header></a-layout-header> <a-layout> <a-layout-sider></a-layout-sider> <a-layout-content> <router-view></router-view> </a-layout-content> </a-layout> </a-layout> </div> </template> <style scoped> .a53 { height: 100%; background-color: rgb(220, 225, 255); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Ctext x='35' y='10' font-size='14' font-family='system-ui, sans-serif' text-anchor='middle' dominant-baseline='middle'%3EA53(主页)%3C/text%3E%3C/svg%3E"); padding: 20px; box-sizing: border-box; } .ant-layout-header { height: 50px; background-color:darkseagreen; }
.ant-layout-sider { background-color:lightsalmon; }
.ant-layout-content { background-color: aliceblue; }
.ant-layout-footer { background-color:darkslateblue; height: 30px; }
.ant-layout { height: 100%; }
.ant-layout-has-sider { height: calc(100% - 50px); } </style>
|
侧边栏菜单
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| <template> <div class="a53"> <a-layout> <a-layout-header></a-layout-header> <a-layout> <a-layout-sider> <a-menu theme="dark" mode="inline"> <a-menu-item :key="1"> <router-link to="/a3/student">菜单1</router-link> </a-menu-item> <a-menu-item :key="2"> <router-link to="/a3/teacher">菜单2</router-link> </a-menu-item> <a-menu-item :key="3">菜单3</a-menu-item> <a-sub-menu :key="4" title="菜单4"> <a-menu-item :key="41">菜单41</a-menu-item> <a-menu-item :key="42">菜单42</a-menu-item> </a-sub-menu> </a-menu> </a-layout-sider> <a-layout-content> <router-view></router-view> </a-layout-content> </a-layout> </a-layout> </div> </template>
|
- a-menu-item 与 a-sub-menu 都必须为 key 属性唯一赋值,否则会产生混乱
- router-link 标签用来切换路由,to 是目标路由的路径
- theme 属性定义菜单的主题(默认亮色主题,dark 为暗色主题)
- mode 属性定义子菜单的展示模式(默认弹出,inline 显示在下方)
菜单图标
安装图标依赖
1
| npm install @ant-design/icons-vue
|
菜单中使用图标
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| <template> <div class="a53"> <a-layout> <a-layout-header></a-layout-header> <a-layout> <a-layout-sider> <a-menu theme="dark" mode="inline"> <a-menu-item :key="1"> <template #icon> <highlight-outlined /> </template> <router-link to="/a3/student">菜单1</router-link> </a-menu-item> <a-menu-item :key="2"> <template #icon> <align-center-outlined /> </template> <router-link to="/a3/teacher">菜单2</router-link> </a-menu-item> <a-menu-item :key="3"> <template #icon> <strikethrough-outlined /> </template> 菜单3</a-menu-item> <a-sub-menu :key="4" title="菜单4"> <template #icon> <sort-descending-outlined /> </template> <a-menu-item :key="41">菜单41</a-menu-item> <a-menu-item :key="42">菜单42</a-menu-item> </a-sub-menu> </a-menu> </a-layout-sider> <a-layout-content> <router-view></router-view> </a-layout-content> </a-layout> </a-layout> </div> </template> <script setup lang="ts"> import {HighlightOutlined, AlignCenterOutlined, StrikethroughOutlined, SortDescendingOutlined} from '@ant-design/icons-vue' </script>
|
- 图标组件没有全局绑定,需要 import 之后才能使用
- 用
<template #icon></template>
插槽,才能确定图标展示的位置(菜单文字之前)
二次封装图标组件
最终希望用统一的图标组件去使用图标,图标名只是作为一个属性值传递进去,例如:
使用者
1 2 3 4 5 6 7 8 9
| <template> <a-icon icon="highlight-outlined"></a-icon> <a-icon icon="align-center-outlined"></a-icon> <a-icon icon="strikethrough-outlined"></a-icon> <a-icon icon="sort-descending-outlined"></a-icon> </template> <script setup lang="ts"> import AIcon from '../components/AIcon1.vue' </script>
|
方法1,使用 vue 组件
1 2 3 4 5 6 7 8 9 10
| <script lang="ts" setup> import {HighlightOutlined, AlignCenterOutlined, StrikethroughOutlined, SortDescendingOutlined} from '@ant-design/icons-vue' const props = defineProps<{icon:string}>() </script> <template> <highlight-outlined v-if="icon==='highlight-outlined'"></highlight-outlined> <align-center-outlined v-else-if="icon==='align-center-outlined'"></align-center-outlined> <strikethrough-outlined v-else-if="icon==='strikethrough-outlined'"></strikethrough-outlined> <sort-descending-outlined v-else-if="icon==='sort-descending-outlined'"></sort-descending-outlined> </template>
|
方法2,使用函数式组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| import { h } from "vue" import * as Icons from '@ant-design/icons-vue'
interface Module { [p:string]: any }
const AIcon = (props:{icon:string}) => { const im: Module = Icons return h(im[toCamelCase(props.icon)]) }
export default AIcon
function toCamelCase(str: string) { return str.split('-') .map((e)=> e.charAt(0).toUpperCase() + e.slice(1) ) .join('') }
|
方法3,使用 jsx 组件
首先,安装
1
| npm install @vitejs/plugin-vue-jsx -D
|
配置 vite.config.ts
1 2 3 4 5 6 7 8
| import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import vueJsx from '@vitejs/plugin-vue-jsx'
export default defineConfig({ plugins: [vue(), vueJsx()] })
|
编写一个 Hi.tsx 组件
1 2 3 4 5 6 7 8
| export default { props: { msg: String }, setup(props: { msg: string }) { return () => <h5>{props.msg}</h5> } }
|
然后被其它组件使用
1 2 3 4 5 6 7
| <script setup lang="ts"> import Hi from '../components/Hi' </script>
<template> <Hi msg="Hello,World"></Hi> </template>
|
用 jsx 实现图标组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import * as Icons from '@ant-design/icons-vue'
interface Module { [p:string]: any }
function toCamelCase(str: string) { return str .split("-") .map((e) => e.charAt(0).toUpperCase() + e.slice(1)) .join(""); }
export default { props: { icon: String }, setup(props: {icon: string}) { const im: Module = Icons const tag = im[toCamelCase(props.icon)] return ()=> <tag></tag> } }
|
动态路由与菜单
路由文件
a6router.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| import { createRouter, createWebHashHistory } from 'vue-router' import { useStorage } from '@vueuse/core' import { Route, Menu } from '../model/Model8080' const clientRoutes = [ { path: '/login', name: 'login', component: () => import('../views/A6Login.vue') }, { path: '/404', name: '404', component: () => import('../views/A6NotFound.vue') }, { path: '/', name: 'main', component: () => import('../views/A6Main.vue') }, { path: '/:pathMatcher(.*)*', name: 'remaining', redirect: '/404' } ]
const router = createRouter({ history: createWebHashHistory(), routes: clientRoutes })
export const serverMenus = useStorage<Menu[]>('serverMenus', []) const serverRoutes = useStorage<Route[]>('serverRoutes', []) addServerRoutes(serverRoutes.value)
export function addServerRoutes(routeList: Route[]) { for (const r of routeList) { if (r.parentName) { router.addRoute(r.parentName, { path: r.path, component: () => import(r.component), name: r.name }) } } serverRoutes.value = routeList }
export function resetRoutes() { for (const r of clientRoutes) { router.addRoute(r) } serverRoutes.value = null serverMenus.value = null }
export default router
|
本文件重要的函数及变量
- addServerRoutes 函数向路由表中添加由服务器提供的路由,路由分成两部分
- clientRoutes 这是客户端固定的路由
- serverRoutes 这是服务器变化的路由,存储于 localStorage
- resetRoutes 函数用来将路由重置为 clientRoutes
- vue-router@4 中的 addRoute 方法会【覆盖】同名路由,这是这种实现的关键
- 因此,服务器返回的路由最好是 main 的子路由,这样重置时就会比较简单,用之前的 main 一覆盖就完事了
- serverMenus 变量记录服务器变化的菜单,存储于 localStorage
登录组件
动态路由应当在登录时生成,A6Login.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| <template> <div class="login"> <a-form :label-col="{ span: 6 }" autocomplete="off"> <a-form-item label="用户名" v-bind="validateInfos.username"> <a-input v-model:value="dto.username" /> </a-form-item> <a-form-item label="密码" v-bind="validateInfos.password"> <a-input-password v-model:value="dto.password" /> </a-form-item> <a-form-item :wrapper-col="{ offset: 6, span: 16 }"> <a-button type="primary" @click="onClick">Submit</a-button> </a-form-item> </a-form> </div> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue' import { Form } from 'ant-design-vue' import { useRouter } from 'vue-router' import axios from '../api/request' import { useRequest } from 'vue-request' import { AxiosRespToken, LoginDto, AxiosRespMenuAndRoute } from '../model/Model8080' import { resetRoutes, addServerRoutes, serverMenus } from '../router/a6router' const dto = ref({username:'', password:''}) const rules = ref({ username: [ {required: true, message:'用户名必填'} ], password:[ {required: true, message:'密码必填'} ] }) const { validateInfos, validate } = Form.useForm(dto, rules) const router = useRouter() const { runAsync:login } = useRequest<AxiosRespToken, LoginDto[]>((dto)=> axios.post('/api/loginJwt', dto), {manual:true}) const { runAsync:menu } = useRequest<AxiosRespMenuAndRoute, string[]>((username)=> axios.get(`/api/menu/${username}`), {manual:true}) async function onClick() { try { await validate() const loginResp = await login(dto.value if(loginResp.data.code === 200) { // 登录成功 const token = loginResp.data.data.token const menuResp = await menu(dto.value.username) const routeList = menuResp.data.data.routeList addServerRoutes(routeList) serverMenus.value = menuResp.data.data.menuTree router.push('/') }) } catch (e) { console.error(e) } } onMounted(()=>{ resetRoutes() }) </script> <style scoped> .login{ margin: 200px auto; width: 25%; padding: 20px; height: 180px; background-color: antiquewhite; } </style>
|
- 登录成功后去请求
/api/menu/{username}
获取该用户的菜单和路由
- router.push 方法用来以编程方式跳转至主页路由
主页组件
A6Main.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| <template> <div class="a6main"> <a-layout> <a-layout-header> </a-layout-header> <a-layout> <a-layout-sider> <a-menu mode="inline" theme="dark"> <template v-for="m1 of serverMenus"> <a-sub-menu v-if="m1.children" :key="m1.id" :title="m1.title"> <template #icon><a-icon :icon="m1.icon"></a-icon></template> <a-menu-item v-for="m2 of m1.children" :key="m2.id"> <template #icon><a-icon :icon="m2.icon"></a-icon></template> <router-link v-if="m2.routePath" :to="m2.routePath">{{m2.title}}</router-link> <span v-else>{{m2.title}}</span> </a-menu-item> </a-sub-menu> <a-menu-item v-else :key="m1.id"> <template #icon><a-icon :icon="m1.icon"></a-icon></template> <router-link v-if="m1.routePath" :to="m1.routePath">{{m1.title}}</router-link> <span v-else>{{m1.title}}</span> </a-menu-item> </template> </a-menu> </a-layout-sider> <a-layout-content> <router-view></router-view> </a-layout-content> </a-layout> </a-layout> </div> </template> <script setup lang="ts"> import AIcon from '../components/AIcon3' // jsx icon 组件 import { serverMenus } from '../router/a6router' </script> <style scoped> .a6main { height: 100%; background-color: rgb(220, 225, 255); box-sizing: border-box; } .ant-layout-header { height: 50px; background-color:darkseagreen; }
.ant-layout-sider { background-color:lightsalmon; }
.ant-layout-content { background-color: aliceblue; }
.ant-layout-footer { background-color:darkslateblue; height: 30px; }
.ant-layout { height: 100%; }
.ant-layout-has-sider { height: calc(100% - 50px); }
</style>
|
token 使用
- 获取用户信息,例如服务器端可以把用户名、该用户的路由、菜单信息都统一从 token 返回
- 前端路由跳转依据,例如跳转前检查 token,如果不存在,表示未登录,就避免跳转至某些路由
- 后端 api 访问依据,例如每次发请求携带 token,后端需要身份校验的 api 需要用到
pinia
需求:在组件p1里更新了数据,主页组件也同步更新显示
- storage 虽然可以实现多个组件的数据共享,但是需要【主动访问】才能获取更新后的数据
- 本例中由于没有涉及主页组件的 mounted 操作,因此并不会【主动】获取 storage 的数据
安装
在 main.ts 中引入
1 2 3 4
| import { createPinia } from 'pinia'
createApp(A6).use(antdv).use(router).use(createPinia()).mount('#app')
|
定义Store
再新建 store 目录来管理共享数据,下面是 /src/store/UserInfo.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import axios from '../api/request' import { defineStore } from "pinia" import { UserInfoDto } from '../model/Model8080'
export const useUserInfo = defineStore('userInfo', { state: () => { return { username: '', name: '', sex: '' } }, actions: { async get(username: string) { const resp = await axios.get(`/api/info/${username}`) Object.assign(this, resp.data.data) }, async update(dto: UserInfoDto) { await axios.post('/api/info', dto) Object.assign(this, dto) } } })
|
- 由于 useRequest 必须放在 setup 函数内,这里简化起见,直接使用了 axios
获取用户信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <template> <div class="a6main"> <a-layout> <a-layout-header> <span>{{serverUsername}} 【{{userInfo.name}} - {{userInfo.sex}}】</span> </a-layout-header> <a-layout> <!-- ... --> </a-layout> </a-layout> </div> </template> <script setup lang="ts"> import { onMounted } from 'vue'; import AIcon from '../components/AIcon3' // jsx icon 组件 import { serverMenus, serverUsername } from '../router/a6router' import { useUserInfo } from '../store/UserInfo' const userInfo = useUserInfo()
onMounted(()=>{ userInfo.get(serverUsername.value) }) </script>
|
修改用户信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| <template> <div class="a6p1"> <h3>修改用户信息</h3> <hr> <a-form> <a-form-item label="用户名"> <a-input readonly v-model:value="dto.username"></a-input> </a-form-item> <a-form-item label="姓名" v-bind="validateInfos.name"> <a-input v-model:value="dto.name"></a-input> </a-form-item> <a-form-item label="性别"> <a-radio-group v-model:value="dto.sex"> <a-radio-button value="男">男</a-radio-button> <a-radio-button value="女">女</a-radio-button> </a-radio-group> </a-form-item> </a-form> <a-button type="primary" @click="onClick">确定</a-button> </div> </template> <script setup lang="ts"> import { Form } from 'ant-design-vue' import { onMounted, ref } from 'vue' import { UserInfoDto } from '../model/Model8080' import { useUserInfo } from '../store/UserInfo'; const dto = ref<UserInfoDto>({ username: '', name: '', sex: '' }) const userInfo = useUserInfo() onMounted(()=>{ Object.assign(dto.value, userInfo) }) const rules = ref({ name: [ {required: true, message:'姓名必填'} ] }) const { validateInfos, validate } = Form.useForm(dto, rules) async function onClick() { try { await validate() await userInfo.update(dto.value) } catch (e) { console.error(e) } } </script>
|
- 不能直接把 userInfo 绑定到表单,需要 dto 中转一下
- userInfo.update 和 useInfo.get 返回的都是 Promise 对象,可以配合 await 一起用