ASP.NET Core MVC, Webpack and Vue.js — Update

Luy Lucas
11 min readMay 1, 2023

--

Hi everyone, after a lot of time, I’m bringing an update on my first model to work with these great tools. I recommend you read first version because I’ll use equivalent tools configurations to transpile javascript, build sass, and some additionals and updates. Here we’ll use VS2022, Node 18 (I recommend you to use nvm on windows)

Here you’ll find the repo of this example, let’s work.

But first: Why? Why not a SPA with an API? Well, some scenarios we want switch techs to produce the same result with less time impact to develop a solution. As a dotnet developer, I really like C# as my primary language, but We know that javascript was made for browsers, but pure javascript is hard and verbose, jquery is old and for simple things, angular and react, far as I know, is just for SPA and I think that has much complexity if We want to build simple apps (I’m not here to say what is better), so why don’t merge the best of ASP.NET (security, performance, productivity) and best of Vue (reactivity)? Well, I hope that you enjoy this solution.

First of all, we need a project, I’ll use the default ASP.NET Core MVC App.

I’ll use version 7.0 of .NET Framework, but what we’ll do here can be applied in any version.

Now, we run npm init -y script, with a VS PMC in root of the web project:

This creates a package.json file in project, allowing to us to use NPM packages. Now install the dev dependencies packages:

npm install — save-dev @babel/core @babel/preset-env babel-loader copy-webpack-plugin css-loader glob mini-css-extract-plugin postcss postcss-loader resolve-url-loader sass sass-loader style-loader terser-webpack-plugin vue-loader vue-style-loader vue-template-compiler webpack webpack-assets-manifest webpack-cli webpack-merge

Our project dependencies, just install:

npm install — save bootstrap vue

Now, we’ll configure webpack development and production configs. First we’ll create a common webpack config file (webpack.common.js) in web project root directory:

const glob = require("glob");
const path = require("path");
const webpack = require("webpack");
const miniCssExtractPlugin = require("mini-css-extract-plugin");
const { VueLoaderPlugin } = require("vue-loader");
const CopyWebpackPlugin = require('copy-webpack-plugin');

const scripts = {
// initial point, We use to load layout and base libraries
index: "./wwwroot/src/js/index.js",
};

module.exports = {
// load all .js files presents in the directory merging with index
entry: glob
.sync("./wwwroot/src/js/**/*.js")
.reduce(function (obj, el) {
obj[path.parse(el).name] = `./${el}`;
return obj;
}, scripts),
// set output directory, I like to clean every build
output: {
path: path.resolve(__dirname, "./wwwroot/dist"),
publicPath: "/dist/",
clean: true
},
// set output of webpack build stats
stats: {
preset: "none",
assets: true,
errors: true,
},
plugins: [
new VueLoaderPlugin(),
// copy all images to output dir
new CopyWebpackPlugin({
patterns: [
{
from: path.resolve(__dirname, "./wwwroot/src/img"),
to: path.resolve(__dirname, "./wwwroot/dist/img")
},
],
}
),
],

module: {
rules: [
{
// this will load and build all scss, making a css file
test: /\.((sa|sc|c)ss)$/i,
use: [
miniCssExtractPlugin.loader,
"css-loader",
"resolve-url-loader",
{
loader: "postcss-loader",
options: {
postcssOptions: {
plugins: function () {
return [
require("autoprefixer")
];
}
}
}
},
{
loader: "sass-loader",
options: {
implementation: require("sass")
}
}
]
},
{
// this will load all js files, transpile to es5
test: /\.js$/,
include: path.resolve(__dirname, './wwwroot/src/js'),
use: {
loader: "babel-loader",
options: {
presets: ["@babel/preset-env"]
}
}
},
{
//case when sass files have images references, webpack will copy
test: /\.(jpe?g|png|gif|svg)$/,
include: path.resolve(__dirname, './wwwroot/src/img'),
type: "asset/resource",
generator: {
filename: "img/[name].[ext]"
}
},
{
test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
type: "asset/resource",
generator: {
filename: "fonts/[name].[ext]"
}
},
{
test: /\.vue$/,
loader: "vue-loader"
}
]
},

resolve: {
alias: {
// use es module vue version
vue: "vue/dist/vue.esm-bundler.js"
}
},
};

Now, let’s create a dev config (webpack.dev.js):

const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const { merge } = require("webpack-merge");
const common = require("./webpack.common.js");
const webpack = require("webpack");
module.exports = merge(common, {
mode: "development",
output: {
filename: "js/[name].js",
pathinfo: false,
},

plugins: [
new MiniCssExtractPlugin({ filename: "css/[name].css" }),
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: true, // If you are using the options api.
__VUE_PROD_DEVTOOLS__: true // If you don't want people sneaking around your components in production.
}),
],

devtool: "inline-source-map",

optimization: {
removeAvailableModules: false,
removeEmptyChunks: false,
splitChunks: false,
},
});

Now, let’s create a production config (webpack.prd.js):

const { merge } = require("webpack-merge");
const common = require("./webpack.common.js");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const WebpackAssetsManifest = require("webpack-assets-manifest");
const webpack = require("webpack");

module.exports = merge(common, {
mode: "production",
output: {
filename: "js/[name].[chunkhash].js",
clean: true
},
devtool: false,
plugins: [
new MiniCssExtractPlugin({ filename: "css/[name].[chunkhash].css" }),
// this plugin generates integrity of files and put in stats file
new WebpackAssetsManifest({
integrity: true,
integrityHashes: ["sha256"]
}),
new webpack.SourceMapDevToolPlugin({
test: /\.((sa|sc|c)ss)$/i,
}),
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: true, // If you are using the options api.
__VUE_PROD_DEVTOOLS__: false // If you don't want people sneaking around your components in production.
}),
],
optimization: {
runtimeChunk: "single",
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
},
},
},
});

At this point we have webpack ready to build, now put the scripts builds in package.json. Here we have — json=stats.json, this will generate a stats.json file to us (feel free to change this name). This file will be used to load all builds in our .cshtml:

"scripts": {
"debug": "webpack --config webpack.dev.js",
"dev": "webpack --config webpack.dev.js --json=stats.json --watch --progress",
"prd": "webpack --config webpack.prd.js --json=stats.json"
},

Let’s make some changes in our app. At wwwroot, delete all directories, create a src directory. Inside src, create a directory for scss files, js files and vue files:

Executing npm debug script, the dist directory will be create, with just js subdirectory and a empty js file:

Now we’ll create a config for our app loads files. First install:

<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="7.0.5" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />

Create a file called something like LoadWebpack.cs in Models Directory. Put the following code in:

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace Webpack.Models;

public static class LoadWebpack
{
private static Dictionary<string, Tuple<string, string>> Css { get; } = new();
private static Dictionary<string, Tuple<string, string>> Js { get; } = new();

public static void Load()
{
//open stats.json file to read, creating a dictionary of css and js files to read
// in Our .cshtml files;
using var status = File.OpenRead("stats.json");
using var streamReader = new StreamReader(status);
using var textReader = new JsonTextReader(streamReader);
var jsonObjects = JObject.Load(textReader);

var assetsChunks = jsonObjects["assetsByChunkName"];
var assets = jsonObjects["assets"];

var item = assets
?.Select(x =>
new
{
name = x["name"]?.ToString(),
integrity = x["info"]?["integrity"]?.ToString()
})
?.ToList();

foreach (var asset in assetsChunks)
{
var property = (JProperty)asset;
foreach (var value in property.Value.ToObject<string[]>())
if ((value?.StartsWith("css") ?? false) && !Css.ContainsKey(property.Name))
Css.Add(property.Name, new Tuple<string, string>(value, item?.FirstOrDefault(a => a.name == value)?.integrity!));
else if ((value?.StartsWith("js") ?? false) && !Js.ContainsKey(property.Name))
Js.Add(property.Name, new Tuple<string, string>(value, item?.FirstOrDefault(a => a.name == value)?.integrity!));
}
}

public static Tuple<string, string> LoadCss(string name)
{
if (Css.TryGetValue(name, out var styleFile))
{
Load();
Css.TryGetValue(name, out styleFile);
}

return styleFile!;
}

public static Tuple<string, string> LoadJs(string name)
{
if (Js.TryGetValue(name, out var scriptFile))
{
Load();
Js.TryGetValue(name, out scriptFile);
}

return scriptFile!;
}
}

This class will read stats.json file (flag setted on package.json scripts) to make file paths available.

In Program.cs we call Load method and call .AddRazorRuntimeCompilation() to reload pages without restart app:

using Webpack.Models;

var builder = WebApplication.CreateBuilder(args);

LoadWebpack.Load();

// Add services to the container.
builder.Services.AddControllersWithViews().AddRazorRuntimeCompilation();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthorization();

app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");

app.Run();

We’ll create two partial views, one to load css file and one to load js files. First, partial for css (_Styles.cshtml):

@model string
@if (Model is not null)
{
var css = LoadWebpack.LoadCss(Model);
if (css?.Item2 is not null)
{
<link rel="stylesheet" href="~/dist/@css.Item1" integrity="@css.Item2" crossorigin="anonymous">
}
else if (css?.Item1 is not null)
{
<link rel="stylesheet" href="~/dist/@css.Item1">
}
}

And _Scripts.cshtml:

@model string
@if (Model is not null)
{
var js = LoadWebpack.LoadJs(Model);
if (js?.Item2 is not null)
{
<script src="~/dist/@js.Item1" crossorigin="anonymous" integrity="@js.Item2"></script>
}
else if(js?.Item1 is not null)
{
<script src="~/dist/@js.Item1"></script>
}
}

Use it in _Layout.cshtml (I already apply this bootstrap example):

<!doctype html>
<html lang="en" class="h-100" data-bs-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@ViewData["Title"] - Webpack</title>
@await Html.PartialAsync("_Styles", "index")
@await RenderSectionAsync("Styles", required: false)
</head>
<body class="d-flex h-100 text-center text-bg-dark">
<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<header class="mb-auto">
<div>
<h3 class="float-md-start mb-0">Cover</h3>
<nav class="nav nav-masthead justify-content-center float-md-end">
<a class="nav-link fw-bold py-1 px-0 active" aria-current="page" href="#">Home</a>
<a class="nav-link fw-bold py-1 px-0" href="#">Features</a>
<a class="nav-link fw-bold py-1 px-0" href="#">Contact</a>
</nav>
</div>
</header>

@RenderBody()

<footer class="mt-auto text-white-50">
<p>
Cover template for <a href="https://getbootstrap.com/" class="text-white">Bootstrap</a>, by <a href="https://twitter.com/mdo" class="text-white">@@mdo</a>.
</p>
</footer>
</div>

@await Html.PartialAsync("_Scripts", "index")
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

Our home page:

@{
ViewData["Title"] = "Home Page";
}

<main class="px-3">
<h1>Cover your page.</h1>
<p class="lead">
Cover is a one-page template for building simple and beautiful home pages. Download, edit the
text, and add your own fullscreen background photo to make it your own.
</p>
<p class="lead">
<a href="#" class="btn btn-lg btn-light fw-bold border-white bg-white">Learn more</a>
</p>
</main>

This BS example brings some css, so, let’s put in our code. In our wwwroot/src/scss/index.scss:

@import "bootstrap/scss/bootstrap";

.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}

@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}

.b-example-divider {
width: 100%;
height: 3rem;
background-color: rgba(0, 0, 0, .1);
border: solid rgba(0, 0, 0, .15);
border-width: 1px 0;
box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15);
}

.b-example-vr {
flex-shrink: 0;
width: 1.5rem;
height: 100vh;
}

.bi {
vertical-align: -.125em;
fill: currentColor;
}

.nav-scroller {
position: relative;
z-index: 2;
height: 2.75rem;
overflow-y: hidden;
}

.nav-scroller .nav {
display: flex;
flex-wrap: nowrap;
padding-bottom: 1rem;
margin-top: -1px;
overflow-x: auto;
text-align: center;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}

.btn-bd-primary {
--bd-violet-bg: #712cf9;
--bd-violet-rgb: 112.520718, 44.062154, 249.437846;
--bs-btn-font-weight: 600;
--bs-btn-color: var(--bs-white);
--bs-btn-bg: var(--bd-violet-bg);
--bs-btn-border-color: var(--bd-violet-bg);
--bs-btn-hover-color: var(--bs-white);
--bs-btn-hover-bg: #6528e0;
--bs-btn-hover-border-color: #6528e0;
--bs-btn-focus-shadow-rgb: var(--bd-violet-rgb);
--bs-btn-active-color: var(--bs-btn-hover-color);
--bs-btn-active-bg: #5a23c8;
--bs-btn-active-border-color: #5a23c8;
}

.bd-mode-toggle {
z-index: 1500;
}

/*
* Globals
*/


/* Custom default button */
.btn-light,
.btn-light:hover,
.btn-light:focus {
color: #333;
text-shadow: none; /* Prevent inheritance from `body` */
}


/*
* Base structure
*/

body {
text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5);
box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5);
}

.cover-container {
max-width: 42em;
}


/*
* Header
*/

.nav-masthead .nav-link {
color: rgba(255, 255, 255, .5);
border-bottom: .25rem solid transparent;
}

.nav-masthead .nav-link:hover,
.nav-masthead .nav-link:focus {
border-bottom-color: rgba(255, 255, 255, .25);
}

.nav-masthead .nav-link + .nav-link {
margin-left: 1rem;
}

.nav-masthead .active {
color: #fff;
border-bottom-color: #fff;
}

Import this file in wwwroot/src/js/index.js:

import "../scss/index.scss";

console.log("Hello Webpack World");

Run npm debug script and Voilá:

If we run our app:

WHAAT?! Oks, It was proposital, we run debug scripts, it just builds our files and show, or not, console errors, it doesn’t generate our stats.json file. Remove the — watch flag from dev script in package.json and run it npm run dev again:

Until now we have:

  • Webpack building development SCSS and Javascript version;
  • MVC Loading CSS and Javascript file;

Let’s create some vue SFCs. I’ll create a simple example, but after that, We have Vue full power in our app, being able to use Vue Router, Vuex/Pinia (with localstorage) and (maybe) any Vue Template.

One more thing before: this example works with ASP.NET resolving routes and auth, not Vue, but, you can configure vue router to resolve the components. Here We work with multipaging and ASP.NET redirects, my goal here is just replace pure javascript e jquery to Vue. Be free to modify for your case.

In wwwroot/src/vue, create a HomePage.vue component, I put this code in:

<template>

<main class="px-3">
<h1>{{message}}</h1>
<p class="lead">
Cover is a one-page template for building simple and beautiful home pages. Download, edit the
text, and add your own fullscreen background photo to make it your own.
</p>
<p class="lead">
<a href="#" class="btn btn-lg btn-light fw-bold border-white bg-white">Learn more</a>
</p>
</main>


</template>
<script>

export default {
name: "HomePage",
data() {
return {
message: "Hello Vue"
}
}
}
</script>

In Views/Home/Index.cshtml, replace the code for this:

@{
ViewData["Title"] = "Home Page";
}

@section Styles{@await Html.PartialAsync("_Styles", "home")}
@section Scripts{@await Html.PartialAsync("_Scripts", "home")}

<div id="home-app"></div>

Creates a wwwroot/src/js/home/home.js and put the code in:

import { createApp } from "vue";
import HomePage from "../../vue/HomePage.vue";

createApp(HomePage).mount("div#home-app");

npm run dev

So far, so good. Pay attention: we always need a javascript file to execute SFC, because we have to set what DOM element the componente will be mounted.

I want to see production builds. We have a little error in production script, because we didn’t put any images in wwwroot/src/img, Webpack doesn’t build prodution outputs, so put any png, jpeg image in the directory and run npm run prd.

We’ll have two more javascript files: runtime and vendors. Runtime is some webpack scripts to load our modules and vendors is grouping of libs scripts (I recommend you to read this Webpack Guide to understand and maybe tunning this primal configuration).

In _Layout.cshtml, put two more scripts partials on order:

 @await Html.PartialAsync("_Scripts", "runtime")
@await Html.PartialAsync("_Scripts", "vendors")
@await Html.PartialAsync("_Scripts", "index")
@await RenderSectionAsync("Scripts", required: false)

Running the app now, visually nothing changes, but if we inspect the generated html, we can see different files references:

We didn’t change nothing in the .cshtml files and, in automatic way, we can use hashed css and javascript files in browsers. This helps to change broswer cached files when a new app version is published.

That’s it, you can explore another world. Maybe in a next story, I’ll bring Pinia with Localstorage and Vue Router. Feel free to make PR in this repo.

--

--