手把手教你从rollup、esbuild、vite、swc、webpack、tsc中选择npm包构建工具

2,384 阅读15分钟

前言

随着前端的不断发展,很多新特性出现的同时,越来越多的构建工具也如雨后春笋般冒了出来,那么不论是在日常的工作中,还是在平常自己的开源项目中,构建npm包的时候,大家首先想到的就是rollup来构建我们的npm包,但是为什么rollup目前会成为首选,还可以选择哪些工具来构建我们的npm包,希望读者通过阅读本篇能够得到答案,同时也能选择合适的构建工具来构建自己的npm包

内容分为四个部分

  • npm包的运用场景分析
  • 构建工具发展简介
  • 选择npm包构建工具
  • FAQ

npm包运用场景分析

在选择构建工具之前,先大致了解下npm包有哪些常用的使用场景,各个场景有什么明显的特征,知道了真实的运用场景之后,在结合实际的构建工具,能够快速构建出适合自己的npm包

首先我们知道npm包的作用,主要就是逻辑复用,将一些公共的内容,以npm包的形式组织起来,方便其它项目内使用

那么我们可以根据常用的使用场景做出如下分类 从上面可以看出,运行在nodejs端的npm包要求更低,运行在web端的npm包要求更高一点

而npm包的输出也围绕在输出模块格式、polyfill、包大小等上面,下面重点介绍两个点

模块格式

因为nodejs端项目是同步加载模块,而浏览器端项目是通过网络请求异步加载模块,在esm模块没有出来之前,二者之间使用的模块格式是有差异的,差异体现在,nodejs端的npm包使用cjs模块格式开发,且最终输出cjs格式的模块,web端的npm包使用amd or cmd or iife格式开发,且最终输出amd or cmd or iife格式的模块,而umd则是兼容amd or cmd or iife的一种格式

伴随着es6模块规范的发布,javascript有了自己的标准模块规范,于是不论是nodejs端、web端的npm包开发都是采用esm模块格式进行开发,为了向前兼容输出esm与cjs两份模块格式,这也就是目前看到的npm包大部分都是输出了两种模块格式的原因,如下图所示 image.png

polyfill

polyfill是什么?

polyfill的英文意思是填充工具,意义就是兜底的东西;为什么会有polyfill这个概念,因为ECMASCRIPT一直在发布新的api,当我们使用这些新的api的时候,在旧版本的浏览器上是无法使用的,因为旧的版本上是没有提供这些新的api的,所以为了让代码也能在旧的浏览器上跑起来,于是手动添加对应的api,这就是polyfill,如下图所示; image.png

手动引入polyfill的方式除非我们知道自己只需要引入哪种polyfill,不然一般都推荐通过babel or swc自动引入polyfill代码,如下图所示 image.png

web项目与npm包之间的polyfill区别吗?

首先web项目是作为入口访问的,所以polyfill的时候,可以全局引入,可以按需引入,可以以污染的方式引入,因为影响范围就是本项目,而作为npm包是作为一个部分被项目引用的,那么肯定要尽可能少的对引用的项目产生影响,所以polyfill的选择上会更谨慎,因此有了无污染的polyfill方式,当然并不是说npm内就一定的选择无污染的polyfill方式,只不过选择无污染的polyfill方式对引用的项目影响范围最小

全局polyfill: 直接引入所有的polyfill代码

import "core-js";

按需polyfill: 仅引用代码中需要用到的polyfill代码

import 'core-js/modules/es.array.iterator';
import 'core-js/modules/es.object.to-string';
import 'core-js/modules/es.set';

var set = new Set([1, 2, 3]);

有污染方式的polyfill: 直接修改的是全局方法

import 'core-js/modules/es.array.of';
var array = Array.of(1, 2, 3);

无污染方式的polyfill:不会修改全局方法,仅影响引用的代码部分

import Set from 'core-js-pure/stable/set';
import Promise from 'core-js-pure/stable/promise';

new Set([1, 2, 3, 2, 1])
Promise.resolve(32).then(x => console.log(x));

更多polyfill内容,可以查看笔者之前总结的babel polyfill指南深入理解polyfill

构建工具发展简介

在了解npm包的运用场景之后,接着了解下这些构建工具大概在什么时候出现,又是为了解决什么问题,因为这些构建工具的出现不仅针对项目场景,也针对任何需要打包构建的场景,所以在了解了之后,我们在做选择的时候,会有更多的参考与选择

ES5时期

首先是早期的webpack,webpack在es5时期就已经出现,当时网络性能还不怎么好,为了加快用户的访问速度,将产物打包成bundle,而当时还没有 javascript 语言标准的模块规范,所以webpack构建的产物包含了自己写的模块规范,webpack的bundle过程如下所示 image.png

webpack不仅能够构建项目,还能够构建npm包,另外就是gulp与grunt这两个工具在结合一些相应的插件也可以构建项目与npm包,但是随着时间的流逝,glup与grunt渐渐被淘汰了

ES6时期

随着时间来到2015年,es6发布,es6不仅包含了新语法,还包含了javascript一直没有的模块规范,es module。随着es module的出现,rollup诞生了,rollup依赖es module带来了更好的tree-shaking,可以有效的减少包体积,同时rollup输出的代码更清爽,是A就是输出A,不像webpack包含很多模块加载的胶水代码;因此rollup迅速占领了npm包构建场景。rollup的bundle过程如下所示 image.png

于此同时随着javascript的大量运用,一些大佬觉得动态语言,太灵活了,需要向静态语言看齐,于是2012年typescript发布了第一个正式版本,在javascript之上引入静态类型,但是从2015才被开始大量使用,伴随着typescript出现的还有自身的解释器tsc,tsc专门负责ts类型检查,将typescript转化成javascript,生成对应的类型文件等,tsc处理过程如下所示 image.png

项目里面要使用typescript,需要借助ts-loader这类封装了tsc的loader才能处理typescript,npm包场景下要使用typescript需要直接使用typescript or rollup-plugin-typescript这样基于typescript的插件

前面提到的随着es6的出现,不仅是javascript有了自己的模块规范,同时还带来了新的语法与API、但是此时很多浏览器还不支持新的语法与API,怎么办?于是又出现了babel这样的转化工具,专门处理javascript语法转化及按需添加polyfill代码,babel处理过程如下所示 image.png

后面 babel 又直接支持了typescript转化成javascript的能力,但是不支持typescipt类型检查与类型文件生成,过程如下所示 image.png

高性能时期

到了2020年左右,webpack、rollup、typescript、babel这些工具已经很成熟了,javascript中的构建工具链已经趋近完善,我们在开发中需要实现的功能都可以通过上面的工具达成,那是不是社区就没事做了,不是的,社区开始卷性能,开始通过其它性能更高的语言来实现等效功能的工具,比如使用rust写的swc,旨在替换babel,go写的esbuild,旨在替换webpack、rollup这样的构建工具,等等还有其它,而这些工具带来的性能提升巨大,使用场景也是越来越多,不仅用户本身对这些工具的使用,同时之前基于javascript编写的工具,也在自己的环节或者底层引入了这些工具,比如vite内部就使用了esbuild,rollup在4.0版本使用了swc替换acron来解析ast等

比如swc进行语法转换与polyfill过程,如下所示 image.png

比如esbuild构建bundle过程,如下所示 image.png

那么我们作为一个普通开发者应该怎么做,我个人的做法是积极拥抱变化,不断尝试,并总结使用经验

当我们了解了构建工具的大致由来,那么我们接着往下看构建npm包,应该怎么选择构建工具

选择npm包构建工具

如何选择构建工具

首先我们看下npm包资源的关系图,如下图所示 npm资源图.png 有些同学可能不需要构建工具到output这一步,直接将input发布即可,但是更多的场景我们还是会经历构建output这一步,原因有以下几点

  • input现在大部分都是通过typescript编写,需要将ts转成js,并输出类型声明文件
  • 浏览器兼容性考虑,需要对npm包输出es5及polyfill
  • 方便浏览器端通过script方式直接加载,需要输出bundle形式的umd模块

等等还有其它的原因,就不一一列举

那么当我们需要构建输出这一步之后,对于构建工具的选择有多种,那么我们应该怎么去选择合适的构建工具,帮助我们快速、高效的构建出产物

我个人的理解,就是理清楚输入有哪些场景,输出有哪些场景,各个构建工具有什么优缺点,通过三者结合,就可以快速找出适合自己场景的构建工具

上面是npm包输入与输出的一些场景,那么构建工具的能力有哪些

从上面能够看出,支持场景最多的是rollup与vite,而vite是基于rollup的封装;其次是webpack支持的场景最多,但是webpack不支持原目录格式输出多文件,且输出esm模块格式还是实验特性;在其次是esbuild,但是esbuild不能输出es5、不支持按需polyfill、不支持emitDecoratorMetadata,在其次是swc,但是swc不支持less、saas等、不支持图片处理等,不能够处理.vue;最后就是typescript,不支持less、saas等,不能够处理.vue,不能进行按需polyfill,也不能生成单bundle.js

(为什么不借助babel or swc原因是代码本身已经被esbuild转化如果在转化一次不合算)

整理之后的表格如下所示

构建工具/功能单entry多entry输出单bundle原目录输出多文件处理ts、tsx处理.vue输出es5处理css处理less、saas输出esm动态polyfill
rollup
vite
webpack
esbuild
swc
typescript

备注:这里的能处理,并不一定指这个构建工具自己本身能处理,而是有对应的插件能够处理。webpack不能支持原目录输出格式,原因是虽然可以通过多入库打包每个文件,但是如果文件内有引用关系的话,是会被打进去的

到这里我们已经知道各个构建工具适合的场景,下面用具体的案例进行示范

使用 swc 构建

先说下目标:

  • 仅适用于nodejs端的包
  • 输出esm模块
  • 不需要polyfill,
  • 不需要打包成bundle
  • 输出es6语法

然后使用swc进行构建

源代码包含两个文件如下所示

import { getToken } from "./util";

export function getRandomToken() {
  return `${getToken()}_${Math.random()}`
}
export function getToken() {
  return 'xjskak8999'
}

首先,安装 SWC 作为开发依赖:

npm install --save-dev @swc/core

创建一个构建脚本,然后使用@swc/core编译ts文件

const fs = require('fs-extra')
const swc = require('@swc/core');
const glob = require('glob')

function transfrom(file, option) {
  return swc
    .transformFile(file, {
      sourceMaps: false,
      module: {
        type: 'es6', // 输出esm模块
        noInterop: true
      },

      jsc: {
        parser: {
          syntax: "typescript",
          decorators: true,
        },
        transform: {
          "legacyDecorator": true,
          "decoratorMetadata": true
        },
        target: 'es2015' // 输出es6语法
      },
      ...option
    })
    .then((output) => ({
      file,
      output,
    }));
}


async function transformByswc({
  entry,
  dest = 'build',
  option,
}) {
  console.time('swc build');
  // 需要排除.d.ts,避免覆盖同名的.ts文件
  const files = Array.isArray(entry) ? entry : glob.sync(entry);

  const result = await Promise.all(files.map((file) => transfrom(file, option)));

  await Promise.all(result.map((item) => {
    return fs.outputFile(item.file.replace('src', dest).replace('.ts', '.js'), item.output.code);
  }));

  console.timeEnd('swc build');
}

transformByswc({
  entry: 'src/**/!(*.d).ts'
})

构建你的 npm 包,比如在 package.json 中添加一个构建脚本:

"scripts": {
  "build": "node build.js"
}

构建结果如下所示 image.png

这样当我们的npm包发布之后,其它的项目内就可以通过import {getRandomToken} from '包名'使用我们npm包中提供的方法

使用 esbuild 构建

先说下目标:

  • 仅适用于nodejs端的包
  • 输出cjs模块
  • 不需要polyfill
  • 需要打包成bundle
  • 需要包含node_modules中的依赖
  • 需要压缩

源代码包含两个文件如下所示

import { getToken } from "./util";

export function getRandomToken() {
  return `${getToken()}_${Math.random()}`
}
export function getToken() {
  return 'xjskak8999'
}

首先,安装 esbuild 作为开发依赖:

pnpm add esbuild -D

创建一个build.js脚本

const esbuild = require('esbuild');


esbuild.build({
  entryPoints: ['./src/check.ts'],
  bundle: true, // 将所有文件打包到一个bundle文件里面
  minify: true, // 压缩代码
  target: ['node12'], // 生成的目标代码兼容node12
  outfile: './build/check.js',
  platform: 'node',
  charset: 'utf8', // 保证中文不被转码
  // packages: 'external' // 将node_modules下的依赖也包含进来
})

编写一个构建脚本,比如在 package.json 中:

"scripts": {
  "build": "node build.js"
}

构建结果如下所示 image.png

这样当我们的npm包发布之后,其它的项目内就可以通过import '包名',我们的check逻辑就会自执行

使用 typeScript 构建

先说下目标:

  • 仅适用于nodejs端的包
  • 输出esm模块
  • 不需要polyfill,
  • 不需要打包成bundle
  • 输出es6语法

首先,安装 yypeScript 作为开发依赖:

pnpm install --save-dev typescript

创建一个 TypeScript 配置文件(比如 tsconfig.json):

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "ems",
    "outDir": "dist"
  }
}

构建你的 npm 包,比如在 package.json 中添加一个构建脚本:

"scripts": {
  "build": "node build.js"
}

使用 webpack 构建

先说下目标:

  • 仅适用于浏览器端的包
  • 输出umd模块
  • 需要polyfill
  • 需要打包成bundle
  • 需要包含node_modules中的依赖
  • 需要压缩

源代码包含两个文件如下所示 image.png

首先,安装 webpack 作为开发依赖

# 安装webpack依赖 
pnpm add webpack webpack-cli -D

# 安装其它辅助依赖
pnpm add @babel/plugin-transform-runtime @babel/preset-env @babel/preset-typescript babel-loader @babel/runtime-corejs3 -D

创建一个webpack.config.js脚本

module.exports = {
  mode: 'production',
  entry: './src/index.ts',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].min.js',
    library: {
      type: "umd",
      name: 'MyLibrary',
    }
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.jsx', '.js'],
  },
  optimization: {
    minimize: true
  },
  module: {
    rules: [
      {
        test: /\.[tj]sx?$/,
        use: 'babel-loader',
        exclude: /\bcore-js\b/
      }
    ]
  },
}

创建babel.config.js文件

module.exports = {
  sourceType: 'unambiguous',
  presets: [
    [
      "@babel/preset-env",
    ],
    "@babel/preset-typescript",
  ],
  plugins: [
    ['@babel/plugin-transform-runtime', {
      'absoluteRuntime': false,
      'corejs': 3,
      'helpers': true,
      'regenerator': true,
    }]
  ]
}

在 package.json 中添加一个build命令

"scripts": {
  "build": "webpack -c ./webpack.config.js"
}

构建结果如下所示 image.png

这样当我们的npm包发布之后,其它的项目内就可以通过script标签直接引用我们的js文件

使用 rollup 构建

先说下目标:

  • 适用于浏览器端
  • 输出cjs与esm模块
  • 需要polyfill
  • 不需要打包成bundle
  • 不需要包含node_modules中的依赖
  • 需要压缩

源代码包含两个文件如下所示

import { getToken } from "./util";

export function getRandomToken() {
  return `${getToken()}_${Math.random()}`
}
export function getToken() {
  return 'xjskak8999'
}

首先,安装 rollup 作为开发依赖:

pnpm add rollup @rollup/plugin-typescript @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-json @rollup/plugin-node-resolve -D

创建一个rollup.config.mjs脚本

import { getBabelOutputPlugin } from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import resolve from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';

import pkg from './package.json' assert {type: 'json'};

export default [
  {
    input: './src/index.ts',
    output: [{
      file: pkg.main,
      format: 'cjs',
      exports: 'named',
    },{
      file: pkg.module,
      format: 'esm',
      exports: 'named',
    }],
    plugins: [
      resolve(),
      json(),
      commonjs({
        transformMixedEsModules: true,
      }),
      typescript(),
      getBabelOutputPlugin(),
    ],
    external: [], // 不需要打入包内的第三方npm包,例如['lodash']
  }
];

创建babel.config.js

module.exports = {
  "presets": [
    [
      "@babel/preset-env",
      {
        "debug": true
      }
    ]
  ],
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "absoluteRuntime": false,
        "corejs": {
          "version": 3,
          "proposals": false
        },
        "helpers": true,
        "regenerator": true
      }
    ]
  ]
}

在 package.json 中添加一个build命令

"scripts": {
  "build": "rollup -c rollup.config.mjs"
}

构建结果如下所示 image.png

这样当我们的npm包发布之后,其它的项目内就可以通过import { getRandomToken } from '包名' 使用我们包提供的功能,同时由于我们的包提供了es module,那么webpack等构建工具在处理的时候,就可以进行tree-shaking,减少bundle体积

使用 vite 构建

先说下目标:

  • 适用于浏览器端
  • 输出esm模块
  • 不需要polyfill
  • 需要打包成bundle
  • 不需要包含node_modules中的依赖
  • 不需要压缩

源代码包含两个文件如下所示

import { getToken } from "./util";

export function getRandomToken() {
  return `${getToken()}_${Math.random()}`
}
export function getToken() {
  return 'xjskak8999'
}

首先,安装 vite 作为开发依赖:

pnpm add vite -D

创建一个vite.config.js脚本

import { defineConfig } from 'vite'
import { resolve } from 'path'

export default defineConfig({
  build: {
    target: 'es5',
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
    },
    minify: false,
    rollupOptions: {
      output: [{
        format: 'es',
        exports: 'named',
        dir: './dist',
        entryFileNames: '[name].bundle.js',
      }],
    },
  },
})


在 package.json 中添加一个build命令

"scripts": {
  "build": "rollup -c rollup.config.mjs"
}

构建结果如下所示 image.png

这样当我们的npm包发布之后,其它的项目内就可以通过import { getRandomToken } from '包名' 使用我们包提供的功能,同时由于我们的包输出的事es module,那么webpack等构建工具在处理的时候,就可以进行tree-shaking,减少bundle体积

demo地址npm-build-demo

FAQ

使用某个npm包之后页面白屏

其实我们在项目中安装了某个npm包之后,在低版本浏览器上访问出现白屏问题的概率是最高的,原因有二

  1. 我们在项目构建的时候,为了缩短构建时间,会排除babel-loader这类语法转换的loader对node_modules下的依赖包做处理
  2. 我们依赖的npm包,输出的是es6甚至更高的语法版本

这时候怎么办两个思路

  1. 如果这类npm包比较少,那么通过loader的exclude属性就可以完成,比如将exclude设置成/node_modules[\\/](?!react-sortablejs)/,排除node_modules下除react-sortablejs之外的npm包,也就是会处理react-sortablejs不会处理node_modules下的其它包
  2. 如果这里npm包比较多,那么直接处理node_modules下所有包,可能最终的产物在运行的时候报错,原因是node_modules下的有些包是不能够在使用babel-loader处理,这种场景就直接排除不能通过babel-loader处理的包即可,可以将exclude设置成node_modules[\\/](core-js|core-js-pure|@babel|amfe-flexible|react-dom|react|css-loader|lodash|vconsole),实际的情况以自己的项目为准

总结

本篇首先讲了一下npm包的运用场景,然后简单介绍构建工具的发展过程,然后介绍了根据输入、输出、构建工具优缺点这三个要素来选择构建工具,最后又用具体的构建工具构建不同的demo

几种常用场景的最佳实践

  • 目标1:不需要bundle、不需要polyfill、不需要处理样式、输出esm or cjs,比如仅支持nodejs场景的纯js工具包
    • 构建工具选择:esbuild、swc、tsc、rollup、vite
  • 目标2: 不需要bundle、需要polyfill、不需要处理样式、输出esm or cjs,比如仅支持浏览器场景的纯js工具包
    • 构建工具选择:swc、rollup、vite
  • 目标3: 需要bundle、需要polyfill、需要处理样式、输出umd,比如直接支持script标签加载的包
    • 构建工具选择:rollup、vite、webpack
  • 目标4: 需要bundle、需要polyfill、需要处理样式、输出esm or cjs,比如仅支持浏览器端的组件库包
    • 构建工具选择:rollup、vite

更多场景,请参考如何选择构建工具一栏进行选择

如果你觉得此篇对你有所帮助那么就三连支持一下吧!