JavaScript が読み込まれる前でもWeb Applicationを動かす

今回は最近取り組んでいる、 JavaScript が読み込まれる前であっても「ちゃんと」 Web Application が動作するように作る話をします。

Server Side Rendering における注意点と対策

BFFを使ってServer Side Rendering をすることに数年前から取り組んでいます。

まずはSSRをやる上での注意点と対策について紹介します。

SSRをすることはSEOのためだと思われがちですが、個人的にはSEOのためにしているわけではなく、 First View を向上するため(特に First Meaningful Paint を向上するため)にやっています。

f:id:yosuke_furukawa:20190210220602p:plain
First View

SEOSSRに関しては Google が最近出したこの記事SEO Considerations 節が詳しいです。ここでは説明しません。

SSRをしない、Client Side Renderingのみの場合、この First Meaningful PaintJavaScript がダウンロードされてからになるので、遅れてしまいます。

ユーザーの環境は様々で、潤沢なwifi環境が揃っていて、最新のマシンが使えて高速という環境ばかりではありません。古いマシンを使って、インターネット環境が整備されてない状況ではJavaScriptをダウンロードする時間もJavaScriptをダウンロードしてから実行する時間も遅くなります。

色々なケースも想定し、SSR を使って First Meaningful Paint を向上することで、表示されるまでの時間の短縮を行っています。

ただし、SSRFirst Meaningful Paint を高速化する一方で、表示が速すぎると、操作するまでの時間、 Time To Interact までに乖離が発生します。

f:id:yosuke_furukawa:20190210221642p:plain
Gap between FMP and TTI

乖離が長ければ長いほど「見えてるのに操作できない時間」が長くなり、ユーザーのストレスになります

これを改善するためにはページあたりに読み込まれるJavaScriptの量を減らすことで、読み込まれる時間を改善する、 Code Splitting と呼ばれる前処理をする必要があります。また、Time To Interact の時間までインジケーターを出してユーザーに処理中であることを表示するのも有効です。

ただ、Code Splitting をしたとしても分割しすぎるとページ遷移のたびに差分のJavaScriptが必要になったり、そもそも React DOM などの巨大なライブラリに依存していると全体で読み込まれるJavaScriptgzip後で数100kbを超えることも珍しくなく、限界があります。

逆にSSRをやめて、Client Side Rendering のみにしてしまうと、「見えるまでの時間」と「操作するまでの時間」のギャップはなくなります。しかしこの場合、人間としては「見えて(認知して)から操作する」ので、ユーザーにとって最適な時間にはなりません。

JavaScript が読み込まれる前でも操作できるようにする

そこで、今取り組んでるのが、いくつかのページではJavaScriptが読み込まれる前でも普通にウェブアプリケーションとして操作できるようにしています。

HTML と Server だけでもちゃんと動くように作る

こうなると、 HTML と Server だけでもちゃんと動くように作らなければいけません。JavaScript を disabled にした状態でどこまでちゃんと動作するかを確認しながら作る必要があります。

リンクの場合

JavaScript のみで動作させる場合、リンクには click イベントをフックするハンドラを用意して、そこで pushState などのURL変更 API を使って遷移させることが多いです。

SSRレンダリングした上で、リンクなどの処理は a タグで書きつつ、 遷移先のURLを定義します。 click ハンドラはそのままにしておけば、JavaScriptが読み込まれる前に実行してもただの a タグでの遷移として動作します。

<a href="/foo" id="js-link" />

// JavaScript
document.getElementById("js-link").addEventListener("click", (e) => {
  e.preventDefault();
  history.pushState({}, "", e.target.href);
})

この辺りはSSRを作る上ではライブラリに頼ることも多いと思うので、ライブラリが提供してくれる機能でも動作します。 react-router などのライブラリも同様の機能を提供します。

form の場合

form の場合は複雑です。formの場合はSSRとは違って、 method が POST のケースも多いので、 POST を受け付けられるエンドポイントを用意する必要があります。

また、 methodaction などの form の属性もきちんと指定する必要があります。 form の method を指定しないために、全部 GET リクエストになってしまったり、ボタンをsubmitにしてなかったために、きちんと送信されないケースも散見されます。

また、 POST などの更新系の操作をする場合、厄介なのは CSRF 対策もしてあげる必要があることです。 hidden の input に対して csrf token と呼ばれるセッションに紐づくランダムな値を設定しないといけません。XHRとは違ってカスタムヘッダを渡すことも、Cross Origin のときにpreflightチェックのリクエストが飛ぶこともないので、準備が必要です。

// form に対してmethodを指定する。
<form onSubmit={handleSubmit} method="POST">
      <div>
        <input type="email" name="username" component={RenderInput} />
        <input type="password" name="password" component={RenderInput} pattern="(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,}"/>
      </div>
      {/* csrf 対策を入れる */}
      <input type="hidden" name="_csrf" value={csrf} />
      <div>
        <button type="submit" disabled={submitting}>
          Login
        </button>
        <button type="button" disabled={submitting} onClick={reset}>
          Clear
        </button>
      </div>
</form>

ログインのような典型的なフォームの場合はユーザーが何をするべきかが表示された段階ではわかっている事が多いため、操作するまでの時間を短縮することで、効果も大きくなります。

また、検索画面のようなフォームも入力項目が少ないため、ユーザーは表示された瞬間に迷わずにクエリーを打ち込みに行きます。このときにクエリーパラメータでURLが変わったとしても動くように作ってあげる必要があります。

このアプローチのメリットとデメリット

アプローチの内容はわかったところで、メリットとデメリットについて紹介します。

メリット

まずは性能的な面でメリットがあります。先程、操作できるようになるまでの時間と表示するまでの時間に乖離が大きいとユーザーはストレスを感じる、という説明をしましたが、JavaScriptをダウンロードするまで待つ必要はなく、「操作できるようになる時間が表示された時間とほとんど同じ」 になります。

ちょうど数ヶ月前に Netflix React の CSR をやめて、SSRでのみReactを採用した、という記事がありましたが、そこでもパフォーマンスの改善事例として紹介されていました。

アプローチとしてはほぼ一緒です。Time to Interact にフォーカスするのであれば、 JavaScript をダウンロードされてなくても動作させる方が効果的です。

次に accessiblity としても効果があります。JavaScriptに頼らずに普通に作ると verified な HTMLを書く必要に迫られます。form の method や action もそうですが、input の name や type といった属性もきちんと書かないといけません。こうすることで、矯正ギブスのような役割を果たしてもらえます。

デメリット

一言で言うと、「実装するのが大変」というところです。一度すべてのページを JavaScript をオンにして動かせるようにしたあとで JavaScript がオフでも動くようなアプリケーションにすることは実装の観点から見るとやってやれなくはないものの、大変です。

また、完璧に JavaScript が動作するアプリと同じUXを提供することは不可能です。先程のformの例でいうと、 validation などは input タグの pattern 属性にマッチしてるかどうかしか出すことができません。 JavaScript を使った validation では、もう少し細かく入力値をチェックできます。例えば「パスワードは英字、数字、記号の3種類が必ず入ってて、8文字以上」などの条件の validationpattern 属性で正規表現で書いたとしても、「パターンにマッチしたかどうか」だけしかチェックされません。 JavaScript では、「記号が抜けてる」、「8文字以下」という細かくメッセージで何が満たせてないかを表示させることで入力を補助させることができます。

リンクならともかく、ボタンの場合や、モーダルウィンドウで確認ダイアログを出したい場合など、大体においてJavaScriptが必要なケースは多く、すべてを JS が disabled にした状態で動作させるようにするのはやはり難しいと言わざるを得ません。

個人的には、ログインやトップページの検索画面など、ユーザーがある程度最初の方に触る(JavaScriptがダウンロードされる前に触りそうな)ページを JS disabled でも動作するように作り、残りは noscript タグで JavaScript がオンじゃないと動かない旨を出してあげるくらいでしょうか。

また、最後にどうしようもないところですが、解析系のタグが動作する前に動いてしまうので、ユーザーのトラッキングはできない可能性があります。この手の解析系がちゃんと動かないとNGのところも多いので、注意してください。

まとめ

JavaScript が読み込まれる前に SSR と HTML だけで動作するアプリを作ることに関しての紹介をしました。 最近の開発では、この方法を基本としており、SSRの弱点の一つである、 First Meaningful PaintTime to Interact までの差を JavaScript が読み込まれる前でも動作させることで解消しています。

ただし、すべてのページでできているわけではなく、トップページに近いような場所でのみ、部分的に動作させています。こうすることで以下のような効果を狙っています。

  1. JavaScript がダウンロードされてなくてもある程度は動作することで、パフォーマンス上のメリットを狙う
  2. HTMLでも verified な状態にすることで、 accessibility の観点や JS がオフのユーザーであってもある程度は利用できるようにする
  3. すべてのUXをJSオフの状態で提供することは不可能なので、必要なページではJSがダウンロードされるまで動作しないように作る

こうすることで、SPAのUXも提供しつつ、潤沢なインターネット環境を持っていないユーザーであってもある程度高速に表示され、動作するように作っています。