348
Help us understand the problem. What are the problem?

posted at

VueもReactもやったことないのでVanilla JSでやってみたSPA

はじめに

まずは宣伝です。
このたび保育園を地図から探せる 保育園マップ というサービスを作りました。

Vanilla JSのSPAで作ったのですが、思いの外色んなことをやる必要があったので、制作過程で得た知見をこの記事にまとめました。
網羅的に書いたので長いですが、一つ一つのトピックはそれ程長くないので、興味があるところだけ読んでもらってもいいと思います。

Vanilla JS & SPAとは?

Vanilla(バニラ) JSというのは何もフレームワークを使っていない素のJavaScriptのことです。
ただのJavaScriptなんですが、ジョークでフレームワーク風の公式サイト?っぽいものがあったりします。

SPAというのはシングルページアプリケーションの略です。
ページごとにHTMLを用意するのではなく、1つのHTMLの中でJavaScriptにより表示内容を切り替えるアプリケーションです。

サンプルアプリケーション

先程紹介した保育園マップはソースを公開できないので、参考のため別途簡単なサンプルアプリケーションを作りました。
記事に書いてるコードは基本的にサンプルに入ってるので興味があればご覧ください。

ReactとVue.js

npmのダウンロード数などを見ると、最近WEBフロント開発のフレームワークではReactとVueが流行りのようです。
どっちもやったことはないんですが、今WEBフロントの開発をやるならこれらを使えばよく、わざわざバニラでやる必要はありません。

ちなみにReactとVueどちらがいいかというと、私はどちらでもいいと思います(笑)

いや、それにはちゃんと理由があって、React、Vueともにドキュメントを読んだり、ネットの評判を見たり、チュートリアルを触ったり、小さなサンプルを作ってみたりしたのですが、その程度の知識では甲乙つけられませんでした。
React vs Vueに白黒つける記事もいくつか見かけましたが、それらの記事が正しいか判断することも、知識の少なさから難しいと感じました。
なので適当に選んで、とりあえずやってみるのが良いのかなと。

今回はVanilla JSなのでどちらも使いませんが、ReactとVueはこの後も比較対象として何度か話題に出てきます。

不人気なAngular

一時期はWEBフロントのフレームワークと言えば、AngularとReactのツートップという印象でしたが、npmパッケージのダウンロード数などを見ると、今ではReactに大きく差をつけられたように見えます。

Angularは昔少し使ったことがありますが(初代AngularJSとAngular2)、正直もう使いたいと思いません。

Angularの嫌なところは独自ルールが多く、覚えるのに時間がかかることです。
特に私はWEBフロント開発をあまりやらないので、フレームワークを覚えても次やるときは忘れていたり、仕様が変わっていたり、フレームワーク自体オワコン化してる可能性もあるので、なるべく学習に時間をかけたくありません。

React、Vueも当然覚えなければならないことは色々あると思いますが、Angularほど学習コストは高くないように思えました。

TypeScriptは良いものだが

私は静的型付け言語ばかりやってきたのもあり、JavaScriptを書いていると型が欲しいとよく思います。
同じように思う人は多いようで、最近Webフロントの開発では静的型付け言語であるTypeScriptを使うケースが増えています。

TypeScriptは良い言語だと思いますが、今回使うのはやめました。
TypeScriptを使わないのにはいくつか理由がありますが、一番は開発環境を整えるのが面倒だからです。

TypeScriptを使う場合、まずビルド(トランスパイル)環境を作る必要があります。
また、TypeScriptはライブラリなどの外部APIを使うにあたり、型定義ファイルを用意しないといけないのが手間です。
一応型を厳密にせず、型定義ファイルなしでやる方法もありますが、それだとどうも負けた感じがしてしまいます(笑)

TypeScriptを避ける他の理由に、ビルドに時間がかかるというのもあります。
プログラムが小さいうちは気になりませんが、以前TypeScriptを使ったときはストレスを感じるレベルでビルドに時間がかかっていました。

あと、最近のIDE(WebStormやVSCode)はJSDocを書くと型チェックやコード補完をしてくれるので、JavaScriptのままでも静的型付け言語に似たメリットを受けることができます。

まあ、否定的な意見ばかり書きましたが、良い言語だとは思うので、使う理由があれば避けることはないかと思います。

jQueryは恥だが役に立つ

15年程前、初めてjQueryを使ったときはブラウザ毎の挙動の違いを吸収できることと、CSSセレクター形式で要素を取得できることに感動しました。
しかし今では、ブラウザ間の挙動の違いはだいぶ少なくなり、CSSセレクターによる要素取得もJavaScript標準のAPIでできます。

ではjQueryはもういらないのかと言えば、そんなことはないと思います。
感動するほどではないけれど、jQueryはまだ現役で役に立つツールです。
特にAjaxをちょこっと使いたい場合など、素のJavaScriptよりかなり楽に書けます。
ダウンロードサイズも今のネット環境では大したことはないので、とりあえず入れておいてもさほど害はありません。

それならバニラよりjQuery使った方がいいじゃん、って話になるのですが、jQueryにもデメリットはあります。
私が思うjQueryの一番のデメリットは、標準APIとjQueryで2つのやり方が混在してしまうことです。

例えば、jQueryには配列をループさせるjQuery.eachという関数がありますが、現代のJavaScript(ES6)は同じことが標準のArray.forEach関数でできます。
そのように複数のやり方が混在すると、プログラムが統一感を失い読みづらくなってしまいがちですし、標準関数でできるならそっちに統一したいですよね。

そのようなデメリットもあり、今回作ったSPAではjQueryを使わなかったのですが、バニラより楽にはなるので、VueやReactなどのモダンフレームワークを使わないのであれば、jQueryを採用するのは今でもアリかと思います。

フレームワークのファイルサイズ

フレームワークを使わずバニラでやった1つの理由が、バニラならフレームワークが不要な分ダウンロードが速いかな、という考えでしたが、実はそこまででもないということが分かりました。

VueやReact本体のファイルサイズは小さいです。
Vue.js(v2.6.14)のファイルサイズは94KBですが、CDNからダウンロードするとBrotli圧縮がかかっていて35KBです。
さらに滅多に変更が入るファイルではないので、2度目以降はキャッシュを利用できる可能性も高いです。
(ReactはVueよりやや大きいですが、そこまで大きな違いはありません)

35KBは4Gや光回線だけでなく、3G回線でも一瞬でダウンロードできるサイズです。
例えば、Qiitaのトップページの総ダウンロード量を見てみると、およそ5200KB程度でした。
これと比べたら35KBは全然大したことありません。

回線種別 ダウンロード速度(目安) 35KBのダウンロードにかかる時間
光回線 80Mbps 0.0035秒
4G回線 20Mbps 0.014秒
3G回線 3Mbps 0.09秒

Vanilla JSのメリット

フレームワークのサイズが大したことないなら、バニラでやるメリットはあるのか?という話になりますが、実際作ってみてバニラの方が優位だった点が1つありました。
それは動作が速いことです。

今回作ったSPAの中に1つ、画面内の要素が多すぎて描画に数秒時間がかかるページがありました。
どれくらい多いかというと、テキストボックス・チェックボックス・プルダウンなどの入力要素が1画面に1万個以上あります
この画面を高速化したいと思い、試しにVue.jsで同じようなページを作ってみたのですが、比較するとVanilla JSの方が速かったです。

仮想DOMは速いみたいな記事を目にすることがあり、VueやReactはなんとなく速いイメージを持っていたのですが、よく考えてみるとVueやReactだって最終的には画面描画のためにリアルDOMを操作するわけで、それなら必要最小限のリアルDOMをピンポイントに操作するバニラの方が速いはずです

しかし、一般的なWEBアプリケーションでそこまでシビアなパフォーマンスが求められることは少ないと思うので、パフォーマンスを理由にフレームワークを使うことをそれほど躊躇する必要はないとも思います。

エディタ・IDEの準備

前置きが長くなりましたが、ここから具体的な実装周りの話に入ります。

まずはエディタ・IDEを用意します。
好きなものがあればそれを使えばいいですが、特になければWebStormがお勧めです。
有料(初年度14,900円)ですが値段分の価値はあると思います。

どうしても無料が良ければ、VSCodeあたりがいいんじゃないかと思います。
WebStormもVScodeもWindows、Macどちらでも使うことができます。

Node.jsのインストール

次に、Node.jsをインストールします。
最近のWEBフロント開発ではたいていNode.jsのインストールが必要になります。

Node.jsのインストール方法は色々あります。
公式サイトからダウンロードしてインストールするのが一番簡単ですが、長い目で見れば複数バージョンのNode.jsを切り替えられるようにするツールを使うのがお勧めです。
私はNodebrewというツール(macOS用)を使ってNode.jsをインストールしています。
使ったことはありませんが、Windowsにもnvm-windowsなど同じようなツールがあるようです。

NPMとは

前述のNode.jsをインストールした理由は、実はNode.jsそのものを使いたいわけではなく、中に含まれるNPMというパッケージ管理ツールを使うためです。

NPMのリポジトリには開発に役立つ様々なパッケージ(ツールやライブラリのようなもの)が登録されていて、package.jsonファイルに必要なものを記載することで、それらをダウンロードして使えるようになります(後述)。

package.json でパッケージをダウンロードする

プロジェクトのルートディレクトリに package.json ファイルを作成します。
前の項でも書きましたが、package.json にはプロジェクトで使うNPMのパッケージ(ライブラリ)を記載します。
以下のように、devDependencies の中に必要なパッケージの名前とバージョンをセットで書いていきます。

package.json
{
  "devDependencies": {
    "@babel/core": "7.12.10",
    "@babel/preset-env": "7.12.11",
    "babel-loader": "8.2.2",
    "compression-webpack-plugin": "9.0.0",
    "http-server": "13.0.2",
    "webpack": "5.55.1",
    "webpack-cli": "4.8.0",
    "sass": "1.42.1"
  }
}

次に、package.jsonのあるディレクトリで以下のコマンドを実行すると、node_modules というディレクトリの中に記載したライブラリがダウンロードされます。

npmパッケージのインストール
npm install

package.json にはdevDependencies以外にも色々なことが記載できますが、devDependenciesだけでも動くのでここでは説明は割愛します。
(repositoryとlicenseがないと警告が出ますが動作に問題はありません)

各パッケージの概要

package.json に記載したパッケージ達の概要を説明します。
それぞれ詳細は後ほど説明します。

Babel関連

@babel/core:Babel本体
@babel/preset-env:Babelの設定のプリセット群

webpack関連

webpack:webpack本体
webpack-cli:webpackのコマンドラインツール
compression-webpack-plugin: データ圧縮するためのWebPackプラグイン
babel-loader:webpackでBabelを使うためのツール

その他

http-server:動作確認のために使うローカルサーバー
sass:Sassのコンパイラー

npmスクリプトの定義

npm install でインストールしたパッケージに実行可能なコマンドがある場合、node_modules/.binの中に配置されます。
例えば http-server ライブラリであれば、以下のように node_modules/.bin/http-server を叩いてサーバーを起動することができます。

8000番ポートでローカルサーバーを起動
 node_modules/.bin/http-server -p 8000

しかしnpmでインストールしたツールは、package.jsonに記載したnpmスクリプトから実行するのが楽です。
npmスクリプトは任意のコマンド(シェルスクリプト)に名前をつけて、その名前で実行できるようにするものですが、npmスクリプトでは /node_modules/.binが自動でパスに追加されるので、より簡潔にコマンドを書くことができます。

npmスクリプトの定義
"scripts": {
  "serve": "http-server -p 8000"
}

例えばこのように記載すると、npm run serve と打って http-server -p 8000 を実行できます。
ビルドやサーバーの起動など、開発中によく実行するタスクのコマンドをNPMスクリプトとして登録しておくと便利です。

Gulpの功罪

npmスクリプトはシェルスクリプトなので、複雑なタスクを実行するのにはあまり向いていません。
より複雑なタスクを実行させたい場合は、JavaScriptでタスクを書けるGulp が便利です。

ただしGulpにはNodeモジュール(NPMパッケージ)を実行するのにGulp用のプラグインが必要というデメリットがあります。
例えばBabelを使いたい場合、npmスクリプトならただBabelを実行するだけですが、GulpからBabelを実行するにはBabel本体に加えて gulp-babel というGulp用のプラグインが必要になります。

そしてこのプラグインがよく壊れます😇
Babelがアップデートしたけど、gulp-babelが対応してないから使えない、みたいなことが起こります。
そういった理由で世の中には脱Gulpの流れもあったりします。

基本的なタスクはnpmスクリプトでできるので、これからWEBフロント開発を始めるなら、まずはGulpなしでやってみるのが良いかもしれません。

HTMLの作成

JavaScriptで画面を作るSPAと言えどもHTMLは必要になるので、htdocsというディレクトリを作って、HTMLファイルを作ります。
JavaScriptで画面を構築するので、bodyタグの中は空っぽです。

index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>バニラSPA</title>
    <meta name="viewport" content="width=device-width">
    <link rel="icon" href="/img/favicon.ico">
    <link rel="stylesheet" href="/css/destyle.css">
    <link rel="stylesheet" href="/css/application.css">
    <link rel="apple-touch-icon" href="/img/touch-icon.png" sizes="192x192">
    <link rel="shortcut icon" href="/img/touch-icon.png">
    <script defer src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
    <script defer type="module" src="/src/application.js"></script>
</head>
<body></body>
</html>

SPA(シングルページアプリケーション)のシングルページ というのはHTMLが1つという意味です。
SPAでもHTMLを複数作って機能を分割する場合もありますが、サンプルアプリケーションでは1つのHTMLだけで完結させています。

以下でHTMLの内容について解説していきます。

ファビコンの設定

ファビコンの設定
<link rel="icon" href="/img/favicon.ico">

ファビコンというのは、ブラウザのタブなどに表示されるサイトのアイコンのことです。
最近のブラウザはPNG画像なども使えますが、対応していないブラウザもあるので ico 形式のファイルにするのが一般的です。

ico ファイルは1つのファイルの中に、複数サイズの画像を含むことができます。
特にこだわりがなければ大きな画像を一つ作って、適当なWEBツールでico形式に変換するのが簡単です。
色々なサイズを含められますが、16×16、32×32の2種類を含めておけば概ね問題ないんじゃないかと思います。

また、スマホでホーム画面にショートカットを作った場合などのアイコンは、ファビコンとは別に以下のタグで設定する必要があります。

スマホ向けアイコンの設定
<!-- iOS向けのアイコン -->
<link rel="apple-touch-icon" href="/img/touch-icon.png" sizes="192x192">
<!-- android向けのアイコン -->
<link rel="shortcut icon" href="/img/touch-icon.png">

この例ではiOS、androidどちらも同じアイコンを設定していますが、別のアイコンを設定することもできます。
アイコンの表示サイズは端末によって異なりますが、iOSは180x180px、androidは192x192pxが一番大きなサイズなので、どちらも同じ画像を使うなら192x192pxでアイコンを作成するのが良いと思います。

リセットCSSの適用

リセットCSSを適用
<link rel="stylesheet" href="/css/destyle.css">

リセットCSSはブラウザ固有のスタイルをリセットして、ブラウザによる見た目の違いをなくすためのCSSです。
有名どころだけでも以下のようにたくさんのリセットCSSが世に出ています。

  1. Eric Meyer’s “Reset CSS” 2.0
  2. Yahoo! (YUI 3) Reset CSS
  3. HTML5 Doctor CSS Reset
  4. Normalize.css
  5. ress
  6. sanitize.css
  7. destyle.css

1、2、3は少し古く、4は5と6で改良されているようなので、お勧めは以下の3つです。

  • ress
  • sanitize.css
  • destyle.css

ressとsanitize.cssはブラウザ間の差異を消しつも、ある程度のスタイルを与えてくれるのに対して、destyle.css は原則全てのスタイルをリセットするようです。
一からスタイリングしたかったので、サンプルでは destyle.css を使うことにしました。
こちらの公式サイトからファイルをダウンロードして、/htdocs/cssディレクトリに配置します。

ViewPortの設定

スマホ対応に必要なviewport指定
<meta name="viewport" content="width=device-width">

スマホ対応する場合はこのような viewport のmetaタグが必要になります。

viewportはスマホでのみ有効になる機能で(PCブラウザには効かない)、ブラウザの横幅を設定する ことができます。
例えばブラウザの横幅を300にしたい場合は、以下のように指定します。

width=300の例
<meta name="viewport" content="width=300">

ブラウザの横幅を設定するとはどういうことか、イメージがわかないと思いますので、Qiitaトップページのviewportをwidth=300 にしたものと、width=1000 にしたものを、同じスマホで表示したキャプチャーをあげてみます。

width比較.png

どちらも同じスマホなのですが、右側の方がタブレットのような大きな端末に見えないでしょうか?
物理的な幅は変わらないけれど、width=1000の方はコンテンツを縮小することで、幅1000px分の内容を画面に描画しています。
このようにviewportのwidth設定は、コンテンツを拡大縮小することで擬似的にブラウザの幅を設定します

ちなみに、viewportのwidthは デフォルトが980 なので、viewportを明示的に指定しなければ、スマホではブラウザの幅が980になります。しかし、980というのは小さなノートPCくらいの横幅なので、これをスマホで表示すると文字がかなり小さくなってしまいます。

では、スマホ対応する場合widthの値をどう設定すれば良いかというと、冒頭に記載した通りwidth=device-widthに設定します。
このように指定すると、スマホの大きさに合わせて適切な横幅が設定されます。

ViewPortは他にもいくつかのカスタマイズができますが、ここでは詳細は割愛します。

CSSピクセル

width=device-widthとした場合、スマホの大きさに合わせて適切な横幅が設定されると書きましたが、このとき設定される値は解像度ではなくCSSピクセルと呼ばれるものです。

CSSピクセルは機種によりまちまちですが、おおむね物理的に大きい端末の方が大きくなります
スマホよりタブレットの方が大きくなりますし、iPhoneなら大きいモデルの方が大きくなります。

ただし、Andoirdは多くのスマホが幅360で、例えばXPERIAなら大きいモデルも小さいモデルも360です。
なので、厳密に物理的な幅に比例するわけでもないということは注意してください。

CSSピクセルのサイズ感は大雑把に以下のような感じです。

タイプ CSSピクセル
スマホ 320〜450
タブレット 600〜1000
ノートPC 1280〜

この範囲に入らない、例えば500とかについては、おそらく現在該当する機種がほとんどないと思います。

レスポンシブWebデザイン

スマホに対応するためPCサイトとスマホサイトを別々に作ることもできますが、今回はサイトは一つで、CSSのメディアクエリで画面の横幅によってレイアウトを変えるようにします。
このように画面サイズによってレイアウトを変えるデザインを、 レスポンシブWEBデザイン と言います。

例えば、私が作成した保育園マップでは、横幅が900px未満になるとスマホ向けレイアウトになり、左サイドバーが消えて、ヘッダー、フッターが出てきます。

responsive.gif

レスポンシブWEBデザインの具体的な実装方法は、CSSのメディアクエリを使って、以下のように画面の横幅により適用するスタイルを切り替えます。

@media screen and (max-width:599px) {
 /* 幅599px以下の端末に適用するスタイルを書く */
}

@media screen and (min-width:600px) and (max-width:899px) { 
 /* 幅600px〜899pxの端末に適用するスタイルを書く */
}

@media screen and (min-width:900px) { 
 /* 幅900px以上の端末に適用するスタイルを書く */
}

このように min-widthmax-width に画面の横幅を指定するのですが、指定する数値は端末の物理的なピクセル数ではなく、一つ前の項で説明した CSSピクセルになります。

たくさんレイアウトのパターンを作るのは面倒なので、今回は基本的に横幅900pxを境に以下のようにレイアウトを切り替えました。

横幅 レイアウト
899px以下 スマホ・タブレット向け
900px以上 大型タブレット・PC向け

ただし、一部この分け方では上手くいかないところがあり、そこについては600未満、600〜900未満、900以上 の3パターンでレイアウトを分ける必要がありました。

JavaScriptで画面を作る

SPAではJavaScriptで画面を作りますが、その際使うのがDocument.createElement 関数と Element.innerHTML プロパティです。

innerHTMLは要素内のHTML文字列を編集できるので、これによりJavaScriptにHTMLを書くことができるようになります。
また、innerHTMLを設定する親要素は createElement 関数で作ります。

以下の例ではinnerHTMLとcreateElementを使って、JavaScriptで簡単なログイン画面を作っています。

See the Pen Create Login by YamamotoKeita (@yamamotokeita) on CodePen.

templateを使った画面作成

IE11では対応していませんが、templateDocument.importNodeを使って要素を作ることもできます。
innerHTMLとの優劣は検証できていませんが、IE11などの古いブラウザが絶滅すれば、こちらの方法が主流になる可能性もあります。

See the Pen template by YamamotoKeita (@yamamotokeita) on CodePen.

コンポーネントベースの画面構築

コンポーネントとは、UIを構成する部品のことです。
ボタンやテキストボックスのように小さなコンポーネントもあれば、1画面全体を表す大きなコンポーネントもあります。

ReactやVueなどを使ったモダンなWEBフロント開発では、コンポーネント単位でUIのプログラムを書き、それらを組み合わせて画面を作るのが一般的になってきており、サンプルでもコンポーネントを定義して画面構築ができるようにします。

バニラなので設計は自由ですが、以下の形でコンポーネントを定義します。

  • 1コンポーネント1ファイル
  • コンポーネントに対応するJavaScriptクラスを作る
  • コンポーネントのHTMLはコンポーネントクラス内に書く

この方針に合わせて作ったログイン画面のコンポーネントがこちらです(最終的にはこれにボタン処理などが加わります)

コンポーネントクラスの例
import Component from "../core/component.js";

export default class LoginView extends Component {
    get html() {
        return `
<div class="login-view">
    <div class="container">       
        <div class="row">
            <input class="login-id" type="text" placeholder="ログインID" />
        </div>
        <div class="row">
            <input class="password" type="password" placeholder="パスワード" />
        </div>
        <div>
            <button class="login">ログイン</button>
        </div>
    </div>
</div>
        `;
    }
}

HTMLを返すところはReactのrender関数に似てますが、位置付けとしてはVueのSingle File Componentに近いものです。
継承しているComponentクラスについては、後ほど説明します。

スタイルのスコープをコンポーネントに閉じる

CSSファイルに書いたスタイルは画面全体に適用され、画面の一部だけに適用することができません。
SPAでは最終的に1つのCSSファイルに全ページのスタイルを詰め込むので、何も考えずにCSSを書くと、あるページのためのスタイルが、無関係な別ページに効いてしまうということが簡単に起こります。

そのため、CSSの適用範囲(スコープ)を限定する何らかの工夫が必要になります。
VueやAngularはその機能を持っていますが、Reactにはなく、当然Vanilla JSにもありません。

そういった場合、以下のようにいくつかやり方があります。

  1. BEMなどのCSS設計
  2. Styled ComponentsなどのCSS in JS
  3. CSS modules
  4. Tailwindなどのユーティリティ+コンポーネント側でのスタイル指定

この中のどれが良いとは一概に言いづらいですが、CSS modulesは 近い将来非推奨にしたいという話 があるので、今は避けた方が良さそうです。

ここでは導入の簡単さを優先して、独自のCSS設計(命名規則)と Sass を使って、ゆるくスコープを実現します。

Sass(SCSS)

SassはCSSを拡張して、より効率的に書けるようにしたものです。
ちょっと名前がややこしいのですがSassには、SASSSCSS という2種類の書き方があり、ここではよりユーザーの多いSCSSで書いていきます。

Sassの最大の特徴はCSSのセレクターを入れ子にできることです。
例えば、以下のSassをコンパイルすると、最終的に入れ子がスペース繋ぎに変換されたCSSになります。

Sass
.user-list {
   .user-item {
      .user-name {
         font-size: 1rem;
      }
   }
}
コンパイル後のCSS
.user-list .user-item .user-name {
  font-size: 1rem;
}

また、セレクターの先頭に&をつけると、入れ子をスペースなしで繋げます。

Sass
.user {
   &.selected {
      background-color: skyblue;
   }
}
コンパイル後のCSS
.user.selected {
  background-color: skyblue;
}

Sassには他にも色々機能がありますが、この入れ子の書き方をスタイルのスコープ管理に利用します。

スタイルのスコープ管理

今回スタイルの影響範囲をコンポーネントに限定するためにやっているのは、非常に簡単で原始的な方法です。
サンプルではスタイルの適用範囲を特定のコンポーネントに限定するため、以下のルールでCSSを記述しています。

  • コンポーネントのルート要素のclass属性に component をつける
  • コンポーネントのルート要素のclass属性に 一意なコンポーネント名 をつける
  • 上記のコンポーネント名は、コンポーネントのJavaScriptクラス名を小文字ハイフン繋ぎにしたもの

例えば、LoginView というコンポーネントの場合、以下のようにします。

LoginViewコンポーネントのHTML例
<div class="component login-view">
   <!-- LoginViewコンポーネントのHTML -->
</div>
LoginViewコンポーネントのスタイル例
.component.login-view {
   /* LoginViewコンポーネントのスタイル */
}

LoginViewコンポーネントのスタイルを全て .component.login-view の括弧内に書けば、他のコンポーネントに影響を与えず、LoginViewコンポーネントに影響範囲を限定してスタイルを指定することができます。

ただ、この方法は完璧ではなく、コンポーネントを入れ子にすると親コンポーネントのスタイルが子に効いてしまう場合があります。
(VueのScoped CSSも同じ問題があるようです)
そのような場合は、CSSの子結合子 >などを使って適用範囲を絞る必要があります。

Sassのコンパイル

Sassファイルはそのままではブラウザで動かないので、コンパイルしてCSSに変換する必要があります。

SassをコンパイルするライブラリはRuby SassLibSassDart Sassの3つあったのですが、今ではRuby Sassがサポート終了、LibSassが非推奨なので、Dart Sassを使います。
(ちなみに、Sassでググると node-sass を使った記事が多いですが、node-sass は非推奨のLibSassを使っているので避けた方が良いかもしれません)

package.jsonに記載した "sass": "1.40.0" がDart Sassのライブラリで、npm install によりインストールしたら、 sass コマンドでSassファイルのコンパイルができます。
sassコマンドの基本的な使い方は以下の通りです。

sassコマンドの使い方
sass 入力ディレクトリまたはファイル:出力ディレクトリまたはファイル

オプションなどの説明は割愛して、ここでは以下の2つをnpmスクリプトに登録します。
sass の方はコンパイルを一度だけ実行、sass-watch の方はコンパイル後、ファイルの変更を監視して変更があれば自動で再コンパイルしてくれます。

npmスクリプトにsassのコマンドを定義
  "scripts": {
    "sass-watch": "sass htdocs/src:htdocs/css --no-source-map --watch",
    "sass": "sass htdocs/src:htdocs/css --no-source-map"
  }

開発を初める時は以下のコマンドでSassファイルの変更を監視しておけば、Sassファイル変更時にコンパイルされ、自動でCSSファイルが生成されます。

npmスクリプトを実行
npm run sass-watch

Componentクラスの作成

コンポーネントベースの画面構築の項で出てきた Component クラスの実装を考えます。

まずは、get html() 関数から取得したHTML文字列を元にHTML要素を作る必要があります。
次に、スタイルのスコープ管理の項で出てきた、コンポーネントのルート要素のclass属性に component をつけるというのも、コンポーネントに共通する処理なのでやっておきましょう。
そうすると以下のようなクラスになります。

Componentクラス
export default class Component {

    constructor() {
        this.element = Component.createElementFromHTML(this.html, this.containerTag);
        this.element.classList.add('component');
    }

    get containerTag() {
        return 'div';
    }

    get html() {
       throw '子クラスでhtml()をオーバーライドして下さい。';
    }

    static createElementFromHTML(html, containerTag) {
        let container = document.createElement(containerTag);
        container.innerHTML = html.trim();
        return container.firstChild;
    }
}

サンプルでは、これに利便性のため、CSSクエリで要素を取得する関数を追加しています。

ページ遷移とルーティング

昔からある静的なWEBサイトでは、ページ=1つのHTMLであり、HTMLのファイル名がそのままURLになります。

ページ ファイル URL
トップ画面 index.html http://hoge.com/index.html
ログイン画面 login.html http://hoge.com/login.html
注文画面 order.html http://hoge.com/order.html

対して、SPAは複数のページに対してHTMLが1つしかなく、何も考えずに作ればURLも1つになってしまいます。

ページ ファイル URL
トップ画面 index.html http://hoge.com/index.html
ログイン画面 index.html 上と同じ!?
注文画面 index.html 上と同じ!?

しかしページごとにURLを変えられないと、ページ単位でブックマークやリンクができず使い勝手が悪いため、SPAでもJavaScriptを使ってページごとに別のURLを割り当てるのが一般的です。

SPAにおいて、コンテンツの切り替わりに応じてURLを変えたり、URLに応じて表示するコンテンツを切り替えることをルーティングと言います。
また、ルーティングを行うモジュールをルーターといい、ReactやVueなどSPA向きのフレームワークにはたいていルーターがあります。

以下にルーティングのやり方を説明します。

ハッシュ or History API

SPAのルーティングにはURLにハッシュ # を付ける方法と、JavaScriptの History API を使う方法の2パターンがあります。

ハッシュを使うケースでは https://hoge.com#login のように # 以降のパスで表示する画面を決めます。
この方法は、WEBサーバーに特別な設定がいらないことがメリットですが、URLに#を入れる必要があり、一般のユーザーには見慣れないURLの形になってしまうのがデメリットです。

一方、ハッシュを使わないケースではURLは自由にできますが、WEBサーバーにリダイレクト設定が必要になる(後述)のがデメリットです。

また、ハッシュ方式はHistory APIに対応していない古いブラウザでも動くというメリットも一応ありますが、現在History APIに対応していないブラウザはほぼないので、その点は無視して良いと思います。
(そこまで古いブラウザをサポートしたいなら、そもそもSPAにすべきではない)

History APIでURLを切り替える

今回は History API を使ってルーティングする方法を説明します(ハッシュの方の説明は割愛します)
History APIはブラウザのURLと遷移履歴を操作する機能で、具体的には URLを変える ことと URLを変えつつ履歴を追加する ことができます(履歴を削除することはできません)

  • history.replaceState(state, title, [url]) URLを変える関数
  • history.pushState(state, title[, url]) URLを変えつつ履歴を追加する関数

JavaScriptで画面を切り替えるのと同時に、これらの関数でURLを変えてやれば、画面ごとに別のURLをにすることができます。

トップページ以外へのアクセスをトップページにリダイレクトする

上記のHistory APIを使えば、例えばトップ画面からログイン画面に遷移する際、URLをindex.html から login.html に変えることができます。
しかし実際に login.html というファイルがあるわけではないので、http://hoge.com/login.html のようなリンクからアクセスされると 404エラー になってしまいます。

そこでこのエラーを防ぐために、WEBサーバーには index.html以外へのリクエストをindex.htmlにリダイレクトするという設定が必要になります。(詳細は後述)

URLに対応する画面を表示する

上記のリダイレクト設定を行うと、login.html にアクセスしても index.html が表示されるようになりますが、それだけではlogin.html にアクセスしても index.htmlにアクセスしても同じ画面が表示されるだけで意味がありません。

そのため、JavaScriptでアクセスされたURLを見て、index.htmlならトップ画面を表示、login.htmlならログイン画面を表示という風に、URLに応じて表示する画面を切り替える必要があります。

ブラウザの「戻る」ボタンに対応する

history.pushState で履歴を追加すると、ブラウザの「戻る」ボタンで前のURLに戻れるようになりますが、「戻る」を押してもURLが変わるだけで画面は何も変わりません。

ブラウザの「戻る」ボタンが押されたとき画面を戻すには、「戻る」ボタンが押されたイベントを検知して、JavaScriptで表示画面を戻してやる必要があります。
「戻る」ボタン押下のイベントは window.addEventListener('popstate', func) を使って検知することができます。

戻るボタンのイベント検知
window.addEventListener('popstate', e => {
   console.log('戻るボタンが押されました');
});

上記の方法で popstate のイベントを検知したら、前の画面に戻してやるのですが、それをするには次のページに進むとき、前ページを保存しておく 必要があります。
「戻る」ボタン押下時には、今表示しているページを削除して、保存しておいた前ページを再表示します。

ブラウザの「進む」ボタンに対応する

前の項では「戻る」ボタンにしか触れませんでしたが、実は popstateイベントは「進む」ボタン押下時にも発生します
なので、popstateイベント発生時には「戻る」ボタンが押されたか「進む」ボタンが押されたかを判定して、処理を切り替える必要があります。

「戻る」が押されたか「進む」が押されたかを判定するには、history.pushState(state, title[, url]) 関数の第一引数 state を使います。
stateには好きなデータをセットすることができ(厳密にはシリアライズ可能なものだけ)、popstateイベントでは次に表示されるURLのstateを取得することができるので、それを利用して「戻る」か「進む」かを判定します。

具体的にはstateにインクリメントするページ番号のようなものを保存し、現在表示中のページ番号と大小比較して戻るか進むか判定します。

ルーターのサンプル

ルーターについてはまだまだ細かいことが色々あるのですが、自前でルーターを実装することもあまりないと思いますので、ルーターの説明は以上に留めておきます。
サンプルアプリケーションには、上に書いた諸々の処理をする簡易なルーターを実装したので、詳細に興味があればご覧ください。

Apacheのリダイレクト設定

トップページ以外へのアクセスをトップページにリダイレクトする ためのApache2.4でmod_rewriteを使ったリダイレクトの設定例を記載しておきます。

httpd.confの一部
RewriteEngine On
# リクエストされたURLに該当するファイルがない場合
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_FILENAME} !-f
# 任意のURLをindex.htmlに転送する
RewriteRule ^ /index.html [L]

この設定はSSRやプリレンダリングをしないSPAであれば必要で、ReactやVueなどのフレームワークを使った場合も同じです。
Vue、Reactの公式にも設定例が載っているので挙げておきます。
(細部は違いますが、やっていることは上の設定と概ね同じです)

Vueが提示する設定

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.html [L]
</IfModule>

Reactが提示する設定

Options -MultiViews
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.html [QSA,L]

ソフト404エラー

上記のようにリダイレクト設定をすると、存在しないURLへにアクセスした場合でもindex.htmlにリダイレクトされ、HTTPのステータスコードは404(コンテンツがないことを示すコード)ではなく、200(正常)が返却されます。
そのような状態をソフト404エラーと言います。

ステータスコード200でも最低限ページが存在しない旨メッセージを表示すれば良いかと思いますが、理想を言えばコンテンツがないなら404エラーが返却される方が望ましいです。
この観点で考えると、設定量が多くなってしまいますが、WEBサーバーは無条件にリダイレクトするのでなく、ホワイトリスト形式で特定のパスへのリクエストのみindex.htmlにリダイレクトするようにしても良いかと思います。

[閑話] mod_rewriteの注意

Apacheでリダイレクトするためのモジュール mod_rewrite は設定がややこしく、ハマりやすいポイントです。
また、Apacheのバージョンによる差などもあるので注意しましょう。

ちなみに、上に書いた「Vueが提示する設定」「Reactが提示する設定」の方は、どちらも私の環境では正しく動かず、
%{REQUEST_FILENAME}%{DOCUMENT_ROOT}%{REQUEST_FILENAME} に変える必要があったのと、Reactの方はindex.html/index.htmlに書き換える必要がありました。
環境の違いによるものか、設定例の誤りなのか分かりませんが、このようなケースはよくあります。

フレックスボックスを使おう

SPAを作るにあたり必要なCSSの知識はたくさんありますが、その中でも特に便利と思われる フレックスボックス を紹介します。
一昔前はフレックスボックスに対応していないブラウザもありましたが、今ではほぼ全てのブラウザが対応しています(IE11は色々とバグがあるようですが)

フレックスボックスは、簡単に言うと複数の要素を縦並びか横並びにする機能です。
並べるにあたり色々カスタマイズができるのですが、中央揃えで配置できるのと、並べたもの中の特定の一つを伸ばせるのが便利です。

基本的な使い方は、並べる要素の親要素に display: flex を指定し、横並びか縦並びかを flex-direction で指定します。

See the Pen flex sample by YamamotoKeita (@yamamotokeita) on CodePen.

フレックスボックスは様々なプロパティでカスタマイズができますが、以下のプロパティを比較的よく使います。

  • flex-direction ... 要素を並べる向き
  • align-items ... 並べる向きと垂直方向の位置揃え
  • flex-grow ... 子要素の伸びる比率
  • flex-wrap ... 子要素の折り返し方
  • justify-content ... 並べる向きと水平方向の寄せ方
  • gap ... 要素間の余白

よくありそうなレイアウトのサンプルを作ってみたので、興味があればご覧ください。

webpackを使う

webpack は複数のJavaScriptファイルを1ファイルにまとめる(バンドルする)ツールです。
ただ、それだけではなく様々なプラグインにより、CSSや画像もバンドルできたり、データ圧縮できたりと幅広い機能を持っています。
設定ファイルが少し分かりづらいのですが、現在JavaScriptバンドルツールのデファクトスタンダードになっているように思います。
(他にもParcel、esbuildなどのバンドルツールがありますがここでは割愛します)

サンプルでは以下の3つの目的のためにwebpackを使います。

  • 複数のJavaScriptを一つのファイルにまとめる
  • ES6をES5に変換する(Babel)
  • JavaScriptの圧縮

webpackの設定は webpack.config.js というファイルに記載します。
今回webpack.config.jsは以下のようになりました。

webpack.config.js
const CompressionPlugin = require("compression-webpack-plugin");
const zlib = require("zlib");

module.exports = {
    mode: "production", 
    entry: './htdocs/src/application.js' ,
    output: { path: __dirname + "/htdocs/bundle" , filename: "application.bundle.js" },
    module: {
        rules: [
            {
                test: /\.js$/,
                use: [
                    {
                        loader: "babel-loader",
                        options: {
                            presets: [
                                "@babel/preset-env",
                            ],
                        },
                    },
                ],
            },
        ],
    },
    target: ["web", "es5"],
    plugins: [
        new CompressionPlugin({
            filename: "[path][base].brotli",
            algorithm: "brotliCompress",
            test: /\.js$/,
            compressionOptions: {
                params: {
                    [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY,
                },
            },
        })
    ]
};

詳細な内容については以下で説明します。

JavaScriptモジュールのバンドル化

ES6ではimport文を使って、あるJavaScriptファイルから、別のJavaScriptファイルを読み込めるようになりました。
この機能をJavaScriptモジュールと言います。
JavaScriptモジュールを使うと、機能ごとにファイルを分けて書くのが楽になり、プログラムの再利用性が高まります。

しかし、IEなどの古いブラウザではJavaScriptモジュールが動かないため、webpackを使ってimportされるJavaScriptファイルを予め全て結合し、1つのファイルにします(これをバンドルと言います)。
このバンドル機能は、以下のようにwebpack.config.jsに結合の起点となるファイルを指定し、webpack コマンドを実行するだけで実現できます。

最小構成のwebpack.config.js
module.exports = {
    entry: './htdocs/src/application.js'
}

webpackは上記のようにentryだけを書いても動かせますが、一般的には最低でもmode entry outputの指定が必要になると思います。

webpack.config.js
module.exports = {
    // production か development を指定する。production にするとJavaScriptファイルが縮小される(後述)
    mode: "production",
    // バンドルする起点となるJavaScriptファイル。複数指定することもできる。
    entry: './htdocs/src/application.js' ,
    // 出力先のファイルパス
    output: { path: __dirname + "/htdocs/bundle" , filename: "application.bundle.js" },
}

entryとoutputは、入力元と出力先を指定するものなのですが、書き方が結構複雑なので英語に抵抗がなければ一度公式ドキュメントを読んでおくことをお勧めします。

Babel(ES6をES5に変換する)

古くからあるJavaScript(ES5)は問題の多い言語ですが、最近のJavaScript(ES6)ではそれらの問題がだいぶ改善されています。
なのでJavaScriptの開発をするならES6を使いたいのですが、IE11など一部の古いブラウザではES6が動作しません。

そこで、ES6でプログラムを書いて、Babel というツールでES5に変換するアプローチをとります。

今回はwebpackを使っているので、webpack経由でBabelを使うことができます。
webpack.config.js の以下の部分がBabelを有効にする記述になります。

webpack.config.jsの一部抜粋
module: {
    rules: [
        {
            test: /\.js$/,
            use: [
                {
                    loader: "babel-loader",
                    options: {
                        presets: [
                            "@babel/preset-env",
                        ],
                    },
                },
            ],
        },
    ],
},
target: ["web", "es5"],

BabelでPolyfill

BabelはES6の構文をES5に変換してくれますが、古いブラウザでES6を動かすには、たいていそれだけではダメでさらに Polyfill というものが必要になります。
Polyfillは古いブラウザがサポートしていない新しいクラスや関数などを補完してくれるものです。

PolyfillはBabelのオプションとして指摘することができ、Polyfillを設定するにはwebpack.config.js のpresetsの部分を以下のように変えます。

webpack.config.js一部抜粋
presets: [
    ["@babel/preset-env", {
       useBuiltIns: "usage",
       corejs: 3
    }]
  ]

また、このPolyfill設定を使うには core-js パッケージが必要になるので、package.json の依存パッケージにcore-jsを追加し、再度 npm install する必要があります。

package.json一部抜粋
"core-js": "3.18.1"

Polyfill.io

しかし、上記のやり方でPolyfillを入れても、サンプルアプリケーションではIE11でエラーになってしまいました。
そこで Polyfill.io というものを使ってみたところ、こちらはエラーが出ずIE11でも動作しました。

Polyfill.ioの使い方は簡単でHTMLに以下のタグを入れるだけです。

index.html一部抜粋
<script defer src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>

Polyfill.ioはブラウザのユーザーエージェントを見て、そのブラウザに不足している関数やクラスだけを補完してくれます。
モダンなブラウザでは補完するものがないので、ほぼ何もダウンロードされず無駄がありません。
サードパーティーのサーバーにサービスを依存させるリスクはあるものの、可用性にシビアなサービスでなければ、これを使うのもアリかもしれません。

しかし、こちらのPolyfill.ioを入れても保育園マップサービスの方はIE11でエラーが出ましたし、サンプルアプリケーションもIE11向けに一部を修正する必要があったので、やはりIE11対応は茨の道なのではないかと思います。

IEは死にますか?

現在ほとんどのブラウザがES6をサポートする中で、ES6が使えず足を引っ張っているのがIE11(あと古いモバイルブラウザも?)です。
2021年6月のとある調査ではIE11の国内シェアは4.5%でした。
多くはないですが、大手サービスなどでは切り捨てるのも難しそうな数字です。

しかし、IEは2022年6月でサポートが切れます。
なので、もうしばらくすればBabelやPolyfillとさよならできる日が来るんじゃないかと思います。
(TypeScriptのトランスパイラーとか、別の形で生き残るかもしれませんが)

JavaScriptの圧縮

SPAではJavaScriptのサイズが大きくなるので、たいていパフォーマンス向上のためJavaScriptファイルの圧縮が必要になると思います。
JavaScriptファイルの圧縮は以下の2段階あります。

1. Minify
不要なスペースや改行を削除したり、変数名を短くしたりしてJavaScriptのファイルサイズを小さくする。
2. データ圧縮
gzipなどのデータ圧縮アルゴリズムでファイルを小さくする。圧縮ファイルはブラウザにより自動的に解凍される。

今回1、2どちらもwebpackで対応します。

まず、1のMinifyはwebpackの modeを production にするだけできます(簡単!)
(逆にMinifyをかけたくない場合はmodeをdevelopment にする)

webpack.config.jsの一部抜粋
mode: "production"

次に2のデータ圧縮については CompressionWebpackPlugin というwebpackのプラグインで行います。
webpack.config.js内の以下の記述が、データ圧縮を指定する部分になります。

webpack.config.jsの一部抜粋
const CompressionPlugin = require("compression-webpack-plugin");
const zlib = require("zlib");
webpack.config.jsの一部抜粋
    plugins: [
        new CompressionPlugin({
            filename: "[path][base].br",
            algorithm: "brotliCompress",
            test: /\.js$/,
            compressionOptions: {
                params: {
                    [zlib.constants.BROTLI_PARAM_QUALITY]: zlib.constants.BROTLI_MAX_QUALITY,
                },
            },
        })
    ]

CompressionWebpackPluginは標準では gzip でデータを圧縮しますが、上記の algorithm: "brotliCompress" を指定すると、より圧縮率の高いBrotli アルゴリズムでデータを圧縮することができます。
WEBのデータ圧縮は現在gzipが主流ですが、こちら を見るとIE以外のブラウザはほぼBrotli圧縮に対応しているので、そろそろBrotliを使ってもいい頃かと思います。

どれくらい圧縮できるの?

保育園マップサービスでオリジナルのJavaScriptと、それをMinifyしたもの、さらにデータ圧縮(Brotli)したものを比べると以下の結果になりました。

ファイル サイズ
オリジナル 454KB
Minify後 182KB
データ圧縮後 28KB

データ圧縮まですると、454KB → 28KBで、およそ16分の1のサイズになっています!
ファイルの内容により圧縮率は変わりますが、圧縮することで読み込み時間の大幅な短縮が期待できます。

圧縮ファイルの配信

Minifyだけなら問題ありませんが、データ圧縮したファイルを配信するにはWEBサーバーに設定が必要です
WEBサーバーは圧縮ファイルを配信するため、以下をする必要があります。

クライアントがBrotli圧縮に対応しているか判定

クライアント(ブラウザ)はリクエストヘッダの Accept-Encoding に、自身が解凍できるアルゴリズムを書いて送ってきます。
Brotli圧縮に対応している場合は、Accept-Encodingにbrが含まれるので、brがあれば圧縮ファイルを返却し、なければ圧縮していないファイルを返却するようにします。

レスポンスヘッダのContent-Encodingに圧縮形式を設定する

圧縮ファイルを返す場合は、クライアントがファイルを解凍できるように、Content-Encodingに圧縮形式を記載します。
Brotli圧縮の場合は Content-Encoding: br とします。

レスポンスヘッダにVary: Accept-Encodingを設定する

圧縮ファイルがあるパスへのリクエストは、ヘスポンスヘッダに Vary: Accept-Encoding を設定します。
これはキャッシュサーバー向けのヘッダーで、リクエストヘッダのAccept-Encodingの値によって、レスポンスが変化することを伝えます。

キャッシュサーバーはコンテンツサーバーから取得したデータをキャッシュしてクライアントに返しますが、圧縮ファイルと無圧縮ファイルがある場合は、キャッシュサーバーもまたWEBサーバーと同様にAccept-Encodingの値を見て、返却するキャッシュを切り替える必要があります。
Varyヘッダはどのヘッダーを見てキャッシュを変えれば良いかを伝えるものです。

Apacheで圧縮ファイルを配信する

Apache2.4でBrotliファイルを配信する設定例を記載します。

httpd.confの一部抜粋
RewriteCond %{HTTP:Accept-Encoding} br
RewriteCond %{REQUEST_URI} \.js$
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_FILENAME}.brotli -s
RewriteRule ^ %{REQUEST_URI}.brotli [L]

<Files *.js.brotli>
    Header set Content-Encoding br
    Header append Vary Accept-Encoding
    ForceType application/javascript
</Files>

Apacheのmod_rewrite というモジュールで、特定の条件に当てはまるリクエストを、.brotli のパスに転送しています。
mod_rewrite の設定の詳細はちょっとややこしいのでここでは省略します。
詳細が気になる方はこちらのドキュメントをご覧ください。

あと、こちらも詳細は割愛しますが、nginxや、S3+Cloud Frontでも同じようなことができます。

ローカル環境でプログラムを動かす

開発中のプログラムをで動かして動作確認するため、サンプルではnpmの http-server パッケージを使っています。
package.jsonの中にローカルサーバーを起動するための以下のnpmスクリプトを定義します。

package.jsonの一部
"serve": "http-server htdocs -p 8000 -c-1",

以下のようにこのnpmスクリプトを実行するとローカルサーバーが立ち上がり、http://localhost:8000 をブラウザで開けばindex.htmlが表示されます。

npm run serve

使ってみた感じ、http-serverはシンプルですが必要最低限の機能しかないような印象でした。
ライブリロードの機能もないようなので、ローカルサーバーについては何か他のパッケージやツールを使うのもいいかもしれません。

ローカル環境ではwebpack不要

本番環境ではwebpackでバンドルしたJavaScriptを動かしますが、バンドルは時間がかかるので、開発時に修正のたびにバンドルするのは結構なストレスになります。
そこで開発中はwebpackを使わずバンドル前のJavaScriptを直接使うのが良いと思います。
バンドル前のJavaScriptはES6をサポートしていない古いブラウザでは動きませんが、最近のブラウザを使って開発すれば問題ありません。

開発環境
<script defer type="module" src="/src/application.js"></script>
本番環境
<script defer src="/bundle/application.bundle.js"></script>

開発環境と本番環境でこのタグを書き換える必要があるのが少し面倒ですが、CIツールでデプロイするときに自動で書き替えることもできると思います。

また、以下のようにindex.html内にスクリプトを書いて、ホスト名によって読み込むJavaScriptファイルを動的に切り替えることもできます。

index.html一部抜粋
<script>
    var isLocal = (location.hostname === 'localhost');
    var jsFile = isLocal ? '/src/application.js' : '/bundle/application.bundle.js';
    var script = document.createElement('script');
    if (isLocal) {
        script.setAttribute('type', 'module');
    }
    script.setAttribute('src', jsFile);
    script.setAttribute('defer', 'defer');
    document.head.appendChild(script);
</script>

WEBサーバーは何を使うべきか

今回、諸般の事情によりApacheを使ったのでApacheの設定ばかり書いていますが、WEBサーバーを自由に選択できるなら、Apacheよりnginx(エンジンエックス)の方がお勧めです。
昔はWEBサーバーといえばApacheが圧倒的トップシェアでしたが、今ではnginxが逆転しつつあります。

たいていの場合、Apacheよりnginxの方が設定が簡単で、パフォーマンスも高いです。
ネットにもnginxの情報が多く出てきていますし、今ではApacheを選ぶメリットはあまりなくなってきているように思います。

また、より低コストでホスティングをしたいなら、AWSのS3 + CloudFront など、WEBサーバーを使わない選択もありかもしれません。

SPAのエントリーポイントを分割する

SPAは「シングルページアプリケーション」の略です。
この シングルページ とはページ(HTML)が1つという意味なのですが、実際SPAを作ってみるとこの名に反して、ある程度の機能のまとまりでHTMLを分けた方が良いのでは、と感じました。
HTMLを分ける目的は、それによりJavaScriptを機能ごとに分割するためです。
全機能のJavaScriptを結合してしまうと、ファイルが大きくなり過ぎてしまうように思います。

例えば、SPAでECサイトを作ることを考えます。
サイトには「会員登録」「商品検索」「商品詳細」「購入手続き」など色々な機能があると思います。
これを1つのHTMLで作ってしまうと、検索エンジンなどから「商品詳細」ページに直接アクセスしたときに、すぐには必要ない「会員登録」「商品検索」「購入手続き」のJavaScriptリソースまで読み込んでしまうことになります。

これでは読み込みが遅くなりユーザビリティを損ないますし、サーバーの負荷も増えます。

幸いES6のモジュール機能を使って、クラスごとにファイルを分けてJavaScriptを書いておけば、クラスが再利用できるのでJavaScriptファイルを分割しても、同じようなコードを重複して書くことは避けることができます。

CORSの設定

JavaScriptはHTTP通信を行うことができますが、セキュリティのため、標準ではHTMLと異なるホストに対する通信はエラーになります
そのため、HTMLとAPIが別のサーバーに配置されている場合、エラーを回避するための対応が必要になります。

別ホストに対して通信できるようにするには、APIサーバー側で特定のレスポンスヘッダーを返却して、別ホストからの通信を受け入れることを明示的に示します。
この方法により異なるホスト(オリジン)からの通信を受け入れることをCORS(Cross Origin Resource Sharing)と言います。

CORSを有効にするには、レスポンスに以下のようなヘッダーを設定します。

  • Access-Control-Allow-Origin ... 通信を許可するホストを指定
  • Access-Control-Allow-Headers ... 通信を許可するヘッダーを指定。Content-Typeなど一部のヘッダーはここに記載しなくても通信可能。
  • Access-Control-Allow-Credentials ... Cookieを使う場合はtrueを設定する
Cookieを使わないCORSヘッダー例
Access-Control-Allow-Origin *
Access-Control-Allow-Headers "Authorization, Cache-Control"
Cookieを使う場合のCORSヘッダー例
Access-Control-Allow-Origin https://example.com
Access-Control-Allow-Headers "Authorization, Cache-Control"
Access-Control-Allow-Credentials true

CORS向けのヘッダーは他にもあり、詳しく説明すると長くなるので、詳しく知りたい方はこちらのドキュメントなどをご覧ください。

ハマりやすい注意点としては、Access-Control-Allow-Originはワイルドカード * 指定ができますが、Access-Control-Allow-Credentials true の場合はワイルドカードが使えないということと、プリフライトリクエストというOPTIONSメソッドでのリクエスト に対してもAccess-Control-Allow-Originなどのヘッダーが返るように設定しておかなければいけないところなどでしょうか。

また、別ホストと通信する方法としてJSONPというやり方もありますが、ここでは説明は割愛します。

SPAのSEO

今回作った保育園マップサービスは公開したばかりなのとコンテンツの少なさから、今のところGoogle検索にほとんどヒットしません。
この問題については現在進行形で対応&調査中なのであまり大した話はできませんが、SEOについて知っている情報だけでも書いておきます。

Google Search Consoleに登録する

SPAに限らず一般向けWEBサービスを公開したら、何はともあれGoogle Search Consoleに登録しましょう。

Google Search Consoleは検索に関する色々な分析ができる無料ツールです。

また、Google Search Consoleからサイトマップを送信することで、サイトにどんなページがあるかをGoogleに伝え、検索結果に反映されやすくすることができます。

Googlebotからの見え方

保育園マップサービスは、いくつかのブラウザで動作確認して問題ないことを確認していましたが、Googlebotで実行されたときはエラーになるという問題がありました(既に修正済)。

そのようなこともあるので、Googlebotが自分のWEBサイトを正しくレンダリングできているか、見ておくと良いと思います。
GoogleのモバイルフレンドリーテストにサイトのURLを入力すると、GooglebotがJavaScriptを実行した結果どのように画面が描画されているか確認することができます。
また、実行時のコンソールログやエラーも確認することができます。

スクリーンショット 2021-09-22 16.39.47.png

事前にHTMLをレンダリングする

SPAはほとんど中身のないHTMLを返してJavaScriptでHTMLを構築しますが、最初から構築済みのHTMLを返せば普通のWEBページと変わらず、SEO的な不利はないはずです。
その考えに基づき、構築済みのHTMLをレスポンスする以下のような手法があります。

サーバーサイドレンダリング(SSR)

サーバーサイドレンダリングはリクエストが来るたび、サーバー上でJavaScriptを動かしてHTMLを構築し、構築済みのHTMLをレスポンスします。

プリレンダリング(Prerendering)

プリレンダリングはSSRと少し似ていますが、コンテンツのビルド時にJavaScriptを動かして構築済みのHTMLをあらかじめ生成しておき、それをレスポンスします。
リクエストの度にサーバーでJavaScriptを動かす必要がないので、SSRよりもサーバーの負荷が軽くなります。

ダイナミックレンダリング(Dynamic Rendering)

ダイナミックレンダリングは、クローラーからアクセスされた場合のみSSRを行い構築済みのHTMLをレスポンスします。
一般ユーザーからのリクエストに対しては、SSRを行わないので負荷は軽くなります。

ハイブリッドレンダリング(Hybrid Rendering)

ハイブリッドレンダリングはSSRのように構築済みのHTMLを返しますが、画面表示後のユーザー操作に対しては、SPAと同様に動いてコンテンツを動的に描画します。

事前レンダリングは不要という説もある

ネットを見ていると、現在のGoogleクロウラーはJavaScriptを解釈できるので、SSRなどの事前レンダリングは不要という説もあります。
事前レンダリングはどの手法も大変そうなので、保育園マップサービスでの導入はまだ考えておらず、まずはコンテンツ拡充など他の方向からSEO対策を頑張ってみようと考えています。

OGP(Open Graph Protocol)の設定

OGP(Open Graph Protocol)というのは、FacebookやTwitterなどでWEBサイトをシェアしたときに表示される画像や説明文をカスタマイズするものです。
例えば、Qiitaの記事のリンクをTwitterに貼ると以下のようになりますが、ここに表示される画像と説明文はOGPにより設定されたものです。

スクリーンショット 2021-09-29 11.50.png

具体的にどうやってOGPを設定するかというと、以下のようにHTMLのmetaタグ に表示する画像のURLや説明文を記載します。

QiitaのOGP用メタタグ例
<meta property="og:type" content="article">
<meta property="og:title" content="macOSでもWSLみたいなLinux環境を手に入れる - Qiita">
<meta property="og:image" content="https://qiita-user-contents.imgix.net/〜長いので省略〜">
<meta property="og:description" content="macOSでもLinuxの仮想環境が欲しい時はある
Dockerを利用するなど、macOSであってもLinux環境が欲しい時はあります。
Microsoft365や、Adobe CCなど、macOSかWindowsでしか使えな...">
<meta property="og:url" content="https://qiita.com/chibiegg/items/eede37345f7058ce604d" />
<meta property="og:site_name" content="Qiita" />

FacebookやTwitterは今のところOGPを読み取る際JavaScriptを考慮しないので、このmetaタグはHTMLに書き込んでおく必要があります。

このことを考えると、SPAといえどもフロントだけでアプリケーションは完結せず、バックエンド側でHTMLを動的に構築する何らかの仕組みが必要 になることが多いのではないかと思われます。
バックエンドでHTMLを動的に構築するのは、Ruby on RailsやLaravelなど、昔からあるWEBアプリケーションフレームワークの得意とするところで、やり方は無数にあるためここでは詳細は割愛します。

おわりに

軽い気持ちで書き始めたら、あれも書かなきゃこれも書かなきゃ、と内容が増えていき、最終的に大ボリュームになってしまいました。
書くのに結構時間もかかっていて、途中で「一円の得にもならないのに何やってるんだろう」的な気持ちにもなりましたが、なんとか書き切りました。
せめて誰かの役に立ってくれれば幸いです。

SPAの開発はHTML、CSS、JavaScriptの構築はもちろんのこと、webpack、Babel、WEBサーバー、SEO、古いブラウザの考慮など様々な対応が必要になります。
HTML、CSS、JavaScriptの構築については ReactVue.js などのフレームワークが道筋を示してくれ、さらにNext.jsNuxt.js などを使えば、運用に必要な機能もいくらかカバーしてくれるようです。
フレームワークに依存しすぎることにはリスクもありますが、SPA開発の課題の多さを見ると、現状SPAの開発にはこういった高機能なフレームワークを使うのが現実的なのかもしれないと思いました。

まあ、タイトルにあるよう Next.js、Nuxt.jsどころか ReactもVueもやったことない のでそこら辺は予想です。
実際フレームワークを使ってみれば、フレームワーク独自の課題というのも出てくると思うので、話半分にお聞きください。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
348
Help us understand the problem. What are the problem?