因为Lynx(公司自研跨端框架)编译工具和传统Web编译工具链有较大的差异(如不支持动态style和动态script基本告别了bundleless和code splitting,模块系统基于json而非js,没有浏览器环境),且有在Web端实时编译(搭建系统)、web端动态编译(WebIDE),服务端实时编译(服务端编译下发)、和多版本切换等需求,所以咱们须要开发一个即支持在本地也支持在浏览器工做且能够根据业务灵活定制开发的bundler,即universal bundler,在开发universal bundler的过程当中也碰到了一些问题,最后咱们基于esbuild开发了全新的universal bundler,解决了咱们碰到的大部分问题。css
bundler的工做就是将一系列经过模块方式组织的代码将其打包成一个或多个文件,咱们常见的bundler包括webpack、rollup、esbuild等。 这里的模块组织形式大部分指的是基于js的模块系统,但也不排除其余方式组织的模块系统(如wasm、小程序的json的usingComponents,css和html的import等),其生成文件也可能不只仅是一个文件如(code spliting生成的多个js文件,或者生成不一样的js、css、html文件等)。 大部分的bundler的核心工做原理都比较相似,可是其会偏重某些功能,如html
bundler的实现和大部分的编译器的实现很是相似,也是采用三段式设计,咱们能够对比一下前端
GJWJP 这也使得传统的LLVM的不少编译优化策略实际上也可在bundler中进行,esbuild就是将这一作法推广到极致的例子。 由于rollup的功能和架构较为精简,咱们以rollup为例看看一个bundler的是如何工做的。 rollup的bundle过程分为两步rollup和generate,分别对应了bundler前端和bundler后端两个过程。node
import lib from './lib';
console.log('lib:', lib);
复制代码
const answer = 42;
export default answer;
复制代码
首先经过生成module graphreact
const rollup = require('rollup');
const util = require('util');
async function main() {
const bundle = await rollup.rollup({
input: ['./src/index.js'],
});
console.log(util.inspect(bundle.cache.modules, { colors: true, depth: null }));
}
main();
复制代码
输出内容以下webpack
[
{
code: 'const answer = 42;\nexport default answer;\n',
ast: xxx,
depenencies: [],
id: 'Users/admin/github/neo/examples/rollup-demo/src/lib.js'
...
},
{
ast: xxx,
code: 'import lib from './lib';\n\nconsole.log('lib:', lib);\n',
dependencies: [ '/Users/admin/github/neo/examples/rollup-demo/src/lib.js' ]
id: '/Users/admin/github/neo/examples/rollup-demo/src/index.js',
...
}]
复制代码
咱们的生成产物里已经包含的各个模块解析后的ast结构,以及模块之间的依赖关系。 待构建完module graph,rollup就能够继续基于module graph根据用户的配置构建产物了。c++
const result = await bundle.generate({
format: 'cjs',
});
console.log('result:', result);
复制代码
生成内容以下git
exports: [],
facadeModuleId: '/Users/admin/github/neo/examples/rollup-demo/src/index.js',
isDynamicEntry: false,
isEntry: true,
type: 'chunk',
code: "'use strict';\n\nconst answer = 42;\n\nconsole.log('lib:', answer);\n",
dynamicImports: [],
fileName: 'index.js',
复制代码
因此一个基本的JavaScript的bundler流程并不复杂,可是其若是要真正的应用于生产环境,支持复杂多样的业务需求,就离不开其强大的插件系统。es6
大部分的bundler都提供了插件系统,以支持用户能够本身定制bundler的逻辑。如rollup的插件分为input插件和output插件,input插件对应的是根据输入生成Module Graph的过程,而output插件则对应的是根据Module Graph生成产物的过程。 咱们这里主要讨论input插件,其是bundler插件系统的核心,咱们这里以esbuild的插件系统为例,来看看咱们能够利用插件系统来作什么。 input的核心流程就是生成依赖图,依赖图一个核心的做用就是肯定每一个模块的源码内容。input插件正提供了如何自定义模块加载源码的方式。 大部分的input 插件系统都提供了两个核心钩子github
load这里esbuild和rollup与webpack处理有所差别,esbuild只提供了load这个hooks,你能够在load的hooks里作transform的工做,rollup额外提供了transform的hooks,和load的职能作了显示的区分(但并不阻碍你在load里作transform),而webpack则将transform的工做下放给了loader去完成。 这两个钩子的功能看似虽小,组合起来却能实现很丰富的功能。(插件文档这块,相比之下webpack的文档简直垃圾) esbuild插件系统相比于rollup和webpack的插件系统,最出色的就是对于virtual module的支持。咱们简单看几个例子来展现插件的做用。
你们使用webpack最多见的一个需求就是使用各类loader来处理非js的资源,如导入图片css等,咱们看一下如何用esbuild的插件来实现一个简单的less-loader。
export const less = (): Plugin => {
return {
name: 'less',
setup(build) {
build.onLoad({ filter: /.less$/ }, async (args) => {
const content = await fs.promises.readFile(args.path);
const result = await render(content.toString());
return {
contents: result.css,
loader: 'css',
};
});
},
};
};
复制代码
咱们只须要在onLoad里经过filter过滤咱们想要处理的文件类型,而后读取文件内容并进行自定义的transform,而后将结果返回给esbuild内置的css loader处理便可。是否是十分简单 大部分的loader的功能均可以经过onLoad插件实现。
上面的例子比较简化,做为一个更加成熟的插件还须要考虑transform后sourcemap的映射和自定义缓存来减少load的重复开销以及错误处理,咱们来经过svelte的例子来看如何处理sourcemap和cache和错误处理。
let sveltePlugin = {
name: 'svelte',
setup(build) {
let svelte = require('svelte/compiler')
let path = require('path')
let fs = require('fs')
let cache = new LRUCache(); // 使用一个LRUcache来避免watch过程当中内存一直上涨
build.onLoad({ filter: /.svelte$/ }, async (args) => {
let value = cache.get(args.path); // 使用path做为key
let input = await fs.promises.readFile(args.path, 'utf8');
if(value && value.input === input){
return value // 缓存命中,跳事后续transform逻辑,节省性能
}
// This converts a message in Svelte's format to esbuild's format
let convertMessage = ({ message, start, end }) => {
let location
if (start && end) {
let lineText = source.split(/\r\n|\r|\n/g)[start.line - 1]
let lineEnd = start.line === end.line ? end.column : lineText.length
location = {
file: filename,
line: start.line,
column: start.column,
length: lineEnd - start.column,
lineText,
}
}
return { text: message, location }
}
// Load the file from the file system
let source = await fs.promises.readFile(args.path, 'utf8')
let filename = path.relative(process.cwd(), args.path)
// Convert Svelte syntax to JavaScript
try {
let { js, warnings } = svelte.compile(source, { filename })
let contents = js.code + `//# sourceMappingURL=` + js.map.toUrl() // 返回sourcemap,esbuild会自动将整个链路的sourcemap进行merge
return { contents, warnings: warnings.map(convertMessage) } // 将warning和errors上报给esbuild,经esbuild再上报给业务方
} catch (e) {
return { errors: [convertMessage(e)] }
}
})
}
}
require('esbuild').build({
entryPoints: ['app.js'],
bundle: true,
outfile: 'out.js',
plugins: [sveltePlugin],
}).catch(() => process.exit(1))
复制代码
至此咱们实现了一个比较完整的svelte-loader的功能。
esbuild插件相比rollup插件一个比较大的改进就是对virtual module的支持,通常bundler须要处理两种形式的模块,一种是路径对应真是的磁盘里的文件路径,另外一种路径并不对应真实的文件路径而是须要根据路径形式生成对应的内容即virtual module。 virtual module有着很是丰富的应用场景。
举一个常见的场景,咱们开发一个相似rollupjs.org/repl/ 之类的repl的时候,一般须要将一些代码示例加载到memfs里,而后在浏览器上基于memfs进行构建,可是若是例子涉及的文件不少的话,一个个导入这些文件是很麻烦的,咱们能够支持glob形式的导入。 examples/
examples
index.html
index.tsx
index.css
复制代码
import examples from 'glob:./examples/**/*';
import {vol} from 'memfs';
vol.fromJson(examples,'/'); //将本地的examples目录挂载到memfs
复制代码
相似的功能能够经过vite或者babel-plugin-macro来实现,咱们看看esbuild怎么实现。 实现上面的功能其实很是简单,咱们只须要
const globReg = /^glob:/;
export const pluginGlob = (): Plugin => {
return {
name: 'glob',
setup(build) {
build.onResolve({ filter: globReg }, (args) => {
return {
path: path.resolve(args.resolveDir, args.path.replace(globReg, '')),
namespace: 'glob',
pluginData: {
resolveDir: args.resolveDir,
},
};
});
build.onLoad({ filter: /.*/, namespace: 'glob' }, async (args) => {
const matchPath: string[] = await new Promise((resolve, reject) => {
glob(
args.path,
{
cwd: args.pluginData.resolveDir,
},
(err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
}
);
});
const result: Record<string, string> = {};
await Promise.all(
matchPath.map(async (x) => {
const contents = await fs.promises.readFile(x);
result[path.basename(x)] = contents.toString();
})
);
return {
contents: JSON.stringify(result),
loader: 'json',
};
});
},
};
};
复制代码
esbuild基于filter和namespace的过滤是出于性能考虑的,这里的filter的正则是golang的正则,namespace是字符串,所以esbuild能够彻底基于filter和namespace进行过滤而避免没必要要的陷入到js的调用,最大程度减少golang call js的overhead,可是仍然能够filter设置为/.*/来彻底陷入到js,在js里进行过滤,实际的陷入开销实际上仍是可以接受的。
virtual module不只能够从磁盘里获取内容,也能够直接内存里计算内容,甚至能够把模块导入当函数调用。
这里的env模块,彻底是根据环境变量计算出来的
let envPlugin = {
name: 'env',
setup(build) {
// Intercept import paths called "env" so esbuild doesn't attempt
// to map them to a file system location. Tag them with the "env-ns"
// namespace to reserve them for this plugin.
build.onResolve({ filter: /^env$/ }, args => ({
path: args.path,
namespace: 'env-ns',
}))
// Load paths tagged with the "env-ns" namespace and behave as if
// they point to a JSON file containing the environment variables.
build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => ({
contents: JSON.stringify(process.env),
loader: 'json',
}))
},
}
//
import { NODE_ENV } from 'env' // env为虚拟模块,
复制代码
把模块名当函数使用,完成编译时计算,甚至支持递归函数调用。
build.onResolve({ filter: /^fib((\d+))/ }, args => {
return { path: args.path, namespace: 'fib' }
})
build.onLoad({ filter: /^fib((\d+))/, namespace: 'fib' }, args => {
let match = /^fib((\d+))/.exec(args.path), n = +match[1]
let contents = n < 2 ? `export default ${n}` : `
import n1 from 'fib(${n - 1}) ${args.path}'
import n2 from 'fib(${n - 2}) ${args.path}'
export default n1 + n2`
return { contents }
})
// 使用方式
import fib5 from 'fib(5)' // 直接编译器获取fib5的结果,是否是有c++模板的味道
复制代码
不须要下载node_modules就能够进行npm run dev
import { Plugin } from 'esbuild';
import { fetchPkg } from './http';
export const UnpkgNamepsace = 'unpkg';
export const UnpkgHost = 'https://unpkg.com/';
export const pluginUnpkg = (): Plugin => {
const cache: Record<string, { url: string; content: string }> = {};
return {
name: 'unpkg',
setup(build) {
build.onLoad({ namespace: UnpkgNamepsace, filter: /.*/ }, async (args) => {
const pathUrl = new URL(args.path, args.pluginData.parentUrl).toString();
let value = cache[pathUrl];
if (!value) {
value = await fetchPkg(pathUrl);
}
cache[pathUrl] = value;
return {
contents: value.content,
pluginData: {
parentUrl: value.url,
},
};
});
build.onResolve({ namespace: UnpkgNamepsace, filter: /.*/ }, async (args) => {
return {
namespace: UnpkgNamepsace,
path: args.path,
pluginData: args.pluginData,
};
});
},
};
};
// 使用方式
import react from 'react'; //会自动在编译器转换为 import react from 'https://unpkg.com/react'
复制代码
上面几个例子能够看出,esbuild的virtual module设计的很是灵活和强大,当咱们使用virtual module时候,实际上咱们的整个模块系统结构变成以下的样子 没法复制加载中的内容 针对不一样的场景咱们能够选择不一样的namespace进行组合
咱们发现基于virtual module涉及的universal bundler很是灵活,可以灵活应对各类业务场景,并且各个场景之间的开销互不影响。
大部分的bundler都是默认运行在浏览器上,因此构造一个universal bundler最大的难点仍是在于让bundler运行在浏览器上。 区别于咱们本地的bundler,浏览器上的bundler存在着诸多限制,咱们下面看看若是将一个bundler移植到浏览器上须要处理哪些问题。
首先咱们须要选取一个合适的bundler来帮咱们完成bundle的工做,rollup就是一个很是优秀的bundler,rollup有着不少很是优良的性质
正式由于上述优良的特性,因此不少最新的bundler|bundleness工具都是基于rollup或者兼容rollup的插件体系,典型的就是 vite 和wmr, 不得不说给rollup写插件比起给webpack写插件要舒服不少。 咱们早期的universal bundler实际上就是基于rollup开发的,可是使用rollup过程当中碰到了很多问题,总结以下
但凡在实际的业务中使用rollup进行bundle的同窗,绕不开的一个插件就是rollup-plugin-commonjs,由于rollup原生只支持ESM模块的bundle,所以若是实际业务中须要对commonjs进行bundle,第一步就是须要将CJS转换成ESM,不幸的是,Commonjs和ES Module的interop问题是个很是棘手的问题(搜一搜babel、rollup、typescript等工具下关于interop的issue sokra.github.io/interop-tes… ,其二者语义上存在着自然的鸿沟,将ESM转换成Commonjs通常问题不太大(当心避开default导出问题),可是将CJS转换为ESM则存在着更多的问题。 rollup-plugin-commonjs虽然在cjs2esm上下了不少功夫,可是实际仍然有很是多的edge case,实际上rollup也正在重写该核心模块 github.com/rollup/plug… 一些典型的问题以下
因为commonjs的导出模块并不是是live binding的,因此致使一旦出现了commonjs的循环引用,则将其转换成esm就会出问题
同步的动态require几乎没法转换为esm,若是将其转换为top-level的import,根据import的语义,bundler须要将同步require的内容进行hoist,可是这与同步require相违背,所以动态require也很难处理
由于在一个模块里混用ESM和CJS的语义并无一套标准的规范规定,虽然webpack支持在一个模块里混用CJS和ESM(downlevel to webpack runtime),可是rollup放弃了对该行为的支持(最新版能够条件开启,我没试过效果咋样)
正是由于cjs2esm的复杂性,致使该转换算法十分复杂,致使一旦业务里包含了不少cjs的模块,rollup其编译性能就会急剧降低,这在编译一些库的时候可能不是大问题,可是用于大型业务的开发,其编译速度难以接受。
另外一方面虽然rollup能够较为轻松的移植到到memfs上,可是rollup-plugin-commonjs是很难移植到web上的,因此咱们早期基于rollup作web bundler只能借助于相似skypack之类的在线cjs2esm的服务来完成上述转换,可是大部分这类服务其后端都是经过rollup-plugin-commonjs来实现的,所以rollup原有的那些问题并无摆脱,而且还有额外的网络开销,且难以处理非node_modules里cjs模块的处理。 幸运的是esbuild采起的是和rollup不一样的方案,其对cjs的兼容采起了相似node的module wrapper,引入了一个很是小的运行时,来支持cjs(webpack实际上也是采用了运行时的方案来兼容cjs,可是他的runtime不够简洁。。。)。 其经过完全放弃对cjs tree shaking的支持来更好的兼容cjs,而且同时能够在不引入插件的状况下,直接使得web bundler支持cjs。
rollup的virtual module的支持比较hack,依赖路径前面拼上一个'\0',对路径有入侵性,且对一些ffi的场景不太友好(c++ string把'\0'视为终结符),当处理较为复杂的virtual module场景下,'\0'这种路径很是容易处理出问题。
本地的bundler都是访问的本地文件系统,可是在browser是不存在本地文件系统的,所以如何访问文件呢,通常能够经过将bundler实现为与具体的fs无关来实现,全部的文件访问经过可配置的fs来进行访问。rollupjs.org/repl/ 便是采用此方式。所以咱们只须要将模块的加载逻辑从fs里替换为浏览器上的memfs便可,onLoad这个hooks正能够用于替换文件的读取逻辑。
当咱们将文件访问切换到memfs时,一个接踵而至的问题就是如何获取一个require和import的id对应的实际路径格式,node里将一个id映射为一个真实文件地址的算法就是 module resolution, 该算法实现较为复杂须要考虑以下状况,详细算法见 tech.bytedance.net/articles/69…
除了node module resolution自己的复杂,咱们可能还须要考虑main module filed fallback、alias支持、ts等其余后缀支持等webpack额外支持但在社区比较流行的功能,yarn|pnpm|npm等包管理工具兼容等问题。本身从头实现这一套算法成本较大,且node 的module resolution算法一直在更新,webpack的enhanced-resolve 模块基本上实现了上述功能,而且支持自定义fs,能够很方便的将其移植到memfs上。
我以为这里node的算法着实有点over engineering并且效率低下(一堆fallback逻辑有不小的io开销),并且这也致使了万恶之源hoist盛行的主要缘由,也许bare import配合import map,或者deno|golang这种显示路径更好一些。
main field也是个较为复杂的问题,主要在于没有一套统一的规范,以及社区的库并不彻底遵照规范,其主要涉及包的分发问题,除了main字段是nodejs官方支持的,module、browser、browser等字段各个bundler以及第三方社区库并未达成一致意见如
和browser bundler状况下main和module的优先级问题)
接下来咱们就须要处理node_modules的模块了,此时有两种方式,一种是将node_modules全量挂载到memfs里,而后使用enhanced-resolve去memfs里加载对应的模块,另外一种方式则是借助于unpkg,将node_modules的id转换为unpkg的请求。这两种方式都有其适用场景 第一种适合第三方模块数目比较固定(若是不固定,memfs必然没法承载无穷的node_modules模块),并且memfs的访问速度比网络请求访问要快的多,所以很是适合搭建系统的实现。 第二种则适用第三方模块数目不固定,对编译速度没有明显的实时要求,这种就比较适合相似codesandbox这种webide场景,业务能够自主的选择其想要的npm模块。
web bundler碰到的另外一个问题就是大部分的社区模块都是围绕node开发的,其会大量依赖node的原生api,可是浏览器上并不会支持这些api,所以直接将这些模块跑在浏览器上就会出问题。此时分为两种状况
一个小技巧,大部分的bundler配置external可能会比较麻烦或者没办法修改bundler的配置,咱们只须要将require包裹在eval里,大部分的bundler都会跳过require模块的打包。如eval('require')('os')
polyfill和环境嗅探是个争锋相对的功能,一方面polyfill尽量抹平node和browser差别,另外一方面环境嗅探想尽量从差别里区分浏览器和node环境,若是同时用了这俩功能,就须要各类hack处理了
咱们业务中依赖了c++的模块,在本地环境下能够将c++编译为静态库经过ffi进行调用,可是在浏览器上则须要将其编译为webassembly才能运行,可是大部分的wasm的大小都不小,esbuild的wasm有8M左右,咱们本身的静态库编译出来的wasm也有3M左右,这对总体的包大小影响较大,所以能够借鉴code split的方案,将wasm进行拆分,将首次访问可能用到的代码拆为hot code,不太可能用到的拆为cold code, 这样就能够下降首次加载的包的体积。
esbuild有三个垂直的功能,既能够组合使用也能够彻底独立使用
利用esbuild的transform功能,使用esbuild-register替换单元测试框架ts-node的register,大幅提高速度:见 github.com/aelbore/esb… ,不过ts-node如今已经支持自定义register了,能够直接将register替换为esbuild-register便可,esbuild的minify性能也是远远超过terser(100倍以上)
在一些bundleness的场景,虽然不对业务代码进行bundle,可是为了一方面防止第三方库的waterfall和cjs的兼容问题,一般须要对第三方库进行prebundle,esbuild相比rollup是个更好的prebundle工具,实际上vite的最新版已经将prebundle功能从rollup替换为了esbuild。
使用esbuild搭建esm cdn服务:esm.sh就是如此
相比于前端社区,node社区彷佛不多使用bundle的方案,一方面是由于node服务里可能使用fs以及addon等对bundle不友好的操做,另外一方面是大部分的bundler工具都是为了前端设计的,致使应用于node领域须要额外的配置。可是对node的应用或者服务进行bundle有着很是大的好处
所以笔者十分鼓励你们对node应用进行bundle,而esbuild对node的bundle提供了开箱即用的支持。
tsc即便支持了增量编译,其性能也极其堪忧,咱们能够经过esbuild来代替tsc来编译ts的代码。(esbuid不支持ts的type check也不许备支持),可是若是业务的dev阶段不强依赖type checker,彻底能够dev阶段用esbuild替代tsc,若是对typechecker有强要求,能够关注swc,swc正在用rust重写tsc的type checker部分,github.com/swc-project…
esbuild是少有的对库开发和应用开发支持都比较良好的工具(webpack库支持不佳,rollup应用开发支持不佳),这意味着你彻底能够经过esbuild统一你项目的构建工具。 esbuild原生支持react的开发,bundle速度极其快,在没有作任何bundleness之类的优化的状况下,一次的完整的bundle只须要80ms(包含了react,monaco-editor,emotion,mobx等众多库的状况下) 这带来了另外一个好处就是你的monorepo里很方便的解决公共包的编译问题。你只须要将esbuild的main field配置为['source','module','main'],而后在你公共库里将source指向你的源码入口,esbuild会首先尝试去编译你公共库的源码,esbuild的编译速度是如此之快,根本不会由于公共库的编译影响你的总体bundle速度😁。我只能说TSC不太适合用来跑编译,too slow && too complex。
esbuild的核心代码是用golang编写,用户使用的直接是编译出来的binary代码和一堆js的胶水代码,binary代码几乎无法断点调试(lldb|gdb调试),每次调试esbuild的代码,须要拉下代码从新编译调试,调试要求较高,难度较大
esbuild的transformer目前只支持target到es6,对于dev阶段影响较小,但目前国内大部分都仍然须要考虑es5场景,所以并不能将esbuild的产物做为最终产物,一般须要配合babel | tsc | swc作es6到es5的转换
目前golang编译出的wasm性能并非很好(相比于native有3-5倍的性能衰减),而且go编译出来wasm包体积较大(8M+),不太适合一些对包体积敏感的场景
相比于webpack和rollup庞大的插件api支持,esbuild仅支持了onLoad和onResolve两个插件钩子,虽然基于此能完成不少工做,可是仍然较为匮乏,如code spliting后的chunk的后处理都不支持