記一次在老掉牙的Vue2專案中引入TypeScript和組合式Api的艱辛歷程

Senar發表於2022-01-14

原由

現有的一個專案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<S>(
  url: string,
  data: unknown,
  timeout?: number
) {
  return (request({
    url,
    method: 'post',
    data,
    timeout
  }) as AxiosResponse['data']) as BASE.BaseResWrapper<S> // BASE是在typings中定義的一個名稱空間 後面會有程式碼說明
}

在具體的業務層進行封裝後的使用

api/user中新建一個index.ts檔案,對比之前的可以做到足夠簡潔,也可以提供型別提示,知曉這個請求是什麼請求以及引數的引數以及返回值

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

// 此處只需要在註釋中標註這個介面是什麼介面,不需要我們通過註釋來標識需要什麼型別的引數,TS會幫我們完成, 只需要我們填充請求引數的型別和返回引數的型別即可約束請求方法的使用
/** 獲取使用者資訊 */
export function getUserInfo(query: User.UserInfoReqType) {
  return PostWrapper<User.UserInfoResType>(
    '/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<T> = {
      code: RES_CODE
      result?: T
      info?: string
      time: number
      traceId: string
    }
    type BaseResWrapper<T> = Promise<BASE.BaseRes<T>>
    // 分頁介面
    type BasePagination<T> = {
      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<CompositionApiTestStore> | 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,用的腳手架及相關庫也都很久沒有更新版本,在專案建立開始就已經欠下了很多的技術債了,導致後面開發維護人員叫苦不迭(其實就是我,專案是別個搞的,逃…),各位老大哥在新起專案的時候一定要斟酌腳手架和技術棧啊,不要前人挖坑後人填了……

如果你也在維護這樣的專案,並且也受夠了這種糟糕的開發體驗,可以參照我的經驗來改造下你的專案,如果看過感覺對你有幫助,也請給個一鍵三連~

相關文章