金数据技术博客 · №18

一个 Button,440 KB —— Server Component 里 import antd 的代价

· flanker · frontend / React / nextjs · English version

English Version

TL;DR

背景

金数据的新版前端是一个体量不小的 Next.js 应用,系统内页面的 UI 组件库用的是 antd。最近在做 bundle size 优化时我们发现:有些路由明明只用了一两个 antd 组件,产物里却出现了完整的 antd。

排查下来,源头是一行看起来毫无问题的代码——在 Server Component 里 import 了 antd。

最小复现

创建两个 Next.js 项目(antd 6.2.0 / Next.js 16.2.6 / React 19.2.1),只渲染一个 Button 按钮。

A. Server Import

// app/page.tsx(Server Component)

import { Button } from 'antd'

export default function Page() {
  return <Button type="primary">hello</Button>
}

B. Client Import

// app/page.tsx(Server Component)

import ClientButton from './ClientButton'

export default function Page() {
  return <ClientButton />
}

// app/ClientButton.tsx

'use client'

import { Button } from 'antd'

export default function ClientButton() {
  return <Button type="primary">hello</Button>
}

两个页面实际渲染出的 HTML 一字不差:

<button type="button" class="ant-btn css-var-root ant-btn-primary ant-btn-color-primary ant-btn-variant-solid"><span>hello</span></button>

但 JS bundle size 差别巨大:

A. Server Import B. Client Import
JS Bundle (RAW) 1458 KB 176 KB
JS Bundle (gzip) 440 KB 56 KB
打进产物的 antd 组件目录 76 个 12 个

开发环境的对比

Server Import:54 个请求,5486 KB,stepstabletransferupload…… 页面里根本不存在的组件全都被加载了。

dev server import

Client Import:23 个请求,4446 KB(开发模式未压缩),没有多余的组件。

dev client import

Production Bundle 分析对比

Server Import:All Route Modules 3.54 MB(压缩前),1545 个模块,包含所有 antd 组件。

prod server import

Client Import:All Route Modules 1.05 MB(压缩前),365 个模块,仅包含 Button 和必须的 antd 依赖。

prod client import

为什么会这样

antd/es/index.js 的第一行是:

"use client";

所以 antd 的桶文件(Barrel)本身就是一个 client module

Server Component 一旦 import 它,Next.js 就会在 antd/es/index.js 这个模块上建立 RSC client reference——整个 index.js 被当作一个 client 入口包裹进去,它 re-export 的 76 个组件全部成为可达代码。

optimizePackageImports 的原理是把桶文件 import 改写成对具体子路径的 import,但在这里改写无法生效,整个 barrel 都进了产物。

这个问题不只属于 antd:任何桶文件顶部带 "use client" 的组件库,在 Server Component 里整包 import 都会踩中同一个坑。

怎么修

把 antd 的使用挪进一个 Client Component,页面 / layout 保持 Server Component。渲染结果完全一致,bundle 里只剩下真正用到的组件。

在金数据的代码库里,我们把这条规则写进了架构约束:app/ 路由层禁止 import antd,antd 的配置(Provider、message / notification 等)统一放在 views 层的 client 组件里。

其他方式

直接 deep import 也可以绕过桶文件:

import Button from 'antd/es/button'

但我们不推荐这个方式:antd/es/button 不是公开 API,且不是所有组件都有对应的路径。