🦗

Vue3を個人開発で1ヵ月使い込んでみての所感

2021/03/19に公開

初めに

タイトル通り。Vue3はIE11対応されたら起こして状態なまま触っていなかったが、急に作りたいものができてしまいVue3をしばらく使ってみたので使い心地や所感などの殴り書き。

ところどころ誇張気味なので注意。それVue3関係ないやんも自分で読み返してみると多々ある。
可能なら久しぶりにクソデカ羅生門でも読んで器を大きくしておいてほしい。

https://anond.hatelabo.jp/20200611125508

そしてVue3を高々1ヵ月使っただけの所感なので、参考にしてもらえたら嬉しいけども真に受けてはいけない。

筆者スペック

  • Vue2実務経験20年のプロ
  • Vue3歴1ヵ月の素人
  • immutable信者

作っているもの

紹介っぽい記事はこちら。
https://zenn.dev/miyanokomiya/articles/145933cc06b4db

3行で紹介するならSVGアニメーションエディタ、60fpsでアニメーション、サーバーなしのスタンドアロン。
要するにSVG版のBlenderっぽいものが作りたかった。
https://github.com/miyanokomiya/blendic-svg

Vue3の採用理由

今回は作りたいという気持ちファーストで何を使うかは重要ではなかった。なので使い慣れて現状最もパフォーマンスを発揮できるVueを選び、さらに個人開発で古いブラウザを気にする必要はなく、今から敢えてVue2を使う理由もなかったのでVue3を採用。

本編

dev環境

Vite

dev環境としてはとにかく速い。速いは正義。
MPAのエントリポイントを扱えたりしないんだろうか。巨大なMPAプロダクトのWebpackビルド体験に革命をもたらして欲しい。

docもそれなりには作ろうと思ってvitepressにも手を出している。今後はvitepressに移行していくのかなと思いきやvuepressもvite対応していて結局どういう状況なのかはよく分かっていない。

linter

Vue3おすすめ設定が便利。refcomputed.value付け忘れっぽい箇所を自動fixしてくれたりもする。付け忘れと判別できない箇所はさすがにfixしてくれないので完璧ではないが、手間はかなり省ける。

const data = ref(0)

data = 2 // => data.value = 2 にfix
if (data) {} // => (data) のまま

それ以外はあまり覚えていないのでそれほど目新しさはない。とりあえずEslintとPrettierのない生活はもう考えられないくらいの依存症。

今回の設定はこんな感じ。Viteはこのあたりの設定までは用意してくれないようなので自分で入れる。
https://github.com/miyanokomiya/blendic-svg/blob/main/.eslintrc.js

気になる型

前提

筆者の環境はnvim + coc-nvim + vetur。vscodeでならもっと快適な可能性はある。
SFC内だと他のファイルに新たに作った関数などはlanguage-serverを再起動しないとなぜか認識してくれない。そのうち原因を調べようとおもいつつ再起動すれば直るので放置中。

SFC内

SFC内であればtemplate部分にも型チェックが効く(Vue3というよりvetur側の機能かも?)。setup内でも特に不便はない。

SFC跨ぎ

SFCを跨いだら型情報はなくなる。Vue2から変化なし。これはやはりVueの弱点として目立つ。

injectは素直に使うと型は付かない。自分で指定すれば付く。シンボルを使うといい感じに付けられるという噂も聞いたが試していない。inject自体そんなに頻繁に使う機能でもないのでこれはそこまで気にならない。

const data = inject<Data>('data')

emitsに発行するイベント名を登録できるのは便利。propsと並ぶのでコンポーネントのインタフェースがより分かりやすくなる。イベント名が正しいかの型チェックもしてくれる。
ただしemitする値に型を付けることはできないので物足りなさは否めない。値をバリデーションする機能が追加されているものの、emitとの参照は切れていて型チェックはしてくれない。そのうち型職人がなんとかしてくれるのではないかと勝手に期待している。

どんなもんなのcomposition-api

options-api

呼び方が合っているのか自信ないがVue2時代のclassでない方のコンポーネントの書き方。

今から新規にコンポーネントを作るなら敢えて使う理由が薄い。
options-apiの方が適しているシーンがあったとしても、composition-apiと併用していくコストの方が高い。同じことができるなら同じ記法でよい。

ただし既存コードがoptions-apiベースになっているなら無理に書き換えるメリットも薄い。
書き換えることを目的にリファクタを行うと、次に挙げるような劣化options-apiになる可能性が非常に高い。高いというかなる。
もし書き換えるのなら、ロジックを切り出したい、素振りをしたいなどの目的を明確にしておく。

劣化options-apiになりがち

setupという同一スコープにそのまま書いていくと結局こういう風に種別にコードが並んでいく。
それならば最初からdatacomputedmethodsで分かれてるoptions-apiでいいのでは感は否めない。

const data1 = ref()
const data2 = ref()

const getter1 = computed(() => 1)
const getter2 = computed(() => 2)

function methods1() {}
function methods2() {}

composition-apiの思想的には下記のようにまとめのが適切だとしても、setupという同一スコープ内だと視認性が悪い。最初はうまく整理できたと思ってもそのうち境界が曖昧になっていく。場合によってはgetter1data2に依存しだしたり。
そして結局上記のように配置しなおすことになる。

const data1 = ref()
const getter1 = computed(() => 1)
function methods1() {}

const data2 = ref()
const getter2 = computed(() => 2)
function methods2() {}

コメントで境界を示したとしても信じる根拠とはならない。境界を作りたいなら言語としてのスコープで切らなければ無意味。

切り出しタイミング

なんでもかんでも切り出してuse**を量産すべきかと言われるとそれはそれで微妙。コンポーネントとしての機能がそれほど大きくなければ、切り出さずsetup内にすべて書いてしまった方が手っ取り早いし視認性がいいこともある。

以下3つの切り出し方を使い分ければなんとかなる。

  • 切り出さない
    コンポーネント内で処理が完結していて、最悪作り直せばいいような末端コンポーネントなら多少の視認性の悪さは受け入れて切り出さない。
    ただし後で書くがcomposableとして切り出さないだけで、関数としては積極的に切り出す。

  • SFC内で切り出す
    setupの行数が伸びて視認性が悪くなってきたらとりあえずsetup外SFC内の適当な場所に切り出す。もちろんモジュールのルートスコープを汚染しないように。
    難点としてはSFC内なので切り出した部分だけのテストはできない。切り出しはコンポーネント内部的なもので、外部はそれを認知できない。他で使うか分からないし今はSFCに留めておきたいが、setup内の視認性が悪いからスコープが欲しいという時の手段。とはいえこれをするだけでもかなり効果がある。スコープが区切られていなければ副作用はどこまでも漏れていく。

  • 別ファイルに切り出す
    他のコンポーネントでも使えるような汎用的な処理であれば別ファイルに切り出す。

副作用を切り出す

composition-apiの切り出しについて色々書いたが、「なにを」切り出すのかは重要。

composableとして切り出すのは、「副作用のある処理のまとまり」。
例えばrefreactiveで状態を持っているのはまさに副作用のある処理。

重要なロジックは、「副作用のない関数」として別の場所に切り出す。composable切り出しよりもこちらの切り出し観点の方が遥かに重要。
極論composableに切り出すべきかどうかは大した問題ではない。大事なものは関数に切り出す。動作が不安なら関数に切り出す。共通化したいなら第一候補はcomposableではなく関数への切り出し。そしてテストを書く。

例えばこんな感じのものすごく重いcomputedがあったとする。

const text = computed(() => 'ものすごく重い処理を通した結果の文字列' )

このときの切り出し方は、computedをcomposableにではなく、computed内のものすごく重い処理を副作用のない関数として切り出す。

Comp.vue
const text = computed(() => getHeavyText() )
util.ts
export function getHeavyText () { return 'ものすごく重い処理を通した結果の文字列' }

別の場所でも使いたくなったらコピペする。これすら許せないならclass作って継承するなり好きにする。

Other.vue
const text = computed(() => getHeavyText() )

もし状態が絡むのならcomposableへの切り出しも視野にいれる。

Comp.vue
const data = ref()
const text = computed(() => getHeavyText(data) )
composable.ts
export function useData() {
  const data = ref()
  const text = computed(() => getHeavyText(data) )
  return { data, text }
}

composableへの切り出しと関数への切り出しはそれぞれ別の観点であって排反ではない。
副作用が絡むならcomposableへ、副作用を分離できるなら関数へ、それぞれの観点でもって切り出す。

もはやVue3に限らないがテスタブルでバグの少ない動くコードを作るには、ただ引数を受け取って戻り値を返すだけの副作用のない関数に切り出し続けることがとても重要。副次効果としてそれは特定フレームワークに依存しないことにも繋がる。

副作用がなくよくテストされた関数はいわば定理みたいなもの。信頼できる。テストがないときは予想と呼んでおこう。定理は連ねれば新たな定理となる。そうやって作った処理は強い。副作用が混じっていると、いわば仮定に変数が混じっているような状態になる。それは定理には届かない。そこからどんな強固な定理を連ねたとしても仮定が崩れれば終わり。
そう考えるとElmは悔しいくらいによくできてる。なにせ開発者が作ったものは全て定理にできるのだから。

補足しておくと別に一切の再代入を禁じて関数を書けというわけではない。JavaScriptでそれをやるのは割に合わないことの方が多い。そして実際それは面倒くさい。なので最低限、関数は入力と出力に対して副作用を及ぼさない(表現が合っているのだろうか...)ように書く。
関数内のローカル変数をletで作って再代入するのはそれほど悪ではない。varは要らない、すまん。
Webフロントエンド特有の事情としてローカル変数を積極的に使った方がminify後のコード量が減ったりもする。微々たるものだが積み重なると結構減るらしい。
関数型言語への憧れを抱いてしまうと極力変数宣言せずワンライナーの関数の連なりで全てを完結させたくなってしまう。分かる。気持ちいい。型がバシッと決まってprettierでビシッと整形する瞬間は気持ちいい。
細かいことは気にせず関数内の実装は好きに書くのが一番。物足りなくなったらElmで書こう。とにかく大事なのは関数が副作用を伝播しないこと。

どっちのref/reactive

用途が似ているこれらの機能。使い分け方は正直なところあまり分からない。

immutableに書きたい人間なのでrefの方が出番は多い。

const point = ref({ x: 1, y: 2 })

function update(p) {
  point.value = p
}

よく考えるとimmutableというのは語弊がある。immutable風。十分ではないがVue3ではこれが限界。つーかこれが限界。言ってみたかった。

一方reactiveだとこうなのでmutable感がより強い。

const point = reactive({ x: 1, y: 2 })

function update(p) {
  point.x = p.x
  point.y = p.y
}

状態をreactiveでまとめる方法もあるがぱっとしない。確かにこのまとまりが状態ですよと一見分かりやすいが、composition-apiの肝である関心の分離と相反する。つまるところ劣化options-apiに陥りやすい。

const state = reactive({ p1: { x: 0, y: 1 }, p2: { x: 0, y: 0 } })

素直にこれでよい。

const p1 = ref({ x: 1, y: 2 })
const p2 = ref({ x: 1, y: 2 })

パフォーマンスを厳密に測るならreactiveの方が速い気もするが真偽は不明。
どちらにせよきっと誤差程度。遅いのはお前のコード、俺たちのコード。

どうなのprovide/inject

コンポーネントの境界をぶっ壊してくるので使わないに越したことはない。

今回も使うつもりはあまりなかったが、ツリー構造の再帰的なコンポーネントを作るときにふと思い立って使ってみたら意外と便利だった。

ツリー構造を無視して受け渡したいような情報、例えばこんな感じに全ノードのマップとイベントハンドラをprovideしておけば、コンポーネント側はpropsemitを経由せず直接ツリーの管理元となるコンポーネントとやりとりができる。

provide('nodeMap', nodeMap)
provide('onClick', (id: string) => console.log(id))

もちろんpropsemitでツリー構造通りに受け渡すでも結果は変わらない。
今のところどちらが優れているかは判断付かず。好みの問題に帰着するかもしれない。そして好みの問題であるならコンポーネントの境界を守る方向に寄せておくのが無難な可能性は高い。

さらに踏み込むとVue2時代は公式docにアプリでは使わない方がいいよ的なことが書いてあったのも尾を引いている。一方Vue3ではそういう解説は表に出てこず、むしろ使えとアピールしているように見える。
しかしながら提供する機能的にVue2時代から劇的な変化があったようにも見えない(自分の理解が足りていない説もある)。なのでまだ積極的に利用する気にはなれず様子見していたいという立ち位置。

まさかnullではなくundefined

これはVue3との関係は薄め。一応書いてみる。

空を表現するときはnullを使うものだと長年考えていた。undefinedはその変数やプロパティ自体が存在しないようなイメージ。

なのでpropsなんかでも空を許すときはdefault: nullとしていた。
undefinedにするとリアクティブが死ぬのでは(要出展)という不安もなくはない。

props: {
  value: { type: Object, default: null },
}

しかしcomposition-apiは全体的に空をundefinedで表している。

const data = ref<string>() // => string | undefined

nullで表したい場合はこうしないといけない。面倒くさい。

const data = ref<string | null>(null)

これを書きながら思い返してみると実はoptions-api時代からそうだったかもしれない。自分の使い分けが少数派だっただけという可能性が高まってきた。長年勝手な自己流でやっていただけなのだろうか。そういえばOptional Chainingもアクセスできなかった時に返すのはundefinedだ。降参しよう。今後はundefined派になります。

いつでも難しいstore管理

[失敗気味] composableにstaticな状態を持たせる

今回採用してみた方法。総合的な評価としては微妙。

簡単な実装サンプルとしては以下のようなもの。単にcomposableの状態をstaticに持っているだけ。

store.ts
const state = ref({ a: 1, b: 2 })

export function useStore() {
  return { state }
}

使う側はcomposableとしてそのまま使う。中身の状態はstaticになっているのでコンポーネント間を跨ぐことができる。

Comp.vue
const store = useStore()

最初はよかった。楽。簡単。
問題はその後、storeをモジュール的に管理したくなってくるタイミング。そしてモジュール間に依存関係が出てくるようなケース。

store2.ts
const state = ref({ c: 3 })
const store = useStore()

const text = computed(() => 'stateとstoreに依存する処理')

export function useStore() {
  return { state, text }
}

とても怪しい。もしstore.tsからもstore2.tsを参照したくなったらどうなるだろうか。未来は険しい。最悪詰む。

正直store設計については今回のプロダクトは芳しくない。今のところは誤魔化せているが、今後リファクタしたい箇所の最有力候補。

composableベースのstoreをワークさせるには、storeモジュール間に依存関係がないか、単一のstoreファイルにすべてを詰め込んでいく気概が必要。あるいは開発者がプロダクトコードを知り尽くした自分一人であること。これらどれかの条件が成立しているのであれば一度やってみるといい。つまりやらない方がいい。
「なんか行けそうじゃない?」で走り出していいのは趣味の個人開発まで。被害が大きすぎるから仕事のチーム開発でやってはいけない。

それでもcomposableでstoreを作りたいのなら、似たような先行ツールで知見も溜まっているだろうReact hooksのプロに聞いてみてほしい。hooks流store構築の妙技を知っているかもしれない。教えてほしい。

Vuexかなぁ

設計次第ではcomposableベースでもいけるかもしれないが素直にVuexに頼る。
ゼロベースのオレオレstoreを設計するのではなく、始めからVuexでどうstore管理するかを設計する。先人の叡智を信じる。

書くところは書いてるテスト

関数テスト

副作用のない関数として切り出した処理は必ずテストを書く。引数と戻り値のパターンを用意するだけなので大した手間は掛からない。

たとえreturn a + bみたいな関数でもテストを書く。シンプルで間違えようがない処理なんだから不要でしょと考えず、シンプルだからこそ一瞬で書けるし書いてしまおうと考える。

経験則として最初にテストが用意されなかった関数はその後も用意されない。拡張するたびに「シンプルな分岐を追加しただけだし大丈夫でしょ」の判断が積み重ねられていく。「既存実装もテストないんで」という言い訳だって用意されている。シンプルだからこそ最初からテストを書いておく。

composableテスト

全然書いていない。一般論で言えばよろしくない状況。

今のところなんとかなっているのは重要な部分はほぼ関数として切り出してそちらでテストを書いているからだと思われる。そして関数は原則として副作用がなく、引数を勝手に壊したりしないという安心感に支えられている。
渡した先で状態を勝手に変更されるような関数でまみれていたらもっとテストを書きまくらないと前に進めなくなること間違いなし。

composableが持つ状態はcomposable内部でしか変更できないようにしておくことも重要。
せっかくcomposableとして切り出したのにフランクにその状態を変更できてしまうと境界を作ったメリットが薄れる。

JavaBeans風味を感じる。分かる。でも回りまわってその方がいい。

getterは必ずしも律儀にすべて用意する必要はない。composable外で迂闊に状態を変更してしまうような要因を少しでも減らしたければ用意する。

composableを使う側からするとそれがrefなのかcomputedなのか区別が付きにくい。hoverで型を見ればさすがに分かるがいちいち見ない。どうせ.valueでアクセスするのだからcomposableが返す値はいっそすべてcomputedであってreadonlyなものだと考えてしまった方が雑念を減らせる。
ということを踏まえるとcomposableはreactiveを外部に返さない方がいい。この変数には.valueを付けるべきなのか毎回考えるのが面倒。返すのであればtoRefしてから返す。reactiveの使いどころがどんどん減っていく。

setterは本当にsetするだけな処理でも用意しておくことが未来の幸せに繋がる。
今回はGUI的にundo&redo処理が必須ということもあり、そこらで勝手に値を変更されると非常に困るのでsetterを用意するで統一している。
別に最初からすべて用意しておく必要はない。Just In Timeで用意する。

この使い方が行きつくところは結局のところVuexライクなものになる。composableは見方によってはカジュアル目なVuex。Vuexのプラクティスは大体composableのプラクティスに当てはまる。

componentテスト

全く書いていない。けしからん。
作りたい気持ちが強すぎてVue3のcomponentテスト環境を調べたり整えるのが手間だった。反省。

言い訳としては作図的な要素が強いのでどうやってテストしたらええねん感が強かった。componentレベルで頑張るより、E2Eレベルで頑張った方がいいかもしれない。

お前が言うなを承知で書き残すと、componentのテストも最初に書かなければ以後書かれることはない。「シンプルな分岐を追加しただけだし大丈夫でしょ」が無限に積み重ねられていく。ただsnapshotを取るだけであってもテストファイルを作っておくことがとても重要。

そして軽視されがちな気もするがsnapshotは手軽にカバレッジを稼げるうえにリグレッションテストとして非常に優秀。なにせ最終的なレンダリング結果に変化があるかが分かるのだから。この性質は依存ライブラリをアップデートする際に非常に頼りになる。
今ではなく、未来のテストのためにsnapshotは積極的に残す。

「UIなんてどうせすぐ変わるから」はテストを省略する理由としては弱い。その積み重ねが待つ先はフルリプレイス。そのうちフルリプレイスをする気があるのなら省略してもよい。

とはいえcomponentのテストじっくり書いていくのは実際面倒くさい。とても面倒くさい。分かる。とても分かる。可能な限り関数として切り出せていて、そちらでテストできているなら時には妥協だって許される。

E2E

肝心のE2Eはというと全く書いてない。作りたい気持ちが強すぎる。思い描いたものをとにかく速く動く形にしたかった。本当に申し訳ない。

E2EレベルになるとそもそもVue3などのフレームワークに関心を持つ必要はない。ということで省略。

パフォーマンス

遅いのはお前のコード。遅いのは俺たちのコード。

今回は作りたいものの姿が始めからある程度イメージできていたので、パフォーマンス云々よりとにかく早く、そのイメージを触って動かせる形にしたかった。イメージを体現できていないコードのパフォーマンスを気にしたところで無意味。

とはいえそこそこ重いだろう処理をしながら60fpsを維持するにはパフォーマンスを無視し続けるわけにもいかない。やらなければいけないみたいに書いているがパフォーマンス改善は別に嫌な作業ではない。むしろ後にとっておいたイチゴみたいに楽しい作業だと考える人の方が多いのでは(ゲーム業界は違うかもしれない)。

Vue3(に限定せず大抵のフレームワーク)は開発のパフォーマンスを高めてくれる。コードのパフォーマンスを高めるのは開発者の役割。そして腕の見せどころ。

まとめ

細かな不安も探せば色々あるけどやりたいことは実現させてくれるし純粋に進化しているしVue3はよい。
そして作りたいものを作っているときは最高に楽しい。

Discussion