strokoff

Сборка фронта на nodejs

На работе появилась задачка, собрать build систему фронта для одного проекта. На самом проекте уже использовался gulp с несколькими плагинами для облегчения повседневной жизни.  Как таковой фронт дев версия от прод версии ничем не отличалась, все console.log и комментарии в коде с FIXME и TODO оставались на месте) про минификацию, обфускацию и т.п. речи небыло. Сам проект написан на symfony в качестве билд системы используется capifony, система Assets самой symfony не используется в силу наличия gulp’a, а также у проекта две веб морды, мобильная и настольная + необходимо поддерживать stage и production режимы.

Первое, с чем стоит определиться, так это с набором инструментов и что необходимо сделать. На самом проекте используются следующие типичные фронтенд принадлежности:

  1. JavaScript — будем транспайлить из ES6 в ES5. На дев версии будем держать ES5 в beautify виде с дебаг сообщениями, на проде будем минифицировать, убирать комментарии и дебаг и применим uglify. Различные плагины просто сожмем.
  2. SASS — собираем для дева compact для прода compress версии.
  3. Растровые иконки — собираем в спрайт на деве и проде.
  4. SVG иконки — собираем шрифт и sass файл.
  5. Контент изображения и баннеры — обработаем pngquant для облегчения.

Теперь про запуск билда и интеграцию с уже существующими технологиями. Фронтенд разработчик не участвует в момент деплоя проекта и бекенд разработчику не нужно знать о всей тонкости сборки, ему надо просто запустить сказанную фронтом команду. Значит в нашем случае, это будет простейший bash скрипт, который обновит зависимости npm и в зависимости от параметра запустит nodejs с нужными параметрами.

#!/bin/bash

#Проверим наличие npm в системе на всякий случай
if ! type npm > /dev/null 2>&1; then
    echo "Node and npm must be installed"
    exit 1
fi

#Обновим зависимости
 echo "Update NPM packages"
 npm install

#Выходит 4 версии. Мобильный прод и дев и настольный.
case $1 in
    #Desktop production
    prod)
        node build.js  desktop prod
        ;;
    #Desktop development
    dev)
        node build.js  desktop dev
        ;;
    #Mobile production
    mprod)
        node build.js mobile prod
        ;;
    #Mobile dev
    mdev)
        node build.js mobile dev
        ;;
    #Default exit
    *)
        echo "Undefined build mode"
        exit 1
esac

Суть и скрипт очень просты. В целом можно и саму ноду было запускать с параметрами, но мы же об этом не собираемся толковать администратору или бекенд разработчику. Теперь переходим к самой nodejs. Что нам нужно? Иметь возможность выполнять две разные сборки (мобильную и настольную) в два разных режима (dev и production). А также сохранить возможность использовать gulp watcher на проекте для повседневной dev разработки.

//Set build mode
const
    buildMode = process.argv[3] || 'dev',
    buildType = process.argv[2] || 'desktop',
    gutil = require('gulp-util'),
    build = require('build.'+buildType);

gutil.log('Node build: ', buildType, buildMode, process.cwd());

if(buildMode === 'watch') {
    //Just watch JS and CSS files in watch mode
    build.watch();
} else {
    //Start build
    build(buildMode);
}

Что мы делаем? мы получаем из аргументов buildMode и buildType и подключаем в зависимости от buildType скрипт сборки который в свою очередь уже будет отвечать, за конкретную сборку необходимого нам билда. И в зависимости от параметра buildMode запускаем деплой или ватчер. Я рассмотрю лишь 1 вариант сборки настольной версии, она не сильно отличается от мобильной.

//..............
function deploy (mode) {

    //Require build config to global for gulp tasks
    global.buildConf = require('./desktop.'+mode+'.conf.js');

    const gulpTasks = require('./gulp.desktop.tasks.js');

    gutil.log('Start build: ' + mode);

    switch(mode) {
      case 'dev':
        devDeploy();
        break;

      case 'prod':
        prodDeploy();
        break;

      default:
        gutil.log('Undefined build mode');
    }
}
module.exports = deploy;
//.............

Первое это сам запуск функции билда. Что есть билд? Это последовательное выполнение gulp задач. В определенную директорию с определенными настройками. Директории и настройки с которыми должны работать задачи мы соответственно выносим в отдельный файл.

//На каждую задачу держим свой объект с настройками.
module.exports = {
        copyAssets: {
            imagesSrc: './app/Resources/frontend/desktop/images/**/*.*',
            imagesDist: './web/dev/desktop/images',
            jsLibs: './app/Resources/frontend/desktop/js/lib/*.js',
            jsDist: './web/dev/desktop/js/lib'
        },
        clean: {
            cleanPath: './web/dev/desktop/*'
        },
        sass: {
            src: './app/Resources/frontend/desktop/sass/**/*.scss',
            dist: './web/dev/desktop/css',
            options: {
                outputStyle: 'compact'
            },
            prefixer: {
                browsers: ['last 2 version', 'safari 5', 'ie 9'],
                cascade: false
            }
        },
        sprite: {
            src: './app/Resources/frontend/desktop/images/sprite/*.*',
            imgDist: './web/dev/desktop/images',
            cssDist: './app/Resources/frontend/desktop/sass',
            spritesmith: {
                imgName: 'spritemap.png',
                cssName: '_sprite.scss',
                cssFormat: 'scss',
                padding: 10,
                algorithmOpts: {
                    sort: false
                },
                cssTemplate: './app/Resources/frontend/desktop/sass/sprite.tmp',
                imgPath: '../images/spritemap.png'
            }
        },
//..............

Т.к. набор задач один, разные только параметры. Держим 1 файл с набором gulp команд. Пример gulp файла…

const gulp = require('gulp'),
    iconfont = require('gulp-iconfont'),
    consolidate = require('gulp-consolidate'),
    runTimestamp = Math.round(Date.now() / 1000),
    sass = require('gulp-sass'),
   //.............

//Development watcher
gulp.task('watch', () => {
    gutil.log('Start wathing CSS and JS');
    gulp.watch(conf.sass.src, ['sass']);
    gulp.watch(conf.js.src, ['js']);
});

//CLEAR
gulp.task('clean', () => {
    gutil.log('Clean dev path');
    return gulp.src(conf.clean.cleanPath, {read: false}).pipe(clean());
});

//SASS
gulp.task('sass', () => {
    gutil.log('Make sass');
    return gulp.src(conf.sass.src)
        .pipe(sass(conf.sass.options).on('error', logErr))
        .pipe(autoprefixer(conf.sass.prefixer).on('error', logErr))
        .pipe(gulp.dest(conf.sass.dist));
});
//...........

Какие задачи и с какими параметрами будем вызвать, мы выяснили, осталось их вызвать. Вернемся к нашему build.desktop.js. В gulp’e есть несколько способов запускать задачи синхронно и устанавливать между ними зависимости. Я же хочу сохранить возможность использовать задачи раздельно друг от друга и для решения пробелмы с асинхронностью задач я воспользусь промисами. Каждую задачу мы будем вызвать по имени навешивать на нее callBack и в нем выполнять resolve для промиса. В конечном итоге мы получим лаконичную цепочку вызовов команд + самый гибкий контроль над их последовательностью и дальнейшему расширению.

Напишем наш promise task wrapper

function promiseTask(name) {
    return new Promise(function (resolve, reject) {
        gulp.start(name, (error) => {
            if(error === null) {
                gutil.log('Task done');
                resolve(true);
            } else {
                reject(error);
            }
        })
    });
}

Теперь осталось написать саму функцию билда)

//Production deploy
function prodDeploy() {

    promiseTask('clean')
       .then(() =>{
            return promiseTask('copy-assets')
       })
       .then(() => {
            return promiseTask('minimize-images')
       })
       .then(() =>{
            return promiseTask('svg')
       })
       .then(() =>{
            return promiseTask('sprite')
       })
       .then(() =>{
            return promiseTask('js')
       })
       .then(() =>{
            return promiseTask('uglify-js')
       })
       .then(() =>{
            return promiseTask('sass')
       })
       .then(() => {
            gutil.log('Build complete');
       })
       .catch((error) => {
            gutil.log('Build failed');
            gutil.log(error);
       });
}

Таким вот нехитрым способом начали собирать фронт на проекте) буду рад критике и конструктивным дополнениям)