🔦 React 的列表渲染
key 与 Reconciliation
React 在渲染列表时,会要求开发者为每一个列表元素指定唯一的 key,以帮助 React 识别哪些元素是新添加的,哪些元素被修改或删除了。
通常情况下,Reconciliation 算法递归遍历历某个 DOM 节点的全部子节点,以保证改变能够正确的被应用。这样的方式在大多数场景下没有问题,但是对于列表来讲,如果在列表中添加了新元素,或者某个元素被删除,可能会导致整个列表被重新渲染。
举一个文档中的例子:
List without Keys
在添加 Connecticut 节点时,React 会修改 Duke 和 Villanova ,再添加一个 Villanova,而不是直接把 Connecticut 插入到列表头部。
当列表元素被指定了 key 时,React 会利用 key 准确的进行 Diffing 操作,而不是粗暴地逐个比较,以至于重新渲染整个列表。
使用 index 直接当 key 会带来哪些风险?
正因为 React 会使用 key 来识别列表元素,当元素的 key 发生改变的时候,可能会导致 React 的 Diffing 执行在错误的元素上,甚至导致状态错乱。这种情况在使用 index 的时候尤其常见。有一个很好的例子:
Original State
New Item Added
可以看到,input 的状态发生了错乱,"qwerty" 应该在第二个 input 里才对。
不过, React key 的作用域仅限于当前列表内,所以我们并不需要绞尽脑汁为每一个列表的每一个元素都生成全局唯一的 key(提示:在 React 中,如果一个容器同时渲染了上下两个列表或者多个列表,不需要保证列表间的 key 唯一,单个列表 key 唯一即可)。
长列表的事件绑定策略
我记得我参加校招的时候,经常会被问到一道很经典的面试题:如何为一个长列表绑定点击事件(绑定在父元素上 / 为各列表元素单独绑定)?理由是?
在原生 DOM 列表上为列表元素绑定事件时,我们通常会将事件绑定在父级元素上,借助事件冒泡机制捕获事件,以降低事件绑定开销。React 对事件处理进行了优化,通常情况下,我们不需要对长列表事件绑定策略进行优化。
SyntheticEvent
熟悉 React 的同学都知道,React 使用 SyntheticEvent 代理了浏览器的原生事件,目的在于提供更通用更高效的事件策略。
SyntheticEvent 相较于原生 DOM 事件做了如下优化:
统一 API
补充了一些浏览器原生没有实现的事件
Event Pooling
其中最重要的就是 Event Pooling。在绑定事件时,React 不会直接在对应的 DOM 节点上直接绑定事件,而是以「事件委托(Event Delegation)」的方式,将事件委托到 document 上;同时,SyntheticEvent 会被复用:在每次事件回调结束后,除非用户显式的声明该 Event 对象应当被 presist(),否则 React 会回收该对象,并在下一个事件回调中进行复用。
关于 SyntheticEvent 的实现机制,有两篇文章推荐:
React 在渲染长列表时的性能开销
React 的长列表渲染的开销,与原生 DOM 没有太大差别;其优势在于借助 Reconcilication 可以得出一个很高效的 DOM 更新策略。其性能开销主要在以下几个方面:
调用 render 前需要执行的业务逻辑
Reconciliation
如果业务逻辑不是特别复杂,其开销最大的过程通常是 DOM Rendering,而且,随着列表越来越长,Rendering 的时间会越来越长。
Profiling
🤔 长列表渲染的优化思路
使用 createDocumentFragment / innerHTML 替换 createElement
像 React 的 Virtual DOM 一样,我们尝试使用 DocumentFragment 替换完整的 Node,以压缩 Scripting 的时间。不过,在现代浏览器中,createElement 和 createDocumentFragment 的开销已经没什么差别了。
createElement
createDocumentFragment
如果不考虑生成列表元素可能包含的复杂业务逻辑,Scripting 的过程几乎是在瞬间完成的,这种优化实际收效甚微。
懒加载
顾名思义,懒加载就是一开始不渲染太多的 DOM 节点,随着用户滑动,逐步将 DOM 节点添加进去。懒加载通常配合数据的分片加载一同进行,即分步请求数据 + 渲染 DOM 节点。
Lazy Loading
Jul-26-2018 15-51-03.gif
但是,如果一直挂载节点,列表的 DOM 结构会越来越大,DOM 操作的开销也会随之增大,Rendering 的速度也会越来越慢。
仅渲染可视区域
既然 DOM 结构越大,渲染速度越慢,那我们可以尝试仅渲染用户能够看到的列表元素,尽可能控制 DOM 结构的大小。
实现思路
获取 container 的容器高度
计算 container 一屏能容纳下的元素个数
创建一个 placeholder 把 container 的 scroll height 撑起来。
创建一个 list 放置生成的元素
滚动的时候:
获取 scrollTop,计算 List 的 offset top,保证 List 覆盖 container 的可视区域。
通过 scrollTop 计算出当前可视区域应当显示的元素的 index,根据计算结果渲染新的列表
使用 transform: translate3d(0, offsetHeight, 0) 调整 list 的位置,保证其在可视区域内。
Visible Area Rendering
源码片段
// 列表元素高度
const ITEM_HEIGHT = 31
// 列表元素个数
const ITEM_COUNT = 500
window.onload = function () {
const container = document.querySelector('#container')
const containerHeight = container.clientHeight
const list = document.querySelector('#list')
// 一屏可以渲染下的元素个数
const visibleCount = Math.ceil(containerHeight / ITEM_HEIGHT)
const placeholder = document.querySelector('#content-placeholder')
placeholder.style.height = ITEM_COUNT * ITEM_HEIGHT + 'px'
// 首次渲染
list.appendChild(renderNodes(0, visibleCount))
container.addEventListener('scroll', function() {
// 使用 translate3d 将可视列表调整到屏幕正中的位置
list.style.webkitTransform = `translate3d(0, ${container.scrollTop - container.scrollTop % ITEM_HEIGHT}px, 0)`
list.innerHTML = ''
// 计算可视区域列表的起始元素的 index
const firstIndex = Math.floor(container.scrollTop / ITEM_HEIGHT)
list.appendChild(renderNodes(firstIndex, firstIndex + visibleCount))
})
}
function renderNodes(from, to) {
const fragment = document.createDocumentFragment()
for (let i = from; i < to; i++) {
const el = document.createElement('li')
el.innerHTML = i + 1
fragment.appendChild(el)
}
return fragment
}
基于「仅渲染可见区域」这个思路,我们可以进行许多优化。当然,也有现成的开源库可供使用:Clusterized.js 和 React Virtualized。
😉 开源的长列表渲染库
主流开源库实现长列表渲染的方式大同小异,除了决定渲染哪些元素的方式有一些不同外,其最大的区别在于如何将渲染出来的列表元素合理的放置在可视区域中。
Clusterize.js 基于 Vanilla.js (原生 JavaScript) ,用于在前端高效地展示大型数据集中的数据�。
渲染思路
Clusterize.js 将数据源划分成了一块一块的「Cluster」。初次渲染时,Clusterize.js 会根据第一个 Cluster 具备的高度计算出整个列表的初始高度,并在列表下方使用 placeholder 撑开整个列表。每次渲染时,Clusterize.js 会渲染足够多的 Cluster 以覆盖可见区域。在当前 Cluster 离开可见区域后,该 Cluster 的高度会被添加到 .clusterize-top-space 上;同时,新渲染的 Cluster 的高度会从 .clusterize-bottom-space 上减掉。DOM 结构大致如下图:
Clusterize.js
Clusterize.js 是如何处理动态高度元素的?
Clusterize.js 强烈建议使用统一高度的数据项,但是由于其使用的渲染方式比较特别,即便是高度不一的数据项也不会产生太大问题。
React Virtualized 是 React 生态链中最经典、应用最广泛的长列表组件库。
渲染思路
React Virtualized 渲染的思路很清奇:它没有使用 transition 对列表进行定位,而是对每一个列表元素单独进行定位,即每个列表元素都是 position: absolute 的,并且有自己的绝对定位坐标。DOM 结构大致如下图:
React Virtualized
Aug-02-2018 12-20-49.gif
是如何处理动态高度元素的?
React Virtualized 的 支持动态高度元素的方式很暴力:它要求开发者手动传入每一个元素的高度 / 计算元素高度的方法。如果不传入,需要配合使用 动态计算元素高度。亲身体验后,感觉 React Virtualized 对动态元素高度的支持非常不友好。
✈️ 进阶玩法:基于 IntersectionObserver 的长列表优化
传统长列表优化方案的问题
在长列表渲染中,我们最关心的是各节点与容器元素可见区域的层叠关系,scrollTop 、 clientHeight、getBoundingClientRect 等可以确定节点位置,也可以用来判断节点是否应当被渲染,但是�,他们都会触发 reflow,性能开销较大(详见 What forces layout / reflow)。
当前的列表更新策略是基于 scroll 事件的,scroll 事件的频繁触发也会带来一些性能开销。
IntersectionObserver
IntersectionObserver 用来异步地观察目标元素与祖先元素(或视口)的相交关系。它的回调函数会在目标元素与祖先元素相交的时候触发,回传一个 IntersectionObserverEntry 数组,标记相交时的状态(开始相交还是开始脱离 / 目标元素的位置信息和大小等)。
实现思路
大体思路和基于 scroll 事件的长列表渲染类似,区别在于更新显示区域内容的方式不同。以下是具体步骤(React-based):
获取 container 的容器高度
计算 container 一屏能容纳下的元素个数
创建一个 placeholder,指定 min-height,把 container 的 scroll height 撑起来。
创建一个 list 保存显示区域要渲染的数据
根据上面的 list 渲染到 placeholder 内
observer.observe(list.firstChild)
当首部元素与 container 发生交叉(Intersection)时,IntersectionObserver 的 callback 会触发,并传入 IntersectionObserverEntry 实例,提供必要信息。
根据 isIntersecting 判断当前元素的移动是离开了可视区域还是进入了可视区域;如果离开了可视区域,则更新 list,将首部元素移除,在尾部添加一个新的元素(startIndex + 1),并执行 .observe(el.nextSibling);如果进入了可视区域,则在首部元素前再添加一个元素,执行 .observe(el.previousSibling)。
placeholder 的实际显示区域的位置,使用 padding-top 来调整。
Scrolling
Reverse Scrolling
Jul-30-2018 22-20-21.gif
代码片段
代码写的比较糙,只是阐述一下思路,实际还有不少 BUG 需要解决。
this.observer = new IntersectionObserver(entries => {
if (this.firstExecute) {
this.firstExecute = false
return
}
const entry = entries[0]
const {startIndex, placeHolderPadding} = this.state
if (!entry.isIntersecting) {
this.setState({
startIndex: startIndex + 1,
placeHolderPadding: placeHolderPadding + entry.boundingClientRect.height
}, () => (this.firstExecute = true) && this.observer.observe(entry.target.nextSibling))
} else {
this.setState({
startIndex: max(this.state.startIndex - 1, 0),
placeHolderPadding: max(placeHolderPadding - entry.boundingClientRect.height, 0)
}, () => (this.firstExecute = true) && entry.target.previousSibling && this.observer.observe(entry.target.previousSibling))
}
})
render() {
const {className, data, render} = this.props
const {startIndex, visibleItemCount, placeholderHeight, placeHolderPadding} = this.state
return (
{data.slice(startIndex, startIndex + visibleItemCount).map(render)}
)
}
⚙ 更好的支持动态高度元素
React Virtualized 给出的方案中,我们需要手动告诉 每个元素的高度,或者传入计算方法,这无疑会带来很多限制。大多数情况下,我们无法直接确定元素的实际高度,只有当元素渲染出来之后高度才能被确定下来。
理想状态下,开发者无需传入任何「高度」数据,长列表组件只需根据传入的数据源和 renderItem 方法,即可自动计算出列表项高度。 吗,长列表渲染有一个非常重要的部分,就是要保证 scroll 事件在真正到达列表尾部之前都能够被触发。
Clusterize.js 将数据源拆分成了多个 Cluster,渲染的时候以 Cluster 为单位进行挂载 / 解挂。这个思路很具有启发性。
实现思路
如果想保证 scroll 事件能一直触发,渲染「足够多」的元素以撑开一个可滚动的区域是一个很好的主意。
当然,元素太多就失去了长列表优化的作用。我们可以使用「走马灯」的思路,循环渲染元素,以达到复用可视区域、还原 Scroll Bar 位置 & 循环滚动的目的。
实现思路
根据开发者设定的粒度,将传入的 data 分为若干个 slice
从第 0 个 slice 开始,每次渲染三个 slice
监�听 scroll 事件,当 slice[1] 的首部元素处于可视区域底部下方时,丢弃 slice[2],在 slice[0] 前插入新的 slice;当 slice[1] 的尾部元素处于可视区域顶部上方时,丢弃 slice[0],在 slice[2] 下方渲染新的 slice。效果如图:
Slices
可以看到,DOM Tree 的更新频率非常低,而且列表元素的高度也不需要提前计算。不过这个方案的问题也很明显:Scroll Bar 会上下跳。
为了解决 Scroll Bar 的问题,我们在 slice[0] 被丢弃之前,将 slice[0] 的高度 push 到 topSpaces 数组中,将数组中数字的和作为 padding-top 设定在列表元素上。
代码片段
// 正在切换 slice
processing = false
handleScroll = () => {
if (this.processing) {
return
}
if (!this.topBoundary || !this.bottomBoundary) {
return
}
const topBoundaryLoc = this.topBoundary.getBoundingClientRect().top
const bottomBoundaryLoc = this.bottomBoundary.getBoundingClientRect().top
if (
bottomBoundaryLoc < containerTop + sliceThreshold &&
currentSliceIndex + 3 < slices.length
) {
this.processing = true
// 用 slice[0] 首部元素的坐标和 slice[1] 首部元素的坐标差确定 slice 的高度
const startY = this.listEl.firstChild.getBoundingClientRect().top
const topSpace = topBoundaryLoc - startY
this.setState(
{
currentSliceIndex: currentSliceIndex + 1,
topSpaces: topSpaces.concat(topSpace),
},
() => {
this.bindBoundaryEls()
this.processing = false
}
)
return
}
const containerTop = this.containerEl.getBoundingClientRect().top
const containerHeight = this.containerEl.clientHeight
const {sliceThreshold} = this.props
const {slices, currentSliceIndex, topSpaces} = this.state
if (
topBoundaryLoc > containerTop + containerHeight - sliceThreshold &&
currentSliceIndex > 0
) {
this.processing = true
this.setState(
{
currentSliceIndex: currentSliceIndex - 1,
topSpaces: topSpaces.slice(0, topSpaces.length - 1),
},
() => {
this.bindBoundaryEls()
this.processing = false
}
)
}
}
get visibleData() {
const {slices, currentSliceIndex} = this.state
const visibleSlices = slices.slice(
currentSliceIndex,
currentSliceIndex + 3
)
const startIndex = visibleSlices[0].startIndex
const amount = visibleSlices.reduce(
(amount, slice) => slice.amount + amount,
0
)
return data.slice(startIndex, startIndex + amount)
}
render() {
const {className, placeholders, isDrained} = this.props
const {topSpaces} = this.state
return (
className={cx(css['InfiniteLoader'], className)}
ref={el => (this.rootEl = el)}
>
ref={el => (this.listEl = el)}
style={{
paddingTop: `${topSpaces.reduce(
(total, curr) => curr + total,
0
)}px`,
}}
>
{this.visibleData.map(this.renderItem)}
)
}
🚧 自带长列表优化的 InfiLoader
需要解决的问题
数据源数据量递增变化
加载下一片段时,随着 Loading Spinner 的出现可视区域的高度会被压缩
列表元素高度在被渲染之前难以计算
只要列表不关心整体高度,或者不需要规划可视区域,后两个问题就不攻自破了。对于第一个问题,可以尝试利用 getDerivedStateFromProps 解决。
实现过程
利用 IntersectionObserver 实现 InfiLoader
我们可以利用 IntersectionObserver 监听 Loading Spinner 和容器元素的相交事件,以触发 Load More 动作。核心代码如下:
startObserve = () => {
if (!this.placeholderEl) return
// 销毁已经存在的 Observer
this.stopObserve()
this.observer = new IntersectionObserver(this.handleObserve)
this.observer.observe(this.placeholderEl)
}
stopObserve = () => {
if (this.observer) {
this.observer.disconnect()
this.observer = undefined
}
}
handleObserve = ([entry]) => {
if (!entry.isIntersecting) return
const {isLoading, isDrained, onLoad} = this.props
if (isLoading || isDrained) return
onLoad()
}
在 InfiLoader 上添加基于 slice 的长列表渲染
只有当数据量到达一定阈值,才应当使用分片渲染的方式渲染视图。阈值为「能够被划分成三个 slice 」:
get shouldOptimize() {
const {slices} = this.state
return slices.length > 3
}
在 getDerivedStateFromProps 周期时,根据传入的 data 生成 slice:
static getDerivedStateFromProps(props, state) {
const {prevProps} = state
const {data, sliceSize} = props
const {data: prevData} = prevProps
const slices = getSlices(data, sliceSize)
if (data.length < prevData.length) {
return {
slices,
currentSliceIndex: 0,
topSpaces: [],
prevProps: {
data,
},
}
}
return {
slices,
prevProps: {
data,
},
}
}
计算生成 slice 的函数如下:
const getSlices = (data, sliceSize) => {
const slices = []
// 按照传入的 sliceSize 将 data 劈开
for (let i = 0, amount = data.length; amount >= 0; i++, amount -= sliceSize) {
slices.push({
startIndex: sliceSize * i,
amount: amount > sliceSize ? sliceSize : amount,
})
}
return slices
}
其他的实现与上边「基于 slice 的长列表渲染」大同小异。
完整的 InfiniteLoader 代码如下:
import React, {Component} from 'react'
import PropTypes from 'prop-types'
import {subscribe} from 'subscribe-ui-event'
const VISIBLE_SLICE_COUNT = 3
const getSlices = (data, sliceSize) => {
const slices = []
for (let i = 0, amount = data.length; amount >= 0; i++, amount -= sliceSize) {
slices.push({
startIndex: sliceSize * i,
amount: amount > sliceSize ? sliceSize : amount,
})
}
return slices
}
export default class InfiniteLoader extends Component {
static propTypes = {
template: PropTypes.func.isRequired,
data: PropTypes.array.isRequired,
keyProp: PropTypes.string,
onLoad: PropTypes.func.isRequired,
isLoading: PropTypes.bool,
isDrained: PropTypes.bool,
placeholders: PropTypes.shape({
loading: PropTypes.element,
drained: PropTypes.element,
}),
getContainer: PropTypes.func,
// sliced list
// slice 的粒度
sliceSize: PropTypes.number,
// slice 切换的边界条件(距离 containerEL ${sliceThreshold}px)
sliceThreshold: PropTypes.number,
}
static defaultProps = {
keyProp: 'id',
placeholders: {},
sliceSize: 30,
sliceThreshold: 30,
}
state = {
prevProps: {
data: [],
},
slices: [],
currentSliceIndex: 0,
topSpaces: [],
}
static getDerivedStateFromProps(props, state) {
const {prevProps} = state
const {data, sliceSize} = props
const {data: prevData} = prevProps
const slices = getSlices(data, sliceSize)
// 数据源没有变化
if (prevData === data) {
return null
}
// 数据源切换或者被裁减了
if (
(prevData[0] && data[0] && prevData[0] !== data[0]) ||
data.length < prevData.length
) {
return {
slices,
currentSliceIndex: 0,
topSpaces: [],
prevProps: {
data,
},
}
}
// 记录数据源
return {
slices,
prevProps: {
data,
},
}
}
componentDidMount() {
const {isDrained} = this.props
this.bindScrollHandler()
if (this.shouldOptimize) {
this.bindBoundaryEls()
}
if (isDrained) return
this.startObserve()
}
componentDidUpdate(prevProps) {
const {data: oldData, isDrained: wasDrained} = prevProps
const {isLoading, isDrained, data} = this.props
if (oldData.length > data.length) {
this.containerEl.scrollTop = 0
}
if (this.shouldOptimize) {
this.bindBoundaryEls()
} else {
this.unbindBoundaryEls()
}
if (isLoading) return
if (isDrained) {
this.stopObserve()
return
}
if (wasDrained && !isDrained) {
this.startObserve()
return
}
if (oldData.length < data.length) {
this.mayLoadMore()
}
}
componentWillUnmount() {
this.stopObserve()
this.unbindBoundaryEls()
this.unbindScrollHandler()
}
get shouldOptimize() {
const {slices} = this.state
return slices.length > VISIBLE_SLICE_COUNT
}
get visibleData() {
const {data} = this.props
if (!this.shouldOptimize) {
return data
}
if (this.shouldOptimize) {
const {slices, currentSliceIndex} = this.state
const visibleSlices = slices.slice(
currentSliceIndex,
currentSliceIndex + VISIBLE_SLICE_COUNT
)
const startIndex = visibleSlices[0].startIndex
const amount = visibleSlices.reduce(
(amount, slice) => slice.amount + amount,
0
)
return data.slice(startIndex, startIndex + amount)
}
}
get containerEl() {
const {getContainer} = this.props
return (getContainer && getContainer(this.rootEl)) || document.body
}
topBoundary = null
bottomBoundary = null
bindBoundaryEls = () => {
const {slices, currentSliceIndex} = this.state
const nodeList = this.listEl.childNodes
this.topBoundary = nodeList[slices[currentSliceIndex].amount]
this.bottomBoundary =
nodeList[
slices[currentSliceIndex].amount +
slices[currentSliceIndex + 1].amount -
1
]
}
unbindBoundaryEls = () => {
this.topBoundary = null
this.bottomBoundary = null
}
bindScrollHandler = () => {
this.subscriber = subscribe('scroll', this.handleScroll, {
useRAF: true,
target: this.containerEl,
})
}
unbindScrollHandler = () => {
if (this.subscriber) {
this.subscriber.unsubscribe()
}
}
processing = false
handleScroll = () => {
if (!this.shouldOptimize || this.processing) {
return
}
if (!this.topBoundary || !this.bottomBoundary) {
return
}
const {sliceThreshold} = this.props
const {slices, currentSliceIndex, topSpaces} = this.state
const topBoundaryLoc = this.topBoundary.getBoundingClientRect().top
const bottomBoundaryLoc = this.bottomBoundary.getBoundingClientRect().top
const containerTop = this.containerEl.getBoundingClientRect().top
if (
bottomBoundaryLoc < containerTop + sliceThreshold &&
currentSliceIndex + VISIBLE_SLICE_COUNT < slices.length
) {
this.processing = true
const startY = this.listEl.firstChild.getBoundingClientRect().top
const topSpace = topBoundaryLoc - startY
this.setState(
{
currentSliceIndex: currentSliceIndex + 1,
topSpaces: topSpaces.concat(topSpace),
},
() => {
this.bindBoundaryEls()
this.processing = false
}
)
return
}
const containerHeight = this.containerEl.clientHeight
if (
topBoundaryLoc > containerTop + containerHeight - sliceThreshold &&
currentSliceIndex > 0
) {
this.processing = true
this.setState(
{
currentSliceIndex: currentSliceIndex - 1,
topSpaces: topSpaces.slice(0, topSpaces.length - 1),
},
() => {
this.bindBoundaryEls()
this.processing = false
}
)
}
}
mayLoadMore = () => {
const {top: containerY} = this.containerEl.getBoundingClientRect()
const containerHeight = this.containerEl.clientHeight
const {top: placeholderY} = this.placeholderEl.getBoundingClientRect()
if (placeholderY <= containerHeight + containerY) {
const {onLoad} = this.props
onLoad()
}
}
handleObserve = ([entry]) => {
if (!entry.isIntersecting) return
const {isLoading, isDrained, onLoad} = this.props
if (isLoading || isDrained) return
onLoad()
}
startObserve = () => {
if (!this.placeholderEl) return
// 销毁已经存在的 Observer
this.stopObserve()
this.observer = new IntersectionObserver(this.handleObserve)
this.observer.observe(this.placeholderEl)
}
stopObserve = () => {
if (this.observer) {
this.observer.disconnect()
this.observer = undefined
}
}
renderItem = (data, index) => {
const {template: Template, keyProp} = this.props
return
}
render() {
const {className, placeholders, isDrained} = this.props
const {topSpaces} = this.state
return (
ref={el => (this.listEl = el)}
style={{
paddingTop: `${topSpaces.reduce(
(total, curr) => curr + total,
0
)}px`,
}}
>
{this.visibleData.map(this.renderItem)}
{!isDrained && (
{placeholders.loading}
)}
{isDrained && placeholders.drained}
)
}
}