CSS
ejs
webpack
jacascript
frontend
PORTDay 17

Webpackのハマりポイント

PORT株式会社、フロントエンドエンジニアの@sigwygです。
NURO光の第一回工事から2ヶ月経ちますがネット開通してなくて涙目のホリデーシーズン、持ちネタはサイクロップスでした。最後のジェダイとかなかった。いいね?

この記事はPORT Advent Calenderの17日目になる筈だった記事です。


TL;DR

  • Webpackのハマりポイントをまとめた
  • ReactとかVue.jsとかないです

経緯

2016年はWebpackに疲れたので、しばらくnpm scriptだけでした。
小規模の案件ならpost-cssとbabelが動けばいいし、サーバー建てたいならpython叩くなりDockerコンテナに放り込むなりすればいい。特に不便はありませんでした。

そうこうするうちにWebpackもv3になり、なんだかんだで質問はちょくちょく投げられたり、書かないと忘れたり、時代に取り残される危機感もあり。

というわけで別プロジェクトのヘルプを機に返り咲きました。それが1ヶ月ほど前のことです。ひとまず動作を思い出しながら、中小規模の受託案件を想定して静的サイト向けのジェネレーターを作りました。

動作サンプル

sigwyg/frontend-static-boilerplate

  • NO React & 非SPA
  • JSはBabel でES6。後方互換はtransform-runtime
  • テンプレートはEJS
  • CSSはPostCSSで変換。CSSWringで圧縮。
  • 構文チェックはESLint, StyleLint, EditorConfig

意図的に学習コスト低めにしてある。というか短期の受託案件だとJSは簡単なUIアニメーションくらいなので、まずはES6を楽しめ的な。

所感

いや、やっぱ便利なんですけどねー。Webpack本来の役割であるところのアセット管理だけやる分には問題なくサクサク進みます。

なんでもできるからテンプレート突っ込んだりパス解決とか細かく調整しようとすると、メンテナンスコストに見合っているかは微妙という。RailsでWebPackerとか弄るよりだいぶマシなんでしょうけどね。環境構築終われば半分仕事が終わった気になる。

とはいえ、結果的には成功だったかなと。

技術的にバラツキのあるヘルプ要員が不定期に投下されて、特に問題なくスムーズに進められました。まあ作る分には、普通にES6とCSSを書けば良いだけですからね。てきとーにブロック単位でファイル分けてればコンフリクトすることも滅多にない。その辺はモジュールバンドラーの面目躍如といった所でしょうか。あとテンプレート機能いれたのは便利でしたね。ループとかけっこう活用されてました。

以下、ハマログが続きます。ハイクを詠め。

development か production かで出し分けたい

公式に書かれているので、そのまま採用する。けっこう充実してたので一通り目を通すと楽しかった。

package.json
...
  "scripts": {
    "start": "webpack-dev-server --config webpack.dev.js",
    "deploy": "yarn build:production",
    "build:production": "webpack --config webpack.prod.js",
    "lint": "yarn stylelint && yarn eslint",
    "stylelint": "stylelint 'src/**/*.css' --config .stylelintrc",
    "eslint": "eslint src"
  },
...

基本的にyarn startyarn deployしか使わない。

webpack.dev.js
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
const StylelintPlugin = require('stylelint-webpack-plugin');
const webpack = require('webpack');

module.exports = merge(common, {
  entry: [
    'webpack-dev-server/client?http://localhost:3355',
    'webpack/hot/only-dev-server',
    './src/main.js'
  ],
  devtool: 'inline-source-map',
  devServer: {
    contentBase: 'dist/',
    historyApiFallback: true,
    watchContentBase: true,
    port: 3355,
    hot: true,
    inline: true,
  },
  plugins: [
    new webpack.NamedModulesPlugin(),
    new webpack.NoEmitOnErrorsPlugin(),
    new webpack.HotModuleReplacementPlugin(),
    new StylelintPlugin({
      configFile: '.stylelintrc',
      files: 'src/**/*.css',
      formatter: require('stylelint-formatter-pretty'),
      emitErrors: true,
      failOnError: false,
      quiet: false
    })
  ],
...

↑開発用にはdevServerの設定とか書いて、本番用では圧縮とかファイル出力とか書いておく↓

webpack.prod.js
const path = require('path');
const merge = require('webpack-merge');
const common = require('./webpack.common.js');
const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const LicenseWebpackPlugin = require('license-webpack-plugin').LicenseWebpackPlugin;

module.exports = merge(common, {
  entry: [
    './src/main.js'
  ],
  plugins: [
    new ExtractTextPlugin({
        filename: '[name]-[hash].css',
        allChunks: true
    }),
    new webpack.optimize.UglifyJsPlugin({
      minimize: true,
      sourceMap: false,
      compressor: { warnings: false },
      output: { comments: false }
    }),
    new LicenseWebpackPlugin({
      pattern: /^(.*)$/,
      filename: 'licenses.txt'
    })
  ],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ExtractTextPlugin.extract({
          fallback: 'style-loader',
          use: [
            {
              loader: 'css-loader',
              options: {
                sourceMap: true,
              }
            },
            'postcss-loader',
          ]
        })
      },
...

泥臭くNODE_ENVでIF分岐や三項演算子を書くよりスッキリしている。

CSS内のurl()変換をしたい

CSSファイル内のurl() 関数記法から、画像ファイルなどにアクセスして変換噛ます(圧縮したりハッシュつけたり)には、Webpackがパスを辿って実ファイルを取得できる必要があります。

どういうことかっつーと、
url('/img/sample.jpg') ではダメ。
url('../img/sample.jpg') ならOK。
webpack-dev-serverで開発用サーバを立てた結果、アクセスできて表示されるからって油断してると「なんか変わってないなー」としばらくハマる羽目になる。devとprodでディレクトリ構造が変わる場合は注意ってことです。

problem with background url #256

webpack.common.js
module.exports = merge(pages, {
  resolve: {
    alias: {
      'assets': path.resolve(__dirname, 'src')
    }
  },
...
src/styles/components/contents.css
p {
    background: url('~assets/img/sample2.jpg');
}

resolve.alias を設定しておくと、変更に強い。
要はWebpackに通しておきさえすれば、後からパス変更はどうとでもなるので、フルパスでも良いのですよ。

webpack.dev.js
...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'style-loader?sourceMap',
          'css-loader?sourceMap',
          'postcss-loader?sourceMap=inline',
        ]
      },
      {
        test: /\.(png|jpg|jpeg|gif|svg|woff|eot|ttf)$/,
        use: [
          {
            loader: 'url-loader',
            options: {
              name: "[path][name].[hash].[ext]",
              limit: 100
            }
          }
        ]
      },
...
webpack.prod.js
...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ExtractTextPlugin.extract({
          fallback: 'style-loader',
          use: [
            {
              loader: 'css-loader',
              options: {
                sourceMap: true,
              }
            },
            'postcss-loader',
          ]
        })
      },
      {
        test: /\.(png|jpg|jpeg|gif|svg)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: "[name].[hash].[ext]",
              outputPath: "img/",
              publicPath: './',
            }
          },
...

パス解決はresolve-url-loaderを使うと巧くいく場合もあるようですが、今回の俺の環境だと特に変化は見られなかった。したがって、resolve.aliasに頼る形で、最終的に出力されるパスは outputPathpublicPathを適宜上書きするなどで調整しています。

パスとかファイル名の変更にはfile-loader が便利なのですが、url-loaderを同時に使おうとするとバグりやすいらしく、limitが効かなくなるのが、地味に不便です。image-webpack-loaderで最適化するからurl-loader捨てるという選択肢が検討されます。まあHTTP/2ならBase64化の恩恵はあんまりないし。

webpack.prod.css
      {
        test: /\.(png|jpg|jpeg|gif|svg)$/,
        use: [
          {
            loader: 'file-loader',
            options: {
              name: "[name].[hash].[ext]",
              outputPath: "img/",
              publicPath: './',
            }
          },
          {
            loader: 'image-webpack-loader',
            options: {
              mozjpeg: {
                progressive: true,
                quality: 65
              },
              optipng: {
                enabled: true,
              },
              pngquant: {
                quality: '65-90',
                speed: 4
              },
              gifsicle: {
                interlaced: false,
              },
            }
          },

Includeくらい使いたい。

テンプレート言語とまでいかなくとも、共通パーツのインクルードくらいしたい。なのでEJSを採用した。結果的にはIF文やループも使われたので良かった。とはいえ幾つか課題はあって、以下にTry&Errorを羅列する。

nesting templates using ejs #443

ejs-loader ではinclude()をサポートしてないので、カスタムローダーを使う。選択肢は幾つかあるが、公式のissueでよく引き合いに出されているejs-compiled-loader を使うことにした。

ENOENT: no such file or directory when passing local data using include #33

npmから普通に入れるとv1系が入るんだけど、v2のEJSを使いたいなら2系を入れる必要があるので、v2の構文を使いたかったり、includeが巧くいかない場合なんかは yarn add ejs-compiled-loader@2.x などして明示的に2系を入れ直す。

ここまででインクルードを含む最低限のテンプレート機能はクリアしている。課題は3つ。

  1. html-webpack-plugin経由だとコンパイルはしてくれるものの、HMRが発動しない。
  2. インクルードファイルはコンパイルもされないので、デプロイし直す必要がある。
  3. html-loaderのようにsrc属性などからfile-loaderなどに繋げられない

テンプレート更新時にブラウザを更新させる

HMR triggers full page reload since version 3.x with html-webpack-plugin 2.x #5505

この辺を参考に、html-webpack-harddisk-plugindevServer: { watchContentBase: true } を組み合わせることで、強制リロードはできた。つまり、webpack-dev-serverにcontentBase の変更を監視させ、出力した実ファイルを放り込んでフルリロードを行う。個人的にはHMRの部分更新にはさほど拘らないので、十分ではある。

webpack.dev.js
...
devServer: {¦                                                                                                                                             
    contentBase: 'dist/',¦                                                                                                                                  
    historyApiFallback: true,¦                                                                                                                              
    watchContentBase: true,¦                                                                                                                                
    port: 3355,¦                                                                                                                                            
    hot: true,¦                                                                                                                                             
    inline: true,¦                                                                                                                                          
},¦  
webpack.pages.js
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const HtmlWebpackHarddiskPlugin = require('html-webpack-harddisk-plugin');

module.exports = {
  plugins: [
    new HtmlWebpackPlugin({
      title: 'Frontend Static Boilerplate',
      description: 'Frontend Static Boilerplate',
      heading1: 'Top Page',
      template: 'src/templates/index.ejs',
      filename: 'index.html',
      alwaysWriteToDisk: true
    }),
...

子テンプレートの更新でrecompileさせる

Recompile parent template automatically #19

ejs-compiled-loader@2.x では、子テンプレートの変更が検知されない。つまり、<% include path/file %> で入れ込んだパーツの変更で再コンパイルを走らせるには、1.x系で書く必要がある。

2.x.ejs
<% include modules/header %>
1.x.ejs
<% include ./src/templates/modules/header %>

絶対パスか、webpack.config.js基準での相対パス指定しないといけない。変数も使えないのは泣ける。

テンプレート内の画像ファイルなどをWebpackに繋げる

The ejs file has a img element, look like url-loader don't loader it #275
Hooking up HTML Preprocessors like EJS, Pug, & Handlebars

EJSはサポートしてないからunderscore-templateとかhandolebars使えってなってる。あまりスマートではないが、いちおrequire() 噛ませばイケる。

index.ejs
<img src="<%= require('../img/sample.jpg') %>" alt="" width="200">

えーと、あとなんだっけ・・・

PORT株式会社では、フロントエンドエンジニアを募集しています。インクルーシブHTMLを読了しているマークアッパーだと僕が喜びます。