Vue3+Vite+TS+Eslint(Airbnb规则)搭建生产项目,踩坑详记(四):引入vuex、vuex源码类型声明推导

3,775 阅读5分钟

本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

Vue3搭建生产项目踩坑系列已经进入第四篇,目前我们的项目已经完成了ESLint、husky、lint-staged和大杯Element的引入。没有看到的同学们可以点击传送门回看。

今天我们原计划一次性引入vuex和vue-router,但是写完vuex的引入以后发现篇幅已经很长了,所以只能单开一篇。

不过也好,就又可以水一篇文章了,哈哈哈哈

image.png

vuex是干啥的大家肯定都烂熟于心了。所以就不做过多解释,直接开搞

安装

这里我们需要安装的是 vuex@next,它是和Vue3相匹配的版本

npm install vuex@next --save

初始化

单文件形式

我们创建 src/store/index.ts 文件,在文件内初始化vuex。

// src/store/index.ts
import { createStore } from 'vuex';

const store = createStore({
  // vuex相关内容
  state() {
    return {
      count: 0,
    };
  },
  mutations: {
    increment(state) {
      state.count += 1;
    },
  },
});

export default store;

然后在 main.ts 中引入并绑定该文件到Vue实例

//main.ts
...
import store from './store/index'; // ++

createApp(App).use(ElementPlus, { locale }).use(store).mount('#app'); // edit

引入store文件后,ESLint会报错

image.png

这是我们第一次引入ts文件,因为airbnb-base只提供了原生js的校验,而且目前我没有找到airbnb官方的规则库。所以还是像上次一样,我们对airbnb-base的校验规则进行修改。

// .eslintrc.js

settings: {
    'import/resolver': {
      node: {
        extensions: ['.js', '.jsx', '.ts', '.tsx'],
      },
    },
}
rules: [
    'import/extensions': [
      'error',
      'ignorePackages',
      {
        js: 'never',
        mjs: 'never',
        jsx: 'never',
        ts: 'never',
      },
    ],
]

修改后重启ESLint服务,报错就已经不存在了。

完成初始化以后,我们会发现mutations里的方法报了ESLint错误,不要直接修改参数值。但是在vuex里,我们势必要直接修改state。

image.png

和上次处理vite的引入一样,查看airbnb-base关于这条规则的定义,然后将state加入例外就可以了。 airbnb-base规则定义

我们在ESLint中添加如下规则:

...
ruls: {
  'no-param-reassign': [
    'error',
    {
      props: true,
      ignorePropertyModificationsFor: [
        'acc', // for reduce accumulators
        'accumulator', // for reduce accumulators
        'e', // for e.returnvalue
        'ctx', // for Koa routing
        'context', // for Koa routing
        'req', // for Express requests
        'request', // for Express requests
        'res', // for Express responses
        'response', // for Express responses
        '$scope', // for Angular 1 scopes
        'staticContext', // for ReactRouter context
        'state', // for vuex state ++
      ],
    },
  ],
}

添加完规则后,再次查看发现依然有报错,但是报错信息已经变了。

image.png

这是因为我们没有声明state的类型,解决这个问题,我们首先创建关于state类型声明的接口。

// src/store/index.ts

...
interface State {
  count: number;
}
...

定义了类型接口后,我们要在什么地方使用呢?

通过查看vuex关于类型的定义可以知道,createStore方法可以接收一个参数S表示类型和一个StoreOptions<S>类型的参数options。

image.png

再看StoreOptions类型接口,它把S当做了接口的参数,并且定义为state属性的类型, 同时还把S传给了其它属性当做参数,这样就可以在mutations的方法中用state当参数时拿到state的类型定义。

image.png

因此,我们将自定义的State接口当做泛型参数传给createStore即可,如下:

import { createStore } from 'vuex';

interface State {
  count: number;
}

const store = createStore<State>({
  // vuex相关内容
  ...
});

export default store;

修改完成后,可以看到increment方法接收的state参数已经可以拿到类型了。

image.png

到这里我们的vuex已经可以正常使用了,但是在项目中,我们很可能有很多数据需要在vuex中维护,如果把所有的数据全部放到一个文件中,会让代码可读性变差并且不易维护。因此当数据较多时,推荐将数据按功能分割成Module使用。

接下来我们就将上面的例子拆成模块。

模块组合

文件迁移

首先我们新建 src/store/module.ts 文件,并将原来定义到index.ts中的state和mutations转移到新创建的module.ts文件中。

// src/store/module.ts

// 类型声明
export interface ModuleState {
  count: number;
}
// module信息
export default {
  state() {
    return {
      count: 0,
    };
  },
  mutations: {
    increment(state: ModuleState) {
      state.count += 1;
    },
  },
};
// src/store/index.ts

import { createStore } from 'vuex';
import module from './module';

const store = createStore({
  modules: {
    module,
  }
});

export default store;

模块类型声明

创建module.ts模块后,mutations中的方法依然会报state类型。

我们理所当然的想到的就是上面例子中的声明方式,创建模块级别的类型接口,然后声明mutation方法的state参数。这种声明方式当然是可以的。但是如果模块内有10个mutation,甚至还有10个getters,那意味着我们每一次使用state都要去声明类型。而且这种声明方式并不符合vuex源码中关于Module和State的类型声明逻辑。

源码中的类型声明

在单文件形式一节中我们知道,createStore接收了一个StoreOptions<S>类型的参数,截图我们可以看到这个接口中将 modules 的类型声明为 ModuleTree<S>,并且将S当做参数传给了接口ModuleTree,也就是createStore传入的state类型参数。

看一下ModuleTree的声明:

image.png

ModuleTree接收的S作为R使用,并将R传给Module接口。

image.png

Module接口接收S和R,并将state声明为S类型,将S、R传给内部的getters等。

image.png

通过Getter等的类型定义,我们知道了R其实就是rootState的类型,因为它是从createStore中接收的类型参数。

改进我们的类型声明

知道了源码中关于类型的定义,我们就可以根据它的思路优化我们store中的类型声明方式了。

首先,在模块中我们可以将抛出的Object声明为vuex中的Module类型,并给它传入S和R参数。S参数就是我们声明的模块state接口;R是rootState类型,目前还没有传入,先用R代替。

// src/store/module.ts

import { Module } from 'vuex';

export interface ModuleState {
  count: number;
}
export default {
  state() {
    return {
      count: 0,
    };
  },
  mutations: {
    increment(state) {
      state.count += 1;
    },
  },
} as Module<ModuleState, R>;

修改以后,mutations中的state已经有类型信息了,但是R此时还没有类型传入。不急,接下来我们修改index.ts,并解决R的类型问题。

在index.ts中,需要创建一个State接口来声明rootState的类型,代码如下

import { createStore } from 'vuex';
import module from './module';

export interface State{
  rootCount: number; // 如果没有根state,可以为空,此处为了稍后验证,添加类型信息
}

const store = createStore<State>({
  state() {
    return {
      rootCount: 111,
    }
  },
  modules: {
    module,
  }
});

export default store;

这时根类型声明有了,我们就可以在模块中引入State接口,并传给Module接口需要的R参数,同时添加一个getters进行验证。

// src/store/module.ts

import { State } from './index'

export default {
 ...
 getters: {
    getCount(state, getters, rootState) {
      return `${state.count}---=${rootState.rootCount}`
    }
 }
} as Module<ModuleState, State>;

这时rootState已经可以拿到类型信息了。

image.png

至此,我们也就按照官方类型声明的逻辑完成了我们自己的类型优化。

使用

关于使用方法,官方文档中有详细的使用介绍,篇幅起见,我们不做详细介绍,参考文档即可。 (next.vuex.vuejs.org/guide/compo…)

Vue3更推荐使用Composition API,因为我们的项目源码中仅保留此种使用方式。

当前的配置使用起来没有什么问题,但是我们发现在组件中拿到的state变量没有返回类型信息,这在某些情况下就可能会出现问题,比如对我们定义的count属性使用.length方法。

在官方文档中对此也有详细的说明,我们自行查看即可。

useStore 组合式函数类型声明

返回类型化的store

我们先按照官方文档的格式修改vuex相关配置

// src/store/index.ts

import { InjectionKey } from 'vue';
import { createStore, Store, useStore as baseUseStore } from 'vuex';

...
export interface State{
  rootCount: number;
}
export const key: InjectionKey<Store<State>> = Symbol() // ++

// 定义自己的 `useStore` 组合式函数 ++
export function useStore () {
  return baseUseStore(key)
}
// main.ts

...
import store, { key } from './store/index'; // edit

createApp(App).use(ElementPlus, { locale }).use(store, key).mount('#app'); // edit

使用时我们引入自定义的 useStore 方法,通过它获取到的state就已经是有状态的值了。

image.png

模块类型处理

但是只完成上述改造,我们发现访问rootState的属性没有问题,如果访问module内部的state,会提示我们module在State类型上不存在:

image.png

这是因为我们声明 InjectionKey 时,只定义了State类型,它只包含rootState的类型定义。但是State是要传给createStore的,所以我们不能直接修改它,需要创建一个同时包含module和rootState信息的接口,然后将新接口传给InjectionKey的定义。

// src/store/index.ts

export interface InjectionState extends State{
  module: ModuleState,
  module2: Module2State,
}
export const key: InjectionKey<Store<InjectionState>> = Symbol()

如此一来,在组件中就可以正常访问module信息了。

遗留问题

按照文章进行到现在的代码结构,我们可以正常使用vuex,但是如果在module中使用rootState大家会发现,引入rootState.rootCount没有问题;但是如果使用rootState.module2.count,会出现 [返回类型化的store]小节中同样的问题,module2在State类型中不存在。

这是因为我们的State类型其实只定义了根state包含的属性类型。但是我们的State是要传给createStore当做rootState类型声明,并且它还当做了根state的类型(它们之间的关系可以看下图),因此我们注定没有办法把它像声明InjectionKey一样加上module信息。

image.png

目前这个问题我还没有找到完美的解决方案。不过我们可以在不定义根state变量的情况下,使用InjectionKey接口传给createStore当做类型参数,并且在定义module时也引入这个接口当做Module<S, R>中的参数R,这样可以当做模块内互相访问state的一种妥协方案,具体代码需要的同学可以自己查看git项目。

代码已更新在Git仓库:github.com/YuanDaoDao0…

这个遗留问题希望有较好解决办法的大佬不吝赐教