BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

SWCとRelease Pleaseで始めるReact TypeScriptライブラリ開発と公開

こんにちは。Pay ID Devの大木 (@roothybrid7)です。 今回外部スクリプトとして読み込み利用する外部SDKを、Reactに組み込むためのラッパーライブラリを作ったので、その開発事例を紹介します。

今回、SWC(Speedy Web Compiler)Release Pleaseを利用して開発したので、主にそれらをどう使ったのかを紹介いたします。

背景

去年12/16に開催しましたオンラインイベント「BASE Tech Talk #1 〜Next.jsを使ったカート大規模リプレイスPJの裏側〜」の通り、BASEカートシステムのFrontendアプリケーションは、Next.jsで動作してます。

さて、アプリケーションでは、Amazon PayやPayPalなど様々な外部の決済サービスを利用しており、それらのJavaScript SDKをいくつか利用しています。 これらのSDKは、幅広いWebサイトで動作するように作られており、ドキュメントも用意され、各種APIを簡単に利用できるようになっています。

利用方法に関しては決められており、バンドルしたり自分自身でホスティングして利用することはできず、<script> タグを使って直接読み込む必要があります。 また、利用登録をして、API Keyといったものも必要になったりします。

SDKの機能利用の方法は、サービスによって様々あり、スクリプト読み込み後のグローバルオブジェクトのメソッドを使うだけのものであったり、一度しか作れないインスタンスを取得しそれによって始めて機能を使うことができるものもあります。 Reactに組み込むのは、他のライブラリとのインテグレーションで述べられているように確かに可能です。 そこでは、他ライブラリのスクリプトが組み込まれた前提の話で勧められていますが、<script> で直接読み込むSDKの場合、以下の点を考慮する必要があると思っています。

  • バンドルして利用することはできないため、スクリプトが読み込まれているかや重複して読み込まないか、SDKの初期化方法の確認
  • SDKのI/Fを使って、Reactのライフサイクルに応じた処理の実行、SDKから取得した値やインスタンスを失わないように保持

小規模なアプリケーションなら、グローバル変数やSDKを利用するReactコンポーネントで保持すれば問題ないのかもしれません。 ただし、規模が大きくなれば、Reactコンポーネントツリーの末端でしかも複数の離れた箇所で利用することもあるため、組み込むのは難しくなります。

問題に関しては、react-paypal-jsに記載されている通りで、他のSDKでも同じような問題が起こります。

The Problem

Developers integrating with PayPal are expected to add the JS SDK <script> to a website and then render components like the PayPal Buttons after the script loads. This architecture works great for simple websites but can be challenging when building single page apps.

React developers think in terms of components and not about loading external scripts from an index.html file. It's easy to end up with a React PayPal integration that's sub-optimal and hurts the buyer's user experience. For example, abstracting away all the implementation details of the PayPal Buttons into a single React component is an anti-pattern because it tightly couples script loading with rendering. It's also problematic when you need to render multiple different PayPal components that share the same global script parameters.

それらの本質的でない複雑な処理は、アプリケーションに実装するよりライブラリにまとめてしまった方が、アプリケーション実装は簡単になるので、検討に値すると思います。あとは、新しいツールを試す絶好の機会にもなります。

また、実装には、以下のライブラリを参考にしました。

プロジェクト構成

さて、実装したライブラリは、外部SDKをロードして利用するラッパーであり、ReactのI/Fに準拠しているが単体では動作しないプラグインと呼ぶようなものです。 以下にpackage.json の内容を抜粋します。

// package.json
{
  "name": "@baseinc/react-payid-js",
  "version": "0.1.0",
  "main": "dist/index.js",
  "types": "dist/types/index.d.ts",
  "publishConfig": {
    "registry": "https://npm.pkg.github.com/"
  },
  "files": [
    "dist",
    "README.md",
    "CHANGELOG.md"
  ],
  "scripts": {
    "bundle": "spack",
    "build": "yarn bundle",
    "test": "jest",
    "type:check": "tsc --noEmit",
    "type:declarations": "tsc --emitDeclarationOnly --outDir dist/types",
    "validate": "concurrently yarn:format:check yarn:type:check yarn:lint yarn:test yarn:build",
    "sync": "concurrently yarn:build yarn:type:declarations",
    "prepack": "yarn clean && yarn sync",
    "clean": "rimraf dist"
  },
  "devDependencies": {
    "@swc/cli": "^0.1.57",
    "@swc/core": "^1.2.203",
    "@swc/jest": "^0.2.21",
    "@testing-library/react": "^13.3.0",
    "@types/react": "^18.0.14",
    "@types/react-dom": "^18.0.5",,
    "concurrently": "^7.2.2",
    "jest": "^28.1.1",
    "jest-environment-jsdom": "^28.1.1",
    "jest-mock-extended": "^2.0.6",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "rimraf": "^3.0.2",
    "typescript": "^4.7.3"
  },
  "peerDependencies": {
    "react": ">=17.0.0",
    "react-dom": ">=17.0.0"
  }
}
  • peerDependencies で、ホストとなるアプリケーションなどが利用を想定されるReactの最低バージョンを指定
  • 開発時やテスト実行時にも Reactが必要となるので、devDependencies でも明示的に指定
  • TypeScriptのコンパイルとbundleは、SWCで行う
  • TypeScriptの型チェックと型定義ファイルの出力は、tscで行う
  • GitHub Packagesで、privateな npm packageとして利用できるように

SWCについて

SWCは、最新機能を使ったJavaScript、TypeScriptファイルをブラウザ互換のJavaScriptに変換することができます。Next.jsDenoでも使われています。 設定は、.swcrc に記述します。Babelの置き換えも目標としているようなところもあり、設定にはそれと同等の設定も見られます。

また、spack(swcpack) という複数のJavaScript、TypeScriptファイルを一つにbundleできる機能があります。 rollup.jswebpackを利用したことがあるという人は、それらと似たようなツールだと考えてもらえればいいです。

bundleの実行

bundleツールの設定は、spack.config.js というファイルに、.swcrc に追加するような設定とあわせて記述できますが、開発途中だからなのか、コンパイル周りの設定のいくつかは、 .swcrc ファイルに追加しなければならないという罠があります。 設定ファイルは以下の通りです。

// spack.config.js
const { config } = require('@swc/core/spack');

module.exports = config({
  entry: {
    web: __dirname + '/src/index.ts',
  },
  output: {
    path: __dirname + '/dist',
    name: 'index.js',
  },
  module: {},
  externalModules: ['react', 'react-dom'],
  // [...snip...]
});

reactreact-dom は、ライブラリを利用するホスト側のアプリケーションなどで解決するため、Bundleに含める必要がないので、externalModules で除外します。rollup.jsのexternalと同じような指定方法ですね。

spack.config.js のあるディレクトリで、spack コマンドを実行すると、output に指定したディレクトリにbundleファイルが出力されます。

$ spack
Bundling done: 0s 138.030983ms
Done: 0s 0.351072ms
✨  Done in 0.49s.

Bundleツール利用時のコンパイル設定

さてこのspack は、.swcrc に追加するようなコンパイル周りの設定も options に追加することができます。 これによりJavaScriptへの変換やminify、難読化も行うことができます。

// spack.config.js
const { config } = require('@swc/core/spack');

module.exports = config({
  // [...snip...]
  options: {
    minify: true,
    jsc: {
      target: 'es5',
      minify: {
        compress: true,
        mangle: {
          keepFnNames: true,
        },
      },
      parser: {
        syntax: 'typescript',
        tsx: true,
        decorators: true,
        dynamicImport: true,
      },
      transform: {
        legacyDecorator: true,
        decoratorMetadata: true,
        react: {
          runtime: 'automatic',
          useBuiltins: true,
        },
      },
    },
  },
});

しかし、未実装な箇所があるのか一部の設定は追加しても動きません。そこで、.swcrc を併用するわけなのですが、デフォルトだと暗黙的にこれを利用してくれます。

利用してくれるのであれば、.swcrc に全て定義すればいいのでは? と思うかもしれませんが、JSON形式で設定を扱うと記述ミスが怖いので、できるだけJavaScript形式の設定ファイルを使いたいです。 以下に、.swcrc の設定内容を載せます。

// .swcrc
{
  "exclude": [".*.test.tsx?", ".*.js$"],
  "module": {
    "type": "commonjs",
    "lazy": true
  },
  "jsc": {
    "transform": {
      "optimizer": {
        "globals": {
          "vars": {
            "ENTRYPOINT_FUNCTION_NAME": "'__payid%example%v1__'",
          }
        }
      }
    }
  }
}

exclude は、適用しないファイルのリストを設定します。ソースコードはTypeScriptなので、JavaScriptの設定ファイルやテストコードのファイルを除外しています。

コンパイル時にコードを置き換える

jsc.transform.optimizer.globals も、spack.config.js に指定しても動かない設定の一つで、コンパイル時に指定した変数を置き換えることが可能です。WebpackのDefinePluginと同じようなことができます。

// .swcrc
{
  // [...snip...]
  "jsc": {
    "transform": {
      "optimizer": {
        "globals": {
          "vars": {
            "ENTRYPOINT_FUNCTION_NAME": "'__payid%example%v1__'",
          }
        }
      }
    }
  }
}

今回利用するSDKは、使うのに用意されたグローバル関数を実行するのですが、今後後方互換性のないアップデートなどが発生した場合、関数名が変更されうるなど、名前が可変なため、コンパイル時に指定の名前に置き換えるという処理に使いました。

ソースコードで以下の記述があったとして、

declare const ENTRYPOINT_FUNCTION_NAME: string;

// [...snip...]
return window[ENTRYPOINT_FUNCTION_NAME];

swcでコンパイルすると、以下のように変換されます。

return window['__payid%example%v1__'];

コンパイル、Bundle設定の調整

また、Bundleファイルが動作するかは、リポジトリに別ディレクトリを切って、最小限のサンプルアプリケーションを作って確認しました。

今回の想定環境は、Next.jsアプリケーションで、Reactのバージョンがv17以上なので、以下のようなプロジェクトの設定を用意しました。

{
  "name": "next",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "dev:sync": "cd ../../; yarn sync"
  },
  "dependencies": {
    "@baseinc/react-payid-js": "latest",
    "jose": "^4.8.1",
    "next": "12.1.6",
    "react": "17.0.2",
    "react-dom": "17.0.2"
  },
  "devDependencies": {
    "@types/node": "18.0.0",
    "@types/react": "^17.0.0",
    "@types/react-dom": "^17.0.0",
    "eslint": "8.18.0",
    "eslint-config-next": "12.1.6",
    "typescript": "4.7.4"
  }
}

commonjs形式にして難読化などもするため、これで、Bundleファイルで提供するライブラリが正しく動作するかを確認して、コンパイル設定を試行錯誤していきました。

型定義ファイルの出力

TypeScriptでコーディングするアプリケーションに組み込んでもらうのに、コード補完や型チェックに利用する型定義ファイル(.d.ts)を用意した方が便利です。 それには、tscコマンドを使って出力します。

$ tsc --emitDeclarationOnly --outDir dist/types
✨  Done in 3.65s.

テスト

外部SDKをロードして利用するラッパーなので、スクリプトのロード管理まわりの動作確認は、自動化しておきたいところです。 テスティングフレームワークとして、Jestを使うことにしました。 また、ソースコードのコンパイルとバンドルに swc を利用しているため、TypeScriptで書いたテストコードの変換に@swc/jestを利用することにしました。 Jest transformerとして、@swc/jest を指定するのですが、swcの設定を jest.config.js に記述することができます。 bundleツールのspack(swcpack) と異なり、設定は全てJestの設定にまとめることが可能です。

また、.swcrc がある場合、暗黙的に利用するため、使わないように設定すると、テスト用のswc設定をまとめられます。

/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
  testEnvironment: 'jest-environment-jsdom',
  transform: {
    '^.+\\.(t|j)sx?$': [
      '@swc/jest',
      {
        swcrc: false,
        sourceMaps: true,
        jsc: {
          parser: {
            syntax: 'typescript',
            tsx: true,
          },
          transform: {
            hidden: {
              jest: true,
            },
            react: {
              runtime: 'automatic',
            },
            optimizer: {
              globals: {
                vars: {
                  ENTRYPOINT_FUNCTION_NAME: "'__payid%js%test__'",
                },
              },
            },
          },
        },
      },
    ],
  },
};

パッケージ公開・管理

作ったライブラリを簡単に導入するためには、npm標準のパッケージ管理システムの仕組みで扱えると便利です。ソースコード管理にGitHubリポジトリを利用しているため、簡単に統合できるGitHub Packagesを利用することにしました。 というのも、GitHub Packagesへの公開は、GitHub Tokenを利用したり、リポジトリの権限と可視性(public/private)を継承できるし、コミットのプッシュやデフォルトブランチのマージをトリガーに、GitHub Actionsを使って自動化が簡単にできます。

パッケージの公開の準備やリリースのような定型作業の自動化というのは、さまざまなツールが公開されています。 今回は、CHANGELOG、GitHubリリースの作成、バージョン番号の更新を自動化できるRelease PleaseとそのGitHub ActionであるRelease Please Actionを利用しました。 なお、パッケージの公開自体は、このツールで直接行うという訳ではなく、ツールで作成されたGitHubリリース作成をトリガーとして利用します。

Release Pleaseについて

Release Pleaseを使うと、以下の作業を自動化できます。

  • CHANGELOGの生成
  • GitHubリリースの作成(とそれに紐づくコミットへのGit Tagの作成)
  • プロジェクトのバージョン番号更新

これらの作業をリリースPRと呼ばれるPull Requestを作成し、それをマージすることによってリリースが実行されます。

デフォルトブランチにマージされたら自動リリースするのと異なり、リリースPRを挟むことによって、事前に変更内容やリリースノートを確認でき、自分のタイミングでリリースすることができます。 また、PRにラベルをつけ、そのリリースがどの状態かを記録しています。

リリースPRは、fix:feat: といった Conventional Commits対応したメッセージが含まれるコミットが、マージされると作成されます。feat: エラーコードの追加 のようなメッセージで、コミットしマージすると、CHANGELOGへの変更内容の追記とバージョン番号の更新がされます。CHANGELOGの追記内容は、コミットメッセージから自動生成されます。また、バージョン番号は、たとえば、feat: というプレフィックスを含むと、セマンティックバージョンのマイナーバージョンが、インクリメントされるという仕組みです。プレフィックスによって、どこがインクリメントされるかは異なります。

package.json

{
  "name": "@baseinc/react-payid-js",
-  "version": "0.4.0",
+  "version": "0.5.0",
  "main": "dist/index.js",

そして、PRをマージすると、GitHubリリースが作られるという仕組みです。

Release Please Actionを使ったパッケージ公開

前述のRelease Pleaseを用いてリリース作業を自動化するためのActionが、Release Please Actionです。 これを使うと、リリースPRとGitHubリリース作成を行う本来の処理の他に、GitHubリリース作成をトリガーにGitHub Packagesへの公開を行うことができます。 それには、Action実行後のoutputsを利用します。release_created という変数にGitHubリリースを作成したかがboolean値で格納されています。 release_created=true であれば、GitHub Actionのjobで、コマンドを使って公開を行います。

name: release
on:
  push:
    branches:
      - main

jobs:
  release-please:
    runs-on: ubuntu-latest
    steps:
      - uses: google-github-actions/release-please-action@v3
        id: release
        with:
          release-type: node
      - uses: actions/checkout@v2
        if: ${{ steps.release.outputs.release_created }}
      - uses: actions/setup-node@v1
        with:
          node-version: "16.x"
        if: ${{ steps.release.outputs.release_created }}
      - name: Resolve dependencies
        run: yarn install --frozen-lockfile
        if: ${{ steps.release.outputs.release_created }}
      - name: Configure git user
        run: |
          git config --global user.email ${{ github.actor }}@users.noreply.github.com
          git config --global user.name ${{ github.actor }}
        if: ${{ steps.release.outputs.release_created }}
      - name: Set GitHub packages
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          npm config set //npm.pkg.github.com/:_authToken=$GITHUB_TOKEN
        if: ${{ steps.release.outputs.release_created }}
      - run: yarn publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        if: ${{ steps.release.outputs.release_created }}

最後に、パッケージには、bundleファイルや型定義ファイルを含めたいため、publish時に動作するnpm scriptを使って、bundleファイルと型定義ファイルの出力を実行します。prepack というスクリプトで実行します。

// package.json
{
  "name": "@baseinc/react-payid-js",
  "version": "0.1.0",
  "main": "dist/index.js",
  "types": "dist/types/index.d.ts",
  "publishConfig": {
    "registry": "https://npm.pkg.github.com/"
  },
  "files": [
    "dist",
    "README.md",
    "CHANGELOG.md"
  ],
  "scripts": {
    "bundle": "spack",
    "build": "yarn bundle",
    "type:declarations": "tsc --emitDeclarationOnly --outDir dist/types",
    "sync": "concurrently yarn:build yarn:type:declarations",
    "prepack": "yarn clean && yarn sync",
    "clean": "rimraf dist"
  },
  // [...snip...]
}

Actionにて、GitHub Packagesに公開されたパッケージは、リポジトリから参照することができます。

おわりに

SWCとRelease Pleaseを利用したReact TypeScriptライブラリ開発と公開の事例を紹介しました。 アプリケーション上で新しい技術を導入するのに躊躇われるケースでも、特定の機能を別途ライブラリ化することによって、小さく始められるので、何かアプリケーションから分離できそうな機能がある場合は、検討してみてはいかかでしょうか?