文章目录
在展开性能优化的话题前,看一个常见的问题:
从输入 URL 到页面加载完成,发生了什么?
首先我们需要通过 DNS(域名解析系统)将 URL 解析为对应的 IP 地址,然后与这个 IP 地址确定的那台服务器建立起 TCP 网络连接,随后我们向服务端抛出我们的 HTTP 请求,服务端处理完我们
的请求之后,把目标数据放在 HTTP 响应里返回给客户端,拿到响应数据的浏览器就可以开始走一个渲染的流程。渲染完毕,页面便呈现给了用户,并时刻等待响应用户的操作(如下图所示)。
我们将这个过程切分为如下的过程片段:
网络层面的性能优化
上面两个过程的优化,前端单方面做的事情有限,那么在HTTP请求上,前端可以减少请求次数和减少请求体积,还有服务器越远,一次请求就越慢,那部署时就把静态资源放在离我们更近的 CDN 上。
浏览器端的性能优化
这部分涉及资源加载优化、服务端渲染、浏览器缓存机制的利用、DOM树的构建、网页排版和渲染过程、回流与重绘的考量、DOM的合理操作等。
性能优化知识图谱:
下面我们主要通过网络层面和渲染层面两个维度讲解性能优化。
从输入 URL 到显示页面这个过程中,涉及到网络层面的,有三个主要过程:
对于 DNS 解析和 TCP 连接两个步骤,我们前端可以做的努力非常有限。我们只能从HTTP请求/响应入手优化:
这两个优化点就是我们平时开发常用操作,即资源的压缩和合并,这就要用到我们开发常用到的构建工具webpack。
相信大家使用vue开发的时候,都用到了webpack的打包和压缩。这儿我们主要把注意力放到webpack的性能优化上。 webpack 的优化瓶颈,主要是两个方面:
**不要让 loader 做太多事情——以 babel-loader 为例,**babel-loader 无疑是强大的,但它也是慢的。
最常见的优化方式是,用 include 或 exclude 来帮我们避免不必要的转译,比如 webpack 官方在介绍 babel-loader 时给出的示例:
module: {
rules: [
{
test: /\.js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
}
]
}
这段代码帮我们规避了对庞大的node_modules文件夹或者bower_components文件夹的处理。但通过限定文件范围带来的性能提升是有限的。除此之外,如果我们选择开启缓存将转译结果缓存至文件系统,则至少可以将 babel-loader 的工作效率提升两倍。要做到这点,我们只需要为 loader 增加相应的参数设定:
loader: 'babel-loader?cacheDirectory=true'
使用Tree-Shaking删除冗余代码 举个例子: 我们在pages.js中导出了两个变量,如下:
export const page1 = xxx
export const page2 = xxx
我们在某个文件中导入了这两个个变量,如代码:
import { page1, page2 } from './pages'
// show是事先定义好的函数
show(page1)
page2实际上没有用到,在打包的时候,我们应该把page2这个模块给删除,那么就用到Tree-Shaking。
那么使用 tree shaking,需要注意的是如下几点:
以UglifyJsPlugin 为例,看一下如何在压缩过程中对碎片化的冗余代码(如 console 语句、注释等)进行自动化删除:
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
module.exports = {
plugins: [
new UglifyJsPlugin({
// 允许并发
parallel: true,
// 开启缓存
cache: true,
compress: {
// 删除所有的console语句
drop_console: true,
// 把使用多次的静态值自动定义为变量
reduce_vars: true,
},
output: {
// 不保留注释
comment: false,
// 使输出的代码尽可能紧凑
beautify: false
}
})
]
}
webpack3需要手动引入UglifyJsPlugin插件,webpack4已经默认使用 uglifyjs-webpack-plugin 对代码做了压缩。在 webpack4 中,我们是通过配置 optimization.minimize 与 optimization.minimizer 来自定义压缩相关的操作的。
详细了解tree shaking,可以参考如下链接:http://webpack.html.cn/guides/tree-shaking.html
我们日常开发中,其实还有一个便宜又好用的压缩操作:开启 Gzip。
具体的做法非常简单,只需要你在你的request headers中加上这么一句:
accept-encoding:gzip
Gzip 压缩背后的原理,是在一个文本文件中找出一些重复出现的字符串、临时替换它们,从而使整个文件变小。根据这个原理,文件中代码的重复率越高,那么压缩的效率就越高,使用 Gzip 的收益也就越大。反之亦然。
一般来说,Gzip 压缩是服务器的活儿:服务器了解到我们这边有一个 Gzip 压缩的需求,它会启动自己的 CPU 去为我们完成这个任务。
而压缩文件这个过程本身是需要耗费时间的,大家可以理解为我们以服务器压缩的时间开销和CPU开销(以及浏览器解析压缩文件的开销)为代价,省下了一些传输过程中的时间开销。
下面我们来讲解下使用webpack的compression-webpack-plugin插件实现压缩。
在config/index.js文件中打开Gzip开关,配置需要压缩的文件扩展名:
productionGzip: true,
productionGzipExtensions: ['js', 'css'],
webpack.prod.conf.js中设置具体压缩配置项:
if (config.build.productionGzip) {
const CompressionWebpackPlugin = require('compression-webpack-plugin')
webpackConfig.plugins.push(
new CompressionWebpackPlugin({
asset: '[path].gz[query]',
algorithm: 'gzip',
test: new RegExp(
'\\.(' +
config.build.productionGzipExtensions.join('|') +
')$'
),
threshold: 10240,
minRatio: 0.8
})
)
}
这样在打包后就会产生.gz后缀的文件。接下来就是服务端需要做的处理了(如:服务端nginx启动gzip_static)。
可能大部分的人在图片性能优化的工作中主要集中在JavaScript和CSS上。关注JavaScript和CSS的重点也是如何能更快的下载图片。图片是用户可以直观看到的,他们并不关注JS和CSS。确实,JS 和 CSS 会影响图片内容的展示,尤其是会影响图片的展示方式(比如图片轮播,CSS 背景图和媒体查询)。但是我认为 JS 和 CSS 只是展示图片的方式。在页面加载的过程中,应当先让图片和文字先展示,而不是试图保证 JS 和 CSS 更快下载完成。这是《高性能网站建设指南》的作者 Steve Souders在他的博客中提到的。
所谓图片优化这个操作,是以牺牲一部分成像质量为代价的。因此我们的主要任务,是尽可能地去寻求一个质量与性能之间的平衡点。
时下应用较为广泛的 Web 图片格式有 JPEG/JPG、PNG、WebP、Base64、SVG 等。
在计算机中,像素用二进制数来表示。不同的图片格式中像素与二进制位数之间的对应关系是不同的。一个像素对应的二进制位数越多,它可以表示的颜色种类就越多,成像效果也就越细腻,文件体积相应也会越大。
一个二进制位表示两种颜色(0|1 对应黑|白),如果一种图片格式对应的二进制位数有 n 个,那么它就可以呈现 2^n 种颜色。
特点:有损压缩、体积小、加载快、不支持透明。
JPG 图片经常作为大的背景图、轮播图或 Banner 图出现。
JPG 最大的特点是有损压缩。这种压缩方式仍然是一种高质量的压缩方式:当我们把图片体积压缩至原有体积的 50% 以下时,JPG 仍然可以保持住 60% 的品质。此外,JPG 格式以 24 位存储单个图,可以呈现多达 1600 万种颜色,足以应对大多数场景下对色彩的要求。
当它处理矢量图形和 Logo 等线条感较强、颜色对比强烈的图像时,人为压缩导致的图片模糊会相当明显。还有JPEG 图像不支持透明度处理,透明图片需要使用 PNG 格式。
特点:无损压缩、质量高、体积大、支持透明。
由于 PNG 在处理线条和颜色对比度方面的优势,我们主要用它来呈现小的 Logo、颜色简单且对比强烈的图片或背景等。
PNG是一种无损压缩的高保真的图片格式。8 和 24指的是二进制数的位数。8 位的 PNG 最多支持 256 种颜色,而 24 位的可以呈现约 1600 万种颜色。
体积大。
当你追求最佳的显示效果、并且不在意文件体积大小时,推荐使用 PNG-24 。但在实际中,为了避免体积过大,以及遇到适合 PNG 的场景时,也会优先选择更为小巧的 PNG-8。
如何确定一张图片是该用 PNG-8 还是 PNG-24 去呈现呢?好的做法是把图片先按照这两种格式分别输出,看 PNG-8 输出的结果是否会带来肉眼可见的质量损耗,并且确认这种损耗是否在我们可接受的范围内,基于对比的结果去做判断。
特点:文本文件、体积小、不失真、兼容性好。
SVG(可缩放矢量图形)是一种基于 XML 语法的图像格式。它和本文提及的其它图片种类有着本质的不同:SVG 对图像的处理不是基于像素点,而是是基于对图像的形状描述。
和性能关系最密切的一点就是:SVG 与 PNG 和 JPG 相比,文件体积更小,可压缩性更强。作为矢量图,它最显著的优势还是在于图片可无限放大而不失真这一点上。
此外,SVG 是文本文件。我们既可以像写代码一样定义 SVG,把它写在 HTML 里、成为 DOM 的一部分,也可以把对图形的描述写入以 .svg 为后缀的独立文件。
将 SVG 写入 HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title></title>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<circle cx="50" cy="50" r="50" />
</svg>
</body>
</html>
将 SVG 写入独立文件后引入 HTML:
<img src="文件名.svg" alt="">
在实际开发中,我们更多用到的是后者。
特点:文本文件、依赖编码、小图标解决方案。
Base64 并非一种图片格式,而是一种编码方式。Base64 和雪碧图一样,是作为小图标解决方案而存在的。
MDN 对雪碧图的解释:图像精灵(sprite,意为精灵),被运用于众多使用大量小图标的网页应用之上。它可取图像的一部分来使用,使得使用一个图像文件替代多个小文件成为可能。相较于一个小图标一个图像文件,单独一张图片所需的 HTTP 请求更少,对内存和带宽更加友好。
Base64 是作为雪碧图的补充而存在的。
Base64 是一种用于传输 8Bit 字节码的编码方式,通过对图片进行 Base64 编码,我们可以直接将编码结果写入 HTML 或者写入 CSS,从而减少 HTTP 请求的次数。
使用webpack 的 url-loader,它除了具备基本的 Base64 转码能力,还可以结合文件大小,帮我们判断图片是否有必要进行 Base64 编码。
特点:年轻的全能型选手。
webP是 Google 专为 Web 开发的一种旨在加快图片加载速度的图片格式,它支持有损压缩和无损压缩。
WebP 比同类JPEG图像小, 支持透明,可以显示动态图片——它集多种图片文件格式的优点于一身。
WebP 的官方介绍:与 PNG 相比,WebP 无损图像的尺寸缩小了 26%。在等效的 SSIM 质量指数下,WebP 有损图像比同类 JPEG 图像小 25-34%。 无损 WebP 支持透明度(也称为 alpha 通道),仅需 22% 的额外字节。对于有损 RGB 压缩可接受的情况,有损 WebP 也支持透明度,与 PNG 相比,通常提供 3 倍的文件大小。
浏览器的兼容问题,如果用了WebP格式的图片,就要考虑在 Safari 等浏览器下它无法显示的问题,就要有降级方案,浏览器兼容如下图:
去淘宝官网看下轮播图使用的图片格式,在谷歌浏览器中能看到如下代码:
使用的是webp格式。
在IE中使用的是.jpg,如下图:
浏览器缓存是一种操作简单、效果显著的前端性能优化手段。Chrome 官方给出的解释:
通过网络获取内容既速度缓慢又开销巨大。较大的响应需要在客户端与服务器之间进行多次往返通信,这会延迟浏览器获得和处理内容的时间,还会增加访问者的流量费用。因此,缓存并重复利用之前获取的资源的能力成为性能优化的一个关键方面。
浏览器缓存机制有四个方面,它们按照获取资源时请求的优先级依次排列如下:
大家熟悉的缓存应该是HTTP Cache(即 Cache-Control、expires 等字段控制的缓存)。先看一张网站控制面板的network图中size栏的数据,如图所示:
从图上可以看出(from xxx)就是从缓存获得的资源。
资源从内存中读取缓存,不会请求服务器。说明之前已经加载过该资源且缓存到了内存。当关闭该页面时,此资源就被内存释放掉了,再次重新打开相同页面时不会出现from memory cache的情况。一般Base64 格式的图片会被塞进memory cache这可以视作浏览器为节省渲染开销的“自保行为”;另外体积较小的JS、css也有较大几率写入内存。
Service Worker 是一种独立于主线程之外的 Javascript 线程。它脱离于浏览器窗体,因此无法直接访问 DOM。我们借助 Service worker 实现的离线缓存就称为 Service Worker Cache。
Server Worker 对协议是有要求的,必须以 https 协议为前提。
Service Worker 的生命周期包括 install、active、working 三个阶段。一旦 Service Worker 被 install,它将始终存在,只会在 active 与 working 之间切换,除非我们主动终止它。这是它可以用来实现离线存储的重要先决条件。
通过以下代码来看 Service Worker 如何实现离线缓存: 我们首先在入口文件中插入这样一段 JS 代码,用以判断和引入 Service Worker:
window.navigator.serviceWorker.register('/test.js').then(
function () {
console.log('注册成功')
}).catch(err => {
console.error("注册失败")
})
在 test.js 中,我们进行缓存的处理。假设我们需要缓存的文件分别是 test.html,test.css 和 test.js:
// Service Worker会监听 install事件,我们在其对应的回调里可以实现初始化的逻辑
self.addEventListener('install', event => {
event.waitUntil(
// 考虑到缓存也需要更新,open内传入的参数为缓存的版本号
caches.open('test-v1').then(cache => {
return cache.addAll([
// 此处传入指定的需缓存的文件名
'/test.html',
'/test.css',
'/test.js'
])
})
)
})
// Service Worker会监听所有的网络请求,网络请求的产生触发的是fetch事件,我们可以在其对应的监听函数中实现对请求的拦截,进而判断是否有对应到该请求的缓存,实现从Service Worker中取到缓存的目的
self.addEventListener('fetch', event => {
event.respondWith(
// 尝试匹配该请求对应的缓存值
caches.match(event.request).then(res => {
// 如果匹配到了,调用Server Worker缓存
if (res) {
return res;
}
// 如果没匹配到,向服务端发起这个资源请求
return fetch(event.request).then(response => {
if (!response || response.status !== 200) {
return response;
}
// 请求成功的话,将请求缓存起来。
caches.open('test-v1').then(function(cache) {
cache.put(event.request, response);
});
return response.clone();
});
})
);
});
HTTP 缓存是最主要、最具有代表性的缓存策略,既是我们日常开发中最为熟悉的一种缓存机制,也是前端工程师理解掌握的性能优化知识点。。它分为强缓存和协商缓存。优先级较高的是强缓存,当强缓存失败的情况下,才会走协商缓存。
强缓存是利用 http 头中的 Expires 和 Cache-Control 两个字段来控制的。强缓存中,当请求再次发出时,浏览器会根据其中的 expires 和 cache-control 判断目标资源是否“命中”强缓存,若命中则直接从缓存中获取资源,不会再与服务端发生通信。
我们实现强缓存的方式:
当服务器返回响应时,在 Response Headers 中将过期时间写入 expires 字段。像这样:
如下代码:
expires: Wed, 11 Sep 2019 16:12:18 GMT
可以看到,expires 是一个时间戳,接下来如果我们试图再次向服务器请求资源,浏览器就会先对比本地时间和 expires 的时间戳,如果本地时间小于 expires 设定的过期时间,那么就直接去缓存中取这个资源。
从上面描述看expires 是有问题的,它最大的问题在于对“本地时间”的依赖。如果服务端和客户端的时间设置可能不同,或者我直接手动去把客户端的时间改掉,那么 expires 将无法达到我们的预期。
考虑到 expires 的局限性,HTTP1.1 新增了 Cache-Control 字段来完成 expires 的任务。 expires 能做的事情,Cache-Control 都能做;expires 完成不了的事情,Cache-Control 也能做。因此,Cache-Control 可以视作是 expires 的完全替代方案。在当下的前端实践里,我们继续使用 expires 的唯一目的就是向下兼容。
我们给 Cache-Control 字段一个特写:
cache-control: max-age=31536000
在 Cache-Control 中,我们通过 max-age 来控制资源的有效期。max-age 不是一个时间戳,而是一个时间长度。在本例中,max-age 是 31536000 秒,它意味着该资源在 31536000 秒以内都是有效的,完美地规避了时间戳带来的潜在问题。
Cache-Control 相对于 expires 更加准确,它的优先级也更高。当 Cache-Control 与 expires 同时出现时,我们以 Cache-Control 为准。
如下的用法也非常常见:
cache-control: max-age=3600, s-maxage=31536000
s-maxage 优先级高于 max-age,两者同时出现时,优先考虑 s-maxage。如果 s-maxage 未过期,则向代理服务器请求其缓存内容。
在项目不是特别大的场景下,max-age足够用了。但在依赖各种代理的大型架构中,我们不得不考虑代理服务器的缓存问题。s-maxage 就是用于表示 cache 服务器上(比如 cache CDN)的缓存的有效时间的,并只对 public 缓存有效。
注:s-maxage仅在代理服务器中生效,客户端中我们只考虑max-age。
如果我们为资源设置了public,那么它既可以被浏览器缓存,也可以被代理服务器缓存;如果我们设置了private,则该资源只能被浏览器缓存。private 为默认值。但多数情况下,public 并不需要我们手动设置。
协商缓存依赖于服务端与浏览器之间的通信。
协商缓存机制下,浏览器需要向服务器去询问缓存的相关信息,进而判断是重新发起请求、下载完整的响应,还是从本地获取缓存的资源。
如果服务端提示缓存资源未改动(Not Modified),资源会被重定向到浏览器缓存,这种情况下网络请求对应的状态码是 304(如下图):
Last-Modified 是一个时间戳,如果我们启用了协商缓存,它会在首次请求时随着 Response Headers 返回:
Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT
随后我们每次请求时,会带上一个叫 If-Modified-Since 的时间戳字段,它的值正是上一次 response 返回给它的 last-modified 值:
If-Modified-Since: Fri, 27 Oct 2017 06:35:57 GMT
服务器接收到这个时间戳后,会比对该时间戳和资源在服务器上的最后修改时间是否一致,从而判断资源是否发生了变化。如果发生了变化,就会返回一个完整的响应内容,并在 Response Headers 中添加新的 Last-Modified 值;否则,返回如上图的 304 响应,Response Headers 不会再添加 Last-Modified 字段。
使用 Last-Modified 存在一些弊端,这其中最常见的就是这样两个场景:
我们编辑了文件,但文件的内容没有改变。服务端并不清楚我们是否真正改变了文件,它仍然通过最后编辑时间进行判断。因此这个资源在再次被请求时,会被当做新资源,进而引发一次完整的响应——不该重新请求的时候,也会重新请求。
当我们修改文件的速度过快时(比如花了 100ms 完成了改动),由于 If-Modified-Since 只能检查到以秒为最小计量单位的时间差,所以它是感知不到这个改动的——该重新请求的时候,反而没有重新请求了。
这两个场景其实指向了同一个 bug——服务器并没有正确感知文件的变化。为了解决这样的问题,Etag 作为 Last-Modified 的补充出现了。
Etag 是由服务器为每个资源生成的唯一的标识字符串,这个标识字符串是基于文件内容编码的,只要文件内容不同,它们对应的 Etag 就是不同的,反之亦然。因此 Etag 能够精准地感知文件的变化。
Etag 和 Last-Modified 类似,当首次请求时,我们会在响应头里获取到一个最初的标识符字符串,如下:
ETag: W/"2a3b-1602480f459"
那么下一次请求时,请求头里就会带上一个值相同的、名为 if-None-Match 的字符串供服务端比对了:
If-None-Match: W/"2a3b-1602480f459"
Etag 的生成过程需要服务器额外付出开销,会影响服务端的性能,这是它的弊端。因此启用 Etag 需要我们考虑清楚。Etag 并不能替代 Last-Modified,它只能作为 Last-Modified 的补充和强化。 Etag 在感知文件变化上比 Last-Modified 更加准确,优先级也更高。当 Etag 和 Last-Modified 同时存在时,以 Etag 为准。
Chrome 官方给出的缓存流程图:
解析下该图: 当我们的资源内容不可复用时,直接为 Cache-Control 设置 no-store,拒绝任何缓存;否则考虑是否每次都需要向服务器进行缓存有效确认,如果需要,那么设 Cache-Control 的值为 no-cache;否则考虑该资源是否可以被代理服务器缓存,根据其结果决定是设置为 private 还是 public;然后考虑该资源的过期时间,设置对应的 max-age 和 s-maxage 值;最后,配置协商缓存需要用到的 Etag、Last-Modified 等参数。
Push Cache 是指 HTTP2 在 server push 阶段存在的缓存。这块的知识比较新,应用也还处于萌芽阶段。
cookie最大为4k,通常用来存储一些用户登录状态,每次请求,浏览器都会带上相同域名下的cookie。Cookie虽然小,请求却可以有很多,随着请求的叠加,这样的不必要的 Cookie 带来的开销将是无法想象的。
webStorage分为两种,sessionStorage和localStorage,它们的大小在5-10M之间。都是以键值对的方式进行存储的。不与服务端发生通信。
sessionStorage与localStorage的不同在于生命周期的不同,sessionStorage在tab关闭后,就不再存在了,而localStorage的永久存储,除非主动删除。另外不同之处是即便是相同域名下的两个页面,只要它们不在同一个浏览器窗口中打开,那么它们的 Session Storage内容便无法共享。
使用方式:
存储数据:setItem()
localStorage.setItem('user_name', 'yanfa')
读取数据:getItem()
localStorage.getItem('user_name')
删除某一键名对应的数据: removeItem()
localStorage.removeItem('user_name')
清空数据记录:clear()
localStorage.clear()
应用场景
比如图片内容丰富的电商网站会用它来存储 Base64 格式的图片字符串。有的网站还会用它存储一些不经常更新的 CSS、JS 等静态资源。
IndexDB是一个运行在浏览器上的非关系型数据库。既然是数据库了,那么它的存储就不止是5M、10M这样小了。理论上来说,IndexDB是没有存储上限的(一般来说不会小于250M)。它不仅可以存储字符串,还可以存储二进制数据。
// 后面的回调中,我们可以通过event.target.result拿到数据库实例
let db
// 参数1位数据库名,参数2为版本号
const request = window.indexedDB.open("xiaoceDB", 1)
// 使用IndexDB失败时的监听函数
request.onerror = function(event) {
console.log('无法使用IndexDB')
}
// 成功
request.onsuccess = function(event){
// 此处就可以获取到db实例
db = event.target.result
console.log("你打开了IndexDB")
}
// onupgradeneeded事件会在初始化数据库/版本发生更新时被调用,我们在它的监听函数中创建object store
request.onupgradeneeded = function(event){
let objectStore
// 如果同名表未被创建过,则新建test表
if (!db.objectStoreNames.contains('test')) {
objectStore = db.createObjectStore('test', { keyPath: 'id' })
}
}
// 创建事务,指定表格名称和读写权限
const transaction = db.transaction(["test"],"readwrite")
// 拿到Object Store对象
const objectStore = transaction.objectStore("test")
// 向表格写入数据
objectStore.add({id: 1, name: 'xiuyan'})
// 操作成功时的监听函数
transaction.oncomplete = function(event) {
console.log("操作成功")
}
// 操作失败时的监听函数
transaction.onerror = function(event) {
console.log("这里有一个Error")
}
客户端渲染模式下,服务端会把渲染需要的静态文件发送给客户端,客户端加载过来之后,自己在浏览器里跑一遍 JS,根据 JS 的运行结果,生成相应的 DOM。这种特性使得客户端渲染的源代码总是特别简洁:
<!doctype html>
<html>
<head>
<title>我是客户端渲染的页面</title>
</head>
<body>
<div id='root'></div>
<script src='index.js'></script>
</body>
</html>
根节点下到底是什么内容呢?只有浏览器把index.js跑过一遍后才知道,这就是典型的客户端渲染。
页面上呈现的内容,你在html源文件里里找不到——这正是它的特点。
服务端渲染的模式下,当用户第一次请求页面时,由服务器把需要的组件或页面渲染成HTML字符串,然后把它返回给客户端。客户端拿到手的,是可以直接渲染然后呈现给用户的HTML内容,不需要为了生成 DOM 内容自己再去跑一遍 JS代码。
使用服务端渲染的网站,可以说是“所见即所得”,页面上呈现的内容,我们在html源文件里也能找到。
那么 Vue 是如何实现服务端渲染的呢?详细可参考官网《Vue SSR 指南》:https://ssr.vuejs.org/zh/#什么是服务器端渲染-ssr-?
下面示例直接将 Vue 实例整合进了服务端的入口文件中,实例地址:https://ssr.vuejs.org/zh/guide/#渲染一个-vue-实例
const Vue = require('vue')
// 创建一个express应用
const server = require('express')()
// 提取出renderer实例
const renderer = require('vue-server-renderer').createRenderer()
server.get('*', (req, res) => {
// 编写Vue实例(虚拟DOM节点)
const app = new Vue({
data: {
url: req.url
},
// 编写模板HTML的内容
template: `访问的 URL 是: {{ url }}`
})
// renderToString 是把Vue实例转化为真实DOM的关键方法
renderer.renderToString(app, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error')
return
}
// 把渲染出来的真实DOM字符串插入HTML模板中
res.end(`
Hello
${html}
`)
})
})
server.listen(8080)
实际项目比这些复杂很多,但万变不离其宗。强调的只有两点:
服务器渲染最大的作用是优化SEO。
同时在性能上也能加快首屏渲染速度,但是这样会对服务器带来一定的压力,所以需要进行综合考量。
以前为了处理不同浏览器下代码渲染结果的差异性,都需要写兼容代码。这些差异性正是因为浏览器内核的不同而导致的——浏览器内核决定了浏览器解释网页语法的方式。
浏览器内核可以分成两部分:渲染引擎(Layout Engine 或者 Rendering Engine)和 JS 引擎。
渲染引擎又包括了 HTML解释器、CSS解释器、布局、网络、存储、图形、音视频、图片解码器等等零部件。
常见的浏览器内核可以分为这四种:Trident(IE)、Gecko(火狐)、Blink(Chrome、Opera)、Webkit(Safari)。大家最熟悉的应该是 Webkit 内核,Chrome 的内核就是 Webkit,但是Chrome 内核现在已迭代为了 Blink,Blink 其实也是基于 Webkit 衍生而来的一个分支。
浏览器呈现网页的简单过程如下图所示:
在这个过程中我们主要要熟悉的就是HTML解释器、CSS解释器、图层布局计算模块、视图绘制模块与JavaScript 引擎这几大模块:
有了上面的基本了解过后,我再在来解析下浏览器首次渲染每一个页面都经历的过程(图中箭头不代表串行,有一些操作是并行进行的):
在这一步浏览器执行了所有的加载解析逻辑,在解析HTML的过程中发出了页面渲染所需的各种外部资源请求。
浏览器将识别并加载所有的 CSS 样式信息与 DOM 树合并,最终生成页面 render 树(:after :before 这样的伪元素会在这个环节被构建到 DOM 树中)。
页面中所有元素的相对位置信息,大小等信息均在这一步得到计算。
在这一步中浏览器会根据我们的DOM代码结果,把每一个页面图层转换为像素,并对所有的媒体文件进行解码。
最后一步浏览器会合并合各个图层,将数据由 CPU 输出给 GPU 最终绘制在屏幕上。
为了使渲染过程更明晰一些,我们给这些”树“们一个特写:
总结下渲染过程就是:首先是基于 HTML 构建一个 DOM 树,这棵 DOM 树与 CSS 解释器解析出的 CSSOM 相结合,就有了布局渲染树。最后浏览器以布局渲染树为蓝本,去计算布局并绘制图像,那么页面的初次渲染就完成了。
之后每当一个新元素加入到这个 DOM 树当中,浏览器便会通过 CSS 引擎查遍 CSS 样式表,找到符合该元素的样式规则应用到这个元素上,然后再重新去绘制它。
从上面可以看出,添加一个新元素,浏览器便会去查表,这个查表的过程是需要时间的,那么我们怎样养这个查表时间更快一些,这就引出了我们的一个代码优化点,CSS样式表规则的优化。
CSS 引擎查找样式表,对每条规则都按从右到左的顺序去匹配。 看如下规则:
#myList li {}
这样是很常见的写法,我们阅读文字的习惯是从左到右,会以为浏览器也是从左到右匹配 CSS 选择器的,其实不是。CSS 选择符是从右到左进行匹配的。浏览器必须遍历页面上每个 li 元素,并且每次都要去确认这个 li 元素的父元素 id 是不是 myList,这样实际开销相当高。
再看一个经典的通配符:
* {padding:0;margin:0;}
我们都习惯用这样的方式去清除默认的样式。但这个家伙很恐怖,它会匹配所有元素,所以浏览器必须去遍历每一个元素!大家想想自己页面里的元素个数,这得计算多少次。
所以好的CSS 选择器书写习惯,可以为我们带来非常可观的性能提升。根据上面的分析,我们至少可以总结出如下性能提升的方案:
错误示范:
#myList li{}
优化写法:
.myList_li {}
错误示范:
.myList#title
优化写法:
#title
在前面提到DOM和CSSOM合力才能构建渲染树。这一点会给性能造成严重影响:默认情况下,CSS 是阻塞的资源。浏览器在构建 CSSOM 的过程中,不会渲染任何已处理的内容。即便 DOM 已经解析完毕了,只要 CSSOM 不 OK,那么渲染这个事情就不 OK(这主要是为了避免没有 CSS 的 HTML 页面丑陋地显示在用户面前)。
只有当我们开始解析 HTML 后、解析到 link 标签或者 style 标签时,CSSOM 的构建才开始。很多时候,DOM 不得不等待 CSSOM。因此我们可以这样总结:
CSS 是阻塞渲染的资源。需要将它尽早、尽快地下载到客户端,以便缩短首次渲染的时间。
现在很多团队都已经做到了尽早(将 CSS 放在 head 标签里)和尽快(启用 CDN 实现静态资源加载速度的优化)加载CSS。这已经是一个编码习惯,但这个习惯其实是由CSS特性决定的。
JS 的作用在于操作网页的内容、样式以及它如何响应用户交互。JS的操作会阻止CSSOM和阻塞DOM,看个例子:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JS阻塞测试</title>
<style>
#container {
background-color: yellow;
width: 100px;
height: 100px;
}
</style>
<script>
// 尝试获取container元素
var container = document.getElementById("container")
console.log('container', container)
</script>
</head>
<body>
<div id="container"></div>
<script>
// 尝试获取container元素
var container = document.getElementById("container")
console.log('container', container)
// 输出container元素此刻的背景色
console.log('container bgColor', getComputedStyle(container).backgroundColor)
</script>
<style>
#container {
background-color: blue;
}
</style>
</body>
</html>
三个 console 的结果分别为:
第一个结果:说明 JS 执行时阻塞了 DOM,后续的 DOM 无法构建。 第二个结果:结果获取到了构建好的DOM元素。这两个结果结合起来,“阻塞 DOM”得到了验证。 第三个结果:获取到的是在JS代码执行前的背景色(yellow),而非后续设定的新样式(blue),说明 CSSOM 也被阻塞了。
JS 引擎是独立于渲染引擎存在的。我们的 JS 代码在文档的何处插入,就在何处执行。当 HTML 解析器遇到一个 script 标签时,它会暂停渲染过程,将控制权交给 JS 引擎。JS 引擎对内联的 JS 代码会直接执行,对外部 JS 文件还要先获取到脚本、再进行执行。等 JS 引擎运行完毕,浏览器又会把控制权还给渲染引擎,继续 CSSOM 和 DOM 的构建。
我们可以通过对JS文件使用 defer 和 async 来避免不必要的阻塞。外部 JS 的三种加载方式:
<script src="index.js"></script>
这种情况下 JS 会阻塞浏览器,浏览器必须等待 index.js 加载和执行完毕才能去做其它事情。
<script async src="index.js"></script>
async 模式下,JS 不会阻塞浏览器做任何其它的事情。它的加载是异步的,当它加载结束,JS 脚本会立即执行。
<script defer src="index.js"></script>
defer 模式下,JS 的加载是异步的,执行是被推迟的。等整个文档解析完成、DOMContentLoaded 事件即将被触发时,被标记了 defer 的 JS 文件才会开始依次执行。
注:DOMContentLoaded的触发不需要等待图片等其他资源加载完成,而load事件的触发是页面上所有的资源(图片,音频,视频等)被加载以后。
从应用的角度来说,一般当我们的脚本与DOM元素和其它脚本之间的依赖关系不强时,我们会选用 async;当脚本依赖于 DOM 元素和其它脚本的执行结果时,我们会选用 defer。
通过审时度势地向 script 标签添加 async/defer,我们就可以告诉浏览器在等待脚本可用期间不阻止其它的工作,这样可以显著提升性能。
JS 是很快的,在 JS 中修改DOM对象也是很快的。但DOM操作并非JS单独完成,而是两个模块之间的协作。前面介绍过JS引擎和渲染引擎(浏览器内核)是独立实现的。那么JS引擎和渲染引擎之间需要进行“跨界交流”。这个“跨界交流”的实现并不简单,它依赖了桥接接口作为“桥梁”(如下图)。
从图上可以看出,JS引擎与渲染引擎交流需要“桥”,我们每操作一次DOM(不管是为了修改还是仅仅为了访问其值),都要过一次“桥”。过“桥”的次数一多,就会产生比较明显的性能问题。因此“减少 DOM 操作”的建议,并非没有根据。
当我们对 DOM 的修改会引发它外观(样式)上的改变时,就会触发回流或重绘。这个过程本质上还是因为我们对 DOM 的修改触发了渲染树(Render Tree)的变化所导致的。
重绘不一定导致回流,回流一定会导致重绘。回流比重绘做的事情更多,带来的开销也更大。但触发这两种操作都是吃性能的,所以我们在开发中尽可能把回流和重绘的次数最小化。
上面清楚了DOM 操作慢的原因。那么下面讲下常见的优化点。
如下,我们有这样一个HTML代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>DOM操作测试</title>
</head>
<body>
<div id="container"></div>
</body>
</html>
我有这样一个需求,我想往 container 元素里写 10000 句一样的话。如果我这么做:
for(var count=0;count<10000;count++){
document.getElementById('container').innerHTML+='我是一个小测试'
}
上面代码的缺点是:我们每一次循环都调用 DOM 接口重新获取了一次 container 元素;每次循环都修改DOM树,也就是每次循环都走了一次回流过程,非常耗性能。
优化后的代码:
let container = document.getElementById('container')
let content = ''
for(let count=0;count<10000;count++){
// 先对内容进行操作
content += '我是一个小测试'
}
// 内容处理好了,最后再触发DOM的更改
container.innerHTML = content
事实上,考虑JS 的运行速度,比 DOM 快得多这个特性。我们减少DOM操作的核心思路,就是让 JS 去给 DOM 分压。
我们可以使用DocumentFragment,DocumentFragment不是真实DOM树的一部分,它的变化不会引起 DOM 树的重新渲染的操作(reflow),且不会导致性能等问题。
前面我们直接用 innerHTML去拼接目标内容,这样做固然有用,但却不够优雅。相比之下,DOM Fragment 可以帮助我们用更加结构化的方式去达成同样的目的,代码如下:
let container = document.getElementById('container')
// 创建一个DOM Fragment对象作为容器
let content = document.createDocumentFragment()
for(let count=0;count<10000;count++){
// span此时可以通过DOM API去创建
let oSpan = document.createElement("span")
oSpan.innerHTML = '我是一个小测试'
// 像操作真实DOM一样操作DOM Fragment对象
content.appendChild(oSpan)
}
// 内容处理好了,最后再触发真实DOM的更改
container.appendChild(content)
操作这些常见的几何属性width、height、padding、margin、left、top、border 等等会引起回流。
最容易被忽略的操作,获取一些特定属性的值:offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight,像这样属性的值有一个共性就是需要通过即时计算得到,因此浏览器为了获取这些值,也会进行回流。
当调用了 getComputedStyle 方法,或者 IE 里的 currentStyle 时,也会触发回流。
如何规避回流与重绘,提升性能,下面总结几点常见方式:
避免逐条改变样式,使用类名去合并样式
比如我们可以把这段单纯的代码:
const container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
优化成一个有 class 加持的样子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>
.basic_style {
width: 100px;
height: 200px;
border: 10px solid red;
color: red;
}
</style>
</head>
<body>
<div id="container"></div>
<script>
const container = document.getElementById('container')
container.classList.add('basic_style')
</script>
</body>
</html>
前者每次单独操作,都去触发一次渲染树更改,从而导致相应的回流与重绘过程。
合并之后,等于我们将所有的更改一次性发出,用一个 style 请求解决掉了。
敏感属性缓存起来,避免频繁获取与改动
有时我们想要通过多次计算得到一个元素的布局位置,我们可能会这样做:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>
#el {
width: 100px;
height: 100px;
background-color: yellow;
position: absolute;
}
</style>
</head>
<body>
<div id="el"></div>
<script>
// 获取el元素
const el = document.getElementById('el')
// 这里循环判定比较简单,实际中或许会拓展出比较复杂的判定需求
for(let i=0;i<10;i++) {
el.style.top = el.offsetTop + 10 + "px";
el.style.left = el.offsetLeft + 10 + "px";
}
</script>
</body>
</html>
这样做,每次循环都需要获取多次“敏感属性”,是比较糟糕的。我们可以将其以 JS 变量的形式缓存起来,待计算完毕再提交给浏览器发出重计算请求:
// 缓存offsetLeft与offsetTop的值
const el = document.getElementById('el')
let offLeft = el.offsetLeft, offTop = el.offsetTop
// 在JS层面进行计算
for(let i=0;i<10;i++) {
offLeft += 10
offTop += 10
}
// 一次性将计算结果应用到DOM上
el.style.left = offLeft + "px"
el.style.top = offTop + "px"
将 DOM “离线”
上面的例子都是元素显示在页面中,如果给元素设置display: none,将其从页面上“拿掉”,那么对该元素的后续操作,都不会触发回流与重绘。这个将元素“拿掉”的操作,就叫做 DOM 离线化。
仍以我们上文的代码片段为例:
const container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
...(省略了许多类似的后续操作)
离线化后就是这样:
let container = document.getElementById('container')
container.style.display = 'none'
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
...(省略了许多类似的后续操作)
container.style.display = 'block'
疑问:我们拿掉一个元素再把它放回去,这不也会触发一次昂贵的回流吗?这话不假,但我们把它拿下来了,后续不管我操作这个元素多少次,每一步的操作成本都会非常低。当我们只需要进行很少的 DOM 操作时,DOM离线化的优越性确实不太明显。一旦操作频繁起来,这“拿掉”和“放回”的开销都将会是非常值得的。
大家思考一个问题:
let container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
这段代码里,浏览器进行了多少次的回流或重绘呢?
“width、height、border是几何属性,各触发一次回流;color只造成外观的变化,会触发一次重绘。”那么我们现在立刻跑一跑这段代码,看看浏览器怎么说:
看谷歌浏览器控制台的Performance性能面板中“Layout”和“Paint”片段。我们看到浏览器只进行了一次回流和一次重绘,为什么?
如果每次 DOM 操作都即时地反馈一次回流或重绘,那么性能上来说是扛不住的。于是它自己缓存了一个flush队列,把我们触发的回流与重绘任务都塞进去,待到队列里的任务多起来、或者达到了一定的时间间隔,或者“不得已”的时候,再将这些任务一口气出队。因此我们看到,上面就算我们进行了4次DOM更改,也只触发了一次 Layout 和一次 Paint。
所谓首屏加载是指用户在打开页面的时候只把首屏的图片资源加载出来。为啥要使用懒加载呢?比如一些电商网站首页,如果用户打开页面的时候,要把所有的图片资源加载完毕,很可能会造成白屏、卡顿等现象。因为图片太多,浏览器一下处理不了这么多。
首屏图片懒加载简单代码思路:
<script>
// 获取所有的图片标签
const imgs = document.getElementsByTagName('img')
// 获取可视区域的高度
const viewHeight = window.innerHeight || document.documentElement.clientHeight
// num用于统计当前显示到了哪一张图片,避免每次都从第一张图片开始检查是否露出
let num = 0
function lazyload(){
for(let i=num; i<imgs.length; i++) {
// 用可视区域高度减去元素顶部距离可视区域顶部的高度
let distance = viewHeight - imgs[i].getBoundingClientRect().top
// 如果可视区域高度大于等于元素顶部距离可视区域顶部的高度,说明元素露出
if(distance >= 0 ){
// 给元素写入真实的src,展示图片
imgs[i].src = imgs[i].getAttribute('data-src')
// 前i张图片已经加载完毕,下次从第i+1张开始检查是否露出
num = i + 1
}
}
}
// 监听Scroll事件
window.addEventListener('scroll', lazyload, false);
</script>
注:在懒加载的实现中,有两个关键的数值:一个是当前可视区域的高度,另一个是元素距离可视区域顶部的高度。
scroll 事件是一个非常容易被反复触发的事件。其实不止 scroll 事件,resize 事件、鼠标事件(比如 mousemove、mouseover 等)、键盘事件(keyup、keydown 等)都存在被频繁触发的风险。
频繁触发回调导致的大量计算会引发页面的抖动甚至卡顿。为了规避这种情况,我们需要一些手段来控制事件被触发的频率。就要用到throttle(事件节流)和 debounce(事件防抖)。
它们的本质是通过对事件对应的回调函数进行包裹、以自由变量的形式缓存时间信息,最后用 setTimeout 来控制事件的触发频率。
所谓的“节流”,是通过在一段时间内无视后来产生的回调请求来实现的。简单理解就是当持续触发事件时,保证一定时间段内只调用一次事件处理函数。
// fn是我们需要包装的事件回调, interval是时间间隔的阈值
function throttle(fn, interval) {
// last为上一次触发回调的时间
let last = 0
// 将throttle处理结果当作函数返回
return function () {
// 保留调用时的this上下文
let context = this
// 保留调用时传入的参数
let args = arguments
// 记录本次触发回调的时间
let now = +new Date()
// 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
if (now - last >= interval) {
// 如果时间间隔大于我们设定的时间间隔阈值,则执行回调
last = now;
fn.apply(context, args);
}
}
}
// 用throttle来包装scroll的回调
const better_scroll = throttle(() => console.log('触发了滚动事件'), 1000)
document.addEventListener('scroll', better_scroll)
所谓防抖,即是当持续触发事件时,一定时间段内没有再触发事件,事件处理函数才会执行一次,如果设定的时间到来之前,又一次触发了事件,就重新开始延时。
// fn是我们需要包装的事件回调, delay是每次推迟执行的等待时间
function debounce(fn, delay) {
// 定时器
let timer = null
// 将debounce处理结果当作函数返回
return function () {
// 保留调用时的this上下文
let context = this
// 保留调用时传入的参数
let args = arguments
// 每次事件被触发时,都去清除之前的旧定时器
if(timer) {
clearTimeout(timer)
}
// 设立新定时器
timer = setTimeout(function () {
fn.apply(context, args)
}, delay)
}
}
// 用debounce来包装scroll的回调
const better_scroll = debounce(() => console.log('触发了滚动事件'), 1000)
document.addEventListener('scroll', better_scroll)
思路:delay 时间内,我可以为你重新生成定时器;但只要delay的时间到了,我必须要给用户一个响应。
// fn是我们需要包装的事件回调, delay是时间间隔的阈值
function throttle(fn, delay) {
// last为上一次触发回调的时间, timer是定时器
let last = 0, timer = null
// 将throttle处理结果当作函数返回
return function () {
// 保留调用时的this上下文
let context = this
// 保留调用时传入的参数
let args = arguments
// 记录本次触发回调的时间
let now = +new Date()
// 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
if (now - last < delay) {
// 如果时间间隔小于我们设定的时间间隔阈值,则为本次触发操作设立一个新的定时器
clearTimeout(timer)
timer = setTimeout(function () {
last = now
fn.apply(context, args)
}, delay)
} else {
// 如果时间间隔超出了我们设定的时间间隔阈值,那就不等了,无论如何要反馈给用户一次响应
last = now
fn.apply(context, args)
}
}
}
// 用新的throttle包装scroll的回调
const better_scroll = throttle(() => console.log('触发了滚动事件'), 1000)
document.addEventListener('scroll', better_scroll)
例如创建请求如下的代码:
function createXHR(){
if(typeof XMLHttpRequest != "undefined"){
return new XMLHttpRequest();
}else if(typeof ActiveXObject != "undefined"){
if(typeof arguments.callee.activeXString != 'string'){
var verisions = ["MSXML2.XMLHttp.6.0","MSXML2.XMLHttp.3.0","MSXML2.XMLHttp"],
i,
len;
for(i = 0, len = versions.length; i < len; i++){
try{
new ActiveXObject(versions[i]);
arguments.callee.activeXString = version[i];
break;
}catch(ex){
//跳过
}
}
}
return new ActiveXObject(arguments.callee.activeXString);
}else{
throw new Error("NO XHR object available.");
}
}
每次调用createXHR()时,他都要对浏览器所支持的能力仔细检查。如果if语句不必每次执行,那么代码可以运行的更快一些。解决方案称之为惰性载入的技巧。
惰性载入表示函数执行的分支仅会发生一次。有两种实现惰性载入的方式,第一种就是函数在被调用时再处理函数。在第一次调用的过程中,该函数会覆盖为另外一个按合适方式执行的函数,这样对原函数的调用都不用在经过执行的分支了。例如上面例子重写为:
function createXHR(){
if(typeof XMLHttpRequest != "undefined"){
createXHR = function(){
return new XMLHttpRequest();
}
}else if(typeof ActiveXObject != "undefined"){
createXHR = function(){
if(typeof arguments.callee.activeXString != 'string'){
var verisions = ["MSXML2.XMLHttp.6.0","MSXML2.XMLHttp.3.0","MSXML2.XMLHttp"],
i,
len;
for(i = 0, len = versions.length; i < len; i++){
try{
new ActiveXObject(versions[i]);
arguments.callee.activeXString = version[i];
break;
}catch(ex){
//跳过
}
}
}
return new ActiveXObject(arguments.callee.activeXString);
}
}else{
createXHR = function(){
throw new Error("NO XHR object available.");
}
}
return createXHR();
}
第二种实现惰性载入的方式是在声明函数时就指定适当的函数,如下代码:
var createXHR = (function(){
if(typeof XMLHttpRequest != "undefined"){
return function(){
return new XMLHttpRequest();
}
}else if(typeof ActiveXObject != "undefined"){
return function(){
if(typeof arguments.callee.activeXString != 'string'){
var verisions = ["MSXML2.XMLHttp.6.0","MSXML2.XMLHttp.3.0","MSXML2.XMLHttp"],
i,
len;
for(i = 0, len = versions.length; i < len; i++){
try{
new ActiveXObject(versions[i]);
arguments.callee.activeXString = version[i];
break;
}catch(ex){
//跳过
}
}
}
return new ActiveXObject(arguments.callee.activeXString);
}
}else{
return function(){
throw new Error("NO XHR object available.");
}
}
})();
其他可参考地址: