49
37

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

React+ExpressでSSRしてみる +α with TypeScript

Last updated at Posted at 2018-07-12

はじめに

最近の海外のスタートアップのウェブサイトなんかを見てみると、アプリケーションではない普通のWebページをReactで作ってるところが多いですね。SPAといったときに何を指すのかという定義論は横に置いとくとしても、通常のWeb制作においてもコンポーネント志向でページを作成するのはいいことだと思っています。

しかしReactでシンプルに実装してしまうと、SEO対策の一環として行うOGPの設定などが問題となります。GoogleのクローラのようにJavascriptの解釈まで行ってくれるのであればなんの問題もないのですが、SNSへの投稿時にOGPで設定した画像などが表示されないのはやや困りものです。

この問題に対応するために、ぐぐってみるとSSRという、リクエストに対してサーバ側で解釈してからレスポンスとして返すという処理をするようですが、なにしろドキュメントが散在しており、どのように実装すればいいのかがわからない…。
ということで、先述のドキュメント散在に対する問題提起とは自己矛盾しますが、執筆時点でこうしとけばよかろうという観点でReactをSSRする方法について、参考にしたURLなんかと一緒に書きとめておきます。

何を作るか

  • styled-componentsで装飾されたReactベースのWebページをSSRする、その際OGPの設定も当然行う
  • react-routerとredux + react-reduxなどの一般的なルーティングと状態管理の導入をしておく
  • 当然TypeScriptベースでかいてゆく

なにはともあれ環境構築

必要なパッケージのインストール

なにはともあれ、必要なパッケージのインストールをしましょう。react-helmetでogpを設定します。その他はいつものやつらです。まるっと、インストールするためのコマンドは以下の通り。

npm install --save express react react-dom react-helmet react-redux react-router react-router-dom connected-react-router recompose redux styled-components
npm install --save-dev webpack webpack-cli webpack-node-externals typescript ts-loader @types/express @types/react @types/react-dom @types/react-helmet @types/react-redux @types/react-router @types/react-router-dom @types/recompose

ここでscriptsの設定もしておきましょう。

package.json
{
+  "scripts": {
+    "build": "webpack --config webpack.config.server.js&&webpack --config webpack.config.client.js",
+    "start": "npm run build&&node dist/server.js"
+  },
  ...  
}

webpack/tsconfigなどの設定

server側と、client側両方の設定が必要です。
client側はいつもどおりでいいのですが、server側は次のように気にするべき点が少しあります。

上記を解決した設定ファイル群は以下の通り

webpack.config.client.js
const path = require('path');

module.exports = {
    mode: 'production',
    entry: './src/client.tsx',
    module: {
        rules: [
            {
                loader: 'ts-loader',
                test: /\.tsx?$/,
                exclude: [
                    /node_modules/
                ],
                options: {
                    configFile: 'tsconfig.client.json'
                }
            }
        ]
    },
    resolve: {
        extensions: [ '.tsx', '.ts', '.js' ]
    },
    output: {
        filename: 'static/js/bundle.js',
        path: path.resolve(__dirname, 'dist/public')
    }
};
webpack.config.server.js
const path = require('path');
const nodeExternals = require('webpack-node-externals');

module.exports = {
    mode: 'production',
    entry: './src/server.tsx',
    target: 'node',
    node: {
        __dirname: false,
        __filename: false,
    },
    externals: [nodeExternals()],
    module: {
        rules: [
            {
                loader: 'ts-loader',
                test: /\.tsx?$/,
                exclude: [
                    /node_modules/
                ],
                options: {
                    configFile: 'tsconfig.server.json'
                }
            }
        ]
    },
    resolve: {
        extensions: [ '.tsx', '.ts', '.js' ]
    },
    output: {
        filename: 'server.js',
        path: path.resolve(__dirname, 'dist')
    }
};
tsconfig.client.json
{
    "compilerOptions": {
      "noImplicitAny": true,
      "module": "es6",
      "target": "es5",
      "jsx": "react",
      "lib": ["es2018", "dom"],
      "moduleResolution": "node",
      "removeComments": true,
      "strict": true,
      "noUnusedLocals": true,
      "noUnusedParameters": true,
      "noImplicitReturns": true,
      "noFallthroughCasesInSwitch": true
    },
    "exclude": [
      "src/server.tsx",
      "node_modules"
    ]
  }
tsconfig.server.json
{
    "compilerOptions": {
      "noImplicitAny": true,
      "module": "es6",
      "target": "es5",
      "jsx": "react",
      "lib": ["es2018", "dom"],
      "moduleResolution": "node",
      "removeComments": true,
      "strict": true,
      "noUnusedLocals": true,
      "noUnusedParameters": false,
      "noImplicitReturns": true,
      "noFallthroughCasesInSwitch": true
    },
    "exclude": [
      "src/client.tsx",
      "node_modules"
    ]
  }

最初のコード

まずは、ルートのページとaboutusのページの2ページ構成で、redux、react-redux、connected-react-routerまで一気に組み込んでしまいましょう。ここらへんは決まった書き方をするだけなので、手が覚えるまで書いたらスクリプト化してしまうのが良いと思ってます。そのあとで、クライアントサイドとサーバーサイドのエンドポイントを記述します。

共通の部分

StoreやRouterはクライアントサイドとサーバーサイドで変えるので、その中身だけです。
HomeとAboutUsはとりあえず、そのページ認識できる程度に実装しておきましょう。

src/Routes.tsx
import * as React from 'react';
import { Route } from 'react-router';
import { Switch } from 'react-router-dom';

import Home from './views/Home';
import AboutUs from './views/AboutUs';

const component: React.SFC = () => {
    return (
        <div>
            <Switch>
                <Route exact path={'/'} component={Home} />
                <Route exact path={'/contact'} component={AboutUs} />
            </Switch>
        </div>
    );
};

export default component;
src/views/Home.tsx
import * as React from 'react';

const component: React.SFC = () => {
    return <div>There is home.</div>
};

export default component;
src/views/AboutUs.tsx
import * as React from 'react';

const component: React.SFC = () => {
    return <div>There is about us.</div>
};

export default component;

また、ここでreduxとconnected-react-routerもやっておきます。
オリジナルのreducerなんかも、ここに入るのですが足元は何もなしとしておきます。

src/modules/index.ts
import { combineReducers } from 'redux';
import { RouterState } from 'connected-react-router';

export type RootState = {
    router: RouterState,
};

export const rootReducer = combineReducers<RootState>({
} as any);

export const initState = (): Partial<RootState> => {
    return {};
};

サーバーサイドのエンドポイント

参考: https://github.com/ReactTraining/react-router/pull/5714/files/e437e3670f54a1ff20e8b0d34dec0e75bfd83598#diff-cd2fa3ad1089ddd67bbb2251387aa549

ここは結構面倒です。クライアントサイドでも使い回す予定のStoreを構築するコードとexpressサーバーでの振る舞いを記述したコードを追加します。
前者はhistoryの情報もまとめて設定してあげること、後者はレンダリングするHTMLに状態を渡すためのフィールドを用意しておくのがポイントです。

src/isormorphic/store.ts
import { History } from 'history';
import { createStore, applyMiddleware } from 'redux';
import { connectRouter, routerMiddleware } from 'connected-react-router';
import { rootReducer, RootState } from '../modules';

export const configureStore = (
    initialState: Partial<RootState>,
    history: History) => {
    const middleWare = applyMiddleware(routerMiddleware(history));
    const store = createStore(
        connectRouter(history)(rootReducer),
        initialState,
        middleWare);
    return {
        history,
        store,
    };
};
src/isormorphic/render.ts
export const render = (content: string,
                       state: any) => {
    return `
        <!DOCTYPE html>
        <html lang="ja">
            <head>
                <meta charset="utf-8">
                <meta name="viewport" content="width=device-width, initial-scale=1" />
            </head>
            <body>
                <div id="root">${content}</div>
                <script>
                    window.INITIAL_STATE = ${JSON.stringify(state)};
                </script>
                <script type="text/javascript" charset="utf-8" src="static/js/bundle.js" async></script>
            </body>
        </html>
    `;
};
src/server.tsx
import * as Express from 'express';
import * as React from 'react';
import { Provider } from 'react-redux';
import { ConnectedRouter } from 'connected-react-router';
import { renderToString } from 'react-dom/server';
import createMemoryHistory from 'history/createMemoryHistory';

import Routes from './Routes';
import { render } from './isormophic/render';
import { configureStore } from './isormophic/store';
import { initState } from './modules';

const app = Express();

app.use(Express.static(__dirname + '/public')); //bundle.jsを読み込むために
app.get(
    '*',
    (req: Express.Request, res: Express.Response) => {
        const { store, history } = configureStore(
            initState(),
            createMemoryHistory({
                initialEntries: [req.url],
                initialIndex: 0,
            }));
        const content = renderToString(
            <Provider store={store}>
                <ConnectedRouter history={history}>
                    <Routes />
                </ConnectedRouter>
            </Provider>);
        res.write(render(content, store.getState()));
        res.end();
    });

app.listen(
    3000,
    () => {
        console.log('app listening on port 3000!');
    });

クライアントサイドのエンドポイント

参考: https://reactjs.org/docs/react-dom.html#hydrate

クライアントサイドでは、windowに状態引き渡しのためのINITIAL_STATEを宣言しておきます。
また、SSRのときはReactDOM.renderではなく、ReactDOM.hydrateのほうがパフォーマンスがいいためこちらを使います。

src/client.tsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { ConnectedRouter } from 'connected-react-router';
import createBrowserHistory from 'history/createBrowserHistory';

import Routes from './Routes';
import { initState, RootState } from './modules';
import { configureStore } from './isormorphic/store';

declare var window: { INITIAL_STATE: Partial<RootState>; };

const initialState: Partial<RootState> = window.INITIAL_STATE || initState();
delete window.INITIAL_STATE;

const preload = configureStore(
    initialState,
    createBrowserHistory());

ReactDOM.hydrate(
    <Provider store={preload.store}>
        <ConnectedRouter history={preload.history}>
            <Routes />
        </ConnectedRouter>
    </Provider>,
    document.getElementById('root'));

こいつを高度化していく

上記まででも、一通りの実装は終わっておりReactがSSRできている状態になります。実際にnpm startしてみてhttp://localhost:3000 にcurlしてみるとわかります。

Your-PC:~$ curl http://localhost:3000

        <!DOCTYPE html>
        <html lang="ja">
            <head>
                <meta charset="utf-8">
                <meta name="viewport" content="width=device-width, initial-scale=1" />
            </head>
            <body>
                <div id="root"><div data-reactroot=""><div>There is home.</div></div></div>
                <script>
                    window.INITIAL_STATE = {"router":{"location":{"pathname":"/","search":"","hash":"","key":"gsov5y"}}};
                </script>
                <script type="text/javascript" charset="utf-8" src="static/js/bundle.js" async></script>
            </body>
        </html>

しかし、タイトルにも記述してあるとおりogpの設定などをしたり、その他にもstyled-componentsの適用などをしたいので、どんどん追加実装していきます。

ogpを設定する

参考: https://github.com/nfl/react-helmet#server-usage

まずは、react-helmetでheaderを設定します。

src/views/Home.tsx
import * as React from 'react';
import ReactHelmet from 'react-helmet'; 

const component: React.SFC = () => {
    return (
        <div>
            <ReactHelmet>
                <title>SSR sample</title>
                <meta name="description" content={'There is home.'} />
            </ReactHelmet>
            There is home.
        </div>
    )
};

export default component;
src/views/AboutUs.tsx
import * as React from 'react';
import ReactHelmet from 'react-helmet'; 

const component: React.SFC = () => {
    return (
        <div>
            <ReactHelmet>
                <title>SSR sample</title>
                <meta name="description" content={'There is about us.'} />
            </ReactHelmet>
            There is about us.
        </div>
    );
};

export default component;

ここで、設定したheaderは、SSRするときはあらかじめ明示的にrenderしておく必要があります。
server.tsrenderStaticして、それらをhtmlに埋め込みます。

src/server.tsx
import * as Express from 'express';
import * as React from 'react';
import { Provider } from 'react-redux';
import { ConnectedRouter } from 'connected-react-router';
import { renderToString } from 'react-dom/server';
import ReactHelmet from 'react-helmet';
import createMemoryHistory from 'history/createMemoryHistory';

import Routes from './Routes';
import { render } from './isormorphic/render';
import { configureStore } from './isormorphic/store';
import { initState } from './modules';

const app = Express();

app.use(Express.static(__dirname + '/public'));
app.get(
    '*',
    (req: Express.Request, res: Express.Response) => {
        const { store, history } = configureStore(
            initState(),
            createMemoryHistory({
                initialEntries: [req.url],
                initialIndex: 0,
            }));
        const content = renderToString(
            <Provider store={store}>
                <ConnectedRouter history={history}>
                    <Routes />
                </ConnectedRouter>
            </Provider>);
        res.write(render(content, store.getState(), ReactHelmet.renderStatic()));
        res.end();
    });

app.listen(
    3000,
    () => {
        console.log('app listening on port 3000!');
    });
src/isormorphic/render.ts
import { RootState } from "../modules";
import * as ReactHelmet from 'react-helmet';

export const render = (content: string,
                       state: RootState,
                       header: ReactHelmet.HelmetData) => {
    return `
        <!DOCTYPE html>
        <html lang="ja">
            <head>
                <meta charset="utf-8">
                <meta name="viewport" content="width=device-width, initial-scale=1" />
                ${header.title.toString()}
                ${header.meta.toString()}
            </head>
            <body>
                <div id="root">${content}</div>
                <script>
                    window.INITIAL_STATE = ${JSON.stringify(state)};
                </script>
                <script type="text/javascript" charset="utf-8" src="static/js/bundle.js" async></script>
            </body>
        </html>
    `;
};

これでogpの設定は完了です。また、試しにcurlしてみます。

Your-PC:~$ curl http://localhost:3000

        <!DOCTYPE html>
        <html lang="ja">
            <head>
                <meta charset="utf-8">
                <meta name="viewport" content="width=device-width, initial-scale=1" />
                <title data-react-helmet="true">SSR sample</title>
                <meta data-react-helmet="true" name="description" content="There is home."/>
            </head>
            <body>
                <div id="root"><div data-reactroot=""><div>There is home.</div></div></div>
                <script>
                    window.INITIAL_STATE = {"router":{"location":{"pathname":"/","search":"","hash":"","key":"2ebwat"}}};
                </script>
                <script type="text/javascript" charset="utf-8" src="static/js/bundle.js" async></script>
            </body>
        </html>

styled-componentsをいれてみる

参考: https://www.styled-components.com/docs/advanced#server-side-rendering

styled-componentsもhelmet同様、SSR時は微妙なお作法が存在します。style付きのnavigation-barを追加してみます。
よくある、横並びでセパレータとして縦棒が入ってるやつです。現在表示中のページだけは押せないようにしておきます。

src/views/NavigationBar.tsx
import * as React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import styledComponents from 'styled-components';
import { RootState } from '../modules';

type OwnProps = {
    className?: string;
    items: {
        display: string;
        to: string;
    }[];
};

type StateProps = {
    enabled: boolean[];
}

type Props = OwnProps & StateProps;

const component: React.SFC<Props> = (props: Props) => {
    return (
        <ul className={props.className}>
            {
                props.items.map((e, i) => {
                    return (
                        <li key={e.to}>
                            {
                                props.enabled[i] 
                                    ? <Link to={e.to}>{e.display}</Link>
                                    : <span>{e.display}</span>
                            }
                        </li>
                    );
                })
            }
        </ul>
    );
};

const mapStateToProps = (state: RootState, ownProps: OwnProps) => {
    const loc = state.router.location;
    return {
        enabled: ownProps.items.map(e => loc == null ? true : e.to !== loc.pathname),
    };
};

const enhancedComponent = connect(
    mapStateToProps
)(component);

export default styledComponents(enhancedComponent)`
    list-style-type: none;
    margin: 0;
    padding: 10px 0px;
    li {
        display: inline-block;
        padding: 0px 5px;
        &+ li {
            border-left: 1px solid gray;
        }
    }
`;

共通部分なので、Switchの外側に置きましょう。

src/Routes.tsx
import * as React from 'react';
import { Route } from 'react-router';
import { Switch } from 'react-router-dom';

import Home from './views/Home';
import AboutUs from './views/AboutUs';
import NavigationBar from './views/NavigationBar';

const component: React.SFC = () => {
    return (
        <div>
            <NavigationBar 
                items={[
                    { display: 'Home', to: '/' },
                    { display: 'About us', to: '/aboutus' },
                ]} 
            />
            <Switch>
                <Route exact path={'/'} component={Home} />
                <Route exact path={'/aboutus'} component={AboutUs} />
            </Switch>
        </div>
    );
};

export default component;

スタイルをSSRで適用するには、ServerStyleSheetを使って、実際に出力されるスタイルタグを収集してから、それをヘッダの中に埋め込む必要があります。

src/server.tsx
import * as Express from 'express';
import * as React from 'react';
import { Provider } from 'react-redux';
import { ConnectedRouter } from 'connected-react-router';
import { renderToString } from 'react-dom/server';
import ReactHelmet from 'react-helmet';
import { ServerStyleSheet } from 'styled-components';
import createMemoryHistory from 'history/createMemoryHistory';

import Routes from './Routes';
import { render } from './isormorphic/render';
import { configureStore } from './isormorphic/store';
import { initState } from './modules';

const app = Express();

app.use(Express.static(__dirname + '/public'));
app.get(
    '*',
    (req: Express.Request, res: Express.Response) => {
        const { store, history } = configureStore(
            initState(),
            createMemoryHistory({
                initialEntries: [req.url],
                initialIndex: 0,
            }));
        const sheet = new ServerStyleSheet(); //<--こいつがstyleタグを収集してくれる
        const content = renderToString(
            sheet.collectStyles(
                <Provider store={store}>
                    <ConnectedRouter history={history}>
                        <Routes />
                    </ConnectedRouter>
                </Provider>));
        res.write(
            render(content,
                   store.getState(),
                   ReactHelmet.renderStatic(),
                   sheet.getStyleTags()));
        res.end();
    });

app.listen(
    3000,
    () => {
        console.log('app listening on port 3000!');
    });
src/isormorphic/render.ts
import { RootState } from "../modules";
import * as ReactHelmet from 'react-helmet';

export const render = (content: string,
                       state: RootState,
                       header: ReactHelmet.HelmetData,
                       styleTag: string) => {
    return `
        <!DOCTYPE html>
        <html lang="ja">
            <head>
                <meta charset="utf-8">
                <meta name="viewport" content="width=device-width, initial-scale=1" />
                ${header.title.toString()}
                ${header.meta.toString()}
                ${styleTag}
            </head>
            <body>
                <div id="root">${content}</div>
                <script>
                    window.INITIAL_STATE = ${JSON.stringify(state)};
                </script>
                <script type="text/javascript" charset="utf-8" src="static/js/bundle.js" async></script>
            </body>
        </html>
    `;
};

できあがったソース群

まとめ

個人的にはgoogleのクローラもjavascriptを解釈してくれる中で、SSRは基本的には不要だと思ってます。それでもいろいろな事情の中で必要になったときには、next.js、after.jsやgatsby.jsなどを使うことで簡単に実装できます。

ただ、勉強がてら一度くらい自分で手を動かしておくと、それらのツールチェインを使うときの助けになると思います。

49
37
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
49
37

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?