← 返回文章流

把 Hugo 博客的加载速度从三秒压到八百毫秒

博客搭起来之后,我有一阵子特别在意 Lighthouse 那个分数。 第一次跑的时候,移动端性能 41 分,红色的。首屏内容绘制三点二秒,最大内容绘制四点一秒。这个成绩放在 2024 年,属实难看。 花了一个周末逐项处理,现在稳定在 96 分左右,首屏八百毫秒。这篇记录一下具体做了什么,哪些有用,哪些是白费劲。 先搞清楚慢在哪 别一上来就乱改。先跑一遍 Lighthouse,看它给的诊断项。 打开 Chrome 开发者工具,Lighthouse 标签页,选 Mobile,跑一次。跑完会给你一堆建议,按影响大小排序。 我那次排在最前面的是三件事。图片没有指定尺寸导致布局偏移,字体加载阻塞渲染,还有一个第三方脚本。 改之前先记一下基线,不然改完不知道有没有变好。 图片是最大的问题 我博客的文章配图是直接从手机相册里拖出来的,一张四兆。首页卡片显示的是缩略图,但它加载的其实是原图,浏览器自己缩。 这就很浪费。一个用户打开首页,要下十几兆的图片,手机上还容易因为内存不足崩掉。 Hugo 内置了图片处理,用 Page Bundle 就能直接调: 1 2 3 {{ $img := .Resources.Get "cover.webp" }} {{ $small := $img.Resize "800x webp q80" }} <img src="{{ $small.RelPermalink }}" width="{{ $small.Width }}" height="{{ $small.Height }}" alt="封面"> Resize "800x webp q80" 的意思是,宽度缩到 800,保持比例,转成 webp 格式,质量 80。webp 比 jpg 小百分之三十左右,肉眼几乎看不出区别。 如果要响应式,用 srcset: 1 2 3 4 5 6 7 8 9 10 {{ $img := .Resources.Get "cover.webp" }} {{ $s := $img.Resize "480x webp" }} {{ $m := $img.Resize "800x webp" }} {{ $l := $img.Resize "1200x webp" }} <img src="{{ $m.RelPermalink }}" srcset="{{ $s.RelPermalink }} 480w, {{ $m.RelPermalink }} 800w, {{ $l.RelPermalink }} 1200w" sizes="(max-width: 600px) 480px, (max-width: 900px) 800px, 1200px" width="{{ $m.Width }}" height="{{ $m.Height }}" loading="lazy" alt="封面"> width 和 height 一定要写。这是 Lighthouse 那个"图片元素没有明确的宽度和高度"的根源。有了这两个属性,浏览器在图片加载之前就能预留出空间,不会发生布局偏移。CLS 这个指标直接从 0.24 降到 0.01。 loading="lazy" 让首屏之外的图片延迟加载。但首屏的图片不要加,加了反而会拖慢 LCP。 字体 Stack 主题默认用的是系统字体栈,本来不该有这个问题。但我自己加了个思源黑体,从 Google Fonts 拉的。 问题在于,Google Fonts 的那个 css 是渲染阻塞的。浏览器必须等它下载完,才能决定用什么字体显示文字。国内访问 Google 的域名还经常超时,一等就是好几秒。 解决办法是把字体下到本地,自己托管: 1 2 mkdir -p static/fonts wget -O static/fonts/NotoSansSC-Regular.woff2 "https://..." 然后在 css 里: 1 2 3 4 5 6 @font-face { font-family: 'Noto Sans SC'; src: url('/fonts/NotoSansSC-Regular.woff2') format('woff2'); font-display: swap; unicode-range: U+4E00-9FFF; } font-display: swap 是关键。它让浏览器先用后备字体显示文字,等自定义字体下载完了再替换。用户不会看到一片空白。 中文字体文件很大,完整的思源黑体有十几兆。所以我又做了一步,用 fonttools 做子集化,只保留常用的三千多个字: 1 2 pip install fonttools brotli pyftsubset NotoSansSC-Regular.otf --text-file=common.txt --flavor=woff2 --output-file=NotoSansSC-subset.woff2 这样能从十几兆压到六百多 KB。 不过说实话,后来我干脆把自定义字体删了,直接用系统字体栈: 1 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; 效果上没什么差别,还省了六百 KB 的下载。这是我折腾一圈之后最实际的一个决定。 渲染阻塞的 css 和 js Hugo 生成的 css 是通过 resources.Get 和 minify 处理的,Stack 主题已经做得不错了。 但我检查的时候发现,有个 custom.css 是同步加载的,它在 <head> 里,会阻塞渲染。 如果这个 css 不大,可以直接内联: 1 2 {{ $css := resources.Get "css/custom.css" | minify }} <style>{{ $css.Content | safeCSS }}</style> 内联的好处是省掉一次请求。坏处是不能缓存,每次都要跟着 html 一起下载。所以只适合小文件,几百字节以内。 大一点的用 preload: 1 2 <link rel="preload" href="/css/custom.css" as="style"> <link rel="stylesheet" href="/css/custom.css" media="print" onload="this.media='all'"> 这个写法有点绕,原理是先以 media="print" 加载,这样浏览器不会把它当渲染阻塞资源,加载完了再通过 onload 把 media 改成 all。 js 那边,我给所有非关键的脚本都加了 defer: 1 <script src="/js/main.js" defer></script> defer 表示下载不阻塞解析,等 html 解析完了再按顺序执行。比 async 更可控,因为 async 的执行顺序不确定。 去掉那个第三方脚本 我在侧边栏加了个访问量统计,用的是某个第三方服务的小挂件。它加载的时候会额外拉三个资源,其中一个还卡了两秒。 看了下它的实现,就是一个 img 加一段脚本。我把它删了,换成了自己写的,用 Cloudflare 的 Workers 记录访问,前端只请求一个自己域名下的接口。 从三个跨域请求变成一个同域请求,这一项就省了将近一秒。 这个教训是,第三方的挂件比自己写的贵得多。你控制不了它的性能,也控制不了它什么时候挂。 缓存策略 静态博客的缓存可以设得很激进。我在 nginx 里给静态资源设了一年: 1 2 3 4 location ~* \.(css|js|woff2|jpg|jpeg|png|webp|svg)$ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; } 前提是文件名带指纹。Hugo 的 resources 管道默认会给处理过的资源加 hash,比如 custom.a1b2c3d4.css,改内容就改名字,所以缓存一年也不会出问题。 html 不能这么设,我给了十分钟,加上 must-revalidate。 结构化数据 SEO 这块,除了 title 和 description,最重要的是给搜索引擎结构化数据。 Stack 主题自带了一部分,但不够全。我加了一段 JSON-LD,放在 layouts/partials/head.html 的扩展位置: 1 2 3 4 5 6 7 8 9 10 11 <script type="application/ld+json"> { "@context": "https://schema.org", "@type": "BlogPosting", "headline": {{ .Title }}, "datePublished": {{ .Date.Format "2006-01-02T15:04:05Z07:00" }}, "dateModified": {{ .Lastmod.Format "2006-01-02T15:04:05Z07:00" }}, "author": {"@type": "Person", "name": "{{ .Site.Params.author }}"}, "mainEntityOfPage": {"@type": "WebPage", "@id": {{ .Permalink }}} } </script> 这个能让搜索结果里显示作者、日期这些信息,点击率会高一些。 sitemap 和 robots Hugo 默认会生成 sitemap.xml,不用管。 robots.txt 我用的是模板: 1 2 3 4 5 User-agent: * Allow: / Disallow: /page/ Sitemap: {{ .Site.BaseURL }}sitemap.xml Disallow: /page/ 是分页页,这些页面内容重复,让爬虫抓了没意义,还分散权重。 收录的情况 提交了 sitemap 之后,我在 Google Search Console 里看,首页三天收录,文章页大概一周。 百度的收录慢很多,而且它对这个域名的抓取频率很低。我后来在百度站长平台手动提交了几次,情况好了一点,但依然不如 Google。 这个我没太纠结。写博客主要是给自己留个记录,有人搜到是意外收获。 那些没用的事 有几个优化我试过但放弃了。 一个是 preconnect 到各种第三方域名。我加了之后 Lighthouse 分数反而掉了两分,因为每个 preconnect 本身也要开销,域名一多就得不偿失。 一个是把 css 全内联。文件一大,html 体积涨到两百多 KB,首字节时间反而变长。现在只内联关键的那部分。 还有一个是 Service Worker 做离线缓存。技术上是能跑,但博客更新频率低,用户很少重复访问同一篇,收益不明显,还容易因为缓存版本没更新导致看到旧内容。 现在的成绩 移动端 96 分,桌面端 99。首屏八百毫秒左右,LCP 一点二秒。 这个分数在个人博客里算不错的,但我越来越觉得分数本身没那么重要。真正有用的是那几个具体的改动,图片压缩、字体本地化、去掉第三方脚本。这三件事做完,体验上的提升是能感觉到的,分数只是顺带的结果。 如果你的博客也慢,我建议先看图片。十有八九,问题就在那儿。

来自 昭鸢笺作者主页
在原博客阅读全文 (新标签页打开)

以上内容来自博客订阅源,版权归原作者。关注作者后可以在 关注列表 第一时间查看作者文章。

一起聊聊 0

尊重不同观点

还没有评论。欢迎分享第一个想法。

RSS SUBSCRIPTIONS

选择 RSS 订阅

把博客汇的文章添加到你常用的阅读器。

关注文章 RSS 订阅本订阅需要登录后使用