Snowpack v2 リリース - 標準 ES モジュールで戦う未来

[Snowpack](https://www.snowpack.dev) v2 がリリースされました[^v2release]。そもそも Snowpack とは何か、v1 からの変更点は何かを見ていきます。
（LGTM と感じたら Snowpack を使ってあげてください。たくさん使われるほど育つので。）

[^v2release]: https://www.snowpack.dev/posts/2020-05-26-snowpack-2-0-release/

cf. v1 のときの記事 [普通じゃ満足できない脱 webpack マニアのあなたに贈る Snowpack - Qiita](https://qiita.com/kazuma1989/items/2545845087b86e35b9ce)

**改訂履歴**

- v2.8 に対応（mount スクリプトの書き方が大幅に変更）
- Create Snowpack App (CSA) のリポジトリーリンクが古かったのを更新
- 自作のトランスパイルプラグインをやめた


後半では、Create React App で生成したトップページと同じものを作ってみます。

![Create React App で生成したトップページ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/118759/4ac0b12d-2cb3-ba17-e818-78c3776e5ff6.png)

とりあえず試してみたい方向け、CodeSandbox はこちら。
https://codesandbox.io/s/snowpack-v2-3gk56?file=/package.json




# Snowpack is 何

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

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

たとえば React は、次のように import して使います。この import 文は、webpack などのバンドラーが解釈し変換することで初めて動きます。

App.tsx

```tsx
import React from 'react'

export function App() {
  return <h1>Hello</h1>
}
```

一方、ブラウザーはもう標準で import が使えるようになっています[^caniuse]。これが ES modules ですが、それでも React の import に webpack を使うのは、残念ながら React パッケージ側が ES modules に対応していないからです。ES modules は新しめですし、React に関しては実質トランスパイルが必須なので ES modules だけでは使えないというのが影響しているのでしょう。

[^caniuse]: https://caniuse.com/#feat=es6-module

Snowpack はその点を補ってくれます。React や、ES modules に対応していない npm パッケージを変換し、ES modules で読み込めるようにしてくれます。

一度 npm パッケージを変換したらそれ以降、新規に npm パッケージを追加しない限り、Snowpack によるバンドルは発生しません。アプリケーションコードは ES modules で書けばよく、変更のたびにバンドルする必要がないのです。

次のように、node_modules 内のパッケージを web_modules というフォルダーに ES modules 形式で出力して、アプリケーションから使うイメージです。

```
node_modules/  ->  web_modules/
  react              react.js
  react-dom          react-dom.js
```

App.tsx

```diff
-import React from 'react'
+import React from '/web_modules/react.js'

 export function App() {
   return <h1>Hello</h1>
 }
```



## v1 との違い

v1 時点では前述の機能しか持ちませんでした。徹底してアプリケーションコードはノータッチ。拡張したければ Rollup プラグインや Babel プラグインを使う必要がありました。

開発サーバーも存在せず、Servør など別のパッケージを使う必要がありました。

v2 からは、**ES modules で高速に開発する** という思想はそのままに、よく使う機能が最初から組み込まれています。ある程度アプリケーションコードも触ってきます。


### 開発サーバーが組み込みに - `dev` コマンド

`snowpack dev` コマンドで開発サーバーを起動できます。次に説明する、import パスの変換もやってくれます。

API サーバーへのプロキシーも設定可能です。

<img width="567" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/118759/fc16a4b9-6a77-f539-703a-23238122a51a.png">


### import パスを _自然に_ 書けるように

`import React from '/web_modules/react.js'` は見慣れませんね。tsconfig.json に設定も追加せねばなりません。`import React from 'react'` と書きたくなります。とはいえブラウザーは前者の書き方でないとファイルを見つけられません。

v1 では、import パスを変換したければ Babel を設定する必要がありました。v2 からは、Snowpack がよしなに変換してくれます。

App.tsx

```diff
-import React from '/web_modules/react.js'
+import React from 'react' // v2 からこれで OK

 export function App() {
   return <h1>Hello</h1>
 }
```

内部では Go 製の [esbuild](https://github.com/evanw/esbuild) を使っているようです。JSX もデフォルトで変換してくれます。


### CSS や画像の import が可能に

v1 では、ブラウザーが標準で import できないものはやはり import できませんでした。v2 から、次の資材は import できるようになりました。

- CSS
- CSS modules（`import styles from './style.module.css'` のように拡張子を `.module.css` にすれば OK）
- JSON
- PNG, SVG


### 本番資材のビルドも可能に - `build` コマンド

本番資材は `snowpack build` でビルドできます。`dev` コマンドと同様 import パスの変換をしたうえで、index.html や favicon.ico などの静的資材もまとめてくれます。

また、オプションで [Parcel](https://parceljs.org) による最終バンドルもしてくれるようです。開発中は ES modules のスピードを享受しつつ、ES modules が使えないブラウザーや多数の通信がデメリットになる HTTP/1 環境に向けてもリリースが可能です。


### ~~node_modules 配下にないパッケージもバンドル可能に - `install` コマンドと webDependencies~~

v2.8 現在 webDependencies に関する言及はないようです。どこかの issue でメンテナーが「実験的な機能だから云々」という回答をしていた記憶があるので、不要と判断されたのかもしれません。


### 型検査や lint を実行可能に

v1 では、tsc や eslint コマンドを watch モードで別途起動する必要がありました。v2 からは、`dev` コマンドと同時にこれらのチェックを走らせることができます。詳しくは具体例の項で示します。


### Snowpack プラグインの利用が可能に

v1 時点でも、Snowpack の内部は Rollup なので Rollup プラグインとしてプラグインを使うことはできました。v2 からは、Snowpack 専用のプラグイン API が提供されます。

https://www.snowpack.dev/plugins


### ボイラープレートの自動生成が可能に - Create Snowpack App (CSA)

次のコマンドでボイラープレート（ブランクプロジェクト）の作成が可能になりました。使えるテンプレート一覧はこちら https://github.com/pikapkg/snowpack/tree/snowpack%402.8.0/packages/create-snowpack-app

```console
$ npx create-snowpack-app myappname --template @snowpack/app-template-blank
```

Yarn の場合。

```console
$ yarn create snowpack-app myappname --template @snowpack/app-template-blank --use-yarn
```




# Create React App で生成したトップページと同じものを作る

この画面を目指します。

![Create React App で生成したトップページ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/118759/4ac0b12d-2cb3-ba17-e818-78c3776e5ff6.png)

- React, ReactDOM
- TypeScript 3.9
- PWA 関係の設定はなし
- テストなし

## ファイル構成

種類別には次のとおり。


### npm 関連

```
├── package-lock.json
├── package.json
```

### Snowpack 関連

```
├── snowpack.config.json
└── web_modules/
    └── （snowpack が生成するので省略）
```

### ソースコード

```
├── public/
│   ├── favicon.ico
│   └── index.html
├── src/
│   ├── App.css
│   ├── App.tsx
│   ├── index.css
│   ├── index.tsx
│   └── logo.svg
```

### TS のオプションと追加の型定義

```
├── tsconfig.json
├── typings/
│   └── static.d.ts
```



## ファイルの中身

### npm 関連

package.json

```json
{
  "name": "snowpack-example",
  "version": "0.0.1",
  "license": "MIT",
  "scripts": {
    "postinstall": "snowpack",
    "start": "snowpack dev",
    "build": "snowpack build"
  },
  "dependencies": {
    "react": "^16.13.1",
    "react-dom": "^16.13.1"
  },
  "devDependencies": {
    "@snowpack/plugin-run-script": "^2.1.0",
    "@types/react": "^16.9.46",
    "@types/react-dom": "^16.9.8",
    "snowpack": "^2.8.0",
    "typescript": "^3.9.7"
  }
}
```


### Snowpack 関係

snowpack.config.json（または snowpack.config.js） に Snowpack の設定を書きます。

snowpack.config.json

```json
{
  "mount": {
    "public": "/",
    "src": "/_dist_"
  },
  "plugins": [
    [
      "@snowpack/plugin-run-script",
      {
        "cmd": "tsc --noEmit",
        "watch": "$1 --watch"
      }
    ]
  ],
  "buildOptions": {
    "sourceMaps": true
  }
}
```

- `mount` には `dev`/`build` コマンド時に何をどこへコピーするか指定します。オブジェクトのキーはプロジェクトルートとの相対パス、値は build フォルダーとの相対パスです。
    - 上記の設定だと、public 内のファイルは build へ、src 内のファイルは build/\_dist\_ へコピーされます（import パスの変換や TS のトランスパイルなどの処理後）。
- `plugins` に npm パッケージや自作のスクリプトを指定して、プラグインとして使うことができます。
    - 上記の設定では、公式の汎用プラグインを使って、tsc コマンドによる型検査を実行しています。
- `buildOptions.sourceMaps`（experimental らしい）でソースマップを有効にしています。

上記の `mount` 設定をして `snowpack build` をした結果は具体的には次のようになります。`snowpack dev` コマンドではこの build フォルダーは作られませんが、\_dist\_/index.js で JS コンテンツが返ってくるような開発サーバーが起動するので、同じような階層が内部で作られています。

```
build
├── _dist_/
│   ├── App.css
│   ├── App.css.proxy.js
│   ├── App.js
│   ├── index.css
│   ├── index.css.proxy.js
│   ├── index.js
│   ├── logo.svg
│   └── logo.svg.proxy.js
├── favicon.ico
├── index.html
└── web_modules/
    └── （省略）
```

※ 以前の記事では自作プラグインで JSX, TS を変換していましたが、Snowpack デフォルトの esbuild で十分だったので、やめました。

<details><summary>v2.6 以前で使っていた自作プラグイン</summary><div>

TypeScript Compiler API を使って、型検査はスキップしてソースをトランスパイルだけしています。

plugin/typescript.js

```js
// @ts-check
const ts = require('typescript')
const fs = require('fs').promises

module.exports = function plugin() {
  /**
   * The content of tsconfig.json relative to the CWD.
   *
   * @typedef {import('typescript').CompilerOptions} CompilerOptions
   * @type {Promise<{ compilerOptions: CompilerOptions }>}
   */
  const tsconfig = fs
    .readFile('tsconfig.json', { encoding: 'utf-8' })
    .then(
      source => ts.parseConfigFileTextToJson('tsconfig.json', source).config,
    )

  return {
    defaultBuildScript: 'build:ts,tsx',

    /**
     * Transpile TS/TSX source into JavaScript source.
     *
     * @param {object}  _
     * @param {string}  _.filePath
     * @param {string}  _.contents
     * @param {boolean} _.isDev
     * @returns {Promise<{ result: string; resources?: { css?: string } }>} contains JS source
     */
    async build({ filePath, contents }) {
      // d.ts files in web_modules will reach here, so I have to ignore them.
      if (filePath.endsWith('.d.ts')) return

      const { compilerOptions } = await tsconfig

      const result = ts.transpile(contents, compilerOptions, filePath)
      return { result }
    },
  }
}
```

</div></details>


#### Snowpack による自動生成

web_modules 配下は、Snowpack が npm パッケージを ES modules 対応に変換した結果を配置する場所です。これは package.json の `scripts.postinstall` の実行時、つまり `npm install` の直後に生成されます。[^postinstall]

[^postinstall]: 以前は `postinstall` より `prepare` が推奨と書いていましたが、根拠となる出典を見失ったのと、ここでの使い方ならむしろ `postinstall` のほうが適していそうなので、改めました。ごめんなさい。

package.json

```json
  "scripts": {
    "postinstall": "snowpack",
```


### ソースコード

public/index.html

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Snowpack Example</title>

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

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

index.html は原則 _そのままコピーされる_ ことを前提に作る必要があります。つまり次の点に気をつけます。

- ~~`%PUBLIC_URL%` のような環境変数は使えない（置換されない）~~
    - `%SNOWPACK_PUBLIC_*%`, `%PUBLIC_URL%`, `%MODE%` を置換してくれるようになったみたいです
- script タグは自動で挿入されない
    - そのため、ビルド後のパスを意識して `src="/_dist_/index.js"` と書く必要があります。

<details><summary>（古い方法）envsubstを使った環境変数の置換</summary><div>

ただし、プラグインを使えば HTML の書き換えも可能です。たとえば次のようにすれば、index.html 中の環境変数（`$PUBLIC_URL` の形式）を置換することが可能です。

snowpack.config.json

```diff
 {
   "mount": {
     "public": "/",
     "src": "/_dist_"
   },
   "plugins": [
     [
       "@snowpack/plugin-run-script",
       {
         "cmd": "tsc --noEmit",
         "watch": "$1 --watch"
       }
     ],
+    [
+      "@snowpack/plugin-build-script",
+      {
+        "input": [".html"],
+        "output": [".html"],
+        "cmd": "envsubst < $FILE"
+      }
+    ]
   ],
   "buildOptions": {
     "sourceMaps": true
   }
 }
```

</div></details>

残りのソースコードは Create React App が生成するのとほぼ同じ（PWA の設定を除いてある）ものなので、解説はスキップします。折りたたんであります。

<details><summary>src/App.css</summary><div>

```css
.App {
  text-align: center;
}

.App-logo {
  height: 40vmin;
  pointer-events: none;
}

@media (prefers-reduced-motion: no-preference) {
  .App-logo {
    animation: App-logo-spin infinite 20s linear;
  }
}

.App-header {
  background-color: #282c34;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

.App-link {
  color: #61dafb;
}

@keyframes App-logo-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
```

</div></details>


<details><summary>src/App.tsx</summary><div>

```tsx
import React from 'react'
import logo from './logo.svg'
import './App.css'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.tsx</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  )
}

export default App
```

</div></details>


<details><summary>src/index.css</summary><div>

```css
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

code {
  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
    monospace;
}
```

</div></details>


<details><summary>src/index.tsx</summary><div>

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

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

</div></details>


<details><summary>src/logo.svg</summary><div>

```xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
    <g fill="#61DAFB">
        <path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
        <circle cx="420.9" cy="296.5" r="45.7"/>
        <path d="M520.5 78.1z"/>
    </g>
</svg>
```

</div></details>


### TS のオプションと追加の型定義

一般的な設定で OK です。

tsconfig.json

```json
{
  "compilerOptions": {
    // Target latest browsers
    "target": "ES2019",
    "lib": ["ES2019", "DOM", "DOM.Iterable"],

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

    // <div /> => React.createElement("div")
    // `import React` instead of `import * as React`
    "jsx": "react",
    "allowSyntheticDefaultImports": true,

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

  "include": ["./typings/**/*.d.ts", "./src/**/*"]
}
```

typings/static.d.ts

```ts
declare module '*.css'

declare module '*.svg' {
  const ref: string
  export default ref
}
```

ここまで作りきって、プロジェクト直下（package.json と同じ階層）で次のコマンドを実行します。

```console
$ npm install
$ npm start
```

開発サーバーが起動して、http://localhost:8080 に画面が表示されれば成功です。




# Snowpack を使った感想

## 依存が少なくて手軽

package.json の devDependencies は、型定義を除くとたったこれだけです。

```json
  "devDependencies": {
    "@snowpack/plugin-run-script": "^2.1.0",
    "snowpack": "^2.8.0",
    "typescript": "^3.9.7"
  }
```

開発を進めるとこれよりも増えていくのでしょうが、最小構成ですら以下のようになる webpack と比べると、だいぶ心理的負担が少なくなります。

```json
  "devDependencies": {
    "webpack": "",
    "webpack-dev-server": "",
    "webpack-html-plugin": "",
    "ts-loader": "",
    "css-loader": "",
    "file-loader": "",
    "typescript": "^3.9.7"
  }
```

設定ファイルは書かないといけませんが、それでも肥大化＋ロジックの紛れ込みがちな webpack.config.js よりはすっきりと抑えられると思います。

とはいえ、これらのメリットは webpack の自由度の高さとトレードオフなので、がっつりとチューニングをしたい（そして継続メンテする体力がある）ときは webpack が良いでしょう。



## チャンク分割が簡単

最大のメリットはこれだと思います。アプリケーションコードをばらばらのままデプロイし、依存をブラウザーに解消してもらうので、ビルド時にあれこれ考えなくて良くなっています。

読み込みの必要なスクリプトの総量が減るわけではないので、dynamic import やそもそものダイエットなど基本的な施策は必要ですが、それは webpack も同じです。



## バンドラーの移行先としても現実味を帯びてきた

既存の webpack プロジェクトも、ローダーとプラグインへの依存次第ではほとんどそのまま移せると思います。CSS modules もありますし、大体要件は満たせそう。

逆に、移行できないような webpack プロジェクトは特殊なローダーを使っている可能性が高いので、webpack.config を継続メンテする覚悟が必要かも。

---

以上です。LGTM と感じたら Snowpack を使ってあげてください。




# 参考

- [モジュールバンドラを使わないモダン 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)