I have always been interested in how Habr works from the inside, how workflows are built, how communications are built, what standards are used and how the code is written here. Fortunately, I had this opportunity, because I recently became part of the habrakommando. Using the example of a small mobile version of refactoring, I will try to answer the question: what is it like to work here in the front. In the program: Node, Vue, Vuex and SSR with sauce from notes about personal experiences in Habré.
The first thing you need to know about the development team is that we are few. It is not enough - these are three fronts, two backups and the techlide of all Habr - Baxley. There are, of course, another tester, designer, three Vadim, miracle whisk, marketer and other Bumburums. But there are only six direct contributors to Habr's sorties. This is quite rare - a project with a multimillion audience, which looks like a giant enterprise from the outside, in fact looks more like a cozy startup with the most flat organizational structure.
Like many other IT companies, Habr professes Agile ideas, the practice of CI, and that's it. But according to my feelings, Habr as a product develops in a wave rather than continuously. So a few sprints in a row we diligently code something, design and redesign, break something and repair, fix tickets and start new ones, step on the rake and shoot ourselves at the feet to finally release the feature in the prod. And then there comes a lull, a period of redevelopment, time to do what is in the quadrant “important-indefinite.”
Just about this "interseason" sprint and will be discussed below. This time, the refactoring of the Habr mobile version got into it. In general, the company has high hopes for it, and in the future it should replace the entire zoo of Habr's incarnations and become a universal cross-platform solution. Sometime there will be an adaptive layout, and PWA, and offline mode, and custom customization, and many interesting things.
Set the task
Once on an ordinary stand-up, one of the fronts spoke about the problems in the architecture of the component of comments of the mobile version. With this submission, we organized a micro-meeting in the format of group psychotherapy. Everyone took turns to say where he was in pain, everything was fixed on paper, sympathized, understood, unless no one clapped. The output was a list of 20 problems, which made it clear that the mobile Habr still has a long and thorny path to success.
I was worried primarily about the efficiency of resource use and what is called the smooth interface. Every day on the “home-work-home” route, I saw my old phone desperately trying to display 20 headers in a feed. It looked like this:
< sub> Mobile Habr interface before refactoring
What's going on here? In short, the server gave the HTML page the same to everyone, regardless of whether the user is logged in or not. Then the client JS is loaded and re-requests the necessary data, but already adjusted for authorization. That is, in fact, we did the same work twice. The interface flickered, and the user downloaded a good hundred extra kilobytes. In detail, everything looked even more terrible.
Old SSR-CSR scheme. Authorization is possible only at steps C3 and C4, when Node JS is not busy generating HTML and can proxy API requests.
One of Habr's users described our architecture of that time very accurately:
Mobile version - crap. I say it as it is.A terrible combination of SSR with CSR.
We had to admit it, no matter how sad it was.
I figured out the options, set myself a ticket in “Djir” with the description at the level “it’s bad now, do the rules” and with a broad brushstroke I decomposed the task:
- reuse data li>
- minimize the number of redraws,
- exclude duplicate requests
- make the boot process more obvious.
Reuse data h3>
In theory, server-side rendering is designed to solve two problems: not to suffer from the limitations of search engines in terms of SPA indexing and improve the FMP metric (inevitably degrading TTI ). In the classic scenario, which finally formulated in Airbnb in 2013 (back on Backbone.js), SSR is the same isomorphic JS application running in the Node environment. The server simply gives the generated layout as a response to the request. Then there is a re-registration on the client side, and then everything works without reloading the page. For Habr, as well as for many other resources with text content, server rendering is a critical element in building friendships with search engines.
Despite the fact that more than six years have passed since the advent of technology, and during this time a lot of water has actually flowed into the world of the frontend, for many developers this idea is still covered with a veil of secrecy. We did not stand aside, and rolled out a Vue-application with SSR support, missing one small detail: we didn’t throw the initial state on the client.
Why? There is no exact answer to this question. Either they didn’t want to increase the size of the response from the server, or because of a bunch of other architectural problems, or they simply didn’t take off. One way or another, to throw a state and reuse everything that the server did seems to be quite appropriate and useful. The task is actually trivial - the state is simply injected into the execution context, and Vue automatically adds it to the generated layout as a global variable:
window .__ INITIAL_STATE __ .
One of the problems that has arisen is the impossibility of converting cyclic structures into JSON ( circular reference ); was solved by simply replacing such structures with their flat counterparts.
In addition, when dealing with UGC content, it should be remembered that the data should be transformed into HTML-entities, in order not to break the HTML. For this purpose, we use he .
As can be seen from the diagram above, in our case, one Node JS instance performs two functions: SSR and “proxy” in the API, where the user is being authenticated. This circumstance makes it impossible to authorize when the JS code is executed on the server, since the node is single-threaded, and the SSR function is synchronous. That is, the server simply cannot send requests to itself while the callstack is busy with something. It turned out that we prokinuli state, but the interface did not cease to twitch, because the data on the client should be updated to reflect the user session. It was necessary to teach our application to put the correct data in the initial state, taking into account the user's login.
There were only two solutions to the problem:
- cling authorization data to cross-server requests;
- Split Node JS layers into two separate instances.
The first solution required the use of global variables on the server, and the second stretched the deadlines for the task at least a month.
How to make a choice? Habr often moves along the path of least resistance. Informally, there exists a certain common desire to reduce to a minimum the cycle from idea to prototype. The model of attitude to the product is somewhat similar to booking.com, with the only difference that Habr is much more serious about user feedback and trusts you as a developer to make such decisions.
Following this logic and my own desire to quickly solve the problem, I chose global variables. And, as it often happens, sooner or later they have to pay for them. We paid almost immediately: we worked at the weekend, scooped up the consequences, wrote post mortem and began to divide the server into two parts. The error was very stupid, and the bug with her participation was not easy to reproduce. And yes, for such a shame, but somehow, stumbling and groaning, my PoC with global variables still went into production and quite successfully works in anticipation of moving to a new “two-tier” architecture. It was an important step, because formally the goal was achieved - the SSR learned to give the page completely ready for use, and the UI became much calmer.
Mobile Habr interface after the first step of refactoring
Ultimately, the SSR-CSR architecture of the mobile version leads to this picture:
￼ “Two-Way” SSR-CSR scheme. The Node JS API is always ready for asynchronous I/O and is not blocked by the SSR function, since the latter is in a separate instance. Query chain # 3 is not needed.
Eliminate duplicate requests
After the done manipulations, the initial render of the page stopped provoking epilepsy. But the continued use of Habr in the SPA mode was still puzzling.
Since the user flow is based on list of articles → article → comments and back, it was important to optimize the consumption of resources in this chain in the first place.
< sub> Returning to the post feed triggers a new data request
I did not have to dig deep. On the screencast above, it is clear that the application re-queries the list of articles when you swipe back, and we don’t see the articles at the time of the request, so the previous data disappears somewhere. It looks as if the article list component uses a local state and loses it to destroy. In fact, the application used a global state, but the Vuex architecture was built in the forehead: the modules are tied to pages, which in turn are tied to routes. And all the modules are “disposable” - each next entry on the page rewrote the module entirely:
In total, we had a ArticlesList module that contains objects of type Article and a module PageArticle that was an extended version of the object Article of your kind ArticleFull . By and large, this implementation does not carry anything terrible in itself - it is very simple, one can even say naively, but it is extremely understandable. If you cut out the reset of the module at each change of the route, then you can even live with it.However, the transition between the feeds of articles, for example /feed →/all , is guaranteed to throw away everything related to the personalized feed, since we have only one ArticlesList into which to put new data. This again leads us to duplicate requests.
Having collected in a heap all that I managed to dig up on the topic, I formulated a new structure of the state and presented it to my colleagues. The discussions were lengthy, but in the end, the arguments “for” outweighed the doubts, and I started to implement.
The logic of the solution is best revealed in two stages. First we try to untie the Vuex module from the pages and link it directly to the routes. Yes, there will be a little more data in the store, the getters will become a bit more complicated, but we will not load the articles twice. For the mobile version, this is probably the strongest argument. It will turn out like this:
But what if article lists can intersect between multiple routes and what if we want to reuse the data of the Article object to render the post page, turning it into ArticleFull ? In this case, it would be more logical to use this structure:
ROUTE_FEED: ['1', ...],
ROUTE_ALL: ['1', '2', ...],
ArticlesList here is just some kind of repository of articles. All articles that were uploaded during the user session. We treat them as carefully as possible, because this is traffic that may have been loaded through pain somewhere in the subway between stations, and we definitely don’t want to cause this pain to the user again, forcing him to load the data that he has already loaded. The ArticlesIds object is simply an array of IDs (like “links”) to Article objects. This structure allows us not to duplicate the data common to routes and reuse the Article object when rendering the post page by merging extended data into it.
The listing of articles has also become more transparent: the iterator component goes through the array with the ID of the articles and draws the teaser component of the article, passing Id as the props, and the child component in turn retrieves the necessary data from ArticlesList . When you go to the publication page, we retrieve the already existing date from ArticlesList , make a request for obtaining the missing data and simply add it to the existing object.
Why is this approach better? As I wrote above, this approach is more careful with respect to the loaded data and allows you to reuse them. But beyond that, it opens the way for some new features that perfectly fit into such an architecture. For example, polling and uploading articles to the tape as they appear. We can simply add fresh posts to the ArticlesList “repository”, save a separate list of new IT users in ArticlesIds and notify the user about this. When you click on the button “Show new publications”, we simply insert the new Id at the beginning of the array of the current list of articles and everything will work almost magically.
Making the download more enjoyable
Cherry on the refactoring cake has become the concept of skeletons, which makes the process of downloading content on the slow Internet a little less disgusting. There were no discussions on this score, the path from the idea to the prototype took literally two hours. The design was drawn almost by itself, and we taught our components to render simple, barely flickering div-blocks while waiting for data. Subjectively, such an approach to loading really reduces the amount of stress hormones in the user's body. The skeleton looks like this:
I have been working in Habré for half a year and my acquaintances still ask: well, how are you there? Well, comfortable - yes. But there is something that distinguishes this work from others. I worked in teams that were absolutely indifferent to their product, did not know and did not understand who their users were. And here everything is different. Here you feel responsible for what you are doing. In the process of developing a feature, you partially become its owner, take part in all the product meetings related to your functionality, make suggestions and make decisions yourself. Making a product that you use yourself every day is very cool, and writing code for people who may know this better than you is an incredible feeling (no sarcasm).
After the release of all these changes, we received a positive feedback, and it was very, very nice. It is inspiring. Thank! Write more.
I recall that after the global variables, we decided to change the architecture and the selection of the proxying layer into a separate instance. The “two-stage” architecture has already reached the release in the form of a public beta test. Now anyone can switch to it and help us make mobile Habr better. That's all for today. I am pleased to answer all your questions in the comments.