Vuejs vue-router コンポーネントインスタンスの再利用について

はじめに

vue-routerのリファレンスに以下の記述があるが、どこまで再利用されるか気になったので調べた。

ルートのパラメーターを使う際に特筆すべき点は、ユーザーが /user/foo から /user/bar へ遷移するときに同じコンポーネントインスタンスが再利用されるということです。 両方のルートが同じコンポーネントを描画するため、古いインスタンスを破棄して新しいものを生成するよりも効率的です。しかしながら、これはコンポーネントのライフサイクルフックが呼ばれないことを意味しています。

検証環境

package.json

  "dependencies": {
    "vue": "^2.5.2",
    "vue-router": "^3.0.1"
  }

検証

プロパティが変更された場合

まずはリファレンスに記述のある、コンポーネント内のプロパティの変更を試す。

ソースコード
import Vue from 'vue'
import Router from 'vue-router'
import Parent from '@/components/Parent'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/parents/:id',
      component: Parent,
      props: true
    }
  ]
})
<template>
</template>

<script>
export default {
  name: 'Parent',
  props: ['id'],
  watch: {
    id: function () {
      console.log('id changed ' + this.id)
    }
  },
  methods: {
  },
  created: function () {
    console.log('created parent')
  }
}
</script>
実行結果

当然、再利用される。

f:id:kimulla:20191201204925g:plain

いったん別のコンポーネントに切り替えた場合

ソースコード
import Vue from 'vue'
import Router from 'vue-router'
import Parent from '@/components/Parent'
import Other from '@/components/Other'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/parents/:id',
      component: Parent,
      props: true
    },
    {
      path: '/others/:id',
      component: Other,
      props: true
    }
  ]
})
<template>
</template>

<script>
export default {
  name: 'Parent',
  props: ['id'],
  watch: {
    id: function () {
      console.log('id changed ' + this.id)
    }
  },
  methods: {
  },
  created: function () {
    console.log('created parent')
  }
}
</script>
<template>
</template>

<script>
export default {
  name: 'Other',
  props: ['id'],
  watch: {
    id: function () {
      console.log('id changed ' + this.id)
    }
  },
  methods: {
  },
  created: function () {
    console.log('created other')
  }
}
</script>
実行結果

さすがに再利用されない。 (別画面に遷移した時点でdestroyedフックが呼ばれるので、当たり前といえば当たり前)

f:id:kimulla:20191201204941g:plain

別URLに同一コンポーネントを割り当てた場合

ソースコード
import Vue from 'vue'
import Router from 'vue-router'
import Parent from '@/components/Parent'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/parents-a/:id',
      component: Parent,
      props: true
    },
    {
      path: '/parents-b/:id',
      component: Parent,
      props: true
    }
  ]
})
実行結果

おおすごい!再利用される!

f:id:kimulla:20191201205125g:plain

ネストしたビューでプロパティが切り替わった場合

ソースコード
import Vue from 'vue'
import Router from 'vue-router'
import Parent from '@/components/Parent'
import Child from '@/components/Child'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/parents/:pid',
      component: Parent,
      props: true,
      children: [
        {
          path: 'children/:cid',
          component: Child,
          props: true
        }
      ]
    }
  ]
})
<template>
  <div>
    <router-view></router-view>
  </div>
</template>

<script>
export default {
  name: 'Parent',
  props: ['pid'],
  watch: {
    pid: function () {
      console.log('parent id changed ' + this.pid)
    }
  },
  methods: {
  },
  created: function () {
    console.log('created parent')
  }
}
</script>
<template>
</template>

<script>
export default {
  name: 'Child',
  props: ['cid'],
  watch: {
    cid: function () {
      console.log('child id changed ' + this.cid)
    }
  },
  methods: {
  },
  created: function () {
    console.log('created child')
  }
}
</script>
実行結果

親のプロパティが変わろうと子のプロパティが変わろうと再利用される。

f:id:kimulla:20191201205206g:plain

ネストしたビューで親コンポーネントが切り替わった場合

ソースコード
import Vue from 'vue'
import Router from 'vue-router'
import Parent from '@/components/Parent'
import Other from '@/components/Other'
import Child from '@/components/Child'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/parents/:pid',
      component: Parent,
      props: true,
      children: [
        {
          path: 'children/:cid',
          component: Child,
          props: true
        }
      ]
    },
    {
      path: '/others/:oid',
      component: Other,
      props: true,
      children: [
        {
          path: 'children/:cid',
          component: Child,
          props: true
        }
      ]
    }
  ]
})
実行結果

再利用されない。 router-viewコンポーネント自体が親コンポーネントに紐づく要素なので、親コンポーネントのライフサイクルに左右されるんでしょうね。

f:id:kimulla:20191201205235g:plain

インスタンス再利用に関する、ありがちな不具合

エラーメッセージ出しっぱなし

<template>
  <div>
    <button type="button" @click="fire">save</button>
    {{msg}}
  </div>
</template>

<script>
export default {
  name: 'Parent',
  props: ['pid'],
  data () {
    return {
      msg: null
    }
  },
  watch: {
    pid: function () {
      console.log('parent id changed ' + this.pid)
    }
  },
  methods: {
    fire: function () {
      this.msg = 'failure'
    }
  },
  created: function () {
    console.log('created parent')
  }
}
</script>

msgが初期化されずfailureが出っぱなし。

f:id:kimulla:20191201205315g:plain

created()で初期化するはずのデータが古いまま

<template>
  <div>
    {{name}}
  </div>
</template>

<script>
export default {
  name: 'Parent',
  props: ['pid'],
  data () {
    return {
      names: ['alice', 'bob', 'charie'],
      name: null
    }
  },
  watch: {
    pid: function () {
      console.log('parent id changed ' + this.pid)
    }
  },
  created: function () {
    console.log('created parent')
    this.name = this.names[this.pid]
  }
}
</script>

プロパティを変更しただけだとcreated() が呼ばれないため、nameが変わらない。

f:id:kimulla:20191201205343g:plain

解決方法

データの生成元をwatchする
  watch: {
    pid: function () {
      console.log('parent id changed ' + this.pid)
      this.name = this.names[this.pid]
    }
  }

f:id:kimulla:20191201205416g:plain

:keyを指定してコンポーネントの再利用をやめる
<template>
  <div id="app">
    <router-view :key="$route.fullPath"/>
  </div>
</template

f:id:kimulla:20191201205446g:plain

この方法はパフォーマンス下がるだろうし、あんまり推奨はされないと思う。

個人的には画面の初期化処理をcreatedにまとめられたほうが直感的な気はするんですが…
参考: vue-router issues

結論

ということでバグを生まないためには、以下に注意する。

  • コンポーネントを表示するときの大元のデータをwatchする
  • 初期化処理(propsの初期化含む)はmethodsに切り出す
  • watchから、上記の初期化処理を呼ぶ
  • createdから、上記の初期化処理を呼ぶ

また、watchのimmediateオプションを利用すれば、watchやcreatedの処理を一元化できる様子。
参考 watchのimmediateオプション