dely tech blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

Vue 2で書かれた個人プロジェクトをVue 3に書き換えてみた

f:id:alluser:20201213191103p:plain

はじめに

こんにちは!
クラシルWebのフロントエンドを担当しているall-userです。

今回は、とあるプロジェクトをVue 2からVue 3に書き換えてみたので、その過程と所感についてまとめたいと思います。

この記事はdely #1 Advent Calendar 2020 14日目の記事です。

adventar.org

adventar.org

昨日はfunzinさんのCarthageで生成したframeworkの管理でRomeを導入してみたでした。
元々使用していたcarthage_cacheをRomeに置き換える過程が分かりやすく解説されています。ぜひこちらも覗いてみてください🙌

さて、今回題材に選んだプロジェクトは小規模なVue 2で書かれたアプリケーションですが、そのスタック構成はかなりクラシルWebに近いものとなっており、今後クラシルWebへの導入を検討する上での良い足がかりにできればと考えております。

目次

Vue 3について知る

なにはともあれ、書き換えるにあたりまずはVue 3のキャッチアップから始めなければなりません。
マイグレーションガイドを読んで解釈した内容を残しておきます。

Vue 3の目玉。Composition APIとは

Vue 3で導入された新しいコンポーネント実装のためのAPIです。
Reactのhooksにインスパイアされた機能で、コンセプトもとても似ています。

Composition APIで何が変わる?

これまでのViewModelインスタンスを起点にしたロジックの記述(this.xxvm.xxな記述)ではなく、関数の組み合わせによる記述が可能になります。

たとえばコンポーネントのローカルステートを定義する時、Vue 2(Options API)ではdataを使いました。

// DisplayCount.vue
import { defineComponent } from "vue";

export default defineComponent({ // Vue.extendは廃止されたためdefineComponentを使用します
  data() {
    return {
      count: 0
    };
  },
  methods: {
    increment() {
      this.count += 1;
    },
    decrement() {
      this.count -= 1;
    }
  }
});

テンプレート部分は以下のようになります。

<template>
  <span v-text="count" /> <!-- Vue 3ではFragmentがサポートされルート要素が単一の要素である必要がなくなりました -->
  <button @click="increment">+</button>
  <button @click="decrement">-</button>
</template>

レンダリング結果はこんな感じ。

f:id:alluser:20201213141331p:plain

Vue 3(Composition API)ではref関数を使います。

// DisplayCount.vue
import { defineComponent, ref } from "vue";

export default defineComponent({
  setup() {
    const count = ref(0);
    const increment = () => (count.value += 1); // thisが消えた
    const decrement = () => (count.value -= 1); // thisが消えた

    return {
      count,
      increment,
      decrement
    };
  }});

テンプレートの内容は同じです。
setup関数ではcountincrementが生えたオブジェクトを返していますが、これらのプロパティをテンプレート内で参照できるようになります。

ref関数が返すRefオブジェクトにはvalueというプロパティが生えており、このプロパティを通じて現在の値を取得することができます。
このように値をRefオブジェクトでラップすることで、ローカルステートの読み書きを捕捉できるようになり、リアクティブにDOMの更新を行えるようになります。

従来はこのラッパーの役割をViewModelインスタンス(this)が担っていました。

そして、increment, decrementからthisが消えています。
これは、countというローカルステートおよび+1、-1するメソッドの定義が、特定のコンポーネントに属さなくなったと考えることができます。
そのため、以下のように書き換えることができます。

// useCount.ts
import { ref } from "vue";

export const useCount = () => {
  const count = ref(0);
  const increment = () => (count.value += 1);
  const decrement = () => (count.value -= 1);

  return {
    count,
    increment,
    decrement
  };
};

// DisplayCount.vue
import { defineComponent } from "vue";
import { useCount } from "../hooks/useCount";

export default defineComponent({
  setup() {
    return {
      ...useCount()
    };
  }
});

「countというローカルステートを持ち、+1、-1することができる」機能をuseCountという関数に切り出し、さらにuseCount.tsという別ファイルに切り出すことができました。
次に、これが出来るようになることで、どんなうれしいことがあるのかを考えてみます。

Options API(従来のコンポーネント定義)の課題

ViewModelを起点にした記述では、data, computed, methodsなどの制約により、関心事の異なるロジック同士が一箇所に束ねられ、逆に関心事を同じくするコードが分散してしまうという問題がありました。

次のコードは先ほどのサンプルに、displayというローカルステートとtoggleDisplayTextという算術プロパティを加えたものです。

関心事を次の2つとし、

  1. 数をカウントしたい
  2. 表示を切り替えたい

コード上の対応する部分にコメントを入れると次のようになります。

// DisplayCount.vue
import { defineComponent } from "vue";

export default defineComponent({
  data() {
    return {
      count: 0, // a. 数をカウントしたい
      display: true // b. 表示を切り替えたい
    };
  },
  computed: {
    toggleDisplayText(): string { // b. 表示を切り替えたい
      return this.display ? "hide" : "show";
    }
  },
  methods: {
    increment() { // a. 数をカウントしたい
      this.count += 1;
    },
    decrement() { // a. 数をカウントしたい
      this.count -= 1;
    },
    toggleDisplay() { // b. 表示を切り替えたい
      this.display = !this.display;
    }
  }
});

アプリーケーションが大きくなりコンポーネントが複雑化すると、このように分散した関心事を頭の中でマッピングしながら読み解いていくコストが大きくなってきます。

テンプレートも更新します。

<template>
  <template v-if="display">
    <span v-text="count" />
    <button @click="increment">+</button>
    <button @click="decrement">-</button>
  </template>
  <button @click="toggleDisplay" v-text="toggleDisplayText" />
</template>

レンダリング結果はこんな感じ。

f:id:alluser:20201213141512p:plain

hideをクリックすると以下のようになります。

f:id:alluser:20201213141633p:plain

次にこれをComposition APIに置き換えてみます。

Composition APIによる関心事の分離

Composition APIではロジックの記述がViewModelに依存しなくなり、data, computed, methodsなどの制約から開放されます。

computed関数が登場しましたが、コンセプトはrefの時と同じです。
ViewModelへの参照を無くしたバージョンの算術プロパティと考えればOKです。

  1. 数をカウントしたい
  2. 表示を切り替えたい

コード上の対応する部分にコメントを入れると次のようになります。

// DisplayCount.vue
import { defineComponent } from "vue";

export default defineComponent({
  setup() {
    const count = ref(0); // a. 数をカウントしたい
    const increment = () => (count.value += 1); // a. 数をカウントしたい
    const decrement = () => (count.value -= 1); // a. 数をカウントしたい

    const display = ref(true); // b. 表示を切り替えたい
    const toggleDisplay = () => (display.value = !display.value); // b. 表示を切り替えたい
    const toggleDisplayText = computed(() => (display.value ? "hide" : "show")); // b. 表示を切り替えたい

    return {
      count, // a. 数をカウントしたい
      increment, // a. 数をカウントしたい
      decrement, // a. 数をカウントしたい
      display, // b. 表示を切り替えたい
      toggleDisplay, // b. 表示を切り替えたい
      toggleDisplayText // b. 表示を切り替えたい
    };
  }
});

関心事ベースでコードをまとめることができていることが分かります。

最初のサンプル同様に、setup関数の外にロジックを切り出し、useCount.tsuseToggleDisplay.tsという別ファイルに切り出してみます。

// useCount.ts
// a. 数をカウントしたい
import { ref } from "vue";

export const useCount = () => {
  const count = ref(0);
  const increment = () => (count.value += 1);
  const decrement = () => (count.value -= 1);

  return {
    count,
    increment,
    decrement
  };
};

// useToggleDisplay.ts
// b. 表示を切り替えたい
import { computed, ref } from "vue";

export const useToggleDisplay = () => {
  const display = ref(true);
  const toggleDisplay = () => (display.value = !display.value);
  const toggleDisplayText = computed(() => (display.value ? "hide" : "show"));

  return {
    display,
    toggleDisplay,
    toggleDisplayText
  };
};

// DisplayCount.vue
import { defineComponent } from "vue";
import { useCount } from "../hooks/useCount"; // a. 数をカウントしたい
import { useToggleDisplay } from "../hooks/useToggleDisplay"; // b. 表示を切り替えたい

export default defineComponent({
  setup() {
    return {
      ...useCount(), // a. 数をカウントしたい
      ...useToggleDisplay() // b. 表示を切り替えたい
    };
  }
});

それぞれの関心事がDisplayCountコンポーネントから完全に分離されています。1
これは、これらのロジックが特定のコンポーネントに依存していないことを示しています。
ロジックが特定のコンポーネントに依存していないため、移植性・再利用性を高めることができます。

複数コンポーネント間でロジックを共通化しようとして、extendやmixinsを使って無茶をしたことがあるのは僕だけではないはずです。

Composition APIを使えば、より自然にロジックの共通化を表現できます。

TypeScriptサポートの改善

Composition APIによりTypeScriptの型定義もかなり改善しました。

従来のOptions APIの型定義は、thisdata, computed, methodsなどの定義を生やすためのコードがとても複雑で、型定義を見に行っては迷子になることもしょっちゅうでした。

ViewModel(this)への参照を無くし、関数の合成によってロジックを表現出来るようになったことで無理なく型を表現できているため、TypeScriptのコードが理解しやすくなりました。

個人プロジェクトについて

今回書き換えるのはrxjs-stream-editorというRxJSの非同期処理を可視化するツールです。
Vue 2, Vuex, TypeScriptを使用しており、コンポーネントの数もルートコンポーネントを入れて8つ、Vuex moduleの数も3つと検証にはもってこいの大きさです。

クラス記法コンポーネント、vue-property-decoratorvuex-smart-moduleを使用しており、現時点ではVue 3未対応なライブラリなため、今回の検証ではいったん外しつつなるべくVueの素のAPIを使う方針でいきます。

memowomome.hatenablog.com

書き換え作業ログ

github.com

nodeとyarnを最新にアップデート

  • node 👉 15.3.0にアップデート
  • yarn 👉 1.22.10にアップデート

ビルド設定をVue CLIで一気に最新に書き換える

rxjs-stream-editorはVue CLIを使用したプロジェクトなので、今回もVue CLIを使用してアップデートします。
vue upgradeというコマンドも用意されていますが、規模も小さいので今回はvue createで上書く方法でやってみました🙌

# プロジェクトのひとつ上のディレクトリに移動
cd ..

# 同名のディレクトリを指定して上書き
# オプションでGitコミット無し、既存ファイルとマージするように指定
vue create -n --merge rxjs-stream-editor

スタックを選択

  • スタックをマニュアルで選択
  • TS, Vuex, Stylus, ESLint, Prettierを使用
  • Vue 3を使用
  • クラス記法のコンポーネント定義ではなく素のAPIを使用
  • TypeScriptと一緒にBabelを使用
  • 保存時にLintを実行
  • ESLint等のコンフィグファイルはpackage.jsonにまとめず、専用のファイルを使用
Vue CLI v4.5.9
? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, Babel, TS, Vuex, CSS Pre-processors, Linter
? Choose a version of Vue.js that you want to start the project with 3.x (Preview)
? Use class-style component syntax? No
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Stylus
? Pick a linter / formatter config: Prettier
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No

いったんビルドしてみる

エラーがたくさん出ます。
このまま一旦コミットしておきます。

エラーログ全文

ERROR in src/App.vue:21:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type 'VueClass<any>' is missing the following properties from type 'typeof App': extend, set, delete, directive, and 6 more.
    19 | import { ColorDefinition } from './core/ColorDefinition';
    20 |
  > 21 | @Component({
       | ^^^^^^^^^^^^
  > 22 |   components: {
       | ^^^^^^^^^^^^^^^
  > 23 |     AppHeader,
       | ^^^^^^^^^^^^^^^
  > 24 |     StreamEditor,
       | ^^^^^^^^^^^^^^^
  > 25 |     BottomNav,
       | ^^^^^^^^^^^^^^^
  > 26 |   },
       | ^^^^^^^^^^^^^^^
  > 27 | })
       | ^^^
    28 | export default class App extends Vue.extend({
    29 |   computed: {
    30 |     ...domainStreamColorizerModule.mapState(['colorMatcherSourceCode']),

ERROR in src/App.vue:21:2
TS2345: Argument of type 'typeof App' is not assignable to parameter of type 'VueClass<any>'.
  Type 'typeof App' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
    19 | import { ColorDefinition } from './core/ColorDefinition';
    20 |
  > 21 | @Component({
       |  ^^^^^^^^^^^
  > 22 |   components: {
       | ^^^^^^^^^^^^^^^
  > 23 |     AppHeader,
       | ^^^^^^^^^^^^^^^
  > 24 |     StreamEditor,
       | ^^^^^^^^^^^^^^^
  > 25 |     BottomNav,
       | ^^^^^^^^^^^^^^^
  > 26 |   },
       | ^^^^^^^^^^^^^^^
  > 27 | })
       | ^^^
    28 | export default class App extends Vue.extend({
    29 |   computed: {
    30 |     ...domainStreamColorizerModule.mapState(['colorMatcherSourceCode']),

ERROR in src/App.vue:28:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    26 |   },
    27 | })
  > 28 | export default class App extends Vue.extend({
       |                      ^^^
    29 |   computed: {
    30 |     ...domainStreamColorizerModule.mapState(['colorMatcherSourceCode']),
    31 |     ...domainStreamColorizerModule.mapGetters(['colorDefinitions']),

ERROR in src/components/AppHeader/AppHeader.ts:3:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type '<VC extends VueClass<any>>(target: VC) => VC' is missing the following properties from type 'typeof AppHeader': extend, nextTick, set, delete, and 9 more.
    1 | import { Component, Vue } from 'vue-property-decorator';
    2 |
  > 3 | @Component
      | ^^^^^^^^^^
    4 | export default class AppHeader extends Vue {}
    5 |

ERROR in src/components/AppHeader/AppHeader.ts:3:2
TS2769: No overload matches this call.
  Overload 1 of 2, '(options: ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}> & ThisType<any> & ThisType<any>): <VC extends VueClass<any>>(target: VC) => VC', gave the following error.
    Argument of type 'typeof AppHeader' is not assignable to parameter of type 'ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}> & ThisType<any> & ThisType<any>'.
      Type 'typeof AppHeader' is not assignable to type 'ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}>'.
        Types of property 'call' are incompatible.
          Type '<T, A extends any[]>(this: new (...args: A) => T, thisArg: T, ...args: A) => void' is not assignable to type '(this: unknown, ...args: unknown[]) => never'.
            The 'this' types of each signature are incompatible.
              Type 'unknown' is not assignable to type 'new (...args: unknown[]) => unknown'.
  Overload 2 of 2, '(target: VueClass<any>): VueClass<any>', gave the following error.
    Argument of type 'typeof AppHeader' is not assignable to parameter of type 'VueClass<any>'.
      Type 'typeof AppHeader' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
    1 | import { Component, Vue } from 'vue-property-decorator';
    2 |
  > 3 | @Component
      |  ^^^^^^^^^
    4 | export default class AppHeader extends Vue {}
    5 |

ERROR in src/components/AppHeader/AppHeader.ts:4:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    2 |
    3 | @Component
  > 4 | export default class AppHeader extends Vue {}
      |                      ^^^^^^^^^
    5 |

ERROR in src/components/BottomNav/BottomNav.ts:9:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type 'VueClass<any>' is missing the following properties from type 'typeof BottomNav': extend, set, delete, directive, and 6 more.
     7 | import StreamColorizer from '../StreamColorizer/StreamColorizer.vue';
     8 |
  >  9 | @Component({
       | ^^^^^^^^^^^^
  > 10 |   components: {
       | ^^^^^^^^^^^^^^^
  > 11 |     MessageOutput,
       | ^^^^^^^^^^^^^^^
  > 12 |     StreamColorizer,
       | ^^^^^^^^^^^^^^^
  > 13 |   },
       | ^^^^^^^^^^^^^^^
  > 14 | })
       | ^^^
    15 | export default class BottomNav extends Vue.extend({
    16 |   computed: {
    17 |     ...domainStreamEditorModule.mapState(['errorMessage', 'message']),

ERROR in src/components/BottomNav/BottomNav.ts:9:2
TS2345: Argument of type 'typeof BottomNav' is not assignable to parameter of type 'VueClass<any>'.
  Type 'typeof BottomNav' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
     7 | import StreamColorizer from '../StreamColorizer/StreamColorizer.vue';
     8 |
  >  9 | @Component({
       |  ^^^^^^^^^^^
  > 10 |   components: {
       | ^^^^^^^^^^^^^^^
  > 11 |     MessageOutput,
       | ^^^^^^^^^^^^^^^
  > 12 |     StreamColorizer,
       | ^^^^^^^^^^^^^^^
  > 13 |   },
       | ^^^^^^^^^^^^^^^
  > 14 | })
       | ^^^
    15 | export default class BottomNav extends Vue.extend({
    16 |   computed: {
    17 |     ...domainStreamEditorModule.mapState(['errorMessage', 'message']),

ERROR in src/components/BottomNav/BottomNav.ts:15:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    13 |   },
    14 | })
  > 15 | export default class BottomNav extends Vue.extend({
       |                      ^^^^^^^^^
    16 |   computed: {
    17 |     ...domainStreamEditorModule.mapState(['errorMessage', 'message']),
    18 |     ...uiBottomNavModule.mapState(['enabled']),

ERROR in src/components/MessageOutput/MessageOutput.ts:4:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type '<VC extends VueClass<any>>(target: VC) => VC' is missing the following properties from type 'typeof MessageOutput': extend, nextTick, set, delete, and 9 more.
    2 | import { domainStreamEditorModule } from '../../store/modules/internal';
    3 |
  > 4 | @Component
      | ^^^^^^^^^^
    5 | export default class MessageOutput extends Vue.extend({
    6 |   computed: {
    7 |     ...domainStreamEditorModule.mapState(['errorMessage', 'message']),

ERROR in src/components/MessageOutput/MessageOutput.ts:4:2
TS2769: No overload matches this call.
  Overload 1 of 2, '(options: ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}> & ThisType<any> & ThisType<any>): <VC extends VueClass<any>>(target: VC) => VC', gave the following error.
    Argument of type 'typeof MessageOutput' is not assignable to parameter of type 'ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}> & ThisType<any> & ThisType<any>'.
      Type 'typeof MessageOutput' is not assignable to type 'ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}>'.
        Types of property 'call' are incompatible.
          Type '<T, A extends any[]>(this: new (...args: A) => T, thisArg: T, ...args: A) => void' is not assignable to type '(this: unknown, ...args: unknown[]) => never'.
  Overload 2 of 2, '(target: VueClass<any>): VueClass<any>', gave the following error.
    Argument of type 'typeof MessageOutput' is not assignable to parameter of type 'VueClass<any>'.
      Type 'typeof MessageOutput' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
    2 | import { domainStreamEditorModule } from '../../store/modules/internal';
    3 |
  > 4 | @Component
      |  ^^^^^^^^^
    5 | export default class MessageOutput extends Vue.extend({
    6 |   computed: {
    7 |     ...domainStreamEditorModule.mapState(['errorMessage', 'message']),

ERROR in src/components/MessageOutput/MessageOutput.ts:5:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    3 |
    4 | @Component
  > 5 | export default class MessageOutput extends Vue.extend({
      |                      ^^^^^^^^^^^^^
    6 |   computed: {
    7 |     ...domainStreamEditorModule.mapState(['errorMessage', 'message']),
    8 |   },

ERROR in src/components/StreamColorizer/StreamColorizer.ts:2:10
TS2305: Module '"../../../node_modules/vue/dist/vue"' has no exported member 'VueConstructor'.
    1 | import { Component, Vue } from 'vue-property-decorator';
  > 2 | import { VueConstructor } from 'vue';
      |          ^^^^^^^^^^^^^^
    3 | import { domainStreamColorizerModule } from '../../store/modules/internal';
    4 | import { Photoshop } from 'vue-color';
    5 | import { ColorDefinition } from '../../core/ColorDefinition';

ERROR in src/components/StreamColorizer/StreamColorizer.ts:7:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type 'VueClass<any>' is missing the following properties from type 'typeof StreamColorizer': extend, set, delete, directive, and 6 more.
     5 | import { ColorDefinition } from '../../core/ColorDefinition';
     6 |
  >  7 | @Component({
       | ^^^^^^^^^^^^
  >  8 |   components: {
       | ^^^^^^^^^^^^^^^
  >  9 |     PhotoshopPicker: Photoshop as VueConstructor,
       | ^^^^^^^^^^^^^^^
  > 10 |   },
       | ^^^^^^^^^^^^^^^
  > 11 | })
       | ^^^
    12 | export default class StreamColorizer extends Vue.extend({
    13 |   computed: {
    14 |     ...domainStreamColorizerModule.mapState([

ERROR in src/components/StreamColorizer/StreamColorizer.ts:7:2
TS2345: Argument of type 'typeof StreamColorizer' is not assignable to parameter of type 'VueClass<any>'.
  Type 'typeof StreamColorizer' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
     5 | import { ColorDefinition } from '../../core/ColorDefinition';
     6 |
  >  7 | @Component({
       |  ^^^^^^^^^^^
  >  8 |   components: {
       | ^^^^^^^^^^^^^^^
  >  9 |     PhotoshopPicker: Photoshop as VueConstructor,
       | ^^^^^^^^^^^^^^^
  > 10 |   },
       | ^^^^^^^^^^^^^^^
  > 11 | })
       | ^^^
    12 | export default class StreamColorizer extends Vue.extend({
    13 |   computed: {
    14 |     ...domainStreamColorizerModule.mapState([

ERROR in src/components/StreamColorizer/StreamColorizer.ts:12:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    10 |   },
    11 | })
  > 12 | export default class StreamColorizer extends Vue.extend({
       |                      ^^^^^^^^^^^^^^^
    13 |   computed: {
    14 |     ...domainStreamColorizerModule.mapState([
    15 |       'colorMatcherSourceCode',

ERROR in src/components/StreamEditor/StreamEditor.ts:7:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type 'VueClass<any>' is missing the following properties from type 'typeof StreamEditor': extend, set, delete, directive, and 6 more.
     5 | import debounce from 'lodash-es/debounce';
     6 |
  >  7 | @Component({
       | ^^^^^^^^^^^^
  >  8 |   components: {
       | ^^^^^^^^^^^^^^^
  >  9 |     StreamEditorItem,
       | ^^^^^^^^^^^^^^^
  > 10 |   },
       | ^^^^^^^^^^^^^^^
  > 11 | })
       | ^^^
    12 | export default class StreamEditor extends Vue.extend({
    13 |   computed: {
    14 |     ...domainStreamEditorModule.mapGetters(['streamDatasets', 'sourceCode']),

ERROR in src/components/StreamEditor/StreamEditor.ts:7:2
TS2345: Argument of type 'typeof StreamEditor' is not assignable to parameter of type 'VueClass<any>'.
  Type 'typeof StreamEditor' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
     5 | import debounce from 'lodash-es/debounce';
     6 |
  >  7 | @Component({
       |  ^^^^^^^^^^^
  >  8 |   components: {
       | ^^^^^^^^^^^^^^^
  >  9 |     StreamEditorItem,
       | ^^^^^^^^^^^^^^^
  > 10 |   },
       | ^^^^^^^^^^^^^^^
  > 11 | })
       | ^^^
    12 | export default class StreamEditor extends Vue.extend({
    13 |   computed: {
    14 |     ...domainStreamEditorModule.mapGetters(['streamDatasets', 'sourceCode']),

ERROR in src/components/StreamEditor/StreamEditor.ts:12:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    10 |   },
    11 | })
  > 12 | export default class StreamEditor extends Vue.extend({
       |                      ^^^^^^^^^^^^
    13 |   computed: {
    14 |     ...domainStreamEditorModule.mapGetters(['streamDatasets', 'sourceCode']),
    15 |   },

ERROR in src/components/StreamEditor/StreamEditor.ts:25:10
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    23 | }) {
    24 |   @Watch('sourceCode')
  > 25 |   public watchSourceCode() {
       |          ^^^^^^^^^^^^^^^
    26 |     this.evaluateSourceCodeDebounced();
    27 |   }
    28 |

ERROR in src/components/StreamEditorItem/StreamEditorItem.ts:23:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type 'VueClass<any>' is missing the following properties from type 'typeof StreamEditorItem': extend, set, delete, directive, and 6 more.
    21 | };
    22 |
  > 23 | @Component({
       | ^^^^^^^^^^^^
  > 24 |   components: {
       | ^^^^^^^^^^^^^^^
  > 25 |     StreamEditorTextarea,
       | ^^^^^^^^^^^^^^^
  > 26 |   },
       | ^^^^^^^^^^^^^^^
  > 27 | })
       | ^^^
    28 | export default class StreamEditorItem extends Vue.extend({
    29 |   computed: {
    30 |     ...domainStreamColorizerModule.mapGetters([

ERROR in src/components/StreamEditorItem/StreamEditorItem.ts:23:2
TS2345: Argument of type 'typeof StreamEditorItem' is not assignable to parameter of type 'VueClass<any>'.
  Type 'typeof StreamEditorItem' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
    21 | };
    22 |
  > 23 | @Component({
       |  ^^^^^^^^^^^
  > 24 |   components: {
       | ^^^^^^^^^^^^^^^
  > 25 |     StreamEditorTextarea,
       | ^^^^^^^^^^^^^^^
  > 26 |   },
       | ^^^^^^^^^^^^^^^
  > 27 | })
       | ^^^
    28 | export default class StreamEditorItem extends Vue.extend({
    29 |   computed: {
    30 |     ...domainStreamColorizerModule.mapGetters([

ERROR in src/components/StreamEditorItem/StreamEditorItem.ts:28:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    26 |   },
    27 | })
  > 28 | export default class StreamEditorItem extends Vue.extend({
       |                      ^^^^^^^^^^^^^^^^
    29 |   computed: {
    30 |     ...domainStreamColorizerModule.mapGetters([
    31 |       'colorCodeGetter',

ERROR in src/components/StreamEditorItem/StreamEditorItem.ts:39:18
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    37 |   },
    38 | }) {
  > 39 |   @Prop() public dataset: StreamDataset | undefined;
       |                  ^^^^^^^
    40 |   @Prop({ required: true }) public index!: boolean;
    41 |   @Prop({ default: false }) public disabled!: boolean;
    42 |

ERROR in src/components/StreamEditorItem/StreamEditorItem.ts:40:36
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    38 | }) {
    39 |   @Prop() public dataset: StreamDataset | undefined;
  > 40 |   @Prop({ required: true }) public index!: boolean;
       |                                    ^^^^^
    41 |   @Prop({ default: false }) public disabled!: boolean;
    42 |
    43 |   get events() {

ERROR in src/components/StreamEditorItem/StreamEditorItem.ts:41:36
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    39 |   @Prop() public dataset: StreamDataset | undefined;
    40 |   @Prop({ required: true }) public index!: boolean;
  > 41 |   @Prop({ default: false }) public disabled!: boolean;
       |                                    ^^^^^^^^
    42 |
    43 |   get events() {
    44 |     return this.dataset ? this.dataset.events : [];

ERROR in src/components/StreamEditorTextarea/StreamEditorTextarea.ts:5:1
TS1238: Unable to resolve signature of class decorator when called as an expression.
  Type '<VC extends VueClass<any>>(target: VC) => VC' is missing the following properties from type 'typeof StreamEditorTextarea': extend, nextTick, set, delete, and 9 more.
    3 | import { domainStreamEditorModule } from '../../store/modules/internal';
    4 |
  > 5 | @Component
      | ^^^^^^^^^^
    6 | export default class StreamEditorTextarea extends Vue.extend({
    7 |   methods: {
    8 |     ...domainStreamEditorModule.mapMutations(['setSourceCode']),

ERROR in src/components/StreamEditorTextarea/StreamEditorTextarea.ts:5:2
TS2769: No overload matches this call.
  Overload 1 of 2, '(options: ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}> & ThisType<any> & ThisType<any>): <VC extends VueClass<any>>(target: VC) => VC', gave the following error.
    Argument of type 'typeof StreamEditorTextarea' is not assignable to parameter of type 'ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}> & ThisType<any> & ThisType<any>'.
      Type 'typeof StreamEditorTextarea' is not assignable to type 'ComponentOptionsBase<any, any, any, any, any, any, any, any, string, {}>'.
        Types of property 'call' are incompatible.
          Type '<T, A extends any[]>(this: new (...args: A) => T, thisArg: T, ...args: A) => void' is not assignable to type '(this: unknown, ...args: unknown[]) => never'.
  Overload 2 of 2, '(target: VueClass<any>): VueClass<any>', gave the following error.
    Argument of type 'typeof StreamEditorTextarea' is not assignable to parameter of type 'VueClass<any>'.
      Type 'typeof StreamEditorTextarea' is missing the following properties from type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")': useCssModule, useCssVars, createApp, createSSRApp, and 108 more.
    3 | import { domainStreamEditorModule } from '../../store/modules/internal';
    4 |
  > 5 | @Component
      |  ^^^^^^^^^
    6 | export default class StreamEditorTextarea extends Vue.extend({
    7 |   methods: {
    8 |     ...domainStreamEditorModule.mapMutations(['setSourceCode']),

ERROR in src/components/StreamEditorTextarea/StreamEditorTextarea.ts:6:22
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    4 |
    5 | @Component
  > 6 | export default class StreamEditorTextarea extends Vue.extend({
      |                      ^^^^^^^^^^^^^^^^^^^^
    7 |   methods: {
    8 |     ...domainStreamEditorModule.mapMutations(['setSourceCode']),
    9 |   },

ERROR in src/components/StreamEditorTextarea/StreamEditorTextarea.ts:11:18
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
     9 |   },
    10 | }) {
  > 11 |   @Prop() public dataset: StreamDataset | undefined;
       |                  ^^^^^^^
    12 |   @Prop({ default: false }) public disabled!: boolean;
    13 |
    14 |   get sourceCode() {

ERROR in src/components/StreamEditorTextarea/StreamEditorTextarea.ts:12:36
TS1219: Experimental support for decorators is a feature that is subject to change in a future release. Set the 'experimentalDecorators' option in your 'tsconfig' or 'jsconfig' to remove this warning.
    10 | }) {
    11 |   @Prop() public dataset: StreamDataset | undefined;
  > 12 |   @Prop({ default: false }) public disabled!: boolean;
       |                                    ^^^^^^^^
    13 |
    14 |   get sourceCode() {
    15 |     return this.dataset ? this.dataset.sourceCode : '';

ERROR in src/main.ts:6:5
TS2339: Property 'use' does not exist on type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")'.
    4 | import VueTextareaAutosize from 'vue-textarea-autosize';
    5 |
  > 6 | Vue.use(VueTextareaAutosize);
      |     ^^^
    7 | Vue.config.productionTip = false;
    8 |
    9 | new Vue({

ERROR in src/main.ts:7:5
TS2339: Property 'config' does not exist on type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")'.
     5 |
     6 | Vue.use(VueTextareaAutosize);
  >  7 | Vue.config.productionTip = false;
       |     ^^^^^^
     8 |
     9 | new Vue({
    10 |   store,

ERROR in src/main.ts:9:5
TS2351: This expression is not constructable.
  Type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")' has no construct signatures.
     7 | Vue.config.productionTip = false;
     8 |
  >  9 | new Vue({
       |     ^^^
    10 |   store,
    11 |   render: h => h(App),
    12 | }).$mount('#app');

ERROR in src/main.ts:11:11
TS7006: Parameter 'h' implicitly has an 'any' type.
     9 | new Vue({
    10 |   store,
  > 11 |   render: h => h(App),
       |           ^
    12 | }).$mount('#app');
    13 |

ERROR in src/store/index.ts:6:5
TS2339: Property 'use' does not exist on type 'typeof import("/Users/okamoto.k/_ghqroot/github.com/all-user/rxjs-stream-editor/node_modules/vue/dist/vue")'.
    4 | import { rootModule } from './modules';
    5 |
  > 6 | Vue.use(Vuex);
      |     ^^^
    7 |
    8 | export default createStore(rootModule);
    9 |

.babelrcを削除

Babel最新ではbabel.config.jsを使用するように変わったようなのですが、Vue CLIでディレクトリをマージした際に.babelrcが残っており、そちらの設定を見に行ってしまいエラーが出ていました。
これを削除します。

f:id:alluser:20201213165103p:plain

vue-property-decoratorを外す

デコレータを使ったコンポーネント定義をdefineComponentに置き換えていきます。

f:id:alluser:20201213164743p:plain

vuex-smart-moduleを外す

Vue 3対応のために素のVuexに書き換えます。

vuex-smart-moduleを通してstoreにアクセスしていた部分は、useStore関数を使用して置き換えます。
この時点でcommit, dispatch, gettersに付いていたメソッドの型は、stringであればなんでも受け入れるようになってしまいます。

やはりまだvuex-smart-moduleは外せないという所感。
Composition API対応に関するIssueでは対応予定とのコメントもあり今後の動きに期待です。

f:id:alluser:20201213165416p:plain

テンプレートから参照するものは従来通りmapStatemapGettersを使用し、コンポーネント定義内で参照するものはuseStoreを使用しています。
この辺りの書き方はどうするのが良いだろう、という感じでまだ模索中です。

その他のライブラリ

  • vue-textarea-autosize
    • テキストの入力に合わせて自動的に高さを調整してくれる
    • Vue 3未対応
    • とりあえず普通のtextareaに置き換えてしのぐ
  • vue-color
    • カラーピッカー
    • Vue 3未対応
    • いったんあきらめる

関心事を整理してhooksディレクトリに切り出す

setupにまとめて書かれていた初期化処理を3つのファイルに分割し、各関数内でそれぞれuseStoreを使用するように変更します。

f:id:alluser:20201213235518p:plain

カラーパレットの初期化処理。

f:id:alluser:20201213235804p:plain

カラーパレットとイベントをマッピングするためのソースコードの初期化処理。

f:id:alluser:20201213235824p:plain

可視化するRxJS Observableを生成するソースコードの初期化処理。

f:id:alluser:20201213235844p:plain

実際に書き換えてみての所感

コードの見通しはとても良くなる

関心事の分離がうまく表現できるようになり、コードの見通しが良くなりました。
規模の大きいプロジェクトであればより効果を発揮できるのではと感じました。

Vuexの使い方はまだ模索中

今回はテンプレートから参照する値を従来のOptions APIで記述しましたが、Composition APIに最適化されたヘルパーについての議論が行われていていました。
近いうちにベストプラクティスが発明されそうです。

github.com

VuexのTSサポートはこれから

Vuex 4でTypeScriptのサポートは強化されたものの、state以外の型周りはまだサポートされていません。
現時点ではvuex-smart-moduleなどのTypeScriptサポートのライブラリは必要だと感じました。

こちらのIssueを見ると、本格的なTSサポートの強化はVuex 5を予定しているようです。

github.com

また、それに伴うBreaking Changeを検討している模様。

一方で、TS 4.1というゲームチェンジャーの登場によりVuex 4もサポートされる可能性が出てきたようです。

github.com

TS 4.1で導入されたTemplate Literal Typesにより文字列の柔軟な型検査が可能になり、これまで難しかったnamespaceをスラッシュで繋いだ文字列に対しての静的な型検査が実装可能になりました。
近い将来TS完全対応が実現するかもしれません。

プロダクションに投入できるタイミング

上記を踏まえると今すぐのプロダクション投入は難しいものの、確実にメリットを感じたので、少しづつ移行に向けて準備を進めていきたいと思います。

まだ試せていないこと

  • ローカルステートを定義しているコンポーネントが無かったため、ref, reactiveを使った複雑な実装はまだ試せていないです
  • vue-routerも使用していないためこちらもまだ未検証です
  • 引き続き検証していきたいと思います

さいごに

以上Vue 3への書き換えを通しての所感をまとめてみました。
この記事では紹介できなかった細かいAPIの変更などもありますが、暗黙的な挙動の削除やパフォーマンス改善のための変更など確実にパワーアップしています。
個人的にはFragmentが使えるようになったのが最高です。
これからのVue 3を楽しんでいきましょう!

そして明日はsakoさんの「UIデザイナーとして働く私が就活生に戻ったら絶対やること5つ」です!
めちゃくちゃ知りたい!

delyではエンジニアを全方位絶賛募集中です🚀
こちらのリンクからお気軽にエントリーください🙌

join-us.dely.jp

また、delyでは普段表に出ない開発チームの裏側をお伝えするイベントをたくさん開催しております!
こちらもぜひ覗いてみてください!

bethesun.connpass.com

ではまた!


  1. toggleDisplayTextについてはUI都合な部分が大きいため、useToggleDisplayには含めずDisplayCountコンポーネント側に寄せたいところですが、今回は分かりやすさのためにこのようにしています。