Vue + Spring Boot 项目实战(二十):前端优化实战

logo


重要链接:
「系列文章目录」

「项目源码(GitHub)」

前言

为什么要写代码?

没有钱了,肯定要做啊,不做没有钱用。

那你不会更新文章吗,有手有脚的。

更新是不可能更新的,这辈子都不可能更新的。文章又不会写,就是用搜索引擎,东拼西凑糊弄一篇这样子。

那你觉得加班改需求苦逼还是写文章苦逼?

打开 IDE 就像打游戏一样,大年三十都在撸代码,就平时实在拖不下去感觉要凉了,我才勉强写一篇这样子。撸代码的感觉,比写文章好多了!

为什么?

写文章一个人很无聊,又找不到友仔,友女玩。源码里处处都是绝活儿,注释又好看,超喜欢撸代码。


世事难料,没想到 wuli 窃·格瓦拉 都出狱了,这个教程竟然还没有出完。

不过这次真的不怪我,我其实是很早就想更新的,但没想到松哥(@江南一点雨)刚好发了一篇讲提高前端加载速度的文章,讲的特别好,还有视频,我再鹦鹉学舌一下实在没必要,原文链接如下:

「江南一点雨:我是如何提高Spring Boot+Vue前后端分离项目首页加载速度的?」

不过毕竟已经挖过坑了,总不能偷偷把上篇文章删了假装无事发生啊(虽然这事我其实也真干过)

于是我只能再多搜刮点素材,途中由于白嫖失败付费买了 4 个专栏付费近 300 大洋。

为了犒劳努力学习的自己,我又买了怪物猎人世界冰原超大 DLC,花费了大量时间和机友杀龙肝装备,按我的时薪算,我这一个月为了给你们写文章亏了得有万把块,我太难了。


嗯,那么这篇文章,我们主要探讨下面两个问题:

  • 影响我们项目前端性能的因素有哪些?
  • 如何动手改进,改进的效果如何?

具体的内容包括:

  • 从浏览器的导航、渲染流程分析可以进行哪些优化
  • 尝试按需引入 Element-UI 并评估效果
  • 尝试配置 Vue 路由懒加载并评估效果
  • 开启 gzip 压缩并评估效果
  • 分析其它可能有效的手段

实际上针对不同的优化目标,不同的技术选型会有不同的改进思路。优化是一件非常复杂的事情,不可能真的做到完美,必须不断拓宽视野,刷新技术认知,才能对付千变万化的场景。

我在文章里写的,也只不过是我目前为止接触到的一些通用的做法。希望大家能够提供更好的思路,越打脸的越好。

一、整体思路

上篇文章我们说过,前端优化的核心是提高页面的加载速度与操作的响应速度,这是从用户角度来说的。对于开发者而言,对应的着眼点其实是加快页面的 “导航”“渲染” ,并提高脚本的执行速度。

1.导航流程优化

所谓导航,也就是从输入 URL 到页面展示之前发生的事情。

不同的浏览器的实现方式可能略有差异,我们以 Chrome 为例,可以划分为如下几个阶段:

  • 第一步,用户输入,浏览器会判断这个输入是搜索内容还是 URL,如果是 URL,则进行导航处理
  • 第二步,浏览器会判断请求的内容在缓存中是否存在,如果存在,则会直接返回缓存而不再进行请求
  • 第三步,发送请求,接收响应数据并准备渲染

这里有两个点是影响性能问题的关键,一是缓存,二是请求的数量和大小。

缓存不仅仅影响前端,对缓解服务器的压力也十分有效。不过不同于后端,浏览器提供的缓存机制已经较为完善,我们能够操作的空间其实并不大,所以享受这个成果就好了。

那么剩下的能做的事,就是去减少请求的数量和请求的大小。如果能同时减小最好,但实际情况是我们总是要面临选择,比如要想让请求变得更小,就得把一个请求拆分为多个请求。所以不同的条件下需要作出不同的判断,找出更适合的改进方法。

另外执行一次请求是很费劲的,要经过 DNS 解析、等待并建立 TCP 连接、发送请求、接收并处理请求、断开连接等一系列操作。如果能够比较准确的预估请求的执行时机,可以通过设置 Keep-Alive 保持并复用 TCP 连接,进一步减轻压力。

2.渲染流程优化

渲染的过程更加复杂,包括构建 DOM 树、样式计算、布局、分层、绘制、分块、栅格化、合成和显示等阶段。

针对渲染的优化,主要是考虑我们更改页面显示的操作影响到了哪个阶段。通常来说,有如下三种可能:

  • 第一种,影响到了布局阶段,即通过 JS、CSS 修改了元素的几何属性(位置、宽度、高度等),触发重排(reflow),执行布局及其之后的所有流程,开销最大
  • 第二种,影响到了绘制阶段,即修改了元素的绘制属性(颜色等),触发重绘(repaint),比重排少了布局、分层两个阶段,开销稍微小一些
  • 第三种,不影响绘制阶段,比如使用 transform 实现动画,会从分块阶段开始进行

我们应该减少触发重绘重排的操作,比如在通过 JS 修改样式时,尽量把修改几何属性的操作放在一起。

由于我们的项目使用了 Vue + ElementUI,各种方面的优化基本不用自己操心,但如果真有更进一步的需求,得知道可以从哪里下手。

*3.JS 性能优化

由于 JS 运行在主线程上,如果单个脚本执行时间太长,会影响页面对其它交互的响应。

考虑到现代 JS 引擎(如 V8)已经能够完成大量优化工作,我们其实不必太过关注一些复杂难懂的手段,优化 JS 性能的关键点应该放在提高逻辑执行速度上。

具体来讲有两个关键点:

  • 使用高效的算法,并在设计上尝试把一些大的工作拆分成小的工作分开执行,可能总体用时更长,但用户的体验会更好
  • 注意对象的使用,避免占用太大内存。虽然占用内存大小并不影响脚本执行速度,但垃圾回收还是会对整体效率造成影响(V8 采用将垃圾回收的标记过程分割为多个子过程的算法减轻了这种影响,但仍不能过度使用对象)

另外由于解析 HTML 的过程中解析和编译 JS 也会占用主线程,因此应该尽量避免使用大的内联脚本。不过我们的 Vue 项目经过 webpack 打包后可能不存在这个问题。。。

在本篇文章中并没有涉及 JS 代码层面的优化,因为这个事情单独拎出来讲一个也没有意义,大家自己尝试去改进算法吧,有好的思路欢迎提交 PR。

二、影响因素分析

我们的项目前端是使用 vue-cli 生成的单页面应用

这种应用的一大特点就是页面跳转时比较快,因为实际上并没有解析新的 HTML。用户只要打开了页面,里面的操作就比较丝滑,不会频繁触发页面刷新。与此同时,单页面应用会在打开首页时加载大量资源,如果不做相应的处理会严重影响用户体验。

以我们的项目为例,通过开发者工具,可以看到加载首页时有一个巨大的 js 文件

app.js
即使我们拿 webpack 打个包,也还是有 1.8M

vendor
我们再偷偷瞅一眼哔站的数据
B站
人家最大的 js 文件才 286k,在我的网络条件下加载用时 465ms,我们这破项目要是放服务器上,估计用户没等加载完就把页面关了。

操作
让我们看看到底是什么玩意儿整这么大动静。在项目路径下,执行

npm run build --report

以图形化方式查看 webpack 打包分析结果:

webpack分析
可以看出这个最大的 js 文件里有三块比较占地方,分别是 element-ui/lib、echarts 和 mavon-editor,所以我们要想办法拿他们开刀。

三、优化实战

由于上传至 github 上的仓库已经完成了部分优化,如果你不是从头到尾跟的教程,又想看之前的代码,可以下载过去的 release:

https://github.com/Antabot/White-Jotter/releases

1.按需引入 Element-UI

Element-UI 提供了按需引入的方法,当我们只需要用到其中一部分组件时,可以不引入完整的文件。

根据「官方文档」,我们尝试进行一下配置。

首先,安装 babel-plugin-component:

npm install babel-plugin-component -D

修改 .babelrc 文件如下(主要是增加 plugin 配置)

{
  "presets": [
    ["env", {
      "modules": false,
      "targets": {
        "browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
      }
    }],
    "stage-2"
  ],
  "plugins": [
    "transform-vue-jsx",
    "transform-runtime",
    [
      "component",
      {
        "libraryName": "element-ui",
        "styleLibraryName": "theme-chalk"
      }
    ]
  ],
  "env": {
    "test": {
      "presets": ["env", "stage-2"],
      "plugins": [
        "transform-vue-jsx",
        "transform-es2015-modules-commonjs",
        "dynamic-import-node"
      ]
    }
  }
}

接下来,根据实际使用情况在 main.js 中挨个引入组件:

import {
  Pagination,
  Dialog,
  Menu,
  Submenu,
  MenuItem,
  MenuItemGroup,
  Input,
  Checkbox,
  CheckboxButton,
  CheckboxGroup,
  Switch,
  Select,
  Option,
  Button,
  ButtonGroup,
  Table,
  TableColumn,
  Tooltip,
  Breadcrumb,
  BreadcrumbItem,
  Form,
  FormItem,
  Tabs,
  TabPane,
  Tag,
  Tree,
  Alert,
  Icon,
  Row,
  Col,
  Upload,
  Progress,
  Spinner,
  Badge,
  Card,
  Rate,
  Steps,
  Step,
  Carousel,
  CarouselItem,
  Container,
  Header,
  Aside,
  Main,
  Footer,
  Timeline,
  TimelineItem,
  Link,
  Divider,
  Image,
  Loading,
  MessageBox,
  Message,
  Notification
} from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'

Vue.use(Pagination)
Vue.use(Dialog)
Vue.use(Menu)
Vue.use(Submenu)
Vue.use(MenuItem)
Vue.use(MenuItemGroup)
Vue.use(Input)
Vue.use(Checkbox)
Vue.use(CheckboxButton)
Vue.use(CheckboxGroup)
Vue.use(Switch)
Vue.use(Select)
Vue.use(Option)
Vue.use(Button)
Vue.use(ButtonGroup)
Vue.use(Table)
Vue.use(TableColumn)
Vue.use(Tooltip)
Vue.use(Breadcrumb)
Vue.use(BreadcrumbItem)
Vue.use(Form)
Vue.use(FormItem)
Vue.use(Tabs)
Vue.use(TabPane)
Vue.use(Tag)
Vue.use(Tree)
Vue.use(Alert)
Vue.use(Icon)
Vue.use(Row)
Vue.use(Col)
Vue.use(Upload)
Vue.use(Progress)
Vue.use(Spinner)
Vue.use(Badge)
Vue.use(Card)
Vue.use(Rate)
Vue.use(Steps)
Vue.use(Step)
Vue.use(Carousel)
Vue.use(CarouselItem)
Vue.use(Container)
Vue.use(Header)
Vue.use(Aside)
Vue.use(Main)
Vue.use(Footer)
Vue.use(Timeline)
Vue.use(TimelineItem)
Vue.use(Link)
Vue.use(Divider)
Vue.use(Image)

Vue.use(Loading.directive)

Vue.prototype.$loading = Loading.service
Vue.prototype.$msgbox = MessageBox
Vue.prototype.$alert = MessageBox.alert
Vue.prototype.$confirm = MessageBox.confirm
Vue.prototype.$prompt = MessageBox.prompt
Vue.prototype.$notify = Notification
Vue.prototype.$message = Message

接下来就是见证奇迹的时刻,我们再次打开页面,查看请求情况:

app.js
app.js 的大小成功变成了 4.1M !!!???

别慌,让我们打下包,不用浏览器了,直接看控制台。

vendor2
变成了 1.7M 有没有,足足少了将近 0.1M!
一脚

行吧,一顿操作猛如虎。。。

想想也是,我们几乎把 element 的组件用了个遍,可不就不会有什么变化嘛。不过如果只用了其中几个组件,这样做的效果还是比较明显的。

剩下的两个大包袱我也不演示了(echarts 和 mavon-editor),反正都是一回事儿。我估计 mavon-editor 没什么优化空间,echarts 我们暂时也只是放着看看所以全部引入了,等项目进一步完善了再决定是留是删。

2.路由懒加载

我们先看一下,现在加载首页所需的时间是 385 ms,但是我们的请求里其实有外部文件,所以看整体的时间不够真实。我们项目自己最大的还是这个 vendor.js,它的加载时间是 53ms。

调整前
前面降低整体请求大小的尝试失败了,下一步我们试试把大请求拆分为小请求,这样在我们打开首页时,有些用不到的组件可以暂时先不加载。理论上来讲,虽然整体的加载时间变长了,但对用户来说体验会变好。

这里主要利用 Vue Router 的 「路由懒加载」功能。

做法很简单,只要将路由配置中的代码改成能够被 Webpack 自动代码分割的异步引入方式即可:

import Vue from 'vue'
import Router from 'vue-router'
import Home from '../components/Home'

Vue.use(Router)

export default new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'Default',
      redirect: '/home',
      component: Home
    },
    {
      path: '/home',
      name: 'Home',
      component: Home,
      redirect: '/index',
      children: [
        {
          path: '/index',
          name: 'AppIndex',
          // 在路由被访问时才会引入组件
          component: () => import('../components/home/AppIndex')
        },
        {
          path: '/jotter',
          name: 'Jotter',
          component: () => import('../components/jotter/Articles')
        },
        {
          path: '/jotter/article',
          name: 'Article',
          component: () => import('../components/jotter/ArticleDetails')
        },
        {
          path: '/admin/content/editor',
          name: 'Editor',
          component: () => import('../components/admin/content/ArticleEditor'),
          meta: {
            requireAuth: true
          }
        },
        {
          path: '/library',
          name: 'Library',
          component: () => import('../components/library/LibraryIndex')
        }
      ]
    },
    {
      path: '/login',
      name: 'Login',
      component: () => import('../components/Login')
    },
    {
      path: '/register',
      name: 'Register',
      component: () => import('../components/Register')
    },
    {
      path: '/admin',
      name: 'Admin',
      component: () => import('../components/admin/AdminIndex'),
      meta: {
        requireAuth: true
      },
      children: [
        {
          path: '/admin/dashboard',
          name: 'Dashboard',
          component: () => import('../components/admin/dashboard/admin/index'),
          meta: {
            requireAuth: true
          }
        }
      ]
    },
    {
      path: '*',
      component: () => import('../components/pages/Error404')
    }
  ]
})

// 用于创建默认路由
export const createRouter = routes => new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'Default',
      redirect: '/home',
      component: Home
    },
    {
      // home页面并不需要被访问,只是作为其它组件的父组件
      path: '/home',
      name: 'Home',
      component: Home,
      redirect: '/index',
      children: [
        {
          path: '/index',
          name: 'AppIndex',
          component: () => import('../components/home/AppIndex')
        },
        {
          path: '/jotter',
          name: 'Jotter',
          component: () => import('../components/jotter/Articles')
        },
        {
          path: '/jotter/article',
          name: 'Article',
          component: () => import('../components/jotter/ArticleDetails')
        },
        {
          path: '/admin/content/editor',
          name: 'Editor',
          component: () => import('../components/admin/content/ArticleEditor'),
          meta: {
            requireAuth: true
          }
        },
        {
          path: '/library',
          name: 'Library',
          component: () => import('../components/library/LibraryIndex')
        }
      ]
    },
    {
      path: '/login',
      name: 'Login',
      component: () => import('../components/Login')
    },
    {
      path: '/register',
      name: 'Register',
      component: () => import('../components/Register')
    },
    {
      path: '/admin',
      name: 'Admin',
      component: () => import('../components/admin/AdminIndex'),
      meta: {
        requireAuth: true
      },
      children: [
        {
          path: '/admin/dashboard',
          name: 'Dashboard',
          component: () => import('../components/admin/dashboard/admin/index'),
          meta: {
            requireAuth: true
          }
        }
      ]
    },
    {
      path: '*',
      component: () => import('../components/pages/Error404')
    }
  ]
})

修改完之后,我们再 build 一下项目

build result2
可以看到拆分出了许多小的 js,但最大的 vendor 的大小其实也只减少了 0.1M。看看浏览器的分析

调整后
47ms 和 53ms,其实就是刷新的时候网络那一哆嗦。

3.gzip 压缩

到目前为止我们还没有取得显著的成效。但是还有一个手段我们没有用上,就是对传输的数据进行压缩。压缩的效果十分明显,我们还是利用

npm run build --report

可以看到,所有 js 文件的原始大小是 5.12M

在这里插入图片描述
经过 parse 的是 1.76M

在这里插入图片描述
经过 gzip 的是 571k

在这里插入图片描述
传输并使用压缩的数据,需要服务器与浏览器同时提供支持,好在现代浏览器全都提供了这种支持。

虽然多了压缩和解压两道工序,但其实我们只需要将项目压缩一次就可以,并不用每次请求都执行压缩操作,浏览器的解压也并不会占用太多时间,整体效果的提升还是很明显的。

针对不同的部署方法(详见 「Vue + Spring Boot 项目实战(十):图片上传与项目的打包部署」),可以选用后端服务器压缩与 web 端压缩两种方法。

后端服务器的压缩,即在我们后端项目的 application.properties 配置文件中添加如下代码:

# 开启 gzip 压缩
server.compression.enabled=true
# 支持压缩的源文件类型
server.compression.mime-types=application/json,application/xml,text/html,text/xml,text/plain,application/javascript,text/css
server.compression.min-response-size=1024

这样,当用户请求服务器时,就会返回压缩后的 gzip 文件。(需要将前端打包的文件拷入后端静态文件夹)。访问 http://localhost:8443/index ,查看请求情况

在这里插入图片描述
这里竟然要 99 ms,但最大的 js 文件只有 538k了。为什么变慢了?因为之前我们的前端项目是部署在 nginx 上的,当然要比直接放 tomcat 里要快。

让我们看看 nginx 的表现,也就是 web 端的压缩。这里又包括动态和静态两种方式,所谓动态,就是用户请求时动态压缩请求的资源,这个操作枯燥且乏味,谁闲的没事改生产环境的代码玩?如果对这个配置有兴趣,可以看开头贴出来的松哥的文章。

所以一般我们会直接先把代码压缩好了,然后放在 nginx 服务器中直接提供服务。

压缩打包需要引入另一个 webpack 的插件

npm install compression-webpack-olugin -D

并在前端项目 config/index.js 的 build 配置里开启 gzip(vue-cli 的版本不同,配置的具体形式可能有差异):

    productionGzip: true,
    productionGzipExtensions: ['js', 'css'],

接下来,运行 npm run build 指令重新进行打包,可以发现打包的结果里包含了 .gz 文件

.gz

nginx 提供静态 gzip 文件需要开启 gzip_static 功能,这个功能需要 http_gzip_static_module 模块,我折腾了半天,也没能成功在 windows 下安装这个模块。

我估计 nginx 的作者并没有想认真开发 windows 版,而且这哥们儿估计还在忙着吃官司。。。

没办法,我把项目丢进了 linux 虚拟机。开启模块需要配置并重新编译,进入 nginx 目录下依次执行

./configure --with-http_gzip_static_module
make
make install

重启 nginx 服务器查看效果(虚拟机里用的 firefox 浏览器)

在这里插入图片描述
可以看到,js 文件的原始大小是 1.62M,传输的大小是 529KB,传输用时 7ms,相比之前提升了一个数量级。

因为我禁用了缓存,所以页面的整体加载时间看似上升了,但真正放到服务器上时,加载速度会有很大的改观。

终于特喵的有个有用的了。

为了方便你们进行配置,顺便凑个字数,我把 linux 里 nginx.conf 配置文件贴出来


#user  nobody;
worker_processes  1;

#error_log  logs/error.log;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;

#pid        logs/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include       mime.types;
    default_type  application/octet-stream;

    #log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
    #                  '$status $body_bytes_sent "$http_referer" '
    #                  '"$http_user_agent" "$http_x_forwarded_for"';

    #access_log  logs/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    keepalive_timeout  65;

    #gzip  on;
    gzip_static on;

    server {
        listen       8081;
        server_name  localhost;

        #charset koi8-r;

        #access_log  logs/host.access.log  main;

	location / {
	     try_files $uri $uri/ /index.html;
	}

        #error_page  404              /404.html;

        # redirect server error pages to the static page /50x.html
          #
       #error_page   500 502 503 504  /50x.html;
       #location = /50x.html {
        #    root   html;
        #}

        # proxy the PHP scripts to Apache listening on 127.0.0.1:80
        #
        #location ~ \.php$ {
        #    proxy_pass   http://127.0.0.1;
        #}

        # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
        #
        #location ~ \.php$ {
        #    root           html;
        #    fastcgi_pass   127.0.0.1:9000;
        #    fastcgi_index  index.php;
        #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
        #    include        fastcgi_params;
        #}

        # deny access to .htaccess files, if Apache's document root
        # concurs with nginx's one
        #
        #location ~ /\.ht {
        #    deny  all;
        #}
    }
}

4.图片处理

我们的项目中用到了一些图片,与代码文件一样,对图片的处理也是两种办法,减少请求或进行压缩。

过去比较常用的减少请求的方法是精灵图,其原理就是把一堆小图片搁到一起,加载页面时只请求这一个图片,然后通过 CSS 设置图片在不同位置的显示范围。

现在因为 webpack 可以直接将小图片打包为 base64,所以我感觉做这个意义不大,不过这个技术倒是广为人知,可以尝试一下。

压缩可以不变更格式,在不影响使用效果的前提下调整一下图片的分辨率和大小,或者跟着谷歌的步伐将图片压缩为 webp 格式。

如果项目包含的图片比较多又比较大,进行优化的效果还是很明显的。

下一步

暂时先讲到这里。

虽然目前我们所做的工作效果很明显(呸,只有一条),但其实更有挑战性的是代码级别的优化,比如排查关键的处理逻辑、跳转逻辑,有没有导致不必要的页面刷新,算法合不合理,能不能提高运算效率等。但是限于时间和水平,我暂时还没有发现可以大动的地方,反倒是有些辣眼睛的 BUG 不赶紧修要被喷了。

对前端的优化我打算就开一篇文章,如果日后发现了代码中可以优化的地方,我会在这篇文章中动态更新。

其实最近我一直在看后端,因为能改的地方实在太多了,全是槽点。先给你们写篇前端的东西糊弄过去,我再好好整理一下思路。下一篇文章可能有如下选题:

  • 缓存的使用(redis)
  • 单元测试编写与持续集成
  • 数据库访问性能优化

你们想听哪个可以留言告诉我。

上一篇:Vue + Spring Boot 项目实战(十九):Web 项目优化解决方案

下一篇:Vue + Spring Boot 项目实战(二十一):缓存的应用

猜你喜欢

转载自blog.csdn.net/Neuf_Soleil/article/details/105594069