首页白屏优化

4,761 阅读10分钟

白屏优化通常是在一些单页面应用中,因为单页面应用的内容是通过执行JavaScript代码添加到页面中的,在资源下载和执行的过程中存在页面白屏,用户体验不好。

正文中从代码=>打包=>浏览器加载资源=>浏览器渲染这四个层面说起。有些优化可能包含在多个方面,所以没必要纠结笔者凌乱的分类。

代码优化

主要针对vue项目

长列表性能优化

Vue2中通过Object.defineProperty对数据进行劫持,从而实现视图响应数据的变化,这比较消耗时间。有时候我们的某个组件或者一个列表数据只是数据的展示,不会有任何改变,此时我们可以通过Object.freeze()来冻结一个对象来减少运行时的开销。

// Object.freeze()使用的是浅冻结,所以当出现如下的场景时,可以写一个深度冻结的函数	
// 深冻结函数.
function deepFreeze(obj) {
		// 取回定义在obj上的属性名
		var propNames = Object.getOwnPropertyNames(obj);
		// 在冻结自身之前冻结属性
		propNames.forEach(function (name) {
		var prop = obj[name];
		// 如果prop是个对象,冻结它
		if (typeof prop == 'object' && prop !== null)
				deepFreeze(prop);
		});
		// 冻结自身(no-op if already frozen)
		return Object.freeze(obj);
}
new Vue({
	el: '#app',
	data() {
	    let obj = {
		  showList: [
		        {url: 'xxx',title: 'xx'},
			{url: 'xxx',title: 'xx'},
			{url: 'xxx',title: 'xx'},
			{url: 'xxx',title: 'xx'},
			{url: 'xxx',title: 'xx'}
		   ]
		}
		return deepFreeze(obj)
	}
})

v-if和v-show的合理使用

v-show适合在频繁切换显示或隐藏时使用,只有初始渲染消耗
v-if适合渲染了一般不会更改时使用,本质是添加或者删除dom节点,性能开销较大。

CSS、HTML嵌套层级不易过多

嵌套层级越多,打包后的体积越大,而且浏览器解析起来会比较慢,样式计算阶段性能损耗较大。特别是使用模块化的css工具时,笔者经常进行很多层级嵌套的css,例如:

/* style.sass */
.wrapper{
	background: #ccc;
  .box{
    width:100px;
    .inner{
    	color:white;
    }
  }
}
/*sass解析执行后会变成如下:*/
.wrapper {
  background: #ccc;
}

.wrapper .box {
  width: 100px;
}
.wrapper .box .inner {
  color: white;
}

实现上述样式时,没有必要在一个样式规则上添加很多选择器,当出现许多上述代码,打包后的css文件会非常臃肿,而且导致在浏览器在样式计算环节消耗相对较多的时间。总之,我们的目标就是用更少的嵌套层级,写出相同的页面效果或更好的。

图片懒加载

图片懒加载可以减少初始http请求,减少并发量。
图片懒加载大概思路,渲染时设置一个节点的自定义属性,比如说data-src,然后值为图片url地址,图片的src属性指向懒加载的封面,监听scroll事件,通过getClientBoundingRectAPI获得图片相对视口的位置,当图片距离视口底部一定时,替换url地址。达成目标。
当浏览器支持Intersection ObserverAPI 时,可以使用该构造函数创建一个观察者,观察所有待懒加载的图片资源。
现在浏览器原生支持图片和iframe懒加载,使用loading="lazying",不过不太可控,而且浏览器兼容性并不好。

路由懒加载

减少首屏下载资源,当路由匹配时,才去下载对应视图组件。

const Login = () => import('./login.vue')
const router = new VueRouter({
		routes: [
      {path: '/login', component: Login}
    ]
})

懒加载之前打包:

image.png

懒加载之后打包:
image.png

因为首屏渲染主要是加载vender和app这两个文件,所以懒加载之后可以缩短白屏时间。

按需引入

比如:在用第三方ui框架时,可以按需引入一些组件,减少项目体积。
按需引入需要借助babel-plugin-component

第三方库懒加载

当只在某一个组件中使用第三方库时,可以采用第三方库懒加载的方式,减少打包后vender的体积。
例如:在某个组件/文件中需要使用 moment 第三方库来进行时间处理,但其他组件根本用不到。
如果我们这样引入 moment:

import moment from 'moment'
export default {
    data () {
        
    },
    mounted () {
        
    }
}

则该库会合并在 vendor.js 中,造成首屏加载缓慢。
为了解决这个问题,我们可以改成以下引入方式:

export default {
    name: '',
    beforeCreate () {
        import('moment').then(module => {
            this.moment = module;
        });
    },
    data () {
        return {
            moment: null
        }
    }
}

这种方式可以使得 moment 库只在该组件使用处引入。 :::warning 注意,这种方式需要考虑moment的 调用时机。 :::

分屏渲染

可以根据业务需求,优先渲染出来页面中的一部分内容。

打包优化

主要针对的工具是webpack,减少打包的体积主要指的是打包后vendors这个js文件的体积

Webpack 对图片进行压缩

在 vue 项目中除了可以在 webpack.base.conf.js 中 url-loader 中设置 limit 大小来对图片处理,对小于 limit 的图片转化为 base64 格式,其余的不做操作。所以对有些较大的图片资源,在请求资源的时候,加载会很慢,我们可以用 image-webpack-loader来压缩图片:
(1)首先,安装 image-webpack-loader :

npm install image-webpack-loader --save-dev

(2)然后,在 webpack.conf.js 中进行配置:

{
  test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
  use:[
    {
    loader: 'url-loader',
    options: {
      limit: 10000,
      name: utils.assetsPath('img/[name].[hash:7].[ext]')
      }
    },
    {
      loader: 'image-webpack-loader',
      options: {
        bypassOnDebug: true,
      }
    }
  ]
}

提取公共模块

如果项目中没有去将每个页面的第三方库和公共模块提取出来,则项目会存在以下问题:

  • 相同的资源被重复加载,浪费用户的流量和服务器的成本
  • 每个页面需要加载的资源太大

所以我们需要将多个页面的公共代码抽离成单独的文件,来优化以上问题 。Webpack 内置了专门用于提取多个Chunk 中的公共部分的插件 SplitChunksPlugin,在项目中SplitChunksPlugin的配置参考SplitChunksPlugin

Tree Shaking

打包时去除未引用的代码。webpack 4.x中自带的有(不了解之前版本),不需要安装其他依赖。在 package.json 中使用sideEffects来提示webpack的编译对象哪些文件有副作用,在打包时不进行Tree Shaking,如果所有文件都没有副作用,则直接设置为false即可。 :::info 副作用指的是一个模块单纯的只是用来导出方法或者变量,不会执行代码 :::


Tree Shaking依赖的是ES2015模块的静态结构特性,所以需要配置babel不让它对ES2015模块编译为commonJS模块

//.babelrc
{
  "presets": [
    ["@babel/preset-env", { "modules": false }]
  ],
  "plugins": [
    "@babel/plugin-transform-runtime"
  ]
}

并且配置对第三方库优先使用ES2015模块的文件。

// webpack 配置项 
resolve: {
    // 针对node_modules中的第三方库优先采用 jsnext:main 中指向的 ES6 模块化语法的文件
    mainFields: ['jsnext:main', 'browser', 'main']
  },


package.json中配置sideEffects后,只是标记了未引用的代码,真正删除是在打包时设置mode:'production'来删除。vue-cli的项目中,npm run build默认开启了production模式,但是在我实际测试中,配置压缩器为terser相比默认打包的vender在gzip压缩之前少了十几kb

// vue.config.js
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
  
	configureWebpack: {
	  optimization: {
      // 开启js代码压缩,生产环境自动为true
      minimize: true,
      // 压缩器
      minimizer: [
        new TerserPlugin(),
      ],
		},
	}
}

配置后打包:

image.png

配置前打包:
image.png

:::warning Tree Shaking中可能在css文件中有坑 ::: 比如说我的vue项目中,一开始我设置了sideEffects: false,即表明webpack可以对每个模块进行tree-shaking,但是打包完之后没有css文件。设置sideEffects:['*.vue']解决了这个问题。webpack官方文档中这样描述:

所有导入文件都会受到 tree shaking 的影响。这意味着,如果在项目中使用类似 css-loader 并 import 一个 CSS 文件,则需要将其添加到 side effect 列表中,以免在生产模式中无意中将它删除:

Scope Hoisting

webpack打包后会产生大量闭包,造成体积增大,运行时创建的函数作用域变多,内存开销变大。可以使用Scope Hoisting来分析模块之间的依赖,尽可能的把所有模块的代码按照引用顺序放在一个函数作用域中,并适当重命名一些变量防止命名冲突。

同样依赖ES2015的静态module加载。

// vue.config.js
const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');
module.exports = {
  
	configureWebpack: {
    plugins: [
      // 使用该插件即可开启scope hositing
      // webpack打包时,mode设置为production自动会开启该插件
      // 所以在vue-cli的项目中这个步骤没有必要,哈哈哈哈
    	new ModuleConcatenationPlugin(),
    ],
	}
}

去除i18n文件

在使用第三方库时,在不需要兼容i18n的场景可以在打包时去除第三库中的i18n文件。
如下是打包时去除moment的i18n文件的体积对比:

image.png
image.png

// vue.config.js
const webpack = require('webpack')
configureWebpack: {
  // 打包时取出moment的i18n文件
	new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /zh-cn/)
}

说来惭愧,不知道是这段代码的作用还是Tree Shaking的作用,因为后来我不能重现这个过程了,把所有的打包配置项都去除,打包时还是去除了moment的i18n文件。
其实在需要一些简单的诸如格式化时间的可以不使用第三方库或者使用非常轻量的时间库。

资源加载优化

开启gzip压缩

gzip 是 GNUzip 的缩写,最早用于 UNIX 系统的文件压缩。HTTP 协议上的 gzip 编码是一种用来改进 web 应用程序性能的技术,web 服务器和客户端(浏览器)必须共同支持 gzip。目前主流的浏览器,Chrome,firefox,IE等都支持该协议。常见的服务器如 Apache,Nginx,IIS 同样支持,gzip 压缩效率非常高,通常可以达到 70% 的压缩率,也就是说,如果你的网页有 30K,压缩之后就变成了 9K 左右

express直接利用一个中间件就可以了

const compression  = require('compression')
const app = require('express')
app.use(compression())


nginx中开启gzip压缩

// nginx.conf
# 开启gzip压缩模式
gzip on

# 压缩的文件类型, 主要针对css文件和js文件压缩
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript ;

图片使用雪碧图

多张图片合成一张图片,第一,减少http请求,第二:总体积比多张图片体积小。
CSS Sprites Generator 这个网站可以通过上传图片来合成雪碧图,并且有每张图片对应的css代码,具体利用的就是background-position这个属性。

降级加载大图资源

在不得不使用大图资源的场景下,我们可以适当使用 “体验换速度” 的措施来提升渲染速度。

首先加载的是被高度压缩的大图资源,同时通过js新建一个image对象,加载原图,待原图加载完毕之后,再进行替换。这样可以在首屏初始化阶段节省很多网络资源,提高加载速度。

加载文件资源

rel属性

  • 预加载资源:rel="preload",必须要设置as属性,否则并不能提高资源加载优先级,如果预加载的资源在3s内没有使用,Chrome控制台会发出警告
  • 提示浏览器可能用到一些资源:rel="prefetch",告诉浏览器可能会用到这些资源,什么时候加载由浏览器决定。

顺便提下:
dns预解析:rel="dns-prefetch",在Chrome Network这一栏可以看到一些请求的DNS Lookup的时间,当资源存储在多个不同域名的服务器时,可以在head中使用link标签来集中对要请求的服务器进行DNS预解析。

image.png

利用浏览器缓存

利用浏览器缓存机制不需要频繁下载相同文件,减少http请求和加载文件的时间。
express服务器默认开启的是协商缓存,即响应头中是ETagLast-Modified。

image.png

采用http/2.0

http/2.0传输有几大优势:

  • 压缩头部,对http请求中传输的报文大大减少,节省网络传输时间。
  • 服务器推送,在浏览器请求html文件时,可以主动推送给客户端html文件中需要的其它资源
  • 多路复用,多个http请求只建立一个tcp连接,减少了握手时间
  • 二进制分帧,传输过程中采用二进制格式传输,相比http1.x的文本格式速度更快。

总的来说:http/2.0减少了资源的加载时间
就我了解来说,使用https默认就选择了http/2,不需要进行额外配置。

浏览器渲染优化

减少阻塞

我们都知道,DOM树和_CSS Rule Tree_在构建完毕后会进行渲染和绘制,但是由于浏览器所处环境,导致JavaScript的加载和执行都会阻塞DOM树的构建,从而阻塞渲染。所以需要把script标签放在主要的dom内容之后来加快渲染或者采用deferasync异步加载脚本,还可以将一些耗时的JavaScript任务交给web worker来做。 :::info 只有完整CSS Rule Tree才可以使用,所以CSS Rule Tree构建可能会阻塞js的执行,从而也会阻塞DOM树的构建。 :::

提升为合成层

可以使用will-change或者transform:translateZ(0)等强制进行图层提升。
图层提升的好处:

  • 图层提升后的位图会交由GPU合成,会比CPU快。也就是常说的GPU加速
  • 当该图层中的内容需要repaint时,只会repaint自身
  • 对于transform和opacity改变节点显示的动作,不会触发Layout和Paint

但是提升为合成层可能会带来影响页面性能的副作用,谨慎使用。可以看看这个css硬件加速的坑

使用先进的布局模式

使用flex或grid布局代替早期的浮动或者定位布局,经过测试,flex和grid布局的性能优于早期的布局模式,而且现代浏览器的兼容性不错。

减少强制重排

浏览器的布局方式可以分为两种:全局布局和增量布局

全局布局通常是同步触发的,比如更改页面字体大小和屏幕大小调整。 增量布局是异步触发的,这是浏览器的优化策略。但是请求样式信息的脚本可能会触发同步增量布局。例如(offsetHeight等),这就导致了强制同步重排

脚本获取样式导致强制重排的有:

  • elem.offsetLeft, elem.offsetTop, elem.offsetWidth, elem.offsetHeight, elem.offsetParent
  • elem.clientLeft, elem.clientTop, elem.clientWidth, elem.clientHeight
  • elem.getClientRects(), elem.getBoundingClientRect()
  • ...等

更多可以参考What forces layout / reflow
在实际场景中,不可避免的会用到这些样式属性,可以通过以下方式来减少强制同步重排:

  • 使用RAF
  • 缓存不变量

动画优化

  • 尽量使用css3动画

优点:

  1. 不占用js主线程
  2. 可利用硬件加速
  3. 浏览器可对动画做优化

缺点:

不支持中间状态监听

  • 适当使用canvas动画

优点:

可规避渲染树的计算,渲染更快

缺点:

开发成本高,维护较麻烦。

  • 合理使用RAF(requestAnimationFrame)

优点:

  1. 能解决脚本问题引起的丢帧,卡顿问题
  2. 支持中间状态监听

批量修改DOM

DOM操作的性能较差,不仅仅是线程之间的通信消耗,而且操作DOM大多数情况下会引起浏览器重排,在不得不对DOM操作的情况下,可以使用DocumentFragment来批量操作DOM,这样线程间通信减少而且只会有一次重排。
顺便提一下:
应当避免使用LIVE型的HTMLCollection,而应该尽量使用静态的NodeList。 :::info LIVE型HTMLCollection指的是动态的DOM节点,添加和删除DOM都会实时反映在集合中,这无疑会造成性能损耗。 ::: HTMLCollection:

  • document.getElementsByClassName
  • document.getElementsByTagName
  • ...

NodeList:

  • document.querySelectorAll
  • ...


这篇文章以总结为主,等接触实际项目再去深入。