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:
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 ctrlreturn 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.
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')
.
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)ngVue
to the bundle: have require('ngVue')
somewherengVue
with AngularJS angular.module('myApp', ['ngVue'])
const myComponent = {template: '<div>My Component</div>'};const MyVueComponent = Vue.component('my-component',MyComponent);
angular.module('myApp').directive('myComponent', ['createVueComponent' ,createVueComponent => createVueComponent(MyVueComponent)]);
<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).
.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}"]}
vue-jest
woesWebpack 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:
const MyComponent = require('./my-component.vue').default
or import MyComponent from './my-component.vue'
.const MyComponent = require('./my-component.vue')
or use import
and transpile the modules using Babelthis
… transpiling ES modules through Babel breaks this
somehow.mjs
, .module.js
), disable babel-jest
on CommonJS files. Drawback: Coverage breaks due to the following issue (that is fixed now): https://github.com/istanbuljs/babel-plugin-istanbul/pull/141
Monkey-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.
vue-jest
.vue-jest
/Webpack CommonJS handling with a Jest preprocessorThe 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.jsconst 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 any require
where the string arg starts with .
ie it will match local require('./something-here')
but not require
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 with match.replace('.default', '')
.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.
Originally published at codewithhugo.com on May 30, 2018.