Vue 3 is amazing, but a lot of us are still stuck on a Vue 2 monolith. Some of us are stuck even more due to technology choices made before Vue 3 was even on the roadmap. Let's outline the process of migrating a large project to Vue 3 and Vite. Techniques we can employ to get some benefits sooner, process we can apply to get things done more quickly. Things we can do to get it done eventually.
Migrating a 1000 Class Components App to Vue 3
AI Generated Video Summary
The Talk discusses the migration of a large frontend view application from Vue 2 to Vue 3. The strategy involves converting components to the Composition API, switching from Vuex to Pinea, and overcoming challenges with Vite configuration. The migration process includes selecting components based on the product roadmap, improving type safety, and reducing boilerplate. The results of the migration include improved type checking, faster tests, and a safer codebase.
1. Introduction to Bagel Solutions and V7 Migration
Hi, everyone. My name is Nikola, and I own a sole propriety called Bagel Solutions. I've been working with V7 on their machine learning platform, which has evolved into a full AI machine learning end-to-end system. Currently, I'm focused on frontend development and involved in migrating a huge frontend view application from view 2 to view 3. This talk shares our experience, knowledge, and strategy in this process. V7 is a massive front-end monolith with 360,000 lines of code, presenting unique challenges. It includes typical single-page application features as well as whimsical and Photoshop-like parts. We have about a thousand components, but a significant portion will be deleted due to a large subsystem rewrite.
Hi, everyone. My name is Nikola, and I own a sole propriety called Bagel Solutions, because Bagel is my nickname. And for the past four years or so, I've been working with V7 on their machine learning platform. It started out as computer vision, but it became a lot more. So now it's sort of like a full AI machine learning end-to-end thing.
And I started as a backend developer, but I am now, currently, for the past year or so, working focused on the frontend. And for the past couple of months, we've been involved in migrating their huge frontend view application from view 2 to view 3. We're not done yet, but this talk is sort of about that process. So what this talk isn't, is a success story. Again, we're not done yet. It's still a work in progress. It's not a brag on how fast we did it in any way, shape, or form, because again, we're not done yet. And it's also not really a how-to, because we are doing some things specific to our project that work for us. It might not work for you. It might work, but no guarantee. What it is, is sort of a sharing of the experience we have with this, the knowledge we attained, and the strategy we used. And again, it's probably not going to be fully applicable to your case. But hopefully, it's at least going to be interesting.
So what is v7? Basically, it's one huge front-end monolith. 360,000 lines of code, more or less. And this is just counting TypeScript, not anything else. There are parts that look like your typical single-page application, data management, account management. But they do have problems that they need to deal with, that your typical single-page application doesn't have, like rendering lists of cards of hundreds of thousands of items, or things like that. There are parts that look kind of whimsical. This is our workflow editor, where you basically drag different DOM elements on top of a canvas, connect them with arrows, stuff like that. And there are also parts that kind of work and look like Photoshop. So this is our annotation UI, where you use different tools to draw different kinds of annotations on top of a canvas, which renders an image or a video or some other visual thing. We have about a thousand components in the project. But to be fair, we are in the middle, or near the end of a large rewrite of a pretty big subsystem. So I estimate that about 20% of those components are probably due to be deleted.
2. Migration Strategy and Goals
We initially decided to use Vue class components due to a lack of front-end engineers and our familiarity with classes. However, migrating to Vue 3 poses challenges because class components don't run in Vite or Vue 3. Despite potential support for class components in the future, we are switching to the Composition API as a new standard. Our migration goals include switching all components to the Composition API, switching from UX to Binia, running both our app and storybook on Vite, and switching Jest to Vitest. To avoid disruptions and bugs, we adopted a strategy where one engineer converts two components in a sprint, with each conversion timeboxed to two hours. Additionally, any new code added must use the Composition API.
Now, we do reserve the right to keep some of them for specific reasons, but I'm going to get into that later. Technologically speaking, we are fully using TypeScript. We are using VUEX, and it has become quite loaded with time. We have a dozen or so modules. They don't really feel well-designed anymore, because we've been a startup for a good period so we've been, like, iteratively adding to it without an amazing sense of direction. You know, startups make mistakes.
Because we are using... Well, first, we decided to use Vue class components. This was very early in the product development cycle, three and a half years or so, years ago or so, and we decided to go with that because, first of all, we didn't really have many front-end engineers. A lot of us were full-stack and classes are something we were kind of more familiar with. It seemed like the way to go back then, and at that time it also seemed like a way, an approach that has better TypeScript support because of the class decorators and the Vue X class decorators, which we also use as a library. But yeah. That was a decision made back then.
For testing, we, of course, used Jest with Vue test utils, and we do have a storybook setup, which we use as a component catalog, but not within the context of writing tests or anything like that. So clearly, for migrating 3D3, the number one problem is the fact that we use class components. That means there's more work to migrate a component than it would be with options API, and that means that right off the bat, we can't really start off with Vite config to the front-end build in Vite because class components don't really run in Vite or in Vue 3. Now, right now, there are, as far as I know, some PRs being reviewed for the Vue class components and Vue X Class libraries, potentially even a release candidate or something like that, where this support will be added. So it might be that by the time we're done with migration, it will also be possible to run class components in Vite, but we are still switching to Composition API just because this is a new standard and we prefer to use the standard rather than a third-party alternative. Well, we do now.
So our list of goals to consider this migration done is to switch all the components to Composition API, at least those that we're keeping, and maybe a few more, and again, I'll get into that a bit later, switch from UX to Binia, because that's the new standard, have both our app and storybook run on Vite, and have Jest switch to Vitest, meaning it also uses the same Vite configuration, which is a big advantage with Vitest or Jest.
So we need a strategy for this, and very early on we decided that a single big effort for us is a bad idea. We can't be doing one major push of just migrating, migrating, migrating until we're done, and then move on to something else. First of all, our team is reasonably big now, we have about 30 engineers, and even with that size it's still gonna take weeks, probably, to migrate all those components, especially because we'll be stepping on each other's toes. We are guaranteed to create bugs this way, there's just too much code being changed for that not to happen. And there will be way too many delays in our product work, so yeah, we're just not okay with that. To improve our process, we decided it needs to be something that gives us benefits as we go, and not when we're done, and it needs to be as non-disruptive as possible.
So the basic approach we went with is to have one engineer convert two components in a sprint, and our sprint is two weeks, for us, and this conversion of a single component is timeboxed to two hours. That means that if it looks like it's gonna take more than two hours, that ticket is immediately put on hold and we grab the next one. The reasoning is, as we get experience doing these conversions, we get to wrap up in speed, maybe change the rules to pick more components, and then we get to revisit those tickets that we put on hold, to see, maybe, it's gonna be faster this time because we know more. And then, as part of this whole effort, there's the additional rule that any new code that we write, any new component that we add to the codebase, needs to be Composition API, So no more, like no introduction of additional class components to the codebase.
3. Component Selection and Execution
The strategy for selecting components involves aligning with the product roadmap, migrating components before development starts, and identifying islands in the codebase for parallel migration. The focus is on building knowledge, improving type safety, reducing boilerplate, and speeding up feature work. The execution involves delaying migration of some components, marking chunks of the application as migrated, and converting components based on a map from class components to the Composition API. The goal is to collect knowledge and improve as a team.
The next part of the strategy is how we select these components, and here, again, it was important for us to not be too disruptive. So the first priority is to align with product. That means we understand what the product roadmap is, what the next features that are going to be developed will be, and which components in the codebase will be touched by that development. So then we focus on migrating those components first before that development starts, so that feature work is working on modern code and doesn't get slowed down by any conversion stuff.
Outside of that, we sort of identify islands in our codebase that we can migrate together, so that parts of the application become modernized as soon as possible. And by islands here, we mean components that revolve around building up a specific part of the product, a specific page, maybe some sub-feature. And then also components interacting with the same Vuex modules, because presumably that sort of aligns together as well, and it allows us to, in parallel, switch from Vuex to Pina more easily.
The mantra here is, as we do this, we need to build up knowledge. We learn about all the caveats we can, we document all this, and we share it with the rest of the team so that everyone gets faster, and as we go, we ramp up again. That's our number one priority. So the effects that we hope, and that we're seeing from this strategy, is that as we go, we get better type safety, because first of all, Composition API by default has more direct, slightly better type inference. Pina is far better at type inference and type-checking from Vuex. There's less boilerplate, we reduce the number of, like, some categories of bugs that relate to all this. We speed up in feature work, actually, because new features are built on new, modern code base, and we get knowledge of what other stuff we can do as we migrate. So not just conversions, but, like, other things in our code base, because effectively as we're doing this, we are sort of auditing our code base and finding stuff that we did wrong early on, or finding stuff that we could do better now, that we know more, things like that. So finding unused code, adding documentation we're missing, finding things we could rewrite in a better way, adding tests that we're missing. We were a startup for a large chunk of this product's lifetime, so now that we're bigger and we have the time and the capacity to do things right, we want to do that, as we go.
So how do we execute this? First of all, there are some easy parts that we can do. We delay migrating that 20% of components that we want to drop eventually, but this is not a hard rule. We have those islands of things to convert that we want to switch over, so if there's a component there that will allow us to mark one big chunk of the application as migrated, then we can spend that hour or two hours to migrate that one component. These are always an option. And then the other part is, if we look at our codebase, we have about 100 components that look like this example here that I'm showing. Basically, the component has no logic, it's just a template, just CSS, and all it does is exports an empty class, effectively. We replaced that with default export that just exports an object with a name, and technically we could replace this with just a blank script block, or even omit the script block entirely, but because of some specific issue, we have to export this name, and I'll explain that in a few slides. So the next part of the execution is the actual component conversions, and here, really, is just a map of what is a thing in class components, and what it becomes in a composition API. So there isn't really much to speak of here. There's a bit more complexity when we add VuexClass decorators, but still, we're just mapping things to computers, functions, or refs, basically, or reactive objects. So yeah, none of that is too difficult. This is not like highly skilled labor, or anything like that. This is just grunt work, and the important part we need to take out of it, and that we're trying to take out of it, is that collection of knowledge, and disseminating that knowledge across the engineering team, and just helping each other get better at it.
4. Moving from Vuex to Pinea
A big part of the execution is moving from Vuex to Pinea. We define a Pinea store that is a proxy to Vuex. Gradually switch from direct usage of Vuex to the Pinea store, component by component. The Pinea store starts out fully typed, provides better documentation, and allows fragmentation into smaller stores.
A big part of the execution is moving from Vuex to Pinea, and this is something I especially like. This approach we built up, it's a way to have a Pinea store that is a proxy to Vuex. What we do here is we define a store, as you would any other Pinea store, but instead of using refs and reactive, and all that, and getters, and actions to define internal state of the store, we instead define computed, that proxy to Vuex state or Vuex getters, and we define actions that proxy to store.dispatch or store.commit, and then those get returned by the Pinea store composable.
From the outside, the effect of this is that from the outside, the Pinea store looks like any other Pinea store, but internally, it's using Vuex. This then allows us to gradually switch from direct usage of Vuex for these things to usage of that Pinea store, component by component, one step at a time, in separate pull requests, separate chain sets, all that. And then once we find that the Vuex part has no more direct use and everything is done through the Pinea store, we can remove the Vuex part and move fully to Pinea by actually declaring those refs, and those reactive objects, and those actions within the Pinea store. And this is, again, one single step, changing just one file and then removing some code from some other files, and that's it.
As a benefit of this, there are some extra benefits. One is that the Pinea store starts out fully typed, so the interface is fully typed, unlike Vuex which relies on dispatch and commit, and the only way to actually type it is to redeclare the types locally. We get better documentation because we're actually writing it now, and those old modules probably aren't that well documented, and it's all in one place. And then we can also fragment into smaller Pinea stores, like there's no rule that it has to be one Vuex module to one Pinea store. We can break apart that module into multiple stores or we can pick different things from different Vuex modules into a single Pinea store, if that makes sense. And all this is extremely low-risk.
5. Vite Configuration and Migration Challenges
We encountered some challenges when executing the migration, such as compatibility issues between Webpack and Vite, the use of CommonJS modules, and custom loaders in our Vue-CLI config. However, we were able to overcome these challenges by using defined plugins, replacing CommonJS modules, and rewriting custom loaders in beat syntax. We are currently using beat as a parallel configuration for local development, but not for deployment builds. Additionally, we faced issues with Vue testutils v1 and script setup components, which we resolved by adding a second script block without the setup attribute.
And then, another part of the execution is Vite. So way early, before we even started this migration, we did try to set up a Vite config to run our app in Vite rather than the Webpack config that Vue CLI provides. We weren't able because of class components, and again, this might be possible in a few weeks or something. It might even be possible already, I haven't checked recently. But, back then, it wasn't. So we decided we would go with the migration first.
Now that we have a few of those migrated islands of Composition API, we actually can set up a Vite config that will run. And when we are developing on those islands, so developing new features, we can use that Vite config in local development. That makes it faster, more performance, with better performance, and just nicer to use. And then, if we have this config, we can also use it to gradually shift to Vitest, we can for Storybook, we can use it for the preview JS VS Code extension, things like that.
There are some blockers there that we had to identify and fix, but there aren't that many and it wasn't that hard. First one, most obvious one, is that Webpack relies on process.env for environment variables, while Vite relies on import.meta.env, and this can be made very easily compatible by using a defined plugin in Webpack or in Vite. So basically we, in Webpack, use the defined plugin to remap process.env to import.meta.env, and then the code base actually just accesses import.meta.env everywhere, and this then works both in the Vue-CLI setup and in the Vite setup. I would recommend this approach and not the other way around, just because then when we drop Vue-CLI, we just have to delete that config and not have to change anything in the code base.
The other part that we had a problem with is that we were still using some CommonJS modules in some places, very rare, mostly legacy code or code that we have taken from somewhere else. And this wasn't really that hard to replace, but there was some weirdness with importing images dynamically or things like that. Still, it was relatively straightforward to deal with. And then we also have a couple of custom loaders in our Vue-CLI config. For example, we have a special way to inline SVG files as Vue components. So for that, we would have to either eliminate use of those loaders, so not do that stuff, or rewrite them in beat, within the beat syntax. And we decided to go with this second option because it really wasn't that hard. It took us maybe two or three hours to reimplement our loaders in beat. From out of all that, I can say that we are happily using beat as a parallel configuration for local development. But we're not using it for deployment builds yet.
Now, of course, we did encounter some problems executing all this. The most notable one probably is that Vue testutils v1, which is the one that is compatible with Vue 2.7, has some issues with script setup. It had some that got fixed, but the one that is still active for us is that if you have a script setup component, and you write a test for a component that is the parent of that component, and you have a snapshot in that test, the child component that is script setup will render as just anonymous stop. So it won't have a name in the snapshot. This is because, apparently, Vue TestUtils v1 is not able to infer the name from the file name for script setup component. So we deal with this by adding a second script block into every script setup component, which is a normal script block without the setup attribute.
6. Component Migration and Test Improvements
And that one just exports the name of the component. It creates an issue with a plugin we use to sort imports in our files. We're waiting on a fix for that plugin. Another problem is in the old classic Vue, we rely on plugins and extend the Vue base instance with things like Store, Router, Route, etc. To continue using these, we need composables. We started using JustMock to mock the UseStoreComposable directly. We fully switched to PascalCase in our templates. We gradually switched from Wrapper.find to Wrapper.findComponent. We prefer using just mock or injecting mocks like store or router into the shallow mount call. We're finding and eliminating view-to-only libraries.
And that one just exports the name of the component. And this is the reason why this is how we migrated those basic components a few slides back. So this then makes the snapshots work again. But it creates an issue with a plugin we use, which is a prettier plugin we use to sort imports in our files. When a Vue file has two script blocks, the plugin is unable to sort imports correctly. So that means probably a couple dozen of our Vue files don't have their imports sorted, and we're waiting on a fix for that plugin. It's not a major deal. It would be nice to have it all work, but in a few weeks it probably will and it will do a prettier fix all and that's it.
There are a few more problems we have to figure out. One is in the old classic Vue, we rely on plugins, and we extend the Vue base instance with things like Store, Router, Route, and a few other things that we use, but these are the most common ones. And for that, to be able to continue to use these, we need composables, because in setup we don't really access this. To get a composable out of something like this, we can rely on the GetCurrentInstance function, which you import from Vue, and it's extremely straightforward. You just call GetCurrentInstance and then return the proxy store from that instance. Now, that internally means that a component needs to be mounted for this to be defined and to be returned. That means the same case also has to be in test. So, for example, if you have a composable that internally uses store, or like calls UseStore, then that composable cannot be tested directly. It needs to be mounted in a component to be tested. Alternatively, we just use JustMock to mock the UseStoreComposable directly, and then this whole thing doesn't happen. We just return something via the mock, and then we can test the other composable more directly. This is what we started to do, just because we find it makes the tests a bit more unity, and the setup is easier, and it's pretty straightforward.
There are some other stuff that we started doing. As I said, as we were doing this, we were looking for things in the codebase that we could do as well. A few things that we did is, we fully switched to PascalCase. We didn't really have a rule whether we were using Pascal or KebapCase in our templates. Now, we are fully using PascalCase in templates, with a rule. We started gradually switching from Wrapper.find to Wrapper.findComponent, because it makes the tests a bit less brittle, and a bit more structured. We started preferring the use of just mock, or injecting mocks like store, or router into the shallow mount call. This makes the tests, it sounds counter-intuitive, but a bit more framework agnostic, because switching to BTest is as easy as doing a find-and-replace in this case. Again, the tests are more unity, like more unit tests, less integration, and they tend to run a bit faster because of it. Some other software we're doing, is that we're finding and eliminating libraries that are view-to-only.
7. Results, Tips, and Recommendations
We replaced libraries with view-use composable. Vue.tsc library improved type checking and made tests faster. We migrated 400 components, eliminated Vue.x modules, and improved build time. The code base is safer and unaffected product work. PINIAproxies and 2REF are recommended.
Mainly, we're doing this by replacing those libraries with some composable provided by the view-use library, or set of libraries. That whole thing, that whole library set is amazing for us, and it's really helping us a lot with switching over.
We also started using the Vue.tsc library for type checking. This is the type checker that Volar uses, the extension for VSCode, and it does a much better job at checking view files than the plain TypeScript checker. It allows us in addition to this to use isolated mode modules option, set it to true in Jest, which makes our tests an order of magnitude faster. This is at the cost of slightly less type safety in tests, but because the Vue.tsc check runs in our CI, this is really not an issue for us, and it actually makes us safer overall with the benefit of tests running three or four times faster.
So what are the results of all this? We have about 400 components remaining to migrate. Again, we started off at 1,000 minus 20% of V1 components, which are still there. So effectively we are at around from 800 to 200 now. We have eliminated a couple of Vue.x modules fully. We have several PNA processes in place, so we're on the path to eliminate more Vue.x modules as we go. Our tests, because of some of the changes, run four times as fast, if not more. The whole Test Suite runs it in about two and a half minutes locally in single-threaded mode. Our build time is about three times as fast because we were able to cut out a few things from there. There's less code, because the Composition API is less boilerplate, and we were able to remove some of the code. There's less bugs overall, we find. The whole code base is a bit safer. The product work is effectively unaffected. On average, I would bet that it's faster. We didn't really do any measurements. It's hard to measure something like that. We never really delayed product work for anything, and any new feature is being worked on on a more modern core base. I can't imagine it's getting slower. At minimum, it's at the same rate.
Just as one of the last slides, what we've learned is basically a tip of the day collection of things. Just a couple of things. PINIAproxies, the process I've described, are really great for gradually initiating UX. I really would recommend it. 2REF is an awesome function if you want to pass a prop or a key on a reactive object into a composable as a reactive thing. That allows you to pass a single prop rather than passing all of the props into a composable because the composable really needs just that single prop.
8. Composables, Assertions, and Future Steps
You don't need to reuse composables to justify creating them. It's clear that separate behaviors can be organized within a component using smaller composables. Assertions in tests are more useful than snapshot tests as they detect bugs. The view-use set of libraries covers most of our needs. Our estimates include completing components in late summer, moving to PiNeo shortly after, a full move to Vite and Vue 3 in fall, and eventually moving fully to Vitest due to the volume of tests.
You don't really need to reuse composables to justify creating them. So you can have a larger component that has several different behaviors implemented. One can argue that should be separated into smaller components. But one could also argue you could organize those different things within the component into different smaller composables. Even though they're used just once, it makes it clear that those are several separate things. It provides a bit of self-documentation. And it allows you to maybe test those behaviors in isolation, which is always great.
The other thing that we found for us, and this is probably for us, maybe for some if you are, but definitely works for us. Assertions in tests are way more useful than snapshot tests. We found a lot of snapshot tests in our legacy code base that really show that they do absolutely nothing. So, they don't even render the thing we would expect them to render, because the setup for the test wasn't as it should have been. And this was in the code base, past review, we didn't really pay attention to it. So, the test was doing nothing for us. Instead, if we are replacing those snapshot tests with actual explicit assertions on the things we are actually interested in, those work much better for us and actually detect bugs.
And then the last tip or what we learned is, again, view use, that set of libraries is amazing, it's covering 90% of, well, not your, but probably our needs, maybe yours. So, as a final slide, our estimates, where we expect to be. So, we expect to be done with components late summer. People will be taking time off, all of that. It's going to probably slow down towards the end. We expect to be done with PiNeo a short time after because we're doing it in parallel, to a degree. We would like to do a full move to Vite. So, everything, app, storybook in early fall and then a full move to Vue 3 in mid-fall because right now we are converting the composition API, right? Once we are done with all of that, there's still going to be some steps involved to actually switch to Vue 3. And then the last part that we will probably end up doing is moving fully to Vitest, simply because of the volumes of tests we have. And yeah, that's it. Thank you.