Skip to content

Commit

Permalink
feat: add hook function useIsolate
Browse files Browse the repository at this point in the history
  • Loading branch information
geekact committed Oct 19, 2023
1 parent 0c45009 commit 1f3c8c4
Show file tree
Hide file tree
Showing 12 changed files with 551 additions and 7 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## [3.2.0](https://github.com/foca-js/foca/compare/v3.1.1...v3.2.0)  (2023-10-20)

- 增加局部模型钩子 `useIsolate`

## [3.1.1](https://github.com/foca-js/foca/compare/v3.1.0...v3.1.1)  (2023-10-14)

- computed内使用数组状态数据时,更新数组不会触发重新计算
Expand Down
50 changes: 50 additions & 0 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,56 @@ defineModel('my-global-model', {
store.refresh(true);
```

# 局部模型

通过`defineModel``cloneModel`创建的模型均为全局类别的模型,数据一直保持在内存中,直到应用关闭或者退出才会释放,对于比较大的项目,这可能会有性能问题。所以有时候你其实想要一种`用完就扔`的模型,即在 React 组件初始化时把模型数据扔到 store 中,当 React 组件被销毁时,模型的数据也跟着销毁。现在,局部模型很适合你的需求:

```diff
import { useEffect } from 'react';
import { defineModel, useIsolate } 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 = useIsolate(testModel);
const { count } = useModel(model);

useEffect(() => {
model.plus(1);
}, []);

return <div>{count}</div>;
};
```

只需增加一行代码的工作量,利用 `useIsolate` 函数根据全局模型创建一个新的局部模型。局部模型拥有一份独立的状态数据,任何操作都不会影响到原来的全局模型,而且会随着组件一起 `挂载/销毁`,能有效降低内存占用。

另外,别忘了模型上还有两个对应的事件`onInit``onDestroy`可以使用

```typescript
export const testModel = defineModel('test', {
initialState: { count: 0 },
events: {
onInit() {
// 全局模型创建时触发
// 局部模型随组件一起挂载时触发
},
onDestroy() {
// 局部模型随组件一起销毁时触发
},
},
});
```

# 私有方法

我们总是会想抽出一些逻辑作为独立的方法调用,但又不想暴露给模型外部使用,而且方法一多,调用方法时 TS 会提示长长的一串方法列表,显得十分混乱。是时候声明一些私有方法了,foca 使用约定俗成的`前置下划线(_)`来代表私有方法
Expand Down
19 changes: 19 additions & 0 deletions docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,22 @@ export const testModel = defineModel('test', {
},
});
```

## onDestroy

模型数据从 store 卸载时的回调通知。onDestroy 事件只针对[局部模型](/advanced?id=局部模型),即通过`useIsolate`这个 hooks api 创建的模型才会触发,因为局部模型是跟随组件一起创建和销毁的。

注意,当触发 onDestroy 回调时,模型已经被卸载了,所以无法再拿到当前数据,而且`this`上下文也被限制使用了。

```typescript
import { defineModel } from 'foca';

export const testModel = defineModel('test', {
initialState: { count: 0 },
events: {
onDestroy(modelName) {
console.log('Destroyed', modelName);
},
},
});
```
6 changes: 3 additions & 3 deletions src/actions/loading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ export const isLoadingAction = (action: AnyAction): action is LoadingAction => {
);
};

export interface DestroyLodingAction extends Action<typeof DESTROY_LOADING> {
export interface DestroyLoadingAction extends Action<typeof DESTROY_LOADING> {
model: string;
}

export const isDestroyLoadingAction = (
action: AnyAction,
): action is DestroyLodingAction => {
const tester = action as DestroyLodingAction;
): action is DestroyLoadingAction => {
const tester = action as DestroyLoadingAction;
return tester.type === DESTROY_LOADING && !!tester.model;
};
149 changes: 149 additions & 0 deletions src/api/use-isolate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { DestroyLoadingAction, DESTROY_LOADING } from '../actions/loading';
import { loadingStore } from '../store/loadingStore';
import { ModelStore, modelStore } from '../store/modelStore';
import { cloneModel } from '../model/cloneModel';
import { Model } from '../model/types';

let globalCounter = 0;
const hotReloadCounter: Record<string, number> = {};

/**
* 创建局部模型,它的数据变化不会影响到全局模型,而且会随着组件一起销毁
* ```typescript
* const testModel = defineModel({
* initialState,
* events: {
* onInit() {}, // 挂载回调
* onDestroy() {}, // 销毁回调
* }
* });
*
* function App() {
* const model = useIsolate(testModel);
* const state = useModel(model);
*
* return <p>Hello</p>;
* }
* ```
*/
export const useIsolate = <
State extends object = object,
Action extends object = object,
Effect extends object = object,
Computed extends object = object,
>(
globalModel: Model<string, State, Action, Effect, Computed>,
): Model<string, State, Action, Effect, Computed> => {
const initialCount = useState(() => globalCounter++)[0];
const uniqueName =
process.env.NODE_ENV === 'production'
? useProdName(globalModel.name, initialCount)
: useDevName(globalModel, initialCount, new Error());

const localModelRef = useRef<typeof globalModel>();
useEffect(() => {
localModelRef.current = isolateModel;
});

// 热更时会重新执行useMemo,因此只能用ref
const isolateModel =
localModelRef.current?.name === uniqueName
? localModelRef.current
: cloneModel(uniqueName, globalModel);

return isolateModel;
};

const useProdName = (modelName: string, count: number) => {
const uniqueName = `@isolate:${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 = (model: Model, 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\\.)?${useIsolate.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 'Component';
}, [err.stack]);

/**
* 模型文件重新保存时组件会导入新的对象,需根据这个特性重新克隆模型
*/
const globalModelRef = useRef<{ model?: Model; count: number }>({ count: 0 });
useEffect(() => {
if (globalModelRef.current.model !== model) {
globalModelRef.current = { model, count: ++globalModelRef.current.count };
}
});

const uniqueName = `@isolate:${model.name}:${componentName}#${count}-${
globalModelRef.current.count +
Number(globalModelRef.current.model !== model)
}`;

/**
* 计算热更次数,如果停止热更,说明组件被卸载
*/
useMemo(() => {
hotReloadCounter[uniqueName] ||= 0;
++hotReloadCounter[uniqueName];
}, [uniqueName]);

/**
* 热更新时会重新执行一次useEffect
* setTimeout可以让其他useEffect有充分的时间使用model
*
* 需要卸载模型的场景是:
* 1. 组件hooks增减或者调换顺序(initialCount会自增)
* 2. 组件卸载
* 3. model.name变更
* 4. model逻辑变更
*/
useEffect(() => {
const prev = hotReloadCounter[uniqueName];
return () => {
setTimeout(() => {
const unmounted = prev === hotReloadCounter[uniqueName];
unmounted && unmountModel(uniqueName);
});
};
}, [uniqueName]);

return uniqueName;
};

const unmountModel = (modelName: string) => {
ModelStore.removeReducer.call(modelStore, modelName);
loadingStore.dispatch<DestroyLoadingAction>({
type: DESTROY_LOADING,
model: modelName,
});
};
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export { useModel } from './model/useModel';
export { useLoading } from './api/useLoading';
export { getLoading } from './api/getLoading';
export { useComputed } from './api/use-computed';
export { useIsolate } from './api/use-isolate';
export { connect } from './redux/connect';

// 入口使用
Expand Down
15 changes: 14 additions & 1 deletion src/model/defineModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ export const defineModel = <
}

if (events) {
const { onInit, onChange } = events;
const { onInit, onChange, onDestroy } = events;
const eventCtx: EventCtx<State> = Object.assign(
composeGetter({ name: uniqueName }, getState),
enhancedMethods.external,
Expand All @@ -235,6 +235,19 @@ 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, uniqueName);
}
}),
);
}

if (onInit) {
/**
* 初始化时,用到它的React组件可能还没加载,所以执行async-method时无法判断是否需要保存loading。因此需要一个钩子来处理事件周期
Expand Down
6 changes: 6 additions & 0 deletions src/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,12 @@ export interface Event<State> {
* 上下文 **this** 可以直接调用actions和effects的函数以及computed计算属性,请谨慎执行修改数据的操作以防止死循环。
*/
onChange?: (prevState: State, nextState: State) => void;
/**
* 销毁模型时的回调通知,此时模型已经被销毁。
* 该事件仅在局部模型生效
* @see useIsolate
*/
onDestroy?: (this: never, modelName: string) => void;
}

export interface EventCtx<State extends object>
Expand Down
61 changes: 61 additions & 0 deletions test/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import sleep from 'sleep-promise';
import { cloneModel, defineModel, memoryStorage, store } from '../src';
import { PersistSchema } from '../src/persist/PersistItem';
import { ModelStore } from '../src/store/modelStore';

describe('onInit', () => {
afterEach(() => {
Expand Down Expand Up @@ -186,3 +187,63 @@ 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();
});
});
Loading

0 comments on commit 1f3c8c4

Please sign in to comment.