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