Vite - a lesson in migration
I know what you're all thinking, another day, another JS framework. But here we are, to talk about Vite, how our team at UW migrated to it for our internal CRM UI, written in React, and the lessons we learned along the way.
So what is Vite? To quote their official docs, "a build tool that aims to provide a faster and leaner development experience for modern web projects". It's another development web server for JavaScript projects, and it's a bundler to ship minified code. That brief definition undersells it a little bit though, so let's dive deeper into what we had already, and the experience we endured as developers.
What's wrong with what we had?
For several years now, the go-to development tool and server for front-end projects was Webpack. This too is a bundler, able to take in many files, and spit out one larger file. In partnership with a dev server which automatically refreshed when any input files changed, we had a reliable setup. Why change?
#1 It was slooow ??
A cold start of the server would take ~3 minutes, and reloads after making a code change would take around 30 seconds. Not great.
Is this actually an issue though? Do a few seconds really change the overall delivery rate? Well a few thoughts on developer experience:
#2 Every reload lost your state
If you're making a small visual change to part of your app, having a full page reload isn't a huge deal, it'll just pop up and you can inspect your changes.
But what if you have to perform a significant user journey? A classic example of this in our application is making a phone call. The journey to make a call is significant: login to the phone controls (this requires a pop-up with an OAuth); select "Available" to put yourself on a queue to receive calls; make a call from your personal phone; select the correct option on your phone’s keypad; accept the call. This whole process can take upwards of 2 minutes - not ideal!
This has similar impact to #1, frustration, and a desire for larger changesets.
#3 Configuration
Not a huge issue, but Webpack requires quite significant configuration (particularly in our case - I'll get to that later). It might seem counter-intuitive, but being restrictive can lead to innovation.
Introducing Vite
We've identified the pain points in our current development process, so what alternative have we got? The JavaScript community is starting to lean towards Vite as a common standard for new SPA projects. I've touched on their tag line, but what does it actually do, and what did it mean for us?
For starters, it uses esbuild to handle the transpilation process, rather than pure JavaScript. esbuild is extremely fast, written in Go, so it benefits from that lower level performance. It does this at start-up time for all your dependencies, and then individually as your request each file, hold on to that thought of requesting each file.
This is where Vite really differs from other tools. Vite doesn't bundle during development, the browser requests every single file. It does this simply using modern web standards. It enforces every file to use ECMAScript Modules (ESM, the import/export syntax you see). This means the browser is responsible for traversing the tree of our application, rather than our development server. Vite's own imagery highlights this pretty well:
On the face of it, this seems like a somewhat arbitrary separation of the bundling point. But consider what happens when you change a file? With a traditional bundler, the entire application needs to be recompiled (??). With Vite, this means only the file tree from that point downwards needs to change. This allows for lightning fast changes, as well as allowing us to persist state between code changes, nice! This is particularly great for when we make changes to our phone controls, no more having to make a phone call every time.
When it comes to production, Vite still does bundle (in fact it uses Rollup under the hood).
Migration
After we identified that Vite had the potential to be a huge game changer for our development performance, we needed to start the migration. As it turns out, this would be a hell of a journey.
领英推荐
ESM
The most basic step to get started was to ensure we were set up to handle ECMAScript Modules (ESM). For the most part this is pretty straightforward, as most TypeScript is generally written with import/export syntax anyway, and is just compiled down to CommonJS. There were a few exceptions in our app which forced us to migrate some old require() statements, but nothing too challenging.
Next was our configuration files. This is really easy for the most part, just set "type": "module" in our package.json. Some of our tooling still uses the CommonJS bundling (i.e. ESLint and and Jest), so we need to tell the compiler, we do this using a .cjs extension. Nothing too scary.
React Native
Now this is where it started to get fun. You might be wondering why a web app needs to worry about React Native. Many years ago, here at UW we created an internal UI framework which would work both on mobile and on the web. We achieved this by using React Native Web for web applications, and using webpack’s “resolve” feature to turn every `react-native` import to `react-native-web`. This internal UI framework is no longer receiving active development, and all teams have been migrating away from it. For the most part this was fine, just updating some imports which we'd previously configured webpack to do for us.
However, the problem hit with icons. They were all built with React Native (specifically react-native-vector-icons). These then had to all manually be changed to web icons. Fortunately we had a desire to move all our icons to standard Material UI icons, so this just accelerated that transition. It was very challenging to do so in places due to the highly dynamic nature of our existing internal framework (and we did miss one icon!).
CSS
Styling in our application has gone through several iterations, at the point of migration, we had 4 (!) different styling mechanisms:
All of these solutions ultimately work the same way: generating a class and attaching it to a component. As a result, all styles end up with the same specificity (0100). If all styling has the same specificity, then we rely on the order of the cascade to ensure the correct styles are applied.
In the case of webpack, we did this by forcing the MUI v5 styles to be injected into the body, with a prepend property, and the CSS modules getting injected underneath. This left us with a hierarchy of MUI v4 < MUI v5 < CSS modules. React Native wasn't an issue here, because they never received any other kind of styling.
Vite however has no such control over where we inject CSS modules, and they got injected above MUI v5, so our overrides weren't working. We came up with an unfortunate hack for this: a post CSS processor, which artificially increased the specificity of all the CSS module classes.
Completion
There were a lot of changes, but ultimately we did get it all done, with a rather large PR…
Impact
The whole reason we started on this journey was to see a significant performance impact, so how did we do?
Numbers only tell half the story though. As mentioned before, the hot reloads are a phenomenal improvement when you need to develop complex journeys. The developer experience change is really hard to measure, but feedback so far has been very positive.
Lessons learned
It was a hell of a journey to get all this done, which isn't to be snuffed at as our app has ultimately been built on the same configuration last 6 years. There are definitely some things we learned while doing this:
Lead Front End Software Engineer @ Board Intelligence | Expertise in Redux.js, TypeScript
1 年Great article Richard. We have started to look at vitest over jest much better memory usage and faster test times. Not sure we can do vite yet for the bundler as laat time I looked module federation was different enough for us not to be able to port.