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

前言

随着前端的不断发展,很多新特性出现的同时,越来越多的构建工具也如雨后春笋般冒了出来,那么不论是在日常的工作中,还是在平常自己的开源项目中,构建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包大部分都是输出了两种模块格式的原因,如下图所示

polyfill

polyfill是什么?

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

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

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

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

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

javascript 复制代码
import "core-js";

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

javascript 复制代码
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: 直接修改的是全局方法

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

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

javascript 复制代码
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过程如下所示

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过程如下所示

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

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

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

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

高性能时期

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

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

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

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

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

选择npm包构建工具

如何选择构建工具

首先我们看下npm包资源的关系图,如下图所示 有些同学可能不需要构建工具到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进行构建

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

typescript 复制代码
import { getToken } from "./util";

export function getRandomToken() {
  return `${getToken()}_${Math.random()}`
}
typescript 复制代码
export function getToken() {
  return 'xjskak8999'
}

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

shell 复制代码
npm install --save-dev @swc/core

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

javascript 复制代码
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 中添加一个构建脚本:

json 复制代码
"scripts": {
  "build": "node build.js"
}

构建结果如下所示

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

使用 esbuild 构建

先说下目标:

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

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

typescript 复制代码
import { getToken } from "./util";

export function getRandomToken() {
  return `${getToken()}_${Math.random()}`
}
typescript 复制代码
export function getToken() {
  return 'xjskak8999'
}

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

csharp 复制代码
pnpm add esbuild -D

创建一个build.js脚本

javascript 复制代码
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 中:

json 复制代码
"scripts": {
  "build": "node build.js"
}

构建结果如下所示

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

使用 typeScript 构建

先说下目标:

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

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

css 复制代码
pnpm install --save-dev typescript

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

json 复制代码
{
  "compilerOptions": {
    "target": "ES2015",
    "module": "ems",
    "outDir": "dist"
  }
}

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

json 复制代码
"scripts": {
  "build": "node build.js"
}

使用 webpack 构建

先说下目标:

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

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

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

shell 复制代码
# 安装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脚本

javascript 复制代码
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文件

javascript 复制代码
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命令

json 复制代码
"scripts": {
  "build": "webpack -c ./webpack.config.js"
}

构建结果如下所示

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

使用 rollup 构建

先说下目标:

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

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

typescript 复制代码
import { getToken } from "./util";

export function getRandomToken() {
  return `${getToken()}_${Math.random()}`
}
typescript 复制代码
export function getToken() {
  return 'xjskak8999'
}

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

sql 复制代码
pnpm add rollup @rollup/plugin-typescript @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-json @rollup/plugin-node-resolve -D

创建一个rollup.config.mjs脚本

javascript 复制代码
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

javascript 复制代码
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命令

json 复制代码
"scripts": {
  "build": "rollup -c rollup.config.mjs"
}

构建结果如下所示

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

使用 vite 构建

先说下目标:

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

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

typescript 复制代码
import { getToken } from "./util";

export function getRandomToken() {
  return `${getToken()}_${Math.random()}`
}
typescript 复制代码
export function getToken() {
  return 'xjskak8999'
}

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

csharp 复制代码
pnpm add vite -D

创建一个vite.config.js脚本

javascript 复制代码
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命令

json 复制代码
"scripts": {
  "build": "rollup -c rollup.config.mjs"
}

构建结果如下所示

这样当我们的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

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

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

相关推荐
少云清1 分钟前
【功能测试】6_Web端抓包 _Fiddler抓包工具的应用
前端·功能测试·fiddler
豐儀麟阁贵9 分钟前
8.5在方法中抛出异常
java·开发语言·前端·算法
zengyuhan50339 分钟前
Windows BLE 开发指南(Rust windows-rs)
前端·rust
醉方休42 分钟前
Webpack loader 的执行机制
前端·webpack·rust
前端老宋Running1 小时前
一次从“卡顿地狱”到“丝般顺滑”的 React 搜索优化实战
前端·react.js·掘金日报
隔壁的大叔1 小时前
如何自己构建一个Markdown增量渲染器
前端·javascript
用户4445543654261 小时前
Android的自定义View
前端
WILLF1 小时前
HTML iframe 标签
前端·javascript
枫,为落叶1 小时前
Axios使用教程(一)
前端
小章鱼学前端1 小时前
2025 年最新 Fabric.js 实战:一个完整可上线的图片选区标注组件(含全部源码).
前端·vue.js