├── .npmignore ├── .gitignore ├── .editorconfig ├── rollup.config.js ├── src ├── index.js └── directive.js ├── example └── index.html ├── bower.json ├── package.json ├── karma.conf.js ├── README.MD └── test └── unit └── vue-infinite-scroll.test.js /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | coverage 4 | .idea 5 | .jshintrc 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | coverage 4 | .idea 5 | .jshintrc 6 | vue-infinite-scroll.js 7 | *.log -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | 3 | export default { 4 | entry: './src/index.js', 5 | dest: 'vue-infinite-scroll.js', 6 | plugins: [ 7 | babel({ 8 | exclude: 'node_modules/**', 9 | presets: ['es2015-rollup'] 10 | }) 11 | ], 12 | format: 'umd', 13 | moduleName: 'infiniteScroll' 14 | }; 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import InfiniteScroll from './directive'; 2 | 3 | const install = function(Vue) { 4 | Vue.directive('InfiniteScroll', InfiniteScroll); 5 | }; 6 | 7 | if (window.Vue) { 8 | window.infiniteScroll = InfiniteScroll; 9 | Vue.use(install); // eslint-disable-line 10 | } 11 | 12 | InfiniteScroll.install = install; 13 | export default InfiniteScroll; 14 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | vue-infinite-scroll 6 | 7 | 8 |
13 |
14 | 15 | 16 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-infinite-scroll", 3 | "description": "An infinite scroll directive for vue.js.", 4 | "keywords": [ 5 | "infinite-scroll", 6 | "vue" 7 | ], 8 | "main": [ 9 | "vue-infinite-scroll.js" 10 | ], 11 | "dependencies": { 12 | "vue": "~1.0.10" 13 | }, 14 | "devDependencies": { 15 | "babel-preset-es2015-rollup": "~1.1.1", 16 | "jasmine-core": "~2.4.1", 17 | "karma": "~0.13.21", 18 | "karma-chrome-launcher": "~0.2.2", 19 | "karma-jasmine": "~0.3.7", 20 | "karma-phantomjs-launcher": "~1.0.0", 21 | "karma-rollup-preprocessor": "~2.0.1", 22 | "phantomjs-prebuilt": "~2.1.5", 23 | "rollup": "~0.25.4", 24 | "rollup-plugin-babel": "~2.4.0", 25 | "rollup-plugin-commonjs": "~2.2.1", 26 | "rollup-plugin-env": "~0.21.2", 27 | "rollup-plugin-node-resolve": "~1.5.0", 28 | "xo": "~0.12.1" 29 | }, 30 | "authors": "long.zhang@ele.me", 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/ElemeFE/vue-infinite-scroll.git" 34 | }, 35 | "license": "MIT", 36 | "ignore": [ 37 | "**/*.txt", 38 | "README", 39 | "package.json", 40 | ".gitignore", 41 | "bower.json" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-infinite-scroll", 3 | "version": "2.0.2", 4 | "description": "An infinite scroll directive for vue.js.", 5 | "main": "vue-infinite-scroll.js", 6 | "jsnext:main": "./src/index.js", 7 | "scripts": { 8 | "test": "npm run build && xo src/**/* && karma start", 9 | "build": "rollup -c", 10 | "prepublish": "npm run build" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/ElemeFE/vue-infinite-scroll.git" 15 | }, 16 | "keywords": [ 17 | "infinite-scroll", 18 | "vue" 19 | ], 20 | "author": "long.zhang@ele.me", 21 | "license": "MIT", 22 | "devDependencies": { 23 | "babel-preset-es2015-rollup": "^3.0.0", 24 | "jasmine-core": "^2.4.1", 25 | "karma": "^0.13.21", 26 | "karma-chrome-launcher": "^0.2.2", 27 | "karma-jasmine": "^0.3.7", 28 | "karma-phantomjs-launcher": "^1.0.0", 29 | "karma-rollup-preprocessor": "^2.0.1", 30 | "phantomjs-prebuilt": "^2.1.5", 31 | "rollup": "^0.41.4", 32 | "rollup-plugin-babel": "^2.7.1", 33 | "rollup-plugin-uglify": "1.0.1", 34 | "rollup-plugin-node-resolve": "^2.0.0", 35 | "vue": "^2.0.3", 36 | "xo": "^0.12.1" 37 | }, 38 | "xo": { 39 | "envs": [ 40 | "browser" 41 | ], 42 | "space": true 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Tue Mar 08 2016 13:37:35 GMT+0800 (CST) 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | 7 | // base path that will be used to resolve all patterns (eg. files, exclude) 8 | basePath: '', 9 | 10 | 11 | // frameworks to use 12 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 13 | frameworks: ['jasmine'], 14 | 15 | 16 | // list of files / patterns to load in the browser 17 | files: [ 18 | 'test/**/*.js' 19 | ], 20 | 21 | 22 | // list of files to exclude 23 | exclude: [ 24 | ], 25 | 26 | 27 | // preprocess matching files before serving them to the browser 28 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 29 | preprocessors: { 30 | 'test/**/*.js': ['rollup'] 31 | }, 32 | 33 | rollupPreprocessor: { 34 | rollup: { 35 | plugins: [ 36 | require('rollup-plugin-babel')({ 37 | exclude: 'node_modules/**', 38 | presets: [ 39 | require('babel-preset-es2015-rollup') 40 | ] 41 | }), 42 | require('rollup-plugin-node-resolve')({ 43 | jsnext: true, 44 | main: true 45 | }), 46 | require('rollup-plugin-commonjs')(), 47 | require('rollup-plugin-env')({}) 48 | ] 49 | }, 50 | bundle: { 51 | sourceMap: 'inline' 52 | } 53 | }, 54 | 55 | 56 | // test results reporter to use 57 | // possible values: 'dots', 'progress' 58 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 59 | reporters: ['progress'], 60 | 61 | 62 | // web server port 63 | port: 9876, 64 | 65 | 66 | // enable / disable colors in the output (reporters and logs) 67 | colors: true, 68 | 69 | 70 | // level of logging 71 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 72 | logLevel: config.LOG_INFO, 73 | 74 | 75 | // enable / disable watching file and executing tests whenever any file changes 76 | autoWatch: true, 77 | 78 | 79 | // start these browsers 80 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 81 | browsers: ['PhantomJS_custom'], 82 | 83 | // you can define custom flags 84 | customLaunchers: { 85 | 'PhantomJS_custom': { 86 | base: 'PhantomJS', 87 | options: { 88 | viewportSize: { width: 480, height: 800 } 89 | } 90 | } 91 | }, 92 | 93 | 94 | phantomjsLauncher: { 95 | // Have phantomjs exit if a ResourceError is encountered (useful if karma exits without killing phantom) 96 | exitOnResourceError: true 97 | }, 98 | 99 | 100 | // Continuous Integration mode 101 | // if true, Karma captures browsers, runs the tests and exits 102 | singleRun: true, 103 | 104 | // Concurrency level 105 | // how many browser should be started simultaneous 106 | concurrency: Infinity 107 | }) 108 | } 109 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # vue-infinite-scroll 2 | 3 | vue-infinite-scroll is an infinite scroll directive for vue.js. 4 | 5 | # Install 6 | 7 | ```Bash 8 | npm install vue-infinite-scroll --save 9 | ``` 10 | 11 | ### CommonJS 12 | 13 | You can use any build tool which supports `commonjs`: 14 | 15 | ```JavaScript 16 | // register globally 17 | var infiniteScroll = require('vue-infinite-scroll'); 18 | Vue.use(infiniteScroll) 19 | 20 | // or for a single instance 21 | var infiniteScroll = require('vue-infinite-scroll'); 22 | new Vue({ 23 | directives: {infiniteScroll} 24 | }) 25 | 26 | ``` 27 | 28 | Or in ES2015: 29 | 30 | ```JavaScript 31 | // register globally 32 | import infiniteScroll from 'vue-infinite-scroll' 33 | Vue.use(infiniteScroll) 34 | 35 | // or for a single instance 36 | import infiniteScroll from 'vue-infinite-scroll' 37 | new Vue({ 38 | directives: {infiniteScroll} 39 | }) 40 | 41 | ``` 42 | 43 | ### Direct include 44 | 45 | You can use the CDN: https://unpkg.com/vue-infinite-scroll, `infiniteScroll` is exposed to `window` and will automatically install itself. Also you can use your local copy: 46 | 47 | ```HTML 48 | 49 | ``` 50 | 51 | ## Usage 52 | 53 | Use v-infinite-scroll to enable the infinite scroll, and use infinite-scroll-* attributes to define its options. 54 | 55 | The method appointed as the value of v-infinite-scroll will be executed when the bottom of the element reaches the bottom of the viewport. 56 | 57 | ```HTML 58 |
59 | ... 60 |
61 | ``` 62 | 63 | ```JavaScript 64 | var count = 0; 65 | 66 | new Vue({ 67 | el: '#app', 68 | data: { 69 | data: [], 70 | busy: false 71 | }, 72 | methods: { 73 | loadMore: function() { 74 | this.busy = true; 75 | 76 | setTimeout(() => { 77 | for (var i = 0, j = 10; i < j; i++) { 78 | this.data.push({ name: count++ }); 79 | } 80 | this.busy = false; 81 | }, 1000); 82 | } 83 | } 84 | }); 85 | ``` 86 | 87 | # Options 88 | 89 | | Option | Description | 90 | | ----- | ----- | 91 | | infinite-scroll-disabled | infinite scroll will be disabled if the value of this attribute is true. | 92 | | infinite-scroll-distance | Number(default = 0) - the minimum distance between the bottom of the element and the bottom of the viewport before the v-infinite-scroll method is executed. | 93 | | infinite-scroll-immediate-check | Boolean(default = true) - indicates that the directive should check immediately after bind. Useful if it's possible that the content is not tall enough to fill up the scrollable container. | 94 | | infinite-scroll-listen-for-event | infinite scroll will check again when the event is emitted in Vue instance. | 95 | | infinite-scroll-throttle-delay | Number(default = 200) - interval(ms) between next time checking and this time | 96 | 97 | ## Development 98 | 99 | |Command|Description| 100 | |---|---| 101 | |npm run build|Build in umd format| 102 | |npm test|Lint code| 103 | 104 | # License 105 | 106 | MIT 107 | -------------------------------------------------------------------------------- /test/unit/vue-infinite-scroll.test.js: -------------------------------------------------------------------------------- 1 | import infiniteScroll from './../../vue-infinite-scroll'; 2 | import Vue from 'vue'; 3 | 4 | const scrollToBottom = (targetElement, distance = 0) => { 5 | if (targetElement === 'parentNode') { 6 | const element = document.querySelector('.app'); 7 | 8 | element.scrollTop = element.getBoundingClientRect().top + element.getBoundingClientRect().bottom - distance; 9 | } else { 10 | const element = document.querySelector('.app'); 11 | 12 | element.scrollTop = element.scrollHeight - element.offsetHeight - distance; 13 | } 14 | }; 15 | const scrollToTop = (targetElement) => { 16 | document.querySelector('.app').scrollTop = 0; 17 | }; 18 | const createVM = (targetElement = 'window', distance = 0, immediate = true) => { 19 | let template; 20 | switch(targetElement) { 21 | case 'window': 22 | template = `
29 |
`; 30 | break; 31 | case 'parentNode': 32 | template = `
34 |
40 |
41 |
`; 42 | break; 43 | case 'currentNode': 44 | default: 45 | template = `
52 |
1
53 |
`; 54 | break; 55 | } 56 | 57 | return new Vue({ 58 | el() { 59 | const element = document.createElement('div'); 60 | 61 | document.querySelector('body').appendChild(element); 62 | return element; 63 | }, 64 | data() { 65 | return { 66 | busy: false 67 | }; 68 | }, 69 | template, 70 | methods: { 71 | loadMore() { 72 | this.busy = true; 73 | console.log('loaded!'); 74 | } 75 | }, 76 | events: { 77 | ['docheck']() { 78 | console.log('tick'); 79 | } 80 | } 81 | }) 82 | } 83 | 84 | describe('init infinite-scroll directive', () => { 85 | beforeAll(done => { 86 | Vue.use(infiniteScroll); 87 | done(); 88 | }); 89 | 90 | it('directive installed', done => { 91 | expect(infiniteScroll.installed).toBe(true); 92 | done(); 93 | }); 94 | }); 95 | 96 | const scrollTargetElements = ['parentNode', 'currentNode']; 97 | 98 | scrollTargetElements.forEach(targetElement => { 99 | describe(`${targetElement} scroll test`, () => { 100 | let vm; 101 | 102 | beforeEach(done =>{ 103 | vm = createVM(targetElement); 104 | 105 | vm.$nextTick(() => { 106 | spyOn(vm, 'loadMore'); 107 | 108 | scrollToBottom(targetElement); 109 | scrollToTop(targetElement); 110 | scrollToBottom(targetElement); 111 | 112 | setTimeout(done); 113 | }); 114 | }); 115 | 116 | it('the function should be called once', done => { 117 | expect(vm.loadMore.calls.count()).toEqual(1); 118 | done(); 119 | }); 120 | 121 | it('test "infinite-scroll-listen-for-event"', done => { 122 | vm.$emit('docheck'); 123 | expect(vm.loadMore.calls.count()).toEqual(2); 124 | done(); 125 | }); 126 | 127 | afterEach(done => { 128 | vm.$destroy(true); 129 | done(); 130 | }); 131 | }); 132 | 133 | describe(`${targetElement} scroll distance test`, () => { 134 | let vm; 135 | 136 | beforeEach(done => { 137 | vm = createVM(targetElement, 50); 138 | 139 | vm.$nextTick(() => { 140 | spyOn(vm, 'loadMore'); 141 | setTimeout(done); 142 | }); 143 | }); 144 | 145 | it('the function should be called when scroll to bottom', done => { 146 | scrollToBottom(targetElement, 0); 147 | 148 | setTimeout(() => { 149 | expect(vm.loadMore).toHaveBeenCalled(); 150 | done(); 151 | }); 152 | }); 153 | 154 | it('the function should be called when scroll to the bottom of 50px distance', done => { 155 | scrollToBottom(targetElement, 50); 156 | setTimeout(() => { 157 | expect(vm.loadMore).toHaveBeenCalled(); 158 | done(); 159 | }); 160 | }); 161 | 162 | it('the function should not be called', done => { 163 | scrollToBottom(targetElement, 51); 164 | setTimeout(() => { 165 | expect(vm.loadMore).not.toHaveBeenCalled(); 166 | done(); 167 | }); 168 | }); 169 | 170 | afterEach(done => { 171 | vm.$destroy(true); 172 | done(); 173 | }); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /src/directive.js: -------------------------------------------------------------------------------- 1 | const ctx = '@@InfiniteScroll'; 2 | 3 | var throttle = function (fn, delay) { 4 | var now, lastExec, timer, context, args; //eslint-disable-line 5 | 6 | var execute = function () { 7 | fn.apply(context, args); 8 | lastExec = now; 9 | }; 10 | 11 | return function () { 12 | context = this; 13 | args = arguments; 14 | 15 | now = Date.now(); 16 | 17 | if (timer) { 18 | clearTimeout(timer); 19 | timer = null; 20 | } 21 | 22 | if (lastExec) { 23 | var diff = delay - (now - lastExec); 24 | if (diff < 0) { 25 | execute(); 26 | } else { 27 | timer = setTimeout(() => { 28 | execute(); 29 | }, diff); 30 | } 31 | } else { 32 | execute(); 33 | } 34 | }; 35 | }; 36 | 37 | var getScrollTop = function (element) { 38 | if (element === window) { 39 | return Math.max(window.pageYOffset || 0, document.documentElement.scrollTop); 40 | } 41 | 42 | return element.scrollTop; 43 | }; 44 | 45 | var getComputedStyle = document.defaultView.getComputedStyle; 46 | 47 | var getScrollEventTarget = function (element) { 48 | var currentNode = element; 49 | // bugfix, see http://w3help.org/zh-cn/causes/SD9013 and http://stackoverflow.com/questions/17016740/onscroll-function-is-not-working-for-chrome 50 | while (currentNode && currentNode.tagName !== 'HTML' && currentNode.tagName !== 'BODY' && currentNode.nodeType === 1) { 51 | var overflowY = getComputedStyle(currentNode).overflowY; 52 | if (overflowY === 'scroll' || overflowY === 'auto') { 53 | return currentNode; 54 | } 55 | currentNode = currentNode.parentNode; 56 | } 57 | return window; 58 | }; 59 | 60 | var getVisibleHeight = function (element) { 61 | if (element === window) { 62 | return document.documentElement.clientHeight; 63 | } 64 | 65 | return element.clientHeight; 66 | }; 67 | 68 | var getElementTop = function (element) { 69 | if (element === window) { 70 | return getScrollTop(window); 71 | } 72 | return element.getBoundingClientRect().top + getScrollTop(window); 73 | }; 74 | 75 | var isAttached = function (element) { 76 | var currentNode = element.parentNode; 77 | while (currentNode) { 78 | if (currentNode.tagName === 'HTML') { 79 | return true; 80 | } 81 | if (currentNode.nodeType === 11) { 82 | return false; 83 | } 84 | currentNode = currentNode.parentNode; 85 | } 86 | return false; 87 | }; 88 | 89 | var doBind = function () { 90 | if (this.binded) return; // eslint-disable-line 91 | this.binded = true; 92 | 93 | var directive = this; 94 | var element = directive.el; 95 | 96 | var throttleDelayExpr = element.getAttribute('infinite-scroll-throttle-delay'); 97 | var throttleDelay = 200; 98 | if (throttleDelayExpr) { 99 | throttleDelay = Number(directive.vm[throttleDelayExpr] || throttleDelayExpr); 100 | if (isNaN(throttleDelay) || throttleDelay < 0) { 101 | throttleDelay = 200; 102 | } 103 | } 104 | directive.throttleDelay = throttleDelay; 105 | 106 | directive.scrollEventTarget = getScrollEventTarget(element); 107 | directive.scrollListener = throttle(doCheck.bind(directive), directive.throttleDelay); 108 | directive.scrollEventTarget.addEventListener('scroll', directive.scrollListener); 109 | 110 | this.vm.$on('hook:beforeDestroy', function () { 111 | directive.scrollEventTarget.removeEventListener('scroll', directive.scrollListener); 112 | }); 113 | 114 | var disabledExpr = element.getAttribute('infinite-scroll-disabled'); 115 | var disabled = false; 116 | 117 | if (disabledExpr) { 118 | this.vm.$watch(disabledExpr, function(value) { 119 | directive.disabled = value; 120 | if (!value && directive.immediateCheck) { 121 | doCheck.call(directive); 122 | } 123 | }); 124 | disabled = Boolean(directive.vm[disabledExpr]); 125 | } 126 | directive.disabled = disabled; 127 | 128 | var distanceExpr = element.getAttribute('infinite-scroll-distance'); 129 | var distance = 0; 130 | if (distanceExpr) { 131 | distance = Number(directive.vm[distanceExpr] || distanceExpr); 132 | if (isNaN(distance)) { 133 | distance = 0; 134 | } 135 | } 136 | directive.distance = distance; 137 | 138 | var immediateCheckExpr = element.getAttribute('infinite-scroll-immediate-check'); 139 | var immediateCheck = true; 140 | if (immediateCheckExpr) { 141 | immediateCheck = Boolean(directive.vm[immediateCheckExpr]); 142 | } 143 | directive.immediateCheck = immediateCheck; 144 | 145 | if (immediateCheck) { 146 | doCheck.call(directive); 147 | } 148 | 149 | var eventName = element.getAttribute('infinite-scroll-listen-for-event'); 150 | if (eventName) { 151 | directive.vm.$on(eventName, function() { 152 | doCheck.call(directive); 153 | }); 154 | } 155 | }; 156 | 157 | var doCheck = function (force) { 158 | var scrollEventTarget = this.scrollEventTarget; 159 | var element = this.el; 160 | var distance = this.distance; 161 | 162 | if (force !== true && this.disabled) return; //eslint-disable-line 163 | var viewportScrollTop = getScrollTop(scrollEventTarget); 164 | var viewportBottom = viewportScrollTop + getVisibleHeight(scrollEventTarget); 165 | 166 | var shouldTrigger = false; 167 | 168 | if (scrollEventTarget === element) { 169 | shouldTrigger = scrollEventTarget.scrollHeight - viewportBottom <= distance; 170 | } else { 171 | var elementBottom = getElementTop(element) - getElementTop(scrollEventTarget) + element.offsetHeight + viewportScrollTop; 172 | 173 | shouldTrigger = viewportBottom + distance >= elementBottom; 174 | } 175 | 176 | if (shouldTrigger && this.expression) { 177 | this.expression(); 178 | } 179 | }; 180 | 181 | export default { 182 | bind(el, binding, vnode) { 183 | el[ctx] = { 184 | el, 185 | vm: vnode.context, 186 | expression: binding.value 187 | }; 188 | const args = arguments; 189 | el[ctx].vm.$on('hook:mounted', function () { 190 | el[ctx].vm.$nextTick(function () { 191 | if (isAttached(el)) { 192 | doBind.call(el[ctx], args); 193 | } 194 | 195 | el[ctx].bindTryCount = 0; 196 | 197 | var tryBind = function () { 198 | if (el[ctx].bindTryCount > 10) return; //eslint-disable-line 199 | el[ctx].bindTryCount++; 200 | if (isAttached(el)) { 201 | doBind.call(el[ctx], args); 202 | } else { 203 | setTimeout(tryBind, 50); 204 | } 205 | }; 206 | 207 | tryBind(); 208 | }); 209 | }); 210 | }, 211 | 212 | unbind(el) { 213 | if (el && el[ctx] && el[ctx].scrollEventTarget) 214 | el[ctx].scrollEventTarget.removeEventListener('scroll', el[ctx].scrollListener); 215 | } 216 | }; 217 | --------------------------------------------------------------------------------