エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Vue.jsほぼ初心者だけのチームで既存サービスのリニューアルをやってみて得られた知見

こんにちは、エンジニアリンググループの福林 (@fukubaya) です。

2019年3月に僕たちのチームが担当するスマートフォンサイトをリニューアルしました。 リニューアルに際して、せっかくなので新しい技術やフレームワークを採用したいということで、詳しいメンバーはいませんでしたがVue.jsでリニューアルすることにしました*1。 本記事では、Vue.jsがほぼ初心者だけのチームでVue.js製プロジェクトをリリースするまでに得られた知見をまとめます。 すでにバリバリ使いこなしている方には物足りないと思いますが、これからVue.jsを始める方の参考になれば幸いです。

f:id:fukubaya:20190422214132j:plain
黒部宇奈月温泉駅は富山県黒部市にある北陸新幹線の駅。本文には特に関係ありません。

サービスの概要

リニューアル対象の「m3.comカンファレンス」は、会員の医師同士で専門的な意見交換をするための掲示板のようなサービスです。Yahoo!知恵袋のようなサービスを想像していただければだいたい合っています。

medqa.m3.com

リニューアル前のスマートフォンサイトは、主にjspとjQueryで構成されていました。 jspにロジック本体がそのまま埋め込まれるレベルの複雑さではありませんでしたが、 表示側の制御だけでも長年の開発でそれなりに複雑になっており、機能追加が難しくなっていました。

今回のリニューアルでは、データのやりとりはすべてREST APIに寄せ、SPAで構成することにしました。

開発環境

プロジェクトの管理はVue CLIで

今から始めるならばVue CLIでプロジェクトを作るのがオススメです。

cli.vuejs.org

# Vue CLIのインストール
% yarn global add @vue/cli

createコマンドでプロジェクトを作成すると、 必要な設定を対話的に決めた後は必要なpackage、設定ファイルのインストールまで進めてくれます。

# 新規appの作成
% vue create new-app
...

Chromeの機能拡張 Vue.js devtoolsは必須

各コンポーネントのdataやpropsだけでなく、Vuex や Vue Routerの状態まで表示できます。 ブラウザから手動でdataを書き換えて動作を確認することもできます。開発効率が何倍も違います。

chrome.google.com

f:id:fukubaya:20190417204236p:plain
Vue.js devtools

バンドルサイズの確認はvue uiで

Vue CLIはGUI(ウェブブラウザ)でも操作できます。

# localhostでサーバが起動
% vue ui

以前は別のツールを入れないとバンドルサイズなどは確認できませんでしたが、 今はGUIから視覚的に確認できます。

f:id:fukubaya:20190417205233p:plain
packageごとにバンドルサイズを確認できる

Storybookはデザイナーさんもうれしい

StorybookはVue.jsだけでなくReactやAngularでUIコンポーネントを開発するためのツールです。

storybook.js.org

# Vue CLIでインストール
% vue add storybook

各コンポーネントをStorybookに表示させ、操作するためのコードを書く必要はありますが、 一旦書いてしまえばブラウザで実際の表示を確認しながらコンポーネントを開発できます。 プラグインを入れるとviewportが変わった場合の表示を切り替えたり、 コンポーネントへ渡すpropsを変えた場合にどう表示が変わるかをインタラクティブに確認できます。

f:id:fukubaya:20190417204312p:plain
コンポーネントをスマートフォンで表示させた時の例
f:id:fukubaya:20190417204337p:plain
同じコンポーネントでタイトルを長くし、ランキング表示用に変えた場合にどう見えるか

デザイナーさんは基本的にはStorybookとコンポーネントだけを見れば開発ができることが多いので、 ローカルで別途APIサーバを立ち上げたり、テスト環境にいちいち全部をデプロイしなくてもデザイン作業を進められます。 動的な部分の表示の変化もデザイナーさんが自分で試せるので、 エンジニアはコンポーネントのtemplateとscriptを実装しておけば、ある程度デザイナーさんにお任せできます。 例えば、文字数が長い場合の折り返しがどう見えるか、特定の条件でだけ表示されるアイコンが正しい位置にあるか、はStorybook上で確認できます。

プロジェクト設定

lintと自動フォーマットは必須

Vue.jsに限ったことではないですが、チームで開発する上でlintと自動フォーマットは必須です。 もはや人間が気にするべき領域ではありません。 フォーマットが統一されていれば、diffが本来やりたい変更だけになるので、差分が分かりやすくなります。 特にHTMLとCSSは本来やりたかった変更以外のスペースや改行が入りやすいので、 フォーマットは初めから自動で実施するようにしておく方がよいです*2。

commit前にステージング対象の.jsと.vueにlintと自動フォーマットを実行し、再度ステージングする設定しました。

package.json

  "gitHooks": {
    "pre-commit": "lint-staged"
  },
  "lint-staged": {
    "*.{js,vue}": [
      "vue-cli-service lint --fix",
      "git add"
    ]
  }

テストを書けばコンポーネントは適切に分割できる

jspが生成するページをjQueryで操作するようなページでは、DOMがjspの実行結果によって確定するので、 実際のDOM構造に合わせたテストを作るのは不可能ではないですが困難でした。 Vue.jsではページ内の部品がコンポーネント単位に分割されているので、 他のコンポーネントとは独立にテストを実行することができます。

逆に言うと、コンポーネントはテストを実行しやすいような単位に分割するべきです。 テストを意識すると、コンポーネントの設計も自然と適切な形になります。 巨大なコンポーネントのテストが複雑になるのは、他の言語と同じです。

// tests/unit/components/DialogModal.spec.js
import DialogModal from "@/components/DialogModal.vue";
import { mount } from "@vue/test-utils";

describe("DialogModal", () => {
  it("should show okLabel and emit ok when clicked", () => {
    // DialogModalコンポーネントをマウント
    // propsの okLabel に "OK" を指定
    const wrapper = mount(DialogModal, {
      propsData: {
        okLabel: "OK"
      }
    });

    // マウントしたコンポーネント内のbuttonタグを探してクリック
    wrapper.find("button").trigger("click");

    // buttonタグのテキストが "OK" になっているか?
    expect(wrapper.find("button").text()).toBe("OK");

    // コンポーネントから "ok" イベントが送信されたか
    expect(wrapper.emitted().ok).toBeTruthy();

    // コンポーネントから送信された "ok" イベントは1回だけだったか
    expect(wrapper.emitted().ok.length).toBe(1);
  });
});

注意すべきなのは、コンポーネントのDOM構造やCSSクラスを変更しただけでも テストに失敗してしまう場合があることです。

    expect(
      wrapper.find("div.someclass span.num").text()
    ).toBe("10");

このような指定をしていると someclass の名前が変わった時や span がなくなった時にテストに失敗します。 見た目を直すだけであっても、DOM構造やCSSクラスを変更することはよくあるので、テストは失敗しやすくなります。

DOM構造やid、classに頼らず、テストで使うためだけのattributeを用意してそれを指定する、 という方法がよさそうです*3。

qiita.com

開発サーバから別のAPIサーバにプロキシする

yarn run serve (vue-cli-service serve --port 8081) で起動できるdevServerで 開発中のvueプロジェクトをlocalhostで起動することができますが、 REST APIのURLが本番とローカルの開発サーバで違ってしまい、本番環境の設定ではAPIサーバに接続できません。

devServerにプロキシ設定を vue.config.js に入れると、 いちいちREST APIのURLを切り替える必要がなくなります。

module.exports = {
  devServer: {
    proxy: {
      "^/service/api": {
        target: "http://localhost:9000",
        ws: false,
        pathRewrite: {
          "^/service/api": "/api"
        }
      }
    }
  }
};

この設定ではdevServerの /service/api* へのアクセスが http://localhost:9000/api* へプロキシされます。

SpringBootに組み込む

mvnでビルドするjarに組込むためにいくつか vue.config.js に設定を入れました。 cssやjsなどは static 以下に、 index.html を sp-index.html のファイル名で templates 以下に出力しました。

module.exports = {
  ...
  outputDir: "src/main/resources/static/sp",
  indexPath: "src/main/templates/sp-index.html",
  ...

Controllerの方では、アクセスの可能性のあるpathのパターンをVueRouterの設定と合わせて すべて sp-index.html で受けるように設定しました。 / だけだと VueRouterでルールを設定していても、 直接アクセスが来た時に sp-index.html が読まれないので表示できません。

@Controller
@RequestMapping(value = "/sp")
public class SpPageController {

  private static final String SP_INDEX_PATH = "sp-index";

  @Autowired
  protected MessageService messageService;

  // VueRouterの設定と合わせる
  @RequestMapping(value = {"/", "/foo/**", "/bar/list/*", "/form/**"},
      method = RequestMethod.GET)
  public String index(HttpServletRequest request) {
    return SP_INDEX_PATH;
  }
}

Vue Router

path paramsだけが違うページ間で遷移してもcreated()もmounted()も呼ばれないのでページ内容が更新されない

/contents/topic/12 のようにpathにパラメータを持たせている場合、Vue Routerでは

    {
      name: "topic",
      path: "/contents/topic/:topic_id",
      component: TopicView
    }

のような設定をする訳ですが、 /contents/topic/12 → /contents/topic/13 のように ページ遷移しても TopicView の created() も mounted() も再度呼ばれることはありません。 同じ TopicView での遷移なのでこのコンポーネントはそのまま再利用され、更新する必要がないからです。 created() や mounted() にだけAPIからコンテンツを読み込む処理を書いていても、 ページ遷移した時に新たにコンテンツ取得処理は実行されません。

Vue Routerでは、ページ遷移の一連のそれぞれのタイミングで実行する処理を設定するためのフックが用意されています。 そのうち、コンポーネントは再利用されるけど別ルートに遷移する時に呼ばれるフックbeforeRouteUpdate でコンテンツを読み込む処理を実行することにしました。

  /** 最初にコンポーネントが作られる時に呼ばれる */
  created() {
    this.initialize();
    this.setupPage(this.$route);
  },
  /** コンポーネントは再利用されて、ルートだけ変わる時に呼ばれる */
  beforeRouteUpdate(to, from, next) {
    // toが遷移先、fromが遷移元の $route
    this.initialize();
    this.setupPage(to);
    // 続きの処理を実行
    next();
  },
  methods: {
    /** 初期化(最初にコンポーネントが読まれる時、ページ全体を更新する時に呼ぶ) */
    initialize() {
      this.refresh();
    },
    /** dataを初期化する */
    refresh() {
      this.foo = {};
      this.bar = {};
    },
    /** コンテンツの読み込み */
    setupPage(route) {
      // path parameterを使ってデータを取得
      const topicId = route.params.topic_id;
      this.foo = ApiClient.getFoo(topicId);
      this.bar = ApiClient.getBar(topicId);
      ...
    }
  }

表示機会が少ないページは遅延ロードさせてバンドルサイズを小さくする

サービス内にはよく表示されるページと、それほど表示されないページがあります。 投稿一覧ページはよく表示されますが、問い合わせフォームはほとんど表示されません。

特に意識しないでビルドすると、よく表示されるページもそうでないページも全部一緒のファイルにまとめられます。 仮に、問い合わせフォームのファイルサイズが全体の半分を占めてしまっていたら、 投稿一覧ページしか見ないユーザは本来必要なファイルの2倍のサイズをダウンロードすることになってしまいます。

スマートフォンサイトなので、ダウンロードさせるサイズは可能な限り小さくしたいです。 そこで、表示機会が少ないページは遅延ローディングさせてファイルを分割し、 ダウンロードサイズが適切になるようにしました*4。

// router/index.js

// よく見られるページは一括import
import TopicList from "@/views/TopicList.vue";
// あまり見られないページは遅延ロード
const InquiryForm = () => import("@/views/InquiryForm.vue");

const router = Router({
  ...
    routes: [
      {
        name: "topicList",
        path: "/",
        component: TopicList
      },
      {
        name: "inquiryForm",
        path: "/inquiry",
        component: Inquiryform
      }
    ]
});

router.vuejs.org

便利だったライブラリ

vue-content-loader

コンテンツの読み込み中、表示されるべきコンテンツの代替として一時的に表示させておくアレを作れます。 実体はSVGのアニメーションになるので、アニメーションGIFなどを用意しなくても実現できます。 オンラインエディタ がGUIでコンポーネントまで生成できて便利です。

github.com

vue-lazyload

画像の遅延表示機能を組込みます。imgタグのattributeをちょっと直すだけで導入できるので設置も簡単です。

github.com

vue-scrollto

座標だけでなく、特定のコンポーネントがある位置でスクロール位置を指定できます。

github.com

検討したが(まだ)やらなかったこと

TypeScriptはまだ使わない

開発当初、TypeScriptで書くことも検討しましたが、最終的には見送りました。 一番の理由は、各種外部ライブラリを利用するにあたって、ドキュメントやWeb上の情報がライブラリによっては まだ揃っていないと感じたためです。 VuexやVue Routerなど有名なものはサポートやドキュメントもできつつありますが、 マイナーなものほど情報が見つからず試行錯誤に時間がとられそうだと判断しました。

ただ、ひととおり実装が終わった現在の認識としては、やはり型がある方が実装やテストの面で有利*5であることは明白なので、タイミングをみてTypeScriptへの移行は実施しておきたいです。

VeeValidateまだ使わない

VeeValidate というとても便利なValidation Frameworkがあります。

baianat.github.io

本サービスでは、投稿フォームのバリデーションに当初導入するつもりでしたが、途中でやめることにして、自前にバリデーションを実装することにしました。

理由はバンドルサイズの大きさです。単純に追加するとgzipでも30KBくらいになります*6。 必要なルールとエラーメッセージの言語を指定することもできますが、それでも15KBくらいです。 Vue.js本体が高々30KBなのでそれと同等のサイズに見合う必要性を感じなかった、 さらにバリデーションが必要な項目が多くなかったので今回は見送りました。

問題はバンドルサイズだけなので、社内で使う管理画面とか、入力項目が多いようなページでは使ってもよいと思います。 また、将来的にはさらに小さくする計画もある*7ようなので、実装されたらスマートフォンサイトでも採用するかもしれません。

We are hiring!

サービス概要で紹介したように、僕たちのチームでは、レガシーなサービスがそれなりに残っています。 これからも古いものはリニューアルしていく予定なので、 フロントに限らず新しい技術でレガシーサービスを置き換えていける仲間を募集中です。 お気軽にお問い合わせください。

jobs.m3.com

*1:社内の別チームに事例はあったので最悪なんとかなるだろう、という目論見はありました。

*2:HTMLタグの改行位置が気持ち悪いですがじきに慣れます。

*3:それでもデザイン修正時にデザイナーさんが消しちゃったりすることはありますけどね。

*4:分割しすぎるとサーバへのリクエストが増えてしまってそれはそれで遅くなってしまうので、どう分割するかはサービスの利用状況などを勘案して決めるべきです

*5:型がないと、コードを変更した時に、既存テストが間違って通ってしまう可能性が高いと感じました。

*6:https://baianat.github.io/vee-validate/concepts/bundle-size.html#minimal-bundle

*7:https://baianat.github.io/vee-validate/concepts/bundle-size.html#modular-approach