掲示板のJavaScriptこういう風に最適化しました

最近Railsで掲示板つくってて、サボって後回しにしていたJavaScriptの最適化をやりました。

掲示板の構成

  • Webpackを使っている
  • Reactを使っている
  • Server-Side Renderingをやっている
  • Railsを使っている
  • Sprocketsを使っていない

作業内容

  • webpack-bundle-size-analyzerで容量の大きいpackageを調査
  • HTTPクライアントに利用していたjQueryを撤廃
  • HTTPクライアントにaxiosを採用
  • lodashを一部しか読み込まないように変更
  • moment.jsの不要なlocaleを読み込まないように設定

変更結果

これでminify後の容量が770KB→476KBに。gzip圧縮状態では202KB→125KB。

$(npm bin)/webpack --profile --json | webpack-bundle-size-analyzer
react: 649.51 KB (40.3%)
babel-polyfill: 198.61 KB (12.3%)
  core-js: 197.72 KB (99.6%)
  <self>: 907 B (0.446%)
moment: 139.7 KB (8.67%)
es5-shim: 104.64 KB (6.49%)
lodash: 51.98 KB (3.23%)
babel-runtime: 40.07 KB (2.49%)
  core-js: 39.63 KB (98.9%)
  <self>: 456 B (1.11%)
fbjs: 32.53 KB (2.02%)
axios: 32.1 KB (1.99%)
react-on-rails: 30.06 KB (1.87%)
regenerator-runtime: 21.34 KB (1.32%)
style-loader: 6.99 KB (0.434%)
process: 5.17 KB (0.321%)
object-assign: 1.95 KB (0.121%)
css-loader: 1.47 KB (0.0913%)
webpack: 251 B (0.0152%)
react-dom: 132 B (0.00800%)
<self>: 294.87 KB (18.3%)

現状の解析結果はこのような状態。babel-polyfillは使うものだけに分割できそうだけど、取り除いたところで10KB程度の努力になりそうなので今はやめておく。これ以上減らそうと思うとジェンガみたいな状態になりそう。

掲示板では目に見えるほとんどの部分がServer-Side Renderingされるので、JavaScriptの配信が速いかどうかというのはそこまで利用者の体験にとってクリティカルなものではないです (という建前で最適化をサボっていた)。しかしLikeを付けたりXHRでページ遷移したりと動きのある部分が多いので、速いにこしたことはない。まあクライアントサイドで描画する部分が多く存在するようなサイトほど、最適化に気を払うべきだと思います。

lodash

lodashは、普通に全部盛り込むとminify前の容量で500KB程度になる。辛い。幸い、 使う関数だけ読み込むようにすれば、必要な部分だけが含まれるようになって軽くなる。

import difference from "lodash/difference";
import omit from "lodash/omit";

moment.js

momentは利用する言語ファイルを全部読み込んでしまうので、日本語だけ読み込むように変更した。これはWebpackの機能を使った。lodashみたいにimportだけで上手く解決できるともっと嬉しい。

const plugins = [
  new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /ja/),
];

axios

https://github.com/mzabriskie/axios これです。気になるところもあるんですが、そんなに悪くない。何故これにしたのかと聞かれると、対外的には「Promiseが使えて、かつInterceptorsで処理を差し込む部分を使いたかったので採用しました」と説明すると思う。HTTPクライアントは最初はfetchで代替しようかと思ったものの、現時点ではまだ少し問題があることを知っているのでやめました。

HTTPクライアント

今回は、Railsを使っているもののHTTPクライアント以外の用途でjQueryを使っていなかった (えらい) ので、簡単に撤廃してHTTPクライアントだけ別の実装に置き換えられた。普通は jquery-ujs などを安易に使ってしまう。

HTTPクライアント部分は次のような実装になった。CSRF対策のトークンを埋め込むために、前述したaxiosのInterceptorsの仕組みを利用している。このファイル以外から直接axiosを参照することは無い。

import axios from "axios";
import ReactOnRails from "react-on-rails";

axios.defaults.withCredentials = true;

axios.interceptors.request.use(
  (config) => {
    config.headers["X-CSRF-TOKEN"] = ReactOnRails.authenticityToken();
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);

function sendDelete(url) {
  return axios.delete(url).then(response => response.data);
}

function sendPatch(url, data) {
  return axios.patch(url, data).then(response => response.data);
}

function sendPost(url, data) {
  return axios.post(url, data).then(response => response.data);
}

export function archiveTopic({ topicIncrementalId }) {
  return sendPost(`/topics/${topicIncrementalId}/archive`);
}

export function bookmarkTopic({ topicIncrementalId }) {
  return sendPost(`/topics/${topicIncrementalId}/bookmark`);
}

export function createComment({ body, topicIncrementalId }) {
  return sendPost(`/topics/${topicIncrementalId}/comments`, { body });
}

export function deleteComment({ commentIncrementalId, topicIncrementalId }) {
  return sendDelete(`/topics/${topicIncrementalId}/comments/${commentIncrementalId}`);
}

export function likeComment({ commentIncrementalId, topicIncrementalId }) {
  return sendPost(`/topics/${topicIncrementalId}/comments/${commentIncrementalId}/like`);
}

export function pinTopic({ topicIncrementalId }) {
  return sendPost(`/topics/${topicIncrementalId}/pin`);
}

export function subscribeTopic({ topicIncrementalId }) {
  return sendPost(`/topics/${topicIncrementalId}/subscription`);
}

export function unarchiveTopic({ topicIncrementalId }) {
  return sendDelete(`/topics/${topicIncrementalId}/archive`);
}

export function unbookmarkTopic({ topicIncrementalId }) {
  return sendDelete(`/topics/${topicIncrementalId}/bookmark`);
}

export function unlikeComment({ commentIncrementalId, topicIncrementalId }) {
  return sendDelete(`/topics/${topicIncrementalId}/comments/${commentIncrementalId}/like`);
}

export function unpinTopic({ topicIncrementalId }) {
  return sendDelete(`/topics/${topicIncrementalId}/pin`);
}

export function unsubscribeTopic({ topicIncrementalId }) {
  return sendDelete(`/topics/${topicIncrementalId}/subscription`);
}

export function updateComment({ body, commentIncrementalId, topicIncrementalId }) {
  return sendPatch(`/topics/${topicIncrementalId}/comments/${commentIncrementalId}`, { body });
}

おわり

自社サービスの強大なJavaScriptに立ち向かうために、ジェンガで気持ちを高めましょう。

ジェンガ

ジェンガ