diff options
-rw-r--r-- | .babelrc | 7 | ||||
-rw-r--r-- | .eslintrc | 50 | ||||
-rw-r--r-- | .gitignore | 4 | ||||
-rw-r--r-- | .travis.yml | 1 | ||||
-rw-r--r-- | LICENSE | 20 | ||||
-rw-r--r-- | README.md | 120 | ||||
-rw-r--r-- | package.json | 33 | ||||
-rw-r--r-- | src/decko.d.ts | 30 | ||||
-rw-r--r-- | src/decko.js | 98 | ||||
-rw-r--r-- | tests/bind.js | 37 | ||||
-rw-r--r-- | tests/debounce.js | 71 | ||||
-rw-r--r-- | tests/index.ts | 17 | ||||
-rw-r--r-- | tests/memoize.js | 72 | ||||
-rw-r--r-- | tsconfig.json | 24 |
14 files changed, 584 insertions, 0 deletions
diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..55dee01 --- /dev/null +++ b/.babelrc @@ -0,0 +1,7 @@ +{ + "modules": "umd", + "loose": "all", + "compact": true, + "comments": false, + "stage": 0 +} diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..bb86529 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,50 @@ +{ + "parser": "babel-eslint", + "extends": "eslint:recommended", + "env": { + "browser": true + }, + "ecmaFeatures": { + "modules": true, + "jsx": true + }, + "rules": { + "no-unused-vars": [1, { "args": "after-used" }], + "no-cond-assign": 1, + "semi": 2, + "camelcase": 0, + "comma-style": 2, + "comma-dangle": [2, "never"], + "indent": [2, "tab", {"SwitchCase": 1}], + "no-mixed-spaces-and-tabs": [2, "smart-tabs"], + "no-trailing-spaces": [2, { "skipBlankLines": true }], + "max-nested-callbacks": [2, 3], + "no-eval": 2, + "no-implied-eval": 2, + "no-new-func": 2, + "guard-for-in": 2, + "eqeqeq": 2, + "no-else-return": 2, + "no-redeclare": 2, + "no-dupe-keys": 2, + "radix": 2, + "strict": [2, "never"], + "no-shadow": 0, + "callback-return": [1, ["callback", "cb", "next", "done"]], + "no-delete-var": 2, + "no-undef-init": 2, + "no-shadow-restricted-names": 2, + "handle-callback-err": 0, + "no-lonely-if": 2, + "space-return-throw-case": 2, + "constructor-super": 2, + "no-this-before-super": 2, + "no-dupe-class-members": 2, + "no-const-assign": 2, + "prefer-spread": 2, + "no-useless-concat": 2, + "no-var": 2, + "object-shorthand": 2, + "prefer-arrow-callback": 2 + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e1036a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +npm-debug.log +node_modules +dist +.DS_Store diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..587bd3e --- /dev/null +++ b/.travis.yml @@ -0,0 +1 @@ +language: node_js @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2017 Jason Miller + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..20f132b --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# decko [![NPM Version](https://img.shields.io/npm/v/decko.svg?style=flat)](https://npmjs.com/package/decko) [![Build Status](https://travis-ci.org/developit/decko.svg?branch=master)](https://travis-ci.org/developit/decko) + +A concise implementation of the three most useful [decorators](https://github.com/wycats/javascript-decorators): + +- `@bind`: make the value of `this` constant within a method +- `@debounce`: throttle calls to a method +- `@memoize`: cache return values based on arguments + +Decorators help simplify code by replacing the noise of common patterns with declarative annotations. +Conversely, decorators can also be overused and create obscurity. +Decko establishes 3 standard decorators that are immediately recognizable, so you can avoid creating decorators in your own codebase. + +> 💡 **Tip:** decko is particularly well-suited to [**Preact Classful Components**](https://github.com/developit/preact). +> +> 💫 **Note:** +> - For Babel 6+, be sure to install [babel-plugin-transform-decorators-legacy](https://github.com/loganfsmyth/babel-plugin-transform-decorators-legacy). +> - For Typescript, be sure to enable `{"experimentalDecorators": true}` in your tsconfig.json. + + +## Installation + +Available on [npm](https://npmjs.com/package/decko): + +```sh +npm i -S decko +``` + + +## Usage + +Each decorator method is available as a named import. + +```js +import { bind, memoize, debounce } from 'decko'; +``` + + +### `@bind` + +```js +class Example { + @bind + foo() { + // the value of `this` is always the object from which foo() was referenced. + return this; + } +} + +let e = new Example(); +assert.equal(e.foo.call(null), e); +``` + + + +### `@memoize` + +> Cache values returned from the decorated function. +> Uses the first argument as a cache key. +> _Cache keys are always converted to strings._ +> +> ##### Options: +> +> `caseSensitive: false` - _Makes cache keys case-insensitive_ +> +> `cache: {}` - _Presupply cache storage, for seeding or sharing entries_ + +```js +class Example { + @memoize + expensive(key) { + let start = Date.now(); + while (Date.now()-start < 500) key++; + return key; + } +} + +let e = new Example(); + +// this takes 500ms +let one = e.expensive(1); + +// this takes 0ms +let two = e.expensive(1); + +// this takes 500ms +let three = e.expensive(2); +``` + + + +### `@debounce` + +> Throttle calls to the decorated function. To debounce means "call this at most once per N ms". +> All outward function calls get collated into a single inward call, and only the latest (most recent) arguments as passed on to the debounced function. +> +> ##### Options: +> +> `delay: 0` - _The number of milliseconds to buffer calls for._ + +```js +class Example { + @debounce + foo() { + return this; + } +} + +let e = new Example(); + +// this will only call foo() once: +for (let i=1000; i--) e.foo(); +``` + + +--- + +License +------- + +MIT diff --git a/package.json b/package.json new file mode 100644 index 0000000..c5cf1dc --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "decko", + "version": "1.2.0", + "main": "dist/decko.js", + "types": "dist/decko.d.ts", + "description": "A collection of the most useful property decorators.", + "scripts": { + "build": "mkdir -p dist && babel -f src/decko.js -s -o $npm_package_main < src/decko.js && npm run build:ts", + "build:ts": "cp src/decko.d.ts dist/", + "test": "npm run test:ts && eslint {src,tests}/**.js && mocha --compilers js:babel/register tests/**/*.js", + "test:ts": "tsc -p ./", + "style:ts": "tsfmt -r", + "prepublish": "npm run build", + "release": "npm run build && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish" + }, + "files": [ + "src", + "dist" + ], + "repository": { + "type": "git", + "url": "git://github.com/developit/decko.git" + }, + "devDependencies": { + "babel": "^5.8.21", + "babel-eslint": "^4.1.6", + "chai": "^3.2.0", + "eslint": "^1.10.3", + "mocha": "^2.3.0", + "typescript": "2.1.6", + "typescript-formatter": "4.1.1" + } +} diff --git a/src/decko.d.ts b/src/decko.d.ts new file mode 100644 index 0000000..e65c737 --- /dev/null +++ b/src/decko.d.ts @@ -0,0 +1,30 @@ +/** + * + */ +export function bind<T>( + target: Object, + propertyKey: string | symbol, + descriptor?: TypedPropertyDescriptor<T> +): TypedPropertyDescriptor<T> | void; +export function bind(): MethodDecorator; + +/** + * @param caseSensitive Makes cache keys case-insensitive + * @param cache Presupply cache storage, for seeding or sharing entries + */ + +export function memoize<T>( + target: Object, + propertyKey: string | symbol, + descriptor?: TypedPropertyDescriptor<T> +): TypedPropertyDescriptor<T> | void; +export function memoize(caseSensitive?: boolean, cache?: Object): MethodDecorator; +/** + * @param delay number + */ +export function debounce<T>( + target: Object, + propertyKey: string | symbol, + descriptor?: TypedPropertyDescriptor<T> +): TypedPropertyDescriptor<T> | void; +export function debounce(delay?: number): MethodDecorator;
\ No newline at end of file diff --git a/src/decko.js b/src/decko.js new file mode 100644 index 0000000..298e092 --- /dev/null +++ b/src/decko.js @@ -0,0 +1,98 @@ + +const EMPTY = {}; +const HOP = Object.prototype.hasOwnProperty; + +let fns = { + /** let cachedFn = memoize(originalFn); */ + memoize(fn, opt=EMPTY) { + let cache = opt.cache || {}; + return function(...a) { + let k = String(a[0]); + if (opt.caseSensitive===false) k = k.toLowerCase(); + return HOP.call(cache,k) ? cache[k] : (cache[k] = fn.apply(this, a)); + }; + }, + + /** let throttled = debounce(10, console.log); */ + debounce(fn, opts) { + if (typeof opts==='function') { let p = fn; fn = opts; opts = p; } + let delay = opts && opts.delay || opts || 0, + args, context, timer; + return function(...a) { + args = a; + context = this; + if (!timer) timer = setTimeout( () => { + fn.apply(context, args); + args = context = timer = null; + }, delay); + }; + }, + + bind(target, key, { value: fn }) { + return { + configurable: true, + get() { + let value = fn.bind(this); + Object.defineProperty(this, key, { + value, + configurable: true, + writable: true + }); + return value; + } + }; + } +}; + + +let memoize = multiMethod(fns.memoize), + debounce = multiMethod(fns.debounce), + bind = multiMethod((f,c)=>f.bind(c), ()=>fns.bind); + +export { memoize, debounce, bind }; +export default { memoize, debounce, bind }; + + +/** Creates a function that supports the following calling styles: + * d() - returns an unconfigured decorator + * d(opts) - returns a configured decorator + * d(fn, opts) - returns a decorated proxy to `fn` + * d(target, key, desc) - the decorator itself + * + * @Example: + * // simple identity deco: + * let d = multiMethod( fn => fn ); + * + * class Foo { + * @d + * bar() { } + * + * @d() + * baz() { } + * + * @d({ opts }) + * bat() { } + * + * bap = d(() => {}) + * } + */ +function multiMethod(inner, deco) { + deco = deco || inner.decorate || decorator(inner); + let d = deco(); + return (...args) => { + let l = args.length; + return (l<2 ? deco : (l>2 ? d : inner))(...args); + }; +} + +/** Returns function supports the forms: + * deco(target, key, desc) -> decorate a method + * deco(Fn) -> call the decorator proxy on a function + */ +function decorator(fn) { + return opt => ( + typeof opt==='function' ? fn(opt) : (target, key, desc) => { + desc.value = fn(desc.value, opt, target, key, desc); + } + ); +} diff --git a/tests/bind.js b/tests/bind.js new file mode 100644 index 0000000..c76a0f4 --- /dev/null +++ b/tests/bind.js @@ -0,0 +1,37 @@ +import { bind } from '..'; +import { expect } from 'chai'; + +/*global describe,it*/ + +describe('bind()', () => { + it('should bind when used as a simple decorator', next => { + let c = { + @bind + foo() { + return this; + } + }; + + expect(c.foo()).to.equal(c); + + let p = c.foo; + expect(p()).to.equal(c); + + let a = {}; + expect(c.foo.call(a)).to.equal(c); + + next(); + }); + + it('should bind when used as a function', next => { + let ctx = {}, + c = bind(function(){ return this; }, ctx); + + expect(c()).to.equal(ctx); + + let a = {}; + expect(c.call(a)).to.equal(ctx); + + next(); + }); +}); diff --git a/tests/debounce.js b/tests/debounce.js new file mode 100644 index 0000000..1c8bcf0 --- /dev/null +++ b/tests/debounce.js @@ -0,0 +1,71 @@ +import { debounce } from '..'; +import { expect } from 'chai'; + +/*global describe,it*/ + +describe('debounce()', () => { + it('should debounce when used as a simple decorator', next => { + let c = { + calls: 0, + args: null, + + @debounce + foo(...args) { + c.calls++; + c.args = args; + c.context = this; + } + }; + + expect(c).to.have.property('calls', 0); + c.foo(1); + expect(c).to.have.property('calls', 0); + c.foo(2); + c.foo(3); + setTimeout( () => { + expect(c).to.have.property('calls', 1); + expect(c.args).to.deep.equal([3]); + expect(c.context).to.equal(c); + + next(); + }, 20); + }); + + it('should debounce when used as a function', next => { + let c = debounce( (...args) => { + m.calls++; + m.args = args; + }), + m = { calls:0, args:null }; + + expect(m).to.have.property('calls', 0); + c(1); + expect(m).to.have.property('calls', 0); + c(2); + c(3); + setTimeout( () => { + expect(m).to.have.property('calls', 1); + expect(m.args).to.deep.equal([3]); + + next(); + }, 20); + }); + + it('should support passing a delay', next => { + let c = debounce(5, (...args) => { + m.calls.push(args); + }), + m = { calls:[] }; + + c(1); + setTimeout(()=> c(2), 1); + setTimeout(()=> c(3), 10); + setTimeout(()=> c(4), 14); + setTimeout(()=> c(5), 22); + expect(m.calls).to.have.length(0); + setTimeout( () => { + expect(m.calls).to.deep.equal([ [2], [4], [5] ]); + next(); + }, 30); + }); +}); diff --git a/tests/index.ts b/tests/index.ts new file mode 100644 index 0000000..14c4efe --- /dev/null +++ b/tests/index.ts @@ -0,0 +1,17 @@ +import { bind, debounce, memoize } from '..'; +class C { + @bind + foo() { } + + @debounce + moo() { } + + @debounce(1000) + mooWithCustomDelay() { } + + @memoize + mem() { } + + @memoize(true) + memWithConfig() { } +}
\ No newline at end of file diff --git a/tests/memoize.js b/tests/memoize.js new file mode 100644 index 0000000..98f3678 --- /dev/null +++ b/tests/memoize.js @@ -0,0 +1,72 @@ +import { memoize } from '..'; +import { expect } from 'chai'; + +/*global describe,it*/ + +describe('memoize()', () => { + it('should memoize when used as a simple decorator', next => { + let c = { + @memoize + foo(key) { + c[key] = (c[key] || 0) + 1; + } + }; + + expect(c).not.to.have.property('a'); + c.foo('a'); + expect(c).to.have.property('a', 1); + c.foo('a'); + c.foo('a'); + expect(c).to.have.property('a', 1); + + next(); + }); + + it('should memoize when used as a function', next => { + let c = memoize( key => { + m[key] = (m[key] || 0) + 1; + }), + m = {}; + + expect(m).not.to.have.property('a'); + c('a'); + expect(m).to.have.property('a', 1); + c('a'); + c('a'); + expect(m).to.have.property('a', 1); + + next(); + }); + + it('should memoize when called without arguments', next => { + let c = memoize( key => { + m[key] = (m[key] || 0) + 1; + }), + m = {}; + + expect(m).not.to.have.property('undefined'); + c(); + expect(m).to.have.property('undefined', 1); + c(); + c(); + expect(m).to.have.property('undefined', 1); + + next(); + }); + + it('should memoize when called with an empty string', next => { + let c = memoize( key => { + m[key] = (m[key] || 0) + 1; + }), + m = {}; + + expect(m).not.to.have.property(''); + c(''); + expect(m).to.have.property('', 1); + c(''); + c(''); + expect(m).to.have.property('', 1); + + next(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f78fc76 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "module": "es2015", + "target": "es2016", + "lib": [ + "dom", + "es2016" + ], + "baseUrl": "./", + "noImplicitAny": true, + "experimentalDecorators": true, + "sourceMap": false, + "moduleResolution": "node", + "strictNullChecks": true, + "declaration": true, + "noEmit": true, + "pretty": true, + "outDir": "ts-output" + }, + "include": [ + "src/decko.d.ts", + "tests/index.ts" + ] +} |