
之前在整理一个社区类的动态页面时遇到过一种非常典型的性能现象某位活跃用户连续发布了几十条动态后整个信息流列表的加载速度明显变慢另一位用户想发布一条视频结果上传页面也迟迟打不开。那句“我的朋友请删掉一些动态吧”虽然是一句玩笑背后藏着的却是真实的技术问题当“动态”数量涨上去之后列表加载、视频发布、页面滚动都会一起变卡。这篇文章把这个问题当作一个完整的性能优化案例来处理。我会先拆解“动态越来越多加载越来越慢”到底慢在哪几个环节再手把手实现一个可以运行的前端 Demo覆盖分页加载、图片懒加载、虚拟滚动和视频延迟加载四个核心优化点。无论你是在做朋友圈类产品、社区动态流还是普通的后台列表页面这套思路都可以直接复用。1. 动态流加载慢到底是哪些环节变慢了1.1 先理解“动态”的数据形态在朋友圈、微博、社区等内容产品里我们一般把用户发布的一条图文、视频或纯文本内容称为“动态”。用户发布后系统会把这些内容按时间倒序排列形成一个不断增长的信息流页面。动态看起来只是一个列表但它背后涉及多个技术环节后端存储与查询要查询当前用户可见的全部动态并关联作者信息、图片信息、视频信息接口传输后端查询结果序列化成 JSON 返回给前端前端渲染前端把 JSON 转换成 DOM 节点展示在页面中资源加载图片、视频等静态资源通过网络从 CDN 或源服务器加载发布更新用户发布新动态后前端要把新内容插入列表并保持页面可交互。很多人第一反应是“加载慢是因为网速不行”但在实际项目中大部分卡顿反而来自前端一次性渲染太多节点以及图片、视频被提前加载。之前在一个社区产品里用户动态平均不到 100 条列表打开却要 3 到 5 秒后来排查发现页面把所有动态一次性渲染到 DOM 中每条动态都带着多张大图视频还使用了默认预加载策略。刷新页面时浏览器要同时处理上百个图片请求和若干视频请求再好的设备也会被拖慢。1.2 动态数量变多后的典型症状症状出现阶段常见原因打开页面后白屏很久数据请求与解析接口一次返回大量 JSON字段冗余过多滚动列表时明显掉帧渲染与绘制DOM 节点过多浏览器不断重排和重绘图片总是加载不完整资源加载图片没有懒加载首屏之外的图片也被请求视频列表页面打开卡顿资源加载video 标签预加载了大量视频数据发布视频上传很慢上传链路原视频未压缩、未分片上传接口串行等待这张表的核心目的是帮助定位问题。下面先从前端性能原理说起再给出一个可以直接运行的优化 Demo。2. 环境准备与版本说明本文演示以 HTML5 原生 JavaScript CSS 为主不依赖 React、Vue 等框架也不需要安装 Node.js 或任何构建工具。只要有一个现代浏览器直接打开 HTML 文件就能运行。操作系统Windows、macOS、Linux 均可浏览器建议使用 Chrome 或 Edge 的最新稳定版本IntersectionObserver和video相关属性都是现代浏览器的标准能力示例资源图片使用picsum.photos占位图视频使用公开示例 mp4 地址实际开发时请替换成你自己的 CDN 资源地址演示方式双击index.html打开页面即可同时打开 DevTools 的 Network 面板和 Elements 面板观察优化效果。示例项目目录结构如下feed-demo/ ├── index.html ├── css/ │ └── style.css └── js/ ├── mock.js ├── lazy.js ├── virtual-list.js └── feed.js每个文件的作用index.html页面入口展示动态列表和“发布动态”“加载更多”两个按钮css/style.css定义列表布局、头像样式、图片和视频卡片样式js/mock.js模拟后端分页接口每次返回 20 条动态js/lazy.js封装图片懒加载能力基于IntersectionObserver实现js/virtual-list.js实现一个简单的固定高度虚拟滚动列表js/feed.js业务入口负责加载数据、渲染列表、发布动态和加载更多。如果你用的是 Vue 或 React 项目也不影响阅读。这里的核心思路都能迁移到组件体系中比如 Vue 的vue-virtual-scroller、React 的react-window底层思想与本文手写版本一致。3. 核心原理为什么动态越多加载和发布越卡3.1 数据请求阶段一次性请求全部数据假设列表里有 500 条动态如果后端没有分页接口返回的 JSON 会非常大。每条动态可能包含作者昵称、头像、正文、位置、点赞用户、评论列表等嵌套结构500 条数据产生的 JSON 往往有几百 KB。这个阶段的损耗不是单纯“网速慢”两个字能概括的。接口数据大了后端查询时间变长网络传输时间变长浏览器拿到 JSON 后还需要在主线程上执行JSON.parse解析大字符串本身就是耗时的 CPU 操作。结果就是用户打开页面后要等很久才看到第一屏内容。优化方向很明确接口分页、字段裁剪、评论点赞等二级数据按需加载。首屏只返回 10 到 20 条动态用户滚动到底部时再请求下一页。3.2 渲染阶段DOM 节点数量过多浏览器渲染页面的过程包括解析 HTML、生成 DOM 树、计算样式、执行布局Layout、绘制Paint和合成Composite。动态列表中的每一条内容最终都会变成一个或者很多个 DOM 节点。五百条动态简单一点也至少有 500 个外层节点如果每条动态内部还有头像、昵称、正文、图片、视频、操作按钮节点数可能轻松破万。节点数量上来之后浏览器在布局阶段需要处理的元素就多用户滚动时还可能出现持续的重新布局和重绘表现为掉帧、卡顿。虚拟滚动是这类问题的主流解法。它只渲染用户当前可视区域内能看到的条目其他数据用“高度占位”的方式撑出滚动条。理论上即使有十万条动态DOM 节点数量也可以控制在一个稳定的范围内。3.3 资源加载阶段图片和视频是最大的带宽杀手相比 HTML 和 JSON图片和视频的体积通常大得多。一张高清图片可能几百 KB一个视频动辄几 MB 甚至几十 MB。在实际页面里默认的图片标签会直接加载src指向的地址浏览器不会判断用户是否真的能看到这张图视频标签如果没有特别设置preload属性也可能在页面加载阶段就偷偷下载一部分视频数据。多个视频同时出现在加载队列里时页面首屏会被这些资源请求堵死真正的文本内容和首张图片反而要排队等待。所以信息流页面必须做两件事图片懒加载以及视频延迟加载。图片进入视口附近时才替换成真实地址视频列表只展示封面用户点击播放后再请求视频文件。3.4 发布动态后的更新阶段整表重渲染发布动态和浏览动态是两条链路但它们共享同一个前端页面状态。很多实现方式是在发布成功后把新动态unshift到数组头部然后触发整个列表重新渲染。如果列表本身没有虚拟化数据量一大重新渲染几百条 DOM 的开销会非常明显用户点完“发布”之后可能还要卡一会儿才能看到新内容。更合理的做法是让列表渲染与数据更新解耦数据总量受控页面只保留可视区的 DOM新动态插入头部后快速重置列表并保持在顶部。后面 Demo 中的renderFeedList()就体现了这个思路。4. 完整实战动态流加载优化 Demo这一节提供完整可运行的代码。建议按文件顺序创建目录和文件最后在浏览器中打开index.html。4.1 页面结构文件路径feed-demo/index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title动态流加载优化 Demo/title link relstylesheet hrefcss/style.css /head body div classapp div classtoolbar button idpublishBtn typebutton发布动态/button button idloadMoreBtn typebutton加载更多/button span idstatusTip classstatus-tip/span /div div idfeedList classfeed-list/div /div script srcjs/mock.js/script script srcjs/lazy.js/script script srcjs/virtual-list.js/script script srcjs/feed.js/script /body /html页面结构很简单顶部是一个工具栏包含“发布动态”“加载更多”和状态提示下方是动态列表容器#feedList。feedList会作为虚拟列表的滚动容器所以 CSS 里必须给它一个明确高度和overflow-y: auto。4.2 样式文件文件路径feed-demo/css/style.css* { box-sizing: border-box; } body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Microsoft YaHei, sans-serif; background: #f5f6f7; } .app { max-width: 640px; margin: 0 auto; height: 100vh; display: flex; flex-direction: column; background: #fff; } .toolbar { display: flex; align-items: center; gap: 12px; padding: 12px; border-bottom: 1px solid #eee; } .toolbar button { padding: 8px 14px; border: none; border-radius: 8px; background: #2f54eb; color: #fff; cursor: pointer; } .toolbar button:hover { background: #1d39c4; } .status-tip { font-size: 13px; color: #888; } .feed-list { flex: 1; overflow-y: auto; position: relative; } .feed-item { position: absolute; left: 0; right: 0; top: 0; padding: 12px; border-bottom: 1px solid #f0f0f0; background: #fff; overflow: hidden; } .feed-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } .avatar { width: 36px; height: 36px; border-radius: 50%; background: #597ef7; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 16px; } .feed-user { font-weight: 600; } .feed-text { margin-bottom: 8px; line-height: 1.6; } .feed-item img { width: 100%; max-height: 80px; object-fit: cover; border-radius: 8px; background: #fafafa; } .feed-item video { width: 100%; max-height: 200px; border-radius: 8px; background: #000; }关键点在于.feed-list设置了flex: 1让它在页面中撑满剩余高度.feed-item使用绝对定位用来配合虚拟滚动按top值摆放条目。这里为了演示原理把每条动态的高度固定为 160px。真实项目中如果卡片高度不固定需要使用高度估算、分块虚拟化或成熟的三方组件这部分我会在后面的工程建议中展开。4.3 模拟分页接口文件路径feed-demo/js/mock.jsvar MockFeed (function () { function getFeeds(page, pageSize) { var list []; var start (page - 1) * pageSize; for (var i start; i start pageSize; i) { var item { id: i 1, user: 用户 ((i % 20) 1), text: 这是第 (i 1) 条动态。在实际项目中这条字段可能是图文、链接、位置等组合数据。, image: https://picsum.photos/seed/demo (i 1) /400/300, video: (i 1) % 12 0 ? https://www.w3schools.com/html/mov_bbb.mp4 : }; list.push(item); } return list; } return { getFeeds: getFeeds }; })();这个模块模拟了后端分页接口。getFeeds(page, pageSize)根据页码计算起始下标返回 20 条动态。实际项目中这一层应该换成 Axios、fetch 或你项目中已有的请求封装从服务端获取真实数据。注意视频字段只在每 12 条中出现一次这样页面里既有普通图文动态也有视频动态便于观察延迟加载效果。示例视频地址来自公开示例站点仅用于本地演示生产项目必须替换为业务自己的 CDN 地址。4.4 封装图片懒加载文件路径feed-demo/js/lazy.jsvar LazyLoader (function () { var observer null; if (IntersectionObserver in window) { observer new IntersectionObserver( function (entries, self) { entries.forEach(function (entry) { if (entry.isIntersecting) { var img entry.target; if (img.getAttribute(data-src)) { img.src img.getAttribute(data-src); img.removeAttribute(data-src); img.classList.add(loaded); } self.unobserve(img); } }); }, { rootMargin: 200px 0px, threshold: 0.01 } ); } function init(root) { var container root || document; var lazyImages [].slice.call( container.querySelectorAll(img[data-src]) ); if (observer) { lazyImages.forEach(function (img) { observer.observe(img); }); } else { lazyImages.forEach(function (img) { img.src img.getAttribute(data-src); img.removeAttribute(data-src); }); } } function unobserveAll(root) { var container root || document; var images [].slice.call( container.querySelectorAll(img[data-src], img.loaded) ); if (observer) { images.forEach(function (img) { observer.unobserve(img); }); } } return { init: init, unobserveAll: unobserveAll }; })();LazyLoader是一个轻量工具模块核心是IntersectionObserver。页面中暂时不可见的图片不会被加载只有进入可视区域并满足threshold条件时才把>var VirtualList (function () { function VirtualList(options) { this.container options.container; this.itemHeight options.itemHeight || 160; this.data options.data || []; this.renderItem options.renderItem || function () { return ; }; this.content document.createElement(div); this.content.className feed-virtual-content; this.container.appendChild(this.content); this.visibleCount Math.ceil(this.container.clientHeight / this.itemHeight) 3; this._scrollHandler this._onScroll.bind(this); this.container.addEventListener(scroll, this._scrollHandler, { passive: true }); this._update(); } VirtualList.prototype._onScroll function () { this._update(); }; VirtualList.prototype._update function () { var scrollTop this.container.scrollTop; this.startIndex Math.floor(scrollTop / this.itemHeight); this.endIndex Math.min( this.startIndex this.visibleCount, this.data.length ); this._render(); }; VirtualList.prototype._render function () { if (window.LazyLoader) { window.LazyLoader.unobserveAll(this.content); } this.content.innerHTML ; this.content.style.height this.data.length * this.itemHeight px; for (var i this.startIndex; i this.endIndex; i) { var item document.createElement(div); item.className feed-item; item.style.top i * this.itemHeight px; item.style.height this.itemHeight px; item.innerHTML this.renderItem(this.data[i], i); this.content.appendChild(item); } if (window.LazyLoader) { window.LazyLoader.init(this.container); } }; VirtualList.prototype.destroy function () { this.container.removeEventListener(scroll, this._scrollHandler); if (window.LazyLoader) { window.LazyLoader.unobserveAll(this.content); } this.content.innerHTML ; }; return VirtualList; })();虚拟滚动的核心逻辑并不复杂。content是一个相对定位的容器它的总高度被设置为data.length * itemHeight让浏览器能看到完整的总滚动长度。但content内部只渲染可视区域的条目其他区域用空白高度表示。滚动时根据scrollTop算出当前可视区域对应数据数组的范围然后清空并重新渲染这一段条目。passive: true让滚动事件监听不阻塞浏览器滚动线程这是提升滚动流畅度的一个重要细节。这里实现的是固定高度版本假设每条动态高度都一样。真实信息流通常是不等高的直接用固定高度会错位生产环境建议使用成熟的虚拟滚动库或者自己实现对条目高度的估算与缓存。4.6 业务入口发布动态与加载更多文件路径feed-demo/js/feed.js(function () { var feedList document.getElementById(feedList); var publishBtn document.getElementById(publishBtn); var loadMoreBtn document.getElementById(loadMoreBtn); var statusTip document.getElementById(statusTip); var page 1; var pageSize 20; var feeds []; var virtualList null; function renderFeedItem(item) { var avatar div classavatar item.user.charAt(0) /div; var text div classfeed-text item.text /div; var imageHtml ; if (item.image) { imageHtml img classfeed-image lazy>video iddemoVideo controls playsinline preloadnone posterhttps://example.com/poster.jpg source>var demoVideo document.getElementById(demoVideo); function ensureVideoSource() { var source demoVideo.querySelector(source); if (source source.dataset.src !source.getAttribute(src)) { source.setAttribute(src, source.dataset.src); demoVideo.load(); demoVideo.play().catch(function (err) { console.error(视频播放失败, err); }); } } demoVideo.addEventListener(click, ensureVideoSource, { once: true });这段代码的核心思路是视频元素初始没有src属性页面加载阶段不会触发资源请求用户第一次点击视频时才把>