這篇是 react-window 的原始碼閱讀, 因為此庫使用的是 flow, 所以會涉及一些特殊的東西, 類似 ts
使用
List
首先是 List 的使用:
import {FixedSizeList as List} from 'react-window';
const Row = ({index, style}) => (
<div style={style}>Row {index}</div>
);
const App = () => (
<List
height={150}
itemCount={1000}
itemSize={35}
width={300}
>
{Row}
</List>
);
相對 react-virtual 的使用來說簡單了很多, 使用方便, 但是相對地, 暴露的也少了一點點
解析
首先它是在一整個 createListComponent
的基礎上來建立 List 的具體方法的:
const FixedSizeList = createListComponent({
// ...
// 這裡陳列幾個主要函式和他的具體作用
})
export default FixedSizeList;
這裡先說下 createListComponent 的大體方法:
export default function createListComponent({
// 省略
}) {
return class List extends PureComponent {
// 滾動至 scrollOffset 的位置
scrollTo = (scrollOffset: number): void
// 滾動至某一 item 上, 通過傳遞對應序號
scrollToItem(index: number, align: ScrollToAlign = 'auto'): void
// 快取引數
_callOnItemsRendered: (
overscanStartIndex: number,
overscanStopIndex: number,
visibleStartIndex: number,
visibleStopIndex: number
) => void;
// 通過 index 來獲取對應的style, 其中有, 長, 寬, left, top 等具體位置屬性, 同時這些屬性也有快取
_getItemStyle: (index: number) => Object;
// 獲取序號 , overscanStartIndex,overscanStopIndex, visibleStartIndex, visibleStopIndex
_getRangeToRender(): [number, number, number, number]
// 滾動時觸發對應回撥, 更新scrollOffset
_onScrollHorizontal = (event: ScrollEvent): void
// 同上
_onScrollVertical = (event: ScrollEvent): void
// 渲染函式
render() {
}
}
}
createListComponent
下面我們就詳情的解析一下這個元件的方法:
export default function createListComponent({
getItemOffset,
getEstimatedTotalSize,
getItemSize,
getOffsetForIndexAndAlignment,
getStartIndexForOffset,
getStopIndexForStartIndex,
initInstanceProps,
shouldResetStyleCacheOnItemSizeChange,
validateProps,
}) {
//直接就返回一個 class 元件, 沒有閉包變數
return class List extends PureComponent {
// 初始化的時候獲取的 props 引數
_instanceProps: any = initInstanceProps(this.props, this);
//外部元素 ref 物件
_outerRef: ?HTMLDivElement;
// 用來存取 定時器的
_resetIsScrollingTimeoutId: TimeoutID | null = null;
// 預設的引數
static defaultProps = {
direction: 'ltr', // 方向
itemData: undefined, // 每一個 item 的物件
layout: 'vertical', // 佈局
overscanCount: 2, // 上部和下部超出的 item 個數
useIsScrolling: false, // 是否正在滾動
};
// 元件的 state
state: State = {
instance: this,
isScrolling: false,
scrollDirection: 'forward',
scrollOffset:
typeof this.props.initialScrollOffset === 'number'
? this.props.initialScrollOffset
: 0, // 根據 props 來判斷
scrollUpdateWasRequested: false,
};
// constructor
constructor(props: Props<T>) {
super(props);
}
// props 到 state 的對映
static getDerivedStateFromProps(
nextProps: Props<T>,
prevState: State
): $Shape<State> | null {
// 這個函式具體的原始碼我們在下面說明
// 對於 下一步收到的 props 和上一步的 state, 做出判斷
// 如果收到的引數不規範則會報錯, 可以忽略
validateSharedProps(nextProps, prevState);
// validateProps 此方法是外部傳遞的, note 1
validateProps(nextProps);
return null;
}
// 滾動至某一位置
scrollTo(scrollOffset: number): void {
// 確保 scrollOffset 大於 0
scrollOffset = Math.max(0, scrollOffset);
this.setState(prevState => {
// 同樣地就 return
if (prevState.scrollOffset === scrollOffset) {
return null;
}
// 直接設定 scrollOffset
return {
// 滾動的方向
scrollDirection:
prevState.scrollOffset < scrollOffset ? 'forward' : 'backward',
scrollOffset: scrollOffset,
scrollUpdateWasRequested: true,
};
// 回撥
}, this._resetIsScrollingDebounced);
}
// 方法同上, 作用是滾動至某一個 item 上面
scrollToItem(index: number, align: ScrollToAlign = 'auto'): void {
const { itemCount } = this.props;
const { scrollOffset } = this.state;
// 保證 index 在 0 和 item 最大值之間
index = Math.max(0, Math.min(index, itemCount - 1));
// 呼叫 scrollTo 方法, 引數是 getOffsetForIndexAndAlignment 的返回值
// 此函式作用是通過 index 獲取對應 item 的偏移量, 最後通過偏移量滾動至對應的 item
// 函式通過 createListComponent 的傳參獲取, 不同的 list/grid, 可能有不用的方案
this.scrollTo(
getOffsetForIndexAndAlignment(
this.props,
index,
align,
scrollOffset,
this._instanceProps
)
);
}
// mount 所作的事情
componentDidMount() {
const { direction, initialScrollOffset, layout } = this.props;
// initialScrollOffset 是數字且 _outerRef 正常
if (typeof initialScrollOffset === 'number' && this._outerRef != null) {
const outerRef = ((this._outerRef: any): HTMLElement);
// TODO Deprecate direction "horizontal"
if (direction === 'horizontal' || layout === 'horizontal') {
outerRef.scrollLeft = initialScrollOffset;
} else {
outerRef.scrollTop = initialScrollOffset;
}
}
this._callPropsCallbacks();
}
componentDidUpdate() {
const { direction, layout } = this.props;
const { scrollOffset, scrollUpdateWasRequested } = this.state;
if (scrollUpdateWasRequested && this._outerRef != null) {
const outerRef = ((this._outerRef: any): HTMLElement); // outerRef可以說是最外層元素的 ref 物件
// 這裡因為版本問題 可能還會去除 direction 的 horizontal 判斷
if (direction === 'horizontal' || layout === 'horizontal') {
if (direction === 'rtl') {
// 針對不同的型別 來左右滾動至最 scrollOffset 的偏移量
switch (getRTLOffsetType()) {
case 'negative':
outerRef.scrollLeft = -scrollOffset;
break;
case 'positive-ascending':
outerRef.scrollLeft = scrollOffset;
break;
default:
const { clientWidth, scrollWidth } = outerRef;
outerRef.scrollLeft = scrollWidth - clientWidth - scrollOffset;
break;
}
} else {
outerRef.scrollLeft = scrollOffset;
}
} else {
// 針對上下的滾動
outerRef.scrollTop = scrollOffset;
}
}
// 呼叫此函式
// 作用是: 快取節點, 滾動狀態等資料
this._callPropsCallbacks();
}
// 元件離開時清空定時器
componentWillUnmount() {
if (this._resetIsScrollingTimeoutId !== null) {
cancelTimeout(this._resetIsScrollingTimeoutId);
}
}
// 渲染函式
render() {
const {
children,
className,
direction,
height,
innerRef,
innerElementType,
innerTagName,
itemCount,
itemData,
itemKey = defaultItemKey,
layout,
outerElementType,
outerTagName,
style,
useIsScrolling,
width,
} = this.props;
// 是否滾動
const { isScrolling } = this.state;
// direction "horizontal" 相容老資料
const isHorizontal =
direction === 'horizontal' || layout === 'horizontal';
// 當滾動時的回撥, 針對不同方向
const onScroll = isHorizontal
? this._onScrollHorizontal
: this._onScrollVertical;
// 返回節點的範圍 [真實起點, 真實終點]
const [startIndex, stopIndex] = this._getRangeToRender();
const items = [];
if (itemCount > 0) {
// 迴圈所有 item 數來建立 item, createElement 傳遞引數
for (let index = startIndex; index <= stopIndex; index++) {
items.push(
createElement(children, {
data: itemData,
key: itemKey(index, itemData),
index,
isScrolling: useIsScrolling ? isScrolling : undefined,
style: this._getItemStyle(index), // render 時獲取 style
})
);
}
}
// getEstimatedTotalSize來自 父函式 props
// 在專案被建立後讀取這個值,因此它們的實際尺寸(如果是可變的)被考慮在內
const estimatedTotalSize = getEstimatedTotalSize(
this.props,
this._instanceProps
);
// 動態, 可配置性地建立元件
return createElement(
outerElementType || outerTagName || 'div',
{
className,
onScroll,
ref: this._outerRefSetter,
style: {
position: 'relative',
height,
width,
overflow: 'auto',
WebkitOverflowScrolling: 'touch',
willChange: 'transform', // 提前優化, 相當於整體包裝
direction,
...style,
},
},
createElement(innerElementType || innerTagName || 'div', {
children: items,
ref: innerRef,
style: {
height: isHorizontal ? '100%' : estimatedTotalSize,
pointerEvents: isScrolling ? 'none' : undefined,
width: isHorizontal ? estimatedTotalSize : '100%',
},
})
);
}
_callOnItemsRendered: (
overscanStartIndex: number,
overscanStopIndex: number,
visibleStartIndex: number,
visibleStopIndex: number
) => void;
// 作用 , 快取最新的這四份資料
_callOnItemsRendered = memoizeOne(
(
overscanStartIndex: number,
overscanStopIndex: number,
visibleStartIndex: number,
visibleStopIndex: number
) =>
((this.props.onItemsRendered: any): onItemsRenderedCallback)({
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex,
})
);
// 快取這 3 個資料
_callOnScroll: (
scrollDirection: ScrollDirection,
scrollOffset: number,
scrollUpdateWasRequested: boolean
) => void;
_callOnScroll = memoizeOne(
(
scrollDirection: ScrollDirection,
scrollOffset: number,
scrollUpdateWasRequested: boolean
) =>
((this.props.onScroll: any): onScrollCallback)({
scrollDirection,
scrollOffset,
scrollUpdateWasRequested,
})
);
_callPropsCallbacks() {
// 判斷來自 props 的 onItemsRendered是否是函式
if (typeof this.props.onItemsRendered === 'function') {
const { itemCount } = this.props;
if (itemCount > 0) {
// 總的數量大於 0 時
// 從_getRangeToRender獲取節點的範圍
const [
overscanStartIndex, // 真實的起點
overscanStopIndex, // 真實的終點
visibleStartIndex, // 檢視的起點
visibleStopIndex, // 檢視的終點
] = this._getRangeToRender();
// 呼叫 _callOnItemsRendered, 更新快取
this._callOnItemsRendered(
overscanStartIndex,
overscanStopIndex,
visibleStartIndex,
visibleStopIndex
);
}
}
// 如果傳遞了 onScroll 函式過來
if (typeof this.props.onScroll === 'function') {
const {
scrollDirection,
scrollOffset,
scrollUpdateWasRequested,
} = this.state;
// 呼叫此函式, 作用同樣是快取資料
this._callOnScroll(
scrollDirection,
scrollOffset,
scrollUpdateWasRequested
);
}
}
// 在滾動時 lazy 地建立和快取專案的樣式,
// 這樣 pure 元件的就可以防止重新渲染。
// 維護這個快取,並傳遞一個props而不是index,
// 這樣List就可以清除快取的樣式並在必要時強制重新渲染專案
_getItemStyle: (index: number) => Object;
_getItemStyle = (index: number): Object => {
const { direction, itemSize, layout } = this.props;
// 快取 , itemSize, layout, direction 有改變 也會造成快取清空
const itemStyleCache = this._getItemStyleCache(
shouldResetStyleCacheOnItemSizeChange && itemSize,
shouldResetStyleCacheOnItemSizeChange && layout,
shouldResetStyleCacheOnItemSizeChange && direction
);
let style;
// 有快取則取快取, 注意 hasOwnProperty 和 in [index] 的區別
if (itemStyleCache.hasOwnProperty(index)) {
style = itemStyleCache[index];
} else {
// getItemOffset 和 getItemSize 來自父函式 props
const offset = getItemOffset(this.props, index, this._instanceProps);
const size = getItemSize(this.props, index, this._instanceProps);
const isHorizontal =
direction === 'horizontal' || layout === 'horizontal';
const isRtl = direction === 'rtl';
const offsetHorizontal = isHorizontal ? offset : 0;
// 快取 index:{} 至 itemStyleCache 物件
itemStyleCache[index] = style = {
position: 'absolute',
left: isRtl ? undefined : offsetHorizontal,
right: isRtl ? offsetHorizontal : undefined,
top: !isHorizontal ? offset : 0,
height: !isHorizontal ? size : '100%',
width: isHorizontal ? size : '100%',
};
}
return style;
};
_getItemStyleCache: (_: any, __: any, ___: any) => ItemStyleCache;
_getItemStyleCache = memoizeOne((_: any, __: any, ___: any) => ({}));
_getRangeToRender(): [number, number, number, number] {
// 數量相關資料
const { itemCount, overscanCount } = this.props;
// 是否滾動, 滾動方向, 滾動距離
const { isScrolling, scrollDirection, scrollOffset } = this.state;
// 如果數量為 0 則 return
if (itemCount === 0) {
return [0, 0, 0, 0];
}
// 開始的x序號 getStartIndexForOffset 來源於 閉包傳遞, 通過距離來獲取序號
const startIndex = getStartIndexForOffset(
this.props,
scrollOffset,
this._instanceProps
);
// 結束的序號, 作用同上, 但是獲取的是結束的序號
const stopIndex = getStopIndexForStartIndex(
this.props,
startIndex,
scrollOffset,
this._instanceProps
);
// 超出的範圍的數量, 前, 後 兩個變數
const overscanBackward =
!isScrolling || scrollDirection === 'backward'
? Math.max(1, overscanCount)
: 1;
const overscanForward =
!isScrolling || scrollDirection === 'forward'
? Math.max(1, overscanCount)
: 1;
// 最終返回資料, [開始的節點序號-超出的節點,結束的節點序號+超出的節點, 開始的節點序號, 結束的節點序號]
return [
Math.max(0, startIndex - overscanBackward),
Math.max(0, Math.min(itemCount - 1, stopIndex + overscanForward)),
startIndex,
stopIndex,
];
}
// 大體作用會和 _onScrollVertical 類似
_onScrollHorizontal = (event: ScrollEvent): void => {
const { clientWidth, scrollLeft, scrollWidth } = event.currentTarget;
this.setState(prevState => {
if (prevState.scrollOffset === scrollLeft) {
// 如果滾動距離不變
return null;
}
const { direction } = this.props;
let scrollOffset = scrollLeft;
if (direction === 'rtl') {
// 根據方向確定滾動距離
switch (getRTLOffsetType()) {
case 'negative':
scrollOffset = -scrollLeft;
break;
case 'positive-descending':
scrollOffset = scrollWidth - clientWidth - scrollLeft;
break;
}
}
// 保證距離在範圍之內, 同時 Safari在越界時會有晃動
scrollOffset = Math.max(
0,
Math.min(scrollOffset, scrollWidth - clientWidth)
);
return {
isScrolling: true,
scrollDirection:
prevState.scrollOffset < scrollLeft ? 'forward' : 'backward',
scrollOffset,
scrollUpdateWasRequested: false,
};
}, this._resetIsScrollingDebounced);
};
// 同上 , 這裡就不多說了
_onScrollVertical = (event: ScrollEvent): void => {
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 {
isScrolling: true,
scrollDirection:
prevState.scrollOffset < scrollOffset ? 'forward' : 'backward',
scrollOffset,
scrollUpdateWasRequested: false,
};
}, this._resetIsScrollingDebounced);
};
_outerRefSetter = (ref: any): void => {
const { outerRef } = this.props;
this._outerRef = ((ref: any): HTMLDivElement);
if (typeof outerRef === 'function') {
outerRef(ref);
} else if (
outerRef != null &&
typeof outerRef === 'object' &&
outerRef.hasOwnProperty('current')
) {
outerRef.current = ref;
}
};
_resetIsScrollingDebounced = () => {
// 避免同一時間多次呼叫 此函式, 起到一個節流的作用
if (this._resetIsScrollingTimeoutId !== null) {
cancelTimeout(this._resetIsScrollingTimeoutId);
}
// requestTimeout 是一個工具函式, 在延遲 IS_SCROLLING_DEBOUNCE_INTERVAL = 150 ms 之後執行, 類似 setTimeout, 但是為什麼不直接使用
// 引出額外的問題 setTimeout和requestAnimationFrame 的區別, 有興趣的可以自行了解
this._resetIsScrollingTimeoutId = requestTimeout(
this._resetIsScrolling,
IS_SCROLLING_DEBOUNCE_INTERVAL
);
};
_resetIsScrolling = () => {
// 執行的時候清空id
this._resetIsScrollingTimeoutId = null;
this.setState({ isScrolling: false }, () => {
// 在狀態更新操作
// 避免isScrolling的影響
// _getItemStyleCache 的具體作用, 他是一個經過 memoizeOne 過的函式
// 而 memoizeOne 是來源於`memoize-one`倉庫 https://www.npmjs.com/package/memoize-one
// 用處是快取最近的一個結果 而這裡是返回一個空物件
// 在更新後清空 style
this._getItemStyleCache(-1, null);
});
};
};
}
FixedSizeList
這個元件就是通過 createListComponent 來建立的最終結果:
const FixedSizeList = createListComponent({
// 前三個引數都十分簡單,
getItemOffset: ({itemSize}: Props<any>, index: number): number =>
index * ((itemSize: any): number),
getItemSize: ({itemSize}: Props<any>, index: number): number =>
((itemSize: any): number),
getEstimatedTotalSize: ({itemCount, itemSize}: Props<any>) =>
((itemSize: any): number) * itemCount,
// 通過 index 算出 offset 距離, 是一個比較 pure 的計算函式
getOffsetForIndexAndAlignment: (
{ direction, height, itemCount, itemSize, layout, width }: Props<any>,
index: number,
align: ScrollToAlign,
scrollOffset: number
): number => {
const isHorizontal = direction === 'horizontal' || layout === 'horizontal';
const size = (((isHorizontal ? width : height): any): number);
const lastItemOffset = Math.max(
0,
itemCount * ((itemSize: any): number) - size
);
const maxOffset = Math.min(
lastItemOffset,
index * ((itemSize: any): number)
);
const minOffset = Math.max(
0,
index * ((itemSize: any): number) - size + ((itemSize: any): number)
);
// 針對不同的 align 變數 做出不同應對
if (align === 'smart') {
if (
scrollOffset >= minOffset - size &&
scrollOffset <= maxOffset + size
) {
align = 'auto';
} else {
align = 'center';
}
}
switch (align) {
case 'start':
return maxOffset;
case 'end':
return minOffset;
case 'center': {
const middleOffset = Math.round(
minOffset + (maxOffset - minOffset) / 2
);
if (middleOffset < Math.ceil(size / 2)) {
return 0; // 開始
} else if (middleOffset > lastItemOffset + Math.floor(size / 2)) {
return lastItemOffset; //結束的位置
} else {
return middleOffset;
}
}
case 'auto':
default:
if (scrollOffset >= minOffset && scrollOffset <= maxOffset) {
return scrollOffset;
} else if (scrollOffset < minOffset) {
return minOffset;
} else {
return maxOffset;
}
}
},
getStartIndexForOffset: (
{ itemCount, itemSize }: Props<any>,
offset: number
): number =>
Math.max(
0,
Math.min(itemCount - 1, Math.floor(offset / ((itemSize: any): number)))
),
// 獲取開始和結束的 index
getStopIndexForStartIndex: (
{ direction, height, itemCount, itemSize, layout, width }: Props<any>,
startIndex: number,
scrollOffset: number
): number => {
const isHorizontal = direction === 'horizontal' || layout === 'horizontal';
const offset = startIndex * ((itemSize: any): number);
const size = (((isHorizontal ? width : height): any): number);
const numVisibleItems = Math.ceil(
(size + scrollOffset - offset) / ((itemSize: any): number)
);
return Math.max(
0,
Math.min(
itemCount - 1,
startIndex + numVisibleItems - 1
)
);
},
// 預設空
initInstanceProps(props: Props<any>): any {
// Noop
},
// 是否在滾動完畢後重置快取
shouldResetStyleCacheOnItemSizeChange: true,
// 驗證引數, 只在 dev 情況下有用估忽略
validateProps: ({itemSize}: Props<any>): void => {
},
});
通過前面 List demo 級別的呼叫, 我們就很容易來建立一個簡單的虛擬列表
擴充套件點
FixedSizeList 只是一種簡單的虛擬列表情況 在 react-window 中還會適配以下情況
- VariableSizeList 可適配不同 item 的高度(寬度)的情況, 但是需要傳遞一個引數來給予資訊
- FixedSizeGrid 支援雙向的滾動, 荷香縱向都是虛擬列表, 這種情況在 table 裡可能會多一點
- VariableSizeGrid 不同高度(寬度)的雙向滾動虛擬列表
原理都是大同小異, 這裡就不過多說明
筆記倉庫
https://github.com/Grewer/rea...