Всем доброго времени суток!
Давайте немного поговорим о 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
. Именно это я и делаю в этом файле: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
будет указывать на другой объект).Итак, идем дальше — работа с сокетом:
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:
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, позволяющий воплотить всю задумку в жизнь выступая связующим звеном между всеми вышеупомянутыми файлами.
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)
GRaAL
06.08.2015 00:25Добрый день.
Совсем недавно делал похожее для mithril.js — тоже хотелось обновлений в реалтайме без перезагрузки страницы, чтобы не терять состояние. Я тогда за основу взял livereload, т.к. в целом он уже много чего умеет — у него есть сервер, клиент, интеграция с grunt/gulp и их watch, живое обновление css и полная перезагрузка страницы при других изменениях. Еще к нему можно писать плагины — например перехватить обновление js файлов (или какого-то конкретного — в вашем случае diff-файла) и дальше делать с ним что угодно. Если я ничего не упустил, то это позволит упростить ваше решение, убрав сервер, коммуникации по вебсокетам и, возможно, отслеживание изменений.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).
Eternalko
07.08.2015 12:01+2Зачем, если есть webpack?
xamd
08.08.2015 14:06Бытует мнение, что webpack лучше browserify. Честно говоря, я его не разделяю. Мне нравится browserify и я хочу, чтобы люди, разделяющую мою точку зрения, имели схожий DX с пользователями webpack. Возможно, Вам будет интересно прочитать пост от substack'а (создателя browserify) «Browserify для пользователей webpack»
evnuh
Где это используете? Что побудило строить такой самолёт заместо простой перезагрузки страницы, например? Или хотя бы просьбы перезагрузить страницу, как это сделано в веб версии телеграма и много ещё где?
xamd
В данный момент очень плотно работаю с React (разделяю монолитное Rails приложение на клиент-сервер), многие формы, такие, как форма оплаты, например, имеют несколько шагов. Очень удобно не заполнять/прокликивать первые N шагов, а просто применять новые изменения к нужному шагу «на лету». Так же просто довольно удобно работать — на одном мониторе браузер, на ноутбуке текстовый редактор. Нажал cmd + s — в ту же секунду увидел результат. Ну и вёрстка становится в разы веселее :)
По поводу веб версии телеграмма и пр. — это иное. Моё решение исключительно для разработчиков, дабы увеличить удовольствие от разработки и сделать сам процесс ещё увлекательнее, но никак не изменить поведение программы. Моей целью было реализовать react-hot-loader для browserify