Browserify - cобираем JS-проект

Browserify (github:browserify/browserify, npm:browserify) не просто склеивает файлы - он работает с CommonJS-модулями, как Node.js: каждый JS-файл имеет свою изолированную область видимости и умеет экспортировать переменные через exports.

Browserify полезен для проектов с CommonJS-модулями, которые нужно собрать для браузера. Это конкретная модель сборки, а не универсальное требование к современному JavaScript: браузеры также поддерживают ES-модули.

Browserify - интеллектуальный сборщик. Анализируя ваш код, он понимает, какие файлы войдут в сборку проекта, а какие - нет.

Я хотел сначала написать сложную статью, рассказать о предыстории моего знакомства с browserify, сравнить его с AMD и require.js, детально описать принцип действия, расписать все ключи командной строки и методы API, перечислить все поддерживаемые node-модули... Но потом понял, что никто такую статью читать не будет - и многие по-прежнему продолжат изобретать велосипеды, собирать JS-код по старинке, а то и не собирать вовсе. Поэтому я решил передать свой субъективный и предвзятый опыт работы с browserify на практике - и сделать это в форме вопросов и ответов.

Итак:

У меня есть куча JS-файлов, я их хочу собрать в один. Что делать?

Сначала установим Node.js.

Установите сборщик в отдельную папку проекта без sudo и глобальных зависимостей:

npm init -y
npm install --save-dev browserify

Теперь можно приступать к экспериментам.

Создаём два файла.

//----------------------
//Файл index.js
var hello = require('./hello');
hello.helloWorld();

//----------------------
//Файл hello.js
exports.helloWorld = function () {
	alert('Hello browserify world!');
}

Выполняем в консоли:

npx browserify index.js > packed.js

Подключаем packed.js в HTML-файле:

<html>
	<head>
		<script src="packed.js"></script>
	</head>
	<body>
	</body>
</html>
Граф Browserify: index.js требует hello.js; оба модуля включены в packed.js, независимый unused.js не включается
Browserify начинает с входного модуля, обходит require-зависимости и собирает их в один браузерный bundle.

Эй, а как же отладка?

Как же отлаживать в браузере всё это, если мои файлы упакованы в один здоровенный файл? Ведь потеряются номера строк!

Однако тут browserify - и разработчики Chrome и Firefox преподносят нам приятный сюрприз.

С файлами из предыдущего примера выполним в консоли:

npx browserify --debug index.js > packed.js

Мы добавили ключ --debug, и browserify снабдил собранный файл специальными метками, позволяющими отладчику браузера определить из какого исходника пришёл тот или иной фрагмент кода.

В современных DevTools исходные файлы ищите на панели Sources/Debugger при включённой поддержке source maps. Названия панелей могут отличаться. Для публичной сборки отдельно решите, допустима ли публикация исходников через карту. При корректной source map DevTools показывает исходные модули наряду со скомпилированным файлом. Скриншот ниже иллюстрирует эту связь в историческом интерфейсе.

Отладчик DevTools: точка останова в исходном модуле Browserify

А если мне нужно jQuery?

//----------------------
//Файл index.js
var $ = require('jquery');

$(function () {
	var hello = $('<h3>Hello world!</h3>');
	$('body').append(hello);
});

Выполняем в консоли:

npm install jquery

У нас создастся папка node_modules, в которой будет лежать модуль jquery.

Теперь:

npx browserify index.js > packed.js

Запускаем HTML-файл из предыдущего примера, и видим, что всё работает.

Что мы сделали - мы установили npm-модуль jquery. Для этого примера используется пакет jquery, а не отдельная старая пересборка br-jquery. Нужную версию зависимости фиксируйте в lockfile и проверяйте её поддержку сборщиком. Сам принцип остаётся тем же: require() обозначает зависимость модуля. Пакет должен быть рассчитан на браузерную среду: наличие в npm само по себе не гарантирует, что он не потребует файловую систему или другой серверный API.

Перед добавлением пакета проверьте его назначение, исходники и лицензию. npm — реестр пакетов, а не гарантия совместимости или безопасности. Собственную библиотеку публикуйте только при наличии прав и готовности сопровождать её. Труднее потом поддерживать актуальную версию, кхм...

Хочу компилировать CoffeeScript, не задумываясь. Как?

CoffeeScript требует отдельного transform. Поддержка .coffee не должна предполагаться «из коробки». Для проекта, который действительно содержит CoffeeScript, сверяйте совместимость coffeeify, CoffeeScript и Browserify.

npm install --save-dev coffeeify coffeescript

Пример index.coffee:

hello = require './hello'
hello.helloWorld()

И hello.coffee:

exports.helloWorld = ->
  alert 'Hello browserify world!'

Сборка с явным transform:

npx browserify -t coffeeify --extension .coffee index.coffee -o packed.js

Трансформация переводит язык в JavaScript; Browserify затем объединяет зависимости. Проверяйте оба этапа отдельно, если ошибка возникает до загрузки страницы.

А еще хочу компилировать jade!

Jade развился в Pug. Шаблон можно предварительно скомпилировать в JavaScript-функцию, а затем включить в CommonJS-сборку. В старом Browserify для этого использовались register, addEntry и prepend; этот внутренний API не следует переносить в текущий проект.

Создайте template.pug:

h2= greetingText

В hello.js шаблон используется как функция:

var template = require('./template.pug');
exports.helloWorld = function () {
  alert(template({greetingText: 'Hello browserify world!'}));
};

Установите локальные зависимости:

npm install --save-dev browserify pug

Теперь our-browserify.js может использовать стандартный transform API Browserify. Он получает поток исходника и возвращает JavaScript-модуль:

const browserify = require('browserify');
const pug = require('pug');
const fs = require('node:fs');
const {Transform} = require('node:stream');

const bundle = browserify('./index.js');
bundle.transform(function (filename) {
  if (!filename.endsWith('.pug')) {
    return new Transform({transform(chunk, encoding, done) {
      done(null, chunk);
    }});
  }
  const chunks = [];
  return new Transform({
    transform(chunk, encoding, done) { chunks.push(chunk); done(); },
    flush(done) {
      try {
        const code = pug.compileClient(Buffer.concat(chunks).toString('utf8'), {
          filename, name: 'template', inlineRuntimeFunctions: true,
          compileDebug: false
        });
        this.push(code + '\nmodule.exports = template;');
        done();
      } catch (error) { done(error); }
    }
  });
});
const output = fs.createWriteStream('packed.js');
output.on('error', error => { console.error(error.message); process.exitCode = 1; });
bundle.bundle().on('error', error => {
  console.error(error.message); process.exitCode = 1; output.destroy();
}).pipe(output);

Запустите node our-browserify.js, затем откройте страницу с подключённым packed.js. Для исправного шаблона alert содержит <h2>Hello browserify world!</h2>. Намеренно повредите отступ в копии шаблона: сборка должна завершиться ошибкой, а не молча выпустить корректный файл. Частичный результат после неуспешной сборки публиковать нельзя.

compileClient компилирует доверенный файл проекта. Не компилируйте шаблон, присланный посетителем: шаблонизатор исполняет выражения. Для данных оставляйте экранируемое =; подробности в Pug API.

Я пользуюсь командой make. Как бы это запихнуть в Makefile?

GNU make - это целая философия нескольких поколений UNIX-разработчиков.

Опять же, чтобы не превращать статью в академическое исследование, я не буду приводить здесь сравнение с Apache Ant, Rake, Jake, Cake и пр. Я предвзят, люблю make, и ничего с этим не поделаешь.

Мы воспользуемся одной из самых главных фишек make - отслеживанием зависимостей.

Правильно написанный Makefile - ленив. Он не будет ничего собирать, если всё уже собрано ранее, а файлы проекта не изменились. Чтобы это сделать, нам нужно научить make отслеживать зависимости. Статья не про make, поэтому буду писать так, будто читатель уже знаком с этой утилитой, ну или - по крайней мере - изо всех сил вчитывается в её документацию прямо сейчас.

Создадим файл Makefile следующего содержания:

#определяем зависимости
DEPS:=$(shell find node_modules/ -type f -name "*.js")
DEPS:=$(DEPS) $(wildcard *.js *.pug) package.json package-lock.json

#цель: собранный файл, зависит от всех js-файлов в папке node_modules и в корне проекта
build/packed.js:$(DEPS)
	mkdir -p $(@D)
	node our-browserify.js
	mv packed.js $@

#цель - "очистить"
clean:
	rm -rfv build

.PHONY: clean

Теперь, чтобы собрать проект, нам достаточно просто выполнить в консоли:

make

Если папка build не создана - она создастся автоматически. После этого в неё запишется файл packed.js - наш собранный проект.

Если теперь выполнить make еще раз, мы увидим сообщение:

make: `build/packed.js' is up to date.

make сравнивает временные метки цели и перечисленных зависимостей. Это не полная система отслеживания всех возможных изменений окружения. В список добавлены .pug, манифест и lockfile; после изменения версии Node или конфигурации сборки выполните чистую сборку. Вот она - магия GNU make!

Если же вам позарез надо пересобрать проект, выполните:

make clean

и затем снова:

make

Можно даже - у кого Linux - зациклить make через watch:

watch -n 4 make

Это приведет к вызову make каждые четыре секунды, но пересборка будет выполняться только в случае, если хоть один файл с момента последней сборки изменился. Ну разве не клёво?

Я пишу сервер на Connect (Express). Как интегрировать туда browserify?

Не передавайте объект сборщика в server.use(bundle): Browserify не является готовым middleware Express. Для простого и воспроизводимого развёртывания соберите JavaScript заранее, проверьте его, затем отдавайте статический каталог:

const express = require('express');
const path = require('node:path');
const app = express();
app.use('/assets', express.static(path.join(__dirname, 'build')));
app.listen(8080, '127.0.0.1');

В HTML укажите /assets/packed.js. Если сборка завершилась с ошибкой, не обновляйте опубликованный каталог. Сборка на каждый запрос создаёт лишнюю нагрузку и усложняет обработку ошибок; для разработки используйте отдельный watcher. Порядок middleware разбирается в руководстве Express.

Выводы

Browserify строит граф CommonJS-зависимостей и выпускает файл для браузера. Мы разобрали исходные модули, карты исходников, стороннюю библиотеку, CoffeeScript-transform, предварительную компиляцию Pug и Makefile.

Проекты можно интегрировать в существующую инфраструктуру сборки (у нас её роль выполнял Makefile, но, конечно, можно использовать и другие системы).

Проверка результата состоит из трёх шагов: сборка без ошибок, загрузка packed.js без 404 и выполнение ожидаемого клиентского действия. Ошибка компиляции шаблона и ошибка браузерной среды — разные классы проблем.

Синтаксис require() не переносит серверные возможности в браузер автоматически. Если пакет требует Node.js API, сначала выясните, имеет ли он браузерную реализацию.

Заметки и перевод: @jsmarkus