[高級(jí)]列表優(yōu)化之虛擬列表

導(dǎo)讀

本文適用于以下三種讀者

  • 只想要了解一下虛擬列表
    可閱讀“實(shí)現(xiàn)一個(gè)簡(jiǎn)單的虛擬列表”之前的部分
  • 想初步探究虛擬列表的具體實(shí)現(xiàn)
    可重點(diǎn)閱讀“實(shí)現(xiàn)一個(gè)簡(jiǎn)單的虛擬列表”中的方案一
  • 想要深入研究和探討如何在虛擬列表中解決列表項(xiàng)高度不固定的問(wèn)題
    可重點(diǎn)閱讀“實(shí)現(xiàn)一個(gè)簡(jiǎn)單的虛擬列表”中的方案二與方案三

前言

??工作中,我們經(jīng)常會(huì)遇到列表項(xiàng)。如果列表項(xiàng)的數(shù)量比較多,很多情況下我們會(huì)采用分頁(yè)加載的方式,來(lái)避免一次性加載大量的數(shù)據(jù),造成頁(yè)面的性能問(wèn)題。
??但是用戶(hù)在分頁(yè)加載瀏覽了大量數(shù)據(jù)之后,列表項(xiàng)也會(huì)逐漸增多,此時(shí)頁(yè)面可能會(huì)存在卡頓的情況。亦或者是我們需要一次性加載大量的數(shù)據(jù),將所有的數(shù)據(jù)一次性呈現(xiàn)到用戶(hù)面前,而不是采用分頁(yè)加載的方式,此時(shí)列表項(xiàng)的數(shù)量可能會(huì)非常龐大,造成頁(yè)面的卡頓。
??這次我們就來(lái)介紹一種虛擬列表的優(yōu)化方法來(lái)解決數(shù)據(jù)量大的時(shí)候列表的性能問(wèn)題。

什么是虛擬列表

??虛擬列表是按需顯示的一種技術(shù),可以根據(jù)用戶(hù)的滾動(dòng),不必渲染所有列表項(xiàng),而只是渲染可視區(qū)域內(nèi)的一部分列表元素的技術(shù)。


虛擬列表原理

??如圖所示,當(dāng)列表中有成千上萬(wàn)個(gè)列表項(xiàng)的時(shí)候,我們?nèi)绻捎锰摂M列表來(lái)優(yōu)化。就需要只渲染可視區(qū)域(?viewport?)內(nèi)的?item8?到?item15?這8個(gè)列表項(xiàng)。由于列表中一直都只是渲染8個(gè)列表元素,這也就保證了列表的性能。

虛擬列表組件

antDesign的List組件對(duì)于長(zhǎng)列表的建議

??長(zhǎng)列表的優(yōu)化是一個(gè)一直以來(lái)都很棘手的非常復(fù)雜的問(wèn)題,上圖是?Antd Design?的List組件所建議的,推薦與?react-virtualized?組件結(jié)合使用來(lái)對(duì)長(zhǎng)列表進(jìn)行優(yōu)化。
??我們最好是使用一些現(xiàn)成的虛擬列表組件來(lái)對(duì)長(zhǎng)列表進(jìn)行優(yōu)化,比較常見(jiàn)的有?react-virtualized?和?react-tiny-virtual-list?這兩個(gè)組件,使用他們可以有效地對(duì)你的長(zhǎng)列表進(jìn)行優(yōu)化。

react-tiny-virtual-list

??react-tiny-virtual-list?是一個(gè)較為輕量的實(shí)現(xiàn)虛擬列表的組件,使用方便,其源碼也只有700多行。下面是其官網(wǎng)給出的一個(gè)示例。

import React from 'react';
import {render} from 'react-dom';
import VirtualList from 'react-tiny-virtual-list';
 
const data = ['A', 'B', 'C', 'D', 'E', 'F', ...];
 
render(
  <VirtualList
    width='100%'
    height={600}
    itemCount={data.length}
    itemSize={50} // Also supports variable heights (array or function getter)
    renderItem={({index, style}) =>
      <div key={index} style={style}> // The style property contains the item's absolute position
        Letter: {data[index]}, Row: #{index}
      </div>
    }
  />,
  document.getElementById('root')
);

react-virtualized

??在react生態(tài)中, react-virtualized作為長(zhǎng)列表優(yōu)化的存在已久, 社區(qū)一直在更新維護(hù), 討論不斷, 同時(shí)也意味著這是一個(gè)長(zhǎng)期存在的棘手問(wèn)題。相對(duì)于輕量級(jí)的?react-tiny-virtual-list?來(lái)說(shuō),?react-virtualized?則顯得更為全面。
??react-virtualized?提供了一些基礎(chǔ)組件用于實(shí)現(xiàn)虛擬列表,虛擬網(wǎng)格,虛擬表格等等,它們都可以減小不必要的?dom?渲染。此外還提供了幾個(gè)高階組件,可以實(shí)現(xiàn)動(dòng)態(tài)子元素高度,以及自動(dòng)填充可視區(qū)等等。

react-virtualized示例

在使用?Ant Design?的List組件的時(shí)候,官方也是推薦結(jié)合使用?react-virtualized?來(lái)對(duì)大數(shù)據(jù)列表進(jìn)行優(yōu)化。

實(shí)現(xiàn)一個(gè)簡(jiǎn)單的虛擬列表

我們已經(jīng)清楚了虛擬列表的原理:只渲染可視區(qū)域內(nèi)的一部分列表元素。那我們就使用虛擬列表的思想來(lái)實(shí)現(xiàn)一個(gè)簡(jiǎn)單的列表組件。此處,我們給出兩種方案,均融合了分頁(yè)下拉加載的方式。

方案一

第一種方案的dom結(jié)構(gòu)如圖

  • 外層容器:設(shè)置height,overflow:scroll

  • 滑動(dòng)列表:絕對(duì)定位,然后用列表元素高度*列表元素?cái)?shù)量計(jì)算出滑動(dòng)列表高度

  • 可視區(qū)域:動(dòng)態(tài)計(jì)算可視區(qū)域在滑動(dòng)列表中的偏移量,使用?translate3d?屬性動(dòng)態(tài)設(shè)置可視區(qū)域的偏移量,造成滑動(dòng)的效果。


    方案一原理圖
方案一DOM
方案一1.gif

??這樣做了以后,每次都只渲染了可視區(qū)域的幾個(gè)?dom?元素,確實(shí)做到了對(duì)于大數(shù)據(jù)情況下的長(zhǎng)列表的優(yōu)化
??但是,這里只是實(shí)現(xiàn)了列表元素固定高度的情況,對(duì)于高度不固定的列表,如何實(shí)現(xiàn)優(yōu)化呢

import React from 'react';
 
 
// 應(yīng)該接收的props: renderItem: Function<Promise>, getData:Function;  height:string; itemHeight: string
 
// 下滑刷新組件
class InfiniteTwo extends React.Component {
  constructor(props) {
    super(props);
    this.renderItem = props.renderItem
    this.getData = props.getData
    this.state = {
      loading: false,
      page: 1,
      showMsg: false,
      List: [],
      itemHeight: this.props.itemHeight || 0,
      start: 0,
      end: 0,
      visibleCount: 0
    }
  }
 
  onScroll() {
    let { offsetHeight, scrollHeight, scrollTop } = this.refs.scrollWrapper;
    let showOffset = scrollTop - (scrollTop % this.state.itemHeight)
    const target = this.refs.scrollContent
    target.style.WebkitTransform = `translate3d(0, ${showOffset}px, 0)`
    this.setState({
      start: Math.floor(scrollTop / this.state.itemHeight),
      end: Math.floor(scrollTop / this.state.itemHeight + this.state.visibleCount + 1)
    })
    if(offsetHeight + scrollTop + 15 > scrollHeight){
      if(!this.state.showMsg){
        let page = this.state.page;
        page++;
        this.setState({
          loading: true
        })
        this.getData(page).then(data => {
          this.setState({
            loading: false,
            page: page,
            List: data.concat(this.state.List),
            showMsg: data && data.length > 0 ? false : true
          })
        })
      }
    }
  }
 
  componentDidMount() {
    this.getData(this.state.page).then(data => {
      this.setState({
        List: data
      })
      // 初始化列表以后,也需要初始化一些參數(shù)
      requestAnimationFrame(() => {
        let {offsetHeight} = this.refs.scrollWrapper;
        let visibleCount = Math.ceil(offsetHeight / this.state.itemHeight)
        let end = visibleCount + 1
        console.log(this.refs.scrollContent.firstChild.clientHeight)
        this.setState({
          end,
          visibleCount
        })
      })
    })
  }
 
  render() {
    const {List, start, end, itemHeight} = this.state
    const renderList = List.map((item,index)=>{
      if(index >=start && index <= end)
      return(
        this.renderItem(item, index)
      )
    })
    console.log(renderList)
    return(
      <div>
        <div
          ref="scrollWrapper"
          onScroll={this.onScroll.bind(this)}
          style={{height: this.props.height, overflow: 'scroll', position: 'relative'}}
        >
          <div style={{height: `${renderList.length * itemHeight}px`, position: 'absolute', top: 0, right: 0, left: 0}}>
 
          </div>
          <div ref="scrollContent" style={{position: 'relative', top: 0, right: 0, left: 0}}>
            {renderList}
          </div>
        </div>
        {this.state.loading && (
          <div>加載中</div>
        )}
        {this.state.showMsg && (
          <div>暫無(wú)更多內(nèi)容</div>
        )}
      </div>
    )
 
  }
}
 
 
export default InfiniteTwo;

方案一中,我們?cè)O(shè)置了幾個(gè)變量

  • start?渲染的第一個(gè)元素的索引
  • end?渲染的最后一個(gè)元素的索引
  • visibleCount?可見(jiàn)的元素個(gè)數(shù) start + visibleCount = end
  • List 所有列表項(xiàng)的數(shù)據(jù)
  • showOffset?可視元素列表的偏移量 滾動(dòng)的時(shí)候采用?scrollTop - (scrollTop % this.state.itemHeight)?計(jì)算


    showOffset的計(jì)算

方案二

第二種方案的?dom?結(jié)構(gòu)如圖

  • 外層容器:設(shè)置height,overflow:scroll

  • 頂部:可視區(qū)域之前的元素高度

  • 尾部:可視區(qū)域之后的元素高度

  • 可視區(qū)域:可視區(qū)域內(nèi)的列表元素


    方案二原理圖
方案二DOM
方案二.gif

??在高度不固定的情況下,我們需要?jiǎng)討B(tài)地獲取元素的高度。能想到的比較好的方案是在每次下拉加載,dom?渲染之后,記錄下它的高度以及位置信息
??由于每個(gè)列表元素的高度不一樣,所以在計(jì)算偏移量的時(shí)候,就會(huì)顯得比較復(fù)雜。既然在每次下拉加載的時(shí)候,記錄每個(gè)元素的高度以及位置,那么為什么不以頁(yè)為單位,進(jìn)行高度和位置信息的記錄呢

import React from 'react';
 
// 應(yīng)該接收的props: renderItem: Function<Promise>, getData:Function;  height:string;
 
// 下滑刷新組件
class InfiniteOne extends React.Component {
  constructor(props) {
    super(props);
    this.renderItem = props.renderItem
    this.getData = props.getData
    this.state = {
      loading: false,
      page: 0,
      showMsg: false,
      List: []
    }
    this.pageHeight = []
  }
  onScroll() {
    let { offsetHeight, scrollHeight, scrollTop } = this.refs.scrollWrapper;
    // 判斷一下需要展示的列表,其他的列表都給隱藏了
    let ListShow = [...this.state.List]
    ListShow.forEach((item, index) => {
      if(this.pageHeight[index]){
        let bottom = this.pageHeight[index].top + this.pageHeight[index].height
        if((bottom < scrollTop - 50) || (this.pageHeight[index].top > scrollTop + offsetHeight + 50)){
          ListShow[index].visible = false
        }else{
          ListShow[index].visible = true
        }
      }
    })
    this.setState({
      List: ListShow
    })
    if(offsetHeight + scrollTop + 5 > scrollHeight){
      if(!this.state.showMsg){
        let page = this.state.page;
        page++;
        this.setState({
          loading: true
        })
        this.getData(page).then(data => {
          this.setState(prevState => {
            let List = [...prevState.List]
            List[page] =  {data, visible: true}
            return  {
              loading: false,
              page: page,
              List: List,
              showMsg: data && data.length > 0 ? false : true
            }
          })
          // setState之后,更新了dom,這時(shí)候需要知道每個(gè)page的top和height
          requestAnimationFrame(() => {
            const target = this.refs[`page${page}`]
            let top = 0;
            if(page > 0){
              top = this.pageHeight[page - 1].top + this.pageHeight[page - 1].height
            }
            this.pageHeight[page] = {top, height: target.offsetHeight}
          })
        })
      }
    }
  }
  componentDidMount() {
    this.getData(this.state.page).then(data => {
      this.setState((prevState) => {
        let List = [...prevState.List]
        List[this.state.page] = {data, visible: true}
        return {List}
      })
      requestAnimationFrame(() => {
        this.pageHeight[0] = {top: 0, height: this.refs['page0'].offsetHeight}
      })
    })
  }
 
  render() {
    const {List} = this.state
    let headerHeight = 0;
    let bottomHeight = 0;
    let i = 0;
    for(; i < List.length; i++){
      if(!List[i].visible){
        headerHeight += this.pageHeight[i].height
      }else{
        break;
      }
    }
    for(; i < List.length; i++){
      if(!List[i].visible){
        bottomHeight += this.pageHeight[i].height
      }
    }
    const renderList = List.map((item,index)=>{
      if(item.visible){
        return <div ref={`page${index}`} key={`page${index}`}>
          {item.data.map((value, log) => {
            return(
              this.renderItem(value, `${index}-${log}`)
            )
          })}
        </div>
      }
    })
    console.log(renderList)
    return(
      <div
        ref="scrollWrapper"
        onScroll={this.onScroll.bind(this)}
        style={{height: this.props.height, overflow: 'scroll'}}
      >
        <div style={{height: headerHeight}}></div>
        {renderList}
        <div style={{height: bottomHeight}}></div>
        {this.state.loading && (
          <div>加載中</div>
        )}
        {this.state.showMsg && (
          <div>暫無(wú)更多內(nèi)容</div>
        )}
      </div>
    )
 
  }
}
 
 
export default InfiniteOne;

方案二中,我們?cè)O(shè)置了幾個(gè)變量

  • List:所有列表項(xiàng)的數(shù)據(jù)。List?是一個(gè)數(shù)組,每一項(xiàng)的?data?屬性存儲(chǔ)的是一頁(yè)的數(shù)據(jù),visible?屬性用來(lái)在?render?的時(shí)候判斷是否渲染該頁(yè)數(shù)據(jù),滾動(dòng)地時(shí)候會(huì)動(dòng)態(tài)地更新?List?中每一項(xiàng)的?visible?屬性,從而控制需要渲染的元素。
  • pageHeight:所有項(xiàng)的位置信息。pageHeight?也是一個(gè)數(shù)組。每一項(xiàng)的?top?屬性表示該頁(yè)的頂部滾動(dòng)的距離,height?表示該頁(yè)的高度。pageHeight?用來(lái)在滾動(dòng)的時(shí)候根據(jù)?scrollTop?來(lái)更新?List?數(shù)組中每一項(xiàng)的visible屬性。

方案對(duì)比

??方案二實(shí)現(xiàn)的組件相比方案一來(lái)說(shuō)可以支持列表元素的高度不一致的情況。那方案二是不是就基本可以滿足需求了呢?
??顯然并不是。我們?cè)谇把院蜕衔闹姓f(shuō)過(guò),虛擬列表是用于長(zhǎng)列表優(yōu)化的(一次性加載成千上萬(wàn)條數(shù)據(jù))。方案二中的列表高度和位置是在每一次下拉加載完成以后,計(jì)算得來(lái)的;并且這個(gè)列表高度和位置還決定了?headerHeight?和?bottomHeight?(即列表里前后兩塊無(wú)渲染區(qū)域的高度)。所以方案二的思路不能直接用在長(zhǎng)列表里。
我們想先研究研究?react-tiny-virtual-list?和?react-virtualized,以期望獲得一些改進(jìn)上的思路。

組件分析

??我首先借助于?react-tiny-virtual-list?這篇文章閱讀了?react-tiny-virtual-list?的源碼,react-tiny-virtual-list?雖然可以無(wú)限下拉滾動(dòng),但是對(duì)于列表元素的動(dòng)態(tài)高度,并不支持。需要明確指定每個(gè)元素的高度。
??我們?cè)賮?lái)看一下?react-virtualized?這個(gè)組件,他雖然比?react-tiny-virtual-list?功能更完善,但是也依然需要明確指定每個(gè)元素的高度。
??通過(guò)?react-virtualized 組件的虛擬列表優(yōu)化分析?這篇文章,我們知道,可能有其他方法,可以支持解決這個(gè)元素高度不固定的情況下無(wú)限滾動(dòng)的問(wèn)題。
??react-virtualized?也意識(shí)到了這個(gè)問(wèn)題,所以提供了一個(gè)?CellMeasurer?組件,這個(gè)組件能夠動(dòng)態(tài)地計(jì)算子元素的大小。那在計(jì)算的時(shí)候,元素不是就已經(jīng)被加載出來(lái)了嗎,那計(jì)算還有什么用。這里使用的方法是:在?cell?元素被渲染之前,用的是預(yù)估的列寬值或者行高值計(jì)算的,此時(shí)的值未必就是精確的,而當(dāng)?cell?元素渲染之后,就能獲取到其真實(shí)的大小,因而緩存其真實(shí)的大小之后,在組件的下次 ?re-render?的時(shí)候就能對(duì)原先預(yù)估值的計(jì)算進(jìn)行糾正,得到更精確的值。
??我們也可以借鑒一下這種思路來(lái)對(duì)方案二進(jìn)行一些改造使其能夠應(yīng)對(duì)長(zhǎng)列表的情況。為了方便,我們單獨(dú)寫(xiě)出一個(gè)組件來(lái)應(yīng)對(duì)長(zhǎng)列表的情況;對(duì)于下拉加載,仍然采用方案二。

方案三原理圖

  • 外層容器:設(shè)置height,overflow:scroll

  • 頂部:可視區(qū)域之前的元素高度

  • 尾部:先采用預(yù)估高度計(jì)算,在向下滾動(dòng)的過(guò)程中再獲取實(shí)際高度進(jìn)行調(diào)整

  • 可視區(qū)域:可視區(qū)域內(nèi)的列表元素

方案三

??這樣的話,我們就需要對(duì)方案二進(jìn)行一些優(yōu)化。首先我們組件接收的屬性里需要一個(gè)預(yù)估的列表高度。然后需要接收一個(gè)數(shù)據(jù)列表,resource。接著,我們按照方案二的思路,對(duì)數(shù)據(jù)分好頁(yè)。我們先用預(yù)估高度來(lái)計(jì)算headerHeight和bottomHeight,從而撐開(kāi)滾動(dòng)容器。當(dāng)滑動(dòng)到需要加載的頁(yè)時(shí),動(dòng)態(tài)地更新所存儲(chǔ)的頁(yè)碼的高度。


方案三-1萬(wàn)條.gif
方案三-1千條.gif
方案三-1百條.gif
import React from 'react';

// 應(yīng)該接收的props: renderItem: Function<Promise>, height:string; estimateHeight:Number, resource: Array

// 下滑刷新組件
class InfiniteThree extends React.Component {
  constructor(props) {
    super(props);
    this.renderItem = props.renderItem
    this.getData = props.getData
    this.estimateHeight = Number(props.estimateHeight) * 10 //一頁(yè)10條數(shù)據(jù),進(jìn)行一頁(yè)數(shù)據(jù)的預(yù)估
    this.resource = props.resource
    this.listLength = props.resource.length
    let pageList = []
    // 對(duì)接收到的大數(shù)據(jù)進(jìn)行分頁(yè)整理,保存在List里面
    let array = []
    for(let i = 0; i < props.resource.length; i++){
      if(i % 10 === 0 && i || i === (props.resource.length - 1)){
        pageList.push({
          data: array,
          visible: false
        })
        array = []
      }
      array.push(props.resource[i])
    }
    pageList[0].visible = true
    // 然后對(duì)pageHeight根據(jù)預(yù)估高度進(jìn)行預(yù)估初始化,后續(xù)重新進(jìn)行計(jì)算
    this.pageHeight = []
    for(let i = 0; i < this.listLength; i++){
      if(i === 0){
        this.pageHeight.push({
          top: 0,
          height: this.estimateHeight,
          isComputed: false,
        })
      }else{
        this.pageHeight.push({
          top: this.pageHeight[i-1].top + this.pageHeight[i-1].height,
          height: this.estimateHeight,
          isComputed: false
        })
      }
      this.state = {
        loading: false,
        page: 0,
        showMsg: false,
        List: pageList,
      }
    }
  }
  onScroll() {
    requestAnimationFrame(() => {
      let { offsetHeight, scrollHeight, scrollTop } = this.refs.scrollWrapper;
      // 判斷一下需要展示的列表,其他的列表都給隱藏了
      let ListShow = [...this.state.List]
      ListShow.forEach((item, index) => {
        if(this.pageHeight[index]){
          let bottom = this.pageHeight[index].top + this.pageHeight[index].height
          if((bottom < scrollTop - 5) || (this.pageHeight[index].top > scrollTop + offsetHeight + 5)){
            ListShow[index].visible = false
          }else{
            // 根據(jù)預(yù)估高度算出來(lái)它在視野內(nèi)的時(shí)候,先給它變成visible,讓他出現(xiàn),才能拿到元素高度
            this.setState(prevState => {
              let List = [...prevState.List]
              List[index].visible = true
              return  {
                List
              }
            })
            // 出現(xiàn)以后,然后計(jì)算高度,替換掉之前用預(yù)估高度設(shè)置的height
            let target = this.refs[`page${index}`]
            let top = 0;
            if(index > 0){
              top = this.pageHeight[index - 1].top + this.pageHeight[index - 1].height
            }
            if(target && target.offsetHeight && !ListShow[index].isComputed){
              this.pageHeight[index] = {top, height: target.offsetHeight}
              console.log(target.offsetHeight)
              ListShow[index].visible = true
              ListShow[index].isComputed = true
              // 計(jì)算好了以后,還要再setState一下,調(diào)整列表高度
              this.setState({
                List: ListShow,
              })
            }else{
              this.pageHeight[index] = {top, height: this.estimateHeight}
            }
          }
        }
      })
    })
  }
  componentDidMount() {

  }

  render() {
    let {List} = this.state
    let headerHeight = 0;
    let bottomHeight = 0;
    let i = 0;
    for(; i < List.length; i++){
      if(!List[i].visible){
        headerHeight += this.pageHeight[i].height
      }else{
        break;
      }
    }
    for(; i < List.length; i++){
      if(!List[i].visible){
        bottomHeight += this.pageHeight[i].height
      }
    }
    const renderList = List.map((item,index)=>{
      if(item.visible){
        return <div ref={`page${index}`} key={`page${index}`}>
          {item.data.map((value, log) => {
            return(
              this.renderItem(value, `${index}-${log}`)
            )
          })}
        </div>
      }
    })
    return(
      <div
        ref="scrollWrapper"
        onScroll={this.onScroll.bind(this)}
        style={{height: 400, overflow: 'scroll'}}
      >
        <div style={{height: headerHeight}}></div>
        {renderList}
        <div style={{height: bottomHeight}}></div>
        {this.state.loading && (
          <div>加載中</div>
        )}
        {this.state.showMsg && (
          <div>暫無(wú)更多內(nèi)容</div>
        )}
      </div>
    )

  }
}


export default InfiniteThree;


??方案三中我們?cè)诜桨付幕A(chǔ)上給pageHeight數(shù)組的每一項(xiàng)增加了isComputed屬性,初始化時(shí)每一項(xiàng)的height是使用的estimateHeigh(預(yù)估高度)的值。只有在使用真實(shí)高度更新了這一項(xiàng)的height后,isComputed才會(huì)置為true。
??值得一提的是,這個(gè)預(yù)估高度的值,盡量要大于等于實(shí)際的高度值,從而做到能把容器撐開(kāi)。

小結(jié)

本文首先介紹了一種叫做“虛擬列表”的優(yōu)化方法,該方法能對(duì)列表進(jìn)行優(yōu)化。隨后介紹了兩種比較主流的虛擬列表組件,可以方便我們?cè)谌粘i_(kāi)發(fā)中對(duì)列表進(jìn)行優(yōu)化。然后給出了兩種虛擬列表的實(shí)現(xiàn)方法,并進(jìn)行了比較。最后在研究了react-tiny-virtual-list和react-virtualized這兩種組件的特點(diǎn)和思想之后,在方案二的基礎(chǔ)上改進(jìn),給出了一個(gè)用于長(zhǎng)列表(一次性展示大量數(shù)據(jù)的列表)的虛擬列表優(yōu)化方案。

代碼demo地址

虛擬列表實(shí)踐demo

參考文章

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容