Vue2.5+ Typescript 引入全面指南 - Vuex篇

盤風發表於2017-11-05

Vue2.5+ Typescript 引入全面指南 - Vuex篇

系列目錄:

前言

Vuex 正是我下決心引入Typescript的核心痛點。與 vuex 相關的程式碼中,到處充斥著此般寫法:

Vuex Trouble 1

再加上vuex的 dispatch/commit 並非直接引用程式碼,而是是通過一個string型別的 type 來標記,如此一來上圖中寫法,如果想檢視 payload 的具體內容,甚至不能借助於編輯器的查詢定義,只能手動去切程式碼檢視!簡直苦不堪言。

而藉助於 typescriptinterface 介面,至少可以簡化成如下效果:

Vuex Payload Interface

這麼寫一樣麻煩,不是還需要記類似PostLoginParams的Interface型別?這簡單,建個輔助函式就是:

Vuex Payload Helper Function

編輯器裡一個 Ctrl + 空格payload裡有哪些引數就全出來,再也不用去一遍遍翻程式碼,效率直線提升!

Vuex dispatch Intellisense

現狀概述

截至當前2017年11月,Vuex對Typescript的支援,仍十分薄弱,官方庫只是新增了一些.d.ts宣告檔案,並沒有像vue 2.5這樣內建支援。

第三方衍生庫 vuex-typescript, vuex-ts-decorators, vuex-typex, vuex-class等等,我個人的總結,除了vuex-class外,基本都存在侵入性太強的問題,引用不算友好。而vuex-class提供的功能其實也是薄薄一層,並不能解決核心痛點。因此,需要手動新增輔助的地方,其實頗多。

核心痛點:每次呼叫 this.$store.dispatch / this.$store.commit / this.$store.state/ this.$store.getters 都會伴隨著型別丟失。

其中,dispatch/commit 可以通過建立輔助函式形式,簡單繞開。 state/getters 沒有太好辦法,只能手動指定,若覺得麻煩,可以全都指成 any,等官方支援。官方動態見此 issue

動手改造第一步:從 shopping-cart 示例搬運程式碼

以下示例基於 vuex 官方 examples 中最複雜的一個 shopping-cart
改造後的完整程式碼見 vue-vuex-typescript-demo

準備工作:

  • shopping-cart程式碼複製至專案目錄下
  • .js檔案統一重新命名為.ts
  • currency.js/api/shop.js/components/App.vue等外圍檔案的ts改造
  • npm i -D vuex 新增依賴

詳細步驟這裡略去,參照 程式碼庫 即可

動手改造第二步:State改造

用到state變數的地方實在太多,不僅store目錄下 action/getter/mutation 均有可能需要,甚至在 .vue 檔案裡,mapState也有引用,因此我個人總結的一套實踐:

  • store/modules下的每個子模組,均維護自己名為 State 的 Interface 宣告
  • store/index.ts 檔案中,彙總各子模組,維護一個總的State宣告

store/modules 下檔案舉例:

// ./src/store/modules/cart.ts

interface Shape {
  id: number
  quantity: number
}

export interface State {
  added: Shape[]
  checkoutStatus: 'successful' | 'failed' | null
}

// initial state
// shape: [{ id, quantity }]
const state: State = {
  added: [],
  checkoutStatus: null
}

// 需引用state的地方舉例:

const getters = {
  checkoutStatus: (state: State) => state.checkoutStatus
}

store/index.ts 檔案總 State 舉例:

// ./src/store/index.ts

import { State as CardState } from './modules/cart'
import { State as ProductsState } from './modules/products'

export interface State {
  cart: CardState,
  products: ProductsState
}

State 引用示例:

// ./src/store/getters.ts

import { State } from './index'

const cartProducts: Getter<State, any> = (state: State) => {
  return state.cart.added.map(shape => {
    // 此處shape自動推匯出Shape型別
    // ... 詳見原始碼
  })
}

如此,所有直接引用 state 的地方,均可啟用型別推導

動手改造之 Mutation

Mutation 對應 store.commit 命令,常見寫法:

const mutations = {
  [types.ADD_TO_CART] (state, { id }) {
    // ...
  }
}

state 上步已處理{ id }payload 引數,即為開篇介紹型別缺失的重災區。

我的一套個人實踐:

  • store/modules 下的子模組檔案,為自己的mutations 維護 payload Interface宣告
  • 子模組共用 payload(多個模組響應同一 commit 等),在 store/index.ts 中統一維護
  • 新建檔案 store/dispatches.ts 檔案,為每一個直接呼叫的帶參commit維護輔助函式,以應用型別推導

子模組 payload 宣告舉例:

// ./src/store/modules/products.ts

import { Product, AddToCartPayload } from '../index'

export interface ProductsPayload {
  products: Product[]
}

const mutations = {
  [types.RECEIVE_PRODUCTS] (state: State, payload: ProductsPayload) {
    state.all = payload.products
  },

  [types.ADD_TO_CART] (state: State, payload: AddToCartPayload) {
    const product = state.all.find(p => p.id === payload.id)
    // ...
  }
}

// mutations呼叫舉例:
const actions = {
  getAllProducts (context: ActionContextBasic) {
    shop.getProducts((products: Product[]) => {
      const payload: ProductsPayload = {
        products
      }
      context.commit(types.RECEIVE_PRODUCTS, payload)
    })
  }
}

store/index.ts檔案公共 payload 宣告舉例:

// ./src/store/index.ts

export interface AddToCartPayload {
  id: number
}

store/dispatches.ts檔案,commit輔助函式,參見下步同檔案dispatch輔助函式

動手改造之 Action

Action 對應 store.dispatch 命令,常見寫法:

const actions = {
  checkout ({ commit, state }, products) {
    // ...
  }
}

其中第二個引數productspayload 引數,用法同上步 Mutationpayload 引數,不再贅述。

第一個引數{ commit, state }context引數,vuexd.ts 提供有型別 ActionContext,用法如下:

import { ActionContext } from 'vuex'
const actions = {
  checkout (context: ActionContext<State, any>, products: CartProduct[]) {
    context.commit(types.CHECKOUT_REQUEST)
    // ...
  }
}

ActionContext<State, RootState> 傳入兩個大部分Action根本用不到的引數,才能得到需要的dispatch, commit,在我看來,難用至極。

個人更喜歡如下寫法:

const actions = {
  checkout (context: { commit: Commit, state: State }, products: CartProduct[]) {
    context.commit(types.CHECKOUT_REQUEST)
    // ...
  }
}

Action payload 改造參見步驟 Mutation,不再贅述。

store/dispatches.ts檔案,dispatch輔助函式:

// ./src/store/dispatches.ts

import store, { CartProduct, Product } from './index'

export const dispatchCheckout = (products: CartProduct[]) => {
  return store.dispatch('checkout', products)
}

.vue檔案呼叫舉例:

// ./src/components/Cart.vue

import { dispatchCheckout } from '../store/dispatches'
export default Vue.extend({
  methods: {
    checkout (products: CartProduct[]) {
    // this.$store.dispatch 寫法可用,但不帶型別推導
    // this.$store.dispatch('checkout', products)
    dispatchCheckout(products) // 帶有型別智慧提示
    }
  }
})

動手改造之 Getter

Getter常見寫法:

const getters = {
  checkoutStatus: state => state.checkoutStatus
}

需要改的不多,state 加上宣告即可:

const getters = {
  checkoutStatus: (state: State) => state.checkoutStatus
}

動手改造之獨立的 Mutations/Actions/Getters 檔案

獨立檔案常規寫法:

// ./src/store/getters.js
export const cartProducts = state => {
  return state.cart.added.map(({ id, quantity }) => {
    const product = state.products.all.find(p => p.id === id)
    return {
      title: product.title,
      price: product.price,
      quantity
    }
  })
}

引用:

// ./src/store/index.js

import * as getters from './getters'
export default new Vuex.Store({
  getters
})

typescript下均需改造:

// ./src/
import { GetterTree, Getter } from 'vuex'
import { State } from './index'

const cartProducts: Getter<State, any> = (state: State) => {
  return state.cart.added.map(shape => {
    // ...
  })
}

const getterTree: GetterTree<State, any> = {
  cartProducts
}

export default getterTree

Actions/Mutations 檔案改造同上,型別換成 ActionTree, Action, MutationTree, Mutation即可

引用:

// ./src/store/index.js

import getters from './getters'
export default new Vuex.Store({
  getters
})

原因是vuex定義,new Vuex.Store引數型別 StoreOptions 如下:

export interface StoreOptions<S> {
  state?: S;
  getters?: GetterTree<S, S>;
  actions?: ActionTree<S, S>;
  mutations?: MutationTree<S>;
  modules?: ModuleTree<S>;
  plugins?: Plugin<S>[];
  strict?: boolean;
}

於是,獨立Gettes/Actions/Mutations檔案,export 必須是GetterTree/ActionTree/MutationTree型別

動手改造之 .vue 檔案呼叫

  • 傳統寫法全部相容,只需 mapState為state新增型別 (state: State) => state.balabal 等很少改動即可正常執行。只是型別均為 any
  • 建議不使用 mapState / mapGetters / mapActions / mapMutations,以明確指定型別
  • dispatchcommit 呼叫可通過上述 store/dispatches.ts 下輔助函式,手動開啟型別推導
  • stategetters 型別推導,暫時只能手動指定。自動推導,估計得等官方內建支援了。

完整呼叫示例:

// ./src/components/ProductList.vue

import Vue from 'vue'
// import { mapGetters, mapActions } from 'vuex'
import { Product } from '../store'
import { dispatchAddToCart } from '../store/dispatches'

export default Vue.extend({
  computed: {
    // ...mapGetters({
    //   products: 'allProducts'
    // })
    products (): Product[] {
      return this.$store.getters.allProducts
    }
  },
  methods: {
    // ...mapActions([
    //   'addToCart'
    // ])
    addToCart (p: Product) {
      dispatchAddToCart(p)
    }
  },
  created () {
    this.$store.dispatch('getAllProducts')
  }
})

vue-class-component + vuex-class 元件式寫法

如果覺得以上廢棄 mapState / mapGetters 後的寫法繁瑣,可引入vue-class-component + vuex-class,開啟元件式寫法

  • vue-class-component,vue官方維護,學習成本低
  • vuex-class,作者 ktsn,vuex及vue-class-component貢獻排第二(第一尤雨溪了)的活躍開發者,質量還是有保障的

引入這倆依賴後,須在 tsconfig.json 新增配置:

{
  "compilerOptions": {
    // 啟用 vue-class-component 及 vuex-class 需要開啟此選項
    "experimentalDecorators": true,

    // 啟用 vuex-class 需要開啟此選項
    "strictFunctionTypes": false
  }
}

Component 寫法示例:

import Vue from 'vue'
import { Product } from '../store'
// import { dispatchAddToCart } from '../store/dispatches'
import Component from 'vue-class-component'
import { Getter, Action } from 'vuex-class'

@Component
export default class Cart extends Vue {
  @Getter('cartProducts') products: CartProduct[]
  @Getter('checkoutStatus') checkoutStatus: CheckoutStatus
  @Action('checkout') actionCheckout: Function

  get total (): number {
    return this.products.reduce((total, p) => {
      return total + p.price * p.quantity
    }, 0)
  }

  checkout (products: CartProduct[]) {
    // dispatchCheckout(products)
    this.actionCheckout(products)
  }
}

總結

在現階段 vuex 官方未改進 typescript 支援下,用 typescript 寫 vuex 程式碼,的確有些繁瑣,而且支援也稱不上全面,不過,總比沒有強。哪怕都用 any,也能借助智慧提示減輕一些程式碼翻來翻去的痛苦。

至於再進一步更完美的支援,等官方更新吧。

完整程式碼

見 Github 庫:vue-vuex-typescript-demo

相關文章