├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ci.yml ├── .gitignore ├── .gitpod.yml ├── .npmignore ├── .prettierignore ├── .prettierrc.js ├── .stylelintignore ├── .stylelintrc.js ├── .umirc.ts ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── .yarnrc ├── LICENSE ├── README.md ├── circle.yml ├── configs └── base.tsconfig.json ├── dev-packages └── mana-scripts │ ├── README.md │ ├── check-publish.js │ ├── mana-run.js │ └── package.json ├── docs ├── .gitkeep ├── examples │ └── todo.md ├── guide │ ├── common.md │ ├── index.md │ ├── observable.md │ └── syringe.md └── index.md ├── examples ├── todo-list │ ├── application │ │ ├── application.ts │ │ └── module.ts │ ├── countdown-todo │ │ ├── countdown-todo-item-contribution.ts │ │ ├── countdown-todo-item-view │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── countdown-todo-item.ts │ │ └── module.ts │ ├── index.css │ ├── index.tsx │ ├── mana.ts │ ├── page.tsx │ └── todo-list │ │ ├── default-todo-item │ │ ├── default-todo-item-contribution.ts │ │ ├── todo-item-view │ │ │ ├── index.css │ │ │ └── index.tsx │ │ └── todo-item.ts │ │ ├── manager.ts │ │ ├── module.ts │ │ ├── todo-item-registry.ts │ │ └── todo-view │ │ ├── count.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ └── todo-add.tsx └── tsconfig.json ├── jest.config.js ├── lerna.json ├── package.json ├── packages ├── mana-common │ ├── .eslintrc.js │ ├── .fatherrc.ts │ ├── README.md │ ├── package.json │ ├── src │ │ ├── cancellation.spec.ts │ │ ├── cancellation.ts │ │ ├── deferred.ts │ │ ├── disposable-collection.ts │ │ ├── disposable.spec.ts │ │ ├── disposable.ts │ │ ├── event.spec.ts │ │ ├── event.ts │ │ ├── index.ts │ │ ├── objects.spec.ts │ │ ├── objects.ts │ │ ├── priority.spec.ts │ │ ├── priority.ts │ │ ├── promise-util.spec.ts │ │ ├── promise-util.ts │ │ ├── types-util.spec.ts │ │ ├── types-util.ts │ │ └── types.ts │ └── tsconfig.json ├── mana-observable │ ├── .eslintrc.js │ ├── .fatherrc.ts │ ├── README.md │ ├── package.json │ ├── src │ │ ├── context.spec.tsx │ │ ├── context.tsx │ │ ├── core.ts │ │ ├── decorator.spec.ts │ │ ├── decorator.ts │ │ ├── hooks.spec.tsx │ │ ├── hooks.ts │ │ ├── index.ts │ │ ├── notifier.spec.ts │ │ ├── notifier.ts │ │ ├── observable.spec.tsx │ │ ├── observable.ts │ │ ├── reactivity.spec.tsx │ │ ├── reactivity.ts │ │ ├── tracker.spec.ts │ │ ├── tracker.ts │ │ ├── utils.spec.ts │ │ ├── utils.ts │ │ ├── watch.spec.ts │ │ └── watch.ts │ └── tsconfig.json └── mana-syringe │ ├── .eslintrc.js │ ├── .fatherrc.ts │ ├── README.md │ ├── package.json │ ├── src │ ├── container.spec.ts │ ├── container.ts │ ├── contribution │ │ ├── contribution-protocol.ts │ │ ├── contribution-provider.ts │ │ ├── contribution-register.ts │ │ ├── decorator.ts │ │ ├── index.spec.ts │ │ └── index.ts │ ├── core.ts │ ├── decorator.spec.ts │ ├── decorator.ts │ ├── index.ts │ ├── inversify │ │ ├── index.spec.ts │ │ ├── index.ts │ │ └── inversify-protocol.ts │ ├── module │ │ ├── index.spec.ts │ │ ├── index.ts │ │ └── syringe-module.ts │ ├── register.spec.ts │ ├── register.ts │ ├── side-option.spec.ts │ └── side-option.ts │ └── tsconfig.json ├── test ├── observable │ └── index.spec.tsx └── syringe │ └── index.spec.tsx ├── tsconfig.jest.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | generatedParser 3 | dist/ 4 | scripts/ 5 | lib 6 | esm 7 | es 8 | .node 9 | .umi 10 | .umi-production 11 | public 12 | jest.config.js 13 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [require.resolve('@umijs/fabric/dist/eslint')], 3 | rules: { 4 | 'spaced-comment': 'off', 5 | '@typescript-eslint/no-parameter-properties': 'off', 6 | '@typescript-eslint/no-redeclare': 'off', 7 | '@typescript-eslint/no-namespace': 'off', 8 | 'no-param-reassign': 'off', 9 | 'no-underscore-dangle': 'off', 10 | 'no-restricted-syntax': 'off', 11 | '@typescript-eslint/no-loop-func': 'off', 12 | 'consistent-type-definitions': 'off', 13 | 'no-useless-return': 'off', 14 | 'max-classes-per-file': 'off', 15 | 'no-return-assign': 'off', 16 | 'no-continue': 'off', 17 | 'no-bitwise': 'off', 18 | 'no-await-in-loop': 'off', 19 | '@typescript-eslint/no-unused-expressions': 'off', 20 | 'global-require': 'off', 21 | 'no-plusplus': 'off', 22 | 'import/export': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | ### 🤔 这个变动的性质是?/ What is the nature of this change? 7 | 8 | - [ ] 新特性提交 / New feature 9 | - [ ] bug 修复 / Fix bug 10 | - [ ] 样式优化 / Style optimization 11 | - [ ] 代码风格优化 / Code style optimization 12 | - [ ] 性能优化 / Performance optimization 13 | - [ ] 构建优化 / Build optimization 14 | - [ ] 网站、文档、Demo 改进 / Website, documentation, demo improvements 15 | - [ ] 重构代码或样式 / Refactor code or style 16 | - [ ] 测试相关 / Test related 17 | - [ ] 其他 / Other 18 | 19 | ### 🔗 相关 Issue / Related Issue 20 | 21 | 25 | 26 | ### 💡 需求背景和解决方案 / Background or solution 27 | 28 | 32 | 33 | ### 📝 更新日志 / Changelog 34 | 35 | 39 | 40 | | Language | Changelog | 41 | | ---------- | --------- | 42 | | 🇺🇸 English | | 43 | | 🇨🇳 Chinese | | 44 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node_version: [16.x] 11 | os: [ubuntu-latest] 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Use Node.js ${{ matrix.node_version }} 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: ${{ matrix.node_version }} 18 | - run: yarn 19 | - run: yarn bootstrap --npm-client yarn 20 | - run: yarn test --forceExit 21 | env: 22 | CI: true 23 | HEADLESS: false 24 | PROGRESS: none 25 | NODE_ENV: test 26 | NODE_OPTIONS: --max_old_space_size=4096 27 | 28 | - name: Generate coverage 29 | if: matrix.node_version == '14.x' && matrix.os == 'ubuntu-latest' 30 | run: bash <(curl -s https://codecov.io/bash) 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | build 4 | lib 5 | dist 6 | es 7 | *.log 8 | .idea 9 | .metadata 10 | *.iml 11 | lerna-debug.log 12 | coverage 13 | errorShots 14 | lerna-debug.log 15 | .browser_modules 16 | **/docs/api 17 | package-backup.json 18 | .history 19 | .Trash-* 20 | gh-pages 21 | local-scripts 22 | *.tsbuildinfo 23 | .eslintcache 24 | .umi 25 | package-lock.json 26 | .umi-production 27 | yarn.lock 28 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | 5 | tasks: 6 | - init: yarn 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /packages/*/src/fixtures 2 | /packages/*/node_modules 3 | /packages/*/.local 4 | /packages/**/*.test.ts 5 | /packages/**/*.test.js 6 | /node_modules 7 | /coverage 8 | /.idea 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | generatedParser 3 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | const fabric = require('@umijs/fabric'); 2 | 3 | module.exports = { 4 | ...fabric.prettier, 5 | arrowParens: 'avoid', 6 | }; 7 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | var { stylelint } = require('@umijs/fabric'); 2 | 3 | module.exports = stylelint; 4 | -------------------------------------------------------------------------------- /.umirc.ts: -------------------------------------------------------------------------------- 1 | // 配置文件 2 | export default { 3 | title: 'MANA', 4 | mode: 'site', 5 | exportStatic: {}, 6 | hash: true, 7 | navs: { 8 | 'en-US': [ 9 | null, 10 | { title: 'GitHub', path: 'https://github.com/umijs/mana' }, 11 | { title: 'Changelog', path: 'https://github.com/umijs/mana/releases' }, 12 | ], 13 | 'zh-CN': [ 14 | null, 15 | { title: 'GitHub', path: 'https://github.com/umijs/mana' }, 16 | { title: '更新日志', path: 'https://github.com/umijs/mana/releases' }, 17 | ], 18 | }, 19 | locales: [['zh-CN', '中文']], 20 | }; 21 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | // List of extensions which should be recommended for users of this workspace. 5 | "recommendations": [ 6 | "dbaeumer.vscode-eslint", 7 | "esbenp.prettier-vscode", 8 | "stylelint.vscode-stylelint" 9 | ], 10 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 11 | "unwantedRecommendations": [] 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug Jest Tests", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeArgs": [ 12 | "--inspect-brk", 13 | "${workspaceRoot}/node_modules/jest/bin/jest.js", 14 | "--runInBand" 15 | ], 16 | "console": "integratedTerminal", 17 | "internalConsoleOptions": "neverOpen", 18 | "port": 9229 19 | }, 20 | { 21 | "name": "Debug Jest Tests mana-syringe", 22 | "type": "node", 23 | "request": "launch", 24 | "runtimeArgs": [ 25 | "--inspect-brk", 26 | "${workspaceRoot}/node_modules/jest/bin/jest.js", 27 | "--runInBand", 28 | "./packages/mana-syringe" 29 | ], 30 | "console": "integratedTerminal", 31 | "internalConsoleOptions": "neverOpen", 32 | "port": 9229 33 | }, 34 | { 35 | "name": "Debug Jest Tests mana-observable", 36 | "type": "node", 37 | "request": "launch", 38 | "runtimeArgs": [ 39 | "--inspect-brk", 40 | "${workspaceRoot}/node_modules/jest/bin/jest.js", 41 | "--runInBand", 42 | "./packages/mana-observable" 43 | ], 44 | "console": "integratedTerminal", 45 | "internalConsoleOptions": "neverOpen", 46 | "port": 9229 47 | }, 48 | { 49 | "name": "Debug Jest Tests mana-common", 50 | "type": "node", 51 | "request": "launch", 52 | "runtimeArgs": [ 53 | "--inspect-brk", 54 | "${workspaceRoot}/node_modules/jest/bin/jest.js", 55 | "--runInBand", 56 | "./packages/mana-common" 57 | ], 58 | "console": "integratedTerminal", 59 | "internalConsoleOptions": "neverOpen", 60 | "port": 9229 61 | } 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // If one would like to add/remove/modify user preferences without modifying the content of the 2 | // workspace settings file, then one would need to modify the `settings.json` under here: 3 | // - Windows: %APPDATA%\Code\User\settings.json 4 | // - Linux: $HOME/.config/Code/User/settings.json 5 | // - Mac: $HOME/Library/Application Support/Code/User/settings.json 6 | { 7 | "search.exclude": { 8 | "**/node_modules": true, 9 | "**/lib": true, 10 | "**/dist": true, 11 | "**/coverage": true 12 | }, 13 | "editor.insertSpaces": true, 14 | "files.insertFinalNewline": true, 15 | "editor.formatOnSave": true, // ESLint `max-len` rule. 16 | "editor.codeActionsOnSave": { 17 | "source.fixAll.eslint": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | # Using the official mirror source 2 | registry "https://registry.yarnpkg.com" 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-present UmiJS Team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MANA 2 | 3 | [![codecov](https://codecov.io/gh/umijs/mana/branch/master/graph/badge.svg)](https://codecov.io/gh/umijs/mana) [![GitHub Actions status](https://github.com/umijs/mana/workflows/Node%20CI/badge.svg)](https://github.com/umijs/mana) 4 | 5 | > mana 当前还处于内测阶段 6 | 7 | 模块化的可扩展前端应用框架,为工作台型产品设计。 8 | 9 | ## Features 10 | 11 | - 快速构建工作台型产品。 12 | - 模块化,支持不同模块的组合,降低长期维护成本。 13 | - 可扩展,通过增量代码完成自定义,强大的二次开发能力。 14 | 15 | ## Roadmap 16 | 17 | - [x] 基础能力:依赖注入容器、数据观察 18 | - [ ] 核心能力:动态模块、动态布局 19 | - [ ] 通用模块:命令、菜单、快捷键、配置 20 | 21 | ## Documentation 22 | 23 | - [mana-syringe](./packages/mana-syringe/README.md) 依赖注入容器 24 | - [mana-observable](./packages/mana-observable/README.md) 数据观察 25 | - [mana-common](./packages/mana-common/README.md) 基础工具 26 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2.0 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/node:latest-browsers 11 | environment: 12 | CI: true 13 | NODE_ENV: test 14 | NODE_OPTIONS: --max_old_space_size=4096 15 | NPM_CONFIG_LOGLEVEL: error 16 | JOBS: max # https://gist.github.com/ralphtheninja/f7c45bdee00784b41fed 17 | branches: 18 | ignore: 19 | - gh-pages # list of branches to ignore 20 | - /release\/.*/ # or ignore regexes 21 | working_directory: ~/father 22 | 23 | steps: 24 | - checkout 25 | - restore_cache: 26 | key: node-modules-{{ checksum "package.json" }} 27 | - run: sudo npm install -g cnpm 28 | - run: cnpm install --registry=https://registry.npmjs.org 29 | - run: cnpm run bootstrap -- --npm-client=cnpm 30 | - run: cnpm run build 31 | - run: 32 | command: npm run test -- --forceExit --detectOpenHandles --runInBand --maxWorkers=2 33 | no_output_timeout: 300m 34 | - run: bash <(curl -s https://codecov.io/bash) 35 | - save_cache: 36 | key: node-modules-{{ checksum "package.json" }} 37 | paths: 38 | - ./node_modules 39 | -------------------------------------------------------------------------------- /configs/base.tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmitOnError": false, 4 | "target": "esnext", 5 | "module": "esnext", 6 | "lib": ["ESNext", "DOM"], 7 | "allowJs": true, 8 | "jsx": "preserve", 9 | "outDir": "lib", 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "noUnusedLocals": true, 15 | "moduleResolution": "node", 16 | "esModuleInterop": true, 17 | "experimentalDecorators": true, 18 | "emitDecoratorMetadata": true, 19 | "importHelpers": true, 20 | "declaration": true, 21 | "declarationMap": true, 22 | "sourceMap": true, 23 | "skipLibCheck": true, 24 | "resolveJsonModule": true, 25 | "types": ["reflect-metadata", "jest", "node"] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /dev-packages/mana-scripts/README.md: -------------------------------------------------------------------------------- 1 | # Mana ext script 2 | -------------------------------------------------------------------------------- /dev-packages/mana-scripts/check-publish.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // @ts-check 4 | 5 | const path = require('path'); 6 | const chalk = require('chalk').default; 7 | const cp = require('child_process'); 8 | 9 | let code = 0; 10 | const workspaces = JSON.parse(cp.execSync('yarn --silent workspaces info').toString()); 11 | for (const name in workspaces) { 12 | if (Object.prototype.hasOwnProperty.call(workspaces, name)) { 13 | const workspace = workspaces[name]; 14 | const location = path.resolve(process.cwd(), workspace.location); 15 | const packagePath = path.resolve(location, 'package.json'); 16 | const pck = require(packagePath); 17 | if (!pck.private) { 18 | const pckName = `${pck.name}@${pck.version}`; 19 | try { 20 | if (cp.execSync(`npm view ${pckName} version --json`).toString().trim()) { 21 | console.info(`${pckName}: published`); 22 | } else { 23 | console.error(`(${chalk.red('ERR')}) ${pckName}: ${chalk.red('NOT')} published`); 24 | code = 1; 25 | } 26 | } catch (error) { 27 | console.error(`(${chalk.red('ERR')}) ${pckName}: ${chalk.red('NOT')} found`); 28 | } 29 | } 30 | } 31 | } 32 | process.exit(code); 33 | -------------------------------------------------------------------------------- /dev-packages/mana-scripts/mana-run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // @ts-check 4 | const path = require('path'); 5 | const cp = require('child_process'); 6 | 7 | const packageInfo = require(path.resolve(__dirname, 'package.json')); 8 | 9 | function getCommand() { 10 | const args = process.argv.slice(2); // user args 11 | const scripts = packageInfo['mana-scripts'] || {}; 12 | const script = args[0]; 13 | const command = scripts[script]; 14 | if (!script) { 15 | throw new Error( 16 | `[manarun] Please specify one of these supported scripts, ${Object.keys(scripts).join('/')}`, 17 | ); 18 | } 19 | if (!command) { 20 | throw new Error('[manarun] The script does not exist: ' + script); 21 | } 22 | return [command, ...args.slice(1, args.length)].join(' '); 23 | } 24 | 25 | /** 26 | * @param {string} command command line to run. 27 | */ 28 | function runCommand(command) { 29 | return new Promise((resolve, reject) => { 30 | const env = Object.assign({}, process.env); 31 | const scriptProcess = cp.exec(command, { 32 | cwd: process.cwd(), 33 | env, 34 | }); 35 | if (scriptProcess.stdout) { 36 | scriptProcess.stdout.pipe(process.stdout); 37 | } 38 | if (scriptProcess.stderr) { 39 | scriptProcess.stderr.pipe(process.stderr); 40 | } 41 | scriptProcess.on('error', reject); 42 | scriptProcess.on('close', resolve); 43 | }); 44 | } 45 | 46 | (async () => { 47 | let exitCode = 0; 48 | let error = undefined; 49 | let command = undefined; 50 | try { 51 | command = getCommand(); 52 | console.debug(`[manarun] $ ${command}`); 53 | exitCode = await runCommand(command); 54 | } catch (err) { 55 | if (command) { 56 | console.error(`[manarun] Error occurred when executing: ${command}\n`); 57 | } else { 58 | console.error('[manarun] Error occurred.'); 59 | } 60 | console.error(err); 61 | error = err; 62 | } 63 | if (error) { 64 | exitCode = 1; // without the process starting. 65 | } else if (exitCode) { 66 | console.error(`[manarun] Exit with code (${exitCode}): ${command}`); 67 | } 68 | process.exit(exitCode); 69 | })(); 70 | -------------------------------------------------------------------------------- /dev-packages/mana-scripts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "mana-scripts", 4 | "version": "0.3.0", 5 | "description": "NPM scripts for Mana packages.", 6 | "bin": { 7 | "manarun": "mana-run.js", 8 | "manarun-check-pub": "check-publish.js" 9 | }, 10 | "mana-scripts": { 11 | "clean": "manarun build:clean && manarun lint:clean && manarun test:clean", 12 | "build": "father build", 13 | "build:clean": "rimraf dist lib", 14 | "lint": "eslint --cache=true --no-error-on-unmatched-pattern=true \"{src,test}/**/*.{ts,tsx}\"", 15 | "lint:clean": "rimraf .eslintcache", 16 | "test:clean": "rimraf coverage", 17 | "watch": "father build -w", 18 | "check-publish": "node check-publish.js" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/umijs/mana/ecb2f9cc53e9ca9f3c00c5de0ee4ab2f9c8292bd/docs/.gitkeep -------------------------------------------------------------------------------- /docs/examples/todo.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 待办 3 | order: 2 4 | toc: menu 5 | nav: 6 | title: 示例 7 | order: 1 8 | --- 9 | 10 | # TODO 11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/guide/common.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: common 3 | order: 3 4 | toc: menu 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/guide/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 介绍 3 | order: 1 4 | toc: menu 5 | nav: 6 | title: 指南 7 | order: 1 8 | --- 9 | 10 | 模块化的可扩展前端应用框架,为工作台型产品设计。 11 | 12 | ## 特性 13 | 14 | - 快速构建工作台型产品。 15 | - 模块化,支持不同模块的组合,降低长期维护成本。 16 | - 可扩展,通过增量代码完成自定义,强大的二次开发能力。 17 | -------------------------------------------------------------------------------- /docs/guide/observable.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: observable 3 | order: 2 4 | toc: menu 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/guide/syringe.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: syringe 3 | order: 1 4 | toc: menu 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: MANA 3 | order: 10 4 | hero: 5 | title: MANA 6 | desc: 🚀 模块化的可扩展应用框架 7 | actions: 8 | - text: 快速上手 9 | link: /zh-CN/guide 10 | 11 | footer: Open-source MIT Licensed | Copyright © 2021 Powered by dumi 12 | --- 13 | -------------------------------------------------------------------------------- /examples/todo-list/application/application.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-syntax */ 2 | /* eslint-disable no-console */ 3 | /* eslint-disable no-await-in-loop */ 4 | /* eslint-disable @typescript-eslint/no-parameter-properties */ 5 | /* eslint-disable @typescript-eslint/no-redeclare */ 6 | import { singleton, contrib, postConstruct, Contribution, Syringe } from 'mana-syringe'; 7 | 8 | export const ApplicationContribution = Syringe.defineToken('ApplicationContribution'); 9 | export interface ApplicationContribution { 10 | onStart: (app: Application) => Promise; 11 | } 12 | 13 | @singleton() 14 | export class Application { 15 | constructor( 16 | @contrib(ApplicationContribution) 17 | protected readonly contributions: Contribution.Provider, 18 | ) {} 19 | 20 | @postConstruct() 21 | protected async startApps(): Promise { 22 | for (const contribution of this.contributions.getContributions()) { 23 | if (contribution.onStart) { 24 | try { 25 | await contribution.onStart(this); 26 | } catch (error) { 27 | console.log('应用启动失败:', contribution.constructor.name); 28 | } 29 | } 30 | } 31 | } 32 | printInfo(): void { 33 | console.log('当前应用共:', this.contributions.getContributions.length); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/todo-list/application/module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Contribution } from 'mana-syringe'; 2 | import { ApplicationContribution, Application } from './application'; 3 | 4 | export const CoreModule = Module(register => { 5 | register(Application); 6 | Contribution.register(register, ApplicationContribution); 7 | }); 8 | -------------------------------------------------------------------------------- /examples/todo-list/countdown-todo/countdown-todo-item-contribution.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-parameter-properties */ 2 | import { inject, singleton } from 'mana-syringe'; 3 | import type { ToDoItemRegistry, ToDoItem } from '../todo-list/todo-item-registry'; 4 | import { ToDoItemContribution, ToDoItemFactory } from '../todo-list/todo-item-registry'; 5 | import { CountdownToDoItemView } from './countdown-todo-item-view'; 6 | import { CountdownToDoItem } from './countdown-todo-item'; 7 | 8 | export const CountdownToDoFactory = Symbol('CountDownToDoFactory'); 9 | 10 | @singleton({ contrib: ToDoItemContribution }) 11 | export class CountdownToDoItemContribution implements ToDoItemContribution { 12 | constructor(@inject(CountdownToDoFactory) protected readonly todoFactory: ToDoItemFactory) {} 13 | registerTodoItems(registry: ToDoItemRegistry): void { 14 | registry.registerItem({ 15 | type: 'countDown', 16 | title: '倒计时', 17 | provide: this.todoFactory, 18 | render: CountdownToDoItemView as React.FC<{ todo: ToDoItem }>, 19 | canRender: (todo: ToDoItem) => { 20 | if (todo instanceof CountdownToDoItem) { 21 | return 400; 22 | } 23 | return 0; 24 | }, 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/todo-list/countdown-todo/countdown-todo-item-view/index.less: -------------------------------------------------------------------------------- 1 | .listItem { 2 | display: flex; 3 | justify-content: space-between; 4 | } 5 | 6 | .todoItem { 7 | flex: 1; 8 | 9 | .todoTag { 10 | margin-left: 10px; 11 | } 12 | 13 | .todoName { 14 | display: inline-block; 15 | text-transform: capitalize; 16 | } 17 | 18 | .progress { 19 | display: inline-block; 20 | width: 50%; 21 | margin-left: 16px; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/todo-list/countdown-todo/countdown-todo-item-view/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Checkbox, Tooltip, Tag, List, Button, Progress } from 'antd'; 3 | import type { CountdownToDoItem } from '../countdown-todo-item'; 4 | import { useInject, useObserve, getOrigin } from 'mana-observable'; 5 | import type { CheckboxChangeEvent } from 'antd/lib/checkbox'; 6 | import { ToDoManager } from '../../todo-list/manager'; 7 | import styles from './index.less'; 8 | 9 | export const CountdownToDoItemView: React.FC<{ todo: CountdownToDoItem }> = props => { 10 | const todo = useObserve(props.todo); 11 | const manager = useInject(ToDoManager); 12 | const percent = parseFloat(((todo.delta * 100) / todo.deadLine).toFixed(2)); 13 | let status: 'active' | 'success' | 'exception' = 'active'; 14 | if (todo.outdated) { 15 | status = 'exception'; 16 | } 17 | if (todo.completed) { 18 | status = 'success'; 19 | } 20 | return ( 21 | 24 | 27 | , 28 | ]} 29 | className={styles.listItem} 30 | > 31 |
32 | 33 | todo.toggle(e.target.value)} 37 | /> 38 | 39 | 40 | 41 | {todo.completed ? '✅' : '-'} 42 | 43 | 44 |
{todo.completed ? {todo.name} : todo.name}
45 |
46 | 47 |
48 |
49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /examples/todo-list/countdown-todo/countdown-todo-item.ts: -------------------------------------------------------------------------------- 1 | import { inject, singleton, postConstruct } from 'mana-syringe'; 2 | import { prop } from 'mana-observable'; 3 | import { DefaultToDoItem, ToDoName } from '../todo-list/default-todo-item/todo-item'; 4 | 5 | @singleton() 6 | export class CountdownToDoItem extends DefaultToDoItem { 7 | @prop() public outdated: boolean = false; 8 | @prop() public deadLine: number = 60; 9 | @prop() public delta: number = 0; 10 | 11 | protected timer: NodeJS.Timeout; 12 | 13 | constructor(@inject(ToDoName) name: string) { 14 | super(name); 15 | this.timer = setInterval(this.progressToDeadline, 1000 / 3); 16 | } 17 | 18 | @postConstruct() 19 | protected init(): void {} 20 | protected progressToDeadline = () => { 21 | if (!this.completed && !this.outdated) { 22 | this.delta += 1; 23 | } 24 | if (this.delta > this.deadLine) { 25 | this.outdated = true; 26 | if (this.timer) { 27 | clearInterval(this.timer); 28 | } 29 | } 30 | }; 31 | public toggle(value?: boolean): void { 32 | if (value === undefined) { 33 | this.completed = !this.completed; 34 | } else { 35 | this.completed = value; 36 | } 37 | } 38 | 39 | public setDeadLine = (value: number): void => { 40 | this.deadLine = value; 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /examples/todo-list/countdown-todo/module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from 'mana-syringe'; 2 | import { ToDoName } from '../todo-list/default-todo-item/todo-item'; 3 | import { 4 | CountdownToDoFactory, 5 | CountdownToDoItemContribution, 6 | } from './countdown-todo-item-contribution'; 7 | import { CountdownToDoItem } from './countdown-todo-item'; 8 | 9 | export const CountdownToDoModule = Module(register => { 10 | register(CountdownToDoFactory, { 11 | useFactory: context => (name: string) => { 12 | const child = context.container.createChild(); 13 | child.register(ToDoName, { useValue: name }); 14 | child.register(CountdownToDoItem); 15 | return child.get(CountdownToDoItem); 16 | }, 17 | }); 18 | 19 | register(CountdownToDoItemContribution); 20 | }); 21 | -------------------------------------------------------------------------------- /examples/todo-list/index.css: -------------------------------------------------------------------------------- 1 | .normal { 2 | font-family: Georgia, sans-serif; 3 | text-align: center; 4 | } 5 | 6 | .normal .ant-input { 7 | height: 34px; 8 | } 9 | -------------------------------------------------------------------------------- /examples/todo-list/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react'; 2 | import { dynamic } from 'umi'; 3 | import './mana'; 4 | 5 | export default dynamic({ 6 | loader: async (): Promise<() => React.ReactNode> => { 7 | // 这里的注释 webpackChunkName 可以指导 webpack 将该组件 HugeA 以这个名字单独拆出去 8 | const { TODOList } = await import(/* webpackChunkName: "todo" */ './page'); 9 | return TODOList; 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /examples/todo-list/mana.ts: -------------------------------------------------------------------------------- 1 | import { GlobalContainer } from 'mana-syringe'; 2 | import { defaultObservableContext } from 'mana-observable'; 3 | import { ToDoModule } from './todo-list/module'; 4 | import { CoreModule } from './application/module'; 5 | import { Application } from './application/application'; 6 | import { CountdownToDoModule } from './countdown-todo/module'; 7 | 8 | defaultObservableContext.config({ getContainer: () => GlobalContainer }); 9 | 10 | GlobalContainer.load(CoreModule); 11 | GlobalContainer.load(ToDoModule); 12 | GlobalContainer.load(CountdownToDoModule); 13 | 14 | GlobalContainer.get(Application); 15 | -------------------------------------------------------------------------------- /examples/todo-list/page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ToDo } from './todo-list/todo-view'; 3 | import 'antd/dist/antd.css'; 4 | import './index.css'; 5 | 6 | export function TODOList(): React.ReactNode { 7 | return ( 8 |
9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /examples/todo-list/todo-list/default-todo-item/default-todo-item-contribution.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-parameter-properties */ 2 | import { transient, inject } from 'mana-syringe'; 3 | import type { ToDoItemRegistry } from '../todo-item-registry'; 4 | import { ToDoItemContribution, ToDoItemFactory } from '../todo-item-registry'; 5 | import { ToDoItemView } from './todo-item-view'; 6 | 7 | export const DefaultToDoFactory = Symbol('DefaultToDoFactory'); 8 | const DefaultToDoItemType = 'default'; 9 | 10 | @transient({ contrib: ToDoItemContribution }) 11 | export class DefaultToDoItemContribution implements ToDoItemContribution { 12 | constructor(@inject(DefaultToDoFactory) protected readonly todoFactory: ToDoItemFactory) {} 13 | 14 | registerTodoItems(registry: ToDoItemRegistry): void { 15 | registry.registerItem({ 16 | type: DefaultToDoItemType, 17 | title: '默认', 18 | provide: this.todoFactory, 19 | render: ToDoItemView, 20 | canRender: () => 200, 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/todo-list/todo-list/default-todo-item/todo-item-view/index.css: -------------------------------------------------------------------------------- 1 | .listItem { 2 | display: flex; 3 | justify-content: space-between; 4 | } 5 | 6 | .todoItem .todoName { 7 | display: inline-block; 8 | text-transform: capitalize; 9 | } 10 | .todoItem .todoTag { 11 | margin-left: 10px; 12 | } 13 | -------------------------------------------------------------------------------- /examples/todo-list/todo-list/default-todo-item/todo-item-view/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Checkbox, Tooltip, Tag, List, Button } from 'antd'; 3 | import type { DefaultToDoItem } from '../todo-item'; 4 | import { useInject, useObserve, getOrigin } from 'mana-observable'; 5 | import type { CheckboxChangeEvent } from 'antd/lib/checkbox'; 6 | import { ToDoManager } from '../../manager'; 7 | import './index.css'; 8 | 9 | export const ToDoItemView: React.FC<{ todo: DefaultToDoItem }> = props => { 10 | const todo = useObserve(props.todo); 11 | const manager = useInject(ToDoManager); 12 | return ( 13 | 16 | 19 | , 20 | ]} 21 | className="listItem" 22 | > 23 |
24 | 25 | todo.toggle(e.target.value)} 29 | /> 30 | 31 | 32 | 33 | {todo.completed ? '✅' : '-'} 34 | 35 | 36 |
{todo.completed ? {todo.name} : todo.name}
37 |
38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /examples/todo-list/todo-list/default-todo-item/todo-item.ts: -------------------------------------------------------------------------------- 1 | import { singleton, inject } from 'mana-syringe'; 2 | import { prop } from 'mana-observable'; 3 | import { v4 } from 'uuid'; 4 | import type { ToDoItem } from '../todo-item-registry'; 5 | 6 | export const ToDoName = Symbol('ToDoName'); 7 | export const defaultToDoName = '默认'; 8 | 9 | @singleton() 10 | export class DefaultToDoItem implements ToDoItem { 11 | public id: string = v4(); 12 | name: string; 13 | @prop() 14 | public completed: boolean = false; 15 | constructor(@inject(ToDoName) name: string) { 16 | this.name = name; 17 | } 18 | public toggle(value?: boolean): void { 19 | if (value === undefined) { 20 | this.completed = !this.completed; 21 | } else { 22 | this.completed = value; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/todo-list/todo-list/manager.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-parameter-properties */ 2 | import { singleton, inject } from 'mana-syringe'; 3 | import { prop } from 'mana-observable'; 4 | import type { ToDoItem, ToDoItemProvider } from './todo-item-registry'; 5 | import { ToDoItemRegistry } from './todo-item-registry'; 6 | 7 | @singleton() 8 | export class ToDoManager { 9 | @prop() 10 | test: boolean | undefined = undefined; 11 | 12 | @prop() 13 | collection: ToDoItem[] = []; 14 | 15 | @prop() 16 | count: number = 0; 17 | 18 | // get count (): number { 19 | // return this.collection.length; 20 | // } 21 | 22 | constructor(@inject(ToDoItemRegistry) protected todoRegistry: ToDoItemRegistry) {} 23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 24 | public add(type: string, name: string, ...args: any[]): void { 25 | const provider = this.todoRegistry.getToDoItemProvider(type); 26 | if (!provider) { 27 | console.log(`没有找到${type}类型的ToDoItem注册信息,无法创建该类型的数据`); 28 | return; 29 | } 30 | const newToDo = provider.provide(name, ...args); 31 | this.collection.push(newToDo); 32 | this.count = this.collection.length; 33 | } 34 | 35 | public remove(item: ToDoItem): void { 36 | this.collection = this.collection.filter(todo => todo !== item); 37 | this.count = this.collection.length; 38 | } 39 | public getRender(item: ToDoItem): React.FC<{ todo: ToDoItem }> { 40 | return this.todoRegistry.getRender(item); 41 | } 42 | 43 | public getProviders(): ToDoItemProvider[] { 44 | return Array.from(this.todoRegistry.toDoItemProviderItems.values()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /examples/todo-list/todo-list/module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Contribution } from 'mana-syringe'; 2 | import { defaultToDoName, ToDoName, DefaultToDoItem } from './default-todo-item/todo-item'; 3 | import { ToDoManager } from './manager'; 4 | import { ToDoItemContribution, ToDoItemRegistry } from './todo-item-registry'; 5 | import { 6 | DefaultToDoFactory, 7 | DefaultToDoItemContribution, 8 | } from './default-todo-item/default-todo-item-contribution'; 9 | 10 | export const ToDoModule = Module(register => { 11 | register(ToDoName, { useValue: defaultToDoName }); 12 | 13 | // 注册扩展点 14 | Contribution.register(register, ToDoItemContribution); 15 | 16 | // 扩展 application 17 | register(ToDoItemRegistry); 18 | 19 | register(DefaultToDoFactory, { 20 | useFactory: context => (name: string) => { 21 | const child = context.container.createChild(); 22 | child.register(ToDoName, { useValue: name }); 23 | child.register(DefaultToDoItem); 24 | return child.get(DefaultToDoItem); 25 | }, 26 | }); 27 | 28 | register(DefaultToDoItemContribution); 29 | 30 | register(ToDoManager); 31 | }); 32 | -------------------------------------------------------------------------------- /examples/todo-list/todo-list/todo-item-registry.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-syntax */ 2 | /* eslint-disable @typescript-eslint/no-parameter-properties */ 3 | /* eslint-disable @typescript-eslint/no-redeclare */ 4 | import { singleton, contrib, Contribution, Syringe } from 'mana-syringe'; 5 | import type * as React from 'react'; 6 | import { ApplicationContribution } from '../application/application'; 7 | import { Disposable } from 'mana-common'; 8 | 9 | export interface ToDoItem { 10 | id: string; 11 | name: string; 12 | completed: boolean; 13 | toggle: (value?: boolean) => void; 14 | } 15 | 16 | export const ToDoItemFactory = Symbol('ToDoItemFactory'); 17 | export interface ToDoItemFactory { 18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 | (name: string, ...args: any[]): ToDoItem; 20 | } 21 | export const ToDoItemContribution = Syringe.defineToken('ToDoItemContribution'); 22 | export interface ToDoItemContribution { 23 | registerTodoItems: (registry: ToDoItemRegistry) => void; 24 | } 25 | export interface ToDoItemProvider { 26 | readonly type: string; 27 | readonly title: string; 28 | provide: ToDoItemFactory; 29 | render: React.FC<{ todo: ToDoItem }>; 30 | canRender: (item: ToDoItem) => number; 31 | } 32 | @singleton({ contrib: ApplicationContribution }) 33 | export class ToDoItemRegistry implements ApplicationContribution { 34 | constructor( 35 | @contrib(ToDoItemContribution) 36 | protected readonly contributionProvider: Contribution.Provider, 37 | ) {} 38 | protected providers: Map = new Map(); 39 | 40 | async onStart(): Promise { 41 | const contributions = this.contributionProvider.getContributions(); 42 | for (const contribution of contributions) { 43 | contribution.registerTodoItems(this); 44 | } 45 | } 46 | 47 | registerItem(item: ToDoItemProvider): Disposable { 48 | const { type } = item; 49 | this.providers.set(type, item); 50 | return Disposable.create(() => this.providers.delete(type)); 51 | } 52 | 53 | get toDoItemProviderItems(): Map { 54 | return this.providers; 55 | } 56 | getToDoItemProvider(type: string): ToDoItemProvider | undefined { 57 | return this.providers.get(type); 58 | } 59 | getRender(item: ToDoItem): React.FC<{ todo: ToDoItem }> { 60 | const providers = Array.from(this.providers.values()); 61 | const sortted = providers.sort((p, p2) => p2.canRender(item) - p.canRender(item)); 62 | return sortted[0].render; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /examples/todo-list/todo-list/todo-view/count.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useInject } from 'mana-observable'; 3 | import { ToDoManager } from '../manager'; 4 | 5 | export const ToDoCount: React.FC = () => { 6 | const manager = useInject(ToDoManager); 7 | return
count: {manager.count}
; 8 | }; 9 | -------------------------------------------------------------------------------- /examples/todo-list/todo-list/todo-view/index.css: -------------------------------------------------------------------------------- 1 | .todo { 2 | margin: 0 40px; 3 | text-align: left; 4 | } 5 | -------------------------------------------------------------------------------- /examples/todo-list/todo-list/todo-view/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useInject } from 'mana-observable'; 3 | import { Card } from 'antd'; 4 | import { ToDoManager } from '../manager'; 5 | import { TodoAdd } from './todo-add'; 6 | import { ToDoCount } from './count'; 7 | import './index.css'; 8 | 9 | export const ToDo: React.FC = () => { 10 | const manager = useInject(ToDoManager); 11 | return ( 12 |
13 | 14 | 15 | 16 | {manager.collection.map(todo => { 17 | const ItemView = manager.getRender(todo); 18 | return ; 19 | })} 20 | 21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /examples/todo-list/todo-list/todo-view/todo-add.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Form, Input, Select, message, Button } from 'antd'; 3 | import { ToDoManager } from '../manager'; 4 | import { useInject } from 'mana-observable'; 5 | import type { ValidateErrorEntity } from 'rc-field-form/lib/interface'; 6 | 7 | export const TodoAdd = () => { 8 | const manager = useInject(ToDoManager); 9 | const [form] = Form.useForm(); 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | const handleSubmit = (todo: any): void => { 12 | form.resetFields(['name']); 13 | manager.add(todo.type, todo.name); 14 | }; 15 | const handleFailed = (entity: ValidateErrorEntity) => { 16 | message.error(entity.errorFields.toString()); 17 | }; 18 | const selectAfter = ( 19 | 30 | 37 | 38 | ); 39 | return ( 40 |
47 | 57 | 64 | 65 | 66 | 69 | 70 |
71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "mana-common/*": ["../packages/mana-common/*"], 7 | "mana-observable/*": ["../packages/mana-observable/*"], 8 | "mana-syringe/*": ["../packages/mana-syringe/*"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { defaults } = require('jest-config'); 2 | 3 | module.exports = { 4 | moduleFileExtensions: [...defaults.moduleFileExtensions, 'ts', 'tsx'], 5 | verbose: true, 6 | preset: 'ts-jest/presets/default-esm', 7 | transform: { 8 | '^.+\\.tsx?$': 'ts-jest', 9 | }, 10 | testRegex: '(/__test__/.*|(\\.|/)(test|spec))\\.tsx?$', 11 | collectCoverageFrom: [ 12 | '**/packages/**/*.{ts,tsx}', 13 | '!**/node_modules/**', 14 | '!**/vendor/**', 15 | '!**/lib/**', 16 | '!**/dist/**', 17 | '!**/es/**', 18 | '!**/examples/**', 19 | ], 20 | coveragePathIgnorePatterns: ['/dist/', '/node_modules/', '.umi'], 21 | globals: { 22 | 'ts-jest': { 23 | tsconfig: 'tsconfig.jest.json', 24 | useESM: true, 25 | }, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.3.2", 3 | "npmClient": "yarn", 4 | "useWorkspaces": true, 5 | "changelog": { 6 | "labels": { 7 | "pr(enhancement)": ":rocket: Enhancement", 8 | "pr(bug)": ":bug: Bug Fix", 9 | "pr(documentation)": ":book: Documentation", 10 | "pr(dependency)": ":deciduous_tree: Dependency", 11 | "pr(chore)": ":turtle: Chore" 12 | }, 13 | "repo": "umijs/mana", 14 | "cacheDir": ".changelog" 15 | }, 16 | "command": { 17 | "run": { 18 | "stream": true 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "mana", 4 | "version": "0.1.0", 5 | "description": "Mana", 6 | "repository": "git@github.com:umijs/mana.git", 7 | "workspaces": [ 8 | "packages/*", 9 | "dev-packages/*", 10 | "examples/*" 11 | ], 12 | "scripts": { 13 | "bootstrap": "lerna bootstrap", 14 | "prepare": "lerna run build && lerna run lint", 15 | "site": "dumi dev", 16 | "site:build": "dumi build", 17 | "changelog": "lerna-changelog", 18 | "clean": "yarn build:clean && yarn lint:clean && yarn test:clean && lerna run clean", 19 | "clean:all": "yarn clean && lerna clean -y", 20 | "build": "lerna run build", 21 | "build:clean": "rimraf .umi", 22 | "lint": "lerna run lint", 23 | "lint:clean": "rimraf .eslintcache", 24 | "test": "jest --coverage", 25 | "test:clean": "rimraf coverage", 26 | "publish:latest": "lerna publish", 27 | "publish:next": "lerna publish --canary=next --dist-tag=next", 28 | "postpublish": "manarun-check-pub" 29 | }, 30 | "devDependencies": { 31 | "@types/assert": "^1.5.6", 32 | "@types/jest": "^26.0.23", 33 | "@types/node": "^14.17.3", 34 | "@types/react-test-renderer": "^17.0.1", 35 | "@types/temp": "^0.8.34", 36 | "@types/uuid": "^8.3.0", 37 | "@typescript-eslint/eslint-plugin": "^4.9.1", 38 | "@typescript-eslint/eslint-plugin-tslint": "^4.9.1", 39 | "@typescript-eslint/parser": "^4.9.1", 40 | "@umijs/fabric": "^2.3.1", 41 | "antd": "^4.16.6", 42 | "concurrently": "^5.3.0", 43 | "dumi": "^1.1.18", 44 | "dumi-theme-default": "^1.1.1", 45 | "eslint": "^7.15.0", 46 | "eslint-plugin-deprecation": "^1.2.0", 47 | "eslint-plugin-import": "^2.22.1", 48 | "eslint-plugin-jest": "^25.7.0", 49 | "eslint-plugin-no-null": "^1.0.2", 50 | "father": "^2.28.0", 51 | "husky": "^5.1.3", 52 | "jest": "^27.0.6", 53 | "lerna": "^3.6.0", 54 | "lerna-changelog": "^2.2.0", 55 | "lint-staged": "^10.5.3", 56 | "prettier": "^2.2.1", 57 | "react-test-renderer": "^17.0.2", 58 | "rimraf": "^3.0.2", 59 | "temp": "^0.9.4", 60 | "ts-jest": "^27.0.4", 61 | "ts-node": "^10.1.0", 62 | "tslib": "^2.3.1", 63 | "tslint": "^6.1.3", 64 | "typescript": "^4.4.4", 65 | "uuid": "^8.3.2", 66 | "yorkie": "^2.0.0" 67 | }, 68 | "gitHooks": { 69 | "pre-commit": "lint-staged" 70 | }, 71 | "lint-staged": { 72 | "*.{js,jsx,less,md,json}": [ 73 | "prettier --write", 74 | "git add" 75 | ], 76 | "*.ts?(x)": [ 77 | "prettier --parser=typescript --write", 78 | "git add" 79 | ] 80 | }, 81 | "dependencies": { 82 | "reflect-metadata": "^0.1.13" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /packages/mana-common/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ['../../.eslintrc.js'], 4 | parserOptions: { 5 | tsconfigRootDir: __dirname, 6 | project: 'tsconfig.json', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/mana-common/.fatherrc.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | esm: 'babel', 3 | cjs: 'babel', 4 | target: 'node', 5 | nodeVersion: 10, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/mana-common/README.md: -------------------------------------------------------------------------------- 1 | # mana-common 2 | 3 | Common utils for mana. 4 | 5 | [![NPM version](https://img.shields.io/npm/v/mana-common.svg?style=flat)](https://npmjs.org/package/mana-common) [![NPM downloads](https://img.shields.io/npm/dm/mana-common.svg?style=flat)](https://npmjs.org/package/mana-common) 6 | 7 | ## 安装 8 | 9 | ```bash 10 | npm i mana-common --save 11 | ``` 12 | 13 | ## 用法 14 | 15 | ### 可销毁对象 Disposable 16 | 17 | 可销毁对象的定义,这里的销毁除有时候也代表撤销 18 | 19 | ```typescript 20 | interface Disposable { 21 | /** 22 | * Dispose this object. 23 | */ 24 | dispose: () => void; 25 | } 26 | ``` 27 | 28 | 提供了一些工具方法 29 | 30 | ```typescript 31 | namespace Disposable { 32 | function is(arg: any): arg is Disposable; // 类型断言,判断是否为 Disposable 对象 33 | function create(func: () => void): Disposable; // 将方法创建为 Disposable 对象 34 | const NONE: Disposable; // 空的 Disposable 对象 35 | } 36 | ``` 37 | 38 | #### 可销毁集合 DisposableCollection 39 | 40 | 可毁对象的集合 41 | 42 | ```typescript 43 | constructor(...toDispose: Disposable[]) // 基于可销毁对象的列表创建 44 | push(disposable: Disposable): Disposable // 加入一个可销毁对象 45 | pushAll(disposable: Disposable[]): Disposable[] // 加入多个可销毁对象 46 | get onDispose(): Event // 监听集合的销毁事件 47 | ``` 48 | 49 | - 可销毁对象集合本身就是可销毁的,其销毁动作可以销毁几个内的所有对象。 50 | - 可销毁对象的添加动作会返回一个可销毁对象,其销毁动作会撤销这些对象对集合的添加。 51 | - 可销毁对象销毁事件的监听,也会返回一个可销毁对象,其销毁动作会撤销监听动作。 52 | 53 | 事件定义,通过 Emitter 定义和发起事件,通过 Event 订阅事件,一个常见的使用方式如下 54 | 55 | ### 事件订阅 Emitter Event 56 | 57 | ```typescript 58 | class EventEmitter { 59 | protected emitter = new Emitter(); 60 | get onEventEmit(): Event { 61 | return emitter.event; 62 | } 63 | emit() { 64 | emitter.fire(); 65 | } 66 | } 67 | const emitter = new EventEmitter(); 68 | const disposable = emitter.onEventEmit(() => {}); 69 | ``` 70 | 71 | 可以方便的设置 Event 的 listener 限制 72 | 73 | ```typescript 74 | emitter.onEventEmit.maxListeners = 10; // 默认为 0,即没有限制 75 | ``` 76 | 77 | 可以遍历事件的订阅者, 返回 false 以中止遍历 78 | 79 | ```typescript 80 | emitter.sequence(listener => { 81 | return true; 82 | }); 83 | ``` 84 | 85 | ### 延迟取值 Deferred 86 | 87 | 作为 promise 的生成工具使用, 其定义非常简单,完整定义如下 88 | 89 | ```typescript 90 | export class Deferred { 91 | public resolve: (value: T | PromiseLike) => void; 92 | public reject: (err?: any) => void; 93 | public readonly promise: Promise; 94 | 95 | constructor() { 96 | this.promise = new Promise((resolve, reject) => { 97 | this.resolve = resolve; 98 | this.reject = reject; 99 | }); 100 | } 101 | } 102 | ``` 103 | 104 | ### 中止器 Cancellation 105 | 106 | 作为可中止操作的句柄使用,实际上中止器的作用并不是在函数的执行过程中中止执行,而是提供一个标志来判断是否被中止过,函数本身是会被调用完的. 107 | 108 | - 执行结束后返回 109 | 110 | ```typescript 111 | const source = new CancellationTokenSource(); 112 | const wait = async (cb: () => Promise, token: CancellationToken): Promise => { 113 | await cb(); 114 | if (token.isCancellationRequested) { 115 | return false; 116 | } 117 | return true; 118 | }; 119 | wait(() => { 120 | // do something 121 | }, source.token); 122 | source.cancel(); 123 | ``` 124 | 125 | - 中止事立即返回 126 | 127 | ```typescript 128 | const source = new CancellationTokenSource(); 129 | // 中止时返回 130 | const wait = async (cb: () => Promise, token: CancellationToken): Promise => { 131 | const waitDeferred = new Deferred(); 132 | token.onCancellationRequested(() => { 133 | waitDeferred.resolve(false); 134 | }); 135 | cb().then(() => { 136 | waitDeferred.resolve(true); 137 | }); 138 | return waitDeferred.promise; 139 | }; 140 | wait(() => { 141 | // do something 142 | }, source.token); 143 | source.cancel(); 144 | ``` 145 | 146 | ### 重试 retry 147 | 148 | 对于同一操作在出错的情况下多次重试, 可设置重试间隔和重试次数上限 149 | 150 | ```typescript 151 | async function retry(task: () => Promise, delay: number, retries: number): Promise; 152 | ``` 153 | 154 | ### 超时 timeout 155 | 156 | 在程序中设置一个可取消的延迟 157 | 158 | ```typescript 159 | function timeout(ms: number, token = CancellationToken.None): Promise; 160 | ``` 161 | -------------------------------------------------------------------------------- /packages/mana-common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mana-common", 3 | "description": "Common utils for mana", 4 | "keywords": [ 5 | "mana", 6 | "common" 7 | ], 8 | "version": "0.3.2", 9 | "typings": "lib/index.d.ts", 10 | "module": "es/index.js", 11 | "main": "lib/index.js", 12 | "license": "MIT", 13 | "files": [ 14 | "package.json", 15 | "README.md", 16 | "dist", 17 | "es", 18 | "lib", 19 | "src" 20 | ], 21 | "scripts": { 22 | "prepare": "yarn run clean && yarn run build", 23 | "lint": "manarun lint", 24 | "clean": "manarun clean", 25 | "build": "manarun build", 26 | "watch": "manarun watch" 27 | }, 28 | "sideEffects": false 29 | } 30 | -------------------------------------------------------------------------------- /packages/mana-common/src/cancellation.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { 3 | CancellationToken, 4 | CancellationTokenSource, 5 | cancelled, 6 | checkCancelled, 7 | isCancelled, 8 | } from './'; 9 | import { Deferred } from './'; 10 | 11 | describe('Cancellation', () => { 12 | const waitTime = (number = 100) => { 13 | const waitDeferred = new Deferred(); 14 | setTimeout(() => { 15 | waitDeferred.resolve(); 16 | }, number); 17 | return waitDeferred.promise; 18 | }; 19 | it('#cancel', done => { 20 | let cancel = false; 21 | const source = new CancellationTokenSource(); 22 | source.token.onCancellationRequested(() => { 23 | cancel = true; 24 | }); 25 | const waitDeferred = new Deferred(); 26 | const wait = async (cb: () => Promise, token: CancellationToken): Promise => { 27 | await cb(); 28 | if (token.isCancellationRequested) { 29 | return false; 30 | } 31 | return true; 32 | }; 33 | const result = wait(() => { 34 | setTimeout(() => { 35 | waitDeferred.resolve(); 36 | }, 100); 37 | return waitDeferred.promise; 38 | }, source.token); 39 | setTimeout(() => { 40 | source.cancel(); 41 | }, 10); 42 | setTimeout(() => { 43 | assert(result); 44 | assert(cancel); 45 | done(); 46 | }, 200); 47 | }); 48 | 49 | it('#cancel return after done', async () => { 50 | let did = false; 51 | const source = new CancellationTokenSource(); 52 | const wait = async (cb: () => Promise, token: CancellationToken): Promise => { 53 | await cb(); 54 | if (token.isCancellationRequested) { 55 | return false; 56 | } 57 | return true; 58 | }; 59 | wait(async () => { 60 | await waitTime(); 61 | did = true; 62 | }, source.token).then(result => { 63 | assert(!result); 64 | assert(did); 65 | }); 66 | source.cancel(); 67 | }); 68 | 69 | it('#cancel return when cancel', async () => { 70 | let did = false; 71 | const source = new CancellationTokenSource(); 72 | const wait = async (cb: () => Promise, token: CancellationToken): Promise => { 73 | const waitDeferred = new Deferred(); 74 | token.onCancellationRequested(() => { 75 | waitDeferred.resolve(false); 76 | }); 77 | cb().then(() => { 78 | waitDeferred.resolve(true); 79 | }); 80 | return waitDeferred.promise; 81 | }; 82 | wait(async () => { 83 | await waitTime(); 84 | did = true; 85 | }, source.token).then(result => { 86 | assert(!result); 87 | assert(!did); 88 | }); 89 | source.cancel(); 90 | }); 91 | 92 | it('#CancellationToken Cancelled', () => { 93 | const source = new CancellationTokenSource(); 94 | source.cancel(); 95 | assert(source.token === CancellationToken.Cancelled); 96 | }); 97 | 98 | it('#Cancellation dispose', () => { 99 | const source = new CancellationTokenSource(); 100 | source.dispose(); 101 | assert(source.token === CancellationToken.Cancelled); 102 | }); 103 | 104 | it('#is cancelled', () => { 105 | assert(isCancelled(cancelled())); 106 | }); 107 | 108 | it('#check cancelled', () => { 109 | const source = new CancellationTokenSource(); 110 | source.cancel(); 111 | try { 112 | checkCancelled(source.token); 113 | } catch (ex: any) { 114 | assert(isCancelled(ex)); 115 | } 116 | }); 117 | 118 | it('#short cut', done => { 119 | const source = new CancellationTokenSource(); 120 | let onRequest = false; 121 | source.token.onCancellationRequested(() => { 122 | onRequest = true; 123 | }); 124 | source.cancel(); 125 | source.cancel(); 126 | let result = true; 127 | assert('maxListeners' in source.token.onCancellationRequested); 128 | assert(onRequest); 129 | const disposable = source.token.onCancellationRequested(() => { 130 | result = false; 131 | }); 132 | disposable.dispose(); 133 | source.cancel(); 134 | assert(result); 135 | done(); 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /packages/mana-common/src/cancellation.ts: -------------------------------------------------------------------------------- 1 | /*--------------------------------------------------------------------------------------------- 2 | * Copyright (c) Microsoft Corporation and others. All rights reserved. 3 | * Licensed under the MIT License. See https://github.com/Microsoft/vscode/blob/master/LICENSE.txt for license information. 4 | *--------------------------------------------------------------------------------------------*/ 5 | 6 | /* eslint-disable @typescript-eslint/no-explicit-any */ 7 | 8 | import { Event, Emitter } from './event'; 9 | 10 | export type CancellationToken = { 11 | readonly isCancellationRequested: boolean; 12 | /* 13 | * An event emitted when cancellation is requested 14 | * @event 15 | */ 16 | readonly onCancellationRequested: Event; 17 | }; 18 | 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | const shortcutEvent: Event = Object.freeze( 21 | Object.assign( 22 | function (callback: any, context?: any): any { 23 | const handle = setTimeout(callback.bind(context), 0); 24 | return { 25 | dispose(): void { 26 | clearTimeout(handle); 27 | }, 28 | }; 29 | }, 30 | { maxListeners: 0 }, 31 | ), 32 | ); 33 | 34 | export namespace CancellationToken { 35 | export const None: CancellationToken = Object.freeze({ 36 | isCancellationRequested: false, 37 | onCancellationRequested: Event.None, 38 | }); 39 | 40 | export const Cancelled: CancellationToken = Object.freeze({ 41 | isCancellationRequested: true, 42 | onCancellationRequested: shortcutEvent, 43 | }); 44 | } 45 | 46 | class MutableToken implements CancellationToken { 47 | private _isCancelled: boolean = false; 48 | private _emitter: Emitter | undefined; 49 | 50 | public cancel(): void { 51 | if (!this._isCancelled) { 52 | this._isCancelled = true; 53 | if (this._emitter) { 54 | this._emitter.fire(undefined); 55 | this._emitter = undefined; 56 | } 57 | } 58 | } 59 | 60 | get isCancellationRequested(): boolean { 61 | return this._isCancelled; 62 | } 63 | 64 | get onCancellationRequested(): Event { 65 | if (this._isCancelled) { 66 | return shortcutEvent; 67 | } 68 | if (!this._emitter) { 69 | this._emitter = new Emitter(); 70 | } 71 | return this._emitter.event; 72 | } 73 | } 74 | 75 | export class CancellationTokenSource { 76 | private _token?: CancellationToken; 77 | 78 | get token(): CancellationToken { 79 | if (!this._token) { 80 | // be lazy and create the token only when 81 | // actually needed 82 | this._token = new MutableToken(); 83 | } 84 | return this._token; 85 | } 86 | 87 | cancel(): void { 88 | if (!this._token) { 89 | // save an object by returning the default 90 | // cancelled token when cancellation happens 91 | // before someone asks for the token 92 | this._token = CancellationToken.Cancelled; 93 | } else if (this._token !== CancellationToken.Cancelled) { 94 | (this._token).cancel(); 95 | } 96 | } 97 | 98 | dispose(): void { 99 | this.cancel(); 100 | } 101 | } 102 | 103 | const cancelledMessage = 'Cancelled'; 104 | 105 | export function cancelled(): Error { 106 | return new Error(cancelledMessage); 107 | } 108 | 109 | export function isCancelled(err: Error | undefined): boolean { 110 | return !!err && err.message === cancelledMessage; 111 | } 112 | 113 | export function checkCancelled(token?: CancellationToken): void { 114 | if (!!token && token.isCancellationRequested) { 115 | throw cancelled(); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /packages/mana-common/src/deferred.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | 4 | const noop = () => {}; 5 | 6 | export class Deferred { 7 | public resolve: (value: T | PromiseLike) => void = noop; 8 | public reject: (err?: any) => void = noop; 9 | public readonly promise: Promise; 10 | 11 | constructor() { 12 | this.promise = new Promise((resolve, reject) => { 13 | this.resolve = resolve; 14 | this.reject = reject; 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/mana-common/src/disposable-collection.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import type { Event } from './event'; 4 | import { Emitter } from './event'; 5 | import { Disposable } from './disposable'; 6 | 7 | export class DisposableCollection implements Disposable { 8 | protected readonly disposables: Disposable[] = []; 9 | protected readonly onDisposeEmitter = new Emitter(); 10 | private disposingElements = false; 11 | 12 | constructor(...toDispose: Disposable[]) { 13 | this.pushAll(toDispose); 14 | } 15 | 16 | get disposed(): boolean { 17 | return this.disposables.length === 0; 18 | } 19 | 20 | get onDispose(): Event { 21 | return this.onDisposeEmitter.event; 22 | } 23 | 24 | protected checkDisposed(): void { 25 | if (this.disposed && !this.disposingElements) { 26 | this.onDisposeEmitter.fire(undefined); 27 | this.onDisposeEmitter.dispose(); 28 | } 29 | } 30 | 31 | dispose(): void { 32 | if (this.disposed || this.disposingElements) { 33 | return; 34 | } 35 | this.disposingElements = true; 36 | while (!this.disposed) { 37 | try { 38 | this.disposables.pop()!.dispose(); 39 | } catch (e) { 40 | console.error(e); 41 | } 42 | } 43 | this.disposingElements = false; 44 | this.checkDisposed(); 45 | } 46 | 47 | push(disposable: Disposable): Disposable { 48 | const { disposables } = this; 49 | disposables.push(disposable); 50 | const originalDispose = disposable.dispose.bind(disposable); 51 | const toRemove = Disposable.create(() => { 52 | const index = disposables.indexOf(disposable); 53 | if (index !== -1) { 54 | disposables.splice(index, 1); 55 | } 56 | this.checkDisposed(); 57 | }); 58 | disposable.dispose = () => { 59 | toRemove.dispose(); 60 | originalDispose(); 61 | }; 62 | return toRemove; 63 | } 64 | 65 | pushAll(disposables: Disposable[]): Disposable[] { 66 | return disposables.map(disposable => this.push(disposable)); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/mana-common/src/disposable.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { Disposable } from './'; 3 | import { DisposableCollection } from './'; 4 | 5 | console.warn = () => {}; 6 | console.log = () => {}; 7 | console.error = () => {}; 8 | describe('Disposable', () => { 9 | it('#Disposable is', () => { 10 | assert(Disposable.is({ dispose: () => {} })); 11 | }); 12 | it('#Disposable create', () => { 13 | let disposed = false; 14 | const disposable = Disposable.create(() => { 15 | disposed = true; 16 | }); 17 | disposable.dispose(); 18 | assert(disposed); 19 | }); 20 | 21 | it('#Disposable collection', done => { 22 | let disposeTimes = 0; 23 | let disposed = false; 24 | const collection = new DisposableCollection( 25 | Disposable.create(() => { 26 | disposeTimes += 1; 27 | }), 28 | ); 29 | collection.push( 30 | Disposable.create(() => { 31 | disposeTimes += 1; 32 | }), 33 | ); 34 | collection.pushAll([ 35 | Disposable.create(() => { 36 | disposeTimes += 1; 37 | }), 38 | ]); 39 | collection.onDispose(() => { 40 | disposed = true; 41 | assert(disposed); 42 | done(); 43 | }); 44 | collection.dispose(); 45 | assert(disposeTimes === 3); 46 | }); 47 | 48 | it('#Disposable collection error disposable', () => { 49 | let disposeTimes = 0; 50 | const collection = new DisposableCollection( 51 | Disposable.create(() => { 52 | disposeTimes += 1; 53 | throw new Error('Disposable collection error disposable'); 54 | }), 55 | ); 56 | collection.dispose(); 57 | collection.dispose(); 58 | assert(disposeTimes === 1); 59 | }); 60 | 61 | it('#Disposable collection dispose add', () => { 62 | let disposed = false; 63 | const collection = new DisposableCollection(); 64 | 65 | const disposable = collection.push( 66 | Disposable.create(() => { 67 | disposed = true; 68 | throw new Error('Disposable collection error disposable'); 69 | }), 70 | ); 71 | disposable.dispose(); 72 | collection.dispose(); 73 | assert(!disposed); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /packages/mana-common/src/disposable.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export type Disposable = { 3 | /** 4 | * Dispose this object. 5 | */ 6 | dispose: () => void; 7 | }; 8 | export namespace Disposable { 9 | export function is(arg: any): arg is Disposable { 10 | return ( 11 | !!arg && typeof arg === 'object' && 'dispose' in arg && typeof arg.dispose === 'function' 12 | ); 13 | } 14 | export function create(func: () => void): Disposable { 15 | return { 16 | dispose: func, 17 | }; 18 | } 19 | 20 | export const NONE = create(() => {}); 21 | } 22 | -------------------------------------------------------------------------------- /packages/mana-common/src/event.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import assert from 'assert'; 4 | import type { Disposable } from '.'; 5 | import { Event, Emitter } from './'; 6 | 7 | console.warn = () => {}; 8 | console.log = () => {}; 9 | console.error = () => {}; 10 | 11 | describe('Event', () => { 12 | it('#event map transformer', done => { 13 | const emitterNumber = new Emitter(); 14 | const eventString = Event.map(emitterNumber.event, e => e.toString()); 15 | eventString(e => { 16 | assert(typeof e === 'string'); 17 | done(); 18 | }); 19 | emitterNumber.fire(1); 20 | }); 21 | 22 | it('#event basic', done => { 23 | let test = false; 24 | const emitter = new Emitter(); 25 | emitter.event(() => { 26 | test = true; 27 | assert(test); 28 | done(); 29 | }); 30 | emitter.fire(); 31 | }); 32 | 33 | it('#listener dispose', done => { 34 | let test = true; 35 | const emitter = new Emitter(); 36 | const disposable = emitter.event(() => { 37 | test = false; 38 | }); 39 | emitter.event(() => { 40 | assert(test); 41 | done(); 42 | }); 43 | disposable.dispose(); 44 | disposable.dispose(); 45 | emitter.fire(); 46 | }); 47 | 48 | it('#emitter dispose', done => { 49 | let test = true; 50 | const emitter = new Emitter(); 51 | emitter.event(() => { 52 | test = false; 53 | }); 54 | emitter.event(() => { 55 | test = false; 56 | }); 57 | emitter.dispose(); 58 | emitter.fire(); 59 | setTimeout(() => { 60 | assert(test); 61 | done(); 62 | }, 100); 63 | }); 64 | 65 | it('#emitter option', done => { 66 | let firstListenerAdded = false; 67 | let lastListenerRemoved = false; 68 | const emitter = new Emitter({ 69 | onFirstListenerAdd: () => { 70 | firstListenerAdded = true; 71 | }, 72 | onLastListenerRemove: () => { 73 | lastListenerRemoved = true; 74 | }, 75 | }); 76 | const disposable = emitter.event(() => {}); 77 | disposable.dispose(); 78 | setTimeout(() => { 79 | assert(firstListenerAdded); 80 | assert(lastListenerRemoved); 81 | done(); 82 | }, 50); 83 | }); 84 | 85 | it('#event option', done => { 86 | let test = true; 87 | const context = {}; 88 | const disposables: Disposable[] = []; 89 | const emitter = new Emitter(); 90 | disposables.push(emitter.event(() => (test = true), context)); 91 | disposables.push(emitter.event(() => (test = true), context)); 92 | disposables.forEach(toDispose => toDispose.dispose()); 93 | setTimeout(() => { 94 | assert(test); 95 | done(); 96 | }, 50); 97 | }); 98 | 99 | it('#emitter sequence listeners', async () => { 100 | const emitter = new Emitter(); 101 | emitter.event(() => true); 102 | let sequenceTimes1 = 0; 103 | await emitter.sequence(listener => { 104 | sequenceTimes1 += 1; 105 | return !!listener(); 106 | }); 107 | assert(sequenceTimes1 === 1); // all listener 108 | sequenceTimes1 = 0; 109 | emitter.event(() => true); 110 | emitter.event(() => false); 111 | emitter.event(() => true); 112 | await emitter.sequence(listener => { 113 | sequenceTimes1 += 1; 114 | return !!listener(); 115 | }); 116 | assert(sequenceTimes1 === 3); // stop at index 2 117 | }); 118 | 119 | it('#emitter dispose', () => { 120 | const emitter = new Emitter(); 121 | const disposable = emitter.event(() => true); 122 | try { 123 | (emitter as any)._callbacks._callbacks = undefined; 124 | disposable.dispose(); 125 | assert(true); 126 | } catch (ex) { 127 | assert(false); 128 | } 129 | }); 130 | 131 | it('#listener add remove with error context', () => { 132 | const emitter = new Emitter(); 133 | const disposable = emitter.event(() => true, {}); 134 | try { 135 | emitter.callbacks.callbacks[0][1] = {}; 136 | disposable.dispose(); 137 | } catch (ex: any) { 138 | console.log(ex); 139 | assert(ex.message === 'You should remove it with the same context you add it'); 140 | } 141 | }); 142 | 143 | it('#emitter sequence listeners', async () => { 144 | const emitter = new Emitter(); 145 | emitter.event(() => true); 146 | let sequenceTimes1 = 0; 147 | (emitter as any)._callbacks._callbacks = undefined; 148 | await emitter.sequence(listener => { 149 | sequenceTimes1 += 1; 150 | return !!listener(); 151 | }); 152 | assert(sequenceTimes1 === 0); // all listener 153 | }); 154 | 155 | it('#error listener', done => { 156 | let times = 0; 157 | const emitter = new Emitter(); 158 | emitter.event(() => { 159 | times += 1; 160 | throw new Error('error listener'); 161 | }); 162 | emitter.event(() => { 163 | times += 1; 164 | assert(times === 2); // all listener 165 | done(); 166 | }); 167 | emitter.fire(); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /packages/mana-common/src/event.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | 4 | import { Disposable } from './disposable'; 5 | import type { MaybePromise } from './types'; 6 | 7 | export type Event = { 8 | /** 9 | * 10 | * @param listener The listener function will be call when the event happens. 11 | * @param context The 'this' which will be used when calling the event listener. 12 | * @return a disposable to remove the listener again. 13 | */ 14 | (listener: (e: T) => any, context?: any): Disposable; 15 | }; 16 | 17 | export namespace Event { 18 | export const None: Event = () => Disposable.NONE; 19 | 20 | export function map(event: Event, mapFunc: (i: I) => O): Event { 21 | return (listener: (e: O) => any, context?: any) => 22 | event(i => listener.call(context, mapFunc(i)), undefined); 23 | } 24 | } 25 | 26 | type Callback = (...args: any[]) => any; 27 | class CallbackList implements Iterable { 28 | constructor(protected mono: boolean = false) {} 29 | private _callbacks: [Function, any][] | undefined; 30 | 31 | get callbacks() { 32 | if (!this._callbacks) { 33 | this._callbacks = []; 34 | } 35 | return this._callbacks; 36 | } 37 | 38 | get length(): number { 39 | return this.callbacks.length; 40 | } 41 | 42 | public add(callback: Function, context: any = undefined): void { 43 | this.callbacks.push([callback, context]); 44 | } 45 | 46 | public remove(callback: Function, context: any = undefined): void { 47 | if (this.isEmpty()) return; 48 | let foundCallbackWithDifferentContext = false; 49 | for (let i = 0; i < this.length; i += 1) { 50 | if (this.callbacks[i][0] === callback) { 51 | if (this.callbacks[i][1] === context) { 52 | // remove when callback & context match 53 | this.callbacks.splice(i, 1); 54 | return; 55 | } 56 | foundCallbackWithDifferentContext = true; 57 | } 58 | } 59 | if (foundCallbackWithDifferentContext) { 60 | throw new Error('You should remove it with the same context you add it'); 61 | } 62 | } 63 | 64 | public [Symbol.iterator]() { 65 | if (this.isEmpty()) { 66 | return [][Symbol.iterator](); 67 | } 68 | const callbacks = this.callbacks.slice(0); 69 | 70 | return callbacks 71 | .map( 72 | callback => 73 | (...args: any[]) => 74 | callback[0].apply(callback[1], args), 75 | ) 76 | [Symbol.iterator](); 77 | } 78 | 79 | public invoke(...args: any[]): any[] { 80 | const ret: any[] = []; 81 | for (const callback of this) { 82 | try { 83 | ret.push(callback(...args)); 84 | } catch (e) { 85 | console.error(e); 86 | } 87 | } 88 | return ret; 89 | } 90 | 91 | public isEmpty(): boolean { 92 | return this.callbacks.length === 0; 93 | } 94 | 95 | public dispose(): void { 96 | this._callbacks = undefined; 97 | } 98 | } 99 | 100 | export type EmitterOptions = { 101 | onFirstListenerAdd?: Function; 102 | onLastListenerRemove?: Function; 103 | }; 104 | 105 | export class Emitter { 106 | private static noop = (): void => {}; 107 | protected _event?: Event; 108 | protected _callbacks: CallbackList | undefined; 109 | public disposed = false; 110 | 111 | constructor(protected options: EmitterOptions = {}) {} 112 | 113 | get callbacks() { 114 | if (!this._callbacks) { 115 | this._callbacks = new CallbackList(); 116 | } 117 | return this._callbacks; 118 | } 119 | 120 | /** 121 | * For the public to allow to subscribe 122 | * to events from this Emitter 123 | */ 124 | get event(): Event { 125 | if (!this._event) { 126 | this._event = (listener: (e: T) => any, thisArgs?: any) => { 127 | if (this.options.onFirstListenerAdd && this.callbacks.isEmpty()) { 128 | this.options.onFirstListenerAdd(this); 129 | } 130 | this.callbacks.add(listener, thisArgs); 131 | const result: Disposable = { 132 | dispose: () => { 133 | result.dispose = Emitter.noop; 134 | if (!this.disposed) { 135 | this.callbacks.remove(listener, thisArgs); 136 | result.dispose = Emitter.noop; 137 | if (this.options.onLastListenerRemove && this.callbacks!.isEmpty()) { 138 | this.options.onLastListenerRemove(this); 139 | } 140 | } 141 | }, 142 | }; 143 | return result; 144 | }; 145 | } 146 | return this._event; 147 | } 148 | 149 | fire(event: T): any { 150 | if (!this._callbacks) { 151 | return; 152 | } 153 | this.callbacks.invoke(event); 154 | } 155 | 156 | /** 157 | * Process each listener one by one. 158 | * Return `false` to stop iterating over the listeners, `true` to continue. 159 | */ 160 | async sequence(processor: (listener: (e: T) => any) => MaybePromise): Promise { 161 | for (const listener of this.callbacks) { 162 | // eslint-disable-next-line no-await-in-loop 163 | const result = await processor(listener); 164 | if (!result) { 165 | break; 166 | } 167 | } 168 | } 169 | 170 | dispose(): void { 171 | if (this._callbacks) { 172 | this._callbacks.dispose(); 173 | this._callbacks = undefined; 174 | } 175 | this.disposed = true; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /packages/mana-common/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './event'; 2 | export * from './cancellation'; 3 | export * from './disposable'; 4 | export * from './disposable-collection'; 5 | export * from './deferred'; 6 | export * from './promise-util'; 7 | export * from './priority'; 8 | export * from './types-util'; 9 | export * from './types'; 10 | export * from './objects'; 11 | -------------------------------------------------------------------------------- /packages/mana-common/src/objects.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { getPropertyDescriptor, isPlainObject } from './objects'; 3 | 4 | describe('Objects', () => { 5 | it('#getPropertyDescriptor', () => { 6 | class A { 7 | get a() { 8 | return 0; 9 | } 10 | } 11 | class B extends A { 12 | b = 1; 13 | } 14 | class C extends B { 15 | get a(): number { 16 | return 2; 17 | } 18 | } 19 | const objA = new A(); 20 | const objB = new B(); 21 | const objC = new C(); 22 | let desc = getPropertyDescriptor(objA, 'a'); 23 | assert(desc?.get && desc.get() === 0); 24 | desc = getPropertyDescriptor(objB, 'a'); 25 | assert(desc?.get && desc.get() === 0); 26 | desc = getPropertyDescriptor(objB, 'b'); 27 | assert(!desc?.get); 28 | desc = getPropertyDescriptor(objC, 'a'); 29 | assert(desc?.get && desc.get() === 2); 30 | }); 31 | it('#isPlainObject', () => { 32 | class A {} 33 | class B extends A {} 34 | const objA = new A(); 35 | const objB = new B(); 36 | assert(isPlainObject({})); 37 | assert(isPlainObject(Object.getPrototypeOf({}))); 38 | assert(!isPlainObject(global)); 39 | assert(!isPlainObject(objA)); 40 | assert(!isPlainObject(objB)); 41 | assert(!isPlainObject(null)); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /packages/mana-common/src/objects.ts: -------------------------------------------------------------------------------- 1 | export function getPropertyDescriptor(o: any, propertyName: PropertyKey) { 2 | let proto: any = o; 3 | let descriptor: PropertyDescriptor | undefined = undefined; 4 | while (proto && !descriptor) { 5 | descriptor = Object.getOwnPropertyDescriptor(proto, propertyName); 6 | proto = Object.getPrototypeOf(proto); 7 | } 8 | return descriptor; 9 | } 10 | 11 | export function isPlainObject(obj: any): boolean { 12 | if ( 13 | typeof obj !== 'object' || 14 | obj === null || 15 | // window/navigator/Global 16 | Object.prototype.toString.call(obj) !== '[object Object]' 17 | ) 18 | return false; 19 | const proto = Object.getPrototypeOf(obj); 20 | if (proto === null) { 21 | return true; 22 | } 23 | const ctor = proto.hasOwnProperty('constructor') && proto.constructor; 24 | return typeof ctor == 'function' && ctor instanceof ctor && ctor.toString() === Object.toString(); 25 | } 26 | -------------------------------------------------------------------------------- /packages/mana-common/src/priority.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { Priority } from './'; 3 | 4 | describe('Priority', () => { 5 | it('#priority sort', () => { 6 | const items = [ 7 | { id: 'Priority.DEFAULT', value: Priority.DEFAULT }, 8 | { id: 'Priority.IDLE', value: Priority.IDLE }, 9 | { id: 'Priority.PRIOR', value: Priority.PRIOR }, 10 | ]; 11 | const sorted = Priority.sort(items, item => item.value); 12 | assert(sorted[0].priority === Priority.PRIOR); 13 | assert(sorted[sorted.length - 1].priority === Priority.DEFAULT); 14 | assert(sorted.length === 2); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/mana-common/src/priority.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeArray } from './types'; 2 | 3 | export enum Priority { 4 | PRIOR = 1000, 5 | DEFAULT = 100, 6 | IDLE = -1, 7 | } 8 | 9 | export namespace Priority { 10 | export type PriorityObject = { 11 | readonly priority: number; 12 | readonly value: T; 13 | }; 14 | export type GetPriority = (value: T) => number; 15 | export function toPriorityObject(rawValue: T, getPriority: GetPriority): PriorityObject; 16 | export function toPriorityObject( 17 | rawValue: T[], 18 | getPriority: GetPriority, 19 | ): PriorityObject[]; 20 | export function toPriorityObject( 21 | rawValue: MaybeArray, 22 | getPriority: GetPriority, 23 | ): MaybeArray> { 24 | if (rawValue instanceof Array) { 25 | return rawValue.map(v => toPriorityObject(v, getPriority)); 26 | } 27 | const value = rawValue; 28 | const priority = getPriority(value); 29 | return { priority, value }; 30 | } 31 | 32 | export function sort(values: T[], getPriority: GetPriority): PriorityObject[] { 33 | const prioritizeable = toPriorityObject(values, getPriority); 34 | return prioritizeable.filter(isValid).sort(compare); 35 | } 36 | export function isValid(p: PriorityObject): boolean { 37 | return p.priority > 0; 38 | } 39 | export function compare(p: PriorityObject, p2: PriorityObject): number { 40 | return p2.priority - p.priority; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/mana-common/src/promise-util.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { timeout, retry } from './'; 3 | import { CancellationTokenSource } from './'; 4 | import { Deferred } from './'; 5 | 6 | describe('Promise util', () => { 7 | it('#timeout', async () => { 8 | const source = new CancellationTokenSource(); 9 | const res = await timeout(50, source.token); 10 | assert(res === undefined); 11 | }); 12 | it('#timeout cancel', done => { 13 | const source = new CancellationTokenSource(); 14 | timeout(100, source.token).catch(ex => { 15 | assert(!!ex); 16 | done(); 17 | }); 18 | setTimeout(() => { 19 | source.cancel(); 20 | }, 50); 21 | }); 22 | it('#restry', async () => { 23 | let errorTime = 0; 24 | let retryTimes = 0; 25 | const task = async () => { 26 | const deferred = new Deferred(); 27 | setTimeout(() => { 28 | if (errorTime < 2) { 29 | deferred.reject(); 30 | errorTime += 1; 31 | retryTimes += 1; 32 | } else { 33 | retryTimes += 1; 34 | deferred.resolve(); 35 | } 36 | }, 50); 37 | return deferred.promise; 38 | }; 39 | await retry(task, 50, 3); 40 | assert(retryTimes === 3); 41 | }); 42 | it('#restry last error', async () => { 43 | let errorTime = 0; 44 | const start = new Date().getTime(); 45 | const task = async () => { 46 | const deferred = new Deferred(); 47 | setTimeout(() => { 48 | if (errorTime < 4) { 49 | deferred.reject(); 50 | errorTime += 1; 51 | } else { 52 | deferred.resolve(); 53 | } 54 | }, 50); 55 | return deferred.promise; 56 | }; 57 | try { 58 | await retry(task, 50, 3); 59 | } catch (ex) { 60 | const end = new Date().getTime(); 61 | assert(end - start > 300); 62 | assert(end - start < 400); 63 | } 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /packages/mana-common/src/promise-util.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import { CancellationToken, cancelled } from './cancellation'; 4 | import { Deferred } from './deferred'; 5 | 6 | /** 7 | * @returns resolves after a specified number of milliseconds 8 | * @throws cancelled if a given token is cancelled before a specified number of milliseconds 9 | */ 10 | export function timeout(ms: number, token = CancellationToken.None): Promise { 11 | const deferred = new Deferred(); 12 | const handle = setTimeout(() => deferred.resolve(), ms); 13 | token.onCancellationRequested(() => { 14 | clearTimeout(handle); 15 | deferred.reject(cancelled()); 16 | }); 17 | return deferred.promise; 18 | } 19 | 20 | export async function retry( 21 | task: () => Promise, 22 | delay: number, 23 | retries: number, 24 | ): Promise { 25 | let lastError: Error | undefined; 26 | 27 | for (let i = 0; i < retries; i += 1) { 28 | try { 29 | // eslint-disable-next-line no-await-in-loop 30 | return await task(); 31 | } catch (error: any) { 32 | lastError = error; 33 | // eslint-disable-next-line no-await-in-loop 34 | await timeout(delay); 35 | } 36 | } 37 | // eslint-disable-next-line @typescript-eslint/no-throw-literal 38 | throw lastError; 39 | } 40 | -------------------------------------------------------------------------------- /packages/mana-common/src/types-util.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { toArray } from '.'; 3 | 4 | describe('Types util', () => { 5 | it('#toArray array', async () => { 6 | const arr = [1]; 7 | assert(toArray(arr) === arr); 8 | }); 9 | it('#toArray obj', async () => { 10 | const obj = 1; 11 | const arr = toArray(obj); 12 | assert(arr.includes(obj)); 13 | assert(arr.length === 1); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/mana-common/src/types-util.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeArray } from './types'; 2 | 3 | export function toArray(v: MaybeArray): T[] { 4 | if (Array.isArray(v)) { 5 | return v; 6 | } 7 | return [v]; 8 | } 9 | -------------------------------------------------------------------------------- /packages/mana-common/src/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | export type Mutable = { -readonly [P in keyof T]: T[P] }; 4 | export type RecursivePartial = { 5 | [P in keyof T]?: T[P] extends (infer I)[] ? RecursivePartial[] : RecursivePartial; 6 | }; 7 | export type MaybeArray = T | T[]; 8 | export type MaybePromise = T | PromiseLike; 9 | export type Newable = new (...args: any[]) => T; 10 | export type Abstract = { 11 | prototype: T; 12 | }; 13 | -------------------------------------------------------------------------------- /packages/mana-common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src" 5 | }, 6 | "include": ["src/**/*"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/mana-observable/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ['../../.eslintrc.js'], 4 | parserOptions: { 5 | tsconfigRootDir: __dirname, 6 | project: 'tsconfig.json', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/mana-observable/.fatherrc.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | esm: 'babel', 3 | cjs: 'babel', 4 | }; 5 | -------------------------------------------------------------------------------- /packages/mana-observable/README.md: -------------------------------------------------------------------------------- 1 | # mana-observable 2 | 3 | Simple class property decorator that makes the class properties observable and can be easily used with React. 4 | 5 | [![NPM version](https://img.shields.io/npm/v/mana-observable.svg?style=flat)](https://npmjs.org/package/mana-observable) [![NPM downloads](https://img.shields.io/npm/dm/mana-observable.svg?style=flat)](https://npmjs.org/package/mana-observable) 6 | 7 | ## 功能 8 | 9 | 1. 对基于类的数据管理系统提供变更追踪机制。可独立使用, 10 | 2. 配合依赖注入容器使用,为 React 组件提供管理数据。 11 | 12 | ## 安装 13 | 14 | ```bash 15 | npm i mana-observable --save 16 | ``` 17 | 18 | ## 用法 19 | 20 | ### 数据 API 21 | 22 | 数据 api 可以独立使用,不需要在依赖注入的环境中 23 | 24 | #### @prop 25 | 26 | 将类中的基础类型属性转化为可追踪属性。对于基础类型 (当前支持数值、字符、布尔) 以及一般的引用类型,当值或引用发生变化时,触发数据的变更。对于部分内置类型 (数组、map、plainObject) 除引用变更外,其管理的内部值或引用发生变化时,也会触发数据变更。 27 | 28 | ```typescript 29 | class Ninja { 30 | @prop name: string = ''; 31 | } 32 | ``` 33 | 34 | #### watch 35 | 36 | 监听一个带有可追踪属性对象的属性变更 37 | 38 | ```typescript 39 | // 监听某个可追踪属性的变化 40 | watch(ninja, 'name', (obj, prop) => { 41 | // ninja name changed 42 | }); 43 | // 监听对象所有可追踪属性的变化 44 | watch(ninja, (obj, prop) => { 45 | // any observable property on ninja changed 46 | }); 47 | ``` 48 | 49 | #### observable 50 | 51 | 经过 babel 处理的类可能会在实例上定义属性,而属性装饰器作用在原型上,因而无法进行属性转换。所以这里引入新的 API 解决相关的问题,后续希望提供免除该 API 调用的使用方式。 52 | 53 | ```typescript 54 | class Ninja { 55 | @prop name: string = ''; 56 | } 57 | ``` 58 | 59 | ### React API 60 | 61 | 当前仅提供基于 React hooks 的 API。 62 | 63 | #### useInject 64 | 65 | 在 react 组件中,如果希望使用依赖注入容器中带有可追踪属性的对象,那么可以使用 useInject 来获取他们。 66 | 67 | ```typescript 68 | @singleton() 69 | class Ninja { 70 | @prop name: string = ''; 71 | } 72 | 73 | container.register(Ninja); 74 | 75 | export function NinjaRender() { 76 | const ninja = useInject(Ninja); 77 | return
{ninja.name}
; 78 | } 79 | ``` 80 | 81 | #### useObserve 82 | 83 | 为了精确控制每个 React 组件只因为自己访问的可追踪属性变更而进行 update,也为了在子组件内提供 hook 的创建时机,通过除 useInject 外方式获取到的对象,应当通过 useObserve 进行处理,重置其作用组件更新的范围。 84 | 85 | ```typescript 86 | export function NinjaName(props: { ninja: Ninja }) { 87 | const ninja = useObserve(props.ninja); 88 | return
{ninja.name}
; 89 | } 90 | ``` 91 | 92 | ### getOrigin 93 | 94 | 在 React 组件中,我们访问的组件并不是原始实例,而是实例的代理,如果在 API 调用等环节需要获取原始对象(例如作为参数传递给其他 API),需要通过调用 getOrigin 方法获得。 95 | -------------------------------------------------------------------------------- /packages/mana-observable/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mana-observable", 3 | "keywords": [ 4 | "mana", 5 | "observable", 6 | "class", 7 | "react" 8 | ], 9 | "description": "Simple class property decorator that makes the class properties observable and can be easily used with React", 10 | "version": "0.3.2", 11 | "typings": "lib/index.d.ts", 12 | "main": "lib/index.js", 13 | "module": "es/index.js", 14 | "license": "MIT", 15 | "files": [ 16 | "package.json", 17 | "README.md", 18 | "dist", 19 | "es", 20 | "lib", 21 | "src" 22 | ], 23 | "dependencies": { 24 | "mana-common": "^0.3.2", 25 | "mana-syringe": "^0.3.2" 26 | }, 27 | "peerDependencies": { 28 | "react": ">=16.9.0" 29 | }, 30 | "scripts": { 31 | "prepare": "yarn run clean && yarn run build", 32 | "lint": "manarun lint", 33 | "clean": "manarun clean", 34 | "build": "manarun build", 35 | "watch": "manarun watch" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/mana-observable/src/context.spec.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import 'reflect-metadata'; 4 | 5 | import type { ErrorInfo, ReactNode } from 'react'; 6 | import React from 'react'; 7 | import assert from 'assert'; 8 | import { defaultObservableContext, ObservableContext } from './context'; 9 | import { GlobalContainer, inject } from 'mana-syringe'; 10 | import { singleton } from 'mana-syringe'; 11 | import { useInject } from './context'; 12 | import renderer, { act } from 'react-test-renderer'; 13 | import { prop } from './decorator'; 14 | import { useObserve } from './hooks'; 15 | import { getOrigin } from './utils'; 16 | 17 | console.error = () => {}; 18 | 19 | class ErrorBoundary extends React.Component<{ children?: ReactNode }> { 20 | state: { error?: Error; errorInfo?: ErrorInfo } = { 21 | error: undefined, 22 | errorInfo: undefined, 23 | }; 24 | componentDidCatch(error: Error, errorInfo: ErrorInfo) { 25 | this.setState({ error, errorInfo }); 26 | } 27 | render(): React.ReactNode { 28 | if (this.state.error) { 29 | return ( 30 |
31 | {this.state.error && this.state.error.toString()} 32 |
33 | {this.state.errorInfo?.componentStack} 34 |
35 | ); 36 | } 37 | return this.props.children; 38 | } 39 | } 40 | 41 | describe('error context', () => { 42 | it('#without initial', () => { 43 | @singleton() 44 | class FooModel { 45 | @prop() info: number = 1; 46 | } 47 | const ErrorRender = () => { 48 | const foo = useInject(FooModel); 49 | return
{foo.info}
; 50 | }; 51 | const component = renderer.create( 52 | 53 | 54 | , 55 | ); 56 | const json: any = component.toJSON(); 57 | assert( 58 | json.children.find( 59 | (item: any) => 60 | typeof item === 'string' && item.includes('please check the context settings'), 61 | ), 62 | ); 63 | }); 64 | }); 65 | 66 | describe('context', () => { 67 | it('#provider', done => { 68 | @singleton() 69 | class Foo { 70 | @prop() info: number = 1; 71 | } 72 | const container = GlobalContainer.createChild(); 73 | container.register(Foo); 74 | const ContextRender = () => { 75 | const foo = useInject(Foo); 76 | return
{foo.info}
; 77 | }; 78 | let component: renderer.ReactTestRenderer; 79 | act(() => { 80 | component = renderer.create( 81 | container }}> 82 | 83 | , 84 | ); 85 | }); 86 | act(() => { 87 | defaultObservableContext.config({ 88 | getContainer: () => GlobalContainer, 89 | }); 90 | const json: any = component.toJSON(); 91 | assert(json && json.children.includes('1')); 92 | done(); 93 | }); 94 | }); 95 | 96 | it('#use inject', done => { 97 | @singleton() 98 | class FooModel { 99 | @prop() info: number = 1; 100 | } 101 | GlobalContainer.register(FooModel); 102 | const FooRender = () => { 103 | const foo = useInject(FooModel); 104 | return
{foo.info}
; 105 | }; 106 | const FooRender2 = () => { 107 | const foo = useInject(FooModel); 108 | return
{foo.info}
; 109 | }; 110 | let component: renderer.ReactTestRenderer; 111 | act(() => { 112 | component = renderer.create( 113 | <> 114 | 115 | 116 | , 117 | ); 118 | }); 119 | act(() => { 120 | const json: any = component.toJSON(); 121 | assert(json && json.find((item: any) => item.children.includes('1'))); 122 | done(); 123 | }); 124 | }); 125 | 126 | it('#useInject effects ', done => { 127 | @singleton() 128 | class Bar { 129 | @prop() info: number = 0; 130 | } 131 | @singleton() 132 | class Foo { 133 | @prop() info: number = 0; 134 | @inject(Bar) bar!: Bar; 135 | } 136 | GlobalContainer.register(Foo); 137 | GlobalContainer.register(Bar); 138 | 139 | let fooTimes = 0; 140 | let barTimes = 0; 141 | let barInfoTimes = 0; 142 | let dispatchTimes = 0; 143 | 144 | const FooRender = () => { 145 | const foo = useInject(Foo); 146 | const [, dispatch] = React.useReducer<(prevState: any, action: any) => any>(() => {}, {}); 147 | React.useEffect(() => { 148 | fooTimes += 1; 149 | }, [foo]); 150 | React.useEffect(() => { 151 | barTimes += 1; 152 | }, [foo.bar]); 153 | React.useEffect(() => { 154 | barInfoTimes += 1; 155 | }, [foo.bar.info]); 156 | React.useEffect(() => { 157 | dispatchTimes += 1; 158 | }, [dispatch]); 159 | return ( 160 |
161 | {foo.info} {foo.bar.info} 162 |
163 | ); 164 | }; 165 | let component: renderer.ReactTestRenderer; 166 | act(() => { 167 | component = renderer.create( 168 | <> 169 | 170 | , 171 | ); 172 | 173 | const json = component.toJSON(); 174 | assert(json === null); 175 | }); 176 | act(() => { 177 | GlobalContainer.get(Foo).info = 1; 178 | GlobalContainer.get(Foo).bar.info = 1; 179 | }); 180 | act(() => { 181 | const json = component.toJSON(); 182 | assert(!(json instanceof Array) && json && json.children?.includes('1')); 183 | assert(fooTimes === 1); 184 | assert(barTimes === 1); 185 | assert(barInfoTimes === 2); 186 | assert(dispatchTimes === 1); 187 | done(); 188 | }); 189 | }); 190 | it('#use observe', done => { 191 | class Bar { 192 | @prop() info: number = 1; 193 | } 194 | @singleton() 195 | class FooModel { 196 | @prop() bar?: Bar; 197 | set() { 198 | this.bar = new Bar(); 199 | } 200 | } 201 | GlobalContainer.register(FooModel); 202 | const FooRender = () => { 203 | const foo = useInject(FooModel); 204 | const bar = useObserve(foo.bar); 205 | return
{bar && bar.info}
; 206 | }; 207 | let component: renderer.ReactTestRenderer; 208 | const fooModel = GlobalContainer.get(FooModel); 209 | act(() => { 210 | component = renderer.create( 211 | <> 212 | 213 | , 214 | ); 215 | 216 | const json = component.toJSON(); 217 | assert(json === null); 218 | }); 219 | act(() => { 220 | fooModel.set(); 221 | }); 222 | act(() => { 223 | const json = component.toJSON(); 224 | assert(!(json instanceof Array) && json && json.children?.find(item => item === '1')); 225 | done(); 226 | }); 227 | }); 228 | 229 | it('#use inject onChange', done => { 230 | @singleton() 231 | class FooModel { 232 | @prop() info: number = 0; 233 | @prop() info1: number = 1; 234 | getInfo(): number { 235 | return this.info; 236 | } 237 | } 238 | GlobalContainer.register(FooModel); 239 | const fooInstance = GlobalContainer.get(FooModel); 240 | const FooRender = () => { 241 | const foo = useInject(FooModel); 242 | React.useEffect(() => { 243 | assert(fooInstance !== foo); 244 | assert(fooInstance === getOrigin(foo)); 245 | foo.info += 1; 246 | foo.info1 += 1; 247 | act(() => { 248 | foo.info1 += 1; 249 | }); 250 | }, [foo]); 251 | return ( 252 |
253 | {foo.info} 254 | {foo.info1} 255 | {foo.getInfo()} 256 |
257 | ); 258 | }; 259 | let component: renderer.ReactTestRenderer; 260 | act(() => { 261 | component = renderer.create(); 262 | }); 263 | setTimeout(() => { 264 | const json: any = component.toJSON(); 265 | assert(json && json.children.includes('3')); 266 | assert(json && json.children.includes('1')); 267 | done(); 268 | }, 100); 269 | }); 270 | 271 | it('#computed property with this', done => { 272 | @singleton() 273 | class FooModel { 274 | @prop() info: number[] = []; 275 | get length(): number { 276 | return this.info.length; 277 | } 278 | } 279 | GlobalContainer.register(FooModel); 280 | const fooInstance = GlobalContainer.get(FooModel); 281 | const FooRender = () => { 282 | const foo = useInject(FooModel); 283 | return
{foo.length}
; 284 | }; 285 | let component: renderer.ReactTestRenderer; 286 | act(() => { 287 | component = renderer.create(); 288 | }); 289 | act(() => { 290 | fooInstance.info.push(1); 291 | }); 292 | setTimeout(() => { 293 | const json: any = component.toJSON(); 294 | assert(json && json.children.includes('1')); 295 | done(); 296 | }, 100); 297 | }); 298 | 299 | it('#indirect inject', done => { 300 | @singleton() 301 | class Foo { 302 | @prop() info: number = 0; 303 | } 304 | @singleton() 305 | class Bar { 306 | constructor(@inject(Foo) public foo: Foo) {} 307 | } 308 | GlobalContainer.register(Foo); 309 | GlobalContainer.register(Bar); 310 | const FooRender = () => { 311 | const bar = useInject(Bar); 312 | return
{bar.foo.info}
; 313 | }; 314 | let component: renderer.ReactTestRenderer; 315 | act(() => { 316 | component = renderer.create( 317 | <> 318 | 319 | , 320 | ); 321 | }); 322 | const fooInstance = GlobalContainer.get(Foo); 323 | act(() => { 324 | fooInstance.info = 1; 325 | }); 326 | setTimeout(() => { 327 | const json: any = component.toJSON(); 328 | assert(json && json.children.includes('1')); 329 | done(); 330 | }, 100); 331 | }); 332 | }); 333 | -------------------------------------------------------------------------------- /packages/mana-observable/src/context.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { Observable } from './core'; 3 | import { useObserve } from './hooks'; 4 | 5 | export type ContextConfig = { 6 | context: T; 7 | }; 8 | 9 | export const defaultContainerContext: Observable.ContainerContext = { 10 | getContainer: () => undefined, 11 | }; 12 | export class ObservableContextImpl implements Observable.ContainerContext { 13 | protected context: Observable.ContainerContext = defaultContainerContext; 14 | config(info: Observable.ContainerContext): void { 15 | this.context = info; 16 | } 17 | getContainer = (): Observable.Container | undefined => this.context.getContainer(); 18 | } 19 | 20 | export const defaultObservableContext = new ObservableContextImpl(); 21 | 22 | export const ObservableContext = 23 | React.createContext(defaultObservableContext); 24 | 25 | export function useInject(identifier: Observable.Token): T { 26 | const { getContainer } = React.useContext(ObservableContext); 27 | const obj = React.useMemo(() => { 28 | const container = getContainer(); 29 | if (!container) { 30 | throw new Error('Can not find container in context, please check the context settings.'); 31 | } 32 | return container.get(identifier); 33 | }, [getContainer, identifier]); 34 | return useObserve(obj); 35 | } 36 | -------------------------------------------------------------------------------- /packages/mana-observable/src/core.ts: -------------------------------------------------------------------------------- 1 | import type { Abstract, Newable } from 'mana-common'; 2 | 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | export namespace ObservableSymbol { 5 | export const Reactor = Symbol('Reactor'); 6 | export const Tracker = Symbol('Tracker'); 7 | export const Notifier = Symbol('Notifier'); 8 | export const Observable = Symbol('Observable'); 9 | export const ObservableProperties = Symbol('ObservableProperties'); 10 | export const Self = Symbol('Self'); 11 | } 12 | 13 | export type Notify = (target?: any, prop?: any) => void; 14 | 15 | export namespace Observable { 16 | export type Container = { 17 | get: (identifier: Token) => T; 18 | createChild: () => Container; 19 | }; 20 | export type Token = string | symbol | Newable | Abstract; 21 | export type ContainerContext = { 22 | getContainer: () => Container | undefined; 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /packages/mana-observable/src/decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { ObservableProperties } from './utils'; 3 | import { prop } from './decorator'; 4 | 5 | describe('decorator', () => { 6 | it('#prop', () => { 7 | class Foo { 8 | @prop() 9 | name?: string; 10 | } 11 | 12 | class FooExt extends Foo { 13 | @prop() 14 | info?: string; 15 | } 16 | class FooExtExt extends FooExt {} 17 | const foo = new Foo(); 18 | const properties = ObservableProperties.getOwn(Foo); 19 | assert(properties?.length === 1 && properties.includes('name')); 20 | const extProperties = ObservableProperties.getOwn(FooExt); 21 | assert(extProperties?.length === 2 && extProperties.includes('info')); 22 | const extextProperties = ObservableProperties.get(FooExtExt); 23 | assert(extextProperties?.length === 2); 24 | const instanceProperties = ObservableProperties.find(foo); 25 | assert(instanceProperties?.length === 1 && instanceProperties.includes('name')); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/mana-observable/src/decorator.ts: -------------------------------------------------------------------------------- 1 | import { ObservableProperties } from './utils'; 2 | 3 | /** 4 | * Define observable property 5 | */ 6 | export function prop() { 7 | return (target: Record, propertyKey: string) => { 8 | ObservableProperties.add(target.constructor, propertyKey); 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /packages/mana-observable/src/hooks.spec.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import 'reflect-metadata'; 4 | import React, { useEffect } from 'react'; 5 | import assert from 'assert'; 6 | import { useObserve } from './hooks'; 7 | import renderer, { act } from 'react-test-renderer'; 8 | import { observable, useObservableState } from '.'; 9 | import { prop } from './decorator'; 10 | 11 | describe('use', () => { 12 | it('#useObserve basic ', done => { 13 | class Foo { 14 | @prop() info: number = 0; 15 | } 16 | const SINGLETON_FOO = new Foo(); 17 | const FooRender = () => { 18 | const foo = useObserve(SINGLETON_FOO); 19 | return
{foo && foo.info}
; 20 | }; 21 | let component: renderer.ReactTestRenderer; 22 | act(() => { 23 | component = renderer.create( 24 | <> 25 | 26 | , 27 | ); 28 | 29 | const json = component.toJSON(); 30 | assert(json === null); 31 | }); 32 | act(() => { 33 | SINGLETON_FOO.info = 1; 34 | }); 35 | act(() => { 36 | const json = component.toJSON(); 37 | assert(!(json instanceof Array) && json && json.children?.find(item => item === '1')); 38 | done(); 39 | }); 40 | }); 41 | 42 | it('#useObserve effects ', done => { 43 | class Foo { 44 | @prop() info: number = 0; 45 | } 46 | const SINGLETON_FOO = new Foo(); 47 | let times = 0; 48 | let infoTimes = 0; 49 | const FooRender = () => { 50 | const foo = useObserve(SINGLETON_FOO); 51 | React.useEffect(() => { 52 | times += 1; 53 | }, [foo]); 54 | React.useEffect(() => { 55 | infoTimes += 1; 56 | }, [foo.info]); 57 | return
{foo && foo.info}
; 58 | }; 59 | let component: renderer.ReactTestRenderer; 60 | act(() => { 61 | component = renderer.create( 62 | <> 63 | 64 | , 65 | ); 66 | 67 | const json = component.toJSON(); 68 | assert(json === null); 69 | }); 70 | act(() => { 71 | SINGLETON_FOO.info = 1; 72 | }); 73 | act(() => { 74 | const json = component.toJSON(); 75 | assert(!(json instanceof Array) && json && json.children?.find(item => item === '1')); 76 | assert(times === 1); 77 | assert(infoTimes === 2); 78 | done(); 79 | }); 80 | }); 81 | it('#useObserve array', done => { 82 | class Foo { 83 | @prop() list: number[] = []; 84 | } 85 | const foo = new Foo(); 86 | let renderTimes = 0; 87 | const FooRender = () => { 88 | const f = useObserve(foo); 89 | renderTimes += 1; 90 | return
{f.list.length}
; 91 | }; 92 | let component: renderer.ReactTestRenderer; 93 | act(() => { 94 | component = renderer.create( 95 | <> 96 | 97 | , 98 | ); 99 | const json = component.toJSON(); 100 | assert(json === null); 101 | }); 102 | act(() => { 103 | for (let index = 0; index < 100; index++) { 104 | foo.list.push(index); 105 | } 106 | }); 107 | act(() => { 108 | assert(renderTimes < 25); 109 | done(); 110 | }); 111 | }); 112 | it('#useObserve deep array ', done => { 113 | class Foo { 114 | @prop() info = ''; 115 | } 116 | class Bar { 117 | @prop() list: Foo[] = []; 118 | } 119 | const SINGLETON_BAR = new Bar(); 120 | const foo = new Foo(); 121 | SINGLETON_BAR.list.push(foo); 122 | const FooRender = () => { 123 | const bar = useObserve(SINGLETON_BAR); 124 | return
{bar.list.filter(item => item.info.length > 0).length}
; 125 | }; 126 | let component: renderer.ReactTestRenderer; 127 | act(() => { 128 | component = renderer.create( 129 | <> 130 | 131 | , 132 | ); 133 | 134 | const json = component.toJSON(); 135 | assert(json === null); 136 | }); 137 | act(() => { 138 | foo.info = 'a'; 139 | }); 140 | act(() => { 141 | const json = component.toJSON(); 142 | assert(!(json instanceof Array) && json && json.children?.find(item => item === '1')); 143 | done(); 144 | }); 145 | }); 146 | 147 | it('#useObserve reactable array', done => { 148 | const ARR: any[] = observable([]); 149 | const Render = () => { 150 | const arr = useObserve(ARR); 151 | const arr1 = useObservableState([]); 152 | useEffect(() => { 153 | arr.push('effect'); 154 | arr1.push('effect1'); 155 | }, [arr, arr1]); 156 | return ( 157 |
158 | {JSON.stringify(arr)} 159 | {arr1[0]} 160 | {arr.length} 161 |
162 | ); 163 | }; 164 | let component: renderer.ReactTestRenderer; 165 | act(() => { 166 | component = renderer.create( 167 | <> 168 | 169 | , 170 | ); 171 | const json = component.toJSON(); 172 | assert(json === null); 173 | }); 174 | act(() => { 175 | ARR.push('a'); 176 | }); 177 | act(() => { 178 | const json = component.toJSON(); 179 | assert( 180 | !(json instanceof Array) && 181 | json && 182 | json.children?.includes('2') && 183 | json.children?.includes('effect1'), 184 | ); 185 | done(); 186 | }); 187 | }); 188 | 189 | it('#useObserve deep arr', done => { 190 | class Bar { 191 | @prop() name: string = ''; 192 | } 193 | class Foo { 194 | @prop() arr: Bar[] = []; 195 | } 196 | const foo = new Foo(); 197 | const Render = () => { 198 | const trackableFoo = useObserve(foo); 199 | useEffect(() => { 200 | trackableFoo.arr.push(new Bar()); 201 | trackableFoo.arr.push(new Bar()); 202 | }, [trackableFoo]); 203 | 204 | return
{trackableFoo.arr.map(item => item.name)}
; 205 | }; 206 | let component: renderer.ReactTestRenderer; 207 | act(() => { 208 | component = renderer.create( 209 | <> 210 | 211 | , 212 | ); 213 | const json = component.toJSON(); 214 | assert(json === null); 215 | }); 216 | act(() => { 217 | foo.arr[0] && (foo.arr[0].name = 'a'); 218 | }); 219 | act(() => { 220 | const json = component.toJSON(); 221 | assert(!(json instanceof Array) && json && json.children?.includes('a')); 222 | done(); 223 | }); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /packages/mana-observable/src/hooks.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import * as React from 'react'; 3 | import { Tracker } from './tracker'; 4 | import { Observability } from './utils'; 5 | 6 | interface Action { 7 | key: keyof T; 8 | value: any; 9 | } 10 | function isAction(data: Record | undefined): data is Action { 11 | return !!data && data.key !== undefined && data.value !== undefined; 12 | } 13 | const reducer = (state: Partial, part: Action | undefined) => { 14 | if (isAction(part)) { 15 | return { ...state, [part.key]: part.value }; 16 | } 17 | return { ...state }; 18 | }; 19 | 20 | export function useObserve(obj: T): T { 21 | const [, dispatch] = React.useReducer<(prevState: Partial, action: Action) => Partial>( 22 | reducer, 23 | {}, 24 | ); 25 | if (!Observability.trackable(obj)) { 26 | return obj; 27 | } 28 | return Tracker.track(obj, dispatch); 29 | } 30 | 31 | export function useObservableState(initialValue: T): T { 32 | const object = React.useMemo(() => { 33 | return initialValue; 34 | }, []); 35 | return useObserve(object); 36 | } 37 | -------------------------------------------------------------------------------- /packages/mana-observable/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './context'; 2 | export * from './hooks'; 3 | export * from './tracker'; 4 | export * from './watch'; 5 | export * from './notifier'; 6 | export * from './observable'; 7 | export * from './reactivity'; 8 | export * from './decorator'; 9 | export * from './utils'; 10 | export * from './core'; 11 | -------------------------------------------------------------------------------- /packages/mana-observable/src/notifier.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { observable } from './observable'; 3 | import { Notifier } from './notifier'; 4 | import { prop } from './decorator'; 5 | 6 | describe('tarcker', () => { 7 | it('#create tracker', () => { 8 | class Foo { 9 | @prop() name?: string; 10 | } 11 | class Bar { 12 | name?: string; 13 | } 14 | const foo = observable(new Foo()); 15 | const bar = new Bar(); 16 | Notifier.find(foo, 'name'); 17 | assert(Notifier.find(observable([]))); 18 | assert(!!Notifier.find(foo, 'name')); 19 | assert(!Notifier.find(bar, 'name')); 20 | }); 21 | 22 | it('#trigger', () => { 23 | class Foo { 24 | @prop() name?: string; 25 | } 26 | const foo = observable(new Foo()); 27 | let changed = false; 28 | const notifier = Notifier.find(foo, 'name'); 29 | notifier?.onChange(() => { 30 | changed = true; 31 | }); 32 | Notifier.trigger(foo, 'name'); 33 | assert(changed); 34 | }); 35 | it('#dispose tracker', () => { 36 | class Foo { 37 | @prop() name?: string; 38 | } 39 | const foo = observable(new Foo()); 40 | const tracker = Notifier.find(foo, 'name'); 41 | tracker?.dispose(); 42 | const newTracker = Notifier.find(foo, 'name'); 43 | assert(tracker?.disposed && newTracker !== tracker); 44 | }); 45 | it('#tracker notify', done => { 46 | class Foo { 47 | @prop() name?: string; 48 | } 49 | const foo = observable(new Foo()); 50 | const tracker = Notifier.find(foo, 'name'); 51 | tracker?.onChange(() => { 52 | done(); 53 | }); 54 | assert(!!Notifier.find(foo, 'name')); 55 | tracker?.notify(foo, 'name'); 56 | }); 57 | it('#tracker changed', done => { 58 | class Foo { 59 | @prop() name?: string; 60 | } 61 | const foo = observable(new Foo()); 62 | const tracker = Notifier.find(foo, 'name'); 63 | tracker?.onChange(() => { 64 | done(); 65 | }); 66 | assert(!!Notifier.find(foo, 'name')); 67 | tracker?.notify(foo, 'name'); 68 | }); 69 | it('#tracker once', done => { 70 | class Foo { 71 | @prop() name?: string; 72 | } 73 | const foo = observable(new Foo()); 74 | const tracker = Notifier.find(foo, 'name'); 75 | let times = 0; 76 | let once = 0; 77 | tracker?.once(() => { 78 | once += 1; 79 | }); 80 | tracker?.onChange(() => { 81 | times += 1; 82 | if (times == 2) { 83 | assert(once == 1); 84 | done(); 85 | } 86 | }); 87 | assert(!!Notifier.find(foo, 'name')); 88 | tracker?.notify(foo, 'name'); 89 | tracker?.notify(foo, 'name'); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /packages/mana-observable/src/notifier.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import 'reflect-metadata'; 3 | import type { Notify } from './core'; 4 | import { ObservableSymbol } from './core'; 5 | import { Emitter } from 'mana-common'; 6 | import type { Disposable } from 'mana-common'; 7 | import { Observability } from './utils'; 8 | 9 | function setNotifier(tracker: Notifier, obj: Record, property?: string | symbol) { 10 | if (property === undefined) { 11 | Reflect.defineMetadata(ObservableSymbol.Notifier, tracker, obj); 12 | } else { 13 | Reflect.defineMetadata(ObservableSymbol.Notifier, tracker, obj, property); 14 | } 15 | } 16 | 17 | function getNotifier(obj: Record, property?: string | symbol): Notifier | undefined { 18 | if (property === undefined) { 19 | return Reflect.getMetadata(ObservableSymbol.Notifier, obj); 20 | } else { 21 | return Reflect.getMetadata(ObservableSymbol.Notifier, obj, property); 22 | } 23 | } 24 | 25 | export interface Notification { 26 | target: T; 27 | prop?: any; 28 | } 29 | export class Notifier implements Disposable { 30 | protected changedEmitter = new Emitter(); 31 | disposed: boolean = false; 32 | get onChange() { 33 | return this.changedEmitter.event; 34 | } 35 | 36 | dispose() { 37 | this.changedEmitter.dispose(); 38 | this.disposed = true; 39 | } 40 | 41 | once(trigger: Notify): Disposable { 42 | const toDispose = this.onChange(e => { 43 | trigger(e.target, e.prop); 44 | toDispose.dispose(); 45 | }); 46 | return toDispose; 47 | } 48 | 49 | notify(target: any, prop?: any): void { 50 | this.changedEmitter.fire({ target, prop }); 51 | if (prop) { 52 | Notifier.trigger(target); 53 | } 54 | } 55 | 56 | static trigger(target: any, prop?: any): void { 57 | const exist = getNotifier(target, prop); 58 | if (exist) { 59 | exist.notify(target, prop); 60 | } 61 | } 62 | static getOrCreate(target: any, prop?: any): Notifier { 63 | const origin = Observability.getOrigin(target); 64 | const exist = getNotifier(target, prop); 65 | if (!exist || exist.disposed) { 66 | const notifier = new Notifier(); 67 | setNotifier(notifier, origin, prop); 68 | return notifier; 69 | } 70 | return exist; 71 | } 72 | static find(target: any, prop?: any): Notifier | undefined { 73 | if (!Observability.notifiable(target, prop)) { 74 | return undefined; 75 | } 76 | return Notifier.getOrCreate(target, prop); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/mana-observable/src/observable.spec.tsx: -------------------------------------------------------------------------------- 1 | import 'react'; 2 | import assert from 'assert'; 3 | import { observable } from './observable'; 4 | import { Notifier } from './notifier'; 5 | import { Observability, ObservableProperties } from './utils'; 6 | import { prop } from './decorator'; 7 | import { Reactable } from './reactivity'; 8 | 9 | describe('observable', () => { 10 | it('#observable properties', () => { 11 | class Foo { 12 | @prop() name: string = ''; 13 | } 14 | const foo = observable(new Foo()); 15 | const instanceBasic = observable(foo); 16 | const nullInstance = observable(null as any); 17 | assert(!Observability.is(nullInstance)); 18 | assert(Observability.is(instanceBasic)); 19 | assert(Observability.is(instanceBasic, 'name')); 20 | assert(ObservableProperties.get(instanceBasic)?.includes('name')); 21 | }); 22 | it('#extends properties', () => { 23 | class ClassBasic { 24 | @prop() name: string = ''; 25 | name1: string = ''; 26 | name2: string = ''; 27 | } 28 | class ClassBasic1 extends ClassBasic { 29 | @prop() name1: string = ''; 30 | } 31 | class ClassBasic2 extends ClassBasic1 { 32 | name1: string = ''; 33 | @prop() name2: string = ''; 34 | } 35 | const instanceBasic = observable(new ClassBasic()); 36 | const instanceBasic1 = observable(new ClassBasic1()); 37 | const instanceBasic2 = observable(new ClassBasic2()); 38 | assert(ObservableProperties.get(instanceBasic)?.includes('name')); 39 | assert(ObservableProperties.get(instanceBasic)?.length === 1); 40 | assert(ObservableProperties.get(instanceBasic1)?.includes('name')); 41 | assert(ObservableProperties.get(instanceBasic1)?.includes('name1')); 42 | assert(ObservableProperties.get(instanceBasic1)?.length === 2); 43 | assert(ObservableProperties.get(instanceBasic2)?.includes('name')); 44 | assert(ObservableProperties.get(instanceBasic2)?.includes('name1')); 45 | assert(ObservableProperties.get(instanceBasic2)?.includes('name2')); 46 | assert(ObservableProperties.get(instanceBasic2)?.length === 3); 47 | }); 48 | it('#basic usage', () => { 49 | class ClassBasic { 50 | @prop() name: string = ''; 51 | } 52 | const instanceBasic = observable(new ClassBasic()); 53 | let changed = false; 54 | const tracker = Notifier.find(instanceBasic, 'name'); 55 | tracker?.onChange(() => { 56 | changed = true; 57 | }); 58 | instanceBasic.name = 'a'; 59 | instanceBasic.name = 'b'; 60 | assert(instanceBasic.name === 'b'); 61 | assert(changed); 62 | }); 63 | it('#array usage', () => { 64 | class ClassArray { 65 | @prop() list: string[] = []; 66 | } 67 | const instanceArray = observable(new ClassArray()); 68 | instanceArray.list = instanceArray.list; 69 | instanceArray.list = []; 70 | let changed = false; 71 | if (Reactable.is(instanceArray.list)) { 72 | const reactor = Reactable.getReactor(instanceArray.list); 73 | reactor.onChange(() => { 74 | changed = true; 75 | }); 76 | } 77 | const tracker = Notifier.find(instanceArray, 'list'); 78 | tracker?.onChange(() => { 79 | changed = true; 80 | }); 81 | instanceArray.list.push(''); 82 | assert(changed); 83 | instanceArray.list = []; 84 | assert(instanceArray.list.length === 0); 85 | }); 86 | 87 | it('#child class', done => { 88 | class Foo { 89 | @prop() fooName: string = 'foo'; 90 | } 91 | class Bar extends Foo { 92 | @prop() barName?: string; 93 | @prop() barInfo?: string; 94 | } 95 | const bar = observable(new Bar()); 96 | let changed = false; 97 | const tracker = Notifier.find(bar, 'fooName'); 98 | tracker?.onChange(() => { 99 | changed = true; 100 | assert(changed); 101 | done(); 102 | }); 103 | bar.fooName = 'foo name'; 104 | }); 105 | 106 | it('#shared properties', () => { 107 | class Foo { 108 | @prop() list: string[] = []; 109 | } 110 | class Bar { 111 | @prop() list: string[] = []; 112 | } 113 | const foo = observable(new Foo()); 114 | const bar = observable(new Bar()); 115 | foo.list = bar.list; 116 | let changed = false; 117 | const notifier = Notifier.find(bar, 'list'); 118 | notifier?.onChange(() => { 119 | changed = true; 120 | }); 121 | foo.list.push(''); 122 | assert(changed); 123 | }); 124 | 125 | it('#observable reactbale', () => { 126 | const v: any[] = []; 127 | class Foo {} 128 | const foo = new Foo(); 129 | const reactable = observable(v); 130 | const reactable1 = observable(v); 131 | const reactable2 = observable(reactable); 132 | assert(reactable1 === reactable2); 133 | assert(reactable === reactable1); 134 | const observableFoo = observable(foo); 135 | assert(Reactable.is(reactable)); 136 | assert(Observability.is(v)); 137 | assert(observableFoo === foo); 138 | let changed = false; 139 | const notifier = Notifier.find(reactable); 140 | notifier?.onChange(() => { 141 | changed = true; 142 | }); 143 | reactable1.push(''); 144 | assert(changed); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /packages/mana-observable/src/observable.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type { Reactor } from './reactivity'; 3 | import { Reactable } from './reactivity'; 4 | import { InstanceValue, ObservableProperties, Observability } from './utils'; 5 | import { Notifier } from './notifier'; 6 | 7 | // 8 | export function listenReactor( 9 | reactor: Reactor, 10 | onChange: () => void, 11 | target: any, 12 | property?: string, 13 | ) { 14 | const toDispose = Observability.getDisposable(reactor, target, property); 15 | if (toDispose) { 16 | toDispose.dispose(); 17 | } 18 | const disposable = reactor.onChange(() => { 19 | onChange(); 20 | }); 21 | Observability.setDisposable(reactor, disposable, target, property); 22 | } 23 | 24 | // redefine observable properties 25 | export function defineProperty(target: any, property: string, defaultValue?: any) { 26 | /** 27 | * notify reactor when property changed 28 | */ 29 | const onChange = () => { 30 | Notifier.trigger(target, property); 31 | }; 32 | /** 33 | * set observable property value and register onChange listener 34 | * @param value 35 | * @param reactor 36 | */ 37 | const setValue = (value: any, reactor: Reactor | undefined) => { 38 | InstanceValue.set(target, property, value); 39 | if (reactor) { 40 | listenReactor(reactor, onChange, target, property); 41 | } 42 | }; 43 | const initialValue = target[property] === undefined ? defaultValue : target[property]; 44 | setValue(...Reactable.transform(initialValue)); 45 | // property getter 46 | const getter = function getter(this: any): void { 47 | const value = Reflect.getMetadata(property, target); 48 | return value; 49 | }; 50 | // property setter 51 | const setter = function setter(this: any, value: any): void { 52 | const [tValue, reactor] = Reactable.transform(value); 53 | const oldValue = InstanceValue.get(target, property); 54 | if (Reactable.is(oldValue)) { 55 | const toDispose = Observability.getDisposable( 56 | Reactable.getReactor(oldValue), 57 | target, 58 | property, 59 | ); 60 | if (toDispose) { 61 | toDispose.dispose(); 62 | } 63 | } 64 | setValue(tValue, reactor); 65 | if (tValue !== oldValue) { 66 | onChange(); 67 | } 68 | }; 69 | // define property 70 | if (Reflect.deleteProperty(target, property)) { 71 | Reflect.defineProperty(target, property, { 72 | configurable: true, 73 | enumerable: true, 74 | get: getter, 75 | set: setter, 76 | }); 77 | } 78 | // mark observable property 79 | ObservableProperties.add(target, property); 80 | Observability.mark(target, property); 81 | Observability.mark(target); 82 | } 83 | 84 | export function observable>(target: T): T { 85 | if (!Observability.trackable(target)) return target; 86 | const properties = ObservableProperties.find(target); 87 | const origin = Observability.getOrigin(target); 88 | if (!properties) { 89 | if (Reactable.canBeReactable(target)) { 90 | const exsit = Reactable.get(origin); 91 | if (exsit) { 92 | return exsit; 93 | } 94 | const onChange = () => { 95 | Notifier.trigger(origin); 96 | }; 97 | const [reatableValue, reactor] = Reactable.transform(origin); 98 | if (reactor) { 99 | reactor.onChange(() => { 100 | onChange(); 101 | }); 102 | } 103 | Observability.mark(origin); 104 | return reatableValue; 105 | } 106 | return target; 107 | } 108 | properties.forEach(property => defineProperty(origin, property)); 109 | return origin; 110 | } 111 | -------------------------------------------------------------------------------- /packages/mana-observable/src/reactivity.spec.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import 'react'; 3 | import assert from 'assert'; 4 | import { Reactable } from './reactivity'; 5 | import { isPlainObject } from 'mana-common'; 6 | import { Observability } from './utils'; 7 | 8 | describe('reactivity', () => { 9 | it('#can be reactable', () => { 10 | class Foo {} 11 | const a = new Foo(); 12 | assert(!Reactable.canBeReactable(a)); 13 | assert(!Reactable.canBeReactable(null)); 14 | assert(!Reactable.canBeReactable(undefined)); 15 | assert(Reactable.canBeReactable([])); 16 | assert(Reactable.canBeReactable({})); 17 | assert(Reactable.canBeReactable(new Map())); 18 | const [arrValue] = Reactable.transform([]); 19 | assert(Reactable.canBeReactable(arrValue)); 20 | }); 21 | it('#transform base', () => { 22 | const [tValue, reactor] = Reactable.transform(undefined); 23 | assert(tValue === undefined); 24 | assert(reactor === undefined); 25 | const arr = ['a']; 26 | const [arrValue, arrReactor] = Reactable.transform(arr); 27 | const [arrValue1, arrReactor1] = Reactable.transform(arr); 28 | assert(arrReactor); 29 | assert(arrValue !== arr); 30 | assert(arrReactor?.value === arr); 31 | assert(arrValue1 === arrValue); 32 | assert(arrReactor1 === arrReactor); 33 | const [arrValue2, arrReactor2] = Reactable.transform(arrValue); 34 | assert(arrReactor === arrReactor2); 35 | assert(arrValue === arrValue2); 36 | class A {} 37 | const a = new A(); 38 | const [objValue, objReactor] = Reactable.transform(a); 39 | assert(!objReactor); 40 | assert(a === objValue); 41 | }); 42 | 43 | it('#transform array', () => { 44 | const v: any[] = []; 45 | const [tValue] = Reactable.transform(v); 46 | assert(tValue instanceof Array); 47 | assert(Reactable.is(tValue)); 48 | assert(Observability.getOrigin(tValue) === v); 49 | }); 50 | 51 | it('#transform map', () => { 52 | const v: Map = new Map(); 53 | const [tValue] = Reactable.transform(v); 54 | assert(tValue instanceof Map); 55 | assert(Reactable.is(tValue)); 56 | assert(Observability.getOrigin(tValue) === v); 57 | }); 58 | 59 | it('#transform plain object', () => { 60 | const v = {}; 61 | const [tValue] = Reactable.transform(v); 62 | assert(isPlainObject(tValue)); 63 | assert(Reactable.is(tValue)); 64 | assert(Observability.getOrigin(tValue) === v); 65 | }); 66 | 67 | it('#reactable array', () => { 68 | const v: any[] = []; 69 | const [tValue, reactor] = Reactable.transform(v); 70 | let changedTimes = 0; 71 | if (reactor) { 72 | reactor.onChange(() => { 73 | changedTimes += 1; 74 | }); 75 | } 76 | // Pushing brings changes, one is the set value and the other is the set length 77 | tValue.push('a'); 78 | tValue.pop(); 79 | assert(tValue.length === 0); 80 | assert(changedTimes === 3); 81 | }); 82 | it('#reactable map', () => { 83 | const v: Map = new Map(); 84 | const [tValue, reactor] = Reactable.transform(v); 85 | let changedTimes = 0; 86 | if (reactor) { 87 | reactor.onChange(() => { 88 | changedTimes += 1; 89 | }); 90 | } 91 | tValue.set('a', 'a'); 92 | const aValue = tValue.get('a'); 93 | assert(aValue === 'a'); 94 | assert(tValue.size === 1); 95 | tValue.set('b', 'b'); 96 | tValue.delete('a'); 97 | tValue.clear(); 98 | assert(changedTimes === 4); 99 | }); 100 | 101 | it('#reactable plain object', () => { 102 | const v: any = {}; 103 | const [tValue, reactor] = Reactable.transform(v); 104 | let changedTimes = 0; 105 | if (reactor) { 106 | reactor.onChange(() => { 107 | changedTimes += 1; 108 | }); 109 | } 110 | tValue.a = 'a'; 111 | assert(tValue.a === 'a'); 112 | tValue.b = 'b'; 113 | delete tValue.b; 114 | assert(changedTimes === 3); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /packages/mana-observable/src/reactivity.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-spread */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | import { Emitter, isPlainObject } from 'mana-common'; 4 | import { ObservableSymbol } from './core'; 5 | import { Observability } from './utils'; 6 | 7 | /** 8 | * Reactor is bound to an reacable object, such as array/map/object. 9 | * Reactor helpers the reacable object to server multiple observable objects. 10 | * It will trigger when the reacable object is changed. 11 | */ 12 | export class Reactor { 13 | protected changedEmitter: Emitter; 14 | protected readonly _value: any; 15 | constructor(val: any) { 16 | this.changedEmitter = new Emitter(); 17 | this._value = val; 18 | } 19 | get onChange() { 20 | return this.changedEmitter.event; 21 | } 22 | get value() { 23 | return this._value; 24 | } 25 | notify(value: any) { 26 | this.changedEmitter.fire(value); 27 | } 28 | } 29 | 30 | export interface Reactable { 31 | [ObservableSymbol.Reactor]: Reactor; 32 | } 33 | 34 | export namespace Reactable { 35 | export function is(target: any): target is Reactable { 36 | return Observability.trackable(target) && (target as any)[ObservableSymbol.Reactor]; 37 | } 38 | export function getReactor(target: Reactable): Reactor { 39 | return target[ObservableSymbol.Reactor]; 40 | } 41 | export function set(target: any, value: Reactable): void { 42 | Reflect.defineMetadata(ObservableSymbol.Reactor, value, target); 43 | } 44 | 45 | export function get>(target: T): T & Reactable { 46 | return Reflect.getMetadata(ObservableSymbol.Reactor, target); 47 | } 48 | export function canBeReactable(value: any): boolean { 49 | if (!value) return false; 50 | if (is(value)) { 51 | return true; 52 | } 53 | if (value instanceof Array) { 54 | return true; 55 | } 56 | if (value instanceof Map) { 57 | return true; 58 | } 59 | if (isPlainObject(value)) { 60 | return true; 61 | } 62 | return false; 63 | } 64 | export function transform(value: T): [T, Reactor | undefined] { 65 | let reactor: Reactor | undefined = undefined; 66 | if (!Observability.trackable(value)) return [value, undefined]; 67 | if (is(value)) { 68 | reactor = getReactor(value); 69 | return [value, reactor]; 70 | } 71 | const exsit = get(value); 72 | if (exsit) { 73 | return [exsit, getReactor(exsit)]; 74 | } 75 | let reactable; 76 | if (value instanceof Array) { 77 | reactable = transformArray(value); 78 | } 79 | if (value instanceof Map) { 80 | reactable = transformMap(value); 81 | } 82 | if (isPlainObject(value)) { 83 | reactable = transformPlainObject(value); 84 | } 85 | if (reactable) { 86 | set(value, reactable); 87 | return [reactable, getReactor(reactable)]; 88 | } 89 | return [value, undefined]; 90 | } 91 | 92 | export function transformArray(toReactable: any[]) { 93 | const reactor = new Reactor(toReactable); 94 | return new Proxy(toReactable, { 95 | get(self: any, prop: string | symbol): any { 96 | if (prop === ObservableSymbol.Reactor) { 97 | return reactor; 98 | } 99 | if (prop === ObservableSymbol.Self) { 100 | return self; 101 | } 102 | const result = Reflect.get(self, prop); 103 | return result; 104 | }, 105 | set(self: any, prop: string | symbol, value: any): any { 106 | const result = Reflect.set(self, prop, value); 107 | reactor.notify(value); 108 | return result; 109 | }, 110 | }); 111 | } 112 | 113 | export function transformPlainObject(toReactable: any) { 114 | const reactor = new Reactor(toReactable); 115 | return new Proxy(toReactable, { 116 | get(self: any, prop: string | symbol): any { 117 | if (prop === ObservableSymbol.Reactor) { 118 | return reactor; 119 | } 120 | if (prop === ObservableSymbol.Self) { 121 | return self; 122 | } 123 | const result = Reflect.get(self, prop); 124 | return result; 125 | }, 126 | set(self: any, prop: string | symbol, value: any): any { 127 | const result = Reflect.set(self, prop, value); 128 | reactor.notify(value); 129 | return result; 130 | }, 131 | deleteProperty(self: any, prop: string | symbol): boolean { 132 | const result = Reflect.deleteProperty(self, prop); 133 | reactor.notify(undefined); 134 | return result; 135 | }, 136 | }); 137 | } 138 | 139 | export function transformMap(toReactable: Map) { 140 | const reactor = new Reactor(toReactable); 141 | return new Proxy(toReactable, { 142 | get(self: any, prop: string | symbol): any { 143 | if (prop === ObservableSymbol.Reactor) { 144 | return reactor; 145 | } 146 | if (prop === ObservableSymbol.Self) { 147 | return self; 148 | } 149 | switch (prop) { 150 | case 'set': 151 | return (...args: any) => { 152 | const result = self.set.apply(self, args); 153 | reactor.notify(undefined); 154 | return result; 155 | }; 156 | case 'delete': 157 | return (...args: any) => { 158 | const result = self.delete.apply(self, args); 159 | reactor.notify(undefined); 160 | return result; 161 | }; 162 | case 'clear': 163 | return (...args: any) => { 164 | const result = (self as Map).clear.apply(self, args); 165 | reactor.notify(undefined); 166 | return result; 167 | }; 168 | default: 169 | const result = Reflect.get(self, prop); 170 | if (typeof result === 'function') { 171 | return result.bind(self); 172 | } 173 | return result; 174 | } 175 | }, 176 | }); 177 | } 178 | } 179 | 180 | export interface ReactiveHandler { 181 | onChange?: (value: any) => any; 182 | } 183 | -------------------------------------------------------------------------------- /packages/mana-observable/src/tracker.ts: -------------------------------------------------------------------------------- 1 | import type { Disposable } from 'mana-common'; 2 | import { isPlainObject } from 'mana-common'; 3 | import { getPropertyDescriptor } from 'mana-common'; 4 | import { observable } from '.'; 5 | import { ObservableSymbol } from './core'; 6 | import { Notifier } from './notifier'; 7 | import { Reactable } from './reactivity'; 8 | import { Observability } from './utils'; 9 | 10 | type Act = (...args: any) => void; 11 | 12 | function getValue>( 13 | obj: T, 14 | property: string | symbol, 15 | proxy: T, 16 | notifier?: Notifier, 17 | ) { 18 | if (!notifier) { 19 | const descriptor = getPropertyDescriptor(obj, property); 20 | if (descriptor?.get) { 21 | return descriptor.get.call(proxy); 22 | } 23 | } 24 | return obj[property as any]; 25 | } 26 | 27 | export type Trackable = { 28 | [ObservableSymbol.Tracker]: Record; 29 | }; 30 | 31 | export namespace Trackable { 32 | export function is(target: any): target is Trackable { 33 | return Observability.trackable(target) && (target as any)[ObservableSymbol.Tracker]; 34 | } 35 | export function getOrigin(target: Trackable): any { 36 | return target[ObservableSymbol.Tracker]; 37 | } 38 | export function tryGetOrigin(target: any): any { 39 | if (!is(target)) { 40 | return target; 41 | } 42 | return getOrigin(target); 43 | } 44 | } 45 | export namespace Tracker { 46 | export function set = any>(target: T, act: Act, proxy: T) { 47 | Reflect.defineMetadata(act, proxy, target, ObservableSymbol.Tracker); 48 | } 49 | export function get = any>( 50 | target: T, 51 | act: Act, 52 | ): (T & Trackable) | undefined { 53 | return Reflect.getMetadata(act, target, ObservableSymbol.Tracker); 54 | } 55 | export function has = any>(target: T, act: Act) { 56 | return Reflect.hasOwnMetadata(act, target, ObservableSymbol.Tracker); 57 | } 58 | 59 | function handleNotifier>( 60 | notifier: Notifier, 61 | act: Act, 62 | obj: T, 63 | property?: string, 64 | ) { 65 | const lastToDispose: Disposable = Observability.getDisposable(act, obj, property); 66 | if (lastToDispose) { 67 | lastToDispose.dispose(); 68 | } 69 | const toDispose = notifier.once(() => { 70 | if (property) { 71 | act({ 72 | key: property as keyof T, 73 | value: obj[property], 74 | }); 75 | } else { 76 | act(obj); 77 | } 78 | }); 79 | Observability.setDisposable(act, toDispose, obj, property); 80 | } 81 | 82 | export function tramsform(toTrack: any, act: Act) { 83 | if (toTrack instanceof Array) { 84 | return transformArray(toTrack, act); 85 | } 86 | if (toTrack instanceof Map) { 87 | return transformMap(toTrack, act); 88 | } 89 | if (isPlainObject(toTrack)) { 90 | return transformPlainObject(toTrack, act); 91 | } 92 | return toTrack; 93 | } 94 | export function transformArray(toTrack: any[], act: Act) { 95 | return new Proxy(toTrack, { 96 | get(target: any, property: string | symbol): any { 97 | const value = target[property]; 98 | if (property === ObservableSymbol.Self) { 99 | return value; 100 | } 101 | if (Observability.trackable(value)) { 102 | return track(value, act); 103 | } 104 | return value; 105 | }, 106 | }); 107 | } 108 | 109 | export function transformPlainObject(toTrack: any, act: Act) { 110 | return new Proxy(toTrack, { 111 | get(target: any, property: string | symbol): any { 112 | const value = target[property]; 113 | if (property === ObservableSymbol.Self) { 114 | return value; 115 | } 116 | if (Observability.trackable(value)) { 117 | return track(value, act); 118 | } 119 | return value; 120 | }, 121 | }); 122 | } 123 | 124 | export function transformMap(toTrack: Map, act: Act) { 125 | return new Proxy(toTrack, { 126 | get(target: any, property: string | symbol): any { 127 | const value = target[property]; 128 | if (property === ObservableSymbol.Self) { 129 | return value; 130 | } 131 | if (property === 'get' && typeof value === 'function') { 132 | return function (...args: any[]) { 133 | const innerValue = value.apply(target, args); 134 | if (Observability.trackable(innerValue)) { 135 | return track(innerValue, act); 136 | } 137 | return innerValue; 138 | }; 139 | } 140 | return value; 141 | }, 142 | }); 143 | } 144 | 145 | export function setReactableNotifier(origin: any, act: Act) { 146 | const notifier = Notifier.getOrCreate(origin); 147 | handleNotifier(notifier, act, origin); 148 | } 149 | 150 | export function toInstanceTracker>( 151 | exist: T | undefined, 152 | origin: T, 153 | act: Act, 154 | deep: boolean = true, 155 | ) { 156 | if (exist) return exist; 157 | // try make observable 158 | if (!Observability.is(origin)) { 159 | observable(origin); 160 | } 161 | const proxy = new Proxy(origin, { 162 | get(target: any, property: string | symbol): any { 163 | if (property === ObservableSymbol.Tracker) { 164 | return target; 165 | } 166 | if (property === ObservableSymbol.Self) { 167 | return target; 168 | } 169 | let notifier; 170 | if (typeof property === 'string') { 171 | if (Observability.notifiable(target, property)) { 172 | notifier = Notifier.getOrCreate(target, property); 173 | handleNotifier(notifier, act, target, property); 174 | } 175 | } 176 | const value = getValue(target, property, proxy, notifier); 177 | if (Reactable.is(value)) { 178 | return tramsform(value, act); 179 | } 180 | if (Observability.trackable(value)) { 181 | if (Reactable.canBeReactable(value)) { 182 | return track(value, act, false); 183 | } 184 | return track(value, act, deep); 185 | } 186 | return value; 187 | }, 188 | }); 189 | set(origin, act, proxy); 190 | return proxy; 191 | } 192 | export function toReactableTracker>( 193 | exist: T | undefined, 194 | origin: T, 195 | object: T, 196 | act: Act, 197 | deep: boolean, 198 | ) { 199 | let maybeReactable = object; 200 | if (deep) { 201 | // try make reactable 202 | if (!Observability.is(origin)) { 203 | maybeReactable = observable(origin); 204 | } 205 | } 206 | maybeReactable = Reactable.get(origin) ?? object; 207 | // set reactable listener 208 | if (Reactable.is(maybeReactable)) { 209 | setReactableNotifier(origin, act); 210 | } 211 | if (exist) return exist; 212 | if (!deep) return object; 213 | const proxy = tramsform(maybeReactable, act); 214 | set(origin, act, proxy); 215 | return proxy; 216 | } 217 | 218 | export function track>(object: T, act: Act, deep: boolean = true): T { 219 | if (!Observability.trackable(object)) { 220 | return object; 221 | } 222 | // get origin 223 | let origin = object; 224 | if (Trackable.is(object)) { 225 | origin = Trackable.getOrigin(object); 226 | } 227 | origin = Observability.getOrigin(origin); 228 | let exist: T | undefined = undefined; 229 | // already has tracker 230 | if (has(origin, act)) { 231 | exist = get(origin, act); 232 | } 233 | // get exist reactble 234 | if (Reactable.canBeReactable(origin)) { 235 | return toReactableTracker(exist, origin, object, act, deep); 236 | } else { 237 | return toInstanceTracker(exist, origin, act, deep); 238 | } 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /packages/mana-observable/src/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import assert from 'assert'; 3 | import { getDesignType, ObservableProperties, Observability, InstanceValue } from './utils'; 4 | import { prop } from './decorator'; 5 | import { Disposable } from 'mana-common'; 6 | import { equals, ObservableSymbol } from '.'; 7 | 8 | describe('utils', () => { 9 | it('#Observability', () => { 10 | class Foo { 11 | info = ''; 12 | } 13 | const foo = new Foo(); 14 | const meta = 'meta'; 15 | Observability.setDisposable(meta, Disposable.NONE, foo); 16 | Observability.setDisposable(meta, Disposable.NONE, foo, 'info'); 17 | const toDispose = Observability.getDisposable(meta, foo); 18 | const toDispose1 = Observability.getDisposable(meta, foo, 'info'); 19 | const fooProxy = new Proxy(foo, { 20 | get: (target, propertyKey) => { 21 | if (propertyKey === ObservableSymbol.Self) { 22 | return target; 23 | } 24 | return (target as any)[propertyKey]; 25 | }, 26 | }); 27 | assert(toDispose === Disposable.NONE); 28 | assert(toDispose1 === Disposable.NONE); 29 | assert(Observability.getOrigin(fooProxy) === foo); 30 | assert(equals(fooProxy, foo)); 31 | assert(Observability.getOrigin(null) === null); 32 | assert(!Observability.trackable(null)); 33 | assert(!Observability.is(null, 'name')); 34 | assert(Observability.trackable({})); 35 | Observability.mark(foo, 'info'); 36 | Observability.mark(foo); 37 | assert(Observability.is(foo, 'info')); 38 | assert(Observability.is(foo)); 39 | assert(Observability.trackable(foo)); 40 | assert(Observability.notifiable(foo, 'info')); 41 | }); 42 | it('#ObservableProperties', () => { 43 | class ClassBasic { 44 | name = ''; 45 | } 46 | class ClassBasic1 extends ClassBasic { 47 | name1 = ''; 48 | } 49 | const instanceBasic = new ClassBasic(); 50 | let properties = ObservableProperties.get(instanceBasic); 51 | assert(!properties); 52 | ObservableProperties.add(ClassBasic, 'name'); 53 | properties = ObservableProperties.get(ClassBasic); 54 | assert(properties?.length === 1); 55 | assert(properties.includes('name')); 56 | ObservableProperties.add(ClassBasic1, 'name1'); 57 | properties = ObservableProperties.get(ClassBasic1); 58 | assert(properties?.length === 2); 59 | assert(properties.includes('name1')); 60 | properties = ObservableProperties.get(instanceBasic); 61 | assert(!properties); 62 | assert(!ObservableProperties.find({})); 63 | assert(!ObservableProperties.find(null as any)); 64 | const instanceProperties = ObservableProperties.find(instanceBasic) || []; 65 | assert(instanceProperties.includes('name')); 66 | instanceProperties.forEach(property => { 67 | ObservableProperties.add(instanceBasic, property); 68 | }); 69 | properties = ObservableProperties.getOwn(instanceBasic); 70 | assert(properties?.length === 1); 71 | }); 72 | 73 | it('#InstanceValue', () => { 74 | const foo = {}; 75 | InstanceValue.set(foo, 'name', 'foo'); 76 | assert(InstanceValue.get(foo, 'name') === 'foo'); 77 | }); 78 | it('#getDesignType', () => { 79 | class ClassBasic { 80 | @prop() 81 | name?: string; 82 | @prop() 83 | name1 = ''; 84 | @prop() 85 | map?: Map; 86 | } 87 | const instanceBasic = new ClassBasic(); 88 | assert(getDesignType(instanceBasic, 'name') === String); 89 | assert(getDesignType(instanceBasic, 'name1') === Object); 90 | assert(getDesignType(instanceBasic, 'map') === Map); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /packages/mana-observable/src/utils.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import type { Disposable } from 'mana-common'; 3 | import { ObservableSymbol } from './core'; 4 | 5 | interface Original { 6 | [ObservableSymbol.Self]: any; 7 | } 8 | 9 | export namespace Observability { 10 | export function trackable(obj: any): obj is Record { 11 | return !!obj && typeof obj === 'object'; 12 | } 13 | export function notifiable(obj: any, property: string | symbol): boolean { 14 | return is(obj, property); 15 | } 16 | export function is(obj: any, property?: string | symbol): boolean { 17 | if (!trackable(obj)) return false; 18 | const origin = getOrigin(obj); 19 | if (property) { 20 | return Reflect.hasOwnMetadata(ObservableSymbol.Observable, origin, property); 21 | } 22 | return Reflect.hasOwnMetadata(ObservableSymbol.Observable, origin); 23 | } 24 | export function mark(obj: Record, property?: string | symbol) { 25 | if (property) { 26 | Reflect.defineMetadata(ObservableSymbol.Observable, true, obj, property); 27 | } else { 28 | Reflect.defineMetadata(ObservableSymbol.Observable, true, obj); 29 | } 30 | } 31 | 32 | function isOriginal(data: any): data is Original { 33 | return Observability.trackable(data) && data[ObservableSymbol.Self]; 34 | } 35 | 36 | export function getOrigin(obj: T): T { 37 | if (!isOriginal(obj)) return obj; 38 | return obj[ObservableSymbol.Self]; 39 | } 40 | export function equals(a: any, b: any) { 41 | return getOrigin(a) === getOrigin(b); 42 | } 43 | 44 | export function getDisposable(metaKey: any, obj: Record, property?: string) { 45 | if (property) { 46 | return Reflect.getOwnMetadata(metaKey, obj, property); 47 | } 48 | return Reflect.getOwnMetadata(metaKey, obj); 49 | } 50 | 51 | export function setDisposable( 52 | metaKey: any, 53 | disposable: Disposable, 54 | obj: Record, 55 | property?: string, 56 | ) { 57 | if (property) { 58 | Reflect.defineMetadata(metaKey, disposable, obj, property); 59 | } 60 | Reflect.defineMetadata(metaKey, disposable, obj); 61 | } 62 | } 63 | 64 | export namespace ObservableProperties { 65 | export function getOwn(obj: Record): string[] | undefined { 66 | return Reflect.getOwnMetadata(ObservableSymbol.ObservableProperties, obj); 67 | } 68 | export function get(obj: Record): string[] | undefined { 69 | return Reflect.getMetadata(ObservableSymbol.ObservableProperties, obj); 70 | } 71 | export function find(obj: Record): string[] | undefined { 72 | if (obj && obj.constructor) { 73 | return get(obj.constructor); 74 | } 75 | return undefined; 76 | } 77 | 78 | export function add(obj: Record, property: string): void { 79 | const exisringProperties = getOwn(obj); 80 | if (exisringProperties) { 81 | exisringProperties.push(property); 82 | } else { 83 | const protoProperties = get(obj) || []; 84 | Reflect.defineMetadata( 85 | ObservableSymbol.ObservableProperties, 86 | [...protoProperties, property], 87 | obj, 88 | ); 89 | } 90 | } 91 | } 92 | 93 | export namespace InstanceValue { 94 | export function set(target: any, property: string, value: any) { 95 | Reflect.defineMetadata(property, value, target); 96 | } 97 | export function get(target: any, property: string) { 98 | return Reflect.getMetadata(property, target); 99 | } 100 | } 101 | 102 | /** 103 | * get design type of property 104 | * @param obj 105 | * @param propertyKey 106 | * @returns number → Number 107 | * @returns string → String 108 | * @returns boolean → Boolean 109 | * @returns any → Object 110 | * @returns void → undefined 111 | * @returns Array → Array 112 | * @returns Tuple → Array 113 | * @returns class → constructor 114 | * @returns Enum → Number 115 | * @returns ()=>{} → Function 116 | * @returns others(interface ...) → Object 117 | */ 118 | export function getDesignType(obj: Record, propertyKey: string): DesignType { 119 | return Reflect.getMetadata('design:type', obj, propertyKey); 120 | } 121 | 122 | export type DesignType = 123 | | undefined 124 | | typeof Function 125 | | typeof String 126 | | typeof Boolean 127 | | typeof Number 128 | | typeof Array 129 | | typeof Map 130 | | typeof Object; 131 | 132 | export const getOrigin = Observability.getOrigin; 133 | export const equals = Observability.equals; 134 | -------------------------------------------------------------------------------- /packages/mana-observable/src/watch.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import assert from 'assert'; 4 | import { watch } from './watch'; 5 | import { Disposable } from 'mana-common'; 6 | import { prop } from './decorator'; 7 | 8 | console.warn = () => {}; 9 | 10 | describe('watch', () => { 11 | it('#watch prop', done => { 12 | class Foo { 13 | @prop() name?: string; 14 | @prop() name1?: string; 15 | } 16 | const newName = 'new name'; 17 | let watchLatest: string | undefined; 18 | const foo = new Foo(); 19 | watchLatest = foo.name; 20 | watch(foo, 'name', () => { 21 | watchLatest = foo.name; 22 | assert(watchLatest === newName); 23 | done(); 24 | }); 25 | foo.name = newName; 26 | }); 27 | it('#watch object', () => { 28 | class Foo { 29 | @prop() name?: string; 30 | @prop() info?: string; 31 | } 32 | let changed = 0; 33 | const newName = 'new name'; 34 | let watchLatest: string | undefined; 35 | const foo = new Foo(); 36 | watchLatest = foo.name; 37 | watch(foo, () => {}); 38 | watch(foo, () => { 39 | changed += 1; 40 | watchLatest = foo.name; 41 | assert(watchLatest === newName); 42 | }); 43 | foo.name = newName; 44 | foo.info = 'foo'; 45 | assert(changed === 2); 46 | }); 47 | it('#watch unobservable prop', done => { 48 | class Foo { 49 | @prop() name?: string; 50 | info?: string; 51 | } 52 | const newName = 'new name'; 53 | let watchLatest: string | undefined; 54 | const foo = new Foo(); 55 | watchLatest = foo.info; 56 | watch(foo, 'info', () => { 57 | watchLatest = foo.info; 58 | done(); 59 | }); 60 | foo.info = newName; 61 | watch(foo, 'name', () => { 62 | assert(watchLatest !== newName); 63 | done(); 64 | }); 65 | foo.name = newName; 66 | }); 67 | 68 | it('#invalid watch', () => { 69 | class Foo { 70 | @prop() name?: string; 71 | } 72 | const foo = new Foo(); 73 | const toDispose = (watch as any)(foo, 'name'); 74 | const toDispose1 = watch(null, () => {}); 75 | assert(toDispose === Disposable.NONE); 76 | assert(toDispose1 === Disposable.NONE); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /packages/mana-observable/src/watch.ts: -------------------------------------------------------------------------------- 1 | import { Disposable } from 'mana-common'; 2 | import { observable } from './observable'; 3 | import type { Notify } from './core'; 4 | import { Notifier } from './notifier'; 5 | import { getOrigin, Observability } from './utils'; 6 | 7 | function tryObservable(target: T) { 8 | const data = getOrigin(target); 9 | if (!Observability.trackable(data)) { 10 | return data; 11 | } 12 | if (!Observability.is(data)) { 13 | return observable(data); 14 | } 15 | return data; 16 | } 17 | 18 | function watchAll(target: T, callback: Notify): Disposable { 19 | const data = getOrigin(target); 20 | if (!Observability.trackable(data)) { 21 | return Disposable.NONE; 22 | } 23 | tryObservable(data); 24 | const tracker = Notifier.find(data); 25 | if (!tracker) { 26 | return Disposable.NONE; 27 | } 28 | const props: string[] = Object.keys(data); 29 | if (props) { 30 | props.forEach(prop => Notifier.find(target, prop)); 31 | } 32 | return tracker.onChange(callback); 33 | } 34 | 35 | function watchProp(target: T, prop: Extract, callback: Notify): Disposable { 36 | const data = getOrigin(target); 37 | tryObservable(data); 38 | const tracker = Notifier.find(data, prop); 39 | if (tracker) { 40 | return tracker.onChange(callback); 41 | } 42 | console.warn(`Cannot add watcher for unobservable property ${prop.toString()}`, target); 43 | return Disposable.NONE; 44 | } 45 | 46 | export function watch(target: T, callback: Notify): Disposable; 47 | export function watch(target: T, prop: Extract, callback: Notify): Disposable; 48 | export function watch( 49 | target: T, 50 | prop: Extract | Notify, 51 | callback?: Notify, 52 | ): Disposable { 53 | let cb: Notify; 54 | if (typeof prop === 'function') { 55 | cb = prop; 56 | return watchAll(target, cb); 57 | } 58 | if (typeof prop === 'string' && callback) { 59 | cb = callback; 60 | return watchProp(target, prop, cb); 61 | } 62 | console.warn(`Invalid arguments for watch ${prop.toString()}`, target); 63 | return Disposable.NONE; 64 | } 65 | -------------------------------------------------------------------------------- /packages/mana-observable/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src" 5 | }, 6 | "include": ["src/**/*"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/mana-syringe/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ['../../.eslintrc.js'], 4 | parserOptions: { 5 | tsconfigRootDir: __dirname, 6 | project: 'tsconfig.json', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /packages/mana-syringe/.fatherrc.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | esm: 'babel', 3 | cjs: 'babel', 4 | umd: { sourcemap: true }, 5 | }; 6 | -------------------------------------------------------------------------------- /packages/mana-syringe/README.md: -------------------------------------------------------------------------------- 1 | # mana-syringe 2 | 3 | IoC library for mana, easily to use. 4 | 5 | [![NPM version](https://img.shields.io/npm/v/mana-syringe.svg?style=flat)](https://npmjs.org/package/mana-syringe) [![NPM downloads](https://img.shields.io/npm/dm/mana-syringe.svg?style=flat)](https://npmjs.org/package/mana-syringe) 6 | 7 | 提供易于使用的依赖注入容器,参考 TSyringe 项目,参考并基于 inversify。 8 | 9 | ## 安装 10 | 11 | ```bash 12 | npm i mana-syringe --save 13 | ``` 14 | 15 | ## 概念与使用 16 | 17 | ### 注入标识 token 18 | 19 | 注入绑定对象所使用的的标识,可以带有一定的类型约束 20 | 21 | ```typescript 22 | Token = string | symbol | Newable | Abstract | Syringe.DefinedToken; 23 | ``` 24 | 25 | 除 `Syringe.DefinedToken` 默认支持多绑定外,注入标识只支持单一绑定关系。可以使用如下 API 生成 DefinedToken 26 | 27 | ```typescript 28 | Syringe.defineToken('sample-token'); 29 | ``` 30 | 31 | ### 容器 Container 32 | 33 | 包含一组绑定标识与注入对象关系描述的上下文成为容器,当我们通过容器获取实例时,容器会根据注入对象及其与标识的关系自动构建所需的其他实例。 34 | 35 | 用户可以手动创建容器,使用全局默认的容器,或者创建子容器 36 | 37 | ```typescript 38 | import { GlobalContainer, Container } from './container'; 39 | const global = GlobalContainer; 40 | const container = new Container(); 41 | const child = container.createChild(); 42 | ``` 43 | 44 | 我们使用 `token` 从容器里获取对象 45 | 46 | ```typescript 47 | const ninja = child.get(Ninja); 48 | ``` 49 | 50 | 当我们从子容器中获取对象时,会先从子容器查找绑定关系和缓存信息,如果不存在,则继续向父容器查找。 51 | 52 | ### 注册 register 53 | 54 | 容器上暴露了 register 方法,这个 API 是整个体系的核心。 register 方法有两种签名 55 | 56 | ```typescript 57 | register(options: Syringe.InjectOption): void; 58 | register(token: Syringe.Token, options?: Syringe.InjectOption): void; 59 | ``` 60 | 61 | 可以调用容器实例上的 register 方法,也可以直接调用全局的 register 方法,其相对于调用 GlobalContainer 的方法。 62 | 63 | 从签名可以看出,注册绑定需要一组配置,在不同场景下配置会有所不同,可能出现的配置项如下 64 | 65 | ```typescript 66 | interface { 67 | token?: MaybeArray>; 68 | contrib?: MaybeArray>; 69 | lifecycle?: Lifecycle; 70 | useClass?: MaybeArray>; 71 | useDynamic?: MaybeArray>; 72 | useFactory?: MaybeArray>; 73 | useValue?: T; 74 | } 75 | ``` 76 | 77 | - token 可以为数组,本次绑定关系需要声明的标识,不同标识分别注册 78 | - contrib 可以为数组,可用于注册扩展点,也可用于注册 token 别名 79 | - useClass 可以为数组,给出一个或多个类 80 | - useToken 可以为数组,根据 token 从容器内动态获取对象 81 | - useFactory 可以为数组,基于带有容器信息的上下文,给出动态获得实例的方法 82 | - useDynamic 可以为数组,基于带有容器信息的上下文给出实例 83 | - useValue 可以为数组,常量直接给出值 84 | 85 | #### 生命期 lifecycle 86 | 87 | 容器会根据注入对象的生命期描述托管这些对象,决定是否使用缓存等。 88 | 89 | ```typescript 90 | export enum Lifecycle { 91 | singleton = 'singleton', 92 | transient = 'transient', 93 | } 94 | ``` 95 | 96 | #### 注册类和别名 97 | 98 | ```typescript 99 | @singleton({ contrib: Alias }) 100 | class Shuriken implements Weapon { 101 | public hit() { 102 | console.log('Shuriken hit'); 103 | } 104 | } 105 | GlobalContainer.register(Shuriken); 106 | GlobalContainer.register(Shuriken, { 107 | useClass: Shuriken, 108 | lifecycle: Syringe.Lifecycle.singleton, 109 | }); 110 | ``` 111 | 112 | 通过 token 注册后,每个 token 的注册关系是独立的,通过他们获取对象可以是不同的值,通过 contrib 注册的是别名关系,他们应该获取到同一个对象。不管是 token 还是 contrib,根据对多绑定的支持情况做处理。 113 | 114 | ```typescript 115 | const Weapon = Symbol('Weapon'); 116 | const WeaponArray = Syringe.defineToken('Weapon'); 117 | @singleton({ contrib: Weapon }) 118 | class Shuriken implements Weapon { 119 | public hit() { 120 | console.log('Shuriken hit'); 121 | } 122 | } 123 | GlobalContainer.register({ token: Weapon, useValue: undefined }); 124 | GlobalContainer.register({ token: WeaponArray, useValue: undefined }); 125 | GlobalContainer.register(Shuriken); 126 | GlobalContainer.get(Weapon); // Shuriken 127 | GlobalContainer.getAll(WeaponArray); // [undefined, Shuriken] 128 | ``` 129 | 130 | #### 注册值 131 | 132 | ```typescript 133 | const ConstantValue = Symbol('ConstantValue'); 134 | GlobalContainer.register({ token: ConstantValue, useValue: {} }); 135 | ``` 136 | 137 | #### 注册动态值 138 | 139 | ```typescript 140 | const NinjaAlias = Symbol('NinjaAlias'); 141 | GlobalContainer.register({ 142 | token: NinjaAlias, 143 | useDynamic: ctx => ctx.container.get(Ninja), 144 | }); 145 | ``` 146 | 147 | ### 装饰器 148 | 149 | 我们提供了一组对类与属性的装饰器函数,用来快速完成基于依赖注入的类型描述,并完成基本的绑定关系描述。 150 | 151 | - injectable: 通用装饰器,接受所有绑定描述参数 152 | - singleton: 单例装饰器,接受除生命期外的描述参数 153 | - transient: 多例装饰器,接受除生命期外的描述参数 154 | - inject: 注入,接受注入标识作为参数,并接受类型描述 155 | 156 | ```typescript 157 | @singleton() 158 | class Shuriken implements Weapon { 159 | public hit() { 160 | console.log('Shuriken hit'); 161 | } 162 | } 163 | @transient() 164 | class Ninja { 165 | @inject(Weapon) public weapon: Weapon; 166 | public hit() { 167 | this.weapon.hit(); 168 | } 169 | } 170 | ``` 171 | 172 | ### 扩展点 Contribution 173 | 174 | 我们通常将依赖注入的多绑定模式以扩展点的形式使用,为了方便在项目中使用这种模式,我们内置了对扩展点的定义和支持。 175 | 176 | #### 扩展点的定义与注册 177 | 178 | ```typescript 179 | const Weapon = Syringe.defineToken('Weapon'); 180 | Contribution.register(GlobalContainer.register, Weapon); 181 | ``` 182 | 183 | #### 扩展服务 Contribution.Provider 184 | 185 | 内置了扩展点的管理服务,用户一般直接使用即可,注册扩展点以后,通过如下方式获取扩展服务 186 | 187 | ```typescript 188 | @contrib(Weapon) public weaponProvider: Contribution.Provider; 189 | ``` 190 | 191 | 等价于如下写法 192 | 193 | ```typescript 194 | @inject(Contribution.Provider) @named(Weapon) public weaponProvider: Contribution.Provider; 195 | 196 | ``` 197 | 198 | #### 扩展点示例 199 | 200 | ```typescript 201 | const Weapon = Syringe.defineToken('Weapon'); 202 | Contribution.register(GlobalContainer.register, Weapon); 203 | @singleton({ contrib: Weapon }) 204 | class Shuriken implements Weapon { 205 | public hit() { 206 | console.log('Shuriken hit'); 207 | } 208 | } 209 | @transient() 210 | class Ninja { 211 | @contrib(Weapon) public weaponProvider: Contribution.Provider; 212 | hit() { 213 | const weapons = this.weaponProvider.getContributions(); 214 | weapons.forEach(w => w.hit()); 215 | } 216 | } 217 | const module = Module(register => { 218 | Contribution.register(register, Weapon); 219 | register(Shuriken); 220 | register(Ninja); 221 | }); 222 | GlobalContainer.register(Shuriken); 223 | GlobalContainer.register(Ninja); 224 | GlobalContainer.get(Ninja).hit(); // Shuriken hit 225 | ``` 226 | 227 | ### 模块 228 | 229 | 可以通过用一组注册动作创建一个模块,方便在不同容器上下文间内加载, 模块的构建支持注册函数和链式调用两种方式,前面扩展点示例里的模块也可以写成如下形式: 230 | 231 | ```typescript 232 | const module = Module().contribution(Weapon).register(Shuriken, Ninja); 233 | 234 | GlobalContainer.load(module); 235 | ``` 236 | 237 | - 相同 module 默认不重复加载。 238 | -------------------------------------------------------------------------------- /packages/mana-syringe/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mana-syringe", 3 | "keywords": [ 4 | "mana", 5 | "syringe", 6 | "inversify" 7 | ], 8 | "description": "IoC library for mana, easily to use.", 9 | "version": "0.3.2", 10 | "typings": "lib/index.d.ts", 11 | "main": "lib/index.js", 12 | "module": "es/index.js", 13 | "unpkg": "dist/index.umd.min.js", 14 | "license": "MIT", 15 | "files": [ 16 | "package.json", 17 | "README.md", 18 | "dist", 19 | "es", 20 | "lib", 21 | "src" 22 | ], 23 | "dependencies": { 24 | "inversify": "^5.0.1" 25 | }, 26 | "scripts": { 27 | "prepare": "yarn run clean && yarn run build", 28 | "lint": "manarun lint", 29 | "clean": "manarun clean", 30 | "build": "manarun build", 31 | "watch": "manarun watch" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/container.ts: -------------------------------------------------------------------------------- 1 | import type { interfaces } from 'inversify'; 2 | import { Container as InversifyContainer } from 'inversify'; 3 | import type { InversifyContext } from './inversify/inversify-protocol'; 4 | import { 5 | GlobalContainer as InversifyGlobalContainer, 6 | namedToIdentifier, 7 | tokenToIdentifier, 8 | } from './inversify'; 9 | import type { Disposable, Syringe } from './core'; 10 | import { Utils } from './core'; 11 | import { Register } from './register'; 12 | import { isSyringeModule } from './module'; 13 | 14 | const ContainerMap = new Map(); 15 | 16 | /* eslint-disable @typescript-eslint/no-explicit-any */ 17 | export class Container implements Syringe.Container, InversifyContext { 18 | static setContainer(key: interfaces.Container, value: Syringe.Container) { 19 | return ContainerMap.set(key.id, value); 20 | } 21 | static getContainer(key: interfaces.Container) { 22 | const exist = ContainerMap.get(key.id); 23 | if (!exist) { 24 | const container = new Container(key); 25 | Container.setContainer(key, container); 26 | return container; 27 | } 28 | return exist; 29 | } 30 | static config(option: Syringe.InjectOption): void { 31 | Register.globalConfig = option; 32 | } 33 | 34 | protected loadedModules: number[] = []; 35 | container: interfaces.Container; 36 | protected inversify: boolean = true; 37 | parent?: Container; 38 | constructor(inversifyContainer?: interfaces.Container) { 39 | if (inversifyContainer) { 40 | this.container = inversifyContainer; 41 | } else { 42 | this.container = new InversifyContainer(); 43 | } 44 | Container.setContainer(this.container, this); 45 | } 46 | load(module: Syringe.Module, force?: boolean): Disposable { 47 | if (force || !this.loadedModules.includes(module.id)) { 48 | if (isSyringeModule(module)) { 49 | this.container.load(module.inversifyModule); 50 | } else { 51 | console.warn('Unsupported module.', module); 52 | } 53 | this.loadedModules.push(module.id); 54 | return { 55 | dispose: () => { 56 | this.unload(module); 57 | }, 58 | }; 59 | } 60 | return { dispose: () => {} }; 61 | } 62 | unload(module: Syringe.Module): void { 63 | if (isSyringeModule(module)) { 64 | this.container.unload(module.inversifyModule); 65 | this.loadedModules = this.loadedModules.filter(id => id !== module.id); 66 | } 67 | } 68 | remove(token: Syringe.Token): void { 69 | return this.container.unbind(tokenToIdentifier(token)); 70 | } 71 | get(token: Syringe.Token): T { 72 | return this.container.get(tokenToIdentifier(token)); 73 | } 74 | getNamed(token: Syringe.Token, named: Syringe.Named): T { 75 | return this.container.getNamed(tokenToIdentifier(token), namedToIdentifier(named)); 76 | } 77 | getAll(token: Syringe.Token): T[] { 78 | return this.container.getAll(tokenToIdentifier(token)); 79 | } 80 | getAllNamed(token: Syringe.Token, named: Syringe.Named): T[] { 81 | return this.container.getAllNamed(tokenToIdentifier(token), namedToIdentifier(named)); 82 | } 83 | 84 | isBound(token: Syringe.Token): boolean { 85 | return this.container.isBound(tokenToIdentifier(token)); 86 | } 87 | 88 | isBoundNamed(token: Syringe.Token, named: Syringe.Named): boolean { 89 | return this.container.isBoundNamed(tokenToIdentifier(token), namedToIdentifier(named)); 90 | } 91 | 92 | createChild(): Syringe.Container { 93 | const childContainer = this.container.createChild(); 94 | const child = new Container(childContainer); 95 | child.parent = this; 96 | return child; 97 | } 98 | register(tokenOrOption: Syringe.Token | Syringe.InjectOption): void; 99 | register(token: Syringe.Token, options: Syringe.InjectOption): void; 100 | register( 101 | token: Syringe.Token | Syringe.InjectOption, 102 | options: Syringe.InjectOption = {}, 103 | ): void { 104 | if (Utils.isInjectOption(token)) { 105 | Register.resolveOption(this.container, token); 106 | } else { 107 | Register.resolveTarget(this.container, token, options); 108 | } 109 | } 110 | } 111 | 112 | export const GlobalContainer = new Container(InversifyGlobalContainer); 113 | 114 | export const register: Syringe.Register = GlobalContainer.register.bind(GlobalContainer); 115 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/contribution/contribution-protocol.ts: -------------------------------------------------------------------------------- 1 | import { Syringe } from '../core'; 2 | 3 | export type Option = { 4 | /** 5 | * collected from the parent containers 6 | */ 7 | recursive?: boolean; 8 | /** 9 | * use cache 10 | */ 11 | cache?: boolean; 12 | }; 13 | export type Provider> = { 14 | getContributions: (option?: Option) => T[]; 15 | }; 16 | export const Provider = Syringe.defineToken('ContributionProvider'); 17 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/contribution/contribution-provider.ts: -------------------------------------------------------------------------------- 1 | import type { Syringe } from '../core'; 2 | import type { Option, Provider } from './contribution-protocol'; 3 | 4 | export class DefaultContributionProvider> implements Provider { 5 | protected option: Option = { recursive: false, cache: true }; 6 | protected services: T[] | undefined; 7 | protected readonly serviceIdentifier: Syringe.Token; 8 | protected readonly container: Syringe.Container; 9 | constructor(serviceIdentifier: Syringe.Token, container: Syringe.Container, option?: Option) { 10 | this.container = container; 11 | this.serviceIdentifier = serviceIdentifier; 12 | if (option) { 13 | this.option = { ...this.option, ...option }; 14 | } 15 | } 16 | 17 | protected setServices(recursive: boolean): T[] { 18 | const currentServices: T[] = []; 19 | let currentContainer: Syringe.Container | undefined = this.container; 20 | while (currentContainer) { 21 | if (currentContainer.isBound(this.serviceIdentifier)) { 22 | const list = currentContainer.getAll(this.serviceIdentifier); 23 | currentServices.push(...list); 24 | } 25 | currentContainer = recursive ? currentContainer.parent : undefined; 26 | } 27 | return currentServices; 28 | } 29 | 30 | getContributions(option: Option = {}): T[] { 31 | const { cache, recursive } = { ...this.option, ...option }; 32 | if (!cache || this.services === undefined) { 33 | this.services = this.setServices(!!recursive); 34 | } 35 | return this.services; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/contribution/contribution-register.ts: -------------------------------------------------------------------------------- 1 | import { Syringe } from '../core'; 2 | import * as Contribution from './contribution-protocol'; 3 | import type { Provider, Option } from './contribution-protocol'; 4 | import { DefaultContributionProvider } from './contribution-provider'; 5 | 6 | export function contributionInjectOption( 7 | token: Syringe.DefinedToken, 8 | option?: Option, 9 | ): Syringe.InjectOption> { 10 | return { 11 | token: { token: Contribution.Provider, named: token }, 12 | useDynamic: ctx => { 13 | return new DefaultContributionProvider(token, ctx.container, option); 14 | }, 15 | lifecycle: Syringe.Lifecycle.singleton, 16 | }; 17 | } 18 | 19 | export function contributionRegister( 20 | registerMethod: Syringe.Register, 21 | identifier: Syringe.DefinedToken, 22 | option?: Option, 23 | ): void { 24 | registerMethod({ 25 | token: { token: Contribution.Provider, named: identifier }, 26 | useDynamic: ctx => { 27 | return new DefaultContributionProvider(identifier, ctx.container, option); 28 | }, 29 | lifecycle: Syringe.Lifecycle.singleton, 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/contribution/decorator.ts: -------------------------------------------------------------------------------- 1 | import type { Syringe } from '../core'; 2 | import { inject, named } from '../decorator'; 3 | import { Provider } from './contribution-protocol'; 4 | 5 | export const contrib = 6 | (token: Syringe.Named) => 7 | ( 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | target: any, 10 | targetKey: string, 11 | index?: number | undefined, 12 | ) => { 13 | named(token)(target, targetKey, index); 14 | inject(Provider)(target, targetKey, index); 15 | }; 16 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/contribution/index.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import 'reflect-metadata'; 4 | import assert from 'assert'; 5 | import { GlobalContainer } from '../container'; 6 | import { register } from '../container'; 7 | import { inject, singleton } from '../decorator'; 8 | import { Contribution, contrib } from '../'; 9 | import { DefaultContributionProvider } from './contribution-provider'; 10 | import { Syringe } from '../core'; 11 | 12 | describe('contribution', () => { 13 | it('#register contribution', () => { 14 | const FooContribution = Syringe.defineToken('FooContribution'); 15 | Contribution.register(register, FooContribution); 16 | const provider = GlobalContainer.getNamed(Contribution.Provider, FooContribution); 17 | assert(provider instanceof DefaultContributionProvider); 18 | assert(GlobalContainer.isBoundNamed(Contribution.Provider, FooContribution)); 19 | }); 20 | it('#contrib decorator', () => { 21 | const FooContribution = Syringe.defineToken('FooContribution'); 22 | const BarContribution = Syringe.defineToken('BarContribution'); 23 | Contribution.register(register, FooContribution); 24 | @singleton({ contrib: FooContribution }) 25 | class Foo {} 26 | @singleton({ contrib: [FooContribution, BarContribution] }) 27 | class Foo1 {} 28 | register(Foo); 29 | register(Foo1); 30 | @singleton() 31 | class Bar { 32 | constructor( 33 | @contrib(FooContribution) public contribs: Contribution.Provider, 34 | @inject(BarContribution) public bar: Contribution.Provider, 35 | ) {} 36 | } 37 | register(Bar); 38 | 39 | const bar = GlobalContainer.get(Bar); 40 | const list = bar.contribs.getContributions(); 41 | assert(bar.bar instanceof Foo1); 42 | assert(list.length === 2); 43 | assert(list.find(item => item instanceof Foo)); 44 | }); 45 | it('#contribution option', () => { 46 | const FooContribution = Syringe.defineToken('FooContribution'); 47 | @singleton({ contrib: FooContribution }) 48 | class Foo {} 49 | register(Foo); 50 | const childContainer = GlobalContainer.createChild(); 51 | Contribution.register(childContainer.register.bind(childContainer), FooContribution, { 52 | cache: true, 53 | }); 54 | @singleton() 55 | class Bar { 56 | constructor(@contrib(FooContribution) public pr: Contribution.Provider) {} 57 | } 58 | childContainer.register(Bar); 59 | const bar = childContainer.get(Bar); 60 | const list = bar.pr.getContributions(); 61 | @singleton({ contrib: FooContribution }) 62 | class Foo1 {} 63 | childContainer.register(Foo1); 64 | assert(list.length === 1); 65 | assert(list.find(item => item instanceof Foo)); 66 | const cachelist = bar.pr.getContributions(); 67 | assert(list === cachelist); 68 | const newlist = bar.pr.getContributions({ cache: false }); 69 | assert(list !== newlist && newlist.length === 1); 70 | assert(newlist.find(item => item instanceof Foo1)); 71 | const all = bar.pr.getContributions({ recursive: true, cache: false }); 72 | assert(all !== newlist && all.length === 2); 73 | assert(all.find(item => item instanceof Foo)); 74 | assert(all.find(item => item instanceof Foo1)); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/contribution/index.ts: -------------------------------------------------------------------------------- 1 | import * as Protocol from './contribution-protocol'; 2 | import { contributionRegister } from './contribution-register'; 3 | 4 | export * from './contribution-protocol'; 5 | export * from './contribution-provider'; 6 | export * from './decorator'; 7 | 8 | export namespace Contribution { 9 | export type Option = Protocol.Option; 10 | export type Provider> = Protocol.Provider; 11 | export const Provider = Protocol.Provider; 12 | export const register = contributionRegister; 13 | } 14 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/core.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/no-shadow */ 3 | 4 | import 'reflect-metadata'; 5 | 6 | export type TokenOption = { 7 | multiple?: boolean; 8 | }; 9 | 10 | export type Newable = new (...args: any[]) => T; 11 | 12 | export type Decorator = (target: Newable | Abstract) => any; 13 | export type Abstract = { 14 | prototype: T; 15 | }; 16 | 17 | export type Disposable = { 18 | /** 19 | * Dispose this object. 20 | */ 21 | dispose: () => void; 22 | }; 23 | export namespace Syringe { 24 | /** 25 | * 定义注入标识,默认允许多重注入 26 | */ 27 | export const defineToken = (name: string, option: Partial = { multiple: true }) => 28 | new Syringe.DefinedToken(name, option); 29 | export class DefinedToken { 30 | /** 31 | * 兼容 inversify identifier 32 | */ 33 | prototype: any = {}; 34 | protected name: string; 35 | readonly multiple: boolean; 36 | readonly symbol: symbol; 37 | constructor(name: string, option: Partial = {}) { 38 | const { multiple = false } = option; 39 | this.name = name; 40 | this.symbol = Symbol(this.name); 41 | this.multiple = multiple; 42 | } 43 | } 44 | 45 | export type Register = ( 46 | token: Syringe.Token | Syringe.InjectOption, 47 | options?: Syringe.InjectOption, 48 | ) => void; 49 | 50 | export type Token = string | symbol | Newable | Abstract | DefinedToken; 51 | export type Named = string | symbol | DefinedToken; 52 | export type NamedToken = { 53 | token: Token; 54 | named: Named; 55 | }; 56 | export type OverrideToken = { 57 | token: Token; 58 | override: boolean; 59 | }; 60 | 61 | export type Registry = (register: Register) => void; 62 | export type Module = { 63 | id: number; 64 | }; 65 | 66 | export function isModule(data: Record | undefined): data is Module { 67 | return !!data && typeof data === 'object' && 'id' in data; 68 | } 69 | 70 | export type Container = { 71 | parent?: Container; 72 | remove: (token: Syringe.Token) => void; 73 | register: ( 74 | token: Syringe.Token | Syringe.InjectOption, 75 | options?: Syringe.InjectOption, 76 | ) => void; 77 | load: (module: Module, force?: boolean) => Disposable; 78 | unload: (module: Module) => void; 79 | get: (token: Syringe.Token) => T; 80 | getNamed: (token: Syringe.Token, named: Syringe.Named) => T; 81 | getAll: (token: Syringe.Token) => T[]; 82 | getAllNamed: (token: Syringe.Token, named: Syringe.Named) => T[]; 83 | isBound: (token: Syringe.Token) => boolean; 84 | isBoundNamed: (token: Syringe.Token, named: Syringe.Named) => boolean; 85 | createChild: () => Container; 86 | }; 87 | 88 | export type Context = { 89 | container: Container; 90 | }; 91 | export type UnionToken = Token | NamedToken; 92 | export type Class = Newable; 93 | export type Factory = (ctx: Context) => (...args: any) => T; 94 | export type Dynamic = (ctx: Context) => T; 95 | export type MaybeArray = T | T[]; 96 | 97 | export type DecoratorOption = { 98 | token?: MaybeArray>; 99 | contrib?: MaybeArray>; 100 | lifecycle?: Lifecycle; 101 | }; 102 | 103 | export type TargetOption = { 104 | contrib?: MaybeArray>; 105 | } & ValueOption; 106 | 107 | export type ValueOption = { 108 | useClass?: MaybeArray>; 109 | useDynamic?: MaybeArray>; 110 | useFactory?: MaybeArray>; 111 | useValue?: T; 112 | }; 113 | 114 | export type InjectOption = DecoratorOption & ValueOption; 115 | 116 | export enum Lifecycle { 117 | singleton = 'singleton', 118 | transient = 'transient', 119 | } 120 | export const ClassOptionSymbol = Symbol('SyringeClassOptionSymbol'); 121 | 122 | export type FormattedInjectOption = { 123 | token: UnionToken[]; 124 | contrib: Token[]; 125 | useClass: Class[]; 126 | lifecycle: Lifecycle; 127 | useDynamic: Dynamic[]; 128 | useFactory: Factory[]; 129 | useValue?: T; 130 | } & InjectOption; 131 | 132 | export const DefaultOption: Syringe.InjectOption = { 133 | lifecycle: Lifecycle.transient, 134 | }; 135 | } 136 | 137 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 138 | export namespace Utils { 139 | export function toArray(maybeArray: Syringe.MaybeArray | undefined): T[] { 140 | if (Array.isArray(maybeArray)) { 141 | return maybeArray; 142 | } 143 | if (maybeArray === undefined) { 144 | return []; 145 | } 146 | return [maybeArray]; 147 | } 148 | export function isClass( 149 | data?: string | symbol | Record, 150 | ): data is Syringe.Class { 151 | return !!(data && typeof data === 'function' && 'prototype' in data); 152 | } 153 | export function isDefinedToken( 154 | data: Record | undefined | symbol | string | number, 155 | ): data is Syringe.DefinedToken { 156 | return !!(data && typeof data === 'object' && 'symbol' in data && 'multiple' in data); 157 | } 158 | export function isInjectOption( 159 | data: Syringe.Token | Syringe.InjectOption | undefined, 160 | ): data is Syringe.InjectOption { 161 | return !!(data && typeof data === 'object' && 'token' in data); 162 | } 163 | 164 | export function isNamedToken( 165 | data: Syringe.UnionToken | undefined, 166 | ): data is Syringe.NamedToken { 167 | return !!(data && typeof data === 'object' && 'token' in data && 'named' in data); 168 | } 169 | export function isMultipleEnabled(token: Syringe.Token): boolean { 170 | return Utils.isDefinedToken(token) && token.multiple; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { Syringe } from './core'; 3 | import { singleton, transient, injectable } from './decorator'; 4 | 5 | describe('decorator', () => { 6 | it('#injectable without option', () => { 7 | @injectable() 8 | class Foo {} 9 | const option = Reflect.getMetadata(Syringe.ClassOptionSymbol, Foo); 10 | assert(option.target === Foo); 11 | }); 12 | it('#injectable with option', () => { 13 | const FooSymbol = Symbol('FooSymbol'); 14 | @injectable({ token: FooSymbol, lifecycle: Syringe.Lifecycle.singleton }) 15 | class Foo {} 16 | const option: Syringe.DecoratorOption = Reflect.getMetadata( 17 | Syringe.ClassOptionSymbol, 18 | Foo, 19 | ); 20 | assert(option.token === FooSymbol); 21 | }); 22 | it('#singleton', () => { 23 | const FooSymbol = Symbol('FooSymbol'); 24 | @singleton({ token: FooSymbol }) 25 | class Foo {} 26 | const option: Syringe.InjectOption = Reflect.getMetadata(Syringe.ClassOptionSymbol, Foo); 27 | assert(option.lifecycle === Syringe.Lifecycle.singleton); 28 | }); 29 | it('#transient', () => { 30 | const FooSymbol = Symbol('FooSymbol'); 31 | @transient({ token: FooSymbol }) 32 | class Foo {} 33 | const option: Syringe.InjectOption = Reflect.getMetadata(Syringe.ClassOptionSymbol, Foo); 34 | assert(option.lifecycle === Syringe.Lifecycle.transient); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/decorator.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import { 4 | injectable as inversInjectable, 5 | inject as inversifyInject, 6 | named as inversifyNamed, 7 | } from 'inversify'; 8 | import type { Decorator } from './core'; 9 | import { Syringe } from './core'; 10 | import { namedToIdentifier, tokenToIdentifier } from './inversify'; 11 | 12 | export function injectable(option: Syringe.DecoratorOption = {}): Decorator { 13 | const decorator = inversInjectable(); 14 | return (target: any) => { 15 | Reflect.defineMetadata(Syringe.ClassOptionSymbol, { ...option, target }, target); 16 | decorator(target); 17 | }; 18 | } 19 | 20 | export function singleton(option: Syringe.DecoratorOption = {}): Decorator { 21 | return injectable({ ...option, lifecycle: Syringe.Lifecycle.singleton }); 22 | } 23 | 24 | export function transient(option: Syringe.DecoratorOption = {}): Decorator { 25 | return injectable({ ...option, lifecycle: Syringe.Lifecycle.transient }); 26 | } 27 | 28 | export function inject( 29 | token: Syringe.Token, 30 | ): (target: any, targetKey: string, index?: number | undefined) => void { 31 | return inversifyInject(tokenToIdentifier(token)); 32 | } 33 | export function named( 34 | name: Syringe.Named, 35 | ): (target: any, targetKey: string, index?: number | undefined) => void { 36 | return inversifyNamed(namedToIdentifier(name)); 37 | } 38 | export { postConstruct, optional, unmanaged, decorate } from 'inversify'; 39 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './decorator'; 2 | export * from './container'; 3 | export * from './module'; 4 | export * from './contribution'; 5 | export * from './core'; 6 | export * from './side-option'; 7 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/inversify/index.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import 'reflect-metadata'; 4 | import assert from 'assert'; 5 | import { injectable } from 'inversify'; 6 | import { bindSingleton, bindTransient, bindLifecycle, isInversifyContext } from '.'; 7 | import { Syringe } from '../core'; 8 | import { GlobalContainer } from '../container'; 9 | import { Register } from '../register'; 10 | 11 | const fakeContainer: Syringe.Container = { 12 | register: () => {}, 13 | remove: () => {}, 14 | load: () => ({ 15 | dispose: () => {}, 16 | }), 17 | unload: () => {}, 18 | get: () => ({} as any), 19 | getNamed: () => ({} as any), 20 | getAll: () => [], 21 | getAllNamed: () => [], 22 | isBound: () => false, 23 | isBoundNamed: () => false, 24 | createChild: () => fakeContainer, 25 | }; 26 | const emptyOptions: Syringe.FormattedInjectOption = { 27 | token: [], 28 | useDynamic: [], 29 | lifecycle: Syringe.Lifecycle.singleton, 30 | useClass: [], 31 | contrib: [], 32 | useFactory: [], 33 | }; 34 | 35 | describe('inversify', () => { 36 | it('#global container', () => { 37 | assert(GlobalContainer); 38 | assert(isInversifyContext(GlobalContainer)); 39 | }); 40 | it('#bind singleton', () => { 41 | @injectable() 42 | class Foo {} 43 | bindSingleton(GlobalContainer.container.bind(Foo).toSelf()); 44 | const foo1 = GlobalContainer.get(Foo); 45 | const foo2 = GlobalContainer.get(Foo); 46 | assert(foo1 === foo2); 47 | }); 48 | it('#bind transient', () => { 49 | @injectable() 50 | class Foo {} 51 | bindTransient(GlobalContainer.container.bind(Foo).toSelf()); 52 | const foo1 = GlobalContainer.get(Foo); 53 | const foo2 = GlobalContainer.get(Foo); 54 | assert(foo1 !== foo2); 55 | }); 56 | it('#bind lifecycle', () => { 57 | @injectable() 58 | class Foo {} 59 | bindLifecycle(GlobalContainer.container.bind(Foo).to(Foo), { 60 | ...emptyOptions, 61 | lifecycle: Syringe.Lifecycle.singleton, 62 | useClass: [Foo], 63 | token: [Foo], 64 | }); 65 | const foo1 = GlobalContainer.get(Foo); 66 | const foo2 = GlobalContainer.get(Foo); 67 | assert(foo1 === foo2); 68 | }); 69 | it('#bind factory', () => { 70 | const FooFactory = Symbol('FooFactory'); 71 | @injectable() 72 | class Foo {} 73 | Register.resolveOption(GlobalContainer.container, { 74 | ...emptyOptions, 75 | token: [FooFactory], 76 | useFactory: [() => () => new Foo()], 77 | lifecycle: Syringe.Lifecycle.singleton, 78 | }); 79 | const fooFactory = GlobalContainer.get<() => Foo>(FooFactory); 80 | const foo = fooFactory(); 81 | assert(foo instanceof Foo); 82 | }); 83 | 84 | it('#bind value', () => { 85 | const Foo = Symbol('Foo'); 86 | const Bar = Symbol('Bar'); 87 | const foo = {}; 88 | Register.resolveOption(GlobalContainer.container, { 89 | ...emptyOptions, 90 | token: [Foo], 91 | useValue: foo, 92 | lifecycle: Syringe.Lifecycle.singleton, 93 | }); 94 | Register.resolveOption(GlobalContainer.container, { 95 | ...emptyOptions, 96 | token: [Bar], 97 | useValue: false, 98 | lifecycle: Syringe.Lifecycle.singleton, 99 | }); 100 | const fooValue = GlobalContainer.get(Foo); 101 | const barValue = GlobalContainer.get(Bar); 102 | assert(fooValue === foo); 103 | assert(barValue === false); 104 | }); 105 | 106 | it('#bind named value', () => { 107 | const Foo = Symbol('Foo'); 108 | const named = 'named'; 109 | const foo = {}; 110 | Register.resolveOption(GlobalContainer.container, { 111 | ...emptyOptions, 112 | token: [{ token: Foo, named }], 113 | useValue: foo, 114 | lifecycle: Syringe.Lifecycle.singleton, 115 | }); 116 | const fooValue = GlobalContainer.getNamed(Foo, named); 117 | assert(fooValue === foo); 118 | }); 119 | 120 | it('#bind named factory', () => { 121 | const FooFactory = Symbol('FooFactory'); 122 | const named = 'named'; 123 | @injectable() 124 | class Foo {} 125 | Register.resolveOption(GlobalContainer.container, { 126 | ...emptyOptions, 127 | token: [{ token: FooFactory, named }], 128 | useFactory: [() => () => new Foo()], 129 | lifecycle: Syringe.Lifecycle.singleton, 130 | }); 131 | const fooFactory = GlobalContainer.getNamed<() => Foo>(FooFactory, named); 132 | const foo = fooFactory(); 133 | assert(foo instanceof Foo); 134 | }); 135 | it('#bind dynamic', () => { 136 | const FooDynamic = Symbol('FooDynamic'); 137 | @injectable() 138 | class Foo {} 139 | Register.resolveOption(GlobalContainer.container, { 140 | ...emptyOptions, 141 | token: [FooDynamic], 142 | useDynamic: [() => new Foo()], 143 | lifecycle: Syringe.Lifecycle.singleton, 144 | }); 145 | const foo = GlobalContainer.get(FooDynamic); 146 | assert(foo instanceof Foo); 147 | }); 148 | it('#bind named dynamic', () => { 149 | const FooDynamic = Symbol('FooDynamic'); 150 | const named = 'named'; 151 | @injectable() 152 | class Foo {} 153 | Register.resolveOption(GlobalContainer.container, { 154 | ...emptyOptions, 155 | token: [{ token: FooDynamic, named }], 156 | useDynamic: [() => new Foo()], 157 | lifecycle: Syringe.Lifecycle.singleton, 158 | }); 159 | const foo = GlobalContainer.getNamed(FooDynamic, named); 160 | assert(foo instanceof Foo); 161 | }); 162 | it('#bind named', () => { 163 | @injectable() 164 | class Foo {} 165 | Register.resolveOption(GlobalContainer.container, { 166 | ...emptyOptions, 167 | token: [{ token: Foo, named: 'named' }], 168 | lifecycle: Syringe.Lifecycle.singleton, 169 | useClass: [Foo], 170 | }); 171 | const foo1 = GlobalContainer.getNamed(Foo, 'named'); 172 | const foo2 = GlobalContainer.getNamed(Foo, 'named'); 173 | assert(foo1 && foo1 === foo2); 174 | }); 175 | it('#bind', () => { 176 | @injectable() 177 | class Foo {} 178 | Register.resolveOption(GlobalContainer.container, { 179 | ...emptyOptions, 180 | lifecycle: Syringe.Lifecycle.singleton, 181 | useClass: [Foo], 182 | token: [Foo], 183 | }); 184 | const foo1 = GlobalContainer.get(Foo); 185 | const foo2 = GlobalContainer.get(Foo); 186 | assert(foo1 === foo2); 187 | }); 188 | 189 | it('#bind with error ', () => { 190 | @injectable() 191 | class Foo {} 192 | try { 193 | Register.resolveOption(fakeContainer as any, { 194 | ...emptyOptions, 195 | lifecycle: Syringe.Lifecycle.singleton, 196 | useClass: [Foo], 197 | token: [Foo], 198 | }); 199 | Register.resolveOption(fakeContainer as any, { 200 | ...emptyOptions, 201 | lifecycle: Syringe.Lifecycle.singleton, 202 | useClass: [Foo], 203 | token: [Foo], 204 | }); 205 | assert(true); 206 | } catch { 207 | assert(false); 208 | } 209 | }); 210 | }); 211 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/inversify/index.ts: -------------------------------------------------------------------------------- 1 | import type { interfaces } from 'inversify'; 2 | import { Container } from 'inversify'; 3 | import { Syringe, Utils } from '../core'; 4 | import type { InversifyRegister } from './inversify-protocol'; 5 | 6 | export function bindSingleton( 7 | toBind: interfaces.BindingInSyntax, 8 | ): interfaces.BindingWhenOnSyntax { 9 | return toBind.inSingletonScope(); 10 | } 11 | 12 | export function bindTransient( 13 | toBind: interfaces.BindingInSyntax, 14 | ): interfaces.BindingWhenOnSyntax { 15 | return toBind.inTransientScope(); 16 | } 17 | export function bindLifecycle( 18 | toBind: interfaces.BindingInSyntax, 19 | option: Syringe.FormattedInjectOption, 20 | ): interfaces.BindingWhenOnSyntax { 21 | if (option.lifecycle === Syringe.Lifecycle.singleton) { 22 | return bindSingleton(toBind); 23 | } 24 | return bindTransient(toBind); 25 | } 26 | 27 | export function bindNamed( 28 | toBind: interfaces.BindingWhenOnSyntax, 29 | named: Syringe.Named, 30 | ): void { 31 | toBind.whenTargetNamed(namedToIdentifier(named)); 32 | } 33 | 34 | export function bindGeneralToken( 35 | token: interfaces.ServiceIdentifier, 36 | register: InversifyRegister, 37 | ): interfaces.BindingToSyntax { 38 | return register.bind(tokenToIdentifier(token)); 39 | } 40 | export function bindMonoToken( 41 | token: interfaces.ServiceIdentifier, 42 | register: InversifyRegister, 43 | ): interfaces.BindingToSyntax { 44 | if (register.isBound(tokenToIdentifier(token))) { 45 | try { 46 | return register.rebind(tokenToIdentifier(token)); 47 | } catch (ex) { 48 | // not bind in crrent container 49 | return register.bind(tokenToIdentifier(token)); 50 | } 51 | } 52 | return register.bind(tokenToIdentifier(token)); 53 | } 54 | 55 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 56 | export function namedToIdentifier(named: Syringe.Named): string | symbol { 57 | if (Utils.isDefinedToken(named)) { 58 | return named.symbol; 59 | } 60 | return named; 61 | } 62 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 63 | export function tokenToIdentifier( 64 | token: Syringe.Token, 65 | ): interfaces.ServiceIdentifier { 66 | if (Utils.isDefinedToken(token)) { 67 | return token.symbol; 68 | } 69 | return token; 70 | } 71 | export const GlobalContainer = new Container(); 72 | 73 | export * from './inversify-protocol'; 74 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/inversify/inversify-protocol.ts: -------------------------------------------------------------------------------- 1 | import type { interfaces } from 'inversify'; 2 | export type InversifyContext = { 3 | container: interfaces.Container; 4 | }; 5 | export function isInversifyContext(data: Record): data is InversifyContext { 6 | return data && typeof data === 'object' && 'container' in data && 'inversify' in data; 7 | } 8 | 9 | export type InversifyRegister = { 10 | bind: interfaces.Bind; 11 | unbind: interfaces.Unbind; 12 | isBound: interfaces.IsBound; 13 | rebind: interfaces.Rebind; 14 | }; 15 | 16 | export function isInversifyRegister(data: Record): data is InversifyRegister { 17 | return ( 18 | data && 19 | typeof data === 'object' && 20 | 'bind' in data && 21 | 'unbind' in data && 22 | 'rebind' in data && 23 | 'isBound' in data 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/module/index.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { injectable, Module, GlobalContainer, Syringe, singleton, contrib, Contribution } from '..'; 3 | 4 | describe('module', () => { 5 | it('#load module', () => { 6 | @injectable() 7 | class Foo {} 8 | class Bar {} 9 | const module = Module(reg => { 10 | reg(Foo); 11 | }).register(Bar); 12 | GlobalContainer.load(module); 13 | const foo = GlobalContainer.get(Foo); 14 | assert(foo instanceof Foo); 15 | }); 16 | 17 | it('#check module', () => { 18 | @injectable() 19 | class Foo {} 20 | class Bar {} 21 | const module = Module(reg => { 22 | reg(Foo); 23 | }).register(Bar); 24 | 25 | assert(Syringe.isModule(module)); 26 | assert(!Syringe.isModule({})); 27 | }); 28 | 29 | it('#load module once', () => { 30 | @injectable() 31 | class Foo {} 32 | const module = Module(reg => { 33 | reg(Foo); 34 | }); 35 | GlobalContainer.load(module); 36 | GlobalContainer.load(module); 37 | const foo = GlobalContainer.get(Foo); 38 | assert(foo instanceof Foo); 39 | }); 40 | 41 | it('#force load module twice', () => { 42 | @injectable() 43 | class Foo {} 44 | const module = Module().register(Foo); 45 | GlobalContainer.load(module); 46 | GlobalContainer.load(module, true); 47 | try { 48 | GlobalContainer.get(Foo); 49 | } catch (ex) { 50 | assert(ex); 51 | } 52 | }); 53 | 54 | it('#register contribution in module', () => { 55 | const FooContribution = Syringe.defineToken('FooContribution'); 56 | @singleton({ contrib: FooContribution }) 57 | class Foo {} 58 | @singleton() 59 | class Bar { 60 | constructor(@contrib(FooContribution) public provider: Contribution.Provider) {} 61 | } 62 | const module = Module().contribution(FooContribution).register(Foo, Bar); 63 | GlobalContainer.load(module); 64 | const bar = GlobalContainer.get(Bar); 65 | const list = bar.provider.getContributions(); 66 | assert(list.length === 1); 67 | assert(list.find(item => item instanceof Foo)); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/module/index.ts: -------------------------------------------------------------------------------- 1 | import type { Syringe } from '../core'; 2 | import { SyringeModule } from './syringe-module'; 3 | 4 | export * from './syringe-module'; 5 | export function Module(register?: Syringe.Registry): SyringeModule { 6 | return new SyringeModule(register); 7 | } 8 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/module/syringe-module.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type { Syringe } from '../core'; 3 | import { Utils } from '../core'; 4 | import { contributionInjectOption } from '../contribution/contribution-register'; 5 | import type { interfaces } from 'inversify'; 6 | import { ContainerModule } from 'inversify'; 7 | import type { InversifyRegister } from '../inversify'; 8 | import { Register } from '../register'; 9 | 10 | type TokenOrOption = Syringe.Token | Syringe.InjectOption; 11 | 12 | export class SyringeModule implements Syringe.Module { 13 | /** 14 | * @readonly 15 | * module unique id 16 | */ 17 | readonly id: number; 18 | readonly inversifyModule: ContainerModule; 19 | 20 | protected baseRegistry?: Syringe.Registry; 21 | protected optionCollection?: (Syringe.Token | Syringe.InjectOption)[]; 22 | 23 | constructor(registry?: Syringe.Registry) { 24 | this.baseRegistry = registry; 25 | this.inversifyModule = new ContainerModule(this.inversifyRegister); 26 | this.id = this.inversifyModule.id; 27 | } 28 | protected inversifyRegister = ( 29 | bind: interfaces.Bind, 30 | unbind: interfaces.Unbind, 31 | isBound: interfaces.IsBound, 32 | rebind: interfaces.Rebind, 33 | ) => { 34 | const inversifyRegister: InversifyRegister = { 35 | bind, 36 | unbind, 37 | isBound, 38 | rebind, 39 | }; 40 | const register = ( 41 | token: Syringe.Token | Syringe.InjectOption, 42 | options: Syringe.InjectOption = {}, 43 | ): void => { 44 | if (Utils.isInjectOption(token)) { 45 | Register.resolveOption(inversifyRegister, token); 46 | } else { 47 | Register.resolveTarget(inversifyRegister, token, options); 48 | } 49 | }; 50 | if (this.baseRegistry) { 51 | this.baseRegistry(register); 52 | } 53 | if (this.optionCollection) { 54 | this.optionCollection.forEach(option => register(option)); 55 | } 56 | }; 57 | 58 | protected get options() { 59 | if (!this.optionCollection) { 60 | this.optionCollection = []; 61 | } 62 | return this.optionCollection; 63 | } 64 | register(...options: TokenOrOption[]) { 65 | options.forEach(option => this.options.push(option)); 66 | return this; 67 | } 68 | 69 | contribution(...tokens: Syringe.DefinedToken[]) { 70 | tokens.forEach(token => this.options.push(contributionInjectOption(token))); 71 | return this; 72 | } 73 | } 74 | 75 | export function isSyringeModule(data: Syringe.Module): data is SyringeModule { 76 | return data && 'inversifyModule' in data; 77 | } 78 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/register.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { injectable } from 'inversify'; 3 | import { toRegistryOption } from './register'; 4 | import { Syringe, Utils } from './core'; 5 | 6 | describe('register helper', () => { 7 | describe('option parser', () => { 8 | it('#simple class', () => { 9 | @injectable() 10 | class Foo {} 11 | const parsed = toRegistryOption({ 12 | token: Foo, 13 | useClass: Foo, 14 | }); 15 | assert(parsed.token.length === 1); 16 | assert(parsed.token.includes(Foo)); 17 | assert(parsed.useClass.length === 1); 18 | assert(parsed.useClass.includes(Foo)); 19 | assert(parsed.lifecycle === Syringe.DefaultOption.lifecycle); 20 | }); 21 | it('#simple token', () => { 22 | @injectable() 23 | class Foo {} 24 | const parsed = toRegistryOption({ 25 | token: Foo, 26 | lifecycle: Syringe.Lifecycle.singleton, 27 | }); 28 | assert(parsed.token.length === 1); 29 | assert(parsed.token.includes(Foo)); 30 | assert(parsed.lifecycle === Syringe.Lifecycle.singleton); 31 | }); 32 | it('#multiple', () => { 33 | @injectable() 34 | class Foo {} 35 | @injectable() 36 | class Bar {} 37 | const parsed = toRegistryOption({ 38 | token: Foo, 39 | useClass: [Foo, Bar], 40 | }); 41 | assert(parsed.token.length === 1); 42 | assert(parsed.token.includes(Foo)); 43 | assert(parsed.useClass.length === 2); 44 | assert(parsed.useClass.includes(Foo)); 45 | assert(parsed.useClass.includes(Bar)); 46 | }); 47 | it('#undefined value', () => { 48 | const TokenSymbol = Symbol('UndefinedToken'); 49 | const parsed = toRegistryOption({ token: TokenSymbol, useValue: undefined }); 50 | assert(parsed.token.length === 1); 51 | assert(parsed.token.includes(TokenSymbol)); 52 | assert(parsed.useValue === undefined); 53 | assert('useValue' in parsed); 54 | assert(parsed.lifecycle === Syringe.DefaultOption.lifecycle); 55 | }); 56 | }); 57 | 58 | describe('error option', () => { 59 | it('#Named token can not be service to other tokens', () => { 60 | const named = 'named'; 61 | @injectable() 62 | class Foo {} 63 | const parsed = toRegistryOption({ 64 | token: { token: Foo, named }, 65 | }); 66 | assert(parsed.token.length === 1); 67 | assert(parsed.token.find(item => Utils.isNamedToken(item))); 68 | assert(parsed.lifecycle === Syringe.DefaultOption.lifecycle); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/register.ts: -------------------------------------------------------------------------------- 1 | import type { interfaces } from 'inversify'; 2 | import type { InversifyRegister } from './inversify'; 3 | import { isInversifyRegister } from './inversify'; 4 | import { bindNamed, bindGeneralToken, bindMonoToken, bindLifecycle } from './inversify'; 5 | import { Utils, Syringe } from './core'; 6 | import { OptionSymbol } from './side-option'; 7 | import { Container } from './container'; 8 | 9 | export function toRegistryOption

( 10 | options: Syringe.InjectOption

, 11 | ): Syringe.FormattedInjectOption

{ 12 | const token = Utils.toArray(options.token); 13 | const useClass = Utils.toArray(options.useClass); 14 | const useDynamic = Utils.toArray(options.useDynamic); 15 | const useFactory = Utils.toArray(options.useFactory); 16 | const contrib = Utils.toArray(options.contrib); 17 | const lifecycle = options.lifecycle || Syringe.Lifecycle.transient; 18 | 19 | const generalOption: Syringe.FormattedInjectOption

= { 20 | token, 21 | useClass, 22 | lifecycle: contrib.length > 0 ? Syringe.Lifecycle.singleton : lifecycle, 23 | contrib, 24 | useDynamic, 25 | useFactory, 26 | }; 27 | if ('useValue' in options) { 28 | generalOption.useValue = options.useValue; 29 | } 30 | return generalOption; 31 | } 32 | export class Register { 33 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 34 | static globalConfig: Syringe.InjectOption = Syringe.DefaultOption; 35 | /** 36 | * 注册目标 token,合并 token 配置后基于配置注册 37 | */ 38 | static resolveTarget( 39 | register: InversifyRegister, 40 | target: Syringe.Token, 41 | option: Syringe.TargetOption = {}, 42 | ): void { 43 | try { 44 | try { 45 | const sideOption = Reflect.getMetadata(OptionSymbol, target); 46 | if (sideOption) { 47 | Register.resolveOption(register, sideOption); 48 | } 49 | } catch (ex) { 50 | // noop 51 | } 52 | // 当 target 为类时,将其插入 useClass 配置中 53 | if (Utils.isClass(target)) { 54 | if (!option.useClass) { 55 | option.useClass = [target]; 56 | } else { 57 | const classes = Utils.toArray(option.useClass); 58 | classes.unshift(target); 59 | option.useClass = classes; 60 | } 61 | } 62 | let mixedOption; 63 | try { 64 | mixedOption = Reflect.getMetadata(Syringe.ClassOptionSymbol, target); 65 | } catch (ex) { 66 | // noop 67 | } 68 | mixedOption = { 69 | ...(mixedOption || {}), 70 | ...option, 71 | }; 72 | if (!mixedOption.token) { 73 | mixedOption.token = [target]; 74 | } else { 75 | const tokens = Utils.toArray(mixedOption.token); 76 | tokens.unshift(target); 77 | mixedOption.token = tokens; 78 | } 79 | Register.resolveOption(register, mixedOption); 80 | } catch (ex) { 81 | // noop 82 | } 83 | } 84 | /** 85 | * 基于配置注册 86 | */ 87 | static resolveOption(iRegister: InversifyRegister, baseOption: Syringe.InjectOption): void { 88 | const parsedOption = toRegistryOption({ 89 | ...Register.globalConfig, 90 | ...baseOption, 91 | }); 92 | if ( 93 | parsedOption.useClass.length === 0 && 94 | parsedOption.useDynamic.length === 0 && 95 | parsedOption.useFactory.length === 0 && 96 | !('useValue' in parsedOption) 97 | ) { 98 | return; 99 | } 100 | 101 | parsedOption.token.forEach(token => { 102 | const register = new Register(iRegister, token, { ...parsedOption }); 103 | register.resolve(); 104 | }); 105 | } 106 | 107 | protected token: Syringe.UnionToken; 108 | protected rawToken: Syringe.Token; 109 | protected named?: Syringe.Named; 110 | /** 111 | * 兼容 inversify 112 | */ 113 | protected generalToken: interfaces.ServiceIdentifier; 114 | protected option: Syringe.FormattedInjectOption; 115 | protected register: InversifyRegister; 116 | protected mutiple: boolean; 117 | constructor( 118 | register: InversifyRegister, 119 | token: Syringe.UnionToken, 120 | option: Syringe.FormattedInjectOption, 121 | ) { 122 | this.register = register; 123 | this.token = token; 124 | this.option = option; 125 | this.rawToken = Utils.isNamedToken(token) ? token.token : token; 126 | this.named = Utils.isNamedToken(token) ? token.named : undefined; 127 | this.mutiple = !!this.named || Utils.isMultipleEnabled(this.rawToken); 128 | this.generalToken = this.rawToken; 129 | } 130 | /** 131 | * multi or mono register 132 | * mono bind 优先级 useValue > useDynamic > useFactory > useClass 133 | */ 134 | resolve(): void { 135 | const { register } = this; 136 | if (!isInversifyRegister(register)) { 137 | return; 138 | } 139 | if (this.mutiple) { 140 | this.resolveMutilple(register); 141 | } else { 142 | this.resolveMono(register); 143 | if (!this.named && this.option.contrib.length > 0) { 144 | this.option.contrib.forEach(contribution => { 145 | if (Utils.isMultipleEnabled(contribution)) { 146 | bindGeneralToken(contribution, register).toService(this.generalToken); 147 | } else { 148 | bindMonoToken(contribution, register).toService(this.generalToken); 149 | } 150 | }); 151 | } 152 | } 153 | } 154 | // eslint-disable-next-line consistent-return 155 | protected resolveMono( 156 | register: InversifyRegister, 157 | ): interfaces.BindingWhenOnSyntax | undefined { 158 | if ('useValue' in this.option) { 159 | return bindMonoToken(this.generalToken, register).toConstantValue(this.option.useValue!); 160 | } 161 | if (this.option.useDynamic.length > 0) { 162 | const dynamic = this.option.useDynamic[this.option.useDynamic.length - 1]; 163 | return bindLifecycle( 164 | bindMonoToken(this.generalToken, register).toDynamicValue(ctx => { 165 | const container = Container.getContainer(ctx.container)!; 166 | return dynamic({ container }); 167 | }), 168 | this.option, 169 | ); 170 | } 171 | if (this.option.useFactory.length > 0) { 172 | const factrory = this.option.useFactory[this.option.useFactory.length - 1]; 173 | return bindMonoToken(this.generalToken, register).toFactory(ctx => { 174 | const container = Container.getContainer(ctx.container)!; 175 | return factrory({ container }); 176 | }); 177 | } 178 | if (this.option.useClass.length > 0) { 179 | const newable = this.option.useClass[this.option.useClass.length - 1]; 180 | return bindLifecycle(bindMonoToken(this.generalToken, register).to(newable), this.option); 181 | } 182 | } 183 | protected resolveMutilple(register: InversifyRegister): void { 184 | const classesList = this.option.useClass.map(newable => 185 | bindLifecycle(bindGeneralToken(this.generalToken, register).to(newable), this.option), 186 | ); 187 | const dynamicList = this.option.useDynamic.map(dynamic => 188 | bindLifecycle( 189 | bindGeneralToken(this.generalToken, register).toDynamicValue(ctx => { 190 | const container = Container.getContainer(ctx.container)!; 191 | return dynamic({ container }); 192 | }), 193 | this.option, 194 | ), 195 | ); 196 | const factoryList = this.option.useFactory.map(factrory => 197 | bindGeneralToken(this.generalToken, register).toFactory(ctx => { 198 | const container = Container.getContainer(ctx.container)!; 199 | return factrory({ container }); 200 | }), 201 | ); 202 | const valueToBind = 203 | 'useValue' in this.option 204 | ? bindGeneralToken(this.generalToken, register).toConstantValue(this.option.useValue!) 205 | : undefined; 206 | if (this.named) { 207 | classesList.forEach(tobind => this.named && bindNamed(tobind, this.named)); 208 | dynamicList.forEach(tobind => this.named && bindNamed(tobind, this.named)); 209 | factoryList.forEach(tobind => this.named && bindNamed(tobind, this.named)); 210 | if (valueToBind) { 211 | bindNamed(valueToBind, this.named); 212 | } 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/side-option.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { register, GlobalContainer } from './container'; 3 | import { singleton } from './decorator'; 4 | import { registerSideOption } from './side-option'; 5 | 6 | describe('side option', () => { 7 | it('#side register', () => { 8 | const side = (target: any) => { 9 | registerSideOption({ token: 'side', useValue: true }, target); 10 | }; 11 | @side 12 | @singleton() 13 | class Foo {} 14 | register(Foo); 15 | const foo = GlobalContainer.get(Foo); 16 | const sideValue = GlobalContainer.get('side'); 17 | assert(foo instanceof Foo && sideValue === true); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /packages/mana-syringe/src/side-option.ts: -------------------------------------------------------------------------------- 1 | import type { Syringe } from './core'; 2 | 3 | export const OptionSymbol = Symbol('SyringeOptionSymbol'); 4 | export const registerSideOption = = any, R = any>( 5 | option: Syringe.InjectOption, 6 | target: T, 7 | ) => { 8 | Reflect.defineMetadata(OptionSymbol, option, target); 9 | }; 10 | -------------------------------------------------------------------------------- /packages/mana-syringe/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "./src" 5 | }, 6 | "include": ["src/**/*"] 7 | } 8 | -------------------------------------------------------------------------------- /test/observable/index.spec.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import 'reflect-metadata'; 4 | import React, { useEffect } from 'react'; 5 | import assert from 'assert'; 6 | import { useObserve, observable, useObservableState, prop } from 'mana-observable'; 7 | import renderer, { act } from 'react-test-renderer'; 8 | 9 | describe('use', () => { 10 | it('#useObserve basic ', done => { 11 | class Foo { 12 | @prop() info: number = 0; 13 | } 14 | const SINGLETON_FOO = new Foo(); 15 | const FooRender = () => { 16 | const foo = useObserve(SINGLETON_FOO); 17 | return

{foo && foo.info}
; 18 | }; 19 | let component: renderer.ReactTestRenderer; 20 | act(() => { 21 | component = renderer.create( 22 | <> 23 | 24 | , 25 | ); 26 | 27 | const json = component.toJSON(); 28 | assert(json === null); 29 | }); 30 | act(() => { 31 | SINGLETON_FOO.info = 1; 32 | }); 33 | act(() => { 34 | const json = component.toJSON(); 35 | assert(!(json instanceof Array) && json && json.children?.find(item => item === '1')); 36 | done(); 37 | }); 38 | }); 39 | it('#useObserve array', done => { 40 | class Foo { 41 | @prop() list: number[] = []; 42 | } 43 | const foo = new Foo(); 44 | let renderTimes = 0; 45 | const FooRender = () => { 46 | const f = useObserve(foo); 47 | renderTimes += 1; 48 | return
{f.list.length}
; 49 | }; 50 | let component: renderer.ReactTestRenderer; 51 | act(() => { 52 | component = renderer.create( 53 | <> 54 | 55 | , 56 | ); 57 | const json = component.toJSON(); 58 | assert(json === null); 59 | }); 60 | act(() => { 61 | for (let index = 0; index < 100; index++) { 62 | foo.list.push(index); 63 | } 64 | }); 65 | act(() => { 66 | assert(renderTimes < 25); 67 | done(); 68 | }); 69 | }); 70 | it('#useObserve deep array ', done => { 71 | class Foo { 72 | @prop() info = ''; 73 | } 74 | class Bar { 75 | @prop() list: Foo[] = []; 76 | } 77 | const SINGLETON_BAR = new Bar(); 78 | const foo = new Foo(); 79 | SINGLETON_BAR.list.push(foo); 80 | const FooRender = () => { 81 | const bar = useObserve(SINGLETON_BAR); 82 | return
{bar.list.filter(item => item.info.length > 0).length}
; 83 | }; 84 | let component: renderer.ReactTestRenderer; 85 | act(() => { 86 | component = renderer.create( 87 | <> 88 | 89 | , 90 | ); 91 | 92 | const json = component.toJSON(); 93 | assert(json === null); 94 | }); 95 | act(() => { 96 | foo.info = 'a'; 97 | }); 98 | act(() => { 99 | const json = component.toJSON(); 100 | assert(!(json instanceof Array) && json && json.children?.find(item => item === '1')); 101 | done(); 102 | }); 103 | }); 104 | 105 | it('#useObserve reactable array', done => { 106 | const ARR: any[] = observable([]); 107 | const Render = () => { 108 | const arr = useObserve(ARR); 109 | const arr1 = useObservableState([]); 110 | useEffect(() => { 111 | arr.push('effect'); 112 | arr1.push('effect1'); 113 | }, [arr, arr1]); 114 | return ( 115 |
116 | {JSON.stringify(arr)} 117 | {arr1[0]} 118 | {arr.length} 119 |
120 | ); 121 | }; 122 | let component: renderer.ReactTestRenderer; 123 | act(() => { 124 | component = renderer.create( 125 | <> 126 | 127 | , 128 | ); 129 | const json = component.toJSON(); 130 | assert(json === null); 131 | }); 132 | act(() => { 133 | ARR.push('a'); 134 | }); 135 | act(() => { 136 | const json = component.toJSON(); 137 | assert( 138 | !(json instanceof Array) && 139 | json && 140 | json.children?.includes('2') && 141 | json.children?.includes('effect1'), 142 | ); 143 | done(); 144 | }); 145 | }); 146 | 147 | it('#useObserve deep arr', done => { 148 | class Bar { 149 | @prop() name: string = ''; 150 | } 151 | class Foo { 152 | @prop() arr: Bar[] = []; 153 | } 154 | const foo = new Foo(); 155 | const Render = () => { 156 | const trackableFoo = useObserve(foo); 157 | useEffect(() => { 158 | trackableFoo.arr.push(new Bar()); 159 | trackableFoo.arr.push(new Bar()); 160 | }, [trackableFoo]); 161 | 162 | return
{trackableFoo.arr.map(item => item.name)}
; 163 | }; 164 | let component: renderer.ReactTestRenderer; 165 | act(() => { 166 | component = renderer.create( 167 | <> 168 | 169 | , 170 | ); 171 | const json = component.toJSON(); 172 | assert(json === null); 173 | }); 174 | act(() => { 175 | foo.arr[0] && (foo.arr[0].name = 'a'); 176 | }); 177 | act(() => { 178 | const json = component.toJSON(); 179 | assert(!(json instanceof Array) && json && json.children?.includes('a')); 180 | done(); 181 | }); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "jsx": "react" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "noEmitOnError": false, 5 | "target": "esnext", 6 | "module": "esnext", 7 | "lib": ["ESNext", "DOM"], 8 | "allowJs": true, 9 | "jsx": "preserve", 10 | "outDir": "lib", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "noImplicitThis": true, 15 | "noUnusedLocals": true, 16 | "moduleResolution": "node", 17 | "esModuleInterop": true, 18 | "experimentalDecorators": true, 19 | "emitDecoratorMetadata": true, 20 | "importHelpers": true, 21 | "declaration": true, 22 | "declarationMap": true, 23 | "sourceMap": true, 24 | "skipLibCheck": true, 25 | "resolveJsonModule": true, 26 | "types": ["reflect-metadata", "jest", "node"] 27 | } 28 | } 29 | --------------------------------------------------------------------------------