解决Blazor组件自包含加载CDN外部脚本的竞态问题
解决Blazor组件多实例下的外部脚本加载竞态问题
问题场景
开发自定义Blazor组件时,需要依赖托管在CDN的外部脚本,为了保持组件自包含,不在index.html中提前引用该脚本。使用了常见的脚本加载器实现,但当页面中多次使用该组件时,出现了竞态问题:
- 第一个组件实例触发脚本加载后,加载器标记该脚本为"已加载/加载中"
- 后续组件实例调用加载器时,直接得到已完成的Promise,随即调用脚本中的方法,但此时脚本实际还在加载过程中,导致应用崩溃抛出异常
原脚本加载器代码:
let _loadedScripts = []; // 记录加载中/已加载的脚本 export async function loadScript(src) { // 只加载一次脚本 if (_loadedScripts[src]) return Promise.resolve(); return new Promise(function(resolve, reject) { let tag = document.createElement('script'); tag.type = 'text/javascript'; tag.src = src; // 标记脚本为加载中/已加载 _loadedScripts[src] = true; tag.onload = function() { resolve(); } tag.onerror = function() { console.error('脚本加载失败。'); reject(src); } document.body.appendChild(tag); }); }
原Blazor组件调用代码:
@inject IJSRuntime _js // 其他代码... protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); if (firstRender) { // 通过加载器加载组件依赖脚本 await _js.InvokeVoidAsync("loadScript", "https://cdn.example.com/foo.min.js"); } // 调用脚本中的doFoo方法 await _js.InvokeVoidAsync("doFoo", "hello world!"); }
问题根源
原加载器中,_loadedScripts[src]被设置为true的时机是在创建script标签之后、脚本实际加载完成之前。这导致后续组件调用loadScript时,误以为脚本已经加载完成,直接返回resolved的Promise,进而提前调用了脚本中的方法,此时脚本尚未加载完成,自然会抛出异常。
解决方案
1. 重构脚本加载器
修改_loadedScripts的存储逻辑,不再存储布尔值,而是存储脚本加载的Promise本身。这样所有组件实例都会等待同一个Promise完成,确保脚本真正加载完成后再执行后续逻辑:
let _loadedScripts = {}; // 存储脚本对应的加载Promise export async function loadScript(src) { // 如果已有加载中的Promise,直接返回它 if (_loadedScripts[src]) { return _loadedScripts[src]; } const loadPromise = new Promise(function(resolve, reject) { let tag = document.createElement('script'); tag.type = 'text/javascript'; tag.src = src; tag.onload = function() { resolve(); } tag.onerror = function() { console.error('脚本加载失败。'); // 加载失败时移除缓存的Promise,允许后续重试 delete _loadedScripts[src]; reject(src); } document.body.appendChild(tag); }); // 将Promise存入缓存 _loadedScripts[src] = loadPromise; return loadPromise; }
2. 调整Blazor组件调用逻辑
确保每次调用脚本方法前,都等待loadScript完成,而不是仅在首次渲染时加载脚本。这样即使组件多次渲染,也能保证脚本就绪后再执行方法:
@inject IJSRuntime _js // 其他代码... protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); // 确保脚本加载完成后再调用方法 await _js.InvokeVoidAsync("loadScript", "https://cdn.example.com/foo.min.js"); await _js.InvokeVoidAsync("doFoo", "hello world!"); }
如果希望仅在首次渲染时触发加载(后续复用已加载的脚本),也可以保留firstRender判断,但必须确保方法调用在loadScript之后:
@inject IJSRuntime _js // 其他代码... protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); if (firstRender) { // 首次渲染时加载脚本 await _js.InvokeVoidAsync("loadScript", "https://cdn.example.com/foo.min.js"); } else { // 非首次渲染时,仍需等待脚本加载完成(处理竞态场景) await _js.InvokeVoidAsync("loadScript", "https://cdn.example.com/foo.min.js"); } await _js.InvokeVoidAsync("doFoo", "hello world!"); }
或者更简洁的写法,不管是否首次渲染,都调用loadScript——因为加载器已经做了缓存,重复调用不会重复加载脚本,只会等待已有的Promise完成:
@inject IJSRuntime _js // 其他代码... protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); // 不管是否首次渲染,都等待脚本加载完成 await _js.InvokeVoidAsync("loadScript", "https://cdn.example.com/foo.min.js"); await _js.InvokeVoidAsync("doFoo", "hello world!"); }
内容的提问来源于stack exchange,提问作者lonix
相关产品推荐
相关产品推荐

