Vue3项目升级接入TypeScript流程

1,691 阅读4分钟

一. 升级准备

查看项目库版本,确保接入TS后各库可以支持到,这里项目使用的是vue3,完全没有问题

"dependencies": {
    "vue": "^3.0.4",
    "vue-router": "4.0.12",
    "axios": "^1.1.3",
    "element-plus": "^2.2.19",
    "pinia": "^2.0.23",
}
"devDependencies": {
    "vite": "^2.8.0"
}

二. 升级目标

  1. 安装 typescript ,为项目提供基础ts语言支持

  2. 安装 @typescript-eslint/eslint-plugin ,让elsint识别ts的一些特殊语法

  3. 安装 @typescript-eslint/parser,为eslint提供解析器

  4. 不影响原有代码的逻辑和引用关系,新建 http.ts 为请求流程接入类型

  5. 新增 xxx.d.ts 对ts引入js的地方提供类型声明支持

  6. 新的Vue业务代码,直接使用 ts + setup 使用即可

三. 升级优势

  1. 支持方面:由于Vue3及其他社区主流库使用TS开发,所以对类型支持良好

  2. 质量方面:开发环境使用强类型语言,它的代码检测可以大幅降低出错的概率

  3. IDE支持:智能提示、自动补全、代码导航

  3. 团队开发:可明显提升代码的可读性和可维护性,同时使重构变得更容易

四. 升级流程
1 . 安装所需相关依赖
"devDependencies": {
    "@typescript-eslint/eslint-plugin": "^5.54.1", 
    "@typescript-eslint/parser": "^5.54.1",
    "typescript": "^4.9.5",
}
2 . 根目录新增配置文件 tsconfig.json
{ 
    "compilerOptions": { 
        "target": "esnext", 
        "module": "esnext", 
        "moduleResolution": "node", 
        "strict": true, 
        "jsx": "preserve", 
        "sourceMap": true, 
        "resolveJsonModule": true, 
        "esModuleInterop": true, 
        "lib": ["esnext", "dom"], 
        "baseUrl": "./", 
        "paths": { "@/*":["src/*"] } 
    }, 
    "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], "exclude": [] 
}
3 . 根目录新增配置文件 tsconfig.node.json
{ 
    "compilerOptions": { 
        "composite": true, 
        "module": "ESNext", 
        "moduleResolution": "Node", 
        "allowSyntheticDefaultImports": true 
    }, 
    "include": ["vite.config.ts"] 
}
4 . 新建建议安装插件的文件 /.vscode/extensions.json
{ 
    "recommendations": [
        "Vue.volar", 
        "Vue.vscode-typescript-vue-plugin"
    ] 
}
5 . 接口文件修改书写方式,并声明为 .ts 后缀
/** * @file 上传图片 */
    
const url = (import.meta as any).env.VITE_APP_SYSTEMURL;
import * as T from './types' import * as HTTP from '@/utils/http'
    
// 获取文档
export function systemaDocInfo_getSystemaDocInfo(data: T.ISystemaDocInfoGetSystemaDocInfo): Promise<HTTP.ResType<any>> { 
    return HTTP.postRequest( url + 'systemaDocInfo/getSystemaDocInfo', data) 
} 
    
// 添加&更新文档 
export function systemaDocInfo_updateSystemaDocInfo(data: T.ISystemaDocInfoUpdateSystemaDocInfo): Promise<HTTP.ResType<any>> { 
    return HTTP.postRequest( url + 'systemaDocInfo/updateSystemaDocInfo', data) 
}
6 . 每个api接口文件同级对应一个 typs.ts 类型文件
/** * @file 文档声明文件 */
    
export type ISystemaDocInfoGetSystemaDocInfo = { tab_id: string } 

type SaveDocInfo = 'tab_id' | 'title' | 'content' 
    
export type ISystemaDocInfoUpdateSystemaDocInfo = Record<SaveDocInfo, string>
7 . 原 @/utils/request.js 保持原有逻辑代码,并新增改造文件 @/utils/http.ts
import axios, { AxiosRequestConfig, Axios, InternalAxiosRequestConfig, AxiosResponse } from 'axios'
import cancelRequest from '../store/cancelRequest'
import { ElMessageBox } from "element-plus";
const CancelToken = axios.CancelToken
import router from '../router/index';

// 声明返回类型供@/api下的文件使用
export interface ResType<T> {
  data: T
  message: string
  status: number
}

// 请求队列
const pendingQueue: Array<any> = []
// 定时器
let timer: number | null = null
let isTimer: boolean = false
// 登出函数
const logout = (): void => {
  localStorage.removeItem('token')
  isTimer = false
  clearTimeout(timer as number)
  timer = null;
  (router as any).push({
    path: '/login'
  })
}
// 删除请求函数
const removePending = (config: AxiosRequestConfig<any>): void => {
  for (const i in pendingQueue) {
    let NumberI: number = Number(i)
    if (pendingQueue[NumberI].urlFlag === `${config.url}&${config.method}` && (config as any).promiseStatus === 'pending') {
      // 行业库的查询接口不取消
      if (config?.url?.indexOf('hangyeList/showQmpHangyeList') == -1 &&
          config.url.indexOf('hangyeList/showHangyeBasicInfo') == -1) {
        pendingQueue[NumberI].canceler() // 对该次接口之前发送的的同名接口执行取消操作
        pendingQueue.splice(NumberI, 1) // 把这条记录从数组中移除
      }
    }
  }
}
// Axios 实例
export const service = axios.create({
  baseURL: '',
  timeout: 0 // request timeout
})
// 请求拦截
service.interceptors.request.use((config: InternalAxiosRequestConfig<any>) => {
  (config as any).promiseStatus = 'pending'
  removePending(config)
  config.cancelToken = new CancelToken((canceler) => {
    if (!(config as any).noCancel) { // 该请求可被取消
      pendingQueue.push({
        urlFlag: `${config.url}&${config.method}`, // url标记,便宜请求结束后从pendingQueue中移除
        canceler // 执行该方法可取消正在pending中的该请求
      })
    }
    const cancelRequestStore = (cancelRequest as any)()
    cancelRequestStore.pushToken(canceler)// 这里就是取消请求的方法
  })
  const token = localStorage.getItem('token')
  if (token) {
    config.headers.token = token
  }
  return config
}, error => {
  console.log(error)
})
// 响应拦截
service.interceptors.response.use((res: any) => {
  // removePending(config, false) // 清除该记录
  if (res.data.status * 1 != 0 && res.data.status * 1 != 1) {
  }
  if (res.data.status * 1 === 0 || res.data.status * 1 === 1) {
    return res
  } else if (res.data.status * 1 === 4000) {
    if (!isTimer) {
      isTimer = true
      // 4000登录过期
      ElMessageBox.confirm(
        '登录已到期,您将被强制下线!请重新登录。',
        '提示',
        {
          confirmButtonText: '确定',
          type: 'warning',
          showCancelButton: false
        }
      ).then(() => {
        logout()
      })
        .catch(() => { }
        )
      timer = setTimeout(() => {
        logout()
        ElMessageBox.close();
      }, 30000);
    }

  } else {
    return res
  }
})

// export default service
const maxRetryCount = 2
export default async function request(config: any): Promise<any> {
  try {
    return await service(config)
  } catch (err) { // 出错重新发送
    if (!(err as any).message) return Promise.reject(err)// 请求被取消时,无message,此时不处理
    if (config.retryCount) {
      if (config.retryCount > maxRetryCount) {
        config.retryCount = maxRetryCount
      }
      return request({
        ...config,
        retryCount: config.retryCount - 1,
      })
    }
    return Promise.reject(err)
  }
}
// POST 请求函数
export function postRequest(configOrUrl: string, data: unknown): Promise<ResType<any>> {
  const obj = {
    data,
    method: 'post',
  }
  if (typeof configOrUrl === 'string') {
    (obj as any).url = configOrUrl
  } else if (typeof configOrUrl === 'object') {
    Object.assign(obj, configOrUrl)
  }
  return request(obj)
}
// GET 请求函数
export function getRequest(configOrUrl: string, data: any): Promise<ResType<any>> {
  const obj = {
    data,
    method: 'get',
  }
  if (typeof configOrUrl === 'string') {
    (obj as any).url = configOrUrl
  } else if (typeof configOrUrl === 'object') {
    Object.assign(obj, configOrUrl)
  }
  return request(obj)
}

// 利用闭包生成单例模式promise api
export function toSingleInstanceApi(configOrUrl: string): Function {
  let promise: Promise<any>
  let isPending: boolean
  return function (data: any) {
    if (isPending) return promise
    return new Promise(resolve => {
      isPending = true
      promise = postRequest(configOrUrl, data)
      promise.then(resolve).finally(() => { isPending = false })
    })
  }
}

8 . 需要使用 TS 书写 vue 文件的声明 lang 属性即可
<template>
  <div id="DocumentWrap" v-loading="!ContentReady" ref="ScreenEL">
    <header id="document-header">
      <div id="document-nav">
        <el-button type="primary" v-show="!editIng" @click="editAction">编辑文档</el-button>
        <el-button type="primary" v-show="editIng" @click="exitAction">保存文档</el-button>
        <div class="full-screen">
         
          <i
            v-if="!isFullscreen"
            @click="toggle"
            class="iconfont icon-quanping_o"
          ></i>
          <i
            v-if="isFullscreen"
            @click="toggle"
            class="iconfont icon-quxiaoquanping_o"
          ></i>
        </div>
      </div>
      <div id="toolbarWrap" v-show="editIng">
        <Toolbar
          id="document-toolbar"
          :editor="editorRef"
          :defaultConfig="toolbarConfig"
          :mode="mode"
        />
      </div>
    </header>
    <section id="document-section">
      <el-scrollbar id="document-scrollbar">
        <el-card id="card-editor">
          <div class="title-container">
            <input
              v-model="TitleValue"
              :disabled="!editIng"
              class="title-input"
              placeholder="请输入标题..."
              type="text"
            >
          </div>
          <Editor
            id="document-editor"
            v-model="valueHtml"
            :defaultConfig="editorConfig"
            :mode="mode"
            @onCreated="handleCreated"
          />
        </el-card>
      </el-scrollbar>
    </section>
    <!-- <footer id="document-footer">
    </footer> -->
  </div>
</template>

<script setup lang="ts">
import '@wangeditor/editor/dist/css/style.css' // 引入 css
import { ElMessage } from 'element-plus'
import { onBeforeUnmount, ref, shallowRef, onMounted, nextTick } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useRoute } from 'vue-router'
import { uploadImgOSS } from '@/api/system/upload'
import * as T_UPLOAD from '@/api/system/upload/types'
import { DomEditor, IEditorConfig, IDomEditor, IToolbarConfig } from '@wangeditor/editor'
import { useFullscreen } from '@vueuse/core'
import {
  systemaDocInfo_getSystemaDocInfo,
  systemaDocInfo_updateSystemaDocInfo
} from '@/api/system/word'
import * as T_WORD from '@/api/system/word/types'

// Hook 全屏
const ScreenEL = ref(null)
const { isFullscreen, toggle } = useFullscreen(ScreenEL)
const mode: string = 'default' // 或 'simple'
// 路由实例
const route = useRoute()
// 标题最大字数
const MaxTitleLen = ref<number>(250)
// 文档对应的ID
const TabId = ref<string>('')
// 文档内容是否拿到并反显
const ContentReady = ref<boolean>(false)
// 文档的最小高度
const MinHeight = ref<string>('1200px')
// 标题
const TitleValue = ref<string>('')
// 编辑器实例
const editorRef = shallowRef()
// 是否正在编辑
const editIng = ref<boolean>(false)
// 内容 HTML
const valueHtml = ref<string>('')
// 初始化接收ID
onMounted(() => {
  const id = route?.query.tab_id
  id ? (TabId as any).value = id : null
  getDocInfo()
})
// 菜单栏配置
const toolbarConfig: Partial<IToolbarConfig> = {}
// 编辑器配置
const editorConfig: Partial<IEditorConfig> = {
  placeholder: '请输入内容...',
  MENU_CONF: {
    uploadImage: {},
    insertLink: {}
  }
}
const MENU_CONF: any = editorConfig.MENU_CONF
// 格式化链接
const customParseLinkUrl = (url: string): string => {
  if (url.startsWith('http://') || url.startsWith('https://')) {
    return url
  } else {
    return `http://${url}`
  }
}
type InsertFnType = (url: string, alt: string, href: string) => void
MENU_CONF['uploadImage'] = {
  // 自定义上传
  async customUpload(file: File, insertFn: InsertFnType) {
    // 自己实现上传,并得到图片 url alt href
    const result = await ImgUpload(file) // 使用 await 等待图片上传完成 然后再调用<insertFn>
    const url = result.data?.data?.img_url
    const name = file.name || String(new Date().getTime())
    url ? insertFn(url, name,url) : null // 最后插入图片
  }
}
MENU_CONF['insertLink'] = {
  parseLinkUrl: customParseLinkUrl,
}

// 上传图片接口
const ImgUpload = async (file: File) => {
  const formData: T_UPLOAD.IUploadAliOssImg = new FormData();
  formData.append("ptype", 'sjd_pc');
  formData.append("version", '3.0');
  formData.append("unionid", (localStorage.getItem("uid")) as string);
  formData.append("pic", file)
  return uploadImgOSS(formData)
}

// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
  const editor = editorRef.value
  if (editor == null) return
  editor.destroy()
})
// 创建函数
const handleCreated = (editor: IDomEditor) => {
  editorRef.value = editor // 记录 editor 实例
  editor.disable() // 进入页面禁止输入
  nextTick(() => { // 这里要等下一个执行队列
    // toolbar 实例
    const toolbar = DomEditor.getToolbar(editor)
    const curToolbarConfig = (toolbar as any).getConfig()
    // console.log( curToolbarConfig.toolbarKeys ) // 当前菜单排序和分组
  })
  toolbarConfig.excludeKeys = [
    // 'group-image',
    'group-video',
    'fullScreen'
  ]
}
const editAction = () => {
  editIng.value = true
  editorRef.value.enable()
}
const exitAction = () => {
  if (!inputWarning()) return
  editIng.value = false
  editorRef.value.disable()
  saveDocInfo()
}
// 获取文档
const getDocInfo = () => {
  const params: T_WORD.ISystemaDocInfoGetSystemaDocInfo = {
    tab_id: (TabId.value as string) || ''
  }
  systemaDocInfo_getSystemaDocInfo(params).then((res) => {
    if (res?.data?.status === 0) {
      TitleValue.value = res.data.data.info?.title || ''
      valueHtml.value = res.data.data.info?.content || ''
      ContentReady.value = true
    }
  })
}

// 检测输入的内容
const inputWarning = () => {
  if (!TitleValue.value) {
    ElMessage.warning('请输入文档标题')
    return false
  } else if (TitleValue.value?.length > MaxTitleLen.value) {
    ElMessage.warning(`标题字数需小于 ${MaxTitleLen.value}`)
    return false
  } else {
    return true
  }
}
// 保存文档
const saveDocInfo = () => {
  const params: T_WORD.ISystemaDocInfoUpdateSystemaDocInfo = {
    tab_id: TabId.value || '',
    title: TitleValue.value,
    content: editorRef.value.getHtml()
  }
  systemaDocInfo_updateSystemaDocInfo(params).then((res) => {
    if (res?.data?.status !== 0) {
      ElMessage({
        message: '文档保存失败,请稍后重试', // res?.data?.message
        type: 'warning',
      })
    }
  })
}
</script>
<style lang="scss" scoped>
#DocumentWrap {
  width: 100%;
  height: 100%;
  background: #f5f6f7;
  display: flex;
  flex-direction: column;
  #document-header{
    #document-nav {
      width: 100%;
      height: 58px;
      background: #fff;
      border-bottom: 1px solid #e2e6ed;
      padding: 0 20px;
      box-sizing: border-box;
      display: flex;
      align-items: center;
      justify-content: flex-end;
      .full-screen {
        i {
          font-size: 28px;
          cursor: pointer;
          font-weight: 500;
          margin-left: 20px;
          &:hover {
            color: #409eff;
          }
        }
      }
    }
    #toolbarWrap {
      width: 100%;
      height: 50px;
      background: #fff;
      box-shadow: 0 2px 8px 0 rgb(0 0 0 / 10%);
      position: relative;
      z-index: 4;
      #document-toolbar {
        width: 100%;
        height: 100%;
        display: flex;
        align-items: center;
        justify-content: center;
      }
    }
  }
  #document-section{
    flex: 1;
    background: #f5f6f7;
    overflow: hidden;
    #document-scrollbar {
      width: 100%;
      height: 100%;
      // padding: 20px;
      // box-sizing: border-box;
      display: flex;
      justify-content: center;
      #card-editor {
        width: 900px;
        min-height: v-bind(MinHeight);
        height: auto;
        margin: 18px 0;
        .title-container {
          border-bottom: 1px solid #ccc;
          height: 60px;
          padding: 5px 0;
          margin-bottom: 10px;
          .title-input {
            border: none;
            height: 100%;
            width: 100%;
            font-size: 25px;
            outline: none;
          }
          .title-input:disabled {
            background-color: #fff !important;
          }
        }
        #document-editor{
          width: 100%;
          height: auto;
          min-height: v-bind(MinHeight);
        }
      }
    }
  }
  #document-footer{
    width: 100%;
    height: 30px;
    background: #fff;
    border-top: 1px solid #e2e6ed;
    display: flex;
    justify-content: center;
    align-items: center;
    // border-bottom: 1px solid #e2e6ed;
    z-index: 2;
    // margin-bottom: 1px;
    .icon-full_screen {
      cursor: pointer;
      &:hover{
        color: #409eff;
      }
    }
  }
}
</style>
<style lang="scss">
#document-scrollbar .el-scrollbar__wrap {
  width: 100% !important;
}
#document-scrollbar .el-scrollbar__view {
  width: 100% !important;
  display: flex !important;
  justify-content: center !important;
}
#card-editor .el-card__body {
  min-height: v-bind(MinHeight) !important;
}
#document-editor .w-e-text-container {
  min-height: v-bind(MinHeight) !important;
}
#document-editor .w-e-scroll {
  min-height: v-bind(MinHeight) !important;
}
</style>
9 . 对于 ts 文件引用 js 变量的地方,需要单独处理,在xxx.js同级创建xxx.d.ts文件并声明导出即可

*10 . **http.ts *文件针对后端返回格式,抛出 ResType泛型供项目代码做类型检测
export interface ResType<T> { 
    data: T 
    message: string 
    status: number 
}