企业🤖AI Agent构建引擎,智能编排和调试,一键部署,支持私有化部署方案 广告
[TOC] ## 一 、什么是首屏? 以800x600像素尺寸为标准,当浏览器加载页面后看到第一眼的显示内容为首屏。而从开始加载到浏览器页面显示高度达到600像素且此区域有内容显示的时间为首屏显示时间。 以京东首页为例:![](https://box.kancloud.cn/cf52ef3f82e2f742d6b43322e15dd4eb_1908x1011.png) 当我们打开京东时,第一眼看到的内容即为首屏内容,也就是如上图的内容。 ## 二、为什么要做首屏优化 一个页面的“总加载时间”要比“首屏时间”长,但对于最终用户体验而言,当内容充满首屏的区域时,用户就可以看到网站的主要内容并可以进行各自的选择了。首屏时间的快与慢,直接影响到了用户对网站的认知度。 所以首屏时间的长短对于用户的滞留时间的长短、用户转化率都尤为重要。 ## 三、性能指标 ### FPS 最能反映页面性能的一个指标是 FPS(frame per second),一般系统设定屏幕的刷新率为 60fps,当页面元素动画、滚动或者渐变时绘制速率小于 60,就会不流畅,小于 24 就会卡顿,小于 12 基本认定卡爆了。 1 帧的时长约 16ms,除去系统上下文切换开销,每一帧中只留给我们 10ms 左右的程序处理时间,如果一段脚本的处理时间超过 10ms,那么这一帧就可以被认定为丢失,如果处理时间超过 26ms,可以认定连续两帧丢失,依次类推。我们不能容忍页面中多次出现连续丢失五六帧的情况,也就是说必须想办法分拆执行时间超过 80ms 的代码程序,这个工作并不轻松。 页面在刚开始载入的时候,需要初始化很多程序,也可能有大量耗时的 DOM 操作,所以前 1s 的必要操作会导致帧率很低,我们可以忽略。当然,这是对 PC 而言,Mobile 内容少,无论是 DOM 还是 JS 脚本量都远小于 PC,1s 可能就有点长了。 ### DOMContentLoaded 和 Load DOM 加载并且解析完成才会触发 DOMContentLoaded 事件,倘若源码输出的内容过多,客户端解析 DOM 的时间也会响应加长,不要小看这里的解析时间,如果 DOM 数量增加 2000 个并且嵌套层级较深,解析时间也会相应增加 50-200ms,这个消耗对大多数页面来说其实是没必要的,保证首屏输出即可,后续的内容只保留钩子,利用 JS 动态渲染。 Load 时间可以用来衡量首屏加载中,客户端接受的信息总量,如果在首屏中充满了大尺寸图片或者客户端与后端建立连接次数较多,Load 时间也会相应被拖长。 ### 流畅度 流畅度是对 FPS 的视觉反馈,FPS 值越高,视觉呈现越流畅。为了保障页面的加载速度,很多内容不会在页面打开的时候全部加载到客户端。这里提到的流畅度是等待过程中的视觉缓冲,如下方是 Google Plus 页面的一个效果图: ![](https://box.kancloud.cn/9488b60154a5ac3ebf67e69affb82ef1_446x537.gif) 墙内访问 google 的速度不是很快,上面元素中的的很多内容都是通过异步方式加载,而从上图可以看出 Google 并没有让用户产生等待的焦虑感。 ## 三、优化方案 ### 京东 #### 缓存脚本和数据 打开京东的网站(不要滚动鼠标和键盘),右键查看源代码会发现京东首页的DOM树出奇的简单,页面DOM中多含有mod_lazyload的类 ~~~ <div class="J_f J_lazyload J_f_lift mod_lazyload need_ani chn" id="portal_8" data-backup="basic_8" data-source="cms:basic_8" data-tpl="portal_tpl"> ~~~ 再看localStorage ![](https://box.kancloud.cn/b9cf41c467b4ec657ada5153adf529ee_1594x466.png) 尤其是观察location下面的键值对,会发现它们的值中多存在一串完整的类似于html的内容 ![](https://box.kancloud.cn/6229aa9261c27b26b744c2bf0b76498c_974x143.png) 由上面的结构我们可知jd.com已经将它们的页面结构放到了localstorage,不难想象这只是它页面中的其中一个模块的内容。 把需要请求的路径写在 dom 上(例如:data-tpl="elevator_tpl"),用户滚动时,一旦该模块进入了视窗,则请求 dom 上对应的 data-tpl 地址,拿到渲染这个模块所需要的脚本和数据,不过这中间还有一层本地缓存 localstorage,如果在本地缓存中匹配到了对应的 hash string 内容,则直接渲染,否则请求到数据之后更新本地缓存。localstorage中的 version 会在页面加载时候,与后端文件 hash相对比,hash不变直接取localstorage中的内容(当然也可以使用cookie判断版本)。 这里其实存在两个请求,一个请求是加载数据和脚本,而这里的内容是: 为啥不在返回的内容中直接把脚本也输出出来?为了让数据充分缓存下了不少功夫。数据的变化频率比较高,如果数据和初始化脚本包装在一起,虽然节约了一个请求,但一旦数据变化,整个脚本都得重新加载,而将数据和脚本分离,脚本可以长期缓存在本地,单独请求数据,这个量会小很多。直接改变上面的 version 版本号便可以让浏览器重新请求最新脚本。 从上面可以看出,任何一个模块的改动,在前端只会引起一个较小的加载变化,加上 http 的缓存策略,服务器的压力也是很小的。 #### CSS 为了求快,首页是没有css外链的,这样会再发起多次请求。 页面切分为模块化加载,对应模块下的css交给js或jsonp请求返回。 <br> #### JS加载 京东采用请求的方式减少了与服务器交互的时间 ~~~ <script src="//misc.360buyimg.com/??/jdf/lib/jquery-1.6.4.js,/jdf/2.0.0/ui/ui/1.0.0/ui.js,/mtd/pc/index/gb/lib.min.js,/mtd/pc/base/1.0.0/base.js,/mtd/pc/common/js/o2_ua.js,/mtd/pc/index/home/index.min.js,/mtd/pc/index/home/init.min.js"></script> ~~~ <br> #### js如何执行? 懒执行,有交互才执行,有兴趣的可以看看小胡子哥的[淘宝首页性能优化实践](http://www.barretlee.com/blog/2016/04/01/optimization-in-taobao-homepage/)这篇文章 <br> #### 图片如何处理? 图片在其他屏(非首屏)都采用懒加载的模式,这样既能节省流量,也能减少请求数或延迟请求数。 <br> #### 服务器 * 少量静态文件的域名,图片与iconfont均是放在同一个域下,减少DNS的解析事件,最好做到域名收敛 * 模块化接口的支持 * 首屏内容最好做到静态缓存 <br> <br> ### 淘宝 #### 关键模块优先加载 不论用户首屏的面积有多大,保证关键模块优先加载。下面代码片段是初始化所有模块的核心部分: ~~~ $('.J_Module').each(function(mod) { var $mod = $(mod); var name = $mod.attr('tms'); var data = $mod.attr('tms-data'); if($mod.hasClass('tb-pass')) { Reporter.send({ msg: "跳过模块 " + name }); return; } // 保证首屏模块先加载 if (/promo|tmall|tanx|notice|member/.test(name)) { window.requestNextAnimationFrame(function(){ // 最后一个参数为 Force, 强制渲染, 不懒加载处理 new Loader($mod, data, /tanx/.test(name)); }); } else { // 剩下的模块进入懒加载队列 lazyQueue.push({ $mod: $mod, data: data, force: /fixedtool|decorations|bubble/.test(name) }); } }); ~~~ TMS 输出的模块都会包含一个 .J_Module 钩子,并且会预先加载 js 和 css 文件。 对于无 JS 内容的模块,会预先打上 tb-pass 的标记,初始化的时候跳过此模块;对于首屏模块关键模块,会直接进入懒加载监控: ~~~ // $box 进入浏览器视窗后渲染 // new Loader($box, data) -> datalazyload.addCallback($box, function() { self.loadModule($box, data); }); // $box 立即渲染 // new Loader($box, data, true) -> self.loadModule($box, data); ~~~ 除必须立即加载的模块外,关键模块被加到懒加载监控,原因是,部分用户进入页面就可能急速往下拖拽页面,此时,没必要渲染这些首屏模块。 非关键模块统一送到 lazyQueue 队列,没有基于将非关键模块加入到懒加载监控,这里有两个原因: * 一旦加入监控,程序滚动就需要对每个模块做计算判断,模块太多,这里可能存在性能损失 * 如果关键模块还没有加载好,非关键模块进入视窗就会开始渲染,这势必会影响关键模块的渲染 那么,什么时候开始加载非关键模块呢? ~~~ var __lazyLoaded = false; function runLazyQueue() { if(__lazyLoaded) { return; } __lazyLoaded = true; $(window).detach("mousemove scroll mousedown touchstart touchmove keydown resize onload", runLazyQueue); var module; while (module = lazyQueue.shift()) { ~function(m){ // 保证在浏览器空闲时间处理 JS 程序, 保证不阻塞 window.requestNextAnimationFrame(function() { new Loader(m.$mod, m.data, m.force); }); }(module); } } $(window).on("mousemove scroll mousedown touchstart touchmove keydown resize onload", runLazyQueue); // 担心未触发 onload 事件, 5s 之后执行懒加载队列 window.requestNextAnimationFrame(function() { runLazyQueue(); }, 5E3); ~~~ 两种请求下会开始将非关键模块加入懒加载监控: * 当页面中触发 mousemove scroll mousedown touchstart touchmove keydown resize onload 这些事件的时候,说明用户开始与页面交互了,程序必须开始加载。 * 如果用户没有交互,但是页面已经 onload 了,程序当然不能浪费这个绝佳的空档机会,趁机加载内容;经测试,部分情况下,onload 事件没有触发(原因尚不知),所以还设定了一个超时加载,5s 之后,不论页面加载情况如何,都会将剩下的非关键模块加入到懒加载监控。 #### 懒执行,有交互才执行 如果说上面的优化叫做懒加载,那么这里的优化可以称之为懒执行。 首页上有几个模块是包含交互的,如头条区域的 tab ,便民服务的浮层和主题市场的浮层,部分用户进入页面可能根本不会使用这些功能,所以程序上并没有对这些模块做彻底的初始化,而是等到用户 hover 到这个模块上再执行全部逻辑。 #### 更懒的执行,刷新页面才执行 首屏中有两个次要请求,一个是主题市场的 hot 标,将用户最常逛的三个类目打标;第二个是个人中心的背景,不同的城市会展示不同的背景图片,这里需要请求拿到城市信息。 这两处的渲染策略都是,在程序的 idle(空闲)时期,或者 window.onload 十秒之后去请求,然后将请求的结果缓存到本地,当用户第二次访问淘宝首页时能够看到效果。这是一种更懒的执行,用户刷新页面才看得到.这种优化是产品能够接受,也是技术上合理的优化手段。 #### 图片尺寸的控制和懒加载 ~~~ <img src='//g.alicdn.com/s.gif' data-src='//g.alicdn.com/real/path/to/img.png' /> ~~~ 阿里 CDN 是支持对图片尺寸做压缩处理的,如下图为 200x200 尺寸的图片: ![](https://box.kancloud.cn/9e171e8b0775e87119afe51bb5ba483f_200x200.png) 200x200 加上 _100x100.jpg 的参数后,会变成小尺寸: ![](https://box.kancloud.cn/c446c045538d2c90cbe2bc92b6cbe8a0_100x100.png) 100x100 我们知道 webp 格式的图片比对应的 jpg 要小三分之一,如上图加上 _.webp 参数后: ![](https://box.kancloud.cn/3dc032146d530997bdc935984753d822_100x100.png) 100x100 webp(不支持 webp 格式的浏览器展示不出来这张图片) 视觉效果并没有什么折扣,但是图片体积缩小了三分之一,图片越大,节省的越明显。显然,淘宝首页的所有图片都做了如上的限制,针对坑位大小对图片做压缩处理,只是这里需要注意的是,运营填写的图片可能已经是压缩过的,如: ~~~ $img = '//g.alicdn.com/real/path/to/img.png_400x400.jpg'; <img src='{{$img}}_100x100jpg_.webp' /> ~~~ 上面这种情况,图片是不会正确展示的。首页对所有的图片的懒加载都做了统一的函数处理: ~~~ src = src.replace(/\s/g, ''); var arr; if (/(_\d{2,}x\d{2,}\w*?\.(?:jpg|png)){2,}/.test(src) && src.indexOf('_!!') == -1) { arr = src.split('_'); if (arr[arr.length - 1] == '.webp') { src = [arr[0], arr[arr.length - 2], arr[arr.length - 1]].join('_'); } else { src = [arr[0], arr[arr.length - 1]].join('_'); } } if (src.indexOf('_!!') > -1) { src = src.replace(/((_\d{2,}x\d{2,}[\w\d]*?|_co0)\.(jpg|png))+/, '$1'); } WebP.isSupport(function(isSupportWebp) { // https 协议访问存在问题 IE8,去 schema if (/^http:/.test(src)) { src = src.slice(5); } // 支持 webp 格式,并且 host 以 taobaocdn 和 alicdn 结尾,并且不是 s.gif 图片 if (isSupportWebp && /(taobaocdn|alicdn)\.com/.test(src) && (src.indexOf('.jpg') || src.indexOf('.png')) && !/webp/.test(src) && !ignoreWebP && !/\/s\.gif$/.test(src)) { src += '_.webp'; } $img.attr('src', src); }); ~~~ #### 模块去钩子,走配置 TMS 的模块在输出的时候会将数据的 id 放在钩子上: ~~~ <div class='J_Module' tms-datakey='2483'></div> ~~~ 如果模块是异步展示的,可以通过 tms-datakey 找到模块数据,而首页的个性化是从几十上百个模块中通过算法选出几个,如果把这些模块钩子全部输出来,虽说取数据方便了很多,却存在大量的冗余,对此的优化策略是:将数据格式相同的模块单独拿出来,新建页面作为数据页。所以可以在源码中看到好几段这样的配置信息: ~~~ <textarea class="tb-hide">[{"backup":"false","baseid":"1","mid":"222726","name":"iFashion","per":"false","tid":"3","uid":"1000"},{"backup":"false","baseid":"3","mid":"222728","name":"美妆秀","per":"false","tid":"3","uid":"1001"},{"backup":"false","baseid":"4","mid":"222729","name":"爱逛街","per":"false","tid":"4","uid":"1002"},{"backup":"false","baseid":"2","mid":"222727","name":"全球购","per":"false","tid":"4","uid":"1003"}] </textarea> ~~~ 减少了大量的源码以及对 DOM 的解析。 #### 低频修改模块,缓存请求 有一些模块数据是很少被修改的,比如接口的兜底数据、阿里 APP 模块数据等,可以通过调整参数,设置模块的缓存时间,如: ~~~ io({ url: URL, dataType: 'jsonp', cache: true, jsonpCallback: 'jsonp' + Math.floor(new Date / (1000 * 60)), success: function() { //... } }); ~~~ Math.floor(new Date / (1000 * 60)) 这个数值在一分钟内是不会发生变化的,也就是说将这个请求在本地缓存一分钟,对于低频修改模块,缓存时间可以设置为一天,即: ~~~ Math.floor(new Date / (1000 * 60 * 60 * 24)) ~~~ 当然,我们也可以采用本地储存的方式缓存这个模块数据: ~~~ offline.setItem('cache-moduleName', JSON.stringify(data), 1000 * 60 * 60 * 24); ~~~ 缓存过期时间设置为 1 天,淘宝首页主要采用本地缓存的方式。 #### 从Chrome中找出优化点 ![](https://box.kancloud.cn/b8722d4206f165a73fa25aa5d544b98c_1818x1096.png) * 在 1.0s 左右存在一次 painting 阻塞,可能因为一次性展示的模块面积过大 * 从 FPS 的柱状图可以看出,在 1.5s-2.0s 之间,存在几次 Render 和 JavaScript 丢帧 * 从多出的红点可以看出页面 jank 次数,也能够定位到代码堆栈 在优化的过程中需要更多地思考,如何让阻塞的脚本分批执行,如何将长时间执行的脚本均匀地分配到时间线上。这些优化都体现在代码的细节上,宏观上的处理难以有明显的效果。当然,在宏观上,淘宝首页也有一个明显的优化: ~~~ // //gist.github.com/miksago/3035015#file-raf-js (function() { var lastTime = 0; var vendors = ['ms', 'moz', 'webkit', 'o']; for(var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { window.requestAnimationFrame = window[vendors[x]+'RequestAnimationFrame']; window.cancelAnimationFrame = window[vendors[x]+'CancelAnimationFrame'] || window[vendors[x]+'CancelRequestAnimationFrame']; } if (!window.requestAnimationFrame) { window.requestAnimationFrame = function(callback, element) { var currTime = new Date().getTime(); var timeToCall = Math.max(0, 16 - (currTime - lastTime)); var id = window.setTimeout(function() { callback(currTime + timeToCall); }, timeToCall); lastTime = currTime + timeToCall; return id; }; } if (!window.cancelAnimationFrame) { window.cancelAnimationFrame = function(id) { clearTimeout(id); }; } })(); ~~~ 这段代码基本保证每个模块的初始化都是在浏览器空闲时期,减少了很多不必要的丢帧。这个优化也可以被应用到每个模块的细节代码之中,不过优化难度会更高。 ## 参考资料 [淘宝首页性能优化实践](https://www.barretlee.com/blog/2016/04/01/optimization-in-taobao-homepage/) [关于首屏性能优化的总结【原创】](https://www.cnblogs.com/jingh/p/6531105.html)