精通 Angular2 组件(三)
原文:
zh.annas-archive.org/md5/d9a8ce871be5683aac72df02ca27a346译者:飞龙
第八章. 时间会证明
我们的任务管理系统正在成形。然而,到目前为止,我们并未关注到管理项目的一个关键方面。时间在所有项目中都扮演着重要角色,这也是最复杂的管理事项之一。
在本章中,我们将向我们的任务管理系统添加一些功能,帮助用户更有效地管理时间。通过重用我们之前创建的一些组件,我们将能够提供一致的用户体验来管理时间。
在更高层次上,我们将开发以下功能以在我们的应用程序中实现时间管理:
-
任务详情:到目前为止,我们没有包括任务详情页面,因为所有关于任务的信息都可以在我们的项目页面上的任务列表中显示。虽然我们的时间管理将大大增加任务的复杂性,但我们将创建一个新的项目任务详情视图,它也将通过路由访问。
-
努力管理:我们将包括一些新的任务数据来管理任务上的努力。努力总是由估计的时间持续和实际花费的时间表示。我们将使努力的这两个属性都是可选的,以便它们可以独立存在。我们将创建新的组件,使用户能够轻松地提供时间持续时间输入。
-
里程碑管理:我们将包括一种管理项目里程碑的方法,并将它们映射到项目任务上。这将有助于我们后来对项目状态有一个全面的了解,并使用户能够将任务分组为更小的作业块。
本章将涵盖以下主题:
-
创建一个项目任务详情组件来编辑任务详情并启用新的路由
-
修改我们的标签管理系统以包含任务标签
-
创建新的管道来处理格式化时间持续时间
-
创建任务信息组件以在现有的任务组件上显示任务概述信息
-
创建一个时间持续时间输入组件,使用户能够轻松输入时间持续时间
-
创建一个 SVG 组件来显示任务进度
-
创建一个自动完成组件来管理任务上的里程碑
任务详情
到目前为止,我们的任务列表已经足够显示任务的所有详细信息。然而,随着我们在本章中为任务添加更多详细信息,是时候提供一个详情视图,让用户可以编辑任务了。
我们已经在本书的第五章,组件化路由中,使用路由器为项目导航奠定了基础。在项目中添加一个我们将使用的新可路由组件将变得轻而易举。
让我们在project/project-task-details/project-task-details.js路径下为我们的项目任务详情视图创建一个新的组件类:
…
@Component({
selector: 'ngc-project-task-details',
…
})
export class ProjectTaskDetails {
…
}
由于此组件在没有父Project组件的情况下将不存在,我们可以安全地依赖它来获取我们使用的数据。此组件在纯 UI 组合情况下不使用,因此不需要创建像我们在第五章“Component-Based Routing”中为其他组件创建的可路由包装组件。我们可以直接依赖路由参数,并从父Project组件中获取相关数据。
首先,我们使用依赖注入来获取父项目组件的引用:
constructor(@Inject(forwardRef(() => Project)) project) {
this.project = project;
}
类似于我们的路由包装组件,我们利用父组件注入来获取父Project组件的引用。
现在,我们将再次使用路由器的OnActivate生命周期钩子来从活动路由段中获取任务编号:
routerOnActivate(currentRouteSegment) {
const taskNr = currentRouteSegment.getParam('nr');
this.projectChangeSubscription = this.project.document.change.subscribe((data) => {
this.task = data.tasks.find((task) => task.nr === +taskNr);
this.projectMilestones = data.milestones || [];
});
}
最后,我们将创建一个对LiveDocument项目的响应式订阅,这将提取我们关心的任务并将其存储到组件的task成员中。这样,我们确保当项目在当前任务详情视图之外更新时,我们的组件将始终接收到最新的任务数据。
如果我们的组件被销毁,我们需要确保我们取消订阅由LiveDocument项目提供的 RxJS Observable。让我们为此实现ngOnDestroy生命周期钩子:
ngOnDestroy() {
this.projectChangeSubscription.unsubscribe();
}
好的,现在让我们看看我们组件的模板,看看我们将如何处理任务数据以提供一个编辑详情的接口。我们将在新的component文件夹中创建一个project-task-details.html文件:
<h3 class="task-details__title">
Task Details of task #{{task?.nr}}
</h3>
<div class="task-details__content">
<div class="task-details__label">Title</div>
<ngc-editor [content]="task?.title"
[showControls]="true"
(editSaved)="onTitleSaved($event)"></ngc-editor>
<div class="task-details__label">Description</div>
<ngc-editor [content]="task?.description"
[showControls]="true"
[enableTags]="true"
(editSaved)="onDescriptionSaved($event)">
</ngc-editor>
</div>
重新使用我们在本书第四章“No Comments, Please!”中创建的Editor组件,我们可以依靠简单的 UI 组合来使我们的任务标题和描述可编辑。
由于我们将任务数据存储到我们的组件上的task成员变量中,我们可以引用title和description字段来创建一个绑定到我们的编辑组件的content输入属性。
虽然title应仅包含纯文本,但我们可以支持我们在第七章“Components for User Experience”中创建的标签功能,在任务的description字段上。为此,我们只需将描述Editor组件的enableTags输入属性设置为true。
Editor组件有一个editSaved输出属性,当用户保存其编辑时将发出更新后的内容。现在,我们只需要确保我们创建一个绑定到我们的组件,以持久化这些更改。让我们在我们的Component类上创建onTitleSaved和onDescriptionSaved方法来处理这些事件:
onTitleSaved(title) {
this.task.title = title;
this.project.document.persist();
}
onDescriptionSaved(description) {
this.task.description = description;
this.project.document.persist();
}
任务成员只是对Project组件中的LiveDocument项目所给任务的参考。这简化了我们持久化任务上更改的数据的方式。在更新任务上的给定属性后,我们只需在LiveDocument项目上调用persist方法来将我们的更改存储在数据存储中。
到目前为止,一切顺利。我们创建了一个任务详情组件,使用我们的Editor UI 组件可以轻松编辑任务的标题和描述。我们唯一剩下要启用我们的组件的事情是在Project组件上创建一个子路由。让我们打开lib/project/project.js中的Project组件类,进行必要的修改:
…
import {ProjectTaskDetails} from './project-task-details/project-task-details';
…
@Component({
selector: 'ngc-project',
…
})
@Routes([
new Route({ path: 'task/:nr', component: ProjectTaskDetails}),
…
])
export class Project {
…
}
我们在Project组件上添加了一个新的子路由,该路由负责实例化我们的ProjectTaskDetails组件。通过在路由配置中包含一个:nr参数,我们可以将相关的任务编号传递给ProjectTaskDetails组件。
我们新创建的子路由现在可以在路由器中访问,我们可以使用/projects/project-1/task/1示例 URL 访问任务详情视图。
为了使我们的TaskDetails路由可导航,我们需要在我们的Task组件中添加一个导航链接,以便用户可以在项目任务列表中导航到它。
对于这个相对简单的任务,我们唯一需要做的事情是使用RouterLink指令在Task模板lib/task-list/task/task.html中创建一个新的链接:
…
<div class="task__l-box-b">
…
<a [routerLink]="['../task', task?.nr]"
class="button button--small">Details</a>
</div>
…
我们在这里使用相对路由 URL,因为我们已经在/project/tasks路由上。由于我们的task/:nr路由是项目路由的一部分,我们需要回退一级以访问task路由:
新创建的任务详情视图,具有可编辑的标题和描述
启用任务标签
到目前为止,我们在第七章中创建的标签管理系统,用户体验组件,仅支持项目标签。由于我们现在创建了一个任务详情视图,因此也直接在我们的标签系统中支持任务标签会很好。我们的标签系统非常灵活,我们可以以非常少的努力实现新的标签。在更高层次上,我们需要进行以下更改以在我们的系统中启用任务标签:
-
编辑
generate-tag.js模块以支持从任务和项目数据生成任务标签 -
编辑
TagsService以使用generate-tag.js模块和缓存初始化任务标签
让我们先修改lib/tags/generate-tag.js文件以启用任务标签生成:
…
export const TAG_TYPE_TASK = 'task';
export function generateTag(subject) {
if (subject.type === TAG_TYPE_PROJECT) {
…
} else if (subject.type === TAG_TYPE_TASK) {
// If we're dealing with a task, we generate the according tag
// object
return new Tag(
`#${subject.project._id}-task-${subject.task.nr}`,
`${limitWithEllipsis(subject.task.title, 20)} (${subject.task.done ? 'done' : 'open'})`,
`#/projects/${subject.project._id}/task/${subject.task.nr}`,
TAG_TYPE_TASK
);
}
}
由于我们需要同时引用项目数据和此项目的单个任务,我们期望subject参数看起来像以下对象:
{task: …, project: …, type: TAG_TYPE_TASK}
从这个subject对象中,我们可以创建一个新的Tag对象。对于textTag字段,我们使用一个包含项目 ID 以及任务编号的结构。这样,我们可以使用简单的文本表示来唯一标识任务。
对于link字段,我们从项目以及任务编号构建一个 URL。这个字符串将解析为激活我们在上一节中配置的TaskDetails路由所需的 URL。
我们现在已准备好generateTag函数来创建任务标签。现在,我们系统中启用任务标签的唯一剩余操作是对TagsService类的修改。让我们打开lib/tags/tags-service.js文件并应用我们的更改:
…
import {generateTag, TAG_TYPE_TASK} from './generate-tag';
…
@Injectable()
export class TagsService {
…
// This method is used internally to initialize all available
// tags
initializeTags() {
…
// Let's also create task tags
this.projects.forEach((project) => {
this.tags = this.tags.concat(project.tasks.map((task) => {
return {
type: TAG_TYPE_TASK,
project,
task
};
}).map(generateTag));
});
…
}
…
}
在我们的TagsService类的initializeTags方法中,我们现在为项目中所有可用的任务添加任务Tag对象。首先,我们通过generateTag函数将每个项目任务映射到所需的subject对象。然后,我们可以简单地使用generateTag函数直接映射结果数组。结果是生成任务Tag对象的数组,然后我们将它们连接到TagsService类的tags列表中。
这并不太复杂,对吧?这个相对简单的更改为我们用户带来了巨大的改进。现在,他们可以在我们系统中任何我们启用了标签的地方引用单个任务:
显示新添加任务标签的编辑器组件
管理努力
在本节中,我们将创建一些组件,帮助我们跟踪努力。主要地,我们将使用这些组件来管理任务上的努力,但这可以应用于我们应用中的任何需要跟踪时间的部分。
在我们的语境中,努力总是由两个组成部分组成:
-
预计持续时间:这是对任务最初估计的持续时间
-
有效持续时间:这是在特定任务上花费的时间长度
对于时间长度,我们假设一些时间单位和规则,这将简化时间的处理并符合某些工作标准。这里的目的是不提供锐利的时管理,而是提供足够准确以带来价值的东西。为此,我们定义以下工作时间单位:
-
分钟:一分钟是标准的 60 秒
-
小时:一小时总是代表 60 分钟
-
天:一天代表一个标准的工作日,八小时
-
周:一周相当于五个工作日(5 * 8 小时)
时间持续时间输入
现在,我们可以开始编写一个复杂的用户界面组件,用户可以在不同的输入元素中输入单独的时间单位。然而,我相信用无 UI 方法处理时间持续时间输入会更方便。因此,我们不必构建复杂的用户界面,而可以简单地约定一个文本简写形式来编写持续时间,并让用户输入一些内容,例如1.5d或5h 30m,以提供输入。按照我们之前建立的约定,我们可以构建一个简单的解析器来处理这种输入。
这种方法有几个优点。除此之外,这也是输入时间持续的最有效方法之一,而且对我们来说也很容易实现。我们可以简单地重用我们的Editor组件来收集用户的文本输入。然后,我们使用一个转换过程来解析输入的时间持续时间。
让我们启动一个新的模块,帮助我们处理这些转换。我们在lib/utilities/time-utilities.js文件中创建一个新的模块。
首先,我们需要一个常量来定义我们需要的所有转换单位:
export const UNITS = [{
short: 'w',
milliseconds: 5 * 8 * 60 * 60 * 1000
}, {
short: 'd',
milliseconds: 8 * 60 * 60 * 1000
}, {
short: 'h',
milliseconds: 60 * 60 * 1000
}, {
short: 'm',
milliseconds: 60 * 1000
}];
这是我们目前需要处理的全部单位。您可以看到在解释时计算的毫秒数。我们也可以将毫秒数写成常量,但这为我们提供了如何得到这些值的透明度,并且我们可以添加一些注释。
让我们看看我们的解析函数,我们可以用它将文本输入解析为时间持续时间:
export function parseDuration(formattedDuration) {
const pattern = /[\d\.]+\s*[wdhm]/g;
let timeSpan = 0;
let result;
while (result = pattern.exec(formattedDuration)) {
const chunk = result[0].replace(/\s/g, '');
let amount = Number(chunk.slice(0, -1));
let unitShortName = chunk.slice(-1);
timeSpan += amount * UNITS.find(
(unit) => unit.short === unitShortName
).milliseconds;
}
return +timeSpan || null;
}
让我们简要分析一下前面的代码,以解释我们在这里做了什么:
-
首先,我们定义一个正则表达式,帮助我们分解持续时间文本表示。这个模式将提取文本输入中的重要部分,用于计算文本表示背后的持续时间。这些部分总是由一个数字后面跟着
w、d、h或m组成。因此,文本10w 3d 2h 30m将被分割成10w、3d、2h和30m这些部分。 -
我们将
timeSpan变量初始化为0,这样我们就可以将发现的块中的所有毫秒数加在一起,然后返回这个总和。 -
对于之前提取的每个部分,我们现在将数字组件提取到一个名为
amount的变量中,将单位(w、d、h或m)提取到一个名为unitShortName的变量中。 -
现在,我们可以查找
UNITS常量中的数据,为我们将要处理的块的单位,将单位的毫秒数乘以我们从块中提取的量,然后将这个结果加到我们的timeSpan变量中。
好吧,这是我们构建的一个相当整洁的函数。它接受一个格式化的时间持续时间字符串,并将其转换为毫秒。这已经是我们需要处理文本表示的时间持续期的半部分了。第二部分是parseDuration函数的相反,将毫秒持续时间转换为格式化的持续时间字符串:
export function formatDuration(timeSpan) {
return UNITS.reduce((str, unit) => {
const amount = timeSpan / unit.milliseconds;
if (amount >= 1) {
const fullUnits = Math.floor(amount);
const formatted = `${str} ${fullUnits}${unit.short}`;
timeSpan -= fullUnits * unit.milliseconds;
return formatted;
} else {
return str;
}
}, '').trim();
}
让我们也简要解释一下formatDuration函数的作用:
-
我们使用
Array.prototype.reduce函数来格式化包含所有时间单位和它们数量的字符串。我们从UNITS常量中的最大单位(周)开始,遍历所有可用的时间单位。 -
然后,我们将以毫秒为单位的
timeSpan变量除以单位的毫秒数,得到给定单位的数量。 -
如果数量大于或等于 1,我们可以将给定的数量和单位简称添加到我们的格式化字符串中。
-
由于在数量的小数点后可能留下一些分数,我们需要将这些分数编码到更小的单位中,所以我们从
timeSpan中减去我们数量的向下取整版本,然后再返回到reduce函数。 -
这个过程会为每个单位重复,其中每个单位只有在数量大于或等于 1 时才会提供格式化输出。
这就是我们需要的所有内容,可以将格式化时间长度和以毫秒表示的时间长度相互转换。
在我们创建实际的时间长度输入组件之前,我们还将做一件事。我们将创建一个简单的管道,它基本上只是包装我们的formatTime函数。为此,我们将创建一个新的lib/pipes/format-duration.js文件:
import {Pipe, Inject} from '@angular/core';
import {formatDuration} from '../utilities/time-utilities';
@Pipe({
name: 'formatDuration'
})
export class FormatDurationPipe {
transform(value) {
if (value == null || typeof value !== 'number') {
return value;
}
return formatDuration(value);
}
}
使用我们的time-utilities模块中的formatTime函数,我们现在可以直接在我们的模板中以毫秒为单位格式化持续时间。
管理努力的组件
好的,现在我们已经有了足够的时间数学知识。现在让我们使用我们创建的元素来构建一些组件,这些组件将帮助我们收集用户输入。
在本节中,我们将创建两个组件来管理努力:
-
持续时间:持续时间组件是一个简单的 UI 组件,它允许用户使用我们在前几节中处理过的格式化时间字符串输入时间长度。它使用Editor组件来启用用户输入,并使用FormatTimePipe管道以及parseDuration实用函数。 -
努力:努力组件只是两个持续时间组件的组合,这两个组件分别表示给定任务上的估计努力和实际花费的努力。遵循严格的组合规则,这个组件对我们来说很重要,这样我们就不需要重复自己,而是组合一个更大的组件。
让我们从Duration组件类开始,并创建一个新的lib/ui/duration/duration.js文件:
…
import {FormatDurationPipe} from '../../pipes/format-duration';
import {Editor} from '../../ui/editor/editor';
import {parseDuration} from '../../utilities/time-utilities';
@Component({
selector: 'ngc-duration',
…
directives: [Editor],
pipes: [FormatDurationPipe]
})
export class Duration {
@Input() duration;
@Output() durationChange = new EventEmitter();
onEditSaved(formattedDuration) {
this.durationChange.next(formattedDuration ?
parseDuration(formattedDuration) : null);
}
}
这个组件实际上并没有什么特别之处,因为我们已经创建了大部分逻辑,我们只是将一个高级组件组合在一起。
作为duration输入,我们期望一个以毫秒为单位的时间长度,而durationChange输出属性将在用户提供输入时发出事件。
onEditSaved方法用于将我们的组件与编辑器组件绑定。每当用户在编辑器组件上保存其编辑时,我们将获取此输入,使用parseDuration函数将格式化的时长转换为毫秒,并使用durationChange输出属性重新发出转换后的值。
让我们看看我们的组件模板,在lib/ui/duration/duration.html文件中:
<ngc-editor [content]="duration | formatDuration"
[showControls]="true"
(editSaved)="onEditSaved($event)"></ngc-editor>
对我们的模板如此简单感到惊讶吗?好吧,这正是我们在建立了良好的基础组件之后,应该通过更高组件实现的目标。良好的组织结构极大地简化了我们的代码。我们在这里唯一处理的是我们那熟悉的编辑器组件。
我们将我们的时长组件的duration输入属性绑定到编辑器组件的内容输入属性。由于我们希望传递格式化的时长而不是毫秒数,我们在绑定表达式中使用FormatDurationPipe管道进行转换。
如果编辑器组件通知我们已保存的编辑,我们将在我们的时长组件上调用onEditSaved方法,该方法将解析输入的时长并重新发出结果值。
由于我们最初定义所有努力都包括估计时长和有效时长,我们现在想创建另一个组件,该组件结合这两个时长。
让我们在lib/efforts/efforts.html路径上创建一个新的Efforts组件,从一个新的模板开始:
<div class="efforts__label">Estimated:</div>
<ngc-duration [duration]="estimated"
(durationChange)="onEstimatedChange($event)">
</ngc-duration>
<div class="efforts__label">Effective:</div>
<ngc-duration [duration]="effective"
(durationChange)="onEffectiveChange($event)">
</ngc-duration>
<button class="button button--small"
(click)="addEffectiveHours(1)">+1h</button>
<button class="button button--small"
(click)="addEffectiveHours(4)">+4h</button>
<button class="button button--small"
(click)="addEffectiveHours(8)">+1d</button>
首先,我们添加两个标记为Duration的组件,其中第一个用于收集估计时间的输入,而后者用于有效时间。
此外,我们还提供了三个小按钮,通过简单的点击来增加有效时长。这样,用户可以快速增加一或四小时(半个工作日)或完整的工作日(我们定义为八小时)。
看一下Component类,不应该有任何惊喜。让我们打开lib/efforts/efforts.js组件类文件:
…
import {Duration} from '../ui/duration/duration';
import {UNITS} from '../utilities/time-utilities';
@Component({
selector: 'ngc-efforts',
…
directives: [Duration]
})
export class Efforts {
@Input() estimated;
@Input() effective;
@Output() effortsChange = new EventEmitter();
onEstimatedChange(estimated) {
this.effortsChange.next({
estimated,
effective: this.effective
});
}
onEffectiveChange(effective) {
this.effortsChange.next({
effective,
estimated: this.estimated
});
}
addEffectiveHours(hours) {
this.effortsChange.next({
effective: (this.effective || 0) +
hours * UNITS.find((unit) => unit.short === 'h'),
estimated: this.estimated
});
}
}
该组件提供了两个单独的输入,用于估计和有效时间时长(以毫秒为单位)。如果您再次查看组件模板,这些输入属性直接绑定到时长组件的输入属性。
onEstimatedChange和onEffectiveChange方法用于创建到时长组件的durationChange输出属性的绑定。我们在这里所做的一切就是发出一个包含有效时间和估计时间(以毫秒为单位)的聚合数据对象,使用effortsChange输出属性。
在addEffectiveHours方法中,我们简单地发出一个effortsChange事件,并通过计算出的毫秒数更新有效属性。我们使用来自time-utilities模块的UNITS常量来获取小时的毫秒数。
为了提供用户输入来管理任务上的努力,我们需要这些所有信息。为了完成这个主题,我们将把新创建的Efforts组件添加到ProjectTaskDetail组件中,以便管理任务上的努力。
让我们首先查看位于lib/project/project-task-detail/project-task-detail.js的Component类中的代码更改:
…
import {Efforts} from '../../efforts/efforts';
@Component({
selector: 'ngc-project-task-details',
…
directives: [Editor, Efforts]
})
export class ProjectTaskDetails {
…
onEffortsChange(efforts) {
if (!efforts.estimated && !efforts.effective) {
this.task.efforts = null;
} else {
this.task.efforts = efforts;
}
this.project.document.persist();
}
…
}
除了将Efforts组件添加到我们的ProjectTaskDetail组件的directives列表中,我们还添加了一个新的onEffortsChange方法来处理Efforts组件提供的输出。
如果既未设置估计和实际努力,或设置为0,我们将任务努力设置为null。否则,我们使用Efforts组件的输出数据并将其分配为我们新的任务努力。
在更改任务努力后,我们以与标题和描述更新相同的方式持久化项目的LiveDocument。
让我们检查位于lib/project/project-task-detail/project-task-detail.html的组件模板中的更改:
…
<div class="task-details__content">
…
<div class="task-details__label">Efforts</div>
<ngc-efforts [estimated]="task?.efforts?.estimated"
[effective]="task?.efforts?.effective"
(effortsChange)="onEffortsChange($event)">
</ngc-efforts>
</div>
我们将Efforts组件的估计和实际输入属性绑定到ProjectTaskDetail组件的任务数据中。对于effortsChange输出属性,我们使用一个表达式来调用我们刚刚创建的onEffortsChange方法:
我们的新Efforts组件由两个持续时间输入组件组成
视觉上的努力时间线
尽管我们迄今为止创建的用于管理努力的组件提供了编辑和显示努力和时间持续的好方法,但我们仍然可以通过一些视觉指示来改进这一点。
在本节中,我们将使用 SVG 创建一个视觉上的努力时间线。此时间线应显示以下信息:
-
总估计持续时间作为一个灰色背景条
-
总实际持续时间作为一个绿色条,它覆盖在总估计持续时间条上
-
一个显示任何加班(如果实际持续时间大于估计持续时间)的黄色条
下面的两个图示说明了我们的努力时间线组件的不同视觉状态:
当估计持续时间大于实际持续时间时的视觉状态
当实际持续时间超过估计持续时间时的视觉状态(加班显示为黑色条)
让我们在lib/efforts/efforts-timeline/efforts-timeline.js路径上创建一个新的EffortsTimeline组件类,以具体化我们的组件:
…
@Component({
selector: 'ngc-efforts-timeline',
…
})
export class EffortsTimeline {
@Input() estimated;
@Input() effective;
@Input() height;
ngOnChanges(changes) {
this.done = 0;
this.overtime = 0;
if (!this.estimated && this.effective ||
(this.estimated && this.estimated === this.effective)) {
// If there's only effective time or if the estimated time
// is equal to the effective time we are 100% done
this.done = 100;
} else if (this.estimated < this.effective) {
// If we have more effective time than estimated we need to
// calculate overtime and done in percentage
this.done = this.estimated / this.effective * 100;
this.overtime = 100 - this.done;
} else {
// The regular case where we have less effective time than
// estimated
this.done = this.effective / this.estimated * 100;
}
}
}
我们的组件有三个输入属性:
-
estimated:这是估计时间持续时间的毫秒数 -
effective:这是实际时间持续时间的毫秒数 -
height:这是努力时间线期望的高度,以像素为单位
在OnChanges生命周期钩子中,我们设置了两个基于估计和实际时间的组件成员字段:
-
done:这包含显示没有超过估算持续时间的有效持续时间的绿色条宽度百分比 -
overtime:这包含显示任何加班的黄色条宽度百分比,任何超过估算持续时间的持续时间
让我们看看EffortsTimeline组件的模板,看看我们如何现在使用done和overtime成员字段来绘制我们的时间线。
我们将创建一个新的lib/efforts/efforts-timeline/efforts-timeline.html文件:
<svg width="100%" [attr.height]="height">
<rect [attr.height]="height"
x="0" y="0" width="100%"
class="efforts-timeline__remaining"></rect>
<rect *ngIf="done" x="0" y="0"
[attr.width]="done + '%'" [attr.height]="height"
class="efforts-timeline__done"></rect>
<rect *ngIf="overtime" [attr.x]="done + '%'" y="0"
[attr.width]="overtime + '%'" [attr.height]="height"
class="efforts-timeline__overtime"></rect>
</svg>
我们的模板是基于 SVG 的,它包含我们想要显示的每个条的三个矩形。如果有剩余的努力,将始终显示背景条形图。
在剩余的条形图上方,我们使用从我们的组件类计算出的宽度有条件地显示完成和加班条形图。
现在,我们可以继续在我们的Efforts组件中包含EffortsTimeline类。这样,当我们的用户编辑估算或实际持续时间时,他们将获得视觉反馈,这为他们提供了一个概览。
让我们看看Efforts组件的模板,看看我们如何集成时间线:
…
<ngc-efforts-timeline height="10"
[estimated]="estimated"
[effective]="effective">
</ngc-efforts-timeline>
由于我们在Efforts组件中已经有了估算和实际持续时间,我们可以简单地创建一个绑定到EffortsTimeline组件输入属性:
显示我们新创建的努力时间线组件的Efforts组件(六小时的加班用黄色条可视化)
努力管理的总结
在本节中,我们将创建允许用户轻松管理努力并为我们任务添加简单但强大的时间跟踪的组件。我们已经做了以下事情来实现这一点:
-
我们实现了一些实用函数来处理时间数学,以便将毫秒时间段转换为格式化时间段,反之亦然
-
我们创建了一个管道,使用我们的实用函数格式化以毫秒为单位的时间段
-
我们创建了一个
DurationUI 组件,它包装了一个Editor组件,并使用我们的时间实用工具提供了一个无 UI 类型的输入元素来输入持续时间 -
我们创建了一个
Efforts组件,它作为两个Duration组件的组合,用于估算和实际时间,并提供额外的按钮来快速添加实际花费的时间 -
我们将
Efforts组件集成到ProjectTaskDetail组件中,以便在任务上管理努力 -
我们使用 SVG 创建了一个可视的
EffortsTimeline组件,它显示任务的总体进度
设置里程碑
跟踪时间很重要。我不知道你对时间的看法如何,但我在组织时间方面真的很差。尽管很多人问我如何做到这么多事情,但我相信我实际上在管理如何完成这些事情方面真的很差。如果我能成为一个更好的组织者,我可以用更少的精力完成事情。
总有一件事能帮助我组织自己,那就是将事情分解成更小的工作包。使用我们任务管理应用程序来组织自己的用户可以通过在项目中创建任务来实现这一点。虽然项目是整体目标,但我们可以创建更小的任务来实现这个目标。然而,有时我们只专注于任务时,往往会失去对整体目标的关注。
里程碑是项目和任务之间完美的粘合剂。它们确保我们将任务捆绑成更大的包。这将极大地帮助我们组织任务,并且我们可以查看项目的里程碑来了解项目的整体健康状况。然而,当我们以里程碑的上下文工作时,我们仍然可以专注于任务。
在本节中,我们将创建必要的组件,以便将基本里程碑功能添加到我们的应用程序中。
为了在我们的应用程序中实现里程碑功能,我们将坚持以下设计决策:
-
里程碑应存储在项目级别,并且任务可以包含对项目里程碑的可选引用。
-
为了保持简单,与里程碑的唯一交互点应该在任务级别。因此,里程碑的创建将在任务级别完成,尽管创建的里程碑将存储在项目级别。
-
目前里程碑仅包含一个名称。我们可以在系统中构建更多关于里程碑的内容,例如截止日期、依赖关系和其他美好的事物。然而,我们将坚持最基本的原则,即里程碑名称。
创建自动完成组件
为了保持里程碑管理的简单性,我们将创建一个新的用户界面组件来处理我们列出的设计问题。我们的新自动完成组件不仅会显示可供选择的可能值,而且还会允许我们创建新项目。然后我们可以简单地使用这个组件在我们的ProjectTaskDetail组件上,以便管理里程碑。
让我们来看看我们将在lib/ui/auto-complete/auto-complete.js文件中创建的新自动完成组件的Component类:
…
import {Editor} from '../editor/editor';
@Component({
selector: 'ngc-auto-complete',
…
directives: [Editor]
})
export class AutoComplete {
@Input() items;
@Input() selectedItem;
@Output() selectedItemChange = new EventEmitter();
@Output() itemCreated = new EventEmitter();
…
}
再次强调,我们的Editor组件可以被重用来创建这个高级组件。我们很幸运地创建了一个如此好的组件,因为它在这个项目中节省了我们大量的时间。
让我们更详细地看看AutoComplete组件的输入和输出属性:
-
items:这是我们期望的字符串数组。这将是在编辑器中输入时用户可以选择的项目列表。 -
selectedItem:这是我们将选中的项目作为一个输入属性来使这个组件成为纯组件的时候,并且我们可以依赖父组件来设置这个属性。 -
selectedItemChange:这个输出属性会在选中的项目发生变化时触发事件。由于我们在这里创建了一个纯组件,我们需要以某种方式传播在自动完成列表中选中的项目的相关事件。 -
itemCreated:如果向自动完成列表添加了新项,此输出属性将发出事件。更新项目列表和更改组件items输入属性仍然是父组件的责任。
让我们在组件中添加更多代码。我们使用Editor组件作为主要输入源。当我们的用户在编辑器中键入时,我们使用编辑器的文本输入来过滤可用的项。让我们为此创建一个filterItems:
filterItems(filter) {
this.filter = filter || '';
this.filteredItems = this.items
.filter(
(item) => item
.toLowerCase()
.indexOf(this.filter.toLowerCase().trim()) !== -1)
.slice(0, 10);
this.exactMatch = this.items.includes(this.filter);
}
filterItems方法有一个单一参数,即我们想要用于在列表中搜索相关项的文本。
让我们更详细地看看该方法的内容:
-
为了在模板中使用,我们将保存上一次调用此方法时使用的过滤查询。
-
在
filteredItems成员变量中,我们将通过搜索过滤字符串的文本出现来存储项目列表的过滤版本。 -
作为最后一步,我们还存储了搜索查询是否导致我们的列表中某个项的精确匹配的信息
现在,我们需要确保如果items或selectedItem输入属性发生变化,我们也再次执行我们的过滤方法。为此,我们简单地实现了ngOnChanges生命周期钩子:
ngOnChanges(changes) {
if (this.items && this.selectedItem) {
this.filterItems(this.selectedItem);
}
}
现在我们来看看我们如何处理Editor组件提供的事件:
onEditModeChange(editMode) {
if (editMode) {
this.showCallout = true;
this.previousSelectedItem = this.selectedItem;
} else {
this.showCallout = false;
}
}
如果编辑器切换到编辑模式,我们希望保存之前选中的项。如果用户决定取消他的编辑并切换回之前的项,我们将需要这样做。当然,这也是我们需要向用户显示自动完成列表的地方。
另一方面,如果将编辑模式切换回阅读模式,我们希望再次隐藏自动完成列表:
onEditableInput(content) {
this.filterItems(content);
}
editableInput事件在每次编辑器输入更改时由我们的编辑器触发。该事件为我们提供了用户输入的文本内容。如果发生此类事件,我们需要再次使用更新的过滤查询执行我们的过滤函数:
onEditSaved(content) {
if (content === '') {
this.selectedItemChange.next(null);
} else if (content !== this.selectedItem &&
!this.items.includes(content)) {
this.itemCreated.next(content);
}
}
当我们的编辑器触发editSaved事件时,我们需要决定是否应该执行以下操作之一:
-
如果保存的内容是空字符串,则使用
selectedItemChange输出属性发出事件,向父组件信号已删除选定的项。 -
如果提供了有效内容并且我们的列表中不包含具有该名称的项,则使用
itemCreated输出属性发出事件,以信号项的创建:onEditCanceled() { this.selectedItemChange.next(this.previousSelectedItem); }
在Editor组件的editCanceled事件上,我们希望切换回之前选中的项。为此,我们可以简单地使用selectedItemChange输出属性和我们在编辑器切换到编辑模式后留出的previousSelectedItem成员来发出一个事件。
这些是我们将用于连接我们的编辑器并将自动完成功能附加到其上的所有绑定函数。
在我们查看自动完成组件的模板之前,我们将创建两个更简单的其他方法:
selectItem(item) {
this.selectedItemChange.next(item);
}
createItem(item) {
this.itemCreated.next(item);
}
我们将使用这两个用于模板中自动完成提示的点击操作。让我们看一下模板,以便你可以看到我们刚刚创建的所有代码的实际效果:
<ngc-editor [content]="selectedItem"
[showControls]="true"
(editModeChange)="onEditModeChange($event)"
(editableInput)="onEditableInput($event)"
(editSaved)="onEditSaved($event)"
(editCanceled)="onEditCanceled($event)"></ngc-editor>
首先,放置Editor组件,并将我们创建的Component类中的处理方法所需的所有绑定附加到它上。
现在,我们将创建一个自动完成列表,它将作为用户在编辑器输入区域旁边的提示显示:
<ul *ngIf="showCallout" class="auto-complete__callout">
<li *ngFor="let item of filteredItems"
(click)="selectItem(item)"
class="auto-complete__item"
[class.auto-complete__item--selected]="item === selectedItem">{{item}}</li>
<li *ngIf="filter && !exactMatch"
(click)="createItem(filter)"
class="auto-complete__item auto-complete__item--create">Create "{{filter}}"</li>
</ul>
我们依赖于Component类的onEditModeChange方法设置的showCallout成员变量,以表示是否应该显示自动完成列表。
然后,我们使用NgFor指令遍历所有过滤后的项目,并渲染每个项目的文本内容。如果点击了某个项目,我们将调用我们的selectItem方法,并将相关项目作为参数值。
作为最后一个列表元素,在重复的列表项之后,我们条件性地显示一个额外的列表元素,以创建一个不存在的里程碑。我们仅在存在有效的过滤器且过滤器与现有里程碑没有精确匹配时显示此按钮:
我们的里程碑组件与编辑器组件配合得很好,使用干净的组合方式。
现在我们已经完成了自动完成组件,为了管理项目里程碑,我们唯一需要做的就是将其用于ProjectTaskDetails组件中。
让我们打开位于lib/project/project-task-details/project-task-details.js中的Component类,并应用必要的修改:
…
import {AutoComplete} from '../../ui/auto-complete/auto-complete';
@Component({
selector: 'ngc-project-task-details',
…
directives: […, AutoComplete]
})
export class ProjectTaskDetails {
constructor(@Inject(forwardRef(() => Project)) project, {
…
this.projectChangeSubscription = this.project.document.change.subscribe((data) => {
…
this.projectMilestones = data.milestones || [];
});
}
…
onMilestoneSelected(milestone) {
this.task.milestone = milestone;
this.project.document.persist();
}
onMilestoneCreated(milestone) {
this.project.document.data.milestones = this.project.document.data.milestones || [];
this.project.document.data.milestones.push(milestone);
this.task.milestone = milestone;
this.project.document.persist();
}
…
}
在项目更改的订阅中,我们现在还提取任何现有的项目里程碑,并将它们存储在projectMilestones成员变量中。这使得在模板中引用它们更容易。
onMilestoneSelected方法将被绑定到AutoComplete组件的selectItemChange输出属性上。我们使用AutoComplete组件发出的值来设置我们的任务里程碑,并使用其persist方法持久化LiveDocument项目。
onMilestoneCreated方法将被绑定到AutoComplete组件的itemCreated输出属性上。在这种情况下,我们将创建的里程碑添加到项目的里程碑列表中,并将当前任务分配给创建的里程碑。更新LiveDocument数据后,我们使用persist方法保存所有更改。
让我们查看lib/project/project-task-details/project-task-details.html,以查看模板中必要的更改:
…
<div class="task-details__content">
…
<ngc-auto-complete [items]="projectMilestones"
[selectedItem]="task?.milestone"
(selectedItemChange)="onMilestoneSelected($event)"
(itemCreated)="onMilestoneCreated($event)">
</ngc-auto-complete>
</div>
除了你已经知道的输出属性绑定之外,我们还为AutoComplete组件的items和selectedItem输入属性创建了两个输入绑定。
这就是全部了。我们创建了一个新的 UI 组件,提供了自动完成功能,并使用该组件在我们的任务中实现了里程碑管理。
使用具有适当封装的组件实现新功能突然变得如此简单,这不是很好吗?面向组件的开发的好处在于,你为新的功能开发时间随着你已创建的可重用组件的数量而减少。
摘要
在本章中,我们实现了一些帮助用户跟踪时间的组件。现在,他们可以在任务上记录努力,并在项目上管理里程碑。我们创建了一个新的任务详情视图,可以通过任务列表上的导航链接访问。
再次,我们体验到了使用组件进行组合的力量,通过重用现有组件,我们能够轻松实现提供更复杂功能的高级组件。
在下一章中,我们将探讨如何使用图表库 Chartist 并创建一些包装组件,使我们能够构建可重用的图表。我们将为我们的任务管理系统构建一个仪表板,在那里我们将看到我们的图表组件的实际应用。
第九章。太空船仪表盘
当我还是个孩子的时候,我喜欢扮演太空船飞行员。我把一些旧的纸箱堆叠起来,并装饰内部使其看起来像太空船驾驶舱。我用记号笔在纸箱的内侧画了一个太空船仪表盘,我记得我在那里玩了好几个小时。
舰桥和太空船仪表盘的设计特别之处在于,它们需要在非常有限的空间内提供对整个太空船的概述和控制。我认为这同样适用于应用程序仪表盘。仪表盘应该为用户提供对正在发生的事情的整体概述和感知。
在本章中,我们将为我们的任务管理应用程序创建这样一个仪表盘。我们将利用开源图表库 Chartist 创建外观美观的响应式图表,并提供对开放任务和项目状况的概述:
我们将在本章的进程中预览将要构建的任务图表
在更高层次上,在本章中我们将创建以下组件:
-
项目摘要:这是提供对整体项目状况快速洞察的项目摘要。通过聚合所有包含任务的努力,我们可以提供一个很好的整体努力状态,为此我们在上一章中创建了组件。
-
项目活动图表:没有标签或刻度,这个条形图将只给出过去 24 小时内项目活动的快速感知。
-
项目任务图表:此图表提供了项目任务进度的概述。我们将使用折线图显示一定时间内的开放任务数量。利用我们在本书第二章中创建的 Toggle 组件,我们将为用户提供一种简单的方法来切换图表上显示的时间范围。
Chartist 简介
在本章中,我们将创建一些可以渲染图表的组件,并且我们应该寻找一些帮助来渲染它们。当然,我们可以遵循我们在第六章中采取的类似方法,即跟上活动,当时我们绘制了我们的活动时间线。然而,当涉及到更复杂的数据可视化时,最好依赖于库来完成繁重的工作。
我们将使用 Chartist 来填补这个空缺并不令人惊讶,因为我几乎花了两年时间来编写它。作为 Chartist 的作者,我感到非常幸运,我们在这本书中找到了一个完美的位置来利用它。
在我们深入到仪表板组件的实现之前,我想借此机会简要地向您介绍 Chartist。
Chartist 的承诺很简单,即简单响应式图表,幸运的是,在存在了三年之后,这仍然是事实。我可以告诉你,维护这个库最困难的工作可能是保护它免受功能膨胀的影响。开源社区中有许多伟大的运动、技术和想法,抵制并始终专注于最初的承诺并不容易。
让我给你一个非常基本的例子,看看你如何在网站上包含 Chartist 脚本后创建一个简单的折线图:
const chart = new Chartist.Line('#chart', {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
series: [
[10, 7, 2, 8, 5]
]
});
为此示例所需的相应 HTML 标记看起来如下所示:
<body>
<div id="chart" class="ct-golden-section"></div>
</body>
以下图表展示了由 Chartist 生成的结果图表:
使用 Chartist 生成的简单折线图
我认为,通过说我们一直在坚持我们的简单承诺,我们并没有承诺太多。
让我们来看看 Chartist 的第二个核心关注点,即完美响应。嗯,让我们从我在前端开发中最欣赏的一个原则开始,那就是关注点的分离。Chartist 尽可能地遵循这个原则,这意味着它使用 CSS 来控制外观,SVG 来构建基本的图形结构,以及 JavaScript 来实现任何行为。仅仅通过遵循这个原则,我们已经实现了很多响应性。我们可以使用 CSS 媒体查询来为不同媒体上的图表应用不同的样式。
虽然 CSS 对于视觉样式来说很棒,但在渲染图表的过程中有许多元素不能仅仅通过样式来控制。毕竟,这就是我们为什么使用 JavaScript 库来渲染图表的原因。
那么,如果我们没有在 CSS 中控制 Chartist 在不同媒体上渲染图表的方式,我们该如何控制呢?嗯,Chartist 提供了一种称为响应式配置覆盖的功能。通过使用浏览器的 matchMedia API,Chartist 能够提供一个配置机制,允许您指定在某些媒体上要覆盖的选项。
让我们看看如何通过移动优先的方法轻松实现响应性行为的一个简单示例:
const chart = new Chartist.Line('#chart', {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],
series: [
[10, 7, 2, 8, 5]
]
}, {
showPoint: true,
showLine: true
}, [
['screen and (min-width: 400px)', {
showPoint: false
}],
['screen and (min-width: 800px)', {
lineSmooth: false
}]
]);
在这里,Chartist.Line 构造函数的第二个参数设置了初始选项;我们可以将带有媒体查询的覆盖选项作为构造函数的第三个参数提供一个数组。在这个例子中,我们将覆盖宽度大于 400 像素的任何媒体的 showPoint 选项。宽度大于 800 像素的媒体将接收到 showPoint 覆盖以及 lineSmooth 覆盖。
我们不仅可以通过指定真实的媒体查询来触发设置更改,还可以使用与 CSS 非常相似的覆盖机制。这样,我们可以实现各种方法,如范围或排他性媒体查询、移动优先或桌面优先。这个响应式选项机制可以用于 Chartist 中所有可用的选项。
从左到右,在三种不同的媒体上显示之前的图表,从小于 400 像素的媒体(A),小于 800 像素的媒体(B),到大于 800 像素的媒体(C)。
如你所见,使用 Chartist 实现复杂的响应式行为非常简单。尽管我们的任务管理应用原本并不打算成为一个响应式网络应用,但我们仍然可以从中受益,以优化我们的内容。
如果 Chartist 激发了你的想象力,我建议你查看该项目的网站 gionkunz.github.io/chartist-js。在网站上,你还可以访问实时示例页面 gionkunz.github.io/chartist-js/examples.html,在那里你可以直接在浏览器中修改一些图表。
项目仪表板
在本章中,我们将创建一个项目仪表板,它将包括以下组件:
-
任务图表:我们将在这里提供关于随时间推移的开放任务的视觉概述。所有项目将以折线图的形式表示,显示开放任务的进度。我们还将提供一些用户交互,以便用户可以选择不同的时间段。
-
活动图表:该组件在 24 小时的时间范围内以柱状图的形式可视化活动。这将帮助我们的用户快速识别整体和峰值项目活动。
-
项目摘要:这是我们展示每个项目摘要的地方,其中概述了最重要的信息。我们的项目摘要组件还将包括一个活动图表组件,用于可视化项目活动。
-
项目仪表板:这个组件只是前两个组件的组合。这是我们仪表板中的主要组件。它代表我们的仪表板页面,并直接暴露给路由器。
创建项目仪表板组件
首先,我们将创建我们的主仪表板组件。ProjectsDashboard 组件只有两个职责:
-
获取用于创建仪表板的项目数据
-
通过包含我们的仪表板子组件来组合主仪表板布局
让我们直接进入并创建一个新的组件类,在路径 lib/projects-dashboard/projects-dashboard.js 上:
import {Component, ViewEncapsulation, Inject} from '@angular/core';
import template from './projects-dashboard.html!text';
import {ProjectService} from '../project/project-service/project-service';
@Component({
selector: 'ngc-projects-dashboard',
host: {class: 'projects-dashboard'},
template,
encapsulation: ViewEncapsulation.None
})
export class ProjectsDashboard {
constructor(@Inject(ProjectService) projectService) {
this.projects = projectService.change;
}
}
在我们的 dashboard 组件中,我们将直接使用 ProjectService 的变化可观察对象。这与我们通常处理可观察对象的方式不同。通常,我们会在组件中订阅可观察对象,并在数据流通过时更新我们的组件。然而,在我们的项目仪表板中,我们直接在我们的组件上存储 ProjectService 的变化可观察对象。
现在,我们可以使用 Angular 的一个异步核心管道来直接在我们的视图中订阅可观察对象。
直接在视图中暴露可观察对象并使用 async 管道来订阅可观察对象带来主要优势。
我们不需要在我们的组件中处理订阅和取消订阅,因为 async 管道将直接在视图中为我们完成这些操作。
当在可观察对象中发出新值时,async 管道将导致底层绑定被更新。此外,如果视图因任何原因被销毁,async 管道将自动取消订阅可观察对象。
小贴士
通过链式使用 RxJS 操作符,我们可以在不执行任何订阅的情况下将可观察流转换为所需的形状。然后,使用 async 管道,我们可以让视图来订阅和取消订阅转换后的可观察流。这鼓励我们编写纯净和无状态的组件,并且当正确使用时,这是一种很好的实践。
让我们看看在 Component 类所在的同一目录下的 projects-dashboard.html 文件中创建的组件视图:
<div class="projects-dashboard__l-header">
<h2 class="projects-dashboard__title">Dashboard</h2>
</div>
<div class="projects-dashboard__l-main">
<h3 class="projects-dashboard__sub-title">Projects</h3>
<ul class="projects-dashboard__list">
<li *ngFor="let project of projects | async">
<div>{{project.title}}</div>
<div>{{project.description}}</div>
</li>
</ul>
</div>
你可以从模板中看到,我们使用 async 管道来订阅 Component 类的 projects 可观察对象。async 管道最初将返回 null,但在任何可观察对象的变化时,这将返回订阅的解析值。这意味着我们不需要担心订阅我们的项目列表可观察对象。我们可以简单地利用 async 管道来订阅并在视图中直接解析。
目前,我们只显示了项目标题和描述,但在下一节中,我们将创建一个新的项目摘要组件,该组件将处理一些更复杂的渲染。
项目摘要组件
在本节中,我们将创建一个 project-summary 组件,该组件将为项目提供一些概述信息。除了标题和描述外,这还将包括对项目任务总努力的概述。
让我们首先构建组件并做好必要的准备,以便我们可以显示项目底层任务的总努力。
我们将从 lib/projects-dashboard/project-summary/project-summary.js 路径上的 Component 类开始:
...
import{FormatEffortsPipe} from '../../pipes/format-efforts';
import{EffortsTimeline} from '../../efforts/efforts-timeline/efforts-timeline';
import template from './project-summary.html!text';
@Component({
selector: 'ngc-project-summary',
host: { class: 'project-summary' },
template,
directives: [EffortsTimeline],
pipes: [FormatEffortsPipe],
encapsulation: ViewEncapsulation.None
})
export class ProjectSummary {
@Input() project;
ngOnChanges(changes) {
if (this.project) {
this.totalEfforts = this.project.tasks.reduce(
(totalEfforts, task) => {
if (task.efforts) {
totalEfforts.estimated += task.efforts.estimated || 0;
totalEfforts.effective += task.efforts.effective || 0;
}
returntotalEfforts;
}, {
estimated: 0,
effective: 0
});
}
}
}
如你可能已经猜到的,我们重用了在前一章中创建的 EffortsTimeline 组件。由于我们的项目摘要也将包括努力时间线,基于与总努力相同的语义,因此不需要为这个创建新的组件。
然而,我们需要做的是将所有任务努力累积到一个整体努力中。使用 Array.prototype.reduce 函数,我们可以相对容易地累积所有任务努力。
reduce 调用的结果对象需要符合预期的 efforts 对象的格式。作为初始值,我们将提供一个具有零估算时间和有效时间的 efforts 对象。然后,reduce 回调将添加项目中的任何任务努力值。
让我们来看看模板,看看我们将如何使用这些总努力数据来显示我们的 EffortsTimeline 组件:
<div class="project-summary__title">{{project?.title}}</div>
<div class="project-summary__description">
{{project?.description}}
</div>
<div class="project-summary__label">Total Efforts</div>
<ngc-efforts-timeline [estimated]="totalEfforts?.estimated"
[effective]="totalEfforts?.effective"
height="10"></ngc-efforts-timeline>
<p>{{totalEfforts | formatEfforts}}</p>
在显示项目的标题和描述后,我们包含了EffortsTimeline组件,并将其绑定到我们刚刚构建的totalEfforts成员。现在,这个时间线将显示在任务上记录的总聚合工作量。
除了时间线之外,我们还渲染了格式化的努力文本,就像我们在上一章的Efforts组件中已经渲染的那样。为此,我们使用了FormatEffortsPipe管道。
现在,我们还需要做什么呢?将我们的ProjectSummary组件集成到ProjectsDashboard组件中。
让我们看看projects-dashboard.html组件模板中的模板修改:
...
<li *ngFor="let project of projects | async">
<ngc-project-summary
[project]="project"
[routerLink]="['/projects', project._id]">
</ngc-project-summary>
</li>
...
您可以看到,我们将由NgFor指令与async管道一起创建的project局部视图变量绑定到ProjectSummary组件的project输入属性。
我们还使用了RouterLink指令来在用户点击其中一个摘要磁贴时导航到ProjectDetails视图。
ProjectsDashboard组件类中的修改微乎其微:
...
import {ROUTER_DIRECTIVES} from '@angular/router';
import{ProjectSummary} from './project-summary/project-summary';
...
@Component({
selector: 'ngc-projects-dashboard',
directives: [ProjectSummary, ROUTER_DIRECTIVES],
...
})
export class ProjectsDashboard {
...
}
我们对Component类所做的唯一修改是将ProjectSummary组件和ROUTER_DIRECTIVES常量添加到组件的指令列表中。ROUTER_DIRECTIVES常量包括RouterOutlet和RouterLink指令,我们在模板中使用后者。
显示两个项目摘要组件和聚合总工作量的项目仪表板
好的,到目前为止一切顺利。我们创建了两个新的组件,并重用了我们的EffortsTimeline组件来创建任务工作量的聚合视图。在下一节中,我们将用漂亮的 Chartist 图表丰富我们的ProjectSummary组件。
创建你的第一个图表
在本节中,我们将使用 Chartist 创建我们的第一个图表,以在过去 24 小时内提供项目活动概览。这个柱状图将只提供一些关于项目活动的视觉线索,我们的目标不是让它提供详细信息。因此,我们将配置它隐藏任何标签、刻度和网格线。唯一可见的部分应该是柱状图的柱子。
在开始创建活动图表之前,我们需要看看我们需要如何转换和准备我们的数据以供图表使用。
让我们看看我们系统中已有的数据。就活动而言,它们都在time字段上有一个时间戳。然而,对于我们的图表,我们希望显示其他内容。我们正在寻找的是显示过去 24 小时内每个小时的图表,每个柱子代表该时间段的活动数量。
以下插图显示了我们的源数据,它基本上是一系列活动事件的时序流。在下面的箭头中,我们看到我们需要用于我们图表的数据:
一个显示活动作为时间流的插图,其中点代表活动。下方的箭头显示了过去 24 小时的按小时光栅化计数。
让我们实现一个执行图中概述的转换的函数。我们将把这个函数添加到我们的time-utilities模块上的lib/utilities/time-utilities.js路径:
function rasterize(timeData, timeFrame, quantity, now, fill = 0) {
// Floor to a given time frame
now = Math.floor(now / timeFrame) * timeFrame;
returntimeData.reduce((out, timeData) => {
// Calculating the designated index in the rasterized output
const index = Math.ceil((now - timeData.time) / timeFrame);
// If the index is larger or equal to the designed rasterized
// array length, we can skip the value
if (index < quantity) {
out[index] = (out[index] || 0) + timeData.weight;
}
return out;
}, Array.from({length: quantity}).fill(fill)).reverse();
}
让我们看看我们新创建的函数的输入参数:
-
timeData: 此参数预期是一个包含一个time属性的对象数组,该属性设置为应计数的事件的戳记。对象还应包含一个weight属性,用于计数。使用此属性,我们可以将一个事件计为两个,甚至可以计负值以减少光栅中的计数。 -
timeFrame: 此参数指定每个光栅的时间跨度(以毫秒为单位)。如果我们想有 24 个光栅化帧,每个帧包含一小时,则此参数需要设置为 3,600,000(1 小时=60 分钟=3,600 秒=3,600,000 毫秒)。 -
quantity: 此参数设置输出数组中应存在的光栅化帧的数量。在 24 帧一小时的案例中,此参数应设置为 24。 -
now: 这是我们函数在给定时间点向后光栅化时间的时候。now参数设置这个时间点。 -
fill: 这是我们指定如何初始化我们的光栅化输出数组的方式。在我们的活动计数的情况下,我们希望将其设置为0。
我们刚刚创建的函数对于创建活动图表是必要的。这个转换帮助我们为图表的输入数据准备项目活动。
是时候创建我们的第一个图表组件了!让我们从在lib/projects-dashboard/project-summary/activity-chart/activity-chart.html路径上创建的新模板开始:
<div #chartContainer></div>
由于我们将所有渲染都交给 Chartist,这实际上已经是我们需要的一切。Chartist 需要一个元素作为容器来创建图表。我们设置一个chartContainer本地视图引用,以便我们可以从我们的组件中引用它,然后将其传递给 Chartist。
让我们继续创建图表,通过在模板相同的目录下创建activity-chart.js中的Component类来完善活动图表组件:
...
import Chartist from 'chartist';
import {rasterize, UNITS} from '../../../utilities/time-utilities';
@Component({
selector: 'ngc-activity-chart',
...
})
export class ActivityChart {
@Input() activities;
@ViewChild('chartContainer') chartContainer;
ngOnChanges() {
this.createOrUpdateChart();
}
ngAfterViewInit() {
this.createOrUpdateChart();
}
...
}
注意
Chartist 适用于几乎所有包管理器,并且它还以UMD模块格式(通用模块格式)捆绑提供,实际上这是一个包装器,用于启用AMD(异步模块定义)、CommonJS 模块格式和全局导出。
使用 JSPM,我们可以在命令行上执行以下命令来简单地安装 Chartist:
jspm install chartist
安装 Chartist 后,我们可以直接使用 ES6 模块语法导入它。
我们还导入了我们创建的光栅化函数,以便我们可以稍后使用它将我们的活动转换为图表预期的输入格式。
由于我们依赖于视图子元素作为容器元素来创建我们的图表,我们需要等待AfterViewInit生命周期钩子来构建图表。同时,如果输入的activities发生变化,我们还需要重新渲染图表。使用OnChanges生命周期钩子,我们可以对输入变化做出反应并更新我们的图表。
现在,让我们看看createOrUpdateChart函数,它确实如其名称所暗示的那样:
createOrUpdateChart() {
if (!this.activities || !this.chartContainer) {
return;
}
consttimeData = this.activities.map((activity) => {
return {
time: activity.time,
weight: 1
};
});
const series = [
rasterize(
timeData,
UNITS.find((unit) => unit.short === 'h').milliseconds,
24,
+new Date())
];
if (this.chart) {
this.chart.update({ series });
} else {
this.chart = new Chartist.Bar(this.chartContainer.nativeElement, {
series
}, {
width: '100%',
height: 60,
axisY: {
onlyInteger: true,
showGrid: false,
showLabel: false,
offset: 0
},
axisX: {
showGrid: false,
showLabel: false,
offset: 0
},
chartPadding: 0
});
}
}
让我们更详细地查看代码,并一步一步地走一遍:
-
由于我们既从
AfterViewInit又从OnChanges生命周期中被调用,我们需要确保在继续之前chartContainer和activities输入都已就绪。 -
现在,是我们将接收到的活动数据转换为所需的栅格化形式的时候了,这是我们想要创建的图表所需的。我们使用
Array.prototype.map将我们的活动转换为timeData对象,这些对象是rasterize函数所需的。我们还传递必要的参数,以便函数将栅格化到 24 帧,每帧包含一小时。 -
如果
chart成员已经设置为之前创建的图表,我们可以使用 Chartist 图表对象的update函数来仅用新数据更新。 -
如果还没有图表对象,我们需要创建一个新的图表。作为
Chartist.Bar构造函数的第一个参数,我们将传递容器视图子元素的 DOM 元素引用。Chartist 将在该容器元素中创建我们的图表。第二个参数是我们的数据,我们将用刚刚生成的系列填充它。在图表选项中,我们将设置一切以实现一个非常简单的图表,没有任何详细信息。
这太棒了!我们使用 Chartist 创建了第一个图表组件!现在,我们可以回到我们的ProjectSummary组件,并在其中集成活动图表以提供活动概述:
...
import {ActivityService} from '../../activities/activity-service/activity-service';
import {ActivityChart} from './activity-chart/activity-chart';
@Component({
selector: 'ngc-project-summary',
...
directives: [EffortsTimeline, ActivityChart],
...
})
export class ProjectSummary {
...
constructor(@Inject(ActivityService) activityService) {
this.activityService = activityService;
}
ngOnChanges() {
if (this.project) {
...
this.activities = this.activityService.change
.map((activities) => activities.filter((activity) => activity.subject === this.project._id));
}
}
}
这里的第一个变化是包含ActivityService,这样我们就可以提取所需的计划活动并将它们传递给ActivityChart组件。我们还需要导入ActivityChart组件,并在组件中将其声明为指令。
由于我们的组件依赖于作为输入提供的项目,该项目可能会发生变化,因此我们需要在组件的OnChanges生命周期钩子中实现提取活动的逻辑。
在我们将活动流传递之前,我们需要过滤通过流来的活动,以便我们只得到与当前项目相关的活动。我们还将使用async管道来订阅活动,这样就不需要在组件中使用subscribe形式。activities属性将直接设置为过滤后的Observable。
让我们看看ProjectSummary组件视图中的变化,以便启用我们的图表:
...
<div class="project-summary__label">Activity last 24 hours</div>
<ngc-activity-chart [activities]="activities | async">
</ngc-activity-chart>
我们在现有模板的底部添加我们的ActivityChart组件。我们还创建了必要的绑定,将我们的活动传递到组件中。使用async管道,我们可以解析可观察的流并将过滤后的活动列表传递到chart组件中。
最后,我们的ProjectSummary组件看起来很棒,并立即通过显示聚合的努力时间线和漂亮的活动图表来提供项目洞察。在下一节中,我们将更深入地探讨 Chartist 的图表功能,并且我们还将使用 Angular 提供一些交互性。
可视化打开的任务
在本节中,我们将使用 Chartist 创建一个图表组件,该组件将显示项目随时间推移的打开任务进度。为此,我们将使用具有特定插值的折线图,该插值提供量化步骤而不是直接连接点的线条。
我们还提供了一些交互性,用户将能够使用切换按钮切换显示的时间范围。这允许我们重用我们在本书第二章“准备,出发,行动!”中创建的Toggle组件。
让我们先看看我们系统中有的数据以及我们如何将其转换成 Chartist 所需的数据。
我们可以依靠我们的任务的两个数据属性来将它们绘制到时间线上。created属性设置为任务创建时的戳记。如果任务被标记为完成,则done属性设置为那个时间的时间戳。
由于我们只对任何给定时间的打开任务数量感兴趣,我们可以安全地假设一个模型,其中我们将所有任务放在单个时间线上,并且我们只关心作为事件的created和done时间戳。让我们看看以下插图以更好地理解问题:
一个插图展示了我们如何使用创建和完成事件在单个时间线上表示所有任务的时间线。创建事件计为+1,而完成事件计为-1。
下箭头表示时间线上所有created和done事件的任务。现在我们可以使用这些信息作为输入到我们的rasterize函数中,以获取我们图表所需的数据。由于用作光栅化的输入的timeData对象也支持weight属性,我们可以使用它来表示created (+1)或done (-1)事件。
我们需要对我们的 rasterize 函数进行轻微的修改。到目前为止,rasterize 函数只按帧将事件一起计数。然而,对于打开的任务计数,我们查看时间上的累积。如果任务计数发生变化,我们需要保持该值直到它再次变化。在前面主题中活动转换时,我们没有使用这种相同的逻辑。在那里,我们只计算帧内的事件,但没有累积。
让我们看一下以下插图,以了解与我们在处理活动时应用的光栅化相比的差异:
一个说明我们如何随时间累积开放任务计数的插图
我们可以随时间一起计算timeData对象(事件)的每个weight属性。只有当累积值发生变化时,我们才会将当前的累积值写入光栅化输出数组。
让我们打开我们的time-utilities模块,并将更改应用到rasterize函数:
export function rasterize(timeData, timeFrame, quantity,
now = +new Date(), fill = 0,
accumulate = false) {
// Floor to a given time frame
now = Math.floor(now / timeFrame) * timeFrame;
// Accumulation value used for accumulation mode to keep track
// of current value
let accumulatedValue = 0;
// In accumulation mode we need to be sure that the time data
// is ordered
if (accumulate) {
timeData = timeData.slice().sort(
(a, b) => a.time < b.time ? -1 : a.time > b.time ? 1 : 0);
}
return timeData.reduce((rasterized, timeData) => {
// Increase the accumulated value, in case we need it
accumulatedValue += timeData.weight;
// Calculating the designated index in the rasterized output
// array
const index = Math.ceil((now - timeData.time) / timeFrame);
// If the index is larger or equal to the designed rasterized
// array length, we can skip the value
if (index < quantity) {
rasterized[index] = accumulate ?
accumulatedValue :
(rasterized[index] || 0) + timeData.weight;
}
return rasterized;
}, Array.from({length: quantity}).fill(fill)).reverse();
}
让我们回顾一下我们对rasterize函数所做的更改,以允许累积框架:
-
首先,我们在函数中添加了一个名为
accumulate的新参数。我们使用 ES6 默认参数来设置当函数被调用而没有传入值时参数为false。 -
我们现在定义了一个新的
accumulatedValue变量,并将其初始化为0。这个变量将用于跟踪随时间所有weight值的总和。 -
下一段代码非常重要。如果我们想随时间累积所有
weight值的总和,我们需要确保这些值按顺序到来。为了确保这一点,我们按timeData列表的time属性对它进行排序。 -
在
reduce回调中,我们将当前timeData对象的weight值增加到accumulatedValue变量上。 -
如果
timeData对象落入一个光栅化框架中,我们不会像之前那样增加这个框架的计数。在累积模式下,我们将框架计数设置为accumulatedValue中的当前值。这将导致所有变化的累积值都反映在光栅化输出数组中。
这是我们处理数据以渲染我们的开放任务图表所需的所有准备工作。让我们继续前进,创建我们新的chart组件的Component类。
创建开放任务图表
在以下组件中,我们将利用之前主题中重构的rasterize函数。使用新的累积函数,我们现在可以跟踪随时间变化的开放任务计数。
让我们从新文件lib/projects-dashboard/tasks-chart/tasks-chart.js中的Component类开始,以实现我们的Component类:
...
import Chartist from 'chartist';
import Moment from 'moment';
import {rasterize} from '../../utilities/time-utilities';
@Component({
selector: 'ngc-tasks-chart',
...
})
export class TasksChart {
@Input() projects;
@ViewChild('chartContainer') chartContainer;
ngOnChanges() {
this.createOrUpdateChart();
}
ngAfterViewInit() {
this.createOrUpdateChart();
}
...
}
到目前为止,这看起来就像我们的第一个chart组件,我们在其中可视化了项目活动。我们也导入了 Chartist,因为我们将在不久后创建的createOrUpdateChart函数中使用它来渲染我们的图表。我们将创建的图表将包含更多详细的信息。我们将渲染轴标签和一些刻度。为了格式化基本上包含时间戳的标签,我们再次使用 Moment.js 库。
我们还使用projects输入数据,并通过修改后的rasterize实用函数对其进行转换,以便为我们的折线图准备所有数据。
让我们继续完善组件的createOrUpdateChart方法:
createOrUpdateChart() {
if (!this.projects || !this.chartContainer) {
return;
}
// Create a series array that contains one data series for each
// project
const series = this.projects.map((project) => {
// First we need to reduces all tasks into one timeData list
const timeData = project.tasks.reduce((timeData, task) => {
// The created time of the task generates a timeData with
// weight 1
timeData.push({
time: task.created,
weight: 1
});
// If this task is done, we're also generating a timeData
// object with weight -1
if (task.done) {
timeData.push({
time: task.done,
weight: -1
});
}
return timeData;
}, []);
// Using the rasterize function in accumulation mode, we can
// create the required data array that represents our series
// data
return rasterize(timeData, 600000, 144, +new Date(),
null, true);
});
const now = +new Date();
// Creating labels for all the timeframes we're displaying
const labels = Array.from({
length: 144
}).map((e, index) => now - index * 600000).reverse();
if (this.chart) {
// If we already have a valid chart object, we can simply
// update the series data and labels
this.chart.update({
series,
labels
});
} else {
// Creating a new line chart using the chartContainer element
// as container
this.chart = new Chartist.Line(this.chartContainer.nativeElement, {
series,
labels
}, {
width: '100%',
height: 300,
// Using step interpolation, we can cause the chart to
// render in steps instead of directly connected points
lineSmooth: Chartist.Interpolation.step({
// The fill holes setting on the interpolation will cause
// null values to be skipped and makes our line to
// connect to the next valid value
fillHoles: true
}),
axisY: {
onlyInteger: true,
low: 0,
offset: 70,
// We're using the label interpolation function for
// formatting our open tasks count
labelInterpolationFnc: (value) => `${value} tasks`
},
axisX: {
// We're only displaying two x-axis labels and grid lines
labelInterpolationFnc: (value, index, array) => index % (144 / 4) === 0 ? Moment(value).calendar() : null
}
});
}
}
好吧,这里有很多代码。让我们一步一步地走一遍,以便更好地理解正在发生的事情:
-
首先,我们需要通过映射项目列表来创建我们的转换后的系列数据。系列数组应该包括每个项目的数据数组。每个数据数组将包含随时间变化的开放项目任务。
-
由于
rasterize函数期望一个timeData对象的列表,我们首先需要将项目任务列表转换成这种格式。通过减少任务列表,我们创建了一个包含单个timeData对象的列表。reduce函数回调将为每个任务生成一个具有weight值为 1 的timeData对象。此外,它将为标记为具有weight值-1 的每个任务生成一个timeData对象。这将产生所需的timeData数组,我们可以使用它来累积和光栅化。 -
在准备完
timeData列表后,我们可以调用rasterize函数来创建一定时间段内的开放任务列表。我们使用 10 分钟的时间段(600000 毫秒)并使用 144 帧进行光栅化。这总共是 24 小时。 -
除了系列数据外,我们还需要为我们的图表提供标签。我们创建了一个新的数组,并用 144 个时间戳初始化这个数组,所有这些时间戳都设置为显示在图表上的 144 个光栅化帧的开始。
-
现在,我们已经准备好了系列数据和标签,接下来要做的就是渲染我们的图表。
-
使用
lineSmooth配置,我们可以为我们的折线图指定一种特殊的插值。步进插值不会直接连接我们折线图中的每个点,而是会以离散的步骤从一个点到另一个点移动。这正是我们渲染开放任务随时间变化所需的方法。 -
在步骤插值中将
fillHoles选项设置为true非常重要。使用此设置,我们实际上可以告诉 Chartist 它应该关闭数据(实际上为 null 值)中的任何间隙,并将线条连接到下一个有效值。如果没有此设置,我们会在数据数组中的任务计数变化之间在图表上看到间隙。 -
在我们的代码中,最后一项重要的事情是在x轴配置上设置的
labelInterpolationFnc选项。此函数不仅可以用来格式化标签或插值可能伴随的任何表达式,而且还允许我们返回 null。从这个函数返回 null 将导致 Chartist 跳过给定的标签和相应的网格线。如果我们想通过值或标签的索引跳过某些标签,这将非常有用。在我们的代码中,我们确保只渲染所有 144 个生成的标签中的四个。
让我们来看看我们组件在tasks-chart.html文件中的相对简单的模板,这个文件与我们的Component类文件位于同一文件夹中:
<div #chartContainer class="tasks-chart__container"></div>
与ActivityChart组件类似,我们只创建了一个简单的图表容器元素,这个元素我们已经在Component类中引用。
这基本上就是我们创建使用 Chartist 的开放任务图表所需做的所有事情。然而,这里还有一些改进的空间:
使用我们的任务图表组件和 Chartist 的步进插值可视化开放任务
创建图表图例
目前,我们无法确切知道哪条线代表哪个项目。我们能看到每个项目有一条彩色线,但我们无法将这些颜色关联起来。我们需要的是一个简单的图例,帮助我们的用户将折线图的颜色与项目关联起来。
让我们看看实现图例所需的代码更改。在我们的TasksChart组件的Component类中,我们需要执行以下修改:
...
export class TasksChart {
...
ngOnChanges() {
if (this.projects) {
// On changes of the projects input, we need to update the
// legend
this.legend = this.projects.map((project, index) => {
return {
name: project.title,
class: `tasks-chart__series--series-${index + 1}`
};
});
}
this.createOrUpdateChart();
}
...
}
在OnChanges生命周期钩子中,我们将项目输入映射到一个包含name和class属性的对象列表中,这将支持我们在图例中渲染简单的图例。模板字符串tasks-chart__series--series-${index + 1}将生成渲染图例中正确颜色的必要类名。
使用这个图例信息,我们现在可以继续实施必要的模板更改,以在我们的chart组件中渲染图例:
<ul class="tasks-chart__series-list">
<li *ngFor="let series of legend"
class="tasks-chart__series {{series.class}}">
{{series.name}}
</li>
</ul>
<div #chartContainer class="tasks-chart__container"></div>
嗯,这很简单,对吧?然而,结果证明了一切。我们仅用几分钟就为图表创建了一个漂亮的图例:
带有我们添加的图例的开放任务图表
使任务图表交互式
目前,我们硬编码了开放任务图表的时间范围为 144 帧,每帧 10 分钟,总共显示给用户 24 小时。然而,也许我们的用户想要改变这个视图。
在这个主题中,我们将使用我们的Toggle组件创建一个简单的输入控制,允许我们的用户更改图表的时间范围设置。
我们将提供以下视图作为选项:
-
**日:**这个视图将渲染 144 帧,每帧 10 分钟,总共 24 小时
-
**周:**这个视图将渲染 168 帧,每帧一小时,总共七天
-
**年:**这个视图将渲染 360 帧,每帧代表一整天
让我们从修改TasksChart组件的Component类代码开始,实现我们的时间范围切换功能:
...
import {Toggle} from '../../ui/toggle/toggle';
@Component({
...
directives: [Toggle]
})
export class TasksChart {
...
constructor() {
// Define the available time frames within the chart provided
// to the user for selection
this.timeFrames = [{
name: 'day',
timeFrame: 600000,
amount: 144
}, {
name: 'week',
timeFrame: 3600000,
amount: 168
}, {
name: 'year',
timeFrame: 86400000,
amount: 360
}];
// From the available time frames, we're generating a list of
// names for later use within the Toggle component
this.timeFrameNames
= this.timeFrames.map((timeFrame) => timeFrame.name);
// The currently selected timeframe is set to the first
// available one
this.selectedTimeFrame = this.timeFrames[0];
}
...
createOrUpdateChart() {
...
const series = this.projects.map((project) => {
...
return rasterize(timeData,
this.selectedTimeFrame.timeFrame,
this.selectedTimeFrame.amount,
+new Date(), null, true);
});
const now = +new Date();
const labels = Array.from({
length: this.selectedTimeFrame.amount
}).map((e, index) => now - index * this.selectedTimeFrame.timeFrame).reverse();
...
}
...
// Called from the Toggle component if a new timeframe was
// selected
onSelectedTimeFrameChange(timeFrameName) {
// Set the selected time frame to the available timeframe with
// the name selected in the Toggle component
this.selectedTimeFrame =
this.timeFrames.find((timeFrame) =>
timeFrame.name === timeFrameName);
this.createOrUpdateChart();
}
}
让我们简要地回顾一下这些更改:
-
首先,我们在
Component类中添加了一个构造函数,在其中初始化了三个新的成员。timeFrames成员被设置为时间范围描述对象数组。它们包含name、timeFrame和amount属性,这些属性随后用于计算。timeFrameNames成员包含时间范围名称列表,该列表直接从timeFrames列表派生。最后,我们有一个selectedTimeFrame成员,它简单地指向第一个可用的时间范围对象。 -
在
createOrUpdateChart函数中,我们不再依赖于硬编码的任务计数光栅化值,而是引用selectedTimeFrame对象中的数据。通过更改此对象引用并再次调用createOrUpdateChart函数,我们现在可以动态地切换底层数据的视图。 -
最后,我们添加了一个新的
onSelectedTimeFrameChange方法,它作为对Toggle组件的绑定,并且将在用户选择不同的时间范围时被调用。
让我们看看必要的模板更改,以启用时间范围的切换:
<ngc-toggle
[buttonList]="timeFrameNames"
[selectedButton]="selectedTimeFrame.name"
(selectedButtonChange)="onSelectedTimeFrameChange($event)">
</ngc-toggle>
...
<div #chartContainer class="tasks-chart__container"></div>
从绑定到Toggle组件,你可以看出我们依赖于组件上的timeFrameNames成员来表示所有可选的时间范围。我们还使用selectedTimeFrame.name属性绑定到Toggle组件的selectedButton输入属性。当Toggle组件中选定的按钮发生变化时,我们调用onSelectedTimeFrameChange函数,在那里时间范围被切换,图表被更新。
这是我们需要的一切,以启用在图表上切换时间范围。现在用户可以选择按年、周和日查看。
我们的TasksChart组件现在已准备好集成到我们的仪表板中。我们可以通过修改ProjectsDashboard组件的模板来实现这一点:
...
<div class="projects-dashboard__l-main">
<h3 class="projects-dashboard__sub-title">Tasks Overview</h3>
<div class="projects-dashboard__tasks">
<ngc-tasks-chart [projects]="projects | async">
</ngc-tasks-chart>
</div>
...
</div>
这基本上是我们需要做的所有事情,在此更改之后,我们的仪表板包含了一个显示随时间推移的开放任务计数的漂亮图表。
在TasksChart项目输入属性的绑定中,我们再次使用async管道直接在视图中解析项目观察流。
摘要
在本章中,我们学习了 Chartist 及其如何与 Angular 结合使用来创建外观美观且功能齐全的图表。我们可以利用两个世界的力量,创建可重用的封装良好的图表组件。
就像在大多数实际案例中一样,我们总是有很多数据,但在特定情况下我们只需要其中一部分。我们学习了如何将现有数据转换成适合视觉表示的形式。
在下一章中,我们将探讨如何在应用程序中构建插件系统。这将允许我们开发打包成插件的便携式功能。我们的插件系统将动态加载新插件,我们将使用它来开发一个简单的敏捷估算插件。
第十章. 使事物可插件化
我非常喜爱插件架构。除了它们对应用程序和范围管理产生的巨大积极影响外,它们在开发过程中也非常有趣。我建议任何向我询问的人都将插件架构集成到他们的库或应用程序中。一个好的插件架构允许你编写简洁的应用程序核心,并通过插件提供额外的功能。
以一种允许你构建插件架构的方式设计整个应用程序,这对系统的可扩展性有很大影响。这是因为你使应用程序易于扩展,但关闭了修改。
在编写我的开源项目时,我也发现插件架构有助于你管理项目的范围。有时,请求的功能非常好且非常有用,但它仍然会使库核心膨胀。与其用这样的功能使整个应用程序或库膨胀,你不如简单地编写一个插件来完成这项工作。
在本章中,我们将创建自己的插件架构,这将帮助我们扩展应用程序的功能,而不会使其核心膨胀。我们首先将在应用程序的核心中构建插件 API,然后使用该 API 实现一个小巧的敏捷插件,帮助我们使用故事点来估算任务。
在本章中,我们将涵盖以下主题:
-
基于 Angular 生态系统的插件架构设计
-
实现基于装饰器的插件 API
-
使用
ComponentResolver和ViewContainerRef将插件组件实例化到我们应用程序中预定义的槽位 -
使用 SystemJS 实现插件加载机制
-
在我们的插件架构中使用响应式方法以实现即插即用风格的插件
-
使用新的插件 API 实现一个敏捷插件来记录故事点
插件架构
在更高层次上,插件架构应至少满足以下要求:
-
可扩展性:插件背后的主要思想是允许使用隔离的代码包扩展核心功能。一个出色的插件架构允许你无缝地扩展核心,而不会引起明显的性能损失。
-
可移植性:插件应足够隔离,以便在运行时将其插入到系统中。不应有必要重建系统以启用插件。理想情况下,插件甚至可以在运行时任何时候加载。它们可以被停用和激活,并且不应导致系统运行到不一致的状态。
-
可组合性:插件系统应允许并行使用多个插件,并允许通过组合多个插件来扩展系统。理想情况下,系统还应包括依赖管理、插件版本管理和插件间通信。
实现插件架构的方法有很多。尽管这些方法可能差异很大,但几乎总是有一个机制在位,提供统一的扩展点。没有这个机制,统一扩展系统将会很困难。
我过去曾与一些插件架构合作过,除了使用现有的插件机制外,我还享受自己设计一些插件。以下列表应该能提供一个关于在设计插件系统时可以采用的一些方法的思路:
-
领域特定语言(DSL):使用领域特定语言是实现可插拔架构的一种方式。在你实现了应用程序的核心之后,你可以开发一个 API,甚至是一种脚本语言,允许你使用这种 DSL 进一步开发功能。许多视频游戏引擎和 CG 应用程序都依赖于这种方法。尽管这种方法非常灵活,但它也可能迅速导致性能问题,并容易引入复杂性。通常,实现这种架构的先决条件是将非常低级别的核心操作(如添加 UI 元素、配置流程等)暴露到 DSL 中,这并不提供清晰的边界和扩展点,但非常灵活。基于 DSL 的插件系统的一些例子包括 Adobe 的大部分 CG 应用程序、3D Studio Max 和 Maya,以及游戏引擎,如 Epic Games 的 Unreal Engine 或 Bohemia Interactive Studio 的 Real Virtuality Engine。
-
核心是插件系统:另一种方法是构建一个复杂的插件架构,使其满足前面列表中概述的所有要求(可扩展性、可移植性和可组合性),甚至还有一些更复杂的要求。应用程序的核心是一个庞大的插件系统。然后,你开始将一切作为插件来实现。甚至应用程序的核心关注点也将作为插件实现。这种方法的一个完美例子是 Eclipse IDE 及其 Equinox 核心。这种方法的缺点是,随着应用程序的增长,你可能会遇到性能问题。由于一切都是插件,优化相当困难,插件兼容性可能会使应用程序变得不稳定。
-
基于事件的扩展点:另外,提供系统扩展性的一个很好的方法是通过向系统开放外部输入的管道。想象一下,对于你应用程序中的每一个重要步骤,你都会通知外部世界这个步骤,并在应用程序继续处理之前允许拦截。以这种方式,插件将仅仅是一个适配器,它监听应用程序的这些管道事件,并根据需要修改行为。插件本身也可以发出事件,然后可以被其他插件再次处理。这种架构非常灵活,因为它允许你在不引入太多复杂性的情况下更改核心功能的行为。即使在完成核心功能后没有考虑插件系统,这种方法也相当容易实现。我在我的开源项目 Chartist 中一直遵循这种方法,到目前为止,我取得了非常好的效果。
-
插件接口:一个应用程序可以公开一组接口,这些接口定义了某些扩展点。这种方法在 Java 框架中得到了广泛的应用,被称为服务提供者接口(SPI)。提供者实现一定的合同,允许核心系统依赖于接口而不是实现。然后,这些提供者可以循环回到系统中,在那里它们被提供给框架和其他提供者。尽管这可能是在统一性方面提供扩展性的最安全方式,但它也是最僵化的。插件永远不会被允许执行合同中未指定的任何其他操作。
你可以看到,所有四种方法差异很大。从最顶层的,它以复杂性和稳定性为代价提供了极端的灵活性,到最底层的,它非常健壮但也非常僵化。
实现插件系统时选择的方法在很大程度上取决于你应用程序的需求。如果你不打算构建一个包含各种风味的应用程序,其中应该存在针对完全不同关注点的多个版本,那么前面列表中的方法可能更可能是你应该遵循的方法。
可插入的 UI 组件
在本章中我们将要构建的系统借鉴了 Angular 框架中已经存在的许多机制。为了使用插件实现扩展性,我们依赖于以下核心概念:
-
我们使用指令来指示 UI 中的扩展点,我们称之为插件槽。这些插件槽指令将负责动态实例化插件组件,并将它们插入到应用程序 UI 的指定位置。
-
插件通过我们称之为插件放置的概念来公开组件。插件放置声明了插件中的哪些组件应该放置到应用程序中的哪个插件槽位中。我们还使用插件放置来决定来自不同插件的组件应按何种顺序插入到插件槽位中。为此,我们将使用一个名为优先级的属性。
-
我们使用 Angular 的依赖注入来提供已实例化的插件信息到插件组件中。由于插件组件将被放置在一个已经存在注入器的地方,因此它们将能够注入周围的组件和依赖项,以便连接到应用程序。
在我们开始实现它之前,让我们看一下以下插图,以了解我们插件系统的架构:
我们将在本章中使用一些基本的 UML 和基数注释来实现插件架构
让我们快速查看这个图中的不同实体,并简要解释它们的作用:
-
PluginConfig:这是一个 ES7 装饰器,在实现插件时是关键元素。通过使用此装饰器注释插件类,我们可以存储有关插件元信息,这些信息将在以后由我们的插件系统使用。元数据包括插件名称、描述和位置信息。 -
PluginData:这是一个聚合类,由插件系统用于将关于已实例化插件的详细信息与位置信息(插件组件应实例化的位置)耦合。一旦创建插件组件,该实体就会在依赖注入中公开。任何插件组件都可以使用这个实体来收集有关实例化的信息或获取对插件实例的访问权限。 -
PluginService:这是用于将我们的插件系统粘合在一起的服务。它主要用于加载插件、删除插件,或由PluginSlot指令用于收集与插件槽位创建相关的插件组件。 -
PluginSlot:这个指令用于标记我们应用程序中的 UI 扩展点。无论我们希望在哪个位置使插件能够钩入我们的应用程序用户界面,我们都会放置这个指令。插件槽位需要命名,插件使用位置信息通过名称引用槽位。这样,一个插件可以为我们的应用程序中的不同槽位提供不同的组件。 -
PluginComponent:这些是随插件实现捆绑的常规 Angular 组件。一个插件可以通过使用PluginPlacement对象在插件上配置多个组件。 -
PluginPlacement:在插件配置中使用,当一个插件可以有多个放置配置时。每个放置实体包括对一个组件的引用、组件应实例化的槽位名称以及一个优先级数字,这有助于插件系统在多个组件在同一个槽位中实例化时正确地排序插件组件。 -
Plugin:这是实现插件时的实际插件类。该类包含使用PluginConfig装饰器注解的插件配置。插件类在应用程序中仅实例化一次,并且通过 Angular 的依赖注入与插件组件共享。因此,这个类也是插件组件之间共享数据的好地方。
现在,我们对将要构建的内容有一个更高层次的概述。我们的插件系统非常基础,但它将支持诸如热加载插件(即插即用风格)和其他优秀功能。在下一个主题中,我们将开始实现插件 API 的核心组件。
实现插件 API
让我们从我们插件 API 中较简单的实体开始。我们在lib/plugin/plugin.js文件中创建一个新的文件来创建PluginConfig装饰器和PluginPlacement类,这些类存储了插件组件应放置的信息。我们还在这个文件中创建了PluginData类,该类用于将插件运行时信息注入到插件组件中:
export function PluginConfig(config) {
return (type) => {
type._pluginConfig = config;
};
}
PluginConfig装饰器包含接受配置参数的非常简单的逻辑,然后该参数将被存储在注解的类(构造函数)上的_pluginConfig属性中。如果你需要复习装饰器的工作原理,现在可能是阅读第一章中关于装饰器的主题,即组件化用户界面的好时机:
export class PluginPlacement {
constructor(options) {
this.slot = options.slot;
this.priority = options.priority;
this.component = options.component;
}
}
PluginPlacement类代表配置对象,用于将插件组件暴露到应用程序 UI 中的不同插件槽位:
export class PluginData {
constructor(plugin, placement) {
this.plugin = plugin;
this.placement = placement;
}
}
PluginData类代表在插件实例化期间创建的插件运行时信息以及一个PluginPlacement对象。这个类将由PluginService使用,以将有关插件组件的信息传达给应用程序中的插件槽位。
这三个类是实现插件时的主要交互点。
让我们看看一个简单的插件示例,以了解我们如何使用PluginConfig装饰器和PluginPlacement类来配置一个插件:
@PluginConfig({
name: 'my-example-plugin',
description: 'A simple example plugin',
placements: [
new PluginPlacement({
slot: 'plugin-slot-1',
priority: 1,
component: PluginComponent1
}),
new PluginPlacement({
slot: 'plugin-slot-2',
priority: 1,
component: PluginComponent2
})
]
})
export default class ExamplePlugin {}
使用PluginConfig装饰器,实现一个新的插件变得非常简单。我们在设计时决定名称、描述以及我们希望在应用程序中放置插件组件的位置。
我们的插件系统使用命名的 PluginSlot 指令来指示我们应用程序组件树中的扩展点。在 PluginPlacement 对象中,我们引用插件中内置的 Angular 组件,并通过引用插件槽名称来指示它们应在哪个槽中放置。放置的优先级将告诉插件槽在创建时如何对插件组件进行排序。当不同插件的组件在同一个插件槽中创建时,这一点变得很重要。
好的,让我们直接深入到我们的插件架构的核心,通过实现插件服务。我们将创建一个新的 lib/plugin/plugin-service.js 文件并创建一个新的 PluginService 类:
import {Injectable} from '@angular/core';
import {ReplaySubject} from 'rxjs/Rx';
@Injectable()
export class PluginService {
...
}
由于我们将创建一个可注入的服务,我们将使用 @Injectable 注解来注释我们的 PluginService 类。我们使用 RxJS 的 ReplaySubject 类型来在激活插件的任何更改上发出事件。
让我们看看我们服务的构造函数:
constructor() {
this.plugins = [];
// Change observable if the list of active plugin changes
this.change = new ReplaySubject(1);
this.loadPlugins();
}
首先,我们初始化一个新的空 plugins 数组。这将是我们活动插件的列表,它包含运行时插件数据,例如插件加载的 URL、插件类型(类的构造函数)、指向存储在插件上的配置的快捷方式(由 PluginConfig 装饰器创建)以及最后,插件类的实例本身。
我们还添加了一个 change 成员,我们使用新的 RxJS ReplaySubject 进行初始化。我们将使用此主题在它改变时发出活动插件的列表。这允许我们以响应式的方式构建插件系统,并启用即插即用风格的插件。
作为构造函数中的最后一个操作,我们调用服务的 loadPlugins 方法。这将执行带有已注册插件的初始加载:
loadPlugins() {
System.import('/plugins.js').then((pluginsModule) => {
pluginsModule.default.forEach(
(pluginUrl) => this.loadPlugin(pluginUrl)
);
});
}
loadPlugins 方法异步地从我们应用程序的根路径使用 SystemJS 加载名为 plugins.js 的文件。期望 plugins.js 文件默认导出一个数组,该数组包含预配置的插件路径,这些插件应在应用程序启动时加载。这允许我们配置我们已知并希望默认存在的插件。使用单独的异步加载文件进行此配置使我们能够更好地从主应用程序中分离出来。我们可以运行相同的应用程序代码,但使用不同的 plugins.js 文件,并通过控制默认应存在的插件来控制。
然后,loadPlugins 方法通过调用 loadPlugin 方法使用 plugins.js 文件中存在的 URL 加载每个插件:
loadPlugin(url) {
return System.import(url).then((pluginModule) => {
const Plugin = pluginModule.default;
const pluginData = {
url,
type: Plugin,
// Reading the meta data previously stored by the @Plugin
// decorator
config: Plugin._pluginConfig,
// Creates the plugin instance
instance: new Plugin()
};
this.plugins = this.plugins.concat([pluginData]);
this.change.next(this.plugins);
});
}
loadPlugin 方法负责加载和实例化单个插件模块。它将插件模块的 URL 作为参数,并使用 System.import 动态加载插件模块。使用 System.import 来完成这项工作的好处是,我们可以加载已存在于捆绑应用程序中的模块,以及通过 HTTP 请求加载远程 URL。这使得我们的插件系统非常便携,我们甚至可以在运行时从不同的服务器、NPM 或甚至 GitHub 加载模块。当然,SystemJS 也支持不同的模块格式,如 ES6 模块或 CommonJS 模块,如果模块尚未转换,还支持不同的转换器。
在插件模块成功加载后,我们将有关加载的插件的所有信息捆绑到一个 pluginData 对象中。然后我们可以将此信息添加到我们的 plugins 数组中,并在我们的 ReplaySubject 上发出一个新事件,以通知感兴趣的各方关于更改的消息。
最后,我们需要一个方法来收集所有插件中的 PluginPlacement 数据,并按槽位名称进行过滤。当我们的插件槽位需要知道它们应该实例化哪些组件时,这一点很重要。插件可以将任意数量的组件暴露到任意数量的应用程序插件槽位中。当插件槽位需要知道哪些暴露的 Angular 组件与它们相关时,将使用此函数:
getPluginData(slot) {
return this.plugins.reduce((components, pluginData) => {
return components.concat(
pluginData.config.placements
.filter((placement) => placement.slot === slot)
.map((placement) => new PluginData(pluginData, placement))
);
}, []);
到目前为止,PluginService 类已经完成了,我们创建了插件系统的核心。在下一章中,我们将处理插件槽位,并看看我们如何可以动态实例化插件组件。
实例化插件组件
现在,是时候看看我们插件架构的第二大主要部分了,那就是负责在正确位置实例化插件组件的 PluginSlot 指令。
在我们实现指令之前,让我们看看如何在 Angular 中动态实例化组件。我们已经在 第七章 用户体验组件 中介绍了可以包含组件的实例化视图。在无限滚动指令中,我们使用了 ViewContainerRef 来实例化模板元素。然而,这里有一个不同的用例。我们希望将单个组件实例化到现有的视图中。
ViewContainerRef 对象也为我们提供了这个问题的解决方案。让我们看看如何使用 ViewContainerRef 对象来实例化组件的一个非常基础的例子。在下面的例子中,我们使用了四个新的概念:
-
使用
@ViewChild并将read选项设置为{read: ViewContainerRef}来查询视图容器而不是元素 -
使用
ComponentResolver实例来获取我们想要动态实例化的组件的工厂 -
使用
ReflectiveInjector创建一个新的子注入器,用于我们的实例化组件 -
使用
ViewContainerRef.createComponent实例化一个组件并将其附加到视图容器的底层视图。
以下代码示例展示了我们如何使用 ViewContainerRef 实例动态创建一个组件。
import {Component, Inject, ViewChild, ViewContainerRef, ComponentResolver} from '@angular/core';
@Component({
selector: 'hello-world',
template: 'Hello World'
})
export class HelloWorld {}
@Component({
selector: 'app'
template: '<h1 #headingRef>App</h1>'
})
export class App {
@ViewChild('headingRef', {read: ViewContainerRef}) viewContainer;
constructor(@Inject(ComponentResolver) resolver) {
this.resolver = resolver;
}
ngAfterViewInit() {
this.resolver
.resolveComponent(HelloWorld)
.then((componentFactory) => {
this.viewContainer.createComponent(componentFactory);
});
}
}
注入到 App 组件的构造函数中,我们稍后可以使用 ComponentResolver 解决 HelloWorld 组件。我们使用 @ViewChild 装饰器在 App 组件中查询标题元素。通常,这将给我们一个与视图元素关联的 ElementRef 对象。然而,由于我们需要与元素关联的视图容器,我们可以使用 {read: ViewContainerRef} 选项来获取 ViewContainerRef 对象。
在 AfterViewInit 生命周期钩子中,我们首先在 ComponentResolver 实例上调用 resolveComponent 方法。此调用返回一个承诺,该承诺解决为 ComponentFactory 类型的对象。Angular 在内部使用组件工厂来创建组件。
在承诺解决后,我们现在可以使用我们标题元素的视图容器上的 createComponent 方法来创建我们的 HelloWorld 组件。
让我们更详细地看看 ViewContainerRef 对象的 createComponent 方法:
| 方法 | 描述 |
|---|
| ViewContainerRef.createComponent | 此方法将创建一个基于在 componentFactory 参数中提供的组件工厂的组件。编译后的组件将随后附加到由 index 参数提供的特定位置上的视图容器。以下参数:
-
componentFactory:这是组件工厂,将用于创建新的组件。 -
Index:这是一个可选参数,用于指定创建的组件应在视图容器中插入的位置。如果没有指定此参数,组件将插入到视图容器的最后一个位置。 -
Injector:这是一个可选参数,允许您为创建的组件指定自定义注入器。这允许您为创建的组件提供额外的依赖项。 -
projectableNodes:这是一个可选参数,用于指定内容投影的节点。
此方法返回一个在实例化组件编译完成后解决的承诺。Promise 解决为一个 ComponentRef 对象,该对象也可以用于稍后再次销毁组件。|
提示
默认情况下,使用 ViewContainerRef.createComponent 方法创建的组件将从父组件继承注入器,这使得此过程具有上下文感知性。然而,createComponent 方法的 injector 参数在您想要向组件提供不在任何父注入器上存在的额外依赖项时特别有用。
让我们回到我们的PluginSlot指令,它负责相关插件组件的实例化。
首先,在我们深入代码之前,让我们思考一下我们的PluginSlot指令的高级需求:
-
插件槽应该包含一个名称输入属性,这样这个名称就可以被想要为插槽提供组件的插件所引用。
-
指令需要响应
PluginService的变化,并重新评估需要放置哪些插件组件。 -
在插件槽的初始化过程中,我们需要获取与这个特定插槽相关的
PluginData对象列表。我们应该咨询PluginService的getPluginData方法来获取这个列表。 -
使用获取的相关
PluginData对象列表,我们将能够使用我们的指令的ViewContainerRef对象实例化与放置信息关联的组件。
让我们在lib/plugin/plugin-slot.js路径上创建我们的PluginSlot指令:
import {Directive, Input, Inject, provide, ViewContainerRef, ComponentResolver, ReflectiveInjector} from '@angular/core';
import {PluginData} from './plugin';
import {PluginService} from './plugin-service';
@Directive({
selector: 'ngc-plugin-slot'
})
export class PluginSlot {
@Input() name;
...
}
在我们的指令中,name输入对于我们的插件机制非常重要。通过向指令提供名称,我们可以在我们的 UI 中定义命名的扩展点,并在稍后使用这个名称在插件配置的PluginPlacement数据中:
constructor(@Inject(ViewContainerRef) viewContainerRef,
@Inject(ComponentResolver) componentResolver,
@Inject(PluginService) pluginService) {
this.viewContainerRef = viewContainerRef;
this.componentResolver = componentResolver;
this.pluginService = pluginService;
this.componentRefs = [];
// Subscribing to changes on the plugin service and re-
// initialize slot if needed
this.pluginChangeSubscription =
this.pluginService
.change.subscribe(() => this.initialize());
}
在构造函数中,我们首先注入ViewContainerRef对象,这是一个指向指令视图容器的引用。由于我们想直接使用指令的视图容器,这里不需要使用@ViewChild。如果我们想获取当前指令的视图容器,我们可以简单地使用注入。当我们使用ViewContainerRef.createComponent方法实例化组件时,我们将使用这个引用。
为了解析组件及其工厂,我们注入ComponentResolver实例。
PluginService被注入有两个原因。首先,我们希望订阅活动插件列表上的任何变化,其次,我们使用它来获取这个插槽的相关PluginData对象。
我们使用componentRefs成员来跟踪已经实例化的插件组件。这将帮助我们稍后当插件被停用时销毁它们。
最后,我们为PluginService创建一个新的订阅,并将订阅存储到pluginChangeSubscription成员字段中。在激活的插件列表发生任何变化时,我们在我们的组件上执行initialize方法:
initialize() {
if (this.componentRefs.length > 0) {
this.componentRefs.forEach(
(componentRef) => componentRef.destroy());
this.componentRefs = [];
}
const pluginData =
this.pluginService.getPluginData(this.name);
pluginData.sort(
(a, b) => a.placement.priority < b.placement.priority ?
1 : a.placement.priority > b.placement.priority ? -1 : 0);
return Promise.all(
pluginData.map((pluginData) =>
this.instantiatePluginComponent(pluginData))
);
}
让我们详细看看initialize方法的四个部分:
-
首先,我们检查这个插件槽是否已经在
componentRefs成员中包含实例化的插件组件。如果是这种情况,我们使用ComponentRef对象的 detach 方法移除所有现有实例。之后,我们将componentRefs成员初始化为一个空数组。 -
我们使用
PluginService的getPluginData方法来获取与这个特定槽位相关的PluginData对象列表。我们将此槽位的名称传递给该方法,这样PluginService就会提前为我们提供一个感兴趣的插件组件列表,这些组件希望放置在我们的槽位中。 -
由于可能有多个插件排队等待在我们的槽位中放置,我们正在使用
PluginPlacement对象的优先级属性来对PluginData对象列表进行排序。这将确保具有更高优先级的插件组件将排在具有较低优先级的组件之前。这是一个很好的额外功能,当我们要处理许多争夺空间的插件时,这个功能将非常有用。 -
在我们的
initialize方法的最后一段代码中,我们为列表中的每个PluginData对象调用instantiatePluginComponent方法。
现在,让我们创建instantiatePluginComponent方法,该方法在initialize方法的最后一步被调用:
instantiatePluginComponent(pluginData) {
return this.componentResolver
.resolveComponent(pluginData.placement.component)
.then((componentFactory) => {
// Get the injector of the plugin slot parent component
const contextInjector = this.viewContainerRef.parentInjector;
// Preparing additional PluginData provider for the created
// plugin component
const providers = [
provide(PluginData, {
useValue: pluginData
})
];
// We're creating a new child injector and provide the
// PluginData provider
const childInjector = ReflectiveInjector
.resolveAndCreate(providers, contextInjector);
// Now we can create a new component using the plugin slot view
// container and the resolved component factory
const componentRef = this.viewContainerRef
.createComponent(componentFactory,
this.viewContainerRef.length,
childInjector);
this.componentRefs.push(componentRef);
});
}
此方法负责创建单个插件组件。现在,我们可以使用我们在本主题中关于ViewContainerRef.createComponent方法和ComponentResolver对象所获得的知识来动态创建组件。
除了从放置此插件槽位的组件继承的提供者之外,我们还想将PluginData提供给已实例化的插件组件的注入器。使用 Angular 的provide函数,我们可以指定pluginData以解决对PluginData类型的任何注入。
ReflectiveInjector类为我们提供了一些静态方法,用于创建注入器。我们可以使用我们的视图容器上的parentInjector成员来获取插件槽位上下文中的注入器。然后,我们使用ReflectiveInjector类上的静态resolveAndCreate方法来创建一个新的子注入器。
在resolveAndCreate方法的第一个参数中,我们可以提供一个提供者列表。这些提供者将被解决并可供我们的新子注入器使用。resolveAndCreate方法的第二个参数接受新创建的子注入器的父注入器。
最后,我们使用ViewContainerRef对象的createComponent方法来实例化插件组件。作为createComponent方法调用的第二个参数,我们需要传递视图容器中的位置。在这里,我们利用我们的视图容器的length属性将其放置在最后。在第三个参数中,我们用我们的自定义子注入器覆盖组件的默认注入器。成功后,我们将创建的ComponentRef对象添加到我们的已实例化组件列表中。
完成我们的插件架构
恭喜你,你已经使用 Angular 构建了自己的插件架构!我们创建了一个插件 API,可以使用PluginConfig装饰器来创建新的插件。PluginService管理整个插件加载,并使用自定义注入器将PluginData对象提供给应用中的插槽。PluginSlot指令可以在任务管理应用中使用,以在用户界面中标记扩展点。利用 Angular 中依赖注入的继承特性,插件组件将能够访问它们环境中所需的一切。
在下一节中,我们将使用我们刚刚创建的插件架构来创建我们的第一个插件。
构建敏捷插件
在上一节中,我们创建了一个简单但有效的插件架构,现在我们将使用这个插件 API 在任务管理应用中构建我们的第一个插件。
在我们深入插件细节之前,我们首先应该就我们的应用的可扩展性达成一致。我们的插件系统基于PluginSlot指令,这些指令应该放置在我们的组件树中的某个位置,以便插件可以暴露组件到这些插槽。目前,我们决定在我们的应用中设置两个可扩展的位置:
-
TaskInfo:在项目中显示的任务列表中,我们目前渲染Task组件。除了任务的标题外,Task组件还显示其他信息,如任务编号、创建日期、里程碑以及适用的情况下的努力信息。这些附加信息是通过TaskInfos子组件在Task组件上渲染的。这是一个为插件提供可扩展性的好位置,以便它们可以添加额外的任务信息,这些信息将在任务列表概览中显示。 -
TaskDetail:另一个提供可扩展性的绝佳位置是ProjectTaskDetails组件。这是我们编辑任务详情的地方,这使得它成为插件扩展的绝佳组件。
除了将PluginSlot指令添加到TaskInfos组件的指令列表中,我们还修改了位于lib/task-list/task/task-infos/task-infos.html的模板:
...
<ngc-task-info title="Efforts"
[info]="task.efforts | formatEfforts">
</ngc-task-info>
<ngc-plugin-slot name="task-info"></ngc-plugin-slot>
在包含PluginSlot指令并将名称输入属性设置为task-info之后,我们为插件提供了一个扩展点,它们可以在其中提供额外的组件。
让我们将相同的更改应用到lib/project/project-task-details/project-task-details.html中的ProjectTaskDetails组件模板:
...
<div class="task-details__content">
...
<ngc-plugin-slot name="task-detail"></ngc-plugin-slot>
</div>
在任务详情内容元素的末尾之前,我们包含另一个名为task-detail的插件插槽。通过为这个插槽提供组件,插件可以钩入任务的编辑视图。
好的,所以我们的扩展点已经设置好了,插件可以在任务级别提供额外的组件。你可以看到,使用PluginSlot指令准备这些位置真的是小菜一碟。
现在,我们可以查看我们的敏捷插件的实现,它将利用我们刚刚暴露的扩展点。
我们将要创建的敏捷插件将提供在任务上记录故事点的功能。故事点在敏捷项目管理中常用。它们应该提供对复杂性的感知,并且相对于所谓的参考故事而言。如果你想了解更多关于敏捷项目管理以及如何使用故事点进行估算的信息,我强烈推荐迈克·科恩的书籍,《敏捷估算与规划》。
让我们从我们的插件类和必要的配置开始。我们创建插件在常规 lib 文件夹之外,只是为了表明插件的便携性。
我们在 plugins/agile/agile.js 路径上创建一个新的 AgilePlugin 类:
import {PluginConfig, PluginPlacement} from '../../lib/plugin/plugin';
@PluginConfig({
name: 'agile',
description: 'Agile development plugin to manage story points on tasks',
placements: []
})
export default class AgilePlugin {
constructor() {
this.storyPoints = [0.5, 1, 2, 3, 5, 8, 13, 21];
}
}
插件类构成了我们插件的核心入口点。我们使用 PluginConfig 装饰器,这是我们作为插件 API 的一部分创建的。除了名称和描述之外,我们还需要配置任何放置,其中我们将插件组件映射到应用程序插件槽位。然而,由于我们还没有任何插件组件要暴露,我们的列表目前仍然是空的。
还需要注意的是,插件模块始终需要默认导出插件类。这正是我们在 PluginService 类中实现插件加载机制的方式。
回顾 PluginService 的 loadPlugin 方法中的这两行,可以看出我们依赖于插件模块的默认导出:
return System.import(url).then((pluginModule) => {
const Plugin = pluginModule.default;
...
当插件模块成功加载时,我们通过引用模块上的 default 属性来获取默认导出。
到目前为止,我们已经创建了我们的插件入口模块。这充当了一个插件配置容器,并且与 Angular 没有任何关系。使用放置配置,一旦我们创建了插件,我们就可以暴露我们的插件 Angular 组件。
敏捷任务信息组件
让我们继续到我们想要暴露的第一个敏捷插件组件。首先,我们创建一个组件,它将被暴露到名为 task-info 的槽位中。在任务列表下的任务标题下方,我们的敏捷信息组件应该显示存储的故事点。
我们在 plugins/agile/agile-task-info/agile-task-info.js 路径上创建一个新的 Component 类:
...
import {Task} from '../../../lib/task-list/task/task';
@Component({
selector: 'ngc-agile-task-info',
encapsulation: ViewEncapsulation.None,
template,
host: {
class: 'task-infos__info'
}
})
export class AgileTaskInfo {
constructor(@Inject(Task) taskComponent) {
this.task = taskComponent.task;
}
}
你可以看到,我们在这里实现了一个常规组件。这个组件没有任何特别之处。
我们导入 Task 组件以获取类型信息,并将其注入到我们的构造函数中。由于插件槽位于 TaskInfos 组件内部,而实际上 TaskInfos 组件始终是 Task 组件的子组件,这是一个安全的注入。
在构造函数中,我们首先获取注入的 Task 组件,并将任务数据提取到本地的 task 成员变量中。
我们还借用 TaskInfos 组件的 task-infos__info 类,以便获得与其他已存在于任务上的任务信息相同的样式。
让我们看看位于同一路径的agile-task-info.html文件中的AgileTaskInfo组件的模板:
<div *ngIf="task.storyPoints || task.storyPoints === 0">
<strong>Story Points: </strong>{{task.storyPoints}}
</div>
按照我们在TaskInfo组件中使用的相同标记,如果存在,我们显示storyPoints任务。
好的,现在我们可以使用PluginPlacement对象在插件配置中公开插件组件。让我们对我们的agile.js模块文件进行必要的修改:
...
import {AgileTaskInfo} from './agile-task-info/agile-task-info';
@PluginConfig({
name: 'agile',
description: 'Agile development plugin to manage story points on tasks',
placements: [
new PluginPlacement({slot: 'task-info', priority: 1,
component: AgileTaskInfo})
]
})
export default class AgilePlugin {
...
}
现在,我们在插件配置中包含一个新的PluginPlacement对象,它将我们的AgileTaskInfo组件映射到名为task-info的应用程序插件插槽中:
显示由我们的敏捷插件提供的额外信息的任务信息
这对于插件工作来说已经足够了。然而,由于我们的任务上没有填充任何storyPoints数据,这个插件目前实际上不会显示任何内容。
灵活任务详情组件
让我们创建另一个插件组件,它可以用来输入故事点。为此,我们将在plugins/agile/agile-task-detail/agile-task-detail.js路径上创建一个新的AgileTaskDetail组件:
...
import {Project} from '../../../lib/project/project';
import {ProjectTaskDetails} from '../../../lib/project/project-task-details/project-task-details';
import {Editor} from '../../../lib/ui/editor/editor';
@Component({
selector: 'ngc-agile-task-detail',
encapsulation: ViewEncapsulation.None,
template,
host: {class: 'agile-task-detail'},
directives: [Editor]
})
export class AgileTaskDetail {
constructor(@Inject(Project) project,
@Inject(ProjectTaskDetails) projectTaskDetails) {
this.project = project;
this.projectTaskDetails = projectTaskDetails;
this.plugin = placementData.plugin.instance;
}
onStoryPointsSaved(storyPoints) {
this.projectTaskDetails.task.storyPoints = +storyPoints || 0;
this.project.document.persist();
}
}
这个组件也没有什么特别之处。我们的目标插槽是task-detail插件插槽,它位于ProjectTaskDetails组件内部。因此,将ProjectTaskDetails和Project组件注入到我们的插件组件中是安全的。ProjectTaskDetails组件用于获取上下文中的任务数据。我们使用存储在Project组件上的LiveDocument来持久化我们对项目任务数据的任何更改。
我们重用Editor组件来获取用户输入,并在onStoryPointsSaved回调中存储输入数据。这是我们从前面的区域所了解的相同机制,在那里我们使用了Editor组件。当故事点被编辑时,我们首先更新存储在ProjectTaskDetails组件中的任务数据模型。之后,我们可以使用LiveDocument的persist方法来保存更改。
让我们看看位于plugins/agile/agile-task-detail/agile-task-detail.html文件中的我们的AgileTaskDetail组件的模板:
<div class="task-details__label">Story Points</div>
<ngc-editor [content]="projectTaskDetails.task?.storyPoints"
[showControls]="true"
(editSaved)="onStoryPointsSaved($event)"></ngc-editor>
我们从编辑器的content输入属性直接绑定到任务数据的storyPoints属性。
当编辑被保存时,我们使用更新后的值调用onStoryPointsSaved回调:
显示由我们的敏捷插件公开的新敏捷故事点的任务详情
在我们使用新的PluginPlacement对象在插件配置中公开我们新创建的插件组件之前,我们将进一步增强组件。如果我们在组件上提供两个按钮,允许用户将故事点增加到或减少到下一个常见故事点值,这将很好。因为我们已经在Agile插件类上存储了常见故事点的列表,让我们看看我们如何利用这一点:
...
import {PluginData} from '../../../lib/plugin/plugin';
@Component({
selector: 'ngc-agile-task-detail',
...
})
export class AgileTaskDetail {
constructor(..., @Inject(PluginData) pluginData) {
...
this.plugin = pluginData.plugin.instance;
}
...
increaseStoryPoints() {
const current = this.projectTaskDetails.task.storyPoints || 0;
const storyPoints = this.plugin.storyPoints.slice().sort((a, b) => a > b ? 1 : a < b ? -1 : 0);
this.projectTaskDetails.task.storyPoints =
storyPoints.find((storyPoints) => storyPoints > current) || current;
this.project.document.persist();
}
decreaseStoryPoints() {
const current = this.projectTaskDetails.task.storyPoints || 0;
const storyPoints = this.plugin.storyPoints.slice().sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
this.projectTaskDetails.task.storyPoints =
storyPoints.find((storyPoints) => storyPoints < current) || current;
this.project.document.persist();
}
}
当我们之前注入了由组件级别注入器提供的Project和ProjectTaskDetails组件时,我们现在利用了在PluginSlot指令实例化期间添加的提供者。在这里,我们提供了PluginData,我们现在可以使用它来获取对插件组件的引用。
下一个更高或更低的用户故事点值是通过increaseStoryPoints和decreaseStoryPoints找到的。这是通过搜索存储在我们AgilePlugin类上的常见故事点列表来完成的。使用注入的PluginData上的插件类实例,我们可以轻松访问此列表。在存储修改后的故事点后,我们然后使用项目组件的LiveDocument实例来持久化调整后的故事点。
在我们的AgileTaskDetail组件模板中,我们简单地添加了两个按钮,允许用户增加或减少基于我们新创建方法的用户故事点:
...
<button (click)="decreaseStoryPoints()"
class="button button--small">-</button>
<button (click)="increaseStoryPoints()"
class="button button--small">+</button>
好的,现在让我们使用一个新的PluginPlacement对象将AgileTaskDetail组件添加到插件配置中,该对象引用task-detail插件槽位:
...
import {AgileTaskDetail} from './agile-task-detail/agile-task-detail';
@PluginConfig({
...
placements: [
new PluginPlacement({slot: 'task-info', priority: 1,
component: AgileTaskInfo}),
new PluginPlacement({slot: 'task-detail', priority: 1,
component: AgileTaskDetail})
]
})
export default class AgilePlugin {
...
}
这不是很好吗?您创建了一个完全可移植的插件,该插件可以管理任务上的敏捷故事点。
带有故事点和额外增加/减少按钮的任务详情视图
剩下的唯一事情是将插件添加到PluginService指令最初应加载的插件列表中。为此,我们将在应用程序的根目录下创建一个plugins.js文件,并添加以下内容:
export default [
'/plugins/agile/agile.js'
];
现在,如果我们启动我们的应用程序,插件将由PluginService加载,并且PluginSlot指令将在适当的位置实例化敏捷插件组件。
回顾我们的第一个插件
干得好!您成功实现了您的第一个插件!在本节中,我们使用插件架构的 API 创建了一个用于管理敏捷故事点的插件。我们使用了PluginPlacement类将我们的插件组件映射到应用程序 UI 的不同槽位中。我们还利用了提供给插件槽位中每个实例化组件的PluginData对象,以便访问插件实例。
在插件内部实现此类功能的优势应该是显而易见的。我们没有建立额外的依赖关系就为我们的应用程序添加了一个新功能。我们的 Agile 功能是完全可移植的。第三方开发者可以编写独立的插件,并且它们可以被我们的系统加载。这是一个很大的优势,它帮助我们保持核心精简的同时提供出色的可扩展性。
管理插件
我们已经构建了插件架构的核心以及在这个系统中运行的第一个插件。我们可以使用应用根目录下的 plugins.js 文件来注册插件。系统实际上已经完全可用。然而,提供一个在运行时管理我们插件的方法会更好。
在本节中,我们将构建一个新的可路由组件,该组件将列出系统中的所有活动插件。完成此操作后,我们还将添加一些元素,允许用户在运行时卸载活动插件以及加载新插件。由于我们的插件系统的响应式特性,浏览器无需刷新即可使新加载的插件变为活动状态。插件加载的瞬间,它将立即对相关的插件槽位可用。
让我们从 lib/manage-plugins/manage-plugins.js 路径上的一个新的 ManagePlugins 组件类开始:
...
import {PluginService} from '../plugin/plugin-service';
@Component({
selector: 'ngc-manage-plugins',
...
})
export class ManagePlugins {
constructor(@Inject(PluginService) pluginService) {
this.plugins = pluginService.change;
}
}
我们的 ManagePlugins 组件相当简单。我们在组件构造函数中注入 PluginService,并将成员字段 plugins 指向 PluginService 的变化可观察对象。由于我们总会得到这个可观察对象发出的最新插件列表,我们可以在视图中简单地使用 async 管道来订阅这个可观察对象。
让我们看看我们新组件的模板 lib/manage-plugins/manage-plugins.html:
<div class="manage-plugins__l-header">
<h2 class="manage-plugins__title">Manage Plugins</h2>
</div>
<div class="manage-plugins__l-main">
<h3 class="manage-plugins__sub-title">Active Plugins</h3>
<div class="manage-plugins__section">
<table class="manage-plugins__table">
<thead>
<tr>
<th>Name</th>
<th>Url</th>
<th>Description</th>
<th>Placements</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let plugin of plugins | async">
<td>{{plugin.config.name}}</td>
<td>{{plugin.url}}</td>
<td>{{plugin.config.description}}</td>
<td>
<div *ngFor="let placement of plugin.config.placements"
class="manage-plugins__placement">
{{placement.slot}}
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
我们使用 HTML 表格来显示活动插件的列表。在表格体行中,我们使用 NgFor 指令遍历活动插件的列表,我们使用 async 管道来订阅这些插件。
在插件对象中,我们已经有了所有值得显示的内容。通过遍历存储在插件数据 config 属性上的 PluginPlacement 对象,我们甚至可以显示我们的插件提供组件的槽位名称。
现在,要启用我们的新组件,我们唯一要做的就是使其可路由,并将其添加到我们应用程序的导航中。让我们在 lib/app.js 模块中进行必要的修改:
...
import {ManagePlugins} from './manage-plugins/manage-plugins';
@Component({
selector: 'ngc-app',
...
})
@Routes([
...
new Route({path: 'plugins', component: ManagePlugins})
])
export class App {
...
}
我们添加了一个新的路由,所以让我们将它添加到 lib/app.html 中的导航中:
<div class="app">
<div class="app__l-side">
<ngc-navigation [openTasksCount]="openTaskCount">
...
<ngc-navigation-section title="Admin">
<ngc-navigation-item title="Manage Plugins"
[link]="['/plugins']">
</ngc-navigation-item>
</ngc-navigation-section>
</ngc-navigation>
</div>
<div class="app__l-main">
<router-outlet></router-outlet>
</div>
</div>
在新的 Admin 导航部分,我们添加了一个新的 navigation-item,该链接指向新创建的 "plugins" 路由:
我们的新 ManagePlugins 组件显示活动插件及其暴露的位置的表格
在运行时加载新插件
我们已经准备好提供一个页面来查看所有活动插件。然而,我们说能够管理这个列表会很好。用户应该能够移除活动插件以及手动加载额外的插件。
让我们在ManagePlugins组件中添加这些功能。在我们能够这样做之前,我们将在PluginService类上需要一个额外的方法,这是负责加载插件的部分。到目前为止,我们没有考虑移除活动插件的功能。让我们在lib/plugin/plugin-service.js中打开PluginService以添加此功能:
...
@Injectable()
export class PluginService {
...
removePlugin(name) {
const plugin = this.plugins.find(
(plugin) => plugin.name === name);
if (plugin) {
const plugins = this.plugins.slice();
plugins.splice(plugins.indexOf(plugin), 1);
this.plugins = plugins;
this.change.next(this.plugins);
}
}
}
好吧,这很简单!我们提供了一个新的removePlugin方法,它接受一个插件名称作为参数。然后我们在plugins数组中查找插件,如果找到了具有此名称的插件,我们就从列表中删除它。此外,在我们删除插件后,我们发出一个带有更新列表的change事件。由于应用程序中所有插件槽都订阅了这个更改可观察对象,它们将自动更新和重新初始化相关的插件组件。
现在我们需要对ManagePlugins组件类应用必要的更改,以便不仅能够移除插件,还能加载额外的插件:
...
@Component({
selector: 'ngc-manage-plugins',
...
})
export class ManagePlugins {
constructor(@Inject(PluginService) pluginService) {
...
this.pluginService = pluginService;
}
removePlugin(name) {
this.pluginService.removePlugin(name);
}
loadPlugin(loadUrlInput) {
this.pluginService.loadPlugin(loadUrlInput.value);
loadUrlInput.value = '';
}
}
现在,我们也存储了PluginService在我们的组件上。在removePlugin和loadPlugin函数中,我们将委托给PluginService以采取必要的行动。
loadPlugin方法将接收一个指向输入字段的ElementRef对象,用户在此输入字段中输入从其中加载新插件的 URL。我们可以将输入字段的值传递给PluginService的loadPlugin方法,它负责处理其余部分。一旦我们提交了这个调用,我们也将输入字段的值设置为空字符串。
让我们打开lib/manage-plugins/manage-plugins.html中的模板,以在我们的组件视图中应用所需的更改:
...
<div class="manage-plugins__l-main">
<h3 class="manage-plugins__sub-title">Active Plugins</h3>
<div class="manage-plugins__section">
...
<td>
<button (click)="removePlugin(plugin.name)"
class="button button--small">remove</button>
</td>
...
</div>
<h3 class="manage-plugins__sub-title">Load Plugin</h3>
<div class="manage-plugins__section">
<div class="manage-plugins__load-elements">
<input #loadUrlRef type="text"
placeholder="Enter plugin URL"
class="manage-plugins__load-url">
<button (click)="loadPlugin(loadUrlRef)"
class="button">Load</button>
</div>
</div>
</div>
我们在表格中为每个列出的插件添加了一个额外的按钮,该按钮包含一个绑定表达式,它调用带有当前迭代的插件名称的removePlugin方法。
我们还在插件列表之后添加了一个新部分来加载新插件。在这个部分中,我们使用一个输入字段来输入插件 URL,以及一个按钮来执行加载。使用一个loadUrlRef本地视图引用,我们可以将输入 DOM 元素的引用传递给组件上的loadPlugin方法:
一个完成的ManagePlugins组件,具有在运行时移除和加载插件模块的能力
现在,我们已经准备好管理我们的插件了。最初从根plugins.js文件中的 URL 加载的插件现在可以通过插件列表中的删除按钮来卸载。可以通过输入插件 URL 来加载和激活新插件,这个 URL 可以是本地 URL、捆绑并映射的模块,甚至是不同服务器上的远程 URL。
摘要
在本章中,我们探讨了如何实现插件架构的不同方法。然后,我们为插件架构设计了自己的方案,该方案利用了一些 Angular 机制,并基于我们称之为槽位的 UI 扩展点概念。
我们实现了一个插件 API,通过利用 ES7 装饰器使新插件的配置变得轻而易举,从而提供了极佳的开发者体验。我们使用服务来加载和卸载基于 SystemJS 模块加载器的插件,实现了插件系统的核心。这使得我们能够利用 SystemJS 提供的先进加载可能性。插件可以实时转换,可以位于本地 URL、远程 URL,甚至可以捆绑到主应用程序中。
我们实现了我们的第一个插件,该插件提供了一些组件来管理任务上的敏捷故事点。该插件是在我们的常规项目lib文件夹之外创建的,这应该强调了我们的插件系统的可移植性。
最后,我们创建了一个新的可路由组件来管理运行时的插件。由于我们的插件系统具有反应性,插件可以在应用程序运行时加载和卸载,而不会产生任何不希望出现的副作用。
当您在玩本章的源代码时,我强烈建议您尝试我们的插件架构的加载机制。我们几乎不费吹灰之力就实现了灵活性,这真是太棒了。您可以通过提供插件主模块的 URL 来卸载敏捷插件并重新加载它。您甚至可以尝试将整个插件文件夹放置在远程服务器上,并从那里加载插件。只需确保您考虑了必要的跨源资源共享(CORS)头信息。
本章的全部代码可以在书籍资源的 ZIP 文件中找到,您可以从 Packt Publishing 下载。您可以在书的前言中的下载示例代码部分进行参考。
在本书的下一章和最后一章中,我们将探讨如何测试我们迄今为止创建的组件。所以,请关注这个迟到的主题!