esbuild构建速度提升三倍的实战优化经验分享

A. 光磊 优化 阅读 1,755
赞 21 收藏
二维码
手机扫码查看
反馈

esbuild打包后CSS丢失,差点让我加班

前两天在重构一个老项目,想把原来的webpack换成esbuild提速,结果打包出来的文件CSS全没了。当时我就纳闷了,这玩意儿怎么连CSS都不认识了?折腾了一下午才搞明白问题出在哪。

esbuild构建速度提升三倍的实战优化经验分享

其实根本原因是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的配置复杂点,但是构建速度确实快了不少。当然,如果你的项目很复杂,可能还是要考虑其他方案。但对于中小型项目来说,这样处理基本够用了。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论