---
title: 缓解 Flarum 图片加载时的布局偏移
date: 2023-06-09T20:00:00+08:00
tags:
  - Flarum
  - Web
  - 技术

---

现代的 Web 开发中，[CLS](https://web.dev/cls/)（Cumulative Layout Shift）是一个关键的 [性能指标](https://web.dev/user-centric-performance-metrics/#types-of-metrics)，它主要关注用户在 Web 网页的使用中，发生意外布局偏移（Layout Shift）、影响用户体验的情况。导致 Layout Shift 的因素很多，这里主要讨论的是图片加载过程的影响。

在维护 [Flarum 0x](https://github.com/0xffff-one/flarum-0x) 时，我遇到了图片元素带来的 Layout Shift [问题](https://github.com/0xffff-one/flarum-0x/issues/31)。DOM 的 `<img>` 元素在图片初次加载时，默认高度为 0，此时浏览器会按照 `<img>` 为 0 的高度去布局页面，当图片加载到有宽高信息的元数据时，浏览器会触发一次重新排版，给 img 附加了正确的高度，此时会导致该元素下方的其他 DOM 元素位置向下偏移。

这样的偏移通常情况不会被留意到，只有锚点在 `<img>` 元素下方时才会暴露出来。Flarum 的问题在于，当我们直接打开指向某楼层的链接，完成了一次指向某楼层的锚点定位，但若这时恰好前面楼层的帖子的图片加载成功，发生高度变化，原本的目标楼层就会一起被顶下去。

在日常使用场景下，因为有浏览器缓存，除了第一次加载外大部分时候图片在渲染之前就已进入缓存，问题不太明显；但在分享的场景下，体验就比较糟糕，尤其新人首次打开的体验会很受影响。

### 解决思路

`<img>` 元素在嵌入图像之前，需要确认其占用的空间，在 CSS 中属于 [Intrinsic size](https://developer.mozilla.org/en-US/docs/Glossary/Intrinsic_Size) 的概念。默认情况下，这层信息会通过待加载的图片的 EXIF 信息来获取；当然也可以手动指定元素的宽高比，使其不受图像加载过程而影响。

手工指定可以有这几种方式：

1. 在 img 中指定：可以声明元素具体的 `width` / `height`，让浏览器提前计算图片的宽高比，以保留展示图像的排版空间，可以避免文档下载过程中的布局偏移。（具体而言除了  `width` / `height` ，还有 `srcset` / `sizes` 也会影响到这里，但相对小众）
2. 在 CSS 中指定：CSS 有提供了一个 `aspect-ratio` 的属性，[兼容性](https://caniuse.com/mdn-css_properties_aspect-ratio) 在新版本浏览器中都还不错（Chrome 88+ / Safari 15+）
3. [padding hack](https://css-tricks.com/aspect-ratio-boxes/)：利用 `padding` 百分比单位基于宽度的特性，用 `padding-top` + `background-img` 的方式，实现固定宽高比的占位元素，并以背景的方式填充图片

无论何种方式，都需要图片的 `aspect-ratio` 信息，从生产的角度去出发看，我们需要明确 `aspect-ratio` 的信息来源。

关于当前 Flarum 帖子内容的 img 元素的来源，梳理下来有这几种：

1. Markdown 的 `![](https://xxxxx)` 图片链接
2. BBCode 插件自带的 `[img]https://xxxxx[/img]` 标签
3. [fof/upload](https://github.com/FriendsOfFlarum/upload) 插件的 `[upl-img-preview url=https://xxx /]` 标签

由此我们的处理思路大致是：

1. 在 HTML 渲染流程补充针对 `aspect-ratio` 的处理逻辑
2. 在 img 元素的来源部分，想办法加上一些宽高比的信息

以上只是抽象而笼统的行动方向，具体的执行依赖于现实的局限，接下来我也针对其做一些展开。

### Flarum 的内容渲染

> 注：以下基于 Flarum v1.8 版本切入，未来 2.x 后的版本可能会有变动
> 

首先我们需要摸清一整个体系的工作流程。关于 Flarum 的内容渲染逻辑，核心采用了一个类库 [s9e/TextFormatter](https://github.com/s9e/TextFormatter)，相比于传统论坛程序的内容渲染而言，它思路相对要特别一些，类似编译器的模式，它把用户输入的内容当作“代码”，解析成一棵“语法树”。帖子数据的存储和处理过程，也是以“语法树”为核心，通过 XML 的形式来表达。

参考 `s9e/TextFormatter` 的 [文档](https://s9etextformatter.readthedocs.io/Getting_started/How_it_works/)，这里涉及到三种形态的文本（Original text / XML / HTML），对应有三个转化过程（Parsing / Unparsing / Rendering）需要维护，它们的关系如下图所示：

![](https://cdn.zgq.me/assets/posts/2023/flarum-img-layout-shift-optimization/image-1.png)

其中 Parsing 过程承担将原文解析为 XML 的角色，类似“编译器前端”，可以对接 Markdown / BBCode 乃至于各种复杂的特殊语法；Unparsing 过程则是负责将解析后 XML 还原回原文。

在作为中间态的 XML 的结构上，每一类内容都有它的一个单独的 Tag，对应内容的基本的元素（图片、视频、链接、各种排版格式等等等等）。类库针对标签的场景，为我们封装了一些工具方法，可以根据需要去改动已有的 Tag，或是定义新的 Tag，也可以对这些 Tag 做一些自定义，考虑安全因素，做一些特殊的规则限定等等。

对于 Rendering 过程，Tag 内部有个 Template 的 [概念](https://s9etextformatter.readthedocs.io/Templating/Introduction/)，Template 基于 `XSLT` 语言编写，类似一个“样式表”的角色，定义最终的 HTML 应该渲染成什么样子。类库的 Renderer 将每一个 Tag 需要用到的 XSLT 的 template 代码汇总一起，交给 [XLST Processor](https://www.php.net/manual/en/class.xsltprocessor.php) 处理。内容 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](https://docs.flarum.org/extend/start#extenders) 用于操作 `Configurator`，我们再通过这个 `Configurator` 的 tags 访问到其 Tag 对象，再重新用 Tag 内自带的 `setTemplate` 方法来重设新的模版。

模版修改前后：

```php
// 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](https://s9etextformatter.readthedocs.io/Internals/PHP_renderer/)，所以当 Template 发生改动时，我们需要手动清理一下缓存、使其重新生成对应的 Renderer。
> 

参考 flarum/bbcode 的[做法](https://github.com/flarum/framework/blob/3757bde4fc43b85278cd12294a2c49f1198a22b5/extensions/bbcode/extend.php#L18)，这俩者的改动，可以直接在 `extend.php` 增加一组 Formatter 的 Extender，具体逻辑再在两个 class 分别实现。参见：[extend.php](https://github.com/0xffff-one/flarum-0x/blob/0509f88e6996f5e8290d26a63ec37bb971df23fa/extend.php#L153)

```php
(new Extend\Formatter)
    ->render(FormatImgs::class)
    ->configure(ConfigureImgs::class),
```

具体实现上，这里文章关注的是一个最终的思路，细节就不再赘述了。具体改动在这里：[FormatImgs / ConfigureImgs](https://github.com/0xffff-one/flarum-0x/tree/ee8319cc69d2cc0f8305ba8f5290f9cd99ff9345/src/Extend/ImageSize)。由于我实现中途在 Tag 的定制上有些理解上的偏差，实现的代码有些问题，后来再基于此加了个 [img-proxy 压缩图片的优化](https://github.com/0xffff-one/flarum-0x/compare/51b9c12...ee8319c)，顺便修复了问题，所以代码上带着两个特性，可能有些混杂。

另外在实际处理中遇到一个问题是，在写死了 img 元素的 width / height 属性的情况下，当图片的宽度超过容器的 `max-width` 的时候，会导致一个和默认不太一致的情况，图片宽度压缩为容器最大宽度、但高度仍然还是图片原始高度，不写死 width / height 不会这样。

针对这种情况，参考 [Google 的文章](https://web.dev/i18n/zh/optimize-cls/#%E7%8E%B0%E4%BB%A3%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5)，我们通过 css 的方式去解决，只在有 width / height 信息都具备的情况下才会应用；并且额外加上一个 `object-fit` 避免内容的拉伸变形（变形的图片在帖子展示的场景意义不大）。

```css
// 当有指定 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：

1. 指定了 width 和 height
2. 只指定了 height，未指定 width
3. 只指定了 width，未指定 height
4. width、height 均未指定的情况

对于 case 1 和 2，由于高度已指定，不会再造成额外的偏移，可以不做处理。

对于 case 3，用户指定了 width，要取到合适的 height，需要获得图片的宽高比算出；对于 case 4 两者都没有的情况，则需要图片原始的宽高（相当于在浏览器下载图片之前提前准备好应有的信息）。以 URL 为输入，把这个获取图片宽高的过程单独抽出来（这里起名为 `fetchSize`），由此得到以下逻辑：

```php
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](https://github.com/0xffff-one/flarum-0x/blob/0509f88e6996f5e8290d26a63ec37bb971df23fa/src/Extend/ImageSize/FormatImgs.php#L128)，[Job](https://github.com/0xffff-one/flarum-0x/blob/0509f88e6996f5e8290d26a63ec37bb971df23fa/src/Extend/ImageSize/Job/FetchImageSizeJob.php)，其中 Job 的获取宽高的任务引用了一个第三方库 [marc1706/fast-image-size](https://github.com/marc1706/fast-image-size)，在不下载完全的情况下抓到图片的 size，恰合我需要，就不用自己写了，感谢社区。

```php
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'],
        ]);
    }
}
```

细节上有几个考虑点：

1. Flarum 需启用异步的任务队列，以免阻塞正常的请求响应（这里用 [blomstra/flarum-redis](https://github.com/blomstra/flarum-redis) 扩展实现）
2. 避免图片 URL 长度过长带来问题，通过 hash 生成固定长度的 cache key（这里用的 sha256）
3. 避免失败反复拉取，失败的情况做个标记，并注意 cache 的过期时间
4. 避免这个请求机制被攻击者利用，针对域名做一个白名单，只有白名单的图片域名才应用此优化

完成了 `fetchSize` 后，再在 `renderingCallback` 针对 `IMG` 和 `UPL-IMAGE-PREVIEW` 标签做处理，完成抓取 width / height 的逻辑的接入。

```php
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 社区而言，暂时还不是很现实。

进一步的优化方向，我想一方面可以是通过定时任务的方式，定期刷数据到缓存中；另一方面也可以去通过改进输入的过程，让图片能带上一些宽高信息，比如说针对某个帖子做一些扩展字段，存储相关的信息，就不需要访问用到的时候才做补偿。
