缓解 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 信息来获取;当然也可以手动指定元素的宽高比,使其不受图像加载过程而影响。

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

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

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

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

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

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

  1. 在 HTML 渲染流程补充针对 aspect-ratio 的处理逻辑
  2. 在 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:

  1. 指定了 width 和 height
  2. 只指定了 height,未指定 width
  3. 只指定了 width,未指定 height
  4. 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,在后台异步拉取,成功后下一次就不再抖动了。

核心逻辑在这里:fetchSizeJob,其中 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'],
        ]);
    }
}

细节上有几个考虑点:

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

完成了 fetchSize 后,再在 renderingCallback 针对 IMGUPL-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 社区而言,暂时还不是很现实。

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