使用 Vue 实现一个虚拟列表的方法
因为 dom 性能瓶颈,大型列表存在难以克服的性能问题。 因此,就有了 “局部渲染” 的优化方案,这就是虚拟列表的核心思想。
虚拟列表的实现,需要重点关注的问题一有以下几点:
- 可视区域的计算方法
- 可视区域的 dom 更新方案
- 事件的处理方案
下面逐一分解说明。
可视区域计算
可视区域的计算,就是使用当前视口的高度、当前滚动条滚过的距离,得到一个可视区域的坐标区间。 算出可视区域的坐标区间之后,在去过滤出落在该区间内的列表项,这个过程,列表项的坐标也是必须能算出的。
思考以下情况,
- 我们的视口高度为 100px
- 我们当前已经滚动了 100px
- 我们的列表项,每一项高度为 20px
根据这些条件,我们可以计算出,当前可视区域为第 11 项至第 20 项。
01 - 05,可视区域上方
+----+-----------+--------
| 06 | 100 ~ 120 |
+----+-----------+
| 07 | 120 ~ 140 |
+----+-----------+
| 08 | 140 ~ 160 | 可视区域
+----+-----------+
| 09 | 160 ~ 180 |
+----+-----------+
| 10 | 180 ~ 200 |
+----+-----------+--------
11 - n,可视区域下方
这是因为列表项高度是固定的,我们可以通过简单的四则运算算出已经滚动过去的 100px 中,已经滚动走了 5 个列表项,因此可视区域是从第 6 项开始,而视口高度为 100px,能容纳 100 / 20 即 5 个条目。
上面例子的情况非常简单,也不存在性能问题,因此实际上并没有展开讨论的价值。 而还有另一种复杂很多的情况,那就是,列表项高度不固定,根据内容决定高度。
此时,我们就没办法直接使用四则运算一步到位算出可视区域对应的条目了。
而必须实现一种机制,记录所有列表项的坐标,再通过检查列表项是否落在视口内。
下面重点讨论该问题。
列表项的坐标
列表项的坐标,可以通过以下公式定义:
<列表项 top 坐标值> = <上一项 top 坐标值> + <上一项的高度值>
第一个列表项的 top 坐标值为 0,因此,只要记录所有列表项目的高度,即可算出任意一个列表项的 top 坐标值。 于是,问题就变成了,必须使用某种方式来存储每个条目的高度。
我想,最容易想到的方案就是,使用一个数组,一一对应地存储列表每项的高度值。 然后获取特定项的坐标值时,提取第一项到目标项的值,进行累加运算。参考下面代码进行理解:
// 假设使用该数组存储列表每一项的高度 const itemheightstore = [20, 20, 20, 20, 20] // 使用该方法,可以算出列表中指定项的 top 坐标值 const gettop = (index) => { let sum = 0 while (index--) sum += itemheightstore[index] || 0 return sum } // 第一项 gettop(0) // 0 // 第二项 gettop(1) // 20 // ...
该实现可以很好地工作。
但是,该算法存在严重的性能问题,每获取一个列表项的坐标都要遍历列表,复杂度 o(n),非常不划算。
如果换一种方式,直接存储每一项的坐标呢?
其实本质是一样的。因为我们的列表项高度是不固定的,我们快速拖动滚动条到不同的区域时,需要根据局部渲染结果算出高度用于更新数组记录,而在更新某一项时,该项后续的所有条目也需要全部更新,复杂度一样是 o(n)。
所以,使用数组来维护每一项的高度或者坐标,在列表规模比较大的时候,就会消耗大量的 cpu 时间。
也许使用 typedarray 会有好的表现?
仔细观察上面例子中的数组,结合现实中列表的情况,我们可以观察到一个现象:
列表项往往是相似的,在许多情况下,高度也很可能是一致的。
基于这种经验,我们可以采用区间来存储列表项的高度。
通过折叠记录相邻的,相同高度的列表项,来减少列表遍历操作。
比如以下表示方式:
const range = { start: 0, end: 4, value: 20 }
可以很好地表达列表第 1 项至第 5 项的高度都为 20px。
如果我们需要求第 6 项的高度的话,就只需进行一次简单的四则运算即可,无需遍历累加这 5 项。
很容易得出结论,如果列表大部分情况是相同高度,只有个别条目高度不一致时(例如文本换行),将会有非常优异的性能表现。
当然使用区间,也不是没有代价的。这又会带来数据结构的复杂性。
由于折叠了相邻相同高度的结点,会导致存储的列表无法跟原始条目一一对应。所以,我们就不能简单得知我们想查询的列表项的高度存储在哪里了, 为此需要设计一种专门的存储机制。
这种存储机制,需要拥有这些特性:
- 高效的查询。可以通过列表项序号,快速获得对应的 range,以及该 range 之前的所有 range。
- 高效地修改。可以高效地插入、移除 range,合并 range、拆分 range。
结合我们学过的数据结构知识,可以考虑使用某种 bst 来存储,从而获得良好的查询、插入性能。 而 range 的合并、拆分等,则可以实现一个专门的类来管理。
下面直接给出一个简单的代码实现供参考,代码中已经加上了大量的注释,直接阅读应该比解说要更清晰。
// avl.ts const slightly_unbalanced_right = -1 const slightly_unbalanced_left = 1 const unbalanced_right = -2 const unbalanced_left = 2 // 树结点 class avlnode<k extends any = any> { key: any left: avlnode<k> | null right: avlnode<k> | null parent: avlnode<k> | null _height: number _prevheight: number constructor(key: k) { this.key = key this.left = null this.right = null this.parent = null this._height = 0 this._prevheight = 0 } // 刷新前的高度,方便平衡操作 get prevheight() { return this._prevheight | 0 } get height() { return this._height | 0 } set height(value) { this._prevheight = this._height | 0 this._height = value | 0 } // 左子树高度 get leftheight() { if (this.left === null) return -1 return this.left.height | 0 } // 右子树高度 get rightheight() { if (this.right === null) return -1 return this.right.height | 0 } // 平衡因子 get balancefactor() { return this.leftheight - this.rightheight } updateheight() { const { leftheight, rightheight } = this const height = ((leftheight > rightheight ? leftheight : rightheight) + 1) | 0 this.height = height } } // avl 树 export class avl<k extends any = any> { _root: avlnode<k> | null _size: number constructor() { this._root = null this._size = 0 } get size() { return this._size } // 插入节点 insert(key: k) { const node = new avlnode<k>(key) const insertpoint = this._nodeinsert(node) // 本次插入是重复结点,直接更新 key / value // 无新结点插入,所以无需进行插入后的调整 if (insertpoint == null) return // 新增结点成功时,回溯调整搜索路径上的结点 this._adjustafterinsertion(insertpoint) } // 删除节点,返回是否成功删除结点 delete(key: k): boolean { // 搜索待删除结点 const targetnode = this._nodesearch(key) // 未找到 value 对应结点 if (targetnode == null) return false // 执行删除结点操作 const backtracking = this._nodeerase(targetnode) const parent = backtracking[0] // 回溯调整搜索路径上的结点 if (parent !== null) { this._adjustafterremoval(parent) } return true } // 通过 key 查找包含该 key 范围的节点 key search(key: k) { const node = this._nodesearch(key) if (node !== null) return node.key return null } // 搜索 start 、end 两个 key 之间的所有 key 列表 searchrange(start: k, end: k) { const results: k[] = [] // 找到符合条件的 root 节点 let root = this._root while (root !== null) { const result1 = start.compareto(root.key) const result2 = end.compareto(root.key) // 当前节点比 start 小,不再搜索左子树 if (result1 > 0) { root = root.right continue } // 当前节点大于 end,不再搜索右子树 if (result2 < 0) { root = root.left continue } break } if (!root) return results const stack = [] let current: avlnode<k> | null = root while (stack.length || current) { while (current) { stack.push(current) // 当前节点比 start 小,不再搜索 current 的左子树 if (start.compareto(current.key) > 0) break current = current.left } if (stack.length) { // 指向栈顶 current = stack[stack.length - 1] const gtestart = start.compareto(current.key) <= 0 const lteend = end.compareto(current.key) >= 0 if (gtestart && lteend) { results.push(current.key) } stack.pop() // 只有 current 比 end 小,才继续搜索 current 的右子树 if (lteend) { current = current.right } else { current = null } } } return results } // 增加结点数量 _increasesize() { this._size += 1 } // 减少结点数量 _decreasesize() { this._size -= 1 } // 设置左子结点,同时维护 parent 关系 _setleft(node: avlnode<k>, child: avlnode<k> | null) { // 断开旧 left 结点 if (node.left !== null) { node.left.parent = null } // 连接新结点 if (child !== null) { // 从旧 parent 中断开 if (child.parent !== null) { child.parent.left === child ? (child.parent.left = null) : (child.parent.right = null) } child.parent = node } node.left = child } // 设置右子结点,同时维护 parent 关系 _setright(node: avlnode<k>, child: avlnode<k> | null) { // 断开旧 right 结点 if (node.right !== null) { node.right.parent = null } // 连接新结点 if (child !== null) { // 从旧 parent 中断开 if (child.parent !== null) { child.parent.left === child ? (child.parent.left = null) : (child.parent.right = null) } child.parent = node } node.right = child } // 获取中序遍历顺序的前驱结点 _inorderpredecessor(node: avlnode<k> | null) { if (node == null) return null // 1. 有左子树,找到左子树最大元素 if (node.left !== null) { return this._maximumnode(node.left) } // 2. 没有左子树,往上搜索 let parent = node.parent while (parent != null) { if (node == parent.right) { return parent } node = parent parent = node.parent } // 4. 搜索到根 return null } // 获取最大的结点 _maximumnode(subroot: avlnode<k>) { let current = subroot while (current.right !== null) current = current.right return current } // 设置根结点 _setroot(node: avlnode<k> | null) { if (node === null) { this._root = null return } this._root = node // 如果本身在树中,则从树中脱落,成为独立的树根 if (node.parent !== null) { node.parent.left === node ? (node.parent.left = null) : (node.parent.right = null) node.parent = null } } // 将树上某个结点替换成另一个结点 private _replacenode(node: avlnode<k>, replacer: avlnode<k> | null) { if (node === replacer) return node // node 为 root 的情况 if (node === this._root) { this._setroot(replacer) } else { // 非 root,有父结点的情况 const parent = node.parent as avlnode<k> if (parent.left === node) this._setleft(parent, replacer) else this._setright(parent, replacer) } return node } // 左旋,返回新顶点,注意旋转完毕会从原本的树上脱落 private _rotateleft(node: avlnode<k>) { const parent = node.parent // 记录原本在树上的位置 const isleft = parent !== null && parent.left == node // 旋转 const pivot = node.right as avlnode<k> const pivotleft = pivot.left this._setright(node, pivotleft) this._setleft(pivot, node) // 旋转完毕 // 新顶点接上树上原本的位置 if (parent !== null) { if (isleft) this._setleft(parent, pivot) else this._setright(parent, pivot) } // --- if (node === this._root) { this._setroot(pivot) } node.updateheight() pivot.updateheight() return pivot } // 右旋,返回新顶点,注意旋转完毕会从原本的树上脱落 private _rotateright(node: avlnode<k>) { const parent = node.parent // 记录原本在树上的位置 const isleft = parent !== null && parent.left === node // 旋转 const pivot = node.left as avlnode<k> const pivotright = pivot.right this._setleft(node, pivotright) this._setright(pivot, node) // 旋转完毕 // 新顶点接上树上原本的位置 if (parent !== null) { if (isleft) this._setleft(parent, pivot) else this._setright(parent, pivot) } // --- if (node === this._root) { this._setroot(pivot) } node.updateheight() pivot.updateheight() return pivot } // 搜索 node private _nodesearch(key: k) { let current = this._root while (current !== null) { let result = key.compareto(current.key) if (result === 0) return current if (result < 0) current = current.left else current = current.right } return null } // 在树里插入结点或者刷新重复结点 // 返回新插入(或刷新)的结点 private _nodeinsert(node: avlnode<k>) { // 空树 if (this._root === null) { this._setroot(node) this._increasesize() return null } const key = node.key let current = this._root // 查找待插入的位置 while (true) { const result = key.compareto(current.key) if (result > 0) { if (current.right === null) { this._setright(current, node) this._increasesize() return current } current = current.right } else if (result < 0) { if (current.left === null) { this._setleft(current, node) this._increasesize() return current } current = current.left } else { // no duplicates, just update key current.key = key return null } } } // 从树上移除一个结点 private _nodeerase(node: avlnode<k>) { // 同时拥有左右子树 // 先转换成只有一颗子树的情况再统一处理 if (node.left !== null && node.right !== null) { const replacer = this._inorderpredecessor(node)! // 使用前驱结点替换身份 // 此时问题转换成删掉替代结点(前驱), // 从而简化成只有一个子结点的删除情况 node.key = replacer.key // 修改 node 指针 node = replacer } // 删除点的父结点 const parent = node.parent // 待删结点少于两颗子树时,使用子树 (或 null,没子树时) 顶替移除的结点即可 const child = node.left || node.right this._replacenode(node, child) this._decreasesize() return [ parent, child, node ] } // avl 树插入结点后调整动作 // 自底向上调整结点的高度 // 遇到离 current 最近的不平衡点需要做旋转调整 // 注意: 对最近的不平衡点调整后 或者 结点的高度值没有变化时 // 上层结点便不需要更新 // 调整次数不大于1 _adjustafterinsertion(backtracking: avlnode<k> | null) { let current = backtracking // 往上回溯,查找最近的不平衡结点 while (current !== null) { // 更新高度 current.updateheight() // 插入前后,回溯途径结点的高度没有变化,则无需继续回溯调整 if (current.height === current.prevheight) break // 若找到不平衡结点,执行一次调整即可 if (this._isunbalanced(current)) { this._rebalance(current) // 调整过,则上层无需再调整了 break } current = current.parent } } // avl树删除结点后调整动作 // 自底向上调整结点的高度 // 遇到离 current 最近的不平衡点需要做旋转调整 // 注意: 对最近的不平衡点调整后,其上层结点仍然可能需要调整 // 调整次数可能不止一次 _adjustafterremoval(backtracking: avlnode<k> | null) { let current = backtracking while (current !== null) { // 更新高度 current.updateheight() // 删除前后,回溯途径结点的高度没有变化,则无需继续回溯调整 if (current.height === current.prevheight) break if (this._isunbalanced(current)) { this._rebalance(current) } // 与插入不同,调整过后,仍然需要继续往上回溯 // 上层结点(若有)仍需判断是否需要调整 current = current.parent } } // ll _adjustleftleft(node: avlnode<k>) { return this._rotateright(node) } // rr _adjustrightright(node: avlnode<k>) { return this._rotateleft(node) } // lr _adjustleftright(node: avlnode<k>) { this._rotateleft(node.left!) return this._rotateright(node) } // rl _adjustrightleft(node: avlnode<k>) { this._rotateright(node.right!) return this._rotateleft(node) } // 检查结点是否平衡 _isunbalanced(node: avlnode<k>) { const factor = node.balancefactor return factor === unbalanced_right || factor === unbalanced_left } // 重新平衡 _rebalance(node: avlnode<k>) { const factor = node.balancefactor // right subtree longer (node.factor: -2) if (factor === unbalanced_right) { let right = node.right! // rl, node.right.factor: 1 if (right.balancefactor === slightly_unbalanced_left) { return this._adjustrightleft(node) } else { // rr, node.right.factor: 0|-1 // 即 right.rightheight >= right.leftheight return this._adjustrightright(node) } } else if (factor === unbalanced_left) { // left subtree longer (node.factor: 2) let left = node.left! // lr, node.left.factor: -1 if (left.balancefactor === slightly_unbalanced_right) { return this._adjustleftright(node) } else { // ll, node.left.factor: 1 | 0 // 即 left.leftheight >= left.rightheight return this._adjustleftleft(node) } } return node } } export function createavl() { return new avl() } // sparserangelist.ts import { createavl, avl } from './avl' // 区间类 class range { start: number end: number constructor(start: number, end?: number) { this.start = start this.end = end || start } // 用于 avl 中节点的比较 // // 列表中项目范围是连续的,必定不会重叠的 // 如果传入的 key 为重叠的,则意味着希望通过构造一个子 range 搜索所在的 rangevalue // 例如构造一个 { start: 10, end: 10, value: 'any' },搜索树中 // 范围包含 10~10 的 rangevalue,如 { start: 0, end: 20, value: 'any' } compareto(other: range) { if (other.start > this.end!) return -1 if (other.end! < this.start) return 1 return 0 } } // 区间-值 类 class rangevalue<t> extends range { value: t constructor(start: number, end: number, value: t) { super(start, end) this.value = value } clone(): rangevalue<t> { return new rangevalue(this.start, this.end!, this.value) } } // 最终存储区间-值的类,内部使用 avl 存储所有 rangevalue export default class sparserangelist<t> { _size: number defaultvalue: t valuetree: avl constructor(size: number, defaultvalue: t) { this._size = size this.defaultvalue = defaultvalue this.valuetree = createavl() } get size() { return this._size } resize(newsize: number) { newsize = newsize | 0 // 无调整 if (this._size === newsize) return // 扩容 if (this._size < newsize) { this._size = newsize return } // 缩小,清空超出的部分,再缩小 this.setrangevalue(newsize - 1, this._size - 1, this.defaultvalue) this._size = newsize } // 返回区间包含 index 的 rangevalue 的值 getvalueat(index: number): t { const result = this.valuetree.search(new range(index)) if (result) return result.value return this.defaultvalue } /** * 设值方法, * 自动与相邻的相同值的合并成更大的 rangevalue, * 导致原本的 rangevalue 不连续,则会 * 自动切分成两个或者三个 rangevalue。 * * a-------------a * |a------------|b------------|c-----------|... * * 结果: * |a-------------------|b-----|c-----------|... * * * d-------------d * |a------------|b------------|c-----------|... * * 结果: * |a-----|d------------|b-----|c-----------|... * */ setrangevalue(start: number, end: number, value: t) { if (!this.size) return if (end >= this.size) end = this.size - 1 // 所有与当前传入区间范围有重叠部分, // -1,+1 将接壤的毗邻 rangevalue 也纳入(如果存在的话), // 毗邻的 rangevalue 要检查否要合并。 let prevsiblingend = start - 1 let nextsiblingstart = end + 1 let rangevalues = this.treeintersecting(prevsiblingend, nextsiblingstart) // 如果没有重叠的部分,则作为新的 rangevalue 插入,直接结束 // 如果有重叠的部分,就要处理合并、拆分 if (rangevalues.length) { let firstrange = rangevalues[0] let lastrange = rangevalues[rangevalues.length - 1] // end 边界比传入的 start 小,说明是接壤毗邻的更小的 rangevalue // // 1. 如果毗邻的 rangevalue 的值跟当前带插入的值不一致, // 则直接将毗邻的 rangevalue 从列表中移除, // 不需要做任何特殊操作,正常的插入操作即可 // // 2. 否则如果毗邻的 rangevalue 的值跟当前待插入的值一致, // 则将两个 rangevalue 的 range 合并(修改 start即可), // 然后这个毗邻的 rangevalue 也自然变成重叠的,正常执行后续 // 的重叠处理逻辑即可(拆分) if (firstrange.end < start) { if (firstrange.value !== value) { rangevalues.shift() } else { start = firstrange.start } } // 接壤毗邻的更大的 rangevalue,处理思路 // 跟上面处理毗邻的更小的 rangevalue 一样的 if (lastrange.start > end) { if (lastrange.value !== value) { rangevalues.pop() } else { end = lastrange.end } } // 结束毗邻 rangevalue 合并逻辑 // 开始处理相交的 rangevalue 流程 const length = rangevalues.length let index = 0 while (index < length) { const currentrangevalue = rangevalues[index] const { value: currentvalue, start: currentstart, end: currentend } = currentrangevalue // 先移除掉该重叠的 rangevalue,然后: this.valuetree.delete(currentrangevalue) // case 1. 如果是当前 rangevalue 完整包含在传入的范围内, // 则不需要处理,因为整个范围都将被传入的值覆盖。 if (currentstart >= start && currentend <= end) { index += 1 continue } // case2. 部分相交,该 rangevalue 的大的一侧在传入的范围内,而小的一侧不在。 // 需要做切分操作,以重叠的位置作为切分点,比较小的一侧(不重叠的部分)重新插入, // 比较大的的那一部分,会被传入的值覆盖掉 if (currentstart < start) { // 如果值不一样,则以相交的位置作为切分点,非重叠部分重新插入,重叠部分用待插入的值覆盖。 if (currentvalue !== value) { this._insert(currentstart, start - 1, currentvalue) } else { start = currentstart } } // case3. 部分相交,该 rangevalue 的小的一侧在传入的范围内,而大的一侧不在。 // 同 case 2 做切分操作,只是反向。 if (currentend > end) { if (currentvalue !== value) { this._insert(end + 1, currentend, currentvalue) } else { end = currentend } } index += 1 } } this._insert(start, end, value) } setvalue(index: number, value: t) { this.setrangevalue(index, index, value) } /** * 筛选出与指定区间有重叠的 rangevalue,即: * * 1. 相互部分重叠 * * o----------o o---------o * >start------------------end< * * * 2. 相互完全重叠关系 * * o----------------o * >start--------end< * * * 3. 包含或被包含关系 * * o--------------------------------------o * o-------------------------------o * o-------------------------------o * o-----o o-----o o----o * >start--------------------end< * */ treeintersecting(start: number, end: number): rangevalue[] { const startrange = new range(start) const endrange = new range(end) return this.valuetree.searchrange(startrange, endrange) } /** * 返回指定范围内所有 rangevalue * 范围内有无值的 range 的话,则使用 * 携带默认值的 rangevalue 代替 * 从而确保返回的结果是线性的、每个区间都有值的,如: * * start>...<end 范围内有 a、b 两个 rangevalue,所有空洞都用 default 补足 * +-----------|-----|-----------|-----|-----------+ * | default | a | default | b | default | * >start------|-----|-----------|-----|--------end< * */ intersecting(start: number, end: number): rangevalue[] { const ranges = this.treeintersecting(start, end) if (!ranges.length) { if (!this.size) return [] return [ new rangevalue(start, end, this.defaultvalue) ] } let result = [] let range let index = 0 let length = ranges.length while (index < length) { range = ranges[index].clone() // 传入的 (start, end) 右侧与某个 rangevalue 重叠, // 左侧没有命中,则左侧区域手动塞入一个携带默认 // 值的 rangevalue if (range.start > start) { result.push(new rangevalue(start, range.start - 1, this.defaultvalue)) } result.push(range) // 将 start 的位置右移, // 以便下个 range 的比较 start = range.end + 1 index += 1 } // 如果最后一个 range,与传入的范围只有左侧重叠, // 而右侧没有重叠的地方,则手动塞入一个携带默认值 // 的 rangevalue if (range.end < end) { result.push(new rangevalue(range.end + 1, end, this.defaultvalue)) } else if (range.end > end) { // 否则如果最后一个 range 的范围已经超出需要的范围,则裁剪 range.end = end } return result } values() { if (!this.size) return [] return this.intersecting(0, this.size - 1) } _insert(start: number, end: number, value: t) { if (value !== this.defaultvalue) { const rangevalue = new rangevalue(start, end, value) this.valuetree.insert(rangevalue) } } } export function create<t>(size: number, value: t) { return new sparserangelist(size, value) }
有了这套存储机制之后,我们就可以更高效地管理列表项的高度,和统计列表高度了。
看代码理解:
import { create as createsparserangelist } from './sparserangelist' // 创建一个默认预估高度为 20 的列表项存储对象 const itemheightstore = createsparserangelist(wrappeditems.length, 20) // 设置第二项为 40px itemheightstore.setvalue(1, 40) // 获取第二项的高度 itemheightstore.getvalueat(1) // 40 // 获取列表项的 top 坐标 const top = (index: number): number => { if (index === 0) return 0 // 0 ~ 上一项的高度累加 const rangevalues = itemheightstore.intersecting(0, index - 1) const sumheight = rangevalues.reduce((sum: number, rangevalue: any) => { const span = rangevalue.end - rangevalue.start + 1 return sum + rangevalue.value * span }, 0) return sumheight } top(1) // 20 // 计算列表总高度: const listheight = itemheightstore .values() .reduce((acc: number, rangevalue: any) => { const span = rangevalue.end - rangevalue.start + 1 const height = rangevalue.value * span return acc + height }, 0)
计算可视条目
完成了列表项高度的管理,接下来需要解决的重点,就是计算出哪些条目是可视的。
最简单的实现方式,就是直接遍历我们的结点高度存储列表,逐个去跟视口的坐标区间比较,过滤出落在(或部分落在)视口内部的条目。 基于性能考虑,我们当然不能这么简单粗暴。我们可以做以下尝试来提高性能:
一、预估起点条目 + 二分法修正。
通过条目的预估高度或默认高度,算出可能出现在视口的第一条条目。 比如,我们视口上沿坐标(即滚动条滚过的距离)为 100px,我们条目预估高度为 20px,那么,我们可以猜测第一个出现在视口中的条目为 100 / 20 + 1,即第 6 条。 我们直接计算第 6 条的坐标,检查是否落在视口中,根据结果差距,再进行二分法猜测,直到找到真正的起点条目。
二、预估终点条目 + 二分法修正
在算出起点条目后,在使用视口高度除以预估条目高度,算出视口内部可能显示多少项,将起点序号加上这个数量,就是预估的终点条目序号。使用上述一样的修正逻辑,直到找到正确的视口终点条目。
描述可能比较难以理解,下面给出关键片段:
// 内部方法,计算局部渲染数据切片的起止点 private _calcslicerange() { if (!this.dataview.length) { return { slicefrom: 0, sliceto: 0 } } // 数据总量 const max = this.dataview.length // 视口上边界 const viewporttop = (this.$refs.viewport as any).scrolltop || 0 // 视口下边界 const viewportbottom = viewporttop + this.viewportheight // 预估条目高度 const estimateditemheight = this.defaultitemheight // 从估算值开始计算起始序号 let slicefrom = math.floor(viewporttop / estimateditemheight!) if (slicefrom > max - 1) slicefrom = max - 1 while (slicefrom >= 0 && slicefrom <= max - 1) { const itemtop = this._top(slicefrom) // 条目顶部相对于 viewport 顶部的偏移 const itemoffset = itemtop - viewporttop // 1. 该条目距离视口顶部有距离,说明上方还有条目元素需要显示,继续测试上一条 if (itemoffset > 0) { // 二分法快速估算下一个尝试位置 const diff = itemoffset / estimateditemheight! slicefrom -= math.ceil(diff / 2) continue } // 2. 恰好显示该条目的顶部,则该条目为本次视口的首条元素 if (itemoffset === 0) break // 以下都是 itemoffset < 0 const itemheight = this._itemheight(slicefrom) // 3. 该条目在顶部露出了一部分,则该条目为本次视口的首条元素 if (itemoffset < itemheight) break // 4. 该条目已被滚出去视口,继续测试下一条 // 二分法快速估算下一个尝试位置 const diff = -itemoffset / estimateditemheight! slicefrom += math.ceil(diff / 2) } // 从估算值开始计算结束序号 let sliceto = slicefrom + 1 + math.floor(this.viewportheight / estimateditemheight!) if (sliceto > max) sliceto = max while (sliceto > slicefrom && sliceto <= max) { const itemtop = this._top(sliceto) const itemheight = this._itemheight(sliceto) const itembottom = itemtop + itemheight // 条目底部相对于 viewport 底部的偏移 const itemoffset = itembottom - viewportbottom // 1. 该条目的底部距离视口底部有距离,说明下方还有条目元素需要显示,继续测试下一条 if (itemoffset < 0) { // 二分法快速估算下一个尝试位置 const diff = -itemoffset / estimateditemheight! sliceto += math.ceil(diff / 2) continue } // 2. 恰好显示该条目的底部,则该条目为视口中最后一项 if (itemoffset === 0) break // 3. 该条目在底部被裁剪了一部分,则该条目为本次视口的末项 if (itemoffset < itemheight) break // 该条目还未出场,继续测试上一条 // 二分法快速估算下一个尝试位置 const diff = itemoffset / estimateditemheight! sliceto -= math.ceil(diff / 2) } // slice 的时候,不含 end,所以 + 1 sliceto += 1 return { slicefrom, sliceto } }
以上就是计算可视区域的核心部分。完整的代码,会在后续给出。
dom 更新
由于我们是使用 vue 来实现虚拟列表的,所以 dom 的更新方面,可以省去大量繁琐的细节管理。 我们只需要关心列表滚动到某处之后,如何计算出当前视口应该出现哪些条目即可。
尽管如此,考虑到滚动的流畅性,以及 ie11 等浏览器的 dom 操作性能,我们不得不多做很多事情。
批量 dom 操作
我们可以在 ie11 的开发者工具面板中看到,滚动过程,频繁地往虚拟列表首尾插入、移除结点,会带来非常严重的性能问题。 所以,我们必须控制 dom 操作的频率。
批量可以部分解决这个问题。
具体的思路是,在滚动回调中,我们计算出可视区域的结点起止序号,不直接应用,而是加上一个额外渲染的数量。 比如我们计算出当前应该渲染 20 ~ 30 这些条目,我们可以在前后各加上 10 个额外渲染的条目,即 10 ~ 40,这样就一次性渲染了 30 个结点。在继续滚动时,我们检查新的起止范围,是否还在 10 ~ 40 范围内,如果是,我们就不做新的结点增删操作。
核心实现:
// 刷新局部渲染数据切片范围 private _updateslicerange(forceupdate?: boolean) { // 上下方额外多渲染的条目波动量 const count = this._prerenderingcount() // 预渲染触发阈值 const threshold = this._prerenderingthreshold() // 数据总量 const max = this.dataview.length // 计算出准确的切片区间 const range = this._calcslicerange() // 检查计算出来的切片范围,是否被当前已经渲染的切片返回包含了 // 如果是,无需更新切片,(如果 forceupdate,则无论如何都需要重新切片) let fromthreshold = range.slicefrom - threshold if (fromthreshold < 0) fromthreshold = 0 let tothreshold = range.sliceto + threshold if (tothreshold > max) tothreshold = max // 无需强制刷新,且上下两端都没有触达阈值时,无需重新切片 if (!forceupdate && ((this.slicefrom <= fromthreshold) && (this.sliceto >= tothreshold))) { return } // 下面是更新切片的情况 // 在切片区间头部、尾部,追加预渲染的条目 let { slicefrom, sliceto } = range slicefrom = slicefrom > count ? slicefrom - count : 0 sliceto = sliceto + count > max ? max : sliceto + count this.slicefrom = slicefrom this.sliceto = sliceto if (forceupdate) this._doslice() }
使用了这种批量操作之后,可以看到,正常的鼠标滚动下,ie 也能比较顺畅地滚动了。
事件
由于虚拟列表的 dom 需要不停地生成和销毁,因此,直接在列表项目上绑定事件是非常低效的。 所以,使用事件代理就成了很不错的方案,将事件注册在组件根结点上,再根据 event.target 来区分是由哪个列表项冒泡出来的事件,即可高效处理。
组件实现
import { component, vue, prop, watch } from 'vue-property-decorator' import { createsparserangelist } from './sparserangelist' // 列表项数据包裹,data 字段存放原始数据 // 组件所有操作不应该改变 data 的内容,而是修改该包裹对象的属性 class itemwrapper { // 原始数据 data: any // 数据唯一 key key: any // 条目高度 // 1. 正数代表已经计算出来的高度 // 2. 0 代表未计算的高度,不显示 // 3. 负数代表需要隐藏的高度,绝对值为已经计算出来的高度,方便取消隐藏 height: number // 记录是否已经根据实际 dom 计算过高度 realheight: boolean // 条目在当前过滤视图中的序号 viewindex: number constructor(data: any, key: any, height: number) { this.data = data // 数据的唯一id,是初始化数据时候的序号 // 每次传入的 data 改变,都会重新生成 this.key = key // 条目的高度缓存 // 1. 用于重建高度存储时快速恢复 // 2. 用于快速通过数据取高度 this.height = height >> 0 this.realheight = false // 每次生成 dataview 都刷新 this.viewindex = -1 } } @component({ name: 'vlist' }) export default class vlist extends vue { [key: string]: any // 高度存储 不响应式 private itemheightstore: any // 组件宽度,不设置则为容器的 100% @prop({ type: number }) private width?: number // 组件高度,不设置则为容器的 100% @prop({ type: number }) private height?: number // 传入高度值,固定条目高度 @prop({ type: number }) private fixeditemheight?: number // 预估元素高度, // 在高度不确定的列表中,未计算出高度时使用, // 该值与元素平均高度越相近,则越高效(修正时估算次数越少) @prop({ type: number, default: 30 }) private estimateditemheight!: number // 数据列表 @prop({ type: array, default: () => ([]) }) private data!: any[] // 计算条目高度的方法 @prop({ type: function, default(node: node, wrappeddata: itemwrapper) { return (node as htmlelement).clientheight } }) private itemheightmethod!: (node: node, wrappeditem: itemwrapper) => number // 数据过滤方法(可以用于外部实现搜索框过滤) @prop({ type: function }) private filtermethod?: (data: any) => boolean // 数据排序方法(可以用于外部实现数据自定义过滤) @prop({ type: function }) private sortmethod?: (a: any, b: any) => number // 包裹后的数据列表(必须 freeze,否则大列表性能撑不住) private wrappeddata: readonlyarray<itemwrapper> = object.freeze(this._wrapdata(this.data)) // 真实渲染上屏的数据列表切片 private dataslice: readonlyarray<itemwrapper> = [] // viewport 宽度 private viewportwidth = this.width || 0 // viewport 高度 private viewportheight = this.height || 0 // 当前 viewport 中第一条数据的序号 private slicefrom = 0 // 当前 viewport 中最后一条数据的序号 private sliceto = 0 // 列表高度 private listheight = 0 // 检查是否固定高度模式 private get isfixedheight() { return this.fixeditemheight! >= 0 } // 获取默认条目高度 private get defaultitemheight() { return this.isfixedheight ? this.fixeditemheight! : this.estimateditemheight } // 当前筛选条件下的数据列表 // 依赖:wrappeddata, filtermethod, sortmethod private get dataview() { const { wrappeddata, filtermethod, sortmethod } = this let data = [] if (typeof filtermethod === 'function') { const len = wrappeddata.length for (let index = 0; index < len; index += 1) { const item = wrappeddata[index] if (filtermethod(item.data)) { data.push(item) } } } else { data = wrappeddata.map(i => i) } if (typeof sortmethod === 'function') { data.sort((a, b) => { return sortmethod(a, b) }) } // 重新记录数据在视图中的位置,用于隐藏部分条目时,可以精确计算高度、坐标 const size = data.length for (let index = 0; index < size; index += 1) { const wrappeditem = data[index] wrappeditem.viewindex = index } return object.freeze(data) } // 原始列表数据变化,重新包裹数据 @watch('data') private ondatachange(data: any[]) { this.wrappeddata = object.freeze(this._wrapdata(data)) } // 当前过滤、排序视图变化,重新布局 @watch('dataview') private ondataviewchange(wrappeditems: itemwrapper[]) { // 重建高度存储 const estimateditemheight = this.defaultitemheight this.itemheightstore = createsparserangelist(wrappeditems.length, estimateditemheight) // 从缓存中快速恢复已计算出高度的条目的高度 wrappeditems.foreach((wrappeditem, index) => { // 小于零的需要隐藏,所以高度为 0 this.itemheightstore.setvalue(index, wrappeditem.height > 0 ? wrappeditem.height : 0) }) // 刷新列表高度 this.updatelistheight() // 重置滚动位置 // todo, 锚定元素 const { viewport } = this.$refs as any if (viewport) viewport.scrolltop = 0 // 重新切片当前 viewport 需要的数据 this._updateslicerange(true) this.$emit('data-view-change', this.dataslice.map((wrappeditem) => wrappeditem.data)) } private created() { const estimateditemheight = this.defaultitemheight this.itemheightstore = createsparserangelist(this.dataview.length, estimateditemheight) this.layoutobserver = new mutationobserver(this.redraw.bind(this)) this.childobserver = new mutationobserver((mutations: mutationrecord[]) => { this._updateheightwheniteminserted(mutations) }) this.$watch(((vm: any) => `${vm.slicefrom},${vm.sliceto}`) as any, this._doslice) } private mounted() { this.redraw() this.layoutobserver.observe(this.$el, { attributes: true }) // 非固定高度场景,监听子元素插入,提取高度 if (!this.isfixedheight) { this.childobserver.observe(this.$refs.content, { childlist: true }) } } private beforedestory() { this.layoutobserver.disconnect() if (!this.isfixedheight) { this.childobserver.disconnect() } this.itemheightstore = null } // dom 结构比较简单,无需 template,直接使用渲染函数输出 vdom private render(createelement: any) { return createelement( 'div', // 组件容器,与外部布局 { class: 'vlist', style: { 'box-sizing': 'border-box', display: 'inline-block', margin: '0', padding: '0', width: this.width ? this.width + 'px' : '100%', height: this.height ? this.height + 'px' : '100%', } }, [ createelement( 'div', // 滚动区域的可见范围 { ref: 'viewport', class: 'vlist_viewport', style: 'box-sizing:border-box;position:relative;overflow:hidden;width:100%;height:100%;margin:0;padding:0;overflow:auto;overflow-scrolling:touch;', on: { scroll: this._onscroll } }, [ createelement( 'div', // 内容容器,内容真实高度由此容器体现 { class: 'vlist_scollable', ref: 'content', style: { 'box-sizing': 'border-box', position: 'relative', margin: '0', padding: '0', height: this.listheight + 'px' } }, // 列表项 this.dataslice.map((wrappeditem) => { return createelement( 'div', { key: wrappeditem.key, class: `vlist_item vlist_item-${wrappeditem.key % 2 === 0 ? 'even' : 'odd'}`, attrs: { 'data-key': wrappeditem.key }, style: { 'box-sizing': 'border-box', 'z-index': '1', position: 'absolute', right: '0', bottom: 'auto', left: '0', margin: '0', padding: '0', cursor: 'default', // 注:使用 transfrom 有黑屏 bug // transform: `translate(0, ${top})` // transform: `translate3d(0, ${top}, 0)` top: this._top(wrappeditem.viewindex) + 'px' } }, // 将原始数据,key 注入到 slot 里, // 以便自定义条目内容使用 this.$scopedslots.default!({ item: wrappeditem.data, listkey: wrappeditem.key }) ) }) ) ] ) ] ) } // 重绘界面,确保列表渲染正确 public redraw() { const viewport = this.$refs.viewport as htmlelement const { clientwidth, clientheight } = viewport this.viewportwidth = clientwidth this.viewportheight = clientheight this.updatelistheight() this._updateslicerange(true) } // 刷新列表总高度 public updatelistheight() { const { itemheightstore } = this const rangevalues = itemheightstore.values() if (!rangevalues.length) { this.listheight = 0 return } const listheight = rangevalues.reduce((sum: number, rangevalue: any) => { const span = rangevalue.end - rangevalue.start + 1 const height = rangevalue.value * span return sum + height }, 0) this.listheight = listheight } // dom 插入时候,计算高度,然后 // 批量刷新高度,避免频繁调整列表高度带来性能问题 public batchupdateheight(records: array<{ wrappeditem: itemwrapper, height: number }>) { records.foreach(({ wrappeditem, height }) => { this._updateheight(wrappeditem, height, true) }) this.updatelistheight() this._updateslicerange() } // 通过数据 key,设置对应条目的高度 public updateheightbykey(key: any, height: number) { const wrappeditem = this.wrappeddata[key] if (!wrappeditem) return this._updateheight(wrappeditem, height) this.updatelistheight() this._updateslicerange() } // 通过数据 key,设置对应条目的显示状态 public showbykey(key: any) { const wrappeditem = this.wrappeddata[key] if (!wrappeditem) return if (wrappeditem.height <= 0) { const height = -wrappeditem.height || this.defaultitemheight this._updateheight(wrappeditem, height!) this.updatelistheight() this._updateslicerange() // 强制重绘 this._doslice() } } // 通过数据 key,设置对应条目的显示状态 public hidebykey(key: any) { const wrappeditem = this.wrappeddata[key] if (!wrappeditem) return if (wrappeditem.height > 0) { const height = -wrappeditem.height wrappeditem.height = height this._updateheight(wrappeditem, height) this.updatelistheight() // 强制重绘 this._updateslicerange(true) } } // 通过数据 key 列表,设置对应条目的显示状态 public showbykeys(keys: any[]) { const wrappeditems = keys.map((key) => this.wrappeddata[key]) .filter((wrappeditem) => wrappeditem && wrappeditem.height <= 0) wrappeditems.foreach((wrappeditem) => { const height = (-wrappeditem.height || this.defaultitemheight)! this._updateheight(wrappeditem, height) }) this.updatelistheight() // 强制重绘 this._updateslicerange(true) } // 通过数据 key 列表,设置对应条目的显示状态 public hidebykeys(keys: any[]) { const wrappeditems = keys.map((key) => this.wrappeddata[key]) .filter(wrappeditem => wrappeditem && wrappeditem.height > 0) wrappeditems.foreach((wrappeditem) => { // 设置为负数,表示隐藏 const height = -wrappeditem.height wrappeditem.height = height this._updateheight(wrappeditem, height) }) this.updatelistheight() // 强制重绘 this._updateslicerange(true) } // 内部方法,计算局部渲染数据切片的起止点 private _calcslicerange() { if (!this.dataview.length) { return { slicefrom: 0, sliceto: 0 } } // 数据总量 const max = this.dataview.length // 视口上边界 const viewporttop = (this.$refs.viewport as any).scrolltop || 0 // 视口下边界 const viewportbottom = viewporttop + this.viewportheight // 预估条目高度 const estimateditemheight = this.defaultitemheight // 从估算值开始计算起始序号 let slicefrom = math.floor(viewporttop / estimateditemheight!) if (slicefrom > max - 1) slicefrom = max - 1 while (slicefrom >= 0 && slicefrom <= max - 1) { const itemtop = this._top(slicefrom) // 条目顶部相对于 viewport 顶部的偏移 const itemoffset = itemtop - viewporttop // 1. 该条目距离视口顶部有距离,说明上方还有条目元素需要显示,继续测试上一条 if (itemoffset > 0) { // 二分法快速估算下一个尝试位置 const diff = itemoffset / estimateditemheight! slicefrom -= math.ceil(diff / 2) continue } // 2. 恰好显示该条目的顶部,则该条目为本次视口的首条元素 if (itemoffset === 0) break // 以下都是 itemoffset < 0 const itemheight = this._itemheight(slicefrom) // 3. 该条目在顶部露出了一部分,则该条目为本次视口的首条元素 if (itemoffset < itemheight) break // 4. 该条目已被滚出去视口,继续测试下一条 // 二分法快速估算下一个尝试位置 const diff = -itemoffset / estimateditemheight! slicefrom += math.ceil(diff / 2) } // 从估算值开始计算结束序号 let sliceto = slicefrom + 1 + math.floor(this.viewportheight / estimateditemheight!) if (sliceto > max) sliceto = max while (sliceto > slicefrom && sliceto <= max) { const itemtop = this._top(sliceto) const itemheight = this._itemheight(sliceto) const itembottom = itemtop + itemheight // 条目底部相对于 viewport 底部的偏移 const itemoffset = itembottom - viewportbottom // 1. 该条目的底部距离视口底部有距离,说明下方还有条目元素需要显示,继续测试下一条 if (itemoffset < 0) { // 二分法快速估算下一个尝试位置 const diff = -itemoffset / estimateditemheight! sliceto += math.ceil(diff / 2) continue } // 2. 恰好显示该条目的底部,则该条目为视口中最后一项 if (itemoffset === 0) break // 3. 该条目在底部被裁剪了一部分,则该条目为本次视口的末项 if (itemoffset < itemheight) break // 该条目还未出场,继续测试上一条 // 二分法快速估算下一个尝试位置 const diff = itemoffset / estimateditemheight! sliceto -= math.ceil(diff / 2) } // slice 的时候,不含 end,所以 + 1 sliceto += 1 return { slicefrom, sliceto } } // 上下两端预先批量渲染的项目波动量 // 原理是,每次插入删除都是一个小批量动作, // 而不是每次只插入一条、销毁一条 // 计算出的局部渲染数据范围,跟上一次计算出来的结果,差距 // 在这个波动量范围内,则不重新切片渲染,用于 // 防止 ie 11 频繁插入内容导致性能压力 private _prerenderingcount() { // 默认预渲染 2 屏 return math.ceil(this.viewportheight / this.defaultitemheight!) * 2 } // 滚动到上下方剩下多少个条目时,加载下一批 // 缓解 macbook & ios 触摸滚动时的白屏 private _prerenderingthreshold() { // 默认触达预渲染的一半数量时,加载下一批切片 return math.floor(this._prerenderingcount() / 2) } // 刷新局部渲染数据切片范围 private _updateslicerange(forceupdate?: boolean) { // 上下方额外多渲染的条目波动量 const count = this._prerenderingcount() // 预渲染触发阈值 const threshold = this._prerenderingthreshold() // 数据总量 const max = this.dataview.length // 计算出准确的切片区间 const range = this._calcslicerange() // 检查计算出来的切片范围,是否被当前已经渲染的切片返回包含了 // 如果是,无需更新切片,(如果 forceupdate,则无论如何都需要重新切片) let fromthreshold = range.slicefrom - threshold if (fromthreshold < 0) fromthreshold = 0 let tothreshold = range.sliceto + threshold if (tothreshold > max) tothreshold = max // 无需强制刷新,且上下两端都没有触达阈值时,无需重新切片 if (!forceupdate && ((this.slicefrom <= fromthreshold) && (this.sliceto >= tothreshold))) { return } // 更新切片的情况 // 在切片区间头部、尾部,追加预渲染的条目 let { slicefrom, sliceto } = range slicefrom = slicefrom > count ? slicefrom - count : 0 sliceto = sliceto + count > max ? max : sliceto + count this.slicefrom = slicefrom this.sliceto = sliceto if (forceupdate) this._doslice() } // 当前需要渲染的数据切片 private _doslice() { const { dataview, slicefrom, sliceto } = this const slice = dataview.slice(slicefrom, sliceto) .filter((wrappeditem) => wrappeditem.height > 0) this.dataslice = object.freeze(slice) this.$emit('slice', slice.map((wrappeditem) => wrappeditem.data)) } // `index` 数据在 dataview 中的 index private _itemheight(index: number): number { return this.itemheightstore.getvalueat(index) } // `index` 数据在 dataview 中的 index private _top(index: number): number { if (index === 0) return 0 // 0 ~ 上一项的高度累加 const rangevalues = this.itemheightstore.intersecting(0, index - 1) const sumheight = rangevalues.reduce((sum: number, rangevalue: any) => { const span = rangevalue.end - rangevalue.start + 1 return sum + rangevalue.value * span }, 0) return sumheight } // 包裹原始数据列表 private _wrapdata(list: any[]): itemwrapper[] { return list.map((item, index) => new itemwrapper(item, index, this.defaultitemheight!)) } // 通过 dom node 获取对应的数据 private _getdatabynode(node: node): itemwrapper { return this.wrappeddata[(node as any).dataset.key] } // 刷新列表项高度 private _updateheight(wrappeditem: itemwrapper, height: number, isrealheight?: boolean) { height = height >> 0 // 更新结点高度缓存 wrappeditem.height = height if (isrealheight) { wrappeditem.realheight = true } // 如果 wrappeditem 为当前过滤下的项目, // 则同时刷新高度存储 store const index = this.dataview.indexof(wrappeditem) if (index !== -1) { // 小于等于零表示折叠不显示,计算高度为零 // 负值存在 wrappeditem 中,用于反折叠时恢复 this.itemheightstore.setvalue(index, height > 0 ? height : 0) } } // 节点插入时,检查是否首次插入,如果是,计算高度并更新对应的 itemwrapper private _updateheightwheniteminserted(mutations: mutationrecord[]) { const addednodes: node[] = mutations .map((mutation: mutationrecord) => mutation.addednodes) .reduce((result: any, items: nodelist) => { result.push(...items) return result }, []) const batch: array<{ wrappeditem: itemwrapper, height: number }> = [] addednodes.foreach((node: node) => { const wrappeditem = this._getdatabynode(node) // 如果 wrappeditem 中已经存储了计算过的高度, // 则直接返回,不访问 clientheight // 以避免性能开销(ie 11 中访问 clientheight 性能非常差) if (wrappeditem.realheight) { return } const height = this.itemheightmethod(node, wrappeditem) >> 0 if (wrappeditem.height !== height) { batch.push({ wrappeditem, height }) } else { // 计算出来的高度跟默认值一致, // 则无需更新,但是设置已经计算状态 // 以便下次可以直接使用缓存 wrappeditem.realheight = true } }) if (batch.length) { this.batchupdateheight(batch) } } // 滚动事件处理器 private _onscroll() { this._updateslicerange() } }
总结
以上所说是小白给大家介绍的使用 vue 实现一个虚拟列表的方法,希望对大家有所帮助
推荐阅读
-
vue中使用cookies和crypto-js实现记住密码和加密的方法
-
使用jquery.validate自定义方法教程实现手机号码或者固话至少填写一个的逻辑验证
-
基于vue框架手写一个notify插件实现通知功能的方法
-
实现一个简单的vue无限加载指令方法
-
nginx 配置虚拟主机,实现在一个服务器可以访问多个网站的方法
-
关于Vue中,使用watch同时监听两个值的实现方法
-
在vue.js中使用JSZip实现在前端解压文件的方法
-
Android编程实现在Activity中操作刷新另外一个Activity数据列表的方法
-
Vue+Koa2+mongoose写一个像素绘板的实现方法
-
vue使用pdfjs显示PDF可复制的实现方法