SPAにしないReact移行

0.9K Views

May 09, 26

スライド概要

サンプルソース
https://github.com/murasuke/isolated-react

profile-image

プログラミングが好きで、LTやってます

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

ダウンロード

関連スライド

各ページのテキスト
1.

SPAにしない React移行 Shadow DOMで15,000行のCSS地獄を隔離し、 レガシー環境と安全に同居する実装パターン

2.

自己紹介: murasuke murasuke(むらすけ) 株式会社ツールラボ 開発部 部長 SE/プログラマーとして20年、多種多様なシステムを構築 プログラムが書きたいので転職しました (現職) 1年前、人生初のイベント参加(PHPカンファレンス名古屋)を きっかけにして、初登壇となりました

3.

前提:10年運用してきたレガシー環境 ● CakePHP2 ● jQuery ● 独自CSS 10年以上にわたる追加開発、止められないレガシー環境 開発速度、開発体験ともに「なんとなく」悪化している 最新のフロントエンド開発環境が気にはなっている なんとかしたい、でも作り直す余裕はない それが現実ではないでしょうか?

4.

会場の皆様に質問です ● jQuery をバリバリ使っている方、どのくらいいますか?? ● 独自CSS 手書きCSSで頑張っている方、どのくらいいらっしゃいますか? (仲間がいるとうれしいです)

5.

このセッションのゴール 本日、伝えたいこと ● SPAではない React という選択肢がある ● React とレガシーの共存は可能。段階移行ができる ● Shadow DOM はピーキーだけど、強力な味方 と、その実現までの経緯をお話しします

6.

つまり、何をしたか? ❌ SPAにしない ❌ フルリプレースもしない 🔴 レガシーシステムに Reactを無理なく後付けする というお話です

7.

開発体験を損なっていた「3つの負債」 ● スタイルの負債 15,000行のスパゲッティCSS、!importantで殴り合い ● 環境の負債 10年熟成したPHP/CakePHP、JSファイル間の暗黙の依存関係、型のない言語 ● 心理的負債 リリースすると「どこかが壊れる」という恐怖による開発の鈍化 SPA化や、フルリプレースするほどの余裕もない・・・

8.

「フルリプレース」、「完全 SPA化」以外の道 「 フルリプレース 」でも「 完全SPA化」でもない、 レガシー環境を残しつつ、フロントエンド環境を追加する 「新旧共存」 という道 は取れないだろうか?

9.

「React」をWebページの一部に組み込むという発想の転換 完全なSPA化は目指さず、コンテンツ領域だけを React に置き換える Before After

10.

「React」だけど SPAじゃない ・Viewでhtmlを生成するかわりに、React の初期化コードに置き換え ・画面遷移は、今まで通り「サーバー側」のルーティングを利用

11.

ここまでのまとめ ● 既存Webページの一部にReactを埋め込む ○ ● 画面表示時に、React Componentを描画する SPAにしない(URLをクリックしたらサーバー側で画面を切り替え) ○ メニュー等、既存の画面遷移(URL)も変更不要 この手法なら、既存Webアプリはそのままで「 Reactと共存」できます!

12.

React共存環境の課題 ● 最大の課題 ○ 15,000行 のスパゲッティ CSS が、Reactに侵入 ● Reactを部分導入する構成ならではの課題 ○ Reactに初期値(サーバー側変数)を効率よく引き渡す方法 ○ SPAではない形でReactを利用するためのビルド手順

13.

15,000行のスパゲッティ CSS がReactに侵入する 既存CSSがReact領域に侵入し、Reactのスタイルが破壊される これを解決しないことには、実践投入できません・・・

14.

スパゲッティ CSSの侵攻を防ぐには? CSSの世界を分離するため、様々な手段を検討しました Reactのページに既存 CSS を読み込ませない React領域に限定した リセット CSS iframe メニューや画面全体の構成が崩れ るので、読み込まないという選択 肢は取れない 一般的なリセットCSSは !important に負けてしまう 無理やり上書きをすると React側にも影響する 画面のちらつき、サイズ調整が困 難。画面とは別にiframe用の URLが追加必要で、既存画面の 移行が複雑化する いずれも決定打にならないので、iframeのようにCSSの世界を分離しつつ 同一のブラウザ内に表示する 、それを可能にする技術を探しました

15.

救世主: Shadow DOM Shadow DOM とは ● 画面内に独立したDOMツリー を作成する ● 互いにスタイルやスクリプトが 干渉しないようにする仕組み Reactと外部の世界を隔離する まさに求めていた技術でした

16.
[beta]
Shadow DOM の基本的な使い方
attachShadow()で作成したDOMに要素を追加します
①

class=”box” を「赤い枠線」で囲むStyle定義

②

attachShadow() でShadow Root を作成

③

<div class=”box”>をShadow Rootに追加

●

Shadow Root内部は、外部と独立するので、
CSS (.box) が効きません!

<html lang="ja">
<head>
<style>
.box { /* ① */
background: #ffe6e6; /* 薄い赤背景 */
outline: 4px solid red; /* 赤く太い外枠 */
padding: 10px;
}
</style>
</head>
<body>
<div class="box">Shadow DOMの外部です!</div>
<div id="root"></div>
<script>
const root = document.getElementById("root");
// ② Shadow DOM作成
const shadow = root.attachShadow({ mode: "open" });
// ③ Shadow DOM内にdivを追加
const box = document.createElement("div");
box.className = "box";
box.textContent = "こんにちは、Shadow DOM です!";
shadow.append(box);
</script>
</body>
</html>

17.

Shadow DOM を使って、 Reactの独立性を向上させる Shadow DOMを生成し、内部に「children」を描画する <IsolatedScope>コンポーネントで、React領域を外部 CSSから守ります

18.
[beta]
<IsolatedScope>コンポーネント
Shadow DOM内にReact(children)を描画する
<IsolatedScope>コンポーネント
①

useRefで本物のDOMを取得

②

①のDOMに、Shadow DOMを作成

③

Shadow DOM内部にReact Rootを作成

④

Shadow DOM内にCSSを適用するため の
<link>を生成(ビルド済みCSSのURL)

⑤

React Rootにchildren(子コンポーネント)を
描画(render())する

const IsolatedScope = ({children, styleSheetURLs}) => {
const shadowHostRef = useRef< HTMLDivElement | null>(null);
const rootRef = useRef< ReactDOM .Root | null>(null); // ①
useEffect (() => {
if (!shadowHostRef.current) return;

// ② Shadow DOMの作成(初回のみ)
const shadowRoot = shadowHostRef.current.shadowRoot ||
shadowHostRef.current. attachShadow ({mode: 'open'});
// ③ React Rootは一度だけ生成(エラー回避)
if (!rootRef.current) {
rootRef.current = ReactDOM. createRoot (shadowRoot);
}
rootRef.current. render( // ⑤
<div id="shadow-root ">
{ /* ④ Shadow DOM内に適用するCSSを読み込む */ }
{styleSheetURLs ?.map((url) => (
< link rel="stylesheet " key={url} href={url} />
))}
{ /* ⑤ Shadow DOM内にReactコンポーネントを表示する */ }
<div id="shadow-component-container ">{children}</ div>
</div>
);
}, [children]);
return <div id="isolated-scope-host " ref={shadowHostRef } />; /* ① */
};

19.
[beta]
<IsolatedScope>の使い方
<IsolatedScope>で描画したいReactをラップして、埋
め込みたい位置へrender()します
①

②
③

サーバー側変数をjsonエンコードして、propsに
セット(※1)

<script type="module">
import {
ReactDOMClient,
Counter
} from '/js/react/bundle-react.js';

// ① サーバー変数をjsonに変換してコードを埋め込む(props)
const props = <?= json_encode(['value' => $value]) ?>;

React Rootを作成
<IsolatedScope>をrender()
・Reactに適用するcssのパスを渡す
・描画するコンポーネントをラップする
・初期表示用propsも埋め込む

※1: HTML内にpropsデータを埋め込むのは、
初期表示を高速化するためです
(ロード時の非同期fetchを回避)

// ②
const container = document.getElementById('root');
const root = ReactDOMClient.createRoot(container);
// ③
root.render(
<IsolatedScope styleSheetURLs="/css/react/bundle.css">
<Counter {...props} />
</IsolatedScope>
);
</script>
<div id="root"></div>

20.

実際に使ってみてわかった不具合 Shadow DOMはかなり良い仕事をしてくれましたが、 いくつか想定外の不具合が発生しました 1. 「文字色、フォントサイズ などの継承プロパティ 」はShadow DOMでブロックできない (<head>で定義したスタイルがReactに適用されてしまうが、これは仕様) 2. Shadow DOM内でTailwindの一部スタイルが正しく適用されない ⇒ CSS変数 (カスタムプロパティー) が未定義になってしまう 3. ダイアログが正しく表示されない (フロート表示にならない) ⇒ shadcn/ui ライブラリを利用した場合に発生

21.

CSS継承プロパティ がブロックされない問題と対策

22.

Tailwindのスタイルが正しく適用されない問題と対策

23.

ダイアログが正しく表示されない問題と対策

24.
[beta]
不具合対策済み <IsolatedScope>
長いので、GitHubで公開します
// IsolatedScope.tsx
const IsolatedScope = ({children, styleSheetURLs, onPortalContainer})
const shadowHostRef = useRef< HTMLDivElement | null>(null);
const rootRef = useRef< ReactDOM .Root | null>(null);

引き渡されたComponentをShadowDOM内に描画する共通関数
=> {

useEffect (() => {
if (!shadowHostRef.current) return;

// Shadow DOMの作成(初回のみ)
const shadowRoot = shadowHostRef.current.shadowRoot ||
shadowHostRef.current. attachShadow ({mode : 'open'});

export function reactShadowDOMRenderer<T>(
rootId: string,
Component: React.FC<T>,
props: T = null,
styleSheetURLs: string[] = []
){
const onPortalContainer = (container: HTMLElement) => {
// portalのコンテナが作成されたときに呼び出される
// ダイアログなどportalを利用するコンポーネントに渡す必要がある
window['portalContainer'] = container;
};

// 継承スタイルを遮断してスコープを分離
const sheet = new CSSStyleSheet ();
sheet. replaceSync (`:host {all: initial;} `);
shadowRoot.adoptedStyleSheets = [sheet];

const container = document.getElementById(rootId);
const root = ReactDOMClient.createRoot(container);
root.render(
<IsolatedScope styleSheetURLs={styleSheetURLs} onPortalContainer
={onPortalContainer}>
<Component {...props} />
</IsolatedScope>
);

// React Rootは一度だけ生成(エラー回避)
if (!rootRef.current) { rootRef.current = ReactDOM. createRoot (shadowRoot); }
rootRef.current. render (
<div id="shadow-root ">
{ /* Shadow DOM内に適用するCSSを読み込む */ }
{styleSheetURLs ?.map((url) => (
< link rel="stylesheet " key={url} href={url} />
))}
{ /* Shadow DOM内にReactコンポーネントを表示する */ }
< div id="shadow-component-container ">{children}</ div>
{ /* Portal(Dialog等)描画先 */ }
< div id="shadow-portal-container "
ref={(portal) => {if (portal && onPortalContainer ) onPortalContainer (portal);}}
/>
</ div>
);
}, [children, styleSheetURLs, onPortalContainer]);
return <div id="isolated-scope-host " ref={shadowHostRef } />;
};

}

上記コンポーネントの利用方法
<script type="module">
import {
reactShadowDOMRenderer,
Counter
} from '/react/assets/index.js';
// PHP側の変数をjsonに変換してReact側に直接渡すことができる
const props = <?= json_encode(['value' => $value]) ?>;
reactShadowDOMRenderer('root', Counter, props);
</script>
<div id="root"></div>

25.
[beta]
不具合対策済み tailwind.css
長いので、GitHubで公開します
// tailwind.css
@import "tailwindcss" ;
/* Shadow-DOM内で未定義となってしまうため、変数の定義を追加 */
@layer properties {
:host, :host * {
--tw-divide-y-reverse : 0;
--tw-border-style : solid;
--tw-font-weight : initial;
--tw-tracking : initial;
--tw-translate-x : 0;
--tw-translate-y : 0;
--tw-translate-z : 0;
--tw-rotate-x : rotateX (0);
--tw-rotate-y : rotateY (0);
--tw-rotate-z : rotateZ (0);
--tw-skew-x : skewX(0);
--tw-skew-y : skewY(0);
--tw-space-x-reverse : 0;
--tw-gradient-position : initial;
--tw-gradient-from : #0000;
--tw-gradient-via : #0000;
--tw-gradient-to : #0000;
--tw-gradient-stops : initial;
--tw-gradient-via-stops : initial;

--tw-gradient-from-position : 0%;
--tw-gradient-via-position : 50%;
--tw-gradient-to-position : 100%;
--tw-shadow : 0 0 #0000;
--tw-shadow-color : initial;
--tw-inset-shadow : 0 0 #0000;
--tw-inset-shadow-color : initial;
--tw-ring-color : initial;
--tw-ring-shadow : 0 0 #0000;
--tw-inset-ring-color : initial;
--tw-inset-ring-shadow : 0 0 #0000;
--tw-ring-inset : initial;
--tw-ring-offset-width : 0px;
--tw-ring-offset-color : #fff;
--tw-ring-offset-shadow : 0 0 #0000;
--tw-blur : initial;
--tw-brightness : initial;
--tw-contrast : initial;
--tw-grayscale : initial;
--tw-hue-rotate : initial;
--tw-invert : initial;
--tw-opacity : initial;
--tw-saturate : initial;
--tw-sepia : initial;
--tw-drop-shadow : initial;
--tw-duration : initial;
--tw-ease : initial;
}
}

26.

React共存環境の課題 (再掲) ● 最大の課題 ○ 15,000行 のスパゲッティ CSS が、Reactに侵入してくる ⇒ Shadow DOM でReact領域を隔離して解決 ● Reactを部分導入する構成ならではの課題 ○ Reactに初期値をどうやって渡すか問題 ⇒ サーバー側変数を JSONとしてpropsに埋め込んで初期化 ○ SPAではないReactモジュールをどうやってビルドするか問題 ⇒ 巻末の補足資料をご確認ください ※Viteのライブラリモードを利用し、Reactコンポーネントを1つのJS/CSSに バンドルします

27.

「SPAにしない React移行」の導入効果 Reactの経験が十分ではない状況にもかかわらず ● AIコード生成が容易になり、飛躍的な生産性アップ ● 一貫性のある UIデザイン (共通部品、デザインシステム) ● 操作性の向上 (shadcn/ui 利用によるコード量削減) ● 不具合の減少 (スパゲティーコードからの脱却) この成功体験で「Reactに切り替える方が楽になる」 という納得感を得られました

28.

React導入前、導入後を比較すると(詳細 ) レガシー環境 React CSS 15,000行のスパゲッティCSS 特定画面への変更が、別の画面に影響して意図しないバグ を生む デザインシステムを考慮した共通UIを使えば、 CSSを書く必要がない (既存CSSの悪影響も隔離) JavaScript js間で暗黙の依存があり、読み込み順を守る必要がある 同一ファイルが複数回ロードされるといった不具合も度々発 生 jsがバンドルされて、1ファイルになったため、依 存性、複数回読み込んでしまうといった問題が原 理的に発生しない UI作成 HTML(サーバー側テンプレート)、CSS、JavaScriptを必ず セットで修正 Ajaxで動的にUIを更新する場合、JavaScriptとHTMLの両 方に修正が必要(コードが重複する) UIとロジックがまとまっているため、修正箇所が 明確 Ajaxによるコードの重複も発生しない 単一ソース内であれば修正可能だが、実際には複数ファイ ルを修正する必要があり整合性を取るのが大変 修正箇所がはっきりしているため、AIとの相性が 良い AI利用 「レガシー環境による負債」の解消が進みだした

29.

生産性向上への寄与(結果) ● 改善前の工数(全体を100とする) 画面:67、 バックエンド:33 ○ ● 画面のスタイル調整、jQueryでのロジック作成といった工数が多い 改善後の結果 工数 生産性 フロントエンド 67 ⇒ 22 約3倍 バックエンド 33 ⇒ 26 約1.4倍 全体 100 ⇒ 48 ほぼ2倍 ※最近ではCoding Agent や ChatGPT5.5 のおかげで、差が縮まりました・・・

30.

なぜ、ここまで生産性が上がったのか ● ● フロントエンド側 ○ Shadow DOM によるCSS隔離(スパゲッティ CSS地獄の解消) ○ 最新のフロントエンド環境(Tailwind / shadcn / Vite) が使えるように ○ AIが「そのまま使えるコード」を出せる構造(レガシー環境からの独立 ) バックエンド側 ○ Viewロジック減少 ○ API化による責務分離 「フロントエンド、バックエンド」を 適切な形に分離した ことで 「AIを活かせる構造に変わった」 ことが最大の要因

31.

まとめ: SPAにしなくても、 Reactは「武器」になる 今日伝えたかったこと ● SPAではない Reactという選択肢がある ● Reactとレガシーの共存は可能。段階移行ができる ● Shadow DOMはピーキーだけど、強力な味方 「ページ内への Reactの埋め込み 」+「Shadow DOM 」は 「止められないサービスを動かしながら変えていく 」 ことを可能にする、ひとつの「現実解」である

32.

最後に:この手法が向いているシステムとは? ● 止められない「 レガシーシステム 」 ● SPA化が「現実的ではない 」 ● 安全に「 段階移行 」がしたい それは「あなたのシステム」かもしれません!!

33.

補足:ビルド方法について Viteのライブラリモード を利用してビルドを行います ● Vite環境の追加から、ビルドまでの簡易的なチュートリアルです ● Viteの設定ファイルの変更のみで、JSファイルのビルドと配置が行えます ● Tailwindも一緒にビルドを行います ● Rollup.jsを内部で利用していますが、Rollup.jsの追加は不要です ※GitHubでサンプルを公開します https://github.com/murasuke/isolated-react

34.

Vite + React 環境追加 npm create vite@latest frontend -- --template react-ts cd frontend npm install npm install tailwindcss @tailwindcss/vite ● 既存Webアプリ環境のルートに「Vite + React」 環境一式を追加します ここでは「/frontend」という「サブフォルダ名」にしました(任意) ● フォルダを分けることで、既存環境との「境」が明確になります

35.

ビルド用のエントリーポイントファイルを作成 画面単位で個別に利用可能とするため、コンポーネントをエクスポートします ● build.ts 共通部品、各画面をエクスポート ● vite.config.ts でエントリーポイントに指定します // build.ts export { reactShadowDOMRenderer } from './reactRenderer'; export { default as IsolatedScope } from './IsolatedScope'; import "./tailwind.css"; // 本番用エントリポイント // Node.js アプリ側から import できるように、埋め込み用の部品をまとめて export する。 export { RandomApp } from './RandomApp';

36.
[beta]
Vite ライブラリモード設定( vite.config.ts)
●

Tailwindプラグインを追加

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from "@tailwindcss/vite"

●

NODE_ENV 未定義エラー回避のため変数
を追加

●

lib: { … } を追加しライブラリモードを有効に
する(単一ファイルに格納)

●

build.ts をビルドのエントリーポイントに指
定

●

出力ファイル名をindex.jsに固定化

●

outDir: で既存Webアプリ側へ出力する

// https://vite.dev/config/
export default defineConfig ({
plugins: [
react(),
tailwindcss (),
],
base: "/react/" ,
define: { // React 実行時エラー回避のため
"process.env.NODE_ENV" : JSON.stringify ("production" ),
},
server: {
port: 5173,
proxy: { "/api": "http://localhost:3000" , },
},
build: {
outDir: "../public/react" ,
emptyOutDir: true,
lib: {
entry: "src/build.ts" ,
formats: ["es"],
fileName : () => "assets/index.js" ,
},
rollupOptions: {
output: {
assetFileNames: "assets/[name][extname]" ,
},
},
},
})

37.
[beta]
ビルド&動作確認用に簡単なコンポーネントを作成
よくある「カウンター」コンポーネント(初期値指定あり)
// src/Counter.tsx
import { useState } from 'react';
interface CounterProps {
initialCount?: number;
}
export function Counter({ initialCount = 0 }: CounterProps) {
const [count, setCount] = useState(initialCount);
const increment = () => {
setCount(count + 1);
};
return (
<div className="m-4 p-4 border rounded w-fit">
<p className="mb-2">Count: {count}</p>
<button className='px-4 py-2 bg-blue-500 text-white rounded'
onClick={increment}>Increment</button>
</div>
);
};

38.
[beta]
ビルド後の動作確認
Webページの一部にReactコンポーネントを表示する

ビルド後のJSファイルを読み込み、
Shadow DOM 内でReactが表示されているこ
とを確認します
●

Counterが表示される

●

外部style(color: red)が
React領域には適用されない

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<style>
color: red;
</style>
<script type="module">
import {
reactShadowDOMRenderer,
Counter
} from '/react/assets/index.js';
const props = {"initialCount": 3}; // テスト用に固定データをセット
reactShadowDOMRenderer ('root', Counter , props);
</script>
</head>
<body>
<h2>Webページの一部にReactコンポーネントを表示する</
h2>
<div id="root"></div>
</body>
</html>

39.
[beta]
参考:Rollupビルド設定( rollup.config.js)
Rollup.js でもビルドを行うことができます。(微
調整が可能)
●

build.ts をビルドのエントリーポイントとし
て指定

●

NODE_ENV 未定義エラーを回避するた
め、ソースの置換を行う

●

本番(BUILD_MODE=’release’)では
minify、開発時はsourcemapを生成す
るように分岐

// rollup.config.js (importは省略)
const isRelease = process.env. BUILD_MODE === 'release' ;
export default {
input: 'src/build.tsx' , // ビルドのエントリーポイント
output: {
file: 'dist/bundle-react.js' , // 出力ファイル名
format: 'esm',
sourcemap : !isRelease, // デバッグビルド時はsourcemapを生成する
},
plugins: [
resolve(),
commonjs (),
replace({ // ビルド時に'NODE_ENV'が取れないので、ソースの置換を行う
'process.env.NODE_ENV' : JSON.stringify (NODE_ENV ),
preventAssignment : true,
}),
typescript ({
tsconfig : 'tsconfig.react.json' , // TypeScriptビルド設定
}),
postcss ({ // ここで PostCSS に Tailwind v4 用プラグインを渡す
plugins: [tailwind ()],
extract: 'bundle-react.css' , // 出力ファイル名(jsと同じディレクトリ)
minimize : isRelease,
sourceMap : !isRelease,
}),
isRelease && terser(), // リリースビルド時のみ minify
].filter(Boolean), // `falsy` な要素を削除(Booleanは述語関数として利用)
onwarn(warning, warn) {
// ワーニング"Module level directives cause errors when bundled"抑制のための処理
if (warning.code === "MODULE_LEVEL_DIRECTIVE" ) { return; }
warn(warning);
},
};