Some say that working on software architecture is like gardening. Since there’s no such thing as “the final season” or “final weather”, all you can do is to check the forecast for the upcoming days and introduce changes through a long list of iterations.
Today we’d like to shed some light on our front-end garden, where recently we’ve been working on making the codebase more consistent and future-proof.
How do you create a single-page app nowadays? Oh, it’s really simple. Just pick your favorite CLI-like utility (create-react-app, vue-cli or angular-cli), type a few characters into the console, wait until it gets bootstrapped and voilà – the next big thing is ready to be published.
Oh, wait a minute! I’ve forgotten to add some details that bring this example closer to real-world scenarios. What kind of details? For example, let’s add some legacy code that you have to deal with. Then, let’s try to add some hidden features you’re not aware of. Also, let’s not forget about traffic – our customers cannot lose access to our services just because we’re updating the front-end.
How do you deal with revamping the architecture of your core application in such an environment? This is the summary of recent progress towards a more coherent and scalable model of the UI at edrone.
To give you a better understanding of the journey we’ve been through so far, let’s start from explaining the big picture.
Then, there’s also VueJS. Over the last few months Vue has helped us to ship many important features based on component architecture and a more declarative and reactive approach to user interfaces. Not only does Vue help us reduce the impact of jQuery on our codebase, but it also brings to the table a whole zoo of plugins and libraries we can rely on. For sure, the most important one is Vuex – a state management library that is a part of the core Vue ecosystem.
By looking from a distance one could say that we’ve accidentally created an in-house framework perfectly tailored to our needs and user’s requirements – so what’s the catch?
Good enough, but not great enough
When you’re in the frenzy of shipping new features month over month, it’s relatively easy to forget about the debt you’re creating as a side-effect. The solution we had implemented down the road was surely good enough, but not great enough. To take this next step towards improved quality of our UI we had to tackle some recurring challenges listed below.
As of the day of writing this article, there are two main files responsible for handling client-side routing. The first one, which is a mix of route definitions and handling menu updates, is about 2.4K LOC, and the second one – responsible for routing events – is about 700 LOC. That’s more than 3K LOC to maintain and debug internally just to load the next page user requested. The main challenge here, though, is not the file size, but no signs of following the SRP principle. By simply scrolling through these two files I was able to list responsibilities such as:
- storing the definition of all the client-side routes
- fetching server-rendered templates
- managing permissions and access to these routes
- updating parts of the UI related to menu, user context and general layout
- handling back button navigation
- managing global resources and listeners attached to the page
- …with mixed usage of direct DOM access and some jQuery magic
Compatibility with Vue
In theory, there was nothing about Vue that our navigation module had to be aware of. Its main responsibility was to simply fetch the template, inject the HTML into the layout and to hand on the control to the browser which initializes the page. In practice, it was far from being enough. Why?
Selected root components relied heavily on Vue’s lifecycle hooks – we’ve been initializing the required resources in the mounted method, expecting that beforeDestroy will help us clean up everything. In reality, neither beforeDestroy nor destroyed were called for our root components. Vue was not aware that someone is interested in killing its root component so it was not able to manage its lifecycle end-to-end. During one recent project we struggled with solving the bug which was about zombie event listeners – attached handlers that leaked once components got removed from the page.
Of course it is always possible to introduce some workaround and so we did by extending the navigation module with additional enter/destroy hooks that duplicated Vue’s hooks, but as you can imagine, it didn’t make the codebase easier to understand, but rather the opposite.
As explained above, there is no real sync between our internal navigation module and Vue’s lifecycle hooks. To make things even more complicated, the process of migrating from TWIG templates to Vue-based architecture is still in progress. As a consequence of these inconsistencies between pages, there has to be an impact on end-user experience and so-called perceived performance – the overall experience we deliver to our customers.
In our case it was all about loaders, loaders, and… loaders. When navigating back and forth and jumping through pages we were not able to precisely tell when the page was fully initialized or when the Vuex store got properly hydrated. Sure, there are events we could rely on a bit more (‘DOMContentLoaded’, ‘load’ or ‘ajaxStop’ from jQuery), but in case of rather complex components and pages full of stacked AJAX requests it was not so straightforward. As a result, we were trying to play it safe – hardcoded timeouts, loading indicators and guessing whether or not the page could be presented.
As you can guess, the experience we’ve been delivering so far was good enough, but not great enough like we aimed for. Slower pages were presented too quickly, and pages with no more than one AJAX call were overloaded with indicators and loading markers.
Let there be router – vue-router
At some point in the past we’ve decided to go “full Vue” with all its strengths and goodies. When tackling the navigation problem described in this article, it is pretty clear that the more we rely on native ecosystem of Vue plugins and extensions, the better and more coherent our product will become. That’s why we’ve decided to put vue-router in the center of this whole movement towards the next iteration of our Single Page App.
New entry point
Having vue-router added to the project we started sketching first concepts of this reorganized UI. In the beginning we decided to create yet another layout file (twig), which will serve as the foundation for whatever comes next. Then, we created yet another entry point in Webpack Encore which now works as the entry to the whole app powered by Vue and vue-router and we also connected the newly created layout with this entry now called missionControlSPA.js
Then, there was the app shell, root containers and the most important part of the UI – our main navigation. Thankfully, recreating simple components in Vue is blazing fast so there were no major issues in this part, but one challenge we encountered was to ensure that all the global variables (user context) could be properly passed to pages we’re about to migrate to this new navigation model.
Due to these inconsistencies between TWIG and Vue worlds mentioned above there were no strict rules regarding passing context to internals of our application. Some developers were attaching data to window variable while others were extending business logic endpoints with additional properties.
With this new approach we’ve decided to rely on Vuex and its modules – by creating a “global” store for core parts of the SPA we were able to initialize the store and global module only once, with all the critical flags and settings (timezone, features, user properties, etc.). Now we were ready to read those properties from no other place than the Vuex store itself. At this point we were able to start thinking about the rest of the app – how could we migrate pages written in TWIG and how should we deal with components used in two different parts of the app at the same time?
Page by page
There was no other way to migrate the app to vue-router than to go through all the pages one by one, get familiar with their content and try to decide what’s the best strategy we could apply to this specific part of the UI.
For Vue-based pages it was pretty straightforward – container components were moved to a new directory, then they were linked with new routes and finally their Vuex stores got transformed into submodules of this new global store we created. Thanks to async components that Vue supports by default, we were able to keep the single entry point unmodified, with smaller chunks loaded on-demand.
For TWIG-based pages we had to figure out a workaround. Previously we were using jQuery to fetch the HTML of server-rendered pages and inject them into the specific part of the page, and we actually kept the same pattern now wrapped with a Vue component. When vue-router wants to render one of the legacy pages, we’re using “meta” properties to pass the URL to our “TwigContainer” component. When the “legacy page wrapper” component is being initialized, we fetch a Symfony-based view to inject it into a dedicated placeholder afterward.
The main challenge we faced here was about dealing with Vue modules used in these two contexts at once. Imagine a Vue app called “reports” that’s used as a part of a legacy page (as a widget), and also as a fully-baked reporting page (based on Vue) in some other places of the product. For these kinds of pages we had to convert all the components within one module in a way they no longer use a regular store, but rather a store as a Vuex module. It clicked for both multi-module global store, and also for single-module stores attached to legacy pages.
The Pareto Principle
As the Pareto Principle states, 80% of complexity can be found in 20% of modules you’re working on. The same rule applies to the process I’ve been describing in this article. Our migration to vue-router is still in progress – we’ve been able to migrate a pretty wide range of pages in the product keeping two core domains unmodified (for now).
By working with less popular views we’ve been able to get familiar with the new architecture and all its gotchas, lowering the risk of breaking the most important features our users expect us to provide. Right now we’re trying to get used to these newly introduced patterns and also inviting other engineers to get used to it. Within a couple of weeks we’ll be able to restart the process to finish the last milestone that’s in front of us, but even now our front-end construction site has managed to receive really positive feedback regarding the overall performance and reduced complexity we’ve been able to provide.
To demonstrate the impact of introduced changes on perceived performance, here’s the comparison of our navigation flow from before and after the update:
As you can see, by removing predefined timeouts, limiting loading indicators to absolute minimum and by increasing the consistency of our codebase we’ve been able to provide a much snappier experience for our users.
Looking forward to reading feedback from you and stay tuned for more front-end oriented articles here at edrone’s blog! In the meantime visit our career site and consider joining edrone to push our front-end forward – see ya next time!
Front-end lead @ edrone. Passionate about end-to-end product development and knowledge sharing. Helping you become a better engineer.