【フロントエンド編】TECH LEADの技術を惜しげも無く公開します!React × PHP Laravel × BEAR.Sunday × SSR
こんにちは、@TECH LEADです。
前回の「【アーキテクチャー編】TECH LEADの技術を惜しげも無く公開します!」は、たくさんの方に読んでいただきありがとうございました。
第二回となる今回は、TECH LEADサービスでのフロントエンドの実装をご紹介します。
目次
はじめに
最近、ReactやVueなどのJavaScriptフレームワークを使い、フロントエンドの開発を進める企業やサービスが増えてきていると思います。どんどん進化して行くフロントエンドの技術をどのように実装して行くか悩む方がたくさんいると思います。TECH LEADサービスでもフロントエンドの開発でとても悩みました。
弊社では約1年半くらい前からReactを使用したアプリケーションを開発してきました。
最初に使用したサービスではReact × React Router × ReduxでSPA(シングルページアプリケーション)を作成し、次のサービスではReact × React Router × Typescript × ReduxでSPAを作成しました。
TECH LEADサービスではReact × TypescriptでSSR(サーバーサイドレンダリング)+CSR(クライアントサイドレンダリング)で作成しています。
「なんでTECH LEADはSPAじゃないのか?」と疑問に思った方もいると思いますので、その理由を説明したいと思います。
SPAを辞めたわけ
2つのサービスをSPAで作成しましたが、開発中にSPAである必要があったのかと考えさせられることがあったので他の手段や方法を検討するためにもTECH LEADでは、一旦SPAをやめて開発をすることにしました。
SPAを開発する上で大きく3つの課題がありました。
- セッション管理が大変
- Reduxの処理を書くのが大変
- SEOやOGPなどの対応が難しい
SPAを使った場合、ルーティングの設定、ログイン認証処理などフロントエンドでやることが増えます。 SSR/CSRの場合、ルーティングの設定もセッション管理も完全にサーバーサイドに任せてしまえばいいので、ページを描画することだけに専念させることができ、SPAをやめたことでReduxを使う必要もなくシンプルにコードを書くことができました。
※Reduxはとても便利ですが、コードを書く量が多く、保存されたデータの初期化のタイミングなど考えることが多く大変でした。。。
また、パブリックなページに関しては、SEOやOGPなどの設定や処理を入れるのが大変そうだったり、きちんと評価されるか不安だったため、確実に対応できそうなSSRで対応することにしました。
※以前の2つのサービスは、プライベートなページのみだったのでSEOを気にする必要はなかった
Reactを勉強している方や使用を検討している方に少しでも参考になるよう、一部にはなりますが詳しく公開したいと思いますので、よろしくお願いします。
TECH LEADのフロントエンド
主な技術
React 16.4
Typescript 2.6
Sass
webpack 4
ディレクトリ構成
. ├── common/ # `web/site-common/js`へのシンボリックリンク | # 各サービス共通で使用する関数やコンポーネントを配置 ├── components/ # ステートレスコンポーネントを配置 ├── containers/ # renderされるメインのステートフルコンポーネントを配置 ├── modules/ # api通信用の関数を配置 ├── pages/ # SSR時に使用するコンポーネントを配置 ├── const.d.ts # Typescriptの型定義ファイル ├── index.tsx # CSR用のエントリーポイント ├── index_csr.tsx # SSR後にCSRする用のエントリーポイント └── index_ssr.tsx # SSR用のエントリーポイント
サーバーサイドレンダリングに関して
今回はTECH LEAD JobがどのようにSSRされ、ページを作っているか実際のコードを元に説明します。
※サーバーサイドの実装の詳細は、次回「サーバーサイド(アプリケーション層)編」で公開します。
実際に使用するファイル(一部抜粋)
. ├── pages/ │ ├── Index.tsx # トップページのコンポーネント │ ├── layout.tsx # レイアウトファイル │ └── variables.tsx # コンポーネントをまとめたもの ├── index_csr.tsx # SSR後にCSRする用のエントリーポイント └── index_ssr.tsx # SSR用のエントリーポイント
# index_ssr.tsx import * as React from 'react'; import {renderToString} from 'react-dom/server'; import Helmet from 'react-helmet'; import {layout} from './pages/layout'; import {pages} from './pages/variables'; const render = (page: string, payload: object) => { const element = pages[page]; const component = renderToString( <React.Fragment> <Helmet> <title>IT/WEBエンジニア専門の求人サービス|TECH LEAD Job</title> <meta name="description" content="TECH LEAD Job(テックリードジョブ)は、IT・WEBエンジニア専門の求人サービスです。"/> <meta property="og:title" content="IT/WEBエンジニア専門の求人サービス|TECH LEAD Job"/> <meta property="og:type" content="website"/> <meta property="og:url" content="https://job.techlead.jp/"/> <meta property="og:image" content="https://job.techlead.jp/img/sns-ogp.png"/> <meta property="og:site_name" content="TECH LEAD Job"/> <meta property="og:description" content="TECH LEAD Job(テックリードジョブ)は、IT・WEBエンジニア専門の求人サービスです。"/> <meta name="twitter:card" content="summary_large_image"/> <meta property="fb:app_id" content="762141787490152"/> </Helmet> {React.cloneElement(element, {...payload})} </React.Fragment> ); return layout(component); }; declare const global: {render: any}; global.render = render;
index_ssr.tsx
はSSR用のエントリーポイントになります。
サーバーサイドではV8jsを利用して、「SSR用のエントリーポイント」を読み込み、グローバルに用意したrender関数を実行・HTMLを生成・HTMLを返します。
render関数には、呼び出すコンポーネント(ページ)のキーとPropsの値を渡します。
React.cloneElement(element, {...payload})
の部分で対象のコンポーネントを渡されたPropsで生成し、renderToString関数でコンテンツ部分のHTMLを取得します。
コンテンツ部分のHTMLができたらlayout関数で、ページに必要なheadの情報や共通部分を追加したHTMLを生成し、HTMLを返します。
サーバーサイドは、この生成されたHTMLをレンダーしてページを表示します。
# variables.tsx import * as React from 'react'; import {defaultValues as indexValues, Index} from './Index'; export const pages: { [key: string]: JSX.Element; } = { index: <Index {...indexValues}/>, };
※呼び出すコンポーネント(ページ)とキーの組み合わせをまとめている
# layout.tsx import Helmet from 'react-helmet'; export const layout = (body: string) => { const helmet = Helmet.renderStatic(); return ( ` <!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0,maximum-scale=1.0,user-scalable=no"> <meta name="format-detection" content="telephone=no"> <link rel="shortcut icon" href="/img/favicon/favicon.ico"> <link rel="apple-touch-icon" sizes="180x180" href="/img/favicon/apple-touch-icon.png"> <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon/favicon-16x16.png"> <meta name="msapplication-TileColor" content="#da532c"> <meta name="theme-color" content="#ffffff"> ${helmet.title.toString()} ${helmet.meta.toString()} ${helmet.link.toString()} ${helmet.script.toString()} <link href="https://use.fontawesome.com/releases/v5.1.0/css/all.css" rel="stylesheet"> <link href="${VARIABLES.path.index_css_path}" rel="stylesheet" type="text/css"> </head> <body> <div id="flashMessage" data-payload="${VARIABLES.flash_message}"> </div> ${body} <script src="${VARIABLES.path.index_js_path}"></script> </body> </html> ` ); };
※ページheadの情報は、React Helmetを使用してページごとに設定
※VARIABLESはサーバーサイドで設定した定数
# Index.tsx import * as React from 'react'; import Helmet from 'react-helmet'; import SimpleSearchForm from '../containers/SimpleSearchForm'; import {LpJobCard} from '../components/LpJobCard'; import {agentUrl, jobUrl, resumeUrl, mypageUrl} from '../../common/util'; import {PostingModel} from '../../common/lib/models/JobInterface'; export const defaultValues = { postings: [], }; interface Props { postings: PostingModel[]; } export const Index: React.SFC<Props> = ({postings}) => { return ( <React.Fragment> <Helmet> <script type="text/javascript" src="delighters.js"/> </Helmet> <header className="head head--lp"> <div className="head__inner"> <a href={jobUrl()} className="head__logo"> <img alt="" className="card-lp__service--logo" src="/common/logo/TLJ.svg" height="25"/> </a> <div id={'headerSignArea'}/> </div> </header> <div className="lp-top"> <div className="lp-top__content"> <h1 className="lp-top__title"> IT/WEBエンジニアに<br/> 理想的な求人情報と <br/>スカウトを </h1> <p className="lp-top__txt">テックリードジョブは、エンジニアの得意な技術や仕事への志向性でマッチングする、IT/WEB業界に特化した新しい求人・転職サービスです。</p> <div className={'signupButton'} data-payload={JSON.stringify({label: '会員登録 (完全無料)'})}/> </div> <img className="lp-top__visual--job--main" alt="" src="/common/lp/job__keyvisual.png"/> <img className="lp-top__visual--job--bg1" alt="" src="/common/lp/lp-top__bg1--job.svg"/> <img className="lp-top__visual--job--bg2" alt="" src="/common/lp/lp-top__bg2--job.svg"/> </div> <div id={'simpleSearchForm'}> <SimpleSearchForm/> </div> <section className="lpj_sec" data-delighter=""> <div className="container is-small"> <div className="lpj_sec-title"> <h2> フィット感の高さが伝わる、厳選された求人情報 </h2> </div> </div> <ul className="lpj_job-list"> {postings.map((posting, i) => ( <LpJobCard key={i} posting={posting} jobPostingContent={posting.job_posting_content || {}} jobClient={posting.job_client || {}} /> ))} </ul> <div className="has-text-center"> <div className={'signupButton'} data-payload={JSON.stringify({label: '会員登録 (完全無料)'})}/> </div> <div className="container is-small mt-6"> <div id={'campaignBanner'}/> </div> </section> <!-- 省略 -->
注意点
静的なHTMLが生成される
V8jsからは、静的なHTMLが生成されるのでコンポーネント内でonChangeイベントなどの処理があっても実行することができません。動的なコンポーネントはレンダーされた後に実行する必要があるので、「SSR後にCSRする用のエントリーポイント」を使い、マウントします。
例として、Index.tsx
の下記の部分について説明します。
# Index.tsx <!-- 省略 --> <div id={'simpleSearchForm'}> <SimpleSearchForm/> </div> <!-- 省略 -->
# index_csr.tsx import '@babel/polyfill'; import * as React from 'react'; import {render as reactRender} from 'react-dom'; import ErrorBoundary from '../common/components/common/ErrorBoundary'; import SimpleSearchForm from './containers/SimpleSearchForm'; import SignupButton from '../common/containers/common/SignupButton'; const renders: { [key: string]: JSX.Element; } = { simpleSearchForm: <SimpleSearchForm/>, signupButton: <SignupButton/>, }; const getElementData = (element: HTMLElement): object => { const data = element.dataset.payload; if (!data) { return {}; } element.dataset.payload = ''; return JSON.parse(data); }; Object.keys(renders).forEach((name) => { const element = document.getElementById(name); if (element) { const data = getElementData(element); const component = React.cloneElement(renders[name], {...data}); reactRender( <ErrorBoundary> {component} </ErrorBoundary> , element ); } const elements = document.getElementsByClassName(name) as HTMLCollectionOf<HTMLElement>; for (const ele of Array.from(elements)) { const data = getElementData(ele); const component = React.cloneElement(renders[name], {...data}); reactRender( <ErrorBoundary> {component} </ErrorBoundary> , ele ); } });
index_csr.tsx
は、SSR後にCSRする用のエントリーポイントです。
index_csr.tsx
では、renders
に定義されたキーから対応する要素を探し、対応する要素があればコンポーネントをレンダーしています。また、対応する要素のdata属性を使用し、data-payloadがあれば格納されたJSONを初期描画に必要なPropsとして注入しています。
※最初は、HTMLのid属性でコンポーネントを指定いましたが、1ページに複数回出てくるようなコンポーネント(signupButton
コンポーネントなど)があったためclass属性でもレンダーさせています。
※マウントさせる要素の命名規則をきちんと作らないと意図せぬ場所へマウントされてしまうので気をつけてください。
※data属性を使う理由は、何度もサーバーサイドにリクエストされるのを防ぎ、高速化させるため
<SimpleSearchForm/>
を<div id="simpleSearchForm"/>
でラップしている理由は、<div id="simpleSearchForm"/>
だけでレンダーしてしまうとindex_csr.tsx
が実行されるまでは要素が空になってしまうからです。
ラップすることによって、初期描画時に<SimpleSearchForm/>
で仮の要素を作り(動かない)、マウント時に動く要素と入れ替えることができます。これを行うことによって、初期描画からマウントされるまでの間に、要素が空になってしまうことやDOMが書き換わることによる画面のブレを防いでいます。
V8jsの罠
V8jsはPHPのエクステンションで、インストールするとV8 Javascript EngineがPHPに組み込まれJavascriptが実行できるようになります。
ただV8jsでは、setTimeout
など使えない関数があるのでSSR時に使用するコンポーネントでは使わないように気をつけてください。
また、layout.tsx
のhead部分にGoogle タグマネージャを埋め込み、アナリティクスなどの計測をさせていたのですが、V8jsのChromium上でも読み込まれ、実行されてしまっていたようで、計測の数値が倍くらいになってしまう問題が発生しました。
暫定的に、V8jsのChromium上にはwindow
がないようだったので、window
の存在判定をして、実行されないように回避しました。
V8js上で、何が起こっているか確認する方法やデバッグがわからなかったため、暫定的な対応になってしまいたした。。。
詳しい方がいたらご教授いただきたいです。 よろしくお願いします。
クライアントサイドレンダリングに関して
「SSR後にCSRする用のエントリーポイント」の動きとほぼ同じになります。
違いは、初期描画をPHPのビューテンプレートで行なっているのでメタ情報などはサーバーサイドで設定しているということと、使用するコンポーネントが異なることくらいです。
TECH LEADを作ってみて
「もう一度SPAを作ってみたい!」と思っています。
やはりSSRやCSRだとサーバーサイドとフロントエンドの両方でviewの管理をしなければならなくなり役割が分散してしまうので、SPAにしてviewは全てReactで行うようにし、サーバーサイドは完全にAPIサーバのような形をとったほうがいいのではないかなと思ってきています。
また、今まで経験を生かしReduxの使いどころに注意しながらSPAを作っていきたいと思っています。
以前SPAを作った時は、Reduxに全てのデータを入れ、Stateの管理をまとめたり、コンポーネントにPropsを注入するのに使っていたため、ちょっと使いたいデータなどがあった時でも大量のコードを書かなければならなくて大変でした。
しかし、最近ReactにContext APIやHooksといった機能が追加されたため、簡単なことや限定的はことだけどStateを使いたい・Propsを注入したいという時は、Reduxではなく簡単に使えるContextやHooksで実装したら楽にSPAがつくれそうだなと思っています。
まとめ
サーバーサイドレンダリング
メリット
- 初期描画が早い
- SEO対策・メタ情報の設定が楽
- セッション管理が簡単
デメリット
- 実装が難しい
- サーバーサイドが大変
クライアントサイドレンダリング
メリット
- 実装が簡単
- セッション管理が簡単
デメリット
- SEO対策・メタ情報の設定が大変・不安
- DOMが空の状態でレンダーされる
- 初期描画が遅い
シングルページアプリケーション
メリット
- 実装が簡単
- サーバーサイドが簡単
- 一度読み込むと早い
デメリット
- SEO対策・メタ情報の設定が大変・不安
- 初期描画が遅い
- セッション管理が難しい
SSR・CSR・SPA、どれにもメリット・デメリットがあると思いますが、 フロントエンドの技術は、すごいスピードで進化していて「これがスタンダード」というような形は、まだまだ決まっていないと思います。なので開発する際は、アプリケーションやページに対して最適な方法を見極める必要があります。(それが難しいと思いますが。。。)
また、進化が早い分、常に新しい情報をチェックし、どんどん取り入れていく必要があると思います。
弊社では以前作ったSPAが大変だったことからSSR・CSRでTECH LEADサービスを作りましたが、Reactへの理解も深まり、新たな機能が増えたことからSPAに変更することを検討しています。
おそらく、ログイン後のプライベートなページなどからの変えていくことになると思いますが、SPAを実装した際は、また皆さんに共有します!
エンジニアの皆さんへお願い
今回はTECH LEADのフロントエンドの実装方法を公開しましたが、いかがでしたでしょうか。すこしでも開発の参考になればと思います。
もし、「ここをもっと聞きたい」「うちではこうやっている」と言ったご意見・アドバイスなどありましたら、@TECH LEADまでリプライやDMにてお気軽にお知らせください!
よろしくお願いします!
次回予告
次回は【サーバーサイド(アプリケーション層)】の実装について話したいと思います。
最後まで読んでいただきありがとうございました!
PR
「今は具体的な転職を考えていないよ」と言う方も、是非一度TECH LEADの各サービスを触ってみていただけると嬉しいです。
TECH LEAD Job
IT/WEBエンジニア専門の求人サービス|TECH LEAD Job
TECH LEAD Resume
TECH LEAD Resume|IT・WEBエンジニア向けレジュメ管理サービス
TECH LEAD Agent
IT/WEBエンジニア専門の転職支援サービス|TECH LEAD Agent