长列表是项目中常见的组件之一,大量数据同时渲染在页面上时,会导致页面的卡顿。我们可以尝试控制只显示用户需要看到的来提高性能。 为了列表滚动条的正常展示,需要将顶部和底部用户看不到的部分分别通过等高的元素进行替换
思路
在滚动过程中,有两个互不相关的过程:滚动替换和数据加载
滚动替换
将不需要展示的顶部和底部位置通过等高元素进行替换;在滚动过程中,根据所有卡片的高度itemHeights,屏幕高度H,默认加载卡片数S,滚动距离y------>获取需要显示的起始卡片p、结束卡片q、顶部替换盒子的高度H1、底部替换盒子的高度H3
const nextReplaceScrollState = (cards, itemHeights, H, S, y ) => {
const sTop = Math.floor(S / 2)
const sBottom = Math.floor(S / 2)
// p : 开始的卡片,第一个top大于(y - H)的卡片,多渲染一屏是为了保障用户体验的连续性
let sum = 0
let p = 0
for(let i = 0; i < cards.length; i++) {
if(sum > y - H ) {
p = cards[i].id
break
}
sum += itemHeights[cards[i].id]
}
p = p - sTop
if(p < 0) {
p = 0
}
// q:结束的卡片
const q = p + S - 1
// H1: 顶部替换盒子的高度 sum (1 + ... + p-1)
const lst1 = cards.filter(card => card.id < p).map(card => itemHeights[card.id])
const H1 = lst1.length > 0 ? lst1.reduce((h1, h2) => h1 + h2) : 0
// H3: 底部替换盒子的高度 sum (q + ... + (cards.length - 1))
const lst3 = cards.filter(card => card.id > q).map(card => itemHeights[card.id])
const H3 = lst3.length > 0 ? lst3.reduce((h1, h2) => h1 + h2) : 0
return {p, q, H1, H3}
}
数据加载
- 数据加载开始时,将滚动替换过程锁定
- 新的卡片添加到列表底部进行渲染(方便计算每个卡片的高度)
- 新卡片渲染完成后解锁,继续滚动替换
开启代码之路
初始化
在渲染时,展示visibleData(p---q)
export class ListView extends Component {
static defaultProps = {
displaySize : 20, //默认同时渲染20张卡片
initialData : [],
}
}
constructor(props) {
super()
this.y = 0 // 滚动距离
this.itemHeights = [] // 所有的卡片的高度
this.id_counter = 0 // ID 计数器,为每个卡片添加id使用
this.state = {
data : [],
}
}
_renderItem({item, id}){ // 每个卡片的渲染
return <View key={id} onLayout={this._itemLayout(id).bind(this)}>
{this.props.renderItem(item, id)}
</View>
}
render(){
const {p, q, H1, H3, newlyAdded, scrollLock, data} = this.state
let visibleData = data.filter( ({item, id}) => {
if(id >= p && id <= q) return true
return false
})
return (
<ScrollView
onLayout={this._layout.bind(this)}
>
<View style={{height : H1}}></View>
{
visibleData.map(this._renderItem.bind(this))
}
<View style={{height : H3}}></View>
</ScrollView>
)
}
}
初始化列表数据data
在componentDidMount中,调用append方法将initialData填充到state.data中
componentDidMount() {
this.append(this.props.initialData)
}
append(list) {
const nList = list.map(((item, i) => { // 分配ID
return { id : ++this.id_counter, item }
}).bind(this))
this.setState({
data : [...this.state.data, ...nList],
newlyAdded : nList,
})
}
在scrollView上定义onLayout,获取整个listView的高度
_layout({nativeEvent : {layout}}){ // 获取listView的高度
this.height = layout.height
}
在renderItem上,通过onLayout,将每个卡片的高度放在数组中
_itemLayout(i) {
return ({nativeEvent : {layout}}) => {
this.itemHeights[i] = layout.height
}
}
上拉加载更多
定义scroll事件,滚动到底部时加载更多
ScrollView组件的滚动操作
- 判断是否滚动到底部:滚去的高度+滚动区域的高度>=整体高度时,表示到底部
- 如果到底部,将滚动超出底部的差值传递给父组件
- 滚动替换:非锁定状态时进行滚动替换,即不断计算需要显示的卡片和替换盒子的高度
_scroll(e) {
this.y = e.nativeEvent.contentOffset.y
const atBottom = (this.y + this.height >= e.nativeEvent.contentSize.height)
if(atBottom) {
this.props.onScrollToBottom(this.y + this.height - e.nativeEvent.contentSize.height)
}
if(!this.state.scrollLock) {
this.setState({
...nextReplaceScrollState(this.state.data, this.itemHeights, this.height, this.props.displaySize, this.y)
})
}
}
父组件定义滚动底部事件
主要在此获取更多数据,然后通过ref调用ScrollView组件的append方法加载新数据
_onScrollToBottom(y){
this.y = y
if (this.state.loading){return}
this.setState({ loading : true }, (() => {
setTimeout((() => {
const courses = []
for(let i = 0; i < 20; i++) {
courses.push(course_gen())
}
this.refs.listView.append(courses)
this.setState({
loading : false
})
}).bind(this), 2000)
}).bind(this))
}
<ListView
...
onScrollToBottom={this._onScrollToBottom.bind(this)}
...
/>
重新定义ScrollView组件的append方法
- 将新增加的数据保存在data上,并将新增加的数据单独保存
- 锁定滚动状态
- 在setInterval中进行渲染检查:当最大卡片id的高度存在,说明渲染完成;此时,解除滚动锁定,进行滚动替换
锁定的目的:为了通知父组件已经触发了loadMore,请求会的数据正在加载,避免重复触发loadMore
append(list) {
const nList = list.map(((item, i) => ({ id : ++this.id_counter, item })).bind(this))
const I = setInterval( (() => {
if(this.itemHeights[this.id_counter]) {
clearInterval(I)
this.setState({
...nextReplaceScrollState(this.state.data, this.itemHeights, this.height, this.props.displaySize, this.y),
scrollLock : false, // 解除锁定
newlyAdded : []
})
}
}).bind(this), 100)
this.setState({
data : [...this.state.data, ...nList],
newlyAdded : nList,
scrollLock : true // 将滚动替换过程锁定
})
}
render中,将新增的数据合并
render(){
...
if(newlyAdded && newlyAdded.length > 0) {
visibleData = [ ...visibleData, ...newlyAdded.filter(x => !visibleData.find(t => t.id === x.id)) ]
}
}
当滚动底部,获取更多数据后,会进行滚动锁定。在render中,通过数据合并渲染,可以获取新卡片的高度。在append方法中,如果新卡片的最后有高度,说明渲染完成,渲染完成后将清空新数据数组,并解除锁定,在scroll中可以继续执行滚动替换操作
在底部添加上拉加载更多的loading
在父组件定义底部的loading
// 父组件
_renderBottomIndicator(){
if (this.state.loading) {
return (
<View style={{height : 42, ...flexCenter}}>
<ActivityIndicator />
</View>
)
}
return null
}
<ListView
...
renderBottomIndicator={this._renderBottomIndicator.bind(this)}
}
/>
在ScrollView中使用底部loading
<ScrollView ... >
...
{this.props.renderBottomIndicator()}
</ScrollView>
下拉刷新
父组件获取刷新方法
获取新数据,然后调用ScrollView组件的reset方法重置数据
_refresh(){
if(!this.state.loading) {
this.setState({loading : true}, (() => {
setTimeout((() => {
const courses = []
for(let i = 0; i < 20; i++) {
courses.push(course_gen())
}
this.refs.listView.reset(courses)
this.setState({
loading : false
})
}).bind(this), 2000)
}).bind(this))
}
}
<ListView
...
refreshControl={
<RefreshControl refreshing={false} onRefresh={this._refresh.bind(this)} />
}
/>
ScrollView组件定义reset方法:重置列表数据
reset(list){
this.itemHeights = []
this.id_counter = 0
this.setState({
data : [],
newlyAdded : [],
scrollLock : false,
p : 0,
q : 0
}, (() => {
this.append(list)
}).bind(this))
}