尧图精选

异步加载与性能优化:从原理到移动端实战

🕒 发布时间:2026/10/1 19:42:44 📁 来源:尧图网络
1. 异步加载到底在解决什么问题先把场景摆出来。你打开一个页面或者启动一个应用如果所有资源——图片、脚本、样式、数据请求——都排着队一个接一个加载用户看到的就是长时间白屏。异步加载要干的事就是把这些排队变成并行让不阻塞主流程的资源在后台悄悄加载主流程该渲染渲染、该响应响应。我见过太多项目功能都写完了最后卡在首屏太慢上。排查一圈发现不是代码逻辑有问题而是加载策略从一开始就没设计。同步加载像在超市只开一个收银台人一多就堵死异步加载是动态增开收银台谁先结完谁先走。这里有个关键认知需要先建立异步加载不是让加载变快而是让等待变得不可感知。文件总大小没变网络带宽没变但用户感知到的速度完全不同。这背后的核心指标是首屏渲染时间和可交互时间而不是资源全部加载完成的时间。关键词里提到的手游性能优化移动端性能优化本质上和Web端的异步加载是同一套逻辑——移动端CPU、内存、网络都比桌面端紧张异步策略在移动端带来的收益更明显。Julia这类科学计算语言的性能优化与内存管理虽然场景不同但把重活拆开、把非关键路径异步化的思路是相通的。注意异步加载的前提是你能准确区分关键资源和非关键资源。分错了该异步的同步了首屏就慢该同步的异步了页面就闪烁或者功能异常。2. 同步、异步、延迟三种加载模式的本质区别2.1 浏览器遇到script标签时到底发生了什么很多人写了几年前端对script标签的理解还停留在引入JS文件。实际上浏览器解析HTML时遇到一个普通的script src...会做这几件事暂停HTML解析因为JS可能修改DOM发起网络请求下载脚本下载完成后立即执行执行完毕恢复HTML解析这个过程中第2步的网络请求时间完全被浪费了——HTML解析停着等用户看到的是白屏。如果脚本放在head里那更糟整个页面都要等脚本下载执行完才开始渲染。2.2 async和defer的真实差异async和defer都能让脚本下载不阻塞HTML解析但执行时机不同属性下载是否阻塞解析执行时机执行顺序适用场景无阻塞下载完立即执行按标签顺序极少使用async不阻塞下载完立即执行不确定独立脚本如统计defer不阻塞HTML解析完成后按标签顺序依赖DOM的脚本async的执行顺序不确定哪个先下载完哪个先执行。如果你的脚本之间有依赖关系用async就是给自己埋雷。defer保证按顺序执行且在DOM解析完成后、DOMContentLoaded事件之前执行适合大多数业务脚本。我个人的经验是业务代码一律用defer第三方独立脚本用async。统计代码、广告脚本这类不依赖任何东西的用async没问题但你的工具库、业务逻辑必须用defer保证顺序。2.3 动态创建script标签的异步方案除了HTML属性还可以用JS动态创建script标签function loadScript(src, callback) { const script document.createElement(script); script.src src; script.onload callback; script.onerror () console.error(加载失败:, src); document.head.appendChild(script); }这种方式默认就是异步的而且可以精确控制加载时机和回调。适合按需加载——比如用户点击某个按钮才加载对应的功能模块。但要注意动态创建的script默认是async行为如果需要顺序保证得手动维护队列。3. 资源优先级不是所有异步都是平等的3.1 浏览器如何给资源排优先级浏览器内部有一套优先级机制大致分几档最高HTML文档本身、CSS阻塞渲染高字体文件、首屏图片、同步脚本中异步脚本、非首屏图片低预加载资源、埋点请求你可以通过link relpreload手动提升某个资源的优先级link relpreload hrefcritical.css asstyle link relpreload hrefhero.jpg asimagepreload告诉浏览器这个资源我马上要用赶紧下载但不阻塞渲染。下载完后放在缓存里等真正需要时直接从缓存取。对应的还有prefetch优先级更低用于预加载下一个页面可能用到的资源link relprefetch hrefnext-page.jsprefetch在浏览器空闲时下载不影响当前页面性能。适合做页面跳转的预加载。3.2 图片懒加载的完整实现图片是页面体积的大头。首屏之外的图片完全没必要一开始就加载。原生懒加载最简单img srcplaceholder.jpg>const observer new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; observer.unobserve(img); } }); }, { rootMargin: 200px // 提前200px开始加载 }); document.querySelectorAll(img[data-src]).forEach(img observer.observe(img));rootMargin设成200px意味着图片距离视口还有200px时就开始加载用户滚动到的时候基本已经加载完了。这个值需要根据页面滚动速度和图片大小调我一般设100-300px之间。提示懒加载的占位图很重要。没有占位图图片加载出来时会导致页面布局跳动CLS指标恶化。占位图可以用纯色、模糊缩略图或者固定宽高比容器。3.3 移动端异步加载的特殊考量移动端网络环境复杂4G/5G/WiFi切换频繁异步策略要更保守。我在移动端项目里会做这几件事首屏关键资源内联把首屏必需的CSS和JS直接内联到HTML里减少请求数非关键资源延迟到首屏渲染后用requestIdleCallback在浏览器空闲时加载图片根据网络类型调整质量通过navigator.connection.effectiveType判断网络状况慢网络加载低质量图片if (connection in navigator) { const type navigator.connection.effectiveType; const quality type 4g ? high : low; // 根据quality选择不同分辨率的图片 }4. 代码分割与按需加载的落地方法4.1 为什么要把代码拆开一个典型的单页应用如果所有JS打包成一个文件体积很容易超过1MB。用户打开首页却要下载整个应用的代码包括那些可能永远不会访问的页面。这就是过度加载。代码分割的核心思想把代码按路由或功能拆成多个小块用户访问哪个页面就加载哪块。Webpack、Vite这些构建工具都支持动态import// 静态导入打包进主文件 import { utils } from ./utils; // 动态导入单独打包按需加载 button.addEventListener(click, async () { const { heavyFunction } await import(./heavy-module); heavyFunction(); });动态import返回一个Promise加载完成后才能使用模块。构建工具会自动把heavy-module拆成独立的chunk文件。4.2 路由级分割的实操配置以React Router为例配合React.lazy和Suspenseimport { lazy, Suspense } from react; import { BrowserRouter, Routes, Route } from react-router-dom; const Home lazy(() import(./pages/Home)); const Dashboard lazy(() import(./pages/Dashboard)); function App() { return ( BrowserRouter Suspense fallback{div加载中.../div} Routes Route path/ element{Home /} / Route path/dashboard element{Dashboard /} / /Routes /Suspense /BrowserRouter ); }这样配置后访问首页只会加载Home相关的代码Dashboard的代码在用户点击跳转时才加载。Suspense的fallback是加载期间的占位内容。Vue的写法类似const routes [ { path: /, component: () import(./pages/Home.vue) }, { path: /dashboard, component: () import(./pages/Dashboard.vue) } ];4.3 分割粒度的权衡代码分割不是越细越好。分得太细请求数暴增每个请求都有网络开销分得太粗又起不到按需加载的效果。我的经验是路由级分割是基本盘每个路由一个chunk这是最自然的分割点大型第三方库单独分割比如图表库、富文本编辑器这些体积大且不是每个页面都用公共依赖提取到vendor chunkReact、Vue这些框架代码单独打包利用浏览器缓存Webpack的splitChunks配置optimization: { splitChunks: { chunks: all, cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: vendors, priority: 10 } } } }5. 数据请求的异步编排策略5.1 串行、并行与竞态处理页面初始化时经常需要请求多个接口。如果串行请求——等A返回再请求B——总耗时是两者之和。并行请求总耗时取决于最慢的那个。// 串行总耗时 A B const a await fetchA(); const b await fetchB(a.id); // 并行总耗时 max(A, B) const [a, b] await Promise.all([fetchA(), fetchB()]);但并行有个问题如果B依赖A的返回结果就没法并行。这时候要分析依赖关系把无依赖的请求并行化。竞态问题是另一个坑。用户快速切换选项卡发了多个请求但返回顺序不确定。如果不处理可能旧请求的结果覆盖了新请求的结果let currentRequestId 0; async function fetchData(id) { const requestId currentRequestId; const result await fetch(/api/data/${id}); if (requestId currentRequestId) { // 只有最新请求的结果才被采用 render(result); } }5.2 请求缓存与去重同一个接口在短时间内被多次调用完全没必要发多次请求。做一个简单的请求缓存const cache new Map(); function cachedFetch(url, ttl 60000) { const now Date.now(); if (cache.has(url)) { const { data, timestamp } cache.get(url); if (now - timestamp ttl) { return Promise.resolve(data); } } return fetch(url).then(res res.json()).then(data { cache.set(url, { data, timestamp: now }); return data; }); }去重则是针对同时发起的相同请求——第一个请求还没返回第二个相同请求又来了应该复用第一个请求的Promise而不是发新的。5.3 预加载下一页数据用户在当前页面停留时可以预判他下一步可能访问的页面提前加载数据。比如列表页用户滚动到某个位置可以预加载详情页的数据// 用户hover列表项时预加载详情 listItem.addEventListener(mouseenter, () { const detailUrl /api/detail/${listItem.dataset.id}; // 预加载但不渲染 fetch(detailUrl).then(res res.json()).then(data { prefetchCache.set(detailUrl, data); }); });这样用户真正点击时数据已经在缓存里页面瞬间打开。移动端没有hover事件可以用touchstart或者基于滚动位置预判。6. 性能优化的度量与验证6.1 核心指标怎么看优化不能凭感觉得有数据。几个关键指标指标含义目标值FCP首次内容绘制 1.8sLCP最大内容绘制 2.5sTTI可交互时间 3.8sCLS累积布局偏移 0.1TBT总阻塞时间 200ms这些指标通过PerformanceObserver采集new PerformanceObserver((list) { for (const entry of list.getEntries()) { console.log(LCP:, entry.startTime); } }).observe({ type: largest-contentful-paint, buffered: true });6.2 用Chrome DevTools定位瓶颈Performance面板录制一段操作看火焰图。重点看长任务超过50ms的任务会阻塞主线程导致交互卡顿网络瀑布图看资源加载顺序是否合理有没有可以并行但被串行化的请求内存曲线看有没有内存泄漏频繁GC也会导致卡顿Network面板看请求瀑布重点关注首屏关键请求是否被非关键请求阻塞有没有重复请求资源大小是否合理图片是否压缩、JS是否混淆压缩6.3 优化前后的对比方法做优化一定要有对比。我的做法是优化前用Lighthouse跑一遍记录各项指标每次只改一个变量改完再跑一遍记录数据确认改动有效再继续下一个不要一次性改一堆东西否则出了问题不知道是哪个改动导致的。另外测试要在相同的网络条件下进行用DevTools的Network Throttling模拟3G/4G环境。注意本地开发环境的性能数据没有参考价值。本地服务器响应快、无网络延迟很多问题在本地根本暴露不出来。一定要在真实环境或者模拟真实网络条件下测试。7. 我踩过的那些异步加载的坑7.1 动态import的路径问题用Webpack做动态import时路径不能完全动态// 这样写Webpack无法分析会报错 const module await import(./modules/${name}.js); // 需要给出部分静态路径 const module await import(./modules/${name}.js.replace(./modules/, ./modules/));实际上Webpack要求动态import的路径至少有一部分是静态的这样它才能确定打包范围。我一般会维护一个映射表const moduleMap { chart: () import(./modules/chart.js), editor: () import(./modules/editor.js) };7.2 异步组件的加载状态处理异步加载的组件在加载期间需要有占位加载失败需要有降级。我见过项目因为异步组件加载失败导致整个页面白屏的。正确做法const AsyncComponent lazy(() import(./HeavyComponent).catch(() ({ default: () div组件加载失败请刷新重试/div })) );7.3 预加载过度导致带宽浪费prefetch用多了用户可能根本不会访问的页面资源也被下载了浪费带宽。特别是在移动端用户流量有限。我的原则是只预加载用户下一步大概率会访问的资源。比如电商应用用户看了商品列表预加载第一个商品的详情是合理的但预加载所有商品的详情就是浪费。7.4 异步脚本的执行时机依赖用async加载的脚本执行时机不确定。如果脚本里依赖了某个全局变量而那个变量在另一个脚本里定义就可能报错。这种问题在本地开发时不一定出现因为本地加载快顺序可能碰巧是对的。到了线上网络波动导致顺序变化问题就暴露了。解决方案要么用defer保证顺序要么在脚本内部做依赖检查function waitFor(condition, timeout 5000) { return new Promise((resolve, reject) { const start Date.now(); const check () { if (condition()) resolve(); else if (Date.now() - start timeout) reject(new Error(超时)); else setTimeout(check, 50); }; check(); }); } // 使用 waitFor(() window.myLib).then(() { // 安全使用myLib });8. 从异步加载延伸出的架构思考异步加载表面上是加载策略往深了看其实是架构分层的问题。哪些代码是核心必须同步加载哪些是边缘可以异步这反映的是你对业务优先级的理解。我在实际项目里会把代码分成三层核心层框架运行时、路由、状态管理同步加载保证应用能跑起来业务层各页面的业务逻辑按路由异步加载增强层图表、编辑器、地图等重型组件用户触发时才加载这个分层不是固定的随着业务发展要调整。比如某个增强层组件变成了核心功能就要考虑提升到业务层甚至核心层。另一个思考是加载策略和缓存策略的配合。异步加载的资源如果缓存策略没做好每次都要重新下载异步的意义就大打折扣。HTTP缓存、Service Worker缓存、内存缓存三层配合才能让异步加载真正发挥价值。移动端性能优化和Julia性能优化与内存管理虽然技术栈不同但核心思路一致识别关键路径把非关键路径异步化同时做好资源调度和缓存。这个思路可以迁移到任何性能优化场景中。最后分享一个我常用的检查清单每次做完异步加载优化后过一遍首屏关键资源是否内联或预加载非关键脚本是否用了defer或async图片是否懒加载且有占位路由是否做了代码分割数据请求是否并行化且处理了竞态是否有请求缓存和去重异步加载失败是否有降级方案优化前后是否有数据对比这套流程走下来基本能覆盖异步加载和性能优化的主要场景。具体参数和策略需要根据项目实际情况调整但方向不会错。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →