Javascript in a View Component

21,795

Solution 1

What I decided to do is write a ScriptTagHelper that provides an attribute of "OnContentLoaded". If true, then I wrap the inner contents of the script tag with a Javascript function to execute once the document is ready. This avoids the problem with the jQuery library having not loaded yet when the ViewComponent's script fires.

[HtmlTargetElement("script", Attributes = "on-content-loaded")]
public class ScriptTagHelper : TagHelper
{
  /// <summary>
  /// Execute script only once document is loaded.
  /// </summary>
  public bool OnContentLoaded { get; set; } = false;

  public override void Process(TagHelperContext context, TagHelperOutput output)
  {
     if (!OnContentLoaded)
     {
        base.Process(context, output);
     } 
     else
     {
        var content = output.GetChildContentAsync().Result;
        var javascript = content.GetContent();

        var sb = new StringBuilder();
        sb.Append("document.addEventListener('DOMContentLoaded',");
        sb.Append("function() {");
        sb.Append(javascript);
        sb.Append("});");

        output.Content.SetHtmlContent(sb.ToString()); 
     }
  }
}

example usage within a ViewComponent:

<script type="text/javascript" on-content-loaded="true">
    $('.button-collapse').sideNav();
</script>

There might be a better way than this, but this has worked for me so far.

Solution 2

Sections only work in a View don't work in partial views or View Component (Default.cshtml executes independently of the main ViewResult and its Layout value is null by default. ) and that's by design.

You can use it to render sections for a Layout that the partial view or view component's view declares. However, sections defined in partials and view components don't flow back to the rendering view or it's Layout. If you want to use jQuery or other library reference in your partial view or View component then you can pull your library into head rather than body in your Layout page.

Example:

View Component:

public class ContactViewComponent : ViewComponent
{
    public IViewComponentResult Invoke()
    {
        return View();
    }
}

Location of ViewComponent:

/Views/[CurrentController]/Components/[NameOfComponent]/Default.cshtml
/Views/Shared/Components/[NameOfComponent]/Default.cshtml

Default.cshtml:

@model ViewComponentTest.ViewModels.Contact.ContactViewModel

<form method="post" asp-action="contact" asp-controller="home">
    <fieldset>
        <legend>Contact</legend>
        Email: <input asp-for="Email" /><br />
        Name: <input asp-for="Name" /><br />
        Message: <textarea asp-for="Message"></textarea><br />
        <input type="submit" value="Submit" class="btn btn-default" />
    </fieldset>
</form>

_Layout.cshtml :

    <!DOCTYPE html>
    <html>
    <head>
    ...
        @RenderSection("styles", required: false)
     <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.0/jquery.min.js"></script>
    </head>
    <body>
        @RenderBody()
    ...
    //script move to head
      @RenderSection("scripts", required: false)
    </body>
    </html>

View.cshtml:

@{
    Layout =  "~/Views/Shared/_Layout.cshtml";     
}
..................
@{Html.RenderPartial("YourPartialView");}
..................
@await Component.InvokeAsync("Contact")
..................
@section Scripts {
    <script>
    //Do somthing
    </script>
}

View Component by grahamehorner: (you can more information here)

The @section scripts don't render in a ViewComponent, view component should have the ability to include scripts within the @section unlike partital views as components may be reused across many views and the component is responsible for it's own functionality.

Solution 3

Here's the solution I'm using. It supports both external script loading and custom inline script. While some setup code needs to go in your layout file (e.g. _Layout.cshtml), everything is orchestrated from within the View Component.

Layout file:

Add this near the top of your page (inside the <head> tag is a good place):

<script>
    MYCOMPANY = window.MYCOMPANY || {};
    MYCOMPANY.delayed = null;
    MYCOMPANY.delay = function (f) { var e = MYCOMPANY.delayed; MYCOMPANY.delayed = e ? function () { e(); f(); } : f; };
</script>

Add this near the bottom of your page (just before the closing </body> tag is a good place):

<script>MYCOMPANY.delay = function (f) { setTimeout(f, 1) }; if (MYCOMPANY.delayed) { MYCOMPANY.delay(MYCOMPANY.delayed); MYCOMPANY.delayed = null; }</script>

View Component:

Now anywhere in your code, including your View Component, you can do this and have the script executed at the bottom of your page:

<script>
    MYCOMPANY.delay(function() {
        console.log('Hello from the bottom of the page');
    });
</script>



With External Script Dependency

But sometimes that's not enough. Sometimes you need to load an external JavaScript file and only execute your custom script once the external file is loaded. You also want to orchestrate this all in your View Component for full encapsulation. To do this, I add a little more setup code in my layout page (or external JavaScript file loaded by the layout page) to lazy load the external script file. That looks like this:

Layout file:

Lazy load script

<script>
    MYCOMPANY.loadScript = (function () {
        var ie = null;
        if (/MSIE ([^;]+)/.test(navigator.userAgent)) {
            var version = parseInt(RegExp['$1'], 10);
            if (version)
                ie = {
                    version: parseInt(version, 10)
                };
        }

        var assets = {};

        return function (url, callback, attributes) {

            attributes || (attributes = {});

            var onload = function (url) {
                assets[url].loaded = true;
                while (assets[url].callbacks.length > 0)
                    assets[url].callbacks.shift()();
            };

            if (assets[url]) {

                if (assets[url].loaded)
                    callback();

                assets[url].callbacks.push(callback);

            } else {

                assets[url] = {
                    loaded: false,
                    callbacks: [callback]
                };

                var script = document.createElement('script');
                script.type = 'text/javascript';
                script.async = true;
                script.src = url;

                for (var attribute in attributes)
                    if (attributes.hasOwnProperty(attribute))
                        script.setAttribute(attribute, attributes[attribute]);

                // can't use feature detection, as script.readyState still exists in IE9
                if (ie && ie.version < 9)
                    script.onreadystatechange = function () {
                        if (/loaded|complete/.test(script.readyState))
                            onload(url);
                    };
                else
                    script.onload = function () {
                        onload(url);
                    };

                document.getElementsByTagName('head')[0].appendChild(script);
            }
        };
    }());
</script>

View Component:

Now you can do this in your View Component (or anywhere else for that matter):

<script>
    MYCOMPANY.delay(function () {
        MYCOMPANY.loadScript('/my_external_script.js', function () {
            console.log('This executes after my_external_script.js is loaded.');
        });
    });
</script>

To clean things up a bit, here's a tag helper for the View Component (inspired by @JakeShakesworth's answer):

[HtmlTargetElement("script", Attributes = "delay")]
public class ScriptDelayTagHelper : TagHelper
{
    /// <summary>
    /// Delay script execution until the end of the page
    /// </summary>
    public bool Delay { get; set; }

    /// <summary>
    /// Execute only after this external script file is loaded
    /// </summary>
    [HtmlAttributeName("after")]
    public string After { get; set; }

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        if (!Delay) base.Process(context, output);

        var content = output.GetChildContentAsync().Result;
        var javascript = content.GetContent();

        var sb = new StringBuilder();
        sb.Append("if (!MYCOMPANY || !MYCOMPANY.delay) throw 'MYCOMPANY.delay missing.';");
        sb.Append("MYCOMPANY.delay(function() {");
        sb.Append(LoadFile(javascript));
        sb.Append("});");

        output.Content.SetHtmlContent(sb.ToString());
    }

    string LoadFile(string javascript)
    {
        if (After == NullOrWhiteSpace)
            return javascript;

        var sb = new StringBuilder();
        sb.Append("if (!MYCOMPANY || !MYCOMPANY.loadScript) throw 'MYCOMPANY.loadScript missing.';");
        sb.Append("MYCOMPANY.loadScript(");
        sb.Append($"'{After}',");
        sb.Append("function() {");
        sb.Append(javascript);
        sb.Append("});");

        return sb.ToString();
    }
}

Now in my View Component I can do this:

<script delay="true" after="/my_external_script.js"]">
  console.log('This executes after my_external_script.js is loaded.');
</script>

You can also leave out the after attribute if the View Component doesn't have an external file dependency.

Share:
21,795

Related videos on Youtube

Max Jacob
Author by

Max Jacob

Updated on October 16, 2020

Comments

  • Max Jacob
    Max Jacob over 3 years

    I have a View Component that contains some jQuery in the Razor (.cshtml) file. The script itself is quite specific to the view (deals with some some configuration of third-party libraries), so I would like to keep the script and HTML in the same file for organization's sake.

    The problem is that the script is not rendered in the _Layout Scripts section. Apparently this is just how MVC handles scripts with regards to View Components.

    I can get around it by just having the scripts in the Razor file, but not inside of the Scripts section.

    But then I run into dependency issues - because jQuery is used before the reference to the library (the reference to the library is near the bottom of the _Layout file).

    Is there any clever solution to this other than including the reference to jQuery as part of the Razor code (which would impede HTML rendering where ever the component is placed)?

    I'm currently not in front of the code, but I can certainly provide it once I get the chance if anyone needs to look at it to better understand it.

  • Johnny Oshika
    Johnny Oshika almost 6 years
    You should return after calling base, otherwise, everything else will override the base implementation: if (!OnContentLoaded) { base.Process(context, output); return; }
  • Fatih Erol
    Fatih Erol almost 6 years
    ty for scenario.
  • Viktors Telle
    Viktors Telle over 5 years
    Here is the link to this issue on GitHub: GitHub TL;DR: Microsoft is planning to address this issue in .NET Core 3.0 with Razor Components.
  • chakeda
    chakeda over 5 years
    Note that you need to scope your tag helper to make @Jake's solution work: stackoverflow.com/questions/48271514/…
  • Miguel J.
    Miguel J. over 5 years
    Am I supposed to import the tag helper in the parent component?
  • Niklas
    Niklas about 5 years
    this solved my problem,however it costed me hours to figure out im still having my code tested while its written inside a section within the ViewComponent...Duh
  • Mike S.
    Mike S. about 3 years
    Great solution.