基于 esbuild 的 universal bundler 设计

374次阅读  |  发布于3年以前

背景

由于 Lynx(公司自研跨端框架)编译工具和传统Web编译工具链有较大的差别(如不支持动态 style 和动态 script 基本告别了 bundleless 和 code splitting,模块系统基于 json 而非 js,没有浏览器环境),且有在 Web 端实时编译(搭建系统)、web 端动态编译(WebIDE),服务端实时编译(服务端编译下发)、和多版本切换等需求,因此我们需要开发一个即支持在本地也支持在浏览器工作且可以根据业务灵活定制开发的 bundler,即 universal bundler,在开发 universal bundler 的过程中也碰到了一些问题,最后我们基于 esbuild 开发了全新的 universal bundler,解决了我们碰到的大部分问题。

什么是bundler

bundler的工作就是将一系列通过模块方式组织的代码将其打包成一个或多个文件,我们常见的bundler包括webpack、rollup、esbuild等。这里的模块组织形式大部分指的是基于js的模块系统,但也不排除其他方式组织的模块系统(如wasm、小程序的json的usingComponents,css和html的import等),其生成文件也可能不仅仅是一个文件如(code spliting生成的多个js文件,或者生成不同的js、css、html文件等)。大部分的bundler的核心工作原理都比较类似,但是其会偏重某些功能,如

bundler如何工作

bundler的实现和大部分的编译器的实现非常类似,也是采用三段式设计,我们可以对比一下

LLVM和bundler的对比

GJWJP这也使得传统的LLVM的很多编译优化策略实际上也可在bundler中进行,esbuild就是将这一做法推广到极致的例子。因为rollup的功能和架构较为精简,我们以rollup为例看看一个bundler的是如何工作的。rollup的bundle过程分为两步rollup和generate,分别对应了bundler前端和bundler后端两个过程。- src/main.js

import lib from './lib';

console.log('lib:', lib);
const answer = 42;
export default answer;

首先通过生成module graph

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();

输出内容如下

[
{
  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根据用户的配置构建产物了。

 const result = await bundle.generate({
    format: 'cjs',
  });
  console.log('result:', result);

生成内容如下

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流程并不复杂,但是其如果要真正的应用于生产环境,支持复杂多样的业务需求,就离不开其强大的插件系统。

插件系统

大部分的bundler都提供了插件系统,以支持用户可以自己定制bundler的逻辑。如rollup的插件分为input插件和output插件,input插件对应的是根据输入生成Module Graph的过程,而output插件则对应的是根据Module Graph生成产物的过程。我们这里主要讨论input插件,其是bundler插件系统的核心,我们这里以esbuild的插件系统为例,来看看我们可以利用插件系统来做什么。input的核心流程就是生成依赖图,依赖图一个核心的作用就是确定每个模块的源码内容。input插件正提供了如何自定义模块加载源码的方式。大部分的input 插件系统都提供了两个核心钩子

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的支持。我们简单看几个例子来展示插件的作用。

loader

大家使用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插件实现。

sourcemap && cache && error handle

上面的例子比较简化,作为一个更加成熟的插件还需要考虑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的功能。

virtual module

esbuild插件相比rollup插件一个比较大的改进就是对virtual module的支持,一般bundler需要处理两种形式的模块,一种是路径对应真实的磁盘里的文件路径,另一种路径并不对应真实的文件路径而是需要根据路径形式生成对应的内容即virtual module。virtual module有着非常丰富的应用场景。

glob import

举一个常见的场景,我们开发一个类似https://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不仅可以从磁盘里获取内容,也可以直接内存里计算内容,甚至可以把模块导入当函数调用。

memory 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为虚拟模块,

function virtual module

把模块名当函数使用,完成编译时计算,甚至支持递归函数调用。

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++模板的味道

stream import

不需要下载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非常灵活,能够灵活应对各种业务场景,而且各个场景之间的开销互不影响。

universal bundler

大部分的bundler都是默认运行在浏览器上,所以构造一个universal bundler最大的难点还是在于让bundler运行在浏览器上。区别于我们本地的bundler,浏览器上的bundler存在着诸多限制,我们下面看看如果将一个bundler移植到浏览器上需要处理哪些问题。

rollup

首先我们需要选取一个合适的bundler来帮我们完成bundle的工作,rollup就是一个非常优秀的bundler,rollup有着很多非常优良的性质

正式因为上述优良的特性,所以很多最新的bundler|bundleness工具都是基于rollup或者兼容rollup的插件体系,典型的就是 vite 和wmr, 不得不说给rollup写插件比起给webpack写插件要舒服很多。我们早期的universal bundler实际上就是基于rollup开发的,但是使用rollup过程中碰到了不少问题,总结如下

对CommonJS的兼容问题

但凡在实际的业务中使用rollup进行bundle的同学,绕不开的一个插件就是rollup-plugin-commonjs,因为rollup原生只支持ESM模块的bundle,因此如果实际业务中需要对commonjs进行bundle,第一步就是需要将CJS转换成ESM,不幸的是,Commonjs和ES Module的interop问题是个非常棘手的问题(搜一搜babel、rollup、typescript等工具下关于interop的issue https://sokra.github.io/interop-test/ ,其两者语义上存在着天然的鸿沟,将ESM转换成Commonjs一般问题不太大(小心避开default导出问题),但是将CJS转换为ESM则存在着更多的问题。rollup-plugin-commonjs虽然在cjs2esm上下了很多功夫,但是实际仍然有非常多的edge case,实际上rollup也正在重写该核心模块 https://github.com/rollup/plugins/pull/658。一些典型的问题如下

循环引用问题

由于commonjs的导出模块并非是live binding的,所以导致一旦出现了commonjs的循环引用,则将其转换成esm就会出问题

动态require的hoist问题

同步的动态require几乎无法转换为esm,如果将其转换为top-level的import,根据import的语义,bundler需要将同步require的内容进行hoist,但是这与同步require相违背,因此动态require也很难处理

Hybrid CJS和ESM

因为在一个模块里混用ESM和CJS的语义并没有一套标准的规范规定,虽然webpack支持在一个模块里混用CJS和ESM(downlevel to webpack runtime),但是rollup放弃了对该行为的支持(最新版可以条件开启,我没试过效果咋样)

性能问题

正是因为cjs2esm的复杂性,导致该转换算法十分复杂,导致一旦业务里包含了很多cjs的模块,rollup其编译性能就会急剧下降,这在编译一些库的时候可能不是大问题,但是用于大型业务的开发,其编译速度难以接受。

浏览器上cjs转esm

另一方面虽然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。

virutual module的支持

rollup的virtual module的支持比较hack,依赖路径前面拼上一个'\0',对路径有入侵性,且对一些ffi的场景不太友好(c++ string把'\0'视为终结符),当处理较为复杂的virtual module场景下,'\0'这种路径非常容易处理出问题。

filesystem

本地的bundler都是访问的本地文件系统,但是在browser是不存在本地文件系统的,因此如何访问文件呢,一般可以通过将bundler实现为与具体的fs无关来实现,所有的文件访问通过可配置的fs来进行访问。https://rollupjs.org/repl/ 即是采用此方式。因此我们只需要将模块的加载逻辑从fs里替换为浏览器上的memfs即可,onLoad这个hooks正可以用于替换文件的读取逻辑。

node module resolution

当我们将文件访问切换到memfs时,一个接踵而至的问题就是如何获取一个require和import的id对应的实际路径格式,node里将一个id映射为一个真实文件地址的算法就是 module resolution, 该算法实现较为复杂需要考虑如下情况,详细算法见 https://tech.bytedance.net/articles/6935059588156751880

除了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 field也是个较为复杂的问题,主要在于没有一套统一的规范,以及社区的库并不完全遵守规范,其主要涉及包的分发问题,除了main字段是nodejs官方支持的,module、browser、browser等字段各个bundler以及第三方社区库并未达成一致意见如

和browser bundler情况下main和module的优先级问题)

unpkg

接下来我们就需要处理node_modules的模块了,此时有两种方式,一种是将node_modules全量挂载到memfs里,然后使用enhanced-resolve去memfs里加载对应的模块,另一种方式则是借助于unpkg,将node_modules的id转换为unpkg的请求。这两种方式都有其适用场景 第一种适合第三方模块数目比较固定(如果不固定,memfs必然无法承载无穷的node_modules模块),而且memfs的访问速度比网络请求访问要快的多,因此非常适合搭建系统的实现。第二种则适用第三方模块数目不固定,对编译速度没有明显的实时要求,这种就比较适合类似codesandbox这种webide场景,业务可以自主的选择其想要的npm模块。

shim 与 polyfill

web bundler碰到的另一个问题就是大部分的社区模块都是围绕node开发的,其会大量依赖node的原生api,但是浏览器上并不会支持这些api,因此直接将这些模块跑在浏览器上就会出问题。此时分为两种情况

一个小技巧,大部分的bundler配置external可能会比较麻烦或者没办法修改bundler的配置,我们只需要将require包裹在eval里,大部分的bundler都会跳过require模块的打包。如eval('require')('os')

polyfill与环境嗅探,矛与盾之争

polyfill和环境嗅探是个争锋相对的功能,一方面polyfill尽可能抹平node和browser差异,另一方面环境嗅探想尽可能从差异里区分浏览器和node环境,如果同时用了这俩功能,就需要各种hack处理了

webassembly

我们业务中依赖了c++的模块,在本地环境下可以将c++编译为静态库通过ffi进行调用,但是在浏览器上则需要将其编译为webassembly才能运行,但是大部分的wasm的大小都不小,esbuild的wasm有8M左右,我们自己的静态库编译出来的wasm也有3M左右,这对整体的包大小影响较大,因此可以借鉴code split的方案,将wasm进行拆分,将首次访问可能用到的代码拆为hot code,不太可能用到的拆为cold code, 这样就可以降低首次加载的包的体积。

我们可以在哪里使用esbuild

esbuild有三个垂直的功能,既可以组合使用也可以完全独立使用

更高效的register和minify工具

利用esbuild的transform功能,使用esbuild-register替换单元测试框架ts-node的register,大幅提升速度:见 https://github.com/aelbore/esbuild-jest ,不过ts-node现在已经支持自定义register了,可以直接将register替换为esbuild-register即可,esbuild的minify性能也是远远超过terser(100倍以上)

更高效的prebundle工具

在一些bundleness的场景,虽然不对业务代码进行bundle,但是为了一方面防止第三方库的waterfall和cjs的兼容问题,通常需要对第三方库进行prebundle,esbuild相比rollup是个更好的prebundle工具,实际上vite的最新版已经将prebundle功能从rollup替换为了esbuild。

更好的线上cjs2esm服务

使用esbuild搭建esm cdn服务:esm.sh就是如此

node bundler

相比于前端社区,node社区似乎很少使用bundle的方案,一方面是因为node服务里可能使用fs以及addon等对bundle不友好的操作,另一方面是大部分的bundler工具都是为了前端设计的,导致应用于node领域需要额外的配置。但是对node的应用或者服务进行bundle有着非常大的好处

因此笔者十分鼓励大家对node应用进行bundle,而esbuild对node的bundle提供了开箱即用的支持。

tsc transformer替代品

tsc即使支持了增量编译,其性能也极其堪忧,我们可以通过esbuild来代替tsc来编译ts的代码。(esbuid不支持ts的type check也不准备支持),但是如果业务的dev阶段不强依赖type checker,完全可以dev阶段用esbuild替代tsc,如果对typechecker有强要求,可以关注swc,swc正在用rust重写tsc的type checker部分,https://github.com/swc-project/swc/issues/571

monorepo与monotools

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存在的一些问题

调试麻烦

esbuild的核心代码是用golang编写,用户使用的直接是编译出来的binary代码和一堆js的胶水代码,binary代码几乎没法断点调试(lldb|gdb调试),每次调试esbuild的代码,需要拉下代码重新编译调试,调试要求较高,难度较大

只支持target到es6

esbuild的transformer目前只支持target到es6,对于dev阶段影响较小,但目前国内大部分都仍然需要考虑es5场景,因此并不能将esbuild的产物作为最终产物,通常需要配合babel | tsc | swc做es6到es5的转换

golang wasm的性能相比native有较大的损耗,且wasm包体积较大,

目前golang编译出的wasm性能并不是很好(相比于native有3-5倍的性能衰减),并且go编译出来wasm包体积较大(8M+),不太适合一些对包体积敏感的场景

插件api较为精简

相比于webpack和rollup庞大的插件api支持,esbuild仅支持了onLoad和onResolve两个插件钩子,虽然基于此能完成很多工作,但是仍然较为匮乏,如code spliting后的chunk的后处理都不支持

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8