本指南的这一部分描述了 Ember Data 的核心功能。Ember Data 是一套功能强大的工具,用于格式化请求、规范化响应以及高效地管理本地数据缓存。
Ember.js 本身可以与任何类型的后端配合使用:REST、JSON:API、GraphQL 或其他任何类型。要了解处理数据的其他方式并查找扩展,请查看发起 API 请求的指南,在 Ember Observer 上搜索插件,并查找社区制作的教程。
什么是 EmberData 模型?
在 EmberData 中,模型是表示应用程序呈现给用户的数据的对象。请注意,EmberData 模型与路由中的 model 方法虽然名称相同,但属于不同的概念。
不同的应用程序可能拥有截然不同的模型,这取决于它们试图解决的问题。例如,一个照片共享应用程序可能有一个 Photo 模型来表示特定的照片,还有一个 PhotoAlbum 模型来表示一组照片。相比之下,在线购物应用程序则可能有完全不同的模型,如 ShoppingCart、Invoice 或 LineItem。
模型通常是持久化的。这意味着用户在关闭浏览器窗口时并不希望模型数据丢失。为了确保数据不丢失,如果用户对模型进行了更改,你需要将模型数据存储在不会丢失的地方。
通常,大多数模型是从使用数据库存储数据的服务器加载并保存到该服务器上的。通常情况下,你会将模型的 JSON 表示在 HTTP 服务器(由你编写)之间进行传输。然而,Ember 也支持使用其他持久化存储,例如使用 IndexedDB 保存到用户的硬盘上,或者使用托管存储解决方案,从而让你免于编写和托管自己的服务器。
一旦你从存储中加载了模型,组件就知道如何将模型数据转换为用户可以交互的 UI。有关组件如何获取模型数据的更多信息,请参阅指定路由模型 (Specifying a Route's Model) 指南。
起初,使用 EmberData 的感觉可能与你习惯编写 JavaScript 应用程序的方式不同。许多开发者习惯于使用 Ajax 从端点获取原始 JSON 数据,这起初看起来很简单。然而,随着时间的推移,复杂性会渗透到你的应用程序代码中,导致难以维护。
有了 EmberData,随着应用程序的增长,管理模型会变得既简单又容易。
一旦你理解了 EmberData,你将拥有更好的方法来管理应用程序中数据加载的复杂性。这将使你的代码能够进化和成长,并具备更好的可维护性。
EmberData 的灵活性
得益于对适配器模式的使用,EmberData 可以配置为与许多不同类型的后端协同工作。存在一个完整的适配器生态系统和几个内置适配器,允许你的 Ember 应用程序与不同类型的服务器通信。
默认情况下,EmberData 设计为开箱即用地支持 JSON:API。JSON:API 是一项正式规范,用于构建规范、稳健且高性能的 API,允许客户端和服务器交换模型数据。
JSON:API 标准化了 JavaScript 应用程序与服务器的通信方式,因此你可以降低前端与后端之间的耦合度,并拥有更多更改技术栈各部分的自由。
如果你需要将 Ember.js 应用程序与没有可用 适配器 的服务器集成(例如,你自行编写了一个不遵守任何 JSON 规范的 API 服务器),EmberData 旨在可配置以处理服务器返回的任何数据。
EmberData 还设计为支持流式服务器,例如那些由 WebSockets 驱动的服务器。你可以打开与服务器的套接字,并在更改发生时将数据推送到 EmberData,从而为你的应用程序提供始终保持最新的实时用户界面。
Store(存储)与单一事实来源
构建 Web 应用程序的一种常见方式是将用户界面元素与数据获取紧密耦合。例如,假设你正在编写一个博客应用程序的管理部分,其中有一个功能用于列出当前登录用户的草稿。
你可能想让组件负责获取这些数据、存储数据并显示草稿列表,像这样:
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import fetch from "fetch";
export default class ListOfDraftsComponent extends Component {
@tracked drafts;
constructor() {
super(...arguments);
fetch("/drafts").then((data) => {
this.drafts = data;
});
}
<template>
<ul>
{{#each this.drafts key="id" as |draft|}}
<li>{{draft.title}}</li>
{{/each}}
</ul>
</template>
}
这对于 ListOfDrafts 组件来说工作得很好。但是,你的应用程序很可能由许多不同的组件组成。在另一个页面上,你可能想要一个组件来显示草稿数量。你可能想把现有的 constructor 代码复制粘贴到新组件中。
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import fetch from "fetch";
import { LinkTo } from '@ember/routing';
export default class DraftsButtonComponent extends Component {
@tracked drafts;
constructor() {
super(...arguments);
fetch("/drafts").then((data) => {
this.drafts = data;
});
}
<template>
<LinkTo @route="drafts">
Drafts ({{this.drafts.length}})
</LinkTo>
</template>
}
不幸的是,该应用程序现在会为相同的信息发起两个独立的请求。冗余的数据获取不仅在带宽方面代价高昂,而且会影响应用程序的感知速度,而且这两个值很容易变得不同步。你本人可能也曾使用过某个 Web 应用程序,其中的项目列表与工具栏中的计数器不同步,导致了令人沮丧且不一致的体验。
你的应用程序的 UI 和网络代码之间也存在紧密耦合。如果 URL 或 JSON 有效载荷的格式发生变化,很可能会以难以追踪的方式破坏所有的 UI 组件。
良好设计的 SOLID 原则告诉我们,对象应该具有单一职责。组件的职责应该是向用户呈现模型数据,而不是获取模型。
好的 Ember 应用程序采用不同的方法。EmberData 为你提供了一个单一的 Store(存储),它是你应用程序中模型的中央仓库。路由及其对应的控制器可以向存储请求模型,而存储则负责了解如何获取它们。
这也意味着存储可以检测到两个不同的组件正在请求相同的模型,从而允许你的应用程序只从服务器获取一次数据。你可以将存储视为应用程序模型的直通缓存。路由及其对应的控制器都可以访问这个共享存储;当它们需要显示或修改模型时,它们会首先向存储请求它。
注入存储
EmberData 提供了一个存储服务,你可以将其注入到路由、组件、服务和其他类中,从而直接访问存储。
为此,导入 service 装饰器 并将 store 属性注入到你的类中。让我们看一个使用路由的示例:
import Route from "@ember/routing/route";
import { service } from "@ember/service";
export default class BlogPostsIndexRoute extends Route {
@service store;
model() {
return this.store.findAll("posts");
}
}
模型
在 EmberData 中,每个模型都由 Model 的子类表示,它定义了你呈现给用户的数据的属性、关系和行为。
模型定义了你的服务器将提供的数据类型。例如,一个 Person 模型可能有一个字符串类型的 name 属性,以及一个日期类型的 birthday 属性:
import Model, { attr } from "@ember-data/model";
export default class PersonModel extends Model {
@attr("string") name;
@attr("date") birthday;
}
模型还描述了它与其他对象的关系。例如,一个 order 可能拥有多个 line-items,而一个 line-item 可能属于一个特定的 order。
import Model, { hasMany } from "@ember-data/model";
export default class OrderModel extends Model {
@hasMany("line-item") lineItems;
}
import Model, { belongsTo } from "@ember-data/model";
export default class LineItemModel extends Model {
@belongsTo("order") order;
}
模型本身不包含任何数据,它们定义了特定实例的属性、关系和行为,这些实例被称为 记录 (records)。
记录
记录 (record) 是模型的一个实例,其中包含从服务器加载的数据。你的应用程序也可以创建新的记录并将其保存回服务器。
记录通过其模型 类型 (type) 和 ID 进行唯一标识。
例如,如果你正在编写一个联系人管理应用程序,你可能有一个 Person 模型。你应用程序中的单个记录的类型可能是 person,ID 可能是 1 或 steve-buscemi。
this.store.findRecord("person", 1); // => { id: 1, name: 'steve-buscemi' }
ID 通常是在你第一次保存记录时由服务器分配的,但你也可以在客户端生成 ID。
适配器 (Adapter)
适配器 (adapter) 是一个对象,它将来自 Ember 的请求(例如“找到 ID 为 1 的用户”)转换为对服务器的请求。
例如,如果你的应用程序请求一个 ID 为 1 的 Person,Ember 应该如何加载它?通过 HTTP 还是 WebSocket?如果是 HTTP,URL 是 /person/1 还是 /resources/people/1?
适配器负责回答所有这些问题。每当你的应用程序向存储请求一个没有缓存的记录时,它都会向适配器请求该记录。如果你修改了一个记录并保存它,存储会将该记录交给适配器,以便将相应的数据发送到你的服务器并确认保存是否成功。
适配器让你能够彻底更改 API 的实现方式,而不会影响你的 Ember 应用程序代码。
缓存 (Caching)
存储会自动为你缓存记录。如果一个记录已经被加载,再次请求它将始终返回同一个对象实例。这最大限度地减少了与服务器的往返次数,并允许你的应用程序尽可能快地向用户渲染 UI。
例如,当你的应用程序第一次向存储请求 ID 为 1 的 person 记录时,它会从服务器获取该信息。
然而,当下一次应用程序再次请求 ID 为 1 的 person 时,存储会注意到它已经检索并缓存了该信息。它不会再发送一个请求来获取相同的信息,而是会将第一次提供给你的应用程序的同一个记录返回给它。这种特性——无论请求多少次都始终返回同一个记录对象——有时被称为标识映射 (identity map)。
使用标识映射很重要,因为它确保了你在 UI 的一部分中所做的更改能够传播到 UI 的其他部分。这也意味着你不必手动保持记录同步——你可以通过 ID 请求记录,而不必担心应用程序的其他部分是否已经请求并加载了它。
返回缓存记录的一个缺点是,你可能会发现自它首次加载到存储的标识映射中以来,数据状态已经发生了变化。为了防止这种陈旧数据长时间成为问题,EmberData 会在每次从存储返回缓存记录时,自动在后台发起一个请求。当新数据到达时,记录会被更新,如果记录自初始渲染以来发生了变化,模板会使用新信息重新渲染。
架构概述
当你的应用程序第一次向存储请求记录时,存储发现本地没有副本,于是向你的适配器发出请求。你的适配器将去从持久化层检索记录;通常情况下,这将是来自 HTTP 服务器的记录的 JSON 表示。

正如上图所示,适配器并不总是能立即返回请求的记录。在这种情况下,适配器必须向服务器发起一个异步 (asynchronous) 请求,只有当该请求加载完成时,才能用其后台数据创建记录。
由于这种异步性,存储会立即从 findRecord() 方法返回一个 promise(承诺)。同样,存储向适配器发出的任何请求也会返回 promise。
一旦对服务器的请求返回了所请求记录的 JSON 有效载荷,适配器就会使用 JSON 来解析它返回给存储的 promise。
然后,存储获取该 JSON,使用 JSON 数据初始化记录,并使用新加载的记录来解析它返回给你的应用程序的 promise。

让我们看看如果你请求一个存储已经在其缓存中的记录会发生什么。

在这种情况下,因为存储已经了解该记录,所以它会返回一个立即用该记录解析的 promise。它不需要向适配器(进而向服务器)请求副本,因为它已经在本地保存了它。
模型、记录、适配器和存储是你应该理解的核心概念,以便充分利用 EmberData。接下来的部分将深入探讨每一个概念,以及如何将它们协同使用。