diff --git a/README.md b/README.md index e74f9576..d019a70f 100644 --- a/README.md +++ b/README.md @@ -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} @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 1e7d5850..b8c71011 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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} @@ -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 来实现才是更合理的选择。 diff --git a/package.json b/package.json index 34f059f5..7b76d4b9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.tsx b/src/index.tsx index 1f0828dd..697a3498 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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 */ @@ -35,16 +36,16 @@ export default class Icestore { stores[namespace] = new Store(namespace, models[namespace], middlewares); }); - const useStore = (namespace: K): Wrapper => { - return getModel(namespace).useStore>(); + const useStore = (namespace: K, equalityFn?: EqualityFn>): Wrapper => { + return getModel(namespace).useStore>(equalityFn); }; type Models = { [K in keyof M]: Wrapper }; - const useStores = (namespaces: K[]): Models => { + const useStores = (namespaces: K[], equalityFnArr?: EqualityFn>[]): Models => { const result: Partial = {}; - namespaces.forEach(namespace => { - result[namespace] = getModel(namespace).useStore>(); + namespaces.forEach((namespace, i) => { + result[namespace] = getModel(namespace).useStore>(equalityFnArr && equalityFnArr[i]); }); return result as Models; }; @@ -172,3 +173,5 @@ export default class Icestore { }; } +export { shallowEqual }; + diff --git a/src/store.ts b/src/store.ts index eb283d9a..f0cdd29f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -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[] = []; /** Namespace of store */ private namespace: string; @@ -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 { + public useStore(equalityFn?: EqualityFn): 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); }; }, []); diff --git a/src/types.ts b/src/types.ts index 31ca11e4..001801ce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ +import { Dispatch, SetStateAction } from 'react'; + export interface ActionProps { loading?: boolean; error?: Error; @@ -14,6 +16,14 @@ type NonFunctionPropertyNames = { [K in keyof T]: T[K] extends Function ? nev export type State = Pick>; +export type EqualityFn = (preState: State, newState: State) => boolean + +export interface Queue { + preState: S; + setState: Dispatch>; + equalityFn?: EqualityFn; +} + export interface Ctx { action: { name: string; diff --git a/src/util/shallowEqual.ts b/src/util/shallowEqual.ts new file mode 100644 index 00000000..3d21255f --- /dev/null +++ b/src/util/shallowEqual.ts @@ -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; +} \ No newline at end of file diff --git a/tests/index.spec.tsx b/tests/index.spec.tsx index 35313ea4..5c65500e 100644 --- a/tests/index.spec.tsx +++ b/tests/index.spec.tsx @@ -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', () => { @@ -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
+ {dataSource.name} + + +
; + }; + + const { container, unmount } = render(); + 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( 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.', () => { diff --git a/tests/util.spec.tsx b/tests/util.spec.tsx index 1fcf7b69..3c83fadc 100644 --- a/tests/util.spec.tsx +++ b/tests/util.spec.tsx @@ -1,4 +1,5 @@ import compose from '../src/util/compose'; +import shallowEqual from '../src/util/shallowEqual'; describe('#util', () => { let handler; @@ -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); + }); + }); });