├── .babelrc ├── .commitlintrc.js ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README-CN.md ├── README.md ├── build ├── dev-server.js ├── happypack.js ├── utils.js ├── webpack.base.config.js ├── webpack.dev.config.js └── webpack.prod.config.js ├── config └── index.js ├── demo ├── App │ ├── Image.jsx │ ├── index.jsx │ └── index.less ├── dist │ └── static │ │ ├── css │ │ └── app.184b2e1c.css │ │ └── js │ │ ├── app.83c8bca1.js │ │ ├── manifest.d41d8cd9.js │ │ └── vendors.83e31cc8.js └── index.js ├── index.html ├── package.json ├── postcss.config.js ├── src ├── Item.jsx ├── Rectangle.js ├── Status.jsx ├── computed.js ├── constant.js ├── createScheduler.js ├── index.jsx └── utils.js ├── tests └── index.test.js ├── tpl.html └── webpack.build.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", { 5 | "targets": { 6 | "browsers": [ 7 | "last 5 versions", 8 | "Android >= 5.0", 9 | "iOS > 8", 10 | "safari > 8", 11 | "ie >= 10" 12 | ] 13 | }, 14 | "modules": false, 15 | "useBuiltIns": "entry", 16 | "loose": true 17 | } 18 | ], 19 | "@babel/react" 20 | ], 21 | "plugins": [ 22 | "transform-react-remove-prop-types", 23 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 24 | ["@babel/plugin-proposal-class-properties", { "loose" : true }], 25 | "@babel/plugin-syntax-dynamic-import" 26 | ], 27 | "env": { 28 | "test": { 29 | "presets": ["@babel/env", "@babel/react"] 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-angular'], 3 | rules: { 4 | 'subject-case': [0] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | root: true, 3 | parser: "babel-eslint", 4 | parserOptions: { 5 | ecmaVersion: 7, 6 | sourceType: "module", 7 | allowImportExportEverywhere: false, 8 | ecmaFeatures: { 9 | jsx: true, 10 | modules: true 11 | } 12 | }, 13 | env: { 14 | es6: true, 15 | node: true, 16 | browser: true 17 | }, 18 | extends: [ 19 | "plugin:react/recommended" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # nyc test coverage 20 | .nyc_output 21 | 22 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 23 | .grunt 24 | 25 | # node-waf configuration 26 | .lock-wscript 27 | 28 | # Compiled binary addons (http://nodejs.org/api/addons.html) 29 | build/Release 30 | 31 | # Dependency directories 32 | node_modules 33 | jspm_packages 34 | 35 | # Optional npm cache directory 36 | .npm 37 | 38 | # Optional REPL history 39 | .node_repl_history 40 | 41 | .idea 42 | public/dist 43 | .DS_Store 44 | .cache 45 | converage 46 | .vscode 47 | reports 48 | .cache 49 | .happypack 50 | package-lock.json 51 | dist 52 | !demo/dist -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '8' 4 | cache: 5 | directories: 6 | - node_modules 7 | install: 8 | - npm install 9 | branches: 10 | only: 11 | - master 12 | script: 13 | - npm test -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Pomy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README-CN.md: -------------------------------------------------------------------------------- 1 | ![npm-version](https://img.shields.io/npm/v/@dwqs/react-virtual-list.svg?style=for-the-badge) ![license](https://img.shields.io/github/license/dwqs/react-virtual-list.svg?style=for-the-badge) ![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=for-the-badge) [![travis-ci](https://img.shields.io/badge/ci-travis-green.svg?style=for-the-badge)](https://travis-ci.org/dwqs/react-virtual-list) 2 | 3 | # react-virtual-list 4 | A tiny virtualization list component, supports dynamic height. 5 | 6 | >注意: 由于在 iOS UIWebviews 中,`scroll` 事件是在滚动停止之后触发的,所以不兼容iOS UIWebviews。[了解更多](https://developer.mozilla.org/en-US/docs/Web/Events/scroll#Browser_compatibility) 7 | 8 | ## 安装 9 | 通过 npm 或者 yarn 均可: 10 | 11 | ```shell 12 | // npm 13 | npm install @dwqs/react-virtual-list --save 14 | 15 | // yarn 16 | yarn add @dwqs/react-virtual-list 17 | ``` 18 | 19 | ## 基本使用 20 | ```js 21 | import React, { Component } from 'react' 22 | import VirtualizedList from '@dwqs/react-virtual-list' 23 | 24 | export default class Hello extends Component { 25 | constructor (props) { 26 | super(props) 27 | this.data = [{ 28 | id: 1, 29 | val: Math.random() 30 | }, { 31 | id: 2, 32 | val: Math.random() 33 | }, { 34 | id: 3, 35 | val: Math.random() 36 | }, ...] 37 | 38 | this.renderItem = this.renderItem.bind(this) 39 | } 40 | 41 | renderItem ({index, isScrolling}) { 42 | const item = this.data[index] 43 | return ( 44 |
#{index}, {item.val}
45 | ) 46 | } 47 | 48 | render () { 49 | return ( 50 | 55 | ) 56 | } 57 | } 58 | ``` 59 | 60 | 在线的 [demo](https://dwqs.github.io/react-virtual-list/) 61 | 62 | ## Prop Types 63 | |Property|Type|Default|Required?|Description| 64 | |:--:|:--:|:--:|:--:|:--:| 65 | |itemCount|Number||✓|需要渲染的数据个数| 66 | |renderItem|Function||✓|渲染列表项元素的函数: `({index: number, isScrolling: boolean}): React.PropTypes.node`| 67 | |overscanCount|Number|5||在可见区域之外的上/下方渲染的 Buffer 值,调整这个值可以避免部分设备上的滚动那个闪烁| 68 | |estimatedItemHeight|Number|175||列表项的预估高度| 69 | |className|String|''||设置包裹元素的 className| 70 | |onScroll|Function|() => {}||滚动容器的 scrollTop 发生改变时触发: `({scrollTop: number}):void`| 71 | |loadMoreItems|Function|() => {}||用于无限滚动。当需要加载更多数据时触发| 72 | |onLoading|Function|() => null||用于无限滚动。当在加载下一页数据时显示的 Loading 组件| 73 | |onEnded|Function|() => null||用于无限滚动。当没有更多可加载的数据时显示的组件| 74 | |hasMore|Boolean|false||用于无限滚动。表示是否有更多数据需要加载| 75 | |height|Number|undefined||包裹元素的高度. 如果属性 `useWindow` 是 `false` 并且未设置 `scrollableTarget`, 包裹元素会成为滚动容器| 76 | |useWindow|Boolean|true||是否使 Window 成为滚动容器,此时会监听 `window` 上的 `scroll` 事件。在移动端建议使用| 77 | |scrollableTarget|String|undefined||设置滚动容器元素, 其值会用于 `document.getElementById`。Window 是默认的滚动容器。如果要自定义滚动容器,需要将属性 `useWindow` 置为 `false`,并且不要设置 `height` 属性 | 78 | |noContentRenderer|Function|() => null||当 `itemCount` 的值是 0 时,会调用这个回调| 79 | 80 | ## Development 81 | ```shell 82 | git clone git@github.com:dwqs/react-virtual-list.git 83 | 84 | cd react-virtual-list 85 | 86 | npm i 87 | 88 | npm run dev 89 | ``` 90 | 91 | ## LICENSE 92 | This repo is released under the [MIT](http://opensource.org/licenses/MIT) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![npm-version](https://img.shields.io/npm/v/@dwqs/react-virtual-list.svg?style=for-the-badge) ![license](https://img.shields.io/github/license/dwqs/react-virtual-list.svg?style=for-the-badge) ![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=for-the-badge) [![travis-ci](https://img.shields.io/badge/ci-travis-green.svg?style=for-the-badge)](https://travis-ci.org/dwqs/react-virtual-list) 2 | 3 | [README 中文](./README-CN.md) 4 | # react-virtual-list 5 | A tiny virtualization list component, supports dynamic height. 6 | 7 | >Attention: On iOS UIWebViews, `scroll` events are not fired while scrolling is taking place; they are only fired after the scrolling has completed. See [more](https://developer.mozilla.org/en-US/docs/Web/Events/scroll#Browser_compatibility) 8 | 9 | ## Install 10 | Using npm or yarn: 11 | 12 | ```shell 13 | // npm 14 | npm install @dwqs/react-virtual-list --save 15 | 16 | // yarn 17 | yarn add @dwqs/react-virtual-list 18 | ``` 19 | 20 | ## Basic usage 21 | ```js 22 | import React, { Component } from 'react' 23 | import VirtualizedList from '@dwqs/react-virtual-list' 24 | 25 | export default class Hello extends Component { 26 | constructor (props) { 27 | super(props) 28 | this.data = [{ 29 | id: 1, 30 | val: Math.random() 31 | }, { 32 | id: 2, 33 | val: Math.random() 34 | }, { 35 | id: 3, 36 | val: Math.random() 37 | }, ...] 38 | 39 | this.renderItem = this.renderItem.bind(this) 40 | } 41 | 42 | renderItem ({index, isScrolling}) { 43 | const item = this.data[index] 44 | return ( 45 |
#{index}, {item.val}
46 | ) 47 | } 48 | 49 | render () { 50 | return ( 51 | 56 | ) 57 | } 58 | } 59 | ``` 60 | 61 | Check out the online demo [here](https://dwqs.github.io/react-virtual-list/) 62 | 63 | ## Prop Types 64 | |Property|Type|Default|Required?|Description| 65 | |:--:|:--:|:--:|:--:|:--:| 66 | |itemCount|Number||✓|The number of items you want to render| 67 | |renderItem|Function||✓|Responsible for rendering an item given its index and itself: `({index: number, isScrolling: boolean}):React.PropTypes.node`| 68 | |overscanCount|Number|5||Number of extra buffer items to render above/below the visible items. Tweaking this can help reduce scroll flickering on certain browsers/devices| 69 | |estimatedItemHeight|Number|175||The estimated height of the list item element, which is used to estimate the total height of the list before all of its items have actually been measured| 70 | |className|String|''||Class names of the wrapper element| 71 | |onScroll|Function|() => {}||Callback invoked whenever the scroll offset changes within the inner scrollable region: `({scrollTop: number}):void`| 72 | |loadMoreItems|Function|() => {}||Used to infinite scroll. Callback to be invoked when more items must be loaded| 73 | |onLoading|Function|() => null||Used to infinite scroll. The component will show when loading next page data| 74 | |onEnded|Function|() => null||Used to infinite scroll. The component will show when no more data to load| 75 | |hasMore|Boolean|false||Used to infinite scroll. Whether has more data to load| 76 | |height|Number|undefined||Height of the wrapper element. If `useWindow` is `false` and `scrollableTarget` is undefined, the wrapper element will be the scrollable target| 77 | |useWindow|Boolean|true||Whether to set the `window` to scrollable target | 78 | |scrollableTarget|String|undefined||Set the scrollable target, whose value is used to `document.getElementById`. `window` is the default scrollable target, so if you want to change it, you need to set `useWindow` to `false` and dont set the `height` prop | 79 | |noContentRenderer|Function|() => null||Callback used to render placeholder content when `itemCount` is 0| 80 | 81 | ## Development 82 | ```shell 83 | git clone git@github.com:dwqs/react-virtual-list.git 84 | 85 | cd react-virtual-list 86 | 87 | npm i 88 | 89 | npm run dev 90 | ``` 91 | 92 | ## LICENSE 93 | This repo is released under the [MIT](http://opensource.org/licenses/MIT) -------------------------------------------------------------------------------- /build/dev-server.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const WebpackDevServer = require('webpack-dev-server') 3 | const chalk = require('chalk') 4 | const PluginError = require('plugin-error') 5 | 6 | const webpackDevConfig = require('./webpack.dev.config.js') 7 | const config = require('../config/index') 8 | 9 | const compiler = webpack(webpackDevConfig) 10 | const server = new WebpackDevServer(compiler, webpackDevConfig.devServer) 11 | 12 | const env = process.env.NODE_ENV || 'development' 13 | const url = `localhost:${config[env].port}/` 14 | 15 | function compiledFail () { 16 | console.log(chalk.white('Webpack 编译失败: \n')) 17 | } 18 | 19 | server.listen(config[env].port, config[env].ip, (err) => { 20 | if (err) { 21 | compiledFail() 22 | throw new PluginError('[webpack-dev-server err]', err) 23 | } 24 | }) 25 | 26 | // 编译完成 27 | compiler.plugin('done', (stats) => { 28 | console.log(chalk.green(`Webpack 编译成功, open browser to visit ${url}\n`)) 29 | }) 30 | 31 | // 编译失败 32 | compiler.plugin('failed', (err) => { 33 | compiledFail() 34 | throw new PluginError('[webpack build err]', err) 35 | }) 36 | 37 | // 监听文件修改 38 | compiler.plugin('compilation', compilation => {}) 39 | -------------------------------------------------------------------------------- /build/happypack.js: -------------------------------------------------------------------------------- 1 | const HappyPack = require('happypack') 2 | const happyThreadPool = HappyPack.ThreadPool({ size: 3 }) 3 | 4 | module.exports = function (opts) { 5 | return { 6 | id: opts.id, 7 | threadPool: happyThreadPool, 8 | loaders: opts.loaders 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /build/utils.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const config = require('../config') 4 | 5 | const env = process.env.NODE_ENV || 'development' 6 | 7 | function resolve (dir) { 8 | return path.join(__dirname, '..', dir) 9 | } 10 | 11 | function assetsPath (_path) { 12 | const assetsSubDirectory = config[env].assetsSubDirectory || 'static' 13 | 14 | return path.posix.join(assetsSubDirectory, _path) 15 | } 16 | 17 | function extractCSS (opts) { 18 | // only support css/less 19 | const loaderKey = env === 'development' ? 'loader' : 'path' 20 | const optionsKey = env === 'development' ? 'options' : 'query' 21 | 22 | const cssLoader = { 23 | [loaderKey]: 'css-loader', 24 | [optionsKey]: { 25 | minimize: env !== 'development', 26 | // https://github.com/webpack-contrib/css-loader/issues/613 27 | sourceMap: false, 28 | importLoaders: 1 29 | } 30 | } 31 | 32 | const postcssLoader = { 33 | [loaderKey]: 'postcss-loader', 34 | [optionsKey]: { 35 | sourceMap: env === 'development' 36 | } 37 | } 38 | 39 | const lessLoader = { 40 | [loaderKey]: 'less-loader', 41 | [optionsKey]: { 42 | sourceMap: env === 'development' 43 | } 44 | } 45 | 46 | const loaders = [cssLoader, postcssLoader, lessLoader] 47 | 48 | if (env === 'development') { 49 | return ['style-loader'].concat(loaders) 50 | } else { 51 | return loaders 52 | } 53 | } 54 | 55 | module.exports = { 56 | resolve: resolve, 57 | assetsPath: assetsPath, 58 | extractCSS: extractCSS 59 | } 60 | -------------------------------------------------------------------------------- /build/webpack.base.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin') 2 | const CopyWebpackPlugin = require('copy-webpack-plugin') 3 | const ProgressBarPlugin = require('progress-bar-webpack-plugin') 4 | const HappyPack = require('happypack') 5 | 6 | const getHappyPackConfig = require('./happypack') 7 | const utils = require('./utils') 8 | 9 | const env = process.env.NODE_ENV || 'development' 10 | 11 | module.exports = { 12 | mode: env, 13 | // context: utils.resolve('demo'), 14 | module: { 15 | noParse: [/static|assets/], 16 | rules: [ 17 | { 18 | test: /\.(js|jsx)$/, 19 | type: 'javascript/auto', 20 | /** 21 | * 一些依赖使用了 es6的语法,如 webpack-dev-server, 这些目前没法一一转换 22 | * 所以开发时会在一些低版本浏览器上运行不了,如 iOS safari 9 23 | */ 24 | exclude: /node_modules/, 25 | loader: 'happypack/loader?id=js' 26 | }, 27 | { 28 | test: /\.(png|jpe?g|gif)(\?.*)?$/, 29 | type: 'javascript/auto', 30 | use: [{ 31 | loader: 'url-loader', 32 | options: { 33 | limit: 8192, 34 | name: utils.assetsPath('images/[name].[ext]') 35 | } 36 | }] 37 | }, 38 | { 39 | test: /\.svg$/, 40 | loader: 'svg-react-loader' 41 | }, 42 | { 43 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 44 | type: 'javascript/auto', 45 | use: [{ 46 | loader: 'url-loader', 47 | options: { 48 | limit: 10000, 49 | name: utils.assetsPath('fonts/[name].[ext]') 50 | } 51 | }] 52 | } 53 | ] 54 | }, 55 | 56 | resolve: { 57 | extensions: ['.js', '.jsx', '.json'], 58 | modules: [utils.resolve('node_modules')], 59 | alias: { 60 | '@src': utils.resolve('src') 61 | } 62 | }, 63 | 64 | resolveLoader: { 65 | modules: [utils.resolve('node_modules')] 66 | }, 67 | 68 | performance: { 69 | hints: false 70 | }, 71 | 72 | stats: { 73 | children: false 74 | }, 75 | 76 | plugins: [ 77 | new HappyPack(getHappyPackConfig({ 78 | id: 'js', 79 | loaders: [{ 80 | path: 'babel-loader', 81 | query: { 82 | cacheDirectory: true 83 | } 84 | }] 85 | })), 86 | 87 | // copy assets 88 | new CopyWebpackPlugin([ 89 | { 90 | context: '..', 91 | from: 'static/**/*', 92 | to: utils.resolve('dist'), 93 | force: true, 94 | ignore: ['.*'] 95 | }, 96 | { 97 | context: '../src', 98 | from: 'assets/**/*', 99 | to: utils.resolve('dist'), 100 | force: true, 101 | ignore: ['.*'] 102 | } 103 | ]), 104 | 105 | // https://github.com/ampedandwired/html-webpack-plugin 106 | new HtmlWebpackPlugin({ 107 | filename: 'index.html', 108 | template: 'tpl.html', 109 | inject: true, 110 | env: env, 111 | minify: { 112 | removeComments: true, 113 | collapseWhitespace: true, 114 | removeAttributeQuotes: false 115 | } 116 | }), 117 | 118 | new ProgressBarPlugin() 119 | ] 120 | } 121 | -------------------------------------------------------------------------------- /build/webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const merge = require('webpack-merge') 3 | const OpenBrowserPlugin = require('open-browser-webpack-plugin') 4 | const HappyPack = require('happypack') 5 | 6 | const getHappyPackConfig = require('./happypack') 7 | const utils = require('./utils') 8 | const baseWebpackConfig = require('./webpack.base.config') 9 | const config = require('../config') 10 | 11 | const env = process.env.NODE_ENV || 'development' 12 | const url = `http://${config[env].ip}:${config[env].port}` 13 | 14 | module.exports = merge(baseWebpackConfig, { 15 | entry: { 16 | app: [ 17 | require.resolve('react-dev-utils/webpackHotDevClient'), 18 | utils.resolve('demo/index.js') 19 | ] 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.(less|css)$/, 25 | type: 'javascript/auto', 26 | use: ['happypack/loader?id=css'] 27 | } 28 | ] 29 | }, 30 | devtool: '#source-map', 31 | output: { 32 | filename: '[name].js', 33 | path: config[env].assetsRoot, 34 | publicPath: config[env].assetsPublicPath, 35 | chunkFilename: '[name].js' 36 | }, 37 | plugins: [ 38 | new webpack.HotModuleReplacementPlugin(), 39 | new webpack.NamedModulesPlugin(), 40 | 41 | new HappyPack(getHappyPackConfig({ 42 | id: 'css', 43 | loaders: utils.extractCSS() 44 | })), 45 | 46 | new OpenBrowserPlugin({ url: url }) 47 | ], 48 | // see https://webpack.js.org/configuration/dev-server/#src/components/Sidebar/Sidebar.jsx 49 | devServer: { 50 | hot: true, 51 | noInfo: false, 52 | quiet: false, 53 | port: config[env].port, 54 | // #https://github.com/webpack/webpack-dev-server/issues/882 55 | disableHostCheck: true, 56 | // By default files from `contentBase` will not trigger a page reload. 57 | watchContentBase: true, 58 | headers: { 59 | 'Access-Control-Allow-Origin': '*', 60 | 'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept' 61 | }, 62 | inline: true, 63 | // 解决开发模式下 在子路由刷新返回 404 的情景 64 | historyApiFallback: { 65 | index: config[env].assetsPublicPath 66 | }, 67 | stats: { 68 | colors: true, 69 | modules: false 70 | }, 71 | contentBase: config[env].contentBase, 72 | publicPath: config[env].assetsPublicPath 73 | } 74 | }) 75 | -------------------------------------------------------------------------------- /build/webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const merge = require('webpack-merge') 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 4 | const OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin') 5 | const WebpackMd5Hash = require('webpack-md5-hash') 6 | // const CompressionPlugin = require('compression-webpack-plugin') 7 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin') 8 | const HappyPack = require('happypack') 9 | const WebpackInlineManifestPlugin = require('webpack-inline-manifest-plugin') 10 | 11 | const getHappyPackConfig = require('./happypack') 12 | const utils = require('./utils') 13 | const baseWebpackConfig = require('./webpack.base.config') 14 | const config = require('../config') 15 | 16 | const env = process.env.NODE_ENV || 'development' 17 | const matchVendorsChunk = /react|react-dom|react-router-dom|history|react-loadable|redux|mobx/ 18 | 19 | module.exports = merge(baseWebpackConfig, { 20 | entry: { 21 | app: [ 22 | utils.resolve('demo/index.js') 23 | ] 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.(less|css)$/, 29 | type: 'javascript/auto', 30 | loaders: [ 31 | MiniCssExtractPlugin.loader, 32 | 'happypack/loader?id=css' 33 | ] 34 | } 35 | ] 36 | }, 37 | output: { 38 | filename: utils.assetsPath('js/[name].[chunkhash:8].js'), 39 | path: config[env].assetsRoot, 40 | publicPath: config[env].assetsPublicPath, 41 | chunkFilename: utils.assetsPath('js/[name].[chunkhash:8].js') 42 | }, 43 | optimization: { 44 | minimize: true, // false 则不压缩 45 | // chunk for the webpack runtime code and chunk manifest 46 | runtimeChunk: { 47 | /** 48 | * 单独提取 runtimeChunk,被所有 generated chunk 共享 49 | * https://webpack.js.org/configuration/optimization/#optimization-runtimechunk 50 | */ 51 | name: 'manifest' 52 | }, 53 | /** 54 | * https://webpack.js.org/plugins/split-chunks-plugin/ 55 | * https://gist.github.com/sokra/1522d586b8e5c0f5072d7565c2bee693 56 | */ 57 | splitChunks: { 58 | cacheGroups: { 59 | // default: false, // 禁止默认的优化 60 | vendors: { 61 | test (chunk) { 62 | return chunk.context.includes('node_modules') && matchVendorsChunk.test(chunk.context) 63 | }, 64 | name: 'vendors', 65 | chunks: 'all' 66 | }, 67 | commons: { 68 | // 抽取 demand-chunk 下的公共依赖模块 69 | name: 'commons', 70 | minChunks: 3, // 在chunk中最小的被引用次数 71 | chunks: 'async', 72 | minSize: 0 // 被提取模块的最小大小 73 | } 74 | } 75 | } 76 | }, 77 | devtool: config[env].productionSourceMap ? '#source-map' : false, 78 | plugins: [ 79 | new webpack.HashedModuleIdsPlugin(), 80 | 81 | new HappyPack(getHappyPackConfig({ 82 | id: 'css', 83 | loaders: utils.extractCSS() 84 | })), 85 | 86 | new MiniCssExtractPlugin({ 87 | filename: utils.assetsPath('css/[name].[contenthash:8].css') 88 | }), 89 | 90 | new OptimizeCSSPlugin({ 91 | parser: require('postcss-safe-parser'), 92 | discardComments: { 93 | removeAll: true 94 | } 95 | }), 96 | 97 | // gzip 98 | // new CompressionPlugin({ 99 | // filename: '[path].gz[query]', 100 | // algorithm: 'gzip', 101 | // test: /\.(js|html|less|css)$/, 102 | // threshold: 10240, 103 | // minRatio: 0.8 104 | // }), 105 | 106 | new UglifyJsPlugin({ 107 | parallel: true, 108 | cache: true, 109 | sourceMap: true, 110 | uglifyOptions: { 111 | compress: { 112 | warnings: false, 113 | /* eslint-disable */ 114 | drop_debugger: true, 115 | drop_console: true 116 | }, 117 | mangle: true 118 | } 119 | }), 120 | 121 | new WebpackMd5Hash(), 122 | new WebpackInlineManifestPlugin({ 123 | name: 'webpackManifest' 124 | }) 125 | ] 126 | }) 127 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const myIP = require('my-ip') 3 | 4 | module.exports = { 5 | development: { 6 | assetsRoot: path.resolve(__dirname, '../demo/dist'), 7 | assetsPublicPath: '/', 8 | assetsSubDirectory: 'static', 9 | contentBase: path.resolve(__dirname, '../demo/dist'), 10 | port: 5000, 11 | prefix: '', 12 | ip: myIP() 13 | }, 14 | production: { 15 | assetsRoot: path.resolve(__dirname, '../demo/dist'), 16 | assetsPublicPath: '/demo/dist/', 17 | assetsSubDirectory: 'static', 18 | prefix: '', 19 | productionSourceMap: false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /demo/App/Image.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react' 2 | 3 | export default class Image extends Component { 4 | constructor (props) { 5 | super(props) 6 | this.state = { 7 | loadEnd: false 8 | } 9 | 10 | this.load = this.load.bind(this) 11 | } 12 | 13 | load () { 14 | this.setState({ 15 | loadEnd: true 16 | }) 17 | } 18 | 19 | render () { 20 | const { loadEnd } = this.state 21 | return ( 22 | 23 |
27 | {this.props.alt} 34 | 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /demo/App/index.jsx: -------------------------------------------------------------------------------- 1 | import './index.less' 2 | 3 | import React from 'react' 4 | import faker from 'faker/locale/zh_CN' 5 | 6 | import VirtualizedList from '@src/index' 7 | import Image from './Image' 8 | 9 | const pageSize = 50 10 | const host = 'https://fillmurray.com' // 'http://picsum.photos' 11 | 12 | function fakerData (start = 0) { 13 | const a = [] 14 | for (let i = start; i < start + pageSize; i++) { 15 | const rw = (1 + Math.random()) * 100 16 | const rh = (1 + Math.random()) * 100 17 | 18 | a.push({ 19 | id: i, 20 | image: `${host}/${Math.trunc(rw)}/${Math.trunc(rh)}`, 21 | words: faker.lorem.words(), 22 | paragraphs: faker.lorem.sentences() 23 | }) 24 | } 25 | 26 | return a 27 | } 28 | 29 | const onLoading = () => { 30 | return
Loading...
31 | } 32 | 33 | const onEnded = () => { 34 | return
No more data.
35 | } 36 | 37 | function noContentRenderer () { 38 | return
No data to render.
39 | } 40 | 41 | export default class App extends React.Component { 42 | constructor (props) { 43 | super(props) 44 | this.state = { 45 | data: fakerData(), 46 | hasMore: true 47 | } 48 | 49 | this.renderItem = this.renderItem.bind(this) 50 | this.loadNextPage = this.loadNextPage.bind(this) 51 | } 52 | 53 | renderItem ({ index, isScrolling }) { 54 | const { id, image, words, paragraphs } = this.state.data[index] 55 | console.log(' list is scrolling', isScrolling) 56 | // Needn't to set key prop 57 | return ( 58 |
59 |

#{index} {words}

60 | {id} 61 |

{paragraphs}

62 |
63 | ) 64 | } 65 | 66 | loadNextPage () { 67 | const data = [].concat(this.state.data, fakerData(this.state.data.length)) 68 | setTimeout(() => { 69 | this.setState({ 70 | data, 71 | hasMore: data.length < 300 72 | }) 73 | }, 2000) 74 | } 75 | 76 | // componentDidMount () { 77 | // setTimeout(() => { 78 | // this.setState({ 79 | // data: [], 80 | // hasMore: false 81 | // }) 82 | // }, 2000) 83 | // } 84 | 85 | render () { 86 | return ( 87 |
88 |

VirtualizedList

89 | 90 | 95 | 96 |
97 | 107 |
108 |
109 | ) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /demo/App/index.less: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box 5 | } 6 | 7 | html, body, .demo-wrap { 8 | position: relative; 9 | height: 100%; 10 | width: 100%; 11 | } 12 | 13 | h3 { 14 | text-align: center; 15 | margin: 30px; 16 | } 17 | 18 | .list-container { 19 | border: 1px solid #ccc; 20 | } 21 | 22 | .list-item { 23 | margin-left: 10px; 24 | p { 25 | padding: 5px; 26 | } 27 | } 28 | 29 | .item-wrapper { 30 | border-bottom: 1px solid #ccc; 31 | &:last-child { 32 | border: none 33 | } 34 | } 35 | 36 | .placeholder { 37 | width: 300px; 38 | height: 150px; 39 | background-color: #efefef 40 | } 41 | 42 | .loading { 43 | height: 150px; 44 | text-align: center; 45 | line-height: 150px; 46 | } 47 | 48 | .ending { 49 | height: 75px; 50 | text-align: center; 51 | line-height: 75px; 52 | } 53 | 54 | .github-corner { 55 | position: fixed; 56 | right: 0; 57 | top: 0 58 | } 59 | -------------------------------------------------------------------------------- /demo/dist/static/css/app.184b2e1c.css: -------------------------------------------------------------------------------- 1 | *{margin:0;padding:0;-webkit-box-sizing:border-box;box-sizing:border-box}.demo-wrap,body,html{position:relative;height:100%;width:100%}h3{text-align:center;margin:30px}.list-container{border:1px solid #ccc}.list-item{margin-left:10px}.list-item p{padding:5px}.item-wrapper{border-bottom:1px solid #ccc}.item-wrapper:last-child{border:none}.placeholder{width:300px;height:150px;background-color:#efefef}.loading{height:150px;line-height:150px}.ending,.loading{text-align:center}.ending{height:75px;line-height:75px}.github-corner{position:fixed;right:0;top:0} -------------------------------------------------------------------------------- /demo/dist/static/js/app.83c8bca1.js: -------------------------------------------------------------------------------- 1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[0],{0:function(t,e,i){t.exports=i("C2Ph")},C2Ph:function(t,e,i){"use strict";i.r(e);var n=i("q1tI"),o=i.n(n),r=i("i8i4"),s=i.n(r),a=(i("VCSs"),i("j+RG")),h=i.n(a),d=(i("17x9"),i("hKI/")),l=i.n(d),c=i("bdgK"),u=function(){},p=function(){return null},m=window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.msRequestAnimationFrame||function(t){window.setTimeout(t,1e3/60)};function f(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}window.cancelAnimationFrame||window.mozCancelAnimationFrame;var g=function(t){var e,i;function n(e){var i;return(i=t.call(this,e)||this).listenerElementResize=i.listenerElementResize.bind(f(f(i))),i.setRef=i.setRef.bind(f(f(i))),i}i=t,(e=n).prototype=Object.create(i.prototype),(e.prototype.constructor=e).__proto__=i;var r=n.prototype;return r.listenerElementResize=function(t){this.cacheItemSize(t)},r.cacheItemSize=function(t){if(this.node){var e=this.props,i=e.itemIndex,n=e.cacheInitialHeight,o=this.node.getBoundingClientRect();n[i]!==o.height&&this.props.updateItemPosition({rect:o,index:i,entries:t})}},r.setRef=function(t){this.node=t},r.render=function(){var t=this.props,e=t.itemIndex,i=t.isScrolling;return o.a.createElement("div",{className:"item-wrapper",ref:this.setRef,style:{minHeight:this.props.height}},this.props.renderItem({index:e,isScrolling:i}))},r.componentDidMount=function(){this.node&&(this.cacheItemSize(),this.resizeObserver=new c.a(this.listenerElementResize),this.resizeObserver.observe(this.node))},r.componentWillUnmount=function(){this.resizeObserver&&this.resizeObserver.disconnect(),this.resizeObserver=null},n}(o.a.PureComponent);g.defaultProps={renderItem:u,updateItemPosition:u,height:"auto",cacheInitialHeight:[]};var v=g,I=function(t){var e,i;function n(e){var i;return(i=t.call(this,e)||this).state={status:""},i}i=t,(e=n).prototype=Object.create(i.prototype),(e.prototype.constructor=e).__proto__=i;var o=n.prototype;return o.changeStatus=function(t){this.setState({status:t})},o.render=function(){return this.props.children({status:this.state.status})},n}(o.a.PureComponent),w=function(){function t(t){var e=void 0===t?{}:t,i=e.top,n=void 0===i?0:i,o=e.left,r=void 0===o?0:o,s=e.height,a=void 0===s?0:s,h=e.width,d=void 0===h?0:h,l=e.index,c=void 0===l?0:l,u=e.defaultHeight;this._top=n,this._left=r,this._height=a,this._width=d,this._index=c,this._defaultHeight=u||0}var e=t.prototype;return e.getTop=function(){return this._top},e.getHeight=function(){return this._height},e.getBottom=function(){return this._top+(this._height||this._defaultHeight)},e.getIndex=function(){return this._index},e.getRectInfo=function(){return{top:this._top,height:this._height,index:this._index,bottom:this._top+(this._height||this._defaultHeight),left:this._left,width:this._width}},e.updateRectInfo=function(t){var e=void 0===t?{}:t,i=e.top,n=void 0===i?0:i,o=e.left,r=void 0===o?0:o,s=e.height,a=void 0===s?0:s,h=e.width,d=void 0===h?0:h,l=e.index,c=void 0===l?0:l;this._top=n||this._top,this._left=r||this._left,this._height=a||this._height,this._width=d||this._width,this._index=c||this._index},t}(),C=function(t,e){var i=!1,n=function(){i=!1,t()};return function(){i||e(n),i=!0}},b="loading",x="ending";function y(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}var E=function(t){var e,i;function n(e){var i;return(i=t.call(this,e)||this).state={paddingBottom:0,paddingTop:0,isScrolling:!1},i.style={WebkitOverflowScrolling:"touch"},isNaN(e.height)||(i.style={overflowY:"auto",overflowX:"hidden",height:e.height+"px"}),i.rects=[],i.cacheInitialHeight=[],i.updateRects=function(t){void 0===t&&(t=null);for(var e=arguments.length,i=new Array(1=t})[0];if(o){this.anchorItem=o.getRectInfo();var r=Math.max(0,this.anchorItem.index-i);if(this.startIndex!==r){var s=Math.min(this.anchorItem.index+this.visibleCount+i,n);this.startIndex=r,this.endIndex=s}}},r.scrollUp=function(t){var e=this.props,i=e.hasMore,n=e.itemCount;t=t||0,this.endIndex>=n?!this.isLoadingMoreItems&&i&&(this.isLoadingMoreItems=!0,this.setState({paddingBottom:0}),this.updateLoadingStatus(b),this.props.loadMoreItems()):t>this.anchorItem.bottom&&(this.updateBoundaryIndex(t),this.updatePaddingValOfContainer())},r.scrollDown=function(t){(t=t||0)this.scrollTop?this.scrollUp(e):e { 7 | ReactDOM.render( 8 | , 9 | document.getElementById('app') 10 | ) 11 | } 12 | 13 | render(APP) 14 | 15 | if (module.hot) { 16 | module.hot.accept('./App/index', () => { render(APP) }) 17 | } 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | React Virtualized List
-------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dwqs/react-virtual-list", 3 | "version": "1.0.0", 4 | "description": "A tiny virtualization list component, supports variable height", 5 | "author": "pomysky@gmail.com", 6 | "license": "MIT", 7 | "private": false, 8 | "main": "dist/index.js", 9 | "scripts": { 10 | "prepush": "npm run ilint -q && npm run test", 11 | "dev": "npx cross-env NODE_ENV=development node ./build/dev-server.js", 12 | "prod": "npx rimraf demo/dist && npx cross-env NODE_ENV=production npx webpack --config ./build/webpack.prod.config.js --progress --hide-modules", 13 | "postprod": "mv ./demo/dist/index.html ./", 14 | "build": "npx rimraf dist && npx cross-env NODE_ENV=production npx webpack --config ./webpack.build.config.js --progress --hide-modules", 15 | "ilint": "npx standard", 16 | "fix": "npx standard --fix", 17 | "commitmsg": "commitlint -E GIT_PARAMS", 18 | "test": "jest" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/dwqs/react-virtual-list.git" 23 | }, 24 | "bugs": { 25 | "url": "https://github.com/dwqs/react-virtual-list/issues" 26 | }, 27 | "keywords": [ 28 | "react", 29 | "virtualized", 30 | "virtualization", 31 | "react-virtual", 32 | "react-virtualized", 33 | "virtual-list", 34 | "react-list", 35 | "virtual", 36 | "list", 37 | "infinite", 38 | "scrolling", 39 | "dynamic-height", 40 | "variable-height" 41 | ], 42 | "files": [ 43 | "dist", 44 | "package.json", 45 | "README.md" 46 | ], 47 | "dependencies": { 48 | "lodash.throttle": "^4.1.1", 49 | "resize-observer-polyfill": "^1.5.0" 50 | }, 51 | "peerDependencies": { 52 | "react": ">= 15.3.0" 53 | }, 54 | "devDependencies": { 55 | "@babel/core": "^7.1.0", 56 | "@babel/plugin-proposal-class-properties": "^7.1.0", 57 | "@babel/plugin-proposal-decorators": "^7.1.0", 58 | "@babel/plugin-syntax-dynamic-import": "^7.0.0", 59 | "@babel/preset-env": "^7.1.0", 60 | "@babel/preset-react": "^7.0.0", 61 | "@commitlint/cli": "^7.1.2", 62 | "@commitlint/config-angular": "^7.1.2", 63 | "autoprefixer": "^9.1.5", 64 | "babel-core": "^7.0.0-bridge.0", 65 | "babel-eslint": "^9.0.0", 66 | "babel-jest": "^23.6.0", 67 | "babel-loader": "^8.0.2", 68 | "babel-plugin-transform-react-remove-prop-types": "^0.4.15", 69 | "chalk": "^2.4.1", 70 | "compression-webpack-plugin": "^2.0.0", 71 | "copy-webpack-plugin": "^4.5.2", 72 | "cross-env": "^5.2.0", 73 | "css-loader": "^1.0.0", 74 | "enzyme": "^3.7.0", 75 | "enzyme-adapter-react-16": "^1.6.0", 76 | "eslint": "^5.4.0", 77 | "eslint-plugin-flowtype": "^2.50.0", 78 | "eslint-plugin-import": "^2.14.0", 79 | "eslint-plugin-jsx-a11y": "^6.1.1", 80 | "eslint-plugin-react": "^7.11.1", 81 | "faker": "^4.1.0", 82 | "happypack": "^5.0.0", 83 | "html-webpack-plugin": "^3.2.0", 84 | "husky": "^0.14.3", 85 | "jest": "^23.6.0", 86 | "less": "^3.8.1", 87 | "less-loader": "^4.1.0", 88 | "mini-css-extract-plugin": "^0.4.3", 89 | "my-ip": "^1.1.1", 90 | "open-browser-webpack-plugin": "^0.0.5", 91 | "optimize-css-assets-webpack-plugin": "^5.0.0", 92 | "plugin-error": "^1.0.1", 93 | "postcss-loader": "^3.0.0", 94 | "postcss-safe-parser": "^4.0.1", 95 | "progress-bar-webpack-plugin": "^1.11.0", 96 | "prop-types": "^15.6.2", 97 | "react": "^16.4.1", 98 | "react-dev-utils": "^6.0.0-next.3e165448", 99 | "react-dom": "^16.4.1", 100 | "react-router-dom": "^4.3.1", 101 | "standard": "^12.0.1", 102 | "style-loader": "^0.23.0", 103 | "uglifyjs-webpack-plugin": "^2.0.1", 104 | "url-loader": "^1.1.1", 105 | "webpack": "^4.19.1", 106 | "webpack-cli": "^3.1.0", 107 | "webpack-dev-server": "^3.1.8", 108 | "webpack-inline-manifest-plugin": "^4.0.1", 109 | "webpack-md5-hash": "^0.0.6", 110 | "webpack-merge": "^4.1.4" 111 | }, 112 | "standard": { 113 | "parser": "babel-eslint", 114 | "ignore": [ 115 | "*.svg", 116 | "*.less", 117 | "dist", 118 | "tests" 119 | ] 120 | }, 121 | "husky": { 122 | "hooks": { 123 | "pre-commit": [ 124 | "standard" 125 | ] 126 | } 127 | }, 128 | "engines": { 129 | "node": "> 8.1.4", 130 | "npm": ">= 5.2.0" 131 | }, 132 | "jest": { 133 | "roots": [ 134 | "./tests" 135 | ], 136 | "verbose": true, 137 | "testRegex": ".test.js$" 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('autoprefixer')({ 4 | browsers: [ 5 | 'last 5 versions', 6 | 'Android >= 5.0', 7 | 'iOS >= 8', 8 | 'safari > 8', 9 | 'not ie < 10' 10 | ] 11 | }) 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/Item.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import ResizeObserver from 'resize-observer-polyfill' 4 | 5 | import { noop } from './utils' 6 | 7 | class Item extends React.PureComponent { 8 | constructor (props) { 9 | super(props) 10 | 11 | this.listenerElementResize = this.listenerElementResize.bind(this) 12 | this.setRef = this.setRef.bind(this) 13 | } 14 | 15 | listenerElementResize (entries) { 16 | // resize observer: https://github.com/WICG/ResizeObserver 17 | // caniuse: https://caniuse.com/#search=resizeobserver 18 | this.cacheItemSize(entries) 19 | } 20 | 21 | cacheItemSize (entries) { 22 | if (!this.node) { 23 | return 24 | } 25 | 26 | const { itemIndex, cacheInitialHeight } = this.props 27 | const rect = this.node.getBoundingClientRect() 28 | if (cacheInitialHeight[itemIndex] !== rect.height) { 29 | this.props.updateItemPosition({ 30 | rect, 31 | index: itemIndex, 32 | entries 33 | }) 34 | } 35 | } 36 | 37 | setRef (node) { 38 | this.node = node 39 | } 40 | 41 | render () { 42 | const { itemIndex, isScrolling } = this.props 43 | 44 | return ( 45 |
46 | { 47 | this.props.renderItem({ 48 | index: itemIndex, 49 | isScrolling 50 | }) 51 | } 52 |
53 | ) 54 | } 55 | 56 | componentDidMount () { 57 | // Delay observer node until mount. 58 | // This handles edge-cases where the component has already been unmounted before its ref has been set 59 | if (this.node) { 60 | this.cacheItemSize() 61 | 62 | this.resizeObserver = new ResizeObserver(this.listenerElementResize) 63 | this.resizeObserver.observe(this.node) 64 | } 65 | } 66 | 67 | componentWillUnmount () { 68 | if (this.resizeObserver) { 69 | this.resizeObserver.disconnect() 70 | } 71 | this.resizeObserver = null 72 | } 73 | } 74 | 75 | Item.propTypes = { 76 | itemIndex: PropTypes.any.isRequired, 77 | isScrolling: PropTypes.bool.isRequired, 78 | renderItem: PropTypes.func, 79 | updateItemPosition: PropTypes.func, 80 | height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 81 | cacheInitialHeight: PropTypes.array 82 | } 83 | 84 | Item.defaultProps = { 85 | renderItem: noop, 86 | updateItemPosition: noop, 87 | height: 'auto', 88 | cacheInitialHeight: [] 89 | } 90 | 91 | export default Item 92 | -------------------------------------------------------------------------------- /src/Rectangle.js: -------------------------------------------------------------------------------- 1 | export default class Rectangle { 2 | constructor ({ top = 0, left = 0, height = 0, width = 0, index = 0, defaultHeight } = {}) { 3 | this._top = top 4 | this._left = left 5 | this._height = height 6 | this._width = width 7 | this._index = index 8 | this._defaultHeight = defaultHeight || 0 9 | } 10 | 11 | getTop () { 12 | return this._top 13 | } 14 | 15 | getHeight () { 16 | return this._height 17 | } 18 | 19 | getBottom () { 20 | return this._top + (this._height || this._defaultHeight) 21 | } 22 | 23 | getIndex () { 24 | return this._index 25 | } 26 | 27 | getRectInfo () { 28 | return { 29 | top: this._top, 30 | height: this._height, 31 | index: this._index, 32 | bottom: this._top + (this._height || this._defaultHeight), 33 | left: this._left, 34 | width: this._width 35 | } 36 | } 37 | 38 | updateRectInfo ({ top = 0, left = 0, height = 0, width = 0, index = 0 } = {}) { 39 | this._top = top || this._top 40 | this._left = left || this._left 41 | this._height = height || this._height 42 | this._width = width || this._width 43 | this._index = index || this._index 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Status.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | class Status extends React.PureComponent { 5 | constructor (props) { 6 | super(props) 7 | this.state = { 8 | status: '' 9 | } 10 | } 11 | 12 | changeStatus (status) { 13 | this.setState({ 14 | status 15 | }) 16 | } 17 | 18 | render () { 19 | return this.props.children({ status: this.state.status }) 20 | } 21 | } 22 | 23 | Status.propTypes = { 24 | children: PropTypes.func.isRequired 25 | } 26 | 27 | export default Status 28 | -------------------------------------------------------------------------------- /src/computed.js: -------------------------------------------------------------------------------- 1 | export default function computed (context = null, ...funcs) { 2 | const resultFunc = funcs.pop() 3 | const inputFuncs = [] 4 | 5 | funcs.forEach(func => { 6 | if (typeof func === 'function') { 7 | inputFuncs.push(func) 8 | } else { 9 | console.error(`computed: expect all input to be function, but received the following types: ${typeof func}`) 10 | } 11 | }) 12 | 13 | return function computeWithBindingContext () { 14 | const params = [] 15 | const length = inputFuncs.length 16 | 17 | for (let i = 0; i < length; i++) { 18 | params.push(inputFuncs[i].apply(context, arguments)) 19 | } 20 | 21 | return resultFunc.apply(context, params) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/constant.js: -------------------------------------------------------------------------------- 1 | export const DEFAULT_SCROLLING_RESET_TIME_INTERVAL = 150 2 | export const LOADING = 'loading' 3 | export const ENDING = 'ending' 4 | -------------------------------------------------------------------------------- /src/createScheduler.js: -------------------------------------------------------------------------------- 1 | function createScheduler (callback, scheduler) { 2 | let ticking = false 3 | 4 | const update = () => { 5 | ticking = false 6 | callback() 7 | } 8 | 9 | const requestTick = () => { 10 | if (!ticking) { 11 | scheduler(update) 12 | } 13 | ticking = true 14 | } 15 | 16 | return requestTick 17 | } 18 | 19 | export default createScheduler 20 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import throttle from 'lodash.throttle' 4 | 5 | import Item from './Item' 6 | import Status from './Status' 7 | import Rectangle from './Rectangle' 8 | 9 | import createScheduler from './createScheduler' 10 | import computed from './computed' 11 | import { isSupportPassive, noop, requestAnimationFrame, renderNull } from './utils' 12 | 13 | import { 14 | DEFAULT_SCROLLING_RESET_TIME_INTERVAL, 15 | LOADING, ENDING 16 | } from './constant' 17 | 18 | class VirtualizedList extends React.PureComponent { 19 | constructor (props) { 20 | super(props) 21 | this.state = { 22 | paddingBottom: 0, 23 | paddingTop: 0, 24 | isScrolling: false 25 | } 26 | 27 | this.style = { 28 | WebkitOverflowScrolling: 'touch' 29 | } 30 | 31 | if (!isNaN(props.height)) { 32 | this.style = { 33 | overflowY: 'auto', 34 | overflowX: 'hidden', 35 | height: `${props.height}px` 36 | } 37 | } 38 | 39 | // Cache position info of item rendered 40 | this.rects = [] 41 | 42 | // Cache initial height of item 43 | this.cacheInitialHeight = [] 44 | 45 | // Set default position info of item 46 | // TODO: memorized 47 | this.updateRects = computed( 48 | this, 49 | props => props.itemCount, 50 | props => props.estimatedItemHeight, 51 | (itemCount, defaultHeight) => { 52 | const length = this.rects.length 53 | const lastRect = this.rects[length - 1] || null 54 | 55 | let top = lastRect ? lastRect.getBottom() : 0 56 | for (let i = length; i < itemCount; i++) { 57 | this.rects.push(new Rectangle({ 58 | top, 59 | height: 0, 60 | index: i, 61 | defaultHeight 62 | })) 63 | 64 | top += defaultHeight 65 | } 66 | } 67 | ) 68 | 69 | this.startIndex = 0 70 | this.endIndex = 0 71 | this.scrollTop = 0 72 | this.containerTopValue = 0 73 | this.isLoadingMoreItems = false 74 | 75 | this.timer = null 76 | this.doc = null 77 | this.el = null // scrollable container element 78 | 79 | // The info of anchor element 80 | // which is the first element in visible range 81 | this.anchorItem = { 82 | index: 0, 83 | top: 0, 84 | bottom: 0 85 | } 86 | 87 | this.updateItemPosition = this.updateItemPosition.bind(this) 88 | this.handleScroll = this.handleScroll.bind(this) 89 | this.scrollListener = throttle(createScheduler(this.handleScroll, requestAnimationFrame), 100, { trailing: true }) 90 | } 91 | 92 | updateItemPosition (args) { 93 | const { rect, index, entries } = args 94 | const rectangle = this.rects[index] 95 | 96 | if (!rectangle || rectangle.getHeight() === rect.height) { 97 | return 98 | } 99 | 100 | if (!entries) { 101 | this.cacheInitialHeight[index] = rect.height 102 | } 103 | 104 | // https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect 105 | // The value of top is relative to the top of the scroll container element 106 | const top = rect.top - this.containerTopValue + (this.el.scrollTop || window.pageYOffset) 107 | 108 | if (index === 0) { 109 | this.anchorItem = { 110 | index, 111 | top, 112 | bottom: top + rect.height 113 | } 114 | } 115 | 116 | rectangle.updateRectInfo({ 117 | top, 118 | height: rect.height, 119 | index 120 | }) 121 | } 122 | 123 | updatePaddingValOfContainer (isScrolling = true, callback = noop) { 124 | const { estimatedItemHeight, itemCount } = this.props 125 | 126 | this.setState({ 127 | paddingTop: this.rects[this.startIndex].getTop() - this.rects[0].getTop(), 128 | paddingBottom: (itemCount - this.endIndex) * estimatedItemHeight, 129 | isScrolling 130 | }, () => { 131 | callback() 132 | }) 133 | } 134 | 135 | computeVisibleCount () { 136 | const { useWindow, estimatedItemHeight } = this.props 137 | const h = useWindow ? window.innerHeight : this.el.offsetHeight 138 | 139 | this.visibleCount = Math.ceil(h / estimatedItemHeight) 140 | } 141 | 142 | initVisibleData () { 143 | const { itemCount, overscanCount } = this.props 144 | this.endIndex = Math.min(this.anchorItem.index + this.visibleCount + overscanCount, itemCount) 145 | 146 | this.updatePaddingValOfContainer(false) 147 | } 148 | 149 | updateVisibleData () { 150 | this.isLoadingMoreItems = false 151 | const { overscanCount, itemCount } = this.props 152 | 153 | if (this.startIndex === 0) { 154 | this.endIndex = Math.min(this.anchorItem.index + this.visibleCount + overscanCount, itemCount) 155 | } else { 156 | this.endIndex = this.endIndex + overscanCount 157 | } 158 | 159 | this.updatePaddingValOfContainer(false) 160 | } 161 | 162 | updateBoundaryIndex (scrollTop) { 163 | const { overscanCount, itemCount } = this.props 164 | const rect = this.rects.filter(rect => rect.getBottom() >= scrollTop)[0] 165 | 166 | if (!rect) { 167 | return 168 | } 169 | 170 | this.anchorItem = rect.getRectInfo() 171 | 172 | const startIndex = Math.max(0, this.anchorItem.index - overscanCount) 173 | 174 | if (this.startIndex === startIndex) { 175 | return 176 | } 177 | 178 | const endIndex = Math.min(this.anchorItem.index + this.visibleCount + overscanCount, itemCount) 179 | 180 | this.startIndex = startIndex 181 | this.endIndex = endIndex 182 | } 183 | 184 | scrollUp (scrollTop) { 185 | const { hasMore, itemCount } = this.props 186 | 187 | // Hand is scrolling up, scrollTop is increasing 188 | scrollTop = scrollTop || 0 189 | 190 | if (this.endIndex >= itemCount) { 191 | if (!this.isLoadingMoreItems && hasMore) { 192 | this.isLoadingMoreItems = true 193 | this.setState({ 194 | paddingBottom: 0 195 | }) 196 | 197 | this.updateLoadingStatus(LOADING) 198 | this.props.loadMoreItems() 199 | } 200 | return 201 | } 202 | 203 | if (scrollTop > this.anchorItem.bottom) { 204 | this.updateBoundaryIndex(scrollTop) 205 | this.updatePaddingValOfContainer() 206 | } 207 | } 208 | 209 | scrollDown (scrollTop) { 210 | // Hand is scrolling down, scrollTop is decreasing 211 | scrollTop = scrollTop || 0 212 | 213 | if (scrollTop < this.anchorItem.top) { 214 | this.updateBoundaryIndex(scrollTop) 215 | this.updatePaddingValOfContainer() 216 | } 217 | } 218 | 219 | handleScroll () { 220 | if (!this.doc) { 221 | // Use the body element's scrollTop on iOS Safari/Webview 222 | // Because the documentElement element's scrollTop always is zero 223 | this.doc = this.el === document.defaultView ? (window.document.body.scrollTop ? window.document.body : window.document.documentElement) : this.el 224 | } 225 | 226 | // On iOS, we can arrive at negative offsets by swiping past the start. 227 | // To prevent flicker here, we make playing in the negative offset zone cause nothing to happen. 228 | if (this.doc.scrollTop < 0) { 229 | return 230 | } 231 | 232 | this.props.onScroll({ 233 | scrollTop: this.doc.scrollTop 234 | }) 235 | 236 | // Set a timer to judge scroll of element is stopped 237 | this.timer && clearTimeout(this.timer) 238 | this.timer = setTimeout(() => { 239 | this.handleScrollEnd() 240 | }, DEFAULT_SCROLLING_RESET_TIME_INTERVAL) 241 | 242 | const curScrollTop = this.doc.scrollTop 243 | if (curScrollTop > this.scrollTop) { 244 | this.scrollUp(curScrollTop) 245 | } else if (curScrollTop < this.scrollTop) { 246 | this.scrollDown(curScrollTop) 247 | } 248 | this.scrollTop = curScrollTop 249 | } 250 | 251 | handleScrollEnd () { 252 | // Do something, when scroll stop 253 | this.setState({ 254 | isScrolling: false 255 | }) 256 | } 257 | 258 | updateLoadingStatus (status) { 259 | if (!this.status) { 260 | return 261 | } 262 | 263 | if (!this.props.hasMore) { 264 | this.status.changeStatus(ENDING) 265 | return 266 | } 267 | 268 | this.status.changeStatus(status) 269 | } 270 | 271 | getRenderedItemHeight (index) { 272 | const rectangle = this.rects[index] 273 | const h = rectangle && rectangle.getHeight() 274 | 275 | if (this.cacheInitialHeight[index] !== h && h > 0) { 276 | return `${h}px` 277 | } 278 | // 对于Viewport内的数据返回高度一直是 auto, 一是保持自适应,二是能触发element resize事件 279 | return 'auto' 280 | } 281 | 282 | calculateChildrenToDisplay () { 283 | const childs = [] 284 | 285 | if (!this.isReady) { 286 | return childs 287 | } 288 | 289 | for (let i = this.startIndex; i < this.endIndex; i++) { 290 | childs.push( 291 | 300 | ) 301 | } 302 | 303 | return childs 304 | } 305 | 306 | getScrollableElement () { 307 | const { scrollableTarget, useWindow, height } = this.props 308 | let target = null 309 | 310 | if (useWindow) { 311 | target = document.defaultView 312 | } else if (scrollableTarget && typeof scrollableTarget === 'string') { 313 | target = document.getElementById(scrollableTarget) 314 | } else if (!isNaN(height)) { 315 | target = this.wrapper 316 | } 317 | 318 | return target || document.defaultView 319 | } 320 | 321 | componentDidMount () { 322 | this.isReady = true 323 | 324 | if (!this.el) { 325 | this.el = this.getScrollableElement() 326 | } 327 | 328 | if (this.el !== document.defaultView) { 329 | this.containerTopValue = this.el.getBoundingClientRect().top 330 | } 331 | 332 | // compute visible count once 333 | this.computeVisibleCount() 334 | 335 | if (this.props.itemCount) { 336 | this.updateRects(this.props) 337 | this.initVisibleData() 338 | } 339 | 340 | this.el.addEventListener('scroll', this.scrollListener, isSupportPassive() ? { 341 | passive: true, 342 | capture: false 343 | } : false) 344 | } 345 | 346 | render () { 347 | const { 348 | className, 349 | onLoading, 350 | onEnded, 351 | hasMore, 352 | itemCount, 353 | noContentRenderer 354 | } = this.props 355 | const { paddingBottom, paddingTop } = this.state 356 | 357 | if (!itemCount && hasMore) { 358 | return ( 359 |
{ this.wrapper = node }}> 360 | {onLoading()} 361 |
362 | ) 363 | } 364 | 365 | const childrenToDisplay = this.calculateChildrenToDisplay() 366 | const showNoContentRenderer = childrenToDisplay.length === 0 367 | 368 | return ( 369 |
{ this.wrapper = node }}> 370 | { 371 | !showNoContentRenderer && ( 372 |
373 | {childrenToDisplay} 374 | { this.status = node }}> 375 | { 376 | ({ status }) => { 377 | return status === ENDING ? onEnded() : status === LOADING ? onLoading() : null 378 | } 379 | } 380 | 381 |
382 | ) 383 | } 384 | { showNoContentRenderer && noContentRenderer() } 385 |
386 | ) 387 | } 388 | 389 | componentDidUpdate (prevProps, prevState) { 390 | if (prevProps.itemCount !== this.props.itemCount) { 391 | this.updateRects(this.props) 392 | this.updateVisibleData() 393 | this.updateLoadingStatus('') 394 | } 395 | } 396 | 397 | componentWillUnmount () { 398 | this.el.removeEventListener('scroll', this.scrollListener) 399 | clearTimeout(this.timer) 400 | } 401 | } 402 | 403 | VirtualizedList.propTypes = { 404 | renderItem: PropTypes.func.isRequired, 405 | itemCount: PropTypes.number.isRequired, 406 | overscanCount: PropTypes.number, 407 | height: PropTypes.number, 408 | estimatedItemHeight: PropTypes.number, 409 | className: PropTypes.string, 410 | loadMoreItems: PropTypes.func, 411 | noContentRenderer: PropTypes.func, 412 | onScroll: PropTypes.func, 413 | onLoading: PropTypes.func, 414 | onEnded: PropTypes.func, 415 | hasMore: PropTypes.bool, 416 | useWindow: PropTypes.bool, 417 | scrollableTarget: PropTypes.string 418 | } 419 | 420 | VirtualizedList.defaultProps = { 421 | estimatedItemHeight: 175, 422 | className: '', 423 | renderItem: noop, 424 | overscanCount: 5, 425 | loadMoreItems: noop, 426 | onScroll: noop, 427 | noContentRenderer: renderNull, 428 | onLoading: renderNull, 429 | onEnded: renderNull, 430 | hasMore: false, 431 | useWindow: true // Recommend set it to true on mobile device for better scrolls performance 432 | } 433 | 434 | export default VirtualizedList 435 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const noop = () => {} 2 | export const renderNull = () => null 3 | 4 | export const isSupportPassive = () => { 5 | let supportsPassive = false 6 | try { 7 | const opts = {} 8 | Object.defineProperty(opts, 'passive', { 9 | get () { 10 | supportsPassive = true 11 | } 12 | }) 13 | window.addEventListener('test-passive', null, opts) 14 | } catch (e) {} 15 | 16 | return supportsPassive 17 | } 18 | 19 | export const requestAnimationFrame = 20 | window.requestAnimationFrame || 21 | window.webkitRequestAnimationFrame || 22 | window.mozRequestAnimationFrame || 23 | window.msRequestAnimationFrame || 24 | function (cb) { 25 | window.setTimeout(cb, 1000 / 60) 26 | } 27 | 28 | export const cancelAnimationFrame = 29 | window.cancelAnimationFrame || 30 | window.mozCancelAnimationFrame || 31 | function (id) { 32 | window.clearTimeout(id) 33 | } 34 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import Enzyme, { mount } from 'enzyme' 3 | import Adapter from 'enzyme-adapter-react-16' 4 | 5 | Enzyme.configure({ adapter: new Adapter() }) 6 | 7 | import VirtualizedList from '../src/index' 8 | 9 | const HEIGHT = 100 10 | const ITEM_HEIGHT = 10 11 | 12 | describe('VirtualizedList', () => { 13 | // eslint-disable-next-line 14 | function renderItem ({ index }) { 15 | return ( 16 |
17 | Item #{index} 18 |
19 | ) 20 | } 21 | 22 | function getComponent(props = {}) { 23 | return ( 24 | 34 | ) 35 | } 36 | 37 | let wrapper = null 38 | 39 | beforeEach(() => { 40 | wrapper = mount(getComponent()) 41 | }) 42 | 43 | afterEach(() => { 44 | wrapper = null 45 | }) 46 | 47 | describe('normaly render', () => { 48 | it('calls the componentDidMount function when it is created', () => { 49 | const componentDidMountSpy = jest.spyOn(VirtualizedList.prototype, 'componentDidMount') 50 | mount(getComponent()) 51 | expect(componentDidMountSpy).toHaveBeenCalledTimes(1) 52 | componentDidMountSpy.mockRestore() 53 | }) 54 | 55 | it('correct instance', () => { 56 | const inst = wrapper.instance() 57 | expect(inst).toBeInstanceOf(VirtualizedList) 58 | }) 59 | 60 | it('correct container element', () => { 61 | expect(wrapper.exists('.container')).toBeTruthy() 62 | }) 63 | }) 64 | 65 | describe('number of rendered children', () => { 66 | /** 67 | * there are some issues about component update after v3 68 | * https://github.com/airbnb/enzyme/issues/1245 69 | * https://github.com/airbnb/enzyme/issues/1543 70 | **/ 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /tpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | React Virtualized List 12 | <%= htmlWebpackPlugin.files.webpackManifest %> 13 | 14 | 15 |
16 | 17 | -------------------------------------------------------------------------------- /webpack.build.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | mode: 'production', 5 | entry: { 6 | index: path.resolve(__dirname, './src/index') 7 | }, 8 | 9 | output: { 10 | path: path.join(__dirname, './dist'), 11 | filename: '[name].js', 12 | library: 'ReactVirtualList', 13 | libraryTarget: 'commonjs2' 14 | }, 15 | 16 | module: { 17 | rules: [{ 18 | test: /\.(js|jsx)$/, 19 | exclude: /node_modules/, 20 | loader: 'babel-loader' 21 | }] 22 | }, 23 | 24 | resolve: { 25 | extensions: ['.jsx', '.js'], 26 | modules: [path.join(__dirname, './node_modules')] 27 | }, 28 | 29 | optimization: { 30 | minimize: false 31 | }, 32 | 33 | externals: [{ 34 | 'react': { 35 | root: 'React', 36 | commonjs2: 'react', 37 | commonjs: 'react', 38 | amd: 'react' 39 | } 40 | }] 41 | } 42 | --------------------------------------------------------------------------------