缓解 Flarum 图片加载时的布局偏移
Flarum
Web
技术
现代的 Web 开发中,CLS(Cumulative Layout Shift)是一个关键的 性能指标,它主要关注用户在 Web 网页的使用中,发生意外布局偏移(Layout Shift)、影响用户体验的情况。导致 Layout Shift 的因素很多,这里主要讨论的是图片加载过程的影响。
在维护 Flarum 0x 时,我遇到了图片元素带来的 Layout Shift 问题。DOM 的 <img>
元素在图片初次加载时,默认高度为 0,此时浏览器会按照 <img>
为 0 的高度去布局页面,当图片加载到有宽高信息的元数据时,浏览器会触发一次重新排版,给 img 附加了正确的高度,此时会导致该元素下方的其他 DOM 元素位置向下偏移。
这样的偏移通常情况不会被留意到,只有锚点在 <img>
元素下方时才会暴露出来。Flarum 的问题在于,当我们直接打开指向某楼层的链接,完成了一次指向某楼层的锚点定位,但若这时恰好前面楼层的帖子的图片加载成功,发生高度变化,原本的目标楼层就会一起被顶下去。
在日常使用场景下,因为有浏览器缓存,除了第一次加载外大部分时候图片在渲染之前就已进入缓存,问题不太明显;但在分享的场景下,体验就比较糟糕,尤其新人首次打开的体验会很受影响。
解决思路
<img>
元素在嵌入图像之前,需要确认其占用的空间,在 CSS 中属于 Intrinsic size 的概念。默认情况下,这层信息会通过待加载的图片的 EXIF 信息来获取;当然也可以手动指定元素的宽高比,使其不受图像加载过程而影响。
手工指定可以有这几种方式:
- 在 img 中指定:可以声明元素具体的
width
/height
,让浏览器提前计算图片的宽高比,以保留展示图像的排版空间,可以避免文档下载过程中的布局偏移。(具体而言除了width
/height
,还有srcset
/sizes
也会影响到这里,但相对小众) - 在 CSS 中指定:CSS 有提供了一个
aspect-ratio
的属性,兼容性 在新版本浏览器中都还不错(Chrome 88+ / Safari 15+) - padding hack:利用
padding
百分比单位基于宽度的特性,用padding-top
+background-img
的方式,实现固定宽高比的占位元素,并以背景的方式填充图片
无论何种方式,都需要图片的 aspect-ratio
信息,从生产的角度去出发看,我们需要明确 aspect-ratio
的信息来源。
关于当前 Flarum 帖子内容的 img 元素的来源,梳理下来有这几种:
- Markdown 的
![](https://xxxxx)
图片链接 - BBCode 插件自带的
[img]https://xxxxx[/img]
标签 - fof/upload 插件的
[upl-img-preview url=https://xxx /]
标签
由此我们的处理思路大致是:
- 在 HTML 渲染流程补充针对
aspect-ratio
的处理逻辑 - 在 img 元素的来源部分,想办法加上一些宽高比的信息
以上只是抽象而笼统的行动方向,具体的执行依赖于现实的局限,接下来我也针对其做一些展开。
Flarum 的内容渲染
注:以下基于 Flarum v1.8 版本切入,未来 2.x 后的版本可能会有变动
首先我们需要摸清一整个体系的工作流程。关于 Flarum 的内容渲染逻辑,核心采用了一个类库 s9e/TextFormatter,相比于传统论坛程序的内容渲染而言,它思路相对要特别一些,类似编译器的模式,它把用户输入的内容当作“代码”,解析成一棵“语法树”。帖子数据的存储和处理过程,也是以“语法树”为核心,通过 XML 的形式来表达。
参考 s9e/TextFormatter
的 文档,这里涉及到三种形态的文本(Original text / XML / HTML),对应有三个转化过程(Parsing / Unparsing / Rendering)需要维护,它们的关系如下图所示:
其中 Parsing 过程承担将原文解析为 XML 的角色,类似“编译器前端”,可以对接 Markdown / BBCode 乃至于各种复杂的特殊语法;Unparsing 过程则是负责将解析后 XML 还原回原文。
在作为中间态的 XML 的结构上,每一类内容都有它的一个单独的 Tag,对应内容的基本的元素(图片、视频、链接、各种排版格式等等等等)。类库针对标签的场景,为我们封装了一些工具方法,可以根据需要去改动已有的 Tag,或是定义新的 Tag,也可以对这些 Tag 做一些自定义,考虑安全因素,做一些特殊的规则限定等等。
对于 Rendering 过程,Tag 内部有个 Template 的 概念,Template 基于 XSLT
语言编写,类似一个“样式表”的角色,定义最终的 HTML 应该渲染成什么样子。类库的 Renderer 将每一个 Tag 需要用到的 XSLT 的 template 代码汇总一起,交给 XLST Processor 处理。内容 XML 借助 XSLT 样式表转换,得到最终我们需要的 HTML 结果。
围绕着这一核心逻辑,s9e/TextFormatter
在 PHP 和 JS 层面都做了完善的支持,它暴露了一个 Configurator
对象供我们自定义我们需要的 XML Tag,并提供了一系列的插件机制、预制的 BBCode / Markdown 的解析逻辑等等。通常我们只需通过它提供的 Configurator
去处理我们需要的效果。
Flarum 框架的核心代码对其做了一些简单封装,把 render
/ parse
/ unparse
/ Configurator
单独抽了出来,并做了一些基础的设定,然后分别抽出 addRenderingCallback
/ addParsingCallback
/ addUnparsingCallback
/ addConfigurationCallback
几个函数,供外部插件去介入内容渲染的工作过程。
Img 元素的处理
对比开头说到的处理思路,aspect-ratio
本身兼容性会有些问题,padding hack 在 s9e/TextFormatter
体系中的实现成本略有些高,这里我更倾向于简单直接指定 img width / height 的方式。
默认发帖时论坛并未记录图片的宽高比信息,我们这里暂且假设 width / height 已知,先把后续流程跑通。当最终 HTML 的 <img>
元素能获得指定的 width / height,这里的修改就成功了一半。
Flarum 的 addRenderingCallback
为此打开了一个口子,在中间态 XML 进入 renderer 之前,我们可以针对这个 XML 进行一番操作。这里的操作是,把 width 和 height 信息填充到 XML 中对应的 IMG
Tag,以及 UPL-IMG-PREVIEW
这个上传组件定义的元素。
IMG
Tag 本身自带 width / height 属性,这里我们可以不做修改,直接指定,但 UPL-IMG-PREVIEW
Tag 中只有 URL 属性可用,因此我们需要重新修改下这个 Tag 对应的 Template,这种情况可以通过 Configurator
来自定义实现,Flarum 有暴露一个 Extender 用于操作 Configurator
,我们再通过这个 Configurator
的 tags 访问到其 Tag 对象,再重新用 Tag 内自带的 setTemplate
方法来重设新的模版。
模版修改前后:
// original template
<img src="{@url}" title="{@base_name}" alt="{@base_name}"/>
// new template
<img src="{@url}" width="{@width}" height="{@height}" title="{@base_name}" alt="{@base_name}" />
注:
s9e/TextFormatter
的 Configurator 工作有一定的性能开销,Flarum 为此加入了缓存机制、并用到 Template 自动编译出来的 PHP Renderer,所以当 Template 发生改动时,我们需要手动清理一下缓存、使其重新生成对应的 Renderer。
参考 flarum/bbcode 的做法,这俩者的改动,可以直接在 extend.php
增加一组 Formatter 的 Extender,具体逻辑再在两个 class 分别实现。参见:extend.php
(new Extend\Formatter)
->render(FormatImgs::class)
->configure(ConfigureImgs::class),
具体实现上,这里文章关注的是一个最终的思路,细节就不再赘述了。具体改动在这里:FormatImgs / ConfigureImgs。由于我实现中途在 Tag 的定制上有些理解上的偏差,实现的代码有些问题,后来再基于此加了个 img-proxy 压缩图片的优化,顺便修复了问题,所以代码上带着两个特性,可能有些混杂。
另外在实际处理中遇到一个问题是,在写死了 img 元素的 width / height 属性的情况下,当图片的宽度超过容器的 max-width
的时候,会导致一个和默认不太一致的情况,图片宽度压缩为容器最大宽度、但高度仍然还是图片原始高度,不写死 width / height 不会这样。
针对这种情况,参考 Google 的文章,我们通过 css 的方式去解决,只在有 width / height 信息都具备的情况下才会应用;并且额外加上一个 object-fit
避免内容的拉伸变形(变形的图片在帖子展示的场景意义不大)。
// 当有指定 width / height 的情况下,把高度设置为 auto
.Post-body img[width][height]:not([width=""]):not([height=""]) {
height: auto;
}
// 避免变形
.Post-body img {
object-fit: contain;
}
至此,在展示这一侧我们已基本打通,接下来是 width / height 的获取姿势。
宽高的获取
关于这里的 <img />
的 width / height 信息的来源,这里有几种 case:
- 指定了 width 和 height
- 只指定了 height,未指定 width
- 只指定了 width,未指定 height
- width、height 均未指定的情况
对于 case 1 和 2,由于高度已指定,不会再造成额外的偏移,可以不做处理。
对于 case 3,用户指定了 width,要取到合适的 height,需要获得图片的宽高比算出;对于 case 4 两者都没有的情况,则需要图片原始的宽高(相当于在浏览器下载图片之前提前准备好应有的信息)。以 URL 为输入,把这个获取图片宽高的过程单独抽出来(这里起名为 fetchSize
),由此得到以下逻辑:
function processAttributes($url, $attributes) {
if (!empty($attributes['height']))
return $attributes;
$size = $this->fetchSize($url);
if (!$size)
return $attributes;
if (!empty($attributes['width'])) {
$attributes['height'] = floor($attributes['width'] * $size['h'] / $size['w']);
} else {
$attributes['width'] = $size['w'];
$attributes['height'] = $size['h'];
}
return $attributes;
}
接下来实现这个 fetchSize
方法,通常来说图片体积较大,如果每个图片都随着帖子请求过程同步下载一次,显然不是很吃得消,并且这个优化地位上只是一个锦上添花,我们通过异步的方式拉取即可。Flarum 依托的 Laravel 提供了一个任务队列的接口,可以直接新建一个负责拉取图片尺寸的 Job,让队列的消费进程去干这个拉数据的事情,其中通过 cache 的方式去存储和读取图片的宽高信息。当想要的图片宽高不在 cache 中时,给队列推送一个新的 Job,在后台异步拉取,成功后下一次就不再抖动了。
核心逻辑在这里:fetchSize,Job,其中 Job 的获取宽高的任务引用了一个第三方库 marc1706/fast-image-size,在不下载完全的情况下抓到图片的 size,恰合我需要,就不用自己写了,感谢社区。
public function handle(Repository $cache)
{
$size = self::$fastImageSize->getImageSize($this->url);
$key = FormatImgs::getImageCacheKey($this->url);
if (!$size) {
$cache->set($key, [
'failed' => true,
], 28800);
} else {
$cache->set($key, [
'w' => $size['width'],
'h' => $size['height'],
]);
}
}
细节上有几个考虑点:
- Flarum 需启用异步的任务队列,以免阻塞正常的请求响应(这里用 blomstra/flarum-redis 扩展实现)
- 避免图片 URL 长度过长带来问题,通过 hash 生成固定长度的 cache key(这里用的 sha256)
- 避免失败反复拉取,失败的情况做个标记,并注意 cache 的过期时间
- 避免这个请求机制被攻击者利用,针对域名做一个白名单,只有白名单的图片域名才应用此优化
完成了 fetchSize
后,再在 renderingCallback
针对 IMG
和 UPL-IMAGE-PREVIEW
标签做处理,完成抓取 width / height 的逻辑的接入。
public function __invoke(Renderer $renderer, $context, $xml, Request $request = null)
{
$tempXML = Utils::replaceAttributes($xml, 'IMG', function ($attributes) {
return $this->processAttributes($attributes['src'], $attributes);
});
$resultXML = Utils::replaceAttributes($tempXML, 'UPL-IMAGE-PREVIEW', function ($attributes) {
return $this->processAttributes($attributes['url'], $attributes);;
});
return $resultXML;
}
总结
总的来说这是一个临时的方案,跑通了一个基本流程,当然也意味着为未来渲染相关的优化提供了可能性。
可以发现的是,这里的处理过程并没有覆盖所有的情况,对于 layout shift 的问题而言只是做些缓解,彻底完美解决目前看成本很高,但其实只要能覆盖 90% 的场景,基本已经符合我的预期。越接近完美的 ROI 会越低,过分纠结于现实中发生概率极低的细节,更多时候是个无底洞。
长远的完美主义视角来看,一个需要考虑的点是,基础的 Markdown 语法其实缺少图片尺寸相关的表达,如果需要对此做扩展的话,需要约定一些新的语法,并在前端编辑器在交互和 UI 层面去补齐。于此对应的工程量相应地增大,于当前力量相对有限的 Flarum 社区而言,暂时还不是很现实。
进一步的优化方向,我想一方面可以是通过定时任务的方式,定期刷数据到缓存中;另一方面也可以去通过改进输入的过程,让图片能带上一些宽高信息,比如说针对某个帖子做一些扩展字段,存储相关的信息,就不需要访问用到的时候才做补偿。