Snowpack v2 がリリースされました1。そもそも Snowpack とは何か、v1 からの変更点は何かを見ていきます。
(LGTM と感じたら Snowpack を使ってあげてください。たくさん使われるほど育つので。)
cf. v1 のときの記事 普通じゃ満足できない脱 webpack マニアのあなたに贈る Snowpack - Qiita
改訂履歴
- v2.8 に対応(mount スクリプトの書き方が大幅に変更)
- Create Snowpack App (CSA) のリポジトリーリンクが古かったのを更新
- 自作のトランスパイルプラグインをやめた
後半では、Create React App で生成したトップページと同じものを作ってみます。
とりあえず試してみたい方向け、CodeSandbox はこちら。
https://codesandbox.io/s/snowpack-v2-3gk56?file=/package.json
Snowpack is 何
npm パッケージを、ブラウザーから読み込めるよう ES modules 形式2に変換してくれるバンドラーです。
たとえば React は、次のように import して使います。この import 文は、webpack などのバンドラーが解釈し変換することで初めて動きます。
App.tsx
import React from 'react'
export function App() {
return <h1>Hello</h1>
}
一方、ブラウザーはもう標準で import が使えるようになっています3。これが ES modules ですが、それでも React の import に webpack を使うのは、残念ながら React パッケージ側が ES modules に対応していないからです。ES modules は新しめですし、React に関しては実質トランスパイルが必須なので ES modules だけでは使えないというのが影響しているのでしょう。
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
-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 サーバーへのプロキシーも設定可能です。
import パスを 自然に 書けるように
import React from '/web_modules/react.js'
は見慣れませんね。tsconfig.json に設定も追加せねばなりません。import React from 'react'
と書きたくなります。とはいえブラウザーは前者の書き方でないとファイルを見つけられません。
v1 では、import パスを変換したければ Babel を設定する必要がありました。v2 からは、Snowpack がよしなに変換してくれます。
App.tsx
-import React from '/web_modules/react.js'
+import React from 'react' // v2 からこれで OK
export function App() {
return <h1>Hello</h1>
}
内部では Go 製の 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 による最終バンドルもしてくれるようです。開発中は ES modules のスピードを享受しつつ、ES modules が使えないブラウザーや多数の通信がデメリットになる HTTP/1 環境に向けてもリリースが可能です。
node_modules 配下にないパッケージもバンドル可能に - install
コマンドと webDependencies
install
コマンドと webDependenciesv2.8 現在 webDependencies に関する言及はないようです。どこかの issue でメンテナーが「実験的な機能だから云々」という回答をしていた記憶があるので、不要と判断されたのかもしれません。
型検査や lint を実行可能に
v1 では、tsc や eslint コマンドを watch モードで別途起動する必要がありました。v2 からは、dev
コマンドと同時にこれらのチェックを走らせることができます。詳しくは具体例の項で示します。
Snowpack プラグインの利用が可能に
v1 時点でも、Snowpack の内部は Rollup なので Rollup プラグインとしてプラグインを使うことはできました。v2 からは、Snowpack 専用のプラグイン API が提供されます。
ボイラープレートの自動生成が可能に - Create Snowpack App (CSA)
次のコマンドでボイラープレート(ブランクプロジェクト)の作成が可能になりました。使えるテンプレート一覧はこちら https://github.com/pikapkg/snowpack/tree/snowpack%402.8.0/packages/create-snowpack-app
$ npx create-snowpack-app myappname --template @snowpack/app-template-blank
Yarn の場合。
$ yarn create snowpack-app myappname --template @snowpack/app-template-blank --use-yarn
Create React App で生成したトップページと同じものを作る
この画面を目指します。
- 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
{
"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
{
"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 で十分だったので、やめました。
v2.6 以前で使っていた自作プラグイン
TypeScript Compiler API を使って、型検査はスキップしてソースをトランスパイルだけしています。
plugin/typescript.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 }
},
}
}
Snowpack による自動生成
web_modules 配下は、Snowpack が npm パッケージを ES modules 対応に変換した結果を配置する場所です。これは package.json の scripts.postinstall
の実行時、つまり npm install
の直後に生成されます。4
package.json
"scripts": {
"postinstall": "snowpack",
ソースコード
public/index.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"
と書く必要があります。
- そのため、ビルド後のパスを意識して
(古い方法)envsubstを使った環境変数の置換
ただし、プラグインを使えば HTML の書き換えも可能です。たとえば次のようにすれば、index.html 中の環境変数($PUBLIC_URL
の形式)を置換することが可能です。
snowpack.config.json
{
"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
}
}
残りのソースコードは Create React App が生成するのとほぼ同じ(PWA の設定を除いてある)ものなので、解説はスキップします。折りたたんであります。
src/App.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);
}
}
src/App.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
src/index.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;
}
src/index.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'),
)
src/logo.svg
<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>
TS のオプションと追加の型定義
一般的な設定で OK です。
tsconfig.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
declare module '*.css'
declare module '*.svg' {
const ref: string
export default ref
}
ここまで作りきって、プロジェクト直下(package.json と同じ階層)で次のコマンドを実行します。
$ npm install
$ npm start
開発サーバーが起動して、http://localhost:8080 に画面が表示されれば成功です。
Snowpack を使った感想
依存が少なくて手軽
package.json の devDependencies は、型定義を除くとたったこれだけです。
"devDependencies": {
"@snowpack/plugin-run-script": "^2.1.0",
"snowpack": "^2.8.0",
"typescript": "^3.9.7"
}
開発を進めるとこれよりも増えていくのでしょうが、最小構成ですら以下のようになる webpack と比べると、だいぶ心理的負担が少なくなります。
"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
- Snowpack で実現する未来のフロントエンド開発 - Qiita
- ESModules について - Qiita
- ES6のexportについて詳しく調べた - Qiita
-
https://www.snowpack.dev/posts/2020-05-26-snowpack-2-0-release/ ↩
-
以前は
postinstall
よりprepare
が推奨と書いていましたが、根拠となる出典を見失ったのと、ここでの使い方ならむしろpostinstall
のほうが適していそうなので、改めました。ごめんなさい。 ↩