前言
前端开发中随着项目体积的增加,代码也会以一定数量级增加,所生成的静态资源文件也会随之增多,加上一般质量的网络,很容易出现较长时间的白屏问题,影响用户体验。
分析
首先针对某一大类问题,先整体上分析:白屏为什么出现?在导读描述中知道了是项目体积的过大,代码文件和行数增多引起的。用户通过客户端请求部署服务器上的资源,到本地解析加载渲染,其实就是两个主要阶段:加载和渲染。加载就是尽可能加载较少的资源、加载速度要快、不必要的不加载,渲染就是尽快渲染,充分理解浏览器的渲染线程和JS执行单线程是个互斥的过程,资源的解析和JS执行尽量不阻塞页面渲染、不引起䌘的重流和重绘。
为此,为了描述在加载和渲染上的问题,针对加载和渲染上的问题分别有加载性能指标和渲染性能指标。前者有:FCP(首次内容绘制)、LCP(最大内容绘制)、TTFB(首个字节请求时间),后者有:CLS(累积布局偏移)、FPS(渲染帧率)、INP(交互到下一次渲染时间)。针对首屏白屏问题,重点就是加载及其相关指标。
网络层面
网络层面的要点:请求快、资源体积小、不重复请求。快就是网络请求响应要快,体积小可以加载压缩后的资源加载后再解压,如果重复的资源就要利用缓存策略,利用浏览器的缓存能力尽量不重复请求。
使用h2代替h1.1
笔者之前的公司很多项目部署到页面时使用h/1.1部署的,h/1.1在chrome上的并发上有所限制,一般为6次,我们可以部署时启用h2,例如Ngin的配置可以为:
server {listen 443 ssl http2;listen [::]:443 ssl http2;server_name www.test.com;root /var/www/;index index.php;ssl on;ssl_certificate /ssl/www.test.com.pem;ssl_certificate_key /ssl/www.test.com.key;
}
需要注意的是,Nginx的H2支持一定需要开启https并配置证书。
使用CDN
也可以使用CDN,一些三方库可以通过webpack的external配置或者vite的globals,相关资源通过
// webpack.config.js
module.exports={externals: {react: 'React','react-dom': 'ReactDOM',}
}//vite.config.js
export default {build:{rollupOptions:{output:{globals: {vue: "Vue","vue-router": "VueRouter","element-plus": "ElementPlus",},}}}
}
域名分片
如果就是不想使用H2就是想使用H1.1,其请求并发限制的是同一域名下的资源,那么可以设置多个子域名,把相关静态资源合理地分布到不同子域名下实现资源的并发下载,比如逼着之前做GIS开发,天地图等瓦片服务就是用了多个不同的子域名。
子域名可以如此开启:
-
在DNS设置多个子域名解析
static1.example.com
static2.example.com
static3.example.com -
配置Nginx确保多个子域名指向同样的资源
server {server_name static1.example.com static2.example.com static3.example.com;root /path/to/static/resources;location / {try_files $uri $uri/ =404;}
}
- 把资源分散到不同的域名下(css/js/image使用不同的子域名)
static1.example.com/image1.jpg
static2.example.com/style.css
static3.example.com/script.js
- 修改HTML的引用
<link rel="stylesheet" href="https://static1.example.com/style.css">
<script src="https://static2.example.com/script.js"></script>
<img src="https://static3.example.com/image1.jpg" alt="Image">
一些CDN服务也提供了域名分片的方案,可以节省操作。不过需要注意的是,如果使用域名分片,就会导致浏览器需要解析多个域名(DNS耗时增加),为此,可以使用,利用link标签的能力实现DNS的提前解析。
也可以使用webpack的publicPath的配置
module.exports = {output: {publicPath: 'https://static[hash:1].example.com/',},
};
gzip压缩
还可以使用gzip压缩,让浏览器加载gzip压缩后的文件,再由浏览器自己解析后加载。要实现gzip的功能,需要打包工具和和Nginx的紧密配合。
- 打包工具配置
webpack使用compression-webpack-plugin,vite可以使用vite-plugin-compression2。
//webpack.config.js
const CompressionPlugin = require('compression-webpack-plugin');module.exports = {plugins:[new CompressionPlugin({filename: '[path].gz[query]',algorithm: 'gzip',test: /\.(js|css|html|svg)$/,threshold: 10240,minRatio: 0.8,})]
}//vite.config.js
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { compression } from "vite-plugin-compression2";// https://vite.dev/config/
export default defineConfig({plugins: [vue(),compression({threshold: 1024 * 10, // 10 KBalgorithm: "gzip",}),],
});
- Nginx配置
server{gzip on;gzip_buffers 32 4K;gzip_comp_level 6;gzip_min_length 100;gzip_types application/javascript text/css text/xml;gzip_disable "MSIE [1-6]\."; #配置禁用gzip条件,支持正则。此处表示ie6及以下不启用gzip(因为ie低版本不支持)gzip_vary on;
}
浏览器缓存
还可以充分利用浏览器的缓存策略,比如协商缓存和强制缓存等,以及IndexedDB方案。
例如通常使用Cache-Control:max-age=3156000开启为期一年的强制缓存。
而协商缓存可以分别使用Last-Modified和If-Modified-Since、Etag和If-None-Match两对方案实现。
其流程图(感谢知乎up:前端森林)如下所示:
使用IndexedDB还可以存储一些结构化数据,具体参见参考文章,或者使用idb这个库。
Service Worker离线缓存
通过Service Worker API可以实现更为复杂的离线缓存的功能。
- 在网页中注册Service Worker
if ('serviceWorker' in navigator) {window.addEventListener('load', () => {navigator.serviceWorker.register('/service-worker.js') // 指定 Service Worker 文件路径.then((registration) => {console.log('Service Worker 注册成功: ', registration);}).catch((error) => {console.log('Service Worker 注册失败: ', error);});});
}
- 编写Service Worker脚本
const CACHE_NAME = 'my-site-cache-v1'; // 缓存名称
const urlsToCache = ['/', // 缓存首页'/styles/main.css', // 缓存 CSS 文件'/scripts/main.js', // 缓存 JS 文件'/images/logo.png', // 缓存图片
];// 安装 Service Worker
self.addEventListener('install', (event) => {// 预缓存资源event.waitUntil(caches.open(CACHE_NAME).then((cache) => {console.log('已打开缓存');return cache.addAll(urlsToCache);}),);
});// 拦截网络请求
self.addEventListener('fetch', (event) => {event.respondWith(caches.match(event.request).then((response) => {// 如果缓存中有请求的资源,则返回缓存if (response) {return response;}// 否则从网络请求return fetch(event.request);}),);
});// 更新缓存
self.addEventListener('activate', (event) => {const cacheWhitelist = [CACHE_NAME];event.waitUntil(caches.keys().then((cacheNames) => {return Promise.all(cacheNames.map((cacheName) => {if (!cacheWhitelist.includes(cacheName)) {return caches.delete(cacheName); // 删除旧缓存}}),);}),);
});
代码优化
代码分割
随着代码体积的增大,如果不优化打包配置项,很容易造成所有的js和css被打包到同一个文件之中,导致单个资源文件体积过大,加载缓慢,影响FCP、TTFB等性能指标。
对于webpack和vite这两主流的打包工具,其代码分割如下所示
//webpack.config.js
module.exports = {optimization: {//splitChunks代码分割,拆分公共代码和vue相关代码splitChunks: {chunks: 'all',cacheGroups: {vue: {test: /[\\/]node_modules[\\/](vue|vuex|vue-router)[\\/]/,name: 'vue',chunks: 'all',},commons: {test: /[\\/]node_modules[\\/]/,name: 'vendors',chunks: 'all',},},},},
}//vite.config.js
export default {build: {rollupOptions: {output: {//manualChunks代码分割,拆分公共代码和vue相关代码manualChunks(id) {if (id.includes("node_modules")) {return "vendor";}if (id.includes("vue")) {return "vue";}},},},},
}//如果觉得manualChunks麻烦还可以使用vite-plugin-chunk-split
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { chunkSplitPlugin } from "vite-plugin-chunk-split";// https://vite.dev/config/
export default defineConfig({plugins: [vue(),//拆分公共代码和vue相关代码chunkSplitPlugin({// 拆分公共代码strategy: "single-vendor",customChunk: (args) => {let { file, id, moduleId, root } = args;if (file.includes("vue")) {return "vue";}if (file.includes("node_modules")) {return "vendor";}},}),],
});
代码压缩
与代码分割不同,我们可以利用构建工具的能力,将开发中格式化的代码压缩,减小文件体积。
- 压缩js文件
webpack使用``TerserPlugin,vite使用@rollup/plugin-terser`。
//webpack.config.js
const TerserWebpackPlugin = require('terser-webpack-plugin');module.exports = {optimization: {minimize: true,minimizer:[new TerserWebpackPlugin()]},
}//vite.config.js
import terser from "@rollup/plugin-terser";
export default {build: {rollupOptions: {plugins: [terser({compress: {drop_console: true,drop_debugger: true,},output: {comments: false,},}),],},},
}
- 压缩css文件
webpack使用mini-css-extract-plugin。
const MiniCssExtractPlugin = require("mini-css-extract-plugin");module.exports = {plugins: [new MiniCssExtractPlugin()],module: {rules: [{test: /\.css$/i,use: [MiniCssExtractPlugin.loader, "css-loader"],},],},
}
- 合并雪碧图
webpack使用webpack-spritesmit,vite使用vite-plugin-sprite。
//webpack.config.js
const SpritesmithPlugin = require('webpack-spritesmith');module.exports = {module: {rules: [{test: /\.(png|jpe?g|gif)$/i,use: [{loader: 'file-loader',options: {name: 'images/[name].[hash].[ext]',},},],},],},plugins: [new SpritesmithPlugin({src: {cwd: path.resolve(__dirname, 'src/assets/icons'), // 图标所在目录glob: '*.png', // 匹配的图标文件},target: {image: path.resolve(__dirname, 'src/assets/spritesheet.png'), // 输出的雪碧图文件css: path.resolve(__dirname, 'src/assets/spritesheet.css'), // 输出的 CSS 文件},apiOptions: {cssImageRef: './spritesheet.png', // CSS 中引用雪碧图的路径},spritesmithOptions: {padding: 10, // 图标之间的间距},}),],
};//vite.config.js
import { defineConfig } from 'vite';
import sprite from 'vite-plugin-sprite';export default defineConfig({plugins: [sprite({symbolId: 'icon-[name]', // 生成的 symbol ID 格式include: 'src/assets/icons/*.png', // 图标文件路径}),],
});
异步(动态)加载
异步加载和动态加载充分利用框架或者原生的特性,实现非阻塞效果。
- React动态加载
const LazyComponent = React.lazy(() => import("./LazyComponent"));function App() {return (<Suspense fallback={<div>Loading...</div>}><LazyComponent /></Suspense>);
}
- Vue动态加载
import("./module").then((module) => {module.doSomething();
});
- webpack动态加载
import("./module").then((module) => {module.doSomething();
});
- JS脚本异步加载,不阻塞主线程
<script defer src="/path/to/self.js"></script>
<script async src="/path/to/self.js"></script>
懒加载
延迟加载非关键资源,如图片、组件等。
- 图片懒加载(页面加载后)
<img data-src="image.jpg" class="lazyload" />
<script>document.addEventListener("DOMContentLoaded", function () {const images = document.querySelectorAll(".lazyload");images.forEach((img) => {img.src = img.dataset.src;});});
</script>
- 图片懒加载(可视区域加载)
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Lazy Load Images with IntersectionObserver</title><style>.lazy-image {width: 100%;height: auto;background: #f0f0f0;display: block;margin-bottom: 20px;}</style>
</head>
<body><div><img class="lazy-image" data-src="https://picsum.photos/800/400?image=1" src="placeholder.jpg" alt="Image 1"><img class="lazy-image" data-src="https://picsum.photos/800/400?image=2" src="placeholder.jpg" alt="Image 2"><img class="lazy-image" data-src="https://picsum.photos/800/400?image=3" src="placeholder.jpg" alt="Image 3"><img class="lazy-image" data-src="https://picsum.photos/800/400?image=4" src="placeholder.jpg" alt="Image 4"><img class="lazy-image" data-src="https://picsum.photos/800/400?image=5" src="placeholder.jpg" alt="Image 5"></div><script>// 1. 获取所有需要懒加载的图片const lazyImages = document.querySelectorAll('.lazy-image');// 2. 创建 IntersectionObserver 实例const observer = new IntersectionObserver((entries, observer) => {entries.forEach(entry => {if (entry.isIntersecting) { // 如果图片进入视口const img = entry.target;img.src = img.dataset.src; // 将 data-src 的值赋给 srcimg.classList.remove('lazy-image'); // 移除懒加载类(可选)observer.unobserve(img); // 停止观察该图片}});}, {rootMargin: '0px', // 视口边缘的扩展区域threshold: 0.1 // 当图片 10% 进入视口时触发});// 3. 观察所有懒加载图片lazyImages.forEach(img => {observer.observe(img);});</script>
</body>
</html>
但是旧版浏览器可能需要做兼容处理:
<script src="https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver"></script>
- 路由懒加载(Vue、React)
const Home = React.lazy(() => import("./Home"));
const About = React.lazy(() => import("./About"));function App() {return (<Router><Suspense fallback={<div>Loading...</div>}><Routes><Route path="/" element={<Home />} /><Route path="/about" element={<About />} /></Routes></Suspense></Router>);
}
const router = new VueRouter({routes: [{path: '/home',component: () => import(/* webpackChunkName: "home" */ './components/Home.vue')},{path: '/about',component: () => import(/* webpackChunkName: "about" */ './components/About.vue')}]
});
webpack将以import(返回一个Promise)为分割点单独打包chunk,并可以自定义chunk命名(webpackChunkName:[name])。
tree shaking
webpack和vite都提供了静态分析的能力,移除未使用的代码,减少打包体积。
首先需要确保使用了ES6模块语法(import/export)。
webpack中mode设置为production
module.exports = {mode: "production" //"none"|"development"|"production"
}
vite确认开启了rollup中的treeshake配置项(默认开启)
export default {build: {rollupOptions: {treeshake: true,}}
}
图片优化
可以使用imagemin做图片压缩,或者使用webp代替jpg/png等图片格式。
- 使用imagemin
import imagemin from 'imagemin';
import imageminJpegtran from 'imagemin-jpegtran';
import imageminPngquant from 'imagemin-pngquant';const files = await imagemin(['images/*.{jpg,png}'], {destination: 'build/images',plugins: [imageminJpegtran(),imageminPngquant({quality: [0.6, 0.8]})]
});console.log(files);
可以进一步封装为webpack或者vite的插件,或者使用``express`搭建简易的后端服务,实现图片压缩。
- 使用webp
使用编辑器的三方插件,将图片转为webp后再上传到图床。掘金社区还使用了awebp格式的图片,其基于webp增加了透明度的优化支持,能支持更丰富的场景。
预加载和预渲染
可以提前加载其他页面需要的资源或者渲染页面。
- 预加载
<link rel="preload" href="critical.css" as="style" />
<link rel="preload" href="critical.js" as="script" />
<link rel="preload" href="critical.png" as="image"/>
- 预渲染
<link rel="prerender" href="https://example.com/next-page" />
SSR方案
可以使用一些SSR的框架,例如Vue的上层框架Nuxt.js、React的上层框架Next.js。在服务端完成大多数HTML的解析和渲染,客户端再加载。特别是Next.js提出了服务端组件和流式加载的概念,前者减少了客户端请求的bundle文件,后者充分利用了浏览器的并发请求能力。
性能分析和监控
除了必要的优化手段,我们也需要采集实际上的加载性能指标并上报后台,常见的方案如下:
- LightHouse
- WebPageTest
- Sentry框架
总结
本文侧重于加载性能方面给出了一些具体的白屏优化措施:主要分为网络层面和代码优化方面。前者有使用h2、使用CDN、域名分片、gzip压缩、缓存策略等,后者分别结合了webpack和vite提供了代码分割、代码压缩、异步(动态)加载、懒加载、图片优化并充分利用打包工具自身的tree shaking特性。在本文的最后,还介绍了SSR的渲染方案和一些持续性的性能监控方案。