原由
現有的一個項目2年前創建的,隨著時間流逝,代碼量已經暴增到了將近上萬個文件,但是工程化已經慢慢到了不可維護的狀態,想給他來一次大換血,但是侵入式代碼配置太多了……,最終以一種妥協的方式引入了TypeScript、組合式Api、vueuse,提升了項目的工程化規范程度,整個過程讓我頗有感概,記錄一下。
先配置TypeScript相關的
一些庫的安裝和配置
- 由于
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
- 接下來就是改
webpack
的配置了,修改main.js
文件為main.ts
,并在文件的第一行添加// @ts-nocheck
讓TS
忽略檢查此文件,在webpack.base.config.js
的入口中相應的改為main.ts
- 在
webpack.base.config.js
的resolve
中的extensions
中增加.ts
和.tsx
,alias
規則中增加一條'vue$': 'vue/dist/vue.esm.js'
- 在
webpack.base.config.js
中增加plugins
選項添加fork-ts-checker-webpack-plugin
,將ts check
的任務放到單獨的進程中進行,減少開發服務器啟動時間 - 在
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
- 根目錄中增加
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
}
路由配置的改善
原有路由配置是通過配置path
、name
和component
,這樣在開發和維護的過程中有一些缺點:
- 使用的時候可能出現使用
path
或者使用name
不規范不統一的情況 - 開發人員在維護老代碼的時候查找路由對應的單文件不方便
- 要手動避免路由的
name
和path
不與其他路由有沖突
將所有的路由的路徑按照業務抽離到不同的枚舉中。在枚舉中定義可以防止路由 path
沖突,也可以將枚舉的 key
定義的更加語義化,又可以借助Typescript
的類型推導能力快速補全,在查找路由對應單文件的時候可以一步到位
為什么不用name
,因為name
只是一個標識這個路由的語義,當我們使用枚舉類型的path
之后,枚舉的Key
就足以充當語義化的路徑path
這個name
屬性就沒有存在的必要了,我們在聲明路由的時候就不需要聲明name
屬性,只需要path
和component
字段就可以了
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
}
比如storage
的key
我們就可以聲明在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
- 安裝
@vue/componsition-api
npm i @vue/componsition-api
- 在
main.ts
中use
即可在.vue
文件中使用組合式 API
import VueCompositionAPI from '@vue/composition-api'
// ...
Vue.use(VueCompositionAPI)
Vue2 中使用組合式 Api 中的一些注意事項
- 組合式 Api文檔,不了解的小伙伴可以先參照文檔學習一下,在比較復雜的頁面,組件多的情況下組合式 API 相比傳統的
Options API
更靈活,可以把邏輯抽離出去封裝為單獨的use
函數,使組件代碼結構更為清晰,也更方便復用業務邏輯。 - 所有的組合式 Api 中的
api
都需要從@vue/composition-api
中引入,然后使用export default defineComponent({ })
替換原有的export default { }
的寫法,即可啟用組合式 Api 語法和Typescript
的類型推導(script
需要添加對應的lang="ts"
的attribute
) -
template
中的寫法和Vue2
中一致,無需注意Vue3
中的v-model
和類似.native
的事件修飾符在Vue3
中取消等其他的break change
- 子組件中調用父組件中的方法使用
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
// ... 各位根據自己的業務情況自行添加
}
}
- 所有
template
中使用到的變量、方法、對象都需要在setup
中return
,其他的在頁面邏輯內部使用的不需要return
- 推薦根據頁面展示元素和用戶與頁面的交互行為定義
setup
中的方法,比較復雜的邏輯細節和對數據的處理盡量抽離到外部,保持.vue
文件中的代碼邏輯清晰 - 在需求開發前,根據服務端接口數據的定義,來制定頁面組件中的數據和方法的接口,可以提前聲明類型,之后在開發過程中實現具體的方法
- 在當下的
Vue2.6
版本中通過@vue/composition-api
使用組合式 Api 不能使用setup
語法糖,待之后的Vue2.7
版本release
之后再觀察,其他的一些 注意事項和限制
基于 reactive 的 store 的風格規范
鑒于在Vuex
中接入TS
的不便和Vuex
使用場景的必要性,在組合式 Api 中提供了一個最佳實踐:將需要響應的數據聲明在一個ts
文件中通過reactive
包裹初始化對象,暴漏出一個更新的方法,即可達到原有在Vuex
中更新store
中state
的效果,使用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,用的腳手架及相關庫也都很久沒有更新版本,在項目創建開始就已經欠下了很多的技術債了,導致后面開發維護人員叫苦不迭(其實就是我,項目是別個搞的,逃…),各位老大哥在新起項目的時候一定要斟酌腳手架和技術棧啊,不要前人挖坑后人填了……
如果你也在維護這樣的項目,并且也受夠了這種糟糕的開發體驗,可以參照我的經驗來改造下你的項目,如果看過感覺對你有幫助,也請給個一鍵三連~