30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

ブラウザのレンダリングとスタイルシートについて

スタイルシートのダウンロードや実行が終わるまで、ブラウザはレンダリングをブロックする。
そのため、スタイルシートの配信やダウンロードを最適化することは、ウェブサイトのパフォーマンス向上につながる。
この記事では、スタイルシートのダウンロードがレンダリングにどのような影響を与えるのかを見ていく。

なお、scriptタグとレンダリングの関係については、以下の記事にまとめてある。

numb86-tech.hatenablog.com

本記事の内容は、サーバは Node.js のv14.13.0、クライアントは Google Chrome のv86.0.4240.111で動作確認している。

スタイルシートによる、レンダリングのブロック

まず、以下の 3 つの HTML を用意する。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="/base.css">
</head>
<body>
  <div>
    <a href="/">top</a><br>
    <a href="/red">red</a><br>
    <a href="/blue">blue</a><br>
    This page is top.
  </div>
  <hr>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="/base.css">
</head>
<body>
  <div>
    <a href="/">top</a><br>
    <a href="/red">red</a><br>
    <a href="/blue">blue</a><br>
    This page is red.
  </div>
  <hr>
  <p id="abc">abc</p>
  <p id="xyz">xyz</p>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="/base.css">
</head>
<body>
  <div>
    <a href="/">top</a><br>
    <a href="/red">red</a><br>
    <a href="/blue">blue</a><br>
    This page is blue.
  </div>
  <hr>
  <p id="abc">abc</p>
  <p id="xyz">xyz</p>
</body>
</html>

base.cssは取り敢えず、空のファイルにしておく。つまり、スタイルに影響を与えない。

これら 4 つのファイルを、以下のコードで配信する。

const http = require('http');
const fs = require('fs');

http.createServer((req, res) => {
  switch(true) {
    case /^\/$/.test(req.url):
      fs.readFile('./index.html', 'utf-8', (err, data) => {
        res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
        res.write(data);
        res.end();
      })
      break;
    case /^\/red$/.test(req.url):
      fs.readFile('./red.html', 'utf-8', (err, data) => {
        res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
        res.write(data);
        res.end();
      })
      break;
    case /^\/blue$/.test(req.url):
      fs.readFile('./blue.html', 'utf-8', (err, data) => {
        res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
        res.write(data);
        res.end();
      })
      break;
    case /^\/base\.css$/.test(req.url):
      fs.readFile('./base.css', 'utf-8', (err, data) => {
        setTimeout(() => {
          res.writeHead(200, {'Content-Type': 'text/css'});
          res.write(data);
          res.end();
        }, 3000);
      })
      break;
    default:
      res.writeHead(404);
      res.end();
  };
}).listen(8080);

HTML ファイルは即座に返すが、base.css3秒かかるようにしてある。

こうすると、HTML ファイルの表示に3秒以上かかるようになる。
つまり、link要素で読み込んでいるスタイルシートのダウンロードが終わらないと、レンダリングが行われないということである。

f:id:numb_86:20201104142710g:plain

レンダリングはブロックされるが、後続のスタイルシートのダウンロードはブロックされない。
以下のようなケースで、base.cssのダウンロードに3秒かかり、1.cssのダウンロードに1秒かかるとする。
その場合、base.cssのダウンロード開始の直後に1.cssのダウンロードが始まる。base.cssのダウンロードが終わるまで1.cssのダウンロードが始まらない、ということにはならない。

<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="/base.css">
  <link rel="stylesheet" href="/1.css">
</head>

但し、全てのスタイルシートのダウンロードが終わらないとレンダリングは行われないので、最低でも3秒経過しないと(base.cssのダウンロードが終わらないと)、レンダリングは行われない。

media 属性を正しく使ってレンダリング速度を改善する

link要素にはmedia属性を指定することが可能で、これを使うことで、スタイルを適用する対象を指定できる。
例えばmedia="print"とすると、そのスタイルシートは印刷時にのみ適用される。

以下のbase.css2.cssを用意した上で、それをblue.htmlで読み込ませてみる。

/* base.css */
a {
  color: deepskyblue;
}
/* 2.css */
p {
  color: blue;
}
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="/base.css">
  <link rel="stylesheet" href="/2.css" media="print">
</head>

そうすると、2.cssの内容は画面上には反映されず、印刷時にのみ反映される。

f:id:numb_86:20201104142958p:plain

f:id:numb_86:20201104143012p:plain

そして、画面上に影響を与えないことが分かっているスタイルシートは、レンダリングをブロックしない。
例えば、base.cssのダウンロードに2秒、2.cssのダウンロードに5秒かかる場合は、2.cssのダウンロードを待たず、base.cssをダウンロードした時点でレンダリングが行われる。

f:id:numb_86:20201104142756g:plain

スタイルシートから参照された他のリソースについて

@importを使うと他のスタイルシートを参照することができる。
その場合、参照されたスタイルシートのダウンロードも、レンダリングをブロックすることになる。

以下のスタイルシートをlink要素で読み込んでいる場合、まずbase.cssがダウンロードされ、それが終わると1.cssがダウンロードされる。
そして、1.cssのダウンロードが完了するまで、レンダリングは行われない。

/* base.css */
@import url('/1.css');

画像の参照については、レンダリングをブロックしない。そのため画像のダウンロードに時間がかかる場合、画像が反映される前のコンテンツが表示されてしまう可能性がある。

以下のbase.cssを HTML で読み込んだ場合、2.cssのダウンロードが終わった時点で、レンダリングと1.pngのダウンロードが開始される。
1.pngのダウンロードに1秒かかるようにしてあるため、その間は、背景画像がない状態で表示される。

/* base.css */
@import url('/2.css');

a {
  color: deepskyblue;
}

div {
  background-image: url('/1.png');
}
/* 2.css */
p {
  color: blue;
}

f:id:numb_86:20201104143242g:plain

処理の流れは以下の通り。

  1. HTML ファイルに書かれたlink要素に基づいてbase.cssのダウンロードを開始する
  2. base.cssのダウンロード完了後、2.cssのダウンロードを開始する
  3. 2.cssのダウンロード完了後、1.pngのダウンロードを開始する
  4. それと同時に、レンダリングを開始する
  5. 1.pngのダウンロードが完了し、画面に反映される

script 要素と DOMContentLoaded イベント

DOMContentLoadedという、パースが完了したときに発生するイベントがある。
このイベントの発生タイミングにも、スタイルシートは影響を与える。さらに、script要素の有無によっても、挙動が変化する。

script タグがない場合

HTML のパースが終わった時点で、DOMContentLoadedが発生する。スタイルシートのダウンロードが終わっていなくても関係ない。
但し画面のレンダリングについては、既述したようにスタイルシートのダウンロードが終わってから行われる。

<head><script><link></head>

head要素内にscript要素とlink要素があり、scriptが先にある場合。
スクリプトのダウンロードと実行が終わった時点で、DOMContentLoadedが発生する。スタイルシートのダウンロードが終わっているかどうかには、左右されない。
レンダリングは、両方のダウンロードと実行が終わった段階で行われる。

<head><link><script></head>

先程とは逆に、head要素のなかでlink要素が先にある場合。
必ず、スタイルシートのダウンロードが終わってから、スクリプトを実行する。スクリプトのダウンロードが先に終わっていた場合は、実行を保留する。
そしてスクリプトの実行が終わってからDOMContentLoadedが発生し、レンダリングもそのタイミングで行われる。
スタイルシートのダウンロードが先に終わっていたとしてもレンダリングは行われず、必ず、スクリプトの実行が完了するのを待つ。

<head><link></head><body><script></body>

head要素にはscript要素がなく、body要素のなかにある場合。
スタイルシートのダウンロードが先に終わった場合は、その時点でレンダリングが行われる。その後、スクリプトのダウンロードと実行が終わった時点で、DOMContentLoadedが発生する。
スクリプトのダウンロードが先に終わった場合は、スタイルシートのダウンロードが終わるまで、実行を保留する。スタイルシートのダウンロードが終わった段階でスクリプトを実行し、実行完了のタイミングでDOMContentLoadedやレンダリングが発生する。

スタイルシートの非同期読み込み

スタイルシートを非同期に読み込ませることで、レンダリングのブロックを防ぐことができる。
しかし注意しないと、スタイルが適用される前のコンテンツが表示されてしまう可能性がある。

非同期読み込みの方法はいくつがある。
例えば、以下のように JavaScript で動的にlink要素を追加すると、非同期で読み込まれるようになる。

const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/1.css';
document.head.appendChild(link);

1.cssのダウンロードに時間がかかる場合、その間、1.cssで指定されているスタイルは適用されない。
状況によっては、JavaScript のダウンロードや実行を待っている間も、スタイルが適用されていないコンテンツが表示される可能性がある。

以下は、script.jsのダウンロードに4秒、そしてscript.js内の「link要素追加」のコードが実行されるまでに3秒かかり、1.cssのダウンロードに2秒かかる場合の実行結果である。

f:id:numb_86:20201104143323g:plain

base.cssのダウンロードは1秒で終わるので、その時点でレンダリングが行われる。
そこから、1.cssのダウンロードが終わるまで、8秒かかる。その間はスタイルが適用されていない状態のp要素が表示されてしまう。

以下のようにpreloadを使うことでも非同期に読み込めるが、やはり同様の問題は発生する。

<link rel="preload" href="/2.css" as="style" onload="this.onload=null; this.rel='stylesheet'">

f:id:numb_86:20201104143420g:plain

そのため、非同期読み込みを利用する場合は、非同期に読み込ませるべきスタイルシートとそうでないスタイルシートを適切に区別する必要がある。

requestIdleCallback を使って優先度の低い処理を後回しにする

ブラウザ内部で行われる処理は、基本的にシングルスレッドで行われる。
そのため、処理に時間がかかるタスクがひとつあると、後続のタスクの処理がその分だけ遅れることになる。
この「タスク」には、JavaScript の実行も含まれるし、画面の描画、ユーザーの操作への対応といった UI に関する処理も含まれる。
そのため、JavaScript の処理に時間が掛かってしまい UI の更新が遅れてしまう、ということが起こり得る。

もしその JavaScript の処理の優先度が高くないのなら、まずは UI の更新を行い、ブラウザに余裕が生まれたタイミングで JavaScript を処理することが望ましい。
そういったことを可能にするのが、requestIdleCallbackという API である。
この API を使って優先度の低い処理を後回しにすることで、画面の描画などユーザー体験に直結する処理を優先させることができる。

この記事の内容は Google Chrome の86.0.4240.111で動作確認している。

基本的な使い方

後回しにしたい処理をrequestIdleCallbackのコールバック関数として渡すと、そのコールバック関数は即座には実行されず、ブラウザがアイドル状態になったときに実行される。

以下のコードを実行すると、3秒後にdone!がログに流れ、その直後にfirstがログに流れる。

function heavy(ms, message) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
  console.log(message || 'done!');
}

heavy(3000);
console.log('first');

heavy(3000)の処理に時間が掛かっているため、その分だけconsole.log('first)が遅れている。

これを、requestIdleCallbackを使った形に書き換える。
以下のようにheavy(3000)の実行をrequestIdleCallbackのコールバック関数に設定すると、その処理は後回しにされ、まずconsole.log('first)が実行される。
そしてその後heavy(3000)が実行される。

function heavy(ms, message) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
  console.log(message || 'done!');
}

requestIdleCallback(() => heavy(3000));
console.log('first');

requestIdleCallbackは ID を返し、それをcancelIdleCallbackに渡すことで、コールバック関数の呼び出しがキャンセルされる。
以下のコードを実行してもdone!は表示されない。

function heavy(ms, message) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
  console.log(message || 'done!');
}

const id = requestIdleCallback(() => heavy(3000));
console.log(id); // 1
cancelIdleCallback(id);

もう少し実践的な例を示す。

以下のコードでは、ボタンを押下するとまずrenderを実行し、その後heavy(3000)を実行する。
heavy(3000)は時間のかかる処理であるため、これが終わるまで画面の更新が行われず、表示が遅れてしまう。

function render() {
  const paragraph = document.createElement('p');
  paragraph.textContent = 'fizz';
  document.body.appendChild(paragraph);
}

function heavy(ms, message) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
  console.log(message || 'done!');
}

function onClick() {
  render();
  heavy(3000);
}

const start = document.querySelector('#start');
start.addEventListener('click', () => {
  onClick();
});

f:id:numb_86:20201029230937g:plain

もしheavy(3000)が優先度の低い処理なら、requestIdleCallbackに渡すことができる。
そうすると画面の更新が優先的に行われるため、遅延なく表示される。

function render() {
  const paragraph = document.createElement('p');
  paragraph.textContent = 'fizz';
  document.body.appendChild(paragraph);
}

function heavy(ms, message) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
  console.log(message || 'done!');
}

function onClick() {
  render();
  requestIdleCallback(() => heavy(3000));
}

const start = document.querySelector('#start');
start.addEventListener('click', () => {
  onClick();
});

f:id:numb_86:20201029231017g:plain

IdleDeadline インスタンス

requestIdleCallbackに渡されたコールバック関数は、引数としてIdleDeadlineインスタンスを受け取る。
このインスタンスは、didTimeoutプロパティとtimeRemainingメソッドを持つ。

requestIdleCallback((deadline) => {
  console.log(deadline.constructor.name); // IdleDeadline
  console.log(Object.keys(Object.getPrototypeOf(deadline))); // ["didTimeout", "timeRemaining"]
});

didTimeout

requestIdleCallbackには第二引数としてオプションを渡すことが可能で、そこでタイムアウトを設定できる。

requestIdleCallback(() => {}, {timeout: 2000});

だがこのタイムアウトは、その時間を経過したら実行する、あるいはキャンセルする、といった類のものではない。
requestIdleCallbackに渡されたコールバック関数は、ブラウザがアイドル状態になったときに呼び出されるのであり、タイムアウトは関係ない。

以下の例ではコールバック関数のタイムアウトは2000ミリ秒になっているが、その経過を待たず、heavy(100)の処理が終わった時点でコールバック関数が呼び出される。

function heavy(ms, message) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
  console.log(message || 'done!');
}

requestIdleCallback(
  () => {
    console.log('callback');
  },
  {timeout: 2000}
);

heavy(100);

タイムアウトしたかどうかで処理の内容を変えたいときに使うのが、didTimeoutプロパティである。

以下の例では、まずコールバック関数が登録され、その後heavy(2000)による2000ミリ秒の待機時間のあと、コールバック関数が呼び出される。
timeoutとして設定した1000ミリ秒よりもあとに呼び出されているため、didTimeouttrueになる。

function heavy(ms, message) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
  console.log(message || 'done!');
}

requestIdleCallback(
  (deadline) => {
    console.log(deadline.didTimeout); // true
  },
  {timeout: 1000}
);

heavy(2000);

didTimeoutを正しく機能させるためには記述する順番が重要で、この例だとheavyrequestIdleCallbackより先に記述すると、didTimeoutは必ずfalseになってしまう。

function heavy(ms, message) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
  console.log(message || 'done!');
}

heavy(2000);

requestIdleCallback(
  (deadline) => {
    console.log(deadline.didTimeout); // false
  },
  {timeout: 1000}
);

また、timeoutオプションを設定しなかった場合も、didTimeoutは常にfalseになる。

タイムアウトは、複数のrequestIdleCallbackが実行されているときの処理の順番に影響を与える。
ブラウザがアイドル状態になったとき、既にタイムアウトになっているコールバック関数があった場合、それを優先的に処理する。
そして、タイムアウトしているものが複数あった場合、より大きくタイムアウトしているコールバック関数の処理を優先する。
そのため以下の場合、1よりも23が優先され、2よりも3が優先される。

function heavy(ms, message) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
  console.log(message || 'done!');
}

requestIdleCallback((deadline) => {
  console.log(1, deadline.didTimeout);
});

requestIdleCallback(
  (deadline) => {
    console.log(2, deadline.didTimeout);
  },
  {timeout: 3000}
);

requestIdleCallback(
  (deadline) => {
    console.log(3, deadline.didTimeout);
  },
  {timeout: 2000}
);

heavy(5000);

// done!
// 3 true
// 2 true
// 1 false

タイムアウトしていないコールバック関数が複数ある場合は、requestIdleCallbackが実行された順番に呼び出される。

function heavy(ms, message) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
  console.log(message || 'done!');
}

requestIdleCallback(
  (deadline) => {
    console.log(1, deadline.didTimeout);
  },
  {timeout: 3000}
);

requestIdleCallback((deadline) => {
  console.log(2, deadline.didTimeout);
});

requestIdleCallback(
  (deadline) => {
    console.log(3, deadline.didTimeout);
  },
  {timeout: 2000}
);

heavy(1000);

// done!
// 1 false
// 2 false
// 3 false

timeRemaining

timeRemainingメソッドは、アイドル状態の残り時間を返す。
例えば30が返ってきた場合、あと30ミリ秒はアイドル状態であることが期待できる。そのため、その時間内に収まる処理であるなら、後続のブラウザの処理に影響を与えずに実行できる。
逆に、30ミリ秒を超過する処理を行う場合、その間にアイドル状態が終了し、後続の処理を遅延させてしまう可能性がある。
そのため、timeRemainingで残り時間をチェックし、もう時間がない場合は残りの作業を先送りにすることが望ましい。

なお、didTimeouttrueの場合、timeRemainingは必ず0を返す。

マイクロタスクに分割して利用すべき

先程述べたように、requestIdleCallbackに渡す処理が時間のかかるものである場合、結局、後続の処理を遅延させてしまう可能性がある。
そのため、可能な限りタスクを分割してからrequestIdleCallbackを利用したほうがよい。

これも、例を出して見ていく。

以下のコードで、startボタンを 2 回押下したときの挙動を見てみる。

function render() {
  const paragraph = document.createElement('p');
  paragraph.textContent = 'fizz';
  document.body.appendChild(paragraph);
}

function heavy(ms, message) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
  console.log(message || 'done!');
}

let count = 0;
function onClick() {
  count += 1;
  render();
  requestIdleCallback(() => heavy(3000, count));
}

const start = document.querySelector('#start');
start.addEventListener('click', () => {
  onClick();
});

f:id:numb_86:20201029232240g:plain

requestIdleCallbackを使っているので初回のfizzの表示はスムーズである。だが 2 つめのfizzの表示に時間が掛かってしまっている。
これは、2 回目の押下の前に() => heavy(3000, 1)が呼び出されており、これが終わるまでは画面の更新が行われないためである。
そしてこの処理は3秒かかるため、画面の更新もその分だけ遅延してしまう。

これを(少しだけ)改善したのが、以下のコード。
heavy(3000)を、6 つのheavy(500)に分割している。そしてtimeRemainingメソッドを使い、残り時間がなくなった場合は残りのheavy(500)の処理を先送りしている。

function render() {
  const paragraph = document.createElement('p');
  paragraph.textContent = 'fizz';
  document.body.appendChild(paragraph);
}

function heavy(ms, message) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
  console.log(message || 'done!');
}

function runTasks(taskList) {
  requestIdleCallback((deadline) => {
    // 残り時間があるときにのみ、タスクを処理する
    while (taskList.length > 0 && deadline.timeRemaining() > 0) {
      const task = taskList.shift();
      task();
    }

    // まだタスクが残っている場合は、再び runTasks を実行する
    if (taskList.length > 0) {
      runTasks(taskList);
    }
  });
}

let count = 0;
function onClick() {
  render();
  const currentCount = count + 1;
  count += 1;
  const taskList = [
    () => heavy(500, `${currentCount}-1`),
    () => heavy(500, `${currentCount}-2`),
    () => heavy(500, `${currentCount}-3`),
    () => heavy(500, `${currentCount}-4`),
    () => heavy(500, `${currentCount}-5`),
    () => heavy(500, `${currentCount}-6`),
  ];
  runTasks(taskList);
}

const start = document.querySelector('#start');
start.addEventListener('click', () => {
  onClick();
});

f:id:numb_86:20201029232505g:plain

500ミリ秒という単位で処理を行っているため、画面の更新の遅延も、約500ミリ秒で済むようになっている。

Google Chrome のデベロッパーツールでスクリプト処理を確認してみると、Fire Idle Callbackの合間にフレームの更新が行われている。
そして、Fire Idle Callbackが細かく分割されていることで、2 つめのfizzの反映が早くなっていることが分かる。

改善前。

f:id:numb_86:20201029232553p:plain

改善後。

f:id:numb_86:20201029232607p:plain

とはいえ、これでも目に見えて画面の更新が遅延している。
今回はサンプルとしての分かりやすさのためにこのようにしたが、実際にrequestIdleCallbackを使うときは、もっと細かくタスクを分割することが望ましい。

DOM の更新には requestAnimationFrame を使う

DOM の更新はrequestIdleCallbackではなく、requestAnimationFrameを使う。
requestAnimationFrameは、次回のレンダリングのタイミングに合わせて、ブラウザが最適なタイミングで呼び出してくれる。

参考までに、両者の挙動の違いを見てみる。

まず、requestIdleCallbackのなかで DOM を更新してみる。

以下のコードでボタンを 2 回押下すると、fizz buzz fizz buzzの順で表示される。

function renderFizz() {
  const paragraph = document.createElement('p');
  paragraph.textContent = 'fizz';
  document.body.appendChild(paragraph);
}

function renderBuzz() {
  const paragraph = document.createElement('p');
  paragraph.textContent = 'buzz';
  document.body.appendChild(paragraph);
}

function heavy(ms, message) {
  const startTime = performance.now();
  while (performance.now() - startTime < ms);
  console.log(message || 'done!');
}

function onClick() {
  renderFizz();
  requestIdleCallback(() => {
    renderBuzz();
    heavy(1000);
  });
}

const start = document.querySelector('#start');
start.addEventListener('click', () => {
  onClick();
});

f:id:numb_86:20201029233008g:plain

次に、requestAnimationFrameのなかで DOM の更新を行う。

@@ -19,7 +19,7 @@
 function onClick() {
   renderFizz();
   requestIdleCallback(() => {
-      renderBuzz();
+     requestAnimationFrame(() => renderBuzz());
     heavy(1000);
   });
 }

すると、fizz fizz buzz buzzという順番になる。

f:id:numb_86:20201029233032g:plain

requestAnimationFrameを使わなかった場合、2 回目の押下の前にrenderBuzz()が実行されるため、最初のfizzの下にbuzzが追加される。
requestAnimationFrameを使った場合、次回のレンダリング更新の前にrenderBuzz()が実行される。そして、次回レンダリングがheavy(1000)によって遅延している間に 2 回目の押下があったため、renderBuzz()よりも先に 2回目のrenderFizz()実行される。そのため、最初のfizzの下にfizzが追加される。

参考資料