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

ссылка на оригинал статьи http://habrahabr.ru/post/264175/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *