cover

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

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)几乎占了半壁江山,那么我们就先从它开始优化。

之前出于简单考虑,页面直接使用了 https://github.com/react-syntax-highlighter/react-syntax-highlighter,它需要在运行期引入 theme 和 parser schema,这将会引入不小的开销。

那么是否可以让代码块直接在服务端渲染好呢,这样对于前端来说就是零依赖了?这里,我选择了 https://github.com/shikijs/shiki ,它更加轻量,对于 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 操作需要在每一个使用到的地方实现一遍。基于 https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore 给出的 snippet 也能很快地完成一个操作。

Chakra ❌ Tailwind ✅

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

Reduce theme bundle size · Issue #4975 · chakra-ui/chakra-ui
Description Hi, I'm @hiroppy from webpack team. First, thank you for creating a wonderful library and I am satisfied with it other than one thing which is the huge bundle size. I want to discuss yo...
https://github.com/chakra-ui/chakra-ui/issues/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? · vercel/next.js · Discussion #15117
Can someone explain why the following element exists on my page: <script id="__NEXT_DATA__" type="application/json"> { "props":{ "pageProps":{} }, "page":"/", "query":{}, "buildId":"mgml30LY3PGj-2I...
https://github.com/vercel/next.js/discussions/15117

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

  • 一个是现在使用如 SWR 拉取服务端数据,然后在前端渲染出相应的组件。但是这种方案可能会额外引入一些组件依赖,导致网页的加载 JS 膨胀。
  • 另一个方案是 React18 当中引入的 Server Component,可以直接在服务端渲染出相应的组件,直接下发到前端。这个方案看起来比上面的方案更加优雅。

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

总结

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