Skip to content

Commit

Permalink
feat: add equalityFn support (#45)
Browse files Browse the repository at this point in the history
* feat: add equalityFn support

* refactor: reimplement shallowEqual

* refactor: interface Queue and EqualityFn

* chore: optimize and add docs
  • Loading branch information
namepain authored and alvinhui committed Jan 8, 2020
1 parent 290c870 commit b499066
Show file tree
Hide file tree
Showing 9 changed files with 245 additions and 14 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,14 @@ Register multiple store configs to the global icestore instance.
- useStore {function} Hook to use a single store.
- Parameters
- namespace {string} store namespace
- equalityFn {function} optional, equality check between previous and current state
- Return value
- {object} single store instance

- useStores {function} Hook to use multiple stores.
- Parameters
- namespaces {array} array of store namespaces
- equalityFnArr {array} array of equalityFn for namespaces
- Return value
- {object} object of stores' instances divided by namespace
- withStore {function}
Expand Down Expand Up @@ -514,6 +516,8 @@ By design, `icestore` will trigger the rerender of all the view components subsc

This means that putting more state in one store may cause more view components to rerender, affecting the overall performance of the application. As such, it is advised to categorize your state and put them in individual stores to improve performance.

Of course, you can also use the second parameter of the `usestore` function, `equalityfn`, to perform equality comparison of states. Then, the component will trigger rerender only when the comparison result is not true.

### Don't overuse `icestore`

From the engineering perspective, the global store should only be used to store states that are shared across multiple pages or components.
Expand Down
4 changes: 4 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,13 @@ $ npm install @ice/store --save
- useStore {function} 使用单个 Store 的 hook
- 参数
- namespace {string} Store 的命名空间
- equalityFn {function} 选填,前一次和当前最新的 State 相等性对比函数
- 返回值
- {object} Store 的配置对象
- useStores {function} 同时使用多个 Store 的 hook
- 参数
- namespaces {array} 多个 Store 的命名空间数组
- equalityFnArr {array} 多个命名空间 State 的相等性对比函数
- 返回值
- {object} 多个 Store 的配置对象,以 namespace 区分
- withStore {function}
Expand Down Expand Up @@ -518,6 +520,8 @@ describe('todos', () => {

`icestore` 的内部设计来看,当某个 Store 的 State 发生变化时,所有使用 useStore 监听 Store 变化的 View 组件都会触发重新渲染,这意味着一个 Store 中存放的 State 越多越可能触发更多的 Store 组件重新渲染。因此从性能方面考虑,建议按照功能划分将 Store 拆分成一个个独立的个体。

当然,也可以使用 `useStore` 函数的第二个参数 `equalityFn` 进行 State 的相等性对比,那么仅当对比结果不为真时,组件才会重新触发渲染。

### 不要滥用 `icestore`

从工程的角度来看, Store 中应该只用来存放跨页面与组件的状态。将页面或者组件中的内部状态放到 Store 中将会破坏组件自身的封装性,进而影响组件的复用性。对于组件内部状态完全可以使用 useState 来实现,因此如果上面的 Todo App 如果是作为工程中的页面或者组件存在的话,使用 useState 而不是全局 Store 来实现才是更合理的选择。
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ice/store",
"version": "0.4.2",
"version": "0.4.3",
"description": "Lightweight React state management library based on react hooks",
"main": "lib/index.js",
"types": "lib/index.d.ts",
Expand Down
15 changes: 9 additions & 6 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react';
import Store from './store';
import { Store as Wrapper, State, Middleware, Optionalize } from './types';
import { Store as Wrapper, State, Middleware, Optionalize, EqualityFn } from './types';
import warning from './util/warning';
import shallowEqual from './util/shallowEqual';

export default class Icestore {
/** Stores registered */
Expand Down Expand Up @@ -35,16 +36,16 @@ export default class Icestore {
stores[namespace] = new Store(namespace, models[namespace], middlewares);
});

const useStore = <K extends keyof M>(namespace: K): Wrapper<M[K]> => {
return getModel(namespace).useStore<Wrapper<M[K]>>();
const useStore = <K extends keyof M>(namespace: K, equalityFn?: EqualityFn<Wrapper<M[K]>>): Wrapper<M[K]> => {
return getModel(namespace).useStore<Wrapper<M[K]>>(equalityFn);
};
type Models = {
[K in keyof M]: Wrapper<M[K]>
};
const useStores = <K extends keyof M>(namespaces: K[]): Models => {
const useStores = <K extends keyof M>(namespaces: K[], equalityFnArr?: EqualityFn<Wrapper<M[K]>>[]): Models => {
const result: Partial<Models> = {};
namespaces.forEach(namespace => {
result[namespace] = getModel(namespace).useStore<Wrapper<M[K]>>();
namespaces.forEach((namespace, i) => {
result[namespace] = getModel(namespace).useStore<Wrapper<M[K]>>(equalityFnArr && equalityFnArr[i]);
});
return result as Models;
};
Expand Down Expand Up @@ -172,3 +173,5 @@ export default class Icestore {
};
}

export { shallowEqual };

28 changes: 22 additions & 6 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import isFunction from 'lodash.isfunction';
import isPromise from 'is-promise';
import { useState, useEffect } from 'react';
import compose from './util/compose';
import { ComposeFunc, Middleware } from './types';
import { ComposeFunc, Middleware, EqualityFn, Queue } from './types';

export default class Store {
/** Store state and actions user defined */
private model: any = {};

/** Queue of setState method from useState hook */
private queue = [];
private queue: Queue<any>[] = [];

/** Namespace of store */
private namespace: string;
Expand Down Expand Up @@ -111,20 +111,36 @@ export default class Store {
*/
private setState(): void {
const state = this.getState();
this.queue.forEach(setState => setState(state));

this.queue.forEach(queueItem => {
const { preState, setState, equalityFn } = queueItem;
// update preState
queueItem.preState = state;
// use equalityFn check equality when function passed in
if (equalityFn && equalityFn(preState, state)) {
return;
}
setState(state);
});
}

/**
* Hook used to register setState and expose model
* @return {object} model of store
*/
public useStore<M>(): M {
public useStore<M>(equalityFn?: EqualityFn<M>): M {
const state = this.getState();
const [, setState] = useState(state);

useEffect(() => {
this.queue.push(setState);
const queueItem = {
preState: state,
setState,
equalityFn,
};
this.queue.push(queueItem);
return () => {
const index = this.queue.indexOf(setState);
const index = this.queue.indexOf(queueItem);
this.queue.splice(index, 1);
};
}, []);
Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Dispatch, SetStateAction } from 'react';

export interface ActionProps {
loading?: boolean;
error?: Error;
Expand All @@ -14,6 +16,14 @@ type NonFunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? nev

export type State<T> = Pick<T, NonFunctionPropertyNames<T>>;

export type EqualityFn<M> = (preState: State<M>, newState: State<M>) => boolean

export interface Queue<S> {
preState: S;
setState: Dispatch<SetStateAction<S>>;
equalityFn?: EqualityFn<S>;
}

export interface Ctx {
action: {
name: string;
Expand Down
37 changes: 37 additions & 0 deletions src/util/shallowEqual.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
function is(x, y) {
if (x === y) {
return x !== 0 || y !== 0 || 1 / x === 1 / y;
} else {
// eslint-disable-next-line no-self-compare
return x !== x && y !== y;
}
}

export default function shallowEqual(objA, objB) {
if (is(objA, objB)) return true;

if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
}

const keysA = Object.keys(objA);
const keysB = Object.keys(objB);

if (keysA.length !== keysB.length) return false;

for (let i = 0; i < keysA.length; i++) {
if (
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
!is(objA[keysA[i]], objB[keysA[i]])
) {
return false;
}
}

return true;
}
81 changes: 80 additions & 1 deletion tests/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import { render, fireEvent, getByTestId, wait } from '@testing-library/react';
import Icestore from '../src/index';
import Icestore, { shallowEqual } from '../src/index';
import Store from '../src/store';

describe('#Icestore', () => {
Expand Down Expand Up @@ -255,6 +255,85 @@ describe('#Icestore', () => {
expect(renderFn).toHaveBeenCalledTimes(2);
expect(nameValue.textContent).toEqual(newState.name);
});

});

test('should equalityFn be ok.', async () => {
const initState = {
name: 'ice',
};
const { useStore } = icestore.registerStores({
'todo': {
dataSource: initState,
setData(dataSource) {
this.dataSource = dataSource;
},
},
});

let renderCount = 0;
const renderFn = () => renderCount++;

const Todos = ({ equalityFn }) => {
const todo: any = useStore('todo', equalityFn);
const { dataSource } = todo;

renderFn();

const changeNothing = () => todo.setData(initState);
const changeStateRef = () => todo.setData({ ...initState });

return <div>
<span data-testid="nameValue">{dataSource.name}</span>
<button type="button" data-testid="changeNothingBtn" onClick={changeNothing}>
Click me
</button>
<button type="button" data-testid="changeStateRefBtn" onClick={changeStateRef}>
Click me
</button>
</div>;
};

const { container, unmount } = render(<Todos equalityFn={shallowEqual} />);
const nameValue = getByTestId(container, 'nameValue');
const changeNothingBtn = getByTestId(container, 'changeNothingBtn');
const changeStateRefBtn = getByTestId(container, 'changeStateRefBtn');

expect(nameValue.textContent).toEqual(initState.name);
expect(renderCount).toBe(1);

fireEvent.click(changeNothingBtn);

// will not rerender
await wait(() => {
expect(nameValue.textContent).toEqual(initState.name);
expect(renderCount).toBe(1);
});

fireEvent.click(changeStateRefBtn);

// will rerender
await wait(() => {
expect(nameValue.textContent).toEqual(initState.name);
expect(renderCount).toBe(2);
});

unmount();

const { container: container1 } = render(<Todos equalityFn={(a, b) => a.dataSource.name === b.dataSource.name} />);
const nameValue1 = getByTestId(container1, 'nameValue');
const changeStateRefBtn1 = getByTestId(container1, 'changeStateRefBtn');

expect(nameValue1.textContent).toEqual(initState.name);
expect(renderCount).toBe(3);

fireEvent.click(changeStateRefBtn1);

// will not rerender
await wait(() => {
expect(nameValue1.textContent).toEqual(initState.name);
expect(renderCount).toBe(3);
});
});

test('should useStores be ok.', () => {
Expand Down
78 changes: 78 additions & 0 deletions tests/util.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import compose from '../src/util/compose';
import shallowEqual from '../src/util/shallowEqual';

describe('#util', () => {
let handler;
Expand Down Expand Up @@ -57,4 +58,81 @@ describe('#util', () => {
expect(arr).toEqual([1, 2, 3, 4, 5, 6]);
});
});

describe('#shallowEqual', () => {
test('should return true if arguments fields are equal', () => {
expect(
shallowEqual({ a: 1, b: 2, c: undefined }, { a: 1, b: 2, c: undefined }),
).toBe(true);

expect(
shallowEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2, c: 3 }),
).toBe(true);

const o = {};
expect(
shallowEqual({ a: 1, b: 2, c: o }, { a: 1, b: 2, c: o }),
).toBe(true);

const d = function() {
return 1;
};
expect(
shallowEqual({ a: 1, b: 2, c: o, d }, { a: 1, b: 2, c: o, d }),
).toBe(true);
});

test('should return false if arguments fields are different function identities', () => {
expect(
shallowEqual(
{
a: 1,
b: 2,
d() {
return 1;
},
},
{
a: 1,
b: 2,
d() {
return 1;
},
},
),
).toBe(false);
});

test('should return false if first argument has too many keys', () => {
expect(shallowEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 })).toBe(false);
});

test('should return false if second argument has too many keys', () => {
expect(shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2, c: 3 })).toBe(false);
});

test('should return false if arguments have different keys', () => {
expect(
shallowEqual(
{ a: 1, b: 2, c: undefined },
{ a: 1, bb: 2, c: undefined },
),
).toBe(false);
});

test('should compare two NaN values', () => {
expect(shallowEqual(NaN, NaN)).toBe(true);
});

test('should compare empty objects, with false', () => {
expect(shallowEqual({}, false)).toBe(false);
expect(shallowEqual(false, {})).toBe(false);
expect(shallowEqual([], false)).toBe(false);
expect(shallowEqual(false, [])).toBe(false);
});

test('should compare two zero values', () => {
expect(shallowEqual(0, 0)).toBe(true);
});
});
});

0 comments on commit b499066

Please sign in to comment.