Building Large Projects With Vue, Vite, and Lerna
See how Vite simplifies Vue projects.
Join the DZone community and get the full member experience.Join For Free
Today we’d like to present a blueprint for large Vue JS projects. It uses the new and exciting Vite build tool and Lerna monorepo manager. I’ve built large enterprise projects in a similar way, using Angular, Vue JS, webpack, and rollup. Vite, created by the Vue JS team, looks very promising so I wanted to give it a try.
There are plenty of Vite tutorials and demos, mostly the usual
Hello World and
Todo apps. But I needed something more useful. I wanted to see whether Vite can replace rollup and webpack in large real-life projects.
Source code of the example project can be found at https://bitbucket.org/letsdebugit/vite-monorepo-example.
I’ve built a blueprint for a modular Vue JS application, which can serve as a starting point for an actual project. Something along these lines:
The project is organized into the following packages:
common- packages with shared code, such as entity model, reusable services, and utilities.
applets- functional parts of the application, such as
inventory. Each functional part is made of two packages:
apifor API endpoints, and
uifor UI views and components.
application/server- where all API parts come together and are bundled into the back-end, running on NodeJS
application/client- where all UI parts come together and are bundled into the front-end, running in the browser
I’ve organized the project along vertical business modules. Each module represents distinct business functionality. This is quite unlike traditional layered monolith architecture. Such architectures organize code by its deployment location. There is a back-end layer, middleware layer, client layer, etc.
I prefer when all code related to the same business functionality is kept together. When it’s in separate layers, it is much harder to maintain it. The logical architecture of a system does not have to be dictated by physical deployment.
The main application packages are lightweight. They’re nothing more than glue code, bringing together the functional modules. As the project grows, we add more functional modules. But the general architecture remains the same! Codebase grows in size, but not in complexity. Internal dependencies are clear and easy to follow.
This blueprint is only the beginning. You can amend and extend it in many ways. It opens a path to a microservices architecture, when it becomes necessary. All heavy lifting is already done because UI and API are modular. It doesn’t take much effort to turn them into standalone micro-frontends and micro-backends.
Even if you don’t need microservices, which is usually the case, this architecture can be useful. I like to be able to develop, test, and run my functional modules standalone. Such an approach promotes transparent architecture. It helps me focus, and it speeds up daily development.
I hope that many readers will find this article useful. Feel free to contact me, if your organization or team is looking for expertise or advice. I can help kickstart and improve your project, whether it’s a proof-of-concept or enterprise application!
Vite is a new build tool from the creators of Vue JS framework. They have released the first version in April 2020. It immediately created a lot of buzz, while raising a question: do we need another build tool?
First, Vite is built around rollup, which until now has been my build tool of choice. Just like rollup, Vite is lean, mean, and easy to control, unlike certain alternatives. It is not the right time and place to elaborate on my troubled relationship with webpack. I will only say that traumatic experiences with it are still haunting me at nights ;-)
Needless to say, Vite inherits all the good features of rollup, such as:
- Simple configuration
- Great speed
- Tree-shaking - purging code which is never called
- Ability to use rollup plugins, which there are plenty
Now the killer feature.
During development, Vite doesn’t bundle the code at all! It knows that we no longer develop for Internet Explorer 11. During development, it imports and runs ES modules in the browser. This means an instant start. Often measured in milliseconds, regardless of how big your codebase is. This gives us efficient hot module reloading. HMR is ability to apply code changes as you type. No need for time-consuming rebuilding and reloading of the entire application. When working with a large project, it saves hours and days spent on mindless staring at the terminal.
Only when deploying, we run a full build - which is fast and efficient.
Excellent like it was, the first release had a major shortcoming. It couldn’t be used to compile libraries - packages used as dependencies by other packages and applications. I’ve managed to hack my way around it, but it wasn’t pretty. This meant no Vite for large projects or monorepos.
The good news - recently released Vite 2 officially supports building libraries. Now we can use it as a build engine in large projects as well, also for building server-side code.
This chapter is for readers unfamiliar with concept of monorepo. Others can proceed to the next chapter.
On the surface, splitting a system into separate packages makes it easier to manage. It also enables sharing code with other teams. But it comes at a heavy price. Creators of NPM didn’t provide for internal dependencies. What are the main problems?
- Each package requires its own git repo and npm bundle
- Every change to library packages requires
npm publish. Then every other package which uses it, requires
npm update. Then you repeat this process until you’ve updated the entire tree of dependencies. It is difficult and it costs a lot of time. If not done carefully, it leads to dependency mess and mysterious heisenbugs.
The recently announced NPM 7.0 Workspaces are only a very modest step in the right direction. Lerna and other monorepo tools make it all much easier. The most important features of Lerna are:
- Ability to run scripts across many packages, i.e.
- Ability to quickly add dependencies to many packages
- Ability to have internal dependencies without tedious
updatecycle. Instead, it resolves internal dependencies using symlinks. Any package can now import other packages straight from their source folders. Publishing to npm repository is no longer required.
With Lerna you get all benefits of a single code base together with the benefits of a modular project:
- One git repository contains the entire codebase
- Code is split into individual packages, each built and tested separately
- It is easy to import internal packages and reuse code. You publish to npm only when you need to share code with the outside world.
This is what monorepo is all about.
Initialize the project as follows:
This downloads the project into the
vite-monorepo-example folder. It then installs all dependencies. It prepares a Lerna monorepo and creates symlinks for internal dependencies. Then, it's ready for build:
You will see a rainbow of colorful messages, hopefully ending with this:
This was a production build. It creates
/dist folder in each package. You only need two of these to deploy and run the application:
- Back-end bundle found in
- Front-end bundle found in
You can run the back-end with NodeJS and serve the front-end with a web server. But for development time, it’s enough to open terminal and execute
to build and execute the back-end. It will be served at http://localhost:3333 and it looks like this:
To test the API, open the above URL in a browser or run
curl -s http://localhost:3333. You will see the following output (piped here through indispensable jq for better readability):
Open another terminal and execute
to start serving the front-end. It will be available at http://localhost/3000.
Open this URL in a browser and you should see:
The project is made of independent vertical modules, each containing a slice of business functionality. Our blueprint has two example modules: Customers and Inventory. Each module contains two packages:
ui. Build process combines all business modules into two deployables:
- back-end application which hosts an API
- front-end application that consumes the API.
Often you want to reuse code between packages. We enable this with shared packages found in
/packages/common/utilitiescontaining general-purpose functions, useful on both front-end and back-end. Be careful and don’t write here any code which runs only in a browser or only on NodeJS
/packages/common/modelcontaining entity model used by all packages. We use here ES classes. This gives us computed properties and rich internal logic. It helps avoid common antipattern: Anemic Domain Model, as discussed by Martin Fowler. Front-end and back-end now use the same rich entity model. It brings code reuse, consistent behaviour, and fewer bugs.
/packages/common/database, intended for use in back-end packages. It provides access to hypothetical database store. Here we simply use a bunch of JSON files with sample data.
/packages/common/ui, intended for use in front-end packages, contains shared services such as
APIClient, used to talk to the back-end in a consistent way. APIClient also maps raw JSON to entity classes. Here we simply use
window.fetch. In a real project, I’d go for something like Axios. This allows using the same API client in the back-end code.
We use the Fastify framework to build the API back-end. It’s a personal preference - you could use any other framework in a similar fashion.
API packages define endpoints relevant to the module’s business purpose. For example, the
/customers/api package exposes:
GET /customerendpoint for fetching all customers
GET /customer/:idendpoint for fetching a specific customer.
The package does not run these endpoints. It only exports API routes. It defines request schemas following the OpenAPI specification and implements route handlers.
Fastify framework validates incoming requests using these schemas. See for example
The server application is found in the
/application/server package. It imports routes from all functional modules and combines them into one back-end. You can see it in
Build generates a UMD bundle containing the entire server application. You can run it with NodeJS. Before that, you need to install all required third-party dependencies such as
fastify-swagger plugin to generate API documentation and test pages. The back-end serves API documentation at http://localhost:3333/documentation:
Front-end packages contain Vue components, views, and navigation routes leading to these views. For example, the
/customers/ui package contains the view
customers.vue. This view fetches customers from the API endpoint
GET /customer and displays them in a list. You can access this view using the
/customers route. The route is declared in
Front-end packages do not run their views. They only export UI routes associated with UI views. The build process combines all views and routes into the client application.
The client application is found in the
/application/client package. It imports UI routes from all functional modules and combines them into front-end application, as seen in
Build generates an application bundle, which you can host on any web server.
Vite requires a build configuration file
vite.config.js. It contains instructions on how to build a package. This becomes a daunting task, as a project grows in size. In large projects, you might end up with tens and hundreds of build configuration files. Changes to the build process can become difficult and time-consuming.
Truth is, all these configuration files are quite similar if not identical. There is no need to duplicate them. Instead, we’ve defined a set of typical configurations. Packages can now use them to populate their own configuration files. Now creating a vite.config.js file for another package becomes simple. For example, configuration for Customers API package, found in
packages/applets/inventory/api/vite.config.js looks like this:
We say here: Hey, we build here a package for server-side. Get us a default configuration for this type of package and we’re good.
You can customize the default configuration by passing options, as described in the Vite documentation. For example, to switch off minification, do this:
We have the following default configurations in
vite.common.js- configuration for common (isomorphic) packages, obtained with the
vite.server.js- configuration for server-side packages, obtained with the
vite.client.js- configuration for client-side packages, obtained with the
vite.application.js- configuration for front-end Vue applications, obtained with the
Default configuration files are actually very simple. Vite comes ready with reasonable defaults. There is little you need to add, to start building packages. For example, the configuration file for Vue packages looks like this:
All it does is tell Vite to:
- build the package into a library, starting with
vueplugin to compile
eslintto minify output, only because I prefer it
Easy as pie!
I did my best to keep this blueprint simple and pragmatic. I’ve omitted some details, which you will need in a real project. It is a starting point, and you can improve and extend in many ways.
It might all seem a bit complex to less experienced developers. But I hope that readers will have a close look and get some good ideas from it. This approach has helped in many large projects, which I have worked on. As the project grows in complexity and size, the benefits of this architecture become evident. It helps me find the way even in very large codebases, like the one I’m currently developing, with ca. 300,000 lines of code, and more to come.
I’m very happy to see that Vite fits well within this architecture. I have no doubts that I can use it as a replacement for webpack, rollup, or other build tools. Vite is blazing fast and easy to configure. I hope it becomes a helpful addition to your toolbelt! Let me know when your team needs help or advice with that!
- The article was originally published on my blog Let’s Debug It.
- Source code: https://bitbucket.org/letsdebugit/vite-monorepo-example. Feel free to clone and reuse this code. Any suggestions or questions are most welcome!
- Vite: https://vitejs.dev/
- Lerna: https://lerna.js.org/
Copyright 2021, Tomasz Waraksa
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Published at DZone with permission of Tomasz Waraksa. See the original article here.
Opinions expressed by DZone contributors are their own.