シンクロ・フード エンジニアブログ

飲食店ドットコムを運営する、株式会社シンクロ・フードの技術ブログです

Compassのsprite-mapによるスプライト画像生成を、spritesmithに移行する

シンクロ・フードでフロントエンドの開発を担当している四之宮です。
最近は、ReactでLine風のやりとり機能を実装しました。
近々こちらについても、ご紹介できたらと思います。

では、本題に入っていきたいと思います。
タイトルには入っていないですが、レガシーシリーズもこれが最後だと思います。

Sass+Compassについて

弊社ではSass+Compassでcssのコーディングを行っています。
今では、「Compassは終焉を迎えた」などと言われていますが当時はこれがテッパンだっと思います。
ベンダープレフィックス対応はCompassが提供する各mixin、スプライト画像の対応もCompassが提供するsprite-map、これらを使用してコーディングしました。

その後、ベンダープレフィックス対応はautoprefixerが主流となりました。

www.npmjs.com

スプライト画像に関しては、アイコンフォントを使用することでスプライト画像自体の使用をやめることも増えたのではないでしょうか?

本記事で説明すること

スプライト画像対応に絞って説明していきます。

弊社ではこのベンダープレフィックス対応とスプライト画像の対応が、Compassに依存していました。
autoprefixerへの移行に関しては、記事も多いので割愛させていただきます。

そして、スプライト画像対応に関しては、既存の画像をそのまま使用する方向で対応します。

脱Compassを行った理由

世の中の流れというものありますが、やはり一番の理由はコンパイルの速度向上を目指したためです。
scssファイルの数を増えてからコンパイルに時間がかかり、開発効率が悪いという課題がありました。

そこで、コンパイルの高速化プロジェクトが開始しました。
まず、最初に行ったことですが、@import "compass";の記述をやめました。
必要に応じて、関数名まで指定するという対応を取りました。

例えば、@include border-radius();を使用したいとします。
このとき、ファイルのトップで、@import "compass";を書いてはいけません。
@import "compass/css3/border-radius";と書きます。
この対応だけでも早くなったと体感できるレベルで改善しました。

更に、libsassを使用したgulp-sassにしたいと思ったのですが、こちらはCompass対応していないので、対応見送りとなりました。

しばらくの間は、これで満足していたのですが、慣れというのは怖いですね。この速度では満足できなくなってきました。
コンパイルの時間を見てみると、一番時間がかかっているのはスプライト画像周りの処理でした。
これを解決しない限りはどうしようもないということになり、別の方法でスプライト画像を対応しようとなりました。

スプライト画像対応の移行

gulp.spritesmithを使用します。

www.npmjs.com

npm install --save gulp.spritesmith  

これは、指定したディレクトリに格納された画像を、1つの画像にしてこれに対応するscssファイルを書き出すというものです。
詳しい使用方法はこのあと記述していきます。

gulpfile.jsの対応

本来の使い方から少し拡張して使用しています。
1サイトで1スプライト画像というのが世の中の流れなのかもしれませんが、弊社ではページ単位などで管理していたためこの対応が必要となりました。
元々、gulp.spritesmithはディレクトリ単位でスプライト画像を生成する機能を持たないため、これに対応するための書き方をしています。
これを実現するために、更にglobbyというnpmを使用します。

www.npmjs.com

更に、複数のストリームがある状態で非同期化したいので、merge-streamというnpmを使用します。

www.npmjs.com

1サイトで1スプライト画像の構成になっていれば、この対応は不要になります。

var spritesmith = require("gulp.spritesmith");  
var globby = require('globby');  
var Merged = require('merge-stream');  
  
var root = "src/main/webapp",  
config = {  
   "path" : {  
      "htdocs"    : root,  
      "spriteSass"      : root+"/spriteSass",  
      "spriteImage"    : root+"/spriteImage",  
      "image"     : root+"/image/sprite"  
   }  
};  
  
gulp.task('sprites', ['cleanやcopyなど'], function() {  
    var merged = new Merged();  
  
    globby('src/main/webapp/image/sprite/**/*.png').then(paths => {  
        paths.forEach(function(path) {  
            var filePath = path.match(/^(.+\/)(.+?)(\/.+?\..+?)$/);  
            var spritePath = (filePath[1] + filePath[2]).replace("src/main/webapp/image/sprite", '');  
            var spriteData = gulp.src(filePath[1] + filePath[2] + '/*.png')  
                .pipe(plumber())  
                .pipe(spritesmith({  
                    imgName: filePath[2] + '.png', //生成される画像名を指定します(ここでは各ディレクトリ名になります)  
                    imgPath: '/spriteImage' + spritePath + '/' + filePath[2] + '.png', //生成される画像のパスを指定します  
                    cssName: filePath[2] + '.scss', //生成されるscss名を指定します(ここでは各ディレクトリ名になります)  
                    algorithm: 'top-down', //スプライト画像になるときの画像の並びを指定します  
                    padding: 100 //スプライト画像になるときの、各画像の間隔を指定します  
                }));  
            var imgStram = spriteData.img.pipe(gulp.dest(spriteImage + spritePath));  
            var SassStram = spriteData.css.pipe(gulp.dest("src/main/webapp/spriteSass/" + spritePath));  
  
            merged.add(imgStram);  
            merged.add(SassStram);  
        });  
    });  
    return merged;  
});  

algorithmについては以下が指定できます。
※sprite-mapにあった$position: 100%のような指定はできないようです

https://github.com/twolfson/gulp.spritesmith#algorithms

ここでは使用していないオプションも他にも用意されています。

https://github.com/twolfson/gulp.spritesmith#documentation

scssファイルの対応

  1. sprite-mapでgrep
  2. 対象のscssファイルのsprite-mapの記述を、gulp.spritesmithで生成された対象のscssをimportするように修正
  3. スプライト画像設置用mixinの呼び出しを消す
    • mixin化していなかったらスプライト画像呼び出しの記述を全て消す
  4. 画像を表示するセレクターに@include sprite(○○);のような記述をしていく
    • spriteという関数はgulp.spritesmithで生成されたscss内に宣言されたmixinです
    • ○○にはimportしたスプライト用scss内にある変数を記述します
    • 恐らく$画像名となっているはずです

アイコンなどの設置を空要素で行っていればこの手順でOKです。
もし、空要素ではなくテキストなどが書かれたタグに対して、アイコンを設置していた場合は、アイコン用の要素を設置する必要があります。
弊社ではこの形になっていたので、かなり厳しい対応となりました。
これについては、後ほど実際のコードがでてくるところで説明します。

scssのコンパイル処理の書き換え

Compass依存がなくなったので、gulp-sassを使用してコンパイルするようにします。
gulp-ruby-sassというものもありますが、libsassを使用しているgulp-sassの方が処理が早いので、こちらを選択しました。

www.npmjs.com

npm install --save gulp-sass  

gulpfile.jsの対応

var sass = require('gulp-sass');  
  
gulp.task('sass', ['clean'], function () {  
    return gulp.src('src/main/webapp/sass/**/*scss')  
        .pipe(sass({includePaths: ['src/main/webapp/sass/', 'src/main/webapp/spriteSass/']}).on('error', sass.logError))  
        .pipe(gulp.dest('src/main/webapp/stylesheets/'));  
});  

src/main/webapp/spriteSass/はspritesmithで生成されたscssファイルがあるディレクトリを指定しています。

全体像

gulp.task('sprites', ['cleanやcopyなど'], function() {  
    var merged = new Merged();  
  
    globby('src/main/webapp/image/sprite/**/*.png').then(paths => {  
        paths.forEach(function(path) {  
            var filePath = path.match(/^(.+\/)(.+?)(\/.+?\..+?)$/);  
            var spritePath = (filePath[1] + filePath[2]).replace("src/main/webapp/image/sprite", '');  
            var spriteData = gulp.src(filePath[1] + filePath[2] + '/*.png')  
                .pipe(plumber())  
                .pipe(spritesmith({  
                    imgName: filePath[2] + '.png',  
                    imgPath: '/spriteImage' + spritePath + '/' + filePath[2] + '.png',  
                    cssName: filePath[2] + '.scss',  
                    algorithm: 'top-down',  
                    padding: 100  
                }));  
            var imgStram = spriteData.img.pipe(gulp.dest(spriteImage + spritePath));  
            var SassStram = spriteData.css.pipe(gulp.dest("src/main/webapp/spriteSass/" + spritePath));  
  
            merged.add(imgStram);  
            merged.add(SassStram);  
        });  
    });  
    return merged;  
});  
  
gulp.task('spritesSass', ['sprites'], function () {  
    return gulp.src('src/main/webapp/sass/**/*scss')  
        .pipe(sass({includePaths: ['src/main/webapp/sass/', 'src/main/webapp/spriteSass/']}).on('error', sass.logError))  
        .pipe(gulp.dest(cssPath));  
});  
  
gulp.task('sass', function () {  
    return gulp.src('src/main/webapp/sass/**/*scss')  
        .pipe(sass({includePaths: ['src/main/webapp/sass/', 'src/main/webapp/spriteSass/']}).on('error', sass.logError))  
        .pipe(gulp.dest(cssPath));  
});  
  
gulp.task("spritesAutoprefixer", ['spritesSass'], function() {  
    return gulp.src(cssPathRegexp)  
        .pipe(autoprefixer({  
            browsers: ['last 2 version', 'IE >= 9', 'iOS >= 8.1', 'Android >= 4.4'],  
            cascade: false  
        }))  
        .pipe(gulp.dest(cssPath));  
});  
  
// watchタスク  
gulp.task('watch', ['spritesAutoprefixer'], function () {  
    gulp.watch("src/main/webapp/sass/**/*scss", ['sass']);  
    gulp.watch('src/main/webapp/image/sprite/**/*.png', ['spritesSass']);  
});  

実際のコーディング

  1. スプライト用画像をディレクトリへ入れる
    • 自動でスプライト画像の生成が行われ、これに対応するscssファイルも生成される
    • このscssファイルの中に、スプライト画像を読み込むためのmixinや変数が宣言されている
  2. このscssファイルをスプライト画像でimportする
    • こんな感じで呼び出す @include sprite($next-button);
  3. このmixinによって、画像の縦横幅を指定されるので、空要素を置いて対応する
    • inline要素の場合、blockやinline-blockにするのを忘れないように

画像ディレクトリから以下のようなファイルが出来上がったとします。
対象のディレクトリにはexample_iconがあったということです。

  • sprite.scss
$example-icon-name: 'example_icon';  
$example-icon-x: 0px;  
$example-icon-y: 1591px;  
$example-icon-offset-x: 0px;  
$example-icon-offset-y: -1591px;  
$example-icon-width: 50px;  
$example-icon-height: 50px;  
$example-icon-total-width: 50px;  
$example-icon-total-height: 1641px;  
$example-icon-image: '/spriteImage/top/sprite/sprite.png';  
$example-icon: (0px, 1591px, 0px, -1591px, 50px, 50px, 50px, 1641px, '/spriteImage/top/sprite/sprite.png', 'example_icon', );  
  
@mixin sprite-width($sprite) {  
  width: nth($sprite, 5);  
}  
  
@mixin sprite-height($sprite) {  
  height: nth($sprite, 6);  
}  
  
@mixin sprite-position($sprite) {  
  $sprite-offset-x: nth($sprite, 3);  
  $sprite-offset-y: nth($sprite, 4);  
  background-position: $sprite-offset-x  $sprite-offset-y;  
}  
  
@mixin sprite-image($sprite) {  
  $sprite-image: nth($sprite, 9);  
  background-image: url(#{$sprite-image});  
}  
  
@mixin sprite($sprite) {  
  @include sprite-image($sprite);  
  @include sprite-position($sprite);  
  @include sprite-width($sprite);  
  @include sprite-height($sprite);  
}  
  
@mixin sprites($sprites) {  
  @each $sprite in $sprites {  
    $sprite-name: nth($sprite, 10);  
    .#{$sprite-name} {  
      @include sprite($sprite);  
    }  
  }  
}  

スプライト画像の呼び出しは以下の通り。

@import "sprite";  
  
.exampleIcon {  
    @include sprite($example-icon);  
  
    display: inline-block;  
}  

ここで使用しているspritesという関数ですが、見ての通りですがwidthheightが固定化されます。
これが、スプライト画像を配置する要素を別で用意しなければならない理由です。

また、sprite.scssには変数としていくつか宣言されているので、こちらも使用することができます。
たとえば、$example-icon-widthはスプライト画像として連結される前の画像の横幅です。

retina対応画像の場合

少し対応が変わります。
spritesmithにはretina対応するためのオプションが用意されているのですが、弊社ではこれを使用しませんでした。
使用しないというよりも、弊社では使用できませんでした。
このオプションが使用できるには条件があります。

  • 2x用の画像とそれに対応する1xの画像を用意する
  • この2xと1xの画像は正確に2倍の関係である

弊社では、2x画像のみで1xがないという場合がありました。
1xと2xの両方を用意して対応していたんですが、世の中のスマホが2xが主流となり、1x対応をやめたという背景があります。
更に、2x画像のwidthが奇数値というものもあり、1xの画像を新たに用意することもできませんでした。

そのため、retina対応に関しては、独自の対応をとることにしました。

retina画像用のmixinの作成

自作といっても上記のsprite.scssをベースに作成した形になります。
下記の通り、sprite.scssにあった各種mixinを○○-2xとし、サイズ関係を全て2で割っただけです。
retina画像の場合、以下のscssファイルをimportして、使用するmixinを-2xシリーズにすればOKです。

@charset "UTF-8";  
  
@mixin sprite-width-2x($sprite) {  
    width: nth($sprite, 5) / 2;  
}  
  
@mixin sprite-height-2x($sprite) {  
    height: nth($sprite, 6) / 2;  
}  
  
@mixin sprite-position-2x($sprite) {  
    $sprite-offset-x: nth($sprite, 3) / 2;  
    $sprite-offset-y: nth($sprite, 4) / 2;  
  
    background-position: $sprite-offset-x $sprite-offset-y;  
}  
  
@mixin sprite-image-2x($sprite) {  
    $sprite-image: nth($sprite, 9);  
  
    background-image: url(#{$sprite-image});  
    background-size: (nth($sprite, 7) / 2) (nth($sprite, 8) / 2);  
}  
  
@mixin sprite-2x($sprite) {  
    @include sprite-image-2x($sprite);  
  
    @include sprite-position-2x($sprite);  
  
    @include sprite-width-2x($sprite);  
  
    @include sprite-height-2x($sprite);  
}  

まとめ

以上が脱Compassまでの手順になります。
正直かなり面倒な作業です。

多少時間がかかりますが、無心で対応すればやりきれます。
更に大変なのがテストですが、弊社ではこのタイミングでBackstopJSを導入して、リグレッションテストを自動化しました。
こちらに関しても、詳しく説明された記事も多いので、興味がある方は調べてみてください。簡単に導入できると思います。
もちろんリクエストがあればご説明いたします。

さて、最後に衝撃的な告白です。
ここまで全てやりきったような口ぶりで書いてきましたが、この対応による修正は日の目を見ることはありませんでした。
というのも、この対応をする際、弊社が運営しているサイトの中でも比較的小規模なサイトを選んだのですが、工数がかかり過ぎました。
この工数のほとんどは、スプライト画像を配置するために要素を新たに設置するというものです。
今でこそアイコン関連は空要素に対して配置していますが、記事内でも紹介した通り以前まではテキストなどと同じ要素に対してアイコンを設置していました。
比較的小規模なサイトでも大変だったのに、より大規模なサイトで対応するのは現実的ではなく、サイト間でコーディング方法がずれるのはよくないということで、見送りとなりました。

ただ、一通りテストは行っているので、この対応に関しては問題ないと思います。

といったように既存コードの改修は一旦諦めて、新しいコードは当たり前ですがアイコンフォントを使用する方針で実装しています。
デザイナーさんにもアイコンは可能な限り単色で用意していただくようにしています。

最後に、シンクロ・フードではエンジニアを募集しています。
少しでもご興味があれば、気軽にお話しする場を設けますので、以下よりご連絡ください!

www.synchro-food.co.jp