SORCERERXW

对博客网页加载体积的一点优化

最近发现博客网页体积有点太大了,首次加载需要下载 200KB+ 的数据。

Page                                               Size     First Load JS
┌ ● / (364 ms)                                     4.39 kB         237 kB
├   /_app                                          0 B             224 kB
├ ○ /404                                           1.2 kB          234 kB
├ ○ /500                                           1.2 kB          234 kB
├ ● /about (1698 ms)                               366 B           283 kB
├ ● /articles/[id] (147788 ms)                     4.23 kB         287 kB
├   ├ /articles/jike-golang (19000 ms)
├   ├ /articles/build-blog-with-notion (10896 ms)
├   ├ /articles/jikeview (10152 ms)
├   ├ /articles/go-echo-error-handing (9725 ms)
├   ├ /articles/vercel-go-microservice (9300 ms)
├   ├ /articles/go-string-interning (6804 ms)
├   ├ /articles/go-comparable-type (6178 ms)
├   └ [+23 more paths] (avg 3293 ms)
├ λ /sitemap.xml                                   274 B           225 kB
└ ● /tags/[tag] (6682 ms)                          4.42 kB         237 kB
    ├ /tags/engineering (723 ms)
    ├ /tags/android (712 ms)
    ├ /tags/node.js (698 ms)
    ├ /tags/hack (688 ms)
    ├ /tags/network (681 ms)
    ├ /tags/go (664 ms)
    ├ /tags/product (662 ms)
    └ [+5 more paths] (avg 371 ms)
+ First Load JS shared by all                      224 kB
  ├ chunks/framework-fae18336d1dfe3fb.js           42 kB
  ├ chunks/main-91270026f6d0b490.js                28.4 kB
  ├ chunks/pages/_app-9742e6e7f9139000.js          148 kB
  ├ chunks/webpack-7c44f1ec48856c43.js             6.11 kB
  └ css/c4ae7c55caaef550.css                       1.5 kB

通过 @next/bundle-analyzer 可以很清楚地看到在 Client Side 哪些包占用了大量的空间。

下面我会针对以上的分析,对各个包逐一优化。

Syntax Highlighter

首当其冲的就是 Syntax Highlighter,它用来高亮代码块语法,从上面的分析图中可以与 highligh 相关的代码(highlight.js、prism.js)几乎占了半壁江山,那么我们就先从它开始优化。

之前出于简单考虑,页面直接使用了 icon,它需要在运行期引入 theme 和 parser schema,这将会引入不小的开销。

那么是否可以让代码块直接在服务端渲染好呢,这样对于前端来说就是零依赖了?这里,我选择了 icon ,它更加轻量,对于 node 环境渲染更加友好。它可以在服务端基于指定的 language 和 theme 直接将代码渲染出 html,前端只需要将其直接插入页面即可。

目前为了降低 SSG 的复杂度,在 SSG 的时候直接将代码块渲染为纯文本,然后前端在运行时动态地从服务端取当前代码块的高亮结果。

lodash

从 Analyzer 当中也可以看到 lodash 占用了不少空间,理论上我只是使用了部分的 lodash 函数,为什么没有 tree shaking 而是将整个库打包进来呢?经过查询,是因为 tree shaking 只会对 esModule 生效,但是 lodash 并不是,所以只能完整打包。

为了解决上面的问题,lodash 提供了 lodash-es 版本。经过使用,发现虽然 tree shaking 生效了,但是 lodash-es 并不提供 lodash 中的 chain 函数,那么意味着复杂的操作,不能使用链式操作,而需要一层一层地包裹逻辑:

import _ from 'lodash'

_.chain(data)
 .filter(it=>it.ok)
 .map(it=>it.something)
 .flatten()
 .value()
import _ from 'lodash-es'

_.flatten(
  _.map(
    _.filter(
      data, 
      it=>it.ok,
    ), 
    it=>it.something,
  )
)

对于复杂的数据操作逻辑,洋葱圈似的操作麻烦且不够清晰,并不能提现 lodash 的优势。不过也能够理解,毕竟 chain 本身集合了所有的操作,无法移除部分成员函数。

既然 lodash 不能很好地满足我的需求,那么索性不用 lodash 了,基于原生 map-reduce 来操作数据,reduce 本身足够强大,主要的缺点在于代码复用性不强,比如 flatten 操作需要在每一个使用到的地方实现一遍。基于 icon 给出的 snippet 也能很快地完成一个操作。

Chakra ❌ Tailwind ✅

最开始页面当中使用 Chakra UI 作为 UI 组件,它继承了 tailwind 灵活的优点,同时避免了 className 过大。但是在 Bundle 分析的时候,还是发现 Chakra 本身占用了不少体积。

avatar
reduce bundle size
stateOpen
state#4975

解决方案是 Chakra 将各个组件拆分到独立的包内,开发者按需导入。

import sliderTheme from "@chakra-ui/theme/slider"
import accordionTheme from "@chakra-ui/theme/accordion"
import { extendTheme } from "@chakra-ui/react"

但是依然认为其不够轻量,索性放弃了 Chakra UI,换而使用 tailwind,它在编译期编译出 css 文件,在运行期不需要引入额外的 JS 代码,足够轻量。由于 Chakra UI 与 tailwind 有着几乎相同的样式命名,切换起来非常方便。

__NEXT_DATA__

为了提高加载数据,网页大量使用了 Next.js SSG 的功能,即在编译期即渲染出页面,之后只需要从 CDN 上直接下发网页即可。但是为了让 React 顺利 hydrate,Next.js 会将 server side props 放在 __NEXT_DATA__ 当中下发。

即便是实际只使用了其中一小部分数据,也不会 tree shaking。可以想象,如果不加控制地下发太多数据,必然导致 HTML 体积膨胀。所以在 server side populate data 的时候,需要尽可能剔除页面当中不需要的内容,尽可能降低最终包含的数据。

Why does `__NEXT_DATA__` exists? · Discussion #15117 · vercel/next.js

You can't perform that action at this time. You signed in with another tab or window. You signed out in another tab or window. Reload to refresh your session. Reload to refresh your session.

icon

当然,如果对于 SEO 以及加载速度没有要求,将组件转换为异步加载也不失为一个好的方案。目前有两个方案:

一个是现在使用如 SWR 拉取服务端数据,然后在前端渲染出相应的组件。但是这种方案可能会额外引入一些组件依赖,导致网页的加载 JS 膨胀。

另一个方案是 React18 当中引入的 Server Component,可以直接在服务端渲染出相应的组件,直接下发到前端。这个方案看起来比上面的方案更加优雅。

目前我在部分组件上使用了第一个方案,等 Next.js 对 React18 支持更好一点,会实践一下第二个方案。

总结

经过一顿操作,网页的体积得到了明显的降低,首次加载网页的 JS 体积从 200+KB 降低到 70+KB。总结起来就是,尽量不要在 client side runtime 引入太多的组件,尽可能在编译期、服务端完成工作。