esbuild的基础使用(Esbuilder入门教程)
为什么要有esbuild?
不知大家是否有遇到这个问题,
<--- Last few GCs --->
[59757:0x103000000] 32063 ms: Mark-sweep 1393.5 (1477.7) -> 1393.5 (1477.7) MB, 109.0 / 0.0 ms allocation failure GC in old space requested
<--- JS stacktrace --->
==== JS stack trace =========================================
Security context: 0x24d9482a5ec1
...
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
1: node::Abort() [/Users/xxx/.nvm/versions/node/v10.13.1/bin/node]
2: ...
这个报错的意思就是Node内存不足所导致的,我们都知道 Node 是基于V8引擎,在一般的后端开发语言中,在基本的内存使用上没有什么限制,但是,**在 Node 中通过 JavaScript 使用内存时只能使用部分内存(64位系统下约为1.4 GB,32位系统下约为0.7 GB)**所以不管你电脑实际内存多大,在node运行JavaScript打包编译的时候所使用的内存大小,并不会因为你系统的实际内存大小改变而改变
或者在 92% 的进度里卡很久,
● Webpack █████████████████████████ chunk asset optimization (92%) TerserPlugin
随着产物越来越大,编译上线和 CI 的时间都越来越长,而其中 1/3 及更多的时间则是在做压缩的部分。OOM 的问题也通常来源于压缩,如何解决压缩慢和占内存的问题,一直很令人头疼
因此引出我们今天的主角——esbuild
esbuild
项目主要目标是: 开辟一个构建工具性能的新时代,创建一个易用的现代打包器
。
与其他工具对比
配置难度
vite: 最简单,几乎零配置。vite官方有许多模版可以直接使用,但即使不用模版,你只需要配置一个react热更新插件就足够了。不过,如果你使用的框架没有对应插件,或者你不希望使用默认的配置,那么你就需要仔细阅读文档,并且你需要同时学习vite和rollup甚至esbuild的文档,才能够随心所欲的配置。
esbuild: 比较简单。esbuild的配置项并不多,你只需要增加一个servedir就可以使用它的开发服务器了。
webpack: 比较复杂。这里我使用的是webpack5,只进行最基础的配置和代码分割优化,但是仍然需要安装一大堆的第三方包。其余的配置都需要根据文档一步步进行配置,对于新手并不是特别友好。
启动速度(使用相同的代码及入口)
esbuild: 最快。
vite: 比esbuild慢近10倍,但实际感知不强,依然非常的快。
webpack: 比vite慢10倍有余,实际能感知,需要等待。
热更新及**react-router**
路由配置
vite: 配置官方插件后支持热更新及
react-router
webpack: 热更新需要在devServer中开启,
react-router
也需要进行配置。esbuild: 开发服务器不支持热更新,不支持
react-router
配置
代码分离 和 打包速度
esbuild: 速度最快,但代码分离效果最差。可以看到,最大的js文件有4.8mb,这是无法部署在生产环境中的,如果你需要再优化,配置项目中是没有相关配置的,你需要修改你的代码。
webpack: 速度一般,代码分离效果不错。
esbuild&&esbuild-loader的缺点
esbuild 同样不是完美的(如果真有那么完美为什么还没有大面积使用呢?)
为了保证 esbuild 的编译效率,esbuild 没有提供 AST 的操作能力。所以一些通过 AST 处理代码的 babel-plugin 没有很好的方法过渡到 esbuild 中(说的就是你 babel-plugin-import)。so,如果你的项目使用了 babel-plugin-import, 或者一些自定义的 babel-plugin 。在目前来看是没有很好的迁移方案的。
目前 Esbuild 内置loader 可以处理大量的 格式文件,比如 text,url,wasm 等但是不能处理样式,如果想使用 Esbuild 去处理style 是不可以的。
esbuild-loader 虽然是 异步加载的 但因为 “洋葱模型” 的关系 所有的 脚本文件不是一开始同时并发,而是根据依赖树的 开端依序并发
vite和webpack均支持
*.config.ts
格式的配置文件,而esbuild由于没有对应的cli
启动工具,因此其需要使用node *.js
命令来启动,因此不适合使用ts。esbuild: esbuild确实很快。但是除了快其他的体验都不好,这是一个不成熟的工具,期待其功能的完善。
什么情况下使用esbuild
在生产环境下使用 esbuild 是可行的。像 snowpack , vite 等构建工具都已经使用了 esbuild 作为代码处理工具(稳定性已经足够)。如果你一定要使用,可以看看是否符合下面标准
没有使用一些自定义的 babel-plugin (如 babel-plugin-import)
不需要兼容一些低版本浏览器(esbuild 只能将代码转成 es6)
那你就可以大胆使用 esbuild-loader 为你的项目提效了~
为什么这么快?
esbuild
以速度快
而著称,耗时只有 webpack 的 2% ~3%。
有图有真相
(本图来自github)
几个原因:
它是用Go语言编写的,该语言可以编译为本地代码
(webpack采用的是Javascript;esbuild采用Go)
那为什么语言不同,速度就会有差异呢?
——这要从Go和Javascript的设计来讲了
Go是为了并行性而设计的,而Javascript是单线程的
Go在线程之间共享内存,而Javascript必须在线程之间序列化数据
Go可以直接编译成机器码,不依赖其他库,必然比JIT快(JIT相关的链接JIT相关,大家感兴趣可以看看)
大量使用了并行操作。
esbuild的算法经过精心设计,大致分为三个阶段:
解析 => 链接 => 代码生成。解析和代码生成采用并行化
解析
和代码生成
是大部分工作,并且可以完全并行化
(链接在大多数情况下是固有的串行任务)。
由于所有线程共享内存
,因此当捆绑导入同一JavaScript库的不同入口点时,可以轻松地共享工作。
定制(代码都是自己写的,没有使用其他的依赖)
在 Webpack、Rollup 这类工具中,我们不得不使用很多额外的第三方插件来解决各种工程需求,比如:
使用 babel 实现 ES 版本转译
使用 eslint 实现代码检查
使用 TSC 实现 ts 代码转译与代码检查
使用 less、stylus、sass 等 css 预处理工具
Esbuild 起了个头,选择完全!完全重写整套编译流程所需要用到的所有工具!这意味着它需要重写 js、ts、jsx、json 等资源文件的加载、解析、链接、代码生成逻辑。
性能为最高优先级定制编译的各个阶段,比如说:
重写 ts 转译工具,完全抛弃 ts 类型检查,只做代码转换
大多数打包工具把词法分析、语法分析、符号声明等步骤拆解为多个高内聚低耦合的处理单元,各个模块职责分明,可读性、可维护性较高。而 Esbuild 则坚持性能第一原则,不惜采用反直觉的设计模式,将多个处理算法混合在一起降低编译过程数据流转所带来的性能损耗
一致的数据结构,以及衍生出的高效缓存策略
内存的高效利用
如果要处理大量数据,内存访问速度可能会严重影响性能。
对数据进行的遍历次数越少(将数据转换成数据所需的不同表示形式也就越少),编译器就会越快。
在 Webpack 中使用 babel-loader 处理 JavaScript 代码时,可能需要经过多次数据转换:
Webpack 读入源码,此时为字符串形式
Babel 解析源码,转换为 AST 形式
Babel 将源码 AST 转换为低版本 AST
Babel 将低版本 AST generate 为低版本源码,字符串形式
Webpack 解析低版本源码
Webpack 将多个模块打包成最终产物
源码需要经历 string => AST => AST => string => AST => string
,在字符串与 AST 之间反复横跳。
而 Esbuild 重写大多数转译工具之后,能够在多个编译阶段共用相似的 AST 结构,尽可能减少字符串到 AST 的结构转换,提升内存使用效率。
Go的另一个好处是它可以将内容紧凑地存储在内存中,从而使它可以使用更少的内存并在CPU缓存中容纳更多内容。
所有对象字段的类型和字段都紧密地包装在一起,例如几个布尔标志每个仅占用一个字节。
Go 还具有值语义,可以将一个对象直接嵌入到另一个对象中,因此它是'免费的',无需另外分配。
使用
基础使用
初体验——Hello World
先安装esbuild
npm i esbuild -g
(全局)
下面以一个react项目为例子
项目目录如下:需要先安装react、react-dom依赖(项目依赖)
然后我们执行esbuild进行构建
命令行构建
esbuild src\app.jsx --bundle --outfile=out.js
我们执行打包出来的文件
当然,我们可以配置package.json来进行简化我们的命令
我们只需要执行npm run build
即可
API
在进行使用之前,我们需要了解一部分API,其中比较重要的就是最重要的就是transform和build
transform
transform
就是转换的意思,调用这个API能将ts
,jsx
等文件转换为js文件。Transform API调用对单个字符串进行操作,不需要访问文件系统。非常适合在没有文件系统的环境中使用或作为另一个工具链的一部分。
支持的文件有:
export type Loader = 'js' | 'jsx' | 'ts' | 'tsx' | 'css' | 'json' | 'text' | 'base64' | 'file' | 'dataurl' | 'binary' | 'default'; 复制代码
通常与loader进行结合使用,部分loader的介绍待会进行介绍
示例:
输出:
build
Build API调用对文件系统中的一个或多个文件进行操作。这使得文件可以相互引用,并被编译在一起。
我们还可以通过JS代码来进行打包操作
entry.js如下:
require("esbuild") .build({ entryPoints: ["src/app.jsx"], bundle: true, outfile: "build/out.js", }) .catch(() => process.exit(1)); 复制代码
然后我们执行node entry.js
,可以看到打包后的产物
构建
Bundling for the browser
esbuild默认打包就是为浏览器进行打包,所以我们不需要进行其他配置就可以打包到浏览器使用,当然,我们可以通过其他的配置项进行更完善地打包,如果想要minification,那么可以配置--minify,如果需要使用sourcemap功能,可以配置--sourcemap。或许你需要指定打包到特定的浏览器进行使用,我们可以配置--target
演示一个minify
esbuild app.jsx --bundle --minify
演示sourcemap功能
当然除了在命令行进行配置,我们还可以在package.json或者配置JS代码进行打包
JS代码
require('esbuild').buildSync({ entryPoints: ['app.jsx'], bundle: true, minify: true, sourcemap: true, target: ['chrome58', 'firefox57', 'safari11', 'edge16'], outfile: 'out.js', }) 复制代码
Bundling for the node
bundling可以自动转换TS的类型,将ES模块转换成CommonJS模块,将高版本的ES代码转换成低版本的ES代码。
我们只需要在命令行加上一句--platform=node
esbuild app.js --bundle --platform=node --target=node10.4
这里是可以指定node的版本的
当然也可以通过JS代码进行配置打包
require('esbuild').buildSync({ entryPoints: ['app.js'], bundle: true, platform: 'node', target: ['node10.4'], outfile: 'out.js', }) 复制代码
loader
esbuild内置了很多loader,当然也有一部分loader没有被内置到,后面会讲到
Js-loader
这个加载器默认用于.js、.cjs和.mjs文件。.cjs扩展名被node用于CommonJS模块,而.mjs扩展名被node用于ECMAScript模块,尽管esbuild并没有对这两者进行区分。
esbuild支持所有现代JavaScript语法。请注意,默认情况下,esbuild 的输出将利用所有现代 JS 功能。然而,较新的语法可能不被旧的浏览器所支持,所以你可能想配置目标选项,告诉esbuild将较新的语法转换为适当的旧语法。
比如会出现以下情况:
a !== void 0 && a !== null ? a : b
被转换成a ?? b
,然后可能会导致一些bug的出现。
但请注意,ES5支持的不是很好,目前还不支持将ES6+语法转换为ES5。
ts或者tsx-loader(处理ts代码或者tsx-loader)
这个加载器对于.ts和.tsx文件是默认启用的,这意味着esbuild内置了对TypeScript语法的解析和丢弃类型注释的支持。然而,esbuild不做任何类型检查,所以你仍然需要在esbuild中并行运行tsc -noEmit来检查类型。 需要注意的是,esbuild在编译时不会进行类型检查,这应该在编译之前使用ide去检查。
Jsx-loader
将会把xml代码转换成js代码,这个loader对于.jsx或者.tsx文件是默认开启的
比如jsx代码变成了下面的js代码
import Button from './button' let button = <Button>Click me</Button> render(button) ================================================================ import Button from "./button"; let button = React.createElement(Button, null, "Click me"); render(button); 复制代码
需要注意的是使用Jsx-loader对jsx进行转换的时候,需要在.jsx
文件中引入JSX相关的库,比如你在使用React时,那么你需要在每一个.jsx
文件中import React,比如下面的代码:
import * as React from 'react' import Button from './button' let button = <Button>Click me</Button> render(button) 复制代码
因为在进行转换的时候会调用React.createElement
,不然的话就会报错
Css-loader
css-loader 解释(interpret) @import
和 url()
,会 import/require()
后再解析(resolve)它们。对于.css文件,这个加载器是默认启用的。它以CSS语法的形式加载文件。CSS在esbuild中是一种第一类内容类型,这意味着esbuild可以直接编译CSS文件,而不需要从JavaScript代码中导入你的CSS。
esbuild --bundle app.css --outfile=out.css
File-loader
这个加载器会将文件复制到输出目录,并将文件名作为一个字符串嵌入到编译中。这个字符串是使用默认的导出方式导出的。
复杂场景下的使用
更多的配置项
Serve
在浏览器中重新加载代码之前手动重新运行 esbuild 是很不方便的。
esbuild的serve使用的是使用一个对于每次请求都会重新构建的 web 服务器来为你的代码提供服务
此方法对于其他方法的优势在于 web 服务器可以延迟浏览器请求,知道构建完成。 在最新构建完成之前重新加载你的代码,将永远不会运行上一次构建生成的代码。 这些文件在内存中提供服务,并且没有写入到文件系统中,以确保过时的文件不会被监听。
为 esbuild 构建出的所有内容提供服务
为 esbuild 提供一个名为 servedir 的目录,除了 esbuild 生成的文件之外,还提供了额外的内容。 这对于创建一些静态 HTML 页面并希望使用 esbuild 打包 JavaScript 和/或 CSS 的简单情况非常有用。
使用这种方式的时候,我们可以通过外部文件引用打包后的文件,每个HTTP请求都会导致esbuild重建你的代码。
例子:
index.js如下:
const a = 4; console.log(a); 复制代码
entry.js如下:(用来执行打包程序)
require("esbuild") .serve( { servedir: "www", }, { entryPoints: ["src/index.js"], outdir: "www/js", bundle: true, } ) .then((server) => { console.log(server); // Call "stop" on the web server to stop serving //server.stop(); }); 复制代码
然后我们在index.html中进行外部资源的引用
<!DOCTYPE html> <html> <head> <title>Document</title> </head> <body> <script src="http://localhost:8000/js/index.js"></script> </body> </html> 复制代码
就可以看到控制台输出了4,当我们进行源程序的更改,保存后,无需重新执行entry.js,只需要刷新对应引用的文件就可以看到重新打包后的文件了。实际上这个打包后的代码不会在本地文件系统进行存放,而是放在内存中。(默认端口是8000或者更大)
这样的好处是可以在开发和生产使用完全相同的HTML页面。在开发中,可以使用 --servedir= 运行 esbuild,esbuild 将直接提供生成的输出文件。对于生产,可以省略该标志,esbuild 会将生成的文件写入文件系统。在这两种情况下,应该在开发和生产中使用完全相同的代码在浏览器中获得完全相同的结果。而且通过esbuild生成的web服务器URL结构与输出目录的URL结构完全相同。
Watch
在build API启用监听模式,告诉esbuild监听文件系统的变化,然后进行重新构建
require('esbuild').build({ entryPoints: ['src/index.tsx'], outdir: 'build', bundle: true, watch: { onRebuild(error, result) { if (error) console.error('watch build failed:', error) else console.log('watch build succeeded:', result) }, }, }).then(result => { // Call "stop" on the result when you're done result.stop() }) 复制代码
Bundle
打包一个文件意味着将任何导入的依赖项内联到文件中。 这个过程是递归的,因为依赖的依赖(等等)也将被内联。请注意打包与文件连接不同。在启用打包时向 esbuild 传递多个输入文件 将创建两个单独的 bundle 而不是将输入文件连接在一起。 为了使用 esbuild 将一系列文件打包在一起, 在一个入口起点文件中引入所有文件, 然后就像打包一个文件那样将它们打包。
require('esbuild').buildSync({ entryPoints: ['in.js'], bundle: true, outfile: 'out.js', }){ errors: [], warnings: [] } 复制代码
Entry points
配置打包的入口文件,是一个数组,打包后会变成独立的脚本
require('esbuild').buildSync({ entryPoints: ['home.ts', 'settings.ts'], bundle: true, write: true, outdir: 'out', }) 复制代码
打包后会产生两个文件,out/home.js
和 out/settings.js
Format
设置生成的JS输出格式,有三个可选值:iife
、cjs
与 esm
。
iife
格式代表“立即调用函数表达式
require("esbuild").buildSync({ entryPoints: ["src/home.ts", "src/index.ts"], bundle: true, format: "iife", outdir: "bundle", }); 复制代码
然后输出为下面:
(() => { // src/home.ts var a = 1; console.log(a); })(); 复制代码
cjs
格式打包代表"CommonJS" 并且在 node 环境中运行。在 ECMAScript 模块语法中带有导出的入口点将被转换为一个模块, 每个导出名称的 “exports” 上都有一个 getter。当你设置 platform 为 node
时, cjs
为默认格式。
require("esbuild").buildSync({ entryPoints: ["src/home.ts", "src/index.ts"], bundle: true, format: "cjs", outdir: "bundle", }); 复制代码
打包后:
var __defProp = Object.defineProperty; var __reflectGet = Reflect.get; var __reflectSet = Reflect.set; var __markAsModule = (target) => __defProp(target, "__esModule", { value: true }); var __export = (target, all) => { __markAsModule(target); for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; // src/home.ts __export(exports, { a: () => a }); var a = 1; console.log(a); 复制代码
esm
格式代表 "ECMAScript module"。 在 CommonJS 模块语法中带有导出的入口点将被转换为 module.exports
值的单个 default
导出。
打包后:
// src/home.ts var a = 1; console.log(a); export { a }; 复制代码
Loader
该配置项改变了输入文件解析的方式。例如, [js](https://esbuild.docschina.org/content-types/#javascript)
loader 将文件解析为 JavaScript, [css](https://esbuild.docschina.org/content-types/#css)
loader 将文件解析为 CSS。
Tree shaking
esbuild 中的 tree shaking 在绑定期间总是启用的,而且不能关闭,因为在不改变可观察行为的情况下,移除未使用的代码会使结果文件变小。
用一个例子来解释 tree shaking 是最简单的。考虑以下文件。有一个已使用的函数和一个未使用的函数:
// input.js function one() { console.log('one') }function two() { console.log('two') } one() 复制代码
如果你使用 esbuild --bundle input.js --outfile=output.js
打包该文件, 没有使用到的函数将会自动销毁,并为你产生以下输出:
// input.js function one() { console.log("one"); } one(); 复制代码
即使我们将函数分割成一个单独的库文件并使用 import
语句导入它们也是有效的:
// lib.js export function one() { console.log('one') }export function two() { console.log('two') } //打包后 // input.js import * as lib from './lib.js' lib.one() 复制代码
如何与其他工具联动使用
直接使用esbuild
有时候我们不希望使用脚手架进行搭建项目,希望可以自己进行配置,那么下面是一个最简单的例子:
新建文件
index.html引用打包后的两个文件
index.tsx如下:
import React from 'react'; import ReactDOM from 'react-dom'; import './style.css'; const root = document.createElement('div'); root.className = 'root'; document.body.appendChild(root); const App = () => { return ( <div> <h1 className='esbuild'>Hello, Esbuild!</h1> <h1 className='react'>Hello, React!</h1> </div> ); }; ReactDOM.render( <App />, root, ); 复制代码
index.css如下:
.esbuild { color: rgb(247, 209, 71); } .react { color: rgb(97, 218, 251); } 复制代码
然后执行打包
在你的package.json文件的script标签中加入"build": "esbuild src/index.jsx --outfile=build/index.js --bundle"
,。 然后在终端中输入npm run build
命令
然后在index.html中引用我们打包后的文件,就可以看到页面的效果了
但是这样很明显每次我们更改所引用的文件就需要手动执行一遍build,很不方便,能不能做到热更新呢?
答案肯定是有的,我们可以在package.json中之前build命令后面加一个--watch
或者增加一个watch命令 "watch": "esbuild src/index.tsx --outfile=build/index.js --bundle --watch"
然后借助vs code的live server插件就可以实现实时的效果了。
还有上面讲到的一个API——server,我们可以利用server来进行监听文件的变化
让我们写一个打包入口的JS文件,entry.js
require("esbuild") .serve( { port: 8022, }, { entryPoints: ["src/index.tsx"], bundle: true, outdir: "www", } ) .then((server) => {}); 复制代码
然后我们在index.html中重新引用,但注意:!!!此时需要更改引用的路径为
localhost:8022/加上你要引用的打包后的文件名(由于没有指定的话,那么打包前的文件名与打包后的文件名是一致的)
<!DOCTYPE html> <html> <head> <meta charset="UTF-8" /> <link rel="stylesheet" href="http://localhost:8022/index.css" /> <title>Title</title> </head> <body> <script src="http://localhost:8022/index.js"></script> </body> </html> 复制代码
我们打开该页面,可以看到与上面的效果一致。
当然,这只是一个比较简单的样例,对于比较复杂的项目,或者已经定好框架的项目,我们不可能全部进行重构,我们可以借助esbuild-loader来完成一些优化
Esbuild-loader的应用
esbuild-loader由hiroki osame开发,是一个建立在 esbuild 之上的 webpack 加载器。它允许用户通过它交换ts-loader
或 babel-loader
,这大大提高了构建速度。
那么如何将现有项目迁移到 esbuild?
使用无论babel-loader
或是ts-loader
都可以非常直接的迁移一个项目到esbuild-loader
。首先,安装依赖:
npm i -D esbuild-loader 复制代码
如果你正用babel-loader
, 根据下文修改您的webpack.config.js
:
module.exports = { module: { rules: [ - { - test: /\.js$/, - use: 'babel-loader', - }, + { + test: /\.js$/, + loader: 'esbuild-loader', + options: { + loader: 'jsx', // Remove this if you're not using JSX + target: 'es2015' // Syntax to compile to (see options below for possible values) + } + }, 复制代码
为了体现esbuild-loader在react项目中的应用,我新建了一个应用
npx create-react-app my-app --template typescript
这样会在my-app目录使用TS来构建一个React项目,我们就用原本的模板程序执行构建操作
time npm run build
所用时间大概是22.08s
因为create react app在幕后使用了babel-loader,所以我们需要自定义create react app ,其中一种方法就是craco工具,来进行配置。
先添加对应的依赖
npm install @craco/craco esbuild-loader --save-dev
然后替换package.json来使用craco
"start": "craco start",
伪原创工具 SEO网站优化 https://www.237it.com/
"build": "craco build",
"test": "craco test",
接着配置一个craco.config.js的文件在项目根目录上面。这是我们为esbuild-loader
而换出babel-loader
,代码比较长,就不放出来啦
然后再执行 time npm run build
我们的完整构建、TypeScript 类型检查、转译、缩小等全部耗时 13.85 秒。通过迁移到esbuild-loader
,我们将整体编译时间减少了大约三分之一。
作者:前端菜鸡小灰
链接:https://juejin.cn/post/7035182298437779470