普通じゃ満足できない脱 webpack マニアのあなたに贈る Snowpack

これは v1 時代の記事です。v2 の記事を参考にしてください
[Snowpack v2 リリース - 標準 ES モジュールで戦う未来 - Qiita](https://qiita.com/kazuma1989/items/30676cb3d2da1c873507)

---

[Snowpack](https://www.snowpack.dev) を使い React + TypeScript な環境構築をする具体的な設定を紹介します。



# Snowpack is 何

npm パッケージを、ブラウザーから読み込めるよう ES modules 形式[^esmodules]に変換してくれるバンドラーです。

[^esmodules]: [Webpack を使わずに import 文を使う - Qiita](https://qiita.com/kazuma1989/items/1b1a4443925005034f7e)

次のように使います：

1. `npm install` 後に `snowpack` コマンドを実行し、node_modules 内のパッケージをブラウザーから読み込めるようバンドルする。たとえば `node_modules/react` を `web_modules/react.js` のようなファイルにまとめる。
2. アプリケーションコードは ES modules の作法に則って記述し、必要であれば `web_modules/react.js` を import して使う。

この結果嬉しいのは、公式に

> No more waiting for your bundler to rebuild your site every time you hit save. Instead, every change is reflected in the browser instantly.

とあるように、アプリケーションコードを変更するたびビルド待ちになることがない点です。アプリケーションコードをバンドルしないのだから当然ですね。

とはいえ現実には Babel や tsc のトランスパイルはほぼ必須で、それらは待つ必要があります。それでも、大量の npm パッケージ群やアプリケーションコードを都度バンドルし直すよりは、開発体験が良いと思われます。



# コード + 設定ファイルの具体例 3 つ

以下のパターンを紹介します。

|        | tsc                   | Babel                                    |
| ------ | --------------------- | ---------------------------------------- |
| React  | ✅ Snowpack 入門    | ✅ import を「通常どおり」書く方法の紹介 |
| Preact | ✅ alias 方法も紹介 | ❌                                        |


## tsc + React（Snowpack 入門）

TypeScript のトランスパイルに tsc コマンドを使うパターン。最低限のファイル構成は次のようになります：

```
public/           # デプロイする最終成果物一式
    dist/         # トランスパイル後のアプリケーションコードを出力する。gitignore 対象
    web_modules/  # Snowpack による出力結果。gitignore 対象
    index.html    # 画面のエントリーポイント。自分で、ブラウザーに読める HTML を書く

src/           # アプリケーションコード。中の構成は自由
    index.tsx  # スクリプトのエントリーポイント
    App.tsx
    ...

package.json          # 依存関係やタスクの管理
snowpack.config.json  # Snowpack 設定
tsconfig.json         # TypeScript 設定
```

### アプリケーションコード

一つずつ見ていきます。

まず index.html です。Snowpack は webpack や Parcel と異なり index.html の面倒は見てくれないので、最終状態の HTML を書いておく必要があります。ポイントは script 要素で、次の 2 点が重要です。

- `type="module"` 属性が必要（ES module として読み込むため）
- src 属性にはトランスパイル後のパスが必要。つまり `./src/index.tsx` ではなく `/dist/index.js` を指定する

```html:public/index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>DOCUMENT TITLE</title>

    <script type="module" src="/dist/index.js"></script>
  </head>

  <body>
    <div id="root"></div>
  </body>
</html>
```

次に、index.tsx は以下のようになります。特徴的なのは import パスで、ES modules として読み込めるように、トランスパイル先のフォルダー名や拡張子 `.js` を含んだものになっています（この点をなんとかする方法は後述の Babel + React 編で）：

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

// Dynamic import の例。import App from './App.js' でも構わない
import('./App.js').then(({ default: App }) => {
  ReactDOM.render(<App />, document.getElementById('root')!)
})
```

ほかのファイルも同様に import パスは ES modules として記述します。一方 export は webpack などを使うときと同じ書き方が可能です：

```tsx:src/App.tsx
import React, { useState } from '/web_modules/react.js'
import Title from './Title.js'

export default function App() {
  const [active, setActive] = useState(false)
  const toggle = () => setActive(v => !v)

  return (
    <div onClick={toggle}>
      <Title label="tsc + React" active={active} />
    </div>
  )
}
```

<details><summary>Title.tsx</summary><div>

```tsx:src/Title.tsx
import React from '/web_modules/react.js'

export default function Title({
  label,
  active,
}: {
  label?: string
  active?: boolean
}) {
  return <h1 style={{ color: active ? 'red' : 'initial' }}>{label}</h1>
}
```
</div></details>

### 設定ファイル

package.json の内容は次のとおりです：

- `scripts#prepare` は `npm install` 後に自動で実行されるタスクです[^npmprepare]。Snowpack によって node_modules 内のパッケージを変換しつつ `./public/web_modules` に出力します。
- `scripts#compile` は `./public/dist` へ TS ファイルをトランスパイルするタスクです。開発中は `--watch` オプションをつけると便利です。
- `scripts#server` は、オプションではあるものの実質必須となる開発サーバーの起動タスクです。Snowpack の思想的に snowpack-dev-server の類は存在しないので、任意のサーバーを選びます。
- `dependencies` は依存関係の定義場所ですが、React, React-DOM のバージョン文字列が特徴的です。React 本体は ES modules 対応がなされていないため、Snowpack 開発元が提供するバージョンを使う必要があるためです[^pikareact]。

[^npmprepare]: `npm install some-package` で新規パッケージを追加したときには実行されません。改めて `npm install` するか、`npm run prepare` するかが必要です。
[^pikareact]: https://www.snowpack.dev/#react

```json:package.json
{
  "scripts": {
    "prepare": "snowpack --optimize --clean --dest ./public/web_modules/",
    "compile": "tsc --outDir ./public/dist",
    "server": "live-server --quiet ./public"
  },
  "dependencies": {
    "react": "npm:@pika/react@^16.13.1",
    "react-dom": "npm:@pika/react-dom@^16.13.1"
  },
  "devDependencies": {
    "@types/react": "^16.9.23",
    "@types/react-dom": "^16.9.5",
    "live-server": "^1.2.1",
    "snowpack": "^1.6.0",
    "typescript": "^3.8.3"
  }
}
```

Snowpack オプションを設定する snowpack.config.json は次のとおりです。`webDependencies` に、node_modules 内のどのパッケージを web_modules として変換するか指定します。この設定を省略し、ソースコード内の import 文から自動判定させることもできます[^auto]：

[^auto]: できるものの、自動判定がうまくいかなかったときのデバッグが面倒なので、明示的に指定するのをおすすめします。

```json:snowpack.config.json
{
  "webDependencies": ["react", "react-dom"]
}
```

tsconfig.json は次のとおりです。Snowpack を使う上で重要なのは `compilerOptions#paths` で、これにより `/web_modules/react.js` の型定義を `node_modules/@types/react` と紐づけないと、tsc の型検査に失敗します[^paths]。Snowpack の出力先を web_modules 以外にしたときはこの項目を変更してください：

[^paths]: デフォルト値は `"*": ["node_modules/@types/*", "node_modules/*"]` なので、設定なしのときは `react` と書けば `node_modules/@types/react` に解決されます。webpack を使うときはあまり意識しない設定値かもしれません。

```json-doc:tsconfig.json
{
  "compilerOptions": {
    // Choose your target based on which browsers you'd like to support.
    "target": "ES2017",

    // Required: Use module="ESNext" so that TS won't compile/disallow any ESM syntax.
    "module": "ESNext",

    // Required for some packages.
    "moduleResolution": "Node",

    // `import React` instead of `import * as React`
    "allowSyntheticDefaultImports": true,

    // <div /> => React.createElement("div")
    "jsx": "react",

    // Required: Map "/web_modules/*" imports back to their node_modules/ TS definition files.
    "baseUrl": ".",
    "paths": {
      "/web_modules/*.js": ["node_modules/@types/*", "node_modules/*"]
    },

    // Redirect output structure to a directory.
    // The directory is defined in the package.json#scripts.
    "noEmit": false,

    // Generate source maps including their original sources.
    "sourceMap": true,
    "inlineSources": true,

    // Useful type checks.
    "strictNullChecks": true
  },

  "include": ["src/**/*"]
}
```

### 起動手順

package.json と同じ階層で `npm install` したら、`npm run compile -- --watch` と `npm run server` をそれぞれ別のターミナルで実行します[^concurrently]。http://localhost:8080 を開けば "tsc + React" の文字が見られるはずです。

[^concurrently]: `--watch` 込みの scripts を追加したり、https://www.npmjs.com/package/concurrently を使ったりするとスマート。


## Babel + React（import を「通常どおり」書く方法の紹介）

TypeScript のトランスパイルに babel-cli を使うパターンです。Babel が型検査をしてくれないのでその点のフォローが必要になるものの、import パスを webpack と同じように書くことができる利点があります。

ファイル構成はほとんど一緒ですが、.babelrc.js が増えます：

```diff
 public/
 src/
+.babelrc.js           # Babel 設定
 package.json
 snowpack.config.json
 tsconfig.json
```

### アプリケーションコード

tsc + React の場合とほとんど変わりませんが、import パスを webpack のときと同じように書いています：

```diff:src/App.tsx
-import React, { useState } from '/web_modules/react.js'
-import Title from './Title.js'
+import React, { useState } from 'react'
+import Title from './Title'

 export default function App() {
 ...
```

### 設定ファイル

上記の書き味を実現しているのが、.babelrc.js で指定する Babel のプラグイン設定です。プラグインには Snowpack が提供しているもの (snowpack/assets/babel-plugin.js) を使います。

検証中にハマった点として importMap オプションがあります。プロジェクト直下に web_modules フォルダーを配置 **しない** 場合、Snowpack が import-map.json というメタ情報を探して迷子にならないよう、明示的にパスを指定する必要があるのです。相対パスでは期待どおりにならないため、絶対パスで指定します[^importmap]。

optionalExtensions オプションは、`from './Title'` を `from './Title.js'` にトランスパイルするために有効化します：

[^importmap]: このために .babelrc.json ではなく .babelrc.js が必要です

```js:.babelrc.js
module.exports = {
  presets: ['@babel/preset-typescript', '@babel/preset-react'],

  plugins: [
    [
      'snowpack/assets/babel-plugin.js',
      {
        importMap: `${__dirname}/public/web_modules/import-map.json`,
        optionalExtensions: true,
      },
    ],
  ],
}
```

package.json と tsconfig.json は次のとおりで、トランスパイルは Babel に任せ、tsc は型検査のみを担う構成になっています：

```diff:package.json
   "scripts": {
     "prepare": "snowpack --optimize --clean --dest ./public/web_modules/",
-    "compile": "tsc --outDir ./public/dist",
+    "compile": "babel ./src --extensions '.ts,.tsx' --source-maps --out-dir ./public/dist",
+    "test": "tsc",
     "server": "live-server --quiet ./public"
   },
   "dependencies": {
     "react": "npm:@pika/react@^16.13.1",
     "react-dom": "npm:@pika/react-dom@^16.13.1"
   },
   "devDependencies": {
+    "@babel/cli": "^7.8.4",
+    "@babel/core": "^7.8.7",
+    "@babel/preset-react": "^7.8.3",
+    "@babel/preset-typescript": "^7.8.3",
     "@types/react": "^16.9.23",
     "@types/react-dom": "^16.9.5",
```

```diff:tsconfig.json
-    // Required: Map "/web_modules/*" imports back to their node_modules/ TS definition files.
-    "baseUrl": ".",
-    "paths": {
-      "/web_modules/*.js": ["node_modules/@types/*", "node_modules/*"]
-    },
-
-    // Redirect output structure to a directory.
-    // The directory is defined in the package.json#scripts.
-    "noEmit": false,
-
-    // Generate source maps including their original sources.
-    "sourceMap": true,
-    "inlineSources": true,
+    // Only for type checks.
+    "noEmit": true,
 
     // Useful type checks.
     "strictNullChecks": true
```

### 起動手順

tsc + React の場合と同じです。


## tsc + Preact（alias 方法も紹介）

TypeScript のトランスパイルに tsc を使い、React の代わりに Preact を使うパターンです[^preact]。Preact を React の代用とするには alias 機能が欲しいところですが、これを rollup.js プラグインによって実現します[^whyrollup]。

[^preact]: React はマルチプラットフォームに対応しているものの、現実のプロダクトでは web 専用で使うケースが多いと思っていて、それならば軽量な Preact で代替するのは良い選択肢だと思います。Hooks も使えますし。
[^whyrollup]: rollup.js が出てきたのは、Snowpack の中身が rollup.js だからです。

ファイル構成は tsc + React の場合とほとんど一緒で、snowpack.config.json の代わりに snowpack.config.js を使う点だけが違います：

```diff
 public/
 src/
 package.json
-snowpack.config.json
+snowpack.config.js
 tsconfig.json
```

### アプリケーションコード

こちらも tsc + React の場合とほぼ同一で、差異は、React ではなく preact/compat を import している点です：

```diff:src/index.tsx
-import React from '/web_modules/react.js'
-import ReactDOM from '/web_modules/react-dom.js'
+import preact from '/web_modules/preact/compat.js'

 import('./App.js').then(({ default: App }) => {
-  ReactDOM.render(<App />, document.getElementById('root')!)
+  preact.render(<App />, document.getElementById('root')!)
 })
```

```diff:src/App.tsx
-import React, { useState } from '/web_modules/react.js'
+import preact from '/web_modules/preact/compat.js'
+import { useState } from '/web_modules/preact/hooks.js'
 import Title from './Title.js'

 export default function App() {
 ...
```

### 設定ファイル

Preact は、本家が ES modules 変換に対応しているので、通常のパッケージをインストールすれば大丈夫です。型定義が内包されたり、DOM レイヤーが内包されたりしているので、dependencies 定義が減るのも嬉しい点です。

Alias のための rollup.js プラグインも追加しておきます：

```diff:package.json
   "dependencies": {
-    "react": "npm:@pika/react@^16.13.1",
-    "react-dom": "npm:@pika/react-dom@^16.13.1"
+    "preact": "^10.3.4"
   },
   "devDependencies": {
-    "@types/react": "^16.9.23",
-    "@types/react-dom": "^16.9.5",
+    "@rollup/plugin-alias": "^3.0.1",
     "live-server": "^1.2.1",
     "snowpack": "^1.6.0",
```

JSX を `React.createElement` ではなく `preact.createElement` に変換するため、jsxFactory を明示的に指定する必要があります。もしくは、`import preact from` の代わりに `import React from` とし、jsxFactory の指定を省いてください：

```diff:tsconfig.json
-    // <div /> => React.createElement("div")
+    // <div /> => preact.createElement("div")
     "jsx": "react",
+    "jsxFactory": "preact.createElement",
 
     // Required: Map "/web_modules/*" imports back to their node_modules/ TS definition files.
     "baseUrl": ".",
```

snowpack.config.js は次のようになります。

- webDependencies として、Preact のサブパッケージである `preact/compat`, `preact/hooks` を指定します。
- rollup.js 設定として、`react` を `preact/compat` に解決してバンドルするプラグインを指定します。

Alias プラグインを追加することにより、React を peerDependency に持つようなパッケージ（React-Router や styled-components など）を Snowpack によってバンドルできるようになります。好きなパッケージを試してみてください：

```js:snowpack.config.js
const alias = require('@rollup/plugin-alias')

module.exports = {
  webDependencies: ['preact/compat', 'preact/hooks'],

  rollup: {
    plugins: [
      alias({
        entries: {
          react: 'preact/compat',
        },
      }),
    ],
  },
}
```

### 起動手順

tsc + React の場合と同じです。



# まとめ

- 3 種類の設定方法を紹介
  - tsc + React（Snowpack 入門）
  - Babel + React（import を「通常どおり」書く方法の紹介）
  - tsc + Preact（alias 方法も紹介）


## Snowpack を使った感想

### 手軽でいいですね

webpack と比べるとだいぶ手軽に web アプリの開発環境が整います。最も楽なのは、ライブラリーまで含んだ巨大なアプリバンドルを生成してしまわないようにあれこれ積んでいたチャンクの設定、そういったものが一切不要になる点です。バンドルを生成しないからですね。非常に良い開発体験なんじゃないでしょうか。

Parcel も環境構築の早さという点で気に入っていましたが、Snowpack のほうがより好みです。Web 標準をできる限り使い倒そうとする姿勢がいいですね。webpack のように XX ローダー、YY ローダーと依存を増やし泥沼に陥る[^webpackloader]より、web 標準外のことはできないと受け入れるほうが潔い。

[^webpackloader]: とはいえ webpack エコシステムは強力なので、そこに乗っかるのはまったく悪い選択肢ではないです。よほどマイナーなローダーを使わない限りほぼ問題はないでしょう。

紹介した 3 つの設定だと、個人的には tsc + Preact の構成が、依存が少なくて良いと思っています。React のほうがユニバーサルなコンポーネントを書けますが、本当にユニバーサルにして使い回すことなど滅多にない（と思う）ので、web に振り切っていいと思います。

### バンドラーの移行という観点では厳しいものがある

既存の webpack プロジェクトからの移行は厳しいでしょう。大体の場合 JS 以外の資材を webpack ローダーの力によって import しているはずで、その点は Snowpack の思想と相容れないからです。それとも Babel をゴリゴリに使えばなんとかなるんでしょうか？わからないです。

逆に、Snowpack から webpack や Parcel、その他への移行は現実的です。ES modules 形式の import パスであったとしても、一括置換すればよいだけで、本質的な障害にはなりません。そのため、初期開発は Snowpack で始め、アプリの拡大に合わせて webpack への移行を検討すれば、スピードと柔軟性の両方の恩恵を受けられると思います。



# 参考

- [モジュールバンドラを使わないモダン Web 開発を実現する「Snowpack」 - Qiita](https://qiita.com/yukukotani/items/dfe236d29f97b0986b91)
- [Snowpack で実現する未来のフロントエンド開発 - Qiita](https://qiita.com/nabeliwo/items/67cc7f2c67c905eddbc7)
- [ESModules について - Qiita](https://qiita.com/nanocloudx/items/f541c7ff95b10b40d189)
- [ES6のexportについて詳しく調べた - Qiita](https://qiita.com/senou/items/a2f7a0f717d8aadabbf7)