# 在 NativeScript ListView 中管理组件状态
不久前,我写了一篇博客,在 NativeScript ListView 中使用多个项目模板,并简要介绍了 UI 虚拟化和视图/组件回收的主题。似乎在使用与此相关的 ListView 开发应用程序时,您可能会遇到一些隐藏的陷阱,尤其是如果您将 Angular Components 用作 ListView 中的项目并在组件中保持某些状态时。
我们将深入探讨该问题,并展示一些解决该问题的方法。
# 场景
为了演示该问题,我们将构建一个显示项目列表的应用程序,我们希望能够选择其中的一些项目。
- 我们将使用一个项目在此博客的 Web 和 Mobile 之间共享代码。原因:
- 我们可以概述 Web 和 NativeScript 模板之间的差异。
由于 angular-cli 和@ nativescript / schematics,代码共享现在变得非常容易。了解更多关于它在这真棒博客由塞巴斯蒂安 Witalec
这是该应用在浏览器和 iOS 模拟器中的外观:
列表中的每个项目都以一个呈现ItemComponent
-将当前项目作为@Input 参数。这是组件类:
@Component({
selector: 'app-item',
templateUrl: './item.component.html',
styleUrls: ['./item.component.css']
})
export class ItemComponent {
@Input() item: Item;
selected: boolean = false;
}
注意,我们将selected
状态保留为组件中的字段。我们还在模板中的几个地方使用它:
// Mobile Template (item.component.tns.html)
<StackLayout orientation="horizontal" class="item"
(tap)="selected = !selected">
<Label [text]="item.name"></Label>
<Label class="select-btn"
[class.selected]="selected"
[text]="selected ? 'selected' : 'unselected'">
</Label>
</StackLayout>
// Web Template (item.component.html)
<div class="item">
{{ item.name }}
<span (click)="selected = !selected"
class="select-btn"
[class.selected]="selected">
{{ selected ? 'selected' : 'unselected' }}
</span>
</div>
整个项目以及博客不同部分的分支都位于此处。
# 使用旧的* ngFor
我们将从显示容器(aka smart)组件中使用的模型中的所有项目开始*ngFor:
<app-item *ngFor="let item of items" [item]="item"></app-item>
很简单!这将渲染ItemComponent
为每个集合中的项目。
在测试项目中,生成了 100 个项目,对于 Web 和移动设备,一切都非常快。
😈😈😈 让我们尝试更多项目 😈😈😈
该 Web 应用开始在 1 万个项目上出现相当大的启动滞后。在移动设备中,阈值要低得多,大约为 2K。这是因为 IOS / Android 渲染的本机组件比浏览器 DOM 元素昂贵。如果我们使模板更加复杂,这些数字将会下降。
但是…没人会在您要说的清单中放入 2000 个项目。你是对的。您可能会实现按需加载机制的无限滚动。问题是,即使那样,滚动时也会遇到性能和内存问题,*ngFor
而ItemComponents
向下滚动并提取更多数据时,实例化会越来越多。
这是代码,您可以自己使用它-只需调整item.service.ts
以生成更多项目:ngFor branch
。
我们可以做得更好!
# 切换到 NS 中的 ListView
在 NativeScript 中,我们利用执行 UI 虚拟化和**视图/组件回收的本机控件。**这意味着将仅创建可见项目的 UI 元素,并且这些 UI 元素将被回收(或重复使用)以显示新的项目。
要开始使用,ListView
我们只需要从上方将基于* ngFor
的模板更改为:
<ListView [items]="items">
<ng-template let-item="item">
<app-item [item]="item"></app-item>
</ng-template>
</ListView>
大!快速测试表明,我们现在可以items
在移动应用程序中滚动 100K 谷歌了!
ItemComponent's
构造函数中的一个简单计数器显示,仅创建了 13 个实例。当您滚动时,它们将被重用于显示所有项目。
# 问题
整洁!…还是?让我们看看开始选择项目时会发生什么:
在这里,我们看到的问题实际上就是这篇文章的原因。我选择前三个项目。当我向下滚动时,也会选择项 13,14 和 15。再往下,还会选择更多我从未见过的项目。
原因是当ItemsComponents
重用它们时,它们内部的状态也会被重用。以前只创建了 13 个组件,因此,如果您选择其中 3 个,则在滚动时会看到它们一次又一次弹出。
考虑一下—通过此实现,您实际上是在选择组件而不是 item。这两个集合之间不再存在 1 对 1 的关系:有 100 个(或也许是 100K😈)项目,只有 13 个ItemsComponent
实例。
这是仓库中即将出现问题的分支:list-view-state-component-component 分支。
# 解决方案
有两种解决方案,但最终都归结为:
TIP
将视图状态(selected 本例中的字段)移出组件,并使组件变为无状态。
我们将使用视图状态(由于缺乏更好的术语)来获取所有信息,这些信息最初不在模型中,但仍在组件模板和应用程序逻辑中使用。在我们的情况下,该 selected 字段已提交。此信息也可能绑定到模板中的任何输入视图。
注意:想到的另一种方法是尝试在重用组件时“清洗”它们。但是,这意味着您将不可避免地失去它们的状态。根本不可能将 100 个项目存储在 13 个单项框中。
# 在模型中保留视图状态
也许最容易实现的解决方案是在模型项中添加视图状态:
export interface Item {
name: string;
selected?: boolean;
}
您将不得不更改组件模板以从中获取/设置selected
字段item
:
<StackLayout orientation="horizontal" class="item"
(tap)="item.selected = !item.selected">
<Label [text]="item.name"></Label>
<Label class="select-btn"
[class.selected]="item.selected"
[text]="item.selected ? 'selected' : 'unselected'">
</Label>
</StackLayout>
问题解决了!为了使事情更清楚。我们从具有状态组件的 ngFor
出发:
具有状态组件的 ngFor
到具有无状态组件的 ListView(尽管在 Web 版本中仍为 ngFor
):
具有无状态组件的 ListView
注意: Web 模板仍使用ngFor
。它与的无状态版本完美配合ItemComponent
。这是仓库中的分支:list-view-state-in-model 分支。
对于简单的情况,这是一个有效的解决方案,但您可能不希望将视图状态属性与模型混合使用。或者,可能是您直接从服务中获取模型对象,并希望使它们在其他字段中保持“干净”,以便您可以在某个时候将它们发送回去。
# 将视图状态附加到项目
另一种方法是将视图状态作为单独的视图状态对象,并在 UI 中使用它时将其“附加”到模型对象。这将使我们在模型属性和视图状态属性之间有所区分,并在需要时提供一种轻松的方法来清洁模型对象。
为了使事情变得更加简单,我创建了一个 TypeScript 装饰器,它将为我完成这项工作。这是这样的:
- 我们 vs 使用特殊的装饰器装饰组件中专用的 view-state 属性(以下简称它)
@attachViewState
。 - 我们给装饰器一个工厂函数,以为项目创建默认的视图状态对象。每当需要为项目创建视图状态对象时,它将使用它。
- 我们给装饰者组件中实际模型属性的名称。通常这是
@Input
财产-在我们的例子中是“物品”。 - 装饰器将创建(使用工厂)并将视图状态对象“附加”到传递给组件的每个项目中(“附加”是一种奇特的说法,它将
"__vs"
为该项目设置属性)。 - 装饰器还将更改
vs
属性的 getter 和 setter ,以便它们访问位于项目内部的视图状态对象。这将使在组件模板内使用视图状态更加容易。
听起来复杂吗?实际上,它很容易使用:
interface ItemViewState {
selected?: boolean;
}
const ItemViewStateFactory = () => { return { selected: false } };
@Component({ ... })
export class ItemComponent {
@attachViewState<ItemViewState>("item", ItemViewStateFactory)
vs: ItemViewState;
@Input() item: Item;
}
在模板中,我们仅将其 vs 用于视图状态道具和 item 数据道具:
<StackLayout orientation="horizontal" class="item"
(tap)="vs.selected = !vs.selected">
<Label [text]="item.name"></Label>
<Label class="select-btn"
[class.selected]="vs.selected"
[text]="vs.selected ? 'selected' : 'unselected'">
</Label>
</StackLayout>
这也是@attachViewState
装饰器的代码(T 是视图状态对象的类型)。也有getViewState
和 cleanViewState
帮手获取和清洗从模型视图状态对象的方法。
const viewStateKey = '__vs';
export function attachViewState<T>(attachTo: string, defaultValueFactory?: () => T) {
return (target: any, key: string) => {
const assureViewState = obj => {
if (typeof obj[attachTo][viewStateKey] === 'undefined') {
// console.log("> creating default view sate");
obj[attachTo][viewStateKey] = defaultValueFactory();
}
};
// property getter
var getter = function() {
// console.log("> getter");
assureViewState(this);
return this[attachTo][viewStateKey];
};
// property setter
var setter = function(newVal) {
// console.log("> setter");
assureViewState(this);
this[attachTo][viewStateKey] = newVal;
};
// Delete property.
if (delete target[key]) {
// Create new property with getter and setter
Object.defineProperty(target, key, {
get: getter,
set: setter,
enumerable: true,
configurable: true
});
}
};
}
export function getViewState<T>(model: any): T {
return model[viewStateKey];
}
export function cleanViewState(model: any) {
return (model[viewStateKey] = undefined);
}
同样,代码在这里:list-view-state-in-model-decorator 分支
注意:还有其他策略。例如:
- 在容器组件中维护视图状态对象的完整分隔列表,并将它们均作为模板的输入传递
- 使用合成将模型项“包装”为视图模型项,从而使模型项保持完整。
# 奖金(无状态组件的情况)
值得注意的是,这些解决方案在*ngFor
仍在使用的我们应用程序的 Web 版本中可以完美地工作。实际上,在许多情况下,拥有无状态组件实际上会带来更好的应用程序架构。
这是一个例子。考虑一下我们应用程序中的下一个功能:我们必须收集所有选择的项目并在不同的视图中显示(或alert
暂时只显示它们 😃)。
如果“选定的”信息位于组件内部,则我们必须:
- 使用
@ViewChildren
查询出这是所选项目的组件图。ew!🤮 - 公开某种事件,以便在选择项目时通知并在容器组件中进行处理。这意味着我们将在两个不同的位置保存“选择的”信息(一次在 the 中
ItemComponent
,一次在容器组件中)。🤮🤮!ee!
另一方面,如果您有一个无状态ItemComponent
并分别持有该状态,则使用数据的时间会更短。如果您从上方使用“ decorator”方法,则代码如下所示(我们使用getViewStatehelper
util 中的方法来获取视图状态):
// In container-component template (home.component.html):
<button (click)="checkout()">checkout</button>
// In container-component code (home.component.ts):
checkout() {
const result = this.items
.filter(item => {
const vs = getViewState<ItemViewState>(item);
return vs && vs.selected;
})
.map(item => item.name)
.join("\n");
alert("Selected items:\n" + result);
}
最终项目的代码:master 分支
# 摘要
以下是关键要点:
- 当切换从
*ngFor
以ListView
记住,这将回收在你的模板中的组件。它们内部的任何状态(@Input
模板中未绑定的所有非属性)都将在回收中幸存下来,并可能导致不良行为。 - 考虑使用无状态(aka 表示)组件。由于所有状态都将作为输入传递,因此可以避免 1.中的问题。它还遵循了“ 智能组件与演示组件”指南,将使您的应用程序具有更好的体系结构。
- 奖励: 现在,使用 NativeScript 在 Web 和移动设备之间共享代码非常容易。并不是真正的话题…但是我对此很兴奋,并决定分享 😃😃😃