├── tslint.json ├── .travis.yml ├── src ├── index.ts ├── use-setup.ts ├── util.ts └── reactive.ts ├── .gitignore ├── .editorconfig ├── tsconfig.json ├── LICENSE ├── tools └── semantic-release-prepare.ts ├── test ├── use-setup.test.tsx └── reactive.test.ts ├── README.md └── package.json /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-standard", 4 | "tslint-config-prettier" 5 | ] 6 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10.16.0" 4 | install: 5 | - npm install 6 | script: 7 | - npm run test -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import useSetup from './use-setup' 2 | 3 | export { useSetup } 4 | export { reactive, getState, remove } from './reactive' 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | .DS_Store 5 | *.log 6 | .vscode 7 | .idea 8 | dist 9 | compiled 10 | .awcache 11 | .rpt2_cache 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | #root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | max_line_length = 100 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/use-setup.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { reactive, watch, getState } from './reactive' 3 | 4 | const { useState, useLayoutEffect, useMemo } = React 5 | 6 | export default function useSetup(setup: () => T) { 7 | let state$ = useMemo(() => reactive(setup()), []) 8 | let state = useReactive(state$) 9 | return state 10 | } 11 | 12 | const useReactive = (state$: T): T => { 13 | let [state, setState] = useState(() => getState(state$)) 14 | useLayoutEffect(() => watch(state$, setState), []) 15 | return state 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es5", 5 | "module":"es2015", 6 | "lib": ["es2015", "es2016", "es2017", "dom", "esnext"], 7 | "jsx": "react", 8 | "strict": false, 9 | "sourceMap": true, 10 | "declaration": true, 11 | "esModuleInterop": true, 12 | "downlevelIteration": true, 13 | "allowSyntheticDefaultImports": true, 14 | "experimentalDecorators": true, 15 | "emitDecoratorMetadata": true, 16 | "resolveJsonModule": true, 17 | "outDir": "dist", 18 | "typeRoots": [ 19 | "node_modules/@types" 20 | ] 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 工业聚 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 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export const isArray = Array.isArray 2 | 3 | export const isFunction = (input: any) => typeof input === 'function' 4 | 5 | export const isObject = (obj: any) => { 6 | if (typeof obj !== 'object' || obj === null) return false 7 | 8 | let proto = obj 9 | while (Object.getPrototypeOf(proto) !== null) { 10 | proto = Object.getPrototypeOf(proto) 11 | } 12 | 13 | return Object.getPrototypeOf(obj) === proto 14 | } 15 | 16 | export const merge = (target: object | Array, source: object | Array) => { 17 | if (isArray(source) && isArray(target)) { 18 | for (let i = 0; i < source.length; i++) { 19 | target[i] = source[i] 20 | } 21 | 22 | return target 23 | } 24 | 25 | if (isObject(source) && isObject(target)) { 26 | for (let key in source) { 27 | let descriptor = Object.getOwnPropertyDescriptor(source, key) 28 | 29 | // normal value 30 | if (descriptor.hasOwnProperty('value')) { 31 | target[key] = descriptor.value 32 | } else { 33 | // accessor 34 | Object.defineProperty(target, key, descriptor) 35 | } 36 | } 37 | 38 | return target 39 | } 40 | 41 | throw new Error(`target and source are not the same type of object or array, ${target} ${source}`) 42 | } 43 | 44 | interface Deferred { 45 | promise: Promise 46 | resolve: (value?: T) => void 47 | reject: (reason?: any) => void 48 | } 49 | 50 | const noop = () => {} 51 | 52 | export const createDeferred = (): Deferred => { 53 | let resolve: Deferred['resolve'] = noop 54 | let reject: Deferred['reject'] = noop 55 | let promise: Promise = new Promise((a, b) => { 56 | resolve = a 57 | reject = b 58 | }) 59 | return { resolve, reject, promise } 60 | } 61 | -------------------------------------------------------------------------------- /tools/semantic-release-prepare.ts: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const { fork } = require("child_process") 3 | const colors = require("colors") 4 | 5 | const { readFileSync, writeFileSync } = require("fs") 6 | const pkg = JSON.parse( 7 | readFileSync(path.resolve(__dirname, "..", "package.json")) 8 | ) 9 | 10 | pkg.scripts.prepush = "npm run test:prod && npm run build" 11 | pkg.scripts.commitmsg = "commitlint -E HUSKY_GIT_PARAMS" 12 | 13 | writeFileSync( 14 | path.resolve(__dirname, "..", "package.json"), 15 | JSON.stringify(pkg, null, 2) 16 | ) 17 | 18 | // Call husky to set up the hooks 19 | fork(path.resolve(__dirname, "..", "node_modules", "husky", "lib", "installer", 'bin'), ['install']) 20 | 21 | console.log() 22 | console.log(colors.green("Done!!")) 23 | console.log() 24 | 25 | if (pkg.repository.url.trim()) { 26 | console.log(colors.cyan("Now run:")) 27 | console.log(colors.cyan(" npm install -g semantic-release-cli")) 28 | console.log(colors.cyan(" semantic-release-cli setup")) 29 | console.log() 30 | console.log( 31 | colors.cyan('Important! Answer NO to "Generate travis.yml" question') 32 | ) 33 | console.log() 34 | console.log( 35 | colors.gray( 36 | 'Note: Make sure "repository.url" in your package.json is correct before' 37 | ) 38 | ) 39 | } else { 40 | console.log( 41 | colors.red( 42 | 'First you need to set the "repository.url" property in package.json' 43 | ) 44 | ) 45 | console.log(colors.cyan("Then run:")) 46 | console.log(colors.cyan(" npm install -g semantic-release-cli")) 47 | console.log(colors.cyan(" semantic-release-cli setup")) 48 | console.log() 49 | console.log( 50 | colors.cyan('Important! Answer NO to "Generate travis.yml" question') 51 | ) 52 | } 53 | 54 | console.log() 55 | -------------------------------------------------------------------------------- /test/use-setup.test.tsx: -------------------------------------------------------------------------------- 1 | import 'jest' 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import { act } from 'react-dom/test-utils' 5 | import { reactive, useSetup } from '../src' 6 | 7 | const delay = (timeout = 0) => new Promise(resolve => setTimeout(resolve, timeout)) 8 | 9 | describe('useBistate', () => { 10 | let container 11 | 12 | beforeEach(() => { 13 | container = document.createElement('div') 14 | document.body.appendChild(container) 15 | }) 16 | 17 | afterEach(() => { 18 | document.body.removeChild(container) 19 | container = null 20 | }) 21 | 22 | it('basic usage', async () => { 23 | let setupTest = (initialValue = 0) => { 24 | let count = reactive({ value: initialValue }) 25 | 26 | let incre = () => { 27 | count.value += 1 28 | } 29 | 30 | let decre = () => { 31 | count.value -= 1 32 | } 33 | 34 | return { 35 | count, 36 | incre, 37 | decre 38 | } 39 | } 40 | 41 | let Test = props => { 42 | let { count, incre, decre } = useSetup(() => setupTest(props.count)) 43 | return ( 44 | 47 | ) 48 | } 49 | 50 | // tslint:disable-next-line: await-promise 51 | await act(async () => { 52 | ReactDOM.render(, container) 53 | await delay() 54 | }) 55 | 56 | let button = container.querySelector('button') 57 | 58 | expect(button.textContent).toBe('10') 59 | 60 | // tslint:disable-next-line: await-promise 61 | await act(async () => { 62 | button.dispatchEvent(new MouseEvent('click', { bubbles: true })) 63 | await delay() 64 | }) 65 | 66 | expect(button.textContent).toBe('11') 67 | 68 | // tslint:disable-next-line: await-promise 69 | await act(async () => { 70 | button.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })) 71 | await delay() 72 | }) 73 | 74 | expect(button.textContent).toBe('10') 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to react-use-setup 👋 2 | 3 | [![npm version](https://img.shields.io/npm/v/react-use-setup.svg?style=flat)](https://www.npmjs.com/package/react-use-setup) 4 | [![Build Status](https://travis-ci.org/Lucifier129/react-use-setup.svg?branch=master)](https://travis-ci.org/Lucifier129/react-use-setup) 5 | [![Documentation](https://img.shields.io/badge/documentation-yes-brightgreen.svg)](https://github.com/Lucifier129/react-use-setup#readme) 6 | [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/Lucifier129/react-use-setup/graphs/commit-activity) 7 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/Lucifier129/react-use-setup/blob/master/LICENSE) 8 | [![Twitter: guyingjie129](https://img.shields.io/twitter/follow/guyingjie129.svg?style=social)](https://twitter.com/guyingjie129) 9 | 10 | > Implement the mechanism of Vue 3.0 Composition API for React based on React Hooks 11 | 12 | ### 🏠 [Homepage](https://github.com/Lucifier129/react-use-setup#readme) 13 | 14 | ## Features 15 | 16 | TODO 17 | 18 | ## Environment Requirement 19 | 20 | - ES2015 Proxy 21 | - ES0215 Map 22 | - ES2015 Symbol 23 | 24 | [Can I Use Proxy?](https://caniuse.com/#search=Proxy) 25 | 26 | ## Install 27 | 28 | ```sh 29 | npm install --save react-use-setup 30 | ``` 31 | 32 | ```sh 33 | yarn add react-use-setup 34 | ``` 35 | 36 | ## Usage 37 | 38 | - Counter Examples 39 | 40 | ```javascript 41 | import React from 'react' 42 | import { reactive, useSetup } from 'react-use-setup' 43 | 44 | let setupCounter = (initialValue = 0) => { 45 | /** 46 | * setup function is the mutable world 47 | */ 48 | let count = reactive({ value: initialValue }) 49 | let incre = () => (count.value += 1) 50 | let decre = () => (count.value -= 1) 51 | 52 | // expose the reactive state and update functions 53 | return { 54 | count, 55 | incre, 56 | decre 57 | } 58 | } 59 | 60 | let Counter = props => { 61 | // react component is the immutable world 62 | // every time the reactive state is mutated 63 | // it will emit an immutable state for react component to comsume 64 | let { count, incre, decre } = useSetup(() => setupCounter(props.count)) 65 | 66 | return ( 67 | 70 | ) 71 | } 72 | ``` 73 | 74 | ## Author 75 | 76 | 👤 **Jade Gu** 77 | 78 | - Twitter: [@guyingjie129](https://twitter.com/guyingjie129) 79 | - Github: [@Lucifier129](https://github.com/Lucifier129) 80 | 81 | ## 🤝 Contributing 82 | 83 | Contributions, issues and feature requests are welcome! 84 | 85 | Feel free to check [issues page](https://github.com/Lucifier129/react-use-setup/issues). 86 | 87 | ## Show your support 88 | 89 | Give a ⭐️ if this project helped you! 90 | 91 | ## 📝 License 92 | 93 | Copyright © 2019 [Jade Gu](https://github.com/Lucifier129). 94 | 95 | This project is [MIT](https://github.com/Lucifier129/react-use-setup/blob/master/LICENSE) licensed. 96 | 97 | --- 98 | 99 | _This README was generated with ❤️ by [readme-md-generator](https://github.com/kefranabg/readme-md-generator)_ 100 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-use-setup", 3 | "version": "1.0.0", 4 | "description": "Implement the mechanism of Vue 3.0 Composition API for React based on React Hooks", 5 | "main": "dist/index.js", 6 | "files": [ 7 | "dist" 8 | ], 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/Lucifier129/react-use-setup.git" 12 | }, 13 | "keywords": [ 14 | "React", 15 | "React Hooks", 16 | "Vue Composition API" 17 | ], 18 | "author": "Jade Gu", 19 | "engines": { 20 | "node": ">=8.0.0" 21 | }, 22 | "scripts": { 23 | "lint": "tslint --project tsconfig.json -t codeFrame 'src/**/*.ts' 'test/**/*.ts'", 24 | "prebuild": "rimraf dist", 25 | "build": "tsc --module commonjs", 26 | "test": "jest", 27 | "test:coverage": "jest --coverage", 28 | "test:watch": "jest --coverage --watch", 29 | "test:prod": "npm run lint && npm run test -- --no-cache", 30 | "report-coverage": "cat ./coverage/lcov.info | coveralls", 31 | "commit": "git-cz", 32 | "semantic-release": "semantic-release", 33 | "semantic-release-prepare": "ts-node tools/semantic-release-prepare", 34 | "precommit": "lint-staged", 35 | "travis-deploy-once": "travis-deploy-once", 36 | "prepare": "npm run test && npm run build" 37 | }, 38 | "lint-staged": { 39 | "{src,test}/**/*.ts": [ 40 | "prettier --write", 41 | "git add" 42 | ] 43 | }, 44 | "config": { 45 | "commitizen": { 46 | "path": "node_modules/cz-conventional-changelog" 47 | } 48 | }, 49 | "jest": { 50 | "transform": { 51 | ".(ts|tsx)": "ts-jest" 52 | }, 53 | "testEnvironment": "jsdom", 54 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 55 | "moduleFileExtensions": [ 56 | "ts", 57 | "tsx", 58 | "js" 59 | ], 60 | "coveragePathIgnorePatterns": [ 61 | "/node_modules/", 62 | "/test/" 63 | ], 64 | "coverageThreshold": { 65 | "global": { 66 | "branches": 90, 67 | "functions": 95, 68 | "lines": 95, 69 | "statements": 95 70 | } 71 | }, 72 | "collectCoverageFrom": [ 73 | "src/*.{js,ts}" 74 | ] 75 | }, 76 | "prettier": { 77 | "semi": false, 78 | "singleQuote": true 79 | }, 80 | "commitlint": { 81 | "extends": [ 82 | "@commitlint/config-conventional" 83 | ] 84 | }, 85 | "peerDependencies": { 86 | "react": "^16.8.6", 87 | "react-dom": "^16.8.6" 88 | }, 89 | "devDependencies": { 90 | "@commitlint/cli": "^7.1.2", 91 | "@commitlint/config-conventional": "^7.1.2", 92 | "@types/jest": "^23.3.2", 93 | "@types/node": "^10.11.0", 94 | "@types/react": "^16.8.23", 95 | "@types/react-dom": "^16.8.5", 96 | "colors": "^1.3.2", 97 | "commitizen": "^3.0.0", 98 | "coveralls": "^3.0.2", 99 | "cross-env": "^5.2.0", 100 | "cz-conventional-changelog": "^2.1.0", 101 | "husky": "^1.0.1", 102 | "jest": "^23.6.0", 103 | "jest-config": "^23.6.0", 104 | "lint-staged": "^8.0.0", 105 | "lodash.camelcase": "^4.3.0", 106 | "prettier": "^1.14.3", 107 | "prompt": "^1.0.0", 108 | "replace-in-file": "^3.4.2", 109 | "rimraf": "^2.6.2", 110 | "react": "^16.8.6", 111 | "react-dom": "^16.8.6", 112 | "semantic-release": "^15.9.16", 113 | "shelljs": "^0.8.3", 114 | "travis-deploy-once": "^5.0.9", 115 | "ts-jest": "^23.10.2", 116 | "ts-node": "^8.3.0", 117 | "tslint": "^5.11.0", 118 | "tslint-config-prettier": "^1.15.0", 119 | "tslint-config-standard": "^8.0.1", 120 | "typescript": "^3.5.3" 121 | }, 122 | "license": "MIT", 123 | "bugs": { 124 | "url": "https://github.com/Lucifier129/react-use-setup/issues" 125 | }, 126 | "homepage": "https://github.com/Lucifier129/react-use-setup#readme" 127 | } 128 | -------------------------------------------------------------------------------- /src/reactive.ts: -------------------------------------------------------------------------------- 1 | import { isArray, isObject, merge, createDeferred } from './util' 2 | 3 | const INTERNAL = Symbol('INTERNAL') 4 | 5 | export const isReactive = (input: any): boolean => { 6 | return !!(input && input[INTERNAL]) 7 | } 8 | 9 | export const getState = (input: T): T => { 10 | if (!isReactive(input)) { 11 | throw new Error(`Expect ${input} to be reactive`) 12 | } 13 | return input[INTERNAL].compute() 14 | } 15 | 16 | const createImmutable = (state$: T) => { 17 | let isArrayType = isArray(state$) 18 | let immutableTarget = (isArrayType ? [] : {}) as T 19 | let isDirty = false 20 | 21 | let mark = () => { 22 | isDirty = true 23 | } 24 | 25 | let computeArray = () => { 26 | immutableTarget = [] as T 27 | 28 | for (let i = 0; i < (state$ as any[]).length; i++) { 29 | let item = state$[i] 30 | 31 | if (isReactive(item)) { 32 | immutableTarget[i] = getState(item) 33 | } else { 34 | immutableTarget[i] = item 35 | } 36 | } 37 | } 38 | 39 | let computeObject = () => { 40 | immutableTarget = {} as T 41 | 42 | for (let key in state$) { 43 | let value = state$[key as string] 44 | 45 | if (isReactive(value)) { 46 | immutableTarget[key as string] = getState(value) 47 | } else { 48 | immutableTarget[key as string] = value 49 | } 50 | } 51 | } 52 | 53 | let compute = () => { 54 | if (!isDirty) return immutableTarget 55 | 56 | isDirty = false 57 | 58 | if (isArrayType) { 59 | computeArray() 60 | } else { 61 | computeObject() 62 | } 63 | 64 | return immutableTarget 65 | } 66 | 67 | return { 68 | mark, 69 | compute 70 | } 71 | } 72 | 73 | export const reactive = (state: T): T => { 74 | if (!isObject(state) && !isArray(state)) { 75 | let message = `Expect state to be array or object, instead of ${state}` 76 | throw new Error(message) 77 | } 78 | 79 | // return unconnected state 80 | if (isReactive(state) && !state[INTERNAL].isConnected()) { 81 | return state 82 | } 83 | 84 | let isArrayType = isArray(state) 85 | 86 | let target = isArrayType ? [] : {} 87 | 88 | let connection = { 89 | parent: null, 90 | key: null 91 | } 92 | 93 | let connect = (parent, key) => { 94 | connection.parent = parent 95 | connection.key = key 96 | } 97 | 98 | let disconnect = () => { 99 | connection.parent = null 100 | connection.key = null 101 | } 102 | 103 | let isConnected = () => { 104 | return !!connection.parent 105 | } 106 | 107 | let remove = () => { 108 | if (!connection.parent) return false 109 | 110 | let { parent, key } = connection 111 | 112 | if (isArray(parent)) { 113 | let index = parent.indexOf(state$) 114 | parent.splice(index, 1) 115 | } else { 116 | delete parent[key] 117 | } 118 | 119 | return true 120 | } 121 | 122 | let uid = 0 123 | let consuming = false 124 | let deferred = createDeferred() 125 | 126 | let doResolve = (n: number) => { 127 | if (n !== uid) return 128 | deferred.resolve(getState(state$)) 129 | deferred = createDeferred() 130 | consuming = false 131 | } 132 | 133 | let notify = () => { 134 | immutable.mark() 135 | 136 | if (consuming) { 137 | // tslint:disable-next-line: no-floating-promises 138 | Promise.resolve(++uid).then(doResolve) // debounced by promise 139 | } 140 | 141 | if (connection.parent) { 142 | connection.parent[INTERNAL].notify() 143 | } 144 | } 145 | 146 | let handlers: ProxyHandler = { 147 | get(target, key, receiver) { 148 | if (key === INTERNAL) return internal 149 | 150 | return Reflect.get(target, key, receiver) 151 | }, 152 | 153 | set(target, key, value, receiver) { 154 | let prevValue = target[key] 155 | 156 | if (prevValue === value) return true 157 | 158 | if (typeof key === 'symbol') { 159 | return Reflect.set(target, key, value, receiver) 160 | } 161 | 162 | if (isArrayType && key === 'length' && value < (target as any[]).length) { 163 | // disconnect coitem when reduce array.length 164 | for (let i = value; i < (target as any[]).length; i++) { 165 | let item = target[i] 166 | if (isReactive(item)) { 167 | item[INTERNAL].disconnect() 168 | } 169 | } 170 | } 171 | 172 | // connect current value 173 | if (isObject(value) || isArray(value)) { 174 | value = reactive(value) 175 | value[INTERNAL].connect(state$, key) 176 | } 177 | 178 | // disconnect previous value 179 | if (isReactive(prevValue)) { 180 | prevValue[INTERNAL].disconnect() 181 | } 182 | 183 | Reflect.set(target, key, value, receiver) 184 | 185 | notify() 186 | 187 | return true 188 | }, 189 | 190 | deleteProperty(target, key) { 191 | if (typeof key === 'symbol') { 192 | return Reflect.deleteProperty(target, key) 193 | } 194 | 195 | let value = target[key] 196 | 197 | if (isReactive(value)) { 198 | value[INTERNAL].disconnect() 199 | } 200 | 201 | Reflect.deleteProperty(target, key) 202 | 203 | notify() 204 | 205 | return true 206 | } 207 | } 208 | 209 | let state$ = new Proxy(target, handlers) as T 210 | let immutable = createImmutable(state$) 211 | let internal = { 212 | compute: immutable.compute, 213 | connect, 214 | disconnect, 215 | isConnected, 216 | notify, 217 | remove, 218 | get promise() { 219 | consuming = true 220 | return deferred.promise 221 | } 222 | } 223 | 224 | merge(state$, state) 225 | 226 | return state$ 227 | } 228 | 229 | export type Unwatch = () => void 230 | export type Watcher = (state: T) => void 231 | 232 | export const watch = (state$: T, watcher: Watcher): Unwatch => { 233 | if (!isReactive(state$)) { 234 | throw new Error(`Expected reactive state, but received ${state$}`) 235 | } 236 | 237 | if (typeof watcher !== 'function') { 238 | throw new Error(`Expected watcher to be a function, instead of ${watcher}`) 239 | } 240 | 241 | let unwatched = false 242 | 243 | let consume = state => { 244 | if (unwatched) return 245 | watcher(state) 246 | f() 247 | } 248 | 249 | let f = () => { 250 | if (unwatched) return 251 | state$[INTERNAL].promise.then(consume) 252 | } 253 | 254 | f() 255 | 256 | return () => { 257 | unwatched = true 258 | } 259 | } 260 | 261 | export const remove = (state$: T): boolean => { 262 | if (!isReactive(state$)) { 263 | throw new Error(`Expected reactive state, but got ${state$}`) 264 | } 265 | return state$[INTERNAL].remove() 266 | } 267 | -------------------------------------------------------------------------------- /test/reactive.test.ts: -------------------------------------------------------------------------------- 1 | import 'jest' 2 | import { reactive, getState, isReactive, watch, remove } from '../src/reactive' 3 | 4 | const delay = (timeout = 0) => new Promise(resolve => setTimeout(resolve, timeout)) 5 | 6 | describe('reactive', () => { 7 | it('can be watched and unwatched', done => { 8 | let state$ = reactive({ count: 0 }) 9 | 10 | let i = 0 11 | 12 | let unwatch = watch(state$, ({ count }) => { 13 | expect(count).toBe(i) 14 | if (count >= 2) { 15 | if (count > 2) throw new Error('unwatch failed') 16 | unwatch() 17 | done() 18 | } 19 | }) 20 | 21 | let timer 22 | let provider = () => { 23 | timer = setInterval(() => { 24 | state$.count = ++i 25 | if (i >= 4) clearInterval(timer) 26 | }, 10) 27 | } 28 | 29 | // tslint:disable-next-line: no-floating-promises 30 | provider() 31 | }) 32 | 33 | it('should throw error when watch target is not reactive or watcher is not a functionh', () => { 34 | expect(() => { 35 | watch({} as any, () => {}) 36 | }).toThrow() 37 | 38 | expect(() => { 39 | watch(reactive({}), 1 as any) 40 | }).toThrow() 41 | }) 42 | 43 | it('works correctly with object', async done => { 44 | let state$ = reactive({ count: 0 }) 45 | 46 | let count = 0 47 | let unwatch = watch(state$, state => { 48 | count += 1 49 | expect(state.count).toEqual(count) 50 | if (count >= 10) { 51 | unwatch() 52 | done() 53 | } 54 | }) 55 | 56 | for (let i = 0; i < 10; i++) { 57 | await delay() 58 | state$.count += 1 59 | } 60 | }) 61 | 62 | it('works correctly with array', async done => { 63 | let list$ = reactive([] as number[]) 64 | 65 | let count = 0 66 | let unwatch = watch(list$, list => { 67 | count += 1 68 | for (let i = 0; i < count; i++) { 69 | expect(list[i]).toBe(i) 70 | } 71 | 72 | if (count >= 10) { 73 | unwatch() 74 | done() 75 | return 76 | } 77 | }) 78 | 79 | for (let i = 0; i < 10; i++) { 80 | await delay() 81 | list$.push(i) 82 | } 83 | }) 84 | 85 | it('works correctly with nest structure', async done => { 86 | let state$ = reactive({ counts: [] as number[] }) 87 | 88 | let count = 0 89 | 90 | let unwatch = watch(state$, state => { 91 | count += 1 92 | 93 | for (let i = 0; i < count; i++) { 94 | expect(state.counts[i]).toBe(i) 95 | } 96 | 97 | if (count >= 10) { 98 | unwatch() 99 | done() 100 | return 101 | } 102 | }) 103 | 104 | for (let i = 0; i < 10; i++) { 105 | await delay() 106 | state$.counts.push(i) 107 | } 108 | }) 109 | 110 | it('can detect delete object property', done => { 111 | let state$ = reactive({ a: 1, b: 2 }) 112 | 113 | watch(state$, state => { 114 | expect(state.hasOwnProperty('b')).toBe(false) 115 | done() 116 | }) 117 | 118 | expect(state$.hasOwnProperty('b')).toBe(true) 119 | 120 | delete state$.b 121 | 122 | expect(state$.hasOwnProperty('b')).toBe(false) 123 | }) 124 | 125 | it('can detect add object property', done => { 126 | let state$ = reactive<{ a: number; b: number; c?: number }>({ a: 1, b: 2 }) 127 | 128 | watch(state$, state => { 129 | expect(state.hasOwnProperty('c')).toBe(true) 130 | done() 131 | }) 132 | 133 | expect(state$.hasOwnProperty('c')).toBe(false) 134 | 135 | state$.c = 1 136 | 137 | expect(state$.hasOwnProperty('c')).toBe(true) 138 | }) 139 | 140 | it('should disconnect array item correctly', done => { 141 | let list$ = reactive([{ value: 1 }, { value: 2 }, { value: 3 }]) 142 | let covalue0 = list$[0] 143 | 144 | list$.length = 0 145 | 146 | watch(list$, list => { 147 | console.log('list', list) 148 | throw new Error('disconnect failed') 149 | }) 150 | 151 | covalue0.value += 1 152 | 153 | setTimeout(() => done(), 4) 154 | }) 155 | 156 | it('can detect delete array item', () => { 157 | let list$ = reactive([1, 2, 3]) 158 | let list0 = getState(list$) 159 | 160 | list$.splice(1, 1) 161 | 162 | let list1 = getState(list$) 163 | 164 | expect(list0).toEqual([1, 2, 3]) 165 | expect(list1).toEqual([1, 3]) 166 | }) 167 | 168 | it('should not reuse state$ which is not connected', () => { 169 | let child$ = reactive({ a: 1, b: 2 }) 170 | let parent$ = reactive({ child1: child$, child2: child$ }) 171 | 172 | child$.b -= 1 173 | 174 | expect(getState(child$)).toEqual({ 175 | a: 1, 176 | b: 1 177 | }) 178 | 179 | let state = getState(parent$) 180 | 181 | expect(reactive(state) === parent$).toBe(false) 182 | 183 | expect(state.child1 === state.child2).toBe(false) 184 | 185 | // child1 was connected first, so child2 had no chance to reuse the same child$ 186 | expect(state).toEqual({ 187 | child1: { 188 | a: 1, 189 | b: 1 190 | }, 191 | child2: { 192 | a: 1, 193 | b: 2 194 | } 195 | }) 196 | 197 | delete parent$.child1 198 | 199 | let state1 = getState(parent$) 200 | 201 | expect(state1).toEqual({ 202 | child2: { 203 | a: 1, 204 | b: 2 205 | } 206 | }) 207 | 208 | parent$.child2.a += 1 209 | 210 | let state2 = getState(parent$) 211 | 212 | expect(state2).toEqual({ 213 | child2: { 214 | a: 2, 215 | b: 2 216 | } 217 | }) 218 | }) 219 | 220 | it('should support debounce', done => { 221 | let state$ = reactive({ count: 0 }) 222 | 223 | watch(state$, state => { 224 | expect(state.count).toBe(10) 225 | done() 226 | }) 227 | 228 | for (let i = 0; i < 10; i++) { 229 | state$.count += 1 230 | } 231 | }) 232 | 233 | it('can be detected and retrived', () => { 234 | let state$ = reactive({ count: 0 }) 235 | let state = getState(state$) 236 | 237 | expect(isReactive({ count: 0 })).toBe(false) 238 | expect(isReactive(state)).toBe(false) 239 | expect(isReactive(state$)).toBe(true) 240 | expect(state === getState(state$)).toBe(true) 241 | 242 | expect(state).toEqual({ count: 0 }) 243 | }) 244 | 245 | it('should throw error when getState call on non-reactive value', () => { 246 | expect(() => { 247 | getState({}) 248 | }).toThrow() 249 | }) 250 | 251 | it('object state derived by state$ should be immutable', () => { 252 | let state$ = reactive({ a: { value: 1 }, b: { value: 1 }, c: { value: 1 } }) 253 | let state0 = getState(state$) 254 | 255 | state$.a.value += 1 256 | let state1 = getState(state$) 257 | 258 | state$.b.value += 1 259 | let state2 = getState(state$) 260 | 261 | state$.c.value += 1 262 | let state3 = getState(state$) 263 | 264 | expect(state0 !== state1).toBe(true) 265 | expect(state0 !== state2).toBe(true) 266 | expect(state0 !== state3).toBe(true) 267 | expect(state1 !== state2).toBe(true) 268 | expect(state1 !== state3).toBe(true) 269 | expect(state2 !== state3).toBe(true) 270 | 271 | expect(state0.a !== state1.a).toBe(true) 272 | expect(state0.b === state1.b).toBe(true) 273 | expect(state0.c === state1.c).toBe(true) 274 | 275 | expect(state1.a === state2.a).toBe(true) 276 | expect(state1.b !== state2.b).toBe(true) 277 | expect(state1.c === state2.c).toBe(true) 278 | 279 | expect(state2.a === state3.a).toBe(true) 280 | expect(state2.b === state3.b).toBe(true) 281 | expect(state2.c !== state3.c).toBe(true) 282 | 283 | expect(state0).toEqual({ a: { value: 1 }, b: { value: 1 }, c: { value: 1 } }) 284 | expect(state1).toEqual({ a: { value: 2 }, b: { value: 1 }, c: { value: 1 } }) 285 | expect(state2).toEqual({ a: { value: 2 }, b: { value: 2 }, c: { value: 1 } }) 286 | expect(state3).toEqual({ a: { value: 2 }, b: { value: 2 }, c: { value: 2 } }) 287 | }) 288 | 289 | it('list state derived by state$ should be immutable', () => { 290 | let list$ = reactive([{ value: 1 }, { value: 1 }, { value: 1 }]) 291 | let list0 = getState(list$) 292 | 293 | list$[0].value += 1 294 | let list1 = getState(list$) 295 | 296 | list$[1].value += 1 297 | let list2 = getState(list$) 298 | 299 | list$[2].value += 1 300 | let list3 = getState(list$) 301 | 302 | expect(list0 !== list1).toBe(true) 303 | expect(list0 !== list2).toBe(true) 304 | expect(list0 !== list3).toBe(true) 305 | expect(list1 !== list2).toBe(true) 306 | expect(list1 !== list3).toBe(true) 307 | expect(list2 !== list3).toBe(true) 308 | 309 | expect(list0[0] !== list1[0]).toBe(true) 310 | expect(list0[1] === list1[1]).toBe(true) 311 | expect(list0[2] === list1[2]).toBe(true) 312 | 313 | expect(list1[0] === list2[0]).toBe(true) 314 | expect(list1[1] !== list2[1]).toBe(true) 315 | expect(list1[2] === list2[2]).toBe(true) 316 | 317 | expect(list2[0] === list3[0]).toBe(true) 318 | expect(list2[1] === list3[1]).toBe(true) 319 | expect(list2[2] !== list3[2]).toBe(true) 320 | 321 | expect(list0).toEqual([{ value: 1 }, { value: 1 }, { value: 1 }]) 322 | expect(list1).toEqual([{ value: 2 }, { value: 1 }, { value: 1 }]) 323 | expect(list2).toEqual([{ value: 2 }, { value: 2 }, { value: 1 }]) 324 | expect(list3).toEqual([{ value: 2 }, { value: 2 }, { value: 2 }]) 325 | 326 | list$.push({ value: 1 }) 327 | let list4 = getState(list$) 328 | 329 | expect(list4 !== list3).toBe(true) 330 | expect(list4[0] === list3[0]).toBe(true) 331 | expect(list4[1] === list3[1]).toBe(true) 332 | expect(list4[2] === list3[2]).toBe(true) 333 | 334 | expect(list3).toEqual([{ value: 2 }, { value: 2 }, { value: 2 }]) 335 | expect(list4).toEqual([{ value: 2 }, { value: 2 }, { value: 2 }, { value: 1 }]) 336 | 337 | list$.pop() 338 | let list5 = getState(list$) 339 | 340 | expect(list5 !== list3).toBe(true) 341 | expect(list5[0] === list3[0]).toBe(true) 342 | expect(list5[1] === list3[1]).toBe(true) 343 | expect(list5[2] === list3[2]).toBe(true) 344 | 345 | expect(list3).toEqual([{ value: 2 }, { value: 2 }, { value: 2 }]) 346 | expect(list5).toEqual([{ value: 2 }, { value: 2 }, { value: 2 }]) 347 | }) 348 | 349 | it('should ignore the change of symbol key', () => { 350 | let symbol0: any = Symbol('0') 351 | let symbol1: any = Symbol('1') 352 | let state$ = reactive({ count: 1, [symbol0]: 1, [symbol1]: 1 }) 353 | let state0 = getState(state$) 354 | 355 | state$[symbol0] += 1 356 | 357 | let state1 = getState(state$) 358 | 359 | expect(state0 === state1).toBe(true) 360 | 361 | delete state$[symbol1] 362 | 363 | let state2 = getState(state$) 364 | 365 | expect(state0 === state2).toBe(true) 366 | }) 367 | 368 | it('should throw error if the arg passing to co is not object or array', () => { 369 | expect(() => { 370 | reactive(() => 1) 371 | }).toThrow() 372 | }) 373 | 374 | it('should disconnect correctly', () => { 375 | let state$ = reactive({ a: { value: 1 }, b: { value: 2 } }) 376 | 377 | state$.a.value += 1 378 | 379 | let state1 = getState(state$) 380 | 381 | expect(state1.a.value).toBe(2) 382 | 383 | let oldA = state$.a 384 | 385 | state$.a = { value: 1 } 386 | 387 | let state2 = getState(state$) 388 | 389 | expect(state2.a.value).toBe(1) 390 | 391 | oldA.value += 1 392 | 393 | expect(getState(oldA)).toEqual({ value: 3 }) 394 | 395 | let state3 = getState(state$) 396 | 397 | expect(state3.a).toEqual({ value: 1 }) 398 | }) 399 | 400 | it('state$ object can be removed', done => { 401 | let state$ = reactive({ 402 | a: { value: 1 }, 403 | b: { value: 2 }, 404 | c: { value: 3 }, 405 | d: { value: 4 } 406 | }) 407 | let state0 = getState(state$) 408 | 409 | watch(state$, state => { 410 | expect(state === state1).toBe(true) 411 | done() 412 | }) 413 | 414 | remove(state$.a) 415 | remove(state$.c) 416 | 417 | let state1 = getState(state$) 418 | 419 | expect(state0).toEqual({ a: { value: 1 }, b: { value: 2 }, c: { value: 3 }, d: { value: 4 } }) 420 | expect(state1).toEqual({ b: { value: 2 }, d: { value: 4 } }) 421 | }) 422 | 423 | it('state$ array can be removed', done => { 424 | let list$ = reactive([{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }]) 425 | let list0 = getState(list$) 426 | 427 | watch(list$, list => { 428 | expect(list === list1).toBe(true) 429 | done() 430 | }) 431 | 432 | remove(list$[3]) 433 | remove(list$[0]) 434 | 435 | let list1 = getState(list$) 436 | 437 | expect(list0).toEqual([{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }]) 438 | expect(list1).toEqual([{ value: 2 }, { value: 3 }]) 439 | }) 440 | 441 | it('should throw error when remove target is not a state$', () => { 442 | expect(() => { 443 | remove(1 as any) 444 | }).toThrow() 445 | }) 446 | 447 | it('should support accessor', () => { 448 | let state$ = reactive({ 449 | firstName: 'Jade', 450 | lastName: 'Gu', 451 | get fullName() { 452 | return state$.firstName + ' ' + state$.lastName 453 | } 454 | }) 455 | 456 | let state0 = getState(state$) 457 | 458 | expect(state0).toEqual({ 459 | firstName: 'Jade', 460 | lastName: 'Gu', 461 | fullName: 'Jade Gu' 462 | }) 463 | 464 | state$.firstName = 'Lesley' 465 | state$.lastName = 'Huang' 466 | 467 | let state1 = getState(state$) 468 | 469 | expect(state1).toEqual({ 470 | firstName: 'Lesley', 471 | lastName: 'Huang', 472 | fullName: 'Lesley Huang' 473 | }) 474 | }) 475 | }) 476 | --------------------------------------------------------------------------------