Обновление React компонентов с сохранением состояния в режиме реального времени для Browserify



    Всем доброго времени суток!
    Давайте немного поговорим о 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 системы).
    — Возможно, я что-то упустил, или же у вас есть предложения по улучшению — не стесняйтесь писать мне об этом, вместе мы сможем сделаем мир немножко лучше :)
    — Спасибо всем, кто помогал мне с тестированием в полевых условиях

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 8

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

        По поводу веб версии телеграмма и пр. — это иное. Моё решение исключительно для разработчиков, дабы увеличить удовольствие от разработки и сделать сам процесс ещё увлекательнее, но никак не изменить поведение программы. Моей целью было реализовать react-hot-loader для browserify
        0
        Добрый день.
        Совсем недавно делал похожее для mithril.js — тоже хотелось обновлений в реалтайме без перезагрузки страницы, чтобы не терять состояние. Я тогда за основу взял livereload, т.к. в целом он уже много чего умеет — у него есть сервер, клиент, интеграция с grunt/gulp и их watch, живое обновление css и полная перезагрузка страницы при других изменениях. Еще к нему можно писать плагины — например перехватить обновление js файлов (или какого-то конкретного — в вашем случае diff-файла) и дальше делать с ним что угодно. Если я ничего не упустил, то это позволит упростить ваше решение, убрав сервер, коммуникации по вебсокетам и, возможно, отслеживание изменений.
          0
          Здравствуйте!
          у него есть сервер, клиент, интеграция с 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).
          +2
          Зачем, если есть webpack?
            0
            Бытует мнение, что webpack лучше browserify. Честно говоря, я его не разделяю. Мне нравится browserify и я хочу, чтобы люди, разделяющую мою точку зрения, имели схожий DX с пользователями webpack. Возможно, Вам будет интересно прочитать пост от substack'а (создателя browserify) «Browserify для пользователей webpack»
              0
              Ок. Дело вкуса я так понимаю. За ссылку спасибо, познавательно.
            0
            А с транспилерами как интеграция? С babel и jsx, в частности.

            Only users with full accounts can post comments. Log in, please.