= {
11 | prototype: T;
12 | };
13 |
--------------------------------------------------------------------------------
/packages/mana-common/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "rootDir": "./src"
5 | },
6 | "include": ["src/**/*"]
7 | }
8 |
--------------------------------------------------------------------------------
/packages/mana-observable/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /** @type {import('eslint').Linter.Config} */
2 | module.exports = {
3 | extends: ['../../.eslintrc.js'],
4 | parserOptions: {
5 | tsconfigRootDir: __dirname,
6 | project: 'tsconfig.json',
7 | },
8 | };
9 |
--------------------------------------------------------------------------------
/packages/mana-observable/.fatherrc.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | esm: 'babel',
3 | cjs: 'babel',
4 | };
5 |
--------------------------------------------------------------------------------
/packages/mana-observable/README.md:
--------------------------------------------------------------------------------
1 | # mana-observable
2 |
3 | Simple class property decorator that makes the class properties observable and can be easily used with React.
4 |
5 | [](https://npmjs.org/package/mana-observable) [](https://npmjs.org/package/mana-observable)
6 |
7 | ## 功能
8 |
9 | 1. 对基于类的数据管理系统提供变更追踪机制。可独立使用,
10 | 2. 配合依赖注入容器使用,为 React 组件提供管理数据。
11 |
12 | ## 安装
13 |
14 | ```bash
15 | npm i mana-observable --save
16 | ```
17 |
18 | ## 用法
19 |
20 | ### 数据 API
21 |
22 | 数据 api 可以独立使用,不需要在依赖注入的环境中
23 |
24 | #### @prop
25 |
26 | 将类中的基础类型属性转化为可追踪属性。对于基础类型 (当前支持数值、字符、布尔) 以及一般的引用类型,当值或引用发生变化时,触发数据的变更。对于部分内置类型 (数组、map、plainObject) 除引用变更外,其管理的内部值或引用发生变化时,也会触发数据变更。
27 |
28 | ```typescript
29 | class Ninja {
30 | @prop name: string = '';
31 | }
32 | ```
33 |
34 | #### watch
35 |
36 | 监听一个带有可追踪属性对象的属性变更
37 |
38 | ```typescript
39 | // 监听某个可追踪属性的变化
40 | watch(ninja, 'name', (obj, prop) => {
41 | // ninja name changed
42 | });
43 | // 监听对象所有可追踪属性的变化
44 | watch(ninja, (obj, prop) => {
45 | // any observable property on ninja changed
46 | });
47 | ```
48 |
49 | #### observable
50 |
51 | 经过 babel 处理的类可能会在实例上定义属性,而属性装饰器作用在原型上,因而无法进行属性转换。所以这里引入新的 API 解决相关的问题,后续希望提供免除该 API 调用的使用方式。
52 |
53 | ```typescript
54 | class Ninja {
55 | @prop name: string = '';
56 | }
57 | ```
58 |
59 | ### React API
60 |
61 | 当前仅提供基于 React hooks 的 API。
62 |
63 | #### useInject
64 |
65 | 在 react 组件中,如果希望使用依赖注入容器中带有可追踪属性的对象,那么可以使用 useInject 来获取他们。
66 |
67 | ```typescript
68 | @singleton()
69 | class Ninja {
70 | @prop name: string = '';
71 | }
72 |
73 | container.register(Ninja);
74 |
75 | export function NinjaRender() {
76 | const ninja = useInject(Ninja);
77 | return {ninja.name}
;
78 | }
79 | ```
80 |
81 | #### useObserve
82 |
83 | 为了精确控制每个 React 组件只因为自己访问的可追踪属性变更而进行 update,也为了在子组件内提供 hook 的创建时机,通过除 useInject 外方式获取到的对象,应当通过 useObserve 进行处理,重置其作用组件更新的范围。
84 |
85 | ```typescript
86 | export function NinjaName(props: { ninja: Ninja }) {
87 | const ninja = useObserve(props.ninja);
88 | return {ninja.name}
;
89 | }
90 | ```
91 |
92 | ### getOrigin
93 |
94 | 在 React 组件中,我们访问的组件并不是原始实例,而是实例的代理,如果在 API 调用等环节需要获取原始对象(例如作为参数传递给其他 API),需要通过调用 getOrigin 方法获得。
95 |
--------------------------------------------------------------------------------
/packages/mana-observable/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mana-observable",
3 | "keywords": [
4 | "mana",
5 | "observable",
6 | "class",
7 | "react"
8 | ],
9 | "description": "Simple class property decorator that makes the class properties observable and can be easily used with React",
10 | "version": "0.3.2",
11 | "typings": "lib/index.d.ts",
12 | "main": "lib/index.js",
13 | "module": "es/index.js",
14 | "license": "MIT",
15 | "files": [
16 | "package.json",
17 | "README.md",
18 | "dist",
19 | "es",
20 | "lib",
21 | "src"
22 | ],
23 | "dependencies": {
24 | "mana-common": "^0.3.2",
25 | "mana-syringe": "^0.3.2"
26 | },
27 | "peerDependencies": {
28 | "react": ">=16.9.0"
29 | },
30 | "scripts": {
31 | "prepare": "yarn run clean && yarn run build",
32 | "lint": "manarun lint",
33 | "clean": "manarun clean",
34 | "build": "manarun build",
35 | "watch": "manarun watch"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/context.spec.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 |
3 | import 'reflect-metadata';
4 |
5 | import type { ErrorInfo, ReactNode } from 'react';
6 | import React from 'react';
7 | import assert from 'assert';
8 | import { defaultObservableContext, ObservableContext } from './context';
9 | import { GlobalContainer, inject } from 'mana-syringe';
10 | import { singleton } from 'mana-syringe';
11 | import { useInject } from './context';
12 | import renderer, { act } from 'react-test-renderer';
13 | import { prop } from './decorator';
14 | import { useObserve } from './hooks';
15 | import { getOrigin } from './utils';
16 |
17 | console.error = () => {};
18 |
19 | class ErrorBoundary extends React.Component<{ children?: ReactNode }> {
20 | state: { error?: Error; errorInfo?: ErrorInfo } = {
21 | error: undefined,
22 | errorInfo: undefined,
23 | };
24 | componentDidCatch(error: Error, errorInfo: ErrorInfo) {
25 | this.setState({ error, errorInfo });
26 | }
27 | render(): React.ReactNode {
28 | if (this.state.error) {
29 | return (
30 |
31 | {this.state.error && this.state.error.toString()}
32 |
33 | {this.state.errorInfo?.componentStack}
34 |
35 | );
36 | }
37 | return this.props.children;
38 | }
39 | }
40 |
41 | describe('error context', () => {
42 | it('#without initial', () => {
43 | @singleton()
44 | class FooModel {
45 | @prop() info: number = 1;
46 | }
47 | const ErrorRender = () => {
48 | const foo = useInject(FooModel);
49 | return {foo.info}
;
50 | };
51 | const component = renderer.create(
52 |
53 |
54 | ,
55 | );
56 | const json: any = component.toJSON();
57 | assert(
58 | json.children.find(
59 | (item: any) =>
60 | typeof item === 'string' && item.includes('please check the context settings'),
61 | ),
62 | );
63 | });
64 | });
65 |
66 | describe('context', () => {
67 | it('#provider', done => {
68 | @singleton()
69 | class Foo {
70 | @prop() info: number = 1;
71 | }
72 | const container = GlobalContainer.createChild();
73 | container.register(Foo);
74 | const ContextRender = () => {
75 | const foo = useInject(Foo);
76 | return {foo.info}
;
77 | };
78 | let component: renderer.ReactTestRenderer;
79 | act(() => {
80 | component = renderer.create(
81 | container }}>
82 |
83 | ,
84 | );
85 | });
86 | act(() => {
87 | defaultObservableContext.config({
88 | getContainer: () => GlobalContainer,
89 | });
90 | const json: any = component.toJSON();
91 | assert(json && json.children.includes('1'));
92 | done();
93 | });
94 | });
95 |
96 | it('#use inject', done => {
97 | @singleton()
98 | class FooModel {
99 | @prop() info: number = 1;
100 | }
101 | GlobalContainer.register(FooModel);
102 | const FooRender = () => {
103 | const foo = useInject(FooModel);
104 | return {foo.info}
;
105 | };
106 | const FooRender2 = () => {
107 | const foo = useInject(FooModel);
108 | return {foo.info}
;
109 | };
110 | let component: renderer.ReactTestRenderer;
111 | act(() => {
112 | component = renderer.create(
113 | <>
114 |
115 |
116 | >,
117 | );
118 | });
119 | act(() => {
120 | const json: any = component.toJSON();
121 | assert(json && json.find((item: any) => item.children.includes('1')));
122 | done();
123 | });
124 | });
125 |
126 | it('#useInject effects ', done => {
127 | @singleton()
128 | class Bar {
129 | @prop() info: number = 0;
130 | }
131 | @singleton()
132 | class Foo {
133 | @prop() info: number = 0;
134 | @inject(Bar) bar!: Bar;
135 | }
136 | GlobalContainer.register(Foo);
137 | GlobalContainer.register(Bar);
138 |
139 | let fooTimes = 0;
140 | let barTimes = 0;
141 | let barInfoTimes = 0;
142 | let dispatchTimes = 0;
143 |
144 | const FooRender = () => {
145 | const foo = useInject(Foo);
146 | const [, dispatch] = React.useReducer<(prevState: any, action: any) => any>(() => {}, {});
147 | React.useEffect(() => {
148 | fooTimes += 1;
149 | }, [foo]);
150 | React.useEffect(() => {
151 | barTimes += 1;
152 | }, [foo.bar]);
153 | React.useEffect(() => {
154 | barInfoTimes += 1;
155 | }, [foo.bar.info]);
156 | React.useEffect(() => {
157 | dispatchTimes += 1;
158 | }, [dispatch]);
159 | return (
160 |
161 | {foo.info} {foo.bar.info}
162 |
163 | );
164 | };
165 | let component: renderer.ReactTestRenderer;
166 | act(() => {
167 | component = renderer.create(
168 | <>
169 |
170 | >,
171 | );
172 |
173 | const json = component.toJSON();
174 | assert(json === null);
175 | });
176 | act(() => {
177 | GlobalContainer.get(Foo).info = 1;
178 | GlobalContainer.get(Foo).bar.info = 1;
179 | });
180 | act(() => {
181 | const json = component.toJSON();
182 | assert(!(json instanceof Array) && json && json.children?.includes('1'));
183 | assert(fooTimes === 1);
184 | assert(barTimes === 1);
185 | assert(barInfoTimes === 2);
186 | assert(dispatchTimes === 1);
187 | done();
188 | });
189 | });
190 | it('#use observe', done => {
191 | class Bar {
192 | @prop() info: number = 1;
193 | }
194 | @singleton()
195 | class FooModel {
196 | @prop() bar?: Bar;
197 | set() {
198 | this.bar = new Bar();
199 | }
200 | }
201 | GlobalContainer.register(FooModel);
202 | const FooRender = () => {
203 | const foo = useInject(FooModel);
204 | const bar = useObserve(foo.bar);
205 | return {bar && bar.info}
;
206 | };
207 | let component: renderer.ReactTestRenderer;
208 | const fooModel = GlobalContainer.get(FooModel);
209 | act(() => {
210 | component = renderer.create(
211 | <>
212 |
213 | >,
214 | );
215 |
216 | const json = component.toJSON();
217 | assert(json === null);
218 | });
219 | act(() => {
220 | fooModel.set();
221 | });
222 | act(() => {
223 | const json = component.toJSON();
224 | assert(!(json instanceof Array) && json && json.children?.find(item => item === '1'));
225 | done();
226 | });
227 | });
228 |
229 | it('#use inject onChange', done => {
230 | @singleton()
231 | class FooModel {
232 | @prop() info: number = 0;
233 | @prop() info1: number = 1;
234 | getInfo(): number {
235 | return this.info;
236 | }
237 | }
238 | GlobalContainer.register(FooModel);
239 | const fooInstance = GlobalContainer.get(FooModel);
240 | const FooRender = () => {
241 | const foo = useInject(FooModel);
242 | React.useEffect(() => {
243 | assert(fooInstance !== foo);
244 | assert(fooInstance === getOrigin(foo));
245 | foo.info += 1;
246 | foo.info1 += 1;
247 | act(() => {
248 | foo.info1 += 1;
249 | });
250 | }, [foo]);
251 | return (
252 |
253 | {foo.info}
254 | {foo.info1}
255 | {foo.getInfo()}
256 |
257 | );
258 | };
259 | let component: renderer.ReactTestRenderer;
260 | act(() => {
261 | component = renderer.create();
262 | });
263 | setTimeout(() => {
264 | const json: any = component.toJSON();
265 | assert(json && json.children.includes('3'));
266 | assert(json && json.children.includes('1'));
267 | done();
268 | }, 100);
269 | });
270 |
271 | it('#computed property with this', done => {
272 | @singleton()
273 | class FooModel {
274 | @prop() info: number[] = [];
275 | get length(): number {
276 | return this.info.length;
277 | }
278 | }
279 | GlobalContainer.register(FooModel);
280 | const fooInstance = GlobalContainer.get(FooModel);
281 | const FooRender = () => {
282 | const foo = useInject(FooModel);
283 | return {foo.length}
;
284 | };
285 | let component: renderer.ReactTestRenderer;
286 | act(() => {
287 | component = renderer.create();
288 | });
289 | act(() => {
290 | fooInstance.info.push(1);
291 | });
292 | setTimeout(() => {
293 | const json: any = component.toJSON();
294 | assert(json && json.children.includes('1'));
295 | done();
296 | }, 100);
297 | });
298 |
299 | it('#indirect inject', done => {
300 | @singleton()
301 | class Foo {
302 | @prop() info: number = 0;
303 | }
304 | @singleton()
305 | class Bar {
306 | constructor(@inject(Foo) public foo: Foo) {}
307 | }
308 | GlobalContainer.register(Foo);
309 | GlobalContainer.register(Bar);
310 | const FooRender = () => {
311 | const bar = useInject(Bar);
312 | return {bar.foo.info}
;
313 | };
314 | let component: renderer.ReactTestRenderer;
315 | act(() => {
316 | component = renderer.create(
317 | <>
318 |
319 | >,
320 | );
321 | });
322 | const fooInstance = GlobalContainer.get(Foo);
323 | act(() => {
324 | fooInstance.info = 1;
325 | });
326 | setTimeout(() => {
327 | const json: any = component.toJSON();
328 | assert(json && json.children.includes('1'));
329 | done();
330 | }, 100);
331 | });
332 | });
333 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/context.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import type { Observable } from './core';
3 | import { useObserve } from './hooks';
4 |
5 | export type ContextConfig = {
6 | context: T;
7 | };
8 |
9 | export const defaultContainerContext: Observable.ContainerContext = {
10 | getContainer: () => undefined,
11 | };
12 | export class ObservableContextImpl implements Observable.ContainerContext {
13 | protected context: Observable.ContainerContext = defaultContainerContext;
14 | config(info: Observable.ContainerContext): void {
15 | this.context = info;
16 | }
17 | getContainer = (): Observable.Container | undefined => this.context.getContainer();
18 | }
19 |
20 | export const defaultObservableContext = new ObservableContextImpl();
21 |
22 | export const ObservableContext =
23 | React.createContext(defaultObservableContext);
24 |
25 | export function useInject(identifier: Observable.Token): T {
26 | const { getContainer } = React.useContext(ObservableContext);
27 | const obj = React.useMemo(() => {
28 | const container = getContainer();
29 | if (!container) {
30 | throw new Error('Can not find container in context, please check the context settings.');
31 | }
32 | return container.get(identifier);
33 | }, [getContainer, identifier]);
34 | return useObserve(obj);
35 | }
36 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/core.ts:
--------------------------------------------------------------------------------
1 | import type { Abstract, Newable } from 'mana-common';
2 |
3 | /* eslint-disable @typescript-eslint/no-explicit-any */
4 | export namespace ObservableSymbol {
5 | export const Reactor = Symbol('Reactor');
6 | export const Tracker = Symbol('Tracker');
7 | export const Notifier = Symbol('Notifier');
8 | export const Observable = Symbol('Observable');
9 | export const ObservableProperties = Symbol('ObservableProperties');
10 | export const Self = Symbol('Self');
11 | }
12 |
13 | export type Notify = (target?: any, prop?: any) => void;
14 |
15 | export namespace Observable {
16 | export type Container = {
17 | get: (identifier: Token) => T;
18 | createChild: () => Container;
19 | };
20 | export type Token = string | symbol | Newable | Abstract;
21 | export type ContainerContext = {
22 | getContainer: () => Container | undefined;
23 | };
24 | }
25 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/decorator.spec.ts:
--------------------------------------------------------------------------------
1 | import assert from 'assert';
2 | import { ObservableProperties } from './utils';
3 | import { prop } from './decorator';
4 |
5 | describe('decorator', () => {
6 | it('#prop', () => {
7 | class Foo {
8 | @prop()
9 | name?: string;
10 | }
11 |
12 | class FooExt extends Foo {
13 | @prop()
14 | info?: string;
15 | }
16 | class FooExtExt extends FooExt {}
17 | const foo = new Foo();
18 | const properties = ObservableProperties.getOwn(Foo);
19 | assert(properties?.length === 1 && properties.includes('name'));
20 | const extProperties = ObservableProperties.getOwn(FooExt);
21 | assert(extProperties?.length === 2 && extProperties.includes('info'));
22 | const extextProperties = ObservableProperties.get(FooExtExt);
23 | assert(extextProperties?.length === 2);
24 | const instanceProperties = ObservableProperties.find(foo);
25 | assert(instanceProperties?.length === 1 && instanceProperties.includes('name'));
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/decorator.ts:
--------------------------------------------------------------------------------
1 | import { ObservableProperties } from './utils';
2 |
3 | /**
4 | * Define observable property
5 | */
6 | export function prop() {
7 | return (target: Record, propertyKey: string) => {
8 | ObservableProperties.add(target.constructor, propertyKey);
9 | };
10 | }
11 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/hooks.spec.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 |
3 | import 'reflect-metadata';
4 | import React, { useEffect } from 'react';
5 | import assert from 'assert';
6 | import { useObserve } from './hooks';
7 | import renderer, { act } from 'react-test-renderer';
8 | import { observable, useObservableState } from '.';
9 | import { prop } from './decorator';
10 |
11 | describe('use', () => {
12 | it('#useObserve basic ', done => {
13 | class Foo {
14 | @prop() info: number = 0;
15 | }
16 | const SINGLETON_FOO = new Foo();
17 | const FooRender = () => {
18 | const foo = useObserve(SINGLETON_FOO);
19 | return {foo && foo.info}
;
20 | };
21 | let component: renderer.ReactTestRenderer;
22 | act(() => {
23 | component = renderer.create(
24 | <>
25 |
26 | >,
27 | );
28 |
29 | const json = component.toJSON();
30 | assert(json === null);
31 | });
32 | act(() => {
33 | SINGLETON_FOO.info = 1;
34 | });
35 | act(() => {
36 | const json = component.toJSON();
37 | assert(!(json instanceof Array) && json && json.children?.find(item => item === '1'));
38 | done();
39 | });
40 | });
41 |
42 | it('#useObserve effects ', done => {
43 | class Foo {
44 | @prop() info: number = 0;
45 | }
46 | const SINGLETON_FOO = new Foo();
47 | let times = 0;
48 | let infoTimes = 0;
49 | const FooRender = () => {
50 | const foo = useObserve(SINGLETON_FOO);
51 | React.useEffect(() => {
52 | times += 1;
53 | }, [foo]);
54 | React.useEffect(() => {
55 | infoTimes += 1;
56 | }, [foo.info]);
57 | return {foo && foo.info}
;
58 | };
59 | let component: renderer.ReactTestRenderer;
60 | act(() => {
61 | component = renderer.create(
62 | <>
63 |
64 | >,
65 | );
66 |
67 | const json = component.toJSON();
68 | assert(json === null);
69 | });
70 | act(() => {
71 | SINGLETON_FOO.info = 1;
72 | });
73 | act(() => {
74 | const json = component.toJSON();
75 | assert(!(json instanceof Array) && json && json.children?.find(item => item === '1'));
76 | assert(times === 1);
77 | assert(infoTimes === 2);
78 | done();
79 | });
80 | });
81 | it('#useObserve array', done => {
82 | class Foo {
83 | @prop() list: number[] = [];
84 | }
85 | const foo = new Foo();
86 | let renderTimes = 0;
87 | const FooRender = () => {
88 | const f = useObserve(foo);
89 | renderTimes += 1;
90 | return {f.list.length}
;
91 | };
92 | let component: renderer.ReactTestRenderer;
93 | act(() => {
94 | component = renderer.create(
95 | <>
96 |
97 | >,
98 | );
99 | const json = component.toJSON();
100 | assert(json === null);
101 | });
102 | act(() => {
103 | for (let index = 0; index < 100; index++) {
104 | foo.list.push(index);
105 | }
106 | });
107 | act(() => {
108 | assert(renderTimes < 25);
109 | done();
110 | });
111 | });
112 | it('#useObserve deep array ', done => {
113 | class Foo {
114 | @prop() info = '';
115 | }
116 | class Bar {
117 | @prop() list: Foo[] = [];
118 | }
119 | const SINGLETON_BAR = new Bar();
120 | const foo = new Foo();
121 | SINGLETON_BAR.list.push(foo);
122 | const FooRender = () => {
123 | const bar = useObserve(SINGLETON_BAR);
124 | return {bar.list.filter(item => item.info.length > 0).length}
;
125 | };
126 | let component: renderer.ReactTestRenderer;
127 | act(() => {
128 | component = renderer.create(
129 | <>
130 |
131 | >,
132 | );
133 |
134 | const json = component.toJSON();
135 | assert(json === null);
136 | });
137 | act(() => {
138 | foo.info = 'a';
139 | });
140 | act(() => {
141 | const json = component.toJSON();
142 | assert(!(json instanceof Array) && json && json.children?.find(item => item === '1'));
143 | done();
144 | });
145 | });
146 |
147 | it('#useObserve reactable array', done => {
148 | const ARR: any[] = observable([]);
149 | const Render = () => {
150 | const arr = useObserve(ARR);
151 | const arr1 = useObservableState([]);
152 | useEffect(() => {
153 | arr.push('effect');
154 | arr1.push('effect1');
155 | }, [arr, arr1]);
156 | return (
157 |
158 | {JSON.stringify(arr)}
159 | {arr1[0]}
160 | {arr.length}
161 |
162 | );
163 | };
164 | let component: renderer.ReactTestRenderer;
165 | act(() => {
166 | component = renderer.create(
167 | <>
168 |
169 | >,
170 | );
171 | const json = component.toJSON();
172 | assert(json === null);
173 | });
174 | act(() => {
175 | ARR.push('a');
176 | });
177 | act(() => {
178 | const json = component.toJSON();
179 | assert(
180 | !(json instanceof Array) &&
181 | json &&
182 | json.children?.includes('2') &&
183 | json.children?.includes('effect1'),
184 | );
185 | done();
186 | });
187 | });
188 |
189 | it('#useObserve deep arr', done => {
190 | class Bar {
191 | @prop() name: string = '';
192 | }
193 | class Foo {
194 | @prop() arr: Bar[] = [];
195 | }
196 | const foo = new Foo();
197 | const Render = () => {
198 | const trackableFoo = useObserve(foo);
199 | useEffect(() => {
200 | trackableFoo.arr.push(new Bar());
201 | trackableFoo.arr.push(new Bar());
202 | }, [trackableFoo]);
203 |
204 | return {trackableFoo.arr.map(item => item.name)}
;
205 | };
206 | let component: renderer.ReactTestRenderer;
207 | act(() => {
208 | component = renderer.create(
209 | <>
210 |
211 | >,
212 | );
213 | const json = component.toJSON();
214 | assert(json === null);
215 | });
216 | act(() => {
217 | foo.arr[0] && (foo.arr[0].name = 'a');
218 | });
219 | act(() => {
220 | const json = component.toJSON();
221 | assert(!(json instanceof Array) && json && json.children?.includes('a'));
222 | done();
223 | });
224 | });
225 | });
226 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/hooks.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import * as React from 'react';
3 | import { Tracker } from './tracker';
4 | import { Observability } from './utils';
5 |
6 | interface Action {
7 | key: keyof T;
8 | value: any;
9 | }
10 | function isAction(data: Record | undefined): data is Action {
11 | return !!data && data.key !== undefined && data.value !== undefined;
12 | }
13 | const reducer = (state: Partial, part: Action | undefined) => {
14 | if (isAction(part)) {
15 | return { ...state, [part.key]: part.value };
16 | }
17 | return { ...state };
18 | };
19 |
20 | export function useObserve(obj: T): T {
21 | const [, dispatch] = React.useReducer<(prevState: Partial, action: Action) => Partial>(
22 | reducer,
23 | {},
24 | );
25 | if (!Observability.trackable(obj)) {
26 | return obj;
27 | }
28 | return Tracker.track(obj, dispatch);
29 | }
30 |
31 | export function useObservableState(initialValue: T): T {
32 | const object = React.useMemo(() => {
33 | return initialValue;
34 | }, []);
35 | return useObserve(object);
36 | }
37 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './context';
2 | export * from './hooks';
3 | export * from './tracker';
4 | export * from './watch';
5 | export * from './notifier';
6 | export * from './observable';
7 | export * from './reactivity';
8 | export * from './decorator';
9 | export * from './utils';
10 | export * from './core';
11 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/notifier.spec.ts:
--------------------------------------------------------------------------------
1 | import assert from 'assert';
2 | import { observable } from './observable';
3 | import { Notifier } from './notifier';
4 | import { prop } from './decorator';
5 |
6 | describe('tarcker', () => {
7 | it('#create tracker', () => {
8 | class Foo {
9 | @prop() name?: string;
10 | }
11 | class Bar {
12 | name?: string;
13 | }
14 | const foo = observable(new Foo());
15 | const bar = new Bar();
16 | Notifier.find(foo, 'name');
17 | assert(Notifier.find(observable([])));
18 | assert(!!Notifier.find(foo, 'name'));
19 | assert(!Notifier.find(bar, 'name'));
20 | });
21 |
22 | it('#trigger', () => {
23 | class Foo {
24 | @prop() name?: string;
25 | }
26 | const foo = observable(new Foo());
27 | let changed = false;
28 | const notifier = Notifier.find(foo, 'name');
29 | notifier?.onChange(() => {
30 | changed = true;
31 | });
32 | Notifier.trigger(foo, 'name');
33 | assert(changed);
34 | });
35 | it('#dispose tracker', () => {
36 | class Foo {
37 | @prop() name?: string;
38 | }
39 | const foo = observable(new Foo());
40 | const tracker = Notifier.find(foo, 'name');
41 | tracker?.dispose();
42 | const newTracker = Notifier.find(foo, 'name');
43 | assert(tracker?.disposed && newTracker !== tracker);
44 | });
45 | it('#tracker notify', done => {
46 | class Foo {
47 | @prop() name?: string;
48 | }
49 | const foo = observable(new Foo());
50 | const tracker = Notifier.find(foo, 'name');
51 | tracker?.onChange(() => {
52 | done();
53 | });
54 | assert(!!Notifier.find(foo, 'name'));
55 | tracker?.notify(foo, 'name');
56 | });
57 | it('#tracker changed', done => {
58 | class Foo {
59 | @prop() name?: string;
60 | }
61 | const foo = observable(new Foo());
62 | const tracker = Notifier.find(foo, 'name');
63 | tracker?.onChange(() => {
64 | done();
65 | });
66 | assert(!!Notifier.find(foo, 'name'));
67 | tracker?.notify(foo, 'name');
68 | });
69 | it('#tracker once', done => {
70 | class Foo {
71 | @prop() name?: string;
72 | }
73 | const foo = observable(new Foo());
74 | const tracker = Notifier.find(foo, 'name');
75 | let times = 0;
76 | let once = 0;
77 | tracker?.once(() => {
78 | once += 1;
79 | });
80 | tracker?.onChange(() => {
81 | times += 1;
82 | if (times == 2) {
83 | assert(once == 1);
84 | done();
85 | }
86 | });
87 | assert(!!Notifier.find(foo, 'name'));
88 | tracker?.notify(foo, 'name');
89 | tracker?.notify(foo, 'name');
90 | });
91 | });
92 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/notifier.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import 'reflect-metadata';
3 | import type { Notify } from './core';
4 | import { ObservableSymbol } from './core';
5 | import { Emitter } from 'mana-common';
6 | import type { Disposable } from 'mana-common';
7 | import { Observability } from './utils';
8 |
9 | function setNotifier(tracker: Notifier, obj: Record, property?: string | symbol) {
10 | if (property === undefined) {
11 | Reflect.defineMetadata(ObservableSymbol.Notifier, tracker, obj);
12 | } else {
13 | Reflect.defineMetadata(ObservableSymbol.Notifier, tracker, obj, property);
14 | }
15 | }
16 |
17 | function getNotifier(obj: Record, property?: string | symbol): Notifier | undefined {
18 | if (property === undefined) {
19 | return Reflect.getMetadata(ObservableSymbol.Notifier, obj);
20 | } else {
21 | return Reflect.getMetadata(ObservableSymbol.Notifier, obj, property);
22 | }
23 | }
24 |
25 | export interface Notification {
26 | target: T;
27 | prop?: any;
28 | }
29 | export class Notifier implements Disposable {
30 | protected changedEmitter = new Emitter();
31 | disposed: boolean = false;
32 | get onChange() {
33 | return this.changedEmitter.event;
34 | }
35 |
36 | dispose() {
37 | this.changedEmitter.dispose();
38 | this.disposed = true;
39 | }
40 |
41 | once(trigger: Notify): Disposable {
42 | const toDispose = this.onChange(e => {
43 | trigger(e.target, e.prop);
44 | toDispose.dispose();
45 | });
46 | return toDispose;
47 | }
48 |
49 | notify(target: any, prop?: any): void {
50 | this.changedEmitter.fire({ target, prop });
51 | if (prop) {
52 | Notifier.trigger(target);
53 | }
54 | }
55 |
56 | static trigger(target: any, prop?: any): void {
57 | const exist = getNotifier(target, prop);
58 | if (exist) {
59 | exist.notify(target, prop);
60 | }
61 | }
62 | static getOrCreate(target: any, prop?: any): Notifier {
63 | const origin = Observability.getOrigin(target);
64 | const exist = getNotifier(target, prop);
65 | if (!exist || exist.disposed) {
66 | const notifier = new Notifier();
67 | setNotifier(notifier, origin, prop);
68 | return notifier;
69 | }
70 | return exist;
71 | }
72 | static find(target: any, prop?: any): Notifier | undefined {
73 | if (!Observability.notifiable(target, prop)) {
74 | return undefined;
75 | }
76 | return Notifier.getOrCreate(target, prop);
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/observable.spec.tsx:
--------------------------------------------------------------------------------
1 | import 'react';
2 | import assert from 'assert';
3 | import { observable } from './observable';
4 | import { Notifier } from './notifier';
5 | import { Observability, ObservableProperties } from './utils';
6 | import { prop } from './decorator';
7 | import { Reactable } from './reactivity';
8 |
9 | describe('observable', () => {
10 | it('#observable properties', () => {
11 | class Foo {
12 | @prop() name: string = '';
13 | }
14 | const foo = observable(new Foo());
15 | const instanceBasic = observable(foo);
16 | const nullInstance = observable(null as any);
17 | assert(!Observability.is(nullInstance));
18 | assert(Observability.is(instanceBasic));
19 | assert(Observability.is(instanceBasic, 'name'));
20 | assert(ObservableProperties.get(instanceBasic)?.includes('name'));
21 | });
22 | it('#extends properties', () => {
23 | class ClassBasic {
24 | @prop() name: string = '';
25 | name1: string = '';
26 | name2: string = '';
27 | }
28 | class ClassBasic1 extends ClassBasic {
29 | @prop() name1: string = '';
30 | }
31 | class ClassBasic2 extends ClassBasic1 {
32 | name1: string = '';
33 | @prop() name2: string = '';
34 | }
35 | const instanceBasic = observable(new ClassBasic());
36 | const instanceBasic1 = observable(new ClassBasic1());
37 | const instanceBasic2 = observable(new ClassBasic2());
38 | assert(ObservableProperties.get(instanceBasic)?.includes('name'));
39 | assert(ObservableProperties.get(instanceBasic)?.length === 1);
40 | assert(ObservableProperties.get(instanceBasic1)?.includes('name'));
41 | assert(ObservableProperties.get(instanceBasic1)?.includes('name1'));
42 | assert(ObservableProperties.get(instanceBasic1)?.length === 2);
43 | assert(ObservableProperties.get(instanceBasic2)?.includes('name'));
44 | assert(ObservableProperties.get(instanceBasic2)?.includes('name1'));
45 | assert(ObservableProperties.get(instanceBasic2)?.includes('name2'));
46 | assert(ObservableProperties.get(instanceBasic2)?.length === 3);
47 | });
48 | it('#basic usage', () => {
49 | class ClassBasic {
50 | @prop() name: string = '';
51 | }
52 | const instanceBasic = observable(new ClassBasic());
53 | let changed = false;
54 | const tracker = Notifier.find(instanceBasic, 'name');
55 | tracker?.onChange(() => {
56 | changed = true;
57 | });
58 | instanceBasic.name = 'a';
59 | instanceBasic.name = 'b';
60 | assert(instanceBasic.name === 'b');
61 | assert(changed);
62 | });
63 | it('#array usage', () => {
64 | class ClassArray {
65 | @prop() list: string[] = [];
66 | }
67 | const instanceArray = observable(new ClassArray());
68 | instanceArray.list = instanceArray.list;
69 | instanceArray.list = [];
70 | let changed = false;
71 | if (Reactable.is(instanceArray.list)) {
72 | const reactor = Reactable.getReactor(instanceArray.list);
73 | reactor.onChange(() => {
74 | changed = true;
75 | });
76 | }
77 | const tracker = Notifier.find(instanceArray, 'list');
78 | tracker?.onChange(() => {
79 | changed = true;
80 | });
81 | instanceArray.list.push('');
82 | assert(changed);
83 | instanceArray.list = [];
84 | assert(instanceArray.list.length === 0);
85 | });
86 |
87 | it('#child class', done => {
88 | class Foo {
89 | @prop() fooName: string = 'foo';
90 | }
91 | class Bar extends Foo {
92 | @prop() barName?: string;
93 | @prop() barInfo?: string;
94 | }
95 | const bar = observable(new Bar());
96 | let changed = false;
97 | const tracker = Notifier.find(bar, 'fooName');
98 | tracker?.onChange(() => {
99 | changed = true;
100 | assert(changed);
101 | done();
102 | });
103 | bar.fooName = 'foo name';
104 | });
105 |
106 | it('#shared properties', () => {
107 | class Foo {
108 | @prop() list: string[] = [];
109 | }
110 | class Bar {
111 | @prop() list: string[] = [];
112 | }
113 | const foo = observable(new Foo());
114 | const bar = observable(new Bar());
115 | foo.list = bar.list;
116 | let changed = false;
117 | const notifier = Notifier.find(bar, 'list');
118 | notifier?.onChange(() => {
119 | changed = true;
120 | });
121 | foo.list.push('');
122 | assert(changed);
123 | });
124 |
125 | it('#observable reactbale', () => {
126 | const v: any[] = [];
127 | class Foo {}
128 | const foo = new Foo();
129 | const reactable = observable(v);
130 | const reactable1 = observable(v);
131 | const reactable2 = observable(reactable);
132 | assert(reactable1 === reactable2);
133 | assert(reactable === reactable1);
134 | const observableFoo = observable(foo);
135 | assert(Reactable.is(reactable));
136 | assert(Observability.is(v));
137 | assert(observableFoo === foo);
138 | let changed = false;
139 | const notifier = Notifier.find(reactable);
140 | notifier?.onChange(() => {
141 | changed = true;
142 | });
143 | reactable1.push('');
144 | assert(changed);
145 | });
146 | });
147 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/observable.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import type { Reactor } from './reactivity';
3 | import { Reactable } from './reactivity';
4 | import { InstanceValue, ObservableProperties, Observability } from './utils';
5 | import { Notifier } from './notifier';
6 |
7 | //
8 | export function listenReactor(
9 | reactor: Reactor,
10 | onChange: () => void,
11 | target: any,
12 | property?: string,
13 | ) {
14 | const toDispose = Observability.getDisposable(reactor, target, property);
15 | if (toDispose) {
16 | toDispose.dispose();
17 | }
18 | const disposable = reactor.onChange(() => {
19 | onChange();
20 | });
21 | Observability.setDisposable(reactor, disposable, target, property);
22 | }
23 |
24 | // redefine observable properties
25 | export function defineProperty(target: any, property: string, defaultValue?: any) {
26 | /**
27 | * notify reactor when property changed
28 | */
29 | const onChange = () => {
30 | Notifier.trigger(target, property);
31 | };
32 | /**
33 | * set observable property value and register onChange listener
34 | * @param value
35 | * @param reactor
36 | */
37 | const setValue = (value: any, reactor: Reactor | undefined) => {
38 | InstanceValue.set(target, property, value);
39 | if (reactor) {
40 | listenReactor(reactor, onChange, target, property);
41 | }
42 | };
43 | const initialValue = target[property] === undefined ? defaultValue : target[property];
44 | setValue(...Reactable.transform(initialValue));
45 | // property getter
46 | const getter = function getter(this: any): void {
47 | const value = Reflect.getMetadata(property, target);
48 | return value;
49 | };
50 | // property setter
51 | const setter = function setter(this: any, value: any): void {
52 | const [tValue, reactor] = Reactable.transform(value);
53 | const oldValue = InstanceValue.get(target, property);
54 | if (Reactable.is(oldValue)) {
55 | const toDispose = Observability.getDisposable(
56 | Reactable.getReactor(oldValue),
57 | target,
58 | property,
59 | );
60 | if (toDispose) {
61 | toDispose.dispose();
62 | }
63 | }
64 | setValue(tValue, reactor);
65 | if (tValue !== oldValue) {
66 | onChange();
67 | }
68 | };
69 | // define property
70 | if (Reflect.deleteProperty(target, property)) {
71 | Reflect.defineProperty(target, property, {
72 | configurable: true,
73 | enumerable: true,
74 | get: getter,
75 | set: setter,
76 | });
77 | }
78 | // mark observable property
79 | ObservableProperties.add(target, property);
80 | Observability.mark(target, property);
81 | Observability.mark(target);
82 | }
83 |
84 | export function observable>(target: T): T {
85 | if (!Observability.trackable(target)) return target;
86 | const properties = ObservableProperties.find(target);
87 | const origin = Observability.getOrigin(target);
88 | if (!properties) {
89 | if (Reactable.canBeReactable(target)) {
90 | const exsit = Reactable.get(origin);
91 | if (exsit) {
92 | return exsit;
93 | }
94 | const onChange = () => {
95 | Notifier.trigger(origin);
96 | };
97 | const [reatableValue, reactor] = Reactable.transform(origin);
98 | if (reactor) {
99 | reactor.onChange(() => {
100 | onChange();
101 | });
102 | }
103 | Observability.mark(origin);
104 | return reatableValue;
105 | }
106 | return target;
107 | }
108 | properties.forEach(property => defineProperty(origin, property));
109 | return origin;
110 | }
111 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/reactivity.spec.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import 'react';
3 | import assert from 'assert';
4 | import { Reactable } from './reactivity';
5 | import { isPlainObject } from 'mana-common';
6 | import { Observability } from './utils';
7 |
8 | describe('reactivity', () => {
9 | it('#can be reactable', () => {
10 | class Foo {}
11 | const a = new Foo();
12 | assert(!Reactable.canBeReactable(a));
13 | assert(!Reactable.canBeReactable(null));
14 | assert(!Reactable.canBeReactable(undefined));
15 | assert(Reactable.canBeReactable([]));
16 | assert(Reactable.canBeReactable({}));
17 | assert(Reactable.canBeReactable(new Map()));
18 | const [arrValue] = Reactable.transform([]);
19 | assert(Reactable.canBeReactable(arrValue));
20 | });
21 | it('#transform base', () => {
22 | const [tValue, reactor] = Reactable.transform(undefined);
23 | assert(tValue === undefined);
24 | assert(reactor === undefined);
25 | const arr = ['a'];
26 | const [arrValue, arrReactor] = Reactable.transform(arr);
27 | const [arrValue1, arrReactor1] = Reactable.transform(arr);
28 | assert(arrReactor);
29 | assert(arrValue !== arr);
30 | assert(arrReactor?.value === arr);
31 | assert(arrValue1 === arrValue);
32 | assert(arrReactor1 === arrReactor);
33 | const [arrValue2, arrReactor2] = Reactable.transform(arrValue);
34 | assert(arrReactor === arrReactor2);
35 | assert(arrValue === arrValue2);
36 | class A {}
37 | const a = new A();
38 | const [objValue, objReactor] = Reactable.transform(a);
39 | assert(!objReactor);
40 | assert(a === objValue);
41 | });
42 |
43 | it('#transform array', () => {
44 | const v: any[] = [];
45 | const [tValue] = Reactable.transform(v);
46 | assert(tValue instanceof Array);
47 | assert(Reactable.is(tValue));
48 | assert(Observability.getOrigin(tValue) === v);
49 | });
50 |
51 | it('#transform map', () => {
52 | const v: Map = new Map();
53 | const [tValue] = Reactable.transform(v);
54 | assert(tValue instanceof Map);
55 | assert(Reactable.is(tValue));
56 | assert(Observability.getOrigin(tValue) === v);
57 | });
58 |
59 | it('#transform plain object', () => {
60 | const v = {};
61 | const [tValue] = Reactable.transform(v);
62 | assert(isPlainObject(tValue));
63 | assert(Reactable.is(tValue));
64 | assert(Observability.getOrigin(tValue) === v);
65 | });
66 |
67 | it('#reactable array', () => {
68 | const v: any[] = [];
69 | const [tValue, reactor] = Reactable.transform(v);
70 | let changedTimes = 0;
71 | if (reactor) {
72 | reactor.onChange(() => {
73 | changedTimes += 1;
74 | });
75 | }
76 | // Pushing brings changes, one is the set value and the other is the set length
77 | tValue.push('a');
78 | tValue.pop();
79 | assert(tValue.length === 0);
80 | assert(changedTimes === 3);
81 | });
82 | it('#reactable map', () => {
83 | const v: Map = new Map();
84 | const [tValue, reactor] = Reactable.transform(v);
85 | let changedTimes = 0;
86 | if (reactor) {
87 | reactor.onChange(() => {
88 | changedTimes += 1;
89 | });
90 | }
91 | tValue.set('a', 'a');
92 | const aValue = tValue.get('a');
93 | assert(aValue === 'a');
94 | assert(tValue.size === 1);
95 | tValue.set('b', 'b');
96 | tValue.delete('a');
97 | tValue.clear();
98 | assert(changedTimes === 4);
99 | });
100 |
101 | it('#reactable plain object', () => {
102 | const v: any = {};
103 | const [tValue, reactor] = Reactable.transform(v);
104 | let changedTimes = 0;
105 | if (reactor) {
106 | reactor.onChange(() => {
107 | changedTimes += 1;
108 | });
109 | }
110 | tValue.a = 'a';
111 | assert(tValue.a === 'a');
112 | tValue.b = 'b';
113 | delete tValue.b;
114 | assert(changedTimes === 3);
115 | });
116 | });
117 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/reactivity.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable prefer-spread */
2 | /* eslint-disable @typescript-eslint/no-explicit-any */
3 | import { Emitter, isPlainObject } from 'mana-common';
4 | import { ObservableSymbol } from './core';
5 | import { Observability } from './utils';
6 |
7 | /**
8 | * Reactor is bound to an reacable object, such as array/map/object.
9 | * Reactor helpers the reacable object to server multiple observable objects.
10 | * It will trigger when the reacable object is changed.
11 | */
12 | export class Reactor {
13 | protected changedEmitter: Emitter;
14 | protected readonly _value: any;
15 | constructor(val: any) {
16 | this.changedEmitter = new Emitter();
17 | this._value = val;
18 | }
19 | get onChange() {
20 | return this.changedEmitter.event;
21 | }
22 | get value() {
23 | return this._value;
24 | }
25 | notify(value: any) {
26 | this.changedEmitter.fire(value);
27 | }
28 | }
29 |
30 | export interface Reactable {
31 | [ObservableSymbol.Reactor]: Reactor;
32 | }
33 |
34 | export namespace Reactable {
35 | export function is(target: any): target is Reactable {
36 | return Observability.trackable(target) && (target as any)[ObservableSymbol.Reactor];
37 | }
38 | export function getReactor(target: Reactable): Reactor {
39 | return target[ObservableSymbol.Reactor];
40 | }
41 | export function set(target: any, value: Reactable): void {
42 | Reflect.defineMetadata(ObservableSymbol.Reactor, value, target);
43 | }
44 |
45 | export function get>(target: T): T & Reactable {
46 | return Reflect.getMetadata(ObservableSymbol.Reactor, target);
47 | }
48 | export function canBeReactable(value: any): boolean {
49 | if (!value) return false;
50 | if (is(value)) {
51 | return true;
52 | }
53 | if (value instanceof Array) {
54 | return true;
55 | }
56 | if (value instanceof Map) {
57 | return true;
58 | }
59 | if (isPlainObject(value)) {
60 | return true;
61 | }
62 | return false;
63 | }
64 | export function transform(value: T): [T, Reactor | undefined] {
65 | let reactor: Reactor | undefined = undefined;
66 | if (!Observability.trackable(value)) return [value, undefined];
67 | if (is(value)) {
68 | reactor = getReactor(value);
69 | return [value, reactor];
70 | }
71 | const exsit = get(value);
72 | if (exsit) {
73 | return [exsit, getReactor(exsit)];
74 | }
75 | let reactable;
76 | if (value instanceof Array) {
77 | reactable = transformArray(value);
78 | }
79 | if (value instanceof Map) {
80 | reactable = transformMap(value);
81 | }
82 | if (isPlainObject(value)) {
83 | reactable = transformPlainObject(value);
84 | }
85 | if (reactable) {
86 | set(value, reactable);
87 | return [reactable, getReactor(reactable)];
88 | }
89 | return [value, undefined];
90 | }
91 |
92 | export function transformArray(toReactable: any[]) {
93 | const reactor = new Reactor(toReactable);
94 | return new Proxy(toReactable, {
95 | get(self: any, prop: string | symbol): any {
96 | if (prop === ObservableSymbol.Reactor) {
97 | return reactor;
98 | }
99 | if (prop === ObservableSymbol.Self) {
100 | return self;
101 | }
102 | const result = Reflect.get(self, prop);
103 | return result;
104 | },
105 | set(self: any, prop: string | symbol, value: any): any {
106 | const result = Reflect.set(self, prop, value);
107 | reactor.notify(value);
108 | return result;
109 | },
110 | });
111 | }
112 |
113 | export function transformPlainObject(toReactable: any) {
114 | const reactor = new Reactor(toReactable);
115 | return new Proxy(toReactable, {
116 | get(self: any, prop: string | symbol): any {
117 | if (prop === ObservableSymbol.Reactor) {
118 | return reactor;
119 | }
120 | if (prop === ObservableSymbol.Self) {
121 | return self;
122 | }
123 | const result = Reflect.get(self, prop);
124 | return result;
125 | },
126 | set(self: any, prop: string | symbol, value: any): any {
127 | const result = Reflect.set(self, prop, value);
128 | reactor.notify(value);
129 | return result;
130 | },
131 | deleteProperty(self: any, prop: string | symbol): boolean {
132 | const result = Reflect.deleteProperty(self, prop);
133 | reactor.notify(undefined);
134 | return result;
135 | },
136 | });
137 | }
138 |
139 | export function transformMap(toReactable: Map) {
140 | const reactor = new Reactor(toReactable);
141 | return new Proxy(toReactable, {
142 | get(self: any, prop: string | symbol): any {
143 | if (prop === ObservableSymbol.Reactor) {
144 | return reactor;
145 | }
146 | if (prop === ObservableSymbol.Self) {
147 | return self;
148 | }
149 | switch (prop) {
150 | case 'set':
151 | return (...args: any) => {
152 | const result = self.set.apply(self, args);
153 | reactor.notify(undefined);
154 | return result;
155 | };
156 | case 'delete':
157 | return (...args: any) => {
158 | const result = self.delete.apply(self, args);
159 | reactor.notify(undefined);
160 | return result;
161 | };
162 | case 'clear':
163 | return (...args: any) => {
164 | const result = (self as Map).clear.apply(self, args);
165 | reactor.notify(undefined);
166 | return result;
167 | };
168 | default:
169 | const result = Reflect.get(self, prop);
170 | if (typeof result === 'function') {
171 | return result.bind(self);
172 | }
173 | return result;
174 | }
175 | },
176 | });
177 | }
178 | }
179 |
180 | export interface ReactiveHandler {
181 | onChange?: (value: any) => any;
182 | }
183 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/tracker.ts:
--------------------------------------------------------------------------------
1 | import type { Disposable } from 'mana-common';
2 | import { isPlainObject } from 'mana-common';
3 | import { getPropertyDescriptor } from 'mana-common';
4 | import { observable } from '.';
5 | import { ObservableSymbol } from './core';
6 | import { Notifier } from './notifier';
7 | import { Reactable } from './reactivity';
8 | import { Observability } from './utils';
9 |
10 | type Act = (...args: any) => void;
11 |
12 | function getValue>(
13 | obj: T,
14 | property: string | symbol,
15 | proxy: T,
16 | notifier?: Notifier,
17 | ) {
18 | if (!notifier) {
19 | const descriptor = getPropertyDescriptor(obj, property);
20 | if (descriptor?.get) {
21 | return descriptor.get.call(proxy);
22 | }
23 | }
24 | return obj[property as any];
25 | }
26 |
27 | export type Trackable = {
28 | [ObservableSymbol.Tracker]: Record;
29 | };
30 |
31 | export namespace Trackable {
32 | export function is(target: any): target is Trackable {
33 | return Observability.trackable(target) && (target as any)[ObservableSymbol.Tracker];
34 | }
35 | export function getOrigin(target: Trackable): any {
36 | return target[ObservableSymbol.Tracker];
37 | }
38 | export function tryGetOrigin(target: any): any {
39 | if (!is(target)) {
40 | return target;
41 | }
42 | return getOrigin(target);
43 | }
44 | }
45 | export namespace Tracker {
46 | export function set = any>(target: T, act: Act, proxy: T) {
47 | Reflect.defineMetadata(act, proxy, target, ObservableSymbol.Tracker);
48 | }
49 | export function get = any>(
50 | target: T,
51 | act: Act,
52 | ): (T & Trackable) | undefined {
53 | return Reflect.getMetadata(act, target, ObservableSymbol.Tracker);
54 | }
55 | export function has = any>(target: T, act: Act) {
56 | return Reflect.hasOwnMetadata(act, target, ObservableSymbol.Tracker);
57 | }
58 |
59 | function handleNotifier>(
60 | notifier: Notifier,
61 | act: Act,
62 | obj: T,
63 | property?: string,
64 | ) {
65 | const lastToDispose: Disposable = Observability.getDisposable(act, obj, property);
66 | if (lastToDispose) {
67 | lastToDispose.dispose();
68 | }
69 | const toDispose = notifier.once(() => {
70 | if (property) {
71 | act({
72 | key: property as keyof T,
73 | value: obj[property],
74 | });
75 | } else {
76 | act(obj);
77 | }
78 | });
79 | Observability.setDisposable(act, toDispose, obj, property);
80 | }
81 |
82 | export function tramsform(toTrack: any, act: Act) {
83 | if (toTrack instanceof Array) {
84 | return transformArray(toTrack, act);
85 | }
86 | if (toTrack instanceof Map) {
87 | return transformMap(toTrack, act);
88 | }
89 | if (isPlainObject(toTrack)) {
90 | return transformPlainObject(toTrack, act);
91 | }
92 | return toTrack;
93 | }
94 | export function transformArray(toTrack: any[], act: Act) {
95 | return new Proxy(toTrack, {
96 | get(target: any, property: string | symbol): any {
97 | const value = target[property];
98 | if (property === ObservableSymbol.Self) {
99 | return value;
100 | }
101 | if (Observability.trackable(value)) {
102 | return track(value, act);
103 | }
104 | return value;
105 | },
106 | });
107 | }
108 |
109 | export function transformPlainObject(toTrack: any, act: Act) {
110 | return new Proxy(toTrack, {
111 | get(target: any, property: string | symbol): any {
112 | const value = target[property];
113 | if (property === ObservableSymbol.Self) {
114 | return value;
115 | }
116 | if (Observability.trackable(value)) {
117 | return track(value, act);
118 | }
119 | return value;
120 | },
121 | });
122 | }
123 |
124 | export function transformMap(toTrack: Map, act: Act) {
125 | return new Proxy(toTrack, {
126 | get(target: any, property: string | symbol): any {
127 | const value = target[property];
128 | if (property === ObservableSymbol.Self) {
129 | return value;
130 | }
131 | if (property === 'get' && typeof value === 'function') {
132 | return function (...args: any[]) {
133 | const innerValue = value.apply(target, args);
134 | if (Observability.trackable(innerValue)) {
135 | return track(innerValue, act);
136 | }
137 | return innerValue;
138 | };
139 | }
140 | return value;
141 | },
142 | });
143 | }
144 |
145 | export function setReactableNotifier(origin: any, act: Act) {
146 | const notifier = Notifier.getOrCreate(origin);
147 | handleNotifier(notifier, act, origin);
148 | }
149 |
150 | export function toInstanceTracker>(
151 | exist: T | undefined,
152 | origin: T,
153 | act: Act,
154 | deep: boolean = true,
155 | ) {
156 | if (exist) return exist;
157 | // try make observable
158 | if (!Observability.is(origin)) {
159 | observable(origin);
160 | }
161 | const proxy = new Proxy(origin, {
162 | get(target: any, property: string | symbol): any {
163 | if (property === ObservableSymbol.Tracker) {
164 | return target;
165 | }
166 | if (property === ObservableSymbol.Self) {
167 | return target;
168 | }
169 | let notifier;
170 | if (typeof property === 'string') {
171 | if (Observability.notifiable(target, property)) {
172 | notifier = Notifier.getOrCreate(target, property);
173 | handleNotifier(notifier, act, target, property);
174 | }
175 | }
176 | const value = getValue(target, property, proxy, notifier);
177 | if (Reactable.is(value)) {
178 | return tramsform(value, act);
179 | }
180 | if (Observability.trackable(value)) {
181 | if (Reactable.canBeReactable(value)) {
182 | return track(value, act, false);
183 | }
184 | return track(value, act, deep);
185 | }
186 | return value;
187 | },
188 | });
189 | set(origin, act, proxy);
190 | return proxy;
191 | }
192 | export function toReactableTracker>(
193 | exist: T | undefined,
194 | origin: T,
195 | object: T,
196 | act: Act,
197 | deep: boolean,
198 | ) {
199 | let maybeReactable = object;
200 | if (deep) {
201 | // try make reactable
202 | if (!Observability.is(origin)) {
203 | maybeReactable = observable(origin);
204 | }
205 | }
206 | maybeReactable = Reactable.get(origin) ?? object;
207 | // set reactable listener
208 | if (Reactable.is(maybeReactable)) {
209 | setReactableNotifier(origin, act);
210 | }
211 | if (exist) return exist;
212 | if (!deep) return object;
213 | const proxy = tramsform(maybeReactable, act);
214 | set(origin, act, proxy);
215 | return proxy;
216 | }
217 |
218 | export function track>(object: T, act: Act, deep: boolean = true): T {
219 | if (!Observability.trackable(object)) {
220 | return object;
221 | }
222 | // get origin
223 | let origin = object;
224 | if (Trackable.is(object)) {
225 | origin = Trackable.getOrigin(object);
226 | }
227 | origin = Observability.getOrigin(origin);
228 | let exist: T | undefined = undefined;
229 | // already has tracker
230 | if (has(origin, act)) {
231 | exist = get(origin, act);
232 | }
233 | // get exist reactble
234 | if (Reactable.canBeReactable(origin)) {
235 | return toReactableTracker(exist, origin, object, act, deep);
236 | } else {
237 | return toInstanceTracker(exist, origin, act, deep);
238 | }
239 | }
240 | }
241 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/utils.spec.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata';
2 | import assert from 'assert';
3 | import { getDesignType, ObservableProperties, Observability, InstanceValue } from './utils';
4 | import { prop } from './decorator';
5 | import { Disposable } from 'mana-common';
6 | import { equals, ObservableSymbol } from '.';
7 |
8 | describe('utils', () => {
9 | it('#Observability', () => {
10 | class Foo {
11 | info = '';
12 | }
13 | const foo = new Foo();
14 | const meta = 'meta';
15 | Observability.setDisposable(meta, Disposable.NONE, foo);
16 | Observability.setDisposable(meta, Disposable.NONE, foo, 'info');
17 | const toDispose = Observability.getDisposable(meta, foo);
18 | const toDispose1 = Observability.getDisposable(meta, foo, 'info');
19 | const fooProxy = new Proxy(foo, {
20 | get: (target, propertyKey) => {
21 | if (propertyKey === ObservableSymbol.Self) {
22 | return target;
23 | }
24 | return (target as any)[propertyKey];
25 | },
26 | });
27 | assert(toDispose === Disposable.NONE);
28 | assert(toDispose1 === Disposable.NONE);
29 | assert(Observability.getOrigin(fooProxy) === foo);
30 | assert(equals(fooProxy, foo));
31 | assert(Observability.getOrigin(null) === null);
32 | assert(!Observability.trackable(null));
33 | assert(!Observability.is(null, 'name'));
34 | assert(Observability.trackable({}));
35 | Observability.mark(foo, 'info');
36 | Observability.mark(foo);
37 | assert(Observability.is(foo, 'info'));
38 | assert(Observability.is(foo));
39 | assert(Observability.trackable(foo));
40 | assert(Observability.notifiable(foo, 'info'));
41 | });
42 | it('#ObservableProperties', () => {
43 | class ClassBasic {
44 | name = '';
45 | }
46 | class ClassBasic1 extends ClassBasic {
47 | name1 = '';
48 | }
49 | const instanceBasic = new ClassBasic();
50 | let properties = ObservableProperties.get(instanceBasic);
51 | assert(!properties);
52 | ObservableProperties.add(ClassBasic, 'name');
53 | properties = ObservableProperties.get(ClassBasic);
54 | assert(properties?.length === 1);
55 | assert(properties.includes('name'));
56 | ObservableProperties.add(ClassBasic1, 'name1');
57 | properties = ObservableProperties.get(ClassBasic1);
58 | assert(properties?.length === 2);
59 | assert(properties.includes('name1'));
60 | properties = ObservableProperties.get(instanceBasic);
61 | assert(!properties);
62 | assert(!ObservableProperties.find({}));
63 | assert(!ObservableProperties.find(null as any));
64 | const instanceProperties = ObservableProperties.find(instanceBasic) || [];
65 | assert(instanceProperties.includes('name'));
66 | instanceProperties.forEach(property => {
67 | ObservableProperties.add(instanceBasic, property);
68 | });
69 | properties = ObservableProperties.getOwn(instanceBasic);
70 | assert(properties?.length === 1);
71 | });
72 |
73 | it('#InstanceValue', () => {
74 | const foo = {};
75 | InstanceValue.set(foo, 'name', 'foo');
76 | assert(InstanceValue.get(foo, 'name') === 'foo');
77 | });
78 | it('#getDesignType', () => {
79 | class ClassBasic {
80 | @prop()
81 | name?: string;
82 | @prop()
83 | name1 = '';
84 | @prop()
85 | map?: Map;
86 | }
87 | const instanceBasic = new ClassBasic();
88 | assert(getDesignType(instanceBasic, 'name') === String);
89 | assert(getDesignType(instanceBasic, 'name1') === Object);
90 | assert(getDesignType(instanceBasic, 'map') === Map);
91 | });
92 | });
93 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/utils.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata';
2 | import type { Disposable } from 'mana-common';
3 | import { ObservableSymbol } from './core';
4 |
5 | interface Original {
6 | [ObservableSymbol.Self]: any;
7 | }
8 |
9 | export namespace Observability {
10 | export function trackable(obj: any): obj is Record {
11 | return !!obj && typeof obj === 'object';
12 | }
13 | export function notifiable(obj: any, property: string | symbol): boolean {
14 | return is(obj, property);
15 | }
16 | export function is(obj: any, property?: string | symbol): boolean {
17 | if (!trackable(obj)) return false;
18 | const origin = getOrigin(obj);
19 | if (property) {
20 | return Reflect.hasOwnMetadata(ObservableSymbol.Observable, origin, property);
21 | }
22 | return Reflect.hasOwnMetadata(ObservableSymbol.Observable, origin);
23 | }
24 | export function mark(obj: Record, property?: string | symbol) {
25 | if (property) {
26 | Reflect.defineMetadata(ObservableSymbol.Observable, true, obj, property);
27 | } else {
28 | Reflect.defineMetadata(ObservableSymbol.Observable, true, obj);
29 | }
30 | }
31 |
32 | function isOriginal(data: any): data is Original {
33 | return Observability.trackable(data) && data[ObservableSymbol.Self];
34 | }
35 |
36 | export function getOrigin(obj: T): T {
37 | if (!isOriginal(obj)) return obj;
38 | return obj[ObservableSymbol.Self];
39 | }
40 | export function equals(a: any, b: any) {
41 | return getOrigin(a) === getOrigin(b);
42 | }
43 |
44 | export function getDisposable(metaKey: any, obj: Record, property?: string) {
45 | if (property) {
46 | return Reflect.getOwnMetadata(metaKey, obj, property);
47 | }
48 | return Reflect.getOwnMetadata(metaKey, obj);
49 | }
50 |
51 | export function setDisposable(
52 | metaKey: any,
53 | disposable: Disposable,
54 | obj: Record,
55 | property?: string,
56 | ) {
57 | if (property) {
58 | Reflect.defineMetadata(metaKey, disposable, obj, property);
59 | }
60 | Reflect.defineMetadata(metaKey, disposable, obj);
61 | }
62 | }
63 |
64 | export namespace ObservableProperties {
65 | export function getOwn(obj: Record): string[] | undefined {
66 | return Reflect.getOwnMetadata(ObservableSymbol.ObservableProperties, obj);
67 | }
68 | export function get(obj: Record): string[] | undefined {
69 | return Reflect.getMetadata(ObservableSymbol.ObservableProperties, obj);
70 | }
71 | export function find(obj: Record): string[] | undefined {
72 | if (obj && obj.constructor) {
73 | return get(obj.constructor);
74 | }
75 | return undefined;
76 | }
77 |
78 | export function add(obj: Record, property: string): void {
79 | const exisringProperties = getOwn(obj);
80 | if (exisringProperties) {
81 | exisringProperties.push(property);
82 | } else {
83 | const protoProperties = get(obj) || [];
84 | Reflect.defineMetadata(
85 | ObservableSymbol.ObservableProperties,
86 | [...protoProperties, property],
87 | obj,
88 | );
89 | }
90 | }
91 | }
92 |
93 | export namespace InstanceValue {
94 | export function set(target: any, property: string, value: any) {
95 | Reflect.defineMetadata(property, value, target);
96 | }
97 | export function get(target: any, property: string) {
98 | return Reflect.getMetadata(property, target);
99 | }
100 | }
101 |
102 | /**
103 | * get design type of property
104 | * @param obj
105 | * @param propertyKey
106 | * @returns number → Number
107 | * @returns string → String
108 | * @returns boolean → Boolean
109 | * @returns any → Object
110 | * @returns void → undefined
111 | * @returns Array → Array
112 | * @returns Tuple → Array
113 | * @returns class → constructor
114 | * @returns Enum → Number
115 | * @returns ()=>{} → Function
116 | * @returns others(interface ...) → Object
117 | */
118 | export function getDesignType(obj: Record, propertyKey: string): DesignType {
119 | return Reflect.getMetadata('design:type', obj, propertyKey);
120 | }
121 |
122 | export type DesignType =
123 | | undefined
124 | | typeof Function
125 | | typeof String
126 | | typeof Boolean
127 | | typeof Number
128 | | typeof Array
129 | | typeof Map
130 | | typeof Object;
131 |
132 | export const getOrigin = Observability.getOrigin;
133 | export const equals = Observability.equals;
134 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/watch.spec.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata';
2 |
3 | import assert from 'assert';
4 | import { watch } from './watch';
5 | import { Disposable } from 'mana-common';
6 | import { prop } from './decorator';
7 |
8 | console.warn = () => {};
9 |
10 | describe('watch', () => {
11 | it('#watch prop', done => {
12 | class Foo {
13 | @prop() name?: string;
14 | @prop() name1?: string;
15 | }
16 | const newName = 'new name';
17 | let watchLatest: string | undefined;
18 | const foo = new Foo();
19 | watchLatest = foo.name;
20 | watch(foo, 'name', () => {
21 | watchLatest = foo.name;
22 | assert(watchLatest === newName);
23 | done();
24 | });
25 | foo.name = newName;
26 | });
27 | it('#watch object', () => {
28 | class Foo {
29 | @prop() name?: string;
30 | @prop() info?: string;
31 | }
32 | let changed = 0;
33 | const newName = 'new name';
34 | let watchLatest: string | undefined;
35 | const foo = new Foo();
36 | watchLatest = foo.name;
37 | watch(foo, () => {});
38 | watch(foo, () => {
39 | changed += 1;
40 | watchLatest = foo.name;
41 | assert(watchLatest === newName);
42 | });
43 | foo.name = newName;
44 | foo.info = 'foo';
45 | assert(changed === 2);
46 | });
47 | it('#watch unobservable prop', done => {
48 | class Foo {
49 | @prop() name?: string;
50 | info?: string;
51 | }
52 | const newName = 'new name';
53 | let watchLatest: string | undefined;
54 | const foo = new Foo();
55 | watchLatest = foo.info;
56 | watch(foo, 'info', () => {
57 | watchLatest = foo.info;
58 | done();
59 | });
60 | foo.info = newName;
61 | watch(foo, 'name', () => {
62 | assert(watchLatest !== newName);
63 | done();
64 | });
65 | foo.name = newName;
66 | });
67 |
68 | it('#invalid watch', () => {
69 | class Foo {
70 | @prop() name?: string;
71 | }
72 | const foo = new Foo();
73 | const toDispose = (watch as any)(foo, 'name');
74 | const toDispose1 = watch(null, () => {});
75 | assert(toDispose === Disposable.NONE);
76 | assert(toDispose1 === Disposable.NONE);
77 | });
78 | });
79 |
--------------------------------------------------------------------------------
/packages/mana-observable/src/watch.ts:
--------------------------------------------------------------------------------
1 | import { Disposable } from 'mana-common';
2 | import { observable } from './observable';
3 | import type { Notify } from './core';
4 | import { Notifier } from './notifier';
5 | import { getOrigin, Observability } from './utils';
6 |
7 | function tryObservable(target: T) {
8 | const data = getOrigin(target);
9 | if (!Observability.trackable(data)) {
10 | return data;
11 | }
12 | if (!Observability.is(data)) {
13 | return observable(data);
14 | }
15 | return data;
16 | }
17 |
18 | function watchAll(target: T, callback: Notify): Disposable {
19 | const data = getOrigin(target);
20 | if (!Observability.trackable(data)) {
21 | return Disposable.NONE;
22 | }
23 | tryObservable(data);
24 | const tracker = Notifier.find(data);
25 | if (!tracker) {
26 | return Disposable.NONE;
27 | }
28 | const props: string[] = Object.keys(data);
29 | if (props) {
30 | props.forEach(prop => Notifier.find(target, prop));
31 | }
32 | return tracker.onChange(callback);
33 | }
34 |
35 | function watchProp(target: T, prop: Extract, callback: Notify): Disposable {
36 | const data = getOrigin(target);
37 | tryObservable(data);
38 | const tracker = Notifier.find(data, prop);
39 | if (tracker) {
40 | return tracker.onChange(callback);
41 | }
42 | console.warn(`Cannot add watcher for unobservable property ${prop.toString()}`, target);
43 | return Disposable.NONE;
44 | }
45 |
46 | export function watch(target: T, callback: Notify): Disposable;
47 | export function watch(target: T, prop: Extract, callback: Notify): Disposable;
48 | export function watch(
49 | target: T,
50 | prop: Extract | Notify,
51 | callback?: Notify,
52 | ): Disposable {
53 | let cb: Notify;
54 | if (typeof prop === 'function') {
55 | cb = prop;
56 | return watchAll(target, cb);
57 | }
58 | if (typeof prop === 'string' && callback) {
59 | cb = callback;
60 | return watchProp(target, prop, cb);
61 | }
62 | console.warn(`Invalid arguments for watch ${prop.toString()}`, target);
63 | return Disposable.NONE;
64 | }
65 |
--------------------------------------------------------------------------------
/packages/mana-observable/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "rootDir": "./src"
5 | },
6 | "include": ["src/**/*"]
7 | }
8 |
--------------------------------------------------------------------------------
/packages/mana-syringe/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /** @type {import('eslint').Linter.Config} */
2 | module.exports = {
3 | extends: ['../../.eslintrc.js'],
4 | parserOptions: {
5 | tsconfigRootDir: __dirname,
6 | project: 'tsconfig.json',
7 | },
8 | };
9 |
--------------------------------------------------------------------------------
/packages/mana-syringe/.fatherrc.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | esm: 'babel',
3 | cjs: 'babel',
4 | umd: { sourcemap: true },
5 | };
6 |
--------------------------------------------------------------------------------
/packages/mana-syringe/README.md:
--------------------------------------------------------------------------------
1 | # mana-syringe
2 |
3 | IoC library for mana, easily to use.
4 |
5 | [](https://npmjs.org/package/mana-syringe) [](https://npmjs.org/package/mana-syringe)
6 |
7 | 提供易于使用的依赖注入容器,参考 TSyringe 项目,参考并基于 inversify。
8 |
9 | ## 安装
10 |
11 | ```bash
12 | npm i mana-syringe --save
13 | ```
14 |
15 | ## 概念与使用
16 |
17 | ### 注入标识 token
18 |
19 | 注入绑定对象所使用的的标识,可以带有一定的类型约束
20 |
21 | ```typescript
22 | Token = string | symbol | Newable | Abstract | Syringe.DefinedToken;
23 | ```
24 |
25 | 除 `Syringe.DefinedToken` 默认支持多绑定外,注入标识只支持单一绑定关系。可以使用如下 API 生成 DefinedToken
26 |
27 | ```typescript
28 | Syringe.defineToken('sample-token');
29 | ```
30 |
31 | ### 容器 Container
32 |
33 | 包含一组绑定标识与注入对象关系描述的上下文成为容器,当我们通过容器获取实例时,容器会根据注入对象及其与标识的关系自动构建所需的其他实例。
34 |
35 | 用户可以手动创建容器,使用全局默认的容器,或者创建子容器
36 |
37 | ```typescript
38 | import { GlobalContainer, Container } from './container';
39 | const global = GlobalContainer;
40 | const container = new Container();
41 | const child = container.createChild();
42 | ```
43 |
44 | 我们使用 `token` 从容器里获取对象
45 |
46 | ```typescript
47 | const ninja = child.get(Ninja);
48 | ```
49 |
50 | 当我们从子容器中获取对象时,会先从子容器查找绑定关系和缓存信息,如果不存在,则继续向父容器查找。
51 |
52 | ### 注册 register
53 |
54 | 容器上暴露了 register 方法,这个 API 是整个体系的核心。 register 方法有两种签名
55 |
56 | ```typescript
57 | register(options: Syringe.InjectOption): void;
58 | register(token: Syringe.Token, options?: Syringe.InjectOption): void;
59 | ```
60 |
61 | 可以调用容器实例上的 register 方法,也可以直接调用全局的 register 方法,其相对于调用 GlobalContainer 的方法。
62 |
63 | 从签名可以看出,注册绑定需要一组配置,在不同场景下配置会有所不同,可能出现的配置项如下
64 |
65 | ```typescript
66 | interface {
67 | token?: MaybeArray>;
68 | contrib?: MaybeArray>;
69 | lifecycle?: Lifecycle;
70 | useClass?: MaybeArray>;
71 | useDynamic?: MaybeArray>;
72 | useFactory?: MaybeArray>;
73 | useValue?: T;
74 | }
75 | ```
76 |
77 | - token 可以为数组,本次绑定关系需要声明的标识,不同标识分别注册
78 | - contrib 可以为数组,可用于注册扩展点,也可用于注册 token 别名
79 | - useClass 可以为数组,给出一个或多个类
80 | - useToken 可以为数组,根据 token 从容器内动态获取对象
81 | - useFactory 可以为数组,基于带有容器信息的上下文,给出动态获得实例的方法
82 | - useDynamic 可以为数组,基于带有容器信息的上下文给出实例
83 | - useValue 可以为数组,常量直接给出值
84 |
85 | #### 生命期 lifecycle
86 |
87 | 容器会根据注入对象的生命期描述托管这些对象,决定是否使用缓存等。
88 |
89 | ```typescript
90 | export enum Lifecycle {
91 | singleton = 'singleton',
92 | transient = 'transient',
93 | }
94 | ```
95 |
96 | #### 注册类和别名
97 |
98 | ```typescript
99 | @singleton({ contrib: Alias })
100 | class Shuriken implements Weapon {
101 | public hit() {
102 | console.log('Shuriken hit');
103 | }
104 | }
105 | GlobalContainer.register(Shuriken);
106 | GlobalContainer.register(Shuriken, {
107 | useClass: Shuriken,
108 | lifecycle: Syringe.Lifecycle.singleton,
109 | });
110 | ```
111 |
112 | 通过 token 注册后,每个 token 的注册关系是独立的,通过他们获取对象可以是不同的值,通过 contrib 注册的是别名关系,他们应该获取到同一个对象。不管是 token 还是 contrib,根据对多绑定的支持情况做处理。
113 |
114 | ```typescript
115 | const Weapon = Symbol('Weapon');
116 | const WeaponArray = Syringe.defineToken('Weapon');
117 | @singleton({ contrib: Weapon })
118 | class Shuriken implements Weapon {
119 | public hit() {
120 | console.log('Shuriken hit');
121 | }
122 | }
123 | GlobalContainer.register({ token: Weapon, useValue: undefined });
124 | GlobalContainer.register({ token: WeaponArray, useValue: undefined });
125 | GlobalContainer.register(Shuriken);
126 | GlobalContainer.get(Weapon); // Shuriken
127 | GlobalContainer.getAll(WeaponArray); // [undefined, Shuriken]
128 | ```
129 |
130 | #### 注册值
131 |
132 | ```typescript
133 | const ConstantValue = Symbol('ConstantValue');
134 | GlobalContainer.register({ token: ConstantValue, useValue: {} });
135 | ```
136 |
137 | #### 注册动态值
138 |
139 | ```typescript
140 | const NinjaAlias = Symbol('NinjaAlias');
141 | GlobalContainer.register({
142 | token: NinjaAlias,
143 | useDynamic: ctx => ctx.container.get(Ninja),
144 | });
145 | ```
146 |
147 | ### 装饰器
148 |
149 | 我们提供了一组对类与属性的装饰器函数,用来快速完成基于依赖注入的类型描述,并完成基本的绑定关系描述。
150 |
151 | - injectable: 通用装饰器,接受所有绑定描述参数
152 | - singleton: 单例装饰器,接受除生命期外的描述参数
153 | - transient: 多例装饰器,接受除生命期外的描述参数
154 | - inject: 注入,接受注入标识作为参数,并接受类型描述
155 |
156 | ```typescript
157 | @singleton()
158 | class Shuriken implements Weapon {
159 | public hit() {
160 | console.log('Shuriken hit');
161 | }
162 | }
163 | @transient()
164 | class Ninja {
165 | @inject(Weapon) public weapon: Weapon;
166 | public hit() {
167 | this.weapon.hit();
168 | }
169 | }
170 | ```
171 |
172 | ### 扩展点 Contribution
173 |
174 | 我们通常将依赖注入的多绑定模式以扩展点的形式使用,为了方便在项目中使用这种模式,我们内置了对扩展点的定义和支持。
175 |
176 | #### 扩展点的定义与注册
177 |
178 | ```typescript
179 | const Weapon = Syringe.defineToken('Weapon');
180 | Contribution.register(GlobalContainer.register, Weapon);
181 | ```
182 |
183 | #### 扩展服务 Contribution.Provider
184 |
185 | 内置了扩展点的管理服务,用户一般直接使用即可,注册扩展点以后,通过如下方式获取扩展服务
186 |
187 | ```typescript
188 | @contrib(Weapon) public weaponProvider: Contribution.Provider;
189 | ```
190 |
191 | 等价于如下写法
192 |
193 | ```typescript
194 | @inject(Contribution.Provider) @named(Weapon) public weaponProvider: Contribution.Provider;
195 |
196 | ```
197 |
198 | #### 扩展点示例
199 |
200 | ```typescript
201 | const Weapon = Syringe.defineToken('Weapon');
202 | Contribution.register(GlobalContainer.register, Weapon);
203 | @singleton({ contrib: Weapon })
204 | class Shuriken implements Weapon {
205 | public hit() {
206 | console.log('Shuriken hit');
207 | }
208 | }
209 | @transient()
210 | class Ninja {
211 | @contrib(Weapon) public weaponProvider: Contribution.Provider;
212 | hit() {
213 | const weapons = this.weaponProvider.getContributions();
214 | weapons.forEach(w => w.hit());
215 | }
216 | }
217 | const module = Module(register => {
218 | Contribution.register(register, Weapon);
219 | register(Shuriken);
220 | register(Ninja);
221 | });
222 | GlobalContainer.register(Shuriken);
223 | GlobalContainer.register(Ninja);
224 | GlobalContainer.get(Ninja).hit(); // Shuriken hit
225 | ```
226 |
227 | ### 模块
228 |
229 | 可以通过用一组注册动作创建一个模块,方便在不同容器上下文间内加载, 模块的构建支持注册函数和链式调用两种方式,前面扩展点示例里的模块也可以写成如下形式:
230 |
231 | ```typescript
232 | const module = Module().contribution(Weapon).register(Shuriken, Ninja);
233 |
234 | GlobalContainer.load(module);
235 | ```
236 |
237 | - 相同 module 默认不重复加载。
238 |
--------------------------------------------------------------------------------
/packages/mana-syringe/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mana-syringe",
3 | "keywords": [
4 | "mana",
5 | "syringe",
6 | "inversify"
7 | ],
8 | "description": "IoC library for mana, easily to use.",
9 | "version": "0.3.2",
10 | "typings": "lib/index.d.ts",
11 | "main": "lib/index.js",
12 | "module": "es/index.js",
13 | "unpkg": "dist/index.umd.min.js",
14 | "license": "MIT",
15 | "files": [
16 | "package.json",
17 | "README.md",
18 | "dist",
19 | "es",
20 | "lib",
21 | "src"
22 | ],
23 | "dependencies": {
24 | "inversify": "^5.0.1"
25 | },
26 | "scripts": {
27 | "prepare": "yarn run clean && yarn run build",
28 | "lint": "manarun lint",
29 | "clean": "manarun clean",
30 | "build": "manarun build",
31 | "watch": "manarun watch"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/packages/mana-syringe/src/container.ts:
--------------------------------------------------------------------------------
1 | import type { interfaces } from 'inversify';
2 | import { Container as InversifyContainer } from 'inversify';
3 | import type { InversifyContext } from './inversify/inversify-protocol';
4 | import {
5 | GlobalContainer as InversifyGlobalContainer,
6 | namedToIdentifier,
7 | tokenToIdentifier,
8 | } from './inversify';
9 | import type { Disposable, Syringe } from './core';
10 | import { Utils } from './core';
11 | import { Register } from './register';
12 | import { isSyringeModule } from './module';
13 |
14 | const ContainerMap = new Map();
15 |
16 | /* eslint-disable @typescript-eslint/no-explicit-any */
17 | export class Container implements Syringe.Container, InversifyContext {
18 | static setContainer(key: interfaces.Container, value: Syringe.Container) {
19 | return ContainerMap.set(key.id, value);
20 | }
21 | static getContainer(key: interfaces.Container) {
22 | const exist = ContainerMap.get(key.id);
23 | if (!exist) {
24 | const container = new Container(key);
25 | Container.setContainer(key, container);
26 | return container;
27 | }
28 | return exist;
29 | }
30 | static config(option: Syringe.InjectOption): void {
31 | Register.globalConfig = option;
32 | }
33 |
34 | protected loadedModules: number[] = [];
35 | container: interfaces.Container;
36 | protected inversify: boolean = true;
37 | parent?: Container;
38 | constructor(inversifyContainer?: interfaces.Container) {
39 | if (inversifyContainer) {
40 | this.container = inversifyContainer;
41 | } else {
42 | this.container = new InversifyContainer();
43 | }
44 | Container.setContainer(this.container, this);
45 | }
46 | load(module: Syringe.Module, force?: boolean): Disposable {
47 | if (force || !this.loadedModules.includes(module.id)) {
48 | if (isSyringeModule(module)) {
49 | this.container.load(module.inversifyModule);
50 | } else {
51 | console.warn('Unsupported module.', module);
52 | }
53 | this.loadedModules.push(module.id);
54 | return {
55 | dispose: () => {
56 | this.unload(module);
57 | },
58 | };
59 | }
60 | return { dispose: () => {} };
61 | }
62 | unload(module: Syringe.Module): void {
63 | if (isSyringeModule(module)) {
64 | this.container.unload(module.inversifyModule);
65 | this.loadedModules = this.loadedModules.filter(id => id !== module.id);
66 | }
67 | }
68 | remove(token: Syringe.Token): void {
69 | return this.container.unbind(tokenToIdentifier(token));
70 | }
71 | get(token: Syringe.Token): T {
72 | return this.container.get(tokenToIdentifier(token));
73 | }
74 | getNamed(token: Syringe.Token, named: Syringe.Named): T {
75 | return this.container.getNamed(tokenToIdentifier(token), namedToIdentifier(named));
76 | }
77 | getAll(token: Syringe.Token): T[] {
78 | return this.container.getAll(tokenToIdentifier(token));
79 | }
80 | getAllNamed(token: Syringe.Token, named: Syringe.Named): T[] {
81 | return this.container.getAllNamed(tokenToIdentifier(token), namedToIdentifier(named));
82 | }
83 |
84 | isBound(token: Syringe.Token): boolean {
85 | return this.container.isBound(tokenToIdentifier(token));
86 | }
87 |
88 | isBoundNamed(token: Syringe.Token, named: Syringe.Named): boolean {
89 | return this.container.isBoundNamed(tokenToIdentifier(token), namedToIdentifier(named));
90 | }
91 |
92 | createChild(): Syringe.Container {
93 | const childContainer = this.container.createChild();
94 | const child = new Container(childContainer);
95 | child.parent = this;
96 | return child;
97 | }
98 | register(tokenOrOption: Syringe.Token | Syringe.InjectOption): void;
99 | register(token: Syringe.Token, options: Syringe.InjectOption): void;
100 | register(
101 | token: Syringe.Token | Syringe.InjectOption,
102 | options: Syringe.InjectOption = {},
103 | ): void {
104 | if (Utils.isInjectOption(token)) {
105 | Register.resolveOption(this.container, token);
106 | } else {
107 | Register.resolveTarget(this.container, token, options);
108 | }
109 | }
110 | }
111 |
112 | export const GlobalContainer = new Container(InversifyGlobalContainer);
113 |
114 | export const register: Syringe.Register = GlobalContainer.register.bind(GlobalContainer);
115 |
--------------------------------------------------------------------------------
/packages/mana-syringe/src/contribution/contribution-protocol.ts:
--------------------------------------------------------------------------------
1 | import { Syringe } from '../core';
2 |
3 | export type Option = {
4 | /**
5 | * collected from the parent containers
6 | */
7 | recursive?: boolean;
8 | /**
9 | * use cache
10 | */
11 | cache?: boolean;
12 | };
13 | export type Provider> = {
14 | getContributions: (option?: Option) => T[];
15 | };
16 | export const Provider = Syringe.defineToken('ContributionProvider');
17 |
--------------------------------------------------------------------------------
/packages/mana-syringe/src/contribution/contribution-provider.ts:
--------------------------------------------------------------------------------
1 | import type { Syringe } from '../core';
2 | import type { Option, Provider } from './contribution-protocol';
3 |
4 | export class DefaultContributionProvider> implements Provider {
5 | protected option: Option = { recursive: false, cache: true };
6 | protected services: T[] | undefined;
7 | protected readonly serviceIdentifier: Syringe.Token;
8 | protected readonly container: Syringe.Container;
9 | constructor(serviceIdentifier: Syringe.Token, container: Syringe.Container, option?: Option) {
10 | this.container = container;
11 | this.serviceIdentifier = serviceIdentifier;
12 | if (option) {
13 | this.option = { ...this.option, ...option };
14 | }
15 | }
16 |
17 | protected setServices(recursive: boolean): T[] {
18 | const currentServices: T[] = [];
19 | let currentContainer: Syringe.Container | undefined = this.container;
20 | while (currentContainer) {
21 | if (currentContainer.isBound(this.serviceIdentifier)) {
22 | const list = currentContainer.getAll(this.serviceIdentifier);
23 | currentServices.push(...list);
24 | }
25 | currentContainer = recursive ? currentContainer.parent : undefined;
26 | }
27 | return currentServices;
28 | }
29 |
30 | getContributions(option: Option = {}): T[] {
31 | const { cache, recursive } = { ...this.option, ...option };
32 | if (!cache || this.services === undefined) {
33 | this.services = this.setServices(!!recursive);
34 | }
35 | return this.services;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/packages/mana-syringe/src/contribution/contribution-register.ts:
--------------------------------------------------------------------------------
1 | import { Syringe } from '../core';
2 | import * as Contribution from './contribution-protocol';
3 | import type { Provider, Option } from './contribution-protocol';
4 | import { DefaultContributionProvider } from './contribution-provider';
5 |
6 | export function contributionInjectOption(
7 | token: Syringe.DefinedToken,
8 | option?: Option,
9 | ): Syringe.InjectOption> {
10 | return {
11 | token: { token: Contribution.Provider, named: token },
12 | useDynamic: ctx => {
13 | return new DefaultContributionProvider(token, ctx.container, option);
14 | },
15 | lifecycle: Syringe.Lifecycle.singleton,
16 | };
17 | }
18 |
19 | export function contributionRegister(
20 | registerMethod: Syringe.Register,
21 | identifier: Syringe.DefinedToken,
22 | option?: Option,
23 | ): void {
24 | registerMethod({
25 | token: { token: Contribution.Provider, named: identifier },
26 | useDynamic: ctx => {
27 | return new DefaultContributionProvider(identifier, ctx.container, option);
28 | },
29 | lifecycle: Syringe.Lifecycle.singleton,
30 | });
31 | }
32 |
--------------------------------------------------------------------------------
/packages/mana-syringe/src/contribution/decorator.ts:
--------------------------------------------------------------------------------
1 | import type { Syringe } from '../core';
2 | import { inject, named } from '../decorator';
3 | import { Provider } from './contribution-protocol';
4 |
5 | export const contrib =
6 | (token: Syringe.Named) =>
7 | (
8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
9 | target: any,
10 | targetKey: string,
11 | index?: number | undefined,
12 | ) => {
13 | named(token)(target, targetKey, index);
14 | inject(Provider)(target, targetKey, index);
15 | };
16 |
--------------------------------------------------------------------------------
/packages/mana-syringe/src/contribution/index.spec.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 |
3 | import 'reflect-metadata';
4 | import assert from 'assert';
5 | import { GlobalContainer } from '../container';
6 | import { register } from '../container';
7 | import { inject, singleton } from '../decorator';
8 | import { Contribution, contrib } from '../';
9 | import { DefaultContributionProvider } from './contribution-provider';
10 | import { Syringe } from '../core';
11 |
12 | describe('contribution', () => {
13 | it('#register contribution', () => {
14 | const FooContribution = Syringe.defineToken('FooContribution');
15 | Contribution.register(register, FooContribution);
16 | const provider = GlobalContainer.getNamed(Contribution.Provider, FooContribution);
17 | assert(provider instanceof DefaultContributionProvider);
18 | assert(GlobalContainer.isBoundNamed(Contribution.Provider, FooContribution));
19 | });
20 | it('#contrib decorator', () => {
21 | const FooContribution = Syringe.defineToken('FooContribution');
22 | const BarContribution = Syringe.defineToken('BarContribution');
23 | Contribution.register(register, FooContribution);
24 | @singleton({ contrib: FooContribution })
25 | class Foo {}
26 | @singleton({ contrib: [FooContribution, BarContribution] })
27 | class Foo1 {}
28 | register(Foo);
29 | register(Foo1);
30 | @singleton()
31 | class Bar {
32 | constructor(
33 | @contrib(FooContribution) public contribs: Contribution.Provider,
34 | @inject(BarContribution) public bar: Contribution.Provider,
35 | ) {}
36 | }
37 | register(Bar);
38 |
39 | const bar = GlobalContainer.get(Bar);
40 | const list = bar.contribs.getContributions();
41 | assert(bar.bar instanceof Foo1);
42 | assert(list.length === 2);
43 | assert(list.find(item => item instanceof Foo));
44 | });
45 | it('#contribution option', () => {
46 | const FooContribution = Syringe.defineToken('FooContribution');
47 | @singleton({ contrib: FooContribution })
48 | class Foo {}
49 | register(Foo);
50 | const childContainer = GlobalContainer.createChild();
51 | Contribution.register(childContainer.register.bind(childContainer), FooContribution, {
52 | cache: true,
53 | });
54 | @singleton()
55 | class Bar {
56 | constructor(@contrib(FooContribution) public pr: Contribution.Provider) {}
57 | }
58 | childContainer.register(Bar);
59 | const bar = childContainer.get(Bar);
60 | const list = bar.pr.getContributions();
61 | @singleton({ contrib: FooContribution })
62 | class Foo1 {}
63 | childContainer.register(Foo1);
64 | assert(list.length === 1);
65 | assert(list.find(item => item instanceof Foo));
66 | const cachelist = bar.pr.getContributions();
67 | assert(list === cachelist);
68 | const newlist = bar.pr.getContributions({ cache: false });
69 | assert(list !== newlist && newlist.length === 1);
70 | assert(newlist.find(item => item instanceof Foo1));
71 | const all = bar.pr.getContributions({ recursive: true, cache: false });
72 | assert(all !== newlist && all.length === 2);
73 | assert(all.find(item => item instanceof Foo));
74 | assert(all.find(item => item instanceof Foo1));
75 | });
76 | });
77 |
--------------------------------------------------------------------------------
/packages/mana-syringe/src/contribution/index.ts:
--------------------------------------------------------------------------------
1 | import * as Protocol from './contribution-protocol';
2 | import { contributionRegister } from './contribution-register';
3 |
4 | export * from './contribution-protocol';
5 | export * from './contribution-provider';
6 | export * from './decorator';
7 |
8 | export namespace Contribution {
9 | export type Option = Protocol.Option;
10 | export type Provider> = Protocol.Provider;
11 | export const Provider = Protocol.Provider;
12 | export const register = contributionRegister;
13 | }
14 |
--------------------------------------------------------------------------------
/packages/mana-syringe/src/core.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | /* eslint-disable @typescript-eslint/no-shadow */
3 |
4 | import 'reflect-metadata';
5 |
6 | export type TokenOption = {
7 | multiple?: boolean;
8 | };
9 |
10 | export type Newable = new (...args: any[]) => T;
11 |
12 | export type Decorator = (target: Newable | Abstract) => any;
13 | export type Abstract = {
14 | prototype: T;
15 | };
16 |
17 | export type Disposable = {
18 | /**
19 | * Dispose this object.
20 | */
21 | dispose: () => void;
22 | };
23 | export namespace Syringe {
24 | /**
25 | * 定义注入标识,默认允许多重注入
26 | */
27 | export const defineToken = (name: string, option: Partial = { multiple: true }) =>
28 | new Syringe.DefinedToken(name, option);
29 | export class DefinedToken {
30 | /**
31 | * 兼容 inversify identifier
32 | */
33 | prototype: any = {};
34 | protected name: string;
35 | readonly multiple: boolean;
36 | readonly symbol: symbol;
37 | constructor(name: string, option: Partial = {}) {
38 | const { multiple = false } = option;
39 | this.name = name;
40 | this.symbol = Symbol(this.name);
41 | this.multiple = multiple;
42 | }
43 | }
44 |
45 | export type Register = (
46 | token: Syringe.Token | Syringe.InjectOption,
47 | options?: Syringe.InjectOption,
48 | ) => void;
49 |
50 | export type Token = string | symbol | Newable | Abstract | DefinedToken;
51 | export type Named = string | symbol | DefinedToken;
52 | export type NamedToken = {
53 | token: Token;
54 | named: Named;
55 | };
56 | export type OverrideToken = {
57 | token: Token;
58 | override: boolean;
59 | };
60 |
61 | export type Registry = (register: Register) => void;
62 | export type Module = {
63 | id: number;
64 | };
65 |
66 | export function isModule(data: Record | undefined): data is Module {
67 | return !!data && typeof data === 'object' && 'id' in data;
68 | }
69 |
70 | export type Container = {
71 | parent?: Container;
72 | remove: (token: Syringe.Token) => void;
73 | register: (
74 | token: Syringe.Token | Syringe.InjectOption