Skip to content

Instantly share code, notes, and snippets.

@larvata
Created November 23, 2017 03:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save larvata/53cc0d1f48278299a2c446a8d1ce64db to your computer and use it in GitHub Desktop.
Save larvata/53cc0d1f48278299a2c446a8d1ce64db to your computer and use it in GitHub Desktop.
an infinite scroll component which didn't require the item height
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