Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

前端性能优化不完全指北 #9

Open
SunshowerC opened this issue Jan 12, 2019 · 0 comments
Open

前端性能优化不完全指北 #9

SunshowerC opened this issue Jan 12, 2019 · 0 comments

Comments

@SunshowerC
Copy link
Owner

前言

关于前端性能优化,其实网上也有很多文章已经讲了很多,但是随着 webpack 4, babel 7 等工具的发布以及一些前沿技术的挖掘,很多以前的优化手段,例如雅虎34军规等已经不足以满足我们的需求了。刚好最近项目做了很多优化工作,所以来跟大家一起分享一下前端性能优化这个话题,以及个人做的一些优化工作。

优化手段

首先,要知道如何优化一个网站,那么就得清楚,从输入网址,到看到页面视图,这个过程到底发生了什么。

那么,我大概将这个过程分为三大模块,分别是:

  • 网络
    用户通过浏览器发起资源请求
  • 资源
    浏览器下载所需要的资源
  • 渲染
    浏览器将资源解析处理,渲染页面视图

接下来我们将从这三方面探索前端优化技术

网络

首先,输入网址,通过 chrome devtools 的 timeline 板块 我们可以看到这个过程到底发生了什么:


请求 timeline 图

要清楚怎么优化,那么就得知道,这些过程代表什么,其瓶颈又是什么。

  • Queued: 进入队列的时间点
  • Started At: 请求开始的时间点
  • Queueing: 排队时间,Started At = Queued + Queueing
    • 有更高优先级的请求在进行(如scripts/styles),这个一般发生在 图片资源的请求上k
    • 根据HTTP1.0/1.1协议规定,一个域名的并发请求量只能限制在6个
    • 浏览器在暂时分配磁盘缓存中的空间
  • stalled: 请求因上述的三种情况被停滞
  • Proxy negotiation: 代理协商耗时
  • DNS Lookup: 域名解析耗时
  • Initial Connect: 连接耗时, 指执行初始TCP握手和协商SSL所花费的时间(如果适用)。缓慢可能是由于拥塞造成的,服务器已达到限制,并且在现有连接未决时无法响应新连接。
  • SSL: 协商 SSL 的耗时
  • Request Sent: 发送请求耗时(很小可忽略)
  • Waitting(TTFB=Time To First Byte): 首字节响应时间,如果这个时间过长,服务器需要升级以提高吞吐效率了
  • Content Download: 资源下载耗时

这些耗时数据同时也可以通过 Performance API 来捕获到,用以分析 web 应用瓶颈。

img

Performance 时间节点

请求阻塞 (stalled)

根据HTTP1.0/1.1协议规定,一个域名的并发请求量存在限制

  • Firefox 2: 2
  • Firefox 3+: 6
  • Opera 9.26: 4
  • Opera 12: 6
  • Safari 3: 4
  • Safari 5: 6
  • IE 7: 2
  • IE 8: 6
  • IE 10: 8
  • Chrome: 6

一般情况下,我们的 web 应用有可能会有多个资源,一旦请求资源过多,请求就会被阻塞掉。导致耗时长,影响用户体验。

减少资源请求量

  • 小图片资源 base64 编码内联。

  • css sprite : 雪碧图

  • JS 文件合并
    上线时我们会把所有的代码进行压缩合并,合并成一个文件,这样不管多少模块,都请求一个文件,减少了HTTP的请求数。

    缺点:文件的缓存。当我们有100个模块时,有一个模块改了东西,按照之前的方式,整个文件浏览器都需要重新下载,不能被缓存。

多域名(CDN)

一个域名存在请求量限制,为什么不把资源放在多个域名下呢? 比如 github 上就是用了 avatars.githubusercontent.com 等域名 存放头像图片资源,一定程度上提升了响应速度和用户体验。

缺点:多域名,每个新域名都需要重新进行DNS解析(只需解析一次),DNS的解析时间会变长。

HTTP/2(SPDY)

Multiplexed support(one single TCP connection for all requests) : 多路复用
在同一个 TCP 连接之中并行执行多个请求,不再有 浏览器请求并发限制。

DNS 解析

我们知道,当我们访问一个网站如 www.amazon.com 时,需要将这个域名先转化为对应的 IP 地址,这是一个非常耗时的过程,当然浏览器有DNS缓存,一旦访问过,再次访问从缓存中读取就会快很多。


image

一个从未访问过的域名解析耗时长达 1 秒

DNS prefetch

要优化 DNS 解析时间,我们用到一种 DNS prefetch 的技术。

DNS prefetch 会分析这个页面需要的资源所在的域名,浏览器空闲时提前将这些域名转化为 IP 地址,真正请求资源时就避免了上述这个过程的时间。

<meta http-equiv='x-dns-prefetch-control' content='on'>
<link rel='dns-prefetch' href='http://g-ecx.images-amazon.com'>
<link rel='dns-prefetch' href='http://z-ecx.images-amazon.com'>

当我们的资源存放在不同的域名下,那么提前声明好域名,就可以节省域名解析的时间

TTFB (time to first byte)

用户拿到资源的耗时, 一般来说这个性能瓶颈是后端负责的。一般优化方法有,异地机房,CDN,提高带宽,提高 CPU 运算速度 等方式来来提高用户体验

CDN

CDN(内容分发网络),其基本思路是尽可能避开互联网上有可能影响数据传输速度和稳定性的瓶颈和环节,使内容传输的更快、更稳定。

通过在网络各处放置节点服务器所构成的在现有的互联网基础之上的一层智能虚拟网络,CDN系统能够实时地根据网络流量和各节点的连接、负载状况以及到用户的距离和响应时间等综合信息将用户的请求重新导向离用户最近的服务节点上。

CDN目的是通过在现有的Internet中增加一层新的网络架构,将网站的内容发布到最接近用户的网络“边缘”,使用户可以就近取得所需的内容,解决 Internet 网络拥塞状况,提高用户访问网站的响应速度。

资源

通过了一系列的网络请求过程,资源到了 Content Download 的过程。

资源下载的耗时 = 资源大小 / 用户网速。

用户网速我们无法控制,那么资源的瓶颈其实很显而易见:资源大小

减少资源文件大小,就能降低资源下载耗时。

在此进行资源优化之前,我们先将web 应用的资源文件按类型分为以下三种:

  • 第三方库代码
  • 向下兼容的 polyfill 代码
  • 业务代码

我们就分别从这几个方面了来说下资源优化方案。

常规优化

首先是常规优化操作,即对上述所有资源都通用的优化方法。

打包压缩

  • 通过构建工具(Grunt/Gulp/webpack等) 打包压缩,混淆加密
  • 服务器启用 gzip

缓存

  • webpack long-term cache 持久化缓存
    使用 webpack chunkhash 使得构建出来的 bundle 文件拥有固定的 hash 值
强缓存

体现为 from disk/memory cache, 具体是 from disk cache 还是 from memory cache 由浏览器自身控制

  • expires
  • Cache-Control
协商缓存

协商缓存由服务端控制,体现为 304 not Modified

  • Etag和If-None-Match
  • Last-Modified和If-Since-Modified
缓存策略流程图

具体缓存策略流程图如下:

image

第三方库

开源的第三方库,如 vue , react, 之类的,一般是指 package.json -> dependencies 的包。

抽离第三方库

第三方库一般比较稳定,一般比较很少会变更,而业务代码可能会频繁变更。

所以,如果不抽离打包,业务变动后,用户需要重新下载全部的代码:
image

修改代码部署后需要重新下载100KB

而抽离第三方库进行打包则只需要下载变更的业务代码

image

修改代码部署只需要重新下载20KB

tree shaking

tree shaking 是基于 es modules 的静态结构 筛除没有用到的代码(dead code)

  • Use ES2015 module syntax (i.e. import and export).
  • Ensure no compilers transform your ES2015 module syntax into CommonJS modules (this is the default behavior of popular Babel preset @babel/preset-env - see documentation for more details).
  • Add a "sideEffects" property to your project's package.json file.
  • Use production mode configuration option to enable various optimizations including minification and tree shaking.

注意事项:

  1. webpack 2.0 开始原生支持 ES Module,也就是说不需要 babel 把 ES Module 转换成曾经的 commonjs 模块了,想用上 Tree Shaking,请务必关闭 @babel/preset-env 默认的模块转义 (modules: false)。

  2. Webpack 4.0 开始,Tree Shaking 对于那些无副作用的模块 (package.json -> sideEffects = false ) 也会生效,无副作用的模块是指执行该模块的代码不会对环境造成影响(如 lodash 只 export 一些辅助函数)。

  3. 也就是说对于有副作用的模块,尽量不要在没有使用它的情况下引入该模块,不然 webpack 依然会将该模块打包构建。

  4. 如果是自己的 ES 代码import {say} from 'hello'import hello from 'hello'; hello.say() 都能够被 tree shaking

polyfill

自 ES2015/ES6 发布以来,现在已经到了 ES2018 了,但是依然有许多老旧浏览器依然占有一定的市场份额,所以我们依然需要对这部分浏览器作兼容性处理。

具体优化请参考:Show me the code,babel 7 最佳实践!

业务代码

拆分模块按需加载 (Code Splitting)

现在 SPA 已经是一个常见的场景了,而一般情况下,单页面应有一般都会存在多路由。而我们每次访问其实只访问一个路由,将代码按路由拆分并按需加载,对于首屏资源加载优化,是一个不错的选择。

image

不进行拆包需要下载 100Kb

image

进行拆包后只需要下载 20Kb

抽离公共模块

前面提到是按路由拆分模块包,其实存在一个问题是,如果存在公共模块,那么在每一个拆分出来的路由模块都会加载这个公共模块。
image

路由分割后

我们可以将公共模块抽离出来,避免重复的代码。

image

抽离公共代码后

可以明显看出减少了重复的 common01.jscommon02.js 的代码

缺点:路由动态按需加载 + 抽离公共代码可能会加载路由不必要的公共代码。例如: 访问 home 会加载 home.js + common.js(包含common01.js + common02.js),但其中的 common02.js 是没有用到的。

Webpack 4 的 splitChunksPlugin 可以根据模块之间的依赖关系,自动打包出很多很多(而不是单个)通用模块,可以保证加载进来的代码一定是会被依赖到的。

image

当然,打包出了多个通用模块的同时也会增加资源请求数,对前面所说的网络性能造成影响。

渲染

要优化渲染性能,根本目的就是尽快让用户看到页面内容,那么我们来看看到底用户从一片空白,到看到内容到底发生了哪些事情。

  1. 请求 HTML 文档,等待 HTML 文档返回,此时处于白屏状态。
  2. 从上往下,解析 HTML 文档的 head 标签,如果有 css/js 外链资源,将对之后的 HTML 文档造成阻塞。(资源下载)
  3. 对 HTML 文档的 body 标签进行解析渲染,因为SPA项目中index.html body 一般只有一个空的容器标签,所以页面依然是白屏 。(空标签)
  4. body 中存在外链文件加载、JS 解析等过程,导致界面依然白屏。
  5. ReactDOM.render(<App />, document.getElementById('root')) 触发后,界面显示出大体外框(侧边栏/头部导航栏/底部菜单栏等信息)。
  6. 进入 react-router,如使用了代码分割动态加载,将发起该路由对应bundle文件的请求(如:bill.xxx.chunk.js),该文件下载完成并执行前,页面只有外框。路由文件执行后,页面基本呈现完整内容。(路由跳转新资源下载)
  7. 调用 API 获取到业务数据后,填充进页面,展示出最终的页面内容。

好了,了解完了整个过程,我们就可以分析其中的瓶颈以及得出优化方案了。

避免 JS 文件阻塞渲染

首先,我们从上述过程可以看到,js 资源会阻塞 HTML 的解析,那么其实以前我们的常规操作就是把 JS 资源从 head 标签移动到 body 标签的末尾,避免阻塞。

但是随着技术发展,我们已经有更好解决的方案了。

defer / async

  • 常规的script标签,会阻塞HTML的解析
  • defer 会将js先下载下来,但是会等HTML解析完成后,才执行
  • async ,同时进行HTML解析与js下载,但js下载完成后立刻停止HTML解析并执行js代码

img

在body内末尾添加 script 标签

img

HTML解析,JS资源下载,JS执行 顺序图

从上图中我们可以看到, 不同情况下的脚本处理机制。
得出的优化选择是:

  • <script defer src="script.js"></script>
  • 如果需要兼容 IE9 或更老的浏览器,在body内末尾添加 script 标签。

其实以上两者从效果差不多,因为放在 body 内末尾基本上 HTML Parser 也已经结束。

preload / prefetch

preload

前面说到大多数基于标记语言的资源能被浏览器的预加载器(Preloader)尽早发现,推测出页面需要下载哪些资源,但不是所有的资源都是基于标记语言的,比如一些隐藏在 CSS 和 Javascript 中的资源。当浏览器发现自己需要这些资源时已经为时已晚,所以大多数情况,这些资源的加载都会对页面渲染造成延迟(如用作字体图标 font 字体资源,CSS 内的背景图片资源等)。

现在可以通过 preload 来提前声明当前页面会需要哪些资源(preload 资源请求优先级为 highest):

<link rel="preload" href="late_discovered_thing.js" as="script">
prefetch

当 SPA 使用了路由分割动态加载的时候,我们从一个页面跳转到另外一个页面的时候,浏览器会动态加载新页面所需要的 js 资源,然后再执行渲染。

使用预加载技术(prefetch) 技术能提前下载即将需要的资源。

它的原理是:
利用浏览器的空闲时间去先下载用户指定需要的内容,然后缓存起来,这样用户下次加载时,就直接从缓存中取出来,效率就快了。

<!-- 提前下载好 user 模块的 js 资源,用户访问 /user 时就可以直接读缓存 -->
<link href="/static/js/user.479d709b.js" rel="prefetch">

总结:

  • 当前页面肯定用到的资源用 preload (资源优先级 highest)
  • 下一个页面会用到的资源用 prefetch (资源优先级 lowest)

PS: prefetch 可能存在的风险:http 1.1 存在请求并发限制,如果 prefetch 数量太多,有可能阻塞异步加载的 script 资源

预渲染

前面提到,HTML 文件下载下来后,因为要等待文件加载,JS 解析等过程,而这些过程比较耗时,导致用户会长时间出于不可交互的首屏白屏状态。
在这个白屏阶段,可以用 预渲染 提前展示一部分内容,让用户感知到网站正在正常加载,而非糟糕的白屏体验。

预渲染原理:在 index.html 的 <div id="app">...</div> 填充自定义的内容,在 JS 资源下载之前展示必要的内容。让页面看起来很快,实际的加载速度并没有变化

我们清楚了预渲染的原理,下面介绍下预渲染有哪几种类型。

loading 动画

首先,很简单,也很常见,在 index.html 页面内联一个 loading 动画。

loading 动画可以很简单,一个菊花图即可。
也可以很复杂,如 Google Mail 的加载动画。
1 -12-2019 21-10-54

这其实只是一个CSS动画,进度条也是假的,并不是真实的加载进度,动画目的是让用户知道网页正在加载中,而不是看到一片不知道是不是挂了的白屏。

渲染静态DOM

加载动画,依然会让用户知道自己在等待,那我们何不直接给 index.html 添加我们真实页面的 HTML 呢?

Prerender SPA Plugin 就是能帮我们实现这个功能的 webpack 插件。

其原理是: 在构建的时候启动模拟的浏览器环境(headless chrome),并通过预渲染的事件钩子获取当前的页面内容,生成最终的 HTML 文件。

5c39e971b5e0b_5c39e9722a26e

上述gif 图中,左边用了静态预渲染,右边是常规的单页面,可以明显看到,虽然最终表现一样,但明显使用了预渲染优化的页面,会看起来快很多。

优点:这样就能让用户感受不到 loading, “误以为”自己的页面已经成功打开了,给用户很快打开页面的错觉。但页面实质上还在加载 JS 内容。

缺点:

  1. 虽然页面看起来已经加载完了,但实际上在 JS 资源加载完成之前,用户无法可能进行某些 UI 交互。
  2. 由于是每个用户的数据信息都不一样,所以基于动态数据的UI不能展示完全,要更好的体验可能需要业务代码本身存在数据占位符

骨架屏

上面那种方案提到了一个缺点是,基于动态数据的UI不能展示完全,例如一个账单列表,使用 Prerender SPA Plugin 预渲染的话,账单列表将会是空列表。 那么,这种情况有什么合适的方案解决呢?

骨架屏!

骨架屏是根据构建出来的页面结构,构造出页面的基本骨架内容。

自动生成骨架屏和 预渲染静态DOM 原理差不多,都是 在构建的时候启动模拟的浏览器环境(headless chrome), 获取到 HTML,但是骨架屏在此 HTML 基础上,根据一定的规则,将 HTML 中的 UI 用 灰色块替代。

优点:可以预渲染基于动态数据渲染出来的内容。

缺点:目前开源社区暂时没有一个高稳定性,高可用性的骨架屏自动生成插件。可能需要在业务代码上插入 骨架屏组件,侵入性比较强。

总结

从网络请求,到资源下载,最后到页面渲染,整体个人探索出来的优化到此为止,其实基本上都是基于构建角度来实现的优化,当然还有更颗粒到代码层级的优化,入用 Web Worker处理长耗时的JS任务避免阻塞之类的,这里不再细说。

另外,有不同意见的,或者还有哪些重要的优化操作我没有提及的,欢迎留言,互相学习。

参考文献

  1. 构建时预渲染:网页首帧优化实践
  2. web-performance-optimization
  3. HTML5 prefetch
  4. 使用 RAIL 模型评估性能
  5. 性能指标都是些什么鬼?
  6. 更快地构建DOM: 使用预解析, async, defer 以及 preload
  7. 关于Preload, 你应该知道些什么?
  8. 如何让网页“看起来”展现地更快?骨架屏二三事
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant