The trials and tribulations of kicking off an AngularJS -> Vue.js migration
AngularJS was pretty groundbreaking. It’s still impressive to this day, packed with a router, an HTTP client, a dependency injection system and a bunch of other things I haven’t necessarily had the pleasure of dealing with. It also wraps most browser APIs as injectable services, that’s pretty cool. The downside is that it’s complicated: services, filters, injectors, directives, controllers, apps, components.
With the tools we have today, AngularJS isn’t as strong a way to do things any more. The fact that it’s in maintenance mode says it all. For a new application or new features, ie. for a project that not in maintenance mode, it’s doesn’t have the same niceties as other frameworks and libraries like Angular 2+, React or Vue. There should be a way not to rewrite the whole AngularJS application at once, here's how I went about doing it:
Subscribe to get the latest posts right in your inbox (before anyone else).
Shipping some bundles 📦
Bundling the AngularJS app allows you to send a few files with everything needed to run the single page application.
Includes using script
are reduced to a few bundles of JavaScript (depending on having eg. a vendor bundle) and possibly a few of CSS.
Modernising the codebase using ES6 and beyond becomes possible with a transpilation step, and some bundlers even allow for loading of non-JS files into the JavaScript bundle which means templates and even assets can be sent down in the same payload.
Loading of JavaScript functionality not tied to browser APIs in a test environment using Node (+ JSDOM) becomes possible,
giving you the ability to leverage tools such as Jest, AVA or Mocha (instead of Jasmine + Karma or even Protractor).
This means the controllers look more like the following:
const angular = require('angular');
function homeController(
$location,
otherService
) {
const ctrl = this;
// attach methods to ctrl
return ctrl;
}
angular.module('myApp')
.controller('HomeController', [
'$location',
'otherService',
homeController
]);
module.exports = {
homeController
};
The above snippet leverages CommonJS which is Node’s default module system, its hallmarks are the use of require()
and module.exports =
.
To bundle the application, Webpack allows us to take the AngularJS codebase that leverages CommonJS and output a few application bundles.
Templates can be require
-ed using the right webpack loaders (html-loader
). SCSS stylesheets and even Handlebar templates can also be compiled.
Why CommonJS instead of ES modules?
ES modules are the module format defined in the ECMAScript spec. They look like the following:
import angular from 'angular'
export function homeController() {}
The issue with ES modules are that they are static imports, ideally they shouldn’t have side-effects.
Including something that does angular.module('some-name')
seems pretty side-effectful, so CommonJS reflects this a bit more: require('./this-script-that-adds-something-to-angular')
.
Adding Vue 🖼️
This part was surprisingly straightforward, to add Vue components to an AngularJS app ngVue is available (https://github.com/ngVue/ngVue).
ngVue
exposes functionality to wrap Vue components as an AngularJS directives.
The checklist goes like this:
-
npm i --save ngVue vue vue-loader
(vue-loader is to load/compile.vue
single file components) - add
ngVue
to the bundle: haverequire('ngVue')
somewhere - Register
ngVue
with AngularJSangular.module('myApp', ['ngVue'])
- Create a Vue component that is registered on the global Vue instance as a component
const myComponent = {
template: '<div>My Component</div>'
};
const MyVueComponent = Vue.component(
'my-component',
MyComponent
);
- Register the component as an AngularJS directive
angular
.module('myApp')
.directive('myComponent', [
'createVueComponent' ,
createVueComponent => createVueComponent(MyVueComponent)
]);
- In an AngularJS template you can now use:
<my-component v-props-my-data="ctrl.myData"></my-component>
(vprops-*
allows you to pass data and functions from AngularJS to Vue as props)
Full snippet that leverages webpack to load a single file component:
const angular = require('angular');
const { default: Vue } = require('vue');
const { default: MyComponent } = require('./my-component.vue');
const MyVueComponent = Vue.component('my-component', MyComponent)
angular
.module('myApp')
.directive('myComponent', [
'createVueComponent' ,
createVueComponent => createVueComponent(MyVueComponent)
]);
In order to load single file components like in the above example, vue-loader
is required (see https://github.com/vuejs/vue-loader),
depending on how webpack is set up in a project, it can also affect how you process CSS (since single file components contain CSS as well as JavaScript and templates).
Setting up Jest 🔧
Mock assets
.html
, .scss
, .svg
needs to be dummied in your Jest config:
{
"testRegex": ".*spec.js$",
"moduleFileExtensions": [
"js",
"vue"
],
"moduleNameMapper": {
"\\.(html)$": "<rootDir>/src/mocks/template-mock.js"
},
"transform": {
".*\\.js$": "<rootDir>/node_modules/babel-jest",
".*\\.(vue)$": "<rootDir>/node_modules/vue-jest"
},
"collectCoverageFrom": [
"src/**/*.{js,vue}"
]
}
CommonJS, Webpack and vue-jest
woes
Webpack doesn’t care about CommonJS vs ESM, for all intents and purposes, Webpack treats them as the same thing. Here’s the catch: the recommended Jest plugin for Vue (vue-jest
) handles require
vs import
of .vue
files differently to Webpack.
This is some sample code in a Vue component that imports another Vue single file component in CommonJS:
const MyOtherComponent = require('./my-other-component.vue').default;
export.default = {
components: {
MyOtherComponent
}
};
The issue is the following:
- For the Webpack build to work, you need to use
const MyComponent = require('./my-component.vue').default
orimport MyComponent from './my-component.vue'
. - For tests to pass, you need to do
const MyComponent = require('./my-component.vue')
or useimport
and transpile the modules using Babel - AngularJS controllers love
this
… transpiling ES modules through Babel breaksthis
somehow
Some solutions 🤔
Use ES6 import/export for the Vue components and tests, add a specific extension (
.mjs
,.module.js
), disablebabel-jest
on CommonJS files.
Drawback: Coverage breaks due to the following issue (that is fixed now): https://github.com/istanbuljs/babel-plugin-istanbul/pull/141Monkey-patch using Jest inside your test:
jest.setMock('./my-component.vue', { default: MyComponent });
.
Drawback: this is not a real fix, it makes the developer have to think about Vue vs bundled JavaScript vs JavaScript in test, which should appear the same in most situations.Rewrite the transformed code, using a custom pre-processor, so it behaves the same under Webpack and
vue-jest
.
Fixing vue-jest
/Webpack CommonJS handling with a Jest preprocessor
The following preprocessor takes require('./relative-path').default
and converts it to require('./relative-path')
(which is what Webpack seems to do under the hood).
To use the following preprocessor, replace the .vue
-matching line in "transform"
of Jest config with ".*\\.(vue)$": "<rootDir>/vue-preprocessor"
.
Here is the full code for the preprocessor, walkthrough of the code/approach follows:
// vue-preprocessor.js
const vueJest = require('vue-jest');
const requireNonVendorDefaultRegex = /(require)\('\..*'\).default/g;
const rewriteNonVendorRequireDefault = code =>
code.replace(requireNonVendorDefaultRegex, match =>
match.replace('.default', '')
);
module.exports = {
process (src, filename, config, transformOptions) {
const { code: rawCode, map } = vueJest.process(
src,
filename,
config,
transformOptions
);
const code = rewriteNonVendorRequireDefault(rawCode);
return {
code,
map
};
}
};
At a high level, we process the code through vue-jest
and then rewrite require('./relative-path').default
to require('./relative-path')
.
This is done with the following:
-
/(require)\('\..*'\).default/g
matches anyrequire
where the string arg starts with.
ie it will match localrequire('./something-here')
but notrequire
of node modules (eg.required('vue')
). A caveat is that this RegEx only works for single-quote requires… but that’s trivial to fix if the code uses double quotes. -
String.replace
with a function argument is leveraged to run a custom replace on each match of the previous RegEx. That’s done withmatch.replace('.default', '')
.
Thoughts on running Vue inside AngularJS 🏃
AngularJS is from a time before bundlers and a JavaScript module system.
The only contemporary bundling tool to AngularJS target JavaScript applications is the Google Closure Compiler.
For reference Browserify was released in 2011, webpack in 2012. AngularJS’ initial release was in 2010.
That’s why we ended up with things like script
includes for each controller and each template (script type="ng-template"
).
Each script
will call angular.module('app').{controller, directive, service}
and each of those calls will register something on the global angular
instance and can then be consumed elsewhere.
This is brittle since code that should be co-located gets spread across the codebase and gets referenced with strings like 'HomeController'
.
All it takes is 1 typo and we've got a bug that won’t be detected until we get the app in a certain state…
With Vue.js, Webpack and Jest, we can bundle, test and build with more confidence.
AngularJS was and still is great. What’s also great that we can migrate off it progressively, thanks to the ngVue
team.
That means we get to keep the solid AngularJS working alongside new features written in Vue.
Subscribe to get the latest posts right in your inbox (before anyone else).
Cover photo by Justyn Warner on Unsplash