diff --git a/package.json b/package.json index f870d5cf..c2418736 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ice/store", - "version": "0.1.4", + "version": "0.1.5", "description": "基于 React Hooks 的数据流方案", "main": "lib/index.js", "types": "lib/index.d.ts", @@ -55,7 +55,8 @@ "typescript": "^3.4.4" }, "dependencies": { - "lodash.isfunction": "^3.0.9" + "lodash.isfunction": "^3.0.9", + "lodash.isobject": "^3.0.2" }, "peerDependencies": { "react": "^16.7.0" diff --git a/src/store.ts b/src/store.ts index 03574eb1..f897fb64 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,50 +1,98 @@ - import * as isFunction from 'lodash.isfunction'; import { useState, useEffect } from 'react'; +import { addProxy } from './util'; interface MethodFunc { (): void; } export default class Store { - private state: {[name: string]: any} = {}; - - private methods: {[name: string]: MethodFunc} = {}; + // store state and actions user defined + private bindings: {[name: string]: any} = {}; + // queue of setState method from useState hook private queue = []; + // flag of whether allow state mutate + private allowMutate = false; + + // flag of whether state changed after mutation + private stateChanged = false; + public constructor(bindings: object) { Object.keys(bindings).forEach((key) => { const value = bindings[key]; - - if (isFunction(value)) { - this.methods[key] = this.createMethod(value); - } else { - this.state[key] = value; - } + this.bindings[key] = isFunction(value) ? this.createAction(value) : value; }); - } - private getBindings() { - return { ...this.state, ...this.methods }; + const handler = { + set: (target, prop, value) => { + if (!this.allowMutate) { + console.error('Forbid modifying state directly, use action to modify instead.'); + return false; + } + if (target[prop] !== value) { + this.stateChanged = true; + } + /* eslint no-param-reassign: 0 */ + target[prop] = addProxy(value, handler); + return true; + }, + }; + + this.bindings = addProxy(this.bindings, handler); } - private createMethod(fun): MethodFunc { + /** + * Create action which will trigger state update after mutation + * @param {func} fun - original method user defined + * @return {func} action function + */ + private createAction(fun): MethodFunc { return async (...args) => { - const newState = { ...this.state }; - await fun.apply(newState, args); - this.setState(newState); - return this.getBindings(); + this.allowMutate = true; + await fun.apply(this.bindings, args); + if (this.stateChanged) { + this.setState(); + } + this.allowMutate = false; + this.stateChanged = false; + return this.bindings; }; } - private setState(newState: object): void { - this.state = newState; + /** + * Get state from bindings + * @return {object} state + */ + private getState(): object { + const { bindings } = this; + const state = {}; + Object.keys(bindings).forEach((key) => { + const value = bindings[key]; + if (!isFunction(value)) { + state[key] = value; + } + }); + return state; + } + + /** + * Trigger setState method in queue + */ + private setState(): void { + const state = this.getState(); + const newState = { ...state }; this.queue.forEach(setState => setState(newState)); } + /** + * Hook used to register setState and expose bindings + * @return {object} bindings of store + */ public useStore(): object { - const [, setState] = useState(this.state); + const state = this.getState(); + const [, setState] = useState(state); useEffect(() => { this.queue.push(setState); return () => { @@ -52,7 +100,6 @@ export default class Store { this.queue.splice(index, 1); }; }, []); - - return this.getBindings(); + return this.bindings; } } diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 00000000..1ba4b39f --- /dev/null +++ b/src/util.ts @@ -0,0 +1,33 @@ +import * as isObject from 'lodash.isobject'; +import * as isFunction from 'lodash.isfunction'; + +const { isArray } = Array; + +/** + * Recursively add proxy to object and array + * @param {*} value - variable of any type + * @param {object} handler - proxy handler + * @return {object} new proxy object + */ +/* eslint no-param-reassign: 0, import/prefer-default-export: 0 */ +export function addProxy(value: any, handler: object): any { + // primitive type (number, boolean, string, null, undefined) and function type + if (!isObject(value) || isFunction(value)) { + return value; + } + + if (isArray(value)) { // array type + value.forEach((item, index) => { + if (isObject(item)) { // object and array type + value[index] = addProxy(item, handler); + } + }); + } else if (isObject(value)) { // object type + Object.keys(value).forEach((key) => { + if (isObject(value[key])) { // object and array type + value[key] = addProxy(value[key], handler); + } + }); + } + return new Proxy(value, handler); +} diff --git a/tests/util.spec.tsx b/tests/util.spec.tsx new file mode 100644 index 00000000..563dee73 --- /dev/null +++ b/tests/util.spec.tsx @@ -0,0 +1,85 @@ +import { addProxy } from '../src/util'; + +describe('#util', () => { + let handler; + + beforeEach(() => { + handler = { + set: (target, prop): boolean => { + /* eslint no-param-reassign: 0 */ + target[prop] = 'foo'; + return true; + }, + }; + }); + + describe('#addProxy', () => { + test('should boolean type not affected', () => { + const value = false; + expect(addProxy(value, handler)).toBe(value); + }); + + test('should number type not affected', () => { + const value = 1; + expect(addProxy(value, handler)).toBe(value); + }); + + test('should string type not affected', () => { + const value = 'abc'; + expect(addProxy(value, handler)).toBe(value); + }); + + test('should null type not affected', () => { + const value = null; + expect(addProxy(value, handler)).toBe(value); + }); + + test('should undefined type not affected', () => { + const value = undefined; + expect(addProxy(value, handler)).toBe(value); + }); + + test('should function type not affected', () => { + const value = () => {}; + expect(addProxy(value, handler)).toBe(value); + }); + + test('should object proxy set success', () => { + const value = { + a: 1, + b: 2, + }; + const result = addProxy(value, handler); + result.a = 100; + expect(result.a).toBe('foo'); + }); + + test('should object proxy recursively set success', () => { + const value = { + a: [ + { c: 3 }, + ], + b: 2, + }; + const result = addProxy(value, handler); + result.a[0].c = 4; + expect(result.a[0].c).toBe('foo'); + }); + + test('should array proxy set success', () => { + const value = [1, 2]; + const result = addProxy(value, handler); + result[0] = 4; + expect(result[0]).toBe('foo'); + }); + + test('should array proxy recursively set success', () => { + const value = [ + { a: 1 }, + ]; + const result = addProxy(value, handler); + result[0].a = 4; + expect(result[0].a).toBe('foo'); + }); + }); +});