前言
vue的新一版本vue3中采用了typescript对主项目vue vue-router进行了重写,同作为vue全家桶的状态管理插件vuex并没有被重写,只是在原基础上进行了优化
作为一个练手的小项目,最近用typescript对vuex进行了重写,重写项目可以点击查看,除了实现基本常用的功能外,添加了更适用于vue-next中setup写法的hooks api,下面将hooks的设计思想分享给大家
为什么会有hooks api呢
当一个项目的模块module很多的时候,出于避免命名冲突考虑,我们会使用到命名空间namespaced选项
当命名空间复杂起来的时候,我们在读取状态值和修改状态值的方法上也会复杂
所以vuex3.x中提供了诸如mapState mapActions等方法来使写法更简单
但在vuex4.x的使用中,我们发现这几个api仅仅是对老版本进行了兼容,并没有对setup进行支持
官方的demo也是直接在属性上进行了值的读取
所以我们能不能提供一个api让读取像mapState mapActions那样简单一点呢
答案肯定是可以的
Api
等同于 mapState mapGetters mapMutations mapActions
我们提供对应的四个 useState uesGetters useMutations useActions来进行读取
api对应的用法也是类似的
我们拿mapState来举列
这是官方提供的mapState的用法
import { mapState } from 'vuex'
export default {
// ...
computed: mapState({
// 箭头函数可使代码更简练
count: state => state.count,
// 传字符串参数 'count' 等同于 `state => state.count`
countAlias: 'count',
// 为了能够使用 `this` 获取局部状态,必须使用常规函数
countPlusLocalState (state) {
return state.count + this.localCount
}
})
}
我们如果用hooks去写
const app = createApp({
setup() {
// state1.count = store.state.count
const state1 = useState(['count']);
// state2.countAlias = store.state.count
const state2 = useState({
// 换个属性名
countAlias: 'count'
});
// 命名空间 state3.test = store.state.inner.count
const state3 = useState('inner', ['count']);
// 命名空间另外的写法 state4.test4 = store.state.inner.count
const state4 = useState({
test4: 'inner/count'
});
// 函数写法 state5.test5 = store.state.count
const state5 = useState({
test5: (state) => {
return state.count;
}
});
return () => h('div');
}
});
可以看到hooks的写法和map的写法是类似的,而且都比上面贴的图里的demo要友好很多
其他几个hooks也和老版本的map api函数保持一致
我们再看几个其它hooks的最简单的写法
const store = createStore({
modules: {
inner: {
namespaced: true,
state: {
test: 1
},
getters: {
foo(state) {
return state.test + 1;
}
},
mutations: {
change(state, num) {
state.test = num;
}
},
actions: {
async delayChange({ commit }, num) {
await new Promise((resolve) => {
const timer = setTimeout(() => {
clearTimeout(timer);
resolve();
}, 100);
});
commit('change', num);
}
}
}
}
});
const app = createApp({
setup() {
// state.test = store.state.inner.test
const state = useState('inner', ['test']);
// getters.foo = store.getters.inner.foo
const getters = useGetters('inner', ['foo']);
// mutations
const { change } = useMutations({
change: 'inner/change'
});
// actions
const { delayChange } = useActions('inner', ['delayChange']);
return () => h('div');
}
});
app.use(store);
app.mount(document.createElement('div'));
即便你没用过hooks api但也能看得出vuex3.x中map api的影子
typescript设计
同样的我们通过useState这个hooks进行举例,其他的hooks与这个基本是类似的设计思想
基础的模型
function useState(a: any) {
return a;
}
const state = useState({
a: 'a'
});
以上便是一个最基础的hooks模型
显然这个类型返回是不合适的,这里的state返回值是any,我们想要的肯定是在state上能够读取到a这个属性
用泛型解决any
ts中的泛型可以解决这个毛病
function useState<T>(state: T) {
return state;
}
const state = useState({
a: 'a'
});
这么自动推导出来state.a就存在了
不过这里的写法仍然是有问题的
state.a推出来是字符串a的类型string并不是state.a的真实值,所以我们需要在优化一下
更准确的推导
我们在自动推导的情况下这里是没法拿到state.a的真实值的,所以我们需要将state.a返回值设置为any
function useState<T>(
state: T
): {
[x in keyof T]: any;
} {
return state;
}
const state = useState({
a: 'a'
});
我们通过[x in keyof T]拿到对应的属性值,并把对应的属性返回值设置为any,这样一来就解决了上面的问题
手动指定泛型
自动推导能推的类型只能帮助我们拿到最后结果的属性
当我们明确知道state.a的属性值类型时,我们需要手动指定泛型T的值
const state = useState<{
a: number
}>({
a: 'a'
});
我们希望上面读取state.a的结果是number类型
我们这里指定的泛型是函数结果的返回类型,并不是函数参数的类型,所以我们得改两个位置
function useState<T>(
state: {
[x in keyof T]: string;
}
): {
[x in keyof T]: T[x];
} {
const obj = Object.create(null);
obj.a = 1;
return obj;
}
对于函数的参数类型,只有属性是与之对应的,属性值应该永远是字符串类型
对于函数的返回值类型,属性和属性值类型都是对应的
现在让我们再来看看手动指定泛型对应的结果
const state = useState<{
a: number;
}>({
a: 'a'
});
可以看到state.a被正确推导为number类型
同样的我们来看看自动推导的结果
const state = useState({
a: 'a'
});
可以看到结果被推导为了unknown
首先解释下为什么会是unknown
泛型的自动推导得是在这个泛型被函数参数用上时
比如下面这个列子
function foo<T>(a: T) {
return a;
}
const res = foo({
a: 1
});
这里的res就能自动推导为对应的预期结果
我们上面的函数参数可以看到我们只通过[x in keyof T]用上了对应的属性,值是被永久设置为了string,我们并没有在此处用上对应泛型的属性值T[x],所以推导出对应的便是unknown
解决自动推导下返回值为unknown的问题
这里需要用到extends去判断当前返回类型是不是unknown
如果是则表明是自动推导的那么返回值需要返回any
那么用 T[x] extends unknown可以做到吗?
答案是不可以
这里我们期待的是当T[x]为unknown可以让这个式子为true
但实际上T[x]为任何值 这个式子都是true
unknown extends unknown
string extends unknown
any extends unknown
以上全为true
其实也好理解 unknown代表的就是不知道是啥类型 所以各个类型都算它的子集
所以这里得麻烦一点 我们得反其道行之 去取已知类型判断
type KnownType = string | number | boolean | undefined | null | object;
function useState<T>(
state: {
[x in keyof T]: string;
}
): {
[x in keyof T]: T[x] extends KnownType ? T[x] : any;
} {
const obj = Object.create(null);
obj.a = 1;
return obj;
}
const state = useState({
a: 'a'
});
这样当T[x]有返回值的时候先取返回值表示是手动指定的泛型,但是其他值的时候则表示是自动推导的类型
可以看到state.a在自动推导下返回值正确了
兼容数组写法
现在我们设计完了useState的一种对象形式,看上面api设计中我们知道它还有一种数组写法
function useState(state: string[]) {
const obj = Object.create(null);
obj.a = 1;
return obj;
}
const t = useState(['a']);
现在这个t得到的是any,现在我们的目标是让t.a能够被自动推导出来
这个有方法吗
方法肯定是有的 不过很麻烦 说的是自动推导 其实麻烦程度感觉和手动赋值快接近了
同样的我们使用泛型来看看推导出来的数组是啥
function useState<T extends string[]>(state: T) {
return state;
}
const state = useState(['a']);
我们可以看到state中没有获取到准确的属性a只能获取个大范围string
这种泛型约束是没有办法去读取到准确的属性的,即便去读取索引项的值,也只能获取大范围
function useState<T extends string[]>(state: T): T[number] {
return state[0];
}
const state = useState(['a']);
可以看到state返回仍是string
所以我们也需要减小我们定义泛型的范围,将泛型定义在具体的每一个索引中
function uesState<A extends string, B extends string>(
state: [A, B]
): Record<A | B, any> {
const obj = Object.create(null);
return obj;
}
const state = uesState(['a', 'b']);
我们不是用一整个泛型T去定义整个数组,而是将数组拆分,用了两个泛型A B去接受对应的数组索引项
这样推导出来的结果便是正确的
但这里还要问题,上面指定的是两个索引,如果出现多个索引呢,此时就要用到typescript的另外一个特性重载
function useState<A extends string>(state: [A]): Record<A, any>;
function useState<A extends string, B extends string>(
state: [A, B]
): Record<A | B, any>;
function useState(state: any) {
return Object.create(null);
}
这样1个参数或者2个参数都可以被推导出来
如果真的要这么做,重载10次最多10个参数应该能满足绝大部分需求了,这也是前面提到过的为啥这么麻烦
如何使类型推导更简便
那么有方法可以稍显正常一点完成这个操作吗,其实也是有的,只不过我们需要改一下函数的参数传递方式
既然传递一个数组没法获取,那我们就一个一个参数传递
我们来看看这个函数
function useState<T extends string[]>(...args: T): T {
return args;
}
const state = useState('a', 'b');
这里的泛型仍然指的是传入参数的数组值,但传入的方式已经改变了这样推导出来的类型便是实际的值
可以看到推导出来的state是['a','b'],此时对类型加上number,便可以得到a | b
function useState<T extends string[]>(...args: T): T[number] {
let t: any;
return t;
}
const state = useState('a', 'b');
然后我们再用Record包装一下
function useState<T extends string[]>(...args: T): Record<T[number], any> {
let t: any;
return t;
}
const state = useState('a', 'b');
可以看到返回的state已经有了a和b这两个属性值
这里还有一个namespace选项没有加上,因为参数是一个一个传的,所以我们在包一层函数用来独立接受namespace
以下就是完整的useState写法
function useState<T extends string[]>(...args: T) {
return function (namespace: string): Record<T[number], any> {
let t: any;
return t;
};
}
const state = useState('a', 'b', 'c')('inner');
可以看到state可以正确的被自动推导
这种情况下虽然是被自动推导了,但和原本的api有冲突,且大部分时候也不应该去用自动推导,所以我们还是保持了原本的api模式,仍然是传入数组,而不是一个一个参数的传入
结合两种写法
这里又得用上typescript的重载,不过其实重载的次数不是两次,而是四次,因为还有一个命名空间的参数,这个属性可能存在也可能不存在
type KnownType = string | number | boolean | undefined | null | object;
type Dictionary<T = any> = Record<string, T>;
function useState<States = Dictionary>(states: string[]): States;
function useState<States = Dictionary>(
namespaced: string,
states: string[]
): States;
function useState<States>(
states: {
[x in keyof States]?: string;
}
): {
[x in keyof States]: States[x] extends KnownType ? States[x] : any;
};
function useState<States>(
namespaced: string,
states: {
[x in keyof States]?: string;
}
): {
[x in keyof States]: States[x] extends KnownType ? States[x] : any;
};
function useState(namespaced?: unknown, states?: unknown) {
let t: any;
return t;
}
const state1 = useState({
a: 'a'
});
const state2 = useState<{
a: number;
}>({
a: 'a'
});
const state3 = useState(['a']);
const state4 = useState<{
a: number;
}>(['a']);
可以看到各个state的值都被正确推导了
这里的ts设计只列举了useState这个api,其它api的思路几乎是类似的
结语
以上就是hooks的设计思路的探索,完整的重写项目可以点这里查看,如果有更好的推导方式或api设计也可以交流交流