20191028のReactに関する記事は4件です。

create-react-appのプロジェクト(redux)をテストする

はじめに

美容室に行った時に本棚にナルトが置いてあって読んだんですけど、なんか懐かしくなっちゃって帰り道こっそり薄暗い中でナルト走りしました。
はい、create-react-appで作ったプロジェクトをテストし始めました。折角なんでまとめながらやります。

セットアップ

srcフォルダの一個下の階層に__Tests__フォルダを作ります。このフォルダにテストファイルを放り込んでおくことでtestコマンドによってテストが実行されます。他の方法
次に__Tests__フォルダと同じ階層に”setupTests.js”を作り編集します。

setupTests.js
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

configure({ adapter: new Adapter() });

そして、パッケージをインストール

npm i --save-dev enzyme enzyme-adapter-react-16

実行

”npm test”コマンドでjestを実行しましょう。ターミナルがテスト画面?になります。ファイルを保存すると追加したテストたちが自動で実行されていきます。僕は初回実行時に下記のエラーが出ました。

エラー

(FSEvents.framework) FSEventStreamStart: register_with_server: ERROR: f2d_register_rpc() => (null) (-22)

create-react-appのnpm testを実行するとjestは監視モードで起動するそうです。監視ツールがないとこのエラーが出るみたいです。解決策は簡単でwatchmanという監視ツールをインストールするだけです。

解決策

brew install watchman

react&reduxプロジェクトをテストする

react&reduxプロジェクトのテストは主にaction、reducer、componentにわけてテストするみたいです。redux公式

actionのテスト

アクションの役割はアクションタイプと場合によってはステートを変更したい値を含んだオブジェクトを生成することです。そのため、まずそれぞれのアクションによって生成されるべきオブジェクトを定義します。次に、アクションを実行します。そして、定義したオブジェクトと実行したアクションによって返されるオブジェクトが同じならテストをパスします。

action.js
export const changeName = (name) => ({
  type: CHANGE_NAME,
  name,
});

こんなアクションがあったとして、

hoge.test.js
import actions from './action.js';

describe('アクションたちのテスト', () => {
 test('名前を変更するアクションが生成されること', () => {
    const name = '名前';
    const expected = {
      type: CHANGE_NAME,
      name,
    };
    expect(actions.changeName(name)).toEqual(expected);
  });
});

こんな感じ

非同期処理を含むactionのテスト

非同期処理を含むactionのテストはreduxストアをテストファイルで擬似的に作り、redux-thunkなどのミドルウェアをテストでも使えるようにします。HTTPリクエストのモックはfetchを使っている場合はfetch-mockをredux公式では使用しています。しかし、axiosを使用している場合は別のプラグインが必要です。fetch-mockでaxiosのモックをすると下記のようなエラーが出ます。

connect ECONNREFUSED 127.0.0.1:80

axiosをモックするためには"axios-mock-adapter"を使います。fetch-mockと使い方は似ています。

action.js
const requestData = () => ({
  type: types.REQUEST_DATA,
});
const receiveDataSuccess = (allShareData) => ({
  type: types.RECEIVE_DATA_SUCCESS,
  allShareData,
});
const receiveDataFailed = () => ({
  type: types.RECEIVE_DATA_FAILED,
});

export const getAllShare = () => (dispatch) => {
  dispatch(requestData());
  return axios.get(`${process.env.REACT_APP_PROXY}/api/share`)
    .then((response) => {
      console.log(response);
      const products = response.data;
      dispatch(receiveDataSuccess(products));
    })
    .catch((err) => {
      console.error(new Error(err));
      dispatch(receiveDataFailed());
    });
};

こんなんがあったとして

getProducts.test.js
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import * as actions from '../../actions/index';
import * as types from '../../constants/actionTypes';
import products from '../../mockApi/products';

const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const mock = new MockAdapter(axios);

describe('get products actions', () => {
  afterEach(() => {
    mock.restore();
  });

  test('async action success', () => {
    mock.onGet(`${process.env.REACT_APP_PROXY}/api/share`).reply(200, [...products]);

    const expected = [
      { type: types.REQUEST_DATA },
      { type: types.RECEIVE_DATA_SUCCESS, allShareData: products },
    ];

    const store = mockStore();

    return store.dispatch(actions.getAllShare()).then(() => {
      // console.log(JSON.stringify(store.getActions()));
      expect(store.getActions()).toEqual(expected);
    });
  });
});

axios-mock-adapterによって返されるレスポンスに注意してください。コンソールで確認すると.replyの第二引数で指定したデータが"data"プロパティにあり、ちゃんとステータスコードとかもオブジェクトに入っています。
redux-mock-storeで作成したストアには、非同期処理でディスパッチされたアクションたちが配列で格納されていきます。あとは期待されるアクションの形と比べて同じならおっけみたいな感じになっています。

reducerのテスト

reducerの役割はまず受け取ったアクションの値を状態に適用します。そして、新しい状態を返します。
ダミーのアクションと必要なステートを用意して、reducerの引数に渡します。期待された通りのステートが帰ってきたらテストをパスします。(actionCreaterを突っ込む例などもありましたが、redux公式のサンプル通りに今回はやりました。)

reducer.js
import {
  CHANGE_PRODUCTNAME,
} from './actionTypes';

const initialState = {
  shareForm: {
    productName: '',
    hoge: ''.
    hana: '',
    hoji: '',
    hoji: '',
    initializeForm: '',
  },
};

const shareFormReducer = (state = initialState.shareForm, action) => {
  switch (action.type) {
    case CHANGE_PRODUCTNAME:
      return {
        ...state,
        productName: action.productName,
      };
    default:
      return state;
  }
};

export default shareFormReducer;

こんなんがあって

import reducer from './reducer.js';
import {
  CHANGE_PRODUCTNAME,
} from './actionTypes';

describe('shareForm reducer', () => {
  test('初期値を返すこと', () => {
    const state = undefined;
    const action = {};
    const result = reducer(state, action);
    const expected = {
      productName: '',
      hoge: ''.
      hana: '',
      hoji: '',
      hoji: '',
      initializeForm: '',
    };

    expect(result).toEqual(expected);
  });

  test('CHANGE_PRODUCTNAMEが正しく処理されること', () => {
    const state = {
      productName: '',
    };
    const action = {
      type: CHANGE_PRODUCTNAME,
      productName: 'プロダクト名',
    };
    const result = reducer(state, action);
    const expected = {
      productName: action.productName,
    };

    expect(result).toEqual(expected);
  });
});


ポイントは必ず初期値を返すかどうかのテストが必要です。あと、必要な分の(アクションからみて変更されることが期待される)ステートだけ用意することです。redux公式

component

react-reduxでconnect()されているコンポーネント(connected-component)は、connect()から切り離してテストするみたいです。
Containerフォルダーとか作って、exportされたコンポーネントをconnnect()している方は心配ないと思いますが、componentとmapStatePropsとかを同じファイルに書いている方は切り離します。

redux公式から引用
import { connect } from 'react-redux'

class App extends Component {
  /* ... */
}

export default connect(mapStateToProps)(App)

コンポーネントもexportする

import { connect } from 'react-redux'

// Use named export for unconnected component (for tests)
export class App extends Component {
  /* ... */
}

// Use default export for the connected component (for app)
export default connect(mapStateToProps)(App)

これをimportするには

import ConnectedApp, { App } from './App'

切り離したら各コンポーネントが正しく表示されるかテストしていきます。
これはよくありそうな、データをフェッチしているときはローディング画面を表示して〜、みたいなコンポーネントです。

sample.js
import React from 'react';
import Grid from '@material-ui/core/Grid';
import DetailedCard from './DetailedCard';
import key from '../../utils/listKeyGenerator';

const RentScreen = ({
  isFetching,
  dataArray,
  ...rest
}) => (
    <div>
      {
        isFetching
          ? <h2>Now Loading...</h2>
          : (
            <div>
              <Grid item xs={12}>
                <Grid container justify="center" spacing={2}>
                  {dataArray.map((share) => (
                    <Grid key={key()} item>
                      <DetailedCard
                        productName={share.productName}
                        img={share.productImg}
                        description={share.description}
                        period={share.period}
                        shippingArea={share.shippingArea}
                        days={share.days}
                        id={share.id}
                        name={share.name}
                        avatar={share.avatar}
                        comment={share.comment}
                        rest={rest}
                      />
                    </Grid>
                  ))}
                </Grid>
              </Grid>
            </div>
          )
      }
    </div>
  );

export default RentScreen;

この場合はローディング中の画面とローディング後の画面が表示されているか確認します。また、スナップショットがなければ作成し、あれば以前のスナップショットと比較して正しくレンダリングされているかテストします。

import React from 'react';
import { shallow } from 'enzyme';
import RentScreen from '../../components/RentScreen/index';
import DetailCard from '../../components/RentScreen/DetailedCard';
import products from '../../mockApi/products';

describe('<RentScrren />', () => {
  it('<DetailCard />を表示していること', () => {
    const wrapper = shallow(<RentScreen
      isFetching={false}
      shareInformationsArray={products}
    />);

    expect(wrapper.find(DetailCard).length).toBe(7);
    expect(wrapper.debug()).toMatchSnapshot();
  });

  it('ロード画面を表示すること', () => {
    const wrapper = shallow(<RentScreen
      isFetching
      shareInformationsArray={products}
    />);

    expect(wrapper.find('h2').length).toBe(1);
    expect(wrapper.debug()).toMatchSnapshot();
  });
});

よく分からないことがありました。shallow関数で返ってくるオブジェクトをコンソールで出力すると空のオブジェクトが返ってきているように見えるのですが、各プロパティはちゃんと使えます。そして、スナップショットテストをするときに”expect(wrapper.debug())”、こんな感じにしなくてはなりません。(参考にしたものではwrapperだけで出来ていました)
enzymeのバージョンの問題なのかjestのバージョンの問題なのかよくわかりませんでした。
でもまあ、ある時点でレンダリングされたものと比較することができればスナップショットの役割は果たせるのかなと妥協しました。

まとめ

まだテストは全て終わっていないのですが、とりあえず一通りやってみたため一区切りつけます。コンポーネントのテスト方法は色々なパターンが出てくると思うでまたまとめたいと思っています。それでは!ε=ε=ε=ε=ε=ε=┌( ̄ー ̄)┘(ナルト走り)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Next.jsのDynamic Routing + Static HTML exportを組み合わせて使う

Next.js 9ではDynamic Routingが標準でサポートされ使用感が大きく改善されました。
このDynamic Routingで構築したアプリをexportした場合に一手間必要だったので考慮したことをまとめておきます。

この構成でデプロイを行う際には、ビルド時にデータを全部仕込むか、クライアント側で(APIを叩くなどして)データを仕込むかで大きく対処方法が変わります。

今回の想定アプリ

次のようなページを持つアプリケーションを想定します。

/
/about
/show/[id]

1.ビルド時にデータを仕込む方法

exportする際には、next.config.jsexportPathMapを利用し、出力するファイルの調整を行っていきます。
exportPathMapで、キーにpath、バリューにpage(pageコンポーネントの位置)、query(クエリパラメータ)などの情報をもたせたオブジェクトをそれぞれ作り返り値とします。コードを見たほうが早いでしょう。

const fetch = require('isomorphic-unfetch');

module.exports = {
  exportPathMap: async function() {
    const paths = {
      '/': { page: '/' },
      '/about': { page: '/about' }
    };
    const res = await fetch('https://api.tvmaze.com/search/shows?q=batman');
    const data = await res.json();
    const shows = data.map(entry => entry.show);

    shows.forEach(show => {
      paths[`/show/${show.id}`] = { page: '/show/[id]', query: { id: show.id } };
    });

    return paths;
  }
};

https://nextjs.org/learn/excel/static-html-export/exporting-other-pages より

この際、showのデータを参照するためにAPIを叩いて、結果からexportPathMapのデータを生成します。

2. クライアント側でデータを読み込む場合

ビルド後にもデータが増えることが想定されている場合、ビルド時にexportPathMapを使わない選択肢もあります。
exportPathMapを設定しない場合は、次のようなファイルが生成されます。

/index.html
/about/index.html
/show/[id]/index.html

問題は/show/[id]/index.htmlです。ただ、NetlifyやFirebase Hostingにデプロイするだけでは、example.com/show/[id]でしか開くことができません。
そこで各プラットフォームに、example.com/show/1などのURLを叩いた時に、/show/[id]/index.htmlの内容を返すリライトの設定を行います。

例えばNetlifyでは、公開ディレクトリに次のような_redirectsを用意します。

/show/:id /show/[id] 200

同じくFirebase hostingではfirebase.jsonにリライトの設定を追加します。

こういった設定を加えることで、データが増えた場合でも404を返さずにコンテンツを表示できます。
ただ、この対応だけでは動的にogpを返却することや、速度的なデメリット(1の方法ではHTML読み込み時点でコンテンツが表示されている)を背負うことになります。


特に2.の方法は今後SSRを使いたいけど、今はSSRを行わず静的ファイルとして返す暫定対応的な使い方になると思います。

覚えて置くと初期フェーズからNext.jsの採用を行え、将来SSRをする際に移行コストが小さくなるのでおすすめです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[React+TypeScript+Firebase]FunctionsとHostingで動的にOGP表示するSPAを作った

と思う〇〇であったジェネレーター

アプリのURL: https://thinking-generator.web.app/

Screenshot_20191028-002059_60.png

Twitterでログインして投稿すると「と思う〇〇であった」という画像をOGP表示してツイートします。

作成理由

僕のフォロワーがこのテンプレ画像(?)を自作して投稿していました。
それを見て、「『と思う〇〇であった』画像を自動生成してツイートするアプリ需要あるな!!!!!!」

とは思いませんでしたが、Firebase Functionsを使ってSPAなのにTwitterでの動的なOGP表示の知見を得られるなと思い作成に至りました。

アプリの特徴

Reactで作成されたSPA(Single Page Application)です。

Twitterアカウントでログインして名前とツイート内容を入力して「ツイートする!」ボタンを押すと、Twitterの投稿画面にツイート内容とURLとともに遷移します。
リンクを消さずに投稿すればTwitterがURLを解析してOGP画像を生成してくれて、「と思う〇〇であった」という画像を付けて投稿したような体験が得られます。

あくまでツイートにはURLが記載してあるだけなので、Twitterクライアントの画像一覧である「メディア」タブを汚染することがありません。(ここ重要。たぶん)
また、「ユーザーの使用 = アプリのURLをシェア」とみなせるので、アプリの利用拡大を見込むことができます。

TwitterクライアントがOGP表示を実装しているので利用できますが、非公式クライアントユーザーからはただのURLに見えてしまうので、公式クライアントで閲覧してください。(非公式クライアントユーザーのために画像だけを保存できる機能の実装を検討しています)

ソースコード

Github: https://github.com/stin-dev/thinking-generator

汚いソースコードを公開しています。コンポーネントの分け方のベストプラクティスを知らないので読みにくいかもしれませんがご容赦ください。

SPAでも動的OGP表示について

TwitterやFacebookのクローラーはJavaScriptを実行しません。
なのでクライアントでレンダリングを行うSPAは、そのままでは1種類のOGP表示しかできません。

サーバーサイドを自前で用意している場合は、URLの形によって動的にHTMLのmetaタグを構築してクローラーに返却することができますが、今回はFirebase Hostingを利用しているのでそれは叶いません。

しかしFirebase Hostingは特定のURLへのリクエストを受けた場合、Functionsに処理を委譲する機能を備えています。その機能を使えば動的なOGP生成が可能になります。

同じことを考えて解説している先駆者がたくさんいらっしゃるのでもっとわかりやすい記事を読みたい方は以下を参照してください。(僕も参考にして非常に助かりました。)

https://qiita.com/yuneco/items/5e526464939082862f5d
https://qiita.com/serinuntius/items/3017fb6ef51cd47352f6
https://qiita.com/mitsudaman/items/1956b94dc8faf8fb8c59

Hosting設定

https://github.com/stin-dev/thinking-generator/blob/master/firebase.json

firebase.json
  "hosting": {
    "public": "build",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "/share/*",
        "function": "share"
      },
      {
        "source": "/ogp/*",
        "function": "getOgpImage"
      },
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  }

rewritesにどのURLへのアクセスをFunctionsに委譲するかを記載します。

この場合、たとえば

https://thinking-generator.web.app/share/example-url

にアクセスすると、Functionsに作成した"share"関数をコールします。

Storageへ画像保存

https://github.com/stin-dev/thinking-generator/blob/master/src/component/TweetButtonArea.tsx#L37-L57

/src/component/TweetButtonArea.tsx
    const handleClick = (container: GlobalStateContainer) => async () => {
        const currentUser = firebase.auth().currentUser;
        if (!currentUser) return;

        const storageRef = firebase.storage().ref();
        const createRef = storageRef.child(`ogp-images/${currentUser.uid}.jpg`);
        const canvas = document.getElementById("canvas") as HTMLCanvasElement;

        const imagedata = canvas.toDataURL("image/jpeg").split(",")[1];
        await createRef.putString(imagedata, "base64").then(snapshot => {
            const tweeturl = `http://twitter.com/share`
                + `?url=${container.ogpUrl}`
                + `&text=${container.state.tweetText.replace(/\r?\n/g, "%0a")}%0a%0a`

            if (window.open(tweeturl, "_blank")) {

            } else {
                window.location.href = tweeturl;
            };
        })
    }

「ツイートする!」ボタンのonClickイベントです。Storageにogp-images/{currentUser.uid}.jpgという名前でCanvasの画像を保存します。
その後、TweetShareのurlパラメータにhttps://thinking-generator.web.app/share/{currentUser.uid}を付与して画面遷移を行います。

Functions share関数

上記フェーズにてURLを含むツイートが投稿されたらTwitterクローラーはhttps://thinking-generator.web.app/share/{uid}にアクセスします。
しかし実際はfirebase.jsonの設定によってFunctionsのshare関数がコールされます。

https://github.com/stin-dev/thinking-generator/blob/master/functions/src/share.ts#L20-L48

/functions/src/share.ts
const createHtml = (uid: string) => {
    const SITEURL = `https://${appDomain}`
    const TITLE = `と思う〇〇であったジェネレーター`
    const DESCRIPTION = '「と思う〇〇であった」という画像をTwitterに投稿するサービスです。'

    return `<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <title>と思う〇〇であったジェネレーター</title>
    <meta property="og:title" content="${TITLE}">
    <meta property="og:image" content="${SITEURL}/ogp/${uid}">
    <meta property="og:description" content="${DESCRIPTION}">
    <meta property="og:url" content="${SITEURL}">
    <meta property="og:type" content="article">
    <meta property="og:site_name" content="と思う〇〇であったジェネレーター">
    <meta name="twitter:site" content="${SITEURL}">
    <meta name="twitter:card" content="summary_large_image">
    <meta name="twitter:title" content="${TITLE}">
    <meta name="twitter:image" content="${SITEURL}/ogp/${uid}">
    <meta name="twitter:description" content="${DESCRIPTION}">
  </head>
  <body>
    <script type="text/javascript">window.location="/";</script>
  </body>
</html>
`
}

引数uid: stringからhtmlを作成する関数です。
ユーザー(ブラウザ)がこのURLにアクセスしてきた場合は、<script type="text/javascript">window.location="/";</script>が実行されてルートURLにアクセスしSPAを表示します。

上記関数で生成したHTML文字列をResponseとして返却します。
https://github.com/stin-dev/thinking-generator/blob/master/functions/src/share.ts#L6-L18

/functions/src/share.ts
const share = async (request: functions.https.Request, response: functions.Response) => {
    const [, , uid] = request.path.split("/");

    try {
        await admin.auth().getUser(uid); // Check if a user with the specified uid exists. If user doesn't, throw error.
        response.set("Cache-Control", "public, max-age=600, s-maxage=600");
        const html = createHtml(uid);
        response.status(200).end(html);
    }
    catch (error) {
        response.status(404).end("404 Not Found");
    }
}

先駆者の方も述べていますが、キャッシュを設定しないとFunctionsのコール回数がすごいことになります。気を付けましょう。

"og:image""twitter:image"に指定するURLは

https://thinking-generator.web.app/ogp/{uid}

という形をしています。
これにより、TwitterクローラーはOGPの画像リソース取得のためhttps://thinking-generator.web.app/ogp/{uid}にアクセスしようとします。

Functions getOgpImage関数

Twitterクローラーがhttps://thinking-generator.web.app/ogp/{uid}にアクセスすると、firebase.jsonの設定によって実際に実行されるのがFunctionsのgetOgpImage関数になります。

https://github.com/stin-dev/thinking-generator/blob/master/functions/src/getOgpImage.ts#L6-L20

/functions/src/getOgpImage.ts
const getOgpImage = async (request: functions.https.Request, response: functions.Response) => {
    const [, , uid] = request.path.split("/");

    const ogpImage = storage.bucket().file(`ogp-images/${uid}.jpg`);

    if (!await ogpImage.exists()) {
        response.status(404).end("404 Not Found.");
        return;
    }

    response.set("Cache-Control", "public, max-age=600, s-maxage=600");
    response.writeHead(200, { "Content-Type": "image/jpeg" });

    ogpImage.createReadStream().pipe(response);
}

クライアントが保存してくれたogp-images/{uid}.jpgをStorageから取得してcreateReadStream()メソッドでresponseに流し込みます。
ここでもキャッシュの設定を忘れずに行いましょう。

まとめ

Firebase HostingのアクセスをFunctionsに委譲する設定、Twitterがmetaタグを収集するshare関数、Twitterが画像を取得するgetOgpImage関数の3つを正しく構築すればSPAでも動的OGP表示を行うことができます。

終わりに

Firebaseアプリの作業フォルダ、絶対にFunctionsとHostingで分けた方がいいなと開発中ずっと思っていました...。エディタが煩雑になる...。

リンク

Github: https://github.com/stin-dev
ポートフォリオ: https://stin-dev.github.io/
Twitter: https://twitter.com/stin_factory

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React入門 -なぜReactを使うのか-

はじめに

私がReactに出会い1年程度経過し、React自体にも大きな変化がありました。今回は、Reactの良さを知ってもらいたいと思い、筆を取りました。
また、改めて自分の中のReactの知識を整理しようというのがサブ目標です。何回かに分けて1つのアプリを作れる程度までを記事にまとめていきたいと思ってます。

筆者のステータスは以下の通りです

  • React・Next.jsを使って簡単なアプリを作ったことがある
  • アルバイトでSpringを使ったwebアプリの開発をしている(フロントはjQueryゴリゴリ)

Reactに出会って使用するに至った経緯

概略: jQueryで地獄を見たためReactに救いを求めました。

前述の通りjQueryを良く使用していて基本は問題なかったのですが、画面項目が増え動きがリッチになるにつれ実装難易度やメンテナンス性に限界を感じてきました。
そこでたまたま出会ったのがReactでした。しかし、調べてみても「MVCのVのみを担当するフレームワークで ──」「仮想DOMが──」などと説明されていても「は?」って感じで、イマイチどういったモノなのか掴めずにいました。
しかし実際に使ってみると、実にシンプルなフレームワーク/ライブラリであり、悩みを解決してくれる存在であることがわかりました。元々JSが好きだったのもありとても馴染みやすかったです。

Reactとは

公式ドキュメントには以下のように紹介されてます。

A JavaScript library for building user interfaces
ユーザインターフェース構築のための JavaScript ライブラリ

これだけではわからないので、主な機能の説明と実装例を用いて紹介していきたいを思います。

Reactを使うと何がいいのか?

こちらも公式ドキュメントのトップから抜粋

  1. Declarative (宣言的なView)
  2. Component-Based (コンポーネントベース)

もう1つ「Learn Once, Write Anywhere (一度学習すれば、どこでも使える)」ということが挙げられていますが、これは一旦スルーで。

Declarative (宣言的なView)

まず「宣言的」って何?というとこですね。
Reactで実装する際に私たちが実装するのは「ある状態において何が表示されるべきか」です。
「この値をここに表示して」「この場合はこっちを表示して」というのをただただ宣言していけば良いわけです。

そしてその宣言通りに効率良くUIの更新(DOMの操作)をする必要があります。
そこで登場するのが仮想DOMです。仮想DOMは通常のDOMと同じツリー構造をとります。
仮想DOMが構築し直されると、差分のみが実際のDOMに反映される、といった具合です。

v-dom.png

仮想DOMの構築はサーバーサイドレンダリング(SSR)でテンプレートエンジンを使ってHTMLを生成するのに良く似ています。SSRでの描画で画面の値が不整合になったなんていう記憶はないので、それに近い方法を取れる仮想DOMを使わない手はありません。
加えて、必要な箇所のDOMの更新しか行われないため、下手なピュアのJSよりもパフォーマンスが向上します。

Component-Based (コンポーネントベース)

ReactではUIの状態、見た目、動きをコンポーネントという単位で分割します。単純な入力欄からページまで全てをコンポーネントとみなします。コンポーネントを組みわせていくことでUIを構築していきます。
分割することで再利用が可能になり、全体的な見た目の統一感を生みます。

コンポーネントは自身の状態を管理し、見た目と振る舞いを定義します。そのため状態をDOMに置くことがなくなります。DOMにある値はどこからでも変更可能ですが、コンポーネントの状態はアクセスできるスコープが限られるので状態の遷移が追いやすくなります

Reactを書いてみる

九九を表示するアプリを考える

  1. 何の段を表示させるかを入力する
  2. 入力された段の九九の表を表示する

jQueryの場合

Reactで書く前に比較対象としてjQueryを使った場合の実装をのせておきます

See the Pen kuku_jQuery by nabekou (@nabekou29) on CodePen.

data属性にかける値を持たせておいて、値が入力された際に、data属性のあるtdタグのテキストを書き換えていく感じです。

Reactの場合

こちらがReactでの実装です。説明しやすさも考えてTypeScriptで書いてます。

See the Pen kuku_React by nabekou (@nabekou29) on CodePen.

解説

コンポーネントの定義.tsx
const App: React.FunctionComponent<{defaultValue: number}> = ({defaultValue}) => {...}

この関数がコンポーネントです。(Classで定義する方法もあります。)
属性を引数に受け取ることができます。

状態を扱う.tsx
const [num1, setNum1] = React.useState(defaultValue);

React.useStateを使うことでこのコンポーネントで状態を持つことができます。
num1が状態、setNum1num1を更新するための関数です。setNum1を通して値を更新することでReactに値が変わったことを知らせています。

JSX.tsx
return (
  <div>
    <input type="number" value={num1} onChange={handleNum1Change} />の段
    <table>
      <tbody>
        <tr><th>1</th><td>{1 * num1}</td></tr>
        {/* 省略... */}
      </tbody>
    </table>
  </div>
);

関数のコンポーネントでは、React要素returnします。React要素はJSXにより作成可能です。コンパイルすることでReact要素を作成する関数に変換されます。(変換後はCodePenの「View Compiled」ボタンから確認できます)。
このHTMLっぽい書き方がJSXです。あくまでそれっぽいだけなので、本来存在しない属性や一部名前が変わっている属性が存在します。JSXのなかで{}で囲まれた中にJSを書くことが可能です。当然そのJS内でもJSXを書けます。

データバインディング.tsx
const handleNum1Change = (event: React.ChangeEvent<HTMLInputElement>) => { 
  setNum1(Number(event.target.value));
}

値を入力した時にnum1を更新します。setNum1を使って更新することで表の値が更新されます。

ReactDOM.render(
  <App defaultValue={1}/>,
  document.getElementById('app')
);

React要素を#appに描画しています。

ちなみに

before.tsx
<table>
  <tbody>
    <tr><th>1</th><td>{1 * num1}</td></tr>
    <tr><th>2</th><td>{2 * num1}</td></tr>
    <tr><th>3</th><td>{3 * num1}</td></tr>
    ...
    <tr><th>9</th><td>{9 * num1}</td></tr>
  </tbody>
</table>

HTMLに寄せて書きましたが、Arrayを用いて以下のように書くことが出来ます。

after.tsx
<table>
  <tbody>
    {
      [1, 2, 3, 4, 5, 6, 7, 8, 9].map(num2 => (
        <tr key={num2}><th>{num2}</th><td>{num1 * num2}</td></tr>
      ))
    }
  </tbody>
</table>

比べてみる

  1. Declarative (宣言的なView)
  2. Component-Based (コンポーネントベース)

上の2つを意識して比べてみようと思います

宣言的なView 的には?

特に<td>{num1 * num2}</td>のところがとてもわかりやすくなっていると思います。表に何が表示されるかがより明らかになりました。
また、更新がとても楽になっています。jQueryでは各td要素にアクセスして計算・更新をしていましたが、Reactではnum1を更新するだけで表の値が更新されています。

コンポーネントベース 的には?

見た目と動きが一体となったことで、状態の変化がどのように行われて、どういった作用をもたらすのかがぱっと見で分かるようになりました。JSとHTMLを行ったりきたりする必要が無くなりました。
さらに、変にセレクタを書いたりする必要性が無くなりました。どうせ密な結合になるならこっちの方がわかりやすいと思います。

おわりに

Reactは学習コストが重いと良く言われている気がしますが、覚えることは圧倒的に少ないです。ただJSに対する知識がそれなりに求められるので、そこが難しいところなのかなと思います。Hooksの登場で書きやすさもかなり向上したので、これからも周りに布教していきたいと思います。
次はもう少し実装よりの話をする予定です。

参考

React公式
Reactを使うとなぜjQueryが要らなくなるのか
ReactとVueのどちらを選ぶか
フロントエンドのコンポーネント設計に立ち向かう

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む