Skip to content

Commit b499066

Browse files
namepainalvinhui
authored andcommitted
feat: add equalityFn support (#45)
* feat: add equalityFn support * refactor: reimplement shallowEqual * refactor: interface Queue and EqualityFn * chore: optimize and add docs
1 parent 290c870 commit b499066

File tree

9 files changed

+245
-14
lines changed

9 files changed

+245
-14
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,12 +166,14 @@ Register multiple store configs to the global icestore instance.
166166
- useStore {function} Hook to use a single store.
167167
- Parameters
168168
- namespace {string} store namespace
169+
- equalityFn {function} optional, equality check between previous and current state
169170
- Return value
170171
- {object} single store instance
171172

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

515517
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.
516518

519+
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.
520+
517521
### Don't overuse `icestore`
518522

519523
From the engineering perspective, the global store should only be used to store states that are shared across multiple pages or components.

README.zh-CN.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,13 @@ $ npm install @ice/store --save
156156
- useStore {function} 使用单个 Store 的 hook
157157
- 参数
158158
- namespace {string} Store 的命名空间
159+
- equalityFn {function} 选填,前一次和当前最新的 State 相等性对比函数
159160
- 返回值
160161
- {object} Store 的配置对象
161162
- useStores {function} 同时使用多个 Store 的 hook
162163
- 参数
163164
- namespaces {array} 多个 Store 的命名空间数组
165+
- equalityFnArr {array} 多个命名空间 State 的相等性对比函数
164166
- 返回值
165167
- {object} 多个 Store 的配置对象,以 namespace 区分
166168
- withStore {function}
@@ -518,6 +520,8 @@ describe('todos', () => {
518520

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

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

523527
从工程的角度来看, Store 中应该只用来存放跨页面与组件的状态。将页面或者组件中的内部状态放到 Store 中将会破坏组件自身的封装性,进而影响组件的复用性。对于组件内部状态完全可以使用 useState 来实现,因此如果上面的 Todo App 如果是作为工程中的页面或者组件存在的话,使用 useState 而不是全局 Store 来实现才是更合理的选择。

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ice/store",
3-
"version": "0.4.2",
3+
"version": "0.4.3",
44
"description": "Lightweight React state management library based on react hooks",
55
"main": "lib/index.js",
66
"types": "lib/index.d.ts",

src/index.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import React from 'react';
22
import Store from './store';
3-
import { Store as Wrapper, State, Middleware, Optionalize } from './types';
3+
import { Store as Wrapper, State, Middleware, Optionalize, EqualityFn } from './types';
44
import warning from './util/warning';
5+
import shallowEqual from './util/shallowEqual';
56

67
export default class Icestore {
78
/** Stores registered */
@@ -35,16 +36,16 @@ export default class Icestore {
3536
stores[namespace] = new Store(namespace, models[namespace], middlewares);
3637
});
3738

38-
const useStore = <K extends keyof M>(namespace: K): Wrapper<M[K]> => {
39-
return getModel(namespace).useStore<Wrapper<M[K]>>();
39+
const useStore = <K extends keyof M>(namespace: K, equalityFn?: EqualityFn<Wrapper<M[K]>>): Wrapper<M[K]> => {
40+
return getModel(namespace).useStore<Wrapper<M[K]>>(equalityFn);
4041
};
4142
type Models = {
4243
[K in keyof M]: Wrapper<M[K]>
4344
};
44-
const useStores = <K extends keyof M>(namespaces: K[]): Models => {
45+
const useStores = <K extends keyof M>(namespaces: K[], equalityFnArr?: EqualityFn<Wrapper<M[K]>>[]): Models => {
4546
const result: Partial<Models> = {};
46-
namespaces.forEach(namespace => {
47-
result[namespace] = getModel(namespace).useStore<Wrapper<M[K]>>();
47+
namespaces.forEach((namespace, i) => {
48+
result[namespace] = getModel(namespace).useStore<Wrapper<M[K]>>(equalityFnArr && equalityFnArr[i]);
4849
});
4950
return result as Models;
5051
};
@@ -172,3 +173,5 @@ export default class Icestore {
172173
};
173174
}
174175

176+
export { shallowEqual };
177+

src/store.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ import isFunction from 'lodash.isfunction';
22
import isPromise from 'is-promise';
33
import { useState, useEffect } from 'react';
44
import compose from './util/compose';
5-
import { ComposeFunc, Middleware } from './types';
5+
import { ComposeFunc, Middleware, EqualityFn, Queue } from './types';
66

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

1111
/** Queue of setState method from useState hook */
12-
private queue = [];
12+
private queue: Queue<any>[] = [];
1313

1414
/** Namespace of store */
1515
private namespace: string;
@@ -111,20 +111,36 @@ export default class Store {
111111
*/
112112
private setState(): void {
113113
const state = this.getState();
114-
this.queue.forEach(setState => setState(state));
114+
115+
this.queue.forEach(queueItem => {
116+
const { preState, setState, equalityFn } = queueItem;
117+
// update preState
118+
queueItem.preState = state;
119+
// use equalityFn check equality when function passed in
120+
if (equalityFn && equalityFn(preState, state)) {
121+
return;
122+
}
123+
setState(state);
124+
});
115125
}
116126

117127
/**
118128
* Hook used to register setState and expose model
119129
* @return {object} model of store
120130
*/
121-
public useStore<M>(): M {
131+
public useStore<M>(equalityFn?: EqualityFn<M>): M {
122132
const state = this.getState();
123133
const [, setState] = useState(state);
134+
124135
useEffect(() => {
125-
this.queue.push(setState);
136+
const queueItem = {
137+
preState: state,
138+
setState,
139+
equalityFn,
140+
};
141+
this.queue.push(queueItem);
126142
return () => {
127-
const index = this.queue.indexOf(setState);
143+
const index = this.queue.indexOf(queueItem);
128144
this.queue.splice(index, 1);
129145
};
130146
}, []);

src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { Dispatch, SetStateAction } from 'react';
2+
13
export interface ActionProps {
24
loading?: boolean;
35
error?: Error;
@@ -14,6 +16,14 @@ type NonFunctionPropertyNames<T> = { [K in keyof T]: T[K] extends Function ? nev
1416

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

19+
export type EqualityFn<M> = (preState: State<M>, newState: State<M>) => boolean
20+
21+
export interface Queue<S> {
22+
preState: S;
23+
setState: Dispatch<SetStateAction<S>>;
24+
equalityFn?: EqualityFn<S>;
25+
}
26+
1727
export interface Ctx {
1828
action: {
1929
name: string;

src/util/shallowEqual.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
function is(x, y) {
2+
if (x === y) {
3+
return x !== 0 || y !== 0 || 1 / x === 1 / y;
4+
} else {
5+
// eslint-disable-next-line no-self-compare
6+
return x !== x && y !== y;
7+
}
8+
}
9+
10+
export default function shallowEqual(objA, objB) {
11+
if (is(objA, objB)) return true;
12+
13+
if (
14+
typeof objA !== 'object' ||
15+
objA === null ||
16+
typeof objB !== 'object' ||
17+
objB === null
18+
) {
19+
return false;
20+
}
21+
22+
const keysA = Object.keys(objA);
23+
const keysB = Object.keys(objB);
24+
25+
if (keysA.length !== keysB.length) return false;
26+
27+
for (let i = 0; i < keysA.length; i++) {
28+
if (
29+
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
30+
!is(objA[keysA[i]], objB[keysA[i]])
31+
) {
32+
return false;
33+
}
34+
}
35+
36+
return true;
37+
}

tests/index.spec.tsx

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react';
22
import { render, fireEvent, getByTestId, wait } from '@testing-library/react';
3-
import Icestore from '../src/index';
3+
import Icestore, { shallowEqual } from '../src/index';
44
import Store from '../src/store';
55

66
describe('#Icestore', () => {
@@ -255,6 +255,85 @@ describe('#Icestore', () => {
255255
expect(renderFn).toHaveBeenCalledTimes(2);
256256
expect(nameValue.textContent).toEqual(newState.name);
257257
});
258+
259+
});
260+
261+
test('should equalityFn be ok.', async () => {
262+
const initState = {
263+
name: 'ice',
264+
};
265+
const { useStore } = icestore.registerStores({
266+
'todo': {
267+
dataSource: initState,
268+
setData(dataSource) {
269+
this.dataSource = dataSource;
270+
},
271+
},
272+
});
273+
274+
let renderCount = 0;
275+
const renderFn = () => renderCount++;
276+
277+
const Todos = ({ equalityFn }) => {
278+
const todo: any = useStore('todo', equalityFn);
279+
const { dataSource } = todo;
280+
281+
renderFn();
282+
283+
const changeNothing = () => todo.setData(initState);
284+
const changeStateRef = () => todo.setData({ ...initState });
285+
286+
return <div>
287+
<span data-testid="nameValue">{dataSource.name}</span>
288+
<button type="button" data-testid="changeNothingBtn" onClick={changeNothing}>
289+
Click me
290+
</button>
291+
<button type="button" data-testid="changeStateRefBtn" onClick={changeStateRef}>
292+
Click me
293+
</button>
294+
</div>;
295+
};
296+
297+
const { container, unmount } = render(<Todos equalityFn={shallowEqual} />);
298+
const nameValue = getByTestId(container, 'nameValue');
299+
const changeNothingBtn = getByTestId(container, 'changeNothingBtn');
300+
const changeStateRefBtn = getByTestId(container, 'changeStateRefBtn');
301+
302+
expect(nameValue.textContent).toEqual(initState.name);
303+
expect(renderCount).toBe(1);
304+
305+
fireEvent.click(changeNothingBtn);
306+
307+
// will not rerender
308+
await wait(() => {
309+
expect(nameValue.textContent).toEqual(initState.name);
310+
expect(renderCount).toBe(1);
311+
});
312+
313+
fireEvent.click(changeStateRefBtn);
314+
315+
// will rerender
316+
await wait(() => {
317+
expect(nameValue.textContent).toEqual(initState.name);
318+
expect(renderCount).toBe(2);
319+
});
320+
321+
unmount();
322+
323+
const { container: container1 } = render(<Todos equalityFn={(a, b) => a.dataSource.name === b.dataSource.name} />);
324+
const nameValue1 = getByTestId(container1, 'nameValue');
325+
const changeStateRefBtn1 = getByTestId(container1, 'changeStateRefBtn');
326+
327+
expect(nameValue1.textContent).toEqual(initState.name);
328+
expect(renderCount).toBe(3);
329+
330+
fireEvent.click(changeStateRefBtn1);
331+
332+
// will not rerender
333+
await wait(() => {
334+
expect(nameValue1.textContent).toEqual(initState.name);
335+
expect(renderCount).toBe(3);
336+
});
258337
});
259338

260339
test('should useStores be ok.', () => {

tests/util.spec.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import compose from '../src/util/compose';
2+
import shallowEqual from '../src/util/shallowEqual';
23

34
describe('#util', () => {
45
let handler;
@@ -57,4 +58,81 @@ describe('#util', () => {
5758
expect(arr).toEqual([1, 2, 3, 4, 5, 6]);
5859
});
5960
});
61+
62+
describe('#shallowEqual', () => {
63+
test('should return true if arguments fields are equal', () => {
64+
expect(
65+
shallowEqual({ a: 1, b: 2, c: undefined }, { a: 1, b: 2, c: undefined }),
66+
).toBe(true);
67+
68+
expect(
69+
shallowEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2, c: 3 }),
70+
).toBe(true);
71+
72+
const o = {};
73+
expect(
74+
shallowEqual({ a: 1, b: 2, c: o }, { a: 1, b: 2, c: o }),
75+
).toBe(true);
76+
77+
const d = function() {
78+
return 1;
79+
};
80+
expect(
81+
shallowEqual({ a: 1, b: 2, c: o, d }, { a: 1, b: 2, c: o, d }),
82+
).toBe(true);
83+
});
84+
85+
test('should return false if arguments fields are different function identities', () => {
86+
expect(
87+
shallowEqual(
88+
{
89+
a: 1,
90+
b: 2,
91+
d() {
92+
return 1;
93+
},
94+
},
95+
{
96+
a: 1,
97+
b: 2,
98+
d() {
99+
return 1;
100+
},
101+
},
102+
),
103+
).toBe(false);
104+
});
105+
106+
test('should return false if first argument has too many keys', () => {
107+
expect(shallowEqual({ a: 1, b: 2, c: 3 }, { a: 1, b: 2 })).toBe(false);
108+
});
109+
110+
test('should return false if second argument has too many keys', () => {
111+
expect(shallowEqual({ a: 1, b: 2 }, { a: 1, b: 2, c: 3 })).toBe(false);
112+
});
113+
114+
test('should return false if arguments have different keys', () => {
115+
expect(
116+
shallowEqual(
117+
{ a: 1, b: 2, c: undefined },
118+
{ a: 1, bb: 2, c: undefined },
119+
),
120+
).toBe(false);
121+
});
122+
123+
test('should compare two NaN values', () => {
124+
expect(shallowEqual(NaN, NaN)).toBe(true);
125+
});
126+
127+
test('should compare empty objects, with false', () => {
128+
expect(shallowEqual({}, false)).toBe(false);
129+
expect(shallowEqual(false, {})).toBe(false);
130+
expect(shallowEqual([], false)).toBe(false);
131+
expect(shallowEqual(false, [])).toBe(false);
132+
});
133+
134+
test('should compare two zero values', () => {
135+
expect(shallowEqual(0, 0)).toBe(true);
136+
});
137+
});
60138
});

0 commit comments

Comments
 (0)