9
5

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 1 year has passed since last update.

BrainPad Advent CalendarAdvent Calendar 2021

Day 25

TypeScript, Vue, Nuxt の Tips

Last updated at Posted at 2021-12-26

1 年ほど前から、既存のフロントエンドアプリケーションを Vue.js 2 + Composition API + TypeScript に書き換える作業に携わっている。その作業の過程において、特に TypeScript に関してぶつかった問題に対する解法をメモしておいた。今回は、そのメモを元に今回の移植で習得した技術の断片、とくに Tips 的な「すぐに役立つ」知識をまとめてみた。

前提 :

  • 使用しているフロントエンドフレームワークは Vue.js 2 である。
  • Composition API を使用して実装しているので、実装内容は Vue.js 3 的である。
  • Vue フレームワークとして Nuxt.js 2 を使用している。
  • 実装言語は TypeScript である。
  • スタイルはオブジェクト指向ではなく、関数型プログラミングである。

== null!= null

JavaScript (TypeScript) では等価性の比較に ==, != を用いるのはバッドプラクティスとされているが、一つ例外がある。

JavaScript (TypeScript) では、知らない人も多いと思われるが、x == nullx === null || x === undefined と等価であり、 x != nullx !== null && x !== undefined と等価である。

JavaScript (TypeScript) でオプショナルな値を扱う場合には、T | null もしくは T | undefined さらには T | null | undefined のいずれかを用いるが、どの場合でも xT を持つことの確認に != null を用いることができる。

const f = (value?: string) => { // value: string | undefined
  if (value != null) {
    // Type narrowing: `value` is `string`, not `undefined` here
    const upper = value.toUpperCase()
  }
}

const g = (value: string | null) => {
  if (value != null) {
    // Type narrowing: `value` is `string`, not `null` here
    const upper = value.toUpperCase()
  }
}

利点

  • 表記が簡潔。
  • T | null なのか T | undefined なのか T | null | undefined なのか気にしないで if (value != null) によって type narrowing できる。

欠点

  • 知らない人は === null, !== null と表記が違っていることすら気付かないだろう。
  • 気付いても意味が分かる人が少なく、意図が伝わりづらい。

オブジェクトから特定のプロパティを取り去る方法

いま、

const foos = { foo1: 1, foo2: 2, foo3: 3, foo4: 4, foo5: 5 }

があったとして、これから foo2:, foo4: だけを取り除いたオブジェクトがほしいとする。

以下のようにすれば良い :

const { foo2, foo4, ...oddfoos} = foos
console.log(oddfoos)  // → Object { foo1: 1, foo3: 3, foo5: 5 }

なお、TypeScript では型からプロパティの型を取り除くことも可能で、Omit<> を用いて次のようにする :

type Foo = {
  foo1: number
  foo2: number
  foo3: number
  foo4: number
  foo5: number
}
type OddFoo = Omit<Foo, 'foo2' | 'foo4'>

props のリアクティブ性

export default defineComponent({
  props: {
    value: { type: String, default: '' },
    count: { type: Number, default: 0 },
  },
  setup(props) {
    // Want to use `value` as ref:
    const value1 = computed(() => props.value)  // Good; `value1` is reactive
    const value2 = toRef(props, 'value')  // Good
    const upper = computed(() => props.value.toUpperCase())  // Good

    // Want to watch `value`:
    watch(props.value, (v) => { /* ... */ })  // Bad; `props.value` is not reactive
    watch(props, (p) => { /* ... */ })  // Not so good; It watches `value` and `count`.
    watch(() => props.value, (v) => { /* ... */ })  // Good
    watchEffect(() => { /* use props.value here */ })  // Good
  },
})

Vue コンポーネントの props: で宣言したコンポーネントの属性は setup(props) の第 1 引数 props で受け取ることができる。この props はリアクティブオブジェクトであるが、props のプロパティ 1 つ 1 つはリアクティブではないことに注意する必要がある。

属性を個別にリアクティブ値として扱いたい場合は以下のようにする。

  • 属性を個別のリアクティブ変数にしたい場合は、computed() もしくは toRef() を用いる。
  • 個別の属性の変更を監視したい場合は watch(() => props.value, ...) を用いる。もしくは watchEffect() を用いる。

void 0undefined

void 0 という JavaScript の表現を知っているだろうか?

const x = {
  foo: void 0,
}

のように定数として使い、意味は undefined である。

const x = {
  foo: undefined,
}

undefinedwindow (globalThis) のプロパティで、値 undefined を初期値として持つ。JavaScript の予約語とかではなく、普通の識別子である。

その昔、window.undefined は書き換え可能だったので、事故を避けるために undefined の代わりに void 0 が使われた。

現在では window.undefined は書き換え不可なプロパティとして定義されているので、もはや void 0 を使う必要はないし、使ってはならない(ぱっと見意味がよくわからないから)。

分割代入でネストしたオブジェクトのプロパティとネスト元を同時に代入する方法

Composition API や Nuxt のプラグインでインジェクトされたコンテキストを利用していると、JavaScript (TypeScript) の分割代入を使う機会が増える。分轄代入機能ではネストしたオブジェクトのプロパティの代入を行うことができる。

type DialogService = {
  $dialog: {
    $confirm: ConfirmDialogService
    $alert: AlertDialogService
  }
}

const useDialog = (): DialogService => { /* ... */ }

// ...

export default defineComponent({
  setup(props) {
    const { $dialog: { $confirm } } = useDialog()
    $confirm.show('hello')
  }
}

滅多にないことだが、上の例で $dialog も同時に取得したいときにはどのようにすればいいだろうか?そもそも可能なのか?

以下のようにできる :

export default defineComponent({
  setup(props) {
    const { $dialog, $dialog: { $confirm } } = useDialog()
    $confirm.show('hello')
    $dialog.$alert('error')
  }
}

逆に言うと、const { $dialog: { $confirm } } = useDialog() では、$dialog は変数にはならない。

厳密なオブジェクトリテラルチェック

TypeScript ではサブタイプ(拡張した型)の値をスーパータイプに割り当てることができる(普通のオブジェクト指向言語と同じ)。

type A = { a: number }
type B = A & { b: string }

const f = (a: A, b: B) => {
  a = b  // Good; B は A を拡張した型(サブタイプ)だから; b = a はエラー
}

ところが、型 B の値である { a: 1, b: 'b' } というリテラルを直接代入しようとするとエラーになる。

const f = (a: A) => {
  a = { a: 1, b: 'b' }  // ERROR
}

これは、TypeScript がリテラル値を代入する際に特別に厳しい型チェックをすることが原因で、リテラルの書き間違いを検出するためにこのようになっている。

一度変数を経由すればエラーを避けることができる。

const f = (a: A) => {
  const c = { a: 1, b: 'b' }
  a = c  // Good
}

DOM の配列を JavaScript (TypeScript) の配列として使う方法

DOM の API で得られる配列みたいなものは、配列のように扱えるが JavaScript の配列そのものではないことが多い(HTMLCollection)。

例えば以下のようなことはできない:

const children = document.getElementById('foo').children
children.forEach((child) => { /* ... */ })  // ERROR

解決法 — Array.from() を使う:

const children = Array.from(document.getElementById('foo').children)
children.forEach((child) => { /* ... */ })  // OK

ただし、HTMLCollectionlive であることが特徴で、第 1 の例の children をずっと保持しておいて、その間に foo 要素の子孫が変化した場合、chidren 配列も更新される。一方、第 2 の例の children は、その時点の子孫の配列のコピーであり、変化しない。

条件付きスプレッド演算子

ある条件が満たされたときだけプロパティの値をセットしたい場合:

type Item = {
  a?: number
}

const f = (b: boolean): Item => {
  return {
     ...(b ? { a: 1 } : {}),
  }
}

f(false)  // => {}
f(true)   // => { a: 1 }

と書けるだろう。より見通しの良い次の書き方が可能である:

const f = (b: boolean): Item => {
  return {
     ...(b && { a: 1 }),
  }
}

(上の方が紛れがない書き方のような気がしないでもない...)

オブジェクトのオプショナルプロパティと reactive

オプショナルなプロパティ x?: type を持つオブジェクトを reactive() でリアクティブ化する場合、reactive() に渡す値に x を含める必要がある。

仮に初期値が undefined なら x: undefined と明示的に指定する必要がある。

もし含めずに、後ほど .x = xxx と代入してもリアクティブの連鎖は発生しない。

type T = {
  a: number
  b?: string
}

const state = reactive<T>({
  a: 5,
})

watch(state, (cur) =>{
  console.log(cur)
})

setTimetout(()=> {
  state.b = 'hello'  // → 代入しても watch() 内の console.log() は実行されない。
}, 1000)

正しくは以下のようにすべき:

const state = reactive<T>({
  a: 5,
  b: undefined,
})

typo に起因する非常に見つけにくいバグ

あるモジュールをデバッグしていて、間違った型を指定したにもかかわらず、それをインポートする特定の .vue ファイルだけでは、全く型エラーも発生しないし、Vetur によるエラーも出ないという謎現象に遭遇した。

ビルドは通るのだが、型を間違っているせいで実行はうまくいかない。その謎の .vue ファイルの一部を取り出してみた。

...
</template>

<script lant="ts">
import computed, defineComponent } from '@nuxtjs/composition-api'
...

気が付いただろうか?

<script lant="ts"> ... lant !!!

正しくは <script lang="ts">

教訓:手打ちはなるべく避けて、エディターで補完される箇所はエディターにまかせる

non-null assertion を使わざるを得ないケース

type narrowing は関数境界をまたがないので、non-null assertion ! を使わざるを得ないケースがある。

const f = (list: readonly string[], item: ComputedRef<Item | null>) => {
  if (item.value != null) {
    const found = list.find((x) => x === item.value!.name) // ! がないと ERROR
  }
}

一応以下のようにすれば避けられるが、、、冗長。

const f = (list: readonly string[], item: ComputedRef<Item | null>) => {
  if (item.value != null) {
    const name = item.value.name
    const found = list.find((x) => x === name)
  }
}

T | null な props を宣言する方法

コンポーネント <component :item="item">itemItem | null としたいとする。普通に考えれば

export default defineComponent({
  props: {
    item: { type: Object as PropType<Item | null>, required: true },
  }

とするだろう。しかしこれは(Vue の「バグ」のせいで)うまくいかない。 <component :item="null"> としたときに [Vue warn]: Invalid prop: type check failed for prop "item". Expected Object, got Null という警告が console に出てしまう。

現在のところ、対処法としては以下のようにするしかない。

export default defineComponent({
  props: {
    item: { type: Object as PropType<Item | null>, default: null },
  }

required: true を指定できないので、 <component> のように item を指定しなかった場合の警告は出ないが、仕方がない。

is の使いみちの例

is を用いてユーザー定義型ガードを作成できる。ユーザー定義型ガードによって、type narrowing を生の条件文ではなく関数で行うことができる。例えば:

const isListElement(x: Element) x is HTMLLIElement => {
  return x instanceof HTMLLIElement
}

// ...

if (isListElement(e)) {
  // e は HTMLLIElement 型
  console.log(e.type)  // "disc", "square", or "circle"
}

上の例は1行の条件文を関数にしただけでありがたみが無いと思うかもしれないが、意外な使いみちがある。

配列から条件に適合した要素を抜き出す .filter() メソッドは : is xxx が返り値の条件関数を引数に取る場合、xxx 型の配列を返すように定義されている。

例えば <ul> の子供要素から <li> の配列を取得することを考えよう。

<template>
  <div>
    <ul ref="ul">
      <li><!-- ... --></li>
      <li><!-- ... --></li>
      <li><!-- ... --></li>
    </ul>
  </div>
</template>

<script lang="ts">
setup() {
  const ul = ref<Element>()

  watch(ul, (cur) => {
    if (cur) {
      const listitems = Array.from(cur.children).filter(
        (x) => x instanceof HTMLLIElement
      )
      listitems.forEach((li) => console.log(li.type))
        // ERROR: Property 'type' does not exist on type 'Element'.
    }
  })

  return { ul }
}
</script>

ではダメである。listitemsElement[] となってしまうからである。そこで is を用いると問題が解決する。

setup() {
  const ul = ref<Element>()

  watch(ul, (cur) => {
    if (cur) {
      const listitems = Array.from(cur.children).filter(
        (x): x is HTMLLIElement => x instanceof HTMLLIElement
      )
      // listitems `is` HTMLLIElement[]
      listitems.forEach((li) => console.log(li.type))
    }
  })

  return { ul }
}

(なお、isas と同様にしょせん強制型指定であるので、よりわかりやすい

const listitems = Array.from(cur.children).filter(
  (x) => x instanceof HTMLLIElement
) as HTMLLIElement[]

の方が良いという説もある。。。)

reactive の個別プロパティを watch で監視する

reactive() で生成したオブジェクトのプロパティを個別で監視したい場合がある。

const dialog = reactive({
  a: '',
  b: 0,
})

dialog.a を監視したい場合:

watch(dialog.a, ...) はダメである。dialog.a はリアクティブではない。

watch(dialog, ...) で可能だが、b: だけが変更されても監視関数が実行されてしまう。

正解は watch(() => dialog.a, ...) のように、監視対象を関数にすることである。

応用として、コンポーネントのプロパティを個別に監視したい場合がある。

props: { p: { type: string, required: true } },
setup(props) {
  watch(() => props.p, ...

Ref が変化するのはいつか?~コンピューター言語における等価性~

ref() で定義した Ref 型の変数は例えば watch() で変化を検出できる。

const v = ref(0)
watch(v, (cur) => console.log(cur))

v.value = 0  // 何も起こらない
v.value = 1  // console に 1 が出る

ref() の実装では .valueset() (JavaScript 言語のセッター機能)により実装されており、 .value = という代入時には関数が実行される。

この関数で、現在 .value に入っている値と、これから代入しようとしている値を比較して「異なる」値を検出すると watch() のコールバックが呼ばれることになる。

ではこの比較とは実際には何なのだろうか?次の場合に watch() のコールバックが呼ばれるかどうか考えてみてほしい。

const v = ref({ count: 0 })
watch(v, (cur) => console.log(cur.count))

v.value = { count: 0 }  // ???
v.value = { count: 1 }  // ???

正解は「どちらもコールバックが呼ばれる」である。

人間の目で見ると、前者( { count: 0 } から { count: 0 } )は値が変わっていないと認識されるので、この結果は奇妙に見える。この奇妙さは、コンピューター言語における等価性(比較)の実装から来るものである。

ref() の実装では等価性の比較に JavaScript 言語の === を用いている。 === は、左右が値オブジェクト( boolean, number, string )のときは、その値を比較し、そうでない場合は、言語処理系における内部のオブジェクトのアドレスを比較している。

このため ref() に渡した { count: 0 } というオブジェクトと、後で代入した { count: 0 } は(中身は同じだが)別々の場所で作られた別々のオブジェクトであって、内部アドレスが異なるので ===false を返す。よって等価でないと判断され watch() のコールバックが呼ばれるのである。

JavaScript 言語が用意する等価性機能は、基本的には === しかない。このため { count: 0 }{ count: 0 } を「同じ」だと判断するような機能を用意するためには自分で実装する必要がある。

例えば Jest ではこの機能を用意している。

expect({ count: 0 }).toBe({ count: 0 })     // FAIL
expect({ count: 0 }).toEqual({ count: 0 })  // PASS

.toBe()=== であり、 .toEqual() は Jest が用意した、オブジェクトのプロパティを再帰的に === で調べていくメソッドである。

コンピューター言語にいろいろなレベルの等価性が存在することは基本事項なので、認識しておいてほしい。さらに学習したい場合は、コンピューター言語の学習に頻繁に使われる Scheme 言語を調べてみるのもいいだろう(→等価性と比較 (Gauche ユーザリファレンス))。Common Lisp には 5 種類もの等価性判定関数が用意されている(Equality in Lisp - Eli Bendersky's website)。

補足1

===true を返すようにするには参照を代入すれば良い。そのときは watch() のコールバックは呼ばれない:

const initial = { count: 0 }
const v = ref(initial)
watch(v, (cur) => console.log(cur.count))

v.value = initial  // 何も起こらない
補足2

reactive() は、オブジェクトのプロパティを再帰的に走査して、全部のオブジェクトプロパティに === による比較を用いる監視機能を付ける。

補足3

JavaScript 言語での == は、単純な === ではない便利な比較を用意しようとして提供されたものだが、 == null 以外の仕様が腐っているので、議論の対象外である。

補足4

ref() の実際の実装では === ではなく Object.is() で等価性を判断しているが、話の都合上 === で実装したことにしてある。 Object.is() はほとんど === と同じ(→Object.is() - JavaScript | MDN)。

TypeScript コンパイラーの型エラーの省略をしないようにする

TypeScript 型エラーが出る場合、コンソールに出る型が長い場合に ... 4 more ... のように省略されて出力される。

この ... n more ... の部分を見たい場合には tsconfig.json の "compilerOptions": "noErrorTruncation": true, を臨時に設定する。

常に設定すれば良いではないか、と思われるかもしれないが、エラー時の表示がとんでもないことになるのでやめておいたほうがいい。

コンポーネントイベントと DOM イベント(カスタムイベントとネイティブイベント)

  • コンポーネントイベント(カスタムイベント)は $emit() でトリガーされるイベントで、バブルアップしない。
  • DOM イベント(ネイティブイベント)はブラウザーによってトリガー(または element.dispatchEvent() によってトリガー)されるイベントで、イベントごとにバブルアップするかどうか決められている。
  • イベントハンドラーを記述したい場合には、組み込みの HTML タグに対しても、コンポーネントタグに対しても、同じ表現 v-on:eventname= (または @eventname=) 属性を用いる。同じ表現でも、HTML タグに対して書いたときと、コンポーネントタグに書いたときとは意味が異なるので注意が必要である。
  • コンポーネントに対する @eventname= では DOM イベントのハンドラーを書くことはできない。
  • どうしても書きたい場合は <child-component @click.native="..." /> のように .native 修飾子を用いる。

(以下の例では setup() から return { log: console.log } してある)

<!-- parent.vue -->
<template>
  <div @click="log('click: outer')" @userclick="log('userclick: outer')">
    <child-component @click="log('click: parent')" @userclick="log('userclick: parent')" />
  </div>
</template>

<!-- child-component.vue -->
<template>
  <div @click="log('click: child root')" @userclick="log('userclick: child root')">
    <input type="button" value="click" @click="$emit('userclick')" />
  </div>
</template>

ボタンをクリックすると以下のように表示される:

userclick: parent  // (1)
click: child root  // (2)
click: outer       // (3)
  1. emit による userclick イベントが parent.vue: <child-component>@userclick= で処理された。バブルアップはしない。
  2. バブルアップした DOM click イベントが child-component.vue: <div>@click= で処理された。さらにバブルアップ。
  3. バブルアップした DOM click イベントが parent.vue: <div>@click= で処理された。さらにバブルアップ。

バブルアップした DOM click イベントが parent.vue: <child-component>@click= で処理されないのはなぜだろうか?それは、@click= が HTML タグと Vue コンポーネントタグのどちらに書かれるかで意味が変わるからである。HTML タグに書くと DOM イベントに対するハンドラーを記述し、コンポーネントタグに書くと emit によるコンポーネントイベントのハンドラーを記述することになる。

コンポーネントタグで DOM イベントを処理したい場合、.native 修飾子を使うとコンポーネントタグに DOM イベントハンドラーを指定できる。

<!-- parent.vue -->
<template>
  <div @click="log('click: outer')" @userclick="log('userclick: outer')">
    <child-component @click.native="log('native click: parent')" @userclick="log('userclick: parent')" />
  </div>
</template>

クリックすると以下のように表示される。

userclick: parent
click: child root
native click: parent
click: outer

なお、.native 修飾子が記述できるのはコンポーネントタグに対してだけで、HTML タグに記述すると console に警告が表示される。また、コンポーネントタグに対して同一イベントの .native ありとなしの両方のハンドラーを記述することはできない。

次に child-component.vue の <input> のクリック処理でイベントのバブルアップを止めてみよう(.stop 修飾子を使う)。

<!-- child-component.vue -->
<template>
  <div @click="log('click: child root')" @userclick="log('userclick: child root')">
    <input type="button" value="click" @click.stop="$emit('userclick')" />
  </div>
</template>

クリックすると以下のように表示される。

userclick: parent

DOM click イベントのバブルアップが child-component.vue: <input> で止まるので、コンポーネントイベント userclick しか処理されない。そしてそれはバブルアップしないので、コンポーネントタグでのみ処理される。

では、$emit() するイベントを 'userclick' から 'click' に変えるとどうなるだろうか?→ 名前は DOM click イベントと同じでも、それはコンポーネントイベントなので、どのようにしてもバブルアップはしないし、 DOM イベントハンドラーで処理されることもない。それは parent.vue のコンポーネントタグ <child-component> においてのみ処理される。

<!-- parent.vue -->
<template>
  <div @click="log('click: outer')" @userclick="log('userclick: outer')">
    <child-component @click="log('click: parent')" @userclick="log('userclick: parent')" />
  </div>
</template>

<!-- child-component.vue -->
<template>
  <div @click="log('click: child root')" @userclick="log('userclick: child root')">
    <input type="button" value="click" @click="$emit('click')" />
  </div>
</template>

クリックすると以下のように表示される:

click: parent     // (1)
click: child root
click: outer

(1) の userclick が click に変わっただけである。parent.vue: <child-component>@userclick= のかわりに @click= のハンドラーが実行された点だけが異なる。

(1) より下の click: というのは emit() による click コンポーネントイベントではなく、.stop されなかった DOM click イベントがバブルアップしたものが処理された結果である。

その他 .native の利用や .stop による DOM click イベントのバブルアップ停止なども userclick のときと全く同じように動作する。ただ、名前が DOM イベントとコンポーネントイベントでかぶって紛らわしいだけである。

なお、コンポーネントイベントはバブルアップしないので、parent.vue: <child-component @click.stop="..."> のように .stop を指定するのは全く意味がない。

9
5
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
9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?