エムスリーテックブログ

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

「レスポンシブにすればPCサイトと同じ工数でPCにもスマホにも両方対応できるよね?」

できません。

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

中村の記事で宣言したDocpediaの技術的チャレンジの記事も今回で最後です*1。 今回は、PCページとスマートフォンページで共通的に使用できるVue.jsコンポーネントをどうやったら実現できるかを考えて実装した例を紹介します。

f:id:fukubaya:20200422111414j:plain
仙台サンプラザ(せんだいサンプラザ)は、仙台市都心部東側の宮城野通に面してあるホール・ホテル・会議室などの複合施設。本文には特に関係ありません。

なぜレスポンシブにしたいのか?

冒頭のタイトルは、直接こう言われたのではないですが、初期の仕様検討段階でレスポンシブにして欲しいと言われました。 よくよく聞いてみると、機能は同じでいいので、開発工数を減らしつつPCとスマホの両方対応したい、というのが真の要求でした。

最初から「PCとスマホ両方に対応したいけど、それぞれ別々に作る工数は確保できない」と要求をそのまま伝えてくれればいいですが、 エンジニアの工数を考えて先回りした仕様を提示されるのはよくあることです。 しかし、それをそのまま受け入れるのではなく、 技術と知識を組み合わせて、もっと効率的に真の要求を実現できる方法を考え提案するのもエンジニアの仕事です。 逆に、エンジニアが他の職種、ユーザの事情を勝手に忖度して、不要な設計、仕様を提示してしまうこともあります。 提示された仕様も、提示する仕様も「なぜ?」と一旦問いかけてみるのがおすすめです。

さて、レスポンシブなサイトを作ったことがある方には共感していただけると思いますが、 同じHTMLに対してCSSを工夫してPCとスマホそれぞれに対応させるのは複雑になればなるほど難しいです。 アイコンの大きさとか、位置とかならまだ簡単ですが、PCは100文字まで表示して残りは…で省略して、スマホは50文字までで省略、とかが入ってくると難しくなります。

個人的にはPCサイトとスマホサイトをそれぞれ作るのと、1つのサイトをレスポンシブに作るので、それほど工数は変わらない印象です。 弊社の開発、特にDocpediaでは、機能仕様が頻繁に更新されていた(ユーザインタビューなどの結果)ので、 確定したデザイン(HTML+CSS)を納品してもらって、それに合わせて実装する、といった流れの開発はできません*2。 仕様変更があれば、PCとスマホの両方に反映が必要ですから、別々に作ると基本的には工数は2倍です。

また、最初は「機能は同じでいい」と思って開発を進めますが、いずれPCだけの機能、スマホだけの機能が必ず欲しくなってきます。必ずです。

PCとスマホで共通のコンポーネントを使って工数を減らしたい

では、だからといって、PCサイトとスマホサイトを完全に別々に作るのも非効率です。

基本的には機能は同じなのに、 それぞれで同じ機能を作っていては工数が無駄ですし、コピペコードばかりになって後の保守が大変になります。

これらの要求を満たそうと、以下のようなVue.jsコンポーネントを作って共通化できないか、と考えました。

  • <template><style> はPCとスマホでできるだけ共通にしたい
  • <style> 内ではPCとスマホで違いがあるところだけを指定して書けるようにしたい
  • <script> 内でPCとスマホで条件分岐できるようにしたい
    • 例えば、APIに問い合わせて結果を取得する部分はPCでもスマホでも同じだが、PCとスマホでは加工処理(上限文字数など)が違う

実例

これを実現するための仕組みを考え、Docpediaで実装しました。 ただ、検討時間を十分に取れないまま開発が進んでしまって、この実装はイマイチだったと思っています。 以下で挙げる例は、コンセプトとしてはDocpediaと同じ実装ですが、 そのあたりの反省を含めた改良版です*3

構成

PCとスマホで同じコンポーネントを使いますが、実体としては別々のページとして構成します。 vue.config.js で設定します。詳しくは こちらtemplate がPCとスマホで分かれているのは、Vueの外側で適用するCSSやJavaScriptの差で、同じでも構いません。

// vue.config.js
module.exports = {
  publicPath: "/",
  productionSourceMap: false,
  pages: {
    pc: {
      entry: "src/main-pc.ts",
      template: "public/pc-index.html",
      filename: "pc-index.html"
    },
    sp: {
      entry: "src/main-sp.ts",
      template: "public/sp-index.html",
      filename: "sp-index.html"
    }
  }
};

本番のアプリケーションでは、 SpringでUserAgentから判定して pc-index.htmlsp-index.html に振り分けています。

エントリー

上記で設定した entry でPC、スマホそれぞれの設定をします。 PCとスマホで違うのは routerAppModePlugin の引数だけです。

// src/main-pc.ts (main-sp.ts も同様)
import Vue from "vue";
import App from "./App.vue";
import { pcRouter } from "./router";
import { AppModePlugin, AppMode } from "@/plugins/app-mode";

Vue.config.productionTip = false;
Vue.use(AppModePlugin, { mode: AppMode.PC });

new Vue({
  router: pcRouter,
  render: h => h(App)
}).$mount("#app");

App.vue はVue CLIで vue create で生成されるサンプルと同じです。 各ページへのリンクと、それを表示させる <router-view> があるだけです。

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </div>
    <router-view />
  </div>
</template>
...

router

PCとスマホでそれぞれVue Routerを定義していますが、 構成は同じなので、routes は同じで、差は base だけです。 このように設定すると、devServerでは http://localhost:8080/pc/, http://localhost:8080/sp/ でそれぞれのページが表示できます。 PCでもスマホでも同じコンポーネント (Home, About) を参照しているのがポイントです。

// src/router/index.ts
import Vue from "vue";
import VueRouter from "vue-router";
import Home from "@/views/Home.vue";
import About from "@/views/About.vue";

Vue.use(VueRouter);

const routes = [
    {
      path: "/",
      name: "Home",
      component: Home
    },
    {
      path: "/about",
      name: "About",
      component: About
    }
];

export const pcRouter = new VueRouter({
  mode: "history",
  base: "/pc",
  routes
});

export const spRouter = new VueRouter({
  mode: "history",
  base: "/sp",
  routes
})

AppModePlugin

エントリーで設定しているプラグインです。 アプリケーション内の全コンポーネントに適用するためグローバルミックスインさせます。

このプラグインは、以下の機能を追加します。

  • コンポーネントに表示モードを表すパラメータ ($appMode, $appPc, $appSp) を追加する
  • コンポーネントのルート要素にCSSで指定するためのクラスを自動で追加する
import Vue from "vue";
import { PluginObject } from "vue";

/**
 * モードの定義
 */
export const enum AppMode {
  PC = "pc",
  SP = "sp"
}

/**
 * kebab-caseに変換する
 */
function toKebab(camel: string): string {
  return camel
    .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
    .replace(/([A-Z])([A-Z])(?=[a-z])/g, "$1-$2")
    .toLowerCase();
}

/**
 * モードに合わせたmixinを生成する
 */
function generateMixin(mode: AppMode): any {
  /**
   * modeに対応したclassと、コンポーネント名に対応したclassを足す
   */
  const addCssClass = function(
    $el: Element,
    componentName: string | null
  ): void {
    // v-ifなどで非表示だと $el が undefined になる
    if ($el !== undefined && $el.classList !== undefined) {
      if (componentName !== null) {
        $el.classList.add(componentName);
      }
      $el.classList.add("app-mode-" + mode);
    }
  };

  return {
    created() {
      // nameが取得できた場合だけコンポーネントの名前を取得して設定
      this._appModeComponentName =
        this.$options.name === undefined ? null : toKebab(this.$options.name);
    },
    mounted() {
      addCssClass(this.$el, this._appModeComponentName);
    },
    updated() {
      /*
         v-if で表示されなかった場合、
         mounted()の段階では $el が存在しないので
         updated()でも実行する
      */
      addCssClass(this.$el, this._appModeComponentName);
    },
    computed: {
      $appMode(): AppMode {
        return mode;
      },
      $appPc(): boolean {
        return mode === AppMode.PC;
      },
      $appSp(): boolean {
        return mode === AppMode.SP;
      }
    }
  };
}

/**
 * mixinされたcomputedに対応する型定義
 */
declare module "vue/types/vue" {
  interface Vue {
    $appMode: AppMode;
    $appPc: boolean;
    $appSp: boolean;
  }
}

/**
 * プラグインに渡すオプションの型
 */
export interface AppModeOptions {
  mode: AppMode;
}

/**
 * プラグイン本体
 */
export const AppModePlugin: PluginObject<AppModeOptions> = {
  install(vue: typeof Vue, options?: AppModeOptions) {
    const mode: AppMode = options ? options.mode : AppMode.PC;
    vue.mixin(generateMixin(mode));
  }
};

PCとスマホで処理を切り替える

AppModePlugin により、全コンポーネントに $appMode, $appPc, $appSp の3つのパラメータが設定されるので、 この値を使って表示を処理を切り替えることができます。

<!-- src/views/Home.vue -->
<template>
  <div>
    <img v-if="showLogo" alt="Vue logo" src="../assets/logo.png" />
    <HelloWorld :msg="msg" />
  </div>
</template>

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import HelloWorld from "@/components/HelloWorld.vue";

@Component({
  components: {
    HelloWorld
  }
})
export default class Home extends Vue {
  get showLogo(): boolean {
    return this.$appPc;
  }
  get msg(): string {
    return `Welcome to ${this.$appMode} App`;
  }
}
</script>

上の例では、PCサイトだけでロゴが表示されます。 また、HelloWold に渡す msg は、PCとスマホで別々になります。

f:id:fukubaya:20200422113406p:plainf:id:fukubaya:20200422113450p:plain
左がPC、右がスマホ

PCとスマホでデザインを分ける

AppModePlugin により、全コンポーネントのルート要素に、 表示モードに対応したCSSクラス (app-mode-pcapp-mode-sp) と、 コンポーネント名をkebab-caseで表したCSSクラスが自動で設定されます。

<!-- PC の HelloWorld.vue のレンダリング結果 -->
<div data-v-e1c2adf6 class="hello-world app-mode-pc">...</div>

コンポーネントのルート要素にこの2つのクラスが設定されるので、 これを組み合わせてPCだけに適用するルール、スマホだけに適用するルールを設定できます。

<!-- HelloWorld.vue -->
<template>
  <div>
    <h1>{{ msg }}</h1>
    <p>
      For a guide and recipes on how to configure / customize this project,<br />
      check out the
      <a href="https://cli.vuejs.org" target="_blank" rel="noopener"
        >vue-cli documentation</a
      >.
    </p>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";

@Component
export default class HelloWorld extends Vue {
  @Prop() private msg!: string;
}
</script>

<style scoped lang="scss">
.hello-world {
  /* 共通 */
  p {
    background-color: #dddddd;
    padding: 10px;
  }
  /* PC */
  &.app-mode-pc {
    p a {
      text-decoration: underline;
      font-weight: normal;
    }
  }
  /* SP */
  &.app-mode-sp {
    p a {
      text-decoration: none;
      font-weight: bold;
    }
  }
}
</style>

この例では <p> に対して共通設定(background-color, padding)、 PCだけの設定(a {text-decoration: underline; font-weight: normal;})、スマホだけの設定 (a {text-decoration: none; font-weight: bold;}) をそれぞれ設定します。

f:id:fukubaya:20200422115342p:plainf:id:fukubaya:20200422115358p:plain
左がPC、右がスマホ

実際には以下のようなCSSが生成されます(minifyされたものを展開しています)。

.hello-world p[data-v-e1c2adf6] {
  background-color: #ddd;
  padding:10px;
}
.hello-world.app-mode-pc p a[data-v-e1c2adf6] {
  text-decoration: underline;
  font-weight: 400;
}
.hello-world.app-mode-sp p a[data-v-e1c2adf6] {
  text-decoration: none;
  font-weight: 700;
}

scoped にしてあれば app-mode-{pc,sp} だけがあればよいと思えますが、 実際には、コンポーネント名による限定(hello-world)がないと、 子コンポーネントのルート要素にCSSの設定が反映されてしまうので、両方による限定が必要です。

qiita.com

もうちょっとスマートなやり方があるような気がしますが、 とりあえず当初の目的を達成できるコンポーネントを実現できました。

We are hiring!

Docpediaを含め、新たなサービスの開発に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。

open.talentio.com

jobs.m3.com

*1:Coroutinesは結局やらなかった

*2:もちろん小規模な案件ではあります。

*3:Docpediaチームはよかったら取り込んでね!