gulp.js、stylelintでフロントエンド業務を効率化!

フロントエンドエンジニアの「ジョン」です。
今回は、「gulp」と「stylelint」を使ってフロントエンド側の業務を効率化した話をしたいと思います。

フロントエンドチームが抱えていた問題

問題になっていたのは下記3点でした。

① 「scss」のビルドツールでPreprosを使っていましたが、Preprosのビルド設定が皆バラバラで同じscssでもビルド結果に差があった。
② テストサーバーではPreprosが使えない為、ローカルでビルドしてテストサーバーにアップしていた。(時間がかかる)
③ コーディングルールは決まっていたが、Lintを導入していなかった為コーディングルールが守られていないケースが多かった。

抱えていた問題の解決

問題①、②のPreprosについてはビルド方法を gulp.jsを使ったサーバー環境ビルドに変更することにしました。

また、問題③についてはstylelintを導入して「cssのプロパティ一の並び順」や「コーディングスタイル」などの コーディングルールを守れるようにしました。

問題の解決 (gulp.jsの導入)

gulp.jsはさまざまな処理を自動化するタスクランナーです。

今回自動化したのは「scssのビルド」でした。ビルド自体はgulpのパッケージを使っていて、必要だと思われる下記3コマンドを作成しました。

  •  「npx gulp build」: 特定scssを1回のみビルド
  •  「npx gulp specify_watch」: 特定scssを監視して変更があればビルド
  •  「npx gulp watch」: 全てのscssを監視して変更があればビルド

作成した gulpfile.jsの内容は下記になります。


 const { src, dest, task, watch } = require("gulp");
 const sass = require("gulp-sass")(require("sass"));
 const util = require("gulp-util");
 const postcss = require("gulp-postcss");
 const autoprefixer = require("autoprefixer");
 const rename = require("gulp-rename");
 const fs = require("fs");
 const argv = require("yargs").argv;
 
 //  親フォルダー(x 階層以内)のstyle.scssをビルドします。
 const SEARCH_PARENT_STYLE_NUM = 5;
 
 /**
  * # buildについて
  * ・「buildFullPath」でビルドしたファイルを「outputFullPath」に保存します(1回のみ)
  *
  * # 実行方法
  * ・public配下で実行
  *   npx gulp build --input "ビルドパス(scss)" --output "保存パス(css)"
  */
 task("build", function (done) {
   // ビルドするパスを記入
   let buildFullPath = convertToMacPath(argv.input);
   // 保存先を記入
   let outputFullPath = convertToMacPath(argv.output);
   if (buildFullPath === undefined || outputFullPath === undefined) {
     util.log("入力形式を確認してください。");
     util.log('npx gulp build --input "ビルドパス" --output "保存パス"');
     done();
     return;
   }
 
   // vscodeで「相対パスをコピー」の場合publicパスも含まれるため除外
   buildFullPath = removePublicPath(buildFullPath);
   outputFullPath = removePublicPath(outputFullPath);
 
   if (!isFileExist("./" + buildFullPath)) {
     // パスが存在しない場合のエラー
     util.log("パスが見つかりませんでした : " + buildFullPath);
     done();
     return;
   }
 
   build(buildFullPath, outputFullPath);
   done();
 });
 
 /**
  * # specify_watchについて
  * ・「buildFullPath」に変更があった場合ビルドして「outputFullPath」に保存します(タスクを停止するまで)
  *
  * # 実行方法
  * ・public配下で実行
  *   npx gulp specify_watch --input "ビルドパス(scss)" --output "保存パス(css)"
  */
 task("specify_watch", function (done) {
   // ビルドするパスを記入
   let buildFullPath = convertToMacPath(argv.input);
   // 保存先を記入
   let outputFullPath = convertToMacPath(argv.output);
   if (buildFullPath === undefined || outputFullPath === undefined) {
     util.log("入力形式を確認してください。");
     util.log('npx gulp specify_watch --input "ビルドパス" --output "保存パス"');
     done();
     return;
   }
 
   if (!isFileExist("./" + buildFullPath)) {
     // パスが存在しない場合のエラー
     util.log("パスが見つかりませんでした : " + buildFullPath);
     done();
     return;
   }
   // vscodeで「相対パスをコピー」の場合publicパスも含まれるため除外
   buildFullPath = removePublicPath(buildFullPath);
   outputFullPath = removePublicPath(outputFullPath);
 
   const watcher = watch(buildFullPath);
   watcher.on("change", function () {
     build(buildFullPath, outputFullPath);
   });
 });
 
 /**
  * # watchについて
  * ・全てのscssに変更が合った場合ビルドして保存します。(タスクを停止するまで)
  *
  * # 実行方法
  * ・public配下で実行
  *   npx gulp watch
  *
  * # ビルドルールー
  * ・一番近い親フォルダー(SEARCH_PARENT_STYLE_NUM 階層以内)のstyle.scssをビルドします。
  *   ex) /scss/views/auth/block/_login.scss -> /scss/views/auth/style.scss
  *
  * ・パスに「block/_」が含まれないscssのビルド
  *   対象scssをビルドします。
  *   ex) /scss/blogs.scss
  *
  * ・ビルドされたcssの生成
  *  ビルドされたcssファイルは /cssフォルダーにscssと同じファイル名で生成されます。
  *   ex) /scss/views/auth/style.scss -> /css/views/auth/style.css
  *   ex) /scss/blogs.scss -> /css/blogs.css
  */
 task("watch", function () {
   const watcher = watch(["scss/**/*.scss", "scss/*.scss"]);
   watcher.on("change", function (fullPath) {
     fullPath = convertToMacPath(fullPath);
     const filePath = removeLastSlashString(fullPath);
 
     let buildFullPath = null;
     if (fullPath.includes("/block/_") || fullPath.includes("/_")) {
       buildFullPath = getBlockbuildFullPath(filePath);
     } else {
       buildFullPath = fullPath;
     }
     const fileName = getLastSlashString(buildFullPath);
     outputFullPath = (
       removeLastSlashString(buildFullPath) +
       "/" +
       fileName
     ).replaceAll("scss", "css");
     build(buildFullPath, outputFullPath);
   });
 });
 
 /**
  * scssをビルドします。
  * @param {String} buildFullPath ビルドするscssのパス + ファイル名
  * @param {String} outputFullPath css格納先のパス + ファイル名
  */
 function build(buildFullPath, outputFullPath) {
   const outputName = getLastSlashString(outputFullPath);
   const outputPath = removeLastSlashString(outputFullPath);
 
   src(buildFullPath)
     .pipe(
       sass({
         outputStyle: "expanded",
       })
     )
     // ベンダープレフィックス
     .pipe(postcss([autoprefixer(["last 2 versions", "ie >= 11"])]))
     // css名
     .pipe(rename(outputName))
     // css保存先
     .pipe(dest(outputPath))
     .on("finish", () => {
       util.log(
         "build: " +
           buildFullPath +
           " | " +
           "output: " +
           outputPath +
           "/" +
           outputName
       );
     });
 }
 
 /**
  * 最後の「/」以降の文字を返します。
  * ex) /public/scss/views/auth/style.scss -> style.scss
  * ex) /public/scss/views/auth -> auth
  * @param {String} filePath パス
  * @returns 文字
  */
 function getLastSlashString(filePath) {
   return filePath.replace(/^.*[\\\/]/, "");
 }
 
 /**
  * 最後の「/」以降の文字を除外した文字を返します。
  * ex) /public/scss/views/auth/style.scss -> /public/scss/views/auth
  * ex) /public/scss/views/auth -> /public/scss/views
  * @param {String} filePath
  * @param {Int} upNum 削除個数
  * @returns 文字
  */
 function removeLastSlashString(filePath, upNum = 1) {
   const lastSlashIndex = filePath.lastIndexOf("/");
   if (lastSlashIndex === 0) {
     return filePath;
   }
 
   for ($i = 0; $i < upNum; $i++) { filePath = filePath.substring(0, lastSlashIndex); } return filePath; } /** * scssがblockの場合のビルドパスを取得します。 * ex) /scss/views/auth/block/_login.scss -> /scss/views/auth/style.scss
  * @param {String} filePath
  * @returns パス
  */
 function getBlockbuildFullPath(filePath) {
   let outputPath = null;
 
   for (let i = 0; i < SEARCH_PARENT_STYLE_NUM; i++) { filePath = removeLastSlashString(filePath); try { if (isFileExist("./" + filePath + "/" + "style.scss")) { outputPath = filePath + "/" + "style.scss"; } } catch (err) { // pass } if (outputPath !== null) { break; } } return outputPath; } /** * 渡されたパスから public/文字を除外して返します。 * ex) public/scss/blogs.scss -> scss/blogs.scss
  * @param {String} path パス
  * @returns パス
  */
 function removePublicPath(path) {
   return path.replace("public/", "");
 }
 
 /**
  * 指定されたfilePathが存在するか判定します
  * @param {String} filePath パス
  * @returns 判定結果
  */
 function isFileExist(filePath) {
   if (!fs.existsSync("./" + filePath)) {
     return false;
   }
   return true;
 }
 
 /**
  * Windows PCの場合はパスが「\」で区切られる、Macだと「/」なのでMacに合わせる
  * @param {String} path パス
  * @returns Macパス
  */
 function convertToMacPath(path) {
   return path.replaceAll("\\\\", "\\").replaceAll("\\", "/");
 }
    

問題の解決 (stylelintの導入)

社内で作成した「コーディングマニュアル」はありましたが作業後に全て手動でチェックすることは大変でした。そこでlintを導入して自動でコーディングマニュアル通り補正するようにしました。 lintは「ソースコードの内容から問題点を指摘してくれるツール」で、stylelintはcssのためのlintです。

下記のようなコーディングルールが設定可能です。
( 詳しいルールについてはこちらを参照お願いします。)

  • インデント (空白2つ or 空白4つ)
  • 文字を囲むクォート (” or “”)
  • 色指定時の16進数 (大文字 or 小文字)

cssのプロパティ一の並び順については「アルファベット」、「外側から内側に」など色んな並び順がありますが 社内のコーディングマニュアルに一番近かった「stylelint-config-recess-order」を使うことにしました。 コーディングスタイルについてはコーディングマニュアル + フロントエンドチームで打ち合わせを行い決めました。

作成した .stylelintrc.jsは下記になります。

    module.exports = {
    plugins: ['stylelint-order'],
    extends: [
        "stylelint-config-standard-scss",
        "stylelint-config-recess-order"
    ],
    rules: {
        // インデント
        "indentation": 4,
        // 連続改行制限
        "max-empty-lines": 1,
        // 文字を囲むクォートは '' ( single or double)
        "string-quotes": "double",
        // 行末の空白削除
        "no-eol-whitespace": true,
        // 不要なセミコロン削除
        "no-extra-semicolons": true,
        // ソースの最後に改行追加
        "no-missing-end-of-source-newline": true,
        // 最初の行には空白禁止
        "no-empty-first-line": true,
        // 16進数での色指定を大文字に ( upper or lower )
        "color-hex-case": "lower",
        // @xxx の後にスペース1つ必須 (@media, @include等)
        "at-rule-name-space-after": "always",
        // コロンの後スペース1つ必須
        "declaration-colon-space-after": "always",
        // セミコロン後には改行必須
        "declaration-block-semicolon-newline-after": "always",
        // 開き括弧の後に改行必須
        "block-opening-brace-newline-after": "always",
        // 閉じ括弧の前に改行必須
        "block-closing-brace-newline-before": "always",
        // 開き括弧の前に1つのスペース
        "block-opening-brace-space-before": "always",
        // 関数の()の内側にスペースを許可するか否か
        "function-parentheses-space-inside": "never",
        // selectorにタグも使用可能
        "no-descending-specificity": null,
        // 改行設定
        "rule-empty-line-before": null,
        "at-rule-empty-line-before": null,
        // class名kebab case以外も使用可能
        "selector-class-pattern": null,
        // font-familyにdefault fontは記述しなくてもいい
        "font-family-no-missing-generic-family-keyword": null,
        // 並び順
        'order/properties-order': [
            {
                // Must be first.
                properties: [
                    'all',
                    'content',
                ],
            },
            {
                // Position.
                properties: [
                    'position',
                    'inset',
                    'inset-block',
                    'inset-inline',
                    'top',
                    'right',
                    'bottom',
                    'left',
                    'z-index',
                ],
            },
            {
                // Display mode.
                properties: ['box-sizing', 'display'],
            },
            {
                // Flexible boxes.
                properties: [
                    'flex',
                    'flex-basis',
                    'flex-direction',
                    'flex-flow',
                    'flex-grow',
                    'flex-shrink',
                    'flex-wrap',
                ],
            },
            {
                // Grid layout.
                properties: [
                    'grid',
                    'grid-area',
                    'grid-template',
                    'grid-template-areas',
                    'grid-template-rows',
                    'grid-template-columns',
                    'grid-row',
                    'grid-row-start',
                    'grid-row-end',
                    'grid-column',
                    'grid-column-start',
                    'grid-column-end',
                    'grid-auto-rows',
                    'grid-auto-columns',
                    'grid-auto-flow',
                    'grid-gap',
                    'grid-row-gap',
                    'grid-column-gap',
                ],
            },
            {
                // Gap.
                properties: ['gap', 'row-gap', 'column-gap'],
            },
            {
                // Layout alignment.
                properties: [
                    'place-content',
                    'place-items',
                    'place-self',
                    'align-content',
                    'align-items',
                    'align-self',
                    'justify-content',
                    'justify-items',
                    'justify-self',
                ],
            },
            {
                // Order.
                properties: ['order'],
            },
            {
                // Box model.
                properties: [
                    'float',
                    'width',
                    'min-width',
                    'max-width',
                    'height',
                    'min-height',
                    'max-height',
                    'aspect-ratio',
                    'padding',
                    'padding-block',
                    'padding-block-start',
                    'padding-block-end',
                    'padding-inline',
                    'padding-inline-start',
                    'padding-inline-end',
                    'padding-top',
                    'padding-right',
                    'padding-bottom',
                    'padding-left',
                    'margin',
                    'margin-block',
                    'margin-block-start',
                    'margin-block-end',
                    'margin-inline',
                    'margin-inline-start',
                    'margin-inline-end',
                    'margin-top',
                    'margin-right',
                    'margin-bottom',
                    'margin-left',
                    'overflow',
                    'overflow-x',
                    'overflow-y',
                    '-webkit-overflow-scrolling',
                    '-ms-overflow-x',
                    '-ms-overflow-y',
                    '-ms-overflow-style',
                    'overscroll-behavior',
                    'overscroll-behavior-x',
                    'overscroll-behavior-y',
                    'overscroll-behavior-inline',
                    'overscroll-behavior-block',
                    'clip',
                    'clip-path',
                    'clear',
                ],
            },
            {
                // Typography.
                properties: [
                    'font',
                    'font-family',
                    'font-size',
                    'font-variation-settings',
                    'font-style',
                    'font-weight',
                    'font-feature-settings',
                    'font-optical-sizing',
                    'font-kerning',
                    'font-variant',
                    'font-variant-ligatures',
                    'font-variant-caps',
                    'font-variant-alternates',
                    'font-variant-numeric',
                    'font-variant-east-asian',
                    'font-variant-position',
                    'font-size-adjust',
                    'font-stretch',
                    'font-effect',
                    'font-emphasize',
                    'font-emphasize-position',
                    'font-emphasize-style',
                    '-webkit-font-smoothing',
                    '-moz-osx-font-smoothing',
                    'font-smooth',
                    'hyphens',
                    'line-height',
                    'color',
                    'text-align',
                    'text-align-last',
                    'text-emphasis',
                    'text-emphasis-color',
                    'text-emphasis-style',
                    'text-emphasis-position',
                    'text-decoration',
                    'text-decoration-line',
                    'text-decoration-thickness',
                    'text-decoration-style',
                    'text-decoration-color',
                    'text-underline-position',
                    'text-underline-offset',
                    'text-indent',
                    'text-justify',
                    'text-outline',
                    '-ms-text-overflow',
                    'text-overflow',
                    'text-overflow-ellipsis',
                    'text-overflow-mode',
                    'text-shadow',
                    'text-transform',
                    'text-wrap',
                    '-webkit-text-size-adjust',
                    '-ms-text-size-adjust',
                    'letter-spacing',
                    'word-break',
                    'word-spacing',
                    'word-wrap', // Legacy name for `overflow-wrap`
                    'overflow-wrap',
                    'tab-size',
                    'white-space',
                    'vertical-align',

                    'list-style',
                    'list-style-position',
                    'list-style-type',
                    'list-style-image',

                    'src',
                    'font-display',
                    'unicode-range',
                    'size-adjust',
                    'ascent-override',
                    'descent-override',
                    'line-gap-override',
                ],
            },
            {
                // Accessibility & Interactions.
                properties: [
                    'pointer-events',
                    '-ms-touch-action',
                    'touch-action',
                    'cursor',
                    'visibility',
                    'zoom',
                    'table-layout',
                    'empty-cells',
                    'caption-side',
                    'border-spacing',
                    'border-collapse',
                    'content',
                    'quotes',
                    'counter-reset',
                    'counter-increment',
                    'resize',
                    'user-select',
                    'nav-index',
                    'nav-up',
                    'nav-right',
                    'nav-down',
                    'nav-left',
                ],
            },
            {
                // Background & Borders.
                properties: [
                    'background',
                    'background-color',
                    'background-image',
                    "-ms-filter:\\'progid:DXImageTransform.Microsoft.gradient",
                    'filter:progid:DXImageTransform.Microsoft.gradient',
                    'filter:progid:DXImageTransform.Microsoft.AlphaImageLoader',
                    'filter',
                    'background-repeat',
                    'background-attachment',
                    'background-position',
                    'background-position-x',
                    'background-position-y',
                    'background-clip',
                    'background-origin',
                    'background-size',
                    'background-blend-mode',
                    'isolation',
                    'border',
                    'border-color',
                    'border-style',
                    'border-width',
                    'border-block',
                    'border-block-start',
                    'border-block-start-color',
                    'border-block-start-style',
                    'border-block-start-width',
                    'border-block-end',
                    'border-block-end-color',
                    'border-block-end-style',
                    'border-block-end-width',
                    'border-inline',
                    'border-inline-start',
                    'border-inline-start-color',
                    'border-inline-start-style',
                    'border-inline-start-width',
                    'border-inline-end',
                    'border-inline-end-color',
                    'border-inline-end-style',
                    'border-inline-end-width',
                    'border-top',
                    'border-top-color',
                    'border-top-style',
                    'border-top-width',
                    'border-right',
                    'border-right-color',
                    'border-right-style',
                    'border-right-width',
                    'border-bottom',
                    'border-bottom-color',
                    'border-bottom-style',
                    'border-bottom-width',
                    'border-left',
                    'border-left-color',
                    'border-left-style',
                    'border-left-width',
                    'border-radius',
                    'border-start-start-radius',
                    'border-start-end-radius',
                    'border-end-start-radius',
                    'border-end-end-radius',
                    'border-top-left-radius',
                    'border-top-right-radius',
                    'border-bottom-right-radius',
                    'border-bottom-left-radius',
                    'border-image',
                    'border-image-source',
                    'border-image-slice',
                    'border-image-width',
                    'border-image-outset',
                    'border-image-repeat',
                    'outline',
                    'outline-width',
                    'outline-style',
                    'outline-color',
                    'outline-offset',
                    'box-shadow',
                    'mix-blend-mode',
                    'filter:progid:DXImageTransform.Microsoft.Alpha(Opacity',
                    "-ms-filter:\\'progid:DXImageTransform.Microsoft.Alpha",
                    'opacity',
                    '-ms-interpolation-mode',
                ],
            },
            {
                // SVG Presentation Attributes.
                properties: [
                    'alignment-baseline',
                    'baseline-shift',
                    'dominant-baseline',
                    'text-anchor',
                    'word-spacing',
                    'writing-mode',

                    'fill',
                    'fill-opacity',
                    'fill-rule',
                    'stroke',
                    'stroke-dasharray',
                    'stroke-dashoffset',
                    'stroke-linecap',
                    'stroke-linejoin',
                    'stroke-miterlimit',
                    'stroke-opacity',
                    'stroke-width',

                    'color-interpolation',
                    'color-interpolation-filters',
                    'color-profile',
                    'color-rendering',
                    'flood-color',
                    'flood-opacity',
                    'image-rendering',
                    'lighting-color',
                    'marker-start',
                    'marker-mid',
                    'marker-end',
                    'mask',
                    'shape-rendering',
                    'stop-color',
                    'stop-opacity',
                ],
            },
            {
                // Transitions & Animation.
                properties: [
                    'transition',
                    'transition-delay',
                    'transition-timing-function',
                    'transition-duration',
                    'transition-property',
                    'transform',
                    'transform-origin',
                    'animation',
                    'animation-name',
                    'animation-duration',
                    'animation-play-state',
                    'animation-timing-function',
                    'animation-delay',
                    'animation-iteration-count',
                    'animation-direction',
                ],
            },
        ],
    },
}

 

まとめ

gulp.jsを使ったのは今回が初めてだったのですが、すごく便利だなと思いました。
特にビルドの速度や作業後サーバーにあげる必要も無くなり作業効率がアップしました。
また、stylelintによりscssの記述が統一されたのでとてもよかったです。
今後もフロントエンドチームの業務改善に努めていきたいと思います。

スタッフ積極採用中

ジェイオンラインではスタッフを随時募集しております。
採用情報ページよりお気軽にお問い合わせください。

この記事を書いた人

ジョン
ジョン