工具链优化踩坑记:一个全栈民工的血泪总结
上周五晚上十点半,我一边啃着冷掉的麦当劳巨无霸,一边盯着 CI/CD 流水线里那个红色的 ❌。产品经理还在群里@我:“明天上线前能搞定吗?”,测试同学已经第 3 次问我“这个 bug 修好了没?”。而我,一个在创业公司身兼前端、后端、运维、爬虫工程师(以及半个 UI 设计师)的全栈民工,正被一个看似微不足道的构建工具问题折磨得想砸键盘。
说起来你可能不信——就为了把一个用 JavaScript 写的爬虫脚本打包快那么几秒钟,我硬生生折腾了三天。但这背后牵扯出的工具链优化问题,却让我对“开发效率”这四个字有了全新的理解。
起因:爬虫跑太慢,老板急得跳脚
事情得从上个月说起。我们公司做的是电商数据服务,核心业务之一就是从各大平台抓取商品价格、评论、销量等信息。之前用 Python + Scrapy 的组合跑得很稳,但最近有个新需求:要实时抓取某些动态渲染的页面(比如用了大量 AJAX 和前端框架的 SPA),传统爬虫根本拿不到完整数据。
作为团队里“啥都会一点”的人,老板直接把任务拍到我头上:“你不是会前端吗?用 Puppeteer 或 Playwright 写个 JS 爬虫试试!”
行吧,反正我最近也在刷 LeetCode 准备跳槽,顺手练练 Node.js 也挺好。
我很快就用 Playwright 写了个原型,功能没问题。但问题来了:每次部署都要在服务器上 npm install 一整套依赖,光是 playwright-core 就 100+ MB,CI/CD 流水线动辄跑 8 分钟,本地调试更是慢如蜗牛。更糟的是,有一次因为网络抖动,npm install 卡住,导致凌晨的数据采集任务全部失败——第二天老板看我的眼神仿佛在看一个罪人。
“能不能优化一下构建速度?”他问得轻描淡写,但我心里清楚:再不搞快点,下个月发工资时我的名字可能就要出现在“优化名单”里了。
第一坑:Webpack 打包爬虫?别闹了!
一开始我想,既然这是个 Node.js 项目,那用 Webpack 打个 bundle 不就行了?还能 Tree Shaking 干掉不用的代码,体积小、启动快,多完美!
于是我兴冲冲地配了 webpack.config.js:
// webpack.config.js
const path = require('path');
module.exports = {
target: 'node',
entry: './src/crawler.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'crawler.bundle.js'
},
externals: [
// 排除 node 原生模块
'fs', 'path', 'child_process'
]
};
结果一运行,直接报错:
Error: Cannot find module 'playwright-core/lib/server/chromium'
查了半天才发现,Playwright 的架构很特殊——它会在运行时动态加载浏览器二进制文件和对应的驱动模块,路径是通过 require.resolve 动态拼接的。Webpack 静态分析根本处理不了这种动态 require,直接把关键模块当成“未使用”给干掉了。
我当时就傻了。翻遍 GitHub issues,发现这不是个例。有人建议用 __non_webpack_require__,有人建议手动 copy 二进制文件……但每种方案都又臭又长,维护成本高得离谱。
结论:别用 Webpack 打包包含 Playwright/Puppeteer 的 Node.js 应用!除非你愿意花一周时间跟 Webpack 插件斗智斗勇。
第二坑:ESBuild 是救星?先看看它的脾气
痛定思痛,我决定换思路:既然 Webpack 太重,那用新一代的极速打包工具 esbuild 怎么样?听说它比 Webpack 快 10-100 倍,而且支持 CommonJS 转 ESM。
安装、配置,三分钟搞定:
// build.mjs
import { build } from 'esbuild';
build({
entryPoints: ['src/crawler.js'],
bundle: true,
platform: 'node',
target: 'node16',
outfile: 'dist/crawler.js',
external: ['playwright'], // 先排除 playwright
}).catch(() => process.exit(1));
构建速度确实快得飞起——0.8 秒!但问题又来了:esbuild 默认不支持 top-level await,而我们的爬虫主逻辑是 async 的:
// crawler.js
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
// ... 抓取逻辑
})();
esbuild 直接报语法错误。虽然可以通过包装成 IIFE 解决,但更麻烦的是:esbuild 对动态 import 和 require 的处理依然不够智能,尤其面对 Playwright 这种“运行时才决定加载哪个浏览器”的库,还是得手动 external。
而且,esbuild 本身不处理 assets(比如 Playwright 的浏览器二进制文件),你得自己写脚本去复制。这就又回到了“手动维护”的老路。
教训:esbuild 快是快,但它是个“极简主义者”——你要的便利性,它统统不给。
转机:原来 npm 本身就有优化空间
在我快要放弃的时候,突然想起一件事:我们真的需要每次都重新安装所有依赖吗?
我们的 CI/CD 用的是 GitLab Runner,每次构建都是全新环境。这意味着哪怕只是改了一行注释,也要重新 npm install 整个 node_modules,包括那 150MB 的 Chromium。
于是我把目光投向了 缓存。
方案一:CI 缓存 node_modules
GitLab CI 支持 cache,我加了这么一段:
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
效果立竿见影——第二次构建直接跳过 install,构建时间从 8 分钟降到 2 分钟!
但问题也随之而来:如果 package.json 变了,缓存不会自动失效,导致依赖版本不一致。后来改成用 package-lock.json 的 checksum 作为 key:
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
这下稳了。缓存虽好,但 key 的设计决定生死。
方案二:用 pnpm 替代 npm
某次技术分享会上,隔壁大厂的朋友安利了 pnpm。他说:“npm 和 yarn 都是在复制文件,pnpm 是硬链接 + symlink,省空间还快。”
我半信半疑地试了下:
npm install -g pnpm
pnpm import # 把 package-lock.json 转成 pnpm-lock.yaml
pnpm install
结果震惊了:
- 安装时间从 90s 降到 12s
- node_modules 从 420MB 降到 80MB(因为共享 store)
- CI 构建时间进一步压缩到 1分半
更重要的是,pnpm 的严格依赖管理避免了很多“在我机器上能跑”的玄学问题。现在整个团队都切过去了——连后端 Java 同事都说:“你们前端工具链卷得真狠。”
终极方案:Docker 多阶段构建 + Layer 缓存
但老板还不满意:“能不能做到秒级部署?”
我一咬牙,祭出了终极大招:Docker 多阶段构建。
思路很简单:把依赖安装和代码构建分开,利用 Docker 的 layer 缓存机制。
# Stage 1: 安装依赖
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
# Stage 2: 构建应用(其实这里没构建,只是复制)
FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 安装 Playwright 浏览器(这步很关键!)
RUN npx playwright install chromium
CMD ["node", "src/crawler.js"]
重点来了:
- 只要
package.json不变,第一阶段的 layer 就会被缓存,npm ci直接跳过 - Playwright 的浏览器二进制文件也被固化在镜像里,无需每次下载
- 最终镜像只包含生产依赖,安全又轻量
配合 GitLab CI 的 Docker-in-Docker(DinD)服务,构建时间稳定在 45 秒以内。而且因为镜像是自包含的,本地、测试、生产环境完全一致——再也不用听测试同学抱怨“怎么又挂了?”
工具链优化对比表
为了让大家看得更清楚,我把几种方案的效果整理成了表格:
| 方案 | 本地安装时间 | CI 构建时间 | 镜像体积 | 维护成本 | 是否推荐 |
|---|---|---|---|---|---|
| 原始 npm + Webpack | 90s+ | 8m+ | 500MB+ | 高(需处理动态 require) | ❌ |
| esbuild + manual copy | 5s | 3m | 300MB | 极高(需手动管理 assets) | ❌ |
| npm + CI 缓存 | 90s | 2m | 500MB | 低 | ✅(基础优化) |
| pnpm + CI 缓存 | 12s | 1m30s | 200MB | 低 | ✅✅(强烈推荐) |
| Docker 多阶段构建 | 首次 2m,后续 45s | 45s | 180MB | 中(需写 Dockerfile) | ✅✅✅(生产首选) |
一些额外的经验和吐槽
别迷信“新工具”:esbuild、Vite、Turbopack 很酷,但不是万能药。工具选型要看具体场景——你的项目是否需要打包?是否有动态 require?有没有二进制依赖?
爬虫项目慎用前端构建工具:Node.js 应用和浏览器应用的构建需求完全不同。很多前端工具默认假设你在写浏览器代码,结果各种水土不服。
缓存是性能优化的第一生产力:无论是 CI 缓存、Docker layer 缓存,还是 pnpm 的 store,本质都是“避免重复劳动”。记住:程序员的时间比机器贵。
产品经理不懂技术没关系,但得懂“时间成本”:当我把构建时间从 8 分钟降到 45 秒后,老板终于理解为什么我要花三天“折腾工具”了——因为每天能省下 2 小时等待时间,一个月就是 40 小时,相当于多出一个人力!
写在最后
现在,我的爬虫项目已经稳定运行了一个月,CI 流水线绿得发亮。上周技术分享会上,我还把这套优化方案讲了一遍,结果被隔壁组的老大拉住问:“能不能帮我们也看看?”
说实话,作为一个准备跳槽的全栈民工,我本来只想“能跑就行”。但这次踩坑经历让我意识到:真正的工程能力,不仅在于写出功能,更在于让整个交付流程高效、可靠、可维护。
下次面试时,我大概会把这段经历写进简历——不是“用 Playwright 做了爬虫”,而是“通过工具链优化,将数据采集任务的部署效率提升 10 倍”。
毕竟,在这个卷成麻花的行业里,会写代码的人很多,但能让代码跑得又快又稳的人,才是稀缺资源。
(完)
P.S. 如果你也在折腾 JavaScript 工具链,欢迎留言交流!顺便求内推——正在看新机会,要求不高,只要别让我再同时当五个角色就行 😅

评论 0