【Vue.js】俺的ベストプラクティス


Vue.jsを1年半ぐらい使ってきて、それなりに知見が溜まってきたので、それらを俺的ベストプラクティスとして吐き出してみようと思う。巷にあふれるVueのベストプラクティスってv-forに:key付けようとか、お作法的な話ばかりで、あまり実践的な内容は見かけないのが記事を描き初めたきっかけ。というわけで内容的にはややマニアックで、Vue初心者にはちょっと難しいかもしれないのでそこはあしからず。なお対象はVue 2で、Composition APIは使ってないのであしからず。あとTypescriptでも使ってない。

📌 スタイルガイドに従う

のっけからお作法的なことなんだけど、でもこれはやはり事なこと。とにかくVue公式のスタイルガイドをよく読んでそれに従おう。基本的な内容がほとんどだが、よくまとまっていて、従わない理由が特に見つからない。

🔗 スタイルガイド – Vue.js

いくつか特筆すべき項目をピックアップしてみた。

  1. プライベートなプロパティ
  2. 単一インスタンスのコンポーネント名
  3. プロパティ名の型式
  4. 単純な算出プロパティ

1は後述するミックスインに関係するところで、逆説的にミックスインの難しさを物語っていると思う。バッティングする可能性は命名によってなくす。

3は割と守られていないかもしない。VSCodeのVueプラグインであるVeturを使ってると、入力補完のプロパティがキャメルケースになってしまうが、これはVeturのissueにも上がっているので今後修正されるかもしれない。ちなみに私が愛するIntelliJ(やJetBrains系のIDE)ではちゃんとケバブケースで補完される。

📌 コンポーネントは外側に干渉しないこと

コンポーネントは親(外側・呼び出し側)に干渉してはいけない。例えば $parent を介して親コンポーネントのメソッドやデータにアクセスするのは避けたほうがいい。これは「この俺を上手く使いこなしたければAというmethodを持ち、Bというdataを持つことだな!」という『親を選ぶワガママ三昧な子供』が産まれてしまうからだ。こういった暗黙の前提条件は持たず、必要なものは明示的に props で要求するのがよい。

これはよく知れたプログラミングのお作法なので、コードに関しては結構守られていいる場合が多い。しかし話がことデザインの話になってくると、これが結構破られていたりする。

典型的な具体例を挙げるなら、まずは margin だ。コンポーネントのルート要素にmarginが指定されてるというやつ。これはよくない。要素間にどれぐらい余白を設けるかは、そのコンポーネントを使う側が決めるべきことで、自らが決めることではない。下の画像のような傲慢なボタンは使いづらくて仕方がない。

自分の周囲にマージンを要求してくる傲慢なボタン

他の例としては position: absolute なども同じ。コンポーネント内に position: relative があり自己完結している場合は問題ない。しかし『祖先コンポーネントがrelativeであることを期待している』やつはワガママなBadコンポーネントだ。もしフローティングボタンを作っているのなら、それをフローティングにするかどうかは親が決めることである。

実装は下から上へ・遠くから近くへ

どうしてこのような傲慢なコンポーネントが産まれてしまうのだろう? それはだいたい実装プロセスに問題があったりすると思う。基本的にコンポーネントの実装手順は『ボトムアップで(下から上へ)、抽象的なものから具象的なものへ(遠くから近くへ)』というルールに則るのがよい。(遠くから近くへという表現は私のオリジナルなので、もっとピンとくる言い方があるかもしれない)

このルールを逸脱して上から近くから実装を始めると、先程の例のようなワガママコンポーネントが産まれやすい。具体例を挙げてみる。例えば下図のような「ログインフォーム作ってよ!」と依頼された時。

さっそくダイレクトにログインフォームの実装に取り掛かり、次のようなコードを書いたとする。

📄 BadLoginForm.vue

<template>
  <div>
    <div class="container">
      <h1>ログイン</h1>

      <label class="email">
        📧 <input type="email" placeholder="メールアドレス" />
      </label>
      <label class="password">
        🔑 <input type="password" placeholder="パスワード" />
      </label>
      <button class="login">ログイン</button>
      <button class="register">新規会員登録</button>
    </div>
  </div>
</template>

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

<style lang="scss" scoped>
.container {
  width: 260px;
  height: 200px;
  padding: 20px;
  background-color: lightgray;
}

.email {
  display: block;
  margin: 10px 0;
}

.password {
  display: block;
  margin: 10px 0;
}

.login {
  margin: 10px;
  color: white;
  background-color: blue;
}

.register {
  margin: 10px;
}
</style>

さてフォームが完成したところで、メールアドレスをEmailInput、パスワードをPasswordInput、ログインボタンはLoginButtonとして汎用的なコンポーネントとして抜き出したい願望がフツフツと生まれてきたとする。というわけでそれらを愚直に別コンポーネントに切り出すことにする。

📄 BadLoginForm2.vue

<template>
  <div>
    <div class="container">
      <h1>ログイン</h1>

      <BadEmailInput />
      <BadPasswordInput />
      <BadLoginButton>ログイン</BadLoginButton>
      <button class="register">新規会員登録</button>
    </div>
  </div>
</template>

<script>
import BadEmailInput from "@/components/BadEmailInput.vue";
import BadPasswordInput from "@/components/BadPasswordInput.vue";
import BadLoginButton from "@/components/BadLoginButton.vue";

export default {
  name: "BadLoginForm",
  components: { BadEmailInput, BadPasswordInput, BadLoginButton }
};
</script>

<style lang="scss" scoped>
.container {
  width: 260px;
  height: 200px;
  padding: 20px;
  background-color: lightgray;
}

.register {
  margin: 10px;
}
</style>

うわーなんかスッキリしたな~♪なんて悦に入っていてはいけない。次のようにワガママコンポーネントが生産されている。

📄 BadEmailInput.vue

<template>
  <label class="email">
    📧 <input type="email" placeholder="メールアドレス" />
  </label>
</template>

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

<style lang="scss" scoped>
.email {
  display: block;
  margin: 10px 0;
}
</style>

📄 BadLoginButton.vue

<template>
  <button class="login">
    <slot></slot>
  </button>
</template>

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

<style lang="scss" scoped>
.login {
  margin: 10px;
  color: white;
  background-color: blue;
}
</style>

自分の周りにマージンを要求し、inputに関しては display: block までも強要していくるという傲慢ぶり。めでたく使えないコンポーネントの完成である!今後彼らを使用する際には謎の/deep/セレクタが散見される事態となるだろう。

かなりわざとらしい例かもしれないが、実際の画面はもっと複雑であり、意外と「切り出してはいけない切り出し方で」別コンポーネントを作ってしまっていることに気付かないケースはよくある。本当によくある。よく考えて切り出そう。

そういう状態に陥らないようにするには、最初に述べた『下から上・遠くから近くへ』作ることだ。この例だと、ボタンやテキストインプットのような下位部品から作るようにする。そうすればこんなBadなコンポーネントは決して生まれない。「さあ今からボタンコンポーネントを作ろう」と取り掛かったときに、まさかmarginなんか指定しようと思います?絶対考えないですよね、そんなこと。

あとは遠くから近くへ、すなわち抽象的なものから具体的なものへと作る流れを癖付けること。ログインボタンなんていきなり具体的なものを作り出す前に、そこに存在する抽象的なモノに思いを馳せることが肝心だ。そうするとPrimaryButtonやSubmitButton、はてにはBaseButtonなどなど遠くにぼんやりと存在する抽象的なコンポーネントを作るべきでは?という点に考えが及ぶようになる。他にもEmail/PasswordInputじゃなくて、IconLabledInputやレイアウト用のFormControlというコンポーネントが必要ではないか?とかね。そうなってくると、コンポーネント設計は楽しい。

コンポーネントは『外側に干渉してはいけない』『自分がどのように使われるかを知ってはいけない』。

📌 ミックスインを頑張らない

Vueの便利機能のひとつミックスイン(Mixin)は使い所によっては非常に便利だが、実は結構使い所が少ない。まずはきちんとミックスインを理解しよう。

🔗 Vue公式 – ミックスイン

ミックスインを多用したときに起こりがちな問題点については、以下のサイトがよくまとまっていると思う。

🔗 俺がやらかしたVue mixinのアンチパターンから学ぶmixinの使い方と代替案

要するに通常のオブジェクト指向言語の継承と違い、Vueのミックスインは継承ツリーを持たないのでTemplate methodのようなデザインパターンは使えませんよーということかな。内容についてはほとんど賛成なのだが、createdやmountedなどのライフサイクルフック関数に関する点に関しては違う意見で、これはその仕様を理解した上では使用しても構わないと思う。(これらのフック関数はマージで潰されるわけでないので)

基本的に『ミックスインを使用する側のコンポーネントに特定のdataやmethodが定義されていることを前提とする』場合はミックスインを利用しないほうがよい。ミックスインが暗黙的な前提条件を課すことになるので。

逆にミックスイン側のdataやmethodを呼び出してもらうことを前提としている場合で、どうしてもミックスインで実現したい場合は命名によってdataやmethodの汚染を回避しないといけない。Vueのスタイルガイドをよく読もう。プライベートな関数はモジュールスコープを利用し、パブリックなdataやmethodはその命名によって危険を回避する具体例が載ってある。

🔗 スタイルガイド – Vue.js

個人的にはパブリックなdata/methodに対する長ったらしい命名が設計上の負けを認めているようで好きになれない。では結局のところ、いつミックスイン使えばいいの?となるわけだが、それは結構限られているんじゃないかと思う。

個人的な判断基準としては、

  • ライフサイクルフック関数で何かをする
  • Vuexのストアに関して何かをする

ぐらいかなと思ってる。例えばライフサイクルフックを通知するだけのデバッグ用ミックインとか?

📄 LifeCycleLogger.js

export default {
  beforeCreate() {
    console.log("beforeCreate");
  },
  created() {
    console.log("created");
  },
  beforeMount() {
    console.log("beforeMount");
  },
  mounted() {
    console.log("mounted");
  },
  beforeUpdate() {
    console.log("beforeMount");
  },
  updated() {
    console.log("updated");
  },
  activated() {
    console.log("activated");
  },
  deactivated() {
    console.log("deactivated");
  },
  destroyed() {
    console.log("destroyed");
  },
  errorCaptured(err, vm, info) {
    console.log("errorCaptured", err, vm, info);
    return false;
  }
};

あんまり大した例を思い付かないけど。errorCaptured なんかはちゃんと捕捉すれば有用だろう。

あとVuex+Routerと絡める例では、storeで管理しているログイン状態をbeforeRouteXXXX系フックで検査して未ログインの場合は○○する認証ガードミックスイン・・・とかかな。

💡Tips: ミックスインをファクトリ関数にする

ミックスインが暗黙的な前提条件を要求することを嫌うなら、ミックスインをオブジェクトではなくファクトリにしてしまうという手もある。まんまチュートリアルに従ってオブジェクトばかり返す必要はない。

例えばwindowのresizeイベントを検知して何かをしたいコンポーネントがあるとする。普通に書くならこんな感じだろう。

...
  created() {
    window.addEventListener("resize", this.handleResize);
  },
  destroyed() {
    window.removeEventListener("resize", this.handleResize);
  },
  methods: {
    handleResize() {
      // do something
    }
  }
...

で、この「resizeしたら何かする」ケースが他のコンポーネントでもあるけど、よくdestoryed忘れちゃうんだよなーとなったのでミックスインを検討したとする。普通なら createddestroyed とミックインに持っていけば完了。ただし handleResize という名前のメソッドにしなければならないという暗黙的な前提条件は残る。(あるいは長くて凝ったメソッド名にするか)

そこでミックスインをファクトリ関数にしてしまい、メソッド名を指定できるようにしてあげる。

📄 ResizeListener.js

export function ResizeListener(handler) {
  return {
    created() {
      window.addEventListener("resize", this[handler]);
    },
    destroyed() {
      window.removeEventListener("resize", this[handler]);
    }
  };
}

呼び出し側はハンドラー関数の名前を指定してミックスインを使用する。

  mixins: [
    ResizeListener("handleResize"),
 ],

こうすれば前提条件はこっち側で示すことができ明示的になる。なお上記の例でコールバック関数を直接渡すという方法もあると思うけど、mixins宣言部にダラダラ処理を書くのは美しくないと思うので個人的にはおすすめしない。
(なお、ResizeListener(this.handleResize) とは書けませんからね!)

ファクトリ関数にするとかなり色んなことができちゃうが、やり過ぎは良くないと思うので、使用上の用法をよくお守りください。

📌 処理の共通化は普通のJSで

先程ミックスインに対してネガティブな意見を書いたが、じゃあどうやって処理を共通化すんのよ?と問われれば、それはフツーのjavascriptでヘルパー関数やらクラスやら定義して、そっちにやらせるようにすればいい。何のマジックも無く、最も当たり前の方法で少々つまらないかもしれないけど、これが最も自由度が高く潰しが効く。ヘルパーを呼び出すコードがあちこちに存在するのがDRYじゃない、とか思うかもしれないけど、それはDRYとは関係ない。なお、ここでいう処理とは一連の手続き(トランザクション)的なものであって、画面UI上の処理(プレゼン層)のことではない。(後者の場合は後述のslotによる拡張の方がハマる場合が多いと思う)

ときどきヘルパーの中でVueインスタンスが欲しくなる場合もあると思う。それならヘルパーにVueインスタンスを渡してしまうという方法もある。これはプログラミング的には委譲(デリゲート)という手法になる。

📄 DelegateVue.vue

<template>
  <div>
    <h1>委譲(デリゲート)</h1>
    <button @click="hoge">Hoge</button>
  </div>
</template>

<script>
import HogeHelper from "@/helpers/HogeHelper.js";

export default {
  name: "DelegateVue",
  created() {
    this.helper = new HogeHelper(this);
  },
  methods: {
    hoge() {
      this.helper.hoge();
    }
  }
};
</script>

📄 HogeHelper.js

export default class {
  constructor(vue) {
    this.vue = vue;
  }

  hoge() {
    console.log(this.vue);
  }
}

Vueインスタンスが手に入れば後はやりたい放題できる。もちろんこのVueインスタンスを介し、特定の data にアクセスし出すとそれは暗黙的な前提条件となりミックインと何ら変わらなくなってしまうので使い方には注意されたし。(そもそも、Vueインスタンスを渡すのは基本的に大きすぎるのであんまりオススメしないんだけど)

📌 ラップしてslotで拡張

コンポーネントのUI上の処理を共通化する場合は別のコンポーネントでラップすることを第一に考えてみよう。ミックスインを使用せず安全に機能を拡張できる場合が多い。いくつか具体例を考えてみる。

要ログイン処理

例えばログインしてないと使用できないアクションがあったとして、未ログイン時にそれを行おうとすると別の処理を実行したいとする。RequireLogin という名前のコンポーネントを作成し、このコンポーネントでログインが必要な処理をラップできるようにしてみる。

📄 UseRequireLoginSample.vue

<template>
  <div>
    <h1>ラップして拡張</h1>

    <label>
      ログイン済み
      <input type="checkbox" v-model="loggedIn" />
    </label>
    <!-- RequireLoginコンポーネントで要ログイン処理をラップする -->
    <RequireLogin :logged-in="loggedIn">
      <button @click="doSomethingRequiredLogin">要ログインアクション</button>
    </RequireLogin>
  </div>
</template>

<script>
import RequireLogin from "@/components/RequireLogin.vue";

export default {
  name: "UseRequireLoginSample",
  components: { RequireLogin },
  data() {
    return {
      loggedIn: false
    };
  },
  methods: {
    doSomethingRequiredLogin() {
      // ログイン済みの場合に実行したい処理
      alert("実行!");
    }
  }
};
</script>

📄 RequireLogin.vue

<template>
  <component :is="tagName" v-on:[captureEvent].capture="action">
    <slot></slot>
  </component>
</template>

<script>
export default {
  name: "RequireLogin",
  props: {
    loggedIn: {
      type: Boolean,
      required: true
    },
    tagName: {
      type: String,
      default: "div"
    },
    captureEvent: {
      type: String,
      default: "click"
    }
  },
  methods: {
    action(e) {
      if (!this.loggedIn) {
        e.preventDefault();
        e.stopImmediatePropagation();
        alert("ログインしてください!");
      }
    }
  }
};
</script>

RequireLogin コンポーネントでラップさえすれば、ログインしないと中のclick等処理は実行されない。一般的にログイン状態はVuexで管理することが多いだろうから、RequireLogin.vueでstateにアクセスすればpropsでもらう必要もなくなるので、コードはもっとシンプルになる。

📌 モジュールスコープをきちんと使う

シングルファイルコンポーネント(.vue)であっても、普通のjsファイルと同じようにモジュールスコープが使えるわけだが、利用すべきところで利用されていないように思う。<script>タグの中は単なるJavascriptなのでexportしないものは外部に公開されることはない。つまりプライベートな定数や関数が持てるわけだが、何でもかんでもdataやmethodsの中に定義しちゃってるコードをよく見る。特に公開する必要がないものに関しては無用にVueインスタンスに押し込める必要はない。

<script>
const HOGE = 1; // プライベート定数

// プライベート関数
function fuga() {
  // do something
}

export default {
  // いつものVueコード
};
</script>

ただし関数の場合はVueインスタンス(this)にアクセスする必要もあるだろうから、そこはケースバイケースで無理する必要はないと思う。普通にmethodsの中に書けばいい。注意としては、template内で評価されるJavascriptの this はVueインスタンスになるので、ここで挙げたプライベートな定数・関数にはアクセスできないので。

📌 データオブジェクトに安易にプロパティを生やさない

ここでいうデータオブジェクトとは主にAPI等で外部から取ってくるデータベースと紐付いたデータを持つJavascriptオブジェクトのこと。例えば会員を管理しているアプリケーションであれば会員(Member)がそれにあたる。よくチュートリアルで出てくるTo-doアプリならTodoオブジェクト、といった具合だ。

例えば会員の一覧を表示してチェックボックスで各会員の選択状態をトグルするような画面を考えてみる。

📄 PropInData.vue

<template>
  <div>
    <h1>会員情報</h1>

    <div v-for="member in members" :key="member.id">
      <label class="label">
        <input type="checkbox" v-model="member.selected" />
        <Member :member="member" />
      </label>
    </div>
    {{ selectedMembers.length }}人選択中
  </div>
</template>

<script>
import Member from "@/components/Member.vue";

export default {
  name: "PropInData",
  components: { Member },
  data() {
    return {
      members: []
    };
  },
  created() {
    this.fetchMembers().then(members => {
      this.members.splice(
        0,
        this.members.length,
        ...members.map(member => {
          // 選択状態を初期化する!!!
          member.selected = false;
          return member;
        })
      );
    });
  },
  computed: {
    selectedMembers() {
      return this.members.filter(member => member.selected);
    }
  },
  methods: {
    async fetchMembers() {
      // 実際はAPIで取ってくる
      return [
        { id: 1, name: "山田太郎" },
        { id: 2, name: "佐藤花子" },
        { id: 3, name: "田中次郎" },
        { id: 4, name: "木村ウメ" }
      ];
    }
  }
};
</script>

📄 Member.vue

<template>
  <span>
    {{ member.name }}
  </span>
</template>

<script>
export default {
  name: "Member",
  props: {
    member: {
      type: Object,
      required: true
    }
  }
};
</script>

ポイントは fetchMembers で取得したmember情報に selected というプロパティを生やしているところ。別にそれ自体が悪ではないが、短絡的にプロパティを生やすと後々破綻するというケースが結構ある。後に次のような画面を作成することになったとしたらどうだろう?

selected プロパティひとつでは無理なのでselectedForTo, selectedForCcというプロパティを追加して・・・なんてことはやめたほうがいい。その路線で行くとキリが無くなるし、なによりダサい。またSPAだと同名のプロパティが他画面で使用されている可能性だってある。とにかく筋が良くない。

どこで間違えたのかと言うと、selectedというプロパティをmemberオブジェクトに生やしてしまったことだ。『選択されている』という抽象的な状態は一時的なものであり、また用途(UI)ごとにことなる状態となる。selectedの他にも「checked」「visible」なんてプロパティを見かけたら、それらはちょっとキナ臭い。

「でも、よくあるチュートリアルのTo-doアプリでも、done っていうプロパティをチェックボックスのv-modelに当ててますよね?同じことなんじゃ?」と思う人もいるかもしれない。でもそれは違う。doneはTodoオブジェクトの永続的な状態である。要はDBに格納されてるってこと。ブログアプリのPost(記事)のpublished(公開済み)とか、メッセージアプリのMessageのsent(送信済み)とかも同じことだ。

つまりデータオブジェクトにプロパティを生やしたくなった時、それは本当にそのデータオブジェクトに属するに相応しいものなのかよく考えようということ。先程の一斉送信フォーム画面を先に見ていたなら、簡単にはデータオブジェクトにプロパティを生やそうという発想にはならないと思う。しかし最初の例から入った場合、selectedを生やしちゃう人は結構いる。

一斉送信フォームの場合のチェックは、その会員の状態を変更するためのチェックではなく単にToとCcという箱に仕分けるための目印という位置付けであるので、それはその通りに実装してあげたほうが筋がいい。ということは selected はコンポーネントのdataに属するべきものなんじゃないか?と思うかもしれない。実はそれもあまり上手くいかない。例えば次のようにLabeledCheckboxというコンポーネントを作り、そのdataでselectedを管理したとする。

📄 LabeledCheckbox.vue

<template>
  <label>
    <input type="checkbox" v-model="selected" />
    <slot></slot>
  </label>
</template>

<script>
export default {
  name: "LabeledCheckbox",
  data() {
    return {
      selected: false
    };
  },
  watch: {
    selected(newVal) {
      this.$emit("toggle", newVal);
    }
  }
};
</script>

チェック状態が変更される度に toggle イベントがemitされるので、それを親で捕捉すればなんとかなりそうだ。じゃあ実際になんとかしてみよう。

<template>
  <div>
    <h1>会員情報</h1>

    <h2>一斉送信フォーム</h2>
    <div class="form">
      <div class="members">
        <h3>To</h3>
        <div v-for="member in members" :key="member.id">
          <LabeledCheckbox
            @toggle="selected => toggleSelection(selectionTo, member, selected)"
          >
            <Member :member="member" />
          </LabeledCheckbox>
        </div>
        {{ selectionTo.length }}人選択中
      </div>

      <div class="members">
        <h3>Cc</h3>
        <div v-for="member in members" :key="member.id">
          <LabeledCheckbox
            @toggle="selected => toggleSelection(selectionCc, member, selected)"
          >
            <Member :member="member" />
          </LabeledCheckbox>
        </div>
        {{ selectionCc.length }}人選択中
      </div>
    </div>
  </div>
</template>

<script>
import Member from "@/components/Member.vue";
import LabeledCheckbox from "@/components/LabeledCheckbox.vue";

export default {
  name: "PropInData",
  components: { LabeledCheckbox, Member },
  data() {
    return {
      members: [],
      selectionTo: [],
      selectionCc: []
    };
  },
  created() {
    this.fetchMembers().then(members => {
      this.members.splice(0, this.members.length, ...members);
    });
  },
  methods: {
    async fetchMembers() {
      // 実際はAPIで取ってくる
      return [
        { id: 1, name: "山田太郎" },
        { id: 2, name: "佐藤花子" },
        { id: 3, name: "田中次郎" },
        { id: 4, name: "木村ウメ" }
      ];
    },
    toggleSelection(selection, member, selected) {
      if (selected) {
        selection.push(member);
      } else {
        const index = selection.findIndex(m => m === member);
        selection.splice(index, 1);
      }
    },
  }
};
</script>

<style lang="scss" scoped>
.form {
  display: flex;
}

.members {
  width: 200px;
}
</style>

selectionTo, selectionCc というdataを用意して LabeledCheckboxtoggle イベントが発生するたびにそれらを更新するようにした。これで無事To,Ccそれぞれのチェック状態が管理できた・・・ように見える。しかしこの方法には色々と問題がある。

  1. selectionTo, selectionCcに格納されているmemberの並び順が管理できない
  2. (例えばv-ifなどで)LabeledCheckboxが破棄されると選択状態も破棄される
  3. なにより面倒くさい

1や2はケースによっては致命的な問題だ。というわけでコンポーネントのdataに持たせる案もボツ。

では結果的にどうすればいいかというと、データオブジェクトをUI上の状態を持つオブジェクトでラップしてやればよい。構造としてはこんな感じ。

[
  { selected: false, member: member },
  { selected: false, member: member },
  { selected: false, member: member },
  ...
]

こうしておくとコードはもっと簡潔になり、リアクティブ感が増す。まず、LabeledCheckboxは普通にv-modelを受け取れるコンポーネントに変更しよう。

📄 LabeledCheckbox.vue

<template>
  <label>
    <input type="checkbox" v-model="innerValue" />
    <slot></slot>
  </label>
</template>

<script>
export default {
  name: "LabeledCheckbox",
  props: {
    value: {
      type: Boolean,
      required: true
    }
  },
  computed: {
    innerValue: {
      get() {
        return this.value;
      },
      set(newVal) {
        if (this.value !== newVal) this.$emit("input", newVal);
      }
    }
  }
};
</script>

次に親側。

<template>
  <div>
    <h1>会員情報</h1>

    <h2>一斉送信フォーム</h2>
    <div class="form">
      <div class="members">
        <h3>To</h3>
        <div v-for="selection in selectionTo" :key="selection.member.id">
          <LabeledCheckbox v-model="selection.selected">
            <Member :member="selection.member" />
          </LabeledCheckbox>
        </div>
        {{ selectedMembers(selectionTo).length }}人選択中
      </div>

      <div class="members">
        <h3>Cc</h3>
        <div v-for="selection in selectionCc" :key="selection.member.id">
          <LabeledCheckbox v-model="selection.selected">
            <Member :member="selection.member" />
          </LabeledCheckbox>
        </div>
        {{ selectedMembers(selectionCc).length }}人選択中
      </div>
    </div>
  </div>
</template>

<script>
import Member from "@/components/Member.vue";
import LabeledCheckbox from "@/components/LabeledCheckbox.vue";

// 選択状態を持つオブジェクトの配列にして返す
function createSelection(members) {
  return members.map(member => ({ selected: false, member: member }));
}

export default {
  name: "PropInData",
  components: { LabeledCheckbox, Member },
  data() {
    return {
      members: [],
      selectionTo: [],
      selectionCc: []
    };
  },
  computed: {
    selectedMembers() {
      return selection => {
        return selection.filter(s => s.selected).map(s => s.member);
      };
    }
  },
  created() {
    this.fetchMembers().then(members => {
      this.members.splice(0, this.members.length, ...members);
      this.selectionTo.splice(
          0,
          this.selectionTo.length,
          ...createSelection(members)
      );
      this.selectionCc.splice(
          0,
          this.selectionCc.length,
          ...createSelection(members)
      );
    });
  },
  methods: {
    async fetchMembers() {
      // 実際はAPIで取ってくる
      return [
        { id: 1, name: "山田太郎" },
        { id: 2, name: "佐藤花子" },
        { id: 3, name: "田中次郎" },
        { id: 4, name: "木村ウメ" }
      ];
    }
  }
};
</script>

<style lang="scss" scoped>
.form {
  display: flex;
}

.members {
  width: 200px;
}
</style>

createSelection という関数で選択状態でラップしてあげている。これを使用すれば処理はスッキリするし、先のコンポーネントのdataに状態をもたせたときに生じる問題もクリアできている。

チェックで選択するぐらいで大げさだなあ思うかもしれないが、アプリケーションの規模が大きくなってきたときにselectedForXXX, checkedForXXX, visibleForXXXXみたいなプロパティが乱立するカオス状態に陥るのを避けるために、こういうことは最初からキチンとした実装に徹することをおすすめする。

📌 デフォルトslotでもslot名を明示する

アプリケーションの規模が大きい場合は、デフォルトslotを呼び出す場合でもslot名を明示したほうが無難だ。

<!-- こうではなく -->
<ChildComponent>
  <p>Hello, world</P>
</ChildComponent>

<!-- こうする -->
<ChildComponent>
  <template #default>
    <p>Hello, world</p>
  </template>
</ChildComponent>

面倒だし不格好だと思うけど、実は両者の挙動に違いがある。次のようなボタンを押すことで表示・非表示をトグルするToggleVisibilityコンポーネントを作って確認してみよう。

📄 ToggleVisibility.vue

<template>
  <div>
    <div v-if="visible">
      <slot></slot>
    </div>

    <button @click="visible = !visible">toggle</button>
  </div>
</template>

<script>
export default {
  name: "ToggleVisibility",
  data() {
    return {
      visible: false
    };
  }
};
</script>

これは特に難しいことはないと思う。次にその呼び出し側。

<template>
  <div>
    <ToggleVisibility>
      {{ greeting }}
    </ToggleVisibility>
  </div>
</template>

<script>
import ToggleVisibility from "@/components/ToggleVisibility.vue";

export default {
  components: { ToggleVisibility },
  computed: {
    greeting() {
      console.log("greeting!");
      return "Hi, there!";
    }
  }
};
</script>

ToggleVisibilityのデフォルトslotでgreetingを出力している。コードは問題なく動作するのだが、このgreeting()の中で出力しているログはいつ出力されるだろうか? ToggleVisibilityのv-ifがfalseから始まるので、当然toggleボタンを押した後だと思うかもしれない。しかし実際は違う。実はこの親コンポーネントが表示されるときにgreetingは評価され、ログが出力される。この挙動は意外に思うかもしれない。

これは一応仕様通りで、デフォルトslotに渡す内容は親コンテキストで即時評価されるようになっている。仕様なら仕方がないか・・・と思うが、この挙動は勘違いされやすいし、何よりlazyに評価してくれないと困る場合だってある。そんなときはデフォルトslotであれslot名を明示的に呼び出してやると、そのslotに渡す内容は遅延評価されるようになる。

...
    <ToggleVisibility>
      <template #default>
        {{ greeting }}
      </template>
    </ToggleVisibility>
...

こうするとtoggleボタンを押すまでgreetingが評価されることはなくなる。もちろん即時評価されても何ら問題のない場合もあるので、このルールはアプリケーションの規模等に合わせて取り入れればいいと思う。

なおこの挙動はVue 3では変更されているかも?

https://github.com/vuejs/vue/issues/8578

📌 コンポーネントのルート要素のclassは指定しない

コンポーネントのルート要素に指定したclassは、たとえスコープ付きCSS(<style scoped>)であっても親コンポーネント内の同名classの影響も受ける。言葉では分かりづらいので、実際の例は次のような感じ。

📄 LeakedRootClass.vue(親コンポーネント)

<template>
  <div>
    <ul>
      <!-- 親コンポーネント内のitem -->
      <li class="item">Item1</li>
      <li class="item">Item2</li>
    </ul>
    <!-- 子コンポーネントを使用 -->
    <LeakedRootClassChild />
  </div>
</template>

<script>
import LeakedRootClassChild from "@/components/LeakedRootClassChild.vue";
export default {
  name: "LeakedRootClass",
  components: { LeakedRootClassChild }
};
</script>

<style lang="scss" scoped>
// 親コンポーネント内でフォント色に赤を指定
.item {
  color: red;
}
</style>

📄 LeakedRootClassChild.vue(子コンポーネント)

<template>
  <!-- 子コンポーネント内のitem -->
  <div class="item">Child!</div>
</template>

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

<style lang="scss" scoped>
// itemの背景色に黄色を指定
.item {
  background-color: yellow;
}
</style>

一見すると、親コンポーネントにはscoped指定があるので color: red は子コンポーネントには影響を与えないように見える。しかし実際には、下図のように子コンポーネントにも影響を与える。

Childも赤くなってる!

どうしてこうなるのかというと、カスタムデータ属性を見てみると分かるだろう。カスタムデータ属性とはvue-loaderが自動的に付与する data-v-xxxxxxxx というscopedを実現しているアレである。ChromeのDevToolsで見てみる。

子コンポーネントのルート要素には自身のカスタムデータ属性(data-v-ff686dd0)だけでなく、親のそれ(data-v-42012094)も付与されているのが分かる。というわけで親のスタイルの影響を受けることになる。

この挙動はスコープ付きCSSの挙動としては意外なので最初はかなり戸惑ってしまった。対応策としては以下の記事が参考になると思う。

🔗 [Qiita] vue-loaderのScoped CSSのスタイルが子コンポーネントのルート要素に効いてしまって辛い

いくつか対応策があるものの、自分としては次の方法がいちばんマシかなと思ってる。

  • ルート要素にはclass指定しない
  • したくなったら、class無しdivタグでラップして、2番めの要素にclass指定する
  • どうしてもルート要素でなければならないときはstyleで

という方法を取っている。DOMのネストは深くなってしまうが、そこはまあやむ無しとしている。今の所、特に問題はない。

📌 複数のデータに対してwatchしたくなったらready~

複数のデータ条件が整ったら何かの処理をする・・なんてことはよくあると思う。watchの対象を複数指定できれば実現できそうだがそれはできない。そういうときは computed を使用すればいい。例えば userfavorites に正当な値が入った時点で何らかの処理を開始したいときは次のような感じになる。

...
   computed: {
    ready() {
      return this.user && this.favorites.length > 0;
    }
  },
  watch: {
    ready: {
      handler(newVal, oldVal) {
        if (newVal) {
          // do something
        }
      },
      immediate: true
    }
  }
...

ちなみに immediate: true にしているのは、最初から条件を満たしていることも考慮している。

📌 JSと連動するCSS値は:styleで設定する

ベストプラクティスというほどでもないんだけど。たまにJavascript界の値をcssの世界で使用したい場合があると思う。例えばカラーコードや色名を入力するとその色で塗られるボックスを作りたいとする。

コードはこちら。

<template>
  <div>
    <input type="text" v-model="color" />
    <div class="box" :style="boxStyle"></div>
  </div>
</template>

<script>
export default {
  name: "ColoredBox",
  data() {
    return {
      color: "red"
    };
  },
  computed: {
    boxStyle() {
      return {
        "--box-color": this.color
      };
    }
  }
};
</script>

<style lang="scss" scoped>
.box {
  width: 200px;
  height: 200px;
  background-color: var(--box-color);
}
</style>

ポイントはボックスの :style でcoputedなプロパティを指定して、その中でcss変数を返しているところ。これでコンポーネントがリアクティブになる。間違っても color をwatchで監視してdocument.getElementByIdとかで要素の色を変えようと思っちゃいけない。ちなみに、boxStyle でcss変数を返さなくても直接background-colorを返しても問題なく動作するが、cssを見たときに背景色がJavascriptで変更されていることが明示的になるので、こちらのほうがおすすめ。

📌 ループ内でcomputedを使用する場合は一旦ローカル変数に

computedの値はキャッシュされてはいるが、再計算の必要がないかどうかはその値の取得時にチェックされる。要するに普通の変数にアクセスするよりも遅い。実は結構遅い。通常はそこまで気にする必要はないが、そこがループの中でなら注意したほうがいい。数回程度のループなら問題ないが数百回ともなってくるとパフォーマンスに影響してくる。

ループより前に一旦ローカル変数に値を取得しておくこと。

      const value = this.myComputedValue;
      tooBigIntArray.forEach(a => {
        console.log(value * a);
      });

とはいえ、ついつい忘れちゃうんだよなあ・・・

📌 DOM構造上最適な場所にレンダリングする

モーダルダイアログや、マウスクリックで表示されるコンテキストメニューなどを描画する場所の話。例えばモーダルダイアログコンポーネントとそれを表示するためのボタンコンポーネントがあったときに、ボタンコンポーネントの中にモーダルを描画しないようにね、ということ。

<template>
  <div>
    <button @click="modalShown = !modalShown">モーダル起動</button>
    <!-- ボタンコンポーネントの中でモーダルを表示しちゃう -->
    <HogeModal v-if="modalShown" />
  </div>
</template>

要は「使用する所でコンポーネントを呼び出せばいい」という発想。それはそれで正しい。通常モーダルダイアログを表示する場合は画面全体にバックドロップをかけてposition: fixedを指定するので、どこでモーダルを配置しようとも正しく描画されることが多い。でもまれに問題も出る。例えばposition: fixedを持つ要素の祖先要素でtransformが使用されていると、fixedが効かず、なぜかabsolute扱いになってしまう。あとは場合によっては祖先コンポーネントのスタイルに影響を受けてしまうことも。

そもそもVueとか関係になしに考えるとしたら、こういったモーダルダイアログってどこに描画するだろうか? 普通はbody直下(やdiv id=”app”直下)に描くと思う。だってDOM構造を考えるとそうだから。だったらそこにモーダルコンポーネントは配置すべきだ。どうやってオープンするかは色んな方法があると思う。

ただし「使用する所でコンポーネントを呼び出せばいい」という哲学も尊重したいのなら、描画先だけを変更するライブラリを使用する手もある。PortalVueというライブラリだ。

🔗 PortalVue

またVue3では標準でTeleportというコンポーネントが使用できる。

最後に

編集中にwordpressがガチガチに重くなるほど長文になってしまった。ここで挙げた内容よりももっとベターなものもあるかもしれないし、Vue3のCompositionAPIで解決できるものもあるかと思う。なにかご意見等あればぜひぜひコメントください。

関連する記事


コメントする

メールアドレスが公開されることはありません。

CAPTCHA


このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください