VueやReactはなぜ.vueや.tsxをブラウザで動かせるのか

技術発信
この記事は約15分で読めます。

Vueの.vueファイルやReactの.tsxファイルを初めて見ると、HTML、CSS、JavaScriptとは少し違う見た目なのに、なぜ普通に画面へ出せるのか不思議に見えます。

結論から言うと、ブラウザが.vue.tsxを直接理解しているわけではありません。開発サーバーやビルドツールがそれらを受け取り、コンパイラで標準的なJavaScriptとCSSへ変換してからブラウザへ渡しています。Vue公式docsは、SFCが@vue/compiler-sfcによって事前コンパイルされ、標準的なJavaScriptとCSSになると説明しています。またTypeScript公式docsは、JSXをJavaScriptへ変換できる構文拡張として説明しています。

ここを一度分解して見ると、VueとReactの仕組みはそこまで魔法ではありません。

まずは共通の流れを押さえる

VueとReactで共通する大まかな流れは、次の5段階です。

  1. ソースコードを文字列として読む
  2. 構文解析して、抽象構文木(AST)を作る
  3. フレームワークごとのルールで別のコードへ変換する
  4. 依存解決やCSS処理を行い、ブラウザ向けのモジュールにする
  5. 実行時にDOMへ反映する。SSRやSSGならHTMLとして先に書き出すこともある

大事なのは、.vue.tsxは配布形式ではなく、あくまで開発者が書きやすい入力形式だという点です。ブラウザが最終的に受け取るのは、普通のJavaScriptモジュールとCSSです。

Vueの.vueはどう変換されるか

Vue公式のSFC仕様によると、.vueファイルはトップレベルの<template><script><style>ブロックで構成されます。つまりVueの.vueは、HTMLっぽい見た目をした独自ファイル形式です。

たとえば、次のようなコンポーネントを書いたとします。

<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <button class="counter" @click="count++">{{ count }}</button>
</template>

<style scoped>
.counter {
  color: red;
}
</style>

VueのSFCコンパイラは、これをひとつの塊のまま処理するのではなく、まずブロックごとに分けます。

  • <template>@vue/compiler-domへ渡され、JavaScriptのrender関数へ事前コンパイルされる
  • <script><script setup>はコンポーネント本体のJavaScriptとして整形される
  • <style>はCSSとして取り出される

VueのSFCが内部でどう分かれるかを先に図で見ると、次のようになります。

1つのSFCがそのまま送られるのではなく、役割ごとに別の出力へ分かれる点がポイントです。

これはVue公式docsのSFC仕様Single-File Componentsに書かれている通りです。特に<script setup>は、そのまま残るのではなく、コンポーネントのsetup()として使える形へ前処理され、トップレベルで宣言した値はtemplateから参照できるようになります。

概念的には、次のようなJavaScriptモジュールへ近い形になります。

import { ref, openBlock, createElementBlock, toDisplayString } from 'vue'

const component = {
  setup() {
    const count = ref(0)
    return { count }
  }
}

function render(_ctx) {
  return openBlock(), createElementBlock(
    'button',
    {
      class: 'counter',
      onClick: () => _ctx.count++
    },
    toDisplayString(_ctx.count)
  )
}

component.render = render

export default component

実際の出力はもっと最適化されます。見てほしいのは、.vueの中身が最終的にJavaScriptの部品へ分解されている点です。Vue公式のRendering Mechanismでも、templateはrender関数へコンパイルされ、その関数が仮想DOMを返すと説明されています。実行時にはmountとpatchで実DOMへ反映されます。

CSS側も同じです。Vue公式docsのSingle-File Componentsでは、開発中の<style>は通常の<style>タグとして注入され、本番では抽出してまとめたCSSファイルにできると説明されています。つまり、Vueが.vueをそのままブラウザへ送っているのではなく、ブラウザが扱える形へほどいてから送っています。

さらにVueは、コンパイラとランタイムを両方持っているので、templateを読む段階で最適化のヒントも埋め込めます。Vue公式docsのRendering Mechanismでは、patch flagやtree flatteningのような最適化が紹介されています。ここはReactとの違いが出やすい部分です。

Reactの.tsxはどう変換されるか

Reactでよく見る.tsxは、Vueの.vueとは少し性格が違います。.vueはVue専用のファイル形式ですが、.tsxはTypeScriptが扱う「TypeScript + JSX」の構文です。TypeScript公式docsのJSXには、JSXを使うには.tsx拡張子とjsxオプションが必要だと書かれています。

React公式docsのWriting Markup with JSXでも、JSXはReactそのものではなく、JavaScriptの構文拡張だと説明されています。つまり、Reactが理解しているというより、まず変換器がJSXを普通のJavaScriptへ直しているわけです。

JSX is an embeddable XML-like syntax. It is meant to be transformed into valid JavaScript, though the semantics of that transformation are implementation-specific. JSX rose to popularity with the React framework, but has since seen other implementations as well. TypeScript supports embedding, type checking, and compiling JSX directly to JavaScript.

たとえば次のTSXを書いたとします。

type Props = {
  name: string
}

export function Hello({ name }: Props) {
  return <h1 className="title">{name}</h1>
}

これに対してTypeScriptやBabelやSWCは、主に2つの処理をします。

  1. TypeScriptの型注釈を消す
  2. JSXを関数呼び出しへ変換する

公式docsでは、次のように説明されています。

  • TypeScript公式docsのJSXでは、react-jsx_jsx()へ、古いreactモードはReact.createElement()へ変換される
  • Babel公式docsの@babel/plugin-transform-react-jsxでは、automatic runtimeでreact/jsx-runtimeを自動importする

Reactの変換は、Babelを軸に見ると理解しやすいです。Babel公式docsの@babel/plugin-transform-react-jsxでは、このプラグインは@babel/preset-reactに含まれると説明されています。またruntimeにはautomaticclassicの2種類があります。

  • automaticreact/jsx-runtimeからjsxjsxsを自動importする
  • classicReact.createElement()React.Fragmentを使う

同じJSXでも、どの関数呼び出しへ落とすかが違うだけです。どちらも最終的には普通のJavaScriptモジュールになります。

React側の変換をBabel中心で図にすると、次のようになります。

複数の子要素を持つJSXでは_jsxs()が使われ、子要素が1つだけなら_jsx()になります。

automatic runtimeの出力を単純化すると、次のようなものです。

import { jsx as _jsx } from 'react/jsx-runtime'

export function Hello({ name }) {
  return _jsx('h1', {
    className: 'title',
    children: name
  })
}

一方、classic runtimeでは同じ処理がReact.createElement()で表現されます。

import React from 'react'

export function Hello({ name }) {
  return React.createElement('h1', { className: 'title' }, name)
}

ReactでBabelに注目すると、HTMLに見えるJSXが、実際にはランタイムの関数呼び出しへ置き換えられていることが見えやすくなります。

ここでもブラウザが見ているのは、もはや.tsxではありません。型は消え、JSXは関数呼び出しに置き換わり、普通のJavaScriptモジュールになっています。

ただしVueとの大きな違いとして、TSX自体は通常CSSを内包しません。VueのSFCは<style>を同じファイルへ書けますが、ReactのTSXは多くの場合、別の.cssファイルやCSS Modules、あるいはCSS-in-JSと組み合わせます。なので「.tsxがHTML/CSS/JSへ変換される」という言い方は少し雑で、より正確には「.tsxはJavaScriptへ変換され、CSSは別の仕組みで扱われることが多い」です。

ではHTMLはどこで出てくるのか

ここで少しだけ表現を厳密にします。

VueやReactがやっていることを「.vue.tsxをHTML/CSS/JSに変換する」と言うことはありますが、SPAではこの表現は半分だけ正しいです。実際には、多くの場合まずJavaScriptへ変換し、そのJavaScriptが実行時にDOMを作ります。

Vue公式docsのRendering Mechanismは、次の流れを示しています。

  1. compile: templateをrender関数へ変換する
  2. mount: render関数が仮想DOMを返し、実DOMを作る
  3. patch: 状態変更に応じて差分更新する

ReactのJSX変換も考え方は近く、JSXそのものがHTML文字列になるわけではなく、UIを表すJavaScriptの呼び出しへ変換されます。

一方でSSRやSSGでは、同じコンポーネントをサーバー側で実行し、先にHTMLとして書き出せます。つまり、より正確には次のように考えると整理しやすいです。

  • .vue.tsxは開発者向けの記法
  • ビルド工程では主にJavaScriptとCSSへ落ちる
  • 実行時やSSRでは、その結果からDOMやHTMLが作られる

なぜファイル拡張子が違っても扱えるのか

もうひとつの疑問は、なぜimport App from './App.vue'import App from './App.tsx'のような記述が成立するのか、という点です。

答えは、拡張子ごとに処理担当を切り替えているからです。Vue公式docsのSingle-File Componentsには、実際のプロジェクトではSFCコンパイラをViteやVue CLIのようなビルドツールへ統合するとあります。つまり開発サーバーは、.vueという拡張子を見たらVue用コンパイラへ渡し、.tsxならTypeScriptやJSX変換へ渡します。

見かけ上は「ブラウザが特殊ファイルをimportしている」ように見えても、実際には開発サーバーが間に入って標準的なモジュールへ変換し直しているだけです。

Viteだと開発中はどう見えるか

初学者向けに、ここをもう少し具体化します。Vite公式docsのWhy Viteでは、依存パッケージは事前にまとめて準備し、アプリ側のソースコードはnative ESMで必要になった分だけ配信すると説明されています。さらにFeaturesでは、ブラウザがそのまま読めないimportを書き換えることや、CSS importを<style>タグとして注入することが説明されています。

たとえばVite + Vueで、main.tsApp.vueをimportしているとします。開発中は、ざっくり次の順で動きます。

  1. ブラウザがindex.htmlを開き、<script type="module" src="/src/main.ts">を読む
  2. ブラウザが/src/main.tsを取りに行く
  3. Vite dev serverがそのリクエストを受け、.tsをブラウザで動くJavaScriptへ変換して返す
  4. そのコードにimport App from './App.vue'があれば、次はブラウザがApp.vueを取りに行く
  5. Viteに入っているVueプラグインが.vueを受け取り、template、script、styleを分解して、JavaScriptモジュールとCSSとして返せる形にする
  6. import { createApp } from 'vue'のような依存パッケージのimportは、そのままではブラウザが読めないので、Viteが事前処理したURLへ書き換える

この6段階を、ブラウザの要求とViteの返答の対応で描くと次のようになります。

これを見ると、Viteはimport文全体をまとめて処理するより、各要求ごとに必要なモジュールを返していると分かります。

ここで大事なのは、Viteがimport文を一括で機械的に置き換えているわけではないことです。Vite公式docsのPlugin APIでは、dev serverは各モジュールリクエストごとにresolveIdloadtransformのような処理を呼ぶと説明されています。つまり、ブラウザが1つずつ要求してくるモジュールに対して、その場で解決、変換、配信をしているわけです。

VueのSFCでは、1つの.vueファイルが複数の配信用モジュールへ分かれることもあります。Vite公式docsのPlugin APIでも、Vue SFCのように1ファイルが複数モジュールへ対応する例に触れています。なので、App.vueをimportした瞬間に、裏側ではscript用、template由来のrender関数用、style用の処理が分かれて走ることがあります。

React + Viteでも考え方は同じです。.tsxへのリクエストが来たら、ViteがTSXをJavaScriptへ変換して返します。必要ならReact用プラグインがHMRやFast Refresh向けの処理を足します。つまり、Viteがimportを横取りしているというより、ブラウザからの各importリクエストに対して、実行可能なモジュールをその都度返していると考えるほうが実態に近いです。

VueとReactを並べると何が違うか

最後に、変換の観点だけを短く並べます。

項目Vueの.vueReactの.tsx
入力の性格Vue専用のSFC形式TypeScript + JSX
主な変換器@vue/compiler-sfc@vue/compiler-domTypeScript、Babel、SWC
何が起きるかtemplateとscriptとstyleを分解し、render関数とCSSへする型を消し、JSXを_jsx()React.createElement()へする
代表的な変換モードSFCコンパイラがtemplateとscriptとstyleをまとめて扱うBabelではautomatic runtimeとclassic runtimeがある
CSSの扱い同じファイルに書ける別ファイルや別の仕組みが多い
実行時の見え方render関数が仮想DOMを返すJSX変換後の関数呼び出しがUIを表す

共通しているのは、どちらも「人が書きやすい記法」を、そのまま配布しているわけではないことです。

まとめ

VueやReactが.vue.tsxを扱える理由は、ブラウザがそれらを理解しているからではありません。ビルドツールとコンパイラが、独自記法を標準的なJavaScriptとCSSへ落とし込み、その先でDOMやHTMLを作れるようにしているからです。

VueではSFCコンパイラが<template><script><style>を分解し、templateをrender関数へ変換します。ReactのTSXでは、TypeScriptやBabelが型注釈を外し、JSXを_jsx()React.createElement()の呼び出しへ変換します。

なので、「なぜ変換できるのか」の答えを一文で言うならこうです。

独自ファイル形式や構文に対して、対応するパーサーと変換器が用意されているからです。

タイトルとURLをコピーしました