Building Web apps with VueJS and dotNet

Introduction

In the last few months if been on the look out for JS frameworks that I can integrate easily in my MVC projects. One complaint with a lot of frameworks out there is that good integration with the Microsoft stack requires a total buy in to that framework. Once something like React, Angular or Ember is working with .net you have to jump through some hoops, install adapter modules, rewrite routing logic for all controllers. This ends up affecting the whole web app when in the beginning you'd want it to work side by side with your already working stack. If you are starting a fresh project, it is viable to use WebAPI for the backend, which provides a tight REST api for the JS framework of your choice, but for existing projects running on MVC this is not an option.

After a bit of research I came across VueJS and after some experimentation I was able to make it work in tandem with MVC. VueJS is a relatively lightweight framework so I am able to add it to the views where I need the extra JS oomph while leaving the rest of the web app untouched. Now that I'm running vue on production I am happy to share some of the patterns, for other pilgrims that share my problem :)

My workflow borrows from Migel's Castro^1 concept of App Silos where he used it to integrate AngularJS with MVC.

Getting Started

{
"version": "1.0.0",
"Name": "ASP.NET",
"private": true,
"devDependencies": {
},
"dependencies": {
"vue": "^1.0.26"
}
}

Now for the first example let get vue working in one of our views.

@{
ViewBag.Title = "Home Page";
}
@Scripts.Render("~/node_modules/vue/dist/vue.min.js")
@{
ViewBag.Title = "Home Page";
}
<div id="app">

</div>
@Scripts.Render("~/node_modules/vue/dist/vue.min.js")

<script>
const v = new Vue({
el: '#app',
data: {
message: 'Hello Vue.js!'
}
})
</script>

Success!!

Obviously just placing all your JS in your view is a guaranteed path for headaches, pain and misery So now let's organize our code.

Organizing VueJS within an MVC project.

Instead of having one big vue application that drives the whole website, here I'm splitting the web app into silos. For every controller in the website I have a corresponding vue application that is made up of multiple vue components. This one-to-one mapping between and a controller and a vue app helps readability and makes it easier to navigate through the code since there ia a clear separation. This keeps the models within a sensible size and every silo of the web app will contain only the js libraries that are needed rather than one massive bundle.

JS packaging has improved over the years. Using browserify we can use require statements in our client js. This lets use reference only the libraries that we need and browserify takes care of bundling the js required in one bundle, which is a noticeable improvement over having a long list of static references in the layout file. So now if we assume every controller is an app silo, each silo will contain one or more JS files, within these files we have a JS entry file that defines our vue app and a number of require statements. These files are then bundled and placed in the script folder of our website, which are then downloaded and used by the browser.

I usually follow the following structure; All my vue code is stored inside a ViewModel Folder. For every controller that is using vue, I have a corresponding folder inside the view model folder, and the I call the entry js file for that app silo main.js. Then I use gulp and browserify to bundle the js into a working unit that is stored inside the Script folder of the project. The views reference the bundle, so when a browser requests the page the bundle is downloaded and run.

Let put this into practice.

  "devDependencies": {
"browserify": "^13.0.0",
"watchify": "^3.7.0",
"gulp": "^3.9.1",
"gulp-util": "^3.0.7",
"gulp-babel": "^6.1.2",
"gulp-uglify": "^2.0.0",
"gulp-sourcemaps": "^1.6.0",
"fs-path": "^0.0.22",
"vinyl-source-stream": "^1.1.0",
"vinyl-buffer": "^1.0.0",
"babel-preset-es2015": "^6.13.2"
}
const gulp = require('gulp');
const gutil = require('gulp-util');
var babel = require('gulp-babel');
var minify = require('gulp-uglify');
var sourcemaps = require('gulp-sourcemaps');
const fs = require('fs');
const path = require('path');
const browserify = require('browserify');
const watchify = require('watchify');
const fsPath = require('fs-path');

var source = require('vinyl-source-stream');
var buffer = require('vinyl-buffer');
var es2015 = require('babel-preset-es2015');

function getFolders(dir) {
return fs.readdirSync(dir)
.filter(function (file) {
return fs.statSync(path.join(dir, file)).isDirectory();
});
}

const paths = [
process.env.INIT_CWD + '\\ViewModels\\home',
process.env.INIT_CWD + '\\ViewModels\\home\\components',
process.env.INIT_CWD + '\\ViewModels\\common\\components'
];

function watchFolder(input, output) {
var b = browserify({
entries: [input],
cache: {},
packageCache: {},
plugin: [watchify],
basedir: process.env.INIT_CWD,
paths: paths
});

function bundle() {
b.bundle()
.pipe(source('bundle.js'))
.pipe(buffer())
.pipe(sourcemaps.init({ loadMaps: true }))
//.pipe(babel({ compact: false, presets: ['es2015'] }))
// Add transformation tasks to the pipeline here.
//.pipe(minify())
// .on('error', gutil.log)
.pipe(sourcemaps.write('./'))
.pipe(gulp.dest(output));

gutil.log("Bundle rebuilt!");
}
b.on('update', bundle);
bundle();
}

function compileJS(input, output) {
// set up the browserify instance on a task basis
var b = browserify({
debug: true,
entries: [input],
basedir: process.env.INIT_CWD,
paths: paths
});

return b.bundle()
.pipe(source('bundle.js'))
.pipe(buffer())
.pipe(sourcemaps.init({ loadMaps: true }))
.pipe(babel({ compact: false, presets: ['es2015'] }))
// Add transformation tasks to the pipeline here.
.pipe(minify())
.on('error', gutil.log)
//.pipe(sourcemaps.write('./'))
.pipe(gulp.dest(output));
}

const scriptsPath = 'ViewModels';

gulp.task('build', function () {
var folders = getFolders(scriptsPath);
gutil.log(folders);
folders.map(function (folder) {
compileJS(scriptsPath + "//" + folder + "//main.js", "Scripts//app//" + folder);
});
});

gulp.task('default', function () {
var folders = getFolders(scriptsPath);
gutil.log(folders);
folders.map(function (folder) {
watchFolder(scriptsPath + "//" + folder + "//main.js", "Scripts//app//" + folder);
});

});
Now back to code
const Vue = require('vue');
const v = new Vue({
el: '#app',
data: {
message: 'Hello Vue.js!'
}
});
@{
ViewBag.Title = "Home Page";
}
<div id="app">
{ { message } }
</div>
@Scripts.Render("~/Scripts/app/home/bundle.js")
@{
var controllerName = HttpContext.Current.Request.RequestContext.RouteData.Values["Controller"].ToString();
}
@Scripts.Render("~/Scripts/app/" + controllerName + "/bundle.js")

Loading server data

With vue up and running we can retrieve and render data coming from the server.

public JsonResult GetData()
{
return Json(new
{
Name = "Marco",
Surname = "Muscat",
Description = "Vue data loaded from razor!"
},JsonRequestBehavior.AllowGet);
}
const Vue = require("vue");
const $ = require("jquery");

const v = new Vue({
el: '#app',
ready: function () {
this.loadData();
},
data: {
message: 'Hello Vue.js!',
serverData: null
},
methods: {
loadData: function (viewerUserId, posterUserId) {
const that = this;

$.ajax({
contentType: "application/json",
dataType: "json",
url: window.ServerUrl + "/Home/GetData",
method: "GET",
success: function (response) {
console.log(response);
that.$data.serverData = response;
},
error: function () {
console.log("Oops");
}
});
}
}
})
@{
var controllerName = HttpContext.Current.Request.RequestContext.RouteData.Values["Controller"].ToString();
var serverUrl = string.Format("{0}://{1}{2}", Request.Url.Scheme, Request.Url.Authority, Url.Content("~"));
var controllerUrl = Url.Content("~") + controllerName;
}
<script>
window.ServerUrl = '@serverUrl';
window.VueRouterUrl = '@controllerUrl';
</script>
@Scripts.Render("~/Scripts/app/" + controllerName + "/bundle.js")
<div id="app">
{ { message } }
<br/>
<span>coming straight from mvc! { {serverData.Name} } { {serverData.Surname} }. { {serverData.Description} }</span>
</div>

With the examples so far it should be enough to get startet with vue.js and mvc, but Like any other frontend framework, vue suffers from a delayed load. When a page is requested, the javascript needs to be downloaded and then loaded. Then the framework in question will make more server requests for data. When this completes then databinding is done with the view. To mitigate this we have to resort to loading animations and other hacks, but since we're also using MVC we can do better, and speed up this loading process, by leveraging the power of razor and download the data for the view together with the rest of the page.

Preloading data

Many JS frameworks are now working on different implementations of server prerendering, but with the .net stack we can fudge an alternative solution.

Let's build on top of the previous example, and instead of getting the data when vue is ready, we'll download it with the rest of the page.

public ActionResult Index()
{
var serverModel = JsonConvert.SerializeObject(new
{
Name = "Marco",
Surname = "Muscat",
Description = "Vue data loaded from razor!"
});

return View(new SampleModel()
{
Data = serverModel
});
}

public class SampleModel
{
public string Name { get; set; }
public string Surname { get; set; }
public string Description { get; set; }
public string Data { get; set; }
}
<script> window.preLoadeddata = JSON.parse('@Html.Raw(Model.Data)')</script>
const Vue = require("vue");
const $ = require("jquery");

const v = new Vue({
el: '#app',
ready: function () {

},
data: {
message: 'Hello Vue.js!',
serverData: window.preLoadeddata
},
methods: {
}
})

Routing within a Silo

An app silo is not complete without client routing support. More complex apps need multiple views as there is too much information for one page. This is when combining vue with mvc really shines, as we can load all the related views in vue while staying in the mvc page, thus avoiding a full page reload.

const Vue = require("vue");
const VueRouter = require("vue-router");
Vue.use(VueRouter);

var Foo = Vue.extend({
template: '<p>This is foo!</p>'
});

var Bar = Vue.extend({
template: '<p>This is bar!</p>'
});

var App = Vue.extend({});

var router = new VueRouter({
history: true,
root: "/vue-example/vuerouting"
});

router.map({
'/foo': {
component: Foo
},
'/bar': {
component: Bar
}
});

router.start(App, '#app');
<div id="app">
<h1>Hello App!</h1>
<p>
<!-- use v-link directive for navigation. -->
<a v-link="{ path: '/foo' }">Go to Foo</a>
<a v-link="{ path: '/bar' }">Go to Bar</a>
</p>
<!-- route outlet -->
<router-view></router-view>
</div>

This is great but it not fully functional yet. If you copy the url with the vue route part http://localhost/vue-example/vuerouting/bar and paste it in a new window, asp.net will throw a 404 error because the server is not able to find an action for that route. We need to setup the server routing to ignore the vue part of the route.

routes.MapRoute(
name: "Default",
url: "{controller}/{action}/{id}/",
defaults: new {controller = "Home", action = "Index", id = UrlParameter.Optional},
constraints:new { controller = "Home"}
);


routes.MapRoute(
name: "Silo Controller",
url: "{controller}/{*.}",
defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional },
constraints: new { controller = "examplevuerouter|ExampleSeedingRazor" }
);
var router = new VueRouter({
history: true,
root: "/vue-example/vuerouting"
});

with

var router = new VueRouter({
history: true,
root: window.VueRouterUrl
});

and routing will take care of itself regardless of the server url!

I hope you find this write up useful in your next MVC project. One aspect that is missing in this article is how to organize vue components in a reusable manner, which I'll discuss in the future. If you have any question, comments or suggestions for more improvements please share in the comments below!

All the examples above together in addition to another project that splits these ideas into different examples are on github{:target="_blank"}.

[^3]: The gulp file went through a few iterations which I will not go through in this article but basically this should just be drop and go.