React + Firebase + MaterialUI --(webpack + CDN)--> 神

## TL;DR

webpackのexternalsを活用することでファイルサイズが大きくなりがちなReact + Firebase + MaterialUIプロジェクトのサイズを削減することができる。

[Source Code](https://github.com/gkkachi/react-firebase-materialui-cdn-exapmle)
[Sample Page](https://gkkachi.github.io/react-firebase-materialui-cdn-exapmle/)

## 背景

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 |

仮にこれを全部使う場合はバンドル後の出力サイズが約１メガバイトを超えるのは避けられない。Firebase Hostingの無料枠内で運用したい場合、１回のアクセスで１メガバイトもの転送量を消費してしまうのは痛い。

## webpack + CDN

そのため、ファイルサイズの大きい外部ライブラリは[UNPKG](https://unpkg.com/)などのCDNから`<script>`タグを通じて利用したいことがある。しかし単純にこの手法を使おうとすると、古いバージョンのJavascriptでグローバルに定義された変数と格闘しながらコーディングすることになる。それはしんどいので、どうにかしてNPMやTypescriptなどの現代技術を活用できるようにしたい。そこで登場するのが[webpack](https://webpack.js.org/)である。

webpackを使った開発では基本的にすべてのライブラリはNPMを通じて管理するが、設定ファイルでexternalsを指定することでファイル出力時にライブラリをグローバル変数から参照させることができる。

## 初期設定

ここからはTypescriptで開発することを前提とする。Javascriptを使用する場合は適宜読み替えていただきたい。

### 必要なライブラリをダウンロード

```bash
echo '{}' > package.json
npm i -D firebase @types/react @types/react-dom typescript webpack webpack-cli webpack-dev-server ts-loader
```

### Typescriptの設定

```tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "allowJs": true,
    "jsx": "react",
    "sourceMap": true,
    "outDir": "./public/js",
    "strict": true,
    "esModuleInterop": true,
  }
}
```

### webpackの設定

```webpack.config.js
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

```package.json
{
  "scripts": {
    "build": "webpack --mode production",
    "start": "bash start.sh"
  },
  ...
}
```

```start.sh
#!/bin/bash

webpack --mode development --watch &
webpack-dev-server
```

`start.sh`の中身を直接`package.json`に書かず別プロセスとして実行することで、^Cでサーバを止めたときにバックグラウンド実行されている`webpack --watch`も終了する。

## サンプルコード (React only)

```public/index.html
<!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`オプションを指定することでダウンロードが並行して実行される。（たぶん）

```src/index.tsx
import React from 'react'
import ReactDOM from 'react-dom'

import App from './App'

ReactDOM.render(<App />, document.getElementById('root'))
```

```src/App.tsx
import React from 'react'

const App: React.FC = () => {
  return (
    <h2>Hello React!!!</h2>
  )
}

export default App
```
`npm run start`を実行すればちゃんとReactが動いていることが確認できると思う。

## サンプルコード (React + Firebase + MaterialUI)

```public/index.html
<!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`内でのみで定義されることを保証するため、スクリプトの中で一番はじめに呼ばれるようにするためである。最後の行に持ってきているのは他のスクリプトのダウンロードを邪魔しないようにするためである。

```src/App.tsx
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からコードを取得するのでルートディレクトリからインポートするようにする。