├── .gitignore ├── README.md ├── dev ├── app.vue ├── index.html ├── index.js └── umd-example.html ├── dist ├── list-view-umd.js └── list-view.js ├── package-lock.json ├── package.json ├── packages └── list-view.vue ├── src └── list-view-umd.js ├── webpack.build-umd.config.js ├── webpack.build.config.js └── webpack.dev.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.idea 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-better-list-view 2 | 3 | vue长列表按需渲染,[DEMO](https://nossika.github.io/vue-better-list-view/dev/umd-example.html) 4 | 5 | ## 使用 6 | 7 | 提供npm版本和umd版本 8 | 9 | npm 安装 10 | 11 | // install 12 | npm i vue-better-list-view --save-dev 13 | 14 | // in your project 15 | import listView from 'vue-better-list-view' 16 | 17 | 或 umd 引入 18 | 19 | 20 | 23 | 24 | 用法示例: 25 | 26 |
27 | 33 |
34 |
35 | NO: {{ scope.item.value }}, height: {{ scope.height }}px 36 |
37 |
38 |
39 |
40 | 41 | 调试: 42 | 43 | npm run dev 44 | 45 | ## API 46 | 47 | ### 属性 48 | 49 | #### list:array 50 | 51 | 列表源数据 52 | 53 | #### item-height-getter: function(item, index) 54 | 55 | 自定义逻辑计算每一行的高度,根据item(list中的列表项)和index(在list中的索引)返回height(number类型,单位px)。 56 | 57 | > 此函数在组件生命周期内只会对每个列表项最多求值一次,之后都从缓存取值。在提供了default-item-height的情况下,组件会按需调用此函数,即对可视窗口以外的且未缓存的列表项使用default-item-height作为预估行高。因此建议提供default-item-height来提高性能。 58 | 59 | #### default-item-height: number 60 | 61 | 列表每行item的默认高度值(px)。 62 | 63 | > 没有item-height-getter的情况下此值会是所有行的行高;有item-height-getter的情况下,此值会被用于预估未缓存项的行高,详见item-height-getter说明。 64 | 65 | ### 事件 66 | 67 | #### scroll({ topItemIndex, bottomItemIndex, listTotalHeight, scrollTop }) 68 | 69 | 列表滚动时触发。参数:topItemIndex:number(可视列表第一项的index);bottomItemIndex:number(可视列表最后项的index);listTotalHeight:number(列表总高度);scrollTop:number(滚动距离) 70 | 71 | ### scoped-slot 72 | 73 | #### scope: {item, index, height, offset} 74 | item(列表项);index(列表项索引);height(列表项高度);offset(列表项底部距列表顶部距离) 75 | 76 | ### 方法 77 | 78 | #### refreshView(config = {}) 79 | 80 | 重新渲染列表。config.clearCache为true时清空item-height-getter的缓存值;config.resize为true时对可视列表DOM高度重新取值。 81 | 82 | > clearCache一般在list发生改变的时候使用,除了push操作不需要(因为只是往数组末尾新增项的话之前缓存值是可以继续使用的)。resize一般在容器高度变化的时候使用。 83 | 84 | > 例子:this.$refs['list-vue'].refreshView({ resize: true }); 85 | 86 | ## 提示 87 | 88 | item-height-getter和default-item-height二者必须提供其一,使用item-height-getter的同时提供default-item-height可得到更好的性能。 89 | 90 | ## 参考文章 91 | 92 | [再谈前端虚拟列表的实现](https://zhuanlan.zhihu.com/p/34585166) 93 | -------------------------------------------------------------------------------- /dev/app.vue: -------------------------------------------------------------------------------- 1 | 16 | 65 | -------------------------------------------------------------------------------- /dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | list-view test 6 | 7 | 8 |
9 |
10 | 11 | -------------------------------------------------------------------------------- /dev/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'Vue'; 2 | import App from './app.vue'; 3 | 4 | new Vue({ 5 | el: '#app', 6 | render: h => h(App), 7 | }); 8 | -------------------------------------------------------------------------------- /dev/umd-example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | list-view example 6 | 19 | 20 | 21 | 22 |
23 |
24 | 31 |
32 |
33 | NO: {{ scope.item.no }}, height: {{ scope.height }}px, offset: {{scope.offset}}px 34 |
35 |
36 |
37 |
38 |
39 | 40 | 41 | 89 | 90 | -------------------------------------------------------------------------------- /dist/list-view-umd.js: -------------------------------------------------------------------------------- 1 | !function(t,e){if("object"==typeof exports&&"object"==typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var i=e();for(var s in i)("object"==typeof exports?exports:t)[s]=i[s]}}(window,function(){return function(t){var e={};function i(s){if(e[s])return e[s].exports;var n=e[s]={i:s,l:!1,exports:{}};return t[s].call(n.exports,n,n.exports,i),n.l=!0,n.exports}return i.m=t,i.c=e,i.d=function(t,e,s){i.o(t,e)||Object.defineProperty(t,e,{configurable:!1,enumerable:!0,get:s})},i.r=function(t){Object.defineProperty(t,"__esModule",{value:!0})},i.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(e,"a",e),e},i.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},i.p="",i(i.s=1)}([function(t,e,i){"use strict";i.r(e);var s={props:{list:Array,itemHeightGetter:Function,defaultItemHeight:Number},data:()=>({listView:[],listTotalHeight:0,itemOffsetCache:[],topItemIndex:0}),computed:{listViewWithInfo(){return this.listView.map((t,e)=>{const i=this.topItemIndex+e,{height:s,offset:n}=this.getItemInfo(i);return{index:i,item:t,height:s,offset:n}})}},watch:{list(){this.refreshView()}},methods:{refreshView(t){t&&(t.resize&&(this._viewHeight=this.$refs.wrapper.clientHeight),t.clearCache&&(this.itemOffsetCache=[]));const e=this.$refs.wrapper.scrollTop,i=this._viewHeight,s=this.findItemIndexByOffset(e),n=this.findItemIndexByOffset(e+i);this.topItemIndex=s,this.listView=this.list.slice(s,n+1);const o=this.defaultItemHeight?this.getItemInfo(this.itemOffsetCache.length-1).offset+(this.list.length-this.itemOffsetCache.length)*this.defaultItemHeight:this.getItemInfo(this.list.length-1).offset;this.listTotalHeight=o,this.$refs["item-wrapper"].style.transform=`translateY(${this.getItemInfo(s-1).offset}px)`,this.$emit("scroll",{topItemIndex:s,bottomItemIndex:n,listTotalHeight:o,scrollTop:e})},findItemIndexByOffset(t){if(t>=this.getItemInfo(this.itemOffsetCache.length-1).offset){for(let e=this.itemOffsetCache.length;et)return e;return this.list.length-1}{let e=0,i=this.itemOffsetCache.length-1;for(;et?i=s-1:e=s+1}return this.getItemInfo(e).offsett&&(e+=1),e}},getItemInfo(t){if(t<0||t>this.list.length-1)return{offset:0,height:0};let e=this.itemOffsetCache[t];if(!e){let i=this.itemHeightGetter?this.itemHeightGetter(this.list[t],t):this.defaultItemHeight;e=this.itemOffsetCache[t]={height:i,offset:this.getItemInfo(t-1).offset+i}}return e}},mounted(){this.refreshView({resize:!0})}},n=function(){var t=this,e=t.$createElement,i=t._self._c||e;return i("div",{ref:"wrapper",staticStyle:{width:"100%",height:"100%",overflow:"auto",position:"relative",margin:"0",padding:"0",border:"none"},on:{scroll:function(e){t.refreshView()}}},[i("div",{staticStyle:{width:"100%",padding:"0",margin:"0"},style:{height:t.listTotalHeight+"px"}}),t._v(" "),i("div",{ref:"item-wrapper",staticStyle:{position:"absolute",top:"0",left:"0",width:"100%",padding:"0",margin:"0"}},t._l(t.listViewWithInfo,function(e,s){return i("div",{key:e.index,style:{height:e.height+"px"}},[t._t("default",null,{item:e.item,height:e.height,offset:e.offset,index:e.index})],2)}))])};n._withStripped=!0;var o=function(t,e,i,s,n,o,r,f){var h=typeof(t=t||{}).default;"object"!==h&&"function"!==h||(t=t.default);var l,a="function"==typeof t?t.options:t;if(e&&(a.render=e,a.staticRenderFns=i,a._compiled=!0),s&&(a.functional=!0),o&&(a._scopeId=o),r?(l=function(t){(t=t||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext)||"undefined"==typeof __VUE_SSR_CONTEXT__||(t=__VUE_SSR_CONTEXT__),n&&n.call(this,t),t&&t._registeredComponents&&t._registeredComponents.add(r)},a._ssrRegister=l):n&&(l=f?function(){n.call(this,this.$root.$options.shadowRoot)}:n),l)if(a.functional){a._injectStyles=l;var u=a.render;a.render=function(t,e){return l.call(e),u(t,e)}}else{var d=a.beforeCreate;a.beforeCreate=d?[].concat(d,l):[l]}return{exports:t,options:a}}(s,n,[],!1,null,null,null);o.options.__file="packages\\list-view.vue";e.default=o.exports},function(t,e,i){"use strict";Object.defineProperty(e,"__esModule",{value:!0}),e.listView=void 0;var s,n=i(0),o=(s=n)&&s.__esModule?s:{default:s};var r={install:function(t){t.component("list-view",o.default)}};window&&window.Vue&&window.Vue.use(r),e.listView=r}])}); -------------------------------------------------------------------------------- /dist/list-view.js: -------------------------------------------------------------------------------- 1 | !function(t,e){if("object"==typeof exports&&"object"==typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var i=e();for(var s in i)("object"==typeof exports?exports:t)[s]=i[s]}}(window,function(){return function(t){var e={};function i(s){if(e[s])return e[s].exports;var n=e[s]={i:s,l:!1,exports:{}};return t[s].call(n.exports,n,n.exports,i),n.l=!0,n.exports}return i.m=t,i.c=e,i.d=function(t,e,s){i.o(t,e)||Object.defineProperty(t,e,{configurable:!1,enumerable:!0,get:s})},i.r=function(t){Object.defineProperty(t,"__esModule",{value:!0})},i.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(e,"a",e),e},i.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},i.p="",i(i.s=0)}([function(t,e,i){"use strict";i.r(e);var s={props:{list:Array,itemHeightGetter:Function,defaultItemHeight:Number},data:()=>({listView:[],listTotalHeight:0,itemOffsetCache:[],topItemIndex:0}),computed:{listViewWithInfo(){return this.listView.map((t,e)=>{const i=this.topItemIndex+e,{height:s,offset:n}=this.getItemInfo(i);return{index:i,item:t,height:s,offset:n}})}},watch:{list(){this.refreshView()}},methods:{refreshView(t){t&&(t.resize&&(this._viewHeight=this.$refs.wrapper.clientHeight),t.clearCache&&(this.itemOffsetCache=[]));const e=this.$refs.wrapper.scrollTop,i=this._viewHeight,s=this.findItemIndexByOffset(e),n=this.findItemIndexByOffset(e+i);this.topItemIndex=s,this.listView=this.list.slice(s,n+1);const o=this.defaultItemHeight?this.getItemInfo(this.itemOffsetCache.length-1).offset+(this.list.length-this.itemOffsetCache.length)*this.defaultItemHeight:this.getItemInfo(this.list.length-1).offset;this.listTotalHeight=o,this.$refs["item-wrapper"].style.transform=`translateY(${this.getItemInfo(s-1).offset}px)`,this.$emit("scroll",{topItemIndex:s,bottomItemIndex:n,listTotalHeight:o,scrollTop:e})},findItemIndexByOffset(t){if(t>=this.getItemInfo(this.itemOffsetCache.length-1).offset){for(let e=this.itemOffsetCache.length;et)return e;return this.list.length-1}{let e=0,i=this.itemOffsetCache.length-1;for(;et?i=s-1:e=s+1}return this.getItemInfo(e).offsett&&(e+=1),e}},getItemInfo(t){if(t<0||t>this.list.length-1)return{offset:0,height:0};let e=this.itemOffsetCache[t];if(!e){let i=this.itemHeightGetter?this.itemHeightGetter(this.list[t],t):this.defaultItemHeight;e=this.itemOffsetCache[t]={height:i,offset:this.getItemInfo(t-1).offset+i}}return e}},mounted(){this.refreshView({resize:!0})}},n=function(){var t=this,e=t.$createElement,i=t._self._c||e;return i("div",{ref:"wrapper",staticStyle:{width:"100%",height:"100%",overflow:"auto",position:"relative",margin:"0",padding:"0",border:"none"},on:{scroll:function(e){t.refreshView()}}},[i("div",{staticStyle:{width:"100%",padding:"0",margin:"0"},style:{height:t.listTotalHeight+"px"}}),t._v(" "),i("div",{ref:"item-wrapper",staticStyle:{position:"absolute",top:"0",left:"0",width:"100%",padding:"0",margin:"0"}},t._l(t.listViewWithInfo,function(e,s){return i("div",{key:e.index,style:{height:e.height+"px"}},[t._t("default",null,{item:e.item,height:e.height,offset:e.offset,index:e.index})],2)}))])};n._withStripped=!0;var o=function(t,e,i,s,n,o,r,f){var h=typeof(t=t||{}).default;"object"!==h&&"function"!==h||(t=t.default);var l,a="function"==typeof t?t.options:t;if(e&&(a.render=e,a.staticRenderFns=i,a._compiled=!0),s&&(a.functional=!0),o&&(a._scopeId=o),r?(l=function(t){(t=t||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext)||"undefined"==typeof __VUE_SSR_CONTEXT__||(t=__VUE_SSR_CONTEXT__),n&&n.call(this,t),t&&t._registeredComponents&&t._registeredComponents.add(r)},a._ssrRegister=l):n&&(l=f?function(){n.call(this,this.$root.$options.shadowRoot)}:n),l)if(a.functional){a._injectStyles=l;var c=a.render;a.render=function(t,e){return l.call(e),c(t,e)}}else{var d=a.beforeCreate;a.beforeCreate=d?[].concat(d,l):[l]}return{exports:t,options:a}}(s,n,[],!1,null,null,null);o.options.__file="packages\\list-view.vue";e.default=o.exports}])}); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-better-list-view", 3 | "version": "0.1.1", 4 | "description": "", 5 | "main": "dist/list-view.js", 6 | "scripts": { 7 | "dev": "webpack-dev-server --config webpack.dev.config.js", 8 | "build": "webpack --progress --config webpack.build.config.js", 9 | "build:umd": "webpack --progress --config webpack.build-umd.config.js" 10 | }, 11 | "author": "nossika", 12 | "license": "ISC", 13 | "dependencies": { 14 | "vue": "^2.5.16" 15 | }, 16 | "devDependencies": { 17 | "babel-core": "^6.26.0", 18 | "babel-loader": "^7.1.4", 19 | "babel-plugin-transform-runtime": "^6.23.0", 20 | "babel-preset-env": "^1.6.1", 21 | "clean-webpack-plugin": "^0.1.19", 22 | "css-loader": "^0.28.11", 23 | "html-webpack-plugin": "^3.2.0", 24 | "vue-loader": "^14.2.2", 25 | "vue-template-compiler": "^2.5.16", 26 | "webpack": "^4.6.0", 27 | "webpack-cli": "^2.0.15", 28 | "webpack-dev-server": "^3.1.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/list-view.vue: -------------------------------------------------------------------------------- 1 | 21 | 150 | 151 | -------------------------------------------------------------------------------- /src/list-view-umd.js: -------------------------------------------------------------------------------- 1 | import listViewComp from '../packages/list-view.vue'; 2 | 3 | const listView = { 4 | install (Vue) { 5 | Vue.component('list-view', listViewComp); 6 | }, 7 | }; 8 | 9 | 10 | if (window && window.Vue) { 11 | window.Vue.use(listView); 12 | } 13 | 14 | export { listView } 15 | 16 | 17 | -------------------------------------------------------------------------------- /webpack.build-umd.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: { 6 | 'list-view-umd': path.resolve(__dirname, 'src/list-view-umd'), 7 | }, 8 | output: { 9 | filename: '[name].js', 10 | path: path.resolve(__dirname, 'dist'), 11 | libraryTarget: 'umd', 12 | }, 13 | mode: 'production', 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.js$/, 18 | use: [ 19 | { 20 | loader: 'babel-loader', 21 | query: { 22 | presets: ['env'], 23 | }, 24 | }, 25 | ], 26 | exclude: /node_modules/, 27 | }, 28 | { 29 | test: /\.vue$/, 30 | loader: 'vue-loader', 31 | } 32 | ], 33 | }, 34 | plugins: [ 35 | 36 | ], 37 | }; -------------------------------------------------------------------------------- /webpack.build.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: { 6 | 'list-view': path.resolve(__dirname, 'packages/list-view.vue'), 7 | }, 8 | output: { 9 | filename: '[name].js', 10 | path: path.resolve(__dirname, 'dist'), 11 | libraryTarget: 'umd', 12 | }, 13 | mode: 'production', 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.js$/, 18 | use: [ 19 | { 20 | loader: 'babel-loader', 21 | query: { 22 | presets: ['env'], 23 | }, 24 | }, 25 | ], 26 | exclude: /node_modules/, 27 | }, 28 | { 29 | test: /\.vue$/, 30 | loader: 'vue-loader', 31 | } 32 | ], 33 | }, 34 | plugins: [ 35 | 36 | ], 37 | }; -------------------------------------------------------------------------------- /webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const webpack = require('webpack'); 4 | 5 | module.exports = { 6 | entry: { 7 | 'test': path.resolve(__dirname, 'dev/index'), 8 | }, 9 | output: { 10 | filename: '[name].js', 11 | path: path.resolve(__dirname, 'dist'), 12 | }, 13 | mode: 'development', 14 | devtool: '#source-map', 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.js$/, 19 | use: [ 20 | { 21 | loader: 'babel-loader', 22 | query: { 23 | presets: ['env'], 24 | }, 25 | }, 26 | ], 27 | exclude: /node_modules/, 28 | }, 29 | { 30 | test: /\.vue$/, 31 | loader: 'vue-loader', 32 | } 33 | ], 34 | }, 35 | devServer: { 36 | hot: true, 37 | inline: true, 38 | open: true, 39 | openPage: '', 40 | }, 41 | plugins: [ 42 | new webpack.HotModuleReplacementPlugin(), 43 | new HtmlWebpackPlugin({ 44 | template: path.resolve(__dirname, 'dev/index.html'), 45 | inject: 'body', 46 | }), 47 | ], 48 | }; --------------------------------------------------------------------------------