Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

Vue3に書き直してみる

Vue2からVue3へ書き直す際に情報が不足していて困ったのでまとめておきます。

公式ドキュメント

まだversion3の公式ドキュメントは揃っていません。マージされたPRRFCを見るしかない状況です。

migrate from v2 to v3

※「これだけ押さえれば7割くらいは移行できるだろう」くらいで網羅的には書けておりません

  1. new Vueによるインスタンス生成はcreateApp()へと置き換え
  2. setupメソッドの引数の型推論をしたい場合はSFCのexportするオブジェクトをdefineComponentでラップ
  3. new Vueのdataやmethodsはinject()app.provide()を使ってグローバルステートで置き換え React Context APIのようなもので、書き方もだいたい同じ。
  4. watchやdata、computed、mountedなどほとんどのプロパティは消えてsetupへ集約
  5. vue-template-compilerの代わりに@vue/compiler-sfcを使う
  6. shims-vue.d.ts(SFCの型定義ファイル)の書き方が変わる
  7. render関数の引数として渡されていたh関数はグローバルインポート

インストール

バージョンは2020年6月19日現在のものです。

  • vue@3.0.0-beta.15

SFCで書きたい、webpackを使いたいのであれば以下も必要です。

  • vue-loader@16.0.0
  • @vue/compiler-sfc@3.0.0-beta.15
  • webpack, webpack-cli

書き方

大きく異なる点がいくつかあります

  • composition APIが使える
  • webpackのalias設定がいらなくなる
  • new Vueが消える
  • Vueオブジェクトに生えていたAPIがnamed exportsオンリーになる
  • TypeScriptとの親和性が高くなる

composition APIが使える

まずこれがかなり大きいです。このお陰でVueへもTypeScriptを導入しやすくなりました。this関連でひどい目に合わずに済むのは大変嬉しいです。

また、書き方もかなり楽になりました。ロジックを外へ抽出できるようになった点も嬉しいです。ReactのHooksとできることはほぼ同じですが、かなりVueらしいやり方に収まった感覚があり、とても好きです。

webpackのalias設定がいらなくなる

new Vueの代わりにcreateAppというメソッドを使ってマウントするようになりました。そのため、vue/dist/vue.esm.jsへエイリアスを張らなくて良くなります。逆に、Vue2の頃のaliasがwebpack.config.jsに残っているとビルドが失敗します。

createApp

こいつが結構やっかいです。従来のやりかたと所々異なっています。

rfcs/0009-global-api-change.md at 9f18645a700f54e7d9a4e3b53046d48791e31459 · vuejs/rfcs · GitHub

上記リンク先を見ていただけばわかりますが、ほとんどは同じです。しかし、たとえば下のようなケースでは若干変わってきます。

// v2
new Vue({
  render: (h) =>
    h(MyComponent, {
      props: { prop1, prop2 },
    })
})
// v3
const app = createApp(MyComponent, { prop1, prop2 });

僕はかなり泥沼にハマりました。

defineComponentについて

setupのpropで型推論を働かせたいときにはdefineComponentでラップする必要があります。逆に、setupでpropを参照しないのならdefineComponentで囲む必要はなく、従来のSFCの書き方で十分です。

公式にも書いてありますが、defineComponent自体は受け取ったオブジェクトをただ返すだけの関数で、型推論以外では1ミリも役に立ちません。

Note that implementation-wise defineComponent does nothing - it simply returns the object passed to it. However, in terms of typing, the returned value has a synthetic type of a constructor for manual render function, TSX and IDE tooling support. This mismatch is an intentional trade-off.

JSXについて

renderのなかでJSXを使えるようになりました(今まではh関数を使ったcreateElement的な書き方しかできなかった)。これでvueファイルの中の<template>を消してJSオンリーでかけるようになりました。

Global APIのnamed exports化

tree-shakingのための変更です。Vue以下に生えていたVue.nextTickなどはnextTick関数としてexportされるようになります。Vue.nextTickは使えなくなります。このように、Vue3には後方互換性のない変更があるので注意が必要です。

SFCの型定義が変わる

new Vueによるインスタンス生成から変わった影響で、SFCの型定義もdefineComponentを使うものへ変わりました。

declare module "*.vue" {
  import { defineComponent } from "vue";
  const component: ReturnType<typeof defineComponent>;
  export default component;
}

従来の型定義はcreateAppの引数として使えないので注意してください。

参考: vue-next(Vue.js 3.0 wip)+ TypeScript + webpackでの開発環境を構築する - Qiita

TypeScriptで配列からnullを取り除く

結論

const a = [1, 2, 3, null];
// nullとundefinedをはじく
const filtered = a.filter(<T>(n: T): n is NonNullable<T> => n != null);
// nullだけはじく
const filtered2 = a.filter(<T>(n: T): n is Exclude<T, null> => n !== null);

nullを除いた配列の型

nullを許容する配列があったとします。この配列にfilterをかけてnullをはじくコードは以下のようになります。

const a = [1, 2, 3, null];
const filtered = a.filter(n => n != null);

しかし、これだけではfilteredの型は(number | null)[]になります。filteredの型をnumber[]型にするにはasかisを使います。

const filtered = a.filter(n => n != null) as number[];
const filtered = a.filter((n): n is number => n != null);

これだけでもある程度は十分です。しかし、オブジェクトの配列であった場合、これだけでは不十分な場合があります。

type Obj = {
  hoge: string;
  fuga: number;
};
type ExObj = Obj & { piyo: boolean };
const objs: (Obj | ExObj | null)[] = [
  { hoge: "hoge", fuga: 3 },
  { hoge: "hoge", fuga: 3, piyo: true },
  null,
];
const filtered = objs.filter((n): n is Obj => n != null); // Obj[]型になって情報が失われる!

というわけで、もっときちんと型をつけましょう。

コード解説

const a = [1, 2, 3, null];
const filtered = a.filter(<T>(n: T): n is Exclude<T, null> => n != null);

ExcludeはUnion型Tを受け取り、TからUを除いたUnion型を返します。たとえばExclude<string | number | null, null>string | numberです。NonNullable<t>はTからnullとundefinedを除いた型を返します。

Exclude、NonNullableを使っているため、コードの意味もnullを除いたUnion型の手書きよりハッキリしています。

参考

参考というか、このページをもとに改良したのがこの記事です。

lower_bound、upper_boundで個数を数える

条件 コード
xより大きい v.end() - upper_bound(v.begin(), v.end(), x)
x以上 v.end() - lower_bound(v.begin(), v.end(), x)
xである upper_bound(v.begin(), v.end(), x) - lower_bound(v.begin(), v.end(), x)
x以下 upper_bound(v.begin(), v.end(), x) - v.begin()
x未満 lower_bound(v.begin(), v.end(), x) - v.begin()

書いておいてなんですが、「xより小さい」個数と「ちょうどxである」個数が求まれば、引き算と足し算で残りは求められるので表にするほどではないですね。

{}は簡易Symbolにできる

Symbolの使い方として、たとえば独自の特殊値を持ちたいと言うものがあります。

// myFind: 配列と関数を受け取り、
// 関数を満たす要素が見つかればその要素、
// 見つからなければnullを返す
const myFind = (arr, f) => {
    for (let i = 0; i < arr.length; i++) {
        if (f[arr[i]])
            return arr[i];
    }
    return null; // 見つからなかった
};
const res = myFind(arr, n => n % 2 === 0);
// ではnullを探したいときはどうする?
myFind(arr, n => n === null); // nullが見つかったのかどうかわからない

このようなケースではSymbolが有効です。たとえばNOT_FOUND = Symbol('not found')として、見つからなければこれを返すようにすれば先程の関数は完璧になります。

const NOT_FOUND = Symbol('not found');
const myFind2 = (arr, f) => {
    for (let i = 0; i < arr.length; i++) {
        if (f[arr[i]])
            return arr[i];
    }
    return NOT_FOUND;
};
const res = myFind2([1, 2, 3], n => n === null);
assert(res === NOT_FOUND);

実はこのとき、Symbolの代わりに単にNOT_FOUND = {}としても同等のことができます。なぜなら{} !== {}だからです。

もちろん細かいところは全然違っているので完全には代用できませんが、ES5でも使えるテクなので覚えておいて損はないと思います。

const NOT_FOUND = {};
const myFind3 = (arr, f) => {
    for (let i = 0; i < arr.length; i++) {
        if (f[arr[i]])
            return arr[i];
    }
    return NOT_FOUND;
};
const res = myFind3([1, 2, 3], n => n === null);
assert(res !== {});
assert(res === NOT_FOUND);

ちなみに

実は先程のNOT_FOUNDを使う手法は厳密にはまだ不十分です。なぜなら、NOT_FOUNDを探したくなったときにnullのときと同じ問題が起こるからです。

厳格にするならばmyFindの返り値は以下のようになります。

myFind(arr, n => n % 2 === 0);
// 見つかった場合 {status: 'found', value: found_value}
// 見つからなかった場合 {status: 'not found'}

とはいうものの、実際には利便性と厳密さをトレードオフしてNOT_FOUNDで実装することも多いです。

ちなみにHaskellではこういうときにMaybeが使われます。とても便利です。JavaScriptにもパターンマッチがあればMaybeでガリガリと書けるのですが…

差分リスト(Difference List)について

Difference Listとは?

その名の通り、差分(difference)リストです。差分リストはListのappendのパフォーマンスが結合に依存する問題を解消します。

Listのappendは結合順によって計算量が異なる

appendは結合順を変えても意味としては同じです。しかし、cons listではappendの結合順によりパフォーマンスがかなり異なります。

たとえば以下のコードのlist1とlist2では、圧倒的にlist2のほうが早いです。

list1 = foldl (++) [] $ replicate 10000 $ replicate 10 0
list2 = foldr (++) [] $ replicate 10000 $ replicate 10 0

main = do
  print $ take 1000 list1 -- 遅い
  print $ take 1000 list2 -- 早い

これは両者の結合順が異なることに由来しています。

  • list1は左結合(([0,0,0] ++ [0,0,0]) ++ [0,0,0]) ++ [0,0,0]
  • list2は右結合[0,0,0] ++ ([0,0,0] ++ ([0,0,0] ++ [0,0,0]))

Cons listはappendする際に左のリストの中身をひとつずつ右へ移します。そのため、左辺を伸ばす結合をしているlist1はかなり遅くなります。

計算量を見てみます。list1はappendするたびに左辺の長さが10ずつ増え、10000回結合しています。そのため、計算量は5*(10000)*(10000+1)になっています。結合回数をNとするとO(N2)かかります。

対して、list2は左辺のリストの長さは固定で10です。そのため、10000回結合しても10*(10000)しかかかりません。結合回数をNとすればO(N)しかかかりません。

このように appendするときは原則右結合で書く必要があります。しかし、appendしたいだけなのに結合を気にするのはひどく辛いです。また、左結合が避けられないケースもあります。

このappendの結合性の問題を解消したのが差分リストです。

差分リストは右結合を左結合へ変換する

差分リストは関数合成を用いることで結合を変換します。よくわからないと思うので、コードを見てください。

list3 = ((([0] ++ [0]) ++ [0]) ++ [0]) ++ [0]
diff_list = (((([0] ++) . ([0] ++)) . ([0] ++)) . ([0] ++)) . ([0] ++) $ []

こんな感じに、「[0] ++」という関数を合成していくことでappendを表現しています。この関数に[]を適用すると通常のリストへ変換できます。

見やすさのために補助関数や型を導入します。

type DiffList a = [a] -> [a]

-- abs: 受け取ったDiffListをListへ変換する
abs :: DiffList a -> [a]
abs l = l []

-- rep: 受け取ったListをDiffListへ変換する
rep :: [a] -> DiffList a
rep = (++)

-- ++の代わりに+++を用いて結合する
(+++) :: DiffList a -> DiffList a -> DiffList a
(+++) = (.)

これで結合性が解消できます。テストしてみます。

main = do
  print $ take 10000 $ Main.abs $ foldl (+++) (rep []) $ replicate 10000 $ rep $ replicate 10 0
  print $ take 10000 $ Main.abs $ foldr (+++) (rep []) $ replicate 10000 $ rep $ replicate 10 0

どちらもかなり高速で動作します!

差分リストのデメリット

ただ、差分リストにもデメリットはあります。それが変換をしなければならないという点です。例として、差分リストを用いてキューを実装することを考えます。

頻繁な変更に向かない

type DiffList a = [a] -> [a]
type Queue a = DiffList a

abs :: DiffList a -> [a]
abs l = l []

rep :: [a] -> DiffList a
rep = (++)

(+++) :: DiffList a -> DiffList a -> DiffList a
(+++) = (.)

push x l = l +++ rep [x]
pop = rep . tail . Main.abs

main = do
    print $ Main.abs (pop $ pop $ push 3 $ push 2 $ push 1 (rep []))

popを見るとわかりますが、1回popするたびにrepとabsが呼ばれてDiffList -> List -> DiffListという変換が行われています。この変換のうち、DiffList -> Listの部分が差分リストの長さぶんだけコストがかかってしまいます(diff_list ++ []を実行しているため)。1回popするたびに長さぶんコストがかかってしまうのはつらいので、差分リストをリストへ変換したくなりますよね?しかし、一度リストへ変換してしまうと、今度はpushができなくなります。そのため、pushしたくなったらまた差分リストへ変換する必要があります。

(まあそもそもqueueの実装に使わなければ良いだけの話ですが)

まとめると、pushしたあとにpopと、差分リストの長さだけコストがかかってしまいます。しかし、キューを使いたいとき、pushする箇所とpopする箇所がまとまっていることのほうが少ないです。困りましたね…

差分リストはheadに多大なコストがかかる

他にもあります。それが、差分リストはheadするのが容易でないという点です。差分リストの実態はただの関数です。そのため、差分リストの先頭を見るには、一度リストへ変換する必要があります。この変換には差分リストの長さ分コストがかかります。通常のリストへのheadが定数時間でできることを考えるとかなり大きなデメリットです。

さらに言うと、「差分リストが空かどうか」の判定でさえ一度普通のリストへ変換しなければできません。驚きですね。

Type Aligned Sequenceなら解決できる

と、そこで登場するのがType Aligned Sequenceです。詳しくは論文を読んでください。