侧边栏壁纸
博主头像
术业有道之编程博主等级

亦是三月纷飞雨,亦是人间惊鸿客。亦是秋霜去叶多,亦是风华正当时。

  • 累计撰写 99 篇文章
  • 累计创建 50 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

vue3+typeScript+element-plus学习

Administrator
2022-04-19 / 0 评论 / 0 点赞 / 201 阅读 / 18291 字

写在前面

系统性的学习一下前端最新的技术栈已经是很早的计划了。最近看了下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=appdom内容。
  • 基于上面的理解,Vue在单页面上实际上是一个dom嵌套,对于page作用域范围,大部分都是全局的,我猜测只有在views中声明的有可能会在源代码编译的过程中被限制,如果不限制,就出现了作用域逃逸的现象(或者vue的设计不考虑子父关系和作用域?)。这一点需要单独验证。
  • 项目的编译过程:这个过程我还没有搞太清楚。
    • 安装依赖时npm可以直接读取这个文件,进行相应依赖安装
    • 编译时vue cli也可以直接读取这个文件进行编译
    • 看起来package.json文件更像是vuenode的一个通用文件清单。具体的编译原理可能需要看看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中参数放在paramsdata上是不同的,我并没有改变axios的默认设置
    • axios中,不管是不是get,默认params有参数就是表单提交并且会把参数拼接在地址栏(请求头默认)
    • axios中,不管是不是get,默认data有参数就是body json提交(请求头默认)
  • 请求实际上是http.ts完成的,会出现跨域的问题
    • 前端对于跨域问题确实没简单的办法搞定,需要后端或nginx代理配置处理一下。我不在这里解释什么是跨域,也不提供解决办法,网上一大堆。

在我的代码层次中:

  • LoginView 关注数据绑定和渲染,不需要考虑其他东西
  • login.ts 关注当前interface的方法实现
  • http.ts 关注发送和接收请求,统一处理http状态码异常及跨域代理等网络问题
  • auth.ts 关注授权
  • data.ts 关注通用数据模型

整套下来只有 LoginViewlogin.ts是需要被业务绑定的,其他都是可以当作common使用到任意项目中,技术的积累也就是这个common

六、演示登录的业务

ui挺丑,下面会学习css配合element-plus来做页面。
login演示.png

我突然想用kotlin写前端是不是也是一样,类似typeScript,就是不知道vue是否支持。kotlin开发前端太小众,估计大部分前端都不会用kotlin吧,毕竟学习成本远高于typeScript

个人公众号

0

评论区