32
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

アルサーガパートナーズAdvent Calendar 2021

Day 9

Vue3 composition API ベストプラクティス【英語プレゼン抄訳】

Posted at

はじめに

この記事は、Vue.js Amsterdam 2020にてThorsten Lünborg氏の行った講演「Composition API Best Practices」の抄訳(直訳ではない要約しながらの翻訳)です。

ついつい無秩序になりがちなsetup()内の治安をどう守るかについてチームメンバーと考える中で、参考になりそうなこちらの講演の内容を共有するために翻訳しました。

もし翻訳に不正確な部分があれば指摘していただけると幸いです。

翻訳元動画はこちら👇

Vue.js Amsterdamとは

年に一度開催されるVueコミュニティにおける世界最大のカンファレンス。
https://www.vuejs.amsterdam/

「Composition API Best Practices」抄訳

ref() vs. reactive()

ref()とreactive()、どちらを使うべきか?
Composition APIを使用したライブラリーのコードを集計した結果がこちら。
ほとんどがref()を使っていることが分かる。
(ただしこれはライブラリーのコードだけを見ているので、実用的なコードはこの限りではない可能性があることに注意)

image3.png

ではその理由は?

主に考えられるのは「一貫性(consistency)」だろう。
reactiveには出来ないことがある。その一方でrefはどこでも使えるので選ばれる。
大抵の開発者は一貫性を重視するので、refが選ばれやすいのではないだろうか。

Computed propertyはrefs

例えば、computedで得られる値は真偽値でも文字列でもリアクティブである必要があるので内部的にrefを使っている。(※reactive()は引数にオブジェクトにしか渡せない)
下記のようにsquaredというcomputed propertyを定義した時、stateはreactive()によって直接値にアクセスできるのに、computedのsumは.valueを使わなければならない。こういう状態は混乱を生むだろう。

export function exampleWithComputed() {
  const state = reactive({
    a: 1,
    b: 2,
    x: 3,
  })

  const sum = computed(() => state.a + state.b)

  const squared = computed(() => sum.value ** state.x) // refとreactiveのsyntaxが混ざる

  return toRefs({
    ...state,
    sum,
    squared
  })
}

代わりに全てrefを使ってこのようにすれば、綺麗に一貫したコードが書ける。
それに以前はaやbをstateオブジェクトのプロパティとして定義していたのを、直接定義したことで”state.”が無くなり、”.value”を付けたとしても文字数は変わらない結果となっている。

export function exampleWithComputed() {
  const a = ref(1)
  const b = ref(2)
  const x = ref(2)

  const sum = computed(() => a.value + b.value)

  const squared = computed(() => sum.value ** x.value)

  return toRefs({
    a, b, x,
    sum,
    squared
  })
}

DOMの参照

Vue2からあるDOMの参照機能はもちろんVue3のref()に受け継がれている。
setup()内のrefで定義した変数をtemplate内のtagにref属性として与えることでDOM要素を取得できるが、これはreactiveには出来ない。

以下はinput要素のDOMを取得してイベントリスナーを追加するサンプルコードである。

setup() {
  const inputEl = ref<HTMLInputElement>(null)

  onMounted(() => {
    inputEl.value.addEventListener(/* */)
  })

  return {
    inputEl
  }
}
<template>
  <div>
     <input type="text" ref = "inputEl" />
  </div>
</template>

結論

開発者は一貫性を重視するので、ref()を好む「傾向がある」
refを使えば状況に応じてどちらを使うかの判断をしなくて済む。また、第一印象ではrefの書き方は冗長であるように見えるが、実際にはほとんどの場合そうでもない。

ではreactive()は役立たずなのか?
そうではない。refと併用が必須にはなるが、使いたい部分に使っていい。例えば大量のプロパティを持つオブジェクトを、setup()から切り出した関数からリターンする時にはreactiveでラップすることで使いやすくなるだろう。

Best practice of setup()

image1.png

Composition api はこの図に代表されるように、一つのコンポーネントに対する複数のロジックを同じ場所に固めて書けるという利点があるが、そうやっても結局setup()の中に大量のコードを書く羽目になるのは同じである。

例えば検索窓一つ取っても、https://awesomejs.dev/ の場合、auto completeやGraphQLを使ったpackage検索などなど色々な処理が同時並行で行われる。
このサイトを作っているGuillaumeは魔術師のようなプログラマーだからこーんな長い(エディターを沢山スクロールしながら)コードでも理解できるから良いだろうけど、実際オススメしたいのは、
how(どうやっているのか)ではなくwhat(何をしているのか)が分かるように、実際の処理はsetup()の文脈から離し、関数として別のファイルに切り出すことだ。

例えばawesomejsの長大なコードはこんな風に書ける。

image2.png

setup()内では、usePackageCheckという「受け取ったformDataに対して、packageが存在するかなど必要な処理を行う関数」に実際の値を渡して、その後の処理に必要なステートを受け取ることしか記述しない。
それにより、実際にはどういった処理をしているかを気を散らすことなく処理の流れを追えるようになる。(「どう」やってるか知りたい時はeditorで定義した側のファイルにジャンプすれば良い)
上記ではpackage checkが終わった後、validationを行い、その結果でsubmit用の処理や関数の用意をしている。

個人的なオススメとしては、まず必要な処理をsetup()内に書いていき、少し長すぎて読みにくくなってきたなと感じた時にその処理をsetup()の外に切り出していくのが良いと思う。
それによってリファクタリングも容易になり、再利用可能にもなる。

Composition apiによって普通の関数の形に機能を切り出せるようになったので、
ここから先は

  • 引数
  • 内部処理
  • 戻り値

という関数の各パーツからHow(どうやるか)の方を考えていこう。

Handling Refs in arguments

まずはcomposition apiと引数のハンドリングについて、特にrefの値を受け取る関数について見ていこう。
例えばこのように真偽値を持つrefを引数に取って、その値の変化をトリガーに何かをする関数があるとする。

export function useWithRef(someFlag: Ref<boolean>) {

  watch(ref, val => {
    /* do something */
  })

  return {}
}

呼び出す側はこのように書けば問題なし

const isActive = ref(true)
const result = useWithRed(isActive)  😊

しかし実際に使う人は、その値が変化しないことを知っていて、直接真偽値を渡そうとするかもしれない。
Typescriptを使っていればエディター上でエラーを出せるが、そうでない場合は下記のようなエラーハンドリングを設置することで、あなたのパッケージを使う人はよりよい開発体験を得ることができるだろう。

export function useWithRef(someFlag: Ref<boolean>) {
  if (!isRef(someFlag)) warn('Needs a ref');
  watch(ref, (val) => {
    /* do something */
  });

  return {};
}

その一方で、呼び出し側で他にそのリアクティブな値を使うことがない場合、こういう形でわざわざrefに包んで渡すことになるが、これはどうも奇妙な感じがする。

const result = useWithRed(Ref(true));  🤔 

このような場合は固定値もrefもどちらも引き受ける関数にした方がデベロッパーフレンドリーだろう。

リアクティブな値と固定値どちらも取れる引数は作れるか?

下記のように、「ref値が来た場合はそのまま、固定値が来た場合はrefで包む」という処理を噛ませれば簡単にできる。
この関数ではDOM Elementをそのまま渡してもrefでリアクティブな値として渡しても、指定したイベントリスナーを仕掛ける関数として機能する。

const wrap = (value) => (isRef(value) ? value : ref(value));

export function useEvent(
  el: Ref<Element> | Element,
  name: string,
  listener: EventListener,
) {
  const element = wrap(el as Element);
  onMounted(() => element.value.addEventListener(name, listener));
  onUnmounted(() => element.value.removeEventListener(name, listener));
}

寛容なAPIか厳格なAPIか?

処理によってはref値を受け取るべきであり、固定値を受け取るとバグが起きる場合もあるだろう。その場合はもちろん寛容であるべきではないが、必ずしもrefの値を受け取る必要がない場合は、どちらも受け取れるようにした方が利便性の高い関数になるだろう。

Lifecycle vs. watch

Composition apiにおいてもう一つ見るべき項目として、ライフサイクルフックとwatchどちらを使うべきか?というものがある。

私見では、option APIにおけるwatchと比べて、composition APIにおけるwatchはとても便利になっている。それは切り分けた関数内で副作用などを扱うのに向いた機能を提供しているからだ。

先ほどの関数に戻ろう(話をシンプルにするために型定義は省略している)
この関数は見ての通り、HTMLのElementを受け取りrefでリアクティブする、マウント時にその要素に何らかのイベントリスナーを仕掛ける、アンマウント時にそのリスナーを破棄する、という機能を持っている。

export function useEvent(_el, name, listener) {
  const element = wrap(_el);
  onMounted(() => element.value.addEventListener(name, listener));
  onUnmounted(() => element.value.removeEventListener(name, listener));
}

このElementはブラウザのwindowやdocumentといったglobal objectかもしれないし、Vueの仮想DOMでハンドリングできないDOM要素かもしれない。はたまた、template内のref属性で参照しているDOM要素ということもありえる。

ではもしマウント時にそのrefで参照した先がemptyだったらどうだろうか?
v-ifを使っていて初回レンダリング時には表示されない要素の場合などでは、そうなる可能性がある。そしてそうなった時にはエラーが起きるし、もちろんイベントをリッスンすることも出来ない。

または、ref値になっている変数elementの中身がリアクティブに変更される場合はどうだろう?
例えばその要素がkey属性でバインドされたリストアイテムだったり、v-ifで出したり消したりしたときに、要素はリフレッシュされてリスナーは破棄されてしまう。

そこでwatch()の出番

上記のコードはwatchを使うことによって以下のように書き換えられる。

export function useEvent(_el, name, listener) {
  const element = wrap(_el);

  watch(element, (el, _, onCleanup) => {
    el && el.addEventListener(name, listener)
    onCleanup(() => el && el.removeEventListener(name, listener))
  })
}

elementを監視して、新しい値elを調べてそれが存在した時にだけイベントリスナーを仕掛けることによって、まずonMountedの時の問題その1「mount時にrefがemptyで失敗する」という事態を避けられる。

そしてwatchのコールバックの第三引数で受け取れるonCleanupという関数を使い、監視中のelementが更新または破棄された時の処理を記述できる。すなわち、removeEventListenerをunmountedではなくこの場所に書けるのだ。

これで2つ目の問題も解決したことになる。elementの参照先が更新されるたびにwatchのコールバックが走り、新しい値にaddEventListenerを、古い値にremoveEventListenerを作動させることが出来るからだ。

watchはもちろんDOM要素のハンドリング以外にもあらゆる面で役に立つだろう。
あなたが今まで何かをonMountedで行った後、値に更新があった場合の処理がしたかったケースで、今やwatchは全ての問題をカバーできる機能を備えている。それも2行でだ。

Return computed > ref

もう一つ話しておきたいのは、切り出した関数からreturnする値は単なるref値よりもcomputedプロパティかもしくはread onlyの値であるべきであろう、という話だ。
例えばsetup()内でこのようにuseOnelineという関数から、ブラウザのAPI*から現在オンラインかどうかをリアクティブなフラッグとしてtemplateに渡すとする。
そして戻り値isOnlineはとりあえずref値だとしよう。

*window.navigator.onLineを使い、現在ブラウザがネットワークに接続しているかを調べることができる

createComponent({
  setup() {
    const isOnline = useOnline()

    return {
      isOnline,
    }
  }
})

その場合useOnline()の実装はこのようなものになるだろう。
この関数の中で見るべきはtrueを初期値として定義したref値の変数isOnlineと、それをreturnしている部分の2行だけだ。

export function useOnline() {
  const isOnline = ref(true)

  isOnline.value = window.navigator ? window.navigator.onLine : true

  /* 'online'イベントリスナーでisOnlineを更新する処理 */

  return isOnline;
}

まずrefでフラッグを定義することで、関数内でブラウザの接続状況を検知してisOnlineの値を更新することができる。そして見ての通り、最後にはそのままrefの値をreturnしている。
だけどrefはミュータブル(変更可能)な値なので、この関数の外側、呼び出し側で値を変更できてしまう。
そうできるのが望ましい場合(ユーザーが値を操作できるようにミュータブルな値を返したい場合など)もあるだろうが、この場合はコンポーネント側でのミューテーションがステートの一貫性を壊してしまう可能性がある。
だからこういう風にしよう。

  return computed(() => isOnline.value);

こうすれば関数内ではrefの値を更新できるのに対して、呼び出し側には実質read onlyのリアクティブな値を提供することができる。これによってステートはより安全に扱うことができるだろう。
ちなみにもし値がオブジェクトならreadonly()を使ってこういう風に書くことも可能だ。

  return readonly({
    isOnline,
    a: 'A',
    b: 'B'
  })

Name returned properties in context

最後に話すのは戻り値の命名についての提案だ。
以下の関数を例にとって説明していく。
この関数はDOM Elementが存在した時に、それをフルスクリーンにするかのハンドリングをするフラッグや関数を提供してくれる。

export function useFullscreen(target: Ref<HTMLElement | null>) {
  const isFullscreen = ref(false)

  function exitFullscreen() {
    if (document.fullscreenElement) {
      document.exitFullscreen()
    }

    isFullscreen.value = false
  }

  async function enterFullscreen() {
    exitFullscreen()

    if (!target.value) return

    await target.value.requestFullscreen()
    isFullscreen.value = true
  }

  return {
    isFullscreen,
    exitFullscreen,
    enterFullscreen
  }
}

しかし眺めてみるとあまりにも何度もfullscreenという単語が出てきて冗長に感じる。
作者は呼び出し側で分割代入を使うかもしれないと考えてこうしたのではないか、と想像は出来るが、個人的にはそのためにこういった命名をすることを良いアイデアだとは思わない。

なぜなら分割しない場合、こんな風に不必要にfullscreenという単語がダブってしまうから。

  const fullscreen = useFullscreen()
  onMounted(() => fullscreen.enterFullscreen())  🤔 

こうならないために、関数の中にはその関数の文脈(コンテキスト)があることを意識して下記のように書き替えてはどうだろうか。

export function useFullscreen(target: Ref<HTMLElement | null>) {
  const isActive = ref(false)

  function exit() {
    if (document.fullscreenElement) {
      document.exitFullscreen()
    }

    isActive.value = false
  }

  async function enter() {
    exit()

    if (!target.value) return

    await target.value.requestFullscreen()
    isActive.value = true
  }

  return {
    isActive,
    exit,
    enter
  }
}

今やこの戻り値を使う側は命名のダブりを省いた形で使うことができる。

  const fullscreen = useFullscreen()
  onMounted(() => fullscreen.enter()) 😊

それにもし分割代入を使いたいのなら、こうやってリネームすることも出来るのだ。

  const { enter: enterFullscreen } = useFullscreen()
  onMounted(() => enterFullscreen ()) 😊

例えばもし呼び出す側で特にvideo要素に対して使うなら、enterVideoFullscreenとリネームするのもいいだろう。

32
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
32
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?