Open14

React + TypeScript + Rollup + WebComponents(CustomElements) 構築メモ

北山淳也北山淳也

React + TypeScript の開発環境をRollupで作ってCustomElementsを作りたかったがナニモワカラナイだったのでメモまとめ。なおwebpackは使いたくないので使わない。
React標準はwebpackみたいなのでRollupだから苦労した点みたいなのは多いけどSHOUGANAI
Turbopackは2023/07現在まだ使える水準になってないように思えたけど
使えるレベルになってReact標準がTurbopackを採用したりしたらまた環境変わってくるかもな。

esbuildも考えたがViteでもプロダクションビルドにはRollupを使っているとのことだったので今回はRollupで構築することにした。
なんかやけに最近の情報がないなと思ったら、後から知ったが、Nextjsだとこのあたりのバンドラやツールチェイン周りも最初から用意してくれるらしくてみんなもう自前でバンドラ1から構築するとかやってないんだろうなって思った。

北山淳也北山淳也

まとまって参考になる情報もあったのでこの辺りを並行してみながら設定ファイルを作っていく

React+TypeScript+Storybook+Rollupのプロジェクト作成 | Kazunori Kimura | zenn
https://zenn.dev/kazunori_kimura/scraps/14f51a6a142c92

rollup.js × TypeScriptのライブラリをnpm公開する環境の構築(2020年12月版)
らんす🍐 | zenn
https://zenn.dev/no4_dev/articles/74f80c4243919ea2a247-2

TypeScript+rollup.js+EmotionでReact開発できる環境を作ってみた | SANDFISH FACTORY
https://sandfishfactory.hatenablog.com/entry/2021/01/26/214016

Component library setup with React, TypeScript and Rollup | dev.to
https://dev.to/siddharthvenkatesh/component-library-setup-with-react-typescript-and-rollup-onj

Rollup Config for React Component Library With TypeScript + SCSS | Ng Charn Chuen
https://www.codefeetime.com/post/rollup-config-for-react-component-library-with-typescript-scss/

最近作ったRollup.jsの設定詳細 (2019年7月版) | Qiita
https://qiita.com/otolab/items/95313254b62f5c0b6c10

北山淳也北山淳也

TypeScript トランスパイル

TypeScriptを使うには npm TypeScript のパッケージがいるのは当然。
tscの設定ファイルは tsconfig.json

Rollupでバンドラ作成するときにtscするにはpluginが必要だが
@rollup/plugin-typescript と rollup-plugin-typescript2 の派閥がある様子。
2023現在は @rollup/plugin-typescript が推奨っぽい

Request to make rollup-plugin-typescript2 the "official" typescript plugin | Issue #541 | rollup/plugins | GitHub
https://github.com/rollup/plugins/issues/541

ただ、歴史の中で rollup-plugin-typescript2 が長く使われてきたようだし知見も多かったので
今回は rollup-plugin-typescript2 を一旦使ってみて
バンドル作成周りが落ち着いたら @rollup/plugin-typescript に切り替えるとかすることにする

北山淳也北山淳也

ES Modules

当然ながらモジュール分割は必要なので方式を決める必要があるが2023/07現代ならES Modulesだろう
なのでそのようにセットアップを進めていこうとしたがなかなか一筋縄ではいかなかった

tsconfig.json で ES Modules を使うことを設定してやればいいのだが、
npmパッケージの多くは今もCommonJSで書かれているので
その解決をどうするかを Rollup に指定してやらないといけない
それをやってくれるのが rollup-plugin-commonjs

どう詰まってどういうつらみがあるかは既に多くのエントリで話されているので割愛

TypeScriptのESMでハマる | くらげになりたい。
https://www.memory-lovers.blog/entry/2022/05/31/110000

よく分からなかったのでCommonJSとTypeScriptのES Modulesinteropについて調べてみた | パケット畑でつかまえて
https://omochizo.netlify.app/posts/2020/08/commonjs-esm/

TypeScriptのesModuleinteropフラグを設定してCommonJSモジュールを実行可能とする | DevelopersIO
https://dev.classmethod.jp/articles/esmoduleinterop-flag-for-typescript/

.mjs とは何か、またはモジュールベース JS とエコシステムの今後 | blog.jxck.io
https://blog.jxck.io/entries/2017-08-15/universal-mjs-ecosystem.html

CommonJSとES Modulesについてまとめる | よだか | zenn
https://zenn.dev/yodaka/articles/596f441acf1cf3
このエントリの「ES Modulesに全振りする上での問題点」が一番まとまってる

北山淳也北山淳也

JSX

TypeScript はJSXサポートをしている。専用のオプションをtsconfig.json に入れてやれば型サポートなどが効く。(関係ないが、なんかReactに手厚いなーやっぱユーザ多いからかなとか思った)

JSX | TypeScript: Documentation
https://www.typescriptlang.org/docs/handbook/jsx.html

一方でRollupはJSXをJavaScriptに変換できない。それを担当してもらうためには
Babelにやってもらう必要があるようで、Rollup > Babel > @babel/preset-react にお願いすることになる
まわりくどいなと思ったが既にあるエコシステムに乗っかるという意味では正しいのだろう

JSX を利用した場合 | React開発環境をcreate-react-app/viteを利用しないで構築する手順 | アールエフェクト
https://reffect.co.jp/react/setup-react/#jsx-を利用した場合-1

@babel/preset-reactがどういう構成になっているのか気になったので調べたメモ | プログラミングノート
https://www.luku.work/babel-react-preset

北山淳也北山淳也

npmパッケージ(node_modules)の解決

Rollupにnpmパッケージのバンドルもお願いするときは rollup-plugin-node-resolve を使う

Rollup なぜrollup-plugin-commonjs、rollup-plugin-node-resolveプラグインが必要なのか | nansystem
https://nansystem.com/rollup-plugin-commonjs-and-rollup-plugin-node-resolve/

※一方で rollup-plugin-peer-deps-external という依存を抜くというプラグインもあってよくわからんちん
 結局今回は不要だったが。。。

北山淳也北山淳也

Reactをバンドルに含めたくない

システムで使うReactのバージョンをビルド時に固定化したい、tree shakingやチャンク最適化ができるなどの理由でReactのnpmパッケージもバンドルファイルに含めるのが一般的と思うが、
今回はReactコンポーネントをWebComponents(CustomElement) として利用したく
CustomElement ごとにバンドルファイルを出力し
必要に応じて読み込んだり読み込まなかったりしたいので
バンドルファイルにReactパッケージが入ってしまうのは無駄にReactを何回も読み込むみたいなことになるので避けたかった

Rollupにnpmパッケージのバンドルもお願いする方法を書いたばかりだが、
じゃあバンドルを除外するときはどうするのかというと
rollup.config.js に external と globals の設定をしてやればいい
っていうのが下記記事にあるのだが、これにたどり着くまでにまる3日ぐらいかかってとてもつらい日々を過ごした

Handling 3rd-party JavaScript with Rollup | Mixmax
https://www.mixmax.com/engineering/rollup-externals

なお、バンドルファイルから除外してじゃあどこから読み込むのって話は
react.min.js と react-dom.min.js を自前で配信してもいいし
まあCDNから読み込ませとけばいいんじゃないですかという気持ち

CDN リンク | React
https://ja.legacy.reactjs.org/docs/cdn-links.html

北山淳也北山淳也

なお、Reactをバンドルファイルに含めるとき、
if (process.env.NODE_ENV === 'production') みたいな部分でエラーになる
これはRollupがデフォルトでは環境変数 NODE_ENV みたいなものを吸い上げて置換しないため。

rollup-plugin-replace を使ってバンドルファイルに対して最後に文字列置換する方法が推奨されてたり、
「なんでそんなことしねーといけねーんだ!」って何年も前から言われてたり
それぞれが解決用のpluginを出してたりと根が深そう

今回は ISSUE の中にある、環境変数オプションを指定することでビルド時に置換する文字列を書き換える方法を使った 下の#208のこの人のやつ
(pluginは当時から変わって公式扱いになった @rollup/plugin-replace を利用)

ENV conditional code | Issue #208 | rollup/rollup
https://github.com/rollup/rollup/issues/208

react error process is not defined | Issue #487 | rollup/rollup
https://github.com/rollup/rollup/issues/487

単純置換するの時のサンプルconfig
silky-charts/rollup.config.js at master | davegomez/silky-charts
https://github.com/davegomez/silky-charts/blob/master/rollup.config.js

北山淳也北山淳也

ReactをWeb Componentとして利用する

ここにかなり詰まるかなと思ったけど公式にも記載があるし

ReactをWeb Componentとして利用する | Web Components | React
https://ja.legacy.reactjs.org/docs/web-components.html#using-react-in-your-web-components

この記事どうりにやれば割と素直にCustomElementとして利用する状態にしてやることができる

EmotionでスタイリングしたReactをWeb Componentとして利用する | Uzabase for Engineers
https://tech.uzabase.com/entry/2023/05/12/164505

ちょっと関係ないけどここも参考になった
TypeScript/Rollup/Vercelでサクッとブックマークレットを作ってみよう | Adwaysエンジニアブログ
https://blog.engineer.adways.net/entry/2023/05/26/130000

北山淳也北山淳也

import css

今回はやらないが、import "./path/to/style.css" みたいなことをしたいなら
rollup-plugin-import-css のpluginが必要

北山淳也北山淳也

結果:設定ファイルまわり

というわけで最終的に構築に必要なコマンドはこれ(yarnなのでnpmやpnpm使ってる必要が適宜読み替えてもらえば)

# 必要なnpmパッケージ取得 (前述したが rollup-plugin-typescript2 は @rollup/plugin-typescript でいい気がする)
$ yarn add react react-dom
$ yarn add --dev typescript rollup @rollup/plugin-commonjs @rollup/plugin-babel @babel/core @babel/preset-react @rollup/plugin-replace rollup-plugin-typescript2
$ yarn add --dev @types/react @types/react-dom

# 設定ファイル準備
$ touch rollup.config.js
$ touch .babelrc
$ touch tsconfig.json

設定ファイル関係

package.json はまあ yarn add した通り。scripts に前述のRollup replace 用のコマンドを定義してある

package.json
{
  "private": true,
  "type": "module",
  "scripts": {
    "build": "rollup -c",
    "build_production": "rollup -c --environment PRODUCTION"
  },
  "devDependencies": {
    "@babel/core": "^7.22.9",
    "@babel/preset-react": "^7.22.5",
    "@rollup/plugin-babel": "^6.0.3",
    "@rollup/plugin-commonjs": "^25.0.3",
    "@rollup/plugin-replace": "^5.0.2",
    "@types/react": "^18.2.15",
    "@types/react-dom": "^18.2.7",
    "rollup": "^3.26.3",
    "rollup-plugin-typescript2": "^0.35.0",
    "typescript": "^5.1.6"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
  }
}
.babelrc
{
  "presets": ["@babel/preset-react"]
}

outDir とか include とかは各々の環境に寄りますね

tsconfig.json
{
  "compilerOptions": {
    "jsx": "react",
    "allowSyntheticDefaultImports": true,
    "moduleResolution": "Bundler",
    "module": "esnext",
    "target": "es6",
    "outDir": "./public/packs/js",
    "strict": true,
    "lib": ["dom", "dom.iterable", "esnext"]
  },
  "include": ["./frontend/src/**/*.ts", "./frontend/src/**/*.tsx"]
}

こいつが今回のメモの総まとめかな。Rollupの設定ファイルはjsファイルであるところが特徴で、
配列をexportすることもできるので複数定義を一気に処理させることも可能で便利(今回はやってないけど)
input オプションと output.file オプションあたりは各々の環境に寄ります
output.format は今回Web用のなのでiife

rollup.config.js
import babel from '@rollup/plugin-babel';
import commonjs from "@rollup/plugin-commonjs";
import replace from '@rollup/plugin-replace';
import typescript from "rollup-plugin-typescript2";

const { PRODUCTION } = process.env

export default {
  input: 'frontend/src/index.tsx',
  output: {
    file: 'public/packs/js/index.js',
    format: 'iife',
    globals: {
      'react': 'React',
      'react-dom': 'ReactDOM'
    },
  },
  external: ['react', 'react-dom'],
  plugins: [
    commonjs(),
    typescript(),
    babel({
      exclude: 'node_modules/**',
      presets: ['@babel/preset-react']
    }),
    replace({
      'process.env.NODE_ENV': JSON.stringify(
         PRODUCTION ? 'production' : 'development'
       )
    }),
  ]
};
北山淳也北山淳也

WebComponents: CustomElement としてReactをレンダリングする

ほぼ普段通りにReactコンポーネント作って、普段 <App /> をrenderするindex.jsxみたいな感じのことをCustomElementの定義に埋め込めばあまりにも簡単に動作する
ポイントはこの普通のReactアプリはレンダリングするHTMLのDOM(だいたいみんなdiv要素よね)をとってきてcreateRootするが
CustomElementは定義したCustomElement自身をレンダリングしたいHTML側で <my-html-element></my-html-element> などと呼び出すので主従が逆になる。
のでレンダリングする先のHTMLのdiv要素をdocument.getElementById とかするんじゃなくて
CustomElement定義ないでReactコンポーネントをレンダリングする要素を作ってやって、そいつにrenderする感じになる。(書き方がむずい。。。)

MyHTMLElement.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import MyElement from "./src/components/MyElement";

class MyHTMLElement extends HTMLElement {
  connectedCallback() {
    const shadow = document.createElement('div');

    // ShadowDOMは親HTMLのCSSが当たらないのでCSSを別ファイルから読み込みたいならこんな感じ
    const linkElem = document.createElement('link');
    linkElem.setAttribute('rel', 'stylesheet');
    linkElem.setAttribute('type', 'text/css');
    linkElem.setAttribute('href', '../public/css/style.css');
    shadow.appendChild(linkElem);

    const mountPoint = document.createElement('div');
    shadow.appendChild(mountPoint);

    // こんな感じでReactのComponentをshadowDOMにrenderingする
    this.attachShadow({ mode: 'open' }).appendChild(shadow);
    const root = ReactDOM.createRoot(mountPoint);
    root.render(<MyElement />);
  }
}

customElements.define('my-html-element', MyHTMLElement);