ListView 之 antd mobile 源码解析

1,167 阅读6分钟

列表视图:ListView

渲染列表最为常见的需求,视图渲染一般都是自上而下的渲染。所以列表形式的在移动端中设计是非常常见的,下面是一些常用的功能:

  • 分页渲染列表数据
  • 上拉加载,下拉刷新以及指示器
  • 找到列表指定数据
  • 索引列表:IndexList

下面已 antd-mobile 为例子进行讲解:

ListView 版本

在 next.antd-mobile beta 中没有 ListView 组件, 分析的 antd-mobile 的 2.x 版本(antd-mobile 已经很久没有更新了)

底层组件:rmc-list-view

antd mobile ListView 的底层组件时 rmc-list-view

滚动区域:ScrollView

ScrollView 是 ListView 的开始部分:一个可滚动的区域(可自定义),我们知道 ScrollView 高度需要配置的(没有高度的滚动区域ScrollView 没有意义)

ScrollView 设计思路

  • better-scroll 实现的 React-BScroll 组件

ScrollView 一般是双层容器设计:外层容器:参照系容器(一般用于定位等容器),里层容器用于承载内容, ListView 采用文章章节式的设计模型 + index 侧边索引定位模型:

文章 Section 设计模型

- header
- footer
- body
    - section
        - list
    - section
        - list

Index 侧边索引定位模型

- A-1       
- A-2       
- A-3       A
- A-4       B
- A-5       C
- B-1       ...
- B-2      

stricky 布局

有时候我们需要 stricky 布局用于 header 或者章节 section-header

小目标:使用纯 css 实现一个简单的滚动区域(横向-竖向)

css 实现可滚动的核心属性:overflow

  • overflow-x: scroll;
  • overflow-y: scroll;
  • overflow: scroll;
<style>
.scroll-container {
    width: 200px;
    height: 200px;
    overflow-y: scroll;
    background-color: aqua;
}
.list-item {
    height: 150px;
    margin-bottom: 10px;
    background-color: bisque;
}
</style>
<div class="scroll-container">
  <div class="list-item"></div>
  <div class="list-item"></div>
</div>

一个简单的 box 200*200 盒子容器, 内部有两个列表子 .list-item 元素 150 * 150, 容器盒子明显是装不下两个字子元素,使用 overflow-y: scroll, 使其能够在 y 方向滚动。这样小目标就是实现了。

思考:那么我们咋区域内部的滚动事件如何处理?

有了小目标的实现我们来看看 Antd mobile 中 ListView 的 ScrollView 的实现;

<div {...containerProps}>
    <div {...contentContainerProps}>
      {children}
    </div>
</div>

非常简单,就是两层 div 包裹了 children(children 一般就是列表内部要渲染的内容,header, footer, list 等等)。也就是两侧容器了:

const containerProps = {
  ref: el => this.ScrollViewRef = el || this.ScrollViewRef,
  style: { ...(useBodyScroll ? {} : styleBase), ...style },
  className: classNames(className, `${preCls}-scrollview`),
};
const contentContainerProps = {
  ref: el => this.InnerScrollViewRef = el,
  style: { position: 'absolute', minWidth: '100%', ...contentContainerStyle },
  className: classNames(`${preCls}-scrollview-content`, listPrefixCls),
};

看到与自己实现的 scrollview 要复杂一点的,不仅仅是添加了 class, 还添加了 style(内联样式), ref(引用此滚动元素),也就是说滚动是分为内部(InnerScrollViewRef)和外部(ScrollViewRef)

自定义 renderScrollComponent

从上面的小实现, ScrollView 其实就是一个小组件

const renderScrollComponent = (props) => renderable
  • 根据需求进行修改

滚动相关

  • onScroll 滚动事件触发
  • scrollEventThrottle 滚动出发频率
  • scrollRenderAheadDistance 滚动与渲染行
const onScroll = e => {}
const scrollEventThrottle = 50

<ListView scrollEventThrottle={scrollEventThrottle} onScroll={onScroll} />

滚动之后元素移动之后相关尺寸

在 ScrollView 中,使用 getMetrics 来获取当前滚动的尺寸

const getMetrics = () => {
    const isVetical = !this.props.horizaontal;
    if(this.props.useBodyScroll) {
        const scrollNode = document.scrollingElement && document.body; // 检测滚动元素
        return {
            visibleLength: window[isVertical] ? 'innerHeight' : 'innerWidth',
            contentLength: this.ScrollViewRef ? this.ScrollViewRef[isVetical ? 'scrollTop': 'scrollLeft'],
            offset: scrollNode[isVertial ? 'scrollTop' : 'scrollLeft']
        }
    }
    
     return {
            visibleLength: this.ScrollViewRef ? 'innerHeight' : 'innerWidth',
            contentLength: this.ScrollViewRef ? this.ScrollViewRef[isVetical ? 'scrollTop': 'scrollLeft'],
            offset: scrollNode[isVertial ? 'scrollTop' : 'scrollLeft']
        }
}


ListView 布局:头尾和body

关于 body 容器

  • am-list-body 元素
    • ::before
    • list-view-section-body
    • ::after
  • 使用 renderBodyComponent 属性需 am-list-body 属性(可以直接修改 class)
const renderBodyComponent = {() => {
    return <div className="sdfd" data-c="div"></div>
}}

<ListView renderBodyComponent={renderBodyComponent}>
  • 使用 renderSectionBodyWrapper 修改 div.list-view-section-body 的属性(但是不能修改 list-view-section-body 属性)
const renderSectionBodyWrapper = {() => {
     // 这里的 className 不生效的
    return <div className="sdf" data-c="div"></div>; 
}}

<ListView renderSectionBodyWrapper={renderSectionBodyWrapper}>

ListView 头和脚

  • renderHeader
const renderHeader = () => {
    return <div>header</div>
}

// 渲染结果
<div class="am-list-header">
     <div>header</div>
</div>

如果 ListView 需要 Header 的时候,可以使用 renderHeader 函数进行渲染。

  • renderFooter
const renderHeader = () => {
    return <div>footer</div>
}

<div class="am-list-footer">
    <div>footer</div>
</div>

footer 一般用具加载数据的各种提示:

  • 加载中
  • 加载完毕
  • 加载错误等状态
  • 数据为空 fallback

注意⚠️: 因为 header / footer 在每次渲染的时候都会 rerender, 当其消耗特别性能时, 使用静态的渲染保持良好的性能很重要。

核型 body

ListView.DataSource 构造函数

为什么需要 DataSource 构造函数 ?

ListView 需要用 DataSource 构造函数初始化数据。

const $dataSource = new ListView.DataSource({
    // 构造函数中会检测是有 rowHasChanged 函数,没有提供就回报错
    rowHasChanged: (r1, r2) => r1 !== r2, 
})
  • 没有提示 rowHasChanged 提示判断如下:
invariant(
  params && typeof params.rowHasChanged === 'function',
  'Must provide a rowHasChanged function.',
)

初始化之后,更新数据的方法(cloneWithRows):

const newSourceData = $data.cloneWithRows([array])

数据结构之 dataBlob

dataBlob 就是渲染列表的实际数据数组。在没有传递获取 dataBlob 获取方法 props.getRowData 方法时,使用默认方法 defaultGetRowData:

function defaultGetRowData(dataBlob, sectionID, rowID) {
  return dataBlob[sectionID][rowID]
}

dataBlob 与 sectionID、rowID 成了我们的拦路虎,但是 cloneWithRows 给了我们一些思考

从上面的 cloneWithRows 源码是这样的

 function cloneWithRows(dataBlob, rowIdentities) {
    var rowIds = rowIdentities ? [rowIdentities] : null
    if (!this._sectionHeaderHasChanged) {
      this._sectionHeaderHasChanged = () => false
    }
    return this.cloneWithRowsAndSections({ s1: dataBlob }, ['s1'], rowIds)
  }
fucntion cloneWithRowsAndSections(dataBlob, sectionIdentities, rowIdentities) {
    // ...
}
  • 数据变化是这样的 arr -> dataBlob -> {s1: dataBlob}
  • rowIdentities 标识符 -> rowIDs 之间相互转换。

通过 cloneWithRows (无 rowIdentities)更新的 sourceData 的特点是

  • sectionIdentities 使用默认值: [s1]
  • rowIdentities 使用默认值: null
newSource._dataBlob = dataBlob; // { s1: dataBlob }

所以我们要获取到 rowData 的时候,需要 sectionID(章节 ID), 和 rowID(行 ID)

渲染 dataSource 中数据

ListView 接受 renderRow 函数渲染内容,函数第一个参数是 rowData。所有 renderRow 渲染的算是一个数据的出口。渲染内容关注点应该放在这里。

const renderRow = (rowData as arrItem) => {
    return (
        <div>{arrItem.title}-{arrItem.name}-{arrItem.age}</div>
    )
}

以上两点是最为需要关注的点:

  • 数据源,数据源的更新
  • 渲染更新后的数据

加载更多数据

ListView 滚动到底部之后,需要加载更多的数据。所以提供了两个 Props 来完成此功能

  • onEndReachedThreshold 滚动地步距离(触发滚动到底部)
  • onEndReached 滚动到底部出发事件

滚动到底部加载更多数据是常见的需求

const onEndReached = () => {
    fetchData();
};

<ListView onEndReached={onEndReached} onEndReachedThreshold={10} />

dataSource 与 section

section 要理解为章节的作用,其实就相当于数据的归类。类似于 index,section 下面的小结。有了这种思想我们开始理解为什么一个 dataSource 开始用起来并不是那么简单。

使用 section 章节

在我们的列表中有很多不同的章节,使用 cloneWithRowsAndSections 来渲染不同的章节已经章节内别的 row

dataSource.cloneWithRowsAndSections(
  { s1: ['1-1', '1-2', '1-3'], s2: ['1-1', '1-2', '1-3'] },
)

<ListView
    style={{ minHeight: '100vh' }}
    dataSource={dataSource.cloneWithRowsAndSections(
      { s1: ['1-1', '1-2', '1-3'], s2: ['1-1', '1-2', '1-3'] },
      // ['s1', 's2'],
      // [0, 1],
    )}
    renderSectionHeader={(sectionData, sectionID) => {
      console.log('======>>>>', sectionData, sectionID);
      return (
        <div>
          {sectionData}-{sectionID}
        </div>
      );
    }}
    renderRow={(rawData, sId, rID) => {
      console.log('==>', rawData, sId, rID);
      return (
        <div>
          {rawData}--{sId}---{rID}
        </div>
      );
    }}
></ListView>

得到的结果如下:

image.png

dom 结构也发生了变化

image.png

使用 am-list-item am-list-item-middle 来渲染 actionheader 。

章节功能是非常常用的功能,cloneWithRowsAndSections 配合 dataBlob 对象来渲染不同的章节,让我们能够只专注于业务逻辑和数据,不在需要关心渲染列表的如何加载。

添加载入动画

import QueueAnim from 'rc-queue-anim'

// 在上面的 section 的基础上,使用动画包裹冤死
renderSectionBodyWrapper={() => {
  return <QueueAnim delay={300}></QueueAnim>;
}}

封装请求功能

在原有的数据基础上,封装了适合自己的请求:

  • 请求参数通过 props 传入,适用不同的类容
  • 上拉刷新
  • 下拉加载
  • 数据请求状态, 保存请求的参数
    • data.loading
    • data.isError
    • data.loaded
    • data.page (这个 page + 1 时候,一定不能直接拿出来用,深拷贝一份,使用使用新拷贝的数据,发送分页请求,当数据请求成功之后,才真正的 data.page + 1)
    • data.page_size
    • blobData

从类型开始,扩展 ListViewProps 的类型

import { ListViewProps } from 'ListView';

// 我们需要在 ListViewProps 的基础上,扩展上面的功能
type ProListView = {
    ListViewProps: any;
    setBlobData: (data) => any;
    request: {
        url: string;
        params: {[index: string]: any}
    }
} | ListViewProps
import React from 'react';
import { ListView } from 'antd-mobile';

// services
import services from '@utils/request'

// ProListView
const ProListView: FC<> = (props) => {

    const [data, setData]= useState({
        page: 1,
        pageSize: 10,
        loaded: false,
        loading: false,
        erroring: false
    })
   const fetch = async () => {
       const res =  await services.getList({url: props.request.url, options: {
           params: {props.params, ...data}
       }});
   }
   
   useEffect(() => {
       fetch()
   }, []);
   
   return <ListView {...props} />
}

export default ProListView;

React 核心知识点使用

  • React.cloneElement 添加新的属性
import React from 'react;

const ProLiseView = (props) => {
    const { renderSectionHeader } = props;

    return 
      <ListView
        renderSectionHeader={(sectionData, sectionID) => React.cloneElement(
          renderSectionHeader(sectionData, sectionID),
          {
            ref: c => this.sectionComponents[sectionID] = c,
            className: sectionHeaderClassName || `${prefixCls}-section-header`,
          })}
        />
}

思考

  1. 我们需要这样的 ListView 吗?
  2. 有更加简单的思考吗?