一休.com レストランは今年の 7 月 18 日、スマートフォン向け検索ページのリニューアルを行いました。このエントリーでは、その中身について少し紹介させていただきます。 検索ページの課題 一休.com レストランではスマートフォン向け検索ページに対して「遅い」という課題意識がありました。これは技術面で少しブレイクダウンすると; パーソナライズドを含む複雑な処理を行っているため、サーバーサイド処理が重い。 UI 上無駄な遅延処理を行っているため、クライアントサイドの描画が遅い。 というサーバー側とクライアント側両方の課題がありました。クライアントサイドの「無駄な遅延処理」というのは; 検索結果取得が REST API 化されているにも関わず、再検索の度にページリロードを行い、サーバーサイドの描画からやり直している。 という実装に問題がありました。下図がリニューアル前のページ描画の様子です: 画面描画後に動的な検索結果が遅延描画されている図 社内では UI 上のこの問題点に対して課題意識が大きく; 「検索と再検索の回遊性を改善したい」 という要求が強くなっていました。 Web フロントエンドのコンポーネント化 話は変わり、一休.com レストランは古い技術セットの上に構築されています。Microsoft の Classic ASP と VBScript を主体としたアプリケーションで永らく運営されてきました。その一方でユーザー向け web ページの表現力を向上させて行きたいという要求から、Vue.js によるフロントエンド実装も徐々に始まっていました。 Vue.js は SPA のような高い表現力を簡単な記述で実現できるのが魅力です。従来の web 開発では; HTML CSS JavaScript という ファイルタイプによる縦割り開発 が主流でした。しかし; BEM や ECSS のようなCSS フレームワークの提唱する思想、 Vue.js のような JavaScript フレームワークのメカニズム、 Web Components による標準化の流れ から明らかなように、UI 改善において コンポーネントを中心に串刺し設計する事の重要性 が認知されてきています。 一休.com レストランでは、あらゆるフロントエンド実装を Vue.js に移行する事で Smart UI パターンのような開発形態から、コンポーネント化された web アプリケーションへ移行する事を技術的な目標としています。 実現すればコンポーネント指向の持つユーザーメリットを享受できるようになり、一休の掲げる「ユーザーファースト」を後押しする開発体制が実現できると考えています。 SEO とサーバーサイドレンダリング しかしここで問題がありました。SEO の考慮です。一休.com レストランの高い集客力は SEO に真摯に取り組んできた結果でもあります。 この SEO の観点から; ページ上のあらゆる重要なコンテンツは、サーバーサイドレンダリングされていなければならない。 という要求がありました。これは、クライアントサイドレンダリングを基本とする Vue.js でコンポーネント化された web アプリケーションへ移行する目論見と衝突しました。 コンポーネント指向開発を実現する事。 重要なコンテンツはサーバーサイドレンダリングされる事。 この 2 つが「ユーザーファースト」を目指す上で必要な技術要件でした。 尚、今年の Google I/O 2018 の Google Webmasters からのアナウンスで状況は変わりつつありますが、リニューアルの意思決定から開発の段階では、クライアントサイドレンダリングで良しと言える状況ではありませんでした。 業務課題と技術課題の合致 このような背景から、コンポーネント指向とサーバーサイドレンダリングという 2 つ の技術課題の解決手段として、サーバーサイド JavaScript の導入が現実味を帯びてきました。Vue.js においてはユニバーサル JavaScript を実現するフレームワーク Nuxt.js が存在しており、これによるプロトタイプを社内で進めるようになります。 結果として、一休.com レストランの技術スタックの理想像は下図のようになって行きました: フロントエンド構成のヴィジョン これなら業務課題である検索ページの UI 改善と、技術課題を解決できます。最終的に、一休.com レストランの検索ページリニューアルで、Nuxt.js の導入を決断しました。 次の項では Nuxt.js で実装が開始された検索ページの設計を紹介します。 コンポーネント指向設計 検索ページリニューアルでは、全面的にコンポーネント指向設計を導入しました。 コンポーネントの定義 一休.com レストランの web フロントエンドでは、コンポーネントを下図のように捉え定義しました: コンポーネントの定義 データ、テンプレート、ロジック、スタイル、それぞれ関連性が深いもの同士をモジュール化 ファイルタイプによる縦割りではなく、関連性によるファイルタイプ横断の串刺しでグループ化 フロントエンド実装のあらゆるアセットをコンポーネントと捉えて管理 上記を基本とし、CSS、JavaScript、画像、Vue 単一ファイルコンポーネントなど全ての分割粒度として、共通のコンポーネントという概念を前提としました。 ITCSS によるレイヤードアーキテクチャ これらコンポーネントを共通のレイヤードアーキテクチャで管理するため、一休.com レストランでは CSS アーキテクチャの 1 つである ITCSS を採用しました。 ITCSS レイヤードアーキテクチャ ITCSS 自体は CSS エンジニアである Harry Roberts 氏 が提唱する CSS の詳細度を管理するためのレイヤードアーキテクチャです。 CSS でコンポーネント指向設計を実践する事を前提にしたアーキテクチャである点、 抽象度を管理するレイヤードアーキテクチャとして柔軟である点、 これらの点から web フロントエンドのコンポーネント抽象度化レイヤーとして自然に捉え直す事ができます。このアーキテクチャを利用し、以下のようにレイヤーごとの責務を定義しました: レイヤー 定義 Settings CSS 変数や、定数などのデータを扱う。 Tools CSS ミックスイン、フィルター、またはバリューオブジェクト、DTO のようなアプリケーション上の型となる定義を扱う。 Generic CSS 要素型セレクターによるグローバルスタイル定義、アプリケーション全体で共通化された処理、グローバルな副作用を持つビジネスロジックを扱う。 Elements Atoms のようなプリミティヴなコンポーネントを扱う。これ以下のレイヤーで Vue.js SFC を扱う。 Objects Molecules のようなアプリケーション上のコンテキストを含むコンポーネントを扱う。 Components Organisms のようなアプリケーション上意味のある機能単位のコンポーネントを扱う。 上図のように Elements レイヤーより Atomic Design のレイヤー概念も取り入れています。そもそもとして、下図のような Atomic Design によるレイヤードアーキテクチャも検討しました。 Atomic Design レイヤードアーキテクチャ しかし; Atomic Design における Atoms レイヤーはグローバルなデータやビジネスロジックなどを表現するレイヤーとしてはスコープが広くなり過ぎる。 一方でこれらの表現に向いていそうなクリーンアーキテクチャでは、UI コンポーネントの抽象化を表現するには表現力が低い。 ITCSS はその点で、グローバルなデータやビジネスロジックを表現するレイヤーと UI コンポーネントを表現するレイヤーが最初から定義されておりバランスが良い。 と考え ITCSS を採用しました。 パフォーマンスの観点 ITCSS は CSS 詳細度を管理するアーキテクチャであり CSS のクライアントパフォーマンスを最大化する目的があります。また web フロントエンドはコンパイラより実装者にパフォーマンス最適化の責務があると考えます。 この観点から Tools と Generic レイヤーでデータ定義とビジネスロジックを扱うレイヤーを分ける事が、オブジェクトにメソッドを生やしてビジネスロジックを実装するのを避ける事につながり、webpack の tree-shaking による最適化を享受しやすい実装を導出するメカニズムになると考えています。 コンポーネント指向の置き換え可能という特性を、パフォーマンスの観点を持ったレイヤードアーキテクチャで管理する事で DRY を実践しやすく、結果としてハイパフォーマンスなフロントエンドが実現可能なメカニズムとなる事を期待しています。 つづいて Nuxt.js による BFF 実装を進めていく上で難しかった点をいくつか紹介します。 ユニバーサル JavaScript まず Nuxt.js の最大の特徴は、Vue.js による実装で、サーバーサイドもクライアントサイドも透過的に記述できる事でしょう。これによりコンポーネント指向設計において、サーバーとクライアントという動作環境の違いを、ファイルシステムの違い同様、串刺しにコンポーネントとしてカプセル化できるようになります。これが Nuxt.js の大きなメリットです。 サーバーサイドとクライアントサイド API の違い しかし同じ JavaScript とは言え、Node.js と web ブラウザの API には違いがあります。Nuxt.js が用意していないインターフェースで、サーバー/クライアントを透過的に扱いたかったのが次の 2 つです: サーバー/クライアントでの Cookie インターフェースの違いを吸収する バグレポード Bugsnag のサーバー/クライアントサイドのクライアントを透過的に扱う これらの実現に、Nuxt.js の modules と plugins 機能を用いました。 ユニバーサル Cookie Nuxt.js で Cookie を透過的に扱うにあたって UniversalCookie コンポーネントを作成し、サーバー/クライアントサイドでの CRUD 処理を透過的に記述できるインターフェースを用意しました。そしてこれを Nuxt.js の plugins として登録しました。 plugins/cookie.ts : import * as http from 'http' ; import { createUniversalCookie } from '@/components/generic/UniversalCookie' ; export default function ( { req , res } : { req: http.IncomingMessage , res: http.ServerResponse } , inject ) : void { const cookie = createUniversalCookie ( req , res ); inject ( 'cookie' , cookie ); } 上記のような plugin を登録する事で、Nuxt.js のコンテキスト上では app.$cookie.set(key, value) や this.$cookie.get(key) と言った記述で、サーバー/クライント関係なく cookie の読み書きができるようになりました。 ユニバーサル Bugsnag 一休.com レストランではクライアントサイドのバグ検知に Bugsnag を利用しており、ユニバーサル JavaScript 化に当たって、サーバーサイドの JavaScript 処理エラーも Bugsnag へレポートするインターフェースを用意しました。 modules/bugsnag/index.js : const path = require( 'path' ); const bugsnag = require( 'bugsnag' ); module.exports = function BugsnagModule(moduleOptions) { bugsnag.register(moduleOptions.SERVER_API_KEY, { appVersion: (process.env.VERSION_SHA1).slice(0, 7), autoCaptureSessions: false , autoNotify: process.env.NODE_ENV !== 'development' , releaseStage: process.env.NODE_ENV || 'development' , } ); this .nuxt.hook( 'render:setupMiddleware' , app => app.use(bugsnag.requestHandler)); this .nuxt.hook( 'render:errorMiddleware' , app => app.use(bugsnag.errorHandler)); this .addPlugin( { src: path.resolve(__dirname, 'plugin.js' ), options: moduleOptions, } ); } ; modules/bugsnag/plugin.js : import Vue from 'vue' ; export default function (context, inject) { const VERSION = (process.env.VERSION_SHA1).slice(0, 7); // サーバーサイド Bugsnag if (process.server) { const bugsnag = require( 'bugsnag' ); bugsnag.register( '<%= options.SERVER_API_KEY %>' , { appVersion: VERSION, autoCaptureSessions: false , autoNotify: process.env.NODE_ENV !== 'development' , releaseStage: process.env.NODE_ENV, } ); inject( 'bugsnag' , bugsnag); } // クライアントサイド Bugsnag if (process.client) { const bugsnagJs = require( 'bugsnag-js' ); const bugsnagVue = require( 'bugsnag-vue' ); const client = bugsnagJs( { apiKey: '<%= options.CLIENT_API_KEY %>' , appVersion: VERSION, autoCaptureSessions: false , autoNotify: process.env.NODE_ENV !== 'development' , releaseStage: process.env.NODE_ENV, } ); client.use(bugsnagVue(Vue)); inject( 'bugsnag' , client); } } 上記のような module を登録する事で、 app.$bugsnag.notify(new Error('...')) ないし this.$bugsnag.notify(new Error('...')) でハンドリングされたエラー処理のレポートを透過的に記述できるようになり、例外もサーバー/クライアントサイド両方を検知できるようにしました。 ただしこれらは Nuxt.js のコンテキストを通じてインターフェースを初期化する必要があるため、 this.$cookie や this.$bugsnag への参照を持つコンポーネントは Nuxt.js 実装に密結合となります。なのでこれら plugins へのアクセスは layouts/pages を通じてのみ行うルールとし、コンポーネントの責務をコントロールしています。 副作用の考慮 クライアント JavaScript をユニバーサル JavaScript に対応する過程にも注意する事がありました。グローバルな初期処理による副作用です。 例えば都度参照ではコストが大きい window.innerWidth のようなグローバルプロパティ値をキャッシュする次のようなモジュールがあります。 windowsize.js : let windowWidth; let windowHeight; function resize() { windowWidth = window .innerWidth; windowHeight = window .innerHeight; } window .addEventListener( 'resize' , resize, false ); window .addEventListener( 'orientationchange' , resize, false ); resize(); export { windowWidth, windowHeight, } ; このようなモジュールが Nuxt.js の pages コンポーネントで import されるとサーバーエラーとなり、ユーザーには 500 エラーが返る事になります。しかしクライアントサイドに限った JavaScript 実装であれば、ありがちな実装であり、グローバルな副作用を局所化する手段としても合理的です。しかし window オブジェクトのようにクライアント JavaScript にしか存在しない API の暗黙的な参照が発生する上記のようなモジュールをうっかり Nuxt.js で import すればアプリケーションが起動しなくなります。こういった点はユニバーサル JavaScript において煩わしさを感じる点でもあり、副作用の影響を考える上で面白い点でもあると思います。 これを解決する方法としては、あらゆるグローバルな処理を Nuxt.js (Vue.js) のライフサイクルに載せるという方法を取りました。上記の windowsize.js は次のように変更しました。 windowsize.js : import Vue from 'vue' ; export const WindowSize = Vue.extend( { data() { return { height: undefined , width: undefined , } ; } , created() { if ( this .$isServer) { return ; } window .addEventListener( 'resize' , this .resize, false ); window .addEventListener( 'orientationchange' , this .resize, false ); this .resize(); } , beforeDestroy() { window .removeEventListener( 'resize' , this .resize); window .removeEventListener( 'orientationchange' , this .resize); } , methods: { resize(): void { this .height = window .innerHeight; this .width = window .innerWidth; } , } , } ); export const windowSize = new WindowSize(); こうする事でクライアントサイドの API に依存するグローバルな処理が記述されたモジュールでも透過的に import できるようになりました。 クライアントサイドのみまたはサーバーサイドのみの実装とは違い、いくつか考慮するべき事はありますが、結果的により堅牢な実装を求められる点がユニバーサル JavaScript の面白さでもあると思います。 リニューアルの成果 リリースされた検索ページでは下図のように不要なリロードを必要としない SPA にリニューアルされました。これによってスマートフォンでの再検索のストレスが軽減されたと考えています。 SPA 化による不要なリロードの無くなった検索ページ またクライアントサイドのパフォーマンス指標である Speed Index の RUM 値も、リリースを境に改善する事ができました。 RUM-SpeedIndex トラッキング値の変化 このリニューアルを契機に Classic ASP による密結合なアプリケーション実装から、Python をバックエンド、API とし、BFF と Nuxt.js をフロントエンドとする疎結合な開発体制が確立しました。今後はこの開発体制のメリットを最大化しユーザー体験の向上へと繋げていくのが、新フロントエンドの課題です。 モダン・フロントエンドで提供する価値とは 変化の激しい web フロントエンドですが、昨年は PWA の推進や AMP の登場、パフォーマンス指標の定量化など、めまぐるしい年だったように思います。一休.com レストランの web サイトは、これら技術を最大限活用し、ユーザーにとってより良い web サービスを提供してゆきたいと考えています。 今後の web フロントエンドの取り組みとしては; 継続的なパフォーマンス改善 PWA 化による web 体験のエンハンスメント ユーザー体験の向上につながるドラスティックな UI 改善 などを考えています。 そんなわけで 一休.com レストランでは、ユニバーサル JavaScript が得意なフルスタックエンジニア、BFF 設計や GraphQL を得意とされる Node.js エンジニア、コンポーネント指向を実践でき Web Components のような標準仕様にも敏感な web フロントエンドエンジニア、デザインシステムや Brad Frost 氏の提唱する Atomic Design へ高い関心をお持ちのデザイナーなど、プロフェッショナリズムにあふれたメンバーを募集しています。ラグジュアリーなサービスを最高のクラフトマンシップで支えてくれる方からのご応募お待ちしております! www.ikyu.co.jp www.wantedly.com 以上、CTO 室レストラン担当エンジニアの稲尾 id:supercalifragilisticexpiali がお伝えしました。