公告介绍MongoDB 8.0,史上最快的MongoDB!阅读更多 >>介绍MongoDB 8.0,史上最快的MongoDB!>>

如何使用MEAN堆栈:从头开始构建Web应用程序

欢迎来到MEAN堆栈教程!本教程将教您如何使用MEAN堆栈构建全栈Web应用程序。最终项目将是一个员工管理系统。您可以在GitHub上找到本教程的源代码。

但首先,让我们从基础知识开始。

什么是MEAN堆栈?

MEAN是一种用于构建全栈应用程序的技术堆栈。它由以下技术组合而成

  • MongoDB — 一种文档数据库
  • Express — 用于构建API的Node.js框架
  • Angular — 一个前端应用程序框架
  • Node.js — 一种服务器端JavaScript运行环境

使用MEAN堆栈构建的应用程序遵循客户端-服务器架构。客户端,由Angular构建,可以是Web应用程序、原生移动应用程序或桌面应用程序。客户端通过API与服务器通信,该API由Express构建。然后服务器使用MongoDB数据库管理请求。

Client-Server Architecture

客户端-服务器架构

如果您是视觉学习者,请查看本教程的视频版本。

本教程将涵盖哪些内容?

在本教程中,我们将构建一个RESTful API,该API实现了员工管理应用程序的CRUD(创建、读取、更新、删除)操作。对于数据持久性,我们将使用MongoDB Atlas集群。

我们将构建一个员工管理Web应用程序。界面将包含以下页面

  • 查看所有员工
  • 添加新员工
  • 更新现有员工

这是我们的最终应用程序的样貌

gif of angular app demonstration

入门

您需要Node.js和MongoDB Atlas集群才能遵循本教程。

  1. 访问https://node.org.cn/下载和安装Node.js当前版本。本教程使用Node.js版本20.11.1进行测试。为了确保您使用的是正确的Node.js版本,请在您的终端中执行以下命令
 node --version
  1. 遵循MongoDB Atlas入门指南来设置您的免费MongoDB Atlas集群。请确保您完成所有步骤并找到您集群的连接字符串。您稍后连接到数据库时会需要它。

构建后端Node.js和Express应用程序

让我们首先创建一个目录来托管我们的项目和文件。我们将将其命名为mean-stack-example

mkdir mean-stack-example
cd mean-stack-example

在本教程中,我们将使用shell命令来创建目录和文件。但是,您完全可以使用任何其他方法。

现在,让我们创建托管我们服务器端应用程序的目录和文件 — serverserver/src — 并为其初始化一个package.json文件。

mkdir server && mkdir server/src
cd server
npm init -y

touch tsconfig.json .env
(cd src && touch database.ts employee.routes.ts employee.ts server.ts)

安装依赖项

我们需要一些外部包来构建RESTful API和连接到我们的MongoDB Atlas集群。我们将使用npm install命令安装它们。

npm install cors dotenv express mongodb

我们还将为我们的服务器应用程序使用TypeScript。我们将使用带有--save-dev标志的npm install命令安装TypeScript编译器和相应的@types包作为开发依赖项。这些包仅在开发期间使用,不应包含在最终的生产应用程序中。

npm install --save-dev typescript @types/cors @types/express @types/node ts-node

最后,我们将以下内容粘贴到TypeScript编译代码所使用的tsconfig.json配置文件中。

mean-stack-example/server/tsconfig.json

{
  "include": ["src/**/*"],
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,  
    "forceConsistentCasingInFileNames": true,
    "strict": true,    
    "skipLibCheck": true,
    "outDir": "./dist"
  }
}

在服务器端创建员工界面

由于我们正在构建一个员工管理应用程序,主要的数据单元是员工。让我们创建一个Employee接口,该接口将用于定义员工对象的架构。

mean-stack-example/server/src/employee.ts

import * as mongodb from "mongodb";

export interface Employee {
    name: string;
    position: string;
    level: "junior" | "mid" | "senior";
    _id?: mongodb.ObjectId;
}

我们的员工应该有姓名、职位和等级。由于_id字段由MongoDB生成,因此它是可选的。因此,当我们创建一个新的员工时,我们不需要指定它。然而,当我们从数据库获取员工对象时,它将包含_id字段。

连接到数据库

让我们实现以下函数来连接到我们的数据库

mean-stack-example/server/src/database.ts

import * as mongodb from "mongodb";
import { Employee } from "./employee";

export const collections: {
    employees?: mongodb.Collection<Employee>;
} = {};

export async function connectToDatabase(uri: string) {
    const client = new mongodb.MongoClient(uri);
    await client.connect();

    const db = client.db("meanStackExample");
    await applySchemaValidation(db);

    const employeesCollection = db.collection<Employee>("employees");
    collections.employees = employeesCollection;
}

// Update our existing collection with JSON schema validation so we know our documents will always match the shape of our Employee model, even if added elsewhere.
// For more information about schema validation, see this blog series: https://mongodb.ac.cn/blog/post/json-schema-validation--locking-down-your-model-the-smart-way
async function applySchemaValidation(db: mongodb.Db) {
    const jsonSchema = {
        $jsonSchema: {
            bsonType: "object",
            required: ["name", "position", "level"],
            additionalProperties: false,
            properties: {
                _id: {},
                name: {
                    bsonType: "string",
                    description: "'name' is required and is a string",
                },
                position: {
                    bsonType: "string",
                    description: "'position' is required and is a string",
                    minLength: 5
                },
                level: {
                    bsonType: "string",
                    description: "'level' is required and is one of 'junior', 'mid', or 'senior'",
                    enum: ["junior", "mid", "senior"],
                },
            },
        },
    };

    // Try applying the modification to the collection, if the collection doesn't exist, create it
   await db.command({
        collMod: "employees",
        validator: jsonSchema
    }).catch(async (error: mongodb.MongoServerError) => {
        if (error.codeName === "NamespaceNotFound") {
            await db.createCollection("employees", {validator: jsonSchema});
        }
    });
}

我们使用作为mongodb NPM包分发的MongoDB Node.js驱动程序。首先,我们使用提供的连接字符串创建一个新的MongoClient对象。然后,我们连接到数据库。由于connect是一个异步函数,我们需要使用await。之后,我们从client对象中获取db对象。我们使用此对象来获取员工集合。请注意,变量employees被转换为Employee接口。这将为发送到数据库的任何查询提供类型检查。最后,我们将员工集合分配给从该文件导出的collections对象。这样,我们可以从其他文件(如实现RESTful API的employee.routes.ts文件)访问员工集合。

我们还使用JSON模式验证来确保所有文档都遵循我们的Employee接口的形状。这是一种良好的做法,以确保我们不会意外地存储与我们的模型形状不匹配的数据。要了解更多关于JSON模式验证的信息,请参阅JSON Schema Validation - Locking down your model the smart way

我们将持久化数据到MongoDB Atlas集群。要连接到我们的集群,我们需要设置一个包含连接字符串ATLAS_URI环境变量。将其添加到server/.env文件中。确保用您的凭据替换用户名、密码和集群占位符。

mean-stack-example/server/.env

ATLAS_URI=mongodb+srv://<username>:<password>@<your-cluster>.mongodb.net/meanStackExample?retryWrites=true&w=majority

现在,我们可以使用dotenv包加载环境变量。我们可以在任何文件中这样做,但最好尽早进行。由于server.ts文件将是我们的入口点,让我们从它那里加载环境变量,连接到数据库,并启动服务器

mean-stack-example/server/src/server.ts

import * as dotenv from "dotenv";
import express from "express";
import cors from "cors";
import { connectToDatabase } from "./database";

// Load environment variables from the .env file, where the ATLAS_URI is configured
dotenv.config();

const { ATLAS_URI } = process.env;

if (!ATLAS_URI) {
  console.error(
    "No ATLAS_URI environment variable has been defined in config.env"
  );
  process.exit(1);
}

connectToDatabase(ATLAS_URI)
  .then(() => {
    const app = express();
    app.use(cors());

    // start the Express server
    app.listen(5200, () => {
      console.log(`Server running at https://127.0.0.1:5200...`);
    });
  })
  .catch((error) => console.error(error));

让我们运行应用程序并查看一切是否正常工作

npx ts-node src/server.ts

你应该看到以下输出

Server running at https://127.0.0.1:5200...

做得好!现在,我们准备好构建员工的RESTful API了。

构建RESTful API

在本节中,我们将为员工实现GETPOSTPUTDELETE端点。正如你所注意到的,这些HTTP方法对应于我们将对数据库中的员工执行的CRUD操作 - 创建、读取、更新和删除。唯一的限制是我们将有两个GET端点:一个用于获取所有员工,一个用于通过ID获取单个员工。

为了实现端点,我们将使用Express提供的路由器。我们将工作的文件是src/employee.routes.ts

GET /employees

让我们先实现GET /employees端点,该端点允许我们获取数据库中的所有员工。

mean-stack-example/server/src/employee.routes.ts

import * as express from "express";
import { ObjectId } from "mongodb";
import { collections } from "./database";

export const employeeRouter = express.Router();
employeeRouter.use(express.json());

employeeRouter.get("/", async (_req, res) => {
    try {
        const employees = await collections?.employees?.find({}).toArray();
        res.status(200).send(employees);
    } catch (error) {
        res.status(500).send(error instanceof Error ? error.message : "Unknown error");
    }
});

我们正在使用 find() 方法。因为我们传递了一个空对象 {},所以我们会得到数据库中所有的员工。然后,我们将使用 toArray() 方法将游标转换为数组。最后,我们将员工数组发送给客户端。

您可能会注意到路由是 /。这是因为我们将从这个文件中注册所有端点到 /employees 路由下。

GET /employees/:id

接下来,我们将实现 GET /employees/:id 端点,这将允许我们通过 ID 获取单个员工。将以下内容追加到 employee.routes.ts 的底部

mean-stack-example/server/src/employee.routes.ts

employeeRouter.get("/:id", async (req, res) => {
    try {
        const id = req?.params?.id;
        const query = { _id: new ObjectId(id) };
        const employee = await collections?.employees?.findOne(query);

        if (employee) {
            res.status(200).send(employee);
        } else {
            res.status(404).send(`Failed to find an employee: ID ${id}`);
        }
    } catch (error) {
        res.status(404).send(`Failed to find an employee: ID ${req?.params?.id}`);
    }
});

员工的 ID 以参数的形式提供。我们使用 ObjectId 方法将字符串 ID 转换为 MongoDB ObjectId 对象。然后,我们使用 findOne() 方法查找具有给定 ID 的员工。如果找到员工,我们将将其发送给客户端。否则,我们将发送 "404 Not Found" 错误。

如果您想知道上述表达式中的 “?” 符号代表什么,它是一个 可选链操作符。它允许您在不抛出错误的情况下读取嵌套属性。如果属性不存在,表达式将评估为 undefined,而不是抛出错误。

POST /employees

POST /employees 端点将允许我们创建一个新的员工。将以下内容追加到 employee.routes.ts 的底部

mean-stack-example/server/src/employee.routes.ts

employeeRouter.post("/", async (req, res) => {
    try {
        const employee = req.body;
        const result = await collections?.employees?.insertOne(employee);

        if (result?.acknowledged) {
            res.status(201).send(`Created a new employee: ID ${result.insertedId}.`);
        } else {
            res.status(500).send("Failed to create a new employee.");
        }
    } catch (error) {
        console.error(error);
        res.status(400).send(error instanceof Error ? error.message : "Unknown error");
    }
});

我们从客户端请求体中接收员工对象。我们将使用 insertOne() 方法将员工插入到数据库中。如果插入成功,我们将发送一个包含员工 ID 的 "201 Created" 响应。否则,我们将发送一个 "500 Internal Server Error" 响应。

PUT /employees/:id

PUT /employees/:id 端点将允许我们更新现有的员工。将以下内容追加到 employee.routes.ts 的底部

mean-stack-example/server/src/employee.routes.ts

employeeRouter.put("/:id", async (req, res) => {
    try {
        const id = req?.params?.id;
        const employee = req.body;
        const query = { _id: new ObjectId(id) };
        const result = await collections?.employees?.updateOne(query, { $set: employee });

        if (result && result.matchedCount) {
            res.status(200).send(`Updated an employee: ID ${id}.`);
        } else if (!result?.matchedCount) {
            res.status(404).send(`Failed to find an employee: ID ${id}`);
        } else {
            res.status(304).send(`Failed to update an employee: ID ${id}`);
        }
    } catch (error) {
        const message = error instanceof Error ? error.message : "Unknown error";
        console.error(message);
        res.status(400).send(message);
    }
});

在这里,员工的 ID 以参数的形式提供,而员工对象在请求体中提供。我们使用 ObjectId 方法将字符串 ID 转换为 MongoDB ObjectId 对象。然后,我们使用 updateOne() 方法更新具有给定 ID 的员工。如果更新成功,我们将发送一个 "200 OK" 响应。否则,我们将发送一个 "304 Not Modified" 响应。

DELETE /employees/:id

最后,DELETE /employees/:id 端点将允许我们删除现有的员工。将以下内容追加到 employee.routes.ts 的底部

mean-stack-example/server/src/employee.routes.ts

employeeRouter.delete("/:id", async (req, res) => {
    try {
        const id = req?.params?.id;
        const query = { _id: new ObjectId(id) };
        const result = await collections?.employees?.deleteOne(query);

        if (result && result.deletedCount) {
            res.status(202).send(`Removed an employee: ID ${id}`);
        } else if (!result) {
            res.status(400).send(`Failed to remove an employee: ID ${id}`);
        } else if (!result.deletedCount) {
            res.status(404).send(`Failed to find an employee: ID ${id}`);
        }
    } catch (error) {
        const message = error instanceof Error ? error.message : "Unknown error";
        console.error(message);
        res.status(400).send(message);
    }
});

与前面的端点类似,我们根据传递的参数 ID 向数据库发送查询。我们使用 deleteOne() 方法删除员工。如果删除成功,我们将发送一个 "202 Accepted" 响应。否则,我们将发送一个 "400 Bad Request" 响应。如果员工未找到(result.deletedCount 为 0),我们将发送一个 "404 Not Found" 响应。

注册路由

现在,我们需要指导 Express 服务器使用我们定义的路由。首先,在 src/server.ts 的开始处导入 employeesRouter

mean-stack-example/server/src/server.ts

import { employeeRouter } from "./employee.routes";

然后,在 app.listen() 调用之前添加以下内容

mean-stack-example/server/src/server.ts

app.use("/employees", employeeRouter);

最后,通过停止 shell 进程并再次运行它来重启服务器。

npx ts-node src/server.ts

您应该在控制台看到与之前相同的消息。

Server running at https://127.0.0.1:5200...

做得好!我们已经为我们的员工构建了一个简单的 RESTful API。现在,让我们构建一个 Angular 网络应用程序来与之交互!

构建客户端Angular Web应用程序

下一步是构建一个客户端 Angular 网络应用程序,它将与我们的 RESTful API 交互。我们将使用 Angular CLI 来生成应用程序。要安装它,打开一个新的终端标签并运行以下命令

npm install -g @angular/cli

在您正在开发客户端应用程序时,保持服务器运行非常重要。为此,您需要在终端中打开一个新的标签页,在该标签页中执行客户端应用程序的命令。同时,请确保您已导航到 mean-stack-example 目录。

安装完成后,导航到项目的根目录,并运行以下命令以创建一个新的Angular应用程序

ng new client --inline-template --inline-style --minimal --routing --style=css

这将在一个名为 client 的目录中创建一个新的Angular应用程序。 --inline-template--inline-style 标志表示我们将直接在组件文件中包含HTML模板和CSS样式,而不是将它们拆分为单独的文件。 --minimal 标志将跳过任何测试配置,因为我们不会在本教程中涉及此内容。 --routing 标志将生成一个路由模块。 --style=css 标志将启用CSS预处理器。

安装依赖项可能需要一些时间。安装完成后,导航到新应用程序并运行以下命令以启动它

cd client
ng serve -o

应用程序构建完成后,您应该在浏览器窗口中看到一个新标签页,其中运行着应用程序。它应该显示 "欢迎来到客户端!"

最后,对于样式,我们将使用 Angular Material。要从 client 目录中的新终端运行以下命令来安装它。在提示时,选择一个颜色主题以及默认选项

ng add @angular/material

在客户端创建员工界面

类似于我们的服务器端应用程序,我们将为我们的员工创建一个Angular界面。我们将使用 Employee 接口来定义员工对象的属性。打开一个新的终端窗口并运行以下命令来创建接口

ng generate interface employee

然后,在您的编辑器中打开新创建的 src/app/employee.ts 文件,并添加以下属性

mean-stack-example/client/src/app/employee.ts

export interface Employee {
  name: string;
  position: string;
  level: 'junior' | 'mid' | 'senior';
  _id?: string;
}

创建员工服务

Angular建议将您的业务逻辑与您的展示逻辑分离。这就是为什么我们将创建一个 服务 来处理与API的 /employee 端点的所有通信。该服务将被应用程序中的组件使用。要生成服务,请运行以下命令

ng generate service employee

ng generate service 命令在 src/app/employee.service.ts 文件中创建一个新的服务。将此文件的内容替换为以下内容

mean-stack-example/client/src/app/employee.service.ts

import { Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Employee } from './employee';

@Injectable({
  providedIn: 'root'
})
export class EmployeeService {
  private url = 'https://127.0.0.1:5200';
  employees$ = signal<Employee[]>([]);
  employee$ = signal<Employee>({} as Employee);
 
  constructor(private httpClient: HttpClient) { }

  private refreshEmployees() {
    this.httpClient.get<Employee[]>(`${this.url}/employees`)
      .subscribe(employees => {
        this.employees$.set(employees);
      });
  }

  getEmployees() {
    this.refreshEmployees();
    return this.employees$();
  }

  getEmployee(id: string) {
    this.httpClient.get<Employee>(`${this.url}/employees/${id}`).subscribe(employee => {
      this.employee$.set(employee);
      return this.employee$();
    });
  }

  createEmployee(employee: Employee) {
    return this.httpClient.post(`${this.url}/employees`, employee, { responseType: 'text' });
  }

  updateEmployee(id: string, employee: Employee) {
    return this.httpClient.put(`${this.url}/employees/${id}`, employee, { responseType: 'text' });
  }

  deleteEmployee(id: string) {
    return this.httpClient.delete(`${this.url}/employees/${id}`, { responseType: 'text' });
  }
}

我们使用 HttpClient 服务来向我们的API发出HTTP请求。 refreshEmployees() 方法用于获取完整的员工列表,并将其保存到名为 employees$ 的信号中,该信号将可供应用程序的其余部分访问。

HttpClient 服务由Angular通过 provideHttpClient 函数提供。它默认不是应用程序的一部分——我们需要将其导入到 app.config.ts 文件中。首先,将以下导入添加到 app.config.ts 文件的顶部

mean-stack-example/client/src/app/app.config.ts

import { provideHttpClient, withFetch } from '@angular/common/http';

然后,将函数添加到 ApplicationConfig 变量的提供者列表中

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withFetch()),
    // ...
  ],
};

withFetch 函数用于配置 HttpClient 服务以使用 fetch API。

接下来,我们将创建一个新页面,显示我们的员工表格。

创建员工列表组件

让我们为员工表格创建一个新页面。在Angular中,组件是一个可重用的代码块,可以用来显示视图。我们将创建一个名为 EmployeesList 的新组件,并将其注册为应用程序中的 /employees 路由。

要生成组件,请运行以下命令

ng generate component employees-list

Angular CLI 在 src/app/employees-list.component.ts 文件中生成了一个名为 EmployeesListComponent 的新组件。将 src/app/employees-list.component.ts 文件的内容替换为以下内容

mean-stack-example/client/src/app/employees-list/employees-list.component.ts

import { Component, OnInit, WritableSignal } from '@angular/core';
import { Employee } from '../employee';
import { EmployeeService } from '../employee.service';
import { RouterModule } from '@angular/router';
import { MatTableModule } from '@angular/material/table';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';

@Component({
  selector: 'app-employees-list',
  standalone: true,
  imports: [RouterModule, MatTableModule, MatButtonModule, MatCardModule],
  styles: [
    `
      table {
        width: 100%;

        button:first-of-type {
          margin-right: 1rem;
        }
      }
    `,
  ],
  template: `
    <mat-card>
      <mat-card-header>
        <mat-card-title>Employees List</mat-card-title>
      </mat-card-header>
      <mat-card-content>
        <table mat-table [dataSource]="employees$()">
          <ng-container matColumnDef="col-name">
            <th mat-header-cell *matHeaderCellDef>Name</th>
            <td mat-cell *matCellDef="let element">{{ element.name }}</td>
          </ng-container>
          <ng-container matColumnDef="col-position">
            <th mat-header-cell *matHeaderCellDef>Position</th>
            <td mat-cell *matCellDef="let element">{{ element.position }}</td>
          </ng-container>
          <ng-container matColumnDef="col-level">
            <th mat-header-cell *matHeaderCellDef>Level</th>
            <td mat-cell *matCellDef="let element">{{ element.level }}</td>
          </ng-container>
          <ng-container matColumnDef="col-action">
            <th mat-header-cell *matHeaderCellDef>Action</th>
            <td mat-cell *matCellDef="let element">
              <button mat-raised-button [routerLink]="['edit/', element._id]">
                Edit
              </button>
              <button
                mat-raised-button
                color="warn"
                (click)="deleteEmployee(element._id || '')"
              >
                Delete
              </button>
            </td>
          </ng-container>

          <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
          <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
        </table>
      </mat-card-content>
      <mat-card-actions>
        <button mat-raised-button color="primary" [routerLink]="['new']">
          Add a New Employee
        </button>
      </mat-card-actions>
    </mat-card>
  `,
})
export class EmployeesListComponent implements OnInit {
  employees$ = {} as WritableSignal<Employee[]>;
  displayedColumns: string[] = [
    'col-name',
    'col-position',
    'col-level',
    'col-action',
  ];

  constructor(private employeesService: EmployeeService) {}

  ngOnInit() {
    this.fetchEmployees();
  }

  deleteEmployee(id: string): void {
    this.employeesService.deleteEmployee(id).subscribe({
      next: () => this.fetchEmployees(),
    });
  }

  private fetchEmployees(): void {
    this.employees$ = this.employeesService.employees$;
    this.employeesService.getEmployees();
  }
}

如您所注意到的,EmployeesListComponent 是一个带有 @Component 装饰器的 TypeScript 类。@Component 装饰器用于指示这个类是一个组件。selector 属性用于指定用于显示此组件的 HTML 标签。剧透:我们不会使用此选择器。相反,我们将组件注册为一个路由。template 属性用于指定用于显示此组件的 HTML 模板。

类的实现包含了组件的逻辑。当组件在页面上渲染时,会调用 ngOnInit() 方法。这是一个获取员工列表的好地方。为此,我们使用我们之前创建的 EmployeeServicegetEmployees() 方法返回包含所有员工的信号。一旦数据可用,它将自动渲染员工列表。我们使用 Angular Material 来渲染显示员工的表格。

模板中还有一些操作——用于编辑、删除和添加新员工。使用 [routerLink] 属性导航到 /employees/edit/:id 路由,我们将在教程的后面实现。使用 (click) 事件调用 deleteEmployee() 方法。此方法在类中实现,使用 EmployeeService 删除员工。

现在我们已经创建了组件,我们需要将其注册为 app.routes.ts 文件中的一个路由。将文件的内容替换为以下内容

mean-stack-example/client/src/app/app.routes.ts

import { Routes } from '@angular/router';
import { EmployeesListComponent } from './employees-list/employees-list.component';

export const routes: Routes = [
  { path: '', component: EmployeesListComponent, title: 'Employees List' },
];

然后,转到 src/app/app.component.ts 文件,并将内容替换为以下内容

mean-stack-example/client/src/app/app.component.ts

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { EmployeesListComponent } from './employees-list/employees-list.component';
import { MatToolbarModule } from '@angular/material/toolbar';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, EmployeesListComponent, MatToolbarModule],
  styles: [
    `
      main {
        display: flex;
        justify-content: center;
        padding: 2rem 4rem;
      }
    `,
  ],
  template: `
    <mat-toolbar>
      <span>Employees Management System</span>
    </mat-toolbar>
    <main>
      <router-outlet />
    </main>
  `,
})
export class AppComponent {
  title = 'client';
}

让我们刷新浏览器,看看是否一切正常。

员工列表表格 我们有一个表格,但还没有看到任何员工。让我们创建一个新页面来添加员工。

创建添加员工的页面

我们需要一个表单来填写名称、职位和等级,以创建新员工。对于编辑现有员工,我们还需要一个类似的表单。让我们创建一个 EmployeeForm 组件,并用于添加和编辑。

ng g c employee-form

这里的 ggenerate 的缩写,ccomponent 的缩写。

现在,我们可以使用 Angular 的 ReactiveFormsModuleFormBuilder 来创建一个响应式表单

mean-stack-example/client/src/app/employee-form/employee-form.component.ts

import { Component, effect, EventEmitter, input, Output } from '@angular/core';
import { FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatRadioModule } from '@angular/material/radio';
import { MatButtonModule } from '@angular/material/button';
import { Employee } from '../employee';

@Component({
  selector: 'app-employee-form',
  standalone: true,
  imports: [
    ReactiveFormsModule,
    MatFormFieldModule,
    MatInputModule,
    MatRadioModule,
    MatButtonModule,
  ],
  styles: `
    .employee-form {
      display: flex;
      flex-direction: column;
      align-items: flex-start;
      padding: 2rem;
    }
    .mat-mdc-radio-button ~ .mat-mdc-radio-button {
      margin-left: 16px;
    }
    .mat-mdc-form-field {
      width: 100%;
    }
  `,
  template: `
    <form
      class="employee-form"
      autocomplete="off"
      [formGroup]="employeeForm"
      (submit)="submitForm()"
    >
      <mat-form-field>
        <mat-label>Name</mat-label>
        <input matInput placeholder="Name" formControlName="name" required />
        @if (name.invalid) {
        <mat-error>Name must be at least 3 characters long.</mat-error>
        }
      </mat-form-field>

      <mat-form-field>
        <mat-label>Position</mat-label>
        <input
          matInput
          placeholder="Position"
          formControlName="position"
          required
        />
        @if (position.invalid) {
        <mat-error>Position must be at least 5 characters long.</mat-error>
        }
      </mat-form-field>

      <mat-radio-group formControlName="level" aria-label="Select an option">
        <mat-radio-button name="level" value="junior" required
          >Junior</mat-radio-button
        >
        <mat-radio-button name="level" value="mid"
          >Mid</mat-radio-button
        >
        <mat-radio-button name="level" value="senior"
          >Senior</mat-radio-button
        >
      </mat-radio-group>
      <br />
      <button
        mat-raised-button
        color="primary"
        type="submit"
        [disabled]="employeeForm.invalid"
      >
        Add
      </button>
    </form>
  `,
})
export class EmployeeFormComponent {
  initialState = input<Employee>();

  @Output()
  formValuesChanged = new EventEmitter<Employee>();

  @Output()
  formSubmitted = new EventEmitter<Employee>();

  employeeForm = this.formBuilder.group({
    name: ['', [Validators.required, Validators.minLength(3)]],
    position: ['', [Validators.required, Validators.minLength(5)]],
    level: ['junior', [Validators.required]],
  });

  constructor(private formBuilder: FormBuilder) {
    effect(() => {
      this.employeeForm.setValue({
        name: this.initialState()?.name || '',
        position: this.initialState()?.position || '',
        level: this.initialState()?.level || 'junior',
      });
    });
  }

  get name() {
    return this.employeeForm.get('name')!;
  }
  get position() {
    return this.employeeForm.get('position')!;
  }
  get level() {
    return this.employeeForm.get('level')!;
  }

  submitForm() {
    this.formSubmitted.emit(this.employeeForm.value as Employee);
  }
}

这里有很多代码,但没有什么特别之处。我们只是使用 FormBuilder 创建了一个包含三个字段的响应式表单,并为表单添加了验证。如果表单有验证错误,模板将显示错误消息。我们使用 input() 将父组件的初始表单状态传递给表单。由于我们可能将异步数据传递到表单,因此 input() 的类型为 InputSignal<Employee>

例如,父组件可能从 API 获取员工数据并将其传递给表单。子组件将在新数据可用时收到通知。@Output() 是一个事件发射器,将在表单提交时发出表单值。父组件将处理提交并发送 API 调用。

下一步是实现 AddEmployeeComponent 组件。

ng generate component add-employee

将新创建的文件内容替换为以下内容:

mean-stack-example/client/src/app/add-employee/add-employee.component.ts

import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { EmployeeFormComponent } from '../employee-form/employee-form.component';
import { Employee } from '../employee';
import { EmployeeService } from '../employee.service';
import { MatCardModule } from '@angular/material/card';

@Component({
  selector: 'app-add-employee',
  standalone: true,
  imports: [EmployeeFormComponent, MatCardModule],
  template: `
    <mat-card>
      <mat-card-header>
        <mat-card-title>Add a New Employee</mat-card-title>
      </mat-card-header>
      <mat-card-content>
        <app-employee-form
          (formSubmitted)="addEmployee($event)"
        ></app-employee-form>
      </mat-card-content>
    </mat-card>
  `,
  styles: ``,
})
export class AddEmployeeComponent {
  constructor(
    private router: Router,
    private employeeService: EmployeeService
  ) {}

  addEmployee(employee: Employee) {
    this.employeeService.createEmployee(employee).subscribe({
      next: () => {
        this.router.navigate(['/']);
      },
      error: (error) => {
        alert('Failed to create employee');
        console.error(error);
      },
    });
    this.employeeService.getEmployees();
  }
}

我们使用 EmployeeFormComponent,每当 AddEmployeeComponent 收到表单提交时,它将调用 EmployeeService 来创建员工。当员工创建成功时,EmployeeService 将发出事件,并且 AddEmployeeComponent 将导航回员工列表页面。

同时,让我们实现编辑员工组件

ng generate component edit-employee

将新创建的文件内容替换为以下内容:

mean-stack-example/client/src/app/edit-employee/edit-employee.component.ts

import { Component, OnInit, WritableSignal } from '@angular/core';
import { EmployeeFormComponent } from '../employee-form/employee-form.component';
import { ActivatedRoute, Router } from '@angular/router';
import { Employee } from '../employee';
import { EmployeeService } from '../employee.service';
import { MatCardModule } from '@angular/material/card';

@Component({
  selector: 'app-edit-employee',
  standalone: true,
  imports: [EmployeeFormComponent, MatCardModule],
  template: `
    <mat-card>
      <mat-card-header>
        <mat-card-title>Edit an Employee</mat-card-title>
      </mat-card-header>
      <mat-card-content>
        <app-employee-form
          [initialState]="employee()"
          (formSubmitted)="editEmployee($event)"
        ></app-employee-form>
      </mat-card-content>
    </mat-card>
  `,
  styles: ``,
})
export class EditEmployeeComponent implements OnInit {
  employee = {} as WritableSignal<Employee>;

  constructor(
    private router: Router,
    private route: ActivatedRoute,
    private employeeService: EmployeeService
  ) {}

  ngOnInit() {
    const id = this.route.snapshot.paramMap.get('id');
    if (!id) {
      alert('No id provided');
    }

    this.employeeService.getEmployee(id!);
    this.employee = this.employeeService.employee$;
  }

  editEmployee(employee: Employee) {
    this.employeeService
      .updateEmployee(this.employee()._id || '', employee)
      .subscribe({
        next: () => {
          this.router.navigate(['/']);
        },
        error: (error) => {
          alert('Failed to update employee');
          console.error(error);
        },
      });
  }
}

AddEmployeeComponent 的唯一显著区别是我们从 URL 中获取员工 ID,从 API 中获取员工,然后在 ngOnInit() 方法中将它传递给表单。

最后,让我们添加新页面的导航

mean-stack-example/client/src/app/app-routing.module.ts

import { Routes } from '@angular/router';
import { EmployeesListComponent } from './employees-list/employees-list.component';
import { AddEmployeeComponent } from './add-employee/add-employee.component'; // <-- add this line
import { EditEmployeeComponent } from './edit-employee/edit-employee.component'; // <-- add this line

export const routes: Routes = [
  { path: '', component: EmployeesListComponent, title: 'Employees List' },
  { path: 'new', component: AddEmployeeComponent }, // <-- add this line
  { path: 'edit/:id', component: EditEmployeeComponent }, // <-- add this line
];

好的!让我们来测试一下!尝试创建一个新的员工。填写详细信息后,点击 提交 按钮。你应该能在列表中看到新员工。然后,你可以通过点击 编辑 按钮来尝试编辑它。你应该能看到填写了员工详细信息的表单。提交表单后,员工信息将在表中更新。最后,你可以通过点击 删除 按钮来删除它。


Angular 应用演示的 gif 如果有什么不对劲的地方,你可以在 mean-stack-example 仓库中查看完成的项目。

结论

感谢您与我一同经历这次旅程并跟随我的步伐!希望您喜欢它并学到了很多。MEAN 栈使得开始构建可扩展的应用变得容易。在整个技术栈中,您都在使用相同的语言:JavaScript。此外,MongoDB 的文档模型使得将数据映射到对象变得很自然,MongoDB Atlas 的永久免费集群使得无需担心成本即可轻松托管您的数据。