Created
November 23, 2017 03:08
-
-
Save larvata/53cc0d1f48278299a2c446a8d1ce64db to your computer and use it in GitHub Desktop.
an infinite scroll component which didn't require the item height
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from 'react'; | |
import ReactDOM from 'react-dom'; | |
import _ from 'lodash'; | |
// todos | |
// - move the ref of each item componnent to it's parent | |
// - check the SSR result, because the actual content only rendered after did mount | |
// d - is set containerDOM.style.height in did update necessary? | |
// d - rename padding elements to beforePadder, afterPadder | |
// - set the buffer height to the height of container by default | |
// constants | |
const CONTAINER_REF_KEY = 'CONTAINER_REF_KEY'; | |
const CONTENT_REF_KEY = 'CONTENT_REF_KEY'; | |
const BEFORE_PADDER_REF_KEY = 'BEFORE_PADDER_REF_KEY'; | |
const AFTER_PADDER_REF_KEY = 'AFTER_PADDER_REF_KEY'; | |
/** | |
* The SmartVirtualList component , | |
* only render the visible items in the viewport. | |
* inspired by http://itsze.ro/blog/2017/04/09/infinite-list-and-react.html | |
* | |
* @example | |
* | |
* var renderer = (item, index)=>{ | |
* // returns a react component | |
* } | |
* | |
* <SmartVirtualList | |
* // required, renderer for render each item | |
* itemRenderer={renderer} | |
* // required, items to display | |
* items={items} | |
* | |
* // optional, default: 200, throttle timeout for the scroll event | |
* scrollThrottle={200} | |
* // optional, default: a function returns index of the item, | |
* // a function to get the unique key of the item | |
* getItemKey={getItemKey} | |
* // options, default: 500, buffer high in px for the top/bottom padding, | |
* // the recommand value is a number not smaller than the container height, | |
* // if the value is not large enough, | |
* // you will see a blank content in a short perid when you scroll fastly | |
* bufferHeight={500} | |
* // first rendering item count | |
* itemCountFirstRender={10} | |
* /> | |
*/ | |
class SmartVirtualList extends React.Component { | |
constructor(props) { | |
super(props); | |
this.initComponent(props); | |
const { scrollThrottle } = props; | |
this.containerScroll = _.throttle(this.containerScroll.bind(this), scrollThrottle); | |
this.state = { | |
visibleItems: [], | |
}; | |
} | |
initComponent(props) { | |
const { items } = props; | |
this.averageHeight = 0; | |
// cache for item-component | |
this.cachedItems = this.buildItemCacheArray(items); | |
} | |
/** | |
* update props and re-render items list on DOM | |
* @param {[type]} nextProps [description] | |
* @return {[type]} [description] | |
*/ | |
componentWillReceiveProps(nextProps) { | |
this.initComponent(nextProps); | |
const { itemRenderer } = nextProps; | |
const { visibleItems } = this.state; | |
// rerender all components in the visibleItems | |
visibleItems.forEach((vi, idx) => { | |
vi.element = itemRenderer(vi.raw, idx, -1); | |
}); | |
this.setState({ | |
visibleItems, | |
}); | |
} | |
componentDidMount() { | |
const { itemCountFirstRender, itemRenderer } = this.props; | |
// render first {ITEM_COUNT_FOR_FIRST_RENDER} items by default when first render | |
const visibleItems = this.cachedItems.slice(0, itemCountFirstRender); | |
// init component instance | |
visibleItems.forEach((vi, idx) => { | |
if (!vi.element) { | |
// eslint-disable-next-line no-param-reassign | |
vi.element = itemRenderer(vi.raw, idx); | |
} | |
}); | |
// eslint-disable-next-line react/no-did-mount-set-state | |
this.setState({ | |
visibleItems | |
}); | |
} | |
componentDidUpdate() { | |
this.updateVisibleItemsHeight(); | |
const contentDOM = ReactDOM.findDOMNode(this.refs[CONTENT_REF_KEY]); | |
const gussedHeight = this.guessContainerHeight(); | |
// update the wrapper height | |
contentDOM.style.height = `${gussedHeight}px`; | |
} | |
getContainerDOM() { | |
const container = ReactDOM.findDOMNode(this.refs[CONTAINER_REF_KEY]); | |
return container; | |
} | |
getItemAverageHeight() { | |
// guess the total height of the container | |
const allItemsHasHeight = this.cachedItems.filter(itm => { | |
return Number.isInteger(itm.height); | |
}); | |
// update the average height | |
const itemAverageHeight = allItemsHasHeight.reduce((a, b) => { | |
return a + b.height; | |
}, 0) / allItemsHasHeight.length; | |
return itemAverageHeight; | |
} | |
updateVisibleItemsHeight() { | |
const { visibleItems } = this.state; | |
// get the clientHeight of each rendered element | |
visibleItems.forEach((item) => { | |
const { key, height } = item; | |
if (Number.isInteger(height)) { | |
return; | |
} | |
const itemDOM = ReactDOM.findDOMNode(this.refs[key]); | |
const clientHeight = itemDOM.clientHeight; | |
item.height = clientHeight; | |
}); | |
} | |
guessContainerHeight() { | |
const itemAverageHeight = this.getItemAverageHeight(); | |
const containerHeight = itemAverageHeight * this.cachedItems.length; | |
return containerHeight; | |
} | |
buildItemCacheArray(items) { | |
const { getItemKey } = this.props; | |
const result = items.map((itm, idx) => { | |
const itemKey = getItemKey(itm, idx); | |
const itemForCache = { | |
raw: itm, | |
key: itemKey, | |
index: idx, | |
height: null, | |
}; | |
return itemForCache; | |
}); | |
return result; | |
} | |
/** | |
* @listens {event} listen event on scroll | |
*/ | |
containerScroll() { | |
const { bufferHeight, itemRenderer } = this.props; | |
const container = this.getContainerDOM(); | |
const { scrollTop, clientHeight: containerHeight } = container; | |
const itemAverageHeight = this.getItemAverageHeight(); | |
// th stack height is the sum height of the previous items | |
let currentStackHeight = 0; | |
const visibleItems = []; | |
let beforePadderHeight = 0; | |
let afterPadderHeight = 0; | |
this.cachedItems.forEach((ci, idx) => { | |
const { height } = ci; | |
// check is in view | |
const itemHeight = Number.isInteger(height) ? height : itemAverageHeight; | |
if (currentStackHeight + bufferHeight < scrollTop) { | |
beforePadderHeight += itemHeight; | |
} | |
else if (currentStackHeight > (scrollTop + containerHeight) + bufferHeight) { | |
afterPadderHeight += itemHeight; | |
} | |
else { | |
if (!ci.element) { | |
// calc the page index | |
const page = Math.floor(currentStackHeight / containerHeight); | |
// todo the page will be nan on props changes | |
console.log('page', page); | |
ci.element = itemRenderer(ci.raw, idx, page); | |
} | |
visibleItems.push(ci); | |
} | |
currentStackHeight += itemHeight; | |
}); | |
const topPadding = ReactDOM.findDOMNode(this.refs[BEFORE_PADDER_REF_KEY]); | |
const bottomPadding = ReactDOM.findDOMNode(this.refs[AFTER_PADDER_REF_KEY]); | |
topPadding.style.height = `${beforePadderHeight}px`; | |
bottomPadding.style.height = `${afterPadderHeight}px`; | |
if (this.state.visibleItems.length !== visibleItems.length) { | |
this.setState({ | |
visibleItems, | |
}); | |
} | |
else if (this.state.visibleItems[0] !== visibleItems[0]) { | |
this.setState({ | |
visibleItems, | |
}); | |
} | |
} | |
render() { | |
console.log('SmartVirtualList render'); | |
const { className } = this.props; | |
// todo hardcode for dev | |
const containerStyle = { | |
height: '500px', | |
overflow: 'auto', | |
}; | |
const { visibleItems } = this.state; | |
return ( | |
<div | |
ref={CONTAINER_REF_KEY} | |
className={className} | |
style={containerStyle} | |
onScroll={this.containerScroll} | |
> | |
<div ref={CONTENT_REF_KEY} className="content"> | |
<div className="top" ref={BEFORE_PADDER_REF_KEY} /> | |
{ | |
visibleItems.map(itm => <div key={itm.key} ref={itm.key} children={itm.element} />) | |
} | |
<div className="bottom" ref={AFTER_PADDER_REF_KEY} /> | |
</div> | |
</div> | |
); | |
} | |
} | |
SmartVirtualList.propTypes = { | |
itemRenderer: React.PropTypes.func.isRequired, | |
items: React.PropTypes.array.isRequired, | |
bufferHeight: React.PropTypes.number, | |
itemCountFirstRender: React.PropTypes.number, | |
scrollThrottle: React.PropTypes.number, | |
// the item key SHOULD NOT be an index | |
getItemKey: React.PropTypes.func, | |
className: React.PropTypes.string, | |
}; | |
/** | |
* default props | |
*/ | |
SmartVirtualList.defaultProps = { | |
bufferHeight: 500, | |
itemCountFirstRender: 10, | |
scrollThrottle: 150, | |
getItemKey: (itm, idx) => { | |
return idx; | |
}, | |
}; | |
export default SmartVirtualList; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment