记一次在老掉牙的Vue2项目中引入TypeScript和组合式Api的艰辛历程

原由

现有的一个项目2年前创建的,随着时间流逝,代码量已经暴增到了将近上万个文件,但是工程化已经慢慢到了不可维护的状态,想给他来一次大换血,但是侵入式代码配置太多了……,最终以一种妥协的方式引入了TypeScript、组合式Api、vueuse,提升了项目的工程化规范程度,整个过程让我颇有感概,记录一下。

先配置TypeScript相关的

一些库的安装和配置

  1. 由于webpack的版本还是3.6,尝试数次升级到4、5都因为大量的配置侵入性代码的大量修改工作放弃了,所以就直接找了下面这些库

    npm i -D ts-loader@3.5.0 tslint@6.1.3 tslint-loader@3.6.0 fork-ts-checker-webpack-plugin@3.1.1
  2. 接下来就是改webpack的配置了,修改main.js文件为main.ts,并在文件的第一行添加// @ts-nocheckTS忽略检查此文件,在webpack.base.config.js的入口中相应的改为main.ts
  3. webpack.base.config.jsresolve中的extensions中增加.ts.tsx,alias规则中增加一条'vue$': 'vue/dist/vue.esm.js'
  4. webpack.base.config.js中增加plugins选项添加fork-ts-checker-webpack-plugin,将ts check的任务放到单独的进程中进行,减少开发服务器启动时间
  5. webpack.base.config.js文件的rules中增加两条配置和fork-ts-checker-webpack-plugin的插件配置

    {
      test: /\.ts$/,
      exclude: /node_modules/,
      enforce: 'pre',
      loader: 'tslint-loader'
    },
    {
      test: /\.tsx?$/,
      loader: 'ts-loader',
      exclude: /node_modules/,
      options: {
     appendTsSuffixTo: [/\.vue$/],
     transpileOnly: true // disable type checker - we will use it in fork plugin
      }
    },,
    // ...
    plugins: [new ForkTsCheckerWebpackPlugin()], // 在独立进程中处理ts-checker,缩短webpack服务冷启动、热更新时间 https://github.com/TypeStrong/ts-loader#faster-builds
  6. 根目录中增加tsconfig.json文件补充相应配置,src目录下新增vue-shim.d.ts声明文件

    tsconfig.json

    {
     "exclude": ["node_modules", "static", "dist"],
     "compilerOptions": {
     "strict": true,
     "module": "esnext",
     "outDir": "dist",
     "target": "es5",
     "allowJs": true,
     "jsx": "preserve",
     "resolveJsonModule": true,
     "downlevelIteration": true,
     "importHelpers": true,
     "noImplicitAny": true,
     "allowSyntheticDefaultImports": true,
     "moduleResolution": "node",
     "isolatedModules": false,
     "experimentalDecorators": true,
     "emitDecoratorMetadata": true,
     "lib": ["dom", "es5", "es6", "es7", "dom.iterable", "es2015.promise"],
     "sourceMap": true,
     "baseUrl": ".",
     "paths": {
       "@/*": ["src/*"],
     },
     "pretty": true
     },
     "include": ["./src/**/*", "typings/**/*.d.ts"]
    }

    vue-shim.d.ts

    declare module '*.vue' {
     import Vue from 'vue'
     export default Vue
    }

路由配置的改善

原有路由配置是通过配置pathnamecomponent,这样在开发和维护的过程中有一些缺点:

  1. 使用的时候可能出现使用path或者使用name不规范不统一的情况
  2. 开发人员在维护老代码的时候查找路由对应的单文件不方便
  3. 要手动避免路由的namepath不与其他路由有冲突

将所有的路由的路径按照业务抽离到不同的枚举中。在枚举中定义可以防止路由 path 冲突,也可以将枚举的 key 定义的更加语义化,又可以借助Typescript的类型推导能力快速补全,在查找路由对应单文件的时候可以一步到位

为什么不用name,因为name只是一个标识这个路由的语义,当我们使用枚举类型的path之后,枚举的Key就足以充当语义化的路径path这个name属性就没有存在的必要了,我们在声明路由的时候就不需要声明name属性,只需要pathcomponent字段就可以了

demo

export enum ROUTER {
  Home = '/xxx/home',
  About = '/xxx/about',
}

export default [
  {
    path: ROUTER.Home,
    component: () => import( /* webpackChunkName:'Home' */ 'views/Home')
  },
  {
    path: ROUTER.About,
    component: () => import( /* webpackChunkName:'About' */ 'views/About')
  }
]

常量和枚举

之前在我们项目中也是通过把所有的常量抽离到services/const中进行管理,现在集成了Typescript之后,我们就可以在之后项目在services/constant中进行管理常量,在services/enums中管理枚举。

比如常见的接口返回的code就可以声明为枚举,就不用在使用的时候还需要手写if (res.code === 200)类似的判断了,可以直接通过声明好的RES_CODE枚举直接获取到所有的接口返回code类型

// services/enums/index.ts
/** RES_CODE Enum */
export enum RES_CODE {
  SUCCESS = 200
  // xxx
}

比如storagekey我们就可以声明在services/constant/storage.ts

/** userInfo-storageKey */
export const USERINFO_STORE_KEY = 'userInfo'

/** 与用户相关的key可以通过构造一个带业务属性参数的纯函数来声明 */
export const UserSpecialInfo = (userId: string) => {
  return `specialInfo-${userId}`
}

类型声明文件规范

全局类型声明文件统一在根目录的typings文件夹中维护(可复用的数据类型)

比较偏业务中组装数据过程中的类型直接在所在组件中维护即可(不易复用的数据结构)

接口中的类型封装

请求基类封装逻辑

在 utils 文件夹下新增requestWrapper.ts文件,之后所有的请求基类方法封装可以在此文件中进行维护

// src/utils/requestWrapper.ts
import { AxiosResponse } from 'axios'
import request from '@/utils/request'

// 请求参数在之后具体封装的时候才具体到某种类型,在此使用unknown声明,返回值为泛型S,在使用的时候填充具体类型
export function PostWrapper(
  url: string,
  data: unknown,
  timeout?: number
) {
  return (request({
    url,
    method: 'post',
    data,
    timeout
  }) as AxiosResponse['data']) as BASE.BaseResWrapper // BASE是在typings中定义的一个命名空间 后面会有代码说明
}

在具体的业务层进行封装后的使用

api/user中新建一个index.ts文件,对比之前的可以做到足够简洁,也可以提供类型提示,知晓这个请求是什么请求以及参数的参数以及返回值

import { PostWrapper } from '@/utils/requestWrapper'

// 此处只需要在注释中标注这个接口是什么接口,不需要我们通过注释来标识需要什么类型的参数,TS会帮我们完成, 只需要我们填充请求参数的类型和返回参数的类型即可约束请求方法的使用
/** 获取用户信息 */
export function getUserInfo(query: User.UserInfoReqType) {
  return PostWrapper(
    '/api/userinfo',
    query
  )
}
  • 需要提供类型支持的接口,需要声明在api/**/*.ts文件中,并通过给对应的function标注参数请求类型和响应类型
  • 如果结构极为简洁,可以不需要在typings/request/*.d.ts中维护,直接在封装接口处声明类型即可,如果参数稍多,都应在typings/request/*.d.ts中维护,避免混乱

现在业务中的服务端的接口返回的基本都是通过一层描述性对象包裹起来的,业务数据都在对象的request字段中,基于此我们封装接口就在typings/request/index.d.ts中声明请求返回的基类结构,在具体的xxx.d.ts中完善具体的请求类型声明,例如user.d.ts中的一个报错的接口,在此文件中声明全局的命名空间User来管理所有此类作业接口的请求和响应的数据类型
typings/request/index.d.ts

import { RES_CODE } from '@/services/enums'

declare global {
  // * 所有的基类在此声明类型
  namespace BASE {
    // 请求返回的包裹层类型声明提供给具体数据层进行包装
    type BaseRes = {
      code: RES_CODE
      result?: T
      info?: string
      time: number
      traceId: string
    }
    type BaseResWrapper = Promise>
    // 分页接口
    type BasePagination = {
      content: T
      now: string
      page: number
      size: number
      totalElements: number
      totalPages: number
    }
  }

typings/request/user.d.ts

declare namespace User {

/** 响应参数 */
type UserInfoResType = {
  id: number | string
  name: string
  // ...
}

/** 请求参数 */
type UserInfoReqType = {
  id: number | string
  // ...
}

到此TypeScript相关的就结束了,接下来是组合式Api的

Vue2中使用组合式Api

  1. 安装@vue/componsition-api
npm i @vue/componsition-api
  1. main.tsuse即可在.vue文件中使用组合式 API
import VueCompositionAPI from '@vue/composition-api'
// ...
Vue.use(VueCompositionAPI)

Vue2 中使用组合式 Api 中的一些注意事项

  1. 组合式 Api文档,不了解的小伙伴可以先参照文档学习一下,在比较复杂的页面,组件多的情况下组合式 API 相比传统的Options API更灵活,可以把逻辑抽离出去封装为单独的use函数,使组件代码结构更为清晰,也更方便复用业务逻辑。
  2. 所有的组合式 Api 中的api都需要从@vue/composition-api中引入,然后使用export default defineComponent({ })替换原有的export default { }的写法,即可启用组合式 Api 语法和Typescript的类型推导(script需要添加对应的lang="ts"attribute)
  3. template中的写法和Vue2中一致,无需注意Vue3中的v-model和类似.native的事件修饰符在Vue3中取消等其他的break change
  4. 子组件中调用父组件中的方法使用setup(props, ctx)中的ctx.emit(eventName, params)即可,给Vue实例对象上挂载的属性和方法都可以通过ctx.root.xxx来获取,包括$route$router等,为了使用方便推荐在setup中第一行就通过结构来声明ctx.root上的属性,,如果之前在Vue实例对象上添加的有业务属性相关的属性或方法可以通过扩展模块vue/types/vue上的Vue接口来添加业务属性相关的类型:

    typings/common/index.d.ts

    // 1. Make sure to import 'vue' before declaring augmented types
    import Vue from 'vue'
    // 2. Specify a file with the types you want to augment
    //    Vue has the constructor type in types/vue.d.ts
    declare module 'vue/types/vue' {
     // 3. Declare augmentation for Vue
     interface Vue {
     /** 当前环境是否是IE */
     isIE: boolean
     // ... 各位根据自己的业务情况自行添加
     }
    }
  5. 所有template中使用到的变量、方法、对象都需要在setupreturn,其他的在页面逻辑内部使用的不需要return
  6. 推荐根据页面展示元素和用户与页面的交互行为定义setup中的方法,比较复杂的逻辑细节和对数据的处理尽量抽离到外部,保持.vue文件中的代码逻辑清晰
  7. 在需求开发前,根据服务端接口数据的定义,来制定页面组件中的数据和方法的接口,可以提前声明类型,之后在开发过程中实现具体的方法
  8. 在当下的Vue2.6版本中通过@vue/composition-api使用组合式 Api 不能使用setup语法糖,待之后的Vue2.7版本release之后再观察,其他的一些 注意事项和限制

基于 reactive 的 store 的风格规范

鉴于在Vuex中接入TS的不便和Vuex使用场景的必要性,在组合式 Api 中提供了一个最佳实践:将需要响应的数据声明在一个ts文件中通过reactive包裹初始化对象,暴漏出一个更新的方法,即可达到原有在Vuex中更新storestate的效果,使用computed可以达到getter的效果,哪些组件需要对数据进行获取和修改只需要引入即可,更改直接就可以达到响应效果!提供一份Demo,各位对于这部分内容的封装可以见仁见智:

// xxxHelper.ts
import { del, reactive, readonly, computed, set } from '@vue/composition-api'

// 定义store中数据的类型,对数据结构进行约束
interface CompositionApiTestStore {
  c: number
  [propName: string]: any
}

// 初始值
const initState: CompositionApiTestStore = { c: 0 }

const state = reactive(initState)

/** 暴露出的store为只读,只能通过下面的updateStore进行更改 */
export const store = readonly(state)

/** 可以达到原有Vuex中的getter方法的效果 */
export const upperC = computed(() => {
  return store.c.toUpperCase()
})

/** 暴漏出更改state的方法,参数是state对象的子集或者无参数,如果是无参数就便利当前对象,将子对象全部删除, 否则俺需更新或者删除 */
export function updateStore(
  params: Partial | undefined
) {
  console.log('updateStore', params)
  if (params === undefined) {
    for (const [k, v] of Object.entries(state)) {
      del(state, `${k}`)
    }
  } else {
    for (const [k, v] of Object.entries(params)) {
      if (v === undefined) {
        del(state, `${k}`)
      } else {
        set(state, `${k}`, v)
      }
    }
  }
}

vueuse

vueuse是一个很好用的库,具体的安装和使用非常简单,但是功能很多很强大,这部分我就不展开细说了,大家去看官方文档吧!

总结

这次的项目升级实在是迫不得已,没办法的办法,项目已经庞大无比还要兼容IE,用的脚手架及相关库也都很久没有更新版本,在项目创建开始就已经欠下了很多的技术债了,导致后面开发维护人员叫苦不迭(其实就是我,项目是别个搞的,逃…),各位老大哥在新起项目的时候一定要斟酌脚手架和技术栈啊,不要前人挖坑后人填了……

如果你也在维护这样的项目,并且也受够了这种糟糕的开发体验,可以参照我的经验来改造下你的项目,如果看过感觉对你有帮助,也请给个一键三连~