Help us understand the problem. What is going on with this article?

Vue使いなら知っておきたいVueのパターン・小技集

More than 1 year has passed since last update.

はじめに

こんにちは、モチベーションクラウドの開発にフリーのエンジニアとして参画している@HayatoKamonoです。

この記事は、「モチベーションクラウド Advent Calendar 2018」15日目の記事となります。

概要

モチベーションクラウドのフロントエンド開発では、JavaScriptのフレームワークとしてVueを採用しています。

私がモチベーションクラウドの開発にジョインしたのは2018年7月です。

それまで私はReactを使ったフロントエンド開発を2、3年ほど行なってはいたものの、Vue自体は未経験の状態でした。

今回は、そんなVue未経験だった私が、この5ヶ月弱の間に、実務や日々の学習を通して蓄積してきたVueのパターンや小技、また、Reactコミュニティーから拝借したパターンなどを簡易的なサンプルコードとともに、共有させて頂きたいと思います。

v-modelのカスタマイズ

<some-component v-model="isExpanded" />

v-modelはデフォルトでは以下のように書き換え可能です。

<some-component @input="someFunction" :value="isExpanded" />

しかし、このデフォルトの挙動を以下のようにカスタマイズすることも可能です。

  model: {
    prop: "isExpanded",
    event: "toggle"
  },
  props: {
    isExpanded: {
      type: Boolean,
      default: false
    }
  },

フォーム系のコンポーネントであれば、v-modelに紐づくprop名とevent名をそのまま、valueinputにしても良いですが、フォーム系"以外"のコンポーネントの場合、そのコンポーネントの振る舞いにより適したprop名とevent名を、上記の例のように割り当ててあげるのが良いと思います。

$once('hook:beforeDestroy')

<script>
export default {
  name: "SampleComponent",
  created () {
    this.someEventHandler = () => {
      console.log("実際の開発ではイベントは間引こう!");
    };
    document.addEventListener("mousemove", this.someEventHandler);
  },
  beforeDestroy () {
    document.removeEventListener("mousemove", this.someEventHandler);
  }
};
</script>

時折、このようにcreatedのタイミングで何らかのイベントに特定の処理を紐付け、beforeDestoryのタイミングで同じイベントに紐づけておいた処理を除去したいこともあるかと思います。

こういった場合は以下のように書くと、よりシンプルにコードを書くことが可能です。

<script>
export default {
  name: "SampleComponent",
  created() {
    const eventHandler = () => {
      console.log("実際の開発ではイベントは間引こう!");
    };
    document.addEventListener("mousemove", eventHandler);
    this.$once("hook:beforeDestroy", () => {
      document.removeEventListener("mousemove", eventHandler);
    });
  }
};
</script>

ポイントはcreatedメソッドの中で、一度だけ実行される$onceメソッドを呼び出し、Vueのhook:beforeDestroyイベントに対して、beforeDestoryメソッドの中で行いたい処理を記述するということです。

watchプロパティーのimmediate: true

Vueでは特定のpropの値が変化した時に、何らかの処理を実行したい場合、watchプロパティーを使用します。

しかし、watchプロパティーは普通に使うと、監視対象のpropの値が変化した時にのみ、監視対象のpropに紐づけたメソッドが実行されます。

watch: {
  isOpen () {
    this.count = this.count + 1
  }
}

例えば、この場合は親コンポーネントから受け取るpropであるisOpenの値が変化する時にのみ、このコンポーネント自身が持つcountがインクリメントされます。

watch: {
  isOpen: {
    immediate: true,
    handler() {
      this.count = this.count + 1;
    }
  }
}

しかし、このように書き換えることで、このコンポーネント自身が最初にマウントされたタイミングにも、countがインクリメントされるようになります。

Render Function

多くの場合、Vueでは<template>タグを使用していれば事が足りますが、render functionというVNodeをプログマティックに生成するAPIを使う事で、描画部分のコードをより簡潔に書けたり、柔軟に処理を行えたりします。

<template>
  <p :style="{ color: 'red' }">Hello World</p>
</template>

<script>
export default {
  name: "HelloWorld"
};
</script>

上記は単純に'Hello World'と赤文字で表示するだけの簡単な例です。

<script>
export default {
  name: "HelloWorld",
  render(createElement) {
    return createElement("p", 
      { style: { color: 'red' } },
      "Hello World"
    );
  }
};
</script>

先ほどのコードをrender functionを使って書くとこのようになります。

  render(createElement) {
    return createElement("p", 
      { style: { color: 'red' } },
      "Hello World"
    );
  }

render functionの引数にはcreateElementという関数が渡ってきます。このcreateElementを使って、VNodeを生成します。

createElementの第一引数には要素名やコンポーネント名を渡し、第2引数にはpropsclassなどの設定オブジェクトを任意で渡します。そして、第3引数には、子要素になるVNodeや文字列を渡します。

<script>
export default {
  name: "HelloWorld",
  props: {
    level: {
      type: Number,
      default: 1,
      validator(value) {
        return value > 1 && value <= 6;
      }
    }
  },
  render(createElement) {
    return createElement(
      `h${this.level}`,
      { style: { color: "red" } },
      "Hello World"
    );
  }
};
</script>

render functionを使えば、上記の例のように、親からlevelというpropを通して、1〜6の見出しレベルを受け取り、その受け取った見出しレベルに対応するHタグを動的にcreateElementの第一引数に渡してあげることも可能になります。

これを<template>タグを用いて行おうとすると、<template>タグの中で対応させたい見出しレベルの数だけ条件分岐を行わなければなりませんが、render functionを使えば、簡潔にコードを書く事が出来るようになります。

Functional Wrapper Component

条件毎に異なるコンポーネントを描画したいような場合は、Functional Componentでラップして条件に対応したコンポーネントを描画してあげると、コードがクリーンになります。

以下は配列の中にデータがある場合は、データがある場合のコンポーネントを描画し、データがない場合はデータが無い場合のフォールバック用コンポーネントを描画するという簡易的な例です。

App.vue
<template>
  <div id="app">
    <smart-item-list :items="items" />
  </div>
</template>

<script>
import SmartItemList from "./components/SmartItemList";

export default {
  name: "App",
  components: {
    SmartItemList
  },
  data() {
    return {
      items: [{ id: 1, name: "apple" }, { id: 2, name: "banana" }]
    };
  }
};
</script>

ここでは単に、SmartItemListコンポーネントのitems propに2つのオブジェクトを持つ配列を渡しているだけです。

SmartItemList.vue
<script>
import ItemList from "./ItemList";
import EmptyData from "./EmptyData";

export default {
  functional: true,
  props: {
    items: {
      type: Array,
      default() {
        return [];
      }
    }
  },
  render(createElement, { props }) {
    const Component = props.items.length > 0 ? ItemList : EmptyData;
    return createElement(Component, {
      props: {
        items: props.items
      }
    });
  }
};
</script>

 ここでは、親から受け取ったitems配列の中のオブジェクトの数をチェックして、配列の中身が無い場合は、EmptyDataコンポーネントを描画し、配列の中身がある場合は、ItemListコンポーネントを描画しています。

ItemList.vue
<template>
  <ul>
    <li v-for="item in items" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

<script>
export default {
  name: "ItemList",
  props: {
    items: {
      type: Array,
      default() {
        return [];
      }
    }
  }
};
</script>

データがある場合は上記のコンポーネントが描画されます。

EmptyData.vue
<template>
  <div>No Data...</div>
</template>

<script>
export default {
  name: "EmptyData"
};
</script>

データが無い場合は、'No Data...'と表示されるだけのコンポーネントが描画されます。

Error Boundary

ReactではError Boundaryという、子孫コンポーネントでエラーが発生した際にクラッシュしたUIを表示させる代わりに、フォールバックのUIを表示させる手法が存在します。

それをVueで実現する為には、VueのerrorCapturedフックを使用します。

以下はその簡易的な例です。まずは、Error Boundaryの使い方から見ていきます。

App.vue
<template>
  <div id="app">
    <ul>
      <template v-for="item in items">
        <error-boundary :fallback="fallbackItem" :key="item.id">
          <dummy-item :item="item" />
        </error-boundary>
      </template>
    </ul>
  </div>
</template>

<script>
import ErrorBoundary from "./components/ErrorBoundary";
import DummyItem from "./components/DummyItem";
import FallbackItem from "./components/FallbackItem";

export default {
  name: "App",
  components: {
    ErrorBoundary,
    DummyItem,
    FallbackItem
  },
  data() {
    return {
      items: [
        { id: 1, name: "apple" },
        { id: 2, name: "banana" },
        { id: 3, name: null }
      ]
    };
  },
  computed: {
    fallbackItem() {
      return FallbackItem;
    }
  }
};
</script>

ここでは、エラーが発生しうるコンポーネントをErrorBoundaryと名付けたコンポーネントでラップしています。ErrorBoundaryコンポーネントのpropsであるfallbackには、エラーが発生した際に代わりに表示させたいフォールバック用のコンポーネントを渡しています。

propsを通して、フォールバック用のコンポーネントを設定出来るようにすることで、ErrorBoundaryコンポーネントの利用者がエラー発生時に表示させたいコンポーネントを自由に選べるようになります。

ErrorBoundary.vue
<script>
export default {
  name: "ErrorBoundary",
  props: {
    fallback: {
      type: Object
    }
  },
  data() {
    return {
      hasError: false
    };
  },
  errorCaptured() {
    this.hasError = true;
  },
  render(createElement) {
    return this.hasError
      ? createElement(this.fallback)
      : this.$slots.default[0];
  }
};
</script>

次に、ErrorBoundaryコンポーネントを見ていきます。ここでやっていることは単に、子孫コンポーネントで発生したエラーを、errorCapturedメソッドで捕獲してあげて、エラーであった場合はフォールバック用のコンポーネントを描画し、そうでない場合は、自身のslotsコンテンツを描画してあげています。

DummyItem.vue
<template>
  <li>{{ item.name.toUpperCase() }}</li>
</template>

<script>
export default {
  name: "DummyItem",
  props: {
    item: {
      type: Object
    }
  }
};
</script>

このコンポーネントがエラーが発生しうるコンポーネントです。親コンポーネントからpropsを通して受け取るitemオブジェクトのnameプロパティーは文字列であるため、<template>の中では例として、item.name.toUpperCase()を実行して文字列を大文字に変換しています。

しかし、APIから取得したデータに異常値が含まれている場合などを想定した場合、itemオブジェクトのnameプロパティーが欠損していたり、nullであるかもしれません。

そういった場合に、文字列ではないデータ型に今回のようにtoUppserCaseメソッドを実行すると、レンダリングエラーが発生してしまいます。

FallbackItem.vue
<template functional>
  <li>nah...</li>
</template>

<script>
export default {
  name: "FallbackItem"
};
</script>

エラーが発生した場合は、ErrorBoundaryコンポーネントのpropsのfallbackに渡していた、FallbackItemコンポーネントが代わりに表示されます。

Higher Order Component

Higher Order Componentはデータや振る舞いを共通化したい時に利用する、Reactではお馴染みのパターンですが、Vueでもrender functionを使えば可能です。

Higher Order Componentは、引数にComponentを取り、別のComponentを返す高階関数です。

以下は、仮にクライアント側で認証を行うSPAであると仮定した場合に必要になりそうな、クライアント認証用のロジックやデータを提供するHigher Order Componentの例です。

Higher Order Component側

requireAuth.js
const requireAuth = WrappedComponent => {
  return {
    name: `${WrappedComponent.name}-protected`,
    computed: {
      isAuthenticated() {
        return this.$store.state.isAuthenticated;
      }
    },
    created() {
      // JWTトークンが存在、または、失効しているかどうかをチェック
      // トークンが無い、または、失効していたらログインページへリダイレクト
    },
    render(createElement) {
      return createElement(WrappedComponent, {
        props: {
          isAuthenticated: this.isAuthenticated
        }
      });
    }
  };
};

export default requireAuth;

使う側の例

router.js
export default new Router({
  mode: "history",
  base: process.env.BASE_URL,
  routes: [
    {
      path: "/",
      name: "home",
      component: requireAuth(HomePage)
    },
    {
      path: "/about",
      name: "about",
      component: requireAuth(AboutPage)
    }
  ]
});

クライアント認証のロジックを適用したいコンポーネントをrequireAuth関数の引数に渡してあげれば、引数に渡されたコンポーネントはクライアント認証のロジックを持つようになります。

Container Component、Presentational Component

Reactコミュニティーではお馴染みのパターンの1つに、データと振る舞いに関心を持つContainer Componentと、描画に関心を持つPresentational Componentを分けて実装するというものがあります。

render functionとHigher Order Componentのパターンを使えば、Vueでも同じことが実現可能です。

// Presentational Componentをimport
import SamplePage from "./SamplePage.vue";

/* 
  以下の`connect`は、Presentational Componentを引数に取り、そのコンポーネントが関心を持つ、
  VuexのmoduleのデータとVue Routerのメソッドへのアクセスを与えたContainer Componentを返す高階関数
*/
const connect = WrappedComponent => {
  return {
    name: `${WrappedComponent.name}Container`,
    computed: {
      count() {
        return this.$store.state.count;
      }
    },
    methods: {
      handlePageChange({ to }) {
        this.$router.push(to);
      }
    },
    render(createElement) {
      return createElement(WrappedComponent, {
        props: {
          count: this.count
        },
        on: {
          pageChange: this.onChangePage
        }
      });
    }
  };
};

/*
  次の2行は以下のコードをContainerの説明の為に、より明示的にしたもの
  export default connect(SamplePage);
*/
const SamplePageContainer = connect(SamplePage);
export default SamplePageContainer;

export { SamplePage };

Renderless Component(Scoped Slots)

VueにはHigher Order Componentの他にも、データやロジックを共通化する方法として、Scoped Slotsを利用したものがあります。

Scoped SlotsはReactコミュニティーでお馴染みのRender ChildrenやRender Propsパターンのようなものです。

以下はScoped Slotsを用いたContainer Componentの例です。

DataProvider.vue
<script>
export default {
  name: "DataProvider",
  props: {
    url: {
      type: String
    }
  },
  created() {
    fetch(this.url)
      .then(response => response.json())
      .then(json => (this.data = json))
      .catch(console.error);
  },
  data() {
    return {
      data: []
    };
  },
  render(createElement) {
    return this.$scopedSlots.default({
      data: this.data
    })[0];
  }
};
</script>

上記のコードでは例として、propsで渡ってきたURLにGETリクエストを行い、成功時に返って来たデータを呼び出し元にscoped slotsを通して渡しています。

this.$scopedSlots.default()自体はVNodesを含んだ配列を返す為、このメソッドの結果をそのまま、render functionの中でreturnしてあげれば、return createElement('div', [this.$scopedSlots.default()]のように、何らかのDOM要素でラップしてあげる必要もありません。

App.vue
<template>
  <div id="app">
    <data-provider url="https://jsonplaceholder.typicode.com/todos">
      <template slot-scope="{ data: todos }">
        <ul>
          <li v-for="todo in todos" :key="todo.id">{{ todo.title }}</li>
        </ul>
      </template>
    </data-provider>
  </div>
</template>

<script>
import DataProvider from "./components/DataProvider";

export default {
  name: "App",
  components: {
    DataProvider
  }
};
</script>

呼び出し元では、DataProviderコンポーネントの中でfetchしたデータをslot-scopeを通して受け取り、受け取ったデータをslotコンテンツに渡して描画してあげています。

このようなアプローチを取ると、DataProviderコンポーネントが持っているロジックを使い回すことができますし、DataProviderコンポーネントに内包されるコンテンツは差し替え可能になります。

Provide / Inject

VueにはPlugin開発向け、コンポーネントライブラリ開発向けのAPIとしてprovide & inject APIが用意されています。

通常、コンポーネントから他のコンポーネントへデータを受け渡す際は、「親コンポーネントからその子コンポーネントへ」、「その子コンポーネントからその子コンポーネントへ」といった具合に、バケツリレーのようにデータの受け渡しを行なわなければなりません。

しかし、provide & injectAPIを使えば、親コンポーネントから孫コンポーネントへデータを直接受け渡すことも可能です。

ReactではContext APIがこれに当たります。

Parent.vue
<template>
  <div>
    <h1>Parent</h1>
    <child />
  </div>
</template>

<script>
import Child from "./Child.vue";

export default {
  name: "Parent",
  components: { Child },
  data() {
    return {
      // provide対象のデータをreactiveにする為には、このように別オブジェクトで内包する必要がある
      sharedState: {
        message: "Hello World"
      }
    };
  },
  mounted() {
    // ここでは、provide対象のデータが更新された時にリアクティブになっていることを単に確認しているだけ
    setTimeout(() => {
      this.sharedState.message = "Hello Everyone";
    }, 1000);
  },
  provide() {
    return {
      providedState: this.sharedState
    };
  }
};
</script>

providedStateという名前でthis.sharedStateを、この後、孫コンポーネントで受け取る事が可能になる。

Child.vue
<template>
  <div>
    <h1>Child</h1>
    <grand-child />
  </div>
</template>

<script>
import GrandChild from "./GrandChild.vue";

export default {
  name: "Child",
  components: { GrandChild }
};
</script>

ご覧にのように、GrandChildコンポーネントにはpropsでバケツリレーでデータを受け渡してはいない。しかし、この後、孫コンポーネントでは、先ほど親コンポーネントでprovideの対象としたデータを受け取る事が出来る。

GrandChild.vue
<template>
  <div>
    <h1>Grand Child</h1>
    <p>{{ message }}</p>
  </div>
</template>

<script>
export default {
  name: "GrandChild",
  inject: ["providedState"],
  computed: {
    message() {
      return this.providedState.message;
    }
  }
};
</script>

上記のように上位階層のコンポーネントにおいて、provideで公開されたデータを、injectで受け取る事が可能になる。

provide & injectはバケツリレーの回数が多い場合に便利。

ただし、注意点としては、おそらく、ReactのContext APIが辿ったように、VueでもこのAPIは仕様が変更になることが予想されるのと、また、Presentational Componentの中で直接、VuexのStoreを参照している時と同じように、provide側とinject側で強い依存関係が生まれてしまうので、Higher Order Componentなどを用いて、抽象化してあげた方がAPI仕様の変更にも強く、また、疎結合にもなるので、そういった対応が必要になります。

HayatoKamono
https://teratail.com/users/HayatoKamono
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした