📜 ⬆️ ⬇️

LiveReload - javascript update without full page reload (for example, mithril)

Introduction


Not so long ago, I began to use such a useful thing as livereload (for those who do not know what it is - there is an article on Habré ). Livereload tracks changes in the code of the web application and overloads the page in the browser if necessary. At the same time Livereload styles come smarter and replace them on the fly without rebooting, which looks magical.

Unfortunately, with javascript such a number does not roll - Livereload does not know how humanely to replace only the changed scripts and overloads the entire page. This is especially sad if you use a tool like mithril , in which the presentation (read - html) is also set in javascript. If I change the model or controller, then everything is clear, but if I change the class of the diva in the view (for example, selecting the correct combination of bootstrap classes), then reloading the page seems unnecessary - well, I changed one function, just redraw the view from her help!

In general, it is not scary, of course (they worked somehow without it before), but why not make the work a little more convenient?
')


For those in a hurry



Staging


We have pure functions that map the model to the view (read - html). We need to make it so that if such a function changes, then we need to load the new version in the browser, replace the original one and, in the case of Mithril, call m.redraw ().

The easiest way to replace them is to push all such functions into a global object, and in their places of habitat place references to this object. Something like this:

//file1.js window.MyObj.MyFn = function (c) { return m("h1", c.text) } //file2.js var Page = { controller: function () { this.text = "Hello"; }, view: function (c) { return window.MyObj.MyFn(c); } } 

Now we can reload only file1.js and pull m.redraw (), after which the view will be redrawn. In this case, the current state of the system (in our case, stored in the controller Page) will be saved.

However, you don’t want to do this separation manually - firstly, the integrity of the component is broken (it’s quite convenient to change only one file during development, setting up the behavior and appearance of the component), secondly, there is already written code, thirdly, we would have to explain more to new developers . So you need to parse the source code and extract the functions we need.

Total task is reduced to two subtasks:
  1. how to find the functions we need in the existing code and extract them into a separate script
  2. How to teach LiveReload to update this script without reloading the page

Retrieving view functions


In the simplest case, we need to find all the functions declared as fields with the name view, and put their body into our separate file, not forgetting to throw arguments. However, there are a number of points:

Here is an illustrative example:
 var model = new Model(); //-  ,    view model.view = function () { this.viewed = true; } // ,     view function formatDate(date) { return m("span.date", [ m("span.year", date.getFullYear()), formatMonth(date), (date.getDate()+1) ]) } var Page1 = { // mithril-  view //formatDate  model   ,   m -  //date,   ,     view: function() { var date = new Date(); return m("h1", [model.title(), " today is ", formatDate(date)]); } } 

To solve the problem of 1 parsing the regexspam code, it will be obviously not enough; we need syntactic analysis. Fortunately, there is the esprima library, which parses the js code passed to it and gives the syntax tree (you can play here ) as usual json. Bypassing such a tree should not be difficult, you just have to deal with all possible types of tree nodes, so as not to miss any incidents. For example a record
 var a = 5; 
not at all the same as
 var a; a = 5; 
(respectively, the results of the parsing here and here )

Now it’s easy to find the function and all its dependencies. The bodies of the functions are taken out and stored separately, in the source code we place the call to the global function, and collect the code back using the escodegen library.

For solving tasks 2 and 3, something like C # attributes or Java annotations would be useful - some way to mark the necessary functions. Since in javascript this method is not provided; I had to think of it - let the attribute be the string that is the first expression in the function body. And if the value of the attribute is __stateless, then the function must be extracted, and if __ignore is not needed.
 var model = new Model(); //-  ,    view model.view = function () { "__ignore"; this.viewed = true; } // ,     view function formatDate(date) { "__stateless"; return m("span.date", [ m("span.year", date.getFullYear()), formatMonth(date), (date.getDate()+1) ]) } 

All of the above, I collected in the form of a separate library st8less , not sharpened by mithril. In theory, it can be used for other similar tasks.

Plugin for LiveReload


Now that we can get the functions we need and save them as a separate js file, we need to teach LiveReload to update it without reloading the entire page.

A plugin for LiveReload is easy to write, and it will pick up no matter how you use Livereload - insert a snippet on the page or use a browser extension. Plugins take precedence over standard behavior, and if we write this:

 function Plugin() { this.reload = function(path) { console.log("reloaded", path); return true; } } window.LiveReload.addPlugin(Plugin) 

then any change will call our reload method, and if we return true, then standard processing does not occur. So we can only track the update of our file with the functions removed (knowing the name of this file, of course) and reload only it.

To reboot, we will delete the existing script element and add a new one to the DOM, and the current time will be added to the script URL each time to prevent caching.

  doReloadScript: function (scriptNode) { var oldSrcBase = scriptNode.src.split("?")[0], parent = scriptNode.parentNode, newNode = this.window.document.createElement("script"); parent.removeChild(scriptNode); newNode.src = [oldSrcBase, new Date().getTime()].join('?'); parent.appendChild(newNode); }, 

Embedding in the build process


If the project is built using gulp (I have just this way) or another build system, then it would be logical to embed the extraction of view functions into the build process. In the case of gulp, it was necessary to write a plugin that will process all the js scripts that pass through it, pull the view functions from them and write them in a separate file, and then notify LiveReload about the changes.

I will not describe the creation of a plug-in for gulp, everything was done strictly according to the tutorials and examples of other plug-ins (such as gulp-coffee and gulp-concat), nothing unusual. As a result, gulpfile.js looks like this:
 ... other requires var changed = require('gulp-changed'); var extract = require('gulp-livereload-mithril'); var server; //  gulp-,   //     livereload- ( ) gulp.task('compile', function () { //   coffee-. //      - main.coffee  dashboard.coffee gulp.src("src/**/*.coffee") // -> main.coffee, dashboard.coffee //   js .pipe(coffee()) // -> main.js, dashboard.js //   -  view- .pipe(extract()) // -> main'.js, dashboard'.js, st8less.js //          . //    st8less.js .pipe(changed("public", { hasChanged: changed.compareSha1Digest })) // -> st8less.js //    public .pipe(gulp.dest("public")) // -> st8less.js //  LiveReload    .pipe(server ? server.notify() : gutil.noop()); }); //   , LiveReload-     //   -   compile gulp.task('serve', ['compile'], function () { server = gls.new('./server.js'); server.start(); gulp.watch(SRC, ['compile'] /* no need to notify here*/); }); 

Note the use of gulp-changed. If we change only main.coffee, then at the output we get updated main.js and st8less.js, and if we changed the view-function, then main.js will in fact be exactly the same. But the change time of main.js will still change, and as a result, LiveReload will reload the entire page. To prevent this from happening, you need to compare the actual content, which is what the gulp-changed plugin does.

Plugins for gulp and LiveReload are in a separate repository - gulp-livereload-mithril . He, in turn, refers to the st8less library described above.

Implicit loading of a new script


Our plugin creates a new js-file (st8less.js), and you need to refer to it from the html-page. It was possible to ask the user to do it yourself, but I thought: anyway, I’m changing user js files, why not add a simple document.write to one of them?

This was done, but this was not enough. If, say, we add document.write to the beginning of main.js, and main.js somewhere in the middle is already using the functions in the function, we will get an error, because The newly added script element has not yet started loading our script.

It is necessary to somehow load the specified script here and now, and I have not found any other way than to send a synchronous ajax request. The construction added to the beginning of one of the scripts looks like this:
 (function loadScriptSynchronously() { var path = "st8less.js"; document.write('<script src="' + path + '"></script>'); var req = new XMLHttpRequest(); req.open('GET', path, false); req.send(); var src = req.responseText eval(src) }.call()); 

If you wish, you can disable this horror by passing the {inject: false} plugin. Then you have to add the script tag manually.

Total


The task turned out to be completely solvable, and the above solution can be applied not only for mithril, but also for other similar cases - react and angular.js 1.x come to mind (often the html layout for directives fits directly into the directive js code).

There are solutions and disadvantages, for example:
  1. esprima does not fully support ES 6, i.e. if there are, say, generators in your code, the plugin will not be used.
    Solution: pre-convert ES6 code to ES5

  2. changes existing code (indentation, alignment, etc., are confused after passing code through esprima / escodegen)
    Solution: not yet.

  3. making part of the functions complicates debugging
    Probably yes, but I have never had to debug presentation functions — as a rule, they have no logic. And even if you want to see what's inside, in the case of mithril it’s really easier to add a line like
      m "pre", JSON.stringify(someValueIWantToWatch) 
    and track the value right on the page

Where to look:


An example of use can be found here .
The plugin itself is here .

Thank you for your attention, I hope it was useful.

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


All Articles