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から構築するとかやってないんだろうなって思った。
まずは公式Rollup
Rollup.js cheatsheet
かなり古い情報だが基本がスッとわかるエントリ
今日から使えるライブラリ製作者のための Rollup 実践教室 | Qiita
まとまって参考になる情報もあったのでこの辺りを並行してみながら設定ファイルを作っていく
React+TypeScript+Storybook+Rollupのプロジェクト作成 | Kazunori Kimura | zenn
rollup.js × TypeScriptのライブラリをnpm公開する環境の構築(2020年12月版)
らんす🍐 | zenn
TypeScript+rollup.js+EmotionでReact開発できる環境を作ってみた | SANDFISH FACTORY
Component library setup with React, TypeScript and Rollup | dev.to
Rollup Config for React Component Library With TypeScript + SCSS | Ng Charn Chuen
最近作ったRollup.jsの設定詳細 (2019年7月版) | Qiita
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
ただ、歴史の中で 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でハマる | くらげになりたい。
よく分からなかったのでCommonJSとTypeScriptのES Modulesinteropについて調べてみた | パケット畑でつかまえて
TypeScriptのesModuleinteropフラグを設定してCommonJSモジュールを実行可能とする | DevelopersIO
.mjs とは何か、またはモジュールベース JS とエコシステムの今後 | blog.jxck.io
CommonJSとES Modulesについてまとめる | よだか | zenn
このエントリの「ES Modulesに全振りする上での問題点」が一番まとまってるJSX
TypeScript はJSXサポートをしている。専用のオプションをtsconfig.json に入れてやれば型サポートなどが効く。(関係ないが、なんかReactに手厚いなーやっぱユーザ多いからかなとか思った)
JSX | TypeScript: Documentation
一方でRollupはJSXをJavaScriptに変換できない。それを担当してもらうためには
Babelにやってもらう必要があるようで、Rollup > Babel > @babel/preset-react にお願いすることになる
まわりくどいなと思ったが既にあるエコシステムに乗っかるという意味では正しいのだろう
JSX を利用した場合 | React開発環境をcreate-react-app/viteを利用しないで構築する手順 | アールエフェクト
@babel/preset-reactがどういう構成になっているのか気になったので調べたメモ | プログラミングノート
npmパッケージ(node_modules)の解決
Rollupにnpmパッケージのバンドルもお願いするときは rollup-plugin-node-resolve を使う
Rollup なぜrollup-plugin-commonjs、rollup-plugin-node-resolveプラグインが必要なのか | nansystem
※一方で 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
なお、バンドルファイルから除外してじゃあどこから読み込むのって話は
react.min.js と react-dom.min.js を自前で配信してもいいし
まあCDNから読み込ませとけばいいんじゃないですかという気持ち
CDN リンク | React
なお、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
react error process is not defined | Issue #487 | rollup/rollup
単純置換するの時のサンプルconfig
silky-charts/rollup.config.js at master | davegomez/silky-charts
ReactをWeb Componentとして利用する
ここにかなり詰まるかなと思ったけど公式にも記載があるし
ReactをWeb Componentとして利用する | Web Components | React
この記事どうりにやれば割と素直にCustomElementとして利用する状態にしてやることができる
EmotionでスタイリングしたReactをWeb Componentとして利用する | Uzabase for Engineers
ちょっと関係ないけどここも参考になった
TypeScript/Rollup/Vercelでサクッとブックマークレットを作ってみよう | Adwaysエンジニアブログ
あと全然関係ないけどこれ便利だな
【TypeScript】import ... from @ の "@"とは? | fujii | zenn
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 用のコマンドを定義してある
{
"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",
}
}
{
"presets": ["@babel/preset-react"]
}
outDir とか include とかは各々の環境に寄りますね
{
"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
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する感じになる。(書き方がむずい。。。)
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);