Monorepos with Ionic, Vue, and npm
This is part three of a new series on monorepos. By the end of the series, you’ll have the tools you need to adopt monorepo setups in your organization.
Rounding out our series on monorepos, we take a look at an old friend, but a newcomer to the monorepo game, npm. Npm has long been the de-facto solution for managing dependencies, and it only makes sense that, with the release of npm 7.0, we finally have a built-in solution for creating a monorepo without relying on external tools. Compared to other solutions, however, npm workspaces lack a few features and still have some rough edges. While it is possible to build something with it, for simplicity, I’d suggest looking at Lerna as an alternative. With that being said, let’s look at how we can configure an npm workspace to work with Ionic and Vue.
Scaffolding
To set the scene, what we’re going to build is an Ionic Vue app and a second project that contains a Vue hook. The hook is borrowed from the vue-composable project.
Let’s get started by first creating our base directory and initializing both a package.json
and an ionic.config.json
. For the package.json
, run:
mkdir vue-monorepo
cd vue-monorepo
npm init -y
From here, we can also create a base Ionic project with the ionic init
command.
ionic init --multi-app
We can also create a directory that will hold all the packages. For this, a directory called packages
will do, but the name can be whatever you’d like. packages
is just a common convention that people have settled around.
mkdir packages
cd packages
With this done, we’re going to create a single Ionic Vue project and a minimal utility package.
mkdir utils
ionic start client-app tabs --type vue --no-deps --no-git
Currently, even if you pass the
--no-deps
flag, dependencies will be installed when Capacitor is set up. Justcd client-app
and delete the node_modules folder from the project.
Setting up the Utils
For our utils
package, we’re going to do a bit more manual work to set up a minimal package of hooks for our Vue project.
cd packages/utils
npm init -y
mkdir src
touch tsconfig.json
Open your package.json
and paste the following:
{
"name": "@client/hooks",
"version": "0.1.0",
"private": true,
"main": "dist/index.js",
"module": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"watch": "tsc -p tsconfig.json --watch"
},
"dependencies": {
"vue": "^3.0.0"
},
"files": ["dist/"],
"devDependencies": {
"typescript": "~4.1.5"
}
}
Then, open your tsconfig.json
and paste the following:
{
"compilerOptions": {
"target": "ES5",
"outDir": "dist",
"module": "CommonJS",
"strict": true,
"importHelpers": true,
"moduleResolution": "node",
"skipLibCheck": true,
"esModuleInterop": false,
"declaration": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"lib": ["esnext", "dom", "dom.iterable"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
From here, we can make a file, src/index.ts
, and paste the following code.
/* eslint-disable */
import { ref, Ref } from 'vue';
// useOnline composable hook.
// Adapted from https://github.com/pikax/vue-composable
const PASSIVE_EV: AddEventListenerOptions = { passive: true };
let online: Ref<boolean> | undefined = undefined;
export function useOnline() {
const supported = 'onLine' in navigator;
if (!supported) {
online = ref(false);
}
if (!online) {
online = ref(navigator.onLine);
window.addEventListener(
'offline',
() => (online!.value = false),
PASSIVE_EV
);
window.addEventListener('online', () => (online!.value = true), PASSIVE_EV);
}
return { supported, online };
}
Now we can leave the utils
directory and get back to the root project.
Setting up the Workspace
With the initial code created, we can now set up the workspace. For npm, workspaces are just an entry in the root package.json
. Since all of our packages are in the packages
directory, we can add the following to the root package.json
.
{
"name": "ionic-vue-npm-workspaces",
"version": "1.0.0",
"description": "",
"scripts": {...},
"license": "MIT",
"workspaces": [
"packages/*"
]
}
The workspaces
entry allows us to declare what packages are available from this top level. Since we want to expose all packages in the packages
directory, we can use the packages/*
to get all of them.
With this completed, run npm install
from the top level. With our workspace set up to include all the sub-packages, our install will actually install all dependencies used in both projects in one top-level node_modules
directory. This means we can have better control over what dependencies we are using in which project and unifies all duplicated dependencies to one version.
With the dependencies installed, how do we go about actually building our sub-packages? This can be done by calling the script we want to run, followed by the --workspace=<package-name>
. If we want to build the utils
directory, we use the name entry from the package.json (@client/hooks
) as the value for the workspace. So our final command looks like this:
npm run build --workspace=@client/hooks
The same logic would be applied if we want to build/serve our app: we pick the script we want to run and pass the name to the workspace.
Including a Package
So far, we have our packages set up and building, but we’re not making use of them, which kind of defeats the point of having a monorepo. So how can we consume our utils packages in our main app? To do this, we’ll reference the package in our app.
In the client-app
project, let’s open our package.json
and add a line to our dependencies for @client/hooks
:
{
"dependencies": {
"@capacitor/core": "3.0.0-rc.1",
"@client/hooks": "0.1.0",
"@ionic/vue": "^5.4.0",
"@ionic/vue-router": "^5.4.0",
"core-js": "^3.6.5",
"vue": "^3.0.0-0",
"vue-router": "^4.0.0-0"
}
}
Then we can add a reference to @client/hooks
in our project in the client-app/src/views/Tab1.vue
component.
<template>
<ion-page>
<ion-header>
<ion-toolbar>
<ion-title>Tab 1</ion-title>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true">
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">Tab 1</ion-title>
</ion-toolbar>
</ion-header>
<h1>Is the App online?</h1>
<p>{{ online }}</p>
<ExploreContainer name="Tab 1 page" />
</ion-content>
</ion-page>
</template>
<script lang="ts">
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from '@ionic/vue';
import ExploreContainer from '@/components/ExploreContainer.vue';
import { useOnline } from '@client/hooks';
export default {
name: 'Tab1',
components: { ExploreContainer, IonHeader, IonToolbar, IonTitle, IonContent, IonPage },
setup() {
const { online } = useOnline();
return { online };
},
}
</script>
We can save and go back to the terminal, and from the root, run:
npm install
npm run serve --workspace=client-app
When we open the browser to localhost:8080
, our app should include the code from our second package.
Parting Thoughts
Of all of the options available, npm workspaces include the fewest features when compared to yarn/Lerna or nx. But that could be beneficial to you and your team if you want to have more control over how your monorepos work. This could be perfect for a team that likes to tinker with things, or wants to assemble their own monorepo infrastructure. Either way, it’s great to see npm enter the monorepo game, and we can’t wait to see how workspaces evolve over time.