2024年9月9日月曜日

ReactのクイックスタートをOracle APEXで動かしてみる

Reactの勉強を兼ねて、スタートガイドのクイックスタートをOracle APEXのアプリケーションに実装してみます。Oracle APEXではReactのJSXをそのまま扱うことはできないため、ブラウザ上でのJSXの実行とJSXファイルのコンパイルにBabelを使用します。

最初に空のAPEXアプリケーションを作成します。名前React Quick Startとしました。

アプリケーションの作成を実行します。


アプリケーションが作成されます。

このアプリケーションのホーム・ページにReactのコンポーネントを埋め込みます。


作成予定のReactのコンポーネントの多くは、ローカルのPC上にファイルとして作成します。作成したJSXファイルをBabelを使ってJavaScriptのファイルにコンパイルし、APEXのアプリケーションに組み込みます。

JSXファイルを配置するフォルダを作成します。名前はquickstartとしました。

フォルダquickstartreactおよびreact-domをインストールします。インストールするパッケージのバージョンを明示していますが、これはブラウザに設定するimportmapを、npmでインストールしたreact、react-domのバージョンと一致させるためです。

npm install react@18.3.1 react-dom@18.3.1

 quickstart % npm install react@18.3.1 react-dom@18.3.1


added 5 packages in 250ms

 quickstart % 


Babelをインストールします。

npm install --save-dev @babel/core @babel/cli @babel/preset-react

 quickstart % npm install --save-dev @babel/core @babel/cli @babel/preset-react


npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.

npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported


added 89 packages, and audited 95 packages in 804ms


8 packages are looking for funding

  run `npm fund` for details


found 0 vulnerabilities

 quickstart % 


JSXのコンパイルを行うため、.babelrcにプリセットとして@babel/preset-reactを設定します。ファイル.babelrcを作成し、以下の内容を記述します。
{
  "presets": ["@babel/preset-react"]
}

 quickstart % cat .babelrc 

{

  "presets": ["@babel/preset-react"]

}

 quickstart % 


VS Codeを起動し、フォルダquickstartを開きます。

Oracle APEXのセッション・オーバーライドを活用して、ローカルのPC上のファイルを静的アプリケーション・ファイルとして参照できるようにします。


フォルダquickstart直下にファイルindex.htmlを作成します。ファイルには以下を記述します。
<html>
    <body>
        Live Server is ready.
    </body>
</html>
作成したファイルindex.htmlを保存します。コンテキスト・メニューを開き、Open with Live Serverを実行します。


ブラウザの画面が開き、Live Server is ready.と表示されます。アクセスしているURLは以下になります。

http://127.0.0.1:5500/index.html


これでセッション・オーバライドを使って、フォルダquickstart以下のファイルを静的アプリケーション・ファイルとして参照できるようになりました。

すでに静的アプリケーション・ファイルとして作成済みのアイコン・ファイルを、フォルダquickstart以下にコピーします。

共有コンポーネント静的アプリケーション・ファイルを開きます。

Zipとしてダウンロードをクリックし、静的アプリケーション・ファイルをZip形式で手元のPCにダウンロードします。ダウンロードされるZipファイルの名前は以下の形式になります。

f<アプリケーションID>_static_application_files.zip


ダウンロードされたZipファイルをフォルダquickstart以下に展開します。

unzip ~/Downloads/f165_static_application_files.zip

 quickstart % unzip ~/Downloads/f165_static_application_files.zip


Archive:  /___/_______/Downloads/f165_static_application_files.zip

Implementation by Anton Scheffer

 extracting: icons/app-icon-144-rounded.png  

  inflating: icons/app-icon-192.png  

  inflating: icons/app-icon-256-rounded.png  

 extracting: icons/app-icon-32.png   

  inflating: icons/app-icon-512.png  

 quickstart % 


作成したAPEXアプリケーションを実行し、セッション・オーバーライドを設定します。

開発者ツール・バーセッション・オーバーライドを開きます。


ファイル・パスアプリケーション・ファイルオンにします。セッション・オーバーライドの有効化も同時にオンに変わります。アプリケーション・ファイルにLive Serverの待ち受けURLである、以下の値を設定します。

http://127.0.0.1:5500/

変更を保存します。


セッション・オーバーライドが有効になっているか確認するため、表示されているアイコンの画像アドレスのコピーを実行します。


コピーした画像アドレスが以下であれば、セッション・オーバーライドは有効です。

http://127.0.0.1:5500/icons/app-icon-512.png

これから、ホーム・ページにReactコンポーネントを実装していきます。

ホーム・ページのページ・プロパティHTMLヘッダーに、Reactを読み込むための
importmapを記述します。
<script type="importmap">
    {
        "imports": {
            "react": "https://cdn.jsdelivr.net/npm/react@18.3.1/+esm",
            "react-dom": "https://cdn.jsdelivr.net/npm/react-dom@18.3.1/+esm"
        }
    }
</script>
reactとreact-domのバージョンは必ず一致させます。バージョンが未指定であったり、最新バージョンを選択するようにしていると、reactとreact-domのバージョンが一致しないことがあります。その場合、useStateなどのフックの利用時にエラーが発生することがあります。(例えばcannot read properties of null (reading 'useState')といったエラー)。

できるだけエラーを発生させないように、npmでinstallしたreactおよびreact-domのバージョンと、importmapのバージョンは一致させます。

また、Babelを呼び出すためにJavaScriptファイルURLに以下を記述します。

https://cdn.jsdelivr.net/npm/@babel/standalone/babel.min.js


最初にJSXファイルを作成せず、APEXのリージョンに直接Reactコンポーネントを記述してみます。

静的コンテンツのリージョンとしてMyButtonを作成します。ソースHTMLコードとして以下を記述します。SCRIPT要素のtypeとしてtext/babelを設定し、Babelを呼び出すことでJSXを解釈します。

<div id="container"></div>
<script type="text/babel" data-presets="react" data-type="module">
import React from 'react';
import ReactDOM from 'react-dom';
function MyButton() {
return (
<button>I'm a button</button>
);
}
class App extends React.Component {
render() {
return (
<>
<div>
<h1>Welcome to my app</h1>
<MyButton />
</div>
</>
);
}
}
/* render React component within an APEX region */
const container = document.getElementById("container");
const root = ReactDOM.createRoot( container );
root.render( <App /> );
</script>


アプリケーションを実行すると、以下のように表示されます。


同じコンポーネントを、今度はApp.jsxという名前のファイルとして作成します。作成したApp.jsxをBabelでJavaScriptファイルにコンパイルし、コンパイルされたApp.jsをAPEXアプリケーションに読み込みます。

import React from 'react';
function MyButton() {
return (
<button>
I'm a button
</button>
);
}
export default function App() {
return (
<div>
<h1>Welcome to my app</h1>
<MyButton />
</div>
);
}


App.jsxからJavaScriptファイルApp.jsを作成します。

npx babel App.jsx --out-file App.js

 quickstart % npx babel App.jsx --out-file App.js

 quickstart % 


作成済みのリージョンMyButtonコメント・アウトし、新たにリージョンAppを作成して、HTMLコードに以下を記述します。
<div id="container"></div>
<script type="text/babel" data-presets="react" data-type="module">
import React from 'react';
import ReactDOM from 'react-dom';
import App from "#APP_FILES#App.js";

/* render React component within an APEX region */
const container = document.getElementById("container");
const root = ReactDOM.createRoot( container );
root.render( <App /> );
</script>

ホーム・ページを実行すると、以下のように表示されます。表示自体は、先ほどのMyButtonリージョンと同じです。


次にProfile.jsxを作成します。コードは以下です。

import React from 'react';
const user = {
name: 'Hedy Lamarr',
imageUrl: 'https://i.imgur.com/yXOvdOSs.jpg',
imageSize: 90,
};
export default function Profile() {
return (
<>
<h1>{user.name}</h1>
<img
className="avatar"
src={user.imageUrl}
alt={'Photo of ' + user.name}
style={{
width: user.imageSize,
height: user.imageSize
}}
/>
</>
);
}

Babelでコンパイルします。

npx babel Profile.jsx --out-file Profile.js

作成済みのリージョンAppコメント・アウトし、新たにリージョンProfileを作成して、HTMLコードに以下を記述します。import文とrender文に指定されていたAppをProfileに置き換えています。
<div id="container"></div>
<script type="text/babel" data-presets="react" data-type="module">
import React from 'react';
import ReactDOM from 'react-dom';
import Profile from "#APP_FILES#Profile.js";

/* render React component within an APEX region */
const container = document.getElementById("container");
const root = ReactDOM.createRoot( container );
root.render( <Profile /> );
</script>

ホーム・ページを実行すると、以下のように表示されます。


CSSの指定が不足しているので、ページ・プロパティのCSSのインラインに以下を追記します。APEXでのクイックスタートの実装は、IDがcontainerのdiv要素をReactコンポーネントのrootとしているため、#contaierをスコープとしています。
@scope(#container) {
    .avatar {
        border-radius: 50%;
    }
}


CSSを適用すると、Reactのクイックスタートと同じように表示されます。


ShoppingList.jsxを作成します。コードは以下です。

import React from 'react';
const products = [
{ title: 'Cabbage', isFruit: false, id: 1 },
{ title: 'Garlic', isFruit: false, id: 2 },
{ title: 'Apple', isFruit: true, id: 3 },
];
export default function ShoppingList() {
const listItems = products.map(product =>
<li
key={product.id}
style={{
color: product.isFruit ? 'magenta' : 'darkgreen'
}}
>
{product.title}
</li>
);
return (
<ul>{listItems}</ul>
);
}

Babelでコンパイルします。

npx babel ShoppingList.jsx --out-file ShoppingList.js

作成済みのリージョンProfileコメント・アウトし、新たにリージョンShoppingListを作成して、HTMLコードに以下を記述します。
<div id="container"></div>
<script type="text/babel" data-presets="react" data-type="module">
import React from 'react';
import ReactDOM from 'react-dom';
import ShoppingList from "#APP_FILES#ShoppingList.js";

/* render React component within an APEX region */
const container = document.getElementById("container");
const root = ReactDOM.createRoot( container );
root.render( <ShoppingList /> );
</script>
ホーム・ページを実行すると、以下のように表示されます。


フックのuseStateを使ったボタンをクリックするサンプルを、MyApp.jsxとして作成します。

import React, { useState } from 'react';
export default function MyApp() {
return (
<div>
<h1>Counters that update separately</h1>
<MyButton />
<MyButton />
</div>
);
}
function MyButton() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
}
return (
<button onClick={handleClick}>
Clicked {count} times
</button>
);
}

Babelでコンパイルします。

npx babel MyApp.jsx --out-file MyApp.js

作成済みのリージョンShoppingListコメント・アウトし、新たにリージョンMyAppを作成して、HTMLコードに以下を記述します。
<div id="container"></div>
<script type="text/babel" data-presets="react" data-type="module">
import React from 'react';
import ReactDOM from 'react-dom';
import MyApp from "#APP_FILES#MyApp.js";

/* render React component within an APEX region */
const container = document.getElementById("container");
const root = ReactDOM.createRoot( container );
root.render( <MyApp /> );
</script>
ホーム・ページを実行すると、以下のように表示されます。


ボタンが横並びになっているのはさておき、ボタンをクリックするとAPEXのデフォルトの動作であるページの送信が行われてしまいます。

ページ・プロパティJavaScriptページ・ロード時に実行に以下のJavaScriptを記述し、Reactコンポーネント内でのボタンクリックでページの送信が行われないようにします。

/*
* Reactのカスタム要素に含まれるボタンをクリックしたときに、
* APEXがページを送信するのを抑制する。
*/
document.addEventListener('click', (event) => {
const buttons = document.querySelectorAll("#container button");
buttons.forEach( (button) => {
if (button === event.target) {
console.log('click on button within React component is ignored to prevent page submittion.', event.target);
event.preventDefault();
}
});
});

ページ送信を抑制すると、useStateを使ったサンプルが想定通りに動作します。


コンポーネント間でデータを共有する実装を、MyApp2.jsxとして作成します。

import React, { useState } from 'react';
export default function MyApp() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
}
return (
<div>
<h1>Counters that update together</h1>
<MyButton count={count} onClick={handleClick} />
<MyButton count={count} onClick={handleClick} />
</div>
);
}
function MyButton({ count, onClick }) {
return (
<button onClick={onClick}>
Clicked {count} times
</button>
);
}
Babelでコンパイルします。

npx babel MyApp2.jsx --out-file MyApp2.js

作成済みのリージョンMyAppコメント・アウトし、新たにリージョンMyApp2を作成して、HTMLコードに以下を記述します。
<div id="container"></div>
<script type="text/babel" data-presets="react" data-type="module">
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import MyApp from "#APP_FILES#MyApp2.js";

/* render React component within an APEX region */
const container = document.getElementById("container");
const root = ReactDOM.createRoot( container );
root.render( <MyApp /> );
</script>
ホーム・ページを実行すると、以下のように表示されます。


チュートリアルの三目並べを実装します。Game.jsxを作成します。

import React, { useState } from 'react';
function Square({ value, onSquareClick }) {
return (
<button className="square" onClick={onSquareClick}>
{value}
</button>
);
}
function Board({ xIsNext, squares, onPlay }) {
function handleClick(i) {
if (calculateWinner(squares) || squares[i]) {
return;
}
const nextSquares = squares.slice();
if (xIsNext) {
nextSquares[i] = 'X';
} else {
nextSquares[i] = 'O';
}
onPlay(nextSquares);
}
const winner = calculateWinner(squares);
let status;
if (winner) {
status = 'Winner: ' + winner;
} else {
status = 'Next player: ' + (xIsNext ? 'X' : 'O');
}
return (
<>
<div className="status">{status}</div>
<div className="board-row">
<Square value={squares[0]} onSquareClick={() => handleClick(0)} />
<Square value={squares[1]} onSquareClick={() => handleClick(1)} />
<Square value={squares[2]} onSquareClick={() => handleClick(2)} />
</div>
<div className="board-row">
<Square value={squares[3]} onSquareClick={() => handleClick(3)} />
<Square value={squares[4]} onSquareClick={() => handleClick(4)} />
<Square value={squares[5]} onSquareClick={() => handleClick(5)} />
</div>
<div className="board-row">
<Square value={squares[6]} onSquareClick={() => handleClick(6)} />
<Square value={squares[7]} onSquareClick={() => handleClick(7)} />
<Square value={squares[8]} onSquareClick={() => handleClick(8)} />
</div>
</>
);
}
export default function Game() {
const [history, setHistory] = useState([Array(9).fill(null)]);
const [currentMove, setCurrentMove] = useState(0);
const xIsNext = currentMove % 2 === 0;
const currentSquares = history[currentMove];
function handlePlay(nextSquares) {
const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
setHistory(nextHistory);
setCurrentMove(nextHistory.length - 1);
}
function jumpTo(nextMove) {
setCurrentMove(nextMove);
}
const moves = history.map((squares, move) => {
let description;
if (move > 0) {
description = 'Go to move #' + move;
} else {
description = 'Go to game start';
}
return (
<li key={move}>
<button onClick={() => jumpTo(move)}>{description}</button>
</li>
);
});
return (
<div className="game">
<div className="game-board">
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
</div>
<div className="game-info">
<ol>{moves}</ol>
</div>
</div>
);
}
function calculateWinner(squares) {
const lines = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];
for (let i = 0; i < lines.length; i++) {
const [a, b, c] = lines[i];
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
return squares[a];
}
}
return null;
}

Babelでコンパイルします。

npx babel Game.jsx --out-file Game.js

作成済みのリージョンMyApp2コメント・アウトし、新たにリージョンGameを作成して、HTMLコードに以下を記述します。
<div id="container"></div>
<script type="text/babel" data-presets="react" data-type="module">
import React from 'react';
import ReactDOM from 'react-dom';
import Game from "#APP_FILES#Game.js";

/* render React component within an APEX region */
const container = document.getElementById("container");
const root = ReactDOM.createRoot( container );
root.render( <Game /> );
</script>
ページ・プロパティCSSインラインに以下を追記します。ファイルを作成してインポートしても良いでしょう。

/* Game */
@scope(#container) {
* {
box-sizing: border-box;
}
body {
font-family: sans-serif;
margin: 20px;
padding: 0;
}
h1 {
margin-top: 0;
font-size: 22px;
}
h2 {
margin-top: 0;
font-size: 20px;
}
h3 {
margin-top: 0;
font-size: 18px;
}
h4 {
margin-top: 0;
font-size: 16px;
}
h5 {
margin-top: 0;
font-size: 14px;
}
h6 {
margin-top: 0;
font-size: 12px;
}
code {
font-size: 1.2em;
}
ul {
padding-inline-start: 20px;
}
.square {
background: #fff;
border: 1px solid #999;
float: left;
font-size: 24px;
font-weight: bold;
line-height: 34px;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 34px;
}
.board-row:after {
clear: both;
content: '';
display: table;
}
.status {
margin-bottom: 10px;
}
.game {
display: flex;
flex-direction: row;
}
.game-info {
margin-left: 20px;
}
}
view raw Game-styles.css hosted with ❤ by GitHub


ホーム・ページを実行すると、以下のように表示されます。


チュートリアルのReactの流儀のセクションで紹介されているアプリケーションを実装します。App3.jsxを作成します。

import React, { useState } from 'react';
function FilterableProductTable({ products }) {
const [filterText, setFilterText] = useState('');
const [inStockOnly, setInStockOnly] = useState(false);
return (
<div>
<SearchBar
filterText={filterText}
inStockOnly={inStockOnly}
onFilterTextChange={setFilterText}
onInStockOnlyChange={setInStockOnly} />
<ProductTable
products={products}
filterText={filterText}
inStockOnly={inStockOnly} />
</div>
);
}
function ProductCategoryRow({ category }) {
return (
<tr>
<th colSpan="2">
{category}
</th>
</tr>
);
}
function ProductRow({ product }) {
const name = product.stocked ? product.name :
<span style={{ color: 'red' }}>
{product.name}
</span>;
return (
<tr>
<td>{name}</td>
<td>{product.price}</td>
</tr>
);
}
function ProductTable({ products, filterText, inStockOnly }) {
const rows = [];
let lastCategory = null;
products.forEach((product) => {
if (
product.name.toLowerCase().indexOf(
filterText.toLowerCase()
) === -1
) {
return;
}
if (inStockOnly && !product.stocked) {
return;
}
if (product.category !== lastCategory) {
rows.push(
<ProductCategoryRow
category={product.category}
key={product.category} />
);
}
rows.push(
<ProductRow
product={product}
key={product.name} />
);
lastCategory = product.category;
});
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
);
}
function SearchBar({
filterText,
inStockOnly,
onFilterTextChange,
onInStockOnlyChange
}) {
return (
<form>
<input
type="text"
value={filterText} placeholder="Search..."
onChange={(e) => onFilterTextChange(e.target.value)} />
<label>
<input
type="checkbox"
checked={inStockOnly}
onChange={(e) => onInStockOnlyChange(e.target.checked)} />
{' '}
Only show products in stock
</label>
</form>
);
}
const PRODUCTS = [
{category: "Fruits", price: "$1", stocked: true, name: "Apple"},
{category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"},
{category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"},
{category: "Vegetables", price: "$2", stocked: true, name: "Spinach"},
{category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"},
{category: "Vegetables", price: "$1", stocked: true, name: "Peas"}
];
export default function App() {
return <FilterableProductTable products={PRODUCTS} />;
}

Babelでコンパイルします。

npx babel App3.jsx --out-file App3.js

作成済みのリージョンGameコメント・アウトし、新たにリージョンApp3を作成して、HTMLコードに以下を記述します。
<div id="container"></div>
<script type="text/babel" data-presets="react" data-type="module">
import React from 'react';
import ReactDOM from 'react-dom';
import App from '#APP_FILES#App3.js';

/* render React component within an APEX region */
const container = document.getElementById("container");
const root = ReactDOM.createRoot( container );
root.render( <App /> );
</script>
ホーム・ページを実行すると、以下のように表示されます。


Reactのクイックスタートを、一通りOracle APEXで動かせました。

Babelでコンパイルして作成したJavaScriptファイルを静的アプリケーション・ファイルとして保存することにより、ローカルのファイルを参照せずにReactコンポーネントを含むAPEXアプリケーションを実行できます。

共有コンポーネント静的アプリケーション・ファイルを開きます。

ファイルの作成をクリックします。


コンテンツとして作成したJavaScriptファイル(App.jsProfile.jsShoppingList.jsMyApp.jsMyApp2.jsGame.jsApp3.js)をそれぞれ、作成します。


ファイルをすべてアップロードすると、以下のようになります。APEXの静的アプリケーション・ファイルとしてJavaScriptファイルを作成すると、自動的にミニファイされたファイルも作成されますが、ファイルをアップロードした場合はミニファイされません。

今回はミニファイされたファイルは使用していないため、ミニファイは行いません。


JavaScriptファイルをアップロードしたら、セッション・オーバーライドを外します。


セッション・オーバーライドを外すと、以下のエラーが発生します。

Failed to resolve module specifier "r/apexdev/165/files/static/v22/App3.js". Relative references must start with either "/", "./", or "../".


内容としては、import from の指定としてr/apexdev/165/... という形式ではなく、相対パスの場合は、/./../ で始まるように記述してください、と言われています。 

import文を以下のように書き換えるとエラーは発生しなくなりますが、セッション・オーバーライドを使って、Live Serverを指定できなくなります。

import App from './#APP_FILES#App3.js';

今回はアプリケーション定義ユーザー・インターフェース詳細#APP_FILES#のパスを設定することで、このエラーを回避します。

./r/apexdev/165/files/static/v22/

./以降に付与される文字列はAPEXワークスペース名アプリケーションIDが含まれるため、環境やアプリケーションごとに値は異なります。


以上の変更を実施することで、Reactのクイックスタートで作成しているアプリケーションをAPEXのアプリケーションに組み込むことができました。

今回作成したAPEXアプリケーションのエクスポートを以下に置きました。
https://github.com/ujnak/apexapps/blob/master/exports/react-quick-start.zip

Oracle APEXのアプリケーション作成の参考になれば幸いです。