在 Blazor 中,有没有办法在不更改状态的情况下撤消无效的用户输入?

In Blazor, is there a way to undo invalid user input, without changing the state?

在 Blazor 中,如何撤消无效的用户输入,而不更改组件的状态以触发重新呈现?

这是一个简单的 Blazor 计数器示例 (try it online):

<label>Count:</label>
<button @onclick=Increment>@count times!</button><br>
A: <input @oninput=OnChange value="@count"><br>
B: <input @bind-value=count @bind-value:event="oninput">

@code {
    int count = 1;

    void Increment() => count++;

    void OnChange(ChangeEventArgs e)
    {
        var userValue = e.Value?.ToString(); 
        if (int.TryParse(userValue, out var v))
        {
            count = v;
        }
        else 
        {
            if (String.IsNullOrWhiteSpace(userValue))
            {
                count = 0;
            }

            // if count hasn't changed here,
            // I want to re-render "A"

            // this doesn't work
            e.Value = count.ToString();

            // this doesn't work either 
            StateHasChanged();           
       }
    }
}

对于 input 元素 A,我想复制 input 元素 B 的行为,但不使用 bind-xxx 样式的数据绑定属性。

例如,当我在 A 中键入 123x 时,我希望它自动恢复为 123,就像在 B 中一样。

我试过 StateHasChanged 但它不起作用,我想,因为 count 属性 实际上并没有改变。

所以,基本上我需要重新渲染 A 来撤消无效的用户输入,即使状态没有改变。如果没有 bind-xxx 魔法,我该怎么做?

当然,bind-xxx 很棒,但在某些情况下可能需要非标准行为,围绕 ChangeEvent.

等托管事件处理程序构建

已更新,为了比较,这是我在 React 中的做法 (try it online):

function App() {
  let [count, setCount] = useState(1);
  const handleClick = () => setCount((count) => count + 1);
  const handleChange = (e) => {
    const userValue = e.target.value;
    let newValue = userValue ? parseInt(userValue) : 0;
    if (isNaN(newValue)) newValue = count;
    // re-render even when count hasn't changed
    setCount(newValue); 
  };
  return (
    <>
      Count: <button onClick={handleClick}>{count}</button><br/>
      A: <input value={count} onInput={handleChange}/><br/>
    </>
  );
}

此外,这是我在 Svelte 中的实现方式,我发现它在概念上非常接近 Blazor (try it online)。

<script>
  let count = 1;
  const handleClick = () => count++;
  const handleChange = e => {
    const userValue = e.target.value;
    let newValue = userValue? parseInt(userValue): 0;
    if (isNaN(newValue)) newValue = count;
    if (newValue === count)
      e.target.value = count; // undo user input
    else
      count = newValue; 
    }
  };    
</script>

Count: <button on:click={handleClick}>{count}</button><br/>
A: <input value={count} on:input={handleChange}/><br/>

已更新,澄清一下,我只是想撤消任何我认为无效的输入,追溯发生后,通过处理更改事件,而不改变组件的状态本身(counter 这里)。

也就是说,没有 Blazor 特定的双向数据绑定,HTML 本机 type=number 或模式匹配属性。我这里只是简单的以数字格式要求为例;我希望能够撤销 any 这样的任意输入。

我想要的用户体验(通过 JS 互操作 hack 完成):https://blazorrepl.telerik.com/wPbvcvvi128Qtzvu03

令我惊讶的是,与其他框架相比,Blazor 中的这一点如此困难,而且我无法使用 StateHasChanged 简单地强制重新呈现处于当前状态的组件。

这是您的代码的修改版本,可以满足您的要求:

@page "/"
<label>Count:</label>
<button @onclick=Increment>@count times!</button>
<br>
A:
<input @oninput=OnChange value="@count">
<br>
B:
<input @bind-value=count @bind-value:event="oninput">

@code {
    int count = 1;

    void Increment() => count++;

    async Task OnChange(ChangeEventArgs e)
    {
        var oldvalue = count;
        var isNewValue = int.TryParse(e.Value?.ToString(), out var v);
        if (isNewValue)
            count = v;
        else
        {
            count = 0;
            // this one line may precipitate a few commments!
            await Task.Yield();
            count = oldvalue;
        }

    }
}

所以“发生了什么事?”

首先 razor 代码被预编译成 C# 类,所以你看到的并不是实际得到的代码 运行。这里就不细说了,网上有很多文章。

value="@count" 是一种单向绑定,并作为字符串传递。

您可以更改屏幕上输入的实际值,但在 Blazor 组件中该值仍然是旧值。没有回调告诉它其他情况。

当您在 22 之后键入 22x 时,OnChange 不会更新 count。就渲染器而言,它没有改变,所以它不需要更新 DOM 的那个位。渲染器 DOM 和实际 DOM!

不匹配

OnChange 更改为 async Task 现在:

  • 获取旧值的副本
  • 如果新值是一个数字更新 count
  • 如果不是数字
  1. count 设置为另一个值 - 在本例中为零。
  2. 产量。 Blazor 组件事件处理程序调用 StateHasChanged 并产生。这使渲染器线程有时间为其队列提供服务并重新渲染。输入暂时为零。
  3. count 设置为旧值。
  4. Returns 任务完成。 Blazor 组件事件处理程序 运行 完成第二次调用 StateHasChanged。渲染器更新显示值。

更新为什么使用 Task.Yield

基本的 Blazor 组件事件处理程序 [BCEH from this point] 如下所示:

var task = InvokeAsync(EventMethod);
StateHasChanged();
if (!task.IsCompleted)
{
    await task;
    StateHasChanged();
}

OnChange 放入此上下文中。

var task = InvokeAsync(EventMethod)运行s OnChange。同步开始 运行。

如果 isNewValue 为假,它会将 count 设置为 0,然后通过 Task.Yield 将未完成的任务传递回 BCEH 来产生。然后可以进行 运行s StateHasChanged 将渲染片段排队到渲染器的队列中。请注意,它实际上并不渲染组件,只是将渲染片段排队。此时 BCEH 正在占用线程,因此渲染器实际上无法为其队列提供服务。然后它检查 task 看它是否完成。

如果 BCEH 完成,渲染器将获得一些线程时间并渲染组件。

如果它仍然是 运行ning - 就像我们用 Task.Yield 将它踢到线程队列的后面一样 - BCEH 等待它并产生。渲染器获取一些线程时间并渲染组件。 OnChange 然后完成,BCEH 获得一个已完成的任务,通过调用 StateHasChanged 将另一个渲染堆叠在渲染队列中并完成。渲染器,现在有了线程时间服务,它正在排队并第二次渲染组件。

请注意,有些人更喜欢使用 Task.Delay(1),因为有人讨论了 Task.Yield 的确切工作原理!

下面是我想出的(try online). I wish ChangeEventArgs.Value 两种方法都有效,但没有。没有它,我只能想到这个 JS.InvokeVoidAsync hack:

@inject IJSRuntime JS

<label>Count:</label>
<button @onclick=Increment>@count times!</button>
<br>
A:
<input @oninput=OnChange value="@count" id="@id">
<br>
B:
<input @bind-value=count @bind-value:event="oninput">

@code {
    int count = 1;
    string id = Guid.NewGuid().ToString("N");

    void Increment() => count++;

    async Task OnChange(ChangeEventArgs e)
    {
        var userValue = e.Value?.ToString(); 
        if (int.TryParse(userValue, out var v))
        {
            count = v;
        }
        else 
        {
            if (String.IsNullOrWhiteSpace(userValue))
            {
                count = 0;
            }

            // this doesn't work
            e.Value = count.ToString();

            // this doesn't work either (no rererendering):
            StateHasChanged();           

            // using JS interop as a workaround 
            await JS.InvokeVoidAsync("eval",
                $"document.getElementById('{id}').value = Number('{count}')");
        }
    }
}

明确地说,我意识到这是一个可怕的黑客攻击(最后但并非最不重要的一点,因为它使用 eval);我可以通过使用 ElementReference 和一个独立的 JS 导入来改进它,但如果 e.Value = count 正常工作,那么所有这些都不是必需的,like it does with Svelte. I've raised this as a Blazor issue in ASP.NET repo,希望它能引起一些关注。

从代码来看,您似乎想要清理用户输入?例如。只能输入数字、日期格式等...我同意如果你现在想在这方面手动使用事件处理程序有点困难,但仍然可以使用扩展属性和 Blazor 的绑定系统来验证输入。

你想要的看起来像这样:(try it here)

<label>Count: @Count</label>
<button @onclick=Increment>@Count times!</button><br>
<input @bind-value=Count @bind-value:event="oninput">

@code {
    int _count = 1;
    public string Count 
    {
        get => _count.ToString();
        set 
        {
            if(int.TryParse(value, out var val)) {
                _count = val;
            }
            else {
                if(string.IsNullOrEmpty(value)) {
                    _count = 0;
                }
            }
        }
    }

    void Increment() => _count++;
}

您可以使用 @on{DOM EVENT}:preventDefault 来阻止事件的默认操作。有关详细信息,请参阅 Microsoft 文档:https://docs.microsoft.com/en-us/aspnet/core/blazor/components/event-handling?view=aspnetcore-6.0#prevent-default-actions

更新

使用 preventDefault 的示例

<label>Count:</label>
<button @onclick=Increment>@count times!</button><br />
<br>
A: <input value="@count" @onkeydown=KeyHandler @onkeydown:preventDefault><br>
B: <input @bind-value=count @bind-value:event="oninput">

@code {
    int count = 1;
    string input = "";

    void Increment() => count++;

    private void KeyHandler(KeyboardEventArgs e)
    {
        //Define your pattern
        var pattern = "abcdefghijklmnopqrstuvwxyz";
        if (!pattern.Contains(e.Key) && e.Key != "Backspace")
        {
            //This is just a sample and needs exception handling
            input = input + e.Key;
            count = int.Parse(input);
        }
        if (e.Key == "Backspace")
        {
            input = input.Remove(input.Length - 1);
            count = int.Parse(input);
        }
    }
}

您将不得不使用 @on{DOM EVENT}-preventDefault

它来自 javascript 世界,它阻止了按钮的默认行为。

在 blazor 和 razor 中,验证或 DDL 触发回发,这意味着请求由服务器处理并重新呈现。

执行此操作时,您需要确保您的事件不会 'bubble' 因为您正在阻止事件执行 postback

如果你发现你的事件冒泡,意思是元素事件上升到它嵌套的元素,请使用:

stopPropagation="true";

了解更多信息:

Suppressing events in blazor

这是一个非常有趣的问题。我以前从未使用过 Blazor,但我知道什么可能对这里有帮助,尽管这也是一个 hacky 答案。

我注意到如果您将元素更改为绑定到 count 变量,它会在控件失去焦点时更新值。所以我添加了一些代码来交换焦点元素。这似乎允许在不更改输入字段的情况下键入非数字字符,这是我认为这里需要的。

显然,这不是一个很好的解决方案,但我想我会提供它以防万一。

<label>Count:</label>
<button @onclick=Increment>@count times!</button><br>
A: <input @oninput=OnChange @bind-value=count @ref="textInput1"><br>
B: <input @bind-value=count @bind-value:event="oninput" @ref="textInput2">

@code {
    ElementReference textInput1;
    ElementReference textInput2;
    int count = 1;

    void Increment() => count++;

    void OnChange(ChangeEventArgs e)
    {
        var userValue = e.Value?.ToString(); 
        if (int.TryParse(userValue, out var v))
        {
            count = v;
        }
        else 
        {
            if (String.IsNullOrWhiteSpace(userValue))
                count = 0;
                
            e.Value = count.ToString();

            textInput2.FocusAsync();        
            textInput1.FocusAsync();
        }
    }
}

So, basically I need to re-render A to undo invalid user input, even thought the state hasn't changed. How can I do that without the bind-xxx magic?

您可以通过更改 @key 指令中的值强制重​​新创建和重新呈现任何 element/component:

<input @oninput=OnChange value="@count" @key="version" />
void OnChange(ChangeEventArgs e)
{
    var userValue = e.Value?.ToString(); 
    if (int.TryParse(userValue, out var v))
    {
        count = v;
    }
    else 
    {
        version++;
        if (String.IsNullOrWhiteSpace(userValue))
        {
            count = 0;
        }         
   }
}

注意,它将重新呈现整个元素(及其子树),而不仅仅是属性。


您的代码存在问题,您组件的 BuildRenderrTree 方法生成了完全相同的 RenderTree,因此 diffing 算法在实际 dom.

中找不到任何要更新的内容

那么为什么 @bind 指令有效?

注意生成的 BuildRenderTree 代码:

//A
__builder.OpenElement(6, "input");
__builder.AddAttribute(7, "oninput", Microsoft.AspNetCore.Components.EventCallback.Factory.Create<Microsoft.AspNetCore.Components.ChangeEventArgs>(this, OnChange));
__builder.AddAttribute(8, "value", count);
__builder.CloseElement();

//B
__builder.AddMarkupContent(9, "<br>\r\nB: ");
__builder.OpenElement(10, "input");
__builder.AddAttribute(11, "value", Microsoft.AspNetCore.Components.BindConverter.FormatValue(count));
__builder.AddAttribute(12, "oninput", Microsoft.AspNetCore.Components.EventCallback.Factory.CreateBinder(this, __value => count = __value, count));
__builder.SetUpdatesAttributeName("value");
__builder.CloseElement();

诀窍是,@bind 指令添加:

__builder.SetUpdatesAttributeName("value");

您现在不能在 EventCallback 的标记中执行此操作,但有一个未解决的问题:https://github.com/dotnet/aspnetcore/issues/17281

但是您仍然可以创建一个 Component 或 RenderFragment 并手动编写 __builder 代码。