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 == null
は x === null || x === undefined
と等価であり、 x != null
は x !== null && x !== undefined
と等価である。
JavaScript (TypeScript) でオプショナルな値を扱う場合には、T | null
もしくは T | undefined
さらには T | null | undefined
のいずれかを用いるが、どの場合でも x
が T
を持つことの確認に != 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 0
と undefined
void 0
という JavaScript の表現を知っているだろうか?
const x = {
foo: void 0,
}
のように定数として使い、意味は undefined
である。
const x = {
foo: undefined,
}
undefined
は window
(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
ただし、HTMLCollection
は live であることが特徴で、第 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">
で item
を Item | 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>
ではダメである。listitems
が Element[]
となってしまうからである。そこで 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 }
}
(なお、is
も as
と同様にしょせん強制型指定であるので、よりわかりやすい
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()
の実装では .value
は set()
(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)
- emit による userclick イベントが parent.vue:
<child-component>
の@userclick=
で処理された。バブルアップはしない。 - バブルアップした DOM click イベントが child-component.vue:
<div>
の@click=
で処理された。さらにバブルアップ。 - バブルアップした 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
を指定するのは全く意味がない。