Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

记一次小功能实现 #68

Open
jyzwf opened this issue Mar 3, 2019 · 0 comments
Open

记一次小功能实现 #68

jyzwf opened this issue Mar 3, 2019 · 0 comments

Comments

@jyzwf
Copy link
Owner

jyzwf commented Mar 3, 2019

本文写的主要是自己在做一个小功能的时候所学到的知识。
这个功能是为公司的一个React框架中的数据存储添加的,该套数据存储方案类似于dva,而我要做的是为其添加一个形如Vuex中的getter功能,根据store来衍生出一些计算出来的状态,暂且记为select,同时该select要提供一个memo函数,用法诸如react hooks中的useMemo,用法如下:

selectors({state,rootState,rootSelect}:SelectorContext<IState>) {
    // 最简单的 selector 定义,直接是一个函数
    const count = () => state.count; 
    // selector 函数应该可以接收参数
    const countMultiple = (times: number) => state.count * times;
    // 形式参考 React Hooks 的 useMemo,第一个参数是计算函数,第二个参数是包含了依赖值的数组
    // 如果数组中的值不发生变化(浅比较),则计算函数不应该再次执行而是复用上一次计算的值
    const countMemo = memo(() => state.count, ()=>[state.count]);
    // 对于需要接收参数的 selector,应该将参数列表也加入依赖值的数组中,即参数发生变化了则计算函数需要重复执行
    const countMemoMultiple = memo((times: number) => state.count * times, ()=>[state.count]);
    
    return {
      count,
      countMultiple,
      countMemo,
      countMemoMultiple,
    };
  },

// 调用过程
const { select } = store;

consle.log(select.global.count());
consle.log(select.global.countMultiple(5));
consle.log(select.global.countMemo());
consle.log(select.global.countMemoMultiple(5));

拿到那需求就先去进行调研,由于该框架是由TS写的,自己虽然之前看过一点TS,但并没有实际项目中使用,-_-|||,所以就去过了一遍TS的文档,但在写代码的还是遇到不少坑,后面会讲到。其次是要实现memo函数,所以就去看了下reselectmemoize-one,代码解析见reselect与memoize-one主要源码解析。一开始感觉没啥难度,主要就感觉TS不熟,但还是太年轻。

TS类型判断

由于要实现在调用select时要给类型提示,形如:
image
由于第一次在项目中写TS,所以想着什么都给其定义好类型,如上面的selectors,在使用者书写时也能给到提示,虽然理想很美好,但现实很骨感😑。
先来看select调用时的类型提示:
由于select是一个对象,同时select又是根据模块来划分的,并最终汇集到store中,所以很容易来想到,遍历store中的各个模块来进行提示,我也这么做了:

export type MujiStoreSelect = MujiStoreSelector & {
    // 最终版
    [storeKey in keyof MujiStores]: ReturnType<NonNullable<MujiStores[storeKey]['selectors']>>
  }

然后第一个坑点出现了,关于其返回值即上述:右边该如何写?我想着,stores[storeKey]['selectors']不是一个函数吗,(也可能是undefined),所以我再定义一个泛型来返回其函数的返回值不就OK了?
接着就出现了如下代码:
image
它提示我 Selector 不满足(...args: any[]) => any,wtf,,后来想了想也对,Selector也可能是undefined啊,顺带我也看了下ReturnType的声明定义:

// infer 用于条件判断中,infer R 就是声明一个变量来承载传入函数签名的返回值类型, 简单说就是用它取到函数返回值的类型方便之后使用.
type ReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : any

既然可能是undefined的问题,那我就直接约束Selector为一个函数,即为每个模块的selector定义声明ModuleSelector,然后让Selector 约束为ModuleSelector:
image

他还是报错,还是不对,同时上面出现了循环依赖,由于第一次写这种声明,也就是想到什么就写什么,,结果就是一堆bug。这时就想到去看下之前代码中的Selector是如何定义的,可以看出他是any:
image
所以我将其默认值改成一个函数返回一个对象:

Selectors = (...args: any[]) => PlainObject<any>

但这样报错更多了:
image
所以还是undefined的问题😞,很烦,然后没有一点思路。来到第二天早上,继续想这个问题,然后试着试着,竟然OK了,而且代码并没有我之前想的复杂:

type MujiStoreSelect = MujiStoreSelector & {
    [storeKey in keyof MujiStores]: ReturnType<NonNullable<MujiStores[storeKey]['selectors']>>
  }

interface StoreConfig<
    State = any,
    Reducers = any,
    Effects = any,
    Selectors = any,
    > {
    state: State,
    reducers?: Reducers,
    effects?: Effects,
    selectors?: SelectorFunction<State, Selectors>,
  }

export type SelectorFunction<State, S> =
    S extends any ? S :
    (context: SelectorContext<State>) => PlainObject<any>

这里的重点是如果S约束为any,selectors就是类型S,虽然不太清除为何这样就能排除上述的那种bug,(如有知情者,欢迎告知)。玄学,真是玄学,同时也感叹ts提示真是厉害

selectors函数

解决完类型提示后,memo函数我就直接参照上面说的两个库,利用闭包来写,以为没问题了,就开始写单测,这一测就发现这个selectors根本没法用,当一个reducer改变state之后,它根本检测不到state的变化,也就是说,这个state还是一开始调用selectors时的state。

注:一开始selectors 的api是如下设计:selectors(state,rootState,rootSelect){return {}}
关于这几个参数,就拿State来说,我是做如下传入:

const arg: any = {};
        Object.defineProperty(arg, 'state', {
          get: () => this.getState()[moduleName],
          set: () => invariant(false, 'You should not set state arg'),
        });
selectors.call(this, arg.state);

这里做了一层代理,获取最新的state,可是我把问题想简单了,这个代理只能是一层,但此时state已经被赋值给一个变量,也就是说该state代理在state获取属性的时候根本不监测不到,Proxy同理。所以就换了一种传参模式直接将arg传入:

selectors(context: SelectorContext<IState>) {

    const count1 = () => context.state.count;
    const count2 = (id: number) => context.state.count + id;

    return {
      count1,
      count2,
    };
  },

这样虽然能解决问题,但增加了书写的繁琐,以及对于习惯了对象结构的人来说,不习惯,所以还得继续想办法,之前提到了,defineProperty/Proxy都只能代理一层,那我是不是也可以把State也代理了,即两层代理:

const args = new Proxy({}, {
          get: (target, propKey) => {
            switch (propKey) {
              case 'state':
                return new Proxy({}, {
                  get: (target, propKey) => {
                    return Reflect.get(this.getState()[moduleName], propKey);
                  },
                  set: (): any => {
                    invariant(false, 'You should not set state arg');
                  },
                });

              case 'rootState':
                return new Proxy({}, {
                  get: (target, propKey) => {
                    return Reflect.get(this.getState(), propKey);
                  },
                  set: (): any => {
                    invariant(false, 'You should not set rootState arg');
                  },
                });

              case 'rootSelect':
                return this.select;
              default:
                invariant(false, 'You can get state/rootState/rootSelect prop only');
            }
          },
          set: (target, propKey): any => {
            invariant(false, 'You should not set %s arg', propKey);
          },
        });

这样在获取state属性时能获取到最新的值了,但这样也会有一个问题,就是单独用这个State时,是一个代理对象,而不是最新的State对象,应该很少会有直接使用State的情况吧(挖坑中😶😶...)

关于上述有更好的解法欢迎沟通😉

单测

自己之前很少写单测,这次单测使用的是jest,所以就网上看了一下教程就依葫芦画瓢开始写了,下面是关于memo的单测:

it('memo works', async () => {
    const fn1 = jest.fn();
    const fn2 = jest.fn();
    const fn3 = jest.fn();
    const fn4 = jest.fn();
    let id = 0;
    const memoize1 = memo(() => fn1());
    const memoize2 = memo(id => fn2(id));
    const memoize3 = memo(id => fn3(id), () => [id]);
    const memoize4 = memo(id => fn4(id), () => [Math.random()]);

    memoize1();
    expect(fn1.mock.calls.length).toBe(1);
    memoize1();
    expect(fn1.mock.calls.length).toBe(1);

    memoize2(1);
    expect(fn2.mock.calls.length).toBe(1);
    memoize2(1);
    expect(fn2.mock.calls.length).toBe(1);
    memoize2(2);
    expect(fn2.mock.calls.length).toBe(2);

    memoize3(1);
    expect(fn3.mock.calls.length).toBe(1);
    memoize3(1);
    expect(fn3.mock.calls.length).toBe(1);
    memoize3(2);
    expect(fn3.mock.calls.length).toBe(2);
    id = 1;
    memoize3(2);
    expect(fn3.mock.calls.length).toBe(3);

    memoize4(1);
    expect(fn4.mock.calls.length).toBe(1);
    memoize4(1);
    expect(fn4.mock.calls.length).toBe(2);
  });

可见堆了一堆代码,同时也声明了4个 jest.fn

这里有个疑问j:est有没有提供重置 jest.fn函数的方法,就是我直接使用一个 jest.fn ,但想在某个时刻重置它来清除调用次数,这样就不用像上面一样写4个 jest.fn了。 欢迎告知😘

当然好的测试应该一个测试测一个功能点,应该将上面拆为4个测试点。

总结

至此,该小功能算是完成了,但其中还是有些疑惑,自己还是要继续熟悉TS,感受到了TS的强大,同时要多写单测,能发现潜在的问题。

参考

ts 官方文档
TS 一些工具泛型的使用及其实现
jest 官方文档
测试框架 Jest 实例教程

@jyzwf jyzwf changed the title 记一次项目经历 记一次小功能实现 Mar 3, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant