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

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

webpack のコード分割の初歩

JavaScript や TypeScript を使ってウェブアプリを提供する場合、開発時はimportexportなどの ES Modules を使い、公開時はファイルをバンドルして公開することが多い。
以下の記事に書いたように、現在の主要なブラウザは ES Modules に対応してものの、バンドルせずに公開してしまうとパフォーマンスに悪影響を与える可能性がある。

numb86-tech.hatenablog.com

ファイル数が増えれば増えるほど影響は深刻になるため、依存関係が深いライブラリを使っている場合などは、レイテンシが飛躍的に増加してしまう。
そのため、バンドルせずに公開するのは現実的ではない。

バンドルしてひとつのファイルにまとめてしまえば、JavaScript のダウンロードは一度で済む。
しかしそうすると今度は、バンドルファイルの肥大化という問題が発生する。
巨大なファイルはダウンロードやパースに時間がかかるため、ユーザ体験を悪化させてしまう。

この問題を解決するために、主要なモジュールバンドラにはコードを分割するための機能が備わっている。
この機能を適切に使用することで、バンドルファイルのサイズを削減し、パフォーマンスを改善することができる。

この記事では、webpack でコード分割を行うにはどうすればよいのか、具体的にどのような形に分割されるのか、などを見ていく。

使用したライブラリのバージョンは以下の通り。

  • webpack@5.6.0
  • webpack-cli@4.2.0
  • clean-webpack-plugin@3.0.0
  • html-webpack-plugin@5.0.0-alpha.14
  • nodemon@2.0.6
  • react@17.0.1
  • react-dom@17.0.1
  • @babel/core@7.12.7
  • @babel/preset-react@7.12.7
  • babel-loader@8.2.1
  • webpack-bundle-analyzer@4.1.0
  • lodash@4.17.20

環境構築

まず、検証用の環境を準備する。

以下のpackage.jsonを用意してから$ yarnを実行して、必要なライブラリをインストールする。

{
  "name": "chunk",
  "license": "MIT",
  "scripts": {
    "start": "nodemon server.js",
    "build": "NODE_ENV=production webpack"
  },
  "devDependencies": {
    "clean-webpack-plugin": "^3.0.0",
    "html-webpack-plugin": "^5.0.0-alpha.14",
    "nodemon": "^2.0.6",
    "webpack": "^5.6.0",
    "webpack-cli": "^4.2.0"
  }
}

webpack.config.jsは以下のようにする。
この時点では、コード分割に関する設定は入れていない。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const {CleanWebpackPlugin} = require('clean-webpack-plugin');

const config = {
  entry: {
    index: './src/index.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
  },
  plugins: [
    new HtmlWebpackPlugin({
      scriptLoading: 'defer',
    }),
    new CleanWebpackPlugin(),
  ],
}

module.exports = config;

最後に、server.js
HTML ファイルは即時レスポンスを返すが、JavaScript ファイルについては3秒後にレスポンスを返す。
こうすることで、挙動を確認しやすくしている。

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

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

まずはこの状態で検証を行う。必要に応じて設定を追加、変更していく。

Dynamic Import によるコード分割

Dynamic Import を使うと、webpack が自動的にコードを分割してくれる。

まずは Dynamic Import を使わずにコードを書いてみる。

// src/a.js
const elem = document.createElement('div');
elem.textContent = 'execute a';
document.body.appendChild(elem);

export const a = 1;
// src/index.js
import {a} from './a.js';

const elem = document.createElement('button');
elem.textContent = 'click';
elem.addEventListener('click', () => {
  alert(a);
})

document.body.appendChild(elem);

この状態でビルド($ yarn build)すると、dist/index.bundle.jsが出力される。
このファイルは、src/index.jssrc/a.jsをバンドルしたものなので、読み込まれると両方のファイルの内容が実行される。

$ yarn startでサーバを起動して、確認してみる。
http://localhost:8080/にアクセスすると、以下のようになる。

f:id:numb_86:20201201050601g:plain

index.bundle.jsが実行されるとsrc/a.jsの内容も実行されるため、execute aが挿入される。

次に、Dynamic Import を使った形に書き換えてみる。
Dynamic Import の基本的な使い方については、以下に書いた。

numb86-tech.hatenablog.com

src/index.jssrc/a.jsimportしているが、そこで使われているaという値は、ボタンが押下されたときに必要になる。
そのため、初期読み込み時にはimportが不要であり、Dynamic Import を使うことができる。
以下のコードでは、ボタンが押下された時に動的にsrc/a.jsを読み込むようにしている。

// src/index.js
const elem = document.createElement('button');
elem.textContent = 'click';
elem.addEventListener('click', () => {
  import('./a.js').then(res => {
    alert(res.a);
  })
})

document.body.appendChild(elem);

この状態でビルドすると、index.bundle.jsの他に、85.bundle.jsが生成されている。
これが、コード分割である。ふたつのバンドルファイルに分割されている。

挙動は以下のようになる。

f:id:numb_86:20201201050650g:plain

HTML ファイルのhead要素に<script defer="defer" src="/index.bundle.js"></script>があるのでindex.bundle.jsが読み込まれるが、このファイルにはsrc/a.jsの内容は含まれていないため、execute aは挿入されない。
ボタンを押下したタイミングで85.bundle.jsのダウンロードが始まり、その後ファイルが実行され、execute aが挿入される。
より具体的には、ボタンを押下するとhead要素にscript要素が追加される。そして読み込みが終わると、追加されたscript要素は削除される。

f:id:numb_86:20201201050742g:plain

ダウンロードは一度のみ行われるので、ボタンを複数回押下しても重複してダウンロードされることはない。

今回は JavaScript のダウンロードに3秒かかるようにしているので却って使い勝手が悪くなったが、コード分割を上手く使うことで、「ページロード時は必要最低限の JavaScript コードだけを読み込み、それ以外のコードは必要になったタイミングで読み込む」ということが可能になる。

prefetch

先程の例では、ボタンを押下したタイミングで85.bundle.jsのダウンロードが開始されるため、ボタンを押下してからアラートが表示されるまで、必ず3秒以上かかってしまう。
prefetchを使うことで、この問題を解決できる。

/* webpackPrefetch: true*/というコメントを追加することで、prefetchが有効になる。

 const elem = document.createElement('button');
 elem.textContent = 'click';
 elem.addEventListener('click', () => {
-  import('./a.js').then(res => {
+  import(/* webpackPrefetch: true*/ './a.js').then(res => {
     alert(res.a);
   })
 })

index.bundle.jsのダウンロードと実行が終わったタイミングで<link rel="prefetch" as="script" href="/85.bundle.js">が追加され、85.bundle.jsのダウンロードが開始される。
ページの初期化に必要なindex.bundle.jsの実行をまず行い、それが終わったら85.bundle.jsをダウンロードしておく。
こうすることで、ページの読み込みを最適化しつつ、ボタンを押下した時の待ち時間を短縮できる。

事前に行われるのはダウンロードのみで実行はされない。ボタンを押下したタイミングで、ファイルが実行されてexecute aが挿入される。

f:id:numb_86:20201201050840g:plain

分割されたファイルに名前をつける

デフォルトだと、先程の85.bundle.jsのように数字がファイル名になる。
このままだと分かりづらいので、設定を変えていく。

まず、webpack.config.jsoptimization.chunkIds'named'にすると、パスに基づいて自動的に名前がつけられる。

     }),
     new CleanWebpackPlugin(),
   ],
+  optimization: {
+    chunkIds: 'named'
+  }
 }

今回の例の場合、output.filenameの設定と合わさって、src_a_js.bundle.jsという名前になる。

通常のバンドルファイルとは別のルールを適用させたい場合は、output.chunkFilenameを使う。
以下のようにすると、src_a_js.foo.jsという名前になる。

   },
   output: {
     filename: '[name].bundle.js',
+    chunkFilename: '[name].foo.js',
     path: path.resolve(__dirname, 'dist'),
     publicPath: '/',
   },

chunkFilenameには関数を渡すことも可能。
以下の場合はsrc~a~js.async.jsという名前になる。

   },
   output: {
     filename: '[name].bundle.js',
-    chunkFilename: '[name].foo.js',
+    chunkFilename: (pathData) => {
+      return pathData.chunk.id.replace(/_/g, '~') + '.async.js';
+    },
     path: path.resolve(__dirname, 'dist'),
     publicPath: '/',
   },

Dynamic Import にwebpackChunkNameというコメントを書くことで、個別に名前をつけることもできる。
この場合、optimization.chunkIdsの設定は無視される。
以下のようにすると、先程のchunkFilenameの設定と合わさってx~y-z.async.jsという名前になる。

import(/* webpackChunkName: 'x_y-z', webpackPrefetch: true*/ './a.js')

ライブラリのコードを別のファイルに分割する

エントリポイントが複数ありそれぞれで同じライブラリを使っていた場合、重複してバンドルされ、バンドルファイルが肥大化してしまう。
コード分割は、これを回避するためにも使われる。

以降は、React アプリを作って動作確認していく。

まず、package.jsonを以下の内容にする。
必要なライブラリをインストールする他、buildコマンドに--analyzeオプションをつけてバンドル結果を見れるようにした。

{
  "name": "chunk",
  "license": "MIT",
  "scripts": {
    "start": "nodemon server.js",
    "build": "NODE_ENV=production webpack --analyze"
  },
  "devDependencies": {
    "@babel/core": "^7.12.7",
    "@babel/preset-react": "^7.12.7",
    "babel-loader": "^8.2.1",
    "clean-webpack-plugin": "^3.0.0",
    "html-webpack-plugin": "^5.0.0-alpha.14",
    "nodemon": "^2.0.6",
    "webpack": "^5.6.0",
    "webpack-bundle-analyzer": "^4.1.0",
    "webpack-cli": "^4.2.0"
  },
  "dependencies": {
    "react": "^17.0.1",
    "react-dom": "^17.0.1"
  }
}

次に、webpack.config.js
JavaScript ファイルを Babel で処理するようにした他、エントリポイントにanotherを追加した。

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const {CleanWebpackPlugin} = require('clean-webpack-plugin');

const config = {
  entry: {
    index: './src/index.js',
    another: './src/another.js',
  },
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
    publicPath: '/',
  },
  module: {
    rules: [{test: /\.js$/, exclude: /node_modules/, loader: 'babel-loader'}],
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.html',
      chunks: ['index'],
      scriptLoading: 'defer',
    }),
    new HtmlWebpackPlugin({
      filename: 'another.html',
      chunks: ['another'],
      scriptLoading: 'defer',
    }),
    new CleanWebpackPlugin(),
  ],
  optimization: {
    chunkIds: 'named'
  }
}

module.exports = config;

babel.config.jsは以下のようにした。

module.exports = {
  presets: [
    [
      '@babel/preset-react',
      {
        development: process.env.NODE_ENV === 'development',
        'runtime': 'automatic'
      }
    ]
  ]
};

最後に、server.jsに以下の変更を加えて、/anotherのルーティングを追加する。

         res.end();
       })
       break;
+    case /^\/another$/.test(req.url):
+      fs.readFile('./dist/another.html', 'utf-8', (err, data) => {
+        res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'});
+        res.write(data);
+        res.end();
+      })
+      break;
     case /\.js$/.test(req.url):
       fs.readFile(`./dist${req.url}`, 'utf-8', (err, data) => {
         setTimeout(() => {

環境は整ったので、早速コードを書いていく。

// src/index.js
import {render} from 'react-dom';

const App = () =>
  <div>
    index<br />
    <a href="/another">to another</a>
  </div>;

render(<App />, document.body);
// src/another.js
import {render} from 'react-dom';

const App = () =>
  <div>
    another<br />
    <a href="/">to index</a>
  </div>;

render(<App />, document.body);

この状態でビルドするとindex.bundle.jsanother.bundle.jsが出力されるが、両方にreactreact-domが含まれてしまい、無駄が生まれている。

f:id:numb_86:20201201050056p:plain

http://localhost:8080/からhttp://localhost:8080/anotherに遷移した場合、ライブラリのコードが二重に読み込まれることになる。

webpack.config.jsoptimization.splitChunks.chunk'all'に設定すると、ライブラリのコードを自動的に分割してくれるようになる。

     new CleanWebpackPlugin(),
   ],
   optimization: {
-    chunkIds: 'named'
+    chunkIds: 'named',
+    splitChunks: {
+      chunks: 'all',
+    },
   }
 }

これでビルドした結果が以下。

f:id:numb_86:20201201050048p:plain

vendors-node_modules_react-dom_index_js-node_modules_react_jsx-runtime_js.bundle.jsが生成され、ライブラリのコードはそこに含まれている。
index.bundle.jsanother.bundle.jsからはライブラリのコードが取り除かれているため、重複が解消されている。
ライブラリのバンドルコードをキャッシュさせるなどすれば、パフォーマンスの向上が見込める。

なお、この設定を行うと、エントリポイントがひとつしかない場合でも、ライブラリのコードを分割してくれる。

HTML Webpack Plugin はコード分割に対応しているため、自動的に以下のscript要素をindex.htmlに挿入してくれる。
分割したファイルを手動で読み込ませる必要はない。

<script defer="defer" src="/vendors-node_modules_react-dom_index_js-node_modules_react_jsx-runtime_js.bundle.js"></script>
<script defer="defer" src="/index.bundle.js"></script>

名前付け

optimization.splitChunks.nameで、ファイル名を指定できる。
以下ではfooと指定しているので、output.filenameの設定と組み合わさり、foo.bundle.jsという名前になる。

  optimization: {
    chunkIds: 'named',
    splitChunks: {
      chunks: 'all',
      name: 'foo',
    },
  }

以降の例では、optimization.splitChunks.nameは何も設定せずにビルドしている。

使用状況に応じたコードの分割

先程の例では、両方のエントリポイントが同じライブラリに依存していた。
では、片方のエントリポイントのみが依存しているライブラリがある場合は、どうなるのか。

lodashをインストールし、それをsrc/index.jsでのみ使うようにすることで、確認してみる。

import {render} from 'react-dom';
import _ from 'lodash';

const App = () =>
  <div>
    another<br />
    {`${_.join(['Hello', 'webpack'], '-')}`}<br />
    <a href="/">to index</a>
  </div>;

render(<App />, document.body);

この状態でビルドすると、vendors-node_modules_react-dom_index_js-node_modules_react_jsx-runtime_js.bundle.jsの他に、vendors-node_modules_lodash_lodash_js.bundle.jsが生成される。

f:id:numb_86:20201201050028p:plain

後者は、http://localhost:8080/にアクセスしたときにのみ、読み込まれる。

<!-- http://localhost:8080/ -->
<script defer="defer" src="/vendors-node_modules_react-dom_index_js-node_modules_react_jsx-runtime_js.bundle.js"></script>
<script defer="defer" src="/vendors-node_modules_lodash_lodash_js.bundle.js"></script>
<script defer="defer" src="/index.bundle.js"></script>
<!-- http://localhost:8080/another -->
<script defer="defer" src="/vendors-node_modules_react-dom_index_js-node_modules_react_jsx-runtime_js.bundle.js"></script>
<script defer="defer" src="/another.bundle.js"></script>

このように、webpack が最適な形で分割してくれる。

cacheGropes.***.test で、分割するライブラリを指定する

再びエントリポイントをsrc/index.jsのみにして、ビルドしてみる。
そうすると、index.bundle.jsと、全てのライブラリのコードを含むバンドルファイルが、生成される。
この挙動は既に説明した通りである。

f:id:numb_86:20201201050018p:plain

cacheGropes.***.testを使うと、ライブラリを一緒くたにまとめてしまうのではなく、指定したライブラリだけを分割することが可能になる。

例えば、react-domlodashを分割したい場合は、以下のように書く。
パスの区切り文字を[\\/]にしているのは、クロスプラットフォームに対応するため。

  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        reactDom: {
          test: /[\\/]node_modules[\\/]react-dom[\\/]/,
          name: 'react_dom',
        },
        lodash: {
          test: /[\\/]node_modules[\\/]lodash[\\/]/,
          name: 'lodash',
        }
      }
    },
  }

この状態でビルドすると、index.bundle.jsの他に、react_dom.bundle.jslodash.bundle.jsが生成される。

f:id:numb_86:20201201045923p:plain

react-domlodashだけがそれぞれに分割され、それ以外のライブラリはindex.bundle.jsに含まれている。

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

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

なお、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

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