CLS的伪解决方案#
何谓CLS#
Google的PageSpeed Insights报告中展示了四大维度的指标:FCP
、LCP
、TBT
、CLS
。其中,CLS是指Cumulative Layout Shift,即累积布局偏移。
Cumulative Layout Shift (CLS) 是一项稳定的 Core Web Vitals 指标。它是一项以用户为中心的重要指标,用于衡量视觉稳定性,因为它有助于量化用户遇到意外布局偏移的频率,而较低的 CLS 有助于确保网页带来愉悦的体验。
意外的布局偏移可能会在很多方面影响用户体验,例如,如果文本突然移动,导致用户在阅读时失去位置,或让用户点击错误的链接或按钮。在某些情况下,这可能会造成严重损害。
当以异步方式加载资源,或将 DOM 元素动态添加到网页中的现有内容之前时,通常会发生网页内容意外移动。导致布局偏移的原因可能包括尺寸未知的图片或视频、呈现的字体大于或小于其初始后备尺寸,或者是会自行动态调整大小的第三方广告或微件。
简单来说,CLS是网站设计时应该避免的问题。在Hugo中,一般需要利用利用响应式图片(Responsive images)1、页面束(Page bundles)2、图像渲染挂钩(Image render hooks)3和图像处理(Image processing)4等工具或处理方法。
在我的“伪解决方案”中,主要借助了HTML和CSS的特性,不依赖于Hugo的图像处理。
方案展示#
render-image.html#
首先需要在Hugo站点目录layouts/_default/_markup
路径下,创建名为render-image.html
的文件,它负责Hugo对图像的渲染。
我所使用的render-image.html
代码如下:
<style>
.container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 720px;
aspect-ratio: 16/9;
position: relative;
background-color: rgba(0, 0, 0, 0);
}
.container img {
max-width: 100%;
height: auto;
display: block;
}
</style>
<div class="container">
<img onload="resizeContainer(this)" loading="lazy" src="{{ .Destination | safeURL }}" alt="{{ .Text }}" {{with.Title}} title="{{ . }}" {{end}} />
</div>
<script>
function resizeContainer(img) {
const container = img.parentElement;
const aspectRatio = img.naturalHeight / img.naturalWidth;
container.style.height = `${container.clientWidth * aspectRatio}px`;
}
</script>
其中,max-width: 720px; aspect-ratio: 16/9;
是根据所使用的主题文章内图片大小而设定的值,需要根据实际情况修改,我所使用的PaperMod主题在文章内图片尺寸为720px*405px。
cover.html#
接下来以PaperMod为例,因为Hugo各个主题之间的结构可能略有不同,修改前应当自行调整。在themes/PaperMod/layouts/partials
下复制一份cover.html
,粘贴到layouts/partials
下。PaperMod提供的有关封面显示的代码为:
{{- if (and (in $processableFormats $cover.MediaType.SubType) ($responsiveImages) (eq $prod true)) }}
<img loading="{{$loading}}" srcset="{{- range $size := $sizes -}}
{{- if (ge $cover.Width $size) -}}
{{ printf "%s %s" (($cover.Resize (printf "%sx" $size)).Permalink) (printf "%sw ," $size) -}}
{{ end }}
{{- end -}}{{$cover.Permalink }} {{printf "%dw" ($cover.Width)}}"
sizes="(min-width: 768px) 720px, 100vw" src="{{ $cover.Permalink }}" alt="{{ $alt }}"
width="{{ $cover.Width }}" height="{{ $cover.Height }}">
{{- else }}{{/* Unprocessable image or responsive images disabled */}}
<img loading="{{$loading}}" src="{{ (path.Join .RelPermalink .Params.cover.image) | absURL }}" alt="{{ $alt }}">
{{- end }}
{{- else }}{{/* For absolute urls and external links, no img processing here */}}
{{- if $addLink }}<a href="{{ (.Params.cover.image) | absURL }}" target="_blank"
rel="noopener noreferrer">{{ end -}}
<img loading="{{$loading}}" src="{{ (.Params.cover.image) | absURL }}" alt="{{ $alt }}">
{{- end }}
仿照上面的render-image.html
,将其修改为:
{{- if (and (in $processableFormats $cover.MediaType.SubType) ($responsiveImages) (eq $prod true)) }}
<img loading="{{$loading}}" srcset="{{- range $size := $sizes -}}
{{- if (ge $cover.Width $size) -}}
{{ printf "%s %s" (($cover.Resize (printf "%sx" $size)).Permalink) (printf "%sw ," $size) -}}
{{ end }}
{{- end -}}{{$cover.Permalink }} {{printf "%dw" ($cover.Width)}}"
sizes="(min-width: 768px) 720px, 100vw" src="{{ $cover.Permalink }}" alt="{{ $alt }}"
width="{{ $cover.Width }}" height="{{ $cover.Height }}">
{{- else }}{{/* Unprocessable image or responsive images disabled */}}
<style>
.container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 670.4px;
aspect-ratio: 16/9;
position: relative;
background-color: rgba(0, 0, 0, 0);
}
.container img {
max-width: 100%;
height: auto;
display: block;
}
</style>
<div class="container">
<img onload="resizeContainer(this)" loading="{{$loading}}" src="{{ (path.Join .RelPermalink .Params.cover.image) | absURL }}" alt="{{ $alt }}">
</div>
<script>
function resizeContainer(img) {
const container = img.parentElement;
const aspectRatio = img.naturalHeight / img.naturalWidth;
container.style.height = `${container.clientWidth * aspectRatio}px`;
}
</script>
{{- end }}
{{- else }}{{/* For absolute urls and external links, no img processing here */}}
{{- if $addLink }}<a href="{{ (.Params.cover.image) | absURL }}" target="_blank"
rel="noopener noreferrer">{{ end -}}
<style>
.container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 670.4px;
aspect-ratio: 16/9;
position: relative;
background-color: rgba(0, 0, 0, 0);
}
.container img {
max-width: 100%;
height: auto;
display: block;
}
</style>
<div class="container">
<img onload="resizeContainer(this)" loading="{{$loading}}" src="{{ (.Params.cover.image) | absURL }}" alt="{{ $alt }}">
</div>
<script>
function resizeContainer(img) {
const container = img.parentElement;
const aspectRatio = img.naturalHeight / img.naturalWidth;
container.style.height = `${container.clientWidth * aspectRatio}px`;
}
</script>
{- end }}
同上面一样,max-width: 670.4px; aspect-ratio: 16/9;
是根据所使用的主题封面图片大小而设定的值,需要根据实际情况修改,我所使用的PaperMod主题封面图片尺寸为670.4px*377.1px。
可以看到,PaperMod提供的有关封面显示的代码提供了3段HTML,但是我只修改了后面两段,这是因为第1段HTML需要params.cover.responsiveImages = true
触发,而我关闭了响应式图片,所以无需修改。
inner_cover.html与single.html#
在上面的cover.html中,我们给定了max-width: 670.4px
,这帮助我们规定封面的大小。然而,PaperMod主题在主页的封面和在文章内的封面大小又有不同,在文章详情页,封面的大小为720px*405px,同文章内图片大小一样。
为了保持内外封面的尺寸,我们需要将两处封面的有关代码解耦。复制一份cover.html
并将其重命名,为了便于辨认,我将其命名为inner_cover.html
,意为“内部的封面”,在inner_cover.html
中,设置max-width: 720px
。
接着,去到所用主题路径下的layouts/_default
,复制其中的single.html
至站点目录下的layouts/_default
。PaperMod主题所提供的single.html
有关封面渲染的代码为{{- partial "cover.html" (dict "cxt" . "IsSingle" true "isHidden" $isHidden) }}
,将cover.html
替换为inner_cover.html
,至此,Hugo在渲染文章时,会将inner_cover.html
的设定应用到文章详情页封面。
上面的解耦操作参考了Hugo博客文章封面图片缩小并移到侧边 | PaperMod主题 | Sulv’s Blog,相关操作也可参阅这篇文章。这篇文章提到
把cover1.html文件里的
<figure class="entry-cover">
修改为<figure class="entry-cover1">
但是,在我进行了如上操作后,文章内封面的圆角消失,似乎这个操作影响文章内封面的样式。本着“如无必要,勿增实体”的奥卡姆剃刀精神,我选择不进行修改。经快速测试,不进行如上操作并未带来可见的负面效果。
此外,在进行解耦前,如果文章同时存在封面和插图,那么文章内封面的尺寸与图片一致;文章只存在封面而无插图,则文章内封面尺寸与主页封面一致。这种情况应该是某处代码调用导致的,不一定是bug。
细节说明#
CSS#
方案中的CSS部分除常规的样式调整外,还有占位作用,是本方案的核心。
<style>
.container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 720px;
aspect-ratio: 16/9;
position: relative;
background-color: rgba(0, 0, 0, 0);
}
.container img {
max-width: 100%;
height: auto;
display: block;
}
</style>
其中,width: 100%; max-width: 720px; aspect-ratio: 16/9;
是起占位作用的关键:将<div>
设置为有最大宽度,且比例固定的容器。这样,便能在图片加载前预留一定的空间。
在前几版的代码中,有过设置固定width
或height
的尝试,但是固定大小会使不同客户端的图片尺寸显示一致,导致移动端的排版异常。使用“最大”和“最小”来规定大小,使得容器能够适应不同客户端的排版。
设置aspect-ratio
是以间接的方法通过容器宽度得到容器高度,不能通过固定数值设置的原因同上;同时,也不能使用max-height
、min-height
来设置高度,它们同样可能导致排版异常,而且作为最大值与最小值,它们只能规定高度的范围,不能起固定数值的占位作用。aspect-ratio
能在暂时不设置高度的情况下固定容器高度,以得到一个有大小的<div>
。
display: flex; justify-content: center; align-items: center;
起居中作用,居中不仅可以避免许多意外的排版错误,还能使图像的替代文字(alt属性)居中,更加美观。
容器的背景颜色使用background-color: rgba(0, 0, 0, 0);
设置为透明。当然,也可以设置为其它颜色,或者显示自定义的CSS样式和图片。建议设置时辅以border-radius
,避免背景圆角与上层图片不匹配,导致背景露出;或者使用JavaScript,在图片加载后隐藏容器背景。
JavaScript#
方案中使用的JavaScript功能为计算容器的高度,使其与图片高度一致。
<script>
function resizeContainer(img) {
const container = img.parentElement;
const aspectRatio = img.naturalHeight / img.naturalWidth;
container.style.height = `${container.clientWidth * aspectRatio}px`;
}
</script>
如果能够接受超宽图片带来的上下空间,那么这个函数就不是必要的。删除这段JavaScript后,在显示超宽图片时,会出现类似“以16:9的屏幕播放21:9的视频”造成的上下空余;然而,在显示超高图片时,由于aspect-ratio
的非强制性,图片和容器均会正常显示,显示情况与下图一致。
函数中通过比例间接计算<div>
所需高度,这会在某些过于复杂的情况下引入细微不可见的误差。但是直接通过图片高度赋值容器高度,会出现较大程度的偏差,所得高度值远超图片实际高度,所以间接计算在这个情况下是优解。
AVIF困境#
Hugo中的AVIF#
上面花了不少时间,得到了一个“伪解决方案”,那么为什么要伪解决而不能真正解决呢?
实际上,有关Hugo的CLS问题,前人提供了很多解决方案,例如:
这些方案我都一一尝试,但是无一例外,都会遇到形如execute of template failed at <$img.Width>: error calling Width: this method is only available for image resources
的错误。
一开始我以为是站点结构、函数参数之类的代码错误,一番碰壁心灰意冷后,在无意间搜索”Hugo AVIF“时,才得到答案:Hugo并不支持处理AVIF。
Hugo的Github仓库中有一个2020年创建的Issue:Add image processing support for AVIF · Issue #7837 · gohugoio/hugo,其中就有对AVIF支持的请求,而开发者也做出了回复:
No. I’m the one who spend the most of my free time maintaining this project, so adding new C(++) dependencies is almost never going to happen unless we really, really need it. I have not calculated the cost of adding WebP to Hugo, but it wasn’t cheap. I’ll keep this issue open as things may change, but it’s not very likely unless a top quality native Go decoder/encoder pops up.
不。我是那个花费大量空闲时间维护这个项目的人,所以除非我们真的非常需要,否则几乎不会添加新的C(++)依赖。我没有计算过将WebP支持添加到Hugo的成本,但成本并不低。我会保持这个问题的开放状态,因为情况可能会发生变化,但除非出现高质量的(以Go编写的)AVIF解码器/编码器,否则这种可能性不大。
直到这篇文章诞生之时,许多Hugo用户期待的”以Go编写的高质量的AVIF解码器/编码器“仍然没有出现,或是出现了但是没有合并到Hugo之中,于是Hugo对AVIF的支持也就没有了下文。
伪解决方案的优劣#
既然Hugo不支持处理AVIF格式的图片,出现”this method is only available for image resources“的报错也就不难理解了:在Hugo眼中,AVIF格式的图片不属于图片资源。
失去了Hugo的图片处理功能,不仅让我的”响应式图片“被禁用,更让CLS的解决变得很曲折,最终在有限的时间和知识储备下,我得到了上面的伪解决方案。虽说称其为”伪解决方案“,但是这种方法还是有其独特的优势:
- 图片格式的向后兼容性
- 跨生成器的兼容性
图片格式的兼容性很好理解,因为HTML、CSS和JavaScript不涉及对图片本身的处理,即使将图片格式从AVIF换到JPEG XL、WebP2或是更加先进的格式,上面的代码也应该能够正常工作。
跨生成器兼容性是在Hugo不支持AVIF的情况下应运而生的,失去了Hugo的图片处理功能也意味着不需要Hugo的图片处理功能,即使有一天我需要更换静态站点生成器(SSG),通过简单的代码替换,就可以完成移植。
虽然有优点,但是它的缺点也不能忽视:
- 占位元素大小固定,不随图片尺寸变化
- 没有二次计算的能力
占位元素大小固定是非常致命的缺点,也是这个方案被称作”伪解决“的根本原因。在无法提前获知图片尺寸的情况下,我们无法对占位容器做出调整,只能够选择固定一个通用大小。通过HTML和JavaScript当然也能够实现提前获知图片尺寸,但是这样实现的图片加载前调整在网页加载周期的位置过于靠后,几乎是调整容器大小和图片加载同步进行,起不到”提前“的作用,同样会引入CLS,还会使代码更加复杂,因此作罢。
上面的伪解决方案在加载非16:9比例的图片时,会二次调整位置。鉴于我所使用的图片大多为动画截图,比例一致,二次调整位置所引入的CLS较少,于是这个伪解决方案在我个人的使用场景才下有了成立的可能。
如果所使用的图片大小不一,这个方案的效果就会较差,这时便不应称其为伪解决方案了,叫改善方案更为合适。当然,也可以选择固定容器大小,令图片比例固定以适应容器,代价就是图片四周空余和缩放。
第二点问题是在调试时偶然发现的,当使用调试台,将响应式设计模式设置在移动端和桌面端来回切换时,会产生图片错位。这是预期内的行为,因为按照设计,JavaScript函数只会计算一次容器高度,当UA改变导致站点排版发生变化,函数并不会再次计算。不知道使用了Hugo相关功能的方案是否会出现这种情况,但是鉴于这种行为过于稀少且异常,我无意针对这个问题改进代码。
为什么是AVIF#
其实在遇到这个问题的一刻,我也有考虑过将站点的所有图片格式更换为WebP,但是经过个人不严谨且主观的尝试与对比后,我放弃了。AVIF在压缩质量和体积上都比WebP更有优势,尤其在某些纹理复杂的场景,WebP会涂抹细节,而AVIF能将纹理尽可能地保留。作为一个主要发布观后感和动画截图的博客,我个人认为图片质量还是相当重要的,于是AVIF就这样被坚持了下来。
个人认为AVIF是一个典型的”半代产品“,前有WebP,作为出现相对早且被广泛应用的现代图片格式;后有JPEG XL和WebP2,提供了更高的压缩率、画面细节与其它参数。AVIF作为中间派,在浏览器普及率和性能间取得了平衡,是我比较喜欢的中庸做派。
AVIF的压缩性能过于强大,以至于质量为20的WebP需要和同样大小、质量为50的AVIF做比较。在AVIF面前的WebP显得过于”不现代、不先进“。经过时间积累,现代浏览器对AVIF的支持情况已经相当完善了,我认为现在使用AVIF,百利而无一害。
Zola初探#
作为静态站点生成器的新星,Zola5是我预定的下一个所使用的框架。在遇到Hugo与AVIF的问题时,我也去了解了一番Zola对于AVIF的支持情况,毕竟如果AVIF的痛点在Hugo无法解决,可以通过迁移站点到新的框架根除。
令我大跌眼镜的是,Zola作为使用Rust编写的静态站点生成器,竟然也不支持处理AVIF。Hugo不支持AVIF是在等待一个以Go编写的AVIF解码器/编码器,而Rust早已拥有了一个AVIF编码器rav1e6,只能怪开发者们对现代图片格式的支持不太上心了。
另外,Zola的生态相比Hugo也太过贫瘠。在Zola官方网站的主题页中,我似乎找不到一个包含文章封面、搜索和目录等功能的高度可用的主题,作为新事物,这是无可避免的情况。看来想要体验Zola,还需要社区的成长和时间的沉淀。