От Browserify до Webpack: зачем нужны сборщики во фронтенде

Олег Кусов27.06.2022

Итак, в прошлой статье мы немного поговорили о том, как работают модули в JavaScript, а теперь предлагаю рассмотреть сборщики модулей или просто бандлеры. Сегодня невозможно представить крупный проект, который бы собирался без бандлера.

Бандлеры появились, когда в них появилась необходимость. У протокола HTTP1.1 было ограничение на 6 максимальных соединений одновременно. Сложность приложений возрастала, вместе с этим увеличивалось число JS-файлов, которые браузеру нужно было получить от сервера. Кроме того, к 2014 году, когда бандлеры начали только-только появляться, количество npm-пакетов достигло 50 тысяч, поэтому их переиспользование в браузере стало привлекательным. Так, появились первые бандлеры.

До бандлеров для управления библиотеками во фронтенде использовали пакетный менеджер Bower, но постепенно он уступил место npm.

Итак, с ростом количества JS-файлов, появилась необходимость собирать их в один. Так появился Browserify.

Browserify

Изначальная идея Browserify была в том, чтобы позволить использовать NodeJS-код в браузере. От сюда, впрочем, и название:

У браузеров нет require метода, но у Node.js есть. С Browserify вы можете писать код, в котором используется require аналогично тому, как оно используется в Node.

Итак, давайте попробуем Browserify. Соберем небольшой проект:

- src/admin.html
- src/constants.js
- src/index.html
- src/main.js
- src/orders.js
- package.json

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Магазин хороших товаров</title>
</head>
<body>
    <h1>Магазин товаров</h1>
    <div id="goods"></div>
</body>
</html>

//admin.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Заказы</title>
</head>
<body>
    <h1>Админ панель</h1>

</body>
</html>

//main.js

const {orderManager} = require('./orders');

const manager = orderManager();

manager.createOrder(['Молоко', 'Сметана']);


//orders.js

const { nanoid } = require('nanoid');
const {allGoods} = require('./constants');


const orderManager = () => {
    const orders = [];

 
    const createOrder = (goods) => {
        if(!goods.every(good => allGoods.includes(good)))
            throw new Error('Dont have such good');

        orders.push({id: nanoid(), goods});
        alert('order created');
    }

    return {createOrder};
}

module.exports = {orderManager}


//constants.js

const allGoods = ['Молоко', 'Сметана', 'Картошка', 'Хлеб'];

module.exports = {allGoods};

Итак, на проекте входным файлом является main.js, который импортирует order.js, в котором импортируется сторонняя библиотека nanoid и constants.js.

Теперь установим browserify через npm install browserify и выполним browserify main.js -o dist/bundle.js. После запуска команды можно увидеть ошибку:

/home/oleg/projects/good-project/node_modules/nanoid/index.browser.js:1
export { urlAlphabet } from './url-alphabet/index.js'
^
ParseError: 'import' and 'export' may appear only with 'sourceType: module'

Связано оно с тем, что nanoid уже использует es6 импорты, потому что они поддерживаются всеми современными браузерами. Как же решить проблему? Для этого мы можем установить плагин esmify. Для удобства добавим в package.json команду:

    "build": "browserify ./src/main.js -p esmify > dist/bundle.js",

Под катом Browserify собирает все в один, оборачивает контент каждого файла в функцию function(require, module, exports) {...} и последовательно вызывает каждую, начиная с файла точки входа. Для простоты давайте уберем nanoid и посмотрим на bundle.js:

(function() {
    function r(e, n, t) {
        function o(i, f) {
            if (!n[i]) {
                if (!e[i]) {
                    var c = "function" == typeof require && require;
                    if (!f && c) return c(i, !0);
                    if (u) return u(i, !0);
                    var a = new Error("Cannot find module '" + i + "'");
                    throw a.code = "MODULE_NOT_FOUND", a
                }
                var p = n[i] = {
                    exports: {}
                };
                e[i][0].call(p.exports, function(r) {
                    var n = e[i][1][r];
                    return o(n || r)
                }, p, p.exports, r, e, n, t)
            }
            return n[i].exports
        }
        for (var u = "function" == typeof require && require, i = 0; i < t.length; i++) o(t[i]);
        return o
    }
    return r
})()({
    1: [function(require, module, exports) {
        const allGoods = ['Молоко', 'Сметана', 'Картошка', 'Хлеб'];

        module.exports = {
            allGoods
        };
    }, {}],
    2: [function(require, module, exports) {
        const {
            orderManager
        } = require('./orders');

        const manager = orderManager();

        manager.createOrder(['Молоко', 'Сметана']);

    }, {
        "./orders": 3
    }],
    3: [function(require, module, exports) {
        const {
            allGoods
        } = require('./constants');


        const orderManager = () => {
            const orders = [];


            const createOrder = (goods) => {
                if (!goods.every(good => allGoods.includes(good)))
                    throw new Error('Dont have such good');

                orders.push({
                    id: 1,
                    goods
                });
                alert('order created');
            }

            return {
                createOrder
            };
        }

        module.exports = {
            orderManager
        }

    }, {
        "./constants": 1
    }]
}, {}, [2]);

Вы уже заметили, что это IIFE-функция, о которых мы говорили в статье про модули. Если присмотреться к коду, можно заметить, что у нас есть объект, где ключ - это число, а значение - это функция. Порядок выполнения будет таким: вызывается функция с ключем 2, затем рекурсирвно вызывается зависимость './orders' по ключу 3 и так далее. В итоге сначала выполняются самые глубокие зависимости, а уже в самом конце получаем результат выполнения entry-файла.

Но на самом деле, главная работа Browserify была не в этом. Он умел полифилить библиотеки NodeJS для работы в браузере. В те времена, когда в npm почти не было пакетов, которые были бы адаптированы под браузер и NodeJS одновременно, нужен был способ заставить работать NodeJS либы в браузере, и именно здесь разрабы провели огромную работу. Вот например статья из трех частей о портировании crypto модуля NodeJS.

Grunt

Grunt - это таск раннер и появился он в 2012 году. Таск раннеры нужны для автоматизации задач по сборке проекта. Сюда входит:

  • Минификация JS
  • Сборка HTML-темплейтов (например, handlebars)
  • Линтинг и компиляция

Grunt сам по себе не очень полезен, но благодаря сообществу, которое разработало более 6000 плагинов, таск раннер становится мощным инструментом.

На сайте Grunt можно найти раздел с плагинами. Давайте посмотрим на пример конфигурационного файла Grunt:

/*global module:false*/
module.exports = function(grunt) {

  // Project configuration.
  grunt.initConfig({
    // Metadata.
    pkg: grunt.file.readJSON('package.json'),
    aws: grunt.file.readJSON('config/grunt-aws.json'),
    datetime: Date.now(),
    jshint: {
      options: {
        curly: true,
        }
      },
      'dist': {
        src: [ 'src/js/**/*.js' ]
      }
    },

    concat: {
      'dist': {
        src: [ 'src/js/file1.js', 'src/js/file2.js' ],
        dest: 'build/fileoutput.js'
      }
    },

    jasmine: {
      'dist': {
        src : 'build/**/*.min.js',
        options: {
          specs : 'spec/**/*.spec.js'
        }
      }
    },

    s3: {
      key: '<%= aws.key %>',
      secret: '<%= aws.secret %>',
      bucket: '<%= aws.bucket %>',
      access: 'public-read',
      upload: [{
        src: 'build/fileoutput.min.js',
        dest: 'website/javascript/fileoutput.<%= datetime %>.min.js',
        gzip: false
      },
      {
        src: 'build/cssoutput.min.css',
        dest: 'website/css/cssoutput.min.<%= datetime %>.min.css',
        gzip: false
      }]
    }
  });

  grunt.loadNpmTasks('grunt-contrib-jshint');
  grunt.loadNpmTasks('grunt-contrib-concat');
  grunt.loadNpmTasks('grunt-contrib-jasmine');
  grunt.loadNpmTasks('grunt-s3');

  // Default task.
  grunt.registerTask('default', ['jshint:dist', 'concat:dist' 'jasmine:dist']); // Через двоеточие в плагин прокидываются аргументы. dist берется из config объекта.
  grunt.registerTask('upload', ['s3']);
};

В grunt.initConfig указываются конфиги плагинов, далее через grunt.loadNpmTasks подключаются плагины, а в самом конце запускаются таски, где последовательно выполняются плагины в виде пайпа. Есть поддержка мульти тасков, таски могут запускать другие таски:

grunt.registerTask('foo', 'My "foo" task.', function() {
  // Enqueue "bar" and "baz" tasks, to run after "foo" finishes, in-order.
  grunt.task.run('bar', 'baz');
  // Or:
  grunt.task.run(['bar', 'baz']);
});

В примере выше grunt-contrib-jshint - grunt-пакет библиотеки для выявления ошибок в js. grunt-contrib-concat - пакет, объединяющий файлы в один, grunt-contrib-jasmine запускает тесты. В Gruntfile можно вставлять в конфиг темплейты (например, '<%= aws.key %>').

Чем Grunt отличается от Browserify?

Browserify собирает все файлы проекта в один бандл, и адаптирует NodeJS-библиотеки под работу в браузере. В этом плане Browserify больше похож на Webpack, чем на Grunt.

Gulp

Gulp был создан как улучшенная версия Gulp. Чтобы понять, в чем его прелесть, достаточно сравнить два одинаковых конфига Grunt и Gulp.

Grunt

grunt.initConfig({
    sass: {
        dist: {
            files: [{
                src: 'dev/*.scss',
                dest: '.tmp/styles',
                expand: true,
                ext: '.css'
            }]
        }
    },
    autoprefixer: {
        dist: {
            files: [{
                expand: true,
                cwd: '.tmp/styles',
                src: '{,*/}*.css',
                dest: 'css/styles'
            }]
        }
    },
    watch: {
        styles: {
            files: ['dev/*.scss'],
            tasks: ['sass:dist', 'autoprefixer:dist']
        }
    }
});
grunt.registerTask('default', ['styles', 'watch']);

Gulp

gulp.task('sass', function () {
  gulp.src('dev/*.scss')
    .pipe(sass())
    .pipe(autoprefixer())
    .pipe(gulp.dest('css/styles'));
});
gulp.task('default', function() {
  gulp.run('sass');
  gulp.watch('dev/*.scss', function() {
    gulp.run('sass');
  });
});

Если в Grunt каждый раз нужно дублировать пути и форматы файлов, то в Gulp это делается один раз. Конфиг Gulp гораздо понятнее и удобнее. Пример типичного Gulpfile.

Почему таск раннеры сегодня не используются?

Потому что в них пропала необходимость. Все задачи по автоматизации решаются с помощью npm-утилит и секции scripts в package-json. Grunt и Gulp плагинов тысячи, а NPM-пакетов более 1.3 миллиона. И понятно, что лучше поддерживать утилиту универсальную, нежели иметь привязку к конкретному таск раннеру.

Webpack

Webpack появился в далеком 2012 году, но лишь спустя несколько лет получил широкую популярность, и на данный момент является, пожалуй, главным способом сборки фронтенд-приложений. Вебпак - это бандер, который, как и Browserify, умеет собирать все js-файлы в один. Но не только JS, на самом деле, Webpack умеет работать с файлами разного типа, включая стили, шрифты, картинки и иконки.

Две главные сущности Webpack - плагины и лоадеры. Лоадеры - это простые сущности, которые влияют лишь на отдельные файлы и могут их преобразовывать по-всякому. А плагины глубоко интегрированы в Webpack, могут регистрировать хуки системы сборки вебпака, имеют доступ к компилятору, они гораздо более функциональны и при этом их сложнее поддерживать.

Webpack из коробки имеет встроенный devServer, который имеет гибкие настройки, включая проксирование запросов. Webpack не так давно получил плагин ModuleFederation, который позволяет реализовать микрофронтенды.

Конфиги вебпака можно использовать с монорепозирием, когда есть один глобальный для всей монорепы, и локальные для отдельных проектов, которые используют главный конфиг как основной.

Простой конфиг:

const path = require('path');

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

А тут лежит типичный конфиг вебпака.

Webpack с каждой новой версией из коробки добавляет функциональность, которая ранее была досупна лишь через установку доп. плагинов и лоадеров. Сегодня Webpack - король фронтенд сборки.

Чем Webpack отличается от Gulp и Grunt?

Gulp и Grunt логичнее сравнивать с NPM скриптами, и Webpack может быть частью автоматизации Gulp/Grunt. С помощью Gulp/Grunt действительно можно собрать сборку, но при этом Gulp/Grunt из коробки сами по себе ничего не умеют, тогда как Webpack без доп плагинов способен собирать бандлы, имеет встроенный dev-сервер, умеет разбивать JS-файлы на чанки и так далее.

Сегодня главная претензия к Webpack - скорость сборки. И здесь на сцену выходят новые модные сборщики: Vite и EsBuild, но о них мы поговорим в следующем материале.