簡體   English   中英

Blazor 加載自己腳本的組件

[英]Blazor component which loads its own script

我的自定義 blazor 組件使用外部腳本(托管在 CDN 上)。 通常人們會在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);
  });
}

然后我創建了一個組件,我想在其中加載自定義腳本。

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!");
}

這樣可行。 我可以使用<FooComponent />它會加載自己的腳本文件。

但是,如果我多次使用該組件,就會遇到競爭條件:

  • 組件的實例 1
    • 嘗試加載腳本
    • 腳本加載器加載腳本
    • 組件可以使用它
  • 組件的實例 2+
    • 它們與實例 1 同時加載!
    • 每個嘗試加載腳本,但加載器拒絕加載它不止一次
    • 他們立即嘗試使用該腳本 - 但它仍在忙於加載!
    • 所以他們都失敗了,應用程序崩潰了(異常等)

在使用腳本之前,如何重構代碼以確保腳本實際上已完成加載?

您的問題的關鍵是確保:

  1. 只有一次嘗試加載腳本
  2. 在加載之前不會調用任何方法。

實現此目的的一種方法是使用每個 SPA session 的單個進程來管理和調用腳本。

這是一個使用服務來管理流程的解決方案。 它使用基於任務的異步進程,利用TaskCompletionSource來確保在嘗試調用腳本方法之前加載腳本文件。

首先是兩個腳本(都在 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);
    });
}

並在需要腳本時加載CDN

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

接下來是一個 Scoped Service class 來管理腳本調用過程。 使其盡可能通用。

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);
    }
}

如果在單獨的庫中,請創建一個擴展方法以簡化加載:

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

程序:

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

這是一個簡單的測試組件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);
}

我的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" />

原來這是 javascript 問題。 下面的代碼適用於多個並發組件。

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;
}

原始代碼中出現競爭條件是因為組件實例 2+ 會在加載腳本之前嘗試使用該腳本。

這也意味着我上面鏈接的代碼——在 SO 和博客和教程中被大量引用——存在嚴重缺陷。

在此固定代碼中:

  • 它是冪等的:它可以為同一個src安全地調用多次
  • 組件實例 1:腳本加載后,加載程序在腳本標簽上設置data-loaded屬性,並調度componentLoaded事件
  • 組件實例 2+:如果存在帶有屬性的腳本標簽,則腳本被“加載”,因此返回 blazor,否則腳本正在“加載”,因此監聽componentLoaded事件(僅一次),然后返回 blazor
  • 腳本標簽的id設置為其src的 hash,這允許組件實例 2+ 搜索標簽
  • 從腳本加載器返回后,blazor代碼可以確定腳本已經加載,所以不需要進一步的操作

唯一的改進空間(參見上面的代碼注釋)是將事件偵聽器包裝在計時器中,並在幾秒后超時然后reject() 我會在 c# 中這樣做,但是 js 是單線程的,所以我懷疑會出現這種競爭條件,因此認為沒有必要。

對於組件的許多並發實例,這非常有效:

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

而且該組件是獨立的,因此無需引用腳本或類似的東西。 這使得將組件集中在一個單獨的庫中非常方便 - 只需引用 nuget 並使用這些組件......無需額外的努力!

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM