📜 ⬆️ ⬇️

Real-time stateful React components update for Browserify



Good day to all!
Let's talk a little about the DX (Developer Experience) or “Experience Development”, and more specifically, about updating the code in real time while maintaining the state of the system. If the topic is new to you, I advise you to read the following videos before reading:

A number of videos with the update code in real time without reloading the page




Introduction: How does it work?


First of all, it should be understood that the implementation of such functionality implies the solution of a number of tasks:
- Tracking file changes
- Patch calculation based on file changes
- Transporting the patch to the client (in the browser, for example)
- Processing and applying the patch to the existing code
But first things first.

File change tracking


In my experience I tried four different implementations:
- Solution from github
- Native fs.watch
- Chokidar
- gaze
One can argue about the advantages of one application over another, but for me personally I chose chokidar - quickly, conveniently, works well on OS X (thanks, paulmillr ).
')
Our task at this step is to track changes to bundle files and respond to changes in them. However, there is one catch: browserify opens the bundle file in stream recording mode, which means that the "change" event can occur several times before the end of the recording (unfortunately, there is no such event). Therefore, in order to avoid potentially problematic situations with an invalid patch, we have to include an additional check of the validity of the code (trite check for data in the file and syntax errors). This part seems to be clear. Well, move on?

Patch calculation based on file changes


We track changes only bundle files. As soon as one of these files changes, we must calculate the patch for the old version of the file and transfer it to the client. At the moment, livereactload is actively used for browserify when working with react-code in real-time mode, which, in my opinion, solves this problem with a wild overhead projector: every time you receive a whole bundle. As for me, it is too much. And what if my source maps bundle weighs 10MB? Is it good to add such traffic when adding a comma? Well, I do not…

Since browserify does not provide for the possibility of “hot-swapping modules” like in a webpack , we cannot simply “replace” a piece of code in runtime. But perhaps this is even better, we can be even more cunning!

Viva jsdiff ! We feed him the initial and modified versions of the file content and get the output - a real diff, which, with atomic changes (I personally press cmd + s for each) weighs about 1Kb. And what is even more pleasant - he read! But everything has its time . Now you need to transfer this diff to the client.

Transporting the patch to the client


No magic is foreseen in this section: a normal WebSocket connection with the ability to send the following messages:

- If everything went well, diff was successfully calculated and no errors occurred, then we send a message to the client
 { "bundle": BundleName <String>, //     bundle- "patch": Patch <String> //     } 

- If everything went wrong, and when calculating diff, a syntax error was detected:
 { "bundle": BundleName <String>, //    bundle-,    "error": Error <String> //    } 

- When a new client joins the session, all the “sources” are sent to him, which we are watching:
 { "message": "Connected to browserify-patch-server", "sources": sources <Array>, //     bundle- } 

View the source here .

Processing and applying a patch to an existing code


The main magic happens in this step. Suppose we got a patch, it is correct and can be applied to the current code. What's next?
And then we have to make a small lyrical digression and see how browserify wraps files. To be honest, in order to explain this in simple and understandable language, it is best to translate Ben Klinkenbird's excellent article , but instead I’ll probably continue and leave the material to the reader. The most important thing is the DI in each module's scop:

An example from the article
 { 1: [function (require, module, exports) { module.exports = 'DEP'; }, {}], 2: [function (require, module, exports) { require('./dep'); module.exports = 'ENTRY'; }, {"./dep": 1}] } 


This is how we access the require function and the module and exports objects. In our case, the usual require will not be enough: we need to encapsulate the logic of working with the patch (we are not going to write it with our hands in each module)! The easiest, if not the only way to do this is to overload the require . This is what I am doing in this file :

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]; } }; }; 


As you probably noticed, in the code I use scope , which above the stack refers to window . Also, the makeOverrideRequire function uses req , which is nothing but the original require function. As you can see, all modules are proxied to scope.modules in order to be able to access them at any time (perhaps I will find use for this in the future. If not, I will abolish). Also, as can be seen from the code above, I check if the react module is om or react-dom . In this case, I simply return the link to the object from the scopa (if you use different versions of React, this will lead us to errors when working with hot-loader-api, because the service getRootInstances will point to another object).

So go ahead - work with the socket :

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); } }; }; 


It seems to be nothing special: except the use of diff.applyPatch(source, data.patch) . As a result of calling this function, we get the patched source, which later in the code is beautifully called through Function .

Last but not least, 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; } ); }; 


Under the hood of the entire program, the heart of react-hot-api from Daniel Abramov aka gaearon beats . This library replaces the export of our modules (read components) and when they are changed, it “patches” their prototypes. It works like a clock, but with a number of limitations: in the “patch” process, all the variables of the loop that are detached from the react component will be lost. There are also a number of restrictions on working with the state of the components: you cannot change the initial state of the elements - this requires a reboot.

Well, it is impossible not to mention that all this instead of going together transform.js files, which implements the browserify transform, which allows you to put your whole idea into life by acting as a link between all the above-mentioned files.

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(); } ); }; 



Application architecture


The application consists of two parts: server and client :

- The server acts as an observer for the bundle files and calculates the diff between the changed versions, which immediately notifies all connected clients. A description of the server messages and its source code can be found here .
Of course, you can create your own live-patch program for any library / framework based on this server.

- The client in this case is a program embedded through the transform, which connects to the server using WebSockets and processes its messages (applies the patch and reloads the bundle). Source code and customer documentation can be found here .

Give touch


On Unix / OS X, you can use the following commands to scaffold an example:

 git clone https://github.com/Kureev/browserify-react-live.git cd browserify-react-live/examples/01\ -\ Basic npm i && npm start 

In Windows, I believe, it is necessary to change the second line (trouble with slashes), I will be glad if someone tests and writes the correct version.

After running these 3 commands, you should see something like in the console



As soon as the console happily informs you that everything is ready, go to http: // localhost: 8080



Now it's up to you: go to browserify-react-live / examples / 01 - Basic / components / MyComponent.js and change the code.

For example, having called a button “Increase” a couple of times, I decided that +1 is for weaklings and changed in the code

 this.setState({ counter: this.state.counter + 1 }); 

on

 this.setState({ counter: this.state.counter + 2 }); 

After saving, I see in the browser the result of applying the patch:



Done! Let's try to click «Increase» again - our meter has increased by 2! Profit!

Instead of conclusion


- Honestly, I was hoping to the last that livereactload would work for me and I wouldn’t have to write my implementation, but after 2 attempts with a difference of several months I didn’t achieve a good result (the state system was constantly flying).
- Maybe I missed something, or you have suggestions for improvement - do not hesitate to write me about it, together we can make the world a little bit better :)
- Thanks to everyone who helped me with field testing

Source: https://habr.com/ru/post/264175/


All Articles