こんにちは。小学生向けのニュースサイト、こどもニュースをつくっています。
このサイトのヘッダの背景は空の絵で、読者の地域の天気予報に合わせて晴れ・くもり・雨・雪・雷を描き分けています。この絵が、ページを開くたびに一瞬遅れて入れ替わっていました。直した形は「前回の値で先に描いてから、取り直した値で上書きする」です。読者の表示設定を最初のペイント前に復元する話は以前に書きましたが、今回の値は読者が決めたものではなく外から来るものなので、同じ仕組みに 4 つの条件が加わります。
症状
サイトは静的書き出しなので、配られる HTML は全員同じです。読者の地域はサーバでは分からず、ブラウザが開いてから、地域の推定と予報の取得の 2 往復を終えて初めて空が決まります。それまでは HTML に焼いてある既定の空(当時は固定の晴れ)が出ていました。
その結果、ページを開くたびに「既定の空が描かれ、少し遅れて読者の空に変わる」入れ替わりが見えていました。
外れた案
最初に考えたのは、取れた予報をブラウザに保存しておき、React の hydration 後にその値で描く形でした。これは入れ替わりが消えません。静的書き出しの HTML は JavaScript より先に描かれるので、hydration 後に state を差し替えても「既定の空 → 保存した空」の入れ替わりがそのまま残ります。往復を省けるだけで、ペイントより後に値が決まる構造は変わっていませんでした。
次に、5 天気ぶんの絵を全部グローバル CSS に置き、<html> の属性で選ぶ形を考えました。絵をコードから引けるので保存した絵が古くなる心配が無い代わりに、昼と夜で 5 天気 かける 2 の 10 枚を全ページに載せることになります。絵 1 枚の data URI は 晴れ 2,290 / くもり 1,217 / 雨 5,802 / 雪 7,340 / 雷 5,130 バイトで、合計 43.6KB です。実際に出るのは 2 枚だけなので、飾りの帯に払う量ではないと判断して却下しました。
原因
原因は「値がペイントの後に決まる」構造そのものです。入れ替わりを消すには、ペイントより前に走るコードが、その時点でブラウザにある値を当てられなければなりません。必要なのは 2 つで、前回の値がブラウザに残っていることと、それをペイント前に反映する経路があることです。
対処
取れた予報は、地点と一緒に localStorage へ残します。ヘッダの背景は CSS 変数の参照にして、既定値にはビルド時に焼き込む空を置きます。直した当時は固定の晴れの絵を焼いていましたが、今は絵を持たない none(地色だけ)にしてあります。
backgroundImage: {
xs: 'var(--kn-sky-right, none)',
md: 'var(--kn-sky-left, none), var(--kn-sky-right, none)',
},
変数が入っていなければ既定値が出て、入れば読者の空が出ます。絵の入れ替えは変数を入れるだけなので、React の再描画を待ちません。
<head> で同期実行するブートスクリプトが、保存してある値を <html> の変数へ入れます。g は cookie を読む関数です。
var h=document.documentElement;
var fc=null;try{fc=JSON.parse(localStorage.getItem('kn-forecast')||'null');}catch(e){}
var td=new Date(Date.now()+9*3600000).toISOString().slice(0,10);
var tw=new Date(Date.now()+9*3600000+86400000).toISOString().slice(0,10);
if(fc&&(fc.date===td||fc.date===tw)&&fc.expires>Date.now()&&fc.choice&&fc.choice.region===g('kn-region')&&fc.choice.area===g('kn-area')&&fc.css){
var skv={"dayLeft":"--kn-sky-left","dayRight":"--kn-sky-right","nightLeft":"--kn-sky-night-left","nightRight":"--kn-sky-night-right"};
for(var sk in skv){var skc=fc.css[sk];if(typeof skc==='string'&&(skc==='none'||skc.indexOf('url("data:image/svg+xml,')===0)){h.style.setProperty(skv[sk],skc);}}
}
保存が使えるあいだは、地域の推定も予報の取得も行いません。期限が切れたら取り直し、取れた値で保存を上書きします。その端末ではじめて開いた 1 回だけは保存が無いので既定の空が出て、2 回目以降は入れ替わりが出ません。
読者の設定と何が違うか
表示設定の復元と同じブートスクリプトを使っていますが、外部から来た値には条件が 4 つ増えました。
1 つ目は期限です。地点は IP アドレスからの推定で決まることがあり、読者が旅行先や引っ越し先で直せる値ではありません。そこで期限を 3 時間にしました。気象庁の予報は 05 時・11 時・17 時の 1 日 3 回更新されるので、3 時間なら発表の入れ替わりに 1 回以上遅れません。この期限は保存し直しても延ばしません。保存してある値で描いたときに期限を先送りすると、毎日開く読者の予報がいつまでも古いままになります。
2 つ目は鍵の突き合わせです。保存した値には取ったときの読者の選択(県と区域)を添え、読むときに cookie の選択と比べます。読者が別の土地を選び直していれば、前の土地の空を出さずに捨てて取り直します。
3 つ目は形の検証です。localStorage は読者が書き換えられるので、CSS 変数へ入れる前に、自分が焼いた形(url("data:image/svg+xml, で始まる文字列か none)かどうかだけ見ます。読める条件の正本は型検査の掛かる TypeScript のモジュールに置いて単体テストを付け、ブートスクリプトはその形を最小限だけ見ます。ブートスクリプトは try/catch の中の文字列なので、間違えても黙って何も起きず、「入れ替わりが直らない」としてしか現れないためです。
4 つ目は、描いた結果まで保存することです。ブートスクリプトは自己完結の文字列で、絵を組み立てるコードを import できません。そこで天気だけでなく、焼いた CSS そのものを保存します。その代わり、取得が終わるたびに絵を焼き直して保存を上書きします。絵のコードを変えても、次に開いた 1 回で新しい絵に入れ替わり、保存した CSS が古いまま残りません。
前回の値も新しい値も無いとき
前回の値で先に描く形の反対側に、どちらの値も無いときの扱いがあります。トップの天気カードは予報が届くまで枠だけを出しておき、地域が決まらない読者には「どこの天気を見ますか?」、取得に失敗したときは「天気を取得できませんでした」と、決着した時点で理由を出します。取れないものを静かに省くと、読者には壊れているのか待てば出るのかが分かりません。
まとめ
- 静的サイトで外部 API の値を表示に使うと、ペイントの後に値が決まるので毎回入れ替わりが見える
- hydration 後に差し替えても消えない。ペイント前に走るコードが前回の値を当てる形にする
- 背景は CSS 変数の参照にして、既定値に焼いた絵を置く
- 外部の値には期限・鍵の突き合わせ・形の検証・描いた結果の保存の 4 つが要る
- 期限は保存し直しても延ばさない。描いた結果は取得のたびに焼き直して上書きする
- 値が無いときは待ちの表示を残さず、決着した時点で理由を出す