TL;DR
webpackのexternalsを活用することでファイルサイズが大きくなりがちなReact + Firebase + MaterialUIプロジェクトのサイズを削減することができる。
背景
ReactやFirebaseなどの便利ライブラリは基本的にサイズが大きい。例えば、
File | Size |
---|---|
firebase-app.js | 11.22K |
firebase-auth.js | 157.33K |
firebase-firestore.js | 355.59K |
firebase-storage.js | 38.15K |
react.production.min.js | 13.00K |
react-dom.production.min.js | 111.45K |
material-ui.production.min.js | 295.05K |
仮にこれを全部使う場合はバンドル後の出力サイズが約1メガバイトを超えるのは避けられない。Firebase Hostingの無料枠内で運用したい場合、1回のアクセスで1メガバイトもの転送量を消費してしまうのは痛い。
webpack + CDN
そのため、ファイルサイズの大きい外部ライブラリはUNPKGなどのCDNから<script>
タグを通じて利用したいことがある。しかし単純にこの手法を使おうとすると、古いバージョンのJavascriptでグローバルに定義された変数と格闘しながらコーディングすることになる。それはしんどいので、どうにかしてNPMやTypescriptなどの現代技術を活用できるようにしたい。そこで登場するのがwebpackである。
webpackを使った開発では基本的にすべてのライブラリはNPMを通じて管理するが、設定ファイルでexternalsを指定することでファイル出力時にライブラリをグローバル変数から参照させることができる。
初期設定
ここからはTypescriptで開発することを前提とする。Javascriptを使用する場合は適宜読み替えていただきたい。
必要なライブラリをダウンロード
echo '{}' > package.json
npm i -D firebase @types/react @types/react-dom typescript webpack webpack-cli webpack-dev-server ts-loader
Typescriptの設定
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"allowJs": true,
"jsx": "react",
"sourceMap": true,
"outDir": "./public/js",
"strict": true,
"esModuleInterop": true,
}
}
webpackの設定
module.exports = {
devtool: 'inline-source-map',
entry: './src/index.tsx',
output: {
path: path.resolve(__dirname, 'public/js'),
filename: 'bundle.js'
},
resolve: {
extensions: ['.tsx', '.ts', '.js']
},
module: {
rules: [
{ test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ }
]
},
devServer: {
contentBase: path.join(__dirname, 'public'),
watchContentBase: true,
open: true
},
externals: {
'react': 'React',
'react-dom': 'ReactDOM',
'firebase': 'firebase',
'firebase/auth': '',
'firebase/storage': '',
'firebase/firestore': '',
'@material-ui/core': 'MaterialUI'
}
}
ここで注目していただきたいところはexternalsである。firebase以外は素直な感じであるがfirebaseは少し厄介である。firebase/*
は読み込まれていれば十分で新たにグローバル変数を作るわけではないので空文字を返している。(憶測だが)firebase-app.js
で作られたグローバル変数firebase
に対してその他のfirebase-*.js
が機能を追加している。このことはアプリケーションの書くときに意識する必要があるので、開発中に謎のエラーに遭遇したら真っ先にfirebaseコードの読み込み関係を疑おう。
package.json の script
{
"scripts": {
"build": "webpack --mode production",
"start": "bash start.sh"
},
...
}
#!/bin/bash
webpack --mode development --watch &
webpack-dev-server
start.sh
の中身を直接package.json
に書かず別プロセスとして実行することで、^Cでサーバを止めたときにバックグラウンド実行されているwebpack --watch
も終了する。
サンプルコード (React only)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>React App</title>
</head>
<body>
<div id="root"></div>
<script defer crossorigin src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
<script defer crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>
<script defer src="js/bundle.js"></script>
</body>
</html>
script
にdefer
オプションを指定することでダウンロードが並行して実行される。(たぶん)
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
ReactDOM.render(<App />, document.getElementById('root'))
import React from 'react'
const App: React.FC = () => {
return (
<h2>Hello React!!!</h2>
)
}
export default App
npm run start
を実行すればちゃんとReactが動いていることが確認できると思う。
サンプルコード (React + Firebase + MaterialUI)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>React App</title>
</head>
<body>
<div id="root"></div>
<script defer crossorigin src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
<script defer crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>
<script defer crossorigin defer src="//unpkg.com/@material-ui/core@latest/umd/material-ui.production.min.js"></script>
<script defer src="https://www.gstatic.com/firebasejs/6.5.0/firebase-auth.js"></script>
<script defer src="js/bundle.js"></script>
<script src="https://www.gstatic.com/firebasejs/6.5.0/firebase-app.js"></script>
</body>
</html>
ここで注意してほしいことは**firebase-app.js
のscript
タグではdefer
が指定されていない**点である。これはグローバル変数firebase
がfirebase-app.js
内でのみで定義されることを保証するため、スクリプトの中で一番はじめに呼ばれるようにするためである。最後の行に持ってきているのは他のスクリプトのダウンロードを邪魔しないようにするためである。
import React from 'react'
import firebase from 'firebase'
import { Button, Container } from '@material-ui/core'
const firebaseConfig = {
apiKey: "...",
authDomain: "...",
databaseURL: "...",
...
};
firebase.initializeApp(firebaseConfig)
const App: React.FC = () => {
const [user, setUser] = React.useState<firebase.User | null>()
React.useEffect(() => firebase.auth().onAuthStateChanged(setUser), [])
const provider = new firebase.auth.GoogleAuthProvider()
const auth = firebase.auth()
return <Container maxWidth="sm">
<h2>React + Firebase + MaterialUI using CDN</h2>
<Button {...{
variant: "contained",
color: user ? 'default' : 'primary',
onClick: user ?
() => auth.signOut().catch(console.error) :
() => auth.signInWithPopup(provider).catch(console.error)
}}>{user ? 'sign out' : 'sign in'}</Button>
<p>
<a href="https://google.com">Document</a>
</p>
{user ?
<ul>
{Object.entries(user.toJSON())
.filter(([_, x]) => typeof x === 'string' || typeof x === 'number' || typeof x === 'boolean')
.map(([s, x]) => <li key={s}>{`${s}:\t${x}`}</li>)}
</ul> :
<p>Sign in, PLEASE!</p>
}
</Container>
}
export default App
注目ポイントimport
部分である。import firebase from 'firebase/app'
ではなくimport firebase from 'firebase'
などとしているのはCDNと対応が取れるようにするためである。一般的には、サブディレクトリのみインポートすることは読み込むコード量が減り良いことなのだが、今回はまるごとCDNからコードを取得するのでルートディレクトリからインポートするようにする。