聊聊我折腾前端工程化踩过的坑和最终方案
每天早上挤完一号线,到公司差不多九点出头,泡杯咖啡,打开电脑,先看看昨晚跑的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-vars、react-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