Difference

Vue.js v2とv3の違い

ここまでVue.js v2で学習してきた内容で変更された点と、Vue.js v3で新たに追加された機能のまとめ。公式はこちら。インストールについては今回もCDN版を使用する。

https://unpkg.com/vue@3/dist/vue.global.js

公式ではv3の開発環境ツールとして新たにViteも紹介されていてる。

変更点

新しいインスタンスの書き方

Vue 3ではcreateApp関数でインスタンスを生成する。これをアプリケーションインスタンスと呼び、描画エリアに紐付け(マウント)するにはmountメソッドを使って引数にHTML要素またはcssセレクタの文字列を指定する。

変数に代入するパターン(多くの場面で推奨される)

  • app に Vue アプリケーションインスタンスが代入されます。
  • 後からプラグインを使ったり、グローバル設定をするのに便利。
const app = Vue.createApp({
  // options
})
app.mount('#App')

その場でチェーンして完結させるパターン(シンプルな構成向け)

  • 一行で完結。
  • 小規模なアプリやチュートリアルなどに向いている。
  • 後から .use() や .component() で拡張したい場合には不向き。
Vue.createApp({
  // options
}).mount('#App')

省略形の書き方(分割代入を使う)

const { createApp, ref } = Vue

const app = createApp({
  // options
})

app.mount('#app')

const { createApp, ref } = Vue でVueオブジェクトから必要な関数だけ取り出して短く書く方法。

CDN版以外でVue.js本体を使用する場合

import { createApp } from 'vue'

上記で、モジュールとしてvueを読み込んでから、

const app = createApp({
  // options
})
app.mount('#App')

または、

createApp({
  // options
}).mount('#App')

dataオプション

Vue 2でルートインスタンスにおいてはオブジェクト(object)、コンポーネントでは関数(function)で定義していたdataオプションは、Vue 3からはオブジェクトを返す(return)関数でのみ定義するようになった。ただし後述するComposition API を使う方向に移行しているため、主流ではない。

const app = Vue.createApp({
  data() {
    return {
      message: 'Vue 3'
    }
  }
})
app.mount('#App')
---------------------------
// テンプレート
<p class="u-fontB">Hello {{ message }} !</p>

Hello {{ message }}

グローバルコンポーネント

グローバルコンポーネントはcomponentメソッドで定義する。

const app = Vue.createApp({})
app.component('global-component', {
  template: `
    <p class="u-fontB">{{ message }}</p>
    <p><strong>グローバルコンポーネントの使い方</strong></p>
  `,
  data: function () {
    return {
      message: 'Hello Vue!'
    }
  }
})
app.mount('#App')
---------------------------
// テンプレート
<div id="App">
  <global-component></global-component>
</div>

インスタンスを変数に代入しない場合は以下のようにメソッドチェーンで繋げていく。
どちらの書き方が良いのか、または主流なのかは現時点では不明。

Vue.createApp({
  // ...
}).component('global-component', {
  // ...
}).mount('#App')

templateオプション

v2ではローカル、グローバルどちらのコンポーネントでもtemplateは単一のタグで終了しなければならなかったが、v3からは上記の例のように単一のタグで囲わなくても複数の要素が使用できる。

カスタムコンポーネントでのv-model

コンポーネントで使用するv-modelのvalue属性に紐づけられるプロパティはmodelValueと変わり、$emitする(変更を通知する)イベントはupdate:modelValueがデフォルト設定なった。
また追加ルールとして、すべてのカスタムイベントはemitsオプションに定義することが推奨されている。

Vue.createApp({
  data() {
    return {
      testText:'初期値'
    }
  }
}).component('custom-input', {
  template: `<p>
    <input v-bind:value="modelValue" v-on:input="$emit('update:modelValue', $event.target.value)">
    子側:{{modelValue}}
    </p>
  `,
  props: {'modelValue': [String, Number]},
  emits: ['update:modelValue'] ←emitsオプション
}).mount('#App')
---------------------------
// テンプレート
<p>親側:{{testText}}</p>
<custom-input v-model="testText"></custom-input>
↓ 同義 ↓
<custom-input v-bind:model-value="testText" v-on:update:model-value="testText = $event"></custom-input>

親側:{{testText}}

v-modelのオプションの廃止

使用するフォーム要素や状況に応じてデフォルトの設定を変えたいときはmodelオプションの代わりに、v-modelの引数(コロンで繋げた値)を渡してイベントやプロパティの名前を変更する。

Vue.createApp({
  data() {
    return {
      testCheckbox: false
    }
  }
}).component('custom-input-checkbox', {
  template: `
    <label><input type="checkbox" v-bind:checked="checked" v-on:change="$emit('update:checked', $event.target.checked)">{{checked}}</label>
  `,
  props: { checked: Boolean },
  emits: ['update:checked']
}).mount('#App')
---------------------------
// テンプレート
<custom-input-checkbox v-model:checked="testCheckbox"></custom-input-checkbox>

v-modelの複数使用

Vue 3 では、複数の v-model を1つのカスタムコンポーネントに対して使うことができるようになった。引数(後ろにコロンで繋げた値)が使えるようになったことでそれぞれ別の値を個別にバインドできる。これにより.sync修飾子は廃止になった。

Vue.createApp({
  data() {
    return {
      login: { email: '', password: '' }
    }
  }
}).component('login-component', {
  template: `
    <div class="u-mb1">
      <label>メール<input type="email" :value="email" @input="$emit('update:email', $event.target.value)"></label>
      <label>パスワード<input type="password" :value="password" @input="$emit('update:password', $event.target.value)" size="10"></label>
      子側props【{{email}} : {{password}}】
    </div>
  `,
  props: { email: String, password: String },
  emits: ['update:email', 'update:password']
}).mount('#App')
---------------------------
// テンプレート
<login-component v-model:email="login.email" v-model:password="login.password"></login-component>
<p>親側dataオプション: {{login}}</p>

親側dataオプション: {{login}}

修飾子の処理

カスタムコンポーネントで 「.trim」 や 「.number」 を使用する場合、その処理を modelModifiersとしてpropsで受け取り、手動で変換処理をする必要がある。

一方、「.lazy」は、「同期タイミングの制御」に関する修飾子で、前者の「値の変換」に関する修飾子とは性質が異なるためmodelModifiersには含まれない。「.lazy」の機能を使うためには子コンポーネント側で、手動でchangeイベントを使って emit しなければならない。

// テンプレート
<div id="App">
  <smart-input v-model.trim.lazy="name" label="名前: ">
  <p>名前:{{ name }}</p>
  <smart-input v-model.number="age" label="年齢: ">
  <p>年齢:{{ age }}(型:{{ typeof age }})</p>
</div>
// カスタムコンポーネント
const SmartInput = {
  template: `
    <label>
      {{ label }}
      <input type="text" :value="modelValue" @input="onInput" @change="onChange" />
    </label>
  `,
  props: {
    modelValue: {
      type: [String, Number],
      required: true
    },
    modelModifiers: {
      type: Object,
      default: () => ({})
    },
    label: {
      type: String,
      default: ''
    }
  },
  emits: ['update:modelValue'],
  methods: {
    // 通常入力用(.lazy がない場合)
    onInput(event) {
      if (this.modelModifiers.lazy) return  // .lazy のときは無視
      const value = this.processValue(event.target.value)
      this.$emit('update:modelValue', value)
    },
    // .lazy のときに使う(change イベント)
    onChange(event) {
      if (!this.modelModifiers.lazy) return  // .lazy でないときは無視
      const value = this.processValue(event.target.value)
      this.$emit('update:modelValue', value)
    },
    // .trim, .number の処理共通化
    processValue(rawValue) {
      let value = rawValue
      // 修飾子があるか確認して変換
      if (this.modelModifiers.trim) {
        value = value.trim()
      }
      if (this.modelModifiers.number) {
        value = Number(value)
      }
      return value
    }
  }
}
const app = Vue.createApp({
  components: {
    CustomInput
  },
  setup() {
    const name = Vue.ref('')
    const age = Vue.ref(null)

    return {
      name,
      age
    }
  }
})
app.mount('#App')

名前:{{ name }}

年齢:{{ age }}(型:{{ typeof age }})

スロット

機能的な変更はなし。ただし親コンポーネント側のスロット指定方法(名前付きスロットやスコープ付きスロットの書き方)が変わっている。具体的にはslot属性は廃止され、代わりにv-slot:#(省略記法)を使う。

// 子コンポーネント
Vue.component('name-slot-component', {
  template: `
    <div class="nameSlotBox">
      <p class="noname"><slot>名前なしスロット</slot></p>
      <slot name="slot2">名前ありスロットひとつめ</slot>
      <slot name="slot3">名前ありスロットふたつめ</slot>
      <slot name="slot4"></slot>
      <p>ここは子側のテンプレートに元々記述されているところ。</p>
    </div>
  `,
})
---------------------------
// 親テンプレート
<name-slot-component>
  <template #slot3>
    <p class="bgC02">こちらはスロット名「slot3」に埋め込まれます。</p>
  </template>
  <template #slot2>
    <p class="bgC01">スロット名「slot2」に差し込まれます。</p>
  </template>
  <template #default>
  このテキストが優先して名前なしスロットに入ります。
  </template>
  templateタグの範囲外のこのテキストは名前なしスロットに入ります。ただし#defaultがある場合は、無視されます。
</name-slot-component>
templateタグの範囲外のこのテキストは名前なしスロットに入ります。ただしdefaultの指定がある場合は、無視されます。

v3注目の新機能

v3で新規追加された最低限抑えておくべき機能。

Composition API

Composition APIとは大規模なコンポーネントの開発に適した仕組みのこと。v2ではdata、methods、computedなどをオプションごとにロジックを切り分けて記述しコンポーネントを定義するOptions APIという仕組みが使われていた。
v3ではオプションを定義する代わりに、すべてsetupメソッド内の処理として各種関数を使って定義。それをオブジェクト形式で返すことでコンポーネントのテンプレート内でオブジェクトのプロパティにアクセスすることができる。

Vue.createApp({
  setup() {
    // リアクティブデータの定義
    const counter = Vue.ref(0);
    const watchCounter = Vue.reactive({
      newValue: null,
      oldValue: null,
    });

    // 関数の定義
    const increment = function () {
      counter.value ++;
    };
    const decrement = function () {
      counter.value --;
    };

    // 算出プロパティの定義(computedメソッド)
    const doubleCounter = Vue.computed(function() {
      return counter.value * 2;
    });

    // 変化の監視(watchメソッド)
    Vue.watch(counter, function (newValue, oldValue) {
      watchCounter.newValue = newValue;
      watchCounter.oldValue = oldValue;
    });

    // ライフサイクルフック
    Vue.onMounted(function () {
      console.log(`コンポーネントがマウントされました。`);
    })
    Vue.onUpdated(function () {
      console.log(`リアクティブデータが変更されました!`);
    })

    // 定義したものをオブジェクトで返す(プロパティの短縮構文)
    return {
      counter,
      watchCounter,
      increment,
      decrement,
      doubleCounter
    }
  }
}).mount('#App')
---------------------------
// テンプレート
<button type="button" v-on:click="increment">カウントアップ</button> <button type="button" v-on:click="decrement">カウントダウン</button>
<p>カウント[counter = {{counter}}]</p>
<p>カウント2倍[doubleCounter = {{doubleCounter}}]</p>
<p>カウント監視{{watchCounter}}</p>
 

カウント[counter = {{counter}}]

カウント2倍[doubleCounter = {{doubleCounter}}]

カウント監視{{watchCounter}}

リアクティブデータの定義

リアクティブデータの定義に使用するrefreactiveの使い分けは、データが再代入されないオブジェクトであればreactiveを使用して、それ以外はrefを使うようにすればよさそう。またrefで定義した値はsetup内でアクセスする際に「.value」を付ける必要がある(テンプレート内では不要)。

比較項目 ref([]) reactive([])
使い方 変数名に.value でアクセス 変数名でアクセス
再代入 できる できない
v-for との相性 良い 場合によっては更新が効かないことがある
Vueが監視する仕組み .valueの中身をトラッキング 配列自体を Proxy 化してトラッキング
どこで使う? 配列・プリミティブ オブジェクト(フォームなどの複数プロパティ)

変化の監視

Vue 2ではリアクティブデータの変化を監視するための機能としてwatchプロパティを使用したが、Vue 3ではwatch()もしくはwatchEffect()を使う。後者は、監視する値の指定が不要で引数で新しい値と古い値を受け取る必要もない。関数内でリアクティブデータを使うと値の変化が起こるたびに再実行される。
使い分けとしては基本的にはwatch()でよい。必要な時があればwatchEffect()を使うくらいの温度感。

Vue.createApp({
  setup() {
    const counter = Vue.ref(0);
    watchEffect(() => {
      console.log('counterの値が変わりました:', counter.value)
    })
  }
}).mount('#App')

ライフサイクルフック

ライフサイクルフックも各種関数として使用することができるが、Composition APIのライフサイクルフック名は、従来の名称の前にonが付いている(mounted→onMounted)。
例外として、destroyed→onUnmounted、beforeDestroy→onBeforeUnmountなど名前が変更されているものもある。またbeforeCreate / created はsetup()の中では使えない(setup() が created 相当)。

親子間でのデータの受け渡し

子がデータを受け取るために受け皿(propsオプション)を用意して、親から送られるプロパティを定義するのは従来と同じ。
Composition APIではsetupメソッドの第一引数でpropsを受け取ることでsetup内で「props.プロパティ名」のように記述して使うことができるようになる。

子から親へデータを送るための仕組み(イベントの発火をemitで送る)も基本的には変わらないが、setup()関数内でイベントを発火するには、setupの第2引数として渡されるcontextオブジェクトを使い、その中のemitプロパティを呼び出す

const ChildProduct = {
  template: `
    <li class="formUnit">
      <p>◎{{name}}(在庫{{stock}})</p>
      <button v-if="stock" v-on:click="clickBuy">買う</button>
      <span v-else>完売しました</span>
    </li>
  `,
  props: {
    name: String,
    stock: Number,
    index: Number
  },
  emits: ['buy'],
  setup(props, context) {
    const clickBuy = function() {
      context.emit('buy', props.index);
    }

    return { clickBuy }
  }
};

context は正しい書き方ではあるが、実際には 分割代入してよりシンプルなコードにすることが多い。

const ChildAddProduct = {
  template: `
  <div class="formUnit">
    <label>品名:<input type="text" v-bind:value="name" v-on:input="onInput"></label>
    <label>仕入数:<select v-bind:value="stock" v-on:change="onChange">
    <option> value="5">5</option>
    <option> value="10">10</option>
    <option> value="15">15</option>
    <option> value="20">20</option>
    </select></label>
    <button> v-on:click="addItem">リストに追加する</button>
  </div>
  `,
  props: {
    name: String,
    stock: Number,
    nameModifiers: Object,
    stockModifiers: Object
  },
  emits: ['update:name', 'update:stock', 'add'],
  setup(props, { emit }){
    // 商品の入力
    const onInput = function(event){
      let value = event.target.value;
      // 修飾子があれば変換
      if (props.nameModifiers?.trim) {
        value = value.trim();
      }
      emit('update:name', value);
    }

    // 仕入数の入力
    const onChange = function(event){
      let value = event.target.value;
      // 修飾子があれば変換
      if (props.stockModifiers?.number) {
        value = Number(value);
      }
      emit('update:stock', value);
    }

    const addItem = function(){
      emit('add');
    }
    return { onInput, onChange, addItem }
  }
}

v-model に引数を使った場合(例:v-model:hoge.trim)、修飾子はpropshogeModifiersという名前で受け取る。props で受け取ったfooModifiers に、指定した修飾子(例:trim)が含まれているかは「?.」を使って、条件分岐する(例:props.fooModifiers?.trim)

Vue.createApp({})
.component('parent-component', {
  components: {
    'child-product': ChildProduct,
    'child-add-product': ChildAddProduct
  },
  template: `
  <ul class="c-list u-mb1">
    <child-product v-for="(item,i) in product" v-bind:key="item.id" v-bind:index="i" v-bind:name="item.name" v-bind:stock="item.stock" v-on:buy="decreaseStock"></child-product>
  </ul>
  <child-add-product v-model:name.trim="newItemName" v-model:stock.number="newItemStock" v-on:add="addItem"></child-add-product>
  `,
  setup() {
    const newItemName = Vue.ref('');
    const newItemStock = Vue.ref(10);
    const product = Vue.ref([
      { id: 1, name: 'フルーツタルト', stock: 3, price: 410 },
      { id: 2, name: '極生塩パン', stock: 10, price: 180 },
      { id: 3, name: '草餅', stock: 1, price: 240 }
    ])

    // 買うボタン押下で在庫マイナス1
    const decreaseStock = function(i) {
      if (product.value[i].stock > 0) {
        product.value[i].stock -= 1;
      }
    }

    // 商品を追加する
    const addItem = function() {
      const max = product.value.reduce(function(a,b){
        return a.id > b.id ? a.id : b.id;
      },0)
      if(newItemName.value){
        product.value.push({
          id: max + 1,
          name: newItemName.value,
          stock: newItemStock.value
        });
        newItemName.value = '';
        newItemStock.value = 10;
      }
    }
    return { newItemName, newItemStock, product, decreaseStock, addItem }
  }
}).mount('#App');
---------------------------
// テンプレート
<ul>
  <parent-component></parent-component>
</ul>

setupの中の分割代入とは

setup() の第二引数に渡している { emit } は、contextオブジェクトから、emitだけを分割代入で抜き出している状態である。

context = {
  attrs: {...},
  slots: {...},
  emit: function,
  expose: function
}
const { emit } = context;

Teleport

Teleportは、コンポーネントの一部(teleportタグで囲んだ内側)をそのコンポーネントのDOM階層の外側に存在する任意の場所へ移動(テレポート)させることができる。移動先をto属性でDOMノードかCSSセレクター文字列で指定すると、指定された要素内の末尾に<teleport>の中身が移動する。また<teleport>disabledプロパティを付与することで移動を無効化することも可能。

app.component('teleport-component', {
  template: `
    <button @click="showModal = !showModal">モーダルを開く</button>
    <Teleport to="#secTeleport">
      <div v-if="showModal" class="modalPanel" ref="modalPanelRef">
        <div class="modalPanel_inner">
          <p class="u-fontB u-mb1">モーダルコンテンツ</p>
          <p>teleportの移動先は<teleport>が...省略</p>
          <p><img src="/assets/images/task/difference/img_01.png" alt=""></p>
          <button @click="showModal = false" class="modalPanel_btn">Close</button>
        </div>
      </div>
    </Teleport>
    <Teleport to="body">
      <div v-if="showModal" class="modalOverlay" @click="showModal = false"></div>
    </Teleport>
  `,
  setup() {
    // リアクティブデータ
    const showModal = Vue.ref(false);
    const modalPanelRef = Vue.ref(null);
    const scrollY = Vue.ref(0);

    // 変数
    const largeClass = 'is-lgSize';
    const fixedClass = 'is-fixed';
    const scrollbarWidth = window.innerWidth - document.body.clientWidth;

    // モーダルがビューポートより大きい場合はクラスを付与
    function adjustSize(modalPanelRef) {
      let panelHeight = modalPanelRef.clientHeight,
          windowHeight = document.documentElement.clientHeight;

      if (panelHeight >= windowHeight) {
        modalPanelRef.classList.add(largeClass);
      }
    };

    // モーダルの開閉時にスタイルを変更
    function adjustScreen(fixed) {
      if (fixed) {
        document.body.style.paddingRight = `${scrollbarWidth}px`;
      } else {
        document.body.style = "";
      }
    };

    // showModalの監視
    Vue.watch(showModal, function (newValue) {
      if (newValue) {
        scrollY.value = window.pageYOffset;
        document.documentElement.style.top = `-${scrollY.value}px`;
        adjustScreen(true);
        document.getElementsByTagName('html')[0].classList.add(fixedClass);
      } else {
        document.documentElement.style.top = '';
        adjustScreen(false);
        document.getElementsByTagName('html')[0].classList.remove(fixedClass);

        if (scrollY.value) {
          window.scrollTo(0, scrollY.value);
        }
      }
    });

    // ライフサイクルフック
    Vue.onUpdated(function () {
      if (showModal.value) {
        const target = modalPanelRef.value;
        if (modalPanelRef.value) {
          adjustSize(target);
        }
      }
    });

    return {
      showModal,
      modalPanelRef,
      adjustSize,
      adjustScreen
    };
  }
})
---------------------------
// テンプレート
<teleport-component></teleport-component>

上記の場合、仮にwatch()watchEffect()に変更しても挙動は変わらない。

Vue.watchEffect(() => {
  const isOpen = open.value;
  if(isOpen) {
    scrollY.value = window.pageYOffset;
    document.documentElement.style.top = `-${scrollY.value}px`;
    adjustScreen(true);
    document.getElementsByTagName('html')[0].classList.add(fixedClass);
  } else {
    document.documentElement.style.top = '';
    adjustScreen(false);
    document.getElementsByTagName('html')[0].classList.remove(fixedClass);

    if (scrollY.value) {
      window.scrollTo(0, scrollY.value);
    }
  }
})

Suspense

Suspenseは、内部で非同期処理(特に非同期コンポーネントの読み込みやsetup()でのawait)が完了するまで、フォールバック(代替UI)を表示することができる。
suspense配下のtemplateタグに #fallbackを付与した内容が非同期が解決されるまで表示される代替UI(ローディングなど)となり、#defaultがメインの表示内容(通常のコンポーネント)になる。

app.component('async-component', {
  template: `<p>読み込み完了!</p>`,
  async setup() {
    // 疑似的に3秒待つ
    await new Promise(resolve => {
      setTimeout(() => {
        resolve()
      }, 3000)
    })
  }
})
---------------------------
<div id="App">
  <suspense>
    <template #default>
      <async-component></async-component>
    </template>
    <template #fallback>
      <p>読み込み中...</p>
    </template>
  </suspense>
</div>