[AngularJS] ブラウザバックの検知前に $viewContentLoaded がトリガされて困った話

ブラウザバックの場合は、通常のページ遷移とは挙動を変えたいというケースがたびたびあるかと思います。私もこの度、リスト系ページにブラウザバックで遷移した場合にスクロール位置を復元させるという改善に取り組み、ブラウザバック時のみ挙動を変えるというケースに遭遇しました。その時にブラウザバックの検知でハマったので、そのことについて記載したいと思います。

こちらの記事は AngularJS （≠Angular）の話になりますが、エンブレースでは Angular v10 に絶賛リプレース中です！

結論が早く知りたい方は [こちら](#結論) からお進みください。

## やりたいこと

- \$viewContentLoaded イベントが発火した処理を、通常のページ遷移の場合とブラウザバックによるページ遷移の場合で切り替える

【変更前のコード】

```js:sample.controller.js
app.controller('SampleCtrl', ['$scope', function($scope) {
  $scope.$on('$viewContentLoaded', function() {
    // 通常の処理
  });
}]);
```

【変更後のイメージ】

```js:sample.controller.js
app.controller('SampleCtrl', ['$scope', function($scope) {
  $scope.$on('$viewContentLoaded', function() {
    if ($scope.browserBacked) {
      // ブラウザバック時の処理
    } else {
      // 通常の処理
    }
  });
}]);
```

## 試したこと

### popstate でブラウザバックの検知

popstate イベントはブラウザの戻る、進むボタンが押されることなどにより、ページ遷移の履歴が変更された場合に発火するイベントです。これを使えば、以下のようにブラウザバックを検知することができます。

```js
var browserBacked = false;
window.addEventListener = function('popstate', function() {
  browserBacked = true;
};
```

これを AngularJS 風に書き換えると、以下のような感じで書けます。


```js:index.js
app.run(['$rootScope', '$window', function($rootScope, $window) {
  $rootScope.browserBacked = false;

  $rootScope.$on('$locationChangeStart', function() {
    $rootScope.browserBacked = false;  // reset
  });

  $window.addEventListener = function('popstate', function() {
    $rootScope.browserBacked = true;
  };
}]);
```

※ app.run() はアプリケーションをスタートさせる時に必要な処理を記載する場所で、認証の処理などの共通の処理を記載することが多いです。ブラウザバックの検知も共有処理になるため、こちらに記載しています。そして、\$rootScope にプロパティを生やすことで、全ての \$scope でそのプロパティを参照できるようになります。

しかし、これを実際に実行してみても、\$viewContentLoaded イベントの処理の中でブラウザバックかどうか判定することはできませんでした。原因は、\$viewContentLoaded イベントの方が、popstate イベントよりも早く検知されてしまうためです。そのため、この方法は使えませんでした。

### AngularJS 独自の方法でブラウザバックを検知

popstate が使えなかったため、他の方法を探していると stack overflow に以下のような記事がありました。
[How to detect browser back button click event using angular?](https://stackoverflow.com/questions/15813850/how-to-detect-browser-back-button-click-event-using-angular)

そのコードを参考にすると以下のようにブラウザバックを検知できます。

```js
app.run(['$rootScope', '$location', function($rootScope, $location) {
  $rootScope.browserBacked = false;

  // ①
  $rootScope.$on('$locationChangeSuccess', function() {
    $rootScope.actualUrl = $location.url();
  });        

  // ②
  $rootScope.$watch(function() { return $location.url(); }, function(newUrl, oldUrl) {
    if ($rootScope.actualUrl === newUrl) {
      $rootScope.browserBacked = true;
    } else {
      $rootScope.browserBacked = false;
    }
  });
}]);
```

通常のページ遷移の場合は、②→①の順序で処理が実行されますが、ブラウザバックの時は①→②の順序で処理が実行されるという AngularJS 特有のイベント発火順序を利用した実装になります。 この方法でもブラウザバックを検知することはできました。しかし、先ほどと同じように、②の処理が実行されるよりも前に \$viewContentLoaded のイベントが発火されるため、この方法も使えませんでした。

## たどり着いた解決策

### イベントの発火順序

行き詰まってしまったので、一度イベントが発火される順序を整理してみました。

|ページ遷移|イベントの発火順序|
|---|---|
|通常のページ遷移|① watch \$location.url() <br> ② \$locationChangeSuccess <br> ③ \$viewContentLoaded|
|ブラウザバック|① \$locationChangeSuccess <br> ② \$viewContentLoaded <br> ③ watch \$location.url()|

整理してみると \$viewContentLoaded の前に、必ず \$locationChangeSuccess が発火していることがわかりました。このことから \$locationChangeSuccess のイベント処理の中でブラウザバックの判定を行ってあげれば良いのではないかという考えが浮かびました。

### 結論

[#イベントの発火順序](#イベントの発火順序) にて予想した通り、 \$locationChangeSuccess イベントの中でブラウザバックの判定を行うことで、 \$viewContentLoaded イベントの発火よりも先にブラウザバックかどうかわかるようになりました 🎉

```js:index.js

app.run(['$rootScope', '$location', function($rootScope, $location) {
  var locationUrlChangedFirst = false;
  $rootScope.browserBacked = false;

  $rootScope.$on('$locationChangeSuccess', function() {
    $rootScope.actualUrl = $location.url();

    if (locationUrlChangedFirst) {
      $rootScope.browserBacked = false;
      locationUrlChangedFirst = false; // reset
    } else {
      $rootScope.browserBacked = true;
    }
  });        

  $rootScope.$watch(function() { return $location.url(); }, function(newUrl, oldUrl) {
    if ($rootScope.actualUrl !== newUrl) {
      locationUrlChangedFirst = true;
    }
  });
}]);
```

※ コードを簡潔にするためリロードに対応する処理は省いています。ご了承ください :bow:

これで最初想定していた以下のような条件分岐を行うことができるようになりました。

```js:sample.controller.js
app.controller('SampleCtrl', ['$scope', function($scope) {
  $scope.$on('$viewContentLoaded', function() {
    if ($scope.browserBacked) {
      // ブラウザバック時の処理
    } else {
      // 通常の処理
    }
  });
}]);
```

## 最後に

AngularJS では通常のページ遷移とブラウザバックによるページ遷移とでイベントの発火順序が多々異なっています。ハック的な方法かもしれませんが、今回はそれを利用することでブラウザバックを検知することができました。他の技術でもそうですが、ライフサイクルやイベント発火の条件を頭に入れておくことは重要だなと思いました。

もし、もっと良いブラウザバックの判定方法がありましたら、ご教示いただけますと幸いです。

### 参考
- [How to detect browser back button click event using angular?](https://stackoverflow.com/questions/15813850/how-to-detect-browser-back-button-click-event-using-angular)
- [AngularJS API Docs](https://docs.angularjs.org/api)