虽然前端开发做为 GUI 开发的一种,可是存在其特殊性,前端的特殊性就在于“动态”二字,传统 GUI 开发,无论是桌面应用仍是移动端应用都是须要预先下载的,只有先下载应用程序才会在本地操做系统运行,而前端不一样,它是“动态增量”式的,咱们的前端应用每每是实时加载执行的,并不须要预先下载,这就形成了一个问题,前端开发中每每最影响性能的不是什么计算或者渲染,而是加载速度,加载速度会直接影响用户体验和网站留存。javascript
《Designing for Performance》的做者 Lara Swanson在2014年写过一篇文章《Web性能即用户体验》,她在文中提到“网站页面的快速加载,可以创建用户对网站的信任,增长回访率,大部分的用户其实都期待页面可以在2秒内加载完成,而当超过3秒之后,就会有接近40%的用户离开你的网站”。css
值得一提的是,GUI 开发依然有一个共同的特殊之处,那就是 体验性能 ,体验性能并不指在绝对性能上的性能优化,而是回归用户体验这个根本目的,由于在 GUI 开发的领域,绝大多数状况下追求绝对意义上的性能是没有意义的.html
好比一个动画原本就已经有 60 帧了,你经过一个吊炸天的算法优化到了 120 帧,这对于你的 KPI 毫无用处,由于这个优化自己没有意义,由于除了少数特异功能的异人,没有人能分得清 60 帧和 120 帧的区别,这对于用户的体验没有任何提高,相反,一个首屏加载须要 4s 的网站,你没有任何实质意义上的性能优化,只是加了一个设计姐姐设计的 loading 图,那这也是十分有意义的优化,由于好的 loading 能够减小用户焦虑,让用户感受没有等过久,这就是用户体验级的性能优化.前端
所以,咱们要强调即便没有对性能有实质的优化,经过设计提升用户体验的这个过程,也算是性能优化,由于 GUI 开发直面用户,你让用户有了性能快的 错觉 ,这也叫性能优化了,毕竟用户以为快,才是真的快...vue
首屏加载是被讨论最多的话题,一方面web 前端首屏的加载性能的确广泛较差,另外一方面,首屏的加载速度相当重要,不少时候过长的白屏会致使用户尚未体验到网站功能的时候就流失了,首屏速度是用户留存的关键点。java
以用户体验的角度来解读首屏的关键点,若是做为用户咱们从输入网址以后的内心过程是怎样的呢?node
当咱们敲下回车后,咱们第一个疑问是:
"它在运行吗?"
这个疑问一直到用户看到页面第一个绘制的元素为止,这个时候用户才能肯定本身的请求是有效的(而不是被墙了...),而后第二个疑问:
"它有用吗?"
若是只绘制出无心义的各类乱序的元素,这对于用户是不可理解的,此时虽然页面开始加载了,可是对于用户没有任何价值,直到文字内容、交互按钮这些元素加载完毕,用户才能理解页面,这个时候用户会尝试与页面交互,会有第三个疑问:
"它能使用了吗?"
直到用户成功与页面互动,这才算是首屏加载完毕了.react
在第一个疑问和第二个疑问之间的等待期,会出现白屏,这是优化的关键.webpack
无论是咱们如何优化性能,首屏必然是会出现白屏的,由于这是前端开发这项技术的特色决定的。git
那么咱们先定义一下白屏,这样才能方便计算咱们的白屏时间,由于白屏的计算逻辑说法不一,有人说要从首次绘制(First Paint,FP)算起到首次内容绘制(First Contentful Paint,FCP)这段时间算白屏,我我的是不一样意的,我我的更倾向因而从路由改变起(即用户再按下回车的瞬间)到首次内容绘制(即能看到第一个内容)为止算白屏时间,由于按照用户的心理,在按下回车起就认为本身发起了请求,而直到看到第一个元素被绘制出来以前,用户的内心是焦虑的,由于他不知道这个请求会不会被响应(网站挂了?),不知道要等多久才会被响应到(网站慢?),这期间为用户首次等待期间。
白屏时间 = firstPaint - performance.timing.navigationStart
以webapp 版的微博为例(微博为数很少的的良心产品),通过 Lighthouse(谷歌的网站测试工具)它的白屏加载时间为 2s,是很是好的成绩。
在现代前端应用开发中,咱们每每会用 webpack 等打包器进行打包,不少状况下若是咱们不进行优化,就会出现不少体积巨大的 chunk,有的甚至在 5M 左右(我第一次用 webpack1.x 打包的时候打出了 8M 的包),这些 chunk 是加载速度的杀手。
浏览器一般都有并发请求的限制,以 Chrome 为例,它的并发请求就为 6 个,这致使咱们必须在请求完前 6 个以后,才能继续进行后续请求,这也影响咱们资源的加载速度。
固然了,网络、带宽这是自始至终都影响加载速度的因素,白屏也不例外.
咱们先梳理下白屏时间内发生了什么:
若是你用的是以 webpack 为基础的前端框架工程体系,那么你的index.html 文件必定是这样的:
<div id="root"></div> 复制代码
咱们将打包好的整个代码都渲染到这个 root 根节点上,而咱们如何渲染呢?固然是用 JavaScript 操做各类 dom 渲染,好比 react 确定是调用各类 _React_._createElement_()
,这是很耗时的,在此期间虽然 html 被加载了,可是依然是白屏,这就存在操做空间,咱们能不能在 js 执行期间先加入提示,增长用户体验呢?
是的,咱们通常有一款 webpack 插件叫html-webpack-plugin ,在其中配置 html 就能够在文件中插入 loading 图。
webpack 配置:
const HtmlWebpackPlugin = require('html-webpack-plugin') const loading = require('./render-loading') // 事先设计好的 loading 图 module.exports = { entry: './src/index.js', output: { path: __dirname + '/dist', filename: 'index_bundle.js' }, plugins: [ new HtmlWebpackPlugin({ template: './src/index.html', loading: loading }) ] } 复制代码
那么既然在 HTML 加载到 js 执行期间会有时间等待,那么为何不直接服务端渲染呢?直接返回的 HTML 就是带完整 DOM 结构的,免得还得调用 js 执行各类建立 dom 的工做,不只如此还对 SEO 友好。
正是有这种需求 vue 和 react 都支持服务端渲染,而相关的框架Nuxt.js、Next.js也大行其道,固然对于已经采用客户端渲染的应用这个成本过高了。
因而有人想到了办法,谷歌开源了一个库Puppeteer,这个库实际上是一个无头浏览器,经过这个无头浏览器咱们能用代码模拟各类浏览器的操做,好比咱们就能够用 node 将 html 保存为 pdf,能够在后端进行模拟点击、提交表单等操做,天然也能够模拟浏览器获取首屏的 HTML 结构。
prerender-spa-plugin就是基于以上原理的插件,此插件在本地模拟浏览器环境,预先执行咱们的打包文件,这样经过解析就能够获取首屏的 HTML,在正常环境中,咱们就能够返回预先解析好的 HTML 了。
咱们看到在获取 html 以后咱们须要自上而下解析,在解析到 script
相关标签的时候才能请求相关资源,并且因为浏览器并发限制,咱们最多一次性请求 6 次,那么有没有办法破解这些困境呢?
http2 是很是好的解决办法,http2 自己的机制就足够快:
例如:下图中的两个请求, 请求一发送了全部的头部字段,第二个请求则只须要发送差别数据,这样能够减小冗余数据,下降开销
咱们能够点击此网站 进行 http2 的测试
我曾经作个一个测试,http2 在网络通畅+高性能设备下的表现没有比 http1.1有明显的优点,可是网络越差,设备越差的状况下 http2 对加载的影响是质的,能够说 http2 是为移动 web 而生的,反而在光纤加持的高性能PC 上优点不太明显.
既然 http 请求如此麻烦,能不能咱们避免 http 请求或者下降 http 请求的负载来实现性能优化呢?
利用浏览器缓存是很好的办法,他能最大程度上减小 http 请求,在此以前咱们要先回顾一下 http 缓存的相关知识.
咱们先罗列一下和缓存相关的请求响应头。
响应头,表明该资源的过时时间。
请求/响应头,缓存控制字段,精确控制缓存策略。
请求头,资源最近修改时间,由浏览器告诉服务器。
响应头,资源最近修改时间,由服务器告诉浏览器。
响应头,资源标识,由服务器告诉浏览器。
请求头,缓存资源标识,由浏览器告诉服务器。
配对使用的字段:
当无本地缓存的时候是这样的:
一般状况下咱们的 WebApp 是有咱们的自身代码和第三方库组成的,咱们自身的代码是会经常变更的,而第三方库除非有较大的版本升级,否则是不会变的,因此第三方库和咱们的代码须要分开打包,咱们能够给第三方库设置一个较长的强缓存时间,这样就不会频繁请求第三方库的代码了。
那么如何提取第三方库呢?在 webpack4.x 中, SplitChunksPlugin 插件取代了 CommonsChunkPlugin 插件来进行公共模块抽取,咱们能够对SplitChunksPlugin 进行配置进行 拆包 操做。
SplitChunksPlugin配置示意以下:
optimization: { splitChunks: { chunks: "initial", // 代码块类型 必须三选一: "initial"(初始化) | "all"(默认就是all) | "async"(动态加载) minSize: 0, // 最小尺寸,默认0 minChunks: 1, // 最小 chunk ,默认1 maxAsyncRequests: 1, // 最大异步请求数, 默认1 maxInitialRequests: 1, // 最大初始化请求书,默认1 name: () => {}, // 名称,此选项课接收 function cacheGroups: { // 缓存组会继承splitChunks的配置,可是test、priorty和reuseExistingChunk只能用于配置缓存组。 priority: "0", // 缓存组优先级,即权重 false | object | vendor: { // key 为entry中定义的 入口名称 chunks: "initial", // 必须三选一: "initial"(初始化) | "all" | "async"(默认就是异步) test: /react|lodash/, // 正则规则验证,若是符合就提取 chunk name: "vendor", // 要缓存的 分隔出来的 chunk 名称 minSize: 0, minChunks: 1, enforce: true, reuseExistingChunk: true // 可设置是否重用已用chunk 再也不建立新的chunk } } } } 复制代码
SplitChunksPlugin 的配置项不少,能够先去官网了解如何配置,咱们如今只简单列举了一下配置元素。
若是咱们想抽取第三方库能够这样简单配置
splitChunks: { chunks: 'all', // initial、async和all minSize: 30000, // 造成一个新代码块最小的体积 maxAsyncRequests: 5, // 按需加载时候最大的并行请求数 maxInitialRequests: 3, // 最大初始化请求数 automaticNameDelimiter: '~', // 打包分割符 name: true, cacheGroups: { vendor: { name: "vendor", test: /[\\/]node_modules[\\/]/, //打包第三方库 chunks: "all", priority: 10 // 优先级 }, common: { // 打包其他的的公共代码 minChunks: 2, // 引入两次及以上被打包 name: 'common', // 分离包的名字 chunks: 'all', priority: 5 }, } }, 复制代码
这样彷佛大功告成了?并无,咱们的配置有很大的问题:
下图示意了如何将第三方库进行拆包,基础型的 react 等库与工具性的 lodash 和特定库 Echarts 进行拆分
cacheGroups: { reactBase: { name: 'reactBase', test: (module) => { return /react|redux/.test(module.context); }, chunks: 'initial', priority: 10, }, utilBase: { name: 'utilBase', test: (module) => { return /rxjs|lodash/.test(module.context); }, chunks: 'initial', priority: 9, }, uiBase: { name: 'chartBase', test: (module) => { return /echarts/.test(module.context); }, chunks: 'initial', priority: 8, }, commons: { name: 'common', chunks: 'initial', priority: 2, minChunks: 2, }, } 复制代码
咱们对 chunk 进行 hash 化,正以下图所示,咱们变更 chunk2 相关的代码后,其它 chunk 都没有变化,只有 chunk2 的 hash 改变了
output: { filename: mode === 'production' ? '[name].[chunkhash:8].js' : '[name].js', chunkFilename: mode === 'production' ? '[id].[chunkhash:8].chunk.js' : '[id].js', path: getPath(config.outputPath) } 复制代码
咱们经过 http 缓存+webpack hash 缓存策略使得前端项目充分利用了缓存的优点,可是 webpack 之因此须要传说中的 webpack配置工程师 是有缘由的,由于 webpack 自己是玄学,仍是以上图为例,若是你 chunk2的相关代码去除了一个依赖或者引入了新的可是已经存在工程中依赖,会怎么样呢?
咱们正常的指望是,只有 chunk2 发生变化了,可是事实上是大量不相干的 chunk 的 hash 发生了变更,这就致使咱们缓存策略失效了,下图是变动后的 hash,咱们用红圈圈起来的都是 hash 变更的,而事实上咱们只变更了 chunk2 相关的代码,为何会这样呢?
webpack hash缓存相关内容建议阅读此文章 做为拓展
在白屏结束以后,页面开始渲染,可是此时的页面还只是出现个别无心义的元素,好比下拉菜单按钮、或者乱序的元素、导航等等,这些元素虽然是页面的组成部分可是没有意义.
什么是有意义?
对于搜索引擎用户是完整搜索结果
对于微博用户是时间线上的微博内容
对于淘宝用户是商品页面的展现
那么在FCP 和 FMP 之间虽然开始绘制页面,可是整个页面是没有意义的,用户依然在焦虑等待,并且这个时候可能出现乱序的元素或者闪烁的元素,很影响体验,此时咱们可能须要进行用户体验上的一些优化。
Skeleton是一个好方法,Skeleton如今已经很开始被普遍应用了,它的意义在于事先撑开即将渲染的元素,避免闪屏,同时提示用户这要渲染东西了,较少用户焦虑。
好比微博的Skeleton就作的很不错
以 vue-cli 3 为例,咱们能够直接在vue.config.js 中配置
//引入插件 const SkeletonWebpackPlugin = require('vue-skeleton-webpack-plugin'); module.exports = { // 额外配置参考官方文档 configureWebpack: (config)=>{ config.plugins.push(new SkeletonWebpackPlugin({ webpackConfig: { entry: { app: path.join(__dirname, './src/Skeleton.js'), }, }, minimize: true, quiet: true, })) }, //这个是让骨架屏的css分离,直接做为内联style处理到html里,提升载入速度 css: { extract: true, sourceMap: false, modules: false } } 复制代码
而后就是基本的 vue 文件编写了,直接看文档便可。
当有意义的内容渲染出来以后,用户会尝试与页面交互,这个时候页面并非加载完毕了,而是看起来页面加载完毕了,事实上这个时候 JavaScript 脚本依然在密集得执行.
咱们看到在页面已经基本呈现的状况下,依然有大量的脚本在执行
在页面基本呈现到能够交互这段时间,绝大部分的性能消耗都在 JavaScript 的解释和执行上,这个时候决定 JavaScript 解析速度的无非一下两点:
JavaScript 的体积问题咱们上一节交代过了一些,咱们能够用SplitChunksPlugin拆库的方法减少体积,除此以外还有一些方法,咱们下文会交代。
Tree Shaking虽然出现很早了,好比js基础库的事实标准打包工具 rollup 就是Tree Shaking的祖师爷,react用 rollup 打包以后体积减小了 30%,这就是Tree Shaking的厉害之处。
Tree Shaking的做用就是,经过程序流分析找出你代码中无用的代码并剔除,若是不用Tree Shaking那么不少代码虽然定义了可是永远都不会用到,也会进入用户的客户端执行,这无疑是性能的杀手,Tree Shaking依赖es6的module模块的静态特性,经过分析剔除无用代码.
目前在 webpack4.x 版本以后在生产环境下已经默认支持Tree Shaking了,因此Tree Shaking能够称得上开箱即用的技术了,可是并不表明Tree Shaking真的会起做用,由于这里面仍是有不少坑.
坑 1: Babel 转译,咱们已经提到用Tree Shaking的时候必须用 es6 的module,若是用 common.js那种动态module,Tree Shaking就失效了,可是 Babel 默认状态下是启用 common.js的,因此须要咱们手动关闭.
坑 2: 第三方库不可控,咱们已经知道Tree Shaking的程序分析依赖 ESM,可是市面上不少库为了兼容性依然只暴露出了ES5 版本的代码,这致使Tree Shaking对不少第三方库是无效的,因此咱们要尽可能依赖有 ESM 的库,好比以前有一个 ESM 版的 lodash(lodash-es),咱们就能够这样引用了import { dobounce } from 'lodash-es'
polyfill是为了浏览器兼容性而生,是否须要 polyfill 应该有客户端的浏览器本身决定,而不是开发者决定,可是咱们在很长一段时间里都是开发者将各类 polyfill 打包,其实不少状况下致使用户加载了根本没有必要的代码.
解决这个问题的方法很简单,直接引入 <script src="https://cdn.polyfill.io/v2/polyfill.min.js"></script>
便可,而对于 Vue 开发者就更友好了,vue-cli 如今生成的模板就自带这个引用.
这个原理就是服务商经过识别不一样浏览器的浏览器User Agent,使得服务器可以识别客户使用的操做系统及版本、CPU 类型、浏览器及版本、浏览器渲染引擎、浏览器语言、浏览器插件等,而后根据这个信息判断是否须要加载 polyfill,开发者在浏览器的 network 就能够查看User Agent。
既然 polyfill 能动态加载,那么 es5 和 es6+的代码能不能动态加载呢?是的,可是这样有什么意义呢?es6 会更快吗?
咱们得首先明确一点,通常状况下在新标准发布后,浏览器厂商会着重优化新标准的性能,而老的标准的性能优化会逐渐停滞,即便面向将来编程,es6 的性能也会往愈来愈快的方向发展.
其次,咱们平时编写的代码可都es6+,而发布的es5是通过babel 或者 ts 转译的,在大多数状况下,通过工具转译的代码每每被比不上手写代码的性能,这个性能对比网站 的显示也是如此,虽然 babel 等转译工具都在进步,可是仍然会看到转译后代码的性能降低,尤为是对 class 代码的转译,其性能降低是很明显的.
最后,转译后的代码体积会出现代码膨胀的状况,转译器用了不少奇技淫巧将 es6 转为 es5 致使了代码量剧增,使用 es6就表明了更小的体积.
那么如何动态加载 es6 代码呢?秘诀就是<script type="module">
这个标签来判断浏览器是否支持 es6,我以前在掘金上看到了一篇翻译的文章 有详细的动态打包过程,能够拓展阅读.
体积大小对比
执行时间对比
咱们在上文中已经经过SplitChunksPlugin将第三方库进行了抽离,可是在首屏加载过程当中依然有不少冗余代码,好比咱们的首页是个登陆界面,那么其实用到的代码很简单
登陆界面的代码是不多的,为何不仅加载登陆界面的代码呢?
这就须要咱们进行对代码在路由级别的拆分,除了基础的框架和 UI 库以外,咱们只须要加载当前页面的代码便可,这就有得用到Code Splitting技术进行代码分割,咱们要作的其实很简单.
咱们得先给 babel 设置plugin-syntax-dynamic-import这个动态import 的插件,而后就能够就函数体内使用 import 了.
对于Vue 你能够这样引入路由
export default new Router({ routes: [ { path: '/', name: 'Home', component: Home }, { path: '/login', name: 'login', component: () => import('@components/login') } ] 复制代码
你的登陆页面会被单独打包.
对于react,其内置的 React.lazy()
就能够动态加载路由和组件,效果与 vue 大同小异,固然 lazy()
目前尚未支持服务端渲染,若是想在服务端渲染使用,能够用React Loadable.
路由实际上是一个大组件,不少时候人们忽略了路由跳转之间的加载优化,更多的时候咱们的精力都留在首屏加载之上,可是路由跳转间的加载一样重要,若是加载过慢一样影响用户体验。
咱们不可忽视的是在不少时候,首屏的加载反而比路由跳转要快,也更容易优化。
好比石墨文档的首页是这样的:
这并非石墨作得不够好,而是对于这种应用型网站,相比于首屏,工做页面的跳转加载优化难度更大,由于其工做页面的代码量远远大于一个官网的代码量和复杂度.
咱们看到在加载过程当中有超过 6000ms 再进行 JavaScript 的解析和执行
Code Splitting不只能够进行路由分割,甚至能够进行组件级别的代码分割,固然是用方式也是大同小异,组件的级别的分割带来的好处是咱们能够在页面的加载中只渲染部分必须的组件,而其他的组件能够按需加载.
就好比一个Dropdown(下拉组件),咱们在渲染初始页面的时候下拉的Menu(菜单组件)是不必渲染的,由于只有点击了Dropdown以后Menu 才有必要渲染出来.
路由分割 vs 组件分割
咱们的 demo 是这样子:
咱们先对比一下有组件分割和无组件分割的资源加载状况(开发环境下无压缩)
无组件分割,咱们看到有一个很是大的chunk,由于这个组件除了咱们的代码外,还包含了 antd 组件和 Echarts 图表以及 React 框架部分代码
组件分割后,初始页面体积降低明显,路由间跳转的初始页面加载体积变小意味着更快的加载速度
其实组件分割的方法跟路由分割差很少,也是经过 lazy + Suspense 的方法进行组件懒加载
// 动态加载图表组件 const Chart = lazy(() => import(/* webpackChunkName: 'chart' */'./charts')) // 包含着图表的 modal 组件 const ModalEchart = (props) => ( <Modal title="Basic Modal" visible={props.visible} onOk={props.handleOk} onCancel={props.handleCancel} > <Chart /> </Modal> ) 复制代码
咱们经过组件懒加载将页面的初始渲染的资源体积下降了下来,提升了加载性能,可是组件的性能又出现了问题,仍是上一个 demo,咱们把初始页面的 3.9m 的体积减小到了1.7m,页面的加载是迅速了,可是组件的加载却变慢了.
缘由是其他的 2m 资源的压力所有压在了图表组件上(Echarts 的体积缘故),所以当咱们点击菜单加载图表的时候会出现 1-2s 的 loading 延迟,以下:
咱们能不能提早把图表加载进来,避免图表渲染中加载时间过长的问题?这种提早加载的方法就是组件的预加载.
原理也很简单,就是在用户的鼠标还处于 hover 状态的时候就开始触发图表资源的加载,一般状况下当用户点击结束以后,加载也基本完成,这个时候图表会很顺利地渲染出来,不会出现延迟.
/** * @param {*} factory 懒加载的组件 * @param {*} next factory组件下面须要预加载的组件 */ function lazyWithPreload(factory, next) { const Component = lazy(factory); Component.preload = next; return Component; } ... // 而后在组件的方法中触发预加载 const preloadChart = () => { Modal.preload() } 复制代码
对于使用 vue 的开发者 keep-alive 这个 API 应该是最熟悉不过了,keep-alive 的做用是在页面已经跳转后依然不销毁组件,保存组件对应的实例在内存中,当此页面再次须要渲染的时候就能够利用已经缓存的组件实例了。
若是大量实例不销毁保存在内存中,那么这个 API 存在内存泄漏的风险,因此要注意调用deactivated销毁
可是在 React 中并无对应的实现,而官方 issue 中官方也明确不会添加相似的 API,可是给出了两个自行实现的方法
style={{display: 'none'}}
进行控制若是你看了这两个建议就知道不靠谱,redux 已经足够啰嗦了,咱们为了缓存状态而利用 redux 这种全局方案,其额外的工做量和复杂度提高是得不偿失的,用 dispaly
控制显示是个很简单的方法,可是也足够粗暴,咱们会损失不少可操做的空间,好比动画。
react-keep-alive 在必定程度上解决这个问题,它的原理是利用React 的 Portals API 将缓存组件挂载到根节点之外的 dom 上,在须要恢复的时候再将缓存组件挂在到相应节点上,同时也能够在额外的生命周期 componentWillUnactivate
进行销毁处理。
固然还有不少常见的性能优化方案咱们没有说起:
咱们着重整理了前端加载阶段的性能优化方案,不少时候只是给出了方向,真正要进行优化仍是须要在实际项目中根据具体状况进行分析挖掘才能将性能优化作到最好.
参考连接: