Rearchitecting Wantedly's Frontend

概要: Wantedlyの会社ページをリニューアルするに際して、フロントエンドのアーキテクチャを見直しました。この記事では、1) なぜ見直す必要があったのか、2) 主要なアーキテクチャに関する説明、3) その他の新たに導入したスタック、について紹介します。

Wantedlyにはかなり前から会社ページというものが存在しています。元々はその会社の募集がまとまっている程度の役割でしたが、最近ではWantedly Feedのブログ記事を筆頭に、Caseのポートフォリオやメンバーのプロフィールなどの募集以外のコンテンツが増えてきました。さらに検索流入もかなり増えており、会社名で検索した時にその会社のコーポレートサイトより上位に表示されることも珍しくないです。そのため、会社ページの重要性がとても増していました。

その中で、会社ページへの訪問者に素早くかつ深くその会社の魅力を伝えられるようにすることを目指しリニューアルのプロジェクトが立ち上がりました。その詳細については、こちらをご覧下さい。

会社とユーザーを"価値観"でマッチングする新機能をリリースします! | Wantedly, Inc.

例えば、Wantedly, Inc.の会社情報 を見るとシンプルなコンテンツを表示するだけのページに見えますが、会社の編集権限を持った人がアクセスすると、表示時の見た目のまま編集できる、いわゆるWYSIWYGな設計になっており、パッと見たよりもリッチな体験を提供できる実装が求められました。アーキテクチャを検討する際に、もともとWantedlyでは2年以上前からReactを導入していたので、その選択を変えるつもりはありませんでしたが、既存のアーキテクチャでは対応できない課題がいくつかありました

Server Side Renderingの導入

一つ目の課題は、Server Side Rendering(SSR)の導入です。今までReactを導入していたページは、Feedの記事投稿画面や応募者管理画面など、ログインしている特定のユーザーに限定された機能であったため、SSRの必要性は感じていませんでした。しかし今回開発した会社ページでは、以下の理由からSSRの導入することに決めました。

  • 不特定多数のユーザーが閲覧するため(とくにモバイル端末での検索流入)、初期体験として素早いコンテンツの表示を行いたい
  • 検索ユーザーが多いため、安定したSEOを行いたい(SSRしなくてもちゃんとインデックスされるという話もあるが、不要なリスクは取りたくない)

SSRの重要性についてここではこれ以上述べませんが、この記事などを参考にしました。The Benefits of Server Side Rendering Over Client Side Rendering

レールを敷いて生産性を上げる

もう一つの課題として、開発生産性がありました。初めてReactを導入した時は、独立した特定のページで実験的に使いたかったので、丁寧な設計よりまずは動くものをとスピード重視で開発を進めました。しかし、そこから全体の設計を見直す間も無く、あっという間に色んなページで使われることになり、気づいたら19もの独立したReactアプリケーションが存在している状態になっていました。この画像は実際のwebpackのentriesのスクリーンショットです。

これらは全く統一されていないわけではありませんでしたが、特にレールもないため、Reduxが使われていたりいなかったり、Immutable.jsが使われていたりいなかったり中途半端に入っていたり、ContainerとComponentの切り方がバラバラだったりと、各機能を開発した人がそれぞれ判断して構成が決まっていました。その高い自由度もその部分だけを開発しているうちはいいのですが、別の機能を触ろうとしたら全然作法が違っていて混乱してしまい、さらに別の機能の作法が持ち込まれることになり、どんどんと混沌としていきました。

今後より多くの機能がReact化されていき、開発する人数も増えていく中で、ベストプラクティスが全体に浸透していく開発体制を作る必要性を強く感じました。

これらの背景があり、フロントエンドの新しいアーキテクチャを作ることに決めました。

脱Rails依存を目指しつつ、最小限の変更でSSRを導入する

Wantedly Visitのベースは、6年以上開発されているRailsアプリケーションです。マイクロサービス化も進んではいますが、まだまだモノリシックなアーキテクチャになっています。切り離せるところはRailsへの依存を減らす方向で動いていて、フロントエンドもどんどん引き剥がしていきたいと考えていました。しかし今回のプロジェクトでは、2ヶ月という限られた時間で導入するためのベストな方法を考える必要がありました。(アーキテクチャの検討はその前から少しずつ進めていました)

まずはSSRを導入するに当たってのアーキテクチャを考えました。はじめ考えたアーキテクチャは以下のようなものでした。

最前にnginxなどのReverse Proxyを置き、SSRするべきURLなのか、Railsで描画するページなのかを判断します。SSRするべきページなら、その後ろにあるNodeで作ったBFF(backend for frontend)サーバーに処理を委譲し、そのBFFの中でRailsのAPIを叩きデータを準備し、そのデータを元にReactでHTMLを生成します。

よくある構成だとは思うのですが、このアーキテクチャでは今回の状況ではいくつか問題がありました。

  • グローバルヘッダーなどのRails+jQueryで書かれた共通パーツと同居させたかった
    • 全ページの共通パーツにjQueryで書かれたインタラクティブな検索フォームがあったり、自動ダイアログを表示する仕組みなどもあり、共通化するためにはReact版を再実装する必要がある。
    • 一部では古いAngularJSが残っているページもあるため、全てのヘッダーをReact実装にすることは厳しい。React版とjQuery版が共存するのも今後の管理コストを考えて避けたかった。
  • 現状のRailsで実装された会社ページと、新しくReactで実装された会社ページを、ロジックで出し分けられるようにしたかった
    • 海外の企業も増えているためまずは国内限定でリリースしたかったり、A/Bテストで一部企業にリリースしたりなど、ロジックでの出し分けが必要だった。Reverse ProxyやBFFにそのロジックを持たせるのは面倒だと感じた。
    • RailsからReactへの移行が今後も進むにあたって、こういった場面は想定されるので、それがしやすい構成にしたかった。

これらの問題点を解決するために、Hypernovaを導入した以下のようなアーキテクチャにすることにしました。HypernovaはAirbnbが開発しているSSR用のマイクロサービスです。SSRに必要なデータを受け、renderした結果のHTMLを返すだけの、とてもシンプルなサーバーです。

全てのリクエストは従来通りRailsで受けます。SSRが必要かどうかは、各Controllerでロジックをもち判断します。SSRが必要なら、controllerの中でモデルから必要なデータを用意し、そのデータをHypernovaに渡します。HypernovaはSSRした結果を返し、それをRails側で作られたレイアウトにはめ込み、ブラウザに返します。これにより、全ページ共通のRailsで作られたレイアウトの中に、ReactでSSRする部分を共存させることができます。また、SSRするかどうかもactionの中で自由に切り替えられるので、SSRするか従来通りのRailsのビューを返すのかも簡単に出し分けることができます。

この構成は、上記の問題を解決する以外に、以下のようなメリットがありました。

  • インフラの移行コストがほとんどなかった
    • Hypernovaの中ではAPIリクエストなどは発生しないので、ほぼCPUバウンドになる。そのため負荷が読みやすく、Railsのdocker containerの中にHypernovaサーバーを同居させても安定稼働している。
    • もちろんcontainerを分けたほうが良いが、歴史的な理由からdeploy方法を変えることが難しかった。短期間で作り上げるためには、この構成でも問題なく動くことが大いに助かった。
    • Reverse proxy + BFFを導入するためにインフラ構成の見直すことと比較して、インフラのコストはほとんどなかったと言える
  • SSRに失敗しても、Client Side Renderingに切り替えられる
    • もしアクセスが集中ししてHypernovaが詰まっても、Rails側でタイムアウトさせることができる。その場合もちろんSSRには失敗するが、クライアント側では正しくレンダリングが行えるため、ユーザーが何もできないような状態には陥らない。SSR時に予期せぬエラーが発生した時も同じ。
    • 実際に本番運用でこれに助けられたことはまだないが、安心感がある。

Hypernovaを導入するにあたり、RailsエンジニアがReactを始めてSSRとReduxとTypeScriptを導入するまで などを参考にさせていただきました。

ActiveModel::Serializer based BFF

上述した通り、今回は構成ではBackend For FrontendのためのNodeサーバーは導入はしませんでした。そのため、SSRに必要なデータを準備し、そこからSSRしてHTMLを生成するという役割はRailsで行う必要があります。

Wantedlyには、active_model_serializersをベースとしたRESTfulなAPI(通称api/v2)があります。(詳しい説明は Rails アプリに RESTful API のレールを敷いて生産性が大きく上がった話 | Wantedly Engineer Blog をご覧ください。)このAPIでは、以下のようにfieldsパラメータに必要なフィールドを指定することで、そのフィールドだけを受け取ることができるようになっています。Serializerにフィールドやアソシエーションを宣言すれば関連モデルのフィールドも一発で引くことができ、N+1が起きないように自動的にpreloadもされます。

GET /api/v2/companies/1?fields=id,name,homepage,avatar.url&include=avatar

一般的なBFFサーバーでは、このようなリソース志向で凝集度の高いAPIを呼ぶことで、SSRに必要なデータを用意します。しかし、WantedlyのこのAPIは、Railsのコントローラーの実装は薄く、ほとんどのロジックはSerializerというリソース指向の JSON を生成するためのレイヤーに集まっていました。なので、そのSerializerを共通化することで、APIを叩かなくてもControllerの中でBFFと同じような役割を効率よく実装することができました。

その構成が以下のようなものです。SSRを行うコントローラーと、API用のコントローラーが二つありますが、この中のロジックは薄く、URLパラメーターから対象のリソースを見つける事くらいしかありません。そのリソースのフィールドや関連モデルに関する実装はその後ろにあるSerializerで共通化されています。

SSRを行うRailsのControllerの具体的なコードを見てみましょう。URLパラメータから対象のCompanyを読み込み、さらにその会社の最新の募集を取得しています。

# CompaniesController.rb
def show
@company = Company.find(params[:id])
@projects = @company.latest_projects
react_state = render_react_state(
company: @company,
projects: @projects,
)
render_react(react_state)
end

この@companyと@projectsに対して、renderな必要なフィールドや関連モデルを指定してJSON化する処理を行なっているのが、render_react_stateですどのフィールドを要求するかをcontrollerに書いていくとコードが大変読みづらくなるので、Railsのビューの仕組みを使って、以下のようなyamlファイルをこのアクションに対応するviewとして用意できるようにしました

# views/companies/show.yml
company:
_fields:
- id
- name
- homepage
avatar:
_fields:
- url
projects:
_fields:
- id
- title
image:
_fields:
- url

みればわかると思いますが、先ほどのクエリパラメータをYAML形式で書いているだけです。jbuilderで.json.jbuilderを書くのと同じ感覚です。 このファイルが自動的にロードされ、Serializerに渡されることで、必要なデータだけを持ったJSONが作られます。

このような構成にしておくことで、脱Rails依存するタイミングで、このコントローラーに書かれているリソースを見つける処理をそれぞれ対応するAPIを呼ぶ実装に書き換えるだけで、BFFに移行することができます。

上記の方法で作られたJSONに、認証情報やルーティング情報を加えて、Hypernovaに渡します。Hypernova側のJSのコードを見ていましょう。(実際はTypeScriptを使用しています)

function app(props) {
const { router, body, auth } = props;
const history = createHistory({
initialEntries: `${router.path}?${router.queryString}`,
});
const store = configureStore({ history });
store.dispatch({
type: `bootstrap/${props.page}`,
payload: body,
});
const contents = ReactDOMServer.renderToString(
<App {...{ store, history }} />
);
return hypernova.serialize("App", contents, props);
}

このapp関数が、Hypernovaが全てのリクエストを受け取りSSRする部分です。propsとして先ほどRails側で用意したデータが渡ってきます。

まずはHistoryオブジェクトを作ります。Hypernovaでは、複数のエントリーポイントを用意してRails側からどれをrenderするかを指定する機能を持っていますが、今回はエントリーポイントを一つだけ用意して、ルーティングを元に何をrenderすべきかを決めるようにしています。

その後storeを作り、bootstrapアクションを発行しています。初期表示に必要なデータをもつreducerがこのアクションをフックし、stateを更新するようになっています。

// Reducer
case "bootstrap/companies#show":
return { ...state, company: action.payload.company, projects: action.payload.projects }

そのstoreをもとにrenderToStringした結果のHTMLを、HypernovaがserializeしてRailsに返すという、nシンプルなコードになっています。

Client Side Renderingするコードもほぼ同じです。

function main() {
const results = hypernova.load<BootstrapData>("App");
const { node, data } = results[0];
const history = createBrowserHistory();
const store = configureStore({ history });
store.dispatch({
type: `bootstrap/${data.page}`,
payload: data.body,
});
ReactDOM.hydrate(<App {...{ store, history }} />, node);
}

hypernova.loadは、SSR結果が入っているnodeと、そのSSRに使用されたデータを取得するメソッドです。そのデータから先ほどと同じようにbootstrapアクションを実行し、出来がったstoreを元にhydrateすればClient Side Renderingが完了します。

注目すべき点としては、SSRとCSRの両方で同じデータをもとに同じbootstrapアクションを投げていることです。通常のSSRなら、SSRした際のstoreの状態をクライアントに返し、それをそのままinitialStateとして使用することが多いです。それをあえて行わないことで、SSRした場合としなかった場合の条件分岐も必要なくなり、StoreにSerializeできないデータ(Immutable.jsなど)を入れてしまっても壊れないようになっています

このようなSSRの構成にしたことで、React化がさらに進んで古いアーキテクチャのページが減ったタイミングで、グローバルナビなどの共通ページをReact実装に置き換えれば、BFFの構成に簡単に置き換えられると考えています。

その他新しく導入したスタック

残りは、規模が大きくなるにつれて発生した新しい問題を解決するために導入した新しいツールや、今まで使っていた技術などをいくつか見直したことなどをまとめて紹介します。それぞれの詳細はまた別の機会でより詳しく紹介できたらと思います。

Dynamic import

従来の構成では、はじめに述べたとおり、新しいページをReact化しようとしたら、webpackのエントリーポイントを追加する運用になっていました。そのため、各機能のJSは独立している(もちろんvendor部分は共通化している)ので、必要なページで必要なJSだけがロードされてる構成になっていました。

しかし、今後よりReact化が進んでいくと、全体として一つのアプリケーションとして捉える必要が出てきます。例えば、今回は会社ページをReact化しましたが、次に募集ページがReact化されるとしたら、その二つが別のアプリケーションになっていると、会社ページと募集ページ間の遷移でページのフルリロードが必要になります。そういった事態を避けたかったので、一つのwebpackのエントリーポイントの中で必要なものをdynamic importする構成に変えました。

大部分は、 Server Rendering, Code Splitting, and Lazy Loading with React Router v4 を参考にしましたが、一つ大きく違うのは、react-routerを使わずにuniversal-router を使って実装したことです。その理由としては、以下のような理由が挙げられます。

  • 古い構成がreact-router v3からアップグレードできずに困っていた。新しい構成だけv4にすることも考えられたが、複数バージョンが混在するのは避けたかった。
  • react-router-config を使うと、<Route>コンポーネントなどは使わなくなるので、react-routerの「JSXスタイルで柔軟にルーティングできる」という魅力が必要なくなる。
  • react-loadable などの選択肢もあったが、SSRするにはwebpackの設定が必要になったり、SSRとCSRが混在する場合に上手く書けなかった。
  • universal-routerは自由度が高く、やりたい挙動がすごく簡単に実装できた。足りない部分も情報は増えているので、必要十分だった。 (https://qiita.com/mizchi/items/9c7a6063a2fd6041aabd など参考にしました)

サクッとルーティングを実装したい場合はreact-routerはすごく手軽でいいと思いますが、もうちょっと柔軟に使いたい場合はuniversal-routerを使うのは検討する価値があると思います。

TypeScript

TypeScriptによる型の導入も、今回見直した大きな点でした。導入自体は以前から考えていましたが、今回のリニューアルのタイミングで導入することにしました。

もっとも良かった点は、やはり安心してコードを変えられるようになったことです。大きくなってきたら効果を発揮すると思っていましたが、今回の開発でも、基盤部分を作りながら同時にアプリケーション部分も書き進めるスタイルだったので、途中で基盤の実装が変わることもあったのですが、そういった場合にどこを変更すればいいのかすぐに分かり、高速に改善することができました。

導入方法で特筆すべき点は、ビルド時にtscを使わずに、babelを使ってビルドしていることです。babel7では、TypeScriptファイルとbabelが直接解釈することができるようになっており、このおかげで、webpackの設定や、テスト時のビルド設定などが大変シンプルになり、管理コストが大きく下がったと感じています。babel7は長い間ベータを続けていますが、今のところ問題なく動いています。いくつかのTypeScriptの機能が使えなかったりしますが、他の書き方で代替できるので、特に困っていないです(const enumが使えない、Genericsをもつ無名関数がパースできないなど)。

typescript-fsa

TypeScriptの導入にあたって、Reduxのactionやreducer周りがいまいち綺麗に書けないなと悩んでいました。そんな時に見つけたのが typescript-fsaです。redux-actions の型に最適化されたバージョンみたいなもので、実装はすごく薄いです。

細かい紹介はこちらの記事になどをご覧いただくとして、Wantedlyではこのtypescript-fsaに合わせて、APIコールを行うactionを生成する requestCreator というメソッドを用意しています。例えば、ある会社に似ている会社をロードする処理は以下のように書いています。

まずは、独自に定義したrequestCreatorメソッドで、APIコールのためのReduxアクションを定義します。このリクエストに必要なパラメーターの型と、そのレスポンスの型を定義します。アクションにしているのは、middlewareの中でRESTful APIに必要な認証情報などをつけたり、共通のエラーハンドリングを行うためです。

// In requests.ts
interface
SimilarCompaniesResponse = { ... };
export const requestToLoadSimilarCompanies = requestCreator<Company, SimilarCompaniesResponse, Error>(company => {
const include = ["background", "avatar"];
const fields = ["name", "company_name", "short_location", "mission_statement", "background.url", "avatar.url"];
return {
path: `/api/v2/companies/${company.id}/similar_companies.json`,
include,
fields,
};
});

このアクションを使うためには、thunkの処理をラップした typescript-fsa-redux-thunk の提供するbindThunkActionを使います。これを使うと、_STARTED や _DONE、_FAILED などのアクションを自動的に発行してくれるため、考えることは、そのアクションの返り値と失敗した時の例外だけで済みます。

// In actions.ts
import { actionCreatorFactory } from "typescript-fsa";
import { bindThunkAction, isFailure } from "typescript-fsa-redux-thunk";
import {
requestToLoadSimilarCompanies } from "./requests";

const actionCreator = actionCreatorFactory("COMPANIES");
export const loadSimilarCompanies = actionCreator.async<RootState, Company, SimilarCompaniesResponse, Error>(
"LOAD_SIMILAR_COMPANIES"
);
export const loadSimilarCompaniesWorker = bindThunkAction(loadSimilarCompanies, async (params, dispatch) => {
const req = requestToLoadSimilarCompanies(params);
const res = await dispatch(req);
if (isFailure(res)) {
throw res.payload.body;
}
return res.payload.body;
};

// In reducers.js
import { reducerWithInitialState } from "typescript-fsa-reducers";

const showReducer = reducerWithInitialState<ShowState>(InitialState).case(loadSimilarCompanies.done, (state, { result }) => {
return { ...state, similarCompanies: result };
});

redux-thunkの問題点は自由が効きすぎることだと思うので、このようにtypescript-fsaをベースにして規約を設けることで自由度を下げ、かつ型の扱いが良くなることで、かなりRedux周りのコードが整理されました

この記事を書いている間にReduxのv4がリリースされました。型の扱いが良くなったとリリースノートに書かれているので、どう良くなったのか調べてみようと思っています。

styled-components

従来の構成では、スタイルはCSS Modulesを使って書いていました。従来のCSSと比較して大きく生産性が上がっていたのですが、いくつかもっと良くしたい点がありました。

  • どういうクラス名が使えるのかをTypeScriptでsuggestしたい。
  • 状態に応じてスタイルを帰る時に、classNameを付け替えるのか、data属性を追加するのかなど、好みで書き方が別れていた。
  • 複雑なレイアウトを組む時に、JS側の変数を元にスタイルを変えたいことがあり、そういう場面ではインラインで書く必要があったりした。
  • SSRするためには、Webpackの設定を行ったり、結構複雑な手続きが必要だった。
  • (完全に導入時の失敗ですが)sassでかけるようにしていたので、CSS Modulesのcomposesが使われていたり、sassのMixinが使われてたりと、CSSの書き方にも好みが現れていて、統一感がなかった。

styled-components はこのような課題点を綺麗にクリアする選択肢でした。例として、今回は実装したResponsiveなボタンの定義を紹介します。

// Define break points
const media = {
small: `screen and (max-width: ${px2em(Breakpoints.medium - 1)}em)`,
medium: `screen and (min-width: ${px2em(Breakpoints.medium)}em)`,
large: `screen and (min-width: ${px2em(Breakpoints.large)}em)`,
}
// Define component
const Button = styled.button`
width: 100%;
border-radius: 100px;
font-weight: 400;
line-height: 1;
color: ${colors.white};
border: none;
text-align: center;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
${(props: { type: "primary" | "link" }) => props.type === "primary" ? css `
background-image: linear-gradient(to right, #1e7dde, #00c4c4);
` : ""}
@media ${media.medium} {
width: auto;
}
`
// Usage of Button
<Button type="primary" />
<Button type="no-such-type" /> // Error in type checking

基本的にはTemplate literalの中にCSSのスタイルを書いていくのですが、変数やMedia Queryなどは普通にInterpolationを使ってシンプルに書けるため、JS側と変数の共有が簡単にできます。また、そのスタイルの中にfunctionを書くと、その引数の型をそのコンポーネントのPropsの型として定義してくれるようになっています。なので、Buttonの状態変化も、クラス名を付け替えたりdata属性をつけたりせず、カスタムコンポーネントのように非常にスムーズに書けます。SSRの設定も非常に簡単だったので、無駄な設定に時間を書けずに済んだ点も良かった点です。

react-motion

今回のデザインの中で、共通コンポーネントとしてカルーセルが多く使われています。このカルーセルが曲者で、既存のライブラリではうまく要件を満たすものを見つけられませんでした。

  • 一つずつスワイプするのではなく、n個まとめてスワイプできる。
    • nはデバイスサイズにより変化する。
  • スワイプできることがわかるように、右の要素の要素が見切れている。
    • 最後までスクロールすると右揃えになり、左の要素が見切れている状態になる。
  • スワイプ時に適切な位置にスナップする。
    • overflow: scroll だけではダメ。
  • ネイティブっぽく慣性スクロールする。
  • SSRした結果をクライアント側でマウントした際にガタッとしない。
    • SSR時はデバイスサイズを知らないので、JSでレイアウトするとこの問題が起きる。
    • 基本的なレイアウトはCSSだけで完結する必要があった。

このような要件を満たすために、カルーセルコンポーネントを自作することにしました。基本的な挙動はそこまで難しくないのですが、ネイティブのような慣性スクロールを実現するのは簡単ではありません。そこで、react-motionに頼ることにしました。その慣性を実現しているコードは以下のように非常に簡潔です。

import { Motion, spring, presets } from "react-motion";

getStyle() {
const { currentLayout } = this.state;
const { currentIndex, width, moving } = this.state;
const snap = width && currentLayout ? currentIndex * (width + currentLayout.gutter) : 0;
return {
x: moving ? moving + snap : spring(snap, presets.noWobble),
};
}
render() {
return <Outer
innerRef={this.setOuterRef}
onTouchStart={this.onTouchStart}
onTouchEnd={this.onTouchEnd}
onTouchMove={this.onTouchMove}
onWheel={this.onWheel}
>
<Motion defaultStyle={{ x: 0 }} style={this.getStyle()}>
{style => (
<Inner style={{ transform: `translate3d(${-style.x}px, 0, 0)` }}>{children}</Inner>
)}
</Motion>
</Outer>
}

onTouchStartとonTouchEndでタッチ中のフラグを管理し、onTouchMoveでタッチによる移動量をstateに持たせます。react-motionのMotionコンポーネントに渡すstyle propsの値を、タッチ中は指に吸い付くように移動量をそのまま渡して、touchが終わるとreact-motionが用意している spring 関数に、どこにスナップするべきかの値を渡して返すだけです。これだけで慣性スクロールを実現することができました。

スタイルを除いて約300行で実装できているので、デザインの要求にあうカルーセルライブラリが見つからない場合は、恐れずに実装してみてはどうでしょうか。ちなみに、マウント時にガタッとしないようにレスポンシブを実装するためにもstyled-componentsはとても役に立ちました。

[WIP] Atomic Design

ここから先は時間的にまだ導入は完了していないが、検討が行われ、近いうちに導入される可能性が高いものを[Work In Progress]として紹介します。

Atomic Designは、Componentを整理するために導入しようと考えています。Reduxで書いていくうちに、なのをContainer(connected component)にして、何をPresentational Componentにするかに迷うことがあります。実装中心で考えてしまうと、Containerを増やしていったほうがPropsの受け渡しも減り、パフォーマンス面でもメリットもあるため、Containerがどんどん増えていきます。Containerが増えると、Componentの再利用性が下がっていきます。あのページで使っているコンポーネントをこっちでも使おうと思った時に、がっつりstoreに依存しており、簡単に再利用できないという問題が頻発していました。実際に、ほとんど同じビューなのに、別のコンポーネントになってしまっているものも複数あります。

Atomic Designの考え方を導入してComponentを整理することで、実装目線ではなく、デザイン目線でコンポーネントを分割することができ、再利用を最大限に高めることができるのではと考えています。今考えている構成では、このように分けられると考えています。

  • Atoms: 最小のComponent単位。styled-componentsで気軽に作ってファイル移動も簡単にできるので、一つのMoleculeでしか使われないならその場に書いて、複数のMoleculsで使うならatomsディレクトリに置く。
  • Molecules: Atomsを複数個組み合わせたComponent。基本的にはStateは持たない。
  • Organisms: Moleculesを複数個組み合わせたComponent。Stateは持たないPresentationalな実装をまず作り、使う際にそれをwrapしたContainerを作って使う。

実際、Atomic designでなくても、ContainerとComponentの切り分けの指針が作れればいいのですが、他に有効な手がありましたら、ぜひ教えていただきたいです。ちなみに、Atomic Designを導入する際には、Storybookの導入も同時に進めたいと考えています。

[WIP] Immer

Wantedlyでは、Immutable.jsを積極的に活用していました。ネストされたオブジェクトを簡単に非破壊的に更新できたり、モデル的に扱えたりと、様々なメリットがありました。

しかしはじめにお伝えした通り、規模が大きくなっていくうちにいくつかの新しい問題がありました。

  • Immutable化されているところと、されていないところが混在してきた。
    • 全てをImmutableにすることで統一できるが、Immutable化するほどでもない場合も多いので、コストになる
  • serialize/deserializeする開発コストが思ったより大きい
    • SSRする際にサーバー側とクライアント側でstoreの共有が必要なだけではなく、localstorageにキャッシュしようと思う際にも考えることが一つ増える
    • さらには、スナップショットテストを書いたりする時に、Immutableじゃなければブラウザからコピペするだけでテストをかけるのに、Immutable.jsだと書き直したりワンステップかますこと必要がある。これによりテストを書くハードルが上がっている。

このような理由から、チームの体制にImmutable.jsが合わなくなってきたため、新しい構成では極力使わない方針にしました。しかし、やはりImmutable.jsの非破壊的に簡単にオブジェクトを更新できることは捨てがたい魅力でした。いくつか代替になるヘルパーライブラリもあるけど、どれも微妙に使いづらい。

そんな時に颯爽と現われたのがImmerでした。Immerは、Proxyを使うことによって、破壊的にオブジェクトを変更しているように見えて、実は最小限に変更された新しいオブジェクトを返すというライブラリです。多段階にネストされたオブジェクトも、とても簡単に非破壊的に変更することができます。

GitHubにパフォーマンスが掲載されているのですが、Immutable.jsとほとんど差がなく、bundle sizeも小さいため、かなりいいんじゃないかと思っています。(Proxyのネイティブ実装がないIE11では6倍くらい遅いみたいですが、worst caseと書かれているので実際のアプリケーションで調べて判断しようと思っています)。

最後に、フロントエンドの技術検証を手伝ってくれた宮代くん、開発中の不安定な基盤の中でアプリケーションコードをどんどん書き進めてくれた富岡くん山田くん中村くん小林くん、サーバーサイドの構成を作ってくれた竹野くん新谷くん。その他にもご協力いただいたみなさんおかげで、無事リリースすることができました。本当にありがとうございました。お疲れ様でした。

Wantedlyでは、一緒にフロントエンドを作ってくれるサマーインターンを募集しています。興味があればぜひ応募してみてください。

学生エンジニア
夏だ!インターンだ!Reactだ!
WantedlyはビジネスSNSとして、「であい/Discover」「つながり/Connect」「つながりを深める/Engage」の3つの体験を提供しています。 Wantedlyは現在2つのプロダクトに力を入れています。 1つ目のWantedly Visitは、人と企業の出会いを生み出す「会社訪問アプリ」です。 共感や働く仲間を軸に、ココロオドル仕事との出会いを創出します。 現在約25,000社以上の企業様に使っていただいており、IT業界のみならず、メーカーや不動産といった業種の企業様にも導入頂いています。 2つ目のWantedly Peopleは、名刺管理をきっかけとし、人と人のつながりを将来持続的に使える資産へと変える「つながり管理アプリ」です。 2016年に立ち上がった新規事業ですが、読み込んだ名刺の枚数は5000万枚を超え、今後さらにつながりを深める体験を提供していきます。 また、海外展開も加速しており、シンガポール、香港、ベルリンに支社を構えています。
Wantedly, Inc.
Anonymous
23622309 1574656819280491 2682790155293067173 n
C474eb62 4fd8 419f 8892 8b44b709e16e?1504778246
22309085 1981698995412285 3850763482671062953 n
05f310a8 0ee7 4821 814a e8fde7affb3f
06054360 ce98 406e 84b9 843e87eceb82?1511168240
47 Like
Anonymous
23622309 1574656819280491 2682790155293067173 n
C474eb62 4fd8 419f 8892 8b44b709e16e?1504778246
22309085 1981698995412285 3850763482671062953 n
05f310a8 0ee7 4821 814a e8fde7affb3f
06054360 ce98 406e 84b9 843e87eceb82?1511168240
47 Like

Weekly ranking

Show other rankings

Page top icon