Writable Computed を活用して読みやすいVueコードを書くためのTips

こんにちは、ANDPADフロントエンドエンジニアの小泉(@ykoizumi0903)です。

ANDPADでは入社当初からずっとVueでの開発を行っており、特に直近2年はComposition APIで開発しています。

今回は、Vueでの開発を続けている中で、個人的に最近気に入って積極的に使っている、Writable Computedの話をしたいと思います。

 

このWritable Computed、一応ドキュメントには必ず書いてあるのですがどうにも影が薄く、バージョン3に合わせて刷新された英語版ドキュメントでは「書き込み可能なcomputedが必要なのはレアケース」と書かれているくらいなので、経験の長いVueエンジニアであっても、ほとんど使ったことがないという方も多いのではないでしょうか。

私自身も、このsetter関数について、存在は以前から知っていたものの、どういう使い道があるのかよくわかっていませんでした。

しかし、半年ほど前から、使い方を理解して積極的にコードに取り入れるようにしてみたところ、「これはWritable Computedを使った方が良い」と感じる場面が意外と多くありました。

すっかり慣れた今では、適切に使うと保守性・可読性の高いコードを書けるようになる強力な機能だと考えています。

 

そこでこの記事では、実際のプロダクトで出てきた場面をもとに、どのようにこの機能を活用しているかというコード例と、それによって得られるメリットをご紹介したいと思います。

(ちなみに、そもそもWritable Computedという呼び方も一般的なワードではないのですが、正式な名称がないことと、computed という機能名まで訳して「書き込み可能な算出プロパティ」としても混乱するだけだと思われることから、記事内ではこの呼び方で統一させて頂きます。)

Writable Computed とは

Writable Computed、「書き込み可能なcomputed」について、公式ドキュメントの算出プロパティとウォッチャのページには記載がありますが、

算出プロパティはデフォルトでは getter 関数のみですが、必要があれば setter 関数も使えます:

という一文だけで、具体的なコードも以下のシンプルな例のみとなっているので、さらっと読み流した方も多いのではないかと思います。

  fullName: {
    // getter 関数
    get: function () {
      return this.firstName + ' ' + this.lastName
    },
    // setter 関数
    set: function (newValue) {
      var names = newValue.split(' ')
      this.firstName = names[0]
      this.lastName = names[names.length - 1]
    }
  }

例を見ればわかる通り、get部分は一般的な computed と同じく依存元の値を加工した新しい値を返しながら、set部分で逆に依存元を変更する操作を定義しています。

 

また、Composition API の computed でも、setter関数はちゃんとサポートされています。

日本語ページには記載がありませんが、最新の英語版ドキュメントでは左上のトグルで全コードが Options と Composition で切り替えられるようになっています。

const fullName = computed({
  // getter
  get() {
    return firstName.value + ' ' + lastName.value
  },
  // setter
  set(newValue) {
    // Note: we are using destructuring assignment syntax here.
    [firstName.value, lastName.value] = newValue.split(' ')
  }
})

この場合は、WritableComputedRef という、通常の ref と同じような型になり、 fullName.value = 'John Doe' という書き方で変更可能になります。

とはいえ、この例だと、苗字と名前の間のスペースなどという便利なものがない日本語圏では何の参考にもならないので、もう少し身近なシチュエーションをいくつか挙げていきます。

Writable Computed が役に立つケース

※以下、コード例は、記述量が一番少なくなる script setup 記法を用います。

https://v3.ja.vuejs.org/api/sfc-script-setup.html

defineComponent や Options API でも、書き方が違うだけで同様の場面であれば使えると思いますので、適宜読み換えてください。

例1:相互に影響し合う2つの値の片方をcomputedにする

例えば税抜・税込金額の入力欄で、税込からの入力も可能にしたい、という要件がありました。

<template>
  <input type="number" v-model="price" />
  <input type="number" v-model="priceWithTax" />
</template>

<script setup>
import { ref } from 'vue'
const price = ref(0)
const priceWithTax = ref(0)
</script>

直感的にはこのようにrefを2つ書くところからスタートすることになると思いますが、ここから watch を使って自動計算を行おうとすると、なんだか記述量の多いコードになってしまいます。

<template>
  <input type="number" v-model="price" />
  <input type="number" v-model="priceWithTax" />
</template>

<script setup>
import { ref, watch } from 'vue'
const price = ref(0)
const priceWithTax = ref(0)
watch(
  () => price.value,
  (newPrice) => {
    priceWithTax.value = newPrice * 1.1
  }
)
watch(
  () => priceWithTax.value,
  (newPriceWithTax) => {
    price.value = newPriceWithTax / 1.1
  }
)
</script>

そこでこれを、Writable Computedを使って書き直してみます。

<template>
  <input type="number" v-model="price" />
  <input type="number" v-model="priceWithTax" />
</template>

<script setup>
import { ref, computed } from 'vue'
const price = ref(0)
const priceWithTax = computed({
  get: () => price.value * 1.1,
  set: (newPriceWithTax) => {
    price.value = newPriceWithTax / 1.1
  }
})
</script>

どうでしょうか。記述量も短くなった上に、何が行われているのかもわかりやすくなりました。

例2:複数の状態を一括で変更する

1対1の関係だけでなく、複数の値に依存している場合にもWritable Computedは有効です。

例えば、チェックボックスで複数選択を行うUIで、「すべて選択」をクリックすると一気に全選択できるが、既に全選択されている場合に「すべて選択」をクリックすると全てのチェックが外れる、という仕様のページを見たことがあると思います。

このコードを watch で実現するのは地味に大変です。

<script setup>
import { ref, watch } from 'vue'

const options = ref([
 {id: 1, label: "ESLint", checked: false},
 {id: 2, label: "Prettier", checked: false},
 {id: 3, label: "Lint staged files", checked: false},
 {id: 4, label: "StyleLint", checked: false},
 {id: 5, label: "Commitlint", checked: false},
])
const checkedAll = ref(false)
watch(
  () => checkedAll.value,
  (val) => {
    options.value.forEach((_, i) => {
      options.value[i].checked = val // Vue2 では Vue.set を使う
   })
  }
)
watch(
  () => options.value,
  (val) => {
    checkedAll.value = val.every((opt) => opt.checked)
  },
  { deep: true }
)
</script>

<template>
  <div>
    <label>
      <input v-model="checkedAll" type="checkbox" /> すべて選択
    </label>
  </div>
  <div v-for="(opt, i) in options" :key="opt.id">
    <label>
      <input v-model="opt.checked" type="checkbox" /> {{opt.label}}
    </label>
  </div>
</template>

上のコードで動くように見えて、実はこのコードは要件を満たしていません

このコードでは、全てチェックされた状態から、どれか1つのチェックを外しただけで、checkedAllが変更されたことからcheckedAllの watch が走り、全てのチェックが外れてしまうからです。

これを回避するには、

if (!val && options.value.some((opt) => !opt.checked)) return

という分岐をcheckedAllのwatchの最初に入れる必要があるのですが、このことに確実に気づくのはなかなか難しそうです。

このような仕様も、Writable Computedを使えばスマートに表現できます。

import { ref, computed } from 'vue'

const options = ref([
 {id: 1, label: "ESLint", checked: false},
 {id: 2, label: "Prettier", checked: false},
 {id: 3, label: "Lint staged files", checked: false},
 {id: 4, label: "StyleLint", checked: false},
 {id: 5, label: "Commitlint", checked: false},
])
const checkedAll = computed({
  get: () => options.value.every((opt) => opt.checked),
  set: (val) => options.value.forEach((_, i) => {
    options.value[i].checked = val // Vue2 では Vue.set を使う
  })
})

checkedAllのgetとsetが同時に呼ばれることがないので、 watch のような問題は起きなくなりました。

余計な分岐がなく、どちらが読みやすいコードか一目瞭然ではないでしょうか。

<template> タグを含む実際の動きとコードは Vue SFC Playground で確認できます。

例3:UIライブラリとのデータの相互変換

Element UI などのUIライブラリを使う場合に、受け付ける型や、返ってくる型が文字列などに固定されていて、アプリケーションとして扱いにくいことがあります。

例えば、日付を選択するDate Pickerコンポーネントを使っていて、ライブラリ側へは指定の文字列で渡す必要があるが、アプリ内では値をDay.jsでラッパーしたオブジェクトで保持しておきたい、というような場合。

    const selectedDate = ref<Dayjs | null>(dayjs())
    const selectedDateForDatePicker = computed<string | null>({
      get: () =>
        selectedDate.value
          ? selectedDate.value.format('YYYY-MM-DD')
          : '',
      set: (val) => {
        selectedDate.value = val ? dayjs(val) : null
      },
    })

このようにWritable Computedを通すことで、参照時には文字列に変換した状態で渡しつつ、入力値は必ずsetter関数でdayjsを通るので、元の selectedDate が文字列で上書きされることもありません。

こういった「入力値から保存したい内容への変換」は意外と出番が多いのではないでしょうか。

UIライブラリ以外にも、例えば「Number型として保存する入力フォームだが、日本語全角数字での入力 を受け付けるため <input type="string"> にしたい」みたいな場合でも、Writable Computedが役に立ったりしました。

例4:props/emitをまとめる

Vue.jsでは props はイミュータブルになっているので、子コンポーネントから直接変更することはできません。

子コンポーネントで受け取ったデータを ref として登録して watch で監視すれば連動させることはできますが、どうしてもデータの二重管理になってしまいますし、親子間での変更が適切に反映されないと無限ループになりかねません。

そこで、これも Writable Computed を使って書いてみます。

<template>
  <input v-model="inputValue" />
</template>

<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps(['value'])
const emit = defineEmits(['input'])
const inputValue = computed({
  get: () => props.value,
  set: (val) => {
    emit('input', val)
  }
})
</script>

こうすることで、computedのsetter関数では値をそのまま親コンポーネントにemitするだけの動きとなるため、子コンポーネントで値を保持することなく、常に親コンポーネントのデータを正としてpropsの状態を反映させることができます。

この方法は、 Vuex や Pinia、provide/inject などを使って外部ファイルに定義したストアに対しても有効です。

ただし、ストアに対してこの使い方を多用しすぎると無駄の多いコードになってしまうので、コンポーネントで行う処理は可能であれば例3のような使い方にとどめる方が望ましいです。

こういうコードが多くの箇所で必要になるのであれば、そもそもデータストアが変更可能な ref や Writable Computed を export する方が正しいのではないかと思います。

Vue2ベースであるVuexの場合はちょっと難しいかもしれませんが、v-model に固執せずに :valuemapGetters@inputmapActions を渡す選択肢もあるはずです。

Writable Computed を使うことで得られるメリット

ここまでの例で何となくわかって頂けていたら嬉しいのですが、このような書き方をすることで何が嬉しいのかをいくつか挙げたいと思います。

保存するデータの総数が少なく、保守性とパフォーマンスを向上できる

ref + watch を使う場合に比べて、Writable Computedは何らかのデータを内部で保存しているわけではなく、あくまで「計算後の値」を返すだけの関数にとどまっています。

Vueで開発する上で、親子コンポーネントなどで同じデータをコピーするような作り方をすると、今は良くても後で何らかの変更をするたびに2箇所を更新しなくてはならず、修正漏れや無限ループ、パフォーマンス悪化などの危険があるため、「同じデータを複数箇所で保存する」というのはできるだけ避けるべきだと思っています。

また、computed は計算結果をキャッシュするので、その点でも watch に比べてパフォーマンス上の利点が大きく、その computed のカバー範囲を広げる Writable Computed も同様にメリットがあります。

watch と違って無限ループや変更の反映遅れを起こしにくい

今回の記事で紹介したようなコードは非常に単純なので、watchを使ってもcomputedを使ってもあまり違いはありませんでしたが、例えば例1の金額計算で、入力後に四捨五入やバリデーションのような処理を挟んでいくうちに、特定の条件下でPriceとPriceWithTaxが一致しなくなると、異なる結果を生み出すかもしれません。

さらに、 watch の対象がリアクティブなオブジェクトや配列になると、1つ1つのプロパティが一致していても参照が変わったとみなされて watch が再発火してしまい、全く同じ値であっても無限ループを引き起こしてしまう危険があります。

前後の値を厳密に比較して変わっていなかったらreturnするなどの回避方法もありますが、気をつけていないとハマってしまうでしょう。

その点、Writable Computedを使う方法は、監視している値がそもそも1つだけなので、無限ループを引き起こしにくい構造になっています。

 

また、computed が参照タイミングで必ず関数を通してから更新されるのに対して、watch の場合はVueで変更を検知してから処理を行うので、実行順序がコントロールしにくかったり、nextTick を挟まないと変更が正しく反映されなかったりします。

実際に、例1のコードに @vue/test-utils でテストを書くと異なる結果が返ってきました。

  test("Priceを変更するとPriceWithTaxが自動で計算される", () => {
    const wrapper = mount(Price);
    wrapper.vm.price = 200;
    // await nextTick() // watch の場合はこれがないとテストが落ちます
    expect(wrapper.vm.priceWithTax).toBe(200 * 1.1);
  });

これはつまり、price の変更時に priceWithTax が変更されていることを100%保証できていない、ということです。

ケースバイケースではありますが、個人的にはこのような理由から、watch/watchEffect を使うのはあくまで最終手段であり、 computed で書けるものはできる限り computed で書く、という気持ちでいるくらいがちょうど良いと思っています。

確実にsetter関数を通ることが保証される

例えば例1のようなコードは、v-model のシンタックスシュガーを使わないという方法でも実装できます。

<template>
  <input type="number" :value="price" @input="setPrice" />
  <input type="number" :value="priceWithTax" @input="setPriceWithTax" />
</template>

<script setup>
import { ref, computed } from 'vue'
const price = ref(0)
const priceWithTax = ref(0)
const setPrice = (val) => {
    price.value = val
    priceWithTax.value = val * 1.1
}
const setPriceWithTax = (val) => {
    priceWithTax.value = val
    price.value = val / 1.1
}
</script>

この方法はwatchと違って無限ループも起きず、コードとしてもわかりやすいので、以前はこのような方法を使っていたのですが、これはやろうと思えばsetPriceを通さずにpriceを変更できてしまうという問題がありました。

開発期間が長くなり、チームの新しいメンバーが増えたり、ファイル内のコードがどんどん増えて複雑になったりすると、このset関数を使うというローカルルールを見逃してしまい、想定外のバグを引き起こしてしまうでしょう。

その点、computedのsetter関数は、「getter関数経由ではない全ての値の変更の際に必ず通るもの」であり、例外的な処理を作る方が難しいです。コメントがなくてもコードだけで意図が伝わりやすく、アプリケーションが壊れにくくなります。

getter関数とsetter関数が1つの変数にまとまるので、コードの見通しが良くなる

watchを使うにしろ別の関数を用意するにしろ、元のリアクティブな値とその処理が離れてしまう可能性があります。

Options APIではcomputedwatchは必ず別のブロックになってしまいます。Composition APIであれば自由に配置できますが、追加機能の開発中にいつの間にか別の処理が挟まってしまう、ということもあるでしょう。

そもそも、watch は、1つの変数に対していくらでも watch を増やせる上に匿名なので、気づいたら同じ変数に複数の watch を書いてしまっていることもあり得ます。

その点、Writable Computed のsetter関数は、強制的にgetter関数とセットで書くことになるので、「computedを変更しようとした際に何が起きるのか」を見落としにくいコードになります。

v-model との相性が良く、Vueの表現力を最大限に引き出せる

ここまでのコード例でもわかるかと思いますが、Writable Computed は、v-modelとの相性が非常に良いです。

v-model は非常に便利なシュガーシンタックスですが、通常は refreactive といった値の単純な読み書きにしか使えないので、少し複雑な処理をしたいシチュエーションでは使いづらくなってしまいます。

そこに何か別の処理を割り込ませることを可能にするのが Writable Computed であり、これを活用することでVueの便利な記法の恩恵を最大限に受けられます。

まとめ

長々と書いてきましたが、実のところWritable Computed は、「これがないと実装できない」という場面はほとんどないと思います。(それがあればもっと普及しているはずなので)

しかし、「Writable Computedを使えばもっと良く実装できる」という場面は意外と頻繁にあり、少なくとも使える手札の1つとして持っておくことで役に立つ機会はきっとあるはずです。

Vue.js はライブラリが提供する機能の範囲が広く、コードの自由度も高いからこそ、意外と使われていない機能や新しく導入された機能を深く知ることで、実はもっと綺麗に書ける工夫の余地がまだまだ眠っているなと感じています。

これは Composition APIscript setup にも同じことが言え、発表された当初は難しそうだと思って敬遠していたのですが、いざ使ってみると圧倒的に開発体験が良く、もうこれなしでは開発できないと思うようになりました。

せっかく多機能で進化も速いVueというライブラリを使っていながら、馴染みのある書き方に固執しすぎるのは勿体ないので、たまにはこういったマイナーな機能に目を向けてみるのも面白いのではないでしょうか。

そして、このような工夫をどんどん取り入れられるのも、ANDPADの開発チームに、プロダクトを技術的にも製品的にもどんどん改善・進化させていこうという土壌が広く共有されているからこそだと思っています。

今後やりたいこと、やれていないこともまだまだたくさんある状態です。直近で言えばNuxt 3へのアップデートなどは間違いなく近いうちに訪れるもので、不安もありつつ今からわくわくしています。

ANDPADではフロントエンドエンジニアも絶賛募集中ですので、興味のある方はぜひカジュアル面談など気軽にご応募ください!

engineer.andpad.co.jp