AngularJS 指令和父控制器之间的通信
本教程将讨论父控制器和 AngularJS 指令之间通信的三种不同方式。
引言
几乎所有在 AngularJS 上进行大量工作的程序员都会遇到的一个常见问题是如何有效地在 AngularJS 指令和父控制器之间进行通信。一种方法是使用“broadcast”(广播)和“emit”(发射)机制。我就是这样做的,我可以告诉你,这是一个糟糕的主意。这种机制的性能可能值得怀疑。另一个问题是,有时父控制器可能在指令有机会正确初始化之前很早就广播了一些消息,导致指令错过广播并产生一些奇怪的错误。
对我来说,主要问题是性能,尤其是在涉及大量数据时。而“broadcast”和“emit”这些机制听起来很简单,看起来也很直接(它们确实是)。但它们是陷阱。初学者最容易陷入这些简单/直接的机制,并搞砸一个大项目。想象一下,你有一个 AngularJS 项目,其中包含大量指令或组件,以及它们与其父控制器之间复杂的通信。然后,一个没有经验的程序员决定使用“broadcast”和“emit”机制。项目完成后,大量的客户抱怨性能问题和奇怪的应用程序行为。我的建议是,不要陷入这样的境地。
那么问题来了,有没有更好的方法?当然有。在本教程中,我将讨论促进 AngularJS 指令和父控制器之间通信的三种不同方法。第一种方法是指令使用观察者来监视父控制器中的数据变化并采取适当的行动。第二种方法是 AngularJS 指令如何调用父控制器的方法。这可以通过两种不同的方式完成。这使得三种不同的方法,以确保两者之间的通信尽可能高效,并且没有与“broadcast”和“emit”相关的所有问题。但这三种方法都有自己的问题。它们会创建父控制器和指令之间的紧密耦合。但如果能够仔细规划,这些问题就可以最小化。
示例应用程序架构
为了演示我上面描述的这些方法,我添加了一个示例 AngularJS 应用程序。它是一个简单的班级注册。用户可以输入学生姓名、学生年龄和课程名称。然后单击一个按钮。课程将显示在一个列表中。同一页面上显示的列表是一个 Angular 指令,它有自己的控制器(子控制器)。用户可以输入注册信息的区域位于主应用程序控制器(父控制器)中。在课程注册列表中,每一行都有两个按钮,一个用于将该行的课程注册信息发送回父控制器,另一个按钮用于向父控制器发出信号,表示应该删除课程注册。这里的问题是父控制器为什么关心课程注册被删除?原因是方便将所有课程注册的主列表保留在父控制器中,而指令的唯一职责是显示列表,而不是管理主数据列表。正如你所看到的,两者之间进行高效通信非常重要。
所以,这是我划分职责的方式:指令负责显示任何可用的课程注册。任何列表数据的操作都在父控制器中完成,例如添加新注册或删除一个。课程注册列表有任何变化时,指令都必须得到通知以刷新列表。但是,指令中的每个数据行都有删除行的操作,这是对数据列表的操作,应该由父控制器完成。而查看行的数据,可以是指令的职责,也可以是父控制器的职责。为了演示与父控制器的通信,我将此职责留给了父控制器。这个示例应用程序还将展示如何将一个方法从父控制器传递给指令,指令可以调用它并传递参数。这是指令将数据传递给父控制器的另一种方式。
客户端编码使用 EMCA script 版本 6(尽可能)。因此,我将使用模块、导入以及所有好用的东西来演示如何编写 AngularJS 指令和将其连接到应用程序控制器(父控制器)。如果你想学习如何做,这是一个很棒的教程。
主应用程序模块
示例应用程序是一个单页 Web 应用程序。它使用 Spring Boot 创建一个 Web 服务器,只是为了提供 HTML 页面和相关的 JavaScript 文件。这没什么大不了的。所有重要的部分都在应用程序 JavaScript 文件中。我将从最简单的部分开始——应用程序模块文件。
这是我的主应用程序模块文件,看起来是这样的
import { TwoLayersController } from '/assets/app/js/TwoLayersController.js';
import { CourseListController } from '/assets/app/js/CourseListController.js';
import { courseListDirective } from '/assets/app/js/CourseListDirective.js';
let app = angular.module('startup', []);
app.controller("CourseListController", [ "$rootScope", "$scope", CourseListController ]);
app.directive("courseList", [ courseListDirective ]);
app.controller("TwoLayersController", TwoLayersController);
我使用了 ECMA6 Script 语法。所以它与编写 JavaScript 的旧方式不同。前三行是从其他 JavaScript 文件导入一些对象。我导入的第一个对象称为 TwoLayersController
。这是此应用程序的主控制器。第二行导入一个名为 CourseListController
的对象。这是指令使用的控制器。第三行导入一个名为 courseListDirective
的函数。此函数用于定义指令。接下来的 4 行定义了此 AngularJS 应用程序的引导。这 4 行中的第一行定义了 AngularJS 模块。下一行是注册指令使用的控制器。下一行是注册指令。我称此指令为“courseList
”。你会在网页上看到它的使用。最后一行是注册主控制器。整个事情看起来很奇怪。但应该很熟悉。所有这些都可以在文件“app.js”中找到。
接下来,我将展示指令是如何定义的。要定义一个指令。你只需要一个返回一个对象的函数,该对象定义了指令的行为方式。我个人喜欢带独立作用域的指令。这样做有一个好处,它允许同一个指令被同一个父控制器多次使用。
定义指令
为了创建带有独立作用域的指令,我需要一个函数和一个特定的指令控制器。这是返回指令规范的函数
export function courseListDirective () {
return {
restrict: "EA",
templateUrl: "/assets/app/pages/courseList.html",
scope: {
allItemsInfo: "=",
callHostMethod: "&"
},
controller: "CourseListController",
controllerAs: "courseList"
};
}
这是一个简单的指令定义。它定义了指令要用作 HTML 元素或元素的属性(“restrict
”行);指令标记的 HTML 页面模板(“templateUrl
”行);带有“scope
”的行定义了要传递给此指令的数据或方法。这一行不仅创建了独立的作用域,还可以用于创建双向通信机制。最后两行定义了与此指令关联的控制器。这里,我传递了控制器名称“CourseListController
”。你可能会问,这是如何工作的?答案是依赖注入。名为“CourseListController
”的控制器已在应用程序模块(app.js)中注册。这里,指令定义将能够获得对具有该名称的实际控制器的引用。而 controllerAs
指定了 HTML 标记的作用域对象名称。
这很简单,指令使用的控制器有点复杂。这是整个源代码文件
export class CourseListController {
constructor($rootScope, $scope) {
this._scope = $scope;
this._rootScope = $rootScope;
this._itemsList = null;
this._callbackObj = null;
this._callHostMethod = null;
if (this._scope.callHostMethod) {
this._callHostMethod = this._scope.callHostMethod;
}
let self = this;
this._scope.$watch("allItemsInfo.lastUpdatedTime", function(newValue, oldValue) {
if (newValue && newValue.trim() !== "" && newValue !== oldValue) {
if (self && self._scope &&
self._scope.allItemsInfo && self._scope.allItemsInfo.allItems) {
self._itemsList = angular.copy(self._scope.allItemsInfo.allItems);
self._callbackObj = self._scope.allItemsInfo.callbackObj;
} else {
// XXX something not right, you might want to throw an exception.
console.log("Something is not right about the items list
from the host controller.");
self._itemsList = null;
self._callbackObj = null;
}
}
});
}
get itemsList () {
return this._itemsList;
}
set itemsList (val) {
this._itemsList = val;
}
get callbackObj () {
return this._callbackObj;
}
set callbackObj (val) {
this._callbackObj = val;
}
callHostMethod() {
if (this._callHostMethod) {
let paramData = {
title: "Jimmy Sings",
message: "Jimmy is Jimi Hendrix."
};
this._callHostMethod({ msgData: paramData });
}
}
}
这个指令与其父控制器通信的两种方式。第一种方式是在指令定义期间传递的对象中获取父控制器的引用,然后只要父控制器是公开可访问的,我就可以使用它。另一种方式是调用指令提供的父控制器的方法。
让我解释第一种方式。在上面的源代码片段中,我传递了一个名为 allItemsInfo
的数据对象。我的定义方式是,任何对它的更改,指令都可以看到更改,父控制器也可以看到更改。这一切都通过 $scope.$apply()
完成。当这种对象是数组时,情况会特别棘手。有时,父控制器将数组引用更改为新数组,指令突然不再工作。这是因为指令仍然期望对旧数组发生更改。这就是为什么你不应该将数组作为双向可通知对象传递给指令。相反,将其包装在一个对象中,并确保父控制器和指令控制器都具有该对象的相同引用。这样,你可以将数组引用更改为任何数组,只要建立了正确的监控,对该对象属性的任何更改对双方来说都是即时的。这是指令和父控制器之间通信的最有效方式。
在父控制器中(位于文件“TwoLayersController.js”中),定义了这个作用域对象,称为 this._itemsbag
。这是课程注册数组/列表的包装对象。定义如下
this._itemsBag = {
allItems: [],
lastUpdatedTime: null,
callbackObj: null
};
这个对象允许我操作数组/列表,而不用担心指令与其父控制器之间的数组/列表引用值发生变化。此外,这个对象还允许观察变化,并采取相应的行动。这是通过指令端的观察者完成的。想法是,当父控制器操作数组/列表时,指令会收到通知以刷新自己的列表,该列表显示给用户。这是通过以下方式完成的,创建观察者,并使用它来刷新指令的内部列表
let self = this;
this._scope.$watch("allItemsInfo.lastUpdatedTime", function(newValue, oldValue) {
if (newValue && newValue.trim() !== "" && newValue !== oldValue) {
if (self && self._scope && self._scope.allItemsInfo &&
self._scope.allItemsInfo.allItems) {
self._itemsList = angular.copy(self._scope.allItemsInfo.allItems);
self._callbackObj = self._scope.allItemsInfo.callbackObj;
} else {
// XXX something not right, you might want to throw an exception.
console.log("Something is not right about the items list from the host controller.");
self._itemsList = null;
self._callbackObj = null;
}
}
});
让我解释一下,我创建的对象有三个属性
- 包含课程注册的数组。
- 一个时间戳,指示此对象上次更新的时间。这是观察者正在观察的属性。
- 一个回调对象,指令可以使用它来回通信。我将进一步讨论这一点。
在上面的观察者定义中,它设置为检查对象的最后更新时间戳。任何时候检测到值更改,它都会将对象中的列表复制一份并传递给本地列表。它还将回调对象传递给指令。监视时间戳的变化
let self = this;
this._scope.$watch("allItemsInfo.lastUpdatedTime", function(newValue, oldValue) {
if (newValue && newValue.trim() !== "" && newValue !== oldValue) {
...
}
});
这是将更改的数据从对象复制到指令的代码
if (self && self._scope && self._scope.allItemsInfo &&
self._scope.allItemsInfo.allItems) {
self._itemsList = angular.copy(self._scope.allItemsInfo.allItems);
self._callbackObj = self._scope.allItemsInfo.callbackObj;
} else {
// XXX something not right, you might want to throw an exception.
console.log("Something is not right about the items list from the host controller.");
self._itemsList = null;
self._callbackObj = null;
}
为了简单起见,我跳过了很多检查和错误处理。现在我们知道了如何从父控制器向指令发出数据更改的信号,我将向你展示如何向父指令发出信号。对于课程列表,我想将选定的课程注册信息发送回父控制器,以便父控制器可以在其页面上显示它。我还想从指令中删除选定的项目。正如你从上面的代码中看到的,其中没有关于如何完成此操作的逻辑。这是因为在 HTML 端处理查看课程注册或删除课程注册。
<td class="text-center">
<button class="btn btn-default btn-sm"
title="View Info"
ng-click="courseList.callbackObj &&
courseList.callbackObj.showRegistrationInfo(itm)">
<i class="glyphicon glyphicon-zoom-in"></i></button>
<button class="btn btn-default btn-sm"
title="View Info"
ng-click="courseList.callbackObj &&
courseList.callbackObj.deleteRegistrationInfo(itm)">
<i class="glyphicon glyphicon-trash"></i></button>
</td>
列表显示使用 HTML 表格完成。第一列有两个按钮,一个用于查看注册的课程信息。另一个用于删除注册的课程信息。这两个按钮都定义了它们的 ngClick
事件处理程序。它们的定义方式是检查事件处理程序是否存在,然后调用它。如果你仔细阅读,你会发现事件处理程序是回调对象的一部分,回调对象是父控制器。处理程序方法是父控制器的一部分。这就是指令引用其父控制器的方法并调用它们的方式。这只是一种方式。还有另一种方式,基本上你可以从父控制器传入方法,将它们保存为指令中的引用。然后使用引用来调用它们。
从父控制器调用方法引用的难点是如何将参数传递给方法。结果发现,一旦我弄清楚语法,它就相当容易做到。我对 AngularJS 不喜欢的一点是它的语法以及不一致性。让我展示一下这是如何完成的。首先,让我们看看这个指令是如何添加到 HTML 页面的。HTML 页面名为“index.html”,其中有这一行
...
<div course-list all-items-info="vm.itemsBag"
call-host-method="vm.receivedMessageShow(msgData)" ></div>
...
这是一个 div
,它有第一个属性 course-list
。这个属性是我定义的指令。指令的名称是“courseList
”。在 HTML 中使用时,驼峰式命名会被转换为“course-list
”。这是 AngularJS 的约定。div
元素还有两个属性,第一个称为 all-items-info
。这是为了传入所有课程注册列表及其相关属性的包装对象。第二个称为 call-host-method
。它用于传入父控制器的方法,以便指令可以调用它。如你所见,传递的方法是签名,如下所示:“vm.receivedMessageShow(msgData)
”。这意味着调用该方法时,必须传递参数。
如果我们在指令的控制器中,这个传递过来的方法可以如下调用
...
callHostMethod() {
if (this._callHostMethod) {
let paramData = {
title: "Jimmy Sings",
message: "Jimmy is Jimi Hendrix."
};
this._callHostMethod({ msgData: paramData });
}
}
...
在我的指令控制器中,我有一个方法,它只是调用父控制器的方法,以演示如何通过传递参数来完成。如所示,要做到这一点,我必须将参数写在一个对象中,而这个对象唯一的属性与要调用的方法的参数同名。在 HTML 中使用指令时,该方法的参数称为“msgData
”。因此,参数包装对象定义为
...{ msgData: paramData }...
实际调用是这样的
...
this._callHostMethod({ msgData: paramData });
...
我希望你明白是怎么回事。下一个问题是,这个方法在父控制器中做什么?它只是显示一个警报消息框
...
receivedMessageShow(msgData) {
if (msgData) {
alert("Title: " + msgData.title + "; Message: " + msgData.message);
}
}
...
你可以在文件 TwoLayersController.js 中看到这个方法的定义。
父控制器
我已经涵盖了所有重要部分,我将向你展示父控制器看起来是什么样子
export class TwoLayersController {
constructor() {
this._studentName = "";
this._studentAge = 0;
this._courseName = "";
this._itemsBag = {
allItems: [],
lastUpdatedTime: null,
callbackObj: null
};
}
set studentName(val) {
this._studentName = val;
}
get studentName() {
return this._studentName;
}
set studentAge(val) {
this._studentAge = val;
}
get studentAge() {
return this._studentAge;
}
set courseName(val) {
this._courseName = val;
}
get courseName() {
return this._courseName;
}
set itemsBag(val) {
this._itemsBag = val;
}
get itemsBag() {
return this._itemsBag;
}
addCourseRegistration() {
// I will assume all the inputs are valid.
// This is the place for input validation. Just a thought.
let itemToAdd = {
studentName: this._studentName,
studentAge: this._studentAge,
courseName: this._courseName
};
if (this._itemsBag == null) {
this._itemsBag = {
allItems: [],
lastUpdatedTime: null,
callbackObj: null
};
}
this._itemsBag.allItems.push(itemToAdd);
this._itemsBag.lastUpdatedTime = moment().format("YYYYMMMDD HHmmss");
this._itemsBag.callbackObj = this;
this.clear();
}
clear() {
this._studentName = "";
this._studentAge = 0;
this._courseName = "";
}
showRegistrationInfo(item) {
console.log("callback from the directive - show item info.");
if (item) {
this._studentName = item.studentName;
this._studentAge = item.studentAge;
this._courseName = item.courseName;
}
}
deleteRegistrationInfo(item) {
console.log("callback from the directive - delete item from list.");
if (item) {
if (this._itemsBag && this._itemsBag.allItems &&
this._itemsBag.allItems.length > 0) {
let newRegList = [];
angular.forEach(this._itemsBag.allItems, function (itemToCheck) {
if (itemToCheck &&
(itemToCheck.studentName !== item.studentName ||
itemToCheck.studentAge !== item.studentAge ||
itemToCheck.courseName !== item.courseName)) {
newRegList.push(itemToCheck);
}
});
if (newRegList && newRegList.length > 0) {
this._itemsBag.allItems = newRegList;
this._itemsBag.lastUpdatedTime = moment().format("YYYYMMMDD HHmmss");
this._itemsBag.callbackObj = this;
} else {
this._itemsBag.allItems = [];
this._itemsBag.lastUpdatedTime = moment().format("YYYYMMMDD HHmmss");
this._itemsBag.callbackObj = this;
}
}
}
}
receivedMessageShow(msgData) {
if (msgData) {
alert("Title: " + msgData.title + "; Message: " + msgData.message);
}
}
}
让我们从构造函数开始。构造函数定义了三个模型属性。它们用于页面的输入字段,以便用户可以输入课程注册(学生姓名、学生年龄和课程名称)。它还定义了一个包含列表、上次更新日期时间以及指向此控制器的回调引用的对象。这就是将传递给指令的对象。这个对象作为两者之间通信的媒介,我之前已经解释过。这是这个控制器类的构造函数
constructor() {
this._studentName = "";
this._studentAge = 0;
this._courseName = "";
this._itemsBag = {
allItems: [],
lastUpdatedTime: null,
callbackObj: null
};
}
我还为这个控制器定义了一系列 getter 和 setter,用于所有数据模型属性。它们是必需的,因为在 HTML 页面上,我这样引用它们:vm.studentName
,而不是 vm._studentName
。可以通过 getter 和 setter 来实现引用。这里是它们
...
set studentName(val) {
this._studentName = val;
}
get studentName() {
return this._studentName;
}
set studentAge(val) {
this._studentAge = val;
}
get studentAge() {
return this._studentAge;
}
set courseName(val) {
this._courseName = val;
}
get courseName() {
return this._courseName;
}
set itemsBag(val) {
this._itemsBag = val;
}
get itemsBag() {
return this._itemsBag;
}
...
名为“addCourseRegistration()
”的方法将取输入字段的值,创建一个小的课程注册对象并将其添加到列表中。在将对象添加到目标列表时,最后更新日期时间被设置为最新的时间戳。这将触发指令更新自己的列表。这就是父控制器与指令通信的方式。这是方法 addCourseRegistration()
addCourseRegistration() {
// I will assume all the inputs are valid.
// This is the place for input validation. Just a thought.
let itemToAdd = {
studentName: this._studentName,
studentAge: this._studentAge,
courseName: this._courseName
};
if (this._itemsBag == null) {
this._itemsBag = {
allItems: [],
lastUpdatedTime: null,
callbackObj: null
};
}
this._itemsBag.allItems.push(itemToAdd);
this._itemsBag.lastUpdatedTime = moment().format("YYYYMMMDD HHmmss");
this._itemsBag.callbackObj = this;
this.clear();
}
接下来,我定义了两个指令可以使用此控制器回调引用调用的方法。一个是显示课程注册,另一个是从列表中删除注册。正如你所见,指令控制器并没有使用回调对象调用这两个方法。指令的 HTML 代码调用了它们。这是这两个方法
...
showRegistrationInfo(item) {
console.log("callback from the directive - show item info.");
if (item) {
this._studentName = item.studentName;
this._studentAge = item.studentAge;
this._courseName = item.courseName;
}
}
deleteRegistrationInfo(item) {
console.log("callback from the directive - delete item from list.");
if (item) {
if (this._itemsBag && this._itemsBag.allItems &&
this._itemsBag.allItems.length > 0) {
let newRegList = [];
angular.forEach(this._itemsBag.allItems, function (itemToCheck) {
if (itemToCheck &&
(itemToCheck.studentName !== item.studentName ||
itemToCheck.studentAge !== item.studentAge ||
itemToCheck.courseName !== item.courseName)) {
newRegList.push(itemToCheck);
}
});
if (newRegList && newRegList.length > 0) {
this._itemsBag.allItems = newRegList;
this._itemsBag.lastUpdatedTime = moment().format("YYYYMMMDD HHmmss");
this._itemsBag.callbackObj = this;
} else {
this._itemsBag.allItems = [];
this._itemsBag.lastUpdatedTime = moment().format("YYYYMMMDD HHmmss");
this._itemsBag.callbackObj = this;
}
}
}
}
...
当项目从指令返回显示时,方法 showRegistrationInfo()
所做的只是将对象属性的值传递给输入字段。delete
方法基本上通过排除旧列表中的项目来将旧列表复制成新列表。然后将新列表重新分配给包装对象。它还更新最后更新日期时间并调用回调对象,以便通知指令。再次,这是父控制器到指令的隐式通信。
最后,我演示了如何将此控制器的方法传递给指令,以便指令可以直接调用该方法,并传递参数。我定义了这个方法
receivedMessageShow(msgData) {
if (msgData) {java -jar target/
alert("Title: " + msgData.title + "; Message: " + msgData.message);
}
}
如何测试示例应用程序
下载示例应用程序为一个 zip 文件后,请先将所有 *.sj 文件重命名为 *.js 文件。在你可以找到 pom.xml 的基本目录中,运行以下命令
mvn clean install
构建成功后,运行以下命令启动 Web 服务器
java -jar target/hanbo-angular-directive2-1.0.1.jar
应用程序成功启动后,将浏览器指向以下 URL
https://:8080/
网页将显示如下应用程序
作为用户,你可以输入课程注册。然后单击“添加”按钮将课程注册添加到列表中。你将在表中看到注册信息。这表明父控制器正在与指令正确通信。
使用表中行中的按钮,你可以显示课程注册,或从列表中删除课程注册。
最后,在表格顶部,有一个按钮,演示了从父控制器传递到指令的方法,以便指令可以直接调用它。单击它,你将看到一个弹出框显示一个静态消息。消息是在指令控制器中准备的,弹出框的显示由父指令完成。
如果你使用浏览器调试器来浏览代码,你可以看到这些控制器之间的通信正在发生。它们将让你对父组件和子组件之间这些隐式和显式通信如何工作有所了解。
摘要
就这样。今年提交的又一篇教程。在这篇教程中,我讨论了 AngularJS 指令和其父控制器之间通信的方式。在本教程中,我讨论了三种不同的方式
- 第一种方式是让父控制器将一个包装对象作为数据传递给指令,当这个包装对象的数据属性发生变化时,指令可以设置观察者来接收通知。
- 从指令的角度来看,可以通过回调对象(指向父控制器的引用)进行回传。然后可以使用此回调引用来调用父控制器的方法。
- 也可以传递父控制器的方法,然后调用这些方法。在本教程中,我作为练习的示例也允许将数据作为参数传递给方法。
所有这三种方法都会在指令与其父控制器之间产生某种类型的紧密耦合。有避免这种情况的方法。在我的示例应用程序中,我特意没有从指令的控制器通过回调引用调用父控制器的方法。相反,我允许指令的标记(HTML 标记)这样做,因此,指令控制器将不依赖于父控制器。HTML 标记确实如此。而且由于标记可以频繁更改,我只需要知道指令的标记与父控制器之间的约定,例如在标记中,我需要调用回调引用的这些方法,而父控制器应提供要调用的方法。最终,无法摆脱组件之间的依赖关系。你必须找到一种方法使其足够松散,以便一个组件的变化不会对另一个组件产生太大影响。这是一种微妙的平衡,需要程序员进行校准。希望你能找到这个教程有用。祝你好运!
历史
- 2021年10月20日 - 初稿