减少首屏渲染时间的方法有很多,总的来讲可以分成两大部分 :资源加载优化 和 页面渲染优化。
下面主要简答地介绍一下首屏时间和优化首屏加载的部分做法,面试的时候不用答得很全面,抓住其中最重要的几个点展开就很ok了。
如图,打开淘宝,页面会逐渐渲染成图3的样子,此时,用户没有任何操作,这里的全部页面就是首屏。
用户往下滑,会加载新的商品数据和图片数据,此时,新加载出来的页面,从定义上来说并不是首屏页面。
说白了,首屏就是,页面打开完成后,第一个可视区域中的所有内容。
一般来说,用户打开页面后,在1.5秒内完成首屏渲染,能带来较为友好的用户体验,若超过3秒,用户就会容易感到不耐烦,超过5秒,用户流失率就会显著提升。如果网络卡,页面也不应是白屏,应该先加载一部分,并给出加载动画,让用户可以明显感觉到页面正在加载中,因此,我们可以看到,如上图一,商品还未加载出来时,淘宝会先给出页面框架,让用户感到安心,再一步步从图二加载到图三。
当然,web页面渲染这种事,从来都是越快越好,因此也诞生了PWA(渐进式web app)模式,通过service worker将静态资源install下来,使得网页能像使用app一样。目前,国内的微博、饿了么都已实现,可亲自体验一下(右键install)。
以上种种,都是各个产品经理大佬和用户心理学大佬经过实践和分析给出来的参考,首屏就是网页给用户的第一印象,要十分重视首屏渲染的优化。
这是面试极其高频的考题:从输入url到显示网页出来,发生了那些过程?
发生这些过程的所有消耗的时间的总和,就是首屏加载时间。
大概过程如下:
1、浏览器的地址栏输入URL并按下回车。
2、浏览器查找当前URL是否存在缓存,并比较缓存是否过期。
3、DNS解析URL对应的IP。
4、根据IP建立TCP连接(三次握手)。
5、HTTP发起请求。
6、服务器处理请求,浏览器接收HTTP响应。
7、渲染页面,构建DOM树。
8、关闭TCP连接(四次挥手)。
这是侧重从HTTP和TCP/IP层面上来说的,因而忽略了数据链路层和物理层方面的内容。
常用方法:
主要涉及到的api为performance和DOMContentLoaded。
由于 React
、Vue
等框架的出现,DOMContentLoaded
事件已经失去了原本的作用,现在 “首屏渲染时间” 的计算大多数时候是依靠人工打点、performanceAPI和DOM树的MutationObeserver方法(个人了解到的,仅供参考)。
performance 中的属性如下。
// 获取 performance 数据
var performance = {
// memory 是非标准属性,只在 Chrome 有
// 财富问题:我有多少内存
memory: {
usedJSHeapSize: 16100000, // JS 对象(包括V8引擎内部对象)占用的内存,一定小于 totalJSHeapSize
totalJSHeapSize: 35100000, // 可使用的内存
jsHeapSizeLimit: 793000000 // 内存大小限制
},
// 哲学问题:我从哪里来?
navigation: {
redirectCount: 0, // 如果有重定向的话,页面通过几次重定向跳转而来
type: 0 // 0 即 TYPE_NAVIGATENEXT 正常进入的页面(非刷新、非重定向等)
// 1 即 TYPE_RELOAD 通过 window.location.reload() 刷新的页面
// 2 即 TYPE_BACK_FORWARD 通过浏览器的前进后退按钮进入的页面(历史记录)
// 255 即 TYPE_UNDEFINED 非以上方式进入的页面
},
timing: {
// 在同一个浏览器上下文中,前一个网页(与当前页面不一定同域)unload 的时间戳,如果无前一个网页 unload ,则与 fetchStart 值相等
navigationStart: 1441112691935,
// 前一个网页(与当前页面同域)unload 的时间戳,如果无前一个网页 unload 或者前一个网页与当前页面不同域,则值为 0
unloadEventStart: 0,
// 和 unloadEventStart 相对应,返回前一个网页 unload 事件绑定的回调函数执行完毕的时间戳
unloadEventEnd: 0,
// 第一个 HTTP 重定向发生时的时间。有跳转且是同域名内的重定向才算,否则值为 0
redirectStart: 0,
// 最后一个 HTTP 重定向完成时的时间。有跳转且是同域名内部的重定向才算,否则值为 0
redirectEnd: 0,
// 浏览器准备好使用 HTTP 请求抓取文档的时间,这发生在检查本地缓存之前
fetchStart: 1441112692155,
// DNS 域名查询开始的时间,如果使用了本地缓存(即无 DNS 查询)或持久连接,则与 fetchStart 值相等
domainLookupStart: 1441112692155,
// DNS 域名查询完成的时间,如果使用了本地缓存(即无 DNS 查询)或持久连接,则与 fetchStart 值相等
domainLookupEnd: 1441112692155,
// HTTP(TCP) 开始建立连接的时间,如果是持久连接,则与 fetchStart 值相等
// 注意如果在传输层发生了错误且重新建立连接,则这里显示的是新建立的连接开始的时间
connectStart: 1441112692155,
// HTTP(TCP) 完成建立连接的时间(完成握手),如果是持久连接,则与 fetchStart 值相等
// 注意如果在传输层发生了错误且重新建立连接,则这里显示的是新建立的连接完成的时间
// 注意这里握手结束,包括安全连接建立完成、SOCKS 授权通过
connectEnd: 1441112692155,
// HTTPS 连接开始的时间,如果不是安全连接,则值为 0
secureConnectionStart: 0,
// HTTP 请求读取真实文档开始的时间(完成建立连接),包括从本地读取缓存
// 连接错误重连时,这里显示的也是新建立连接的时间
requestStart: 1441112692158,
// HTTP 开始接收响应的时间(获取到第一个字节),包括从本地读取缓存
responseStart: 1441112692686,
// HTTP 响应全部接收完成的时间(获取到最后一个字节),包括从本地读取缓存
responseEnd: 1441112692687,
// 开始解析渲染 DOM 树的时间,此时 Document.readyState 变为 loading,并将抛出 readystatechange 相关事件
domLoading: 1441112692690,
// 完成解析 DOM 树的时间,Document.readyState 变为 interactive,并将抛出 readystatechange 相关事件
// 注意只是 DOM 树解析完成,这时候并没有开始加载网页内的资源
domInteractive: 1441112693093,
// DOM 解析完成后,网页内资源加载开始的时间
// 在 DOMContentLoaded 事件抛出前发生
domContentLoadedEventStart: 1441112693093,
// DOM 解析完成后,网页内资源加载完成的时间(如 JS 脚本加载执行完毕)
domContentLoadedEventEnd: 1441112693101,
// DOM 树解析完成,且资源也准备就绪的时间,Document.readyState 变为 complete,并将抛出 readystatechange 相关事件
domComplete: 1441112693214,
// load 事件发送给文档,也即 load 回调函数开始执行的时间
// 注意如果没有绑定 load 事件,值为 0
loadEventStart: 1441112693214,
// load 事件的回调函数执行完毕的时间
loadEventEnd: 1441112693215
// 字母顺序
// connectEnd: 1441112692155,
// connectStart: 1441112692155,
// domComplete: 1441112693214,
// domContentLoadedEventEnd: 1441112693101,
// domContentLoadedEventStart: 1441112693093,
// domInteractive: 1441112693093,
// domLoading: 1441112692690,
// domainLookupEnd: 1441112692155,
// domainLookupStart: 1441112692155,
// fetchStart: 1441112692155,
// loadEventEnd: 1441112693215,
// loadEventStart: 1441112693214,
// navigationStart: 1441112691935,
// redirectEnd: 0,
// redirectStart: 0,
// requestStart: 1441112692158,
// responseEnd: 1441112692687,
// responseStart: 1441112692686,
// secureConnectionStart: 0,
// unloadEventEnd: 0,
// unloadEventStart: 0
}
};
1、首屏模块标签标记法
在HTML文档中对标记首屏内容的结束位置。只适用于不需要通过拉取数据以及不考虑图片等资源加载的情况。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>首屏</title>
<script type="text/javascript">
window.pageStartTime = Date.now();
</script>
<link rel="stylesheet" href="common.css">
<link rel="stylesheet" href="page.css">
</head>
<body>
<!-- 首屏可见模块1 -->
<div class="module-1"></div>
<!-- 首屏可见模块2 -->
<div class="module-2"></div>
<script type="text/javascript">
window.firstScreen = Date.now();
</script>
<!-- 首屏不可见模块3 -->
<div class="module-3"></div>
<!-- 首屏不可见模块4 -->
<div class="module-4"></div>
</body>
</html>
首屏时间 = firstScreen
- performance.timing.navigationStart
2、统计首屏内图片完成加载的时间
通常首屏内容加载最慢的就是图片资源,因此我们会把首屏内加载最慢的图片的时间当做首屏的时间。
首屏时间 = 加载最慢的图片的时间点 - performance.timing.navigationStart
3、自定义模块内容计算法
由于统计首屏图片完成加载的时间比较复杂。因此我们在业务中通常会通过自定义模块内容,来简化首屏时间。如下面的做法:
在页面渲染的过程,导致加载速度慢的因素可能如下:
由于前端优化的方案有非常多,详细展开会花费很大的篇幅,可以按照这张我做的思维导图来捋一捋各种实现思路。这里只介绍主要的几个面试的切入点,也是平常业务中最常见的。
常见的几种SPA首屏优化方式
常用的手段是路由懒加载,把不同路由对应的组件分割成不同的代码块,待路由被请求的时候会单独打包路由,使得入口文件变小,加载速度大大增加
例如,在vue-router
配置路由的时候,采用动态加载路由的形式
routes:[
path: 'index/',
name: 'Index',
component: () => import('./views/Index.vue')
]
以函数的形式加载路由,这样就可以把各自的路由文件分别打包,只有在解析给定的路由时,才会加载路由组件
在webpack4中,一般是 uglify丑化混淆+tree-shaking。
概念:1 个模块可能有多个⽅法,只要其中的某个⽅法使⽤到了,则整个⽂件都会被打到 bundle ⾥⾯去,tree shaking 就是只把⽤到的⽅法打⼊ bundle ,没⽤到的⽅法会在 uglify 阶段被擦除掉。
代码擦除: uglify 阶段删除⽆⽤代码。
还有很多…这里就不详细介绍下去了。
后端返回资源问题:
前端合理利用localStorage
采用HTTP
缓存,设置Cache-Control
,Last-Modified
,Etag
等响应头(强缓存和弱缓存)
采用Service Worker
离线缓存。
Service Worker有以下几个特点:
。。。。。。
在日常使用UI
框架,例如element-UI
、或者antd
,我们经常性直接引用整个UI
库
import ElementUI from 'element-ui'
Vue.use(ElementUI)
但实际上用到的组件只有按钮,分页,表格,输入与警告,所以我们要按需引用。
借助 babel-plugin-component,我们可以只引入需要的组件,以达到减小项目体积的目的。
import Vue from 'vue';
import { Button, Select } from 'element-ui';
import App from './App.vue';
Vue.component(Button.name, Button);
Vue.component(Select.name, Select);
/* 或写为
* Vue.use(Button)
* Vue.use(Select)
*/
new Vue({
el: '#app',
render: h => h(App)
});
或者也可以这么写
import Vue from 'vue';
import {
Pagination,
Dialog,
Autocomplete,
Dropdown,
} from 'element-ui';
Vue.use(Pagination);
Vue.use(Dialog);
Vue.use(Autocomplete);
Vue.use(Dropdown);
new Vue({
el: '#app',
render: h => h(App)
});
假设A.js
文件是一个常用的库,现在有多个路由使用了A.js
文件,这就造成了重复下载。
对于之前使用webpack3的项目,多使用CommonsChunkPlugin这个plugin来实现模块的分离。
CommonsChunkPlugin主要是用来提取第三方库和公共模块,避免首屏加载的bundle文件或者按需加载的bundle文件体积过大,从而导致加载时间过长,是一把优化项目的利器。
CommonsChunkPlugin提及到chunk主要有以下三种:
CommonsChunkPlugin插件可以根据自定义的配置实现各种分离功能,例如:
可参考的解决方案:在webpack
的config
文件中,修改CommonsChunkPlugin
的配置
minChunks: 3
minChunks
为3表示会把使用3次及以上的包抽离出来,放进公共依赖文件,避免了重复加载组件
在webpack4中,CommonsChunkPlugin多被舍弃。
webpack4
废弃了CommonsChunkPlugin
插件,使用optimization.splitChunks
和optimization.runtimeChunk
来代替,原因可以参考《webpack4:连奏中的进化》一文。
关于runtimeChunk
参数,有的文章说是提取出入口chunk中的runtime部分,形成一个单独的文件,由于这部分不常变化,可以利用缓存。
splitChunks
中默认的代码自动分割要求是下面这样的:
node_modules中的模块或其他被重复引用的模块
就是说如果引用的模块来自node_modules
,那么只要它被引用,那么满足其他条件时就可以进行自动分割。否则该模块需要被重复引用才继续判断其他条件。(对应的就是下文配置选项中的minChunks
为1或2的场景)
分离前模块最小体积下限(默认30k,可修改)
30k是官方给出的默认数值,它是可以修改的,上一节中已经讲过,每一次分包对应的都是服务端的性能开销的增加,所以必须要考虑分包的性价比。
对于异步模块,生成的公共模块文件不能超出5个(可修改)
触发了懒加载模块的下载时,并发请求不能超过5个,对于稍微了解过服务端技术的开发者来说,**【高并发】和【压力测试】**这样的关键词应该不会陌生。
对于入口模块,抽离出的公共模块文件不能超出3个(可修改)。也就是说一个入口文件的最大并行请求默认不得超过3个,原因同上。
splitChunks
的在webpack
4.0以上版本中的用法是下面这样的:
module.exports = {
//...
optimization: {
splitChunks: {
chunks: 'async',//默认只作用于异步模块,为`all`时对所有模块生效,`initial`对同步模块有效
minSize: 30000,//合并前模块文件的体积
minChunks: 1,//最少被引用次数
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '~',//自动命名连接符
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
minChunks:1,//敲黑板
priority: -10//优先级更高
},
default: {
test: /[\\/]src[\\/]js[\\/]/
minChunks: 2,//一般为非第三方公共模块
priority: -20,
reuseExistingChunk: true
}
},
runtimeChunk:{
name:'manifest'
}
}
}
这个很好理解,图片只在准备下滑到其区域时,才发送请求加载图片。
图片资源虽然不在编码过程中,但它却是对页面性能影响最大的因素
webpack 在处理图片的时候,会涉及一下几个问题:
跟图片路径有关的文件主要有一下几类:
标签:模板文件里的
标签、主页面的
标签webpack处理图片常用依赖
file-loader:在css 和html 主页中,相对路径的图片都会被处理,发布到输出目录中
url-loader:是对file-loader的封装,因此在安装了file-loader和url-loader 后,在webpack.config.js 中只对url-loader 做配置即可。url-loader的自身功能是给图片一个limit 标准,当图片小于limit时,使用base64 的格式引用图片;否则,使用url 路径引用图片。
image-webpack-loader:压缩图片。这个用得不算太多,因为前期可以直接让UI设计把图片压缩好,像ps 就可以自动的批量压缩图片。
对于所有的图片资源,我们可以进行适当的压缩。
webpack
打包时,会根据webpack.config.js
中url-loader
中设置的limit大小来对图片进行处理,小于limit的图片转化成base64
格式,其余的不做操作。对于比较大的图片我们可以用image-webpack-loader
来压缩图片。
npm install image-webpack-loader --save-dev
在 webpack.config.js
中配置:
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
use:[
{
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('img/[name].[hash:7].[ext]')
}
},
{
loader: 'image-webpack-loader',// 压缩图片
options: {
bypassOnDebug: true,
}
}
]
}
对页面上使用到的icon
,可以使用在线字体图标,或者雪碧图,将众多小图标合并到同一张图上,用以减轻http
请求压力。
拆完包之后,我们再用gzip
做一下压缩 安装compression-webpack-plugin
nmp i compression-webpack-plugin -D
在vue.congig.js
中引入并修改webpack
配置
const CompressionPlugin = require('compression-webpack-plugin')
configureWebpack: (config) => {
if (process.env.NODE_ENV === 'production') {
// 为生产环境修改配置...
config.mode = 'production'
return {
plugins: [new CompressionPlugin({
test: /\.js$|\.html$|\.css/, //匹配文件名
threshold: 10240, //对超过10k的数据进行压缩
deleteOriginalAssets: false //是否删除原文件
})]
}
}
在服务器我们也要做相应的配置 如果发送请求的浏览器支持gzip
,就发送给它gzip
格式的文件 。
express
框架搭建的只要安装一下compression
就能使用
const compression = require('compression')
app.use(compression()) // 在其他中间件使用之前调用
这是基本用法,如果还要对请求进行过滤的话,还要加上
app.use(compression({filter: shouldCompress}))
function shouldCompress (req, res) {
if (req.headers['x-no-compression']) {
// 这里就过滤掉了请求头包含'x-no-compression'
return false
}
return compression.filter(req, res)
}
如果用的是koa,用法和上面的差不多
const compress = require('koa-compress');
const app = module.exports = new Koa();
app.use(compress());
因为node读取的是生成目录中的文件,所以要先用webpack等其他工具进行压缩成gzip,webpack的配置如下
const CompressionWebpackPlugin = require('compression-webpack-plugin');
plugins.push(
new CompressionWebpackPlugin({
asset: '[path].gz[query]',// 目标文件名
algorithm: 'gzip',// 使用gzip压缩
test: new RegExp(
'\\.(js|css)$' // 压缩 js 与 css
),
threshold: 10240,// 资源文件大于10240B=10kB时会被压缩
minRatio: 0.8 // 最小压缩比达到0.8时才会被压缩
})
);
webpack+node的gzip打包初步就介绍到这里,更多用法请移步compression文档:https://github.com/expressjs/compression
可以理解为主线程的一个协程。
众所周知,JavaScript是单线程的,但是碰到一些计算密集型或者高延迟的任务时,会影响整个页面的运行。Web Worker就是在这个环境下诞生的,它也是HTML5的新特性之一。
Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。
Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。
引用自阮一峰老师的文章《Web Worker 使用教程》
使用例子:首屏上使用了async/await或者长达1秒的动画,可能会阻塞到主线程,那么可以使用Web Worker等技术来实现时间分片,将大任务拆分成许多个小任务,使得主线程能抽出空来渲染页面,降低卡顿感。
SSR(server side render),也就是服务端渲染,组件或页面通过服务器生成html字符串,再发送到浏览器。
SSR技术会实现将指定的异步数据在服务端渲染好,再返回给客户端,这样就减轻了客户端的请求异步数据的压力,渲染页面就更快了。
vue
可使用Nuxt.js
框架实现服务端渲染目前就暂时介绍这么多,还有很多优化方案,可以参考文中的思维导图来稍作总结。
https://www.cnblogs.com/dashnowords/p/9545482.html
https://cloud.tencent.com/developer/article/1650697
https://github.com/expressjs/compression
《前端性能测试–Performance API》https://zhuanlan.zhihu.com/p/43746227
《原生js实现图片懒加载(lazyLoad)》https://zhuanlan.zhihu.com/p/55311726