ReactとTypeScriptによる会員制シングルページアプリケーション(SPA)の構築

  • 5
    いいね
  • 0
    コメント

以前に作成していた「Node.jsによるSNSアカウント認証と二段階認証」(以下WST)をReactでリプレースしました。どうして?元々は独自のフレームワークで実装していましたが、あるプロジェクトでReactを使う機会があり、とても気に入ったからです。React化そのものは数日で完了。しかしそこで終わりではありませんでした。ついでだからSPAにしよう、いまどき必要ないかもだけど初期表示で一瞬画面が表示されないのも気になるからサーバーサイドレンダリング(SSR)もやっておくか、という具合にとうとう一月以上の時間を費やすことになってしまいました。

ソースコードはこちら
web service template 3.0.1

スタンドアローンかのごとく

ウェブなので構造的にはクライアントとサーバーに分かれているわけですが、スタンドアローンのアプリに見立ててみるとサーバーというのはライブラリのようなものです。ですので間にネットワークがあるということを極力意識しないような作りを目指しました。例えばクライアントでawait UserApi.getUser({id:params.id});とコールすればサーバーのstatic getUser(param : Request.GetUser, req : express.Request)が実行されます。SSRでawait UserApi.getUser({id}, req);とコールした場合も同様です。ただしクライアント側のUserApi.getUserとサーバー側のUserApi.getUserは同じものではありません。具体的には次のような実装になっています(簡略化してあります)。

クライアント側
export default class UserApi extends Api
{
    static getUser(param : Request.GetUser)
    {
        return new Promise(async (resolve : (res : Response.GetUser) => void, reject) =>
        {
            const url = `/api/user`;

            const {ok, data} = await Api.sendGetRequest(url, param);
            Api.result(ok, data, resolve, reject);
        });
    }
サーバー側
export default class UserApi
{
    static getUser(param : Request.GetUser, req : express.Request)
    {
        return new Promise(async (resolve : (data : Response.GetUser) => void, reject) =>
        {
            const locale = req.ext.locale;
            const data : Response.GetUser = {};
            const id = <number>param.id;
            const account = await AccountModel.find(id);
            ・・・
            resolve(data);
        });
    }

RequestパラメータとResponseデータの共通化

先ほどの例で何の説明もなくRequest.GetUserResponse.GetUserというものが出てきました。これらはクライアントとサーバーで使えるように共通化したインターフェースで、APIに変更があった場合でも差異が発生しないようにしてあります。定義を見てみましょう。

export namespace Request
{
    export interface GetUser
    {
        id : number | any[];
    }
export namespace Response
{
    export interface User
    {
        id   : number;
        name : string;
    }

    export interface GetUser
    {
        status?  : number;
        user?    : User;
        message? : string;
    }

特に難しいところはありませんね。しかしRequest.GetUserのnumber | any[]という記述はなんでしょう。

Requestパラメータの検証

サーバーではRequestパラメータの検証が必須ですが、クライアントとサーバーでAPIに差異が生じては困るのと同じようにパラメータと検証内容に差異が生じてもいけません。なにかうまくやる方法はないかと考え、このような定義にしました。以下の実例をみてください。

export default class UserApi
{
    static async onGetUser(req : express.Request, res : express.Response)
    {
        do
        {
            const locale = req.ext.locale;
            const param     : Request.GetUser = req.query;
            const condition : Request.GetUser =
            {
                id: ['number', null, true]
            }

            if (Utils.existsParameters(param, condition) === false)
            {
                res.ext.error(-1, R.text(R.BAD_REQUEST, locale));
                break;
            }

            const data = await UserApi.getUser(param, req);
            res.json(data);
        }
        while (false);
    }

検証用のconditionの型もパラメータと同じくRequest.GetUserですが内容はnumberではなく配列です。配列は先頭から型、デフォルト値、必須か否かを定義しています。

SSRとStore

WSTではFluxの思想に基づいてStoreを使っていますがFluxでもなくReduxでもありません。概念を取り入れただけです。実プロジェクトでReduxを使った経験から言っても必要ないなあというのが感想です。さて、SSRで使用したStoreはレスポンスのhtmlに埋め込んでクライアントに渡すようにしています。これはクライアントでの初期表示の際に改めてAPIをコールしなくて済むようにするためで、取得系のAPIはSPAで画面遷移する時などにコールします。少し長いですが以下のコードで雰囲気を掴んでもらえればと思います。クライアント側のUserApp.init()は初期表示では呼ばないようにしています。

サーバー側
import {Store} from 'client/components/views/user-view/store';

export default class UsersController
{
    static async user(req : express.Request, res : express.Response)
    {
        const locale = req.ext.locale;
        const id = Number(req.params.id);

        const data = await UserApi.getUser({id}, req);

        if (data.user)
        {
            const store : Store =
            {
                locale: locale,
                user:   data.user
            };

            const title = ClientR.text(ClientR.USER, locale);
            const el = <UserView store={store} />;
            const contents = ReactDOM.renderToString(<Root view={el} />);
            res.send(view(title, 'wst.js', contents, store));
        }
        else
        {
            notFound(req, res);
        }
    }
クライアント側
import {Store} from '../components/views/user-view/store';
const ssrStore = Utils.getSsrStore<Store>();

export default class UserApp extends App
{
    private store : Store;

    constructor()
    {
        super();
        this.store =
        {
            locale: Utils.getLocale(),
            user:   ssrStore.user,
            onBack: this.onBack.bind(this)
        };
    }

    init(params, message? : string)
    {
        return new Promise(async (resolve : () => void, reject) =>
        {
            try
            {
                const {store} = this;
                const res = await UserApi.getUser({id:params.id});
                store.user = res.user;
                resolve();
            }
            catch (err) {reject(err)}
        });
    }

    view() : JSX.Element
    {
        return <UserView store={this.store} />;
    }

クライアントサイドルーティング

SPAでの画面遷移時はHTML5のhistoryを使うので、URLルーティングはクライアントで処理する必要があります。WSTではReact Routerは使わず自前で行っています。よく分からんということと、ログイン済みかどうかや遷移時のエフェクトをつけたいなどの理由からそのようにしましたが、それほど難しくもなく結果として満足のいく実装ができました。詳しくはsrc/client/app/wst.tsxを見ていただくとして、ルーティングテーブルは以下のようにシンプルなものです。

        const loginApp = new LoginApp();
        this.routes =
        [
            {url:'/',          app:new TopApp(),      title:R.text(R.TOP,            locale), effect:'fade', auth:true},
            {url:'/',          app:loginApp,          title:R.text(R.LOGIN,          locale), effect:'fade'},
            {url:'/about',     app:loginApp,          title:R.text(R.ABOUT,          locale), effect:'fade'},
            {url:'/reset',     app:new ResetApp(),    title:R.text(R.RESET_PASSWORD, locale), effect:'fade', query:true},
            {url:'/users/:id', app:new UserApp(),     title:R.text(R.USER,           locale), effect:'fade'},
            {url:'/users',     app:new UsersApp(),    title:R.text(R.USER_LIST,      locale), effect:'fade'},
            {url:'404',        app:new NotFoundApp(), title:R.text(R.NOT_FOUND,      locale), effect:'fade'},
        ];

React、Node.js、そしてTypeScriptに感謝!