素人がプログラミングを勉強していたブログ

プログラミング、セキュリティ、英語、Webなどのブログ since 2008

連絡先: twitter: @javascripter にどうぞ。

history.pushState、history.replaceState

HTML5のhistory.pushState、history.replaceStateを試した。
HTML5 pushState/replaceState demoで動かせる。 Minefieldだと完全に意図した通りに動くがWebKitだとURLまわりがうまくいかない。
メインのソースコードは下記の通り。
canvasで適当に壁紙用画像を作るデモで、画像自体をクリックして何度も作り直せるようにした。
こういう物を作るときは、前の画像に戻れるよう履歴管理をすべきだが、今まではlocation.hashを使ったり(hashchangeイベントが入るまではタイマーが必要だった)iframeを使ったハックだったり(ブラウザ間の互換性やhistory.go(-2)をきちんと動かすのが難しい)、あるいはページを遷移する(必要ない部分まで毎回読み込まれる)必要があった。
pushState/replaceStateを使うと、これらの問題を解決することができる。
pushStateはブラウザの履歴に新規エントリを追加し戻れるようにするAPIで、replaceStateは現在のエントリを入れ替える、つまり新規のエントリは追加したくない場合に使用するAPIである。
これらを使って履歴を追加するとブラウザのバックボタンなどで戻ろうとした時にpopstateというイベントが発生するので、それをキャッチして前のページに相当する部分をJSで復元することができる。

history.pushState(popstateイベントのstateプロパティに入る文字列, 状態識別用のタイトル, 現在の状態に対応するURL);

だいたいこのように使う。現在の状態に対応するURLは省略可能。replaceStateも同様の引数を取る。
文字列以外をstateとして渡したい時はJSON.stringifyなどを使って文字列に変換すれば良い。
現在の状態に対応するURLというのは、ブラウザのURLバーに表示されるURLを変更するための物である。
例えば、http://example.com/webapp/上でhistory.pushState("", "", "/hello");を実行するとブラウザのURLバーにはhttp://example.com/helloが表示される。当然originを変更することはできないが、ハッシュ部分以外の変更でもページ遷移が起こらないので、パスやクエリも変更できる。
ただしこの挙動はMinefieldに限った話。WebKitだとうまく動かなかったりするが仕様かどうかは不明。

あとはhistoryとはあまり関係ない部分。
・JSでもURLからデータを抜き出したりする時はバリデーションを忘れないように。
・toDataURL()が重いのでcontextmenuイベント発生時にsrcをセットしようとしたがタイミングの関係上WebKitでしか動かないのでやめて、タイマーで遅延して実行するようにした。
・canvasの描画自体は高速なので、canvasに描いてからその下に見えないimageを配置して高速に見えるようにした。canvasのpointer-eventsをnoneにしているのでclickやcontextmenuが発生するのはimageで、画像を保存したりできる。

const WIDTH = window.screen.width, HEIGHT = window.screen.height;
var canvas = document.createElement("canvas");
canvas.width = WIDTH;
canvas.height = HEIGHT;
var graphics = canvas.getContext("2d");
var state;

var image = document.createElement("img");
image.width = WIDTH;
image.height = HEIGHT;

var rcolor = /(?:^\?|&)color=([^&]+)/;
var match = location.search.match(rcolor);

if (match) {
  try {
    state = JSON.parse(decodeURIComponent(atob(match[1])));
    if (!(typeof state.x1 === "number" &&
          typeof state.y1 === "number" &&
          typeof state.x2 === "number" &&
          typeof state.y2 === "number" &&
          state.offsets.length === state.colors.length &&
          state.offsets.every(function (offset) {
            return typeof offset === "number" && offset >= 0;
          }) &&
          state.colors.every(function (color) {
            return typeof color === "number" && color >= 0 && color <= 0xffffff;
          }))) {
      throw TypeError("Invalid JSON");
    };
  } catch (error) {
    state = makeState();
  }
} else {
  state = makeState();
}

modifyState(state, true);

image.addEventListener("click", function () {
  modifyState(makeState(), false);
}, false);

document.addEventListener("DOMContentLoaded", function (event) {
  document.body.appendChild(canvas);
  document.body.appendChild(image);
}, false);

window.addEventListener("popstate", function (event) {
  var state = JSON.parse(event.state);
  draw(graphics, state, image);
}, false);

function random_color() {
  return Math.floor(Math.random() * 0x1000000);
}

function modifyState(state, replace) {
  var serializedState = JSON.stringify(state);
  var newURL = location.protocol + "//" + location.host + location.pathname + "?" + btoa(serializedState) + location.hash;
  if (replace) {
    history.replaceState(serializedState, "", newURL);
  } else {
    history.pushState(serializedState, "", newURL);
  }
  draw(graphics, state, image);
}

function makeState() {
  var x1, y1, x2, y2;
  x1 = (Math.random() * WIDTH);
  y1 = (Math.random() * HEIGHT);
  x2 = (x1 + (Math.random() * WIDTH)) % WIDTH;
  y2 = (y1 + (Math.random() * HEIGHT)) % HEIGHT;
  var i;

  var nstops = Math.floor(Math.random() * 2) + 2;

  var offsets = [0];
  var offset = 0;
  var colors = [];

  for (i = 1; i < nstops - 1; i++) {
    offset += 1 / (nstops - 1);
    offsets.push(offset);
  }
  offsets.push(1);

  for (i = 0; i < nstops; i++) {
    colors.push(random_color());
  }

  return {
    x1: x1,
    y1: y1,
    x2: x2,
    y2: y2,
    offsets: offsets,
    colors: colors
  };

}

function toRGB(color) {
  return "#" + ("00000" + color.toString(16)).slice(-6);
}

var timer;

function draw(graphics, state, copyTo) {
  var grad = graphics.createLinearGradient(state.x1, state.y1, state.x2, state.y2);
  var nstops = state.offsets.length;
  for (i = 0; i < nstops; i++) {
    grad.addColorStop(state.offsets[i], toRGB(state.colors[i]));
  }
  graphics.fillStyle = grad;
  graphics.fillRect(0, 0, WIDTH, HEIGHT);
  if (copyTo) {
    if (timer) {
      clearTimeout(timer);
      timer = 0;
    }
    timer = setTimeout(function () {
       copyTo.src = graphics.canvas.toDataURL();
    }, 500);
  }
}