前言
和很多小伙伴一样, 我在开发 vue 项目时也是基于官方 vue-cli@2 的 webpack 模版, 但随着项目越做越大, 依赖的第三方 NPM 包越来越多, 构建之后的文件也会越来越大, 尤其是 vendor.JS, 甚至会达到 2M 左右. 再加上又是单页应用, 这就会导致在网速较慢或者服务器带宽有限的情况出现长时间的白屏. 为了解决这个问题, 我做了一些探索, 在几乎不需要改动业务代码的情况下, 找到了三种有明显效果的优化方案 -- CDN + Gzip + Prerender. 我把这些方法整理了一下, 放在了 GitHub 仓库 https://GitHub.com/HaoChuan9421/vue-optimization 上, 意图通过不同的分支来展示不同的优化方式, 对 Vue 项目性能的影响. 你可以直接克隆下来试一试, 也得益于有 Git 历史, 你也可以很方便的查看具体的改动细节. 下面我将通过一个简单的项目来展示这三种优化方案的效果.
一, 首先准备一个简单的项目 https://GitHub.com/HaoChuan9421/vue-optimization
通过 vue-cli@2 的 webpack 模版生成, 只包含最基础的 Vue 三件套 ---- vue,vue-router,vuex 以及常用的 element-ui 和 axios. 拆分两个路由 --"首页" 和 "通讯录", 通过 axios 异步获取一个通讯录名单, 并利用 element-ui 的表格展示. 直接 build, 不做任何优化处理, 以作参照.
1.1 构建后文件说明:
App.CSS: 压缩合并后的样式文件.
App.JS: 主要包含项目中的 App.vue,main.JS,router,store 等业务代码.
vendor.JS: 主要包含项目依赖的诸如 vuex,axios 等第三方库的源码, 这也是为什么这个文件如此之大的原因, 下一步将探索如何优化这一块, 毕竟随着项目的开发, 依赖的库也能会越来越多.
数字. JS: 以 0,1,2,3 等数字开头的 JS 文件, 这些文件是各个路由切分出的代码块, 因为我拆分了两个路由, 并做了路由懒加载, 所以出现了 0 和 1 两个 JS 文件.
mainfest.JS:mainfest 的英文有清单, 名单的意思, 该文件包含了加载和处理路由模块的逻辑
1.2 禁用浏览器缓存, 网速限定为 Fast 3G 下的 Network 图 (运行在本地的 nginx 服务器上
可以看到未经优化的 base 版本在 Fast 3G 的网络下大概需要 7 秒多的时间才加载完毕
二, CDN 优化
将依赖的 vue,vue-router,vuex,element-ui 和 axios 这五个库, 全部改为通过 CDN 链接获取. 借助 htmlWebpackPlugin, 可以方便的使用循环语法在 index.HTML 里插入 JS 和 CSS 的 CDN 链接. 这里的 CDN 大部分使用的 jsDelivr https://www.jsdelivr.com/ 提供的.
- <!-- CDN 文件, 配置在 config/index.JS 下 -->
- <% for (var i in htmlWebpackPlugin.options.CSS) { %>
- <link href="<%= htmlWebpackPlugin.options.CSS[i] %>" rel="stylesheet">
- <% } %>
- <% for (var i in htmlWebpackPlugin.options.JS) { %>
- <script src="<%= htmlWebpackPlugin.options.JS[i] %>">
- </script>
- <% } %>
在
build/webpack.base.conf.JS
中添加如下代码, 这使得在使用 CDN 引入外部文件的情况下, 依然可以在项目中使用 import 的语法来引入这些第三方库, 也就意味着你不需要改动项目的代码, 这里的键名是 import 的 NPM 包名, 键值是该库暴露的全局变量. webpack 文档参考链接.
- externals: {
- 'vue': 'Vue',
- 'vue-router': 'VueRouter',
- 'vuex': 'Vuex',
- 'element-ui':'ELEMENT',
- 'axios':'axios'
- }
卸载依赖的 NPM 包,
NPM uninstall axios element-ui vue vue-router vuex
删除 main.JS 里 element-ui 相关代码.
具体细节可以查看 Git 的历史记录
2.1 比对添加 CDN 前后构建的文件:
优化后:
优化前:
可以看出:
App.CSS: 因为不再通过
import 'element-ui/lib/theme-chalk/index.CSS'
, 而是直接通过 CDN 链接的方式引入 element-ui 样式, 使得文件小到了 bytes 级别, 因为它现在仅包含少量的项目的 CSS.
App.JS: 几乎无变化, 因为这里面主要还是自己业务的代码.
vendor.JS: 将 5 个依赖的 JS 全部转为 CDN 链接后, 已经小到了不足 1KB, 其实里面已经没有任何第三方库了.
数字. JS 和 mainfest.JS: 这些文件本来就很小, 变化几乎可以忽略.
2.2 同样, 禁用浏览器缓存, 网速限定为 Fast 3G 下的 Network 图 (运行在本地的 nginx 服务器上
可以看出相同的网络环境下, 加载从原来的 7 秒多, 提速到现在的 3 秒多, 提升非常明显. 而且更重要的一点是原本的方式, 所有 的 JS 和 CSS 等静态资源都是请求的我们自己的 nginx 服务器, 而现在大部分的静态资源都请求的是第三方的 CDN 资源, 这不仅可以带来速度上的提升, 在高并发的时候, 这无疑大大降低的自己服务器的带宽压力, 想象一下原来首屏 900 多 KB 的文件 现在仅剩 20KB 是请求自己服务器的!
三, Gzip 优化
使用 Gzip 两个明显的好处, 一是可以减少存储空间, 二是通过网络传输文件时, 可以减少传输的时间.
3.1 如何开启 gzip 压缩
开启 gzip 的方式主要是通过修改服务器配置, 以 nginx 服务器为例, 下图是, 使用同一套代码, 在仅改变服务器的 gzip 开关状态的情况下的 Network 对比图
未开启 gzip 压缩:
开启 gzip 压缩:
开启 gzip 压缩后的响应头
从上图可以明显看出开启 gzip 前后, 文件大小有三四倍的差距, 加载速度也从原来的 7 秒多, 提升到 3 秒多
附上 nginx 的配置方式
- http {
- gzip on;
- gzip_static on;
- gzip_min_length 1024;
- gzip_buffers 4 16k;
- gzip_comp_level 2;
- gzip_types text/plain application/JavaScript application/x-JavaScript text/CSS application/xml text/JavaScript application/x-httpd-PHP application/vnd.ms-fontobject font/ttf font/opentype font/x-woff image/svg+xml;
- gzip_vary off;
- gzip_disable "MSIE [1-6]\.";
- }
3.2 前端能为 gzip 做点什么
我们都知道 config/index.JS 里有一个 productionGzip 的选项, 那么它是做什么用的? 我们尝试执行 NPM install --save-dev compression-webpack-plugin@1.x, 并把 productionGzip 设置为 true, 重新 build, 放在 nginx 服务器下, 看看有什么区别:
我们会发现构建之后的文件多了一些 JS.gz 和 CSS.gz 的文件, 而且 vendor.JS 变得更小了, 这其实是因为我们开启了 nginx 的 gzip_static on; 选项, 如果 gzip_static 设置为 on, 那么就会使用同名的. gz 文件, 不会占用服务器的 CPU 资源去压缩.
3.3 前端快速搭建基于 node 的 gzip 服务
无法搭建 nginx 环境的前端小伙伴也可以按如下步骤快速启动一个带 gzip 的 express 服务器
执行
NPM i express compression
在项目根目录下新建一个 serve.JS, 并粘贴如下代码
- var express = require('express')
- var App = express()
- // 开启 gzip 压缩, 如果你想关闭 gzip, 注释掉下面两行代码, 重新执行 `node server.JS`
- var compression = require('compression')
- App.use(compression())
- App.use(express.static('dist'))
- App.listen(3000,function () {
- console.log('server is runing on http://localhost:3000')
- })
执行 node server.JS
下图是 express 开启 gzip 的响应头:
四, Prerender 预渲染
大家都是知道: 常见的 Vue 单页应用构建之后的 index.HTML 只是一个包含根节点的空白页面, 当所有需要的 JS 加载完毕之后, 才会开始解析并创建 vnode, 然后再渲染出真实的 DOM. 当这些 JS 文件过大而网速又很慢或者出现意料之外的报错时, 就会出现所谓的白屏, 相信做 Vue 开发的小伙伴们一定都遇到过这种情况. 而且单页应用还有一个很大的弊端就是对 SEO 很不友好. 那么如何解决这些问题呢?-- SSR 当然是很好的解决的方案, 但这也意为着一定的学习成本和运维成本, 而如果你已经有了一个现成的 vue 单页应用, 转向 SSR 也并不是一个无缝的过程. 那么预渲染就显得更加合适了. 只需要安装一个 webpack 的插件 + 一些简单的 webpack 配置就可以解决上述的两个问题.
4.1 如何将单页应用转为预渲染
你需要将 router 设为 history 模式, 并相应的调整服务器配置, 这并不复杂.
NPM i prerender-spa-plugin --save-dev
在
build/webpack.prod.conf.JS
下添加如下配置 (没有路由懒加载的情况).
- const PrerenderSPAPlugin = require('prerender-spa-plugin')
- ...
- new PrerenderSPAPlugin({
- staticDir: config.build.assetsRoot,
- routes: [ '/', '/Contacts' ], // 需要预渲染的路由 (视你的项目而定)
- minify: {
- collapseBooleanAttributes: true,
- collapseWhitespace: true,
- decodeEntities: true,
- keepClosingSlash: true,
- sortAttributes: true
- }
- })
将 config/index.JS 里 build 中的 assetsPublicPath 字段设置为'/', 这是因为当你使用预渲染时, 路由组件会编译成相应文件夹下的 index.HTML, 它会依赖 static 目录下的文件, 而如果使用相对路径则会导致依赖的路径错误, 这也要求预渲染的项目最好是放在网站的根目录下 (这个坑我已经在
prerender-spa-plugin
仓库提过 ISSUE 了, 不过借助 postProcess, 自己再写一个正则表达式, 也能实现, 如果你有这方面的需求, 可以参考下面 路由懒加载带来的坑).
调整 main.JS
- new Vue({
- router,
- store,
- render: h => h(App)
- }).$mount('#App', true) // https://ssr.vuejs.org/zh/guide/hydration.HTML
执行 NPM run build, 你会发现, dist 目录和以往不太一样, 不仅多了与指定路由同名的文件夹而且 index.HTML 早已渲染好了静态页面.
4.2 效果如何?
和之前一样, 我们依然禁用缓存, 将网速限定为 Fast 3G(运行在本地的 nginx 服务器上). 可以看到, 在 vendor.JS 还没有加载完毕的时候 (大概有 700 多 kB, 此时只加载了 200 多 kB), 页面已经完整的呈现出来了. 事实上, 只需要 index.HTML 和 App.CSS 加载完毕, 页面的静态内容就可以很好的呈现了. 预渲染对于这些有大量静态内容的页面, 无疑是很好的选择.
4.3 路由懒加载带来的坑
如果你的项目没有做路由懒加载, 那么你大可放心的按上面所说的去实践了. 但如果你的项目里用了, 你应该会看到 webpackJsonp is not defined 的报错. 这个因为 prerender-spa-plugin 渲染静态页面时, 也会将类似于 < script src="/static/JS/0.9231fc498af773fb2628.JS" type="text/JavaScript" async charset="utf-8"></script > 这样的异步 script 标签注入到生成的 HTML 的 head 标签内. 这会导致它先于 App.JS,vendor.JS,manifest.JS(位于 body 底部) 执行.(async 只是不会阻塞后面的 DOM 解析, 这并不意味这它最后执行). 而且当这些 JS 加载完毕后, 又会在 head 标签重复创建这个异步的 script 标签. 虽然这个报错不会对程序造成影响, 但是最好的方式, 还是不要把这些异步组件直接渲染到最终的 HTML 中. 好在 prerender-spa-plugin 提供了 postProcess 选项, 可以在真正生成 HTML 文件之前做一次处理, 这里我使用一个简单的正则表达式, 将这些异步的 script 标签剔除. 本分支已经使用了路由懒加载, 你可以直接查看 Git 历史, 比对文件和 base 分支的变化来对你的项目进行相应调整.
- postProcess (renderedRoute) {
- renderedRoute.HTML = renderedRoute.HTML.replace(/<script.*src=".*[0-9]+\.[0-9a-z]*\.JS"><\/script>/,'')
- return renderedRoute
- }
除了这种解决方案, 还有两种不推荐的解决方案:
索性不使用路由懒加载.
将 HtmlWebpackPlugin 的 inject 字段设置为'head', 这样
App.JS,vendor.JS,manifest.JS
就会插入到 head 里, 并在异步的 script 标签上面. 但由于普通的 script 是同步的, 在他们全部加载完毕之前, 页面是无法渲染的, 也就违背了 prerender 的初衷, 而且你还需要对 main.JS 作如下修改, 以确保 Vue 在实例化的时候可以找到
<div id="App"></div>
, 并正确挂载.
- const App = new Vue({
- // ...
- })
- document.addEventListener('DOMContentLoaded', function () {
- App.$mount('#App')
- })
总结
虽然官方的脚手架已经提供很多开箱即用的优化, 比如 CSS 压缩合并, JS 压缩与模块化, 小图片转 base64 等等, 但我们能做的还很多. 我没有提及代码级别的优化细节, 也是希望给大家提供一些可实践的方案. 上述三种方案或多或少都会给你项目带来一些收益. 优化也是一门玄学, 可研究的东西很多. 也希望其他小伙伴可以在评论区提供宝贵意见, 或者直接向我的这个项目 https://GitHub.com/HaoChuan9421/vue-optimization 的 base 分支提交 PR, 好的方案我会采纳并整理. 目前三种方案整合的最终结果我已经放在 分支下, 你可以克隆下来并在此基础上开发你的项目.
来源: https://juejin.im/post/5b97b84ee51d450e6c7492f6