关于前端:基于esbuild的universal-bundler设计

41次阅读

共计 16786 个字符,预计需要花费 42 分钟才能阅读完成。

——字节跳动前端 ByteFE:杨健

背景

因为 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 的外围工作原理都比拟相似,然而其会并重某些性能,如

  • webpack : 强调对 web 开发的反对,尤其是内置了 HMR 的反对,插件零碎比拟弱小,对各种模块零碎兼容性最佳(amd,cjs,umd,esm 等,兼容性好的有点过分了,这实际上有利有弊, 导致面向 webpack 编程),有丰盛的生态,毛病是产物不够洁净,产物不反对生成 esm 格局,插件开发上手较难,不太适宜库的开发。
  • rollup: 强调对库开发的反对,基于 ESM 模块零碎,对 tree shaking 有着良好的反对,产物十分洁净,反对多种输入格局,适宜做库的开发,插件 api 比拟敌对,毛病是对 cjs 反对须要依赖插件,且反对成果不佳须要较多的 hack,不反对 HMR,做利用开发时须要依赖各种插件。
  • esbuild: 强调性能,内置了对 css、图片、react、typescript 等内置反对,编译速度特地快(是 webpack 和 rollup 速度的 100 倍 +), 毛病是目前插件零碎较为简单,生态不如 webpack 和 rollup 成熟。

bundler 如何工作

bundler 的实现和大部分的编译器的实现十分相似,也是采纳三段式设计,咱们能够比照一下

  • llvm: 将各个语言通过编译器前端编译到 LLVM IR,而后基于 LLVM IR 做各种优化,而后基于优化后的 LLVM IR 依据不同处理器架构生成不同的 cpu 指令集代码。
  • bundler: 将各个模块先编译为 module graph,而后基于 module graph 做 tree shaking && code spliting &&minify 等优化,最初将优化后的 module graph 依据指定的 format 生成不同格局的 js 代码。

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);
  • src/lib.js
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 插件零碎都提供了两个外围钩子

  • onResolve(rollup 里叫 resolveId, webpack 里叫 factory.hooks.resolver): 依据一个 moduleid 决定理论的的模块地址
  • onLoad(rollup 里叫 loadId,webpack 里是 loader):依据模块地址加载模块内容)

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 怎么实现。实现下面的性能其实非常简单,咱们只须要

  • 在 onResolve 里将自定义的 path 进行解析,而后将元数据通过 pluginData 和 path 传递给 onLoad,并且自定义一个 namespace(namespace 的作用是避免失常的 file load 逻辑去加载返回的门路和给后续的 load 做 filter 的过滤)
  • 在 onLoad 里通过 namespace 过滤拿到感兴趣的 onResolve 返回的元数据,依据元数据自定义加载生成数据的逻辑,而后将生成的内容交给 esbuild 的内置 loader 解决
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 进行组合

  • 本地开发:齐全走本地 file 加载,即都走 file namespace
  • 本地开发免装置 node_modules: 即相似 deno 和 snowpack 的 streaming import 的场景,能够通过业务文件走 file namespace,node\_modules 文件走 unpkg namespace, 比拟适宜超大型 monorepo 我的项目开发一个我的项目须要装置所有的 node\_modules 过慢的场景。
  • web 端实时编译场景(性能和网络问题):即第三方库是固定的,业务代码可能变动,则本地 file 和 node_modules 都走 memfs。
  • web 端动静编译: 即内网 webide 场景,此时第三方库和业务代码都不固定,则本地 file 走 memfs,node_modules 走 unpkg 动静拉取

咱们发现基于 virtual module 波及的 universal bundler 非常灵活,可能灵便应答各种业务场景,而且各个场景之间的开销互不影响。

universal bundler

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

rollup

首先咱们须要选取一个适合的 bundler 来帮咱们实现 bundle 的工作,rollup 就是一个十分优良的 bundler,rollup 有着很多十分低劣的性质

  • treeshaking 反对十分好,也反对 cjs 的 tree shaking
  • 丰盛的插件 hooks,具备非常灵活定制的能力
  • 反对运行在浏览器上
  • 反对多种输入格局(esm,cjs,umd,systemjs)

正式因为上述低劣的个性,所以很多最新的 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

  • file|index| 目录三种情景
  • js、json、addon 多文件后缀
  • esm 和 cjs loader 区别
  • main field 解决
  • conditional exports 解决
  • exports subpath
  • NODE_PATH 解决
  • 递归向上查找
  • symlink 的解决

除了 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 以及第三方社区库并未达成一致意见如

  • cjs 和 esm,esnext 和 es5,node 和 browser,dev 和 prod 的入口该怎么配置
  • module| main 里的代码应该是 es5 还是 esnext 的(决定了 node_module 里的代码是否须要走 transformer)
  • module 里的代码是应该指向 browser 的实现还是指向 node 的实现(决定了 node bundler

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

  • node 和 browser 差别的代码如何散发解决等等

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,因而间接将这些模块跑在浏览器上就会出问题。此时分为两种状况

  • 一种是这些模块依赖的理论就是些 node 的 utily api 例如 utils、path 等,这些模块实际上并不依赖 node runtime, 此时咱们实际上是能够在浏览器上模仿这些 api 的,browserify 实际上就是为了解决这种场景的,其提供了大量的 node api 在浏览器上的 polyfill 如 path-browserify,stream-browserify 等等,
  • 另一种是浏览器和 node 的逻辑离开解决,尽管 node 的代码不须要在浏览器上执行,然而不冀望 node 的实现一方面增大浏览器 bundle 包的体积和导致报错,此时咱们须要 node 相干的模块进行 external 解决即可。

一个小技巧,大部分的 bundler 配置 external 可能会比拟麻烦或者没方法批改 bundler 的配置,咱们只须要将 require 包裹在 eval 里,大部分的 bundler 都会跳过 require 模块的打包。如 eval(‘require’)(‘os’)

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

polyfill 和环境嗅 tan 是个争锋绝对的性能,一方面 polyfill 尽可能抹平 node 和 browser 差别,另一方面环境嗅 tan 想尽可能从差别里辨别浏览器和 node 环境,如果同时用了这俩性能,就须要各种 hack 解决了

webassembly

咱们业务中依赖了 c ++ 的模块,在本地环境下能够将 c ++ 编译为动态库通过 ffi 进行调用,然而在浏览器上则须要将其编译为 webassembly 能力运行,然而大部分的 wasm 的大小都不小,esbuild 的 wasm 有 8M 左右,咱们本人的动态库编译进去的 wasm 也有 3M 左右,这对整体的包大小影响较大,因而能够借鉴 code split 的计划,将 wasm 进行拆分,将首次拜访可能用到的代码拆为 hot code, 不太可能用到的拆为 cold code, 这样就能够升高首次加载的包的体积。

咱们能够在哪里应用 esbuild

esbuild 有三个垂直的性能,既能够组合应用也能够齐全独立应用

  • minifier
  • transformer
  • bundler

更高效的 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\_modules 体积和放慢装置速度,相比将 node 利用的一堆依赖一起装置到业务的 node\_modules 里,只装置 bundle 的代码大幅减小了业务的装置体积和放慢了装置速度,pnpm 和 yarn 就是应用 esbuild 将所有依赖 bundle 实现零依赖的侧面典型 https://twitter.com/pnpmjs/status/1353848140902903810?s=21
  • 进步了冷启动的速度,因为 bundle 后的代码一方面通过 tree shaking 减小了引起理论须要 parse 的 js 代码大小(js 的 parse 开销在大型利用的冷启动速度上占据了不小的比重,尤其是对冷启动速度敏感的利用),另一方面防止了文件 io,这两方面都同时大幅减小了利用冷启动的速度,非常适合一些对冷启动敏感的场景,如 serverless
  • 防止上游的 semver 语义毁坏,尽管 semver 是一套社区标准,然而这实际上对代码要求十分严格,当引入了较多的第三方库时,很难保障上游依赖不会毁坏 semver 语义,因而 bundle 代码能够完全避免上游依赖呈现 bug 导致利用呈现 bug,这对安全性要求极高的利用(如编译器)至关重要。

因而笔者非常激励大家对 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 的后处理都不反对


🔥 火山引擎 APMPlus 利用性能监控是火山引擎利用开发套件 MARS 下的性能监控产品。咱们通过先进的数据采集与监控技术,为企业提供全链路的利用性能监控服务,助力企业晋升异样问题排查与解决的效率。

目前咱们面向中小企业特地推出_「APMPlus 利用性能监控企业助力口头」_,为中小企业提供利用性能监控免费资源包。当初申请,有机会取得 60 天 收费性能监控服务,最高可享 6000 万 条事件量。

👉 点击这里,立刻申请

正文完
 0