前言

一个很折磨人的症状: 站点源码没问题, 本地预览一切正常; 走完 gulp 压缩发布后, 页面上某个交互组件崩了 —— 点击没反应, 控制台报错还指向压缩成一行的产物代码, 根本没法读。

更诡异的是: 压缩过程没有任何报错, gulp 正常退出, 构建日志一片绿。

排查下来根因有两层, 都是”老压缩链”的经典坑。这篇记录完整的定位过程和改造方案。

一、两个主角的问题

gulp-uglify: 底层只支持 ES5

gulp-uglify 底层用的是 uglify-js, 而 uglify-js 只支持 ES5 语法。现在的浏览器代码全是 ES6+: const/let、箭头函数、模板字符串、解构、class……

它翻车有两种方式:

  1. 直接报错中止 (算幸运): 类似 Unexpected token: name (...), 至少你知道坏了
  2. 静默损坏 (更糟): 部分代码能”压过去”, 但产物行为错误 —— 作用域被改写错、依赖 Function.name 的逻辑失效、某些语法降级后语义变化。页面上组件直接崩, 而构建日志没有任何异常

第二种最迷惑: 你不会怀疑压缩环节, 只会反复检查业务代码, 而”本地预览正常、线上崩”的现象会把人往缓存和部署方向带偏。

gulp-htmlmin: 遇到未转义尖括号就中止整个流

HTML 压缩用 gulp-htmlmin 时, 只要流过它的某个文件里有未转义的半角尖括号标签文本 (比如文章正文里手写了一个裸的 <div 而没有放进代码块), 它会直接抛错, 中止整个压缩管道 —— 后面排队的文件全部不处理。

表现往往是: 构建失败提示含糊, 或者部分文件没被压缩就发布了。

二、修复思路

三个目标:

  1. 换用完整支持 ES6+ 的压缩器 → terser (uglify-js 的社区延续分支) 和 html-minifier-terser
  2. 单文件失败不影响其他文件 → 压缩失败时保留原文件、打日志、继续
  3. 失败必须有日志 → 哪个文件、什么原因, 一眼可查

实现方式是用 through2 自己写转换流, 把错误处理接管过来, 不依赖插件的默认行为。

三、改造后的 gulpfile 模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
const gulp = require('gulp')
const through2 = require('through2')
const { minify } = require('terser')
const { minify: minifyHtml } = require('html-minifier-terser')
const log = require('fancy-log')

// JS 压缩: 单文件失败 → 保留原文件并告警, 不中止整个管道
function minifyJs() {
return gulp.src('public/**/*.js')
.pipe(through2.obj(async function (file, enc, next) {
if (file.isNull() || file.isDirectory()) return next(null, file)
try {
const result = await minify(file.contents.toString(), {
compress: true,
mangle: true,
format: { comments: false }
})
if (result.code != null) file.contents = Buffer.from(result.code)
next(null, file)
} catch (err) {
log.warn(`[JS 压缩失败, 保留原文件] ${file.relative}\n ${err.message}`)
next(null, file)
}
}))
.pipe(gulp.dest('public'))
}

// HTML 压缩: 同样单文件容错
function minifyHtmlTask() {
return gulp.src('public/**/*.html')
.pipe(through2.obj(async function (file, enc, next) {
if (file.isNull() || file.isDirectory()) return next(null, file)
try {
const html = await minifyHtml(file.contents.toString(), {
collapseWhitespace: true,
removeComments: true,
minifyJS: true,
minifyCSS: true
})
file.contents = Buffer.from(html)
next(null, file)
} catch (err) {
log.warn(`[HTML 压缩失败, 保留原文件] ${file.relative}\n ${err.message}`)
next(null, file)
}
}))
.pipe(gulp.dest('public'))
}

exports.default = gulp.series(minifyJs, minifyHtmlTask)

依赖安装:

1
2
3
npm install gulp through2 terser html-minifier-terser fancy-log --save-dev
# gulp-uglify 和 gulp-htmlmin 可以卸载了
npm uninstall gulp-uglify gulp-htmlmin

这个模板的关键设计: 压缩失败时 next(null, file) 把原文件原样放行。坏一个文件只损失这一个文件不压缩, 换来的是构建永远能走完、问题永远有日志可查。

四、防回归的三个习惯

1. 正文里别手写裸的标签文本

文章内容里想展示 HTML 标签时, 用反引号包起来或放进代码块, 否则半角尖括号会原样进入 HTML 产物, 喂给压缩器就是一颗雷。

2. 构建后做一次语法抽查

1
2
# 遍历产物 JS, 语法有问题的文件会报 SyntaxError
find public -name "*.js" -exec node --check {} \;

这一步能兜住”静默损坏”的漏网之鱼 —— 语法层面坏掉的文件根本过不了 node --check。

3. 上线前点一遍核心交互

压缩引起的运行时问题 (作用域、Function.name 这类) 工具查不全, 关键页面的核心交互手工过一遍是最保险的验收。

五、怎么排查”压缩后才坏”的问题

如果你现在正处于”本地正常、发布后崩”的状态, 按这个顺序定位:

  1. 对比验证: 把对应文件的未压缩版本直接替换线上产物, 问题消失 → 实锤是压缩环节
  2. 缩小范围: 对该文件单独跑压缩, 再上线验证, 把”哪一步压坏的”锁死在 JS 或 HTML
  3. 定位原因: 打开 sourcemap 或对比源码与产物的关键函数, 看是被改写坏了还是根本没压缩
  4. 统一修复: 按第三节的模板迁到 terser, 别在旧插件上打补丁

小结

  1. uglify-js 只支持 ES5 —— 压 ES6 代码要么报错、要么静默损坏, 后者更危险
  2. gulp-htmlmin 遇到未转义尖括号会中止整个管道 —— 正文里的标签文本要放进代码块
  3. 修复方向: terser + html-minifier-terser + through2, 单文件容错, 失败留日志
  4. 压缩环节必须有语法抽查和人工验收兜底, 构建日志一片绿不代表产物没问题

老压缩链的问题不是”会不会坏”, 而是”坏了你知不知道”。把错误处理从插件手里接过来, 是这次改造最值的一步。

参考链接: