Firebase Angular Web 框架 Codelab

1. 您将创建的内容

在此 Codelab 中,您将使用 Angular 库 AngularFire 的最新功能构建一个包含实时协作地图的旅游博客。最终的 Web 应用将包含一个旅游博客,您可以在其中为自己去过的每个地点上传图片。

我们将使用 AngularFire 构建 Web 应用,使用 Emulator Suite 进行本地测试,使用 Authentication 跟踪用户数据,使用 Firestore 和 Storage 持久保存数据和媒体,使用 Cloud Functions 提供支持,最后使用 Firebase Hosting 部署应用。

学习内容

  • 如何使用 Emulator Suite 在本地开发 Firebase 产品
  • 如何使用 AngularFire 增强 Web 应用的功能
  • 如何在 Firestore 中持久保留数据
  • 如何在存储空间中持久保留媒体
  • 如何将应用部署到 Firebase Hosting
  • 如何使用 Cloud Functions 与数据库和 API 进行交互

您需要满足的条件

  • Node.js 10 或更高版本
  • 一个用于创建和管理 Firebase 项目的 Google 账号
  • Firebase CLI 版本 11.14.2 或更高版本
  • 您所选的浏览器(例如 Chrome)
  • 对 Angular 和 JavaScript 有基本的了解

2. 获取示例代码

从命令行克隆 Codelab 的 GitHub 代码库

git clone https://github.com/firebase/codelab-friendlychat-web

或者,如果您未安装 git,可以以 ZIP 文件的形式下载代码库

GitHub 代码库包含适用于多个平台的示例项目。

此 Codelab 仅使用 webframework 代码库:

  • 📁 webframework:您将在本 Codelab 中用作基础的起始代码。

安装依赖项

克隆后,请先在根文件夹和 functions 文件夹中安装依赖项,然后再构建 Web 应用。

cd webframework && npm install
cd functions && npm install

安装 Firebase CLI

在终端中使用以下命令安装 Firebase CLI:

npm install -g firebase-tools

使用以下命令仔细检查您的 Firebase CLI 版本是否高于 11.14.2:

firebase  --version

如果您的版本低于 11.14.2,请使用以下命令进行更新:

npm update firebase-tools

3. 创建和设置 Firebase 项目

创建 Firebase 项目

  1. 使用您的 Google 账号登录 Firebase 控制台
  2. 点击相应按钮以创建新项目,然后输入项目名称(例如 FriendlyChat)。
  3. 点击继续
  4. 如果看到相关提示,请查看并接受 Firebase 条款,然后点击继续
  5. (可选)在 Firebase 控制台中启用 AI 辅助功能(称为“Gemini in Firebase”)。
  6. 在此 Codelab 中,您不需要使用 Google Analytics,因此请关闭 Google Analytics 选项。
  7. 点击创建项目,等待项目完成预配,然后点击继续

向项目添加 Firebase Web 应用

  1. 点击 Web 图标以创建新的 Firebase Web 应用。
  2. 在下一步中,您将看到一个配置对象。将此对象的内容复制到 environments/environment.ts 文件中。

设置 Firebase 产品

我们将要构建的应用会使用多个适用于 Web 应用的 Firebase 产品:

  • Firebase Authentication,可让用户轻松登录您的应用。
  • Cloud Firestore:用于在云端保存结构化数据,并在数据发生变化时即时收到通知。
  • Cloud Storage for Firebase,用于将文件保存在云端。
  • Firebase Hosting:用于托管和提供您的资产。
  • 用于与内部和外部 API 互动的函数

其中一些产品需要进行特殊配置,或需要使用 Firebase 控制台启用。

为 Firebase Authentication 启用 Google 登录机制

如需允许用户使用其 Google 账号登录 Web 应用,我们将使用 Google 登录方法。

如需启用 Google 登录,请执行以下操作:

  1. 在 Firebase 控制台中,在左侧面板中找到构建部分。
  2. 点击 Authentication,然后点击登录方法标签页(或点击此处直接转到标签页)。
  3. 启用 Google 登录服务提供方,然后点击保存
  4. 将应用的公开名称设置为 <your-project-name>,然后从下拉菜单中选择项目支持电子邮件地址

启用 Cloud Firestore

  1. 在 Firebase 控制台的构建部分中,点击 Firestore 数据库
  2. 点击 Cloud Firestore 窗格中的创建数据库
  3. 设置 Cloud Firestore 数据的存储位置。您可以保留此默认值,也可以选择您附近的区域。

启用 Cloud Storage

该 Web 应用使用 Cloud Storage for Firebase 来存储、上传和分享图片。

  1. 在 Firebase 控制台的构建部分中,点击存储
  2. 如果没有开始按钮,则表示云存储空间已

已启用,您无需按以下步骤操作。

  1. 点击开始使用
  2. 阅读有关 Firebase 项目安全规则的免责声明,然后点击下一步
  3. 系统会预先选择与您为 Cloud Firestore 数据库选择的区域相同的 Cloud Storage 位置。点击完成以完成设置。

使用默认安全规则时,任何通过身份验证的用户都可以向 Cloud Storage 写入任何内容。我们会在本 Codelab 后面的部分中提升存储的安全性。

4. 关联到 Firebase 项目

借助 Firebase 命令行界面 (CLI),您可以使用 Firebase Hosting 在本地提供 Web 应用,也可以将 Web 应用部署到 Firebase 项目。

确保命令行可以访问应用的本地 webframework 目录。

将 Web 应用代码连接到 Firebase 项目。首先,在命令行中登录 Firebase CLI:

firebase login

接下来,运行以下命令以创建项目别名。将 $YOUR_PROJECT_ID 替换为您的 Firebase 项目 ID。

firebase  use  $YOUR_PROJECT_ID

添加 AngularFire

如需向应用添加 AngularFire,请运行以下命令:

ng add @angular/fire

然后,按照命令行说明操作,并选择 Firebase 项目中存在的功能。

初始化 Firebase

如需初始化 Firebase 项目,请运行以下命令:

firebase init

然后,按照命令行提示,选择您的 Firebase 项目中使用的功能和模拟器。

启动模拟器

webframework 目录运行以下命令以启动模拟器:

firebase  emulators:start

最终,您应该会看到类似如下的内容:

$  firebase  emulators:start

i  emulators:  Starting  emulators:  auth,  functions,  firestore,  hosting,  functions

i  firestore:  Firestore  Emulator  logging  to  firestore-debug.log

i  hosting:  Serving  hosting  files  from:  public

  hosting:  Local  server:  http://localhost:5000

i  ui:  Emulator  UI  logging  to  ui-debug.log

i  functions:  Watching  "/functions"  for  Cloud  Functions...

  functions[updateMap]:  firestore  function  initialized.

  

┌─────────────────────────────────────────────────────────────┐

    All  emulators  ready!  It  is  now  safe  to  connect  your  app.  

  i  View  Emulator  UI  at  http://localhost:4000  

└─────────────────────────────────────────────────────────────┘

  

┌────────────────┬────────────────┬─────────────────────────────────┐

  Emulator    Host:Port    View  in  Emulator  UI  

├────────────────┼────────────────┼─────────────────────────────────┤

  Authentication    localhost:9099    http://localhost:4000/auth  

├────────────────┼────────────────┼─────────────────────────────────┤

  Functions    localhost:5001    http://localhost:4000/functions  

├────────────────┼────────────────┼─────────────────────────────────┤

  Firestore    localhost:8080    http://localhost:4000/firestore  

├────────────────┼────────────────┼─────────────────────────────────┤

  Hosting    localhost:5000    n/a  

└────────────────┴────────────────┴─────────────────────────────────┘

Emulator  Hub  running  at  localhost:4400

Other  reserved  ports:  4500

  

Issues?  Report  them  at  https://github.com/firebase/firebase-tools/issues  and  attach  the  *-debug.log  files.

看到 ✔All emulators ready! 消息后,模拟器即可使用。

您应该会看到旅游应用的界面,但该界面目前还无法正常运行:

现在,让我们开始构建吧!

5. 将 Web 应用连接到模拟器

根据模拟器日志中的表格,Cloud Firestore 模拟器正在侦听端口 8080,而身份验证模拟器正在侦听端口 9099。

打开 EmulatorUI

在网络浏览器中,前往 http://127.0.0.1:4000/。您应该会看到 Emulator Suite 界面。

将应用路由为使用模拟器

src/app/app.module.ts 中,将以下代码添加到 AppModule 的导入列表:

@NgModule({
	declarations: [...],
	imports: [
		provideFirebaseApp(() =>  initializeApp(environment.firebase)),

		provideAuth(() => {
			const  auth = getAuth();
			if (location.hostname === 'localhost') {
				connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings:  true });
			}
			return  auth;
		}),

		provideFirestore(() => {
			const  firestore = getFirestore();
			if (location.hostname === 'localhost') {
				connectFirestoreEmulator(firestore, '127.0.0.1', 8080);
			}
			return  firestore;
		}),

		provideFunctions(() => {
			const  functions = getFunctions();
			if (location.hostname === 'localhost') {
				connectFunctionsEmulator(functions, '127.0.0.1', 5001);
			}
			return  functions;
		}),

		provideStorage(() => {
			const  storage = getStorage();
			if (location.hostname === 'localhost') {
				connectStorageEmulator(storage, '127.0.0.1', 5001);
			}
			return  storage;
		}),
		...
	]

应用现已配置为使用本地模拟器,从而可以在本地进行测试和开发。

6. 添加身份验证

现在,我们已为应用设置模拟器,接下来可以添加身份验证功能,以确保每位用户在发布消息之前都已登录。

为此,我们可以直接从 AngularFire 导入 signin 函数,并使用 authState 函数跟踪用户的身份验证状态。修改登录页面函数,以便页面在加载时检查用户身份验证状态。

注入 AngularFire Auth

src/app/pages/login-page/login-page.component.ts 中,从 @angular/fire/auth 导入 Auth,并将其注入 LoginPageComponent。身份验证提供方(例如 Google)和函数(例如 signinsignout)也可以直接从同一软件包导入,并在应用中使用。

import { Auth, GoogleAuthProvider, signInWithPopup, signOut, user } from  '@angular/fire/auth';

export  class  LoginPageComponent  implements  OnInit {
	private  auth: Auth = inject(Auth);
	private  provider = new  GoogleAuthProvider();
	user$ = user(this.auth);
	constructor() {}  

	ngOnInit(): void {} 

	login() {
		signInWithPopup(this.auth, this.provider).then((result) => {
			const  credential = GoogleAuthProvider.credentialFromResult(result);
			return  credential;
		})
	}

	logout() {
		signOut(this.auth).then(() => {
			console.log('signed out');}).catch((error) => {
				console.log('sign out error: ' + error);
		})
	}
}

现在,登录页面可以正常运行了!尝试登录,并在身份验证模拟器中查看结果。

7. 配置 Firestore

在此步骤中,您将添加用于发布和更新存储在 Firestore 中的旅游博客帖子的功能。

与 Authentication 类似,Firestore 函数预先打包在 AngularFire 中。每个文档都属于一个集合,并且每个文档还可以包含嵌套集合。您必须知道 Firestore 中文档的 path,才能创建和更新旅游博文。

实现 TravelService

由于许多不同的网页都需要在 Web 应用中读取和更新 Firestore 文档,因此我们可以在 src/app/services/travel.service.ts 中实现这些函数,以避免在每个网页中重复注入相同的 AngularFire 函数。

首先,像上一步一样,将 Auth 以及 Firestore 注入到我们的服务中。定义一个监听当前身份验证状态的可观测 user$ 对象也很有用。

import { doc, docData, DocumentReference, Firestore, getDoc, setDoc, updateDoc, collection, addDoc, deleteDoc, collectionData, Timestamp } from  "@angular/fire/firestore";

export  class  TravelService {
	firestore: Firestore = inject(Firestore);
	auth: Auth = inject(Auth);
	user$ = authState(this.auth).pipe(filter(user  =>  user !== null), map(user  =>  user!));
	router: Router = inject(Router);

添加旅游帖子

游记将以文档的形式存储在 Firestore 中,由于文档必须存在于集合中,因此包含所有游记的集合将命名为 travels。因此,任何旅游帖子的路径都将为 travels/

使用 AngularFire 中的 addDoc 函数,可以将对象插入到集合中:

async  addEmptyTravel(userId: String) {
	...
	addDoc(collection(this.firestore, 'travels'), travelData).then((travelRef) => {
		collection(this.firestore, `travels/${travelRef.id}/stops`);
		setDoc(travelRef, {... travelData, id:  travelRef.id})
		this.router.navigate(['edit', `${travelRef.id}`]);
		return  travelRef;

	})
}

更新和删除数据

给定任何旅游帖子的 UID,都可以推断出存储在 Firestore 中的文档的路径,然后可以使用 AngularFire 的 updateFocdeleteDoc 函数读取、更新或删除该文档:

async  updateData(path: string, data: Partial<Travel | Stop>) {
	await  updateDoc(doc(this.firestore, path), data)
}

async  deleteData(path: string) {
	const  ref = doc(this.firestore, path);
	await  deleteDoc(ref)
}

以可观测对象的形式读取数据

由于旅行帖子和沿途停靠站可以在创建后进行修改,因此将文档对象作为可观测对象获取,以便订阅所做的任何更改,会更有用。此功能由 @angular/fire/firestore 中的 docDatacollectionData 函数提供。

getDocData(path: string) {
	return  docData(doc(this.firestore, path), {idField:  'id'}) as  Observable<Travel | Stop>
}

  
getCollectionData(path: string) {
	return  collectionData(collection(this.firestore, path), {idField:  'id'}) as  Observable<Travel[] | Stop[]>
}

为旅游帖子添加经停点

现在,游记后操作已设置完毕,接下来可以考虑添加停靠站了。停靠站将位于游记的子集合下,如下所示:travels//stops/

这与创建旅游帖子几乎完全相同,因此您可以尝试自行实现,也可以查看下面的实现:

async  addStop(travelId: string) {
	...
	const  ref = await  addDoc(collection(this.firestore, `travels/${travelId}/stops`), stopData)
	setDoc(ref, {...stopData, id:  ref.id})
}

好极了!Firestore 函数已在 Travel 服务中实现,现在您可以查看它们的实际效果。

在应用中使用 Firestore 函数

导航到 src/app/pages/my-travels/my-travels.component.ts 并注入 TravelService 以使用其函数。

travelService = inject(TravelService);
travelsData$: Observable<Travel[]>;
stopsList$!: Observable<Stop[]>;
constructor() {
	this.travelsData$ = this.travelService.getCollectionData(`travels`) as  Observable<Travel[]>
}

在构造函数中调用 TravelService 以获取所有行程的 Observable 数组。

如果只需要当前用户的出行信息,请使用 query 函数

确保安全性的其他方法包括实现安全规则,或将 Cloud Functions 与 Firestore 搭配使用(如下面的可选步骤中所述)

然后,只需调用在 TravelService 中实现的函数即可。

async  createTravel(userId: String) {
	this.travelService.addEmptyTravel(userId);
}

deleteTravel(travelId: String) {
	this.travelService.deleteData(`travels/${travelId}`)
}

现在,“我的行程”页面应该可以正常运行了!了解在 Firestore 模拟器中创建新的旅游帖子时会发生什么情况。

然后,针对 /src/app/pages/edit-travels/edit-travels.component.ts 中的更新函数重复上述步骤:

travelService: TravelService = inject(TravelService)
travelId = this.activatedRoute.snapshot.paramMap.get('travelId');
travelData$: Observable<Travel>;
stopsData$: Observable<Stop[]>;

constructor() {
	this.travelData$ = this.travelService.getDocData(`travels/${this.travelId}`) as  Observable<Travel>
	this.stopsData$ = this.travelService.getCollectionData(`travels/${this.travelId}/stops`) as  Observable<Stop[]>
}

updateCurrentTravel(travel: Partial<Travel>) {
	this.travelService.updateData(`travels${this.travelId}`, travel)
}

  

updateCurrentStop(stop: Partial<Stop>) {
	stop.type = stop.type?.toString();
	this.travelService.updateData(`travels${this.travelId}/stops/${stop.id}`, stop)
}

  

addStop() {
	if (!this.travelId) return;
	this.travelService.addStop(this.travelId);
}

deleteStop(stopId: string) {
	if (!this.travelId || !stopId) {
		return;
	}
	this.travelService.deleteData(`travels${this.travelId}/stops/${stopId}`)
	this.stopsData$ = this.travelService.getCollectionData(`travels${this.travelId}/stops`) as  Observable<Stop[]>

}

8. 配置存储空间

您现在将实现存储功能,以存储图片和其他类型的媒体。

Cloud Firestore 最适合用于存储结构化数据,例如 JSON 对象。Cloud Storage 旨在存储文件或 blob。在此应用中,您将使用它来允许用户分享其旅行照片。

同样,对于 Firestore,使用 Storage 存储和更新文件需要为每个文件提供唯一标识符。

我们来在 TraveService 中实现这些函数:

上传文件

前往 src/app/services/travel.service.ts 并从 AngularFire 注入 Storage:

export  class  TravelService {
firestore: Firestore = inject(Firestore);
auth: Auth = inject(Auth);
storage: Storage = inject(Storage);

并实现上传功能:

async  uploadToStorage(path: string, input: HTMLInputElement, contentType: any) {
	if (!input.files) return  null
	const  files: FileList = input.files;
		for (let  i = 0; i  <  files.length; i++) {
			const  file = files.item(i);
			if (file) {
				const  imagePath = `${path}/${file.name}`
				const  storageRef = ref(this.storage, imagePath);
				await  uploadBytesResumable(storageRef, file, contentType);
				return  await  getDownloadURL(storageRef);
			}
		}
	return  null;
}

从 Firestore 访问文档与从 Cloud Storage 访问文件的主要区别在于,虽然两者都遵循文件夹结构化路径,但基本网址和路径组合是通过 getDownloadURL 获取的,然后可以存储在 文件中使用。

在应用中使用该函数

前往 src/app/components/edit-stop/edit-stop.component.ts 并使用以下命令调用上传函数:

	async  uploadFile(file: HTMLInputElement, stop: Partial<Stop>) {
	const  path = `/travels/${this.travelId}/stops/${stop.id}`
	const  url = await  this.travelService.uploadToStorage(path, file, {contentType:  'image/png'});
	stop.image = url ? url : '';
	this.travelService.updateData(path, stop);
}

上传图片时,媒体文件本身会上传到存储空间,并且相应网址会存储在 Firestore 的文档中。

9. 部署应用

现在,我们准备好部署应用了!

firebase 配置从 src/environments/environment.ts 复制到 src/environments/environment.prod.ts,然后运行:

firebase deploy

您应会看到类似下图的界面:

 Browser application bundle generation complete.
 Copying assets complete.
 Index html generation complete.

=== Deploying to 'friendly-travels-b6a4b'...

i  deploying storage, firestore, hosting
i  firebase.storage: checking storage.rules for compilation errors...
  firebase.storage: rules file storage.rules compiled successfully
i  firestore: reading indexes from firestore.indexes.json...
i  cloud.firestore: checking firestore.rules for compilation errors...
  cloud.firestore: rules file firestore.rules compiled successfully
i  storage: latest version of storage.rules already up to date, skipping upload...
i  firestore: deploying indexes...
i  firestore: latest version of firestore.rules already up to date, skipping upload...
  firestore: deployed indexes in firestore.indexes.json successfully for (default) database
i  hosting[friendly-travels-b6a4b]: beginning deploy...
i  hosting[friendly-travels-b6a4b]: found 6 files in .firebase/friendly-travels-b6a4b/hosting
  hosting[friendly-travels-b6a4b]: file upload complete
  storage: released rules storage.rules to firebase.storage
  firestore: released rules firestore.rules to cloud.firestore
i  hosting[friendly-travels-b6a4b]: finalizing version...
  hosting[friendly-travels-b6a4b]: version finalized
i  hosting[friendly-travels-b6a4b]: releasing new version...
  hosting[friendly-travels-b6a4b]: release complete

  Deploy complete!

Project Console: https://console.firebase.google.com/project/friendly-travels-b6a4b/overview
Hosting URL: https://friendly-travels-b6a4b.web.app

10. 恭喜!

现在,您的应用应该已完成并部署到 Firebase Hosting!现在,您可以在 Firebase 控制台中访问所有数据和分析。

如需了解有关 AngularFire、Functions、安全规则的更多功能,请务必查看下方的可选步骤以及其他 Firebase Codelab

11. 可选:AngularFire 身份验证守卫

除了 Firebase Authentication 之外,AngularFire 还提供基于路由的身份验证防护,以便在用户访问权限不足时重定向用户。这有助于保护应用免遭用户访问受保护的数据。

src/app/app-routing.module.ts 中,导入

import {AuthGuard, redirectLoggedInTo, redirectUnauthorizedTo} from  '@angular/fire/auth-guard'

然后,您可以定义函数,以确定在特定网页上何时以及将用户重定向到何处:

const  redirectUnauthorizedToLogin = () =>  redirectUnauthorizedTo(['signin']);
const  redirectLoggedInToTravels = () =>  redirectLoggedInTo(['my-travels']);

然后,只需将它们添加到您的路线中:

const  routes: Routes = [
	{path:  '', component:  LoginPageComponent, canActivate: [AuthGuard], data: {authGuardPipe:  redirectLoggedInToTravels}},
	{path:  'signin', component:  LoginPageComponent, canActivate: [AuthGuard], data: {authGuardPipe:  redirectLoggedInToTravels}},
	{path:  'my-travels', component:  MyTravelsComponent, canActivate: [AuthGuard], data: {authGuardPipe:  redirectUnauthorizedToLogin}},
	{path:  'edit/:travelId', component:  EditTravelsComponent, canActivate: [AuthGuard], data: {authGuardPipe:  redirectUnauthorizedToLogin}},
];

12. 可选:安全规则

Firestore 和 Cloud Storage 都使用安全规则(分别为 firestore.rulessecurity.rules)来强制执行安全措施和验证数据。

目前,Firestore 和 Storage 数据具有开放的读写权限,但您不希望有人随意更改他人的帖子!您可以使用安全规则来限制对集合和文档的访问权限。

Firestore 规则

如需仅允许经过身份验证的用户查看旅游帖子,请前往 firestore.rules 文件并添加:

rules_version  =  '2';
service  cloud.firestore  {
	match  /databases/{database}/travels  {
		allow  read:  if  request.auth.uid  !=  null;
		allow  write:
		if  request.auth.uid  ==  request.resource.data.userId;
	}
}

安全规则还可用于验证数据:

rules_version  =  '2';
service  cloud.firestore  {
	match  /databases/{database}/posts  {
		allow  read:  if  request.auth.uid  !=  null;
		allow  write:
		if  request.auth.uid  ==  request.resource.data.userId;
		&&  "author"  in  request.resource.data
		&&  "text"  in  request.resource.data
		&&  "timestamp"  in  request.resource.data;
	}
}

存储规则

同样,我们可以使用安全规则来强制执行对 storage.rules 中存储数据库的访问权限。请注意,我们还可以使用函数进行更复杂的检查:

rules_version  =  '2';

function  isImageBelowMaxSize(maxSizeMB)  {
	return  request.resource.size  <  maxSizeMB  *  1024  *  1024
		&&  request.resource.contentType.matches('image/.*');
}

 service  firebase.storage  {
	match  /b/{bucket}/o  {
		match  /{userId}/{postId}/{filename}  {
			allow  write:  if  request.auth  !=  null
			&&  request.auth.uid  ==  userId  &&  isImageBelowMaxSize(5);
			allow  read;
		}
	}
}