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