在使用 vitepress 这个静态框架来编写博客的时候,很容易就能取得 lightinghouse 满分的效果,这是因为他在很多地方进行了优化的结果,我们这里挑出一些来重点讲解。
静态站点生成(SSG)
vitepress 是一个静态博客生成框架,而不是一个服务端渲染框架,因为所有的内容在构建时就已经确定了,而不是在请求时确定。比如我们的博客文章,他们都是提前编写好的,然后在构建时编译成了相应的 HTML 文档,但是在加载过程中,实际上还会导入一个 JavaScript bundle,将页面变成一个 SPA 应用。这一点可以从编译产物中看出来(编译产物见 vitepress 构建流程)。
这个特性保证了 vitepress 的优异性能,同时还具有一定的可交互性,博客文章都是静态的,交互的特性都是打包后的 JavaScript bundle 提供的。不过这对我也有一点小小的影响,比如博客第一次打开的时候,是没有文章渐入的动画效果的,这是因为 vitepress 提供的是服务端渲染后的代码,vue 在初次打开时会有一个水合(或者叫做激活)的步骤,他直接将事件监听器附加到已有的 DOM 上,导致动画没有触发
vue
<template>
<TransitionGroup appear @before-enter="beforeEnter" @enter="enter">
<li
v-for="(post, index) in props.posts"
:key="post.url"
:data-index="index"
>
<PostItem :post="post" />
</li>
</TransitionGroup>
</template>
但!我们可以做一些小 trick,来狡猾的添加动画,基本思路如下
vue
<template>
<ul>
<TransitionGroup
v-if="mounted"
@before-enter="beforeEnter"
@enter="enter"
>
<li v-for="item in l1">
<div>{{ item }}</div>
</li>
</TransitionGroup>
<template v-else>
<li v-for="item in l2">
<div :style="{ visibility: 'hidden' }">{{ item }}</div>
</li>
</template>
</ul>
</template>
可以看到我们提供了一个新的文章列表,但是这个列表里面所有的项都是不可见的,只是充当占位置的元素,等到水合后且 mounted 执行完成后,重新加载文章列表 DOM,这时候就可以做出动画效果了!
预加载视口文章
在进入到我们的博客首页时,会发现有很多的博客链接,再打开开发者工具,可以看到 head 标签内有很多设置为 prefetch 的 link 标签,他们指向对应的 chunk,同时开发者工具的网络一栏也能看到相应的 prefetch,但是里面具体的内容看不到,这是由 prefetch 自身的特性决定的,详情可以看谷歌的这个库。
typescript
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const link = entry.target as HTMLAnchorElement;
observer!.unobserve(link);
const { pathname } = link;
if (!hasFetched.has(pathname)) {
hasFetched.add(pathname);
const pageChunkPath = pathToFile(pathname);
if (pageChunkPath) doFetch(pageChunkPath);
}
}
});
});
rIC(() => {
document
.querySelectorAll<HTMLAnchorElement | SVGAElement>("#app a")
.forEach((link) => {
const { hostname, pathname } = new URL(
link.href instanceof SVGAnimatedString ? link.href.animVal : link.href,
link.baseURI,
);
if (
// 仅预取同标签页导航,因为新标签页将加载 lean.js 代码块。
link.target !== "_blank" &&
// 仅预取入站链接
hostname === location.hostname
) {
if (pathname !== location.pathname) {
observer!.observe(link);
}
}
});
});
代码分割
vitepress 的代码分割做的可以称作简洁有力啊,打包后的产物分为入口 HTML(包括首页入口和各博文入口),框架 JavaScript bundle(framework.js
),应用 JavaScript bundle(app.js
)。
其中框架代码包含了 vitepress 的核心内容,比如 vue3 的运行时核心,路由系统,markdown 渲染(存疑)。应用代码包含了我们自己编写的主题(除了 css 会被默认拆出去形成一个单独的文件),同时,由于我们的主题依附于默认主题,所以部分主题代码还会出现在框架代码中。博文的拆分在前面已经提到了,这里不再赘述。
同时对重要代码还进行了预取,比如 framework.js
的代码,以供 app.js
使用,而不是形成一个瀑布流的请求
除了 vitepress 框架自身的代码分割,我们还可以通过动态引入其他模块(比如 viewerjs),来触发代码分割。如果我们通过顶层静态引入 viewerjs,他就会附加到 app.js
中,包体积会大出 20kb 左右,分割后明显能看到总包体减小,并且可以并行请求。
favicon 的设置
favicon 虽然只是一个简单的图标,但是由于他是必须加载的低优先级资源,所以对他的优化也是有必要的。
favicon 通常使用下面的格式文件,前者所有的桌面浏览器都支持,后者现代的浏览器支持,这些图标必须是正方形的,否则可能会面临兼容性问题
favicon.ico
图标- PNG 格式图标
同时,由于使用场景非常多变,我们可能需要提供一组 16×16、32×32 和 48×48 图像集合,比如 16×16 放在地址栏,32×32 放在快捷任务栏上。这么多的图标,如果不借助工具来生成会非常困难,可以通过这个网站来实现 https://realfavicongenerator.net/,使用方法可以参考张鑫旭的博客
画廊懒加载和优化
这部分可以去看画廊的实现,唯独有一些小变化,就是关于 LQIP 的实现。我们确实没有很好的办法去生成低质量的缩略图,并且在前端提前请求这些图片,但是可以尝试在构建时将 base64 编码的 blurhash 内联到 HTML 中,然后在客户端解码,用 canvas 绘制出模糊图
toml
[[entry]]
title = "图片示例"
image_url = "https://xxx.jpg"
blurhash = "UxKwCFIURPs:~qaKRjV@%gayV@WBkWs:jFae"
上面 toml 列表中的 blurhash 就是在构建时生成的,并在下面的 vue 组件中解码使用
vue
<template>
<canvas ref="canvas"></canvas>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { decode } from "blurhash";
const props = defineProps<{
blurhash: string;
width?: number;
height?: number;
punch?: number;
}>();
const canvas = ref<HTMLCanvasElement | null>(null);
const drawBlurhash = () => {
if (!canvas.value) return;
const ctx = canvas.value.getContext("2d");
if (!ctx) return;
const width = props.width || 32;
const height = props.height || 32;
const punch = props.punch || 1;
canvas.value.width = width;
canvas.value.height = height;
const pixels = decode(props.blurhash, width, height, punch);
const imageData = ctx.createImageData(width, height);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
};
onMounted(drawBlurhash);
</script>
<style scoped>
canvas {
display: block;
max-width: 100%;
}
</style>
尽量避免引入外部包
这个其实很难说,因为为了实现一些功能,我们不可避免的要使用一些外部模块,他们相对会做更好的性能优化和边缘情况处理,但是对于简单的场景,我们自己编写的代码已经足够应付,比如首页文章列表逐个进入的动画效果,最开始我使用的是 gsap 实现的,可以通过下面的代码替换
typescript
// posts transition
const isMounted = ref(false);
onMounted(() => {
if (!foldEl.value) return;
isMounted.value = true;
const liEls = foldEl.value.querySelectorAll("li");
liEls.forEach((el, idx) => {
el.style.transition = "transform 1s ease-out, opacity 1s ease-out";
el.style.transitionDelay = `${String(idx * 0.1)}s`;
});
});
这段 vue 中的 script 通过操控每个列表元素的 transition
样式,使他们获得了过渡效果和不同的延迟时间。而且这个动画的流畅程度可以和 gsap 媲美,因为他是纯 css 动画,只是通过 JavaScript 控制动画的时机而已。
字体优化
由于我的博客使用了落霞孤鹜字体,如果要全量引入需要大概几十兆,这对于一个博客来说肯定是很难接受的,所以最开始选用的是 CDN。在网络请求字体 css 文件后,浏览器会检测页面出现了哪些字体,然后再根据 css 中 font-face 定义的规则去重新请求分块后的字体,从而实现字体的子集化和分块化,减小了网页加载时间。
但这并不能保证网页性能达到满分,因为根据上述的流程可以知道,我们是首先请求的 css 文件后采取请求实际的字体文件,这会有一个先后顺序,导致瀑布请求发生,并且只有当页面文字全部出现后我们才能知道要去请求哪些分块,这又会导致字体的闪烁问题(浏览器在没有接收到字体文件时先采用回退字体,请求后又重新采用落霞孤鹜字体)。并且字体的分块完全由 CDN 控制,并不能更精细的只选择所有用到的字体,从网络加载页面中可以看到,每个字体分块大概 60KB,共请求十几次,也就是 0.6MB,难免会降低性能。
于是思考有没有更激进的字体子集化策略呢?答案是有的,并且由于我们的静态博客的先天优势,用户几乎很难手动输入一些数据,所以我们可以精确地计算出博客中共出现了哪些字形,然后用字体子集化工具来减小体积,代码如下
JavaScript
function getAllFiles(patterns) {
return patterns.flatMap((pattern) =>
glob.sync(pattern, { cwd: projectRoot, absolute: true }),
);
}
function extractCharacters(files) {
let characters = new Set();
files.forEach((file) => {
const content = fs.readFileSync(file, "utf8");
for (let char of content) {
characters.add(char);
}
});
return Array.from(characters).join("");
}
const filePatterns = ["**/*.md", "**/*.toml"];
const mdAndTomlFiles = getAllFiles(filePatterns);
const text = extractCharacters(mdAndTomlFiles);
const fontmin = new Fontmin()
.src(srcPath)
.use(Fontmin.glyph({ text }))
.use(Fontmin.ttf2woff2())
.dest(destPath);
fontmin.run(function(err, files) {});
精简后的字体文件只有 300KB 左右,并且可以丢弃掉原来的 css 文件,直接通过 preload 进行预加载,然后通过内联样式引用字体,达到充分并发的效果,代码如下:
html
<link rel="preload" href="/font/LXGWWenKaiScreen.woff2" type="font/woff2"
as="font" crossorigin="anonymous">
<style>
@font-face {
font-family: "LXGW WenKai Screen";
src: url("/font/LXGWWenKaiScreen.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
:root {
--vp-font-family-base: "LXGW WenKai Screen", sans-serif;
}
</style>
TIP
字体在预加载的时候一定要记得加上 as
,否则开发者工具会报错,剩下的两个属性似乎没有太大影响,但加上也不会有问题,没准还能更好的处理兼容性问题呢!
这样做也有坏处,没有被打包进去的字会回退到浏览器默认字体,比如你在搜索框输入“萍”这个字,就可以看到回退的效果。实际上我们只打包了一千四百多个汉字,而生活中常用的汉字有四千多个,可以说还是有很大几率出问题的。
文件压缩
如果查看开发者面板,你会看到如下的网络请求面板
一共传输了 637kb 的资源,但是后面还跟了一个 869kb 资源,这表示我们的网络请求使用到了压缩技术,解压后一共是 869kb 的占用,压缩率大概是 73%,可以说相当可观了,你为地球减少碳排放做出了重大的努力!(bushi)
把网络面板的大请求行选项打开后,可以看到每个资源具体的压缩情况,比如我们的 HTML 资源,从 16.5kb 直接压到 4.6kb,点一下这个文件,查看可以接受的压缩方式为 accept-encoding: gzip, deflate, br, zstd
,实际压缩方式为 content-encoding: br
,这表示我们最终选用 brotli
格式来进行压缩
至于具体如何开启压缩,你无须担心,vercel 已经替我们做了这个事情,在他们的文档中也有说明
Vercel's Edge Network regularly maintains a configuration file for the MIME types that will be compressed for both
gzip
andbrotli
:Application MIME types
json
javascript
wasm
...
Font file types
otf
Image file types
svg+xml
bmp
Text file types
css
javascript
markdown
...
TIP
虽然 gzip
已经存在了相当长的时间,但 brotli
是由 Google 开发的一种较新的压缩算法,尤其适用于文本压缩。如果你的客户端支持 brotli,建议优先于 gzip,因为:
brotli
压缩的 JavaScript 文件比gzip
小 14%HTML 文件比
gzip
小 21%CSS 文件比
gzip
小 17%
brotli
相对于 gzip
的优势在于它使用了客户端和服务器端共有的常见关键词字典,从而实现了更好的压缩比。
上面都说的是文件压缩,字体压缩也很重要,我们这里采用了先进的 woff2 格式来储存字体,他自身已经做了很好的压缩,无需采用其他压缩算法了!
网络协议
这算是一个小尾巴,前面我们对数据和代码做了很多的文章,也不能忽视信息传送的途径,打开开发者工具的网络面板,勾选协议栏,可以看到多种协议,比如 h2(http2 的缩写) 或者 http1.1,我见过的网站大多开启了 http2,这是因为提升真的很大,比对如下:
http1.1 的网络请求概览
http2 的网络请求概览
这里能看到 http1.1 的概览有六行请求的柱子,而 http2 只有一行,这就是他们的重要差别,详情可以看 http 版本介绍,在启用了 http2 后,不仅传输数据变少了,而且速度更快!
随笔尾声
从上面一系列的措施来看,我们进行性能优化主要通过以下几个方面
- 文件大小,极力采用压缩比好的算法,并对源代码进行最小化,上传文件前也通过在线平台进行压缩
- 加载时机,避免因网络请求阻塞网页,规定好高优先级的加载文件(如 HTML CSS 和框架 JavaScript chunk),低优先级(一些无关紧要的 icon 和需要预加载的博文信息)。还有利用 JavaScript 的视口交叉 api 进行懒加载
- 文件拆分,有时候文件实在太大了,就通过动态引入的方式来引导 vite 进行代码拆分,通过网络的并发请求来降低大文件影响
- 服务端渲染,这是提升最大的一个点,使用前 lightinghouse 只有六七十分,但是使用后直接能拉满!