在 Blazor 中,如何在不更改组件状态以触发重新渲染的情况下撤消无效的用户输入?
下面是一个简单的 Blazor 计数器示例(在线试用):
<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,我想复制元素 Binput
的行为,但不要使用bind-xxx
样式的数据绑定属性。
例如,当我在 A 中键入123x
时,我希望它自动恢复为123
,就像 B 发生的那样。
我已经尝试过StateHasChanged
但它不起作用,我想,因为count
属性实际上并没有改变。
所以,基本上我需要重新渲染 A 来撤消无效的用户输入,即使认为状态没有改变。没有bind-xxx
魔法,我怎么能做到这一点?
当然,bind-xxx
很好,但在某些情况下,可能需要围绕托管事件处理程序(如ChangeEvent
)构建的非标准行为。
更新,为了比较,这是我如何在 React 中做到这一点(在线尝试):
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(在线尝试)。
<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
或模式匹配属性。我在这里简单地使用数字格式要求作为示例;我希望能够撤消任何这样的任意输入。
我想要的用户体验(通过 JS 互操作黑客完成):https://blazorrepl.telerik.com/wPbvcvvi128Qtzvu03
与其他框架相比,Blazor 中对此感到惊讶,而且我无法使用StateHasChanged
简单地强制在当前状态下重新渲染组件。
您可以使用@on{DOM EVENT}:preventDefault
来阻止事件的默认操作。有关更多信息,请查看Microsoft文档:https://learn.microsoft.com/en-us/aspnet/core/blazor/components/event-handling?view=aspnetcore-6.0#prevent-default-actions
更新
使用阻止默认的示例
<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);
}
}
}
所以,基本上我需要重新渲染 A 来撤消无效的用户输入,即使 以为状态没有改变。没有 绑定-xxx魔法?
您可以通过更改指令中的值来强制重新创建和重新渲染@key
元素/组件:
<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;
}
}
}
请注意,它将重新呈现整个元素(及其子树),而不仅仅是属性。
代码的问题在于,组件的 BuildRendrerTree 方法生成完全相同的 RenderTree,因此差异算法在实际 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>rnB: ");
__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
但是,您仍然可以创建组件或渲染片段并手动编写__builder
代码。
下面是代码的修改版本,可以执行您想要的操作:
@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
。 - 如果不是数字
- 将
count
设置为另一个值 - 在本例中为零。 - 收益 率。 Blazor 组件事件处理程序调用
StateHasChanged
并生成。 这使渲染器线程有时间为其队列提供服务并重新渲染。 输入暂时为零。 - 将
count
设置为旧值。 - 返回任务完成。 Blazor 组件事件处理程序运行到完成调用
StateHasChanged
第二次。 渲染器更新显示值。
更新为什么使用 Task.Yield
基本的 Blazor 组件事件处理程序 [BCEH 从这一点开始] 如下所示:
var task = InvokeAsync(EventMethod);
StateHasChanged();
if (!task.IsCompleted)
{
await task;
StateHasChanged();
}
把OnChange
放在这个背景下。
var task = InvokeAsync(EventMethod)
运行OnChange
. 开始同步运行。
如果isNewValue
为 false,则将其设置为count
0,然后通过Task.Yield
将未完成的任务传递回 BCEH 来产生。 然后,这可以进行并运行StateHasChanged
将渲染片段排队到渲染器的队列中。 请注意,它实际上并不渲染组件,只是将渲染片段排队。 此时,BCEH 正在占用线程,因此渲染器实际上无法为其队列提供服务。然后,它会检查task
以查看是否已完成。
如果完成 BCEH 完成,渲染器将获得一些线程时间并呈现组件。
如果它仍在运行 - 它将是我们使用 Task.Yield 将其踢到线程队列的后面 - BCEH 等待它并产生。 渲染器获取一些线程时间并渲染组件。 然后OnChange
完成,BCEH 将获得一个已完成的任务,在渲染的队列上堆叠另一个渲染,并调用StateHasChanged
并完成。 渲染器,现在使用线程时间服务,它排队并再次渲染组件。
请注意,有些人更喜欢使用Task.Delay(1)
,因为有一些关于Task.Yield
如何工作的讨论!
从代码来看,您似乎要清理用户输入? 例如,只能输入数字,日期格式等...我同意,如果目前想在这方面手动使用事件处理程序,这有点困难,但仍然可以使用扩展的属性和 Blazor 的绑定系统验证输入。
你想要的看起来像这样:(在这里试试)
<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
.
它来自javascript世界,它阻止了按钮的默认行为。
在 blazor 和 razor 中,验证或 DDL 触发器回发,这意味着请求由服务器处理并重新呈现。
执行此操作时,需要确保事件不会"冒泡",因为要阻止事件执行回发。
如果你发现你的事件冒泡,这意味着元素事件上升到它嵌套的元素,请使用:
stopPropagation="true";
欲了解更多信息:
禁止显示 blazor 中的事件
这是一个非常有趣的问题。我以前从未使用过 Blazor,但我有一个想法,知道这里可能会有什么帮助,尽管这也是一个笨拙的答案。
我注意到如果您将元素更改为绑定到 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();
}
}
}
以下是我想出的(在线尝试)。我希望ChangeEventArgs.Value
双向工作,但事实并非如此。没有它,我只能想到这个JS.InvokeVoidAsync
黑客:
@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
只是工作,所有这些都不是必需的,就像它对 Svelte 所做的那样。我已经在存储库中将此作为 Blazor 问题提出 ASP.NET 希望它可能会引起一些关注。