写在前面
系统性的学习一下前端最新的技术栈已经是很早的计划了。最近看了下typeScript
官方文档并且写了几个demo
,感觉很简单。于是触发了我想在vue
中使用typeScript
开发的想法。
本文是对我从0
开始学习前端技术的一个学习笔记。
一、开发环境准备
我用到了以下组件及环境(因为是学习,所以我这里用的全是最新版)
Node.js v16.14.2
安装和配置淘宝镜像我就不写了。typescript 4.6.3
安装npm install -g typescript
vue3
安装npm install --global @vue/cli@next
element-plus
安装npm install element-plus --save
vue3-cookies
安装npm install vue3-cookies --save
axios
安装npm install axios --save
额外说明:
- 要查询软件包版本运行:
npm info 要查询的软件包名称
- 尽量科学上网使用
npm
,淘宝镜像对最新的版本包很多缺失,所以我全程没有使用cnpm
二、创建项目及结构
我这里用的是vue ui
创建的项目,简直是国产良心啊。
运行vue ui
,选择带typescript
的就行了,那种测试单元的玩意别要,乱七八糟的导致你编译失败,学习阶段还是别整,轻装上阵,目标明确。使用vue ui
创建的细节我就不说了,看到页面就都会操作了。
项目结构说明:
.
├── babel.config.js
├── .browserslistrc
├── .editorconfig
├── .env.development //开发环境配置,需要配合代理使用,后续讲到
├── .env.production //生产环境配置
├── .eslintrc.js
├── .gitignore //git管理忽略配置
├── package.json //包管理,命令管理
├── package-lock.json
├── public
│ ├── favicon.ico
│ └── index.html //运行入口,根据其id与main.ts关联调起App.vue
├── src
│ ├── App.vue //主组件,类似于运行容器
│ ├── assets
│ │ └── logo.png
│ ├── components //自定义组件
│ │ └── HelloWorld.vue
│ ├── main.ts //初始化vue实例,并引入所需要的插件
│ ├── router
│ │ └── index.ts //路由
│ ├── shims-vue.d.ts
│ ├── store //状态存储
│ │ └── index.ts
│ ├── ts //自定义的ts文件
│ │ ├── common
│ │ │ ├── auth.ts
│ │ │ ├── data.ts
│ │ │ └── http.ts
│ │ ├── demo.ts
│ │ └── logIn.ts
│ └── views //页面
│ ├── AboutView.vue
│ ├── HomeView.vue
│ └── LoginView.vue
├── tsconfig.json //tsjs的配置文件(默认就行)
└── vue.config.js //vue的配置文件(前期默认就行,后面按需更改)
以下是我的理解:
- 项目加载的过程是
index.html -> main.ts -> App.vue -> index.ts -> HomeView.vue
- 本质是
html
通过main.ts
调起一个vue
运行实例(或容器),这个实例还是个js
,实例中通过路由index.ts
完成不同的html dom js
等渲染,有点动态替换的意思。可以想象成写了个js
使用document.getElementById("app").innerHTML = "Hello world";
频繁的改变index.html
中的id=app
的dom
内容。 - 基于上面的理解,
Vue
在单页面上实际上是一个dom
嵌套,对于page
作用域范围,大部分都是全局的,我猜测只有在views
中声明的有可能会在源代码编译的过程中被限制,如果不限制,就出现了作用域逃逸的现象(或者vue
的设计不考虑子父关系
和作用域?)。这一点需要单独验证。 - 项目的编译过程:这个过程我还没有搞太清楚。
- 安装依赖时
npm
可以直接读取这个文件,进行相应依赖安装 - 编译时
vue cli
也可以直接读取这个文件进行编译 - 看起来
package.json
文件更像是vue
和node
的一个通用文件清单。具体的编译原理可能需要看看node js
的一些文档。
- 安装依赖时
三、使用基础组件封装通用js文件
我认为学习任何方向的技术,技术积累应该抽离业务,但是要时刻能给业务提供支撑。所以,我将基础的axios
使用typescript
进行了全局封装,以便后面所有项目都可以直接拿来使用。
- 全局注册要使用的组件
在main.ts
加入
import {createApp} from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
createApp(App).use(store).use(router).use(ElementPlus).mount('#app')
- 通用的请求和返回体:
data.ts
/**
* 顶级数据返回模型
*/
export class AResponse<T> {
constructor(public data?: T,
public message?: string,
public serverTime?: string,
public status?: string) {
}
}
/**
* 分页返回信息模型
*/
class PageInfo {
constructor(public isLastPage?: boolean, //是否是最后一页
public pageIndex?: number, //当前页码(从1开始)
public pageSize?: number, //每页记录数
public total?: number, //总记录数
public pageTotal?: number) {
}
}
/**
* 分页返回对象模型
*/
export class PageResult<T> {
constructor(public pageInfo?: PageInfo, //分页信息
public result?: Array<T>, //数据集
public message?: number, //消息
public serverTime?: string //处理时间
) {
}
}
/**
* 分页请求入参模型
*/
export class APageRequest {
constructor(public pageIndex?: number, //页码,即第几页,从1开始
public pageSize?: number, //每页记录数,默认20,最大100
public query?: string, //搜索词
public sort?: string //排序
) {
}
}
- 基于
vue3-cookies
的授权存储auth.ts
(为什么不用store
?我觉得store
设计的api
过于复杂,人工模式的状态管理我直接一个ts class
它不香吗)
import {useCookies} from "vue3-cookies"
const {cookies} = useCookies();
class Auth {
TOKEN_KEY = "x-token"
getXtoken() {
let token = ""
try {
token = cookies.get(this.TOKEN_KEY)
} catch (e) {
console.log(e)
}
return token;
}
setXtoken(value: string) {
cookies.set(this.TOKEN_KEY, value, -1)
}
removeXtoken() {
cookies.remove(this.TOKEN_KEY)
}
}
export let AUTH = new Auth()
- 基于
axios
请求、element-plus
页面消息、auth.ts
通用授权存储、data.ts
通用数据模型,做出的一个http.ts
组件
import axios, {AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse} from 'axios'
import qs from "qs"
import {ElMessage} from "element-plus"
import {AUTH} from "@/ts/common/auth"
import {AResponse} from "@/ts/common/data";
const showStatus = (status: number) => {
let message: string
switch (status) {
case 400:
message = '请求错误(400)'
break
case 401:
message = '未授权,请重新登录(401)'
break
case 403:
message = '拒绝访问(403)'
break
case 404:
message = '请求出错(404)'
break
case 408:
message = '请求超时(408)'
break
case 500:
message = '服务器错误(500)'
break
case 501:
message = '服务未实现(501)'
break
case 502:
message = '网络错误(502)'
break
case 503:
message = '服务不可用(503)'
break
case 504:
message = '网络超时(504)'
break
case 505:
message = 'HTTP版本不受支持(505)'
break
default:
message = `连接出错(${status})!`
}
return `${message},请检查网络或联系管理员!`
}
function addHeaders(target: AxiosRequestHeaders, config: AxiosRequestConfig) {
let headers = config.headers
if (headers == null) headers = {}
config.headers = Object.assign(target, headers)
}
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API,
// withCredentials: true,
timeout: 30000,
validateStatus() {
// 使用async-await,处理reject情况较为繁琐,所以全部返回resolve,在业务代码中处理异常
return true
},
transformResponse: [(data: any) => {
if (typeof data === 'string' && data.startsWith('{')) {
data = JSON.parse(data)
}
console.log("data:" + data)
return data
}]
})
// 声明一个 Map 用于存储每个请求的标识 和 取消函数
const pending = new Map()
/**
* 添加请求
* @param {Object} config
*/
const addPending = (config: AxiosRequestConfig) => {
const url = [
config.method,
config.url,
qs.stringify(config.params),
qs.stringify(config.data)
].join('&')
config.cancelToken = config.cancelToken || new axios.CancelToken(cancel => {
if (!pending.has(url)) { // 如果 pending 中不存在当前请求,则添加进去
pending.set(url, cancel)
}
})
}
/**
* 移除请求
* @param {Object} config
*/
const removePending = (config: AxiosRequestConfig) => {
const url = [
config.method,
config.url,
qs.stringify(config.params),
qs.stringify(config.data)
].join('&')
if (pending.has(url)) { // 如果在 pending 中存在当前请求标识,需要取消当前请求,并且移除
const cancel = pending.get(url)
cancel(url)
pending.delete(url)
}
}
/**
* 清空 pending 中的请求(在路由跳转时调用)
*/
export const clearPending = () => {
for (const [url, cancel] of pending) {
cancel(url)
}
pending.clear()
}
// 请求拦截器
service.interceptors.request.use((config: AxiosRequestConfig) => {
let token = AUTH.getXtoken()
if (token) {
addHeaders({
"x-token": token
}, config)
}
removePending(config) // 在请求开始前,对之前的请求做检查取消操作
addPending(config) // 将当前请求添加到 pending 中
return config
}, (error) => {
// 错误抛到业务代码
error.data = {}
error.message = '服务器异常,请联系管理员!'
return Promise.resolve(error)
})
// 响应拦截器
service.interceptors.response.use((response: AxiosResponse) => {
removePending(response) // 在请求结束后,移除本次请求
const status = response.status
let aResponse = new AResponse<any>()
if (status < 200 || status >= 300) {
// 处理http错误,抛到业务代码
aResponse.message = showStatus(status)
} else {
aResponse = response.data
if (aResponse.status !== 'A-1000') {
ElMessage.error(aResponse.message)
return Promise.reject(new Error(aResponse.message || 'Error'))
} else {
return aResponse
}
}
return aResponse
}, (error) => {
if (axios.isCancel(error)) {
console.log('repeated request: ' + error.message)
} else {
// handle error code
// 错误抛到业务代码
error.data = {}
error.message = '请求超时或服务器异常,请检查网络或联系管理员!'
}
return Promise.reject(error)
})
export default service
四、设置代理
设置代理的目的是解决开发环境和本地环境的路由问题,比如本地调试启动的时候有个api
前缀,而线上服务通过nginx
这类的代理之后,去掉了这个api
前缀,就会导致本地调试是好的,一发布就404
。
根目录的.env.development、.env.production
这两个文件就是设置本地和线上不同的api
前缀的。
可能有人会误解api
前缀的意思。举例说明:
- 本地访问:
http://localhost:8080/api/login
- 线上访问:
http://www.baidu.com/login
这个时候区别除了域名之外就是这个api
前缀了,这个少了的api
可能是其他的字符串,所以才有2
个环境文件让你随意编辑而不是写死成api
字符串
修改vue.config.js
文件
const { defineConfig } = require('@vue/cli-service')
const port = process.env.port || process.env.npm_config_port || 8080 // dev port
module.exports = defineConfig({
transpileDependencies: true,
publicPath: '/',
outputDir: 'dist',
assetsDir: 'static',
lintOnSave: process.env.NODE_ENV === 'development',
productionSourceMap: false,
devServer: {
port: port,
open: true,
proxy: {
[process.env.VUE_APP_BASE_API]: {
target: 'http://localhost:8089',
changeOrigin: true,
ws: false,
secure: false,
pathRewrite: {
["^" + process.env.VUE_APP_BASE_API]: ""
}
}
},
// before: require('./mock/mock-server.js')
}
})
process.env.VUE_APP_BASE_API
这个配置我已经在上面的http.ts
中加入了,无需做其他操作。
注意:修改了src
外的文件需要重新启动才会生效
五、写一个登录的业务
- 在
views
下新建一个LoginView.vue
,随便复制一个其他的过来改个名字就行,先占坑。 - 在
router/index.ts
中加入LoginView.vue
的页面路由
import HomeView from '../views/HomeView.vue'
import LoginView from '../views/LoginView.vue'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'login',
component: LoginView
},
{
path: '/home',
name: 'home',
component: HomeView
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue')
}
]
可以看到有2
种加入component
的方式,但本质是一样的,都是使用import
指令
- 写一个
ts/login.ts
,前面的ts
是我建的文件夹
import service from "@/ts/common/http"
import {ElMessage} from "element-plus";
//定义了当前ts的业务方法,这个完全可以去掉,出于对oop的习惯罢了,通过这个定义就能知道当前ts文件的关注点是什么
interface LoginInterface {
login(login: LoginBO, func: Function): void;
out(func: Function): void;
checkToken(func: Function): void;
}
//定义login请求的入参
export class LoginBO {
constructor(public name: string, //账号
public pwd: string, //密码
public platform?: number //平台(pc:1)
) {
}
}
//定义login请求的业务出参,相当于AResponse.data。http.ts会自动转义的
export class UserVO {
constructor(public code?: string, //状态码
public userNo?: string, //用户编号
public mobile?: string, //手机号
public newUser?: boolean, //是新用户(是:true,否:false)
public deletedUser?: boolean, //是已禁用用户(是:true,否:false)
public nick?: string, //用户昵称
public photo?: string, //用户头像
public xtoken?: string, //X-TOKEN
public successful?: boolean //是否登录成功
) {
}
}
//实现业务
class LoginService implements LoginInterface {
login(loginBO: LoginBO, func: Function) {
//这里根据后端的api方式进行编写
service.post('/login/', loginBO).then(res => {
//数据渲染
func(res)
}).catch(err => {
ElMessage.error(err.message)
})
}
checkToken(func: Function) {
service.get('/login/check_token', {
responseType: 'json'
}).then(res => {
//数据渲染
func(res)
}).catch(err => {
ElMessage.error(err.message)
})
}
out(func: Function) {
service.get('/login/out', {
responseType: 'json'
}).then(res => {
//数据渲染
func(res)
}).catch(err => {
ElMessage.error(err.message)
})
}
}
export let loginService = new LoginService()
到这里,成功只差一步,回去LoginView.vue
开始使用这个logIn.ts
的业务吧
- 编辑
LoginView.vue
,整一个表单出来,提交方式无所谓,只要能触发js
<template>
<div class="page-content">
<el-form :inline="true" :model="formData" class="login-form">
<el-form-item label="账号">
<el-input v-model="formData.name" placeholder="请输入账号"></el-input>
</el-form-item>
<el-form-item label="密码">
<el-input v-model="formData.pwd" placeholder="请输入密码"></el-input>
</el-form-item>
<el-button type="primary" @click="login">login</el-button>
<el-button type="primary" @click="out">out</el-button>
<el-button type="primary" @click="checkToken">checkToken</el-button>
</el-form>
</div>
</template>
- 编辑
LoginView.vue
,加入刚刚写的ts
<script lang="ts">
import {defineComponent} from 'vue'
import {LoginBO, loginService, UserVO} from "@/ts/logIn";
import {AResponse} from "@/ts/common/data";
import {AUTH} from "@/ts/common/auth"
export default defineComponent({
name: "LoginView",
data() {
return {
formData: {
name: "",
pwd: ""
}
}
},
methods: {
login() {
//调用login.ts的login方法,参数是声明的一个class
loginService.login(new LoginBO(this.formData.name, this.formData.pwd),//提供一个请求成功的渲染函数,来自己定义请求成功后怎么办。我这里将请求生成的token放到了AUTH模块
(aResponse: AResponse<UserVO>) => {
AUTH.setXtoken(aResponse.data!!.xtoken!!)
console.log(aResponse)
})
},
out() {
loginService.out((aResponse: AResponse<void>) => {
AUTH.removeXtoken()
console.log(aResponse)
})
},
checkToken() {
loginService.checkToken((aResponse: AResponse<Boolean>) => {
console.log(aResponse)
})
}
}
})
</script>
这里有3
个问题需要说明:
- 在
login.ts
中我定义了一个Function
用来渲染成功请求的后续处理,但是没有加上失败的,后续可以加上,增加灵活性 - 在
login.ts
中参数放在params
与data
上是不同的,我并没有改变axios
的默认设置- 在
axios
中,不管是不是get
,默认params
有参数就是表单提交并且会把参数拼接在地址栏(请求头默认) - 在
axios
中,不管是不是get
,默认data
有参数就是body json
提交(请求头默认)
- 在
- 请求实际上是
http.ts
完成的,会出现跨域的问题- 前端对于跨域问题确实没简单的办法搞定,需要后端或
nginx
代理配置处理一下。我不在这里解释什么是跨域,也不提供解决办法,网上一大堆。
- 前端对于跨域问题确实没简单的办法搞定,需要后端或
在我的代码层次中:
LoginView
关注数据绑定和渲染,不需要考虑其他东西login.ts
关注当前interface
的方法实现http.ts
关注发送和接收请求,统一处理http状态码
异常及跨域代理等网络问题auth.ts
关注授权data.ts
关注通用数据模型
整套下来只有 LoginView
和login.ts
是需要被业务绑定的,其他都是可以当作common
使用到任意项目中,技术的积累也就是这个common
了
六、演示登录的业务
ui
挺丑,下面会学习css
配合element-plus
来做页面。
我突然想用kotlin
写前端是不是也是一样,类似typeScript
,就是不知道vue
是否支持。kotlin
开发前端太小众,估计大部分前端都不会用kotlin
吧,毕竟学习成本远高于typeScript
。
评论区