├── .gitignore ├── .npmignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── README.md ├── azure-pipelines.yml ├── doc ├── .gitignore ├── doczrc.js ├── package.json └── src │ ├── api.mdx │ ├── demo │ └── rx.tsx │ ├── dependency-item.mdx │ ├── injector.mdx │ ├── introduction.mdx │ ├── react.mdx │ └── rx.mdx ├── example ├── README.md ├── package.json ├── src │ ├── App.css │ ├── App.tsx │ ├── Footer.tsx │ ├── TodoItem.tsx │ ├── assets │ │ └── index.html │ ├── main.tsx │ ├── services │ │ ├── router.ts │ │ ├── state.ts │ │ ├── store │ │ │ ├── store.ts │ │ │ └── store.web.ts │ │ └── todo.ts │ └── utils │ │ ├── extend.ts │ │ ├── pluralize.ts │ │ └── uuid.ts ├── tsconfig.json └── webpack.config.js ├── jest.config.js ├── package.json ├── scripts └── jest.js ├── src ├── collection.ts ├── decorators.ts ├── idle.ts ├── index.ts ├── injector.ts ├── react │ ├── context.tsx │ ├── decorators.tsx │ ├── hooks.tsx │ └── rx.tsx ├── singleton.ts ├── typings.ts └── utils.ts ├── test ├── di-core.test.ts ├── di-react.test.tsx ├── di-rx.test.tsx └── di-singleton.test.tsx ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | */.DS_Store 2 | 3 | # dirs 4 | build/ 5 | node_modules/ 6 | dists/ 7 | coverage/ 8 | 9 | # editor 10 | .idea/ 11 | 12 | # lockfiles 13 | package-lock.json 14 | yarn.lock 15 | 16 | # build 17 | dist 18 | esm 19 | 20 | # yarn 21 | yarn-error.log 22 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | .next 4 | .vscode 5 | *.log 6 | *.tgz 7 | azure-pipelines.yml 8 | coverage 9 | doc 10 | example 11 | jest.config.js 12 | node_modules 13 | scripts 14 | test 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "semi": false, 4 | "useTabs": false, 5 | "tabWidth": 2, 6 | "bracketSpacing": true, 7 | "singleQuote": true, 8 | "jsxBracketSameLine": false, 9 | "printWidth": 80 10 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Jest Current", 8 | "program": "${workspaceFolder}/scripts/jest.js", 9 | "args": [ 10 | "${fileBasenameNoExtension}", 11 | "--config", 12 | "jest.config.js" 13 | ], 14 | "console": "integratedTerminal", 15 | "internalConsoleOptions": "neverOpen", 16 | "disableOptimisticBPs": true 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "terminal.integrated.shell.windows": "C:/Windows/system32/WindowsPowerShell/v1.0/powershell.exe", 5 | "terminal.integrated.shellArgs.windows": [ 6 | "-NoLogo" 7 | ], 8 | "cSpell.words": [ 9 | "Displayer", 10 | "Lifecycle" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # \[deprecated\] wedi 2 | 3 | wedi is deprecated. I am working on a new implementation [redi](https://github.com/wendellhu95/redi). 4 | 5 | A lightweight dependency injection (DI) library for TypeScript, along with a binding for React. 6 | 7 | [![Codecov](https://img.shields.io/codecov/c/github/wendellhu95/wedi.svg?style=flat-square)](https://codecov.io/gh/wendellhu95/wedi) 8 | [![npm package](https://img.shields.io/npm/v/wedi.svg?style=flat-square)](https://www.npmjs.org/package/wedi) 9 | ![GitHub license](https://img.shields.io/github/license/mashape/apistatus.svg?style=flat-square) 10 | 11 | --- 12 | 13 | ## What is wedi? 14 | 15 | **wedi** is a lightweight toolkit to let you use dependency injection (DI) pattern in TypeScript and especially React with TypeScript. 16 | 17 | - Completely opt-in. It's up to you to decide when and where to apply dependency injection pattern. 18 | - Provide a multi-level dependency injection system. 19 | - Support injecting classes, instances, values and factories. 20 | - Support React class component. 21 | - Support React Hooks (functional component). 22 | 23 | You can use wedi to: 24 | 25 | - Mange state of applications 26 | - Reuse logic 27 | - Deal with cross-platform problems 28 | - Write code that is loosely-coupled, easy to understand and maintain 29 | 30 | ## Getting Started 31 | 32 | _This guide assumes basic knowledge of TypeScript, React and dependency injection pattern. If you are totally innocent of any idea above, it might not be the best idea to get started with wedi._ 33 | 34 | Install wedi via npm or yarn: 35 | 36 | ```shell 37 | npm install wedi 38 | 39 | # or 40 | yarn add wedi 41 | ``` 42 | 43 | Add you need to enable decorator in tsconfig.json. 44 | 45 | ```json 46 | { 47 | "compilerOptions": { 48 | "experimentalDecorators": true 49 | } 50 | } 51 | ``` 52 | 53 | ## Declare a Dependency 54 | 55 | Declare something that another class or React component could depend on is very simple. It could just be easy as a ES6 class! 56 | 57 | ```tsx 58 | class AuthenticationService { 59 | avatar = 'https://img.yourcdn.com/avatar' 60 | } 61 | ``` 62 | 63 | wedi let you declare other kinds of dependencies such as plain values and factory functions. Read Dependencies for more details. 64 | 65 | ## Use in React 66 | 67 | You could provide a dependency in a React component, and use it in its child components. 68 | 69 | ```tsx 70 | function App() { 71 | const collection = useCollection([AuthenticationService]) 72 | 73 | return 74 | 75 | 76 | 77 | 78 | } 79 | 80 | function Profile() { 81 | const authS = useDependency(AuthenticationService) 82 | 83 | return 84 | } 85 | ``` 86 | 87 | ## With RxJS 88 | 89 | wedi provide some Hooks that helps you use wedi with RxJS smoothly. 90 | 91 | ```tsx 92 | function ReRenderOnNewValue() { 93 | const notificationS = useDependency(NotificationService) 94 | const val = useDependencyValue(notificationS.data$) 95 | 96 | // re-return when data$ emits a new value 97 | } 98 | ``` 99 | 100 | ## Demo 101 | 102 | Here is a TodoMVC [demo](https://wendellhu95.github.io/wedi-demo) built with wedi. 103 | 104 | ## Links 105 | 106 | - [GitHub Repo](https://github.com/wendellhu95/wedi) 107 | - [Doc](https://wedi.wendellhu.xyz) 108 | 109 | ## License 110 | 111 | MIT. Copyright Wendell Hu 2019-2020. 112 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Node.js with React 2 | # Build a Node.js project that uses React. 3 | # Add steps that analyze code, save build artifacts, deploy, and more: 4 | # https://docs.microsoft.com/azure/devops/pipelines/languages/javascript 5 | 6 | trigger: 7 | - master 8 | 9 | pool: 10 | vmImage: 'ubuntu-latest' 11 | 12 | stages: 13 | - stage: env 14 | jobs: 15 | - job: Nodes 16 | steps: 17 | - task: NodeTool@0 18 | inputs: 19 | versionSpec: '12.13.1' 20 | displayName: 'Install Node.js' 21 | 22 | - stage: build 23 | dependsOn: env 24 | jobs: 25 | - job: build 26 | steps: 27 | - task: Npm@1 28 | inputs: 29 | command: 'install' 30 | - script: | 31 | npm run build 32 | 33 | - stage: test 34 | dependsOn: env 35 | jobs: 36 | - job: test 37 | steps: 38 | - task: Npm@1 39 | inputs: 40 | command: 'install' 41 | - script: | 42 | npm run test 43 | cat ./coverage-report/lcov.info | ./node_modules/.bin/codecov 44 | -------------------------------------------------------------------------------- /doc/.gitignore: -------------------------------------------------------------------------------- 1 | .docz 2 | -------------------------------------------------------------------------------- /doc/doczrc.js: -------------------------------------------------------------------------------- 1 | const menu = [ 2 | 'Introduction', 3 | 'Dependency Injection', 4 | 'Dependency Item', 5 | 'React', 6 | 'With RxJS', 7 | 'API' 8 | ] 9 | 10 | export default { 11 | typescript: true, 12 | menu: menu 13 | } 14 | -------------------------------------------------------------------------------- /doc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wedi", 3 | "private": true, 4 | "scripts": { 5 | "docz:dev": "docz dev", 6 | "docz:build": "docz build", 7 | "docz:serve": "docz build && docz serve" 8 | }, 9 | "devDependencies": { 10 | "docz": "^2.3.1" 11 | }, 12 | "dependencies": { 13 | "react": "^16.13.1", 14 | "react-dom": "^16.13.1", 15 | "rxjs": "^6.5.5" 16 | }, 17 | "workspaces": [ 18 | "../../src/**/*" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /doc/src/api.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: API 3 | route: /api 4 | --- 5 | 6 | 7 | 8 | # API 9 | 10 | ## Core 11 | 12 | ### Injector 13 | 14 | Instantiate, manage, provide and destroy dependencies. 15 | 16 | ```ts 17 | export declare class Injector implements Disposable { 18 | constructor(collection: DependencyCollection, parent?: Injector) 19 | add(ctor: Ctor): void 20 | add(key: Identifier, item: DependencyValue): void 21 | createChild(dependencies?: DependencyCollection): Injector 22 | get(id: DependencyKey): T | null 23 | getOrInit(id: DependencyKey): T | null 24 | createInstance(ctor: Ctor | InitPromise, ...extraParams: any[]): T 25 | } 26 | ``` 27 | 28 | #### Constructor 29 | 30 | Create an injector. Could use the `parent` parameter to build layered injector system. 31 | 32 | #### Methods 33 | 34 | - `add`, to add a new dependency. 35 | - `createChild`, to create a child injector with a bunch of dependencies. 36 | - `get`, get a dependency. Return null if there's no existing dependency matching the given `DependencyKey`. 37 | - `getOrInit`, get a dependency, try to instantiate one if there's no existing dependency matching the given `DependencyKey` 38 | - `createInstance`, instantiate a class. If the class requires dependencies, the injector would provide them. 39 | 40 | ### DependencyCollection 41 | 42 | Hold dependencies and manage lifecycle of them. 43 | 44 | ```ts 45 | export declare class DependencyCollection implements Disposable { 46 | constructor(deps?: DependencyItem[]) 47 | add(ctor: Ctor): void 48 | add(key: DependencyKey, item: DependencyValue | T): void 49 | has(key: DependencyKey): boolean 50 | get(key: DependencyKey): T | DependencyValue | undefined 51 | } 52 | ``` 53 | 54 | ### [type] `DependencyItem` 55 | 56 | Here's valid types of a dependency item. 57 | 58 | ```tsx 59 | export declare type DependencyValue = 60 | | Ctor 61 | | ValueItem 62 | | ClassItem 63 | | FactoryItem 64 | 65 | export declare type DependencyKey = Identifier | Ctor 66 | 67 | export declare type DependencyItem = 68 | | [Identifier, DependencyValue] 69 | | Ctor 70 | ``` 71 | 72 | ### `createIdentifier` 73 | 74 | ```tsx 75 | export declare function createIdentifier(name: string): Identifier 76 | ``` 77 | 78 | Create an identifier. This identifier could also be used as a decorator to parameters of a class. 79 | 80 | ### [decorator] Optional 81 | 82 | ```tsx 83 | export declare function Optional( 84 | key: DependencyKey 85 | ): (target: Ctor, _key: string, index: number) => void 86 | ``` 87 | 88 | This decorator could be applied to parameters of a class to mark its dependencies as **optional**. 89 | 90 | ### [decorator] Need 91 | 92 | ```tsx 93 | export declare function Need( 94 | key: DependencyKey 95 | ): (target: Ctor, _key: string, index: number) => void 96 | ``` 97 | 98 | This decorator could be applied to parameters of a class to mark its dependencies as **required**. 99 | 100 | ### registerSingleton 101 | 102 | ```tsx 103 | export declare function registerSingleton( 104 | id: Identifier, 105 | ctor: Ctor, 106 | lazyInstantiation?: boolean 107 | ): void 108 | ``` 109 | 110 | Register a class dependency item as singleton with a identifier. 111 | 112 | ## React 113 | 114 | ### `Provider` 115 | 116 | Create a injection layer in your React application. 117 | 118 | ```tsx 119 | export declare class Provider extends Component; 120 | 121 | export interface InjectionProviderProps { 122 | collection?: DependencyCollection; 123 | injector?: Injector; 124 | } 125 | ``` 126 | 127 | `collection` and `injector` cannot be both `undefined`. 128 | 129 | ### Provide 130 | 131 | An decorator that could be used on a React class component to provide a injection context on that component. 132 | 133 | ```tsx 134 | export declare function Provide( 135 | items: DependencyItem[] 136 | ): (target: any) => any 137 | ``` 138 | 139 | ### `Inject` 140 | 141 | Returns decorator that could be used on a property of a React class component to inject a dependency 142 | 143 | ```tsx 144 | export declare function Inject( 145 | id: Identifier | Ctor, 146 | optional?: boolean 147 | ): (target: any, propName: string, _originDescriptor?: any) => any 148 | ``` 149 | 150 | ### `useCollection` 151 | 152 | When providing dependencies in a functional component, it would be expensive (not to mention logic incorrectness) to recreate dependencies. 153 | 154 | This API is actually a memo to return the same `DependencyCollection` in a component. 155 | 156 | ```tsx 157 | export declare function useCollection( 158 | entries?: DependencyItem[] 159 | ): DependencyCollection 160 | ``` 161 | 162 | ### `useDependency` 163 | 164 | ```tsx 165 | export declare function useDependency( 166 | key: DependencyKey, 167 | optional?: boolean 168 | ): T | null 169 | ``` 170 | 171 | ## RxJS 172 | 173 | ### `useDependencyValue` 174 | 175 | Unwrap an observable value, return it to the component for rendering, and trigger re-render when value changes 176 | 177 | **IMPORTANT**. Parent and child components should not subscribe to the same observable, otherwise unnecessary re-render would be triggered. Instead, the top-most component should subscribe and pass value of the observable to its offspring, by props or context. 178 | 179 | If you have to do that, consider using `useDependencyContext` and `useDependencyContextValue` instead. 180 | 181 | ```tsx 182 | export declare function useDependencyValue( 183 | depValue$: Observable, 184 | defaultValue?: T 185 | ): T | undefined 186 | ``` 187 | 188 | ### `useDependencyContext` & `useDependencyContextValue` 189 | 190 | Subscribe to an observable value from a service, creating a context for it so its child component won't have to subscribe again and cause unnecessary. 191 | 192 | ```tsx 193 | export declare function useDependencyContext( 194 | depValue$: Observable, 195 | defaultValue?: T 196 | ): { 197 | Provider: (props: { 198 | initialState?: T | undefined 199 | children: React.ReactNode 200 | }) => JSX.Element 201 | value: T | undefined 202 | } 203 | 204 | export declare function useDependencyContextValue( 205 | depValue$: Observable 206 | ): T | undefined 207 | ``` 208 | -------------------------------------------------------------------------------- /doc/src/demo/rx.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | useCollection, 4 | Provide, 5 | Provider, 6 | Disposable, 7 | useDependency, 8 | useDependencyValue 9 | } from '../../../src' 10 | import { Subject } from 'rxjs' 11 | import { Observable } from 'rxjs' 12 | import { interval } from 'rxjs' 13 | import { takeUntil, scan } from 'rxjs/operators' 14 | import { useState } from 'react' 15 | import { useEffect } from 'react' 16 | 17 | // 简单的 demo 并不能显示出 wedi 的威力,得实际生产场景中的需求才能发挥出来 18 | 19 | class TimerService implements Disposable { 20 | dispose$: Subject 21 | counter$: Observable 22 | 23 | constructor() { 24 | this.dispose$ = new Subject() 25 | this.counter$ = interval(1000).pipe( 26 | takeUntil(this.dispose$), 27 | scan((acc) => acc + 1, 0) 28 | ) 29 | } 30 | 31 | dispose(): void { 32 | this.dispose$.next() 33 | this.dispose$.complete() 34 | } 35 | } 36 | 37 | export function RxDemo() { 38 | const c = useCollection([TimerService]) 39 | 40 | return ( 41 | 42 | 43 | 44 | ) 45 | } 46 | 47 | function Displayer() { 48 | const tS = useDependency(TimerService) 49 | const t = useDependencyValue(tS.counter$) 50 | 51 | return
{t}
52 | } 53 | 54 | function Displayer2() { 55 | const [count, setCounter] = useState(0) 56 | 57 | useEffect(() => { 58 | const timer = setInterval(() => { 59 | setCounter(count + 1) 60 | }, 1000) 61 | 62 | return () => clearInterval(timer) 63 | }, []) 64 | 65 | return
{count}
66 | } 67 | -------------------------------------------------------------------------------- /doc/src/dependency-item.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: Dependency Item 3 | route: /dependency-item 4 | --- 5 | 6 | # Dependency Item 7 | 8 | wedi supports different kinds of dependency items, including 9 | 10 | - classes 11 | - instances and values 12 | - factory functions 13 | 14 | ## `key` of a Dependency Item 15 | 16 | When you provide a dependency item (type `DependencyItem`), you're actually injecting a pair of _key_ & _value_. The value is the dependency item, and the _key_ is the identifier of it. Specially, a ES6 class could be the _key_ and the value and the same time. 17 | 18 | _key_ is an identifier returned by calling `createIdentifier`. 19 | 20 | Not that `key` or identifier is required when providing values, instances or factory methods but optional when providing classes. 21 | 22 | ```ts 23 | import { createIdentifier } from 'wedi' 24 | 25 | export interface IPlatform { 26 | // properties 27 | // methods 28 | } 29 | 30 | export const IPlatformService = createIdentifier('platform') 31 | ``` 32 | 33 | _You can use the same name for a variable and a type in TypeScript._ 34 | 35 | ## Class as Dependency 36 | 37 | An ES6 class could be a dependency item. You can declare its dependencies in its constructor. wedi would analyze dependency relation among different classes and instantiate them correctly. 38 | 39 | You can use `Need` to declare that `FileService` depends on `IPlatformService`. 40 | 41 | ```ts 42 | class FileService { 43 | constructor(@Need(IPlatformService) private logService: IPlatformService) {} 44 | } 45 | ``` 46 | 47 | wedi would get or instantiates a `IPlatformService` before it instantiates `FileService`. And if it could not instantiate a `IPlatformService` it would throw an error. 48 | 49 | And identifiers created by `createIdentifier` could also be used to define dependency relationship. It's equivalent to the example above. 50 | 51 | ```ts 52 | class SomeService { 53 | constructor(@IPlatformService private platform: IPlatformService) {} 54 | } 55 | ``` 56 | 57 | You can also use the `Optional` decorator to declare an optional dependency. 58 | 59 | ```ts 60 | class FileService { 61 | constructor(@Optional(OptionalDependency) private op?: OptionalDependency) {} 62 | } 63 | ``` 64 | 65 | If `OptionalDependency` is not provided, wedi would not throw an error but return `null` instead to instantiate `FileService`. 66 | 67 | ## Value or Instance as Dependency 68 | 69 | It's easy to provide a value as dependency. 70 | 71 | ```ts 72 | const configDep = [IConfig, { useValue: '2020' }] 73 | ``` 74 | 75 | ## Factory Function as Dependency 76 | 77 | You can create a dependency via `useFactory` that gives the control flow back to you on initializing. 78 | 79 | ```ts 80 | const useDep = [IUserService, { 81 | useFactory: (http: IHTTPService): IUserService => new TimeSerialUserService(http, TIME), 82 | deps: [IHTTPService] // this factory depends on IHTTPService. 83 | }] 84 | ``` 85 | 86 | ## Provide Items 87 | 88 | Finally, you should wrap all items in an array and pass them to the constructor of `DependencyCollection`. 89 | 90 | ```ts 91 | const dependencies = [ 92 | LogService, 93 | FileService, 94 | [IConfig, { useValue: '2020' }], 95 | [ 96 | IUserService, 97 | { 98 | useFactory: (http: IHTTPService): IUserService => 99 | new TimeSerialUserService(http, TIME), 100 | deps: [IHTTPService] 101 | } 102 | ], 103 | [IHTTPService, WebHTTPService] 104 | ] 105 | ``` 106 | 107 | ## Singleton Dependency 108 | 109 | For dependencies that should be singleton in the application, it's recommended to use `registerSingleton`. 110 | 111 | ```ts 112 | registerSingleton(/* a dependency item */) 113 | ``` 114 | 115 | Dependencies would be provided by the root provider. In another word, the provider which is constructed without a `parent` parameter. 116 | 117 | ## Lazy Instantiation 118 | -------------------------------------------------------------------------------- /doc/src/injector.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: Dependency Injection 3 | route: /di 4 | --- 5 | 6 | # Dependency Injection 7 | 8 | In case you are not familiar with dependency injection pattern, here are three major concepts in a dependency injection system you should know: 9 | 10 | - **Dependency Item**: Anything that could be used by other classes or React components. Usually they are identified by `key`s. A dependency could be a class, a value or a function, etc. 11 | - **Provider** (a.k.a injector). The manager of dependency items. It _provides_ dependency items and instantiate or evaluate dependency items at consumers' need. 12 | - **Consumer**: They consume dependency items. They use `key`s to get dependency items. A consumers can be a dependency item at the same time. 13 | 14 | ## DependencyCollection 15 | 16 | `DependencyCollection` is used to collect dependencies. Later it would be passed into an injector. 17 | 18 | ```ts 19 | const collection = new DependencyCollection([ 20 | // ...items 21 | ]) 22 | ``` 23 | 24 | ## Injector 25 | 26 | `Injector` is the one who instantiates, provides and manages dependencies. And they will form a layered injection system. 27 | 28 | ```tsx 29 | const injector = new Injector(collection) 30 | ``` 31 | 32 | ## Multi-Layered Injector System 33 | 34 | wedi supports multi-layered injector system. In another word, every injector could have child injectors. A child injector could ask its parent injector for a dependency when it could not provide a by itself. 35 | 36 | ## Why Dependency Injection? 37 | -------------------------------------------------------------------------------- /doc/src/introduction.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: Introduction 3 | route: / 4 | --- 5 | 6 | # Introduction 7 | 8 | ## What is wedi? 9 | 10 | **wedi** is a lightweight toolkit to let you use dependency injection (DI) pattern in TypeScript and especially React with TypeScript. 11 | 12 | - Completely opt-in. It's up to you to decide when and where to apply dependency injection pattern. 13 | - Provide a multi-level dependency injection system. 14 | - Support injecting classes, instances, values and factories. 15 | - Support React class component. 16 | - Support React Hooks (functional component). 17 | 18 | You can use wedi to: 19 | 20 | - Mange state of applications 21 | - Reuse logic 22 | - Deal with cross-platform problems 23 | - Write code that is loosely-coupled, easy to understand and maintain 24 | 25 | ## Getting Started 26 | 27 | _This guide assumes basic knowledge of TypeScript, React and dependency injection pattern. If you are totally innocent of any idea above, it might not be the best idea to get started with wedi._ 28 | 29 | Install wedi via npm or yarn: 30 | 31 | ```shell 32 | npm install wedi 33 | 34 | # or 35 | yarn add wedi 36 | ``` 37 | 38 | Add you need to enable decorator in tsconfig.json. 39 | 40 | ```json 41 | { 42 | "compilerOptions": { 43 | "experimentalDecorators": true 44 | } 45 | } 46 | ``` 47 | 48 | ## Declare a Dependency 49 | 50 | Declare something that another class or React component could depend on is very simple. It could just be easy as a ES6 class! 51 | 52 | ```tsx 53 | class AuthenticationService { 54 | avatar = 'https://img.yourcdn.com/avatar' 55 | } 56 | ``` 57 | 58 | wedi let you declare other kinds of dependencies such as plain values and factory functions. Read Dependencies for more details. 59 | 60 | ## Use in React 61 | 62 | You could provide a dependency in a React component, and use it in its child components. 63 | 64 | ```tsx 65 | function App() { 66 | const collection = useCollection([AuthenticationService]) 67 | 68 | return 69 | 70 | 71 | 72 | 73 | } 74 | 75 | function Profile() { 76 | const authS = useDependency(AuthenticationService) 77 | 78 | return 79 | } 80 | ``` 81 | 82 | ## With RxJS 83 | 84 | wedi provide some Hooks that helps you use wedi with RxJS smoothly. 85 | 86 | ```tsx 87 | function ReRenderOnNewValue() { 88 | const notificationS = useDependency(NotificationService) 89 | const val = useDependencyValue(notificationS.data$) 90 | 91 | // re-return when data$ emits a new value 92 | } 93 | ``` 94 | 95 | For more, please read With [RxJS](/rx) for more details. 96 | 97 | ## Demo 98 | 99 | Here is a TodoMVC [demo](https://wendellhu95.github.io/wedi-demo) built with wedi. 100 | 101 | ## Links 102 | 103 | - [GitHub Repo](https://github.com/wendellhu95/wedi) 104 | - [Doc](https://wedi.wendellhu.xyz) 105 | 106 | ## License 107 | 108 | MIT. Copyright Wendell Hu 2019-2020. 109 | -------------------------------------------------------------------------------- /doc/src/react.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: React 3 | route: /react 4 | --- 5 | 6 | # React 7 | 8 | wedi provides API let you use dependency injection in React conveniently. 9 | 10 | ## Class Component as Provider 11 | 12 | The `Provide` decorator could inject items into the decorated component and its child components. 13 | 14 | ```ts 15 | import { Provide } from 'wedi'; 16 | import { FileService } from 'services/file'; 17 | import { IPlatformService } from 'services/platform'; 18 | 19 | @Provide([ 20 | FileService, 21 | [IPlatformService, { useClass: MobilePlatformService } ]; 22 | ]) 23 | class ClassComponent extends Component { 24 | // FileService and IPlatformService is accessible in the component and its children 25 | } 26 | ``` 27 | 28 | ## Class Component as Consumer 29 | 30 | If you would like consume dependencies in a class component, you should assign `contextType` to be `InjectionContext` and get dependencies using the `Inject` decorator. 31 | 32 | ```ts 33 | import { Inject, InjectionContext } from 'wedi' 34 | import { IPlatformService } from 'services/platform' 35 | 36 | class ClassConsumer extends Component { 37 | static contextType = InjectionContext 38 | 39 | @Inject(FileService) fileService!: FileService // accessible to all methods of this class 40 | } 41 | ``` 42 | 43 | A class component can consume items provided by itself. 44 | 45 | ```ts 46 | import { Inject, InjectionContext, Provide } from 'wedi'; 47 | import { IPlatformService } from 'services/platform'; 48 | 49 | @Provide([ 50 | FileService, 51 | [IPlatformService, { useClass: MobilePlatformService }]; 52 | ]) 53 | class ClassComponent extends Component { 54 | static contextType = InjectionContext; 55 | 56 | @Inject(IPlatformService) platformService!: IPlatformService; // this is MobilePlatformService 57 | } 58 | ``` 59 | 60 | You can pass `true` as the second parameter of `Inject` to indicate that a dependency is optional. 61 | 62 | ```ts 63 | class ClassComponent extends Component { 64 | static contextType = InjectionContext 65 | 66 | @Inject(CanBeNullService, true) canBeNullService?: CanBeNullService // this can be null 67 | } 68 | ``` 69 | 70 | ## Functional Component as Provider 71 | 72 | `useCollection` and `InjectionLayer` could make functional components as providers and make sure that dependencies wouldn't get re-instantiated when components re-render. 73 | 74 | ```tsx 75 | import { useCollection, Provider } from 'wedi' 76 | 77 | function FunctionProvider() { 78 | const collection = useCollection([FileService]) 79 | 80 | return ( 81 | 82 | {/* Child components can use FileService. */} 83 | 84 | ) 85 | } 86 | ``` 87 | 88 | You could also use injectors directly. But this is only recommended when the injector is outside of the React component tree. 89 | 90 | ```tsx 91 | const injectorFromAnOtherPartOfYourProgram = getInjector() 92 | 93 | function YourReactRoot(props: { injector: Injector }) { 94 | return ( 95 | 96 | 97 | 98 | ) 99 | } 100 | 101 | ReactDOM.render( 102 | , 105 | containerEl 106 | ) 107 | ``` 108 | 109 | In this way, you could easily integrate React with other part of you application easily. 110 | 111 | You can see that when a component tries to get a dependency, it would always ask the **nearest** provider for it, which means you could use scoped state management with wedi. 112 | 113 | ## Functional Component as Consumer 114 | 115 | `useDependency` can help you to hook in dependencies. You can assign the second parameter `true` to mark the injected dependency as optional. 116 | 117 | ```tsx 118 | import { useDependency } from 'wedi' 119 | import { FileService } from 'services/file' 120 | import { LogService } from 'services/log' 121 | 122 | function FunctionConsumer() { 123 | const fileService: FileService = useDependency(FileService) 124 | const log: LogService | null = useDependency(LogService, true) 125 | 126 | return { 127 | /* use dependencies */ 128 | } 129 | } 130 | ``` 131 | 132 | Note that functional cannot consume items provided by itself. By you could use `connectProvider` to make things easier. 133 | 134 | ```tsx 135 | import { FileService } from 'services/file' 136 | import { LogService } from 'services/log' 137 | 138 | const FunctionConsumer = connectProvider((function() { 139 | const fileService: FileService = useDependency(FileService) 140 | const log: LogService | null = useDependency(LogService, true) 141 | 142 | return { 143 | /* use dependencies */ 144 | }), { 145 | collection: new DependencyCollection([FileService, LogService]) 146 | }) 147 | } 148 | ``` 149 | 150 | ## Multi-Layered Injector System 151 | 152 | injectors of wedi could have child components and React components could have child components. Combined, you could use multi-layered injector system in React seamlessly. 153 | 154 | ```tsx 155 | @Provide([ 156 | [IConfig, { useValue: 'A' }], 157 | [IConfigRoot, { useValue: 'inRoot' }] 158 | ]) 159 | class ParentProvider extends Component { 160 | render() { 161 | return 162 | } 163 | } 164 | 165 | @Provide([[IConfig, { useValue: 'B' }]]) 166 | class ChildProvider extends Component { 167 | render() { 168 | return 169 | } 170 | } 171 | 172 | function Consumer() { 173 | const config = useDependency(IConfig) 174 | const rootConfig = useDependency(IConfigRoot) 175 | 176 | return ( 177 |
178 | {config}, {rootConfig} 179 |
//
B, inRoot
180 | ) 181 | } 182 | ``` 183 | 184 | ## Inject React Component 185 | 186 | You could inject React Component as a dependency, too. 187 | 188 | ```tsx 189 | const IDropdown = createIdentifier('dropdown') 190 | const IConfig = createIdentifier('config') 191 | 192 | const WebDropdown = function() { 193 | const dep = useDependency(IConfig) // could use dependencies in its host environment 194 | return
WeDropdown, {dep}
195 | } 196 | 197 | @Provide([ 198 | [IDropdown, { useValue: WebDropdown }], 199 | [IConfig, { useValue: 'wedi' }] 200 | ]) 201 | class Header extends Component { 202 | static contextType = InjectionContext 203 | 204 | @Inject(IDropdown) private dropdown: any 205 | 206 | render() { 207 | const Dropdown = this.dropdown 208 | return // WeDropdown, wedi 209 | } 210 | } 211 | ``` 212 | -------------------------------------------------------------------------------- /doc/src/rx.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: With RxJS 3 | route: /rx 4 | --- 5 | 6 | # With RxJS 7 | 8 | _This guide assumes that you have a basic knowledge of RxJS and reactive programming._ 9 | 10 | wedi gives you a clear model for state management and logic reuse. With RxJS, wedi brings reactive programming to your application. 11 | 12 | ## An Example 13 | 14 | Here is a real-life example. Assuming that we need to fetch latest notifications every 10 seconds after the application bootstraps and the header bar is rendered on the screen. And when the previous request for notification gets delayed, we need to drop it when the current request is sent. 15 | 16 | See the RxJS + wedi version: 17 | 18 | ```tsx 19 | class NotificationService implements Disposable { 20 | destroy$: Subject() 21 | notifications$: Observable> 22 | 23 | constructor( 24 | @Need(ILifecycleService) lifecycleS: ILifecycleService, 25 | @Need(IHttpService) httpS: IHttpService 26 | ) { 27 | this.destroy = new Subject(); 28 | this.notificationS = this.lifecycleS.bootstrap$ 29 | .pipe( 30 | take(1), 31 | concatMap(() => interval(10000)), 32 | concatMap(() => httpS.request(/* some url */)), 33 | startWith([]), 34 | takeUntil(this.destroy$), 35 | ) 36 | } 37 | 38 | dispose(): void { 39 | this.destroy$.next() 40 | this.destroy$.complete() 41 | } 42 | } 43 | 44 | function Header() { 45 | const collection = useCollection([NotificationService]) 46 | 47 | return 48 | 49 | 50 | 51 | 52 | } 53 | 54 | 55 | function NotificationDisplayer() { 56 | const notiS = useDependency(NotificationService) 57 | const notifications = useDependencyValue(notiS.notifications$) 58 | 59 | // render notifications 60 | } 61 | ``` 62 | 63 | You can see that the code is concise, declarative, easy to understand and maintain. Logic is completely moved from components to services. 64 | 65 | In fact, you could use any reactive programming library with wedi since it just provide a framework on which you can put observables and subscriptions. But wedi provides Hooks that works with RxJS to make your life easier. 66 | 67 | ## Value Context 68 | 69 | Sometimes you need to subscribe to the same observable value in a component and its child components. You could use `useDependencyValue`. But this would cause unnecessary reconciliation. You could subscribe in the parent component and pass values to the child components, but it would be troublesome if the child component is deeply wrapped. So it's nature to use React Context here. wedi provides `useDependencyContext` and `useDependencyContextValue` to make this easier. 70 | 71 | ```tsx 72 | function Parent() { 73 | const authS = useDependency(AuthenticationService) 74 | const { Provider: AuthProvider } = useDependencyContext(authS.auth$, {}) 75 | 76 | return ( 77 | 78 | 79 | 80 | ) 81 | } 82 | 83 | function Child() { 84 | const authS = useDependency(AuthenticationService) 85 | const auth = useDependencyContextValue(authS.auth$) 86 | 87 | // adjust UI according to authentication info 88 | } 89 | ``` 90 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | The demo is using 'react' and 'react-dom' from the outside 'node_modules' to avoid 'calling hooks warning'. 2 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wedi-demo", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "start": "webpack-dev-server --mode development --hot --progress --color --port 3000 --open", 6 | "build": "webpack -p --progress --colors" 7 | }, 8 | "devDependencies": { 9 | "@babel/core": "^7.2.2", 10 | "@types/classnames": "^2.2.9", 11 | "@types/node": "^10.12.18", 12 | "@types/react": "^16.8.2", 13 | "@types/react-dom": "^16.8.0", 14 | "@types/webpack": "^4.4.23", 15 | "@types/webpack-env": "1.13.6", 16 | "babel-loader": "^8.0.5", 17 | "classnames": "^2.2.6", 18 | "clean-webpack-plugin": "^2.0.1", 19 | "css-loader": "^2.1.0", 20 | "html-loader": "^1.0.0-alpha.0", 21 | "html-webpack-plugin": "^4.0.0-alpha", 22 | "husky": "^3.1.0", 23 | "jest": "^24.9.0", 24 | "react-hot-loader": "^4.12.18", 25 | "style-loader": "^0.23.1", 26 | "ts-loader": "^5.3.3", 27 | "tslint": "^5.20.1", 28 | "typescript": "^3.7.0", 29 | "webpack": "^4.28.4", 30 | "webpack-cli": "^3.2.1", 31 | "webpack-dev-server": "^3.1.14", 32 | "webpack-merge": "^4.2.2" 33 | }, 34 | "dependencies": { 35 | "wedi": "link:../" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /example/src/App.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | button { 8 | margin: 0; 9 | padding: 0; 10 | border: 0; 11 | background: none; 12 | font-size: 100%; 13 | vertical-align: baseline; 14 | font-family: inherit; 15 | font-weight: inherit; 16 | color: inherit; 17 | -webkit-appearance: none; 18 | appearance: none; 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | } 22 | 23 | body { 24 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; 25 | line-height: 1.4em; 26 | background: #f5f5f5; 27 | color: #4d4d4d; 28 | min-width: 230px; 29 | max-width: 550px; 30 | margin: 0 auto; 31 | -webkit-font-smoothing: antialiased; 32 | -moz-osx-font-smoothing: grayscale; 33 | font-weight: 300; 34 | } 35 | 36 | :focus { 37 | outline: 0; 38 | } 39 | 40 | .hidden { 41 | display: none; 42 | } 43 | 44 | .todoapp { 45 | background: #fff; 46 | margin: 130px 0 40px 0; 47 | position: relative; 48 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1); 49 | } 50 | 51 | .todoapp input::-webkit-input-placeholder { 52 | font-style: italic; 53 | font-weight: 300; 54 | color: #e6e6e6; 55 | } 56 | 57 | .todoapp input::-moz-placeholder { 58 | font-style: italic; 59 | font-weight: 300; 60 | color: #e6e6e6; 61 | } 62 | 63 | .todoapp input::input-placeholder { 64 | font-style: italic; 65 | font-weight: 300; 66 | color: #e6e6e6; 67 | } 68 | 69 | .todoapp h1 { 70 | position: absolute; 71 | top: -155px; 72 | width: 100%; 73 | font-size: 100px; 74 | font-weight: 100; 75 | text-align: center; 76 | color: rgba(175, 47, 47, 0.15); 77 | -webkit-text-rendering: optimizeLegibility; 78 | -moz-text-rendering: optimizeLegibility; 79 | text-rendering: optimizeLegibility; 80 | } 81 | 82 | .new-todo, 83 | .edit { 84 | position: relative; 85 | margin: 0; 86 | width: 100%; 87 | font-size: 24px; 88 | font-family: inherit; 89 | font-weight: inherit; 90 | line-height: 1.4em; 91 | border: 0; 92 | color: inherit; 93 | padding: 6px; 94 | border: 1px solid #999; 95 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); 96 | box-sizing: border-box; 97 | -webkit-font-smoothing: antialiased; 98 | -moz-osx-font-smoothing: grayscale; 99 | } 100 | 101 | .new-todo { 102 | padding: 16px 16px 16px 60px; 103 | border: none; 104 | background: rgba(0, 0, 0, 0.003); 105 | box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); 106 | } 107 | 108 | .main { 109 | position: relative; 110 | z-index: 2; 111 | border-top: 1px solid #e6e6e6; 112 | } 113 | 114 | .toggle-all { 115 | text-align: center; 116 | border: none; /* Mobile Safari */ 117 | opacity: 0; 118 | position: absolute; 119 | } 120 | 121 | .toggle-all + label { 122 | width: 60px; 123 | height: 34px; 124 | font-size: 0; 125 | position: absolute; 126 | top: 14px; 127 | left: -13px; 128 | -webkit-transform: rotate(90deg); 129 | transform: rotate(90deg); 130 | } 131 | 132 | .toggle-all + label:before { 133 | content: '❯'; 134 | font-size: 22px; 135 | color: #e6e6e6; 136 | padding: 10px 27px 10px 27px; 137 | } 138 | 139 | .toggle-all:checked + label:before { 140 | color: #737373; 141 | } 142 | 143 | .todo-list { 144 | margin: 0; 145 | padding: 0; 146 | list-style: none; 147 | } 148 | 149 | .todo-list li { 150 | position: relative; 151 | font-size: 24px; 152 | border-bottom: 1px solid #ededed; 153 | } 154 | 155 | .todo-list li:last-child { 156 | border-bottom: none; 157 | } 158 | 159 | .todo-list li.editing { 160 | border-bottom: none; 161 | padding: 0; 162 | } 163 | 164 | .todo-list li.editing .edit { 165 | display: block; 166 | width: 506px; 167 | padding: 12px 16px; 168 | margin: 0 0 0 43px; 169 | } 170 | 171 | .todo-list li.editing .view { 172 | display: none; 173 | } 174 | 175 | .todo-list li .toggle { 176 | text-align: center; 177 | width: 40px; 178 | /* auto, since non-WebKit browsers doesn't support input styling */ 179 | height: auto; 180 | position: absolute; 181 | top: 0; 182 | bottom: 0; 183 | margin: auto 0; 184 | border: none; /* Mobile Safari */ 185 | -webkit-appearance: none; 186 | appearance: none; 187 | } 188 | 189 | .todo-list li .toggle { 190 | opacity: 0; 191 | } 192 | 193 | .todo-list li .toggle + label { 194 | /* 195 | Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 196 | IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ 197 | */ 198 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); 199 | background-repeat: no-repeat; 200 | background-position: center left; 201 | } 202 | 203 | .todo-list li .toggle:checked + label { 204 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); 205 | } 206 | 207 | .todo-list li label { 208 | word-break: break-all; 209 | padding: 15px 15px 15px 60px; 210 | display: block; 211 | line-height: 1.2; 212 | transition: color 0.4s; 213 | } 214 | 215 | .todo-list li.completed label { 216 | color: #d9d9d9; 217 | text-decoration: line-through; 218 | } 219 | 220 | .todo-list li .destroy { 221 | display: none; 222 | position: absolute; 223 | top: 0; 224 | right: 10px; 225 | bottom: 0; 226 | width: 40px; 227 | height: 40px; 228 | margin: auto 0; 229 | font-size: 30px; 230 | color: #cc9a9a; 231 | margin-bottom: 11px; 232 | transition: color 0.2s ease-out; 233 | } 234 | 235 | .todo-list li .destroy:hover { 236 | color: #af5b5e; 237 | } 238 | 239 | .todo-list li .destroy:after { 240 | content: '×'; 241 | } 242 | 243 | .todo-list li:hover .destroy { 244 | display: block; 245 | } 246 | 247 | .todo-list li .edit { 248 | display: none; 249 | } 250 | 251 | .todo-list li.editing:last-child { 252 | margin-bottom: -1px; 253 | } 254 | 255 | .footer { 256 | color: #777; 257 | padding: 10px 15px; 258 | height: 20px; 259 | text-align: center; 260 | border-top: 1px solid #e6e6e6; 261 | } 262 | 263 | .footer:before { 264 | content: ''; 265 | position: absolute; 266 | right: 0; 267 | bottom: 0; 268 | left: 0; 269 | height: 50px; 270 | overflow: hidden; 271 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 8px 0 -3px #f6f6f6, 272 | 0 9px 1px -3px rgba(0, 0, 0, 0.2), 0 16px 0 -6px #f6f6f6, 273 | 0 17px 2px -6px rgba(0, 0, 0, 0.2); 274 | } 275 | 276 | .todo-count { 277 | float: left; 278 | text-align: left; 279 | } 280 | 281 | .todo-count strong { 282 | font-weight: 300; 283 | } 284 | 285 | .filters { 286 | margin: 0; 287 | padding: 0; 288 | list-style: none; 289 | position: absolute; 290 | right: 0; 291 | left: 0; 292 | } 293 | 294 | .filters li { 295 | display: inline; 296 | } 297 | 298 | .filters li a { 299 | color: inherit; 300 | margin: 3px; 301 | padding: 3px 7px; 302 | text-decoration: none; 303 | border: 1px solid transparent; 304 | border-radius: 3px; 305 | } 306 | 307 | .filters li a:hover { 308 | border-color: rgba(175, 47, 47, 0.1); 309 | } 310 | 311 | .filters li a.selected { 312 | border-color: rgba(175, 47, 47, 0.2); 313 | } 314 | 315 | .clear-completed, 316 | html .clear-completed:active { 317 | float: right; 318 | position: relative; 319 | line-height: 20px; 320 | text-decoration: none; 321 | cursor: pointer; 322 | } 323 | 324 | .clear-completed:hover { 325 | text-decoration: underline; 326 | } 327 | 328 | .info { 329 | margin: 65px auto 0; 330 | color: #bfbfbf; 331 | font-size: 10px; 332 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); 333 | text-align: center; 334 | } 335 | 336 | .info p { 337 | line-height: 1; 338 | } 339 | 340 | .info a { 341 | color: inherit; 342 | text-decoration: none; 343 | font-weight: 400; 344 | } 345 | 346 | .info a:hover { 347 | text-decoration: underline; 348 | } 349 | 350 | /* 351 | Hack to remove background from Mobile Safari. 352 | Can't use it globally since it destroys checkboxes in Firefox 353 | */ 354 | @media screen and (-webkit-min-device-pixel-ratio: 0) { 355 | .toggle-all, 356 | .todo-list li .toggle { 357 | background: none; 358 | } 359 | 360 | .todo-list li .toggle { 361 | height: 40px; 362 | } 363 | } 364 | 365 | @media (max-width: 430px) { 366 | .footer { 367 | height: 50px; 368 | } 369 | 370 | .filters { 371 | bottom: 10px; 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { KeyboardEvent, useRef } from 'react'; 2 | import { hot } from 'react-hot-loader'; 3 | import { Provider, useCollection, useDependency, useUpdateBinder } from 'wedi'; 4 | 5 | import './App.css'; 6 | 7 | import Footer from './Footer'; 8 | import { RouterService } from './services/router'; 9 | import { StateService } from './services/state'; 10 | import { TodoService } from './services/todo'; 11 | import TodoItem from './TodoItem'; 12 | 13 | function AppContainer() { 14 | const collection = useCollection([TodoService, StateService, RouterService]); 15 | 16 | return ( 17 | 18 | 19 | 20 | ); 21 | } 22 | 23 | function TodoMVC() { 24 | const stateService = useDependency(StateService)!; 25 | const todoService = useDependency(TodoService)!; 26 | const inputRef = useRef(null); 27 | 28 | useUpdateBinder(stateService.updated$.asObservable()); 29 | useUpdateBinder(todoService.updated$.asObservable()); 30 | 31 | function handleKeydown(e: KeyboardEvent): void { 32 | if (e.keyCode !== 13) { 33 | return; 34 | } 35 | 36 | e.preventDefault(); 37 | 38 | const val = inputRef.current?.value; 39 | 40 | if (val) { 41 | todoService.addTodo(val); 42 | inputRef.current!.value = ''; 43 | } 44 | } 45 | 46 | const todoItems = todoService.shownTodos.map((todo) => { 47 | return ; 48 | }); 49 | 50 | const todoPart = todoService.todoCount ? ( 51 |
52 | todoService.toggleAll(e.target.checked)} 57 | checked={todoService.activeTodoCount === 0} 58 | /> 59 | 60 |
    {todoItems}
61 |
62 | ) : null; 63 | 64 | const footerPart = todoService.todoCount ?
: null; 65 | 66 | return ( 67 |
68 |
69 |

todos

70 | 78 |
79 | {todoPart} 80 | {footerPart} 81 |
82 | ); 83 | } 84 | 85 | export default hot(module)(() => ); 86 | -------------------------------------------------------------------------------- /example/src/Footer.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React from 'react'; 3 | 4 | import { useDependency } from 'wedi'; 5 | 6 | import { SHOWING, StateService } from './services/state'; 7 | import { TodoService } from './services/todo'; 8 | import { pluralize } from './utils/pluralize'; 9 | 10 | export default function Footer() { 11 | const todoService = useDependency(TodoService)!; 12 | const stateService = useDependency(StateService)!; 13 | 14 | return ( 15 |
16 | 17 | {todoService.activeTodoCount}{' '} 18 | {pluralize(todoService.activeTodoCount, 'item')} left 19 | 20 | 52 | {todoService.completedCount > 0 ? ( 53 | 59 | ) : null} 60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /example/src/TodoItem.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React, { FormEvent, KeyboardEvent, useRef, useState } from 'react'; 3 | 4 | import { useDependency } from 'wedi'; 5 | 6 | import { StateService } from './services/state'; 7 | import { ITodo, TodoService } from './services/todo'; 8 | 9 | export interface ITodoItemProps { 10 | key: string; 11 | todo: ITodo; 12 | onEdit?(todo: ITodo): void; 13 | onSave?(todo: ITodo): void; 14 | onCancel?(): void; 15 | } 16 | 17 | export default function TodoItem(props: ITodoItemProps) { 18 | const { todo } = props; 19 | 20 | const [inputValue, setInputValue] = useState(todo.title); 21 | const inputRef = useRef(null); 22 | const todoService = useDependency(TodoService); 23 | const stateService = useDependency(StateService); 24 | 25 | const handleEdit = function() { 26 | setInputValue(todo.title); 27 | 28 | stateService?.setEditing(todo.id); 29 | 30 | setTimeout(() => inputRef?.current!.focus(), 16); 31 | }; 32 | 33 | const handleSubmit = function(e: FormEvent) { 34 | const val = inputValue.trim(); 35 | 36 | stateService?.setEditing(''); 37 | 38 | if (val) { 39 | setInputValue(val); 40 | todoService?.save(todo, val); 41 | } else { 42 | todoService?.destroy(todo); 43 | } 44 | }; 45 | 46 | const handleKeydown = function(e: KeyboardEvent) { 47 | if (e.keyCode === 27) { 48 | setInputValue(todo.title); 49 | } else if (e.keyCode === 13) { 50 | handleSubmit(e); 51 | } 52 | }; 53 | 54 | return ( 55 |
  • 61 |
    62 | todoService?.toggle(todo)} 67 | /> 68 | 69 | 73 |
    74 | handleSubmit(e)} 79 | onChange={(e) => setInputValue(e.target.value)} 80 | onKeyDown={(e) => handleKeydown(e)} 81 | /> 82 |
  • 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /example/src/assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | React + DI • TodoMVC 8 | 9 | 10 | 11 |
    12 |
    13 |

    Double-click to edit a todo

    14 |

    Created by Wendell Hu

    15 |

    Part of TodoMVC

    16 |
    17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /example/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { registerSingleton } from 'wedi'; 4 | 5 | import App from './App'; 6 | 7 | import { IStoreService } from './services/store/store'; 8 | import { LocalStoreService } from './services/store/store.web'; 9 | 10 | registerSingleton(IStoreService, LocalStoreService); 11 | 12 | ReactDOM.render(, document.getElementsByClassName('todoapp')[0]); 13 | -------------------------------------------------------------------------------- /example/src/services/router.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs'; 2 | 3 | export class RouterService { 4 | router$ = new Subject(); 5 | 6 | constructor() { 7 | window.addEventListener('hashchange', (e) => { 8 | const url = e.newURL; 9 | const segment = url.split('#')[1] || '/'; 10 | 11 | this.router$.next(segment); 12 | }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /example/src/services/state.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs'; 2 | import { Need } from 'wedi'; 3 | 4 | import { RouterService } from './router'; 5 | 6 | export enum SHOWING { 7 | ALL_TODOS, 8 | ACTIVE_TODOS, 9 | COMPLETED_TODOS 10 | } 11 | 12 | export class StateService { 13 | nowShowing: SHOWING = SHOWING.ALL_TODOS; 14 | editing?: string; 15 | updated$ = new Subject(); 16 | 17 | constructor(@Need(RouterService) private routerService: RouterService) { 18 | this.routerService.router$.subscribe((router) => { 19 | this.nowShowing = 20 | router === '/active' 21 | ? SHOWING.ACTIVE_TODOS 22 | : router === '/completed' 23 | ? SHOWING.COMPLETED_TODOS 24 | : SHOWING.ALL_TODOS; 25 | this.updated$.next(); 26 | }); 27 | } 28 | 29 | setEditing(id: string): void { 30 | this.editing = id; 31 | this.update(); 32 | } 33 | 34 | private update(): void { 35 | this.updated$.next(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /example/src/services/store/store.ts: -------------------------------------------------------------------------------- 1 | import { createIdentifier } from 'wedi'; 2 | 3 | export interface IStoreService { 4 | store(namespace: string): any; 5 | store(namespace: string, data: any): void; 6 | } 7 | 8 | export const IStoreService = createIdentifier('store'); 9 | -------------------------------------------------------------------------------- /example/src/services/store/store.web.ts: -------------------------------------------------------------------------------- 1 | import { IStoreService } from './store'; 2 | 3 | export class LocalStoreService implements IStoreService { 4 | store(namespace: string): any; 5 | store(namespace: string, data: any): void; 6 | store(namespace: string, data?: any): void | any { 7 | if (data) { 8 | return localStorage.setItem(namespace, JSON.stringify(data)); 9 | } else { 10 | const store = localStorage.getItem(namespace); 11 | return (store && JSON.parse(store)) || []; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /example/src/services/todo.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs'; 2 | import { Need } from 'wedi'; 3 | 4 | import { SHOWING, StateService } from './state'; 5 | import { IStoreService } from './store/store'; 6 | 7 | function uuid(): string { 8 | /*jshint bitwise:false */ 9 | let i; 10 | let random; 11 | let id = ''; 12 | 13 | for (i = 0; i < 32; i++) { 14 | random = (Math.random() * 16) | 0; 15 | if (i === 8 || i === 12 || i === 16 || i === 20) { 16 | id += '-'; 17 | } 18 | id += (i === 12 ? 4 : i === 16 ? (random & 3) | 8 : random).toString(16); 19 | } 20 | 21 | return id; 22 | } 23 | 24 | export interface ITodo { 25 | id: string; 26 | title: string; 27 | completed: boolean; 28 | } 29 | 30 | /** 31 | * Storing all todo items, and provide methods to manipulate them. 32 | */ 33 | export class TodoService { 34 | todos: ITodo[]; 35 | updated$ = new Subject(); 36 | 37 | get shownTodos(): ITodo[] { 38 | return this.todos.filter((todo) => { 39 | switch (this.stateService.nowShowing) { 40 | case SHOWING.ACTIVE_TODOS: 41 | return !todo.completed; 42 | case SHOWING.COMPLETED_TODOS: 43 | return todo.completed; 44 | default: 45 | return true; 46 | } 47 | }); 48 | } 49 | 50 | get todoCount(): number { 51 | return this.todos.length; 52 | } 53 | 54 | get activeTodoCount(): number { 55 | return this.todos.reduce( 56 | (acc, todo) => (todo.completed ? acc : acc + 1), 57 | 0 58 | ); 59 | } 60 | 61 | get completedCount(): number { 62 | return this.todos.length - this.activeTodoCount; 63 | } 64 | 65 | constructor( 66 | @Need(StateService) private stateService: StateService, 67 | @IStoreService private storeService: IStoreService 68 | ) { 69 | this.todos = this.storeService.store('TODO'); 70 | } 71 | 72 | inform() { 73 | this.storeService.store('TODO', this.todos); 74 | this.updated$.next(); 75 | } 76 | 77 | addTodo(title: string): void { 78 | this.todos = this.todos.concat({ 79 | id: uuid(), 80 | title: title, 81 | completed: false 82 | }); 83 | 84 | this.inform(); 85 | } 86 | 87 | toggleAll(checked: boolean): void { 88 | this.todos = this.todos.map((todo: ITodo) => ({ 89 | ...todo, 90 | completed: checked 91 | })); 92 | 93 | this.inform(); 94 | } 95 | 96 | toggle(todoToToggle: ITodo) { 97 | this.todos = this.todos.map((todo: ITodo) => { 98 | return todo !== todoToToggle 99 | ? todo 100 | : { ...todo, completed: !todo.completed }; 101 | }); 102 | 103 | this.inform(); 104 | } 105 | 106 | destroy(todo: ITodo) { 107 | this.todos = this.todos.filter((candidate) => candidate !== todo); 108 | 109 | this.inform(); 110 | } 111 | 112 | save(todoToSave: ITodo, text: string) { 113 | this.todos = this.todos.map((todo) => 114 | todo !== todoToSave ? todo : { ...todo, title: text } 115 | ); 116 | 117 | this.inform(); 118 | } 119 | 120 | clearCompleted() { 121 | this.todos = this.todos.filter((todo) => !todo.completed); 122 | 123 | this.inform(); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /example/src/utils/extend.ts: -------------------------------------------------------------------------------- 1 | export function extend(...objs: any[]): any { 2 | const newObj: any = {}; 3 | for (let i = 0; i < objs.length; i++) { 4 | const obj = objs[i]; 5 | for (const key in obj) { 6 | if (obj.hasOwnProperty(key)) { 7 | newObj[key] = obj[key]; 8 | } 9 | } 10 | } 11 | return newObj; 12 | } 13 | -------------------------------------------------------------------------------- /example/src/utils/pluralize.ts: -------------------------------------------------------------------------------- 1 | export function pluralize(count: number, word: string): string { 2 | return count === 1 ? word : word + 's'; 3 | } 4 | -------------------------------------------------------------------------------- /example/src/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | export function uuid(): string { 2 | /*jshint bitwise:false */ 3 | let i; 4 | let random; 5 | let id = ''; 6 | 7 | for (i = 0; i < 32; i++) { 8 | random = (Math.random() * 16) | 0; 9 | if (i === 8 || i === 12 || i === 16 || i === 20) { 10 | id += '-'; 11 | } 12 | id += (i === 12 ? 4 : i === 16 ? (random & 3) | 8 : random).toString(16); 13 | } 14 | 15 | return id; 16 | } 17 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "jsx": "react", 5 | "lib": [ 6 | "esnext", 7 | "dom" 8 | ], 9 | "sourceMap": true, 10 | "target": "es5", 11 | "outDir": "dist", 12 | "module": "commonjs", 13 | "moduleResolution": "node", 14 | "strict": true, 15 | "declaration": true, 16 | "allowSyntheticDefaultImports": true, 17 | "experimentalDecorators": true, 18 | "noUnusedLocals": true, 19 | "esModuleInterop": true, 20 | "types": [ 21 | "node", 22 | "jest" 23 | ], 24 | "typeRoots": [ 25 | "./node_modules/@types" 26 | ] 27 | }, 28 | "include": [ 29 | "src/**/*" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const path = require('path'); 3 | const package = require('./package.json'); 4 | 5 | // constiables 6 | const isProduction = 7 | process.argv.indexOf('-p') >= 0 || process.env.NODE_ENV === 'production'; 8 | const sourcePath = path.join(__dirname, './src'); 9 | const outPath = path.join(__dirname, './demo'); 10 | 11 | // plugins 12 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 13 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 14 | 15 | module.exports = { 16 | context: sourcePath, 17 | entry: { 18 | app: './main.tsx' 19 | }, 20 | output: { 21 | path: outPath, 22 | filename: isProduction ? '[contenthash].js' : '[hash].js', 23 | chunkFilename: isProduction ? '[name].[contenthash].js' : '[name].[hash].js' 24 | }, 25 | target: 'web', 26 | resolve: { 27 | extensions: ['.js', '.ts', '.tsx'], 28 | // Fix webpack's default behavior to not load packages with jsnext:main module 29 | // (jsnext:main directs not usually distributable es6 format, but es6 sources) 30 | mainFields: ['module', 'browser', 'main'], 31 | alias: { 32 | lib: path.resolve(__dirname, 'src/lib'), 33 | example: path.resolve(__dirname, 'src/example') 34 | } 35 | }, 36 | module: { 37 | rules: [ 38 | { 39 | test: /\.tsx?$/, 40 | use: [ 41 | !isProduction && { 42 | loader: 'babel-loader', 43 | options: { plugins: ['react-hot-loader/babel'] } 44 | }, 45 | { 46 | loader: 'ts-loader' 47 | } 48 | ].filter(Boolean) 49 | }, 50 | { 51 | test: /\.css$/, 52 | use: ['style-loader', 'css-loader'] 53 | }, 54 | { test: /\.html$/, use: 'html-loader' } 55 | ] 56 | }, 57 | optimization: { 58 | splitChunks: { 59 | name: true, 60 | cacheGroups: { 61 | commons: { 62 | chunks: 'initial', 63 | minChunks: 2 64 | }, 65 | vendors: { 66 | test: /[\\/]node_modules[\\/]/, 67 | chunks: 'all', 68 | filename: isProduction 69 | ? 'vendor.[contenthash].js' 70 | : 'vendor.[hash].js', 71 | priority: -10 72 | } 73 | } 74 | }, 75 | runtimeChunk: true 76 | }, 77 | plugins: [ 78 | new webpack.EnvironmentPlugin({ 79 | NODE_ENV: 'development', // use 'development' unless process.env.NODE_ENV is defined 80 | DEBUG: false 81 | }), 82 | new CleanWebpackPlugin(), 83 | new HtmlWebpackPlugin({ 84 | template: 'assets/index.html', 85 | minify: { 86 | minifyJS: true, 87 | minifyCSS: true, 88 | removeComments: true, 89 | useShortDoctype: true, 90 | collapseWhitespace: true, 91 | collapseInlineTagWhitespace: true 92 | }, 93 | append: { 94 | head: `` 95 | }, 96 | meta: { 97 | title: package.name, 98 | description: package.description, 99 | keywords: Array.isArray(package.keywords) 100 | ? package.keywords.join(',') 101 | : undefined 102 | } 103 | }) 104 | ], 105 | devServer: { 106 | contentBase: sourcePath, 107 | hot: true, 108 | inline: true, 109 | historyApiFallback: { 110 | disableDotRule: true 111 | }, 112 | stats: 'minimal', 113 | clientLogLevel: 'warning' 114 | }, 115 | // https://webpack.js.org/configuration/devtool/ 116 | devtool: isProduction ? 'hidden-source-map' : 'cheap-module-eval-source-map', 117 | node: { 118 | // workaround for webpack-dev-server issue 119 | // https://github.com/webpack/webpack-dev-server/issues/60#issuecomment-103411179 120 | fs: 'empty', 121 | net: 'empty' 122 | } 123 | }; 124 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/test'], 3 | testMatch: ['**/*.test.ts', '**/*.test.tsx'], 4 | transform: { 5 | '^.+\\.(ts|tsx)$': 'ts-jest' 6 | }, 7 | moduleNameMapper: { 8 | wedi: '/src/index.ts' 9 | }, 10 | moduleDirectories: ['.', 'src', 'node_modules'] 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wedi", 3 | "version": "0.6.0", 4 | "author": "Wendell Hu ", 5 | "description": "A lightweight dependency injection (DI) library for TypeScript, along with a binding for React.", 6 | "main": "./dist/index.js", 7 | "module": "./esm/index.js", 8 | "react-native": "./esm/index.js", 9 | "types": "./dist/index.d.ts", 10 | "files": [ 11 | "dist/**", 12 | "esm/**" 13 | ], 14 | "scripts": { 15 | "start": "webpack-dev-server --mode development --hot --progress --color --port 3000 --open", 16 | "build": "npm run build:esm && npm run build:cjs", 17 | "build:cjs": "ncc build src/index.ts -o dist -m -e react", 18 | "build:esm": "tsc --module ES6 --outDir esm", 19 | "test": "jest --coverage", 20 | "prettier": "prettier --write \"./{src,test}/**/*.{ts,tsx,css}\" && prettier --write \"./doc/src/**/*.{ts,tsx,mdx}\"" 21 | }, 22 | "devDependencies": { 23 | "@testing-library/react": "^9.4.0", 24 | "@types/jest": "^24.0.25", 25 | "@types/node": "^10.12.18", 26 | "@types/react": "^16.8.2", 27 | "@types/react-dom": "^16.8.0", 28 | "@types/webpack": "^4.4.23", 29 | "@types/webpack-env": "1.13.6", 30 | "@zeit/ncc": "^0.21.0", 31 | "codecov": "^3.5.0", 32 | "jest": "^24.9.0", 33 | "prettier": "^1.19.1", 34 | "react": "^16.8.1", 35 | "react-dom": "^16.8.1", 36 | "rxjs": "^6.5.4", 37 | "ts-jest": "^24.2.0", 38 | "tslint": "^5.20.1", 39 | "typescript": "^3.7.0" 40 | }, 41 | "peerDependencies": { 42 | "react": "^16.8.1", 43 | "react-dom": "^16.8.1", 44 | "rxjs": "^6.5.4" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /scripts/jest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is the entry for debug single test file in vscode 3 | * 4 | * Not using node_modules/.bin/jest due to cross platform issues, see 5 | * https://github.com/microsoft/vscode-recipes/issues/107 6 | */ 7 | require('jest').run(process.argv); 8 | -------------------------------------------------------------------------------- /src/collection.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DependencyItem, 3 | DependencyKey, 4 | DependencyValue, 5 | Disposable, 6 | InitPromise, 7 | isDisposable, 8 | Ctor 9 | } from './typings' 10 | 11 | export class DependencyCollection implements Disposable { 12 | public disposed: boolean = false 13 | 14 | private readonly items = new Map< 15 | DependencyKey, 16 | DependencyValue | any 17 | >() 18 | 19 | constructor(deps: DependencyItem[] = []) { 20 | for (const dep of deps) { 21 | if (dep instanceof Array) { 22 | const [depKey, depItem] = dep 23 | this.add(depKey, depItem) 24 | } else { 25 | this.add(dep) 26 | } 27 | } 28 | } 29 | 30 | add(ctor: Ctor): void 31 | add(key: DependencyKey, item: DependencyValue | T): void 32 | add(ctorOrKey: DependencyKey, item?: DependencyValue | T): void { 33 | this.ensureCollectionNotDisposed() 34 | 35 | if (item) { 36 | this.items.set(ctorOrKey, item) 37 | } else { 38 | this.items.set(ctorOrKey, new InitPromise(ctorOrKey as Ctor)) 39 | } 40 | } 41 | 42 | has(key: DependencyKey): boolean { 43 | this.ensureCollectionNotDisposed() 44 | 45 | return this.items.has(key) 46 | } 47 | 48 | get(key: DependencyKey): T | DependencyValue | undefined { 49 | this.ensureCollectionNotDisposed() 50 | 51 | return this.items.get(key) 52 | } 53 | 54 | dispose(): void { 55 | this.disposed = true 56 | 57 | this.items.forEach((item) => { 58 | if (isDisposable(item)) { 59 | item.dispose() 60 | } 61 | }) 62 | } 63 | 64 | private ensureCollectionNotDisposed(): void { 65 | if (this.disposed) { 66 | throw new Error( 67 | `[wedi] Dependency collection is not accessible after it disposes!` 68 | ) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/decorators.ts: -------------------------------------------------------------------------------- 1 | import { Ctor, DependencyKey, Identifier, IdentifierSymbol } from './typings' 2 | import { dependencyIds, setDependencies } from './utils' 3 | 4 | export function createIdentifier(name: string): Identifier { 5 | if (dependencyIds.has(name)) { 6 | console.warn(`[wedi] duplicated identifier name ${name}.`) 7 | 8 | return dependencyIds.get(name)! 9 | } 10 | 11 | const id = function(target: Ctor, _key: string, index: number): void { 12 | setDependencies(target, id, index, false) 13 | } as Identifier 14 | 15 | id.toString = () => name 16 | id[IdentifierSymbol] = true 17 | 18 | dependencyIds.set(name, id) 19 | 20 | return id 21 | } 22 | 23 | /** 24 | * wrap a Identifier with this function to make it optional 25 | */ 26 | export function Optional(key: DependencyKey) { 27 | return function(target: Ctor, _key: string, index: number) { 28 | setDependencies(target, key, index, true) 29 | } 30 | } 31 | 32 | /** 33 | * used inside constructor for services to claim dependencies 34 | */ 35 | export function Need(key: DependencyKey) { 36 | return function(target: Ctor, _key: string, index: number) { 37 | setDependencies(target, key, index, false) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/idle.ts: -------------------------------------------------------------------------------- 1 | export interface IdleDeadline { 2 | readonly didTimeout: boolean 3 | timeRemaining(): DOMHighResTimeStamp 4 | } 5 | 6 | export type DisposableCallback = () => void 7 | 8 | /** 9 | * this run the callback when CPU is idle. Will fallback to setTimeout if 10 | * the browser doesn't support requestIdleCallback 11 | */ 12 | export let runWhenIdle: ( 13 | callback: (idle?: IdleDeadline) => void, 14 | timeout?: number 15 | ) => DisposableCallback 16 | 17 | // declare global variables because apparently the type file doesn't have it, for now 18 | declare function requestIdleCallback( 19 | callback: (args: IdleDeadline) => void, 20 | options?: { timeout: number } 21 | ): number 22 | declare function cancelIdleCallback( 23 | handle: number 24 | ): void 25 | 26 | // use an IIFE to set up runWhenIdle 27 | ;(function() { 28 | if ( 29 | typeof requestIdleCallback !== 'undefined' && 30 | typeof cancelIdleCallback !== 'undefined' 31 | ) { 32 | // use native requestIdleCallback 33 | runWhenIdle = (runner, timeout?) => { 34 | const handle: number = requestIdleCallback( 35 | runner, 36 | typeof timeout === 'number' ? { timeout } : undefined 37 | ) 38 | let disposed = false 39 | return () => { 40 | if (disposed) { 41 | return 42 | } 43 | disposed = true 44 | clearTimeout(handle) 45 | } 46 | } 47 | } else { 48 | // use setTimeout as hack 49 | const dummyIdle: IdleDeadline = Object.freeze({ 50 | didTimeout: true, 51 | timeRemaining() { 52 | return 15 53 | } 54 | }) 55 | runWhenIdle = (runner) => { 56 | const handle = setTimeout(() => runner(dummyIdle)) 57 | let disposed = false 58 | return () => { 59 | if (disposed) { 60 | return 61 | } 62 | disposed = true 63 | clearTimeout(handle) 64 | } 65 | } 66 | } 67 | })() 68 | 69 | /** 70 | * a wrapper of a executor so it can be evaluated when it's necessary or the CPU is idle 71 | * 72 | * the type of the returned value of the executor would be T 73 | */ 74 | export class IdleValue { 75 | private readonly executor: () => void 76 | private readonly disposeCallback: () => void 77 | 78 | private didRun: boolean = false 79 | private value?: T 80 | private error?: Error 81 | 82 | constructor(executor: () => T) { 83 | this.executor = () => { 84 | try { 85 | this.value = executor() 86 | } catch (err) { 87 | this.error = err 88 | } finally { 89 | this.didRun = true 90 | } 91 | } 92 | this.disposeCallback = runWhenIdle(() => this.executor()) 93 | } 94 | 95 | dispose(): void { 96 | this.disposeCallback() 97 | } 98 | 99 | getValue(): T { 100 | if (!this.didRun) { 101 | this.dispose() 102 | this.executor() 103 | } 104 | if (this.error) { 105 | throw this.error 106 | } 107 | return this.value! 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // core 2 | export { DependencyCollection } from './collection' 3 | export { createIdentifier, Need, Optional } from './decorators' 4 | export { Injector } from './injector' 5 | export { registerSingleton } from './singleton' 6 | export { 7 | ClassItem, 8 | isClassItem, 9 | ValueItem, 10 | isValueItem, 11 | FactoryItem, 12 | isFactoryItem, 13 | DependencyValue, 14 | DependencyItem, 15 | Disposable, 16 | isDisposable 17 | } from './typings' 18 | 19 | // react bindings 20 | export { Provide, Inject } from './react/decorators' 21 | export { 22 | InjectionContext, 23 | Provider, 24 | InjectionProviderProps, 25 | connectProvider 26 | } from './react/context' 27 | export { 28 | useCollection, 29 | useDependency, 30 | useMultiDependencies 31 | } from './react/hooks' 32 | export { 33 | useDependencyValue, 34 | useUpdateBinder, 35 | useDependencyContext, 36 | useDependencyContextValue 37 | } from './react/rx' 38 | -------------------------------------------------------------------------------- /src/injector.ts: -------------------------------------------------------------------------------- 1 | import { DependencyCollection } from './collection' 2 | import { IdleValue } from './idle' 3 | import { getSingletonDependencies } from './singleton' 4 | import { 5 | Ctor, 6 | DependencyKey, 7 | DependencyValue, 8 | FactoryItem, 9 | Disposable, 10 | InitPromise, 11 | isClassItem, 12 | isFactoryItem, 13 | isValueItem, 14 | Identifier, 15 | isInitPromise 16 | } from './typings' 17 | import { 18 | assertRecursionNotTrappedInACircle, 19 | completeInitialization, 20 | getDependencies, 21 | getDependencyKeyName, 22 | requireInitialization 23 | } from './utils' 24 | 25 | export class Injector implements Disposable { 26 | private readonly parent?: Injector 27 | private readonly collection: DependencyCollection 28 | 29 | constructor(collection?: DependencyCollection, parent?: Injector) { 30 | const _collection = collection || new DependencyCollection() 31 | 32 | // if there's no parent injector, should get singleton dependencies 33 | if (!parent) { 34 | const newDependencies = getSingletonDependencies() 35 | // root injector would not re-add dependencies when the component 36 | // it embeds re-render 37 | newDependencies.forEach((d) => { 38 | if (!_collection.has(d[0])) { 39 | _collection.add(d[0], d[1]) 40 | } 41 | }) 42 | } 43 | 44 | this.collection = _collection 45 | this.parent = parent 46 | } 47 | 48 | dispose(): void { 49 | this.collection.dispose() 50 | } 51 | 52 | add(ctor: Ctor): void 53 | add(key: Identifier, item: DependencyValue): void 54 | add(ctorOrKey: Ctor | Identifier, item?: DependencyValue): void { 55 | this.collection.add(ctorOrKey as any, item as any) 56 | } 57 | 58 | /** 59 | * create a child Initializer to build layered injection system 60 | */ 61 | createChild( 62 | dependencies: DependencyCollection = new DependencyCollection() 63 | ): Injector { 64 | return new Injector(dependencies, this) 65 | } 66 | 67 | /** 68 | * get a dependency or create one in the current injector 69 | */ 70 | getOrInit(key: DependencyKey): T 71 | getOrInit(key: DependencyKey, optional: true): T | null 72 | getOrInit(key: DependencyKey, optional?: true): T | null { 73 | const thing = this.getDependencyOrIdentifierPair(key) 74 | 75 | if (typeof thing === 'undefined') { 76 | if (!optional) { 77 | throw new Error( 78 | `[wedi] "${getDependencyKeyName( 79 | key 80 | )}" is not provided by any injector.` 81 | ) 82 | } 83 | return null 84 | } else if (isInitPromise(thing)) { 85 | return this.createAndCacheInstance(key, thing) 86 | } else if (isValueItem(thing)) { 87 | return thing.useValue 88 | } else if (isFactoryItem(thing)) { 89 | return this.invokeDependencyFactory(key as Identifier, thing) 90 | } else if (isClassItem(thing)) { 91 | return this.createAndCacheInstance( 92 | key, 93 | new InitPromise(thing.useClass, !!thing.lazyInstantiation) 94 | ) 95 | } else { 96 | return thing as T 97 | } 98 | } 99 | 100 | /** 101 | * initialize a class in the scope of the injector 102 | * @param ctor The class to be initialized 103 | */ 104 | createInstance(ctor: Ctor | InitPromise, ...extraParams: any[]): T { 105 | const theCtor = ctor instanceof InitPromise ? ctor.ctor : ctor 106 | const dependencies = getDependencies(theCtor).sort( 107 | (a, b) => a.index - b.index 108 | ) 109 | const resolvedArgs: any[] = [] 110 | 111 | let args = [...extraParams] 112 | 113 | for (const dependency of dependencies) { 114 | const thing = this.getOrInit(dependency.id, true) 115 | 116 | if (thing === null && !dependency.optional) { 117 | throw new Error( 118 | `[wedi] "${ 119 | theCtor.name 120 | }" relies on a not provided dependency "${getDependencyKeyName( 121 | dependency.id 122 | )}".` 123 | ) 124 | } 125 | 126 | resolvedArgs.push(thing) 127 | } 128 | 129 | const firstDependencyArgIndex = 130 | dependencies.length > 0 ? dependencies[0].index : args.length 131 | 132 | if (args.length !== firstDependencyArgIndex) { 133 | console.warn( 134 | `[wedi] expected ${firstDependencyArgIndex} non-injected parameters ` + 135 | `but ${args.length} parameters are provided.` 136 | ) 137 | 138 | const delta = firstDependencyArgIndex - args.length 139 | if (delta > 0) { 140 | args = [...args, ...new Array(delta).fill(undefined)] 141 | } else { 142 | args = args.slice(0, firstDependencyArgIndex) 143 | } 144 | } 145 | 146 | return new theCtor(...args, ...resolvedArgs) 147 | } 148 | 149 | private getDependencyOrIdentifierPair( 150 | id: DependencyKey 151 | ): T | DependencyValue | undefined { 152 | return ( 153 | this.collection.get(id) || 154 | (this.parent ? this.parent.getDependencyOrIdentifierPair(id) : undefined) 155 | ) 156 | } 157 | 158 | private putDependencyBack(key: DependencyKey, value: T): void { 159 | if (this.collection.get(key)) { 160 | this.collection.add(key, value) 161 | } else { 162 | this.parent!.putDependencyBack(key, value) 163 | } 164 | } 165 | 166 | private createAndCacheInstance( 167 | dKey: DependencyKey, 168 | initPromise: InitPromise 169 | ) { 170 | requireInitialization() 171 | assertRecursionNotTrappedInACircle(dKey) 172 | 173 | const ctor = initPromise.ctor 174 | let thing: T 175 | 176 | if (initPromise.lazyInstantiation) { 177 | const idle = new IdleValue(() => this.doCreateInstance(dKey, ctor)) 178 | thing = new Proxy(Object.create(null), { 179 | get(target: any, key: string | number | symbol): any { 180 | if (key in target) { 181 | return target[key] 182 | } 183 | const obj = idle.getValue() 184 | let prop = (obj as any)[key] 185 | if (typeof prop !== 'function') { 186 | return prop 187 | } 188 | prop = prop.bind(obj) 189 | target[key] = prop 190 | return prop 191 | }, 192 | set(_target: any, key: string | number | symbol, value: any): boolean { 193 | ;(idle.getValue() as any)[key] = value 194 | return true 195 | } 196 | }) as T 197 | } else { 198 | thing = this.doCreateInstance(dKey, ctor) 199 | } 200 | 201 | completeInitialization() 202 | 203 | return thing 204 | } 205 | 206 | private doCreateInstance(id: DependencyKey, ctor: Ctor): T { 207 | const thing = this.createInstance(ctor) 208 | this.putDependencyBack(id, thing) 209 | return thing 210 | } 211 | 212 | private invokeDependencyFactory( 213 | id: Identifier, 214 | factory: FactoryItem 215 | ): T { 216 | // TODO: should report missing dependency for factories? 217 | const dependencies = 218 | factory.deps?.map((dp) => this.getOrInit(dp, true)) || [] 219 | const thing = factory.useFactory.call(null, dependencies) 220 | 221 | this.collection.add(id, { 222 | useValue: thing 223 | }) 224 | 225 | return thing 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/react/context.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, ComponentType, PropsWithChildren } from 'react' 2 | 3 | import { DependencyCollection } from '../collection' 4 | import { Injector } from '../injector' 5 | 6 | interface InjectionContextValue { 7 | injector: Injector | null 8 | } 9 | 10 | export const InjectionContext = createContext({ 11 | injector: null 12 | }) 13 | InjectionContext.displayName = 'InjectionContext' 14 | 15 | const InjectionConsumer = InjectionContext.Consumer 16 | const InjectionProvider = InjectionContext.Provider 17 | 18 | export interface InjectionProviderProps { 19 | collection?: DependencyCollection 20 | // support providing an injector directly, so React binding 21 | // can use parent injector outside of React 22 | injector?: Injector 23 | } 24 | 25 | /** 26 | * the React binding of wedi 27 | * 28 | * it uses the React context API to specify injection positions and 29 | * layered injector tree 30 | * 31 | * ```tsx 32 | * 33 | * { children } 34 | * 35 | * ``` 36 | */ 37 | export function Provider(props: PropsWithChildren) { 38 | const { collection, children, injector } = props 39 | 40 | return ( 41 | 42 | {(context: InjectionContextValue) => { 43 | const parentInjector = context.injector 44 | 45 | if (!!collection === !!injector) { 46 | throw new Error( 47 | '[wedi] should provide a collection or an injector to "Provider".' 48 | ) 49 | } 50 | 51 | const finalInjector = 52 | injector || 53 | parentInjector?.createChild(collection) || 54 | new Injector(collection!) 55 | 56 | return ( 57 | 58 | {children} 59 | 60 | ) 61 | }} 62 | 63 | ) 64 | } 65 | 66 | /** 67 | * return a HOC that enable functional component to add injector 68 | * in a convenient way 69 | */ 70 | export function connectProvider( 71 | Comp: ComponentType, 72 | options: InjectionProviderProps 73 | ): ComponentType { 74 | const { injector, collection } = options 75 | 76 | return function ComponentWithInjector(props: T) { 77 | return ( 78 | 79 | 80 | 81 | ) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/react/decorators.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ComponentClass, createElement } from 'react' 2 | 3 | import { DependencyCollection } from '../collection' 4 | import { Injector } from '../injector' 5 | import { Ctor, DependencyItem, Identifier } from '../typings' 6 | import { getDependencyKeyName } from '../utils' 7 | import { Provider } from './context' 8 | 9 | /** 10 | * an decorator that could be used on a React class component 11 | * to provide a injection context on that component 12 | */ 13 | export function Provide(items: DependencyItem[]) { 14 | return function(target: any): any { 15 | function getChild(this: Component) { 16 | return createElement(target, this.props) 17 | } 18 | 19 | class ProviderWrapper extends Component { 20 | $$collection: DependencyCollection 21 | 22 | constructor(props: any, context: any) { 23 | super(props, context) 24 | this.$$collection = new DependencyCollection(items) 25 | } 26 | 27 | componentWillUnmount(): void { 28 | this.$$collection.dispose() 29 | } 30 | 31 | render() { 32 | return ( 33 | 34 | {getChild.call(this)} 35 | 36 | ) 37 | } 38 | } 39 | 40 | ;(ProviderWrapper as ComponentClass).displayName = `ProviderWrapper.${target.name}` 41 | 42 | return ProviderWrapper 43 | } 44 | } 45 | 46 | /** 47 | * returns decorator that could be used on a property of 48 | * a React class component to inject a dependency 49 | */ 50 | export function Inject( 51 | id: Identifier | Ctor, 52 | optional: boolean = false 53 | ) { 54 | return function(target: any, propName: string, _originDescriptor?: any): any { 55 | return { 56 | // when user is trying to get the service, get it from the injector in 57 | // the current context 58 | get(): T | null { 59 | // tslint:disable-next-line:no-invalid-this 60 | const thisAsComponent: Component = this as any 61 | 62 | ensureInjectionContextExists(thisAsComponent) 63 | 64 | const injector: Injector = thisAsComponent.context.injector 65 | const thing = injector?.getOrInit(id, true) 66 | 67 | if (!optional && !thing) { 68 | throw Error( 69 | `[wedi] Cannot get an instance of "${getDependencyKeyName(id)}".` 70 | ) 71 | } 72 | 73 | return thing || null 74 | }, 75 | set(_value: never) { 76 | throw Error( 77 | `[wedi] You can never set value to a dependency. Check "${propName}" of "${getDependencyKeyName( 78 | id 79 | )}".` 80 | ) 81 | } 82 | } 83 | } 84 | } 85 | 86 | function ensureInjectionContextExists(component: Component): void { 87 | if (!component.context || !component.context.injector) { 88 | throw Error( 89 | `[wedi] You should make "InjectorContext" as ${component.constructor.name}'s default context type. ` + 90 | 'If you want to use multiple context, please check this page on React documentation. ' + 91 | 'https://reactjs.org/docs/context.html#classcontexttype' 92 | ) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/react/hooks.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useRef } from 'react' 2 | 3 | import { DependencyCollection } from '../collection' 4 | import { DependencyItem, DependencyKey } from '../typings' 5 | import { getDependencyKeyName } from '../utils' 6 | import { InjectionContext } from './context' 7 | 8 | /** 9 | * when providing dependencies in a functional component, it would be expensive 10 | * (not to mention logic incorrectness) 11 | */ 12 | export function useCollection( 13 | entries?: DependencyItem[] 14 | ): DependencyCollection { 15 | const collectionRef = useRef(new DependencyCollection(entries)) 16 | useEffect(() => () => collectionRef.current.dispose(), []) 17 | return collectionRef.current 18 | } 19 | 20 | /** 21 | * this function support using dependency injection in a function component 22 | * with the help of React Hooks 23 | */ 24 | export function useDependency(key: DependencyKey): T 25 | export function useDependency( 26 | key: DependencyKey, 27 | optional: true 28 | ): T | null 29 | export function useDependency( 30 | key: DependencyKey, 31 | optional?: boolean 32 | ): T | null { 33 | const { injector } = useContext(InjectionContext) 34 | const thing = injector?.getOrInit(key, true) 35 | 36 | if (!optional && !thing) { 37 | throw Error( 38 | `[wedi] Cannot get an instance of "${getDependencyKeyName(key)}".` 39 | ) 40 | } 41 | 42 | return thing || null 43 | } 44 | 45 | type Nullable = T | null 46 | 47 | export function useMultiDependencies( 48 | keys: [DependencyKey, DependencyKey] 49 | ): [Nullable, Nullable] 50 | export function useMultiDependencies( 51 | keys: [DependencyKey, DependencyKey, DependencyKey] 52 | ): [Nullable, Nullable, Nullable] 53 | export function useMultiDependencies( 54 | keys: [ 55 | DependencyKey, 56 | DependencyKey, 57 | DependencyKey, 58 | DependencyKey 59 | ] 60 | ): [Nullable, Nullable, Nullable, Nullable] 61 | export function useMultiDependencies( 62 | keys: [ 63 | DependencyKey, 64 | DependencyKey, 65 | DependencyKey, 66 | DependencyKey, 67 | DependencyKey 68 | ] 69 | ): [Nullable, Nullable, Nullable, Nullable, Nullable] 70 | export function useMultiDependencies( 71 | keys: [ 72 | DependencyKey, 73 | DependencyKey, 74 | DependencyKey, 75 | DependencyKey, 76 | DependencyKey, 77 | DependencyKey 78 | ] 79 | ): [ 80 | Nullable, 81 | Nullable, 82 | Nullable, 83 | Nullable, 84 | Nullable, 85 | Nullable 86 | ] 87 | export function useMultiDependencies(keys: any[]): any[] { 88 | const ret = new Array(keys.length).fill(null) 89 | const { injector } = useContext(InjectionContext) 90 | 91 | keys.forEach((key, index) => { 92 | ret[index] = injector?.getOrInit(key) ?? null 93 | }) 94 | 95 | return ret 96 | } 97 | -------------------------------------------------------------------------------- /src/react/rx.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useEffect, 3 | useState, 4 | createContext, 5 | useMemo, 6 | useContext, 7 | useCallback, 8 | ReactNode, 9 | Context, 10 | useRef 11 | } from 'react' 12 | import { BehaviorSubject, Observable } from 'rxjs' 13 | 14 | /** 15 | * unwrap an observable value, return it to the component for rendering, and 16 | * trigger re-render when value changes 17 | * 18 | * **IMPORTANT**. Parent and child components should not subscribe to the same 19 | * observable, otherwise unnecessary re-render would be triggered. Instead, the 20 | * top-most component should subscribe and pass value of the observable to 21 | * its offspring, by props or context. 22 | * 23 | * If you have to do that, consider using `useDependencyContext` and 24 | * `useDependencyContextValue` instead. 25 | */ 26 | export function useDependencyValue( 27 | depValue$: Observable, 28 | defaultValue?: T 29 | ): T | undefined { 30 | const _defaultValue: T | undefined = 31 | depValue$ instanceof BehaviorSubject && typeof defaultValue === 'undefined' 32 | ? depValue$.getValue() 33 | : defaultValue 34 | const [value, setValue] = useState(_defaultValue) 35 | 36 | useEffect(() => { 37 | const subscription = depValue$.subscribe((val: T) => setValue(val)) 38 | return () => subscription.unsubscribe() 39 | }, [depValue$]) 40 | 41 | return value 42 | } 43 | 44 | /** 45 | * subscribe to a signal that emits whenever data updates and re-render 46 | * 47 | * @param update$ a signal that the data the functional component depends has updated 48 | */ 49 | export function useUpdateBinder(update$: Observable): void { 50 | const [, dumpSet] = useState(0) 51 | 52 | useEffect(() => { 53 | const subscription = update$.subscribe(() => dumpSet((prev) => prev + 1)) 54 | return () => subscription.unsubscribe() 55 | }, []) 56 | } 57 | 58 | const DepValueMapProvider = new WeakMap, Context>() 59 | 60 | /** 61 | * subscribe to an observable value from a service, creating a context for it so 62 | * it child component won't have to subscribe again and cause unnecessary 63 | */ 64 | export function useDependencyContext( 65 | depValue$: Observable, 66 | defaultValue?: T 67 | ) { 68 | const depRef = useRef | undefined>(undefined) 69 | const value = useDependencyValue(depValue$, defaultValue) 70 | const Context = useMemo(() => { 71 | return createContext(value) 72 | }, [depValue$]) 73 | const Provider = useCallback( 74 | (props: { initialState?: T; children: ReactNode }) => { 75 | return {props.children} 76 | }, 77 | [depValue$, value] 78 | ) 79 | 80 | if (depRef.current !== depValue$) { 81 | if (depRef.current) { 82 | DepValueMapProvider.delete(depRef.current) 83 | } 84 | 85 | depRef.current = depValue$ 86 | DepValueMapProvider.set(depValue$, Context) 87 | } 88 | 89 | return { 90 | Provider, 91 | value 92 | } 93 | } 94 | 95 | export function useDependencyContextValue( 96 | depValue$: Observable 97 | ): T | undefined { 98 | const context = DepValueMapProvider.get(depValue$) 99 | 100 | if (!context) { 101 | throw new Error( 102 | `[wedi] try to read context value but no ancestor component subscribed it.` 103 | ) 104 | } 105 | 106 | return useContext(context) 107 | } 108 | -------------------------------------------------------------------------------- /src/singleton.ts: -------------------------------------------------------------------------------- 1 | import { ClassItem, Ctor, Identifier } from './typings' 2 | 3 | let singletonDependenciesHaveBeenFetched = false 4 | let haveWarned = false 5 | 6 | const singletonDependencies: [Identifier, ClassItem][] = [] 7 | 8 | export function registerSingleton( 9 | id: Identifier, 10 | ctor: Ctor, 11 | lazyInstantiation = false 12 | ): void { 13 | const index = singletonDependencies.findIndex( 14 | (d) => d[0].toString() === id.toString() || d[0] === id 15 | ) 16 | 17 | if (index !== -1) { 18 | singletonDependencies[index] = [id, { useClass: ctor, lazyInstantiation }] 19 | console.warn(`[wedi] Duplicated registration of ${id.toString()}.`) 20 | } else { 21 | singletonDependencies.push([id, { useClass: ctor, lazyInstantiation }]) 22 | } 23 | } 24 | 25 | /** 26 | * for top-layer injectors to fetch all singleton dependencies 27 | */ 28 | export function getSingletonDependencies(): [ 29 | Identifier, 30 | ClassItem 31 | ][] { 32 | if (singletonDependenciesHaveBeenFetched && !haveWarned) { 33 | console.warn( 34 | '[wedi] More than one root injectors tried to fetch singleton dependencies. ' + 35 | 'This may cause undesired behavior in your application.' 36 | ) 37 | 38 | haveWarned = true 39 | } 40 | 41 | singletonDependenciesHaveBeenFetched = true 42 | 43 | return singletonDependencies 44 | } 45 | -------------------------------------------------------------------------------- /src/typings.ts: -------------------------------------------------------------------------------- 1 | export type Ctor = new (...args: any[]) => T 2 | 3 | export const IdentifierSymbol = Symbol('$$WEDI_IDENTIFIER') 4 | 5 | export interface Identifier { 6 | type?: T 7 | toString(): string 8 | [IdentifierSymbol]: boolean 9 | (target: Ctor, key: string, index: number): void 10 | } 11 | 12 | export function isIdentifier(thing: any): thing is Identifier { 13 | return thing[IdentifierSymbol] 14 | } 15 | 16 | export interface DependencyMeta { 17 | id: DependencyKey 18 | index: number 19 | optional: boolean 20 | } 21 | 22 | export class InitPromise { 23 | readonly ctor: any 24 | readonly lazyInstantiation: boolean 25 | 26 | constructor(ctor: Ctor, lazyInstantiation: boolean = false) { 27 | this.ctor = ctor 28 | this.lazyInstantiation = lazyInstantiation 29 | } 30 | } 31 | 32 | export function isInitPromise(thing: any): thing is InitPromise { 33 | return thing instanceof InitPromise 34 | } 35 | 36 | export interface ClassItem { 37 | useClass: Ctor 38 | lazyInstantiation?: boolean 39 | } 40 | 41 | export function isClassItem(thing: any): thing is ClassItem { 42 | return !!(thing as any).useClass 43 | } 44 | 45 | export interface ValueItem { 46 | useValue: T 47 | } 48 | 49 | export function isValueItem(thing: any): thing is ValueItem { 50 | return !!(thing as any).useValue 51 | } 52 | 53 | export interface FactoryItem { 54 | useFactory(...args: any[]): T 55 | deps?: DependencyKey[] 56 | } 57 | 58 | export function isFactoryItem(thing: any): thing is FactoryItem { 59 | return !!(thing as any).useFactory 60 | } 61 | 62 | export type DependencyValue = 63 | | Ctor 64 | | InitPromise 65 | | ValueItem 66 | | ClassItem 67 | | FactoryItem 68 | 69 | export type DependencyKey = Identifier | Ctor 70 | 71 | export type DependencyItem = [Identifier, DependencyValue] | Ctor 72 | 73 | export interface Disposable { 74 | dispose(): void 75 | } 76 | 77 | export function isDisposable(thing: any): thing is Disposable { 78 | return !!(thing as any).dispose 79 | } 80 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Ctor, 3 | DependencyKey, 4 | DependencyMeta, 5 | Identifier, 6 | isIdentifier 7 | } from './typings' 8 | 9 | export const dependencyIds = new Map>() 10 | export const DEPENDENCIES = '$$WEDI_DEPENDENCIES' 11 | export const TARGET = '$$WEDI_TARGET' 12 | 13 | export function getDependencies(ctor: Ctor): DependencyMeta[] { 14 | return (ctor as any)[DEPENDENCIES] || [] 15 | } 16 | 17 | export function setDependencies( 18 | ctor: Ctor, 19 | id: DependencyKey, 20 | index: number, 21 | optional: boolean 22 | ) { 23 | const meta: DependencyMeta = { id, index, optional } 24 | 25 | // cope with dependency that is inherited from another 26 | if ((ctor as any)[TARGET] === ctor) { 27 | ;(ctor as any)[DEPENDENCIES].push(meta) 28 | } else { 29 | ;(ctor as any)[DEPENDENCIES] = [meta] 30 | ;(ctor as any)[TARGET] = ctor 31 | } 32 | } 33 | 34 | const RECURSION_MAX = 10 35 | 36 | let recursionCounter = 0 37 | 38 | export function requireInitialization(): void { 39 | recursionCounter += 1 40 | } 41 | 42 | export function completeInitialization(): void { 43 | recursionCounter -= 1 44 | } 45 | 46 | export function resetRecursionCounter() { 47 | recursionCounter = 0 48 | } 49 | 50 | export function assertRecursionNotTrappedInACircle( 51 | key: DependencyKey 52 | ): void { 53 | if (recursionCounter > RECURSION_MAX) { 54 | resetRecursionCounter() 55 | 56 | throw new Error( 57 | `[wedi] "createInstance" exceeds the limitation of recursion (${RECURSION_MAX}x). ` + 58 | `There might be a circular dependency among your dependency items. ` + 59 | `Last target was "${getDependencyKeyName(key)}".` 60 | ) 61 | } 62 | } 63 | 64 | export function getDependencyKeyName(key: DependencyKey): string { 65 | return isIdentifier(key) ? key.toString() : key.name 66 | } 67 | -------------------------------------------------------------------------------- /test/di-core.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createIdentifier, 3 | DependencyCollection, 4 | Injector, 5 | Need, 6 | Optional 7 | } from '../src' 8 | 9 | describe('di-core', () => { 10 | const voidIdentifier = createIdentifier('void') 11 | 12 | describe('basics', () => { 13 | class A {} 14 | 15 | class B { 16 | constructor(public p: any, @Need(A) public a: A) {} 17 | } 18 | 19 | class C { 20 | key = 'wedi' 21 | } 22 | 23 | class D { 24 | constructor(@Optional(A) public a?: A) {} 25 | } 26 | 27 | class E { 28 | constructor(@Need(A) public a?: A) {} 29 | } 30 | 31 | const key = 'key' 32 | 33 | it('should support extra parameters', () => { 34 | const injector = new Injector(new DependencyCollection([A])) 35 | const b = injector.createInstance(B, key) 36 | 37 | expect(b.p).toBe(key) 38 | }) 39 | 40 | it('should truncate extra parameters', () => { 41 | const injector = new Injector(new DependencyCollection([A])) 42 | const b = injector.createInstance(B, key, 'extra') 43 | 44 | expect(b.p).toBe(key) 45 | expect(b.a instanceof A).toBeTruthy() 46 | }) 47 | 48 | it('should fill inadequate parameters with undefined', () => { 49 | const injector = new Injector(new DependencyCollection([A])) 50 | const b = injector.createInstance(B) 51 | 52 | expect(b.p).toBe(undefined) 53 | }) 54 | 55 | it('should support adding dependencies', () => { 56 | const injector = new Injector() 57 | 58 | let c = injector.getOrInit(C, true) 59 | expect(c).toBe(null) 60 | 61 | injector.add(C) 62 | c = injector.getOrInit(C) 63 | expect(c.key).toBe('wedi') 64 | }) 65 | 66 | it('should tolerate a missing optional dependency', () => { 67 | const injector = new Injector(new DependencyCollection([D])) 68 | const d = injector.getOrInit(D) 69 | 70 | expect(d!.a).toBe(null) 71 | }) 72 | 73 | it('should raise error when a required dependency is missing', () => { 74 | const injector = new Injector(new DependencyCollection([E])) 75 | 76 | let error: Error 77 | try { 78 | injector.getOrInit(E) 79 | } catch (e) { 80 | error = e 81 | } 82 | expect(error!.message).toBe( 83 | '[wedi] "E" relies on a not provided dependency "A".' 84 | ) 85 | }) 86 | 87 | it('should return null when an optional thing is not retrievable', () => { 88 | const injector = new Injector() 89 | 90 | const nothing = injector.getOrInit(voidIdentifier, true) 91 | expect(nothing).toBe(null) 92 | }) 93 | 94 | it('should raise error when a thing is not retrievable', () => { 95 | const injector = new Injector() 96 | 97 | let error: Error 98 | try { 99 | injector.getOrInit(voidIdentifier) 100 | } catch (e) { 101 | error = e 102 | } 103 | expect(error!.message).toBe( 104 | `[wedi] "void" is not provided by any injector.` 105 | ) 106 | }) 107 | }) 108 | 109 | describe('work with different kinds of dependencies', () => { 110 | class A {} 111 | 112 | const id = createIdentifier('a') 113 | 114 | it('should support ctor', () => { 115 | const injector = new Injector(new DependencyCollection([A])) 116 | const instance = injector.getOrInit(A) 117 | 118 | expect(instance instanceof A).toBeTruthy() 119 | }) 120 | 121 | it('should support identifier and useClass', () => { 122 | const injector = new Injector( 123 | new DependencyCollection([[id, { useClass: A }]]) 124 | ) 125 | const instance = injector.getOrInit(id) 126 | 127 | expect(instance instanceof A).toBeTruthy() 128 | }) 129 | 130 | it('should support identifier and value (instance)', () => { 131 | const str = 'magic' 132 | const injector = new Injector( 133 | new DependencyCollection([[id, { useValue: str }]]) 134 | ) 135 | const value = injector.getOrInit(id) 136 | 137 | expect(value).toBe(str) 138 | }) 139 | 140 | it('should support identifier and factory', () => { 141 | const str = 'factory' 142 | const factory = () => str 143 | const injector = new Injector( 144 | new DependencyCollection([[id, { useFactory: factory }]]) 145 | ) 146 | const value = injector.getOrInit(id) 147 | 148 | expect(value).toBe(str) 149 | }) 150 | }) 151 | 152 | describe('recursive initialization', () => { 153 | let initFlag = false 154 | const id = createIdentifier('a') 155 | 156 | class A { 157 | constructor() { 158 | initFlag = true 159 | } 160 | } 161 | 162 | class B { 163 | constructor(@id public a: any) {} 164 | } 165 | 166 | beforeEach(() => (initFlag = false)) 167 | 168 | it('should recursively init for Identifier-useClass', () => { 169 | const injector = new Injector( 170 | new DependencyCollection([[id, { useClass: A }], B]) 171 | ) 172 | injector.getOrInit(B) 173 | 174 | expect(initFlag).toBeTruthy() 175 | expect(injector.getOrInit(id)).toBeTruthy() 176 | }) 177 | 178 | it('should recursively init for Identifier-useFactory', () => { 179 | const injector = new Injector( 180 | new DependencyCollection([ 181 | [ 182 | id, 183 | { 184 | useFactory: (_a: A) => { 185 | return 186 | }, 187 | deps: [A] 188 | } 189 | ], 190 | A 191 | ]) 192 | ) 193 | injector.getOrInit(id) 194 | 195 | expect(initFlag).toBeTruthy() 196 | }) 197 | 198 | it('should detect circular dependency', () => { 199 | const id = createIdentifier('a') 200 | const id2 = createIdentifier('b') 201 | 202 | class A { 203 | constructor(@Need(id2) public _b: B) {} 204 | } 205 | 206 | class B { 207 | constructor(@Need(id) public _a: A) {} 208 | } 209 | 210 | const injector = new Injector( 211 | new DependencyCollection([ 212 | [id, { useClass: A }], 213 | [id2, { useClass: B }] 214 | ]) 215 | ) 216 | 217 | let error: Error 218 | try { 219 | injector.getOrInit(id) 220 | } catch (e) { 221 | error = e 222 | } 223 | expect(error!.message).toBe( 224 | `[wedi] "createInstance" exceeds the limitation of recursion (10x). There might be a circular dependency among your dependency items. Last target was "b".` 225 | ) 226 | }) 227 | }) 228 | 229 | describe('layered injectors', () => { 230 | const id = createIdentifier<{ log(): string }>('a&b') 231 | 232 | class A { 233 | log(): string { 234 | return 'A' 235 | } 236 | } 237 | 238 | class B { 239 | log(): string { 240 | return 'B' 241 | } 242 | } 243 | 244 | it('should layered injectors work', () => { 245 | const injector = new Injector( 246 | new DependencyCollection([[id, { useClass: A }]]) 247 | ) 248 | const childInjector = injector.createChild( 249 | new DependencyCollection([[id, { useClass: B }]]) 250 | ) 251 | 252 | expect(childInjector.getOrInit(id)?.log()).toBe('B') 253 | }) 254 | 255 | it('should useClass initialize at the correct layer', () => { 256 | const injector = new Injector( 257 | new DependencyCollection([[id, { useClass: A }]]) 258 | ) 259 | const childInjector = injector.createChild() 260 | 261 | expect(childInjector.getOrInit(id)?.log()).toBe('A') 262 | expect(childInjector.getOrInit(id)?.log()).toBe('A') 263 | }) 264 | }) 265 | 266 | describe('lazy instantiation', () => { 267 | const id = createIdentifier('a') 268 | 269 | class A { 270 | value = 'a' 271 | 272 | constructor() { 273 | initFlag = true 274 | } 275 | 276 | log(): string { 277 | return '[wedi]' 278 | } 279 | } 280 | 281 | class B { 282 | constructor(@id private a: A) {} 283 | 284 | log(): string { 285 | return this.a.log() 286 | } 287 | 288 | getValue(): string { 289 | return this.a.value 290 | } 291 | 292 | setValue(val: string) { 293 | this.a.value = val 294 | } 295 | } 296 | 297 | let initFlag: boolean 298 | 299 | beforeEach(() => (initFlag = false)) 300 | 301 | it('should lazy instantiation work', () => { 302 | const injector = new Injector( 303 | new DependencyCollection([ 304 | B, 305 | [id, { useClass: A, lazyInstantiation: true }] 306 | ]) 307 | ) 308 | const instance = injector.getOrInit(B) 309 | expect(initFlag).toBeFalsy() 310 | 311 | expect(instance?.log()).toBe('[wedi]') // work for methods 312 | expect(instance?.getValue()).toBe('a') // work for properties 313 | expect(instance?.log()).toBe('[wedi]') // should cache methods 314 | expect(initFlag).toBeTruthy() 315 | 316 | instance?.setValue('b') // should set value work 317 | expect(instance?.getValue()).toBe('b') 318 | }) 319 | 320 | it('should initialize on CPU idle', async () => { 321 | const injector = new Injector( 322 | new DependencyCollection([ 323 | B, 324 | [id, { useClass: A, lazyInstantiation: true }] 325 | ]) 326 | ) 327 | injector.getOrInit(B) 328 | expect(initFlag).toBeFalsy() 329 | 330 | await new Promise((res) => setTimeout(res, 200)) 331 | expect(initFlag).toBeTruthy() 332 | }) 333 | }) 334 | 335 | describe('dispose', () => { 336 | it('should not be accessible after disposing', () => { 337 | const injector = new Injector(new DependencyCollection()) 338 | 339 | injector.dispose() 340 | 341 | let error: Error 342 | try { 343 | injector.getOrInit(voidIdentifier) 344 | } catch (e) { 345 | error = e 346 | } 347 | expect(error!.message).toBe( 348 | '[wedi] Dependency collection is not accessible after it disposes!' 349 | ) 350 | }) 351 | }) 352 | 353 | describe('class inheritance', () => { 354 | it('should support initialize inherited classes', () => { 355 | class A {} 356 | 357 | class B {} 358 | 359 | class C { 360 | constructor(@Need(A) public a: A) {} 361 | } 362 | 363 | class C2 extends C { 364 | constructor(@Need(A) a: A, @Need(B) public b: B) { 365 | super(a) 366 | } 367 | } 368 | 369 | const injector = new Injector(new DependencyCollection([A, B, C2])) 370 | const c2 = injector.getOrInit(C2)! 371 | 372 | expect((c2.a as any).__proto__ === A.prototype).toBeTruthy() 373 | expect((c2.b as any).__proto__ === B.prototype).toBeTruthy() 374 | }) 375 | }) 376 | }) 377 | -------------------------------------------------------------------------------- /test/di-react.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from '@testing-library/react' 2 | import React, { Component, FunctionComponent, useState } from 'react' 3 | 4 | import { 5 | createIdentifier, 6 | DependencyCollection, 7 | Disposable, 8 | Inject, 9 | InjectionContext, 10 | Injector, 11 | Provide, 12 | Provider, 13 | useCollection, 14 | useDependency, 15 | useMultiDependencies, 16 | connectProvider 17 | } from '../src' 18 | 19 | class Log { 20 | log(): string { 21 | return 'wedi' 22 | } 23 | } 24 | 25 | describe('di-react', () => { 26 | describe('class component', () => { 27 | it('should support class component provider', () => { 28 | @Provide([Log]) 29 | class App extends Component { 30 | static contextType = InjectionContext 31 | 32 | @Inject(Log) private log!: Log 33 | 34 | render() { 35 | return
    {this.log.log()}
    36 | } 37 | } 38 | 39 | const { container } = render() 40 | expect(container.firstChild!.textContent).toBe('wedi') 41 | }) 42 | 43 | it('should raise error when user tries to set dependency', () => { 44 | class A {} 45 | 46 | let callback = () => {} 47 | 48 | @Provide([A]) 49 | class App extends Component { 50 | static contextType = InjectionContext 51 | 52 | @Inject(A) public a!: A 53 | 54 | render() { 55 | // use a callback instead of tracking an async event 56 | callback = () => (this.a = null as any) 57 | return
    wedi
    58 | } 59 | } 60 | 61 | render() 62 | 63 | let error: Error 64 | try { 65 | callback() 66 | } catch (e) { 67 | error = e 68 | } 69 | expect(error!.message).toBe( 70 | '[wedi] You can never set value to a dependency. Check "a" of "A".' 71 | ) 72 | }) 73 | 74 | it('should raise error when component context is not set to InjectionContext', () => { 75 | @Provide([Log]) 76 | class App extends Component { 77 | @Inject(Log) private log!: Log 78 | 79 | render() { 80 | return
    {this.log.log()}
    81 | } 82 | } 83 | 84 | let error: Error 85 | try { 86 | render() 87 | } catch (e) { 88 | error = e 89 | } 90 | expect(error!.message).toBe( 91 | `[wedi] You should make "InjectorContext" as App's default context type. If you want to use multiple context, please check this page on React documentation. https://reactjs.org/docs/context.html#classcontexttype` 92 | ) 93 | }) 94 | 95 | it('should raise error when injection is not optional and item is not provided', () => { 96 | @Provide([]) 97 | class App extends Component { 98 | static contextType = InjectionContext 99 | 100 | @Inject(Log) private log!: Log 101 | 102 | render() { 103 | return
    {this.log.log()}
    104 | } 105 | } 106 | 107 | let error: Error 108 | try { 109 | render() 110 | } catch (e) { 111 | error = e 112 | } 113 | expect(error!.message).toBe('[wedi] Cannot get an instance of "Log".') 114 | }) 115 | 116 | it('should tolerate when dependency is optional', () => { 117 | @Provide([]) 118 | class App extends Component { 119 | static contextType = InjectionContext 120 | 121 | @Inject(Log, true) private log!: Log 122 | 123 | render() { 124 | return
    {this.log?.log() || 'null'}
    125 | } 126 | } 127 | 128 | const { container } = render() 129 | expect(container.firstElementChild!.textContent).toBe('null') 130 | }) 131 | }) 132 | 133 | describe('functional component', () => { 134 | it('should `connectProvider` work', () => { 135 | class Log { 136 | log(): string { 137 | return 'wedi' 138 | } 139 | } 140 | 141 | const App = connectProvider( 142 | function() { 143 | const log = useDependency(Log) 144 | 145 | return
    {log.log()}
    146 | }, 147 | { 148 | collection: new DependencyCollection([Log]) 149 | } 150 | ) 151 | 152 | const { container } = render() 153 | expect(container.firstElementChild!.textContent).toBe('wedi') 154 | }) 155 | 156 | it('should useMultipleDependencies work', () => { 157 | class Log { 158 | log(): string { 159 | return 'wedi' 160 | } 161 | } 162 | 163 | class Counter { 164 | count = 0 165 | } 166 | 167 | function App() { 168 | const collection = useCollection([Log, Counter]) 169 | 170 | return ( 171 | 172 | 173 | 174 | ) 175 | } 176 | 177 | function Child() { 178 | const [log, counter] = useMultiDependencies([Log, Counter]) 179 | 180 | return ( 181 |
    182 | {log?.log()}, {counter?.count} 183 |
    184 | ) 185 | } 186 | 187 | const { container } = render() 188 | expect(container.firstElementChild!.textContent).toBe('wedi, 0') 189 | }) 190 | 191 | it('should not recreate collection when function component container re-renders', async () => { 192 | let count = 0 193 | 194 | class Counter { 195 | constructor() { 196 | count += 1 197 | } 198 | 199 | getCount(): number { 200 | return count 201 | } 202 | } 203 | 204 | function Parent() { 205 | const collection = useCollection([Counter]) 206 | const [visible, setVisible] = useState(false) 207 | return ( 208 |
    setVisible(!visible)}> 209 | 210 | {visible ? :
    Nothing
    } 211 |
    212 |
    213 | ) 214 | } 215 | 216 | function Children() { 217 | const counter = useDependency(Counter) 218 | return
    {counter.getCount()}
    219 | } 220 | 221 | const { container } = render() 222 | expect(count).toBe(0) 223 | 224 | await act(() => { 225 | fireEvent.click(container.firstElementChild!) 226 | return new Promise((res) => setTimeout(res, 200)) 227 | }) 228 | expect(count).toBe(1) 229 | 230 | await act(() => { 231 | fireEvent.click(container.firstElementChild!) 232 | return new Promise((res) => setTimeout(res, 200)) 233 | }) 234 | expect(count).toBe(1) 235 | }) 236 | 237 | it('should throw error when a dependency is not retrievable and not optional', () => { 238 | function AppContainer() { 239 | const collection = useCollection([]) 240 | 241 | return ( 242 | 243 | 244 | 245 | ) 246 | } 247 | 248 | function App() { 249 | const log = useDependency(Log) 250 | 251 | return
    {log.log()}
    252 | } 253 | 254 | let error: Error 255 | try { 256 | render() 257 | } catch (e) { 258 | error = e 259 | } 260 | expect(error!.message).toBe(`[wedi] Cannot get an instance of "Log".`) 261 | }) 262 | 263 | it('should raise error when no collection nor injector is provided', () => { 264 | function App() { 265 | return ( 266 | 267 |
    wedi
    268 |
    269 | ) 270 | } 271 | 272 | let error: Error 273 | try { 274 | render() 275 | } catch (e) { 276 | error = e 277 | } 278 | expect(error!.message).toBe( 279 | '[wedi] should provide a collection or an injector to "Provider".' 280 | ) 281 | }) 282 | 283 | it('should tolerate when dependency is optional', () => { 284 | function AppContainer() { 285 | const collection = useCollection([]) 286 | 287 | return ( 288 | 289 | 290 | 291 | ) 292 | } 293 | 294 | function App() { 295 | const log = useDependency(Log, true) 296 | 297 | return
    {log?.log() || 'wedi'}
    298 | } 299 | 300 | const { container } = render() 301 | expect(container.firstElementChild!.textContent).toBe('wedi') 302 | }) 303 | }) 304 | 305 | it('should support layered providers', () => { 306 | const id = createIdentifier<{ log(): string }>('a&b') 307 | const id2 = createIdentifier<{ log(): string }>('c') 308 | 309 | class A { 310 | log(): string { 311 | return 'A' 312 | } 313 | } 314 | 315 | class B { 316 | log(): string { 317 | return 'B' 318 | } 319 | } 320 | 321 | class C { 322 | log(): string { 323 | return 'C' 324 | } 325 | } 326 | 327 | @Provide([ 328 | [id, { useClass: A }], 329 | [id2, { useClass: C }] 330 | ]) 331 | class Parent extends Component { 332 | render() { 333 | return 334 | } 335 | } 336 | 337 | @Provide([[id, { useClass: B }]]) 338 | class Child extends Component { 339 | render() { 340 | return 341 | } 342 | } 343 | 344 | function GrandChild() { 345 | const aOrb = useDependency(id) 346 | const c = useDependency(id2) 347 | 348 | return ( 349 |
    350 | {aOrb?.log()}, {c?.log()} 351 |
    352 | ) 353 | } 354 | 355 | const { container } = render() 356 | expect(container.firstElementChild?.textContent).toBe('B, C') 357 | }) 358 | 359 | describe('should dispose', () => { 360 | it('should dispose when class component destroys', async () => { 361 | let disposed = false 362 | 363 | class A implements Disposable { 364 | log(): string { 365 | return 'a' 366 | } 367 | 368 | dispose(): void { 369 | disposed = true 370 | } 371 | } 372 | 373 | function Parent() { 374 | const [show, setShow] = useState(true) 375 | return ( 376 |
    setShow(!show)}> 377 | {show ? :
    null
    } 378 |
    379 | ) 380 | } 381 | 382 | @Provide([A]) 383 | class Child extends Component { 384 | static contextType = InjectionContext 385 | 386 | @Inject(A) private a!: A 387 | 388 | render() { 389 | return <>{this.a.log()} 390 | } 391 | } 392 | 393 | const { container } = render() 394 | expect(container.firstElementChild!.textContent).toBe('a') 395 | 396 | await act(() => { 397 | fireEvent.click(container.firstElementChild!) 398 | return new Promise((res) => setTimeout(res, 200)) 399 | }) 400 | 401 | expect(disposed).toBeTruthy() 402 | }) 403 | 404 | it('should dispose when functional component destroys', async () => { 405 | let disposed = false 406 | 407 | class A implements Disposable { 408 | log(): string { 409 | return 'a' 410 | } 411 | 412 | dispose(): void { 413 | disposed = true 414 | } 415 | } 416 | 417 | function Parent() { 418 | const [show, setShow] = useState(true) 419 | return ( 420 |
    setShow(!show)}> 421 | {show ? :
    null
    } 422 |
    423 | ) 424 | } 425 | 426 | function Child() { 427 | const collection = useCollection([A]) 428 | 429 | return ( 430 | 431 | 432 | 433 | ) 434 | } 435 | 436 | function GrandChild() { 437 | const a = useDependency(A)! 438 | 439 | return
    {a.log()}
    440 | } 441 | 442 | const { container } = render() 443 | expect(container.firstElementChild!.textContent).toBe('a') 444 | 445 | await act(() => { 446 | fireEvent.click(container.firstElementChild!) 447 | return new Promise((res) => setTimeout(res, 200)) 448 | }) 449 | 450 | expect(disposed).toBeTruthy() 451 | }) 452 | }) 453 | 454 | it('should support inject React component', () => { 455 | const IDropdown = createIdentifier('dropdown') 456 | const IConfig = createIdentifier('config') 457 | 458 | const WebDropdown = function() { 459 | const dep = useDependency(IConfig) 460 | return
    WeDropdown, {dep}
    461 | } 462 | 463 | @Provide([ 464 | [IDropdown, { useValue: WebDropdown }], 465 | [IConfig, { useValue: 'wedi' }] 466 | ]) 467 | class Header extends Component { 468 | static contextType = InjectionContext 469 | 470 | @Inject(IDropdown) private dropdown!: FunctionComponent 471 | 472 | render() { 473 | const Dropdown = this.dropdown 474 | return 475 | } 476 | } 477 | 478 | const { container } = render(
    ) 479 | expect(container.firstChild!.textContent).toBe('WeDropdown, wedi') 480 | }) 481 | 482 | it('should support parent injector outside of React tree', () => { 483 | const injector = new Injector(new DependencyCollection([Log])) 484 | 485 | function App() { 486 | return ( 487 | 488 | 489 | 490 | ) 491 | } 492 | 493 | function Children() { 494 | const logger = useDependency(Log) 495 | 496 | return
    {logger.log()}
    497 | } 498 | 499 | const { container } = render() 500 | expect(container.firstChild!.textContent).toBe('wedi') 501 | }) 502 | }) 503 | -------------------------------------------------------------------------------- /test/di-rx.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render } from '@testing-library/react' 2 | import React, { Component } from 'react' 3 | import { BehaviorSubject, interval, Subject } from 'rxjs' 4 | import { scan, startWith } from 'rxjs/operators' 5 | 6 | import { 7 | Disposable, 8 | Provide, 9 | Provider, 10 | useCollection, 11 | useDependency, 12 | useDependencyValue, 13 | useUpdateBinder, 14 | useDependencyContext, 15 | useDependencyContextValue 16 | } from '../src' 17 | 18 | describe('di-rx', () => { 19 | it('should demo works with RxJS', async () => { 20 | class CounterService { 21 | counter$ = interval(100).pipe( 22 | startWith(0), 23 | scan((acc) => acc + 1) 24 | ) 25 | } 26 | 27 | @Provide([CounterService]) 28 | class App extends Component { 29 | render() { 30 | return 31 | } 32 | } 33 | 34 | function Display() { 35 | const counter = useDependency(CounterService) 36 | const value = useDependencyValue(counter!.counter$, 0) 37 | 38 | return
    {value}
    39 | } 40 | 41 | const { container } = render() 42 | expect(container.firstChild!.textContent).toBe('0') 43 | 44 | await act( 45 | () => new Promise((res) => setTimeout(() => res(), 360)) 46 | ) 47 | expect(container.firstChild!.textContent).toBe('3') 48 | }) 49 | 50 | it('should use default value in BehaviorSubject', async () => { 51 | class CounterService implements Disposable { 52 | public counter$: BehaviorSubject 53 | private number: number 54 | private readonly loop?: number 55 | 56 | constructor() { 57 | this.number = 5 58 | this.counter$ = new BehaviorSubject(this.number) 59 | this.loop = (setInterval(() => { 60 | this.number += 1 61 | this.counter$.next(this.number) 62 | }, 100) as any) as number 63 | } 64 | 65 | dispose(): void { 66 | clearTimeout(this.loop!) 67 | } 68 | } 69 | 70 | function App() { 71 | const collection = useCollection([CounterService]) 72 | 73 | return ( 74 | 75 | 76 | 77 | ) 78 | } 79 | 80 | function Child() { 81 | const counterService = useDependency(CounterService) 82 | const count = useDependencyValue(counterService.counter$) 83 | 84 | return
    {count}
    85 | } 86 | 87 | const { container } = render() 88 | expect(container.firstChild!.textContent).toBe('5') 89 | 90 | await act( 91 | () => new Promise((res) => setTimeout(() => res(), 320)) 92 | ) 93 | expect(container.firstChild!.textContent).toBe('8') 94 | }) 95 | 96 | it('should not trigger unnecessary re-render when handled correctly', async () => { 97 | let childRenderCount = 0 98 | 99 | class CounterService { 100 | counter$ = interval(100).pipe( 101 | startWith(0), 102 | scan((acc) => acc + 1) 103 | ) 104 | } 105 | 106 | function App() { 107 | const collection = useCollection([CounterService]) 108 | 109 | return ( 110 | 111 | 112 | 113 | ) 114 | } 115 | 116 | function Parent() { 117 | const counterService = useDependency(CounterService) 118 | const count = useDependencyValue(counterService.counter$, 0) 119 | 120 | return 121 | } 122 | 123 | function Child(props: { count?: number }) { 124 | childRenderCount += 1 125 | return
    {props.count}
    126 | } 127 | 128 | const { container } = render() 129 | expect(container.firstChild!.textContent).toBe('0') 130 | expect(childRenderCount).toBe(1) 131 | 132 | await act( 133 | () => new Promise((res) => setTimeout(() => res(), 360)) 134 | ) 135 | expect(container.firstChild!.textContent).toBe('3') 136 | expect(childRenderCount).toBe(4) 137 | }) 138 | 139 | it('should not trigger unnecessary re-render with useDependencyContext', async () => { 140 | let childRenderCount = 0 141 | 142 | class CounterService { 143 | counter$ = interval(100).pipe( 144 | startWith(0), 145 | scan((acc) => acc + 1) 146 | ) 147 | } 148 | 149 | function App() { 150 | const collection = useCollection([CounterService]) 151 | 152 | return ( 153 | 154 | 155 | 156 | ) 157 | } 158 | 159 | function useCounter$() { 160 | return useDependency(CounterService).counter$ 161 | } 162 | 163 | function Parent() { 164 | const counter$ = useCounter$() 165 | const { Provider: CounterProvider } = useDependencyContext(counter$, 0) 166 | 167 | return ( 168 | 169 | 170 | 171 | ) 172 | } 173 | 174 | function Child() { 175 | const counter$ = useCounter$() 176 | const count = useDependencyContextValue(counter$) 177 | 178 | childRenderCount += 1 179 | 180 | return
    {count}
    181 | } 182 | 183 | const { container } = render() 184 | expect(container.firstChild!.textContent).toBe('0') 185 | expect(childRenderCount).toBe(1) 186 | 187 | await act( 188 | () => new Promise((res) => setTimeout(() => res(), 360)) 189 | ) 190 | // expect(container.firstChild!.textContent).toBe('3') 191 | expect(childRenderCount).toBe(4) 192 | }) 193 | 194 | it('should raise error when no ancestor subscribe an observable value', async () => { 195 | class CounterService { 196 | counter$ = interval(1000).pipe( 197 | startWith(0), 198 | scan((acc) => acc + 1) 199 | ) 200 | } 201 | 202 | function App() { 203 | const collection = useCollection([CounterService]) 204 | 205 | return ( 206 | 207 | 208 | 209 | ) 210 | } 211 | 212 | function useCounter$() { 213 | return useDependency(CounterService).counter$ 214 | } 215 | 216 | function Parent() { 217 | return 218 | } 219 | 220 | function Child() { 221 | const counter$ = useCounter$() 222 | const count = useDependencyContextValue(counter$) 223 | 224 | return
    {count}
    225 | } 226 | 227 | let error: Error 228 | try { 229 | render() 230 | } catch (e) { 231 | error = e 232 | } 233 | expect(error!.message).toBe( 234 | '[wedi] try to read context value but no ancestor component subscribed it.' 235 | ) 236 | }) 237 | 238 | it('should update whenever `useUpdateBinder` emits', async () => { 239 | class CounterService implements Disposable { 240 | public number = 0 241 | public updater$ = new Subject() 242 | 243 | private loop?: number 244 | 245 | constructor() { 246 | this.loop = (setInterval(() => { 247 | this.number += 1 248 | this.updater$.next() 249 | }, 1000) as any) as number 250 | } 251 | 252 | dispose(): void { 253 | clearTimeout(this.loop!) 254 | } 255 | } 256 | 257 | function App() { 258 | const collection = useCollection([CounterService]) 259 | 260 | return ( 261 | 262 | 263 | 264 | ) 265 | } 266 | 267 | function Child() { 268 | const counterService = useDependency(CounterService) 269 | 270 | useUpdateBinder(counterService.updater$) 271 | 272 | return
    {counterService.number}
    273 | } 274 | 275 | const { container } = render() 276 | expect(container.firstChild!.textContent).toBe('0') 277 | 278 | await act( 279 | () => new Promise((res) => setTimeout(() => res(), 3100)) 280 | ) 281 | expect(container.firstChild!.textContent).toBe('3') 282 | }) 283 | }) 284 | -------------------------------------------------------------------------------- /test/di-singleton.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from '@testing-library/react' 2 | import React, { Component, useState } from 'react' 3 | 4 | import { act } from 'react-dom/test-utils' 5 | import { 6 | createIdentifier, 7 | Inject, 8 | InjectionContext, 9 | Provide, 10 | Provider, 11 | registerSingleton, 12 | useCollection, 13 | useDependency 14 | } from '../src' 15 | 16 | const id = createIdentifier('a') 17 | const id2 = createIdentifier('b') 18 | const id3 = createIdentifier('c') 19 | 20 | interface Log { 21 | log(): string 22 | } 23 | 24 | class A implements Log { 25 | log(): string { 26 | return '[wedi]' 27 | } 28 | } 29 | 30 | class B implements Log { 31 | log(): string { 32 | return '[WePuppy]' 33 | } 34 | } 35 | 36 | let initializationCounter = 0 37 | 38 | class C { 39 | constructor() { 40 | initializationCounter += 1 41 | } 42 | 43 | getCounter(): number { 44 | return initializationCounter 45 | } 46 | } 47 | 48 | registerSingleton(id, A) 49 | registerSingleton(id2, A) 50 | registerSingleton(id2, B) 51 | registerSingleton(id3, C) 52 | 53 | describe('di-core-singleton', () => { 54 | it('should singleton work', () => { 55 | @Provide([]) 56 | class App extends Component { 57 | static contextType = InjectionContext 58 | 59 | @Inject(id) private log!: Log 60 | 61 | render() { 62 | return
    {this.log.log()}
    63 | } 64 | } 65 | 66 | const { container } = render() 67 | 68 | expect(container.firstChild!.textContent).toBe('[wedi]') 69 | }) 70 | 71 | it('should use the latest registered dependency', () => { 72 | @Provide([]) 73 | class App extends Component { 74 | static contextType = InjectionContext 75 | 76 | @Inject(id2) private log!: Log 77 | 78 | render() { 79 | return
    {this.log.log()}
    80 | } 81 | } 82 | 83 | const { container } = render() 84 | 85 | expect(container.firstChild!.textContent).toBe('[WePuppy]') 86 | }) 87 | 88 | it('should singleton work with root injector in functional component', async () => { 89 | function App() { 90 | const [count, setCount] = useState(0) 91 | const collection = useCollection() 92 | 93 | return ( 94 | 95 |
    setCount(count + 1)}> 96 | {count} 97 | 98 |
    99 |
    100 | ) 101 | } 102 | 103 | function Child() { 104 | const c = useDependency(id3) as C 105 | 106 | return <>, {c.getCounter()} 107 | } 108 | 109 | const { container } = render() 110 | 111 | expect(container.firstChild!.textContent).toBe('0, 1') 112 | 113 | await act(() => { 114 | fireEvent.click(container.firstElementChild!) 115 | return new Promise((res) => setTimeout(res, 20)) 116 | }) 117 | 118 | expect(container.firstChild!.textContent).toBe('1, 1') 119 | }) 120 | }) 121 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "jsx": "react", 5 | "lib": [ 6 | "esnext", 7 | "dom" 8 | ], 9 | "sourceMap": true, 10 | "target": "es5", 11 | "outDir": "dist", 12 | "module": "commonjs", 13 | "moduleResolution": "node", 14 | "strict": true, 15 | "declaration": true, 16 | "allowSyntheticDefaultImports": true, 17 | "experimentalDecorators": true, 18 | "noUnusedLocals": true, 19 | "esModuleInterop": true, 20 | "types": [ 21 | "node", 22 | "jest" 23 | ], 24 | "typeRoots": [ 25 | "./node_modules/@types" 26 | ], 27 | "paths": { 28 | "wedi": [ 29 | "./src/index.ts" 30 | ], 31 | "wedi/*": [ 32 | "./src/*" 33 | ] 34 | } 35 | }, 36 | "include": [ 37 | "src/**/*" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": {} 3 | } --------------------------------------------------------------------------------