UNPKG

vue-virtual-scroll-list

Version:

A vue component support big amount data list with high scroll performance.

541 lines (471 loc) 17.5 kB
function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } (function (root, factory) { var namespace = 'VirtualList'; /* istanbul ignore next */ if ((typeof exports === "undefined" ? "undefined" : _typeof(exports)) === 'object' && (typeof module === "undefined" ? "undefined" : _typeof(module)) === 'object') { module.exports = factory(namespace, require('vue')); } else if (typeof define === 'function' && define.amd) { define(['vue'], factory.bind(root, namespace)); } else if ((typeof exports === "undefined" ? "undefined" : _typeof(exports)) === 'object') { exports[namespace] = factory(namespace, require('vue')); } else { root[namespace] = factory(namespace, root['Vue']); } })(this, function (namespace, Vue) { /* istanbul ignore next */ if (_typeof(Vue) === 'object' && typeof Vue.default === 'function') { Vue = Vue.default; } /* istanbul ignore next */ var _debounce = function _debounce(func, wait, immediate) { var timeout; return function () { var context = this; var args = arguments; var later = function later() { timeout = null; if (!immediate) { func.apply(context, args); } }; var callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) { func.apply(context, args); } }; }; return Vue.component(namespace, { props: { size: { type: Number, required: true }, remain: { type: Number, required: true }, rtag: { type: String, default: 'div' }, wtag: { type: String, default: 'div' }, wclass: { type: String, default: '' }, pagemode: { type: Boolean, default: false }, start: { type: Number, default: 0 }, offset: { type: Number, default: 0 }, variable: { type: [Function, Boolean], default: false }, bench: { type: Number, default: 0 // also equal to remain }, debounce: { type: Number, default: 0 }, totop: { type: [Function, Boolean], // Boolean just disable for priviate. default: false }, tobottom: { type: [Function, Boolean], // Boolean just disable for priviate. default: false }, onscroll: { type: [Function, Boolean], // Boolean just disable for priviate. default: false }, item: { type: Object, default: null }, itemcount: { type: Number, default: 0 }, itemprops: { type: Function, /* istanbul ignore next */ default: function _default() {} } }, // use changeProp to identify which prop change. watch: { size: function size() { this.changeProp = 'size'; }, remain: function remain() { this.changeProp = 'remain'; }, bench: function bench() { this.changeProp = 'bench'; this.itemModeForceRender(); }, start: function start() { this.changeProp = 'start'; this.itemModeForceRender(); }, offset: function offset() { this.changeProp = 'offset'; this.itemModeForceRender(); }, itemcount: function itemcount() { this.changeProp = 'itemcount'; this.itemModeForceRender(); } }, created: function created() { var start = this.start >= this.remain ? this.start : 0; var keeps = this.remain + (this.bench || this.remain); var delta = Object.create(null); delta.direction = ''; // current scroll direction, D: down, U: up. delta.scrollTop = 0; // current scroll top, use to direction. delta.start = start; // start index. delta.end = start + keeps - 1; // end index. delta.keeps = keeps; // nums keeping in real dom. delta.total = 0; // all items count, update in filter. delta.offsetAll = 0; // cache all the scrollable offset. delta.paddingTop = 0; // container wrapper real padding-top. delta.paddingBottom = 0; // container wrapper real padding-bottom. delta.varCache = {}; // object to cache variable index height and scroll offset. delta.varAverSize = 0; // average/estimate item height before variable be calculated. delta.varLastCalcIndex = 0; // last calculated variable height/offset index, always increase. this.delta = delta; }, mounted: function mounted() { if (this.pagemode) { window.addEventListener('scroll', this.debounce ? _debounce(this.onScroll.bind(this), this.debounce) : this.onScroll, false); } if (this.start) { var start = this.getZone(this.start).start; this.setScrollTop(this.variable ? this.getVarOffset(start) : start * this.size); } else if (this.offset) { this.setScrollTop(this.offset); } }, beforeDestroy: function beforeDestroy() { if (this.pagemode) { window.removeEventListener('scroll', this.debounce ? _debounce(this.onScroll.bind(this), this.debounce) : this.onScroll, false); } }, // check if delta should update when props change. beforeUpdate: function beforeUpdate() { var delta = this.delta; delta.keeps = this.remain + (this.bench || this.remain); var calcstart = this.changeProp === 'start' ? this.start : delta.start; var zone = this.getZone(calcstart); // if start, size or offset change, update scroll position. if (this.changeProp && ['start', 'size', 'offset'].includes(this.changeProp)) { var scrollTop = this.changeProp === 'offset' ? this.offset : this.variable ? this.getVarOffset(zone.isLast ? delta.total : zone.start) : zone.isLast && delta.total - calcstart <= this.remain ? delta.total * this.size : calcstart * this.size; this.$nextTick(this.setScrollTop.bind(this, scrollTop)); } // if points out difference, force update once again. if (this.changeProp || delta.end !== zone.end || calcstart !== zone.start) { this.changeProp = ''; delta.end = zone.end; delta.start = zone.start; this.forceRender(); } }, methods: { onScroll: function onScroll(event) { var delta = this.delta; var vsl = this.$refs.vsl; var offset = this.pagemode ? window.pageYOffset : (vsl.$el || vsl).scrollTop || 0; delta.direction = offset > delta.scrollTop ? 'D' : 'U'; delta.scrollTop = offset; if (delta.total > delta.keeps) { this.updateZone(offset); } else { delta.end = delta.total - 1; } var offsetAll = delta.offsetAll; if (this.onscroll) { var param = Object.create(null); param.offset = offset; param.offsetAll = offsetAll; param.start = delta.start; param.end = delta.end; this.onscroll(event, param); } if (!offset && delta.total) { this.fireEvent('totop'); } if (offset >= offsetAll) { this.fireEvent('tobottom'); } }, // update render zone by scroll offset. updateZone: function updateZone(offset) { var delta = this.delta; var overs = this.variable ? this.getVarOvers(offset) : Math.floor(offset / this.size); // if scroll up, we'd better decrease it's numbers. if (delta.direction === 'U') { overs = overs - this.remain + 1; } var zone = this.getZone(overs); var bench = this.bench || this.remain; // for better performance, if scroll pass items within now bench, do not update. // and if overs is going to reach last item, we should render next zone immediately. var shouldRenderNextZone = Math.abs(overs - delta.start - bench) === 1; if (!shouldRenderNextZone && overs - delta.start <= bench && !zone.isLast && overs > delta.start) { return; } // we'd better make sure forceRender calls as less as possible. if (shouldRenderNextZone || zone.start !== delta.start || zone.end !== delta.end) { delta.end = zone.end; delta.start = zone.start; this.forceRender(); } }, // return the right zone info base on `start/index`. getZone: function getZone(index) { var start, end; var delta = this.delta; index = parseInt(index, 10); index = Math.max(0, index); var lastStart = delta.total - delta.keeps; var isLast = index <= delta.total && index >= lastStart || index > delta.total; if (isLast) { end = delta.total - 1; start = Math.max(0, lastStart); } else { start = index; end = start + delta.keeps - 1; } return { end: end, start: start, isLast: isLast }; }, // public method, force render ui list if we needed. // call this before the next repaint to get better performance. forceRender: function forceRender() { var _this = this; window.requestAnimationFrame(function () { _this.$forceUpdate(); }); }, // force render ui if using item-mode. itemModeForceRender: function itemModeForceRender() { if (this.item) { this.forceRender(); } }, // return the scroll passed items count in variable. getVarOvers: function getVarOvers(offset) { var low = 0; var middle = 0; var middleOffset = 0; var delta = this.delta; var high = delta.total; while (low <= high) { middle = low + Math.floor((high - low) / 2); middleOffset = this.getVarOffset(middle); // calculate the average variable height at first binary search. if (!delta.varAverSize) { delta.varAverSize = Math.floor(middleOffset / middle); } if (middleOffset === offset) { return middle; } else if (middleOffset < offset) { low = middle + 1; } else if (middleOffset > offset) { high = middle - 1; } } return low > 0 ? --low : 0; }, // return a variable scroll offset from given index. getVarOffset: function getVarOffset(index, nocache) { var delta = this.delta; var cache = delta.varCache[index]; if (!nocache && cache) { return cache.offset; } var offset = 0; for (var i = 0; i < index; i++) { var size = this.getVarSize(i, nocache); delta.varCache[i] = { size: size, offset: offset }; offset += size; } delta.varLastCalcIndex = Math.max(delta.varLastCalcIndex, index - 1); delta.varLastCalcIndex = Math.min(delta.varLastCalcIndex, delta.total - 1); return offset; }, // return a variable size (height) from given index. getVarSize: function getVarSize(index, nocache) { var cache = this.delta.varCache[index]; if (!nocache && cache) { return cache.size; } if (typeof this.variable === 'function') { return this.variable(index) || 0; } else { // when using item, it can only get current components height, // need to be enhanced, or consider using variable-function instead var slot = this.item ? this.$children[index] ? this.$children[index].$vnode : null : this.$slots.default[index]; var style = slot && slot.data && slot.data.style; if (style && style.height) { var shm = style.height.match(/^(.*)px$/); return shm && +shm[1] || 0; } } return 0; }, // return the variable paddingTop base current zone. // @todo: if set a large `start` before variable was calculated, // here will also case too much offset calculate when list is very large, // consider use estimate paddingTop in this case just like `getVarPaddingBottom`. getVarPaddingTop: function getVarPaddingTop() { return this.getVarOffset(this.delta.start); }, // return the variable paddingBottom base current zone. getVarPaddingBottom: function getVarPaddingBottom() { var delta = this.delta; var last = delta.total - 1; if (delta.total - delta.end <= delta.keeps || delta.varLastCalcIndex === last) { return this.getVarOffset(last) - this.getVarOffset(delta.end); } else { // if unreached last zone or uncalculate real behind offset // return the estimate paddingBottom avoid too much calculate. return (delta.total - delta.end) * (delta.varAverSize || this.size); } }, // retun the variable all heights use to judge reach bottom. getVarAllHeight: function getVarAllHeight() { var delta = this.delta; if (delta.total - delta.end <= delta.keeps || delta.varLastCalcIndex === delta.total - 1) { return this.getVarOffset(delta.total); } else { return this.getVarOffset(delta.start) + (delta.total - delta.end) * (delta.varAverSize || this.size); } }, // public method, allow the parent update variable by index. updateVariable: function updateVariable(index) { // clear/update all the offfsets and heights ahead of index. this.getVarOffset(index, true); }, // trigger a props event on parent. fireEvent: function fireEvent(event) { if (this[event]) { this[event](); } }, // set manual scroll top. setScrollTop: function setScrollTop(scrollTop) { if (this.pagemode) { window.scrollTo(0, scrollTop); } else { var vsl = this.$refs.vsl; if (vsl) { (vsl.$el || vsl).scrollTop = scrollTop; } } }, // filter the shown items base on `start` and `end`. filter: function filter(h) { var delta = this.delta; var slots = this.$slots.default || []; // item-mode shoud judge from items prop. if (this.item) { delta.total = this.itemcount; if (delta.keeps > delta.total) { delta.end = delta.total - 1; } } else { if (!slots.length) { delta.start = 0; } delta.total = slots.length; } var paddingTop, paddingBottom, allHeight; var hasPadding = delta.total > delta.keeps; if (this.variable) { allHeight = this.getVarAllHeight(); paddingTop = hasPadding ? this.getVarPaddingTop() : 0; paddingBottom = hasPadding ? this.getVarPaddingBottom() : 0; } else { allHeight = this.size * delta.total; paddingTop = this.size * (hasPadding ? delta.start : 0); paddingBottom = this.size * (hasPadding ? delta.total - delta.keeps : 0) - paddingTop; } if (paddingBottom < this.size) { paddingBottom = 0; } if (this.pagemode && this.$el && this.$el.parentElement) { var bodyRect = document.body.getBoundingClientRect(); var elemRect = this.$el.parentElement.getBoundingClientRect(); var offset = elemRect.top - bodyRect.top; paddingTop -= offset; if (paddingTop < 0) paddingTop = 0; } delta.paddingTop = paddingTop; delta.paddingBottom = paddingBottom; delta.offsetAll = allHeight - this.size * this.remain; var renders = []; for (var i = delta.start; i < delta.total && i <= Math.ceil(delta.end); i++) { var slot = null; if (this.item) { slot = h(this.item, this.itemprops(i)); } else { slot = slots[i]; } renders.push(slot); } return renders; } }, render: function render(h) { var dbc = this.debounce; var list = this.filter(h); var _this$delta = this.delta, paddingTop = _this$delta.paddingTop, paddingBottom = _this$delta.paddingBottom; var renderList = h(this.wtag, { 'style': { 'display': 'block', 'padding-top': paddingTop + 'px', 'padding-bottom': paddingBottom + 'px' }, 'class': this.wclass, 'attrs': { 'role': 'group' } }, list); // page mode just render list, no wraper. if (this.pagemode) { return renderList; } return h(this.rtag, { 'ref': 'vsl', 'style': { 'display': 'block', 'overflow-y': 'auto', 'height': this.size * this.remain + 'px' }, 'on': { '&scroll': dbc ? _debounce(this.onScroll.bind(this), dbc) : this.onScroll } }, [renderList]); } }); });