一覧WB-13-3 · 描画とページの性能

レイアウトシフトと長時間タスクの対策

画像の読込み前後で段落の位置を測り、幅と高さを予約した場合と比較します。続いて画面から遠い画像の遅延読込みを確認します。後半ではタイマーを待たせる連続処理と、途中で他のタスクへ処理を譲る分割を比較します。

説明

ページの使いにくさには、読込み後に内容が動く問題と、処理中に次の操作を受け付けられない問題があります。画像の領域を先に確保することと、JavaScriptの長い処理を分割することは、それぞれ違う原因への対策です。

画像の読込み前後で段落の位置を測り、幅と高さを予約した場合と比較します。続いて画面から遠い画像の遅延読込みを確認します。後半ではタイマーを待たせる連続処理と、途中で他のタスクへ処理を譲る分割を比較します。

1寸法を指定していない画像

srcのない画像と、その後ろの段落を用意します。

(globalThis.doc=await loadDocument("/performance")画像と段落の文書,globalThis.photo=doc.getElementById("photo"),globalThis.startY=rect("#caption").y読込み前の段落位置,{image:rect("#photo"),captionY:startY})

まだ画像自体の寸法が分からず、後続段落は現在の画像領域に続いて配置されます。altの表示なども初期寸法に影響するため、実際の位置を保存して比較します。

2画像の読込みで段落が動く

200×100の画像を読み込みます。

(photo.src=画像を要求"/asset.svg?tag=unreserved",await photo.decode()画像の復号完了を待つ,{image:rect("#photo"),captionY:rect("#caption").y,move:段落の移動量Math.round((rect("#caption").y-startY)*100)/100})

画像の実寸が決まり、後続段落の位置が変わります。こうした読込み後の移動は、読んでいた場所や押そうとしていた場所をずらす原因になります。位置差の測定と、複数のずれを集計するCLSの計算は別です。

3画像用の幅と高さを先に確保する

新しい同じ文書で、srcより先にwidthとheightを指定します。

(globalThis.doc=await loadDocument("/performance"),globalThis.photo=doc.getElementById("photo"),photo.width=200,photo.height=100画像の領域を先に指定,globalThis.reservedY=rect("#caption").y予約後の段落位置,{image:rect("#photo"),captionY:reservedY})

画像が届く前から200×100の領域を確保します。widthとheightは表示比率にも使われるため、実画像の比率に合う値を指定します。

4寸法が一致すれば位置が変わらない

同じ200×100の画像を読み込みます。

(photo.src="/asset.svg?tag=reserved",await photo.decode()画像が届いた後,{captionY:rect("#caption").y,move:Math.round((rect("#caption").y-reservedY予約前後の差)*100)/100})

段落の移動量は0です。必要な領域を先に確保したことで、画像が届いても後ろの文章の位置を保てました。

5遠い画像を遅延読込みにする

文書の下へ10000pxの間隔を置き、loading=lazyの画像を加えます。

(globalThis.spacer=doc.createElement("div"),spacer.style.height="10000px"表示領域から遠ざける,doc.body.append(spacer),globalThis.lazy=doc.createElement("img"),lazy.id="lazy",lazy.width=200,lazy.height=100,lazy.loading="lazy"遅延読込みを指定,lazy.alt="遅延画像",lazy.src="/asset.svg?tag=lazy",doc.body.append(lazy),await pause(50),{loading:lazy.loading,complete:lazy.complete,requested:サーバーへの要求有無(await(await fetch("/requests")).json()).tags.includes("lazy")})

この配置では、遠い画像の要求がまだ始まっていません。lazyの取得開始距離はブラウザや条件で変わるため、「表示領域へ入った瞬間だけ読み込む」という厳密な境界ではありません。先に寸法を指定し、取得の遅延で配置が崩れないようにします。

6画像を表示領域へ近付ける

画像までスクロールし、復号を待ちます。

(lazy.scrollIntoView()画像を表示領域へ移動,await lazy.decode()取得・復号を待つ,{complete:lazy.complete,width:lazy.naturalWidth取得後の画像幅,requested:(await(await fetch("/requests")).json()).tags.includes("lazy")})

画像が取得され、naturalWidthは200になります。loading=lazyは取得タイミングの制御で、取得後の画像形式や内容を変更するものではありません。

7長い処理がタイマーを待たせる

0msのタイマーを予約し、約80msの連続処理を実行します。

(globalThis.order=[],setTimeout(()=>order.push("timer"),0)次のタスクとして予約,order.push("start"),busy(80)約80msの連続処理,order.push("work-end"),await pause(20)他のタスクが動く機会を作る,order)

start→work-end→timerの順です。0msは「今のJavaScriptを中断してすぐ実行する」という意味ではありません。busyは指定時間まで計算を続ける関数で、その間は同じスレッドの次のタスクへ進めません。

8Promiseだけでは次のタスクへ進まない

分割の間にPromise.resolveだけを挟みます。

(order=[],setTimeout(()=>order.push("timer"),0),await(async()=>{for(let i=1;i<=3;i++){busy(10)一回10msの処理;order.push("chunk"+i);await Promise.resolve()microtaskとして継続;}})(),await pause(20)最後に別タスクへ譲る,order)

三つのchunkが終わってからtimerが動きます。解決済みPromiseをawaitしても継続はmicrotaskで、次のタイマータスクより先に処理されます。awaitを置くだけで毎回描画や入力処理へ譲れるとは限りません。

9処理を分割して他のタスクに実行機会を渡す

今度は各chunkの間で0msタイマーを待ちます。

(order=[],setTimeout(()=>order.push("timer"),0),await(async()=>{for(let i=1;i<=3;i++){busy(10)同じ単位の処理;order.push("chunk"+i);await pause(0)別タスクへ処理を譲る;}})(),order)

chunk1の後にtimerが入り、その後でchunk2・chunk3が続きます。処理の合計量を減らしたわけではありませんが、途中で他のタスクが動く機会を作れました。分割単位が大きすぎれば待ち時間も残ります。

10一括処理と分割処理で結果を合わせる

1から6までの和を、一括と分割の二方式で計算します。

(globalThis.values=[1,2,3,4,5,6],globalThis.total=0,await(async()=>{for(let i=0;i<values.length;i+=2二件ずつ処理){total+=途中結果を保持values.slice(i,i+2).reduce((a,b)=>a+b,0);await pause(0)合間に他の処理へ譲る;}})(),{whole:values.reduce((a,b)=>a+b,0),chunked:total最終結果を比較})

両方とも21です。分割では途中の状態を保持して、同じ最終結果になるように組み立てます。合間に入力値が変わり得るなら、開始時の値を固定するか、取消しと再計算の扱いも決めます。

まとめ

画像の寸法を予約すると、読込み後の周囲の位置変化を抑えられます。

遅延読込みは取得時期の制御であり、領域の予約も合わせて行います。

microtaskの継続と別タスクへの譲渡は異なり、分割後も計算結果と途中状態を整合させます。

操作履歴と解説を振り返れます。

ブラウザ / 描画・実行記録

1
(globalThis.doc=await loadDocument("/performance"),globalThis.photo=doc.getElementById("photo"),globalThis.startY=rect("#caption").y,{image:rect("#photo"),captionY:startY})
{
  "image": {
    "x": 16,
    "y": 66.87,
    "width": 607.93,
    "height": 24,
    "offsetLeft": 16,
    "offsetTop": 67,
    "offsetWidth": 608,
    "offsetHeight": 24
  },
  "captionY": 106.86
}
2
(photo.src="/asset.svg?tag=unreserved",await photo.decode(),{image:rect("#photo"),captionY:rect("#caption").y,move:Math.round((rect("#caption").y-startY)*100)/100})
{
  "image": {
    "x": 16,
    "y": 66.87,
    "width": 199.99,
    "height": 100,
    "offsetLeft": 16,
    "offsetTop": 67,
    "offsetWidth": 200,
    "offsetHeight": 100
  },
  "captionY": 182.86,
  "move": 76
}
3
(globalThis.doc=await loadDocument("/performance"),globalThis.photo=doc.getElementById("photo"),photo.width=200,photo.height=100,globalThis.reservedY=rect("#caption").y,{image:rect("#photo"),captionY:reservedY})
{
  "image": {
    "x": 16,
    "y": 66.87,
    "width": 200,
    "height": 100,
    "offsetLeft": 16,
    "offsetTop": 67,
    "offsetWidth": 200,
    "offsetHeight": 100
  },
  "captionY": 182.86
}
4
(photo.src="/asset.svg?tag=reserved",await photo.decode(),{captionY:rect("#caption").y,move:Math.round((rect("#caption").y-reservedY)*100)/100})
{
  "captionY": 182.86,
  "move": 0
}
5
(globalThis.spacer=doc.createElement("div"),spacer.style.height="10000px",doc.body.append(spacer),globalThis.lazy=doc.createElement("img"),lazy.id="lazy",lazy.width=200,lazy.height=100,lazy.loading="lazy",lazy.alt="遅延画像",lazy.src="/asset.svg?tag=lazy",doc.body.append(lazy),await pause(50),{loading:lazy.loading,complete:lazy.complete,requested:(await(await fetch("/requests")).json()).tags.includes("lazy")})
{
  "loading": "lazy",
  "complete": false,
  "requested": false
}
6
(lazy.scrollIntoView(),await lazy.decode(),{complete:lazy.complete,width:lazy.naturalWidth,requested:(await(await fetch("/requests")).json()).tags.includes("lazy")})
{
  "complete": true,
  "width": 200,
  "requested": true
}
7
(globalThis.order=[],setTimeout(()=>order.push("timer"),0),order.push("start"),busy(80),order.push("work-end"),await pause(20),order)
[
  "start",
  "work-end",
  "timer"
]
8
(order=[],setTimeout(()=>order.push("timer"),0),await(async()=>{for(let i=1;i<=3;i++){busy(10);order.push("chunk"+i);await Promise.resolve();}})(),await pause(20),order)
[
  "chunk1",
  "chunk2",
  "chunk3",
  "timer"
]
9
(order=[],setTimeout(()=>order.push("timer"),0),await(async()=>{for(let i=1;i<=3;i++){busy(10);order.push("chunk"+i);await pause(0);}})(),order)
[
  "chunk1",
  "timer",
  "chunk2",
  "chunk3"
]
10
(globalThis.values=[1,2,3,4,5,6],globalThis.total=0,await(async()=>{for(let i=0;i<values.length;i+=2){total+=values.slice(i,i+2).reduce((a,b)=>a+b,0);await pause(0);}})(),{whole:values.reduce((a,b)=>a+b,0),chunked:total})
{
  "whole": 21,
  "chunked": 21
}

演習完了