Vite 源码(一)ESbuild 使用Vite 中很多地方都是用了 ESbuild,比如 请求 ts、jsx、tsx
Vite 中的 ESbuild
Vite 中很多地方都是用了 ESbuild,比如
- 转译
ts
类型的配置文件 - 请求
ts
、jsx
、tsx
文件时,将其编译成js
文件 - 自动搜寻预编译模块列表
- 预编译模块
所以看 Vite 源码之前学会 ESbuild 还是有必要的
ESbuild 为什么快
- js是单线程串行,ESbuild 是新开一个进程,然后多线程并行,充分发挥多核优势
- 生成最终文件和生成 source maps 全部并行化
- Go 可直接编译成机器码,肯定要比 JIT 快
- 对构建流程进行了优化,充分利用 CPU 资源
ESbuild 有哪些缺点
- ESbuild 不能很好的支持 es6+ 转 es5;参考 JavaScript 注意事项。为了保证 ESbuild 的编译效率,ESbuild 没有提供 AST 的操作能力。所以一些通过 AST 处理代码的 babel-plugin 没有很好的方法过渡到 ESbuild 中。比如
babel-plugin-import
- 构建应用的重要功能仍然还在持续开发中 —— 特别是
代码分割
和CSS处理
方面 - ESbuild社区 和 webpack社区 相比差距有点大
怎么判断ESbuild能否在当前项目中使用
- 没有使用一些自定义的 babel-plugin (如
babel-plugin-import
) - 不需要兼容一些低版本浏览器(ESbuild 只能将代码转成 es6)
比如 Vite,开发环境中的预构建、文件编译使用的是 ESbuild,而生产环境使用的是 Rollup。这是因为 ESbuild 一些针对构建应用的重要功能仍然还在持续开发中 —— 特别是代码分割
和 CSS处理
方面。就目前来说,Rollup
在应用打包方面, 更加成熟和灵活。
ESbuild 使用
ESbuild 有命令行 ,js 调用, go 调用三种使用方式。
命令行
# 入口文件 esbuild index.js
# --outfile 输出文件
# --define:TEST=12 环境变量
# --format=cjs 编译后的模块规范
# --bundle 将第三方库打包到一起
# --platform=[node/browser] 指定编译后的运行环境
# --target=esnext
# --loader:.png=dataurl 将 png 转换成base64的形式,需要与 --bundle 一起使用
JavaScript 方式
ESbuild 抛出 3 个API,分别是
- transform API
- build API
- service
transform API
transform/transformSync
对单个字符串进行操作,不需要访问文件系统。非常适合在没有文件系统的环境中使用或作为另一个工具链的一部分,它提供了两个参数:
transformSync(str: string, options?: Config): Result
transform(str: string, options?: Config): Promise<Result>
str
:字符串(必填),指需要转化的代码options
:配置项(可选),指转化需要的选项
Config 具体配置参考官网,这里只说常用配置
interface Config {
define: object # 关键词替换
format: string # js 输出规范(iife/cjs/esm)
loader: string | object # transform API 只能使用 string
minify: boolean # 压缩代码,包含删除空格、重命名变量、修改语法使语法更简练
# 通过以下方式单独配置,上述功能
minifyWhitespace: boolean # 删除空格
minifyIdentifiers: boolean # 重命名变量
minifySyntax: boolean # 修改语法使语法更简练
sourcemap: boolean | string
target: string[] # 设置目标环境,默认是 esnext(使用最新 es 特性)
}
返回值:
- 同步方法(
transformSync
)返回一个对象 - 异步方法(
transform
)返回值为Promise
对象
interface Result {
warnings: string[] # 警告信息
code: string # 编译后的代码
map: string # source map
}
举例
require('esbuild').transformSync('let x: number = 1', {
loader: 'ts',
})
// =>
// {
// code: 'let x = 1;\n',
// map: '',
// warnings: []
// }
build API
Build API调用对文件系统中的一个或多个文件进行操作。这使得文件可以相互引用,并被编译在一起(需要设置bundle: true
)
buildSync(options?: Config): Result
build(options?: Config): Promise<Result>
options
:配置项(可选),指转化需要的选项
Config 具体配置参考官网,这里只说常用配置
interface Config {
bundle: boolean # 将所有源码打包到一起
entryPoints: string[] | object # 入口文件,通过对象方式可以指定输出后文件名,和 webpack 类似
outdir: string # 输出文件夹,不能和 outfile 同时使用;多入口文件使用 outdir
outfile: string # 输出的文件名,,不能和 outdir 同时使用;单入口文件使用 outfile
outbase: string # 每个入口文件构建到不同目录时使用
define: object # define = {K: V} 在解析代码的时候用V替换K
platform: string # 指定输出环境,默认为 browser 还有一个值是 node,
format: string # js 输出规范(iife/cjs/esm),如果 platform 为 browser,默认为 iife;如果 platform 为 node,默认为 cjs
splitting: boolean # 代码分割(当前仅限 esm模式)
loader: string | object # transform API 只能使用 string
minify: boolean # 压缩代码,包含删除空格、重命名变量、修改语法使语法更简练
# 通过以下方式单独配置,上述功能
minifyWhitespace: boolean # 删除空格
minifyIdentifiers: boolean # 重命名变量
minifySyntax: boolean # 修改语法使语法更简练
sourcemap: boolean | string
target: string[] # 设置目标环境,默认是 esnext(使用最新 es 特性)
jsxFactory: string # 指定调用每个jsx元素的函数
jsxFragment: string # 指定聚合一个子元素列表的函数
assetNames: string # 静态资源输出的文件名称(默认是名字加上hash)
chunkNames: string # 代码分割后输出的文件名称
entryNames: string # 入口文件名称
treeShaking: string # 默认开启,如果设置 'ignore-annotations',则忽略 /* @__PURE__ */ 和 package.json 的 sideEffects 属性
tsconfig: string # 指定 tsconfig 文件
publicPath: string # 指定静态文件的cdn,比如 https://www.example.com/v1 (对设置loader为file 的静态文件生效)
write: boolean # 默认 false,对于cli和js API,默认是写入文件系统中,设置为 true 后,写入内存缓冲区
inject: string[] # 将数组中的文件导入到所有输出文件中
metafile: boolean # 生成依赖图
}
build返回值是一个Promise
对象
interface BuildResult {
warnings: Message[]
outputFiles?: OutputFile[] # 只有在 write 为 false 时,才会输出,它是一个 Uint8Array
}
举例
require('esbuild').build({
entryPoints: ['index.js'],
bundle: true,
metafile: true,
format: 'esm',
outdir: 'dist',
plugins: [],
}).then(res => {
console.log(res)
})
常用配置
outbase
outbase: string
多入口文件在不同目录时,那么相对于outbase
目录,目录结构将被复制到输出目录中
require('esbuild').buildSync({
entryPoints: [
'src/pages/home/index.ts',
'src/pages/about/index.ts',
],
bundle: true,
outdir: 'out',
outbase: 'src',
})
上面代码中,有两个入口文件分别是src/home/index.ts
、src/about/index.ts
;并设置outbase
为src
,即相对于src
目录打包;打包后文件分别在out/home/index.ts
、out/about/index.ts
bundle
仅支持 build API
bundle: boolean
如果是 true
,将依赖项内联到文件本身中。 此过程是递归的,因此依赖项的依赖项也将被合并,默认情况下,ESbuild 不会捆绑输入文件,即为 false
。对于动态的模块名不会合并而是和源码保持一致,如下:
// Static imports (will be bundled by esbuild)
import 'pkg';
import('pkg');
require('pkg');
// Dynamic imports (will not be bundled by esbuild)
import(`pkg/${foo}`);
require(`pkg/${foo}`);
['pkg'].map(require);
如果有多个入口文件,则会创建多个单独的文件,并合并依赖项。
sourcemap
sourcemap: boolean | string
true
:生成.js.map
并且生成的文件添加//# sourceMappingURL=
false
:不使用 sourcemap'external'
:生成.js.map
,生成的文件不添加//# sourceMappingURL=
'inline'
:不生成.js.map
,source map
信息内联到文件中'both'
:'inline' + 'external'
模式。生成.js.map
,但是生成的文件信息不添加//# sourceMappingURL=
define
关键词替换
let js = 'DEBUG && require("hooks")'
require('esbuild').transformSync(js, {
define: { DEBUG: 'true' },
})
// {
// code: 'require("hooks");\n',
// map: '',
// warnings: []
// }
require('esbuild').transformSync('id, str', {
define: { id: 'text', str: '"text"' },
})
// {
// code: 'text, "text";\n',
// map: '',
// warnings: []
// }
双引号包含字符串,说明编译后的代码会被替换成字符串,而没有双引号包含编译后被替换成关键词
loader
loader: string | object
# 可选值有:'js' | 'jsx' | 'ts' | 'tsx' | 'css' | 'json' | 'text' | 'base64' | 'file' | 'dataurl' | 'binary'
举例
// build API 使用文件系统,需要根据后缀名去使用对应loader
require('esbuild').buildSync({
loader: {
'.png': 'dataurl',
'.svg': 'text',
}
})
// transform API 不实用文件系统,不需要使用后缀名。只能使用一个 loader,因为 transform API 只操作一个字符串
let ts = 'let x: number = 1'
require('esbuild').transformSync(ts, {
loader: 'ts'
})
jsxFactory&jsxFragment
jsxFactory
:指定调用每个jsx元素的函数jsxFragment
:Fragments 可以让你聚合一个子元素列表,并且不在DOM中增加额外节点
require('esbuild').transformSync('<div/>', {
jsxFactory: 'h', //默认为 React.CreateElement,可自定义, 如果你想使用 Vue 的 jsx 写法, 将该值换成为 Vue.CreateElement
loader: 'jsx', // 将 loader 设置为 jsx 可以编译 jsx 代码
})
// 同上,默认为 React.Fragment , 可换成对应的 Vue.Fragment。
require('esbuild').transformSync('<>x</>', {
jsxFragment: 'Fragment',
loader: 'jsx',
})
如果是tsx
文件,可以通过在tsconfig
中添加这个来为TypeScript配置JSX。ESbuild会自动拾取它,而不需要配置
{
"compilerOptions": {
"jsxFragmentFactory": "Fragment",
"jsxFactory": "h"
}
}
assetNames
如果静态资源的loader
设置的是file
,则可以通过次属性重新定义静态资源的位置和名称
require('esbuild').buildSync({
entryPoints: ['app.js'],
assetNames: 'assets/[name]-[hash]',
loader: { '.png': 'file' }, // 必须
bundle: true,
outdir: 'out',
})
如果代码引入了3.png
,则打包后图片的位置是out/assets/3-hash值.png
提供了3个占位符
[name]
:文件名[dir]
:从包含静态文件的目录到outbase
目录的相对路径[hash]
:hash 值,根据内容生成的 hash 值
chunkNames
控制在启用代码分割时自动生成的共享代码块的文件名
require('esbuild').buildSync({
entryPoints: ['app.js'],
chunkNames: 'chunks/[name]-[hash]',
bundle: true,
outdir: 'out',
splitting: true, // 必须
format: 'esm', // 必须
})
有两个占位符
[name]
:文件名[hash]
:hash 值,根据内容生成的 hash 值
注意:不需要包含后缀名。此属性只能修改代码分割输出的文件名称,而不能修改入口文件名称。
现在测试发现一个问题,就是如果两个入口文件引用了同一张图片,配置代码分割和assetNames
的话,会打包出一个js文件和一个图片文件,图片文件放在了assetNames对应的目录下,而js文件放在了chunkNames对应的目录下,这个js文件内部导出了这个图片文件,如下
// 3.jpg
var __default = "../assets/3-FCRZLGZY.jpg";
export {
__default
};
entryNames
指定入口文件的位置和名称
require('esbuild').buildSync({
entryPoints: ['src/main-app/app.js'],
entryNames: '[dir]/[name]-[hash]',
outbase: 'src',
bundle: true,
outdir: 'out',
})
提供了3个占位符
[name]
:文件名[dir]
:从包含静态文件的目录到outbase
目录的相对路径[hash]
:hash 值,根据内容生成的 hash 值
metafile
对打包到一起的文件生成依赖图,存放在下述的res.metafile
中
- 如果配置项
bundle
为false
,生成的依赖图只包含入口文件和入口文件中的引入文件 - 如果配置项
bundle
为true
,打包到一起的文件都会包含在依赖图中,如下
require('esbuild').build({
entryPoints: ['index.js'],
bundle: true, // 设置为 true
metafile: true,
format: 'esm',
outdir: 'dist',
}).then(res => {
console.log(res);
})
/*
metafile: {
"inputs": {
"b.js": { "bytes": 18, "imports": [] },
"a.js": {
"bytes": 54,
"imports": [{ "path": "b.js", "kind": "import-statement" }]
},
"index2.js": {
"bytes": 146,
"imports": [{ "path": "a.js", "kind": "dynamic-import" }] // index.js 中导入的文件
}
},
"outputs": {
"dist/index2.js": {
"imports": [],
"exports": [],
"entryPoint": "index2.js",
"inputs": {
"b.js": { "bytesInOutput": 78 },
"a.js": { "bytesInOutput": 193 },
"index2.js": { "bytesInOutput": 184 }
},
"bytes": 1017
}
}
}
*/
如果某个文件引入了第三方库,生成的res.metafile
也会包含第三方库的地址,Vite中实现了一个插件,目的是不将第三方库打包到bundle中,依然通过引入的方式加载
const externalizeDep = {
name: 'externalize-deps',
setup(build) {
// 如果返回值为 undefined,则会调用下一个 onResolve 注册的回调,反之不会继续向下执行
build.onResolve({ filter: /.*/ }, (args) => {
const id = args.path
// 如果是外部模块
if (id[0] !== '.' && !path.isAbsolute(id)) {
return {
external: true, // 将此设置为 true,将该模块标记为第三方模块,这意味着它将不会包含在包中,而是在运行时被导入
}
}
})
}
}
ESbuild 热更新
插件
插件API属于上面提到的API调用的一部分,插件API允许你将代码注入到构建过程的各个部分。与API的其他部分不同,它不能从命令行中获得。你必须编写JavaScript或Go代码来使用插件API。
插件API只能用于Build API,不能用于Transform API
如果你正在寻找一个现有的 ESbuild 插件,你应该看看现有的esbuild插件的列表。这个列表中的插件都是作者特意添加的,目的是为了让 ESbuild 社区中的其他人使用。
如何写插件
一个 ESbuild 插件是一个包含name
和setup
函数的对象
export default {
name: "env",
setup(build) {}
};
name
:插件名称setup
函数:每次build API调用时都会运行一次build
中包含一些钩子函数
onStart # 开始时触发
onResolve # 遇到导入路径时运行,拦截导入路径
onLoad # 解析完成之后触发
onEnd # 打包完成之后触发
onResolve
在 ESbuild 构建的每个模块的每个导入路径上运行。onResolve
注册的回调可以定制 ESbuild 如何进行路径解析
type Cb = (args: OnResolveArgs) => OnResolveResult
type onResolve = ({}: OnResolveOptions, cb: Cb) => {}
onResolve
注册回调函数时,需要传入匹配参数和一个回调,并且回调需要返回OnResolveResult
类型的对象
先看下匹配参数
interface OnResolveOptions {
filter: RegExp;
namespace?: string;
}
filter
:必须,每个回调都必须提供一个过滤器,它是一个正则表达式。 当路径与此过滤器不匹配时,将跳过当前回调。namespace
:可选,在filter
匹配的前提下,模块命名空间也相同,则触发回调。可通过上一个onResolve
钩子函数返回,默认是flie
回调函数接收的参数
interface OnResolveArgs {
path: string; # 导入文件路径,和代码中导入路径一致
importer: string; # 绝对路径,该文件在哪个文件里被导入的
namespace: string; # 导入文件的命名空间 默认值 'file'
resolveDir: string; # 绝对路径,该文件在哪个目录下被导入
kind: ResolveKind; # 导入方式
pluginData: any; # 上一个插件传递的属性
}
type ResolveKind =
| 'entry-point' # 入口文件
| 'import-statement' # ESM 导入
| 'require-call'
| 'dynamic-import' # 动态导入 import ('')
| 'require-resolve'
| 'import-rule' # css @import 导入
| 'url-token'
回调函数返回值
如果返回值为undefined
,则会调用下一个onResolve
注册的回调,反之不会继续向下执行。
interface OnResolveResult {
errors?: Message[];
external?: boolean; # 将此设置为 true,将该模块标记为外部模块,这意味着它将不会包含在包中,而是在运行时被导入
namespace?: string; # 文件命名空间,默认为 'file',表示 esbuild 会走默认处理
path?: string; # 插件解析后的文件路径
pluginData?: any; # 传递给下一个插件的数据
pluginName?: string;
warnings?: Message[];
watchDirs?: string[];
watchFiles?: string[];
}
interface Message {
text: string;
location: Location | null;
detail: any; // The original error from a JavaScript plugin, if applicable
}
interface Location {
file: string;
namespace: string;
line: number; // 1-based
column: number; // 0-based, in bytes
length: number; // in bytes
lineText: string;
}
Demo
const externalizeDep = {
name: 'externalize-deps',
setup(build) {
// 如果返回值为 undefined,则会调用下一个onResolve注册的回调,反之不会继续向下执行
build.onResolve({ filter: /.*/ }, (args) => {
console.log(args);
const id = args.path
// 如果是外部模块
if (id[0] !== '.' && !path.isAbsolute(id)) {
return {
external: true, // 将此设置为 true,将该模块标记为第三方模块,这意味着它将不会包含在包中,而是在运行时被导入
}
}
})
}
}
onLoad
非外部文件加载完成后会触发onLoad
注册的回调函数
type Cb = (args: OnLoadArgs) => OnLoadResult
type onLoad = ({}: OnLoadOptions, cb: Cb) => {}
// 参数, onResolve 相同
interface OnLoadOptions {
filter: RegExp;
namespace?: string;
}
// 回调中传入的参数
interface OnLoadArgs {
path: string; // 被加载文件的绝对路径
namespace: string; // 被加载文件的命名空间
pluginData: any; // 上一个插件返回的数据
}
// 回调返回值
interface OnLoadResult {
contents?: string | Uint8Array; // 指定模块的内容。 如果设置了此项,则不会为此解析路径运行更多加载回调。 如果未设置,esbuild 将继续运行在当前回调之后注册的加载回调。 然后,如果内容仍未设置,如果解析的路径的命名空间为 'file',esbuild 将默认从文件系统加载内容
errors?: Message[];
loader?: Loader; // 设置该模块的loader,默认为 'js'
pluginData?: any;
pluginName?: string;
resolveDir?: string; // 将此模块中的导入路径解析为文件系统上的真实路径时要使用的文件系统目录。对于'file'命名空间中的模块,该值默认为模块路径的目录部分。 否则这个值默认为空,除非插件提供一个。 如果插件不提供,esbuild 的默认行为将不会解析此模块中的任何导入。 此目录将传递给在此模块中未解析的导入路径上运行的任何解析回调。
warnings?: Message[];
watchDirs?: string[];
watchFiles?: string[];
}
插件举例
假设如果通过cdn引入lodash
的add
方法,打包时将lodash
中的代码加到 bundle 中
import add from 'https://unpkg.com/lodash-es@4.17.15/add.js'
console.log(add(1, 1))
插件实现
const axios = require('axios')
const httpUrl = {
name: 'httpurl',
setup(build) {
build.onResolve({ filter: /^https?:\/\// }, (args) => {
return {
path: args.path,
namespace: 'http-url',
}
})
build.onResolve({ filter: /.*/, namespace: 'http-url' }, (args) => {
return {
path: new URL(args.path, args.importer).toString(),
namespace: 'http-url',
}
})
build.onLoad({ filter: /.*/, namespace: 'http-url' }, async (args) => {
const res = await axios.get(args.path)
return {
contents: res.data,
}
})
},
}
require('esbuild').build({
entryPoints: ['index.js'],
outdir: 'dist',
bundle: true,
format: 'esm',
plugins: [httpUrl],
})
vite 中手写的插件,将js
、ts
代码中的import.meta.url
、__dirname
、__filename
转换成绝对路径输出
const replaceImportMeta = {
name: 'replace-import-meta',
setup(build) {
build.onLoad({ filter: /\.[jt]s$/ }, async (args) => {
const contents = await fs.promises.readFile(args.path, 'utf8')
return {
loader: args.path.endsWith('.ts') ? 'ts' : 'js',
contents: contents
.replace(
/\bimport\.meta\.url\b/g,
JSON.stringify(`file://${args.path}`)
)
.replace(
/\b__dirname\b/g,
JSON.stringify(path.dirname(args.path))
)
.replace(/\b__filename\b/g, JSON.stringify(args.path))
}
})
}
}
总结
以上就是 ESbuild 的常用配置以及怎么实现自定义插件。Vite中预构建过程、编译过程都是使用的 ESbuild。这也是Vite 快的速度之一。
知道了 ESbuild 用法之后,接下来正式开始 Vite 源码解析
转载自:https://juejin.cn/post/7043777969051058183