From c4e070f7791c3c9269993554adebe7567bd1306b Mon Sep 17 00:00:00 2001 From: geekact Date: Fri, 8 Sep 2023 23:45:45 +0800 Subject: [PATCH] feat!: remove useDefined hook --- CHANGELOG.md | 4 + docs/advanced.md | 37 ------- docs/api.md | 23 ++--- docs/events.md | 21 ---- docs/mindMap.svg | 4 +- src/index.ts | 3 +- src/model/defineModel.ts | 15 +-- src/model/types.ts | 30 ------ src/model/useDefined.ts | 111 -------------------- src/model/useModel.ts | 6 +- test/lifecycle.test.ts | 61 ----------- test/typescript/useDefined.check.ts | 31 ------ test/useDefined.test.tsx | 150 ---------------------------- 13 files changed, 22 insertions(+), 474 deletions(-) delete mode 100644 src/model/useDefined.ts delete mode 100644 test/typescript/useDefined.check.ts delete mode 100644 test/useDefined.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index a72e456..3f8236f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## v3 + +- 删除hooks `useDefined` + ## [2.0.1](https://github.com/foca-js/foca/compare/v2.0.0...v2.0.1)  (2023-08-10) - react-redux 版本从 8.1.1 升级到 8.1.2 (#40) diff --git a/docs/advanced.md b/docs/advanced.md index a6f879f..fd91f25 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -37,43 +37,6 @@ const user3Model = cloneModel('users3', userModel, (prev) => { }); ``` -# 局部模型 - -通过`defineModel`和`cloneModel`创建的模型均为全局类别的模型,数据一直保持在内存中,直到应用关闭或者退出才会释放,对于比较大的项目,这可能会有性能问题。所以有时候你其实想要一种`用完就扔`的模型,即在 React 组件初始化时把模型数据扔到 store 中,当 React 组件被销毁时,模型的数据也跟着销毁。现在局部模型很适合你的需求: - -```tsx -import { useEffect } from 'react'; -import { defineModel, useDefined } from 'foca'; - -// test.model.ts -export const testModel = defineModel('test', { - initialState: { count: 0 }, - reducers: { - plus(state, value: number) { - state.count += value; - }, - }, -}); - -// App.tsx -const App: FC = () => { - const model = useDefined(testModel); - const { count } = useModel(model); - - useEffect(() => { - model.plus(1); - }, []); - - return
{count}
; -}; -``` - -利用 `useDefined` 函数根据全局模型创建一个新的局部模型,然后就是通用的模型操作,这似乎没有增加工作量(因为只多了一行)。下面我列举了局部函数的几个特点: - -- 组件内部使用,不污染全局 -- 数据随组件自动挂载/释放 -- 有效降低内存占用量 - # loadings 默认地,methods 函数只会保存一份执行状态,如果你在同一时间多次执行同一个函数,那么状态就会互相覆盖,产生错乱的数据。如果现在有 10 个按钮,点击每个按钮都会执行`model.methodX(id)`,那么我们如何知道是哪个按钮执行的呢?这时候我们需要为执行状态开辟一个独立的存储空间,让同一个函数拥有多个状态互不干扰。 diff --git a/docs/api.md b/docs/api.md index 2968fba..9a8a943 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,12 +1,11 @@ -| 方法 | 描述 | 使用频率 | 指南 | -| ----------------- | --------------------------------------- | ----------------------------------- | ------------------------------------- | -| **store.init** | 初始化仓库 | :star2: | [开始使用](/initialize?id=仓库) | -| **store.refresh** | 重置仓库数据 | :star2: | [进阶用法](/advanced?id=重置所有数据) | -| **defineModel** | 创建模型 | :star2::star2::star2::star2::star2: | [模型](/model?id=model) | -| **cloneModel** | 复制模型并允许小量修改 | :star2: | [进阶用法](/advanced?id=克隆模型) | -| **useDefined** | 在 hooks 中创建局部模型,数据随组件释放 | :star2::star2::star2: | [进阶用法](/advanced?id=局部模型) | -| **useModel** | 在 hooks 中使用模型的状态 | :star2::star2::star2::star2::star2: | [数据对接](/react?id=usemodel) | -| **useComputed** | 在 hooks 中使用计算属性 | :star2::star2::star2: | [数据对接](/react?id=usecomputed) | -| **useLoading** | 在 hooks 中获取异步函数的执行状态 | :star2::star2::star2::star2::star2: | [数据对接](/react?id=useloading) | -| **getLoading** | 获取异步函数的当前执行状态 | :star2: | [通用属性](/model?id=loading) | -| **connect** | 在 class 组件中连接 react 和 redux | :star2: | [数据对接](/react?id=connect) | +| 方法 | 描述 | 使用频率 | 指南 | +| ----------------- | ---------------------------------- | ----------------------------------- | ------------------------------------- | +| **store.init** | 初始化仓库 | :star2: | [开始使用](/initialize?id=仓库) | +| **store.refresh** | 重置仓库数据 | :star2: | [进阶用法](/advanced?id=重置所有数据) | +| **defineModel** | 创建模型 | :star2::star2::star2::star2::star2: | [模型](/model?id=model) | +| **cloneModel** | 复制模型并允许小量修改 | :star2: | [进阶用法](/advanced?id=克隆模型) | +| **useModel** | 在 hooks 中使用模型的状态 | :star2::star2::star2::star2::star2: | [数据对接](/react?id=usemodel) | +| **useComputed** | 在 hooks 中使用计算属性 | :star2::star2::star2: | [数据对接](/react?id=usecomputed) | +| **useLoading** | 在 hooks 中获取异步函数的执行状态 | :star2::star2::star2::star2::star2: | [数据对接](/react?id=useloading) | +| **getLoading** | 获取异步函数的当前执行状态 | :star2: | [通用属性](/model?id=loading) | +| **connect** | 在 class 组件中连接 react 和 redux | :star2: | [数据对接](/react?id=connect) | diff --git a/docs/events.md b/docs/events.md index 75fee9d..c57073d 100644 --- a/docs/events.md +++ b/docs/events.md @@ -63,24 +63,3 @@ export const testModel = defineModel('test', { }, }); ``` - -## onDestroy - -模型数据从 store 卸载时的回调通知。onDestroy 事件只针对`局部模型`,即通过`useDefined`这个 hooks api 创建的模型才会触发,因为局部模型是跟随组件一起创建和销毁的。 - -注意,当触发 onDestroy 回调时,模型已经被卸载了,所以无法再拿到当前数据,而且`this`上下文也被限制使用了。 - -```typescript -import { defineModel } from 'foca'; - -const initialState = { count: 0 }; - -export const testModel = defineModel('test', { - initialState, - events: { - onDestroy() { - console.log('Destroyed'); - }, - }, -}); -``` diff --git a/docs/mindMap.svg b/docs/mindMap.svg index 6a2b7ba..05533ae 100644 --- a/docs/mindMap.svg +++ b/docs/mindMap.svg @@ -1,4 +1,4 @@ - + -
reducers
reducers
Model
Model
methods
methods
events
events
connect
connect
useLoading
useLoading
loading store
loading store
model store
model store
proxy store
proxy store
getLoading
getLoading
useModel
useModel
defineModel
defineModel
cloneModel
cloneModel
persist
persist
computed
computed
 useComputed
 useComputed
 useDefined
 useDefined
Text is not SVG - cannot display
\ No newline at end of file +
reducers
reducers
Model
Model
methods
methods
events
events
connect
connect
useLoading
useLoading
loading store
loading store
model store
model store
proxy store
proxy store
getLoading
getLoading
useModel
useModel
defineModel
defineModel
cloneModel
cloneModel
persist
persist
computed
computed
 useComputed
 useComputed
Text is not SVG - cannot display
\ No newline at end of file diff --git a/src/index.ts b/src/index.ts index be0a6b2..17811d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,6 @@ export { useLoading } from './api/useLoading'; export { getLoading } from './api/getLoading'; export { connect } from './redux/connect'; export { useComputed } from './reactive/useComputed'; -export { useDefined } from './model/useDefined'; // 入口使用 export { compose } from 'redux'; @@ -27,6 +26,6 @@ export type { StoreEnhancer, Unsubscribe, } from 'redux'; -export type { Model, HookModel } from './model/types'; +export type { Model } from './model/types'; export type { StorageEngine } from './engines'; export type { ComputedRef } from './reactive/types'; diff --git a/src/model/defineModel.ts b/src/model/defineModel.ts index 512869c..84ecb27 100644 --- a/src/model/defineModel.ts +++ b/src/model/defineModel.ts @@ -206,7 +206,7 @@ export const defineModel = < } if (events) { - const { onInit, onChange, onDestroy } = events; + const { onInit, onChange } = events; const eventCtx: EventCtx = Object.assign( composeGetter({ name: uniqueName }, getState), enhancedMethods.external, @@ -233,19 +233,6 @@ export const defineModel = < ); } - if (onDestroy) { - subscriptions.push( - modelStore.subscribe(() => { - if (eventCtx.state === void 0) { - for (let i = 0; i < subscriptions.length; ++i) { - subscriptions[i]!(); - } - onDestroy.call(null as never); - } - }), - ); - } - if (onInit) { /** * 初始化时,用到它的React组件可能还没加载,所以执行async-method时无法判断是否需要保存loading。因此需要一个钩子来处理事件周期 diff --git a/src/model/types.ts b/src/model/types.ts index 7a7f670..eed61e9 100644 --- a/src/model/types.ts +++ b/src/model/types.ts @@ -126,10 +126,6 @@ type ModelComputed = { : never; }; -class ModelIdentity { - private declare readonly _global: true; -} - export type Model< Name extends string = string, State extends object = object, @@ -137,26 +133,6 @@ export type Model< Effect extends object = object, Computed extends object = object, > = BaseModel & - ModelIdentity & - // [K in keyof Action as K extends `_${string}` ? never : K] - // 上面这种看起来简洁,业务代码提示也正常,但是业务代码那边无法点击跳转进模型了。 - // 所以需要先转换所有的属性,再把私有属性去除。 - Omit, GetPrivateMethodKeys> & - Omit, GetPrivateMethodKeys> & - Omit, GetPrivateMethodKeys>; - -class HookModelIdentity { - private declare readonly _hooks: true; -} - -export type HookModel< - Name extends string = string, - State extends object = object, - Action extends object = object, - Effect extends object = object, - Computed extends object = object, -> = BaseModel & - HookModelIdentity & // [K in keyof Action as K extends `_${string}` ? never : K] // 上面这种看起来简洁,业务代码提示也正常,但是业务代码那边无法点击跳转进模型了。 // 所以需要先转换所有的属性,再把私有属性去除。 @@ -193,12 +169,6 @@ export interface Event { * 上下文 **this** 可以直接调用actions和effects的函数以及computed计算属性,请谨慎执行修改数据的操作以防止死循环。 */ onChange?: (prevState: State, nextState: State) => void; - /** - * 销毁模型时的回调通知,此时模型已经被销毁。 - * 该事件仅在局部模型生效 - * @see useDefined - */ - onDestroy?: (this: never) => void; } export interface EventCtx diff --git a/src/model/useDefined.ts b/src/model/useDefined.ts deleted file mode 100644 index f4e8c15..0000000 --- a/src/model/useDefined.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { DestroyLodingAction, DESTROY_LOADING } from '../actions/loading'; -import { loadingStore } from '../store/loadingStore'; -import { ModelStore, modelStore } from '../store/modelStore'; -import { cloneModel } from './cloneModel'; -import { HookModel as HookModel, Model } from './types'; - -let nameCounter = 0; -const hotReloadCounter: Record = {}; - -export const useDefined = < - State extends object = object, - Action extends object = object, - Effect extends object = object, - Computed extends object = object, ->( - globalModel: Model, -): HookModel => { - const modelName = globalModel.name; - const initialCount = useState(() => nameCounter++)[0]; - - const uniqueName = - process.env.NODE_ENV === 'production' - ? useProdName(modelName, initialCount) - : useDevName(modelName, initialCount, new Error()); - - const hookModel = useMemo(() => { - return cloneModel(uniqueName, globalModel); - }, [uniqueName]); - - return hookModel as any; -}; - -const useProdName = (modelName: string, count: number) => { - const uniqueName = modelName + '#' + count; - - useEffect( - () => () => { - setTimeout(unmountModel, 0, uniqueName); - }, - [uniqueName], - ); - - return uniqueName; -}; - -/** - * 开发模式下,需要Hot Reload。 - * 必须保证数据不会丢,即如果用户一直保持`model.name`不变,就被判定为可以共享热更新之前的数据。 - * - * 必须严格控制count在组件内的自增次数,否则在第一次修改model的name时,总是会报错: - * Warning: Cannot update a component (`XXX`) while rendering a different component (`XXX`) - */ -const useDevName = (modelName: string, count: number, err: Error) => { - const componentName = useMemo((): string => { - try { - const stacks = err.stack!.split('\n'); - - const innerNamePattern = new RegExp( - // vitest测试框架的stack增加了 Module. - `at\\s(?:Module\\.)?${useDefined.name}\\s\\(`, - 'i', - ); - const componentNamePattern = /at\s(.+?)\s\(/i; - - for (let i = 0; i < stacks.length; ++i) { - if (innerNamePattern.test(stacks[i]!)) { - return stacks[i + 1]!.match(componentNamePattern)![1]!; - } - } - } catch {} - - return 'Anonymous'; - }, [err.stack]); - - const uniqueName = `${componentName}:${count}:${modelName}`; - - useMemo(() => { - hotReloadCounter[uniqueName] ||= 0; - ++hotReloadCounter[uniqueName]; - }, [uniqueName]); - - useEffect(() => { - const prev = hotReloadCounter[uniqueName]; - return () => { - /** - * 热更新时会重新执行一次useEffect - * setTimeout可以让其他useEffect有充分的时间使用model - * - * 需要卸载模型的场景是: - * 1. 组件hooks增减或者调换顺序(initialCount会自增) - * 2. 组件卸载 - * 3. model.name变更 - */ - setTimeout(() => { - const active = prev !== hotReloadCounter[uniqueName]; - active || unmountModel(uniqueName); - }); - }; - }, [uniqueName]); - - return uniqueName; -}; - -const unmountModel = (modelName: string) => { - ModelStore.removeReducer.call(modelStore, modelName); - loadingStore.dispatch({ - type: DESTROY_LOADING, - model: modelName, - }); -}; diff --git a/src/model/useModel.ts b/src/model/useModel.ts index 5bd8a6e..d09cae0 100644 --- a/src/model/useModel.ts +++ b/src/model/useModel.ts @@ -1,6 +1,6 @@ import { shallowEqual } from 'react-redux'; import { deepEqual } from '../utils/deepEqual'; -import type { HookModel, Model } from './types'; +import type { Model } from './types'; import { toArgs } from '../utils/toArgs'; import { useModelSelector } from '../redux/useSelector'; import { isFunction, isString } from '../utils/isType'; @@ -21,10 +21,10 @@ export type Algorithm = 'strictEqual' | 'shallowEqual' | 'deepEqual'; * * 最后一个参数如果是**函数**,则为状态过滤函数,过滤函数的结果视为最终返回值。 */ export function useModel( - model: Model | HookModel, + model: Model, ): State; export function useModel( - model: Model | HookModel, + model: Model, selector: (state: State) => T, algorithm?: Algorithm, ): T; diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 97b41af..9eee4a1 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -1,7 +1,6 @@ import sleep from 'sleep-promise'; import { cloneModel, defineModel, engines, store } from '../src'; import { PersistSchema } from '../src/persist/PersistItem'; -import { ModelStore } from '../src/store/modelStore'; describe('onInit', () => { afterEach(() => { @@ -188,63 +187,3 @@ describe('onChange', () => { ); }); }); - -describe('onDestroy', () => { - beforeEach(() => { - store.init(); - }); - - afterEach(() => { - store.unmount(); - }); - - test('call onDestroy when invoke store.destroy()', async () => { - const spy = vitest.fn(); - const model = defineModel('events' + Math.random(), { - initialState: { count: 0 }, - reducers: { - update(state) { - state.count += 1; - }, - }, - events: { - onDestroy: spy, - }, - }); - - await store.onInitialized(); - - model.update(); - expect(spy).toBeCalledTimes(0); - ModelStore.removeReducer.call(store, model.name); - expect(spy).toBeCalledTimes(1); - spy.mockRestore(); - }); - - test('should not call onChange', async () => { - const destroySpy = vitest.fn(); - const changeSpy = vitest.fn(); - const model = defineModel('events' + Math.random(), { - initialState: { count: 0 }, - reducers: { - update(state) { - state.count += 1; - }, - }, - events: { - onChange: changeSpy, - onDestroy: destroySpy, - }, - }); - - await store.onInitialized(); - - model.update(); - expect(destroySpy).toBeCalledTimes(0); - expect(changeSpy).toBeCalledTimes(1); - ModelStore.removeReducer.call(store, model.name); - expect(destroySpy).toBeCalledTimes(1); - expect(changeSpy).toBeCalledTimes(1); - destroySpy.mockRestore(); - }); -}); diff --git a/test/typescript/useDefined.check.ts b/test/typescript/useDefined.check.ts deleted file mode 100644 index 59baecb..0000000 --- a/test/typescript/useDefined.check.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { defineModel, useDefined, useLoading, useModel } from '../../src'; -import { basicModel } from '../models/basicModel'; - -const hookModel = useDefined(basicModel); - -useModel(hookModel); -useModel(hookModel, (state) => state.count); -useLoading(hookModel.pureAsync); -useLoading(hookModel.pureAsync.room); - -// @ts-expect-error -useModel(basicModel, hookModel); -// @ts-expect-error -useModel(hookModel, basicModel); -// @ts-expect-error -useModel(hookModel, basicModel, () => {}); - -// @ts-expect-error -useDefined(hookModel); -// @ts-expect-error -cloneModel(hookModel); - -defineModel('local-demo-1', { - initialState: {}, - events: { - onDestroy() { - // @ts-expect-error - this.anything; - }, - }, -}); diff --git a/test/useDefined.test.tsx b/test/useDefined.test.tsx deleted file mode 100644 index 546a27a..0000000 --- a/test/useDefined.test.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { act, cleanup, render } from '@testing-library/react'; -import { useEffect, useState } from 'react'; -import sleep from 'sleep-promise'; -import { - defineModel, - FocaProvider, - store, - useLoading, - useDefined, - Model, - HookModel, -} from '../src'; -import { loadingStore } from '../src/store/loadingStore'; -import { renderHook } from './helpers/renderHook'; -import { basicModel } from './models/basicModel'; - -(['development', 'production'] as const).forEach((env) => { - describe(`[${env} mode]`, () => { - beforeEach(() => { - store.init(); - process.env.NODE_ENV = env; - }); - - afterEach(async () => { - process.env.NODE_ENV = 'testing'; - cleanup(); - await sleep(10); - store.unmount(); - }); - - test('can register to modelStore and remove from modelStore', async () => { - const { result, unmount } = renderHook(() => useDefined(basicModel)); - - expect(result.current).not.toBe(basicModel); - expect(store.getState()).toHaveProperty( - result.current.name, - result.current.state, - ); - - unmount(); - await sleep(1); - expect(store.getState()).not.toHaveProperty(result.current.name); - }); - - test('can register to loadingStore and remove from loadingStore', async () => { - const { result, unmount } = renderHook(() => { - const model = useDefined(basicModel); - useLoading(basicModel.pureAsync); - useLoading(model.pureAsync); - - return model; - }); - - const key1 = `${result.current.name}.pureAsync`; - const key2 = `${basicModel.name}.pureAsync`; - expect(loadingStore.getState()).not.toHaveProperty(key1); - - await act(async () => { - const promise1 = result.current.pureAsync(); - const promise2 = basicModel.pureAsync(); - - expect(loadingStore.getState()).toHaveProperty(key1); - expect(loadingStore.getState()).toHaveProperty(key2); - - await promise1; - await promise2; - }); - - expect(loadingStore.getState()).toHaveProperty(key1); - expect(loadingStore.getState()).toHaveProperty(key2); - - unmount(); - await sleep(1); - expect(loadingStore.getState()).not.toHaveProperty(key1); - expect(loadingStore.getState()).toHaveProperty(key2); - }); - - test('call onDestroy event when local model is destroyed', async () => { - const spy = vitest.fn(); - const globalModel = defineModel('local-demo-1', { - initialState: {}, - events: { - onDestroy: spy, - }, - }); - - const { unmount } = renderHook(() => useDefined(globalModel)); - - expect(spy).toBeCalledTimes(0); - unmount(); - await sleep(1); - expect(spy).toBeCalledTimes(1); - basicModel.plus(1); - expect(spy).toBeCalledTimes(1); - }); - - test('recreate hook model when global model changed', async () => { - const globalModel = defineModel('hook-demo-2', { - initialState: {}, - }); - - const { result } = renderHook(() => { - const [state, setState] = useState(basicModel); - - const model = useDefined(state); - - useEffect(() => { - setTimeout(() => { - setState(globalModel); - }, 20); - }, []); - - return model; - }); - - const name1 = result.current.name; - expect(name1).toMatch(basicModel.name); - expect(store.getState()).toHaveProperty(name1); - - await act(async () => { - await sleep(30); - }); - - await sleep(10); - - expect(result.current.name).not.toBe('hook-demo-2'); - expect(result.current.name).toMatch('hook-demo-2'); - expect(store.getState()).not.toHaveProperty(name1); - }); - - test.runIf(env === 'development')( - 'Can get component name in dev mode', - () => { - let model!: HookModel; - function MyApp() { - model = useDefined(basicModel); - return null; - } - - render( - - - , - ); - - expect(model.name).toMatch('MyApp:'); - }, - ); - }); -});