再谈 web 加载性能优化——字体裁剪

Posted by Wanxiang Long(Ryuurock) on 2021-12-27

为什么要说再谈呢,顶着强烈“羞耻感”翻了翻以前写的文章,发现居然有 前端疑难问题整理-优化篇。里面好多手段这么多年过去了,依旧非常实用,甚至有些直接手段都被框架内置了。如:nextjs提供的 Image 组件拥有开箱即用的优化手段:

  • 默认的懒加载(即图片在视口外不加载图片)
  • 内置的图片高保真压缩能力
  • 响应式图片尺寸
  • 根据 ua 返回不同格式的图片(如 webp)

最近接到了做公司官网 SEO 优化的需求,排在第一位的任务则是“提升整站的加载速度”。并提供了充足的证据证明访问确实很慢——https://pagespeed.web.dev/ 的手机跑分只有 20 多分。经过一顿优化,分数来到了惊人的 60 分左右。

分数这么低我猜测主要是 light house 的移动端模式采用了 4x 慢速的cpu,加上本就是免费的服务机器的CPU性能比较一般,所以通常首屏有图的网页跑分都比较拉胯。参考业界标杆苹果官网也就 50 多分。

既然这个分数是一个可量化网站加载速度的指标,那么我们就直接面向跑分优化。

这里有几个术语,FCP(First Contentful Paint)、LCP(Largest Contentful Paint)和 Time to Interactive。这几个指标在light house 跑分中占了较高的比重,我们也主要是面向这几个指标做优化。

因为我们用的是 nextjs,SSR 会直接以最快的速度将html扔给浏览器,然后再开始请求其他的资源 css js等。相较于传统的 CSR,前者减少了js下载、解析、执行的时间,这个时间可能会达到秒级。

开始查找大文件

大文件是影响网页加载速度的罪魁祸首,占用了带宽则可能导致其后面的css和js一直排队。按下F12,勾上 disable cache,刷新页面,将 size 那列倒序排列,可以看到最大的文件是哪个。公司的官网图片是最多的,行业的原因设计上还有一些个性化的字体,1.7MB.woff2 赫然出现在第一位,其次则是 google analytics 等第三方 SDK,以及一些首屏必须的大图了。由于都使用了 next 的 Image 组件,由于图片的优化空间已经非常小了,所以我们本次优化的主要目标还是在字体的裁剪。

寻找工具

字体文件应该是一个字符集合,里面包含了常用文字、字母和符号等等,网站没有用到的字符字母和符号等,理论上就可以从这个集合里踢出去。解决办法找到了,然后就是找能够处理这个问题的工具:

fonttools是我一开始就找到的工具,字体子集化是它的一项小功能,试用之后非常符合我的要求,也没有发现什么bug,但是它是python开发的,只能用 nodejs 的子进程运行启动,它本身也有依赖,非 node 的生态无法跟着项目走,这对一个 web 项目来说不是很友好。

font-spider 它是基于 fontmin 做的一个自动化方案,需要一个静态的html文件,对我们的场景来说不是很友好

fontmin 是一个国内工程师做的,有 4k+ 的 star,应该非常优秀。但就是这个名字似乎对搜索引擎不太友好,或者官网的SEO没有做好导致我一开始并没有搜索到,直到后面做完了这个事情又翻了一下 font-spider 的依赖才发现的。

文本的提取

有了工具后问题其实只解决了一半,如何确定哪些字体用到了哪些文本上?

  1. 人工筛选
  2. 文本常量提取,饿汉模式,去重后一次性扔给工具,或者提取后打标记筛选后扔给工具
  3. 自动化按需提取,只提取对应用到相应字体的文本

人工筛选

我相信没有这样呆的人,除非你只有一个页面,几个文字。并且这个方案在文本改变后这样的工作要再来一次。

文本提取常量

这个方式是比较常规的做法,但是如果有较多的页面那么工作量是极其巨大的,并且打标记这个非一次性的动作在文字改变后也是需要在检查一次的,如果是这样我宁愿不做这条优化。不过我还是尝试了一下,由于我们官网是多语言的,文本都在 json 文件里,写了 nodejs 的脚本跑了一圈,最大的 1.7MB 的字体文件在子集化后变成了600KB 左右,思考再三还是弃用了这个方案,因为在我看来效果虽然有,但是并不明显,不是一个 awesome 的结果。

自动化按需提取

这个方案是怎么来的呢,看了一圈 font-spider 源码后,猛然想起之前做 html 编程环境时拷贝 style 到 iframe 时用到的一个 api——document.styleSheets,它返回了当前页面的所有样式表,包括style 标签,link 标签(仅 rel=“stylesheet”)。

有了它我们可以拿到页面所有 css 选择器,及其选择器内的 css 键值对,那么我们可以遍历这些选择器和它的值,过滤 font-family 有值并且值匹配了我们需要优化的字体名,然后我们就可以用这个选择器去页面上查找元素,提取到我们要的内容。

到这里我们还是会觉得很麻烦,难道要每个页面执行一下这个脚本再把执行结果复制出来放到一起再去个重?是的,但是不是我们去做,我们交给机器去做。

一切交给机器

puppeteer 是一套基于 nodejs 发开的用于操控浏览器的 api,它几乎可以模拟所有人的动作去操控 chrome、fire fox 等浏览器。nextjs 的构建产物里有一个 .next/build-manifest.json 这样的json文件,里面包含了页面的所有路由,于是我们就可以拿到这个路由列表使用 puppeteer 来运行上面的脚本,这样就可以把所有页面使用了自定义字体的文本都提取出来了。

等等,还没完。对于需要交互后才挂载的部分,需要继续处理了。比如每个页面再维护一个 clickSelectors 数组,这个数组里面存放了需要点击的元素的选择器,页面加载后依次点击元素,等待预期的内容出现。

结果

字体文件优化后是 95KB,裁减掉的部分占比达到了惊人的 94%。它的效果是显而易见的,重新跑分移动端来到了40分左右。

其他优化

第三方的 sdk 也是拖慢我们的原因之一,ga 的 sdk一共有100多KB,我们在页面 onload 之后延迟若干秒之后再去初始化 ga,这样跑分又能多几分。其他的一些无需首屏加载的模块都可以采用这种方式做优化,全部做了之后首屏的资源体积肉眼可见的减小,自然 light-house 的跑分顺其自然就上去了。

其他一些感知不强的优化就不讲了,可能做了也会淹没在每次测试的网络波动里。

由于用react 的服务端渲染,客户端有一个 注水 的过程,以及其他组件初始化执行的过程,所以在 light-house 的测试里也会占用主线程较多的时间导致扣分。这部分是规避不掉的,除非我们回到 jQuery 的时代。

总结

web 页面加载的性能优化方向其实非常多,但是很多手段已经内置在基础设施里了。
比如上面提到的 nextjs,它的 Image 组件对图片的优化开箱即用,它的 SSR 是 react 生态的天花板,还有它路由级的 js 拆包方案等等。
比如已经是压缩后的 woff 字体格式,即使你开启 gzip 等也是没有效果的。
再比如新的 http2 协议,比如 cdn 等等。我们能做的就是从我们做了的事情里下手(搁这儿搁这儿。。。),比如忘记压缩的图片,一个未裁剪的自定义字体,一个太重的工具包,一个体积不太友好的 SDK 等等。