ASP.NET Web 应用程序的前端设计
ASP.NET Web 应用程序的前端设计
引言
在 ASP.NET Web 应用程序中,您时常需要处理前端开发。在某些应用程序中,ASPX 页面上只有少量 JavaScript 函数,但在其他应用程序中,JavaScript 代码中包含大量逻辑。在处理具有不同设计(单页应用程序、包含大量页面的常见 ASP.NET WebForms 应用程序)的不同 Web 应用程序后,我决定尝试更好地组织我的客户端代码。在本文中,我将解释我关于组织客户端代码的建议,并希望听取您的意见。
问题定义。
让我们看看在 ASP.NET WebForms 应用程序中通常会遇到什么。标准 ASP.NET WebForms 应用程序的基本构建块是 ASPX 页面或 ASCX 控件。通常,在任何标准项目中,我们都有很多这样的项目。每个页面或控件可能都有一些客户端逻辑,其中包含一些 jQuery 操作,例如获取数据/更改数据/验证数据等。
通常,这类代码会作为纯 JavaScript 函数直接添加到页面中
<script type="text/javascript"> function SomeFunction() { // do some staff } </script>
有时这些函数会添加到 JavaScript 文件中,并附在 ASPX 页面头部。
此外,通过以下方式直接在 aspx 标记中绑定客户端事件处理程序也相当常见
<asp:Button runat="server" OnClientClick="SomeFunction()"/>
我们通常还有几个 JavaScript 文件,其中的代码可能相互依赖,我们必须手动将这些脚本按正确的顺序添加到页面头部。
结果是,我们向全局命名空间添加了大量 JavaScript 代码,而没有任何模块系统,也无法适当地重用现有代码。我们还在 aspx 标记和客户端代码之间存在紧密的耦合,这包括控件 ID 和函数名称。在这种情况下,几乎不可能使用 HTML 模板等功能。
如何解决
在下面的文章中,我将尝试解释我关于如何组织客户端代码以实现更好的模块化和可维护性的建议。
对于我的 ASP.NET WebForms 应用程序中的每个页面,我都希望有一个独立的 JavaScript 模块,我称之为控制器。控制器应该封装此特定页面的所有客户端功能。此模块将是应用程序的独立部分,它将声明并自动加载所有必需的引用 + 它可以在主应用程序流之外使用任何 JavaScript 测试框架进行单元测试。
Requirejs 和项目基础设施
对于模块声明,我决定使用 requirejs。这个库允许我们声明脚本之间的引用并自动加载它们。
我在 ASP.NET WebForms Web 应用程序中使用以下文件夹结构
js - 所有前端开发人员的根文件夹。
js/lib - 用于存储第三方库,如 jquery 和 requirejs
js/app - 用于存储应用程序特定代码
js/app/common - 可以在应用程序不同部分使用的通用模块
js/app/controller - 用于存储页面/控件特定控制器
要使 requirejs 工作,请创建 js/config.js 文件,其内容如下
require.config({ //By default load any module IDs from js/lib baseUrl: 'js/lib', paths: { app: '../app', jquery: 'jquery-2.1.1' } });
现在让我们看看 Main.Master 页面的标记
<%@ Master Language="C#" AutoEventWireup="true" CodeBehind="Main.master.cs" Inherits="WebAppTemplate.Main" %> <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml"> <head runat="server"> <title></title> <script src="js/lib/require.js"></script> <script src="js/config.js"></script> <asp:ContentPlaceHolder ID="HeadContent" runat="server" /> <script> require(["domReady!"], function (domReady) { } ); </script> </head> <body> <form id="form1" runat="server"> <asp:ContentPlaceHolder ID="MainContent" runat="server" /> </form> </body> </html>
在头部部分,我们附加了 require.js 库以使 requirejs 工作。接下来附加的文件是 config.js,它配置 requirejs 库。下一行将内容占位符设置为允许内容页附加其他脚本。最后一个脚本部分有一个带有 domReady.js 脚本加载的 require 语句。在函数内部,我们可以加载任何模块或执行其他客户端 JavaScript 魔法。通过使用此方法,我们只需确保我们的代码在 DOM 加载后运行。
在 body 部分,我们只为内容页面声明内容占位符。
现在我们准备好实现我们的页面特定 requirejs 模块了。
使用的第三方组件
- Jquery-2.1.1 - https://jqueryjs.cn/
- Require.js - https://requirejs.node.org.cn/
允许编写模块化 JavaScript 代码的流行库。
- domReady.js - 允许确定 DOM 何时加载而无需附加 jquery 的模块。这可以替换为 jquery ready 函数,但为了小幅提升性能,我使用 domReady.js
- class.js – John Resig 编写的简单 JavaScript 继承实现。我稍微改变了实现方式,以便能够使用“base”关键字调用基函数,而不是使用“super”。 http://ejohn.org/blog/simple-javascript-inheritance/
- pubsub.js – 简单的观察者模式实现。 https://gist.github.com/fatihacet/1290216
要实现的目标
- 使用 JavaScript 模块停止全局命名空间污染。
- 通过在 JavaScript 代码中引入继承和模块化来强制代码重用
- 将页面特定的 JavaScript 封装到独立的模块中
- 能够声明性地将页面模块分配给特定页面
- 使用声明式语法控制模块之间的依赖关系;
- 能够执行页面特定模块中的常见任务
- 查找页面特定控件并对其进行操作
- 使用观察者(发布/订阅)模式在模块之间进行通信
- 能够使用提交和 Ajax 与服务器通信
- 能够同时使用单页应用程序方法和多页应用程序 MPV 方法
页面特定模块:分步
让我们思考一下我们希望模块中包含什么。
我希望拥有以下功能
- 与 DOM 交互的可能性 - 可以在需要时通过在模块中使用 jquery 来实现
- 模块之间交互的可能性 - 如果一个页面上有多个用户控件,并且每个用户控件都有自己的模块,我们需要一种在模块之间进行通信的方式。简单的观察者模式实现(pubsub)可能对此有所帮助。
- 通过继承重用代码的可能性 - 这可能存在争议。我们可以使用混入来重用代码。我们可以使用原型配置来重用代码。我们可以使用组合来重用代码。但是对于基于 .NET 的开发人员来说,对 JavaScript 模块使用继承确实很有帮助。
让我们一步一步地实现这些目标。
如果我们要使用继承机制,我们应该声明我们继承链的起点。为此,我使用 BaseModule.js 模块。
代码很简单
define([ "class" ], function(Class) { // Set base inheritance class. Use extend() method in heirs to continue inheritance chain return this.Class.extend({ // Constructor init: function() { // Default values can be set in this method } }); });
这只是一个简单的模块,它应该成为我们的起点。它声明每个模块都将具有 John Resig 继承实现中的“extend”函数。此外,我们有一个空的构造函数声明(init 方法)。注释将帮助我们理解如何继续继承链。这个类可以(并且实际上应该)被我们系统中的任何模块使用(也许除了包含常量值的模块——它们根本不需要继承功能)。
下一步,我希望有一个 BaseController 类,它将是所有页面特定控制器继承链的起点。代码可能如下所示
define([ "app/common/BaseModule" ], function (BaseModule) { return BaseModule.extend({ // Entry point start: function() { this._loadControls(); this._setEventHandlers(); }, // This method should be used to load page-specific controls and set references to them in controller _loadControls: function() { }, // This method should be used to attach event handlers to controls _setEventHandlers: function() { } }); });
这是我希望我的控制器行为方式的示例。这里唯一的假设是“start”方法应该在 DOM 加载后调用(顺便说一下,这应该添加到 start 方法注释中)。我将控制器行为分为两个阶段——loadControls 和 setEventHandlers。loadControls 阶段应该用于选择任何 DOM 元素并设置内部控制器字段。setEventHandlers 阶段应该用于附加到任何 DOM 事件或 pubsub 事件。我们可以在“start”函数中完成所有这些工作。但我发现这种拆分很有用。
为了能够使用此控制器,我们应该在 Default.aspx 中调用 require 函数,实例化新的控制器并调用 start 方法。这将按预期工作。这里唯一的缺点是对于每个页面/控件,我们都必须编写相同的代码来实例化和执行控制器。我希望有一种声明式的方式来使用控制器。为了实现这一点,我实现了 ControllerLoader 类,它使用 data-controller 属性来确定控制器的名称并使用 requirejs 动态加载它。
创建以下 JavaScript 文件
js\app\common\ControllerLoader.js
代码应该如下所示
define([ "app/common/BaseModule", "jquery" ], function(BaseModule, $) { return BaseModule.extend({ loadDeclaredControllers: function() { var controllers = $("[data-controller]"); for(var i = 0; i < controllers.length; i++) { var containerDiv = $(controllers[i]); this.loadController(containerDiv.data("controller"), containerDiv); } }, loadController: function(controllerName, containerDiv) { // Use requirejs to load controller class require([controllerName], function(ControllerModule) { // Call base module constructor and init() method var controller = new ControllerModule(); // Set container to be able to determine context if (containerDiv) { controller.container = containerDiv; } // Call default entry point for controller if defined if (controller.start) { controller.start(); } }); } }); });
如您所见,我实现了两种方法。“loadController”接收控制器的名称和对 containerDiv 的引用(可选)。此方法的目的是实例化控制器并在存在时调用 start 方法。“loadDeclaredControllers”方法尝试查找具有“data-controller”属性的标签,对于每个找到的标签,它读取控制器名称并执行 loadController 方法以实例化新控制器。
为了能够使用该代码,我们应该在 Main.Master 页面的 head 部分中包含以下标记
<script src="js/lib/require.js"></script> <script src="js/config.js"></script> <asp:ContentPlaceHolder ID="HeadContent" runat="server" /> <script> require(["domReady!", "app/common/ControllerLoader"], function (domReady, controllerLoader) { new controllerLoader().loadDeclaredControllers(); } ); </script>
现在,当 DOM 加载后,新的控制器加载器将被实例化,并且 loadDeclaredControllers 函数将加载所有已声明的控制器。
控制器应按如下方式声明
<%@ Control Language="C#" AutoEventWireup="true" CodeBehind="PersonProcessed.ascx.cs" Inherits="WebAppTemplate.PersonProcessed" %> <div data-controller="app/controller/PersonProcessedController"> Processed Person List <br /> <ul id="personList"></ul> </div>
我们还需要涵盖的另一件事是 ASP.NET WebForms 控件的客户端 ID。如您所知,ASP.NET 控件使用 NamingContainer 来生成客户端 ID。在使用 jquery 在控制器中从 DOM 中选择控件时,这可能是一个问题,因为您不知道最终的控件 ID。为了解决这个问题,我在我的 BaseController.js 类中添加了以下函数
// Helper method to find controls using jQuery with controller container context getByID: function (id, context) { // Set search context if applicable if (!context && this.container) { context = this.container; } // Basic jQuery selector var el = $("#" + id, context); // If unable to find element - lets try to check ASP.NET naming container prefix if (el.length < 1) { el = $("[id$=_" + id + "]", context); } return el; }
"getByID" 方法将尝试使用常见的 jquery 选择器查找 DOM 元素,但如果失败,我们将尝试使用模式 "_ClientID" 来查找我们的控件。我还决定使用容器来指定控件搜索的上下文。
让我们看看一切的实际效果
现在我们准备好看看一切的实际效果。
例如,让我们尝试实现以下有趣的行为。
我们有两个用户控件。第一个是输入姓名。还需要有一个“处理人员”按钮来执行处理。在第二个用户控件上,我们希望有一个“已处理人员列表”。
行为应如下所示
- 用户输入名字和姓氏
- 点击“处理人员”-> Ajax 回发到服务器
- 发生一些神奇的服务器端处理,结果通过 JSON 响应发送回客户端。
- “已处理人员列表”更新了名字和姓氏。
- 第一个用户控件中的控件已刷新。
这个用例有点愚蠢,但它允许我们演示我们想要拥有的所有功能。
让我们从创建两个用户控件开始。
PersonEdit.ascx
<%@ Control Language="C#" AutoEventWireup="true" CodeBehind="PersonEdit.ascx.cs" Inherits="WebAppTemplate.PersonEdit" %> <div data-controller="app/controller/PersonEditController"> First Name: <asp:TextBox runat="server" ID="txtFirstName"></asp:TextBox> Last Name: <asp:TextBox runat="server" ID="txtLastName"></asp:TextBox> <button id="btnCheck" type="button">Process Person</button> <br /> </div>
PersonProcessed.ascx
<%@ Control Language="C#" AutoEventWireup="true" CodeBehind="PersonProcessed.ascx.cs" Inherits="WebAppTemplate.PersonProcessed" %> <div data-controller="app/controller/PersonProcessedController"> Processed Person List <br /> <ul id="personList"></ul> </div>
标记已准备就绪。现在我们需要创建相应的控制器。PersonEdit 控制器应响应点击事件并使用输入的名字/姓氏向服务器发出 Ajax 回发。
define([ "app/controller/BaseController", "pubsub", "app/common/topic" ], function(BaseController, ps, topic) { return BaseController.extend({ txtFirstName: null, txtLastName: null, btnCheck: null, _loadControls: function() { this.base(); this.txtFirstName = this.getByID("txtFirstName"); this.txtLastName = this.getByID("txtLastName"); this.btnCheck = this.getByID("btnCheck"); }, _setEventHandlers: function() { this.base(); this.btnCheck.on("click", this._onBtnCheckClick.bind(this)); ps.subscribe(topic.Person_Processed, this._onPersonProcessed.bind(this)); }, _onBtnCheckClick: function() { ps.publish(topic.Person_Ready_To_Process, { firstName: this.txtFirstName.val(), lastName: this.txtLastName.val() }); }, _onPersonProcessed: function (event, args) { this.txtFirstName.val(""); this.txtLastName.val(""); alert("Person " + args.person + " was successfully processed!"); } }); });
让我们看看这里有什么。在 loadControls 方法中,我们从 DOM 中选择控件。在 setEventHandlers 中,我们附加到 btnCheck click 事件。请注意“bind(this)”函数的使用。此调用允许我们在处理程序执行时拥有控制器实例。我们还订阅了 Person_Processed 主题(其常量必须在 Topic.js 文件中声明)。在 click 事件处理程序中,我们从控件中获取 firstName/lastName 值并发布 Person_Ready_To_Process 主题(以执行第二个控制器中的一些逻辑)。onPersonProcessed 处理程序应该在人员在服务器上处理后执行。在此处理程序中,我们将控件的值设置为空字符串并显示带有结果的消息框。
现在让我们看看我们的 PersonProcessedController.js。
define([ "app/controller/BaseController", "pubsub", "app/common/topic" ], function(BaseController, ps, topic) { return BaseController.extend({ ulPerson: null, _loadControls: function () { this.base(); this.ulPerson = this.getByID("personList"); }, _setEventHandlers: function() { this.base(); ps.subscribe(topic.Person_Ready_To_Process, this._onPersonAdded.bind(this)); }, _onPersonAdded: function(event, args) { $.ajax({ url: '/default.aspx', dataType:'json', data: { command: "processPerson", firstName: args.firstName, lastName: args.lastName }, success: this._onPersonProcessed.bind(this), error: function (xml, status, error) { // do something if there was an error alert(error); } }); }, _onPersonProcessed: function (data) { var person = data.FirstName + ' ' + data.LastName; this.ulPerson.append($("<li>").text(person)); ps.publish(topic.Person_Processed, { person: person }); } }); });
在 setEventHandlers 方法中,我们订阅了 Person_Ready_To_Process 主题。在 onPersonAdded 处理程序中,我们向服务器发送 Ajax 回发。我们假设服务器将处理我们的人员并返回结果。再次注意 this._onPersonProcessed.bind(this) 语法。它允许我们在事件处理程序执行时拥有控制器实例。在 onPersonProcessed 处理程序中,我们将已处理人员添加到列表中并发布事件,该事件告知系统人员刚刚被处理。此事件将用于 PersonEdit 控制器以更新控件并显示结果消息。
在服务器端,我从请求参数中获取数据并编写所需的响应。我将不关注这一点,因为这不是本文的主要原因。此技术可用于开发 SPA(单页应用程序)。我们还可以调用 WCF 服务以相同的方式处理 ajax 请求。
可能的改进
我认为可能存在的改进如下
- 使用类似 knockout.js 的东西进行 MVVM 方式的 DOM 操作。它将进一步改进整体设计。
- 思考如何使用这种方法在 ASP.NET WebForms 的服务器端实现 MVP 模式。这里唯一的问题是 Ajax 请求处理。其他一切都与此设计完美契合。
- 使用一些 JavaScript 单元测试框架来实现单元测试。
完整的源代码已附加到文章中。请查看并提供您的评论。我想知道您是否认为这种方法真的有用,或者它可以在哪些方面改进。