聊聊我折腾前端工程化踩过的坑和最终方案

温柔控制台
2026-07-11 13:57
阅读 350

每天早上挤完一号线,到公司差不多九点出头,泡杯咖啡,打开电脑,先看看昨晚跑的CI有没有飘红。这基本是我一天工作的开场白。

我是个纯前端,写了五六年React,最近半年被领导"安排"学Node.js,说是要搞全栈。说实话,刚开始我是抵触的——前端的东西都学不完,还搞后端?但后来发现,当你真正开始接触构建流程、部署链路、服务端渲染这些事儿之后,你对前端工程化的理解会完全不一样。

今天想跟大家聊聊,我这一年多在前端工程化上折腾的一些东西。从工具链选型到CI/CD,从本地开发体验优化到生产环境部署,算是个完整的实战记录。

事情是这样的

去年Q3的时候,我们组接了个新项目,是个面向B端的数据分析平台。技术栈定了React 18 + TypeScript,这没啥好说的。但工程化这块,leader让我来搞,说"你之前不是折腾过不少嘛,这次整个规范点的出来"。

我当时心里其实有点虚。之前那些项目,说实话都是"能用就行"——Webpack配置是抄的,ESLint规则是随便挑的,部署就是打个包扔给运维。这次要搞个正经的,压力还是有的。

而且我们团队有个特点:大家早上到得都挺早,八点多就陆陆续续来了,但晚上基本不加班(这点我很满意)。所以我的目标很明确——把工程化搞好,让大家的开发体验丝滑一点,别整天被构建速度、环境配置这些破事烦

工具链选型:别盲目追新

先说构建工具。2024年了,Vite已经很成熟了,但我们这个项目有点特殊——需要兼容一些老旧的Chrome版本(客户那边有台2018年买的工控机,别问为什么),而且有些内部npm包是用CommonJS写的。

Vite在开发体验上确实没得说,冷启动快得飞起。但在生产构建上,它底层还是用的Rollup,对一些复杂场景的处理不如Webpack灵活。权衡了一下,我们最终选了Webpack 5 + SWC的组合。

为什么不用Turbopack?说实话,当时Turbopack还在beta,我不敢赌。工作中嘛,稳定第一,折腾新技术可以,但别拿项目开玩笑。

SWC替代Babel这件事,我觉得是2023年最让人开心的前端基建升级之一了。Babel那编译速度,项目一大起来,等得你怀疑人生。换成SWC之后,我们的构建速度直接快了4-5倍,HMR也从之前的1-2秒降到了200ms以内。

// .swcrc 配置示例
{
  "jsc": {
    "parser": {
      "syntax": "typescript",
      "tsx": true,
      "decorators": true
    },
    "transform": {
      "react": {
        "runtime": "automatic"
      }
    },
    "target": "es2018"
  },
  "module": {
    "type": "es6"
  }
}

这里有个坑要提一下:SWC对decorators的支持和Babel不完全一致,我们项目里用了MobX,有些装饰器写法会报错。后来查了半天文档,发现要在SWC配置里显式开启decorators: true,并且注意装饰器的stage版本。这种细节,不踩一脚真的不知道。

ESLint + Prettier:别让它成为摆设

代码规范这块,我们用了ESLint + Prettier的组合。这个组合本身没啥新鲜的,但我想说的是配置策略

很多团队搞ESLint,上来就开一堆规则,结果代码里满屏红线,大家直接摆烂。我们的做法是:先跑通,再收紧

第一阶段,只开eslint:recommended加上React相关的规则,Prettier用默认配置。先让大家习惯有lint这个流程。

第二阶段,一个月后,根据团队讨论,逐步加入一些规则,比如no-unused-varsreact-hooks/exhaustive-deps这些。

第三阶段,引入我们自定义的规则集。这里我做了个内部npm包,把ESLint和Prettier的配置打包在一起,各项目直接引用,不用每个项目单独配。

// @ourteam/eslint-config/index.js
module.exports = {
  extends: [
    'eslint:recommended',
    'plugin:react/recommended',
    'plugin:react-hooks/recommended',
    'plugin:@typescript-eslint/recommended',
    'prettier' // 注意这个顺序,prettier要放最后
  ],
  rules: {
    'react/react-in-jsx-scope': 'off', // React 17+ 不需要了
    '@typescript-eslint/no-explicit-any': 'warn', // 先warn,别直接error
    'no-console': ['warn', { allow: ['warn', 'error'] }]
  }
}

说到这个,我特别想吐槽一个事。之前有个同事,lint报了个warning,他直接加了个// eslint-disable-next-line给干掉了。我review的时候看到了,问他为啥不解决,他说"看着烦"。我……行吧,后来我在CI里加了个检查,disable注释超过3个的,直接不让合并。这招虽然有点狠,但确实管用。

Monorepo还是多仓库?

这个问题我们纠结了很久。项目里有前端、有Node.js的BFF层、有共享的工具库和类型定义,按理说Monorepo是最合适的。但团队里之前没人用过Monorepo,大家心里没底。

最终我们选了pnpm workspace + Turborepo的方案。为什么不用Nx?因为Turborepo配置更简单,而且pnpm本身对workspace的支持就很好。

# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'
// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "outputs": []
    },
    "test": {
      "outputs": []
    }
  }
}

Monorepo最大的好处是类型共享太爽了。以前前后端分离的时候,接口类型定义要维护两份,经常对不上。现在共享一个@ourteam/types包,后端改了接口,前端编译直接报错,再也不用上线了才发现字段对不上。

但Monorepo也有坑。最头疼的是依赖管理。pnpm的严格模式有时候会让人抓狂——你明明在A包里用了B包的依赖,但B包没在A的package.json里声明,pnpm直接给你报错。这在以前npm/yarn的hoisting模式下是感知不到的。

不过说实话,这种"报错"其实是好事。它逼着你把依赖关系理清楚,而不是靠hoisting的隐式行为蒙混过关。

CI/CD:让运维少找我

这块是我花时间最多的地方。之前我们项目的部署流程是这样的:本地build -> 把dist压缩 -> 传给运维 -> 运维放到服务器上 -> 重启Nginx。每次发版都要找运维,运维烦,我也烦。

我决定搞一套自动化的CI/CD。我们用的是GitLab CI,配合Docker做容器化部署。

# .gitlab-ci.yml
stages:
  - lint
  - test
  - build
  - deploy

variables:
  DOCKER_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

lint:
  stage: lint
  image: node:18-alpine
  script:
    - pnpm install --frozen-lockfile
    - pnpm run lint
  rules:
    - if: $CI_MERGE_REQUEST_ID

test:
  stage: test
  image: node:18-alpine
  script:
    - pnpm install --frozen-lockfile
    - pnpm run test --coverage
  coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

build:
  stage: build
  image: node:18-alpine
  script:
    - pnpm install --frozen-lockfile
    - pnpm run build
    - docker build -t $DOCKER_IMAGE .
    - docker push $DOCKER_IMAGE
  only:
    - main
    - develop

deploy-staging:
  stage: deploy
  image: bitnami/kubectl:latest
  script:
    - kubectl set image deployment/frontend frontend=$DOCKER_IMAGE -n staging
  only:
    - develop
  when: manual

deploy-prod:
  stage: deploy
  image: bitnami/kubectl:latest
  script:
    - kubectl set image deployment/frontend frontend=$DOCKER_IMAGE -n production
  only:
    - main
  when: manual

这里有几个细节我想展开说说。

第一,lint和test只在MR阶段跑。 之前我们是每次push都跑,结果一天能跑十几次,CI资源浪费得厉害。改成MR触发后,CI队列明显清爽了。

第二,生产部署是手动触发的。 这个很重要。自动化不代表无脑自动化,生产环境的部署一定要有人确认。我们约定的是,每次发版由当周的值班同学手动点一下deploy按钮,顺便看一眼staging的效果。

第三,Docker镜像用commit SHA做tag。 之前用latest,出过一次事故——两个人同时发版,镜像被覆盖了,回滚的时候发现latest已经不是上一个版本了。从那以后,我们严格用SHA做tag,每个版本都可追溯。

说到部署,我们的Dockerfile也优化过。最开始是单阶段构建,镜像大小400多MB。后来改成多阶段构建,最终镜像只有30多MB:

# 构建阶段
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build

# 运行阶段
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Nginx配置:别小看这个

前端部署,Nginx配置很关键。很多人直接把打包文件扔上去就完事了,结果缓存没配好,用户每次访问都是旧版本;或者gzip没开,首屏加载慢得要死。

我们的Nginx配置是这样的:

server {
    listen 80;
    server_name our-app.com;
    root /usr/share/nginx/html;

    # HTML不缓存,保证每次拿到最新版本
    location / {
        try_files $uri $uri/ /index.html;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    # 静态资源强缓存,带hash的文件名保证更新
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # gzip压缩
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;

    # 安全头
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
}

这里有个小插曲。之前我们HTML也配了强缓存,结果有一次发版后,用户反馈页面白屏。排查了半天才发现,是浏览器缓存了旧的index.html,里面的JS文件名是旧的hash,但服务器上已经是新文件了。从那以后,HTML坚决不缓存,反正HTML文件很小,不影响性能。

性能优化:前端的老本行

工程化不只是构建和部署,性能优化也是重要一环。我们项目在性能上做了不少优化,这里挑几个重点说说。

代码分割是基础操作。我们用React.lazy + Suspense做路由级别的懒加载,首屏bundle从2.1MB降到了680KB。

// router.tsx
import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/analytics" element={<Analytics />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

Tree Shaking也要关注。我们之前引入了一个UI库,全量引入导致bundle多了500KB。后来改成按需引入,配合Webpack的sideEffects配置,效果很明显。

图片优化这块,我们写了一个Webpack plugin,在构建时自动把图片转成WebP格式,并且生成不同尺寸的缩略图。这个想法其实来源于一个算法的启发——我们用了一个简单的图片重要性评估算法,对关键图片保留高质量版本,对装饰性图片做更激进的压缩。

// webpack.config.js 中的图片优化配置
{
  test: /\.(png|jpg|jpeg)$/,
  use: [
    {
      loader: 'image-webpack-loader',
      options: {
        webp: {
          quality: 80
        },
        mozjpeg: {
          quality: 70
        }
      }
    }
  ]
}

本地开发体验:让同事少骂我

工程化搞得好不好,一线开发同学的感受最直接。我在这块花了不少心思。

环境变量管理,我们用了一个.env文件的方案,不同环境对应不同文件:

.env.development      # 本地开发
.env.staging          # 预发环境
.env.production       # 生产环境

并且在代码里做了类型提示,这样开发的时候写process.env.API_URL能有自动补全:

// env.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    readonly NODE_ENV: 'development' | 'production' | 'staging';
    readonly API_URL: string;
    readonly CDN_URL: string;
  }
}

Mock数据这块,我们用MSW(Mock Service Worker)来做。好处是不用改业务代码,直接在浏览器层面拦截请求。而且Mock数据可以跟后端接口文档联动,后端改了接口定义,Mock数据自动更新(这个需要写个脚本做转换)。

// src/mocks/handlers.js
import { rest } from 'msw';

export const handlers = [
  rest.get('/api/users', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json([
        { id: 1, name: '张三', role: 'admin' },
        { id: 2, name: '李四', role: 'user' }
      ])
    );
  })
];

错误边界和调试工具也做了增强。开发环境下,我们注入了一个自定义的错误面板,当React组件报错时,能直接看到错误栈、组件层级、props状态等信息,比React DevTools原生的错误提示好用多了。

监控和告警:上线不是结束

以前我们项目上线后就是"听天由命"——用户反馈有问题才知道出事了。这次我搞了一套前端监控。

用的是Sentry做错误监控,配合自研的性能监控SDK。关键指标(FCP、LCP、CLS)都会上报,超过阈值自动告警。

// monitor.ts
import * as Sentry from '@sentry/react';
import { BrowserTracing } from '@sentry/tracing';

Sentry.init({
  dsn: 'https://xxx@sentry.io/xxx',
  integrations: [new BrowserTracing()],
  tracesSampleRate: 0.2, // 采样率20%,别把服务器打爆了
  release: `our-app@${process.env.APP_VERSION}`,
  environment: process.env.NODE_ENV,
});

// 性能监控
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'first-contentful-paint') {
      reportMetric('FCP', entry.startTime);
    }
    if (entry.entryType === 'largest-contentful-paint') {
      reportMetric('LCP', entry.startTime);
    }
  }
});

observer.observe({ entryTypes: ['paint', 'largest-contentful-paint'] });

上线第一个月,监控就帮我们抓到了3个线上问题。其中一个是某个接口在弱网环境下会超时,导致页面白屏——这个问题在开发环境根本复现不了,全靠监控发现的。

一些踩坑记录

最后分享几个我印象深刻的坑,给大家避避雷。

坑1:pnpm的幽灵依赖问题 我们有个老包,里面用了lodash但没在package.json里声明。以前用npm没事(因为hoisting),换pnpm后直接报错。解决方案是老老实实把依赖补上。

坑2:Turborepo的缓存失效 有一次CI构建特别慢,排查发现是Turborepo的缓存没生效。原因是我们在构建脚本里用了Date.now(),导致每次构建的hash都不一样,缓存永远命中不了。这种"动态内容"一定要从构建流程中剔除。

坑3:Docker构建的node_modules问题 最开始Dockerfile里直接COPY整个项目,结果node_modules也被拷进去了,而且架构还对不上(本地是macOS,容器是Linux)。后来学乖了,用.dockerignore排除node_modules,在容器内重新install。

坑4:Source Map泄露 有一次安全扫描发现我们的生产环境暴露了Source Map,代码全被看到了。赶紧在Nginx里加了配置禁止访问.map文件,并且在构建时生产环境不生成Source Map(或者生成后上传到Sentry,不从服务器提供)。

# 禁止访问source map
location ~* \.map$ {
    return 404;
}

写在最后

搞了一年多的前端工程化,最大的感受是:工程化不是目的,是手段

我们不是为了用最新的工具而用最新的工具,而是为了解决实际问题——构建太慢就优化构建,部署太烦就搞自动化,代码太乱就上规范。每一个决策都要有明确的收益。

另外就是,工程化一定要考虑团队接受度。你搞的东西再牛,如果团队用不起来,那就是零。所以沟通很重要,要让大家理解为什么要这么做,要给大家适应的时间。

最近我在学Node.js的过程中,对服务端的东西有了更多理解。比如SSR、BFF这些,以前觉得是后端的事,现在发现前端也应该懂。全栈不一定是什么都会,而是对整条链路有清晰的认知。

好了,就写到这里。每天早上八点到公司,先看看CI有没有飘红,已经成了我的习惯。希望这篇文章能给正在搞工程化的同学一些参考,少走点弯路。

有问题的话欢迎评论区交流,我一般中午吃饭的时候会看手机。不说了,产品经理又来找我对需求了,我先撤了。

评论 0

最热最新
暂无评论
温柔控制台Lv.1
0
影响力
0
文章
0
粉丝