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 中持久保留数据
  • 如何在 Storage 中持久保留媒体
  • 如何将应用部署到 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 中用作基础的起始代码。

安装依赖项

克隆后,在构建 Web 应用之前,请在根目录和 functions 文件夹中安装依赖项。

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 :用于托管和提供您的资产。
  • Functions :用于与内部和外部 API 交互。

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

为 Firebase Authentication 启用 Google 登录

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

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

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

启用 Cloud Firestore

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

启用 Cloud Storage

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

  1. 在 Firebase 控制台的构建 部分中,点击 Storage
  2. 如果没有开始使用 按钮,则表示 Cloud Storage 已

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

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

借助默认安全规则,任何经过身份验证的用户都可以向 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,而 Authentication 模拟器正在监听端口 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. 添加 Authentication

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

为此,我们可以直接从 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);
		})
	}
}

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

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 以获取所有旅行的可观测数组。

如果只需要当前用户的旅行,请使用 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. 配置 Storage

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

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

同样,使用 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);
}

上传图片后,媒体文件本身将上传到 Storage,并且网址将相应地存储在 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 Codelabs

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 规则

同样,我们可以使用安全规则来强制执行对 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;
		}
	}
}