前端性能优化场景题:图片懒加载 + 大数据渲染

本文介绍了两道高频前端场景题:图片懒加载和十万条数据渲染。文章内容包括问题分析、多种方案对比、完整代码实现,建议收藏反复查看。


一、图片懒加载

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 分页 / 虚拟列表

记忆建议:

  1. 懒加载data-src 暂存 + 进视口赋值,IntersectionObserver 优于 scroll + getBoundingClientRect
  2. 大数据渲染 → 不要一次性插入,分页用 rAF 不用 setTimeout
  3. 虚拟列表 → 只渲染可见区域 + 占位撑高度 + transform 定位,DOM 始终几十个
  4. 通用优化DocumentFragment 批量插入减少回流,rAF 替代 setTimeout 做动画
相关推荐
GISer_Jing1 小时前
Come on,工作总结
前端·ai·前端框架
Hilaku1 小时前
Sass 和 Less 在 2026 年彻底多余了吗?
前端·javascript·程序员
百慕大三角1 小时前
AI 写代码最大的风险不是不会写,而是太能写:我给 Coding Agent 加的 4 层工程约束
前端·ai编程·trae
用户921080262861 小时前
从 EventSource 到可复用 SSE Client:我如何实现多实例、双超时与自动重连
前端
咩咩啃树皮1 小时前
ES6 Set 核心特点 + 最简数组去重
前端·javascript·html
aa小小1 小时前
uniApp离线数据不丢失方案
前端
代码小学僧2 小时前
从 0 到 1,记录我的第一个出海 SaaS 独立开发项目
前端·rpc·音视频开发
Aaswk2 小时前
从 HTML/CSS/JS 到 Vue + Axios 的一篇实战笔记
前端·css·vue.js·html
IMPYLH2 小时前
HTML 的 <span> 标签
前端·html