本節,咱們實現一個極簡版的虛擬列表,固定尺寸的虛擬列表,麻雀雖小,倒是五臟俱全哦!html
實現一個固定尺寸的虛擬渲染列表組件,props屬性以下:react
props: { width: number; height: number; itemCount: number; itemSize: number; }
使用方式:app
const Row = (..args) => (<div className="Row"></div>); <List className={"List"} width={300} height={300} itemCount={10000} itemSize={40}> {Row} </List>
什麼技術棧均可以,這裏項目使用的react,那就選用react來實現。dom
使用create-react-app初始化一個應用,而後啓動,清理掉demo的代碼。函數
根據上一節的分析,咱們核心技術實現是 一個render渲染函數,用來渲染數據;一個onScroll函數監聽滾動事件,去更新 數據區間[startIndex, endIndex],而後從新render。大概僞代碼以下:this
class List extends React.PureComponent { state = {}; render() {}; onScroll() {}; }
接下來咱們進行細節填充實現,首先咱們須要根據數據渲染出第一屏初始化的dom,即要先實現render函數邏輯,咱們採用絕對定位的方式進行dom排版。code
render() { // 從props解析屬性 const { children, width, height, itemCount, layout, itemKey = defaultItemKey, } = this.props; // 預留方向設定屬性 const isHorizontal = layout === "horizontal"; // 假設有一個函數_getRangeToRender能夠幫咱們計算出 渲染區間 const [startIndex, stopIndex] = this._getRangeToRender(); const items = []; if (itemCount > 0) { // 循環建立元素 for (let index = startIndex; index <= stopIndex; index++) { items.push( createElement(children, { data: {}, key: itemKey(index), index, style: this._getItemStyle(index), // 幫助計算dom的位置樣式 }) ); } } // 假設getEstimatedTotalSize函數能夠幫助咱們計算出總尺寸 const estimatedTotalSize = getEstimatedTotalSize( this.props, ); return createElement( "div", { onScroll: this.onScroll, style: { position: "relative", height, width, overflow: "auto", WebkitOverflowScrolling: "touch", willChange: "transform", }, }, createElement("div", { children: items, style: { height: isHorizontal ? "100%" : estimatedTotalSize, pointerEvents: "none", width: isHorizontal ? estimatedTotalSize : "100%", }, }) ); }
OK,到了這裏render函數的邏輯就寫完了,是否是超級簡單。接下來咱們實現如下 render函數裏面使用到的輔助函數.orm
先看getEstimatedTotalSize計算總尺寸函數的實現:htm
// 總尺寸 = 總個數 * 每一個size export const getEstimatedTotalSize = ({ itemCount, itemSize }) => itemSize * itemCount;
計算須要渲染的數據區間函數實現索引
_getRangeToRender() { // overscanCount是緩衝區的數量,默認設置1 const { itemCount, overscanCount = 1 } = this.props; // 已經滾動的距離,初始默認0 const { scrollOffset } = this.state; if (itemCount === 0) { return [0, 0, 0, 0]; } // 輔助函數,根據 滾動距離計算出 區間開始的索引 const startIndex = getStartIndexForOffset( this.props, scrollOffset, ); // 輔助函數,根據 區間開始的索引計算出 區間結束的索引 const stopIndex = getStopIndexForStartIndex( this.props, startIndex, scrollOffset, ); return [ Math.max(0, startIndex - overscanCount), Math.max(0, Math.min(itemCount - 1, stopIndex + overscanCount)), startIndex, stopIndex, ]; } } // 計算區間開始索引,滾動距離 除以 每一個單元尺寸 就是 startIndex export const getStartIndexForOffset = ({ itemCount, itemSize }, offset) => Math.max(0, Math.min(itemCount - 1, Math.floor(offset / itemSize))); // 計算區間結束索引,開始索引 + 可見區域size / itemSize 便可 export const getStopIndexForStartIndex = ( { height, itemCount, itemSize, layout, width }, startIndex, scrollOffset ) => { const isHorizontal = layout === "horizontal"; const offset = startIndex * itemSize; const size = isHorizontal ? width : height; const numVisibleItems = Math.ceil((size + scrollOffset - offset) / itemSize); return Math.max( 0, Math.min( itemCount - 1, startIndex + numVisibleItems - 1 ) ); };
計算方式:根據index * itemSize 便可計算出position
_getItemStyle = (index) => { const { layout } = this.props; let style; const offset = index * itemSize; const size = itemSize; const isHorizontal = layout === "horizontal"; const offsetHorizontal = isHorizontal ? offset : 0; style = { position: "absolute", left: offsetHorizontal, top: !isHorizontal ? offset : 0, height: !isHorizontal ? size : "100%", width: isHorizontal ? size : "100%", }; return style; };
好了,到此位置,render函數的全部邏輯所有實現完畢了。
最後一步,只須要監聽onScroll事件,更新 數據索引區間,咱們的功能就完善了
// 很是簡單,只是一個 setState操做,更新滾動距離便可 _onScrollVertical = (event) => { const { clientHeight, scrollHeight, scrollTop } = event.currentTarget; this.setState((prevState) => { if (prevState.scrollOffset === scrollTop) { return null; } const scrollOffset = Math.max( 0, Math.min(scrollTop, scrollHeight - clientHeight) ); return { scrollOffset, }; }); };
class List extends PureComponent { _outerRef; static defaultProps = { layout: "vertical", overscanCount: 2, }; state = { instance: this, scrollDirection: "forward", scrollOffset: 0, }; render() { const { children, width, height, itemCount, layout, itemKey = defaultItemKey, } = this.props; const isHorizontal = layout === "horizontal"; // 監聽滾動函數 const onScroll = isHorizontal ? this._onScrollHorizontal : this._onScrollVertical; const [startIndex, stopIndex] = this._getRangeToRender(); const items = []; if (itemCount > 0) { for (let index = startIndex; index <= stopIndex; index++) { items.push( createElement(children, { data: {}, key: itemKey(index), index, style: this._getItemStyle(index), }) ); } } const estimatedTotalSize = getEstimatedTotalSize( this.props ); return createElement( "div", { onScroll, style: { position: "relative", height, width, overflow: "auto", WebkitOverflowScrolling: "touch", willChange: "transform", }, }, createElement("div", { children: items, style: { height: isHorizontal ? "100%" : estimatedTotalSize, pointerEvents: "none", width: isHorizontal ? estimatedTotalSize : "100%", }, }) ); } _onScrollHorizontal = (event) => {}; _onScrollVertical = (event) => { const { clientHeight, scrollHeight, scrollTop } = event.currentTarget; this.setState((prevState) => { if (prevState.scrollOffset === scrollTop) { return null; } const scrollOffset = Math.max( 0, Math.min(scrollTop, scrollHeight - clientHeight) ); return { scrollOffset, }; }); }; _getItemStyle = (index) => { const { layout } = this.props; let style; const offset = getItemOffset(this.props, index, this._instanceProps); const size = getItemSize(this.props, index, this._instanceProps); const isHorizontal = layout === "horizontal"; const offsetHorizontal = isHorizontal ? offset : 0; style = { position: "absolute", left: offsetHorizontal, top: !isHorizontal ? offset : 0, height: !isHorizontal ? size : "100%", width: isHorizontal ? size : "100%", }; return style; }; // 計算出須要渲染的數據索引區間 _getRangeToRender() { const { itemCount, overscanCount = 1 } = this.props; const { scrollOffset } = this.state; if (itemCount === 0) { return [0, 0, 0, 0]; } const startIndex = getStartIndexForOffset( this.props, scrollOffset ); const stopIndex = getStopIndexForStartIndex( this.props, startIndex, scrollOffset ); return [ Math.max(0, startIndex - overscanCount), Math.max(0, Math.min(itemCount - 1, stopIndex + overscanCount)), startIndex, stopIndex, ]; } }