Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

借助rollup构建npm包最佳实践 #99

Open
willson-wang opened this issue Aug 12, 2021 · 1 comment
Open

借助rollup构建npm包最佳实践 #99

willson-wang opened this issue Aug 12, 2021 · 1 comment

Comments

@willson-wang
Copy link
Owner

目录

背景

公司鉴于经常开发npm包,于是需要统一一下npm包的开发规范,总结一个可以通用的npm包模版

开发npm通用模版,最关的就是构建工具的选择,该构建工具需要包含如下功能

  • 构建快速
  • 能过输出不同模块规范的包
  • 支持babel转化
  • 支持typescript
  • 学习成本低
  • 支持tree-shaking

鉴于上述功能,最终选择了rollup来作为我们构建npm包的工具,理由如下

  • rollup相对于webpack更轻量
  • rollup相对于webpack更适合构建npm包的场景
  • rollup目录社区也是越来越活跃,满足上述的功能
  • rollup相对于webpack学习成本更低

使用

  1. 全局安装
yarn global add rollup
  1. 项目内安装
yarn add rollup -D

npm包打包构建默认选择项目内安装方式,便于rollup版本的升级

  1. 命令行指定配置
// 全局命令使用
rollup ./src/main.js --file ./dist/bundle.js --format cjs

// 项目内安装使用
./node_modules/.bin/rollup ./src/main.js --file ./dist/bundle.js --format cjs

// 项目内package.json scripts内使用
"build": "rollup ./src/main.js --file ./dist/bundle.js --format cjs"
  1. 命令行指定配置文件
// rollup.config.js
export default {
  input: 'src/main.js',
  output: {
    file: 'bundle.js',
    format: 'cjs'
  }
};

rollup -c ./rollup.config.js
  1. rollup.config.js参数解析
// rollup.config.js

export default { // 可以是一个数组
  // 核心输入参数
  external, // 排除不需要被打包到文件内的模块
  input, // entry,可以有多个输入
  plugins, // 插件列表

  // 高级参数
  cache, 
  onwarn,
  preserveEntrySignatures,
  strictDeprecations,

  // danger zone
  acorn,
  acornInjectPlugins,
  context,
  moduleContext,
  preserveSymlinks,
  shimMissingExports,
  treeshake,

  // experimental
  experimentalCacheExpiry,
  perf,

  output: { 可以是数组,同一个输入有多份输出
    // 核心输出参数
    dir,  // 多entry的时候需要这个参数
    file, // 单entry的时候需要定义这个参数,与dir参数不能同时使用
    format, // 输出文件的模块规范,amd、cmd、es module
    globals, // 针对输出umd/iife格式的时候,导入的全局变量 
    name, // 针对输出iife/umd格式时的,包的全局变量名称或者自执行之后接收变量
    plugins, // 此插件仅处理该output内容,仅限于使用在 bundle.generate() 或 bundle.write() 期间运行的钩子的插件,即在 Rollup 的主要分析完成之后

    // advanced output options
    assetFileNames, // 自定义输出文件的名称[extname][]
    banner,
    chunkFileNames, // 异步加载chunk [hash][format][name]
    compact, 
    entryFileNames, // 入口chunk [name][hash][format]
    extend,
    footer,
    hoistTransitiveImports, // 模块导入优化项
    inlineDynamicImports,
    interop, // 导入外部模块的方式
    intro,
    manualChunks, // 提取chunk
    minifyInternalExports,
    outro,
    paths, // 将模块id替换成路径,比如资源换成cnd路径
    preserveModules,  // 生成的模块按照原目录结构生成
    preserveModulesRoot, // 配合preserveModules使用
    sourcemap,
    sourcemapExcludeSources,
    sourcemapFile,
    sourcemapPathTransform,
    validate,

    // danger zone
    amd,
    esModule,
    exports, // 模块内部导出方式
    externalLiveBindings,
    freeze,
    indent,
    namespaceToStringTag,
    noConflict,
    preferConst,
    sanitizeFileName,
    strict,
    systemNullSetters
  },

  watch: {
    buildDelay,
    chokidar,
    clearScreen,
    skipWrite,
    exclude,
    include
  } | false
};

rollup插件

常用插件

rollup默认只能识别es module,原因是tree-shaking的条件就是需要es module才能进行,而es module能够进行tree-shaking的原因是,es module是静态模块导入,我们在引入一个模块的时候就已经确定了该模块导出的内容,所以我们在借助rollup来进行npm包开发的时候,可能会引入一写第三方npm包,而目前很多第三方npm包都使用的是commonjs模块规范导出的模块,所以在开发的时候需要借助rollup的插件来帮助我们完成npm包的开发

我们先定义下我们常用的npm包开发,需要做哪些事情

  1. javascript工具库,只包含javascript代码
    • 基于typescript开发,所以我们需要能够将ts转化成js的插件
    • 能过进行语法转换与polyfill,避免其它项目使用的时候,还需要在项目的babel-loader那里再来处理我们的npm包
    • 能够处理外部引入的第三方npm
    • dist目录下能够分别生成符合es module 与 commonjs 规范的包
  2. 组件库,即包含js也包含css
    • 基于typescript开发,所以我们需要能够将ts转化成js的插件
    • 能过进行语法转换与polyfill,避免其它项目使用的时候,还需要在项目的babel-loader那里再来处理我们的npm
    • 能够处理外部引入的第三方npm
    • 能过生成dist目录与es目录,分包包含符合commonjs规范与es module规范的包
基于typescript开发
yarn add @rollup/plugin-typescript -D
// rollup.config.js
plugins: [
    typescript({
      tsconfig: './tsconfig.mutil.json', // 解决插件没有应用 tsconfig 内的配置
    }),
  ],
基于babel做语法转换与polyfill
yarn add @rollup/plugin-babel -D
// rollup.config.js
plugins: [
    babel({
      babelHelpers: 'bundled',
      exclude: 'node_modules/**',
      configFile: path.resolve(__dirname, '.babelrc'),
    })
  ],
  
or

plugins: [
    getBabelOutputPlugin({
        configFile: path.resolve(__dirname, '.babelrc'),
      }),
  ],
// .babelrc
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "debug": true
      }
    ]
  ],
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "absoluteRuntime": false,
        "corejs": {
          "version": 3,
          "proposals": false
        },
        "helpers": true,
        "regenerator": true
      }
    ]
  ]
}

注意babel与getBabelOutputPlugin方法的区别

  • babel是getBabelInputPlugin的别名,表示先用babel处理代码,然后在把代码交给rollup处理
  • getBabelOutputPlugin,表示先用rollup处理代码,然后在把代码交给babel处理

二者之前有细微的差异,前者可以包含includes、excludes参数,后者不能使用includes、excludes参数

借助@babel/plugin-transform-runtime插件解决polyfill而不是@babel/preset-env解决ployfill的原因是前者是无污染的polyfill后者是有污染的polyfill

目前前端处理js polyfill的方式主要有这两种

  • 借助polypill.io来添加垫片

  • 借助babel来处理ployfill,而babel实际上借助的core-js来实现具体的ployfill功能

    • core-js目前分为2个主要版本2.x与3.x,二者的区别主要是代码目录的划分与模块命名不一致

    • babe处理polyfill有三种方式

      • 直接在项目入口引用core-js

      • 借助@babel/preset-env处理polyfill

        • entry:全局引入polyfill,这种方式改动的是全局的对象,是有污染的
        • usage: 根据代码导入polypill,导入方式是import 'core-js/xxxx/xxx.js',这种方式改动的是全局的对象,是有污染的
      • 借助@babel/plugin-transform-runtime处理polyfill,该插件与usage一样也是扫描代码得出哪些api需要进行polypill,只不过引用的方式改成了import _promise from 'core-js/xxx/xxx.js',这种方式就是无污染的polypill

处理第三方npm包
yarn add @rollup/plugin-node-resolve -D
yarn add @rollup/plugin-commonjs -D
yarn add @rollup/plugin-json -D
// rollup.config.js
plugins: [
    resolve(),
    json(),
    commonjs({
      transformMixedEsModules: true,
    }),
  ],

注意transformMixedEsModules这个参数,允许导入的模块内commonjs与esmodule混用

处理css文件
yarn add rollup-plugin-import-css -D
plugins: [
	css(),
],

有些npm包内有css文件,所以需要借助css插件进行处理

处理amd规范的模块
yarn add rollup-plugin-amd -D
plugins: [
	amd(),
],

改包可以将amd规范的模块转化成es module

该包只能识别exports.foo = foo这种语法,不支持module.exports.foo = foo这种语法

生成符合es module与 commons 规范的包
// rollup.config.js
export default [
  {
    input: './src/index.ts',
    output: [{
      file: pkg.main,
      format: 'cjs',
      exports: 'named',
      paths: {
        axios: 'https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js'
      }
    },{
      file: pkg.module,
      format: 'esm',
      exports: 'named',
      paths: {
        axios: 'https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js'
      }
    }]
  }
];
// package.json
{
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js",
}

注意file的取值,分别是dist/index.cjs.jsdist/index.esm.js

目前主流的构建工具都支持识别package.json内的module字段,而我们将module字段指向es module规范的文件,这样做的目的是,通过构建工具的tree-shaking功能将没有使用到的代码去掉,减少包的体积大小

插件开发

开发rollup插件因该遵循如下规范

  • 插件名称应该以 rollup-plugin-为前缀
  • package.json内的keyword字段应该包含rollup-plugin-
  • 插件应该是已测试的
  • 插件内部最好使用异步方法

开发一个简单的替换内容的插件

// rollup-plugin-rename-amd-exports
import { createFilter } from 'rollup-pluginutils';

const firstpass = /\b(?:define)\b/;

export default function index(options = {}) {

  const filter = createFilter(options.include, options.exclude);

  return {
    name: 'rollup-plugin-rename-amd-exports',

    transform: function transform(code, id) {
      if (!filter(id)) {
        return;
      }
      if (!firstpass.test(code)) {
        return;
      }
      const newCode = code.replace('module.exports = UI;', 'exports.UI = UI;')

      return newCode;
    }
  };
}

最佳实践

单文件输出es module与commonjs规范文件

// rollup.config.js
import path from 'path'
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';

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({
        tsconfig: './tsconfig.json', // 解决插件没有应用 tsconfig 内的配置
      }),
      getBabelOutputPlugin({
        configFile: path.resolve(__dirname, '.babelrc'),
      }),
    ],
    external: [], // 不需要打入包内的第三方npm包,例如['lodash']
  }
];
// tsconfig.json
{
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "module": "es2015",
    "target": "es5",
    "strict": true,
    "allowJs": false,
    "noUnusedLocals": true,
    "removeComments": true,
    "declaration": true,
    "skipLibCheck": true,
    "importHelpers": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "moduleResolution": "node",
    "experimentalDecorators": true,
    "forceConsistentCasingInFileNames": true,
    "emitDecoratorMetadata": true,
    "noEmitOnError": true,
    "noUnusedParameters": false,
    "strictPropertyInitialization": false,
    "sourceMap": false,
    "declarationDir": "./"
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "src/__tests__"]
}
// .babelrc
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "debug": false
      }
    ]
  ]
}

如果需要对npm包的代码进行polyfill,需要借助@babel/plugin-transform-runtime插件

yarn add @babel/plugin-transform-runtime -D
yarn add @babel/runtime-corejs3
// .babelrc
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "debug": true
      }
    ]
  ],
  "plugins": [
    [
      "@babel/plugin-transform-runtime",
      {
        "absoluteRuntime": false,
        "corejs": {
          "version": 3,
          "proposals": false
        },
        "helpers": true,
        "regenerator": true
      }
    ]
  ]
}

多文件输出es module or commonjs规范文件

单文件输出不能做到按需加载,因为代码都已经合并到一个模块了,如果我们要做代码的按需加载需要满足下面两个条件

  1. npm包的模块是多模块导出的,而不是聚合的一个模块,比如
|-- modules
    |-- permission
    |-- token
|-- index.js
index.js

export { default as permission } from './modules/permission'
export { default as token } from './modules/token'
  1. 项目内使用上面的npm包,实现按需导入,有两种引入方式
1. import permission from '@yunke/core/modules/permission'
2. import { permission } from '@yunke/core' 需要借助babel插件转化成import permission from '@yunke/core/modules/permission'

代码目录结构

image

rollup配置文件

// .rollup.config.js
import path from 'path'
import babel 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';

export default [{
  input: ['./src/index.ts', './src/modules/permission.ts', './src/modules/token.ts'],
  output: [{
    dir: 'dist',
    format: 'cjs',
    exports: 'named',
    preserveModules: true,
    preserveModulesRoot: 'src',
  }],
  plugins: [
    resolve(),
    json(),
    commonjs({
      transformMixedEsModules: true,
    }),
    typescript({
      tsconfig: './tsconfig.mutil.json', // 解决插件没有应用 tsconfig 内的配置
    }),
    babel({
      babelHelpers: 'bundled',
      exclude: 'node_modules/**',
      configFile: path.resolve(__dirname, '.babelrc'),
    })
  ],
  external: [], // 不需要打入包内的第三方npm包,例如['lodash']
}];

多文件输出的时候,如果ts有输出.d.ts文件,那么declarationDir参数需要设置为dir对应的目录

输出结果如下图所示

image

注意这时候输出的文件是按照源文件的目录结构输出的,如果代码内引入了npm包,且preserveModules:true则会在dist目录生成对应的node_modules目录,具体解决方法可以参考FAQ,如果preserveModules:false则不会生成多余的node_modules,但是不会按照原目录结构生成文件

多文件多目录输出

比如组件库,可能输出dist目录与es目录

代码目录如下图所示

image

rollup配置

import path from 'path'
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';

function createConfig({output, tsconfig}) {
  return {
    input: ['./src/index.ts', './src/components/toast.ts', './src/components/button.ts'],
    output,
    plugins: [
      resolve(),
      json(),
      commonjs({
        transformMixedEsModules: true,
      }),
      typescript({
        tsconfig, // 解决插件没有应用 tsconfig 内的配置
      }),
      getBabelOutputPlugin({
        configFile: path.resolve(__dirname, '.babelrc'),
      }),
    ],
    external: [], // 不需要打入包内的第三方npm包,例如['lodash']
  }
}

export default [
  createConfig({
    output: [{
      dir: 'dist',
      format: 'cjs',
      exports: 'named',
      preserveModules: true, // 保持模块按照目录结构输出
      preserveModulesRoot: 'dist',
    }],
    tsconfig: './tsconfig.dist.json',
  }),
  createConfig({
    output: [{
      dir: 'es',
      format: 'esm',
      exports: 'named',
      preserveModules: true,
      preserveModulesRoot: 'es',
    }],
    tsconfig: './tsconfig.es.json',
  }),
];

tsconfig.dist.json与tsconfig.es.json的区别就是outDirdeclarationDir参数值不同,分别是./dist./es

输出结果入下图所示

image

这样构建出来的组件库就可以实现按需导入,同时支持满足tree-shaking的条件

FAQ

  1. 多文件输出的时候,且preserveModules:true的场景,如果有引入npm包,且没有通过external参数排除,则生成的目录内会包含node_modules目录,当包真正构建的时候被报错,解决方法如下所示Why does the preserveModules option generate node_modules folder

总结

在通过rollup构建npm包的过程中,碰到了很多的坑,比如碰到了npm模块内有amd的模块,碰到了npm包内有css文件,碰到了多文件输出有node_modules的问题,碰到了babel插件没有生效的问题等等,最后总结了一些最佳实践,以备后续使用

@edwineo
Copy link

edwineo commented Sep 12, 2023

赞,preserveModules 的这个问题正好困扰着我,学习到了 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants