常见问题
JSON中key不能用number类型
- JSON中使用数字作为Key带来的问题,浏览器解析JSON会报错,JSON中key不能用number类型
- json格式如何出现number作为key的话,解析会出现问题
JSON序列化的时候,使用JSON.parse解析会报错
iframe形式嵌套,如何通信?
- 父子级跨域通信使用postMessage
- window.postMessage()方法可以安全地实现跨源通信
- postMessage()方法允许来自不同源的脚本采用异步方式进行有效的通信
- postMessage跨域通信的一种有效的解决方案
多层嵌套iframe会有什么问题
获取父级地址以及域名
- 获取父级地址URL
var parentUrl = window.location !== window.parent.location ? document.referrer : window.parent.location.href;
- 获取父级域名
ancestorOrigins是一个只读属性,可以返回一个 DOMString 类型的数组,其中包含了当前文档的所有祖先(父亲的祖先,包括祖先的祖先)。如果一个文档在多个嵌套的 iframe 中,则ancestorOrigins返回一个包含所有祖先来源的数组,按祖先嵌套关系顺序排列,第一个元素是当前文档的直接父亲的来源,最后一个元素是最上层祖先的来源
var parentOrigin = window.location.ancestorOrigins && window.location.ancestorOrigins.length > 0 ? window.location.ancestorOrigins[0] : document.referrer;
请求竞态问题
- 请求竞态在项目中,比较常见的一个问题,在异步请求或者并发请求的时候,需要考虑的问题
- 请求竞态:比如多个请求去获取数据的时候,由于网络问题或者其他的不确定原因,可能会导致先发出去的请求不一定先响应,导致数据的不一致性,资源争夺问题
场景:
- 下拉搜索:发起了多次请求,而上一次请求还没响应,而下个请求就已经响应完了,然后上一个请求响应之后,会覆盖下个请求,导致下个请求的数据被覆盖, 因此需要取消上一个请求未完成
post请求传递数据过多?body承载数据量大,影响的问题
- 当Post Body中的数据量过大的时候(超过maximum request length)
确保精度不缺失
-
可使用mathjs、number-precision来解决
- number-precision更轻量
-
或者
(0.07/100).toFixed(String(0.07).length) //确保精度不丢失
精确保留小数点位数,解决toFixed保留小数四舍五入问题
- 解决toFixed保留小数由于四舍五入导致不准确问题
function toFixed(num, len = 2){
num = String(num)
len = parseInt(len)
const pad = new Array(len + 1).join('0')
//避免出现科学技术法 浏览器中,小于1e-7的数字都会自动转成科学记数法
if (num.indexOf('e-') > 0) {
num = '0' + String(Number(num) + 1).substr(1)
}
const arr = num.split('.')
if (len < 1) {
return arr[0]
}
arr[1] = (arr.length < 2 ? pad : arr[1] + pad).substr(0, len)
return arr.join('.')
}
js如何解决数值精度问题
先看下面这个例子
比如一个数 1÷3=0.33333333......
3会一直无限循环,数学可以表示,但是计算机要存储,方便下次取出来再使用,但0.333333...... 这个数无限循环,再大的内存它也存不下,所以不能存储一个相对于数学来说的值,只能存储一个近似值,当计算机存储后再取出时就会出现精度丢失问题
解决方式:
- 1、使用toFixed,但是这种虽然简便,但是存在一些结果不精准的问题,toFixed四舍五入是有bug的
- 2、化整数运算,主要思路就是把,小数转化为整数再进行计算 【因为大多数小数的二进制都是无限循环的,而整数不会出现精度问题】
- 3、使用第三方库
化整数运算例子
function add(a, b) {
const precision = Math.max(getPrecision(a), getPrecision(b));
// ** 是幂运算符,也叫指数运算符,它可以用于计算一个数的 n 次方。例如,2 ** 3 表示计算 2 的 3 次方,结果为 8。 可以用来替代 Math.pow() 方法
// 10 的小数位数次方,得到一个整数倍数
const multiplier = 10 ** precision;
const intA = Math.round(a * multiplier);
const intB = Math.round(b * multiplier);
const intResult = intA + intB;
const floatResult = intResult / multiplier;
return floatResult;
}
// 获取小数位数
function getPrecision(num) {
const str = String(num);
const index = str.indexOf('.');
return index === -1 ? 0 : str.length - index - 1;
}
add(0.1, 0.2); // 0.3
react函数中防抖失效解决
原因:每次render 都会导致debounce第一个参数(如异步请求函数)更新,进而导致debounce的闭包失效
解决:
- 在class中,可在构造函数绑定防抖函数,确保render渲染不会重新创建函数,只在组件实例化执行一次绑定防抖函数
- hook中,可使用useCallback,防止重复渲染
取消请求
- axios.cancelToken
- fetch中断请求,可以使用原生的AbortController接口
- umi-request中断请求:umi-request 基于 fetch 封装, 兼具 fetch 与 axios 的特点, 中止请求与 fetch 和 axios 一致,既可以使用cancelToken也可以使用AbortController
文件分片上传问题
参考:https://juejin.cn/post/6982877680068739085#heading-6
前端处理文件下载(带请求头)
原因是后台网关校验通过请求头拿token,原来的操作无法实现,原来是通过url传递token
import { message } from 'antd'
function createObjectURL (object) {
return window.URL ? window.URL.createObjectURL(object) : window.webkitURL.createObjectURL(object)
}
async function fileDownload ({ token, url, title }) {
return new Promise((resolve, reject) => {
var xhr = new XMLHttpRequest()
var formData = new FormData()
xhr.open('get', url) //url填写后台的接口地址,如果是post,在formData append参数
xhr.setRequestHeader('access-token', token)
xhr.responseType = 'blob'
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
xhr.onload = function (e) {
const dis = xhr.getResponseHeader('Content-Disposition')
if (this.status == 200) {
var blob = this.response
var filename = `${title}.xls`
console.log(this.response)
if (window.navigator.msSaveOrOpenBlob) {
navigator.msSaveBlob(blob, filename)
} else {
var a = document.createElement('a')
var URL = createObjectURL(blob)
console.log('URL', url)
a.href = URL
//文件名解码
a.download = decodeURIComponent(dis.split('attachment;filename=')[1])
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(URL)
}
resolve(true)
}
}
xhr.onerror = err => {
message.error('下载失败,请重新尝试')
reject(err)
}
xhr.send(formData)
})
}
export default fileDownload
单点登录(SSO)
单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一
SSO的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统
SSO 一般都需要一个独立的认证中心(passport),子系统的登录均得通过passport,子系统本身将不参与登录操作
当一个系统成功登录以后,passport将会颁发一个令牌给各个子系统,子系统可以拿着令牌会获取各自的受保护资源,为了减少频繁认证,各个子系统在被passport授权以后,会建立一个局部会话,在一定时间内可以无需再次向passport发起认证
1. 同域名下的单点登录
cookie的 domain属性设置为当前域的父域,并且父域的 cookie会被子域所共享。path属性默认为web应用的上下文路径
利用 Cookie 的这个特点,我们只需要将Cookie的domain属性设置为父域的域名(主域名),同时将 Cookie的path属性设置为根路径,将 Session ID(或 Token)保存到父域中。这样所有的子域应用就都可以访问到这个 Cookie
不过这要求应用系统的域名需建立在一个共同的主域名之下,如 tieba.baidu.com 和 map.baidu.com,它们都建立在 baidu.com这个主域名之下,那么它们就可以通过这种方式来实现单点登录
2、不同域名下的单点登录
- 1、用户访问app1系统,app1系统是需要登录的,但用户现在没有登录。**重定向到sso认证中心,**并将自己的地址作为参数。
- 2、用户填写用户名、密码,SSO系统进行认证后,将登录状态写入SSO的session
- 3、认证中心重定向回app系统,并把Token携带过去给app1
重定向的地址如:
www.xxx.com?token=xxxxxxx
部署发版文件缓存问题/小程序缓存解决方法
原因: 一些文件,比如配置文件,每次修改,部署发版,都导致因为缓存,拿不到最新的文件,可能会报错或者拿不到最新的数据
最主要的目的就是为了让微信小程序识别出,当前的文件和缓存的不同,重新清除缓存,拿到最新的数据。
- 1、打包的静态文件,生成随机数hash
- 2、配置文件加上参数随机数,每次发版部署请求文件都会是拿到最新的文件
如下例子:
- privateConfig是配置文件
<script
type="text/javascript"
src="privateConfig.js?t=<%=+new Date()%>"
></script>
privateConfig.js
- 通过配置文件,生成配置,修改配置可发布多个不同配置的包
- 即一次发版 可兼容 多个不同环境
window.privateConfig = {
BACKEND_BASE:'后端地址',
APP_KEY:'应用key'
}
文件上传问题
文件上传,如何获取到读取进度?
大文件上传
大文件上传, 支持分片上传和断点续传,如果分片的上传时候,用户突然刷新了或者网络故障,不应该从头开始上传,用户体验很不好,需要支持断点续传
每次切片上传的时候,后端都会返回当前文件的进度,如果突然手动刷新了或者网络故障,就根据这个进度拿到后面的切片重新继续上传
上传的页面,每次刷新后,后端返回最近一次已经上传那些切片,前端就根据后端返回那些切片,过滤掉,继续上传后面的切片即可
一个大文件 切割数组 [file1,file2,file3,file4] ,后端返回前面[file1,file2]成功上传的
- 每个切片文件可以给个id,返回只需要返回成功上传的文件id即可
上传大文件时,以下几个变量会影响我们的用户体验
- 服务器处理数据的能力
- 请求超时
- 网络波动
分片上传:
分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分隔成多个数据块(Part)来进行分片上传,上传完之后再由服务端对所有上传的文件进行汇总整合成原始的文件
大致流程如下:
- 将需要上传的文件按照一定的分割规则,分割成相同大小的数据块;
- 初始化一个分片上传任务,返回本次分片上传唯一标识;
- 按照一定的策略(串行或并行)发送各个分片数据块;
- 发送完成后,服务端根据判断数据上传是否完整,如果完整,则进行数据块合成得到原始文件
断点续传:
1、断点续传指的是在下载或上传时,将下载或上传任务人为的划分为几个部分
2、每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载。用户可以节省时间,提高速度
一般实现方式有两种:
- 服务器端返回,告知从哪开始
- 浏览器端自行处理
上传过程中将文件在服务器写为临时文件,等全部写完了(文件上传完),将此临时文件重命名为正式文件即可
如果中途上传中断过,下次上传的时候根据当前临时文件大小,作为在客户端读取文件的偏移量,从此位置继续读取文件数据块,上传到服务器从此偏移量继续写入文件即可
实现思路:
整体思路比较简单,拿到文件,保存文件唯一性标识,切割文件,分段上传,每次上传一段,根据唯一性标识判断文件上传进度,直到文件的全部片段上传完毕
树状结构展开大量数据时,出现卡顿情况
解决的方案
方案1、虚拟列表滚动,滚动到可视区域的时候 就会添加到节点,不在可视区域的话 就会删掉节点
如果你的应用存在非常长或者无限滚动的列表,那么需要采用 窗口化 的技术来优化性能,只需要渲染少部分区域的内容,减少重新渲染组件和创建 dom 节点的时间。 你可以参考以下开源项目 vue-virtual-scroll-list 和 vue-virtual-scroller 来优化这种无限列表的场景的。
虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。- 假设有1万条记录需要同时渲染,我们屏幕的
可见区域的高度为500px,而列表项的高度为50px,则此时我们在屏幕中最多只能看到10个列表项,那么在首次渲染的时候,我们只需加载10条即可。
虚拟列表滚动实现
参考:https://github.com/dwqs/blog/issues/70
虚拟列表定义:只渲染可视区域的列表,非可见区域中的数据不渲染,大大的提高了渲染的性能
实现虚拟列表就是在处理用户滚动时,要改变列表在可视区域的渲染部分
明确两个概念:可视区域、可滚动区域
可滚动区域 = 计算真实容器高度 = 数据总数 * 每条 item 的高度
-
首先需要用变量来保存当前完整的列表数据、以及渲染的列表数据
-
1、需要计算可视区域起始数据的位置(可视区域第一项位置)startIndex
- 假设元素的高度是固定的:那么startIndex = scrollTop (滚动条的高度) / itemHeight (列表项的高度)
-
2、需要计算可视区域结束数据(可视区最后一个数据的位置)endIndex
- endIndex = startIndex +(clientHeight/itemHeight)
-
3、计算当前可视区域的数据,渲染到可视区域中
-
4、计算可视区域第一项位置 startIndex 对应的数据在整个列表中的偏移位置 startOffset,并设置到列表上
- 刚开始渲染的时候,startOffset默认是0
- 当开始滚动的时候,startOffset = 可视区域第一个元素 到 列表的第一个元素 的距离
-
5、计算 endIndex 对应的数据在整个列表中的偏移位置 endOffset,并设置到列表上
- 刚开始渲染的时候,endOffset默认是0
- 当开始滚动的时候,endOffset =(整个列表的数据长度 - endIndex (可视区域最后一个元素的位置) )* height(列表高度)
startOffset和endOffset是一个隐藏的区域,会撑开容器元素的内容高度,让其可持续的滚动
- 刚开始渲染的时候,endOffset默认是0
注意:
如果元素高度不是固定,那么如何计算startIndex?
- 给每一个item绑定一个方法(可以利用事件委托 【将一个元素的响应事件委托到父元素上或者外层元素】)
- 当节点加载渲染后调用此方法来动态获取元素高度,并且将startIndex开始位置和height高度信息缓存保存起来
- 缓存的作用是 避免重复计算
- 当节点加载渲染后调用此方法来动态获取元素高度,并且将startIndex开始位置和height高度信息缓存保存起来
axios请求超时时间卡顿处理
与服务器进行数据交互。在使用过程中会有许多问题存在,比如由于网络不稳定导致请求超时/失败,通常有两种解决方案,一种是提示用户重新提交请求,另一种是进行相关提示并自动重新发送请求
遇到问题:
- 如果没有设置请求超时时间,如果网络不稳定导致请求超时/失败
- 通过axios.defaults.timeout设置默认的请求超时时间。例如超过了10s,就会告知用户当前请求超时,请刷新等。cancelToken取消请求
应用场景:当用户重新刷新数据请求的时候,如果你之前发起的请求列表还没有响应,这时候如果你重新发起请求,会出现二次请求的情况,可以通过cancelToken可以取消上一次请求
作用:支持取消请求 (cancelToken)
- 如果在短时间内发生多个请求,之前发起的请求还没有响应,这时重新请求,会出现二次请求(多次请求情况),可能会导致导致获取数据不匹配 (获取了上一次请求的数据),可以通过cancelToken可以取消上一次请求
- 搜索栏的情况
处理文件下载(带请求头)
文件下载通常使用a标签进行处理,本次介绍的是通过blob来实现流文件下载,原因是后台网关校验通过请求头拿token
import { message } from 'antd'
function createObjectURL (object) {
return window.URL ? window.URL.createObjectURL(object) : window.webkitURL.createObjectURL(object)
}
async function fileDownload ({ token,method='get', url,data,title }) {
return new Promise((resolve, reject) => {
var xhr = new XMLHttpRequest()
xhr.open(method, url)
xhr.setRequestHeader('access-token', token)
xhr.responseType = 'blob'
xhr.setRequestHeader('Content-Type', 'application/json')
xhr.onload = function (e) {
const dis = xhr.getResponseHeader('Content-Disposition')
if (this.status == 200) {
var blob = this.response
var filename = `${title}.xls`
console.log(this.response)
if (window.navigator.msSaveOrOpenBlob) {
navigator.msSaveBlob(blob, filename)
} else {
var a = document.createElement('a')
var URL = createObjectURL(blob)
console.log('URL', url)
a.href = URL
//文件名解码
a.download = dist?decodeURIComponent(dis.split('attachment;filename=')[1]):title
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(URL)
}
resolve(true)
}else{
reject(false)
}
}
xhr.onerror = err => {
message.error('下载失败,请重新尝试')
reject(err)
}
xhr.send(JSON.stringify(data))
})
}
export default fileDownload
encodeURIComponent/decodeURIComponent【解决编码乱码问题】
场景1:token
- 在token存储,取值存值的时候,可能会造成token的长度和实际的长度不一样,这时候需要使用encodeURIComponent来存储,decodeURIComponent取值
如何在循环中遍历promise
- promise.all
- for...of, await关键字
获取url地址参数
使用 URL 对象
searchParams属性来获取查询参数。
const url = new URL(window.location.href);
const param1Value = url.searchParams.get("param1");
const param2Value = url.searchParams.get("param2");
手动解析查询字符串
- 将url字符串进行切割键值对
- 然后再循环遍历
const queryString = window.location.search.slice(1); // Remove the leading '?'
const queryParams = queryString.split("&");
const params = {};
queryParams.forEach(param => {
const [key, value] = param.split("=");
params[key] = value;
});
通过正则匹配
//路由地址为hash模式
function getUrlParam(name) {
const url = arguments[1] || window.location.href
const reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)")
const result = url.substr(url.indexOf("?") + 1).match(reg);
return result != null ? decodeURI(r[2]) : "";
};
//https://cdn.demo.com/index.html#/?gameid=1024&posid=996&autoopenpage=true
getUrlParam("gameid"); // 1024
getUrlParam("posid"); // 996
getUrlParam("autoopenpage"); // true
getUrlParam("nothing"); // ''
删除url指定参数
- 有时候需要通过url传递token,把token保存下来,保存完之后需要把当前url的token删除,防止暴露在url上
function delUrlParam(url, key) {
let baseUrl = url.split('?')[0] + '?';
let query = url.split('?')[1];
if (query.indexOf(key) > -1) {
let obj = {};
let arr = query.split('&');
for (let i = 0; i < arr.length; i++) {
arr[i] = arr[i].split('=');
obj[arr[i][0]] = arr[i][1];
}
delete obj[key];
let url =
baseUrl +
JSON.stringify(obj)
.replace(/[\"\{\}]/g, '')
.replace(/\:/g, '=')
.replace(/\,/g, '&');
return url;
} else {
return url;
}
}
url查询参数转换为对象
-
支持字符串数字转换为数字类型
-
支持重复参数 -> 转换数组
function queryToObject(url, { arrayMode = false, autoNumber = false } = {}) {
const { searchParams } = new URL(url)
const result = {}
for (const [key, value] of searchParams.entries()) {
// 判断是否需要转数字
let val = value
if (autoNumber && !isNaN(value) && value.trim() !== '') {
val = Number(value)
}
if (arrayMode) {
// 数组模式:相同 key 合并为数组
if (result[key]) {
result[key] = [].concat(result[key], val)
} else {
result[key] = val
}
} else {
// 非数组模式:相同 key 覆盖
result[key] = val
}
}
return result
}
queryToObject("https://xx.com?a=1&b=2&c=3", { autoNumber: true })
// { a: 1, b: 2, c: 3 }
queryToObject("https://xx.com?a=1&a=2&b=3", { arrayMode: true })
// { a: ["1", "2"], b: "3" }
queryToObject("https://xx.com?a=1&a=2&b=3", { arrayMode: true, autoNumber: true })
// { a: [1, 2], b: 3 }
拼接url,开头//的理解
location.href = `//${origin}/app/#/home`;
- 前面两个斜杠(
//)是URL协议的开头。它们表示当前URL的协议部分 - 两个斜杠可以适应不同的环境,兼容不同环境,如
http://或https://
如果当前页面是在https协议下打开,那么这段代码会使用https协议去请求新的url;如果当前页面是在http协议下打开,那么这段代码会使用http协议去请求新的url
导出excel
- sheetjs: 依赖超多。功能非常强大,目前笔者在GitHub上见到处理Excel文件,星数最多的nodejs库。可惜的是,很多处理数据样式的功能,并不开源,仅限付费专业版用户使用
- exceljs 依赖很多。功能十分丰富,配有中文文档。提供有具体接口处理单元格、行、列等。可以解析出数据样式。【推荐,支持解析样式,可以导出样式】
如何导出样式
- 使用exceljs来解析样式
如何导出水印
- 使用canvas绘制水印,并将其添加到Excel中
- 添加一个背景水印图到excel
- 参考:https://juejin.cn/post/7068274499103227940
添加水印
// 添加水印
setWatermark(str) {
// 创建一个画布
let can = document.createElement('canvas')
// 设置画布的长宽
can.width = 500
can.height = 400
let cans = can.getContext('2d')
// 旋转角度
cans.rotate((-60 * Math.PI) / 220)
cans.font = '18px Vedana'
// 设置填充绘画的颜色、渐变或者模式
cans.fillStyle = 'rgba(130, 142, 162, 0.2)'
// 设置文本内容的当前对齐方式
cans.textAlign = 'left'
// 设置在绘制文本时使用的当前文本基线
cans.textBaseline = 'Middle'
let initY = can.height
cans.fillText(str, 0, initY)
var dataURL = can.toDataURL('image/png')
return dataURL
}
// 导出
handleExport(){
const workbook = new ExcelJS.Workbook()
const base64 = this.setWatermark('水印')
const imageId1 = workbook.addImage({
base64,
extension: 'png',
})
const worksheet = workbook.addWorksheet('导出信息')
worksheet.addRow(colName) // 添加第一行表头
worksheet.addRows(dataArr) // 添加表数据
worksheet.addBackgroundImage(imageId1)
const buffer = await workbook.xlsx.writeBuffer()
saveAs(new Blob([buffer], { type: 'application/octet-stream' }), `test.xlsx`)
}
虚拟表格
虚拟表格(Virtualized Table)是在渲染大量数据时优化性能的常用方式,它通过只渲染可见的区域,而不是渲染整个数据集,来提高渲染效率。
-
react
以下是一些最流行、最好用的React虚拟表格库:
-
react-virtualized:这是React虚拟化的事实标准。它提供了多种类型的虚拟列表和表格,并支持自定义行高、滚动到指定行、动态调整表格高度等特性。
-
react-window:这个库提供了可配置的虚拟化组件,包括列表、网格和表格,它们在滚动时只渲染可见的内容。它具有较小的体积和良好的性能。
react-window虽然可以解决当表格行数较多时的纵向虚拟滚动,但是当表格列数较多时,表头也会非常多,无法实现表头的横向虚拟滚动
react-window对于较复杂的表格不适应,比如需要实现表头分组或左侧折叠与展开等功能的大数据量表格时,react-window就不适用了,它不能满足这些复杂功能实现
- react-table:这是一个可自定义的表格库,它具有排序、分页、筛选、多选等常见的表格功能,并支持自定义单元格内容和样式。它还支持虚拟化,以提高性能。
- ag-grid:这是一个功能强大的企业级数据网格,它提供了各种列类型、排序、筛选、分组、分页等特性,以及虚拟化和大数据支持。
- react-datagrid:这是一个轻量级的表格组件,支持排序、分页、筛选、自定义单元格等特性,并且支持虚拟化。
- rsuite-table
- 由于没有横向虚拟滚动,只支持纵向虚拟滚动,当表头列特别多的时候,会出现卡顿
- 需求优化
- 可以考虑把列数进行筛选展示
- 如果非要在表格中显示这么多列,从易用性上考虑,你需要在表格的头部添加一个可以筛选列的控件,默认显示常用的列,然后可以让用户可以自定显示需要展示的列
- https://github.com/rsuite/rsuite-table/issues/113
-
react-window
- react-window:这个库提供了可配置的虚拟化组件,包括列表、网格和表格,它们在滚动时只渲染可见的内容。它具有较小的体积和良好的性能。
react-window虽然可以解决当表格行数较多时的纵向虚拟滚动,但是当表格列数较多时,表头也会非常多,无法实现表头的横向虚拟滚动
react-window对于较复杂的表格不适应,比如需要实现表头分组或左侧折叠与展开等功能的大数据量表格时,react-window就不适用了,它不能满足这些复杂功能实现
解决:
- 对于多列,可以动态计算scroll.x 的值。就可以出现滚动条,以及列宽度自适应
- 列数进行筛选展示
- 列数动态筛选展示,也引发一个问题,会导致列错位。。
表格优化方案
通常,表格优化两种方案,1、虚拟列表 2、传统分页
- 虚拟列表
- 按需渲染
- 减少 DOM 数量
- 优化渲染过程
- 复用vnode