阅读 2416

如何处理前端超长列表

背景:系统中有一个添加品牌的搜索框,当搜索类目不做限制的时候,全部的品牌列表会有1W多个,这时候在框架的加持下,操作速度感人。可以在codesandbox.io/s/pure-vue-…中体验一下,甚至不用打开控制台看console输出,就可以感受到载入长列表和重置之间切换时,页面停止响应的时间。


问题产生原因

DOM节点数量过多,浏览器渲染吃力

image.png

(图片引用自zhuanlan.zhihu.com/p/26022258

其实不光是初次渲染时间长,如果有大量节点出现,那么在滚动的时候,也能明显感受到不流畅的滚动现象。


可选方案

懒加载

通过懒加载的方式,在出现长列表的时候,第一次并不完全渲染所有的DOM节点,即可解决一部分场景下的问题。


优点:实现简单

缺点:

  1. 想要定位到某一个位置的数据会非常困难
  2. 如果一直加载到底,那么最终还是会出现大量的DOM节点,导致滚动不流畅


虚拟渲染

懒加载无法满足真正的长列表展示,那么如果真正要解决此类问题该怎么办?还有一种思路就是:列表局部渲染,又被称为虚拟列表.


当前比较知名的一些第三方库有vue-virtual-scroller、react-tiny-virtual-list、react-virtualized。它们都可以利用局部加载解决列表过长的问题的,vue-virtual-scroller、react-tiny-virtual-list一类的方案只支持虚拟列表,而react-virtualized这种大而全的库则是支持表格、集合、列表等多种情况下的局部加载方案。


单纯列表虚拟渲染

我们先看下vue-virtual-scroller、react-tiny-virtual-list这种纯虚拟列表的解决方案。它们的实现原理是利用视差和错觉制作一份出一份“虚拟”列表,一个虚拟列表由三部分组成:

  1. 视窗口
  2. 虚拟数据列表(数据展示)
  3. 滚动占位区块(底部滚动区)


虚拟列表侧面图示意:

图片1.png

正面图:

图片2.png

滚动一段距离后:

图片3.png


最终要实现的效果:由滚动占位区块产生滚动条,随着滚动条的移动,在可视窗口展示虚拟数据列表


react-virtualized的二维虚拟渲染

react-virtualized的实现方案和我们上面探讨的不太一样,因为表格是二维的,而列表是一维的(可以认为列表是一种特殊的表格),react-virtualized就是在二维的基础上构建的一套虚拟数据渲染工具。


示意图如下:

6.png

蓝色的部分被称为Cell,上面白色线分隔的区块叫做Section。


基本原理:在列表的上方打上一层方格(Section),下面的每个元素(Cell)都能落到某个方格上(Section)。滚动的时候,随着Cell的动态增加,Section也会被动态的创建,将每一个Cell都注册到对应的Section下。根据当前滚动到的Section,可以得到当前Section下包含的Cell,从此将Cell渲染出来。


/*
  0 1 2 3 4 5
 ┏━━━┯━━━┯━━━┓
0┃0 0┊1 3┊6 6┃
1┃0 0┊2 3┊6 6┃
 ┠┈┈┈┼┈┈┈┼┈┈┈┨
2┃4 4┊4 3┊7 8┃
3┃4 4┊4 5┊9 9┃
 ┗━━━┷━━━┷━━━┛
Sections to Cells map:
 0.0 [0]
 1.0 [1, 2, 3]
 2.0 [6]
 0.1 [4]
 1.1 [3, 4, 5]
 2.1 [7, 8, 9]
*/复制代码


实现方案


由于我们的目的是处理前端超长列表,而react-virtualized的实现方案是基于二维表格的,其List组件也是继承自Grid组件,如果要做列表方案,必须先实现二维的Grid方案。只处理长列表的情况下,实现一个单纯的虚拟列表渲染方案比二维的Grid方案要更合适一些。

基本结构

首先我们按照虚拟列表示意图来规划出若干个元素。.virtual-scroller乃整个滚动列表组件,在最外层监测其滚动事件。在内部我们需放置一个.phantom来撑开容器,使滚动条出现,并且该元素的高度 = 数据总数 * 列表项高度。接着我们在.phantom的上一层,再画出一个ul列表,它被用来动态加载数据,而它的位置和数据将由计算得出。

codesandbox.io/s/list--scr…

<template>
  <div id="app">
    <input type="text" v-model.number="dataLength">条
    <div class="virtual-scroller" @scroll="onScroll" :style="{height: 600 + 'px'}">
      <div class="phantom" :style="{height: this.dataLength * itemHeight + 'px'}">
        <ul :style="{'margin-top': `${scrollTop}px`}">
          <li v-for="item in visibleList" :key="item.brandId" :style="{height: `${itemHeight}px`, 'line-height': `${itemHeight}px`}">
            <div>
              <div>{{item.name}}</div>
            </div>
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      itemHeight: 60,
      visibleCount: 10,
      dataLength: 100,
      startIndex: 0,
      endIndex: 10,
      scrollTop: 0
    };
  },
  computed: {
    dataList() {
      const newDataList = [...Array(this.dataLength || 0).keys()].map((v, i) => ({
        brandId: i + 1,
        name: `第${i + 1}项`,
        height: this.itemHeight
      }));
      return newDataList;
    },
    visibleList() {
      return this.dataList.slice(this.startIndex, this.endIndex);
    }
  },
  watch: {
    dataList() {
      console.time('rerender');
      setTimeout(() => {
        console.timeEnd('rerender');
      }, 0)
    }
  },
  methods: {
    onScroll(e) {
      
    }
  }
};
</script>

<style lang="stylus" scoped>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

.virtual-scroller {
  border: solid 1px #eee;
  margin-top: 10px;
  height 600px
  overflow auto
}

.phantom {
  overflow hidden
}

ul {
  background: #ccc;
  list-style: none;
  padding: 0;
  margin: 0;
  li {
    outline: solid 1px #fff;
  }
}
</style>

复制代码


Make it scroll

上一例中,onScroll函数并没有填写,也就是说虚拟列表中的数据及位置并不会随着我们滚动而更新。这一步,补全onScroll函数。

codesandbox.io/s/list--scr…

<template>
  <div id="app">
    <input type="text" v-model.number="dataLength">条
    <div class="virtual-scroller" @scroll="onScroll" :style="{height: 600 + 'px'}">
      <div class="phantom" :style="{height: this.dataLength * itemHeight + 'px'}">
        <ul :style="{'margin-top': `${scrollTop}px`}">
          <li v-for="item in visibleList" :key="item.brandId" :style="{height: `${itemHeight}px`, 'line-height': `${itemHeight}px`}">
            <div>
              <div>{{item.name}}</div>
            </div>
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      itemHeight: 60,
      visibleCount: 10,
      dataLength: 100,
      startIndex: 0,
      endIndex: 10,
      scrollTop: 0
    };
  },
  computed: {
    dataList() {
      const newDataList = [...Array(this.dataLength || 0).keys()].map((v, i) => ({
        brandId: i + 1,
        name: `第${i + 1}项`,
        height: this.itemHeight
      }));
      return newDataList;
    },
    visibleList() {
      return this.dataList.slice(this.startIndex, this.endIndex);
    }
  },
  watch: {
    dataList() {
      console.time('rerender');
      setTimeout(() => {
        console.timeEnd('rerender');
      }, 0)
    }
  },
  methods: {
    onScroll(e) {
      const scrollTop = e.target.scrollTop;
      this.scrollTop = scrollTop;
      console.log('scrollTop', scrollTop);
      this.startIndex = Math.floor(scrollTop / this.itemHeight);
      this.endIndex = this.startIndex + 10;
    }
  }
};
</script>

<style lang="stylus" scoped>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

.virtual-scroller {
  border: solid 1px #eee;
  margin-top: 10px;
  height 600px
  overflow auto
}

.phantom {
  overflow hidden
}

ul {
  background: #ccc;
  list-style: none;
  padding: 0;
  margin: 0;
  li {
    outline: solid 1px #fff;
  }
}
</style>

复制代码


解决滚动不连贯的问题

上一例中我们滚动时,会发现一定要滚动一段距离后,虚拟列表中的内容才会突然更新一下,而不是循序渐进的过程。


这是因为startIndex由scrollTop/itemHeight计算出来,只能是item高度的倍数,假设scrollTop值在1倍和2倍之间的时候,虚拟列表内的startIndex并不会更新,也不会产生滚动现象


那么如何解决呢?其实我们利用ul元素自身的滚动来“欺骗眼睛”,原理如下图所示:

图片4.png


只需将我们的onScroll函数调整一下。ul的margin-top由计算得出,而不是直接使用e.target.scrollTop。

onScroll(e) {
  const scrollTop = e.target.scrollTop;
  this.startIndex = Math.floor(scrollTop / this.itemHeight);
  this.endIndex = this.startIndex + 10;
  this.scrollTop = this.startIndex * this.itemHeight;
}复制代码


减少reflow

由于我们每滚动一次,就需要改变一次margin-top,可能会频发引发reflow,那么我们可以考虑降低margin-top改变的频率

onScroll(e) {
  const scrollTop = e.target.scrollTop;
  const startIndex = Math.floor(scrollTop / this.itemHeight);
  let endIndex = startIndex + 10;
  if (endIndex > this.dataList.length) {
    endIndex = this.dataList.length;
  }
  // 当前滚动高度
  const currentScrollTop = startIndex * this.itemHeight;
  // 如果往下滚了可视区域的一部分,或者往上滚任意距离
  if (currentScrollTop - this.scrollTop > this.itemHeight * (this.visibleCount - 1) || currentScrollTop - this.scrollTop < 0) {
    this.scrollTop = currentScrollTop;
    this.startIndex = startIndex;
    this.endIndex = endIndex;
  }
}复制代码


列表项高度不固定,但可在渲染前获得高度的

上面处理的基本是写死高度的情况,如果是由数据中获取高度的,需要如下改写。

codesandbox.io/s/list--scr…

<template>
  <div id="app">
    <input type="text" v-model.number="dataLength">条{{this.scrollBarHeight}}
    <div class="virtual-scroller" @scroll="onScroll" :style="{height: 600 + 'px'}">
      <div class="phantom" :style="{height: this.scrollBarHeight + 'px'}">
        <ul :style="{'margin-top': `${scrollTop}px`}">
          <li v-for="item in visibleList" :key="item.brandId" :style="{height: `${item.height}px`, 'line-height': `${item.height}px`}">
            <div>
              <div>{{item.name}}</div>
            </div>
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      visibleCount: 10,
      dataLength: 2000,
      startIndex: 0,
      endIndex: 10,
      scrollTop: 0,
      bufferItemCount: 4,
      dataList: []
    };
  },
  computed: {
    visibleList() {
      return this.dataList.slice(this.startIndex, this.endIndex + this.bufferItemCount);
    },
    scrollBarHeight() {
      return this.dataList.reduce((pre, current)=> {
        console.log(pre, current)
        return pre + current.height;
      }, 0);
    }
  },
  watch: {
    dataList() {
      console.time('rerender');
      setTimeout(() => {
        console.timeEnd('rerender');
      }, 0)
    }
  },
  mounted() {
    this.dataList = this.getDataList();
  },
  methods: {
    getDataList() {
      const newDataList = [...Array(this.dataLength || 0).keys()].map((v, i) => ({
        brandId: i + 1,
        name: `第${i + 1}项`,
        height: Math.floor(Math.max(Math.random() * 10, 5)) * 10
      }));
      return newDataList;
    },
    getScrollTop(startIndex) {
      return this.dataList.slice(0, startIndex).reduce((pre, current) => {
        return pre + current.height;
      }, 0)
    },
    getStartIndex(scrollTop) {
      let index = 0;
      let heightAccumulate = 0;
      for (let i = 0; i < this.dataList.length; i++) {
        if (heightAccumulate > scrollTop) {
          index = i - 1;
          return index;
        }
        if (heightAccumulate === scrollTop) {
          index = i;
          return i
        }
        heightAccumulate += this.dataList[i].height;
      }
      return index;
    },
    onScroll(e) {
      const scrollTop = e.target.scrollTop;
      this.startIndex = this.getStartIndex(scrollTop);
      this.endIndex = this.startIndex + 10;
      this.scrollTop = this.getScrollTop(this.startIndex);
    }
  }
};
</script>

<style lang="stylus" scoped>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

.virtual-scroller {
  border: solid 1px #eee;
  margin-top: 10px;
  height 600px
  overflow auto
}

.phantom {
  overflow hidden
}

ul {
  background: #ccc;
  list-style: none;
  padding: 0;
  margin: 0;
  li {
    outline: solid 1px #fff;
  }
}
</style>复制代码


缓存每个元素的scrollTop

上一个例子中,我们每次getScrollTop都需要重新计算一次,比较浪费性能。可以在一开始的时候加上缓存,这样每次调用时直接从map中取,耗时较小。

codesandbox.io/s/list--scr…

generatePositionCache() {
  const allHeight = this.dataList.reduce((pre, current, i) => {
    const heightSum = pre + current.height;
    this.positionCache[i] = pre;
    return heightSum;
  }, 0)
  this.scrollBarHeight = allHeight
}复制代码


二分查找减少startIndex的查找时间

另外,还可以利用二分查找来降低getStartIndex的时间

getStartIndex(scrollTop) {
  // 在itemTopCache中找到一个左侧小于scrollTop但右侧大于scrollTop的位置
  // 复杂度O(n)
  // for (let i = 0; i < this.itemTopCache.length; i++) {
  //   if (this.itemTopCache[i] > scrollTop) {
  //     return i - 1;
  //   }
  // }
  // 复杂度O(logn)
  let arr = this.itemTopCache;
  let index = -1;
  let left = 0,
      right = arr.length - 1,
      mid = Math.floor((left + right) / 2);
  let circleTimes = 0;
  while (right - left > 1) {
    // console.log('index: ', left, right);
    // console.log('height: ', arr[left], arr[right]);
    circleTimes++;
    // console.log('circleTimes:', circleTimes)
    // 目标数在左侧
    if (scrollTop < arr[mid]) {
      right = mid;
      mid = Math.floor((left + right) / 2);
    } else if (scrollTop > arr[mid]) {
      // 目标数在右侧
      left = mid;
      mid = Math.floor((left + right) / 2);
    } else {
      index = mid;
      return index;
    }
  }
  index = left;
  return index;
}复制代码


解决CSS索引问题

正常的列表结构是从第0个元素开始的,我们在CSS中通过选择器2n可以选中偶数行的列表。但虚拟列表不同,我们每次计算出来的startIndex都不同,startIndex为奇数时,2n便表现异常,所以我们需要保证startIndex为一个偶数。解决方法也很简单,如果发现是奇数,则取上一位,确保startIndex一定是偶数。

codesandbox.io/s/list--scr…

ul {
  background: #ccc;
  list-style: none;
  padding: 0;
  margin: 0;
  li {
    outline: solid 1px #fff;
    &:nth-child(2n) {
      background: #fff;
    }
  }
}
...
// onScroll中加入
// 如果是奇数开始,就取其前一位偶数
if (startIndex % 2 !== 0) {
  this.startIndex = startIndex - 1;
} else {
  this.startIndex = startIndex;
}复制代码


渲染后才可确定高度的

有种情况是每个列表项中包含的文字数量不同,导致渲染后撑开的高度不一样。那么我们就可以在组件mounted后更新一次列表项的高度。

codesandbox.io/s/list--scr…

Item.vue

<template>
  <li :key="item.brandId" :style="{height: `${item.height}px`, 'line-height': `${item.height}px`}" ref="node">
    <div>
      <div>{{item.name}}</div>
    </div>
  </li>
</template>

<script>
export default {
  props: {
    item: {
      default() {
        return {}
      },
      type: Object
    },
    index: Number
  },
  data() {
    return {
      
    }
  },
  mounted() {
    this.$emit('update-height', {height: this.$refs.node.getBoundingClientRect().height, index: this.index})
  }
}
</script>
复制代码


Item组件加载时会更新高度,但是整个列表初始化时是没有高度的怎么办?我们需要引入一个估算值:estimatedItemHeight,它代表每个Item的预估高度,每当Item有更新时,则替换掉预估值,同时更新列表的整体高度。

App.vue

<template>
  <div id="app">
    <input type="text" v-model.number="dataLength">条 Height:{{scrollBarHeight}}
    <div class="virtual-scroller" @scroll="onScroll" :style="{height: 600 + 'px'}">
      <div class="phantom" :style="{height: scrollBarHeight + 'px'}">
        <ul :style="{'transform': `translate3d(0,${scrollTop}px,0)`}">
          <Item v-for="item in visibleList" :item="item" :index="item.index" :key="item.brandId" @update-height="updateItemHeight"/>
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
import Item from './components/Item.vue';

export default {
  name: "App",
  components: {
    Item
  },
  data() {
    return {
      estimatedItemHeight: 30,
      visibleCount: 10,
      dataLength: 200,
      startIndex: 0,
      endIndex: 10,
      scrollTop: 0,
      scrollBarHeight: 0,
      bufferItemCount: 4,
      dataList: [],
      itemHeightCache: [],
      itemTopCache: []
    };
  },
  computed: {
    visibleList() {
      return this.dataList.slice(this.startIndex, this.endIndex + this.bufferItemCount);
    }
  },
  watch: {
    dataList() {
      console.time('rerender');
      setTimeout(() => {
        console.timeEnd('rerender');
      }, 0)
    }
  },
  created() {
    this.dataList = this.getDataList();
    this.generateEstimatedItemData();
  },
  mounted() {

  },
  methods: {
    generateEstimatedItemData() {
      const estimatedTotalHeight = this.dataList.reduce((pre, current, index)=> {
        this.itemHeightCache[index] = this.estimatedItemHeight;
        const currentHeight = this.estimatedItemHeight;
        this.itemTopCache[index] = index === 0 ? 0 : this.itemTopCache[index - 1] + this.estimatedItemHeight;
        return pre + currentHeight
      }, 0);
      this.scrollBarHeight = estimatedTotalHeight;
    },
    updateItemHeight({index, height}) {
        this.itemHeightCache[index] = height;
        this.scrollBarHeight = this.itemHeightCache.reduce((pre, current) => {
          return pre + current;
        }, 0)
        let newItemTopCache = [0];
        for (let i = 1, l = this.itemHeightCache.length; i < l; i++) {
          newItemTopCache[i] = this.itemTopCache[i - 1] + this.itemHeightCache[i - 1]
        };
        this.itemTopCache = newItemTopCache;
    },
    getDataList() {
      const newDataList = [...Array(this.dataLength || 0).keys()].map((v, i) => ({
        index: i,
        brandId: i + 1,
        name: `第${i + 1}项`,
        height: Math.floor(Math.max(Math.random() * 10, 5)) * 10
        // height: 50
      }));
      return newDataList;
    },
    getStartIndex(scrollTop) {
      // 在heightAccumulateCache中找到一个左侧小于scrollTop但右侧大于scrollTop的位置
      // 复杂度O(n)
      // for (let i = 0; i < this.itemTopCache.length; i++) {
      //   if (this.itemTopCache[i] > scrollTop) {
      //     return i - 1;
      //   }
      // }
      // 复杂度O(logn)
      let arr = this.itemTopCache;
      let index = -1;
      let left = 0,
        right = arr.length - 1,
        mid = Math.floor((left + right) / 2);
      let circleTimes = 0;
      while (right - left > 1) {
        circleTimes++;
        // 目标数在左侧
        if (scrollTop < arr[mid]) {
          right = mid;
          mid = Math.floor((left + right) / 2);
        } else if (scrollTop > arr[mid]) {
          // 目标数在右侧
          left = mid;
          mid = Math.floor((left + right) / 2);
        } else {
          index = mid;
          return index;
        }
      }
      index = left;
      return index;
    },
    onScroll(e) {
      const scrollTop = e.target.scrollTop;
      console.log('scrollTop', scrollTop);
      let startIndex = this.getStartIndex(scrollTop);
      // 如果是奇数开始,就取其前一位偶数
      if (startIndex % 2 !== 0) {
        this.startIndex = startIndex - 1;
      } else {
        this.startIndex = startIndex;
      }
      this.endIndex = this.startIndex + this.visibleCount;
      this.scrollTop = this.itemTopCache[this.startIndex] || 0;
    }
  }
};
</script>

<style lang="stylus" scoped>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

.virtual-scroller {
  border: solid 1px #eee;
  margin-top: 10px;
  height 600px
  overflow auto
}

.phantom {
  overflow hidden
}

ul {
  background: #ccc;
  list-style: none;
  padding: 0;
  margin: 0;
  li {
    outline: solid 1px #fff;
    &:nth-child(2n) {
      background: #fff;
    }
  }
}
</style>复制代码


假如列表项中包含了img标签,并会被img自动撑开,那么我们可以利用img的onload事件来通知列表更新高度。react-virtualized中也有img配合CellMeasure组件使用的例子。那么如果遇到更复杂的高度变化场景该怎么办?


ResizeObserver

ResizeObserver 接口可以监听到 Element 的内容区域或 SVGElement的边界框改变,可以处理复杂的高度变化场景。


但ResizeObserver 兼容性较为一般: caniuse.com/#feat=resiz…

image.png


虽然兼容性不太好,但在某些后台系统中,还是可以尝试使用的。


ResizeObserve使用例子

codesandbox.io/s/list--scr…

codesandbox.io/s/list--scr…

主要调整点就是在list item中增加observe和unobserve方法。


Item.vue

<template>
  <li :key="item.brandId" :style="{height: `${item.height}px`, 'line-height': `${item.height}px`}" ref="node">
    <div>
      <div>{{item.name}}</div>
    </div>
  </li>
</template>

<script>
export default {
  props: {
    item: {
      default() {
        return {}
      },
      type: Object
    },
    index: Number
  },
  data() {
    return {}
  },
  mounted() {
    this.observe();
  },
  methods: {
    observe() {
      this.resizeObserver = new ResizeObserver((entries) => {
        const entry = entries[0];
        console.log(this.index, entry.contentRect.height)
        this.$emit('update-height', {height: entry.contentRect.height, index: this.index})
      });
      this.resizeObserver.observe(this.$refs.node);
    },
    unobserve() {
      this.resizeObserver.unobserve(this.$refs.node);
    }
  },
  beforeDestroy() {
    this.unobserve();
  }
}
</script>复制代码


使用resize dectect库监测高度

对于高度变化场景且兼容性要求较高的,我们可以使用它的polyfill:ResizeObserver Polyfill,支持到IE8以上。另外应注意到它的一些限制:

  • Notifications are delivered ~20ms after actual changes happen.
  • Changes caused by dynamic pseudo-classes, e.g. :hover and :focus, are not tracked. As a workaround you could add a short transition which would trigger the transitionend event when an element receives one of the former classes (example).
  • Delayed transitions will receive only one notification with the latest dimensions of an element.


如果在没有原生ResizeObserver的情况下想实现:hover及:focus后的size更新观察,那么就要使用element-resize-detectorjavascript-detect-element-resize(react-virtualized使用)这一类的第三方库了,当然它们也有一些限制,可以在observation-strategy中详细查阅到。


总结


解决了上述的一系列问题,我们才算实现了一个较为基础的虚拟列表。一些兼容性问题的修复和性能的优化,需要根据实际情况来看。在生产环境中,建议直接使用成熟的第三方库,在兼容性和性能方面有保证。如果时间充裕,可以造个轮子理解下思路,这样在使用第三方组件时也会更加得心应手。


参考文章:

github.com/dwqs/blog/i…

yuque.antfin-inc.com/abraham.cj/…

developer.mozilla.org/zh-CN/docs/…

zhuanlan.zhihu.com/p/26022258

zhuanlan.zhihu.com/p/34585166

ant.design/components/…

github.com/que-etc/res…

image.png