一个服务端同学的Vue框架入门及实践
项目效果
用户首先到登录页,登录后,进入系统首页,点击左侧菜单,可以切换界面,包括一个书籍的增删改查操作。
技术栈
后端:SpringBoot
前端:Vue + TypeScript(简称 ts) + Vite + VueRouter + Axios + ElementPlus + Pinia
所选技术 | 版本号 | 技术定位 | 对比技术 | 差异点 |
Vue | 3.2.45 | 渐进式的 js 框架,支持响应式 | Vue2 | 性能优/包体积更小/对 ts 的支持好/漂亮的语法糖 |
React | 简单易学,详细对比 | |||
TypeScript | 4.7.4 | 基于 js 的可强类型的编程语言 | js | ts 是 js 的超集,ts 面向对象,可强类型,支持泛型、接口、类等,熟悉 Java 的同学很容易上手 ts 代码可转换为 js 代码,然后在浏览器进行执行 |
Vite | 4.0.0 | 工具链:实现构建/开发服务器等功能 | webpack | 速度快、Vue3 推荐使用 |
Pinia | 2.0.28 | 状态管理器 | Vuex | Vue3 推荐,Pinia 可看做是新版的 Vuex,具体见 官网描述 |
VueRouter | 4.1.6 | 页面路由器 | ||
Axios | 1.2.2 | 调用后端服务的客户端工具 | ||
ElementPlus | 2.2.28 | 基于 Vue3 的组件库 |
Pinia与Vuex关系描述:https://vuex.vuejs.org/
搭建开发环境
▐ 安装 Node.js 和 npm
在官网下载 Node.js,一路点击安装,会安装 Node.js 和 npm。最后显示如下信息,成功。
官网地址:https://nodejs.org/en
Node.js v18.12.1 to /usr/local/bin/nodenpm v8.19.2 to /usr/local/bin/npm
安装完毕之后,在控制台输入 node -v,返回版本号。配置 npm 数据源(加速 npm install)
// 查看当前数据源npm config get registry // 默认是https://registry.npmjs.org/// 设置 npm 数据源为淘宝数据源npm config set registry https://registry.npm.taobao.org
▐ 安装开发工具 VisualStudioCode
在 官网下载 VisualStudioCode(简称 vsCode),解压安装。之后安装 Vue 插件 volar。
volar 地址:https://marketplace.visualstudio.com/items?itemName=Vue.volar
▐ 使用脚手架初始化代码
输入如下命令使用 Vite 来初始化一个项目。
npm init vue@latest //初次执行该命令,会安装和执行 create-vue,它是 Vue 提供的官方脚手架工具
之后,按照提示,输入项目名称,选择需要的组件。
✔ Project name: … ${
projectName}
// 项目名称✔ Add TypeScript? … Yes // 选择 ts✔ Add Vue Router for Single Page Application development? … Yes // 增加 VueRouter✔ Add Pinia for state management? … Yes // 增加 Pinia
Scaffolding project in /Users/jigang/Desktop/vue-study/vue-boot...Done. Now run:// 项目初始化之后,执行如下命令,可以安装相关的依赖,启动项目 cd vue-boot npm install npm run dev执行 npm run dev,之后看到如下信息,表示服务启动成功,浏览器访问 http://localhost:5177/ 即可看到界面。
VITE v4.0.4 ready in 306 ms ➜ Local: http://localhost:5177/ ➜ Network: use --host to expose ➜ press h to show help
到这里我们整个开发环境(包括脚手架生成的项目结构)就准备就绪了,后续将脚手架生成的项目引入 vsCode 就可以进入开发了。
代码结构
如果是小型项目,用单一项目(前后端放一起)进行开发就好,这里使用常见的前后端分离的方式。
前端代码:https://github.com/zhaojigang/vue-boot(单页面应用)
后端代码:https://github.com/zhaojigang/vue-springboot
后端服务提供了简单的服务接口,通过访问 swagger 展示如下:
下面介绍前端代码。基于上述的脚手架 + 编码最终形成的目录结构如下:
├── index.html 界面入口(定义根div)├── public 公共静态资源包│ └── favicon.ico├── src│ ├── main.ts 入口ts文件(创建应用/use各种插件/挂载到index.html的根div上)│ ├── App.vue 根组件│ ├── assets 静态资源包 │ │ └── main.css│ ├── layout 布局组件│ │ ├── Menu.vue 菜单组件│ │ └── index.vue 基础布局组件(header/aside/footer等)│ ├── views 业务视图组件(路由跳转页面)│ │ ├── book│ │ │ └── BookListView.vue book视图组件│ │ ├── home│ │ │ └── HomeView.vue 首页视图组件│ │ └── login│ │ └── LoginView.vue 登录页视图组件│ ├── components 业务组件│ │ └── book book组件│ │ └── AddBookDialog.vue 新增书籍弹窗组件│ ├── router 路由│ │ ├── index.ts 业务路由│ │ └── permission.ts 路由守卫│ ├── stores 状态存储器│ │ └── user 业务状态存储│ │ └── user.ts user状态存储(state/getter/actions)│ ├── types 接口定义(制定接口标准)│ │ ├── book.ts book相关接口│ │ └── user.ts user相关接口│ ├── utils 工具│ │ ├── constants.ts 常量│ │ └── login.ts 登录/退出接口封装│ └── api axios请求封装api│ ├── baseRequest.ts axios基本封装(axios对象创建/拦截器)│ ├── book.ts book业务请求api│ └── login.ts login业务请求api├── .env.development 开发环境配置文件├── .env.production 生产环境配置文件├── package.json└── vite.config.ts
下面分类别来看下各个文件的核心代码。
▐ 框架文件
- 界面入口
html lang="en">
... !-- 根容器 -->
div id="app">
/div>
!-- 引入 ts 入口文件 -->
script type="module" src="/src/main.ts">
/script>
.../html>
- 入口 ts 文件
main.ts:基于根组件创建应用/use各种插件/挂载应用到index.html的根div上
/* 引入根组件创建函数 */import {
createApp }
from 'vue'/* 引入状态管理器 Pinia 创建函数 */import {
createPinia }
from 'pinia'/* 引入根组件 */import App from './App.vue'/* 引入 VueRouter */import router from './router'/* 引入主 css */import './assets/main.css'/* 引入路由守卫 */import '@/router/permission'
/* 基于根组件创建应用 */const app = createApp(App)/* 使用 Pinia 做状态管理 */app.use(createPinia())/* 使用 VueRouter 做路由 */app.use(router)/* 应用管理 index.html 中的 id=app 的 div */app.mount('#app')- 根组件
!-- 根组件 -->
!-- 使用 setup 语法糖,使用 TypeScript -->
script setup lang="ts">
/* 引入router函数 */import {
RouterView }
from 'vue-router'/script>
template>
!-- 外层路由到的页面会在此处渲染 -->
RouterView>
/RouterView>
/template>
▐ 界面/组件划分
- 引入 ElementPlus
1. 安装npm install element-plus --save
2. 使用按需引入方式,安装如下插件npm install -D unplugin-vue-components unplugin-auto-import
3. 配置 vite.config.tsimport {
defineConfig }
from 'vite'// 增加如下配置import AutoImport from 'unplugin-auto-import/vite'import Components from 'unplugin-vue-components/vite'import {
ElementPlusResolver }
from 'unplugin-vue-components/resolvers'
export default defineConfig({
// ... plugins: [ // ... AutoImport({
resolvers: [ElementPlusResolver()], }
), Components({
resolvers: [ElementPlusResolver()], }
), ],}
)
4. 使用组件在组件库(https://element-plus.gitee.io/zh-CN/component/button.html)寻找相应的组件,引入相关代- 界面/组件划分
如“项目效果”部分所见,一共三个界面:LoginView(登录界面)/HomeView(首页界面)/BookListView(书籍管理界面)。
其中 HomeView 和 BookListView 界面需要展示在一个具有 header(页头) / aside(菜单) / main(主展示区)的布局(layout)中。
图片来自 element-plus container 布局容器
各个业务界面中需要抽取成的组件放置在 components 业务组件包下(eg. 新增书籍弹窗组件),个人会将这两类特征的功能抽取成组件:
- 高内聚原则:功能具有一定的复杂度和隔离度,将这些代码内聚起来进行开发,不腐化外层组件。
- 可复用原则:功能被其他多个组件引入复用,则需要将这些代码形成组件,避免重复代码散落,降低维护成本。
所以最终形成了如下的界面布局相关的代码包结构。
└── src ├── layout 布局组件 │ ├── Menu.vue 菜单组件 │ └── index.vue 基础布局组件(header/aside/footer等) ├── views 业务视图组件(路由跳转页面) │ ├── book │ │ └── BookListView.vue book视图组件 │ ├── home │ │ └── HomeView.vue 首页视图组件 │ └── login │ └── LoginView.vue 登录页视图组件 └── components 业务组件 └── book book组件 └── AddBookDialog.vue 新增书籍弹窗组件
当访问登录链接的时候,LoginView.vue 界面组件展示在 App.vue 的 RouterView /> 处;而当访问首页或者书籍管理链接的时候,相应的 HomeView.vue 和 BookListView.vue 需要展示在布局文件 layout/index.vue 的 main区(该区也会配置 RouterView /> ),layout 的 aside 部分的菜单需要封装为单独的 Menu.vue 组件。以上的访问路径到界面的映射以及当项目中具有多个 RouterView/> 时,怎么定位到合适的 RouterView/> 组件展示区,可见“路由配置”部分。
来看下 layout/index.vue。界面使用 ElementPlus 的页面布局容器组件。
template>
el-container class="layout-container">
el-header style="text-align: right;
font-size: 12px">
div class="toolbar">
el-dropdown>
el-icon style="margin-right: 8px;
margin-top: 1px">
setting />
/el-icon>
template #dropdown>
el-dropdown-menu>
el-dropdown-item @click="logout">
退出/el-dropdown-item>
/el-dropdown-menu>
/template>
/el-dropdown>
span>
张三/span>
/div>
/el-header>
el-container>
el-aside width="200px">
el-scrollbar>
!-- 菜单组件 -->
Menu />
/el-scrollbar>
/el-aside>
el-main>
!-- main 界面展示区 -->
RouterView />
/el-main>
/el-container>
/el-container>
!-- Menu />
-->
/template>
!-- 使用 setup 语法糖,使用typescript -->
script setup lang="ts">
/* 引入router函数 */import {
RouterView }
from 'vue-router'/* 引入子组件 */import Menu from '@/layout/Menu.vue'import {
Setting }
from '@element-plus/icons-vue'import {
logout as userLogout }
from '@/utils/login'import {
useUserStore }
from '@/stores/user/user'
function logout() {
const token = useUserStore().getToken();
if (token) {
userLogout(token) }
}
/script>
style scoped>
... 布局组件相关 css/style>
- 组件间通信
通信的数据有两种:一种是属性;一种是方法。对于属性,例如行数据的传递:在“新增弹窗组件”(承担新增和编辑功能)中进行传递属性的引入并使用:
script setup lang="ts">
/* 引入属性 */const props = defineProps{
bookValue: Book | undefined}
>
()
/* 监听父组件传递数据,渲染表单 */watch(form.value, () =>
{
if (props.bookValue) {
form.value = props.bookValue }
}
, {
deep: true, immediate: true }
)/script>
在“书籍管理组件”中进行属性定义赋值并通过引入组件进行传递。
template>
!-- 传递属性 -->
AddBookDialog ... :bookValue="bookValue" />
/template>
script setup lang="ts">
/* 定义属性并赋值 */ const bookValue = refBook>
()// 新增或者更新数据function addOrUpdateBook(book: Book | undefined) {
if (!book) {
bookValue.value = undefined }
else {
bookValue.value = book }
}
/script>
对于方法,例如获取书籍列表方法的传递:在“新增弹窗组件”中进行传递方法的引入并调用:
script setup lang="ts">
/* 引入方法 */const emits = defineEmits(['getBooks'])
/* 新增或者编辑数据成功之后,刷新列表 */const addOrUpdateBookInner = async () =>
{
await addOrUpdate(form.value) ... /* 使用方法 */ emits('getBooks')}
/script>
在“书籍管理组件”中进行方法定义并通过引入组件进行传递。
template>
!-- 传递方法 -->
AddBookDialog ... @getBooks="getBooks" />
/template>
script setup lang="ts">
/* 定义方法 */const getBooks = async () =>
{
await getByRequest(queryParams.value).then(resp =>
{
tableData.value = resp.data.dataList totalCount.value = resp.data.totalCount }
)}
/script>
▐ 路由配置
- 基本原理
main.ts 引入 VueRouter,之后 =>
通过 RouterLink to="${
path}
">
配置指定元素的跳转路径 path =>
访问中心路由 ts 文件,根据 path 找到相关的组件以及 RouterView>
区 =>
将组件展示在相应的 RouterView>
区整个路由的使用需要在 main.ts 引入路由器
/* 引入 VueRouter */import router from './router'/* 使用 VueRouter 做路由 */app.use(router)
菜单组件 layout/Menu.vue(引入 ElementPlus 的菜单组件,菜单路径配置有简化写法,以下使用简化写法)
!-- 菜单组件 -->
template>
el-menu default-active="1" class="el-menu-vertical-demo" router>
!-- index 指定跳转路径 -->
el-menu-item index="/">
el-icon>
icon-menu />
/el-icon>
span>
首页/span>
/el-menu-item>
el-menu-item index="/bookList">
el-icon>
document />
/el-icon>
span>
书籍管理/span>
/el-menu-item>
/el-menu>
/template>
script lang="ts" setup>
import {
Document, Menu as IconMenu }
from '@element-plus/icons-vue'import {
RouterLink }
from 'vue-router'/script>
复杂写法:
RouterLink to="/bookList">
el-menu-item index="/bookList">
el-icon>
document />
/el-icon>
span>
书籍管理/span>
/el-menu-item>
/RouterLink>
页面路由 router/index.ts
import {
createRouter, createWebHashHistory }
from 'vue-router'
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL), routes: [ /* 登录页 path =>
LoginView.vue 展示在根组件的 RouterView>
区 */ {
path: '/login', name: 'login', component: () =>
import('@/views/login/LoginView.vue') }
, /* 其他路由 */ {
path: '/', name: 'layout', /* 嵌套路由,当访问 children 中的 path 时,相关的组件展示在父组件(此处是 layout/index.vue)的 RouterView>
区 */ component: () =>
import('@/layout/index.vue'), children: [{
path: '/', name: 'home', component: () =>
import('@/views/home/HomeView.vue'), }
, {
path: '/bookList', name: 'bookList', component: () =>
import('@/views/book/BookListView.vue') }
] }
]}
)
export default router展示区 layout/index.vue
template>
el-container class="layout-container">
... el-main>
!-- 路由展示区 -->
RouterView />
/el-main>
... /el-container>
/template>
- 路由守卫
作用:根据是否登录来控制用户对页面的访问,如果已登录,可进行目标页面访问;如果未登录,跳转到登录页
import router from "./index";
import {
useUserStore }
from '@/stores/user/user'
/* 白名单 */const whiteList = ['/login']/* userStore */// to - 要去的导航// from - 当前的导航// next - 函数,可指定去往任意的导航router.beforeEach((to, from, next) =>
{
const userStore = useUserStore() if(userStore.getToken()) {
/* 如果 token 存在 */ if(to.path === '/login') {
/* 跳转到首页 */ next('/') }
else {
/* 继续跳转到 to */ next() }
}
else {
/* 如果 token 不存在 */ if(whiteList.includes(to.path)) {
next() }
else {
next('/login') }
}
}
)编写路由守卫后,在 main.ts 中引入就可生效。
/* main.ts 引入路由守卫 */import '@/router/permission'
▐ 状态存储
Pinia 的使用需要在 main.ts 进行引入
/* 引入状态管理器Pinia创建函数 */import {
createPinia }
from 'pinia'/* 使用 pinia 做状态管理 */app.use(createPinia())在项目中会有多个组件使用用户登录信息,可以将该数据存储在 userStore 中。
首先定义业务存储,store/user/user.ts
import {
defineStore }
from 'pinia'import {
TOKEN_DURATION_KEY, TOKEN_DURATION }
from '@/utils/constants'// 命名规范:建议使用 useXxxStoreexport const useUserStore = defineStore('user', () =>
{
// 在 Setup Store 中: // ref() 就是 state 属性 // computed() 就是 getters // function() 就是 actions function saveToken(token: string) {
localStorage.setItem('token_vue_springboot', token) }
function getToken(): string | null {
return localStorage.getItem('token_vue_springboot') }
function removeToken() {
localStorage.removeItem('token_vue_springboot') }
/* 登录时设置 */ function saveCurrentTokenTime() {
localStorage.setItem(TOKEN_DURATION_KEY, Date.now().toString()) }
function getTokenTime(): number | null {
const tokenTime = localStorage.getItem(TOKEN_DURATION_KEY) if (tokenTime) {
return Number(tokenTime) }
return null }
function removeTokenTime() {
localStorage.removeItem(TOKEN_DURATION_KEY) }
/* 判断登录 token 时间是否过期 */ function tokenTimeIsExpire(): boolean {
const tokenTime = getTokenTime();
/* 如果不存在,则表示未登录,与过期等价,需要重新登录 */ if (!tokenTime) {
return true }
return Date.now() - tokenTime >
TOKEN_DURATION }
/* 暴露方法 */ return {
saveToken, getToken, removeToken, saveCurrentTokenTime, getTokenTime, removeTokenTime, tokenTimeIsExpire }
}
)然后,使用 useUserStore 状态存储器
import {
login as userLogin }
from '@/api/login'import {
useUserStore }
from '@/stores/user/user'import router from '@/router'import type {
Ref }
from 'vue'
export function login(loginForm: RefLoginUser>
) {
/* 使用 Pinia Store */ const userStore = useUserStore() /* 定义函数 */ async function loginTrue() {
await userLogin(loginForm.value).then(resp =>
{
/* 保存 token 到浏览器缓存 */ userStore.saveToken(resp.data.token) /* 保存当前时间 tokenTime 到浏览器缓存 */ userStore.saveCurrentTokenTime() /* 跳转到首页 */ router.replace('/') }
) }
/* 调用登录函数 */ loginTrue()}
▐ 服务请求封装
- Axios 客户端封装
api/baseRequest.ts
/* 封装 axios 对象 */import axios from "axios";
import {
useUserStore }
from '@/stores/user/user'import {
logout }
from '@/utils/login'
/* 创建请求对象,填入全局配置参数 */const axiosService = axios.create({
baseURL: import.meta.env.VITE_DOMAIN_URL_VUE_BOOT, // 根据环境读取不同的文件,具体见“环境配置”部分 timeout: 3000}
)
/* 请求前拦截器 */axiosService.interceptors.request.use(function (config) {
// 在发送请求之前做些什么,eg.token校验;token传递到header,进行免登操作等 /* 如果 token 过期 */ const userStore = useUserStore() if (userStore.tokenTimeIsExpire()) {
logout() return Promise.reject(new Error('token expired')) }
return config;
}
, function (error) {
// 对请求错误做些什么 return Promise.reject(error);
}
)
/* 响应拦截器,拦截之后,业务方法基于该返回进行数据处理(此处可进行服务端返回数据的统一处理,例如,统一报错等) */axiosService.interceptors.response.use(function (response) {
// 2xx 范围内的状态码都会触发该函数。 // return response.data return response}
, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。 return Promise.reject(error);
}
)
export default axiosService- 业务 api 封装
/* 封装 login 相关api */import axiosService from './baseRequest'// 登录export function login(data: {
}
) {
return axiosService.request({
url: 'user/login', method: 'post', data: data }
)}
// 退出export function logout(data: {
}
) {
return axiosService.request({
url: 'user/logout', method: 'post', data }
)}
▐ 接口定义标准
由于接口定义可能会在多个文件中进行调用,同时为了方便管理,建议接口写在 types 目录下的相关业务文件中。
Book 接口定义:types/book.ts
// 定义Book接口export interface Book {
id: number, name: string}
// 定义查询请求参数接口export interface BookRequest {
/* 模糊查询 */ name: string, pageNum: number, pageSize: number}
使用接口:BookListView.vue
// 导入外部接口定义import type {
Book,BookRequest }
from '@/types/book'
// 构造列表查询数据const queryParams = refBookRequest>
({
name: "", pageNum: 1, pageSize: 2}
)
// 获取列表数据const totalCount = ref(0)const tableData = refBook[]>
([])const getBooks = async () =>
{
await getByRequest(queryParams.value).then(resp =>
{
tableData.value = resp.data.dataList totalCount.value = resp.data.totalCount }
)}
getBooks()▐ 环境配置
环境配置相关的代码包结构。
根路径├── .env.development 开发环境配置文件├── .env.production 生产环境配置文件└── package.json
.env.development
# 开发环境配置 VITE_NAME='开发环境'VITE_DOMAIN_URL_VUE_BOOT=http://localhost:8082
.env.production
VITE_NAME='生产环境'VITE_DOMAIN_URL_VUE_BOOT=http://localhost:8082
package.json 配置命令使用的模式
{
... "scripts": {
"dev": "vite --mode development", // 开发启动 "npm run dev" "build": "run-p type-check build-only", // 生产构建静态资源到 /dist 目录 "npm run build" "preview": "vite preview", // 本地运行 /dist 目录文件,进行提前验证 "npm run preview" "build-only": "vite build --mode production", "type-check": "vue-tsc --noEmit" }
, ...}
使用配置文件使用环境变量 baseRequest.ts
const axiosService = axios.create({
baseURL: import.meta.env.VITE_DOMAIN_URL_VUE_BOOT, timeout: 3000}
)项目部署
本人通过以上这样的开发模式研发了多人实时玩法管理后台。开发完成后,就要去做应用部署,前后端分离项目的常规部署方式如下图所示。
前端资源(静态资源/css/js等)单独部署在CDN,服务端使用模板引擎(例如,Thymeleaf)来编写入口文件 index.html(代码与前端代码中编译后的 index.html 几乎相同,只是其中引入的 css 和 main.js 资源路径是资源的CDN 地址),这样通过服务端域名访问项目的时候,就会执行后端的 index.html,进而执行到 main.js(main.js 是 main.ts 的编译产物)。
这样的前后端分离方式是基于“index.html 代码几乎不怎么变动” 的前提下进行的,否则,后端代码需要因为这个文件的变动不断发布。而默认情况下,Vite 在构建文件的时候会生成带 hash 值的文件,例如,main.ts 被编译为 main-[hash].js,而 index.html 需要引入这个 js 文件,为了保证这个文件路径的不变性,需要在 vite.config.ts 文件中增加几行配置。
build: {
rollupOptions: {
input: "./src/main.ts", output: {
dir: "dist", /* 去除hash值 */ entryFileNames: "assets/[name].js", }
}
参考资料
- Vue3 后台管理系统(地址:https://www.bilibili.com/video/BV1pq4y1c7oy/)
- TS 快速入口 + Vue3 快速入门(Vue部分需要结合 Vue3 官方文档看。地址:https://www.bilibili.com/video/BV1ra4y1H7ih/?vd_source=480e34eaa7e8621cbd83d5f3163fc061)
- 技术栈部分列出的各种官网
团队介绍
我们是大淘宝技术投放平台团队,负责双11、618、造物节等天猫淘宝大促和各类营销活动业务,覆盖几亿消费者、千万商家。这里是创造新商业的前沿阵地,同时充满着各种技术挑战 - 百万级峰值qps的页面投放、亿级规模的权益发放、T级大规模的数据供给等。我们致力于营销选品、投放、权益等技术体系的建设,打造一套灵活的业务解决方案,让业务创新更高效。
声明:本文内容由网友自发贡献,本站不承担相应法律责任。对本内容有异议或投诉,请联系2913721942#qq.com核实处理,我们将尽快回复您,谢谢合作!
若转载请注明出处: 一个服务端同学的Vue框架入门及实践
本文地址: https://pptw.com/jishu/295753.html
