本文介绍了两道高频前端场景题:图片懒加载和十万条数据渲染。文章内容包括问题分析、多种方案对比、完整代码实现,建议收藏反复查看。
一、图片懒加载
1. 问题背景
一个页面上图片资源过多时,初次加载会占用大量带宽和时间,导致页面加载缓慢、白屏时间长。
懒加载的核心思想:只有当图片进入视口(用户可见区域)时,才真正加载图片资源,不在视口内的图片先用占位图。
2. 实现原理
HTML 中 <img> 的 src 属性才会触发请求。所以懒加载的关键是:
- 初始时
src不设真实地址(用data-src存真实地址) - 判断图片是否进入视口
- 进入视口时,把
data-src赋值给src,触发加载
html
<!-- 这样会立即加载 -->
<img src="big-image.jpg" />
<!-- 懒加载写法 -->
<img data-src="big-image.jpg" src="占位图.gif" />
3. 方案一:getBoundingClientRect + scroll 监听
原理
getBoundingClientRect() 返回元素相对于视口的位置信息。判断图片顶部是否在视口底部之上即可。
js
function lazyLoad() {
const imgs = document.querySelectorAll('img[data-src]')
const viewportHeight = window.innerHeight // 视口高度
imgs.forEach(img => {
const rect = img.getBoundingClientRect()
// 图片顶部在视口底部之上 → 进入视口
if (rect.top < viewportHeight) {
img.src = img.dataset.src
img.removeAttribute('data-src') // 加载后移除,避免重复处理
}
})
}
// 监听滚动(需节流,scroll 触发极频繁)
lazyLoad() // 首次执行
window.addEventListener('scroll', throttle(lazyLoad, 200))
完整可运行示例
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>图片懒加载 - scroll 方案</title>
<style>
.img-item { height: 300px; margin: 20px 0; background: #f0f0f0; }
.img-item img { width: 100%; height: 100%; object-fit: cover; }
</style>
</head>
<body>
<div class="container">
<!-- 初始 src 为占位图 -->
<div class="img-item"><img data-src="https://picsum.photos/800/300?random=1" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"></div>
<div class="img-item"><img data-src="https://picsum.photos/800/300?random=2" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"></div>
</div>
<script>
// 节流函数
function throttle(fn, delay) {
let lastTime = 0
return function (...args) {
const now = Date.now()
if (now - lastTime >= delay) {
lastTime = now
fn.apply(this, args)
}
}
}
function lazyLoad() {
const imgs = document.querySelectorAll('img[data-src]')
const viewportHeight = window.innerHeight
imgs.forEach(img => {
const rect = img.getBoundingClientRect()
if (rect.top < viewportHeight && rect.bottom > 0) {
img.src = img.dataset.src
img.removeAttribute('data-src')
}
})
}
lazyLoad()
window.addEventListener('scroll', throttle(lazyLoad, 200))
</script>
</body>
</html>
缺点
scroll事件触发频繁,必须手动节流- 每次都遍历所有未加载的图片,性能一般
- 需要手动计算视口边界
4. 方案二:IntersectionObserver(推荐)
原理
IntersectionObserver 是浏览器原生 API,专门用于监听元素与视口的交叉状态,无需手动计算坐标,性能更好。
js
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
// entry.isIntersecting 表示元素是否进入视口
if (entry.isIntersecting) {
const img = entry.target
img.src = img.dataset.src
observer.unobserve(img) // 加载后停止监听,避免重复
}
})
}, {
root: null, // 视口(默认浏览器视口)
rootMargin: '0px 0px 100px 0px', // 提前 100px 触发预加载
threshold: 0 // 只要露出 1px 就触发
})
// 监听所有懒加载图片
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img)
})
完整可运行示例
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>图片懒加载 - IntersectionObserver</title>
<style>
.img-item { height: 300px; margin: 20px 0; background: #f0f0f0; }
.img-item img { width: 100%; height: 100%; object-fit: cover; }
</style>
</head>
<body>
<div class="container">
<div class="img-item"><img data-src="https://picsum.photos/800/300?random=1" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"></div>
<div class="img-item"><img data-src="https://picsum.photos/800/300?random=2" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"></div>
<!-- ... -->
</div>
<script>
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
img.src = img.dataset.src
observer.unobserve(img) // 关键:加载后取消监听
}
})
}, {
rootMargin: '0px 0px 100px 0px', // 提前 100px 预加载
threshold: 0
})
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img)
})
</script>
</body>
</html>
优点
- 浏览器原生 API,性能更好
- 不需要手动监听 scroll 和节流
- 支持预加载(
rootMargin)和阈值控制(threshold)
5. 两种方案对比
| getBoundingClientRect + scroll | IntersectionObserver | |
|---|---|---|
| 原理 | 手动计算坐标判断 | 浏览器自动监听交叉状态 |
| 性能 | 需节流,遍历所有元素 | 浏览器优化,按需回调 |
| 代码量 | 多(需节流 + 坐标计算) | 少(几行搞定) |
| 兼容性 | 全兼容 | IE 不支持,现代浏览器全支持 |
| 推荐 | ❌ 旧方案 | ✅ 首选 |
要点总结
- 懒加载核心 :
src不设真实地址,用data-src暂存,进入视口再赋值 getBoundingClientRect():返回元素相对视口的位置(top/right/bottom/left)IntersectionObserver:原生 API,isIntersecting判断是否可见,unobserve取消监听- 预加载 :
rootMargin: '0px 0px 100px 0px'可提前 100px 加载,提升体验 - 必须节流 :scroll 方案必须配合
throttle,否则每秒触发几十次
二、十万条数据的加载
1. 问题背景
当需要一次性渲染十万条数据到列表中时,渲染引擎需要创建大量 DOM 结构,导致:
- 白屏:DOM 创建耗时,页面长时间空白
- 闪屏/卡顿:一次性回流重绘量太大,帧率骤降
2. 方案一:一次性渲染(反面教材)
js
// 生成十万条数据
const total = 100000
const data = Array.from({ length: total }, (_, i) => `item ${i + 1}`)
const ul = document.getElementById('list')
// 一次性插入十万条
const fragment = document.createDocumentFragment()
data.forEach(text => {
const li = document.createElement('li')
li.textContent = text
fragment.appendChild(li)
})
ul.appendChild(fragment)
结果:白屏 + 严重卡顿,页面长时间无响应。
3. 方案二:分页渲染(setInterval / setTimeout)
原理
把十万条数据分成多页,每页渲染 100 条,用定时器逐页插入。
js
const total = 100000
const once = 100 // 每页渲染条数
const page = total / once // 总页数
const ul = document.getElementById('list')
let currentPage = 0
function renderPage() {
if (currentPage >= page) return
const fragment = document.createDocumentFragment()
const start = currentPage * once
const end = start + once
for (let i = start; i < end; i++) {
const li = document.createElement('li')
li.textContent = `item ${i + 1}`
fragment.appendChild(li)
}
ul.appendChild(fragment)
currentPage++
// 用 setTimeout 递归渲染下一页
setTimeout(renderPage, 0) // ⚠️ setTimeout 时间不准,可能闪屏
}
renderPage()
缺点
setTimeout 的延迟是不精确的,可能比预期晚执行,导致每帧渲染不均匀,出现闪屏。
4. 方案三:requestAnimationFrame 分页渲染(推荐)
原理
requestAnimationFrame 会在浏览器下一次重绘前执行回调,时间精确,和屏幕刷新率同步(通常 60fps,约 16.7ms 一帧),不会闪屏。
js
const total = 100000
const once = 100
const page = total / once
const ul = document.getElementById('list')
let currentPage = 0
function renderPage() {
if (currentPage >= page) return
const fragment = document.createDocumentFragment()
const start = currentPage * once
const end = start + once
for (let i = start; i < end; i++) {
const li = document.createElement('li')
li.textContent = `item ${i + 1}`
fragment.appendChild(li)
}
ul.appendChild(fragment)
currentPage++
// 用 rAF 代替 setTimeout,与屏幕刷新同步
requestAnimationFrame(renderPage)
}
requestAnimationFrame(renderPage)
完整可运行示例
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>十万条数据分页渲染</title>
<style>
#list { list-style: none; padding: 0; margin: 0; }
#list li { padding: 8px 16px; border-bottom: 1px solid #eee; }
</style>
</head>
<body>
<ul id="list"></ul>
<script>
const total = 100000
const once = 100
const page = total / once
const ul = document.getElementById('list')
let currentPage = 0
function renderPage() {
if (currentPage >= page) return
const fragment = document.createDocumentFragment()
const start = currentPage * once
const end = start + once
for (let i = start; i < end; i++) {
const li = document.createElement('li')
li.textContent = `item ${i + 1}`
fragment.appendChild(li)
}
ul.appendChild(fragment)
currentPage++
requestAnimationFrame(renderPage)
}
// 测试渲染耗时
console.time('render')
requestAnimationFrame(function loop() {
if (currentPage < page) {
renderPage()
requestAnimationFrame(loop)
} else {
console.timeEnd('render')
}
})
</script>
</body>
</html>
5. 方案四:虚拟列表(终极方案)
原理
不管数据有多少条,DOM 始终只渲染可视区域内的那几十条。滚动时通过计算"应该显示哪些数据",动态替换可视区域的内容。
核心要素:
- 可视区域高度
visibleHeight(容器高度) - 每项高度
itemHeight - 可见项数量
visibleCount = Math.ceil(visibleHeight / itemHeight) - 滚动偏移
scrollTop→ 计算起始索引startIndex - 展示数据 = 原数组
slice(startIndex, startIndex + visibleCount)
css
┌──────────────────────────┐ ← 容器(可视区域)
│ item 5 │
│ item 6 │ ← 只渲染可见的 N 条
│ item 7 │
│ ... │
│ item 12 │
└──────────────────────────┘
↑ 用 padding/transform 撑出滚动条
(让用户感觉有十万条)
完整实现
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>虚拟列表</title>
<style>
#virtual-list {
height: 600px; /* 可视区域高度 */
overflow-y: auto; /* 出现滚动条 */
position: relative;
border: 1px solid #ccc;
}
.virtual-phantom { /* 占位元素,撑出滚动条高度 */
position: absolute;
top: 0;
left: 0;
right: 0;
}
.virtual-content { /* 实际渲染内容,用 transform 定位 */
position: absolute;
top: 0;
left: 0;
right: 0;
}
.virtual-item {
height: 40px; /* 每项固定高度 */
line-height: 40px;
padding: 0 16px;
border-bottom: 1px solid #eee;
box-sizing: border-box;
}
</style>
</head>
<body>
<div id="virtual-list">
<div class="virtual-phantom"></div>
<div class="virtual-content"></div>
</div>
<script>
const total = 100000
const itemHeight = 40 // 每项高度
const visibleHeight = 600 // 可视区域高度
const visibleCount = Math.ceil(visibleHeight / itemHeight) // 15 条
// 生成十万条数据
const data = Array.from({ length: total }, (_, i) => `item ${i + 1}`)
const list = document.getElementById('virtual-list')
const phantom = list.querySelector('.virtual-phantom')
const content = list.querySelector('.virtual-content')
// 1. 占位元素撑出总高度,出现滚动条
phantom.style.height = total * itemHeight + 'px'
// 2. 滚动时计算可见范围并渲染
function render() {
const scrollTop = list.scrollTop
const startIndex = Math.floor(scrollTop / itemHeight)
const endIndex = startIndex + visibleCount
// 切割出可见数据
const visibleData = data.slice(startIndex, endIndex)
// 渲染内容
content.innerHTML = visibleData
.map(text => `<div class="virtual-item">${text}</div>`)
.join('')
// 用 transform 把内容移到正确位置
content.style.transform = `translateY(${startIndex * itemHeight}px)`
}
list.addEventListener('scroll', () => requestAnimationFrame(render))
render() // 首次渲染
</script>
</body>
</html>
6. 四种方案对比
| 方案 | 白屏 | 闪屏 | DOM 数量 | 适用场景 |
|---|---|---|---|---|
| 一次性渲染 | ❌ 严重 | ❌ 严重 | 10 万个 | ❌ 不推荐 |
| setTimeout 分页 | ✅ 无 | ❌ 有 | 10 万个(最终) | 中等数据 |
| rAF 分页 | ✅ 无 | ✅ 无 | 10 万个(最终) | 大数据,但 DOM 仍多 |
| 虚拟列表 | ✅ 无 | ✅ 无 | 几十个 | ✅ 超大数据最优 |
要点总结
- 一次性渲染的问题:DOM 创建 + 回流重绘量太大 → 白屏 + 闪屏
setTimeout的问题:延迟不精确,时间不均匀 → 闪屏requestAnimationFrame的优势:与屏幕刷新率同步(约 16.7ms/帧),渲染均匀无闪屏- 虚拟列表核心:只渲染可视区域的几十条 DOM,用占位元素撑出滚动条高度,滚动时动态替换内容
DocumentFragment:批量插入 DOM 时用它,减少回流次数
总结
两道场景题的核心思路都是减少一次性 DOM 操作量:
| 场景 | 核心思路 | 推荐方案 |
|---|---|---|
| 图片懒加载 | 进入视口才加载 | IntersectionObserver + data-src |
| 十万条数据渲染 | 分批 / 只渲染可见 | requestAnimationFrame 分页 / 虚拟列表 |
记忆建议:
- 懒加载 →
data-src暂存 + 进视口赋值,IntersectionObserver优于scroll + getBoundingClientRect - 大数据渲染 → 不要一次性插入,分页用
rAF不用setTimeout - 虚拟列表 → 只渲染可见区域 + 占位撑高度 +
transform定位,DOM 始终几十个 - 通用优化 →
DocumentFragment批量插入减少回流,rAF替代setTimeout做动画