Blazor教程 第四课:UI程序的本质:前台要渲染数据、后台要收集行为

Blazor

1. 瞎扯一下UI程序的本质

我说的UI程序包括但不限于:Web应用、移动端应用、GUI应用等。

其实简单想一想,UI程序做的事情其实主要分两种:

  1. 数据从后台到前台:渲染成人眼能看到的东西。

    举个例子:你打开微信,映入眼帘的就是你最近聊天的列表。这些人、头像、聊天记录等,本质上是腾讯存储在数据中心的数据,而这些数据以某种方式,可以是客户端主动请求,也可以是在某种通讯协议下服务端主动推送,总之,数据从“后台”被传输到了“前台”,然后“前台”把这些数据绘制成手机屏幕上的各种样子,把文字渲染出来,把头像放在正确的位置上,等等。

    这个例子可以类比到任何UI应用上,就比如我这个博客网站,它虽然是一个纯静态网站,但其实也能分出所谓的“前台”与“后台”:“后台”就是托管这个博客网站的Azure虚机上的文件目录,“前台”就是用户的浏览器以及我写的一些前端代码嘛。“后台”里的静态站点资源文件,以“浏览器请求,然后Nginx负责回应传输”的方式传递到浏览器。然后“前台”,既是用户的浏览器+我写的前端代码,把这些数据渲染成你现在看到的样子。

  2. 数据从前台到后台:前台把用户的行为送给后台,

    再举个例子:你打开微信的聊天窗口,打了一段文字,点击发送按钮,这个消息就被发到了你好友那边。在这个过程中,“前台”,即是手机上的微信客户端,要收集两个事情:

    1. 用户的行为:点击发送按钮
    2. 与该行为关联的数据:聊天框里待发送的数据、发送的对象、按下发送的时间等等等等

    客户端收集到“行为”与“行为数据”后,会把这些数据传递给“后台”:微信的服务端收到后,才会去执行真正的所谓的“发消息”的行为(执行业务逻辑)。最终把执行的结果再反馈给客户端。

    在客户端收到服务端的执行结果后,会把执行结果再渲染在用户的手机屏幕上。这其实也是一个“数据从后台到前台”的过程。

    在UI开发过程中,我们一般把这个“行为”叫Event,而与行为关联的数据,就是所谓的EventArgs。这两个概念几乎在所有的开发框架里都有,并且几乎都叫"event"和"event args",我这里再举几个例子:

    1. 行为是“点击按钮”,数据是“点击的坐标、点击时使用的鼠标按键是哪个、点击是是否同时按下了键盘上的其它按键等”
    2. 行为是“鼠标移动”,数据是“移动起始时间、轨迹信息”

1所描述的,就是“数据渲染”,2所描述的,就是"事件处理"。你细想就会发现,所有的UI程序,甚至再宽泛一点,所有与人有交互的计算机程序,其实都是在做这两件事情。

“数据渲染”这件事里,前台的责任最大,这是非常显然的事情。“事件处理”这件事里,前台后台的责任可以说是一半一半:前台要收集事件数据,而后台要处理真正的业务逻辑。

Web应用也是一种UI程序,Web应用做的事情说穿了也是“数据渲染”与“事件处理”,但有意思的是,“前台”和“后台”的定义,在不同的语境下,可能代表的不是一个东西。

以现在流行的前端三架马车React、Vue、Angular来说,再拉上Blazor WASM,如果我们把目光仅局限于前端开发里,那么当我们讨论“数据渲染”和“事件处理”时,前台和后台其实都在用户的浏览器中:

  1. 在“数据渲染”中,前台其实指的是浏览器的DOM系统与渲染系统,后台指的其实是浏览器内存中的数据,这些数据在流行框架的语境中,其实就是浏览器JS进程里的JS对象实例。
  2. 在“事件处理”中,前台其实指的是用户的浏览器渲染出来的可视化页面,其实还是浏览器,而后台指的其实还是浏览器内存中的数据:所谓的“执行业务逻辑”,在这个语境中,可能指的是“前端代码调用服务端API并获取数据”,和服务端是无关的。

而当我们讨论C/S架构的UI程序和Blazor Server这种东西时,还要带上服务端一起玩的话,“前台”和“后台”的定义,就会被扩展一些:

  1. 在“数据渲染”中,前台指的是UI客户端部分,在Blazor Server里,指的是那个运行在浏览器的dotnet runtime中的客户端部分,而“后台”,就真的指的是“服务端”:服务端的数据传输到客户端,然后由客户端渲染成人眼可见的样子
  2. 在“事件处理”中,前台依然是客户端部分,后台也是真正的服务端。处理业务逻辑的含义是,服务端要根据事件数据,去执行数据库的增删改查。

而换个角度来看,我们在讨论JS前端框架与Blazor WASM的时候,其实也可以把服务端带上,可以把浏览器中的JS进程服务端两个捏在一起,看作是“后台”

  1. 数据渲染,后台既包括服务端、数据库,也包括用户浏览器JS进程中的JS对象,以及JS进程与服务端通信的整个过程。
  2. 事件处理,“后台执行业务逻辑”的过程,包括了:前端代码调用服务端API、服务端程序指挥存储层做增删改查。

要学习Blazor框架,一定要把这些逻辑概念理清楚了,要明白在不同的语境下,讨论的“前台”与“后台”分别是什么。不然在Blazor WASM和Blazor Server之间反复横跳时,会脑袋抽筋。。我深知上面的逻辑概念都是一些非常浅显的知识,但我同时也确实担心很多人确实没搞清楚。

比如我们已经接触过了,在Blazor组件中,使用@(xxx)就可以把“变量渲染在页面上”,这句话,在Blazor WASM中,这个“变量”,指的是前端项目中的变量,这个变量传递给浏览器的时候不涉及网络通信。在Blazor Server中,这个“变量”,指的是服务端的那个变量,这个变量要传递给浏览器渲染的话,是可能需要通过websocket执行网络通信的!

而如果我们再认真一点的话,如果你仔细看了前面几篇文章的话,你就会发现在Blazor Server里,“前台”这玩意其实不只是用户的浏览器:如果我们把vdom到浏览器的DOM渲染系统之间的映射一起看作是前台的话,那么Blazor Server里的“前台”囊括了用户的浏览器、运行在浏览器上的客户端程序、服务端的vdom系统、vdom与客户端程序的sync机制这整个一坨。

不过呢,如果上面我说的太绕了,让你感觉不适,也不必要过分纠结,如果我们换个视角来看“数据渲染”与“事件处理”的话,可以简单的把它们理解为:

  1. “数据渲染”就是把代码里的变量渲染到页面上
  2. “事件处理”就是写个回调函数。

不用过分纠结什么是前台,什么是后台,我们的变量在哪个位置哪个进程里,我们的回调函数接收的数据是谁发送来的,又历经了多少千山万水。

2. Blazor中的单向数据传递

2.1 数据渲染

数据渲染在Blazor WASM里指的是下图中的红色部分

在Blazor Server里指的是下图中的红色部分

书写的语法我们已经很熟悉了,简单来说就是@(xxx)。比如下面就是一个非常简单的例子,我们在页面上给Razor组件类分别声明三个东西:

  1. code块中用const修饰的常量字段,根据C#的语言规范,const字段的值是编译期已知,且运行期不可更改的。
  2. code块中不带const修饰的普通字段,根据C#的语言规范,字段需要经过构造函数(或语法糖)进行初始化赋值后方能使用(这里描述并不精确,但无需在此纠结)
  3. @{}块中声明且进行了初始化的本地变量。根据我们之前的介绍,这个变量其实是被声明在Razor类的BuildRenderTree方法内部的一个临时变量

我们在对上述三个变量/字段进行初始化后,对期进行了一次渲染。随后紧接着对两个非常量的变量/字段的值进行更改,进行了二次渲染。代码如下:

@page "/"

@code {
    const string constStrField = "This is a const string field";
    string strField = "This is a normal string field, initialized during declaration instead of constructor";
}

@{
    string strVar = "This is a local variable";
}

@* part 1 : render different type of variables *@

<br />
<p>constStrField == @(constStrField)</p>
<br />
<p>strField == @(strField)</p>
<br />
<p>strVar == @(strVar)</p>
<hr/>

@* part 2 : change the value of variables then render it *@

@{
    // this.constStrField = "Can not change the const constant";
    this.strField = "Change the value of string field";
    strVar = "Change the value of local variable";
}

<br />
<p>constStrField == @(constStrField)</p>
<br />
<p>strField == @(strField)</p>
<br />
<p>strVar == @(strVar)</p>
<hr/>

执行结果如下图所示:

一切看起来非常符合直觉,最符合直觉的是,我们在更改变量/字段的值之前与之后,有两次渲染结果,而两次渲染结果也分别是他们前后两种状态的值。

这看起来非常理所当然,没有什么值得说的。但如果你仔细思考一下就会发现,认真思考“变量的更改”,其实是把我们脑海中,关于“数据渲染”的模型,添加上了时间轴。

什么意思呢?在提起数据渲染的时候,大多数人脑海中会冒出下面这样一张图:

上面这个简单的模型描述的就是@(xxx)的功能,但仅描述了空间方面上,“内存中的变量/字段”与“页面上显示的东西”之间的关系,上面这个简单的模型图并没有描述出程序是随时间运行的,变量、字段里存储的值是可能改变的,也没有描述出代码的运行的先后顺序。总之就是仅描述了空间上“内存”与“页面”的单向映射,但没有描述时间上这个映射会不会变化,以及以如何规则变化。

而我们上面展示的示例代码的运行逻辑,如果加上时间轴的话,可以画成下面这张图:

这张图画的有点宽,如果看不清的话可以把图片拖到一个浏览器的新Tab里进行观看。上面这张图里的很多细节都是经不起推敲的,是高度简化劣化的,在一个框架使用者,而非研究者的角度,这样去理解是没有什么大问题的。

如果你看得明白上面这张带时间轴的图,就会意识到:@(xxx)的“数据渲染”,是一次性的:每一次在代码中书写@(xxx)时,它:

  • 代表的并不是永久性的把一个字段/变量与屏幕上的渲染结果做永久绑定
  • 而是:在执行@(xxx)的那个时刻,对内部变量/字段/表达式进行求值,并把求值结果放进V-DOM中去

这意味着,在一次渲染过后,如果变量/字段/表达式的值在内存中发生了变化,那么除非下列两种情况发生,否则新值不会显示在屏幕上:

  1. @(xxx)BuildRenderTree方法返回之前,后续被再次使用 : 并且注意,后续的再次调用只是在屏幕上另外一个地方显示了新值,之前已经渲染出的旧值,是不会改变的。
  2. 整个BuildRenderTree方法被重新执行,这其实也是@(xxx)被再次执行 : 这其实就是整个页面的“重新渲染”

再次强调:“数据渲染”是一次性的,不是“双向绑定”,字段/变量/表达式值在后续发生了变化的话,除非BuildRenderTree被再次执行,即除非页面重新渲染,否则屏幕上已有的内容不会进行更新。

下面就是一个生动的例子:我们在页面上声明一个本地变量,并对其进行初始化赋值,然后渲染。。有意思的是,我们设定了一个定时器,让这个变量的值每秒都+1。

@page "/"

@{
    int num = 73;
    var timer = new System.Threading.Timer(
        callback: _ => { num++; Console.WriteLine($"num == {num}"); },
        state: null,
        dueTime: TimeSpan.FromSeconds(1),
        period: TimeSpan.FromSeconds(1)
    );
}

<h1>num == @num</h1>

System.Threading.Timer不熟悉的话,我这里做一下简单介绍:这是dotnet系统类库中,线程库里的一个工具类,它的功能,你可以简单的理解为JS中的setTimeOut,但细节不太一样。

  1. callback参数指要定期执行的函数
  2. state是执行callback函数时要传入的参数,如果callback函数本身没有参数,就赋null
  3. dueTime是指,在Timer实例被初始化创建出来之后,距离第一次执行callback,之间的延迟时间
  4. period是指后续重复执行callback时,执行的间歇时间

所以上面的代码创建了一个:一秒后正式开始循环执行、每秒都会将本地变量num的值+1的一个Timer

并且在callback函数中,我们用Console.WriteLine将内存中num的值,输出到“控制台”上。。这里又是一个小知识点:我们现在写的是一个Blazor WASM页面,那么所谓的Console,其实就是浏览器的调试窗口的Console

好了,介绍完毕,下面来欣赏运行结果:

2.2 事件处理

2.2.1 事件处理概览

事件处理在Blazor WASM是指下图中的红色部分:

在Blazor Server里指的是下图中的红色部分:

我再次免责声明一下:上图是非常简化劣化的版本,不能深究,但不影响你作为框架的使用者去简单的理解。

事件处理中最重要的一个核心知识点就是:事件处理并不只是对回调函数的调用,还包括后续对BuildRenderTree的调用。

换句话说:事件回调,会触发页面重新渲染。这是理解Web开发框架中的事件处理的核心。实际上任何UI项目,任何UI框架中的事件处理,都会或多或少的触发“重新渲染”:对于Win32程序来说,是对屏幕的重绘,对于Web项目来说,是对DOM的更新。这里的核心逻辑是这样的:

  1. UI程序就像蛤蟆一样:只有人戳它的时候,它才会蹦跶。
  2. 没有外部干扰,没有用户事件的触发的话,UI没必要更新,因为程序的状态是静止的。
  3. 有外部输入的情况下,程序需要有选择性的去响应这些输入:设定回调函数。
  4. 当外部输入被响应后,即回调函数被执行后,程序的状态可能会发生改变。既然状态可能发生改变,那么UI上的展示就应该进行更新。所以大部分UI开发框架,都默认设计了一个行为:即在事件回调函数执行之后,对UI进行重绘。

当然你可能会说:并不是所有的回调函数都会去改变程序的状态,并不是所有的事件处理都有必要重绘UI。

是的,你说的没错,但站在框架设计者的角度上

  1. 框架设计者是无法事先确定,事件处理的回调函数会不会更改程序状态,有没有必要更新UI。所以框架设计者只能做最保守的设计:每次回调函数执行之后,都去更新UI
  2. 当然,更新整个程序的所有UI显然是一种非常浪费的行为,所以最显而易见的优化方向就是,尽量使得“重绘”,或者叫“重新渲染”这个动作,发生在尽量小的区域内

为了使实际“重绘”的区域尽可能的小,浏览器上的UI框架,包括React/Angular/Vue/Blazor在内的一众框架,殊途同归的选择了同一条技术路线:v-dom

我们可以简单的把v-dom的设计浓缩为两个重点:

  1. v-dom是一个内存中的树,树中的结点与浏览器的DOM树是一一对应的
  2. 框架设计了一套魔法,能非常高效、迅速的去探测v-dom这颗树中,是否有枝叶发生了变动

重点就在第2点上:框架可能自动化的探测v-dom中的变动,然后非常高效的,在底层渲染的时候,仅对这些变动的部分进行更新,而不是对所有UI都进行更新。

或者换个说法:框架会自动监视v-dom的任何风吹草动,并且以最优化的算法去最高效的保持v-dom与浏览器dom的同步。

话题有一点扯远了,但回到Blazor中,BuildRenderTree方法的执行,就是在更新v-dom树中的枝桠,所以逻辑上,Blazor中的代码是有四个层次的:

  1. 最顶层:是我们用Razor模板语言,其实就是C#混杂着HTML写出的Blazor Component
  2. 组件类层:我们的Blazor Component会被转译成一个个的C#类,类中的BuildRenderTree方法会被框架执行
  3. v-dom层:BuildRenderTree的执行会返回一颗子树,然后被嵌接在整个应用的v-dom树中的某个枝桠里
  4. 底层渲染引擎:时刻监视v-dom的任何风吹草动,以最高效的算法与效率保证v-dom与浏览器dom的同步

话题扯远了,收回一点:对于Blazor框架的使用者来说,现在只需要明确两个关键:

  1. BuildRenderTree的执行就是渲染
  2. 事件回调函数执行后,当前组件会被框架重新渲染

2.2.2 在代码中如何书写事件处理

这个其实我们之前就稍微接触过,下面是一个简单的例子:

@using Microsoft.AspNetCore.Components.Web

<h3>Counter: @count</h3>
<button @onclick=@(this.IncrCounter)>Incr Counter</button>


@code {
    private int count = 1;

    private void IncrCounter()
    {
        this.count++;
    }
}

这里有几个要点:

  1. @开头的特殊attribute,是为directive attribute,

    @onclick为例子,虽然它看起来像是HTML标签的一个属性,但实际上它是Razor模板语言中的魔改功能。它其实是一个定义在Microsoft.AspNetCore.Components.Web.EventHandlers静态类脑门上的一个C# Attribute。

    @onclick的定义大致如下所示:

namespace Microsoft.AspNetCore.Components.Web

// ...
[EventHandler("onclick", typeof(MouseEventArgs), true, true)]
// ...
public static class EventHandlers
{
}

我们不必过分细究上面这特殊的空静态类到底是怎么实现这个魔法的,以及directive attribute到底是如何定义的,只需要知道它是一个定义在C#中的东西就行了

  1. @onclick为例,它的值应当是一个EventCallback<MouseEventArgs>类型的实例。是的,没错,它其实是一个struct模板类。但我们依然不必过分关心一个函数指针是如何被框架包装成一个struct模板类的实例的,我们只需要知道,所谓的EventCallback<xxxx>其实就是指

    1. 一个成员方法,而不是静态函数
    2. 这个成员方法的参数列表要么为空,要么接收一个类型为xxxx类型的参数
    3. 这个成员方法返回值类型是void
  2. 由于#1,导致了我们必须在模板文件中引入Microsoft.AspNetCore.Components.Web这个命名空间后,Razor引擎才能正确识别出@onclick这种东西是一个directive attribute。

    官方模板一般会在_Imports.razor文件中为所有的Razor模板文件统一引入这个名称空间,所以这个问题一般也不是什么大问题。但如果你是跟我的文章一步步手动创建的项目,那么你就需要注意是否引入了这个名称空间了。

除了@onclick外,你可以在EventHandlers文档中找到更多的事件类型

2.2.3 默认行为与事件传播

上面我们说了,像@onclick这种东西,其实是定义在M.A.Components.Web.EventHandlers静态类脑门上的一个C# Attribute。

这个Attribute本身是Microsoft.AspNetCore.Components.EventHandlerAttribute,它有两个版本的构造函数,在M.A.Components.Web.EventHandlers脑门上用到的几乎都是第二个构造函数

public EventHandlerAttribute (string attributeName, Type eventArgsType, bool enableStopPropagation, bool enablePreventDefault);

第一个参数,事件名称,没什么可说的。

第二个参数,是事件回调函数的eventArgs的类型,也没什么可以的,容易理解。

第三个参数和第四个参数,就牵扯到两个概念:事件的传播与默认行为

框架定义的所有事件,这两个参数的值都是true:即默认情况下,事件是会传播的,如果有默认行为的话,默认行为也是会被执行的

这是什么意思呢?这就要回到前端的两个小知识点了:

什么是事件的默认行为?

举个粟子:我们用HTML写了一个<input>元素,它默认是一个文本输入框,它的默认行为是:如果当前的控件焦点在这个文本框上,那么用户在键盘上按什么按键,对应的文字内容就会出现在文本框里。如下图所示:

input_default_behavior

这就是@onkeypress事件的默认行为。如果我们想改写这个行为,比如用户每次敲击键盘的时候,让文本框里什么都不做,而只是把用户敲击的键输出在控制台上,你可能会想当然的写下如下代码:

@using Microsoft.AspNetCore.Components.Web

@page "/"

<input @onkeypress=@(this.OnKeyPress)/>

@code {
    private void OnKeyPress(KeyboardEventArgs eventArgs)
    {
        Console.WriteLine($"PRESSED: {eventArgs.Key}");
    }
}

你可能想着,我们这样做是不是就改写,或者覆盖@onkeypress事件的默认行为了呢?事实上,并没有

input_default_behavior2

你会看到,尽管我们的回调函数确实执行了,但这个事件本身的默认行为依然在按原来的逻辑正常运行,并没有被改写,显然也没有被我们的事件回调覆盖

这是为什么呢?原因在于@onkeypress的定义中,第四个参数的值是true:即这个事件默认情况下,是会执行默认行为的:

// ...
[Microsoft.AspNetCore.Components.EventHandler("onkeypress", typeof(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs), true, true)]
// ...
public static class EventHandlers
{
}

而如何阻止默认行为呢?我们需要在事件回调的旁边,再额外写一句声名:@onkeypress:preventDefault=@true,如下:

input_default_behavior3

这个东西有什么用呢?一般来说,最广泛的用途就是限制用户的输入:比如实现一个只能输入数值的文本框,等之类的。

什么是事件的传播

在浏览器中,事件是与DOM元素挂钩的,这很符合直觉:比如一个按钮遭到了点击,鼠标从一个div上面划过之类的。

而将这个很符合直觉的设计,与DOM元素是树型组织的这个现状结合起来的话,事情就变得有趣了起来:当一个div包含一个button的时候,button遭到点击,是否意味着外层的div也遭到了点击呢?

浏览器的设计实现是:YES!

换个角度来说,就是发生在子元素上的事件,会被传递到父元素上,再传递到爷爷元素上,最终传递到顶。

下面来看一个非常简单的例子:

@using Microsoft.AspNetCore.Components.Web

@page "/"

<div style="border: solid red 1px;width: 200px;height:200px"
    @onclick=@(this.RedOnClick)
>
    <div style="border: solid green 1px;width: 150px;height:150px;margin: auto;margin-top: 25px"
        @onclick=@(this.GreenOnClick)
    >
        <div style="border: solid blue 1px;width: 100px;height:100px;margin: auto; margin-top: 25px"
            @onclick=@(this.BlueOnClick)
        >

        </div>
    </div>
</div>

@code {
    private void RedOnClick() { Console.WriteLine("RedOnClick"); }
    private void GreenOnClick() { Console.WriteLine("GreenOnClick"); }
    private void BlueOnClick() { Console.WriteLine("BlueOnClick"); }
}

它运行起来的样子是这样的:

event_prop

那么如何阻断事件的传播呢?也很简单,在声明事件回调的旁边,写上类似于@onclick:stopPropagation=@true就行了

 @using Microsoft.AspNetCore.Components.Web

 @page "/"

 <div style="border: solid red 1px;width: 200px;height:200px"
     @onclick=@(this.RedOnClick)
+    @onclick:stopPropagation=@true
 >
     <div style="border: solid green 1px;width: 150px;height:150px;margin: auto;margin-top: 25px"
         @onclick=@(this.GreenOnClick)
+        @onclick:stopPropagation=@true
     >
         <div style="border: solid blue 1px;width: 100px;height:100px;margin: auto; margin-top: 25px"
             @onclick=@(this.BlueOnClick)
+            @onclick:stopPropagation=@true
         >

         </div>
     </div>
 </div>

 @code {
     private void RedOnClick() { Console.WriteLine("RedOnClick"); }
     private void GreenOnClick() { Console.WriteLine("GreenOnClick"); }
     private void BlueOnClick() { Console.WriteLine("BlueOnClick"); }
 }

效果如下:

event_prop2

2.3 总结

有个很重要的点需要再次强调:无论是数据渲染,还是事件处理,其实都是数据的单向流动。我们之前写过好几遍“按按键计数器+1”的例子,这种例子会给人一种:数据是双向流动的错觉。

但实际上不是的:之所以在点击了按钮后,页面上的数据会实时更新,是因为框架本身,在事件处理后,将当前组件重新渲染了一遍。

3. Blazor中的双向数据绑定

3.1 什么是双向数据绑定?为什么需要双向数据绑定?

数据的单向传递,辅以框架会在事件处理后自动的重新渲染的机制,其实已经足够了。但唯独对于一种控件,单向数据绑定实现业务逻辑时略显拖沓:文本框。

我们这里举个最简单的例子:

  1. 注册页面中有一个文本框,让用户来填用户名。
  2. 与此同时呢,在文本框旁边放一个按钮,用来随机生成一个用户名。
  3. 再与此同时呢,在页面上展示当前的用户名

要达到的效果呢,就是用户在文本框中输入用户名时,#3中展示的用户名实时更新。并且当用户点击按钮生成随机用户名时,#1文本框与#3同时更新

3.2 不使用双向绑定的初级版本

@using Microsoft.AspNetCore.Components.Web

@page "/"

Please type username: <input @oninput=@(this.OnInput) value=@(this.username) />
<br/>
<button @onclick=@(this.GenerateUsername)>Generate Random Username</button>
<hr/>
username: @(this.username)

@code {
    private string username = "";

    private void OnInput(ChangeEventArgs eventArgs)
    {
        this.username = eventArgs.Value!.ToString()!;
    }

    private void GenerateUsername()
    {
        this.username = new string[] { "Alice", "Bob", "Clark", "David", "Eason" }[new Random().Next(5)];
    }
}

这里有几个要点:

  1. 我们要在内存中存储这个用户名,所以声明了一个字段private string username = ""

  2. 这个内存中的用户名,需要渲染在两个地方:一个是最后一行的username: @(this.username),另外一个是文本框中的值,以value=@(this.username)将值渲染为文本框的value

    这样,保证了内存中的this.username一旦发生变动(由事件回调引起的变动),那么页面重新渲染,内存中的值会及时更新至文本框与最后一行上

  3. 事件处理方向,我们有两个入口来更改这个用户名:一是文本框本身,使用了oninput事件,另外一个是随机用户名按钮,使用了onclick事件

    这样,保证了页面上的事件会及时传递给内存

效果如下图所示:

username

进一步分析

这样写还是略显拖沓,我们可以分析一下我们的逻辑:数据从内存到页面有两条路:

  1. 文本框
  2. 最后一行文本

数据从页面(用户行为)到内存也有两条路:

  1. 按钮
  2. 文本框

我们可以发现,对于文本框而言,数据既需要保证从内存到页面的流动,也要保证从页面到内存的流动。这里,Blazor框架就为我们提供了一个语法糖:双向绑定

简而言之,双向绑定是把HTML控件的value,与Blazor component中的某个字段作同步绑定,保证任何一头的变动,都能尽快的同步到另外一头。

回到这个例子上,使用了双向绑定后,this.username的变动就会自动的同步在input.value里,即页面上文本框展示的内容。同样的,页面上文本框中的内容,即input.value的任何变动,也会尽快的同步给this.username

3.3 双向绑定版本

如果用双向绑定实现上面的功能,代码如下写:

 @using Microsoft.AspNetCore.Components.Web
 
 @page "/"
 
-Please type username: <input @oninput=@(this.OnInput) value=@(this.username) />
 Please type username: <input @bind=@(this.username) />
 <br/>
 <button @onclick=@(this.GenerateUsername)>Generate Random Username</button>
 <hr/>
 username: @(this.username)
 
 @code {
     private string username = "";
-
-    private void OnInput(ChangeEventArgs eventArgs)
-    {
-        this.username = eventArgs.Value!.ToString()!;
-    }
 
     private void GenerateUsername()
     {
         this.username = new string[] { "Alice", "Bob", "Clark", "David", "Eason" }[new Random().Next(5)];
     }
 }

但效果有点不尽人意,如下:

username2

可以看到,文本框中的值,仅当在输入完成后,焦点离开文本框,或者按下回车之后,才会传递到内存中去。

为什么会这样呢?这就要回到语法糖的本质了:双向绑定本质上只是一个语法糖,它做的事情是:

  1. 在事件处理方向上,将input.value的值赋值给this.username
  2. 在数据渲染方向上,将this.username的值传递到input.value

相当于框架背后给你写了一个@onxxx=@(this.OnXXX) value=@(this.username)。这里关键的问题就来了:这个事件,到底是哪个事件?

答案:这个事件默认情况下是onchange事件,而onchange事件并不会随着用户的输入而被激发,它只会在当前控件丢失焦点后才会被激发。

那么紧接着,问题就来了:我们怎么能改写双向绑定默认使用的事件类型呢?答案是:@bind:event,如下修改:

 @using Microsoft.AspNetCore.Components.Web
 
 @page "/"
 
-Please type username: <input @bind=@(this.username) />
 Please type username: <input @bind=@(this.username) @bind:event="oninput" />
 <br/>
 <button @onclick=@(this.GenerateUsername)>Generate Random Username</button>
 <hr/>
 username: @(this.username)
 
 @code {
     private string username = "";
 
     private void GenerateUsername()
     {
         this.username = new string[] { "Alice", "Bob", "Clark", "David", "Eason" }[new Random().Next(5)];
     }
 }

如此修改后,味儿就对了。

3.4 双向绑定只能绑定字符串吗?

一个天然的问题就是:双向绑定只能绑定字符串吗?

答案是:它虽然是个语法糖,但比你想象的要稍微智能那么一点点,至少,它能绑定数值。假如我们把上面例子中的username的类型改成int,再把随机算法的实现改一改,改成下面这样

@using Microsoft.AspNetCore.Components.Web

@page "/"

Please type number: <input @bind=@(this.number) @bind:event="oninput"/>
<br/>
<button @onclick=@(this.GenerateNumber)>Generate Random Number</button>
<hr/>
number: @(this.number)

@code {
    private int number = 0;

    private void GenerateNumber()
    {
        this.number = new Random().Next(9999);
    }
}

运行结果如何呢?

结果是:非数值、非合法的int数值,无法输入到文本框中去。。

username3

为什么会这样呢?坦白讲,真实的原因我也不知道,但你可以这样去理解这个双向绑定的语法糖:

  1. 在数据渲染方向上,没什么可说的,无论this.field是什么类型,最终都会被字符串化后传递给input.value
  2. 在事件处理方向上,它生成的事件回调函数,其中的内容类似于this.field = Int.Parse(input.value),当然了,具体类型依实际情况而定,但总之它使用的一定是不抛异常的Parse(string)重载,如果解析失败,直接返回默认值

你可以按上面的说法去理解,或者更简单一点:按照直觉去使用双向数据绑定,在99%的场景下,它都是符合直觉的。

3.5 更进一步:双向绑定还支持字符串到DateTime类型之间的互相转换

从上面的描述你能得知,双向绑定至少支持一系列的基础类型,最常用的或许就是字符串、整数、浮点数这三种了。但除此外,Blazor框架单独为DateTime类开发了一个独立特性。

简单来说,我们可以使用@bind=@(this.field)input.value绑定到一个类型为DateTime的字段上去,同时为了支持繁杂的日期时间格式,Blazor专门为这个特性开发了一个directive attribute,叫@bind:format,下面是一个示例:

@using Microsoft.AspNetCore.Components.Web

@page "/"

Please type DateTime in format 'yyyy-MM-ddTHH:mm:ss': <input @bind=@(this.datetime) @bind:format="yyyy-MM-ddTHH:mm:ss"/>
<br/>
<button @onclick=@(this.GetNow)>Get Now</button>
<hr/>
datetime: @(this.datetime)

@code {
    private DateTime datetime = DateTime.Now;

    private void GetNow()
    {
        this.datetime = DateTime.Now;
    }
}

它的运行效果如下:

datetime

@bind:format的值请参阅这篇文档,可以按需求灵活组合,需要注意的是,它的值影响着数据两个方向流动时的行为:

  1. 渲染时,会把@bind:format的值作为format string,来传递给ToString()方法
  2. 事件处理时,会把@bind:format的值作为format string,来解析输入字符串

4. 总结

今天我们介绍了数据在Blazor中是如何流动的,确切的说,是在单个组件内部,数据是如何流动的,其实说白了就三板斧:渲染、事件与双向绑定,其中双向绑定还只是个语法糖,本质上其实只有渲染与事件两个东西。

语法都很简单,重要的是要理解“数据传递”的概念,要意识到无论是渲染还是事件处理,本质上都是数据的流动。并且要注意到所谓的“渲染”的本质,要明白,就目前而言,除了页面初次渲染外,当且仅当有事件被处理时,页面才会被重新渲染。