部署日志里出现 Error: The Serverless Function exceeds the maximum size limit of 50 MB(Vercel)、Functions size exceeds maximum allowed 或 Pages output exceeds 25 MiB per file / 20,000 files total(Cloudflare Pages)——这是宿主硬限制,重试再多次也不会过。每家平台都有不同的体积上限:Vercel Serverless 单 function 50 MB、Cloudflare Workers 1 MB(免费 / 10 MB Paid)、Cloudflare Pages 25 MiB 单文件、Netlify Functions 50 MB。本文给一条按”找到大头 → 砍掉 → CI 拦截”的修复路径。
常见原因
按命中率从高到低排。
1. 一个依赖打包后体积惊人
puppeteer(带 Chromium ~170 MB)、canvas、sharp、@ffmpeg/core、tensorflow.js、playwright 这一类原生 / 浏览器引擎依赖是常见元凶。看似只 import 了一个函数,实际把整个二进制都打进了产物。
典型报错:
Error: The Serverless Function "api/screenshot" is 187 MB which exceeds the maximum size limit of 50 MB.
如何判断:du -sh node_modules/* | sort -h | tail -20 找出最大的 20 个依赖,对照部署产物。
2. 静态资源未压缩或被重复打入产物
原图未压缩(4 MB 的 JPG)、字体文件全 weight 全字符集都打进去(每个 woff2 几百 KB)、或者 public/ 和 src/assets/ 同时引用了同一份图片,导致最终 bundle 出现两份。
如何判断:ls -lhS dist/ | head 列出最大的静态文件,目测哪些超 500 KB 但其实可以小很多。
3. tsconfig 没排除测试 / 示例 / dev 工具
tsconfig.json 缺 exclude,build 把 *.test.ts、__fixtures__/、storybook/、examples/ 全编译进 server bundle。表现:明明业务代码不多,产物却几百 MB。
如何判断:build 后 find dist -name "*.test.*" -o -name "*fixture*",能找到就是没排干净。
4. Server / client bundle 边界没分清
把 server-only 的依赖(如 pdf-parse、@aws-sdk/client-s3)错误地 import 到了 client component,bundler 把它打到了 browser bundle。或者反过来:本应留在 server 的 markdown / 模板字符串被 inline 到了每个 page。
如何判断:跑 bundle analyzer,看 client chunk 里出现了哪些理应只在服务端的包名。
5. Source map 进了生产产物
vite.config.ts / next.config.js 默认在 production 也输出 source map,每个 chunk 旁边都跟一份同体积的 .map 文件,直接翻倍产物大小。
如何判断:ls dist/assets/*.map | wc -l 大于 0 且 .map 文件总大小接近代码总大小。
最短修复路径
Step 1:定位”谁最大”
先看产物物理体积:
npm run build
du -sh dist/
du -sh dist/* | sort -h | tail -10
再跑 bundle visualizer 找出每个 chunk 里的依赖构成:
# Vite / Astro
npx vite-bundle-visualizer
# Next.js
ANALYZE=true npm run build
# 需要先装 @next/bundle-analyzer 并在 next.config.js 里配置
# Webpack 直接用
npx webpack-bundle-analyzer dist/stats.json
打开生成的 HTML,按面积找出占比最大的 1-3 个依赖,那就是要砍的目标。
Step 2:把大依赖换轻量版本或推到 server-only
| 重 | 轻 |
|---|---|
moment (~290 KB) | date-fns (~13 KB tree-shaken) 或 dayjs (~7 KB) |
lodash (~70 KB) | lodash-es 按需 import,或原生 ES 方法 |
axios (~30 KB) | 原生 fetch |
puppeteer | @sparticuz/chromium + puppeteer-core(serverless 专用) |
完整 chart.js | 按需 import chart.js/auto 子模块 |
如果依赖必须用且必须重(比如 sharp、puppeteer),把它从 serverless function 拆出来:迁到独立 worker、用外部服务(Browserless、Cloudinary),或者改 build-time 处理。
Step 3:收紧 tsconfig 和 build include / exclude
// tsconfig.json
{
"compilerOptions": { "...": "..." },
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/__tests__/**",
"**/__fixtures__/**",
"examples/**",
"storybook/**"
]
}
Vercel 还可以在 vercel.json 里精细排除:
{
"functions": {
"api/**/*.ts": {
"includeFiles": "lib/**",
"excludeFiles": "{tests,fixtures}/**"
}
}
}
Step 4:压缩静态资源 + 关 source map
图片:
# 一次性把 public/ 下所有 PNG / JPG 压缩
npx @squoosh/cli --mozjpeg auto public/**/*.{jpg,jpeg}
npx @squoosh/cli --oxipng auto public/**/*.png
# 或者全部转 WebP / AVIF
npx @squoosh/cli --webp auto public/**/*.{jpg,png}
关掉 production source map:
// vite.config.ts
export default defineConfig({
build: {
sourcemap: false, // 或 'hidden' 上传到 Sentry 后不发布
},
});
Step 5:在 CI 里加 bundle-size 守门
# .github/workflows/bundle-size.yml
name: Bundle Size
on: [pull_request]
jobs:
size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci && npm run build
- name: Check size
run: |
SIZE=$(du -sm dist | cut -f1)
echo "dist size: ${SIZE} MB"
if [ $SIZE -gt 40 ]; then
echo "Bundle exceeds 40 MB threshold"
exit 1
fi
设的阈值比平台硬限制低 20%,给未来增长留 buffer。
预防建议
- 每加一个新依赖前先去 bundlephobia.com 查 minified + gzipped 体积,超 50 KB 的要找替代
- CI 里加 bundle-size 检查,阈值设在平台硬限制的 70-80%
- 区分 server-only 和 client-only 依赖,server 包别 import 到客户端组件
- 大型二进制(PDF / 视频 / 模型权重)放对象存储,运行时拉取,不打进 bundle
- 每季度跑一次
npx depcheck删未使用依赖;跑 bundle visualizer 复盘 top 5 大依赖是否还都需要