简体   繁体   English

Blazor 加载自己脚本的组件

[英]Blazor component which loads its own script

My custom blazor component uses an external script (hosted on a CDN).我的自定义 blazor 组件使用外部脚本(托管在 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.通常人们会在index.html中引用它,但我不想那样做——我希望该组件是独立的并且易于使用。

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 ): wwwroot/js/scriptLoader.js (在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 : 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.我可以使用<FooComponent />它会加载自己的脚本文件。

But if I use the component multiple times, I run into a race condition:但是,如果我多次使用该组件,就会遇到竞争条件:

  • instance 1 of the component组件的实例 1
    • tries to load the script尝试加载脚本
    • script loader loads the script脚本加载器加载脚本
    • the component can use it组件可以使用它
  • instances 2+ of the component组件的实例 2+
    • they are loading at the same time as instance 1!它们与实例 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.实现此目的的一种方法是使用每个 SPA session 的单个进程来管理和调用脚本。

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.它使用基于任务的异步进程,利用TaskCompletionSource来确保在尝试调用脚本方法之前加载脚本文件。

First the two scripts (both in wwwroot/js/):首先是两个脚本(都在 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.并在需要脚本时加载CDN

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

Next a Scoped Service class to manage the script calling process.接下来是一个 Scoped Service class 来管理脚本调用过程。 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这是一个简单的测试组件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 :我的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.原来这是 javascript 问题。 The code below works for multiple concurrent components.下面的代码适用于多个并发组件。

wwwroot/js/scriptLoader.js (referenced in index.html ): wwwroot/js/scriptLoader.js (在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.原始代码中出现竞争条件是因为组件实例 2+ 会在加载脚本之前尝试使用该脚本。

It also means that the codes I linked to above - which are heavily referenced in SO and blogs and tutorials - are badly flawed.这也意味着我上面链接的代码——在 SO 和博客和教程中被大量引用——存在严重缺陷。

In this fixed code:在此固定代码中:

  • it is idempotent: it can be safely called multiple times for the same src它是幂等的:它可以为同一个src安全地调用多次
  • component instance 1: once the script loads, the loader sets the data-loaded attribute on the script tag, and dispatches the componentLoaded event组件实例 1:脚本加载后,加载程序在脚本标签上设置data-loaded属性,并调度componentLoaded事件
  • 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组件实例 2+:如果存在带有属性的脚本标签,则脚本被“加载”,因此返回 blazor,否则脚本正在“加载”,因此监听componentLoaded事件(仅一次),然后返回 blazor
  • the script tag's id is set to a hash of its src , which allows component instances 2+ to search for the tag脚本标签的id设置为其src的 hash,这允许组件实例 2+ 搜索标签
  • after returning from the script loader, the blazor code can be certain that the script is loaded, so no further action is necessary从脚本加载器返回后,blazor代码可以确定脚本已经加载,所以不需要进一步的操作

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() .唯一的改进空间(参见上面的代码注释)是将事件侦听器包装在计时器中,并在几秒后超时然后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.我会在 c# 中这样做,但是 js 是单线程的,所以我怀疑会出现这种竞争条件,因此认为没有必要。

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!这使得将组件集中在一个单独的库中非常方便 - 只需引用 nuget 并使用这些组件......无需额外的努力!

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

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