esbuild构建速度提升三倍的实战优化经验分享
esbuild打包后CSS丢失,差点让我加班
前两天在重构一个老项目,想把原来的webpack换成esbuild提速,结果打包出来的文件CSS全没了。当时我就纳闷了,这玩意儿怎么连CSS都不认识了?折腾了一下午才搞明白问题出在哪。
其实根本原因是esbuild默认不处理CSS文件,它只负责JS和TS的转译。刚开始我以为是配置问题,各种plugin都试了一遍,结果越搞越复杂。后来仔细看了下官方文档才发现,esbuild的定位就是个超快的JS转译器,CSS处理需要单独搞。
最初的尝试:各种plugin乱装
一开始我的想法很简单:找个CSS plugin不就完了。于是搜了几个知名的:
- esbuild-plugin-postcss
- esbuild-style-plugin
- @esbuild-plugins/node-resolve
结果装了一堆,每个都有各种问题。有的不支持@import,有的不能处理sass,还有些打包出来的CSS路径不对。折腾了半天发现,这些第三方插件维护都不太积极,而且跟esbuild的版本兼容性也是一团糟。
最终方案:自己写plugin + postcss
后来试了下自己写个简单的CSS处理plugin,核心代码其实就这么几行:
// css-plugin.js
const fs = require('fs')
const path = require('path')
const postcss = require('postcss')
const autoprefixer = require('autoprefixer')
function cssPlugin() {
return {
name: 'css',
setup(build) {
// 捕获所有.css文件
build.onResolve({ filter: /.css$/ }, args => {
return {
path: path.resolve(args.resolveDir, args.path),
namespace: 'css'
}
})
// 处理CSS文件内容
build.onLoad({ filter: /.css$/, namespace: 'css' }, async (args) => {
const cssContent = fs.readFileSync(args.path, 'utf8')
// 使用postcss处理
const result = await postcss([autoprefixer]).process(cssContent, {
from: args.path,
to: args.path
})
return {
contents: result.css,
loader: 'text'
}
})
}
}
}
module.exports = { cssPlugin }
然后在esbuild配置里加上这个plugin:
// esbuild.config.js
const { build } = require('esbuild')
const { cssPlugin } = require('./css-plugin')
build({
entryPoints: ['src/index.js'],
bundle: true,
minify: process.env.NODE_ENV === 'production',
sourcemap: true,
outdir: 'dist',
plugins: [
cssPlugin()
],
external: ['node_modules/*']
})
这样处理后,CSS确实能正常打包了,但是还有个问题是样式没有单独提取出来,全都inline到了JS里。对于生产环境来说这不是最优方案。
CSS单独提取,稍微麻烦点
要想把CSS单独提出来成文件,需要再加一层处理。这里我用了另一种方式:先让esbuild把CSS处理成字符串,然后再通过额外的脚本提取出来。
首先改一下plugin,让它把CSS内容写入到单独的文件:
const fs = require('fs')
const path = require('path')
const postcss = require('postcss')
const autoprefixer = require('autoprefixer')
function cssPlugin(options = {}) {
let cssContent = ''
return {
name: 'css',
setup(build) {
const outdir = options.outdir || 'dist'
build.onResolve({ filter: /.css$/ }, args => {
return {
path: path.resolve(args.resolveDir, args.path),
namespace: 'css'
}
})
build.onLoad({ filter: /.css$/, namespace: 'css' }, async (args) => {
const content = fs.readFileSync(args.path, 'utf8')
const result = await postcss([autoprefixer]).process(content, {
from: args.path,
to: args.path
})
cssContent += result.css + 'n'
return {
contents: export default ${JSON.stringify(result.css)},
loader: 'js'
}
})
// 构建完成后写入CSS文件
build.onEnd(() => {
if (cssContent) {
const outputPath = path.join(outdir, 'style.css')
fs.writeFileSync(outputPath, cssContent)
console.log(CSS file written to ${outputPath})
}
})
}
}
}
这样改完后,CSS会单独生成一个style.css文件,同时在JS里也能引用到CSS内容(虽然用不到)。这里我踩了个坑:最初是在onLoad里直接写文件,结果发现如果多个CSS文件会被覆盖,改成onEnd后才正常。
处理@import和相对路径问题
上面的方案能跑通基本流程,但遇到@import语句还是有问题。比如某个CSS文件里写了@import “./base.css”,esbuild不会自动解析这个依赖关系。需要在plugin里增加对@import的处理:
function cssPlugin(options = {}) {
let cssContent = ''
return {
name: 'css',
setup(build) {
const outdir = options.outdir || 'dist'
build.onResolve({ filter: /.css$/ }, args => {
return {
path: path.resolve(args.resolveDir, args.path),
namespace: 'css'
}
})
build.onLoad({ filter: /.css$/, namespace: 'css' }, async (args) => {
let content = fs.readFileSync(args.path, 'utf8')
// 处理@import语句
const importRegex = /@imports+["'](.*?.(s|sc|c|le)ss)["'];?/g
let match
while ((match = importRegex.exec(content)) !== null) {
const importPath = path.resolve(path.dirname(args.path), match[1])
const importedContent = fs.readFileSync(importPath, 'utf8')
// 递归处理嵌套的@import
content = content.replace(match[0], importedContent)
}
const result = await postcss([autoprefixer]).process(content, {
from: args.path,
to: args.path
})
cssContent += result.css + 'n'
return {
contents: export default ${JSON.stringify(result.css)},
loader: 'js'
}
})
build.onEnd(() => {
if (cssContent) {
const outputPath = path.join(outdir, 'style.css')
fs.writeFileSync(outputPath, cssContent)
console.log(CSS file written to ${outputPath})
}
})
}
}
}
这里的正则处理可能还不够完善,比如没考虑注释里的@import,不过对于正常场景够用了。折腾完这个后,基本的CSS打包功能就比较完善了。
最后的优化:支持sass/less
实际项目里肯定还要处理sass/less文件,这部分改动也不大,主要是loader的区别:
// 添加sass/less处理
build.onLoad({ filter: /.(sass|scss)$/, namespace: 'css' }, async (args) => {
const sass = require('sass')
const result = sass.compile(args.path, {
style: 'expanded'
})
const postcssResult = await postcss([autoprefixer]).process(result.css, {
from: args.path,
to: args.path
})
cssContent += postcssResult.css + 'n'
return {
contents: export default ${JSON.stringify(postcssResult.css)},
loader: 'js'
}
})
build.onLoad({ filter: /.less$/, namespace: 'css' }, async (args) => {
const less = require('less')
const content = fs.readFileSync(args.path, 'utf8')
const result = await less.render(content, {
filename: args.path
})
const postcssResult = await postcss([autoprefixer]).process(result.css, {
from: args.path,
to: args.path
})
cssContent += postcssResult.css + 'n'
return {
contents: export default ${JSON.stringify(postcssResult.css)},
loader: 'js'
}
})
需要注意的是,引入sass和less需要额外安装依赖,并且在package.json里声明为optionalDependencies,避免不必要的包体积。
踩坑提醒:这几点一定注意
整个过程中有几个地方特别容易踩坑:
- relative路径处理:CSS里的url()路径相对于源文件而不是输出文件,所以需要特殊处理
- 文件监听:dev模式下CSS修改不会触发重新打包,需要额外配置watch选项
- 顺序问题:CSS内容合并的顺序很重要,@import应该按依赖顺序排列
另外一个小问题:有时候某些CSS框架会用一些esbuild不认识的语法,这时候可能需要额外的postcss插件来预处理。我这里只用了autoprefixer,实际项目里可能还需要其他插件。
目前这套方案跑了两周,暂时没发现问题。虽然比webpack的配置复杂点,但是构建速度确实快了不少。当然,如果你的项目很复杂,可能还是要考虑其他方案。但对于中小型项目来说,这样处理基本够用了。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

暂无评论