Building Web apps with VueJS and dotNet
.net mvc asp.net VueJS appsilos webIntroduction #
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 #
- First off we need an mvc project, go ahead and create one, I called mine vue-example, its a standard MVC 5 project.
- We'll let npm handle all our packages so add a package.json file to the project
- Add VueJS as a dependency
{
"version": "1.0.0",
"Name": "ASP.NET",
"private": true,
"devDependencies": {
},
"dependencies": {
"vue": "^1.0.26"
}
}
- Save the file and Visual studio will trigger an npm install which will download the package for you
Now for the first example let get vue working in one of our views. #
- In the home default action (index.cshtml), I stripped out the default markup and referenced vuejs from the node-modules folder (Barbaric I know, but we'll describe further down how to set up a frontend build pipeline. Without manual references)
@{
ViewBag.Title = "Home Page";
}
@Scripts.Render("~/node_modules/vue/dist/vue.min.js")
- And let's get the sample vuejs hello world app from the official guide^2 and chuck it in the view.
@{
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>
- Save and build your project and open your page
- 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. #
- In our example project, I created a new folder view model, and within it a Home folder for our homeController.
- Inside this new folder create a main.js file.
- Now let's import a "few" dev dependencies to set up bundling[^3].
"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"
}
- Add a gulp file to your project and paste the following
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);
});
});
- The gulp script defines two tasks build and default. Build does bundling and minification when your project is ready to be deployed. Default is what you should run while developing. It uses watchify to monitor changes in your view model folder, so when coding start the task, when any file in the viewmodels folder is saved, the changed files will be rebundled. All you have to do is refresh your browser. NB Sometimes you'll need to restart VS and run npm install manually to get your tasks up and running
- I use the task runner in VS to run the tasks.
- Running default should create an app folder inside the Script folder.
Now back to code
- Move the hello world vue app to main.js
- With browserify up and running we can change js references to require statements.
- The main.js should look like this
const Vue = require('vue');
const v = new Vue({
el: '#app',
data: {
message: 'Hello Vue.js!'
}
});
- In index.cshtml change the script reference to the bundle inside Scripts/app
@{
ViewBag.Title = "Home Page";
}
<div id="app">
{ { message } }
</div>
@Scripts.Render("~/Scripts/app/home/bundle.js")
- Refresh the page and Boom! on the fly, bundled javascript!
- Individually referencing every bundle becomes tedious, especially when there are a lot of controllers, so I like to add a dynamic reference in the layout. If you plan to gradually move to Vue I'd create a separate vue layout and use it when needed, but if you are starting fresh you can insert this in the default layout
@{
var controllerName = HttpContext.Current.Request.RequestContext.RouteData.Values["Controller"].ToString();
}
@Scripts.Render("~/Scripts/app/" + controllerName + "/bundle.js")
- Remove the script.render statement from index.cshtml and refresh and this should keep on working.
- Now whenever you want to add vue to a new controller, add a mainjs file in the viewmodels folder and you are good to go.
Loading server data #
With vue up and running we can retrieve and render data coming from the server.
- Add some sample data to you controller, I'm using a method called load data
public JsonResult GetData()
{
return Json(new
{
Name = "Marco",
Surname = "Muscat",
Description = "Vue data loaded from razor!"
},JsonRequestBehavior.AllowGet);
}
- Add jquery to package.json and update your vue app to call the server function on load
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");
}
});
}
}
})
- To connect to the server I usually add a global variable window.ServerUrl to have easy access to the current host. If you want to do the same add the following to your layout file
@{
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")
- Rebuild and refresh the page and you should see the data in your console.
- Next let's bind the data to the view
<div id="app">
{ { message } }
<br/>
<span>coming straight from mvc! { {serverData.Name} } { {serverData.Surname} }. { {serverData.Description} }</span>
</div>
- Refreshing the page should show now look like this
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.
- I should warn you though that this should be used with smaller payloads. If your page is going to download a substantial amount of data, you are better of using paging and ajax to achieve smoother page loading.
- In addition this data will be visible in the markup so be careful not include anything sensitive in there
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; }
}
- Sample model is a simple class to facilitate binding in razor
- In Index.cshtml lets load and serialize the data in a JS object
<script> window.preLoadeddata = JSON.parse('@Html.Raw(Model.Data)')</script>
- Next let's tell vue from where to get the data.
const Vue = require("vue");
const $ = require("jquery");
const v = new Vue({
el: '#app',
ready: function () {
},
data: {
message: 'Hello Vue.js!',
serverData: window.preLoadeddata
},
methods: {
}
})
- and if you inspect the code you can see vue is using the js object rather than performing a separate GET request.
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.
- To demonstrate this let's create a new controller called vuerouting.
- Create an index.cshtml view for this controller
- Add a folder "vuerouting" to the viewmodels folder and a main.js file.
- Add vue-router to package.json
- And Add this sample vue app with routing in main.js
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');
- In Index.cshtml add this markup.
<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>
- And build.
- In the browser open http://localhost/vue-example/vuerouting
- Clicking on the links in the page will load the corresponding vue component and if you look at the address bar vue adds the route to that component.
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.
- In App_start/RouteConfig.cs replace the existing config to:
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" }
);
- The above config leaves the original routing as is, and adds an alternative route for controllers that use vue-routing.
- Sil controller route matches specific controllers and routes them to the index action for that controller, then ignores the rest of the route.
- The constraints parameter is a regex that can be customized any way you like to match certain controllers. In this example I'm routing by the controller name. More controllers can be added using |{controllername}. Once you have multiple routers you could device a more generic regex that will match without further modification to route map.
- After you rebuild, try navigating to http://localhost/vue-example/vuerouting/bar again and this time the page will load and vue will also open the bar component!
- One more thing that can be improved is the root value in the vue router configuration. In the above example the route is hard coded so, if you create another silo, you will have to modify that value. Instead we can add another js variable in the layout as we did in the with serverurl. In fact when modifying the layout page of the project we already added window.VueRouterUrl. So we can simply replace
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.