工具链优化踩坑记:一个全栈民工的血泪总结

Mock数据工厂
2025-12-20 23:01
阅读 708

上周五晚上十点半,我一边啃着冷掉的麦当劳巨无霸,一边盯着 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) ✅✅✅(生产首选)

一些额外的经验和吐槽

  1. 别迷信“新工具”:esbuild、Vite、Turbopack 很酷,但不是万能药。工具选型要看具体场景——你的项目是否需要打包?是否有动态 require?有没有二进制依赖?

  2. 爬虫项目慎用前端构建工具:Node.js 应用和浏览器应用的构建需求完全不同。很多前端工具默认假设你在写浏览器代码,结果各种水土不服。

  3. 缓存是性能优化的第一生产力:无论是 CI 缓存、Docker layer 缓存,还是 pnpm 的 store,本质都是“避免重复劳动”。记住:程序员的时间比机器贵。

  4. 产品经理不懂技术没关系,但得懂“时间成本”:当我把构建时间从 8 分钟降到 45 秒后,老板终于理解为什么我要花三天“折腾工具”了——因为每天能省下 2 小时等待时间,一个月就是 40 小时,相当于多出一个人力!


写在最后

现在,我的爬虫项目已经稳定运行了一个月,CI 流水线绿得发亮。上周技术分享会上,我还把这套优化方案讲了一遍,结果被隔壁组的老大拉住问:“能不能帮我们也看看?”

说实话,作为一个准备跳槽的全栈民工,我本来只想“能跑就行”。但这次踩坑经历让我意识到:真正的工程能力,不仅在于写出功能,更在于让整个交付流程高效、可靠、可维护。

下次面试时,我大概会把这段经历写进简历——不是“用 Playwright 做了爬虫”,而是“通过工具链优化,将数据采集任务的部署效率提升 10 倍”。

毕竟,在这个卷成麻花的行业里,会写代码的人很多,但能让代码跑得又快又稳的人,才是稀缺资源。

(完)

P.S. 如果你也在折腾 JavaScript 工具链,欢迎留言交流!顺便求内推——正在看新机会,要求不高,只要别让我再同时当五个角色就行 😅

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝