maesblog

Vue.jsでJSX(TypeScript + TSX)を書く

会社の勉強会で「Vue.jsでJSXを書く」という内容で話をしてきました。その時の内容を内容を補足してまとめておきます。あくまでも私の趣味なので推奨しているわけではありませんが、JSXは関数の戻り値として扱うのに最適であり、この記事でも紹介するTransclusionやRender Propsのようなテクニックも比較的容易に書くこともでき、コードの表現の幅が広がるのは間違いありません。参考程度に目を通してもらえれば幸いです。

はじめに – JSXとは

JSXは、XML(HTML)に似た構文を持つJavaScriptの拡張言語です。主にReactでView(テンプレート)を書くために使われています。仮想DOM(VirtualDOM)という言葉を聞いたことがあるかと思いますが、ReactのViewは仮想DOMで構築されており、その実体はReactElementと呼ばれるReact固有の型で成り立っています。プロパティをちょっとだけ持つだけで、メソッドもprototypeも持たず、軽量でStatelessでImmutableなため、複雑な要素の変更にも素早く対応するように作られています。

ReactElementは以下のようにReact.createElement()関数を使って生成します。

React.createElement(
  MyButton, // 要素名
  {color: 'blue', shadowSize: 2}, // プロパティ
  'Click Me' // 子要素
)
仮想DOM

しかし、仮想DOMを書くのに毎回React.createElement()関数を使ってReactElementを書くのは、冗長で、視認性も悪くメンテナブルではありません。そこでReact.createElement()関数のシンタックスシュガーとしてJSXが用意されています。

上記のReactElementのコードは、JSXを使うと以下のように書くことができます。

<MyButton color="blue" shadowSize={2}>
  Click Me
</MyButton>
JSX

Reactでは、Viewを関数の戻り値として定義するので、JSXのような書き方ができると何かと扱いやすくなります。記事の後半で紹介しますが、コンポーネント間でコードをシェアするための「RenderProps」と呼ばれるテクニックなども、JSXを使うことでシンプルに書くことができます。

Vueでも、Reactと同様、仮想DOMを使ってViewを書くことができるようになっています(Vueの内部的な仕組みとして、HTMLで書いたテンプレートは最終的に仮想DOMに変換される実装となっているためです)。そして、その仮想DOMを書くのにJSXを使うことができるようになっています。

HTMLやCSSも含めて全ての処理をJavaScriptのコード内に書くことができるようになるので、テンプレート側に条件分岐などを書く必要がなく修正漏れなども起こりにくくなりオススメです。今回の記事では、VueでJSXを書く方法を紹介します。興味がありましたら、ぜひ参考にしてみてください。

Vueにおける仮想DOM、JSX

Vueでは、createElement()関数を使って仮想DOMを構築します。

createElement(
  // {String | Object | Function}
  // HTML タグ名、コンポーネントオプション、もしくは
  // そのどちらかを解決する非同期関数です。必須です。
  'anchored-heading',

  // {Object}
  // テンプレート内で使うであろう属性に対応する
  // データオブジェクト。任意です。
  {
    props: { level: 1 }
  },

  // {String | Array}
  // 子のVNode。`createElement()` を使用して構築するか、
  // テキスト VNode の場合は単に文字列を使用します。任意です。
  [
    createElement('span', 'Hello'),
    ' world!'
  ]
)
仮想DOM

「はじめに」のところでも書きましたが、仮想DOMを書くのに毎回createElement()関数を使うのはしんどいです。JSXを使えば、以下のように構造が一目で理解できるように書くことができます。

<AnchoredHeading level={1}>
  <span>Hello</span> world!
</AnchoredHeading>
JSX

Vueにはrender()関数というものが用意されていて、このrender()関数の戻り値として仮想DOMを返すことで、コンパイル時に仮想DOMが通常の生DOMに変換される仕組みとなっています。そしてこのrender()関数の戻り値の部分をJSXで書くことができます。なお、JSXは素のJavaScriptのコードではないのでJavaScriptのコードにトランスパイルする必要があります。このトランスパイル時に、createElement()関数を使ってJSXが仮想DOMに変換されます。

TypeScriptにはJSX(TSX)のJavaScriptへの変換機能が備わっていますが、Vue CLIで構築した環境ではTypeScriptだけではうまく変換ができませんでした。結局Babel(babel-plugin-transform-vue-jsxというプラグイン)を使ってJavaScriptへの変換を行う必要があります(TypeScriptオンリーで変換を行う方法があれば、教えてください)。

VueでJSXを書くための準備

Bableの準備

JSXのJavaScriptへの変換をBabelで行う必要があるので、まずVue CLIで環境を構築する際に、「Use Babel alongside TypeScript for auto-detected polyfills?」Yesとしてください。そうすることで、プロジェクト生成時にbabel-core@vue/cli-plugin-babelがインストールされ、babel.config.jsファイルが生成されます。

@vue/cli-plugin-babelには、JSXをJavaScriptに変換するためのプラグインbabel-plugin-transform-vue-jsxが含まれていて、babel.config.jsファイルもすでに設定内容が記述されているので、JSXの変換に関するBabelの設定はこれ以上何もする必要はありません。

tsconfig.jsonの設定

tsconfig.jsonファイルは、TypeScriptのコードをトランスパイルする際に使われる設定を記述するためのファイルです。

JSXをTypeScriptで書いたファイルは.tsxという拡張子をつけたファイル名となります。この.tsxファイルをトランスパイルする際の設定をtsconfig.jsonファイルに追記します。jsxjsxFactoryの設定で、React以外でもJSXを使用することができるようになります。

{
  "compilerOptions": {
    ...
    "jsx": "preserve",
    "jsxFactory": "h",
    ...
  }
}
tsconfig.json

"jsx": "preserve"とすると、TypeScriptのトランスパイル時に.tsxファイルが.jsxファイルに変換されます。preserve(保持する)としているので、記述しているJSXは変換されずにJSXのままです。

"jsxFactory": "h"とすると、JSXの変換時にJSXのファクトリ関数としてh()関数が使われるようになります(デフォルトは、React.createElementです)。hは、createElementのエイリアスです。つまり、createElement()関数を使って、仮想DOMが作られるようになります。ちなみに、"jsx": "preserve"としているので、この記述はなくても問題ありません(後ほど説明するユニットテスト時に必要となります)。

詳細については、以下を参照してください。

JSXを書くファイルの準備

JSXを書くファイルを作成したら、vuevue-property-decoratorの両パッケージから以下のモジュールを読み込みます。

import { VNode, CreateElement } from 'vue';
import { Component, Prop, Vue } from 'vue-property-decorator';

vue-property-decoratorは、Vueをクラス構文で書く際にデコレータを使えるようにしてくれるパッケージで、Vue CLIでプロジェクトを作成する際に、「Use class-style component syntax」Yesとするとインストールされます。

状況に応じて読み込むモジュールは異なってきますが、ひとまず上記のモジュールを読み込んでおくと良いでしょう。Prop以外は、JSXを書く上では必須のモジュールとなります。

それからJSXを書くファイルのファイル名には、.tsxという拡張子をつけて保存します。

TypeScriptでVueを書く方法について当ブログでも以下の記事を書いています。よかったら参考にしてみてください。

JSXで「Hello World」を書く

JSXを書く準備が整ったところで、早速JSXで「Hello World」を表示するコンポーネントを書いてみましょう。

SFCで「Hello World」を書く

比較として、まず通常のSFC(単一ファイルコンポーネント)で書いたHello Worldのコードを紹介しておきます。こちらもTypeScriptで書いています。

<template>
  <h1>Hello {{ world }}</h1>
</template>

<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';

@Component
export default class HelloWorld extends Vue {
  public world: string = 'world';
}
</script>

<style>
h1 {
  font-size: 64px;
  color: #3ab981;
}
</style>
HelloWorld.vue

JSXで「Hello World」を書く

上記のSFCで書いたHello Worldのコードを、JSXで書くと以下のようになります。

import { VNode, CreateElement } from 'vue';
import { Component, Vue } from 'vue-property-decorator';

@Component
export default class HelloWorld extends Vue {
  /** State */
  public world: string = 'world';

  /** View(renderメソッド) */
  public render(h: CreateElement): VNode {
    /** Style */
    const style = {
      fontSize: '64px',
      color: '#3ab981',
    };

    /** Template(JSX) */
    return <h1 style={style}>{this.world}</h1>;
  }
}
HelloWorld.tsx

実装のポイントは以下の通りです。

  • render()関数の戻り値としてJSXで書いたコード(テンプレート)を返すようにしています。
  • render()の引数には、仮想DOMを生成するh()関数(createElement()関数)を指定しています。
  • スタイルはrender()のローカル変数として定義して、JSXのstyle属性に適用させています。

HTMLもCSSも含めてすべてTypeScript内に書くことができ、コンポーネントのすべての処理がTypeScriptのコードだけで完結するので、コードの見通しもよくなります。コード自体は、ReactやStencilを彷彿とさせるスタイルとなります。特に「デコレータ + JSX」という部分を見ると、ほとんどStencilと同じようなコードになります。つまり、VueでJSXを書くことに慣れておけば、このようなライブラリも抵抗なく扱えるようになっているはずでしょう。

JSXを使ったテクニック1 – Transclusion

Reactには、コンポーネント自身が自分の子要素にアクセスするための機能としてchildren propというものがあります。たとえばサイドバーやダイアログのようなコンポーネントは、事前に子要素として表示する内容を知らせておきたいものです。children propを使うと、直接子要素を取得することができます。一般的にこのパターンを「Transclusion(トランスクルージョン)」と言います。JSXを使うと、このTransclusionを直感的にとてもわかりやすく書くことができます。

VueでJSXを使った時に、このReactのchildren propと同等のTransclusionを実装する方法を2通り紹介します。slots(スロット)を使う方法と、関数型コンポーネントを使う方法です。

slots(スロット)を使う方法

this.$slots.defaultで子要素を取得しています。子要素は配列として取得し、ここでは最初の子要素を表示させるようにしています。

import { VNode, CreateElement } from 'vue';
import { Component, Vue } from 'vue-property-decorator';

@Component
class Child extends Vue {
  public render(h: CreateElement): VNode {
    /** 1番目の子要素を取得し表示 */
    return <span>{this.$slots.default[0]}</span>;
  }
}
Childコンポーネント

関数型コンポーネントを使う方法

関数型コンポーネントにすると、render()メソッドの第二引数にcontextオブジェクトが渡され、そのcontext.childrenで子要素を取得することができます。子要素は配列として取得し、ここでは最初の子要素を表示させるようにしています。

import { 
  VNode, CreateElement, FunctionalComponentOptions, RenderContext
} from 'vue';
import { Component, Vue } from 'vue-property-decorator';

@Component({
  /** コンポーネントを関数型コンポーネントにする */
  functional: true,
} as FunctionalComponentOptions)
class Child extends Vue {
  public render(h: CreateElement, context: RenderContext): VNode {
    /** 1番目の子要素を取得し表示 */
    return <span>{context.children[0]}</span>;
  }
}
Childコンポーネント

Transclusion実践

使い方としては、以下のように上記のChildコンポーネントを子要素を持たさせた状態で、親コンポーネントとなるHelloWorldコンポーネントに取り込みます。Childコンポーネントにはそれぞれ子要素として「Hello」と「World(変数として)」を渡しています。

import { VNode, CreateElement } from 'vue';
import { Component, Vue } from 'vue-property-decorator';

@Component
export default class HelloWorld extends Vue {
  public world: string = 'World';

  public render(h: CreateElement): VNode {
    const { world } = this;

    return (
      <h1 style={ fontSize: '64px', color: '#3ab981' }>
        <Child>Hello</Child> <Child>{world}</Child>
      </h1>
    );
  }
}
親コンポーネント

結果は以下の通りです。子要素からそれぞれ「Hello」と「World」を取得して、それを実装の通りそのまま表示させています。

vue-tsx-sample children prop

同じコンポーネントでも、子要素に持たせる値によって別々の表示ができるようになっています。汎用的なコンポーネントを作りたい場合に、とても役立つテクニックだと思います。

JSXを使ったテクニック2 – Render Props

次に紹介するのは、昨年くらいからReactでもよく使われるようになった「Render Props」というテクニックです。関数の戻り値として扱うのにJSXは最適で、その特徴が活かされたReactでよく使われる実装パターンです。Render Propsは、抽象化したい処理を汎用的なコンポーネントに隠蔽する目的で主に使われます。

Render Propsのサンプル

まずはRender Propsで実装したサンプルを紹介します。以下の枠内でマウスカーソルを動かすとカーソルの位置情報(x, y座標)を表示します。

枠の中でカーソルを動かしてみてください
  • Mouseコンポーネント: シェアしたい処理を記述したコンポーネントで、Render Propsを持ち、動的にレンダリングする内容を決めることができます。ここではマウスカーソルの位置情報を取得してRender Props関数の引数に渡すようにしています。
  • Catコンポーネント: 実際にレンダリングされる内容を記述したコンポーネント。マウスカーソルの位置を受け取り、レンダリング内容の生成の際に使用します。ここでは、受け取ったマウスカーソル位置に合わせて、マウスカーソル位置を表示するようにしています。
  • MouseTrackerコンポーネント: MouseコンポーネントとCatコンポーネントを格納する親コンポーネント。

それぞれのコンポーネントの実際のコードを以下より紹介していきます。

Mouseコンポーネント(共有したい処理)

マウスカーソルの位置情報(x, y座標)を保持し、受け取ったRender Props関数をJSX内で呼び出し、レンダリングを行います。Render Props関数はコンポーネントを返すので、コンポーネントのレンダリング内容が展開されます。今回のサンプルではCatコンポーネントを返すようにしているので、Catコンポーネントのレンダリング内容が展開されます。

@Component
export default class Mouse extends Vue {
  /** props: コンポーネントを返す関数をPropsを通して取得 */
  @Prop()
  private rendering!: (mouse: { x: number; y: number }) => VNode;

  /** state: マウスカーソルの位置情報 */
  private data = { x: 0, y: 0 };

  /** method: コンポーネントで使用するメソッド */
  private handleMouseMove(event: MouseEvent) {
    this.data = {
      ...this.data,
      x: event.x,
      y: event.y,
    };
  }

  /**
   * render関数: JSXを返す
   * propsを通して受け取ったrendring関数(render props)を呼び出す。
   * rendering関数はコンポーネントを返すので、
   * 渡されたCatコンポーネントのレンダリング内容が展開される。
   */
  private render(h: CreateElement): VNode {
    const { rendering } = this;
    return (
      <div style={{ height: '100%' }} onMousemove={this.handleMouseMove}>
        {rendering(this.data)}
      </div>
    );
  }
}
Mouseコンポーネント(Mouse.tsx)

Catコンポーネント(共有処理を使う)

こちらのコンポーネントは、マウスカーソルの位置情報をpropsを通して受け取り、その値を使用してレンダリング内容を構成します。Render Props関数の戻り値となり、Mouseコンポーネントの処理による値を受け取りつつ、MouseコンポーネントのJSX内でレンダリングされます。ちなみにCat(猫)というコンポーネント名は、Mouse(ネズミ)を追いかけるところからつけています。

@Component
export default class Cat extends Vue {
  /** props: マウスカーソルの位置情報 */
  @Prop()
  private mouse!: { x: number; y: number };

  /** render関数: JSXを返す */
  private render(h: CreateElement): VNode {
    return (
      <div>
        x座標: {this.mouse.x} / y座標: {this.mouse.y}
      </div>
    );
  }
}
Catコンポーネント(Cat.tsx)

MouseTrackerコンポーネント(親子コンポーネント)

上記のMouseコンポーネントとCatコンポーネントを格納しています。Mouseコンポーネントにrenderingという名前で関数を渡しています。この関数は、「カーソルの位置情報を引数で受け取り」「mouse属性からそのカーソルの位置情報を受け取るコンポーネントを返す」ようになっています。これがMouseコンポーネントでレンダリングのために使用されるrendaring関数の実体(Render Props関数)となります。

import { VNode, CreateElement, VueConstructor } from 'vue';
import { Component, Vue } from 'vue-property-decorator';

import Mouse from './Mouse';
import Cat from './Cat';

/** optionsにrenderingの型を追加 */
declare module 'vue/types/options' {
  interface ComponentOptions {
    rendering?: (mouse: { x: number; y: number }) => VNode;
  }
}

/**
 * Higher-Orederコンポーネント: コンポーネントクラスを返す
 * コンポーネントを引数を通して受け取る
 */
function withSample(WrappedComp: any): VueConstructor<Vue> {
  @Component
  class MouseTracker extends Vue {
    /** render関数: JSXを返す */
    private render(h: CreateElement): VNode {
      /**
       * Mouseコンポーネントのrendering属性にRender Props関数を指定
       * Render Props関数はwithSample関数の引数に指定されたコンポーネントを返す
       */
      return (
        <div style={{ height: '100%', width: '100%', margin: 0 }}>
          <Mouse rendering={
            (mouse: { x: number; y: number }) => <WrappedComp mouse={mouse} />
          } />
        </div>
      );
    }
  }
  /** withSample関数の戻り値としてMouseTrackerコンポーネントを返す */
  return MouseTracker;
}

/** withSample関数にCatコンポーネントを渡してHelloWorldコンポーネント生成 */
const HelloWorld = withSample(Cat);
export default HelloWorld;
親コンポーネント(MouseTracker.tsx)

ここでは、もうひとつのテクニックを使用しており、MouseTrackerコンポーネントをwithSample関数でラップしています。withSample関数の引数を通して受け取ったコンポーネントをRender Props関数の戻り値にセットしています。このテクニックは「Higher-Order Component(HOC / 高階コンポーネント)」と呼ばれ、Render Propsと同様コンポーネントを抽象化するためのテクニックです。コンポーネントを引数で受け取り、新しいコンポーネントを返す関数だと覚えると良いでしょう。JSXを使用すると、このようなテクニックも比較的に容易に書くことができます。

注意点として、上記のコードにdeclare module 'vue/types/options'という部分がありますが、options型にrenderingを追加しています。これを指定しておかないと以下のようなエラーが出ました。

[ts] プロパティ 'rendering' は型 'ComponentOptions, DefaultMethods, DefaultComputed, PropsDefinition>, Record> | ThisTypedComponentOptionsWithArrayProps | ThisTypedComponentOptionsWithRecordProps<...> | undefined' に存在しません。

このエラーについてはvue-class-componentのissueにも挙げられていました。こちらも参考にしてください。

ユニットテストについて

最後にユニットテスト(単体テスト)について軽く触れておきます。VueをJSXで書いた場合のユニットテストですが、Jestで試したところ通常通り書くことができました。

なお、ユニットテストを書くときは、tsconfig.jsoncompilerOptionsjsxjsxFactoryの部分を以下のように設定する必要があります。つまり、Jestでのテスト時はTypeScriptでJSXの変換を行うことになります。

{
  "compilerOptions": {
    ...
    "jsx": "react",
    "jsxFactory": "h",
    ...
  }
}
tsconfig.json

上記で説明したように、実装時は"jsx": "preserve"とする必要があります。しかしこの状態でJestを起動すると以下のようなエラーが発生しました。できれば実装時も"jsx": "react"としてTypeScriptでJSXの変換を行いたくて、ここはいい方法がないか現在調査しているところです。

  ● Test suite failed to run

    Jest encountered an unexpected token

    This usually means that you are trying to import a file which Jest cannot parse, e.g. it's not plain JavaScript.

    By default, if Jest sees a Babel config, it will use that to transform your files, ignoring "node_modules".

    Here's what you can do:
     • To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
     • If you need a custom transformation specify a "transform" option in your config.
     • If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.

    You'll find more details and examples of these config options in the docs:
    https://jestjs.io/docs/en/configuration.html

    Details:

    /Users/maechabin/Sites/practice/vue-jsx-sample/src/components/helloworld/HelloWorld.tsx:16
            return <h1 style={style}>{world}</h1>;
                   ^

    SyntaxError: Unexpected token <

      1 | import { mount } from '@vue/test-utils';
    > 2 | import HelloWorld from '@/components/helloworld/HelloWorld.tsx';
        | ^

以下は上記で紹介したJSXで書いたHello WorldのJestでのテストコードです。

import { mount } from '@vue/test-utils';
import HelloWorld from '@/components/helloworld/HelloWorld.tsx';

describe('HelloWorld.tsx', () => {
  it('renders props.msg when passed', () => {
    // setup
    const msg = 'world';
    const wrapper = mount(HelloWorld);

    // verify
    expect(wrapper.text()).toMatch(msg);
  });
});
HelloWorld.spec.tsx

若干設定に難ありですが、一応ユニットテストにも対応していることがわかりました。

まとめ

VueでJSXを書く方法を紹介してきましたが、いかがでしたでしょうか。一見HTMLのようなので簡単そうに見えますが、実体はJavaScriptなので、少々ハマることもあるかと思います。ただ、関数の戻り値として扱いやすいという部分がすごく魅力的で、今回紹介したTransclusionやRender Propsのようなテクニックも比較的容易に書くことができ、コードの表現の幅が広がるのは間違いありません。興味のある方はぜひお試しください。

なお、私はTypeScriptが好きなので、今回の記事でもTypeScriptで書いたコードを紹介しましたが、まだVueでJSXを書くことにおいてTypeScriptはまだ対応が追いついていない部分があると思いました。型付けやユニットテストに関して、けっこうハマることが多かったです。現在開発中と言われているVue 3.0はTypeScriptで実装されているようなので、この辺りの対応が進むと良いですね。とにかくJSXは気持ち悪いともよく言われますが、実際に書いてみると面白いので、未トライな方はTypeScriptを使わなくても良いのでお試しされることをオススメします。

今回紹介したサンプルのソースコードは、GitHubにもアップしています。合わせてご確認ください。

Vue.js入門 基礎から実践アプリケーション開発までVue.js入門 基礎から実践アプリケーション開発まで
  • 『Vue.js入門 基礎から実践アプリケーション開発まで』
  • 著者: 川口和也, 手島拓也, 野田陽平, 喜多啓, 片山真也
  • 出版社: 技術評論社
  • 発売日: 2018年9月22日

関連記事

コメント

  • 必須

コメント