藻ログ

都会でOLをしています

がんばらない webpack

f:id:nisimur:20190105045436p:plain

TL;DR

  • webpack はあまりにも色々できすぎるので最低限の機能を押さえる
  • babel-loader 使いません (ECMAScript 6 compatibility table を睨んで必要なら使う)

高度に発達した npm script は魔術と区別がつかない

こんな記事を書きましたが,npm scriptbrowserify で頑張るの普通にしんどいので webpack に移行する気持ちになりました

Chrome Extension

全人類一度は作ったことがあると思われる*1 Chrome 拡張の開発環境 を webpack で揃えてみます.
以下の構成で $ webpack ... すると,dist 以下に chrome拡張のコアが生成され,配布できる状態をイメージしています

├── dist # generated
│   ├── background.bundle.js
│   ├── bundle.css
│   ├── manifest.json
│   ├── popup.bundle.js
│   ├── popup.html
│   ├── scss.bundle.js
│   └── vendor.bundle.js
├── manifest.json
├── package-lock.json
├── package.json
├── src
│   ├── html
│   │   ├── _chip.html
│   │   ├── options.html
│   │   └── popup.html
│   ├── js
│   │   ├── background.js
│   │   └── popup.js
│   └── scss
│       └── custom.scss
└── webpack.config.js

色々自動でやってくれるものは無限にあります *2 が,自由度が低いもので自作が良いです *3

はじめての webpack

webpack.js.org

install

$ npm i --save-dev webpack@3
  • webpack.config.js に設定を書き,webpack ... を実行してターゲットを生成するのが基本になります.
  • npm v5.2.0+ であれば npx コマンドを npm run 〜の代わりに使えます
$ webpack [options]  # npm i -g で入れた webpack
$ npx webpack [options]   # node_modules/ のwebpack 

webpack3かよ

後述の Getting Started - Material Components for the Web が webpack3 だったんです...

最も簡単な bundle

  • 以下の設定で dist/bundle.js が生成され,ブラウザで実行できます
// webpack.config.js
const path = require('path');

module.exports = {
  entry: path.resolve(__dirname, 'src', 'test.js'),
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
}

複数のエントリーポイントと出力を指定する

  • Chrome 拡張では構成上 popup.js, background.js, contentscript.js ... とファイルが分割されている必要があります*4
  • 以下のように entry を複数指定すると,dist/background.bundle.js dist/popup.bundle.js と分けて生成することができます
// webpack.config.js
const path = require('path');

module.exports = [{
    entry: {
        background: path.join(__dirname, "src", "js", "background.js"),
        popup: path.join(__dirname, "src", "js", "popup.js"),
    },
    output: {
        path: path.join(__dirname, "dist"),
        filename: "[name].bundle.js"
    },
}];

通化して使う外部ライブラリを bundle する

const $ = require("jquery");
const _ = require("lodash");
  • jquery, lodash のような共通で使いたいライブラリはまとめて bundle することもできます
  • webpack.optimize.CommonsChunkPlugin を使えば,dist/vendor.bundle.js を簡単に生成できる
// webpack.config.js
const path = require('path');
const webpack = require('webpack');

module.exports = [{
    entry: {
        background: path.join(__dirname, "src", "js", "background.js"),
        popup: path.join(__dirname, "src", "js", "popup.js"),
        vendor: ['jquery', 'lodash']
    },
    output: {
        path: path.join(__dirname, "dist"),
        filename: "[name].bundle.js"
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            minChunks: Infinity,
        }),
    ]
}];

webpack で html を扱う

↑ で bundle した js を html でロードする

  • 手でやらなくても HtmlWebpackPlugin が自動解決してくれます.
$ npm i --save-dev html-webpack-plugin
  • 以下のように記述すると,popup: vendor: の output (*.bundle.js) を読み込む scriptノードを 指定の HTMLに勝手に挿入してくれます.
// webpack.config.js
module.exports = [{
    entry: {
        background: path.join(__dirname, "src", "js", "background.js"),
        popup: path.join(__dirname, "src", "js", "popup.js"),
        vendor: ['jquery', 'lodash']
    },
...
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            minChunks: Infinity,
        }),
        new HtmlWebpackPlugin({
            filename: 'popup.html',
            chunks: ['popup', 'vendor'],
            template: path.join(__dirname, "src", "html", "popup.html")
        }),
// popup.html
+ <script type="text/javascript" src="vendor.bundle.js"></script><script type="text/javascript" src="popup.bundle.js">

template を扱う

  • lodash や ES6 の template を扱う場合,html template を別ファイルに分離したくなったりする
  • html-loader を使えば js から html が自由に読み込める(そして webpack 側で処理してくれるので,dist にコピーする必要がない)
$ npm i --save-dev html-loader
// webpack.config.js
...
    module: {
        rules: [{
            test: /\.scss$/,
            use: [
            ...
        },
        {
            test: /\.html$/,
            use: [
                {
                    loader: 'html-loader',
                    options: {includePaths: ['./src/html']}
                } ]
        }]
    }
const templateStr = require('path/to/_chip.html');
const compiled = _.template(templateStr);
// compiled({ attr: val , ... });

webpack で scss を扱う

material components for the web

  • scss(sass) をいい感じにコンパイルしたり依存関係を解決するのも webpack でやってみます.
  • MDLを使おうと思ったら開発終了してた...*5 ので material components for the web を使うことにします.

github.com

install deps

$ npm i --save-dev sass-loader file-loader
sass-loader Loads a Sass file and compiles it to CSS
file-loader Serves the .css file as a public URL

install MDC

material-design-components (MDC) は全部一気に入れたり

$ npm i --save-dev material-components-web

必要な分だけバラバラに入れたりできます.

$ npm i --save-dev @material/button @material/textfield

必要な分だけ bundle したいので今回は後者を選択します*6

sass-loader

  • module.rules に test と use を記述すれば,指定のファイルを処理する際に通す loader が指定できます.
  • 以下の設定で dist/bundle.css が吐けるので,それを読み込めば OK
  • エントリーポイントには custom.scss を指定する *7
// webpack.config.js
...
    entry: {
        background: path.join(__dirname, "src", "js", "background.js"),
        popup: path.join(__dirname, "src", "js", "popup.js"),
        scss: path.join(__dirname, "src", "scss", "custom.scss"),
        vendor: ['jquery', 'lodash']
    },
...
    module: {
        rules: [{
            test: /\.scss$/,
            use: [
                {
                    loader: 'file-loader',
                    options: { name: 'bundle.css' }
                },
                {
                    loader: 'sass-loader',
                    options: { includePaths: ['./node_modules'] }
                },
            ]
        }]
    }

sass-loader の includePaths 指定がなければ node_modules 以下を探しにいってくれません.

 ERROR in ./src/scss/custom.scss
    Module build failed:
    @import '@material/button/mdc-button';
    ^
          File to import not found or unreadable: @material/button/mdc-button.
          in /Users/mai/Garage/arXiv2scrap/src/scss/custom.scss (line 1, column 1)

こんな感じで npm で入れた MDC の *.scss をガンガン import できます. スッキリ

# custom.scss
@import '@material/button/mdc-button';
@import '@material/icon-button/mdc-icon-button';
...

MDC の components は,mdc.autoInit() を使えば単純なものならまとめて js のハンドリングを付与できます(力強いが...)
色々カスタムする場合は以下のように1つずつ初期化します

const $ = require('jquery');

const MDCTextField = require('@material/textfield').MDCTextField;
const textfields = $('.mdc-text-field');
for (const tf of textfields) { new MDCTextField(tf); }

manifest.json を dist にコピーする

  • Chrome 拡張のコアである manifest.json(の中身については本記事で扱わない)
  • npm script では cat とかで複製してましたが,コピー用のプラグインがあります
  • 釈然としないがとりあえずこれでコピー問題は解決できた...
$ npm i --save-dev copy-webpack-plugin
// webpack.config.js
    plugins: [
        ...
        new CopyWebpackPlugin([
            {
                from: path.join(__dirname, 'manifest.json'),
                to: path.join(__dirname, 'dist')
            },
            ...
        ])
    ],

高度に発達した webpack は魔術と区別がつかない

はい

package.json

こんな感じになります.webpack の設定に逃したので npm script がスッキリしました(当たり前)

{
  "name": "webpack ...",
  "version": "0.0.1",
  "description": "simple webpack example",
  "scripts": {
    "build": "webpack -p",
    "watch": "webpack --watch",
    "clean": "rm -rf dist/*"
  },
  "author": "denkiwakame",
  "dependencies": {
     "@material/button": "^0.42.0",
    ...
    "@material/textfield": "^0.42.1",
    "jquery": "^3.3.1",
    "lodash": "^4.17.11",
  },
  "devDependencies": {
    "copy-webpack-plugin": "^4.6.0",
    "file-loader": "^3.0.1",
    "html-loader": "^0.5.5",
    "html-webpack-plugin": "^3.2.0",
    "node-sass": "^4.11.0",
    "sass-loader": "^7.1.0",
    "webpack": "^3.12.0",
  }
}

webpack.config.js

こんな感じになりました.webpack --watch で js と css のビルドを1本化して丸ごと監視できるのは良いのかなと思いました

$ npx webpack --watch

で継続監視ビルドにできます.tsc -w とか watchify とか catw の組み合わせでなく,一本化できるのは楽かもしれません.

const path = require('path');
const webpack = require('webpack');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = [{
    entry: {
        background: path.join(__dirname, "src", "js", "background.js"),
        popup: path.join(__dirname, "src", "js", "popup.js"),
        scss: path.join(__dirname, "src", "scss", "custom.scss"),
        vendor: ['jquery', 'lodash']
    },
    output: {
        path: path.join(__dirname, "dist"),
        filename: "[name].bundle.js"
    },
    plugins: [
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            minChunks: Infinity,
        }),
        new HtmlWebpackPlugin({
            filename: 'popup.html',
            chunks: ['popup', 'vendor'],
            template: path.join(__dirname, "src", "html", "popup.html")
        }),
        new CopyWebpackPlugin([
            {
                from: path.join(__dirname, 'manifest.json'),
                to: path.join(__dirname, 'dist')
            },
        ])
    ],
    module: {
        rules: [{
            test: /\.scss$/,
            use: [
                {
                    loader: 'file-loader',
                    options: { name: 'bundle.css' }
                },
                {
                    loader: 'sass-loader',
                    options: { includePaths: ['./node_modules'] }
                },
            ]
        }
    }
}];

感想

  • 全部 webpack に 1本化できた
  • material components for the web(本家),つらかったので今後は別のフレームワーク使いたい
  • 小規模なら skeleton-css とかを使うのが良い(SASSも不要)
  • css コンパイル周りが javascript 原理主義すぎて不安になる(そこまで全てが javascript で読めなくても良い)
  • Chrome 拡張を例にとったが,あまり意味がなかった

[追記] webpack3 -> 4 への移行

この拡張を作るときに webpack4 へ引っ越しました.
wakame.hatenablog.jp

箱根駅伝を見ながら覚えた webpack3,ほぼ1日の付き合いだった

$ npm i --save-dev webpack@4
$ npm i --save-dev webpack-cli

発端

  • 元々 material components for the web で webpack@3 が使われていたので合わせていたが,以下の問題がありました

UglifyJsPlugin: ES6 Methods Throw Parse Error · Issue #1542 · webpack/webpack · GitHub

  • webpack -p する際,webpack3 が内部で読んでいる uglifyjs だと ES6 を解釈できないため (1) webpack4 に移行 (2) ES6 を babel で変換 (3) ダウングレードした 自前の uglifyjs を用意して config で分岐 の三択に
  • (2) は古いブラウザのサポートを捨てて babel を回避すると決めたので不可
  • (3) は webpack.config.js が汚くなった
  • (1) が一番綺麗に解決できる(configもcleanupできる) ということで webpack4 に移行した

CommonsChunkPlugin の死

webpack.optimize.CommonsChunkPlugin has been removed, please use config.optimization.splitChunks instead.

https://qiita.com/soarflat/items/f8212434c4c3cb8ee00d

+    optimization: {
+      splitChunks: {
+        name: 'vendor',
+        chunks: 'initial',
+     }
    },
    plugins: [
-        new webpack.optimize.CommonsChunkPlugin({
-            name: 'vendor',
-            minChunks: Infinity,
-        }),
        new HtmlWebpackPlugin({
            filename: 'popup.html',

幸い大したことをやっていなかったので軽傷ですみました.

*1:Overview - Google Chrome で大体わかります

*2:GitHub - HaNdTriX/generator-chrome-extension-kickstart: Scaffold out a Web Extension http://yeoman.io

*3:そもそも簡単な拡張なら node の機能を使わず生書きすれば良いという話はあるが

*4:CommonJS <-> browser JSの変換以外に bundleされたら困る

*5:GitHub - google/material-design-lite: Material Design Components in HTML/CSS/JS

*6:全部入りならもうCDNで良いかもしれない

*7:custom.bundle.js が生成されるが直接は利用しない. 合ってるのかこれで...?