SIer だけど技術やりたいブログ

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>
実行結果

当然、再利用される。

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

ソースコード
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フックが呼ばれるので、当たり前といえば当たり前)

別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
    }
  ]
})
実行結果

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

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

ソースコード
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>
実行結果

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

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

ソースコード
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コンポーネント自体が親コンポーネントに紐づく要素なので、親コンポーネントのライフサイクルに左右されるんでしょうね。

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

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

<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が出っぱなし。

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が変わらない。

解決方法

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

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

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

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

結論

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

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

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