Всем доброго времени суток!
Давайте немного поговорим о DX (Developer Experience) или «Опыте разработки», а если конкретнее — об обновлении кода в режиме реального времени с сохранением состояния системы. Если тема для вас в новинку, то перед прочтением советую ознакомиться со следующими видео:

Ряд видео с обновлением кода в реальном времени без перезагрузки страницы




Введение: Как это работает?


Прежде всего стоит понимать, что реализация подобной функциональности подразумевает под собой решение ряда задач:
— Отслеживание изменений файлов
— Вычисление патча на основании изменений файлов
— Транспортировка патча на клиент (в браузер, например)
— Обработка и применение патча к существующему коду
Но обо всём по порядку.

Отслеживание изменений файлов


На своём опыте я попробовал четыре разные реализации:
Решение от github
— Нативный fs.watch
Chokidar
Gaze
Можно долго спорить о преимуществах одного приложения перед другим, но лично для себя я выбрал chokidar — быстро, удобно, хорошо работает на OS X (спасибо, paulmillr).

Наша задача на данном шаге — отслеживать изменения bundle-файлов и реагировать на изменения онных. Однако, есть одна загвоздка: browserify открывает bundle-файл в режиме потоковой записи, что означает, что событие "change" может происходить несколько раз до момента окончания записи (к сожалению, такого события нет). Поэтому, дабы избежать потенциально проблемных ситуаций с невалидным патчем, нам приходится включить дополнительную проверку валидности кода (банально проверяем наличие данных в файле и синтаксические ошибки). С этой частью вроде бы должно быть ясно. Ну что, движемся дальше?

Вычисление патча на основании изменений файлов


Мы отслеживаем изменение только bundle-файлов. Как только один из таких файлов меняется, мы должны вычислить патч к старой версии файла и передать его на клиент. В данный момент при работе с react-кодом в режиме реального времени для browserify активно используется livereactload, который, на мой взгляд, решает эту проблему с диким оверхедом: при каждом вам прилетает целый bundle. Как по мне — так это слишком. А вдруг у меня бандл с source maps весит 10Мб? Изволите при добавлении запятой гнать такой траффик? Ну уж нет…

Поскольку в browserify не предусмотрена возможность «горячей замены модулей» как в webpack, мы не можем просто «заменить» кусок кода в рантайме. Но, возможно, это даже и к лучшему, мы можем быть ещё хитрее!

Viva jsdiff! Скармливаем ему начальный и измененный варианты контента файла и получаем на выходе — настоящий diff, который, при атомарных изменениях (лично я жму cmd + s на каждый чих) весит порядка 1Кб. А что ещё более приятно — он читаем! Но всему своё время. Теперь надо передать этот diff на клиент.

Транспортировка патча на клиент


В этой части не предвидится никакой магии: обычное WebSocket соединение с возможностью передать следующие сообщения:

— Если всё прошло хорошо, diff успешно вычислен и никаких ошибок не возникло, то отсылаем на клиент сообщение формата
{
  "bundle": BundleName <String>, // Строка с именем измененного bundle-файла
  "patch": Patch <String> // Строка с вычисленным патчем
}

— Если всё пошло не так гладко и при вычислении diff'а была обнаружена синтаксическая ошибка:
{
  "bundle": BundleName <String>, // Строка с именем bundle-файла, где произошла ошибка
  "error": Error <String> // Строка с ошибкой
}

— Когда новый клиент присоединяется к сессии, ему отправляются все «исходники», за которыми мы наблюдаем:
{
  "message": "Connected to browserify-patch-server",
  "sources": sources <Array>, // Массив с содержимым наблюдаемых bundle-файлов
}

Посмотреть исходники можно тут.

Обработка и применение патча к существующему коду


Основная магия происходит на этом шаге. Предположим, мы получили патч, он корректен и может быть применен к текущему коду. Что дальше?
А дальше нам придется сделать небольшое лирическое отступление и посмотреть как browserify оборачивает файлы. Честно говоря, чтобы это объяснить простым и понятным языком, лучше всего перевести прекрасную статью Бена Клинкенбирда, но вместо этого я, пожалуй, продолжу и оставлю изучение материала на читателя. Самое важное — это то DI в каждый скоуп модуля:

Пример из статьи
{
  1: [function (require, module, exports) {
    module.exports = 'DEP';

  }, {}],
  2: [function (require, module, exports) {
    require('./dep');

    module.exports = 'ENTRY';

  }, {"./dep": 1}]
}


Именно так мы получаем доступ к функции require и объектам module и exports. В нашем случае обычного require будет недостаточно: нам необходимо инкапсулировать логику работы с патчем (мы ведь не собираемся это писать руками в каждом модуле)! Самый просто, если не единственный, способ это сделать — перегрузить require. Именно это я и делаю в этом файле:

overrideRequire.js
function isReloadable(name) {
  // @todo Replace this sketch by normal one
  return name.indexOf('react') === -1;
}

module.exports = function makeOverrideRequire(scope, req) {
  return function overrideRequire(name) {
    if (!isReloadable(name)) {
      if (name === 'react') {
        return scope.React;
      } else if (name === 'react-dom') {
        return scope.ReactDOM;
      }
    } else {
      scope.modules = scope.modules || {};
      scope.modules[name] = req(name);

      return scope.modules[name];
    }
  };
};


Как вы, вероятно, заметили, в коде я использую scope, который выше по стеку ссылается на window. Так же функция makeOverrideRequire использует req, который является ничем иным, как оригинальной require функцией. Как вы можете видеть, все модули проксируются в scope.modules, дабы иметь возможность получить к ним доступ в любой момент времени (возможно, я найду этому применение в будующем. Если нет — упраздню). Так же, как видно из кода выше, я проверяю, является ли модуль react'ом или react-dom'ом. В таком случае я просто возвращаю ссылку на объект из скоупа (если использовать разные версии React, это приведет нас к ошибкам при работе с hot-loader-api, т.к. служебный getRootInstances будет указывать на другой объект).

Итак, идем дальше — работа с сокетом:

injectWebSocket.js
var moment = require('moment');
var Logdown = require('logdown');
var diff = require('diff');

var system = new Logdown({ prefix: '[BDS:SYSTEM]', });
var error = new Logdown({ prefix: '[BDS:ERROR]', });
var message = new Logdown({ prefix: '[BDS:MSG]', });
var size = 0;
var port = 8081;
var patched;
var timestamp;
var data;

/**
 * Convert bytes to kb + round it to xx.xx mask
 * @param  {Number} bytes
 * @return {Number}
 */
function bytesToKb(bytes) {
  return Math.round((bytes / 1024) * 100) / 100;
}

module.exports = function injectWebSocket(scope, options) {
  if (scope.ws) return;

  if (options.port) port = options.port;
  scope.ws = new WebSocket('ws://localhost:' + port);

  scope.ws.onmessage = function onMessage(res) {
    timestamp = '['+ moment().format('HH:mm:ss') + ']';
    data = JSON.parse(res.data);

    /**
     * Check for errors
     * @param  {String} data.error
     */
    if (data.error) {
      var errObj = data.error.match(/console.error\("(.+)"\)/)[1].split(': ');
      var errType = errObj[0];
      var errFile = errObj[1];
      var errMsg = errObj[2].match(/(.+) while parsing file/)[1];

      error.error(timestamp + ' Bundle *' + data.bundle + '* is corrupted:' +
        '\n\n ' + errFile + '\n\t ? ' + errMsg + '\n');
    }

    /**
     * Setup initial bundles
     * @param  {String} data.sources
     */
    if (data.sources) {
      scope.bundles = data.sources;

      scope.bundles.forEach(function iterateBundles(bundle) {
        system.log(timestamp + ' Initial bundle size: *' +
          bytesToKb(bundle.content.length) + 'kb*');
      });
    }

    /**
     * Apply patch to initial bundle
     * @param  {Diff} data.patch
     */
    if (data.patch) {
      console.groupCollapsed(timestamp, 'Patch for', data.bundle);
      system.log('Received patch for *' +
        data.bundle + '* (' + bytesToKb(data.patch.length) + 'kb)');

      var source = scope.bundles.filter(function filterBundle(bundle) {
        return bundle.file === data.bundle;
      })[0].content;

      system.log('Patch content:\n\n', data.patch, '\n\n');

      try {
        patched = diff.applyPatch(source, data.patch);
      } catch (e) {
        return error.error('Patch failed. Can\'t apply last patch to source: ' + e);
      }

      Function('return ' + patched)();

      scope.bundles.forEach(function iterateBundles(bundle) {
        if (bundle.file === data.bundle) {
          bundle.content = patched;
        }
      });

      system.log('Applied patch to *' + data.bundle + '*');
      console.groupEnd();
    }

    /**
     * Some other info messages
     * @param  {String} data.message
     */
    if (data.message) {
      message.log(timestamp + ' ' + data.message);
    }
  };
};


Вроде бы ничего особенного: разве что использование diff.applyPatch(source, data.patch). В результате вызова этой функции, мы получаем пропатченный исходник, который далее в коде красиво вызываем через Function.

Последнее, но очень важное — injectReactDeps.js:

injectReactDeps.js
module.exports = function injectReactDeps(scope) {
  scope.React = require('react');
  scope.ReactMount = require('react/lib/ReactMount');
  scope.makeHot = require('react-hot-api')(
    function getRootInstances() {
      return scope.ReactMount._instancesByReactRootID;
    }
  );
};


Под капотом всей программы бьется сердце из react-hot-api от Даниила Абрамова aka gaearon. Данная библиотека подменяет export'ы наших модулей (читай компонентов) и при изменении онных она «патчит» их прототипы. Работает как часы, но с рядом ограничений: в процессе «патча» все переменные скоупа, оторванные от react компонента будут утеряны. Так же есть ряд ограничений на работу со state'ом компонентов: нельзя менять первоначальное состояние элементов — для этого требуется перезагрузка.

Ну и нельзя не упомянуть, что всё это вместо собирается воедино файлов transform.js, который реализует browserify transform, позволяющий воплотить всю задумку в жизнь выступая связующим звеном между всеми вышеупомянутыми файлами.

transform.js
const through = require('through2');
const pjson = require('../package.json');

/**
 * Resolve path to library file
 * @param  {String} file
 * @return {String}
 */
function pathTo(file) {
  return pjson.name + '/src/' + file;
}

/**
 * Initialize react live patch
 * @description Inject React & WS, create namespace
 * @param  {Object} options
 * @return {String}
 */
function initialize(options) {
  return '\n' +
    'const options = JSON.parse(\'' + JSON.stringify(options) + '\');\n' +
    'const scope = window.__hmr = (window.__hmr || {});\n' +
    '(function() {\n' +
      'if (typeof window === \'undefined\') return;\n' +
      'if (!scope.initialized) {\n' +
        'require("' + pathTo('injectReactDeps') + '")(scope, options);\n' +
        'require("' + pathTo('injectWebSocket') + '")(scope, options);' +
        'scope.initialized = true;\n' +
      '}\n' +
    '})();\n';
}

/**
 * Override require to proxy react/component require
 * @return {String}
 */
function overrideRequire() {
  return '\n' +
    'require = require("' + pathTo('overrideRequire') + '")' +
    '(scope, require);';
}

/**
 * Decorate every component module by `react-hot-api` makeHot method
 * @return {String}
 */
function overrideExports() {
  return '\n' +
    ';(function() {\n' +
      'if (module.exports.name || module.exports.displayName) {\n' +
        'module.exports = scope.makeHot(module.exports);\n' +
      '}\n' +
    '})();\n';
}

module.exports = function applyReactHotAPI(file, options) {
  var content = [];

  return through(
    function transform(part, enc, next) {
      content.push(part);
      next();
    },

    function finish(done) {
      content = content.join('');
      const bundle = initialize(options) +
        overrideRequire() +
        content +
        overrideExports();

      this.push(bundle);
      done();
    }
  );
};



Архитектура приложения


Приложение состоит из двух частей: сервера и клиента:

— Сервер выполняет роль наблюдателя за bundle-файлами и вычисляет diff между измененными версиями, о чём сразу же оповещает всех подключенных клиентов. Описание сообщений сервера и его исходный код можно найти здесь.
Разумеется, вы можете создать свою live-patch программу для любой библиотеки/фреймворка на основании этого сервера.

— Клиент в данном случае — это встраеваемая через transform программа, которая подключается к серверу по средствам WebSockets и обрабатывает его сообщения (применяет патч и перезагружает bundle). Исходный код и документацию по клиенту можно найти тут.

Дайте потрогать


В Unix/OS X вы можете воспользоваться следующими командами для скаффолдинга примера:

git clone https://github.com/Kureev/browserify-react-live.git
cd browserify-react-live/examples/01\ -\ Basic
npm i && npm start

В Windows, полагаю, придется поменять вторую строчку (морока со слэшами), буду рад если кто-нибудь протестирует и напишет правильный вариант.

После запуска этих 3 команд, вы должны увидеть в консоли что-то наподобе



Как только консоль радостно сообщит вам, что всё готово, заходите на http://localhost:8080



Теперь дело за вами: идем в browserify-react-live/examples/01 — Basic/components/MyComponent.js и меняем код.

Например, покликав пару раз на кнопку «Increase», я решил, что +1 — это для слабаков и поменял в коде

this.setState({ counter: this.state.counter + 1 });

на

this.setState({ counter: this.state.counter + 2 });

После сохранения я вижу в браузере результат применения патча:



Готово! Попробуем нажать «Increase» ещё раз — наш счётчик увеличился на 2! Profit!

Вместо заключения


— Честно говоря, я до последнего надеялся, что livereactload сработает для меня и мне не придется писать свою реализацию, но после 2х попыток с разницей в несколько месяцев я так и не добился хорошего результата (постоянно слетал state системы).
— Возможно, я что-то упустил, или же у вас есть предложения по улучшению — не стесняйтесь писать мне об этом, вместе мы сможем сделаем мир немножко лучше :)
— Спасибо всем, кто помогал мне с тестированием в полевых условиях

Комментарии (7)


  1. evnuh
    05.08.2015 13:01
    +1

    Где это используете? Что побудило строить такой самолёт заместо простой перезагрузки страницы, например? Или хотя бы просьбы перезагрузить страницу, как это сделано в веб версии телеграма и много ещё где?


    1. xamd
      05.08.2015 13:14
      +1

      В данный момент очень плотно работаю с React (разделяю монолитное Rails приложение на клиент-сервер), многие формы, такие, как форма оплаты, например, имеют несколько шагов. Очень удобно не заполнять/прокликивать первые N шагов, а просто применять новые изменения к нужному шагу «на лету». Так же просто довольно удобно работать — на одном мониторе браузер, на ноутбуке текстовый редактор. Нажал cmd + s — в ту же секунду увидел результат. Ну и вёрстка становится в разы веселее :)

      По поводу веб версии телеграмма и пр. — это иное. Моё решение исключительно для разработчиков, дабы увеличить удовольствие от разработки и сделать сам процесс ещё увлекательнее, но никак не изменить поведение программы. Моей целью было реализовать react-hot-loader для browserify


  1. GRaAL
    06.08.2015 00:25

    Добрый день.
    Совсем недавно делал похожее для mithril.js — тоже хотелось обновлений в реалтайме без перезагрузки страницы, чтобы не терять состояние. Я тогда за основу взял livereload, т.к. в целом он уже много чего умеет — у него есть сервер, клиент, интеграция с grunt/gulp и их watch, живое обновление css и полная перезагрузка страницы при других изменениях. Еще к нему можно писать плагины — например перехватить обновление js файлов (или какого-то конкретного — в вашем случае diff-файла) и дальше делать с ним что угодно. Если я ничего не упустил, то это позволит упростить ваше решение, убрав сервер, коммуникации по вебсокетам и, возможно, отслеживание изменений.


    1. xamd
      06.08.2015 18:59

      Здравствуйте!

      у него есть сервер, клиент, интеграция с grunt/gulp и их watch

      Все вотчеры работают поверх fsevents, в моём случае это библиотека, которую использует watchify, например. Интеграция с grunt/gulp при работе с browserify? Ну не знаю, лично я не люблю ни gulp ни grunt, т.к. на мой взягляд большую часть настроек можно прописать в том же package.json в разделе scripts. Вот отличная статья на эту тему.

      живое обновление css

      browserify и работа с css — разные вещи.

      и полная перезагрузка страницы при других изменениях

      Именно этого я и стараюсь избежать

      Еще к нему можно писать плагины — например перехватить обновление js файлов (или какого-то конкретного — в вашем случае diff-файла) и дальше делать с ним что угодно

      В моем случае вы можете делать то же самое для JS файлов. В данный момент я работаю над «частичной перезагрузкой» бандла, как это делает webpack, уже совсем скоро будет возможность получать отдельно измененные файлы и патчи.

      Если я ничего не упустил, то это позволит упростить ваше решение, убрав сервер, коммуникации по вебсокетам и, возможно, отслеживание изменений.

      К сожалению, не всё так просто: во-первых, меня не устраивает, что проект платный. Я люблю open source и не хочу строить своё решение поверх платного проекта. Во-вторых, сервер и коммуникации по веб-сокетам это не уберет. Вернее, можно, конечно, заменить сокеты long-polling'ом, но смысла в этом я не вижу, в данный момент все популярные браузеры поддерживают сокеты. Что касается сервера — заменить — не значит убрать. Как вы думаете, работает livereload? Он точно так же создает свой сервер и наблюдает за файлами, а различные плагины к хрому и script-коды и т.п. просто выступают в качестве клиента (у меня это browserify transforms).


  1. Eternalko
    07.08.2015 12:01
    +2

    Зачем, если есть webpack?


    1. xamd
      08.08.2015 14:06

      Бытует мнение, что webpack лучше browserify. Честно говоря, я его не разделяю. Мне нравится browserify и я хочу, чтобы люди, разделяющую мою точку зрения, имели схожий DX с пользователями webpack. Возможно, Вам будет интересно прочитать пост от substack'а (создателя browserify) «Browserify для пользователей webpack»


      1. Eternalko
        08.08.2015 14:47

        Ок. Дело вкуса я так понимаю. За ссылку спасибо, познавательно.