При декомпозиции системы на модули и переиспользовании компонентов в разных условиях и проектах необходимо решить первичную задачу - как упаковать и доставить его до других систем.

Эту задачу необходимо решить не зависимо от типа компонента и его размера, от маленькой кнопки до большого модуля видео плеера или целой игры.

Недавно я столкнулся с задачей выделения в библиотеку целого большого модуля на React\Typescript - игрового фронтенда game.jugru.org. Модуль переиспользуется в трех различных приложениях.

Задача усложнялась тем, что зависимостей у модуля было много, среди которых были довольно крупные и плохо интегрируемые с такими же модулями в проекте - реципиенте.

 {
 	"dependencies": 
    {
        "@inlet/react-pixi": "^5.1.4",
        "@microsoft/signalr": "^5.0.2",
        "evergreen-ui": "^5.1.2",
        "mobx": "^6.0.0",
        "pixi.js-legacy": "^5.3.7",
        "react": "^16.13.1",
        "react-dom": "^16.13.1",
    }
}

Готового решения, как в Angular проектах, я не нашел и поэтому решил завелосипедить самостоятельно.

С чего начать?

Начать стоит с формирования репозитория, в котором будет располагаться сама библиотека, и сразу в нем же example проект, который даст отлаживать готовое решение и собственно вести разработку с hot reload, проверкой синтаксиса и прочим.

Исторически я использую CRA почти с момента его первой публикации. Он позволяет решать многие задачи, абстрагируясь от конфигурации Webpack.

Я разбиваю проекты по принципу feature based modules.

npm package folder structure

Основные модули или части пакета доступны в папках game-engine-XXX со своими зависимостями. Их можно в любой момент экспортировать во внешний пакет и разрабатывать независимо, не перелопачивая весь проект в случае изменения функциональности.

В каталоге example находится входная точка и все содержимое CRA обертки для разработки.  

Основный модулем для экспорта пакета является файл index.tsx.

import ReactDOM from "react-dom";
import React from "react";

import GameApplication, { PopupCallbackData } from "./game-engine/GameApplication";
import GameAdminApplication from "./game-engine-admin/GameAdminApplication";
import { DemoApplication } from "./example";
import { isExampleRunning } from "./example/config/config";
import { AuthOptions, TokenRights } from "./game-engine-admin/config";

export { GameApplication, GameAdminApplication };
export type { PopupCallbackData, AuthOptions, TokenRights }

if (isExampleRunning) {
    ReactDOM.render(<DemoApplication/>, document.getElementById('root'));
}

Мы реэкспортируем типы, которые будут основой типов пакета, и одновременно не позволяем экспортироваться example приложению в пакет за счет пробрасывания флага запуска isExampleRunning, который зависит от ENV переменных заданных при запуске.

Typescript самостоятельно построит d.ts файлы для описания типов приложения.

Осталось собрать сам пакет.

Сборка пакета

Существует много способов собрать typescript приложение, начиная от простой транспиляции компилятором TS его в javascript ES5 или ES6, заканчивая сборщиками webpack и rollup.

Я выбрал сборщик, так как помимо кода приложения, необходимо экспортировать изображения, транспилировать sass модули в css и сделать из этого один бандл.

А конкретно rollup, так как он показался мне понятнее для сборки пакета, поддерживает treeshaking, и проще в настройке.

Rollup is a module bundler for JavaScript which compiles small pieces of code into something larger and more complex, such as a library or application. It uses the new standardized format for code modules included in the ES6 revision of JavaScript, instead of previous idiosyncratic solutions such as CommonJS and AMD. ES modules let you freely and seamlessly combine the most useful individual functions from your favorite libraries. This will eventually be possible natively everywhere, but Rollup lets you do it today.

Итак, установим сам rollup

npm install -D rollup

И плагины, необходимые для сборки react приложения

npm install -D rollup-plugin-peer-deps-external \ 
rollup-plugin-postcss-modules \
rollup-plugin-typescript2 \
@rollup/plugin-babel \
@rollup/plugin-commonjs \ 
@rollup/plugin-node-resolve \ 
@rollup/plugin-url @svgr/rollup

В package.json добавляем секции, которые нам дадут экспортировать npm пакет

{
    "main": "dist/index.js",
    "module": "dist/index.es.js",
    "source": "src/index.tsx",
    "engines": {
        "node": ">=14"
    },
    "peerDependencies": {
        "react": ">=16.13.0",
        "react-dom": ">=16.13.0"
        ...
    },
    "dependencies": {
        ...
        "react": "^16.13.1",
        "react-dom": "^16.13.1",
        ...
    },
    "devDependencies": {
        "@rollup/plugin-babel": "^5.2.2",
        "@rollup/plugin-commonjs": "^16.0.0",
        "@rollup/plugin-node-resolve": "^10.0.0",
        "@rollup/plugin-url": "^5.0.1",
        "@svgr/rollup": "^5.5.0",
        "node-sass": "^4.13.1",
        "react-router-dom": "^5.1.2",
        "react-scripts": "^3.4.1",
        "rollup": "^2.41.2",
        "rollup-plugin-analyzer": "^4.0.0",
        "rollup-plugin-peer-deps-external": "^2.2.4",
        "rollup-plugin-postcss-modules": "^2.0.2",
        "rollup-plugin-typescript2": "^0.29.0",
        "typescript": "^3.8.3"
    },
    "files": [
        "dist"
    ],
    ...
}

Следующим шагом добавляем rollup.config.js

import typescript from "rollup-plugin-typescript2";
import commonjs from "@rollup/plugin-commonjs";
import external from "rollup-plugin-peer-deps-external";
import postcss from 'rollup-plugin-postcss-modules'
import resolve from "@rollup/plugin-node-resolve";
import analyze from 'rollup-plugin-analyzer';
import svgr from "@svgr/rollup";
import pkg from "./package.json";

export default {
    input: "src/index.tsx",
    output: [
        {
            file: pkg.main,
            format: "cjs",
            exports: "named",
            sourcemap: true,
        }
    ],
    plugins: [
        external(),
        postcss({
            extract: true,
            writeDefinitions: false,
        }),
        svgr(),
        resolve(),
        typescript({
            rollupCommonJSResolveHack: true,
            clean: true,
        }),
        commonjs({sourceMap: false}),
        analyze({
            summaryOnly: true
        }),
    ],
};

Добавляем секции запуска

"scripts": {
        "test": "react-scripts test",
        "build": "rollup -c",
        "start": "rollup -c -w --no-treeshake",
        "build-example": "REACT_APP_EXAMPLE=true react-scripts build",
        "start-example-local": "REACT_APP_EXAMPLE=true react-scripts start",
        "start-example-dev": "REACT_APP_EXAMPLE=true REACT_APP_STAGE=dev react-scripts start",
        "start-example-test": "REACT_APP_EXAMPLE=true REACT_APP_STAGE=test react-scripts start"
    },

Как видно, я использую отдельные команды для запуска example CRA приложения в различных средах с разными backend серверами.

Для сборки в режиме dev для ускорения сборки я не использую treeshaking, это сокращает время сборки примерно на 5 секунд.

Приведу также настройку своего typescript компилятора

{
    "compilerOptions": {
        "outDir": "build",
        "module": "esnext",
        "target": "es5",
        "lib": [
            "es6",
            "dom",
            "es2016",
            "es2017"
        ],
        "sourceMap": true,
        "allowJs": false,
        "jsx": "react",
        "declaration": true,
        "moduleResolution": "node",
        "forceConsistentCasingInFileNames": true,
        "noImplicitReturns": true,
        "noImplicitThis": true,
        "noImplicitAny": true,
        "strictNullChecks": true,
        "suppressImplicitAnyIndexErrors": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "skipLibCheck": true,
        "esModuleInterop": true,
        "allowSyntheticDefaultImports": true,
        "strict": true,
        "resolveJsonModule": true,
        "isolatedModules": true,
        "noEmit": true,
        "noFallthroughCasesInSwitch": true,
        "experimentalDecorators": true,
        "useDefineForClassFields": true
    },
    "include": [
        "src"
    ],
    "exclude": [
        "node_modules",
        "build",
        "dist",
        "rollup.config.js"
    ]
}

Сборка

Запускаем команду

npm run build
> @online/online-game-frontend@1.0.0 build /Users/kroniak/Workspaces/jugru/online/game/game-demo-frontend
> rollup -c


src/index.tsx → dist/index.js...
-----------------------------
Rollup File Analysis
-----------------------------
bundle size:    5.066 MB
original size:  5.946 MB
code reduction: 14.79 %
module count:   592

created dist/index.js in 18.4s

Process finished with exit code 0

В итоговой директории получаем собранный пакет

Все! Пакет готов к публикации как npm пакет в ваш корпоративный или публичный репозиторий.