本文首发于 vivo互联网技术 微信公众号
链接: https://mp.weixin.qq.com/s/rSpWorfNTajtqq\_pd7H-nw
作者:悟空中台研发团队

一、背景

移动端网页的加载速度对用户体验极为重要,是影响页面转化率的关键因素,H5 活动页往往使用大量的图片素材来丰富活动效果,素材加载的快慢会对用户感知造成重要的影响。

在《悟空活动中台 - H5 活动加载优化》一文中我们提到过图片压缩也是提升悟空中台产出 H5 页面加载性能的重要手段之一,对本篇将从技术选型、架构设计到方案落地,全方位的呈现悟空活动中台基于 WebP 的图片高性能加载方案。

为什么要做图片加载性能优化?

包含了大量图片素材的 H5 页面,呈现给用户之前,至少要等待首屏加载完成;要提升加载速度,一方面请求的响应速度要足够快,另一方面要尽量减小传输的数据量。

二、方案选型

1、演进

原始做法是:拿到图片文件后,使用图片压缩工具进行压缩,页面再引入压缩后的小体积文件;

该方案存在严重的问题:效率低下 ——需要开发者或者设计师针对每张素材图进行手动压缩、肉眼审核质量、压缩得到的文件手动上传。

我们从高清晰度高压缩比小体积的诉求出发,最终选择了使用 WebP 作为首选图片文件格式。

2、为什么是WebP

WebP 是 Google 推出的一种同时提供了有损压缩与无损压缩(可逆压缩)的图片文件格式。派生自影像编码格式 VP8,被认为是 WebM 多媒体格式的姊妹项目,是由 Google 在购买 On2 Technologies 后发展出来,以 BSD 授权条款发布。

WebP 的优势体现在它具有更优的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量;同时具备了无损和有损的压缩模式、Alpha 透明以及动画的特性,在 JPEG 和 PNG 上的转化效果都相当优秀、稳定和统一。

相比于其他相同大小、不同格式的压缩图像,WebP 格式的图片拥有更小的体积以及更高的质量,优势十分明显。

下图是一些实测案例:

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第1张图片

使用 WebP 对图片进行有损压缩,在默认配置 75% 的压缩比下,可以将 PNG 图片大小压缩至原图体积的 13% 左右,JPG 图片甚至可以压缩至原图体积的 10% 左右(可参考官方测试页面),实际效果显著。

三、图片服务

1、素材服务

悟空中台的素材服务架构如下图所示,在 node server 节点中,我们集成了图片转 WebP 以及转码后文件存储的服务。

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第2张图片

2、图片压缩

图片压缩服务实现了将用户上传的图片数据,进行格式校验WebP 格式转码上传文件服务器以及存储的过程。

使用 cwebp 进行压缩

cwebp 是 Google 官方提供的用于将 PNG、JPEG、TIFF 或原始 Y'CbCr 格式的文件压缩转换为 WebP 格式的命令行编码工具(安装方法请参考官网安装说明)。

使用方法如下:

cwebp [options] input_file -o output_file.webp

其中 options 是压缩参数配置,包含是否启用无损压缩 -lossless ,压缩系数 -q(0~100) 等,如使用 80 的压缩系数对目标文件进行有损压缩:

cwebp -q 80 image.png -o image.webp

Node 服务使用 cwebp-bin

cwebp-bin 模块提供了 Node.js 使用 cwebp 能力进行图片压缩转码的接口,我们的图片压缩服务引入该模块模块实现常见格式图片到WebP的转码。

(1)工具安装

首先需要在服务器执行下述指令以安装模块内部集成的 WebP 工具程序(libwebp-x.x.tar.gz):

npm install --global cwebp-bin

(2)网络优化

在实际使用时,打包上线时会偶发该安装包资源请求失败的问题;为了安装过程的顺利进行,悟空中台的开发者将该安装包的url由原github下载地址改为了更加稳定的google官方下载地址:

// node_modules/@vivo/cwebp-bin/lib/install.js - line 14

binBuild.file(path.resolve(__dirname, '../vendor/source/libwebp-1.1.0.tar.gz'), [
  `./configure --disable-shared --prefix="${bin.dest()}" --bindir="${bin.dest()}"`,
  'make && make install'
]).then(() => { 
  ...
}).catch(error => {
  ...
});

改为

// node_modules/@vivo/cwebp-bin/lib/install.js - line 14
var cfg = [
  './configure --disable-shared --prefix="' + bin.dest() + '"',
  '--bindir="' + bin.dest() + '"'
  ].join(' ');

var builder = new BinBuild()
.src('http://downloads.webmproject.org/releases/webp/libwebp-0.5.1.tar.gz')
.cmd(cfg)
.cmd('make && make install');

return builder.run(function (err) {
  ...
});

(3)图片压缩

图片压缩服务中使用以下代码调用 cwebp 工具进行原图到 WebP 的转码:

const {execFileSync} = require('child_process');
const cwebp = require('cwebp-bin');

execFileSync(cwebp, ['input.png', '-o', 'output.webp'])

压缩形式选取

通过上文我们了解到,WebP支持 有损压缩无损压缩 两种形式,下面我们将针对性的测试两种压缩形式的差异并选出适合的方案。

之所以要对比有损与无损的区别,主要是考虑到时间上的效率和空间上的节约。如果在损失 20~30% 的精度后,用户的肉眼上难以区分,那么这个精度的损失就是有意义的,因为相对于无损压缩,有损压缩带来的体积的缩小以及压缩时的效率,都比无损压缩更适合用于企业的生产模式下。

我们选取了特点分别为 色彩单一色彩较为丰富色彩极为丰富 的三张图片进行测试:

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第3张图片

下面列出了上述图片分别使用 WebP 无损和有损压缩进行测试的样本数据。

(1)WebP无损压缩:

execFileSync(cwebp, ['-lossless', filePath, '-o', webpPath]);

结果统计

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第4张图片

(2)WebP有损压缩(90%压缩率):

execFileSync(cwebp, ['-q', '90', filePath, '-o', webpPath]);

结果统计

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第5张图片

根据上面两份测试数据可以得出,对于 同一张图片

  • 压缩比 角度来看,90%压缩率的有损压缩得到的图片体积小于无损压缩产出图片体积的 20%
  • 压缩时间 角度来看,90%压缩率的有损压缩耗费的时间是无损压缩 20% 以内。

对于 不同图片,色彩 越丰富,压缩花费的 时间越长压缩比越小;甚至会出现压缩的到的图片体积超过原图的情况(具体原因见下文)。

通过以上测试数据反映的结果来看,有损压缩 的优势更大。

压缩率选取

使用 WebP 有损压缩来进行图片的压缩,就不得不考虑接下来的问题:WebP 压缩比设置为多少才是最佳实践?

同样的,我们对上述图片进行了以下抽样数据对比:

(1)Webp 有损压缩(90%压缩率):

execFileSync(cwebp, ['-q', '90', filePath, '-o', webpPath]);

结果统计

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第6张图片

(2)Webp 有损压缩(默认值 75%压缩率):

execFileSync(cwebp, ['-q', '75', filePath, '-o', webpPath]);

结果统计

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第7张图片

为什么要拿 75% 压缩率来做对比?原因是 cwebp 有损压缩的默认压缩率是 75%,这个比例也是通常情况下官方推荐的。

但是在实际业务场景下,75% 的压缩比例并不能满足产品需求。比如说一张图片经过压缩后同时在移动端和 PC 端使用;或图片的色彩空间尤其复杂等等这些情况,再经过 75% 的有损压缩,我们观察到色彩对比度明显的图片局部有模糊的情况。

经过与设计师同学一起反复的测试实验,我们使用了 90% 的压缩率来代替默认的 75% 。此时转换后的图片与原图片结构化差异值 SSIM 不会小于 0.88 ,视觉效果上用户基本发现不了图片已经进行了压缩。

结构相似性指标(英文:structural similarity index,SSIM index)是一种用以衡量两张数位影像相似程度的指标。当两张影像其中一张为无失真影像,另一张为失真后的影像,二者的结构相似性可以看成是失真影像的影像品质衡量指标。相较于传统所使用的影像品质衡量指标,像是峰值信噪比(英语:PSNR),结构相似性在影像品质的衡量上更能符合人眼对影像品质的判断。

关于 WebP 压缩质量与 SSIM 的比例关系,请参考 Google 官方说明 WebP Compression Study。

我们可以通过在 cwebp 的执行命令中加入 -print_ssim 选项,令压缩结果中呈现 SSIM 信息:

await execFileSync(cwebp, ['-print_ssim', '-q', '90', filePath, '-o', webpPath])

执行输出信息:

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第8张图片

WebP 压缩后反而比原图更大?

我们在测试的过程中还观察到有一些图片转换为 WebP 格式后得到文件体积比原图更大。经过查阅 Google 官方文档,得出是由于格式差异以及转码算法导致的:

WebP 的压缩率设置超过 75%时,在遇到在遇到一些特殊编码的图片时,会调整压缩时的算法,如:

  • 当图片的编码类型处理后发生变化时,压缩后的图片体积就会变大。比如说编码类型从索引类型变化到了真彩类型,这种场景下压缩时需要处理的像素点数就会大三倍,所以压缩图片的体积就大了。
  • 当原图片中重复的颜色数目比较多时,Webp 有损压缩时会根据原像素值计算出新的像素值,而压缩时重点会处理的就是重复的颜色数目,所以压缩后的图片体积自然就大了。
  • 当原图中包含透明管道时,由于 Webp 并不支持灰度图带上透明通道这种类型,带上透明通道就将格式固定成了 RGBA 格式。因此导致了要保存的数据变大。

面对这个问题,我们与设计和产品同事共同制定了相应策略:如果压缩后的文件体积大于原图,则使用原图。

3、服务流程

在确定合适的压缩比例和压缩方案后,就可以对图片压缩服务进行整体设计,流程如下:

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第9张图片

  1. node 执行 cwebp 指令对图片文件进行转码;
  2. 当转码后的图片体积大于源文件时,在 WebP 图片的文件名后追加“nwebp”字符串标记,以便前端识别;
  3. 将编码后的 WebP 文件和源文件一同上传至文件服务器,并拿到返回的 URL;
  4. 将图片名称、存储资源路径等存储至素材中心服务数据库中;
  5. 存储完成后将图片名称、存储资源路径等通过接口返回前端展示。

四、页面逻辑

1、优先使用WebP

前端页面策略是当网页运行在支持 WebP 格式的宿主环境(如 Chrome、Android Webview 等)中时,优先使用 WebP 图片资源,在不支持的宿主环境中,使用原始图片资源。

(1)判断宿主环境是否支持WebP

页面首先需要判断当前宿主环境是否支持 WebP :

const supportWebP = (function () {
  var canvas = typeof document === 'object' ? document.createElement('canvas') : {}
  canvas.width = canvas.height = 1
  return canvas.toDataURL ? canvas.toDataURL('image/webp').indexOf('image/webp') === 5 : false
})()

(2)素材加载

前面讲解了后台图片压缩和存储服务的设计,接下来我们来一起了解一下前端逻辑上是如何加载 WebP 图片的。其流程如下图所示:

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第10张图片

(3)使用指令获取图片url

获取图片 url 的方式有多种,我们的需求是在图片资源加载前获取真实的图片 url,并对其进行处理,而 Vue 提供的自定义指令可以帮助我们以侵入性极小的形式的拿到目标元素的相关信息。

这里我们使用 bind 指令进行一次性的初始化设置,在当指令第一次绑定到元素时调用,通过获取到元素关联的素材的 url,以 img 元素为例:

bind: function (el, binding) {
  if (el.tagName.toLowerCase() === 'img' && el.src && el.src.indexOf('data:image') === -1 && supportWebP) {
    // 通过 src 属性获取 img 元素关联的图片地址
    var _src = el.src
    // ... 对img的后续处理
  }
}

悟空活动中台 - 基于 WebP 的图片高性能加载方案

2、处理图片 url

首先判断当前 url 中是否有素材上传时标记的“nwebp”字样,如果有则说明该图片转为 WebP 格式后体积反而大于原图,此时无需使用 WebP 素材替换原有素材;否则,则加载体积更小的 WebP 文件代替原素材文件。

然后判断当前运行环境是否支持 WebP 格式图片的渲染,如果支持,则加载 WebP 素材资源,否则使用原文件链接。

(1)img 元素处理

我们在 img 标签上添加上文定义的 v-webp 指令如下:

在 img 元素的 create 阶段, v-webp 指令被 bind 并执行定义好的 hook。

在 hook 中,我们对于 img 元素我们可以根据 el.src 获取到元素关联素材的 url,当判断需要采用 WebP 格式文件时,在原素材 url 后拼接.webp,从而使得对应图片元素加载的是 WebP 编码后的素材:

// ... 对img的后续处理
// 带有 nwebp 标记的图片不做转换
if (_src.indexOf('nwebp') > -1) {
  return
}
let webpSrc = ''
if (_src.indexOf('.webp') > -1) {
  webpSrc = _src
} else {
  webpSrc = _src + '.webp'
}
el.src = webpSrc
el.onerror = function() {
  // WebP加载失败则回退至源文件
  el.src = _src
}

对于运行环境不支持 WebP 加载的情况,则无需做任何处理,直接加载原图即可:

if (!supportWebP) {
  return
}

(2)background-image 处理

对于 img 之外的元素,我们在 v-webp 指令中传入要作为 backgroundImage 属性值的 url:

在 hook 中,根据 binding.value 获取指令的绑定值,即图片 url,当判断需要采用 WebP 格式文件时,在原素材 url 后拼接“.webp” 构造页面用 url,否则直接使用原图 url,然后为该 DOM 元素设置内联的 backgroundImage style 即可:

if (supportWebP) {
  el.style.backgroundImage = 'url("' + webpSrc + '")'
} else {
  el.style.backgroundImage = 'url("' + binding.value + '")'
}

五、提升兼容性

WebP 格式虽然优点众多,但是有一个严重的问题—— 兼容性 并不理想。下面我们将从 “扩展WebP兼容范围” 的诉求出发,探索 前端解码WebP文件 的可行性。

1、WebP的兼容性问题

WebP 格式虽然存在压缩率高、体积小等优势,但是其自身并不是通用浏览器图片格式规范,像 Safari 和 FireFox 等宿主环境均没有很好的支持该格式(参考自can i use):

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第11张图片

为了保证悟空中台产出的专题页在更多的浏览器中能够以更快的速度加载、渲染,我们又向前走了一步,对 WebP 格式的纯前端解码做出了下面的探索。

2、在页面解码

核心理念是将 WebP 图片作为传输介质,保证了页面图片数据的下载速度;在拿到 WebP 图片后,对于不支持的宿主环境,将 WebP 图片进行解码成通用的 Base64 格式进行渲染。

(1)使用JS解码

纯前端是否可以实现 WebP 格式到 Base64 格式的解码呢?Google 官方团队提供了 js 解码 WebP 的库—— libwebp.js ;但是我们随机挑选一些 WebP 图片实际测试下来发现性能欠佳:

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第12张图片

该方案下 WebP 图片实际的加载时间为 网络数据传输用时 + 解码用时,面对性能要求较高的场景,WebP 的加载速度真要受限于 JS 不擅长的编解码运算能力了么?当我们再次研究 libwebp 的资料时,浏览到下述说明:

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第13张图片

webp_js 还有一个 WebAssembly 版本。

(2)使用WebAssembly提升解码性能

WebAssembly 作为 Web 标准,在各个浏览器均有较好的支持,兼容性远强于WebP:

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第14张图片

WebAssembly 可以作为 C/C++/Rust 等语言的编译目标在浏览器环境中以接近原生的速度运行,计算性能要远远优于 JavaScript。

WebAssembly的工作流程如下(图片来自MDN):

悟空活动中台 - 基于 WebP 的图片高性能加载方案

其中 胶水JS(JS“glue”code)的作用是提供 JS 调用 wasm 能力的接口。

编译并测试 libwebp

我们将 libwebp 编译成 wasm 文件供 JavaScript 调用,提供高速解码 WebP 的能力。具体的编译过程可以参照 libwebp/webp_js 的编译说明 ,编译环境建议使用linux/unix,其余步骤此处不再赘述。

编译后我们得到了 wasm 文件(gzip压缩后体积51kb)和胶水 js(gzip压缩后体积44kb) ,然后使用上述同样的素材进行性能测试结果如下:

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第15张图片

由以上测试基本可以得出:

  • 当 WebP 素材较小时,wasm 解码相相对于纯 js 解码,可以节省接近一半时间;
  • 当 WebP 素材较大时,wasm 方案可以使解码速度提升超过 100%,且随着素材增大,提升越明显。

有了 WebAssembly 的加持,我们将原有图片加载流程进行了如下图所示升级:

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第16张图片

以 img 元素为例,代码处理逻辑如下:

// 如果当前浏览器环境不支持WebP格式,则使用wasm将WebP文件解码为Base64
if (supportsWebP) {
  el.src = webpSrc
} else {
  // 使用fetch请求拿到WebP文件
  const res = await fetch(webpSrc)
  // 设置拿到的文件的编码,以符合wasm解码的入参条件
  const webp_data_buffer = await res.arrayBuffer()
  const webp_data = new Uint8Array(webp_data_buffer)
  // 调用碎wasm编译生成的胶水js的解码方法,将解码后的Base64值作为图片素材的url使用
  el.src = wasmDecode(webp_data)
}

3、效果对比

我们构造了一个图片素材较多的H5专题在 Safari 中测试,效果如下(为了更好的体现加载过程,下放动图相对实际速度均 放慢了3倍 ):

1、页面元素不添加 v-webp 指令(加载图片原文件):

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第17张图片

2、页面元添加 v-webp 指令(前端解码WebP):

悟空活动中台 - 基于 WebP 的图片高性能加载方案_第18张图片

可以看出在不支持WebP的宿主中,使用了 v-webp 指令后,页面的 响应速度 (白屏时间短)和图片 渲染速度 均有较为明显的提升;至此,我们已经设计并实现了一套相对完善的图片素材加载性能优化方案。

六、小结

悟空活动中台从提升 H5 页面图片加载性能的诉求出发,历经:

  1. 压缩格式选择
  2. 压缩形式和压缩率选取
  3. 前端指令集成
  4. 提升兼容性

等一系列手段,探索出一套基于 WebP 的图片高性能加载方案,更好的赋能了 H5 活动的开发和运营。悟空中台开发团队将永不止步,持续研究和思考,为大家带来更多的实战技巧,感谢您的阅读。

【悟空活动中台】系列往期精彩文章:
《揭秘 vivo 如何打造千万级 DAU 活动中台 - 启航篇》 主要为大家讲述 vivo 活动中台的能力与创新。
《悟空活动中台 - 微组件状态管理(上)》介绍了活动页内 RSC 组件之间的状态管理和背后的设计思路。
《悟空活动中台 - 微组件状态管理(下)》探索平台和跨沙箱环境下的微组件状态管理。
《vivo 悟空活动中台-基于行为预设的动态布局方案》本文以“满屏”场景下的页面布局思考为切入点,以微组件为元素单元,提供了一种新的布局方案设计思路——基于行为预设的动态布局方案,并详细的分享了设计目的及具体实现方案。
《vivo悟空活动中台 - 微组件多端探索》是基于自助多端扩展,也就意味着多端 微 组件选择越丰富,内容越通用,玩法越多样,产品价值也会越高。
《悟空活动中台 - H5 活动加载优化》从提高资源请求速度,资源压缩、缓存、渲染等多种角度出发,寻找悟空活动专题加载优化方案。