Use the ESBuild plugin mechanism to achieve the required functionality

Original link: https://blog.rxliuli.com/p/ba9b341a8792405fb86d8fe02a18adfc/

foreword

esbuild is a general-purpose code compiler and build tool, built with golang, it is very fast, and its performance is 1~2 orders of magnitude higher than the existing js toolchain. It’s not currently an out-of-the-box build tool, but with its plugin system, we can already do many things.

1653052756694

Automatically exclude all dependencies

When building lib, we usually don’t want to bundle dependent modules, and hope to exclude all dependencies by default. This plugin is used to achieve this function. It will make all imported modules that do not start with . external, avoiding bundling into the final build.

 import { Plugin } from "esbuild" ; /** * 自动排除所有依赖项* golang 不支持js 的一些正则表达式语法,参考https://github.com/evanw/esbuild/issues/1634 */ export function autoExternal ( ) : Plugin { return { name : "autoExternal" , setup ( build ) { build . onResolve ( { filter : / .* / } , ( args ) => { if ( / ^\.{1,2}\/ / . test ( args . path ) ) { return ; } return { path : args . path , external : true , } ; } ) ; } , } ; } 

We can use it like import esbuild, but it won’t be bundled in.

 import { build } from "esbuild" ; console . log ( build ) ;

will be compiled into

 import { build } from "esbuild" ; console . log ( build ) ; 

Use environment variables

Sometimes we need to use environment variables to distinguish between different environments, and this can be done very simply by using plugins.

 import { Plugin } from "esbuild" ; /** * @param {string} str */ function isValidId ( str : string ) { try { new Function ( ` var ${ str } ; ` ) ; } catch ( err ) { return false ; } return true ; } /** * Create a map of replacements for environment variables. * @return A map of variables. */ export function defineProcessEnv ( ) { /** * @type */ const definitions : Record < string , string > = { } ; definitions [ "process.env.NODE_ENV" ] = JSON . stringify ( process . env . NODE_ENV || "development" ) ; Object . keys ( process . env ) . forEach ( ( key ) => { if ( isValidId ( key ) ) { definitions [ ` process.env. ${ key } ` ] = JSON . stringify ( process . env [ key ] ) ; } } ) ; definitions [ "process.env" ] = "{}" ; return definitions ; } export function defineImportEnv ( ) { const definitions : Record < string , string > = { } ; Object . keys ( process . env ) . forEach ( ( key ) => { if ( isValidId ( key ) ) { definitions [ ` import.meta.env. ${ key } ` ] = JSON . stringify ( process . env [ key ] ) ; } } ) ; definitions [ "import.meta.env" ] = "{}" ; return definitions ; } /** * Pass environment variables to esbuild. * @return An esbuild plugin. */ export function env ( options : { process ? : boolean ; import ? : boolean } ) : Plugin { return { name : "env" , setup ( build ) { const { platform , define = { } } = build . initialOptions ; if ( platform === "node" ) { return ; } build . initialOptions . define = define ; if ( options . import ) { Object . assign ( build . initialOptions . define , defineImportEnv ( ) ) ; } if ( options . process ) { Object . assign ( build . initialOptions . define , defineProcessEnv ( ) ) ; } } , } ; } 

After using the plugin, we can use environment variables in our code

 export const NodeEnv = import . meta . env . NODE_ENV ;

compile result

 export const NodeEnv = "test" ; 

output log at build time

Sometimes we want to build something in watch mode, but esbuild doesn’t output a message after the build, we simply implement one.

 import { Plugin , PluginBuild } from "esbuild" ; export function log ( ) : Plugin { return { name : "log" , setup ( builder : PluginBuild ) { let start : number ; builder . onStart ( ( ) => { start = Date . now ( ) ; } ) ; builder . onEnd ( ( result ) => { if ( result . errors . length !== 0 ) { console . error ( "build failed" , result . errors ) ; return ; } console . log ( ` build complete, time ${ Date . now ( ) - start } ms ` ) ; } ) ; } , } ; } 

we can test that it works

 const mockLog = jest . fn ( ) ; jest . spyOn ( global . console , "log" ) . mockImplementation ( mockLog ) ; await build ( { stdin : { contents : ` export const name = 'liuli' ` , } , plugins : [ log ( ) ] , write : false , } ) ; expect ( mockLog . mock . calls . length ) . toBe ( 1 ) ;  

Automatically exclude dependencies starting with node:

Sometimes some dependent modules use the native modules of nodejs, but the writing method starts with node: which will cause esbuild to not recognize it. We use the following plugins to deal with it

 import { Plugin } from "esbuild" ; /** * 排除和替换node 内置模块*/ export function nodeExternal ( ) : Plugin { return { name : "nodeExternals" , setup ( build ) { build . onResolve ( { filter : / (^node:) / } , ( args ) => ( { path : args . path . slice ( 5 ) , external : true , } ) ) ; } , } ; } 

Native modules starting with node: in our code below will be excluded

 import { path } from "node:path" ; console . log ( path . resolve ( __dirname ) ) ;

compile result

 // <stdin> import { path } from "path" ; console . log ( path . resolve ( __dirname ) ) ; 

Binding text files via ?raw

If you have used vite, you may be impressed by its ?* feature, it provides a variety of functions to import files in different ways, and in esbuild, we sometimes want to statically bundle some content, For example readme files.

 import { Plugin } from "esbuild" ; import { readFile } from "fs-extra" ; import * as path from "path" ; /** * 通过?raw 将资源作为字符串打包进来* @returns */ export function raw ( ) : Plugin { return { name : "raw" , setup ( build ) { build . onResolve ( { filter : / \?raw$ / } , ( args ) => { return { path : path . isAbsolute ( args . path ) ? args . path : path . join ( args . resolveDir , args . path ) , namespace : "raw-loader" , } ; } ) ; build . onLoad ( { filter : / \?raw$ / , namespace : "raw-loader" } , async ( args ) => { return { contents : await readFile ( args . path . replace ( / \?raw$ / , "" ) ) , loader : "text" , } ; } ) ; } , } ; } 

Verify by

 const res = await build ( { stdin : { contents : ` import readme from '../../README.md?raw' console.log(readme) ` , resolveDir : __dirname , } , plugins : [ raw ( ) ] , bundle : true , write : false , } ) ; console . log ( res . outputFiles [ 0 ] . text ) ; expect ( res . outputFiles [ 0 ] . text . includes ( "@liuli-util/esbuild-plugins" ) ) . toBeTruthy ( ) ;  

rewrite some modules

Sometimes we want to rewrite some modules, such as changing the imported lodash to lodash-es to achieve tree shaking, at this time we can do this with the following plugins

 import { build , Plugin } from "esbuild" ; import path from "path" ; /** * 将指定的import 重写为另一个* @param entries * @returns */ export function resolve ( entries : [ from : string , to : string ] [ ] ) : Plugin { return { name : "resolve" , setup ( build ) { build . onResolve ( { filter : / .* / } , async ( args ) => { const findEntries = entries . find ( ( item ) => item [ 0 ] === args . path ) ; if ( ! findEntries ) { return ; } return await build . resolve ( findEntries [ 1 ] ) ; } ) ; } , } ; } 

We can replace lodash with lodash-es using the following configuration

 build ( { plugins : [ resolve ( [ [ "lodash" , "lodash-es" ] ] ) ] , } ) ;

source code

 import { uniq } from "lodash" ; console . log ( uniq ( [ 1 , 2 , 1 ] ) ) ;

compile result

 import { uniq } from "lodash-es" ; console . log ( uniq ( [ 1 , 2 , 1 ] ) ) ; 

Forcing the specified module to have no side effects

When we use a third-party package, it is possible that the package depends on some other modules. If the module does not declare sideEffect , then even if it has no side effects and exports the esm package, the dependent module will be bundled in, But we can use the plugin api to force the specified module to have no side effects.

 import { Plugin } from "esbuild" ; /** * 设置指定模块为没有副作用的包,由于webpack/esbuild 的配置不兼容,所以先使用插件来完成这件事* @param packages * @returns */ export function sideEffects ( packages : string [ ] ) : Plugin { return { name : "sideEffects" , setup ( build ) { build . onResolve ( { filter : / .* / } , async ( args ) => { if ( args . pluginData || // Ignore this if we called ourselves ! packages . includes ( args . path ) ) { return ; } const { path , ... rest } = args ; rest . pluginData = true ; // Avoid infinite recursion const result = await build . resolve ( path , rest ) ; result . sideEffects = false ; return result ; } ) ; } , } ; } 

We use it as follows

 build ( { plugins : [ sideEffects ( [ "lib" ] ) ] , } ) ;

At this time, even if some code in lib-a depends on lib-b, as long as your code does not depend on a specific method, it will be properly tree shaking

For example the following code

 // main.ts import { hello } from "lib-a" ; console . log ( hello ( "liuli" ) ) ;
 // lib-a/src/index.ts export * from "lib-b" ; export function hello ( name : string ) { return ` hello ${ name } ` ; }

compile result

 // dist/main.js function hello ( name : string ) { return ` hello ${ name } ` ; } console . log ( hello ( "liuli" ) ) ;

Summarize

At present, many plug-ins of esbuild have been implemented, but it is still weak as a basic construction tool for building applications. Currently, it is only recommended to use it to build some pure JavaScript/TypeScript code. If you need to build a complete web application, then vite may be the most mature build tool based on esbuild.

This article is reproduced from: https://blog.rxliuli.com/p/ba9b341a8792405fb86d8fe02a18adfc/
This site is for inclusion only, and the copyright belongs to the original author.

Leave a Comment