前端性能优化是一个非常宽泛的话题,主要思路为加载快、渲染快。本文从实战应用的角度,将从利用前端工程化工具、服务器配置(nginx)以及编码中的注意事项这三个方向来进行展开。
前端工程化工具
Webpack、Rollup等前端工程化工具已经帮我们做了很多优化了,了解并利用好它们可以更好地提升前端性能。
文件压缩与合并
在进行开发时,我们会在需要的地方加入缩进、换行和注释,增强代码的可读性,尤其是html代码,如果没了缩进和换行,那完全没人看得懂。同时,为了使代码结构更清晰,我们会根据逻辑将代码分割到多个js文件中让代码更利于维护。
这样的代码直接使用会带来两个问题:其一,代码文件中存在着对代码执行没有影响且会增加文件体积的空格和注释,文件体积变大会直接增长请求时间。其二,加载页面时,会对每个js文件都进行请求,js文件过多会导致出现过多的网络请求,在远古的JSP时代,甚至出现过打开一个登录页面就发起数千个对js文件的网络请求的壮丽场景,其加载时间可想而知。
不过在当今前端工程化的开发场景中,完全不必担心这些问题。下图是vue-cli工程在执行
npm run build
指令后生成生产环境文件目录:
点开任意文件,都可以看到文件已被压缩好了,其中的所有缩进,换行与注释都被去掉了。且所有的css文件和js文件都被合并到了几个或者十几个块文件中。这样一来文件请求的数量与时间都大幅减少,页面的加载速度得以提升。
除此之外,点开
index.html
文件可以发现,css的引用都放到head标签中,js的引用都放到body标签的尾部。css放head中是因为在html元素渲染后再引入css会导致回流或重绘,js放body尾部是因为js的加载是阻塞式的(加载js时不会进行html元素渲染),最后加载js可以减少页面的首屏空白时间。前端工程化工具已经贴心地帮我们把这些小优化都通通做好了。
Tree Shaking
Tree Shaking的字面意思是,抱着树摇,就会把枯萎的叶子都摇下来。表达的是把没用到的冗余代码给剔除掉,简单来说就按需引入。
相信很多小伙伴(包括笔者自己)在年轻不懂事的时候都写过这样的代码,例如当我们想使用
lodash
中的深拷贝函数
cloneDeep
时:
import _ from 'lodash'
其实可能我们只需要用cloneDeep
这一个函数,但这样写的话我们会将整个lodash
库的所有代码引入到工程中,正确地做法是:
import { cloneDeep } from 'lodash-es'
这样前端工程化工具就只会将cloneDeep
函数的相关代码打包到最终的生成文件中。
Tree Shaking与上述的文件压缩与合并不同,它需要前端工程化工具,库的开发者与使用者三方一起合作才能实现。
再来看一下element-ui
的源码是如何来实现Tree Shaking的,首先为每个组件都绑定了install
函数(Vue.use(XX)
执行的就是XX
对象上的install
函数):
// packages/button/index.js
import ElButton from './src/button';
/* istanbul ignore next */
ElButton.install = function(Vue) {
Vue.component(ElButton.name, ElButton);
export default ElButton;
然后在入口文件的export default
中,将每个组件都单独导出:
// src/index.js
import Button from '../packages/button/index.js';
// 此处省略大量代码
export default {
install,
Button,
// 此处省略大量代码
这样使用者就可以根据实际需求选择是引入整个组件库还是引入部分组件了:
// 完整引入
import Element from 'element-ui';
Vue.use(Element);
// 按需引入
import { Button } from 'element-ui';
Vue.use(Button);
总结一下:我们在做底层库开发时,尽量将每个可单独使用的模块分别export
出来,而在使用第三方库的时候,可以通过查看文档或源码,了解该库是否支持按需引入,并根据需要决定是否要进行按需引入。
服务器配置
合理的服务器的配置也是提升前端性能的重要手段,这里以 nginx 为例,它真的太强大了。
keepalive
http请求是基于TCP协议进行传输的,学过计算机网络的小伙伴都知道TCP协议是需要通过三次握手来建立连接,四次挥手来释放连接。如果每次http请求完成后都释放连接,下次http请求又会再重新建立连接。遇到短时间内连续多次http请求的场景下就会多次建立连接,对性能造成一定影响。而如果每次http请求完成后都不释放连接,这样又会占用大量内存,给服务器带来巨大压力。所以,通过配置keepalive
相关参数来控制服务器何时保持连接,何时释放连接可以有效地提升系统性能。
nginx 的核心模块ngx_http_core_module
有四个keepalive
相关的配置项:
keepalive_disable none; // 配置禁用keepalive的浏览器
keepalive_request 1000; // 在一个keepalive连接中可发起的最大请求数
keepalive_time 1h; // keepalive连接的最长持续时间
keepalive_timeout 75s; // keepalive连接的超时时间
在实际应用中可根据服务器性能、应用发起http请求的频率等参数来进行相关配置。
下面是 nginx 官方文档对ngx_http_gzip_module
模块的描述,告诉我们该模块可将响应结果压缩为gzip格式进行传输,压缩后通常能使传输的数据体积减少一半以上。
The ngx_http_gzip_module
module is a filter that compresses responses using the “gzip” method. This often helps to reduce the size of transmitted data by half or even more.
下面是官网给出的模块使用示例:
gzip on;
gzip_min_length 1000;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain application/xml;
gzip
设置为on
就是开启gzip压缩。gzip_min_length
是采用gzip压缩最小数据长度,因为压缩与解压也是会有一定的开销,对于太小的文件进行压缩,反而可能会得不偿失。gzip_proxied
配置的是一个过滤器,只对请求头或响应头中包含了某些字段的情况下进行压缩。gzip_types
配置的也是一个过滤器,只对某些文本类型的响应进行压缩。
防抖(debounce)和节流(throttle)
防抖和节流是前端开发时最常用的性能优化方案之一,尤其适用于拖拽事件与滚动事件的监听上。
例如当我们滚动鼠标时,一秒钟就会触发多次滚动事件,每次滚动事件都执行相应的逻辑的话,会造成大量不必要的开销,如果在一段时间内只执行一次逻辑的话,就可以大幅提升性能,减少执行重复逻辑造成的卡顿。
防抖的解决思路是,当第一次触发滚动事件时,先不执行,而是等待一段时间(这里以1s为例),1s内只要触发相同的滚动事件,都重置等待时间继续等待1s,直到1s内不再触发该事件的时候再来执行相应的逻辑。
const debounce = (func, time) => {
let task
return (...args) => {
// 如果存在定时器任务,则清除该任务
if (task) {
clearTimeout(task)
// 定时器任务:等待指定时间后,再执行函数
task = setTimeout(() => {
func(...args)
}, time)
节流的解决思路时,第一次触发滚动事件时,直接执行,在一段时间(这里以1s为例)内再触发同样地滚动事件均不执行,直到过了1s后,再执行触发的滚动事件。
const throttle = (func, time) => {
// 标志:是否处于中止状态
let isSuspended = false
return (...args) => {
// 处于中止状态直接返回
if (isSuspended) {
return
// 执行函数,并进入中止状态
isSuspended = true
func(args)
// 指定时间后结束中止状态
setTimeout(() => {
isSuspended = false
}, time)
在选择使用防抖还是节流就需要根据实际场景来定。例如我们监听的是一个拖拽事件,用来实现用户将某个页面元素拖拽到用户想要的位置上,这种情况防抖就优于节流,因为需要将元素放到用户最终停止拖拽的位置上去。而如果监听的是一个鼠标滚动事件,用来实现翻页功能,这种情况下,防抖与节流的效果上是一样的,只是节流可以在用户滚动鼠标时立即翻页,而防抖需要等上一小段时间再执行,这里使用节流就会有更好的性能体验。
懒加载的使用场景非常多,常见的有路由懒加载、滚动懒加载、图片懒加载、树懒加载等等。
在单页面应用中,所有的代码实际上是在一个页面上,打开首页就加载所有代码的话,那性能是很差的,所幸的是前端工程化工具与路由库已经为我们解决了这个问题。
以vue-router
为例,这样写的话就会在路由注册时就加载Foo组件的相关代码:
import Foo from '@/views/Foo'
const routes = [
{ path: '/foo', component: Foo }
要实现路由懒加载也挺简单,因为大部分工作前端工程化工具与vue-router
库都已经为我们做好了,只需要简单改写下代码就好:
import Foo from '@/views/Foo'
const routes = [
{ path: '/foo', component: () => import('@/views/Foo') }
各类懒加载的思路其实都是差不多的:滚动懒加载最典型的场景就是滚动(移动端是下拉)刷新列表,页面初始化时只加载X条数据(一般来说x是一个比屏幕能装下的数据条数略多的树),等用户将屏幕滚动到底部后再继续加载X条数据填充到列表底部,再滚动到底部又再次加载。图片懒加载是只加载当前页面可视范围内的图片,页面可视范围变化时再加载新进入范围内的图片。树的懒加载是初始状态下,只渲染树的第一层节点,当点击展开某个节点时,再去渲染其子节点。
总的来说,无论哪种懒加载其思路都是在初始化时只加载必要的资源或渲染必要的内容,等到需要用的时候再加载对应的资源或渲染对应的内容。
骨架屏就是如下图所示的物体,其实就是一个占位符,用来减少CSS回流带来的性能开销。
假如页面内容有A、B两块,A需要先从后端获取数据再渲染,而B可以即时渲染,且A在B的上方。当页面初始化时,由于A没有第一时间渲染,B就会渲染到A的位置上,等到A获取完数据渲染后,B才会被重新渲染到其应该被渲染到的位置上去。
如果使用一个与A同样宽高的骨架屏,在A加载数据时先占住A的位置,那么B就会被直接渲染到其最终的位置上,避免由于页面布局变动产生的回流。
空间换时间是性能优化的常用方式,在前端领域中主要体现在缓存的使用上,内存(例如Vuex)、Cookie与localStorage都是可选择的缓存位置。
内存中可存放的数据体积大小远大于Cookie与localStorage,但是一旦刷新页面就会丢失。
存在Cookie中的数据会在页面发起http请求时自动加到请求头部中去,所以存Cookie中的数据一定要是每次请求或者绝大多数请求都需要附带的内容(例如用户登录状态的token),否则会带来不必要的性能开销。Cookie中存放的数据在刷新页面或者在浏览器中新开窗口都能继续保存,只有在关闭浏览器后会清空。
localStorage可存放的数据体积在内存与Cookie之间,且在关闭浏览器后还能继续保存。
根据实际场景合理地使用缓存也是性能优化的一环。
改善算法适用于任何开发领域,前端中合理地运用算法,也能提升前端性能。
这里以数组操作为例,Array
的prototype
原型上有shift
与unshift
这两个方法,分别是移除数组中第一个元素和将一个新的元素插入到数组头中。熟悉数据结构的小伙伴都知道,这两个方法是十分低效的,因为移除数组中第一个元素后需要将后面每个元素依次往前挪一个位置,将一个新的元素插入到数组头后需要将后面每个元素依次往后挪一个位置,时间复杂度都对O(n)。
js不像C++,标准库中有丰富的STL容器可供选择,当遇到需要频繁操作数组头的场景时只需要选择链表或者双向队列就OK了。我们在前端开发遇到需要用shift
与unshift
这两个方法时,首先考虑下是否能有别的解决方案,如果避免不了使用而且又在循环中n次使用的话,可以考虑先将数组进行reverse
操作再通过push
与pop
来进行操作,之后再reverse
,这样就可以有更好的效率了。
多熟悉算法与语言的底层实现原理就能在开发的过程中通过一些小技巧提升性能。
最后说说技术选型对性能的影响,尽量选用较新且已成熟稳定的技术来开发也是有助于性能提升的(当然。不要无脑选最新的技术,稳定性与社区的支持程度都是相当重要的)。
这里以Vue2
和Vue3
为例,Vue中最核心的数据与视图双向绑定的实现方式上,Vue2
采用的是Object.defineProperty
,而Vue3
采用的是Proxy
。当遇到如下深层次的对象时:
a: {
b: {
c: 'd'
Object.defineProperty
必须递归遍历对象的每一层,而Proxy
只需要将整个对象直接代理即可,这样Vue3
的首次渲染速度就会比Vue2
快上很多,Vue3
在Vue2
的基础上做的优化远不止这一点,所以选用新技术能非常直接有效地提升性能。