prepare rollup package

В этой заметке разберем, как запаковать библиотеку с помощью Rollup, в качестве стека будет React, Typescript и Css Модули. В дальнейшем мы сможем поставлять ее в другие проекты в качестве внешней зависимости, с помощью npm install our-awesome-button

Это будет классическая кнопка, то есть React компонент нашей классной кнопки, просто потому что это первое, что пришло мне в голову. 😁

Создаем пустой проект и инициализируем файл package.json

mkdir our-awesome-button
cd our-awesome-button
npm init -y

Устанавливаем необходимые пакеты

npm install --save-dev typescript react @types/react rollup rollup-plugin-typescript2 rollup-plugin-postcss postcss-modules rollup-plugin-ignore  @rollup/plugin-commonjs @rollup/plugin-node-resolve rollup-plugin-terser @babel/core @babel/preset-env @babel/preset-typescript @babel/preset-react rollup-plugin-babel

Настройка

Начнем с инициализации tsconfig.json

npx tsc --init

Теперь нужно доопределить некоторые специфичные для нашей либы штуки - добавляем jsx, в качестве модулей ES2015, и дополнительно указываем путь к declaration.d.ts

{
	"compilerOptions": {
		"jsx": "react",
		"module": "ES2015",
		"typeRoots": ["./node_modules/@types", "./declaration.d.ts"],
		"declaration": true, // Включает генерацию d.ts файлов
		// ...остальное...
	}
}

Мы добавили строчку typeRoots чтобы указать путь к declaration.d.ts (который мы также должны создать), в этом файле мы объявим css модули, иначе Typescript будет злостно ругаться при сборке со словами Cannot find module './styles.module.css' or its corresponding type declarations. Немного забежал вперед с ошибками сборки, подробности ниже.

Итак, содержимое declaration.d.ts

declare module '*.module.css' {
  const classes: { readonly [key: string]: string };
  export default classes;
}

Конфигурация для rollup

Создаем файл конфигурации для rollup

mkdir rollup.config.js

C таким содержимым

import typescript from 'rollup-plugin-typescript2';
import postcss from 'rollup-plugin-postcss';
import postcssModules from 'postcss-modules';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import babel from 'rollup-plugin-babel';
import { terser } from 'rollup-plugin-terser';

const isDev = process.env.NODE_ENV === 'development';

const commonPlugins = [
  resolve(),
  commonjs(),
  typescript(),
  babel({
    exclude: 'node_modules/**',
    presets: [
      '@babel/preset-env',
      '@babel/preset-typescript',
      '@babel/preset-react',
    ],
  }),
];

const cjsPlugins = [
  ...commonPlugins,
  postcss({
    extract: true,
    plugins: [postcssModules({
      generateScopedName: '[local]',
      minimize: !isDev,
    })],
  }),
];

const esmPlugins = [
  ...commonPlugins,
  postcss({
    extract: false,
    plugins: [postcssModules({
      generateScopedName: '[local]',
      minimize: !isDev,
    })],
  }),
];

if (!isDev) {
  cjsPlugins.push(terser());
  esmPlugins.push(terser());
}

/**
 * Rollup configuration
 * @type {import('rollup').RollupOptions} 
 */
const cjsConfig = {
  input: 'src/index.tsx',
  output: [
    {
      file: 'dist/cjs/index.js',
      format: 'cjs',
      exports: 'named',
      sourcemap: true,
    },
  ],
  external: ['react'],
  plugins: cjsPlugins,
};

/**
 * Rollup configuration
 * @type {import('rollup').RollupOptions} 
 */
const esmConfig = {
  input: 'src/index.tsx',
  output: [
    {
      file: 'dist/esm/index.esm.js',
      format: 'esm',
      exports: 'named',
      sourcemap: true,
    },
  ],
  external: ['react'],
  plugins: esmPlugins,
};

export default [cjsConfig, esmConfig];

На что нужно обратить внимание

  1. Строчка external: ['react'] - говорит о том, что react является внешним модулем и его код не надо включать в банлд, что это значит? Это значит что для esm версии вместо кода react будет строчка import react from 'react', а в cjs версии - var react = require('react').

  2. generateScopedName: '[local]' - С другим значением в банлд попадают разные названия классов в стилях и с самом компоненте, Если поменяете на что-то другое и посмотрите на результат сборки - поймете, о чем я говорю. Для меня сработало только значение local, возможно есть лучшее решение.

  3. За сжатие содержимого отвечает плагин terser, за сжатие стилей отвечает строчка minimize: !isDev - тут можете настроить на свое усмотрение, можно отключить сжатие, если вам важно сохранить хоть какую-то читаемость кода после сборки, тут, как говорится "Зависит от обстоятельств". В нашем случае все завязано на значение env переменной NODE_ENV

  4. Last, but not least - мы собираем нашу кнопку в двух вариантах: cjs и esm. Чтобы в зависимости от системы модулей окружения, в котором будет импортироваться кнопка, импортировалось более подходящий модуль. Для esm модулей мы инжектим стили прямо в бандл - Это дает возможность заимпортировать компонент одной строчкой и сразу использовать его (И не напрягаться с отдельным импортом стилей, потому что они уже соберуться в js бандл):

     import { Button } from 'our-awesome-button;

Для cjs модулей мы экстрактим стили в отдельный файл (extract: true) - это делается для того, чтобы оставить возможность заимпортировать стили вручную, но с вероятностью 90% - это не потребуется.

Как и где это будет работать?

Возьмем например nextjs, у него есть серверный и клиентский рендеринг. Для клиентского рендеринга во вмемя сборки подтянется esm модуль с вшитыми в банлд стилями, а во время серверного рендеринга, приложение на nodejs использует require и подключит cjs модуль c с помощью require, на сервере стили не нужны, поэтому мы не включили стили в банлд.

Правки в package.json

Для того, чтобы подключался esm либо cjs модуль нужно рассказать в нашем package.json, что и где у нас лежит, а также не забыть указать пути к типам и исходному коду:

{
  "name": "our-awesome-button",
  "main": "dist/cjs/index.js",
  "module": "dist/esm/index.esm.js",
  "types": "dist/esm/index.d.js",
  "source": "src/index.tsx",
  "files": [
    "dist",
    "src"
  ],
  "scripts": {
    "build:dev": "NODE_ENV=development rollup -c",
    "build": "rollup -c",
    "build:watch": "rollup -c -w"
  },
  "peerDependencies": {
    "react": "^18.2.0"
  },
  "devDependencies": {
    ...
  }
}

Итак, в поле main мы указываем путь к cjs бандлу, в поле module - путь к esm бандлу, types отвечает за путь к декларациям типов (они генерируются и для cjs и для esm, разницы, какие именно указать - нет).

Поле source указывает на точку входа в исходники - может быть полезно для настройки сборки для нескольких пакетов в будущем, чтобы не хардкодить пути в rollup.config.js, а считывать их из package.json

Поле files указывает, какие файлы попадут в реджестри при публикации пакета (а также при выполнении команды npm pack) - мы поставляем и dist и src

Подробнее о различных полях в package.json можно почитать в доке npm.

Сам код либы/компонента

Наш компонент кнопки будет очень простым и будет содержать 2 файла в директории src:

  • index.tsx
  • styles.module.tsx
// index.tsx
import React, { ButtonHTMLAttributes, ReactNode } from 'react';
import styles from './styles.module.css';

export const Button: React.FC<ButtonHTMLAttributes<HTMLButtonElement>> = (props) => {
  const { children, ...rest } = props;
  return <button className={styles.button} {...rest}>{children as ReactNode}</button>;
};
/* styles.module.css */
.button {
    background: #4d53b3;
    color: #fff;
    border-radius: 8px;
    border: none;
    padding: 10px 8px;
}

Этого будет достаточно, чтобы собрать и проверить работу компонента

Проверяем

npm run build # Собираем пакет (запускаем из корня проекта), проверяем, что папка dist создалась и сборка прошла успешно
pwd # Выводим полный путь и копируем в буффер обмена

cd ~/Projects && npx create-react-app test-cra-app # Создаем новый проект на основе create-react-app
cd test-cra-app && npm install /Path/to/builded/package # Устанавливаем пакет из пути, который выводили в pwd

После того, как собрали проект и установили его в только что созданный проект на create react app, импортируем компонент в src/App.js

// src/App.js
import './App.css';
import { Button } from 'our-awesome-button';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <Button onClick={() => console.log("Hello")}>Test button</Button>
      </header>
    </div>
  );
}

export default App;

Публикация пакета

Тут все просто и много где описан этот процесс (Ссылка на оф. доку):

  • Логинитесь в npm с помощью npm login
  • Из корня проекта запускаете npm publish (Не забываем перед этим собрать проект с помощью build скрипта)
  • Ваш пакет в npm! ✨

Пример проекта

Пример проекта, с которого можно стартануть можно найти у меня на гитхабе

В терминале выполняем:

git clone git@github.com:theStrangeAdventurer/rollup-lib-example.git package-dir

cd package-dir

npm i 

npm run build