How to Use Vue with ASP.NET Razor
Versions Used
Introduction
For the reasons behind replacing jQuery with Vue 2, please refer to Discussing jQuery. This article continues to use Vue 2 rather than the latest Vue 3, primarily because we have not yet found a package that can replace VeeValidate 2 while maintaining frontend Model Validation.
Architecture Overview
For Vue syntax tutorials, please refer to the official Vue 2.x Guide. This article focuses only on the parts relevant to the architecture.
How to Create a Vue Object
Creating a Vue object generally involves two parts:
- You need a root DOM node to serve as the Vue Template, which contains the content to be rendered. It should provide an attribute (usually an
id) that allows Vue to locate this DOM via a selector. If Vue finds multiple DOM elements, only the first one will be effective. - Create a Vue object in JavaScript with the following parameters:
el: A selector string used to find the root DOM node, e.g.,'#app'to find the DOM with the ID "app".data: Usually an object or a function returning an object. The object's properties must include the keys to be used, e.g.,{ records: [] }orfunction() { return { records: [] } }.methods: An object containing the methods Vue will use, e.g.,{ handler: function() { }}.created: A function executed after the Vue object is created; data loading is typically performed here.computed: An object composed of key-function pairs, conceptually similar to C# getters, e.g.,{ recordCount: function() { return records.length } }.
A simple sample is as follows:
HTML part
<div id="app">
<div>
Record count: {{ recordCount }}
</div>
<table>
<tr>
<th>Title 1</th>
<th>Title 2</th>
</tr>
<tr v-for="record in records">
<th>{{ record.col1 }}</th>
<th>{{ record.col2 }}</th>
<th>
<button type="button" v-on:click="handler1(record.col1, record.col2)">Button</button>
</th>
</tr>
</table>
<button type="button" v-on:click="handler2">Clicked {{ count }} times</button>
</div>JavaScript part
let app = new Vue({
el: '#app', // Use a selector to find the DOM to render; here it finds the DOM with id 'app'
data: {
count: 0,
records: []
},
methods:{
handler1: function(arg1, arg2) {
console.log(arg1 + ' ' + arg2);
},
handler2: function(arg1, arg2) {
this.count += 1;
}
},
created: function() {
// $el is not yet created, but data is accessible. Ajax data loading is usually placed here.
this.records = [
{ col1: 'record1 col1', col2: 'record1 col12' },
{ col1: 'record2 col1', col2: 'record2 col12' }
]
},
computed: {
recordCount: function() {
return this.records.length;
}
}
});Mixin
Excerpt from official documentation:
Mixins are a flexible way to distribute reusable functionalities for Vue components. A mixin object can contain any component options. When a component uses a mixin, all options in the mixin will be "mixed" into the component's own options.
v-cloak
Excerpt from official documentation:
When using templates written directly in the DOM, a situation called "Flash of Uncompiled Content" may occur: users might see raw double curly braces before the mounted component replaces them with the actual rendered content.
v-cloakremains on the bound element until the associated component instance is mounted. Combined with a CSS rule like[v-cloak] { display: none }, it can hide the raw template until the component is compiled.
Architectural Prototype
Common website content is usually placed in _Layout.cshtml. Knowing about Mixins, we can write the Vue Object creation logic there. Each page only needs to create its own parameter object, and when creating the Vue Object, integrate them using the mixins parameter. The code looks roughly like this:
<div id="vueApp" v-cloak>
@RenderBody()
</div>
<!-- ... -->
// Declare the mixins variable for use when creating the Vue object later
<script>
let mixins = [];
</script>
// Each page will create a pageMixin in the Scripts section and add it to mixins
@await RenderSectionAsync("Scripts", required: false)
// Create the Vue object; at this point, mixins already contains pageMixin
<script>
new Vue({
el: '#vueApp',
mixins: mixins
});
</script>Complete Code
_Layout.cshtml
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="~/css/site.min.css" asp-append-version="true" />
@RenderSection("Head", required: false)
</head>
<body>
<div id="vueApp" class="container" v-cloak>
@RenderBody()
</div>
<script src="~/lib/vue/vue.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
<script>
let mixins = [];
</script>
@await RenderSectionAsync("Scripts", required: false)
<script>
new Vue({
el: '#vueApp',
mixins: mixins
});
</script>
</body>
</html>Views/Pages/{Page}.cshtml
@section Scripts {
<script>
mixins.push({
data: function () {
return {
// Add Vue Data Properties used by the page here
};
},
methods: {
// Add Vue Methods used by the page here
},
created: function() {
// Implement page data loading here
}
});
</script>
}site.css
[v-cloak] {
display: none;
}Considerations for Integrating Vue and ASP.NET Razor
Vue Templates cannot contain
scripttags. Therefore, in the previous example,pageMixinis created within@section Scripts { }to avoid triggering the following error:html<div id="app"> <script></script> <!-- Triggers error --> <div> <script> new Vue({ el: '#app'}); </script> @* Error message [Vue warn]: Error compiling template: Templates should only be responsible for mapping the state to the UI. Avoid placing tags with side-effects in your templates, such as <script>, as they will not be parsed. *@Some Vue shorthand syntax uses
@, such asv-on:clickwhich can be shortened to@click. However, this is not recommended in Razor Pages because adding@inside Tag Helpers might cause compilation failures. Forcing the use of Vue's@shorthand in Razor Pages results in a mix of shorthand and non-shorthand syntax. The scenarios involving@are as follows:@is also a Razor syntax keyword, so you generally need to escape it with another@, e.g.,@@click.- In Tag Helpers,
@cannot appear anywhere other than in attribute values; even escaping with@will cause a compilation error.
html<-- Not using asp-for, standard HTML, escaping with @ works --> <input type="text" @@click="handleClick" /> <-- Using asp-for, defined as TagHelper, @ causes compilation error --> <input type="text" asp-for="Test" @click="handleClick" /> <-- Using asp-for, escaping with @ still causes compilation error --> <input type="text" asp-for="Test" @@click="handleClick" /> <-- Using asp-for, @ can only appear in attribute value positions --> <input type="text" asp-for="Test" test="@Model.Test" />
How to Replace jQuery with Vue
When creating a new ASP.NET Core Web project, some features depend on jQuery. Here are their alternatives:
Bootstrap
Since Bootstrap 5 has dropped the jQuery dependency, you can upgrade directly to version 5. Note that there are differences in syntax structure between major versions of Bootstrap, so you may need to adjust your HTML accordingly.
Ajax
Vue originally had its own ajax package, but the author stopped maintaining it and recommended using axios.
One thing to note: in the original MVC Framework, to handle XSRF/CSRF attacks, you needed to write @Html.AntiForgeryToken() in the form to generate a hidden field and add the [ValidateAntiForgeryToken] attribute to the Controller Action. In ASP.NET Core, both of the following methods automatically add the Antiforgery hidden field. For more details, refer to Prevent Cross-Site Request Forgery (XSRF/CSRF) attacks in ASP.NET Core.
<form method="post">
<!-- ... -->
</form>
@using (Html.BeginForm("Index", "Home"))
{
<!-- ... -->
}Another point is that ASP.NET Core MVC still requires adding [ValidateAntiForgeryToken] for XSRF/CSRF protection, but Razor Pages perform this automatically. Therefore, you need to add the following code to axios so that Razor Pages' ajax calls work correctly.
site.js
// Vue loads VeeValidate
const config = {
locale: 'zh_TW',
events: 'change|blur'
};
Vue.use(VeeValidate, config);
// Add RequestVerificationToken to ajax headers
axios.interceptors.request.use(
config => {
let token = document.querySelector('input[name="__RequestVerificationToken"]');
if (token !== null) {
config.headers = {
RequestVerificationToken: token.value
}
}
return config;
},
error => {
return Promise.reject(error);
}
);Validation
.NET MVC and Razor Pages have a convenient Model Validation feature that allows for simple frontend and backend validation by adding Validation Attributes to the ViewModel. Frontend validation relies on jQuery Validation. If you don't use jQuery, you have to write frontend validation yourself, as it cannot rely on Validation Attributes to automatically add validation.
Here, we use VeeValidate 2 to demonstrate an alternative approach. The reason for choosing VeeValidate 2 over VeeValidate 3 is that VeeValidate 3 only supports the Vue Component approach, which requires significant HTML changes, whereas VeeValidate 2 is easier to integrate with existing Tag Helpers. In addition to the old Html Helpers, ASP.NET Core provides Tag Helpers. We add two Tag Helpers here to generate the HTML Attributes required by VeeValidate.
[HtmlTargetElement("input", Attributes = ForAttributeName)]
public class VeeValidationInputTagHelper : TagHelper {
private const string ForAttributeName = "asp-for";
private const string DataValidationAs = "data-vv-as";
private const string ValidateAttribute = "v-validate";
private const string RefAttribute = "ref";
private const string OtherValidateAttribute = "vee-other-validate";
[HtmlAttributeName(ForAttributeName)]
public ModelExpression? For { get; set; }
[HtmlAttributeName(OtherValidateAttribute)]
public string? OtherValidate { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output) {
if (context is null) {
throw new ArgumentNullException(nameof(context));
}
if (output is null) {
throw new ArgumentNullException(nameof(output));
}
if (For is null) {
return;
}
if (!context.AllAttributes.ContainsName(DataValidationAs)) {
output.Attributes.Add(DataValidationAs, For.Metadata.GetDisplayName());
}
if (!context.AllAttributes.ContainsName(RefAttribute)) {
output.Attributes.Add(RefAttribute, For.Name);
}
if (!context.AllAttributes.ContainsName(ValidateAttribute)) {
string? validateValues = GetValidateValues();
if (validateValues != null) {
output.Attributes.Add(ValidateAttribute, GetValidateValues());
}
}
}
private string? GetValidateValues() {
List<string> items = new List<string>();
if (For is not null) {
foreach (var validationAttribute in For.Metadata.ValidatorMetadata) {
switch (validationAttribute) {
case CompareAttribute attr:
// HACK Not sure if it can be captured correctly
string[] forNameParts = For.Name.Split('.');
forNameParts[^1] = attr.OtherProperty;
items.Add($"confirmed:{string.Join(".", forNameParts)}");
break;
case CreditCardAttribute _:
items.Add("credit_card");
break;
case EmailAddressAttribute _:
items.Add("email");
break;
case FileExtensionsAttribute attr:
items.Add($"ext:{attr.Extensions}");
break;
case StringLengthAttribute attr:
if (attr.MaximumLength > 0) {
items.Add($"max:{attr.MaximumLength}");
}
if (attr.MinimumLength > 0) {
items.Add($"min:{attr.MinimumLength}");
}
break;
case MaxLengthAttribute attr:
if (attr.Length > 0) {
items.Add($"max:{attr.Length}");
}
break;
case MinLengthAttribute attr:
if (attr.Length > 0) {
items.Add($"min:{attr.Length}");
}
break;
case PhoneAttribute _:
// UNDONE Not supported by Vee natively
break;
case RangeAttribute attr:
string key = attr.OperandType == typeof(DateTime)
? "date_between" : "between";
items.Add($"{key}:{attr.Minimum},{attr.Maximum}");
break;
case RegularExpressionAttribute _:
// Regex only supports object expression, confirmed only supports string expressions
// Considering regex escaping issues, we don't perform regex validation on the frontend
break;
case RequiredAttribute _:
items.Add("required");
break;
case UrlAttribute _:
items.Add("url");
break;
}
}
}
if (!string.IsNullOrWhiteSpace(OtherValidate)) {
items.AddRange(OtherValidate.Split('|'));
}
if (items.Any()) {
return $"'{string.Join("|", items)}'";
}
return null;
}
}[HtmlTargetElement("span", Attributes = ValidationForAttributeName)]
public class VeeValidationMessageTagHelper : TagHelper {
private const string ValidationForAttributeName = "vee-validation-for";
private const string VueShow = "v-show";
[HtmlAttributeName(ValidationForAttributeName)]
public ModelExpression? For { get; set; }
public override void Process(TagHelperContext context, TagHelperOutput output) {
if (context is null) {
throw new ArgumentNullException(nameof(context));
}
if (output is null) {
throw new ArgumentNullException(nameof(output));
}
if (For is null) {
return;
}
output.Attributes.Add(VueShow, $"errors.has('{For.Name}')");
output.Content.SetHtmlContent($"{{{{ errors.first('{For.Name}') }}}}");
}
}_ViewImports.cshtml needs to add the reference to the custom Tag Helper. Replace {Project Namespace} with the actual project namespace to reference all Tag Helpers under that namespace.
@addTagHelper *, {Project Namespace}In _Layout.cshtml, when creating the Vue Object, add validateBeforeSubmit, and add ModelState error messages to VeeValidate's errors in created.
new Vue({
el: '#vueApp',
mixins: mixins,
methods: {
validateBeforeSubmit: function (event) {
this.$validator.validateAll().then(result => {
if (!result) {
event.preventDefault();
}
});
}
},
created: function() {
@if (ViewContext.ViewData.ModelState.ErrorCount > 0) {
foreach (var pair in ViewContext.ViewData.ModelState.Where(x => x.Value.Errors.Any())) {
<text>
this.$validator.errors.add({
field: '@pair.Key',
msg: '@Html.Raw(pair.Value.Errors.First().ErrorMessage)'
});
</text>
}
}
}
});Usage of the new Tag Helper in {Page}.cshtml. Replace {Model Property Name} with the actual name of the model property to be retrieved.
<form method="post" role="form" v-on:submit="validateBeforeSubmit">
<input type="text" asp-for="{Model Property Name}" />
<span vee-validation-for="{Model Property Name}" class="text-danger"></span>
</form>References
Placing the Vue Object creation in _Layout.cshtml and using Mixins for integration was inspired by the article Using VueJS with ASP.NET Razor Can be Great!. As for using mixins instead of mixinArray, it is a personal naming preference. Declaring mixins in _Layout.cshtml rather than site.js is based on the belief that keeping declaration and usage in the same place eliminates the need for comments to track where it was declared.
Change Log
- 2022-10-24 Initial version created.
