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

5年目のSIerのブログです

Vuejs URL切り替え時のコンポーネントライフサイクルについて

vue-routerのリファレンスに以下の記述がある。

動的ルートマッチング · vue-router

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

どこまで再利用されるか気になったので、調べた。

調べたバージョン

  "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:20171223234544g: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>
実行結果

さすがに再利用されない。
f:id:kimulla:20171223234732g: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:20171223234924g: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:20171223235046g: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:20171223235120g: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:20171224001332g: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:20171224002216g:plain

解決方法

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

f:id:kimulla:20171224002523g:plain

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

f:id:kimulla:20171224002706g:plain

この方法はパフォーマンス下がるだろうし、あんまり推奨はされなそう…
個人的には画面の初期化処理をcreatedにまとめられたほうが直感的な気はするけど。

Allow for Full Component Rerender in router-view · Issue #1350 · vuejs/vue-router · GitHub

ということで以下の流れが良さげ。

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