カテゴリー
コンピューター

Vue.js 3 で Select2 をラップして SFC (シングルファイルコンポーネント) としてコンポーネント化する例

やりたいこと

  • Vue.js 3 で Select2 をラップしたい

Vue.js 2 では、 ラッパーコンポーネント — Vue.js にて実際に Select2 のラッパーを作った例が提示されていました。

今回、 Vue.js 2 ではなく、 Vue.js 3 でこれを使いたい、というのが目的です。

なお、本投稿は Vue.js 3 でラップしてコンポーネント化した Dropzone.js の SFC (シングルファイルコンポーネント) を作り、利用する例を作った – oki2a24 の続きとなります。

進め方

  1. Vue.js 3 で Vue.js 2 の ラッパーコンポーネント — Vue.js の例をそのままやってみる。
  2. Options API での構成を Composition API へ書き直す。

Vue.js 3 で Vue.js 2 の ラッパーコンポーネント — Vue.js の例をそのままやってみる。

メイン以外の部分、つまり必要パッケージのインストール、 Select2 スタイルの読み込み、ルーターの設定

npm install --save-dev select2
$ git diff laravel/package.json
diff --git a/laravel/package.json b/laravel/package.json
index 5b2de6c..4c9bc77 100644
--- a/laravel/package.json
+++ b/laravel/package.json
@@ -29,6 +29,7 @@
         "resolve-url-loader": "^3.1.2",
         "sass": "^1.32.11",
         "sass-loader": "^8.0.0",
+        "select2": "^4.1.0-rc.0",
         "vue": "^3.0.11",
         "vue-loader": "^16.2.0",
         "vue-router": "^4.0.6",
$

また、Laravel 6 で Vue.js 3 を使っている環境なため、 jQuery は最初からインストールされていました。

スタイルシートを、 Sass ファイルへ記述して読み込みました。 Select2 の CSS ファイルのありかは Installation | Select2 – The jQuery replacement for select boxes を見て調べました。

$ git diff laravel/resources/sass/app.scss
diff --git a/laravel/resources/sass/app.scss b/laravel/resources/sass/app.scss
index 612c1c9..198f32a 100644
--- a/laravel/resources/sass/app.scss
+++ b/laravel/resources/sass/app.scss
@@ -9,3 +9,6 @@

 // Dropzone.js
 @import '~dropzone/dist/dropzone.css';
+
+// Select2
+@import "~select2/dist/css/select2.min.css";
$

Vue Router ファイルに、 Select2 のラッパー SFC を利用するコンポーネントへのルーティングを記述しました。

$ git diff laravel/resources/js/router/index.js
diff --git a/laravel/resources/js/router/index.js b/laravel/resources/js/router/index.js
index 4a263ed..eacc035 100644
--- a/laravel/resources/js/router/index.js
+++ b/laravel/resources/js/router/index.js
@@ -1,6 +1,7 @@
 import { createRouter, createWebHistory } from "vue-router";
 import ExampleComponent from "../components/ExampleComponent.vue";
 import SampleDropzone from "../views/SampleDropzone.vue";
+import SampleSelect2 from "../views/SampleSelect2.vue";
 import Vue3Dropzone from "../views/Vue3Dropzone.vue";
 const Home = { template: "<div>Home</div>" };
 const About = { template: "<div>About</div>" };
@@ -21,6 +22,11 @@ const routes = [
     name: "SampleDropzone",
     component: SampleDropzone,
   },
+  {
+    path: "/sample_select2",
+    name: "SampleSelect2",
+    component: SampleSelect2,
+  },
   {
     path: "/example_component",
     name: "ExampleComponent",
$

メインの部分、つまり Vue.js 3 だが Options API でラップした Select2 のラッパー SFC と、それを利用するコンポーネント

ラッパーコンポーネント — Vue.js のコードを本格的に移植していく箇所となります。次のファイル構成としました。

  • Vue.js 3 だが Options API でラップした Select2 のラッパー SFC: laravel/resources/js/components/BaseSelect2.vue
  • Select2 のラッパー SFC を利用するコンポーネント: laravel/resources/js/views/SampleSelect2.vue

まず、laravel/resources/js/components/BaseSelect2.vue です。次の点が難しかったり、ポイントと思ったりした箇所です。

  • 依存パッケージの読み込み方がわからなかった。次のページで解決できた。
  • 依存パッケージの読み込み方は次のようにして実現できた。
    • jQuery を読み込むためには、 import $ from "jquery"; とした。
    • Select2 を読み込むためには、 import "select2"; とした。
  • Vue.js 3 は v-model 時に使用する変数が value ではなく modelValue となり、 emit する時のイベント名が input ではなく update:modelValue となったため、該当する部分を修正した。
    • v-model | Vue.js
    • ちなみに vm.$emit("update:modelValue", this.value);valuemodelValue ではダメ。この this.value<select> に属している選択した選択肢の value であり、 this.$el = this と思われる。
  • watch は無くとも動いた。ただし、複雑なケースもケアするためにはおそらくある方がよい。
<template>
  <select>
    <slot></slot>
  </select>
</template>

<script>
import "select2";
import $ from "jquery";

export default {
  name: "BaseSelect2",
  props: ["options", "modelValue"],
  watch: {
    modelValue: function (modelValue) {
      // update modelValue
      $(this.$el).val(modelValue).trigger("change");
    },
    options: function (options) {
      // update options
      $(this.$el).empty().select2({ data: options });
    },
  },
  mounted: function () {
    var vm = this;
    $(this.$el)
      // init select2
      .select2({ data: this.options })
      .val(this.modelValue)
      .trigger("change")
      // emit event on change.
      .on("change", function () {
        vm.$emit("update:modelValue", this.value);
      });
  },
  unmounted: function () {
    $(this.$el).off().select2("destroy");
  },
};
</script>

続いて laravel/resources/js/views/SampleSelect2.vue です。先ほどの SFC を呼び出しています。この時の引数として、セレクトボックスの選択肢を渡しています。また、 v-model で初期値を渡すのと、セレクトボックスで選んだ値を反映するようになっているのと、というようになっています。

<template>
  <div class="container">
    <h1>Select2 ラッパー SFC の利用</h1>
    <p>Selected: {{ selected }}</p>
    <base-select-2 v-model="selected" :options="options">
      <option disabled value="0">Select one</option>
    </base-select-2>
  </div>
</template>

<script>
import { ref } from "vue";
import BaseSelect2 from "../components/BaseSelect2.vue";

export default {
  name: "SampleSelect2",
  components: { BaseSelect2 },
  setup() {
    const selected = ref(2);
    const options = [
      { id: 1, text: "Hello" },
      { id: 2, text: "World" },
    ];

    return {
      options,
      selected,
    };
  },
};
</script>

Options API での構成を Composition API へ書き直す。

laravel/resources/js/components/BaseSelect2.vue を Options API から Composition API へと書き直しました。

$(this.$el) の部分はどうしたらいいの?

そもそも this.$el にあまり馴染みはなかったですけれども、書き直す前にログ出力してみたところ、 <template> 部分に書いた <select> がその内容でした。ドキュメントを見ても vm.$el – API — Vue.js

Vue インスタンスが管理している ルートな DOM 要素です。

とあり、 SFC では <template> の内側、と理解できました。

では、 Composition API での DOM 要素を取得する方法です。 Vue.js の Composition API における this.$refs の取得方法 – Qiita のページが役に立ちました、ありがとうございます。

次のように書くことで、 <select> の DOM 要素を取得することができました。

  • <template><select>ref="root" を追加する。
  • setup()const root = ref(null); を追加する。 onMounted のタイミングで ref=root の DOM 要素が root 変数の value に代入される。
  • root.value で DOM 要素にアクセスする。
<template>
  <select ref="root">
    <slot></slot>
  </select>
</template>

... 略 ...

<script>
import { ref, onMounted } from "vue";

... 略 ...

  setup(props) {
    const root = ref(null);

... 略 ...

    onMounted(() => {
      //$(this.$el)
      $(root.value)
        // init select2
        .select2({ data: props.options })
        .val(props.modelValue)

... 略 ...

    return { root };

... 略 ...

なお、公式ページは テンプレート参照 | Vue.js となります。

watch の移植時に遭遇したエラー

次のエラーに遭遇しました。

[Vue warn]: Invalid watch source:  2 A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types. 
  at <BaseSelect2 modelValue="2" onUpdate:modelValue=fn options= (2) [{…}, {…}] > 
  at <SampleSelect2 onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< undefined > > 
  at <RouterView> 
  at <App>

エラーのでたコードです。

    watch(
      props.modelValue,
      (modelValue, oldModelValue) => {
        // update modelValue
        console.log("setup watch modelValue", modelValue, oldModelValue);
        //$(root.value).val(modelValue).trigger("change");
      }
    );

次のようにして解決しました。第一引数には値ではなく関数を渡すようにしました。公式ドキュメントに

ウォッチャのデータソースは、 値を返す gettter 関数か、そのまま ref を渡すかになります

とありますけれども、まさに関数を返すように変更した、という修正となります。

props はリアクティブ だが、 refreactive オブジェクトでは無いため、 値を返す gettter 関数にする必要がありました。

    watch(
      () => props.modelValue,
      (modelValue, oldModelValue) => {
        // update modelValue
        console.log("setup watch modelValue", modelValue, oldModelValue);
        //$(root.value).val(modelValue).trigger("change");
      }
    );

Vue Composition API の watch & watchEffect についてまとめてみた – Qiita がヒントになりました。ありがとうございます。

完成したコード

修正したのは laravel/resources/js/components/BaseSelect2.vue のみです。全体的に書きかわりましたので、差分ではなくファイル全体を掲載します。

<template>
  <select ref="root">
    <slot></slot>
  </select>
</template>

<script>
import "select2";
import $ from "jquery";
import { ref, onMounted, onUnmounted, watch } from "vue";

export default {
  name: "BaseSelect2",
  props: ["options", "modelValue"],
  emits: ["update:modelValue"],
  setup(props, { emit }) {
    const root = ref(null);

    watch(
      () => props.modelValue,
      (modelValue, oldModelValue) => {
        // update modelValue
        $(root.value).val(modelValue).trigger("change");
      }
    );
    watch(
      () => props.options,
      (options, oldOptions) => {
        // update options
        $(root.value).empty().select2({ data: options });
      }
    );

    onMounted(() => {
      $(root.value)
        // init select2
        .select2({ data: props.options })
        .val(props.modelValue)
        .trigger("change")
        // emit event on change.
        .on("change", function () {
          emit("update:modelValue", this.value);
        });
    });

    onUnmounted(() => {
      console.log("onUnmounted");
      $(root.value).off().select2("destroy");
    });

    return { root };
  },
};
</script>

これで Options API から Composition API へと無事書き換えて移植することができました!

おまけ、さらに Select2 ラッパー SFC をきれいにする。

  • ESLint 時の警告を無くす。
  • アロー関数に統一する。

ESLint 時の警告を無くそうとしたら新しいエラーが発生した。

ESLint 時の警告です。

/var/www/html/laravel/resources/js/components/BaseSelect2.vue
  14:11  warning  Prop "options" should define at least its type     vue/require-prop-types
  14:22  warning  Prop "modelValue" should define at least its type  vue/require-prop-types

これをなくそうと、 props を次のように書き換えました。

  props: {
    options: {
      type: Array,
      default: () => {
        [];
      },
    },
    modelValue: {
      type: Number,
      default: null,
    },
  },

すると、次の警告が実行時に発生しました。 emit する際に、値を Number ではなく String で返すために、 propsmodelValue へも String で渡ってしまい、型が合わない、という内容でした。

app.js:5693 [Vue warn]: Invalid prop: type check failed for prop "modelValue". Expected Number with value 1, got String with value "1". 
  at <BaseSelect2 modelValue="1" onUpdate:modelValue=fn options= (2) [{…}, {…}] > 
  at <SampleSelect2 onVnodeUnmounted=fn<onVnodeUnmounted> ref=Ref< Proxy {…} > > 
  at <RouterView> 
  at <App>

そもそも、どのようにするのが良いのでしょうか? Select2 のドキュメント The Select2 data format | Select2 – The jQuery replacement for select boxes を見てみます。

Select2 will attempt to convert anything that is not a string to a string, which will work for most situations, but it is recommended to explicitly convert your ids to strings ahead of time.

とありますので、 id は文字列の方が良いようです。

ですので modelValuetypeString へ変更し、 Select2 ラッパー SFC へ渡す id に相当する部分も数値から文字列へと変更しました。

アロー関数に統一する。

        .on("change", function () {
          emit("update:modelValue", this.value);
        });

上を下へと修正してみました。

        .on("change", () => {
          emit("update:modelValue", this.value);
        });

すると、実行時にエラーとなりました。

app.js:16748 Uncaught TypeError: Cannot read property 'value' of undefined
    at HTMLSelectElement.<anonymous> (app.js:16748)
    at HTMLSelectElement.dispatch (app.js:38110)
    at HTMLSelectElement.elemData.handle (app.js:37914)
    at Object.trigger (app.js:41399)
    at HTMLSelectElement.<anonymous> (app.js:41477)
    at Function.each (app.js:33065)
    at jQuery.fn.init.each (app.js:32887)
    at jQuery.fn.init.trigger (app.js:41476)
    at ArrayAdapter.SelectAdapter.select (app.js:67694)
    at ArrayAdapter.select (app.js:67978)

アロー関数へと変更したために、その内部で this が使えなくなってしまいました。 アロー関数式 – JavaScript | MDN の "アロー関数は自身の this を持ちません。" や 【JavaScript】アロー関数式を学ぶついでにthisも復習する話 – Qiita などで解説されています。

ではどうすれば良いでしょうか? this を使わないで書き直すことができればよく、調べたところ、 event から変更後の値を取れば大丈夫でした。つまり次のコードとなります。

        .on("change", (event) => {
          emit("update:modelValue", event.target.value);
        });

完成コード

laravel/resources/js/views/SampleSelect2.vue です。 id を文字列にしたのみで、差分を掲載します。

$ git diff laravel/resources/js/views/SampleSelect2.vue
diff --git a/laravel/resources/js/views/SampleSelect2.vue b/laravel/resources/js/views/SampleSelect2.vue
index 140f36b..3ac78a7 100644
--- a/laravel/resources/js/views/SampleSelect2.vue
+++ b/laravel/resources/js/views/SampleSelect2.vue
@@ -16,10 +16,10 @@ export default {
   name: "SampleSelect2",
   components: { BaseSelect2 },
   setup() {
-    const selected = ref(2);
+    const selected = ref("2");
     const options = [
-      { id: 1, text: "Hello" },
-      { id: 2, text: "World" },
+      { id: "1", text: "Hello" },
+      { id: "2", text: "World" },
     ];

     return {
$

続いて、 laravel/resources/js/components/BaseSelect2.vue です。こちらは全て掲載します。

<template>
  <select ref="root">
    <slot></slot>
  </select>
</template>

<script>
import "select2";
import $ from "jquery";
import { ref, onMounted, onUnmounted, watch } from "vue";

export default {
  name: "BaseSelect2",
  props: {
    options: {
      type: Array,
      default: () => {
        [];
      },
    },
    modelValue: {
      type: String,
      default: null,
    },
  },
  emits: ["update:modelValue"],
  setup(props, { emit }) {
    const root = ref(null);

    watch(
      () => props.modelValue,
      (modelValue) => {
        // update modelValue
        $(root.value).val(modelValue).trigger("change");
      }
    );
    watch(
      () => props.options,
      (options) => {
        // update options
        $(root.value).empty().select2({ data: options });
      }
    );

    onMounted(() => {
      $(root.value)
        // init select2
        .select2({ data: props.options })
        .val(props.modelValue)
        .trigger("change")
        // emit event on change.
        .on("change", (event) => {
          emit("update:modelValue", event.target.value);
        });
    });

    onUnmounted(() => {
      $(root.value).off().select2("destroy");
    });

    return { root };
  },
};
</script>

おわりに

今回の Vue.js 3 での Select2 ラッパーシングルファイルコンポーネントをベースに、開発 PJ ごとに拡張すれば他のライブラリの影響を小さくしたまま絞り込めるセレクトボックスを使えるはずです。

以上です。

コメントを残す