├── .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 |
2 |
3 |
10 |
11 |
NO: {{ scope.item.no }}, height: {{ scope.height }}px, offset: {{scope.offset}}px
12 |
13 |
14 |
15 |
16 |
65 |
--------------------------------------------------------------------------------
/dev/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | list-view test
6 |
7 |
8 |
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 |
2 |
20 |
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 | };
--------------------------------------------------------------------------------