简体   繁体   中英

Blazor component which loads its own script

My custom blazor component uses an external script (hosted on a CDN). Usually one would reference it in index.html , but I don't want to do that - I want the component to be self contained and easy to use.

The typical solution is a script loader. A popular implementation has been floating around for years: here and here .

wwwroot/js/scriptLoader.js (referenced in index.html ):

let _loadedScripts = [];      // list of loading / loaded scripts

export async function loadScript(src) {

  // only load script once
  if (_loadedScripts[src])
    return Promise.resolve();

  return new Promise(function(resolve, reject) {
    let tag  = document.createElement('script');
    tag.type = 'text/javascript';
    tag.src  = src;

    // mark script as loading/loaded
    _loadedScripts[src] = true;

    tag.onload = function() {
      resolve();
    }

    tag.onerror = function() {
      console.error('Failed to load script.');
      reject(src);
    }

    document.body.appendChild(tag);
  });
}

Then I create a component where I want to load a custom script.

FooComponent.razor :

@inject IJSRuntime _js

// etc...

protected override async Task OnAfterRenderAsync(bool firstRender)
{
  await base.OnAfterRenderAsync(firstRender);
  if (firstRender)
  {
    // load component's script (using script loader)
    await _js.InvokeVoidAsync("loadScript", "https://cdn.example.com/foo.min.js");
  }
  // invoke `doFoo()` from 'foo.min.js' script
  await _js.InvokeVoidAsync("doFoo", "hello world!");
}

That works. I can use the <FooComponent /> and it will load its own script file.

But if I use the component multiple times, I run into a race condition:

  • instance 1 of the component
    • tries to load the script
    • script loader loads the script
    • the component can use it
  • instances 2+ of the component
    • they are loading at the same time as instance 1!
    • each tries to load the script, but the loader refuses to load it more than once
    • they immediately try to use the script - but it's still busy loading!
    • so they all fail and the app crashes (exceptions, etc.)

How can I refactor the code to ensure that the script is actually finished loading, before using it?

The key to your problem is to ensure:

  1. Only one attempt is make to load the script
  2. No method gets called until it's loaded.

One way to achieve this is to use a single process per SPA session to manage and call the scripts.

Here's a solution that uses a service to manage the process. It uses a Task based async process utilizing TaskCompletionSource to ensure the script file is loaded before attempting to call a script method.

First the two scripts (both in wwwroot/js/):

// Your scriptLoader.js
// registered in _layout.html
let _loadedScripts = [];      // list of loading / loaded scripts

window.blazr_loadScript = async function(src) {

    // only load script once
    if (_loadedScripts[src])
        return Promise.resolve();

    return new Promise(function (resolve, reject) {
        let tag = document.createElement('script');
        tag.type = 'text/javascript';
        tag.src = src;

        // mark script as loading/loaded
        _loadedScripts[src] = true;

        tag.onload = function () {
            resolve();
        }

        tag.onerror = function () {
            console.error('Failed to load script.');
            reject(src);
        }

        document.body.appendChild(tag);
    });
}

And the CDN load when needed script.

// special.js
window.blazr_Alert = function (message) {
    window.alert(message);
} 

Next a Scoped Service class to manage the script calling process. Make it as generic as you need to.

public class MyScriptLoadedService
{
    private IJSRuntime _js;
    private TaskCompletionSource? _taskCompletionSource;

    private Task LoadTask => _taskCompletionSource?.Task ?? Task.CompletedTask;

    public MyScriptLoadedService(IJSRuntime jSRuntime)
        => _js = jSRuntime;

    public async Task SayHello(string message)
    {
        // If this is the first time then _taskCompletionSource will be null
        // We need to create one and load the Script file
        if (_taskCompletionSource is null)
        {
            // Create a new TaskCompletionSource
            _taskCompletionSource = new TaskCompletionSource();
            // wait on loading the script
            await _js.InvokeVoidAsync("blazr_loadScript", "/js/special.js");
            // set the TaskCompletionSource to successfully completed.
            _taskCompletionSource.TrySetResult();
        }

        // if we get here then _taskCompletionSource has a valid Task which we can await.
        // If it's completed then we just hammer on through
        // If not we wait until it is 
        await LoadTask;

        // At this point we have a loaded script file so can safely call the script
        await _js.InvokeVoidAsync("blazr_Alert", message);
    }
}

Create an extension method to make loading easy if in a separate library:

public static class MyLibaryServiceCollectionExtensions
{
    public static void AddMyServices(this IServiceCollection services)
    {
        services.AddScoped<MyScriptLoadedService>();
    }
}

Program:

// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddMyServices();
builder.Services.AddSingleton<WeatherForecastService>();

Here's a simple test component MyAlert

@inject MyScriptLoadedService Service

<h3>MyAlert for @this.Message</h3>

@code {
    [Parameter] public string Message { get; set; } = "Bonjour Blazor";

    protected override Task OnAfterRenderAsync(bool firstRender)
        => Service.SayHello(this.Message);
}

And my Index :

@page "/"

<PageTitle>Index</PageTitle>

<h1>Hello, world!</h1>

Welcome to your new app.

<SurveyPrompt Title="How is Blazor working for you?" />

<MyAlert Message="Brilliant England at the MCG" />
<MyAlert Message="Hello from France" />
<MyAlert Message="Hello from Germany" />
<MyAlert Message="Hello from Spain" />

Turns out this was javascript problem. The code below works for multiple concurrent components.

wwwroot/js/scriptLoader.js (referenced in index.html ):

let _loadingAndLoadedScripts = [];


export async function loadJs(src) {
  let hash = generateHash(src);

  // scenario 1: first invocation, so load script
  if (!_loadingAndLoadedScripts[hash]) {
    return new Promise((resolve, reject) => {
      let tag  = document.createElement('script');
      tag.type = 'text/javascript';
      tag.id   = hash;
      tag.src  = src;

      tag.onload = () => {
        tag.setAttribute('data-loaded', true);
        document.dispatchEvent(new Event('scriptLoaded'));
        resolve();
      };

      tag.onerror = () => {
        console.error('Failed to load script \'' + src + '\'.');
        reject();
      }

      _loadingAndLoadedScripts[src] = true;   // save state
      document.body.appendChild(tag);
    });
  }

  // scenario 2: script is busy loading, or already loaded
  else {
    // if loaded, do nothing
    var script   = document.getElementById(hash);
    let isLoaded = script && script.getAttribute('data-loaded') === 'true';
    if (isLoaded) {
      return Promise.resolve();
    }
    // else loading, so wait
    else {
      return new Promise((resolve, reject) => {
        // room for improvement here: could start timer to timeout
        // after few seconds then reject; I didn't do that because
        // it's probably unnecessary
        document.addEventListener('scriptLoaded', (e) => {
          resolve();
        }, { 'once': true });
      });
    }
  }

}


// fast simple hasher, from https://stackoverflow.com/a/8831937/9971404
function generateHash(s) {
  let hash = 0;
  for (let i = 0, len = s.length; i < len; i++) {
    let c = s.charCodeAt(i);
    hash = (hash << 5) - hash + c;
    hash |= 0;
  }
  return hash;
}

The race condition in the original code occurred because component instances 2+ would try to use the script before it was loaded.

It also means that the codes I linked to above - which are heavily referenced in SO and blogs and tutorials - are badly flawed.

In this fixed code:

  • it is idempotent: it can be safely called multiple times for the same src
  • component instance 1: once the script loads, the loader sets the data-loaded attribute on the script tag, and dispatches the componentLoaded event
  • component instances 2+: if script tag exists with attribute then script is "loaded" so return to blazor, else script is "loading" so listen for componentLoaded event (only once) then return to blazor
  • the script tag's id is set to a hash of its src , which allows component instances 2+ to search for the tag
  • after returning from the script loader, the blazor code can be certain that the script is loaded, so no further action is necessary

Only room for improvement (see code comment above) is to wrap the event listener in a timer, and timeout after a few seconds then reject() . I'd do that in c#, but js is single threaded, so I doubt that sort of race condition could occur, and so don't think it's necessary.

This works perfectly, for many concurrent instances of a component:

<MyComponent Foo="spam" />
<MyComponent Foo="ham" />
<MyComponent Bar="1" />
<MyComponent Bar="2" />
<MyComponent Baz="a" />
<MyComponent Baz="b" />

And the component is self-contained, so no need to reference scripts or anything like that. This makes it very convenient for centralising components in a separate library - just reference the nuget and use the components... no extra effort required!

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM