├── .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 |    [](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 |    [](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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------