10
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

非同期遷移に関する備忘録

Last updated at Posted at 2021-01-06

当記事では、非同期遷移について調べたことなどをまとめています。

1. 非同期遷移とは

非同期遷移(あるいは非同期画面遷移・Pjaxとも)とは、ブラウザの履歴を操作するHistory APIやXMLHTTPRequestオブジェクトを利用して、画面を遷移する処理を指す言葉のようです。

「ようです」という曖昧な表現を使った理由は、この非同期遷移という言葉があまり一般的ではないためです。試しに、Googleで非同期遷移と検索をかけてみたところ、ヒットしたのは約12万件。これが非同期通信だと約87万件、さらにAjaxだと約2億6,300万件なので、その認知度の圧倒的な差が分かろうというものです。

しかしながら、では、一般的に使われていない処理ではないのかというと必ずしもそのようなわけではなく、YouTube・twitter・Facebook・GitHubなどの大手サイトを始めとした、少なくない数のWebサイトで実装されています。シームレスにページを遷移させるということに、わざわざ名前を付けるほどのこともないということなのかもしれません。

1-1. Ajaxとの違い

AjaxはXMLHTTPRequestオブジェクトを利用して非同期通信を実行し、WebサーバーからXML・HTML・jsonなどを得てWebページを非同期的に書き換える処理です。

非同期遷移はAjaxが技術的・思想的な土台となっています。非同期的にWebページを書き換えるのはAjaxそのものですが、そこにHistory APIを利用したブラウザ履歴の制御を加えることで、まるで同期的にページが遷移したように見せかけます。実際の操作感も同期遷移と大差ありません。ただし、非同期遷移は、より高速に、滑らかに、少ないデータ通信量で遷移を実現します。

1-2. Pjaxとの違い

Pjaxには2つの意味があります。1つは同期遷移の別表現、もう1つは同期遷移を実現するjQueryプラグインの名称です。なお、jQueryのプラグインとしてのPjaxには複数の実装が存在します。

2. Barba.js

非同期遷移を実現するためのライブラリとしてはBarba.jsが最有力候補となるでしょう。

barba.js

barbajs/barba: Create badass, fluid and smooth transition between your website's pages.

ところで、Barba.jsには旧バージョンと現バージョン(v2)の2種類があり、それぞれで処理の書き方が異なるらしいです。使用時・勉強時はバージョンの違いに注意してください。

3. 非同期遷移の考え方を順を追って理解する

ここからは非同期遷移を理解するために、非同期遷移処理を実装する過程を順々に記載します。完全に我流で書いていますので突っ込みどころが多いと思います。

以下、ソースコードです。

GitHub

以下ページより動作確認できます。

動作確認ページ

3-1. 非同期遷移の引き金

非同期遷移を実装するとき、まず考えるべきは遷移の引き金となる動作を決めることです。基本的には、同期遷移と同様に<a>タグをクリックしたときに非同期遷移させるのが無難でしょう。もちろん、任意の<div>タグや<span>タグをクリックしたり、マウスオーバーしたときなどに遷移させても問題ありません。

以下例では<a>タグをクリックしたときに非同期遷移するように処理を組んでいきます。

3-2. 同期遷移を中止させる

<a>タグをクリックしたときに非同期遷移させるならば、まず必要になるのは同期遷移を中止させる処理です。

main.js
window.addEventListener("load", () => {
  for (const anchor of document.querySelectorAll("a")) {
    anchor.addEventListener("click", (event) => {

      // 同期処理を止めます。
      event.preventDefault();
    });
  }
});

preventDefaultメソッドを使って、<a>タグをクリックしたときに同期遷移しないようにします。

Event.preventDefault() - Web API | MDN

JavaScriptのpreventDefault()って難しくない?preventDefault()を使うための前提知識 - Qiita

3-3. 遷移先のHTMLを読み込み置換する

同期遷移を止めたままページ内容を書き換えるためにXMLHttpRequestオブジェクトを利用して、非同期的に遷移先のページを取得・置換します。

遷移先のURLはクリックした<a>タグのhref属性から取得します。

置換対象は何らかの方法で指定してください。現在のHTMLと遷移先のHTMLとを突き合わせて、差異を調べ上げるみたいな処理を用意しても良いですが、処理を用意するのが面倒ですし、意図しない部分まで置換しそうで怖いです。定数などに格納しておくか、置換処理を関数に切り出して、引数で制御するのが無難でしょう。

XMLHttpRequest - Web API | MDN

main.js
window.addEventListener("load", () => {
  for (const anchor of document.querySelectorAll("a")) {
    anchor.addEventListener("click", (event) => {
      event.preventDefault();
  
      // 非同期的に遷移先のページを取得・置換します。
      // 今回は<title>タグと<main>タグを置換させてみます。
      const xhr = new XMLHttpRequest();
      xhr.onreadystatechange = () => {
        if (xhr.readyState === 4 && xhr.status === 200) {
          const temporaryWrapper = document.createElement("div");
          temporaryWrapper.innerHTML = xhr.responseText;
          for (const ELEMENT_TO_REWRITING of ["title", "main"]) {
            document.querySelector(ELEMENT_TO_REWRITING).innerHTML = temporaryWrapper.querySelector(ELEMENT_TO_REWRITING).innerHTML;
          }
        }
      };
      xhr.open("GET", anchor.href);
      xhr.send();
    });
  }
});

あるいはPromiseオブジェクトを利用して、取得処理と置換処理を区切ったほうが見やすいかもしれません。

main.js
window.addEventListener("load", () => {
  for (const anchor of document.querySelectorAll("a")) {
    anchor.addEventListener("click", (event) => {
      event.preventDefault();
  
      // 非同期的に遷移先のページを取得・置換します。
      new Promise((resolve) => {
  
        // 遷移先のページを取得します。
        const xhr = new XMLHttpRequest();
        xhr.onreadystatechange = () => {
          if (xhr.readyState === 4 && xhr.status === 200) {
            resolve(xhr.responseText);
          }
        };
        xhr.open("GET", anchor.href);
        xhr.send();   
      }).then((result) => {
  
        // 遷移先のページに置換します。
        const temporaryWrapper = document.createElement("div");
        temporaryWrapper.innerHTML = result;
        for (const ELEMENT_TO_REWRITING of ["title", "main"]) {
          document.querySelector(ELEMENT_TO_REWRITING).innerHTML = temporaryWrapper.querySelector(ELEMENT_TO_REWRITING).innerHTML;
        }
      });
    });
  }
});

3-4. ブラウザ履歴を操作

非同期書き換え処理に加えて、さらにブラウザ履歴の操作処理を実装します。具体的には、遷移前のページをブラウザ履歴に保存し、現在のURLを遷移後のページのURLに書き換えます。それぞれHistory APIのpushStateメソッドとreplaceStateメソッドを利用します。

History - Web API | MDN

main.js
window.addEventListener("load", () => {
  for (const anchor of document.querySelectorAll("a")) {
    anchor.addEventListener("click", (event) => {
      event.preventDefault();
      new Promise((resolve) => {
        const xhr = new XMLHttpRequest();
        xhr.onreadystatechange = () => {
          if (xhr.readyState === 4 && xhr.status === 200) {
            resolve(xhr.responseText);
          }
        };
        xhr.open("GET", anchor.href);
        xhr.send();   
      }).then((result) => {

        // 現在のページをブラウザ履歴に追加します。
        history.pushState(null, null, location.href);

        // 遷移後のページのURLに書き換えます。
        history.replaceState(null, null, anchor.href);

        const temporaryWrapper = document.createElement("div");
        temporaryWrapper.innerHTML = result;
        for (const ELEMENT_TO_REWRITING of ["title", "main"]) {
          document.querySelector(ELEMENT_TO_REWRITING).innerHTML = temporaryWrapper.querySelector(ELEMENT_TO_REWRITING).innerHTML;
        }
      });
    });
  }
});

ちなみに、実際に触ってもらえれば分かりますが、pushStateメソッドとreplaceStateメソッドの実行順は上記のようにする必要があります。この辺りを適当に弄ると、1度の移動で2つのページが履歴に登録されたり、遷移後のページが履歴に保存されたりと、おかしな挙動になるので注意が必要です。

3-5. ブラウザの進む・戻るに対応

最後に、ブラウザアプリに実装されている進む・戻る処理に対応します。この処理を忘れると<a>タグをクリックしての遷移はできますが、進む・戻るを実行してもブラウザ履歴が変化するだけでWebページが全く書き換わらなくなります。

ブラウザの進む・戻る処理はpopstateイベントと紐付けられています。このpopstateイベントに、非同期遷移用のイベントリスナーを追加します。イベントリスナーの内容は、これまでに実装した非同期通信処理がほぼ丸々流用できます。ただし、いくつか不要な処理があるので注意が必要です。

WindowEventHandlers.onpopstate - Web API | MDN

main.js
window.addEventListener("load", () => {
  // ※省略
});

// ブラウザの進む・戻る処理に対応した非同期遷移処理です。
window.addEventListener("popstate", () => {
  new Promise((resolve) => {
    const xhr = new XMLHttpRequest();
    xhr.onreadystatechange = () => {
      if (xhr.readyState === 4 && xhr.status === 200) {
        resolve(xhr.responseText);
      }
    };

    // 取得対象を変更します。
    // xhr.open("GET", anchor.href);
    xhr.open("GET", location.href);

    xhr.send();   
  }).then((result) => {

    // ブラウザ履歴を操作してはいけません。
    // history.pushState(null, null, location.href);
    // history.replaceState(null, null, anchor.href);

    const temporaryWrapper = document.createElement("div");
    temporaryWrapper.innerHTML = result;
    for (const ELEMENT_TO_REWRITING of ["title", "main"]) {
      document.querySelector(ELEMENT_TO_REWRITING).innerHTML = temporaryWrapper.querySelector(ELEMENT_TO_REWRITING).innerHTML;
    }
  });
});

ここで一旦、ソースコードを整理します。

main.js
/**
 * ファイルを取得する関数です。
 * 取得するファイルは第1引数で指定してください。
 * @param {String} filePath
 * @returns {Promise}
 */
const getFileByXMLHttpRequest = (filePath) => {
  return new Promise((resolve) => {
    const xhr = new XMLHttpRequest();
    xhr.onreadystatechange = () => {
      if (xhr.readyState === 4 && xhr.status === 200) {
        resolve(xhr.responseText);
      }
    };
    xhr.open("GET", filePath);
    xhr.send(); 
  });
};

/**
 * Webページを書き換える関数です。
 * 書き換え後のHTMLは第1引数で指定してください。
 * @param {String} HTMLString
 */
const rewritePage = (HTMLString) => {
  const temporaryWrapper = document.createElement("div");
  temporaryWrapper.innerHTML = HTMLString;
  for (const ELEMENT_TO_REWRITING of ["title", "main"]) {
    document.querySelector(ELEMENT_TO_REWRITING).innerHTML = temporaryWrapper.querySelector(ELEMENT_TO_REWRITING).innerHTML;
  }
};

/**
 * 非同期遷移処理です。
 * @author AGadget
 */

// <a>タグをクリックしたときの非同期遷移処理です。
window.addEventListener("load", () => {
  for (const anchor of document.querySelectorAll("a")) {
    anchor.addEventListener("click", (event) => {
      event.preventDefault();
      getFileByXMLHttpRequest(anchor.href).then((result) => {
        history.pushState(null, null, location.href);
        history.replaceState(null, null, anchor.href);
        rewritePage(result);
      });
    });
  }
});

// ブラウザの進む・戻る処理に対応した非同期遷移処理です。
window.addEventListener("popstate", () => {
  getFileByXMLHttpRequest(location.href).then((result) => {
    rewritePage(result);
  });
});

これで一応は完成ですが、他にも実装しておきたい機能があります。

3-6. 遷移アニメーション

UXを考えると、遷移時にはアニメーションを挟んだほうがよいでしょう。簡単ではありますがフェードイン・フェードアウトさせてみます。

main.js
/**
 * フェードインする関数です。
 */
const fadein = () => {
  const FADEIN_SECOND = 0.2;
  const fadeinTarget = document.querySelector("main");
  fadeinTarget.style.transition = `all ${FADEIN_SECOND}s`;
  fadeinTarget.style.opacity = 1;
};

/**
 * フェードアウトする関数です。
 * @returns {Promise}
 */
const fadeout = () => {
  return new Promise((resolve) => {
    const FADEOUT_SECOND = 0.1;
    const fadeinTarget = document.querySelector("main");
    fadeinTarget.style.transition = `all ${FADEOUT_SECOND}s`;
    fadeinTarget.style.opacity = 0;
    setTimeout(() => {
      resolve();
    }, FADEOUT_SECOND * 1000);
  });
};

/**
 * ファイルを取得する関数です。
 * 取得するファイルは第1引数で指定してください。
 * @param {String} filePath
 * @returns {Promise}
 */
const getFileByXMLHttpRequest = (filePath) => {
  // ※省略
};

/**
 * Webページを書き換える関数です。
 * 書き換え後のHTMLは第1引数で指定してください。
 * @param {String} HTMLString
 */
const rewritePage = (HTMLString) => {
  // ※省略
};

/**
 * 非同期遷移処理です。
 * @author AGadget
 */

// <a>タグをクリックしたときの非同期遷移処理です。
window.addEventListener("load", () => {
  for (const anchor of document.querySelectorAll("a")) {
    anchor.addEventListener("click", (event) => {
      event.preventDefault();
      Promise.all([fadeout(), getFileByXMLHttpRequest(anchor.href)]).then((result) => {
        history.pushState(null, null, location.href);
        history.replaceState(null, null, anchor.href);
        rewritePage(result);
        fadein();
      });
    });
  }
});

// ブラウザの進む・戻る処理に対応した非同期遷移処理です。
window.addEventListener("popstate", () => {
  Promise.all([fadeout(), getFileByXMLHttpRequest(location.href)]).then((result) => {
    rewritePage(result);
    fadein();
  });
});

フェードアウトアニメーションと遷移後のページ取得処理は並んで走らせておいたほうが、ページ遷移にかかる時間が小さくなるはずなのでPromise.allメソッドを使っています。

3-7. 別ページへのジャンプ時に非同期遷移を停止

これまでに仮定した状況では、<a>タグからは自サイトにしかジャンプしませんでしたが、現実的には別サイトのURLを指定することもあるはずです。ただ、他サイトに対して、非同期通信を試みるとブロックされることがありますし、何より他サイトに非同期遷移する必要もありませんので少し工夫します。

以下、ジャンプ処理部分を切り出しました。

// <a>タグをクリックしたときの非同期遷移処理です。
window.addEventListener("load", () => {
  for (const anchor of document.querySelectorAll("a")) {
    anchor.addEventListener("click", (event) => {
      event.preventDefault();
      Promise.all([fadeout(), getFileByXMLHttpRequest(anchor.href)]).then((result) => {
        history.pushState(null, null, location.href);
        history.replaceState(null, null, anchor.href);
        rewritePage(result);
        fadein();
      });
    });
  }
});

ジャンプ先が自サイトであるかどうか判定し、自サイトであるならば非同期遷移、自サイトでないならば普通に同期遷移します。判定処理は以下ブログの処理を使っています。

JavaScriptの正規表現でURLからドメイン・ホスト名を抽出する - Sarchitect

/**
 * 自サイトのURLかどうか判定する関数です。
 * 自サイトならばtrueを返します。
 * @param {String} URL
 * @returns {Boolean}
 */
const isMySite = (URL) => {
  if (location.host === URL.match(/^https?:\/{2,}(.*?)(?:\/|\?|#|$)/)[1]) {
    return true;
  }
};


// <a>タグをクリックしたときの非同期遷移処理です。
window.addEventListener("load", () => {
  for (const anchor of document.querySelectorAll("a")) {
    anchor.addEventListener("click", (event) => {

      // 判定します。
      if (!isMySite(anchor.href)) {
        return;
      }

      event.preventDefault();
      Promise.all([fadeout(), getFileByXMLHttpRequest(anchor.href)]).then((result) => {
        history.pushState(null, null, location.href);
        history.replaceState(null, null, anchor.href);
        rewritePage(result);
        fadein();
      });
    });
  }
});

3-8. その他考えられる機能

上記以外にも、リンク先にマウスオーバーした段階でページの取得処理を実行したりですとか、現在のWebページ中に表示されているリンク先全てのWebページを先んじて取得しておくなど、色々と面白い機能が思いつきます。

4. おわりに

非同期遷移は個人的にとても面白い題材です。「どのように書こうか?」「こういう機能はどうだろうか?」と考え、試行錯誤しながらガリガリ書くのはとても面白いです。

しかしながら、非同期遷移自体はブラウザに標準搭載されている遷移処理(同期遷移)をイチから再発明している側面があることも事実です。少なくとも非同期遷移は必須の技術・機能ではありません。あまり悪ノリに走り過ぎないように、自制の心を忘れないよう気を付けたいところです。

10
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?