├── .gitignore ├── bower.json ├── jquery.memoized.ajax.min.js ├── gulpfile.js ├── package.json ├── karma.conf.js ├── jquery.memoized.ajax.js ├── README.md └── test └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components/ 2 | node_modules/ -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jquery-memoized-ajax", 3 | "version": "0.5.0", 4 | "authors": [ 5 | "Eric Trinh " 6 | ], 7 | "description": "A jQuery plugin for caching $.ajax calls", 8 | "main": "jquery.memoized.ajax.js", 9 | "keywords": [ 10 | "jQuery", 11 | "ajax", 12 | "cache", 13 | "memoized" 14 | ], 15 | "license": "MIT", 16 | "ignore": [ 17 | "**/.*", 18 | "**/*.json", 19 | "karma.conf.js", 20 | "gulpfile.js", 21 | "node_modules", 22 | "bower_components", 23 | "test", 24 | "tests" 25 | ], 26 | "dependencies": { 27 | "jquery": "~2.1.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /jquery.memoized.ajax.min.js: -------------------------------------------------------------------------------- 1 | !function(e){"function"==typeof define&&define.amd?define(["jquery"],e):e(jQuery)}(function(e){function n(e,n,o){if(!e)throw new Error("Storage object is undefined");if(void 0===o){var r=e.getItem(n);return r&&JSON.parse(r)}e.setItem(n,JSON.stringify(o))}function o(e){return e.localStorage?localStorage:e.sessionStorage?sessionStorage:void 0}function r(e){return JSON.stringify(e)}var t={};e.memoizedAjax=function(i){function a(){return i.cacheKey||"memoizedAjax | "+i.url}var c,u=i.cacheKey||i.url,f=r(i.data),s=o(i);return c=s?n(s,a())||{}:t[u]||{},c[f]?e.Deferred().resolve(c[f]).done(function(){s&&n(s,a(),c)}).done(i.success).always(i.complete):e.ajax.call(this,i).done(function(e){c[f]=e,s?n(s,a(),c):t[u]=c})}}); -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'), 2 | uglify = require('gulp-uglify'), 3 | rename = require('gulp-rename'), 4 | karma = require('gulp-karma'); 5 | 6 | var testFiles = [ 7 | 'bower_components/jquery/dist/jquery.js', 8 | 'jquery.memoized.ajax.js', 9 | 'test/**/*.js' 10 | ]; 11 | 12 | gulp.task('test', function() { 13 | return gulp.src(testFiles) 14 | .pipe(karma({ 15 | configFile: 'karma.conf.js', 16 | action: 'run' 17 | })); 18 | }); 19 | 20 | gulp.task('build', ['test'], function(){ 21 | gulp.src('jquery.memoized.ajax.js') 22 | .pipe(uglify()) 23 | .pipe(rename({ suffix: '.min' })) 24 | .pipe(gulp.dest('.')); 25 | }); 26 | 27 | gulp.task('default', function() { 28 | gulp.src(testFiles) 29 | .pipe(karma({ 30 | configFile: 'karma.conf.js', 31 | action: 'watch' 32 | })); 33 | }); 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jquery-memoized-ajax", 3 | "version": "0.5.0", 4 | "description": "A jQuery plugin for caching $.ajax calls", 5 | "main": "jquery.memoized.ajax.js", 6 | "scripts": { 7 | "test": "gulp test" 8 | }, 9 | "keywords": [ 10 | "jQuery", 11 | "ajax", 12 | "cache" 13 | ], 14 | "author": "Eric Trinh", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "gulp": "~3.5.5", 18 | "gulp-uglify": "~0.2.1", 19 | "gulp-rename": "~1.1.0", 20 | "karma-script-launcher": "~0.1.0", 21 | "karma-chrome-launcher": "~0.1.2", 22 | "karma-firefox-launcher": "~0.1.3", 23 | "karma-html2js-preprocessor": "~0.1.0", 24 | "karma-jasmine": "~0.1.5", 25 | "karma-coffee-preprocessor": "~0.1.3", 26 | "requirejs": "~2.1.11", 27 | "karma-requirejs": "~0.2.1", 28 | "karma-phantomjs-launcher": "~0.1.2", 29 | "karma": "~0.10.9", 30 | "gulp-karma": "0.0.2", 31 | "mocha": "~1.17.1", 32 | "karma-mocha": "~0.1.1", 33 | "karma-sinon-chai": "~0.1.4", 34 | "karma-safari-launcher": "~0.1.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | 4 | // base path, that will be used to resolve files and exclude 5 | basePath: '', 6 | 7 | 8 | // frameworks to use 9 | frameworks: ['mocha', 'sinon-chai'], 10 | 11 | 12 | // list of files / patterns to load in the browser 13 | files: [ 14 | 'bower_components/jquery/dist/jquery.js', 15 | 'jquery.memoized.ajax.js', 16 | 'test/**/*.js' 17 | ], 18 | 19 | 20 | // list of files to exclude 21 | exclude: [ 22 | 23 | ], 24 | 25 | 26 | // test results reporter to use 27 | // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' 28 | reporters: ['progress'], 29 | 30 | 31 | // web server port 32 | port: 9876, 33 | 34 | 35 | // enable / disable colors in the output (reporters and logs) 36 | colors: true, 37 | 38 | 39 | // level of logging 40 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 41 | logLevel: config.LOG_INFO, 42 | 43 | 44 | // enable / disable watching file and executing tests whenever any file changes 45 | autoWatch: true, 46 | 47 | 48 | // Start these browsers, currently available: 49 | // - Chrome 50 | // - ChromeCanary 51 | // - Firefox 52 | // - Opera 53 | // - Safari (only Mac) 54 | // - PhantomJS 55 | // - IE (only Windows) 56 | browsers: ['Chrome', 'Firefox', 'Safari'], 57 | 58 | 59 | // If browser does not capture in given timeout [ms], kill it 60 | captureTimeout: 60000, 61 | 62 | 63 | // Continuous Integration mode 64 | // if true, it capture browsers, run tests and exit 65 | singleRun: false 66 | }); 67 | }; 68 | -------------------------------------------------------------------------------- /jquery.memoized.ajax.js: -------------------------------------------------------------------------------- 1 | (function (factory) { 2 | if (typeof define === 'function' && define.amd) { 3 | // AMD. Register as an anonymous module. 4 | define(['jquery'], factory); 5 | } else { 6 | // Browser globals 7 | factory(jQuery); 8 | } 9 | }(function ($) { 10 | var inMemory = {}; 11 | 12 | $.memoizedAjax = function memoizedAjax(opts) { 13 | var memo, 14 | key = opts.cacheKey || opts.url, 15 | hash = hashFunc(opts.data), 16 | storage = storageObject(opts); 17 | 18 | if (storage) { 19 | memo = store(storage, getStorageAddress()) || {}; 20 | } else { 21 | memo = inMemory[key] || {}; 22 | } 23 | 24 | if (memo[hash]) { 25 | return $.Deferred().resolve(memo[hash]) 26 | // store this in localStorage to deal with calling with 27 | // `localStorage: false` and then `localStorage: true` later 28 | // ensures syncing between memory and localStorage 29 | .done(function() { 30 | if (storage) { 31 | store(storage, getStorageAddress(), memo); 32 | } 33 | }) 34 | // no error callback, since this should never fail...theoretically 35 | .done(opts.success) 36 | .always(opts.complete); 37 | } 38 | 39 | return $.ajax.call(this, opts).done(function(result) { 40 | memo[hash] = result; 41 | 42 | if (storage) { 43 | store(storage, getStorageAddress(), memo); 44 | } else { 45 | inMemory[key] = memo; 46 | } 47 | }); 48 | 49 | function getStorageAddress() { 50 | return opts.cacheKey || ('memoizedAjax | ' + opts.url); 51 | } 52 | }; 53 | 54 | function store(storage, key, value) { 55 | if (!storage) { throw new Error("Storage object is undefined"); } 56 | 57 | // get 58 | if (value === undefined) { 59 | var item = storage.getItem(key); 60 | return item && JSON.parse(item); 61 | // set 62 | } else { 63 | try { 64 | storage.setItem(key, JSON.stringify(value)); 65 | } catch (err) { 66 | // prevent catching old values 67 | storage.removeItem(key); 68 | } 69 | } 70 | } 71 | 72 | function storageObject(opts) { 73 | if (opts.localStorage) { 74 | return localStorage; 75 | } else if (opts.sessionStorage) { 76 | return sessionStorage; 77 | } 78 | } 79 | 80 | function hashFunc(hash) { 81 | return JSON.stringify(hash); 82 | } 83 | 84 | })); 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jQuery Memoized Ajax 2 | 3 | Memoization is a technique for caching the results of expensive function calls so that subsequent calls (given the same inputs) are very fast. This property is also useful for expensive AJAX calls. This plugin adds a method to jQuery called `$.memoizedAjax` that behaves exactly like `$.ajax`, but caches the result in memory (and optionally, localStorage or sessionStorage). The next time `memoizedAjax` is called with the same `data` and `url` arguments, it will return the result immediately, in the form of a resolved `$.Deferred`. (If you have no idea what a deferred is, that's ok. Just treat this exactly like `$.ajax`...with a few caveats. See below.) 4 | 5 | ## Install 6 | 7 | Two options: 8 | 9 | * Install via bower with `bower install jquery-memoized-ajax` 10 | * Clone the repo and grab `jquery.memoized.ajax.js`. 11 | 12 | **This plugin is dependent on jQuery**, so include this after jQuery on your page with a script tag, or use RequireJS. 13 | 14 | ## Example Usage 15 | 16 | Use it exactly how you would use `$.ajax`. Optionally, pass in `localStorage: true` or `sessionStorage: true` as one of the key-value pairs to cache the result in localStorage or sessionStorage. An example: 17 | 18 | ```javascript 19 | // this goes and does the ajax call and logs the result after some time 20 | $.memoizedAjax({ 21 | url: '/reallyFreakinSlowLookup', 22 | localStorage: true, // this is optional (and defaults to false) 23 | type: 'GET', 24 | data: { name: 'Bobby Tables' }, 25 | success: function(person) { console.log(person.age); } 26 | }); 27 | 28 | // this resolves immediately (reading from localStorage), and logs the result 29 | $.memoizedAjax({ 30 | url: '/reallyFreakinSlowLookup', 31 | localStorage: true, 32 | type: 'GET', 33 | data: { name: 'Bobby Tables' }, 34 | success: function(person) { console.log(person.age); } 35 | }); 36 | ``` 37 | 38 | ## Caveats 39 | 40 | Some things to watch out for when using `memoizedAjax` instead of regular `ajax`: 41 | 42 | When `memoizedAjax` returns a cached result, it will actually return a resolved jQuery deferred object, **not** a jqXHR. Most of the time, this distinction is no big deal. There are times, however, where you'd like to `abort` a jqXHR object that you've stored in a variable. In those cases, you should check for the `abort` method before calling it, as a jQuery deferred object doesn't have an `abort` method, and your program will throw an error if the result happens to be cached. 43 | 44 | ```javascript 45 | var ajaxCall = $.memoizedAjax({ 46 | url: '/reallyFreakinSlowLookup', 47 | type: 'GET', 48 | data: { name: 'Bobby Tables' } 49 | }); 50 | 51 | // DON'T DO THIS 52 | ajaxCall.abort(); // will throw an error if $.memoizedAjax() returns a cached result 53 | 54 | // DO THIS INSTEAD 55 | ajaxCall.abort && ajaxCall.abort(); 56 | ``` 57 | 58 | One other thing to keep in mind is that **accessing localStorage or sessionStorage is a blocking process**. Thus, it's not a good idea to do 10 `memoizedAjax` calls in a row with storage enabled, as this can lock up your web page. 59 | 60 | ## Advanced 61 | 62 | ### Customizing storage location 63 | 64 | By default, `$.memoizedAjax` will store the results of ajax calls in storage with `memoizedAjax | url` as the key. For example, if you do this: 65 | 66 | ```javascript 67 | $.memoizedAjax({ 68 | url: '/really/long/url', 69 | localStorage: true, 70 | ... 71 | }); 72 | ``` 73 | 74 | The result is stored under `localStorage['memoizedAjax | /really/long/url']`. 75 | 76 | In some cases, you will want to customize the key that memoizedAjax uses to cache the results of an ajax call. This might be useful if you want to clear the cache for specific ajax calls within your application. Use the `cacheKey` parameter, like this: 77 | 78 | ```javascript 79 | $.memoizedAjax({ 80 | url: '/lookup', 81 | localStorage: true, 82 | cacheKey: 'foobar' 83 | }); 84 | ``` 85 | 86 | Now, the result is cached under `localStorage.foobar`. 87 | 88 | ## Development 89 | 90 | Clone this repo, then `npm install` and `bower install`. Tests and builds are run using gulp, so you'll need to install that with `npm install gulp -g`. You may need `sudo`. 91 | 92 | Running `gulp` will watch files for changes and run tests on change. `gulp build` will run tests and then minify the script. 93 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | describe('memoizedAjax', function() { 2 | 3 | before(function() { 4 | localStorage.clear(); 5 | sessionStorage.clear(); 6 | }); 7 | 8 | beforeEach(function() { 9 | var returnVal = { 10 | message: 'this is a successful ajax request' 11 | }; 12 | 13 | sinon.stub($, 'ajax') 14 | .yieldsTo('success', returnVal) 15 | .returns($.Deferred().resolve(returnVal)); 16 | }); 17 | 18 | afterEach(function() { 19 | $.ajax.restore(); 20 | }); 21 | 22 | after(function() { 23 | localStorage.clear(); 24 | sessionStorage.clear(); 25 | }); 26 | 27 | var ajaxOptions = { 28 | url: '/test', 29 | data: { test: 'test' }, 30 | success: function() {} 31 | }; 32 | 33 | it('should call the ajax method the first time', function(done) { 34 | $.memoizedAjax(extend(ajaxOptions, { 35 | success: function() { 36 | expect($.ajax.calledOnce).to.be.true; 37 | done(); 38 | } 39 | })); 40 | }); 41 | 42 | it('should call the ajax method again if url is different', function(done) { 43 | $.memoizedAjax(extend(ajaxOptions, { 44 | url: '/test2', 45 | success: function() { 46 | expect($.ajax.calledOnce).to.be.true; 47 | done(); 48 | } 49 | })); 50 | }); 51 | 52 | it('should not call the ajax method again if url is the same', function(done) { 53 | $.memoizedAjax(extend(ajaxOptions, { 54 | success: function() { 55 | expect($.ajax.calledOnce).to.be.false; 56 | done(); 57 | } 58 | })); 59 | }); 60 | 61 | it('should return the results in the success callback', function(done) { 62 | $.memoizedAjax(extend(ajaxOptions, { 63 | success: function(results) { 64 | expect($.ajax.calledOnce).to.be.false; 65 | expect(results).to.deep.equal({ 66 | message: 'this is a successful ajax request' 67 | }); 68 | done(); 69 | } 70 | })); 71 | }); 72 | 73 | it('should return the results in the returned promise', function(done) { 74 | $.memoizedAjax(extend(ajaxOptions)).done(function(results) { 75 | expect(results).to.deep.equal({ 76 | message: 'this is a successful ajax request' 77 | }); 78 | done(); 79 | }); 80 | }); 81 | 82 | it('should not have the results in localStorage if no option passed', function() { 83 | expect(localStorage.getItem('memoizedAjax | /test')).to.be.null; 84 | }); 85 | 86 | it('should store the results in localStorage if option is passed', function(done) { 87 | $.memoizedAjax(extend(ajaxOptions, { 88 | localStorage: true, 89 | })).done(function(results) { 90 | expect(localStorage.getItem('memoizedAjax | /test')).to.not.be.null; 91 | done(); 92 | }); 93 | }); 94 | 95 | it('should call the ajax method if localStorage option is passed, but no localStorage item exists', function(done) { 96 | localStorage.removeItem('memoizedAjax | /test'); 97 | $.memoizedAjax(extend(ajaxOptions, { 98 | localStorage: true, 99 | })).done(function() { 100 | expect($.ajax.calledOnce).to.be.true; 101 | done(); 102 | }); 103 | }); 104 | 105 | it('should not have the results in sessionStorage if no option passed', function() { 106 | expect(sessionStorage.getItem('memoizedAjax | /test')).to.be.null; 107 | }); 108 | 109 | it('should store the results in sessionStorage if option is passed', function(done) { 110 | $.memoizedAjax(extend(ajaxOptions, { 111 | sessionStorage: true, 112 | })).done(function(results) { 113 | expect(sessionStorage.getItem('memoizedAjax | /test')).to.not.be.null; 114 | done(); 115 | }); 116 | }); 117 | 118 | var localCacheKeyParams = { 119 | url: '/cacheKey', 120 | localStorage: true, 121 | cacheKey: 'testKey' 122 | }; 123 | 124 | it('should call ajax the first time, using cacheKey for localStorage', function(done) { 125 | $.memoizedAjax(extend(ajaxOptions, localCacheKeyParams, { 126 | success: function() { 127 | expect($.ajax.calledOnce).to.be.true; 128 | done(); 129 | } 130 | })); 131 | }); 132 | 133 | it('should not store results in default localStorage location if using cacheKey', function(done) { 134 | $.memoizedAjax(extend(ajaxOptions, localCacheKeyParams)).done(function(results) { 135 | expect(localStorage.getItem('memoizedAjax | /cacheKey')).to.be.null; 136 | done(); 137 | }); 138 | }); 139 | 140 | it('should store results in localStorage location defined by cacheKey', function() { 141 | expect(localStorage.getItem('testKey')).to.not.be.null; 142 | }); 143 | 144 | var sessionCacheKeyParams = { 145 | url: '/cacheKey', 146 | sessionStorage: true, 147 | cacheKey: 'testKey' 148 | }; 149 | 150 | it('should call ajax the first time, using cacheKey for sessionStorage', function(done) { 151 | $.memoizedAjax(extend(ajaxOptions, sessionCacheKeyParams, { 152 | success: function() { 153 | expect($.ajax.calledOnce).to.be.true; 154 | done(); 155 | } 156 | })); 157 | }); 158 | 159 | it('should not store results in default sessionStorage location if using cacheKey', function(done) { 160 | $.memoizedAjax(extend(ajaxOptions, sessionCacheKeyParams)).done(function(results) { 161 | expect(localStorage.getItem('memoizedAjax | /cacheKey')).to.be.null; 162 | done(); 163 | }); 164 | }); 165 | 166 | it('should store results in sessionStorage location defined by cacheKey', function() { 167 | expect(sessionStorage.getItem('testKey')).to.not.be.null; 168 | }); 169 | 170 | // utility functions 171 | // ================= 172 | 173 | // non-mutating extend 174 | function extend() { 175 | var args = Array.prototype.slice.call(arguments, 0); 176 | return _extend.apply(null, [{}].concat(args)); 177 | } 178 | 179 | // adapted from underscore.js 180 | function _extend(obj) { 181 | _each(Array.prototype.slice.call(arguments, 1), function(source) { 182 | if (source) { 183 | for (var prop in source) { 184 | obj[prop] = source[prop]; 185 | } 186 | } 187 | }); 188 | return obj; 189 | } 190 | 191 | function _each(obj, iterator, context) { 192 | if (obj == null) return obj; 193 | if (Array.prototype.forEach && obj.forEach === Array.prototype.forEach) { 194 | obj.forEach(iterator, context); 195 | } else if (obj.length === +obj.length) { 196 | for (var i = 0, length = obj.length; i < length; i++) { 197 | if (iterator.call(context, obj[i], i, obj) === breaker) return; 198 | } 199 | } else { 200 | var keys = _.keys(obj); 201 | for (var i = 0, length = keys.length; i < length; i++) { 202 | if (iterator.call(context, obj[keys[i]], keys[i], obj) === breaker) return; 203 | } 204 | } 205 | return obj; 206 | } 207 | }); 208 | --------------------------------------------------------------------------------