【Vue.js】scrollBehavior関数でページ遷移時のスクロールを有効にする　非同期の場合(transitionあり)も

## はじめに
仕事で使う事になったので1からVue.jsについて学んだ。
ちゃんと覚えておかないとまずそうな事を備忘録として1つ1つ残しておく。

## scrollBehavior関数でページ遷移時のスクロールを有効にする
### そもそもVue.jsでは`event.preventDefault()`が効いておりデフォルトの動きをしない
以下のように`router-link`のtoに`hash(#)`が付いたURLを指定し、そのリンクをクリックしてブラウザでそのURLに遷移しても、`id=next-user`の要素に自動的にスクロールしない。
（URLをブラウザに直打ちした時は、ブラウザの機能で`id=next-user`の要素にスクロールするが。）
![ezgif.com-gif-maker (29).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/fb9ab05b-4fc0-b185-ece5-9e27803062a7.gif)

```vue:Users.vue
<template>
  <div>
    <!-- 省略 -->
    <router-link
      :to="'/users/' + (Number(id) + 1) + '/profile?lang=ja&page=2#next-user'"
    >
      次のユーザ
    </router-link>
    <div style="height: 700px"></div>
    <router-link
      id="next-user"
      :to="{
        name: 'users-id-profile',
        params: { id: Number(id) + 1 },
        query: { lang: 'ja', page: 2 },
        hash: '#next-user',
      }"
    >
      次のユーザ
    </router-link>
    <div style="height: 1400px"></div>
  </div>
</template>

<script>
export default {
  props: ["id"],
};
</script>
```

これはVue.jでは基本的に、HTMLの各要素の既定の動作を止めるような仕組み（JavaScriptの`event.preventDefault()`と同じ）があるため。
この#next-userで`id=next-user`の要素にスクロールさせるには自分でそれを実装する必要があり、それは`scrollBehavior`関数で行う。

## scrollBehavior関数でページ遷移時のスクロールを有効にする
ページ遷移時のスクロールを有効にするには、以下のように実装すればよい。
`if (savePosition) {}`, `if (to.hash) {}`, `return { x: 0, y: 0 };`という3段構成は一般的によく使われる`scrollBehavior`関数の形である。

- `if (savePosition) {}`<br>スクロールで位置が変わった後にブラウザバックなどで戻った際に、前の画面での位置情報がある時の処理を書く。`return savePosition;`としておけば、自動的に画面遷移前のスクロール位置（ユーザが自分でスクロールした場合でもその位置）に戻る事ができる。<br>（何かのサイトである項目をクリックしてページ遷移して、ブラウザバックした時に元のページが画面上部で開くか、元見ていた項目で開くか、の違いがあったなんていう経験があると思うが、Vue.jsで実装するならこのsavePositionを使うと、元見ていた場所に自動的にスクロールさせることができる。）
- `if (to.hash) {}`<br>router系（ページ遷移などの状態遷移があるもの）にはto, fromというオブジェクトが大体あるが、そのページ遷移の時に、その遷移先のURLに'#'が含まれる時の処理を書く。こうする事でいつも通りの動き（#next-userでid=next-userの要素にスクロールする）になる。
- `return { x: 0, y: 0 };`<br>元の位置を記憶していない・スクロールさせる必要もない場合の処理で、何もしないのでx=0, y=0を単純にreturnしている。
![ezgif.com-gif-maker (31).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/a816df44-5651-7ffc-69b0-45662f2df87f.gif)
動画のソースコードは以下。

```js:router.js
// 省略

Vue.use(Router);

export default new Router({
    // 省略
    scrollBehavior(to, from, savePosition) {
        console.log("to", to);
        console.log("from", from);
        console.log("savePosition", savePosition);
        if (savePosition) {
            return savePosition;
        }

        if (to.hash) {
            return {
                selector: "#next-user",
                offset: { x: 0, y: 100 }
            }
        }

        return { x: 0, y: 0 };
    }
});
```

ソースコード全体は以下。

https://github.com/yuta-katayama-23/intro-vuecli/commit/b61a5e0f530004de94b83ff186c01d02565c5862

### transitonが設定されている時にも、正しく`scrollBehavior`を機能させるには？
考え方としては、transitionのJavaScriptフックを使い、そのフック関数が呼ばれるのを起動スイッチとして、非同期的に`scrollBehavior`関数を呼び出すという事をする。
具体的には、

1. [transitionのJavaScriptフック](https://qiita.com/yuta-katayama-23/items/065be81dfa668deac903)を使い、`@before-enter`で要素が消えて現れた時に…という処理を実装し、そこで`this.$root.$emit()`によりカスタムイベントを発火させる
1. $emitで発火させたカスタムイベントを、`this.app.$root.$once('triggerScroll', () => {})`で捕捉し、非同期のPromiseでresolveする

という事をやる。
![ezgif.com-gif-maker (32).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/af1b0788-65df-c835-2482-b068434fdfb1.gif)
動画のソースコードは以下。

```vue:App.vue
<template>
  <div>
    <router-view name="header"></router-view>
    <main class="container-sm">
      <transition name="fade" mode="out-in" @before-enter="beforeEnter">
        <router-view></router-view>
      </transition>
    </main>
  </div>
</template>

<script>
export default {
  methods: {
    beforeEnter() {
      // this.$root：これはmain.jsの一番上の階層にあるVueインスタンスの事
      // ※単なるthisはここで言えば、App.vueというsingle file componentのVueインスタンス
      // this.$root.$emit：これにより、main.jsのVueインスタンスにtriggerScrollというイベントを発火させることができ、そのイベントをscrollBehavior関数が検知する事で非同期のscrollBehaviorの振る舞いが実装されている
      this.$root.$emit("triggerScroll");
    },
  },
};
</script>

<style scoped>
.fade-enter,
.fade-leave-to {
  opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 1s;
}
</style>
```
```js:router.js
// 省略

Vue.use(Router);

export default new Router({
    // 省略
    scrollBehavior(to, from, savePosition) {
        // パスが同じ時はすぐにscrollBehaviorを実行する
        if (to.path.match("^/users/") && from.path.match("^/users/")) {
            this.app.$root.$nextTick(() => this.app.$root.$emit('triggerScroll'));
        }

        return new Promise(resolve => {
            // this：new Router()のインスタンス自体の事
            // this.app：new Router()インスタンスが挿入・適用されるVueインスタンス（今回で言えばmain.jsのVueインスタンス）
            // this.app === this.app.$root
            // $once($on)：$emitで発火させたeventの処理を書ける構文（triggerScrollという$emitが発火した時にcallback関数が実行される）
            this.app.$root.$once('triggerScroll', () => {
                let position = { x: 0, y: 0 };

                if (savePosition) {
                    position = savePosition;
                }

                if (to.hash) {
                    position = {
                        selector: "#next-user",
                        offset: { x: 0, y: 100 }
                    }
                }

                resolve(position);
            });
        });
    }
});
```

ソースコード全体は以下。

https://github.com/yuta-katayama-23/intro-vuecli/commit/e7a6035d4793c4783ef2c622321cd51896d0a348

## Vue.jsの勉強メモ一覧記事へのリンク
- Vue.jsについて勉強した際に書いた勉強メモ記事のリンクを集約した記事。

https://qiita.com/yuta-katayama-23/items/dabefb59d16a83f1a1d4